66 Commits

Author SHA1 Message Date
Patrick Schurig
91b7c53d2f docs: resserrer la conclusion du volet 1 + piège du rootmeter (ECS-504)
Deux corrections de fond.

1. « Les 500 W consommés de plus que comptés sont démontrés » allait un cran trop
   loin. Dans le cycle à verrou expiré, le routeur applique RÉELLEMENT 3000 W : il
   n'y a aucun écart consommé/compté dans ce cycle-là. Ce qui est démontré
   directement, c'est la moitié comptable — au même budget, honorer ou ignorer le
   verrou change le résidu de 500 W et fait basculer la charge de rang 3. L'autre
   moitié est observée dans le PREMIER cycle. Le résultat tient par composition de
   deux moitiés observées dans deux cycles différents du même binaire : solide,
   mais pas une observation directe. Un +etm2 réel reste le seul moyen de voir les
   deux dans le même cycle.

2. Rétractation erronée corrigée. J'avais écrit que la question du compteur ECS ne
   se posait pas, LoadConfig n'ayant aucun champ de rattachement. Le danger n'a
   jamais été là : si le compteur de la charge est désigné comme ROOTMETER dans
   nymea, sa puissance entre dans le bilan de surplus que l'arbitre lit par
   internalRootMeter(), et le budget est faussé pour toutes les charges. Aucune
   configuration n'est nécessaire — une désignation dans l'interface suffit.
   Inscrit sous ECS-504 comme piège de mise en service, applicable au câblage du
   compteur Modbus prévu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 08:54:32 +02:00
Patrick Schurig
92013d76af deb: bump 1.15.2+etm4 (ECS-412 — blocage circulaire du verrou à froid)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 08:28:45 +02:00
Patrick Schurig
111686047d fix(etm): ECS-412 — armement à froid TRANSITOIRE (blocage circulaire corrigé)
Défaut constaté AU BANC le 2026-08-09, pas en test : une charge sonde de rang 3
est restée figée à 0 W sous 4 kW de surplus disponible.

Mécanisme. lockWindow() traitait un m_lastSwitch nul comme sentinelle
« elapsed = 0 » à CHAQUE cycle. Une charge démarrant au palier 0 avec
minOffS > 0 voyait donc offHeld vrai en permanence, maxStage forcé à 0 : elle ne
pouvait jamais s'enclencher, donc jamais commuter, donc jamais valider
m_lastSwitch. Blocage circulaire. L'intention d'ECS-412 — armer plutôt que
purger — était juste ; mon écriture rendait l'armement DÉFINITIF au lieu de
transitoire.

Portée réelle : toute charge relay-router démarrant relais ouverts avec
minOffS > 0 était gelée. Le chauffe-eau du banc (minOffS = 60) n'y échappait que
parce que ses relais étaient déjà fermés et qu'ECS-411 lui faisait reprendre un
palier non nul — au premier démarrage sur installation froide, il aurait été
bloqué lui aussi.

Correction : armement PARESSEUX. applyAction(), chemin non-const qui reçoit le
temps de cycle, estampille m_lastSwitch au premier now s'il est invalide. Le
verrou expire alors après sa durée configurée. Le repli « elapsed = 0 » ne
couvre plus que les cycles précédant la première action. L'invariant « temps =
paramètre, jamais l'horloge » est préservé : aucune horloge n'est lue, et le
contrat d'ILoadAdapter n'est pas modifié — passer now au constructeur aurait
changé l'interface pour un cas particulier.

Symétrie vérifiée : relais fermés au départ → ECS-411 donne un palier non nul,
c'est minOn qui s'arme ; relais ouverts → palier 0, c'est minOff. Les deux sont
désormais transitoires de la même façon.

Test — le vrai livrable : testEcsColdStartLockExpires. Palier 0 au départ,
minOffS = 120, budget de 5000 W. La charge reste éteinte à t0 et à t0+119, puis
s'enclenche à t0+121. Couvre aussi l'expiration de la fenêtre exposée au
scheduler (lockMaxPowerW : 0 pendant le verrou, 1000 après). Aucun test ne
combinait « palier 0 au départ » et « minOffS > 0 » — c'était le trou exact.

spec_ecs.md : ECS-412 précise que l'armement est transitoire, d'une durée égale
au verrou configuré, posé paresseusement, avec le tableau de symétrie et le
renvoi au test.

Build amd64 0 erreur. Simulation : 15/15.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 08:24:41 +02:00
Patrick Schurig
4ea32c3118 docs(spec): INST-104 — .75 réglée pour émuler l'installation de référence
La configuration de la box de test déclarait 2 relais à 600/1200 W ; elle en
déclare désormais 3 à 500/1000/2000, posés par SetLoadConfig (RPC, rebuild à
chaud). Trois relais GPIO, Relay1/2/3 ; Relay4/5 restent au SG-Ready.

Formulation retenue : « réglée pour ÉMULER l'installation de référence », pas
« alignée sur le câblage réel ». La différence n'est pas rhétorique — elle dit
que la vérification physique en attente (INST-102) porte sur l'installation
CLIENTE, pas sur les broches de .75. Mesurer le banc à la pince ne confirmerait
que le banc.

Fait établi au passage : le câblage de .75 était DÉJÀ documenté dans
docs/TEST_TERRAIN.md:24-26 sous les noms R500/R1000/R2000, broches BCM 5/6/13,
avec les mêmes ThingId. La configuration à 600/1200 était donc un reliquat,
contredit par la documentation du banc elle-même — ce n'est pas la spec qui a été
imposée au banc, c'est le banc qui a été remis d'accord avec sa propre doc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 14:57:51 +02:00
Patrick Schurig
0f60e4e9dd deb: bump 1.15.2+etm3 (étape 1 — ECS-306, ECS-411, ECS-412)
Version alignée sur la box cible : nymea 1.15.2, plugin installé 1.15.2+etm2.
Numérotation contiguë — aucun +etm3 n'a jamais été distribué.

