Patrick Schurig bbb3492c2c feat(lot E §11): compteur et sonde par charge, publiés et sans effet sur les décisions
Deux champs optionnels de même forme sur LoadConfig — o:meterThingId, o:sensorThingId —
et leur publication en télémétrie. Rien d'autre : l'arbitre ne s'en sert pas pour
décider, et le §12 comme le §10 restent à faire.

« Aucun effet sur les décisions » est vrai par CONSTRUCTION, pas par discipline : les
adaptateurs ne voient jamais ces Things. attachMeasurements() les résout au moment de
publier, dans les deux chemins de télémétrie — y compris le mode dégradé, où un
exploitant veut précisément voir ce que la charge fait pendant que l'arbitre ne décide
plus rien.

measuredW est publié À CÔTÉ de allocatedW et jamais fondu dedans : l'un est ce que le
waterfall a alloué, l'autre ce qu'un compteur a vu. Les confondre rendrait la somme des
allocations irréconciliable avec budget.allocatedW — et injecter une mesure en retard
sur la commande réintroduirait l'oscillation que le recrédit a supprimée.

claimedThingIds() les exclut, et l'exclusion est maintenant commentée sur place : LM-201
vaut pour ce qui commande, ceux-ci mesurent. Sans ce commentaire, la symétrie avec
relays[] invitait à les ajouter — ce qui ferait refuser un compteur divisionnaire
partagé ou une sonde de pièce commune.

spec 0.2.1 : la précondition de livraison du §10 (extension du délestage au waterfall,
même lot ou avant, jamais après) est écrite comme CONDITION et non comme conseil — la
protection de surcharge ne déleste que les bornes, et des planchers éco qui
s'additionnent un matin sans soleil sur un branchement 6 kVA feraient disjoncter
l'installation. Et LM-1008 tranche la cible de confort : saisie en degrés, frontière de
l'arbitre en énergie, conversion à la couche domaine — et aucune conversion nécessaire
aujourd'hui, un comparateur suffit, c'est l'arrêt qui compte et pas le dosage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
2026-08-26 10:34:41 +02:00

