diff --git a/specs/spec_ecs.md b/specs/spec_ecs.md new file mode 100644 index 0000000..5ceb79e --- /dev/null +++ b/specs/spec_ecs.md @@ -0,0 +1,482 @@ +# SPEC — ECS multi-palier + +Version : 0.4.3 +Dépôt : `etm-powersync-energy-plugin-etm` (experience-plugin ems) +Branche : `feature/beta-rulebased` +Contrats faisant autorité : `docs/OPTIMIZER_PROTOCOL.md`, `docs/SAFETY.md`, `AGENTS.md` + +Ce document est **normatif** et **subordonné à `AGENTS.md`**. En cas de conflit, +`AGENTS.md` gagne et cette spec est corrigée. + +Chaque exigence porte un identifiant stable. Un test porte l'identifiant de +l'exigence qu'il couvre. **DOIT** = obligatoire, **DEVRAIT** = recommandé avec +justification si écarté, **PEUT** = optionnel. + +Un agent qui découvre une contradiction entre la spec et le code s'arrête et la +remonte. Il ne tranche pas seul. + +> **0.4.0** — révision après audit du code réel. Les versions ≤ 0.3.0 nommaient +> `EcsRelayAdapter` (classe supprimée en `5100674`) et attribuaient le choix du +> palier au scheduler. Corrigé ci-dessous. Ne pas se fier à une copie antérieure. +> +> **0.4.1** — passe de vérification de la 0.4.0 contre le code. Quatre +> corrections : statut réel des binaires de test (§9), portée exacte du point de +> gouvernance `AGENTS.md` (§0), citation de règle dans ECS-306 (§3), et +> recouvrement ECS-411 ↔ ECS-412 porté en point ouvert (§13-2). +> +> **0.4.2** — étape 2 renommée « extraction du **noyau de calcul** » : le mot +> « domaine » entrait en collision avec `spec_loadmodel.md` LM-100, où il +> désigne la couche métier `Ecs`/`Hvac`/`Ev`/`SmartHome`. Seul changement. +> +> **0.4.3** — ECS-110 étendu : la validation ne doit pas reposer sur `Q_ASSERT`, +> absent du binaire release. Constat issu de la consolidation d'`AGENTS.md`. + +--- + +## §0 — Domicile et découpage (tranché) + +**ECS-001 — RÉSOLU, négatif, reconfirmé contre le `libnymea` du build.** +`/usr/include/nymea/integrations/integrationplugin.h` : la section `protected` +n'expose que `myThings()`, `hardwareManager()`, `pluginStorage()`, +`apiKeyStorage()`, `setMetaData()`. Pas de `thingManager()`. Un +`IntegrationPlugin` ne peut pas commander un Thing d'un autre plugin. + +**ECS-002** — Il n'y a **ni integration-plugin, ni dépôt, ni ThingClass**. L'ECS +ne possède aucun matériel. + +**ECS-003 — Découpage réel (rév. 3).** Les classes en vigueur : + +| Composant | Fichier | Rôle | +|---|---|---| +| `RuleBasedScheduler` | `etm/scheduler/rulebasedscheduler.cpp` | Alloue le budget de surplus, cascade par `priority` ascendante, arrondit sur les paliers dérivés | +| `RelayRouter` | `etm/adapters/relayrouter.{h,cpp}` | Convertit des watts en palier, tient les verrous `minOn`/`minOff`, écrit les Things `powerswitch` | +| `LoadConfig`, `LoadConfigRelay` | `etm/types/loadconfig.{h,cpp}` | Description d'une charge | +| `LoadConfigStore` | `etm/config/loadconfigstore.{h,cpp}` | Persistance + RPC `Get/SetLoadConfig` | +| `EtmVariableLoadAdapter` | `etm/adapters/etmvariableloadadapter.{h,cpp}` | Charge continue (triac) | + +**ECS-004 / ECS-005 — Frontière tenue.** Aucun identifiant de relais ne franchit +la frontière vers le scheduler : seuls des watts dérivés circulent. Cette +propriété est **acquise et non négociable** ; toute évolution la préserve. + +> **Point de gouvernance à trancher hors spec — c'est le SCHÉMA d'`AGENTS.md` +> qui est périmé, pas la règle 2.** +> +> Portée exacte, vérifiée : la règle absolue 2 dit « Les LoadAdapters exécutent, +> ils ne décident pas […] **Aucune logique de répartition dedans.** » Le +> `RelayRouter` ne fait **aucune répartition** — il traduit une enveloppe en +> watts vers une combinaison, il ne s'attribue rien (ECS-007 tient). **Telle +> qu'elle est écrite, la règle 2 n'est pas violée.** Aucun agent ne doit conclure +> d'ici qu'il faut réécrire l'architecture. +> +> Ce qui contredit réellement le code, c'est le schéma « ARCHITECTURE CIBLE (non +> négociable) », `AGENTS.md:228-250`, qui affiche encore : +> +> ``` +> EvAdapter EcsRelayAdapter SgReadyAdapter BatteryAdapter +> (setpoint, (stage 0/1/2) (state 1-4) +> ``` +> +> Deux éléments morts d'un coup : la classe `EcsRelayAdapter` **et** le kind +> `Stage`, tous deux supprimés en `5100674`. +> +> **Décision attendue : redessiner ce schéma dans `AGENTS.md`** (`RelayRouter`, +> kind `Setpoint`, frontière watts↔relais), et non seulement « entériner une +> frontière ». Tant que ce n'est pas fait, le document normatif décrit des +> classes qui n'existent plus. + +**ECS-007** — Aucune couche ECS NE DOIT décider quelle part du surplus lui +revient. Acquis (`rulebasedscheduler.cpp:97-121`). + +**ECS-008** — L'ECS publie `available` et `energyRemaining` ; Héos les consomme. +L'ems exécute, Héos optimise. + +--- + +## §1 — État des exigences (après audit) + +Le code **n'est pas en production client** : ni sur `main` (`f4d5b20`), ni sur un +tag. Il vit sur `feature/beta-rulebased` et `landing-silo`, rattaché au banc +hems. Une modification de comportement ne casse donc aucune installation. + +**Décision : ÉTENDRE, pas refondre.** La frontière watts↔relais est posée et +tenue, l'énumération des sous-ensembles est correcte et testée, la config est +externalisée. Le mélange avec nymea est superficiel : trois fonctions pures +(`m_levels`, `stageForPower`, `lockWindow`) noyées dans une classe qui fait +aussi l'I/O — une extraction d'environ 40 lignes, pas une reconstruction. + +| Exigence | Objet | Statut | +|---|---|---| +| ECS-001 | Pas d'accès cross-thing | ✅ reconfirmé sur le libnymea du build | +| ECS-100 | Désignation par `ThingId` | ✅ `loadconfig.h:46` | +| ECS-101 | Cas à un seul étage | ✅ `relayrouter.cpp:49-64`, testé | +| ECS-102 | Configuration externalisée | ✅ `LoadConfigStore` + RPC — était marqué ouvert à tort | +| ECS-300 | Palier ≤ budget | ⚠️ vrai hors verrou, faux sous verrou → **ECS-306** | +| ECS-301 | Puissances quelconques, non cascadé | ✅ énumération des 2^N sous-ensembles, testé 1500→2000 | +| ECS-302 | Encodages équivalents | ❌ le second encodage est **jeté**, pas arbitré → structurel | +| ECS-304 | Testable hors nymea | ⚠️ 3 fonctions pures extractibles, le reste couplé | +| ECS-400 | Temporisations non codées en dur | ✅ `minOnS`/`minOffS` de la config | +| ECS-402 | Coupure avant fermeture | ✅ `relayrouter.cpp:240-244`, testé | +| ECS-403 | Clamp lock-aware côté scheduler | ❌ `minStage`/`maxStage` **retirés** du contexte en rév. 2/3 | +| ECS-404 | Anti-oscillation | ✅ recrédit `currentPowerW`, `rulebasedscheduler.cpp:186` | +| ECS-110 | Validation de configuration | ⚠️ partielle | +| ECS-111 | Suppression d'un Thing référencé | ❌ non défini | +| ECS-305 | Compteur de commutations | ❌ absent | +| ECS-410 | Échec d'écriture | ❌ absent, `available` codé en dur | +| ECS-411 | Reprise du palier | ❌ absent | +| ECS-412 | Survie au `rebuild` | ❌ absent (nouveau) | +| §5 | Mesure par charge | ⚠️ demi-pas | +| §6 | Thermique | ❌ absent, et sans canal d'entrée | + +**ECS-203 — CLOS.** Aucune ThingClass exposée, donc aucune interface nymea à +implémenter. L'unité interne reste le **watt**. + +--- + +## §2 — Ordre de traitement + +L'ordre est normatif : chaque étape conditionne la suivante. + +**Étape 1 — corrections de comportement (ECS-306, ECS-412).** Ce sont des +défauts qui produisent un mauvais comportement sur du code qui tourne au banc, +pas des fonctionnalités manquantes. Ils passent devant tout le reste. + +> Périmètre d'ECS-412 en étape 1 : **suspendu à l'arbitrage §13-2**, qui peut le +> réduire à `m_lastSwitch` et remonter ECS-411 ici. Ne pas ouvrir l'étape 1 sans +> l'avoir tranché. + +**Étape 2 — extraction du noyau de calcul (type pur, sans `ThingManager`).** +Sortir `m_levels`, `stageForPower` et `lockWindow`. Débloque ECS-304 en vrai +unitaire et conditionne l'étape 3. + +> Ne pas lire « domaine » ici. `specs/spec_loadmodel.md` LM-100 emploie ce mot +> dans son sens courant — la couche métier `Ecs`/`Hvac`/`Ev`/`SmartHome`, qui ne +> parle à aucun matériel. L'étape 2 ne sort **aucune** classe de ce genre : elle +> extrait le noyau de calcul **interne au mécanisme relais**. Rien à voir. + +**Étape 3 — ECS-302 / ECS-305.** Changement de structure de `m_relayMapping`. +Doit précéder tout ce qui s'appuiera dessus. + +**Étape 4 — ECS-411 puis ECS-410**, même chemin `applyRelayStage` / `available`, +à faire ensemble. ECS-411 peut remonter en étape 1 selon l'arbitrage §13-2. + +**Étape 5 — ECS-110 (complément) et ECS-111.** + +**Étape 6 — remontée d'`available` dans `LoadContext`**, avec la mise à jour de +`OPTIMIZER_PROTOCOL.md` qu'exige §9 dans le même lot. Préalable structurel au +thermique. + +**Étape 7 — §6 thermique.** + +--- + +## §3 — Corrections de comportement + +**ECS-306 — Cohérence du budget avec le palier réellement appliqué.** Le budget +décrémenté par le scheduler DOIT correspondre au palier effectivement appliqué, +y compris lorsqu'un verrou empêche la descente. + +Constat : `lockWindow` (`relayrouter.cpp:193-203`) remonte `minStage` à +`m_currentStage` tant que `minOn` n'est pas écoulé, et `qBound` (l. 155) force +alors un palier **au-dessus** du budget ; or le scheduler a déjà retranché le +palier plus bas qu'il avait choisi (`rulebasedscheduler.cpp:223`). Pendant toute +la fenêtre `minOn`, les charges de priorité suivante reçoivent un résidu +**surestimé** — donc l'installation soutire au réseau. Ce n'est pas une décision +d'allocation (ECS-007 tient), c'est un effet de bord sur l'allocation. + +Règle visée : **`AGENTS.md` règle absolue 4** — « bornes par adaptateur +**écrêtent TOUTE sortie** de stratégie ». L'écrêtage existe bien (`qBound`, +`relayrouter.cpp:155`), mais son résultat n'est jamais renvoyé à l'arbitre : +c'est exactement le trou. *Ne pas invoquer ici la règle 1* — son mécanisme est +« deux décideurs sur le même surplus », et il n'y en a qu'un. Un agent qui suit +cette piste ira chercher un second décideur qui n'existe pas ; le défaut est un +décalage de comptabilité entre décision et exécution. + +Le canal qui rendait le scheduler lock-aware **a existé** : `minStage`/`maxStage` +dans `LoadContextTelemetry`, retirés en rév. 2/3 (`surpluscontext.h:62-64`). La +correction DEVRAIT restaurer un signal de verrou vers le scheduler plutôt que se +contenter de remonter le palier appliqué après coup : la première corrige dans le +cycle, la seconde seulement au cycle suivant. + +**ECS-412 — Survie au `rebuild`.** `rebuildLoadAdapters()` +(`energyarbitrator.cpp:137-144`) détruit et reconstruit les adaptateurs à chaque +`SetLoadConfig`, remettant `m_currentStage` **et** `m_lastSwitch` à zéro. Le +palier courant et les horodatages de verrou DOIVENT survivre à un `rebuild` +lorsque la charge concernée n'a pas changé de câblage. + +Il ne s'agit pas de confort : les verrous sont de la **protection matérielle**. +Avec un `minOn` de 300 à 600 s sur un ballon thermodynamique, un client qui +réordonne ses priorités depuis l'app réarme les verrous et peut faire +court-cycler son compresseur. Un test DOIT couvrir « `SetLoadConfig` pendant une +fenêtre de verrou active ». + +> **Recouvrement avec ECS-411 — ordonnancement à arbitrer (§13-2).** Les deux +> champs à préserver n'ont pas le même statut. `m_currentStage` est +> **redéductible** : si ECS-411 est en place, un `RelayRouter` reconstruit +> retrouve son palier depuis les Things sans code dédié. `m_lastSwitch` ne l'est +> **pas** — l'horodatage d'un verrou n'existe nulle part dans le matériel, et +> c'est justement la part qui protège le compresseur. ECS-412 a donc un +> irréductible (`m_lastSwitch`) et une part que l'étape 4 rendrait gratuite +> (`m_currentStage`). Voir §13-2 pour l'arbitrage. + +--- + +## §4 — Combinatoire (`RelayRouter`) + +**ECS-302 — Encodages équivalents.** Un palier DOIT pouvoir porter **plusieurs** +combinaisons de relais, et la combinaison retenue DOIT être celle dont le coût de +transition depuis l'état courant est le plus faible : d'abord le nombre de relais +à basculer, puis le relais dont le compteur de commutations est le moins entamé. + +Requalification issue de l'audit : le second encodage n'est pas mal arbitré, il +est **jeté au constructeur**. `relayrouter.cpp:58` fait +`if (!byPower.contains(sum)) byPower.insert(sum, set)` — la première combinaison +rencontrée gagne, c'est-à-dire le masque le plus bas. Sur un câblage +500/1000/1500, le palier 1500 W retient `{R500, R1000}` et `{R1500}` disparaît. +`m_relayMapping` étant un `QList>`, la structure **ne peut pas** +porter deux combinaisons. ECS-302 est donc un changement de structure, pas +l'ajout d'un critère. + +**ECS-305 — Compteurs de commutations.** Chaque relais DOIT porter un compteur +de commutations, exposé en télémétrie. Le relais de plus faible puissance est le +bit de poids faible de la combinatoire et s'usera le premier ; c'est la grandeur +qui décide de la durée de vie des contacteurs. + +**ECS-307 — Grain des temporisations.** `minOn`/`minOff` sont aujourd'hui **par +charge** (un unique `m_lastSwitch`, `relayrouter.h:107`), ce qui satisfait +ECS-400. Le grain par relais qu'appelle ECS-305 n'existe pas. Décider en étape 3 +s'il est nécessaire, ou si le compteur de commutations suffit à l'observation. + +**ECS-308** — Le plafond `MaxRelays = 16` (`relayrouter.cpp:17`) tronque +silencieusement au-delà, avec un warning. Acceptable ; DOIT rester documenté. + +--- + +## §5 — Robustesse d'exécution + +**ECS-410 — Échec d'écriture.** `writeRelay` (`relayrouter.cpp:222-236`) jette le +`ThingActionInfo*` retourné par `executeAction` : aucune attente de `finished`, +aucun retour arrière, aucun arrêt total. `available` est codé en dur à `true` +(`relayrouter.cpp:91`). Un relais introuvable produit un warning, et +`m_currentStage` est mis à jour comme si tout avait réussi — **on annonce une +puissance non appliquée**, ce que ECS-410 interdit explicitement. + +Comportement exigé : (1) attendre le résultat de `executeAction` ; (2) sur échec, +tenter le retour à l'état précédent ; (3) à défaut, commander l'arrêt total ; +(4) à défaut, `available = false` et cesser toute commande. Le canal existe déjà, +il est simplement ignoré. + +**ECS-411 — Reprise du palier.** `m_currentStage` vaut 0 à la construction +(`relayrouter.h:106`) et aucun état de relais n'est relu. Le palier courant DOIT +être déduit de l'état réel des Things. + +--- + +## §6 — Configuration + +**ECS-110 — Validation.** `LoadConfig::isValid()` (`loadconfig.cpp:80-127`) +refuse déjà `powerW ≤ 0`, `relays[]` vide, `thingId` vide, `minOnS`/`minOffS` +négatifs. DOIT en outre refuser : **deux étages sur le même Thing**, et un Thing +**absent ou n'exposant pas l'interface attendue**. + +Note de conception : `isValid()` ne connaît pas le `ThingManager`, donc la +seconde vérification ne peut pas y vivre. Elle appartient à la construction de +l'adaptateur, pas au type de configuration. + +**Validation effective en build release.** Aucune validation de configuration NE +DOIT reposer sur `Q_ASSERT`. Le paquet est construit par `dh --buildsystem=qmake6` +sans `CONFIG += debug` : qmake compile en release, `QT_NO_DEBUG` est défini, et +`Q_ASSERT` disparaît du binaire livré. Une vérification qui n'existe que chez le +développeur n'est pas une vérification — elle donne au lecteur du code +l'impression d'un filet qui n'est pas là chez le client. + +Quatre `Q_ASSERT` d'invariant de configuration existent aujourd'hui dans `etm/` : + +| Emplacement | Invariant gardé | Peut-il échouer aujourd'hui ? | +|---|---|---| +| `relayrouter.cpp:66` | `m_levels` non vide, `[0] == 0` | **Non** — le masque vide garantit la clé 0 par construction | +| `etmvariableloadadapter.cpp:34` | `powerLevels[0] == 0` | **Non** — déjà refusé par `isValid()` (`loadconfig.cpp:114-115`) | +| `sgreadyadapter.cpp:35` | `states` non vide | **Non** — construction codée en dur | +| `sgreadyadapter.cpp:36` | état 2 présent = **repli sûr obligatoire** | **Non** aujourd'hui — construction codée en dur (`energypluginnymea.cpp:66-76`) | + +Aucun ne masque donc de défaut à ce jour : ECS-110 est bien tenu par +`LoadConfig::isValid()`, pas par ces assertions. **Le risque est prospectif et il +est daté** : le jour où `SgReadyAdapter` bascule sur la configuration — travail +déjà inscrit au `DÉFÉRÉ` d'`AGENTS.md` — `sgreadyadapter.cpp:36` devient le seul +garde-fou sur la présence de l'état 2, l'état de repli sûr du mode dégradé L2, +et ce garde-fou sera absent du binaire livré. + +Exigence : toute vérification portant sur des données venant de la configuration +DOIT vivre dans `isValid()` ou dans la construction de l'adaptateur, avec un +chemin d'erreur explicite (refus + message). Les `Q_ASSERT` restants ne sont +admis que sur des invariants **structurels**, impossibles à violer depuis la +configuration — les deux premières lignes du tableau. + +**ECS-111 — Suppression d'un Thing référencé.** Comportement non défini +aujourd'hui : `findConfiguredThing` renvoie `nullptr`, `writeRelay` logue et +continue, `telemetry()` saute le relais donc `metered = false` et l'on retombe +sur le nominal commandé — **on publie la puissance d'un relais disparu**. À +définir et tester. + +--- + +## §7 — Mesure par charge + +`telemetry()` (`relayrouter.cpp:104-114`) lit déjà `currentPower` sur les Things +relais : c'est un demi-pas vers ECS-500. + +**ECS-500** — Calibrage par mesure (enclencher chaque étage seul, mesurer, +écrire la puissance réelle). Justification : P = U²/R, un étage annoncé à 1000 W +sous 230 V délivre environ 1090 W sous 240 V. + +**ECS-501** — Détecter un étage commandé dont la puissance mesurée est quasi +nulle, en distinguant le thermostat mécanique ouvert (tous les étages chutent +ensemble) d'un étage en défaut (un seul chute). + +**ECS-502** — Un étage en défaut DOIT être retiré des paliers disponibles. + +**ECS-503** — Sans mesure, le comportement actuel est conservé tel quel. + +**ECS-504** — La mesure par charge NE DOIT PAS être réinjectée dans le calcul du +surplus (`AGENTS.md` règle 8, pas de boucle de feedback). Elle sert au diagnostic +et au recrédit anti-clignotement déjà en place. + +--- + +## §8 — Thermique + +Aucune température ni `energyRemaining` dans le dépôt. **Et il manque le canal +d'entrée** : `available` vit dans `LoadTelemetry` (`iloadadapter.h:15`) mais +l'arbitre ne le recopie pas dans `LoadContext`. Même avec une sonde posant +`available = false`, le scheduler ne le verrait pas. C'est un préalable +structurel, indépendant de tout le thermique (étape 6). + +**ECS-600** — La sonde est optionnelle ; sans elle, le comportement actuel est +conservé intégralement. + +**ECS-601** — Avec sonde, `available` passe à faux quand la consigne est +atteinte, afin que le waterfall libère le budget. C'est le **seul** point +d'entrée du thermique dans l'arbitrage : la charge se retire, elle ne négocie pas. + +**ECS-602** — `energyRemaining` publié en kWh (`m · c · ΔT`). + +**ECS-603** — Cycle anti-légionelle périodique, indépendant du surplus. Il +consomme du réseau : il DOIT passer par une `LoadAction` explicite avec son +`decisionReason`, jamais par un contournement du budget. + +**ECS-604** — La sonde n'est jamais la seule limite haute. Le thermostat +mécanique reste **L0**. Perte de sonde → mode sans sonde, pas arrêt d'urgence. + +**ECS-605** — Hystérésis paramétrable par charge, jamais codée en dur. + +--- + +## §9 — Traçabilité (exigences ouvertes) + +| Exigence | Type | Test | +|---|---|---| +| ECS-306 | simulation | `testEcsBudgetUnderLock` | +| ECS-412 | simulation | `testEcsRebuildPreservesLock` | +| ECS-302, ECS-305 | unitaire | `testEcsSwitchCost` | +| ECS-304 | unitaire | `testEcsLevelsPure` | +| ECS-410 | simulation | `testEcsPartialFailure` | +| ECS-411 | simulation | `testEcsRestartRecovery` | +| ECS-110, ECS-111 | unitaire | `testEcsConfigValidation` — DOIT s'exécuter aussi en build release (`QT_NO_DEBUG`), sinon il ne prouve rien du binaire livré | +| ECS-501, ECS-502 | simulation | `testEcsStageFault` | +| ECS-601, ECS-602 | simulation | `testEcsTemperatureTarget` | +| ECS-603 | simulation | `testEcsLegionella` | +| ECS-600, ECS-604 | simulation | `testEcsNoSensorFallback` | + +Acquis à ne pas refaire : `testEcsRelayTopologies`, `testLoadConfigRelayRouter`, +`testLoadConfigBuildsAdapters`, `testLoadConfigRpc`, `testEcsSurplusPV`, +`testMeterSilentFallback`. + +> Des binaires de test datant du 8 juin — antérieurs au `RelayRouter` — traînent +> dans l'arbre de travail. **Rebuild systématique**, jamais de conclusion tirée +> d'un binaire trouvé sur place. +> +> Ils ne sont **pas versionnés** : `git ls-files tests/auto/simulation/nymea-energy-simulation` +> renvoie vide. Ce sont des reliquats de build que `.gitignore` ne couvre pas, +> d'où leur apparition permanente en `??`. Même cas : `qrc_*.cpp`, `moc_*`, +> `target_wrapper.sh`, et les `plugininfo.h` / `extern-plugininfo.h` générés du +> mock. Le remède est donc de **les ignorer ou les supprimer**, pas de « ne pas +> s'y fier ». Hygiène, hors périmètre ECS. + +**Définition de fait** : celle d'`AGENTS.md`. + +**Critère d'acceptation banc** : profil de surplus réel sur 24 h avec relevé du +nombre de commutations **par relais** (ECS-305 le rend enfin mesurable). + +--- + +## §10 — Hors périmètre + +Allocation du surplus, prévision et plan journalier (Héos), contacteur heures +creuses, transport matériel (Waveshare D8, session dédiée), PAC hors SG-Ready. + +Signalé au passage, hors ECS : les `ThingId` K1/K2 et les paliers +`{3:1500, 4:3000}` de la PAC du banc sont codés en dur dans +`energypluginnymea.cpp:68-74`. À traiter avec la couche config, pas ici. + +--- + +## §11 — Dette contractuelle à ne pas aggraver + +`OPTIMIZER_PROTOCOL.md` ne reflète déjà pas `LoadAction.force`, les fenêtres de +verrou ni `degradedMode`. ECS-306 et l'étape 6 modifient le contexte. **Toute +exigence touchant au contexte ou aux actions DOIT embarquer sa mise à jour du +protocole dans le même lot.** + +--- + +## §12 — Journal des décisions + +| Date | Décision | +|---|---| +| 2026-08-06 | ECS multi-palier attaqué en premier ; ECS-simple = son cas à 1 palier | +| 2026-08-07 | Étages déclarés par référence de Thing + puissance par sortie | +| 2026-08-07 | ECS relais et ECS via PAC séparés ; les PAC reportées | +| 2026-08-08 | ECS-001 négatif → pas d'integration-plugin | +| 2026-08-08 | ECS-203 clos : unité interne = watt | +| 2026-08-08 | Audit : `RelayRouter` (et non `EcsRelayAdapter`), code hors production → **étendre** | +| 2026-08-08 | ECS-306 et ECS-412 créés et placés en étape 1 | +| 2026-08-08 | `ioConnections()` écarté formellement | +| 2026-08-08 | Vérification 0.4.1 : règle 2 non violée — c'est le schéma d'`AGENTS.md` qui est périmé | +| 2026-08-08 | ECS-306 rattaché à la règle 4 (écrêtage non remonté), pas à la règle 1 | +| 2026-08-08 | Recouvrement ECS-411 ↔ ECS-412 relevé → §13-2, bloque l'ouverture de l'étape 1 | +| 2026-08-08 | Étape 2 : « type domaine » → « noyau de calcul » (collision avec `spec_loadmodel.md` LM-100) | +| 2026-08-08 | ECS-110 : validation interdite de reposer sur `Q_ASSERT` (absent en release) | + +--- + +## §13 — Points ouverts + +1. **Gouvernance** — redessiner le schéma « ARCHITECTURE CIBLE » d'`AGENTS.md` + (`EcsRelayAdapter` et le kind `Stage` y figurent encore ; cf. §0). Le document + normatif décrit des classes supprimées tant que ce n'est pas fait. +2. **Ordonnancement ECS-411 ↔ ECS-412** — bloque l'ouverture de l'étape 1. Deux + lectures : + - **(a) garder l'ordre du §2** : ECS-412 traite `m_currentStage` et + `m_lastSwitch` dès l'étape 1 ; ECS-411 en réécrira la moitié en étape 4. + Fidèle au principe « les défauts de comportement passent devant », au prix + d'un aller-retour. + - **(b) réduire ECS-412 à `m_lastSwitch`** et remonter ECS-411 en étape 1 avec + lui : ils partagent le chemin de reconstruction, et `m_currentStage` devient + gratuit. Évite l'aller-retour, mais fait entrer en étape 1 une exigence que + le §2 justifiait autrement. + + Dans les deux cas, `testEcsRebuildPreservesLock` DOIT couvrir « `SetLoadConfig` + pendant une fenêtre de verrou active » : c'est l'irréductible. +3. **ECS-307** — grain des temporisations par relais : nécessaire, ou le compteur + de commutations suffit-il ? +4. **ECS-605** — asymétrie de l'hystérésis, à régler à l'usage. +5. Câblage réel du banc : 500/1000/1500 ou 500/1000/2000. Ne bloque pas le code, + mais le premier est le seul qui exerce ECS-302. +6. **Conflit d'écriture** — `connectIO()` est le mécanisme natif si l'on veut un + jour exposer un relais ECS au pilotage manuel de l'utilisateur. Ce serait + alors un conflit d'écriture à arbitrer, pas une aide. + +**CLOS par l'audit** : le waterfall ne choisit pas entre encodages équivalents +(il n'en voit qu'un) ; `ioConnections()`/`connectIO()` est un lien 1↔1 +état-à-état avec pour seule transformation un booléen `inverted` — il ne peut +exprimer ni le N→1, ni l'arithmétique, ni l'ordre de commutation, ni les +temporisations. diff --git a/specs/spec_loadmodel.md b/specs/spec_loadmodel.md new file mode 100644 index 0000000..dd7d924 --- /dev/null +++ b/specs/spec_loadmodel.md @@ -0,0 +1,251 @@ +# SPEC — Modèle de charges (domaine × mécanisme) + +Version : 0.1.1 +Dépôt : `etm-powersync-energy-plugin-etm` +Statut : **intention de conception figée. Aucune implémentation immédiate.** + +> **0.1.1** — passe de vérification contre le code. Quatre précisions : +> préséance des verrous (LM-303), portée de l'union discriminée (LM-302), +> citation de règle (LM-102), `EvAdapter` inscrit au renommage (LM-601). +> Aucune décision de conception modifiée. + +> **Ce document ne déclenche aucun travail.** L'ordre de `specs/spec_ecs.md` §2 +> reste seul en vigueur, et l'étape 1 reste bloquée par ses arbitrages §13-1 et +> §13-2. Une seule chose ici est urgente : la **forme du schéma persisté** (§3), +> parce que c'est la seule partie coûteuse à rattraper une fois que le banc et la +> beta auront écrit des données. Le reste s'implémente au fil des besoins. + +--- + +## §1 — Deux couches + +**LM-100** — Le modèle de charges se découpe en deux couches indépendantes : + +- **Couche domaine** — `Ecs`, `Hvac`, `Ev`, `SmartHome`. Détient le modèle de la + charge (état thermique, échéance de départ, cycle), en déduit la demande et + `available`. **Ne parle à aucun matériel.** +- **Couche mécanisme** — `Relay`, `Variable`, `SgReady`, `ModbusSetpoint`. + Traduit une enveloppe en watts vers le matériel et tient les verrous. + **Écrite une seule fois, partagée par tous les domaines.** + +**LM-101** — Une charge configurée choisit **un domaine et un mécanisme**. Le +domaine ne détermine pas le mécanisme et réciproquement. + +**LM-102** — Aucune des deux couches ne répartit de budget. La couche domaine se +retire de l'allocation via `available` ; elle ne la négocie pas. Règles absolues +1 et 2 d'`AGENTS.md` inchangées. + +**Justification.** Un nommage par domaine seul (`EcsAdapter`, `HvacAdapter`) +obligerait à écrire l'encodage SG-Ready deux fois (ECS et HVAC), la combinatoire +de relais deux fois (ECS et EV en prise commandée), et le transport Modbus deux +fois (HVAC et EV). Un nommage par mécanisme seul perdrait les comportements +propres au domaine, qui sont réels : stockage différable pour l'ECS, confort +immédiat pour le HVAC, échéance de départ pour l'EV, cycle non modulable pour le +SmartHome. + +--- + +## §2 — Matrice + +| Domaine | Mécanismes attendus | +|---|---| +| `Ecs` | `Relay` (résistance à paliers) · `SgReady` (ballon thermodynamique) · `Variable` (triac) | +| `Hvac` | `SgReady` (PAC) · `ModbusSetpoint` (PAC, clim, VMC) | +| `Ev` | `Relay` (prise commandée : GreenUp, Witty) · `ModbusSetpoint` (borne) | +| `SmartHome` | `Relay` — voir §7, sémantique différente | + +Les cases vides ne sont pas interdites, elles ne sont simplement pas prévues. + +--- + +## §3 — Schéma de configuration (à figer) + +**LM-300** — `LoadConfig` est une **union discriminée** par le mécanisme : + +``` +LoadConfig { + id, nom, enabled + domaine : Ecs | Hvac | Ev | SmartHome + typeAppareil : affine le domaine (résistif | thermodynamique | …) + groupe, prioritéGroupe, prioritéMembre + mécanisme : Relay | Variable | SgReady | ModbusSetpoint ← discriminant + chargeUtile : LoadConfigRelay | LoadConfigVariable | … + verrous : minOnS, minOffS + facettes[] : voir §4 +} +``` + +**LM-301** — Ajouter un mécanisme DOIT se limiter à un type de charge utile et +une branche de fabrique. Rien d'autre ne bouge. + +**LM-302** — Chaque charge utile valide la sienne. Les états invalides doivent +être inexprimables : pas de registre Modbus dans une configuration relais. + +**Portée exacte.** L'union discriminée s'applique aux **types C++** et au +**schéma persisté**. Elle ne s'applique **pas** au schéma JSON-RPC : nymea valide +les paramètres *avant* le handler, si bien que deux formes exclusives obligent à +marquer tous les champs spécifiques en optionnel (`"o:"`), faute de quoi l'une +des deux serait rejetée en amont. La contrainte est déjà documentée dans +`nymeaenergyjsonhandler.cpp:155-160` pour deux mécanismes ; elle s'aggrave à +quatre. À la frontière RPC, la validation reste donc **à l'exécution** +(`LoadConfig::isValid()`). Ne pas tenter un schéma RPC strict : c'est un mur +connu. + +**LM-303 — Verrous.** `typeAppareil` fournit des **défauts** (résistif ≈ 60 s, +thermodynamique ≈ 300 s), l'installateur peut les modifier, et c'est la valeur +**stockée** qui fait foi. Jamais de temporisation codée en dur. + +**Préséance.** Les verrous déclarés au niveau de la **charge** s'appliquent par +défaut. Une charge utile PEUT en porter de plus fins — par relais, par registre — +et ceux-là **priment** sur le défaut de charge, pour le seul actionneur qu'ils +désignent. + +Cette règle laisse `spec_ecs.md` §13-3 (ECS-307, grain des temporisations par +relais) entièrement ouvert : ajouter plus tard un champ optionnel dans une charge +utile est rétro-compatible, quelle que soit la réponse. Le schéma persisté n'a +donc pas à être « rendu tolérant » — c'est la préséance qui devait être écrite, +et elle l'est ici. + +--- + +## §4 — Facettes : une machine, plusieurs fonctions + +**LM-400** — Une machine physique est **une seule charge** : un rang, un jeu de +verrous, une ligne de budget. Une PAC assurant chauffage et ECS n'est **jamais** +déclarée deux fois. + +**Justification.** Deux déclarations produiraient une double allocation du +surplus pour une seule consommation, deux rangs pour un seul comportement, et +deux jeux de verrous sur un seul compresseur — la protection anti court-cycling +tomberait précisément là où elle coûte le plus cher. + +**LM-401** — Une charge PEUT porter **plusieurs facettes** au-dessus d'un +actionnement unique. Une PAC mixte porte une facette ECS (consigne, température +ballon) et une facette chauffage, avec un seul mécanisme. + +**LM-402** — Les facettes servent à la lecture, au suivi et au calcul +d'`available`. Elles **ne portent pas de rang**. Une charge à deux facettes ne +peut pas être classée « après les chauffe-eau mais avant la clim » : si cette +granularité est nécessaire, il faut deux machines, pas deux entrées. + +**LM-403 — Le mode est une sortie, pas une entrée.** SG-Ready applique un niveau +d'encouragement à la machine entière ; le choix de la fonction appartient au +régulateur interne. Le HEMS **observe** le mode courant, il ne le sélectionne +pas. Aucune conception ne DOIT supposer un aiguillage. + +Ce que le mode apporte : `available` devient honnête. Machine en mode chauffage +→ la facette ECS n'absorbera rien quoi qu'on encourage → le waterfall passe au +suivant au lieu de le deviner. + +**LM-404 — Biais par consigne : option avancée, hors périmètre pour l'instant.** +Monter `hotWaterSetpointTemperature` place le ballon sous consigne et la machine +bascule d'elle-même. C'est indirect, lent, et surtout **persistant** : une +consigne écrite le reste après un plantage ou un redémarrage, et elle écrase un +réglage d'installateur. Toute implémentation future DOIT comporter des bornes +dures, une politique de restauration et un chien de garde. À ne rouvrir que si le +terrain le justifie. + +**LM-405 — Décomposition côté ems, pas côté plugin.** Le découpage en facettes +DOIT se faire dans l'ems au-dessus des états plats du thing, et non en forkant +les plugins constructeurs. Chaque constructeur expose une structure différente ; +l'abstraction doit vivre là où elle est indépendante du constructeur, sous peine +de la refaire N fois et de gérer deux formes du même concept. + +**LM-406** — Si l'installation n'a pas de chauffe-eau distinct, il n'y a pas de +charge ECS : la facette ECS s'accroche à la charge PAC. + +--- + +## §5 — Groupes et double rang + +**LM-500** — Un groupe est une **clé de tri**, jamais un détenteur de budget. Le +waterfall trie sur le couple `(prioritéGroupe, prioritéMembre)` et reste +inchangé. Un groupe qui reçoit une allocation puis la redistribue est un second +décideur (règle absolue 1). + +**LM-501** — Classer un ECS, une PAC et une clim les uns par rapport aux autres +**fonctionne déjà** avec la liste plate triée par `priority`. Les groupes ne sont +nécessaires qu'à l'échelle : plusieurs chauffe-eau, plusieurs bornes, plusieurs +zones, quand on veut déplacer une famille entière sans renuméroter. + +**LM-502 — Plafond de groupe : optionnel.** Justifié quand les membres partagent +une limite électrique réelle (bornes sur un même câble ou un même abonnement). +Sans objet pour un groupe thermique, dont les membres sont sur des circuits +distincts. C'est le seul point où la boucle du waterfall change vraiment : elle +doit suivre l'engagement cumulé du groupe. + +**LM-503** — Deux notions à **ne pas** confondre avec un rang : + +- **Exclusion mutuelle** (clim et PAC en opposition dans une même zone) : une + contrainte entre appareils. +- **Équité interne** (trois chauffe-eau à rang égal, dont le troisième resterait + froid tout l'été) : une politique de rotation dans le tri. + +**LM-504** — « HVAC » couvre chauffage, ventilation et climatisation ; y ranger +l'eau chaude sanitaire est un abus. Libellé recommandé côté installateur : +« Thermique » ou « Chauffage & eau chaude ». + +--- + +## §6 — Nommage + +**LM-600** — Les classes d'actionnement portent le nom du **mécanisme**, jamais +du domaine. + +**LM-601** — Un renommage de cohérence est souhaitable. Trois classes sont +concernées, avec des échéances distinctes : + +| Classe | Défaut | Débloqué après | +|---|---|---| +| `RelayRouter` | Ne suit pas la convention `*Adapter` de ses voisins | étape 3 de `spec_ecs.md` | +| `EtmVariableLoadAdapter` | Préfixe `Etm` redondant dans un dépôt ETM | étape 3 de `spec_ecs.md` | +| `EvAdapter` | **Nommé d'après un domaine** (§2 range `Ev` en domaine) | **3g** | + +`EvAdapter` n'est pas une classe de domaine égarée dans la couche mécanisme : +c'est un **adaptateur de mécanisme mal nommé**. Il parle à l'interface +`evcharger` de nymea — un mécanisme au même titre que `SgReady`. Le correctif est +un renom, pas un redécoupage. + +Aucun de ces renommages ne se fait avant son échéance. Pour `RelayRouter`, +renommer juste avant la restructuration de `m_relayMapping` double le bruit dans +l'historique ; pour `EvAdapter`, le câblage lui-même change en 3g (l'adaptateur +n'est aujourd'hui pas dispatché), et renommer une classe dont le branchement va +bouger produit le même bruit pour rien. + +`BatteryAdapter` n'entre pas dans ce tableau : LM-700 le traite hors moule. + +--- + +## §7 — Cas hors moule + +**LM-700 — Batterie.** Une batterie n'est pas une charge avec un rang : elle est +aussi une **source**, capable de financer les autres charges (grid-funding, 3f). +Elle intervient à un autre moment de la cascade et ne se modélise pas comme un +consommateur classé. Onduleur hybride et AC-coupling sont bien deux mécanismes. + +**LM-701 — SmartHome.** Un cycle de lave-vaisselle ne se descend pas à 0 W en +cours de route : c'est un engagement pris au démarrage, pas un verrou `minOn`. +La sémantique d'action n'est pas une enveloppe en watts mais un **décalage +temporel** — démarrer maintenant ou plus tard. À traiter comme un type d'action +distinct, pas comme une variante. + +--- + +## §8 — Points ouverts + +1. `typeAppareil` — quelle énumération exacte, et jusqu'où elle affine le + domaine. +2. Représentation persistée des facettes (§4) : imbriquées dans la charge, ou + table séparée référençant la charge. +3. Sémantique d'action du SmartHome (LM-701) : à spécifier avant toute + implémentation, elle ne se déduit pas du reste. +4. Rotation d'équité (LM-503) : politique à définir si le besoin se confirme. + +--- + +## §9 — Ce que ce document ne change pas + +- L'ordre de `specs/spec_ecs.md` §2 et ses arbitrages bloquants. +- Les règles absolues 1 à 10 d'`AGENTS.md`. +- Le découpage en vigueur : le scheduler alloue, `RelayRouter` traduit. +- Rien dans le code aujourd'hui.