19 Commits

Author SHA1 Message Date
Patrick Schurig
ddaeafbc05 docs(§10): 8.2 et 8.4 tranchés, 8.5 posée à l'agent app
Quatre des cinq points du §10 sont désormais tranchés. 8.2 : le plafond §14a
porte les deux formes — par phase et au total — sans jamais convertir. 8.4 : la
cible de confort reste un comparateur, le dosage attendant le modèle thermique du
§11. Confirmations, rien de rouvert.

8.5 reste, et il ne se tranche pas ici : c'est l'ordre de service APPARENT. Un
plancher éco de rang 4 passe devant un confort de rang 1 — voulu, lexicographique
sur (niveau, rang), et contre-intuitif pour l'installateur qui vient de poser son
ordre. La question est POSÉE à l'agent app dans le brief §9, pas répondue.

Trois choses lui sont données pour décider : sur quoi la liste est ordonnée — par
rang, et l'ordre affiché n'est plus l'ordre de service, ce qu'il faut dire ; ou
par ordre de service, et le rang cesse d'être lisible. Une entrée par passe ou
une entrée à deux états. Et ce qu'on voit d'une charge dont l'éco est servi et le
confort non — le cas le plus fréquent, et le seul qui n'ait AUCUN équivalent
aujourd'hui : la télémétrie publie un motif et une allocation, un seul de chaque.

Ce qui lui est demandé est de dire quelle FORME DE PUBLICATION rendrait l'écran
possible, comme la maquette des mécanismes l'a fait pour les six écrans — pas de
câbler quoi que ce soit, le §10 n'étant pas implémenté. Deux garde-fous joints
pour que la réponse ne se ferme pas sur un détail : une charge reçoit une SEULE
commande par cycle quelles que soient les passes (LM-1003), et le rang reste UN —
pas de prioritéEco/prioritéConfort, rejet explicite de LM-1002.

Et la conséquence est écrite des deux côtés : le §10 ne se code pas avant cette
réponse. Commencer par le moteur reviendrait à inventer une charge utile puis à
demander à l'écran de s'en arranger, c'est-à-dire l'ordre inverse de celui qui a
marché.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-29 12:50:01 +02:00
Patrick Schurig
f244729515 docs(§10): trois décisions — réserve, repli dégradé, phaseCount à l'appairage
1. La réserve batterie n'annule QUE le surplus, pas une autorisation de
   soutirage. Inscrit en LM-1203-b : la passe éco du §10 ne dépense pas de
   surplus, elle dépense une autorisation d'acheter. Deux raisons dont chacune
   suffit — une obligation qui cède à la réserve cesse d'être une obligation, et
   la réserve garde son sens là où elle en a un, empêcher de vider la batterie
   dans un chauffe-eau, ce qu'un soutirage réseau ne fait pas.

2. Le repli dégradé : les deux cas sont distingués, et la distinction RETIRE le
   point du lot. Le plafond du §10 est AUTO-IMPOSÉ, donc un repli qui ne dépense
   pas le respecte par construction — rien à écrire. L'exigence dure — tenir une
   borne imposée par un TIERS alors que le moteur ne décide plus — appartient au
   §14a et vivra hors du moteur. Les confondre aurait fait hériter au §10 d'une
   exigence qui ne le concerne pas encore. LM-1006-1 porte désormais la table
   des trois sources et de ce que « survivre » veut dire pour chacune.

3. phaseCount de la V2C : saisie à l'appairage. La Trydan du banc est
   triphasée ; le repli « câbler en monophasé » est écarté, il ferait tester une
   installation qui n'est pas celle du client.

Et l'attendu « sessionEnergy sur matériel réel » est RETIRÉ pour demain — le
refus V2C est documenté, pas un oubli. Remplacé par deux choses : ré-examiner
ChargeEnergy borne et référence en main, et exercer la condition C1 de LM-1009
sur ce cas réel plutôt que sur un cas fabriqué.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
2026-08-27 15:10:31 +02:00
Patrick Schurig
f200c9b76b docs(design): §10 ouvert — éco/confort avec le délestage, et la prépa du banc V2C
Deux livrables, aucun code.

DESIGN_10.md — le §10 sous la contrainte des deux notes.

Le délestage part AVEC pour une raison arithmétique, pas de calendrier : la
passe éco n'est pas bornée par le surplus mais par une autorisation de
soutirage, et cet objet n'existe pas encore. Le lot doit donc le créer.