786 lines
38 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/decisionreason.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
//! Battement de cœur de la télémétrie : période MAXIMALE entre deux notifications, même sans
//! changement. C'est ce qui permet au client de dater la dernière décision et donc de repérer
//! un arbitre figé — un état qui ne bouge pas n'émet rien de lui-même.
constexpr int TelemetryHeartbeatPeriodMs = 60 * 1000; // 60 s
//! Seuil d'allocation (W) en deçà duquel une variation ne déclenche PAS de notification.
//! Une charge à consigne continue module en permanence de quelques watts ; sans seuil, chaque
//! oscillation réveillerait tous les clients abonnés. 50 W est en dessous du plus petit palier
//! réel d'un ECS (≈ 500 W) et au-dessus du bruit de mesure d'un routeur.
constexpr double TelemetryAllocationThresholdW = 50.0;
}
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();
// Battement de cœur de la télémétrie. QTimer d'horloge murale, comme le watchdog et pour la
// MÊME raison : il doit continuer à battre quand le cycle d'arbitrage, lui, s'est arrêté.
// Un battement piloté par le cycle ne pourrait pas révéler l'arrêt du cycle.
m_telemetryHeartbeat = new QTimer(this);
m_telemetryHeartbeat->setInterval(TelemetryHeartbeatPeriodMs);
connect(m_telemetryHeartbeat, &QTimer::timeout, this, &EnergyArbitrator::publishTelemetryHeartbeat);
m_telemetryHeartbeat->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::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;
}
// On ne compare pas les LISTES de relais, on compare la TABLE qu'elles produisent.
//
// Comparer index par index déclarait « matériel changé » sur un simple
// réordonnancement — mêmes Things, mêmes puissances — et coûtait donc une
// reconstruction complète : contacts ouverts par l'état sûr, verrous minOn/minOff
// réarmés à froid. Un installateur qui remonte une ligne dans l'app ne s'attend pas à
// couper sa charge, et annuler son geste en coûtait une seconde.
//
// Comparer les listes comme des ENSEMBLES aurait été faux dans l'autre sens :
// à somme égale la table retient la première combinaison rencontrée, donc échanger
// deux relais de MÊME puissance change le contact qui sert ce palier — autre
// contacteur qui s'use, autre résistance qui chauffe. Un ensemble ne le verrait pas.
//
// La table tranche les deux cas d'un coup, et sans pari : elle EST ce que le routeur
// exécute. deriveStages() est l'implémentation unique, partagée avec la construction
// du routeur et la publication en télémétrie.
return RelayRouter::deriveStages(a.relaysList())
== RelayRouter::deriveStages(b.relaysList());
}
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()];
// Le LIBELLÉ compte ici, et nulle part ailleurs : il ne touche pas au matériel
// (donc pas à sameHardware()), mais il nomme la charge dans chaque ligne de
// journal. L'omettre de ce prédicat faisait compter un renommage seul comme
// « inchangée », l'adaptateur gardait son ancien nom, et le journal contredisait
// GetLoadConfig jusqu'à la prochaine reconstruction.
const bool soft = (old.label() != c.label())
|| (old.priority() != c.priority())
|| (old.needs().dailyDeadline() != c.needs().dailyDeadline())
|| (old.needs().minEnergyWhPerDay() != c.needs().minEnergyWhPerDay());
if (soft) {
existing->updateSoftConfig(c.label(), 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;
}
// 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 &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);
// Le LIBELLÉ n'est pas dans le motif : il est substitué AU RENDU (cf. decisionreason.h).
// On le prend dans le contexte du cycle, seul endroit qui connaisse toutes les charges.
QHash<QString, QString> libelles;
for (const LoadContext &lc : ctx.loads)
libelles.insert(lc.id, lc.label);
for (const LoadAction &action : slot.actions) {
qCInfo(dcNymeaEnergy()) << "[Arbitre]"
<< action.loadId << "→" << renderFr(action.reason, libelles.value(action.loadId))
<< "| 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); // charges arbitrées (Setpoint W et State)
adjustEvChargers(currentDateTime); // EV (kind==Setpoint) → proxy amont jusqu'à 3g
// 8 : frontière de télémétrie. APRÈS le dispatch, jamais avant : la charge utile de mécanisme
// (palier appliqué, état PAC) doit refléter ce qui a réellement été envoyé au matériel, pas
// ce que la stratégie espérait. Publier avant produirait un instantané qu'aucun relais ne
// confirme — la même famille de défaut qu'annoncer une puissance non appliquée (ECS-410).
buildTelemetry(plan, slot, currentDateTime);
publishTelemetry(false);
}
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[] : TOUTES les charges arbitrées, polymorphes (relay-router, etmvariableload,
// sg-ready) — une seule table, toutes construites depuis la configuration persistée. ---
for (auto it = m_loadAdapters.constBegin(); it != m_loadAdapters.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).
// Dispatch AGNOSTIQUE au mécanisme : routage par loadId, le polymorphisme ILoadAdapter
// absorbe (relay-router, etmvariableload OU sg-ready). Il n'existe plus qu'UNE table de
// charges arbitrées, toutes construites depuis LoadConfigStore — c'est ce qui leur vaut
// ECS-412 (reconstruction incrémentale) et ECS-413 (état sûr) sans code dédié, et ce
// qui rend l'ensemble publié par GetLoadTelemetry égal à celui de GetLoadConfig.
// Un Setpoint EV n'est PAS dans cette table → ignoré ici (dispatché par
// adjustEvChargers() amont jusqu'à 3g).
if (action.kind == LoadAction::State || action.kind == LoadAction::Setpoint) {
if (ILoadAdapter *adapter = m_loadAdapters.value(action.loadId, nullptr))
adapter->applyAction(action, now);
else if (action.kind == LoadAction::State)
qCWarning(dcNymeaEnergy()) << "[Arbitre] action State sans adaptateur:" << action.loadId;
}
}
}
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)
// Symétrie de la bascule : la SORTIE se publie aussi tout de suite. La planification ne
// reprend qu'au cycle suivant ; sans cette republication, la télémétrie annoncerait
// encore degradedMode=true pendant une minute entière, après que le repli a été levé.
if (!m_telemetry.isEmpty()) {
m_telemetry.insert(QStringLiteral("degradedMode"), false);
publishTelemetry(true);
}
}
}
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 DecisionReason reason = { DecisionCode::DegradedL2 };
// Repli L2 de TOUTES les charges arbitrées, chacune dans SON état sûr — et c'est le kind
// DÉCLARÉ par l'adaptateur qui décide lequel, jamais une supposition sur sa classe.
//
// Ce point a été un TROU DE SÉCURITÉ, ouvert par LM-300 et refermé ici. Le repli envoyait
// un Setpoint(0) à toute la table des charges pilotées et un State(2) à une table SÉPARÉE
// réservée à la PAC codée en dur. Or une PAC construite depuis la CONFIG vit dans la
// première table : elle recevait donc un Setpoint, que SgReadyAdapter::applyAction()
// rejette d'entrée (kind non supporté) — et rien d'autre. Sous compteur muet, cette PAC-là
// restait en état 4 (forcé) indéfiniment, alors que SAFETY.md §L2 exige l'état 2. Le défaut
// était invisible : le motif DEGRADED_L2 apparaissait bien dans la télémétrie, seule
// l'écriture manquait.
//
// La leçon tient en une ligne : dès qu'un mécanisme rejoint la table commune, tout code qui
// fabrique une action DOIT lire \c supportedKinds au lieu de présumer du kind.
for (ILoadAdapter *adapter : m_loadAdapters) {
const LoadDescriptor d = adapter->descriptor();
LoadAction la;
la.loadId = d.id;
la.force = true;
la.reason = reason;
if (d.supportedKinds.contains(LoadAction::State)) {
// PAC SG-Ready : é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, cf. SAFETY.md).
la.kind = LoadAction::State;
la.state = 2;
} else if (d.supportedKinds.contains(LoadAction::Setpoint)) {
// Charges en watts : relay-router → tous relais ouverts ; etmvariableload → 0 W.
la.kind = LoadAction::Setpoint;
la.powerW = 0;
} else {
qCWarning(dcNymeaEnergy()) << "[Arbitre] Repli L2 : la charge" << d.id
<< "ne déclare aucun kind connu — AUCUN repli appliqué.";
continue;
}
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);
}
// Batterie (aucune charge réseau) : repli ajouté avec son adaptateur (3f).
// Reprise à froid (critère ECS-411) : un client qui se connecte APRÈS la bascule doit lire
// degradedMode. update() rend la main avant toute publication tant que le repli tient ; sans
// cet instantané forcé, la frontière de télémétrie resterait figée sur l'avant-dernier cycle
// NORMAL, et publierait donc l'inverse de la vérité.
buildDegradedTelemetry(now);
publishTelemetry(true);
}
// ---- Frontière de télémétrie (NymeaEnergy.GetLoadTelemetry / LoadTelemetryChanged) ---------
void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const QDateTime &now)
{
QVariantMap snap;
// ISO 8601 UTC — auto-descriptif et sans ambiguïté de fuseau. C'est la FIN du cycle
// d'arbitrage : deux notifications au même timestamp disent « rien n'a été décidé entre-temps ».
snap.insert(QStringLiteral("timestamp"), now.toUTC().toString(Qt::ISODate));
snap.insert(QStringLiteral("degradedMode"), m_degradedMode);
QVariantMap budget;
budget.insert(QStringLiteral("surplusW"), plan.budget.surplusW);
budget.insert(QStringLiteral("evReservedW"), plan.budget.evReservedW);
budget.insert(QStringLiteral("allocatedW"), plan.budget.allocatedW);
budget.insert(QStringLiteral("recreditedW"), plan.budget.recreditedW);
budget.insert(QStringLiteral("remainingW"), plan.budget.remainingW);
snap.insert(QStringLiteral("budget"), budget);
// On itère les ACTIONS, pas les tables d'adaptateurs : l'ordre du waterfall (rang 1 servi en
// premier) est porté par le slot, et QHash n'en garantit aucun. Un ordre instable ferait
// aussi diverger la comparaison de seuil d'un cycle à l'autre, pour rien.
QVariantList loads;
QSet<QString> couvertes;
for (const LoadAction &action : slot.actions) {
ILoadAdapter *adapter = m_loadAdapters.value(action.loadId, nullptr);
if (!adapter)
continue; // EV : décidé en amont du waterfall, hors de cette charge utile.
couvertes.insert(action.loadId);
QVariantMap entry;
entry.insert(QStringLiteral("loadId"), action.loadId);
// ALLOCATION du waterfall, celle qui a décrémenté le budget — pas la mesure. La mesure
// vit dans la charge utile de mécanisme ; les confondre rendrait la somme des
// allocations irréconciliable avec budget.allocatedW.
entry.insert(QStringLiteral("allocatedW"), action.estimatedPowerW);
entry.insert(QStringLiteral("available"), adapter->telemetry().available);
entry.insert(QStringLiteral("decision"), action.reason.toMap());
const LoadRuntimeView v = adapter->runtimeView(now);
if (!v.faultCode.isEmpty())
entry.insert(QStringLiteral("faultCode"), v.faultCode);
if (!v.lockKind.isEmpty()) {
QVariantMap lock;
lock.insert(QStringLiteral("kind"), v.lockKind);
lock.insert(QStringLiteral("remainingS"), v.lockRemainingS);
entry.insert(QStringLiteral("lock"), lock);
}
if (!v.mechanismKind.isEmpty()) {
QVariantMap mech = v.mechanism;
mech.insert(QStringLiteral("kind"), v.mechanismKind);
entry.insert(QStringLiteral("mechanism"), mech);
}
attachMeasurements(entry, action.loadId);
loads.append(entry);
}
// Règle 7-c — une charge arbitrée sans décision serait un TROU dans la charge utile, et le
// client ne pourrait pas distinguer « charge absente » de « charge oubliée du plan ». Cela ne
// devrait pas arriver (toute charge du contexte reçoit une action) : on l'annonce donc au
// journal plutôt que de le laisser passer sans bruit.
for (auto it = m_loadAdapters.constBegin(); it != m_loadAdapters.constEnd(); ++it)
if (!couvertes.contains(it.key()))
qCWarning(dcNymeaEnergy()) << "[Arbitre] Télémétrie : charge" << it.key()
<< "arbitrée mais SANS décision dans le plan — omise.";
snap.insert(QStringLiteral("loads"), loads);
m_telemetry = snap;
}
void EnergyArbitrator::attachMeasurements(QVariantMap &entry, const QString &loadId) const
{
if (!m_tm || !m_loadConfigStore)
return;
LoadConfig cfg;
bool trouvee = false;
for (const LoadConfig &c : m_loadConfigStore->configs()) {
if (c.id() == loadId) { cfg = c; trouvee = true; break; }
}
if (!trouvee)
return;
// Compteur — VÉRIFICATION seulement (LM-1101). Publiée à côté de allocatedW, jamais
// fondue dedans : la somme des allocations doit rester réconciliable avec
// budget.allocatedW, et une mesure en retard sur la commande la rendrait fausse.
if (!cfg.meterThingId().isEmpty()) {
Thing *t = m_tm->findConfiguredThing(ThingId(cfg.meterThingId()));
if (t && t->hasState("currentPower"))
entry.insert(QStringLiteral("measuredW"), t->stateValue("currentPower").toDouble());
}
// Sonde — lecture seule (LM-1103). Sa raison d'être immédiate est le diagnostic ; sa
// raison d'être durable est l'HISTORIQUE, sans lequel les seuils éco/confort du §10 ne
// se règleront jamais autrement qu'au doigt mouillé.
if (!cfg.sensorThingId().isEmpty()) {
Thing *t = m_tm->findConfiguredThing(ThingId(cfg.sensorThingId()));
if (t && t->hasState("temperature"))
entry.insert(QStringLiteral("temperatureC"), t->stateValue("temperature").toDouble());
}
}
void EnergyArbitrator::buildDegradedTelemetry(const QDateTime &now)
{
QVariantMap snap;
snap.insert(QStringLiteral("timestamp"), now.toUTC().toString(Qt::ISODate));
snap.insert(QStringLiteral("degradedMode"), true);
// PAS de « budget » : en mode dégradé la planification est SUSPENDUE, il n'existe aucun
// budget pour ce cycle. Publier des zéros se lirait « arbitrage exécuté, rien à allouer » —
// le contraire de la vérité. Le champ est omis, jamais nul (règle maison, cf. GetEnergyRatios).
QVariantList loads;
auto entree = [&](const QString &id, ILoadAdapter *adapter) {
QVariantMap entry;
entry.insert(QStringLiteral("loadId"), id);
entry.insert(QStringLiteral("allocatedW"), 0.0);
entry.insert(QStringLiteral("available"), adapter->telemetry().available);
entry.insert(QStringLiteral("decision"), DecisionReason(DecisionCode::DegradedL2).toMap());
const LoadRuntimeView v = adapter->runtimeView(now);
if (!v.faultCode.isEmpty())
entry.insert(QStringLiteral("faultCode"), v.faultCode);
if (!v.lockKind.isEmpty()) {
QVariantMap lock;
lock.insert(QStringLiteral("kind"), v.lockKind);
lock.insert(QStringLiteral("remainingS"), v.lockRemainingS);
entry.insert(QStringLiteral("lock"), lock);
}
if (!v.mechanismKind.isEmpty()) {
QVariantMap mech = v.mechanism;
mech.insert(QStringLiteral("kind"), v.mechanismKind);
entry.insert(QStringLiteral("mechanism"), mech);
}
// Publiées AUSSI en mode dégradé : c'est justement là qu'un exploitant veut voir ce
// que la charge fait vraiment, puisque l'arbitre, lui, ne décide plus rien.
attachMeasurements(entry, id);
loads.append(entry);
};
// Ordre STABLE : QHash n'en garantit aucun, et un instantané se compare.
QStringList ids = m_loadAdapters.keys();
ids.sort();
for (const QString &id : ids)
entree(id, m_loadAdapters.value(id));
snap.insert(QStringLiteral("loads"), loads);
m_telemetry = snap;
}
void EnergyArbitrator::publishTelemetry(bool force)
{
if (m_telemetry.isEmpty())
return; // aucun cycle encore exécuté — rien à dire, et rien à inventer.
if (!force && !telemetrySignificantlyDiffers(m_publishedTelemetry, m_telemetry))
return;
m_publishedTelemetry = m_telemetry;
m_lastTelemetryPublish = QDateTime::currentDateTime();
// Le battement est un PLANCHER de cadence, pas un ajout : toute notification relance son
// décompte. Sur une box qui cycle chaque minute, le total reste donc d'environ UNE
// notification par minute — ce que la cible RPi Zero 2W tient sans y penser. Porter cette
// règle par le timer plutôt que par un test d'horloge dans le battement le rend aussi
// injectable : un test peut le déclencher sans attendre soixante secondes.
if (m_telemetryHeartbeat)
m_telemetryHeartbeat->start();
emit loadTelemetryChanged(m_telemetry);
}
void EnergyArbitrator::publishTelemetryHeartbeat()
{
publishTelemetry(true);
}
bool EnergyArbitrator::telemetrySignificantlyDiffers(const QVariantMap &a, const QVariantMap &b)
{
if (a.isEmpty())
return true; // première publication
if (a.value(QStringLiteral("degradedMode")) != b.value(QStringLiteral("degradedMode")))
return true;
if (a.contains(QStringLiteral("budget")) != b.contains(QStringLiteral("budget")))
return true; // entrée ou sortie du mode où il n'y a pas de budget
const double allocA = a.value(QStringLiteral("budget")).toMap()
.value(QStringLiteral("allocatedW")).toDouble();
const double allocB = b.value(QStringLiteral("budget")).toMap()
.value(QStringLiteral("allocatedW")).toDouble();
if (qAbs(allocA - allocB) > TelemetryAllocationThresholdW)
return true;
const QVariantList la = a.value(QStringLiteral("loads")).toList();
const QVariantList lb = b.value(QStringLiteral("loads")).toList();
if (la.size() != lb.size())
return true;
for (int i = 0; i < la.size(); ++i) {
const QVariantMap ea = la.at(i).toMap();
const QVariantMap eb = lb.at(i).toMap();
// Identité, disponibilité, défaut, NATURE du verrou, mécanisme, CODE du motif : tout
// changement qualitatif se notifie immédiatement.
for (const char *k : {"loadId", "available", "faultCode"})
if (ea.value(QLatin1String(k)) != eb.value(QLatin1String(k)))
return true;
if (ea.value(QStringLiteral("lock")).toMap().value(QStringLiteral("kind"))
!= eb.value(QStringLiteral("lock")).toMap().value(QStringLiteral("kind")))
return true; // les SECONDES restantes, elles, décroissent en continu : ignorées.
if (ea.value(QStringLiteral("mechanism")).toMap().value(QStringLiteral("kind"))
!= eb.value(QStringLiteral("mechanism")).toMap().value(QStringLiteral("kind")))
return true;
if (ea.value(QStringLiteral("decision")).toMap().value(QStringLiteral("code"))
!= eb.value(QStringLiteral("decision")).toMap().value(QStringLiteral("code")))
return true; // les PARAMÈTRES du motif portent le budget, qui bouge à chaque cycle.
if (qAbs(ea.value(QStringLiteral("allocatedW")).toDouble()
- eb.value(QStringLiteral("allocatedW")).toDouble())
> TelemetryAllocationThresholdW)
return true;
}
return false;
}