fix(3g-1): les bornes étaient arbitrées mais ne franchissaient pas la frontière RPC

buildTelemetry() n'interrogeait que la table des charges de configuration et sautait l'EV
avec un commentaire devenu faux (« décidé en amont du waterfall »). Le journal montrait les
décisions des deux bornes ; GetLoadTelemetry n'en montrait aucune. Le changement de contrat
annoncé par +etm21 n'était donc pas livré — et LM-1204-b était inéprouvable, puisque son
discriminant est justement le motif publié.

Nouveau champ loads[].funding (« surplus » / « grid ») : dès lors que les bornes sont dans
loads[], deux d'entre elles peuvent y être pour des raisons différentes, l'une comptée dans
budget.allocatedW, l'autre dans budget.evReservedW. Sans lui, sommer allocatedW donne un
écart ininterprétable.

Constat non corrigé, écrit au contrat : le rang d'une borne n'est pas configurable
(priority = 100 en dur) et std::sort n'est pas stable — laquelle des deux bornes est servie
n'est ni réglable ni garanti reproductible.

Aussi : « batterie à 50 %% » — %% n'est pas une échappée pour QString::arg. Vu au banc.
Et retrait du code mort buildMinCurrentAction/buildIdleAction, seuls porteurs de EV_ECO_MIN
et EV_IDLE depuis +etm21.

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:31:26 +02:00
parent e21ab6a0f1
commit aedcbf3cc2
8 changed files with 108 additions and 53 deletions

View File

