[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:
parent
0615e5f39d
commit
312a2484ae
@ -100,7 +100,11 @@ pour chaque LoadContext lc où lc.adapter == "relay-stages" :
|
|||||||
- Mock JSON : ThingClass `mockPowerSwitch` (état `power` bool, état `currentPower` double)
|
- Mock JSON : ThingClass `mockPowerSwitch` (état `power` bool, état `currentPower` double)
|
||||||
- `energytestbase.h` : `mockPowerSwitchThingClassId`
|
- `energytestbase.h` : `mockPowerSwitchThingClassId`
|
||||||
- `testEcsSurplusPV` : cas normal + cas "ECS déjà au palier 1, surplus stable → Y RESTE" (anti-clignotement)
|
- `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)
|
### Arithmétique du budget (corrections A + B)
|
||||||
|
|
||||||
|
|||||||
@ -52,20 +52,36 @@ dans ce cas.
|
|||||||
|
|
||||||
### Mode dégradé — consignes de repli (**Variante B — décision Patrick**)
|
### 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 |
|
| Charge | Consigne de repli |
|
||||||
|--------|------------------|
|
|--------|------------------|
|
||||||
| EV (pluggedIn) | Courant minimum autorisé par la borne (`maxChargingCurrentMinValue`) |
|
| EV **en charge** | Clamp au courant minimum borne (`maxChargingCurrentMinValue`) |
|
||||||
| EV (non pluggedIn) | Désactivé |
|
| EV branché mais **pas en charge** | Inchangé — reste off (off possiblement volontaire : HC/spot à venir) |
|
||||||
| ECS | Relais coupé (palier 0) |
|
| EV débranché | Aucune action |
|
||||||
|
| ECS | Relais coupé (palier 0, `force=true`) |
|
||||||
| SG-Ready PAC | État 1 (normal) |
|
| SG-Ready PAC | État 1 (normal) |
|
||||||
| Batterie | Aucune charge réseau (surplus uniquement, plafonné à 0 si compteur muet) |
|
| 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 :
|
Chaque consigne porte un `decisionReason` explicite :
|
||||||
`"Compteur muet depuis >90 s — consigne de repli (L2 watchdog)"`.
|
`"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
|
**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).
|
indépendante de la défaillance du contrôleur est le rôle de L3 (watchdog systemd).
|
||||||
|
|||||||
@ -8,9 +8,20 @@
|
|||||||
#include "types/surpluscontext.h"
|
#include "types/surpluscontext.h"
|
||||||
#include "types/plan.h"
|
#include "types/plan.h"
|
||||||
#include "../rootmeter.h"
|
#include "../rootmeter.h"
|
||||||
|
#include "../evcharger.h"
|
||||||
|
|
||||||
#include "plugininfo.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,
|
EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm,
|
||||||
SpotMarketManager *sm,
|
SpotMarketManager *sm,
|
||||||
EnergyManagerConfiguration *conf,
|
EnergyManagerConfiguration *conf,
|
||||||
@ -18,6 +29,23 @@ EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm,
|
|||||||
: SmartChargingManager(em, tm, sm, conf, parent)
|
: SmartChargingManager(em, tm, sm, conf, parent)
|
||||||
, m_scheduler(new RuleBasedScheduler(this, this))
|
, 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é.";
|
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] Arbitre ETM initialisé.";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,6 +108,15 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime)
|
|||||||
verifyOverloadProtection(currentDateTime);
|
verifyOverloadProtection(currentDateTime);
|
||||||
verifyOverloadProtectionRecovery(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]
|
// ETM-only : sync adapters + proxy planification → log [Arbitre]
|
||||||
// getPlan() appelle planSpotMarketCharging() + planSurplusCharging() (position 5-6 amont).
|
// getPlan() appelle planSpotMarketCharging() + planSurplusCharging() (position 5-6 amont).
|
||||||
syncAdapters();
|
syncAdapters();
|
||||||
@ -164,3 +201,52 @@ void EnergyArbitrator::applyActionsToAdapters(const Slot &slot)
|
|||||||
adapter->applyAction(action);
|
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.
|
||||||
|
}
|
||||||
|
|||||||
@ -7,6 +7,10 @@
|
|||||||
#include "types/surpluscontext.h"
|
#include "types/surpluscontext.h"
|
||||||
#include "types/plan.h"
|
#include "types/plan.h"
|
||||||
|
|
||||||
|
#include <QDateTime>
|
||||||
|
|
||||||
|
class QTimer;
|
||||||
|
|
||||||
class EvAdapter;
|
class EvAdapter;
|
||||||
class EcsRelayAdapter;
|
class EcsRelayAdapter;
|
||||||
class RuleBasedScheduler;
|
class RuleBasedScheduler;
|
||||||
@ -88,6 +92,7 @@ protected:
|
|||||||
* 1. updateManualSoCsWithoutMeter()
|
* 1. updateManualSoCsWithoutMeter()
|
||||||
* 2. prepareInformation()
|
* 2. prepareInformation()
|
||||||
* 3. verifyOverloadProtection() + verifyOverloadProtectionRecovery()
|
* 3. verifyOverloadProtection() + verifyOverloadProtectionRecovery()
|
||||||
|
* (si \c m_degradedMode actif : retour immédiat — planification/dispatch suspendus, L2)
|
||||||
* 4. m_scheduler->getPlan() → log des decisionReason
|
* 4. m_scheduler->getPlan() → log des decisionReason
|
||||||
* 5. applyActionsToAdapters() (ECS Stage) + adjustEvChargers() (EV) → dispatch matériel
|
* 5. applyActionsToAdapters() (ECS Stage) + adjustEvChargers() (EV) → dispatch matériel
|
||||||
*
|
*
|
||||||
@ -124,7 +129,38 @@ private:
|
|||||||
*/
|
*/
|
||||||
void applyActionsToAdapters(const Slot &slot);
|
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;
|
RuleBasedScheduler *m_scheduler = nullptr;
|
||||||
QHash<QString, EvAdapter *> m_adapters; //!< loadId (ThingId string) → EvAdapter*.
|
QHash<QString, EvAdapter *> m_adapters; //!< loadId (ThingId string) → EvAdapter*.
|
||||||
QHash<QString, EcsRelayAdapter *> m_ecsAdapters; //!< loadId → EcsRelayAdapter*.
|
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.
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user