From e392c1c7b8ca361623c345bbf1eee5a5968445bb Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Fri, 28 Aug 2026 14:49:28 +0200 Subject: [PATCH] =?UTF-8?q?feat(=C2=A711):=20la=20mesure=20d'une=20charge?= =?UTF-8?q?=20se=20lit=20sur=20l'appareil,=20et=20publie=20sa=20source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LM-1105/LM-1106 implantés. measuredW disparaît, sans alias. ILoadAdapter::deviceMeasurement() — PURE VIRTUELLE, comme runtimeView() et pour la même raison : un mécanisme ajouté qui hériterait d'un « non mesurable » par défaut sortirait de la mesure sans bruit, et son écran afficherait « pas mesurable » avec l'autorité d'un constat. Le compilateur pose la question. Le type porte une CAPACITÉ, pas une valeur nulle : `available` répond à « cette charge est-elle mesurable », question posée à la ThingClass, jamais à la valeur du moment. Un routeur au palier 0 mesure 0 W et reste mesurable ; sans ce booléen les deux seraient indistinguables. attachMeasurements() résout meter → device → none. Le compteur explicite garde la priorité, et pas d'abord parce qu'il mesure mieux : un réglage sans effet est pire que pas de réglage. La priorité porte sur la SOURCE, pas sur la valeur — compteur déclaré injoignable, on retombe sur l'appareil, parce que se taire perdrait une mesure qu'on a et que le silence se lirait « non mesurable ». Sous `none`, powerW est OMIS. La puissance nominale reste lisible dans mechanism.*, qui dit d'où elle vient ; la faire passer pour une mesure produirait un écart commandé↔mesuré identiquement nul à tous les cycles — un « tout concorde » permanent, corollaire LM-104. DEUX AFFINEMENTS IMPOSÉS PAR L'IMPLANTATION, tous deux de modèle : LM-1105-a — une mesure PARTIELLE n'est pas une mesure. Là où plusieurs Things portent la charge, il faut qu'ils la portent TOUS : trois relais dont un seul mesure annonceraient 500 W là où 1 500 W coulent, et l'écart enverrait chercher une panne inexistante. À ne pas confondre avec LoadTelemetry::currentPowerW, qui accepte « au moins un » et retombe sur le nominal — celui-là est le juge runtime du budget et doit rendre un nombre à chaque cycle. Les fusionner rouvrirait la correction B. LM-1105-b — la règle présuppose que le Thing piloté est TRAVERSÉ par la puissance. Un relais de relay-router commute la charge, donc sa puissance EST celle de la charge ; un contact SG-Ready est un contact de SIGNAL — la PAC est alimentée ailleurs et un contact qui mesurerait quelque chose mesurerait sa propre bobine. sg-ready est donc `none` par CONSTRUCTION, même sur des relais qui mesurent — c'est le cas du banc — et c'est le seul mécanisme pour lequel meterThingId reste la seule voie. RelayRouter : la capacité se juge sur la ThingClass de TOUS les relais déclarés, la valeur se lit sur les relais FERMÉS. Jugée sur les relais actifs, la source vaudrait `device` au palier 1 et `none` au palier 0 — un bloc de mesure qui apparaît et disparaît au rythme du thermostat. VÉRIFIÉ ÉCHOUANT ainsi. testMeasurementSourceIsPublished : les trois sources sans une ligne de config de compteur, le palier 0 qui ne fait pas basculer la source, la PAC en `none` alors que ses contacts mesurent, la disparition de measuredW, puis les deux règles de priorité — le compteur explicite l'emporte (1 234 contre 2 900), un compteur injoignable fait retomber sur l'appareil. Ne touche PAS ce que l'arbitre lit : LM-1101 tient, LoadTelemetry::currentPowerW est inchangé, et le recrédit anti-clignotement n'a jamais transité par meterThingId. amd64 0 erreur / 0 avertissement. Suites, un processus par test : simulation 30/30, charging 16/16, loadmodel 18/18, spotmarket 7/7. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p --- AGENTS.md | 13 +- INTERFACE.md | 24 +++ docs/BRIEF_depuis_plugin.md | 21 ++- .../etm/adapters/etmvariableloadadapter.cpp | 16 ++ .../etm/adapters/etmvariableloadadapter.h | 12 ++ energyplugin/etm/adapters/evadapter.cpp | 12 ++ energyplugin/etm/adapters/evadapter.h | 10 ++ energyplugin/etm/adapters/iloadadapter.h | 55 ++++++ energyplugin/etm/adapters/relayrouter.cpp | 31 ++++ energyplugin/etm/adapters/relayrouter.h | 20 +++ energyplugin/etm/adapters/sgreadyadapter.h | 21 +++ energyplugin/etm/energyarbitrator.cpp | 64 +++++-- energyplugin/nymeaenergyjsonhandler.cpp | 16 +- specs/spec_loadmodel.md | 34 ++++ tests/auto/simulation/simulation.cpp | 156 ++++++++++++++++++ tests/auto/simulation/simulation.h | 7 + 16 files changed, 496 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 76ea125..c8f0240 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -209,7 +209,7 @@ la somme des charges mesurées** (3 250 W contre 3 980 W pour la seule V2C, plus Même famille que l'aveuglement documenté aux résistances ECS. Ce n'est pas un problème d'arbitrage — câblage ou vue SunSpec — mais un budget juste suppose un compteur qui voit tout. -**DÉCIDÉ, PAS ENCORE ÉCRIT (2026-08-28)** — **la mesure par charge se lit sur l'appareil +**FAIT (2026-08-28, `1.15.2+etm26`)** — **la mesure par charge se lit sur l'appareil piloté**, `specs/spec_loadmodel.md` **LM-1105/LM-1106**, sur un constat d'usage de l'agent app. `meterThingId` était la SEULE source : il fallait désigner une borne comme son propre compteur pour qu'elle publie ce qu'elle mesure déjà. Il reste **prioritaire** — un réglage sans effet est @@ -220,6 +220,17 @@ Ce que la vérification a trouvé, et qui décide de la forme : `telemetry().cur (`relay-router`, dont le booléen `metered` est calculé puis jeté), déclaré (`sg-ready`, délibérément, invariant 8). D'où `measurement {source, o:powerW}` en remplacement de `measuredW`, avec `source: "none"` comme état positif. **Contrat déplacé** : `INTERFACE.md`. +`ILoadAdapter::deviceMeasurement()` est **pure virtuelle**, comme `runtimeView()` : un mécanisme +ajouté qui hériterait d'un « non mesurable » par défaut sortirait de la mesure sans bruit, et son +écran afficherait « pas mesurable » avec l'autorité d'un constat. + +Deux affinements imposés par l'implantation, tous deux de modèle : **LM-1105-a** — une mesure +partielle n'est pas une mesure (relais mixtes → `none`) ; **LM-1105-b** — la règle présuppose que +le Thing piloté est TRAVERSÉ par la puissance, ce qu'un contact SG-Ready n'est pas : `sg-ready` +est `none` par construction, et c'est le seul mécanisme où `meterThingId` reste la seule voie. +Éprouvé par `testMeasurementSourceIsPublished` (trois sources + les deux règles de priorité), +la règle « capacité jugée sur la classe » **vérifiée échouant** quand on la juge sur les relais +actifs. > Ce lot ne touche PAS ce que l'arbitre lit : LM-1101 tient, et le recrédit anti-clignotement > continue de lire `LoadContext::telemetry.currentPowerW`, qui vient de l'adaptateur et n'a diff --git a/INTERFACE.md b/INTERFACE.md index 3608d85..4eef59b 100644 --- a/INTERFACE.md +++ b/INTERFACE.md @@ -676,6 +676,22 @@ le **libellé de la charge n'y figure jamais** (le client le résout via `GetLoa | `device` | L'appareil piloté publie lui-même sa puissance — borne, `etmvariableload`, relais mesurant | | `none` | **Rien ne mesure cette charge.** `powerW` est alors ABSENT, jamais nul | +**Quand vaut-il `none` ?** Trois cas, et le premier surprend : + +- **Toujours pour `sg-ready`**, même montée sur des relais qui mesurent. Un contact SG-Ready est + un contact de **signal** : la PAC est alimentée ailleurs, son courant ne traverse jamais K1/K2, + et un contact qui mesurerait quelque chose mesurerait **sa propre bobine**. C'est le seul + mécanisme pour lequel `meterThingId` reste la seule voie vers une mesure. +- **Topologie de relais MIXTE** — un relais mesurant, deux nus. Une somme partielle présentée + comme la mesure de la charge annoncerait 500 W là où 1 500 W coulent, et l'écart enverrait + chercher une panne inexistante. Une mesure partielle n'est pas une mesure. +- **Thing sans l'état de puissance**, tout simplement. + +> **`source` ne dépend ni de la valeur ni de l'instant.** Un `relay-router` au palier 0 n'a aucun +> relais fermé et publie `{source: "device", powerW: 0}` : **0 W est une mesure**, pas une +> absence. La capacité se juge sur la ThingClass de **tous** les Things déclarés — sinon le bloc +> de mesure apparaîtrait et disparaîtrait au rythme du thermostat. + Publiée **à côté** de `allocatedW`, jamais fondue dedans : `allocatedW` est ce que le waterfall a **alloué**, `measurement.powerW` ce qui a été **vu**. Les confondre rendrait la somme des allocations irréconciliable avec `budget.allocatedW`. @@ -692,6 +708,14 @@ allocations irréconciliable avec `budget.allocatedW`. > bloc de mesure au lieu d'afficher un vide. Sans lui, « pas mesurable » et « compteur en > panne » se ressemblent. +> **L'asymétrie `device` / `meter`, et pourquoi elle vaut d'être dite à l'écran.** Sous +> `device`, une **concordance** ne prouve pas grand-chose : plusieurs wallbox renvoient leur +> consigne en guise de puissance, et « commandé == mesuré » n'y est alors qu'une tautologie. +> Sous `meter`, une concordance est une **preuve** — commande et mesure viennent de deux boîtes +> différentes. Autrement dit : **`device` détecte la désobéissance, `meter` est le seul à +> pouvoir détecter un mensonge.** C'est ce qui justifie de laisser `meterThingId` **disponible** +> sur une charge qui publie déjà sa puissance : masqué par défaut, jamais interdit. + > **Migration depuis `measuredW`.** L'ancienne clé disparaît ; elle ne pouvait pas exprimer > trois états, et la doubler d'un second champ aurait obligé le client à joindre deux clés pour > lire une seule chose. `measuredW: X` devient `measurement: {source: …, powerW: X}` ; son diff --git a/docs/BRIEF_depuis_plugin.md b/docs/BRIEF_depuis_plugin.md index 06b1c02..221e9b9 100644 --- a/docs/BRIEF_depuis_plugin.md +++ b/docs/BRIEF_depuis_plugin.md @@ -270,8 +270,8 @@ Et `targetPercentage = 80` observé sur la V2C **confirme sur machine** le défa ## 7. Le compteur par charge : vous avez raison, et le modèle bouge -*(Votre constat d'usage du 2026-08-28. Tranché : `spec_loadmodel.md` **LM-1105** et -**LM-1106**. Contrat : `INTERFACE.md`. **Pas encore implémenté** — rien à consommer aujourd'hui.)* +*(Votre constat d'usage du 2026-08-28. Tranché : `spec_loadmodel.md` **LM-1105/1106**. +Contrat : `INTERFACE.md`. **LIVRÉ** en `1.15.2+etm26` — `measuredW` n'existe plus.)* ### Le constat est retenu tel quel @@ -326,6 +326,23 @@ en panne », que rien ne séparait. > `meterThingId` reste **autorisé** sur une borne qui publie déjà : à masquer par défaut, pas à > interdire. +### Deux choses trouvées en implantant, qui changent ce que vous afficherez + +**`sg-ready` est `none` par CONSTRUCTION, pas par accident de matériel.** Un contact SG-Ready est +un contact de **signal** : la PAC est alimentée ailleurs, son courant ne traverse jamais K1/K2. +Même montée sur des relais qui mesurent — c'est le cas de notre banc — la charge reste non +mesurable par son organe de commande. Pour une PAC, `meterThingId` n'est pas un réglage avancé : +c'est la **seule** voie vers une mesure, et l'écran devrait le proposer, pas le masquer. + +**Une topologie de relais MIXTE est `none` aussi.** Un relais mesurant et deux nus donneraient +une somme partielle — 500 W annoncés là où 1 500 W coulent — et l'écart vous ferait afficher une +panne qui n'existe pas. Une mesure partielle n'est pas une mesure. + +> Et un point d'affichage qui vous évitera un faux bug : **`{source: "device", powerW: 0}` est +> une mesure**, pas une absence. Un routeur de relais au palier 0 mesure réellement 0 W et reste +> mesurable — la source ne bascule pas vers `none` quand la charge s'éteint. Si elle basculait, +> votre bloc de mesure apparaîtrait et disparaîtrait au rythme du thermostat. + ### Votre coût, et il est d'un écran `measuredW: X` → `measurement: {source, powerW: X}` ; son **absence** → `source: "none"`. diff --git a/energyplugin/etm/adapters/etmvariableloadadapter.cpp b/energyplugin/etm/adapters/etmvariableloadadapter.cpp index e9b9d02..7ce086d 100644 --- a/energyplugin/etm/adapters/etmvariableloadadapter.cpp +++ b/energyplugin/etm/adapters/etmvariableloadadapter.cpp @@ -173,6 +173,22 @@ void EtmVariableLoadAdapter::applySafeState(const QDateTime &now) // ---- privé --------------------------------------------------------------- +LoadMeasurement EtmVariableLoadAdapter::deviceMeasurement() const +{ + LoadMeasurement m; + Thing *thing = m_thingManager->findConfiguredThing(ThingId(m_id)); + if (!thing) + return m; // Thing absent : rien ne mesure. THING_MISSING le dit déjà par ailleurs. + + // Question posée à la CLASSE : une charge à consigne 0 mesure 0 W et reste mesurable. + if (thing->thingClass().stateTypes().findByName("currentPowerW").id().isNull()) + return m; + + m.available = true; + m.powerW = thing->stateValue("currentPowerW").toDouble(); + return m; +} + double EtmVariableLoadAdapter::readCurrentPowerW() const { Thing *thing = m_thingManager->findConfiguredThing(ThingId(m_id)); diff --git a/energyplugin/etm/adapters/etmvariableloadadapter.h b/energyplugin/etm/adapters/etmvariableloadadapter.h index 9782241..c62f506 100644 --- a/energyplugin/etm/adapters/etmvariableloadadapter.h +++ b/energyplugin/etm/adapters/etmvariableloadadapter.h @@ -137,6 +137,18 @@ public: */ LoadRuntimeView runtimeView(const QDateTime &now) const override; + /*! + * \brief Mesure propre de la charge pilotée (§11 / LM-1105). + * \return La puissance que le Thing rapporte lui-même, quand sa ThingClass porte + * \c currentPowerW ; « non mesurable » sinon. + * \note **Ne retombe PAS sur la consigne en vol**, contrairement à \c telemetry(). Celle-ci + * doit rendre un nombre à chaque cycle — le recrédit anti-clignotement en dépend — et + * annonce donc la plus haute consigne possible quand rien ne mesure (ECS-414). Ici, une + * consigne n'est pas une mesure : la publier comme telle produirait un écart + * commandé↔mesuré identiquement nul (LM-1106). + */ + LoadMeasurement deviceMeasurement() const override; + //! \brief État sûr : consigne **0 W**, en \c force = true (ECS-413). \param now Temps de cycle. void applySafeState(const QDateTime &now) override; diff --git a/energyplugin/etm/adapters/evadapter.cpp b/energyplugin/etm/adapters/evadapter.cpp index dd33836..458ec1a 100644 --- a/energyplugin/etm/adapters/evadapter.cpp +++ b/energyplugin/etm/adapters/evadapter.cpp @@ -129,6 +129,18 @@ LoadRuntimeView EvAdapter::runtimeView(const QDateTime &now) const return v; } +LoadMeasurement EvAdapter::deviceMeasurement() const +{ + LoadMeasurement m; + // Question posée à la CLASSE, pas à la valeur : une borne à l'arrêt mesure 0 W et reste + // mesurable. Confondre les deux ferait basculer la source publiée à chaque démarrage. + if (!m_charger->thing()->thingClass().stateTypes().findByName("currentPower").id().isNull()) { + m.available = true; + m.powerW = m_charger->currentPower(); + } + return m; +} + LoadAction EvAdapter::applyAction(const LoadAction &action, const QDateTime &now) { if (action.kind != LoadAction::Setpoint) diff --git a/energyplugin/etm/adapters/evadapter.h b/energyplugin/etm/adapters/evadapter.h index 20f851e..267c2ca 100644 --- a/energyplugin/etm/adapters/evadapter.h +++ b/energyplugin/etm/adapters/evadapter.h @@ -100,6 +100,16 @@ public: */ LoadRuntimeView runtimeView(const QDateTime &now) const override; + /*! + * \brief Mesure propre de la borne (§11 / LM-1105). + * \return La puissance que la borne rapporte elle-même, quand sa ThingClass porte l'état. + * \note Une borne est **traversée** par la puissance qu'elle délivre : sa mesure est bien + * celle de la charge, et non celle d'un organe de commande. C'est le cas qui a motivé + * LM-1105 — il fallait jusqu'ici désigner la borne comme son propre \c meterThingId + * pour publier une valeur qu'elle donne d'elle-même. + */ + LoadMeasurement deviceMeasurement() const override; + //! \brief Sans effet : l'EV n'est pas construit depuis \c LoadConfig et ne peut donc pas //! être « désactivé » par ce chemin. \param now Ignoré. void applySafeState(const QDateTime &now) override { Q_UNUSED(now) } diff --git a/energyplugin/etm/adapters/iloadadapter.h b/energyplugin/etm/adapters/iloadadapter.h index 382b878..1e54098 100644 --- a/energyplugin/etm/adapters/iloadadapter.h +++ b/energyplugin/etm/adapters/iloadadapter.h @@ -17,6 +17,28 @@ struct LoadTelemetry { QDateTime lastActionAt; //!< Dernier instant où applyAction() a produit un effet. }; +/*! + * \brief Mesure PROPRE de l'appareil piloté — §11, LM-1105/LM-1106. + * + * Distincte de \c LoadTelemetry::currentPowerW, et la distinction est tout l'objet du type. + * Ce champ-là est le **juge runtime du budget** : il vaut une mesure quand il en existe une et + * la puissance **commandée ou déclarée** sinon, parce que le recrédit anti-clignotement a + * besoin d'un nombre à chaque cycle. Celui-ci ne vaut **que** ce qu'un appareil a réellement + * mesuré, ou rien. + * + * \par Pourquoi une CAPACITÉ et non une valeur nulle + * \c available répond à « cette charge est-elle mesurable ? », question posée à la + * \c ThingClass, jamais à la valeur du moment. Un routeur au palier 0 mesure 0 W et reste + * mesurable ; sans ce booléen, les deux seraient indistinguables et la source publiée + * basculerait d'un cycle à l'autre sur une installation dont rien n'a changé. + */ +struct LoadMeasurement { + //! Vrai si les Things pilotés portent l'état de puissance — CAPACITÉ, pas valeur. + bool available = false; + //! Puissance réellement mesurée (W) ; sans objet quand \c available est faux. + double powerW = 0; +}; + /*! * \brief Vue runtime d'une charge destinée à la FRONTIÈRE DE TÉLÉMÉTRIE (RPC), et à elle seule. * @@ -173,6 +195,39 @@ public: */ virtual LoadRuntimeView runtimeView(const QDateTime &now) const = 0; + /*! + * \brief Cette charge est-elle mesurée par les appareils qu'elle pilote, et combien ? + * + * §11 — **LM-1105** : le moteur lit l'appareil qu'il pilote plutôt que d'exiger qu'on le + * désigne comme son propre compteur dans \c meterThingId. **LM-1106** : ce qui est publié + * est un couple {valeur, source}, et c'est cette méthode qui répond pour la source + * \c "device". + * + * \return \c available faux = **rien ne mesure cette charge** (\c source: "none" à la + * publication) ; vrai = \c powerW est une mesure réelle. + * + * \note **La question porte sur la ThingClass, jamais sur la valeur ni sur l'instant.** Un + * \c RelayRouter au palier 0 n'a aucun relais fermé : interroger les relais ACTIFS + * ferait osciller la source publiée d'un cycle à l'autre. Elle se pose sur TOUS les + * Things déclarés de la charge. + * \note **Et elle présuppose que le Thing piloté est TRAVERSÉ par la puissance.** Un relais + * de \c RelayRouter commute la charge, donc sa puissance EST celle de la charge ; un + * contact SG-Ready est un contact de SIGNAL — la PAC est alimentée ailleurs, et son + * courant ne passe jamais par lui. Un contact qui mesurerait quelque chose mesurerait sa + * propre bobine. \c SgReadyAdapter répond donc toujours « non mesurable », et c'est le + * seul mécanisme pour lequel \c meterThingId reste la seule voie. + * \note **Une mesure PARTIELLE n'est pas une mesure.** Là où plusieurs Things portent la + * charge, il faut qu'ils la portent TOUS : sommer les seuls relais mesurants + * publierait une puissance sous-évaluée, et l'écart commandé↔mesuré enverrait chercher + * une panne qui n'existe pas (LM-1102). + * \note **Pure virtuelle à dessein**, comme \c runtimeView() et pour la même raison : un + * mécanisme ajouté qui hériterait d'un défaut « non mesurable » sortirait de la mesure + * SANS BRUIT, et son écran afficherait « pas mesurable » avec l'autorité d'un constat. + * Le compilateur pose la question à chaque nouvel adaptateur. + * \note N'entre JAMAIS dans une décision : l'arbitre ne la lit que pour publier (règle 2). + */ + virtual LoadMeasurement deviceMeasurement() const = 0; + /*! * \brief Amène le matériel dans l'ÉTAT SÛR de cet adaptateur (ECS-413). * diff --git a/energyplugin/etm/adapters/relayrouter.cpp b/energyplugin/etm/adapters/relayrouter.cpp index dda1580..701f852 100644 --- a/energyplugin/etm/adapters/relayrouter.cpp +++ b/energyplugin/etm/adapters/relayrouter.cpp @@ -515,6 +515,37 @@ void RelayRouter::settleTransition() } } +LoadMeasurement RelayRouter::deviceMeasurement() const +{ + LoadMeasurement m; + if (m_relayNominalW.isEmpty()) + return m; + + // CAPACITÉ — la question porte sur la ThingClass de TOUS les relais déclarés, jamais sur + // ceux qui sont fermés maintenant : au palier 0 il n'y en a aucun, et juger là-dessus + // ferait basculer la source publiée à chaque extinction (LM-1106). + // + // TOUS, et pas « au moins un » : une topologie mixte donnerait une somme sous-évaluée + // présentée comme la mesure de la charge — un écart commandé↔mesuré qui envoie chercher + // une panne inexistante (LM-1102). Une mesure partielle n'est pas une mesure. + for (auto it = m_relayNominalW.constBegin(); it != m_relayNominalW.constEnd(); ++it) { + Thing *relay = m_thingManager->findConfiguredThing(ThingId(it.key())); + if (!relay || relay->thingClass().stateTypes().findByName("currentPower").id().isNull()) + return m; // un seul relais nu, et la charge n'est pas mesurable + } + + // VALEUR — sur les relais FERMÉS. Un thermostat qui a ouvert donne 0 W relais fermé, et + // c'est une mesure juste : la charge ne consomme réellement rien. + m.available = true; + const QList fermes = m_currentStage < m_relayMapping.size() + ? m_relayMapping.at(m_currentStage) + : QList(); + for (const QString &thingId : fermes) + if (Thing *relay = m_thingManager->findConfiguredThing(ThingId(thingId))) + m.powerW += relay->stateValue("currentPower").toDouble(); + return m; +} + LoadRuntimeView RelayRouter::runtimeView(const QDateTime &now) const { LoadRuntimeView v; diff --git a/energyplugin/etm/adapters/relayrouter.h b/energyplugin/etm/adapters/relayrouter.h index 68d535e..d4d2185 100644 --- a/energyplugin/etm/adapters/relayrouter.h +++ b/energyplugin/etm/adapters/relayrouter.h @@ -175,6 +175,26 @@ public: */ LoadRuntimeView runtimeView(const QDateTime &now) const override; + /*! + * \brief Mesure propre des relais qui portent la charge (§11 / LM-1105). + * + * \return Somme des \c currentPower des relais FERMÉS au palier courant, à condition que + * **tous** les relais déclarés portent l'état ; « non mesurable » sinon. + * + * \note **La capacité se juge sur TOUS les relais déclarés, la valeur se lit sur les relais + * FERMÉS.** Juger la capacité sur les relais fermés ferait répondre « non mesurable » au + * palier 0, où aucun ne l'est — la source publiée basculerait à chaque extinction, sur + * une installation dont rien n'a changé (LM-1106). + * \note **Tous, et pas au moins un.** Une topologie mixte — un relais mesurant, deux nus — + * produirait une somme SOUS-ÉVALUÉE présentée comme la mesure de la charge, et l'écart + * commandé↔mesuré enverrait chercher une panne qui n'existe pas (LM-1102). Une mesure + * partielle n'est pas une mesure ; c'est le cas où \c meterThingId garde tout son sens. + * \note Distincte de \c telemetry(), qui accepte « au moins un » et retombe sur le palier + * NOMINAL sinon : elle doit rendre un nombre à chaque cycle pour le recrédit. Ici, un + * nominal n'est pas une mesure. + */ + LoadMeasurement deviceMeasurement() const override; + //! \brief État sûr du routeur : TOUS relais ouverts (ECS-413). //! \param now Temps de cycle — l'action passe en \c force = true, verrous bypassés. void applySafeState(const QDateTime &now) override; diff --git a/energyplugin/etm/adapters/sgreadyadapter.h b/energyplugin/etm/adapters/sgreadyadapter.h index 941cd31..a9afc46 100644 --- a/energyplugin/etm/adapters/sgreadyadapter.h +++ b/energyplugin/etm/adapters/sgreadyadapter.h @@ -120,6 +120,27 @@ public: */ LoadRuntimeView runtimeView(const QDateTime &now) const override; + /*! + * \brief Toujours « non mesurable » — et c'est une propriété du MÉCANISME (§11 / LM-1105). + * + * \return \c available toujours faux. + * + * \note Ce n'est pas un « pas encore » : un contact SG-Ready est un contact de **SIGNAL**. + * La PAC est alimentée ailleurs et son courant ne traverse jamais K1/K2 — un contact qui + * mesurerait quelque chose mesurerait **sa propre bobine**. La règle générale + * « le moteur lit l'appareil qu'il pilote » présuppose que l'appareil piloté est + * TRAVERSÉ par la puissance de la charge ; ici il ne l'est pas. + * \note C'est donc le seul mécanisme pour lequel \c meterThingId reste la **seule** voie + * vers une mesure, et cela doit se lire à l'écran plutôt que se deviner : d'où + * \c source: "none" publié, plutôt qu'une clé absente (LM-1106). + * \warning Ne PAS publier \c m_estimatedPowerW ici. C'est une puissance **déclarée** — + * le juge du recrédit budget, invariant 8 — et la faire passer pour une mesure + * produirait un écart commandé↔mesuré identiquement nul à tous les cycles : un « tout + * concorde » permanent, corollaire LM-104. + */ + LoadMeasurement deviceMeasurement() const override + { return LoadMeasurement{}; } + /*! * \brief État sûr de la PAC : **état 2** (normal, mains off) — JAMAIS l'état 1 (blocage). * \param now Temps de cycle ; l'action passe en \c force = true (bypass minStateHold). diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index f154a1e..aec8611 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -1072,26 +1072,68 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const void EnergyArbitrator::attachMeasurements(QVariantMap &entry, const QString &loadId) const { - if (!m_tm || !m_loadConfigStore) + if (!m_tm) return; LoadConfig cfg; - bool trouvee = false; - for (const LoadConfig &c : m_loadConfigStore->configs()) { - if (c.id() == loadId) { cfg = c; trouvee = true; break; } + for (const LoadConfig &c : m_loadConfigStore ? m_loadConfigStore->configs() : LoadConfigs()) { + if (c.id() == loadId) { cfg = c; break; } } - if (!trouvee) - return; - // Compteur — VÉRIFICATION seulement (LM-1101). Publiée à côté de allocatedW, jamais - // fondue dedans : la somme des allocations doit rester réconciliable avec - // budget.allocatedW, et une mesure en retard sur la commande la rendrait fausse. + // --- MESURE : {valeur, source}, jamais un nombre seul (§11 / LM-1106) ----------------- + // + // Trois régimes, et deux d'entre eux étaient publiés sous le même nom jusqu'ici. Le + // client ne pouvait pas distinguer « un compteur a été posé exprès » de « l'appareil se + // déclare », alors que l'écart commandé↔mesuré ne conclut pas la même chose dans les deux + // cas : sous "device" il dit que la charge n'obéit pas ; sous "meter" il ouvre trois + // hypothèses — la charge, le compteur, ou le câblage — dont il ne tranche aucune. + QString source = QStringLiteral("none"); + double powerW = 0; + + // 1. Le compteur EXPLICITE d'abord (LM-1105). Non pas d'abord parce qu'il mesure mieux — + // c'est vrai, et ce n'est pas la raison — mais parce qu'un réglage sans effet est pire + // que pas de réglage : préférer l'appareil au compteur que l'installateur a pris la + // peine de désigner rendrait le champ décoratif. if (!cfg.meterThingId().isEmpty()) { Thing *t = m_tm->findConfiguredThing(ThingId(cfg.meterThingId())); - if (t && t->hasState("currentPower")) - entry.insert(QStringLiteral("measuredW"), t->stateValue("currentPower").toDouble()); + if (t && t->hasState("currentPower")) { + source = QStringLiteral("meter"); + powerW = t->stateValue("currentPower").toDouble(); + } } + // 2. À défaut, l'APPAREIL PILOTÉ — LM-1105, et c'est le lot. La priorité porte sur la + // SOURCE, pas sur la valeur : un compteur déclaré mais injoignable fait retomber ici + // plutôt que taire une mesure qu'on a. Le silence se lirait « charge non mesurable ». + if (source == QLatin1String("none")) { + ILoadAdapter *adapter = m_loadAdapters.value(loadId, nullptr); + if (!adapter) + adapter = m_adapters.value(loadId, nullptr); + if (adapter) { + const LoadMeasurement m = adapter->deviceMeasurement(); + if (m.available) { + source = QStringLiteral("device"); + powerW = m.powerW; + } + } + } + + // 3. "none" est un état POSITIF, pas une clé qui manque : « cette charge n'est pas + // mesurable ». C1 de LM-1009 appliqué ici — une capacité manquante SE DIT. Sans lui, + // « pas mesurable » et « compteur en panne » se ressemblent, et l'app continue + // d'afficher un bloc de mesure vide sur un sg-ready qui n'en aura jamais. + // + // Et powerW reste OMIS : la puissance nominale existe, elle est déjà lisible dans + // mechanism.* qui dit d'où elle vient. La faire passer pour une mesure produirait un + // écart commandé↔mesuré identiquement nul à tous les cycles — un « tout concorde » + // permanent, c'est-à-dire le corollaire LM-104 : l'hypothèse de prudence devient un + // masquage de panne. + QVariantMap mesure; + mesure.insert(QStringLiteral("source"), source); + if (source != QLatin1String("none")) + mesure.insert(QStringLiteral("powerW"), powerW); + entry.insert(QStringLiteral("measurement"), mesure); + // Sonde — lecture seule (LM-1103). Sa raison d'être immédiate est le diagnostic ; sa // raison d'être durable est l'HISTORIQUE, sans lequel les seuils éco/confort du §10 ne // se règleront jamais autrement qu'au doigt mouillé. diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index eea0078..9b20942 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -732,6 +732,12 @@ QVariantMap NymeaEnergyJsonHandler::loadTelemetrySchema() decisionItem.insert("code", enumValueName(String)); decisionItem.insert("params", enumValueName(Variant)); + // §11 / LM-1106 — la mesure et SA SOURCE. « source » discrimine, « powerW » n'existe que + // quand quelque chose mesure réellement (jamais sous « none »). + QVariantMap measurementItem; + measurementItem.insert("source", enumValueName(String)); // meter | device | none + measurementItem.insert("o:powerW", enumValueName(Double)); + QVariantMap loadItem; loadItem.insert("loadId", enumValueName(String)); // ALLOCATION du waterfall (ce qui a décrémenté le budget), pas la mesure : la mesure vit @@ -750,8 +756,14 @@ QVariantMap NymeaEnergyJsonHandler::loadTelemetrySchema() // §11 (LM-1101/1103) — mesures de la charge, publiées à côté de l'allocation et JAMAIS // fondues dedans : la somme des allocations doit rester réconciliable avec // budget.allocatedW, et une mesure en retard sur la commande la rendrait fausse. - // Omises quand la charge ne déclare pas de Thing, ou quand le Thing ne porte pas l'état. - loadItem.insert("o:measuredW", enumValueName(Double)); + // + // LM-1106 — « measurement » est un COUPLE {valeur, source}, et non le « measuredW » nu + // qu'il remplace. Le nombre seul ne pouvait pas dire d'où il venait, alors que l'écart + // commandé↔mesuré ne conclut pas la même chose selon la source ; et « source: none » est + // un état POSITIF — cette charge n'est pas mesurable — que l'absence d'une clé ne savait + // pas distinguer d'un compteur en panne. « powerW » est omis sous « none » : la puissance + // nominale reste lisible dans mechanism.*, elle ne se fait pas passer pour une mesure. + loadItem.insert("o:measurement", measurementItem); loadItem.insert("o:temperatureC", enumValueName(Double)); loadItem.insert("o:lock", lockItem); // absent = aucun verrou actif loadItem.insert("o:mechanism", mechItem); diff --git a/specs/spec_loadmodel.md b/specs/spec_loadmodel.md index 87df6b9..aee17b1 100644 --- a/specs/spec_loadmodel.md +++ b/specs/spec_loadmodel.md @@ -909,6 +909,40 @@ sans configuration. `meterThingId` ne reste nécessaire que là où rien ne mesu > palier 0 n'a aucun relais actif : interroger les relais *actifs* ferait osciller la réponse > d'un cycle à l'autre, sur une installation dont rien n'a changé. La question est « ces Things > portent-ils l'état ? », posée sur **tous** les Things déclarés de la charge. +> +> *Vérifié : jugée sur les relais actifs, la source publiée vaut `device` au palier 1 et `none` +> au palier 0 — un écran de mesure qui apparaît et disparaît au rythme du thermostat +> (`testMeasurementSourceIsPublished`, contre-épreuve).* + +**LM-1105-a — Une mesure PARTIELLE n'est pas une mesure.** *(Affinement imposé par +l'implantation, 2026-08-28.)* + +Là où plusieurs Things portent la charge — les relais d'un `relay-router` —, il faut qu'ils la +portent **tous**. « Au moins un » produirait une somme **sous-évaluée** présentée comme la mesure +de la charge : une topologie de trois relais dont un seul mesure annoncerait 500 W là où +1 500 W coulent, et l'écart commandé↔mesuré enverrait chercher une panne qui n'existe pas +(LM-1102). Une topologie mixte est donc `none` — et c'est un des cas où `meterThingId` garde +tout son sens. + +> ⚠️ **À ne pas confondre avec `LoadTelemetry::currentPowerW`**, qui accepte « au moins un » et +> retombe sur le palier **nominal** sinon. Les deux ne répondent pas à la même question : +> celui-là est le **juge runtime du budget**, il doit rendre un nombre à chaque cycle parce que +> le recrédit anti-clignotement en dépend. Celui-ci ne vaut que ce qu'un appareil a réellement +> mesuré, ou rien. **Les fusionner rouvrirait la correction B.** + +**LM-1105-b — La règle présuppose que le Thing piloté est TRAVERSÉ par la puissance.** +*(Trouvé en implantant, et c'est la limite exacte de LM-1105.)* + +« Le moteur lit l'appareil qu'il pilote » n'est vrai que si l'appareil piloté est **dans le +chemin de puissance** de la charge. Un relais de `relay-router` commute la charge : sa puissance +**est** celle de la charge. Un contact **SG-Ready** est un contact de **SIGNAL** — la PAC est +alimentée ailleurs, son courant ne traverse jamais K1/K2, et un contact qui mesurerait quelque +chose mesurerait **sa propre bobine**. + +**`sg-ready` est donc `none` par CONSTRUCTION**, et non par accident de matériel : même montée +sur des relais qui mesurent, la charge reste non mesurable par son organe de commande. C'est le +seul mécanisme pour lequel `meterThingId` demeure la **seule** voie vers une mesure — et cela +doit se lire à l'écran plutôt que se deviner. **Le compteur explicite reste PRIORITAIRE, et ce n'est pas d'abord une question de précision.** Un compteur divisionnaire posé exprès mesure mieux qu'un appareil qui se déclare — c'est vrai, diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 8d2c6f7..a401bb0 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -5626,3 +5626,159 @@ void Simulation::testChargerWithoutCarIsArbitrated() QCOMPARE(qRound(tele.value(routeurId.toString()).value("allocatedW").toDouble()), 3000); #endif } + +void Simulation::testMeasurementSourceIsPublished() +{ +#ifndef ETM_ARBITRATOR + QSKIP("testMeasurementSourceIsPublished nécessite ETM_ARBITRATOR."); +#else + // [§11 · LM-1105/LM-1106] Il fallait désigner une charge comme SON PROPRE compteur dans + // meterThingId pour qu'elle publie ce qu'elle mesure déjà. Le moteur lit désormais + // l'appareil qu'il pilote — et publie D'OÙ vient le nombre, parce que l'écart + // commandé↔mesuré ne conclut pas la même chose selon la source. + // + // Ce test porte les trois sources ET les deux règles de priorité. Sans la seconde moitié + // il passerait aussi si le compteur explicite avait été purement et simplement remplacé. + const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-measure.json"; + QFile::remove(cfgPath); + qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); + + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + + 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); + + // Trois mécanismes. Les relais du mock portent `currentPower` — c'est délibéré : le + // sg-ready doit répondre « non mesurable » MÊME LÀ, parce que ses contacts sont des + // contacts de SIGNAL et que le courant de la PAC ne les traverse pas. + QUuid rA = addPowerSwitch(1000, 26661); // relay-router, 1 relais + QUuid k1 = addPowerSwitch(0, 26662); // sg-ready K1 + QUuid k2 = addPowerSwitch(0, 26663); // sg-ready K2 + QUuid varId = addEtmVariableLoad(27041); // etmvariableload dynamic + Thing *varThing = tm->findConfiguredThing(varId); + QVERIFY(varThing && tm->findConfiguredThing(rA)); + QVERIFY(tm->findConfiguredThing(k1) && tm->findConfiguredThing(k2)); + varThing->setStateValue("currentPowerW", 0); + + // Le compteur divisionnaire, posé EXPRÈS sur le routeur — il n'entrera en jeu qu'au 3. + QUuid divId = addMeter(26670); + Thing *div = tm->findConfiguredThing(divId); + QVERIFY(div); + div->setStateValue("connected", true); + div->setStateValue("currentPower", 1234); + + LoadConfigStore *store = new LoadConfigStore(arb); + arb->setLoadConfigStore(store); + + auto poserConfig = [&](const QString &meterThingId) { + LoadConfigs cfgs; + QVariantMap routeur{ + {"id", varId.toString()}, {"label", "Routeur PV"}, {"adapter", "etmvariableload"}, + {"mode", "dynamic"}, {"maxPowerW", 3000}, {"priority", 1}, {"enabled", true}}; + if (!meterThingId.isEmpty()) + routeur.insert("meterThingId", meterThingId); + cfgs.append(LoadConfig::fromMap(routeur)); + cfgs.append(LoadConfig::fromMap(sgReadyConfigMap("pac-mesure", "PAC", k1, k2, + 800.0, 1600.0, 300, 2))); + cfgs.append(LoadConfig::fromMap(QVariantMap{ + {"id", "ecs-mesure"}, {"label", "ECS relais"}, {"adapter", "relay-router"}, + {"mode", "fixed"}, {"priority", 3}, {"enabled", true}, + {"relays", QVariantList() << QVariantMap{{"thingId", rA.toString()}, + {"powerW", 1000}}}, + {"minOnS", 0}, {"minOffS", 0}})); + QString err; + QVERIFY2(store->setConfigs(cfgs, &err), err.toUtf8()); + }; + + const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); + + // ---- 1. Les TROIS sources, sans une ligne de configuration de compteur ---------------- + poserConfig(QString()); + meter->setStateValue("currentPower", -6000); + arb->simulationCallUpdate(t0); + QCoreApplication::processEvents(); + QHash tele = telemetrieParId(arb->loadTelemetry()); + + // L'appareil piloté publie sa puissance : le moteur la lit, personne n'a rien désigné. + // 0 W est une MESURE, pas une absence — et la source ne doit pas basculer pour autant. + const QVariantMap mRouteur = tele.value(varId.toString()).value("measurement").toMap(); + QCOMPARE(mRouteur.value("source").toString(), QString("device")); + QVERIFY2(mRouteur.contains("powerW"), "une charge mesurable doit publier sa valeur"); + QCOMPARE(qRound(mRouteur.value("powerW").toDouble()), 0); + + // Le relay-router est servi : son relais est fermé et le mock rapporte 1 000 W. La mesure + // traverse bien le relais — c'est ce qui distingue un organe de COMMUTATION d'un contact + // de signal (voir la PAC juste dessous). + const QVariantMap mEcs = tele.value("ecs-mesure").value("measurement").toMap(); + QCOMPARE(mEcs.value("source").toString(), QString("device")); + QCOMPARE(qRound(mEcs.value("powerW").toDouble()), 1000); + + // La PAC : « none », ALORS QUE ses contacts portent currentPower. Un contact SG-Ready est + // un contact de signal — mesurer sa bobine ne mesure pas la PAC. C'est un état POSITIF, + // et powerW est OMIS : une nominale qui se ferait passer pour une mesure produirait un + // écart identiquement nul à tous les cycles. + const QVariantMap mPac = tele.value("pac-mesure").value("measurement").toMap(); + QCOMPARE(mPac.value("source").toString(), QString("none")); + QVERIFY2(!mPac.contains("powerW"), + "une charge non mesurable a publié une puissance sous le nom d'une mesure"); + + // Et l'ancienne clé a bien DISPARU — deux noms pour une chose divergent. + QVERIFY2(!tele.value(varId.toString()).contains("measuredW"), "measuredW survit"); + + // ---- 1-bis. Palier 0 : la SOURCE ne bascule pas, la valeur seule tombe ----------------- + // C'est la règle « la capacité se juge sur la CLASSE de tous les relais déclarés, jamais + // sur ceux qui sont fermés ». Jugée sur les relais fermés, elle répondrait « none » ici — + // et la source publiée basculerait à chaque extinction, sur une installation dont rien + // n'a changé. Il faut un budget NÉGATIF : à budget nul le recrédit anti-clignotement + // rendrait à l'ECS les 1 000 W qu'il consomme, et le relais resterait fermé. + meter->setStateValue("currentPower", 2000); // 2 000 W IMPORTÉS + arb->simulationCallUpdate(t0.addSecs(30)); + QCoreApplication::processEvents(); + tele = telemetrieParId(arb->loadTelemetry()); + const QVariantMap mEcsEteint = tele.value("ecs-mesure").value("measurement").toMap(); + QCOMPARE(mEcsEteint.value("source").toString(), QString("device")); + QCOMPARE(qRound(mEcsEteint.value("powerW").toDouble()), 0); + + // ---- 2. La valeur SUIT l'appareil ------------------------------------------------------ + meter->setStateValue("currentPower", -6000); + varThing->setStateValue("currentPowerW", 2900); + arb->simulationCallUpdate(t0.addSecs(60)); + QCoreApplication::processEvents(); + tele = telemetrieParId(arb->loadTelemetry()); + QCOMPARE(tele.value(varId.toString()).value("measurement").toMap() + .value("source").toString(), QString("device")); + QCOMPARE(qRound(tele.value(varId.toString()).value("measurement").toMap() + .value("powerW").toDouble()), 2900); + + // ---- 3. Le compteur EXPLICITE reste prioritaire ---------------------------------------- + // Pas d'abord parce qu'il mesure mieux : parce qu'un réglage sans effet est pire que pas + // de réglage. 1234 W du compteur doivent l'emporter sur 2900 W déclarés par l'appareil. + poserConfig(divId.toString()); + arb->simulationCallUpdate(t0.addSecs(120)); + QCoreApplication::processEvents(); + tele = telemetrieParId(arb->loadTelemetry()); + const QVariantMap mAvecCompteur = tele.value(varId.toString()).value("measurement").toMap(); + QCOMPARE(mAvecCompteur.value("source").toString(), QString("meter")); + QCOMPARE(qRound(mAvecCompteur.value("powerW").toDouble()), 1234); + + // ---- 4. …mais la priorité porte sur la SOURCE, pas sur la valeur ----------------------- + // Compteur déclaré et injoignable : on retombe sur l'appareil. Se taire perdrait une + // mesure qu'on a, et le silence se lirait « charge non mesurable » — ce qui serait faux. + poserConfig(QUuid::createUuid().toString()); + arb->simulationCallUpdate(t0.addSecs(180)); + QCoreApplication::processEvents(); + tele = telemetrieParId(arb->loadTelemetry()); + const QVariantMap mRepli = tele.value(varId.toString()).value("measurement").toMap(); + QCOMPARE(mRepli.value("source").toString(), QString("device")); + QCOMPARE(qRound(mRepli.value("powerW").toDouble()), 2900); +#endif +} diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 454cd23..99d1f63 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -172,6 +172,13 @@ private slots: // Le mode manuel, lui, reste une exclusion : la seconde moitié du test le prouve. void testChargerWithoutCarIsArbitrated(); + // ── §11 / LM-1105 + LM-1106 ──────────────────────────────────────────── + // La mesure d'une charge se lit sur l'APPAREIL PILOTÉ, sans qu'on ait à le désigner comme + // son propre compteur — et ce qui est publié est un COUPLE {valeur, source}. Les trois + // sources sont éprouvées, plus les deux règles de priorité : le compteur explicite gagne, + // mais un compteur injoignable fait retomber sur l'appareil au lieu de taire la mesure. + void testMeasurementSourceIsPublished(); + void printStates(Thing *thing); void updateChargerMeter(Thing *thing);