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:
parent
9445f60f1d
commit
bbb3492c2c
46
INTERFACE.md
46
INTERFACE.md
@ -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 |
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
BIN
tests/auto/simulation/heaptrack.nymea-energy-simulation.5599.zst
Normal file
BIN
tests/auto/simulation/heaptrack.nymea-energy-simulation.5599.zst
Normal file
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user