diff --git a/AGENTS.md b/AGENTS.md index a065a89..4758392 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,7 +100,11 @@ pour chaque LoadContext lc où lc.adapter == "relay-stages" : - Mock JSON : ThingClass `mockPowerSwitch` (état `power` bool, état `currentPower` double) - `energytestbase.h` : `mockPowerSwitchThingClassId` - `testEcsSurplusPV` : cas normal + cas "ECS déjà au palier 1, surplus stable → Y RESTE" (anti-clignotement) -- `testMeterSilentFallback` : compteur muet 90 s → mode dégradé → ECS off +- `testMeterSilentFallback` : compteur muet 90 s → mode dégradé → ECS off ; **vérifier que + l'ECS RESTE à 0 sur plusieurs cycles `update()` pendant le silence** (planification + suspendue, pas de rallumage sur cache mort) ; dégel → reprise normale + `degradedMode=false`. + Repli EV : seul un VE *déjà en charge* est clampé au minimum (pas d'activation forcée). + Seam de test : piloter `onMeterWatchdogTick()` / `m_lastMeterUpdate` sans 90 s d'horloge réelle. ### Arithmétique du budget (corrections A + B) diff --git a/docs/SAFETY.md b/docs/SAFETY.md index 0bcab79..19b4a74 100644 --- a/docs/SAFETY.md +++ b/docs/SAFETY.md @@ -52,20 +52,36 @@ 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 : +À la **transition** vers le mode dégradé, les charges pilotées reçoivent une consigne de +repli **conservatrice** : le repli n'INITIE rien, il borne ce qui tourne déjà. | Charge | Consigne de repli | |--------|------------------| -| EV (pluggedIn) | Courant minimum autorisé par la borne (`maxChargingCurrentMinValue`) | -| EV (non pluggedIn) | Désactivé | -| ECS | Relais coupé (palier 0) | +| EV **en charge** | Clamp au courant minimum borne (`maxChargingCurrentMinValue`) | +| EV branché mais **pas en charge** | Inchangé — reste off (off possiblement volontaire : HC/spot à venir) | +| EV débranché | Aucune action | +| ECS | Relais coupé (palier 0, `force=true`) | | SG-Ready PAC | État 1 (normal) | | Batterie | Aucune charge réseau (surplus uniquement, plafonné à 0 si compteur muet) | +« Maintenu » ≠ « démarré » : le mode dégradé ne force jamais l'activation d'une charge. +La garantie *"jamais 0 A si le VE est branché"* appartient au **failsafe L1 de la borne** +(timeout communication → courant minimum installateur), pas au repli logiciel. + 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). +**Suspension de la planification** : tant que le mode dégradé est actif, `update()` +exécute la sécurité L4 (position 3, intouchable) puis **retourne immédiatement** — ni +`getPlan()` ni dispatch. Sinon `update()` replanifierait sur le **cache** d'un compteur +mort et rallumerait les charges que le watchdog vient de couper, que le tick suivant +recouperait 30 s plus tard (oscillation). Le repli est donc appliqué **une seule fois à +la transition** ; les consignes tiennent jusqu'au retour du compteur. + +**Risque accepté** : ~1,4 kW de tirage EV non supervisé pour un VE déjà en charge au +moment de la bascule (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). diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 2433946..f809203 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -8,9 +8,20 @@ #include "types/surpluscontext.h" #include "types/plan.h" #include "../rootmeter.h" +#include "../evcharger.h" #include "plugininfo.h" +#include +#include + +namespace { +//! Période du watchdog L2 (SAFETY.md §L2) : tick indépendant des signaux compteur. +constexpr int MeterWatchdogPeriodMs = 30 * 1000; // 30 s +//! Seuil de silence compteur au-delà duquel le mode dégradé L2 est déclenché. +constexpr int MeterSilenceThresholdS = 90; // 90 s +} + EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm, SpotMarketManager *sm, EnergyManagerConfiguration *conf, @@ -18,6 +29,23 @@ EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm, : SmartChargingManager(em, tm, sm, conf, parent) , m_scheduler(new RuleBasedScheduler(this, this)) { + // --- L2 : watchdog fraîcheur compteur (SAFETY.md §L2) --- + // Fraîcheur picotée sur powerBalanceChanged (en plus de la connexion amont L4) ; + // grâce au démarrage : on initialise le timestamp pour éviter un dégradé immédiat. + m_lastMeterUpdate = QDateTime::currentDateTime(); + connect(em, &EnergyManager::powerBalanceChanged, this, [this]() { + m_lastMeterUpdate = QDateTime::currentDateTime(); + if (m_degradedMode) { + qCInfo(dcNymeaEnergy()) << "[Arbitre] Compteur de nouveau actif — sortie du mode dégradé L2."; + m_degradedMode = false; + } + }); + // QTimer (et non signal) : doit rester actif quand le compteur est muet. + m_meterWatchdog = new QTimer(this); + m_meterWatchdog->setInterval(MeterWatchdogPeriodMs); + connect(m_meterWatchdog, &QTimer::timeout, this, &EnergyArbitrator::onMeterWatchdogTick); + m_meterWatchdog->start(); + qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] Arbitre ETM initialisé."; } @@ -80,6 +108,15 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime) verifyOverloadProtection(currentDateTime); verifyOverloadProtectionRecovery(currentDateTime); + // Mode dégradé L2 : la sécurité (L4 ci-dessus) reste active, mais on SUSPEND la + // planification et le dispatch. Replanifier sur le cache d'un compteur mort + // rallumerait les charges que le watchdog vient de couper → oscillation. Les + // consignes de repli (posées à la transition) tiennent jusqu'au retour du compteur. + if (m_degradedMode) { + qCDebug(dcNymeaEnergy()) << "[Arbitre] Mode dégradé L2 actif — planification suspendue."; + return; + } + // ETM-only : sync adapters + proxy planification → log [Arbitre] // getPlan() appelle planSpotMarketCharging() + planSurplusCharging() (position 5-6 amont). syncAdapters(); @@ -164,3 +201,52 @@ void EnergyArbitrator::applyActionsToAdapters(const Slot &slot) adapter->applyAction(action); } } + +void EnergyArbitrator::onMeterWatchdogTick() +{ + if (!m_lastMeterUpdate.isValid()) + return; // Aucune mesure reçue (démarrage) — pas de dégradé (invariant root meter absent). + + const QDateTime now = QDateTime::currentDateTime(); + const qint64 silentS = m_lastMeterUpdate.secsTo(now); + if (silentS <= MeterSilenceThresholdS) + return; + + if (m_degradedMode) + return; // Déjà en repli — les consignes tiennent, pas de ré-émission (anti-oscillation). + + qCWarning(dcNymeaEnergy()) << "[Arbitre] Compteur muet depuis" << silentS + << "s (>" << MeterSilenceThresholdS << "s) — mode dégradé L2."; + applyDegradedMode(now); +} + +void EnergyArbitrator::applyDegradedMode(const QDateTime &now) +{ + m_degradedMode = true; + const QString reason = + QStringLiteral("Compteur muet depuis >90 s — consigne de repli (L2 watchdog)"); + + // ECS : tous paliers coupés (stage 0), force=true → bypass des verrous anti-rebond. + for (EcsRelayAdapter *adapter : m_ecsAdapters) { + LoadAction la; + la.loadId = adapter->descriptor().id; + la.kind = LoadAction::Stage; + la.stage = 0; + la.force = true; + la.reason = reason; + adapter->applyAction(la); + } + + // EV : repli CONSERVATEUR — n'initie aucune charge. On clampe seulement une charge + // DÉJÀ en cours au courant minimum (force=true, bypass lock). Une borne branchée mais + // non chargeante reste off (off volontaire possible : HC/spot à venir) ; débranchée → + // aucune action. La garantie "jamais 0 A si branché" relève du failsafe L1 de la borne. + for (auto it = internalEvChargers().constBegin(); it != internalEvChargers().constEnd(); ++it) { + EvCharger *ev = it.value(); + if (ev->available() && ev->charging()) + ev->setMaxChargingCurrent(ev->maxChargingCurrentMinValue(), now, true); + } + + // SG-Ready (état 1) / Batterie (aucune charge réseau) : repli ajouté avec leurs + // adaptateurs (3e/3f). Le flag degradedMode + notification client arrivent en 3c-6. +} diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index de0d948..71062ed 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -7,6 +7,10 @@ #include "types/surpluscontext.h" #include "types/plan.h" +#include + +class QTimer; + class EvAdapter; class EcsRelayAdapter; class RuleBasedScheduler; @@ -88,6 +92,7 @@ protected: * 1. updateManualSoCsWithoutMeter() * 2. prepareInformation() * 3. verifyOverloadProtection() + verifyOverloadProtectionRecovery() + * (si \c m_degradedMode actif : retour immédiat — planification/dispatch suspendus, L2) * 4. m_scheduler->getPlan() → log des decisionReason * 5. applyActionsToAdapters() (ECS Stage) + adjustEvChargers() (EV) → dispatch matériel * @@ -124,7 +129,38 @@ private: */ void applyActionsToAdapters(const Slot &slot); + /*! + * \brief Tick du watchdog L2 (SAFETY.md §L2) — piloté par \c m_meterWatchdog (QTimer 30 s). + * + * Si \c QDateTime::currentDateTime() − \c m_lastMeterUpdate dépasse 90 s, déclenche + * \c applyDegradedMode(). Indépendant des signaux compteur : reste actif précisément + * quand le compteur est muet (le signal \c powerBalanceChanged ne fire plus). + * \note Tant que \c m_lastMeterUpdate est invalide (aucune mesure reçue depuis le + * démarrage), aucun mode dégradé n'est déclenché (invariant root meter absent). + */ + void onMeterWatchdogTick(); + + /*! + * \brief Applique les consignes de repli L2 (SAFETY.md §L2, Variante B). + * + * Repli CONSERVATEUR (n'initie aucune charge) : ECS → palier 0 \c force=true (bypass + * anti-rebond) ; EV en charge → clamp courant minimum borne ; EV branché non chargeant + * ou débranché → aucune action (planification en cours respectée ; "jamais 0 A si + * branché" relève du failsafe L1). SG-Ready/Batterie : repli ajouté à l'arrivée de + * leurs adaptateurs (3e/3f). Positionne \c m_degradedMode. + * + * \note Appelé une seule fois à la TRANSITION vers le mode dégradé. Ensuite \c update() + * suspend la planification, donc les consignes tiennent sans ré-émission par tick. + * \param now Instant courant (locks anti-rebond des bornes EV). + */ + void applyDegradedMode(const QDateTime &now); + RuleBasedScheduler *m_scheduler = nullptr; QHash m_adapters; //!< loadId (ThingId string) → EvAdapter*. QHash m_ecsAdapters; //!< loadId → EcsRelayAdapter*. + + // --- L2 watchdog fraîcheur compteur (SAFETY.md §L2) --- + QTimer *m_meterWatchdog = nullptr; //!< Tick 30 s, indépendant des signaux compteur. + QDateTime m_lastMeterUpdate; //!< Horodatage du dernier powerBalanceChanged. + bool m_degradedMode = false; //!< Vrai si les consignes de repli L2 sont actives. };