diff --git a/INTERFACE.md b/INTERFACE.md index f00cd83..4346d2b 100644 --- a/INTERFACE.md +++ b/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** diff --git a/docs/BRIEF_depuis_plugin.md b/docs/BRIEF_depuis_plugin.md index 231ed99..915a432 100644 --- a/docs/BRIEF_depuis_plugin.md +++ b/docs/BRIEF_depuis_plugin.md @@ -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 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