feat(R1,R7): decision.level, et l'absence d'un niveau distinguée de son zéro

R7 — decision.level sur le motif hérité, présent exactement quand levels[] l'est.
Sans lui chaque client réinvente sa règle de fusion et deux écrans affichent deux
motifs pour le même cycle. Un seul niveau aujourd'hui, donc la question ne se pose
pas : c'est la raison d'ajouter le champ maintenant plutôt qu'avec le §10 — même
argument que R2 sur le coût de déplacement.

R1 — l'absence et le zéro épinglés dans les deux sens. Niveau absent = passe non
parcourue ; niveau à targetW 0 = passe parcourue, rien à prendre. Publier l'un
sans l'autre les rendrait indistinguables. Aujourd'hui la passe unique est
toujours parcourue dès qu'une charge est arbitrée, donc c'est le MODE DÉGRADÉ qui
porte le cas « aucune passe » — levels[] absent, planification suspendue.

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. La règle
vient de l'agent app, qui n'a pas retiré son exception EV_GRID_START mais l'a
déplacée sur la bonne frontière : 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 aussi en tête d'INTERFACE.md, avec sa conséquence : les versions
y datent les changements, elles ne sont pas des conditions à tester.

R5, R6, R8 NE SONT PAS REPOUSSÉES — elles sont sans objet, et je l'ai vérifié
plutôt que supposé : ECO_FLOOR_MET, ECO_FLOOR_GRID et DRAW_CAP n'existent pas
dans le catalogue. Écrire la garde de R6 aujourd'hui poserait un invariant sur un
code que rien n'émet, c'est-à-dire un test toujours vert.

simulation 35/35, charging 17/17, loadmodel 20/20, spotmarket 7/7, amd64 0/0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
This commit is contained in:
Patrick Schurig 2026-08-30 11:05:36 +02:00
parent fded0ba89d
commit 55a5d6e810
5 changed files with 153 additions and 2 deletions

View File

@ -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())

View File

@ -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 »).

View File

@ -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

View File

@ -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
}

View File

@ -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