feat(lot E §11): compteur et sonde par charge, publiés et sans effet sur les décisions

Deux champs optionnels de même forme sur LoadConfig — o:meterThingId, o:sensorThingId —
et leur publication en télémétrie. Rien d'autre : l'arbitre ne s'en sert pas pour
décider, et le §12 comme le §10 restent à faire.

« Aucun effet sur les décisions » est vrai par CONSTRUCTION, pas par discipline : les
adaptateurs ne voient jamais ces Things. attachMeasurements() les résout au moment de
publier, dans les deux chemins de télémétrie — y compris le mode dégradé, où un
exploitant veut précisément voir ce que la charge fait pendant que l'arbitre ne décide
plus rien.

measuredW est publié À CÔTÉ de allocatedW et jamais fondu dedans : l'un est ce que le
waterfall a alloué, l'autre ce qu'un compteur a vu. Les confondre rendrait la somme des
allocations irréconciliable avec budget.allocatedW — et injecter une mesure en retard
sur la commande réintroduirait l'oscillation que le recrédit a supprimée.

claimedThingIds() les exclut, et l'exclusion est maintenant commentée sur place : LM-201
vaut pour ce qui commande, ceux-ci mesurent. Sans ce commentaire, la symétrie avec
relays[] invitait à les ajouter — ce qui ferait refuser un compteur divisionnaire
partagé ou une sonde de pièce commune.

spec 0.2.1 : la précondition de livraison du §10 (extension du délestage au waterfall,
même lot ou avant, jamais après) est écrite comme CONDITION et non comme conseil — la
protection de surcharge ne déleste que les bornes, et des planchers éco qui
s'additionnent un matin sans soleil sur un branchement 6 kVA feraient disjoncter
l'installation. Et LM-1008 tranche la cible de confort : saisie en degrés, frontière de
l'arbitre en énergie, conversion à la couche domaine — et aucune conversion nécessaire
aujourd'hui, un comparateur suffit, c'est l'arrêt qui compte et pas le dosage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
This commit is contained in:
Patrick Schurig 2026-08-26 10:34:41 +02:00
parent 9445f60f1d
commit bbb3492c2c
11 changed files with 334 additions and 7 deletions

View File

