Patrick Schurig 88626cfd56 feat(etm): câblage RelayRouter + repli L2 unifié (rév. 3) — routeur vivant, sécurité fermée
L'arbitre construit et pilote les routeurs ; le repli L2 couvre les DEUX types ENSEMBLE
(atomique : pas d'intermédiaire avec charge active sans repli).

- Map unique m_loadAdapters (QHash<QString, ILoadAdapter*>) : RelayRouter + EtmVariableLoadAdapter
  cohabitent par polymorphisme. registerRelayRouter() ajouté.
- rebuildLoadAdapters() : la distinction de TYPE vit ICI — RelayRouter si relays[], sinon
  EtmVariableLoadAdapter. Au-dessus tout est ILoadAdapter (Setpoint W).
- Dispatch Setpoint AGNOSTIQUE au type : routage par loadId, le polymorphisme absorbe (pas de
  if(RelayRouter)). Le scheduler distingue Setpoint (etmvariableload + relay-router) vs State
  (sg-ready) — nature de l'action, pas classe concrète.
- Repli L2 : applyDegradedMode boucle sur TOUS les ILoadAdapter → Setpoint(0) force=true. Pour
  le RelayRouter, coupe TOUS les relais (bypass minOn). Ferme le trou T2 pour le routeur.
- testMeterSilentFallback étendu au cas RELAIS : compteur muet → 2 relais OFF force=true,
  restent OFF sur N cycles. Certifie la sécurité relais.

Build prod 0/0 ; suite (config/L2/migrés) verte. Étape qui rouvre le déploiement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:40:28 +02:00

374 lines
16 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/sgreadyadapter.h"
#include "adapters/etmvariableloadadapter.h"
#include "adapters/relayrouter.h"
#include "config/loadconfigstore.h"
#include "types/loadconfig.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))
, m_tm(tm)
{
// --- L2 : watchdog fraîcheur compteur (SAFETY.md §L2) ---
// La LOGIQUE (recordMeterUpdate / evaluateMeterFreshness) prend le temps en paramètre
// et reste testable par injection (symétrique de simulationCallUpdate). Seuls les
// DÉCLENCHEURS RÉELS (signal + QTimer, horloge murale) sont câblés ici, et exclus en
// simulation — comme les connexions amont powerBalanceEntryAdded→update() (SCM l.108-130).
#ifndef ENERGY_SIMULATION
m_lastMeterUpdate = QDateTime::currentDateTime(); // grâce au démarrage (évite un dégradé immédiat)
// Fraîcheur picotée sur powerBalanceChanged (en plus de la connexion amont L4).
connect(em, &EnergyManager::powerBalanceChanged, this, [this]() {
recordMeterUpdate(QDateTime::currentDateTime());
});
// 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();
#else
Q_UNUSED(em)
#endif
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::registerSgReadyAdapter(SgReadyAdapter *adapter)
{
const QString id = adapter->descriptor().id;
if (m_sgReadyAdapters.contains(id)) {
qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] SgReadyAdapter déjà enregistré:" << id;
return;
}
adapter->setParent(this);
m_sgReadyAdapters[id] = adapter;
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] SgReadyAdapter enregistré:" << adapter->descriptor().label;
}
void EnergyArbitrator::registerEtmVariableLoadAdapter(EtmVariableLoadAdapter *adapter)
{
const QString id = adapter->descriptor().id;
if (m_loadAdapters.contains(id)) {
qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] charge pilotée déjà enregistrée:" << id;
return;
}
adapter->setParent(this);
m_loadAdapters[id] = adapter; // upcast EtmVariableLoadAdapter* → ILoadAdapter*
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] etmvariableload enregistré:" << adapter->descriptor().label;
}
void EnergyArbitrator::registerRelayRouter(RelayRouter *adapter)
{
const QString id = adapter->descriptor().id;
if (m_loadAdapters.contains(id)) {
qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] charge pilotée déjà enregistrée:" << id;
return;
}
adapter->setParent(this);
m_loadAdapters[id] = adapter; // upcast RelayRouter* → ILoadAdapter*
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] relay-router enregistré:" << adapter->descriptor().label;
}
void EnergyArbitrator::setLoadConfigStore(LoadConfigStore *store)
{
m_loadConfigStore = store;
if (!store)
return;
connect(store, &LoadConfigStore::changed, this, &EnergyArbitrator::rebuildLoadAdapters);
rebuildLoadAdapters(); // construction initiale depuis la config persistée
}
void EnergyArbitrator::rebuildLoadAdapters()
{
// Purge des adaptateurs construits depuis la config précédente (ILoadAdapter n'est pas QObject :
// les concrets le sont — dynamic_cast pour deleteLater).
for (ILoadAdapter *a : m_loadAdapters)
if (QObject *o = dynamic_cast<QObject *>(a))
o->deleteLater();
m_loadAdapters.clear();
if (!m_loadConfigStore)
return;
for (const LoadConfig &c : m_loadConfigStore->configs()) {
// enabled==false : rôle déclaré mais EXCLU de l'arbitrage (contrat §9) — pas d'adaptateur.
if (!c.enabled())
continue;
LoadNeeds needs;
needs.dailyDeadline = c.needs().dailyDeadline();
needs.minEnergyWhPerDay = c.needs().minEnergyWhPerDay();
// La distinction de TYPE vit ICI (rév. 3) ; au-dessus, tout est ILoadAdapter (Setpoint W).
ILoadAdapter *adapter = nullptr;
if (c.isRelayRouter()) {
adapter = new RelayRouter(m_tm, c.id(), c.label(), c.relaysList(),
c.minOnS(), c.minOffS(), c.priority(), needs, this);
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] relay-router construit depuis config:"
<< c.label() << "(" << c.relaysList().count() << "relais)";
} else {
adapter = new EtmVariableLoadAdapter(m_tm, c.id(), c.label(), c.powerLevelsInt(),
c.maxPowerW(), c.priority(), needs, this);
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] etmvariableload construit depuis config:"
<< c.label() << "(" << (c.isDynamic() ? "dynamic" : "fixed") << ")";
}
m_loadAdapters[c.id()] = adapter;
}
qCInfo(dcNymeaEnergy()) << "[EnergyArbitrator]" << m_loadAdapters.count()
<< "charge(s) pilotée(s) active(s) (config).";
}
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, currentDateTime); // PAC (kind==State) → m_sgReadyAdapters
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 --- (now = ctx.timestamp : source unique des verrous)
for (auto it = m_adapters.constBegin(); it != m_adapters.constEnd(); ++it)
ctx.loads.append(it.value()->toLoadContext(now));
// --- loads[] : charges pilotées en watts (relay-router + etmvariableload), polymorphes ---
for (auto it = m_loadAdapters.constBegin(); it != m_loadAdapters.constEnd(); ++it)
ctx.loads.append(it.value()->toLoadContext(now));
// --- loads[] : SG-Ready adapters (PAC) ---
for (auto it = m_sgReadyAdapters.constBegin(); it != m_sgReadyAdapters.constEnd(); ++it)
ctx.loads.append(it.value()->toLoadContext(now));
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, const QDateTime &now)
{
for (const LoadAction &action : slot.actions) {
// L'adaptateur applique, écrête et verrouille — il ne décide pas (règle 2).
if (action.kind == LoadAction::State) {
SgReadyAdapter *adapter = m_sgReadyAdapters.value(action.loadId);
if (adapter)
adapter->applyAction(action, now);
else
qCWarning(dcNymeaEnergy()) << "[Arbitre] action State sans adaptateur SG-Ready:" << action.loadId;
} else if (action.kind == LoadAction::Setpoint) {
// Dispatch AGNOSTIQUE au type : routage par loadId, le polymorphisme ILoadAdapter
// absorbe (relay-router OU etmvariableload). Un Setpoint EV n'est PAS dans cette table
// → ignoré ici (dispatché par adjustEvChargers() amont).
ILoadAdapter *adapter = m_loadAdapters.value(action.loadId);
if (adapter)
adapter->applyAction(action, now);
}
}
}
void EnergyArbitrator::onMeterWatchdogTick()
{
// Déclencheur réel (QTimer, horloge murale) → délègue à la logique injectable.
evaluateMeterFreshness(QDateTime::currentDateTime());
}
void EnergyArbitrator::recordMeterUpdate(const QDateTime &now)
{
m_lastMeterUpdate = now;
if (m_degradedMode) {
qCInfo(dcNymeaEnergy()) << "[Arbitre] Compteur de nouveau actif — sortie du mode dégradé L2.";
m_degradedMode = false;
emit chargingSchedulesChanged(); // pousse degradedMode=false (planif reprend au cycle suivant)
}
}
void EnergyArbitrator::evaluateMeterFreshness(const QDateTime &now)
{
if (!m_lastMeterUpdate.isValid())
return; // Aucune mesure reçue (démarrage) — pas de dégradé (invariant root meter absent).
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;
emit chargingSchedulesChanged(); // pousse degradedMode=true (notification client L2)
const QString reason =
QStringLiteral("Compteur muet depuis >90 s — consigne de repli (L2 watchdog)");
// Charges pilotées en watts (relay-router ET etmvariableload) : repli Setpoint(0) force=true
// (contrat rév. 3 §9). Boucle sur TOUS les ILoadAdapter — ne PAS oublier un type, sinon des
// relais ECS resteraient allumés en compteur muet (trou T2 recréé). force=true → pour le
// relay-router, coupe TOUS les relais (bypass minOn/minOff) ; pour etmvariableload, setpoint 0.
for (ILoadAdapter *adapter : m_loadAdapters) {
LoadAction la;
la.loadId = adapter->descriptor().id;
la.kind = LoadAction::Setpoint;
la.powerW = 0;
la.force = true;
la.reason = reason;
adapter->applyAction(la, now);
}
// 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 (PAC) : repli en état 2 (NORMAL — mains off), JAMAIS état 1 (blocage).
// Sous compteur muet on cesse de piloter : la PAC chauffe selon son propre thermostat
// (la bloquer = maison qui ne chauffe plus sans raison visible). force=true → bypass minStateHold.
for (SgReadyAdapter *adapter : m_sgReadyAdapters) {
LoadAction la;
la.loadId = adapter->descriptor().id;
la.kind = LoadAction::State;
la.state = 2;
la.force = true;
la.reason = reason;
adapter->applyAction(la, now);
}
// Batterie (aucune charge réseau) : repli ajouté avec son adaptateur (3f).
}