[3c-5] watchdog L2 : QTimer fraîcheur compteur + mode dégradé conservateur

QTimer 30s indépendant des signaux ; m_lastMeterUpdate picoté sur powerBalanceChanged.
Silence >90s → mode dégradé (appliqué à la TRANSITION uniquement) :
  - ECS palier 0 force=true ;
  - EV : clamp courant minimum SEULEMENT si déjà en charge (pas d'activation forcée ;
    "jamais 0 A si branché" relève du failsafe L1, pas du repli logiciel).
update() suspend la planification + le dispatch tant que m_degradedMode (sécurité L4
en position 3 reste active) → pas de rallumage sur le cache d'un compteur mort, pas
d'oscillation. Reprise au retour du compteur.

SAFETY.md §L2 : nuance maintenu/démarré + suspension planification. AGENTS.md morceau 7 :
exiger ECS reste à 0 sur plusieurs cycles. SG-Ready/Batterie déférés 3e/3f ;
flag degradedMode exposé en 3c-6. Build 0 erreur / 0 warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-06-08 16:43:28 +02:00
parent 0615e5f39d
commit 312a2484ae
4 changed files with 148 additions and 6 deletions

View File

@ -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)

View File

@ -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).

View File

@ -8,9 +8,20 @@
#include "types/surpluscontext.h"
#include "types/plan.h"
#include "../rootmeter.h"
#include "../evcharger.h"
#include "plugininfo.h"
#include <energymanager.h>
#include <QTimer>
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 &currentDateTime)
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.
}

View File

@ -7,6 +7,10 @@
#include "types/surpluscontext.h"
#include "types/plan.h"
#include <QDateTime>
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 -émission par tick.
* \param now Instant courant (locks anti-rebond des bornes EV).
*/
void applyDegradedMode(const QDateTime &now);
RuleBasedScheduler *m_scheduler = nullptr;
QHash<QString, EvAdapter *> m_adapters; //!< loadId (ThingId string) → EvAdapter*.
QHash<QString, EcsRelayAdapter *> 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.
};