201 Commits

Author SHA1 Message Date
Patrick Schurig
0dc2a1aeba moteur : l'écart commande/mesure est jugé au début du cycle (P1)
L'écart était jugé APRÈS le dispatch, entre la consigne qui venait de
partir et la mesure de la précédente, puis consommé au cycle suivant
avant d'être rejugé. Toute marche de consigne suivie par la borne se
lisait donc comme un refus vieux d'un cycle (60 s, au-delà des 30 s /
7 s de la borne) : recrédit suspendu, coupure, et une boucle de trois
cycles sous un surplus constant (~1,5 kW exportés en moyenne).

jugerCommande() rend désormais UN jugement par cycle, au début du cycle,
lu par le recrédit et par la télémétrie :
- la commande EN VIGUEUR contre la mesure fraîche ; s'ils concordent
  (max(200 W, 15 %)), l'épisode se referme ;
- l'ouverture est datée à l'instant de la commande, en temps de cycle,
  et non plus à l'horloge murale de la dernière publication ;
- le sens est celui que la commande demande à la charge, et non plus
  deviné au niveau de la mesure ;
- en montée seulement, une mesure rapprochée de la commande de plus de
  200 W depuis le cycle précédent est une latence. La descente n'a
  jamais cette excuse.

command.divergent publie ce verdict ; expectedW et observedW restent
les nombres de la publication.

Le test de reproduction inverse ses assertions « DÉFAUT » : sur trois
rampes (instantanée, 30 s / 7 s, 120 s), 13 A tenus dès le premier
cycle, aucune suspension, export de 10 W dès que la borne a rejoint sa
consigne.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:30:45 +01:00
Patrick Schurig
b5ef257a36 docs : mentions de la démo neutralisées (commentaires et design seulement)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HE6X3TcKdiuCucBHA2XX4n
2026-10-25 11:42:34 +01:00
Patrick Schurig
facad90c2e Délestage visible : shedding.open publié en liste de variantes
Le JSON ne change pas. Mais une QStringList échappait au parcours par type
de l'enregistreur `decisions`, qui anonymise les ThingId des listes : la
référence portait les identifiants tirés au hasard d'une exécution, et le
`check` sortait en ÉCART à chaque passage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 11:28:13 +01:00
Patrick Schurig
da6558fa8e Délestage visible : la protection publie ce qu'elle fait (OVERLOAD_THROTTLED, GetSheddingLog)
- Borne : executeChargingAction() devient virtuel (marque [ETM], corps amont
  inchangé). L'arbitre laisse partir la consigne, puis la lit. Une baisse ou
  une coupure de la protection de surcharge ouvre la tenue, et la borne
  publie OVERLOAD_THROTTLED {fromA, toA, cut, overshootW, limitA, o:phase}.
  La tenue se lève quand le cycle reprend la borne, ou quand la reprise
  amont lui rend son courant manuel. Les verrous de la borne sont lus : un
  geste refusé n'est pas publié comme un délestage.
- Cascade : shedWaterfallForOverload() note chaque délestage au journal,
  avant et après lus sur runtimeView(). Rien d'autre ne change.
- Télémétrie : bloc shedding {revision, open}, absent tant que rien n'a été
  délesté. Sa révision déclenche LoadTelemetryChanged à l'ouverture et à la
  fermeture d'un épisode.
- RPC : NymeaEnergy.GetSheddingLog, même droit que GetLoadTelemetry (table
  des droits et compte sans droit mis à jour).

Aucune consigne, aucun émetteur, aucun verrou ne change (invariant 2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 11:05:53 +01:00
Patrick Schurig
0aa357d5b6 Délestage visible : journal court des délestages (SheddingLog)
Un événement par ÉPISODE, en mémoire : il s'ouvre au premier geste de
protection et se ferme à la fin du premier cycle complet où la protection
ne tient plus la charge, daté du premier instant libre. « Avant » et
« après » portent la charge utile de mécanisme de l'adaptateur, sans liste
de types. Vingt épisodes ; le plus ancien épisode clos sort le premier.
Aucune décision ne le lit, et il est perdu au redémarrage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 11:05:53 +01:00
Patrick Schurig
e28e6375fd Heures creuses : la somme éco + confort de la borne tient dans son plafond (écart 4)
En heures creuses, le niveau éco d'une borne à pleine puissance vaut son
plafond de cycle, qui porte la limite de phase. À l'aube, la passe confort
lui ajoutait le surplus, et seul le courant maximal de la borne écrêtait
la somme : borne monophasée sur A, limite 16 A, plafond 13 A, 2 540 W de
surplus — 16 A servis, la phase A à 18 A. L'achat publié valait 2 990 W
quand le compteur n'importait que 450 W.

Pour une borne qui porte EV_OFF_PEAK_FULL_POWER :
- la passe confort sert le surplus DANS le plafond ;
- l'achat cède ce que le surplus finance (gridW, drawCommittedW,
  draw.committedW) : la somme reste le plafond ;
