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