docs(spec): lot E — besoin à deux niveaux, compteur/sonde, plancher (0.2.0)

Trois manques du modèle, spécifiés AVANT implémentation. Rien à coder tant que
Patrick n'a pas relu.

§10 — Besoin à deux niveaux (LM-1000+). L'éco n'est pas un champ nouveau : c'est
`needs {dailyDeadline, minEnergyWhPerDay}` promu au rang de plancher. Deux passes sur
la MÊME liste ordonnée, soit un ordre lexicographique sur (niveau, rang) — dérivé du
rang existant, donc pas un second système de priorité ; la paire
prioritéEco/prioritéConfort est rejetée explicitement. Deux règles que la conception
appelle et que le brief ne demandait pas : une seule commande par charge et par cycle
(sinon deux commutations dans le même cycle, du court-cycling fabriqué par l'arbitre),
et un recrédit anti-clignotement attribué une seule fois.

La frontière est nommée : la passe 1 n'est pas bornée par le surplus mais par une
AUTORISATION DE SOUTIRAGE, objet distinct que le rule-based rendra binaire et le MILP
continu. Et deux couplages sans lesquels la conception est fausse : la limite de
raccordement — les planchers éco s'additionnent sur le disjoncteur, et la protection
de surcharge ne déleste aujourd'hui QUE les bornes — et l'ordre de service apparent,
qu'un plancher de rang 4 passant devant un confort de rang 1 rend contre-intuitif.

§11 — Compteur et sonde (LM-1100+). Sélection par interface nymea, jamais par plugin.
Le compteur vérifie, il ne décide pas : ne pas l'injecter au budget, il est en retard
sur la commande et la charge est déjà comptée au compteur racine — décomposition, pas
source. Un écart de mesure ne pose PAS available:false : ce serait retirer le service
sur un doute de mesure. Et ni le compteur ni la sonde ne sont exclusifs, contrairement
aux relais : LM-201 ne s'applique pas à ce qui mesure, donc validateSet() ne doit pas
les inclure dans son contrôle d'unicité.

§12 — Le plancher (LM-1200+). Le domaine de la consigne devient {0} ∪ [min, max], un
ensemble discontinu. Déclaré pour etmvariableload seul ; dérivé pour les deux autres —
même règle qu'au lot D pour stagesW. La règle d'allocation qui compte : sous le
plancher, allouer 0 et TRANSMETTRE le budget, sinon une charge de rang 1 bloque tout
le waterfall en gardant un budget qu'elle ne peut pas utiliser. Décision arrêtée : la
priorité porte sur la borne, pas sur la voiture ; pas de priority dans ChargingInfo,
qui ne saurait pas exprimer « VE1 > ECS > VE2 ».

§13 — Ordre recommandé, et ce n'est PAS celui de l'énoncé : §11 d'abord (additif,
risque nul, et la sonde constitue l'historique sans lequel les seuils du §10 ne se
règleront jamais — le différer coûte du temps CALENDAIRE, pas du développement), puis
§12 (contenu, débloque 3g puis l'unification du rang), puis §10 en dernier — et son
délestage général dans le même lot, pas après.

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:24:39 +02:00
parent 5229fdb06e
commit c5bed1f26d

View File

@ -1,12 +1,18 @@
# SPEC — Modèle de charges (domaine × mécanisme)
Version : 0.1.4
Version : 0.2.0
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.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
> structures déjà figées. **Aucun code ne suit ce document** — l'ordre d'implémentation
> recommandé est en §13, et il n'est pas celui dans lequel les trois ont été demandés.
> **0.1.4** — LM-201 et LM-202 ajoutés (§2). **LM-201 est la règle qui manquait** : une
> charge existe quand il y a un **canal de commande distinct pour un service**. Un canal, une
> charge. Elle repose la question du domaine, que 0.1.3 posait mal — le domaine ne décrit pas la
@ -451,7 +457,16 @@ distinct, pas comme une variante.
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.
5. **`heating` vs `hvac` — ce que ces deux codes désignent.** Question **ouverte, et
5. **Autorisation de soutirage (LM-1005).** Quel objet exactement — un booléen d'horloge, une
fenêtre tarifaire, un coût plafond ? Le rule-based et le MILP n'en attendent pas la même
forme, et la choisir trop tôt contraindrait le second.
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.
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
**séparées** — donc où le canal de commande cesse d'être unique, et où LM-201 rend un
@ -469,3 +484,297 @@ distinct, pas comme une variante.
- 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.
> Les §10 à §13 qui suivent sont **postérieurs** à ces deux sections de clôture : ils ont été
> ajoutés en 0.2.0 sans renuméroter le document, pour ne pas casser les renvois existants vers
> « §8-1 ». Les points ouverts qu'ils soulèvent ont été ajoutés au §8.
---
## §10 — Besoin à deux niveaux : éco et confort
> **Statut : intention de conception.** Rien de ceci n'est implémenté. Le modèle actuel n'a
> qu'un seul niveau de besoin, et il est implicite : *tout ce que le surplus permet*.
**LM-1000 — Une charge thermique n'a pas un besoin binaire.** Elle en a deux, de natures
différentes :
- **Éco** — un **plancher à atteindre quoi qu'il arrive**. Un ballon qui n'a pas vu 50 °C de
la journée doit les voir, au réseau s'il le faut. C'est une **obligation d'énergie sur un
horizon**, pas une préférence d'allocation.
- **Confort** — un **bonus**, servi seulement quand le surplus abonde. Monter le même ballon
à 60 °C parce qu'il y a du soleil. Ne rien servir n'est pas un échec.
Les deux ne se distinguent pas par leur quantité mais par ce qui arrive **quand on ne les
sert pas** : rien pour le confort, une obligation reportée pour l'éco.
**LM-1001 — L'éco n'est pas un champ nouveau : c'est `needs` promu.** `LoadConfig` porte déjà
`needs { dailyDeadline, minEnergyWhPerDay }`. C'est **exactement** la forme d'un plancher éco :
une énergie, une échéance. Ce qui manque n'est pas la déclaration du plancher — c'est la
**cible de confort** et la **place des deux dans le waterfall**.
> N'ajouter un `ecoNeeds` séparé serait dupliquer `needs` sous un autre nom, et il faudrait
> ensuite expliquer lequel des deux fait foi.
**LM-1002 — Deux passes sur la MÊME liste ordonnée. Pas de second rang.**
```
passe 1 — ÉCO : parcourir les charges par rang croissant, servir leur plancher éco
passe 2 — CONFORT : reparcourir la MÊME liste, dans le MÊME ordre, avec le reliquat
```
Le rang garde un sens unique et un seul — l'ordre de service que l'installateur a posé. La
hiérarchie éco/confort le **surplombe** : elle dit *quel besoin* est servi avant, jamais
*quelle charge* passe devant.
**Ce que cela vaut, formellement** : un ordre lexicographique sur `(niveau, rang)`, où
`niveau ∈ {éco, confort}`. C'est un ordre **total**, dérivé du rang existant — pas un second
système de priorité.
> **Rejeté explicitement** : une paire `prioritéEco` / `prioritéConfort` par charge. Elle
> permettrait de contredire le rang, elle divergerait de lui à la première modification, et
> elle obligerait l'installateur à tenir deux classements cohérents dans sa tête.
**LM-1003 — Deux passes, mais UNE SEULE commande par charge et par cycle.** Les passes
calculent des **cibles**, elles ne commandent pas. La consigne appliquée à une charge est la
**somme** de ce que les deux passes lui ont attribué, arrondie une seule fois au mécanisme
(palier de relais, état SG-Ready, consigne continue), puis émise une seule fois.
C'est la règle qui empêche le défaut le plus probable de cette conception : un relais commandé
en passe 1 puis recommandé en passe 2, donc deux commutations dans le même cycle — du
court-cycling fabriqué par l'arbitre lui-même.
**LM-1004 — Le recrédit anti-clignotement s'attribue UNE fois.** La correction B rend à chaque
charge sa consommation de début de cycle avant d'arrondir. Avec deux passes, ce crédit doit
être attribué à la **première** passe où la charge apparaît, jamais aux deux : compté deux
fois, il fabriquerait du budget qui n'existe pas.
**LM-1005 — La frontière que ce modèle franchit, et il faut la nommer.**
Jusqu'ici l'arbitre **distribue un surplus**. Un plancher éco non atteint au coucher du soleil
signifie **soutirer au réseau** — donc décider d'**acheter**. L'arbitre cesse d'être un
répartiteur pour devenir un acheteur, et cela ouvre trois questions qu'il n'avait jamais eu à
poser : à quel **prix**, à quelle **heure**, sous quel **signal tarifaire** (heures creuses,
Tempo).
- **Le rule-based franchira cette frontière grossièrement**, et c'est acceptable : « plancher
non atteint à 22 h → chauffer ». Une règle d'horloge, pas une optimisation.
- **Le MILP la franchira vraiment** : c'est précisément le problème qu'il sait résoudre —
placer une énergie obligatoire dans une fenêtre, au coût minimal, sous contraintes.
**Conséquence de conception** : la passe 1 n'est **pas** bornée par le surplus. Elle est bornée
par une **autorisation de soutirage** — un objet distinct, que le rule-based rendra binaire et
que le MILP rendra continu. La passe 2, elle, reste strictement bornée par le reliquat de
surplus. **Ne pas confondre les deux bornes** : c'est ce qui distingue un plancher d'un bonus.
**LM-1006 — Deux couplages à traiter en même temps, sinon la conception est fausse.**
1. **Le raccordement.** Si tous les planchers éco se servent au réseau le même matin sans
soleil, ils s'additionnent sur le disjoncteur de branchement. La passe 1 doit donc être
bornée par la **limite de raccordement** — aujourd'hui `phasePowerLimit`, dont la protection
de surcharge ne déleste **que les bornes de recharge**. Éco et délestage général sont le
même lot ; les séparer livrerait un plancher capable de faire disjoncter une installation.
2. **L'ordre de service apparent.** Un plancher éco de rang 4 passe devant un confort de
rang 1. C'est voulu, et c'est **contre-intuitif** pour l'installateur qui vient de poser son
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-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
permet de l'implémenter sans migration de configuration.
---
## §11 — Compteur et sonde par charge
> **Statut : intention de conception.** Deux champs optionnels, de **même forme**, et dont
> l'arbitre ne se sert **pas pour décider** dans ce lot.
**LM-1100 — Deux champs, une même règle.**
```
LoadConfig {
…
o:meterThingId : ThingId — mesure de CETTE charge
o:sensorThingId : ThingId — grandeur physique du service rendu (température)
}
```
**Sélection par interface nymea, jamais par plugin.** Le compteur se choisit parmi les Things
portant `energymeter` / `smartmeter` ; la sonde parmi celles portant l'interface de température.
Nommer un plugin dans le modèle le rendrait faux au prochain plugin qui rend le même service —
et obligerait à maintenir une table à la main, ce que LM-600 refuse déjà pour les mécanismes.
**LM-1101 — Le compteur sert à VÉRIFIER, jamais à décider.** Commandé 3 000 W, mesuré 0 W sur
trois cycles consécutifs : la charge ne fait pas ce qu'on lui demande, et c'est **la seule
chose** que la mesure autorise à conclure.
**Ne PAS injecter la mesure dans le budget.** Deux raisons, et chacune suffit :
1. **Elle est en retard sur la commande** — scrutation Modbus, moyennage interne du compteur.
L'injecter brute réintroduirait exactement l'oscillation que le recrédit de la correction B
a supprimée : commander, mesurer trop bas, recommander, dépasser, corriger.
2. **La charge est déjà comptée dans le compteur racine.** Le compteur de charge est une
**décomposition** de ce que le racine mesure, pas une source à additionner. L'ajouter
compterait la même énergie deux fois.
**LM-1102 — Un écart de mesure n'est pas un défaut d'écriture.** `WRITE_FAILED` dit « l'échelle
de commande est épuisée » ; un écart de mesure dit « la commande est partie, l'effet n'est pas
là ». Deux causes, deux gestes chez le client : l'une envoie au bus, l'autre au disjoncteur
divisionnaire ou à la résistance.
**L'écart NE DOIT PAS poser `available: false`.** Sortir une charge de l'arbitrage sur un doute
de **mesure** — alors que la commande, elle, est peut-être partie — coûterait le service sur un
soupçon. Il se publie comme **diagnostic**, à côté de `faultCode` et sans le remplacer.
> Le seuil et le nombre de cycles sont des **paramètres**, pas des constantes : un compteur
> lent et une charge à forte inertie produiraient sinon un diagnostic permanent. Trois cycles
> est un point de départ, pas une valeur figée.
**LM-1103 — La sonde est en lecture seule, et son vrai usage est différé.** Publiée en
télémétrie, **aucun effet** sur les décisions. Deux usages, dont le second est le plus
important :
1. **Diagnostic de mise en service** — l'installateur voit la température monter pendant qu'il
commande, ou ne pas monter.
2. **Constituer l'historique** — température, puissance, heure. **Sans lui, les seuils éco et
confort du §10 ne pourront jamais être réglés autrement qu'au doigt mouillé.** Un ballon ne
révèle sa constante de temps qu'observé, et une PAC ne révèle sa courbe qu'à travers une
saison. C'est la raison pour laquelle ce champ mérite d'exister **avant** d'avoir un
consommateur : le jour où le §10 s'implémente, il faut déjà des données.
**LM-1104 — Ni le compteur ni la sonde ne sont exclusifs, contrairement aux relais.** LM-201
(« un canal de commande, une charge ») ne s'applique pas ici : ces Things ne **commandent**
rien, ils mesurent. Deux charges peuvent légitimement partager une sonde de pièce, et un
compteur de tableau divisionnaire peut couvrir plusieurs charges.
**Conséquence directe** : `validateSet()` ne doit **pas** inclure `meterThingId` ni
`sensorThingId` dans son contrôle d'unicité des Things. Les y ajouter par symétrie avec
`relays[]` refuserait des configurations correctes.
---
## §12 — Le plancher de modulation
> **Statut : intention de conception.** C'est le **préalable à l'étape 3g** — la
> transplantation du VE dans le waterfall.
**LM-1200 — Le modèle n'exprime aujourd'hui qu'une enveloppe proportionnelle.** Une charge
modulable est décrite par `maxPowerW`, et l'arbitre suppose qu'elle accepte n'importe quoi
entre 0 et ce plafond. **C'est faux pour la plupart du matériel réel** :
- une borne de recharge **ne charge pas sous 6 A** — sous ce seuil elle ne démarre pas, ou
démarre et s'arrête ;
- les **paliers SG-Ready sont un plancher déguisé** : l'état 3 consomme ce qu'il consomme, il
n'existe pas d'état « 3 à moitié » ;
- un routeur de relais a le même plancher implicite : son plus petit palier non nul.
**LM-1201 — Spécifier : rien, ou au moins X.** Une charge modulable porte un minimum non nul :
```
adapter = "etmvariableload" → { mode: "dynamic", maxPowerW, o:minPowerW }
```
Le domaine de la consigne cesse d'être `[0, maxPowerW]` pour devenir
**`{0} ∪ [minPowerW, maxPowerW]`** — un ensemble discontinu. C'est la seule façon de dire ce
que le matériel fait vraiment.
**LM-1202 — Le plancher existe pour les trois mécanismes ; il n'est déclaré que pour un.**
| Mécanisme | Plancher | Origine |
|---|---|---|
| `etmvariableload` | `minPowerW` | **déclaré** — rien ne permet de le deviner |
| `relay-router` | plus petit palier non nul | **dérivé** de `relays[]` |
| `sg-ready` | `estimatedPowerW` de l'état 3 | **dérivé** de la table d'états |
Ne pas déclarer ce qui se dérive : c'est la règle que le lot D vient d'appliquer aux paliers
(`stagesW`), et pour la même raison — deux sources d'une même vérité divergent.
**LM-1203 — Un budget inférieur au plancher ne doit pas être retenu en otage.** Règle
d'allocation :
```
si budget_disponible < plancher(charge) → allouer 0, et TRANSMETTRE le budget à la suivante
```
Sans cette règle, une charge de rang 1 au plancher élevé bloquerait tout le waterfall en aval
en gardant un budget qu'elle ne peut pas utiliser.
**LM-1204 — Pourquoi c'est le préalable à 3g, et pas un raffinement.**
Deux bornes, 2 kW de surplus, plancher de 1,38 kW chacune (6 A monophasé) :
- **répartition égale** : 1 kW à chacune → **les deux sont sous leur plancher, rien ne charge,
et les 2 kW partent au réseau** ;
- **waterfall** : 1,38 kW à la première, le reliquat de 0,62 kW à la seconde qui n'atteint pas
son plancher et reçoit donc 0 → **une voiture charge**.
Le proxy actuel, qui décide du VE **en amont** du waterfall, ne peut pas produire le second
comportement : il n'a pas de notion de reliquat à passer. **C'est le plancher qui rend la
transplantation nécessaire, pas l'inverse.**
**LM-1205 — La machinerie d'échéance existe déjà.** `needs { dailyDeadline, minEnergyWhPerDay }`
est ce qui avait motivé le proxy — la crainte que le waterfall ne sache pas exprimer « cette
voiture doit être chargée à 7 h ». Il sait : c'est le même champ que le plancher éco du §10
(LM-1001). Une fois le VE dans le waterfall, il hérite du modèle de besoin commun.
**LM-1206 — La priorité porte sur la BORNE, pas sur la voiture. Décision arrêtée.** Il n'existe
pas de communication véhicule aujourd'hui : rien ne permet de savoir *quelle* voiture est
branchée, ni de lui attribuer un rang. Le rang appartient donc au **point de charge**, comme il
appartient à un chauffe-eau.
`assignedCarId` existe déjà dans `ChargingInfo` : le jour où la communication véhicule arrive,
la migration du rang de la borne vers le véhicule reste possible **sans casser l'acquis**.
**LM-1207 — Ne PAS ajouter de `priority` à `ChargingInfo`.** Ce serait un **second système de
priorité**, et il serait immédiatement insuffisant : il ne saurait pas exprimer
**« VE1 > ECS > VE2 »**, puisqu'il ne classerait que les bornes entre elles. Deux classements
séparés qui ne peuvent pas s'interclasser ne sont pas deux fonctionnalités, c'est un défaut.
Le rang du VE doit vivre **là où vivent tous les rangs** : dans `LoadConfig.priority`. C'est
une conséquence directe de 3g, et une raison de plus de ne pas le différer.
---
## §13 — Ordre d'implémentation recommandé
**Ce n'est pas l'ordre dans lequel les trois manques ont été énoncés**, et la raison tient à ce
que chacun coûte et rapporte.
**1. §11 — compteur et sonde.** *Le moins cher, et il conditionne le reste.*
Purement **additif** : deux champs optionnels, aucune décision d'arbitrage modifiée, aucune
migration de configuration. Le risque de régression est proche de zéro.
Mais surtout : **la sonde constitue l'historique sans lequel les seuils du §10 ne pourront pas
être réglés.** Un ballon révèle sa constante de temps en quelques jours, une PAC en une saison.
Chaque semaine où ce champ n'existe pas est une semaine de données qu'on n'aura jamais.
Le différer ne coûte pas du temps de développement — il coûte du **temps calendaire**.
**2. §12 — le plancher.** *Contenu, et il débloque 3g.*
Le plancher est **dérivable** pour deux mécanismes sur trois : seul `etmvariableload` demande un
champ. Le vrai travail est la règle d'allocation LM-1203 (« sous le plancher → 0, et on
transmet »), qui est locale au waterfall et se teste sans matériel.
Il débloque 3g, qui débloque à son tour l'unification du rang (LM-1207). Trois choses attendent
derrière une seule.
**3. §10 — éco / confort.** *Le plus gros, et le seul qui franchisse une frontière.*
À faire en dernier, pour trois raisons :
- il **change le waterfall lui-même**, alors que les deux précédents l'entourent ;
- il fait de l'arbitre un **acheteur**, ce qui ouvre le prix, l'heure et le signal tarifaire ;
- **il est indissociable du délestage général** (LM-1006-1), qui n'existe pas encore : la
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.
**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.
---