tools/ref-optimizer → tools/etm-powersync-ref-optimizer ; ce qu'il reproduit (passe confort)
et ce que le contexte 1.1 ne lui permet pas de reproduire ; codes émissibles seulement.
tools/README.md : renvoi vers son README, règle de comparaison d'une action state.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
Le plan du rule-based tracé porte la puissance ESTIMÉE d'une action state ; un optimiseur ne
la donne pas (§6 ne la définit pas). Le rejeu d'une trace rule-based contre l'optimiseur de
référence voyait donc une divergence sur chaque PAC. Seuls l'état (state) et la puissance
d'une consigne (setpoint) sont comparés. Test unitaire ajouté (7 / 0).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Python 3, bibliothèque standard seule, GPL-3.0. Socket unix par défaut
(unix:///run/powersync/optimizer.sock, 0660), tcp:// accepté. Handshake (majeure, capacités,
token lu dans un fichier et relu à chaque handshake, -32001 puis fermeture s'il diffère),
GetPlan, abstention, plusieurs clients à la fois (un fil par connexion, plan fonction pure de
la requête).
Plan à un créneau qui reproduit la passe confort du waterfall rule-based depuis le seul
contexte GetPlan : tri (rang, id), surplus net signé, recrédit, réserve de stockage, plancher,
plafond, verrous, mapping SG-Ready avec hystérésis. Codes du catalogue émissible seulement,
clés documentées seulement (--list-codes, confronté au moteur par le test).
Modes d'injection : normal, abstain, slow, invalid, expired, wrong-major, drop, unknown-load,
forbidden-kind, missing-load, echo-token, protocol-1.0, deviate ; --mode-file pour changer de
mode sans reconnexion. README.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
Annexe D : défaut désactivé, nom des fichiers, rotation locale, plafond, appariement
requête → réponse (la ligne N attend la réponse à la requête N), no-response, jamais le token ;
le rejeu est fait par tools/etm-powersync-trace-replay (et non plus « hems-sim »).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Python 3, bibliothèque standard seule. unix:// par défaut (unix:///run/powersync/optimizer.sock),
tcp:// accepté ; token lu dans un fichier (--token-file), jamais en argument ni affiché.
Réponses écrites à côté de la trace, jamais d'écrasement ; résumé : requêtes, taux
d'abstention, computeMs médian et max, cycles non comparables, cycles dont les actions du
créneau courant diffèrent du plan enregistré. --fail-on-divergence : code 1.
Test (unittest) contre un serveur Unix minimal qui renvoie les réponses enregistrées (ce n'est
pas l'optimiseur de référence) : trace synthétique et trois traces réelles du moteur → zéro
divergence ; réponse modifiée → divergence vue, code 1 ; tcp://, refus d'écraser, endpoint
absent.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
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>
- INTERFACE.md : GetOptimizerStatus, SetOptimizerEndpoint, SetOptimizerToken, GetPlan,
OptimizerStatusChanged, permissions, UpdateLearned ignoré ; ce que nymea journalise des
paramètres d'un appel ; comment distinguer « pas d'optimiseur » de « pas de plan ce cycle ».
- OPTIMIZER_PROTOCOL.md : tier retiré (E23), « deux requêtes consécutives » (E17), §8 état 1.1.
- AGENTS.md invariant 6 : tier retiré.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 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>
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>
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>
`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>
`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>
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>
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>
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>
Neuf avertissements Doxygen sur etm/ (DoD §5 : zéro) :
- AcLimitMaxW, \return de forecastAcLimitW(), ForecastPlanLu::planThingId (depuis 6b66d49) ;
- \return de knownCodes() et optimizerEmittableCodes(), \param d'optimizerMayEmit() (50a1f91) ;
- « <motif publié> » lu comme balise HTML (e6ee513), échappé.
Commentaires seulement : aucune ligne de code modifiée. gen-doc.sh → 0 avertissement.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
Ce dépôt est GPL et public : le nom commercial de l'optimiseur propriétaire n'y figure plus.
Remplacé par « l'optimiseur ETM » dans la prose et « etm-optimizer » là où l'on désigne le
composant (feuille de route, schéma). Sens inchangé. Aucun code touché.
`git grep -nIi 'h[ée]os'` : vide.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
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>
Design §32 : grille, mesure à la minute, profil, qualité, tarif mural,
règle du créneau à cheval (moyenne pondérée) et ce qui reste pour e1.
Protocole 1.1, correction avant publication : price.source hphc → tou
(jamais émis), baseload.quality optionnel.
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>
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>
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>
Décision du 2026-10-14 : l'app trace prévu × (1 ± dispersion journalière d'ensemble),
libellée « fourchette météo ». Le §4 reste la règle du moteur et du protocole : la
dispersion ne qualifie pas la production, n'entre jamais dans p10W/p90W, et la bande
apprise la remplacera.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
Schéma et clé de forecastLearning, règles NULL, révision « en vigueur à X »,
tableau détectable / non détectable de l'écrêtage, neige, écriture et base
abîmée, limites. Inventaire du mock : pvPlane (paramètres) et solarInverter.
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>