LM-1006-1 devient une structure : DrawCap {source, totalW, perPhaseA, until} et
DrawBudget qui publie `binding` — QUI a mordu. Le piège technique est nommé et
tranché : les trois sources ne parlent pas la même langue (le disjoncteur est
par phase, le §14a et l'éco sont des totaux), un plafond total ne protège pas
d'une surcharge monophasée, un plafond par phase n'exprime pas le §14a. La
proposition est de porter les deux et de ne JAMAIS convertir — c'est la
conversion qui perdrait l'information.

LM-1003 impose la vraie refonte : les passes calculent des cibles, une seule
commande sort par charge et par cycle, arrondie une fois au mécanisme. Cela
scinde buildSetpointAction(), qui mélange aujourd'hui décision, écrêtage,
comptabilité et motif. Deux comptabilités distinctes, PlanBudget (d'où vient
l'énergie) et PlanDraw (combien on a décidé d'acheter) — les fondre rendrait
faux l'invariant publié de la première.

Cinq points à trancher, dont trois ne sont pas techniques. Le premier est le
plus lourd : la réserve batterie annule le budget de SURPLUS, mais la passe éco
dépense une autorisation de SOUTIRAGE. Si la réserve l'annule aussi, un plancher
éco ne sera jamais atteint batterie basse ; si elle ne l'annule pas, la maison
achète pendant qu'elle manque. Recommandation donnée, décision d'exploitation.

L'ordre de travail met en tête les deux étapes qui NE CHANGENT RIEN — publier le
plafond sans effet, puis la refonte décider/émettre à comportement constant,
prouvée par l'invariance des 16 scénarios enregistrés. Si un seul point de
contrôle bouge, c'est qu'une décision a changé, et il faut savoir laquelle.

PREPA_BANC_20260828.md — deux blocages silencieux trouvés la veille.

1. La ThingClass trydan ne déclare PAS phaseCount, et aucun registre Modbus V2C
   ne le porte. EvCharger::phaseCount() retombe sur 1 : sur une Trydan
   triphasée, le plancher publié vaudrait 1 380 W au lieu de 4 140, et la
   conversion W→A donnerait une consigne 3× trop haute. C'est exactement la
   famille de défaut que 3g-2 vient de fermer, rouverte côté plugin. Correctif
   proposé : saisie à l'appairage, comme la voiture simulée le fait déjà.
2. sessionEnergy n'est délibérément pas exposée — le brief du plugin l'interdit,
   evcc ayant retiré son ChargeRater pour cette borne (firmware peu fiable).
   Donc « sessionEnergy sur matériel réel » n'est PAS franchissable demain. En
   échange, demain permet la validation qui lèverait l'interdit, et exerce pour
   de vrai la condition C1 de LM-1009 sur du matériel.

Plus : le plugin V2C n'est pas installé sur le banc, la Skoda n'a aucune
intégration (donc voiture manuelle, avec les quatre réglages à saisir), et
l'IP de test .127 est libre.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
2026-08-27 15:06:37 +02:00
Patrick Schurig
6c543958ed feat(3g-2): une charge, un commandeur
Le relevé machine du 2026-08-26 l'énonçait sans détour : « tant que ceci n'est
pas traité, la transplantation ne commande rien ». L'arbitre décidait,
publiait un motif juste, et adjustEvChargers() commandait autre chose une
milliseconde plus tard. Ce lot ferme les trois points que le banc a ouverts.

1. UNE CHARGE, UN COMMANDEUR

Les actions EV du waterfall sont enfin DISPATCHÉES — elles ne l'étaient pas du
tout : m_loadAdapters ne contient pas les bornes, et applyActionsToAdapters()
s'arrêtait là. Et les bornes ainsi commandées sortent du chemin proxy par deux
crochets virtuels posés dans l'amont, interrogés à deux moments du cycle où la
réponse n'est pas la même : evSurplusPlannedByWaterfall() avant le plan (à qui
la borne appartient-elle), evCommandedByWaterfall() après (qu'a-t-il commandé).
Sans cette distinction, une borne servie par l'échéance ne serait plus
commandée du tout.

Le jeu des bornes commandées est NOMMÉ par le scheduler (Plan::waterfallEvIds).
Le redéduire ailleurs — « motif différent d'EV_DEADLINE », « financement au
surplus » — dériverait du vrai au premier motif ajouté, et EV_GRID_START porte
justement funding=Grid tout en venant du waterfall. Il est vidé à l'entrée du
mode dégradé : le waterfall ne commandant plus, il ne peut plus revendiquer.

chargingState, que posait adjustEvChargers(), est repris par l'arbitre pour
les bornes qui n'y passent plus — sinon l'app afficherait « en charge » une
borne que l'arbitre vient d'éteindre.

2. evFloorW() REND L'EXIGENCE RÉELLE, ET LA TOLÉRANCE DEVIENT VISIBLE

Le plancher valait `min × acquisitionTolerance × 230 × phases` : 2 070 W
annoncés pour une borne qui en exige 4 140. Le waterfall allouait 2 828 W,
l'adaptateur refusait d'enclencher sous 6 A, et 2 828 W étaient retirés du
budget pour personne. Ce sont deux grandeurs différentes : le plancher est ce
que la borne EXIGE, la tolérance est la part qu'on accepte de ne pas couvrir.

La tolérance n'est pas supprimée pour autant — un réglage documenté (défaut
0,5) qui devient inerte sans le dire est un silence ambigu, et tout le parc
verrait son VE démarrer deux fois plus tard sans explication. Elle devient un
seuil de démarrage EXPLICITE : motif EV_GRID_START {budgetW, floorW, gridW},
funding=Grid, et une comptabilité qui se partage — budgetW dans
budget.allocatedW, gridW dans budget.evReservedW, budgetW + gridW ==
allocatedW.

