Patrick Schurig 312a2484ae [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>
2026-06-08 16:43:28 +02:00

253 lines
9.8 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#include "energyarbitrator.h"
#include "adapters/evadapter.h"
#include "adapters/ecsrelayadapter.h"
#include "scheduler/rulebasedscheduler.h"
#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,
QObject *parent)
: 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é.";
}
void EnergyArbitrator::runSurplusPlanning(const QDateTime &now)
{
planSurplusCharging(now);
}
void EnergyArbitrator::runSpotMarketPlanning(const QDateTime &now)
{
planSpotMarketCharging(now);
}
const QHash<EvCharger *, ChargingActions> &EnergyArbitrator::scheduledActions() const
{
return internalChargingActions();
}
void EnergyArbitrator::doExecuteChargingAction(EvCharger *charger,
const ChargingAction &action,
const QDateTime &now)
{
executeChargingAction(charger, action, now);
}
const QHash<ThingId, EvCharger *> &EnergyArbitrator::registeredEvChargers() const
{
return internalEvChargers();
}
RootMeter *EnergyArbitrator::registeredRootMeter() const
{
return internalRootMeter();
}
void EnergyArbitrator::registerEcsAdapter(EcsRelayAdapter *adapter)
{
const QString id = adapter->descriptor().id;
if (m_ecsAdapters.contains(id)) {
qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] EcsRelayAdapter déjà enregistré:" << id;
return;
}
adapter->setParent(this);
m_ecsAdapters[id] = adapter;
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] EcsRelayAdapter enregistré:" << adapter->descriptor().label;
}
void EnergyArbitrator::update(const QDateTime &currentDateTime)
{
qCDebug(dcNymeaEnergy()) << "Updating smart charging";
// 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);
// 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();
SurplusContext ctx = buildContext(currentDateTime);
Plan plan = m_scheduler->getPlan(ctx);
Slot slot = plan.slotCovering(currentDateTime);
for (const LoadAction &action : slot.actions) {
qCInfo(dcNymeaEnergy()) << "[Arbitre]"
<< action.loadId << "" << action.reason
<< "| activé:" << action.chargingEnabled
<< "| courant:" << action.currentA << "A"
<< "| phases:" << action.phaseCount
<< "| stratégie:" << plan.strategy;
}
// 7 : dispatch matériel (même position que l'amont — m_chargingActions rempli par getPlan())
applyActionsToAdapters(slot); // ECS (kind==Stage) → m_ecsAdapters
adjustEvChargers(currentDateTime); // EV (kind==Setpoint) → proxy amont jusqu'à 3g
}
SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const
{
SurplusContext ctx;
ctx.timestamp = now;
// --- Compteur principal (AGENTS invariant 8 : mesure brute, aucune déduction) ---
RootMeter *meter = internalRootMeter();
if (meter) {
// currentPower() < 0 → export ; > 0 → import (convention amont SCM l.1141)
const double p = meter->currentPower();
ctx.meter.importW = qMax(0.0, p);
ctx.meter.exportW = qMax(0.0, -p);
ctx.meter.perPhaseA = {
meter->currentPhaseA(),
meter->currentPhaseB(),
meter->currentPhaseC()
};
}
// SurplusPv : interface inverter — déféré (remplissage prévu en 3d)
// SurplusBattery : déféré 3f
// --- loads[] : EV adapters ---
for (auto it = m_adapters.constBegin(); it != m_adapters.constEnd(); ++it)
ctx.loads.append(it.value()->toLoadContext());
// --- loads[] : ECS relay adapters ---
for (auto it = m_ecsAdapters.constBegin(); it != m_ecsAdapters.constEnd(); ++it)
ctx.loads.append(it.value()->toLoadContext());
return ctx;
}
void EnergyArbitrator::syncAdapters()
{
// Crée les adapters manquants
for (auto it = internalEvChargers().constBegin(); it != internalEvChargers().constEnd(); ++it) {
const QString id = it.key().toString();
if (!m_adapters.contains(id))
m_adapters[id] = new EvAdapter(it.value(), this);
}
// Supprime les adapters obsolètes
for (const QString &id : m_adapters.keys()) {
if (!internalEvChargers().contains(ThingId(id)))
m_adapters.take(id)->deleteLater();
}
}
void EnergyArbitrator::applyActionsToAdapters(const Slot &slot)
{
for (const LoadAction &action : slot.actions) {
// EV (Setpoint) : dispatché par adjustEvChargers() amont jusqu'à 3g.
if (action.kind != LoadAction::Stage)
continue;
EcsRelayAdapter *adapter = m_ecsAdapters.value(action.loadId);
if (!adapter) {
qCWarning(dcNymeaEnergy()) << "[Arbitre] action Stage sans adaptateur ECS:" << action.loadId;
continue;
}
// L'adaptateur applique, écrête et verrouille (anti-rebond) — il ne décide pas.
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.
}