138 Commits

Author SHA1 Message Date
Patrick Schurig
fc41c4ff40 test: suite etm-powersync-optimizer-conformance pour un optimiseur quelconque
Lance l'optimiseur de $ETM_POWERSYNC_OPTIMIZER_CMD (défaut : la référence,
mode normal) et y branche le vrai moteur : handshake direct accepté, GetPlan
répondus sous 10 s, session entière conforme au contrat embarqué (règles
sémantiques comprises), chaque plan appliqué sans fallbackCode, consigne hors
bornes écrêtée et surcharge délestée par L4. Relais transparent pour capturer
la session. README : la lancer contre un autre optimiseur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:46:53 +02:00
Patrick Schurig
07cb44d35f test: les requêtes réelles du moteur jugées contre le contrat
optimizerclient : chaque Handshake envoyé ; socketscheduler : tout ce que le
moteur envoie au faux optimiseur, plus testTheEngineSpeaksTheContractItServes
(GetOptimizerProtocol == embarqué == docs, requêtes jugées avec le contrat
servi) ; refoptimizer : session capturée par la référence, requêtes toujours,
session entière et règles sémantiques en normal et en 1.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:30:44 +02:00
Patrick Schurig
d95a65cb71 test: suite etm-powersync-protocolschema (contrat == docs == code)
Contrat embarqué identique à docs/ ; listes x-etm-generated égales au code
(ancre côté test, dump pour régénérer) ; chaque code de verdict, d'erreur et
de repli lu dans les SOURCES figure dans les listes ; règles sémantiques ==
verdicts du validateur, même ordre ; sérialiseur GetPlan jugé conforme sur
toutes ses branches par le validateur publié.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:30:44 +02:00
Patrick Schurig
5768c36953 fix(optimiseur): token d'au moins 16 caractères (correctifs brief 4, point 4)
Question 4 de T7 : le masquage à la réception remplace TOUTE occurrence du
token dans ce que l'optimiseur renvoie ; un token court, présent par hasard
dans un identifiant de charge, corromprait le plan reçu (unknown-load).

- SetOptimizerToken (writeToken) refuse un token non vide de moins de
  16 caractères (points de code UTF-8) : EnergyErrorInvalidParameter, fault
  `token-refused` comme les autres refus ; rien n'est modifié ; le motif est
  journalisé, jamais la valeur. Vide = retrait, inchangé.
