diff --git a/energyplugin/etm/adapters/relayrouter.cpp b/energyplugin/etm/adapters/relayrouter.cpp index 368f481..9c77c13 100644 --- a/energyplugin/etm/adapters/relayrouter.cpp +++ b/energyplugin/etm/adapters/relayrouter.cpp @@ -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; diff --git a/specs/spec_ecs.md b/specs/spec_ecs.md index 2c91f82..bd7f9ef 100644 --- a/specs/spec_ecs.md +++ b/specs/spec_ecs.md @@ -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 | diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index ab85b4f..3b933d1 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -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(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({ {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({ {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 diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 957a40c..8f6de4e 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -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);