316 Commits

Author SHA1 Message Date
Patrick Schurig
6c44a4e091 docs(traces): INTERFACE.md (réglage, RPC, format, fichiers, plafond), protocole annexe D (brief 4 T6)
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>
2026-10-17 12:12:25 +02:00
Patrick Schurig
64a864bfaa feat(tools): etm-powersync-trace-replay, rejeu des traces vers un optimiseur (brief 4 T6)
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>
2026-10-17 12:12:25 +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
7a32961b23 docs(optimiseur): INTERFACE.md (état, réglage, plan publié), protocole §4/§7/§8 (brief 4 T4)
- 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>
2026-10-17 09:34:39 +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
26c0e02118 docs(doxygen): 0 avertissement — membres non documentés et balise échappée (DoD §5)
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>
2026-10-16 21:01:44 +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
7622e11ea9 refactor(arbitre): l'action appliquée au cycle est conservée par charge (brief 4 T0.5)
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>
2026-10-16 15:08:39 +02:00
Patrick Schurig
e0b83f55a7 docs: retrait du nom « optimiseur ETM » des fichiers publiés (brief 4 T0.5)
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>
2026-10-16 14:18:09 +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
ac6dedbd53 refactor(prévision): l'assemblage du snapshot passe du handler à l'arbitre (brief 4 T0.5)
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>
2026-10-16 08:01:00 +02:00
Patrick Schurig
ab36143385 refactor(arbitre): IScheduler *m_strategy à côté de m_scheduler (brief 4 T0.5)
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>
2026-10-16 07:45:45 +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
83483ccf71 release: 1.15.2+etm69 — tarif saisi et consommation de base (brief 3 T4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:42:04 +02:00
Patrick Schurig
04fea6ffda docs(prévision): §32 consommation de base et prix ; protocole §5.2 (tou, quality)
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>
2026-10-14 10:05:43 +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
b62e6f55a8 docs(prévision PV): §4 — fourchette météo autorisée côté app, jamais publiée comme quantile
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>
2026-10-14 08:45:42 +02:00
Patrick Schurig
72f353bf8e release: 1.15.2+etm68 — GTI observé dans l'enregistrement pour l'apprentissage
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:21:18 +02:00
Patrick Schurig
d37a11e803 docs(prévision PV): §31.7 — GTI observé, compléter plutôt qu'attendre, couverture attendue
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:21:18 +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
ca73fd733b release: 1.15.2+etm67 — enregistrement pour l'apprentissage (brief 3 T2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:18:12 +02:00
Patrick Schurig
b1fdff197f docs(prévision PV): §31 — enregistrement pour l'apprentissage, écrêtage détectable ou non
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>
2026-10-13 09:15:27 +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
139e48a938 changelog(1.15.2+etm66): normalisation de l'identifiant du site de prévision
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:36:22 +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
c8da4ab2a0 release: 1.15.2+etm66 — aWATTar hors build, transport v2, plafond AC de site
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:13:17 +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