Patrick Schurig c2f08cb804 fix(3g-1): une borne sans véhicule branché est hors arbitrage
Trouvé au banc le 2026-08-27, en exerçant 3g-2 (docs/RELEVE_3g2.md §3.1).

Une wallbox en Eco, voiture assignée, mais `pluggedIn: false`, s'est vu allouer
4 853 W qu'elle ne pouvait pas tirer — `currentPower: 0`, `charging: false`. La
PAC derrière elle n'a survécu que sur son propre recrédit. Trois cycles
consécutifs : 2 350 / 4 853 / 5 464 W retirés du budget et donnés à personne.

evParticipatesInWaterfall() reprenait le filtre d'entrée du proxy — voiture
assignée, mode non manuel — mais pas `pluggedIn`. Le proxy n'en avait pas
besoin : son allocation n'était pas une cascade, donc gaspiller ne privait
personne. En cascade, si. L'asymétrie était déjà visible dans le code : la
boucle EV du scheduler, qui sert les financements réseau, teste
`!ev->pluggedIn()` depuis toujours ; seul le chemin waterfall ne le faisait pas.

C'est le défaut du relevé 3g-1 §3.4 par une autre porte : là le plancher
mentait, ici c'est le filtre d'entrée. Défaut de 3g-1, révélé par 3g-2 parce
que la décision atteint enfin le matériel — et corrigé à part du lot, comme les
quatre précédents.

Hors arbitrage, et non « servie à 0 avec un motif » : même famille que les deux
autres exclusions (DESIGN_3g §4). Une borne débranchée ne consomme rien, sa
consommation nulle est déjà au compteur, et lui donner un rang la ferait
apparaître dans la cascade pour n'y rien faire. Le journal l'annonce comme les
deux autres cas.

testUnpluggedChargerTakesNoBudget, VÉRIFIÉ ÉCHOUANT SANS LE CORRECTIF : la
borne débranchée disparaît du contexte et la charge suivante reçoit les 3 000 W
entiers ; la seconde moitié rebranche le véhicule pour prouver que c'est bien
`pluggedIn` qui décide, et non une exclusion fortuite.

Suite complète : simulation 28/28, charging 16/16, loadmodel 16/16,
spotmarket 7/7. Build amd64 0/0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
2026-08-27 12:52:21 +02:00

