24 Commits

Author SHA1 Message Date
Patrick Schurig
053263bc38 feat(rpc): NymeaEnergy.GetOptimizerProtocol sert le contrat embarqué
Le contrat OpenRPC est une ressource Qt du plugin (jamais lu sur disque à
l'exécution) ; GetOptimizerProtocol rend { protocolVersion, schema }, lecture,
PermissionScopeControlThings, aucun secret, sans *Changed (statique pour un
binaire). Documenté dans INTERFACE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:30:44 +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
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
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
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
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
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
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
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
e1fc723e15 feat(§10 étape D, 1/2): l'étalement uniforme, mesuré avant d'être asserté
`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
2026-08-31 12:40:07 +02:00
Patrick Schurig
b52e6d5ccc feat(§10 étape C): l'avancement est une différence, et le régime se publie
`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
2026-08-31 07:53:15 +02:00
Patrick Schurig
7c22e21f68 feat(délestage 1/4): le plafond de soutirage et sa source — comportement inchangé
Étape 1 du design : le TYPE et sa résolution. Rien ne le consomme encore, et
c'est voulu — dans un lot de sécurité, ce qui coûte n'est pas d'écrire le
comportement, c'est de prouver qu'on n'a rien cassé en chemin.

DrawCap porte la source en PARAMÈTRE, jamais enfouie dans le nom d'une fonction :
Breaker (par phase, permanent), GridOperator (total, sur ordre — aucun émetteur,
le transport du §14a n'existe pas), EcoSelfImposed (total, le temps de la passe 1
— la passe n'existe pas non plus). Les deux sources sans émetteur sont déclarées
pour que la porte reste ouverte, pas parce qu'elle s'ouvre.

LE POINT TECHNIQUE : on ne compare pas les plafonds, on compare les MARGES. Un
plafond par phase donne sa marge sur la phase la plus chargée, un plafond total
la sienne sur le soutirage mesuré ; les deux sont des watts. Le plus petit mord,
et on retient sa source ET la phase qui borne — sans elle, un installateur devant
une maison triphasée déséquilibrée ne sait pas où mesurer.

La contre-épreuve de la conversion est éloquente : ramener un plafond par phase à
un « total équivalent » donne 9 300 W de marge là où il y en a 500. Dix-huit fois
trop, sur la couche qui décidera d'acheter. C'est exactement l'hypothèse
d'équilibre que LM-1006-1 interdit, et elle serait restée invisible jusqu'au jour
où elle coupe du courant.

Trois autres invariants, tous vérifiés échouant : aucun plafond déclaré rend une
marge SANS OBJET et non nulle — une marge nulle bornerait tout à 0 W et couperait
l'installation, c'est le « zéro forgé » appliqué à la sécurité ; une marge
NÉGATIVE conserve l'ampleur du dépassement, qui est ce que L4 doit délester ; et
le départage à marge égale est STABLE (Breaker > GridOperator > EcoSelfImposed),
sans quoi le motif publié changerait au gré de l'ordre du tableau, donc sans
qu'aucune décision n'ait changé.

Le design complet est dans docs/DESIGN_DELESTAGE.md, avec les trois décisions
qu'il demande — dont celle qui touche la sécurité : que fait le délestage d'une
charge sous verrou minOn, et la réponse diffère selon la source.

simulation 35/35, charging 17/17, loadmodel 21/21, spotmarket 7/7, amd64 0/0.
Comportement inchangé : aucune suite ne bouge d'une ligne.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 07:56:35 +02:00
Patrick Schurig
3e3e80d445 feat(etm): RelayRouter — couche routeur watts→relais (contrat rév. 3)
Restauration de l'ex-EcsRelayAdapter (26444a5) repositionné en ROUTEUR distinct, sous
l'optimiseur watt-pur. ILoadAdapter consommant Setpoint(W) au lieu de Stage :

- Paliers DÉRIVÉS : toutes les combinaisons (sommes de sous-ensembles) des relais, dédupliquées,
  triées, 0 inclus — source de vérité unique = les relais (descriptor().powerLevels/maxPowerW).
- applyAction(Setpoint W) : mappe powerW → combinaison la plus haute ≤ powerW, applique les
  verrous minOn/minOff EN INTERNE (clamp, plus exposés au scheduler), commute via
  ThingManager::executeAction (interface "power"), off-before-on. force=true → bypass (repli L2).
- currentPowerW : mesuré si relais expose "currentPower", sinon nominal commandé (GPIO bool nu).
- LoadContext : watts uniquement (powerLevels/currentPowerW) — aucun relais ne franchit la frontière.

Classe INERTE à ce commit (non câblée à l'arbitre) : aucune charge ne l'utilise, donc aucune
charge sans repli L2. Câblage + repli L2 = étape 4 (atomiquement). Build prod 0/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:03:17 +02:00
Patrick Schurig
652b434f4c feat(etm): config charge pilotée persistée (Get/SetLoadConfig) + repli L2
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>
2026-06-28 09:51:38 +02:00
Patrick Schurig
10e553d1ea refactor(etm): retirer le kind Stage (ECS→Setpoint), chemin State PAC conservé
Contrat etmvariableload rév. 2 §6 : LoadAction garde DEUX kinds (Setpoint W + état
SG-Ready). On supprime UNIQUEMENT le kind Stage (index de palier ECS) ; l'ECS passera
en etmvariableload (Setpoint W) en T3.

- LoadAction : enum Kind { Setpoint, State, Constraint } (retrait Stage + champ stage).
- SurplusContext : retrait stage/minStage/maxStage de LoadContextTelemetry. state/minState/
  maxState (PAC) CONSERVÉS.
- LoadDescriptor : retrait du champ stages (relay-stages orphelin).
- RuleBasedScheduler : retrait buildEcsStageAction ; waterfall = sg-ready uniquement.
- EnergyArbitrator : retrait registerEcsAdapter / m_ecsAdapters / dispatch Stage /
  boucle contexte ECS / repli L2 ECS. Chemin State (PAC) intact.
- Suppression de EcsRelayAdapter ; retrait du registre en dur ECS (energypluginnymea).

NB : entre ce commit et T4 le repli L2 charge pilotée est absent (recâblé en T4) —
commit intermédiaire d'une branche feature, jamais déployé tel quel.

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 09:06:16 +02:00
Patrick Schurig
cdae69a314 [3e-2] SgReadyAdapter : encodage 2 bits → 4 états + atomicité de transition
Adaptateur sg-ready (kind:State) : pilote N relais signal (stateRelays par état),
lockWindow symétrique (minStateHold, gel total — protection court-cycling), seam de
temps unifié (toLoadContext(now)/applyAction(now)). currentPowerW = puissance allouée
déclarée (pas mesurée → recrédit correct, anti double-comptage état 2).

Atomicité 2 bits : applyStateRelays commute d'abord le relais au transitoire le plus
doux (neutre/reco) puis les autres → jamais de blocage/forcé parasite. Contrat documenté
(transport déporté Shelly/Modbus). État initial = 2 (mains off). Build 0/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:53:07 +02:00
Patrick Schurig
a239a324c8 [wip] 3c morceaux 0-2 compilés + plan 3c validé dans AGENTS.md
Morceaux 0-2 implémentés et compilés (0 erreur / 0 warning) :
- M0 : LoadAction.force=false (bypass verrous anti-rebond sécurité)
- M1 : EcsRelayAdapter (.h+.cpp) — N paliers powerswitch, anti-rebond, etm.pri
- M2 : buildContext() — SurplusMeter brut, loads EV+ECS, registerEcsAdapter()

AGENTS.md : section PLAN 3C ajoutée avec corrections A+B intégrées.
Corrections A (déduction EV unique dans scheduler) et B (recrédit conso
propre anti-clignotement) documentées avant implémentation morceau 3.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 13:34:42 +02:00
Patrick Schurig
6aa0a383ed [3b-wip] EnergyArbitrator + RuleBasedScheduler + EvAdapter (dispatch amont, ETM_ARBITRATOR désactivé)
- EnergyArbitrator : public SmartChargingManager — raison documentée dans AGENTS.md §DÉCISIONS DE DESIGN
- SmartChargingManager : protected slots + virtual update() + 3 accesseurs inline [ETM]
- RuleBasedScheduler::getPlan() wraps planSurplusCharging/planSpotMarketCharging, annote chaque action d'un reason français
- EvAdapter : ILoadAdapter concret pour evcharger — applyAction() implémenté, NON appelé en 3b (dispatch via adjustEvChargers() amont, iso-fonctionnel)
- ETM_ARBITRATOR : commenté dans .pro — ne s'active qu'après preuve iso-fonctionnelle (3b-iv)
- Doxygen \brief + invariants + contrats sur toutes les classes/méthodes publiques etm/ (DoD §5)
- plan.h : timeSlots (pas slots, mot-clé Qt) ; commentaire JSON sérialisation "slots" OPTIMIZER_PROTOCOL §6
- .clangd : flags de repli Qt/nymea pour clangd via symlink ~/Schreibtisch/
- compile_commands.json gitignore (chemins absolus locaux)
- Build : 0 erreurs, 0 warnings — libnymea_energypluginnymea.so 914 KB

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 23:16:49 +02:00
Patrick Schurig
f8109433ab [3a] structs protocole + interfaces LoadAdapter/Scheduler (zéro comportement)
LoadAction (kind+funding+§6 fields), LoadDescriptor, SurplusContext (§5),
Plan/Slot, ILoadAdapter, IScheduler — noms de champs = OPTIMIZER_PROTOCOL.md.
energyplugin.pri inclut etm/etm.pri. Build Qt6 vert, aucun fichier upstream touché.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 22:02:18 +02:00