From c3fedfe36b556c1c8cadf4205bafdc2dfe028d42 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Mon, 8 Jun 2026 07:41:12 +0200 Subject: [PATCH] =?UTF-8?q?[3b]=20d=C3=A9cision=20B=20+=20mod=C3=A8le=20s?= =?UTF-8?q?=C3=A9curit=C3=A9=20(AGENTS=20+=20SAFETY.md)=20+=20Doxygen=20pr?= =?UTF-8?q?oxy/inactif?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AGENTS.md : nouvelle entrée "3b révisé — délégation EV à l'amont" (beta hybride assumée, ETM réel en 3c, transplantation EV en 3g) ; modèle sécurité L0-L4 avec double déclenchement verifyOverloadProtection documenté (signal ligne 127 + appel cyclique ligne 313 SCM.cpp). - docs/SAFETY.md : document normatif 5 couches + signalisation locale optionnelle ; Variante B confirmée pour le repli L2 (EV au minimum + notification nymea + risque 1,4 kW accepté) ; table défaillances/couches corrigée (L1 ne couvre pas compteur hors ligne). - energyarbitrator.cpp update() : commentaire explicitant la correspondance exacte avec l'ordre SCM (1-4 parent, ETM entre 4 et 7, planSpot+planSurplus via getPlan). - rulebasedscheduler.h : Doxygen getPlan() marqué "PROXY AMONT POUR L'EV (beta)". - evadapter.h : Doxygen applyAction() marqué "Inactif jusqu'à 3g". Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 9 +- docs/SAFETY.md | 167 ++++++++++++++++++ energyplugin/etm/adapters/evadapter.h | 5 + energyplugin/etm/energyarbitrator.cpp | 14 +- .../etm/scheduler/rulebasedscheduler.h | 27 ++- 5 files changed, 206 insertions(+), 16 deletions(-) create mode 100644 docs/SAFETY.md diff --git a/AGENTS.md b/AGENTS.md index 64ab3d1..dd9281d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -204,9 +204,12 @@ supérieures n'affecte pas les couches inférieures. Voir `docs/SAFETY.md` pour **Règles de code** : - Le watchdog L2 est piloté par **`QTimer`** (pas par signal `meterChanged`) pour rester actif même si le signal ne fire plus. -- Mode dégradé = consignes **de repli** sur toutes les charges pilotées (pas d'arrêt - brutal) + `decisionReason` non vide + notification `EnergyManagerChanged`. -- `verifyOverloadProtection()` (L4) reste intouchable et appelée avant toute planification. +- Mode dégradé = consignes **de repli** (EV au minimum si pluggedIn, ECS off, etc.) + + `decisionReason` non vide + notification `EnergyManagerChanged` avec `degradedMode`. +- `verifyOverloadProtection()` (L4) est déclenchée par **deux mécanismes** : + (a) signal `powerBalanceChanged` (temps réel — SCM.cpp ligne 127, mécanisme principal) ; + (b) appel cyclique en position 3 d'`update()` (SCM.cpp ligne 313, filet périodique). + La position dans `update()` est **INTOUCHABLE** — même dans `EnergyArbitrator::update()`. --- diff --git a/docs/SAFETY.md b/docs/SAFETY.md new file mode 100644 index 0000000..0bcab79 --- /dev/null +++ b/docs/SAFETY.md @@ -0,0 +1,167 @@ +# SAFETY.md — Modèle de sécurité ETM PowerSync Energy + +Décision Patrick Schurig, validée en session 2026-06-08. Ce document est normatif. + +## Principes + +Cinq couches indépendantes + signalisation locale optionnelle. Une couche supérieure +peut tomber sans impacter les couches inférieures. Jamais de contournement logiciel +d'une couche matérielle. + +--- + +## L0 — Disjoncteur (matériel) + +**Protège contre** : surintensité physique, défaut d'isolement. +**Responsable** : installateur électricien, norme C15-100. +**ETM ne touche pas à cette couche.** Elle fonctionne que nymead soit vivant ou non. + +--- + +## L1 — Failsafe natif des bornes EV et charges pilotées + +**Protège contre** : perte de communication borne ↔ nymead. +**Ne couvre PAS** : perte de communication compteur ↔ nymead (c'est le rôle de L2). +**Responsable** : configuration installateur sur chaque borne/appareil. + +**Checklist ETM à la mise en service** : +- Borne EV : configurer le timeout de communication → repli sur courant minimum + défini par l'installateur (jamais 0 A si le VE est branché, jamais maximum abonnement). +- Relais ECS : désactivation si absence de commande > N minutes (selon chauffe-eau). +- SG-Ready PAC : état 1 (normal, non piloté) si silence > N minutes. + +Cette configuration est documentée dans la checklist de déploiement +(`etm-powersync-deploy`), pas ici. + +--- + +## L2 — Watchdog fraîcheur compteur (à implémenter en phase 3c) + +**Protège contre** : compteur hors ligne, plugin gelé, nymead bloqué. +**Responsable** : code ETM dans `EnergyArbitrator`. + +### Comportement + +Un `QTimer` (pas un signal `meterChanged`) tourne en permanence avec une période de 30 s. +À chaque tick : si `QDateTime::currentDateTime() - m_lastMeterUpdate > 90 s` +→ **mode dégradé** déclenché. + +**Choix de conception :** `QTimer` et non signal `powerBalanceChanged`, car si le +compteur est muet, le signal ne fire plus — le watchdog doit rester actif précisément +dans ce cas. + +### Mode dégradé — consignes de repli (**Variante B — décision Patrick**) + +Toutes les charges pilotées reçoivent une consigne de repli immédiate : + +| Charge | Consigne de repli | +|--------|------------------| +| EV (pluggedIn) | Courant minimum autorisé par la borne (`maxChargingCurrentMinValue`) | +| EV (non pluggedIn) | Désactivé | +| ECS | Relais coupé (palier 0) | +| SG-Ready PAC | État 1 (normal) | +| Batterie | Aucune charge réseau (surplus uniquement, plafonné à 0 si compteur muet) | + +Chaque consigne porte un `decisionReason` explicite : +`"Compteur muet depuis >90 s — consigne de repli (L2 watchdog)"`. + +**Risque accepté** : ~1,4 kW de tirage EV non supervisé (minimum borne typique ≈ 6 A × 230 V × 1 phase). Ce tirage est atténué par L0 (disjoncteur) et L1 (failsafe borne). Le client est informé (voir Notification ci-dessous). + +**Limite** : la notification part du contrôleur dégradé lui-même (best-effort). L'alerte +indépendante de la défaillance du contrôleur est le rôle de L3 (watchdog systemd). + +### Notification client (dans ce repo) + +- Une notification nymea `EnergyManagerChanged` est émise avec un flag `degradedMode: true`. +- L'application affiche : *"Supervision compteur perdue — charge EV maintenue au minimum"*. + +### Alertes externes (hors de ce repo) + +Les notifications push/mail/SMS sont déclenchées via l'infra ETM (n8n) sur réception +de l'événement nymea. Aucun code de notification externe dans ce plugin. + +### Sortie du mode dégradé + +Dès que le compteur fournit une nouvelle mesure (`m_lastMeterUpdate` remis à jour) +→ reprise normale au cycle suivant, `degradedMode: false`. + +### Scénario de simulation obligatoire (DoD 3c) + +Scénario `testMeterSilentFallback` dans `tests/auto/simulation/` : +1. Charger branché, surplus PV → charge en cours. +2. Geler les updates du compteur (mock). +3. Vérifier qu'après 90 s simulés, les consignes de repli sont émises avec le bon `reason` + et que `degradedMode` est à `true`. +4. Dégeler le compteur → vérifier la reprise normale et `degradedMode: false`. + +--- + +## L3 — Watchdog systemd sur nymead + +**Protège contre** : crash de nymead, deadlock process, défaillance de L2. +**Responsable** : `etm-powersync-deploy` (hors scope de ce repo). +Nymead enregistre `sd_notify(WATCHDOG=1)` ; systemd le relance si absent > timeout. +À la reprise, L1 garantit la sécurité matérielle pendant le redémarrage. + +--- + +## L4 — Logique signal-driven existante (amont) + +**Protège contre** : surcharge transitoire sur phases. +**Responsable** : `SmartChargingManager::verifyOverloadProtection()`. + +### Double déclenchement (code amont vérifié) + +`verifyOverloadProtection()` est déclenchée par **deux mécanismes distincts** : + +1. **Signal temps réel** (`smartchargingmanager.cpp` ligne 127) : + ```cpp + connect(m_energyManager, &EnergyManager::powerBalanceChanged, this, [this]() { + verifyOverloadProtection(QDateTime::currentDateTime()); + }); + ``` + C'est le mécanisme **principal** : déclenché à chaque nouveau bilan de puissance + émis par le compteur, sans attendre le prochain cycle `update()`. + +2. **Appel cyclique** (`smartchargingmanager.cpp` ligne 313, dans `update()`) : + ```cpp + verifyOverloadProtection(currentDateTime); // position 3 du cycle + ``` + Filet périodique — garantit une vérification même si le signal précède la mise à + jour des états internes. + +Note : en mode simulation, `simulationCallUpdate()` (ligne 295-299) appelle `update()` +puis `verifyOverloadProtection()` une seconde fois — double appel intentionnel dans +les tests, pas dans la production. + +### Règle de code ETM + +`EnergyArbitrator::update()` appelle `verifyOverloadProtection()` en **position 3**, +identique à l'amont. INTERDIT de déplacer cet appel ou de le conditionner. +La connexion signal est héritée via le constructeur parent — aucune reconnexion ETM. + +--- + +## Signalisation locale (optionnelle — hors de ce repo) + +**Protège contre** : alarme silencieuse non détectée, exigeant une action humaine immédiate. +**Ce plugin** émet les événements (`degradedMode`, alarmes overload, etc.). +**La signalisation** est une Thing nymea (buzzer GPIO ou canal relais) + une **Règle** +déclenchée sur ces événements — configuration installation, documentée dans +`etm-powersync-deploy`. + +Aucun code buzzer/relais dans ce repo. Principe : le moteur émet, la configuration +d'installation décide quoi signaler. + +--- + +## Correspondance couches / scénarios de défaillance + +| Défaillance | L0 | L1 | L2 | L3 | L4 | Signal local | +|-------------|----|----|----|----|-----|------| +| Surintensité réseau | ✅ | ✅ | — | — | ✅ | optionnel | +| Borne perd le réseau IP | — | ✅ | — | — | — | — | +| Compteur hors ligne > 90 s | ✅ | — | ✅ | — | — | optionnel | +| Crash nymead | — | ✅ | — | ✅ | — | — | +| Bug dans `update()` | — | ✅ | ✅ | ✅ | ✅ | — | +| Optimiseur externe mort | — | — | — | — | — | repli rule-based (AGENTS §6) | diff --git a/energyplugin/etm/adapters/evadapter.h b/energyplugin/etm/adapters/evadapter.h index ffefd09..d3e0f18 100644 --- a/energyplugin/etm/adapters/evadapter.h +++ b/energyplugin/etm/adapters/evadapter.h @@ -53,6 +53,11 @@ public: /*! * \brief Applique une consigne Setpoint sur la borne VE. * + * **Inactif jusqu'à 3g** : non appelée par \c EnergyArbitrator::update() en beta. + * Le dispatch EV passe par \c adjustEvChargers() amont (hérité). Cette méthode + * sera câblée lors de la transplantation EV dans \c RuleBasedScheduler (phase 3g). + * \c descriptor() et \c telemetry() sont eux actifs dès maintenant pour le SurplusContext. + * * \param action LoadAction de kind Setpoint. Autres kinds : retour sans effet. * \return L'action après écrêtage matériel (currentA, phaseCount bornés). * diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index ba1a2e3..92b5771 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -53,19 +53,25 @@ RootMeter *EnergyArbitrator::registeredRootMeter() const void EnergyArbitrator::update(const QDateTime ¤tDateTime) { - // 1-4 : préparation + sécurité (ordre garanti, identique à l'amont) + // Ordre IDENTIQUE à SmartChargingManager::update() — INTERDIT de réordonner. + // SCM : 1.updateManual 2.prepareInfo 3.verifyOverload 4.verifyRecovery + // 5.planSpot 6.planSurplus 7.adjustEv + // ETM : idem 1-4 ; insertions ETM entre 4 et 7 ; + // planSpot + planSurplus appelés via m_scheduler->getPlan() (position 5-6). + + // 1-4 : préparation + sécurité (même ordre que l'amont) updateManualSoCsWithoutMeter(currentDateTime); prepareInformation(currentDateTime); verifyOverloadProtection(currentDateTime); verifyOverloadProtectionRecovery(currentDateTime); - // 5 : planification via IScheduler (RuleBasedScheduler pour l'instant) + // ETM-only : sync adapters + proxy planification → log [Arbitre] + // getPlan() appelle planSpotMarketCharging() + planSurplusCharging() (position 5-6 amont). syncAdapters(); SurplusContext ctx = buildContext(currentDateTime); Plan plan = m_scheduler->getPlan(ctx); Slot slot = plan.slotCovering(currentDateTime); - // 6 : log des decisionReason (DoD 3b) for (const LoadAction &action : slot.actions) { qCInfo(dcNymeaEnergy()) << "[Arbitre]" << action.loadId << "→" << action.reason @@ -75,7 +81,7 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime) << "| stratégie:" << plan.strategy; } - // 7 : dispatch matériel + mise à jour des états de charge (via amont iso-fonctionnel) + // 7 : dispatch matériel (même position que l'amont — m_chargingActions rempli par getPlan()) adjustEvChargers(currentDateTime); } diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.h b/energyplugin/etm/scheduler/rulebasedscheduler.h index 33ed7ec..571dafb 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.h +++ b/energyplugin/etm/scheduler/rulebasedscheduler.h @@ -10,17 +10,23 @@ class ChargingAction; class EnergyArbitrator; /*! - * \brief Planificateur réglementaire basé sur les règles GPL (EV surplus + aWATTar). + * \brief Planificateur basé sur les règles GPL (EV surplus + aWATTar). * - * En phase 3b, wraps la logique de SmartChargingManager (planSurplusCharging + - * planSpotMarketCharging) et l'expose via IScheduler en annotant chaque action - * d'un \c reason en français. + * **Rôle en beta (jusqu'à 3g)** : PROXY AMONT POUR L'EV — délègue toute la + * planification EV à \c planSurplusCharging() et \c planSpotMarketCharging() + * héritées de SmartChargingManager. Ces méthodes décident (acquisitionTolerance, + * batteryLevelConsideration, waterfall, spot market) ; ce scheduler relit leurs + * sorties (m_chargingActions) et les reformate en LoadAction annotées d'un + * \c reason français — pour le log [Arbitre] uniquement. + * + * À partir de **3g** : la logique de surplus sera transplantée ici (waterfall + * unifié sur toutes les charges, EV inclus). Voir AGENTS.md § "3b révisé". * * \invariant getPlan() retourne IMMÉDIATEMENT (AGENTS invariant 5). * \invariant getPlan() retourne toujours un Plan valide (isValid() == true). * \invariant Toute LoadAction a un \c reason non vide, en français. * \invariant Priorité : Deadline VE > Surplus PV > aWATTar > Min courant > Idle. - * Identique à adjustEvChargers() amont (iso-fonctionnel 3b). + * Identique à adjustEvChargers() amont — iso-fonctionnel 3b. */ class RuleBasedScheduler : public QObject, public IScheduler { @@ -34,13 +40,16 @@ public: explicit RuleBasedScheduler(EnergyArbitrator *arbitrator, QObject *parent = nullptr); /*! - * \brief Calcule le plan d'action pour le slot courant. + * \brief Retourne le plan pour le slot courant. * - * Appelle les méthodes de planification héritées (surplus + spot market) puis - * traduit les ChargingActions résultantes en LoadActions annotées d'un \c reason. + * **Beta (proxy)** : appelle \c runSpotMarketPlanning() puis \c runSurplusPlanning() + * (qui écrivent dans m_chargingActions via les méthodes parent), lit le résultat + * via \c scheduledActions(), et construit les LoadAction correspondantes. + * Les LoadAction servent uniquement au log [Arbitre] — le dispatch réel reste dans + * \c adjustEvChargers() amont. * * \param ctx SurplusContext courant. En 3b : seul \c ctx.timestamp est utilisé. - * \return Plan avec un Slot couvrant les 60 secondes à venir depuis ctx.timestamp. + * \return Plan à 1 créneau couvrant \c ctx.timestamp + 60 s. */ Plan getPlan(const SurplusContext &ctx) override;