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:
parent
1ccea65ecd
commit
6acaee38f9
22
AGENTS.md
22
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`).
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user