C'est une brèche assumée dans le financement réseau que DESIGN_3g §3.1 déférait
à 3f. Elle est bornée par construction, pas par discipline : surplus réel exigé
(budgetW > 0), tout le budget restant consommé donc AU PLUS UNE borne par
cycle, et le plafond de la protection de surcharge s'applique avant. Écrite en
toutes lettres dans DESIGN_3g §3.1-bis.

3. LE RANG D'UNE BORNE EST CONFIGURABLE

Il vit là où vivent tous les rangs : LoadConfig.priority (LM-1206/LM-1207). Pas
de second système de priorité — 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.

Toute borne détectée reçoit d'office une entrée « evcharger », portant le rang
et RIEN d'autre : ses limites viennent du Thing et changent avec la voiture
branchée. Création journalisée, une ligne par borne, à la création seulement —
une écriture que personne n'a demandée doit laisser une trace. Le tri devient
TOTAL : à rang égal, l'identifiant départage, donc laquelle démarre est
reproductible même avant tout réglage.

L'entrée SURVIT à la disparition de son Thing : borne remplacée, ThingId
changé, appareil déposé. Elle reste publiée available: false / faultCode:
THING_MISSING / motif LOAD_UNAVAILABLE, et ne retient aucun budget. La
supprimer d'office rendrait « borne remplacée » indistinguable de « borne
jamais configurée ».

CORRIGE UNE RUPTURE D'INVARIANT DE 3g-1, non signalée à l'époque : en faisant
entrer les bornes dans loads[], 3g-1 publiait des charges qui ne figuraient
dans AUCUNE GetLoadConfig — la charge fantôme que le lot B-bis avait
supprimée, réapparue par l'autre bout. L'app s'appuie sur cet invariant pour
résoudre libellé, domaine et rang. L'auto-provisionnement le rétablit par
construction.

ÉPROUVÉ

  - testEvChargerRankDecidesWhoCharges : deux bornes, un budget qui ne paie
    qu'un plancher, la seconde refusée avec BELOW_MIN_POWER {budgetW: 620,
    minPowerW: 1380}, et l'inversion du rang change la borne servie. C'est la
    moitié ARBITRAGE de LM-1204-b. Couvre aussi l'aller-retour neutre par la
    frontière RPC et l'inclusion loads[] ⊆ GetLoadConfig.
  - testEvGridStartIsAnnouncedAndBounded : le motif chiffre le soutirage, la
    comptabilité se réconcilie, une seule borne soutire par cycle.
  - testEvChargerConfigOutlivesItsThing : l'entrée survit, publiée en défaut.
  - testEvChargerConfigIsRankOnly / RefusesPayloads / ClaimsItsThing.

Deux points de contrôle de `run` bougent, dans un seul scénario et par une
cause unique : la conversion watts→ampères se fait désormais après
l'allocation, et l'arrondi tombe parfois de l'autre côté. 14 des 16 scénarios
sont identiques au bit près, et ce qui compte est inchangé dans les deux —
la voiture est à 100 % à l'échéance.

Suite complète : simulation 27/27, charging 16/16, loadmodel 16/16,
spotmarket 7/7. Build amd64 0/0.

RIEN N'A TOURNÉ SUR MACHINE. docs/RECAP_3g2.md dit ce qui reste à éprouver au
banc — dont LM-1204-b avec deux bornes réelles, la Terra AC étant vérifiée
absente du réseau (ARP INCOMPLETE depuis le banc lui-même).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
2026-08-27 10:43:25 +02:00
Patrick Schurig
e1fbb224fa fix(3g-1): sous la réserve de stockage, pas de recrédit
LM-1203-b dit « le budget est ANNULÉ, pas réduit », et le motif publié dit
« aucune charge n'est servie ». Le code disait autre chose : la réserve ne
mordait que sur les AUGMENTATIONS.

Le recrédit anti-clignotement (correction B) rend à chaque charge sa
consommation de début de cycle avant d'arrondir. Il le faisait aussi sous la
réserve — donc toute charge déjà en marche gardait exactement sa puissance,
indéfiniment, pendant que la télémétrie annonçait qu'aucune n'était servie.

Le recrédit suppose que le budget est une MESURE dont la consommation de la
charge a déjà été retranchée. Sous la réserve, le budget n'est pas une mesure
mais une DÉCISION de retenir : recréditer dedans rend exactement ce qu'on
venait de confisquer. Le recrédit est donc supprimé dans ce seul cas, pour les
charges en watts comme pour les états SG-Ready — sans quoi une PAC en état 3
ou 4 y resterait elle aussi indéfiniment.

Conséquence sur le motif : ce que la réserve retient à une charge a DEUX
termes, et il faut les deux. Le surplus annulé, le même pour toutes, et sa
propre consommation, que le recrédit lui aurait rendue. Sans le second, une
charge en marche coupée par la réserve sur un surplus nul publiait « surplus
insuffisant » — vrai sur le surplus, faux sur la cause : sans la réserve, elle
aurait continué de tourner.