@ -334,6 +334,41 @@ Détail complet : `docs/INTERFACE_etmvariableload.md` §4.
---
#### `LoadConfig` — champs `o:meterThingId` et `o:sensorThingId`
Deux Things de **mesure**, optionnels, de même forme. Ils décrivent ce qu'on **observe** d'une
charge — jamais ce qu'on lui **commande**.
```jsonc
{ "id": "chauffe-eau", …,
"meterThingId": "{uuid}", // o: — compteur dédié à CETTE charge
"sensorThingId": "{uuid}" } // o: — sonde du service rendu (température)
```
- **L'arbitre ne s'en sert pas pour décider.** Les adaptateurs ne voient même pas ces Things :
ils sont résolus au moment de **publier** la télémétrie, et nulle part ailleurs. « Aucun effet
sur les décisions » est donc vrai par construction, pas par discipline.
- **Choix par INTERFACE nymea, jamais par plugin.** Le compteur se prend parmi les Things
portant `energymeter` / `smartmeter`, la sonde parmi celles portant la température. Nommer un
plugin rendrait le modèle faux au prochain qui rend le même service.
- **Aucun contrôle d'existence à l'écriture.** Un `ThingId` encore inconnu est accepté : refuser
imposerait un ordre aux opérations — déclarer le compteur avant la charge — que rien ne
justifie. Un id qui ne résout pas produit une **absence de mesure**, pas une configuration
invalide.
- **Ni l'un ni l'autre n'est exclusif**, contrairement à `relays[]`. Ils mesurent, ils ne
commandent pas : `validateSet()` les ignore, et deux charges peuvent partager une sonde de
pièce ou un compteur de tableau divisionnaire.
> **Le compteur VÉRIFIE, il ne budgète pas.** Commandé 3 000 W, mesuré 0 W sur plusieurs
> cycles : la charge ne fait pas ce qu'on lui demande. La mesure n'est **pas** injectée dans le
> budget, pour deux raisons dont chacune suffit — elle est **en retard sur la commande**
> (scrutation Modbus, moyennage du compteur), et l'y mettre brute réintroduirait l'oscillation
> que le recrédit anti-clignotement a supprimée ; et la charge est **déjà comptée** dans le
> compteur racine, dont ce compteur-ci n'est qu'une **décomposition**, pas une source à
> additionner.
---
### Télémétrie d'arbitrage
#### `NymeaEnergy.GetLoadTelemetry`
@ -460,6 +495,17 @@ le **libellé de la charge n'y figure jamais** (le client le résout via `GetLoa
> La distinction est le fruit d'ECS-309 et ECS-309-b (corrections de terrain) — un motif faux
> envoie diagnostiquer le mauvais problème, avec l'autorité d'une explication.
**`measuredW`** *(o:)* — puissance lue sur le compteur dédié, quand la charge en déclare un.
Publiée **à côté** de `allocatedW`, jamais fondue dedans : `allocatedW` est ce que le waterfall
a **alloué**, `measuredW` ce qu'un compteur a **vu**. Les confondre rendrait la somme des
allocations irréconciliable avec `budget.allocatedW`. Absente si la charge ne déclare pas de
compteur, ou si le Thing ne porte pas l'état.
**`temperatureC`** *(o:)* — température lue sur la sonde déclarée. **Lecture seule, aucun effet
sur les décisions.** Son usage immédiat est le diagnostic de mise en service ; son usage durable
est de **constituer l'historique** (température, puissance, heure) sans lequel les seuils
éco/confort à venir ne pourront pas être réglés.
**`faultCode`** — absent quand la charge est saine.
| Code | Sens |

View File

@ -1,3 +1,36 @@
powersync-energy-plugin-nymea (1.15.2+etm17) trixie; urgency=medium
* Lot E §11 — compteur et sonde par charge. Deux champs optionnels de même forme sur
LoadConfig : o:meterThingId et o:sensorThingId. Ils décrivent ce qu'on OBSERVE d'une
charge, jamais ce qu'on lui commande.
* L'arbitre ne s'en sert pas pour décider, et c'est vrai par CONSTRUCTION : les adaptateurs
ne voient même pas ces Things. Ils sont résolus au moment de publier la télémétrie
(attachMeasurements()), et nulle part ailleurs — la mesure ne traverse aucun chemin
d'arbitrage.
* Le compteur VÉRIFIE, il ne budgète pas. La mesure n'est pas injectée au budget : elle est
en retard sur la commande (scrutation Modbus, moyennage du compteur) et l'y mettre brute
réintroduirait l'oscillation que le recrédit anti-clignotement a supprimée ; et la charge
est déjà comptée dans le compteur racine, dont celui-ci n'est qu'une décomposition.
measuredW est donc publié À CÔTÉ de allocatedW, jamais fondu dedans, sans quoi la somme
des allocations cesserait d'être réconciliable avec budget.allocatedW.
* La sonde est en lecture seule. Son usage immédiat est le diagnostic de mise en service ;
son usage durable est de constituer l'historique (température, puissance, heure) sans
lequel les seuils éco/confort du §10 ne se régleraient jamais autrement qu'au doigt
mouillé. C'est la raison pour laquelle ce champ existe AVANT d'avoir un consommateur.
* Ni le compteur ni la sonde ne sont exclusifs, contrairement à relays[]. LM-201 (« un canal
de commande, une charge ») vaut pour ce qui commande ; ceux-ci mesurent. claimedThingIds()
les exclut, l'exclusion est désormais commentée sur place pour qu'on ne la « corrige » pas,
et deux charges peuvent partager une sonde de pièce ou un compteur divisionnaire.
* Aucun contrôle d'existence à l'écriture : un ThingId encore inconnu est accepté. Refuser
imposerait un ordre aux opérations — déclarer le compteur avant la charge — que rien ne
justifie. Un id qui ne résout pas produit une absence de mesure, pas une config invalide.
* Omis quand vides, jamais une chaîne vide persistée — même règle que domain.
* tests/auto/loadmodel : 12/12, dont l'aller-retour des deux champs, leur absence de
claimedThingIds(), le partage d'un compteur entre deux charges, et le fait que les
rattacher ne reconstruit rien.
-- Patrick Schurig <etm.schurig@gmail.com> Wed, 26 Aug 2026 11:00:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm16) trixie; urgency=medium
* Lot D — les constats de l'agent app, dans l'ordre de ce qu'ils coûtent.