@ -434,6 +434,7 @@ mécanisme, et **motif de décision sous forme de code**.
{
"loadId": "ecs-chauffe-eau",
"allocatedW": 3500, // ce que le waterfall a ALLOUÉ (pas la mesure)
"funding": "surplus", // o: — « surplus » ou « grid », voir plus bas
"available": true,
"faultCode": "WRITE_FAILED", // o: — absent = aucun défaut
"lock": { "kind": "minOff", "remainingS": 42 }, // o: — absent = aucun verrou actif
@ -464,7 +465,15 @@ le cas normal.
> **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.
> `allocatedW` **des charges financées au surplus** retombe sur `budget.allocatedW` — ce qui
> n'était pas vrai avant.
>
> **`funding` (`o:`, depuis 3g-1)** — `"surplus"` ou `"grid"`. Il existe parce que les bornes
> peuvent être dans `loads[]` pour deux raisons différentes : la **cascade** (surplus, comptée
> dans `budget.allocatedW`) ou le **proxy** — échéance de départ, tarif dynamique — qui soutire
> au réseau et dont la puissance vit dans `budget.evReservedW`. Sommer `allocatedW` sans
> distinguer les deux donne un écart qu'aucun client ne peut interpréter : défaut, ou borne
> servie au réseau ? **Omis en mode dégradé** : sans plan, il n'y a pas de financement.
>
> `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
@ -473,6 +482,13 @@ le cas normal.
> **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.
>
> ⚠️ **Le rang d'une borne n'est pas configurable** : `EvAdapter::descriptor()` fixe
> `priority = 100` pour toutes. Deux bornes ont donc la MÊME clé de tri, et `std::sort` n'est
> pas stable : laquelle est servie n'est ni réglable, ni garanti reproductible d'un cycle à
> l'autre. La cascade est correcte — une seule démarre, l'autre reçoit son motif — mais
> **laquelle** ne se décide pas encore. À traiter par un lot dédié ; à ne pas présenter dans
> l'app comme un réglage tant que ce n'est pas le cas.
**Bornes exclues de `loads[]`** : celles sans voiture assignée et celles en mode manuel
(`ChargingModeNormal`). Elles sont **hors arbitrage**, pas servies en dernier — leur

View File

@ -1,3 +1,38 @@
powersync-energy-plugin-nymea (1.15.2+etm22) trixie; urgency=medium
* 3g-1, CE QUI MANQUAIT — les bornes étaient bien arbitrées, mais elles ne franchissaient
pas la frontière RPC. buildTelemetry() n'interrogeait que la table des charges de
configuration et sautait l'EV avec un commentaire devenu faux (« décidé en amont du
waterfall »). Résultat : le journal montrait les décisions des deux bornes, et
GetLoadTelemetry n'en montrait aucune. Le changement de contrat annoncé par +etm21 n'était
donc PAS livré, et le critère LM-1204-b était inéprouvable — son discriminant est le MOTIF
publié, précisément ce qui n'arrivait pas. Attrapé au premier passage machine.
* NOUVEAU CHAMP loads[].funding — « surplus » ou « grid ». Il devient nécessaire dès lors
que les bornes sont dans loads[] : une borne servie par la cascade est comptée dans
budget.allocatedW, une borne servie par l'échéance ou le tarif dynamique SOUTIRE au réseau
et vit dans budget.evReservedW. Sans ce champ, un client qui somme allocatedW trouve un
écart sans pouvoir dire s'il regarde un défaut ou une borne financée au réseau. Omis en
mode dégradé : sans plan, il n'y a pas de financement.
* Les bornes figurent aussi dans la télémétrie DÉGRADÉE. C'est là que savoir qu'une borne
existe et n'est plus pilotée vaut le plus ; l'omettre ferait lire « pas de borne ».
* Une borne SANS action reste hors du contrôle « charge arbitrée sans décision » : sans
voiture assignée ou en mode manuel, elle est hors arbitrage et n'en recevoir aucune est
correct. Avertir à chaque cycle sur une borne simplement débranchée noierait le signal.
* Rendu du motif BATTERY_RESERVE : « batterie à 50 %% » s'affichait tel quel. « %% » est une
échappée printf ; QString::arg ne l'interprète pas. Vu sur le banc à la première
observation de la règle.
* CONSTAT, non corrigé ici : le RANG d'une borne n'est pas configurable — descriptor() fixe
priority = 100 pour toutes. Deux bornes ont donc la même clé de tri, et std::sort n'est pas
stable : la cascade est correcte (une seule démarre, l'autre reçoit son motif) mais
LAQUELLE n'est ni réglable ni garanti reproductible. Demande un lot dédié, et ne doit pas
être présenté comme un réglage dans l'app avant.
* Code mort retiré : buildMinCurrentAction() et buildIdleAction() n'étaient plus appelés
depuis +etm21 mais restaient compilés, seuls porteurs de EV_ECO_MIN et EV_IDLE. Les trois
codes retirés (avec EV_SURPLUS) restent DÉCLARÉS et rendus pour qu'un journal antérieur ou
une box en retard sur son app reste lisible — mais plus rien ne les produit.
-- Patrick Schurig <etm.schurig@gmail.com> Wed, 26 Aug 2026 19:40:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm21) trixie; urgency=medium
* 3g-1 — LA TRANSPLANTATION. Les bornes de recharge entrent dans le waterfall : elles

View File

@ -689,9 +689,16 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const
QVariantList loads;
QSet<QString> couvertes;
for (const LoadAction &action : slot.actions) {
// 3g-1 — LES BORNES SONT DANS loads[]. Elles étaient exclues ici tant que l'EV était
// décidé en amont du waterfall ; depuis qu'il y entre, les omettre laisserait la somme
// des allocations irréconciliable avec budget.allocatedW et priverait le client du
// motif — c'est-à-dire de la SEULE chose qui distingue « l'arbitre a refusé » de « le
// matériel n'a pas suivi ». Attrapé au premier passage machine.
ILoadAdapter *adapter = m_loadAdapters.value(action.loadId, nullptr);
if (!adapter)
continue; // EV : décidé en amont du waterfall, hors de cette charge utile.
adapter = m_adapters.value(action.loadId, nullptr);
if (!adapter)
continue;
couvertes.insert(action.loadId);
QVariantMap entry;
@ -700,6 +707,15 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const
// vit dans la charge utile de mécanisme ; les confondre rendrait la somme des
// allocations irréconciliable avec budget.allocatedW.
entry.insert(QStringLiteral("allocatedW"), action.estimatedPowerW);
// Le FINANCEMENT, sans quoi la charge utile n'est pas réconciliable. Seules les actions
// financées au SURPLUS ont décrémenté la cascade et sont comptées dans
// budget.allocatedW ; celles financées au RÉSEAU — échéance et spot market, restées au
// proxy — portent une puissance qui vit dans budget.evReservedW. Sans ce champ, un
// client qui somme loads[].allocatedW trouve un écart et n'a aucun moyen de savoir
// s'il regarde un bug ou une borne servie au réseau.
entry.insert(QStringLiteral("funding"),
action.funding == LoadAction::Grid ? QStringLiteral("grid")
: QStringLiteral("surplus"));
entry.insert(QStringLiteral("available"), adapter->telemetry().available);
entry.insert(QStringLiteral("decision"), action.reason.toMap());
@ -725,6 +741,11 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const
// client ne pourrait pas distinguer « charge absente » de « charge oubliée du plan ». Cela ne
// devrait pas arriver (toute charge du contexte reçoit une action) : on l'annonce donc au
// journal plutôt que de le laisser passer sans bruit.
//
// Les BORNES sont volontairement hors de ce contrôle : une borne sans voiture assignée ou
// en mode manuel est HORS arbitrage (DESIGN_3g §4), et ne recevoir aucune action y est le
// comportement correct — pas un trou. Avertir à chaque cycle sur une box dont la borne est
// simplement débranchée noierait le vrai signal.
for (auto it = m_loadAdapters.constBegin(); it != m_loadAdapters.constEnd(); ++it)
if (!couvertes.contains(it.key()))
qCWarning(dcNymeaEnergy()) << "[Arbitre] Télémétrie : charge" << it.key()
@ -802,11 +823,22 @@ void EnergyArbitrator::buildDegradedTelemetry(const QDateTime &now)
loads.append(entry);
};
// Ordre STABLE : QHash n'en garantit aucun, et un instantané se compare.
//
// Les BORNES y figurent aussi depuis 3g-1. Le mode dégradé est précisément le moment où
// savoir qu'une borne existe et n'est plus pilotée a le plus de valeur : l'omettre ferait
// lire « pas de borne » là où la vérité est « borne non pilotée ».
// Pas de champ « funding » ici : en dégradé il n'y a aucun plan, donc aucun financement —
// et le champ est omis, jamais nul.
QStringList ids = m_loadAdapters.keys();
ids.sort();
for (const QString &id : ids)
entree(id, m_loadAdapters.value(id));
QStringList evIds = m_adapters.keys();
evIds.sort();
for (const QString &id : evIds)
entree(id, m_adapters.value(id));
snap.insert(QStringLiteral("loads"), loads);
m_telemetry = snap;
}

View File

@ -229,37 +229,6 @@ LoadAction RuleBasedScheduler::buildTimeRequirementAction(EvCharger *ev,
return la;
}
LoadAction RuleBasedScheduler::buildMinCurrentAction(EvCharger *ev) const
{
const uint minA = qMax(EcoMinChargingCurrent, ev->maxChargingCurrentMinValue());
const uint phases = ev->phaseCount();
LoadAction la;
la.loadId = ev->thing()->id().toString();
la.kind = LoadAction::Setpoint;
la.funding = LoadAction::Surplus;
la.chargingEnabled = true;
la.currentA = minA;
la.phaseCount = phases;
la.reason = { DecisionCode::EvEcoMin };
la.estimatedPowerW = la.currentA * 230.0 * la.phaseCount;
return la;
}
LoadAction RuleBasedScheduler::buildIdleAction(EvCharger *ev) const
{
LoadAction la;
la.loadId = ev->thing()->id().toString();
la.kind = LoadAction::Setpoint;
la.funding = LoadAction::Surplus;
la.chargingEnabled = false;
la.currentA = 0;
la.phaseCount = 0;
la.reason = { DecisionCode::EvIdle };
la.estimatedPowerW = 0;
return la;
}
LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc,
double &remainingSurplusW,
PlanBudget &budget) const

View File

@ -70,20 +70,6 @@ private:
*/
LoadAction buildTimeRequirementAction(EvCharger *ev, const ChargingAction &ca) const;
/*!
* \brief Construit un LoadAction "courant minimum" pour les modes EcoMin.
* \param ev EvCharger concerné.
* \return LoadAction avec funding=Surplus, chargingEnabled=true, currentA=min.
*/
LoadAction buildMinCurrentAction(EvCharger *ev) const;
/*!
* \brief Construit un LoadAction "idle" (recharge désactivée, aucun surplus).
* \param ev EvCharger concerné.
* \return LoadAction avec chargingEnabled=false et reason appropriée.
*/
LoadAction buildIdleAction(EvCharger *ev) const;
/*!
* \brief Construit un LoadAction \c Setpoint (W) pour une charge \c etmvariableload
* (ECS résistif / routeur PV) par la règle d'arrondi du contrat rév. 2 §5.

View File

@ -69,8 +69,10 @@ QString renderFr(const DecisionReason &reason, const QString &label)
if (c == QLatin1String(DecisionCode::BatteryReserve))
// La phrase nomme d'abord ce qui a été RETENU : c'est le chiffre qui rend la cause
// lisible, là où un pourcentage de SOC se subit sans se comprendre.
return QStringLiteral("%1 W de surplus retenus — batterie à %2 %%, sous la réserve de "
"%3 %% ; aucune charge n'est servie")
// « %% » n'est PAS une échappée pour QString::arg — contrairement à printf, arg() le
// laisse tel quel, et le banc a affiché « batterie à 50 %% ». Un seul signe pour cent.
return QStringLiteral("%1 W de surplus retenus — batterie à %2 %, sous la réserve de "
"%3 % ; aucune charge n'est servie")
.arg(w("withheldW"))
.arg(p.value(QStringLiteral("socPercent")).toInt())
.arg(p.value(QStringLiteral("reservePercent")).toInt());

View File

@ -42,11 +42,18 @@
namespace DecisionCode {
// --- EV (proxy amont, beta) -------------------------------------------------
constexpr const char *EvSurplus = "EV_SURPLUS"; //!< params: —
//
// Seuls EV_SPOT_MARKET et EV_DEADLINE sont encore ÉMIS : ils correspondent aux deux décisions
// restées au proxy, financées au réseau. Les trois autres ont été RETIRÉS par 3g-1 — la borne
// est passée dans le waterfall et y reçoit les motifs communs à tous les mécanismes
// (SURPLUS_SETPOINT, BELOW_MIN_POWER, SURPLUS_INSUFFICIENT). Ils restent déclarés et RENDUS
// pour qu'un journal antérieur ou une box en retard sur son app reste lisible ; plus rien ne
// les produit. Ne pas les réutiliser pour autre chose : un code recyclé est pire qu'un code mort.
constexpr const char *EvSpotMarket = "EV_SPOT_MARKET"; //!< params: —
constexpr const char *EvDeadline = "EV_DEADLINE"; //!< params: —
constexpr const char *EvEcoMin = "EV_ECO_MIN"; //!< params: —
constexpr const char *EvIdle = "EV_IDLE"; //!< params: —
constexpr const char *EvSurplus = "EV_SURPLUS"; //!< RETIRÉ 3g-1 — plus émis.
constexpr const char *EvEcoMin = "EV_ECO_MIN"; //!< RETIRÉ 3g-1 — plus émis.
constexpr const char *EvIdle = "EV_IDLE"; //!< RETIRÉ 3g-1 — plus émis.
// --- Charges pilotées en watts (relay-router / etmvariableload) -------------
//! params: \c frozenW — charge en défaut (ECS-410/414), gelée et toujours comptée au budget.

View File

@ -729,8 +729,16 @@ QVariantMap NymeaEnergyJsonHandler::loadTelemetrySchema()
QVariantMap loadItem;
loadItem.insert("loadId", enumValueName(String));
// ALLOCATION du waterfall (ce qui a décrémenté le budget), pas la mesure : la mesure vit
// dans la charge utile de mécanisme. La somme des allocatedW retombe sur budget.allocatedW.
// dans la charge utile de mécanisme. La somme des allocatedW des charges financées au
// SURPLUS retombe sur budget.allocatedW — voir « funding » juste dessous.
loadItem.insert("allocatedW", enumValueName(Double));
// FINANCEMENT — « surplus » ou « grid ». Depuis 3g-1 les bornes figurent dans loads[], et
// deux d'entre elles peuvent y être pour des raisons différentes : la cascade (surplus,
// comptée dans budget.allocatedW) ou le proxy — échéance et tarif dynamique — qui SOUTIRE
// au réseau et dont la puissance vit dans budget.evReservedW. Sans ce champ, un client qui
// somme allocatedW trouve un écart sans pouvoir dire s'il regarde un défaut ou une borne
// servie au réseau. Omis en mode dégradé : sans plan, il n'y a pas de financement.
loadItem.insert("o:funding", enumValueName(String));
loadItem.insert("available", enumValueName(Bool));
loadItem.insert("o:faultCode", enumValueName(String)); // absent = aucun défaut
// §11 (LM-1101/1103) — mesures de la charge, publiées à côté de l'allocation et JAMAIS