From ccf05cea878df70bc005f02880704b72b0c39ceb Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Tue, 1 Sep 2026 17:27:56 +0200 Subject: [PATCH] =?UTF-8?q?feat(R6):=20la=20mesurabilit=C3=A9=20se=20d?= =?UTF-8?q?=C3=A9clare=20=C3=A0=20la=20saisie,=20et=20l'inventaire=20du=20?= =?UTF-8?q?mock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le signal vit dans GetLoadConfig, en r:, et non dans la télémétrie. `measurement` n'existe que pour les charges arbitrées ; or une charge qu'on DÉCLARE est précisément celle qui n'y est pas encore. L'écran serait muet au moment exact où il doit parler — celui de la saisie, avant qu'une obligation n'achète à l'aveugle tous les jours. Trois causes, trois gestes : noMeter se répare en une manipulation, meterWithoutEnergy demande de remplacer le compteur, noSessionEnergy ne se répare pas. Sans la cause, l'avertissement ne peut dire que « ça ne marchera pas ». Et meterThingId ne permet pas de le déduire : un compteur peut publier currentPower sans totalEnergyConsumed — mesuré sur l'ECS du banc, reproduit au test avec un powerSwitch désigné comme compteur. La puissance ne suffit pas, il faut une énergie. Contre-épreuve : déduction par meterThingId seul, le cas du banc passe pour mesurable. INVENTAIRE À FROID DU MOCK. Trois fois en trois jours qu'un cas réel s'est révélé inatteignable ; ce n'est plus un incident, c'est une propriété du banc — il est plus homogène que la réalité, ayant été construit pour faire marcher des scénarios et non pour représenter la diversité du parc. Quatre cas restent structurellement introuvables, dont `source: none` pour une charge SAINE — toutes les classes pilotables portent une puissance, si bien qu'une charge non mesurée mais en bon état est impossible à fabriquer, alors qu'un relais sec est banal sur le terrain. Et la branche `temperature`, écrite et jamais exécutée faute d'une classe qui porte l'état. La règle : avant d'écrire un test qui exerce une ABSENCE, vérifier que le mock peut la produire. Une assertion sur un cas inatteignable ne passe pas, elle NE S'EXÉCUTE PAS — ce qui est pire, car elle compte comme verte. Le symptôme se reconnaît : la contre-épreuve ne mord pas. Deux ajouts dans AGENTS.md. L'asymétrie des latences est une PROPRIÉTÉ, pas une chance : le refus qui coûte de l'argent est celui de la descente, et c'est le plus vite constaté — ce qui n'est vrai que parce que le seuil vient du mécanisme. Et la question opératoire appliquée aux trois autres identités, avec un résultat inégal qui est le but : celle de `draw` repose sur le fait que le compteur voie toute la maison, ce que rien ne vérifie ; Σ levels[].targetW == estimatedPowerW est interne au plan et ne dit rien du matériel — il a fallu un champ hors de la formule pour relier les deux. Suite : simulation 61/61, loadmodel 22/22, charging 48/48 identique à la référence, spotmarket 32/32, doxygen 0 avertissement. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p --- AGENTS.md | 23 +++++++ INTERFACE.md | 31 +++++++++ debian-qt5/changelog | 36 ++++++++++ docs/INVENTAIRE_MOCK.md | 79 ++++++++++++++++++++++ energyplugin/nymeaenergyjsonhandler.cpp | 70 ++++++++++++++++++- energyplugin/nymeaenergyjsonhandler.h | 21 ++++++ energyplugin/smartchargingmanager.h | 6 ++ tests/auto/simulation/simulation.cpp | 90 +++++++++++++++++++++++++ tests/auto/simulation/simulation.h | 1 + 9 files changed, 355 insertions(+), 2 deletions(-) create mode 100644 docs/INVENTAIRE_MOCK.md diff --git a/AGENTS.md b/AGENTS.md index 64af38b..bceb0cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -889,6 +889,29 @@ aucune, la décision est une intention, et elle se périmera à la vitesse du co > la rendre vérifiable. Une hypothèse ne se teste pas par recoupement interne : elle se teste > en la confrontant au monde, ou elle ne se teste pas. +> **Corollaire de conception : l'asymétrie des latences joue dans le bon sens, et c'est une +> PROPRIÉTÉ, pas une chance.** Le refus dangereux est celui de la DESCENTE — commander la baisse +> et ne pas être suivi — et c'est celui dont la latence est la plus courte (V2C : 7 s contre +> 30 s à la montée). **Le refus qui coûte de l'argent se constate donc plus vite que celui qui +> n'en coûte pas.** Ce n'est vrai que parce que le seuil vient du mécanisme : une constante +> unique perdrait exactement cette propriété, en traitant les deux sens pareil. +> +> ### La question appliquée aux identités déjà publiées +> +> *« Sur quoi cette identité repose-t-elle qui ne soit pas dans ses propres termes ? »* — passée +> aux trois autres, elle donne trois réponses inégales, et c'est le but : +> +> | Identité | Ce sur quoi elle repose, hors de ses termes | Vérifié ? | +> |---|---|---| +> | `Σ counts == targetW` | qu'**aucun site d'émission n'oublie** de renseigner `counts` | **oui** — `testCountsSumToTarget` relit la charge utile et ne fait confiance à aucun site ; c'est ainsi qu'un huitième site a été trouvé | +> | `authorisedW − committedW == remainingW` | que **le compteur voie toute la maison** — une charge en amont du compteur laisse l'identité vraie et sans objet | **non** — rien ne le vérifie, et rien ne le peut depuis le moteur | +> | `Σ levels[].targetW == estimatedPowerW` | que **le décidé soit l'appliqué** : elle est interne au PLAN et ne dit rien du matériel, que l'adaptateur écrête après coup | **depuis peu** — c'est exactement ce que `command.divergent` mesure | +> +> **La troisième est la leçon.** Une identité peut être parfaitement vraie *et* ne parler que +> d'elle-même. Celle-là décrit ce que le moteur a décidé, pas ce que la maison fait — et il a +> fallu un champ HORS de la formule pour relier les deux. La deuxième reste ouverte, et il faut +> le savoir plutôt que de croire qu'elle prouve quelque chose sur l'installation. + > **Piège de build, 2026-09-01** : changer la DISPOSITION d'une structure dans un en-tête inclus > transitivement (ici un `QDateTime` ajouté à `EcoProgress`, atteint par `LoadAction`) peut > laisser des objets compilés contre l'ancienne. Le symptôme est un **plantage dans un diff --git a/INTERFACE.md b/INTERFACE.md index e551e1d..b3460ec 100644 --- a/INTERFACE.md +++ b/INTERFACE.md @@ -699,6 +699,37 @@ C'est un **cumul sur la fenêtre d'obligation** : remis à zéro à la bascule d > `purchasedWh` couvre la période d'avant. **Les deux se répondent**, et ensemble ils disent la > vérité complète. Le champ est **absent** tant que rien n'a jamais été mesuré dans la période. +### `progressMeasurable` / `progressUnmeasurableCause` — dans `GetLoadConfig`, pas dans la télémétrie + +```jsonc +// GetLoadConfig → loadConfigs[] +{ "id": "…", "adapter": "relay-router", …, + "progressMeasurable": false, "progressUnmeasurableCause": "meterWithoutEnergy" } +``` + +**Pourquoi ici et pas dans `loads[]`.** `measurement` n'existe que pour les charges arbitrées. +Or une charge qu'on **déclare** est précisément celle qui n'y est pas encore — pas arbitrée, ou +créée à l'instant. L'écran serait muet **au moment exact où il doit parler** : celui où +l'installateur saisit une obligation qui, sans mesure, achètera à l'aveugle tous les jours. + +| Cause | Ce qu'elle veut dire | Le geste | +|---|---|---| +| `noMeter` | aucun compteur rattaché | **réparable en une manipulation** | +| `meterWithoutEnergy` | le compteur ne cumule pas l'énergie | **à remplacer** | +| `noSessionEnergy` | la borne ne compte pas sa session | **rien à faire — c'est le matériel** | + +Sans la cause, l'avertissement ne peut dire que « ça ne marchera pas ». Même structure que les +trois sources de `DRAW_CAP` : trois causes, trois gestes. + +> **`meterThingId` ne permet PAS de le déduire.** Un compteur peut publier `currentPower` **sans** +> `totalEnergyConsumed` — mesuré le 2026-09-01 sur l'ECS du banc. Le champ est renseigné, le +> compteur existe, et pourtant rien ne cumule : **la puissance ne suffit pas, il faut une +> énergie**. Un client qui déduirait la mesurabilité de la présence du champ se tromperait +> exactement sur ce cas. +> +> `progressMeasurable` est en `r:` — **lecture seule**. C'est un constat sur le matériel, pas un +> réglage : un client qui l'écrirait affirmerait quelque chose sur une ThingClass. + ### `command` — l'écart entre le commandé et le mesuré Publié **seulement** quand une source mesure (`measurement.source` ≠ `none`) **et** qu'une diff --git a/debian-qt5/changelog b/debian-qt5/changelog index 8c9af22..8924397 100644 --- a/debian-qt5/changelog +++ b/debian-qt5/changelog @@ -1,3 +1,39 @@ +powersync-energy-plugin-nymea (1.15.2+etm47) trixie; urgency=medium + + * R6 — LA MESURABILITÉ DE L'AVANCEMENT SE DÉCLARE DANS `GetLoadConfig`, en `r:`, et non dans + la télémétrie. `measurement` n'existe que pour les charges arbitrées ; or une charge qu'on + DÉCLARE est celle qui n'y est pas encore. L'écran serait muet au moment exact où il doit + parler — celui de la saisie, avant qu'une obligation n'achète à l'aveugle tous les jours. + * TROIS CAUSES, TROIS GESTES : `noMeter` (réparable en une manipulation), `meterWithoutEnergy` + (compteur à remplacer), `noSessionEnergy` (rien à faire, c'est le matériel). Même structure + que les trois sources de DRAW_CAP. + * ET `meterThingId` NE PERMET PAS DE LE DÉDUIRE : un compteur peut publier `currentPower` sans + `totalEnergyConsumed` — mesuré sur l'ECS du banc, et reproduit au test. La puissance ne + suffit pas, il faut une énergie. Contre-épreuve : déduction par `meterThingId` seul, le cas + du banc passe pour mesurable. + * INVENTAIRE À FROID DU MOCK (docs/INVENTAIRE_MOCK.md). Trois fois en trois jours qu'un cas + réel s'est révélé inatteignable par la suite ; ce n'est plus un incident, c'est une propriété + du banc d'essai — il est PLUS HOMOGÈNE QUE LA RÉALITÉ. La table des classes dit ce qui est + discriminant et ce qui ne l'est pas, et quatre cas restent structurellement introuvables, + dont `source: none` pour une charge SAINE (toutes les classes pilotables portent une + puissance) et la branche `temperature`, écrite et jamais exécutée. + * La règle qui en sort : avant d'écrire un test qui exerce une ABSENCE, vérifier que le mock + peut la produire — une assertion sur un cas inatteignable ne passe pas, elle NE S'EXÉCUTE + PAS, ce qui est pire, car elle compte comme verte. Symptôme reconnaissable : la + contre-épreuve ne mord pas. Parade : compter les cas examinés et échouer si le compte est nul. + * AGENTS.md — l'asymétrie des latences notée comme une PROPRIÉTÉ et non une chance : le refus + qui coûte de l'argent est celui de la descente, et c'est le plus vite constaté. Ce n'est vrai + que parce que le seuil vient du mécanisme ; une constante unique perdrait la propriété. + * Et la question opératoire appliquée aux trois autres identités du contrat. Résultat inégal, + et c'est le but : Σ counts == targetW est vérifiée hors de ses termes ; l'identité de `draw` + repose sur le fait que le compteur voie toute la maison, ce que RIEN ne vérifie ; + Σ levels[].targetW == estimatedPowerW est INTERNE AU PLAN et ne dit rien du matériel — il a + fallu `command.divergent`, hors de la formule, pour relier les deux. + * Suite : simulation 61/61, loadmodel 22/22, charging 48/48 identique à la référence, + spotmarket 32/32, doxygen 0 avertissement. + + -- Patrick Schurig Tue, 01 Sep 2026 14:00:00 +0200 + powersync-energy-plugin-nymea (1.15.2+etm46) trixie; urgency=medium * LE RECRÉDIT EST CONDITIONNÉ À L'OBÉISSANCE CONSTATÉE. Il rendait au budget la conso d'une diff --git a/docs/INVENTAIRE_MOCK.md b/docs/INVENTAIRE_MOCK.md new file mode 100644 index 0000000..3282bc6 --- /dev/null +++ b/docs/INVENTAIRE_MOCK.md @@ -0,0 +1,79 @@ +# INVENTAIRE À FROID DU MOCK — ce qu'il rend structurellement introuvable + +*(Fait le 2026-09-01, après trois occurrences en trois jours d'un cas réel impossible à +reproduire. Ce n'est plus un incident, c'est une propriété du banc d'essai.)* + +## Pourquoi cet inventaire + +Trois fois cette semaine, un défaut réel s'est révélé **inatteignable par la suite** : + +| Occurrence | Ce qui était introuvable | Comment on l'a su | +|---|---|---| +| Correctif amont `calculateAllowanceAmpere` | un compteur publiant les courants **sans** les puissances par phase | le mock pose `currentPhaseB = currentPowerPhaseB / 230`, cohérent **par construction** | +| Test `command` sous `source: none` | une charge dont rien ne mesure la puissance | **toutes** les classes pilotables portent `currentPower` | +| Test `ECO_FLOOR_*` en régime `unmeasurable` | une borne sans `sessionEnergy` | il a fallu **ajouter** l'état à une seule classe pour avoir les deux cas | + +Le point commun : **le mock est plus homogène que la réalité.** Il a été construit pour faire +marcher des scénarios, pas pour représenter la diversité du parc — et une suite verte ne dit alors +rien sur les cas qu'il ne sait pas produire. + +## Les classes, et ce qu'elles portent + +| Classe | `currentPower` | `totalEnergyConsumed` | `sessionEnergy` | +|---|---|---|---| +| `meter` | ✅ | ✅ | — | +| `charger` | ✅ | ✅ | ❌ | +| `chargerPhaseSwitching` | ✅ | ✅ | ✅ *(ajouté le 2026-08-31)* | +| `simpleCharger` | ❌ | ❌ | ❌ | +| `powerSwitch` | ✅ | ❌ | — | +| `etmVariableLoad` | ✅ *(`currentPowerW`)* | ❌ | — | +| `energyStorage` | ✅ | ❌ | — | +| `car`, `notification` | ❌ | ❌ | — | + +**Ce que la table donne, et c'est la bonne nouvelle** : `sessionEnergy` et `totalEnergyConsumed` +sont désormais **discriminants** — il existe des classes avec et sans. Les régimes `measured` / +`unmeasurable` et les causes `noSessionEnergy` / `meterWithoutEnergy` sont donc exerçables. + +## Ce qui reste STRUCTURELLEMENT introuvable + +**1. `measurement.source: none` pour une charge saine.** Toutes les classes pilotables portent une +puissance. Le seul chemin est une charge dont le **Thing n'existe pas** (câblage manquant) — cas +réel, mais qui traîne avec lui `available: false`. **Une charge saine et non mesurée est +impossible à fabriquer ici**, alors qu'elle est banale sur le terrain (un relais sec). + +**2. Un compteur par phase incohérent.** `currentPhaseX` et `currentPowerPhaseX` sont posés +ensemble par le mock, dans un rapport fixe de 230. Aucun scénario ne peut produire une tension +réelle ≠ 230 V, ni un compteur qui ne publierait qu'une des deux familles. + +**3. `temperature`.** Le code teste `hasState("temperature")` et **aucune classe du mock ne le +porte** : la branche « sonde présente » n'a jamais été exécutée. Elle est écrite, elle n'est pas +testée. + +**4. Une borne triphasée à phases déséquilibrées.** Les états existent, mais aucun helper de test +ne les pose indépendamment — d'où le pessimisme de `LM-1210-a` qui n'a jamais été exercé sur un +vrai déséquilibre. + +## La règle qui en sort + +> **Avant d'écrire un test qui exerce une ABSENCE, vérifier que le mock peut la produire.** +> Une assertion sur un cas inatteignable ne passe pas : elle **ne s'exécute pas**, ce qui est +> pire — elle compte comme verte. +> +> Le symptôme se reconnaît : **la contre-épreuve ne mord pas**. C'est le corollaire fin +> d'`AGENTS.md` appliqué au banc d'essai plutôt qu'au code. + +**Et la parade tient en une ligne de test** : quand on itère pour trouver des cas, **compter +combien on en a examinés**, et échouer si le compte est nul. C'est ce qui a rattrapé le test de +`command`, et ce qui garde celui de la mesurabilité. + +## Ce qu'il faudrait ajouter, par ordre d'utilité + +1. **Une classe pilotable SANS aucune puissance** — un relais sec. Débloque `source: none` sur + une charge saine, et sépare enfin « non mesurée » de « en panne ». +2. **Un compteur sans `totalEnergyConsumed`** en tant que classe déclarée, plutôt qu'un + `powerSwitch` détourné comme aujourd'hui. +3. **Une tension par phase réglable** indépendamment des puissances — pour exercer le correctif + amont ailleurs qu'en inversant la fonction. + +> Aucune de ces trois n'est urgente. Ce qui l'est, c'est de **savoir qu'elles manquent** : sans +> cette liste, chaque lot repaiera le même temps à découvrir qu'un cas est introuvable. diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index 745263d..cada151 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -221,6 +221,23 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana // plancher se DÉRIVE des paliers, et LoadConfig::isValid() refuse de le déclarer en // double (LM-302 : l'exigence réelle n'est jamais dans le schéma). loadItem.insert("o:minPowerW", enumValueName(Uint)); + // §10 / R6 — LA MESURABILITÉ DE L'AVANCEMENT, en LECTURE SEULE, et ICI plutôt que dans la + // télémétrie. `measurement` n'existe que pour les charges de `loads[]` ; or une charge qu'on + // DÉCLARE est précisément celle qui n'y est pas encore — pas arbitrée, ou créée à l'instant. + // L'écran serait muet au moment exact où il doit parler : celui de la saisie. + // + // Trois causes, et chacune appelle un geste DIFFÉRENT — même structure que les trois sources + // de DRAW_CAP : + // · `noMeter` aucun compteur rattaché → réparable en une manipulation + // · `meterWithoutEnergy` compteur sans énergie totale → à remplacer + // · `noSessionEnergy` borne sans sessionEnergy → rien à faire, c'est le matériel + // Sans la cause, l'avertissement de saisie ne peut dire que « ça ne marchera pas ». + // + // ET `meterThingId` NE PERMET PAS DE LE DÉDUIRE : un compteur peut publier `currentPower` + // sans `totalEnergyConsumed` — mesuré le 2026-09-01 sur l'ECS du banc. Un client qui + // déduirait la mesurabilité de la présence du champ se tromperait sur ce cas précis. + loadItem.insert("r:progressMeasurable", enumValueName(Bool)); + loadItem.insert("o:progressUnmeasurableCause", enumValueName(String)); loadItem.insert("o:needs", needsItem); loadItem.insert("o:relays", QVariantList() << relayItem); // relay-router (rév. 3) // LM-300 — charge utile du mécanisme sg-ready. estimatedPowerW porte son nom : c'est une @@ -643,14 +660,63 @@ JsonReply *NymeaEnergyJsonHandler::GetChargingSchedules(const QVariantMap ¶m return createReply(returns); } +bool NymeaEnergyJsonHandler::progressMesurable(const LoadConfig &c, QString &cause) const +{ + cause.clear(); + ThingManager *tm = m_smartChargingManager ? m_smartChargingManager->thingManager() : nullptr; + if (!tm) + return false; + + // 1. Une BORNE se mesure par sa session. Sans `sessionEnergy`, rien à faire : c'est le + // matériel, et aucune manipulation ne le changera. Le dire évite qu'un installateur + // cherche un réglage qui n'existe pas. + if (c.adapter() == QLatin1String("evcharger")) { + Thing *t = tm->findConfiguredThing(ThingId(c.id())); + if (t && t->thingClass().hasStateType("sessionEnergy")) + return true; + cause = QStringLiteral("noSessionEnergy"); + return false; + } + + // 2. Une charge en watts se mesure par un compteur DÉSIGNÉ. Aucun : réparable en une + // manipulation, et c'est la cause la plus fréquente à la saisie. + if (c.meterThingId().isEmpty()) { + cause = QStringLiteral("noMeter"); + return false; + } + + // 3. Le compteur existe-t-il, et compte-t-il l'ÉNERGIE ? Un compteur peut publier + // `currentPower` sans `totalEnergyConsumed` — mesuré le 2026-09-01 sur l'ECS du banc. + // C'est précisément pourquoi la présence de `meterThingId` ne permet pas de déduire la + // mesurabilité : la puissance ne suffit pas, il faut un cumul. + Thing *compteur = tm->findConfiguredThing(ThingId(c.meterThingId())); + if (!compteur) { + cause = QStringLiteral("noMeter"); + return false; + } + if (!compteur->thingClass().hasStateType("totalEnergyConsumed")) { + cause = QStringLiteral("meterWithoutEnergy"); + return false; + } + return true; +} + JsonReply *NymeaEnergyJsonHandler::GetLoadConfig(const QVariantMap ¶ms) { Q_UNUSED(params) QVariantMap returns; QVariantList list; if (m_loadConfigStore) { - foreach (const LoadConfig &c, m_loadConfigStore->configs()) - list << pack(c); // inclut les charges enabled==false (éditables par l'app) + foreach (const LoadConfig &c, m_loadConfigStore->configs()) { + QVariantMap m = pack(c).toMap(); // inclut enabled==false (éditables par l'app) + // R6 — la MESURABILITÉ, calculée ici parce que c'est ici que l'app en a besoin : + // au moment de la SAISIE, avant que la charge existe dans loads[]. + QString cause; + m.insert(QStringLiteral("progressMeasurable"), progressMesurable(c, cause)); + if (!cause.isEmpty()) + m.insert(QStringLiteral("progressUnmeasurableCause"), cause); + list << m; + } } returns.insert("loadConfigs", list); return createReply(returns); diff --git a/energyplugin/nymeaenergyjsonhandler.h b/energyplugin/nymeaenergyjsonhandler.h index 8e03d94..6b82337 100644 --- a/energyplugin/nymeaenergyjsonhandler.h +++ b/energyplugin/nymeaenergyjsonhandler.h @@ -35,6 +35,7 @@ class SmartChargingManager; class SpotMarketManager; class LoadConfigStore; +class LoadConfig; class EnergyManager; class NymeaEnergyJsonHandler : public JsonHandler @@ -68,6 +69,26 @@ public: Q_INVOKABLE JsonReply *GetChargingSchedules(const QVariantMap ¶ms); Q_INVOKABLE JsonReply *GetLoadConfig(const QVariantMap ¶ms); + + /*! + * \brief L'avancement d'une obligation est-il MESURABLE pour cette charge ? + * \param c Configuration de la charge. \param[out] cause Code opaque si non mesurable. + * \return Vrai si un cumul d'énergie existe pour juger l'avancement. + * + * \par Pourquoi ce signal vit dans GetLoadConfig et non dans la télémétrie + * \c measurement n'existe que pour les charges de \c loads[] ; or une charge qu'on DÉCLARE + * est précisément celle qui n'y est pas encore — pas arbitrée, ou créée à l'instant. + * L'écran serait muet au moment exact où il doit parler : celui de la saisie. + * + * \par Trois causes, trois gestes + * \c noMeter (réparable en une manipulation), \c meterWithoutEnergy (compteur à remplacer), + * \c noSessionEnergy (rien à faire, c'est le matériel). Sans la cause, l'avertissement ne + * peut dire que « ça ne marchera pas ». + * + * \note \c meterThingId ne permet PAS de le déduire : un compteur peut publier + * \c currentPower sans \c totalEnergyConsumed (mesuré sur l'ECS du banc). + */ + bool progressMesurable(const LoadConfig &c, QString &cause) const; Q_INVOKABLE JsonReply *SetLoadConfig(const QVariantMap ¶ms); // [Phase 2] Ratios canoniques (autoconsommation / autonomie), % [0,100]. diff --git a/energyplugin/smartchargingmanager.h b/energyplugin/smartchargingmanager.h index dccc2a2..8c90e9c 100644 --- a/energyplugin/smartchargingmanager.h +++ b/energyplugin/smartchargingmanager.h @@ -54,6 +54,12 @@ class SmartChargingManager : public QObject public: explicit SmartChargingManager(EnergyManager *energyManager, ThingManager *thingManager, SpotMarketManager *spotMarketManager, EnergyManagerConfiguration *configuration, QObject *parent = nullptr); + //! \brief [ETM] Gestionnaire de Things, pour les consommateurs qui doivent interroger une + //! ThingClass sans passer par NymeaCore (frontière RPC notamment). + //! \return Pointeur non-propriétaire ; jamais nul après construction. + ThingManager *thingManager() const { return m_thingManager; } + + uint phasePowerLimit() const; void setPhasePowerLimit(uint phasePowerLimit); diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 9f6f354..2e240a6 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -7292,6 +7292,96 @@ void Simulation::testRecreditIsALoanAgainstObedience() #endif } +/*! + * \brief [R6] La MESURABILITÉ de l'avancement se déclare à la SAISIE, avec sa cause. + * + * \par Pourquoi ce signal ne peut pas vivre dans la télémétrie + * `measurement` n'existe que pour les charges de `loads[]`. Or une charge qu'on **déclare** est + * précisément celle qui n'y est pas encore — pas arbitrée, ou créée à l'instant. L'écran serait + * muet **au moment exact où il doit parler** : celui où l'installateur saisit une obligation qui + * achètera à l'aveugle tous les jours si rien ne la mesure. + * + * \par Trois causes, trois gestes + * `noMeter` se répare en une manipulation, `meterWithoutEnergy` demande de remplacer le + * compteur, `noSessionEnergy` ne se répare pas — c'est le matériel. Sans la cause, l'avertissement + * ne peut dire que « ça ne marchera pas ». + * + * \par Et `meterThingId` ne permet pas de le déduire + * Un compteur peut publier `currentPower` **sans** `totalEnergyConsumed` : mesuré le 2026-09-01 + * sur l'ECS du banc, et reproduit ici avec un `powerSwitch` désigné comme compteur. Un client qui + * déduirait la mesurabilité de la présence du champ se tromperait sur ce cas précis. + */ +void Simulation::testProgressMeasurabilityIsDeclaredAtConfigTime() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-mes.json"; + QFile::remove(cfgPath); + qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + + ThingManager *tm = NymeaCore::instance()->thingManager(); + QUuid compteur = addMeter(26702); // meter : porte totalEnergyConsumed + QUuid r1 = addPowerSwitch(1000, 26703); // powerSwitch : power + currentPower SEULEMENT + QUuid r2 = addPowerSwitch(1000, 26704); + QUuid r3 = addPowerSwitch(1000, 26705); + QVERIFY(tm->findConfiguredThing(compteur) && tm->findConfiguredThing(r1)); + + // Écrit par la FRONTIÈRE RPC, comme l'app le ferait — c'est le magasin que le handler + // connaît. Un store créé à côté ne serait pas celui qu'il interroge : constaté ici même, + // GetLoadConfig rendait une liste VIDE et le test passait sans rien vérifier. + int rang = 0; + QVariantList aPoser; + auto relais = [&](const QString &id, const QUuid &thing, const QString &meter) { + ++rang; // rangs UNIQUES : le store refuse deux charges de même rang + QVariantMap m{{"id", id}, {"label", id}, {"adapter", "relay-router"}, {"mode", "fixed"}, + {"priority", rang}, {"enabled", true}, {"domain", "ecs"}, + {"relays", QVariantList() << QVariantMap{{"thingId", thing.toString()}, + {"powerW", 1000}}}}; + if (!meter.isEmpty()) m.insert("meterThingId", meter); + return m; + }; + aPoser << relais("sans-compteur", r1, QString()); + aPoser << relais("compteur-ok", r2, compteur.toString()); + aPoser << relais("compteur-sans-energie", r3, r1.toString()); + QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", + QVariantMap({{"loadConfigs", aPoser}})).toMap() + .value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + + QVariantList l = injectAndWait("NymeaEnergy.GetLoadConfig").toMap() + .value("params").toMap().value("loadConfigs").toList(); + QHash parId; + for (const QVariant &v : l) parId.insert(v.toMap().value("id").toString(), v.toMap()); + + // Garde contre l'assertion VACANTE : sans les trois entrées, tout ce qui suit passerait + // sans rien vérifier. Le piège a déjà été rencontré deux fois dans ce lot. + QCOMPARE(parId.count(), 3); + + // 1. AUCUN COMPTEUR — réparable en une manipulation, et c'est la cause la plus fréquente. + QCOMPARE(parId.value("sans-compteur").value("progressMeasurable").toBool(), false); + QCOMPARE(parId.value("sans-compteur").value("progressUnmeasurableCause").toString(), + QString("noMeter")); + + // 2. COMPTEUR AVEC ÉNERGIE — mesurable, et AUCUNE cause publiée : il n'y a rien à réparer. + QCOMPARE(parId.value("compteur-ok").value("progressMeasurable").toBool(), true); + QVERIFY2(!parId.value("compteur-ok").contains("progressUnmeasurableCause"), + "une charge mesurable ne porte pas de cause — l'absence est l'information"); + + // 3. COMPTEUR SANS ÉNERGIE — le cas qui interdit de déduire la mesurabilité de la seule + // présence de `meterThingId`. Le champ est là, le compteur existe, et pourtant rien ne + // cumule : la puissance ne suffit pas, il faut une énergie. + QCOMPARE(parId.value("compteur-sans-energie").value("progressMeasurable").toBool(), false); + QCOMPARE(parId.value("compteur-sans-energie").value("progressUnmeasurableCause").toString(), + QString("meterWithoutEnergy")); + QVERIFY2(!parId.value("compteur-sans-energie").value("meterThingId").toString().isEmpty(), + "le compteur EST désigné — c'est bien la déduction par meterThingId qui échoue"); +#endif +} + void Simulation::testCountsSumToTarget() { #ifndef ETM_ARBITRATOR diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index bb217d2..9eb2d73 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -198,6 +198,7 @@ private slots: void testEcoPassBuysAndSaysTheTrajectory(); void testEcoFloorMissedSaysWhatWasLostAndWhy(); void testCommandDivergenceIsPublishedNotDiagnosed(); + void testProgressMeasurabilityIsDeclaredAtConfigTime(); void testRecreditIsALoanAgainstObedience(); void testARefusedStateIsDistinguishableFromAnApplied(); void testL4ShedsAPacAndSaysItForcedTheLock();