- Le client relit le fichier au début de chaque tentative ; plus court que
  16 caractères, il ne se connecte PAS (ni avec ce token, ni sans token : les
  mesures du site ne partent pas vers un optimiseur que rien n'authentifie).
  lastErrorCode `token-too-short`, dit une fois par série, nouvelle tentative
  au backoff jusqu'au remplacement du fichier.

INTERFACE.md (SetOptimizerToken, lastErrorCode) et protocole §3 (E-T2-8,
forme du token). Tests : 15 refusé, 16 accepté, 15 « é » refusés (caractères,
pas octets), vide = retrait, fichier court sans connexion puis token conforme
envoyé ; tokens des suites portés à 16 au moins. Contre-épreuves M4a (refus
retiré) et M4b (fichier court envoyé) : mordent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:44:19 +02:00
Patrick Schurig
e3b916e249 fix(optimiseur): fallbackCode dit pourquoi on replie (correctifs brief 4, point 3)
Constat `fallbackCode` de T7 (E-T7-6) : une abstention, un plan expiré ou
refusé à la réception vidaient le cache, et le cycle suivant repliait avec
`no-plan` — l'app ne distinguait pas « l'optimiseur s'abstient » de « rien
reçu ».

Le code du verdict de réception d'une réponse non appliquée est gardé comme
code de repli jusqu'à l'issue de la requête suivante ; un plan valide
l'efface, un échec de requête ou une connexion perdue le ramènent à
`no-plan`, qui ne dit plus que « rien reçu ». Publication seule : aucune
décision ne change.

INTERFACE.md : liste fermée des valeurs de fallbackCode et leur lecture
(« pas d'optimiseur », « pas encore de plan », « l'optimiseur s'abstient »,
« plan refusé »). Tests : refoptimizer lit GetPlan.fallbackCode après chaque
cycle de chaque mode (abstain, expired, invalid, unknown-load, missing-load…,
puis retour normal sans fallbackCode) ; socketscheduler vérifie le code publié
pour chaque refus (les tests qui épinglaient `no-plan` sont mis à jour).
Contre-épreuve M3 (toujours `no-plan`) : mord.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:39:12 +02:00
Patrick Schurig
ce323a6e1f fix(optimiseur): une tentative de reconnexion n'est pas une nouvelle perte (correctifs brief 4, point 2)
Constat `drop` de T7 : SocketScheduler notait ConnexionPerdue à CHAQUE état
non connecté du client (Connecting, Waiting d'une tentative), réécrivait
lastErrorCode avec celui du client et émettait etatChange() à chaque
tentative au backoff (une par minute au plafond).

La perte se note désormais à la transition : sortie de Ready ou de
Handshaking (fermeture, handshake refusé), ou premier échec d'un client qui
n'a jamais été connecté (sinon un optimiseur absent dès le départ
n'aurait jamais de lastErrorCode). Les tentatives suivantes d'une panne déjà
notée ne touchent ni au dernier code, ni aux échecs, ni à l'état publié.
Publication seule : aucune décision ne change.

Test socketscheduler::testAReconnectionAttemptIsNotANewLoss : optimiseur
disparu, 3 tentatives au backoff → 1 seul etatChange, 1 seule notification,
lastErrorCode `connection-lost` (le client en est à `connect-failed`) ; au
retour, les transitions du §7 et rien d'autre. Ligne `drop` de refoptimizer :
le dernier code reste `disconnected`. Contre-épreuve M2 (ancien comportement) :
mord sur les deux.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:34:38 +02:00
Patrick Schurig
8c0645d371 feat(tools): l'optimiseur de référence s'annonce powersync-optimizer (correctifs brief 4, point 1)
Décision du 2026-10-17 : l'exemple GPL annonce `powersync-optimizer` dans le
`name` du handshake ; `etm-optimizer` reste le nom de l'optimiseur ETM,
propriétaire, hors de ce dépôt. Le fichier et son répertoire gardent le
préfixe des artefacts (`tools/etm-powersync-ref-optimizer/`).

README de l'outil, annexe E et tools/README.md le disent en une phrase ;
refoptimizer vérifie le `name` publié par GetOptimizerStatus (1.1 et 1.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:32:51 +02:00
Patrick Schurig
fb017f87a7 test(intégration): L2 éprouvé sous un plan d'optimiseur qui commande tout à fond (brief 4 T7)
La contre-épreuve E4 (L2 ne suspend plus la planification) ne mordait que par la ligne de
trace : le plan en cache valait déjà 0 avant L2. Le test ramène l'optimiseur à 2000 / 3000 W
et PAC forcée juste avant la coupure du compteur ; E4 mord désormais sur les consignes de repli.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:45:44 +02:00
Patrick Schurig
644741b0ef test(intégration): suite etm-powersync-refoptimizer, moteur réel + optimiseur de référence réel (brief 4 T7, §3)
Chaque test lance tools/etm-powersync-ref-optimizer en sous-processus (démarrage borné,
échec en clair, jamais bloqué).
- identité Q6 (banc restreint, passe confort seule) sur unix:// et tcp:// : ce que les mocks
  reçoivent au cycle i sous l'optimiseur == au cycle i-1 sous le rule-based seul, 36 cycles ;
- chaque mode de défaut → la ligne du tableau §7, sur le banc complet, et le retour ;
  notifications une par transition ; slow : update() ≤ 1 ms ;
- optimiseur 1.0 accepté ; traces rejouées sur un nouvel optimiseur : 0 divergence, puis
  mode deviate : divergence, code 1 ; L4 et L2 gagnent ; token recopié : nulle part ;
- catalogue des codes émissibles identique à celui du moteur.
8 fonctions, 33 / 0, 54 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:16:43 +02:00
Patrick Schurig
693e01579f fix(optimiseur): un token recopié par l'optimiseur ne sort par aucun journal (brief 4 T7, correctif A)
Constat de T6 (question 2) : la trace masquait un token recopié dans reason ou planId, mais
les lignes [Optimiseur] (planId, texte, détail des refus, nom du handshake) et
GetOptimizerStatus.name le portaient en clair.

- OptimizerSecret : UNE fonction de masquage (texte brut et échappé JSON, objet JSON à toute
  profondeur, clés comprises ; ligne de trace sérialisée), partagée par le client et les traces.
- OptimizerClient masque chaque message reçu dès son analyse, avec le token envoyé sur cette
  connexion : rien en aval (journal, traces, app) ne voit le secret ; dit une fois.
- Test testAnEchoedTokenLeaksNowhere : secret avec " et \ recopié partout ; aucune ligne de
  journal (tous niveaux), aucune trace, aucune réponse RPC. 4 contre-épreuves mordent.

decisions sans endpoint : IDENTIQUE (23 792 lignes), 0 rejeu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:10:15 +02:00
Patrick Schurig
10683744d2 test(traces): le traceur n'apparie une réponse qu'à la ligne de sa requête (brief 4 T6)
Contre-épreuve T4 : sans le contrôle d'identifiant du traceur, aucun test ne cassait — le
SocketScheduler écarte déjà les réponses obsolètes en amont. Ce test unitaire éprouve la
seconde garde seule (autre requête, aucune requête, réponse en double). socketscheduler :
34 fonctions, 117 / 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:24:02 +02:00
Patrick Schurig
483bbcb027 feat(traces): traces de décision NDJSON, une ligne par cycle (brief 4 T6, annexe D)
Réglage optimizerTrace (energy.conf Optimizer/trace, …RetentionDays, …MaxBytesPerDay), défaut
DÉSACTIVÉ : ni objet, ni fil, ni fichier. NymeaEnergy.GetOptimizerTrace / SetOptimizerTrace,
PermissionScopeAdmin comme SetOptimizerEndpoint, type contrôlé par le moteur, appliqué à chaud.

Une ligne par cycle — rule-based, optimiseur, et L2 (strategy none, sans requête) : t (heure
locale), request (params GetPlan envoyés, ou qui l'auraient été), response (ce qui a répondu
à CETTE requête : result brut, error.code, no-response ; sinon le plan du rule-based en codes),
executed (appliqué, clamped, shed), strategy, latencyMs. La ligne N attend la réponse à la
requête N (elle sert au cycle N+1).

Toutes les E/S sur un fil dédié (OptimizerTraceWriter) : rotation à minuit heure locale, purge
au-delà de la rétention (14 j), plafond par fichier journalier (64 Mio, mesuré : 5 Ko/ligne
rule-based, 39 Ko sous un optimiseur à 48 créneaux, 1 cycle/min), file bornée à 256 lignes.
Échec d'écriture et plafond dits une fois. update() ne fait que poster. Le token n'est jamais
dans une ligne ; recopié par une réponse d'optimiseur, il est masqué.

Preuves : decisions IDENTIQUE traces désactivées ET activées (21 132 lignes de trace écrites,
ETM_DECISIONS_TRACES=1). socketscheduler 33 fonctions, 114/0 (+10 fonctions T6).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:12:25 +02:00
Patrick Schurig
1f6c7ec27a refactor(plan): le plan rule-based porte strategy « rules », comme activeStrategy (brief 4 T6)
Décision de Patrick du 2026-10-17 : un seul vocabulaire. Plan::strategy du rule-based,
GetPlan.strategy et la doc passent de « rule-based » à « rules » (INTERFACE.md, protocole §6).
Les libellés de journal (« repli rule-based ») ne sont pas des valeurs et restent.

Référence decisions régénérée. Preuve AVANT régénération, moteur de e592618 + ce seul
renommage, contre l'ancienne référence : comparateur --strategie (nouvelle option, qui ne tolère
que plan.strategy rule-based → rules) IDENTIQUE HORS STRATÉGIE — 23 792 lignes, 23 728
identiques à l'octet, 64 qui ne diffèrent que par .plan.strategy (décompte indépendant des
chemins JSON : 64 × .plan.strategy "rule-based" → "rules", rien d'autre). 0 rejeu. Le même
build, relu contre la nouvelle référence : IDENTIQUE ; 6 suites vertes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:46:36 +02:00
Patrick Schurig
e5926189e9 fix(optimiseur): token déclaré String, type contrôlé par le moteur ; GetPlan sous verrou (brief 4 T4)
Sous Qt 6, le validateur nymea ne contrôle pas le type des paramètres de base : la
déclaration Variant ne protégeait de rien (contre-épreuve inerte), le contrôle du handler
est la seule garde. Retour à String, plus lisible à l'introspection ; INTERFACE.md corrigé
(seule une clé EN TROP fait recopier les paramètres au journal par nymea).

testGetPlanShowsTheAppliedPowerUnderALock : sous le rule-based, un verrou minOn tient le
relais ; GetPlan publie l'appliqué. Avec l'écrêtage amont retiré, c'est la relecture de
l'appliqué qui protège seule (double mutation, elle mord).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 09:47:09 +02:00
Patrick Schurig
c1ce67942c feat(optimiseur): machine de repli §7, état et plan publiés vers l'app (brief 4 T4)
- OptimizerStatus::Vivacite : optimizerAlive, échecs comptés PAR REQUÊTE (E17), deux
  consécutifs = mort, socket fermé = mort, retour au premier plan valide ; une abstention
  rompt la série sans ressusciter. strategieActive() : none sous L2, prioritaire sur tout ;
  rules dès qu'un optimiseur n'est plus vivant, sans attendre le cycle suivant.
- SocketScheduler : branchement de la vivacité, cache abandonné à la mort, UpdateLearned
  journalisé une fois et ignoré.
- NymeaEnergy.GetOptimizerStatus + OptimizerStatusChanged (une notification par
  transition, charge utile déclarée une fois) ; champs installateur absents s'ils sont
  inconnus ; jamais le token, tokenConfigured seulement. Pas de tier (Q5).
- SetOptimizerEndpoint (persisté, appliqué à chaud) et SetOptimizerToken (écriture seule,
  fichier 0600, Q2), tous deux PermissionScopeAdmin ; token déclaré Variant pour qu'un
  mauvais type ne soit pas recopié au journal par le validateur nymea.
- NymeaEnergy.GetPlan : plan en vigueur (appliqué au créneau courant, demandé ensuite),
  codes et paramètres filtrés seulement, inForce toujours présent, fallbackCode quand un
  optimiseur configuré n'a pas commandé.
- Tests : tableau §7 ligne par ligne, transitions notifiées une fois, RPC, token absent de
  tout journal du moteur et de toute réponse.

Sans endpoint : decisions IDENTIQUE (23 792 lignes), suites inchangées, journal >= info
inchangé (8 739 lignes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 09:34:39 +02:00
Patrick Schurig
696cb001f1 test(decisions): référence régénérée après le correctif c (etm71)
Moteur de 6481515 avec les scénarios de ff7c801, contre l'ancienne référence :
IDENTIQUE HORS MOTIFS ET HORS ACHAT (comparateur --achat) — 23 772 lignes, 23 765 identiques à
l'octet, 7 qui ne diffèrent que par progress.purchasedWh, toutes « 0 → absent » (régime
unmeasurable). Aucune consigne ne change. S'y ajoutent les 20 lignes du nouveau
testPurchasedIsCountedOnTheMeasuredEnergy : 68 fonctions, 23 792 lignes. check : IDENTIQUE, 0 rejeu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 01:55:14 +02:00
Patrick Schurig
c391d13312 test(decisions): comparateur étendu à purchasedWh (--achat) et liste des lignes (--lignes)
Le correctif c change une VALEUR PUBLIÉE (progress.purchasedWh), pas une consigne. --achat
retire en plus ce champ de chaque niveau et dit, par scénario et charge, l'ancienne et la
nouvelle valeur (ou « absent ») ; --lignes écrit chaque ligne qui change. Sans option, le
comportement d'etm70 est inchangé.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 01:51:21 +02:00
Patrick Schurig
c81c7b6225 fix(optimiseur): pv.currentW publié dès qu'un smartmeterproducer existe, pas seulement un solarinverter
`solarinverter` étend `smartmeterproducer` dans libnymea. Un compteur de production posé sur un
onduleur non communicant n'est que `smartmeterproducer` — c'est le PV du banc (sdm120Producer) :
le filtre précédent y rendait `pv.currentW: null` alors que l'EnergyManager additionnait bien
sa production. Même critère que la présence d'un producteur côté apprentissage.

- OPTIMIZER_PROTOCOL.md §5.1 : `pv.currentW` documenté ; un seul producteur déclaré par source
  PV (nymea additionne les producteurs).
- EnergyManagerMock::setCurrentPowerProduction() (test) : la production du mock valait toujours 0.
- Test testPvIsReadFromAnyProducer : sans producteur → null ; smartmeterproducer seul → 2400.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 01:51:21 +02:00
Patrick Schurig
6481515b64 fix(moteur): « acheté » compté sur l'énergie mesurée, pas sur la consigne (correctif c)
`noterAchatEco()` intégrait la CIBLE éco avant palier et verrou : sur un relay-router, une
cible de 167 ou 495 W laisse le relais ouvert et ces watts étaient comptés achetés ; sous un
optimiseur, l'ombre rule-based notait sa propre cible, jamais exécutée.

Règle (choix par défaut du brief) : achatWh += ΔE × partÉco, ΔE = énergie MESURÉE de la charge
depuis le cycle précédent (sessionWh), partÉco = min(1, Σ targetW « eco » / estimatedPowerW)
de l'action APPLIQUÉE au cycle précédent (m_actionsAppliquees, lue avant le dispatch). Niveau
unique `optimizer`, charge délestée ou rien d'appliqué : part 0. Un recul ne contribue jamais.

- Sans mesure d'énergie : purchasedWh ABSENT (EcoProgress::purchased), jamais 0 ni la commande.
  Non borné par l'import du site : attribution ambiguë avec plusieurs charges.
- Comptage à chaque cycle, AVANT ecoProgress() : la valeur publiée inclut l'intervalle écoulé,
  et l'intervalle qui enjambe l'échéance va à la période close (base gardée à la bascule).
- Aucune décision ne lit purchasedWh : consignes inchangées. update() inchangé.
- INTERFACE.md : contrat réécrit (« publié dans TOUS les régimes » retiré).
- Recorder `decisions` : purchasedWh absent sans mesure, comme la RPC.
- Tests : testPurchasedIsCountedOnTheMeasuredEnergy (simulation, cas i-vi) et
  testThePlanOfAnOptimizerBuysNothingEco (socketscheduler, cas vii).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 01:51:21 +02:00
Patrick Schurig
ff7c8011e2 feat(optimiseur): SocketScheduler — le plan d'un optimiseur appliqué par le chemin commun (brief 4 T3)
Réglage `Optimizer/endpoint` (energy.conf), lu au démarrage par
EnergyArbitrator::relireReglageOptimiseur() — lecture seule, la RPC viendra
en T4. ABSENT : aucun SocketScheduler ni OptimizerClient n'existe, update()
interroge le rule-based comme avant (decisions IDENTIQUE, 23 772 lignes).

SocketScheduler (IScheduler) : à chaque cycle, le rule-based en OMBRE (effets
de bord conservés), puis GetPlan envoyé sans attendre (getPlanParams + fil de
l'arbitre : compteur racine, PV, phases, réserve, domaines, mesures,
prévision réelle) ; si le plan en cache est valide pour CE cycle, ses actions
remplacent celles de l'ombre (OptimizerPlan::substituer). Sinon rule-based :
plan absent, invalide, expiré, abstention, connexion perdue. Un plan invalide
vide le cache. Les actions passent par applyActionsToAdapters comme celles du
rule-based ; apresDispatch() relit l'appliqué (Q3, Q4, clamped), avant la
fusion L4. L4 et L2 inchangés : L4 garde le dernier mot, L2 suspend tout.

Déclencheur réel (§5.1 trigger) : intent (ChargingInfo), load-change (Thing
ajouté/retiré), config (SetLoadConfig, limites, réserve), forecast (révision
nouvelle) ; le plus significatif l'emporte, consommé à l'envoi.

Journal : le texte libre de l'optimiseur y va (assaini), jamais dans la
télémétrie ; lignes en clair au changement seulement (stratégie, motifs,
écrêtage). Version du moteur du handshake lue dans debian/changelog.

Points d'accroche T4 : reponseJugee(), echecRequete(), strategieDuCycle().
INTERFACE.md : niveau unique « optimizer », budget/draw sous l'optimiseur,
préséance L4 > indisponible/dégradé > optimiseur > éco|confort.

Tests : suite etm-powersync-socketscheduler (12 fonctions, 43 passés) —
faux optimiseur unix et tcp dans le processus, arbitre réel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:06:45 +02:00
Patrick Schurig
a8c58d02f0 feat(optimiseur): validateur pur du plan d'un optimiseur — OptimizerPlan (brief 4 T3)
Fonctions pures (ni E/S ni horloge, « maintenant » = ctx.timestamp) qui
portent toute la sémantique du plan reçu ; le SocketScheduler les enchaînera.

- valider() : JSON → plan interne ou verdict à code stable. Créneaux à
  décalage horaire explicite, ordonnés, sans chevauchement ; loadId connu et
  unique ; kind cohérent avec le mécanisme et annoncé dans capabilities.kinds ;
  reason non vide ; valeurs dans leur domaine ; validUntil ; créneau courant ;
  COUVERTURE Q1 (toute charge disponible nommée). Abstention / expiré /
  invalide distingués ; un plan invalide n'est jamais converti en partie.
  `force` et champs réservés (annexe F) ignorés et listés.
- motifFiltre() : code hors liste blanche → OPTIMIZER_DECISION sans
  paramètres ; sinon seules les clés documentées (optimizerParamKeys, testé
  contre le tableau d'INTERFACE.md), valeurs nombre/booléen/code [A-Za-z]+.
  Le texte libre reste dans ActionRecue::texte, assaini, jamais dans le motif.
- substituer() : le créneau courant remplace les actions de l'ombre à leur
  place ; charges indisponibles → action de l'ombre ; bornes commandées
  sorties du proxy (waterfallEvIds) ; budget/draw de l'ombre jamais gardés.
- finaliser() : après dispatch, puissance APPLIQUÉE, niveau unique
  « optimizer », financement dérivé de la mesure (surplus puis réseau),
  counts cohérents, budget et draw recalculés (Q3, Q4), `clamped`.

LoadAction::clamped ajouté (interne, non publié dans ce lot).
Aucun appelant dans ce commit : le moteur est inchangé.

Tests (loadmodel) : 7 fonctions, dont 31 verdicts data-driven.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:06:33 +02:00
Patrick Schurig
7597c87e58 test(optimiseur): faux optimiseur de T2 extrait dans un en-tête partagé
Le FakeOptimizer (QLocalServer/QTcpServer du processus), waitFor(), settle()
et handshakeResult() quittent testoptimizerclient.cpp pour
tests/auto/common/etm-powersync-fakeoptimizer.h, sans changement de
comportement, afin que la suite socketscheduler (brief 4 T3) les réutilise.

Seul ajout : un répondeur optionnel (`responder`) qui consomme les messages
reçus au lieu de les filer — vide par défaut, la suite optimizerclient ne
l'utilise pas.

optimizerclient : 16 fonctions, 67 passés, 0 échec (inchangé).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:06:17 +02:00
Patrick Schurig
f2f65980b3 test(decisions): référence régénérée après les correctifs a et b (etm70)
Changement VOULU, encadré avant régénération :
1. Moteur e6bc32d + tests de a280820, comparés à l'ancienne référence par
   etm-powersync-compare-hors-motifs.py : IDENTIQUE HORS MOTIFS, 23 739
   lignes, 23 724 identiques à l'octet, 15 qui ne diffèrent que par le
   motif publié (toutes des plans directs du scheduler : SURPLUS_INSUFFICIENT
   → ECO_FLOOR_GRID, et → ECO_FLOOR_MISSED au cycle de clôture). Aucune
   consigne, powerW, état, commutation, budget ni soutirage ne change.
2. Moteur et tests de HEAD : identiques à 1. ligne pour ligne hors des deux
   fonctions touchées ; testEcoFloorMissedSaysWhatWasLostAndWhy garde ses
   7 cycles en préfixe et en ajoute 13, testTheDecisionDescribesTheMergedAction
   ajoute 19 cycles. 67 fonctions, 23 705 cycles.
3. check sur la nouvelle référence : IDENTIQUE (23 772 lignes), 0 rejeu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:53:05 +02:00
Patrick Schurig
cfa4ad9c6b test(decisions): comparateur « identique hors motifs » pour les lots qui ne changent que des motifs
etm-powersync-compare-hors-motifs.py retire de chaque ligne de la
référence le seul motif publié d'une charge (telemetry.loads[].decision,
plan.timeSlots[].actions[].reason) et le manque chiffré d'une clôture non
tenue (missingWh, closedMissingWh), puis exige l'égalité de tout le reste :
consignes, powerW, états, relais, courants, budget, draw, counts, niveaux.
Il liste les cas qui changent (fonction, charge, ancien → nouveau motif).
Sert de preuve aux correctifs a et b (etm70) avant de régénérer la référence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:24:29 +02:00
Patrick Schurig
e6bc32dc85 fix(moteur): ECO_FLOOR_MISSED dit, journalisé et notifié ; livré de clôture complet (correctif b, etm70)
Cinq causes cumulées faisaient qu'une clôture non tenue n'était lue nulle
part hors de levels[0] :
- le niveau de clôture (0 W) ne prenait pas la tête du motif : il la prend
  désormais au seul cycle de la bascule (sous LOAD_UNAVAILABLE, qui garde
  la préséance) ;
- la ligne de bascule disait « base reprise » sans tenue ni manque, et un
  commentaire affirmait à tort « Le journal, lui, le dit » : elle dit
  maintenant « CLOSE NON TENUE — livré X/Y Wh, manque Z Wh, cause C »
  (avertissement), ou « close TENUE — livré X/Y Wh » ;
- le filtre de notification ne voyait que le code de tête : un niveau
  ECO_FLOOR_MISSED force désormais LoadTelemetryChanged, comme
  derogationEnded.

Défaut corrigé au passage : le livré de clôture oubliait les tronçons clos
(m_ecoLivreWh). Avec une mesure intermittente (800 Wh, trou, +500 Wh sur
2000), missingWh publié valait 1500 au lieu de 700. C'est une valeur
PUBLIÉE (motif, journal), pas une consigne.

Test testEcoFloorMissedSaysWhatWasLostAndWhy étendu : tête du motif au
seul cycle de clôture, ligne de journal chiffrée, livré intermittent →
missingWh 700, notification d'une charge indisponible sans autre changement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:16:13 +02:00
Patrick Schurig
e6ee513dcc fix(moteur): le motif publié décrit l'action fusionnée (correctif a, etm70)
Quand la passe éco et la passe confort se cumulent sur une charge en
consigne de puissance (relay-router, etmvariableload, borne « éco +
minimum »), la fusion additionnait les watts sans toucher au motif : une
consigne achetée au réseau se publiait « surplus insuffisant ».

Règle décidée par Patrick le 2026-10-16 : le motif publié est celui de la
part MAJORITAIRE de la consigne, égalité → éco, part éco nulle jamais en
tête ; LOAD_UNAVAILABLE garde la tête (préséance L4 > indisponible /
dégradé > éco | confort). Les deux parts restent dans levels[].

- decision.level = le niveau dont le motif est publié (publishedLevel),
  au lieu du « dernier parcouru » ; règle écrite dans INTERFACE.md R7.
- Journaux [Arbitre], [RelayRouter], [EtmVariableLoadAdapter] : un helper
  unique renderLevelsFr() rend la ventilation quand il y a deux niveaux ;
  ligne inchangée pour une action à un seul niveau.
- Aucune consigne, aucun powerW, aucun budget/soutirage ne change.
- Test testTheDecisionDescribesTheMergedAction : 3 charges × (éco seule,
  égalité, confort majoritaire, sans éco, ECO_FLOOR_MET), journal et RPC.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:11:51 +02:00
Patrick Schurig
92f13cf677 test(decisions): réseau privé par processus (--unshare-net) dans run-decisions.sh
Deux agents testent désormais en parallèle sur la même machine ; le /tmp
privé n'isolait pas les ports fixes du harnais (4847). Recommandation des
rapports brief 4 T1/T2, déjà appliquée dans le script de preuve hors dépôt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:54:25 +02:00
Patrick Schurig
a280820138 merge: brief 4 T2 (client de transport) dans feature/forecast-protocol-1.1
Fusion de feature/forecast-protocol-1.1-t2 (0fdbede). Conflit résolu dans etm/etm.pri :
optimizerprotocol.* (T1) et optimizerclient.* (T2) sont tous deux listés.
Cible de test renommée etm-powersync-optimizerclient (règle de nommage du 2026-10-16).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:02:38 +02:00
Patrick Schurig
7044b4cd07 fix(optimiseur): pas de puissances de cycle sous declared pour une borne (brief 4 T1)
Pour une borne, LoadDeclared::minPowerW/maxPowerW sont recalculés à chaque cycle par
EvAdapter::toLoadContext (phases visées, limite de phase) : ce ne sont pas des valeurs de
plaque, et le §5.3 réserve `declared` à ce que l'installateur a saisi. Ils ne sont plus
émis ; l'exemple du protocole n'en porte pas non plus. `phases` (champ 1.0) reste émis,
mais il porte lui aussi les phases VISÉES : écart E26, proposition de correction du
protocole dans le rapport T1/T5.

Golden site-pv-ecs-borne-sgready : seules les deux clés disparaissent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 16:21:48 +02:00
Patrick Schurig
50a1f91feb feat(motifs): OPTIMIZER_DECISION, PRICE_SLOT, knownCodes() et liste blanche optimiseur (brief 4 T5)
- OPTIMIZER_DECISION : code générique d'une action d'optimiseur dont le reasonCode est
  absent, inconnu ou hors liste blanche ; sans paramètres ni texte (invariant 12).
- PRICE_SLOT (priceCtkWh, o:setpointW) : décision prise sur le prix du créneau, demandée par
  l'app (exigence 5). Aucun émetteur interne — le rule-based ne lit pas le prix (§11).
  EV_SPOT_MARKET ne convenait pas : il nomme un fournisseur hors du build et ne vaut que
  pour une borne.
- DecisionCode::knownCodes() : la liste énumérable du catalogue (« connu » se lit ici).
- DecisionCode::optimizerEmittableCodes() / optimizerMayEmit() : liste blanche E14 —
  SURPLUS_SETPOINT, SURPLUS_INSUFFICIENT, BELOW_MIN_POWER, EV_DEADLINE, SG_FORCED,
  SG_RECOMMENDED, SG_NORMAL, ECO_FLOOR_GRID, PRICE_SLOT, OPTIMIZER_DECISION. Jamais la
  sécurité, les verrous, l'indisponibilité, le plafond, la réserve, la dérogation ni les
  états du moteur.
- renderFr : deux branches ; INTERFACE.md : les deux lignes (libellés FR/DE/EN proposés
  pour l'app) et l'encadré de la liste blanche.

Rien n'appelle ces fonctions : sans endpoint, aucun effet.

Tests (loadmodel) : anti-dérive — knownCodes() confronté aux constantes de l'EN-TÊTE lu par
le test, chacune avec un rendu autre que « motif inconnu » ; liste blanche ⊂ catalogue et
sans aucun code moteur. Contre-épreuves : constante ajoutée sans entrée, branche SG_NORMAL
retirée → échec.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 16:21:11 +02:00
Patrick Schurig
0fdbede6c3 feat(optimiseur): client de transport unix/tcp vers l'optimiseur (brief 4 T2)
OptimizerClient : QLocalSocket ou QTcpSocket, NDJSON, JSON-RPC 2.0, identifiants
croissants sur toute la vie du client, une requête GetPlan en vol au plus (la plus
récente remplace l'attente ; la réponse obsolète est reconnue par son id et ignorée).

Handshake §3 : majeure comparée, capabilities mémorisé (présent ≠ absent), délai de
5 s compté depuis le début de la tentative ; reconnexion au backoff 1 → 60 s, remis
à 1 s après un handshake accepté. GetPlan : 10 s, connexion conservée. Ligne non
JSON, message sans id, id jamais émis, ligne de plus de 1 Mio : erreur comptée,
connexion conservée.

Horloge et réveil injectables (OptimizerClientClock, monotone) : aucune lecture de
l'horloge murale. Token relu à chaque connexion dans settingsPath()/
energy-optimizer.token, jamais journalisé ; writeToken() pose le fichier par
QSaveFile en 0600 (droits posés avant l'écriture du secret). Pas de RPC.

Le client n'est instancié nulle part dans le moteur. Suite légère
nymea-energy-optimizerclient : faux serveurs QLocalServer/QTcpServer, temps simulé,
37 cas ; 17 mutations du client, toutes mordent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 16:12:18 +02:00
Patrick Schurig
13114a2695 feat(optimiseur): sérialiseur GetPlan, protocole 1.1 §5.1-§5.3 (brief 4 T1)
OptimizerProtocol::getPlanParams(SurplusContext, ContexteFil) rend les params d'une requête
GetPlan. Fonction pure, compilée dans le plugin mais appelée par personne : sans endpoint,
rien ne change.

- La prévision voyage À CÔTÉ du contexte (§11) : ContexteFil::prevision porte la sortie de
  EnergyArbitrator::instantanePrevision(), l'assemblage partagé avec GetForecastSnapshot.
  forecastDepuisInstantane() la pose sur la grille §5.2 : Wh par pas → p50W moyens
  (Wh × 3600 / resolutionS), case sans point = null, pv null sans courbe, weather null.
- ContexteFil porte aussi trigger (sa source viendra en T3), meter.source, la PV
  instantanée, la limite de phase, la réserve, le domaine et la mesure par charge.
- Inconnu = null ou absent, jamais 0 : contractedPowerW, pv.currentW, capacité et
  puissances batterie, tarif → null ; learned, comfort, group, champs réservés, force,
  powerLevels → absents ; borne configurée sans appareil → ni déclaration ni verrous.
- meter.exportW brut (règle 8). Champs 1.0 conservés ; energyTargets dérivés par
  resolveDeadline (installer) et de la dérogation à cible (user-intent).

Tests (loadmodel, logique pure) : 4 goldens dans data/optimizer (site sans PV ; PV + ECS
relay-router + borne + SG-Ready ; prévision absente ; charge available=false figée),
régénérables sous ETM_REGENERER_GOLDEN=1, plus 3 tests d'assertion. Contre-épreuves :
contractedPowerW à 0, Wh non convertis, exportW net → chacune fait échouer au moins un test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 16:01:34 +02:00
Patrick Schurig
e5c649e69a test(décisions): horloge murale figée au démarrage de chaque processus (brief 4 T0.5)
Constaté le 2026-10-16 : sur un code inchangé, la vérification échouait une heure après la
génération de la référence. testTheLearningLogIsFedByTheCycle date ses entrées sur
QDateTime::currentDateTimeUtc() (l'heure courante plus trois jours) : la référence ne valait
que dans l'heure UTC où elle avait été produite.

L'enregistreur définit clock_gettime : avec ETM_DECISIONS_EPOCH, CLOCK_REALTIME part de cet
instant au premier appel puis avance au rythme réel ; les horloges monotones (QTimer,
QElapsedTimer) ne sont pas touchées. Le lanceur pose 2026-10-16T05:00:00Z, l'heure où la
référence a été générée : la référence commitée n'est pas régénérée, elle est reproduite à
l'octet (md5 ea2c1db7…, 23 739 lignes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:18:09 +02:00
Patrick Schurig
d4b7d4dced test(décisions): référence au bit près des scénarios de simulation (brief 4 T0.5)
Preuve « sans endpoint = identique au bit près », posée AVANT tout refactoring.

`nymea-energy-decisions` est la suite `simulation` compilée à l'identique, plus un
enregistreur branché à l'édition de liens (`--wrap`) : aucune ligne de la suite ni du
moteur n'est modifiée. Chaque cycle (`simulationCallUpdate`) écrit une ligne NDJSON :
télémétrie de l'arbitre (actions du créneau dans l'ordre, motifs, niveaux, budget, soutirage,
consigne posée), actions laissées au proxy, état des Things. Les scénarios qui interrogent
un `RuleBasedScheduler` local voient leur `Plan` sérialisé champ par champ.

Exclus : `command.since` et les compteurs `totalEnergy*` du mock (horloge murale), les
ThingId (alias par rang dans le créneau, à chaque ligne : deux bornes identiques
reçoivent leur rang provisionné dans l'ordre d'un QHash). `planId` n'est pas lu.

Référence générée sur 83483cc (1.15.2+etm69) : 66 fonctions, 23 673 cycles + 36 plans
directs, deux exécutions identiques à l'octet. Chaque processus a un /tmp privé : libnymea
range l'état de test sous /tmp/nymea-test, partagé et cumulatif (348 bornes orphelines
trouvées dans energy-load-configuration.json).

  tests/auto/decisions/run-decisions.sh <build> check

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 07:24:00 +02:00
Patrick Schurig
bb34c6ed5e feat(prévision): base et prix sur la grille de GetForecastSnapshot, RPC Get/SetTariff
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>
2026-10-14 10:05:43 +02:00
Patrick Schurig
66c81086a9 feat(base): BaseloadLog — minutes du bilan, profil médian, qualité dite
Brief 3 T4. Une minute = consommation du site (échantillon d'une minute du
bilan, jamais ses cumuls) moins chaque charge pilotée selon sa source ; sous
none, sa puissance commandée, et la minute le garde. Deux tables dédiées
dans energy-forecast.sqlite (baseloadMinute, baseloadMinuteLoad), fil
d'écriture propre, lots de 5 min, lecture par journée, rétention 35 jours.
forecastPv et forecastLearning ne sont pas touchées.

Profil : médiane par case locale et type de jour (ouvré/week-end) sur
28 jours ; none sous 7 jours couverts à 90 %. Qualité : minutes comptées à
la commande, négatives, écartées, et par charge source et
constantMeasurement (la constante déguisée du banc).

Tests loadmodel : soustraction par source, profil synthétique (médiane,
jours de 23/25 h), seuil des 7 jours, fichier etm67 réel intact, jamais
bloquant, redémarrage, rétention, fichier illisible.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:51:23 +02:00
Patrick Schurig
e3816ee031 feat(tarif): TariffConfig — tarif saisi validé, plages murales, moyenne pondérée
Brief 3 T4. Une zone = {label, priceCtkWh, ranges[]}, plusieurs plages par
zone ; tarif unique = une zone 00:00-24:00 (source fixed), sinon tou.
Validation stricte et codée (prix ≥ 0, "HH:MM", 24 h sans trou ni
chevauchement, minute en cause rendue). importSeries() lit les plages en
heure murale, minute absolue par minute : jours de 23 h et 25 h exacts ; un
créneau à cheval reçoit la moyenne pondérée par la durée. Aucune décision ne
le lit (§11).

Tests loadmodel : validation, jours de 23/25 h et créneau à cheval sur
oracles Python zoneinfo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:51:17 +02:00
Patrick Schurig
d80b7d02d6 feat(prévision PV): le cycle lit observedGti/observedGtiTime sur le Thing plan
Lus par leur nom, comme kwp. observedGtiTime == 0 ou états absents : rien.
Copies seulement (§11) : aucune décision ne lit ces valeurs, valid n'en
dépend pas.

Mock : pvPlane porte les états observedGti et observedGtiTime (noms
d'openmeteo). testTheLearningLogIsFedByTheCycle : le GTI observé publié pour
t0 + 1 h complète la ligne déjà écrite du pas t0, sur son plan seulement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:21:18 +02:00
Patrick Schurig
dd24cda445 feat(prévision PV): forecastLearning porte le GTI observé — migration etm67 en place
Trois colonnes nullables : gtiObserved (W/m²), gtiObservedSource
(openmeteo-satellite-state), gtiObservedAt (observedGtiTime tel que publié).
Rattachées au pas [T − 1 h, T) : même convention que gti prévu (§14.3),
Open-Meteo horodatant une moyenne horaire à la fin de son heure.

L'observation arrive 1 à 4 h après l'écriture du pas : elle complète la ligne
existante par un UPDATE ciblé — ces trois colonnes seulement, seulement si
gtiObserved IS NULL. Seule exception à « une ligne n'est jamais réécrite » ;
elle ne crée jamais de ligne. Encore en tampon, elle entre par l'INSERT.

Une table de forme etm67 exacte est migrée par ALTER TABLE ADD COLUMN dans une
transaction : lignes conservées, nouvelles colonnes NULL. Le mécanisme « forme
inattendue → refaite » ne vaut plus que pour les autres formes.

Tests loadmodel : migration d'une base écrite par le code etm67 lui-même
(fixture data/energy-forecast-etm67.sqlite), rattachement, retard, nuit à 0,
valeur jamais remplacée, aucun autre champ touché.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:21:18 +02:00
Patrick Schurig
2b83c206e4 feat(prévision PV): le cycle alimente l'enregistrement pour l'apprentissage
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>
2026-10-13 09:15:27 +02:00
Patrick Schurig
ff94377267 feat(prévision PV): table forecastLearning — un pas écoulé, un plan, une ligne
Brief 3 T2. Nouvelle table dans energy-forecast.sqlite, clé (targetAt, siteId,
planId) : le pas écoulé est un fait clos, jamais réécrit (ON CONFLICT DO NOTHING,
pas INSERT OR IGNORE qui avalerait un NOT NULL). Les révisions restent dans
forecastPv ; la table y renvoie par la clé de contenu du §20 (révision en vigueur
à J−1 18:00 locale et à H−1).

gti/tempC et paramètres v2 NULL en v1, jamais 0. Production moyenne depuis les
échantillons d'une minute du bilan, NULL sous 90 % de couverture ou sans
producteur. Écrêtage détecté seulement contre le plafond AC déclaré ; neige =
chute lue sur le site. Écriture sur un fil dédié ; rétention 400 jours ; table
abîmée refaite ou mise de côté, le service continue.

Tests loadmodel : 5 fonctions, 13 contre-épreuves vérifiées échouant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:15:27 +02:00
Patrick Schurig
9c440367fa fix(prévision PV): l'identifiant du site est normalisé, avec ou sans accolades
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>
2026-10-13 07:36:04 +02:00
Patrick Schurig
4f20ca22ed fix(prévision PV): GetPvForecast — une date illisible est une erreur explicite (D4)
Décision de Patrick du 2026-10-13. Constaté le 2026-10-12 : `from`,
`to` ou `knownAt` illisible valait l'epoch 0 en silence ; un `to`
illisible rendait une fenêtre vide, indistinguable de « rien
d'enregistré ».

Les dates sont validées avant toute lecture. Illisible (y compris "") →
`energyError: EnergyErrorInvalidParameter` (énumération amont),
`fault: FORECAST_DATE_UNREADABLE`, `faultParams.fields` (paramètres en
cause). Journal rendu par renderFr().

Schéma déclaré modifié, de façon additive : `energyError` (requis,
EnergyErrorNoError sur toute lecture), `o:fault`, `o:faultParams`.
`forecastPoints` reste requis, donc `[]` en erreur.

Test : testAnUnreadableForecastDateIsAnError (chaque date seule, les
trois ensemble, chaîne vide ; lectures valides sans motif). Le balayage
des getters vérifie `energyError` requis. Contre-épreuve : validation
neutralisée → échec (EnergyErrorNoError au lieu d'InvalidParameter).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 06:59:24 +02:00
Patrick Schurig
b14db632ca feat(prévision PV): SetForecastSite — acLimitW absent = inchangé, null = effacer (D3)
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>
2026-10-13 06:54:28 +02:00
Patrick Schurig
96148b9153 feat(prévision PV): GetForecastSnapshot publie les corrections par plan (perField, D2)
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>
2026-10-13 06:48:11 +02:00
Patrick Schurig
6b66d49f27 feat(prévision PV): lire forecastCurveTransport v1 ET v2, plafond AC de site (brief 3, T1)
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>
2026-10-12 12:59:27 +02:00
Patrick Schurig
a23161b3b6 feat(spot): le fournisseur aWATTar sort du build livré (brief 3, T0)
Le fournisseur spot aWATTar était le seul flux sortant du moteur (invariant 10).
Il n'est plus compilé que sous `qmake CONFIG+=etm_spotmarket` ; la source reste
dans l'arbre pour la synchronisation amont.

Sans l'option (build livré) :
- `spotmarketdataproviderawattar.*` hors de SOURCES/HEADERS, enregistrement
  sous `#ifdef ETM_SPOTMARKET` ;
- aucun `QNetworkAccessManager` : le plugin et l'expérience de simulation
  passent par le même point, `SpotMarketBuild::creer()` ;
- `SetSpotMarketConfiguration` refuse l'activation avec
  `EnergyErrorInvalidParameter` + `fault: SPOT_MARKET_NOT_IN_BUILD` (code,
  pas de phrase). Désactiver reste accepté ;
- `planSpotMarketCharging()` est inerte : `enabled()` ne peut plus devenir vrai.

Tests :
- simulation `testSpotMarketIsOutOfTheBuild` ; contre-épreuve faite (un
  QNetworkAccessManager réintroduit fait échouer le test) ;
- spotmarket `shippedBuildHasNoProvider`, sur le .so réel ; les 4 tests qui
  lisent la liste aWATTar sont sautés hors option et passent avec l'option ;
- `testDecisionReasonRendering` : ligne de rendu du nouveau code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:48:27 +02:00
Patrick Schurig
9044216faa feat(prévision PV): GetForecastSnapshot — la table ou la décision, jamais les deux
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
2026-09-09 09:22:00 +02:00
Patrick Schurig
bb15465109 docs+test: le contrôle qui disparaît avec ce qu'il surveille, et l'optionnel jamais exercé
DEUX CLASSES GRAVÉES DANS AGENTS.md.

1. LE CONTRÔLE QUI DISPARAÎT AVEC CE QU'IL SURVEILLE. Un balayage assis sur la
DÉCLARATION passait au vert quand la déclaration disparaissait : le défaut ne
trompait pas le détecteur, il le SUPPRIMAIT. Il occupe une place vide dans la
famille — la contre-épreuve qui ne mord pas est là mais ne peut pas échouer ;
l'identité qui se referme est vraie par construction ; la mesure qui porte à
côté vise une grandeur voisine ; celui-ci n'est PLUS LÀ, et son absence est
silencieuse.

Remède général : ancrer le critère sur ce qui SUBSISTE quand la référence
disparaît. Ici, la réponse plutôt que la déclaration. Et la seule façon de s'en
apercevoir est de REJOUER le défaut — la relecture donne toujours raison à qui a
écrit.

2. UN PARAMÈTRE OPTIONNEL QU'AUCUN TEST N'EXERCE EST UN PARAMÈTRE QU'ON PEUT
PERDRE. Aucun appel nominal ne l'utilise, donc sa disparition ne casse rien de
visible ; elle ne manquera qu'au client qui s'en sert, et le symptôme sera chez
lui. Ce qui n'est jamais exercé n'est pas « peu testé » : c'est indistinguable
d'absent, alors qu'un client lit les optionnels dans l'introspection et les
croit disponibles.

Fermé plutôt que noté : le balayage fait un SECOND PASSAGE avec les optionnels
renseignés. Contre-épreuve vérifiée — retirer o:knownAt fait sortir « Invalid
params: Invalid key: knownAt ».

AU CONTRAT : lire le STATUT avant la charge utile. Un appel refusé et un appel
qui ne rend rien se ressemblent et n'accusent pas le même — un client qui ne lit
que `params` conclut que le moteur est muet et accuse un code qui n'a pas tourné.

ET UNE NON-OBSERVATION, écrite comme telle : le cycle de 09:20 ne prouvera rien
de mon côté. Il ne reste qu'un plan depuis le retrait de 08:11:36, et une garde
qui compare deux plans ne s'exerce pas sur un seul. Un silence signifie « rien à
comparer », pas « les plans sont alignés » — les deux produisent la même trace,
aucune ligne. Le seul relevé exploitable reste celui de 08:08, obtenu par le
chemin forcé et non par la boucle horaire.

simulation 70/70, 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
2026-09-09 08:57:49 +02:00
Patrick Schurig
c02807c56d test(RPC): balayage des getters — et la contre-épreuve a corrigé mon diagnostic
Le trou nommé hier : la surface RPC n'est couverte par aucun test. Une méthode
qui ne répond plus, en silence, pendant que 31 unitaires, la simulation et
ci-quality passent.

Simulation::testEveryGetterReturnsWhatItDeclares appelle les 14 méthodes
NymeaEnergy.Get* et vérifie, dans cet ordre : le STATUT est success, la charge
utile n'est pas vide, et chaque clé requise de `returns` est servie. Les Set*
sont exclus — ils mutent, et les appeler à l'aveugle serait pire que le trou.

MA PREMIÈRE VERSION ÉTAIT UNE CONTRE-ÉPREUVE INERTE. Elle disait « pour chaque
clé déclarée, vérifier qu'elle est servie » : en retirant la déclaration de
GetPvForecast, elle PASSAIT — il n'y avait plus rien à vérifier. Le défaut
désactivait son propre détecteur, soit exactement le mode d'érosion catalogué
dans AGENTS.md, rencontré dans le contrôle écrit POUR fermer un trou.

Le critère est donc passé de la DÉCLARATION à la RÉPONSE : un getter qui ne rend
rien est cassé, quelle que soit sa déclaration. C'est l'affirmation qui tient
debout sans elle.

ET LA CONTRE-ÉPREUVE A CORRIGÉ MON DIAGNOSTIC. J'avais écrit que nymea élimine
la charge utile quand `returns` n'est pas déclaré. C'est FAUX, vérifié : la
méthode répond correctement sans returns. Ce qui casse, c'est la déclaration des
PARAMÈTRES — sans elle l'appel est rejeté à la validation (« Invalid params:
Invalid key: from ») et le handler n'est jamais atteint.

La différence n'est pas académique : un client qui ne lit que `params` voit une
réponse vide et accuse le handler, qui n'a rien fait. J'avais le bon coupable et
le mauvais mécanisme — conclusion vraie sur un raisonnement faux, et rien ne
l'aurait montré sans une contre-épreuve qui rejoue le défaut. Le contrôle regarde
donc le STATUT en premier, pour nommer le vrai coupable.

Ce que le balayage NE couvre pas, écrit plutôt que sous-entendu : les Set* (non
appelés), la FORME des valeurs (une clé présente ne dit rien de son contenu), et
les paramètres OPTIONNELS (o:knownAt n'est jamais exercé).

simulation 70/70, 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
2026-09-09 08:43:19 +02:00