From aedcbf3cc2cbc284982d3d8e98f8307042701d64 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Wed, 26 Aug 2026 18:31:26 +0200 Subject: [PATCH] =?UTF-8?q?fix(3g-1):=20les=20bornes=20=C3=A9taient=20arbi?= =?UTF-8?q?tr=C3=A9es=20mais=20ne=20franchissaient=20pas=20la=20fronti?= =?UTF-8?q?=C3=A8re=20RPC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H --- INTERFACE.md | 18 +++++++++- debian-qt5/changelog | 35 +++++++++++++++++++ energyplugin/etm/energyarbitrator.cpp | 34 +++++++++++++++++- .../etm/scheduler/rulebasedscheduler.cpp | 31 ---------------- .../etm/scheduler/rulebasedscheduler.h | 14 -------- energyplugin/etm/types/decisionreason.cpp | 6 ++-- energyplugin/etm/types/decisionreason.h | 13 +++++-- energyplugin/nymeaenergyjsonhandler.cpp | 10 +++++- 8 files changed, 108 insertions(+), 53 deletions(-) diff --git a/INTERFACE.md b/INTERFACE.md index a41b8f4..625b249 100644 --- a/INTERFACE.md +++ b/INTERFACE.md @@ -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 diff --git a/debian-qt5/changelog b/debian-qt5/changelog index df0940b..da2fd8d 100644 --- a/debian-qt5/changelog +++ b/debian-qt5/changelog @@ -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 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 diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 038e545..4d953f5 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -689,9 +689,16 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const QVariantList loads; QSet 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; } diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index 23ec281..948ee66 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -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 diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.h b/energyplugin/etm/scheduler/rulebasedscheduler.h index 23d0325..aa3d25a 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.h +++ b/energyplugin/etm/scheduler/rulebasedscheduler.h @@ -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. diff --git a/energyplugin/etm/types/decisionreason.cpp b/energyplugin/etm/types/decisionreason.cpp index 931cf16..a8f2a47 100644 --- a/energyplugin/etm/types/decisionreason.cpp +++ b/energyplugin/etm/types/decisionreason.cpp @@ -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()); diff --git a/energyplugin/etm/types/decisionreason.h b/energyplugin/etm/types/decisionreason.h index 103c510..cd8eeae 100644 --- a/energyplugin/etm/types/decisionreason.h +++ b/energyplugin/etm/types/decisionreason.h @@ -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. diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index 8710ebf..badd471 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -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