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:
parent
fded0ba89d
commit
55a5d6e810
@ -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