Le défaut ne s'est vu qu'en exécutant réellement les décisions du waterfall :
batteryLevelConsiderationStopCharging échouait, la borne restant allumée là
où l'amont la coupe.

Suite charging vert (dont les deux tests de réserve), simulation `run` 16/16,
testEcsSurplusPV / testSgReadySurplus / testLoadTelemetryRpc verts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
2026-08-27 10:35:31 +02:00
Patrick Schurig
9157ebdfe7 feat(3g-1): motif BATTERY_RESERVE, et le défaut d'usine ramené de 0,9 à 0,2
Le vrai sujet n'était pas le banc mais le défaut d'usine. Une fois la règle batterie
dans le budget, un seuil à 0,9 gèle toute l'installation sous 90 % de SOC — ni ECS, ni
PAC, ni borne — sans erreur et sans panne. Personne ne s'en apercevait tant que la règle
ne touchait que le VE.

Le réglage confondait une CIBLE et une RÉSERVE. Une cible est ce qu'on aimerait
atteindre, une réserve ce sous quoi on ne descend pas ; la règle « sous ce niveau, rien
d'autre n'est servi » a une sémantique de réserve et était défaultée comme une cible.
0,2 protège l'autonomie du soir sans confisquer la journée, et un installateur qui veut
« la batterie d'abord » monte la valeur délibérément — maintenant que l'écran l'expose.

BATTERY_RESERVE publie le surplus RETENU, pas seulement le SOC : « 3 200 W retenus » se
comprend là où « batterie à 47 % » se subit.

LM-1203-b inscrit la nuance qui ne se déduit pas du code : sous la réserve le budget est
annulé, pas réduit.

Vérifié au passage, comme demandé : evReservedW devra filtrer sur funding == Grid après
la seconde moitié — spot market et échéance restent au proxy, minCurrent et idle
disparaissent. Il reste correct tel quel tant que le VE n'est pas dans la cascade.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
2026-08-26 11:40:15 +02:00
Patrick Schurig
29dfa15fa6 docs(design): 3g — ce qui se transporte, et les trois choses qui ne se transportent pas
Livrable de phase avant tout code (workflow AGENTS.md). Écrit après lecture des
1 900 lignes de smartchargingmanager.cpp.

Le constat central est plus dur que le scénario ne le laissait croire.
planSurplusCharging calcule allowanceInAmpere UNE fois, avant la boucle, et ne le
décrémente jamais : chaque borne reçoit la TOTALITÉ du surplus, comme si elle était
seule. Avec 4 kW et deux bornes, le proxy ne produit pas « 2 kW à chacune » — il
produit « 4 kW à chacune ». Ce qui rattrape n'est pas l'arbitrage mais le compteur au
cycle suivant, puis la protection de surcharge. Le système converge par correction
d'erreur, pas par décision. Le critère LM-1204-b doit donc se lire : avant 3g les deux
bornes démarrent puis sont rattrapées ; après, une seule démarre, délibérément.

Trois choses ne se transportent pas, et deux demandent une décision non technique :

