diff --git a/debian-qt5/changelog b/debian-qt5/changelog index 946e106..9d258cb 100644 --- a/debian-qt5/changelog +++ b/debian-qt5/changelog @@ -1,3 +1,120 @@ +powersync-energy-plugin-nymea (1.15.2+etm14) trixie; urgency=medium + + * Contrat : loads[] de GetLoadTelemetry n'est PAS en correspondance 1:1 avec GetLoadConfig. + Une charge peut être arbitrée et publiée sans exister dans la configuration. Cas réel, + vérifié au banc : « pac-terrain » est la PAC SG-Ready CODÉE EN DUR d'energypluginnymea.cpp + (contacts K1/K2 = Relay4/Relay5, états {3: 1500 W, 4: 3000 W}, minStateHoldS 300), donc + pleinement arbitrée — elle consomme du budget, porte des verrous, apparaît en télémétrie — + et pourtant inconfigurable : ni renommage, ni rang, ni domain, ni enabled:false. Un client + qui suppose la correspondance affichera une charge sans nom ni domaine, ou cherchera une + configuration inexistante. INTERFACE.md porte désormais la règle de détection : un loadId + de loads[] sans équivalent dans GetLoadConfig est une charge NON CONFIGURABLE — afficher le + loadId, la traiter comme non classée, ne proposer aucun écran de réglage. Aucun champ n'est + ajouté à la charge utile pour le signaler : « configurable: false » préjugerait du sort de + cette charge, qui n'est pas tranché. + * Aucun changement de comportement moteur. Seuls la documentation de contrat, le journal de + vérification et les tests bougent — le binaire est fonctionnellement identique à +etm13, + dont il ne se distingue que par ce changelog. La version est incrémentée parce qu'un + artefact publié doit se relire dans sa propre source : livrer +etm13 avec un changelog + embarqué différent de celui du dépôt aurait recréé, en petit, la divergence que ce lot + passe son temps à fermer. + * Test : runtimeView() est SUBSTANTIELLE pour les quatre adaptateurs, et c'est prouvé par + mutation. La pure virtuelle force à RÉPONDRE, pas à répondre quelque chose : un + « return LoadRuntimeView{}; » compilerait, passerait le dispatch, et sortirait de la + télémétrie sans bruit — le client verrait une charge sans mécanisme ni verrou, + indistinguable d'une charge libre. testRuntimeViewIsSubstantial() amène chaque adaptateur + dans un état non trivial puis exige les valeurs runtime réelles (palier appliqué, consigne, + état normé, pourcentage dérivé à 75 % — ni 0, ni 100, ni la consigne), les verrous en + secondes restantes ET leur expiration, et les codes de défaut THING_MISSING et + UNUSABLE_ENCODING. Deux mutants « vue vide » ont été introduits puis retirés : le test + échoue sur chacun. + * Le déploiement sur box entre au dépôt : etm-powersync-deploy/deploy.sh, après l'incident du + 2026-08-25 où .75 a été rétrogradée de +etm12 à +etm9. Trois causes composées — un glob sur + un répertoire de dépôt qui accumulait quatre paquets périmés, un dpkg -i qui autorise la + rétrogradation par défaut, et aucune relecture de version après coup. La recette fautive + figurait aussi dans docs/TEST_TERRAIN.md §b ; elle est remplacée. Le verrou décisif est + l'ARCHIVAGE avant dépôt : la règle sudoers de la box porte elle-même un « *.deb » et ne peut + pas être resserrée sans root, mais avec un seul paquet dans le répertoire ce glob ne peut + plus désigner qu'un fichier — y compris pour un geste fait à la main. + + -- Patrick Schurig Tue, 25 Aug 2026 14:00:00 +0200 + +powersync-energy-plugin-nymea (1.15.2+etm13) trixie; urgency=medium + + * Frontière de télémétrie d'arbitrage. Constat d'origine, vérifié au banc : 90 s d'écoute, + arbitre actif, ~2,9 kW produits et ~2 kW exportés — ZÉRO notification NymeaEnergy.*. + L'état runtime de l'arbitre (allocation, budget, verrous, paliers, disponibilité, motifs) + ne franchissait aucune frontière RPC : il vivait exclusivement dans qCInfo(dcNymeaEnergy). + Expliquer les décisions au client — promesse présente dans les deux versions de gamme — + n'avait donc aucun transport. Ajout de NymeaEnergy.GetLoadTelemetry et de sa notification + LoadTelemetryChanged, MÊME charge utile pour les deux. + * Aucune phrase composée par la box. La box transporte un CODE et des PARAMÈTRES ; la phrase + est fabriquée par le client, dans la langue de son utilisateur. Justification vérifiée : + la locale nymea est PAR CONNEXION (JSONRPC.Hello o:locale), donc deux utilisateurs d'une + même box peuvent avoir deux langues — impossible à servir avec une phrase composée en C++ ; + et NymeaEnergyJsonHandler est un JsonHandler sans pluginId, donc structurellement hors du + catalogue de traduction nymea : rien ne rattraperait une phrase française émise là. + * LoadAction::reason devient {code, params}. La ligne FRANÇAISE du journal est désormais + RENDUE à partir du code par une fonction de rendu unique (renderFr), et non maintenue en + parallèle : deux écritures parallèles divergeraient, et c'est le diagnostic terrain qui + deviendrait faux. Le catalogue de 19 codes est EXTRAIT des sites existants, pas inventé ; + les motifs d'ECS-309 et ECS-309-b sont repris mot pour mot. Prouvé par comparaison de + traces (1726 lignes de journal identiques en simulation) et par un test de rendu à table + d'or qui échoue au premier code ajouté sans phrase attendue. + * Le libellé de la charge n'entre PAS dans la charge utile RPC : c'est de la langue naturelle + saisie par l'installateur. Il est substitué au moment du RENDU, par l'appelant qui le + connaît. Le client résout le sien via GetLoadConfig. + * Les verrous sortent en SECONDES RESTANTES, pas en watts. lockMinPowerW/lockMaxPowerW sont + un artefact du mécanisme d'allocation, illisible pour un utilisateur. Ceci n'enfreint pas + ECS-306 : cette exigence interdit qu'un index de PALIER franchisse la frontière + d'ALLOCATION ; une valeur d'AFFICHAGE sortante est un autre chemin. + * Le mécanisme sort en union discriminée (relay | variable | sgReady), comme LoadConfig : + kind discrimine, chaque variante porte SA charge utile et seulement la sienne. Toutes les + clés internes optionnelles au schéma (règle LM-302). ILoadAdapter::runtimeView() est PURE + VIRTUELLE à dessein : un mécanisme ajouté qui hériterait d'un défaut vide sortirait de la + télémétrie sans bruit, et le client verrait une charge sans mécanisme ni verrou — + indistinguable d'une charge libre. + * budget publié : surplusW, evReservedW, recreditedW, allocatedW, remainingW, avec l'identité + surplus + recrédit − alloué == restant. recreditedW n'est pas décoratif : le waterfall rend + à chaque charge sa consommation de début de cycle avant d'arrondir (anti-clignotement, + correction B). Sans ce terme, l'identité serait fausse DÈS QU'UNE CHARGE CONSOMME, c'est-à- + dire dans le cas normal, et le client chercherait un écart inexplicable. Il rend aussi + lisible l'artefact de banc documenté dans AGENTS.md (relais sans mesure + rootmeter aveugle + → recrédit du nominal commandé). + * Champs omis, jamais nuls (règle maison) : timestamp absent = aucun cycle d'arbitrage depuis + le démarrage — en forger un se lirait « cycle exécuté, rien à allouer », soit le mensonge + même que ce champ existe pour empêcher ; budget absent = mode dégradé L2, où la + planification est suspendue et où publier des zéros se lirait « rien à allouer ». + * Cadence : émission sur changement QUALITATIF ou variation d'allocation > 50 W, plus un + battement de cœur de 60 s même sans changement. C'est le battement qui rend un arbitre + FIGÉ détectable : les notifications continuent d'arriver tandis que le timestamp cesse + d'avancer. Sans lui, « installation stable » et « moteur arrêté » sont le même silence. + Toute notification relance le décompte : la cadence totale reste d'environ une par minute. + * GetChargingSchedules rend désormais degradedMode. Il était POUSSÉ en notification seule : + un client connecté après la bascule ne pouvait pas savoir dans quel état il se trouvait. + Règle : tout élément runtime a un Get* ET un *Changed, jamais une notification seule. + La bascule ET la sortie du mode dégradé publient aussi la télémétrie immédiatement. + * LoadConfig : champ optionnel domain, énumération FERMÉE (ecs · ev · heating · hvac · + battery). PURE métadonnée d'intention — l'arbitre ne la lit pas, aucun comportement moteur + ne change ; elle enregistre le choix de l'installateur. Codes, jamais libellés. Valeur + absente = charge non classée : le plugin ne la range pas d'office, parce qu'un classement + inventé serait indistinguable d'un classement choisi. Valeur hors énumération rejetée en + bloc. Elle ne préempte pas la couche domaine à venir : elle en deviendra le discriminant. + * Règle 7-c, deux silences fermés : le retour idempotent du RelayRouter et celui du + SgReadyAdapter étaient MUETS, si bien que « consigne reçue, palier déjà bon » ne se + distinguait pas de « l'action n'est jamais parvenue à l'adaptateur » — et c'est justement + le cas ordinaire qu'on vient vérifier quand une charge ne bouge pas. Journalisés au même + niveau que la branche qui commute (7-d, symétrie). + * Tests : rendu français à table d'or (19 codes + repli d'un code hors catalogue), + télémétrie de bout en bout (notification non nulle, snapshot ≡ notification, union de + mécanisme entière, verrous en secondes, seuil qui ne mord pas sur 10 W, battement à + timestamp figé, reprise à froid du mode dégradé par les DEUX portes, contrôle mécanique + « zéro langue naturelle »), et domain (aller-retour, rejet hors énumération, charge non + classée, absence d'effet sur l'arbitrage). Suites : simulation 37/37, charging 46/46, + spotmarket 32/32. + + -- Patrick Schurig Tue, 25 Aug 2026 12:00:00 +0200 + powersync-energy-plugin-nymea (1.15.2+etm12) trixie; urgency=medium * ECS-411-b — la reprise du palier au démarrage s'annonce toujours, et publie ce qu'elle diff --git a/docs/INTERFACE_etmvariableload.md b/docs/INTERFACE_etmvariableload.md index 63e4edd..8dc338c 100644 --- a/docs/INTERFACE_etmvariableload.md +++ b/docs/INTERFACE_etmvariableload.md @@ -101,6 +101,7 @@ piloté par `EtmVariableLoadAdapter`. (`maxPowerW`/`powerLevels` en read optionn "mode": "fixed", "priority": 2, // rang ; 1 = servi en premier "enabled": true, + "domain": "ecs", // o: — catégorie d'INTENTION, cf. ci-dessous "needs": { "dailyDeadline": "06:00", "minEnergyWhPerDay": 4000 }, "relays": [ // ← la combinatoire vit ICI (routeur), pas chez l'optimiseur { "thingId": "{uuid-relais-500W}", "powerW": 500 }, @@ -130,6 +131,30 @@ Règles : - `minOnS`/`minOffS` : verrous anti-rebond (protection relais / compresseur), **par charge**. - `enabled:false` → rôle déclaré mais **exclu** de l'arbitrage (aucune action, aucun adaptateur construit). +- **`domain` (optionnel)** — énumération FERMÉE : `ecs` · `ev` · `heating` · `hvac` · `battery`. + - **Pure métadonnée d'intention.** L'arbitre ne la lit pas ; aucun comportement moteur n'en + dépend. Elle enregistre le choix de l'installateur, qui sélectionne une catégorie puis lui + rattache ses Things. + - **Des codes, jamais des libellés affichables** — même raison que pour les motifs de décision + (locale nymea par connexion, handler hors catalogue de traduction ; cf. `INTERFACE.md`). + Le nom montré à l'utilisateur est fabriqué par l'app. + - **Absente ou vide → charge « non classée ».** Le plugin ne la range pas d'office : il n'a + aucun moyen de deviner juste, et un classement inventé serait indistinguable d'un + classement choisi. + **Forme exacte, vérifiée au banc** : `GetLoadConfig` sérialise via le **méta-objet**, donc + **toutes** les propriétés déclarées sortent, y compris vides — une charge non classée revient + avec `"domain": ""`, exactement comme `"sgReady": {}`, `"powerLevels": []` ou + `"needs": {"dailyDeadline": ""}` sur une charge qui ne les porte pas. C'est le corollaire + LM-302, et c'est ce qui rend l'aller-retour `Get` → `Set` possible. La forme **persistée** + (fichier JSON), elle, omet la clé. Le client doit donc traiter `""` **et** l'absence comme + « non classée » — et ne jamais afficher la chaîne vide comme un domaine. + - `SetLoadConfig` **rejette en bloc** une valeur hors énumération. Une valeur libre acceptée + aujourd'hui deviendrait une valeur à supporter le jour où la couche domaine en fera son + discriminant. + - Elle **ne préempte pas** la couche domaine de `specs/spec_loadmodel.md` : elle en deviendra + le discriminant quand cette couche arrivera. L'énumération de ce spec (§2 : + `Ecs`/`Hvac`/`Ev`/`SmartHome`) est une intention plus ancienne ; c'est celle ci-dessus qui + est implémentée. --- @@ -163,7 +188,22 @@ routeur**. Le kind `Stage` reste supprimé (rév. 2). `reason` reste porté par --- -## 7. Expo du `reason` — tableau additif `loadActions[]` (inchangé) +## 7. Expo du `reason` — ⚠️ REMPLACÉ par `NymeaEnergy.GetLoadTelemetry` + +> **Ce paragraphe décrit une intention qui n'a jamais été implémentée, et qui ne le sera pas.** +> `loadActions[]` n'existe pas dans `GetChargingSchedules` ni dans `ChargingSchedulesChanged`. +> Le canal réel de la *decision card* est `NymeaEnergy.GetLoadTelemetry` + sa notification +> `LoadTelemetryChanged` (contrat : `INTERFACE.md`). +> +> Une différence de fond, et non de forme : le `reason` ci-dessous est une **phrase française +> composée par la box**. C'est intenable — la locale nymea est par CONNEXION, et ce handler est +> hors du catalogue de traduction. La télémétrie transporte donc `{code, params}`, et la phrase +> est fabriquée par le client. La ligne française du journal, elle, est **rendue depuis le même +> code** par une fonction unique (`renderFr`), pour qu'elle ne puisse pas en diverger. + +Forme historique, conservée pour mémoire : + +## 7-bis. Ancienne forme (mémoire) Dans `GetChargingSchedules` / `ChargingSchedulesChanged` : ```jsonc @@ -232,6 +272,9 @@ toucher à l'écran Configurer, sinon il code contre une frontière périmée (r - **rév. 1** : `etmvariableload` interface unique, watts, un seul kind d'action. - **rév. 2** : PAC SG-Ready **exclue** (modèle à états séparé) ; `LoadAction` garde **deux** kinds (`Setpoint` + état SG-Ready) ; suppression du kind `Stage`. +- **lot B (télémétrie)** : `domain` ajouté à `LoadConfig` (§4, optionnel, énumération fermée) ; + §7 remplacé — le `reason` sort désormais en `{code, params}` par + `NymeaEnergy.GetLoadTelemetry` / `LoadTelemetryChanged`, plus jamais en phrase composée. - **rév. 3** : **frontière déplacée** entre optimiseur (watt-pur) et **routeur** (watts→relais, experience-plugin) ; un relais = thing `power` nu ; `LoadConfig` du cas ECS devient une **liste de relais** (`relays[]` + `minOnS`/`minOffS`) ; `powerLevels` **dérivés** ; cas continu diff --git a/energyplugin/etm/adapters/etmvariableloadadapter.cpp b/energyplugin/etm/adapters/etmvariableloadadapter.cpp index f1de5e0..240ddbc 100644 --- a/energyplugin/etm/adapters/etmvariableloadadapter.cpp +++ b/energyplugin/etm/adapters/etmvariableloadadapter.cpp @@ -111,7 +111,7 @@ LoadAction EtmVariableLoadAdapter::applyAction(const LoadAction &action, const Q qCInfo(dcNymeaEnergy()) << "[EtmVariableLoadAdapter]" << m_label << "→ setpoint" << qRound(setpointW) << "W" << (action.force ? "(force)" : "") - << "|" << action.reason; + << "|" << renderFr(action.reason, m_label); m_setpointPrev = m_currentSetpointW; m_setpointTarget = setpointW; @@ -127,6 +127,30 @@ LoadAction EtmVariableLoadAdapter::applyAction(const LoadAction &action, const Q return applied; } +LoadRuntimeView EtmVariableLoadAdapter::runtimeView(const QDateTime &now) const +{ + Q_UNUSED(now) // aucun verrou côté moteur : l'anti-rebond vit dans le thing (cf. toLoadContext). + + LoadRuntimeView v; + v.mechanismKind = QStringLiteral("variable"); + v.mechanism.insert(QStringLiteral("setpointW"), m_currentSetpointW); + // Le pourcentage est la lecture NATURELLE d'une modulation continue — c'est ce qu'un + // utilisateur reconnaît d'un routeur PV. Il reste dérivé de la consigne, jamais l'inverse : + // les watts sont la grandeur commandée, le pourcentage n'est qu'une vue. + if (m_maxPowerW > 0) + v.mechanism.insert(QStringLiteral("percent"), + qRound(1000.0 * m_currentSetpointW / m_maxPowerW) / 10.0); + v.mechanism.insert(QStringLiteral("maxPowerW"), static_cast(m_maxPowerW)); + + // ECS-414 — deux causes DISTINCTES d'indisponibilité, deux codes. Les confondre enverrait + // diagnostiquer un matériel muet là où c'est un Thing absent de la configuration. + if (m_faulted) + v.faultCode = QStringLiteral("WRITE_FAILED"); + else if (!m_thingManager || !m_thingManager->findConfiguredThing(ThingId(m_id))) + v.faultCode = QStringLiteral("THING_MISSING"); + return v; +} + void EtmVariableLoadAdapter::applySafeState(const QDateTime &now) { LoadAction sur; @@ -134,7 +158,7 @@ void EtmVariableLoadAdapter::applySafeState(const QDateTime &now) sur.kind = LoadAction::Setpoint; sur.powerW = 0; sur.force = true; - sur.reason = QStringLiteral("Charge désactivée — mise en état sûr (0 W) avant retrait (ECS-413)"); + sur.reason = { DecisionCode::SafeStateSetpoint }; applyAction(sur, now); } diff --git a/energyplugin/etm/adapters/etmvariableloadadapter.h b/energyplugin/etm/adapters/etmvariableloadadapter.h index fbf92b4..b8ffac3 100644 --- a/energyplugin/etm/adapters/etmvariableloadadapter.h +++ b/energyplugin/etm/adapters/etmvariableloadadapter.h @@ -122,6 +122,15 @@ public: //! \return Vrai si la charge est en défaut (plus aucune consigne écrite). bool faulted() const { return m_faulted; } + /*! + * \brief Vue runtime pour la télémétrie RPC : consigne, pourcentage, défaut. + * \param now Inutilisé — cet adaptateur n'a AUCUNE fenêtre de verrou côté moteur : + * l'anti-rebond vit dans le thing. Le client lit donc \c lock absent, ce qui est la + * vérité, et non un verrou nul qui laisserait croire à une fenêtre expirée. + * \return \c mechanismKind "variable" ; \c mechanism \c {setpointW, percent, maxPowerW}. + */ + LoadRuntimeView runtimeView(const QDateTime &now) const override; + //! \brief État sûr : consigne **0 W**, en \c force = true (ECS-413). \param now Temps de cycle. void applySafeState(const QDateTime &now) override; diff --git a/energyplugin/etm/adapters/evadapter.cpp b/energyplugin/etm/adapters/evadapter.cpp index 1b36a69..24efd7e 100644 --- a/energyplugin/etm/adapters/evadapter.cpp +++ b/energyplugin/etm/adapters/evadapter.cpp @@ -59,6 +59,21 @@ LoadContext EvAdapter::toLoadContext(const QDateTime &now) const return ctx; } +LoadRuntimeView EvAdapter::runtimeView(const QDateTime &now) const +{ + Q_UNUSED(now) // les verrous EV vivent dans SmartChargingManager amont, pas dans cet adaptateur. + + LoadRuntimeView v; + v.mechanismKind = QStringLiteral("evcharger"); + v.mechanism.insert(QStringLiteral("chargingEnabled"), m_charger->charging()); + v.mechanism.insert(QStringLiteral("pluggedIn"), m_charger->pluggedIn()); + v.mechanism.insert(QStringLiteral("currentA"), m_charger->maxChargingCurrent()); + v.mechanism.insert(QStringLiteral("phaseCount"), m_charger->phaseCount()); + if (!m_charger->available()) + v.faultCode = QStringLiteral("THING_MISSING"); + return v; +} + LoadAction EvAdapter::applyAction(const LoadAction &action, const QDateTime &now) { if (action.kind != LoadAction::Setpoint) diff --git a/energyplugin/etm/adapters/evadapter.h b/energyplugin/etm/adapters/evadapter.h index ea1ad4e..79801b9 100644 --- a/energyplugin/etm/adapters/evadapter.h +++ b/energyplugin/etm/adapters/evadapter.h @@ -83,6 +83,22 @@ public: //! explicitement plutôt que de laisser un corps vide (règle 7-c). bool clearFault() override { return false; } + /*! + * \brief Vue runtime — déclarée, mais HORS de la charge utile \c GetLoadTelemetry. + * + * \param now Ignoré : les verrous EV (chargingEnabledLock, currentChangeLock) vivent dans + * \c SmartChargingManager amont, pas ici. + * \return \c mechanismKind "evcharger" et l'état de la borne. + * + * \note L'EV n'apparaît PAS dans \c NymeaEnergy.GetLoadTelemetry en beta : il est décidé + * par l'amont EN AMONT du waterfall (décision B), sa puissance est réservée dans + * \c PlanBudget::evReservedW et non allouée depuis le budget publié. L'y faire figurer + * ferait une somme \c loads[] qui ne retomberait pas sur \c budget.allocatedW. La + * méthode est néanmoins IMPLÉMENTÉE, et non laissée en \c Q_UNIMPLEMENTED : quand 3g + * transplantera l'EV dans le waterfall unifié, il n'y aura rien à écrire ici. + */ + LoadRuntimeView runtimeView(const QDateTime &now) const override; + //! \brief Sans effet : l'EV n'est pas construit depuis \c LoadConfig et ne peut donc pas //! être « désactivé » par ce chemin. \param now Ignoré. void applySafeState(const QDateTime &now) override { Q_UNUSED(now) } diff --git a/energyplugin/etm/adapters/iloadadapter.h b/energyplugin/etm/adapters/iloadadapter.h index 434fb24..acc5473 100644 --- a/energyplugin/etm/adapters/iloadadapter.h +++ b/energyplugin/etm/adapters/iloadadapter.h @@ -3,6 +3,7 @@ #pragma once #include +#include #include "../types/loadaction.h" #include "../types/loaddescriptor.h" #include "../types/surpluscontext.h" @@ -16,6 +17,42 @@ struct LoadTelemetry { QDateTime lastActionAt; //!< Dernier instant où applyAction() a produit un effet. }; +/*! + * \brief Vue runtime d'une charge destinée à la FRONTIÈRE DE TÉLÉMÉTRIE (RPC), et à elle seule. + * + * Distincte de \c LoadTelemetry (que l'arbitre lit pour DÉCIDER) et de \c LoadContext (ce que + * le scheduler reçoit). Elle porte ce qui n'a de sens que pour EXPLIQUER une décision à un + * humain : le défaut, le verrou en secondes restantes, et l'état du mécanisme. + * + * \par Des codes, jamais des phrases + * \c faultCode, \c lockKind et \c mechanismKind sont des CODES d'énumération fermée. La + * phrase est fabriquée par le client, dans la langue de son utilisateur — même raison que + * pour \c DecisionReason (cf. \c decisionreason.h). + * + * \par Des secondes, pas des watts + * \c lockMinPowerW / \c lockMaxPowerW (dans \c LoadContextTelemetry) sont un artefact du + * mécanisme d'allocation : illisibles pour un utilisateur. Le temps restant, lui, se lit. + * Ceci n'enfreint pas ECS-306 : cette exigence interdit qu'un index de PALIER franchisse la + * frontière d'ALLOCATION. Une valeur d'AFFICHAGE sortante est un autre chemin, et + * \c stageW / \c state sont des watts et des états normés, pas des index. + * + * \par Union discriminée par mécanisme + * \c mechanismKind discrimine ; \c mechanism porte la charge utile de la variante, et + * d'elle seule (LM-300). Ajouter un mécanisme = une variante ici et son test — rien d'autre. + */ +struct LoadRuntimeView { + //! Code de défaut ; VIDE = aucun défaut. Énumération fermée, cf. \c INTERFACE.md. + QString faultCode; + //! Verrou ACTIF maintenant : "minOn" | "minOff" | "minStateHold" ; vide = aucun. + QString lockKind; + //! Secondes restantes du verrou nommé par \c lockKind ; sans objet si \c lockKind est vide. + int lockRemainingS = 0; + //! Mécanisme : "relay" | "variable" | "sgReady" | "evcharger". + QString mechanismKind; + //! Charge utile du mécanisme (clés propres à la variante) ; jamais celle d'un autre. + QVariantMap mechanism; +}; + /*! * \brief Interface pure des adaptateurs de charge. * @@ -25,7 +62,7 @@ struct LoadTelemetry { * \invariant Les adaptateurs EXÉCUTENT, ils ne décident pas (AGENTS règle 2). * \invariant applyAction() écrête les valeurs selon les limites matérielles réelles * (second filet après l'écrêtage de l'arbitre). - * \invariant applyAction() avec \c reason vide doit être rejetée silencieusement. + * \invariant applyAction() avec un \c reason.code vide doit être rejetée silencieusement. * \invariant Les méthodes non-applyAction() retournent immédiatement (pas de I/O bloquant). * \invariant **Temps = paramètre, jamais l'horloge.** Toute logique temporelle d'un * adaptateur (verrous minOn/minOff, fenêtres, fraîcheur…) utilise EXCLUSIVEMENT le @@ -65,11 +102,11 @@ public: * * L'arbitre a déjà écrêté selon les limites et le budget — ceci est le second filet. * - * \param action Action à appliquer. Doit avoir \c reason non vide. + * \param action Action à appliquer. Doit avoir \c reason.code non vide. * \param now Temps de cycle (\c ctx.timestamp) — MÊME source que toLoadContext(), * pour que l'évaluation des verrous coïncide avec celle vue par le scheduler. * \return L'action après écrêtage matériel (peut différer de l'entrée). - * \note Retour silencieux sans effet si \c action.reason est vide. + * \note Retour silencieux sans effet si \c action.reason.code est vide. */ virtual LoadAction applyAction(const LoadAction &action, const QDateTime &now) = 0; @@ -118,6 +155,23 @@ public: */ virtual bool clearFault() = 0; + /*! + * \brief Vue runtime destinée à la frontière de télémétrie RPC (\c GetLoadTelemetry). + * + * \param now Temps de cycle — MÊME source que \c toLoadContext()/\c applyAction(), afin + * que le verrou annoncé au client soit celui qui a réellement borné la décision. + * \return Défaut, verrou en secondes restantes, et charge utile du mécanisme. + * + * \note **Pure virtuelle à dessein**, sans implémentation par défaut. Un mécanisme ajouté + * qui hériterait d'un défaut vide sortirait de la télémétrie SANS BRUIT : le client + * verrait une charge sans mécanisme et sans verrou, indistinguable d'une charge libre. + * C'est précisément le silence ambigu de la règle 7-c. Le compilateur pose donc la + * question à chaque nouvel adaptateur. + * \note Cette méthode n'entre JAMAIS dans une décision : l'arbitre ne la lit que pour + * publier. Un adaptateur qui s'en servirait pour décider violerait la règle 2. + */ + virtual LoadRuntimeView runtimeView(const QDateTime &now) const = 0; + /*! * \brief Amène le matériel dans l'ÉTAT SÛR de cet adaptateur (ECS-413). * diff --git a/energyplugin/etm/adapters/relayrouter.cpp b/energyplugin/etm/adapters/relayrouter.cpp index 7938906..04ba98c 100644 --- a/energyplugin/etm/adapters/relayrouter.cpp +++ b/energyplugin/etm/adapters/relayrouter.cpp @@ -225,14 +225,26 @@ LoadAction RelayRouter::applyAction(const LoadAction &action, const QDateTime &n applied.powerW = m_levels.at(newStage); applied.estimatedPowerW = m_levels.at(newStage); - if (newStage == m_currentStage) + // Règle 7-c — journaliser L'ISSUE, pas seulement l'issue remarquable. Ce retour était MUET : + // « consigne reçue, palier déjà bon » ne se distinguait pas de « l'action n'est jamais + // parvenue au routeur », et c'est précisément le cas ordinaire qu'on vient vérifier quand + // une charge ne bouge pas. Même niveau que la branche qui commute (7-d, symétrie) — et le + // volume reste borné : une ligne par charge et par cycle, comme l'adaptateur à consigne + // continue en produit déjà une inconditionnellement. + if (newStage == m_currentStage) { + qCInfo(dcNymeaEnergy()) << "[RelayRouter]" << m_label + << "→ consigne" << qRound(action.powerW) << "W → palier" + << m_levels.at(newStage) << "W INCHANGÉ (aucune commutation)" + << (action.force ? "(force)" : "") + << "|" << renderFr(action.reason, m_label); return applied; // idempotent — pas de re-commutation + } qCInfo(dcNymeaEnergy()) << "[RelayRouter]" << m_label << "→ consigne" << qRound(action.powerW) << "W → palier" << m_levels.at(newStage) << "W" << (action.force ? "(force)" : "") - << "|" << action.reason; + << "|" << renderFr(action.reason, m_label); applyRelayStage(newStage); @@ -475,6 +487,36 @@ void RelayRouter::settleTransition() } } +LoadRuntimeView RelayRouter::runtimeView(const QDateTime &now) const +{ + LoadRuntimeView v; + v.mechanismKind = QStringLiteral("relay"); + // Charge utile du mécanisme : des WATTS, jamais un index de palier ni un ThingId de relais. + // La frontière rév. 3 tient aussi dans le sens sortant. + v.mechanism.insert(QStringLiteral("stageW"), currentSetpointW()); + v.mechanism.insert(QStringLiteral("maxStageW"), + m_levels.isEmpty() ? 0.0 : static_cast(m_levels.last())); + + // ECS-410 — le défaut sort en CODE. Il n'a aujourd'hui qu'une cause : l'échelle d'écriture + // épuisée. Une cause nouvelle vaudra un code nouveau, jamais une phrase. + if (m_faulted) + v.faultCode = QStringLiteral("WRITE_FAILED"); + + // Verrou en SECONDES RESTANTES. Le calcul dérive du MÊME m_lastSwitch et du même \c now que + // lockWindow() : ce que le client lit est ce qui a borné la décision, pas une seconde vue. + // m_lastSwitch nul = « commutation venant d'avoir lieu » (ECS-412, armement à froid) — + // même convention que lockWindow(), sans quoi l'affichage contredirait le comportement. + const qint64 elapsed = m_lastSwitch.isValid() ? m_lastSwitch.secsTo(now) : 0; + if (m_currentStage > 0 && elapsed < m_minOnS) { + v.lockKind = QStringLiteral("minOn"); + v.lockRemainingS = static_cast(m_minOnS - elapsed); + } else if (m_currentStage == 0 && elapsed < m_minOffS) { + v.lockKind = QStringLiteral("minOff"); + v.lockRemainingS = static_cast(m_minOffS - elapsed); + } + return v; +} + void RelayRouter::applySafeState(const QDateTime &now) { // ECS-413 — état sûr du routeur : tous relais ouverts. Passe par le chemin d'action @@ -485,7 +527,7 @@ void RelayRouter::applySafeState(const QDateTime &now) sur.kind = LoadAction::Setpoint; sur.powerW = 0; sur.force = true; - sur.reason = QStringLiteral("Charge désactivée — mise en état sûr avant retrait (ECS-413)"); + sur.reason = { DecisionCode::SafeStateRelay }; applyAction(sur, now); } diff --git a/energyplugin/etm/adapters/relayrouter.h b/energyplugin/etm/adapters/relayrouter.h index da51eb5..ea97c1a 100644 --- a/energyplugin/etm/adapters/relayrouter.h +++ b/energyplugin/etm/adapters/relayrouter.h @@ -104,6 +104,15 @@ public: */ bool clearFault() override; + /*! + * \brief Vue runtime pour la télémétrie RPC : palier en watts, défaut, verrou en secondes. + * \param now Temps de cycle — même source que \c lockWindow(), donc le verrou annoncé est + * celui qui a réellement borné la décision. + * \return \c mechanismKind "relay" ; \c mechanism \c {stageW, maxStageW} — des WATTS, + * jamais un index de palier ni un ThingId (la frontière rév. 3 tient aussi en sortie). + */ + LoadRuntimeView runtimeView(const QDateTime &now) const override; + //! \brief État sûr du routeur : TOUS relais ouverts (ECS-413). //! \param now Temps de cycle — l'action passe en \c force = true, verrous bypassés. void applySafeState(const QDateTime &now) override; diff --git a/energyplugin/etm/adapters/sgreadyadapter.cpp b/energyplugin/etm/adapters/sgreadyadapter.cpp index ead599c..0ab3a4a 100644 --- a/energyplugin/etm/adapters/sgreadyadapter.cpp +++ b/energyplugin/etm/adapters/sgreadyadapter.cpp @@ -142,8 +142,16 @@ LoadAction SgReadyAdapter::applyAction(const LoadAction &action, const QDateTime return action; } - if (newState == m_currentState) + // Règle 7-c — même défaut que celui corrigé au RelayRouter : ce retour était MUET, si bien + // que « état déjà bon » ne se distinguait pas de « action jamais reçue ». Une PAC qui ne + // bouge pas est exactement le cas où l'on vient lire le journal. + if (newState == m_currentState) { + qCInfo(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label + << "→ état" << newState << "INCHANGÉ (aucune commutation)" + << (action.force ? "(force)" : "") + << "|" << renderFr(action.reason, m_label); return action; // Idempotent + } // Verrou minStateHold évalué au temps de cycle (même fenêtre que le scheduler) — // bypassé si force == true (L2 watchdog → état 2). @@ -162,7 +170,7 @@ LoadAction SgReadyAdapter::applyAction(const LoadAction &action, const QDateTime qCInfo(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label << "→ état" << newState << "(" << m_estimatedPowerW.value(newState, 0.0) << "W estimés)" - << "|" << action.reason; + << "|" << renderFr(action.reason, m_label); m_statePrev = m_currentState; m_stateTarget = newState; @@ -179,6 +187,38 @@ LoadAction SgReadyAdapter::applyAction(const LoadAction &action, const QDateTime return applied; } +LoadRuntimeView SgReadyAdapter::runtimeView(const QDateTime &now) const +{ + LoadRuntimeView v; + v.mechanismKind = QStringLiteral("sgReady"); + // L'état normé EST la grandeur commandée d'une PAC SG-Ready : 4 états constructeur, non + // exprimables en watts (c'est la raison d'être du kind State). La puissance qui l'accompagne + // est une ESTIMATION déclarée, jamais un engagement — le nom de la clé le dit. + v.mechanism.insert(QStringLiteral("state"), m_currentState); + v.mechanism.insert(QStringLiteral("estimatedPowerW"), + m_estimatedPowerW.value(m_currentState, 0.0)); + + // ECS-414 et ECS-110 — deux indisponibilités de natures opposées, deux codes. Un encodage + // sans état 2 refuse TOUTE commande depuis le constructeur : le taire ferait passer une + // PAC jamais pilotable pour une PAC au repos. + if (m_faulted) + v.faultCode = QStringLiteral("WRITE_FAILED"); + else if (!m_usable) + v.faultCode = QStringLiteral("UNUSABLE_ENCODING"); + + // Verrou en secondes restantes, calculé sur le MÊME m_lastSwitch que lockWindow(). Un + // m_lastSwitch nul vaut « jamais commuté » et ne verrouille rien — convention identique + // à lockWindow(), pour que l'affichage ne contredise pas le comportement. + if (m_lastSwitch.isValid()) { + const qint64 elapsed = m_lastSwitch.secsTo(now); + if (elapsed < m_minStateHoldS) { + v.lockKind = QStringLiteral("minStateHold"); + v.lockRemainingS = static_cast(m_minStateHoldS - elapsed); + } + } + return v; +} + void SgReadyAdapter::applySafeState(const QDateTime &now) { // ECS-413 — état sûr d'une PAC = état 2 (normal, mains off). PAS l'état 1 : bloquer une @@ -189,7 +229,7 @@ void SgReadyAdapter::applySafeState(const QDateTime &now) sur.kind = LoadAction::State; sur.state = 2; sur.force = true; - sur.reason = QStringLiteral("Charge désactivée — mise en état sûr (état 2) avant retrait (ECS-413)"); + sur.reason = { DecisionCode::SafeStateSgReady }; applyAction(sur, now); // force = true : minStateHoldS vaut 900 s, attendre est indéfendable } diff --git a/energyplugin/etm/adapters/sgreadyadapter.h b/energyplugin/etm/adapters/sgreadyadapter.h index 23475f2..4822f45 100644 --- a/energyplugin/etm/adapters/sgreadyadapter.h +++ b/energyplugin/etm/adapters/sgreadyadapter.h @@ -111,6 +111,14 @@ public: //! \return Vrai si la PAC est en défaut (plus aucune commande émise). bool faulted() const { return m_faulted; } + /*! + * \brief Vue runtime pour la télémétrie RPC : état normé, estimation, défaut, verrou. + * \param now Temps de cycle — même source que \c lockWindow(), donc les secondes annoncées + * sont celles du verrou qui a réellement borné la décision. + * \return \c mechanismKind "sgReady" ; \c mechanism \c {state, estimatedPowerW}. + */ + LoadRuntimeView runtimeView(const QDateTime &now) const override; + /*! * \brief État sûr de la PAC : **état 2** (normal, mains off) — JAMAIS l'état 1 (blocage). * \param now Temps de cycle ; l'action passe en \c force = true (bypass minStateHold). diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 9fe309a..aa78fab 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -9,6 +9,7 @@ #include "config/loadconfigstore.h" #include "types/loadconfig.h" #include "scheduler/rulebasedscheduler.h" +#include "types/decisionreason.h" #include "types/surpluscontext.h" #include "types/plan.h" #include "../rootmeter.h" @@ -24,6 +25,17 @@ namespace { constexpr int MeterWatchdogPeriodMs = 30 * 1000; // 30 s //! Seuil de silence compteur au-delà duquel le mode dégradé L2 est déclenché. constexpr int MeterSilenceThresholdS = 90; // 90 s + +//! Battement de cœur de la télémétrie : période MAXIMALE entre deux notifications, même sans +//! changement. C'est ce qui permet au client de dater la dernière décision et donc de repérer +//! un arbitre figé — un état qui ne bouge pas n'émet rien de lui-même. +constexpr int TelemetryHeartbeatPeriodMs = 60 * 1000; // 60 s + +//! Seuil d'allocation (W) en deçà duquel une variation ne déclenche PAS de notification. +//! Une charge à consigne continue module en permanence de quelques watts ; sans seuil, chaque +//! oscillation réveillerait tous les clients abonnés. 50 W est en dessous du plus petit palier +//! réel d'un ECS (≈ 500 W) et au-dessus du bruit de mesure d'un routeur. +constexpr double TelemetryAllocationThresholdW = 50.0; } EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm, @@ -50,6 +62,14 @@ EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm, m_meterWatchdog->setInterval(MeterWatchdogPeriodMs); connect(m_meterWatchdog, &QTimer::timeout, this, &EnergyArbitrator::onMeterWatchdogTick); m_meterWatchdog->start(); + + // Battement de cœur de la télémétrie. QTimer d'horloge murale, comme le watchdog et pour la + // MÊME raison : il doit continuer à battre quand le cycle d'arbitrage, lui, s'est arrêté. + // Un battement piloté par le cycle ne pourrait pas révéler l'arrêt du cycle. + m_telemetryHeartbeat = new QTimer(this); + m_telemetryHeartbeat->setInterval(TelemetryHeartbeatPeriodMs); + connect(m_telemetryHeartbeat, &QTimer::timeout, this, &EnergyArbitrator::publishTelemetryHeartbeat); + m_telemetryHeartbeat->start(); #else Q_UNUSED(em) #endif @@ -368,9 +388,15 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime) Plan plan = m_scheduler->getPlan(ctx); Slot slot = plan.slotCovering(currentDateTime); + // Le LIBELLÉ n'est pas dans le motif : il est substitué AU RENDU (cf. decisionreason.h). + // On le prend dans le contexte du cycle, seul endroit qui connaisse toutes les charges. + QHash libelles; + for (const LoadContext &lc : ctx.loads) + libelles.insert(lc.id, lc.label); + for (const LoadAction &action : slot.actions) { qCInfo(dcNymeaEnergy()) << "[Arbitre]" - << action.loadId << "→" << action.reason + << action.loadId << "→" << renderFr(action.reason, libelles.value(action.loadId)) << "| activé:" << action.chargingEnabled << "| courant:" << action.currentA << "A" << "| phases:" << action.phaseCount @@ -380,6 +406,13 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime) // 7 : dispatch matériel (même position que l'amont — m_chargingActions rempli par getPlan()) applyActionsToAdapters(slot, currentDateTime); // PAC (kind==State) → m_sgReadyAdapters adjustEvChargers(currentDateTime); // EV (kind==Setpoint) → proxy amont jusqu'à 3g + + // 8 : frontière de télémétrie. APRÈS le dispatch, jamais avant : la charge utile de mécanisme + // (palier appliqué, état PAC) doit refléter ce qui a réellement été envoyé au matériel, pas + // ce que la stratégie espérait. Publier avant produirait un instantané qu'aucun relais ne + // confirme — la même famille de défaut qu'annoncer une puissance non appliquée (ECS-410). + buildTelemetry(plan, slot, currentDateTime); + publishTelemetry(false); } SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const @@ -476,6 +509,14 @@ void EnergyArbitrator::recordMeterUpdate(const QDateTime &now) qCInfo(dcNymeaEnergy()) << "[Arbitre] Compteur de nouveau actif — sortie du mode dégradé L2."; m_degradedMode = false; emit chargingSchedulesChanged(); // pousse degradedMode=false (planif reprend au cycle suivant) + + // Symétrie de la bascule : la SORTIE se publie aussi tout de suite. La planification ne + // reprend qu'au cycle suivant ; sans cette republication, la télémétrie annoncerait + // encore degradedMode=true pendant une minute entière, après que le repli a été levé. + if (!m_telemetry.isEmpty()) { + m_telemetry.insert(QStringLiteral("degradedMode"), false); + publishTelemetry(true); + } } } @@ -500,8 +541,7 @@ void EnergyArbitrator::applyDegradedMode(const QDateTime &now) { m_degradedMode = true; emit chargingSchedulesChanged(); // pousse degradedMode=true (notification client L2) - const QString reason = - QStringLiteral("Compteur muet depuis >90 s — consigne de repli (L2 watchdog)"); + const DecisionReason reason = { DecisionCode::DegradedL2 }; // Charges pilotées en watts (relay-router ET etmvariableload) : repli Setpoint(0) force=true // (contrat rév. 3 §9). Boucle sur TOUS les ILoadAdapter — ne PAS oublier un type, sinon des @@ -541,4 +581,201 @@ void EnergyArbitrator::applyDegradedMode(const QDateTime &now) } // Batterie (aucune charge réseau) : repli ajouté avec son adaptateur (3f). + + // Reprise à froid (critère ECS-411) : un client qui se connecte APRÈS la bascule doit lire + // degradedMode. update() rend la main avant toute publication tant que le repli tient ; sans + // cet instantané forcé, la frontière de télémétrie resterait figée sur l'avant-dernier cycle + // NORMAL, et publierait donc l'inverse de la vérité. + buildDegradedTelemetry(now); + publishTelemetry(true); +} + +// ---- Frontière de télémétrie (NymeaEnergy.GetLoadTelemetry / LoadTelemetryChanged) --------- + +void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const QDateTime &now) +{ + QVariantMap snap; + // ISO 8601 UTC — auto-descriptif et sans ambiguïté de fuseau. C'est la FIN du cycle + // d'arbitrage : deux notifications au même timestamp disent « rien n'a été décidé entre-temps ». + snap.insert(QStringLiteral("timestamp"), now.toUTC().toString(Qt::ISODate)); + snap.insert(QStringLiteral("degradedMode"), m_degradedMode); + + QVariantMap budget; + budget.insert(QStringLiteral("surplusW"), plan.budget.surplusW); + budget.insert(QStringLiteral("evReservedW"), plan.budget.evReservedW); + budget.insert(QStringLiteral("allocatedW"), plan.budget.allocatedW); + budget.insert(QStringLiteral("recreditedW"), plan.budget.recreditedW); + budget.insert(QStringLiteral("remainingW"), plan.budget.remainingW); + snap.insert(QStringLiteral("budget"), budget); + + // On itère les ACTIONS, pas les tables d'adaptateurs : l'ordre du waterfall (rang 1 servi en + // premier) est porté par le slot, et QHash n'en garantit aucun. Un ordre instable ferait + // aussi diverger la comparaison de seuil d'un cycle à l'autre, pour rien. + QVariantList loads; + QSet couvertes; + for (const LoadAction &action : slot.actions) { + ILoadAdapter *adapter = m_loadAdapters.value(action.loadId, nullptr); + if (!adapter) + adapter = m_sgReadyAdapters.value(action.loadId, nullptr); + if (!adapter) + continue; // EV : décidé en amont du waterfall, hors de cette charge utile. + couvertes.insert(action.loadId); + + QVariantMap entry; + entry.insert(QStringLiteral("loadId"), action.loadId); + // ALLOCATION du waterfall, celle qui a décrémenté le budget — pas la mesure. La mesure + // 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); + entry.insert(QStringLiteral("available"), adapter->telemetry().available); + entry.insert(QStringLiteral("decision"), action.reason.toMap()); + + const LoadRuntimeView v = adapter->runtimeView(now); + if (!v.faultCode.isEmpty()) + entry.insert(QStringLiteral("faultCode"), v.faultCode); + if (!v.lockKind.isEmpty()) { + QVariantMap lock; + lock.insert(QStringLiteral("kind"), v.lockKind); + lock.insert(QStringLiteral("remainingS"), v.lockRemainingS); + entry.insert(QStringLiteral("lock"), lock); + } + if (!v.mechanismKind.isEmpty()) { + QVariantMap mech = v.mechanism; + mech.insert(QStringLiteral("kind"), v.mechanismKind); + entry.insert(QStringLiteral("mechanism"), mech); + } + loads.append(entry); + } + + // Règle 7-c — une charge arbitrée sans décision serait un TROU dans la charge utile, et le + // 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. + 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() + << "arbitrée mais SANS décision dans le plan — omise."; + for (auto it = m_sgReadyAdapters.constBegin(); it != m_sgReadyAdapters.constEnd(); ++it) + if (!couvertes.contains(it.key())) + qCWarning(dcNymeaEnergy()) << "[Arbitre] Télémétrie : charge" << it.key() + << "arbitrée mais SANS décision dans le plan — omise."; + + snap.insert(QStringLiteral("loads"), loads); + m_telemetry = snap; +} + +void EnergyArbitrator::buildDegradedTelemetry(const QDateTime &now) +{ + QVariantMap snap; + snap.insert(QStringLiteral("timestamp"), now.toUTC().toString(Qt::ISODate)); + snap.insert(QStringLiteral("degradedMode"), true); + // PAS de « budget » : en mode dégradé la planification est SUSPENDUE, il n'existe aucun + // budget pour ce cycle. Publier des zéros se lirait « arbitrage exécuté, rien à allouer » — + // le contraire de la vérité. Le champ est omis, jamais nul (règle maison, cf. GetEnergyRatios). + + QVariantList loads; + auto entree = [&](const QString &id, ILoadAdapter *adapter) { + QVariantMap entry; + entry.insert(QStringLiteral("loadId"), id); + entry.insert(QStringLiteral("allocatedW"), 0.0); + entry.insert(QStringLiteral("available"), adapter->telemetry().available); + entry.insert(QStringLiteral("decision"), DecisionReason(DecisionCode::DegradedL2).toMap()); + const LoadRuntimeView v = adapter->runtimeView(now); + if (!v.faultCode.isEmpty()) + entry.insert(QStringLiteral("faultCode"), v.faultCode); + if (!v.lockKind.isEmpty()) { + QVariantMap lock; + lock.insert(QStringLiteral("kind"), v.lockKind); + lock.insert(QStringLiteral("remainingS"), v.lockRemainingS); + entry.insert(QStringLiteral("lock"), lock); + } + if (!v.mechanismKind.isEmpty()) { + QVariantMap mech = v.mechanism; + mech.insert(QStringLiteral("kind"), v.mechanismKind); + entry.insert(QStringLiteral("mechanism"), mech); + } + loads.append(entry); + }; + // Ordre STABLE : QHash n'en garantit aucun, et un instantané se compare. + QStringList ids = m_loadAdapters.keys(); + ids.sort(); + for (const QString &id : ids) + entree(id, m_loadAdapters.value(id)); + QStringList sgIds = m_sgReadyAdapters.keys(); + sgIds.sort(); + for (const QString &id : sgIds) + entree(id, m_sgReadyAdapters.value(id)); + + snap.insert(QStringLiteral("loads"), loads); + m_telemetry = snap; +} + +void EnergyArbitrator::publishTelemetry(bool force) +{ + if (m_telemetry.isEmpty()) + return; // aucun cycle encore exécuté — rien à dire, et rien à inventer. + if (!force && !telemetrySignificantlyDiffers(m_publishedTelemetry, m_telemetry)) + return; + + m_publishedTelemetry = m_telemetry; + m_lastTelemetryPublish = QDateTime::currentDateTime(); + // Le battement est un PLANCHER de cadence, pas un ajout : toute notification relance son + // décompte. Sur une box qui cycle chaque minute, le total reste donc d'environ UNE + // notification par minute — ce que la cible RPi Zero 2W tient sans y penser. Porter cette + // règle par le timer plutôt que par un test d'horloge dans le battement le rend aussi + // injectable : un test peut le déclencher sans attendre soixante secondes. + if (m_telemetryHeartbeat) + m_telemetryHeartbeat->start(); + emit loadTelemetryChanged(m_telemetry); +} + +void EnergyArbitrator::publishTelemetryHeartbeat() +{ + publishTelemetry(true); +} + +bool EnergyArbitrator::telemetrySignificantlyDiffers(const QVariantMap &a, const QVariantMap &b) +{ + if (a.isEmpty()) + return true; // première publication + if (a.value(QStringLiteral("degradedMode")) != b.value(QStringLiteral("degradedMode"))) + return true; + if (a.contains(QStringLiteral("budget")) != b.contains(QStringLiteral("budget"))) + return true; // entrée ou sortie du mode où il n'y a pas de budget + + const double allocA = a.value(QStringLiteral("budget")).toMap() + .value(QStringLiteral("allocatedW")).toDouble(); + const double allocB = b.value(QStringLiteral("budget")).toMap() + .value(QStringLiteral("allocatedW")).toDouble(); + if (qAbs(allocA - allocB) > TelemetryAllocationThresholdW) + return true; + + const QVariantList la = a.value(QStringLiteral("loads")).toList(); + const QVariantList lb = b.value(QStringLiteral("loads")).toList(); + if (la.size() != lb.size()) + return true; + + for (int i = 0; i < la.size(); ++i) { + const QVariantMap ea = la.at(i).toMap(); + const QVariantMap eb = lb.at(i).toMap(); + // Identité, disponibilité, défaut, NATURE du verrou, mécanisme, CODE du motif : tout + // changement qualitatif se notifie immédiatement. + for (const char *k : {"loadId", "available", "faultCode"}) + if (ea.value(QLatin1String(k)) != eb.value(QLatin1String(k))) + return true; + if (ea.value(QStringLiteral("lock")).toMap().value(QStringLiteral("kind")) + != eb.value(QStringLiteral("lock")).toMap().value(QStringLiteral("kind"))) + return true; // les SECONDES restantes, elles, décroissent en continu : ignorées. + if (ea.value(QStringLiteral("mechanism")).toMap().value(QStringLiteral("kind")) + != eb.value(QStringLiteral("mechanism")).toMap().value(QStringLiteral("kind"))) + return true; + if (ea.value(QStringLiteral("decision")).toMap().value(QStringLiteral("code")) + != eb.value(QStringLiteral("decision")).toMap().value(QStringLiteral("code"))) + return true; // les PARAMÈTRES du motif portent le budget, qui bouge à chaque cycle. + if (qAbs(ea.value(QStringLiteral("allocatedW")).toDouble() + - eb.value(QStringLiteral("allocatedW")).toDouble()) + > TelemetryAllocationThresholdW) + return true; + } + return false; } diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index 14fe66f..17c96e3 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -11,6 +11,7 @@ #include "types/loadconfig.h" #include +#include class QTimer; @@ -157,6 +158,52 @@ public: */ void evaluateMeterFreshness(const QDateTime &now); + /*! + * \brief Dernier instantané de télémétrie d'arbitrage — CE QUI A ÉTÉ PUBLIÉ, pas un recalcul. + * + * \return La charge utile de \c NymeaEnergy.GetLoadTelemetry : \c timestamp (fin du dernier + * cycle d'arbitrage), \c degradedMode, \c budget, \c loads[]. Vide tant qu'aucun cycle + * n'a eu lieu (démarrage) — voir la note ci-dessous. + * + * \note **Snapshot ≡ notification, par construction.** C'est le MÊME objet que celui émis + * par \c loadTelemetryChanged() ; il n'est pas recalculé à la demande. Un recalcul + * donnerait des verrous rafraîchis sous un \c timestamp périmé — un état qui n'a jamais + * existé, et que le client ne pourrait rapprocher d'aucune notification. + * \note **Vide avant le premier cycle.** Un instantané forgé au démarrage (budget nul, + * aucune charge) se lirait comme « cycle exécuté, rien à allouer » — exactement le + * mensonge que \c timestamp existe pour empêcher. La carte vide est la seule réponse + * vraie ; le handler la rend telle quelle, sans \c timestamp (règle « omis, jamais nul »). + */ + QVariantMap loadTelemetry() const { return m_telemetry; } + + /*! + * \brief Battement de cœur : republie l'instantané COURANT sans condition ni recalcul. + * + * C'est ce qui rend un arbitre FIGÉ détectable : les notifications continuent d'arriver + * tandis que leur \c timestamp cesse d'avancer. Sans battement, « installation stable » et + * « moteur arrêté » se ressemblent — un silence, donc un silence ambigu (règle 7-c). + * + * \note Logique injectable (symétrique de \c evaluateMeterFreshness) : en production + * appelée par un \c QTimer d'horloge murale câblé sous \c \#ifndef ENERGY_SIMULATION ; + * en simulation/test appelée directement. Le \c QTimer ne peut pas dépendre du cycle + * d'arbitrage : c'est précisément l'arrêt de ce cycle qu'il sert à révéler. + * \note Le battement ne double pas une notification fraîche : chaque publication RELANCE + * le timer. La règle vit donc dans le timer, et cette méthode reste inconditionnelle — + * testable sans attendre une période entière. + */ + void publishTelemetryHeartbeat(); + +signals: + /*! + * \brief Émise quand l'état runtime d'arbitrage a CHANGÉ, ou au battement de cœur. + * \param telemetry Charge utile identique à celle de \c loadTelemetry(). + * \note « Changé » exclut les grandeurs qui bougent en continu : les secondes restantes + * d'un verrou et les paramètres numériques d'un motif ne déclenchent rien, et une + * variation d'allocation inférieure au seuil non plus. Voir + * \c telemetrySignificantlyDiffers(). + */ + void loadTelemetryChanged(const QVariantMap &telemetry); + protected: /*! * \brief Boucle principale ETM — surcharge SmartChargingManager::update(). @@ -249,6 +296,46 @@ private: */ void applyDegradedMode(const QDateTime &now); + /*! + * \brief Construit l'instantané de télémétrie du cycle qui vient de s'exécuter. + * + * \param plan Plan retourné par le scheduler — source de \c budget (jamais recalculé ici). + * \param slot Créneau courant : ses actions donnent l'ordre du waterfall ET les motifs. + * \param now Temps de cycle, publié tel quel comme \c timestamp. + * \note Les charges EV sont EXCLUES : décidées par l'amont en amont du waterfall (décision + * B), leur puissance est réservée dans \c budget.evReservedW, pas allouée depuis le + * budget publié. Les inclure ferait une somme \c loads[] qui ne retomberait pas sur + * \c budget.allocatedW. + */ + void buildTelemetry(const Plan &plan, const Slot &slot, const QDateTime &now); + + /*! + * \brief Construit l'instantané du mode dégradé L2 et le publie sans condition. + * \param now Instant de la bascule — c'est bien une décision, elle se date. + * \note Sans cet appel, un client qui se connecte APRÈS la bascule ne lirait jamais + * \c degradedMode : \c update() rend la main avant toute publication tant que le repli + * tient. C'est la reprise à froid, et c'est la famille ECS-411. + */ + void buildDegradedTelemetry(const QDateTime &now); + + /*! + * \brief Publie \c m_telemetry si elle diffère significativement de la dernière publiée. + * \param force Vrai pour publier sans condition (bascule de mode dégradé, battement). + */ + void publishTelemetry(bool force); + + /*! + * \brief Vrai si deux instantanés diffèrent assez pour mériter une notification. + * \param a Dernier instantané publié. \param b Instantané courant. + * \return Vrai sur tout changement d'état QUALITATIF (mode dégradé, jeu de charges, + * disponibilité, code de défaut, nature du verrou, mécanisme, CODE de motif) ou sur une + * variation d'allocation dépassant le seuil. + * \note Sont volontairement IGNORÉS : les secondes restantes d'un verrou (elles décroissent + * à chaque seconde), et les paramètres numériques d'un motif (le budget bouge à chaque + * cycle). Les comparer ferait notifier en permanence et le seuil ne servirait à rien. + */ + static bool telemetrySignificantlyDiffers(const QVariantMap &a, const QVariantMap &b); + RuleBasedScheduler *m_scheduler = nullptr; QHash m_adapters; //!< loadId (ThingId string) → EvAdapter*. QHash m_sgReadyAdapters; //!< loadId → SgReadyAdapter* (PAC). @@ -263,4 +350,10 @@ private: QTimer *m_meterWatchdog = nullptr; //!< Tick 30 s, indépendant des signaux compteur. QDateTime m_lastMeterUpdate; //!< Horodatage du dernier powerBalanceChanged. bool m_degradedMode = false; //!< Vrai si les consignes de repli L2 sont actives. + + // --- Frontière de télémétrie (NymeaEnergy.GetLoadTelemetry / LoadTelemetryChanged) --- + QVariantMap m_telemetry; //!< Instantané courant ; vide avant le premier cycle. + QVariantMap m_publishedTelemetry; //!< Dernier instantané ÉMIS — base de comparaison du seuil. + QDateTime m_lastTelemetryPublish;//!< Horloge murale du dernier envoi (cadence du battement). + QTimer *m_telemetryHeartbeat = nullptr; //!< Battement 60 s, indépendant du cycle d'arbitrage. }; diff --git a/energyplugin/etm/etm.pri b/energyplugin/etm/etm.pri index 5068a5a..bf6f6f4 100644 --- a/energyplugin/etm/etm.pri +++ b/energyplugin/etm/etm.pri @@ -1,5 +1,6 @@ HEADERS += \ $$PWD/types/loadaction.h \ + $$PWD/types/decisionreason.h \ $$PWD/types/loaddescriptor.h \ $$PWD/types/surpluscontext.h \ $$PWD/types/plan.h \ @@ -16,6 +17,7 @@ HEADERS += \ $$PWD/energyarbitrator.h \ SOURCES += \ + $$PWD/types/decisionreason.cpp \ $$PWD/types/loadconfig.cpp \ $$PWD/config/loadconfigstore.cpp \ $$PWD/ratios/energyratioscalculator.cpp \ diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index 6989fe6..165c4d2 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -51,7 +51,7 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) la.chargingEnabled = true; la.currentA = ca.maxChargingCurrent(); la.phaseCount = ca.desiredPhaseCount(); - la.reason = QStringLiteral("Surplus PV disponible — recharge solaire"); + la.reason = { DecisionCode::EvSurplus }; la.estimatedPowerW = la.currentA * 230.0 * la.phaseCount; } else if (actions.value(ChargingAction::ChargingActionIssuerSpotMarketCharging).chargingEnabled()) { @@ -62,7 +62,7 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) la.chargingEnabled = true; la.currentA = ca.maxChargingCurrent(); la.phaseCount = ca.desiredPhaseCount(); - la.reason = QStringLiteral("Tarif aWATTar favorable — recharge heure creuse"); + la.reason = { DecisionCode::EvSpotMarket }; la.estimatedPowerW = la.currentA * 230.0 * la.phaseCount; } else { @@ -96,6 +96,14 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) // transitoire sous minOn est géré par le verrou de l'adaptateur, pas par le budget.) double remainingSurplusW = (ctx.meter.exportW - ctx.meter.importW) - evReservedW; + // Comptabilité PUBLIÉE du budget (télémétrie RPC). Elle s'alimente DANS la cascade, + // pendant que celle-ci se déroule : c'est le seul endroit qui sache ce qui a réellement + // été alloué et ce qui a été recrédité. La recalculer ailleurs donnerait deux comptes à + // tenir d'accord, et le chiffre publié serait le premier à mentir. + PlanBudget compta; + compta.surplusW = remainingSurplusW; + compta.evReservedW = evReservedW; + // Charges pilotables non-EV triées par priorité ASCENDANTE : rang 1 = premier servi // (OPTIMIZER_PROTOCOL §5 + annexe C — la priorité est un rang). Le budget de surplus est // UNIQUE et cascade à travers TOUTES ces charges par priorité : @@ -115,9 +123,9 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) for (const LoadContext &lc : nonEvLoads) { if (lc.adapter == QStringLiteral("sg-ready")) - slot.actions.append(buildSgReadyStateAction(lc, remainingSurplusW)); + slot.actions.append(buildSgReadyStateAction(lc, remainingSurplusW, compta)); else - slot.actions.append(buildSetpointAction(lc, remainingSurplusW)); // etmvariableload + relay-router + slot.actions.append(buildSetpointAction(lc, remainingSurplusW, compta)); // etmvariableload + relay-router } // Grid funding (ECS/PAC) : dormant jusqu'à 3f (waterfall réseau) — non implémenté ici. @@ -126,6 +134,9 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) plan.planId = QUuid::createUuid().toString(QUuid::WithoutBraces); plan.strategy = QStringLiteral("rule-based"); plan.timeSlots.append(slot); + + compta.remainingW = remainingSurplusW; + plan.budget = compta; return plan; } @@ -141,7 +152,7 @@ LoadAction RuleBasedScheduler::buildTimeRequirementAction(EvCharger *ev, la.chargingEnabled = true; la.currentA = ca.maxChargingCurrent(); la.phaseCount = ca.desiredPhaseCount(); - la.reason = QStringLiteral("Deadline VE approchante — recharge prioritaire"); + la.reason = { DecisionCode::EvDeadline }; la.estimatedPowerW = la.currentA * 230.0 * la.phaseCount; return la; } @@ -158,7 +169,7 @@ LoadAction RuleBasedScheduler::buildMinCurrentAction(EvCharger *ev) const la.chargingEnabled = true; la.currentA = minA; la.phaseCount = phases; - la.reason = QStringLiteral("Aucun surplus — courant minimum maintenu (mode EcoMin)"); + la.reason = { DecisionCode::EvEcoMin }; la.estimatedPowerW = la.currentA * 230.0 * la.phaseCount; return la; } @@ -172,18 +183,20 @@ LoadAction RuleBasedScheduler::buildIdleAction(EvCharger *ev) const la.chargingEnabled = false; la.currentA = 0; la.phaseCount = 0; - la.reason = QStringLiteral("Aucun surplus disponible — recharge suspendue"); + la.reason = { DecisionCode::EvIdle }; la.estimatedPowerW = 0; return la; } LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc, - double &remainingSurplusW) const + double &remainingSurplusW, + PlanBudget &budget) const { // Recrédit anti-clignotement (Correction B / contrat rév. 2 §5) : la conso de DÉBUT de cycle // est déjà soustraite de l'export mesuré → on la rend au budget local avant d'arrondir. // PAS de relecture post-setpoint (invariant 8 : aucune boucle de feedback). const double budgetW = remainingSurplusW + lc.telemetry.currentPowerW; + budget.recreditedW += lc.telemetry.currentPowerW; const QList &levels = lc.declared.powerLevels; const bool dynamic = levels.isEmpty(); @@ -220,10 +233,12 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc, la.funding = LoadAction::Surplus; la.powerW = gele; la.estimatedPowerW = gele; - la.reason = QStringLiteral("%1 INDISPONIBLE — aucune commande émise ; %2 W toujours " - "comptés au budget (levée par ClearLoadFault)") - .arg(lc.label).arg(qRound(gele)); - remainingSurplusW = budgetW - gele; + // Le LIBELLÉ n'entre pas dans les paramètres : c'est de la langue naturelle saisie + // par l'installateur, et la charge utile RPC doit en rester exempte. Il est substitué + // au rendu, par renderFr(reason, label). + la.reason = { DecisionCode::LoadUnavailable, {{"frozenW", qRound(gele)}} }; + remainingSurplusW = budgetW - gele; + budget.allocatedW += gele; // une puissance qu'on ne sait plus couper reste allouée return la; } @@ -262,34 +277,35 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc, // suivant un palier 0. Les deux branches se lisent maintenant sur le même axe : // le verrou a relevé la consigne (minOn), ou il l'a rabaissée (minOff). if (setpointW > vouluW) - la.reason = QStringLiteral("Verrou minOn — %1 maintenue à %2 W (puissance engagée, " - "budget %3 W) ; le résidu en tient compte") - .arg(lc.label).arg(qRound(setpointW)).arg(qRound(budgetW)); + la.reason = { DecisionCode::LockMinOn, + {{"appliedW", qRound(setpointW)}, {"budgetW", qRound(budgetW)}} }; else if (setpointW < vouluW) // Le budget payait vouluW ; c'est la fenêtre minOff qui interdit de monter. Le motif // cite le budget DISPONIBLE, précisément pour couper court à l'explication budgétaire. la.reason = setpointW <= 0 - ? QStringLiteral("Verrou minOff — %1 ne peut pas s'enclencher avant la fin de la " - "fenêtre (budget %2 W disponible, palier %3 W refusé)") - .arg(lc.label).arg(qRound(budgetW)).arg(qRound(vouluW)) - : QStringLiteral("Verrou minOff — %1 plafonnée à %2 W avant la fin de la fenêtre " - "(budget %3 W disponible, palier %4 W refusé)") - .arg(lc.label).arg(qRound(setpointW)).arg(qRound(budgetW)).arg(qRound(vouluW)); + ? DecisionReason(DecisionCode::LockMinOff, + {{"budgetW", qRound(budgetW)}, {"requestedW", qRound(vouluW)}}) + : DecisionReason(DecisionCode::LockMinOffCapped, + {{"appliedW", qRound(setpointW)}, {"budgetW", qRound(budgetW)}, + {"requestedW", qRound(vouluW)}}); else if (setpointW > 0) - la.reason = QStringLiteral("Surplus PV %1 W — %2 consigne %3 W%4") - .arg(qRound(budgetW)).arg(lc.label).arg(qRound(setpointW)) - .arg(dynamic ? QString() : QStringLiteral(" (palier)")); + // « stepped » remplace le suffixe « (palier) » composé en dur : le client décide + // comment il le dit, la box dit seulement que la consigne est arrondie sur un palier. + la.reason = { DecisionCode::SurplusSetpoint, + {{"budgetW", qRound(budgetW)}, {"setpointW", qRound(setpointW)}, + {"stepped", !dynamic}} }; else - la.reason = QStringLiteral("Surplus insuffisant (%1 W) — %2 à 0 W") - .arg(qRound(budgetW)).arg(lc.label); + la.reason = { DecisionCode::SurplusInsufficient, {{"budgetW", qRound(budgetW)}} }; // Résidu : budget − consigne engagée → charge suivante de la priorité (même cycle). - remainingSurplusW = budgetW - setpointW; + remainingSurplusW = budgetW - setpointW; + budget.allocatedW += setpointW; return la; } LoadAction RuleBasedScheduler::buildSgReadyStateAction(const LoadContext &lc, - double &remainingSurplusW) const + double &remainingSurplusW, + PlanBudget &budget) const { const int currentState = lc.telemetry.state; const double p3 = lc.declared.estimatedPowerW.value(3, 0.0); @@ -299,6 +315,7 @@ LoadAction RuleBasedScheduler::buildSgReadyStateAction(const LoadContext &lc, // revient au budget — comme l'ECS. Base : "quel surplus si la PAC n'était pas pilotée ?" const double allocatedNowW = lc.declared.estimatedPowerW.value(currentState, 0.0); const double budgetW = remainingSurplusW + allocatedNowW; + budget.recreditedW += allocatedNowW; // Mapping SÉMANTIQUE (pas "le plus haut qui rentre") avec hystérésis d'état anti-oscillation : // monter en 4 si budget ≥ P4×1,2 ; rester en 4 tant que budget ≥ P4×1,0 (zone morte) ; @@ -335,19 +352,18 @@ LoadAction RuleBasedScheduler::buildSgReadyStateAction(const LoadContext &lc, la.estimatedPowerW = lc.declared.estimatedPowerW.value(bestState, 0.0); if (bestState != targetState) - la.reason = QStringLiteral("Verrou minStateHold — %1 maintenue état %2 (court-cycling PAC)") - .arg(lc.label).arg(bestState); + la.reason = { DecisionCode::LockMinStateHold, {{"state", bestState}} }; else if (bestState == 4) - la.reason = QStringLiteral("Surplus abondant %1 W — %2 forcée (état 4, ~%3 W)") - .arg(qRound(budgetW)).arg(lc.label).arg(qRound(p4)); + la.reason = { DecisionCode::SgForced, + {{"budgetW", qRound(budgetW)}, {"estimatedW", qRound(p4)}} }; else if (bestState == 3) - la.reason = QStringLiteral("Surplus PV %1 W — %2 recommandée (état 3, ~%3 W)") - .arg(qRound(budgetW)).arg(lc.label).arg(qRound(p3)); + la.reason = { DecisionCode::SgRecommended, + {{"budgetW", qRound(budgetW)}, {"estimatedW", qRound(p3)}} }; else - la.reason = QStringLiteral("Surplus insuffisant (%1 W) — %2 en normal (état 2, non pilotée)") - .arg(qRound(budgetW)).arg(lc.label); + la.reason = { DecisionCode::SgNormal, {{"budgetW", qRound(budgetW)}} }; // Budget restant : on ne soustrait que la puissance ALLOUÉE (états 1/2 = 0). - remainingSurplusW = budgetW - la.estimatedPowerW; + remainingSurplusW = budgetW - la.estimatedPowerW; + budget.allocatedW += la.estimatedPowerW; return la; } diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.h b/energyplugin/etm/scheduler/rulebasedscheduler.h index 0c5b6a1..8ebb4a6 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.h +++ b/energyplugin/etm/scheduler/rulebasedscheduler.h @@ -97,9 +97,14 @@ private: * * \param lc Charge \c etmvariableload du SurplusContext. * \param[in,out] remainingSurplusW Budget de surplus restant (W) ; mis à jour pour la suite. + * \param[in,out] budget Comptabilité PUBLIÉE : \c allocatedW et \c recreditedW sont + * accumulés ici, dans la cascade même. Le recrédit y figure parce que sans lui + * l'identité \c surplus + \c recrédit − \c alloué == \c restant serait fausse dès + * qu'une charge consomme déjà — c'est-à-dire dans le cas normal. * \return LoadAction kind=Setpoint, funding=Surplus, \c powerW = consigne, \c reason non vide. */ - LoadAction buildSetpointAction(const LoadContext &lc, double &remainingSurplusW) const; + LoadAction buildSetpointAction(const LoadContext &lc, double &remainingSurplusW, + PlanBudget &budget) const; /*! * \brief Construit un LoadAction "state" SG-Ready (PAC) par mapping SÉMANTIQUE du surplus. @@ -112,9 +117,12 @@ private: * * \param lc Charge SG-Ready (adapter == "sg-ready") du SurplusContext. * \param[in,out] remainingSurplusW Budget de surplus restant (W), mis à jour pour la suite. - * \return LoadAction kind=State, funding=Surplus, \c reason français non vide. + * \param[in,out] budget Comptabilité publiée (\c allocatedW / \c recreditedW) — ici le + * recrédit est la puissance DÉCLARÉE de l'état courant, pas une mesure. + * \return LoadAction kind=State, funding=Surplus, \c reason non vide. */ - LoadAction buildSgReadyStateAction(const LoadContext &lc, double &remainingSurplusW) const; + LoadAction buildSgReadyStateAction(const LoadContext &lc, double &remainingSurplusW, + PlanBudget &budget) const; EnergyArbitrator *m_arbitrator; }; diff --git a/energyplugin/etm/types/decisionreason.cpp b/energyplugin/etm/types/decisionreason.cpp new file mode 100644 index 0000000..7354d94 --- /dev/null +++ b/energyplugin/etm/types/decisionreason.cpp @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync + +#include "decisionreason.h" +#include + +/* + * TABLE DE RENDU FRANÇAIS — source unique de la ligne de journal. + * + * Chaque phrase ci-dessous est REPRISE MOT POUR MOT du QStringLiteral qu'elle remplace. Les + * motifs d'ECS-309 et ECS-309-b sont le fruit de corrections coûteuses : ils ne doivent pas + * être reformulés au passage. Toute retouche de texte ici change le journal de production — + * elle se prouve par comparaison de traces, jamais par relecture. + * + * Aucun appel à tr() : ce n'est PAS de la traduction. La traduction est le travail du client, + * qui reçoit le code (voir la note de decisionreason.h). Cette fonction ne fabrique que la + * trace locale de diagnostic, qui est en français par choix d'exploitation. + */ +QString renderFr(const DecisionReason &reason, const QString &label) +{ + const QVariantMap &p = reason.params; + const QString &c = reason.code; + + auto w = [&p](const char *key) { return p.value(QString::fromLatin1(key)).toInt(); }; + + // --- EV (proxy amont) --------------------------------------------------- + if (c == QLatin1String(DecisionCode::EvSurplus)) + return QStringLiteral("Surplus PV disponible — recharge solaire"); + if (c == QLatin1String(DecisionCode::EvSpotMarket)) + return QStringLiteral("Tarif aWATTar favorable — recharge heure creuse"); + if (c == QLatin1String(DecisionCode::EvDeadline)) + return QStringLiteral("Deadline VE approchante — recharge prioritaire"); + if (c == QLatin1String(DecisionCode::EvEcoMin)) + return QStringLiteral("Aucun surplus — courant minimum maintenu (mode EcoMin)"); + if (c == QLatin1String(DecisionCode::EvIdle)) + return QStringLiteral("Aucun surplus disponible — recharge suspendue"); + + // --- Charges pilotées en watts ----------------------------------------- + if (c == QLatin1String(DecisionCode::LoadUnavailable)) + return QStringLiteral("%1 INDISPONIBLE — aucune commande émise ; %2 W toujours " + "comptés au budget (levée par ClearLoadFault)") + .arg(label).arg(w("frozenW")); + + // ECS-309-b — les deux branches de verrou se lisent sur le MÊME axe : le verrou a relevé + // la consigne (minOn), ou il l'a rabaissée (minOff). Le critère est ce que le verrou a + // FAIT à la consigne voulue, jamais une comparaison au budget. + if (c == QLatin1String(DecisionCode::LockMinOn)) + return QStringLiteral("Verrou minOn — %1 maintenue à %2 W (puissance engagée, " + "budget %3 W) ; le résidu en tient compte") + .arg(label).arg(w("appliedW")).arg(w("budgetW")); + if (c == QLatin1String(DecisionCode::LockMinOff)) + return QStringLiteral("Verrou minOff — %1 ne peut pas s'enclencher avant la fin de la " + "fenêtre (budget %2 W disponible, palier %3 W refusé)") + .arg(label).arg(w("budgetW")).arg(w("requestedW")); + if (c == QLatin1String(DecisionCode::LockMinOffCapped)) + return QStringLiteral("Verrou minOff — %1 plafonnée à %2 W avant la fin de la fenêtre " + "(budget %3 W disponible, palier %4 W refusé)") + .arg(label).arg(w("appliedW")).arg(w("budgetW")).arg(w("requestedW")); + if (c == QLatin1String(DecisionCode::SurplusSetpoint)) + return QStringLiteral("Surplus PV %1 W — %2 consigne %3 W%4") + .arg(w("budgetW")).arg(label).arg(w("setpointW")) + .arg(p.value(QStringLiteral("stepped")).toBool() ? QStringLiteral(" (palier)") + : QString()); + if (c == QLatin1String(DecisionCode::SurplusInsufficient)) + return QStringLiteral("Surplus insuffisant (%1 W) — %2 à 0 W") + .arg(w("budgetW")).arg(label); + + // --- SG-Ready (PAC) ----------------------------------------------------- + if (c == QLatin1String(DecisionCode::LockMinStateHold)) + return QStringLiteral("Verrou minStateHold — %1 maintenue état %2 (court-cycling PAC)") + .arg(label).arg(w("state")); + if (c == QLatin1String(DecisionCode::SgForced)) + return QStringLiteral("Surplus abondant %1 W — %2 forcée (état 4, ~%3 W)") + .arg(w("budgetW")).arg(label).arg(w("estimatedW")); + if (c == QLatin1String(DecisionCode::SgRecommended)) + return QStringLiteral("Surplus PV %1 W — %2 recommandée (état 3, ~%3 W)") + .arg(w("budgetW")).arg(label).arg(w("estimatedW")); + if (c == QLatin1String(DecisionCode::SgNormal)) + return QStringLiteral("Surplus insuffisant (%1 W) — %2 en normal (état 2, non pilotée)") + .arg(w("budgetW")).arg(label); + + // --- Sécurité et cycle de vie ------------------------------------------ + if (c == QLatin1String(DecisionCode::DegradedL2)) + return QStringLiteral("Compteur muet depuis >90 s — consigne de repli (L2 watchdog)"); + if (c == QLatin1String(DecisionCode::SafeStateRelay)) + return QStringLiteral("Charge désactivée — mise en état sûr avant retrait (ECS-413)"); + if (c == QLatin1String(DecisionCode::SafeStateSetpoint)) + return QStringLiteral("Charge désactivée — mise en état sûr (0 W) avant retrait (ECS-413)"); + if (c == QLatin1String(DecisionCode::SafeStateSgReady)) + return QStringLiteral("Charge désactivée — mise en état sûr (état 2) avant retrait (ECS-413)"); + + // Code hors catalogue. Règle 7-c : ne JAMAIS rendre une chaîne vide, qui ferait passer un + // motif présent-mais-inconnu pour un motif absent. On publie le code brut et ses + // paramètres — assez pour retrouver le site d'émission fautif dans le journal. + if (c.isEmpty()) + return QStringLiteral("(motif absent)"); + QStringList kv; + for (auto it = p.constBegin(); it != p.constEnd(); ++it) + kv << QStringLiteral("%1=%2").arg(it.key(), it.value().toString()); + return QStringLiteral("(motif inconnu %1%2%3)") + .arg(c, kv.isEmpty() ? QString() : QStringLiteral(" "), kv.join(QStringLiteral(", "))); +} diff --git a/energyplugin/etm/types/decisionreason.h b/energyplugin/etm/types/decisionreason.h new file mode 100644 index 0000000..0ae0baa --- /dev/null +++ b/energyplugin/etm/types/decisionreason.h @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync +#pragma once + +#include +#include + +/*! + * \file decisionreason.h + * \brief Motif de décision sous forme \c {code, params} — la seule forme qui franchit la + * frontière RPC, et la seule source de la ligne française du journal. + * + * \par Pourquoi un code et pas une phrase + * La locale nymea est **par connexion** (\c JSONRPC.Hello accepte \c o:locale, rangée dans + * \c m_clientLocales) : deux utilisateurs d'une même box peuvent avoir deux langues. Une + * phrase composée en C++ ne peut donc être juste que pour l'un des deux. Par ailleurs + * \c NymeaEnergyJsonHandler est un \c JsonHandler **sans \c pluginId** : il est + * structurellement hors du catalogue de traduction nymea, et rien ne rattraperait une + * phrase française émise ici. La box transporte donc un CODE et des PARAMÈTRES ; la phrase + * est fabriquée par le client, dans la langue de son utilisateur. + * + * \par Le journal reste en français — mais RENDU depuis le code + * \c renderFr() est la fonction de rendu UNIQUE. La ligne française n'est pas maintenue en + * parallèle du code : elle en est dérivée. Deux écritures parallèles divergeraient, et + * c'est le diagnostic terrain qui deviendrait faux. + * + * \par Aucun libellé dans \c params + * Le nom de la charge (« Chauffe-eau ») est de la langue naturelle saisie par + * l'installateur. Il n'entre PAS dans \c params : il est passé à \c renderFr() au moment du + * rendu, par l'appelant qui le connaît (l'adaptateur connaît le sien, l'arbitre a le + * contexte). La charge utile RPC reste donc exempte de toute langue naturelle, et le client + * résout le libellé lui-même via \c GetLoadConfig. + */ + +/*! + * \brief Catalogue FERMÉ des codes de motif. + * + * Un code par site d'émission — la liste est EXTRAITE des \c QStringLiteral qui existaient + * avant la refonte, pas inventée. Ajouter un motif = ajouter une constante ici, une branche + * de rendu dans \c renderFr(), et une ligne au tableau d'\c INTERFACE.md. + */ +namespace DecisionCode { + +// --- EV (proxy amont, beta) ------------------------------------------------- +constexpr const char *EvSurplus = "EV_SURPLUS"; //!< params: — +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: — + +// --- 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. +constexpr const char *LoadUnavailable = "LOAD_UNAVAILABLE"; +//! params: \c appliedW, \c budgetW — le verrou minOn a RELEVÉ la consigne voulue. +constexpr const char *LockMinOn = "LOCK_MIN_ON"; +//! params: \c budgetW, \c requestedW — minOff interdit tout enclenchement (consigne à 0 W). +constexpr const char *LockMinOff = "LOCK_MIN_OFF"; +//! params: \c appliedW, \c budgetW, \c requestedW — minOff plafonne sans interdire. +constexpr const char *LockMinOffCapped = "LOCK_MIN_OFF_CAPPED"; +//! params: \c budgetW, \c setpointW, \c stepped (bool) — consigne servie par le surplus. +constexpr const char *SurplusSetpoint = "SURPLUS_SETPOINT"; +//! params: \c budgetW — le budget ne payait aucun palier ; aucun verrou n'a mordu. +constexpr const char *SurplusInsufficient = "SURPLUS_INSUFFICIENT"; + +// --- SG-Ready (PAC) --------------------------------------------------------- +//! params: \c state — le verrou minStateHold maintient l'état (court-cycling compresseur). +constexpr const char *LockMinStateHold = "LOCK_MIN_STATE_HOLD"; +//! params: \c budgetW, \c estimatedW — état 4 (forcé). +constexpr const char *SgForced = "SG_FORCED"; +//! params: \c budgetW, \c estimatedW — état 3 (recommandation). +constexpr const char *SgRecommended = "SG_RECOMMENDED"; +//! params: \c budgetW — état 2 (normal, mains off). +constexpr const char *SgNormal = "SG_NORMAL"; + +// --- Sécurité et cycle de vie ---------------------------------------------- +//! params: — · consigne de repli du watchdog L2 (compteur muet, SAFETY.md §L2). +constexpr const char *DegradedL2 = "DEGRADED_L2"; +//! params: — · état sûr d'un routeur de relais avant retrait (ECS-413) : tous relais ouverts. +constexpr const char *SafeStateRelay = "SAFE_STATE_RELAY"; +//! params: — · état sûr d'une charge à consigne continue avant retrait (ECS-413) : 0 W. +constexpr const char *SafeStateSetpoint = "SAFE_STATE_SETPOINT"; +//! params: — · état sûr d'une PAC SG-Ready avant retrait (ECS-413) : état 2, JAMAIS le blocage. +constexpr const char *SafeStateSgReady = "SAFE_STATE_SG_READY"; + +} + +/*! + * \brief Motif d'une décision d'arbitrage : un code du catalogue + ses paramètres numériques. + * + * \invariant \c code non vide sur toute \c LoadAction transmise à un adaptateur + * (AGENTS règle 7). Un adaptateur rejette silencieusement une action au code vide. + * \invariant \c params ne contient QUE des nombres et des booléens — jamais de langue + * naturelle (voir la note de fichier). + */ +struct DecisionReason +{ + //! \brief Motif vide — état par défaut, refusé par les adaptateurs. + DecisionReason() = default; + + /*! + * \brief Construit un motif. + * \param decisionCode Constante de \c DecisionCode (jamais une chaîne libre). + * \param decisionParams Paramètres du code, nombres et booléens uniquement. + */ + DecisionReason(const char *decisionCode, const QVariantMap &decisionParams = QVariantMap()) + : code(QString::fromLatin1(decisionCode)), params(decisionParams) {} + + QString code; //!< Code du catalogue \c DecisionCode ; vide = aucun motif. + QVariantMap params; //!< Paramètres du code ; clés documentées par code. + + //! \return Vrai si aucun motif n'est porté (\c code vide). + bool isEmpty() const { return code.isEmpty(); } + + //! \return Forme sérialisée \c {code, params} — la charge utile RPC. + //! \note \c params est toujours présent, fût-il vide : un client n'a pas à distinguer + //! « pas de paramètre » de « clé oubliée ». + QVariantMap toMap() const + { + QVariantMap m; + m.insert(QStringLiteral("code"), code); + m.insert(QStringLiteral("params"), params); + return m; + } +}; + +/*! + * \brief Rend la ligne FRANÇAISE du journal à partir du code et de ses paramètres. + * + * Fonction de rendu UNIQUE : toute ligne française de motif du moteur passe par ici. C'est ce + * qui garantit qu'elle ne peut pas diverger du code publié en RPC. + * + * \param reason Motif à rendre. + * \param label Libellé de la charge, substitué là où les phrases historiques le nommaient. + * Vide pour les motifs qui ne nomment aucune charge (EV, repli L2, états sûrs). + * \return La phrase française, mot pour mot celle d'avant la refonte pour la même situation. + * Pour un code inconnu : une forme de repli \c "code(params)" — jamais une chaîne vide, qui + * ferait passer un motif manquant pour un motif absent. + */ +QString renderFr(const DecisionReason &reason, const QString &label = QString()); diff --git a/energyplugin/etm/types/loadaction.h b/energyplugin/etm/types/loadaction.h index e270935..83d2eec 100644 --- a/energyplugin/etm/types/loadaction.h +++ b/energyplugin/etm/types/loadaction.h @@ -3,6 +3,7 @@ #pragma once #include +#include "decisionreason.h" /*! * \brief Action typée émise par l'arbitre vers un ILoadAdapter. @@ -10,8 +11,8 @@ * Noms de champs identiques à OPTIMIZER_PROTOCOL.md §6 (fait autorité). * \c funding est interne à l'arbitre — absent du JSON protocole socket. * - * \invariant \c reason doit être non vide et en français (AGENTS invariant 7). - * Un adaptateur doit rejeter toute action avec \c reason vide. + * \invariant \c reason doit porter un CODE non vide du catalogue \c DecisionCode + * (AGENTS invariant 7). Un adaptateur doit rejeter toute action au code vide. * \invariant Pour kind == Setpoint / evcharger : seuls \c chargingEnabled, * \c currentA, \c phaseCount sont significatifs. */ @@ -48,10 +49,16 @@ struct LoadAction { Permission discharge = Allow; //!< Autorisation de la décharger (arbitrage grid-funding). /*! - * \brief Motif de la décision, non vide, en français. - * Obligatoire (invariant 7). L'adaptateur rejette silencieusement si vide. + * \brief Motif de la décision : un CODE du catalogue \c DecisionCode + ses paramètres. + * + * Obligatoire (invariant 7) : l'adaptateur rejette silencieusement si \c reason.code est + * vide. **Jamais une phrase.** La ligne française du journal est RENDUE à partir de ce + * couple par \c renderFr(), fonction de rendu unique — elle n'est pas maintenue en + * parallèle. Ce même couple franchit la frontière RPC dans \c NymeaEnergy.GetLoadTelemetry + * et sa notification, où le client fabrique la phrase dans la langue de son utilisateur + * (la locale nymea est par CONNEXION, une phrase composée ici ne pourrait pas l'être). */ - QString reason; + DecisionReason reason; /*! * \brief Puissance estimée (W) — hint pour la comptabilité budget de l'arbitre. diff --git a/energyplugin/etm/types/loadconfig.cpp b/energyplugin/etm/types/loadconfig.cpp index dc6a653..1c6d259 100644 --- a/energyplugin/etm/types/loadconfig.cpp +++ b/energyplugin/etm/types/loadconfig.cpp @@ -95,6 +95,16 @@ bool LoadConfigSgReady::hasState(int s) const // ---- LoadConfig ------------------------------------------------------------ +QStringList LoadConfig::knownDomains() +{ + // Énumération FERMÉE, déclarée UNE fois. isValid() et la documentation en dérivent ; les + // redéclarer ailleurs ferait diverger le refus du contrat publié. + // Ce sont des CODES, jamais des libellés : le nom affiché à l'utilisateur est fabriqué par + // le client, dans sa langue (la locale nymea est par connexion). + return { QStringLiteral("ecs"), QStringLiteral("ev"), QStringLiteral("heating"), + QStringLiteral("hvac"), QStringLiteral("battery") }; +} + QVariantMap LoadConfig::sgReady() const { return m_sgReady.states.isEmpty() ? QVariantMap() : m_sgReady.toMap(); @@ -177,6 +187,16 @@ bool LoadConfig::isValid(QString *error) const if (m_mode != QStringLiteral("fixed") && m_mode != QStringLiteral("dynamic")) return fail(QStringLiteral("mode doit être \"fixed\" ou \"dynamic\" (reçu: %1)").arg(m_mode)); + // Domaine : métadonnée d'intention, OPTIONNELLE. Absent = charge non classée — le plugin ne + // la range pas d'office, parce qu'il n'a aucun moyen de deviner juste et qu'un classement + // inventé serait indistinguable d'un classement choisi. Présent, il doit appartenir à + // l'énumération : une valeur libre acceptée aujourd'hui deviendrait une valeur à supporter + // le jour où la couche domaine en fera son discriminant. + if (!m_domain.isEmpty() && !knownDomains().contains(m_domain)) + return fail(QStringLiteral("domain \"%1\" hors énumération (attendu : %2, ou absent " + "pour une charge non classée)") + .arg(m_domain, knownDomains().join(QStringLiteral(", ")))); + // LM-300 — UNION DISCRIMINÉE par mécanisme. Le discriminant est \c adapter ; chaque // mécanisme a UNE charge utile, et les charges utiles des autres mécanismes DOIVENT être // absentes (LM-302 : les états invalides doivent être inexprimables). Ajouter un @@ -285,6 +305,10 @@ QVariantMap LoadConfig::toMap() const m.insert("label", m_label); m.insert("adapter", m_adapter); m.insert("mode", m_mode); + // Omis quand la charge n'est pas classée — jamais une chaîne vide, qui se lirait comme un + // domaine inconnu plutôt que comme une absence de classement (règle « omis, jamais nul »). + if (!m_domain.isEmpty()) + m.insert("domain", m_domain); if (!m_powerLevels.isEmpty()) m.insert("powerLevels", powerLevels()); m.insert("maxPowerW", m_maxPowerW); @@ -312,6 +336,7 @@ LoadConfig LoadConfig::fromMap(const QVariantMap &map) c.m_label = map.value("label").toString(); c.m_adapter = map.value("adapter", QStringLiteral("etmvariableload")).toString(); c.m_mode = map.value("mode").toString(); + c.m_domain = map.value("domain").toString(); // absent → vide → charge non classée c.setPowerLevels(map.value("powerLevels").toList()); // trie + déduplique c.m_maxPowerW = map.value("maxPowerW").toInt(); c.m_priority = map.value("priority").toInt(); diff --git a/energyplugin/etm/types/loadconfig.h b/energyplugin/etm/types/loadconfig.h index 7cea414..1831faf 100644 --- a/energyplugin/etm/types/loadconfig.h +++ b/energyplugin/etm/types/loadconfig.h @@ -9,6 +9,7 @@ #include #include #include +#include /*! * \brief Besoins énergétiques d'une charge pilotée (contrat etmvariableload §4 \c needs). @@ -125,6 +126,8 @@ class LoadConfig Q_PROPERTY(QString label READ label WRITE setLabel) Q_PROPERTY(QString adapter READ adapter WRITE setAdapter) Q_PROPERTY(QString mode READ mode WRITE setMode) + //! Catégorie d'intention déclarée par l'installateur — énumération FERMÉE, code jamais libellé. + Q_PROPERTY(QString domain READ domain WRITE setDomain) Q_PROPERTY(QVariantList powerLevels READ powerLevels WRITE setPowerLevels) Q_PROPERTY(int maxPowerW READ maxPowerW WRITE setMaxPowerW) Q_PROPERTY(int priority READ priority WRITE setPriority) @@ -152,6 +155,36 @@ public: QString adapter() const { return m_adapter; } //! \param v Type d'adaptateur ; toute autre valeur fait échouer isValid(). void setAdapter(const QString &v) { m_adapter = v; } + /*! + * \brief Catégorie d'intention de la charge — PURE MÉTADONNÉE. + * + * \return Un code de l'énumération fermée \c {ecs, ev, heating, hvac, battery}, ou une + * chaîne VIDE : la charge est alors « non classée ». Le plugin ne la range jamais d'office. + * + * \note **L'arbitre ne lit pas ce champ** et aucun comportement moteur n'en dépend. Il + * enregistre le choix de l'installateur, qui sélectionne une catégorie puis lui rattache + * ses Things. Le jour où la couche domaine de \c specs/spec_loadmodel.md arrivera, ce + * champ en deviendra le discriminant — il ne la préempte pas. + * \note **Attention à la forme en sortie RPC.** \c toMap() (persistance) OMET la clé quand + * le domaine est vide, mais \c GetLoadConfig sérialise via le MÉTA-OBJET : la propriété + * sort donc toujours, à \c "" pour une charge non classée — comme \c sgReady vide ou + * \c powerLevels vide. C'est le corollaire LM-302, et c'est ce qui rend l'aller-retour + * \c Get → \c Set possible. Vérifié au banc le 2026-08-25. + * \note **Un code, jamais un libellé affichable** : la locale nymea est par connexion, et + * ce handler est hors du catalogue de traduction (cf. \c decisionreason.h). Le libellé + * affiché est fabriqué par le client. + * \note L'énumération de \c spec_loadmodel.md §2 (\c Ecs/\c Hvac/\c Ev/\c SmartHome) est + * une INTENTION de conception plus ancienne ; c'est celle-ci qui est implémentée. + */ + QString domain() const { return m_domain; } + //! \param v Code de domaine, ou chaîne vide pour « non classée ». Toute autre valeur fait + //! échouer \c isValid() — c'est le seul endroit où l'énumération est refermée. + void setDomain(const QString &v) { m_domain = v; } + + //! \return Codes de domaine acceptés, ordre stable. Source unique de l'énumération : + //! la validation et la documentation en dérivent, elles ne la redéclarent pas. + static QStringList knownDomains(); + //! \return Mode : "fixed" (paliers) ou "dynamic" (modulation continue). QString mode() const { return m_mode; } //! \param v Mode "fixed" ou "dynamic" ; toute autre valeur fait échouer isValid(). @@ -256,6 +289,7 @@ private: QString m_label; QString m_adapter = QStringLiteral("etmvariableload"); QString m_mode; + QString m_domain; //!< Code d'énumération fermée ; vide = charge non classée. QList m_powerLevels; //!< Trié croissant, 0 inclus en *fixed* ; vide en *dynamic*. int m_maxPowerW = 0; int m_priority = 0; diff --git a/energyplugin/etm/types/plan.h b/energyplugin/etm/types/plan.h index f6985c0..3e061a8 100644 --- a/energyplugin/etm/types/plan.h +++ b/energyplugin/etm/types/plan.h @@ -22,6 +22,47 @@ struct Slot { QList actions; //!< Actions à appliquer, triées par priorité croissante. }; +/*! + * \brief Comptabilité du budget de surplus du cycle — INTERNE à l'arbitre. + * + * Non sérialisée vers l'optimiseur socket, exactement comme \c LoadAction::funding : ce n'est + * pas une entrée du contrat OPTIMIZER_PROTOCOL §6, c'est la trace de ce que le waterfall a + * fait de son budget, publiée telle quelle par \c NymeaEnergy.GetLoadTelemetry. + * + * \invariant \c surplusW + \c recreditedW − \c allocatedW == \c remainingW (aux arrondis + * près). C'est ce qui rend la charge utile RPC vérifiable par un client, et le calcul + * auditable contre le journal de la même fenêtre. + * + * \par Pourquoi \c recreditedW existe + * Sans lui l'identité serait FAUSSE, et elle le serait silencieusement : le waterfall rend à + * chaque charge sa propre consommation de début de cycle avant d'arrondir (correction B, + * anti-clignotement). Un client qui poserait \c surplus − \c alloué == \c restant + * trouverait un écart inexpliqué dès qu'une charge consomme déjà — c'est-à-dire dans le cas + * NORMAL. Publier le recrédit rend aussi visible l'artefact de banc documenté dans + * \c AGENTS.md (relais sans mesure + rootmeter aveugle → recrédit du nominal commandé, donc + * double comptage) : le chiffre est là, lisible, au lieu d'être à redéduire. + * \note L'arbitre ne RECALCULE pas ces valeurs : il les reçoit du scheduler qui les a + * réellement utilisées. Un second calcul, même juste le jour où il est écrit, divergerait + * du premier à la première correction — et c'est le chiffre publié qui deviendrait faux. + */ +struct PlanBudget { + //! Budget entrant du waterfall non-EV : surplus net signé (export − import) MOINS la + //! réservation EV du cycle. Négatif = soutirage réseau, le waterfall déleste. + double surplusW = 0; + //! Puissance réservée aux consignes EV de CE cycle, pas encore visible au compteur + //! (correction A). Publiée pour que \c surplusW reste rapprochable de la mesure brute : + //! sans elle, l'écart avec le compteur serait inexplicable côté client. + double evReservedW = 0; + //! Somme des puissances ALLOUÉES aux charges non-EV par le waterfall de ce cycle — le + //! total, pas l'incrément. C'est la somme des \c estimatedPowerW des actions du créneau. + double allocatedW = 0; + //! Somme des RECRÉDITS de début de cycle (correction B) : pour chaque charge, la puissance + //! qu'elle consommait déjà et qui lui est rendue avant l'arrondi. Voir la note de classe. + double recreditedW = 0; + //! Résidu non alloué à l'issue de la cascade. + double remainingW = 0; +}; + /*! * \brief Plan d'optimisation retourné par IScheduler::getPlan(). * @@ -35,6 +76,9 @@ struct Plan { //! Sérialisation JSON : sous le nom "slots" — OPTIMIZER_PROTOCOL.md §6 //! fait autorité sur le nom de fil, le renommage est purement interne C++. QList timeSlots; + //! Comptabilité du budget du cycle — INTERNE, jamais sérialisée vers l'optimiseur socket + //! (même statut que \c LoadAction::funding). Alimente la télémétrie RPC. + PlanBudget budget; /*! * \brief Retourne le Slot couvrant \p dt. diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index 4e38786..49b5176 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -143,8 +143,14 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana params.clear(); returns.clear(); - description = "Get the planed charging schedules."; + description = "Get the planed charging schedules, and whether the L2 degraded mode is " + "currently active (meter silent > 90s, planning suspended)."; returns.insert("chargingSchedules", QVariantList() << objectRef()); + // [ETM] degradedMode était POUSSÉ par ChargingSchedulesChanged sans être LISIBLE ici : un + // client connecté après la bascule ne pouvait pas savoir dans quel état il se trouvait. + // C'est la famille ECS-411 — un silence ambigu. Tout élément runtime a un Get* ET un + // *Changed ; jamais une notification seule. + returns.insert("degradedMode", enumValueName(Bool)); registerMethod("GetChargingSchedules", description, params, returns, Types::PermissionScopeControlThings); @@ -174,6 +180,11 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana loadItem.insert("label", enumValueName(String)); loadItem.insert("adapter", enumValueName(String)); loadItem.insert("mode", enumValueName(String)); + // Métadonnée d'INTENTION (§3 du lot) : un code d'énumération fermée — ecs · ev · heating · + // hvac · battery — jamais un libellé affichable. Optionnelle : absente = charge non classée. + // L'arbitre ne la lit pas ; la validation de l'énumération vit dans LoadConfig::isValid(), + // comme pour « adapter » et « mode » (LM-302 : l'exigence réelle n'est jamais dans le schéma). + loadItem.insert("o:domain", enumValueName(String)); loadItem.insert("priority", enumValueName(Uint)); loadItem.insert("enabled", enumValueName(Bool)); loadItem.insert("o:powerLevels", QVariantList() << enumValueName(Uint)); // etmvariableload fixed @@ -223,6 +234,21 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana returns.insert("energyError", enumRef()); registerMethod("ClearLoadFault", description, params, returns, Types::PermissionScopeControlThings); + // [ETM] Frontière de télémétrie d'arbitrage. AUCUNE phrase composée ici : des codes et des + // paramètres. La locale nymea est par CONNEXION (JSONRPC.Hello o:locale) et ce handler n'a + // pas de pluginId, donc il est hors du catalogue de traduction : une phrase française émise + // ici ne serait rattrapée par rien. Cf. etm/types/decisionreason.h. + params.clear(); returns.clear(); + description = "Get a snapshot of the arbitration runtime state: surplus budget, per-load " + "allocation, availability, fault code, active lock (seconds remaining), " + "mechanism payload and decision reason. Reason and fault are CODES with " + "numeric params - never human-readable sentences: the client renders them in " + "the user's language. Identical payload to the LoadTelemetryChanged " + "notification. 'timestamp' is the end of the last arbitration cycle and is " + "omitted while no cycle has run yet."; + returns = loadTelemetrySchema(); + registerMethod("GetLoadTelemetry", description, params, returns, Types::PermissionScopeControlThings); + // Notifications params.clear(); @@ -332,6 +358,28 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana }); } + // [ETM] LoadTelemetryChanged : MÊME charge utile que GetLoadTelemetry (schéma partagé, une + // seule déclaration). Émise sur changement SIGNIFICATIF (mode dégradé, jeu de charges, + // disponibilité, défaut, nature de verrou, mécanisme, code de motif, ou variation + // d'allocation au-delà du seuil) ET à intervalle périodique même sans changement — c'est + // ce battement qui rend un arbitre FIGÉ détectable, le timestamp cessant d'avancer. + params.clear(); + description = "Emitted whenever the arbitration runtime state changed significantly, and " + "periodically (heartbeat) even when unchanged, so that a frozen arbitrator is " + "detectable by a timestamp that stops advancing. Same payload as " + "GetLoadTelemetry."; + params = loadTelemetrySchema(); + registerNotification("LoadTelemetryChanged", description, params); +#ifdef ETM_ARBITRATOR + if (EnergyArbitrator *arb = dynamic_cast(m_smartChargingManager)) { + connect(arb, &EnergyArbitrator::loadTelemetryChanged, this, [this](const QVariantMap &t) { + Q_UNUSED(t) // on republie la vue NORMALISÉE, pas la carte brute : c'est + // currentLoadTelemetry() qui garantit les clés requises du schéma. + emit LoadTelemetryChanged(currentLoadTelemetry()); + }); + } +#endif + // [Phase 2] EnergyRatiosChanged : émise sur powerBalanceChanged() de l'energymanager, // uniquement quand une valeur (ou sa disponibilité) change. Mêmes champs que GetEnergyRatios. params.clear(); @@ -545,6 +593,7 @@ JsonReply *NymeaEnergyJsonHandler::GetChargingSchedules(const QVariantMap ¶m schedules << pack(schedule); } returns.insert("chargingSchedules", schedules); + returns.insert("degradedMode", m_smartChargingManager->degradedMode()); // [ETM] L2, cf. supra return createReply(returns); } @@ -623,6 +672,99 @@ JsonReply *NymeaEnergyJsonHandler::ClearLoadFault(const QVariantMap ¶ms) return createReply(ret); } +QVariantMap NymeaEnergyJsonHandler::loadTelemetrySchema() +{ + // Verrou : le TEMPS RESTANT, pas des watts. lockMinPowerW/lockMaxPowerW sont un artefact du + // mécanisme d'allocation, illisible pour un utilisateur. Ceci n'enfreint pas ECS-306 : + // cette exigence interdit qu'un index de PALIER franchisse la frontière d'ALLOCATION ; une + // valeur d'AFFICHAGE sortante est un autre chemin. + QVariantMap lockItem; + lockItem.insert("kind", enumValueName(String)); // "minOn" | "minOff" | "minStateHold" + lockItem.insert("remainingS", enumValueName(Int)); + + // UNION DISCRIMINÉE par mécanisme, comme LoadConfig : « kind » discrimine, chaque variante + // porte SA charge utile. LM-302 s'applique tel quel — TOUTES les clés internes sont + // optionnelles, sinon la charge utile d'un mécanisme contraindrait celle des autres. + QVariantMap mechItem; + mechItem.insert("kind", enumValueName(String)); // "relay" | "variable" | "sgReady" + mechItem.insert("o:stageW", enumValueName(Double)); // relay + mechItem.insert("o:maxStageW", enumValueName(Double)); // relay + mechItem.insert("o:setpointW", enumValueName(Double)); // variable + mechItem.insert("o:percent", enumValueName(Double)); // variable + mechItem.insert("o:maxPowerW", enumValueName(Double)); // variable + mechItem.insert("o:state", enumValueName(Int)); // sgReady + mechItem.insert("o:estimatedPowerW", enumValueName(Double)); // sgReady + mechItem.insert("o:chargingEnabled", enumValueName(Bool)); // evcharger (réservé 3g) + mechItem.insert("o:pluggedIn", enumValueName(Bool)); // evcharger (réservé 3g) + mechItem.insert("o:currentA", enumValueName(Double)); // evcharger (réservé 3g) + mechItem.insert("o:phaseCount", enumValueName(Uint)); // evcharger (réservé 3g) + + // Motif : un CODE et ses paramètres. « params » est typé Variant parce que ses clés + // DÉPENDENT du code — les figer au schéma reviendrait à publier N objets au lieu d'un, et à + // casser le contrat à chaque motif ajouté. Le catalogue des codes et de leurs paramètres + // fait autorité dans INTERFACE.md et dans etm/types/decisionreason.h. + QVariantMap decisionItem; + decisionItem.insert("code", enumValueName(String)); + decisionItem.insert("params", enumValueName(Variant)); + + 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. + loadItem.insert("allocatedW", enumValueName(Double)); + loadItem.insert("available", enumValueName(Bool)); + loadItem.insert("o:faultCode", enumValueName(String)); // absent = aucun défaut + loadItem.insert("o:lock", lockItem); // absent = aucun verrou actif + loadItem.insert("o:mechanism", mechItem); + loadItem.insert("decision", decisionItem); + + QVariantMap budgetItem; + budgetItem.insert("surplusW", enumValueName(Double)); + budgetItem.insert("evReservedW", enumValueName(Double)); + budgetItem.insert("allocatedW", enumValueName(Double)); + // Sans « recreditedW » l'identité surplus − alloué == restant serait fausse dès qu'une + // charge consomme déjà, et le client chercherait l'écart. Cf. PlanBudget. + budgetItem.insert("recreditedW", enumValueName(Double)); + budgetItem.insert("remainingW", enumValueName(Double)); + + QVariantMap schema; + // Omis, jamais nul (règle maison, cf. GetEnergyRatios) : + // - timestamp absent = aucun cycle d'arbitrage depuis le démarrage. En forger un se + // lirait « cycle exécuté », soit exactement le mensonge que ce champ existe pour + // empêcher. Présent, c'est la FIN du dernier cycle — la preuve de fraîcheur. + // - budget absent = mode dégradé L2 : la planification est SUSPENDUE, il n'existe + // aucun budget pour ce cycle. Publier des zéros se lirait « rien à allouer ». + schema.insert("o:timestamp", enumValueName(String)); // ISO 8601 UTC + schema.insert("degradedMode", enumValueName(Bool)); + schema.insert("o:budget", budgetItem); + schema.insert("loads", QVariantList() << loadItem); + return schema; +} + +QVariantMap NymeaEnergyJsonHandler::currentLoadTelemetry() const +{ + QVariantMap t; +#ifdef ETM_ARBITRATOR + if (EnergyArbitrator *arb = dynamic_cast(m_smartChargingManager)) + t = arb->loadTelemetry(); +#endif + // Carte minimale tant qu'aucun cycle n'a eu lieu : les clés REQUISES du schéma sont posées, + // « timestamp » ne l'est pas. Le client lit donc « pas encore de cycle », et non un cycle + // fictif à budget nul. + if (!t.contains(QStringLiteral("degradedMode"))) + t.insert(QStringLiteral("degradedMode"), + m_smartChargingManager && m_smartChargingManager->degradedMode()); + if (!t.contains(QStringLiteral("loads"))) + t.insert(QStringLiteral("loads"), QVariantList()); + return t; +} + +JsonReply *NymeaEnergyJsonHandler::GetLoadTelemetry(const QVariantMap ¶ms) +{ + Q_UNUSED(params) + return createReply(currentLoadTelemetry()); +} + JsonReply *NymeaEnergyJsonHandler::GetEnergyRatios(const QVariantMap ¶ms) { Q_UNUSED(params) diff --git a/energyplugin/nymeaenergyjsonhandler.h b/energyplugin/nymeaenergyjsonhandler.h index 4c154e5..8e03d94 100644 --- a/energyplugin/nymeaenergyjsonhandler.h +++ b/energyplugin/nymeaenergyjsonhandler.h @@ -84,6 +84,18 @@ public: */ Q_INVOKABLE JsonReply *ClearLoadFault(const QVariantMap ¶ms); + /*! + * \brief [ETM] Instantané de l'état runtime de l'arbitrage (allocation, budget, verrous, + * mécanismes, motifs). + * \param params Aucun. + * \return La MÊME charge utile que la notification \c LoadTelemetryChanged — pas un + * recalcul : l'objet rendu est celui qui a été publié. + * \note Répond à la règle « tout élément runtime a un \c Get* ET un \c *Changed ». Un + * client connecté après un changement doit pouvoir lire l'état, sans quoi la + * notification seule le laisse dans un silence ambigu (famille ECS-411). + */ + Q_INVOKABLE JsonReply *GetLoadTelemetry(const QVariantMap ¶ms); + signals: void PhasePowerLimitChanged(const QVariantMap ¶ms); void AcquisitionToleranceChanged(const QVariantMap ¶ms); @@ -97,6 +109,8 @@ signals: void BatteryLevelConsiderationChanged(const QVariantMap ¶ms); void LoadConfigChanged(const QVariantMap ¶ms); void EnergyRatiosChanged(const QVariantMap ¶ms); + //! [ETM] État runtime d'arbitrage — sur changement significatif ET au battement périodique. + void LoadTelemetryChanged(const QVariantMap ¶ms); private slots: void onPowerBalanceChanged(); @@ -112,6 +126,18 @@ private: bool m_lastRatiosValid = false; // un calcul a-t-il déjà été émis ? QVariantMap currentEnergyRatios(); + /*! + * \brief Schéma de la charge utile de télémétrie — UNE déclaration, deux usages. + * \return Le map de schéma, posé à l'identique dans les \c returns de + * \c GetLoadTelemetry et dans les \c params de \c LoadTelemetryChanged. + * \note C'est la règle « snapshot ≡ notification » rendue impossible à enfreindre : + * deux déclarations divergeraient, et c'est l'appel de reprise à froid qui casserait — + * celui qu'on ne teste pas tous les jours. + */ + static QVariantMap loadTelemetrySchema(); + //! \return Charge utile courante ; carte MINIMALE (sans \c timestamp) si aucun cycle + //! d'arbitrage n'a encore eu lieu — cf. \c EnergyArbitrator::loadTelemetry(). + QVariantMap currentLoadTelemetry() const; void sendSpotMarketConfigurationChangedNotification(); }; diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 8474b7f..fc30e09 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -40,12 +40,16 @@ using namespace nymeaserver; // (interface etmvariableload, kind Setpoint). La combinatoire relais vit dans le thing. #include "../../../energyplugin/etm/adapters/etmvariableloadadapter.h" #include "../../../energyplugin/etm/adapters/relayrouter.h" +#include "../../../energyplugin/etm/adapters/evadapter.h" +#include "../../../energyplugin/evcharger.h" #include "../../../energyplugin/etm/types/loadconfig.h" #include "../../../energyplugin/etm/config/loadconfigstore.h" #include "../../../energyplugin/etm/ratios/energyratioscalculator.h" #include "../../../energyplugin/etm/scheduler/rulebasedscheduler.h" +#include "../../../energyplugin/etm/types/decisionreason.h" #endif +#include #include #include #include @@ -680,7 +684,9 @@ void Simulation::testEcsRelayTopologies() auto sp = [&](double w) { LoadAction a; a.kind = LoadAction::Setpoint; a.funding = LoadAction::Surplus; - a.powerW = w; a.reason = QStringLiteral("test topo"); + // Le motif est un CODE : ce test ne porte pas sur le texte, seulement sur la + // non-vacuité exigée par la règle 7 (une action au code vide est rejetée). + a.powerW = w; a.reason = { DecisionCode::SurplusSetpoint }; return a; }; auto freshSetup = [&](ThingManager *&tm, QObject *&owner) { @@ -912,7 +918,7 @@ void Simulation::testEcsBudgetUnderLock() auto sp = [&](double w) { LoadAction a; a.kind = LoadAction::Setpoint; a.funding = LoadAction::Surplus; - a.powerW = w; a.reason = QStringLiteral("amorçage test"); return a; + a.powerW = w; a.reason = { DecisionCode::SurplusSetpoint }; return a; }; // Amorçage : la charge 1 monte à 2000 W et arme son verrou minOn (300 s). @@ -1194,7 +1200,7 @@ void Simulation::testEcsColdStartLockExpires() auto sp = [&](double w, const QDateTime &at) { LoadAction a; a.kind = LoadAction::Setpoint; a.funding = LoadAction::Surplus; - a.powerW = w; a.reason = QStringLiteral("test démarrage à froid"); + a.powerW = w; a.reason = { DecisionCode::SurplusSetpoint }; return r->applyAction(a, at); }; @@ -1240,7 +1246,10 @@ void Simulation::testEcsColdStartLockExpires() const Plan plan = sched.getPlan(ctx); for (const LoadAction &a : plan.timeSlots.first().actions) if (a.loadId == routeur->descriptor().id) - return a.reason; + // La ligne du JOURNAL, RENDUE depuis le code — c'est elle que les assertions + // ci-dessous inspectent. Le test porte donc toujours sur le TEXTE PUBLIÉ, et il + // prouve en même temps que la refonte {code, params} ne l'a pas déplacé. + return renderFr(a.reason, routeur->descriptor().label); return QString(); }; @@ -1266,7 +1275,7 @@ void Simulation::testEcsColdStartLockExpires() RelayRouter *r4 = new RelayRouter(tm, "chaud", "Charge chaude", QList({ {rA.toString(), 1000} }), 600, 0, 1, LoadNeeds(), arb); LoadAction montee; montee.kind = LoadAction::Setpoint; montee.funding = LoadAction::Surplus; - montee.powerW = 1000; montee.reason = QStringLiteral("amorçage minOn"); + montee.powerW = 1000; montee.reason = { DecisionCode::SurplusSetpoint }; r4->applyAction(montee, t0); QCOMPARE(qRound(r4->currentSetpointW()), 1000); const QString mOn = motifPour(r4, -400, t0.addSecs(10)); // import 400 W → budget 600 < 1000 @@ -1294,7 +1303,7 @@ void Simulation::testEcsColdStartLockExpires() amorce.kind = LoadAction::Setpoint; amorce.funding = LoadAction::Surplus; amorce.powerW = 0; - amorce.reason = QStringLiteral("amorçage"); + amorce.reason = { DecisionCode::SurplusSetpoint }; r2->applyAction(amorce, t0); LoadContext c1 = r2->toLoadContext(t0.addSecs(minOff + 1)); @@ -1325,7 +1334,7 @@ void Simulation::testEcsPartialFailure() const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); auto sp = [&](double w) { LoadAction a; a.kind = LoadAction::Setpoint; a.funding = LoadAction::Surplus; - a.powerW = w; a.reason = QStringLiteral("test échec d'écriture"); return a; + a.powerW = w; a.reason = { DecisionCode::SurplusSetpoint }; return a; }; // ECS-410 est ASYNCHRONE par construction (AGENTS règle 5 : jamais d'attente dans // update()). Les acquittements arrivent par signal : sans faire tourner la boucle @@ -1522,7 +1531,7 @@ void Simulation::testSgReadyPartialFailure() const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); auto st = [&](int s) { LoadAction a; a.kind = LoadAction::State; a.funding = LoadAction::Surplus; - a.state = s; a.reason = QStringLiteral("test échec SG-Ready"); return a; + a.state = s; a.reason = { DecisionCode::SgRecommended }; return a; }; auto laisserRetomber = [&]() { QTest::qWait(300); }; @@ -3783,6 +3792,696 @@ void Simulation::run() QFile::copy(outputDir.path() + QDir::separator() + simulationImageName, resultsDir.path() + QDir::separator() + simulationImageName); } +void Simulation::testDecisionReasonRendering() +{ +#ifndef ETM_ARBITRATOR + QSKIP("testDecisionReasonRendering nécessite ETM_ARBITRATOR."); +#else + // [Lot B §1 / critère 6] « Journal INCHANGÉ ». + // + // Depuis la refonte, LoadAction::reason porte un CODE et des PARAMÈTRES ; la ligne + // française est RENDUE par renderFr(). Le risque de la manœuvre est là et nulle part + // ailleurs : une phrase reformulée « au passage ». Les motifs d'ECS-309 et ECS-309-b sont + // le fruit de corrections coûteuses au banc — les réécrire enverrait à nouveau + // diagnostiquer le mauvais problème, avec l'autorité d'une explication. + // + // Ce tableau est donc la copie MOT POUR MOT des QStringLiteral d'avant la refonte. Il se + // relit contre `git show HEAD:energyplugin/etm/scheduler/rulebasedscheduler.cpp`. Un test + // « motif non vide » n'aurait rien vu ici — c'est précisément la leçon d'ECS-309. + struct Cas { DecisionReason reason; QString label; QString attendu; }; + const QList table = { + // --- EV (proxy amont) : aucune charge nommée, donc aucun libellé --- + { { DecisionCode::EvSurplus }, QString(), + QStringLiteral("Surplus PV disponible — recharge solaire") }, + { { DecisionCode::EvSpotMarket }, QString(), + QStringLiteral("Tarif aWATTar favorable — recharge heure creuse") }, + { { DecisionCode::EvDeadline }, QString(), + QStringLiteral("Deadline VE approchante — recharge prioritaire") }, + { { DecisionCode::EvEcoMin }, QString(), + QStringLiteral("Aucun surplus — courant minimum maintenu (mode EcoMin)") }, + { { DecisionCode::EvIdle }, QString(), + QStringLiteral("Aucun surplus disponible — recharge suspendue") }, + + // --- Charges pilotées en watts --- + { { DecisionCode::LoadUnavailable, {{"frozenW", 1500}} }, QStringLiteral("Chauffe-eau"), + QStringLiteral("Chauffe-eau INDISPONIBLE — aucune commande émise ; 1500 W toujours " + "comptés au budget (levée par ClearLoadFault)") }, + { { DecisionCode::LockMinOn, {{"appliedW", 2000}, {"budgetW", 600}} }, + QStringLiteral("ECS prioritaire"), + QStringLiteral("Verrou minOn — ECS prioritaire maintenue à 2000 W (puissance engagée, " + "budget 600 W) ; le résidu en tient compte") }, + { { DecisionCode::LockMinOff, {{"budgetW", 7706}, {"requestedW", 1000}} }, + QStringLiteral("Charge à froid"), + QStringLiteral("Verrou minOff — Charge à froid ne peut pas s'enclencher avant la fin " + "de la fenêtre (budget 7706 W disponible, palier 1000 W refusé)") }, + { { DecisionCode::LockMinOffCapped, + {{"appliedW", 1000}, {"budgetW", 4000}, {"requestedW", 2500}} }, + QStringLiteral("ECS relais"), + QStringLiteral("Verrou minOff — ECS relais plafonnée à 1000 W avant la fin de la " + "fenêtre (budget 4000 W disponible, palier 2500 W refusé)") }, + { { DecisionCode::SurplusSetpoint, + {{"budgetW", 3000}, {"setpointW", 2400}, {"stepped", true}} }, + QStringLiteral("Chauffe-eau"), + QStringLiteral("Surplus PV 3000 W — Chauffe-eau consigne 2400 W (palier)") }, + { { DecisionCode::SurplusSetpoint, + {{"budgetW", 3000}, {"setpointW", 3000}, {"stepped", false}} }, + QStringLiteral("Routeur PV"), + QStringLiteral("Surplus PV 3000 W — Routeur PV consigne 3000 W") }, + { { DecisionCode::SurplusInsufficient, {{"budgetW", -389}} }, + QStringLiteral("Charge à froid 3"), + QStringLiteral("Surplus insuffisant (-389 W) — Charge à froid 3 à 0 W") }, + + // --- SG-Ready --- + { { DecisionCode::LockMinStateHold, {{"state", 3}} }, QStringLiteral("PAC test"), + QStringLiteral("Verrou minStateHold — PAC test maintenue état 3 (court-cycling PAC)") }, + { { DecisionCode::SgForced, {{"budgetW", 4000}, {"estimatedW", 3000}} }, + QStringLiteral("PAC test"), + QStringLiteral("Surplus abondant 4000 W — PAC test forcée (état 4, ~3000 W)") }, + { { DecisionCode::SgRecommended, {{"budgetW", 2000}, {"estimatedW", 1500}} }, + QStringLiteral("PAC test"), + QStringLiteral("Surplus PV 2000 W — PAC test recommandée (état 3, ~1500 W)") }, + { { DecisionCode::SgNormal, {{"budgetW", 1000}} }, QStringLiteral("PAC test"), + QStringLiteral("Surplus insuffisant (1000 W) — PAC test en normal (état 2, non pilotée)") }, + + // --- Sécurité et cycle de vie --- + { { DecisionCode::DegradedL2 }, QString(), + QStringLiteral("Compteur muet depuis >90 s — consigne de repli (L2 watchdog)") }, + { { DecisionCode::SafeStateRelay }, QString(), + QStringLiteral("Charge désactivée — mise en état sûr avant retrait (ECS-413)") }, + { { DecisionCode::SafeStateSetpoint }, QString(), + QStringLiteral("Charge désactivée — mise en état sûr (0 W) avant retrait (ECS-413)") }, + { { DecisionCode::SafeStateSgReady }, QString(), + QStringLiteral("Charge désactivée — mise en état sûr (état 2) avant retrait (ECS-413)") }, + }; + + QSet codesCouverts; + for (const Cas &c : table) { + const QString rendu = renderFr(c.reason, c.label); + QVERIFY2(rendu == c.attendu, + qUtf8Printable(QStringLiteral("code %1\n rendu : %2\n attendu : %3") + .arg(c.reason.code, rendu, c.attendu))); + codesCouverts.insert(c.reason.code); + } + + // Le catalogue est FERMÉ : tout code ajouté sans ligne de tableau doit faire échouer ce + // test, sinon la garantie « le journal est rendu depuis le code » se perd au premier oubli. + const QStringList catalogue = { + DecisionCode::EvSurplus, DecisionCode::EvSpotMarket, DecisionCode::EvDeadline, + DecisionCode::EvEcoMin, DecisionCode::EvIdle, DecisionCode::LoadUnavailable, + DecisionCode::LockMinOn, DecisionCode::LockMinOff, DecisionCode::LockMinOffCapped, + DecisionCode::SurplusSetpoint, DecisionCode::SurplusInsufficient, + DecisionCode::LockMinStateHold, DecisionCode::SgForced, DecisionCode::SgRecommended, + DecisionCode::SgNormal, DecisionCode::DegradedL2, DecisionCode::SafeStateRelay, + DecisionCode::SafeStateSetpoint, DecisionCode::SafeStateSgReady }; + for (const QString &code : catalogue) + QVERIFY2(codesCouverts.contains(code), + qUtf8Printable(QStringLiteral("code %1 sans ligne de rendu attendue").arg(code))); + + // Repli d'un code HORS catalogue : jamais une chaîne vide. Un rendu vide ferait passer un + // motif présent-mais-inconnu pour un motif absent — le silence ambigu de la règle 7-c. + const QString inconnu = renderFr(DecisionReason("CODE_QUI_NEXISTE_PAS", {{"x", 1}}), + QStringLiteral("Charge")); + QVERIFY(!inconnu.isEmpty()); + QVERIFY2(inconnu.contains("CODE_QUI_NEXISTE_PAS"), qUtf8Printable(inconnu)); + QVERIFY(!renderFr(DecisionReason()).isEmpty()); // motif absent : dit, pas tu +#endif +} + +void Simulation::testLoadTelemetryRpc() +{ +#ifndef ETM_ARBITRATOR + QSKIP("testLoadTelemetryRpc nécessite ETM_ARBITRATOR."); +#else + // [Lot B §2] La frontière de télémétrie, de bout en bout : c'est la couche que l'app + // consomme. Le constat qui a motivé ce lot était que l'état runtime de l'arbitre ne + // franchissait AUCUNE frontière RPC — 90 s d'écoute, arbitre actif, zéro notification. + // + // Couvre les critères d'acceptation 1 à 5 : notification non nulle et cohérente, + // snapshot ≡ notification, reprise à froid du mode dégradé, arbitre figé détectable, + // zéro langue naturelle dans la charge utile. + const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-telemetry.json"; + QFile::remove(cfgPath); + qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); + + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + + EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); + QVERIFY(arb); + ThingManager *tm = NymeaCore::instance()->thingManager(); + + // Le harnais de simulation n'abonne PAS le client aux notifications (il charge son + // experience mock APRÈS initTestCase, donc EnergyTestBase saute l'abonnement). Or ce test + // porte précisément sur les notifications : sans cet appel, il « passerait » en observant + // le silence qu'il est censé faire échouer. + enableNotifications({"NymeaEnergy"}); + + QUuid meterId = addMeter(); + m_experiencePlugin->energyManager()->setRootMeter(meterId); + Thing *meter = tm->findConfiguredThing(meterId); + QVERIFY(meter); + meter->setStateValue("connected", true); + + // ---- 0. AVANT tout cycle : « pas encore d'arbitrage » se DIT, il ne se devine pas ------ + // timestamp ABSENT (omis, jamais nul) : forger un instant se lirait « cycle exécuté, rien à + // allouer », soit exactement le mensonge que ce champ existe pour empêcher. + QVariant resp = injectAndWait("NymeaEnergy.GetLoadTelemetry"); + QVariantMap vide = resp.toMap().value("params").toMap(); + QVERIFY2(!vide.contains("timestamp"), "timestamp forgé avant le premier cycle"); + QVERIFY(vide.contains("degradedMode")); + QCOMPARE(vide.value("degradedMode").toBool(), false); + QVERIFY(vide.value("loads").toList().isEmpty()); + + // ---- Trois mécanismes, un seul budget : l'union discriminée doit sortir ENTIÈRE -------- + QUuid rA = addPowerSwitch(1000, 26661); // relay-router (1 relais, minOn 300 s) + QUuid k1 = addPowerSwitch(0, 26662); // sg-ready K1 + QUuid k2 = addPowerSwitch(0, 26663); // sg-ready K2 + QUuid varId = addEtmVariableLoad(27040); // etmvariableload dynamic + Thing *relayA = tm->findConfiguredThing(rA); + Thing *varThing = tm->findConfiguredThing(varId); + QVERIFY(relayA && varThing); + QVERIFY(tm->findConfiguredThing(k1) && tm->findConfiguredThing(k2)); + varThing->setStateValue("currentPowerW", 0); + + LoadConfigStore *store = new LoadConfigStore(arb); + arb->setLoadConfigStore(store); + + LoadConfigs cfgs; + cfgs.append(LoadConfig::fromMap(QVariantMap{ + {"id", varId.toString()}, {"label", "Routeur PV"}, {"adapter", "etmvariableload"}, + {"mode", "dynamic"}, {"maxPowerW", 3000}, {"priority", 1}, {"enabled", true}, + {"domain", "ecs"}})); + cfgs.append(LoadConfig::fromMap(QVariantMap{ + {"id", "pac-telemetrie"}, {"label", "PAC"}, {"adapter", "sg-ready"}, {"mode", "fixed"}, + {"priority", 2}, {"enabled", true}, {"domain", "hvac"}, + {"sgReady", QVariantMap{ + {"minStateHoldS", 300}, + {"states", QVariantList() + << QVariantMap{{"state", 1}, {"relays", QVariantList() << k1.toString()}, + {"estimatedPowerW", 0.0}} + << QVariantMap{{"state", 2}, {"relays", QVariantList()}, + {"estimatedPowerW", 0.0}} + << QVariantMap{{"state", 3}, {"relays", QVariantList() << k2.toString()}, + {"estimatedPowerW", 800.0}} + << QVariantMap{{"state", 4}, + {"relays", QVariantList() << k1.toString() << k2.toString()}, + {"estimatedPowerW", 1600.0}}}}}})); + cfgs.append(LoadConfig::fromMap(QVariantMap{ + {"id", "ecs-relais-telemetrie"}, {"label", "ECS relais"}, {"adapter", "relay-router"}, + {"mode", "fixed"}, {"priority", 3}, {"enabled", true}, {"domain", "ecs"}, + {"relays", QVariantList() << QVariantMap{{"thingId", rA.toString()}, {"powerW", 1000}}}, + {"minOnS", 300}, {"minOffS", 0}})); + QString err; + QVERIFY2(store->setConfigs(cfgs, &err), err.toUtf8()); + + // ---- 1. Le CONSTAT rejoué : un cycle d'arbitrage produit-il une notification ? --------- + QSignalSpy notificationSpy(m_mockTcpServer, &MockTcpServer::outgoingData); + const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); + meter->setStateValue("currentPower", -6000); // 6000 W exportés + arb->simulationCallUpdate(t0); + QCoreApplication::processEvents(); + + QVariantList notifs = checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged"); + QVERIFY2(!notifs.isEmpty(), "aucune notification LoadTelemetryChanged — c'est le constat " + "d'origine, arbitre actif et frontière RPC muette"); + QVariantMap notif = notifs.last().toMap().value("params").toMap(); + + // Cohérence avec la cascade : Routeur PV (rang 1) prend 3000 (clamp maxPowerW), reliquat + // 3000 → PAC (rang 2) forcée état 4 (~1600), reliquat 1400 → ECS relais (rang 3) 1000. + QCOMPARE(notif.value("degradedMode").toBool(), false); + QVERIFY(notif.contains("timestamp")); + QCOMPARE(notif.value("timestamp").toString(), t0.toUTC().toString(Qt::ISODate)); + + const QVariantMap budget = notif.value("budget").toMap(); + QCOMPARE(qRound(budget.value("surplusW").toDouble()), 6000); + QCOMPARE(qRound(budget.value("evReservedW").toDouble()), 0); + QCOMPARE(qRound(budget.value("allocatedW").toDouble()), 5600); + QCOMPARE(qRound(budget.value("remainingW").toDouble()), 400); + QCOMPARE(qRound(budget.value("recreditedW").toDouble()), 0); // 1er cycle : rien ne coulait + // L'invariant publié : surplus + recrédit − alloué == restant. Le recrédit y figure parce + // que sans lui l'identité serait FAUSSE dès qu'une charge consomme déjà — c'est-à-dire dans + // le cas normal — et le client chercherait un écart inexplicable. + QCOMPARE(qRound(budget.value("surplusW").toDouble() + budget.value("recreditedW").toDouble() + - budget.value("allocatedW").toDouble()), + qRound(budget.value("remainingW").toDouble())); + + const QVariantList loads = notif.value("loads").toList(); + QCOMPARE(loads.size(), 3); // l'EV n'y est pas : décidé en amont + QVariantMap parId; + double sommeAllouee = 0; + for (const QVariant &v : loads) { + const QVariantMap e = v.toMap(); + parId.insert(e.value("loadId").toString(), e); + sommeAllouee += e.value("allocatedW").toDouble(); + } + // La somme des allocations RETOMBE sur budget.allocatedW : sans quoi un client qui + // additionne les lignes obtiendrait un total en désaccord avec l'en-tête. + QCOMPARE(qRound(sommeAllouee), qRound(budget.value("allocatedW").toDouble())); + + const QVariantMap eVar = parId.value(varId.toString()).toMap(); + const QVariantMap ePac = parId.value("pac-telemetrie").toMap(); + const QVariantMap eRelay = parId.value("ecs-relais-telemetrie").toMap(); + QVERIFY(!eVar.isEmpty() && !ePac.isEmpty() && !eRelay.isEmpty()); + + // Union discriminée de mécanisme : chaque variante sort avec SA charge utile, et seulement + // la sienne (LM-300/302 appliqués au sens sortant). + QCOMPARE(eVar.value("mechanism").toMap().value("kind").toString(), QString("variable")); + QCOMPARE(qRound(eVar.value("mechanism").toMap().value("setpointW").toDouble()), 3000); + QCOMPARE(qRound(eVar.value("mechanism").toMap().value("percent").toDouble()), 100); + QVERIFY(!eVar.value("mechanism").toMap().contains("state")); // charge utile d'un AUTRE mécanisme + QVERIFY(!eVar.value("mechanism").toMap().contains("stageW")); + + QCOMPARE(ePac.value("mechanism").toMap().value("kind").toString(), QString("sgReady")); + QCOMPARE(ePac.value("mechanism").toMap().value("state").toInt(), 4); + QVERIFY(!ePac.value("mechanism").toMap().contains("stageW")); + + QCOMPARE(eRelay.value("mechanism").toMap().value("kind").toString(), QString("relay")); + QCOMPARE(qRound(eRelay.value("mechanism").toMap().value("stageW").toDouble()), 1000); + QVERIFY(!eRelay.value("mechanism").toMap().contains("percent")); + + // Motifs : des CODES, avec leurs paramètres numériques. Jamais une phrase. + QCOMPARE(eVar.value("decision").toMap().value("code").toString(), + QString(DecisionCode::SurplusSetpoint)); + QCOMPARE(eVar.value("decision").toMap().value("params").toMap().value("stepped").toBool(), false); + QCOMPARE(ePac.value("decision").toMap().value("code").toString(), QString(DecisionCode::SgForced)); + QCOMPARE(eRelay.value("decision").toMap().value("code").toString(), + QString(DecisionCode::SurplusSetpoint)); + QCOMPARE(eRelay.value("decision").toMap().value("params").toMap().value("stepped").toBool(), true); + + // Verrous en SECONDES restantes, pas en watts. Les deux charges viennent de commuter à t0, + // donc leur fenêtre est pleine. Le routeur PV n'a AUCUN verrou côté moteur : la clé est + // absente, ce qui est la vérité — et non un verrou à zéro, qui se lirait « fenêtre expirée ». + QCOMPARE(eRelay.value("lock").toMap().value("kind").toString(), QString("minOn")); + QCOMPARE(eRelay.value("lock").toMap().value("remainingS").toInt(), 300); + QCOMPARE(ePac.value("lock").toMap().value("kind").toString(), QString("minStateHold")); + QCOMPARE(ePac.value("lock").toMap().value("remainingS").toInt(), 300); + QVERIFY(!eVar.contains("lock")); + + // Disponibilité, et absence de code de défaut sur une installation saine. + for (const QVariant &v : loads) { + QCOMPARE(v.toMap().value("available").toBool(), true); + QVERIFY(!v.toMap().contains("faultCode")); + } + + // ---- 2. Snapshot ≡ notification -------------------------------------------------------- + // Le Get ne recalcule pas : il rend l'objet publié. Un recalcul donnerait des verrous + // rafraîchis sous un timestamp périmé — un état qui n'a jamais existé. + resp = injectAndWait("NymeaEnergy.GetLoadTelemetry"); + QCOMPARE(resp.toMap().value("params").toMap(), notif); + + // ---- 5. ZÉRO langue naturelle dans la charge utile ------------------------------------- + // Contrôle MÉCANIQUE : toute chaîne émise est ASCII et sans espace. Une phrase française en + // porterait forcément (accents et/ou espaces). Le libellé de la charge, lui, n'entre pas + // dans la charge utile : il est substitué au RENDU, côté journal. + std::function verifierCodes = + [&](const QVariant &v, const QString &chemin) { + if (v.userType() == QMetaType::QVariantMap) { + const QVariantMap m = v.toMap(); + for (auto it = m.constBegin(); it != m.constEnd(); ++it) + verifierCodes(it.value(), chemin + '.' + it.key()); + } else if (v.userType() == QMetaType::QVariantList) { + const QVariantList l = v.toList(); + for (int i = 0; i < l.size(); ++i) + verifierCodes(l.at(i), QStringLiteral("%1[%2]").arg(chemin).arg(i)); + } else if (v.userType() == QMetaType::QString) { + const QString s = v.toString(); + for (const QChar &c : s) + QVERIFY2(c.unicode() >= 32 && c.unicode() < 127, + qUtf8Printable(QStringLiteral("%1 : caractère non ASCII dans « %2 »") + .arg(chemin, s))); + QVERIFY2(!s.contains(' '), + qUtf8Printable(QStringLiteral("%1 : espace dans « %2 » — une valeur " + "émise doit être un CODE, pas une phrase") + .arg(chemin, s))); + } + }; + verifierCodes(notif, QStringLiteral("telemetry")); + + // ---- 4. Arbitre FIGÉ détectable -------------------------------------------------------- + // Aucun cycle nouveau : le battement republie l'instantané tel quel. Le client reçoit donc + // un état, et son timestamp N'AVANCE PAS — c'est la preuve de non-fraîcheur. Sans + // battement, « installation stable » et « moteur arrêté » sont le même silence. + notificationSpy.clear(); + arb->publishTelemetryHeartbeat(); + QCoreApplication::processEvents(); + notifs = checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged"); + QVERIFY2(!notifs.isEmpty(), "aucun battement de cœur"); + const QVariantMap battement = notifs.last().toMap().value("params").toMap(); + QCOMPARE(battement.value("timestamp").toString(), notif.value("timestamp").toString()); + + // ---- Le SEUIL : une oscillation de quelques watts ne réveille personne ------------------ + // Le routeur est déjà à son plafond ; un surplus voisin ne change ni allocation ni code de + // motif. Sans seuil, chaque cycle notifierait pour rien sur une cible RPi Zero 2W. + notificationSpy.clear(); + meter->setStateValue("currentPower", -6010); // +10 W, sous le seuil + arb->simulationCallUpdate(t0.addSecs(60)); + QCoreApplication::processEvents(); + QVERIFY2(checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged").isEmpty(), + "une variation de 10 W a notifié — le seuil ne mord pas"); + // …mais le Get, lui, a bien avancé : l'état INTERNE est à jour même sans notification. + resp = injectAndWait("NymeaEnergy.GetLoadTelemetry"); + const QVariantMap c2 = resp.toMap().value("params").toMap(); + QCOMPARE(c2.value("timestamp").toString(), t0.addSecs(60).toUTC().toString(Qt::ISODate)); + // Deuxième cycle : les charges consomment, donc le RECRÉDIT n'est plus nul — et c'est + // exactement le cas où l'identité serait fausse sans lui. L'allocation TOTALE, elle, n'a + // pas bougé : c'est pour cela que le seuil ne mord pas. + const QVariantMap b2 = c2.value("budget").toMap(); + QVERIFY2(qRound(b2.value("recreditedW").toDouble()) > 0, "recrédit nul alors que les charges consomment"); + QCOMPARE(qRound(b2.value("allocatedW").toDouble()), 5600); + QCOMPARE(qRound(b2.value("surplusW").toDouble() + b2.value("recreditedW").toDouble() + - b2.value("allocatedW").toDouble()), + qRound(b2.value("remainingW").toDouble())); + + // ---- 3. Reprise à froid du mode dégradé ------------------------------------------------ + // Un client qui se connecte APRÈS la bascule doit la lire, par GetLoadTelemetry ET par + // GetChargingSchedules. C'est la famille ECS-411 : une notification seule laisse le + // nouveau venu dans un silence ambigu. + notificationSpy.clear(); + arb->recordMeterUpdate(t0.addSecs(60)); + arb->evaluateMeterFreshness(t0.addSecs(60 + 91)); + QCoreApplication::processEvents(); + QVERIFY(arb->degradedMode()); + + notifs = checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged"); + QVERIFY2(!notifs.isEmpty(), "bascule en mode dégradé sans notification de télémétrie"); + const QVariantMap dgr = notifs.last().toMap().value("params").toMap(); + QCOMPARE(dgr.value("degradedMode").toBool(), true); + // budget ABSENT : la planification est suspendue, il n'y a pas de budget pour ce cycle. + // Publier des zéros se lirait « arbitrage exécuté, rien à allouer » — l'inverse du vrai. + QVERIFY2(!dgr.contains("budget"), "budget publié en mode dégradé (planification suspendue)"); + QVERIFY(!dgr.value("loads").toList().isEmpty()); + for (const QVariant &v : dgr.value("loads").toList()) { + QCOMPARE(v.toMap().value("decision").toMap().value("code").toString(), + QString(DecisionCode::DegradedL2)); + QCOMPARE(qRound(v.toMap().value("allocatedW").toDouble()), 0); + } + verifierCodes(dgr, QStringLiteral("telemetry-degrade")); + + // Le nouveau venu lit l'état par les DEUX portes. + resp = injectAndWait("NymeaEnergy.GetLoadTelemetry"); + QCOMPARE(resp.toMap().value("params").toMap().value("degradedMode").toBool(), true); + resp = injectAndWait("NymeaEnergy.GetChargingSchedules"); + QVERIFY2(resp.toMap().value("params").toMap().contains("degradedMode"), + "GetChargingSchedules ne rend toujours pas degradedMode (poussé en notification " + "seule : le défaut que ce lot corrige)"); + QCOMPARE(resp.toMap().value("params").toMap().value("degradedMode").toBool(), true); + + // Sortie du mode dégradé : symétrique, et publiée TOUT DE SUITE. La planification ne + // reprend qu'au cycle suivant ; sans cette republication, la télémétrie annoncerait encore + // degradedMode=true pendant une minute après la levée du repli. + notificationSpy.clear(); + arb->recordMeterUpdate(t0.addSecs(300)); + QCoreApplication::processEvents(); + QVERIFY(!arb->degradedMode()); + notifs = checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged"); + QVERIFY2(!notifs.isEmpty(), "sortie du mode dégradé sans notification"); + QCOMPARE(notifs.last().toMap().value("params").toMap().value("degradedMode").toBool(), false); + resp = injectAndWait("NymeaEnergy.GetChargingSchedules"); + QCOMPARE(resp.toMap().value("params").toMap().value("degradedMode").toBool(), false); + + qunsetenv("NYMEA_ENERGY_LOAD_CONFIG"); + QFile::remove(cfgPath); +#endif +} + +void Simulation::testLoadConfigDomain() +{ +#ifndef ETM_ARBITRATOR + QSKIP("testLoadConfigDomain nécessite ETM_ARBITRATOR."); +#else + // [Lot B §3] o:domain — PURE MÉTADONNÉE d'intention. Trois exigences, plus une quatrième + // qui n'est pas dans la liste mais qui est la raison d'être du champ : l'arbitrage ne + // change pas. Un champ « sans effet » se prouve, il ne se déclare pas. + const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-domain.json"; + QFile::remove(cfgPath); + qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); + + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + + EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); + QVERIFY(arb); + ThingManager *tm = NymeaCore::instance()->thingManager(); + + QUuid meterId = addMeter(); + m_experiencePlugin->energyManager()->setRootMeter(meterId); + Thing *meter = tm->findConfiguredThing(meterId); + QVERIFY(meter); + meter->setStateValue("connected", true); + + QUuid ecsId = addEtmVariableLoad(27050); + Thing *ecsThing = tm->findConfiguredThing(ecsId); + QVERIFY(ecsThing); + ecsThing->setStateValue("currentPowerW", 0); + + // 1. Charge AVEC domaine + charge SANS domaine : les deux acceptées. + QVariantList configs; + configs << QVariantMap{ + {"id", ecsId.toString()}, {"label", "Chauffe-eau"}, {"adapter", "etmvariableload"}, + {"mode", "fixed"}, {"powerLevels", QVariantList() << 0 << 2400}, {"maxPowerW", 2400}, + {"priority", 1}, {"enabled", true}, {"domain", "ecs"}}; + configs << QVariantMap{ // aucune clé « domain » : non classée + {"id", "{cafe0001-0000-0000-0000-000000000001}"}, {"label", "Sans domaine"}, + {"adapter", "etmvariableload"}, {"mode", "dynamic"}, {"maxPowerW", 1000}, + {"priority", 2}, {"enabled", true}}; + QVariant resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", configs}}); + QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + + // 2. ALLER-RETOUR lire → réécrire sans perte. C'est le geste ordinaire de l'app, et c'est + // le mur qu'a touché LM-302 : GetLoadConfig sérialise via le méta-objet, donc TOUTES les + // propriétés déclarées. Tout mécanisme ou champ ajouté embarque ce test. + resp = injectAndWait("NymeaEnergy.GetLoadConfig"); + const QVariantList lu = resp.toMap().value("params").toMap().value("loadConfigs").toList(); + QCOMPARE(lu.size(), 2); + QVariantMap avec, sans; + for (const QVariant &v : lu) { + if (v.toMap().value("id").toString() == ecsId.toString()) avec = v.toMap(); + else sans = v.toMap(); + } + QCOMPARE(avec.value("domain").toString(), QString("ecs")); + // Forme EXACTE d'une charge non classée en sortie RPC : la clé est PRÉSENTE et VIDE. + // GetLoadConfig sérialise via le méta-objet, donc toutes les propriétés déclarées sortent, + // y compris vides — comme « sgReady: {} » ou « powerLevels: [] » ici même. C'est le + // corollaire LM-302, et c'est ce qui rend l'aller-retour Get → Set possible. L'assertion + // est exacte plutôt que permissive : une tolérance masquerait un changement de forme, or + // c'est la forme que l'app consomme. Vérifié identique au banc le 2026-08-25. + QVERIFY2(sans.contains("domain"), "la propriété domain doit sortir même vide (méta-objet)"); + QCOMPARE(sans.value("domain").toString(), QString()); + QVERIFY(sans.value("sgReady").toMap().isEmpty()); // même forme, pour comparaison + + resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", lu}}); + QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + resp = injectAndWait("NymeaEnergy.GetLoadConfig"); + for (const QVariant &v : resp.toMap().value("params").toMap().value("loadConfigs").toList()) + if (v.toMap().value("id").toString() == ecsId.toString()) + QCOMPARE(v.toMap().value("domain").toString(), QString("ecs")); // survit au tour + + // 3. Valeur HORS énumération : rejet EN BLOC. Une valeur libre acceptée aujourd'hui + // deviendrait une valeur à supporter le jour où la couche domaine en fera son + // discriminant. + QVariantList mauvais = configs; + QVariantMap m0 = mauvais.first().toMap(); + m0["domain"] = "chauffe-eau"; // libellé, pas un code de l'énumération + mauvais[0] = m0; + resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", mauvais}}); + QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorInvalidParameter")); + // Rien n'a été écrasé : la config valide tient toujours. + resp = injectAndWait("NymeaEnergy.GetLoadConfig"); + QCOMPARE(resp.toMap().value("params").toMap().value("loadConfigs").toList().size(), 2); + + // Toute la liste de l'énumération est acceptée — sinon « fermée » voudrait dire « arbitraire ». + for (const QString &d : LoadConfig::knownDomains()) { + QVariantList une; + une << QVariantMap{ + {"id", ecsId.toString()}, {"label", "Chauffe-eau"}, {"adapter", "etmvariableload"}, + {"mode", "fixed"}, {"powerLevels", QVariantList() << 0 << 2400}, {"maxPowerW", 2400}, + {"priority", 1}, {"enabled", true}, {"domain", d}}; + resp = injectAndWait("NymeaEnergy.SetLoadConfig", {{"loadConfigs", une}}); + QCOMPARE(resp.toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + } + + // 4. AUCUN effet sur l'arbitrage : la charge est servie exactement pareil, domaine ou non. + // Le champ enregistre une intention d'installateur ; il ne pilote rien. + meter->setStateValue("currentPower", -3000); + arb->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0))); + QCoreApplication::processEvents(); + QCOMPARE(qRound(ecsThing->stateValue("powerSetpoint").toDouble()), 2400); + + qunsetenv("NYMEA_ENERGY_LOAD_CONFIG"); + QFile::remove(cfgPath); +#endif +} + +void Simulation::testRuntimeViewIsSubstantial() +{ +#ifndef ETM_ARBITRATOR + QSKIP("testRuntimeViewIsSubstantial nécessite ETM_ARBITRATOR."); +#else + // [Lot B §2] La pure virtuelle `ILoadAdapter::runtimeView()` force chaque adaptateur à + // RÉPONDRE — elle ne l'oblige pas à répondre quelque chose. Une implémentation qui rendrait + // `LoadRuntimeView{}` compilerait tout aussi bien, passerait le dispatch, et sortirait de la + // télémétrie SANS BRUIT : le client verrait une charge sans mécanisme et sans verrou, + // indistinguable d'une charge libre. C'est exactement le silence ambigu que le garde-fou + // prétend empêcher, et le compilateur ne le voit pas. + // + // Ce test est le second verrou : pour CHAQUE adaptateur, la vue doit être SUBSTANTIELLE + // (mécanisme nommé, charge utile non vide) ET porter les valeurs RUNTIME réelles — pas des + // valeurs par défaut qui coïncideraient par hasard. On amène donc chaque adaptateur dans un + // état NON trivial avant de lire. + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + + EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); + QVERIFY(arb); + ThingManager *tm = NymeaCore::instance()->thingManager(); + + QUuid meterId = addMeter(); + m_experiencePlugin->energyManager()->setRootMeter(meterId); + Thing *meter = tm->findConfiguredThing(meterId); + QVERIFY(meter); + meter->setStateValue("connected", true); + + const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); + + // ---- 1. RelayRouter : palier appliqué en WATTS + verrou minOn en SECONDES -------------- + QUuid rA = addPowerSwitch(1000, 26661); + QUuid rB = addPowerSwitch(1500, 26662); + QVERIFY(tm->findConfiguredThing(rA) && tm->findConfiguredThing(rB)); + RelayRouter *routeur = new RelayRouter(tm, "rv-relais", "Routeur de relais", + QList({ {rA.toString(), 1000}, {rB.toString(), 1500} }), + 300, 0, 1, LoadNeeds(), arb); + + LoadAction monte; + monte.kind = LoadAction::Setpoint; monte.funding = LoadAction::Surplus; + monte.powerW = 2500; monte.reason = { DecisionCode::SurplusSetpoint }; + routeur->applyAction(monte, t0); + QCOMPARE(qRound(routeur->currentSetpointW()), 2500); // état NON trivial : palier haut + + LoadRuntimeView vRelais = routeur->runtimeView(t0.addSecs(45)); + QCOMPARE(vRelais.mechanismKind, QString("relay")); + // La charge utile porte le palier RÉELLEMENT appliqué, pas 0 ni le plafond. + QCOMPARE(qRound(vRelais.mechanism.value("stageW").toDouble()), 2500); + QCOMPARE(qRound(vRelais.mechanism.value("maxStageW").toDouble()), 2500); + // Verrou : minOn armé à t0, lu 45 s plus tard → 255 s restantes. Une vue vide donnerait 0, + // qui se lirait « fenêtre expirée » — l'inverse de la vérité. + QCOMPARE(vRelais.lockKind, QString("minOn")); + QCOMPARE(vRelais.lockRemainingS, 255); + QVERIFY(vRelais.faultCode.isEmpty()); // installation saine + // …et le verrou EXPIRE : sans cela, un « 255 » constant passerait le test ci-dessus. + QVERIFY(routeur->runtimeView(t0.addSecs(301)).lockKind.isEmpty()); + + // ---- 2. EtmVariableLoadAdapter : consigne, pourcentage dérivé, aucun verrou ----------- + QUuid varId = addEtmVariableLoad(27060); + Thing *varThing = tm->findConfiguredThing(varId); + QVERIFY(varThing); + varThing->setStateValue("currentPowerW", 0); + EtmVariableLoadAdapter *continu = new EtmVariableLoadAdapter( + tm, varId.toString(), "Routeur PV", QList(), 4000, 2, LoadNeeds(), arb); + + LoadAction consigne; + consigne.kind = LoadAction::Setpoint; consigne.funding = LoadAction::Surplus; + consigne.powerW = 3000; consigne.reason = { DecisionCode::SurplusSetpoint }; + continu->applyAction(consigne, t0); + QCOMPARE(qRound(continu->currentSetpointW()), 3000); + + LoadRuntimeView vContinu = continu->runtimeView(t0); + QCOMPARE(vContinu.mechanismKind, QString("variable")); + QCOMPARE(qRound(vContinu.mechanism.value("setpointW").toDouble()), 3000); + QCOMPARE(qRound(vContinu.mechanism.value("maxPowerW").toDouble()), 4000); + // percent est DÉRIVÉ (3000/4000) : une valeur choisie pour qu'aucun défaut plausible — + // 0, 100, ou la consigne elle-même — ne puisse passer par accident. + QCOMPARE(vContinu.mechanism.value("percent").toDouble(), 75.0); + // Aucune fenêtre de verrou côté moteur : l'anti-rebond vit dans le thing. L'ABSENCE est ici + // la vérité, et elle est assertée — pas seulement tolérée. + QVERIFY(vContinu.lockKind.isEmpty()); + QVERIFY(vContinu.faultCode.isEmpty()); + + // Le code de défaut n'est pas du code mort : un thing absent de la configuration le lève. + EtmVariableLoadAdapter *fantome = new EtmVariableLoadAdapter( + tm, "{deadbeef-0000-0000-0000-000000000042}", "Charge fantôme", + QList(), 1000, 3, LoadNeeds(), arb); + QCOMPARE(fantome->runtimeView(t0).faultCode, QString("THING_MISSING")); + + // ---- 3. SgReadyAdapter : état normé, estimation, verrou minStateHold ------------------ + QUuid k1 = addPowerSwitch(0, 26663); + QUuid k2 = addPowerSwitch(0, 26664); + QVERIFY(tm->findConfiguredThing(k1) && tm->findConfiguredThing(k2)); + SgReadyAdapter *pac = new SgReadyAdapter( + tm, "rv-pac", "PAC", + QHash>({ {1, {k1.toString()}}, {2, {}}, + {3, {k2.toString()}}, {4, {k1.toString(), k2.toString()}} }), + QHash({ {1, 0.0}, {2, 0.0}, {3, 1500.0}, {4, 2600.0} }), + 900, 4, arb); + + LoadAction etat; + etat.kind = LoadAction::State; etat.funding = LoadAction::Surplus; + etat.state = 3; etat.reason = { DecisionCode::SgRecommended }; + pac->applyAction(etat, t0); + QCOMPARE(pac->currentState(), 3); // état NON trivial (le défaut est 2) + + LoadRuntimeView vPac = pac->runtimeView(t0.addSecs(120)); + QCOMPARE(vPac.mechanismKind, QString("sgReady")); + QCOMPARE(vPac.mechanism.value("state").toInt(), 3); + QCOMPARE(qRound(vPac.mechanism.value("estimatedPowerW").toDouble()), 1500); + QCOMPARE(vPac.lockKind, QString("minStateHold")); + QCOMPARE(vPac.lockRemainingS, 780); // 900 − 120 + QVERIFY(vPac.faultCode.isEmpty()); + QVERIFY(pac->runtimeView(t0.addSecs(901)).lockKind.isEmpty()); // le verrou expire + + // ECS-110 — une PAC dont l'encodage ne permet pas l'état 2 n'est JAMAIS pilotable. Le taire + // la ferait passer pour une PAC au repos ; le code le dit. + SgReadyAdapter *bancale = new SgReadyAdapter( + tm, "rv-pac-bancale", "PAC sans état 2", + QHash>({ {3, {k2.toString()}} }), + QHash({ {3, 1500.0} }), 300, 5, arb); + QCOMPARE(bancale->runtimeView(t0).faultCode, QString("UNUSABLE_ENCODING")); + + // ---- 4. EvAdapter : hors charge utile en beta, mais IMPLÉMENTÉ ------------------------ + // Déclaré et écrit dès maintenant : en 3g, quand le VE rejoindra le waterfall unifié, il + // n'y aura rien à écrire ici. Une vue vide se découvrirait ce jour-là, en production. + // « ABC » et pas le défaut « All » : le mock n'accepte que A,B,C,AB,BC,AC,ABC. + QUuid chargerId = addCharger(QStringLiteral("ABC")); + QVERIFY(!chargerId.isNull()); + arb->simulationCallUpdate(t0); // syncAdapters() crée les EvAdapter + QCoreApplication::processEvents(); + EvCharger *borne = arb->registeredEvChargers().value(ThingId(chargerId.toString())); + QVERIFY2(borne, "la borne EV n'est pas enregistrée auprès de l'arbitre"); + EvAdapter *ev = new EvAdapter(borne, arb); + + LoadRuntimeView vEv = ev->runtimeView(t0); + QCOMPARE(vEv.mechanismKind, QString("evcharger")); + for (const char *cle : {"chargingEnabled", "pluggedIn", "currentA", "phaseCount"}) + QVERIFY2(vEv.mechanism.contains(QLatin1String(cle)), + qUtf8Printable(QStringLiteral("clé « %1 » absente de la vue evcharger").arg(cle))); + + // ---- 5. Le verrou générique : AUCUNE vue vide, pour AUCUN adaptateur ------------------ + // C'est l'assertion qui échouerait si un adaptateur futur se contentait de « return {}; ». + // Elle porte sur la LISTE des adaptateurs, pas sur un cas particulier : ajouter un + // mécanisme sans l'ajouter ici laisse le trou ouvert, donc la liste est nommée en dur. + struct Cas { const char *nom; ILoadAdapter *a; }; + const QList tous = { {"relay-router", routeur}, {"etmvariableload", continu}, + {"sg-ready", pac}, {"evcharger", ev} }; + for (const Cas &c : tous) { + const LoadRuntimeView v = c.a->runtimeView(t0); + QVERIFY2(!v.mechanismKind.isEmpty(), + qUtf8Printable(QStringLiteral("%1 : mechanismKind VIDE — vue non implémentée").arg(c.nom))); + QVERIFY2(!v.mechanism.isEmpty(), + qUtf8Printable(QStringLiteral("%1 : charge utile de mécanisme VIDE").arg(c.nom))); + // Une charge utile qui ne porterait que « kind » serait une vue vide déguisée : le + // client n'y lirait rien de l'état réel. On exige au moins une grandeur. + QVERIFY2(v.mechanism.count() >= 2, + qUtf8Printable(QStringLiteral("%1 : charge utile réduite à un discriminant").arg(c.nom))); + } +#endif +} + void Simulation::printStates(Thing *thing) { qCDebug(dcSimulation()) << "Thing states for" << thing->name(); diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index cfcaf00..b7488dd 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -117,6 +117,26 @@ private slots: // les identifiants en double, et la tolérance d'une charge désactivée. void testThingOwnershipIsExclusive(); + // [Lot B §1] Rendu français des motifs : chaque code du catalogue produit MOT POUR MOT la + // phrase d'avant la refonte {code, params}. Le journal se prouve par comparaison, pas par + // relecture — et une phrase reformulée au passage ferait échouer ce test, pas un humain. + void testDecisionReasonRendering(); + + // [Lot B §2] Frontière de télémétrie : GetLoadTelemetry ≡ LoadTelemetryChanged, verrous en + // secondes, union discriminée de mécanisme, reprise à froid du mode dégradé, battement de + // cœur, et ZÉRO langue naturelle dans la charge utile. + void testLoadTelemetryRpc(); + + // [Lot B §3] o:domain : aller-retour lire→réécrire sans perte, valeur hors énumération + // rejetée, charge sans domaine acceptée — et aucun effet sur l'arbitrage. + void testLoadConfigDomain(); + + // [Lot B §2] La pure virtuelle force à RÉPONDRE, pas à répondre quelque chose : une vue + // vide compilerait tout aussi bien et sortirait de la télémétrie sans bruit. Ce test exige + // une vue SUBSTANTIELLE — mécanisme nommé, charge utile portant les valeurs runtime + // réelles, verrou en secondes qui expire — pour les QUATRE adaptateurs. + void testRuntimeViewIsSubstantial(); + void printStates(Thing *thing); void updateChargerMeter(Thing *thing);