Patrick Schurig 74465375c4 feat(telemetry): lot B — frontière de télémétrie d'arbitrage + o:domain — 1.15.2+etm14
Constat d'origine, rejoué et daté au banc : 90 s d'écoute, arbitre actif, ~2 kW exportés
→ ZÉRO notification NymeaEnergy.*. L'état runtime de l'arbitre (allocation, budget, verrous,
paliers, disponibilité, motifs) ne franchissait aucune frontière RPC : il vivait exclusivement
dans qCInfo(dcNymeaEnergy). Expliquer les décisions au client n'avait donc aucun transport.
Après déploiement, même geste : 13 notifications en 480 s.

Aucune phrase composée par la box. La locale nymea est PAR CONNEXION (JSONRPC.Hello o:locale),
et NymeaEnergyJsonHandler est un JsonHandler sans pluginId — donc hors du catalogue de
traduction : rien ne rattraperait une phrase française émise là. La box transporte un CODE et
des PARAMÈTRES ; la phrase est fabriquée par le client.

LoadAction::reason devient DecisionReason{code, params}. La ligne française du journal est
RENDUE depuis ce couple par renderFr(), source unique — jamais maintenue en parallèle. Les 19
codes sont EXTRAITS des sites existants, pas inventés ; les motifs d'ECS-309 et ECS-309-b sont
repris mot pour mot. Prouvé par comparaison de traces (1726 lignes de journal identiques en
simulation), par une table d'or qui échoue au premier code sans phrase attendue, et au banc sur
7 motifs distincts. Le libellé de la charge n'entre pas dans la charge utile : il est substitué
au RENDU, par l'appelant qui le connaît.

Les verrous sortent en SECONDES RESTANTES, pas en watts. Ceci n'enfreint pas ECS-306 : cette
exigence interdit qu'un index de PALIER franchisse la frontière d'ALLOCATION ; une valeur
d'AFFICHAGE sortante est un autre chemin. Le mécanisme sort en union discriminée
(relay|variable|sgReady), toutes clés internes optionnelles au schéma (LM-302).

ILoadAdapter::runtimeView() est PURE VIRTUELLE à dessein — mais la pure virtuelle force à
RÉPONDRE, pas à répondre quelque chose. testRuntimeViewIsSubstantial() est le second verrou :
les quatre adaptateurs, amenés dans un état non trivial, doivent rendre les valeurs runtime
réelles, les verrous en secondes ET leur expiration, et les codes de défaut. Deux mutants
« vue vide » introduits puis retirés : le test échoue sur chacun.

budget publié : surplusW, evReservedW, recreditedW, allocatedW, remainingW, avec l'identité
surplus + recrédit − alloué == restant. recreditedW n'est pas décoratif : sans lui l'identité
serait fausse DÈS QU'UNE CHARGE CONSOMME, et le client chercherait un écart inexplicable. Le
banc l'a montré aussitôt — surplusW −250 W avec recreditedW 3000 W — rendant lisible l'artefact
de banc documenté dans AGENTS.md (relais sans mesure + rootmeter aveugle).

Champs omis, jamais nuls : timestamp absent = aucun cycle depuis le démarrage ; budget absent =
mode dégradé, planification suspendue. Publier un zéro se lirait « exécuté, rien à allouer ».

Cadence : changement qualitatif ou variation d'allocation > 50 W, plus un battement de 60 s.
C'est le battement qui rend un arbitre FIGÉ détectable — les notifications continuent tandis que
le timestamp cesse d'avancer. Observé 3 fois au banc. Volume mesuré : ≈ 1,7 Mo/jour, un client.

GetChargingSchedules rend enfin degradedMode : il était POUSSÉ en notification seule, donc
illisible par un client connecté après la bascule (famille ECS-411). Règles absolues 11 et 12
ajoutées à AGENTS.md.

LoadConfig : champ optionnel domain (ecs·ev·heating·hvac·battery), PURE métadonnée d'intention
que l'arbitre ne lit pas. Absente ou vide = non classée. Forme exacte découverte au banc et
corrigée dans la doc ET le test : GetLoadConfig sérialise via le méta-objet, donc la propriété
sort TOUJOURS, à "" — comme sgReady:{} ou powerLevels:[]. C'est le corollaire LM-302.

Règle 7-c, deux silences fermés : les retours idempotents du RelayRouter et du SgReadyAdapter
étaient MUETS — « consigne reçue, palier déjà bon » ne se distinguait pas de « l'action n'est
jamais parvenue à l'adaptateur », et c'est le cas ordinaire qu'on vient vérifier.

⚠️ Porté au contrat : loads[] n'est PAS en correspondance 1:1 avec GetLoadConfig. « pac-terrain »
est la PAC SG-Ready CODÉE EN DUR d'energypluginnymea.cpp (K1/K2 = Relay4/Relay5, {3:1500,
4:3000}) — arbitrée et publiée, mais inconfigurable : ni rang, ni domain, ni enabled:false.
INTERFACE.md porte la règle de détection pour l'app. Son sort reste À TRANCHER (RELEVE_LOTB §8-bis b).

Déploiement : la recette de TEST_TERRAIN.md §b portait un glob « dpkg -i /tmp/…_*_arm64.deb »,
de la même famille que celui qui a rétrogradé .75 de +etm12 à +etm9. Remplacée par
etm-powersync-deploy/deploy.sh (archivage avant dépôt, installation par nom, refus de
rétrogradation, contrôle de version après coup).

Vérifié sur .75 (1.15.2+etm13 puis +etm14) : critères 1, 2, 4, 5, 6, 7, 8 du brief. Critère 3
(épisode dégradé) vérifié en simulation seulement — le déclencher demande de faire taire un
simulateur SunSpec partagé, hors périmètre. Relevé complet, critère par critère, avec ce qui n'a
PAS été vérifié sur la machine et pourquoi : docs/RELEVE_LOTB.md.

Suites : simulation 38/38, charging 46/46, spotmarket 32/32, y compris en build DEBUG où les
Q_ASSERT_X du validateur JSON-RPC sont actifs (le schéma déclaré correspond donc à la charge
utile émise, retours ET notifications). Cross-arm64 : 0 avertissement. Doxygen : 10, inchangé.

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

782 lines
37 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::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 &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); // PAC (kind==State) → m_sgReadyAdapters
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[] : 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)
// 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 };
// 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).
// 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)
adapter = m_sgReadyAdapters.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);
}
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.";
for (auto it = m_sgReadyAdapters.constBegin(); it != m_sgReadyAdapters.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::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);
}
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));
QStringList sgIds = m_sgReadyAdapters.keys();
sgIds.sort();
for (const QString &id : sgIds)
entree(id, m_sgReadyAdapters.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;
}