1. L'ÉCHÉANCE — sa machinerie (projectedEndTime, remainingPercentageToCharge) vient du
   SoC de la voiture et n'a aucun équivalent ailleurs ; et le scheduler ne lit pas
   encore needs (« grid funding dormant jusqu'à 3f »). Forcer pour tenir une échéance,
   c'est soutirer, donc franchir la frontière du §10. Recommandation : la laisser au
   proxy, qui maigrit sans disparaître.
2. LA BATTERIE — batteryLevelConsideration est calculé dans le chemin VE alors que
   c'est une règle de BUDGET. La monter change le comportement de l'ECS et de la PAC,
   qui ne l'ont jamais eue. Recommandation : la monter quand même, et l'assumer.
3. LE NOMBRE DE PHASES — se décide en ampères, le waterfall parle en watts : la
   conversion descend dans l'adaptateur. L'ordre s'inverse, et le piège est silencieux :
   une borne qui passe en triphasé change son plancher effectif (1,4 → 4,1 kW), donc le
   plancher publié doit correspondre au nombre de phases VISÉ, pas au courant.

Nuance sur le plancher : minimalChargingCurrent vaut max(borne, voiture), il dépend
donc de la voiture branchée. C'est une valeur runtime, pas un réglage — l'écran ne doit
pas la présenter comme tel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
2026-08-26 11:25:28 +02:00
Patrick Schurig
e6afa7eea2 docs(spec): LM-201 — un canal de commande, une charge ; LM-202 — un thermostat n'en est pas une
LM-201 est la règle qui manquait sous LM-200. Une charge existe quand il y a un canal de
commande DISTINCT pour un service. Elle repose la question du domaine, que 0.1.3 posait mal :
une PAC air/eau fait chauffage, rafraîchissement et ECS — le domaine ne décrit donc pas la
MACHINE, il décrit CE QU'ON ARBITRE.

Conséquences : PAC + ECS sans ballon séparé = UNE charge (heating), pas de charge ecs — le
SG-Ready commande la machine entière et la répartition appartient à sa régulation ; ballon
séparé = charge distincte, il a son propre canal ; PAC qui fait du froid = toujours une seule
charge, ce qui change est la saison, pas la configuration.

La règle explique l'exclusivité de validateSet() (LM-302-b / ECS-110-b) : deux charges sur un
canal unique se contrediraient, et le budget compterait la même puissance deux fois. Le refus
existait avant la règle ; la règle dit enfin pourquoi il est juste.

LM-202 : thermostats et zones ne sont pas des charges. Un thermostat ouvre une vanne, il ne
porte pas la consommation. Les déclarer produirait des entrées à 0 W dans le waterfall pendant
que la PAC est comptée ailleurs. Ils décrivent où va la chaleur, pas combien on en achète —
leur écran, leur namespace, jamais LoadConfig.

Enforcement LM-200 tranché : avertissement au journal, PAS de refus dans isValid(). Mais
l'avertissement n'a pas encore de prédicat — voir le rapport de session. Keyé sur domain seul
il crierait sur des installations correctes (heating + relay-router = chauffage résistif ;
hvac + relay-router = VMC). Son discriminant est typeAppareil (§8-1), valeur thermodynamique.
Spécifié ici, implémenté avec lui.

§8-5 : heating vs hvac reste OUVERT et ne se saura qu'en dessinant la PAC communicante, où les
consignes ECS et chauffage sont enfin séparées. D'ici là o:domain garde son rôle actuel,
sans force normative — ne construire aucune règle dessus.

INTERFACE_etmvariableload.md (canonique) : §4-bis et §4-ter ajoutés, côté app. C'est l'app qui
guide au moment du choix, et c'est le seul endroit fiable — elle sait quel appareil
l'installateur déclare, le moteur ne le sait pas.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
2026-08-25 21:44:03 +02:00
Patrick Schurig
30c4b3da71 docs(protocole): dire ce que OPTIMIZER_PROTOCOL n'est pas
Ce document décrit le lien moteur ↔ optimiseur sur socket NDJSON. Il ne décrit pas la
frontière JSON-RPC de l'app, et un brief a pourtant envoyé l'agent app y chercher le
namespace de LoadConfig — qui n'y est pas et n'y sera pas. La confusion a coûté une
session : les deux protocoles portent du JSON-RPC, c'est leur seul point commun.

Note de périmètre en tête, avec le tableau des deux frontières et le renvoi vers
INTERFACE.md / nymeaenergyjsonhandler.cpp / JSONRPC.Introspect.

Relevé côté app, en corrigeant CLAUDE.md contre Introspect (plugin 1.15.2+etm15).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
2026-08-25 21:28:24 +02:00
Patrick Schurig
1ec89bf030 feat(telemetry): lot B — frontière de télémétrie d'arbitrage + o:domain — 1.15.2+etm14
Constat d'origine, rejoué et daté au banc : 90 s d'écoute, arbitre actif, ~2 kW exportés
→ ZÉRO notification NymeaEnergy.*. L'état runtime de l'arbitre (allocation, budget, verrous,
paliers, disponibilité, motifs) ne franchissait aucune frontière RPC : il vivait exclusivement
dans qCInfo(dcNymeaEnergy). Expliquer les décisions au client n'avait donc aucun transport.
Après déploiement, même geste : 13 notifications en 480 s.

Aucune phrase composée par la box. La locale nymea est PAR CONNEXION (JSONRPC.Hello o:locale),
et NymeaEnergyJsonHandler est un JsonHandler sans pluginId — donc hors du catalogue de
traduction : rien ne rattraperait une phrase française émise là. La box transporte un CODE et
des PARAMÈTRES ; la phrase est fabriquée par le client.

LoadAction::reason devient DecisionReason{code, params}. La ligne française du journal est
RENDUE depuis ce couple par renderFr(), source unique — jamais maintenue en parallèle. Les 19
codes sont EXTRAITS des sites existants, pas inventés ; les motifs d'ECS-309 et ECS-309-b sont
repris mot pour mot. Prouvé par comparaison de traces (1726 lignes de journal identiques en
simulation), par une table d'or qui échoue au premier code sans phrase attendue, et au banc sur
7 motifs distincts. Le libellé de la charge n'entre pas dans la charge utile : il est substitué
au RENDU, par l'appelant qui le connaît.

Les verrous sortent en SECONDES RESTANTES, pas en watts. Ceci n'enfreint pas ECS-306 : cette
exigence interdit qu'un index de PALIER franchisse la frontière d'ALLOCATION ; une valeur
d'AFFICHAGE sortante est un autre chemin. Le mécanisme sort en union discriminée
(relay|variable|sgReady), toutes clés internes optionnelles au schéma (LM-302).

ILoadAdapter::runtimeView() est PURE VIRTUELLE à dessein — mais la pure virtuelle force à
RÉPONDRE, pas à répondre quelque chose. testRuntimeViewIsSubstantial() est le second verrou :
les quatre adaptateurs, amenés dans un état non trivial, doivent rendre les valeurs runtime
réelles, les verrous en secondes ET leur expiration, et les codes de défaut. Deux mutants
« vue vide » introduits puis retirés : le test échoue sur chacun.

budget publié : surplusW, evReservedW, recreditedW, allocatedW, remainingW, avec l'identité
surplus + recrédit − alloué == restant. recreditedW n'est pas décoratif : sans lui l'identité
serait fausse DÈS QU'UNE CHARGE CONSOMME, et le client chercherait un écart inexplicable. Le
banc l'a montré aussitôt — surplusW −250 W avec recreditedW 3000 W — rendant lisible l'artefact
de banc documenté dans AGENTS.md (relais sans mesure + rootmeter aveugle).

Champs omis, jamais nuls : timestamp absent = aucun cycle depuis le démarrage ; budget absent =
mode dégradé, planification suspendue. Publier un zéro se lirait « exécuté, rien à allouer ».

Cadence : changement qualitatif ou variation d'allocation > 50 W, plus un battement de 60 s.
C'est le battement qui rend un arbitre FIGÉ détectable — les notifications continuent tandis que
le timestamp cesse d'avancer. Observé 3 fois au banc. Volume mesuré : ≈ 1,7 Mo/jour, un client.

GetChargingSchedules rend enfin degradedMode : il était POUSSÉ en notification seule, donc
illisible par un client connecté après la bascule (famille ECS-411). Règles absolues 11 et 12
ajoutées à AGENTS.md.

LoadConfig : champ optionnel domain (ecs·ev·heating·hvac·battery), PURE métadonnée d'intention
que l'arbitre ne lit pas. Absente ou vide = non classée. Forme exacte découverte au banc et
corrigée dans la doc ET le test : GetLoadConfig sérialise via le méta-objet, donc la propriété
sort TOUJOURS, à "" — comme sgReady:{} ou powerLevels:[]. C'est le corollaire LM-302.

Règle 7-c, deux silences fermés : les retours idempotents du RelayRouter et du SgReadyAdapter
étaient MUETS — « consigne reçue, palier déjà bon » ne se distinguait pas de « l'action n'est
jamais parvenue à l'adaptateur », et c'est le cas ordinaire qu'on vient vérifier.

⚠️ Porté au contrat : loads[] n'est PAS en correspondance 1:1 avec GetLoadConfig. « pac-terrain »
est la PAC SG-Ready CODÉE EN DUR d'energypluginnymea.cpp (K1/K2 = Relay4/Relay5, {3:1500,
4:3000}) — arbitrée et publiée, mais inconfigurable : ni rang, ni domain, ni enabled:false.
INTERFACE.md porte la règle de détection pour l'app. Son sort reste À TRANCHER (RELEVE_LOTB §8-bis b).

