[3b] décision B + modèle sécurité (AGENTS + SAFETY.md) + Doxygen proxy/inactif
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
7d3fc6e5ea
commit
c3fedfe36b
@ -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()`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
167
docs/SAFETY.md
Normal file
167
docs/SAFETY.md
Normal file
@ -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) |
|
||||
@ -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).
|
||||
*
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user