diff --git a/AGENTS.md b/AGENTS.md index c89fe4e..3165177 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -328,6 +328,28 @@ Règles absolues : reflètent l'état en continu. 7. **`decisionReason` non vide, en français, sur chaque action.** Action sans reason = rejetée. + - **7-b — le motif nomme le mécanisme qui a produit l'issue** (ECS-309), et il ne le + nomme que si ce mécanisme a réellement agi (ECS-309-b). Un motif faux est pire qu'un + motif absent : il envoie diagnostiquer le mauvais problème, avec l'autorité d'une + explication. + - **7-c — un silence ne doit jamais être ambigu.** Journaliser **l'issue, pas seulement + l'issue remarquable**. Un traitement qui ne s'annonce que dans son cas notable rend + « exécuté, rien à signaler » indistinguable de « pas exécuté » — et c'est justement + dans le cas ordinaire qu'on vient vérifier qu'il a bien eu lieu. + **Journaliser ce qui a été LU, pas seulement ce qui en est déduit** : une conclusion + juste tirée d'une lecture incertaine se relit comme une conclusion sûre. + > ECS-411 déduisait le palier de l'état des relais mais ne l'annonçait qu'au palier + > non nul (`if (i > 0)`). Au palier 0 — le cas de l'armement minOff, celui qu'on vient + > précisément observer — le journal était muet, et « relais lus, tous ouverts » ne se + > distinguait pas de « reprise non exécutée ». Constaté au banc le 2026-08-13. + > Le relevé détaillé exigé par 7-c a immédiatement révélé un second défaut, de fond + > celui-là : un relais annoncé à `(0W)` parce que sa nominale était redéduite des + > 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 + 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 après pilotage. 9. **Aucun composant propriétaire ici** (Héos = repo privé `etm-powersync-optimizer`). diff --git a/debian-qt5/changelog b/debian-qt5/changelog index 3d38acc..717ddf1 100644 --- a/debian-qt5/changelog +++ b/debian-qt5/changelog @@ -1,3 +1,29 @@ +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 + a LU : l'état de chaque relais avec sa puissance déclarée, le palier retenu, la branche + de déduction et la somme lue. La correspondance exacte ne journalisait qu'au palier non + nul ; au palier 0 — le cas de l'armement minOff, celui qu'on vient précisément observer + — le journal était muet, et « relais lus, tous ouverts » ne se distinguait pas de + « reprise non exécutée ». Une reprise avec relais injoignable passe en avertissement : + le palier déduit est plausible mais la lecture est incertaine. + * Défaut de fond trouvé PAR ce relevé, et corrigé. La puissance nominale de chaque relais + était redéduite des encodages solo de la table de paliers, or la déduplication par + puissance peut évincer un encodage solo : avec un câblage 500/1000/1500, le palier 1500 + est encodé {500,1000} et le relais 1500 n'apparaît seul nulle part. Sa nominale valait + 0, donc un démarrage sur ce seul relais annonçait palier 0 pendant que 1500 W + circulaient — l'annonce en sous-estimation qu'ECS-411 existe pour interdire, que le + silence au palier 0 couvrait. La nominale est prise à la déclaration. + * Règle 7-c d'AGENTS.md : un silence ne doit jamais être ambigu. Journaliser l'issue, pas + seulement l'issue remarquable ; journaliser ce qui a été lu, pas seulement ce qui en est + déduit. Trois autres endroits signalés dans spec_ecs.md, non corrigés. + * 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. + + -- Patrick Schurig Thu, 13 Aug 2026 21:00:00 +0200 + powersync-energy-plugin-nymea (1.15.2+etm11) trixie; urgency=medium * ECS-309-b — le motif ne nomme un verrou que si ce verrou a déplacé la consigne. Le diff --git a/docs/RELEVE_ECS306.md b/docs/RELEVE_ECS306.md index f44d63e..f657835 100644 --- a/docs/RELEVE_ECS306.md +++ b/docs/RELEVE_ECS306.md @@ -98,6 +98,43 @@ chauffe-eau reste à 3500 W du début à la fin et le relevé est vide. ## 3. Protocole +### 3.0 — PRÉALABLE : le compteur doit BOUGER, pas seulement être publié + +**À lire avant toute autre ligne de ce protocole.** Il conditionne la validité de +tout ce qui suit, quel que soit le volet. + +Un état nymea n'émet `changed()` que si **la valeur change**. Le watchdog L2 +compte les *mises à jour* du compteur racine, pas les publications : une valeur +republiée à l'identique n'en produit aucune. Au bout de **90 s** sans changement, +l'arbitre déclare le compteur muet, bascule en **mode dégradé L2**, force toutes +les charges à leur repli et **suspend la planification**. + +Conséquence directe : **une mesure à valeur forcée constante ne mesure pas +l'arbitrage, elle mesure le watchdog.** Un profil en plateaux de 90 s ou plus, +injecté comme une consigne fixe, s'auto-détruit — et le relevé se remplit de +`consigne 0 W (force) | "Compteur muet depuis >90 s"` au lieu de décisions. + +> Constaté au banc le 2026-08-13, en forçant le surplus pour observer l'armement +> minOff : le simulateur avait été **gelé** (`banc/cmd/pause`), sur le +> raisonnement — faux — que la publication continue suffisait à entretenir la +> fraîcheur. `tick()` sort avant tout recalcul quand `paused` est vrai, donc la +> valeur ne bougeait plus. L2 a mordu en 105 s, forcé l'ECS et la PAC à 0 et +> suspendu la planification. La mesure visée avait été capturée 80 s plus tôt ; +> elle aurait été perdue avec un cycle de plus. + +**Règle pour les deux volets.** Toute grandeur forcée doit varier à un pas +inférieur à 90 s — quelques dizaines de watts suffisent, l'amplitude est +indifférente, seul le changement compte. Sur le banc, épingler l'horloge simulée +en alternant deux valeurs proches (ex. `banc/cmd/heure` à 11,9 puis 12,1 toutes +les 20 s) tient le budget à peu près constant tout en entretenant les +changements. **Ne jamais utiliser `banc/cmd/pause`** pendant une campagne. + +Corollaire favorable : des **compteurs réels** bougent d'eux-mêmes. Une fois les +Eastron et le smartmeter ECS appairés (§7 de `spec_ecs.md`), ce piège disparaît +de lui-même — c'est une raison de plus de faire l'appairage avant la campagne. + +### 3.1 — Campagnes + Deux campagnes **séparées**, jamais fusionnées : si un résultat surprend, il faut pouvoir dire lequel des deux réglages en est la cause. diff --git a/energyplugin/etm/adapters/relayrouter.cpp b/energyplugin/etm/adapters/relayrouter.cpp index f2c0a06..2680a91 100644 --- a/energyplugin/etm/adapters/relayrouter.cpp +++ b/energyplugin/etm/adapters/relayrouter.cpp @@ -43,6 +43,12 @@ RelayRouter::RelayRouter(ThingManager *thingManager, n = MaxRelays; } + // Nominale par relais, prise à la DÉCLARATION. Voir m_relayNominalW dans l'en-tête : + // la redéduire des encodages solo la perd pour tout relais dont la puissance est + // aussi atteignable par une combinaison. + for (int i = 0; i < n; ++i) + m_relayNominalW.insert(relays.at(i).thingId, relays.at(i).powerW); + // Paliers DÉRIVÉS : toutes les sommes de sous-ensembles, dédupliquées par puissance, triées // (QMap = clés croissantes), 0 inclus (sous-ensemble vide = masque 0). Pour des puissances // identiques (ex. deux relais 1000 W), on garde la première combinaison rencontrée. @@ -295,17 +301,26 @@ int RelayRouter::deduceStageFromThings() const for (const QString &id : list) allRelays.insert(id); - // Puissance nominale par relais, pour sommer l'état lu. - QHash nominal; - for (int i = 0; i < m_relayMapping.size(); ++i) - for (const QString &id : m_relayMapping.at(i)) - if (m_relayMapping.at(i).size() == 1) - nominal.insert(id, m_levels.at(i)); + // Puissance nominale par relais, pour sommer l'état lu. Prise à la DÉCLARATION + // (m_relayNominalW) et non redéduite des encodages solo : avec 500/1000/1500, le + // palier 1500 est encodé {500,1000} et le relais 1500 n'apparaît jamais seul. Sa + // nominale valait alors 0 — donc un démarrage sur ce seul relais donnait sumW = 0, + // aucune correspondance exacte, et un palier 0 annoncé pendant que 1500 W circulaient. + // C'est précisément l'annonce en sous-estimation qu'ECS-411 interdit. Trouvé par le + // relevé d'ECS-411-b, qui affichait « (0W) » pour ce relais. + const QHash &nominal = m_relayNominalW; + // ECS-411-b — journaliser ce qui est LU, pas seulement ce qui en est déduit. + // « palier repris : 0 » ne dit pas si les relais étaient ouverts ou si l'un d'eux + // était injoignable et supposé fermé : deux situations qui appellent des gestes + // opposés. On garde donc la lecture relais par relais, et on la publie avec l'issue. QSet on; int sumW = 0; bool unreadable = false; + QStringList lecture; for (const QString &thingId : allRelays) { + const int w = nominal.value(thingId, 0); + const QString court = thingId.left(8); // assez pour identifier, assez court pour tenir Thing *relay = m_thingManager ? m_thingManager->findConfiguredThing(ThingId(thingId)) : nullptr; if (!relay) { // Thing absent : son état est INCONNU. On le suppose FERMÉ — sous-estimer la @@ -313,26 +328,32 @@ int RelayRouter::deduceStageFromThings() const // que retarder une montée en puissance. unreadable = true; on.insert(thingId); - sumW += nominal.value(thingId, 0); + sumW += w; + lecture << QStringLiteral("%1(%2W)=INJOIGNABLE→supposé fermé").arg(court).arg(w); continue; } - if (relay->stateValue("power").toBool()) { + const bool ferme = relay->stateValue("power").toBool(); + if (ferme) { on.insert(thingId); - sumW += nominal.value(thingId, 0); + sumW += w; } + lecture << QStringLiteral("%1(%2W)=%3").arg(court).arg(w) + .arg(ferme ? QStringLiteral("fermé") : QStringLiteral("ouvert")); } - if (unreadable) - qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label - << "— relais introuvable(s) au démarrage : supposé(s) FERMÉ(s)."; + lecture.sort(); // ordre stable : QSet n'en garantit aucun, et un relevé se compare + + // Sortie UNIQUE. Les trois issues étaient auparavant journalisées séparément, et la + // première se taisait au palier 0 — un silence indistinguable d'une reprise non + // exécutée. Toute issue est désormais annoncée, la nominale comme les remarquables. + int idx = -1; + QString issue; // 1) Correspondance EXACTE de l'ensemble de relais avec un palier : cas nominal. - for (int i = 0; i < m_relayMapping.size(); ++i) { + for (int i = 0; i < m_relayMapping.size() && idx < 0; ++i) { const QList &l = m_relayMapping.at(i); if (QSet(l.begin(), l.end()) == on) { - if (i > 0) - qCInfo(dcNymeaEnergy()) << "[RelayRouter]" << m_label - << "— palier repris au démarrage:" << m_levels.at(i) << "W"; - return i; + idx = i; + issue = QStringLiteral("correspondance exacte"); } } @@ -341,20 +362,35 @@ int RelayRouter::deduceStageFromThings() const // PUISSANCE : toute somme de sous-ensemble figure nécessairement dans m_levels, // puisque m_levels est construit de toutes ces sommes. On annonce donc la bonne // puissance, et le premier applyAction() normalisera l'encodage des relais. - const int idx = m_levels.indexOf(sumW); - if (idx >= 0) { - qCInfo(dcNymeaEnergy()) << "[RelayRouter]" << m_label - << "— encodage de démarrage hors table, repris par la puissance:" - << sumW << "W (normalisé au premier cycle)."; - return idx; + if (idx < 0) { + const int parPuissance = m_levels.indexOf(sumW); + if (parPuissance >= 0) { + idx = parPuissance; + issue = QStringLiteral("encodage hors table, repris par la puissance " + "(normalisé au premier cycle)"); + } } // 3) Inatteignable sauf troncature à MaxRelays : la somme lue dépasse la table. On prend // le palier le plus haut plutôt que 0 — ne JAMAIS annoncer moins que ce qui est appliqué. - qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label - << "— puissance de démarrage" << sumW - << "W hors table (relais tronqués ?) : palier maximal adopté."; - return m_levels.size() - 1; + if (idx < 0) { + idx = m_levels.size() - 1; + issue = QStringLiteral("puissance hors table (relais tronqués ?) : palier maximal adopté"); + } + + const QString releve = QStringLiteral("[RelayRouter] %1 — reprise au démarrage : " + "palier %2 W [%3] ; relais lus : %4 ; somme lue %5 W") + .arg(m_label).arg(m_levels.at(idx)).arg(issue) + .arg(lecture.join(QStringLiteral(", "))).arg(sumW); + + // Un relais injoignable rend la reprise incertaine : elle mérite un avertissement, + // pas une information. Le relevé est le même, seul le niveau change. + if (unreadable) + qCWarning(dcNymeaEnergy()).noquote() << releve << "— état INCERTAIN (relais injoignable)."; + else + qCInfo(dcNymeaEnergy()).noquote() << releve; + + return idx; } void RelayRouter::writeRelay(const QString &thingId, bool on) diff --git a/energyplugin/etm/adapters/relayrouter.h b/energyplugin/etm/adapters/relayrouter.h index ddc1a1c..aa5a91f 100644 --- a/energyplugin/etm/adapters/relayrouter.h +++ b/energyplugin/etm/adapters/relayrouter.h @@ -145,6 +145,11 @@ private: QString m_label; QList m_levels; //!< Paliers W dérivés, triés, [0]=0. QList> m_relayMapping; //!< ThingIds ON par palier (dérivé des combinaisons). + //! Puissance nominale DÉCLARÉE de chaque relais. Ne pas la redéduire de + //! m_relayMapping : la déduplication par puissance peut évincer l'encodage solo d'un + //! relais (ex. 500/1000/1500 — le palier 1500 est encodé {500,1000}, et le relais 1500 + //! n'apparaît alors jamais seul), ce qui lui donnerait une nominale nulle. Voir ECS-411-b. + QHash m_relayNominalW; int m_minOnS; int m_minOffS; int m_priority; diff --git a/specs/spec_ecs.md b/specs/spec_ecs.md index 823a839..a9334e5 100644 --- a/specs/spec_ecs.md +++ b/specs/spec_ecs.md @@ -484,6 +484,48 @@ il est simplement ignoré. (`relayrouter.h:106`) et aucun état de relais n'est relu. Le palier courant DOIT être déduit de l'état réel des Things. +**ECS-411-b — La reprise DOIT s'annoncer, et annoncer ce qu'elle a lu.** Toute +reprise au démarrage DOIT être journalisée, **y compris au palier 0** et y compris +dans son cas nominal. Le relevé DOIT porter l'état lu de **chaque** relais — son +identifiant, sa puissance nominale déclarée, et `fermé` / `ouvert` / +`injoignable → supposé fermé` — ainsi que le palier retenu, la branche de +déduction empruntée et la somme lue. Une reprise comportant un relais injoignable +DOIT être publiée en **avertissement**, l'état déduit étant incertain. + +> Application de la règle **7-c** d'`AGENTS.md` (un silence ne doit jamais être +> ambigu). La correspondance exacte ne journalisait qu'au palier non nul +> (`if (i > 0)`) : au palier 0 — le cas de l'armement minOff d'ECS-412, celui +> qu'on vient précisément observer — le journal était muet, et « relais lus, tous +> ouverts » ne se distinguait pas de « reprise non exécutée ». Constaté au banc le +> 2026-08-13 : le contrôle d'ECS-411 sur le chemin réel était impossible à faire +> depuis les traces, alors même que la reprise fonctionnait. +> +> **Et « palier repris : 0 » reste ambigu sans le relevé** : un relais injoignable +> est supposé fermé, ce qui produit un palier plausible à partir d'une lecture +> incertaine. Deux situations appelant des gestes opposés, une seule ligne. + +> **Défaut de fond trouvé PAR ce relevé** (corrigé le 2026-08-13). Le relevé +> affichait `(0W)` pour le relais 1500 W d'un câblage 500/1000/1500. Cause : la +> nominale par relais était redéduite des encodages **solo** de `m_relayMapping`, +> or la déduplication par puissance peut évincer un encodage solo — le palier 1500 +> est encodé `{500,1000}` (masque 3, rencontré avant le masque 4), si bien que le +> relais 1500 n'apparaît seul nulle part et recevait une nominale de **0**. Un +> démarrage sur ce seul relais donnait alors `sumW = 0`, aucune correspondance +> exacte, et un **palier 0 annoncé pendant que 1500 W circulaient** — exactement +> l'annonce en sous-estimation qu'ECS-411 existe pour interdire, et que le silence +> au palier 0 couvrait. La nominale est désormais prise à la **déclaration** +> (`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 : +> +> | 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é. | + **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 `EtmVariableLoadAdapter` (`etmvariableloadadapter.cpp:134`) jettent toujours le @@ -721,6 +763,7 @@ mécanique reste **L0**. Perte de sonde → mode sans sonde, pas arrêt d'urgenc | ECS-309 | simulation | `testEcsColdStartLockExpires` — les trois causes distinguées par le TEXTE du motif (minOff / budget / minOn), vérifié échouant sans le correctif | | ECS-309-b | simulation | `testEcsColdStartLockExpires` §4 — palier 0 + budget négatif SANS verrou actif : le motif nomme le budget, jamais `minOn`. Vérifié échouant sans le correctif, où il reproduit le texte exact du banc | | ECS-412 | simulation | `testEcsRebuildPreservesLock` + **`testEcsColdStartLockExpires`** (armement à froid transitoire) | +| ECS-411-b | simulation | `testEcsRestartRecovery` cas 1 / 1-bis / 1-ter — la reprise s'annonce au palier 0, nomme l'état lu de chaque relais, passe en avertissement sur relais injoignable, et le cas 1-ter couvre la nominale évincée par déduplication. Vérifiés échouant sans les correctifs | | ECS-303 | banc | relevé de commutations à la frontière de recombinaison (§4.0) — **mesurer avant de coder** | | ECS-305 | unitaire | `testEcsSwitchCount` — compteur par relais | | ECS-302 | unitaire | `testEcsSwitchCost` — non exercé par l'installation de référence ; à tester sur un câblage à encodages multiples | @@ -802,6 +845,9 @@ protocole dans le même lot.** | 2026-08-08 | **Étape 1 OUVERTE** — trois exigences : ECS-306, ECS-411, ECS-412 | | 2026-08-08 | ECS-412 : au démarrage à froid, le verrou est **ARMÉ** (défaut sûr), jamais purgé | | 2026-08-09 | ECS-412 précisé : cet armement est **TRANSITOIRE** et posé paresseusement au premier `now`. Défaut de blocage circulaire constaté **au banc**, corrigé, couvert par `testEcsColdStartLockExpires` | +| 2026-08-13 | **ECS-411-b créé et FAIT** — la reprise au démarrage s'annonce toujours, et publie l'état lu de chaque relais. Le `if (i > 0)` rendait le journal muet au palier 0, soit le cas de l'armement minOff : le contrôle d'ECS-411 sur le chemin réel était impossible depuis les traces. Application de la règle 7-c d'`AGENTS.md` | +| 2026-08-13 | **Défaut de fond trouvé PAR le relevé d'ECS-411-b** — nominale par relais redéduite des encodages solo, que la déduplication peut évincer (câblage 500/1000/1500) : un démarrage sur le seul relais 1500 annonçait palier 0 pendant que 1500 W circulaient. Prise à la déclaration désormais. La lecture rendue visible est ce qui a rendu le défaut trouvable | +| 2026-08-13 | **Règle 7-c écrite dans `AGENTS.md`** — un silence ne doit jamais être ambigu ; journaliser l'issue, pas seulement l'issue remarquable ; journaliser ce qui a été LU, pas seulement ce qui en est déduit. Trois autres endroits signalés, non corrigés | | 2026-08-13 | **ECS-309-b créé et FAIT** — le critère du motif est l'effet du verrou sur la consigne voulue, pas une comparaison au budget. `setpointW > budgetW` nommait `minOn` à palier nul dès que le budget était négatif ; constaté au banc sept fois en quatre heures sur `+etm10`. Antérieur à ECS-309, non audité par lui. Masquait le motif d'armement à froid d'ECS-412 au fond du creux — donc corrigé avant la campagne du volet 2, pas après | | 2026-08-13 | **Journal du banc `.75` rendu persistant** — Raspberry Pi OS force `Storage=volatile` (`40-rpi-volatile-storage.conf`) : le journal vivait en RAM, plafonné à 8,3 Mo, et la fenêtre d'armement à froid du 12 août avait été rotée avant d'être lue. Drop-in `99-etm-retention.conf` (200 Mo, 1 mois) + `journalctl --flush`, et `ThingManager.debug` coupé — 137 → 25 lignes/min, soit ~6,5 Mo/jour sur la carte SD | | 2026-08-13 | **ECS-411 vérifié sur le chemin réel** — au redémarrage de 06:15, `« [RelayRouter] chauffe-eau — palier repris au démarrage: 2500 W »`. La relecture des relais au démarrage fonctionne sur la machine, pas seulement en simulation | diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index a0f8721..e4ca33f 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -948,6 +948,18 @@ void Simulation::testEcsBudgetUnderLock() #endif } +// [ECS-411-b] Collecteur de messages, pour asserter sur ce que le journal DIT. Sans lui, +// on ne pourrait tester que le palier déduit — or le défaut visé est précisément qu'un +// palier juste était annoncé par un silence. +static QStringList s_journal; +static QtMessageHandler s_handlerPrecedent = nullptr; +static void collecterJournal(QtMsgType t, const QMessageLogContext &c, const QString &m) +{ + s_journal << m; + if (s_handlerPrecedent) + s_handlerPrecedent(t, c, m); +} + void Simulation::testEcsRestartRecovery() { #ifndef ETM_ARBITRATOR @@ -975,13 +987,84 @@ void Simulation::testEcsRestartRecovery() {r1500.toString(), 1500} }); // Cas 1 — tous ouverts : palier 0, comportement inchangé. + // [ECS-411-b] Et la reprise DOIT s'annoncer quand même. Jusqu'au 2026-08-13 la + // correspondance exacte ne journalisait que si le palier était non nul (`if (i > 0)`), + // si bien qu'au palier 0 — le cas de l'armement minOff, celui qui compte — le journal + // était muet. Un silence ne distingue pas « relais lus, tous ouverts » de « reprise + // non exécutée ». Le test porte sur le TEXTE, comme pour ECS-309. { t500->setStateValue("power", false); t1000->setStateValue("power", false); t1500->setStateValue("power", false); + s_journal.clear(); + s_handlerPrecedent = qInstallMessageHandler(collecterJournal); RelayRouter *r = new RelayRouter(tm, "ecs-off", "ECS éteint", relays, 0, 0, 1, LoadNeeds(), arb); + qInstallMessageHandler(s_handlerPrecedent); QCOMPARE(r->currentStage(), 0); QCOMPARE(qRound(r->currentSetpointW()), 0); + + const QString repr = s_journal.filter(QStringLiteral("reprise au démarrage")).value(0); + QVERIFY2(!repr.isEmpty(), "ECS-411-b : aucune reprise annoncée au palier 0"); + QVERIFY2(repr.contains(QStringLiteral("palier 0 W")), qUtf8Printable(repr)); + // Ce qui a été LU, pas seulement ce qui en est déduit : les trois relais nommés, + // chacun avec sa puissance et son état. C'est la seconde moitié d'ECS-411-b. + QVERIFY2(repr.contains(QStringLiteral("500W)=ouvert")), qUtf8Printable(repr)); + QVERIFY2(repr.contains(QStringLiteral("1000W)=ouvert")), qUtf8Printable(repr)); + QVERIFY2(repr.contains(QStringLiteral("1500W)=ouvert")), qUtf8Printable(repr)); + QVERIFY2(repr.contains(QStringLiteral("somme lue 0 W")), qUtf8Printable(repr)); + QVERIFY2(!repr.contains(QStringLiteral("INJOIGNABLE")), qUtf8Printable(repr)); + } + + // Cas 1-bis — [ECS-411-b] un relais INJOIGNABLE, supposé fermé. Le palier annoncé + // peut être le même qu'un cas nominal ; c'est le relevé qui doit lever le doute, et + // le niveau qui doit passer en avertissement. Sans ça, « palier repris : 0 » couvre + // deux situations appelant des gestes opposés. + { + t500->setStateValue("power", false); + t1000->setStateValue("power", false); + t1500->setStateValue("power", false); + // Un thingId qui ne correspond à aucun Thing configuré : branche « absent ». + const QList avecFantome({ + {r500.toString(), 500}, + {QUuid("{deadbeef-0000-4000-8000-000000000001}").toString(), 1000} }); + s_journal.clear(); + s_handlerPrecedent = qInstallMessageHandler(collecterJournal); + RelayRouter *rf = new RelayRouter(tm, "ecs-fantome", "ECS relais absent", + avecFantome, 0, 0, 1, LoadNeeds(), arb); + qInstallMessageHandler(s_handlerPrecedent); + Q_UNUSED(rf) + + const QString repr = s_journal.filter(QStringLiteral("reprise au démarrage")).value(0); + QVERIFY2(!repr.isEmpty(), "ECS-411-b : aucune reprise annoncée avec relais absent"); + QVERIFY2(repr.contains(QStringLiteral("INJOIGNABLE")), qUtf8Printable(repr)); + QVERIFY2(repr.contains(QStringLiteral("supposé fermé")), qUtf8Printable(repr)); + QVERIFY2(repr.contains(QStringLiteral("INCERTAIN")), qUtf8Printable(repr)); + // Le relais lisible reste annoncé comme lu, et non noyé dans l'incertitude. + QVERIFY2(repr.contains(QStringLiteral("500W)=ouvert")), qUtf8Printable(repr)); + } + + // Cas 1-ter — [ECS-411-b] le relais dont l'encodage SOLO a été évincé par la + // déduplication. Avec 500/1000/1500, le palier 1500 est encodé {500,1000} (masque 3, + // rencontré avant le masque 4), donc R1500 n'apparaît seul dans AUCUN encodage. Sa + // nominale était alors redéduite à 0, et un démarrage sur ce seul relais donnait + // sumW = 0 → palier 0 annoncé pendant que 1500 W circulaient. C'est l'annonce en + // sous-estimation qu'ECS-411 interdit, et le silence au palier 0 la couvrait. + // Trouvé par le relevé d'ECS-411-b, qui affichait « (0W) » pour ce relais. + { + t500->setStateValue("power", false); + t1000->setStateValue("power", false); + t1500->setStateValue("power", true); // SEUL R1500 fermé + s_journal.clear(); + s_handlerPrecedent = qInstallMessageHandler(collecterJournal); + RelayRouter *r = new RelayRouter(tm, "ecs-dedup", "ECS dédup", relays, 0, 0, 1, LoadNeeds(), arb); + qInstallMessageHandler(s_handlerPrecedent); + + QCOMPARE(qRound(r->currentSetpointW()), 1500); // JAMAIS moins que ce qui circule + const QString repr = s_journal.filter(QStringLiteral("reprise au démarrage")).value(0); + QVERIFY2(!repr.isEmpty(), "ECS-411-b : aucune reprise annoncée"); + QVERIFY2(repr.contains(QStringLiteral("palier 1500 W")), qUtf8Printable(repr)); + QVERIFY2(repr.contains(QStringLiteral("1500W)=fermé")), qUtf8Printable(repr)); + QVERIFY2(repr.contains(QStringLiteral("somme lue 1500 W")), qUtf8Printable(repr)); } // Cas 2 — R1000 fermé au démarrage : le routeur DOIT repartir à 1000 W, pas à 0.