[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** :
|
**Règles de code** :
|
||||||
- Le watchdog L2 est piloté par **`QTimer`** (pas par signal `meterChanged`) pour
|
- Le watchdog L2 est piloté par **`QTimer`** (pas par signal `meterChanged`) pour
|
||||||
rester actif même si le signal ne fire plus.
|
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
|
- Mode dégradé = consignes **de repli** (EV au minimum si pluggedIn, ECS off, etc.)
|
||||||
brutal) + `decisionReason` non vide + notification `EnergyManagerChanged`.
|
+ `decisionReason` non vide + notification `EnergyManagerChanged` avec `degradedMode`.
|
||||||
- `verifyOverloadProtection()` (L4) reste intouchable et appelée avant toute planification.
|
- `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.
|
* \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.
|
* \param action LoadAction de kind Setpoint. Autres kinds : retour sans effet.
|
||||||
* \return L'action après écrêtage matériel (currentA, phaseCount bornés).
|
* \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)
|
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);
|
updateManualSoCsWithoutMeter(currentDateTime);
|
||||||
prepareInformation(currentDateTime);
|
prepareInformation(currentDateTime);
|
||||||
verifyOverloadProtection(currentDateTime);
|
verifyOverloadProtection(currentDateTime);
|
||||||
verifyOverloadProtectionRecovery(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();
|
syncAdapters();
|
||||||
SurplusContext ctx = buildContext(currentDateTime);
|
SurplusContext ctx = buildContext(currentDateTime);
|
||||||
Plan plan = m_scheduler->getPlan(ctx);
|
Plan plan = m_scheduler->getPlan(ctx);
|
||||||
Slot slot = plan.slotCovering(currentDateTime);
|
Slot slot = plan.slotCovering(currentDateTime);
|
||||||
|
|
||||||
// 6 : log des decisionReason (DoD 3b)
|
|
||||||
for (const LoadAction &action : slot.actions) {
|
for (const LoadAction &action : slot.actions) {
|
||||||
qCInfo(dcNymeaEnergy()) << "[Arbitre]"
|
qCInfo(dcNymeaEnergy()) << "[Arbitre]"
|
||||||
<< action.loadId << "→" << action.reason
|
<< action.loadId << "→" << action.reason
|
||||||
@ -75,7 +81,7 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime)
|
|||||||
<< "| stratégie:" << plan.strategy;
|
<< "| 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);
|
adjustEvChargers(currentDateTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,17 +10,23 @@ class ChargingAction;
|
|||||||
class EnergyArbitrator;
|
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 +
|
* **Rôle en beta (jusqu'à 3g)** : PROXY AMONT POUR L'EV — délègue toute la
|
||||||
* planSpotMarketCharging) et l'expose via IScheduler en annotant chaque action
|
* planification EV à \c planSurplusCharging() et \c planSpotMarketCharging()
|
||||||
* d'un \c reason en français.
|
* 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 IMMÉDIATEMENT (AGENTS invariant 5).
|
||||||
* \invariant getPlan() retourne toujours un Plan valide (isValid() == true).
|
* \invariant getPlan() retourne toujours un Plan valide (isValid() == true).
|
||||||
* \invariant Toute LoadAction a un \c reason non vide, en français.
|
* \invariant Toute LoadAction a un \c reason non vide, en français.
|
||||||
* \invariant Priorité : Deadline VE > Surplus PV > aWATTar > Min courant > Idle.
|
* \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
|
class RuleBasedScheduler : public QObject, public IScheduler
|
||||||
{
|
{
|
||||||
@ -34,13 +40,16 @@ public:
|
|||||||
explicit RuleBasedScheduler(EnergyArbitrator *arbitrator, QObject *parent = nullptr);
|
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
|
* **Beta (proxy)** : appelle \c runSpotMarketPlanning() puis \c runSurplusPlanning()
|
||||||
* traduit les ChargingActions résultantes en LoadActions annotées d'un \c reason.
|
* (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é.
|
* \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;
|
Plan getPlan(const SurplusContext &ctx) override;
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user