36 Commits

Author SHA1 Message Date
Patrick Schurig
4fac49dd42 docs(relevé): le trou de pluggedIn refermé et vérifié sur machine
+etm24 posé sur `.75`, condition du §3.1 rejouée à l'identique : wallbox en
Eco, voiture assignée, pluggedIn false. La borne sort du contexte, le journal
le dit, et le budget va entier aux deux charges configurées. Sous +etm23 la
même condition lui donnait 4 853 W.

Une conséquence est notée parce qu'elle n'est pas évidente : « hors arbitrage »
ne veut pas dire « non commandée ». Sortie du waterfall, la borne retombe sur
le proxy, qui la commande à son courant minimum. L'invariant du lot tient — un
seul commandeur — et aucun watt n'est retiré de la cascade. Mais les trois
exclusions ne finissent pas au même endroit, et ça se saura le jour où le §10
fera du proxy autre chose qu'un repli.

Banc rendu à son état d'origine, voiture de test retirée, wallbox éteinte.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
2026-08-27 13:00:03 +02:00
Patrick Schurig
c2f08cb804 fix(3g-1): une borne sans véhicule branché est hors arbitrage
Trouvé au banc le 2026-08-27, en exerçant 3g-2 (docs/RELEVE_3g2.md §3.1).

Une wallbox en Eco, voiture assignée, mais `pluggedIn: false`, s'est vu allouer
4 853 W qu'elle ne pouvait pas tirer — `currentPower: 0`, `charging: false`. La
PAC derrière elle n'a survécu que sur son propre recrédit. Trois cycles
consécutifs : 2 350 / 4 853 / 5 464 W retirés du budget et donnés à personne.

evParticipatesInWaterfall() reprenait le filtre d'entrée du proxy — voiture
assignée, mode non manuel — mais pas `pluggedIn`. Le proxy n'en avait pas
besoin : son allocation n'était pas une cascade, donc gaspiller ne privait
personne. En cascade, si. L'asymétrie était déjà visible dans le code : la
boucle EV du scheduler, qui sert les financements réseau, teste
`!ev->pluggedIn()` depuis toujours ; seul le chemin waterfall ne le faisait pas.

C'est le défaut du relevé 3g-1 §3.4 par une autre porte : là le plancher
mentait, ici c'est le filtre d'entrée. Défaut de 3g-1, révélé par 3g-2 parce
que la décision atteint enfin le matériel — et corrigé à part du lot, comme les
quatre précédents.

Hors arbitrage, et non « servie à 0 avec un motif » : même famille que les deux
autres exclusions (DESIGN_3g §4). Une borne débranchée ne consomme rien, sa
consommation nulle est déjà au compteur, et lui donner un rang la ferait
apparaître dans la cascade pour n'y rien faire. Le journal l'annonce comme les
deux autres cas.

testUnpluggedChargerTakesNoBudget, VÉRIFIÉ ÉCHOUANT SANS LE CORRECTIF : la
borne débranchée disparaît du contexte et la charge suivante reçoit les 3 000 W
entiers ; la seconde moitié rebranche le véhicule pour prouver que c'est bien
`pluggedIn` qui décide, et non une exclusion fortuite.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
2026-08-27 12:52:21 +02:00
Patrick Schurig
93b257172c docs(relevé): 3g-2 sur machine — le doublet a disparu, et le banc a trouvé un trou
`1.15.2+etm23` construit arm64, posé sur `.75` par deploy.sh, exercé.

