fix(etm): ECS-412 — armement à froid TRANSITOIRE (blocage circulaire corrigé)

Défaut constaté AU BANC le 2026-08-09, pas en test : une charge sonde de rang 3
est restée figée à 0 W sous 4 kW de surplus disponible.

Mécanisme. lockWindow() traitait un m_lastSwitch nul comme sentinelle
« elapsed = 0 » à CHAQUE cycle. Une charge démarrant au palier 0 avec
minOffS > 0 voyait donc offHeld vrai en permanence, maxStage forcé à 0 : elle ne
pouvait jamais s'enclencher, donc jamais commuter, donc jamais valider
m_lastSwitch. Blocage circulaire. L'intention d'ECS-412 — armer plutôt que
purger — était juste ; mon écriture rendait l'armement DÉFINITIF au lieu de
transitoire.

Portée réelle : toute charge relay-router démarrant relais ouverts avec
minOffS > 0 était gelée. Le chauffe-eau du banc (minOffS = 60) n'y échappait que
parce que ses relais étaient déjà fermés et qu'ECS-411 lui faisait reprendre un
palier non nul — au premier démarrage sur installation froide, il aurait été
bloqué lui aussi.

Correction : armement PARESSEUX. applyAction(), chemin non-const qui reçoit le
temps de cycle, estampille m_lastSwitch au premier now s'il est invalide. Le
verrou expire alors après sa durée configurée. Le repli « elapsed = 0 » ne
couvre plus que les cycles précédant la première action. L'invariant « temps =
paramètre, jamais l'horloge » est préservé : aucune horloge n'est lue, et le
contrat d'ILoadAdapter n'est pas modifié — passer now au constructeur aurait
changé l'interface pour un cas particulier.

Symétrie vérifiée : relais fermés au départ → ECS-411 donne un palier non nul,
c'est minOn qui s'arme ; relais ouverts → palier 0, c'est minOff. Les deux sont
désormais transitoires de la même façon.

Test — le vrai livrable : testEcsColdStartLockExpires. Palier 0 au départ,
minOffS = 120, budget de 5000 W. La charge reste éteinte à t0 et à t0+119, puis
s'enclenche à t0+121. Couvre aussi l'expiration de la fenêtre exposée au
scheduler (lockMaxPowerW : 0 pendant le verrou, 1000 après). Aucun test ne
combinait « palier 0 au départ » et « minOffS > 0 » — c'était le trou exact.

spec_ecs.md : ECS-412 précise que l'armement est transitoire, d'une durée égale
au verrou configuré, posé paresseusement, avec le tableau de symétrie et le
renvoi au test.

Build amd64 0 erreur. Simulation : 15/15.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-08-09 08:24:41 +02:00
parent 793b76b6f7
commit 6beee20f43
4 changed files with 132 additions and 1 deletions

View File

@ -157,6 +157,22 @@ LoadAction RelayRouter::applyAction(const LoadAction &action, const QDateTime &n
return action;
}
// ECS-412 — ARMEMENT PARESSEUX du verrou (démarrage à froid). Au premier \c now reçu,
// on estampille la « dernière commutation » : le verrou est donc armé pour sa durée
// configurée, puis EXPIRE naturellement.
//
// Sans cela, un \c m_lastSwitch nul faisait office de sentinelle « elapsed = 0 » à chaque
// cycle, donc un verrou PERMANENT : une charge démarrant au palier 0 avec minOffS > 0 ne
// pouvait jamais s'enclencher, donc jamais commuter, donc jamais valider m_lastSwitch —
// blocage circulaire. Constaté au banc le 2026-08-09 (charge sonde figée à 0 W sous
// 4 kW de surplus disponible).
//
// L'armement vit ICI, dans le chemin NON-const qui reçoit le temps de cycle, et non dans
// lockWindow() qui est const et ne fait que calculer. L'invariant « temps = paramètre,
// jamais l'horloge » (iloadadapter.h) est préservé : aucune horloge n'est lue.
if (!m_lastSwitch.isValid())
m_lastSwitch = now;
int newStage = stageForPower(action.powerW);
// Verrou anti-rebond INTERNE (clamp), au temps de cycle. Bypass si force==true (repli L2).
@ -219,6 +235,13 @@ void RelayRouter::lockWindow(const QDateTime &now, int &minStage, int &maxStage)
// elapsed = 0 — et non de le purger. L'écriture naturelle (`valid && elapsed < minOnS`)
// fait l'inverse et laisserait une boucle de redémarrage court-circuiter la protection
// compresseur exactement quand elle est la plus nécessaire.
//
// Cet armement est TRANSITOIRE : applyAction() estampille m_lastSwitch au premier \c now
// reçu, si bien que le verrou expire après sa durée configurée. Le repli ci-dessous ne
// couvre donc que les cycles précédant la première action — typiquement le premier
// toLoadContext(). Il ne doit JAMAIS devenir un état permanent : c'était le défaut
// corrigé le 2026-08-09 (cf. testEcsColdStartLockExpires).
//
// Armer ne signifie PAS verrouiller inconditionnellement : avec une durée nulle,
// `0 < 0` est faux et le verrou reste inactif.
const qint64 elapsed = m_lastSwitch.isValid() ? m_lastSwitch.secsTo(now) : 0;