Le fichier réel est debian-qt5/changelog : debian -> debian-qt6, et
debian-qt6/changelog -> ../debian-qt5/changelog. Un seul changelog pour les deux
arbres de packaging, écrit via debian/changelog.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 14:36:38 +02:00
Patrick Schurig
592fc72d32 docs(spec): spec_ecs 0.5.2 — câblage 500/1000/2000 et DÉCOUPLAGE des puissances
Correction. La 0.5.1 avait corrigé le câblage en 500/1000/1500 sur la foi du
simulateur du banc ; le simulateur ne reflète pas l'installation. Le câblage réel
est 500/1000/2000. Quatrième révision de cette donnée.

Les trois consignations reviennent à l'état de la 0.5.0 :
  - ECS-302 NON exercé — pondération binaire, aucune redondance d'encodage. Il
    perd son caractère de préalable structurel et redescend derrière ECS-305 dans
    l'étape 3 ;
  - ECS-303 RÉTABLI, mais reformulé sans puissances : « hystérésis élargie aux
    frontières de recombinaison », déclenchée dès qu'un passage de palier impose
    de basculer plus de deux relais ;
  - 8 valeurs distinctes, plafond 3500 W, usure 7/3/1 par balayage.

DÉCOUPLAGE — c'est le vrai objet de cette version. Les puissances ne vivent plus
que dans un bloc unique, §4.0 « Installation de référence », qui porte les trois
valeurs, leur source et leur date, ainsi qu'un tableau « ce que cette
installation exerce et ce qu'elle n'exerce pas ». Aucune exigence ne contient
plus de puissance : celles dont le rang ou la portée en dépendent le disent par
renvoi. Vérifié — hors §4.0, il ne subsiste que le changelog, le journal et
l'illustration P = U²/R d'ECS-500.

Motif : ce paramètre a fait quatre allers-retours, chacun coûtant la réécriture
de trois exigences. Le cinquième ne coûtera qu'un paragraphe.

Source inscrite telle qu'elle est : DÉCLARATION DE L'UTILISATEUR, NON MESURÉE.
Une vérification physique — plaque signalétique ou pince ampèremétrique — est en
attente et reste un préalable à l'ouverture de l'étape 3.

Note ajoutée sous ECS-411 : sur cette installation les encodages sont uniques,
donc le modèle d'état ne peut pas diverger du physique. Sur un câblage à
encodages multiples, une correspondance par puissance seule renverrait un palier
dont l'encodage canonique diffère de l'ensemble réellement fermé — la reprise
doit alors restituer l'ensemble de relais, pas seulement l'index.

Le simulateur du banc sera aligné sur cette installation dans un lot SÉPARÉ, sur
le dépôt etm-powersync-hems-sim.

Aucun code touché.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 13:34:43 +02:00
Patrick Schurig
948ecd1a79 fix(etm): ECS-306 — le budget décrémenté suit le palier réellement applicable
Régression contre une décision documentée : AGENTS.md, section « Verrous
minOn/minOff », énonce que le scheduler clampe et décrémente le budget au palier
réel. Le canal qui le permettait — minStage/maxStage dans LoadContextTelemetry —
a été retiré en rév. 2/3. L'écrêtage a continué d'avoir lieu dans l'adaptateur,
mais son résultat ne revenait plus à l'arbitre : pendant toute la fenêtre minOn,
les charges de priorité suivante recevaient un résidu SURESTIMÉ et
l'installation soutirait au réseau.

Règle visée : la 4 — « bornes par adaptateur écrêtent TOUTE sortie de
stratégie ». Pas la 1 : il n'y a qu'un décideur, le défaut est un décalage de
comptabilité entre décision et exécution.

Le canal est restauré EN WATTS, pas en index de palier : lockMinPowerW /
lockMaxPowerW dans LoadContextTelemetry, remplis par RelayRouter::toLoadContext()
depuis lockWindow(), convertis via la table de paliers. La frontière rév. 3 tient
— aucun identifiant de relais ni index de combinaison ne remonte. Un
lockMaxPowerW négatif signifie « aucun plafond », et non « plafond nul ».

buildSetpointAction() applique ces bornes AVANT de décrémenter le budget, donc le
résidu passé aux charges suivantes tient compte de la puissance engagée — quitte
à devenir négatif, ce qui est la réponse correcte. Le decisionReason distingue ce
cas : « Verrou minOn — X maintenue à N W (puissance engagée, budget M W) ; le
résidu en tient compte ».

Corriger dans le cycle, pas au cycle suivant : remonter le palier appliqué après
coup n'aurait rattrapé l'erreur qu'au tour d'après.

Test : testEcsBudgetUnderLock — deux charges classées, la première verrouillée à
2000 W sous un budget de 500 W ; la seconde reste à 0 parce que le résidu tient
compte des 2000 W engagés.

Build amd64 0 erreur. Simulation : 14/14.

Réf. specs/spec_ecs.md §3 ECS-306 (0.5.1).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:47:55 +02:00
Patrick Schurig
40125d1026 fix(etm): ECS-411 — reprise du palier depuis l'état réel des relais
m_currentStage valait 0 à la construction alors que des contacts peuvent être
fermés : le moteur croyait 0 W pendant que le ballon tirait sa puissance. Même
famille de défaut qu'ECS-410 — annoncer une puissance non appliquée.

deduceStageFromThings() lit l'état réel des Things au constructeur, en trois
temps :

  1. correspondance EXACTE de l'ensemble de relais fermés avec un palier — cas
     nominal ;
  2. à défaut, correspondance par PUISSANCE. Ce cas se produit avec les encodages
     dédupliqués : deux combinaisons de même puissance existent, une seule est
     conservée dans la table. Toute somme de sous-ensemble figure nécessairement
     dans m_levels, qui est construit de ces sommes — la reprise aboutit donc
     toujours, et le premier applyAction() normalise l'encodage des relais ;
  3. hors table (troncature à MaxRelays) : palier MAXIMAL, jamais 0.

Un relais introuvable est supposé FERMÉ, avec avertissement. Le principe est
constant sur les trois branches : ne jamais annoncer moins que ce qui peut être
appliqué. Sous-estimer est le défaut qu'ECS-411 corrige ; surestimer ne fait que
retarder une montée en puissance.