- la part confort n'a pas de plancher : il porte sur la somme, qui vaut
  le plafond. Sinon un surplus sous le plancher serait refusé, ou démarré
  par EV_GRID_START, un second achat en plus du premier.
Hors heures creuses et sans tarif, rien ne change.

Tests : testOffPeakFullPowerAtDawnStaysInTheCeiling (nuit, aube, plein
soleil : 13 A, achat 2 990 / 450 / 0 W, achat ≤ import du compteur) ;
testOffPeakFullPowerStaysUnderTheLimitOnABlindMeter étendu au total
aveugle (13, 15, 15… au lieu de 13, 16, 16…) ;
testOffPeakFullPowerGivesNothingBackWhileSwitchingPhases, la garde de
bascule enfin éprouvée (12 A, phase B à 24 A ; sans elle 15 A, B à 27 A).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:41:48 +01:00
Patrick Schurig
679e64b5b3 Heures creuses : le plafond de la borne lui rend ce qu'elle tire, moins 1 A (lot e2, suite)
Près de la limite de phase, la consigne d'une borne à pleine puissance
alternait à chaque cycle : le plafond était la place libre au compteur,
sans lui rendre sa propre consommation. Maison à 2 A par phase, borne
16 A : 16, 7, 16, 7 A sous 25 A ; 16, 14 sous 32 A ; 14, 6, 8, 6 sous 16 A.

En heures creuses, et pour la pleine puissance du lot e2 seulement, le
plafond est lu par EnergyArbitrator::evOffPeakAllowanceW() : limite moins
le tirage de la phase sans la borne, moins 1 A, à l'ampère inférieur. La
règle de l'échéance chez l'amont (adjustEvChargers()). Elle converge en
un cycle : 16 A tenus sous 25 et 32 A, 13 A sous 16 A.

- Courant rendu MESURÉ (currentPower), sur les phases où la borne le
  mesure ; rien rendu pendant une bascule ou sans mesure.
- Tirage de la maison sans la borne écrêté à zéro : une borne qui
  annonce plus que ce que le compteur voit reste à 1 A sous la limite.
- En dépassement, le plafond vaut au plus le courant mesuré moins 1 A :
  il baisse à chaque cycle, jusqu'au plancher, où L4 décide.
- Hors heures creuses et sans tarif : evPhaseAllowanceW(), inchangée.

Tests : testOffPeakFullPowerSettlesUnderThePhaseLimit (sept cas, douze
cycles) et testOffPeakFullPowerStaysUnderTheLimitOnABlindMeter, nouveaux ;
testOffPeakFullPowerYieldsToThePhaseLimit réécrit avec un compteur qui
voit la borne (mesure et non consigne, dépassement, plancher, L4) ;
testBlockedDischargeShowsAtTheMeterAndL4Decides : en dépassement, la
borne finit au plancher (L4 16 → 13 A, stratégie 9 A, L4 6 A) au lieu
d'être coupée.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 07:15:30 +01:00
Patrick Schurig
1291f72d1f Heures creuses : la borne charge à la pleine puissance permise (lot e2)
Décision de Patrick du 2026-10-24. Dans les trois modes qui reportent en
heures creuses (Éco avec minimum, Éco min + échéance, Éco avec échéance) :

- le minimum servi au réseau en heures creuses est le PLAFOND de cycle de
  la borne (declared.maxPowerW), plus son plancher. Il porte le courant
  maximal, les phases et la limite de phase, et ne descend jamais sous le
  plancher : la stratégie rabaisse, L4 coupe. 3 680 W en monophasé 16 A,
  11 040 W en triphasé 16 A ;