View File

@ -620,6 +620,7 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const
mech.insert(QStringLiteral("kind"), v.mechanismKind);
entry.insert(QStringLiteral("mechanism"), mech);
}
attachMeasurements(entry, action.loadId);
loads.append(entry);
}
@ -636,6 +637,38 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const
m_telemetry = snap;
}
void EnergyArbitrator::attachMeasurements(QVariantMap &entry, const QString &loadId) const
{
if (!m_tm || !m_loadConfigStore)
return;
LoadConfig cfg;
bool trouvee = false;
for (const LoadConfig &c : m_loadConfigStore->configs()) {
if (c.id() == loadId) { cfg = c; trouvee = true; 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.
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());
}
// 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é.
if (!cfg.sensorThingId().isEmpty()) {
Thing *t = m_tm->findConfiguredThing(ThingId(cfg.sensorThingId()));
if (t && t->hasState("temperature"))
entry.insert(QStringLiteral("temperatureC"), t->stateValue("temperature").toDouble());
}
}
void EnergyArbitrator::buildDegradedTelemetry(const QDateTime &now)
{
QVariantMap snap;
@ -666,6 +699,9 @@ void EnergyArbitrator::buildDegradedTelemetry(const QDateTime &now)
mech.insert(QStringLiteral("kind"), v.mechanismKind);
entry.insert(QStringLiteral("mechanism"), mech);
}
// Publiées AUSSI en mode dégradé : c'est justement là qu'un exploitant veut voir ce
// que la charge fait vraiment, puisque l'arbitre, lui, ne décide plus rien.
attachMeasurements(entry, id);
loads.append(entry);
};
// Ordre STABLE : QHash n'en garantit aucun, et un instantané se compare.

View File