Le repli initial sur le palier 0 — écrit dans un premier jet — reproduisait
exactement le défaut visé et a été corrigé avant ce commit.

Test : testEcsRestartRecovery — trois cas, dont une combinaison à deux relais
(R500+R1500 = 2000 W) et le cas tout-ouvert.

Build amd64 0 erreur. Simulation : 13/13.

Réf. specs/spec_ecs.md §5 ECS-411 (0.5.1).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:45:15 +02:00
Patrick Schurig
867820fc26 fix(etm): ECS-412 — rebuild incrémental et armement du verrou à froid
Cause racine, pas symptôme. rebuildLoadAdapters() détruisait TOUS les adaptateurs
à chaque SetLoadConfig, réarmant leurs verrous. Sur un ballon thermodynamique à
minOn de 300-600 s, un client qui réordonne ses priorités depuis l'app pouvait
faire court-cycler son compresseur. C'est de la protection matérielle.

L'arbitre mémorise désormais la config ayant servi à construire chaque adaptateur
(m_builtFrom) et ne reconstruit que si le MATÉRIEL a changé — type, câblage,
paliers, plafond, verrous (sameHardware()). Un changement de rang ou de besoins
passe par updateSoftConfig(), en place : ni m_currentStage ni m_lastSwitch ne
bougent, aucun relais n'est réécrit. Le log distingue créées / mises à jour /
inchangées / retirées.

updateSoftConfig est PURE VIRTUELLE, sans implémentation par défaut. Un défaut
vide silencieux ferait qu'un futur adaptateur construit depuis LoadConfig
ignorerait sans bruit les changements de rang ; là, le compilateur force la
décision. EvAdapter et SgReadyAdapter la déclarent sans effet, avec le motif.

Démarrage à froid : après un redémarrage de nymead, m_lastSwitch est
irrécupérable. lockWindow() traite désormais un horodatage nul comme
« commutation venant d'avoir lieu » (elapsed = 0), donc verrou ARMÉ pour sa durée
configurée. L'écriture naturelle (`valid && elapsed < minOnS`) fait l'inverse et
laisserait une boucle de redémarrage court-circuiter la protection compresseur
quand elle est la plus nécessaire. Armer n'est pas verrouiller
inconditionnellement : avec une durée nulle, `0 < 0` est faux et le verrou reste
inactif — un premier jet qui forçait le verrou a fait tomber trois tests
existants, qui avaient raison.

Test : testEcsRebuildPreservesLock — SetLoadConfig pendant une fenêtre de verrou
active, le relais reste fermé ; le délestage reprend une fois minOn écoulé.

Build amd64 0 erreur. Simulation : 12/12.

Réf. specs/spec_ecs.md §3 ECS-412 (0.5.1).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:42:56 +02:00
Patrick Schurig
985a7641bd docs(spec): spec_ecs 0.5.1 — câblage réel 500/1000/1500, les trois consignations s'inversent
La 0.5.0 consignait 500/1000/2000 sur la foi de la consigne d'étape 1. Le
simulateur du banc contredit cette donnée ; arbitrage : le simulateur reflète
l'installation, la donnée était fausse.

Câblage : 500/1000/1500 W, additif, 7 valeurs distinctes de 0 à 3000 W, avec UNE
redondance — 1500 W admet {R1500} et {R500+R1000}.

Trois corrections :

1. ECS-302 est EXERCÉ. Il redevient préalable structurel de l'étape 3 et reprend
   son rang devant tout ce qui s'appuiera sur m_relayMapping.

2. ECS-303 SUPPRIMÉ. Le « point dur » 1500→2000 n'est pas une exigence
   d'hystérésis, c'est une conséquence directe d'ECS-302 non traité :
   relayrouter.cpp:58 retient la première combinaison rencontrée (masque le plus
   bas), donc {R500+R1000} pour 1500 W, et jette {R1500}. La transition vers
   2000 W = {R500+R1500} demande alors d'ouvrir R1000 et de fermer R1500, avec le
   creux qu'impose l'ordre coupure-avant-fermeture. Avec {R1500} pour 1500 W, la
   même transition ne coûterait QU'UNE commutation : fermer R500. Aucune entrée
   ECS-303 ni ECS-605 de ce côté.