1187 lines
59 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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;
// 3g-2 — une borne ne se CONSTRUIT pas depuis la configuration : son adaptateur est bâti
// depuis le Thing par syncAdapters(), et son entrée ne porte qu'un rang. La construire
// ici en ferait un second adaptateur pour le même organe. C'est aussi ce qui rend
// l'aller-retour lire-réécrire inoffensif : réécrire une entrée evcharger ne reconstruit
// rien, donc ne réarme aucun verrou (ECS-412).
if (c.isEvCharger())
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.minPowerW(), 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.";
// 3g-2 — le waterfall ne commande plus rien : il ne peut donc plus revendiquer aucune
// borne. Garder le jeu du dernier cycle sain les exclurait du proxy alors que plus
// personne ne les commande — elles resteraient figées sur leur dernière consigne,
// compteur muet, ce que SAFETY.md §L2 interdit. Le repli est posé à la transition par
// applyDegradedMode() ; c'est lui qui fait autorité tant que le compteur est muet.
m_waterfallEvIds.clear();
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;
}
// 3g-2 — le jeu des bornes que le waterfall a commandées, NOMMÉ par le scheduler. Il doit
// être posé AVANT le dispatch : c'est lui que le crochet evCommandedByWaterfall() rend à
// adjustEvChargers() juste après, et c'est ce qui empêche le proxy de re-commander.
m_waterfallEvIds = plan.waterfallEvIds;
// 7 : dispatch matériel (même position que l'amont — m_chargingActions rempli par getPlan())
applyActionsToAdapters(slot, currentDateTime); // charges arbitrées (Setpoint W, State) ET bornes du waterfall
adjustEvChargers(currentDateTime); // bornes RESTÉES au proxy : échéance, spot market
// 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);
}
bool EnergyArbitrator::evParticipatesInWaterfall(EvCharger *ev) const
{
if (!ev)
return false;
const ChargingInfo info = chargingInfo(ev->id());
// Sans voiture assignée, le proxy ignorait la borne : ni SoC, ni plancher véhicule, ni
// échéance calculable. Rien de tout cela n'apparaît en entrant dans le waterfall.
if (!m_tm->findConfiguredThing(info.assignedCarId()))
return false;
// Mode manuel : l'utilisateur pilote, l'arbitre s'abstient. Hors arbitrage — pas rang 0.
if (info.chargingMode() == ChargingInfo::ChargingModeNormal)
return false;
// Aucun véhicule branché : la borne ne peut RIEN consommer de ce qu'on lui allouerait.
//
// Le proxy n'avait pas besoin de ce test — son allocation n'était pas une cascade, et
// gaspiller ne privait personne. En cascade, si : au banc le 2026-08-27, une wallbox
// `pluggedIn: false` s'est vu allouer 4 853 W qu'elle ne tirait pas, et la PAC derrière
// elle n'a survécu que sur son propre recrédit (docs/RELEVE_3g2.md §3.1).
//
// L'asymétrie était d'ailleurs déjà visible dans le code : la boucle EV du scheduler, qui
// sert les financements RÉSEAU, teste `!ev->pluggedIn()` depuis toujours. Seul le chemin
// waterfall ne le faisait pas.
//
// Hors arbitrage, et non « servie à 0 avec un motif » : c'est la même famille que les deux
// cas ci-dessus (DESIGN_3g §4). Une borne débranchée ne consomme rien, sa consommation
// nulle est déjà au compteur, et lui donner un rang la ferait apparaître dans la cascade
// pour n'y rien faire. Le journal l'annonce, comme pour les deux autres.
if (!ev->pluggedIn())
return false;
return true;
}
double EnergyArbitrator::evFloorW(EvCharger *ev, uint phaseCount) const
{
if (!ev || phaseCount == 0)
return 0;
// max(borne, voiture) — la même règle que m_processInfos.minimalChargingCurrent du proxy.
// Elle dépend du véhicule branché : c'est une valeur runtime, republiée à chaque cycle.
double minA = ev->maxChargingCurrentMinValue();
if (Thing *car = m_tm->findConfiguredThing(chargingInfo(ev->id()).assignedCarId())) {
const double carMinA = car->stateValue("minChargingCurrent").toDouble();
if (carMinA > minA)
minA = carMinA;
}
// 3g-2 — la tolérance d'acquisition N'EST PLUS ICI, et c'est la correction du lot.
//
// Ce sont deux grandeurs différentes, confondues en écrivant evFloorW() : le PLANCHER est
// ce que la borne exige pour démarrer (6 A × 230 V × phases, une donnée de matériel), la
// TOLÉRANCE est la part de ce plancher qu'on accepte de ne pas couvrir par le surplus (un
// réglage). Multiplier l'un par l'autre produisait un plancher qui n'existe nulle part :
// au banc, 2 070 W annoncés pour une borne qui en exige 4 140 — le waterfall lui allouait
// 2 828 W, l'adaptateur convertissait en 4,1 A, refusait d'enclencher sous les 6 A, et
// 2 828 W étaient retirés du budget pour personne (relevé du 2026-08-26, §3.4).
//
// La tolérance vit maintenant là où elle décide vraiment : au DÉMARRAGE, dans le waterfall,
// sous le motif EV_GRID_START qui chiffre ce qui est soutiré.
return minA * 230.0 * phaseCount;
}
double EnergyArbitrator::evReferenceCurrentA(EvCharger *ev) const
{
if (!ev)
return 0;
double a = ev->currentPower() / 230.0;
if (RootMeter *meter = internalRootMeter())
a += -meter->currentPower() / 230.0; // < 0 au compteur = export = surplus
return a;
}
uint EnergyArbitrator::evTargetPhases(EvCharger *ev, double referenceA)
{
if (!ev)
return 1;
// Les bornes de ce qui est POSSIBLE viennent du manager, qui les a établies ce cycle avec
// la voiture branchée (canSwitchPhaseCount, min/maxPossiblePhaseCount). Les redériver ici
// en ferait une seconde version — et c'est exactement ce qui a divergé en 3g-1 : viser
// 3 phases sur une combinaison qui n'en permet qu'une triplait le plancher publié, et sous
// surcharge commandait 3 × 6 A là où le proxy commandait 1 × 6 A.
const ChargingProcessInfo pi = internalProcessInfo(ev);
if (!pi.canSwitchPhaseCount)
return qMax(1u, pi.maxPossiblePhaseCount);
const uint phases = bestPhaseCountFor(ev, referenceA);
return qMax(1u, qBound(pi.minPossiblePhaseCount, phases, pi.maxPossiblePhaseCount));
}
double EnergyArbitrator::evPhaseAllowanceW(EvCharger *ev, uint phaseCount) const
{
if (!ev || phaseCount == 0)
return -1;
// 0 = non réglé : la protection de surcharge est désactivée (contrat amont de
// SetPhasePowerLimit). Rien à écrêter, et surtout rien à annoncer — un plafond publié
// ici serait une limite inventée.
const uint limite = phasePowerLimit();
if (limite == 0)
return -1;
RootMeter *meter = internalRootMeter();
if (!meter)
return -1;
// Place LIBRE par phase, telle que la calcule le proxy — même fonction, même masque de
// phases, et surtout SANS rendre à la borne ce qu'elle tire déjà.
//
// Ce dernier point est délibéré et il est de sécurité. Rendre sa propre consommation à une
// borne serait plus juste en régime établi, mais ferait disparaître la réponse à la
// surcharge : sur une phase en dépassement, la place libre est NÉGATIVE, et c'est ce signe
// qui ramène la borne à son minimum. Avec le retour, elle se verrait autoriser ce qu'elle
// consomme déjà — c'est-à-dire précisément la puissance qui cause le dépassement.
// (`testOverloadProtectionEcoMode` : 16 A ramenés à 6 A ; avec le retour, 9 A maintenus.)
const double allowanceA = meter->calculateAllowanceAmpere(
const_cast<EnergyArbitrator *>(this)->phasesForCount(phaseCount), limite);
return qMax(0.0, allowanceA) * 230.0 * phaseCount;
}
bool EnergyArbitrator::evCommandedByWaterfall(EvCharger *evCharger) const
{
return evCharger && m_waterfallEvIds.contains(evCharger->thing()->id().toString());
}
bool EnergyArbitrator::evSurplusPlannedByWaterfall(EvCharger *evCharger) const
{
return evParticipatesInWaterfall(evCharger);
}
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)
// --- Stockage (3g-1) ------------------------------------------------------------------
// Rempli ICI et plus dans le chemin VE. La prise en compte de la batterie n'a jamais été
// une règle « véhicule » : c'est une règle de BUDGET, et elle vivait dans
// planSurplusCharging() par accident d'histoire. Tant qu'elle y restait, une batterie sous
// sa réserve empêchait de charger la voiture mais laissait le chauffe-eau se servir.
//
// Toutes les batteries sont agrégées en une seule — même simplification que l'amont : un
// niveau moyen et une puissance totale. Distinguer deux stockages demanderait de savoir
// lequel arbitrer en premier, et rien dans le modèle ne le dit.
{
int n = 0;
double niveauTotal = 0, puissanceTotale = 0;
for (Thing *storage : m_tm->configuredThings().filterByInterface("energystorage")) {
++n;
niveauTotal += storage->stateValue("batteryLevel").toDouble();
puissanceTotale += storage->stateValue("currentPower").toDouble();
}
if (n > 0) {
ctx.battery.present = true;
ctx.battery.socPercent = niveauTotal / n;
ctx.battery.powerW = puissanceTotale; // > 0 charge, < 0 décharge
} else {
// Un filtre par interface qui ne trouve rien est TOUJOURS ambigu : « il n'y a pas
// de batterie » et « il y en a une, mal déclarée » se ressemblent, et la seconde
// désactive une règle de sécurité EN SILENCE. C'est le même mode de défaillance
// qu'un seuil de réserve mal réglé : rien ne se signale.
//
// Constaté sur le banc le 2026-08-26 : le Thing « SunSpec Storage » déclare
// `battery` mais pas `energystorage`, donc la réserve ne s'est jamais appliquée —
// ni ici, ni dans le proxy amont qui utilisait le même filtre, ni dans le bilan de
// puissance de l'experience-plugin qui filtre pareil.
const Things candidates = m_tm->configuredThings().filterByInterface("battery");
if (!candidates.isEmpty()) {
QStringList noms;
for (Thing *t : candidates)
noms << t->name();
qCWarning(dcNymeaEnergy())
<< "[Arbitre] Réserve de stockage INACTIVE :" << candidates.count()
<< "appareil(s) portant l'interface 'battery' mais PAS 'energystorage' —"
<< noms.join(", ") << ". Le waterfall ne peut pas les prendre en compte, et"
<< "le bilan de puissance de nymea non plus. À corriger dans le plugin qui"
<< "déclare la classe, pas ici.";
}
}
}
// --- loads[] : bornes VE (3g-1) ------------------------------------------------------
// Elles entrent désormais dans le MÊME contexte que les autres charges — c'est toute la
// transplantation. Le filtre d'entrée du proxy est repris à l'identique : une borne sans
// voiture ou en mode manuel est HORS arbitrage, pas servie en dernier. Sa consommation est
// déjà dans le compteur racine, donc déjà dans le budget ; lui allouer quoi que ce soit la
// compterait deux fois.
for (auto it = m_adapters.constBegin(); it != m_adapters.constEnd(); ++it) {
EvCharger *ev = internalEvChargers().value(ThingId(it.key()));
if (!evParticipatesInWaterfall(ev)) {
qCDebug(dcNymeaEnergy()) << "[Arbitre] Borne" << (ev ? ev->name() : it.key())
<< "hors waterfall (pas de voiture assignée, mode manuel, "
"ou aucun véhicule branché).";
continue;
}
ctx.loads.append(it.value()->toLoadContext(now));
}
// --- loads[] : bornes CONFIGURÉES dont l'appareil manque (3g-2) ----------------------
//
// L'entrée de configuration d'une borne n'est jamais supprimée d'office : c'est une
// configuration, et l'installateur a pu la classer. Quand le Thing disparaît — borne
// remplacée, ThingId changé, appareil déposé — il reste donc une charge sans appareil, et
// elle doit se LIRE comme telle : indisponible, avec son motif, à son rang. La faire
// disparaître du contexte la rendrait indistinguable d'une charge jamais configurée, et
// c'est le silence ambigu que la famille ECS-411 interdit.
//
// Aucune allocation ne lui est faite (branche `!available` du waterfall) et elle ne retient
// rien du budget : 0 W gelés, le budget passe entier à la suivante.
if (m_loadConfigStore) {
for (const LoadConfig &c : m_loadConfigStore->configs()) {
if (!c.isEvCharger() || !c.enabled() || m_adapters.contains(c.id()))
continue;
LoadContext lc;
lc.id = c.id();
lc.adapter = QStringLiteral("evcharger");
lc.label = c.label();
lc.priority = c.priority();
lc.telemetry.available = false;
lc.telemetry.currentPowerW = 0;
lc.telemetry.lockMinPowerW = 0; // rien d'engagé : rien à geler au budget
lc.telemetry.lockMaxPowerW = -1;
ctx.loads.append(lc);
}
}
// --- 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;
}
int EnergyArbitrator::evPriority(const QString &chargerId) const
{
if (m_loadConfigStore) {
for (const LoadConfig &c : m_loadConfigStore->configs())
if (c.isEvCharger() && c.id() == chargerId)
return c.priority();
}
// Pas (encore) de configuration : rang de QUEUE. Une borne dont le rang n'est pas posé ne
// doit pas passer devant des charges que l'installateur a classées — l'inverse ferait de
// l'absence de configuration une décision d'arbitrage.
return 1000;
}
bool EnergyArbitrator::estUneBorneConfiguree(const QString &loadId) const
{
if (!m_loadConfigStore)
return false;
for (const LoadConfig &c : m_loadConfigStore->configs())
if (c.isEvCharger() && c.enabled() && c.id() == loadId)
return true;
return false;
}
void EnergyArbitrator::provisionEvChargerConfig(EvCharger *ev)
{
if (!m_loadConfigStore || !ev)
return;
const QString id = ev->thing()->id().toString();
LoadConfigs configs = m_loadConfigStore->configs();
int rangMin = 0;
bool premier = true;
for (const LoadConfig &c : configs) {
if (c.id() == id)
return; // déjà configurée : on ne touche à rien
if (premier || c.priority() < rangMin) {
rangMin = c.priority();
premier = false;
}
}
LoadConfig c;
c.setId(id);
c.setLabel(ev->name());
c.setAdapter(QStringLiteral("evcharger"));
c.setMode(QStringLiteral("dynamic"));
// Le domaine d'une borne n'est pas une devinette : c'est le mécanisme lui-même. La réserve
// « le plugin ne range pas une charge d'office » vise les charges pilotées, dont le domaine
// ne se lit nulle part.
c.setDomain(QStringLiteral("ev"));
// En tête, sans réécrire le rang de personne. 1 est le premier rang (protocole §5).
c.setPriority(premier ? 1 : qMax(1, rangMin - 1));
c.setEnabled(true);
configs.append(c);
QString erreur;
if (!m_loadConfigStore->setConfigs(configs, &erreur)) {
// 7-c — l'échec ne doit pas être plus discret que le succès : sans entrée, la borne
// reste arbitrée mais absente de GetLoadConfig, et c'est précisément le trou qu'on
// vient refermer.
qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] Configuration automatique de la borne"
<< ev->name() << "REFUSÉE :" << erreur
<< "— elle sera arbitrée sans rang configurable.";
return;
}
qCInfo(dcNymeaEnergy()) << "[EnergyArbitrator] Borne" << ev->name()
<< "ajoutée à la configuration des charges (rang" << c.priority()
<< ", domaine ev) — entrée créée automatiquement, modifiable par "
"SetLoadConfig.";
}
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);
// 3g-2 — la borne entre AUSSI dans la configuration, pour y porter son rang.
provisionEvChargerConfig(it.value());
}
}
// 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.
// 3g-2 — LES BORNES SONT DISPATCHÉES ICI. Elles vivent dans une seconde table
// (m_adapters, construite depuis les Things et non depuis la config), mais le geste
// est le même : l'adaptateur convertit l'enveloppe en consigne matérielle et écrête.
// Tant que ce dispatch n'existait pas, la décision de l'arbitre n'atteignait RIEN et
// c'est adjustEvChargers() qui commandait — deux commandeurs, un seul entendu.
//
// Seules les bornes que le waterfall a commandées passent ici : celles restées au
// proxy (échéance, spot market) gardent leur chemin, allowance de compteur comprise.
if (action.kind == LoadAction::State || action.kind == LoadAction::Setpoint) {
if (ILoadAdapter *adapter = m_loadAdapters.value(action.loadId, nullptr)) {
adapter->applyAction(action, now);
} else if (m_waterfallEvIds.contains(action.loadId)) {
if (EvAdapter *ev = m_adapters.value(action.loadId, nullptr)) {
ev->applyAction(action, now);
// L'ÉTAT de charge publié dans ChargingInfo était posé par
// adjustEvChargers(), que ces bornes ne traversent plus. Sans cette
// reprise, chargingState resterait figé sur sa dernière valeur : l'app
// afficherait « en charge » une borne que l'arbitre vient d'éteindre.
setEvChargingState(ThingId(action.loadId),
action.chargingEnabled
? ChargingInfo::ChargingStateSurplusCharging
: ChargingInfo::ChargingStateIdle);
} else if (action.reason.code != QLatin1String(DecisionCode::LoadUnavailable)) {
// Le waterfall a nommé une borne dont l'adaptateur a disparu ENTRE la
// décision et le dispatch (Thing retiré en cours de cycle). Rien n'est
// commandé, et le silence serait ambigu : cette borne est aussi sortie
// du proxy par le crochet.
//
// Le cas d'une borne configurée dont le Thing manque depuis le début est
// EXCLU de cet avertissement : l'arbitre l'a vu et l'a dit — c'est le
// motif LOAD_UNAVAILABLE, publié à chaque cycle. Répéter l'information en
// qCWarning noierait le vrai signal, qui est la disparition INATTENDUE.
qCWarning(dcNymeaEnergy()) << "[Arbitre] borne" << action.loadId
<< "commandée par le waterfall mais son adaptateur "
"a disparu — aucune consigne émise ce cycle.";
}
} 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) {
// 3g-1 — LES BORNES SONT DANS loads[]. Elles étaient exclues ici tant que l'EV était
// décidé en amont du waterfall ; depuis qu'il y entre, les omettre laisserait la somme
// des allocations irréconciliable avec budget.allocatedW et priverait le client du
// motif — c'est-à-dire de la SEULE chose qui distingue « l'arbitre a refusé » de « le
// matériel n'a pas suivi ». Attrapé au premier passage machine.
ILoadAdapter *adapter = m_loadAdapters.value(action.loadId, nullptr);
if (!adapter)
adapter = m_adapters.value(action.loadId, nullptr);
// 3g-2 — une borne CONFIGURÉE dont l'appareil manque n'a pas d'adaptateur, et doit
// pourtant être publiée : sans elle, une charge que GetLoadConfig annonce serait
// absente de GetLoadTelemetry, et l'app afficherait un vide sans cause. C'est le
// même invariant, pris par l'autre bout.
if (!adapter) {
if (!estUneBorneConfiguree(action.loadId))
continue;
couvertes.insert(action.loadId);
QVariantMap orphan;
orphan.insert(QStringLiteral("loadId"), action.loadId);
orphan.insert(QStringLiteral("allocatedW"), 0);
orphan.insert(QStringLiteral("funding"), QStringLiteral("surplus"));
orphan.insert(QStringLiteral("available"), false);
orphan.insert(QStringLiteral("decision"), action.reason.toMap());
orphan.insert(QStringLiteral("faultCode"), QStringLiteral("THING_MISSING"));
QVariantMap mech;
mech.insert(QStringLiteral("kind"), QStringLiteral("evcharger"));
orphan.insert(QStringLiteral("mechanism"), mech);
attachMeasurements(orphan, action.loadId);
loads.append(orphan);
continue;
}
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);
// Le FINANCEMENT, sans quoi la charge utile n'est pas réconciliable. Seules les actions
// financées au SURPLUS ont décrémenté la cascade et sont comptées dans
// budget.allocatedW ; celles financées au RÉSEAU — échéance et spot market, restées au
// proxy — portent une puissance qui vit dans budget.evReservedW. Sans ce champ, un
// client qui somme loads[].allocatedW trouve un écart et n'a aucun moyen de savoir
// s'il regarde un bug ou une borne servie au réseau.
entry.insert(QStringLiteral("funding"),
action.funding == LoadAction::Grid ? QStringLiteral("grid")
: QStringLiteral("surplus"));
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.
//
// Les BORNES sont volontairement hors de ce contrôle : une borne sans voiture assignée ou
// en mode manuel est HORS arbitrage (DESIGN_3g §4), et ne recevoir aucune action y est le
// comportement correct — pas un trou. Avertir à chaque cycle sur une box dont la borne est
// simplement débranchée noierait le vrai signal.
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.
//
// Les BORNES y figurent aussi depuis 3g-1. Le mode dégradé est précisément le moment où
// savoir qu'une borne existe et n'est plus pilotée a le plus de valeur : l'omettre ferait
// lire « pas de borne » là où la vérité est « borne non pilotée ».
// Pas de champ « funding » ici : en dégradé il n'y a aucun plan, donc aucun financement —
// et le champ est omis, jamais nul.
QStringList ids = m_loadAdapters.keys();
ids.sort();
for (const QString &id : ids)
entree(id, m_loadAdapters.value(id));
QStringList evIds = m_adapters.keys();
evIds.sort();
for (const QString &id : evIds)
entree(id, m_adapters.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;
}