@ -326,6 +326,24 @@ private:
*/
void buildDegradedTelemetry(const QDateTime &now);
/*!
* \brief Ajoute à une entrée de télémétrie les MESURES de la charge, si elle en déclare.
*
* §11 (LM-1101, LM-1103). Deux champs optionnels de \c LoadConfig — \c meterThingId et
* \c sensorThingId — sont résolus ici, au moment de PUBLIER, et nulle part ailleurs.
*
* \note **Les adaptateurs ne voient jamais ces Things.** C'est ce qui rend « aucun effet
* sur les décisions » vrai par construction plutôt que par discipline : la mesure ne
* traverse aucun chemin d'arbitrage, elle est lue au dernier moment et publiée.
* \note Omis, jamais nul : un Thing absent de la configuration nymea, ou un état que le
* Thing ne porte pas, ne produit **aucune clé** — pas un zéro, qui se lirait « mesuré 0 W »
* là où la vérité est « pas de mesure ».
*
* \param entry Entrée de charge en cours de construction, modifiée en place.
* \param loadId Identifiant de la charge, clé dans le magasin de configuration.
*/
void attachMeasurements(QVariantMap &entry, const QString &loadId) const;
/*!
* \brief Publie \c m_telemetry si elle diffère significativement de la dernière publiée.
* \param force Vrai pour publier sans condition (bascule de mode dégradé, battement).

View File

@ -172,6 +172,12 @@ QSet<QString> LoadConfig::claimedThingIds() const
// (cf. etmvariableloadadapter.cpp, findConfiguredThing(ThingId(m_id))).
ids.insert(normalizedThingId(m_id));
}
// §11 / LM-1104 — meterThingId et sensorThingId n'entrent PAS ici, et c'est délibéré.
// LM-201 (« un canal de commande, une charge ») vaut pour ce qui COMMANDE ; ces deux
// Things MESURENT. Deux charges peuvent légitimement partager une sonde de pièce, et un
// compteur de tableau divisionnaire peut en couvrir plusieurs. Les ajouter ici par
// symétrie avec relays[] ferait refuser des installations correctes par validateSet().
return ids;
}
@ -309,6 +315,11 @@ QVariantMap LoadConfig::toMap() const
// domaine inconnu plutôt que comme une absence de classement (règle « omis, jamais nul »).
if (!m_domain.isEmpty())
m.insert("domain", m_domain);
// Mêmes règles que le domaine : omis quand vide, jamais une chaîne vide persistée.
if (!m_meterThingId.isEmpty())
m.insert("meterThingId", m_meterThingId);
if (!m_sensorThingId.isEmpty())
m.insert("sensorThingId", m_sensorThingId);
if (!m_powerLevels.isEmpty())
m.insert("powerLevels", powerLevels());
m.insert("maxPowerW", m_maxPowerW);
@ -337,6 +348,12 @@ LoadConfig LoadConfig::fromMap(const QVariantMap &map)
c.m_adapter = map.value("adapter", QStringLiteral("etmvariableload")).toString();
c.m_mode = map.value("mode").toString();
c.m_domain = map.value("domain").toString(); // absent → vide → charge non classée
// Aucun contrôle d'EXISTENCE ici, à dessein : LoadConfig est un type pur, sans
// ThingManager. Et refuser un ThingId encore inconnu imposerait un ORDRE aux opérations —
// déclarer le compteur avant la charge — que rien ne justifie. Un id qui ne résout pas
// produit une absence de mesure, pas une configuration invalide.
c.m_meterThingId = map.value("meterThingId").toString();
c.m_sensorThingId = map.value("sensorThingId").toString();
c.setPowerLevels(map.value("powerLevels").toList()); // trie + déduplique
c.m_maxPowerW = map.value("maxPowerW").toInt();
c.m_priority = map.value("priority").toInt();

View File

@ -124,6 +124,8 @@ class LoadConfig
Q_GADGET
Q_PROPERTY(QString id READ id WRITE setId)
Q_PROPERTY(QString label READ label WRITE setLabel)
Q_PROPERTY(QString meterThingId READ meterThingId WRITE setMeterThingId)
Q_PROPERTY(QString sensorThingId READ sensorThingId WRITE setSensorThingId)
Q_PROPERTY(QString adapter READ adapter WRITE setAdapter)
Q_PROPERTY(QString mode READ mode WRITE setMode)
//! Catégorie d'intention déclarée par l'installateur — énumération FERMÉE, code jamais libellé.
@ -181,6 +183,29 @@ public:
//! échouer \c isValid() — c'est le seul endroit où l'énumération est refermée.
void setDomain(const QString &v) { m_domain = v; }
/*!
* \brief Compteur dédié à CETTE charge. Vide = aucun. **Ne sert jamais à décider.**
*
* Usage de VÉRIFICATION seulement (LM-1101) : commandé 3 000 W, mesuré 0 W → la charge
* ne fait pas ce qu'on lui demande. La mesure n'est **pas** injectée au budget, pour deux
* raisons dont chacune suffit — elle est en retard sur la commande (scrutation Modbus,
* moyennage du compteur), et la charge est **déjà comptée** dans le compteur racine : ce
* compteur-ci en est une décomposition, pas une source à additionner.
*/
QString meterThingId() const { return m_meterThingId; }
void setMeterThingId(const QString &v) { m_meterThingId = v; }
/*!
* \brief Sonde du service rendu (température). Vide = aucune. **Lecture seule.**
*
* Aucun effet sur les décisions (LM-1103). Deux usages, dont le second est le plus
* important : le diagnostic de mise en service, et surtout **constituer l'historique**
* (température, puissance, heure) sans lequel les seuils éco/confort du §10 ne pourront
* jamais être réglés autrement qu'au doigt mouillé.
*/
QString sensorThingId() const { return m_sensorThingId; }
void setSensorThingId(const QString &v) { m_sensorThingId = v; }
//! \return Codes de domaine acceptés, ordre stable. Source unique de l'énumération :
//! la validation et la documentation en dérivent, elles ne la redéclarent pas.
static QStringList knownDomains();
@ -290,6 +315,11 @@ private:
QString m_adapter = QStringLiteral("etmvariableload");
QString m_mode;
QString m_domain; //!< Code d'énumération fermée ; vide = charge non classée.
//! Compteur de vérification ; vide = aucun. N'entre PAS dans claimedThingIds() : il
//! mesure, il ne commande pas (LM-1104), donc deux charges peuvent le partager.
QString m_meterThingId;
//! Sonde de température ; vide = aucune. Même règle d'exclusivité que le compteur.
QString m_sensorThingId;
QList<int> m_powerLevels; //!< Trié croissant, 0 inclus en *fixed* ; vide en *dynamic*.
int m_maxPowerW = 0;
int m_priority = 0;