View File

@ -255,6 +255,32 @@ quand elle est la plus nécessaire. **L'implémentation naturelle fait l'inverse
(`QDateTime` nul = verrou inactif) : ce point DOIT être écrit explicitement dans
le code et couvert par un test.**
**Cet armement DOIT être TRANSITOIRE**, d'une durée égale au verrou configuré, et
jamais permanent. Il DOIT être posé **paresseusement**, au premier `now` reçu par
le chemin non-const (`applyAction()`), et non calculé dans la fenêtre de verrou :
un horodatage nul servant de sentinelle « écoulé = 0 » à chaque cycle produit un
**blocage circulaire** — une charge démarrant au palier 0 avec `minOffS > 0` ne
peut jamais s'enclencher, donc jamais commuter, donc jamais valider son
horodatage. Passer `now` au constructeur n'est PAS la réponse : cela changerait le
contrat d'`ILoadAdapter` pour un cas particulier.
**Symétrie exigée.** Les deux cas de démarrage à froid sont armés de la même
façon et expirent de la même façon :
| État initial des relais | Palier déduit (ECS-411) | Verrou armé |
|---|---|---|
| fermés | non nul | `minOn` |
| ouverts | 0 | `minOff` |
Le second cas protège d'un `nymead` qui redémarre juste après une ouverture : le
maintien est justifié, c'est sa **permanence** qui ne l'est pas.
Cette exigence porte son test : **`testEcsColdStartLockExpires`** — palier 0 au
départ, `minOffS > 0`, budget largement suffisant ; la charge reste éteinte
pendant `minOffS`, puis s'enclenche. Le défaut a été constaté **au banc** le
2026-08-09 (charge sonde figée à 0 W sous 4 kW de surplus disponible), alors
qu'aucun test ne combinait « palier 0 au départ » et « `minOffS > 0` ».
Il ne s'agit pas de confort : les verrous sont de la **protection matérielle**.
Avec un `minOn` de 300 à 600 s sur un ballon thermodynamique, un client qui
réordonne ses priorités depuis l'app réarme les verrous et peut faire
@ -508,7 +534,7 @@ mécanique reste **L0**. Perte de sonde → mode sans sonde, pas arrêt d'urgenc
| Exigence | Type | Test |
|---|---|---|
| ECS-306 | simulation | `testEcsBudgetUnderLock` |
| ECS-412 | simulation | `testEcsRebuildPreservesLock` |
| ECS-412 | simulation | `testEcsRebuildPreservesLock` + **`testEcsColdStartLockExpires`** (armement à froid transitoire) |
| 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 |
@ -584,6 +610,7 @@ protocole dans le même lot.**
| 2026-08-08 | **§13-2 CLOS, lecture (b)** — ECS-412 réduit à `m_lastSwitch` + cause racine (rebuild incrémental) ; ECS-411 remonté en étape 1 |
| 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-08 | ~~Câblage 500/1000/2000~~**donnée fausse**, corrigée le jour même (cf. ligne suivante) |
| 2026-08-08 | ~~Câblage 500/1000/1500 (source simulateur)~~**donnée fausse** : le simulateur ne reflète pas l'installation |
| 2026-08-08 | ~~ECS-302 exercé~~ — découlait de la donnée fausse |

View File