FRANCHI, journal et RPC à l'appui :

  - Une charge, un commandeur. 9 lignes « Executing action Simulated wallbox »
    sur la fenêtre, 9 AVANT « Adjusting chargers » (dispatch de l'arbitre), 0
    après (proxy). 6 cycles sur 6 portent « is commanded by the ETM waterfall
    this cycle. Not re-deciding here. » Le doublet du relevé 3g-1 §3.3 a
    disparu, et quelqu'un commande — l'arbitre a bien allumé la borne à 10 A
    sur sa propre décision.
  - Le plancher publié est l'exigence réelle : 2 070 W sous +etm22, 4 140 W
    sous +etm23, MÊME borne, MÊME journée. Et il suit le nombre de phases visé
    d'un cycle à l'autre (4 140 en triphasé, 1 380 en monophasé).
  - L'entrée evcharger est créée d'office, journalisée une fois par borne,
    sans charge utile ; l'aller-retour GetLoadConfig → SetLoadConfig verbatim
    est neutre et ne reconstruit aucun adaptateur ; loads[] ⊆ GetLoadConfig
    est rétabli.

TROUVÉ, et c'est le point du passage machine :

  - GRAVE — une borne SANS VÉHICULE BRANCHÉ absorbe le budget de la cascade.
    4 853 W alloués à une wallbox `pluggedIn: false` qui tire 0 W, la PAC
    derrière ne survivant que sur son propre recrédit. Trois cycles
    consécutifs, 2 350 / 4 853 / 5 464 W donnés à personne. C'est le défaut du
    relevé 3g-1 §3.4 par une autre porte : là le plancher mentait, ici c'est le
    filtre d'entrée. evParticipatesInWaterfall() reprend le filtre du proxy —
    voiture assignée, mode non manuel — mais pas pluggedIn. Le proxy n'en avait
    pas besoin : son allocation n'était pas une cascade. Défaut de 3g-1,
    correction PROPOSÉE ET NON APPLIQUÉE, à traiter dans son propre commit.
  - Mineur — le rang par défaut des bornes auto-provisionnées retombe à
    ÉGALITÉ avec le rang 1 déjà occupé, au lieu de « en tête ». Le tri reste
    total et reproductible, mais ce n'est pas ce qui était demandé.

NON OBSERVÉ, et pas fabriqué : EV_GRID_START (le budget est passé de 385 W à
2 350 W sans s'arrêter dans la fenêtre de tolérance), BATTERY_RESERVE avec un
surplus réel, LM-1204-b matériel (la Terra AC ne répond pas à l'ARP depuis le
banc lui-même), et le cas de transition — aucune borne n'était sous commande au
moment de la bascule.

Banc rendu à son état d'origine : ChargingInfos identiques champ par champ,
voiture de test retirée, wallbox éteinte. Les entrées evcharger restent — elles
seraient recréées au prochain démarrage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
2026-08-27 12:44:19 +02:00
Patrick Schurig
baf267a963 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
135cba3e6f 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
a1f17c136b docs(recap): périmé — renvoyer vers le relevé machine
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
2026-08-26 18:39:22 +02:00
Patrick Schurig
0f64609611 docs(relevé): 3g-1 sur machine — ce que le banc a dit, y compris ce qui fait mal
La transplantation ne commande rien tant que adjustEvChargers() n'est pas traité : l'arbitre
refuse, publie un motif juste, et le proxy commande ON à 6 A une ligne plus loin. La même
ligne part vers la Terra AC réelle et n'a rien fait UNIQUEMENT parce qu'elle est hors ligne.

Le plancher EV vaut la moitié du seuil de démarrage réel — acquisitionTolerance, qui est un
seuil de déclenchement, a été pris pour un plancher. 2828 W alloués, 4,1 A demandés, 6 A
exigés : allocation perdue.

La bascule 0,4 / 0,9 est faite dans les deux sens et la correction withheldW: 0 est vérifiée
sur machine. La moitié qui manque — la réserve retenant un surplus RÉEL — demande du soleil.

LM-1204-b n'est pas franchi : il exige deux bornes arbitrées, la Terra AC ne répond pas.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
2026-08-26 18:39:10 +02:00
Patrick Schurig
e21ab6a0f1 docs(recap): le paquet est déjà sorti du conteneur, la reprise part de là
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
2026-08-26 18:08:49 +02:00
Patrick Schurig
0b216f99a8 docs(recap): reprise de 3g-1 — le code est complet, rien n'est déployé
Session arrêtée sur contexte saturé. Le paquet 1.15.2+etm21 est CONSTRUIT mais le banc
tourne encore etm20 : la transplantation n'a jamais tourné une seule fois, sur un banc
qui porte une Terra AC réelle. C'est la première chose à faire, et LM-1204-b avec.

Le RECAP porte la commande exacte de reprise, les cinq points à vérifier au
redéploiement, et ce qui reste ouvert — dont la bascule 0,4 / 0,9 devenue possible
depuis que SunSpec déclare energystorage, et jamais faite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
2026-08-26 18:04:48 +02:00
Patrick Schurig
56c34666c1 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
fa45cb12c8 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
5275ebae31 docs(releve): lot D — les trois correctifs éprouvés sur le banc
1.15.2+etm16 déployé sur .75 le 2026-08-26 09:11. Chaque critère porte sa nature :
machine, test, ou lecture.

§2 — le journal bascule de "chauffe-eau" à "Ballon 200 L" au cycle suivant l'écriture,
et le décompte de l'arbitre passe de « inchangée » à « 1 mise à jour EN PLACE ».

§3 — relais inversés (2000/1000/500), mêmes Things, mêmes puissances :
« 0 créée(s), 0 mise(s) à jour, 2 INCHANGÉE(S) ». Ni reconstruction ni mise à jour,
aucun état sûr, aucune commutation. Avant etm16 la même écriture donnait
« 1 créée(s), 1 retirée(s) » et ouvrait les contacts.

§4 — stagesW = [0,500,1000,1500,2000,2500,3000,3500] publié en télémétrie, maxStageW
étant son dernier élément. Publié LÀ et pas dans GetLoadConfig parce que la condition
« lecture seule ET rejetée à l'écriture » s'y contredit : l'app écrit en renvoyant
verbatim ce qu'elle a lu, donc elle aurait réémis le champ et le refus en bloc aurait
emporté toutes les charges.

§1 — cause datée à la seconde (d08cf50, 05:30:14 UTC) et déjà corrigée en amont ; la
séquelle, elle, était définitive par absorption flottante. Base du banc réparée :
62 726 anomalies → 0, accumulation reprise et mesurée en direct (+0,001953 kWh).
Deux corrections au brief : totalProduction n'était pas sain mais figé depuis 23 jours,
et les deux Things Fronius ont déraillé ensemble.

À remonter en amont : EnergyManagerImpl accumule sans garde-fou de plausibilité dans un
compteur persistant. N'importe quel plugin mal codé peut geler l'historique d'une box
client. Contribution nymea, pas correctif ETM.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
2026-08-26 10:17:20 +02:00
Patrick Schurig
a75d4d8dd7 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
afaa8c2674 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
4166fb3f2a docs(releve): lot B-bis — coordonnées de publication et ce qu'en retient l'agent app
Canal, pool, ajout seul, et le fait que le paquet posé sur le banc et le paquet publié
sont le même (.so sha256 identique, banc redéployé avec le paquet reconstruit).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
2026-08-25 18:37:19 +02:00
Patrick Schurig
0765672e62 feat(config): lot B-bis — la PAC entre en configuration — 1.15.2+etm15
Le bloc d'enregistrement codé en dur d'energypluginnymea.cpp est supprimé, avec
registerSgReadyAdapter(), la table m_sgReadyAdapters et claimedRelays(). Il n'existe plus
aucun chemin d'enregistrement en dur : m_loadAdapters est la table UNIQUE des charges
arbitrées, et toutes ses entrées viennent de LoadConfigStore.

Contrat : loads[] de GetLoadTelemetry est désormais INCLUS dans GetLoadConfig, par
CONSTRUCTION et non par discipline. La règle de détection publiée à +etm14 — « un loadId sans
équivalent dans GetLoadConfig est une charge non configurable » — est retirée d'INTERFACE.md :
elle n'avait qu'un porteur, pac-terrain, qui vient de disparaître. Aucun configurable:false
n'est ajouté, il n'aurait plus rien à signaler. L'inclusion ne vaut que dans ce sens : une
charge enabled:false est configurée sans être publiée.

SÉCURITÉ — repli L2 d'une PAC de configuration, trou ouvert par LM-300 et refermé ici. Le
repli envoyait un Setpoint(0) à toute la table et un State(2) à une table séparée réservée à
la PAC en dur ; une PAC construite depuis la CONFIG vivant dans la première recevait donc une
action que SgReadyAdapter rejette d'entrée, et rien d'autre — elle restait en état 4 sous
compteur muet quand SAFETY.md §L2 exige l'état 2. Le motif DEGRADED_L2 s'affichait
correctement : seule l'écriture manquait. Le repli lit maintenant supportedKinds (LM-301-b).

ECS-411/-411-b au SG-Ready : l'état de départ est relu sur les contacts, avec relevé contact
par contact et l'issue — y compris quand l'issue est l'état neutre. ECS-412 au minStateHold :
armement paresseux au démarrage à froid, jamais permanent.

Harnais de test : JournalCapture se chaînait sur elle-même (récursion infinie à croissance
quadratique dès la deuxième capture vivante, donc OOM) ; tm/owner non initialisés sur trois
sites de freshSetup().

Vérifié sur .75 avant publication — docs/RELEVE_LOTBbis.md. Suites : simulation 85,
charging 74, spotmarket 44, zéro échec, un processus par test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
2026-08-25 18:35:04 +02:00
Patrick Schurig
d4d02dc88b docs(releve): le banc pose AVANT de publier — correction du §8-bis (a)
Ma rédaction se rassurait à tort : « +etm12 n'est pas perdu, .75 était réparable depuis le dépôt
APT à tout moment ». C'est vrai de cette version-là, et faux comme raisonnement.

.75 est le banc de BÊTA-TEST : on y pose un paquet AVANT de le publier. Sur le banc, un paquet
pas encore publié n'existe qu'à deux endroits — le répertoire de dépôt de la box et le pool
local de build. Ni au dépôt APT, ni chez un client, ni dans aucun git (un .deb ne se versionne
pas). L'écraser est IRRÉVERSIBLE, et c'est cela, plus que le ramassage multiple, que le verrou
d'archivage protège.

Le journal dpkg de la box, désormais cité, donne la chronologie qui le montre : +etm12 posé sur
le banc le 13 août à 19:48, publié au pool à 20:48 — une heure où il n'existait QUE sur le banc.
La plupart des paquets de banc ne quittent jamais cette fenêtre, puisqu'ils sont écartés : c'est
leur raison d'être. Le risque ne dépend pas de la chance qu'a eue une version particulière.

Le même journal donne la cascade du 25 août : quatre « upgrade » en onze secondes,
etm12→etm10→etm4→etm8→etm9. L'état final est celui du dernier configuré, pas du plus récent.

Renvoi ajouté vers les deux chemins de déploiement désormais écrits (BUILD-WORKFLOW.md §8,
DEPLOY.md §8) — le premier ne décrivait que le parc, laissant le chemin du banc s'improviser.

Documentation seule : docs/ n'est pas embarqué dans le .deb (vérifié : le paquet ne contient que
le .so, le changelog, le copyright et les .qm). 1.15.2+etm14 reste donc valide et fidèle à sa
source ; pas de nouvelle version.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MfvSskkYrrFriYcj4683D
2026-08-25 13:50:24 +02:00
Patrick Schurig
a0ca2250fe docs(releve): coordonnées de publication du paquet — canal, pool, ajout seul
L'agent app a besoin de savoir OÙ prendre le paquet : canal powersync-nightly, et non stable ni
testing, qui ne contiennent aucun powersync-energy-plugin-nymea.

Consigne au passage : reprepro a été invoqué avec --keepunreferencedfiles. Sans cette option il
aurait supprimé du pool le binaire d'+etm12, qui venait de perdre sa dernière référence — donc
il aurait CAUSÉ la perte que l'incident de rétrogradation décrivait à tort comme déjà survenue.
C'est la règle « une publication AJOUTE, elle n'enlève JAMAIS » de publish-to-repo.sh, appliquée
un cran plus haut, à l'inclusion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MfvSskkYrrFriYcj4683D
2026-08-25 13:20:46 +02:00
Patrick Schurig
74465375c4 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
6acaee38f9 fix(relayrouter): ECS-411-b — la reprise s'annonce, et annonce ce qu'elle a lu
La correspondance exacte ne journalisait qu'au palier non nul (`if (i > 0)`). Au palier 0 —
le cas de l'armement minOff d'ECS-412, celui qu'on vient précisément observer — le journal
était muet, et « relais lus, tous ouverts » ne se distinguait pas de « reprise non
exécutée ». Constaté au banc le 2026-08-13 : ECS-411 fonctionnait, et son contrôle sur le
chemin réel était impossible depuis les traces.

Le relevé porte désormais l'état de CHAQUE relais — identifiant, puissance déclarée, et
fermé / ouvert / injoignable→supposé fermé — avec le palier retenu, la branche empruntée et
la somme lue. Sortie unique pour les trois branches. Un relais injoignable fait passer la
ligne en avertissement : le palier déduit est plausible, la lecture ne l'est pas, et
« palier repris : 0 » couvrait ces deux situations sans les distinguer.

Un défaut de fond a été trouvé PAR ce relevé, qui affichait « (0W) » pour le relais 1500 W.
La nominale par relais était redéduite des encodages solo, or la déduplication par puissance
peut en évincer un : avec 500/1000/1500, le palier 1500 est encodé {500,1000} (masque 3,
rencontré avant le masque 4) et le relais 1500 n'apparaît seul nulle part. Sa nominale valait
0, donc un démarrage sur ce seul relais donnait sumW = 0, aucune correspondance exacte, et un
palier 0 annoncé pendant que 1500 W circulaient — l'annonce en sous-estimation qu'ECS-411
existe pour interdire. Elle est prise à la déclaration (m_relayNominalW). Rendre la lecture
visible est ce qui a rendu le défaut trouvable : c'est le sens de la règle, pas un effet
secondaire.

Règle 7-c dans AGENTS.md : un silence ne doit jamais être ambigu. Journaliser l'issue, pas
seulement l'issue remarquable ; journaliser ce qui a été lu, pas seulement ce qui en est
déduit. Trois autres endroits signalés dans spec_ecs.md, non corrigés — le retour idempotent
du routeur, clearFault() sur charge saine, et le refus minStateHold en qCDebug quand
l'application est en qCInfo.

RELEVE_ECS306 §3.0, en tête du protocole : un état nymea n'émet changed() que si la valeur
bouge, donc une grandeur forcée constante déclenche le watchdog L2 en 90 s et fait mesurer le
watchdog au lieu de l'arbitrage. Constaté au banc en gelant le simulateur.

Les tests portent sur le TEXTE publié, via un collecteur de messages : un test « journal non
vide » n'aurait rien vu. Trois cas, tous vérifiés échouant sans les correctifs. Suite
complète : 112 tests, 0 échec.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 20:13:18 +02:00
Patrick Schurig
92d0bef4ac 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
81621b2bcc docs: emballement du banc — artefact, pas défaut ; ECS-303 non affaibli
Correction de mon explication du volet 2. Le chauffe-eau n'était pas à 3500 W par
reliquat mais par EMBALLEMENT, et le mécanisme change la conclusion.

Deux conditions se composent sur .75 : les Things sont des relais GPIO, dont la
ThingClass n'expose pas currentPower — le recrédit anti-clignotement crédite donc
le nominal commandé ; et le rootmeter est la vue SunSpec du simulateur, aveugle
aux résistances câblées sur ces broches — la conso ECS ne fait jamais baisser
l'export mesuré. budget_charge = surplus + palier double-compte, le palier monte
d'un cran par cycle tant que le surplus dépasse 500 W, jusqu'au plafond. C'est ce
qui explique les quatorze plateaux sans commutation, pas un point de départ
malheureux.

GARDE-FOU ajouté sous « Correction B » dans AGENTS.md et dans le relevé : c'est un
ARTEFACT DE BANC, pas un défaut du moteur. Sur une installation réelle l'ECS est
derrière le compteur réseau, sa consommation fait réellement chuter l'export, et
le recrédit compense exactement ce qu'elle vient de retirer. Le supprimer casserait
l'anti-oscillation qu'il protège (ECS-404) sans rien régler. Sans cette note,
quelqu'un « corrigerait » la correction B sur la foi de cette mesure.

ECS-303 n'est donc PAS affaibli : ma remarque valait pour ce banc, où aucune montée
pas à pas n'est possible. Avec de la physique réelle, la rampe matinale traverse la
bande 1500-2000 W lentement et la frontière est franchie normalement. Le volet 2
n'est pas à refaire depuis le palier 0 sur le simulateur — il est à faire après le
câblage, sur la vraie installation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 09:59:28 +02:00
Patrick Schurig
45fdeb16f7 docs(banc): volet 2 mesuré — prédiction 7/3/1 FALSIFIÉE, cliquet confirmé
Résultat brut : R500=3, R1000=1, R2000=1, total 5 commutations. Prédit 7/3/1,
total 11. Aucune transition à trois relais observée ; la frontière 1500→2000 n'a
jamais été franchie.

Le cliquet est confirmé, et plus fort que prédit : QUATORZE plateaux consécutifs
sans une seule commutation, de 0 à 3600 W puis retour à 500 W. Le palier n'a bougé
qu'en surplus négatif. La prédiction était trop prudente — ce n'est pas seulement
la descente en surplus positif qui ne fait rien redescendre, c'est toute la plage
positive.

La falsification vient de MON profil, pas du moteur. Le chauffe-eau était déjà à
3500 W au départ (reliquat du volet 1) : la montée n'avait rien à monter, et seule
la descente a compté — en sautant des paliers, puisque chaque plateau fait chuter
le budget de 500 à 1000 W d'un coup. Le comptage 7/3/1 supposait un parcours pas à
pas des 8 paliers ; il faut partir du palier 0.

Conséquence signalée sans être tranchée : la transition 1500→2000 n'est atteignable
qu'EN MONTÉE, et le cliquet interdit toute montée pas à pas dès que la charge est
déjà servie. Cette frontière pourrait donc être bien moins fréquente que la spec ne
le suppose, ce qui affaiblirait la justification d'ECS-303. À établir par un
balayage partant du palier 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 09:36:25 +02:00
Patrick Schurig
fa7ee442d3 docs: resserrer la conclusion du volet 1 + piège du rootmeter (ECS-504)
Deux corrections de fond.

1. « Les 500 W consommés de plus que comptés sont démontrés » allait un cran trop
   loin. Dans le cycle à verrou expiré, le routeur applique RÉELLEMENT 3000 W : il
   n'y a aucun écart consommé/compté dans ce cycle-là. Ce qui est démontré
   directement, c'est la moitié comptable — au même budget, honorer ou ignorer le
   verrou change le résidu de 500 W et fait basculer la charge de rang 3. L'autre
   moitié est observée dans le PREMIER cycle. Le résultat tient par composition de
   deux moitiés observées dans deux cycles différents du même binaire : solide,
   mais pas une observation directe. Un +etm2 réel reste le seul moyen de voir les
   deux dans le même cycle.

2. Rétractation erronée corrigée. J'avais écrit que la question du compteur ECS ne
   se posait pas, LoadConfig n'ayant aucun champ de rattachement. Le danger n'a
   jamais été là : si le compteur de la charge est désigné comme ROOTMETER dans
   nymea, sa puissance entre dans le bilan de surplus que l'arbitre lit par
   internalRootMeter(), et le budget est faussé pour toutes les charges. Aucune
   configuration n'est nécessaire — une désignation dans l'interface suffit.
   Inscrit sous ECS-504 comme piège de mise en service, applicable au câblage du
   compteur Modbus prévu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 08:54:32 +02:00
Patrick Schurig
cf49b35baf docs(banc): relevé ECS-306 — volet 1 mesuré, prédiction confirmée
Résultats ajoutés SOUS la prédiction, sans y toucher.

4.0 — La première tentative a révélé un blocage circulaire dans ECS-412, pas dans
le banc. Point de méthode consigné : la prédiction annonçait « sonde éteinte sous
+etm3 » et elle l'était, mais pour la mauvaise raison. Sans test de falsification
cherchant POURQUOI elle se vérifiait, ce relevé aurait coché la prédiction et
conclu à tort. Une prédiction juste peut être confirmée par un mécanisme faux.

4.1 — Volet 1 mesuré sous +etm4. Le contraste attendu entre les deux versions a
été observé au sein d'un même binaire, sur deux cycles consécutifs au même
budget (~3200 W) : verrou actif → chauffe-eau maintenu à 3500 W, résidu −198 W,
sonde éteinte ; verrou expiré → palier 3000 W, résidu +201 W, sonde allumée. Le
cycle à verrou expiré reproduit ce que ferait +etm2, le clamp d'ECS-306 n'ayant
alors rien à contraindre.

Arithmétique vérifiée à 2 W près du prédit, recrédit de la sonde compris. Les
deux prédictions tiennent, y compris la secondaire : la PAC de rang 2 n'a pas
changé d'état — sans la charge sonde, le relevé aurait conclu « aucun effet
observable ».

Reste à mesurer : +etm2 réellement installé, et le volet 2 (commutations).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 08:48:26 +02:00
Patrick Schurig
793b76b6f7 docs(banc): prédiction ECS-306 et commutations — publiée AVANT la mesure
Horodatée 2026-08-09, commitée avant tout profil lancé. Une reconstruction
analytique publiée après la mesure ne vaut rien ; publiée avant, elle est
falsifiable et le commit en fait foi.

Prédiction principale, binaire : sur un point à budget_charge = 3200 W (surplus
−300 W, palier verrouillé à 3500), +etm2 retient le palier 3000 et annonce un
résidu de +200 W — la charge sonde de rang 3 s'allume ; +etm3 force 3500 par
lockMinPowerW et annonce −300 W — elle reste éteinte. Si la sonde s'allume sous
+etm3, le raisonnement est faux.

Prédiction secondaire, tout aussi engageante : la PAC de rang 2 ne changera PAS
d'état entre les deux versions (200 W comme −300 W sont sous P3 = 1500 W). Sans
la charge sonde, ce relevé aurait conclu « aucun effet observable » — à tort.

Raison sous-jacente établie avant mesure : les Things sont des relais GPIO, leur
ThingClass n'expose pas currentPower, donc RelayRouter::telemetry() retombe sur
le nominal commandé et currentPowerW == palier commandé en permanence. La
divergence entre les deux versions ne peut donc apparaître qu'en import net.

Volet 2 : comptage attendu R500=7, R1000=3, R2000=1 sur un aller ; la transition
1500→2000 bascule les trois relais à elle seule (3 des 11). Piège prédit — le
cliquet : sans plateaux à surplus NÉGATIF, le palier ne redescend jamais et le
relevé serait vide.

Protocole et ordre d'exécution inclus, dont la vérification obligatoire que +etm2
relit bien la configuration écrite par +etm3 avant de lancer le profil.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 08:05:49 +02:00
Patrick Schurig
444b13dbf8 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
1f976f0189 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
b7bfd58139 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
380823dc9d [doc] TEST_TERRAIN.md : helper relay() JSON-RPC + seuils SG-Ready + pré-vol §3.5
TÂCHE A — relay() réécrit (JSON-RPC complet) :
- JSONRPC.Hello obligatoire avant tout appel
- auth optionnelle : Users.Authenticate → token (NYMEA_USER/NYMEA_PASS)
- framing par id : boucle sur lignes newline-delimited, ignore notifications push
- résolution stateTypeId 'power' via GetThingClasses (les states GetThings ne portent
  pas le nom, seulement le stateTypeId — l'ancien s.get('name') ne matchait jamais)
- note ACCEPTANCE : tester relay isolément avant T1

TÂCHE B — seuils SG-Ready réels (rulebasedscheduler.cpp kForceMargin=1.2) :
- table seuils en en-tête §4 : état 3 ≥1500, état 4 ≥3600, zone morte ≥3000
- T7 : arithmétique du recrédit explicitée (2500+1500=4000≥3600, pas d'incohérence)
- T9 : annotations (budget ≈ XXXX) supprimées → sémantique surplus brut

Batch précédent (session antérieure rejetée) :
- ThingIds réels R500/R1000/R2000/K1/K2 + BOX/MPORT remplis partout
- root@$BOX → etm@$BOX + sudo (§1.b, §1.c, helpers, logs())
- §3.5 pré-vol SG-Ready : polarité K1/K2 + gpioget nymead arrêté + multimètre NO-COM

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 12:56:45 +02:00
Patrick Schurig
6670bed6cc [3e+doc] clôture 3e + TEST_TERRAIN.md + point d'étape
docs/TEST_TERRAIN.md : procédure Palier 1 (14 tests T1-T14) pour le banc nymea-dev arm64
— §0 pré-vol (forçables [À LIRE SUR LA BOX]), §1 déploiement (cross-arm64→scp→dpkg,
logging NymeaEnergy.debug, déclaration adaptateurs codée dans energypluginnymea.cpp),
§2-3 ECS 1/3 relais (dont transition non-cascadée 1500→2000), §4 SG-Ready (montée/
atomicité/hystérésis), §5 watchdog L2, §6 interaction priorités, §7 EV optionnel.

AGENTS.md ÉTAT : 3e clôturée (testEcsRelayTopologies dfdd988), audit Doxygen (5→0),
TEST_TERRAIN créé ; déféré = passe README+contrats (force/minStage-maxStage/min-maxState/
degradedMode pas encore dans le protocole publié), Waveshare, V2C, 3f, 3g, config priorités,
arm64 CI, Doxyfile+CI. Prochaine action : test terrain vendredi puis passe contrats.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 00:31:47 +02:00
Patrick Schurig
b06ac15714 [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
f71e0405b4 [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
312a2484ae [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
c3fedfe36b [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
074fa71308 [brief] AGENTS.md définitif (arbitre+LoadAdapters), CLAUDE.md pointeur, protocole versionné 2026-06-07 21:32:12 +02:00