- les phases se choisissent sur la pleine puissance, plus sur « courant +
  surplus » (EnergyArbitrator::enHeuresCreuses(), lu par EvAdapter). La nuit,
  une batterie qui couvre la maison et la voiture met le compteur à 0, et la
  règle de bascule gardait le monophasé : 3,7 kW au lieu de 11 ;
- la passe éco n'oppose plus à cette borne la marge d'UNE phase de la maison
  (LM-1500-h, déjà appliquée en tête de passe et en confort) : son plafond
  porte déjà l'autorisation, sur ses phases. Restreint aux heures creuses ;
- motif EV_OFF_PEAK_FULL_POWER {gridW, maxW, phases, until, endsAt,
  o:boundBy phaseLimit|floor}, rendu en français, à la place
  d'ECO_FLOOR_GRID sur la borne.

Hors heures creuses et sans heure creuse dans le tarif : rien ne change.
Le blocage de la décharge se lève toujours sur la puissance MESURÉE (230 W) :
une borne commandée à 16 A dont la voiture ne prend plus rien le lève.

Tests : testOffPeakChargesAtFullPower, testOffPeakFullPowerSwitchesToThreePhases,
testOffPeakFullPowerYieldsToThePhaseLimit. Les tests du lot e1 en heures
creuses attendent le plafond, et leur compteur inclut ce que la borne tire
désormais (TirageHcW) — sinon il fabriquait un surplus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:22:12 +02:00
Patrick Schurig
b007aae720 Batterie : une consigne refusée est réessayée en s'espaçant, la levée jamais (lot e1, suite)
Un onduleur qui refuse la consigne (GEN24 sans « Allow Control », exception
0x04) la voyait reposée à chaque cycle, avec un qCWarning à chaque fois,
toute la nuit, et `held` la publiait comme tenue.

- Une pose refusée (taux ou limite) ouvre une série de refus : la pose
  suivante attend 1, 2, 5, 10 puis 15 min au plus (marge de 5 s pour la
  gigue du cycle). La série se clôt à la première séquence acceptée
  jusqu'au bout (retour au tiers du minuteur) ou à la levée.
- La levée n'est jamais espacée : elle part au cycle même où la décision
  tombe, série en cours ou non.
- Journal : un seul qCWarning par série, les refus suivants en qCDebug, un
  qCInfo à l'acceptation qui clôt la série.
- Télémétrie : pendant une série, `held` est faux (et `dischargeBlocked`
  avec lui), et la batterie publie o:refusedWrites, o:refusedSince,
  o:nextAttemptAt. `held` faux plutôt qu'un drapeau à côté : un client qui
  ignore le champ nouveau ne doit pas afficher une protection absente.

Mock : propriété dynamique `refuserEcritures` sur la batterie pilotable.
Test testRefusedWritesAreSpacedAndNeverDelayTheRelease ; les six
contre-épreuves (espacement, avertissement, held, levée, deux fins de
série) le font rougir.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 11:19:15 +02:00
Patrick Schurig
d9372c14ba Heures creuses : la borne attend les HC, la batterie ne se décharge pas dans la voiture (lot e1)
Décisions de Patrick du 2026-10-24. Sans heure creuse dans le tarif saisi
(aucun tarif, tarif unique, prix tous égaux), rien de ceci ne s'exécute.

LA BORNE — modes « Éco avec minimum », « Éco min + échéance », « Éco avec
échéance » (EvAdapter déclare needs.offPeakShift ; le scheduler décide) :
- hors heures creuses : surplus seulement. Le minimum réseau est retiré,
  EV_GRID_START aussi (le minimum par une autre porte). Motif
  EV_WAITING_OFF_PEAK {at "22:00", startsAt, budgetW}, rendu « … attend les
  heures creuses (22:00) ». Le plafond de soutirage et le refus gardent la tête.
