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:
Patrick Schurig 2026-08-26 18:00:46 +02:00
parent e634ed99de
commit 1478d8f11c
9 changed files with 275 additions and 39 deletions

View File

@ -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` |

View File

@ -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

View File

@ -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;
}

View File

@ -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;
};

View File

@ -396,6 +396,45 @@ void EnergyArbitrator::update(const QDateTime &currentDateTime)
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. ---

View File

@ -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.

View File

@ -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)},

View File

@ -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;
};

View File

@ -137,6 +137,15 @@ protected slots:
protected:
void executeChargingAction(EvCharger *evCharger, const ChargingAction &chargingAction, const QDateTime &currentDateTime); // [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.
/*!