diff --git a/AGENTS.md b/AGENTS.md index 3165177..14d7e9c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -347,7 +347,25 @@ Règles absolues : > encodages solo, que la déduplication peut évincer — d'où un palier 0 annoncé pendant > que 1500 W circulaient. **Rendre la lecture visible est ce qui rend le défaut > trouvable** ; c'est le sens de la règle, pas un effet secondaire. - - Les tests de ces trois points portent sur le **TEXTE** publié, jamais sur la + - **7-d — un refus DOIT être au moins aussi visible que l'application correspondante.** + Jamais un refus en `qCDebug` quand le succès qu'il remplace est en `qCInfo`. + L'asymétrie est le défaut, pas le niveau : un resserrement de la journalisation fait + alors disparaître le refus **avant** le succès, et il ne reste au journal que les + décisions qui ont abouti. Or **un refus est plus informatif qu'une application + réussie** — il dit qu'une décision a été prise et n'a PAS été exécutée. + > `sgreadyadapter.cpp:150` journalisait le refus par verrou `minStateHold` en + > `qCDebug` quand l'application, dix lignes plus bas, était en `qCInfo`. Corrigé le + > 2026-08-13 ; c'était le seul cas, les autres `qCDebug` du moteur étant symétriques + > (traces d'enregistrement et de construction, où les deux branches sont au même + > niveau). Le marqueur « mode dégradé L2 actif » reste en `qCDebug` à dessein : + > l'entrée est en `qCWarning` et la sortie en `qCInfo`, donc l'épisode reste + > délimité sans une ligne par cycle. + - **Une issue rendue à un opérateur distant se journalise là où elle est décidée**, pas + dans chaque implémentation. `ClearLoadFault` est le seul levier de reprise à distance : + son issue est écrite dans l'arbitre, qui couvre tous les adaptateurs d'un coup et ne + peut pas être oublié par un adaptateur futur. Quand le type de retour permet de forcer + la réponse — `clearFault()` rend un `bool` — le préférer à la discipline. + - Les tests de ces quatre points portent sur le **TEXTE** publié, jamais sur la non-vacuité d'une chaîne : un test « motif non vide » n'aurait rien vu, dans aucun de ces cas. 8. **Pas de boucle de feedback** : surplus = PV mesurée + compteur, jamais le net diff --git a/debian-qt5/changelog b/debian-qt5/changelog index 717ddf1..946e106 100644 --- a/debian-qt5/changelog +++ b/debian-qt5/changelog @@ -20,7 +20,26 @@ powersync-energy-plugin-nymea (1.15.2+etm12) trixie; urgency=medium * Protocole de mesure (RELEVE_ECS306 §3.0) : un état nymea n'émet changed() que si la valeur bouge, donc une grandeur forcée constante déclenche le watchdog L2 en 90 s et fait mesurer le watchdog au lieu de l'arbitrage. Constaté au banc. - * Suite complète : 112 tests, 0 échec. Les deux correctifs sont vérifiés échouant sans eux. + * ECS-410-b — l'issue de ClearLoadFault est journalisée, et par l'ARBITRE. Elle distingue + trois cas : défaut levé, aucun défaut à lever, adaptateur introuvable. C'est le seul + levier de reprise à distance : ECS-410 pose un verrou collant que seuls ce RPC ou une + reconstruction lèvent, et sans issue lisible l'opérateur ne sait pas si son geste a agi + — il relance, puis conclut que le système est cassé. Écrite au niveau de l'arbitre, la + ligne couvre les quatre adaptateurs d'un coup et ne peut pas être oubliée par un + adaptateur futur. ILoadAdapter::clearFault() rend désormais un bool plutôt que void : le + type de retour force chaque implémentation à répondre à la question, là où une consigne + de journalisation se serait oubliée à la cinquième. + * Règle 7-d d'AGENTS.md : un refus DOIT être au moins aussi visible que l'application + correspondante. Le refus par verrou minStateHold de SgReadyAdapter était en qCDebug quand + l'application, dix lignes plus bas, était en qCInfo — un resserrement de la journalisation + aurait fait disparaître le refus AVANT le succès, ne laissant au journal que les décisions + abouties. Or un refus est plus informatif : il dit qu'une décision a été prise et n'a pas + été exécutée. Les autres qCDebug du moteur ont été vérifiés symétriques. + * Retour idempotent du routeur (relayrouter.cpp:228) : DIFFÉRÉ, et en qCDebug le jour venu. + C'est le cas le plus fréquent — plusieurs milliers de lignes par jour — et l'ambiguïté y + est partiellement couverte, le scheduler journalisant sa décision et l'armement à froid + s'exécutant avant ce retour. + * Suite complète : 112 tests, 0 échec. Tous les correctifs sont vérifiés échouant sans eux. -- Patrick Schurig Thu, 13 Aug 2026 21:00:00 +0200 diff --git a/energyplugin/etm/adapters/etmvariableloadadapter.cpp b/energyplugin/etm/adapters/etmvariableloadadapter.cpp index 3986804..f1de5e0 100644 --- a/energyplugin/etm/adapters/etmvariableloadadapter.cpp +++ b/energyplugin/etm/adapters/etmvariableloadadapter.cpp @@ -223,10 +223,10 @@ void EtmVariableLoadAdapter::settleTransition() } } -void EtmVariableLoadAdapter::clearFault() +bool EtmVariableLoadAdapter::clearFault() { if (!m_faulted) - return; + return false; // rien à lever — l'arbitre le dira (règle 7-c) qCInfo(dcNymeaEnergy()) << "[EtmVariableLoadAdapter]" << m_label << "— défaut levé par l'opérateur (ClearLoadFault)."; m_faulted = false; @@ -234,4 +234,5 @@ void EtmVariableLoadAdapter::clearFault() m_writeFailed = false; m_currentSetpointW = readCurrentPowerW(); // relu, pas supposé m_setpointPrev = m_setpointTarget = m_currentSetpointW; + return true; } diff --git a/energyplugin/etm/adapters/etmvariableloadadapter.h b/energyplugin/etm/adapters/etmvariableloadadapter.h index 823792f..fbf92b4 100644 --- a/energyplugin/etm/adapters/etmvariableloadadapter.h +++ b/energyplugin/etm/adapters/etmvariableloadadapter.h @@ -117,7 +117,7 @@ public: //! 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. //! \brief Lève le verrou de défaut (ECS-414). La consigne réelle est RELUE, pas supposée. - void clearFault() override; + bool clearFault() override; //! \return Vrai si la charge est en défaut (plus aucune consigne écrite). bool faulted() const { return m_faulted; } diff --git a/energyplugin/etm/adapters/evadapter.h b/energyplugin/etm/adapters/evadapter.h index 5e2912c..ea1ad4e 100644 --- a/energyplugin/etm/adapters/evadapter.h +++ b/energyplugin/etm/adapters/evadapter.h @@ -79,7 +79,9 @@ public: //! \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 {} + //! Aucun mécanisme de défaut côté EV : il n'y a jamais rien à lever. Le dire +//! explicitement plutôt que de laisser un corps vide (règle 7-c). + bool clearFault() override { return false; } //! \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é. diff --git a/energyplugin/etm/adapters/iloadadapter.h b/energyplugin/etm/adapters/iloadadapter.h index bd96ef0..434fb24 100644 --- a/energyplugin/etm/adapters/iloadadapter.h +++ b/energyplugin/etm/adapters/iloadadapter.h @@ -104,8 +104,19 @@ public: * \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**. + * + * \return \c true si un défaut a RÉELLEMENT été levé, \c false s'il n'y en avait pas. + * + * \note **Pourquoi un booléen et non \c void** (règle 7-c d'AGENTS.md). C'est le seul + * levier de reprise à distance : ECS-410 pose un verrou collant que seuls ce RPC ou une + * reconstruction lèvent. Sans valeur de retour, l'arbitre ne peut pas distinguer + * « défaut levé » de « aucun défaut à lever », et un opérateur à distance ne peut pas + * savoir si son geste a agi — il relancera, puis conclura que le système est cassé. + * Le type de retour force chaque implémentation, présente et future, à répondre à la + * question ; une ligne de journal dans chaque adaptateur se serait oubliée à la + * cinquième. */ - virtual void clearFault() = 0; + virtual bool clearFault() = 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 2680a91..7938906 100644 --- a/energyplugin/etm/adapters/relayrouter.cpp +++ b/energyplugin/etm/adapters/relayrouter.cpp @@ -489,10 +489,10 @@ void RelayRouter::applySafeState(const QDateTime &now) applyAction(sur, now); } -void RelayRouter::clearFault() +bool RelayRouter::clearFault() { if (!m_faulted) - return; + return false; // rien à lever — l'arbitre le dira (règle 7-c) qCInfo(dcNymeaEnergy()) << "[RelayRouter]" << m_label << "— défaut levé par l'opérateur (ClearLoadFault)."; m_faulted = false; @@ -502,6 +502,7 @@ void RelayRouter::clearFault() // (même principe qu'ECS-411, et jamais moins que ce qui peut être appliqué). m_currentStage = deduceStageFromThings(); m_stagePrev = m_stageTarget = m_currentStage; + return true; } void RelayRouter::emitRelayWrites(int stage) diff --git a/energyplugin/etm/adapters/relayrouter.h b/energyplugin/etm/adapters/relayrouter.h index aa5a91f..da51eb5 100644 --- a/energyplugin/etm/adapters/relayrouter.h +++ b/energyplugin/etm/adapters/relayrouter.h @@ -102,7 +102,7 @@ public: * 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; + bool clearFault() 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. diff --git a/energyplugin/etm/adapters/sgreadyadapter.cpp b/energyplugin/etm/adapters/sgreadyadapter.cpp index b9b7de4..ead599c 100644 --- a/energyplugin/etm/adapters/sgreadyadapter.cpp +++ b/energyplugin/etm/adapters/sgreadyadapter.cpp @@ -148,8 +148,14 @@ LoadAction SgReadyAdapter::applyAction(const LoadAction &action, const QDateTime // Verrou minStateHold évalué au temps de cycle (même fenêtre que le scheduler) — // bypassé si force == true (L2 watchdog → état 2). if (!action.force && lockActive(newState, now)) { - qCDebug(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label - << "— verrou minStateHold actif, état" << newState << "ignoré."; + // Règle 7-c — un REFUS doit être au moins aussi visible que l'application + // correspondante. Ce refus était en qCDebug quand l'application juste en dessous est + // en qCInfo : un resserrement de la journalisation faisait disparaître le refus AVANT + // le succès. Or un refus est plus informatif qu'une application réussie — il dit + // qu'une décision a été prise et n'a PAS été exécutée. + qCInfo(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label + << "— verrou minStateHold actif, état" << newState + << "REFUSÉ (état" << m_currentState << "maintenu)."; return action; } @@ -349,10 +355,10 @@ void SgReadyAdapter::settleTransition() } } -void SgReadyAdapter::clearFault() +bool SgReadyAdapter::clearFault() { if (!m_faulted) - return; + return false; // rien à lever — l'arbitre le dira (règle 7-c) qCInfo(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label << "— défaut levé par l'opérateur (ClearLoadFault)."; m_faulted = false; @@ -363,6 +369,7 @@ void SgReadyAdapter::clearFault() if (lu > 0) m_currentState = lu; m_statePrev = m_stateTarget = m_currentState; + return true; } void SgReadyAdapter::applyStateRelays(const QSet ¤tOn, int toState) diff --git a/energyplugin/etm/adapters/sgreadyadapter.h b/energyplugin/etm/adapters/sgreadyadapter.h index 8469ee9..23475f2 100644 --- a/energyplugin/etm/adapters/sgreadyadapter.h +++ b/energyplugin/etm/adapters/sgreadyadapter.h @@ -106,7 +106,7 @@ public: * \note L'état réel est RELU depuis les contacts, jamais supposé : après un défaut, le * motif 2 bits peut être valide mais non voulu. */ - void clearFault() override; + bool clearFault() override; //! \return Vrai si la PAC est en défaut (plus aucune commande émise). bool faulted() const { return m_faulted; } diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index d0456c9..9fe309a 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -171,13 +171,32 @@ bool EnergyArbitrator::sameHardware(const LoadConfig &a, const LoadConfig &b) bool EnergyArbitrator::clearLoadFault(const QString &loadId) { + // Règle 7-c — l'ISSUE est journalisée ici, au niveau de l'arbitre, et pas dans chaque + // adaptateur. Une ligne écrite là couvre les quatre adaptateurs d'un coup et ne peut pas + // être oubliée par un adaptateur futur ; répétée dans chacun, elle manquerait le cinquième. + // + // Pourquoi cet appel mérite une issue plutôt qu'une simple trace de demande : c'est le + // SEUL levier de reprise à distance. ECS-410 pose un verrou collant que seuls ce RPC ou + // une reconstruction lèvent. Sans issue lisible, un opérateur à distance ne distingue pas + // « défaut levé » de « aucun défaut à lever » de « l'appel n'a pas atteint l'adaptateur » : + // il relance, puis conclut que le système est cassé. ILoadAdapter *adapter = m_loadAdapters.value(loadId, nullptr); if (!adapter) { - qCWarning(dcNymeaEnergy()) << "[Arbitre] ClearLoadFault : charge inconnue" << loadId; + qCWarning(dcNymeaEnergy()) << "[Arbitre] ClearLoadFault" << loadId + << "→ SANS EFFET : adaptateur introuvable " + "(identifiant inconnu ou charge désactivée)."; return false; } + qCInfo(dcNymeaEnergy()) << "[Arbitre] ClearLoadFault demandé par l'opérateur pour" << loadId; - adapter->clearFault(); + const bool leve = adapter->clearFault(); + if (leve) + qCInfo(dcNymeaEnergy()) << "[Arbitre] ClearLoadFault" << loadId + << "→ défaut LEVÉ ; la charge revient à l'arbitrage."; + else + qCInfo(dcNymeaEnergy()) << "[Arbitre] ClearLoadFault" << loadId + << "→ sans objet : AUCUN défaut à lever, la charge n'était " + "pas verrouillée."; return true; } diff --git a/specs/spec_ecs.md b/specs/spec_ecs.md index 42876a3..7478730 100644 --- a/specs/spec_ecs.md +++ b/specs/spec_ecs.md @@ -517,14 +517,35 @@ DOIT être publiée en **avertissement**, l'état déduit étant incertain. > (`m_relayNominalW`). Rendre la lecture visible est ce qui a rendu le défaut > trouvable. -> **Autres silences ambigus — SIGNALÉS, non corrigés.** Relevés en passant, à -> traiter dans leur propre lot : +> **Autres silences ambigus.** Relevés en passant le 2026-08-13 ; deux corrigés le +> jour même, un différé. > -> | Endroit | Silence | -> |---|---| -> | `relayrouter.cpp:228` | `if (newStage == m_currentStage) return applied;` — le cas idempotent, le plus fréquent, ne produit aucune ligne côté adaptateur. L'issue reste visible par le `[Arbitre]` du cycle, mais la décision de l'adaptateur, non. | -> | `etmvariableloadadapter.cpp:228` | `clearFault()` sort en silence si la charge n'est pas en défaut. L'arbitre journalise la *demande* de l'opérateur (`energyarbitrator.cpp:179`) mais jamais son issue : « reçu, rien à lever » ne se distingue pas de « jamais arrivé ». C'est un geste opérateur, donc le cas le plus visible de la règle après ECS-411. | -> | `sgreadyadapter.cpp:150` | Le refus par verrou `minStateHold` est en `qCDebug`, l'application en `qCInfo`. Le refus disparaît donc avant le succès si la journalisation est resserrée. Atténué aujourd'hui : le scheduler annonce le verrou en `qCInfo` de son côté. | +> | Endroit | Silence | État | +> |---|---|---| +> | `clearFault()`, tous adaptateurs | « reçu, rien à lever » ne se distinguait pas de « jamais arrivé ». **Seul levier de reprise à distance** : ECS-410 pose un verrou collant que seuls ce RPC ou une reconstruction lèvent. | **FAIT** — voir ECS-410-b ci-dessous | +> | `sgreadyadapter.cpp:150` | Refus par verrou `minStateHold` en `qCDebug`, application en `qCInfo` : un resserrement de la journalisation faisait disparaître le refus AVANT le succès. | **FAIT** — passé en `qCInfo`, règle 7-d | +> | `relayrouter.cpp:228` | `if (newStage == m_currentStage) return applied;` — le cas idempotent ne produit aucune ligne côté adaptateur. | **DIFFÉRÉ** (après vacances) | +> +> **Pourquoi le retour idempotent est différé, et en `qCDebug` le jour venu.** C'est +> le cas le **plus fréquent** : une ligne par cycle et par charge, soit plusieurs +> milliers par jour, sur un budget de journal qu'on vient de ramener à ~6,5 Mo/jour +> pour tenir la campagne. Et l'ambiguïté y est **partiellement couverte** : le +> scheduler journalise sa décision à chaque cycle, et l'armement à froid d'ECS-412 +> s'exécute **avant** le retour idempotent — il n'est donc jamais manqué. Le gain +> est faible, le coût réel : l'inverse des deux cas corrigés. + +**ECS-410-b — L'issue de `ClearLoadFault` est journalisée, et par l'arbitre.** +L'issue DOIT distinguer trois cas : **défaut levé**, **aucun défaut à lever**, +**adaptateur introuvable**. Elle DOIT être écrite au niveau de l'**arbitre**, pas +dans chaque adaptateur : une ligne écrite là couvre tous les adaptateurs d'un coup +et ne peut pas être oubliée par un adaptateur futur. `ILoadAdapter::clearFault()` +rend un `bool` — un défaut a-t-il réellement été levé — plutôt que `void` : le type +de retour force chaque implémentation, présente et future, à répondre à la question, +là où une consigne de journalisation se serait oubliée à la cinquième. + +> C'est le seul levier de reprise à distance. Appelé depuis l'étranger sans retour +> lisible, l'opérateur ne distingue pas les trois cas, relance, et conclut que le +> système est cassé. **ECS-414 — Généralisation de l'échelle d'échec. FAIT (2026-08-09).** ECS-410 était implémenté par le seul `RelayRouter`. `SgReadyAdapter` (`sgreadyadapter.cpp:212`) et diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index e4ca33f..8474b7f 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -1379,9 +1379,52 @@ void Simulation::testEcsPartialFailure() QVERIFY(r->faulted()); // Levée DÉLIBÉRÉE, par l'opérateur — le seul chemin hors reconstruction. - r->clearFault(); + // Enregistré auprès de l'arbitre : c'est LUI qui porte l'issue (règle 7-c), donc la + // levée passe par le chemin RPC réel et non par un appel direct à l'adaptateur. + arb->registerRelayRouter(r); + + // [Règle 7-c] L'issue DOIT être journalisée par l'ARBITRE, et distinguer trois cas : + // défaut levé, aucun défaut à lever, adaptateur introuvable. C'est le seul levier de + // reprise à distance : sans issue lisible, un opérateur ne sait pas si son geste a agi, + // relance, puis conclut que le système est cassé. Le test porte sur le TEXTE publié. + QVERIFY(r->faulted()); + s_journal.clear(); + s_handlerPrecedent = qInstallMessageHandler(collecterJournal); + const bool leve = arb->clearLoadFault(QStringLiteral("ko")); + qInstallMessageHandler(s_handlerPrecedent); + QVERIFY(leve); QVERIFY(!r->faulted()); QCOMPARE(r->telemetry().available, true); + + const QString issue1 = s_journal.filter(QStringLiteral("ClearLoadFault")).join(QStringLiteral(" | ")); + QVERIFY2(issue1.contains(QStringLiteral("défaut LEVÉ")), qUtf8Printable(issue1)); + + // Second appel, charge SAINE : « aucun défaut à lever » doit se lire, et ne pas se + // confondre avec le premier cas. Le RPC réussit — il n'y a pas d'erreur — mais l'issue + // est différente, et c'est précisément la distinction que l'opérateur ne pouvait pas + // faire avant. + s_journal.clear(); + s_handlerPrecedent = qInstallMessageHandler(collecterJournal); + const bool leve2 = arb->clearLoadFault(QStringLiteral("ko")); + qInstallMessageHandler(s_handlerPrecedent); + QVERIFY2(leve2, "la charge existe : l'appel aboutit, même sans défaut à lever"); + + const QString issue2 = s_journal.filter(QStringLiteral("ClearLoadFault")).join(QStringLiteral(" | ")); + QVERIFY2(issue2.contains(QStringLiteral("AUCUN défaut")), qUtf8Printable(issue2)); + QVERIFY2(!issue2.contains(QStringLiteral("défaut LEVÉ")), qUtf8Printable(issue2)); + + // Troisième cas : adaptateur introuvable. Ni l'un ni l'autre des deux précédents. + s_journal.clear(); + s_handlerPrecedent = qInstallMessageHandler(collecterJournal); + const bool leve3 = arb->clearLoadFault(QStringLiteral("charge-qui-n-existe-pas")); + qInstallMessageHandler(s_handlerPrecedent); + QVERIFY2(!leve3, "un identifiant inconnu ne doit pas réussir"); + + const QString issue3 = s_journal.filter(QStringLiteral("ClearLoadFault")).join(QStringLiteral(" | ")); + QVERIFY2(issue3.contains(QStringLiteral("introuvable")), qUtf8Printable(issue3)); + + // Les trois issues sont distinctes : c'est ce que la règle 7-c exige. + QVERIFY(issue1 != issue2 && issue2 != issue3 && issue1 != issue3); #endif }