View File

@ -185,6 +185,15 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
// L'arbitre ne la lit pas ; la validation de l'énumération vit dans LoadConfig::isValid(),
// comme pour « adapter » et « mode » (LM-302 : l'exigence réelle n'est jamais dans le schéma).
loadItem.insert("o:domain", enumValueName(String));
// §11 (LM-1100) — deux Things de MESURE, de même forme, optionnels. Le compteur sert à
// VÉRIFIER (commandé 3 000 W, mesuré 0 W → la charge ne fait pas ce qu'on lui demande) ;
// la sonde est en lecture seule et constitue l'historique. **Ni l'un ni l'autre n'entre
// dans une décision d'arbitrage**, et ni l'un ni l'autre n'est exclusif : ils mesurent,
// ils ne commandent pas, donc LM-201 ne s'applique pas et validateSet() les ignore.
// Le client les choisit par INTERFACE nymea (energymeter/smartmeter ; température),
// jamais par plugin.
loadItem.insert("o:meterThingId", enumValueName(Uuid));
loadItem.insert("o:sensorThingId", enumValueName(Uuid));
loadItem.insert("priority", enumValueName(Uint));
loadItem.insert("enabled", enumValueName(Bool));
loadItem.insert("o:powerLevels", QVariantList() << enumValueName(Uint)); // etmvariableload fixed
@ -718,6 +727,12 @@ QVariantMap NymeaEnergyJsonHandler::loadTelemetrySchema()
loadItem.insert("allocatedW", enumValueName(Double));
loadItem.insert("available", enumValueName(Bool));
loadItem.insert("o:faultCode", enumValueName(String)); // absent = aucun défaut
// §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));
loadItem.insert("o:temperatureC", enumValueName(Double));
loadItem.insert("o:lock", lockItem); // absent = aucun verrou actif
loadItem.insert("o:mechanism", mechItem);
loadItem.insert("decision", decisionItem);

View File

