docs(specs): spec_ecs 0.4.3 + spec_loadmodel 0.1.1

Deux documents normatifs, préalables au lot AGENTS.md qui les référence.

spec_ecs.md 0.4.3 — spécification ECS multi-palier, écrite après audit du code :
  - 0.4.1 : statut réel des binaires de test, portée du point de gouvernance
    AGENTS.md, citation de règle dans ECS-306, recouvrement ECS-411/ECS-412 ;
  - 0.4.2 : étape 2 « type domaine » → « noyau de calcul » (collision avec LM-100) ;
  - 0.4.3 : ECS-110 étendu — la validation ne doit pas reposer sur Q_ASSERT,
    absent du binaire release (QT_NO_DEBUG).

spec_loadmodel.md 0.1.1 — modèle de charges (domaine × mécanisme), intention de
conception. Ne déclenche aucun travail.

Aucun code touché.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-08-08 10:50:50 +02:00
parent c27ccd317d
commit 9624b6ec87
2 changed files with 733 additions and 0 deletions

482
specs/spec_ecs.md Normal file
View File

@ -0,0 +1,482 @@
# SPEC — ECS multi-palier
Version : 0.4.3
Dépôt : `etm-powersync-energy-plugin-etm` (experience-plugin ems)
Branche : `feature/beta-rulebased`
Contrats faisant autorité : `docs/OPTIMIZER_PROTOCOL.md`, `docs/SAFETY.md`, `AGENTS.md`
Ce document est **normatif** et **subordonné à `AGENTS.md`**. En cas de conflit,
`AGENTS.md` gagne et cette spec est corrigée.
Chaque exigence porte un identifiant stable. Un test porte l'identifiant de
l'exigence qu'il couvre. **DOIT** = obligatoire, **DEVRAIT** = recommandé avec
justification si écarté, **PEUT** = optionnel.
Un agent qui découvre une contradiction entre la spec et le code s'arrête et la
remonte. Il ne tranche pas seul.
> **0.4.0** — révision après audit du code réel. Les versions ≤ 0.3.0 nommaient
> `EcsRelayAdapter` (classe supprimée en `5100674`) et attribuaient le choix du
> palier au scheduler. Corrigé ci-dessous. Ne pas se fier à une copie antérieure.
>
> **0.4.1** — passe de vérification de la 0.4.0 contre le code. Quatre
> corrections : statut réel des binaires de test (§9), portée exacte du point de
> gouvernance `AGENTS.md` (§0), citation de règle dans ECS-306 (§3), et
> recouvrement ECS-411 ↔ ECS-412 porté en point ouvert (§13-2).
>
> **0.4.2** — étape 2 renommée « extraction du **noyau de calcul** » : le mot
> « domaine » entrait en collision avec `spec_loadmodel.md` LM-100, où il
> désigne la couche métier `Ecs`/`Hvac`/`Ev`/`SmartHome`. Seul changement.
>
> **0.4.3** — ECS-110 étendu : la validation ne doit pas reposer sur `Q_ASSERT`,
> absent du binaire release. Constat issu de la consolidation d'`AGENTS.md`.
---
## §0 — Domicile et découpage (tranché)
**ECS-001 — RÉSOLU, négatif, reconfirmé contre le `libnymea` du build.**
`/usr/include/nymea/integrations/integrationplugin.h` : la section `protected`
n'expose que `myThings()`, `hardwareManager()`, `pluginStorage()`,
`apiKeyStorage()`, `setMetaData()`. Pas de `thingManager()`. Un
`IntegrationPlugin` ne peut pas commander un Thing d'un autre plugin.
**ECS-002** — Il n'y a **ni integration-plugin, ni dépôt, ni ThingClass**. L'ECS
ne possède aucun matériel.
**ECS-003 — Découpage réel (rév. 3).** Les classes en vigueur :
| Composant | Fichier | Rôle |
|---|---|---|
| `RuleBasedScheduler` | `etm/scheduler/rulebasedscheduler.cpp` | Alloue le budget de surplus, cascade par `priority` ascendante, arrondit sur les paliers dérivés |
| `RelayRouter` | `etm/adapters/relayrouter.{h,cpp}` | Convertit des watts en palier, tient les verrous `minOn`/`minOff`, écrit les Things `powerswitch` |
| `LoadConfig`, `LoadConfigRelay` | `etm/types/loadconfig.{h,cpp}` | Description d'une charge |
| `LoadConfigStore` | `etm/config/loadconfigstore.{h,cpp}` | Persistance + RPC `Get/SetLoadConfig` |
| `EtmVariableLoadAdapter` | `etm/adapters/etmvariableloadadapter.{h,cpp}` | Charge continue (triac) |
**ECS-004 / ECS-005 — Frontière tenue.** Aucun identifiant de relais ne franchit
la frontière vers le scheduler : seuls des watts dérivés circulent. Cette
propriété est **acquise et non négociable** ; toute évolution la préserve.
> **Point de gouvernance à trancher hors spec — c'est le SCHÉMA d'`AGENTS.md`
> qui est périmé, pas la règle 2.**
>
> Portée exacte, vérifiée : la règle absolue 2 dit « Les LoadAdapters exécutent,
> ils ne décident pas […] **Aucune logique de répartition dedans.** » Le
> `RelayRouter` ne fait **aucune répartition** — il traduit une enveloppe en
> watts vers une combinaison, il ne s'attribue rien (ECS-007 tient). **Telle
> qu'elle est écrite, la règle 2 n'est pas violée.** Aucun agent ne doit conclure
> d'ici qu'il faut réécrire l'architecture.
>
> Ce qui contredit réellement le code, c'est le schéma « ARCHITECTURE CIBLE (non
> négociable) », `AGENTS.md:228-250`, qui affiche encore :
>
> ```
> EvAdapter EcsRelayAdapter SgReadyAdapter BatteryAdapter
> (setpoint, (stage 0/1/2) (state 1-4)
> ```
>
> Deux éléments morts d'un coup : la classe `EcsRelayAdapter` **et** le kind
> `Stage`, tous deux supprimés en `5100674`.
>
> **Décision attendue : redessiner ce schéma dans `AGENTS.md`** (`RelayRouter`,
> kind `Setpoint`, frontière watts↔relais), et non seulement « entériner une
> frontière ». Tant que ce n'est pas fait, le document normatif décrit des
> classes qui n'existent plus.
**ECS-007** — Aucune couche ECS NE DOIT décider quelle part du surplus lui
revient. Acquis (`rulebasedscheduler.cpp:97-121`).
**ECS-008** — L'ECS publie `available` et `energyRemaining` ; Héos les consomme.
L'ems exécute, Héos optimise.
---
## §1 — État des exigences (après audit)
Le code **n'est pas en production client** : ni sur `main` (`f4d5b20`), ni sur un
tag. Il vit sur `feature/beta-rulebased` et `landing-silo`, rattaché au banc
hems. Une modification de comportement ne casse donc aucune installation.
**Décision : ÉTENDRE, pas refondre.** La frontière watts↔relais est posée et
tenue, l'énumération des sous-ensembles est correcte et testée, la config est
externalisée. Le mélange avec nymea est superficiel : trois fonctions pures
(`m_levels`, `stageForPower`, `lockWindow`) noyées dans une classe qui fait
aussi l'I/O — une extraction d'environ 40 lignes, pas une reconstruction.
| Exigence | Objet | Statut |
|---|---|---|
| ECS-001 | Pas d'accès cross-thing | ✅ reconfirmé sur le libnymea du build |
| ECS-100 | Désignation par `ThingId` | ✅ `loadconfig.h:46` |
| ECS-101 | Cas à un seul étage | ✅ `relayrouter.cpp:49-64`, testé |
| ECS-102 | Configuration externalisée | ✅ `LoadConfigStore` + RPC — était marqué ouvert à tort |
| ECS-300 | Palier ≤ budget | ⚠️ vrai hors verrou, faux sous verrou → **ECS-306** |
| ECS-301 | Puissances quelconques, non cascadé | ✅ énumération des 2^N sous-ensembles, testé 1500→2000 |
| ECS-302 | Encodages équivalents | ❌ le second encodage est **jeté**, pas arbitré → structurel |
| ECS-304 | Testable hors nymea | ⚠️ 3 fonctions pures extractibles, le reste couplé |
| ECS-400 | Temporisations non codées en dur | ✅ `minOnS`/`minOffS` de la config |
| ECS-402 | Coupure avant fermeture | ✅ `relayrouter.cpp:240-244`, testé |
| ECS-403 | Clamp lock-aware côté scheduler | ❌ `minStage`/`maxStage` **retirés** du contexte en rév. 2/3 |
| ECS-404 | Anti-oscillation | ✅ recrédit `currentPowerW`, `rulebasedscheduler.cpp:186` |
| ECS-110 | Validation de configuration | ⚠️ partielle |
| ECS-111 | Suppression d'un Thing référencé | ❌ non défini |
| ECS-305 | Compteur de commutations | ❌ absent |
| ECS-410 | Échec d'écriture | ❌ absent, `available` codé en dur |
| ECS-411 | Reprise du palier | ❌ absent |
| ECS-412 | Survie au `rebuild` | ❌ absent (nouveau) |
| §5 | Mesure par charge | ⚠️ demi-pas |
| §6 | Thermique | ❌ absent, et sans canal d'entrée |
**ECS-203 — CLOS.** Aucune ThingClass exposée, donc aucune interface nymea à
implémenter. L'unité interne reste le **watt**.
---
## §2 — Ordre de traitement
L'ordre est normatif : chaque étape conditionne la suivante.
**Étape 1 — corrections de comportement (ECS-306, ECS-412).** Ce sont des
défauts qui produisent un mauvais comportement sur du code qui tourne au banc,
pas des fonctionnalités manquantes. Ils passent devant tout le reste.
> Périmètre d'ECS-412 en étape 1 : **suspendu à l'arbitrage §13-2**, qui peut le
> réduire à `m_lastSwitch` et remonter ECS-411 ici. Ne pas ouvrir l'étape 1 sans
> l'avoir tranché.
**Étape 2 — extraction du noyau de calcul (type pur, sans `ThingManager`).**
Sortir `m_levels`, `stageForPower` et `lockWindow`. Débloque ECS-304 en vrai
unitaire et conditionne l'étape 3.
> Ne pas lire « domaine » ici. `specs/spec_loadmodel.md` LM-100 emploie ce mot
> dans son sens courant — la couche métier `Ecs`/`Hvac`/`Ev`/`SmartHome`, qui ne
> parle à aucun matériel. L'étape 2 ne sort **aucune** classe de ce genre : elle
> extrait le noyau de calcul **interne au mécanisme relais**. Rien à voir.
**Étape 3 — ECS-302 / ECS-305.** Changement de structure de `m_relayMapping`.
Doit précéder tout ce qui s'appuiera dessus.
**Étape 4 — ECS-411 puis ECS-410**, même chemin `applyRelayStage` / `available`,
à faire ensemble. ECS-411 peut remonter en étape 1 selon l'arbitrage §13-2.
**Étape 5 — ECS-110 (complément) et ECS-111.**
**Étape 6 — remontée d'`available` dans `LoadContext`**, avec la mise à jour de
`OPTIMIZER_PROTOCOL.md` qu'exige §9 dans le même lot. Préalable structurel au
thermique.
**Étape 7 — §6 thermique.**
---
## §3 — Corrections de comportement
**ECS-306 — Cohérence du budget avec le palier réellement appliqué.** Le budget
décrémenté par le scheduler DOIT correspondre au palier effectivement appliqué,
y compris lorsqu'un verrou empêche la descente.
Constat : `lockWindow` (`relayrouter.cpp:193-203`) remonte `minStage` à
`m_currentStage` tant que `minOn` n'est pas écoulé, et `qBound` (l. 155) force
alors un palier **au-dessus** du budget ; or le scheduler a déjà retranché le
palier plus bas qu'il avait choisi (`rulebasedscheduler.cpp:223`). Pendant toute
la fenêtre `minOn`, les charges de priorité suivante reçoivent un résidu
**surestimé** — donc l'installation soutire au réseau. Ce n'est pas une décision
d'allocation (ECS-007 tient), c'est un effet de bord sur l'allocation.
Règle visée : **`AGENTS.md` règle absolue 4** — « bornes par adaptateur
**écrêtent TOUTE sortie** de stratégie ». L'écrêtage existe bien (`qBound`,
`relayrouter.cpp:155`), mais son résultat n'est jamais renvoyé à l'arbitre :
c'est exactement le trou. *Ne pas invoquer ici la règle 1* — son mécanisme est
« deux décideurs sur le même surplus », et il n'y en a qu'un. Un agent qui suit
cette piste ira chercher un second décideur qui n'existe pas ; le défaut est un
décalage de comptabilité entre décision et exécution.
Le canal qui rendait le scheduler lock-aware **a existé** : `minStage`/`maxStage`
dans `LoadContextTelemetry`, retirés en rév. 2/3 (`surpluscontext.h:62-64`). La
correction DEVRAIT restaurer un signal de verrou vers le scheduler plutôt que se
contenter de remonter le palier appliqué après coup : la première corrige dans le
cycle, la seconde seulement au cycle suivant.
**ECS-412 — Survie au `rebuild`.** `rebuildLoadAdapters()`
(`energyarbitrator.cpp:137-144`) détruit et reconstruit les adaptateurs à chaque
`SetLoadConfig`, remettant `m_currentStage` **et** `m_lastSwitch` à zéro. Le
palier courant et les horodatages de verrou DOIVENT survivre à un `rebuild`
lorsque la charge concernée n'a pas changé de câblage.
Il ne s'agit pas de confort : les verrous sont de la **protection matérielle**.
Avec un `minOn` de 300 à 600 s sur un ballon thermodynamique, un client qui
réordonne ses priorités depuis l'app réarme les verrous et peut faire
court-cycler son compresseur. Un test DOIT couvrir « `SetLoadConfig` pendant une
fenêtre de verrou active ».
> **Recouvrement avec ECS-411 — ordonnancement à arbitrer (§13-2).** Les deux
> champs à préserver n'ont pas le même statut. `m_currentStage` est
> **redéductible** : si ECS-411 est en place, un `RelayRouter` reconstruit
> retrouve son palier depuis les Things sans code dédié. `m_lastSwitch` ne l'est
> **pas** — l'horodatage d'un verrou n'existe nulle part dans le matériel, et
> c'est justement la part qui protège le compresseur. ECS-412 a donc un
> irréductible (`m_lastSwitch`) et une part que l'étape 4 rendrait gratuite
> (`m_currentStage`). Voir §13-2 pour l'arbitrage.
---
## §4 — Combinatoire (`RelayRouter`)
**ECS-302 — Encodages équivalents.** Un palier DOIT pouvoir porter **plusieurs**
combinaisons de relais, et la combinaison retenue DOIT être celle dont le coût de
transition depuis l'état courant est le plus faible : d'abord le nombre de relais
à basculer, puis le relais dont le compteur de commutations est le moins entamé.
Requalification issue de l'audit : le second encodage n'est pas mal arbitré, il
est **jeté au constructeur**. `relayrouter.cpp:58` fait
`if (!byPower.contains(sum)) byPower.insert(sum, set)` — la première combinaison
rencontrée gagne, c'est-à-dire le masque le plus bas. Sur un câblage
500/1000/1500, le palier 1500 W retient `{R500, R1000}` et `{R1500}` disparaît.
`m_relayMapping` étant un `QList<QList<QString>>`, la structure **ne peut pas**
porter deux combinaisons. ECS-302 est donc un changement de structure, pas
l'ajout d'un critère.
**ECS-305 — Compteurs de commutations.** Chaque relais DOIT porter un compteur
de commutations, exposé en télémétrie. Le relais de plus faible puissance est le
bit de poids faible de la combinatoire et s'usera le premier ; c'est la grandeur
qui décide de la durée de vie des contacteurs.
**ECS-307 — Grain des temporisations.** `minOn`/`minOff` sont aujourd'hui **par
charge** (un unique `m_lastSwitch`, `relayrouter.h:107`), ce qui satisfait
ECS-400. Le grain par relais qu'appelle ECS-305 n'existe pas. Décider en étape 3
s'il est nécessaire, ou si le compteur de commutations suffit à l'observation.
**ECS-308** — Le plafond `MaxRelays = 16` (`relayrouter.cpp:17`) tronque
silencieusement au-delà, avec un warning. Acceptable ; DOIT rester documenté.
---
## §5 — Robustesse d'exécution
**ECS-410 — Échec d'écriture.** `writeRelay` (`relayrouter.cpp:222-236`) jette le
`ThingActionInfo*` retourné par `executeAction` : aucune attente de `finished`,
aucun retour arrière, aucun arrêt total. `available` est codé en dur à `true`
(`relayrouter.cpp:91`). Un relais introuvable produit un warning, et
`m_currentStage` est mis à jour comme si tout avait réussi — **on annonce une
puissance non appliquée**, ce que ECS-410 interdit explicitement.
Comportement exigé : (1) attendre le résultat de `executeAction` ; (2) sur échec,
tenter le retour à l'état précédent ; (3) à défaut, commander l'arrêt total ;
(4) à défaut, `available = false` et cesser toute commande. Le canal existe déjà,
il est simplement ignoré.
**ECS-411 — Reprise du palier.** `m_currentStage` vaut 0 à la construction
(`relayrouter.h:106`) et aucun état de relais n'est relu. Le palier courant DOIT
être déduit de l'état réel des Things.
---
## §6 — Configuration
**ECS-110 — Validation.** `LoadConfig::isValid()` (`loadconfig.cpp:80-127`)
refuse déjà `powerW ≤ 0`, `relays[]` vide, `thingId` vide, `minOnS`/`minOffS`
négatifs. DOIT en outre refuser : **deux étages sur le même Thing**, et un Thing
**absent ou n'exposant pas l'interface attendue**.
Note de conception : `isValid()` ne connaît pas le `ThingManager`, donc la
seconde vérification ne peut pas y vivre. Elle appartient à la construction de
l'adaptateur, pas au type de configuration.
**Validation effective en build release.** Aucune validation de configuration NE
DOIT reposer sur `Q_ASSERT`. Le paquet est construit par `dh --buildsystem=qmake6`
sans `CONFIG += debug` : qmake compile en release, `QT_NO_DEBUG` est défini, et
`Q_ASSERT` disparaît du binaire livré. Une vérification qui n'existe que chez le
développeur n'est pas une vérification — elle donne au lecteur du code
l'impression d'un filet qui n'est pas là chez le client.
Quatre `Q_ASSERT` d'invariant de configuration existent aujourd'hui dans `etm/` :
| Emplacement | Invariant gardé | Peut-il échouer aujourd'hui ? |
|---|---|---|
| `relayrouter.cpp:66` | `m_levels` non vide, `[0] == 0` | **Non** — le masque vide garantit la clé 0 par construction |
| `etmvariableloadadapter.cpp:34` | `powerLevels[0] == 0` | **Non** — déjà refusé par `isValid()` (`loadconfig.cpp:114-115`) |
| `sgreadyadapter.cpp:35` | `states` non vide | **Non** — construction codée en dur |
| `sgreadyadapter.cpp:36` | état 2 présent = **repli sûr obligatoire** | **Non** aujourd'hui — construction codée en dur (`energypluginnymea.cpp:66-76`) |
Aucun ne masque donc de défaut à ce jour : ECS-110 est bien tenu par
`LoadConfig::isValid()`, pas par ces assertions. **Le risque est prospectif et il
est daté** : le jour où `SgReadyAdapter` bascule sur la configuration — travail
déjà inscrit au `DÉFÉRÉ` d'`AGENTS.md``sgreadyadapter.cpp:36` devient le seul
garde-fou sur la présence de l'état 2, l'état de repli sûr du mode dégradé L2,
et ce garde-fou sera absent du binaire livré.
Exigence : toute vérification portant sur des données venant de la configuration
DOIT vivre dans `isValid()` ou dans la construction de l'adaptateur, avec un
chemin d'erreur explicite (refus + message). Les `Q_ASSERT` restants ne sont
admis que sur des invariants **structurels**, impossibles à violer depuis la
configuration — les deux premières lignes du tableau.
**ECS-111 — Suppression d'un Thing référencé.** Comportement non défini
aujourd'hui : `findConfiguredThing` renvoie `nullptr`, `writeRelay` logue et
continue, `telemetry()` saute le relais donc `metered = false` et l'on retombe
sur le nominal commandé — **on publie la puissance d'un relais disparu**. À
définir et tester.
---
## §7 — Mesure par charge
`telemetry()` (`relayrouter.cpp:104-114`) lit déjà `currentPower` sur les Things
relais : c'est un demi-pas vers ECS-500.
**ECS-500** — Calibrage par mesure (enclencher chaque étage seul, mesurer,
écrire la puissance réelle). Justification : P = U²/R, un étage annoncé à 1000 W
sous 230 V délivre environ 1090 W sous 240 V.
**ECS-501** — Détecter un étage commandé dont la puissance mesurée est quasi
nulle, en distinguant le thermostat mécanique ouvert (tous les étages chutent
ensemble) d'un étage en défaut (un seul chute).
**ECS-502** — Un étage en défaut DOIT être retiré des paliers disponibles.
**ECS-503** — Sans mesure, le comportement actuel est conservé tel quel.
**ECS-504** — La mesure par charge NE DOIT PAS être réinjectée dans le calcul du
surplus (`AGENTS.md` règle 8, pas de boucle de feedback). Elle sert au diagnostic
et au recrédit anti-clignotement déjà en place.
---
## §8 — Thermique
Aucune température ni `energyRemaining` dans le dépôt. **Et il manque le canal
d'entrée** : `available` vit dans `LoadTelemetry` (`iloadadapter.h:15`) mais
l'arbitre ne le recopie pas dans `LoadContext`. Même avec une sonde posant
`available = false`, le scheduler ne le verrait pas. C'est un préalable
structurel, indépendant de tout le thermique (étape 6).
**ECS-600** — La sonde est optionnelle ; sans elle, le comportement actuel est
conservé intégralement.
**ECS-601** — Avec sonde, `available` passe à faux quand la consigne est
atteinte, afin que le waterfall libère le budget. C'est le **seul** point
d'entrée du thermique dans l'arbitrage : la charge se retire, elle ne négocie pas.
**ECS-602** — `energyRemaining` publié en kWh (`m · c · ΔT`).
**ECS-603** — Cycle anti-légionelle périodique, indépendant du surplus. Il
consomme du réseau : il DOIT passer par une `LoadAction` explicite avec son
`decisionReason`, jamais par un contournement du budget.
**ECS-604** — La sonde n'est jamais la seule limite haute. Le thermostat
mécanique reste **L0**. Perte de sonde → mode sans sonde, pas arrêt d'urgence.
**ECS-605** — Hystérésis paramétrable par charge, jamais codée en dur.
---
## §9 — Traçabilité (exigences ouvertes)
| Exigence | Type | Test |
|---|---|---|
| ECS-306 | simulation | `testEcsBudgetUnderLock` |
| ECS-412 | simulation | `testEcsRebuildPreservesLock` |
| ECS-302, ECS-305 | unitaire | `testEcsSwitchCost` |
| ECS-304 | unitaire | `testEcsLevelsPure` |
| ECS-410 | simulation | `testEcsPartialFailure` |
| ECS-411 | simulation | `testEcsRestartRecovery` |
| ECS-110, ECS-111 | unitaire | `testEcsConfigValidation` — DOIT s'exécuter aussi en build release (`QT_NO_DEBUG`), sinon il ne prouve rien du binaire livré |
| ECS-501, ECS-502 | simulation | `testEcsStageFault` |
| ECS-601, ECS-602 | simulation | `testEcsTemperatureTarget` |
| ECS-603 | simulation | `testEcsLegionella` |
| ECS-600, ECS-604 | simulation | `testEcsNoSensorFallback` |
Acquis à ne pas refaire : `testEcsRelayTopologies`, `testLoadConfigRelayRouter`,
`testLoadConfigBuildsAdapters`, `testLoadConfigRpc`, `testEcsSurplusPV`,
`testMeterSilentFallback`.
> Des binaires de test datant du 8 juin — antérieurs au `RelayRouter` — traînent
> dans l'arbre de travail. **Rebuild systématique**, jamais de conclusion tirée
> d'un binaire trouvé sur place.
>
> Ils ne sont **pas versionnés** : `git ls-files tests/auto/simulation/nymea-energy-simulation`
> renvoie vide. Ce sont des reliquats de build que `.gitignore` ne couvre pas,
> d'où leur apparition permanente en `??`. Même cas : `qrc_*.cpp`, `moc_*`,
> `target_wrapper.sh`, et les `plugininfo.h` / `extern-plugininfo.h` générés du
> mock. Le remède est donc de **les ignorer ou les supprimer**, pas de « ne pas
> s'y fier ». Hygiène, hors périmètre ECS.
**Définition de fait** : celle d'`AGENTS.md`.
**Critère d'acceptation banc** : profil de surplus réel sur 24 h avec relevé du
nombre de commutations **par relais** (ECS-305 le rend enfin mesurable).
---
## §10 — Hors périmètre
Allocation du surplus, prévision et plan journalier (Héos), contacteur heures
creuses, transport matériel (Waveshare D8, session dédiée), PAC hors SG-Ready.
Signalé au passage, hors ECS : les `ThingId` K1/K2 et les paliers
`{3:1500, 4:3000}` de la PAC du banc sont codés en dur dans
`energypluginnymea.cpp:68-74`. À traiter avec la couche config, pas ici.
---
## §11 — Dette contractuelle à ne pas aggraver
`OPTIMIZER_PROTOCOL.md` ne reflète déjà pas `LoadAction.force`, les fenêtres de
verrou ni `degradedMode`. ECS-306 et l'étape 6 modifient le contexte. **Toute
exigence touchant au contexte ou aux actions DOIT embarquer sa mise à jour du
protocole dans le même lot.**
---
## §12 — Journal des décisions
| Date | Décision |
|---|---|
| 2026-08-06 | ECS multi-palier attaqué en premier ; ECS-simple = son cas à 1 palier |
| 2026-08-07 | Étages déclarés par référence de Thing + puissance par sortie |
| 2026-08-07 | ECS relais et ECS via PAC séparés ; les PAC reportées |
| 2026-08-08 | ECS-001 négatif → pas d'integration-plugin |
| 2026-08-08 | ECS-203 clos : unité interne = watt |
| 2026-08-08 | Audit : `RelayRouter` (et non `EcsRelayAdapter`), code hors production → **étendre** |
| 2026-08-08 | ECS-306 et ECS-412 créés et placés en étape 1 |
| 2026-08-08 | `ioConnections()` écarté formellement |
| 2026-08-08 | Vérification 0.4.1 : règle 2 non violée — c'est le schéma d'`AGENTS.md` qui est périmé |
| 2026-08-08 | ECS-306 rattaché à la règle 4 (écrêtage non remonté), pas à la règle 1 |
| 2026-08-08 | Recouvrement ECS-411 ↔ ECS-412 relevé → §13-2, bloque l'ouverture de l'étape 1 |
| 2026-08-08 | Étape 2 : « type domaine » → « noyau de calcul » (collision avec `spec_loadmodel.md` LM-100) |
| 2026-08-08 | ECS-110 : validation interdite de reposer sur `Q_ASSERT` (absent en release) |
---
## §13 — Points ouverts
1. **Gouvernance** — redessiner le schéma « ARCHITECTURE CIBLE » d'`AGENTS.md`
(`EcsRelayAdapter` et le kind `Stage` y figurent encore ; cf. §0). Le document
normatif décrit des classes supprimées tant que ce n'est pas fait.
2. **Ordonnancement ECS-411 ↔ ECS-412** — bloque l'ouverture de l'étape 1. Deux
lectures :
- **(a) garder l'ordre du §2** : ECS-412 traite `m_currentStage` et
`m_lastSwitch` dès l'étape 1 ; ECS-411 en réécrira la moitié en étape 4.
Fidèle au principe « les défauts de comportement passent devant », au prix
d'un aller-retour.
- **(b) réduire ECS-412 à `m_lastSwitch`** et remonter ECS-411 en étape 1 avec
lui : ils partagent le chemin de reconstruction, et `m_currentStage` devient
gratuit. Évite l'aller-retour, mais fait entrer en étape 1 une exigence que
le §2 justifiait autrement.
Dans les deux cas, `testEcsRebuildPreservesLock` DOIT couvrir « `SetLoadConfig`
pendant une fenêtre de verrou active » : c'est l'irréductible.
3. **ECS-307** — grain des temporisations par relais : nécessaire, ou le compteur
de commutations suffit-il ?
4. **ECS-605** — asymétrie de l'hystérésis, à régler à l'usage.
5. Câblage réel du banc : 500/1000/1500 ou 500/1000/2000. Ne bloque pas le code,
mais le premier est le seul qui exerce ECS-302.
6. **Conflit d'écriture**`connectIO()` est le mécanisme natif si l'on veut un
jour exposer un relais ECS au pilotage manuel de l'utilisateur. Ce serait
alors un conflit d'écriture à arbitrer, pas une aide.
**CLOS par l'audit** : le waterfall ne choisit pas entre encodages équivalents
(il n'en voit qu'un) ; `ioConnections()`/`connectIO()` est un lien 1↔1
état-à-état avec pour seule transformation un booléen `inverted` — il ne peut
exprimer ni le N→1, ni l'arithmétique, ni l'ordre de commutation, ni les
temporisations.