3. 7 valeurs et plafond 3000 W, non 8 et 3500. ECS-305 reste valable, comptes
   corrigés : par balayage complet R500 commute 5 fois, R1000 3 fois, R1500 une
   seule (vérifié sur l'encodage effectivement retenu par le code actuel).

RÉSERVE INSCRITE : la source est le simulateur (sim/sim_ecs_router.py:38, sommé
dans _power_w(), confirmé en vol sur MQTT), PAS une mesure physique. Ce chiffre a
déjà changé trois fois et il commande désormais l'ordre de l'étape 3 : il doit
être confirmé sur plaque signalétique ou au pince ampèremétrique AVANT
l'ouverture de cette étape. Consigné en §13-3 et en garde-fou sous l'étape 3.

Aucun code touché.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:15:40 +02:00
Patrick Schurig
e7a5c7fbab docs(spec): spec_ecs 0.5.0 — §13-1/§13-2 tranchés, étape 1 ouverte, câblage confirmé
Précondition de la consigne d'étape 1 : les deux arbitrages étaient RELEVÉS au
journal, pas TRANCHÉS. Ils le sont maintenant, datés, dans les termes reçus.

§13-1 CLOS — le schéma « ARCHITECTURE CIBLE » d'AGENTS.md a été redessiné par le
lot de consolidation (f6a4be5) : RelayRouter et EtmVariableLoadAdapter y
figurent, le kind Stage en est retiré, SocketScheduler et BatteryAdapter sont
marqués non écrits.

§13-2 CLOS, lecture (b) — ECS-412 réduit à m_lastSwitch et redirigé vers sa cause
racine (rebuild incrémental : ne reconstruire que ce qui a changé) ; ECS-411
remonté en étape 1. L'étape 1 porte donc TROIS exigences : ECS-306, ECS-411,
ECS-412. Ajouté explicitement : au démarrage à froid, m_lastSwitch étant
irrécupérable, le verrou DOIT être ARMÉ et non purgé — l'implémentation naturelle
(QDateTime nul = verrou inactif) fait l'inverse et court-circuiterait la
protection compresseur sur une boucle de redémarrage.

Câblage du banc confirmé 500/1000/2000 (pondération binaire, 8 paliers de 0 à
3500 W au pas de 500, aucune redondance). Trois conséquences consignées :
  - ECS-302 n'est PAS exercé par ce matériel — un seul encodage par palier. La
    restructuration de m_relayMapping perd sa justification immédiate et cesse
    d'être un préalable structurel de l'étape 3.
  - ECS-305 monte en importance : sur un balayage complet R500 commute 7 fois,
    R1000 3 fois, R2000 une seule. C'est la seule mesure d'usure utile.
  - ECS-303 CRÉÉ — point dur 1500→2000 W : les trois relais basculent d'un coup,
    donc trou de puissance à chaque franchissement avec l'ordre
    coupure-avant-fermeture. La parade est un seuil d'hystérésis élargi à cette
    frontière, pas un réordonnancement. À MESURER au banc avant de coder.

Aucun code touché.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:56:53 +02:00
Patrick Schurig
7f204ebf3a docs(etm): contrats Doxygen — 25 derniers éléments hors fichiers différés
Complète la couverture : 31 → 6 avertissements. Les 6 restants sont exactement
les deux fichiers différés jusqu'à l'étape 3 (relayrouter.h 3,
energyarbitrator.h 3, ce dernier étant la cible d'ECS-412).

energyratioscalculator.h traité EN PREMIER, pour la raison qui le distingue :
c'était le seul endroit où l'absence de documentation cachait une règle métier.
La règle était en fait écrite — mais en commentaires « // », invisibles à
doxygen, donc invérifiables. Conversion en blocs Doxygen SANS réécriture du
fond : les trois cas de reseed de la baseline (premier appel, changement de jour
local, compteur non monotone) et la garantie « dénominateur ≤ 0 → n/a, jamais de
NaN » deviennent des invariants opposables. Précisé au passage que le paramètre
`now` doit être en heure LOCALE — passer de l'UTC déplacerait la frontière de
journée.

Reste du lot : loadaction.h (quels champs sont significatifs selon le kind),
plan.h ([from, to[ et l'interdiction de retourner un plan invalide),
loaddescriptor.h, loadconfigstore.h (tolérance de chargement : un fichier absent
n'est pas une erreur, une entrée invalide est ignorée seule).

Build amd64 0 erreur. Simulation : 7/7, dont testEnergyRatiosAlignment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:47:19 +02:00
Patrick Schurig
4f6dfa4489 docs(etm): contrats Doxygen — types de config/contexte et adaptateurs stables
Couverture des fichiers non visés par la restructuration à venir : l'extraction
du noyau de calcul (étape 2) et celle de m_relayMapping (étape 3) ne les
touchent pas. relayrouter.h et energyarbitrator.h sont volontairement DIFFÉRÉS —
documenter ce qui va changer produirait du bruit d'historique.

Mesure sur la config du Doxyfile : 113 → 31 avertissements.
  loadconfig.h 41→0 · surpluscontext.h 30→0 · sgreadyadapter.h 4→1 (le \return
  restant relève du lot suivant) · etmvariableloadadapter.h 2→0 · evadapter.h 1→0

Ce sont des contrats, pas des étiquettes : enabled dit qu'une charge déclarée
mais exclue n'est JAMAIS pilotée ; priority qu'il s'agit d'un rang ascendant et
non d'un poids ; timestamp qu'il est la source unique du temps, dont dérivent
verrous et fenêtres ; setPowerLevels qu'il trie et déduplique ; fromMap qu'il
retourne une config NON validée ; internalRootMeter() qu'il peut être nul.

Ajouts « // [ETM] » hors etm/ (smartchargingmanager.h) : ils échappent au
périmètre du Doxyfile, la frontière étant un répertoire. Un inventaire explicite
est posé au marqueur [ETM] BEGIN, distinguant les trois cas — degradedMode() et
les trois accesseurs internal* sont des ajouts ETM et sont documentés ; les huit
changements de visibilité seule gardent la documentation de l'amont. La
définition de fait d'AGENTS.md renvoie à cet inventaire et l'étend explicitement
aux ajouts hors etm/.

Build amd64 0 erreur. Simulation : 7/7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:47:05 +02:00
Patrick Schurig
41ef266c32 fix(doc): deux défauts Doxygen réels dans etm/
Corrections, pas ajouts. Les deux n'apparaissent que sous une configuration
générant réellement une sortie (cf. Doxyfile) — ils étaient invisibles jusqu'ici.

- loadconfigstore.h : « <LoadConfig §4> » dans le bloc de tête était interprété
  comme une balise xml/html non supportée, ce qui tronquait la description du
  format fichier. Reformulé sans chevrons.

- sgreadyadapter.h : lockWindow() documentait « \param[out] minState / maxState »
  en une seule directive ; doxygen n'en reconnaissait aucune des deux et
  considérait « now » et « maxState » comme non documentés. Scindé en trois
  directives, une par paramètre.

Aucun changement de comportement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:46:42 +02:00
Patrick Schurig
c20c54bc90 build(doc): Doxyfile de vérification (HTML + XML), non bloquant
Périmètre energyplugin/etm/, EXTRACT_ALL=NO — sans quoi doxygen documente tout
d'office et n'avertit de rien.

GENERATE_HTML et GENERATE_XML restent à YES, et c'est structurel : le nombre
d'avertissements dépend des générateurs actifs. Sans aucune sortie, doxygen
1.9.8 signale « not documented » des membres qui le sont (mesuré sur le même
arbre : 163 sans sortie, 167 en XML seul, 109 en LaTeX, 113 en HTML+XML). Seule
la configuration HTML+XML donne un comptage honnête. L'avertissement est écrit
en tête du fichier.

XML pour une éventuelle chaîne Breathe/Sphinx — coût mesuré ~180 ms, 73
fichiers, 1,5 Mo. Note : l'amont nymea documente en qdoc, pas en doxygen ;
nymea-docs n'a pas pu être consulté.

WARN_AS_ERROR=NO : le job CI ne devient bloquant qu'à la clôture de l'étape 3
de specs/spec_ecs.md (relayrouter.h et le noyau de calcul sont différés
jusque-là). Aucun job CI créé dans ce commit.

doc-generated/ (OUTPUT_DIRECTORY) ajouté au .gitignore.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:46:16 +02:00
Patrick Schurig
91262296d5 chore(gitignore): artefacts de build des tests (19 → 4 entrées non suivies)
Les artefacts produits par `qmake6 CONFIG+=build_tests && make` polluaient
`git status` en permanence. Ajoutés : qrc_*.cpp, target_wrapper.sh, les trois
binaires de test (chemins explicites — ces cibles n'ont pas d'extension), le
plugininfo généré du mock, build-check/ et .cache/.

Portée du motif plugininfo volontairement limitée à
tests/mocks/plugins/energymocks/ : energyplugin/plugininfo.h est SUIVI, un motif
global aurait été trompeur.

Vérifié : aucun fichier suivi n'est nouvellement ignoré
(`git ls-files | git check-ignore --stdin` → vide).

Restent non suivis, volontairement — fichiers de travail, pas des artefacts :
BRIEF_agent_energymanager.md, consignes/, jsonRPC.txt, logs-3biv/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 10:53:21 +02:00
Patrick Schurig
b16b2270a4 docs(specs): spec_ecs 0.4.3 + spec_loadmodel 0.1.1
Deux documents normatifs, préalables au lot AGENTS.md qui les référence.

spec_ecs.md 0.4.3 — spécification ECS multi-palier, écrite après audit du code :
  - 0.4.1 : statut réel des binaires de test, portée du point de gouvernance
    AGENTS.md, citation de règle dans ECS-306, recouvrement ECS-411/ECS-412 ;
  - 0.4.2 : étape 2 « type domaine » → « noyau de calcul » (collision avec LM-100) ;
  - 0.4.3 : ECS-110 étendu — la validation ne doit pas reposer sur Q_ASSERT,
    absent du binaire release (QT_NO_DEBUG).

spec_loadmodel.md 0.1.1 — modèle de charges (domaine × mécanisme), intention de
conception. Ne déclenche aucun travail.

Aucun code touché.

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 07:56:35 +02:00
Patrick Schurig
c9f68c25ae deb: bump 1.15.2+etm2 (rév. 3 — frontière optimiseur↔routeur, RelayRouter)
Build contre nymea 1.15.2. Paquet rév. 3 pour test live RelayRouter sur hems (testing).
(changelog réel = debian-qt5/changelog ; debian → debian-qt6 → ../debian-qt5/changelog.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:39:51 +02:00
Patrick Schurig
e7a15c3c5d test(etm): restaurer testEcsRelayTopologies côté routeur + test config→RelayRouter (rév. 3)
testEcsRelayTopologies réécrit pour le RelayRouter (la combinatoire vit maintenant dans le
routeur, plus dans le thing) :
- 1 relais (dégénéré [0, 2000]) ;
- 3 relais 500/1000/2000 NON-CASCADÉ : paliers dérivés [0,500,..,3500], arrondi watts→combo,
  transition 1500→2000={r2000} seul (off-before-on, commute 3 relais) ;
- DÉDUPLICATION : deux relais identiques 1000 W → niveaux fusionnés [0,1000,2000] (3, pas 4),
  le palier 1000 n'utilise qu'un relais — prouve que la dérivation dédoublonne.

testLoadConfigRelayRouter (nouveau) : chaîne COMPLÈTE SetConfigs(relays[]) → store.changed →
rebuild → RelayRouter → cycle surplus → commutation relais → currentPowerW (pas les pièces
isolées).

Build prod 0/0 ; suite complète verte (8/8 tests à 3 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:00:21 +02:00
Patrick Schurig
170c406f2a feat(etm): câblage RelayRouter + repli L2 unifié (rév. 3) — routeur vivant, sécurité fermée
L'arbitre construit et pilote les routeurs ; le repli L2 couvre les DEUX types ENSEMBLE
(atomique : pas d'intermédiaire avec charge active sans repli).

- Map unique m_loadAdapters (QHash<QString, ILoadAdapter*>) : RelayRouter + EtmVariableLoadAdapter
  cohabitent par polymorphisme. registerRelayRouter() ajouté.
- rebuildLoadAdapters() : la distinction de TYPE vit ICI — RelayRouter si relays[], sinon
  EtmVariableLoadAdapter. Au-dessus tout est ILoadAdapter (Setpoint W).
- Dispatch Setpoint AGNOSTIQUE au type : routage par loadId, le polymorphisme absorbe (pas de
  if(RelayRouter)). Le scheduler distingue Setpoint (etmvariableload + relay-router) vs State
  (sg-ready) — nature de l'action, pas classe concrète.
- Repli L2 : applyDegradedMode boucle sur TOUS les ILoadAdapter → Setpoint(0) force=true. Pour
  le RelayRouter, coupe TOUS les relais (bypass minOn). Ferme le trou T2 pour le routeur.
- testMeterSilentFallback étendu au cas RELAIS : compteur muet → 2 relais OFF force=true,
  restent OFF sur N cycles. Certifie la sécurité relais.

Build prod 0/0 ; suite (config/L2/migrés) verte. Étape qui rouvre le déploiement.

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:03:17 +02:00
Patrick Schurig
c86513e10f feat(etm): LoadConfig rév. 3 — forme relay-router (relays[]) + validation conditionnelle
LoadConfig gagne la forme "relay-router" (ECS multipalier rév. 3) : liste de relais
power (relays[] = [{thingId, powerW}]) + minOnS/minOffS. Coexiste avec la forme
"etmvariableload" (continu/dynamic ou multipalier natif). powerLevels NON stockés pour
le cas relais (DÉRIVÉS par le routeur en étape 3-4).

- LoadConfigRelay (struct) + relays/minOnS/minOffS dans LoadConfig (Q_GADGET + toMap/fromMap).
- isValid() CONDITIONNELLE au type (adapter/mode), pas "tous champs requis" :
  relay-router → relays[] valides ; etmvariableload → powerLevels (fixed) | maxPowerW (dynamic).
- Schéma SET : TOUS les champs spécifiques en "o:" (powerLevels, maxPowerW, relays, minOnS,
  minOffS, needs) — sinon nymea rejette une des deux formes avant le handler (bug objectRef
  strict de T4, doublé en rév. 3). GET reste typé objectRef<LoadConfig>.
- testLoadConfigRpc étendu : SET des DEUX formes (etmvariableload + relay-router) tous deux
  acceptés, round-trip relays[]/minOnS, rejets conditionnels (powerLevels sans 0 ; relays[] vide).

Build prod 0/0 ; suite config/L2/migrés verte.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 11:49:52 +02:00
Patrick Schurig
11f5c36b3a docs(contract): INTERFACE charge pilotée rév. 3 — frontière optimiseur↔routeur
Vérification nymea : un integration-plugin ne peut pas piloter les things d'un autre
plugin (thingManager privé ; seuls cœur/règles/scripts/experience-plugins le font).
Donc la combinatoire watts→relais ne peut vivre que côté experience-plugin.

rév. 3 acte le déplacement de frontière :
- Optimiseur watt-pur (ne connaît jamais un relais) | ROUTEUR (watts→relais,
  experience-plugin, a le ThingManager) | relais = things "power" nus.
- ECS multipalier : LoadConfig devient une LISTE de relais power (relays[] +
  minOnS/minOffS) ; powerLevels DÉRIVÉS des combinaisons (source = les relais).
- Cas continu (EV/triac, EtmVariableLoadAdapter) inchangé.
- §10 IMPACT APP explicite : l'écran « Configurer » passe à une liste de relais +
  picker findConfiguredThings("power") (UI-only) — l'agent app doit lire rév. 3
  avant de toucher l'UI.

À déposer À L'IDENTIQUE dans etm-powersync-app (miroir manuel, hors de ce repo).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 11:17:39 +02:00
Patrick Schurig
652b434f4c feat(etm): config charge pilotée persistée (Get/SetLoadConfig) + repli L2
Pont moteur↔app : déclaration des charges etmvariableload persistée et éditable,
construction des adaptateurs à chaud depuis la config (remplace le registre en dur).

- LoadConfig / LoadConfigNeeds (Q_GADGET typés, introspectables) — forme mot pour mot
  du LoadDescriptor §4 (jonction inter-repos avec l'app).
- LoadConfigStore : persistance atomique /var/lib/nymea/energy-load-configuration.json,
  validation en bloc, signal changed().
- Handler NymeaEnergy.GetLoadConfig / SetLoadConfig (+ notif LoadConfigChanged). GET typé
  objectRef<LoadConfig> ; SET schéma inline o: (powerLevels/needs conditionnels §4).
- EnergyArbitrator : setLoadConfigStore + rebuildEtmVariableLoadAdapters (enabled==true
  seulement, §9) ; repli L2 = setPowerSetpoint(0) force=true sur tout etmvariableload
  (ferme le trou sécurité ouvert en T2).
- Tests simulation : migration ECS→etmvariableload (arrondi fixed/dynamic, recrédit,
  délestage, round-trip powerSetpoint), budget partagé etmvariableload↔PAC, watchdog L2
  réactivé, persistance + construction depuis config + injection RPC end-to-end.

PAC SG-Ready reste hors config (§8, gelée). Les 5 tâches du brief sont couvertes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 09:51:38 +02:00
Patrick Schurig
0d031d2205 feat(etm): règle d'arrondi etmvariableload dans le waterfall + infra de test
RuleBasedScheduler : buildSetpointAction (contrat rév. 2 §5) — fixed = arrondi au plus
haut powerLevels ≤ budget ; dynamic = clamp(budget, 0, maxPowerW) ; recrédit currentPowerW
de DÉBUT de cycle (invariant 8) ; résidu cascadé. Waterfall unifié : etmvariableload
(Setpoint W) + sg-ready (State) triés par priorité, un seul budget.

EnergyArbitrator : registerEtmVariableLoadAdapter, inclusion dans buildContext, dispatch
Setpoint par loadId (l'EV reste au proxy amont).

Infra de test : ThingClass mock etmVariableLoad (currentPowerW read / powerSetpoint write)
+ handler executeAction + helper addEtmVariableLoad — valide l'implémentabilité de
l'interface avant le thing réel. Les scénarios simulation migrés arrivent en T4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 09:47:58 +02:00
Patrick Schurig
10e553d1ea refactor(etm): retirer le kind Stage (ECS→Setpoint), chemin State PAC conservé
Contrat etmvariableload rév. 2 §6 : LoadAction garde DEUX kinds (Setpoint W + état
SG-Ready). On supprime UNIQUEMENT le kind Stage (index de palier ECS) ; l'ECS passera
en etmvariableload (Setpoint W) en T3.

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 09:06:16 +02:00
Patrick Schurig
7ed58a36d1 log: promouvoir transitions ECS/SgReady de qCDebug → qCInfo
Visible avec NymeaEnergy.info=true dans nymead.conf sans activer debug global.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 06:27:00 +02:00
Patrick Schurig
e3dad97683 deb(qt6): fix install.in — chemin qm installé (debian/tmp) pas chemin source
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 16:46:54 +02:00
Patrick Schurig
960c52b7b4 deb: basculer sur debian-qt6 (hems = Qt6) + powersync-* + Provides/Conflicts/Replaces
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 16:39:29 +02:00
Patrick Schurig
1c7b4cb3d7 deb: bump vers 1.15.2+etm1 (build contre nymea 1.15.2)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 16:17:39 +02:00
Patrick Schurig
53923b9301 [packaging] renommage powersync-* + version 1.15.0+etm1 + invariants AGENTS
- debian/control : Source+Package → powersync-energy-plugin-nymea ;
  Provides/Conflicts/Replaces: nymea-energy-plugin-nymea (remplace l'amont) ;
  description + homepage ETM.
- debian/changelog : nouvelle entrée tête powersync-energy-plugin-nymea
  (1.15.0+etm1) trixie — plus de 1.13.0/jammy comme version courante.
- debian/nymea-energy-plugin-nymea.install.in → powersync-energy-plugin-nymea.install.in
  (déjà commité via git mv).
- AGENTS.md : section "INVARIANTS BUILD / PACKAGING" (nom powersync-*,
  version +etmN, cross arm64, tests hors deb, vérification post-build).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 14:50:40 +02:00
Patrick Schurig
d701896ebd [build] exclure nymea-energy-tests du build deb
debian/rules : DH_OPTIONS=-N nymea-energy-tests — les binaires de test
n'existent pas dans le build deb (ils nécessitent CONFIG+=build_tests).
L'exclusion est globale sur tous les outils dh, pas de paquet vide produit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 14:26:50 +02:00
Patrick Schurig
28c0109847 [build] .pro racine : tests sous CONFIG+=build_tests (pas dans le build deb)
libnymea-tests:arm64 1.15.0 n'exporte pas enableNotifications — le link
des tests dans dpkg-buildpackage échoue. Politique : les tests ne font pas
partie du paquet de prod ; ils sont réservés au CI/dev (CONFIG+=build_tests).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 14:15:50 +02:00
Patrick Schurig
fb5c7fca10 [deploy] §1.d banc hems : EcsRelayAdapter + SgReadyAdapter dans init()
Enregistre les adaptateurs ECS (3 relais R500/R1000/R2000) et SG-Ready (K1/K2)
avec les ThingIds réels du banc hems dans EnergyPluginNymea::init(), compilé
uniquement sous ETM_ARBITRATOR. Bloquant pour le cross-build arm64 Palier 1.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 13:11:59 +02:00
Patrick Schurig
fa3660fc60 [3e+] ECS non-cascadé : applyRelayStage off-before-on + tests 1 et 3 relais
applyRelayStage faisait déjà du set-cible complet (delta correct, gère le non-cascadé) :
durcissement off-before-on (anti sur-puissance transitoire quand monter de palier éteint
des relais, ex. 3 résistances 500/1000/2000 : 1500→2000 commute 3 relais) + intention
documentée (comme SG-Ready).

testEcsRelayTopologies : ECS simple 1 relais (on/off) + ECS 3 relais non-cascadé
(transition 1500→2000 → set final r2000 SEUL, r500/r1000 coupés). Couvre les 2 topologies
du test terrain vendredi. Suite simulation 20/20, plugin prod 0/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 00:09:24 +02:00
Patrick Schurig
cbe1404314 [doc] audit Doxygen : \param 'now' + docs périmées après refactors 3c/3e
Audit manuel (doxygen non installé, pas de Doxyfile). 5 findings corrigés :
- EvAdapter::applyAction : \param now manquant (param partiellement documenté → warning) ;
  toLoadContext : \param now ajouté.
- EnergyArbitrator::buildContext : mention SG-Ready + \param now (source unique verrous).
- applyActionsToAdapters : dispatch State→SG-Ready documenté (était ECS/Stage seul).
- onMeterWatchdogTick : doc alignée sur le refactor 7c (délègue à evaluateMeterFreshness,
  QTimer sous #ifndef ENERGY_SIMULATION).
- RuleBasedScheduler (classe + getPlan) : décrivait seulement le proxy EV → ajout du
  waterfall non-EV (budget net signé, priorité ASC, recrédit, clamp lock-aware) et
  correction "seul ctx.timestamp utilisé" (faux : meter + loads aussi).

Concepts 3c/3e vérifiés documentés : seam de temps/lockWindow, minStage/maxStage,
atomicité 2 bits (transientHarm), mode dégradé L2, waterfall unifié + ordre EV→ECS/SG-Ready,
hystérésis SG-Ready. Build 0/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 23:55:31 +02:00
Patrick Schurig
d5821336c9 [3e-5] testSgReadySurplus : montée + hystérésis + court-cycling + budget partagé ECS↔PAC
Test simulation autonome (mock powerSwitch 2 relais, encodage 2 bits). 4 volets :
(1) montée 2→3→4 ; (2) hystérésis 3↔4 (zone morte P4×1,0–1,2, budget oscillant →
reste 4) ; (3) court-cycling (gelé sous minStateHold, bascule au-delà via temps simulé) ;
(4) budget PARTAGÉ ECS↔PAC : ordre priorité → service inverse (preuve waterfall unifié 3e).
Suite simulation 19/19, 0 régression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 23:43:02 +02:00
Patrick Schurig
f7aa7ca7e6 [3e-4] arbitre : registerSgReadyAdapter + dispatch State + mode dégradé → état 2
registerSgReadyAdapter + m_sgReadyAdapters ; buildContext inclut les PAC ;
applyActionsToAdapters dispatche kind==State → m_sgReadyAdapters. Mode dégradé L2 :
SG-Ready → état 2 (NORMAL, mains off, force=true), JAMAIS état 1 (blocage). SAFETY.md
table L2 corrigée (état 2, pas 1). Build 0/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 23:25:09 +02:00
Patrick Schurig
c5c22bfe19 [3e-3] mapping sémantique SG-Ready + waterfall unifié ECS/SG-Ready
getPlan : cascade unique sur charges non-EV (relay-stages + sg-ready) triées par
priorité — budget de surplus partagé. buildSgReadyStateAction : mapping qualitatif
(4 forcé hyst. 1,2/1,0 ; 3 reco ≥P3 ; 2 normal mains off ; 1 jamais via surplus),
recrédit sur puissance allouée déclarée, clamp lock-aware minState/maxState.

AGENTS.md : ROADMAP config priorités utilisateur (acquis tri unifié ; manque 3g VE
+ couche config JSON-RPC/UI Flutter pour drag-and-drop à chaud). Build 0/0.

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:53:07 +02:00
Patrick Schurig
0e18e27911 [3e-1] types SG-Ready : declared.states + estimatedPowerW(état) + telemetry min/maxState
LoadDeclared : states[1-4] + estimatedPowerW QHash<int,double> (déclaré installateur,
approximatif ; états 1/2 ≈ 0 pour l'allocation surplus, invariant 8).
LoadContextTelemetry : minState/maxState (fenêtre verrou minStateHold, parallèle à
minStage/maxStage ECS). Build 0 erreur / 0 warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:42:33 +02:00
Patrick Schurig
4e02b008b9 [3c-7c] testMeterSilentFallback + seam watchdog injectable
Extraction recordMeterUpdate(now)/evaluateMeterFreshness(now) : logique L2 injectable
(temps en paramètre), déclencheurs réels (QTimer + powerBalanceChanged) sous
#ifndef ENERGY_SIMULATION (pattern amont). onMeterWatchdogTick délègue.

testMeterSilentFallback : compteur muet >90s → dégradé (ECS off force=true, bypass
minOn) → STABILITÉ (ECS reste 0 sur 4 cycles, planif suspendue) → REPRISE (recalcul
depuis le surplus, pas de restauration d'ancienne consigne). Suite simulation 18/18,
charging 46/46, plugin prod 0/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:21:22 +02:00
Patrick Schurig
df37454537 [3c-7b] testEcsSurplusPV : waterfall ECS + protection compresseur (seam de temps prouvé)
Test simulation autonome (arbitre frais via initTestCase) : 2 relais powerSwitch +
EcsRelayAdapter minOn=300. 4 régimes pilotés par le temps simulé :
cascade export 0→1→2 ; anti-clignotement (recrédit, hors verrou) ; import<minOn → RESTE
(protection compresseur) ; import>minOn → déleste. Seul le temps simulé change entre les
2 derniers → prouve le seam de temps unifié ET la protection.

Renommage ThingClass mockPowerSwitch→powerSwitch (collision symbole plugininfo vs
energytestbase dans le binaire simulation). Suite simulation : 17 passed, 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:52:14 +02:00
Patrick Schurig
1058e0f7a8 [3c-3-fix] waterfall ECS : surplus net signé + clamp lock-aware (protection compresseur)
Bug : exportW clampé à max(0,-p) AVANT recrédit → sur-crédit en import (ECS
restait allumé sur le réseau, ne délestait jamais). Fix : surplus net SIGNÉ
(exportW - importW). Régime export inchangé.

Le délestage strict est borné par minOn/minOff (protection compresseur, pas confort) :
l'adaptateur expose minStage/maxStage (fenêtre de verrou évaluée au temps de cycle),
le scheduler clampe bestStage et décrémente au palier réel → budget correct pour les
charges suivantes (puissance verrouillée = engagée non-coupable).

Seam de temps unifié : now=ctx.timestamp partagé par toLoadContext()/applyAction() ;
lockWindow() est l'unique calcul, lockActive() en dérive (décision==exécution).
Interface ILoadAdapter étendue (now) + contrat "temps=paramètre, jamais l'horloge"
documenté pour les futurs adaptateurs. EvAdapter aligné. Build 0 erreur / 0 warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:25:22 +02:00
Patrick Schurig
4d4071e0fc [3c-7a] mock powerswitch + helpers energytestbase
ThingClass mockPowerSwitch (interface power : power bool writable + currentPower
double, params port/nominalPower) pour piloter l'EcsRelayAdapter en test.
setupThing + executeAction (power → état + currentPower dérivé du nominal, override
HTTP possible). energytestbase : mockPowerSwitchThingClassId + addPowerSwitch()
+ setPowerSwitchStates(). Cascade N paliers = N instances (matériel réel).
Build mock (-Werror) + binaire de test : 0 erreur / 0 warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:11:01 +02:00
Patrick Schurig
a524599b6f [3c-6] degradedMode() + notification ChargingSchedulesChanged + invariant zéro-cloud
virtual degradedMode() dans SmartChargingManager (base false, [ETM] additif),
override EnergyArbitrator. Champ o:degradedMode (additif) dans la notification
NymeaEnergy.ChargingSchedulesChanged, émise aussi aux transitions du mode dégradé
(planif suspendue → push du flag via emit chargingSchedulesChanged()).
INTERFACE.md : champ degradedMode documenté.

SAFETY.md : notification réconciliée (ChargingSchedulesChanged, pas EnergyManagerChanged)
+ limite "valeur figée non détectée". Correction ZÉRO CLOUD : suppression de la section
"Alertes externes" / mécanisme n8n, remplacée par une signalisation 100% locale
(notification nymea in-app + buzzer/relais via règle nymea, aucun canal réseau sortant).
Invariant 10 "ZÉRO cloud" gravé dans AGENTS.md.

Build 0 erreur / 0 warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:04:09 +02:00
Patrick Schurig
f36395f07f [3c-5] watchdog L2 : QTimer fraîcheur compteur + mode dégradé conservateur
QTimer 30s indépendant des signaux ; m_lastMeterUpdate picoté sur powerBalanceChanged.
Silence >90s → mode dégradé (appliqué à la TRANSITION uniquement) :
  - ECS palier 0 force=true ;
  - EV : clamp courant minimum SEULEMENT si déjà en charge (pas d'activation forcée ;
    "jamais 0 A si branché" relève du failsafe L1, pas du repli logiciel).
update() suspend la planification + le dispatch tant que m_degradedMode (sécurité L4
en position 3 reste active) → pas de rallumage sur le cache d'un compteur mort, pas
d'oscillation. Reprise au retour du compteur.

SAFETY.md §L2 : nuance maintenu/démarré + suspension planification. AGENTS.md morceau 7 :
exiger ECS reste à 0 sur plusieurs cycles. SG-Ready/Batterie déférés 3e/3f ;
flag degradedMode exposé en 3c-6. Build 0 erreur / 0 warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:43:28 +02:00
Patrick Schurig
0ce7041ed3 [3c-4] dispatch ECS : applyActionsToAdapters(Slot) dans update()
Itère slot.actions, dispatche les kind==Stage vers m_ecsAdapters (position 7,
avant adjustEvChargers). EV (Setpoint) reste sur le proxy amont jusqu'à 3g.
Build 0 erreur / 0 warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:26:49 +02:00