@ -1,12 +1,19 @@
# SPEC — Modèle de charges (domaine × mécanisme)
Version : 0.2.0
Version : 0.2.1
Dépôt : `etm-powersync-energy-plugin-etm`
Statut : **intention de conception figée.** §3 (schéma persisté) est **en cours
d'implémentation** depuis le 2026-08-09 : `relay-router`, `etmvariableload` et
`sg-ready` sont trois charges utiles discriminées par `adapter`. Le reste du
document reste une intention.
> **0.2.1** — spec relue et validée (Patrick, 2026-08-26). Deux décisions inscrites :
> l'extension du délestage au waterfall devient une **précondition de livraison** du §10 —
> écrite comme condition et non comme conseil — et la cible de confort est tranchée
> (LM-1008) : saisie en degrés, frontière de l'arbitre en énergie, conversion à la couche
> domaine — et aucune conversion nécessaire aujourd'hui, un comparateur suffit. Ordre
> d'implémentation retenu : §11, puis §12, puis §10.
> **0.2.0** — trois manques du modèle spécifiés, **avant** implémentation : le besoin à deux
> niveaux (§10, LM-1000+), le compteur et la sonde par charge (§11, LM-1100+), le plancher de
> modulation (§12, LM-1200+). Écrits maintenant parce qu'écrits tard ils se heurteraient à des
@ -463,9 +470,8 @@ distinct, pas comme une variante.
6. **Seuil et fenêtre du diagnostic de mesure (LM-1102).** « Trois cycles » est un point de
départ. La valeur juste dépend de l'inertie de la charge et de la latence du compteur — donc
des données que LM-1103 doit d'abord constituer.
7. **Cible de confort : en énergie ou en grandeur physique ?** Le plancher éco s'exprime en Wh
(`minEnergyWhPerDay`). Le confort s'exprime plus naturellement en **température cible**, que
le moteur ne sait pas convertir en watts sans modèle thermique. À trancher avec §11-1103.
7. ~~**Cible de confort : en énergie ou en grandeur physique ?**~~ **TRANCHÉ** (2026-08-26) —
voir LM-1008.
8. **`heating` vs `hvac` — ce que ces deux codes désignent.** Question **ouverte, et
volontairement non tranchée**. Elle ne se saura vraiment qu'en dessinant la **PAC
communicante** (Alpha Innotec, Aquarea), où les consignes ECS et chauffage sont enfin
@ -579,6 +585,32 @@ surplus. **Ne pas confondre les deux bornes** : c'est ce qui distingue un planch
ordre. L'app doit le montrer — deux passes visibles, pas un classement unique qui aurait
l'air désobéi. Sans cela, le premier retour terrain sera « l'ordre n'est pas respecté ».
**LM-1008 — La cible de confort : les deux unités, mais pas au même endroit.** *(Décision
Patrick, 2026-08-26.)*
- **La frontière de l'arbitre reste en ÉNERGIE.** Rien ne change à ce que le moteur reçoit et
distribue : des watts et des Wh.
- **La cible se SAISIT en degrés**, parce que c'est ainsi que l'installateur et le client la
pensent. Un ballon se règle à 60 °C, pas à 2 400 Wh.
- **La conversion appartient à la couche domaine**, jamais à l'arbitre. C'est LM-100 appliqué
tel quel : le domaine détient le modèle de la machine, l'arbitrage ne connaît que des watts.
**Et aujourd'hui, aucune conversion n'est nécessaire.** Sans modèle thermique, il suffit de
savoir si le besoin de confort **existe encore** :
```
sonde < consigne de confort → la charge est demandeuse de son bonus
sonde ≥ consigne de confort → elle ne l'est plus, la passe 2 la saute
```
**C'est l'ARRÊT qui compte, pas le dosage.** Un bonus qu'on sert tant qu'il est utile et qu'on
cesse de servir quand il ne l'est plus se contente d'un comparateur — et un comparateur ne
demande aucun modèle.
Le **dosage en Wh** viendra avec le modèle thermique, que l'historique du §11 permettra
précisément d'apprendre. **Une raison de plus de faire le §11 en premier** : il ne prépare pas
seulement les seuils, il prépare la seule chose qui rendra le dosage possible.
**LM-1007 — Compatibilité descendante, et elle est gratuite.** Une charge qui ne déclare
**aucun** besoin éco ne participe qu'à la passe 2 — soit exactement le comportement
d'aujourd'hui. Le modèle à deux niveaux **dégrade proprement** vers le modèle actuel, ce qui
@ -771,8 +803,18 @@ derrière une seule.
protection de surcharge ne déleste que les bornes. Livrer les planchers éco sans lui, c'est
livrer un mécanisme capable de faire disjoncter une installation un matin sans soleil.
> **Recommandation ferme sur ce dernier point** : traiter l'extension du délestage au waterfall
> **dans le même lot** que les planchers éco, ou avant. Pas après.
> **PRÉCONDITION DE LIVRAISON — pas une recommandation.** Le §10 **ne part pas** sans
> l'extension du délestage au waterfall. Même lot, ou avant. **Jamais après.**
>
> Aujourd'hui la protection de surcharge ne déleste **que les bornes de recharge**
> (`m_evChargers` dans `SmartChargingManager::verifyOverloadProtection`). Des planchers éco qui
> s'additionnent un matin sans soleil sur un branchement 6 kVA feraient **disjoncter
> l'installation** — pas dégrader le confort : couper le courant, chez un client.
>
> Écrit comme condition et non comme conseil parce que c'est exactement le genre de dette
> qu'on ne rembourse jamais : elle ne se manifeste qu'aux conditions extrêmes, donc « ça marche
> chez tout le monde » jusqu'au jour où ça ne marche chez personne. *(Décision Patrick,
> 2026-08-26.)*
**Ce qui peut se faire en parallèle, sans rien attendre** : la publication en télémétrie de la
sonde et du diagnostic de mesure (§11), puisqu'elle n'a aucun effet sur les décisions.

