fix(relayrouter): ECS-411-b — la reprise s'annonce, et annonce ce qu'elle a lu

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 : ECS-411 fonctionnait, et son contrôle sur le
chemin réel était impossible depuis les traces.

Le relevé porte désormais l'état de CHAQUE relais — identifiant, puissance déclarée, et
fermé / ouvert / injoignable→supposé fermé — avec le palier retenu, la branche empruntée et
la somme lue. Sortie unique pour les trois branches. Un relais injoignable fait passer la
ligne en avertissement : le palier déduit est plausible, la lecture ne l'est pas, et
« palier repris : 0 » couvrait ces deux situations sans les distinguer.

Un défaut de fond a été trouvé PAR ce relevé, qui affichait « (0W) » pour le relais 1500 W.
La nominale par relais était redéduite des encodages solo, or la déduplication par puissance
peut en évincer un : avec 500/1000/1500, le palier 1500 est encodé {500,1000} (masque 3,
rencontré avant le masque 4) et le relais 1500 n'apparaît seul nulle part. Sa nominale valait
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 — l'annonce en sous-estimation qu'ECS-411
existe pour interdire. Elle est prise à la déclaration (m_relayNominalW). Rendre la lecture
visible est ce qui a rendu le défaut trouvable : c'est le sens de la règle, pas un effet
secondaire.

Règle 7-c 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 dans spec_ecs.md, non corrigés — le retour idempotent
du routeur, clearFault() sur charge saine, et le refus minStateHold en qCDebug quand
l'application est en qCInfo.

RELEVE_ECS306 §3.0, en tête du protocole : 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 en gelant le simulateur.

Les tests portent sur le TEXTE publié, via un collecteur de messages : un test « journal non
vide » n'aurait rien vu. Trois cas, tous vérifiés échouant sans les correctifs. Suite
complète : 112 tests, 0 échec.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-08-13 20:13:18 +02:00
parent 1ccea65ecd
commit 6acaee38f9
7 changed files with 282 additions and 27 deletions

View File

@ -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`).

View File

@ -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 <etm.schurig@gmail.com> 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

View File

@ -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.

View File

@ -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<QString, int> 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<QString, int> &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<QString> 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<QString> &l = m_relayMapping.at(i);
if (QSet<QString>(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)

View File

@ -145,6 +145,11 @@ private:
QString m_label;
QList<int> m_levels; //!< Paliers W dérivés, triés, [0]=0.
QList<QList<QString>> 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<QString, int> m_relayNominalW;
int m_minOnS;
int m_minOffS;
int m_priority;

View File

@ -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 |

View File

@ -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<LoadConfigRelay> 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.