ClearLoadFault est le SEUL levier de reprise à distance : ECS-410 pose un verrou collant que seuls ce RPC ou une reconstruction lèvent. L'arbitre journalisait la demande de l'opérateur, jamais son issue — si bien qu'appelé depuis l'étranger, on ne distinguait pas « le défaut a été levé » de « il n'y en avait pas » de « l'appel n'a pas atteint l'adaptateur ». L'opérateur relance trois fois et conclut que le système est cassé. L'issue est écrite dans l'ARBITRE, pas dans chaque adaptateur : une ligne là couvre les quatre d'un coup et ne peut pas être oubliée par un adaptateur futur ; répétée dans chacun, elle manquerait le cinquième. Les trois cas sont distincts à la lecture. ILoadAdapter::clearFault() rend un bool au lieu de void. Le type de retour force chaque implémentation, présente et future, à répondre à la question « un défaut a-t-il réellement été levé ». EvAdapter, qui n'a pas de mécanisme de défaut, le dit désormais explicitement plutôt que par un corps vide. Règle 7-d — un refus doit être au moins aussi visible que l'application correspondante. Le refus par verrou minStateHold de SgReadyAdapter était en qCDebug quand l'application, dix lignes plus bas, était en qCInfo. L'asymétrie est le défaut, pas le niveau : un resserrement de la journalisation — et on vient d'en faire un pour tenir deux semaines — aurait fait disparaître le refus AVANT le succès, ne laissant au journal que les décisions abouties. Or un refus est plus informatif qu'une application réussie : il dit qu'une décision a été prise et n'a pas été exécutée. Vérification faite sur les autres adaptateurs : c'était le seul cas. Les qCDebug restants sont symétriques, et le marqueur L2 par cycle reste en debug à dessein, son entrée étant en qCWarning et sa sortie en qCInfo. Le retour idempotent du routeur reste DIFFÉRÉ, et ira en qCDebug : c'est le cas le plus fréquent — plusieurs milliers de lignes par jour sur un budget ramené à 6,5 Mo — et son ambiguïté est partiellement couverte, le scheduler journalisant sa décision et l'armement à froid s'exécutant avant ce retour. Faible gain, coût réel : l'inverse des deux cas corrigés. Le test porte sur le TEXTE des trois issues et vérifie qu'elles sont deux à deux distinctes. Suite complète : 112 tests, 0 échec. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
545 lines
25 KiB
C++
545 lines
25 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)
|
|
{
|
|
// Règle 7-c — l'ISSUE est journalisée ici, au niveau de l'arbitre, et pas dans chaque
|
|
// adaptateur. Une ligne écrite là couvre les quatre adaptateurs d'un coup et ne peut pas
|
|
// être oubliée par un adaptateur futur ; répétée dans chacun, elle manquerait le cinquième.
|
|
//
|
|
// Pourquoi cet appel mérite une issue plutôt qu'une simple trace de demande : c'est le
|
|
// SEUL levier de reprise à distance. ECS-410 pose un verrou collant que seuls ce RPC ou
|
|
// une reconstruction lèvent. Sans issue lisible, un opérateur à distance ne distingue pas
|
|
// « défaut levé » de « aucun défaut à lever » de « l'appel n'a pas atteint l'adaptateur » :
|
|
// il relance, puis conclut que le système est cassé.
|
|
ILoadAdapter *adapter = m_loadAdapters.value(loadId, nullptr);
|
|
if (!adapter) {
|
|
qCWarning(dcNymeaEnergy()) << "[Arbitre] ClearLoadFault" << loadId
|
|
<< "→ SANS EFFET : adaptateur introuvable "
|
|
"(identifiant inconnu ou charge désactivée).";
|
|
return false;
|
|
}
|
|
|
|
qCInfo(dcNymeaEnergy()) << "[Arbitre] ClearLoadFault demandé par l'opérateur pour" << loadId;
|
|
const bool leve = adapter->clearFault();
|
|
if (leve)
|
|
qCInfo(dcNymeaEnergy()) << "[Arbitre] ClearLoadFault" << loadId
|
|
<< "→ défaut LEVÉ ; la charge revient à l'arbitrage.";
|
|
else
|
|
qCInfo(dcNymeaEnergy()) << "[Arbitre] ClearLoadFault" << loadId
|
|
<< "→ sans objet : AUCUN défaut à lever, la charge n'était "
|
|
"pas verrouillée.";
|
|
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).
|
|
}
|