Déploiement : la recette de TEST_TERRAIN.md §b portait un glob « dpkg -i /tmp/…_*_arm64.deb »,
de la même famille que celui qui a rétrogradé .75 de +etm12 à +etm9. Remplacée par
etm-powersync-deploy/deploy.sh (archivage avant dépôt, installation par nom, refus de
rétrogradation, contrôle de version après coup).

Vérifié sur .75 (1.15.2+etm13 puis +etm14) : critères 1, 2, 4, 5, 6, 7, 8 du brief. Critère 3
(épisode dégradé) vérifié en simulation seulement — le déclencher demande de faire taire un
simulateur SunSpec partagé, hors périmètre. Relevé complet, critère par critère, avec ce qui n'a
PAS été vérifié sur la machine et pourquoi : docs/RELEVE_LOTB.md.

Suites : simulation 38/38, charging 46/46, spotmarket 32/32, y compris en build DEBUG où les
Q_ASSERT_X du validateur JSON-RPC sont actifs (le schéma déclaré correspond donc à la charge
utile émise, retours ET notifications). Cross-arm64 : 0 avertissement. Doxygen : 10, inchangé.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MfvSskkYrrFriYcj4683D
2026-08-25 13:18:10 +02:00
Patrick Schurig
5ec101fbe4 fix(etm): ECS-410 — échec d'écriture relais, échelle bornée et défaut collant
writeRelay() jetait le ThingActionInfo* : aucun acquittement, aucun retour
arrière, available codé en dur à true, et m_currentStage mis à jour comme si tout
avait réussi — on annonçait une puissance non appliquée.

MODÈLE ASYNCHRONE. executeAction est asynchrone et update() ne doit jamais
attendre (AGENTS règle 5). « Attendre le résultat » ne veut donc pas dire bloquer
le cycle : l'écriture est émise, l'adaptateur retient combien d'acquittements il
attend (m_pending), le verdict tombe quand le compteur retombe à zéro, et la
conséquence est traitée au cycle suivant. Motif repris tel quel d'EvCharger
(evcharger.cpp:293-299), y compris `this` en contexte de connexion — si
l'adaptateur meurt, les callbacks sont coupés proprement.

INDÉTERMINATION. Pendant une transition, telemetry() annonce
max(m_stagePrev, m_stageTarget) : on ne sait pas ce qui est fermé, on annonce donc
la plus haute des deux puissances possibles. Même direction qu'ECS-411 (relais
injoignable supposé fermé) — ne jamais annoncer moins que ce qui peut être
appliqué. Sous-estimer fait sur-allouer les charges suivantes ; surestimer ne fait
que retarder une montée.

