R7 — decision.level sur le motif hérité, présent exactement quand levels[] l'est. Sans lui chaque client réinvente sa règle de fusion et deux écrans affichent deux motifs pour le même cycle. Un seul niveau aujourd'hui, donc la question ne se pose pas : c'est la raison d'ajouter le champ maintenant plutôt qu'avec le §10 — même argument que R2 sur le coût de déplacement. R1 — l'absence et le zéro épinglés dans les deux sens. Niveau absent = passe non parcourue ; niveau à targetW 0 = passe parcourue, rien à prendre. Publier l'un sans l'autre les rendrait indistinguables. Aujourd'hui la passe unique est toujours parcourue dès qu'une charge est arbitrée, donc c'est le MODE DÉGRADÉ qui porte le cas « aucune passe » — levels[] absent, planification suspendue. Vérifié échouant dans les deux sens : un niveau à zéro omis, et un decision.level publié sans levels[]. LM-1014 — un client teste la PRÉSENCE d'un champ, jamais une version. La règle vient de l'agent app, qui n'a pas retiré son exception EV_GRID_START mais l'a déplacée sur la bonne frontière : elle survit là où la charge utile n'a pas de counts. Ce qui rend une capacité détectable est la charge utile, pas un numéro — une box se rétrograde, un paquet se reconstruit, une branche se déploie hors séquence. Portée aussi en tête d'INTERFACE.md, avec sa conséquence : les versions y datent les changements, elles ne sont pas des conditions à tester. R5, R6, R8 NE SONT PAS REPOUSSÉES — elles sont sans objet, et je l'ai vérifié plutôt que supposé : ECO_FLOOR_MET, ECO_FLOOR_GRID et DRAW_CAP n'existent pas dans le catalogue. Écrire la garde de R6 aujourd'hui poserait un invariant sur un code que rien n'émet, c'est-à-dire un test toujours vert. simulation 35/35, charging 17/17, loadmodel 20/20, spotmarket 7/7, amd64 0/0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
1011 lines
59 KiB
Markdown
1011 lines
59 KiB
Markdown
# Brief agent app — ce que 3g-2 change pour l'écran
|
||
|
||
**Émis par** : l'agent plugin, après 3g-2 · **Date** : 2026-08-27
|
||
**Banc** : `.75`, plugin `1.15.2+etm24`, nymea `1.15.2+202606191336~trixie1`
|
||
**Contexte** : `docs/RELEVE_3g2.md` (ce dépôt) porte les traces machine, cycle par cycle.
|
||
**Contrat** : `INTERFACE.md` fait autorité — ce brief ne fait qu'en signaler les mouvements.
|
||
|
||
> Dix points. Le neuvième était une question ; le dixième répond à la maquette qui y a
|
||
> répondu — et rectifie deux choses, dont une qui change un écran. Le sixième répond à vos quatre constats du 28 août, le septième à votre
|
||
> constat d'usage sur le compteur par charge — et celui-là **bouge un champ du contrat**.
|
||
> Le deuxième **retire une mise en garde** que vous portiez — du code conditionnel
|
||
> peut disparaître. Le cinquième répond à **votre** question sur `targetPercentage`, et c'est
|
||
> celui qui change un écran : l'avancement s'affichera en énergie livrée, pas en pourcentage.
|
||
|
||
---
|
||
|
||
## 1. `loads[] ⊆ GetLoadConfig` est RÉTABLI
|
||
|
||
3g-1 avait rompu l'invariant sans le signaler : les bornes entraient dans `loads[]` de
|
||
`GetLoadTelemetry` **sans figurer dans aucune `GetLoadConfig`**. C'était exactement la charge
|
||
fantôme que le lot B-bis avait supprimée, réapparue par l'autre bout — et vous vous appuyez sur
|
||
cet invariant pour résoudre libellé, domaine et rang de chaque charge publiée.
|
||
|
||
**Depuis `+etm23`, toute borne détectée reçoit d'office une entrée `LoadConfig`.** Sur le banc,
|
||
`GetLoadConfig` est passé de **2 à 4 entrées** au premier cycle après la mise à jour :
|
||
|
||
```
|
||
chauffe-eau adapter=relay-router prio=1 domain=ecs
|
||
PAC banc adapter=sg-ready prio=2 domain=heating
|
||
Simulated wallbox adapter=evcharger prio=1 domain=ev ← nouvelle
|
||
Terra AC Charger (TCP) adapter=evcharger prio=1 domain=ev ← nouvelle
|
||
```
|
||
|
||
Forme d'une entrée `evcharger` : `id` (le ThingId de la borne), `adapter: "evcharger"`,
|
||
`mode: "dynamic"`, `domain: "ev"`, plus `label` / `priority` / `enabled`. **Aucune charge
|
||
utile** — pas de `relays`, pas de `sgReady`, pas de `powerLevels`/`maxPowerW`/`minPowerW`, pas
|
||
de `minOnS`/`minOffS`. Les limites d'une borne viennent du Thing et changent avec le véhicule
|
||
branché ; les déclarer en configuration en ferait une seconde source de vérité.
|
||
|
||
**Rien à faire de votre côté pour que ça marche** : l'aller-retour `GetLoadConfig` →
|
||
`SetLoadConfig` verbatim est **neutre**, vérifié sur machine — l'objet relu est identique et
|
||
aucun adaptateur n'est reconstruit.
|
||
|
||
**Le sens de l'inclusion reste le même** : `GetLoadConfig` peut contenir des charges absentes de
|
||
`loads[]`. Pour une borne, ça veut dire *hors arbitrage* — pas de voiture assignée, mode manuel,
|
||
ou **aucun véhicule branché** (ce dernier cas depuis `+etm24`). Une borne configurée absente de
|
||
`loads[]` se lit « pas arbitrée en ce moment », jamais « perdue ».
|
||
|
||
---
|
||
|
||
## 2. ⚠️ La mise en garde sur `allocatedW` des bornes TOMBE
|
||
|
||
Vous portiez : *« pour une borne, `allocatedW` est la décision de l'arbitre et non l'état de la
|
||
borne »*. C'était juste, et ça ne l'est plus.
|
||
|
||
**Le défaut derrière cette mise en garde** : jusqu'à `+etm22`, l'arbitre décidait et publiait,
|
||
puis `adjustEvChargers()` — le chemin d'exécution hérité de l'amont — re-décidait derrière lui
|
||
et commandait autre chose. La décision publiée n'atteignait pas le matériel.
|
||
|
||
**`+etm23` ferme ça.** Une charge, un commandeur : les actions EV du waterfall sont dispatchées
|
||
vers l'adaptateur, et les bornes ainsi commandées sortent du chemin proxy. Mesuré sur machine :
|
||
9 commandes émises à la borne sur la fenêtre d'observation, **9 par l'arbitre, 0 par le proxy**,
|
||
et 6 cycles sur 6 portent la ligne « commandée par le waterfall, pas de re-décision ici ».
|
||
|
||
**Conséquence pour vous** : `allocationIsCommand` doit rendre `true` **partout**, et les
|
||
branches conditionnelles qui en dépendaient peuvent disparaître.
|
||
|
||
**Une réserve, et elle est petite mais réelle** : l'allocation reste ce que l'arbitre a
|
||
**commandé**, pas ce que la borne **tire**. Un plafond matériel, un véhicule qui refuse ou un
|
||
câble débranché feront toujours diverger les deux. Ce que vous pouvez enfin affirmer, c'est que
|
||
*personne d'autre ne commande*. Pour l'écart commande/réalité, la charge utile `mechanism` de la
|
||
borne porte `chargingEnabled`, `currentA`, `phaseCount` et `pluggedIn` — c'est là qu'il se lit,
|
||
pas dans `allocatedW`.
|
||
|
||
---
|
||
|
||
## 3. Nouvelle clé ARB : `EV_GRID_START`
|
||
|
||
```
|
||
EV_GRID_START { budgetW, floorW, gridW } funding: "grid"
|
||
```
|
||
|
||
**Une borne qui DÉMARRE en soutirant au réseau.** Le surplus ne paie pas son plancher, mais le
|
||
réglage `acquisitionTolerance` (défaut 0,5) autorise l'appoint : à 0,5, une borne qui exige
|
||
4 140 W démarre dès 2 070 W de surplus et tire le reste du réseau.
|
||
|
||
`gridW = floorW − budgetW` est **ce qui est acheté**. C'est le chiffre qui fait de cette ligne
|
||
une décision, et pas un effet de bord : il mérite d'être montré, pas rangé dans un repli.
|
||
|
||
**Comptabilité — c'est le seul cas où l'`allocatedW` d'une charge se partage** entre les deux
|
||
compteurs du budget : `budgetW` vient du surplus et entre dans `budget.allocatedW`, `gridW`
|
||
vient du réseau et entre dans `budget.evReservedW`. `budgetW + gridW == allocatedW`, toujours.
|
||
Si vous réconciliez la somme des allocations avec le budget, c'est la ligne à traiter à part.
|
||
|
||
**Borné par construction** : le démarrage exige du surplus réel et consomme tout le budget
|
||
restant, donc **au plus une borne par cycle** peut soutirer.
|
||
|
||
> **Pas encore vu sur machine.** Guetté six cycles au banc, jamais fabriqué : le budget de la
|
||
> borne est passé de 385 W à 2 350 W sans s'arrêter dans la fenêtre de tolérance. Le motif est
|
||
> éprouvé par la simulation. Prévoyez la clé, mais ne construisez pas d'écran autour d'une
|
||
> chose que le terrain n'a pas encore montrée.
|
||
|
||
Seconde clé du même lot, plus rare : `PHASE_LIMIT { limitW, requiredW }` — c'est la **limite de
|
||
phase** qui interdit, pas le budget. Deux causes, deux gestes : l'une se règle en délestant la
|
||
maison ou en revoyant l'abonnement, l'autre en attendant le soleil.
|
||
|
||
---
|
||
|
||
## 4. Le rang d'une borne est configurable, et il se classe avec les autres
|
||
|
||
`LoadConfig.priority`, comme toute autre charge. **Il n'y a pas de second système de priorité**
|
||
et il n'y en aura pas : un classement propre aux bornes ne saurait pas exprimer
|
||
« VE1 > ECS > VE2 », et deux classements qui ne peuvent pas s'interclasser sont un défaut, pas
|
||
deux fonctionnalités. `ChargingInfo` ne porte donc **aucun** champ de rang.
|
||
|
||
**Le glisser-déposer marche donc sur une seule liste**, bornes comprises, et se pose par
|
||
`SetLoadConfig` comme le reste. Vérifié sur machine, y compris l'inversion de rang entre deux
|
||
charges.
|
||
|
||
Le tri est **total** : à rang égal, l'identifiant départage. Laquelle est servie est donc
|
||
reproductible d'un cycle à l'autre, même avant tout réglage.
|
||
|
||
> **Réserve sur le rang par défaut, et elle vous concerne.** À la création automatique, la borne
|
||
> reçoit « le plus petit rang existant moins un, borné à 1 ». Quand une charge occupe déjà le
|
||
> rang 1 — c'est le cas du banc — l'intention « en tête » **dégénère en égalité** : les deux
|
||
> bornes et le chauffe-eau sont tous à 1. Sans conséquence sur la reproductibilité, mais
|
||
> **l'ordre affiché à l'installateur avant qu'il n'ait rien réglé n'est pas un choix, c'est un
|
||
> artefact**. Ne le présentez pas comme un réglage tant que le point n'est pas tranché côté
|
||
> moteur (`docs/RELEVE_3g2.md` §3.2). Les vrais rangs se posent à la mise en service, par vous.
|
||
|
||
---
|
||
|
||
## 5. `targetPercentage` — ta question est tranchée, et elle change ton écran
|
||
|
||
Tu avais remonté que `targetPercentage` est une cible sans mesure d'avancement : aucune classe
|
||
`evcharger` ne déclare d'état de charge, aucune ne porte d'interface véhicule. **C'est exact** —
|
||
vérifié sur les cinq classes du banc.
|
||
|
||
**Et le code fait pire que « il manque une mesure ».** `targetPercentage` est comparé à
|
||
`carBatteryLevel`, lu sur le Thing *voiture*. Or dans le seul cas où ce mécanisme s'active — une
|
||
voiture manuelle, dont `batteryLevel` est **inscriptible** — c'est **le moteur qui écrit cette
|
||
valeur** : il intègre la puissance de la borne, applique un facteur de pertes, divise par une
|
||
capacité déclarée, et réinjecte le résultat dans l'état du Thing. Il compare donc sa cible à sa
|
||
propre estimation, bâtie sur trois grandeurs dont aucune n'est mesurée.
|
||
|
||
**Ce que ça te coûte aujourd'hui** : l'estimation sort par la frontière RPC sous le nom
|
||
`batteryLevel`, **indistinguable d'une mesure**. Un écran qui affiche « 62 % » affiche une
|
||
intégration. Tu mens de bonne foi, et rien dans la charge utile ne te permet de t'en apercevoir.
|
||
|
||
### Les décisions (Patrick, 2026-08-27) — `specs/spec_loadmodel.md` LM-1009
|
||
|
||
**D1 — L'avancement s'affiche en ÉNERGIE LIVRÉE, jamais en pourcentage.** « 8,4 kWh livrés depuis
|
||
le branchement » est vérifiable ; « 62 % » suppose une capacité que personne ne connaît. Le
|
||
pourcentage garde sa place dans la **saisie de l'intention** — l'utilisateur pense sa voiture en
|
||
pourcentage — mais **sort de l'affichage de l'avancement**.
|
||
|
||
**D2 — Obligation par SESSION d'abord.** La source est `sessionEnergy`, l'énergie livrée depuis le
|
||
branchement, publiée par la borne. C'est aussi l'usage réel : on branche le soir pour partir le
|
||
matin. L'obligation *quotidienne* (`minEnergyWhPerDay`) est un lot à part, qui attend
|
||
l'historique du §11 — ne l'anticipe pas dans l'écran.
|
||
|
||
**D3 — L'estimation sera ÉTIQUETÉE à la publication.** Tant que le chemin `carBatteryLevel`
|
||
survit, il ne franchira plus la frontière RPC sous le même nom qu'une mesure. Le précédent existe
|
||
déjà côté moteur : `estimatedPowerW` porte son préfixe exprès.
|
||
|
||
### Ce que ça implique pour toi, concrètement
|
||
|
||
- **Deux régimes de vérité, et il faut pouvoir dire lequel s'applique** (condition C2 de la spec) :
|
||
une obligation en **énergie** peut annoncer son atteinte ; une cible en **pourcentage** publie
|
||
une intention et un avancement *estimé* — **jamais « tenue »**. Un avancement mesuré et un
|
||
avancement estimé ne se distinguent par aucune de leurs valeurs : les deux sont un nombre qui
|
||
monte. Si le régime ne se lit pas, le second se fait passer pour le premier.
|
||
- **Certaines bornes ne publient pas `sessionEnergy`** (condition C1) — mesuré : `wallboxNoMeter`
|
||
et l'une des deux classes `wallbox`. Sur celles-là l'avancement n'est pas mesurable, et
|
||
**l'écran doit le montrer** : une échéance qui cesse silencieusement d'être mesurable est pire
|
||
qu'une échéance absente, parce qu'elle continue de s'afficher. Surtout, ne pas afficher zéro —
|
||
« pas mesurable » et « rien livré » sont deux états différents.
|
||
- **Rien n'est encore codé côté moteur** : `sessionEnergy` n'est lue nulle part et
|
||
`LoadContextTelemetry::sessionWh` est déclarée mais jamais remplie. Le §10 le fera. Ce brief te
|
||
donne la forme d'arrivée pour que tu ne bâtisses pas sur le pourcentage entre-temps.
|
||
|
||
---
|
||
|
||
---
|
||
|
||
## 6. Réponses à vos quatre constats du 2026-08-28
|
||
|
||
### A — la conclusion est retenue, le mécanisme ne l'est pas
|
||
|
||
**Votre conclusion est juste, elle est adoptée, et elle est LIVRÉE** (`spec_loadmodel.md`
|
||
**LM-1208-b**, moteur `1.15.2+etm25`) : « pas de voiture assignée » cesse d'être un critère
|
||
d'exclusion. Vos trois arguments tiennent, et le
|
||
troisième est décisif — **le critère contredit LM-1206**, qui donne le rang et le plancher à la
|
||
borne et non au véhicule.
|
||
|
||
**Mais le budget n'était pas faussé, et c'est votre propre banc qui le montre.** Le budget part
|
||
de la mesure **brute** du compteur (invariant 8) : la consommation d'une borne exclue y est déjà
|
||
déduite. Journal du 28 à 10:49, pendant que la V2C tirait :
|
||
|
||
```
|
||
Current load on root meter: 3531.4W (avant : −2915 W, en export)
|
||
[Arbitre] "chauffe-eau" → "Surplus insuffisant (−3531 W) — chauffe-eau à 0 W"
|
||
```
|
||
|
||
**L'ECS n'a pas été servi par-dessus** — l'arbitre a vu l'import et a refusé. L'exclusion est
|
||
*conservatrice* : la charge exclue retire du budget aux autres, et personne ne le lui rend.
|
||
|
||
> **Votre règle générale est néanmoins retenue**, parce qu'elle a un porteur étroit et réel : une
|
||
> borne exclue du contexte mais **commandée par le proxy** ne reçoit aucun `evReservedW`. Sa
|
||
> puissance commandée et pas encore mesurée n'est réservée par personne. Transitoire — un cycle —
|
||
> et sans effet aujourd'hui, mais **elle mordra au §10**, où la passe éco décide d'acheter.
|
||
|
||
> **Ce qui change pour vous, concrètement** : une borne en mode éco, véhicule branché, **sans
|
||
> voiture déclarée**, est désormais arbitrée — elle reçoit une enveloppe, un rang, et un motif
|
||
> publié dans `GetLoadTelemetry` comme n'importe quelle autre charge. Ce qu'elle n'a pas :
|
||
> l'échéance et la cible d'énergie, qui restent au proxy et supposent un véhicule connu. C'est
|
||
> une **fonction dégradée**, à montrer comme telle — pas une borne en défaut.
|
||
>
|
||
> **Et une conséquence trouvée en implantant, qui vous concerne** (`LM-1208-c`) : sans voiture,
|
||
> le moteur ne connaît pas la capacité du véhicule, donc il **ne bascule plus les phases** et
|
||
> retient le `phaseCount` de la borne. Une borne triphasée reste arbitrée en triphasé — c'est
|
||
> volontaire et conservateur pour le budget. La bascule 1↔3 revient dès qu'une voiture est
|
||
> déclarée.
|
||
|
||
**⚠️ Un écart de MESURE mérite votre attention, et il est distinct** : le compteur racine lit
|
||
**3 250 W** quand la V2C annonce **3 980 W** à elle seule (plus la PAC à 800 W). Environ 1,5 kW
|
||
que le compteur racine ne voit pas. Ce n'est pas un problème d'arbitrage — c'est une question de
|
||
câblage ou de vue SunSpec, de la même famille que l'aveuglement du compteur aux résistances ECS
|
||
documenté dans `AGENTS.md`. **À investiguer pour lui-même** : un budget juste suppose un compteur
|
||
qui voit tout.
|
||
|
||
### B — même cause, mais le mode manuel n'est pas en cause
|
||
|
||
Corriger A ne suffira pas : la V2C est en **`ChargingModeNormal`**, et le mode manuel reste un
|
||
critère d'exclusion **délibéré** — l'utilisateur pilote, l'arbitre s'abstient.
|
||
|
||
**Le vrai point est ailleurs, et il vous concerne** : le mode par défaut d'une borne fraîchement
|
||
détectée **est** `ChargingModeNormal`. Une borne neuve n'est donc jamais arbitrée tant que
|
||
personne n'a choisi un mode éco. C'est correct par construction, mais **invisible** — et « la
|
||
borne ne charge pas toute seule » se lira comme un défaut. C'est un geste de mise en service à
|
||
montrer, pas un correctif moteur.
|
||
|
||
### C — vous pouvez déjà la retirer, aujourd'hui, sans rien ajouter
|
||
|
||
L'entrée résiduelle de la Terra AC **se supprime depuis l'app** : `SetLoadConfig` **remplace
|
||
l'ensemble**, donc renvoyer la liste sans cette entrée la retire. Et elle ne sera **pas
|
||
recréée** : l'auto-provisionnement ne tourne que pour les bornes présentes dans nymea, et le
|
||
Thing a été supprimé.
|
||
|
||
C'est exactement le « geste, pas une suppression d'office » que vous demandez, et il existe déjà.
|
||
|
||
> ⚠️ **CORRECTION du 2026-08-29 — cette limite était énoncée à l'envers.** Nous vous avions
|
||
> écrit que supprimer l'entrée d'une borne dont le **Thing existe encore** la ferait **recréer
|
||
> au cycle suivant**. **C'est faux, et c'est mesuré** : sur `.75`, entrée retirée, Thing vivant,
|
||
> **rien n'a été recréé en 140 s**.
|
||
>
|
||
> La cause : l'auto-provisionnement ne tourne qu'à la **création de l'adaptateur**, pas à chaque
|
||
> cycle. L'adaptateur est construit depuis le **Thing** — il survit à la suppression de
|
||
> l'entrée, donc la borne n'est plus jamais proposée à la configuration.
|
||
>
|
||
> **Ce que cela produisait** : la borne restait **arbitrée** mais **absente de
|
||
> `GetLoadConfig`**, à un rang de queue non réglable — le trou que 3g-2 avait refermé, rouvert
|
||
> par le côté client.
|
||
>
|
||
> **CORRIGÉ en `1.15.2+etm28`** (`LM-1209-e`). Le rattrapage est désormais **cyclique** : la
|
||
> condition est « cette borne n'a pas d'entrée », plus « l'adaptateur vient de naître ». Une
|
||
> entrée supprimée est donc **recréée au cycle suivant**.
|
||
>
|
||
> **Mais elle revient DÉCLASSÉE — et c'est ce qu'il faut montrer à l'installateur.** Elle
|
||
> réapparaît **en queue**, avec `rankOrigin: "auto"` : personne ne l'a classée, son rang est de
|
||
> nouveau un défaut. **Le rang qu'il avait posé est perdu avec l'entrée.**
|
||
>
|
||
> **Donc la suppression d'une entrée n'est PAS un moyen de retirer une borne de l'arbitrage** —
|
||
> elle revient, à une autre place. `enabled: false` est la voie, et lui conserve son rang. Si
|
||
> votre écran propose une suppression, il devrait le dire avant, pas après.
|
||
|
||
### D — instruction reçue, rien ne bouge côté moteur
|
||
|
||
Aucun cas particulier V2C dans le moteur : le contrat reste celui des autres bornes, et **C1 de
|
||
LM-1009 est maintenue** — d'autres bornes n'auront ni `sessionEnergy` ni valeur accumulable.
|
||
|
||
Votre distinction est plus fine que ce que le brief avait anticipé et elle est notée :
|
||
**un champ absent se voit ; un champ qui s'efface se lit comme un zéro.** C'est un troisième
|
||
régime, entre « mesuré » et « pas mesurable » — et c'est précisément pourquoi C2 exige que le
|
||
régime soit *publié* et non déduit d'une valeur.
|
||
|
||
Et `targetPercentage = 80` observé sur la V2C **confirme sur machine** le défaut corrigé en
|
||
`+etm23` : le champ omis retombait à son défaut. Ce n'est plus une déduction.
|
||
|
||
---
|
||
|
||
## 7. Le compteur par charge : vous avez raison, et le modèle bouge
|
||
|
||
*(Votre constat d'usage du 2026-08-28. Tranché : `spec_loadmodel.md` **LM-1105/1106**.
|
||
Contrat : `INTERFACE.md`. **LIVRÉ** en `1.15.2+etm26` — `measuredW` n'existe plus.)*
|
||
|
||
### Le constat est retenu tel quel
|
||
|
||
Désigner une borne comme **son propre compteur** pour qu'elle affiche ce qu'elle mesure déjà
|
||
n'est devinable par personne. Une note d'écran qui explique un réglage est le signe que le
|
||
défaut est dans le **modèle**, pas dans l'app — et c'est de la configuration demandée à
|
||
l'installateur pour compenser ce que le moteur peut déduire. **Le moteur lira l'appareil qu'il
|
||
pilote** dès qu'il porte lui-même l'état de puissance.
|
||
|
||
Une précision de rédaction : la règle porte sur le **Thing**, pas sur le mécanisme. « Les bornes
|
||
mesurent, les relais non » est vrai en pratique et faux en droit — certains `powerswitch`
|
||
portent `currentPower`, et `RelayRouter` sait déjà les sommer.
|
||
|
||
### Vos deux points, tranchés
|
||
|
||
**1. Le compteur explicite reste prioritaire — oui, et c'est écrit.** Mais la raison principale
|
||
n'est pas la précision : c'est qu'**un réglage sans effet est pire que pas de réglage**. Si le
|
||
moteur préférait l'appareil au compteur que l'installateur a pris la peine de désigner, le champ
|
||
deviendrait décoratif. La priorité porte sur la **source**, pas sur la valeur : compteur
|
||
déclaré mais Thing absent → on retombe sur l'appareil, et la charge utile **dit lequel a servi**.
|
||
|
||
**2. Votre second point est le plus important, et il décide de la FORME.** En le vérifiant, on a
|
||
trouvé que le moteur porte **déjà** trois régimes sous un seul nom — et qu'il jette la
|
||
distinction : une borne rend une mesure ; un `relay-router` rend une mesure **ou le palier
|
||
nominal** selon que ses relais mesurent (le booléen est calculé, puis perdu) ; un `sg-ready`
|
||
rend **toujours** une puissance déclarée, jamais mesurée.
|
||
|
||
Donc le régime **se publie**, il ne se déduit pas d'une valeur — c'est votre C2 de LM-1009, mot
|
||
pour mot. `measuredW` disparaît au profit de :
|
||
|
||
```jsonc
|
||
"measurement": { "source": "meter" | "device" | "none", "powerW": 3980.0 }
|
||
```
|
||
|
||
`powerW` est **absent** sous `none`. Et `none` est un état **positif** : « cette charge n'est pas
|
||
mesurable ». C'est lui qui vous permet de **masquer** le bloc de mesure au lieu d'afficher un
|
||
vide sur un `sg-ready` qui ne le sera jamais — et de distinguer « pas mesurable » de « compteur
|
||
en panne », que rien ne séparait.
|
||
|
||
### Ce que la source change pour vos écrans de diagnostic
|
||
|
||
| `source` | Ce qu'un écart commandé↔mesuré autorise à dire |
|
||
|---|---|
|
||
| `device` | **La charge n'obéit pas** — véhicule qui refuse, thermostat ouvert, plafond interne |
|
||
| `meter` | **La charge, le compteur, ou le câblage** — trois hypothèses, l'écart n'en tranche aucune |
|
||
| `none` | **Rien.** Pas d'écart affichable, et surtout pas un « 0 W d'écart » |
|
||
|
||
> Une asymétrie qui va plus loin que « le compteur mesure mieux », et qui vaut d'être dite à
|
||
> l'écran : sous `device`, une **concordance** ne prouve rien — plusieurs wallbox renvoient leur
|
||
> consigne en guise de puissance. Sous `meter`, elle est une **preuve**. `device` détecte la
|
||
> désobéissance ; `meter` est le seul à pouvoir détecter un **mensonge**. C'est pourquoi
|
||
> `meterThingId` reste **autorisé** sur une borne qui publie déjà : à masquer par défaut, pas à
|
||
> interdire.
|
||
|
||
**Et une mise en garde sur la façon d'écrire cette règle chez vous : décidez sur
|
||
`measurement.source`, jamais sur `adapter`.** « Masqué dès que la charge mesure, sauf SG-Ready »
|
||
paraît équivalent et ne l'est pas — un `relay-router` à topologie **mixte** publie lui aussi
|
||
`none`, et vous masqueriez le champ sur un ECS qui ne peut être mesuré **que** par un compteur
|
||
dédié. La règle sûre tient en une ligne, et elle couvre l'exception SG-Ready sans que vous ayez
|
||
à la connaître :
|
||
|
||
```
|
||
si measurement.source == "device" → compteur dédié en réglage avancé (replié)
|
||
sinon → compteur dédié proposé, visible
|
||
```
|
||
|
||
C'est toute la raison d'être de `source` : le régime est **publié** pour ne pas être déduit. Le
|
||
redéduire du mécanisme côté app remettrait chez vous la déduction qu'on vient de retirer du
|
||
moteur — et vous la maintiendriez à chaque mécanisme ajouté.
|
||
|
||
### Deux choses trouvées en implantant, qui changent ce que vous afficherez
|
||
|
||
**`sg-ready` est `none` par CONSTRUCTION, pas par accident de matériel.** Un contact SG-Ready est
|
||
un contact de **signal** : la PAC est alimentée ailleurs, son courant ne traverse jamais K1/K2.
|
||
Même montée sur des relais qui mesurent — c'est le cas de notre banc — la charge reste non
|
||
mesurable par son organe de commande. Pour une PAC, `meterThingId` n'est pas un réglage avancé :
|
||
c'est la **seule** voie vers une mesure, et l'écran devrait le proposer, pas le masquer.
|
||
|
||
**Une topologie de relais MIXTE est `none` aussi.** Un relais mesurant et deux nus donneraient
|
||
une somme partielle — 500 W annoncés là où 1 500 W coulent — et l'écart vous ferait afficher une
|
||
panne qui n'existe pas. Une mesure partielle n'est pas une mesure.
|
||
|
||
> Et un point d'affichage qui vous évitera un faux bug : **`{source: "device", powerW: 0}` est
|
||
> une mesure**, pas une absence. Un routeur de relais au palier 0 mesure réellement 0 W et reste
|
||
> mesurable — la source ne bascule pas vers `none` quand la charge s'éteint. Si elle basculait,
|
||
> votre bloc de mesure apparaîtrait et disparaîtrait au rythme du thermostat.
|
||
|
||
### Votre coût, et il est d'un écran
|
||
|
||
`measuredW: X` → `measurement: {source, powerW: X}` ; son **absence** → `source: "none"`.
|
||
La clé n'est pas doublée pendant une transition, délibérément : deux noms pour une chose
|
||
divergent (c'est la règle qui a fait naître `renderFr`). Prévenez-nous si le calendrier de
|
||
l'app impose l'inverse.
|
||
|
||
---
|
||
|
||
## 8. Le rang d'une charge créée d'office — un champ de plus, et un piège d'écriture
|
||
|
||
*(Décision Patrick, 2026-08-28 : `spec_loadmodel.md` **LM-1209**. Contrat : `INTERFACE.md`.
|
||
Livré en `1.15.2+etm27`.)*
|
||
|
||
**Ce qui change dans le moteur** : une borne détectée entre désormais **en queue** de la liste,
|
||
plus en tête. Elle ne passe devant aucune charge que quelqu'un a classée — une détection ne
|
||
réordonne pas ce qu'un humain a décidé. Corollaire pratique pour vous : **le rang produit est
|
||
toujours libre**, et `priority` est maintenant **unique**, garanti par le contrat au lieu d'être
|
||
supposé.
|
||
|
||
> ⚠️ **`SetLoadConfig` REFUSE désormais en bloc un ensemble à rangs doublés.** Si votre écran de
|
||
> réordonnancement peut produire deux charges au même rang — ne serait-ce qu'un instant, pendant
|
||
> un glisser-déposer — l'écriture entière sera rejetée. À vérifier chez vous.
|
||
>
|
||
> Une configuration **héritée** à rangs doublés, elle, n'est pas amputée : elle est conservée au
|
||
> chargement et signalée au journal. Votre première écriture la répare.
|
||
|
||
**Le champ nouveau : `o:rankOrigin`.** `"auto"` dit « cette entrée a été créée d'office et
|
||
personne n'a jamais classé cette charge » — c'est ce qui vous permet d'afficher **« rang par
|
||
défaut »** au lieu de laisser croire à un choix. `"user"` dit que le rang est assumé. **Absent
|
||
n'affirme rien**, et c'est volontaire : une configuration héritée ne prétend pas que ses rangs
|
||
ont été décidés. N'affichez le badge que sur un `"auto"` **explicite**.
|
||
|
||
> C'est le même motif de conception que `measurement.source`, un cran plus loin : **publier le
|
||
> régime, et ne jamais laisser un silence passer pour une affirmation.** Une énumération plutôt
|
||
> qu'un booléen précisément pour ça — `rankIsDefault: false` affirmerait que le rang a été
|
||
> choisi, alors que c'est ce qu'on ignore.
|
||
|
||
### Le piège d'écriture, et c'est le point à retenir
|
||
|
||
`SetLoadConfig` **remplace en bloc**. Quand l'installateur déplace la charge B, vous renvoyez
|
||
aussi l'entrée de la charge A — et A n'a toujours été classée par personne.
|
||
|
||
**Le moteur protège ce cas** : la marque tombe quand **le rang de cette charge-là** change, jamais
|
||
parce qu'une écriture est arrivée. Vous pouvez donc omettre la clé sans rien casser — l'omission
|
||
**ne lève rien**.
|
||
|
||
Deux conséquences pour vous :
|
||
|
||
- **Pour lever la marque sans déplacer la charge** — « j'ai vu ce défaut, il me va » — envoyez
|
||
`"user"` explicitement. C'est la seule voie.
|
||
- **N'écrivez jamais `"auto"`.** Le moteur refuse l'écriture entière : ce serait affirmer un geste
|
||
qu'il n'a pas fait. Renvoyer verbatim une entrée qui le porte déjà reste licite — l'aller-retour
|
||
`Get` → `Set` n'est pas cassé.
|
||
|
||
> **Vérifié sur `.75` le 2026-08-29, sur une marque FRAÎCHE** — entrée recréée au redémarrage,
|
||
> rang 5, `rankOrigin: "auto"`, **rien déplacé avant l'essai**. **Vous pouvez renvoyer le `"auto"` sans le filtrer** : l'écho
|
||
> verbatim intégral passe, et l'écho verbatim **avec un renommage ailleurs** passe aussi —
|
||
> `EnergyErrorNoError`, la marque survit, aucun rang ne bouge. Un renommage n'est donc **jamais**
|
||
> bloqué par la présence d'une borne auto-provisionnée. Votre filtre reste inoffensif ; il n'est
|
||
> simplement pas nécessaire.
|
||
>
|
||
> **Le seul refus qui vous concerne est l'ÉCHO PÉRIMÉ**, et il est mesuré : si vous déplacez
|
||
> l'entrée (la marque tombe), puis renvoyez un payload mis en cache **avant** ce déplacement —
|
||
> lequel porte encore `"auto"` —, l'écriture est rejetée. C'est correct : ce payload affirme
|
||
> quelque chose qui n'est plus vrai. **Le remède est de relire avant de réécrire**, et le même
|
||
> geste passe ensuite. Un cache de configuration survivant à une écriture est le vrai piège,
|
||
> pas le champ.
|
||
>
|
||
> **C'est ce qui explique nos deux relevés contradictoires.** Notre premier essai suivait un
|
||
> déplacement fait pour borner le refus — la marque était donc tombée, et votre écriture portait
|
||
> un `"auto"` que le persisté ne portait plus : refus correct, cas de l'écho périmé. Sur marque
|
||
> fraîche, sans rien déplacer, l'écho passe. **`sansRankOriginAuto()` peut partir** ; ce qu'il
|
||
> faut à la place est de **relire avant de réécrire**.
|
||
|
||
### Le regroupement par domaine : à l'affichage, jamais à l'écriture
|
||
|
||
L'insertion en queue entrelace les domaines (`ecs, ev, ev, heating, ev`) et votre note
|
||
d'entrelacement s'allume à juste titre — mais la conclusion est l'inverse de celle qui vient
|
||
naturellement. Tranché en `spec_loadmodel.md` **LM-1209-d**.
|
||
|
||
La contiguïté des domaines **n'a jamais été un invariant** : le waterfall trie sur le rang seul,
|
||
et `domain` est une métadonnée que l'arbitre ne lit pas. L'exiger du moteur serait d'ailleurs
|
||
contradictoire avec la raison même de LM-1209 — insérer une charge `heating` en respectant la
|
||
contiguïté décalerait toutes les `ev` suivantes, soit une détection qui réordonne une
|
||
installation qu'un humain a classée.
|
||
|
||
**Le même raisonnement vaut chez vous.** Un regroupement appliqué **à l'enregistrement** est une
|
||
machine qui réordonne ce qu'un humain a décidé, au pire moment : il défait un entrelacement
|
||
délibéré — « l'ECS après la borne 1 mais avant la borne 2 » est un arbitrage légitime, et le §10
|
||
en produira d'autres — et remonterait au rang 3 une borne que le moteur vient de ranger en queue.
|
||
|
||
- **Grouper à l'affichage : oui.** Rien ne s'y oppose.
|
||
- **Grouper à l'écriture : non.** Si le regroupement a de la valeur, qu'il soit un **geste
|
||
explicite** de l'installateur, visible et réversible — jamais une normalisation au moment de
|
||
sauver.
|
||
- **La note d'entrelacement ne devrait pas être un avertissement.** Une liste entrelacée n'a rien
|
||
d'incohérent ; l'annoncer comme un défaut pousse à « réparer » un ordre que personne n'a cassé.
|
||
Au plus une proposition.
|
||
|
||
> **Une ligne pour votre README des sondes** : **un refus arrive en `status: "success"`**, le
|
||
> verdict étant dans `params.energyError`. Une sonde qui teste `status` annonce « accepté » sur
|
||
> une écriture rejetée. C'est désormais en tête d'`INTERFACE.md`, avec l'extrait qui va bien.
|
||
|
||
---
|
||
|
||
## 9. UNE QUESTION POUR VOUS — l'ordre de service apparent, quand le §10 arrivera
|
||
|
||
*(Point 8.5 de `docs/DESIGN_10.md`, dernier point ouvert du lot. `spec_loadmodel.md` LM-1002 et
|
||
LM-1006. **Rien à câbler** : le §10 n'est pas implémenté, et il ne le sera pas avant que cette
|
||
question soit tranchée — c'est une condition de livraison, pas une suite.)*
|
||
|
||
**Ce n'est pas à nous d'y répondre.** Ce que nous vous demandons est du même ordre que la
|
||
maquette des mécanismes pour les six écrans : **dire quelle forme de publication rendrait
|
||
l'écran possible.** Nous fabriquerons ensuite la charge utile qui la sert.
|
||
|
||
### Le fait qui produit le problème
|
||
|
||
Le §10 donne deux niveaux de besoin à une charge : **éco** (le minimum vital — un ballon doit
|
||
voir 50 °C, une PAC ne doit pas geler) et **confort** (le reste). L'arbitre fera **deux passes
|
||
sur la MÊME liste, dans le MÊME ordre** :
|
||
|
||
```
|
||
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 — l'ordre de service posé par l'installateur. La hiérarchie
|
||
éco/confort le **surplombe** : elle dit *quel besoin* passe avant, jamais *quelle charge*.
|
||
|
||
**Et voici la conséquence, qui est voulue et qui aura l'air d'un bug** :
|
||
|
||
> **le plancher éco d'une charge de rang 4 est servi AVANT le confort d'une charge de rang 1.**
|
||
|
||
Formellement c'est un ordre lexicographique sur `(niveau, rang)` — total, dérivé du rang
|
||
existant, pas un second système de priorité. Vu de l'écran, c'est un installateur qui vient de
|
||
poser son ordre et voit le moteur ne pas le suivre. **Sans un écran qui montre les deux passes,
|
||
le premier retour terrain sera « l'ordre n'est pas respecté ».**
|
||
|
||
### Les trois choses à trancher
|
||
|
||
**1. Sur quoi la liste est-elle ordonnée ?** Les deux réponses coûtent quelque chose, et il faut
|
||
choisir laquelle :
|
||
- **par RANG** — l'ordre affiché n'est alors plus l'ordre de service, et il faut le **dire** à
|
||
l'écran ; sinon la liste ment par omission ;
|
||
- **par ORDRE DE SERVICE effectif** — le rang cesse d'être lisible dans la liste, alors que
|
||
c'est le seul champ que l'installateur règle.
|
||
|
||
**2. Une charge apparaît-elle une fois ou deux ?** Une entrée par passe — la même charge à deux
|
||
endroits de la liste — ou **une seule entrée portant deux états** ?
|
||
|
||
**3. Que voit-on d'une charge dont l'éco est servi et le confort non ?** C'est le cas **le plus
|
||
fréquent** en régime normal, et **il n'a aucun équivalent aujourd'hui** : la télémétrie actuelle
|
||
publie une allocation et un motif par charge, un seul de chaque. Ni « servie à moitié », ni
|
||
« servie pour son minimum, en attente pour le reste » ne sont exprimables.
|
||
|
||
### Ce dont vous disposez pour décider
|
||
|
||
Aujourd'hui `GetLoadTelemetry` publie **par charge** : `allocatedW`, `funding`, `available`,
|
||
`decision {code, params}`, `measurement {source, powerW}`, `mechanism`, `lock`. **Un seul motif,
|
||
une seule allocation.** Tout ce qui distinguerait les deux passes reste à inventer — un second
|
||
couple, un niveau sur le motif, une entrée dédoublée : **c'est précisément ce que nous vous
|
||
demandons de nommer**, en partant de l'écran et non de la charge utile.
|
||
|
||
> **Deux garde-fous, pour que la réponse ne se ferme pas sur un détail** :
|
||
> - **Une charge reçoit UNE SEULE commande par cycle** (LM-1003), quelles que soient les passes.
|
||
> Deux passes ne veulent pas dire deux consignes ; si l'écran montre deux lignes, elles
|
||
> décrivent une seule décision.
|
||
> - **Le rang reste UN**, et il n'y aura jamais de `prioritéEco`/`prioritéConfort` (LM-1002,
|
||
> rejet explicite) : deux classements à tenir cohérents dans la tête d'un installateur, c'est
|
||
> la chose que le modèle refuse.
|
||
|
||
---
|
||
|
||
## 10. Réponse à votre maquette — le §10 est débloqué, et deux points à rectifier
|
||
|
||
*(Votre `deux_passes_eco_confort_mockup.html`, lue en entier. Décisions inscrites en
|
||
`spec_loadmodel.md` **LM-1010** ; précédence des motifs et réponse à R4 en `DESIGN_10.md`.)*
|
||
|
||
**Vos trois réponses sont retenues telles quelles**, et votre argument sur la liste est meilleur
|
||
que le nôtre : nous disions « le rang doit rester lisible », vous dites que **l'ordre de service
|
||
n'est pas une propriété de la configuration** — il change d'un cycle à l'autre selon les
|
||
planchers atteints. Une liste triée par lui aurait la colonne vertébrale qui bouge sous le doigt.
|
||
Ce n'est pas le rang qui devenait illisible, c'était la **liste** qui devenait instable. La
|
||
formulation est reprise dans la spec.
|
||
|
||
Vos trois refus sont acquis — pas d'index d'ordre de service, pas de second rang, pas de
|
||
pourcentage. Le premier est le plus important pour nous : **une seconde source de vérité pour une
|
||
donnée déductible finit toujours par diverger.**
|
||
|
||
### Votre question de R4 : les compteurs de budget comptent le DÉCIDÉ
|
||
|
||
`budget.allocatedW` et ses voisins comptent ce que la **cascade a décidé**, jamais ce que le
|
||
matériel a reçu. Le budget est le **registre de la cascade** : ce qui détermine ce que reçoit la
|
||
charge suivante est la valeur décidée. L'arrondi vit **après**, dans l'adaptateur, au dispatch, et
|
||
il est propre à chaque mécanisme — compter le commandé obligerait à **rouvrir le budget après le
|
||
dispatch**, c'est-à-dire à décider deux fois.
|
||
|
||
### ⚠️ Deux rectifications, dont une qui change votre écran
|
||
|
||
**1. `allocatedW` n'est PAS « ce qui a été commandé ».** R4 le pose ainsi ; c'est faux
|
||
aujourd'hui. `buildTelemetry()` publie le plan, et `applyActionsToAdapters()` reçoit le slot
|
||
**`const`** : rien n'est réécrit après le dispatch. C'est mesurable —
|
||
`testUnpluggedChargerTakesNoBudget` attend `allocatedW == 3000` sur une borne dont le courant
|
||
commandé donne 2 990 W.
|
||
|
||
Conséquence pour vous : **`Σ targetW == allocatedW` tiendra exactement**, les deux étant du
|
||
décidé. L'écart que votre carte veut nommer — « 1 400 W décidés → palier 1 500 W » — n'est pas
|
||
entre `targetW` et `allocatedW`. Il est entre **`allocatedW` (décidé)** et la **charge utile de
|
||
mécanisme (appliqué)** : `mechanism.stageW`, `mechanism.setpointW`, `mechanism.currentA`. **Vous
|
||
avez déjà les deux nombres aujourd'hui** — ce sont leurs noms qui étaient inversés.
|
||
|
||
**2. La précédence que vous demandiez est écrite**, et `DESIGN_10.md` en porte le tableau :
|
||
`DRAW_CAP` = l'autorisation est épuisée, le geste est de revoir le plafond ;
|
||
`BELOW_MIN_POWER` = il en reste mais la charge ne sait pas la prendre, le geste est de lire la
|
||
fiche technique. Votre lecture du point de rupture est confirmée : une charge refusée **après**
|
||
lui pour cause de palier ne le déplace pas.
|
||
|
||
### R2 est LIVRÉE — `1.15.2+etm30`
|
||
|
||
`funding` vit dans `levels[]`. **Un seul niveau aujourd'hui, et c'est `"comfort"`** : la passe
|
||
unique d'aujourd'hui est celle qui, au §10, servira le confort depuis le surplus — la passe éco
|
||
est la nouveauté du lot, pas la passe existante rebaptisée.
|
||
|
||
```jsonc
|
||
"allocatedW": 3500,
|
||
"levels": [ { "level": "comfort", "targetW": 3500, "funding": "surplus" } ]
|
||
```
|
||
|
||
**`funding` a DISPARU du niveau de la charge, sans résumé.** Le cas mixte est justement celui qui
|
||
le rendrait indécidable : « mixte » serait un troisième code que personne n'a demandé, « grid si
|
||
l'un l'est » une convention que chaque client redériverait autrement. Le motif `decision`, lui,
|
||
reste — parce qu'on saura dire de quel niveau il parle (R7). **On garde un champ hérité quand on
|
||
peut en dire quelque chose de vrai, jamais par symétrie.**
|
||
|
||
**Et l'identité de réconciliation est écrite dans `INTERFACE.md`**, pour que vous n'ayez pas à la
|
||
reconstruire :
|
||
|
||
```
|
||
Σ charges Σ niveaux dont funding == "surplus" → niveau.targetW == budget.allocatedW
|
||
```
|
||
|
||
Avec **l'unique exception, publiée** : `EV_GRID_START` partage une allocation entre deux
|
||
compteurs, et sa contribution au surplus est `decision.params.budgetW`, pas `targetW`. C'est ce
|
||
que **R3** généralisera ; d'ici là l'exception est dans le contrat, pas dans la tête du lecteur.
|
||
|
||
> `levels[]` **absent** = box antérieure à `+etm30`, **ou mode dégradé** — sans plan, il n'y a
|
||
> pas de financement. Votre repli sur `allocatedW`/`decision` reste donc juste, et il couvre les
|
||
> deux cas.
|
||
>
|
||
> **`+etm30` est posé sur `.75`** : `levels[]` y est en ligne, `entry.funding` a disparu, et
|
||
> l'identité a été vérifiée sur la vraie box — Σ targetW(surplus) = 5 000 = `budget.allocatedW`.
|
||
> C'est donc votre branche neuve qui s'exécute désormais, plus le repli.
|
||
|
||
### LES TROIS NOMBRES — porté au contrat, parce que votre R4 en confondait deux
|
||
|
||
Votre correction a fait apparaître un troisième nombre que ni la maquette ni nous n'avions
|
||
nommé. `INTERFACE.md` porte désormais le tableau :
|
||
|
||
| | Où | Ce qu'il dit |
|
||
|---|---|---|
|
||
| **DÉCIDÉ** | `allocatedW`, `levels[].targetW` | ce que la cascade a alloué |
|
||
| **APPLIQUÉ** | `mechanism.stageW` · `setpointW` · `state` · `currentA` | ce qui a été commandé au matériel |
|
||
| **MESURÉ** | `measurement.powerW` + sa `source` | ce qu'un appareil ou un compteur a vu |
|
||
|
||
Et surtout **ce que chaque écart signifie**, parce que c'est là qu'un écran se trompe de
|
||
diagnostic : *décidé ≠ appliqué* est une affaire de **format** — le mécanisme ne sait pas prendre
|
||
n'importe quelle valeur ; *appliqué ≠ mesuré* est une affaire d'**obéissance**, et ce qu'elle
|
||
autorise à conclure dépend encore de `measurement.source` (sous `device` elle accuse la charge,
|
||
sous `meter` elle ouvre trois hypothèses, sous `none` elle n'est pas calculable).
|
||
|
||
Votre carte « 1 400 W décidés → palier 1 500 W » lit donc le **premier** écart, entre
|
||
`allocatedW` et `mechanism.*` — pas entre `targetW` et `allocatedW`, qui sont égaux par
|
||
construction.
|
||
|
||
### R3 : ce qu'il nous faudra de vous
|
||
|
||
Nous la prenons ensuite, et `EV_GRID_START` est le cas qui la force. Notre intention, à
|
||
critiquer avant qu'on l'écrive : **`funding` dit d'où viennent les watts, il ne dit pas dans
|
||
quel compteur ils tombent** — ce sont deux questions, et R3 demande la seconde. Un niveau
|
||
publierait donc sa ventilation :
|
||
|
||
```jsonc
|
||
{ "level": "comfort", "targetW": 1380, "funding": "grid",
|
||
"counts": { "budget.allocatedW": 900, "budget.evReservedW": 480 } }
|
||
```
|
||
|
||
`Σ counts == targetW`, toujours. L'identité devient calculable **sans exception**, et elle vaudra
|
||
telle quelle quand `draw.committedW` arrivera.
|
||
|
||
**`counts` sera publié TOUJOURS, même à une seule destination.** Sur une charge ordinaire,
|
||
`counts: {"budget.allocatedW": 3500}` est redondant avec `targetW` — et c'est le prix à payer.
|
||
L'omettre vous obligerait à connaître la **destination par défaut**, donc à la déduire ; vous
|
||
auriez alors deux chemins de lecture, dont un qui n'est exercé que sur les charges ordinaires,
|
||
c'est-à-dire presque toutes. Et l'identité deviendrait conditionnelle (« si `counts`, sommer ;
|
||
sinon attribuer `targetW` au compteur par défaut ») — une identité à branche est une identité
|
||
qu'on peut se tromper à écrire, en silence.
|
||
|
||
> C'est la règle qui a payé toute la semaine : **une absence ne doit jamais vouloir dire « le
|
||
> défaut s'applique »**. `measurement.source: "none"` est publié plutôt que la clé retirée ;
|
||
> `rankOrigin` absent n'affirme rien, surtout pas `"user"` ; `levels[]` absent dit « box
|
||
> antérieure », pas « un seul niveau implicite ». Le silence ne porte que l'ignorance.
|
||
|
||
### Et une question que `counts` fait apparaître, sur `funding` lui-même
|
||
|
||
En posant la ventilation, on voit que **R2 a déplacé le problème d'un cran sans l'éliminer pour
|
||
`EV_GRID_START`** : ce niveau-là est financé **en partie au surplus, en partie au réseau**.
|
||
`funding: "grid"` y est donc déjà une approximation — au niveau, comme il l'était à la charge.
|
||
|
||
Nous ne touchons pas à `funding` sans vous : c'est votre champ de réconciliation. Mais il faut
|
||
choisir en connaissance de cause, et les deux ne dérivent pas l'un de l'autre —
|
||
|
||
- **`funding` dit l'ORIGINE** (« ces watts sont achetés »), une intention, voisine du motif ;
|
||
- **`counts` dit le REGISTRE** (« ils sont comptés ici »), une comptabilité.
|
||
|
||
`counts = {allocatedW: 900, evReservedW: 480}` ne dit pas si l'achat était délibéré ;
|
||
`funding: "grid"` ne dit pas où les 900 W sont comptés. **Garder les deux est justifié — à
|
||
condition que ce soit dit**, sinon quelqu'un « simplifiera » un jour en supprimant celui qui a
|
||
l'air redondant.
|
||
|
||
### Vos trois règles sont retenues, et deux corrigent la nôtre
|
||
|
||
**`grid` dès qu'un watt est acheté** — écrit comme **règle**, pas comme approximation
|
||
(`spec_loadmodel.md` **LM-1013**). Votre argument emporte la décision : tant qu'on la décrit
|
||
comme approximative, quelqu'un voudra la rendre exacte, et sur 900/480 le libellé basculerait
|
||
d'un cycle à l'autre sans qu'aucune décision n'ait changé.
|
||
|
||
**Pas de `"mixed"` publié** — et c'est vous qui l'avez prouvé, par votre propre code : tout
|
||
client qui teste `funding == "grid"` a une branche par défaut, et cette branche est « surplus ».
|
||
Une troisième valeur y tomberait **sans erreur** et rangerait au soleil un niveau dont une part
|
||
est achetée. La panne de l'omission, avec un champ présent en plus pour faire croire le
|
||
contraire.
|
||
|
||
**Et votre ligne est au contrat** (**LM-1012**) : *on publie ce qui ne se dérive pas, on dérive
|
||
ce qui se dérive.* Votre `mixed` est légitime **parce qu'**il est dérivable et local ; le nôtre
|
||
serait illégitime **pour la même raison**. Elle explique après coup trois refus qu'on avait pris
|
||
séparément — l'index d'ordre de service, votre `mixed`, et **`stagesW`**, qui est publié
|
||
précisément parce que la combinatoire des paliers **n'est pas** dérivable chez vous. Symétrique :
|
||
`rankOrigin` est publié parce que rien dans un rang ne dit qui l'a posé.
|
||
|
||
### Vos trois demandes : deux confirmées, une qui découvre un blocage
|
||
|
||
**1. Clés opaques, jamais réutilisées pour un sens nouveau — engagement pris.** Une clé
|
||
inconnue de votre version laisse `Σ counts == targetW` vérifiable et s'affiche « destination
|
||
inconnue », jamais zéro. Nous nous interdisons de recycler un identifiant : une clé retirée ne
|
||
revient pas, comme un numéro de règle retiré (`tools/rules-retired.txt`).
|
||
|
||
**2. `counts` publié même à zéro, avec sa destination — confirmé**, et votre mesure de 06:14 est
|
||
la bonne raison : un niveau à `targetW: 0` est bien publié, et `counts: {}` satisferait
|
||
l'identité en perdant la destination. Sur le cas le plus fréquent.
|
||
|
||
**3. Qui fait foi : votre lecture est la bonne, et nous l'écrivons.** `counts` fait foi pour la
|
||
**comptabilité** ; `decision.params` fait foi pour la **phrase du motif**. Ils ne disent pas la
|
||
même chose : `params.budgetW`/`gridW` expliquent *pourquoi* la charge démarre malgré un budget
|
||
insuffisant, `counts` dit *où* les watts sont comptés. Si les deux divergeaient un jour, c'est
|
||
`counts` qui décrit le bilan.
|
||
|
||
### ⚠️ Mais R3 ne peut pas être écrite telle quelle : une de ses trois destinations n'en est pas une
|
||
|
||
En câblant `counts`, nous avons dû regarder ce que chaque compteur reçoit vraiment. **`budget.evReservedW`
|
||
n'est pas un registre d'allocation.** C'est un **terme de correction du budget**, et il mélange
|
||
deux natures :
|
||
|
||
1. la **correction A** — Σ des puissances commandées **non encore mesurées** des bornes servies
|
||
au réseau, calculée *avant* la cascade (`rulebasedscheduler.cpp:90-106`) ;
|
||
2. la **part réseau d'`EV_GRID_START`**, qui est une vraie tranche d'allocation
|
||
(`rulebasedscheduler.cpp:355`).
|
||
|
||
**Conséquence directe pour vous** : une borne servie au réseau qui tire déjà ce qu'on lui a
|
||
commandé contribue `max(0, targetW − mesuré)` = **0** à `evReservedW`, alors que son `targetW`
|
||
vaut 7 000 W. `Σ counts == targetW` serait donc **faux** — et faux de la pire façon : **vrai au
|
||
premier cycle** après un changement de consigne, faux ensuite. Ça passerait nos tests et
|
||
dériverait chez vos clients.
|
||
|
||
**Nous ne livrerons pas `counts` sur cette base.** Ce que R3 a réellement mis au jour, c'est que
|
||
**les watts achetés n'ont aucun registre aujourd'hui** : `evReservedW` corrige le budget de
|
||
surplus, il ne comptabilise pas un achat. Leur compteur, c'est `draw.committedW` — celui que
|
||
votre maquette place au §10 et qui n'existe pas encore.
|
||
|
||
**Donc `draw.committedW` devient un préalable à R3, pas une suite.** Il est petit : la somme des
|
||
`targetW` financés au réseau, publiée à côté de `budget`. Il ne change **aucune arithmétique** —
|
||
nous avons vérifié que la ligne 355 est de la **publication seule**, sans retour dans le calcul du
|
||
budget. Ce qui change est le **sens** d'`evReservedW`, qui redevient ce qu'il est : une
|
||
correction, pas un compteur.
|
||
|
||
**Votre décision, parce que c'est votre identité de réconciliation** — nous proposons :
|
||
|
||
| Destination | Ce qu'elle compte |
|
||
|---|---|
|
||
| `budget.allocatedW` | les watts **de surplus** alloués par la cascade |
|
||
| `draw.committedW` | les watts **achetés au réseau**, quelle qu'en soit la raison |
|
||
| `budget.evReservedW` | **cesse d'être une destination** — redevient une correction du budget, et le contrat le dira |
|
||
|
||
Avec cette forme, `Σ counts == targetW` tient **sans exception**, `EV_GRID_START` comprise
|
||
(900 → `allocatedW`, 480 → `draw.committedW`).
|
||
|
||
### ✅ LIVRÉ — `draw.committedW`, seul, `1.15.2+etm31`
|
||
|
||
```jsonc
|
||
"budget": { … },
|
||
"draw": { "committedW": 480 } // même trame que budget
|
||
```
|
||
|
||
**Vos trois demandes sont tenues.**
|
||
|
||
**1. Par cycle, en watts, même trame que `budget`.** Publié dans le même instantané, jamais
|
||
séparément — sinon l'identité redeviendrait temporelle par un autre chemin.
|
||
|
||
**2. Seul.** `authorisedW`, `remainingW`, `binding`, `perPhaseBound` : **absents**, pas à zéro.
|
||
Votre argument est dans le code et dans le contrat, en toutes lettres — un `authorisedW: 0`
|
||
assorti d'un `binding` par défaut afficherait un plafond qui n'existe pas, avec sa source.
|
||
|
||
**3. La définition d'`evReservedW`**, et elle justifie de changer votre libellé :
|
||
|
||
> **`evReservedW` est une CORRECTION du budget de surplus, pas une réservation.** Somme des
|
||
> puissances **commandées mais pas encore visibles au compteur** pour les charges servies au
|
||
> réseau. Elle **tend vers 0** à mesure que la mesure rattrape la commande, **sans qu'aucune
|
||
> décision n'ait changé** : c'est un rattrapage de **latence**, pas un montant mis de côté.
|
||
|
||
« Réservé au véhicule » affirme donc deux choses fausses : que c'est réservé, et que ça concerne
|
||
le véhicule plutôt que la latence de sa mesure. Quelque chose comme « commandé, pas encore
|
||
mesuré » dirait ce que c'est — mais c'est votre écran.
|
||
|
||
> **Et il n'a plus qu'une nature depuis `+etm31`.** Il recevait aussi la part réseau
|
||
> d'`EV_GRID_START`, une vraie tranche d'allocation ; nous l'avons déplacée dans
|
||
> `draw.committedW`. Sans ce déménagement, aucune définition du champ n'aurait été vraie pour
|
||
> ses deux termes — et vous auriez renommé un libellé sur une définition à moitié fausse.
|
||
|
||
`EV_GRID_START` **cesse d'être une exception** : ses deux moitiés tombent désormais dans des
|
||
compteurs de **même nature** (900 → `budget.allocatedW`, 480 → `draw.committedW`). Le partage
|
||
demeure — c'est un fait — mais il ne s'énonce plus comme une entorse. **Votre sonde peut aller le
|
||
voir sur `.75` dès que nous aurons posé `+etm31`.**
|
||
|
||
### ✅ LIVRÉ — `counts`, et `GetChargingInfos` filtre enfin (`1.15.2+etm32`)
|
||
|
||
```jsonc
|
||
"levels": [ { "level": "comfort", "targetW": 1380, "funding": "grid",
|
||
"counts": { "budget.allocatedW": 900, "draw.committedW": 480 } } ]
|
||
```
|
||
|
||
La forme arbitrée, sans écart : `Σ counts == targetW` toujours · publié **même à une seule
|
||
destination** · publié **même à zéro, avec sa destination** · **clés opaques, jamais recyclées**.
|
||
`counts` fait foi pour la comptabilité, `decision.params` pour la phrase du motif.
|
||
|
||
**Et l'identité ne passe plus par `funding`** — elle somme des **compteurs**, plus des origines :
|
||
|
||
```
|
||
Σ charges Σ niveaux counts["budget.allocatedW"] == budget.allocatedW
|
||
Σ charges Σ niveaux counts["draw.committedW"] == draw.committedW
|
||
```
|
||
|
||
`EV_GRID_START` y entre **sans exception** : 900 → allocation, 480 → soutirage.
|
||
|
||
> **La garantie n'est pas dans sept sites de construction, elle est dans une machine.**
|
||
> `testCountsSumToTarget` **relit la charge utile** — toutes les charges, trois régimes dont
|
||
> l'import où tout vaut 0 — et vérifie l'identité, la non-vacuité de `counts` et l'appartenance
|
||
> des clés au catalogue. Un huitième site de construction qui oublierait `counts` échouerait ici,
|
||
> pas en relecture de code.
|
||
>
|
||
> **Et la contre-épreuve a trouvé un trou dans le test lui-même** : amputer le partage
|
||
> d'`EV_GRID_START` le laissait **passer**, parce que la borne, auto-provisionnée en queue, ne
|
||
> voyait jamais de budget. Corrigé — rang 1, régime `EV_GRID_START` en premier (une fois la
|
||
> borne en charge, son recrédit porte le budget au-dessus du plancher et le régime ne peut plus
|
||
> se produire), et une assertion qui **exige** que le régime ait été exercé.
|
||
|
||
### 🔍 LA TRACE DU PARTAGE — `EV_GRID_START`, charge utile verbatim
|
||
|
||
Vous ne pouvez pas produire ce régime sur le banc : `pluggedIn` est en lecture seule, aucune
|
||
action RPC ne l'écrit, et sans `assignedCarId` la borne reste hors `loads[]`. **Le partage est
|
||
une propriété du CODE, pas du matériel** — le banc n'ajouterait que la confirmation que le
|
||
binaire arm64 fait pareil. Voici donc la sortie de la suite de simulation,
|
||
`testEvGridStartIsAnnouncedAndBounded`, telle quelle :
|
||
|
||
```jsonc
|
||
{
|
||
"budget": { "allocatedW": 900, "evReservedW": 0, "recreditedW": 0,
|
||
"remainingW": 0, "surplusW": 900 },
|
||
"draw": { "committedW": 480 },
|
||
"degradedMode": false,
|
||
"loads": [
|
||
{
|
||
"loadId": "{87432679-b9b7-4f14-986a-089a83c92941}",
|
||
"allocatedW": 1380,
|
||
"decision": { "code": "EV_GRID_START",
|
||
"params": { "budgetW": 900, "floorW": 1380, "gridW": 480 } },
|
||
"levels": [
|
||
{ "level": "comfort", "targetW": 1380, "funding": "grid",
|
||
"counts": { "budget.allocatedW": 900, "draw.committedW": 480 } }
|
||
],
|
||
"measurement": { "powerW": 1380, "source": "device" },
|
||
"mechanism": { "kind": "evcharger", "chargingEnabled": true,
|
||
"currentA": 6, "phaseCount": 1, "pluggedIn": true }
|
||
},
|
||
{
|
||
"loadId": "{bbf6709c-deac-4bf9-a7b9-1281d1aa52f4}",
|
||
"allocatedW": 0,
|
||
"decision": { "code": "SURPLUS_INSUFFICIENT", "params": { "budgetW": 0 } },
|
||
"levels": [
|
||
{ "level": "comfort", "targetW": 0, "funding": "surplus",
|
||
"counts": { "budget.allocatedW": 0 } }
|
||
]
|
||
}
|
||
],
|
||
"timestamp": "2026-06-08T13:01:00Z"
|
||
}
|
||
```
|
||
|
||
**Votre critère, vérifiable ligne à ligne sur ce bloc** :
|
||
|
||
| | |
|
||
|---|---|
|
||
| `params.budgetW` = **900** | → `counts["budget.allocatedW"]` = **900** → `budget.allocatedW` = **900** |
|
||
| `params.gridW` = **480** | → `counts["draw.committedW"]` = **480** → `draw.committedW` = **480** |
|
||
| `Σ counts` = 900 + 480 = **1380** | == `targetW` = **1380** == `allocatedW` |
|
||
|
||
**Les deux moitiés sont dans les deux compteurs, et les deux compteurs sont de même nature** —
|
||
un registre d'allocation et un registre de soutirage. `budget.evReservedW` vaut **0** : il ne
|
||
reçoit plus de tranche d'allocation, il est redevenu la seule correction de latence qu'il a
|
||
toujours été.
|
||
|
||
Et la seconde borne montre l'autre moitié de la règle : `targetW: 0`, `counts` **présent** avec
|
||
sa destination à zéro. C'est le cas le plus fréquent, et c'est celui qu'un `counts: {}` aurait
|
||
perdu.
|
||
|
||
> **Ce que le banc ajoute quand même** : `+etm32` est posé sur `.75`, et l'identité s'y vérifie
|
||
> sur des données réelles — Σ counts[budget.allocatedW] = 1 700 = `budget.allocatedW`,
|
||
> Σ counts[draw.committedW] = 0 = `draw.committedW`. Le régime `EV_GRID_START`, lui, n'y est pas
|
||
> reproductible, et nous ne le fabriquerons pas : `counts` en serait la seule preuve, et il l'est
|
||
> déjà ci-dessus.
|
||
|
||
### `GetChargingInfos` filtre — et c'était le seul cas du namespace
|
||
|
||
Votre relevé était juste : paramètre déclaré au schéma, documenté « for all or a single EV
|
||
charger », et `Q_UNUSED`. **Des trois issues, celle-là était la pire** — un paramètre absent se
|
||
voit, un paramètre refusé se voit, un paramètre ignoré ne se voit pas.
|
||
|
||
Il filtre. Un **identifiant inconnu rend une liste VIDE, jamais une erreur** : toute borne
|
||
configurée a une entrée, donc une liste vide ne peut vouloir dire qu'une chose. La description du
|
||
schéma le dit désormais.
|
||
|
||
> **Balayage du namespace fait**, et le résultat est rassurant : sur les 20 méthodes, 9
|
||
> déclarent des paramètres et **`GetChargingInfos` était la seule** à en ignorer un. La famille
|
||
> était petite. *(Mon premier balayage avait conclu « aucune méthode ne déclare de paramètre » —
|
||
> la regex était fausse. Refait proprement en croisant, pour chaque méthode, les
|
||
> `params.insert` de sa déclaration avec les `params.value` de son corps.)*
|
||
|
||
> Et deux fils antérieurs de votre brief sont **clos**, au cas où ils ne vous seraient pas
|
||
> parvenus : l'**entrelacement des domaines** est tranché — grouper à l'affichage, **jamais à
|
||
> l'écriture** (LM-1209-d) : votre note ne devrait pas être un avertissement, une liste
|
||
> entrelacée n'ayant rien d'incohérent. Et l'**écho de `rankOrigin`** est vérifié sur marque
|
||
> fraîche : l'écho verbatim passe, y compris avec un renommage ailleurs.
|
||
> **`sansRankOriginAuto()` peut partir.**
|
||
|
||
---
|
||
|
||
## 11. R1 et R7 livrées ; R5, R6, R8 attendent le §10 — et pourquoi
|
||
|
||
*(`1.15.2+etm33`. Votre nuance sur la frontière est écrite en `spec_loadmodel.md` **LM-1014**.)*
|
||
|
||
### Votre nuance devient une règle du modèle
|
||
|
||
Vous n'avez pas *retiré* votre exception `EV_GRID_START`, vous l'avez **déplacée sur la bonne
|
||
frontière** : elle survit là où la charge utile n'a pas de `counts`. C'est le bon geste, et
|
||
l'argument vaut au-delà du cas — **ce qui rend une capacité détectable est la charge utile,
|
||
jamais un numéro de version.** Un champ présent décrit ce que la box *sait faire* ; un numéro
|
||
décrit ce qu'on *croit* qu'elle sait faire, et une box se rétrograde, un paquet se reconstruit,
|
||
une branche se déploie hors séquence.
|
||
|
||
C'est désormais une règle du modèle (**LM-1014**), et une consigne en tête d'`INTERFACE.md` :
|
||
chaque champ neuf sera détectable **par sa présence**, chaque absence portera un sens écrit. Les
|
||
numéros de version du contrat **datent** les changements, ils ne sont pas des conditions à
|
||
tester.
|
||
|
||
### ✅ R1 — l'absence et le zéro, épinglés dans les deux sens
|
||
|
||
Votre lecture était juste, et la trame de ce matin en montrait déjà une moitié. Les deux se
|
||
répondent :
|
||
|
||
| | |
|
||
|---|---|
|
||
| **niveau absent** | la passe **n'a pas été parcourue** |
|
||
| **niveau à `targetW: 0`** | la passe **a été parcourue**, rien à prendre — et son motif le dit |
|
||
|
||
Aujourd'hui la passe unique est toujours parcourue dès qu'une charge est arbitrée : **c'est le
|
||
mode dégradé qui porte le cas « aucune passe »** — `levels[]` y est absent, planification
|
||
suspendue. `testLevelAbsenceAndZeroDifferInMeaning` épingle les deux sens, **vérifié échouant**
|
||
dans les deux.
|
||
|
||
### ✅ R7 — `decision.level`
|
||
|
||
```jsonc
|
||
"decision": { "code": "SURPLUS_INSUFFICIENT", "params": {…}, "level": "comfort" }
|
||
```
|
||
|
||
**Présent exactement quand `levels[]` l'est** — en mode dégradé il n'y a pas de passe, donc pas
|
||
de niveau dont le motif parlerait. Un motif sans niveau à côté d'un tableau de niveaux vous
|
||
forcerait à deviner auquel il se rapporte ; un niveau sans tableau serait un renvoi vers rien.
|
||
|
||
### ⏸ R5, R6, R8 — pas repoussées : sans objet aujourd'hui
|
||
|
||
Nous avons vérifié plutôt que supposé : **`ECO_FLOOR_MET`, `ECO_FLOOR_GRID` et `DRAW_CAP`
|
||
n'existent pas** dans le catalogue de motifs. Ce ne sont pas des champs à ajouter à un motif
|
||
existant, ce sont des motifs qui naîtront avec la passe éco et le plafond de soutirage.
|
||
|
||
- **R5** — le régime de LM-1009 est porté par le **niveau éco**. Il n'y a pas de niveau éco.
|
||
- **R6** — `ECO_FLOOR_MET` interdit sous `unmeasurable`. La garde est écrite au contrat et
|
||
**sera testable le jour où le motif existe** ; l'écrire aujourd'hui garderait un invariant sur
|
||
un code que rien n'émet — un test toujours vert, donc un test qui ne protège rien.
|
||
- **R8** — `DRAW_CAP` publie sa source. Le motif n'existe pas, et sa source viendra du transport
|
||
du §14a, qui n'existe pas non plus.
|
||
|
||
> **Le §10 lui-même reste derrière sa précondition** : il ne part pas sans l'**extension du
|
||
> délestage au waterfall** (LM-1006, couplage 1). Si tous les planchers éco se servent au réseau
|
||
> le même matin sans soleil, ils s'additionnent sur le disjoncteur de branchement — et la
|
||
> protection de surcharge d'aujourd'hui ne déleste **que les bornes**. Livrer l'éco sans elle
|
||
> donnerait un plancher capable de faire disjoncter une installation. Ce n'est pas une question
|
||
> d'écran, et c'est pour cela qu'elle n'apparaît pas dans la maquette.
|
||
|
||
- **L'échéance de départ et le tarif dynamique restent au proxy**, financés au réseau
|
||
(`funding: "grid"`, motifs `EV_DEADLINE` / `EV_SPOT_MARKET`). Leur puissance vit dans
|
||
`budget.evReservedW`, pas dans `budget.allocatedW`.
|
||
- **`chargingState` de `ChargingInfo`** garde son sens et reste publié par l'arbitre pour les
|
||
bornes qu'il commande.
|
||
- **`SetChargingInfo` n'est toujours pas partiel** — l'objet remplace le stocké, et un champ
|
||
writable omis retombe sur son défaut. La description publiée le dit désormais, et
|
||
`INTERFACE.md` en donne la table. Lire → modifier → renvoyer complet.
|
||
- **`repeatDays` va de 1 (lundi) à 7 (dimanche)**, pas de 0 à 6. La documentation disait le
|
||
contraire ; le code a toujours refusé le 0.
|