251
specs/spec_loadmodel.md Normal file
View File

@ -0,0 +1,251 @@
# SPEC — Modèle de charges (domaine × mécanisme)
Version : 0.1.1
Dépôt : `etm-powersync-energy-plugin-etm`
Statut : **intention de conception figée. Aucune implémentation immédiate.**
> **0.1.1** — passe de vérification contre le code. Quatre précisions :
> préséance des verrous (LM-303), portée de l'union discriminée (LM-302),
> citation de règle (LM-102), `EvAdapter` inscrit au renommage (LM-601).
> Aucune décision de conception modifiée.
> **Ce document ne déclenche aucun travail.** L'ordre de `specs/spec_ecs.md` §2
> reste seul en vigueur, et l'étape 1 reste bloquée par ses arbitrages §13-1 et
> §13-2. Une seule chose ici est urgente : la **forme du schéma persisté** (§3),
> parce que c'est la seule partie coûteuse à rattraper une fois que le banc et la
> beta auront écrit des données. Le reste s'implémente au fil des besoins.
---
## §1 — Deux couches
**LM-100** — Le modèle de charges se découpe en deux couches indépendantes :
- **Couche domaine**`Ecs`, `Hvac`, `Ev`, `SmartHome`. Détient le modèle de la
charge (état thermique, échéance de départ, cycle), en déduit la demande et
`available`. **Ne parle à aucun matériel.**
- **Couche mécanisme**`Relay`, `Variable`, `SgReady`, `ModbusSetpoint`.
Traduit une enveloppe en watts vers le matériel et tient les verrous.
**Écrite une seule fois, partagée par tous les domaines.**
**LM-101** — Une charge configurée choisit **un domaine et un mécanisme**. Le
domaine ne détermine pas le mécanisme et réciproquement.
**LM-102** — Aucune des deux couches ne répartit de budget. La couche domaine se
retire de l'allocation via `available` ; elle ne la négocie pas. Règles absolues
1 et 2 d'`AGENTS.md` inchangées.
**Justification.** Un nommage par domaine seul (`EcsAdapter`, `HvacAdapter`)
obligerait à écrire l'encodage SG-Ready deux fois (ECS et HVAC), la combinatoire
de relais deux fois (ECS et EV en prise commandée), et le transport Modbus deux
fois (HVAC et EV). Un nommage par mécanisme seul perdrait les comportements
propres au domaine, qui sont réels : stockage différable pour l'ECS, confort
immédiat pour le HVAC, échéance de départ pour l'EV, cycle non modulable pour le
SmartHome.
---
## §2 — Matrice
| Domaine | Mécanismes attendus |
|---|---|
| `Ecs` | `Relay` (résistance à paliers) · `SgReady` (ballon thermodynamique) · `Variable` (triac) |
| `Hvac` | `SgReady` (PAC) · `ModbusSetpoint` (PAC, clim, VMC) |
| `Ev` | `Relay` (prise commandée : GreenUp, Witty) · `ModbusSetpoint` (borne) |
| `SmartHome` | `Relay` — voir §7, sémantique différente |
Les cases vides ne sont pas interdites, elles ne sont simplement pas prévues.
---
## §3 — Schéma de configuration (à figer)
**LM-300** — `LoadConfig` est une **union discriminée** par le mécanisme :
```
LoadConfig {
id, nom, enabled
domaine : Ecs | Hvac | Ev | SmartHome
typeAppareil : affine le domaine (résistif | thermodynamique | …)
groupe, prioritéGroupe, prioritéMembre
mécanisme : Relay | Variable | SgReady | ModbusSetpoint ← discriminant
chargeUtile : LoadConfigRelay | LoadConfigVariable | …
verrous : minOnS, minOffS
facettes[] : voir §4
}
```
**LM-301** — Ajouter un mécanisme DOIT se limiter à un type de charge utile et
une branche de fabrique. Rien d'autre ne bouge.
**LM-302** — Chaque charge utile valide la sienne. Les états invalides doivent
être inexprimables : pas de registre Modbus dans une configuration relais.
**Portée exacte.** L'union discriminée s'applique aux **types C++** et au
**schéma persisté**. Elle ne s'applique **pas** au schéma JSON-RPC : nymea valide
les paramètres *avant* le handler, si bien que deux formes exclusives obligent à
marquer tous les champs spécifiques en optionnel (`"o:"`), faute de quoi l'une
des deux serait rejetée en amont. La contrainte est déjà documentée dans
`nymeaenergyjsonhandler.cpp:155-160` pour deux mécanismes ; elle s'aggrave à
quatre. À la frontière RPC, la validation reste donc **à l'exécution**
(`LoadConfig::isValid()`). Ne pas tenter un schéma RPC strict : c'est un mur
connu.
**LM-303 — Verrous.** `typeAppareil` fournit des **défauts** (résistif ≈ 60 s,
thermodynamique ≈ 300 s), l'installateur peut les modifier, et c'est la valeur
**stockée** qui fait foi. Jamais de temporisation codée en dur.
**Préséance.** Les verrous déclarés au niveau de la **charge** s'appliquent par
défaut. Une charge utile PEUT en porter de plus fins — par relais, par registre —
et ceux-là **priment** sur le défaut de charge, pour le seul actionneur qu'ils
désignent.
Cette règle laisse `spec_ecs.md` §13-3 (ECS-307, grain des temporisations par
relais) entièrement ouvert : ajouter plus tard un champ optionnel dans une charge
utile est rétro-compatible, quelle que soit la réponse. Le schéma persisté n'a
donc pas à être « rendu tolérant » — c'est la préséance qui devait être écrite,
et elle l'est ici.
---
## §4 — Facettes : une machine, plusieurs fonctions
**LM-400** — Une machine physique est **une seule charge** : un rang, un jeu de
verrous, une ligne de budget. Une PAC assurant chauffage et ECS n'est **jamais**
déclarée deux fois.
**Justification.** Deux déclarations produiraient une double allocation du
surplus pour une seule consommation, deux rangs pour un seul comportement, et
deux jeux de verrous sur un seul compresseur — la protection anti court-cycling
tomberait précisément là où elle coûte le plus cher.
**LM-401** — Une charge PEUT porter **plusieurs facettes** au-dessus d'un
actionnement unique. Une PAC mixte porte une facette ECS (consigne, température
ballon) et une facette chauffage, avec un seul mécanisme.
**LM-402** — Les facettes servent à la lecture, au suivi et au calcul
d'`available`. Elles **ne portent pas de rang**. Une charge à deux facettes ne
peut pas être classée « après les chauffe-eau mais avant la clim » : si cette
granularité est nécessaire, il faut deux machines, pas deux entrées.
**LM-403 — Le mode est une sortie, pas une entrée.** SG-Ready applique un niveau
d'encouragement à la machine entière ; le choix de la fonction appartient au
régulateur interne. Le HEMS **observe** le mode courant, il ne le sélectionne
pas. Aucune conception ne DOIT supposer un aiguillage.
Ce que le mode apporte : `available` devient honnête. Machine en mode chauffage
→ la facette ECS n'absorbera rien quoi qu'on encourage → le waterfall passe au
suivant au lieu de le deviner.
**LM-404 — Biais par consigne : option avancée, hors périmètre pour l'instant.**
Monter `hotWaterSetpointTemperature` place le ballon sous consigne et la machine
bascule d'elle-même. C'est indirect, lent, et surtout **persistant** : une
consigne écrite le reste après un plantage ou un redémarrage, et elle écrase un
réglage d'installateur. Toute implémentation future DOIT comporter des bornes
dures, une politique de restauration et un chien de garde. À ne rouvrir que si le
terrain le justifie.
**LM-405 — Décomposition côté ems, pas côté plugin.** Le découpage en facettes
DOIT se faire dans l'ems au-dessus des états plats du thing, et non en forkant
les plugins constructeurs. Chaque constructeur expose une structure différente ;
l'abstraction doit vivre là où elle est indépendante du constructeur, sous peine
de la refaire N fois et de gérer deux formes du même concept.
**LM-406** — Si l'installation n'a pas de chauffe-eau distinct, il n'y a pas de
charge ECS : la facette ECS s'accroche à la charge PAC.
---
## §5 — Groupes et double rang
**LM-500** — Un groupe est une **clé de tri**, jamais un détenteur de budget. Le
waterfall trie sur le couple `(prioritéGroupe, prioritéMembre)` et reste
inchangé. Un groupe qui reçoit une allocation puis la redistribue est un second
décideur (règle absolue 1).
**LM-501** — Classer un ECS, une PAC et une clim les uns par rapport aux autres
**fonctionne déjà** avec la liste plate triée par `priority`. Les groupes ne sont
nécessaires qu'à l'échelle : plusieurs chauffe-eau, plusieurs bornes, plusieurs
zones, quand on veut déplacer une famille entière sans renuméroter.
**LM-502 — Plafond de groupe : optionnel.** Justifié quand les membres partagent
une limite électrique réelle (bornes sur un même câble ou un même abonnement).
Sans objet pour un groupe thermique, dont les membres sont sur des circuits
distincts. C'est le seul point où la boucle du waterfall change vraiment : elle
doit suivre l'engagement cumulé du groupe.
**LM-503** — Deux notions à **ne pas** confondre avec un rang :
- **Exclusion mutuelle** (clim et PAC en opposition dans une même zone) : une
contrainte entre appareils.
- **Équité interne** (trois chauffe-eau à rang égal, dont le troisième resterait
froid tout l'été) : une politique de rotation dans le tri.
**LM-504** — « HVAC » couvre chauffage, ventilation et climatisation ; y ranger
l'eau chaude sanitaire est un abus. Libellé recommandé côté installateur :
« Thermique » ou « Chauffage & eau chaude ».
---
## §6 — Nommage
**LM-600** — Les classes d'actionnement portent le nom du **mécanisme**, jamais
du domaine.
**LM-601** — Un renommage de cohérence est souhaitable. Trois classes sont
concernées, avec des échéances distinctes :
| Classe | Défaut | Débloqué après |
|---|---|---|
| `RelayRouter` | Ne suit pas la convention `*Adapter` de ses voisins | étape 3 de `spec_ecs.md` |
| `EtmVariableLoadAdapter` | Préfixe `Etm` redondant dans un dépôt ETM | étape 3 de `spec_ecs.md` |
| `EvAdapter` | **Nommé d'après un domaine** (§2 range `Ev` en domaine) | **3g** |
`EvAdapter` n'est pas une classe de domaine égarée dans la couche mécanisme :
c'est un **adaptateur de mécanisme mal nommé**. Il parle à l'interface
`evcharger` de nymea — un mécanisme au même titre que `SgReady`. Le correctif est
un renom, pas un redécoupage.
Aucun de ces renommages ne se fait avant son échéance. Pour `RelayRouter`,
renommer juste avant la restructuration de `m_relayMapping` double le bruit dans
l'historique ; pour `EvAdapter`, le câblage lui-même change en 3g (l'adaptateur
n'est aujourd'hui pas dispatché), et renommer une classe dont le branchement va
bouger produit le même bruit pour rien.
`BatteryAdapter` n'entre pas dans ce tableau : LM-700 le traite hors moule.
---
## §7 — Cas hors moule
**LM-700 — Batterie.** Une batterie n'est pas une charge avec un rang : elle est
aussi une **source**, capable de financer les autres charges (grid-funding, 3f).
Elle intervient à un autre moment de la cascade et ne se modélise pas comme un
consommateur classé. Onduleur hybride et AC-coupling sont bien deux mécanismes.
**LM-701 — SmartHome.** Un cycle de lave-vaisselle ne se descend pas à 0 W en
cours de route : c'est un engagement pris au démarrage, pas un verrou `minOn`.
La sémantique d'action n'est pas une enveloppe en watts mais un **décalage
temporel** — démarrer maintenant ou plus tard. À traiter comme un type d'action
distinct, pas comme une variante.
---
## §8 — Points ouverts
1. `typeAppareil` — quelle énumération exacte, et jusqu'où elle affine le
domaine.
2. Représentation persistée des facettes (§4) : imbriquées dans la charge, ou
table séparée référençant la charge.
3. Sémantique d'action du SmartHome (LM-701) : à spécifier avant toute
implémentation, elle ne se déduit pas du reste.
4. Rotation d'équité (LM-503) : politique à définir si le besoin se confirme.
---
## §9 — Ce que ce document ne change pas
- L'ordre de `specs/spec_ecs.md` §2 et ses arbitrages bloquants.
- Les règles absolues 1 à 10 d'`AGENTS.md`.
- Le découpage en vigueur : le scheduler alloue, `RelayRouter` traduit.
- Rien dans le code aujourd'hui.