- en heures creuses : le plancher de la borne est servi au réseau (passe éco,
  bornée par l'autorisation de soutirage). L'échéance reste au proxy, inchangée.
- Eco et Normal ne sont pas touchés.

LA BATTERIE — StorageDischargeGuard exécute, le rule-based décide (y compris en
ombre d'un optimiseur) :
- heure creuse ET bornes qui TIRENT ≥ 230 W (mesuré) ET batterie pilotable :
  dischargingRate = 0 PUIS enableDischarging = true (StorCtl_Mod bit DiSCHARGE
  = limite active, OutWRte = 0 : la séquence NO_DISCHARGE du plugin) ;
  rafraîchi au tiers du minuteur de retour (revertTime) ;
- sinon : levée EXPLICITE, enableDischarging = false puis dischargingRate au
  maximum. L2 lève aussi. Une limite à 0 % trouvée active au démarrage est
  reprise (rafraîchie ou levée).
- jamais gridCharging, enableCharging, chargingRate, ni un taux négatif.
- générique : interface energystorage et états présents. Sans les états
  (noDischargeControl), sans minuteur (noRevertTimer) ou minuteur < 120 s
  (revertTimerTooShort) : non commandée, STORAGE_NOT_PROTECTED.
- publié : bloc `storage` de la télémétrie (dischargeBlocked, decision,
  batteries[] avec ce qui est LU), absent sans heure creuse ou sans batterie ;
  ligne « [Arbitre] Batterie → … » au journal.

Le blocage ne verse rien au budget : surplus = compteur + PV (invariant 8),
le soutirage ajouté se mesure au cycle suivant, L4 garde le dernier mot.

Mock : classe controllableStorage (états modifiables du sunspecStorage,
paramètre revertTime). Tests : journée de la démo, 06:00 sur jours de 24/23/25 h,
etm74 sans heure creuse, batteries non pilotables ou absente, soutirage et L4,
repli L2 — les écritures lues à la frontière nymea (actionExecuted), l'onduleur
modélisé hors du moteur. Le commentaire du test §11 est réécrit comme il le
demandait.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 08:34:21 +02:00
Patrick Schurig
920cf854da Tarif : les heures creuses sont la ou les zones au prix le plus bas (lot e1)
TariffConfig::zonesCreuses() : les zones au prix minimal d'un tarif à zones
horaires dont les prix diffèrent. Aucun tarif, tarif unique ou prix tous
égaux : aucune heure creuse. Le libellé ne décide pas — « HC » n'a pas de
code, le prix se compare.

enHeuresCreuses() et prochaineBascule() lisent l'heure murale minute
absolue par minute, comme importSeries() : bascules exactes les jours de
23 h (nuit de 7 h) et de 25 h (nuit de 9 h, 02:30 deux fois creuse),
épinglées sur des époques calculées hors de Qt (zoneinfo).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 08:26:01 +02:00
Patrick Schurig
09043fb7c7 Protocole optimiseur 1.3 : forme normée d'error.data, le token avant la majeure
Contrat (docs/etm-powersync-optimizer-protocol.openrpc.json) :
- composants InvalidContextData, ContextProblem, ContextProblemCode : error.data du -32602 de
  GetPlan = {"problems":[{path, problem}]}, 1 à 50 problèmes, truncated au-delà, chemin depuis
  params (motif), nature dans une liste fermée (missing, type, range, format, enum, constraint),
  formes fermées : aucune valeur reçue ne peut s'y glisser ;
- x-etm-data-schema et x-etm-data-required-from (1.3) sur cette erreur ;
- Handshake : token faux et autre majeure dans le même handshake → -32001, le token d'abord ;
- tolérance : error.data jamais lu par le moteur.

Prose (docs/OPTIMIZER_PROTOCOL.md) : v1.3, changelog §0 avec la compatibilité 1.2 ↔ 1.3, §2
(un lot reçu par le moteur reste sans réponse, à dessein), §3 (le token d'abord), §7 (forme
d'error.data, et ce que le moteur en fait : rien), annexes A, D, E, G. Blocs générés régénérés.

Générateur : un `|` dans une cellule (alternative d'un motif) est échappé ; une forme de
l'optimiseur fermée à dessein dit pourquoi (x-etm-closed-why) au lieu de « émise par le moteur ».
Test ajouté, vérifié échouant avec l'ancien générateur.

Moteur : version annoncée au handshake 1.3 (OptimizerProtocol::ProtocolVersion,
OptimizerClient::kProtocolVersion). Rien d'autre ne change dans le client. Tests qui citent la
version mis à jour (optimizerclient, socketscheduler, test du validateur).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:11:55 +02:00
Patrick Schurig
76b348bf8b LoadConfigStore : un libellé hérité n'est cité que sur 64 caractères dans les refus d'ensemble (m-1)
appendConfig() ne borne que l'entrée ajoutée, mais validateSet() (THING_SHARED) et
validateRanks() (RANK_DUPLICATE) citaient en entier les libellés des deux entrées en
conflit — dont une héritée, sans borne. provisionEvChargerConfig() journalise cette phrase.

Les libellés y passent par libellePourMotif() : 64 caractères au plus (leftChars, jamais au
milieu d'une paire), suivis de « … ». Le motif publié (faultParams) ne change pas.

Test : testInheritedLabelIsCutInSetRefusals — héritée de 300 caractères ; l'ajout d'une borne
refusé sur THING_SHARED puis sur RANK_DUPLICATE : motif exact inchangé, phrase avec les 64
premiers caractères et sans le libellé entier.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:22:58 +02:00
Patrick Schurig
58380dd7db LoadConfigStore::load() : les valeurs héritées sont citées échappées au journal
Même défaut que I-1, dans les avertissements de chargement ajoutés par Q7 : l'identifiant hors
règle et l'échéance illisible étaient cités en noquote(). Avant Q7, un client pouvait les
persister avec un saut de ligne (ni l'id ni l'échéance n'étaient bornés), et la lecture du
fichier fabriquait alors une fausse ligne de journal.

Ces valeurs passent par citee() : entre guillemets, échappées par QDebug (même rendu
qu'avant pour une valeur ordinaire). L'avertissement LIBELLÉ TROP LONG, qui cite l'identifiant
dans sa phrase, perd son noquote().

Test : testInheritedValuesAreEscapedInTheJournal charge un fichier dont l'id et l'échéance
valent "x\n[NymeaEnergy] FAUX" : les trois avertissements partent, citent la valeur échappée,
et aucun message n'ajoute de ligne commençant par [NymeaEnergy].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:22:41 +02:00
Patrick Schurig
2ce74892d3 SetLoadConfig : le journal du refus ne recopie plus la saisie du client (relecture q7-suite, I-1)
Le refus écrivait, en noquote(), la phrase détaillée du refus : elle cite mode, domaine,
rankOrigin, adaptateur, relays[].thingId, échéance et libellé tels que saisis. Un saut de ligne
dans l'un d'eux fabriquait une fausse ligne de journal (etm72 l'échappait par `<< why`).

Le journal ne porte plus que la phrase rendue depuis le motif (renderFr), comme SetTariff :
cause et endroit, un identifiant seulement s'il est bien formé. Échapper le détail aurait
suffi contre l'injection, pas contre le volume : relays[].thingId n'est pas borné.

La cause système d'un WRITE_FAILED, que seul le détail portait, est désormais écrite par
LoadConfigStore::commit() dans une ligne à part (chemin et erreur du fichier, aucune saisie).

Test : testSetLoadConfigRefusalIsPublished envoie dix refus dont un champ vaut
"x\n[NymeaEnergy] FAUX" et vérifie, message par message (JournalCapture::messages()), que le
refus est journalisé et qu'aucun message n'ajoute de ligne commençant par [NymeaEnergy].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:22:32 +02:00
Patrick Schurig
f10bed77b5 socketscheduler.h : request-too-long dans la liste des codes d'echecRequete()
Mineur relevé par la relecture d'etm72. Commentaire seul.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:26:26 +02:00
Patrick Schurig
14b86de3c9 trace : une requête non envoyée n'est tracée que par sa taille (Q7)
Avec les traces actives, la ligne d'un cycle recopiait la requête GetPlan
même quand le client ne l'avait pas écrite (ligne de plus de 1 Mio,
request-too-long) : environ 1 Mio par cycle jusqu'au plafond journalier,
pour une requête que personne n'a reçue, et que le rejeu aurait renvoyée.

- La ligne porte alors `requestBytes` (taille de la ligne, octets) à la
  place de `request` ; sa réponse reste l'erreur request-too-long. Même
  règle pour la requête qui SERAIT partie sans optimiseur.
- La décision « part ou ne part pas » est celle du client :
  OptimizerClient::getPlanLine(), extraite de requestPlan() sans changer
  son comportement, est lue par la trace — pas une seconde estimation.
- Le commentaire « la requête RÉELLEMENT envoyée » est corrigé, la doc de
  la ligne de trace (optimizertrace.h) aussi.

Preuve : testARequestNotSentIsTracedBySizeOnly (avec et sans optimiseur ;
contre-épreuve : la requête envoyée est recopiée).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:26:26 +02:00
Patrick Schurig
a728a3c8da arbitre : borne ajoutée d'office même au nom trop long, libellé coupé à 256 caractères (Q7)
Écart « important » de la relecture d'etm72. provisionEvChargerConfig()
prenait le nom du Thing pour libellé et réécrivait l'ensemble complet : un
nom de plus de 256 caractères, ou un libellé hérité trop long sur une AUTRE
entrée, refusait l'ajout, et la borne restait arbitrée sans rang réglable.

- Le libellé est le nom coupé à 256 points de code (LoadConfigStore::
  leftChars), jamais au milieu d'une paire de substitution.
- L'ajout passe par appendConfig() : les bornes de saisie ne portent que
  sur l'entrée apportée. L'écriture cliente, elle, reste refusée tant que
  l'hérité n'est pas raccourci (règle d'etm72 inchangée).
- Le journal nomme la borne par ce libellé coupé.

Preuve : testAutoProvisionedLabelIsCutNeverRefused (nom de 300 caractères,
emoji en 256e position, hérité de 300 caractères sur une autre entrée).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:23:37 +02:00
Patrick Schurig
53a10b3062 SetLoadConfig : motif de refus publié (LOAD_CONFIG_REFUSED), id et échéance bornés à l'écriture (Q7)
Le refus garde EnergyErrorInvalidParameter et gagne, comme SetTariff, un
`fault` LOAD_CONFIG_REFUSED et des `faultParams` : une cause par motif
existant (ID, LABEL, DEADLINE, MODE, DOMAIN, RANK_ORIGIN, RANK_ORIGIN_AUTO,
ADAPTER, FIELD_*, SG_READY_STATE_2, ID_DUPLICATE, THING_SHARED,
RANK_DUPLICATE, WRITE_FAILED, STORE_UNAVAILABLE) et l'endroit (index, id
quand il est bien formé, field, state, indexes/ids, rank, length/max,
position). Jamais le libellé. Les deux champs sont déclarés optionnels dans
le schéma ; un succès rend exactement ce qu'il rendait.

- id : 1 à 128 caractères de [A-Za-z0-9._{}-], refusé à l'écriture ;
  hérité hors règle, chargé tel quel et signalé.
- needs.dailyDeadline : vide ou HH:MM (00:00-23:59), refusé à l'écriture ;
  hérité illisible, l'entrée est chargée, l'échéance retirée de la mémoire
  (ni l'arbitre ni l'optimiseur ne la reçoivent) et signalée.
- Les bornes de saisie passent avant la réconciliation de rankOrigin, dont le
  motif cite le libellé.
- appendConfig() : l'ajout par le moteur ne borne que l'entrée ajoutée.
- leftChars() coupe en caractères sans couper une paire de substitution ;
  l'id d'un motif n'est plus coupé par QString::left().
- Au chargement, chaque libellé trop long est signalé, pas seulement le
  premier ; loadconfigstore.h dit « n'écarte rien au chargement ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:20:07 +02:00
Patrick Schurig
3a4398f0b1 Fusion fix/instants-decalage : tout instant émis porte son décalage
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HE6X3TcKdiuCucBHA2XX4n
2026-10-22 19:30:58 +02:00
Patrick Schurig
9d8b60fdd4 protocole optimiseur : tout instant émis porte son décalage
Constat VM du 2026-10-21 (Europe/Berlin) : l'optimiseur conforme refusait
chaque GetPlan, -32602 sur params.timestamp. `instant()` écrivait
toString(Qt::ISODate), qui n'émet aucun décalage pour une heure locale —
celle de currentDateTime(), donc de ctx.timestamp en production. Les
goldens, construits en UTC ou à décalage fixe, ne pouvaient pas le voir.

Le décalage de l'instant reçu est désormais rendu explicite : une heure
locale garde l'heure du site (+02:00), une heure UTC reste en Z. Il est lu
sur l'instant, donc 02:30 la nuit du retour à l'heure d'hiver sort +02:00
puis +01:00. Goldens inchangés.

Tests (loadmodel) : cinq fuseaux dont un négatif et un à la demi-heure,
été et hiver, tous les instants du contexte ; la nuit des deux changements
d'heure, minute par minute. Vérifiés échouant sans le correctif.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 22:10:15 +02:00
Patrick Schurig
6b1dd95e2e SetLoadConfig : libellé de charge borné à 256 caractères, à l'écriture seulement (Q7)
Le libellé part tel quel dans chaque GetPlan. Rien ne le bornait : une saisie de plus de
1 Mio suffisait à faire dépasser la limite de ligne du protocole. La borne arrête la
saisie en amont ; le refus d'émission du client (commit précédent) reste le filet.

- LoadConfigStore::validateLabels(), appelée en tête de setConfigs() : un libellé de plus
  de 256 points de code Unicode → refus en bloc, rien n'est modifié ; SetLoadConfig rend
  EnergyErrorInvalidParameter et journalise le motif en français, qui nomme la charge par
  son id et donne la longueur, jamais le libellé. Placée AVANT isValid() parce que les
  motifs suivants citent le libellé.
- Même partage que les rangs (LM-1209-a-bis) : load() ne l'applique pas. Un libellé plus
  long déjà persisté est chargé tel quel, ni tronqué ni écarté, et signalé ; la première
  écriture devra le raccourcir.
- Test : testLabelIsBoundedOnWriteNeverOnLoad (256 accepté, 257 refusé sans modification
  en mémoire ni sur disque, configuration héritée conservée entière).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:07:32 +02:00
Patrick Schurig
cd91baf98f optimiseur : le moteur n'émet jamais de ligne de plus de 1 Mio (Q7, protocole 1.2)
Ferme l'écart Q7 trouvé au lot protocole 1.2. Une requête GetPlan dont la ligne
dépasserait maxLineBytes n'est plus écrite sur le socket : OptimizerClient::requestPlan()
sérialise d'abord, rend l'identifiant, puis émet planFailed(id, "request-too-long") au
tour de boucle suivant. L'échec compte (règle des échecs consécutifs, §7), la connexion
est conservée, et le journal le dit une fois par connexion, avec la taille, jamais le
contenu. Avant : la ligne partait, l'optimiseur la jetait, et la requête finissait en
délai dépassé sans que la cause se voie.

Le handshake n'est pas contrôlé : sa ligne est bornée par construction (token de 4 096
octets au plus, 24 Kio une fois échappé).

- knownErrorCodes() : + request-too-long ; LastErrorCode du contrat régénéré
  (--enums-from), blocs générés de la prose régénérés.
- OPTIMIZER_PROTOCOL.md §0/§2 et x-etm-transport : l'écart connu devient la règle tenue.
  Pas de changement de version.
- Tests : testARequestOverTheLineLimitIsNeverSent remplace le XFAIL (aucun octet émis,
  limite exacte éprouvée des deux côtés), testARequestTooLongIsLoggedOncePerConnection,
  testARequestTooLongCountsAsAFailure (socketscheduler).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:07:11 +02:00
Patrick Schurig
8f24709874 protocole optimiseur 1.2 : les règles Q2 à Q9, et le moteur annonce 1.2
docs/OPTIMIZER_PROTOCOL.md passe en v1.2 (mineure) et écrit les décisions du
2026-10-21 à leur place :

- §2 : maxLineBytes (1 Mio, '\n' non compté) dans les deux sens ; lots
  interdits dans les deux sens ; /run/powersync appartient au paquet de
  l'optimiseur, le moteur ne le crée ni ne le suppose. Écart connu noté :
  le moteur ne borne pas encore ce qu'il émet.
- §3 : la majeure se compare des deux côtés (-32602 puis fermeture) ; -32001
  normé ; un token non exigé est accepté ; un handshake refusé est fermé des
  deux côtés ; GetPlan avant handshake → -32600, connexion gardée.
- §7 : contexte GetPlan invalide → -32602 avec chemins et natures, jamais les
  valeurs, compté comme un échec ; codes JSON-RPC 2.0 standard sinon ; table
  des erreurs GÉNÉRÉE depuis le contrat.
- §8 : UpdateLearned à sens unique, -32601 avec id ; forme non normée en 1.2.
- §9, §0 (changelog, compatibilité 1.1), annexes A, E, G.

Contrat OpenRPC : methods[].errors complétés (x-etm-connection),
x-etm-errors-engine, x-etm-message-errors (nouveau), x-etm-transport
(maxLineBytesScope, batches, runtimeDirectory), règle handshake-refused.
La version (info.version, ProtocolVersion) régénérée depuis le code par
--enums-from.

Moteur : OptimizerProtocol::ProtocolVersion et OptimizerClient::kProtocolVersion
passent à "1.2" ; testHandshakeAndPlan vérifie aussi que les deux constantes
sont égales. Aucun autre comportement ne change.

Tests suivis : version attendue dans testoptimizerclient et
testsocketscheduler, handshake de la session de schema-check-test, et un
test du bloc « errors » dans gen-protocol-doc-test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 14:01:46 +02:00
Patrick Schurig
0346775bb9 Fusion feature/droits-lecture-ecriture : droits de NymeaEnergy.* séparés
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 07:40:55 +02:00
Patrick Schurig
cd7cd1a37a fix(eco): ECO_FLOOR_BELOW_STAGE — sous le premier palier, le niveau éco dit « rien n'est acheté »
Sur une charge à paliers (powerLevels déclarés : relay-router, etmvariableload
fixed), une cadence éco sous le premier palier laisse le mécanisme au palier 0.
Le niveau éco publiait pourtant ECO_FLOOR_GRID « 83 W achetés au réseau ».
Il porte désormais ECO_FLOOR_BELOW_STAGE {targetW, stageW, gridW: 0,
o:remainingWh, o:remainingS}. Le premier palier est declared.powerLevels[1],
la source de BELOW_MIN_POWER en passe confort. Une charge continue garde
ECO_FLOOR_GRID.

Aucune consigne ne change : même targetW, mêmes counts, même draw, même
palier, même purchasedWh. Seul le motif du niveau change, et avec lui (R7,
inchangée) decision, decision.level et les lignes [Arbitre]/[RelayRouter].
Hors liste blanche optimiseur : l'optimiseur ne reçoit pas les paliers (E9).

Tests : testEcoBelowTheFirstStageBuysNothing (neuf, échoue sur 61db6cf),
testTheDecisionDescribesTheMergedAction mis à jour (il exigeait l'achat sur
un relais de 1000 W à 83 W), rendu FR et refus optimiseur en loadmodel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 08:43:24 +02:00
Patrick Schurig
8a055eee9c feat(rpc): droits de NymeaEnergy.* séparés — lire, commander, configurer, administrer
Chaque méthode déclare son droit au registerMethod ; plus aucune ne tombe en Admin par la
valeur par défaut de libnymea.

- None (tout compte authentifié) : tous les Get* sauf GetOptimizerTrace, dont
  GetBatteryLevelConsideration (Admin par oubli jusqu'ici). GetOptimizerStatus ne porte ni
  l'endpoint ni le token.
- ControlThings : SetChargingInfo, SetLoadDerogation, ClearLoadDerogation, ClearLoadFault,
  SetLockOnUnplug.
- ConfigureThings : SetLoadConfig, SetTariff, SetForecastSite, SetAcquisitionTolerance,
  SetSpotMarketConfiguration.
- Admin, désormais écrit : SetPhasePowerLimit, SetBatteryLevelConsideration,
  SetOptimizerEndpoint, SetOptimizerToken, GetOptimizerTrace, SetOptimizerTrace.

Un registerMethod privé SANS valeur par défaut masque celui de JsonHandler : oublier le droit
ne compile plus. C'est la seule garde possible pour une méthode Admin, le défaut étant Admin.

Changement de contrat RPC (JSONRPC.Introspect publie permissionScope). Les droits ne mordent
que sur un transport avec authentification.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 08:26:24 +02:00
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
b45d0496c6 feat(moteur): listes fermées du protocole exposées par le code
OptimizerPlan::kinds() (désormais appliquée par convertirAction) et
verdictCodes(), dans l'ordre des contrôles ; SocketScheduler::knownTriggers()
(appliquée par rangDeclencheur), knownFallbackCodes(), knownLastErrorCodes() ;
OptimizerClient::knownErrorCodes(). Le contrat OpenRPC en régénère ses
énumérations. Aucun comportement changé : mêmes listes, mêmes ordres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:30:44 +02:00
Patrick Schurig
31269b531f docs(optimiseur): paramètre de OptimizerClient::tokenLength documenté (correctifs brief 4, point 4)
Doxygen : 1 avertissement introduit par 5768c36 (paramètre non documenté) ;
0 après. Commentaire seul.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 09:34:39 +02:00
Patrick Schurig
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
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
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
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