ÉCHELLE BORNÉE à trois barreaux, une tentative chacun, aucune boucle : cible →
retour arrière → arrêt total → défaut. Le retour arrière est asynchrone au même
titre et passe par le même compteur.

DÉFAUT COLLANT, pas clignotant. m_faulted est un verrou posé une seule fois, sans
délai ni expiration : available ne peut pas osciller d'un cycle à l'autre. Seul
NymeaEnergy.ClearLoadFault le lève — acte délibéré et journalisé de l'opérateur.
La reconstruction le lève aussi, mais par construction : un adaptateur neuf n'a
pas d'historique. À la levée, l'état matériel est RELU (ECS-411), pas supposé.

CANAL OUVERT. LoadContext n'avait AUCUN champ available : le publier aurait été
décoratif. Ajouté à LoadContextTelemetry, avec sa sémantique écrite noir sur
blanc — il gouverne l'allocation, PAS la comptabilité. Une charge en défaut ne
reçoit rien mais reste comptée : une puissance qu'on ne sait plus couper est de la
conso fixe, au même titre que la base de la maison. Le figeage est porté par
lockMin == lockMax == puissance crue engagée, jamais un plafond nul sous un
plancher non nul. Le même canal servira ECS-601.

OPTIMIZER_PROTOCOL.md mis à jour dans le MÊME lot, comme l'exige le §11 de la
spec : available, lockMinPowerW et lockMaxPowerW documentés avec leur sémantique.

clearFault() est PURE VIRTUELLE sur ILoadAdapter : les trois autres adaptateurs la
déclarent sans effet plutôt que d'hériter d'un défaut vide. Leur généralisation est
portée par ECS-414.

Test testEcsPartialFailure : cas nominal sans défaut, puis échelle complète via un
relais introuvable — défaut atteint, relais valide bien ramené à l'ouverture par la
tentative d'arrêt total, available faux, plancher == plafond == puissance comptée,
plus aucune commande une heure plus tard, puis levée délibérée.

Build amd64 0 erreur. Simulation : 16/16.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:32:51 +02:00
Patrick Schurig
6be62a43b3 feat(ratios): NymeaEnergy.GetEnergyRatios + EnergyRatiosChanged (voie c)
Expose autoconsommation/autonomie comme source canonique de l'energymanager,
pour remplacer le seam interim app-side (EnergyRatiosInterim.compute).

Voie (c) : méthode + notification sur le handler NymeaEnergy de NOTRE plugin
(déjà forké/buildé), calculées depuis EnergyManager::totalX() — interface
abstraite stable. PAS de champs sur Energy.PowerBalance → aucun fork du coeur
nymea (nymea-experience-plugin-energy) à rebaser perpétuellement.

- EnergyRatiosCalculator (GPL pur mesure) : Δ de cumuls, baseline minuit local,
  reseed (1er appel / nouveau jour / non-monotone Δ<0), den<=0 -> n/a, clamp
  [0,100]. Porté 1:1 du seam interim app.
- NymeaEnergy.GetEnergyRatios -> {o:selfConsumptionRate, o:autonomyRate} Double,
  champ OMIS si n/a (jamais null).
- NymeaEnergy.EnergyRatiosChanged : meme forme, branchee sur powerBalanceChanged(),
  emise uniquement quand une valeur (ou sa disponibilite) change.
- testEnergyRatiosAlignment : vecteurs joues 1:1 contre l'interim (seed, normal,
  clamp-bas, den<=0->n/a, non-monotone, nouveau jour local).
- docs/INTERFACE_energyratios.md : contrat pour l'agent app (RPC a consommer).

Build prod 0/0. Suite simulation 25/0 (testLoadConfigRpc traverse le handler
modifie -> pas de regression).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 07:56:35 +02:00
Patrick Schurig
11f5c36b3a docs(contract): INTERFACE charge pilotée rév. 3 — frontière optimiseur↔routeur
Vérification nymea : un integration-plugin ne peut pas piloter les things d'un autre
plugin (thingManager privé ; seuls cœur/règles/scripts/experience-plugins le font).
Donc la combinatoire watts→relais ne peut vivre que côté experience-plugin.

rév. 3 acte le déplacement de frontière :
- Optimiseur watt-pur (ne connaît jamais un relais) | ROUTEUR (watts→relais,
  experience-plugin, a le ThingManager) | relais = things "power" nus.
- ECS multipalier : LoadConfig devient une LISTE de relais power (relays[] +
  minOnS/minOffS) ; powerLevels DÉRIVÉS des combinaisons (source = les relais).
- Cas continu (EV/triac, EtmVariableLoadAdapter) inchangé.
- §10 IMPACT APP explicite : l'écran « Configurer » passe à une liste de relais +
  picker findConfiguredThings("power") (UI-only) — l'agent app doit lire rév. 3
  avant de toucher l'UI.

