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:
parent
793b76b6f7
commit
6beee20f43
@ -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;
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user