From 92d0bef4acb4daa221ed30a681572f0f495e5c0f Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 9 Aug 2026 11:32:51 +0200 Subject: [PATCH] =?UTF-8?q?fix(etm):=20ECS-410=20=E2=80=94=20=C3=A9chec=20?= =?UTF-8?q?d'=C3=A9criture=20relais,=20=C3=A9chelle=20born=C3=A9e=20et=20d?= =?UTF-8?q?=C3=A9faut=20collant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit writeRelay() jetait le ThingActionInfo* : aucun acquittement, aucun retour arrière, available codé en dur à true, et m_currentStage mis à jour comme si tout avait réussi — on annonçait une puissance non appliquée. MODÈLE ASYNCHRONE. executeAction est asynchrone et update() ne doit jamais attendre (AGENTS règle 5). « Attendre le résultat » ne veut donc pas dire bloquer le cycle : l'écriture est émise, l'adaptateur retient combien d'acquittements il attend (m_pending), le verdict tombe quand le compteur retombe à zéro, et la conséquence est traitée au cycle suivant. Motif repris tel quel d'EvCharger (evcharger.cpp:293-299), y compris `this` en contexte de connexion — si l'adaptateur meurt, les callbacks sont coupés proprement. INDÉTERMINATION. Pendant une transition, telemetry() annonce max(m_stagePrev, m_stageTarget) : on ne sait pas ce qui est fermé, on annonce donc la plus haute des deux puissances possibles. Même direction qu'ECS-411 (relais injoignable supposé fermé) — ne jamais annoncer moins que ce qui peut être appliqué. Sous-estimer fait sur-allouer les charges suivantes ; surestimer ne fait que retarder une montée. ÉCHELLE BORNÉE à trois barreaux, une tentative chacun, aucune boucle : cible → retour arrière → arrêt total → défaut. Le retour arrière est asynchrone au même titre et passe par le même compteur. DÉFAUT COLLANT, pas clignotant. m_faulted est un verrou posé une seule fois, sans délai ni expiration : available ne peut pas osciller d'un cycle à l'autre. Seul NymeaEnergy.ClearLoadFault le lève — acte délibéré et journalisé de l'opérateur. La reconstruction le lève aussi, mais par construction : un adaptateur neuf n'a pas d'historique. À la levée, l'état matériel est RELU (ECS-411), pas supposé. CANAL OUVERT. LoadContext n'avait AUCUN champ available : le publier aurait été décoratif. Ajouté à LoadContextTelemetry, avec sa sémantique écrite noir sur blanc — il gouverne l'allocation, PAS la comptabilité. Une charge en défaut ne reçoit rien mais reste comptée : une puissance qu'on ne sait plus couper est de la conso fixe, au même titre que la base de la maison. Le figeage est porté par lockMin == lockMax == puissance crue engagée, jamais un plafond nul sous un plancher non nul. Le même canal servira ECS-601. OPTIMIZER_PROTOCOL.md mis à jour dans le MÊME lot, comme l'exige le §11 de la spec : available, lockMinPowerW et lockMaxPowerW documentés avec leur sémantique. clearFault() est PURE VIRTUELLE sur ILoadAdapter : les trois autres adaptateurs la déclarent sans effet plutôt que d'hériter d'un défaut vide. Leur généralisation est portée par ECS-414. Test testEcsPartialFailure : cas nominal sans défaut, puis échelle complète via un relais introuvable — défaut atteint, relais valide bien ramené à l'ouverture par la tentative d'arrêt total, available faux, plancher == plafond == puissance comptée, plus aucune commande une heure plus tard, puis levée délibérée. Build amd64 0 erreur. Simulation : 16/16. Co-Authored-By: Claude Opus 5 (1M context) --- docs/OPTIMIZER_PROTOCOL.md | 19 ++ .../etm/adapters/etmvariableloadadapter.h | 5 + energyplugin/etm/adapters/evadapter.h | 5 + energyplugin/etm/adapters/iloadadapter.h | 14 ++ energyplugin/etm/adapters/relayrouter.cpp | 165 ++++++++++++++++-- energyplugin/etm/adapters/relayrouter.h | 30 ++++ energyplugin/etm/adapters/sgreadyadapter.h | 5 + energyplugin/etm/energyarbitrator.cpp | 12 ++ energyplugin/etm/energyarbitrator.h | 9 + .../etm/scheduler/rulebasedscheduler.cpp | 23 +++ energyplugin/etm/types/surpluscontext.h | 16 ++ energyplugin/nymeaenergyjsonhandler.cpp | 30 ++++ energyplugin/nymeaenergyjsonhandler.h | 11 ++ tests/auto/simulation/simulation.cpp | 83 +++++++++ tests/auto/simulation/simulation.h | 3 + 15 files changed, 411 insertions(+), 19 deletions(-) diff --git a/docs/OPTIMIZER_PROTOCOL.md b/docs/OPTIMIZER_PROTOCOL.md index b56ec65..3f07148 100644 --- a/docs/OPTIMIZER_PROTOCOL.md +++ b/docs/OPTIMIZER_PROTOCOL.md @@ -150,6 +150,25 @@ Notes de schéma : - `forecast` : contenu défini par `CONTRACT_A_openmeteo.md`. Peut être `null` (l'optimiseur peut sourcer ses propres prévisions — il est libre). +### `loads[].telemetry` — champs ETM non encore normalisés + +Trois champs ajoutés par l'implémentation ETM et **absents des exemples ci-dessus**. +Ils sont documentés ici parce qu'ils changent la lecture du contexte, et qu'une +implémentation d'optimiseur qui les ignore prendra de mauvaises décisions. + +| Champ | Type | Sens | +|---|---|---| +| `available` | bool | **Gouverne l'ALLOCATION, pas la COMPTABILITÉ.** Faux ⇒ la charge ne doit recevoir **aucune** consigne neuve — mais elle **continue d'être comptée** pour ce qu'elle consomme via `currentPowerW`. Un optimiseur qui se contenterait de « sauter » les charges indisponibles sauterait aussi leur comptabilité, et offrirait aux charges suivantes un budget qui n'existe pas. | +| `lockMinPowerW` | W | Plancher imposé **maintenant** par un verrou anti-rebond : puissance déjà engagée que l'adaptateur ne peut pas couper. Le budget DOIT être décrémenté de cette valeur **même si elle dépasse le budget disponible** — sinon les charges de rang suivant reçoivent un résidu surestimé et l'installation soutire au réseau. | +| `lockMaxPowerW` | W | Plafond imposé **maintenant** par un verrou : au-delà, l'adaptateur refusera de démarrer. **Négatif ⇒ aucun plafond** (et non « plafond nul »). | + +Une charge en défaut d'écriture est **figée** : `available = false` et +`lockMinPowerW == lockMaxPowerW ==` la puissance crue engagée. Le plancher n'est +jamais supérieur au plafond — un tel couple ferait dérailler tout écrêtage. + +Ces champs sont exprimés **en watts**, jamais en index de palier : aucun +identifiant de relais ni index de combinaison ne franchit cette frontière. + ## 6. Réponse : plan par créneaux ```json diff --git a/energyplugin/etm/adapters/etmvariableloadadapter.h b/energyplugin/etm/adapters/etmvariableloadadapter.h index 2359938..29df9ad 100644 --- a/energyplugin/etm/adapters/etmvariableloadadapter.h +++ b/energyplugin/etm/adapters/etmvariableloadadapter.h @@ -111,6 +111,11 @@ public: void updateSoftConfig(int priority, const LoadNeeds &needs) override { m_priority = priority; m_needs = needs; } + //! \brief Sans effet : cet adaptateur n'implémente pas encore l'échelle de défaut ECS-410. + //! La généralisation est portée par **ECS-414** (lot de mise en configuration + //! du SgReadyAdapter). Déclaré explicitement plutôt qu'hérité d'un défaut vide. + void clearFault() override {} + /*! * \brief Dernière consigne (W) effectivement écrite (avant écrêtage thing). * \return Consigne commandée en W, bornée à \c maxPowerW ; 0 tant qu'aucune action diff --git a/energyplugin/etm/adapters/evadapter.h b/energyplugin/etm/adapters/evadapter.h index ec94e27..5dc081f 100644 --- a/energyplugin/etm/adapters/evadapter.h +++ b/energyplugin/etm/adapters/evadapter.h @@ -76,6 +76,11 @@ public: void updateSoftConfig(int priority, const LoadNeeds &needs) override { Q_UNUSED(priority) Q_UNUSED(needs) } + //! \brief Sans effet : l'EV n'implémente pas encore l'échelle de défaut ECS-410. + //! La généralisation est portée par **ECS-414** (lot de mise en configuration + //! du SgReadyAdapter). Déclaré explicitement plutôt qu'hérité d'un défaut vide. + void clearFault() override {} + /*! * \brief Borne VE sous-jacente (lecture). * \return Pointeur non-propriétaire vers l'EvCharger ; jamais nul (fixé à la construction). diff --git a/energyplugin/etm/adapters/iloadadapter.h b/energyplugin/etm/adapters/iloadadapter.h index 38bdfac..b03326f 100644 --- a/energyplugin/etm/adapters/iloadadapter.h +++ b/energyplugin/etm/adapters/iloadadapter.h @@ -92,4 +92,18 @@ public: * la config ignorerait sans bruit les changements de rang. */ virtual void updateSoftConfig(int priority, const LoadNeeds &needs) = 0; + + /*! + * \brief Lève le verrou de défaut de la charge (ECS-410). + * + * Le défaut est COLLANT par conception — « cesser toute commande jusqu'à intervention ». + * Sa levée est un acte délibéré de l'opérateur, jamais une expiration : sans quoi + * \c available oscillerait d'un cycle à l'autre et le waterfall réagirait à chaque + * battement. + * + * \note **Pure virtuelle à dessein.** Tous les adaptateurs ne savent pas encore tomber en + * défaut — seul \c RelayRouter implémente l'échelle ECS-410 — mais chacun doit le + * DÉCLARER. La généralisation est portée par **ECS-414**. + */ + virtual void clearFault() = 0; }; diff --git a/energyplugin/etm/adapters/relayrouter.cpp b/energyplugin/etm/adapters/relayrouter.cpp index 9c77c13..fbde38a 100644 --- a/energyplugin/etm/adapters/relayrouter.cpp +++ b/energyplugin/etm/adapters/relayrouter.cpp @@ -11,6 +11,7 @@ #include #include #include +#include namespace { //! Au-delà, 2^N combinaisons explose. 16 relais = 65536 combos — bien au-delà de tout ECS réel. @@ -93,7 +94,7 @@ LoadDescriptor RelayRouter::descriptor() const LoadTelemetry RelayRouter::telemetry() const { LoadTelemetry t; - t.available = true; + t.available = !m_faulted; // ECS-410 : en défaut, la charge sort de l'ARBITRAGE… t.lastActionAt = m_lastActionAt; // currentPowerW : @@ -118,6 +119,19 @@ LoadTelemetry RelayRouter::telemetry() const if (!metered && m_currentStage > 0 && m_currentStage < m_levels.size()) power = m_levels.at(m_currentStage); // nominal commandé + // ECS-410 — INDÉTERMINATION. Tant que des écritures sont en vol, on ne sait pas ce qui est + // réellement fermé : on annonce la PLUS HAUTE des deux puissances possibles. Même direction + // qu'ECS-411 (relais injoignable supposé fermé) : ne JAMAIS annoncer moins que ce qui peut + // être appliqué. Sous-estimer fait sur-allouer les charges suivantes et soutirer au réseau ; + // surestimer ne fait que retarder une montée. + if (!metered && m_pending > 0) { + const int hi = qMax(m_stagePrev, m_stageTarget); + if (hi >= 0 && hi < m_levels.size()) + power = qMax(power, static_cast(m_levels.at(hi))); + } + + // …mais PAS de la COMPTABILITÉ : une puissance qu'on ne sait plus couper reste consommée. + // La retirer du bilan offrirait aux charges suivantes un budget qui n'existe pas. t.currentPowerW = power; return t; } @@ -139,6 +153,17 @@ LoadContext RelayRouter::toLoadContext(const QDateTime &now) const // internes en puissances via la table de paliers. Sans ce canal, le scheduler décrémente // le budget d'une valeur que l'adaptateur va écrêter, et les charges suivantes reçoivent // un résidu surestimé pendant toute la fenêtre minOn (AGENTS règle 4). + ctx.telemetry.available = !m_faulted; + + if (m_faulted) { + // ECS-410 — charge FIGÉE, ni montée ni descente : plancher ET plafond à la puissance + // crue engagée. Un plafond nul avec un plancher non nul serait contradictoire et + // ferait dérailler le qBound du scheduler. + ctx.telemetry.lockMinPowerW = ctx.telemetry.currentPowerW; + ctx.telemetry.lockMaxPowerW = ctx.telemetry.currentPowerW; + return ctx; + } + int minStage, maxStage; lockWindow(now, minStage, maxStage); ctx.telemetry.lockMinPowerW = m_levels.at(minStage); @@ -157,6 +182,14 @@ LoadAction RelayRouter::applyAction(const LoadAction &action, const QDateTime &n return action; } + // ECS-410 — en défaut, on CESSE TOUTE COMMANDE, y compris les replis forcés : le matériel + // n'a pas répondu à trois tentatives, en réémettre n'apporterait rien et masquerait l'état. + if (m_faulted) { + LoadAction refused = action; + refused.powerW = refused.estimatedPowerW = currentSetpointW(); + return refused; + } + // ECS-412 — ARMEMENT PARESSEUX du verrou (démarrage à froid). Au premier \c now reçu, // on estampille la « dernière commutation » : le verrou est donc armé pour sa durée // configurée, puis EXPIRE naturellement. @@ -324,13 +357,110 @@ int RelayRouter::deduceStageFromThings() const return m_levels.size() - 1; } -void RelayRouter::applyRelayStage(int stage) +void RelayRouter::writeRelay(const QString &thingId, bool on) +{ + Thing *relay = m_thingManager ? m_thingManager->findConfiguredThing(ThingId(thingId)) : nullptr; + if (!relay) { + qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label << "— relais non trouvé:" << thingId; + m_writeFailed = true; // compte comme un échec : ECS-410 §1 + return; + } + StateType powerStateType = relay->thingClass().stateTypes().findByName("power"); + if (powerStateType.id().isNull()) { + relay->setStateValue("power", on); // repli mock : synchrone, toujours réussi + return; + } + + Action powerAction(powerStateType.id(), relay->id(), Action::TriggeredByRule); + powerAction.setParams(ParamList() << Param(powerStateType.id(), on)); + ThingActionInfo *info = m_thingManager->executeAction(powerAction); + if (!info) { + m_writeFailed = true; + return; + } + + // ECS-410 — on n'attend PAS : le cycle rend la main immédiatement (AGENTS règle 5). + // Le verdict arrive par signal, et la conséquence est traitée au cycle suivant. + // `this` en contexte de connexion : si l'adaptateur meurt, le callback est coupé + // proprement — motif repris d'EvCharger::setChargingEnabled (evcharger.cpp:293-299). + ++m_pending; + connect(info, &ThingActionInfo::finished, this, [this, info]() { + if (info->status() != Thing::ThingErrorNoError) { + m_writeFailed = true; + qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label + << "— écriture relais en échec, status" << info->status(); + } + if (--m_pending == 0) + settleTransition(); + }); +} + +void RelayRouter::settleTransition() +{ + // Appelé quand TOUTES les écritures d'une étape sont acquittées. Fait avancer l'échelle + // d'ECS-410 d'un cran, une tentative par cran, sans jamais boucler. + if (!m_writeFailed) { + m_stagePrev = m_currentStage; // transition confirmée : l'indétermination cesse + m_stageTarget = m_currentStage; + return; + } + m_writeFailed = false; + + switch (m_phase) { + case PhaseNominale: + // Barreau 2 — retour à l'état précédent. + qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label + << "— échec d'écriture : retour au palier" << m_levels.at(m_stagePrev) << "W."; + m_phase = PhaseRepli; + m_currentStage = m_stagePrev; + emitRelayWrites(m_stagePrev); + break; + + case PhaseRepli: + // Barreau 3 — arrêt total. Dernière tentative. + qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label + << "— retour arrière en échec : arrêt total."; + m_phase = PhaseArretTotal; + m_currentStage = 0; + m_stageTarget = 0; + emitRelayWrites(0); + break; + + case PhaseArretTotal: + case PhaseDefaut: + // Fin de l'échelle : verdict. Verrou COLLANT — aucune commande jusqu'à + // ClearLoadFault (ECS-410 §4). Il ne se lève pas tout seul : pas de clignotement. + qCCritical(dcNymeaEnergy()) << "[RelayRouter]" << m_label + << "— arrêt total en échec : charge EN DÉFAUT, plus aucune" + << "commande émise. Levée par NymeaEnergy.ClearLoadFault."; + m_phase = PhaseDefaut; + m_faulted = true; + break; + } +} + +void RelayRouter::clearFault() +{ + if (!m_faulted) + return; + qCInfo(dcNymeaEnergy()) << "[RelayRouter]" << m_label + << "— défaut levé par l'opérateur (ClearLoadFault)."; + m_faulted = false; + m_phase = PhaseNominale; + m_writeFailed = false; + // L'état matériel réel est inconnu après un défaut : on le relit plutôt que de le supposer + // (même principe qu'ECS-411, et jamais moins que ce qui peut être appliqué). + m_currentStage = deduceStageFromThings(); + m_stagePrev = m_stageTarget = m_currentStage; +} + +void RelayRouter::emitRelayWrites(int stage) { // Set CIBLE du palier (delta complet : chaque relais connu amené à son état on/off cible). // Gère les mappings NON-CASCADÉS (monter d'un palier peut éteindre des relais). const QSet wantOn = [&]() { QSet s; - if (stage < m_relayMapping.size()) + if (stage >= 0 && stage < m_relayMapping.size()) for (const QString &id : m_relayMapping.at(stage)) s.insert(id); return s; @@ -341,22 +471,6 @@ void RelayRouter::applyRelayStage(int stage) for (const QString &id : list) allRelays.insert(id); - auto writeRelay = [&](const QString &thingId, bool on) { - Thing *relay = m_thingManager->findConfiguredThing(ThingId(thingId)); - if (!relay) { - qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label << "— relais non trouvé:" << thingId; - return; - } - StateType powerStateType = relay->thingClass().stateTypes().findByName("power"); - if (!powerStateType.id().isNull()) { - Action powerAction(powerStateType.id(), relay->id(), Action::TriggeredByRule); - powerAction.setParams(ParamList() << Param(powerStateType.id(), on)); - m_thingManager->executeAction(powerAction); - } else { - relay->setStateValue("power", on); // repli mock - } - }; - // off-before-on : couper d'abord les relais hors-cible, puis enclencher la cible → pas de // sur-puissance transitoire (somme des deux paliers) sur une transition non-cascadée. for (const QString &id : allRelays) @@ -364,4 +478,17 @@ void RelayRouter::applyRelayStage(int stage) writeRelay(id, false); for (const QString &id : wantOn) writeRelay(id, true); + + // Chemin entièrement synchrone (mock, ou aucun relais) : aucun acquittement n'arrivera, + // le verdict se prend donc tout de suite. + if (m_pending == 0) + settleTransition(); +} + +void RelayRouter::applyRelayStage(int stage) +{ + m_stagePrev = m_currentStage; + m_stageTarget = stage; + m_writeFailed = false; + emitRelayWrites(stage); } diff --git a/energyplugin/etm/adapters/relayrouter.h b/energyplugin/etm/adapters/relayrouter.h index 1d8587d..cac893b 100644 --- a/energyplugin/etm/adapters/relayrouter.h +++ b/energyplugin/etm/adapters/relayrouter.h @@ -7,6 +7,8 @@ #include #include #include "iloadadapter.h" + +class ThingActionInfo; #include "../types/loadconfig.h" // LoadConfigRelay class Thing; @@ -93,7 +95,27 @@ public: //! l'adaptateur — donc sans réarmer les verrous ni recommuter les relais. void updateSoftConfig(int priority, const LoadNeeds &needs) override; + /*! + * \brief Lève le verrou de défaut (ECS-410, RPC \c NymeaEnergy.ClearLoadFault). + * \note Le défaut est COLLANT par conception — « cesser toute commande jusqu'à + * intervention ». Sa levée est donc un acte délibéré et tracé de l'opérateur, jamais + * une expiration. L'état matériel réel étant inconnu après un défaut, il est RELU + * (même principe qu'ECS-411) et non supposé. + */ + void clearFault() override; + + //! \return Vrai si la charge est en défaut (plus aucune commande émise). + bool faulted() const { return m_faulted; } + private: + //! Barreaux de l'échelle ECS-410. Une tentative par barreau, jamais de boucle. + enum Phase { PhaseNominale, PhaseRepli, PhaseArretTotal, PhaseDefaut }; + + void writeRelay(const QString &thingId, bool on); + void emitRelayWrites(int stage); + //! Verdict pris quand toutes les écritures d'une étape sont acquittées. + void settleTransition(); + //! Palier le plus haut dont la puissance ≤ \p powerW (≥ 0). int stageForPower(double powerW) const; /*! @@ -124,4 +146,12 @@ private: int m_currentStage = 0; QDateTime m_lastSwitch; //!< Dernier changement (null = jamais). QDateTime m_lastActionAt; + + // --- ECS-410 : suivi asynchrone des écritures --------------------------------------- + int m_pending = 0; //!< Écritures émises non encore acquittées. + bool m_writeFailed = false; //!< Au moins un acquittement en erreur sur l'étape courante. + int m_stagePrev = 0; //!< Palier avant la transition en cours. + int m_stageTarget = 0; //!< Palier visé par la transition en cours. + Phase m_phase = PhaseNominale; + bool m_faulted = false; //!< Verrou de défaut. COLLANT : seul clearFault() le lève. }; diff --git a/energyplugin/etm/adapters/sgreadyadapter.h b/energyplugin/etm/adapters/sgreadyadapter.h index 35205bf..b19d9e3 100644 --- a/energyplugin/etm/adapters/sgreadyadapter.h +++ b/energyplugin/etm/adapters/sgreadyadapter.h @@ -95,6 +95,11 @@ public: void updateSoftConfig(int priority, const LoadNeeds &needs) override { Q_UNUSED(priority) Q_UNUSED(needs) } + //! \brief Sans effet : la PAC SG-Ready n'implémente pas encore l'échelle de défaut ECS-410. + //! La généralisation est portée par **ECS-414** (lot de mise en configuration + //! du SgReadyAdapter). Déclaré explicitement plutôt qu'hérité d'un défaut vide. + void clearFault() override {} + /*! * \brief État SG-Ready courant (1-4). * \return Dernier état réellement commuté ; valeur initiale tant qu'aucune transition diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 84ec568..7c12bcf 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -156,6 +156,18 @@ bool EnergyArbitrator::sameHardware(const LoadConfig &a, const LoadConfig &b) return true; } +bool EnergyArbitrator::clearLoadFault(const QString &loadId) +{ + ILoadAdapter *adapter = m_loadAdapters.value(loadId, nullptr); + if (!adapter) { + qCWarning(dcNymeaEnergy()) << "[Arbitre] ClearLoadFault : charge inconnue" << loadId; + return false; + } + qCInfo(dcNymeaEnergy()) << "[Arbitre] ClearLoadFault demandé par l'opérateur pour" << loadId; + adapter->clearFault(); + return true; +} + void EnergyArbitrator::rebuildLoadAdapters() { if (!m_loadConfigStore) { diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index 46e7f90..14fe66f 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -117,6 +117,15 @@ public: */ void setLoadConfigStore(LoadConfigStore *store); + /*! + * \brief Lève le verrou de défaut d'une charge pilotée (ECS-410). + * \param loadId Identifiant logique de la charge. + * \return Vrai si la charge existe ; faux si l'identifiant est inconnu. + * \note Acte délibéré de l'opérateur, exposé par \c NymeaEnergy.ClearLoadFault. Le défaut + * ne se lève jamais seul : c'est ce qui empêche \c available de clignoter. + */ + bool clearLoadFault(const QString &loadId); + /*! * \brief Mode dégradé L2 actif (compteur muet > 90 s) — override de SmartChargingManager. * \return \c true tant que les consignes de repli L2 tiennent ; \c false en régime normal. diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index 4cfd53c..090c52d 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -204,6 +204,29 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc, setpointW = chosen; } + // ECS-410 — charge INDISPONIBLE : elle sort de l'ARBITRAGE, pas de la COMPTABILITÉ. + // Aucune allocation neuve, mais sa consommation reste retranchée du budget : une + // puissance qu'on ne sait plus couper est de la conso fixe, au même titre que la base de + // la maison. La retirer du bilan offrirait aux charges suivantes un budget qui n'existe + // pas — c'est le trou d'ECS-306 rouvert par une autre porte. + // + // Le figeage est porté par l'adaptateur : lockMin == lockMax == puissance crue engagée. + // Le clamp ci-dessous suffit donc à produire la bonne comptabilité, sans branche spéciale. + if (!lc.telemetry.available) { + const double gele = lc.telemetry.lockMinPowerW; + LoadAction la; + la.loadId = lc.id; + la.kind = LoadAction::Setpoint; + 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; + return la; + } + // ECS-306 — clamp lock-aware AVANT de décrémenter le budget (AGENTS règle 4 : les // bornes de l'adaptateur écrêtent TOUTE sortie de stratégie, et leur résultat doit // revenir à l'arbitre). Sans ceci, l'adaptateur remonte le palier de son côté et le diff --git a/energyplugin/etm/types/surpluscontext.h b/energyplugin/etm/types/surpluscontext.h index 4e02b9e..036d18f 100644 --- a/energyplugin/etm/types/surpluscontext.h +++ b/energyplugin/etm/types/surpluscontext.h @@ -55,6 +55,22 @@ struct SurplusTariff { /*! \brief Télémétrie d'une charge dans le contexte §5 loads[].telemetry. */ struct LoadContextTelemetry { double currentPowerW = 0; //!< Puissance mesurée (W). + /*! + * \brief Disponibilité de la charge pour l'ARBITRAGE (ECS-410, et plus tard ECS-601). + * + * **Sémantique à ne pas confondre — elle gouverne l'ALLOCATION, pas la COMPTABILITÉ.** + * Une charge indisponible ne reçoit **rien** de neuf, mais elle continue d'être **comptée + * pour ce qu'elle consomme réellement** via \c currentPowerW. Un scheduler qui se + * contenterait de « sauter » les charges indisponibles sauterait aussi leur comptabilité + * et rouvrirait le trou d'ECS-306 par une autre porte : les charges suivantes se verraient + * offrir un budget qui n'existe pas. + * + * Faux quand : la charge est en défaut d'écriture (ECS-410, verrou collant levé seulement + * par \c ClearLoadFault), ou — à venir — quand une sonde thermique déclare la consigne + * atteinte (ECS-601). Les deux cas convergent : un ballon en température a une conso réelle + * nulle, donc la distinction ne mord que sur le défaut. + */ + bool available = true; // --- evcharger --- bool pluggedIn = false; //!< Véhicule branché. Une borne non branchée est ignorée du plan. bool charging = false; //!< Charge effectivement en cours (courant non nul côté borne). diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index 26d86ce..357ef40 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -23,6 +23,9 @@ * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ #include "nymeaenergyjsonhandler.h" +#ifdef ETM_ARBITRATOR +#include "etm/energyarbitrator.h" +#endif #include "types/charginginfo.h" #include "smartchargingmanager.h" #include "spotmarket/spotmarketmanager.h" @@ -194,6 +197,16 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana returns.insert("o:autonomyRate", enumValueName(Double)); registerMethod("GetEnergyRatios", description, params, returns, Types::PermissionScopeControlThings); + // [ETM] ECS-410 — levée du verrou de défaut d'une charge. Délibérée et journalisée : le + // défaut est collant par conception (« cesser toute commande jusqu'à intervention »), sa + // levée doit donc être un acte tracé, pas un effet de bord d'un autre appel. + params.clear(); returns.clear(); + description = "Clear the fault latch of a controllable load (ECS-410). Deliberate operator " + "action: the fault never clears by itself."; + params.insert("loadId", enumValueName(String)); + returns.insert("energyError", enumRef()); + registerMethod("ClearLoadFault", description, params, returns, Types::PermissionScopeControlThings); + // Notifications params.clear(); @@ -577,6 +590,23 @@ QVariantMap NymeaEnergyJsonHandler::currentEnergyRatios() return result; } +JsonReply *NymeaEnergyJsonHandler::ClearLoadFault(const QVariantMap ¶ms) +{ +#ifdef ETM_ARBITRATOR + EnergyArbitrator *arb = dynamic_cast(m_smartChargingManager); + if (arb && arb->clearLoadFault(params.value("loadId").toString())) { + QVariantMap ret; + ret.insert("energyError", enumValueName(EnergyManager::EnergyErrorNoError)); + return createReply(ret); + } +#else + Q_UNUSED(params) +#endif + QVariantMap ret; + ret.insert("energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)); + return createReply(ret); +} + JsonReply *NymeaEnergyJsonHandler::GetEnergyRatios(const QVariantMap ¶ms) { Q_UNUSED(params) diff --git a/energyplugin/nymeaenergyjsonhandler.h b/energyplugin/nymeaenergyjsonhandler.h index 855c4cd..4c154e5 100644 --- a/energyplugin/nymeaenergyjsonhandler.h +++ b/energyplugin/nymeaenergyjsonhandler.h @@ -73,6 +73,17 @@ public: // [Phase 2] Ratios canoniques (autoconsommation / autonomie), % [0,100]. Q_INVOKABLE JsonReply *GetEnergyRatios(const QVariantMap ¶ms); + /*! + * \brief [ETM] Lève le verrou de défaut d'une charge pilotée (ECS-410). + * \param params \c {"loadId": string}. + * \return \c energyError — \c EnergyErrorNoError si la charge existe et son défaut est + * levé, \c EnergyErrorInvalidParameter si l'identifiant est inconnu. + * \note Acte DÉLIBÉRÉ de l'opérateur : le défaut d'ECS-410 est collant par conception, et + * cet appel est le seul moyen de le lever hors reconstruction de l'adaptateur. Il est + * journalisé côté arbitre — c'est ce qui rend le diagnostic possible après coup. + */ + Q_INVOKABLE JsonReply *ClearLoadFault(const QVariantMap ¶ms); + signals: void PhasePowerLimitChanged(const QVariantMap ¶ms); void AcquisitionToleranceChanged(const QVariantMap ¶ms); diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 3b933d1..d869837 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -1143,6 +1143,89 @@ void Simulation::testEcsColdStartLockExpires() #endif } +void Simulation::testEcsPartialFailure() +{ +#ifndef ETM_ARBITRATOR + QSKIP("testEcsPartialFailure nécessite ETM_ARBITRATOR."); +#else + // [ECS-410] Un relais introuvable fait échouer toute écriture le concernant. On exerce + // ainsi l'échelle complète : cible → retour arrière → arrêt total → défaut. + 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 rOk = addPowerSwitch(1000, 26661); + Thing *tOk = tm->findConfiguredThing(rOk); + QVERIFY(tOk); + tOk->setStateValue("power", false); + const QString rKo = QStringLiteral("{deadbeef-0000-0000-0000-000000000000}"); // jamais configuré + + 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; + }; + // 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 + // d'événements, l'échelle ne progresse pas d'un seul barreau. + auto laisserRetomber = [&]() { QTest::qWait(300); }; + + // --- Cas nominal : un seul relais VALIDE, aucune échelle déclenchée ------------------- + { + RelayRouter *sain = new RelayRouter(tm, "sain", "Charge saine", + QList({ {rOk.toString(), 1000} }), 0, 0, 1, LoadNeeds(), arb); + sain->applyAction(sp(1000), t0); + laisserRetomber(); + QCOMPARE(qRound(sain->currentSetpointW()), 1000); + QCOMPARE(tOk->stateValue("power").toBool(), true); + QVERIFY2(!sain->faulted(), "une écriture réussie ne doit pas lever de défaut"); + QCOMPARE(sain->telemetry().available, true); + tOk->setStateValue("power", false); + } + + // --- Échelle complète : relais introuvable → défaut ---------------------------------- + RelayRouter *r = new RelayRouter(tm, "ko", "Charge en panne", + QList({ {rOk.toString(), 1000}, {rKo, 2000} }), 0, 0, 1, LoadNeeds(), arb); + QVERIFY(!r->faulted()); + + // Cible 3000 W = les DEUX relais. L'écriture sur rKo échoue → barreau 2 (retour arrière), + // qui échoue aussi → barreau 3 (arrêt total), qui échoue → défaut. + r->applyAction(sp(3000), t0); + laisserRetomber(); + QVERIFY2(r->faulted(), "l'échelle ECS-410 doit aboutir au défaut"); + + // Le relais VALIDE a bien été ramené à l'ouverture par la tentative d'arrêt total : + // le barreau 3 n'est pas décoratif. + QCOMPARE(tOk->stateValue("power").toBool(), false); + + // available bascule à faux — et ne clignote pas : il est COLLANT. + QCOMPARE(r->telemetry().available, false); + LoadContext c = r->toLoadContext(t0.addSecs(600)); + QCOMPARE(c.telemetry.available, false); + // Charge FIGÉE : plancher == plafond. Un plafond nul sous un plancher non nul ferait + // dérailler le qBound du scheduler. + QCOMPARE(qRound(c.telemetry.lockMinPowerW), qRound(c.telemetry.lockMaxPowerW)); + + // …et la charge continue d'être COMPTÉE : elle sort de l'arbitrage, pas de la comptabilité. + QCOMPARE(qRound(c.telemetry.lockMinPowerW), qRound(c.telemetry.currentPowerW)); + + // Plus aucune commande n'est émise, même longtemps après : le défaut ne s'évapore pas. + tOk->setStateValue("power", false); + r->applyAction(sp(1000), t0.addSecs(3600)); + laisserRetomber(); + QCOMPARE(tOk->stateValue("power").toBool(), false); + QVERIFY(r->faulted()); + + // Levée DÉLIBÉRÉE, par l'opérateur — le seul chemin hors reconstruction. + r->clearFault(); + QVERIFY(!r->faulted()); + QCOMPARE(r->telemetry().available, true); +#endif +} + void Simulation::run_data() { // Simulation infos diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 8f6de4e..e725865 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -99,6 +99,9 @@ private slots: // suffisant : la charge reste éteinte PENDANT minOffS puis s'enclenche. L'armement à // froid doit être transitoire, jamais permanent (défaut constaté au banc 2026-08-09). void testEcsColdStartLockExpires(); + // [ECS-410] Échec d'écriture en cours de transition : retour arrière, arrêt total, puis + // état de défaut collant. Chaque marche de l'échelle a son cas, et la levée est délibérée. + void testEcsPartialFailure(); void printStates(Thing *thing); void updateChargerMeter(Thing *thing);