À déposer À L'IDENTIQUE dans etm-powersync-app (miroir manuel, hors de ce repo).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 11:17:39 +02:00
Patrick Schurig
26444a58d6 feat(etm): adaptateur etmvariableload (interface charge pilotée, Setpoint W)
Côté energymanager : EtmVariableLoadAdapter consomme l'interface etmvariableload
(states currentPowerW read / powerSetpoint write) pour les charges à puissance
pilotable (EV / ECS résistif / routeur PV). La combinatoire matérielle, l'anti-rebond
et les phases vivent dans le thing (contrat §1/§3) — l'adaptateur n'écrit qu'un
setpoint W et relit currentPowerW.

- LoadDescriptor : champs additifs powerLevels / maxPowerW (déclaration §3/§5).
- Contrat partagé versionné : docs/INTERFACE_etmvariableload.md (rév. 2).
- La déclaration de l'interface nymee + le driver thing relèvent d'une session device.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 09:06:16 +02:00
Patrick Schurig
f7aa7ca7e6 [3e-4] arbitre : registerSgReadyAdapter + dispatch State + mode dégradé → état 2
registerSgReadyAdapter + m_sgReadyAdapters ; buildContext inclut les PAC ;
applyActionsToAdapters dispatche kind==State → m_sgReadyAdapters. Mode dégradé L2 :
SG-Ready → état 2 (NORMAL, mains off, force=true), JAMAIS état 1 (blocage). SAFETY.md
table L2 corrigée (état 2, pas 1). Build 0/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 23:25:09 +02:00
Patrick Schurig
a524599b6f [3c-6] degradedMode() + notification ChargingSchedulesChanged + invariant zéro-cloud
virtual degradedMode() dans SmartChargingManager (base false, [ETM] additif),
override EnergyArbitrator. Champ o:degradedMode (additif) dans la notification
NymeaEnergy.ChargingSchedulesChanged, émise aussi aux transitions du mode dégradé
(planif suspendue → push du flag via emit chargingSchedulesChanged()).
INTERFACE.md : champ degradedMode documenté.

SAFETY.md : notification réconciliée (ChargingSchedulesChanged, pas EnergyManagerChanged)
+ limite "valeur figée non détectée". Correction ZÉRO CLOUD : suppression de la section
"Alertes externes" / mécanisme n8n, remplacée par une signalisation 100% locale
(notification nymea in-app + buzzer/relais via règle nymea, aucun canal réseau sortant).
Invariant 10 "ZÉRO cloud" gravé dans AGENTS.md.

Build 0 erreur / 0 warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:04:09 +02:00
Patrick Schurig
f36395f07f [3c-5] watchdog L2 : QTimer fraîcheur compteur + mode dégradé conservateur
QTimer 30s indépendant des signaux ; m_lastMeterUpdate picoté sur powerBalanceChanged.
Silence >90s → mode dégradé (appliqué à la TRANSITION uniquement) :
  - ECS palier 0 force=true ;
  - EV : clamp courant minimum SEULEMENT si déjà en charge (pas d'activation forcée ;
    "jamais 0 A si branché" relève du failsafe L1, pas du repli logiciel).
update() suspend la planification + le dispatch tant que m_degradedMode (sécurité L4
en position 3 reste active) → pas de rallumage sur le cache d'un compteur mort, pas
d'oscillation. Reprise au retour du compteur.

SAFETY.md §L2 : nuance maintenu/démarré + suspension planification. AGENTS.md morceau 7 :
exiger ECS reste à 0 sur plusieurs cycles. SG-Ready/Batterie déférés 3e/3f ;
flag degradedMode exposé en 3c-6. Build 0 erreur / 0 warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:43:28 +02:00
Patrick Schurig
08b07c802a [3b] décision B + modèle sécurité (AGENTS + SAFETY.md) + Doxygen proxy/inactif
- AGENTS.md : nouvelle entrée "3b révisé — délégation EV à l'amont" (beta hybride
  assumée, ETM réel en 3c, transplantation EV en 3g) ; modèle sécurité L0-L4
  avec double déclenchement verifyOverloadProtection documenté (signal ligne 127 +
  appel cyclique ligne 313 SCM.cpp).
- docs/SAFETY.md : document normatif 5 couches + signalisation locale optionnelle ;
  Variante B confirmée pour le repli L2 (EV au minimum + notification nymea +
  risque 1,4 kW accepté) ; table défaillances/couches corrigée (L1 ne couvre pas
  compteur hors ligne).
- energyarbitrator.cpp update() : commentaire explicitant la correspondance exacte
  avec l'ordre SCM (1-4 parent, ETM entre 4 et 7, planSpot+planSurplus via getPlan).
- rulebasedscheduler.h : Doxygen getPlan() marqué "PROXY AMONT POUR L'EV (beta)".
- evadapter.h : Doxygen applyAction() marqué "Inactif jusqu'à 3g".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 07:41:12 +02:00
Patrick Schurig
9236fa1a4a [brief] AGENTS.md définitif (arbitre+LoadAdapters), CLAUDE.md pointeur, protocole versionné 2026-06-07 21:32:12 +02:00