View File

@ -5,6 +5,7 @@
#include "etm/adapters/relayrouter.h"
#include "etm/energyarbitrator.h"
#include "etm/types/loadconfig.h"
#include "etm/config/loadconfigstore.h"
#include <QTest>
@ -13,7 +14,9 @@ namespace {
//! Une config relay-router minimale, valide, dont on ne fait varier que les relais.
LoadConfig relayLoad(const QList<QPair<QString, int>> &relays,
const QString &label = QStringLiteral("chauffe-eau"),
int priority = 1)
int priority = 1,
const QString &meter = QString(),
const QString &sensor = QString())
{
QVariantList list;
for (const auto &r : relays) {
@ -32,6 +35,10 @@ LoadConfig relayLoad(const QList<QPair<QString, int>> &relays,
map.insert("minOnS", 60);
map.insert("minOffS", 60);
map.insert("relays", list);
if (!meter.isEmpty())
map.insert("meterThingId", meter);
if (!sensor.isEmpty())
map.insert("sensorThingId", sensor);
return LoadConfig::fromMap(map);
}
@ -135,4 +142,74 @@ void TestLoadModel::testSoftFieldsNeverRebuild()
QVERIFY(EnergyArbitrator::sameHardware(avant, apres));
}
// ─────────────────────────────────────────────────────────────────────────────
// §11 — compteur et sonde par charge
// ─────────────────────────────────────────────────────────────────────────────
void TestLoadModel::testMeasurementThingsRoundTrip()
{
const QString meter = QStringLiteral("{dddddddd-0000-0000-0000-00000000000m}");
const QString sensor = QStringLiteral("{eeeeeeee-0000-0000-0000-00000000000s}");
const LoadConfig avec = relayLoad({{K1, 500}}, QStringLiteral("ECS"), 1, meter, sensor);
QCOMPARE(avec.meterThingId(), meter);
QCOMPARE(avec.sensorThingId(), sensor);
const QVariantMap m = avec.toMap();
QCOMPARE(m.value("meterThingId").toString(), meter);
QCOMPARE(m.value("sensorThingId").toString(), sensor);
// Omis quand vides — jamais une chaîne vide persistée, même règle que « domain ».
const QVariantMap sans = relayLoad({{K1, 500}}).toMap();
QVERIFY2(!sans.contains("meterThingId"), "un compteur absent ne doit pas sortir à \"\"");
QVERIFY2(!sans.contains("sensorThingId"), "une sonde absente ne doit pas sortir à \"\"");
}
void TestLoadModel::testMeasurementThingsAreNotClaimed()
{
const QString meter = QStringLiteral("{dddddddd-0000-0000-0000-00000000000m}");
const QString sensor = QStringLiteral("{eeeeeeee-0000-0000-0000-00000000000s}");
const LoadConfig c = relayLoad({{K1, 500}, {K2, 1000}}, QStringLiteral("ECS"), 1,
meter, sensor);
// LM-1104 : ces Things MESURENT, ils ne commandent pas. LM-201 ne s'applique donc pas.
const QSet<QString> claimed = c.claimedThingIds();
QCOMPARE(claimed.size(), 2); // les deux relais, et eux seuls
QVERIFY2(!claimed.contains(meter.toLower()), "le compteur ne doit pas être revendiqué");
QVERIFY2(!claimed.contains(sensor.toLower()), "la sonde ne doit pas être revendiquée");
}
void TestLoadModel::testTwoLoadsMayShareAMeter()
{
const QString meter = QStringLiteral("{dddddddd-0000-0000-0000-00000000000m}");
const QString sensor = QStringLiteral("{eeeeeeee-0000-0000-0000-00000000000s}");
LoadConfig a = relayLoad({{K1, 500}}, QStringLiteral("ECS haut"), 1, meter, sensor);
LoadConfig b = relayLoad({{K2, 1000}}, QStringLiteral("ECS bas"), 2, meter, sensor);
b.setId(QStringLiteral("ecs-bas"));
// Un compteur de tableau divisionnaire couvrant deux charges, une sonde de pièce
// partagée : deux configurations parfaitement correctes, que la symétrie avec relays[]
// aurait fait refuser.
LoadConfigs ensemble;
ensemble << a << b;
QString err;
QVERIFY2(LoadConfigStore::validateSet(ensemble, &err), qPrintable(err));
}
void TestLoadModel::testMeasurementThingsAreSoftFields()
{
const QList<QPair<QString, int>> relays{{K1, 500}, {K2, 1000}};
const LoadConfig avant = relayLoad(relays, QStringLiteral("ECS"), 1);
const LoadConfig apres = relayLoad(relays, QStringLiteral("ECS"), 1,
QStringLiteral("{dddddddd-0000-0000-0000-00000000000m}"),
QStringLiteral("{eeeeeeee-0000-0000-0000-00000000000s}"));
// Rattacher un compteur ne touche à AUCUN matériel commandé : pas de reconstruction,
// donc pas de contacts ouverts ni de verrous réarmés à froid pour un geste de mesure.
QVERIFY2(EnergyArbitrator::sameHardware(avant, apres),
"rattacher un compteur ne doit pas reconstruire la charge");
}
QTEST_MAIN(TestLoadModel)

View File

@ -33,6 +33,19 @@ private slots:
//! Libellé, rang et besoins se mettent à jour en place : jamais de reconstruction.
void testSoftFieldsNeverRebuild();
// ── §11 — compteur et sonde par charge ─────────────────────────────────
//! Les deux champs traversent l'aller-retour, et sont OMIS quand ils sont vides.
void testMeasurementThingsRoundTrip();
//! Ils ne sont pas des canaux de commande : ils n'entrent pas dans claimedThingIds().
void testMeasurementThingsAreNotClaimed();
//! Donc deux charges peuvent partager le même compteur et la même sonde.
void testTwoLoadsMayShareAMeter();
//! Et les changer ne reconstruit rien : ils n'ont aucun effet sur le matériel.
void testMeasurementThingsAreSoftFields();
};
#endif // TESTLOADMODEL_H