feat(3g-1): les bornes entrent dans le waterfall
La transplantation. Les bornes partagent le budget, décrémentent la cascade et portent un rang qui a enfin un sens — il était décoratif depuis le début. Le défaut corrigé n'était pas théorique : planSurplusCharging() calculait l'allocation une fois avant la boucle et ne la décrémentait jamais. Chaque borne recevait la totalité du surplus. Avec 4 kW et deux bornes, ce n'était pas « 2 kW à chacune » mais « 4 kW à chacune », et le rattrapage venait du compteur au cycle suivant, pas de l'arbitrage. Trois motifs EV disparaissent — des suppressions, pas des remplacements. EV_ECO_MIN en particulier : le courant minimum était la même idée que le plancher du §12, écrite une seconde fois pour un seul mécanisme. Le nombre de phases est décidé AVANT l'allocation et réutilisé à l'exécution, parce que c'est lui qui fixe le plancher — 6 A valent 1,4 kW en mono et 4,1 kW en tri. C'était le piège le plus silencieux du lot. evReservedW est restreint au financement réseau plutôt que supprimé : ce qui reste décidé en amont doit rester réservé, ce qui passe par la cascade ne le doit plus. Et une correction attrapée à la première observation en vrai de BATTERY_RESERVE : il était publié avec withheldW à 0 sur un surplus négatif, donc il disait « une règle vous bloque » là où il n'y avait simplement rien. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
This commit is contained in:
parent
e634ed99de
commit
1478d8f11c
28
INTERFACE.md
28
INTERFACE.md
@ -458,11 +458,25 @@ le cas normal.
|
||||
- `budget` **absent** = mode dégradé L2 : la planification est **suspendue**, il n'existe aucun
|
||||
budget pour ce cycle. Publier des zéros se lirait « arbitrage exécuté, rien à allouer ».
|
||||
|
||||
**Charges présentes.** Uniquement les charges **arbitrées par le waterfall** : `relay-router`,
|
||||
`etmvariableload`, `sg-ready`. Le VE n'y figure pas en beta — il est décidé par l'amont **avant**
|
||||
le waterfall (décision B, `AGENTS.md`), et sa puissance est réservée dans `budget.evReservedW`,
|
||||
pas allouée depuis le budget publié. L'y ajouter ferait une somme `loads[]` qui ne retomberait
|
||||
pas sur `budget.allocatedW`. Il rejoindra la liste avec l'étape **3g**.
|
||||
**Charges présentes.** Toutes les charges **arbitrées par le waterfall** : `relay-router`,
|
||||
`etmvariableload`, `sg-ready` — et, **depuis 3g-1, les bornes de recharge**.
|
||||
|
||||
> **Changement de contrat en 3g-1.** Le VE ne figurait pas dans `loads[]` : il était décidé en
|
||||
> amont, et sa puissance seulement *réservée* dans `budget.evReservedW`. Il y figure désormais,
|
||||
> son allocation décrémente la cascade comme celle de n'importe quelle charge, et la somme des
|
||||
> `allocatedW` **retombe** sur `budget.allocatedW` — ce qui n'était pas vrai avant.
|
||||
>
|
||||
> `budget.evReservedW` **subsiste**, mais restreint : il ne compte plus que les bornes servies
|
||||
> par un financement **réseau** (échéance de départ, spot market), qui restent décidées en
|
||||
> amont. Une borne servie par la cascade y serait comptée deux fois.
|
||||
>
|
||||
> **Deux bornes ne se partageaient pas le budget avant 3g-1** : l'allocation était calculée une
|
||||
> fois et offerte entière à chacune. Ce n'est plus le cas — la première est servie, la seconde
|
||||
> reçoit le reliquat, et 0 si ce reliquat est sous son plancher.
|
||||
|
||||
**Bornes exclues de `loads[]`** : celles sans voiture assignée et celles en mode manuel
|
||||
(`ChargingModeNormal`). Elles sont **hors arbitrage**, pas servies en dernier — leur
|
||||
consommation est déjà au compteur racine, donc déjà dans le budget.
|
||||
|
||||
#### `loads[]` ⊆ `GetLoadConfig` — toute charge publiée est configurable
|
||||
|
||||
@ -512,7 +526,9 @@ le **libellé de la charge n'y figure jamais** (le client le résout via `GetLoa
|
||||
| `EV_SPOT_MARKET` | — | VE rechargé sur créneau tarifaire favorable |
|
||||
| `EV_DEADLINE` | — | VE prioritaire, échéance approchante |
|
||||
| `EV_ECO_MIN` | — | Pas de surplus, courant minimum maintenu (mode EcoMin) |
|
||||
| `EV_IDLE` | — | Pas de surplus, recharge suspendue |
|
||||
| ~~`EV_IDLE`~~ | — | **Retiré en 3g-1** — remplacé par `BELOW_MIN_POWER` / `SURPLUS_INSUFFICIENT`, qui disent *pourquoi* la borne ne charge pas |
|
||||
| ~~`EV_SURPLUS`~~ | — | **Retiré en 3g-1** — la recharge sur surplus produit désormais `SURPLUS_SETPOINT`, comme toute autre charge |
|
||||
| ~~`EV_ECO_MIN`~~ | — | **Retiré en 3g-1** — le courant minimum est devenu le **plancher** du §12, exprimé une seule fois pour tous les mécanismes |
|
||||
| `LOAD_UNAVAILABLE` | `frozenW` | Charge en défaut : aucune commande émise, `frozenW` reste compté au budget. Levée par `ClearLoadFault` |
|
||||
| `LOCK_MIN_ON` | `appliedW`, `budgetW` | Le verrou `minOn` a **relevé** la consigne : puissance engagée non coupable |
|
||||
| `LOCK_MIN_OFF` | `budgetW`, `requestedW` | Le verrou `minOff` interdit tout enclenchement — le budget, lui, payait `requestedW` |
|
||||
|
||||
@ -1,3 +1,45 @@
|
||||
powersync-energy-plugin-nymea (1.15.2+etm21) trixie; urgency=medium
|
||||
|
||||
* 3g-1 — LA TRANSPLANTATION. Les bornes de recharge entrent dans le waterfall : elles
|
||||
partagent le budget avec l'ECS et la PAC, décrémentent la cascade, et portent enfin un
|
||||
rang qui a un sens. Il était décoratif depuis le début.
|
||||
* Le défaut que cela corrige n'était pas théorique. planSurplusCharging() calculait
|
||||
l'allocation UNE fois, avant la boucle, et ne la décrémentait jamais : chaque borne
|
||||
recevait la TOTALITÉ du surplus, comme si elle était seule. Avec 4 kW et deux bornes, ce
|
||||
n'était pas « 2 kW à chacune » mais « 4 kW à chacune » — et ce qui rattrapait n'était pas
|
||||
l'arbitrage mais le compteur au cycle suivant, puis la protection de surcharge. Le système
|
||||
convergeait par correction d'erreur, pas par décision.
|
||||
* Trois motifs EV disparaissent, et ce sont des suppressions, pas des remplacements :
|
||||
EV_SURPLUS devient SURPLUS_SETPOINT, EV_ECO_MIN devient le PLANCHER du §12 — la même idée
|
||||
exprimée une seule fois pour tous les mécanismes — et EV_IDLE cède à BELOW_MIN_POWER /
|
||||
SURPLUS_INSUFFICIENT, qui DISENT pourquoi la borne ne charge pas.
|
||||
* Le nombre de phases est décidé AVANT l'allocation et réutilisé à l'exécution, parce que
|
||||
c'est lui qui fixe le plancher : 6 A valent 1,4 kW en monophasé et 4,1 kW en triphasé.
|
||||
Publier le plancher du nombre COURANT alors que l'exécution bascule vers un autre
|
||||
produirait un défaut silencieux — le waterfall allouerait 2 kW en croyant démarrer une
|
||||
borne qui en exige 4,1, et se croirait servi.
|
||||
* La conversion watts → ampères descend dans l'adaptateur (règle 4 : l'adaptateur écrête,
|
||||
il ne décide pas). Deux origines coexistent : le proxy remplit currentA, la cascade
|
||||
remplit powerW. Le discriminant est currentA.
|
||||
* evReservedW (correction A) est RESTREINT au financement réseau au lieu d'être supprimé :
|
||||
une borne servie par la cascade décrémente déjà le budget par son allocation, la réserver
|
||||
en plus la compterait deux fois. Échéance et spot market restent décidés en amont, donc
|
||||
restent réservés.
|
||||
* Restent au proxy, délibérément : l'échéance de départ (sa machinerie vient du SoC de la
|
||||
voiture et n'a aucun équivalent ailleurs ; et la tenir revient à SOUTIRER, donc à franchir
|
||||
la frontière du §10), le spot market, la protection de surcharge, l'estimation de SoC.
|
||||
Le proxy maigrit, il ne disparaît pas — c'est une étape assumée, pas un provisoire.
|
||||
* Bornes sans voiture assignée ou en mode manuel : HORS arbitrage, pas rang 0. Leur
|
||||
consommation est déjà au compteur racine, donc déjà dans le budget.
|
||||
* ⚠️ CHANGEMENT DE CONTRAT — loads[] de GetLoadTelemetry contient désormais les bornes, et la
|
||||
somme des allocations retombe sur budget.allocatedW, ce qui n'était pas vrai avant.
|
||||
* Correction du motif BATTERY_RESERVE, attrapée à sa PREMIÈRE observation en vrai : il était
|
||||
publié même quand rien n'avait été retenu (surplus négatif), disant « une règle vous
|
||||
bloque » là où la vérité était « il n'y a pas de surplus » — exactement la confusion qu'il
|
||||
existe pour supprimer, retournée. Il est désormais conditionné à withheldW > 0.
|
||||
|
||||
-- Patrick Schurig <etm.schurig@gmail.com> Wed, 26 Aug 2026 18:00:00 +0200
|
||||
|
||||
powersync-energy-plugin-nymea (1.15.2+etm20) trixie; urgency=medium
|
||||
|
||||
* 3g-1, première moitié — la prise en compte du stockage monte dans le budget. Elle vivait
|
||||
|
||||
@ -45,17 +45,43 @@ LoadTelemetry EvAdapter::telemetry() const
|
||||
|
||||
LoadContext EvAdapter::toLoadContext(const QDateTime &now) const
|
||||
{
|
||||
Q_UNUSED(now) // L'EV n'a pas de verrou de palier (pas de waterfall ECS) — now inutilisé ici.
|
||||
Q_UNUSED(now) // Les verrous EV (enclenchement, changement de courant) vivent en aval.
|
||||
LoadContext ctx;
|
||||
ctx.id = m_charger->thing()->id().toString();
|
||||
ctx.adapter = QStringLiteral("evcharger");
|
||||
ctx.label = m_charger->name();
|
||||
ctx.declared = descriptor().declared;
|
||||
ctx.limits = descriptor().limits;
|
||||
ctx.priority = descriptor().priority;
|
||||
|
||||
// --- 3g-1 : la borne entre dans le waterfall, donc elle parle en WATTS ---------------
|
||||
//
|
||||
// Le nombre de phases VISÉ est décidé ici, avant l'allocation, et non après : c'est lui
|
||||
// qui fixe le plancher. Publier celui du nombre COURANT alors que l'exécution basculera
|
||||
// vers un autre produirait un défaut silencieux — le waterfall allouerait 2 kW en croyant
|
||||
// démarrer une borne qui en exige 4,1, et se croirait servi.
|
||||
//
|
||||
// Le courant de référence pour ce choix est ce que la borne tire DÉJÀ : c'est la seule
|
||||
// grandeur connue avant l'allocation, et c'est celle que le proxy utilisait aussi.
|
||||
const double courantActuelA = m_charger->currentPower() / 230.0;
|
||||
m_targetPhases = m_charger->canSetPhaseCount()
|
||||
? m_parent->evTargetPhases(m_charger, courantActuelA)
|
||||
: m_charger->phaseCount();
|
||||
if (m_targetPhases == 0)
|
||||
m_targetPhases = 1;
|
||||
|
||||
ctx.declared.minPowerW = m_parent->evFloorW(m_charger, m_targetPhases);
|
||||
ctx.declared.maxPowerW = m_charger->maxChargingCurrentMaxValue() * 230.0 * m_targetPhases;
|
||||
ctx.declared.phases = static_cast<int>(m_targetPhases);
|
||||
|
||||
ctx.telemetry.currentPowerW = m_charger->currentPower();
|
||||
ctx.telemetry.available = m_charger->available();
|
||||
ctx.telemetry.pluggedIn = m_charger->pluggedIn();
|
||||
ctx.telemetry.charging = m_charger->charging();
|
||||
|
||||
// Pas de fenêtre de verrou de palier côté EV : lockMaxPowerW négatif = aucun plafond
|
||||
// (convention du contexte), et lockMinPowerW nul = rien d'engagé de non-coupable.
|
||||
ctx.telemetry.lockMaxPowerW = -1;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
@ -87,23 +113,47 @@ LoadAction EvAdapter::applyAction(const LoadAction &action, const QDateTime &now
|
||||
|
||||
const uint minA = m_charger->maxChargingCurrentMinValue();
|
||||
const uint maxA = m_charger->maxChargingCurrentMaxValue();
|
||||
const uint clampedA = static_cast<uint>(
|
||||
qBound(static_cast<double>(minA), action.currentA, static_cast<double>(maxA)));
|
||||
|
||||
// --- 3g-1 : deux origines d'action, deux unités ---------------------------------------
|
||||
//
|
||||
// Le PROXY (échéance, spot market) décide encore en ampères et remplit currentA. Le
|
||||
// WATERFALL, lui, alloue des WATTS : c'est ici, au dernier moment, que la conversion se
|
||||
// fait — conformément à la règle 4 (les bornes de l'adaptateur écrêtent toute sortie de
|
||||
// stratégie, et l'adaptateur seul connaît son matériel).
|
||||
//
|
||||
// Le discriminant est `currentA` : non nul, l'action vient du proxy et s'exécute telle
|
||||
// quelle ; nul avec une puissance, elle vient de la cascade et se convertit.
|
||||
const uint phases = (m_charger->canSetPhaseCount() && action.phaseCount > 0)
|
||||
? qBound(1u, action.phaseCount, m_charger->phaseCount())
|
||||
: m_charger->phaseCount();
|
||||
: (m_targetPhases > 0 ? m_targetPhases : m_charger->phaseCount());
|
||||
|
||||
double demandeA = action.currentA;
|
||||
if (demandeA <= 0 && action.powerW > 0)
|
||||
demandeA = action.powerW / (230.0 * phases);
|
||||
|
||||
const uint clampedA = static_cast<uint>(
|
||||
qBound(static_cast<double>(minA), demandeA, static_cast<double>(maxA)));
|
||||
|
||||
// Sous le plancher, la cascade a déjà alloué 0 W et publié son motif : on n'enclenche pas.
|
||||
// Le test porte sur la DEMANDE, pas sur la valeur écrêtée — qui, elle, est remontée à minA
|
||||
// par le qBound et ferait croire à un courant suffisant.
|
||||
const bool enclencher = action.chargingEnabled && demandeA > 0
|
||||
&& demandeA >= static_cast<double>(minA);
|
||||
|
||||
const auto issuer = (action.funding == LoadAction::Surplus)
|
||||
? ChargingAction::ChargingActionIssuerSurplusCharging
|
||||
: ChargingAction::ChargingActionIssuerTimeRequirement;
|
||||
|
||||
ChargingAction ca(action.chargingEnabled, clampedA, phases, issuer, false);
|
||||
ChargingAction ca(enclencher, clampedA, phases, issuer, false);
|
||||
m_parent->doExecuteChargingAction(m_charger, ca, now); // now = temps de cycle (injectable)
|
||||
m_lastActionAt = now;
|
||||
|
||||
LoadAction applied = action;
|
||||
applied.currentA = clampedA;
|
||||
applied.phaseCount = phases;
|
||||
applied.currentA = clampedA;
|
||||
applied.phaseCount = phases;
|
||||
applied.chargingEnabled = enclencher;
|
||||
// Ce qui a RÉELLEMENT été engagé, en watts — c'est ce chiffre que la télémétrie publie et
|
||||
// que la comptabilité du budget doit voir, pas la demande d'origine.
|
||||
applied.estimatedPowerW = enclencher ? clampedA * 230.0 * phases : 0.0;
|
||||
return applied;
|
||||
}
|
||||
|
||||
@ -114,4 +114,8 @@ private:
|
||||
EvCharger *m_charger;
|
||||
EnergyArbitrator *m_parent;
|
||||
QDateTime m_lastActionAt;
|
||||
//! Nombre de phases VISÉ, décidé à la construction du contexte et réutilisé à
|
||||
//! l'exécution. Les deux doivent parler du MÊME, sinon le plancher publié ne décrit pas
|
||||
//! la borne qui sera réellement commandée (§12 / 3g-1).
|
||||
mutable uint m_targetPhases = 1;
|
||||
};
|
||||
|
||||
@ -396,6 +396,45 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime)
|
||||
publishTelemetry(false);
|
||||
}
|
||||
|
||||
bool EnergyArbitrator::evParticipatesInWaterfall(EvCharger *ev) const
|
||||
{
|
||||
if (!ev)
|
||||
return false;
|
||||
|
||||
const ChargingInfo info = chargingInfo(ev->id());
|
||||
|
||||
// Sans voiture assignée, le proxy ignorait la borne : ni SoC, ni plancher véhicule, ni
|
||||
// échéance calculable. Rien de tout cela n'apparaît en entrant dans le waterfall.
|
||||
if (!m_tm->findConfiguredThing(info.assignedCarId()))
|
||||
return false;
|
||||
|
||||
// Mode manuel : l'utilisateur pilote, l'arbitre s'abstient. Hors arbitrage — pas rang 0.
|
||||
if (info.chargingMode() == ChargingInfo::ChargingModeNormal)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
double EnergyArbitrator::evFloorW(EvCharger *ev, uint phaseCount) const
|
||||
{
|
||||
if (!ev || phaseCount == 0)
|
||||
return 0;
|
||||
|
||||
// max(borne, voiture) — la même règle que m_processInfos.minimalChargingCurrent du proxy.
|
||||
// Elle dépend du véhicule branché : c'est une valeur runtime, republiée à chaque cycle.
|
||||
double minA = ev->maxChargingCurrentMinValue();
|
||||
if (Thing *car = m_tm->findConfiguredThing(chargingInfo(ev->id()).assignedCarId())) {
|
||||
const double carMinA = car->stateValue("minChargingCurrent").toDouble();
|
||||
if (carMinA > minA)
|
||||
minA = carMinA;
|
||||
}
|
||||
|
||||
// La tolérance d'acquisition est le même facteur que celui qu'appliquait le proxy avant
|
||||
// de décider d'enclencher : sans elle, le plancher publié serait plus bas que celui qui
|
||||
// gouverne réellement le démarrage.
|
||||
return minA * acquisitionTolerance() * 230.0 * phaseCount;
|
||||
}
|
||||
|
||||
SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const
|
||||
{
|
||||
SurplusContext ctx;
|
||||
@ -462,9 +501,21 @@ SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const
|
||||
}
|
||||
}
|
||||
|
||||
// --- loads[] : EV adapters --- (now = ctx.timestamp : source unique des verrous)
|
||||
for (auto it = m_adapters.constBegin(); it != m_adapters.constEnd(); ++it)
|
||||
// --- loads[] : bornes VE (3g-1) ------------------------------------------------------
|
||||
// Elles entrent désormais dans le MÊME contexte que les autres charges — c'est toute la
|
||||
// transplantation. Le filtre d'entrée du proxy est repris à l'identique : une borne sans
|
||||
// voiture ou en mode manuel est HORS arbitrage, pas servie en dernier. Sa consommation est
|
||||
// déjà dans le compteur racine, donc déjà dans le budget ; lui allouer quoi que ce soit la
|
||||
// compterait deux fois.
|
||||
for (auto it = m_adapters.constBegin(); it != m_adapters.constEnd(); ++it) {
|
||||
EvCharger *ev = internalEvChargers().value(ThingId(it.key()));
|
||||
if (!evParticipatesInWaterfall(ev)) {
|
||||
qCDebug(dcNymeaEnergy()) << "[Arbitre] Borne" << (ev ? ev->name() : it.key())
|
||||
<< "hors waterfall (pas de voiture assignée, ou mode manuel).";
|
||||
continue;
|
||||
}
|
||||
ctx.loads.append(it.value()->toLoadContext(now));
|
||||
}
|
||||
|
||||
// --- loads[] : TOUTES les charges arbitrées, polymorphes (relay-router, etmvariableload,
|
||||
// sg-ready) — une seule table, toutes construites depuis la configuration persistée. ---
|
||||
|
||||
@ -202,6 +202,42 @@ public:
|
||||
*/
|
||||
static bool sameHardware(const LoadConfig &a, const LoadConfig &b);
|
||||
|
||||
/*!
|
||||
* \brief [3g-1] Cette borne participe-t-elle au waterfall de surplus ?
|
||||
*
|
||||
* Reprend **à l'identique** le filtre d'entrée du proxy : une borne sans voiture assignée
|
||||
* ou en mode manuel (\c ChargingModeNormal) est **hors arbitrage**, pas servie en dernier.
|
||||
* L'exclure du contexte est plus juste que lui donner un rang : sa consommation est déjà
|
||||
* dans le compteur racine, donc dans le budget, sans qu'on ait à la lui allouer.
|
||||
*/
|
||||
bool evParticipatesInWaterfall(EvCharger *ev) const;
|
||||
|
||||
/*!
|
||||
* \brief [3g-1] Nombre de phases visé pour une borne, à un courant donné.
|
||||
*
|
||||
* Simple passe-plat vers la règle de bascule 1↔3 du manager. L'arbitre est la porte
|
||||
* d'entrée de l'adaptateur vers cette règle : la réécrire dans l'adaptateur en ferait
|
||||
* deux versions, et elles divergeraient au premier ajustement.
|
||||
*/
|
||||
uint evTargetPhases(EvCharger *ev, double referenceA)
|
||||
{ return bestPhaseCountFor(ev, referenceA); }
|
||||
|
||||
/*!
|
||||
* \brief [3g-1] Plancher de modulation d'une borne, en watts, pour le nombre de phases VISÉ.
|
||||
*
|
||||
* \param ev Borne. \param phaseCount Nombre de phases visé — **pas** le nombre courant.
|
||||
* \return Puissance minimale sous laquelle la borne ne démarre pas, 0 si indéterminable.
|
||||
*
|
||||
* \warning Le nombre de phases change le plancher du simple au triple (6 A × 230 V = 1,4 kW
|
||||
* en monophasé, 4,1 kW en triphasé). Publier le plancher du nombre COURANT alors que
|
||||
* l'exécution basculera vers un autre produirait un défaut silencieux : le waterfall
|
||||
* allouerait 2 kW en croyant démarrer une borne qui en exige 4,1, et se croirait servi.
|
||||
*
|
||||
* \note Valeur **runtime**, pas un réglage : \c minimalChargingCurrent vaut
|
||||
* `max(borne, voiture)` et dépend donc du véhicule branché.
|
||||
*/
|
||||
double evFloorW(EvCharger *ev, uint phaseCount) const;
|
||||
|
||||
signals:
|
||||
/*!
|
||||
* \brief Émise quand l'état runtime d'arbitrage a CHANGÉ, ou au battement de cœur.
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
|
||||
#include <QSet>
|
||||
#include "rulebasedscheduler.h"
|
||||
#include "../energyarbitrator.h"
|
||||
#include "../../evcharger.h"
|
||||
@ -30,6 +31,11 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
|
||||
const auto &cas = m_arbitrator->scheduledActions();
|
||||
const auto &evs = m_arbitrator->registeredEvChargers();
|
||||
|
||||
// 3g-1 — bornes déjà servies par un financement RÉSEAU (échéance, spot market). Elles ne
|
||||
// doivent pas recevoir en plus une allocation de surplus : la précédence du proxy est
|
||||
// conservée, et c'est ce jeu qui la porte jusqu'au waterfall.
|
||||
QSet<QString> gridFundedEvs;
|
||||
|
||||
// Même priorité que adjustEvChargers() — iso-fonctionnel 3b
|
||||
for (auto it = evs.constBegin(); it != evs.constEnd(); ++it) {
|
||||
EvCharger *ev = it.value();
|
||||
@ -43,17 +49,6 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
|
||||
la = buildTimeRequirementAction(
|
||||
ev, actions.value(ChargingAction::ChargingActionIssuerTimeRequirement));
|
||||
|
||||
} else if (actions.value(ChargingAction::ChargingActionIssuerSurplusCharging).chargingEnabled()) {
|
||||
const auto &ca = actions.value(ChargingAction::ChargingActionIssuerSurplusCharging);
|
||||
la.loadId = ev->thing()->id().toString();
|
||||
la.kind = LoadAction::Setpoint;
|
||||
la.funding = LoadAction::Surplus;
|
||||
la.chargingEnabled = true;
|
||||
la.currentA = ca.maxChargingCurrent();
|
||||
la.phaseCount = ca.desiredPhaseCount();
|
||||
la.reason = { DecisionCode::EvSurplus };
|
||||
la.estimatedPowerW = la.currentA * 230.0 * la.phaseCount;
|
||||
|
||||
} else if (actions.value(ChargingAction::ChargingActionIssuerSpotMarketCharging).chargingEnabled()) {
|
||||
const auto &ca = actions.value(ChargingAction::ChargingActionIssuerSpotMarketCharging);
|
||||
la.loadId = ev->thing()->id().toString();
|
||||
@ -66,16 +61,25 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
|
||||
la.estimatedPowerW = la.currentA * 230.0 * la.phaseCount;
|
||||
|
||||
} else {
|
||||
const ChargingInfo::ChargingMode mode =
|
||||
m_arbitrator->chargingInfo(ev->id()).chargingMode();
|
||||
if (mode == ChargingInfo::ChargingModeEcoWithMinCurrent
|
||||
|| mode == ChargingInfo::ChargingModeEcoMinWithTargetTime) {
|
||||
la = buildMinCurrentAction(ev);
|
||||
} else {
|
||||
la = buildIdleAction(ev);
|
||||
}
|
||||
// 3g-1 — LE SURPLUS N'EST PLUS DÉCIDÉ ICI. La borne descend dans le waterfall, où
|
||||
// elle partage le budget avec l'ECS et la PAC, décrémente la cascade et porte un
|
||||
// vrai rang. Les trois branches supprimées produisaient toutes des actions
|
||||
// financées au surplus :
|
||||
//
|
||||
// EV_SURPLUS → remplacé par SURPLUS_SETPOINT du waterfall
|
||||
// buildMinCurrent → remplacé par le PLANCHER (§12), qui est la même idée
|
||||
// exprimée une seule fois pour tous les mécanismes
|
||||
// buildIdle → remplacé par BELOW_MIN_POWER / SURPLUS_INSUFFICIENT, qui
|
||||
// DISENT pourquoi la borne ne charge pas
|
||||
//
|
||||
// Rien n'est émis dans ce cas : l'absence d'action ici est ce qui laisse la place
|
||||
// à celle du waterfall. En émettre une produirait deux actions pour la même borne.
|
||||
continue;
|
||||
}
|
||||
|
||||
// Seules les actions financées au RÉSEAU sortent encore d'ici — échéance et spot
|
||||
// market, qui restent au proxy (DESIGN_3g §3.1).
|
||||
gridFundedEvs.insert(la.loadId);
|
||||
slot.actions.append(la);
|
||||
}
|
||||
|
||||
@ -87,6 +91,11 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
|
||||
for (const LoadAction &la : slot.actions) {
|
||||
if (la.kind != LoadAction::Setpoint || !la.chargingEnabled)
|
||||
continue;
|
||||
// 3g-1 — RESTREINT au financement réseau. Une borne servie par la cascade décrémente
|
||||
// déjà le budget par son allocation ; la réserver EN PLUS la compterait deux fois.
|
||||
// La correction A ne disparaît pas, elle se limite à ce qui reste décidé en amont.
|
||||
if (la.funding != LoadAction::Grid)
|
||||
continue;
|
||||
EvCharger *ev = evs.value(ThingId(la.loadId));
|
||||
const double measuredW = ev ? ev->currentPower() : 0.0;
|
||||
evReservedW += qMax(0.0, la.estimatedPowerW - measuredW);
|
||||
@ -163,11 +172,23 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
|
||||
// Charges en WATTS (Setpoint) : etmvariableload (continu) ET relay-router (rév. 3, combinaison
|
||||
// de relais dérivée côté routeur). Charge à ÉTATS : sg-ready (PAC). Le scheduler distingue
|
||||
// Setpoint vs State (nature de l'action), PAS la classe concrète d'adaptateur (frontière rév. 3).
|
||||
// 3g-1 — le waterfall n'est plus « non-EV » : les bornes y sont, et c'est toute la
|
||||
// transplantation. Elles partagent le budget, décrémentent la cascade et portent un rang
|
||||
// qui a enfin un sens. Le nom de la variable est conservé pour limiter le diff ; sa
|
||||
// sémantique est désormais « charges arbitrées par le waterfall ».
|
||||
QList<LoadContext> nonEvLoads;
|
||||
for (const LoadContext &lc : ctx.loads) {
|
||||
if (lc.adapter == QStringLiteral("etmvariableload") || lc.adapter == QStringLiteral("relay-router")
|
||||
|| lc.adapter == QStringLiteral("sg-ready"))
|
||||
nonEvLoads.append(lc);
|
||||
const bool arbitree = lc.adapter == QStringLiteral("etmvariableload")
|
||||
|| lc.adapter == QStringLiteral("relay-router")
|
||||
|| lc.adapter == QStringLiteral("sg-ready")
|
||||
|| lc.adapter == QStringLiteral("evcharger");
|
||||
if (!arbitree)
|
||||
continue;
|
||||
// Déjà servie au réseau ce cycle : lui allouer du surplus en plus reviendrait à la
|
||||
// commander deux fois, avec deux consignes différentes.
|
||||
if (gridFundedEvs.contains(lc.id))
|
||||
continue;
|
||||
nonEvLoads.append(lc);
|
||||
}
|
||||
std::sort(nonEvLoads.begin(), nonEvLoads.end(),
|
||||
[](const LoadContext &a, const LoadContext &b) { return a.priority < b.priority; });
|
||||
@ -374,7 +395,7 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc,
|
||||
la.reason = { DecisionCode::SurplusSetpoint,
|
||||
{{"budgetW", qRound(budgetW)}, {"setpointW", qRound(setpointW)},
|
||||
{"stepped", !dynamic}} };
|
||||
else if (m_reserveActive)
|
||||
else if (m_reserveActive && m_reserveWithheldW > 0)
|
||||
// 3g-1 — il Y A du surplus, et une règle l'interdit. À ne pas confondre avec
|
||||
// « il n'y en a pas » : le geste attendu n'est pas le même, et le second envoie
|
||||
// chercher une panne inexistante.
|
||||
@ -452,7 +473,7 @@ LoadAction RuleBasedScheduler::buildSgReadyStateAction(const LoadContext &lc,
|
||||
else if (bestState == 3)
|
||||
la.reason = { DecisionCode::SgRecommended,
|
||||
{{"budgetW", qRound(budgetW)}, {"estimatedW", qRound(p3)}} };
|
||||
else if (m_reserveActive)
|
||||
else if (m_reserveActive && m_reserveWithheldW > 0)
|
||||
la.reason = { DecisionCode::BatteryReserve,
|
||||
{{"socPercent", qRound(m_reserveSocPct)},
|
||||
{"reservePercent", qRound(m_reserveSeuilPct)},
|
||||
|
||||
@ -133,5 +133,12 @@ private:
|
||||
double m_reserveSeuilPct = 0; //!< Seuil configuré, en pourcentage.
|
||||
//! Surplus qui EXISTAIT et n'a pas été distribué. C'est ce chiffre, pas le SOC, qui rend
|
||||
//! la cause lisible : « 3 200 W retenus » se comprend là où « batterie à 47 % » se subit.
|
||||
//!
|
||||
//! \warning **Il conditionne le motif, il ne l'accompagne pas.** Nul, la réserve n'a rien
|
||||
//! confisqué — l'installation importait, et les charges n'auraient rien reçu de toute
|
||||
//! façon. Publier \c BATTERY_RESERVE dans ce cas dirait « une règle vous bloque » là où
|
||||
//! la vérité est « il n'y a pas de surplus » : exactement la confusion que ce motif
|
||||
//! existe pour supprimer, retournée. Constaté à la PREMIÈRE observation en vrai, le
|
||||
//! 2026-08-26 — surplus à −3 555 W, motif publié, \c withheldW à 0.
|
||||
double m_reserveWithheldW = 0;
|
||||
};
|
||||
|
||||
@ -137,6 +137,15 @@ protected slots:
|
||||
protected:
|
||||
void executeChargingAction(EvCharger *evCharger, const ChargingAction &chargingAction, const QDateTime ¤tDateTime); // [ETM] private → protected
|
||||
|
||||
/*!
|
||||
* \brief [ETM 3g-1] Nombre de phases le plus adapté à un courant de surplus donné.
|
||||
* \note `private → protected`. La règle de bascule 1↔3 phases est la MÊME que le VE soit
|
||||
* décidé par le proxy ou par le waterfall ; la dupliquer dans l'arbitre en ferait deux,
|
||||
* et elles divergeraient. C'est la seule raison de cet élargissement de visibilité.
|
||||
*/
|
||||
uint bestPhaseCountFor(EvCharger *evCharger, double surplusAmpere)
|
||||
{ return getBestPhaseCount(evCharger, surplusAmpere); }
|
||||
|
||||
// [ETM] Read-only state accessors — inline, no copies, no logic.
|
||||
|
||||
/*!
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user