Le retour d'applyAction() au dispatch (applyActionsToAdapters) était jeté : c'est pourtant ce
que l'adaptateur a réellement posé, après écrêtage et verrous. Il est désormais rangé dans
m_actionsAppliquees (loadId → LoadAction), daté par m_actionsAppliqueesAt, vidé à chaque
dispatch. Aucune lecture : rien n'en dépend pour décider. Il servira au retour d'exécution
vers l'optimiseur et à l'écart plan/appliqué (analyse des correctifs moteur, point c).
Seuls les deux appels du dispatch sont concernés. Les délestages L4 (dont le retour était
déjà lu) et le repli L2 ne sont pas touchés ; aucun appel ajouté ni retiré.
Neutre : référence de décisions IDENTIQUE (23 739 lignes) ; suites inchangées (simulation
211, loadmodel 135, charging 80, spotmarket 36 + 11 ignorés, 0 échec) ; journal >= info
identique (8 588 lignes, 553 gabarits, aucun nouveau).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EnergyArbitrator::instantanePrevision(now) rend la charge utile de
NymeaEnergy.GetForecastSnapshot — courbe, contentChangedAt, corrections par plan,
conversion, acLimitW, base et prix sur la grille, ou le motif qui les remplace. Le handler
n'ajoute que capturedAt, qui date la réponse. Le sérialiseur de l'optimiseur (T1) lira la
même fonction : un seul assemblage.
Code déplacé tel quel ; seuls changent les accès membres directs (m_forecastStore,
m_forecastAcLimitW, que les accesseurs rendaient tels quels) et une garde redondante
(`!rev.points.isEmpty()`, déjà garantie par le retour anticipé).
Neutre : référence de décisions IDENTIQUE ; suites inchangées (211/135/80/36+11, 0 échec) ;
aucun nouveau gabarit de journal >= info ; les 15 réponses GetForecastSnapshot des six
tests qui l'appellent sont identiques avant/après (trace ponctuelle non commitée, ThingId
et horodatages muraux neutralisés). contentChangedAt n'est rendu dans AUCUN test : son
déplacement n'est prouvé que par le texte.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
update() interroge désormais la stratégie par son interface. Sans endpoint d'optimiseur,
m_strategy pointe sur le rule-based : même objet, même appel. m_scheduler garde son type
concret — le rule-based restera le repli et le plan ombre d'une stratégie externe (T0 §0.1).
Aucun SocketScheduler.
Neutre : référence de décisions IDENTIQUE (23 739 lignes) ; suites inchangées
(simulation 211, loadmodel 135, charging 80, spotmarket 36 + 11 ignorés, 0 échec) ;
aucun nouveau gabarit de journal >= info.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brief 3 T4, câblage.
- Get/SetTariff : tarif saisi persisté (energy.conf, clé tariff), à chaud ;
refus TARIFF_REFUSED {cause, zone, range, at, zones}, rien de modifié ;
timeZone de la box publié (les plages sont murales). Pas de *Changed.
- noterMinuteBilan() : minute du bilan (puissances moyennes, jamais les
cumuls) moins chaque charge pilotée, bornes comprises, selon sa source ;
none = puissance commandée. Rien sans compteur racine ni en mode dégradé.
mesurerCharge() et puissanceCommandee() extraits de la télémétrie : une
seule règle pour l'écran et pour la base.
- GetForecastSnapshot publie start/resolutionS/steps (grille de pv, ou
grille par défaut sans courbe), baseload {model, p50W, quality} et price
(null sans tarif ; créneau à cheval = moyenne pondérée).
- §11 : SurplusContext::tariff non rempli, aucune décision ne lit base ni
prix — épinglé par testNoDecisionReadsBaseloadOrPrice.
- INTERFACE.md : méthodes, paramètres, erreurs, unités, heure locale,
couverture 24 h, qualité de la base.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
observerPourApprentissage(now), après lireCourbePrevision() : copies seulement
(§11), aucune requête dans update(). Paramètres de conversion lus sur le Thing
plan par leur nom, jamais recopiés (§28 décision 1). Minutes du bilan branchées
sur EnergyLogs::powerBalanceEntryAdded(1 min), sous la garde de simulation.
Mock : pvPlane porte kwp/performanceRatio/tempCoeffPctPerK/noctC ; nouvelle
classe solarInverter (smartmeterproducer), seul producteur du mock.
Test : testTheLearningLogIsFedByTheCycle.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SetForecastSite stockait l'identifiant tel que reçu ; forecastSnapshot() et
lireCourbePrevision() le comparent en chaîne à parentId().toString(), qui a
des accolades. Un UUID envoyé sans elles était accepté, puis le snapshot
restait muet et la table vide.
Normalisation par ThingId(...).toString() (siteIdCanonique) à l'écriture
(setForecastSiteId) ET à la relecture des réglages (relireReglagesPrevision,
extraite du constructeur) : une valeur persistée sans accolades par une
version antérieure est relue correctement après mise à jour.
Test testTheForecastSiteIdIsMatchedWhateverItsBraces : forme à accolades,
sans accolades, majuscules (RPC), arbitre direct, valeur persistée relue,
plus une contre-épreuve sur un autre site. Contre-épreuves vérifiées : sans
la normalisation, le test échoue à la première forme sans accolades ; sans
la normalisation à la relecture seule, il échoue sur la valeur persistée.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Décision de Patrick du 2026-10-13 : une app plus ancienne qui ignore
`acLimitW` effaçait le plafond à chaque enregistrement du site.
`acLimitW` devient l'exception explicite au remplacement en entier :
absent = inchangé, null = effacer, entier = poser dans [100, 1 000 000] W
(bornes validées). Désigner aucun site retire aussi le plafond
(invariant « plafond ⇒ site »).
Vérifié dans le harnais (nymea 1.15.2) : la validation refuse `null` sur
un Uint (« Invalid UInt », status error). Le paramètre est donc déclaré
`Variant` — changement du schéma déclaré par rapport à T1 — et sa forme
est contrôlée dans le handler (null ou entier ; chaîne, fraction,
booléen refusés sans rien modifier).
Tests (testTheSiteAcLimitTravelsWithTheDesignation, réécrit) : absent
conserve (mémoire, RPC, disque), null efface (clé retirée, champ omis),
refus de forme et de bornes sans modification, 2500.0 accepté, retrait
du site. Contre-épreuve : handler revenu à « absent = efface » → échec à
l'étape « absent conserve ». Le test D2 passe désormais par null pour
effacer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Décision de Patrick du 2026-10-13. Le `corrections` du site reste
l'intersection (+ `ac-limit` si plafond) ; le détail se lit désormais en
donnée : `perField`, une entrée par plan sommé, `{id, corrections}`,
triée par id, présente seulement avec une courbe. Pas de booléen
« divergent ». Le journal garde son message.
- EnergyArbitrator::forecastSnapshot() : paramètre de sortie optionnel
`parPlan` (ForecastPlanLu) ;
- handler : `o:perField` déclaré et servi ;
- mock : classe `pvPlane` (état forecastCurveTransport) + addPvPlane() ;
premier test qui fait passer une COURBE par le RPC
(testTheSnapshotPublishesEachPlanesCorrections : divergence,
concordance, plafond, refus). Contre-épreuve : parPlan non rempli →
le test échoue (0 entrée au lieu de 2) ;
- OPTIMIZER_PROTOCOL §5.2, INTERFACE.md, design §30.2.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Prérequis du déploiement d'openmeteo v2 (§28) :
- `depuisTransport()` lit v1 et v2. v2 exige `corrections` (liste) ; une
correction inconnue refuse le plan (`CORRECTION_<nom>`), `ac-limit` compris,
qui appartient au site. Autre version : `VERSION_<v>`, jamais devinée.
`gti[]`/`tempC[]` ne sont pas lus ici (enregistrement : T2).
- `acLimitW`, paramètre de SITE porté par `Get/SetForecastSite` : optionnel,
absent = aucun plafond (jamais 0), borné [100, 1 000 000] W, refusé sans
site ; la désignation se remplace en entier. Persisté (`forecastAcLimitW`).
- `ForecastStore::sommerPlans()` : somme des plans du même run ET du même pas,
puis écrêtage à `acLimitW × pas / 3600` APRÈS la somme. Fonction unique pour
la table et le snapshot.
- `GetForecastSnapshot` publie `corrections` (intersection si les plans
diffèrent, journalisé au changement ; `ac-limit` si plafond configuré),
`conversion: "physics"` et `acLimitW`.
Aucune décision ne lit la prévision (§11) ; boucle de sécurité ni appelée ni
modifiée ; pas de `*Changed`.
Tests : loadmodel `testTransportV2IsReadAndAnUnknownCorrectionRefused`,
`testTheSiteCurveIsClippedAfterTheSum` (contre-épreuve faite : plafond
neutralisé → échec) ; simulation `testTheSiteAcLimitTravelsWithTheDesignation`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GetPvForecast sert la TABLE ; GetForecastSnapshot sert la DÉCISION. Un arbitre
qui lit la table lirait sa propre trace au lieu de son entrée : il ne mesure
plus, il se relit. Le snapshot lit le canal, directement, sans persistance.
Écrit maintenant parce que la distinction est nette maintenant : dans six
semaines, le premier consommateur prendrait GetPvForecast sans y penser.
LES DEUX ABSENCES, TRANCHÉES. Aucun candidat : silence. Site désignable non
désigné : MISSING_FORECAST_SITE. Charge utile refusée : FORECAST_PAYLOAD_REFUSED
avec sa cause. Canal muet ou plans de runs différents : ni courbe ni motif.
Dans tous les cas forecastPoints est ABSENT, jamais une liste de zéros — une
prévision manquante et une prévision nulle sont deux affirmations différentes.
Deux motifs, deux gestes : configurer, ou regarder le producteur.
DATER LE CONTENU, PAS SEULEMENT LA LECTURE. capturedAt date la réponse et
distingue « pas répondu » de « répondu, et rien ». Mais un producteur FIGÉ se
lirait comme une prévision stable — le silence qui ressemble à un résultat, sous
sa forme la plus coûteuse puisque celui qui le lit décide d'ACHETER. D'où
contentChangedAt, qui vient du magasin non comme entrée mais parce qu'il SURVIT
AU REDÉMARRAGE : en mémoire, il se serait remis à zéro à chaque relance, et un
producteur figé aurait paru frais après le premier redémarrage venu. Omis quand
le magasin n'a jamais vu ce contenu, plutôt que deviné.
ET UN MÉCANISME ENTIER ÉTAIT EXCLU DU BANC D'ESSAI PAR UNE ACCOLADE. En écrivant
le test de frontière, forecastStore() s'est révélé NUL : le magasin était créé
dans un #ifndef ENERGY_SIMULATION, par accident de placement — ajouté à la suite
du battement de télémétrie, qui lui est bien un déclencheur d'horloge murale.
Toute la prévision était donc absente du binaire de test. Aucune suite ne
pouvait l'exercer à travers l'arbitre, et le balayage des getters voyait
GetPvForecast répondre correctement… par sa branche « magasin absent ». Un
mécanisme entier hors du banc, tous les voyants au vert. Le test qui l'a trouvé
ne le cherchait pas : il voulait vérifier une frontière et a buté sur l'absence
de ce qu'il devait franchir.
Le magasin sort de la garde — il n'est pas un déclencheur d'horloge murale. Sa
purge, si, et elle y reste.
loadmodel 31/31, charging 48/48, spotmarket 32/32, simulation 71/71,
ci-quality 4/4, build 0 erreur 0 warning.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Décision de Patrick : rien n'est jamais désigné automatiquement. Ma proposition
d'adopter le site unique est écartée, et sa raison porte plus loin que la mienne.
« Il n'y a rien à choisir » ne suffit pas, parce que LA DÉSIGNATION SE PERSISTE.
Le jour où un second site apparaît, elle devient un choix que personne n'a fait
et qui a l'air fait — plus rien ne distinguera « l'utilisateur a préféré
celui-ci » de « le moteur a pris le seul qu'il voyait en 2026 ».
Le précédent du rootmeter va dans ce sens (SetRootMeter exige une désignation
explicite, et une adoption d'office sur la seule présence d'une interface a déjà
coûté, cf. §14). Et la distinction avec l'auto-provisionnement des bornes
(+etm23) tient : celui-là ENREGISTRE ce que le moteur pilote déjà, il ne choisit
rien parmi plusieurs.
MISSING_FORECAST_SITE : la lecture s'arrête, mais elle le DIT — c'est ce qui
rend le silence acceptable, et l'écran peut nommer le geste au lieu d'afficher
un vide. Le rendu français donne le geste et pas seulement le manque : « Aucun
site de prévision désigné — choisissez-en un dans la configuration ». Au contrat
c'est un code, jamais une phrase.
Et il ne se publie QUE si un site existe. Aucun candidat : silence — annoncer
une désignation manquante à une installation qui n'a rien à désigner serait un
avertissement qu'aucun geste ne peut lever, donc du bruit permanent, et le bruit
finit par couvrir le signal.
PAS DE PARC, DONC PAS DE MIGRATION — mais une DATE. Aucun mécanisme n'est écrit,
délibérément : un mécanisme sans parc à migrer serait du code jamais exercé,
donc du code faux qu'on découvrirait le jour où il servirait. Si un parc existe
avant le 2026-12-31, la question se rouvre — et la réponse ne sera pas
l'adoption d'office pour autant : ce qui manquera est un chemin de configuration
à la mise en service.
forecastSiteChosen reste, sans plus armer d'adoption : il distingue une
installation jamais configurée d'une dont l'utilisateur a délibérément coupé la
prévision. Les deux se taisent de la même façon et ne veulent pas dire la même
chose.
loadmodel 32/32, ci-quality 4/4, build 0 erreur 0 warning.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
+etm62 lit sans désignation, +etm63 en exige une : une installation qui avait
commencé à remplir sa table s'arrêterait silencieusement. Règle proposée et
implémentée :
0 candidat rien, et RIEN au journal — cas normal d'une installation sans
prévision
1 candidat DÉSIGNÉ D'OFFICE, persisté, une ligne au journal
>= 2 rien de désigné, une ligne au journal, candidats publiés au
contrat
LA DISTINCTION QUI LA FONDE. Le silence est la bonne réponse à « cette
installation n'a pas de prévision ». Il n'est PAS la bonne réponse à « cette
installation en avait une et nous venons de la lui retirer ». La première est
une configuration légitime, la seconde une régression que NOUS introduisons —
les confondre ferait passer notre propre rupture pour un cas normal, ce que la
règle du silence existe précisément pour ne pas couvrir.
POURQUOI LE DÉFAUT D'OFFICE NE DEVINE RIEN : avec un seul candidat, désigner
n'est pas CHOISIR — il n'y a rien à choisir. Dès deux, il y a un vrai choix et
le faire serait deviner : le moteur ne désigne rien, le dit une fois, et
GetForecastSite publie candidateSiteThingIds pour qu'un écran pose la question
sans second appel ni heuristique côté client.
« JAMAIS CONFIGURÉ » N'EST PAS « EXPLICITEMENT MIS À RIEN ». Un second réglage,
forecastSiteChosen, est posé par TOUT appel à SetForecastSite, identifiant vide
compris. Sans lui, un utilisateur ayant délibérément coupé la prévision la
verrait se rallumer au prochain démarrage, sa décision écrasée en silence.
Deux détails qui ont failli être faux : un site candidat se reconnaît à une
propriété OBSERVABLE (un enfant publie forecastCurveTransport), jamais à un nom
de ThingClass ; et la migration tourne au PREMIER CYCLE, pas au constructeur —
là, le ThingManager n'a pas chargé les Things, « aucun candidat » serait
toujours vrai, et le défaut ne se déclencherait jamais. Un mécanisme inerte qui
aurait l'air posé.
loadmodel 32/32, charging 48/48, spotmarket 32/32, simulation 69/69,
ci-quality 4/4, build 0 erreur 0 warning.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Désignation GLOBALE sur le modèle d'Energy.SetRootMeter — même nature : une
source unique pour toute l'installation, désignée une fois. Un forecastThingId
par charge supposerait que deux charges s'appuient sur des prévisions
différentes ; il n'y a qu'un ciel au-dessus de l'installation, et le champ dit
d'où vient la PRODUCTION, pas ce que la charge consomme.
GetForecastSite / SetForecastSite, persisté dans energy.conf.
PLUSIEURS PLANS : LA SOMME, et ce n'était pas un choix libre. La courbe est
superposée à la production MESURÉE, qui est le total. N'en retenir qu'un
donnerait une prévision systématiquement SOUS la mesure — qui se lirait comme
une installation qui sous-performe, soit le mode d'erreur déjà nommé au §3 pour
le biais optimiste, obtenu cette fois par le découpage.
UN DÉFAUT TROUVÉ EN ÉCRIVANT, ET IL ÉTAIT DÉJÀ LÀ. L'implémentation lisait tous
les Things portant forecastCurveTransport et les enregistrait l'un après
l'autre. Avec deux plans du même run, les deux révisions entrent en collision
sur la clé (targetAt, firstIssuedAt) : INSERT OR REPLACE garde LE DERNIER LU, en
silence. Ni somme ni choix — un tirage. Le banc n'a qu'un plan, donc rien ne
l'aurait montré.
ON NE SOMME QUE DES PLANS DU MÊME RUN. Un plan rafraîchi et l'autre pas
donneraient une chimère : une partie du toit à jour, l'autre périmée, publiées
comme le tout. Le moteur attend la passe suivante. Même règle si UN plan est
refusé — additionner le reste publierait une partie du toit sous le nom du tout.
L'ABSENCE DE DÉSIGNATION EST SILENCIEUSE. Rien retenu, rien journalisé : une
installation sans prévision est un cas normal, et un avertissement par cycle
ferait chercher une panne là où il n'y a qu'une fonction non configurée.
GetForecastSite OMET le champ plutôt que de rendre une chaîne vide.
Note de contrat : EnergyError de l'amont n'a pas de ThingNotFound, donc un
identifiant inconnu rend InvalidParameter et la description le dit. Inventer une
valeur d'énumération ferait diverger le contrat de celui de nymea-energy.
loadmodel 32/32, charging 48/48, spotmarket 32/32, simulation 69/69,
ci-quality 4/4, build 0 erreur 0 warning.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Le canal est livré (openmeteo 1.15.2+etm11) : forecastCurveTransport, une
QVariantMap TYPÉE déclarée dans la ThingClass — au contrat, sans seconde couche
d'encodage. Mieux que la chaîne JSON envisagée : le §12.5 réclamait une version
dans la charge utile faute de schéma ; ici la charge utile a un schéma ET porte
sa version.
issuedAt VOYAGE DANS LA CHARGE UTILE. La clé de révision devient exacte, et tout
le §13 est levé : receivedAt, l'interdiction d'en calculer un horizon, la dette
meta.json. targetAt - issuedAt EST l'horizon. Le coût que je chiffrais disparaît
avec sa cause.
CE QUE J'AVAIS FAUX. Ma déduplication REJETAIT une révision aux valeurs
identiques. Avec une vraie heure de run, c'est faux : un run plus récent portant
les mêmes chiffres CONFIRME, il ne répète pas. Le rejeter rendrait indistincts
« le modèle a confirmé quatre fois » et « le modèle n'a rien dit depuis ce
matin ».
Le mécanisme change donc de nature et se simplifie : la CLÉ déduplique par
construction, et la comparaison de contenu ne fait plus que JOURNALISER (« le
run confirme le précédent »). Si issuedAt bougeait un jour sans que la prévision
bouge, cela se verrait dans le nombre de révisions — un défaut à la source,
qu'il faut voir et non masquer. Une déduplication de contenu l'aurait effacé.
La comparaison sur l'intersection reste, mais comme précaution : la fenêtre
couvre désormais les journées entières et les révisions sont alignées. Elle ne
dépend d'aucun comportement du voisin dont personne ne nous préviendrait.
model porte la provenance réelle, repli best_match compris. La colonne cesse
d'être nulle ; la règle de non-remplissage rétroactif reste.
L'INDEXATION EST LUE, PAS SUPPOSÉE. `convention` dit que p[i] est la moyenne sur
[t0 + i·step, t0 + (i+1)·step) : l'énergie vaut p[i] x step / 3600, sans
supposer que le pas vaut une heure. `unit` est VÉRIFIÉE — lire des Wh comme des
W donnerait une courbe plausible, fausse d'un facteur qui dépend du pas.
Lecture branchée dans le cycle, et c'est sans coût : un run déjà connu n'écrit
rien. Attendre une notification serait plus élégant et MOINS SÛR — un état
publié avant notre démarrage ne notifierait jamais, et la courbe resterait
invisible jusqu'au run suivant. L'état est cherché par son NOM, jamais par un
identifiant de ThingClass. Un refus est journalisé une fois par MOTIF, pas une
fois par cycle.
Deux tests : l'identité du run est celle de la révision (avec l'assertion
centrale — un run qui confirme EST conservé), et le refus de charge utile plutôt
que l'interprétation au mieux (version, unité, vide, tronquée, et un créneau
illisible qui reste un TROU).
loadmodel 30/30, charging 48/48, spotmarket 32/32, simulation 69/69,
ci-quality 4/4, build 0 erreur 0 warning.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Ce qui ne dépend pas du canal, écrit maintenant. La lecture se branchera quand
la forme du canal openmeteo sera connue ; le magasin est vide d'ici là, et une
courbe vide EST la réponse honnête à « que prévoit-on ? » quand personne n'a
rien publié.
LA CLÉ EST LA PAIRE (targetAt, receivedAt). Avec targetAt seul, chaque
rafraîchissement écraserait le précédent : on perdrait les révisions, donc la
comparaison prévu/réalisé — ce que ce lot existe pour montrer. Le GPL n'apprend
pas, il rend l'apprentissage possible : cette table est la matière, et l'écraser
la détruirait pour toujours sans que rien ne le signale.
receivedAt, JAMAIS issuedAt, et l'interdiction est écrite et non seulement
nommée : aucun horizon ne se calcule depuis ce champ. Entre l'initialisation
d'un run et sa mise à disposition s'écoule la latence de publication du modèle —
des heures. targetAt - receivedAt est donc trop court, systématiquement et dans
le même sens. « Quelle prévision » est juste, « quel horizon » est faux.
LA DÉDUPLICATION PORTE SUR L'INTERSECTION, pas sur la charge utile. Si la
fenêtre glisse, la courbe de 11 h a un point de moins que celle de 10 h : une
égalité de charge utile verrait une révision toutes les heures, exactement ce
que la déduplication existe pour éviter. Et `communs > 0` est requis — sans
heure commune il n'y a rien à comparer, et conclure « identique » ferait
disparaître une révision dont on ne sait rien (cas du passage de minuit).
Ce choix ne dépend pas de la forme que le producteur publie aujourd'hui : il est
correct que la fenêtre glisse ou non, donc il ne peut pas être invalidé par un
changement chez le voisin dont personne ne nous préviendrait.
RÉTENTION : sept jours pleins, puis les DEUX BOUTS par heure visée — la première
révision (horizon le plus long) et la dernière (meilleure estimation). Les deux,
et pas une : elles répondent aux deux seules questions qui se posent, et n'en
garder qu'une détruirait la révision.
model reste NULL tant que le producteur publie une INTENTION et non une
provenance. Une chaîne vide serait déjà une valeur ; NULL dit « on ne sait pas »,
ce qui est vrai. Aucun remplissage rétroactif à prévoir.
GetPvForecast : un Get* SANS *Changed — amendement assumé à l'invariant 11, qui
vise l'ÉTAT DE L'ARBITRE, celui qui bouge à chaque cycle et laisse un client
déconnecté en retard. Une courbe historisée se lit sur demande, comme
GetPowerBalanceLogs ; notifier chaque révision produirait un message par heure
sur une donnée que personne n'attend en direct. `knownAt` rend la lecture
reproductible : « ce qu'on prévoyait ce matin » se demande en le fixant.
Base SÉPARÉE, jamais energylogs.sqlite : celle-là appartient à l'amont qui en
migre le schéma, et surtout la clé y est un instant de mesure quand la nôtre est
une paire. Une prévision révisée n'a rien à faire dans la base du mesuré.
Trois tests unitaires, et le premier vise le cas qu'un == naïf raterait : une
fenêtre glissée d'une heure, valeurs communes inchangées, charge utile
différente — ce n'est PAS une révision. Avec ses contre-épreuves : une valeur
commune modifiée EN EST une, et une fenêtre sans heure commune aussi.
loadmodel 29/29, charging 48/48, spotmarket 32/32, simulation 69/69,
ci-quality 4/4, build 0 erreur 0 warning.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Deux défauts trouvés par le premier essai au banc (2026-09-08, 11:05→11:16),
et le premier est celui que Patrick a vu en regardant la voiture.
LM-1500-h — UNE BORNE N'EST PAS ÉCRÊTÉE DEUX FOIS.
La tête de passe éco appliquait `margeRestante` au plafond d'une borne. Or
`margeRestante` est la marge d'UNE phase, et le plafond d'une borne triphasée
est une enveloppe sur TROIS : les comparer divise son allocation par trois.
Mesuré : marge de 6 210 W sur la phase la plus chargée, plafond de cycle à
13 800 W, borne servie à 6 210 — 9 A par phase au lieu de 27, 4 kW au lieu des
11 que la voiture pouvait prendre.
Le plafond d'une borne porte DÉJÀ l'autorisation de soutirage :
evPhaseAllowanceW() la calcule et la multiplie par le nombre de phases
occupées. La passe confort ne fait pas cette faute, et son commentaire dit
pourquoi depuis 3g-2 — « mieux vaut la borne mieux informée que la borne la
plus sévère ». Je l'ai réécrite quinze lignes plus haut sans la relire : une
règle notée à un seul endroit du code n'est pas une règle.
L'erreur allait dans le sens PRUDENT, donc rien ne la signalait — ni le
disjoncteur, ni le délestage, ni un test qui vérifie qu'on ne dépasse pas. Elle
ne se voyait qu'en regardant la voiture.
LM-1500-g — ÉCRIRE UN MODE PENDANT UNE DÉROGATION LA TERMINE.
Choisir un mode est durable ; une dérogation répond à un besoin ponctuel. Le
durable prime, et il prime tout de suite. Et SANS restituer le mode mémorisé,
qui écraserait celui que l'utilisateur vient de choisir.
Le cas vient du banc : le bouton « boost » de l'app écrit encore
ChargingModeNormal. Reçu pendant une dérogation, il fait restituer les valeurs
manuelles par l'amont — chargingEnabled à FAUX — et arme un verrou de 300 s,
pendant que la dérogation continue de commander 9 A. Deux commandeurs pour un
organe, et six minutes de dérogation servie sur le papier dans une borne
éteinte. Seul le journal de divergence le voyait, sans pouvoir en dire la cause.
Deux autres corrections du même essai :
- DEROGATION_ACTIVE et DEROGATION_ENDED sont rendus en français au journal ;
ils sortaient en « (motif inconnu …) ». Le repli 7-c a tenu — le code brut et
ses paramètres, jamais une chaîne vide — mais la ligne lue par l'utilisateur
était illisible.
- poseDerogation() prend l'instant en paramètre. Elle lisait l'horloge murale
quand le cycle lit l'horloge injectée : sans effet en production, où les deux
sont la même, mais sous test la fin par durée n'arrivait jamais. Trouvé par le
test, pas par la lecture.
Et `setChargingInfo() déclenche un cycle` est gravé dans AGENTS.md comme une
PROPRIÉTÉ : ce qui l'entoure a un ordre qui compte, dans les deux sens, et
ENERGY_SIMULATION supprime précisément l'appel — le chemin fautif n'existe pas
sous test.
simulation 69/69, loadmodel 26/26, charging 48/48, spotmarket 32/32,
ci-quality 4/4, build 0 erreur 0 warning.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Une dérogation n'est pas un mode. Un mode se choisit une fois et vaut jusqu'au
prochain choix ; une dérogation répond à un besoin ponctuel et s'éteint avec
lui. Un boost encore actif après la fin est un mode qu'on a oublié de quitter.
Elle se superpose au mode courant, sert la charge à son MAXIMUM en tête de
passe éco — devant toute autre charge, quel que soit son rang — financée au
réseau. Le rang classe ce que l'installateur décide (LM-1002) ; une dérogation
est une action ponctuelle de l'utilisateur, et passe par une porte que LM-1002
ne ferme pas.
Mais elle ne surplombe PAS la sûreté : DRAW_CAP la borne et le délestage la
reprend. Servie la première dans ce qui RESTE, jamais au-delà — sans quoi on
rouvrirait le second chemin d'achat refusé au minimum réseau (LM-1500-e).
LA DURÉE EST OBLIGATOIRE ET BORNÉE, refusée à la pose hors de [60 s, 6 h].
C'est la règle du lot : une dérogation qui ne sait pas dire quand elle finit ne
devrait pas exister (LM-1500). Elle reste armée même avec une cible, parce
qu'une cible repose sur un instrument et qu'un instrument peut se taire — à la
perte du lien, la V2C FIGE sessionEnergy au lieu de l'effacer (LM-1012-d) : une
dérogation « 20 kWh puis stop » dont le compteur reste à 12 kWh n'atteint
jamais sa cible.
Trois décisions prises en écrivant, absentes du design :
- Les fins se lisent dans le CONTEXTE DU CYCLE, pas dans les adaptateurs.
evaluerDerogations() tourne entre buildContext() et getPlan(), sur ce que le
scheduler va lire. Une seconde lecture donnerait une seconde vérité, et une
dérogation pourrait finir sur une mesure que le plan ne voit pas. C'est aussi
ce qui rend le débranchement gratuit : la borne est déjà filtrée du contexte,
son absence EST le signal.
- measurementLost ne mord QUE s'il y a une cible (LM-1500-b). Sans cible, rien
ne dépend de la mesure : couper là punirait la charge du silence d'un
instrument dont elle n'a pas besoin. Et le critère est
telemetry.sessionMeasurable, jamais progress.regime : `progress` est absent
d'une charge sans obligation en énergie, c'est-à-dire exactement le cas du
boost — le critère aurait été inerte.
- La base de la cible se capture au PREMIER CYCLE, pas à la pose : au moment où
l'utilisateur appuie, aucun cycle n'a construit de contexte. Prendre 0
compterait depuis le début de la session — une dérogation « 5 kWh » posée sur
une session déjà à 6 kWh serait atteinte avant d'avoir commencé (LM-1012-b).
Le mode précédent se MÉMORISE, il ne se déduit pas (LM-1500-d). Revenir à un
défaut détruirait un réglage en silence, et sur une borne la sortirait de
l'arbitrage : une dérogation qui laisse l'installation moins pilotée qu'avant
elle. Une borne en manuel est basculée en éco pour la durée — seul cas où le
mode est écrit, et modeForce retient qu'on l'a fait.
La passe confort est SAUTÉE sous dérogation : la fusion des niveaux est une
addition, et la dérogation demande déjà le maximum. Conséquence comptable à
connaître : elle est comptée entièrement en achat alors que le surplus présent
la finance en partie — gridW la surestime au soleil. Approximation héritée du
§10, non aggravée, et la raison pour laquelle la première vérification au banc
se fait le matin.
La fin est un ÉVÉNEMENT et dit sa CAUSE (LM-1500-f), publiée dans
derogationEnded[] au NIVEAU DE LA TRAME : au débranchement la charge n'est déjà
plus dans loads[], et l'événement disparaîtrait quand il explique le plus de
choses. Cinq causes, cinq gestes. Le mode dégradé la porte aussi — c'est là que
measurementLost se produit. telemetrySignificantlyDiffers ne la filtre jamais :
un événement ne vit qu'un cycle.
Éprouvé par testDerogationTakesTheHeadButNotTheSafety, dont la tête de passe est
prouvée par la RARETÉ : 7 500 W d'autorisation pour 8 000 demandés, la borne de
rang 9 servie ENTIÈREMENT et l'ECS de rang 1 réduite aux 500 restants — l'inverse
exact de l'ordre des rangs. Contre-épreuve : la même borne sans dérogation
reçoit 0 W. Et à autorisation nulle, AUCUN niveau — pas un niveau à zéro.
simulation 68/68, loadmodel 26/26, charging 48/48, spotmarket 32/32,
ci-quality 4/4, build 0 erreur 0 warning.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Le premier dit ce que le MATÉRIEL permet, le second ce que le MOTEUR fait.
Seul l'EvAdapter remplissait sessionMeasurable, donc sur toute charge non-VE
le premier disait oui et le second non — et l'avertissement de saisie de
l'app, qui se déclenche sur progressMeasurable, restait muet exactement sur
les charges achetées à l'aveugle. sessionWh est alimenté depuis le compteur
désigné.
L'avertissement de l'app est arrivé AVANT que j'écrive la garde, et il était
décisif. Un compteur qui disparaît peut publier 0 au lieu de se taire : la
V2C a forgé un zéro sur currentPower et ses booléens, mais laissé
sessionEnergy à sa dernière valeur. La politique dépend du plugin — donc
inutilisable comme garde.
Et le point à vérifier avait une réponse nette : il n'existe AUCUN signal de
joignabilité fiable. setupComplete parle du setup et non du lien ; connected
est maintenu par le plugin, la V2C basculant après trois échecs quand un
compteur générique peut ne jamais basculer. La garde ne pouvait pas reposer
là-dessus.
Elle porte donc sur la SÉMANTIQUE du compteur, seule chose que le moteur
sache sans parier : sur un compteur de session un recul est une session
neuve, sur un compteur de vie c'est une anomalie — on gèle, on ne recapture
jamais. Sans elle, le scénario chiffré sort tel quel : base reprise à 0
pendant l'absence, retour avec 10 kWh réels, deliveredWh à 10 000 en un
cycle, obligation déclarée tenue sans rien de livré. Et R6 ne l'attrape pas :
elle n'interdit ECO_FLOOR_MET que sous unmeasurable, alors que ce chemin est
MESURÉ — c'est le seul par lequel un faux « tenue » peut sortir, et c'est
celui qu'ouvrait l'alimentation depuis un compteur.
audit-controles.py dit maintenant en tête qu'il ne tranche pas et ne doit
jamais trancher : neuf des dix identités relevées contrôlaient réellement, et
un verdict automatique aurait supprimé neuf contrôles utiles.
AGENTS.md : la diversité du mock n'est pas un confort de test, c'est une
borne supérieure sur ce que la suite peut prouver — elle se fixe en écrivant
les classes, pas les tests.
Quatrième occurrence du piège d'insertion doxygen, rattrapée par le même
contrôle. Comme annoncé.
Suite : simulation 63/63, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Sur measured → unmeasurable → measured dans la même fenêtre, deliveredWh
repartait de zéro : la base était retirée puis reprise à la valeur courante,
donc l'énergie livrée pendant les tronçons mesurés antérieurs disparaissait.
L'écran sous-estimait, et — ce qui coûte — la passe éco rachetait le déjà
livré.
Ce n'est pas théorique : c'est le motif d'une V2C dont le lien Modbus bat.
Chaque perte et retour fait basculer le régime deux fois, et chaque
aller-retour effaçait ce qui précède. Contre-épreuve : tronçon jeté,
l'avancement retombe de 1500 Wh à 0.
On accumule par PÉRIODE, pas par tronçon — le tronçon est clos, pas
abandonné. Et la clôture se fait sur la dernière valeur lue TANT QUE la
mesure existait, jamais sur celle du cycle où le régime tombe : à cet instant
le Thing ne publie plus, et s'en servir fabriquerait une énergie.
Deux tranchages, épinglés. Le cumul de période repart de zéro à la bascule de
période, et les trous avec lui — ils comptaient les interruptions de la
période close. Et measuredSince garde le PREMIER instant mesuré, l'
intermittence se disant à part : redater à chaque retour effacerait
l'histoire au lieu de la raconter, ne rien dire ferait croire à une
continuité que le battement dément.
LM-1014-c : un cumul se teste aussi de part et d'autre d'un CHANGEMENT DE
RÉGIME, pas seulement de période. LM-1014-b ne disait rien de la bascule de
régime à l'intérieur d'une période — c'est exactement là que le défaut
vivait.
Le test a échoué deux fois avant d'être juste, et la seconde fois c'était le
test : mon horodatage « après échéance » visait la même échéance que le
premier, donc rien ne basculait.
Suite : simulation 62/62, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Le recrédit rendait au budget la conso d'une charge sur l'hypothèse qu'on
pourra la lui reprendre. Contre un refus établi, les watts prêtés étaient
réalloués aux suivantes pendant que la charge continuait de les tirer :
4 000 W alloués pour 500 W de surplus réel. Suspension faite, la charge
suivante ne reçoit plus que les 500 W qui existent.
Le seuil vient du MÉCANISME, jamais d'une constante. Un écart récent est une
latence — une charge qui vient d'être commandée n'a pas atteint sa consigne,
et suspendre là ferait osciller ce qui va bien. Latences mesurées sur la
V2C : montée ~30 s, descente ~7 s. L'asymétrie joue dans le bon sens, le
refus qui coûte de l'argent étant celui de la descente. Défaut prudent à
60 s : l'oscillation d'une charge saine est un coût certain, le refus n'est
qu'un risque.
Et ça se dit — RECREDIT_SUSPENDED, avec depuis quand et combien de watts
retenus. Une suspension change l'allocation de toutes les charges suivantes ;
sans le motif, on chercherait un défaut de cascade là où une charge n'obéit
pas.
Et une cinquième classe dans AGENTS.md, la seule qu'aucun contrôle ne pouvait
attraper : une PRÉMISSE FAUSSE SOUS UNE ARITHMÉTIQUE EXACTE. Les quatre
autres se laissent prendre par un instrument — un champ qui ment, un retour
indiscernable de son contraire, un instrument muet, une assertion vacante.
Celle-ci n'a aucune trace : l'identité publiée tient exactement, que la
charge obéisse ou non.
La parade tient en une phrase : une identité qui tient ne prouve pas que le
modèle est juste, seulement qu'il est cohérent. Ce qu'une identité ne peut
jamais attraper, c'est une hypothèse sur le monde. D'où la question à poser à
chaque identité publiée : sur quoi repose-t-elle qui ne soit pas dans ses
propres termes ? Ici, sur l'obéissance — grandeur absente de la formule,
qu'il a fallu aller mesurer ailleurs pour la rendre vérifiable.
Suite : simulation 60/60, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement. Contre-épreuve :
suspension débranchée, le recrédit revient à 4 400 W.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Deux demandes de l'app, toutes deux fondées.
purchasedWh est publié même sous `unmeasurable`, et ce n'est pas une
entorse à la règle qui y interdit deliveredWh — c'en est le complément. Ce
qui est ACHETÉ est un fait connu sans mesure : c'est notre propre commande.
Ce qui est LIVRÉ est une observation qu'on ne peut pas faire. Sans lui, « ce
que ça coûte par jour » redeviendrait une estimation fabriquée à partir du
taux.
C'est un cumul sur la fenêtre d'obligation, donc LM-1014-b s'applique : remis
à zéro à la bascule, comme la base. Le premier cycle d'une période ne
contribue rien — il n'a pas de durée derrière lui, et en inventer une serait
fabriquer de l'énergie.
measuredSince ferme un piège d'affichage. Quand une obligation bascule de
unmeasurable à measured EN COURS de période, deliveredWh repart de 0 pendant
que targetWh reste la cible entière : une barre afficherait « 0,5 sur 4,0 »
après une bascule où 3 kWh ont pu être achetés. Rebaser targetWh serait pire
— on perdrait l'obligation réelle, et l'écran ne pourrait plus dire ce qui
reste dû. Le champ prend la seconde sortie : « mesuré depuis 09:14 », et
purchasedWh couvre l'avant. Les deux se répondent.
Et un piège de build noté dans AGENTS.md : changer la DISPOSITION d'une
structure dans un en-tête inclus transitivement laisse des objets compilés
contre l'ancienne, et le symptôme est un plantage dans un DESTRUCTEUR — qui
ressemble en tout point à un défaut mémoire du code qu'on vient d'écrire.
Avant de chercher le bug : make clean. Le plantage n'avait jamais existé.
Suite : simulation 59/59, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
`command` publie expectedW, observedW, divergent et since — seulement quand
une source mesure et qu'une commande est partie. Absent sous `none` : il n'y
a rien à comparer, et `divergent: false` affirmerait une concordance qu'on ne
peut pas constater.
L'asymétrie va dans les DEUX sens, et le contrat la dit maintenant en entier.
Sous `device`, une concordance ne prouve rien — plusieurs bornes renvoient
leur consigne en guise de puissance — mais une divergence dit que la charge
n'obéit pas. Sous `meter`, c'est l'inverse. Le test le démontre au lieu de
l'énoncer : la borne du mock dérive sa puissance de sa consigne, donc
expectedW == observedW par construction, et la divergence a dû être exercée
par un compteur explicite.
Un écart n'est pas un défaut, et le champ ne le qualifie pas. Véhicule qui
limite, câble en 16 A, mode manuel : le même nombre qu'une commande perdue.
La cause n'est pas dans les watts.
`since` est ce qui rend le champ utile — sans lui, une divergence permanente
ne se distingue pas d'un déphasage d'un cycle, et c'est toute la différence
entre le normal et le cas relevé.
Et une leçon dans AGENTS.md, appliquée à moi-même dans le même lot : une
contre-épreuve qui NE MORD PAS ne prouve pas que le code est bon, elle prouve
qu'on a perturbé au mauvais endroit. Mon assertion « sans mesure, pas de
command » était VACANTE — aucune charge du test n'avait source: none, la
boucle ne s'exécutait jamais, et la garde retirée le test passait toujours.
Toutes les classes du mock portent un état de puissance ; il a fallu une
charge au câblage manquant pour produire le cas. La contre-épreuve mord
désormais.
Suite : simulation 59/59, loadmodel 22/22, charging 48/48 identique à la
référence ligne à ligne, spotmarket 32/32, doxygen 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Ce matin j'ai refusé de créer ce motif : il n'avait pas d'émetteur, et un
motif qui naît avant son émetteur est un invariant posé sur du code que rien
ne produit — un test toujours vert. La bascule de période l'a fait naître.
Une obligation abandonnée est désormais un fait que le moteur produit, le
journal le disait déjà, l'écran non.
Trois causes, trois gestes : drawCap envoie regarder l'abonnement,
loadCapacity la fiche technique de la charge, unmeasurable ce que la borne
publie — et unavailable s'y ajoute. Sans la cause, l'écran n'affiche qu'un
reproche sans recours.
missingWh est ABSENT sous unmeasurable, jamais 0 : on ne sait pas ce qui a
été livré, donc pas davantage ce qui manquait. Publier un chiffre
inventerait la grandeur dont l'absence est le problème.
R5 vérifiée contre la maquette plutôt que supposée conforme : la forme de
`progress` correspond exactement.
Un écart assumé et signalé : la maquette donnait ECO_FLOOR_GRID avec
deliveredWh ; le moteur publie remainingWh. deliveredWh vit déjà dans
`progress`, et le republier ferait deux sources pour un même fait. Surtout,
remainingWh n'est PAS dérivable sous unmeasurable — où deliveredWh est
absent — et c'est justement le cas où l'on achète.
R8 : le point de rupture se lit sur DRAW_CAP, jamais sur un solde tombé à
zéro. remainingW à 0 arrive aussi quand tout a été alloué normalement ; un
écran qui le guetterait signalerait une rupture inexistante et manquerait la
vraie. Écrit dans INTERFACE.md.
Suite : simulation 58/58, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
`computeEcoTarget()` convertit une obligation en énergie en une cible en
watts : cadence = reste / temps restant, recalculée à chaque cycle. Une
cadence figée au départ ne rattrape pas — après une heure sans surplus elle
reste à la cadence initiale et manque l'échéance en ayant l'air de
travailler.
Mesurer d'abord était nécessaire, pas rituel : aucun scénario existant ne
portait d'obligation éco, donc aucune référence. Les valeurs ont été
relevées par sonde avant toute assertion — et c'est le relevé qui a trouvé
un défaut que la lecture du code ne montrait pas.
LA BASCULE DE PÉRIODE (LM-1013-c). À l'instant de l'échéance quotidienne,
resolveDeadline() vise la prochaine occurrence : une nouvelle obligation
s'ouvre. Sans reprise de la base d'avancement, le lendemain hérite de
l'énergie de la veille et se croit tenu sans avoir rien reçu. La sonde
affichait 83 W et 86400 s restantes à 07:00 pile, ce qui n'avait aucun sens
tant qu'on ne cherchait pas la période. Contre-épreuve : 1800 Wh portés au
crédit du jour suivant.
Limite assumée et écrite : l'obligation précédente, si elle n'était pas
tenue, est abandonnée SANS motif dédié. Le dire demande un ECO_FLOOR_MISSED
que rien n'émet ; le journal le dit, l'écran non.
Le durcissement est relevé point par point — 333 W à six heures, 2 kW à une
heure, le plafond dès seize minutes — parce que c'est la courbe que le motif
devra rendre lisible, pas une valeur isolée. Point de non-retour nommé
plutôt que subi : un seuil se discute, une asymptote non.
R6 armée dans le calcul : sous `unmeasurable`, jamais « tenue ». On vise
l'obligation entière, le choix qui sert plutôt que celui qui suppose acquis.
Suite : loadmodel 22/22, simulation 56/56, charging 48/48 identique à la
référence ligne à ligne, spotmarket 32/32, doxygen 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
`sessionEnergy` est l'ACCUMULATEUR du plugin V2C, pas un compteur remis à
zéro pour le moteur : l'écart avec `chargeEnergy` est constant au
dix-millième (7,2044 kWh sur deux échantillons à deux heures d'écart), parce
qu'il porte les incréments d'avant la dernière remise à zéro de la borne.
Le lire directement ferait croire toute obligation éco tenue de 7,2 kWh dès
le premier cycle — un nombre crédible et faux que rien ne signalerait.
Contre-épreuve : lecture directe, le test annonce 7204.
La base se prend à l'ouverture de l'OBLIGATION, jamais au démarrage de
nymead : sinon un redémarrage en pleine session réinitialise l'avancement
sur une valeur déjà accumulée, et la recharge de nuit relue au matin repart
de zéro. Elle est RECAPTURÉE sur un recul franc de l'accumulateur — une
nouvelle session — mais pas sur un recul infime, qui est du bruit de lecture
et coûterait l'avancement acquis à chaque frémissement du compteur.
Ce que le moteur ne refait PAS : la détection de remise à zéro de la BORNE
vit dans le plugin, qui interroge à 30 s. Le moteur travaille à la minute et
raterait des cycles entiers. Lire comment le plugin l'avait écrite a évité
d'ajouter une seconde accumulation, moins fine, en concurrence de la
première.
Trois maillons manquants raccordés, tous déclarés et alimentés par personne :
EvCharger n'exposait pas sessionEnergy ; LoadContextTelemetry::sessionWh
n'était rempli nulle part ; et toLoadContext() ABANDONNAIT les besoins
déclarés, si bien que LoadContext::needs n'était rempli par aucun adaptateur.
L'obligation éco n'existait qu'en configuration.
Mock : sessionEnergy ajouté à chargerPhaseSwitching UNIQUEMENT — `charger`
reste sans, ce qui donne le cas unmeasurable nativement. La suite porte les
deux régimes, comme le banc.
Le régime est publié alors qu'aucune décision ne s'y appuie encore, et c'est
délibéré : cela sépare publier le régime de décider avec, comme measurement
l'a été de l'arbitrage, et rend R6 testable le jour où le motif naîtra au
lieu d'un invariant posé sur du code que rien n'émet.
Suite : simulation 56/56, charging 48/48 identique à la référence ligne à
ligne, loadmodel 21/21, spotmarket 32/32, doxygen 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
La protection de surcharge ne connaissait que les bornes VE : une PAC ou un
routeur pouvaient tenir un dépassement sans que rien ne les redescende. Elle
déleste désormais les charges du waterfall, de la dernière servie à la
première — le même ordre total que la cascade, lu à l'envers, pour que
délester ne puisse pas contredire l'ordre dans lequel on a servi.
Délester n'est pas toujours retirer des watts. Une PAC ne se module pas : on
la fait redescendre d'ÉTAT, vers 2 et jamais 1. C'est le cas que le bornage
de l'étape 3 ne pouvait pas traiter — il empêche d'allouer, il ne retire
rien à personne.
Le franchissement de verrou se CONSTATE : on tente d'abord sans forcer, et
`minOnBypassed` n'est marqué que si le verrou a réellement refusé. Le
déduire d'un verrou simplement ACTIF serait faux — un verrou peut être armé
et permettre malgré tout le geste demandé. Le marqueur annoncerait alors un
court-cycle qui n'a pas eu lieu, et le premier compresseur réellement
maltraité passerait inaperçu au milieu des fausses alertes. Ce constat
n'était pas possible avant ECS-415.
On ne force que pour un plafond SUBI. Comportement observé, non postulé : la
contre-épreuve « forçage interdit » — qui est le comportement d'un plafond
EcoSelfImposed — laisse la PAC en état 4.
Un seul commandeur par organe : les charges délestées sont revendiquées pour
le cycle et le dispatch les saute, sinon la consigne du plan rallumerait ce
que la sécurité vient de couper.
La fenêtre d'exécution est étroite — après syncAdapters(), avant le dispatch
— et les deux placements écartés sont consignés au design §6bis parce qu'ils
se représenteront.
Ce lot a été repris après un arrêt : deux versions antérieures du test
passaient sans rien prouver, leurs prémisses étant supposées. Les trois
cycles sont désormais MESURÉS puis assertés, l'armement à froid compris.
Suite complète : simulation 54/54, charging 48/48 identique à la référence
ligne à ligne, loadmodel 21/21, spotmarket 32/32, doxygen 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Le surplus dit ce qu'on peut dépenser sans acheter ; l'autorisation de
soutirage dit ce que le branchement supporte. Une charge doit tenir dans
les deux. Jusqu'ici seules les bornes VE étaient bornées par la seconde —
une charge de waterfall pouvait donc créer un dépassement que seule la
protection L4 rattrapait, après coup. C'est ce qui change.
`SurplusContext::drawMargin` est une valeur de CYCLE, résolue une fois par
`houseDrawMargin()` et jamais relue : deux lectures à deux instants
feraient décider sur deux mondes. Chaque charge servie l'entame.
Le motif porte les quatre grandeurs gravées au §6.3 : la marge (SIGNÉE),
l'ampleur du dépassement, la source, et la phase qui borne. La phase n'est
publiée que s'il y en a une — un plafond total n'en a pas, et en annoncer
une enverrait mesurer au hasard. Précédence LM-1011 appliquée telle quelle :
DRAW_CAP seulement à autorisation NULLE, sinon BELOW_MIN_POWER.
Les bornes VE ne sont pas écrêtées par la marge de la maison, et ce n'est
pas un oubli : `evPhaseAllowanceW()` connaît LES PHASES QU'ELLES OCCUPENT.
Leur appliquer en plus la pire phase les priverait d'une phase saine parce
qu'une autre sature. Entre deux bornes justes on garde la mieux informée,
pas la plus sévère. Elles décrémentent la marge comme les autres.
LIMITE DE MODÈLE assumée (LM-1210-a) : `LoadDeclared::phases` est un nombre,
pas un masque — le moteur ignore sur quelle phase une charge est câblée. La
pire phase est donc retenue. Deviner, dans une couche de sécurité, se paie
au disjoncteur. Le pessimisme est borné en pratique et coûte du rendement,
jamais de la sécurité ; le lever demande un masque déclaré, donc config,
interface et migration. Documenté dans la spec et le design, à trancher.
Suite complète : simulation 52/52, charging 48/48 identique à la référence
ligne à ligne — les bornes ne bougent pas, c'est l'invariant du lot —
loadmodel 21/21, spotmarket 32/32, doxygen 0 avertissement. Contre-épreuve :
bornage débranché, la charge est servie 1000 W alors que l'autorisation est
déjà dépassée de 460 W.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
`phasePowerLimit` devient un `DrawCap{Breaker, perPhaseW}` et
`evPhaseAllowanceW()` traverse `resolveDrawMargin()` — la mécanique du
délestage est branchée, et rien ne bouge. C'est le but : à comportement
constant, tout écart observé ensuite est un défaut, pas une évolution.
L'égalité est obtenue par CONSTRUCTION. `phaseDrawW()` inverse
`calculateAllowanceAmpere()` phase par phase plutôt que de relire les états
du compteur : les lectures amont ne sont pas symétriques entre phases, et
les recopier ici en aurait fait deux versions vouées à diverger.
Deux déplacements sans changement de valeur : l'écrêtage `qMax(0, …)`
descend chez le consommateur (la résolution rend une marge SIGNÉE, dont
l'étape 4 a besoin pour dire de combien délester), et le facteur 230 remonte
dans le plafond, qui s'exprime en watts comme les autres.
Vérification : quatre suites diffées ligne à ligne contre une référence
capturée avant l'édition — charging 48/48 et simulation 50/50 identiques,
loadmodel 21/21, spotmarket 32/32. Contre-épreuve : à 1 V près la suite ne
discrimine pas (consignes en ampères entiers), donc cet essai ne prouve
rien ; à un facteur dix, 7 échecs. Le chemin est vivant.
Au passage, une `\note` du .h affirmait qu'on rend à la borne ce qu'elle
tire déjà. Le code ne l'a jamais fait, et c'est délibérément de sécurité :
sur une phase en dépassement la marge est négative, et c'est ce signe qui
ramène la borne à son minimum.
Décisions 6.1 à 6.3 gravées dans le design, et défaut amont consigné dans
SAFETY.md — non corrigé sciemment, il appartient à un [upstream-fix] isolé.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
Étape 4 du chantier de documentation. tools/ci-quality.sh porte les trois
vérifications qui se posent au même endroit — elles partagent la même sortie
(doc-generated/) et la même commande de réparation, les séparer ferait trois jobs
qui régénèrent trois fois la même chose :
1. l'index des règles est VRAI (code de retour de gen-rules.py) ;
2. la doc Doxygen est complète — 0 avertissement, DoD §5 ;
3. l'index est REPRODUCTIBLE : deux passes, un seul résultat.
PAS EN SERVICE, et l'extension le garantit : ci/gitea-workflow.yml.disabled ne
déclenche rien tant qu'il n'est pas déplacé dans .gitea/workflows/. La raison est
datée dans le fichier — forge muette, plus de trente commits en attente ici et
plus de soixante côté app ; un job activé à l'aveugle échouerait au moment précis
où tout le monde pousse, et le premier réflexe serait de le désactiver.
CHAQUE ÉCHEC DONNE LA COMMANDE À LANCER. Un job qui annonce « l'index est
périmé » sans dire quoi faire se contourne en le désactivant : c'est le chemin de
moindre effort, et il gagne toujours.
VÉRIFIÉ AVANT D'ÊTRE DÉCLARÉ PRÊT, et ça ne passait pas du premier coup.
— Les 26 avertissements Doxygen de ce matin sont CORRIGÉS, pas contournés par un
cliquet sur un compte de référence : c'était de la dette DoD-5 (retours et
paramètres non documentés dans relayrouter.h, energyarbitrator.h, loadconfig.h,
les trois clearFault(), ILoadAdapter::updateSoftConfig), plus deux que j'avais
introduits — un \rule{} dans un titre \par, et un \param energyLogs qui ne
correspond à aucun argument. Le seuil est donc ZÉRO, le seul qui ne rote pas.
— Et le script lui-même échouait sur un dépôt SAIN : `grep -c ... || echo 0`
affiche 0 PUIS sort en 1 quand il ne trouve rien, si bien que le « || » empile
un second zéro et que le test entier casse. Corrigé ici et dans gen-doc.sh, qui
portait le même piège. Une commande de mesure dont l'échec produit une lecture
fausse — le motif de la semaine, appliqué à l'outil.
CE QUI N'EST PAS VÉRIFIÉ, et pourquoi : « régénérer et refuser si ça diffère de
ce qui est commité » n'a rien à comparer, doc-generated/ étant dans .gitignore.
Le risque qu'un tel diff attraperait — un artefact commité qui dérive de sa
source — a été SUPPRIMÉ en ne le commitant pas, pas déplacé. Ce qui reste à
garantir est que l'index soit vrai (contrôle 1) et déterministe (contrôle 3).
État au 2026-08-29 : ./tools/ci-quality.sh → RC=0, 113 règles, 0 avertissement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
LM-1209-e. Le provisionnement vivait dans la branche « l'adaptateur vient d'être
créé » de syncAdapters(). Or l'adaptateur est construit depuis le THING : il
survit à la suppression de l'entrée de configuration. Une entrée supprimée depuis
l'app n'était donc jamais recréée — mesuré sur .75, rien en 140 s.
Le trou de 3g-2 rouvert par le côté client : la borne restait ARBITRÉE mais
ABSENTE de GetLoadConfig, à un rang de queue non réglable. Elle consommait du
budget sans que personne puisse la classer, et rien ne le disait.
La condition devient un ÉTAT et non un ÉVÉNEMENT — « cette borne n'a pas
d'entrée » au lieu de « l'adaptateur vient de naître ». C'est la leçon qui dépasse
le défaut : un rattrapage dont la condition est un événement ne rattrape rien,
puisqu'il ne se rejoue pas. Même distinction qu'entre ECS-411, qui relit l'état
réel des relais, et l'hypothèse de départ qu'il a remplacée.
L'entrée recréée revient en rankOrigin "auto" — personne ne l'a classée, son rang
est de nouveau un défaut — et le rang posé par l'installateur est PERDU avec
l'entrée. Supprimer n'est donc pas un moyen de retirer une borne de l'arbitrage :
elle revient, déclassée, en queue et avec son badge. C'est exactement pourquoi la
marque doit se voir. enabled: false reste la voie et conserve le rang.
Le refus de provisionnement n'est annoncé qu'une fois par borne
(m_provisionRefuse) : le rattrapage étant cyclique, le répéter produirait un
avertissement par minute et pour toujours — la règle est déjà posée pour les
charges sans décision dans buildTelemetry(). Le drapeau tombe au premier succès.
testDeletedEvChargerConfigComesBack couvre les deux moitiés : le rattrapage, et
le cas négatif — il ne se rejoue PAS quand l'entrée existe, sans quoi il
réécrirait la configuration à chaque cycle, donc un changed(), donc un rebuild
d'adaptateurs, donc des verrous réarmés en boucle (ECS-412). Vérifié échouant
avec l'ancien comportement.
Le brief §6-C, qui promettait à l'app l'inverse de ce qui se passait sur le seul
geste client qui détruit de la configuration, est corrigé — et dit désormais ce
que la suppression coûte vraiment : le rang.
Passe aussi LM-1209-d (grouper à l'affichage, jamais à l'écriture) et
l'avertissement de sonde en tête d'INTERFACE.md.
amd64 0/0. simulation 32/32, charging 16/16, loadmodel 20/20, spotmarket 7/7.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
LM-1208-b appliqué : `assignedCarId` sort du filtre d'entrée du waterfall.
Restent deux exclusions, et elles ne disent pas la même chose — le mode manuel
est une INTENTION (l'utilisateur pilote), l'absence de véhicule branché un
CONSTAT (rien à consommer de ce qu'on allouerait).
Le critère venait du proxy, où il a un sens : sans voiture, pas de SoC, pas de
capacité, pas d'échéance à planifier. Le waterfall n'a besoin d'aucune des trois
pour allouer du surplus. Il contredisait LM-1206 — le rang et le plancher
appartiennent à la borne, pas au véhicule — il n'était pas vérifiable, et il
excluait la borne d'un invité au moment précis où elle consomme. Ce qu'on perd
sans voiture est une FONCTION, échéance et cible d'énergie, pas l'arbitrage.
LM-1208-c, trouvé en implantant, et c'est lui qui aurait mordu : retirer le
critère ne suffit pas. Le proxy ne PRÉPARE pas une borne sans voiture —
prepareInformation() la filtre en entrée — donc elle entre dans le waterfall
sans ChargingProcessInfo. Or internalProcessInfo() rend un objet par DÉFAUT, et
ses valeurs sont plausibles : une phase, pas de bascule. Une borne triphasée
aurait été annoncée monophasée et le budget divisé par trois — plafond 3 680 W
au lieu de 11 040, 16 A commandés au lieu de 9, 11 kW tirés d'une enveloppe qui
en comptait 3,7. Le défaut de 3g-1 pris à l'envers, et silencieux.
D'où internalHasProcessInfo() : la question « cette borne a-t-elle été préparée
ce cycle » devient POSABLE au lieu d'être déduite d'une valeur crédible. Sans
ChargingProcessInfo on ne bascule pas — basculer suppose de savoir ce que la
voiture accepte — et on retient le phaseCount de la BORNE, hypothèse
conservatrice au sens de LM-104 : elle surestime la consommation.
Éprouvé par testChargerWithoutCarIsArbitrated, et VÉRIFIÉ ÉCHOUANT sans le
garde-fou : 3 680 W au lieu de 6 000 W alloués. Le test porte les deux moitiés —
la borne sans voiture est servie ET le mode manuel reste une exclusion, sans quoi
il passerait aussi si toute borne était arbitrée sans condition.
amd64 0 erreur / 0 avertissement. Suites, un processus par test : simulation
29/29 (dont run 16/16), charging 16/16, loadmodel 18/18, spotmarket 7/7.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
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
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
Troisième défaut latent de 3g-1, et le seul des trois qui touche la sécurité.
Corrigé à part du lot 3g-2, pour pouvoir être relu ou rétroporté seul.
L'écrêtage d'une consigne EV par la limite de phase vivait dans
planSurplusCharging() : `newValue = qMin(newRawValue, allowanceOnRootMeter)`.
En transplantant la décision de surplus dans le waterfall, 3g-1 a laissé cet
écrêtage derrière lui. Sans conséquence tant que rien n'était exécuté — mais
la décision du waterfall est censée l'être, et alors plus rien ne borne
l'allocation avant qu'elle ne parte au matériel. Le seul filet restant serait
verifyOverloadProtection(), APRÈS coup.
Le plafond redevient donc une borne DÉCLARÉE de l'adaptateur
(LoadContext.declared.maxPowerW), ce qui le place là où la règle 4 d'AGENTS le
veut : le waterfall ne peut pas allouer au-delà, au lieu d'être rattrapé.
Deux points de sécurité tenus explicitement :
- Le plafond ne descend JAMAIS sous le plancher de la borne. C'est la règle
de l'amont reprise mot pour mot (`if (newValueInt <= minValue) newValueInt
= minValue`) et la répartition des rôles qu'elle porte : la limite de
phase RABAISSE la borne à son minimum, elle ne la coupe pas. Couper est le
geste de la couche L4, qui tourne en temps réel sur powerBalanceChanged et
garde le dernier mot après le dispatch. Écrêter jusqu'à 0 ici ferait
décider la coupure par la stratégie.
- L'allowance est la place LIBRE, sans rendre à la borne ce qu'elle tire
déjà. Le retour serait plus juste en régime établi, mais ferait disparaître
la réponse à la surcharge : sur une phase en dépassement, la place libre
est NÉGATIVE, et c'est ce signe qui ramène la borne à son minimum.
Nouveau motif PHASE_LIMIT {limitW, requiredW} : quand la place libre tombe
sous ce que la borne exige, c'est la limite de phase qui interdit, pas le
budget. Dire « budget sous le plancher » enverrait attendre le soleil là où
il faut regarder l'abonnement et les autres consommateurs — deux causes, deux
gestes (règle 7-b).
Suite simulation `run` 16/16, testOverloadProtectionEcoMode vert.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
Deuxième défaut latent de 3g-1, corrigé à part du lot 3g-2 pour la même
raison que le précédent.
EvAdapter passait `currentPower / 230` comme courant de référence à la règle
de bascule 1↔3 phases, au motif — écrit dans le commentaire — que
l'allocation n'est pas encore connue à cet instant. Mais la bascule ne se
décide pas sur ce que la borne TIRE : elle se décide sur ce qu'elle POURRAIT
tirer. C'est la formule du proxy, `currentPower/230 + allowance`, et c'est la
seule qui puisse répondre « redescends en monophasé, il n'y a plus que
800 W ».
Avec le seul courant courant, une borne passée en triphasé y restait quel que
soit l'effondrement du surplus — et son plancher publié restait à 4,1 kW,
donc plus rien ne pouvait la servir. Le surplus, lui, EST connu avant
l'allocation : c'est la mesure du compteur.
Le terme de stockage de planSurplusCharging() (tampon de 500 W, réinjection
au-dessus du seuil) n'entre volontairement pas dans cette référence : il la
déplace à la marge et n'a d'effet que sur le choix 1↔3, jamais sur
l'allocation — laquelle est faite par le waterfall, avec un budget qui tient
compte du stockage.
Suite simulation `run` 16/16, charging vert.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
Défaut de 3g-1, resté latent parce que sa sortie n'atteignait que la
télémétrie. Il est corrigé à part du lot 3g-2 pour pouvoir être relu,
contesté ou rétroporté sans lui.
EvAdapter interrogeait EvCharger::canSetPhaseCount(), qui dit seulement que
la borne PORTE l'état desiredPhaseCount. La vraie question — la borne ET la
voiture acceptent-elles 1 et 3 phases — est tranchée une fois par cycle par
prepareInformation(), qui en tire canSwitchPhaseCount et le couple
min/maxPossiblePhaseCount. Les deux prédicats ont divergé : là où le proxy
appliquait `qMin(carPhaseCount, getBestPhaseCount(...))`, 3g-1 n'a repris que
l'appel.
Conséquence : une borne dont la combinaison ne permet qu'une phase se voyait
viser 3 phases. Son plancher publié triplait (1,4 kW → 4,1 kW) sans que rien
ne puisse jamais le franchir — la borne restait éternellement « sous son
plancher », avec un motif juste et une cause fausse.
Second défaut, dans applyAction() : le nombre de phases demandé était borné
par m_charger->phaseCount(), qui est l'état COURANT de la borne. Une borne en
monophasé ne pouvait donc jamais être commandée en triphasé, et le plancher
publié pour trois phases n'aurait de toute façon pas pu être servi.
evTargetPhases() cesse d'être un passe-plat et lit les bornes du manager, via
un accesseur [ETM] read-only de plus (internalProcessInfo). L'arbitre ne
redérive rien : une seconde version de cette règle divergerait de la première
au premier ajustement, ce qui est exactement ce qui vient d'arriver.
Suite charging 16/16, simulation `run` 16/16, testLoadTelemetryRpc vert.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
La transplantation. Les bornes partagent le budget, décrémentent la cascade et portent
un rang qui a enfin un sens — il était décoratif depuis le début.
Le défaut corrigé n'était pas théorique : planSurplusCharging() calculait l'allocation
une fois avant la boucle et ne la décrémentait jamais. Chaque borne recevait la totalité
du surplus. Avec 4 kW et deux bornes, ce n'était pas « 2 kW à chacune » mais « 4 kW à
chacune », et le rattrapage venait du compteur au cycle suivant, pas de l'arbitrage.
Trois motifs EV disparaissent — des suppressions, pas des remplacements. EV_ECO_MIN en
particulier : le courant minimum était la même idée que le plancher du §12, écrite une
seconde fois pour un seul mécanisme.
Le nombre de phases est décidé AVANT l'allocation et réutilisé à l'exécution, parce que
c'est lui qui fixe le plancher — 6 A valent 1,4 kW en mono et 4,1 kW en tri. C'était le
piège le plus silencieux du lot.
evReservedW est restreint au financement réseau plutôt que supprimé : ce qui reste
décidé en amont doit rester réservé, ce qui passe par la cascade ne le doit plus.
Et une correction attrapée à la première observation en vrai de BATTERY_RESERVE : il
était publié avec withheldW à 0 sur un surplus négatif, donc il disait « une règle vous
bloque » là où il n'y avait simplement rien.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
Deux champs optionnels de même forme sur LoadConfig — o:meterThingId, o:sensorThingId —
et leur publication en télémétrie. Rien d'autre : l'arbitre ne s'en sert pas pour
décider, et le §12 comme le §10 restent à faire.
« Aucun effet sur les décisions » est vrai par CONSTRUCTION, pas par discipline : les
adaptateurs ne voient jamais ces Things. attachMeasurements() les résout au moment de
publier, dans les deux chemins de télémétrie — y compris le mode dégradé, où un
exploitant veut précisément voir ce que la charge fait pendant que l'arbitre ne décide
plus rien.
measuredW est publié À CÔTÉ de allocatedW et jamais fondu dedans : l'un est ce que le
waterfall a alloué, l'autre ce qu'un compteur a vu. Les confondre rendrait la somme des
allocations irréconciliable avec budget.allocatedW — et injecter une mesure en retard
sur la commande réintroduirait l'oscillation que le recrédit a supprimée.
claimedThingIds() les exclut, et l'exclusion est maintenant commentée sur place : LM-201
vaut pour ce qui commande, ceux-ci mesurent. Sans ce commentaire, la symétrie avec
relays[] invitait à les ajouter — ce qui ferait refuser un compteur divisionnaire
partagé ou une sonde de pièce commune.
spec 0.2.1 : la précondition de livraison du §10 (extension du délestage au waterfall,
même lot ou avant, jamais après) est écrite comme CONDITION et non comme conseil — la
protection de surcharge ne déleste que les bornes, et des planchers éco qui
s'additionnent un matin sans soleil sur un branchement 6 kVA feraient disjoncter
l'installation. Et LM-1008 tranche la cible de confort : saisie en degrés, frontière de
l'arbitre en énergie, conversion à la couche domaine — et aucune conversion nécessaire
aujourd'hui, un comparateur suffit, c'est l'arrêt qui compte et pas le dosage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
Les trois premiers constats de BRIEF_agent_plugin.md, dans l'ordre de ce qu'ils coûtent.
§2 — Renommer une charge n'atteignait jamais l'adaptateur. updateSoftConfig() ne
portait que priority et needs, donc un changement de libellé seul était compté
« inchangée » : l'adaptateur gardait son ancien nom et le journal contredisait
GetLoadConfig jusqu'à la prochaine reconstruction. `label` entre dans la signature et
dans le prédicat soft de l'arbitre, sans entrer dans sameHardware() — mise à jour EN
PLACE, pas reconstruction. La vraie prise était le Q_UNUSED de SgReadyAdapter, hérité
d'avant le lot B-bis : sans lui, renommer la PAC — le cas même qu'on allait tester —
n'aurait rien changé, et on aurait conclu que le correctif ne marchait pas.
§3 — relays[] n'est plus comparé par index. Un réordonnancement reconstruisait la
charge : contacts ouverts, verrous réarmés à froid. Comparer comme un ENSEMBLE aurait
été faux dans l'autre sens — à somme égale la table retient la première combinaison
rencontrée, donc échanger deux relais de même puissance change le contact qui sert ce
palier. sameHardware() compare la TABLE DÉRIVÉE : deux listes qui produisent les mêmes
paliers avec les mêmes contacts sont matériellement identiques, par construction.
§4 — mechanism.stagesW publie les paliers atteignables, en télémétrie et pas dans
GetLoadConfig. La télémétrie n'a aucun chemin d'écriture, donc « lecture seule » y est
vrai par construction ; GetLoadConfig aurait cassé l'aller-retour neutre dont l'app
dépend pour écrire — elle renvoie verbatim ce qu'elle a lu, le champ serait revenu
dans SetLoadConfig, et un refus en bloc aurait emporté toutes les charges.
deriveStages() est l'implémentation unique de la combinatoire, partagée par les trois
usages. C'était le fond de la demande du §4 : une seule règle, pas de divergence.
tests/auto/loadmodel : cible neuve sans serveur nymea, 2 ms. Elle a attrapé un défaut
de la correction elle-même avant le banc — operator== comparait les listes de ThingIds
et voyait une différence entre fermer {K1,K2} et fermer {K2,K1}.
tools/repair_energylogs.py : §1, et ce n'est PAS un correctif de ce plugin. La cause
est extérieure et déjà corrigée (SunSpec float32 aux mots permutés, d08cf50 daté
05:30:14 UTC — la seconde du premier échantillon empoisonné). La séquelle, elle, était
définitive : à 7,5e33 kWh le plus petit incrément représentable vaut 1,67e18, donc le
compteur n'était pas faux mais GELÉ, et rechargé tel quel à chaque démarrage.
Appliqué sur .75 : 62 726 anomalies → 0, accumulation reprise et vérifiée en direct.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
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
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
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>
Cause racine, pas symptôme. rebuildLoadAdapters() détruisait TOUS les adaptateurs
à chaque SetLoadConfig, réarmant leurs verrous. Sur un ballon thermodynamique à
minOn de 300-600 s, un client qui réordonne ses priorités depuis l'app pouvait
faire court-cycler son compresseur. C'est de la protection matérielle.
L'arbitre mémorise désormais la config ayant servi à construire chaque adaptateur
(m_builtFrom) et ne reconstruit que si le MATÉRIEL a changé — type, câblage,
paliers, plafond, verrous (sameHardware()). Un changement de rang ou de besoins
passe par updateSoftConfig(), en place : ni m_currentStage ni m_lastSwitch ne
bougent, aucun relais n'est réécrit. Le log distingue créées / mises à jour /
inchangées / retirées.
updateSoftConfig est PURE VIRTUELLE, sans implémentation par défaut. Un défaut
vide silencieux ferait qu'un futur adaptateur construit depuis LoadConfig
ignorerait sans bruit les changements de rang ; là, le compilateur force la
décision. EvAdapter et SgReadyAdapter la déclarent sans effet, avec le motif.
Démarrage à froid : après un redémarrage de nymead, m_lastSwitch est
irrécupérable. lockWindow() traite désormais un horodatage nul comme
« commutation venant d'avoir lieu » (elapsed = 0), donc verrou ARMÉ pour sa durée
configurée. L'écriture naturelle (`valid && elapsed < minOnS`) fait l'inverse et
laisserait une boucle de redémarrage court-circuiter la protection compresseur
quand elle est la plus nécessaire. Armer n'est pas verrouiller
inconditionnellement : avec une durée nulle, `0 < 0` est faux et le verrou reste
inactif — un premier jet qui forçait le verrou a fait tomber trois tests
existants, qui avaient raison.
Test : testEcsRebuildPreservesLock — SetLoadConfig pendant une fenêtre de verrou
active, le relais reste fermé ; le délestage reprend une fois minOn écoulé.
Build amd64 0 erreur. Simulation : 12/12.
Réf. specs/spec_ecs.md §3 ECS-412 (0.5.1).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
L'arbitre construit et pilote les routeurs ; le repli L2 couvre les DEUX types ENSEMBLE
(atomique : pas d'intermédiaire avec charge active sans repli).
- Map unique m_loadAdapters (QHash<QString, ILoadAdapter*>) : RelayRouter + EtmVariableLoadAdapter
cohabitent par polymorphisme. registerRelayRouter() ajouté.
- rebuildLoadAdapters() : la distinction de TYPE vit ICI — RelayRouter si relays[], sinon
EtmVariableLoadAdapter. Au-dessus tout est ILoadAdapter (Setpoint W).
- Dispatch Setpoint AGNOSTIQUE au type : routage par loadId, le polymorphisme absorbe (pas de
if(RelayRouter)). Le scheduler distingue Setpoint (etmvariableload + relay-router) vs State
(sg-ready) — nature de l'action, pas classe concrète.
- Repli L2 : applyDegradedMode boucle sur TOUS les ILoadAdapter → Setpoint(0) force=true. Pour
le RelayRouter, coupe TOUS les relais (bypass minOn). Ferme le trou T2 pour le routeur.
- testMeterSilentFallback étendu au cas RELAIS : compteur muet → 2 relais OFF force=true,
restent OFF sur N cycles. Certifie la sécurité relais.
Build prod 0/0 ; suite (config/L2/migrés) verte. Étape qui rouvre le déploiement.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pont moteur↔app : déclaration des charges etmvariableload persistée et éditable,
construction des adaptateurs à chaud depuis la config (remplace le registre en dur).
- LoadConfig / LoadConfigNeeds (Q_GADGET typés, introspectables) — forme mot pour mot
du LoadDescriptor §4 (jonction inter-repos avec l'app).
- LoadConfigStore : persistance atomique /var/lib/nymea/energy-load-configuration.json,
validation en bloc, signal changed().
- Handler NymeaEnergy.GetLoadConfig / SetLoadConfig (+ notif LoadConfigChanged). GET typé
objectRef<LoadConfig> ; SET schéma inline o: (powerLevels/needs conditionnels §4).
- EnergyArbitrator : setLoadConfigStore + rebuildEtmVariableLoadAdapters (enabled==true
seulement, §9) ; repli L2 = setPowerSetpoint(0) force=true sur tout etmvariableload
(ferme le trou sécurité ouvert en T2).
- Tests simulation : migration ECS→etmvariableload (arrondi fixed/dynamic, recrédit,
délestage, round-trip powerSetpoint), budget partagé etmvariableload↔PAC, watchdog L2
réactivé, persistance + construction depuis config + injection RPC end-to-end.
PAC SG-Ready reste hors config (§8, gelée). Les 5 tâches du brief sont couvertes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
RuleBasedScheduler : buildSetpointAction (contrat rév. 2 §5) — fixed = arrondi au plus
haut powerLevels ≤ budget ; dynamic = clamp(budget, 0, maxPowerW) ; recrédit currentPowerW
de DÉBUT de cycle (invariant 8) ; résidu cascadé. Waterfall unifié : etmvariableload
(Setpoint W) + sg-ready (State) triés par priorité, un seul budget.
EnergyArbitrator : registerEtmVariableLoadAdapter, inclusion dans buildContext, dispatch
Setpoint par loadId (l'EV reste au proxy amont).
Infra de test : ThingClass mock etmVariableLoad (currentPowerW read / powerSetpoint write)
+ handler executeAction + helper addEtmVariableLoad — valide l'implémentabilité de
l'interface avant le thing réel. Les scénarios simulation migrés arrivent en T4.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>