Compare commits
2 Commits
ed877d86d2
...
86256d0baf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86256d0baf | ||
|
|
1732f1a640 |
45
INTERFACE.md
45
INTERFACE.md
@ -19,6 +19,27 @@
|
||||
> a l'air d'un résultat. Même famille que le journal lu par un `sudo` qui échoue en silence
|
||||
> (`AGENTS.md`, « Lire le journal de la box »).
|
||||
|
||||
> ## ⚑ UN CLIENT TESTE LA PRÉSENCE D'UN CHAMP, JAMAIS UNE VERSION
|
||||
>
|
||||
> *(Règle posée par l'agent app le 2026-08-30, en déplaçant son exception `EV_GRID_START` :
|
||||
> il ne l'a pas retirée, il l'a mise sur la bonne frontière — elle survit là où la charge utile
|
||||
> n'a pas de `counts`, c'est-à-dire sur les installations encore en `+etm2x`.)*
|
||||
>
|
||||
> **Son argument, et il vaut pour tout ce qui sera ajouté ici** : ce qui a rendu l'exception
|
||||
> caduque n'est pas une **version**, c'est une **charge utile**. Un champ présent décrit ce que
|
||||
> la box sait faire ; un numéro de version décrit ce qu'on croit qu'elle sait faire. La première
|
||||
> frontière est stable, la seconde ne l'est pas — une box peut être rétrogradée, un paquet
|
||||
> reconstruit, une branche déployée hors séquence.
|
||||
>
|
||||
> **Conséquence pour ce contrat** : chaque ajout se conçoit pour être détectable **par sa
|
||||
> présence**, et chaque absence porte un sens écrit. C'est déjà le cas de `levels[]` (absent =
|
||||
> box antérieure **ou** mode dégradé), `measurement.source` (`"none"` publié plutôt que la clé
|
||||
> retirée), `draw` (absent = pas de plan), `rankOrigin` (absent = rien d'affirmé) et
|
||||
> `decision.level` (présent exactement quand `levels[]` l'est).
|
||||
>
|
||||
> Les numéros de version cités dans ce document datent les changements — **ils ne sont pas des
|
||||
> conditions à tester.**
|
||||
|
||||
> **Namespace :** `NymeaEnergy`
|
||||
> **Versions enregistrées :** 0–8 (`registerExperienceHandler(..., 0, 8)`)
|
||||
> **Transport :** WebSocket JSON-RPC 2.0, port 4444 (nymea standard)
|
||||
@ -574,6 +595,30 @@ le cas normal.
|
||||
| `funding` | `"surplus"` ou `"grid"` — l'ORIGINE des watts de ce niveau. `"grid"` **dès qu'un seul watt est acheté** (LM-1013) |
|
||||
| `counts` | `{ destination → watts }` — le REGISTRE où ils sont comptés. **`Σ counts == targetW`, toujours** |
|
||||
|
||||
**R1 — `levels[]` ne contient que les passes RÉELLEMENT parcourues, et l'absence n'est pas le
|
||||
zéro.** Les deux moitiés se répondent, et publier l'une sans l'autre les rendrait
|
||||
indistinguables :
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **niveau absent** | la passe **n'a pas été parcourue** — une charge sans obligation éco n'aura jamais d'entrée `"eco"` |
|
||||
| **niveau à `targetW: 0`** | la passe **a été parcourue**, elle n'avait rien à prendre — la charge a été examinée, et son motif le dit |
|
||||
|
||||
> Un zéro forgé à la place d'une absence détruirait la première information ; une absence à la
|
||||
> place d'un zéro détruirait la seconde. Aujourd'hui la passe unique est toujours parcourue dès
|
||||
> qu'une charge est arbitrée : c'est le **mode dégradé** qui porte le cas « aucune passe », la
|
||||
> planification y étant suspendue — `levels[]` y est **absent**, et `decision.level` avec lui.
|
||||
>
|
||||
> Épinglé par `testLevelAbsenceAndZeroDifferInMeaning`, **vérifié échouant** dans les deux sens.
|
||||
|
||||
**R7 — `decision.level` accompagne `levels[]` : les deux présents, ou les deux absents.**
|
||||
`decision` reste au niveau de la charge — un client qui l'a toujours lu continue de le lire — mais
|
||||
il reflète le motif d'**un** niveau. Sans le dire, chaque client réinventerait sa règle de fusion
|
||||
(« le motif du dernier niveau servi ? du premier refusé ? ») et deux écrans afficheraient deux
|
||||
motifs pour le même cycle. Aujourd'hui il n'y a qu'un niveau et la question ne se pose pas —
|
||||
**c'est précisément pourquoi le champ arrive maintenant** : quand elle se posera, les clients le
|
||||
liront déjà.
|
||||
|
||||
> **Aujourd'hui il y a exactement UN niveau, et c'est `"comfort"`.** La passe unique
|
||||
> d'aujourd'hui est celle qui, au §10, servira le confort depuis le surplus ; la passe éco est
|
||||
> la nouveauté du lot, pas la passe existante rebaptisée. Une charge **sans obligation éco**
|
||||
|
||||
@ -1,3 +1,35 @@
|
||||
powersync-energy-plugin-nymea (1.15.2+etm33) trixie; urgency=medium
|
||||
|
||||
* R7 — `decision.level` : le motif hérité dit de quel NIVEAU il parle. `decision` reste au
|
||||
niveau de la charge, mais il reflète le motif d'UN niveau ; sans le dire, chaque client
|
||||
réinventerait sa règle de fusion (« le motif du dernier niveau servi ? du premier refusé ? »)
|
||||
et deux écrans afficheraient deux motifs pour le même cycle. Il n'y a qu'un niveau
|
||||
aujourd'hui et la question ne se pose pas — c'est précisément pourquoi le champ arrive
|
||||
maintenant : quand elle se posera, les clients le liront déjà.
|
||||
* PRÉSENT EXACTEMENT QUAND levels[] L'EST. En mode dégradé il n'y a pas de passe, donc pas de
|
||||
niveau dont le motif parlerait : ni levels[], ni decision.level.
|
||||
* R1 — l'ABSENCE et le ZÉRO disent deux choses différentes, et les deux moitiés sont épinglées.
|
||||
Niveau absent = passe non parcourue ; niveau à targetW 0 = passe parcourue, rien à prendre.
|
||||
Un zéro forgé détruirait la première information, une absence la seconde. Aujourd'hui la
|
||||
passe unique est toujours parcourue dès qu'une charge est arbitrée : c'est le mode dégradé
|
||||
qui porte le cas « aucune passe ».
|
||||
* testLevelAbsenceAndZeroDifferInMeaning, vérifié échouant DANS LES DEUX SENS — un niveau à
|
||||
zéro omis, et un decision.level publié sans levels[].
|
||||
* LM-1014 — UN CLIENT TESTE LA PRÉSENCE D'UN CHAMP, JAMAIS UNE VERSION. Règle posée par
|
||||
l'agent app en déplaçant son exception EV_GRID_START plutôt qu'en la retirant : elle survit
|
||||
là où la charge utile n'a pas de counts. Ce qui rend une capacité détectable est la charge
|
||||
utile, pas un numéro — une box se rétrograde, un paquet se reconstruit, une branche se
|
||||
déploie hors séquence. Portée en tête d'INTERFACE.md : les versions y datent les changements,
|
||||
elles ne sont pas des conditions à tester.
|
||||
* R5, R6 et R8 NE SONT PAS REPOUSSÉES, elles sont sans objet : vérifié que ECO_FLOOR_MET,
|
||||
ECO_FLOOR_GRID et DRAW_CAP n'existent pas dans le catalogue de motifs. Écrire aujourd'hui la
|
||||
garde de R6 poserait un invariant sur un code que rien n'émet — un test toujours vert, donc
|
||||
un test qui ne protège rien.
|
||||
* Suite complète : simulation 35/35, charging 17/17, loadmodel 20/20, spotmarket 7/7. amd64
|
||||
0 erreur / 0 avertissement, doxygen 0 avertissement.
|
||||
|
||||
-- Patrick Schurig <etm.schurig@gmail.com> Sun, 30 Aug 2026 17:10:00 +0200
|
||||
|
||||
powersync-energy-plugin-nymea (1.15.2+etm32) trixie; urgency=medium
|
||||
|
||||
* R3 — `counts` : chaque watt publié tombe dans exactement un compteur, et le mapping est
|
||||
|
||||
@ -935,7 +935,68 @@ schéma le dit désormais.
|
||||
|
||||
---
|
||||
|
||||
## Ce qui n'a PAS changé, et qu'il ne faut pas déduire
|
||||
## 11. R1 et R7 livrées ; R5, R6, R8 attendent le §10 — et pourquoi
|
||||
|
||||
*(`1.15.2+etm33`. Votre nuance sur la frontière est écrite en `spec_loadmodel.md` **LM-1014**.)*
|
||||
|
||||
### Votre nuance devient une règle du modèle
|
||||
|
||||
Vous n'avez pas *retiré* votre exception `EV_GRID_START`, vous l'avez **déplacée sur la bonne
|
||||
frontière** : elle survit là où la charge utile n'a pas de `counts`. C'est le bon geste, et
|
||||
l'argument vaut au-delà du cas — **ce qui rend une capacité détectable est la charge utile,
|
||||
jamais un numéro de version.** Un champ présent décrit ce que la box *sait faire* ; un numéro
|
||||
décrit ce qu'on *croit* qu'elle sait faire, et une box se rétrograde, un paquet se reconstruit,
|
||||
une branche se déploie hors séquence.
|
||||
|
||||
C'est désormais une règle du modèle (**LM-1014**), et une consigne en tête d'`INTERFACE.md` :
|
||||
chaque champ neuf sera détectable **par sa présence**, chaque absence portera un sens écrit. Les
|
||||
numéros de version du contrat **datent** les changements, ils ne sont pas des conditions à
|
||||
tester.
|
||||
|
||||
### ✅ R1 — l'absence et le zéro, épinglés dans les deux sens
|
||||
|
||||
Votre lecture était juste, et la trame de ce matin en montrait déjà une moitié. Les deux se
|
||||
répondent :
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **niveau absent** | la passe **n'a pas été parcourue** |
|
||||
| **niveau à `targetW: 0`** | la passe **a été parcourue**, rien à prendre — et son motif le dit |
|
||||
|
||||
Aujourd'hui la passe unique est toujours parcourue dès qu'une charge est arbitrée : **c'est le
|
||||
mode dégradé qui porte le cas « aucune passe »** — `levels[]` y est absent, planification
|
||||
suspendue. `testLevelAbsenceAndZeroDifferInMeaning` épingle les deux sens, **vérifié échouant**
|
||||
dans les deux.
|
||||
|
||||
### ✅ R7 — `decision.level`
|
||||
|
||||
```jsonc
|
||||
"decision": { "code": "SURPLUS_INSUFFICIENT", "params": {…}, "level": "comfort" }
|
||||
```
|
||||
|
||||
**Présent exactement quand `levels[]` l'est** — en mode dégradé il n'y a pas de passe, donc pas
|
||||
de niveau dont le motif parlerait. Un motif sans niveau à côté d'un tableau de niveaux vous
|
||||
forcerait à deviner auquel il se rapporte ; un niveau sans tableau serait un renvoi vers rien.
|
||||
|
||||
### ⏸ R5, R6, R8 — pas repoussées : sans objet aujourd'hui
|
||||
|
||||
Nous avons vérifié plutôt que supposé : **`ECO_FLOOR_MET`, `ECO_FLOOR_GRID` et `DRAW_CAP`
|
||||
n'existent pas** dans le catalogue de motifs. Ce ne sont pas des champs à ajouter à un motif
|
||||
existant, ce sont des motifs qui naîtront avec la passe éco et le plafond de soutirage.
|
||||
|
||||
- **R5** — le régime de LM-1009 est porté par le **niveau éco**. Il n'y a pas de niveau éco.
|
||||
- **R6** — `ECO_FLOOR_MET` interdit sous `unmeasurable`. La garde est écrite au contrat et
|
||||
**sera testable le jour où le motif existe** ; l'écrire aujourd'hui garderait un invariant sur
|
||||
un code que rien n'émet — un test toujours vert, donc un test qui ne protège rien.
|
||||
- **R8** — `DRAW_CAP` publie sa source. Le motif n'existe pas, et sa source viendra du transport
|
||||
du §14a, qui n'existe pas non plus.
|
||||
|
||||
> **Le §10 lui-même reste derrière sa précondition** : il ne part pas sans l'**extension du
|
||||
> délestage au waterfall** (LM-1006, couplage 1). Si tous les planchers éco se servent au réseau
|
||||
> le même matin sans soleil, ils s'additionnent sur le disjoncteur de branchement — et la
|
||||
> protection de surcharge d'aujourd'hui ne déleste **que les bornes**. Livrer l'éco sans elle
|
||||
> donnerait un plancher capable de faire disjoncter une installation. Ce n'est pas une question
|
||||
> d'écran, et c'est pour cela qu'elle n'apparaît pas dans la maquette.
|
||||
|
||||
- **L'échéance de départ et le tarif dynamique restent au proxy**, financés au réseau
|
||||
(`funding: "grid"`, motifs `EV_DEADLINE` / `EV_SPOT_MARKET`). Leur puissance vit dans
|
||||
|
||||
@ -1070,7 +1070,12 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const
|
||||
{QStringLiteral("counts"),
|
||||
QVariantMap{{QString::fromLatin1(CountKey::BudgetAllocated), 0.0}}}});
|
||||
orphan.insert(QStringLiteral("available"), false);
|
||||
orphan.insert(QStringLiteral("decision"), action.reason.toMap());
|
||||
// R7 — decision.level ACCOMPAGNE levels[] : les deux présents, ou les deux
|
||||
// absents. Un motif sans niveau à côté d'un tableau de niveaux forcerait le client
|
||||
// à deviner auquel il se rapporte.
|
||||
QVariantMap decisionOrphan = action.reason.toMap();
|
||||
decisionOrphan.insert(QStringLiteral("level"), QStringLiteral("comfort"));
|
||||
orphan.insert(QStringLiteral("decision"), decisionOrphan);
|
||||
orphan.insert(QStringLiteral("faultCode"), QStringLiteral("THING_MISSING"));
|
||||
QVariantMap mech;
|
||||
mech.insert(QStringLiteral("kind"), QStringLiteral("evcharger"));
|
||||
@ -1130,7 +1135,18 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const
|
||||
niveau.insert(QStringLiteral("counts"), counts);
|
||||
entry.insert(QStringLiteral("levels"), QVariantList() << niveau);
|
||||
entry.insert(QStringLiteral("available"), adapter->telemetry().available);
|
||||
entry.insert(QStringLiteral("decision"), action.reason.toMap());
|
||||
// R7 — LE MOTIF HÉRITÉ DIT DE QUEL NIVEAU IL PARLE. `decision` reste au niveau de la
|
||||
// charge — un client qui l'a toujours lu continue de le lire — mais il reflète le motif
|
||||
// d'UN niveau, et sans le dire chaque client réinventerait sa règle de fusion : « le
|
||||
// motif du dernier niveau servi ? du premier refusé ? ». Deux écrans afficheraient deux
|
||||
// motifs pour le même cycle.
|
||||
//
|
||||
// Aujourd'hui il n'y a qu'un niveau et la question ne se pose pas — c'est précisément
|
||||
// pourquoi le champ s'ajoute MAINTENANT : quand elle se posera, les clients le liront
|
||||
// déjà. Voir R2, même raisonnement sur le coût de déplacement.
|
||||
QVariantMap decision = action.reason.toMap();
|
||||
decision.insert(QStringLiteral("level"), niveau.value(QStringLiteral("level")));
|
||||
entry.insert(QStringLiteral("decision"), decision);
|
||||
|
||||
const LoadRuntimeView v = adapter->runtimeView(now);
|
||||
if (!v.faultCode.isEmpty())
|
||||
|
||||
@ -767,6 +767,11 @@ QVariantMap NymeaEnergyJsonHandler::loadTelemetrySchema()
|
||||
QVariantMap decisionItem;
|
||||
decisionItem.insert("code", enumValueName(String));
|
||||
decisionItem.insert("params", enumValueName(Variant));
|
||||
// R7 — DE QUEL NIVEAU ce motif parle. Sans lui, chaque client réinvente sa règle de fusion
|
||||
// (« le motif du dernier niveau servi ? du premier refusé ? ») et deux écrans affichent deux
|
||||
// motifs pour le même cycle. PRÉSENT exactement quand levels[] l'est : en mode dégradé il
|
||||
// n'y a pas de passe, donc pas de niveau dont le motif parlerait.
|
||||
decisionItem.insert("o:level", enumValueName(String));
|
||||
|
||||
// §11 / LM-1106 — la mesure et SA SOURCE. « source » discrimine, « powerW » n'existe que
|
||||
// quand quelque chose mesure réellement (jamais sous « none »).
|
||||
|
||||
@ -923,6 +923,26 @@ a-t-il été dépensé ?* — et elle vaudra **sans décision nouvelle** pour le
|
||||
> présent en plus pour faire croire le contraire. Le client qui veut la nuance la **dérive** de
|
||||
> `counts` (LM-1012).
|
||||
|
||||
**LM-1014 — Un client teste la PRÉSENCE d'un champ, jamais une version.** *(Règle posée par
|
||||
l'agent app le 2026-08-30. Elle généralise LM-1007 et lui donne son mécanisme.)*
|
||||
|
||||
Ce qui rend une capacité détectable est **la charge utile**, jamais un numéro de version : un
|
||||
champ présent décrit ce que la box **sait faire**, un numéro décrit ce qu'on **croit** qu'elle
|
||||
sait faire. La première frontière est stable, la seconde ne l'est pas — une box se rétrograde, un
|
||||
paquet se reconstruit, une branche se déploie hors séquence.
|
||||
|
||||
> **L'exemple qui a produit la règle** : l'agent app n'a pas *retiré* son exception
|
||||
> `EV_GRID_START` quand le partage est devenu publié, il l'a **déplacée sur la bonne frontière**
|
||||
> — elle survit là où la charge utile n'a pas de `counts`, c'est-à-dire sur les installations
|
||||
> encore en `+etm2x`, où le partage est **réel mais non publié**. Une exception conditionnée à
|
||||
> une version aurait été fausse au premier déploiement hors séquence.
|
||||
|
||||
**Conséquence pour tout ce qu'on ajoute** : chaque champ neuf doit être détectable par sa
|
||||
présence, et **chaque absence doit porter un sens écrit**. Le contrat le tient déjà pour
|
||||
`levels[]`, `measurement.source`, `draw`, `rankOrigin` et `decision.level` — et c'est pourquoi
|
||||
aucun de ces champs n'est jamais publié « à zéro pour compléter » : un zéro forgé détruit
|
||||
précisément l'information que l'absence portait.
|
||||
|
||||
**LM-1007 — Compatibilité descendante, et elle est gratuite.** Une charge qui ne déclare
|
||||
**aucun** besoin éco ne participe qu'à la passe 2 — soit exactement le comportement
|
||||
d'aujourd'hui. Le modèle à deux niveaux **dégrade proprement** vers le modèle actuel, ce qui
|
||||
|
||||
@ -6297,3 +6297,107 @@ void Simulation::testCountsSumToTarget()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void Simulation::testLevelAbsenceAndZeroDifferInMeaning()
|
||||
{
|
||||
#ifndef ETM_ARBITRATOR
|
||||
QSKIP("testLevelAbsenceAndZeroDifferInMeaning nécessite ETM_ARBITRATOR.");
|
||||
#else
|
||||
// [R1] « levels[] ne contient que les passes RÉELLEMENT parcourues. » La règle a deux
|
||||
// moitiés qui se répondent, et publier l'une sans l'autre les rendrait indistinguables :
|
||||
//
|
||||
// niveau ABSENT → la passe n'a pas été parcourue ;
|
||||
// niveau à 0 → la passe A ÉTÉ parcourue, elle n'avait rien à prendre.
|
||||
//
|
||||
// Un zéro forgé à la place d'une absence détruirait la première information ; une absence à
|
||||
// la place d'un zéro détruirait la seconde. Aujourd'hui la passe unique est toujours
|
||||
// parcourue quand la charge est arbitrée — c'est le MODE DÉGRADÉ qui porte le cas « aucune
|
||||
// passe », la planification y étant suspendue.
|
||||
//
|
||||
// [R7] Et `decision.level` accompagne `levels[]` : les deux présents, ou les deux absents.
|
||||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-levels.json";
|
||||
QFile::remove(cfgPath);
|
||||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||||
|
||||
cleanupTestCase();
|
||||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||||
initTestCase();
|
||||
enableNotifications({"NymeaEnergy"});
|
||||
|
||||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||||
QVERIFY(arb);
|
||||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||||
|
||||
QUuid meterId = addMeter();
|
||||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||||
Thing *meter = tm->findConfiguredThing(meterId);
|
||||
QVERIFY(meter);
|
||||
meter->setStateValue("connected", true);
|
||||
|
||||
QUuid varId = addEtmVariableLoad(27045);
|
||||
Thing *var = tm->findConfiguredThing(varId);
|
||||
QVERIFY(var);
|
||||
var->setStateValue("currentPowerW", 0);
|
||||
|
||||
QVariantList l;
|
||||
l.append(QVariantMap{{"id", varId.toString()}, {"label", "Routeur"},
|
||||
{"adapter", "etmvariableload"}, {"mode", "dynamic"},
|
||||
{"maxPowerW", 3000}, {"priority", 1}, {"enabled", true}});
|
||||
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
|
||||
.toMap().value("params").toMap().value("energyError").toString(),
|
||||
QString("EnergyErrorNoError"));
|
||||
|
||||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||||
|
||||
// ---- 1. Passe parcourue AVEC budget : niveau publié, targetW > 0 -----------------------
|
||||
meter->setStateValue("currentPower", -2000);
|
||||
arb->recordMeterUpdate(t0);
|
||||
arb->simulationCallUpdate(t0);
|
||||
QCoreApplication::processEvents();
|
||||
QVariantMap ch = telemetrieParId(arb->loadTelemetry()).value(varId.toString());
|
||||
QCOMPARE(ch.value("levels").toList().count(), 1);
|
||||
QVERIFY(qRound(ch.value("levels").toList().first().toMap().value("targetW").toDouble()) > 0);
|
||||
QCOMPARE(ch.value("decision").toMap().value("level").toString(), QString("comfort"));
|
||||
|
||||
// ---- 2. Passe parcourue SANS budget : le niveau reste publié, à ZÉRO -------------------
|
||||
// C'est la moitié qui compte. « Rien à prendre » n'est pas « pas de passe » : la charge a
|
||||
// bien été examinée, le motif le dit, et l'écran doit pouvoir écrire « en attente » plutôt
|
||||
// que de faire disparaître la ligne.
|
||||
var->setStateValue("currentPowerW", 0);
|
||||
meter->setStateValue("currentPower", 3000); // import franc
|
||||
arb->recordMeterUpdate(t0.addSecs(60));
|
||||
arb->simulationCallUpdate(t0.addSecs(60));
|
||||
QCoreApplication::processEvents();
|
||||
ch = telemetrieParId(arb->loadTelemetry()).value(varId.toString());
|
||||
const QVariantList niv = ch.value("levels").toList();
|
||||
QCOMPARE(niv.count(), 1);
|
||||
QCOMPARE(qRound(niv.first().toMap().value("targetW").toDouble()), 0);
|
||||
QVERIFY2(!niv.first().toMap().value("counts").toMap().isEmpty(),
|
||||
"un niveau à zéro publie quand même sa destination");
|
||||
QCOMPARE(ch.value("decision").toMap().value("level").toString(), QString("comfort"));
|
||||
QVERIFY2(ch.value("decision").toMap().value("code").toString() != QString(),
|
||||
"une passe parcourue sans budget porte un MOTIF, pas un silence");
|
||||
|
||||
// ---- 3. Aucune passe parcourue : levels[] ABSENT, et decision.level avec lui -----------
|
||||
// Mode dégradé L2 : la planification est SUSPENDUE. Publier un niveau à zéro y mentirait —
|
||||
// il n'y a pas eu de passe du tout, et « rien à prendre » supposerait qu'on ait regardé.
|
||||
QSignalSpy spy(m_mockTcpServer, &MockTcpServer::outgoingData);
|
||||
arb->evaluateMeterFreshness(t0.addSecs(60 + 95)); // compteur muet > 90 s
|
||||
QCoreApplication::processEvents();
|
||||
const QVariantList notifs = checkNotifications(spy, "NymeaEnergy.LoadTelemetryChanged");
|
||||
QVERIFY2(!notifs.isEmpty(), "la bascule en mode dégradé doit notifier");
|
||||
const QVariantMap dgr = notifs.last().toMap().value("params").toMap();
|
||||
QCOMPARE(dgr.value("degradedMode").toBool(), true);
|
||||
|
||||
bool vuUneCharge = false;
|
||||
for (const QVariant &v : dgr.value("loads").toList()) {
|
||||
vuUneCharge = true;
|
||||
const QVariantMap c = v.toMap();
|
||||
QVERIFY2(!c.contains("levels"),
|
||||
"levels[] publié en mode dégradé : aucune passe n'a pourtant été parcourue");
|
||||
QVERIFY2(!c.value("decision").toMap().contains("level"),
|
||||
"decision.level sans levels[] : le client devrait deviner à quoi il se rapporte");
|
||||
}
|
||||
QVERIFY2(vuUneCharge, "le mode dégradé doit publier les charges, sans leurs niveaux");
|
||||
#endif
|
||||
}
|
||||
|
||||
@ -186,6 +186,12 @@ private slots:
|
||||
// zéro avec sa destination.
|
||||
void testCountsSumToTarget();
|
||||
|
||||
// [R1 + R7] L'ABSENCE et le ZÉRO disent deux choses différentes : un niveau à targetW 0 est
|
||||
// publié (« passe parcourue, rien à prendre »), un niveau absent dirait « passe non
|
||||
// parcourue ». Et decision.level accompagne levels[] : les deux présents, ou les deux
|
||||
// absents — en mode dégradé il n'y a pas de passe, donc ni l'un ni l'autre.
|
||||
void testLevelAbsenceAndZeroDifferInMeaning();
|
||||
|
||||
// ── §12 / LM-1209 ──────────────────────────────────────────────────────
|
||||
// Une borne détectée après la mise en service entre EN QUEUE : elle ne passe devant
|
||||
// aucune charge qu'un humain a classée, aucun autre rang ne bouge, le rang produit est
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user