feat(§11): la mesure d'une charge se lit sur l'appareil, et publie sa source

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
This commit is contained in:
Patrick Schurig 2026-08-28 14:49:28 +02:00
parent fcba98159f
commit e392c1c7b8
16 changed files with 496 additions and 16 deletions

View File

@ -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

View File

@ -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

View File

@ -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"`.

View File

@ -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));

View File

@ -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;

View File

@ -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)

View File

@ -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) }

View File

@ -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).
*

View File

@ -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<QString> fermes = m_currentStage < m_relayMapping.size()
? m_relayMapping.at(m_currentStage)
: QList<QString>();
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;

View File

@ -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;

View File

@ -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).

View File

@ -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é.

View File

@ -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);

View File

@ -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,

View File

@ -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<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arb);
ThingManager *tm = NymeaCore::instance()->thingManager();
QUuid meterId = addMeter();
m_experiencePlugin->energyManager()->setRootMeter(meterId);
Thing *meter = tm->findConfiguredThing(meterId);
QVERIFY(meter);
meter->setStateValue("connected", true);
// 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<QString, QVariantMap> 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
}

View File

@ -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);