From 55a5d6e81051aeb7ce79041c6db9890366569ed1 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 30 Aug 2026 11:05:36 +0200 Subject: [PATCH] =?UTF-8?q?feat(R1,R7):=20decision.level,=20et=20l'absence?= =?UTF-8?q?=20d'un=20niveau=20distingu=C3=A9e=20de=20son=20z=C3=A9ro?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p --- energyplugin/etm/energyarbitrator.cpp | 20 ++++- energyplugin/nymeaenergyjsonhandler.cpp | 5 ++ specs/spec_loadmodel.md | 20 +++++ tests/auto/simulation/simulation.cpp | 104 ++++++++++++++++++++++++ tests/auto/simulation/simulation.h | 6 ++ 5 files changed, 153 insertions(+), 2 deletions(-) diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index d5eb93f..44da51d 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -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()) diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index af488ee..745263d 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -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 »). diff --git a/specs/spec_loadmodel.md b/specs/spec_loadmodel.md index ccdf8ed..96dc7e3 100644 --- a/specs/spec_loadmodel.md +++ b/specs/spec_loadmodel.md @@ -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 diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 72de0b6..af1e67c 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -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(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 +} diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index a3c3088..e2a28eb 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -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