@ -1066,6 +1066,83 @@ void Simulation::testEcsRebuildPreservesLock()
#endif
}
void Simulation::testEcsColdStartLockExpires()
{
#ifndef ETM_ARBITRATOR
QSKIP("testEcsColdStartLockExpires nécessite ETM_ARBITRATOR.");
#else
// [ECS-412] L'armement du verrou au démarrage à froid doit être TRANSITOIRE.
//
// Défaut corrigé : un m_lastSwitch nul servait de sentinelle « elapsed = 0 » à CHAQUE
// cycle. Une charge démarrant au palier 0 avec minOffS > 0 ne pouvait donc jamais
// s'enclencher — donc jamais commuter, donc jamais valider m_lastSwitch : blocage
// circulaire. Constaté au banc le 2026-08-09, une charge sonde restant à 0 W sous
// 4 kW de surplus disponible. Aucun test ne combinait « palier 0 au départ » et
// « minOffS > 0 » : c'était exactement le trou.
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arb);
ThingManager *tm = NymeaCore::instance()->thingManager();
QUuid rA = addPowerSwitch(1000, 26661);
Thing *tA = tm->findConfiguredThing(rA);
QVERIFY(tA);
tA->setStateValue("power", false); // départ RELAIS OUVERT → palier 0
const int minOff = 120;
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
RelayRouter *r = new RelayRouter(tm, "froid", "Charge à froid",
QList<LoadConfigRelay>({ {rA.toString(), 1000} }), 0, minOff, 1, LoadNeeds(), arb);
QCOMPARE(r->currentStage(), 0); // ECS-411 : rien de fermé → palier 0
auto sp = [&](double w, const QDateTime &at) {
LoadAction a; a.kind = LoadAction::Setpoint; a.funding = LoadAction::Surplus;
a.powerW = w; a.reason = QStringLiteral("test démarrage à froid");
return r->applyAction(a, at);
};
// Budget LARGEMENT suffisant dès le premier cycle : seul le verrou peut retenir.
// Pendant minOff, la charge DOIT rester éteinte — c'est la protection voulue.
sp(5000, t0);
QCOMPARE(qRound(r->currentSetpointW()), 0);
QCOMPARE(tA->stateValue("power").toBool(), false);
sp(5000, t0.addSecs(minOff - 1)); // encore dans la fenêtre
QCOMPARE(qRound(r->currentSetpointW()), 0);
// …et APRÈS minOff, elle DOIT s'enclencher. C'est la moitié que le défaut supprimait :
// le verrou ne doit pas survivre à sa propre durée.
sp(5000, t0.addSecs(minOff + 1));
QCOMPARE(qRound(r->currentSetpointW()), 1000);
QCOMPARE(tA->stateValue("power").toBool(), true);
// La fenêtre exposée au scheduler (ECS-306) suit la même expiration : plafond nul
// pendant le verrou, plafond réel ensuite.
// Relais REMIS OUVERT avant construction : sans quoi ECS-411 déduirait un palier non
// nul et ce serait minOn, pas minOff, qui s'armerait — on ne testerait pas le cas visé.
tA->setStateValue("power", false);
RelayRouter *r2 = new RelayRouter(tm, "froid2", "Charge à froid 2",
QList<LoadConfigRelay>({ {rA.toString(), 1000} }), 0, minOff, 1, LoadNeeds(), arb);
QCOMPARE(r2->currentStage(), 0);
LoadContext c0 = r2->toLoadContext(t0);
QCOMPARE(qRound(c0.telemetry.lockMaxPowerW), 0); // armé au premier cycle
// Un premier applyAction estampille m_lastSwitch : à partir de là le verrou court.
LoadAction amorce;
amorce.kind = LoadAction::Setpoint;
amorce.funding = LoadAction::Surplus;
amorce.powerW = 0;
amorce.reason = QStringLiteral("amorçage");
r2->applyAction(amorce, t0);
LoadContext c1 = r2->toLoadContext(t0.addSecs(minOff + 1));
QCOMPARE(qRound(c1.telemetry.lockMaxPowerW), 1000); // expiré
#endif
}
void Simulation::run_data()
{
// Simulation infos

View File

@ -95,6 +95,10 @@ private slots:
// [étape 1 / ECS-412] SetLoadConfig pendant une fenêtre de verrou active ne réarme pas
// le verrou : seul le matériel modifié est reconstruit. Couvre aussi l'armement à froid.
void testEcsRebuildPreservesLock();
// [étape 1 / ECS-412] Démarrage à froid, palier 0, minOffS > 0, budget largement
// suffisant : la charge reste éteinte PENDANT minOffS puis s'enclenche. L'armement à
// froid doit être transitoire, jamais permanent (défaut constaté au banc 2026-08-09).
void testEcsColdStartLockExpires();
void printStates(Thing *thing);
void updateChargerMeter(Thing *thing);