LM-301 vérifié à l'usage : brancher le mécanisme n'a touché qu'une branche de fabrique dans rebuildLoadAdapters(), un champ optionnel au schéma RPC, et la comparaison sameHardware() — la charge utile fait partie du « matériel », un état ré-encodé impose une reconstruction. Aucun code d'arbitrage n'a bougé. La PAC de configuration vit dans m_loadAdapters, la MÊME table que les autres charges. Elle hérite donc d'ECS-412 (reconstruction incrémentale) et d'ECS-413 (état sûr à la désactivation, qui vaut ici ÉTAT 2 et non « contacts ouverts ») sans code dédié. Le dispatch des actions route par loadId d'abord, l'enregistrement en dur ne servant plus que de repli. Garde neuve, créée par cette configurabilité même : une PAC de config peut viser les mêmes contacts que la PAC de banc câblée dans energypluginnymea.cpp. Deux adaptateurs sur un contact, c'est deux commandes contradictoires et une charge comptée deux fois dans le budget (règle absolue 1). La configuration fait autorité : l'exemplaire en dur est retiré — sans mise en état sûr, les contacts ayant un propriétaire légitime. testSgReadyFromConfig couvre les deux refus (sans état 2, charges utiles mélangées), le round-trip par le store, le pilotage, et la désactivation vers l'état 2. Suite complète : 111 tests, 0 échec. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
526 lines
23 KiB
C++
526 lines
23 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
|
|
}
|
|
|
|
bool EnergyArbitrator::sameHardware(const LoadConfig &a, const LoadConfig &b)
|
|
{
|
|
// Champs dont un changement impose une VRAIE reconstruction : ils déterminent la table
|
|
// de paliers, le câblage ou les verrous. Tout le reste (rang, besoins, libellé) se met
|
|
// à jour en place.
|
|
if (a.adapter() != b.adapter() || a.mode() != b.mode())
|
|
return false;
|
|
if (a.minOnS() != b.minOnS() || a.minOffS() != b.minOffS())
|
|
return false;
|
|
if (a.maxPowerW() != b.maxPowerW() || a.powerLevelsInt() != b.powerLevelsInt())
|
|
return false;
|
|
|
|
// LM-300 — la charge utile fait partie du « matériel » : un état SG-Ready ré-encodé ou
|
|
// une estimation revue change ce que l'adaptateur commande, donc impose une reconstruction.
|
|
const LoadConfigSgReady sa = a.sgReadyPayload();
|
|
const LoadConfigSgReady sb = b.sgReadyPayload();
|
|
if (sa.minStateHoldS != sb.minStateHoldS || sa.states.size() != sb.states.size())
|
|
return false;
|
|
for (int i = 0; i < sa.states.size(); ++i) {
|
|
if (sa.states.at(i).state != sb.states.at(i).state
|
|
|| sa.states.at(i).relays != sb.states.at(i).relays
|
|
|| !qFuzzyCompare(1 + sa.states.at(i).estimatedPowerW, 1 + sb.states.at(i).estimatedPowerW))
|
|
return false;
|
|
}
|
|
|
|
const QList<LoadConfigRelay> ra = a.relaysList();
|
|
const QList<LoadConfigRelay> rb = b.relaysList();
|
|
if (ra.size() != rb.size())
|
|
return false;
|
|
for (int i = 0; i < ra.size(); ++i)
|
|
if (ra.at(i).thingId != rb.at(i).thingId || ra.at(i).powerW != rb.at(i).powerW)
|
|
return false;
|
|
return true;
|
|
}
|
|
|
|
bool EnergyArbitrator::clearLoadFault(const QString &loadId)
|
|
{
|
|
ILoadAdapter *adapter = m_loadAdapters.value(loadId, nullptr);
|
|
if (!adapter) {
|
|
qCWarning(dcNymeaEnergy()) << "[Arbitre] ClearLoadFault : charge inconnue" << loadId;
|
|
return false;
|
|
}
|
|
qCInfo(dcNymeaEnergy()) << "[Arbitre] ClearLoadFault demandé par l'opérateur pour" << loadId;
|
|
adapter->clearFault();
|
|
return true;
|
|
}
|
|
|
|
void EnergyArbitrator::rebuildLoadAdapters()
|
|
{
|
|
if (!m_loadConfigStore) {
|
|
for (ILoadAdapter *a : m_loadAdapters)
|
|
if (QObject *o = dynamic_cast<QObject *>(a))
|
|
o->deleteLater();
|
|
m_loadAdapters.clear();
|
|
m_builtFrom.clear();
|
|
return;
|
|
}
|
|
|
|
// ECS-412 — reconstruction INCRÉMENTALE. Détruire un adaptateur réarme ses verrous : sur
|
|
// un ballon thermodynamique à minOn de 300-600 s, un client qui réordonne ses priorités
|
|
// depuis l'app pourrait faire court-cycler son compresseur. On ne reconstruit donc que
|
|
// ce dont le MATÉRIEL a changé ; le reste est mis à jour en place.
|
|
QHash<QString, ILoadAdapter *> kept;
|
|
QHash<QString, LoadConfig> keptFrom;
|
|
int created = 0, updated = 0, reused = 0;
|
|
|
|
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();
|
|
|
|
ILoadAdapter *existing = m_loadAdapters.value(c.id(), nullptr);
|
|
if (existing && m_builtFrom.contains(c.id()) && sameHardware(m_builtFrom.value(c.id()), c)) {
|
|
// Matériel inchangé : on GARDE l'adaptateur — donc m_lastSwitch et le palier
|
|
// courant — et on ne met à jour que ce qui ne touche pas au matériel.
|
|
const LoadConfig &old = m_builtFrom[c.id()];
|
|
const bool soft = (old.priority() != c.priority())
|
|
|| (old.needs().dailyDeadline() != c.needs().dailyDeadline())
|
|
|| (old.needs().minEnergyWhPerDay() != c.needs().minEnergyWhPerDay());
|
|
if (soft) {
|
|
existing->updateSoftConfig(c.priority(), needs);
|
|
++updated;
|
|
} else {
|
|
++reused;
|
|
}
|
|
kept[c.id()] = existing;
|
|
keptFrom[c.id()] = c;
|
|
m_loadAdapters.remove(c.id()); // sorti de la table : ne sera pas détruit plus bas
|
|
continue;
|
|
}
|
|
|
|
// La distinction de TYPE vit ICI (rév. 3) ; au-dessus, tout est ILoadAdapter (Setpoint W).
|
|
ILoadAdapter *adapter = nullptr;
|
|
if (c.isSgReady()) {
|
|
// LM-300/301 — une branche de fabrique par mécanisme, rien d'autre à toucher.
|
|
const LoadConfigSgReady p = c.sgReadyPayload();
|
|
QHash<int, QList<QString>> stateRelays;
|
|
QHash<int, double> estimated;
|
|
for (const LoadConfigSgReadyState &e : p.states) {
|
|
stateRelays.insert(e.state, e.relays);
|
|
estimated.insert(e.state, e.estimatedPowerW);
|
|
}
|
|
adapter = new SgReadyAdapter(m_tm, c.id(), c.label(), stateRelays, estimated,
|
|
p.minStateHoldS, c.priority(), this);
|
|
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] sg-ready construit depuis config:"
|
|
<< c.label() << "(" << p.states.count() << "états )";
|
|
} else 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") << ")";
|
|
}
|
|
kept[c.id()] = adapter;
|
|
keptFrom[c.id()] = c;
|
|
++created;
|
|
}
|
|
|
|
// LM-300 — la configuration fait autorité sur l'enregistrement en dur. Depuis que le
|
|
// mécanisme sg-ready se configure, une PAC de config peut viser les MÊMES contacts que la
|
|
// PAC de banc câblée dans energypluginnymea.cpp. Deux adaptateurs sur un même contact,
|
|
// c'est deux commandes contradictoires ET une charge comptée deux fois dans le budget
|
|
// (règle absolue 1). On retire donc l'exemplaire en dur, sans le mettre en état sûr :
|
|
// les contacts ont un nouveau propriétaire légitime, les couper serait le contredire.
|
|
QSet<QString> revendiquesParConfig;
|
|
for (ILoadAdapter *a : kept) {
|
|
if (auto *sg = dynamic_cast<SgReadyAdapter *>(a))
|
|
revendiquesParConfig.unite(sg->claimedRelays());
|
|
}
|
|
if (!revendiquesParConfig.isEmpty()) {
|
|
for (const QString &id : m_sgReadyAdapters.keys()) {
|
|
SgReadyAdapter *dur = m_sgReadyAdapters.value(id);
|
|
if (!dur || (dur->claimedRelays() & revendiquesParConfig).isEmpty())
|
|
continue;
|
|
qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] SgReadyAdapter en dur" << id
|
|
<< "retiré : ses contacts sont désormais commandés par une "
|
|
"charge sg-ready de configuration.";
|
|
m_sgReadyAdapters.remove(id);
|
|
dur->deleteLater();
|
|
}
|
|
}
|
|
|
|
// ECS-413 — ce qui reste dans m_loadAdapters n'est plus référencé par la config :
|
|
// désactivé (enabled: false) ou retiré. On l'amène à son ÉTAT SÛR **avant** de le
|
|
// détruire, sans quoi le matériel resterait dans son dernier état commandé — constaté
|
|
// au banc le 2026-08-09, trois relais laissés fermés juste avant un câblage.
|
|
//
|
|
// Ne s'applique QU'ICI : rien de tel à l'arrêt du plugin ni au redémarrage de nymead,
|
|
// où l'état doit être conservé (c'est ce qu'ECS-411 relit). La désactivation est un acte
|
|
// délibéré de l'opérateur ; un redémarrage n'en est pas un.
|
|
//
|
|
// Les charges CONSERVÉES ne passent pas par ici : un simple changement de rang ne coupe
|
|
// donc rien, et ECS-412 reste entier.
|
|
const int removed = m_loadAdapters.count();
|
|
const QDateTime now = QDateTime::currentDateTime();
|
|
for (ILoadAdapter *a : m_loadAdapters) {
|
|
a->applySafeState(now);
|
|
QObject *o = dynamic_cast<QObject *>(a);
|
|
if (!o)
|
|
continue;
|
|
// ECS-410 : les écritures sont asynchrones et `this` sert de contexte de connexion.
|
|
// Détruire tout de suite couperait les acquittements en vol — on ne saurait donc pas
|
|
// si la mise en sécurité a abouti, précisément dans le cas où elle échoue.
|
|
// deleteLater() laisse le cycle d'événements les délivrer d'abord.
|
|
o->deleteLater();
|
|
}
|
|
|
|
m_loadAdapters = kept;
|
|
m_builtFrom = keptFrom;
|
|
|
|
qCInfo(dcNymeaEnergy()) << "[EnergyArbitrator]" << m_loadAdapters.count()
|
|
<< "charge(s) pilotée(s) active(s) (config) —" << created << "créée(s),"
|
|
<< updated << "mise(s) à jour en place," << reused << "inchangée(s),"
|
|
<< removed << "retirée(s).";
|
|
}
|
|
|
|
void EnergyArbitrator::update(const QDateTime ¤tDateTime)
|
|
{
|
|
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) {
|
|
// Routage par loadId d'abord : un SG-Ready construit depuis la config (LM-300) vit
|
|
// dans la MÊME table que les autres charges — c'est ce qui lui vaut ECS-412
|
|
// (reconstruction incrémentale) et ECS-413 (état sûr) sans code dédié.
|
|
if (ILoadAdapter *adapter = m_loadAdapters.value(action.loadId, nullptr)) {
|
|
adapter->applyAction(action, now);
|
|
continue;
|
|
}
|
|
// Repli : PAC enregistrée en dur par registerSgReadyAdapter() (hors config).
|
|
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).
|
|
}
|