Patrick Schurig 9dcaee8be1 feat(prévision PV): la table bitemporelle, la déduplication sur l'intersection, la rétention
Ce qui ne dépend pas du canal, écrit maintenant. La lecture se branchera quand
la forme du canal openmeteo sera connue ; le magasin est vide d'ici là, et une
courbe vide EST la réponse honnête à « que prévoit-on ? » quand personne n'a
rien publié.

LA CLÉ EST LA PAIRE (targetAt, receivedAt). Avec targetAt seul, chaque
rafraîchissement écraserait le précédent : on perdrait les révisions, donc la
comparaison prévu/réalisé — ce que ce lot existe pour montrer. Le GPL n'apprend
pas, il rend l'apprentissage possible : cette table est la matière, et l'écraser
la détruirait pour toujours sans que rien ne le signale.

receivedAt, JAMAIS issuedAt, et l'interdiction est écrite et non seulement
nommée : aucun horizon ne se calcule depuis ce champ. Entre l'initialisation
d'un run et sa mise à disposition s'écoule la latence de publication du modèle —
des heures. targetAt - receivedAt est donc trop court, systématiquement et dans
le même sens. « Quelle prévision » est juste, « quel horizon » est faux.

LA DÉDUPLICATION PORTE SUR L'INTERSECTION, pas sur la charge utile. Si la
fenêtre glisse, la courbe de 11 h a un point de moins que celle de 10 h : une
égalité de charge utile verrait une révision toutes les heures, exactement ce
que la déduplication existe pour éviter. Et `communs > 0` est requis — sans
heure commune il n'y a rien à comparer, et conclure « identique » ferait
disparaître une révision dont on ne sait rien (cas du passage de minuit).

Ce choix ne dépend pas de la forme que le producteur publie aujourd'hui : il est
correct que la fenêtre glisse ou non, donc il ne peut pas être invalidé par un
changement chez le voisin dont personne ne nous préviendrait.

RÉTENTION : sept jours pleins, puis les DEUX BOUTS par heure visée — la première
révision (horizon le plus long) et la dernière (meilleure estimation). Les deux,
et pas une : elles répondent aux deux seules questions qui se posent, et n'en
garder qu'une détruirait la révision.

model reste NULL tant que le producteur publie une INTENTION et non une
provenance. Une chaîne vide serait déjà une valeur ; NULL dit « on ne sait pas »,
ce qui est vrai. Aucun remplissage rétroactif à prévoir.

GetPvForecast : un Get* SANS *Changed — amendement assumé à l'invariant 11, qui
vise l'ÉTAT DE L'ARBITRE, celui qui bouge à chaque cycle et laisse un client
déconnecté en retard. Une courbe historisée se lit sur demande, comme
GetPowerBalanceLogs ; notifier chaque révision produirait un message par heure
sur une donnée que personne n'attend en direct. `knownAt` rend la lecture
reproductible : « ce qu'on prévoyait ce matin » se demande en le fixant.

Base SÉPARÉE, jamais energylogs.sqlite : celle-là appartient à l'amont qui en
migre le schéma, et surtout la clé y est un instant de mesure quand la nôtre est
une paire. Une prévision révisée n'a rien à faire dans la base du mesuré.

Trois tests unitaires, et le premier vise le cas qu'un == naïf raterait : une
fenêtre glissée d'une heure, valeurs communes inchangées, charge utile
différente — ce n'est PAS une révision. Avec ses contre-épreuves : une valeur
commune modifiée EN EST une, et une fenêtre sans heure commune aussi.

loadmodel 29/29, charging 48/48, spotmarket 32/32, simulation 69/69,
ci-quality 4/4, build 0 erreur 0 warning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-09-08 17:23:35 +02:00

2348 lines
126 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();
// L'historique des prévisions PV. Créé ici parce que sa DURÉE DE VIE est celle de l'arbitre
// — pas parce qu'il décide : il n'entre dans aucun plan, ne pondère aucune échéance.
// La purge tourne une fois par jour ; une table qui ne dit pas quand elle purge finit par
// le décider toute seule un jour de disque plein.
m_forecastStore = new ForecastStore(ForecastStore::cheminParDefaut(), this);
if (m_forecastStore->isValid()) {
m_forecastPurge = new QTimer(this);
m_forecastPurge->setInterval(24 * 3600 * 1000);
connect(m_forecastPurge, &QTimer::timeout, this,
[this]() { m_forecastStore->purger(); });
m_forecastPurge->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;
}
// \rule{ECS-410-b} — l'issue est journalisée, et par l'ARBITRE : c'est lui qui sait si
// un défaut existait, l'adaptateur ne rendant qu'un booléen.
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();
// SAFETY.md §L2 : la planification est suspendue, la SÉCURITÉ ne l'est pas. Le repli a
// posé des consignes à la transition ; si elles ne suffisent pas à tenir le plafond,
// c'est encore à L4 de trancher. Aucune télémétrie n'est publiée dans cet état, d'où
// le slot local — il ne sert qu'à recevoir les actions, personne ne le lira.
return;
}
// ETM-only : sync adapters + proxy planification → log [Arbitre]
// getPlan() appelle planSpotMarketCharging() + planSurplusCharging() (position 5-6 amont).
syncAdapters();
// L4 étendu au waterfall — ÉVALUÉ ICI : après syncAdapters(), avant toute écriture du cycle.
//
// Deux placements ont été essayés et écartés, chacun pour une raison distincte. APRÈS le
// dispatch semblait plus juste (« le dernier mot »), et c'était un
// piège : dans un même update() aucune boucle d'événements ne tourne, si bien qu'un
// adaptateur qui vient d'être commandé rapporte encore son état PRÉCÉDENT. Le délestage
// demandait alors un état que l'adaptateur croyait déjà tenir, se le voyait accorder sans
// rien faire, et publiait des watts rendus que le matériel n'avait pas rendus — une PAC
// restée en état 4 pendant qu'on annonçait 3 kW délestés. Avant tout dispatch, l'état lu
// est POSÉ, et ce qu'on constate est vrai. AVANT syncAdapters() échouait autrement : la
// reconstruction incrémentale (ECS-412) remplace l'adaptateur, et l'écriture en cours
// partait avec lui — les contacts restaient fermés pendant qu'on publiait un délestage.
//
// Ce que le plan ferait de ces charges est neutralisé autrement : L4 les REVENDIQUE, et le
// dispatch les saute — un seul commandeur par organe, exactement le mécanisme qui protège
// déjà les bornes du waterfall (m_waterfallEvIds). Le cycle suivant, la cascade ne les
// rallume pas non plus : la marge est encore négative, et l'étape 3 la borne.
shedWaterfallForOverload(currentDateTime);
SurplusContext ctx = buildContext(currentDateTime);
// Les dérogations vivent ENTRE la construction et la décision : elles se terminent sur ce
// que le contexte rapporte, et les survivantes y sont marquées avant que le plan ne soit
// fait. Les évaluer après le plan servirait un cycle de plus une dérogation déjà finie.
evaluerDerogations(ctx, 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
// Les charges délestées par L4 portent le motif du DÉLESTAGE, pas celui du plan : la
// charge utile doit dire ce qui a été exécuté, et le plan ne les a pas commandées.
fusionnerDelestageDansSlot(slot);
// 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);
}
namespace {
//! Recul en deçà duquel on ne conclut PAS à une nouvelle session : bruit de lecture. Même
//! principe que le seuil du plugin V2C, transposé en Wh.
constexpr double BruitAvancementWh = 50.0;
}
void EnergyArbitrator::noterAchatEco(const QString &loadId, double achatW,
const QDateTime &now) const
{
const QDateTime precedent = m_ecoDernierAchat.value(loadId);
// Le premier cycle d'une période ne contribue RIEN : il n'a pas de durée derrière lui, et
// en inventer une serait fabriquer de l'énergie. L'écart d'un cycle est borné et connu.
if (precedent.isValid() && achatW > 0) {
const double heures = precedent.secsTo(now) / 3600.0;
if (heures > 0)
m_ecoAchatWh[loadId] += achatW * heures;
}
m_ecoDernierAchat.insert(loadId, now);
}
void EnergyArbitrator::noterCauseEco(const QString &loadId, const QString &cause) const
{
if (cause.isEmpty())
m_ecoCause.remove(loadId);
else
m_ecoCause.insert(loadId, cause);
}
EcoProgress EnergyArbitrator::ecoProgress(const LoadContext &lc, const QDateTime &now) const
{
EcoProgress p;
p.targetWh = lc.needs.minEnergyWhPerDay;
// Pas d'obligation déclarée : rien à mesurer, et surtout rien à publier. Une charge sans
// obligation éco n'aura jamais d'entrée « eco » — l'absence EST l'information (R1).
if (p.targetWh <= 0) {
m_ecoBaseWh.remove(lc.id);
m_ecoBaseEcheance.remove(lc.id);
return p; // regime vide : l'appelant n'émet pas de niveau éco
}
// BASCULE DE PÉRIODE — mesuré le 2026-08-31, et ça ne se voyait pas en lisant le code.
//
// À l'instant de l'échéance quotidienne, `resolveDeadline()` vise la PROCHAINE occurrence :
// une nouvelle période d'obligation s'ouvre. Si la base ne se reprenait pas là, le lendemain
// hériterait de l'énergie livrée la veille et se croirait tenu sans avoir rien reçu — un
// avancement crédible et faux, exactement ce que la base existe pour empêcher.
const QDateTime echeance = resolveDeadline(lc.needs, now);
if (echeance.isValid() && m_ecoBaseEcheance.value(lc.id) != echeance) {
if (m_ecoBaseEcheance.contains(lc.id)) {
// CLÔTURE — la période précédente s'achève. Si elle n'était pas tenue, c'est un FAIT
// que le moteur produit, et un fait produit doit se dire. Ce motif n'existait pas ce
// matin faute d'émetteur ; la bascule en est un.
const bool mesurait = lc.telemetry.sessionMeasurable && m_ecoBaseWh.contains(lc.id);
const double livre = mesurait
? qMax(0.0, lc.telemetry.sessionWh - m_ecoBaseWh.value(lc.id)) : 0.0;
if (!mesurait || livre < p.targetWh) {
p.closedMissed = true;
p.closedKnown = mesurait;
// Le manque n'est chiffré que s'il est CONNU. Sous un régime non mesuré, on ne
// sait pas ce qui a été livré, donc pas davantage ce qui manquait : publier un
// chiffre inventerait la grandeur même dont l'absence est le problème.
if (mesurait)
p.closedMissingWh = p.targetWh - livre;
p.closedCause = mesurait
? (m_ecoCause.value(lc.id).isEmpty()
? QString::fromLatin1(MissedCause::LoadCapacity) // servie sans blocage
: m_ecoCause.value(lc.id)) // et servie mais pas assez
: QString::fromLatin1(MissedCause::Unmeasurable);
}
// L'obligation précédente s'achève. Si elle n'était pas tenue, elle est abandonnée
// ici — SANS motif dédié à ce stade, et c'est une limite assumée : le dire demande
// un `ECO_FLOOR_MISSED` que rien n'émet encore. Le journal, lui, le dit.
qCInfo(dcNymeaEnergy()) << "[Arbitre] Obligation éco" << lc.id
<< "— nouvelle période (échéance" << echeance.toString()
<< "), base d'avancement reprise.";
}
m_ecoBaseEcheance.insert(lc.id, echeance);
m_ecoBaseWh.remove(lc.id);
m_ecoCause.remove(lc.id);
// LM-1014-b — les deux CUMULS de la période repartent à zéro, et la date de mesure
// avec eux. Les laisser courir ferait porter au lendemain la dépense de la veille et
// dater la mesure d'une période close.
m_ecoAchatWh.remove(lc.id);
m_ecoDernierAchat.remove(lc.id);
m_ecoMesureDepuis.remove(lc.id);
// Le cumul de période repart de ZÉRO, comme tout ce qui est rapporté à la période
// (\rule{LM-1014-b}) — et les trous avec lui : ils comptaient les interruptions de la
// période close, pas de la neuve.
m_ecoLivreWh.remove(lc.id);
m_ecoDernierSessionWh.remove(lc.id);
m_ecoTrous.remove(lc.id);
}
// C1 de LM-1009 — la borne ne compte pas : l'obligation n'est PAS mesurable, et ça se dit.
// `deliveredWh` reste absent, jamais zéro : une dégradation ne doit pas se lire comme un
// avancement nul, sans quoi l'échéance continue de s'afficher comme si elle tenait.
// L'ACHETÉ est connu sans mesure — c'est notre propre commande — donc il est publié dans
// TOUS les régimes, y compris `unmeasurable`. C'est ce qui distingue « ce qu'on a dépensé »
// de « ce qui a été livré » : le premier est un fait du moteur, le second une observation
// qu'il ne peut pas faire ici.
p.purchasedWh = m_ecoAchatWh.value(lc.id, 0.0);
if (!lc.telemetry.sessionMeasurable) {
p.regime = QString::fromLatin1(ProgressRegime::Unmeasurable);
// CLÔTURE DU TRONÇON, pas abandon. Un aller-retour de régime dans la même période — le
// motif d'un lien Modbus qui bat — jetait tout ce qui précède : la base était reprise à
// la valeur courante, et l'avancement repartait de zéro. Deux conséquences, et la
// seconde coûte : l'écran sous-estime, et la passe éco RACHÈTE le déjà livré.
//
// On additionne au cumul de période, en partant de la DERNIÈRE valeur lue tant que la
// mesure existait : la valeur du cycle courant ne vaut plus rien puisque le Thing ne
// publie plus, et s'en servir fabriquerait une énergie.
if (m_ecoBaseWh.contains(lc.id)) {
const double fin = m_ecoDernierSessionWh.value(lc.id, m_ecoBaseWh.value(lc.id));
m_ecoLivreWh[lc.id] += qMax(0.0, fin - m_ecoBaseWh.value(lc.id));
m_ecoTrous[lc.id] += 1;
m_ecoBaseWh.remove(lc.id);
}
// `m_ecoMesureDepuis` est CONSERVÉ : il date le PREMIER instant mesuré de la période, et
// les trous sont dits séparément. Le remettre à zéro ferait croire à une mesure continue
// qui commencerait au retour du lien.
p.purchasedWh = m_ecoAchatWh.value(lc.id, 0.0);
return p;
}
const double sessionWh = lc.telemetry.sessionWh;
if (!m_ecoBaseWh.contains(lc.id)) {
// OUVERTURE de l'obligation : la base se prend ICI. Pas au démarrage de nymead — la
// reprendre là réinitialiserait l'avancement d'une session en cours sur une valeur
// déjà accumulée, et une recharge de nuit relue au matin repartirait de zéro.
m_ecoBaseWh.insert(lc.id, sessionWh);
// DEPUIS QUAND on mesure. Sans cette date, une bascule `unmeasurable` → `measured` en
// cours de période ferait afficher « 0,5 sur 4,0 » alors que 3 kWh ont pu être achetés
// avant : crédible et faux. Rebaser `targetWh` serait pire — on perdrait l'obligation
// réelle, et l'écran ne pourrait plus dire ce qui reste dû.
if (!m_ecoMesureDepuis.contains(lc.id))
// Ancré en UTC à la CAPTURE : un QDateTime local résout son époque à la
// conversion, donc il dérive au changement de fuseau (cf. energyratioscalculator).
m_ecoMesureDepuis.insert(lc.id, now.toUTC());
} else if (lc.telemetry.sessionCumulative
&& sessionWh < m_ecoBaseWh.value(lc.id) - BruitAvancementWh) {
// COMPTEUR DE VIE QUI RECULE — impossible physiquement, donc la valeur ne vaut rien :
// zéro forgé à la perte du lien, ou compteur remplacé. On NE RECAPTURE PAS, et on gèle
// l'avancement le temps de l'anomalie, comme si la mesure avait disparu.
//
// Recapturer ici serait le chemin du faux « tenue » : base reprise à 0 pendant
// l'absence, retour du compteur avec son cumul réel, et l'obligation se déclarerait
// tenue sans que rien n'ait été livré. R6 ne l'attraperait pas — elle n'interdit
// ECO_FLOOR_MET que sous `unmeasurable`, et ce chemin-là est MESURÉ.
qCInfo(dcNymeaEnergy()) << "[Arbitre] Avancement éco" << lc.id
<< "— compteur de VIE en recul ("
<< m_ecoBaseWh.value(lc.id) << "→" << sessionWh
<< " Wh) : valeur ignorée, avancement gelé.";
p.regime = QString::fromLatin1(ProgressRegime::Unmeasurable);
p.purchasedWh = m_ecoAchatWh.value(lc.id, 0.0);
return p; // le tronçon reste OUVERT sur sa dernière valeur saine
} else if (!lc.telemetry.sessionCumulative
&& sessionWh < m_ecoBaseWh.value(lc.id) - BruitAvancementWh) {
// RECUL FRANC de l'accumulateur : le plugin l'a remis à la valeur brute, donc une
// nouvelle session s'est ouverte. On recapture, sinon la différence devient négative.
// Ce n'est PAS la détection de remise à zéro de la borne — celle-là vit dans le plugin,
// à 30 s, là où le moteur travaille à la minute et raterait des cycles entiers.
qCInfo(dcNymeaEnergy()) << "[Arbitre] Avancement éco" << lc.id
<< "— l'accumulateur a reculé de"
<< (m_ecoBaseWh.value(lc.id) - sessionWh)
<< "Wh : nouvelle session, base reprise à" << sessionWh << "Wh.";
m_ecoBaseWh.insert(lc.id, sessionWh);
}
m_ecoDernierSessionWh.insert(lc.id, sessionWh); // ce qui clôturera le tronçon s'il tombe
p.regime = QString::fromLatin1(ProgressRegime::Measured);
p.delivered = true;
p.measuredSince = m_ecoMesureDepuis.value(lc.id);
p.measuredGaps = m_ecoTrous.value(lc.id, 0);
// Un recul INFIME laisse la base au-dessus de la valeur lue ; l'écrêtage évite un
// avancement négatif sans masquer un vrai recul, qui a déjà provoqué la recapture.
// Le livré de la période = les tronçons CLOS + celui en cours. Sans le premier terme, un
// lien qui bat efface son propre historique à chaque retour.
p.deliveredWh = m_ecoLivreWh.value(lc.id, 0.0)
+ qMax(0.0, sessionWh - m_ecoBaseWh.value(lc.id));
return p;
}
void EnergyArbitrator::shedWaterfallForOverload(const QDateTime &now)
{
m_shedActions.clear();
const DrawMargin marge = houseDrawMargin();
if (!marge.borne || marge.watts >= 0)
return; // aucun plafond en vigueur, ou aucun dépassement : rien à délester
double resteW = -marge.watts; // ce qu'il faut RETIRER à la maison, en watts
// §6.2 — le franchissement des verrous dépend de QUI impose le plafond, et de rien d'autre.
// Le disjoncteur ne connaît pas le court-cycling ; un ordre du gestionnaire de réseau ne se
// négocie pas. Un plafond qu'on s'impose, si : on renonce au plancher éco plutôt qu'au
// matériel.
const bool franchitLesVerrous = marge.source == DrawCapSource::Breaker
|| marge.source == DrawCapSource::GridOperator;
// Ordre INVERSE du rang : la dernière servie est la première délestée. C'est le même ordre
// total que la cascade, lu à l'envers — donc la même règle de départage, pour que délester
// ne puisse pas contredire l'ordre dans lequel on a servi.
QList<QString> ids = m_loadAdapters.keys();
std::sort(ids.begin(), ids.end(), [this](const QString &a, const QString &b) {
const int ra = m_builtFrom.value(a).priority();
const int rb = m_builtFrom.value(b).priority();
if (ra != rb)
return ra > rb;
return a > b;
});
for (const QString &id : ids) {
if (resteW <= 0)
break;
ILoadAdapter *adaptateur = m_loadAdapters.value(id);
if (!adaptateur)
continue;
const LoadTelemetry t = adaptateur->telemetry();
// Une charge indisponible ne se déleste pas : on ne sait plus la commander, et
// prétendre l'avoir coupée ferait croire le dépassement résorbé (ECS-410).
if (!t.available)
continue;
// La puissance engagée, lue sur un état POSÉ. Aucune écriture n'a encore eu lieu ce
// cycle, donc l'inflation d'ECS-414 — « tant que des écritures sont en vol, annoncer
// la plus haute des puissances possibles » — ne s'applique pas ici. C'est la seconde
// raison d'évaluer L4 avant le dispatch, et non la moindre : ailleurs dans le cycle,
// ce nombre est délibérément majorant, ce qui est juste pour un budget et faux pour
// un délestage.
const double engageW = t.currentPowerW;
if (engageW <= 0)
continue; // rien d'engagé : il n'y a rien à retirer
LoadAction shed;
shed.loadId = id;
// DÉLESTER N'EST PAS TOUJOURS RETIRER DES WATTS. Une PAC ne se module pas : on la fait
// redescendre d'ÉTAT, et l'état sûr est 2 (normal), jamais 1 — bloquer une PAC n'est
// pas la mettre en sécurité, c'est arrêter le chauffage sans raison visible (ECS-413).
// C'est ici que le verrou minStateHold s'oppose au délestage, et donc ici que le
// franchissement prend tout son sens : sans lui, une PAC à 3 kW en état 4 tiendrait
// 900 secondes de plus, pendant que le disjoncteur, lui, ne patiente pas.
double cibleW = 0;
if (adaptateur->runtimeView(now).mechanismKind == QStringLiteral("sgReady")) {
shed.kind = LoadAction::State;
shed.state = 2; // ECS-413 : l'état sûr d'une PAC est 2, jamais 1
} else {
cibleW = qMax(0.0, engageW - resteW);
shed.kind = LoadAction::Setpoint;
shed.powerW = cibleW;
shed.estimatedPowerW = cibleW;
shed.chargingEnabled = cibleW > 0;
}
// Motif POSÉ AVANT l'application : les adaptateurs refusent une action sans motif, et
// c'est voulu. Les chiffres réalisés le corrigeront juste après.
shed.reason = { DecisionCode::DrawCap, drawCapReasonParams(marge, true, 0, false) };
// LE FRANCHISSEMENT SE CONSTATE, IL NE SE PRÉSUME PAS. On tente D'ABORD sans forcer :
// si le verrou laisse passer, il n'y a rien à annoncer. C'est seulement quand il
// refuse que le forçage a lieu — et alors le marqueur dit une chose vérifiée.
//
// Déduire le franchissement d'un verrou simplement ACTIF serait faux : un verrou peut
// être armé et permettre malgré tout le geste demandé (une descente vers l'état sûr,
// une réduction déjà dans sa fenêtre). Le marqueur annoncerait alors un court-cycle
// qui n'a pas eu lieu, et le premier compresseur réellement maltraité passerait
// inaperçu au milieu des fausses alertes.
// La CIBLE est-elle atteinte ? La question ne se lit pas au même endroit selon le
// geste, et s'y tromper fait mentir tout le reste. Une action State REFUSÉE revient
// avec l'état courant et un `estimatedPowerW` que l'adaptateur ne renseigne pas —
// le lire donnerait « 0 W, donc cible atteinte », c'est-à-dire l'exact contraire.
auto cibleAtteinte = [&](const LoadAction &resultat) {
if (shed.kind == LoadAction::State)
return resultat.state == shed.state;
return resultat.estimatedPowerW <= cibleW + 0.5;
};
LoadAction essai = shed;
essai.force = false;
LoadAction applique = adaptateur->applyAction(essai, now);
bool minOnFranchi = false;
if (!cibleAtteinte(applique)) {
// Le verrou a refusé. §6.2 — on ne force que pour un plafond SUBI.
if (!franchitLesVerrous)
continue; // plafond auto-imposé : on renonce au plancher, pas au matériel
shed.force = true;
applique = adaptateur->applyAction(shed, now);
minOnFranchi = cibleAtteinte(applique);
}
// Ce qui a RÉELLEMENT été rendu — lu, là encore, à l'endroit qui correspond au geste.
const double apresW = shed.kind == LoadAction::State
? adaptateur->toLoadContext(now).declared.estimatedPowerW.value(applique.state, 0.0)
: applique.estimatedPowerW;
const double rendu = qMax(0.0, engageW - apresW);
resteW -= rendu;
// Le motif publié porte ce qui a été RÉELLEMENT rendu, pas ce qu'on espérait rendre.
// Un verrou peut avoir écrêté le geste ; l'annoncer entier ferait chercher ailleurs la
// cause d'un dépassement qui persiste.
if (rendu <= 0)
continue; // rien n'a bougé : ne pas publier un délestage qui n'a pas eu lieu
LoadAction publie = applique;
publie.reason = { DecisionCode::DrawCap,
drawCapReasonParams(marge, true, rendu, minOnFranchi) };
// REVENDICATION : cette charge est commandée par L4 ce cycle, le dispatch la sautera.
m_shedActions.insert(id, publie);
qCInfo(dcNymeaEnergy()) << "[Arbitre] Délestage L4" << id
<< "→" << renderFr(publie.reason, id)
<< "| rendu:" << rendu << "W | reste à délester:" << resteW << "W";
}
if (resteW > 0)
qCWarning(dcNymeaEnergy()) << "[Arbitre] Délestage L4 INCOMPLET —" << resteW
<< "W restent au-dessus du plafond après avoir délesté tout "
"ce qui pouvait l'être. La cause est hors du waterfall.";
}
void EnergyArbitrator::fusionnerDelestageDansSlot(Slot &slot) const
{
for (auto it = m_shedActions.constBegin(); it != m_shedActions.constEnd(); ++it) {
bool remplace = false;
for (int i = 0; i < slot.actions.size(); ++i) {
if (slot.actions.at(i).loadId == it.key()) {
slot.actions[i] = it.value();
remplace = true;
break;
}
}
if (!remplace)
slot.actions.append(it.value());
}
}
bool EnergyArbitrator::evParticipatesInWaterfall(EvCharger *ev) const
{
if (!ev)
return false;
const ChargingInfo info = chargingInfo(ev->id());
// LM-1208-b — « pas de voiture assignée » N'EST PLUS un critère d'exclusion.
//
// Le critère venait du filtre d'entrée du proxy, où il avait un sens : sans voiture, le
// proxy n'a ni SoC, ni capacité, ni échéance à planifier, et il n'a donc rien à faire de
// cette borne. Le waterfall, lui, n'a besoin d'aucune des trois pour allouer du surplus —
// le rang et le plancher appartiennent à la BORNE (LM-1206), pas au véhicule.
//
// Trois raisons de le retirer, et la troisième est décisive :
// · le cas de l'INVITÉ le rend intenable — une voiture qui n'est pas celle du
// propriétaire ne sera jamais déclarée, et la borne cesserait d'être arbitrée
// précisément quand elle consomme (3,9 kW mesurés au banc le 2026-08-28) ;
// · le critère n'est pas VÉRIFIABLE — l'assignation est déclarée à la main, rien ne dit
// ce qui est réellement branché : un critère d'arbitrage assis sur une déclaration
// invérifiable n'arbitre pas, il devine ;
// · il CONTREDIT LM-1206, en réintroduisant la dépendance au véhicule que LM-1206 avait
// écartée.
//
// Ce qu'on perd sans voiture assignée est une FONCTION — l'échéance et la cible d'énergie,
// qui restent au proxy et que le proxy continue d'ignorer pour cette borne — et non
// l'arbitrage. Dégradation, pas exclusion : la même distinction que C1 de LM-1009.
//
// CONSÉQUENCE À TENIR AILLEURS : le manager ne PRÉPARE pas une borne sans voiture
// (prepareInformation() la filtre en entrée), donc son ChargingProcessInfo n'existe pas.
// C'est evTargetPhases() qui porte ce cas — sans quoi une borne triphasée serait annoncée
// monophasée et le budget divisé par trois.
// Mode manuel : l'utilisateur pilote, l'arbitre s'abstient. Hors arbitrage — pas rang 0.
//
// Sauf DÉROGATION en cours. La pose bascule bien le mode en éco (cf. poseDerogation), donc
// ce test suffirait ; on le garde explicite parce qu'il dit l'ordre des choses : la
// dérogation SURMONTE le mode. Si un jour la pose cessait d'écrire le mode, la borne
// resterait servie au lieu de sortir silencieusement de l'arbitrage.
if (info.chargingMode() == ChargingInfo::ChargingModeNormal
&& !m_derogations.contains(ev->id().toString()))
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 le 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 l'autre.
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;
// LM-1208-b — une borne SANS VOITURE ASSIGNÉE est arbitrée, mais le manager ne la prépare
// pas : prepareInformation() la filtre en entrée, donc elle n'a aucun ChargingProcessInfo.
// internalProcessInfo() rendrait alors un objet PAR DÉFAUT — canSwitchPhaseCount = false,
// maxPossiblePhaseCount = 1 — c'est-à-dire « monophasée » pour une borne qui peut être
// triphasée. Le budget serait divisé par trois, et EN SILENCE : le waterfall allouerait
// 3 680 W en croyant servir 16 A sur une phase, l'adaptateur commanderait 16 A, et le
// matériel en tirerait 11 040 W. C'est le défaut de 3g-1 pris à l'envers.
//
// Sans voiture, la capacité du véhicule est INCONNUE, et une valeur par défaut plausible
// est le pire des masques (LM-104). Deux conséquences, une seule décision : on ne BASCULE
// pas — basculer suppose de savoir ce que la voiture accepte, et executeChargingAction()
// ne commanderait de toute façon aucun desiredPhaseCount sans ChargingProcessInfo — et on
// retient le nombre de phases de la BORNE, qui est celui qu'elle utilisera réellement.
// C'est l'hypothèse conservatrice pour le budget : elle SURESTIME la consommation de la
// borne plutôt que de la sous-estimer.
// \rule{LM-1208-c}
if (!internalHasProcessInfo(ev))
return qMax(1u, ev->phaseCount());
// 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));
}
QList<DrawCap> EnergyArbitrator::drawCaps() const
{
QList<DrawCap> plafonds;
// 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 limiteA = phasePowerLimit();
if (limiteA > 0) {
DrawCap disjoncteur;
disjoncteur.source = DrawCapSource::Breaker;
disjoncteur.perPhaseW = limiteA * 230.0;
plafonds.append(disjoncteur);
}
// GridOperator et EcoSelfImposed : aucun émetteur à ce stade. Leur absence de la liste
// n'est pas un oubli — c'est l'état du système, et la liste dit lequel des plafonds
// existe RÉELLEMENT. Un plafond qu'on n'a pas ne doit pas figurer à zéro (LM-1013).
return plafonds;
}
QList<double> EnergyArbitrator::phaseDrawW(Electricity::Phases masque, uint limiteA) const
{
QList<double> tirages;
RootMeter *meter = internalRootMeter();
if (!meter || limiteA == 0)
return tirages;
// Masque vide == PhaseNone : le proxy interroge alors LES TROIS phases. Reproduire la
// convention plutôt que de renvoyer une liste vide, qui ferait passer « toutes les
// phases » pour « aucune contrainte ».
const QList<Electricity::Phase> toutes =
{ Electricity::PhaseA, Electricity::PhaseB, Electricity::PhaseC };
for (Electricity::Phase p : toutes) {
if (masque != Electricity::PhaseNone && !masque.testFlag(p))
continue;
// Tirage de la phase, en watts, obtenu en INVERSANT calculateAllowanceAmpere plutôt
// qu'en relisant les états du compteur. Relire serait dupliquer des lectures qui
// diffèrent d'une phase à l'autre côté amont (rootmeter.cpp : la phase A se lit en
// ampères, B et C en watts/230), et laisser les deux copies dériver. L'inversion rend
// l'égalité au watt VRAIE PAR CONSTRUCTION, quelles que soient ces lectures.
const double margeA = meter->calculateAllowanceAmpere(p, limiteA);
tirages.append((limiteA - margeA) * 230.0);
}
return tirages;
}
DrawMargin EnergyArbitrator::houseDrawMargin() const
{
DrawMargin sansObjet;
RootMeter *meter = internalRootMeter();
if (!meter)
return sansObjet;
const QList<DrawCap> plafonds = drawCaps();
if (plafonds.isEmpty())
return sansObjet;
// TOUTES les phases, et la plus contrainte l'emporte. C'est le seul choix défendable tant
// que le modèle ne connaît qu'un NOMBRE de phases par charge et jamais LESQUELLES : une
// monophasée de phase inconnue peut tomber sur n'importe laquelle, donc sur la pire.
// Supposer mieux serait deviner, et deviner dans une couche de sécurité se paie au
// disjoncteur. Le pessimisme ne mord d'ailleurs que là où il doit : une phase qui exporte
// est AU MAXIMUM de la marge — celle-ci étant écrêtée au plafond, l'export ne permettant pas
// de tirer plus que le disjoncteur — et ne borne donc jamais.
return resolveDrawMargin(plafonds,
phaseDrawW(Electricity::PhaseNone, phasePowerLimit()),
meter->currentPower());
}
DrawMargin EnergyArbitrator::evDrawMargin(EvCharger *ev, uint phaseCount) const
{
DrawMargin sansObjet;
if (!ev || phaseCount == 0 || !internalRootMeter())
return sansObjet;
const QList<DrawCap> plafonds = drawCaps();
if (plafonds.isEmpty())
return sansObjet;
// MÊME masque que le proxy — la marge d'une borne se juge sur les phases qu'elle occupe,
// pas sur celles de la maison. Une monophasée sur A n'a pas à répondre d'une surcharge
// sur C, qu'elle ne peut ni causer ni soulager.
const Electricity::Phases masque =
const_cast<EnergyArbitrator *>(this)->phasesForCount(phaseCount);
return resolveDrawMargin(plafonds,
phaseDrawW(masque, phasePowerLimit()),
internalRootMeter()->currentPower());
}
double EnergyArbitrator::evPhaseAllowanceW(EvCharger *ev, uint phaseCount) const
{
if (!ev || phaseCount == 0)
return -1;
const DrawMargin marge = evDrawMargin(ev, phaseCount);
if (!marge.borne)
return -1;
// Place LIBRE offerte à la borne, et surtout SANS lui rendre 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 marge 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.)
//
// L'écrêtage à zéro est fait ICI, chez le consommateur, et non dans la résolution : la
// marge PORTE le dépassement (elle vaut -1200 quand on dépasse de 1200 W) parce que
// l'étape 4 en a besoin pour dire de combien délester. Ce qu'une borne peut prendre
// n'est jamais négatif ; ce qu'il faut retirer à la maison, si.
return qMax(0.0, marge.watts) * 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);
}
namespace {
//! Bornes de la durée du filet : au moins une minute, au plus six heures. Une dérogation qui
//! durerait davantage n'est plus un besoin ponctuel — c'est un mode qu'on n'a pas voulu nommer.
constexpr int DerogationDureeMinS = 60;
constexpr int DerogationDureeMaxS = 6 * 3600;
}
bool EnergyArbitrator::poseDerogation(const QString &loadId, int dureeS, double targetWh,
const QDateTime &now)
{
const bool estBorne = m_adapters.contains(loadId);
if (!estBorne && !m_loadAdapters.contains(loadId)) {
qCWarning(dcNymeaEnergy()) << "[Dérogation] charge inconnue :" << loadId;
return false;
}
// \rule{LM-1500} — la durée est BORNÉE des deux côtés, et l'absence de durée n'est pas
// acceptée : une dérogation qui ne sait pas dire quand elle finit ne devrait pas exister.
// Refuser ici est le seul endroit où on peut encore la faire respecter.
if (dureeS < DerogationDureeMinS || dureeS > DerogationDureeMaxS) {
qCWarning(dcNymeaEnergy()) << "[Dérogation] durée hors bornes :" << dureeS
<< "s (attendu" << DerogationDureeMinS << ".."
<< DerogationDureeMaxS << ")";
return false;
}
Derogation d;
d.loadId = loadId;
// ANCRÉS EN UTC À LA CAPTURE. Un QDateTime local résout son époque à la CONVERSION : une
// échéance qui se déplace d'une heure au changement de fuseau n'est plus un filet.
//
// ET SUR L'HORLOGE DU CYCLE, pas sur une autre. En production les deux sont la même —
// update() est déclenché avec QDateTime::currentDateTime(). Sous test, le cycle est
// INJECTÉ : une échéance prise sur l'horloge murale et comparée à un instant simulé
// n'expire jamais, et la fin par durée passerait pour absente alors qu'elle est seulement
// hors d'atteinte. Trouvé par le test, pas par la lecture.
d.startedAt = (now.isValid() ? now : QDateTime::currentDateTime()).toUTC();
d.expiresAt = d.startedAt.addSecs(dureeS);
d.targetWh = qMax(0.0, targetWh);
// baseWh reste à capturer : au moment de la pose, aucun cycle n'a construit de contexte et
// le cumul de session n'est lisible que là (cf. Derogation::baseCapturee).
ChargingInfo pendant;
bool aBasculer = false;
if (estBorne) {
const ChargingInfo avant = chargingInfo(ThingId(loadId));
d.previousMode = static_cast<int>(avant.chargingMode());
// Une borne en MANUEL est hors waterfall : le moteur ne la commande pas, et une
// dérogation n'y produirait rien du tout. On la fait entrer dans l'arbitrage pour la
// durée — et c'est le SEUL cas où le mode est touché.
if (avant.chargingMode() == ChargingInfo::ChargingModeNormal) {
pendant = avant;
pendant.setChargingMode(ChargingInfo::ChargingModeEco);
d.modeForce = true;
aBasculer = true;
}
// Ce que la dérogation s'attend à voir tant qu'elle dure : le mode forcé, ou celui qui
// était déjà là. Toute autre valeur au cycle suivant est une écriture extérieure.
d.modeAttendu = d.modeForce ? static_cast<int>(ChargingInfo::ChargingModeEco)
: d.previousMode;
}
m_derogations.insert(loadId, d);
m_derogationFin.remove(loadId);
qCInfo(dcNymeaEnergy()).noquote()
<< QStringLiteral("[Dérogation] POSÉE %1 — durée %2 s, %3, mode mémorisé %4%5")
.arg(loadId).arg(dureeS)
.arg(d.targetWh > 0 ? QStringLiteral("cible %1 Wh").arg(d.targetWh, 0, 'f', 0)
: QStringLiteral("sans cible"))
.arg(d.previousMode)
.arg(d.modeForce ? QStringLiteral(" (forcé en éco pour la durée)") : QString());
// L'ÉCRITURE DU MODE VIENT EN DERNIER, et l'ordre est décisif : setChargingInfo() déclenche
// lui-même un cycle. La dérogation est donc DÉJÀ posée quand ce cycle passe, et il la sert
// immédiatement. L'inverse la lui aurait fait manquer d'un cycle — soixante secondes
// d'attente sur un mécanisme dont la définition est « tout de suite ».
if (aBasculer)
setChargingInfo(pendant);
return true;
}
bool EnergyArbitrator::leveDerogation(const QString &loadId)
{
if (!m_derogations.contains(loadId))
return false;
const Derogation d = m_derogations.take(loadId);
rendreModeMemorise(d);
m_derogationFin.insert(loadId, QStringLiteral("cancelled"));
m_derogationModeRendu.insert(loadId, d.previousMode);
qCInfo(dcNymeaEnergy()) << "[Dérogation] LEVÉE à la main" << loadId
<< "— mode rendu" << d.previousMode;
return true;
}
void EnergyArbitrator::rendreModeMemorise(const Derogation &d)
{
// \rule{LM-1500-d} — RETOUR AU MODE MÉMORISÉ, et à lui seul. Revenir à un défaut sortirait
// une borne manuelle de la main de son utilisateur — le contraire exact de ce qu'une
// dérogation promet, qui est de rendre la main.
if (!d.modeForce || d.previousMode < 0)
return;
const ThingId id(d.loadId);
const int mode = d.previousMode;
// DIFFÉRÉ D'UN TOUR DE BOUCLE, et ce n'est pas une précaution de style : les fins sont
// évaluées DEPUIS update(), et setChargingInfo() déclenche lui-même un update(). Écrire ici
// imbriquerait un cycle dans un cycle — deux plans construits et deux dispatchs pour le
// même instant, sur des charges qu'un seul des deux croirait avoir commandées.
//
// Le décalage ne se voit nulle part : l'événement de fin est publié dans la trame de CE
// cycle, et le mode est rendu avant le suivant.
QTimer::singleShot(0, this, [this, id, mode]() {
if (!internalEvChargers().contains(id))
return; // la borne a disparu entre-temps : il n'y a plus de réglage à rendre
ChargingInfo info = chargingInfo(id);
info.setChargingMode(static_cast<ChargingInfo::ChargingMode>(mode));
setChargingInfo(info);
});
}
void EnergyArbitrator::evaluerDerogations(SurplusContext &ctx, const QDateTime &now)
{
// Motifs d'ÉVÉNEMENT : ils valent pour CE cycle et disparaissent. Une fin qui resterait
// affichée serait indistinguable d'une fin qui vient de se produire.
m_derogationFin.clear();
m_derogationModeRendu.clear();
if (m_derogations.isEmpty())
return;
// On lit le CONTEXTE DU CYCLE, pas les adaptateurs : c'est exactement ce que le scheduler
// va lire pour décider. Une seconde lecture donnerait une seconde vérité, et la dérogation
// pourrait se terminer sur une mesure que le plan ne voit pas.
QHash<QString, LoadContext *> parId;
for (LoadContext &lc : ctx.loads)
parId.insert(lc.id, &lc);
const QDateTime nowUtc = now.toUTC();
for (const QString &id : m_derogations.keys()) {
Derogation &d = m_derogations[id];
LoadContext *lc = parId.value(id, nullptr);
// ABSENT DU CONTEXTE = la charge n'est plus arbitrable. Pour une borne c'est le
// débranchement (evParticipatesInWaterfall la filtre), et la mesure du 2026-09-07 dit
// que ce constat-là est FERME : `pluggedIn` tombe à faux et y reste. On le confirme
// quand même — le seuil est bas, et l'asymétrie joue dans le bon sens.
const bool branchee = lc && lc->telemetry.available;
const bool mesurable = lc && lc->telemetry.sessionMeasurable;
d.absencesConsecutives = branchee ? 0 : d.absencesConsecutives + 1;
d.sansMesureConsecutifs = mesurable ? 0 : d.sansMesureConsecutifs + 1;
// Base capturée au premier cycle qui la rend lisible, jamais supposée.
if (!d.baseCapturee && mesurable) {
d.baseWh = lc->telemetry.sessionWh;
d.baseCapturee = true;
}
const double livreWh = (d.baseCapturee && mesurable)
? qMax(0.0, lc->telemetry.sessionWh - d.baseWh)
: 0.0;
// \rule{LM-1500-g} — QUELQU'UN A REPRIS LE VOLANT, testé AVANT tout le reste, parce que c'est une
// décision et non un constat. Choisir un mode est durable ; une dérogation répond à un
// besoin ponctuel, et le durable prime.
//
// Le laisser passer coûte cher, et le banc l'a montré (2026-09-08) : un
// ChargingModeNormal écrit pendant une dérogation fait restituer les valeurs manuelles
// par l'amont — chargingEnabled à faux — et arme un verrou de 300 s. La dérogation
// continuait de commander 9 A dans une borne éteinte et verrouillée : deux commandeurs
// pour un organe, que seul le journal de divergence voyait.
bool volantRepris = false;
if (d.modeAttendu >= 0 && internalEvChargers().contains(ThingId(id)))
volantRepris = static_cast<int>(chargingInfo(ThingId(id)).chargingMode()) != d.modeAttendu;
QString cause;
// ORDRE : la plus informative d'abord. C'est la cause que l'utilisateur doit lire, et
// la durée n'est pas un diagnostic — elle vient donc en dernier, comme filet.
if (volantRepris) {
cause = QString::fromLatin1(DerogationEnd::ModeChanged);
} else if (d.targetWh > 0 && d.baseCapturee && livreWh >= d.targetWh) {
cause = QString::fromLatin1(DerogationEnd::TargetReached);
} else if (d.targetWh > 0 && d.sansMesureConsecutifs >= DerogationConfirmationsMesure) {
// \rule{LM-1500-b} — une dérogation dont la CIBLE cesse d'être mesurable doit
// cesser : elle ACHÈTE, et
// acheter sans savoir où l'on en est, c'est acheter à l'aveugle. Sans cible, rien
// ne dépend de la mesure — le filet de durée suffit, et la couper ici serait
// punir la charge du silence d'un instrument dont elle n'a pas besoin.
cause = QString::fromLatin1(DerogationEnd::MeasurementLost);
} else if (d.absencesConsecutives >= DerogationConfirmationsAbsence) {
cause = QString::fromLatin1(DerogationEnd::Unplugged);
} else if (nowUtc >= d.expiresAt) {
cause = QString::fromLatin1(DerogationEnd::DurationElapsed);
}
if (cause.isEmpty()) {
// Elle continue : on la POSE dans le contexte, et c'est de là que le scheduler la
// servira en tête de passe éco.
if (lc) {
lc->derogation = true;
lc->derogationEndsAt = d.expiresAt;
lc->derogationTargetWh = d.targetWh;
lc->derogationDeliveredWh = livreWh;
}
continue;
}
const Derogation finie = m_derogations.take(id);
// PAS de restitution quand le volant a été repris : le mode mémorisé écraserait celui
// que l'utilisateur vient de choisir — on lui reprendrait sa décision au nom de la
// nôtre, à un tour de boucle près et sans qu'il puisse le voir.
if (cause != QString::fromLatin1(DerogationEnd::ModeChanged))
rendreModeMemorise(finie);
m_derogationFin.insert(id, cause);
m_derogationModeRendu.insert(id, finie.previousMode);
qCInfo(dcNymeaEnergy()).noquote()
<< QStringLiteral("[Dérogation] FIN %1 — cause %2, livré %3 Wh sur %4, mode rendu %5")
.arg(id, cause).arg(livreWh, 0, 'f', 0)
.arg(finie.targetWh > 0 ? QStringLiteral("%1 Wh").arg(finie.targetWh, 0, 'f', 0)
: QStringLiteral("aucune cible"))
.arg(finie.previousMode);
}
}
void EnergyArbitrator::alimenterAvancement(LoadContext &lc) const
{
// CONTRADICTION CORRIGÉE (2026-09-03, remontée par l'app). `progressMeasurable` dit ce que
// le MATÉRIEL permet ; `regime` dit ce que le MOTEUR fait. Seul l'EvAdapter remplissait
// `sessionMeasurable`, si bien que toute charge non-VE était `unmeasurable` alors que sa
// configuration annonçait l'inverse.
//
// Ce n'est pas qu'une incohérence d'affichage : l'avertissement de saisie de l'app se
// déclenche sur `progressMeasurable == false`, donc il restait MUET exactement sur les
// charges achetées à l'aveugle — celles dont l'obligation ne pouvait pas être suivie.
if (lc.telemetry.sessionMeasurable || !m_tm || !m_loadConfigStore)
return; // déjà renseigné par l'adaptateur (VE), ou rien pour le résoudre
for (const LoadConfig &c : m_loadConfigStore->configs()) {
if (c.id() != lc.id || c.meterThingId().isEmpty())
continue;
Thing *compteur = m_tm->findConfiguredThing(ThingId(c.meterThingId()));
// Il faut un CUMUL, pas une puissance : `currentPower` ne dit rien de ce qui a été
// livré. C'est la même exigence que celle publiée en `progressUnmeasurableCause`, et
// elle doit être la MÊME des deux côtés — sinon la contradiction se déplace au lieu de
// disparaître.
if (compteur && compteur->thingClass().hasStateType("totalEnergyConsumed")) {
lc.telemetry.sessionMeasurable = true;
// Compteur de VIE : un recul y est une ANOMALIE, jamais une nouvelle session.
lc.telemetry.sessionCumulative = true;
// Compteur de VIE, pas de session — et c'est sans importance : l'avancement est une
// DIFFÉRENCE depuis une base capturée à l'ouverture de l'obligation (LM-1012-b).
// Un cumul monotone est même plus sûr qu'un compteur de session, qui recule.
lc.telemetry.sessionWh = compteur->stateValue("totalEnergyConsumed").toDouble() * 1000.0;
}
return;
}
}
void EnergyArbitrator::qualifierRefus(LoadContext &lc, const QDateTime &now) const
{
const QDateTime depuis = m_divergenceDepuis.value(lc.id);
if (!depuis.isValid())
return; // aucun écart en cours : rien à qualifier
const int ecoule = static_cast<int>(depuis.secsTo(now));
lc.telemetry.refusedSinceS = qMax(0, ecoule);
// Le seuil vient du MÉCANISME. Un écart récent est une latence — une charge qui vient
// d'être commandée n'a pas encore atteint sa consigne, et suspendre là ferait osciller une
// charge saine. Au-delà, ce n'est plus une latence, c'est un refus.
ILoadAdapter *a = m_loadAdapters.value(lc.id, nullptr);
if (!a)
a = m_adapters.value(lc.id, nullptr);
if (!a)
return;
// Sens de la commande : on juge la DESCENTE sur la latence de descente. Le cas dangereux —
// commander la baisse sans être suivi — est justement celui qui se constate le plus vite.
const bool montee = lc.telemetry.currentPowerW < lc.declared.maxPowerW / 2.0;
lc.telemetry.commandRefused = ecoule > a->commandSettleS(montee);
}
SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const
{
SurplusContext ctx;
ctx.timestamp = now;
// Marge de soutirage du cycle — résolue ICI, une fois, avec le reste des mesures.
ctx.drawMargin = houseDrawMargin();
// --- 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. DEUX exclusions seulement, et elles ne disent pas la même chose : le
// mode manuel est une INTENTION (l'utilisateur pilote, l'arbitre s'abstient), l'absence de
// véhicule branché un CONSTAT (la borne ne peut rien tirer de ce qu'on lui allouerait).
// « Pas de voiture assignée » n'en est plus une — LM-1208-b, cf. evParticipatesInWaterfall().
//
// Une borne exclue n'est pas pour autant sortie de la COMPTABILITÉ (LM-1208) : sa
// consommation est déjà dans le compteur racine, donc déjà retranchée du 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 (mode manuel ou aucun véhicule "
"branché).";
continue;
}
LoadContext lcq = it.value()->toLoadContext(now);
alimenterAvancement(lcq);
qualifierRefus(lcq, now);
ctx.loads.append(lcq);
}
// --- 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;
LoadContext lcq = lc;
alimenterAvancement(lcq);
qualifierRefus(lcq, now);
ctx.loads.append(lcq);
}
}
// --- 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) {
LoadContext lcq = it.value()->toLoadContext(now);
alimenterAvancement(lcq);
qualifierRefus(lcq, now);
ctx.loads.append(lcq);
}
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();
// LM-1209 — EN QUEUE, jamais en tête. L'auto-provisionnement REMPLIT UN TROU : sans entrée,
// la borne serait arbitrée sans rang réglable. Il ne s'ensuit rien sur sa PLACE — le rang
// est un choix de l'installateur, et une détection ne réordonne pas ce qu'un humain a
// décidé. Une borne détectée un mois après la mise en service passerait sinon devant l'ECS
// que quelqu'un a réglé exprès.
//
// Ne contredit PAS le défaut « le VE d'abord » : celui-là décrit une MISE EN SERVICE
// INITIALE, où personne n'a encore rien classé — ici la liste est vide et max+1 vaut 1.
//
// Queue de LISTE et non queue du DOMAINE, et c'est plus fidèle au principe : les domaines
// ne sont pas contigus, donc « juste après le dernier ev » exigerait de pousser une charge
// d'un autre domaine (interdit, LM-1209-b). Comme max(domaine) ≤ max(liste), la queue de
// liste est TOUJOURS au moins aussi reculée — elle coïncide quand le domaine occupe la
// fin, et sinon elle place la nouvelle charge derrière tout le monde plutôt que de
// l'intercaler devant une charge classée.
int rangMax = 0;
for (const LoadConfig &c : configs) {
if (c.id() == id)
return; // déjà configurée : on ne touche à rien
rangMax = qMax(rangMax, c.priority());
}
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"));
// 1 est le premier rang (protocole §5) : liste vide → 1. Libre par construction, donc
// l'égalité de rang est impossible par ce chemin (LM-1209-a) — et AUCUN autre rang ne
// bouge (LM-1209-b) : insérer est un geste de configuration, pas un geste d'arbitrage.
c.setPriority(qMax(1, rangMax + 1));
// LM-1209-c — la création se DIT. Le journal ne suffit pas : l'app ne le lit pas, et c'est
// elle qui montre le classement. Cette marque distingue « ce rang est un défaut » de « ce
// rang est un choix » — un client ne peut pas le deviner, rien dans le nombre ne le dit.
c.setRankOrigin(QStringLiteral("auto"));
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.
//
// …mais UNE SEULE FOIS par borne. Depuis LM-1209-e le rattrapage est tenté à chaque
// cycle : répéter l'avertissement en produirait un par minute et pour toujours, ce qui
// noierait le vrai signal — c'est la règle déjà posée pour les bornes sans décision
// dans buildTelemetry(). Le drapeau est levé au premier succès, donc un défaut qui se
// résout se redit s'il revient.
if (!m_provisionRefuse.contains(id)) {
m_provisionRefuse.insert(id);
qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] Configuration automatique de la borne"
<< ev->name() << "REFUSÉE :" << erreur
<< "— elle sera arbitrée sans rang configurable. Réessayé"
<< "à chaque cycle ; cet avertissement ne sera pas répété.";
}
return;
}
m_provisionRefuse.remove(id);
qCInfo(dcNymeaEnergy()) << "[EnergyArbitrator] Borne" << ev->name()
<< "ajoutée à la configuration des charges (rang" << c.priority()
<< ", domaine ev, rankOrigin=auto) — entrée créée automatiquement, "
"insérée EN QUEUE sans déplacer personne. Le rang est un défaut, "
"pas un choix : modifiable par SetLoadConfig.";
}
void EnergyArbitrator::syncAdapters()
{
// Crée les adapters manquants, et RATTRAPE toute borne sans entrée de configuration.
//
// LM-1209-e — les deux gestes sont SÉPARÉS, et ils l'étaient à tort. Le provisionnement
// vivait dans la branche `!m_adapters.contains(id)`, donc il ne tournait qu'UNE FOIS par
// vie d'adaptateur. Or l'adaptateur est construit depuis le THING : il survit à la
// suppression de l'entrée de configuration. Une entrée supprimée depuis l'app n'était donc
// jamais recréée, et la borne restait ARBITRÉE mais ABSENTE de GetLoadConfig, à un rang de
// queue non réglable (evPriority() → 1000) — le trou refermé en 3g-2, rouvert par le côté
// client. Mesuré sur `.75` le 2026-08-29 : rien recréé en 140 s.
//
// Le rattrapage est donc tenté à CHAQUE cycle. provisionEvChargerConfig() rend la main
// aussitôt si l'entrée existe : la condition n'est pas « l'adaptateur vient de naître »,
// c'est « cette borne n'a pas d'entrée ».
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.
// Délestée par L4 ce cycle : un seul commandeur par organe. Réappliquer la consigne
// du plan rallumerait ce que la sécurité vient de couper, et c'est la règle absolue 1.
if (m_shedActions.contains(action.loadId))
continue;
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.
// \rule{LM-301-b} — c'est la limite de LM-301 : « une charge utile, une branche de
// fabrique » vaut pour CONSTRUIRE un mécanisme, pas pour l'ALIMENTER en actions.
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);
// R3 — LE SOUTIRAGE, dans la MÊME TRAME que le budget. Les deux décrivent le même cycle ;
// les publier séparément ferait comparer deux instants, et l'identité de réconciliation
// redeviendrait temporelle par un autre chemin — ce que R3 existe pour empêcher.
//
// `committedW` est publié TOUJOURS : le registre des watts achetés existe à chaque cycle,
// et zéro y est une valeur. Les quatre autres ne sont publiés QUE si un plafond est en
// vigueur — leur absence dit « aucun plafond n'existe », là où un `authorisedW: 0` assorti
// d'un `binding` par défaut afficherait un plafond de soutirage inexistant, avec sa source.
// Le zéro est une valeur quand la grandeur existe ; l'absence en est une quand elle n'existe
// pas, et c'est la même règle des deux côtés.
//
// §10 étape B — ces champs ne sont plus un pari sur le §14a : le plafond de branchement est
// résolu à chaque cycle depuis `phasePowerLimit`, donc ils se LISENT. Le refus de les
// publier tenait à leur inexistence ; l'inexistence a cessé.
QVariantMap draw;
draw.insert(QStringLiteral("committedW"), plan.draw.committedW);
if (plan.draw.capBorne) {
draw.insert(QStringLiteral("authorisedW"), plan.draw.authorisedW);
draw.insert(QStringLiteral("remainingW"), plan.draw.remainingW);
draw.insert(QStringLiteral("binding"), plan.draw.bindingCode);
draw.insert(QStringLiteral("perPhaseBound"), plan.draw.perPhaseBound);
// LAQUELLE borne — présent seulement s'il y en a une. `perPhaseBound` dit qu'une phase
// borne, jamais laquelle : l'écran ne pourrait dire que « bridé » sans dire par quoi, et
// enverrait mesurer au hasard sur trois phases dont deux vont bien.
if (!plan.draw.bindingPhase.isEmpty())
draw.insert(QStringLiteral("bindingPhase"), plan.draw.bindingPhase);
}
snap.insert(QStringLiteral("draw"), draw);
// 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);
// R2 — le financement vit dans levels[], plus sur la charge. Une charge dont
// l'appareil manque n'a rien décidé : un seul niveau, à 0 W.
orphan.insert(QStringLiteral("levels"), QVariantList()
<< QVariantMap{{QStringLiteral("level"), QStringLiteral("comfort")},
{QStringLiteral("targetW"), 0.0},
{QStringLiteral("funding"), QStringLiteral("surplus")},
{QStringLiteral("counts"),
QVariantMap{{QString::fromLatin1(CountKey::BudgetAllocated), 0.0}}}});
orphan.insert(QStringLiteral("available"), false);
// R7 — decision.level ACCOMPAGNE levels[] : les deux présents, ou les deux
// absents. Un motif sans niveau à côté d'un tableau de niveaux forcerait le client
// à deviner auquel il se rapporte.
QVariantMap decisionOrphan = action.reason.toMap();
decisionOrphan.insert(QStringLiteral("level"), QStringLiteral("comfort"));
orphan.insert(QStringLiteral("decision"), decisionOrphan);
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);
// --- R2 : LE FINANCEMENT DESCEND AU NIVEAU -------------------------------------
//
// Il était par CHARGE. Au §10 la même charge sera financée au réseau pour son plancher
// éco et au surplus pour son confort DANS LE MÊME CYCLE : un financement par charge y
// devient indécidable, et c'est lui qui porte l'identité de réconciliation de l'app
// (« somme des parts financées au surplus == budget.allocatedW »). Laissé à la charge,
// cette identité cesserait d'être vérifiable sans que rien ne le signale.
//
// Déplacé MAINTENANT bien que le §10 ne soit pas écrit : c'est la seule des huit
// exigences de la maquette qui soit un changement de FORME d'un champ existant, donc la
// seule qui se renchérisse à chaque écran qui s'y appuie.
//
// Aujourd'hui UN SEUL niveau, et c'est « comfort » : la passe unique d'aujourd'hui est
// celle qui, au §10, servira le confort depuis le surplus. La passe éco est la
// nouveauté du lot, pas la passe existante rebaptisée. Une charge sans obligation éco
// n'aura jamais d'entrée « eco » — l'absence est l'information (R1).
//
// « funding » n'est PAS conservé en résumé sur la charge : voir INTERFACE.md. Le cas
// mixte est justement celui qui le rend indécidable, et deux endroits pour un fait
// divergent. Le motif « decision », lui, RESTE — parce qu'on saura dire de quel niveau
// il parle (R7), là où un financement de charge ne pourrait rien dire de vrai.
// §10 — les niveaux sont LUS DU PLAN, plus fabriqués ici. Les fabriquer à la frontière
// ne pouvait donner qu'un seul niveau, puisqu'il n'y avait qu'un endroit pour le former ;
// et une frontière qui invente une structure finit par diverger de ce que le moteur a
// réellement décidé. Elle traduit désormais, elle ne décide plus.
QVariantList niveaux;
for (const LoadAction::Level &n : action.levels) {
QVariantMap niveau;
niveau.insert(QStringLiteral("level"), n.level);
// R4 — le niveau publie ce qu'il a DÉCIDÉ. Σ targetW == allocatedW, exactement.
niveau.insert(QStringLiteral("targetW"), n.targetW);
niveau.insert(QStringLiteral("funding"),
n.funding == LoadAction::Grid ? QStringLiteral("grid")
: QStringLiteral("surplus"));
// R3 — LA VENTILATION : dans quel compteur chaque watt tombe. Distincte de
// « funding », qui dit d'où ils VIENNENT. Publiée TOUJOURS, même à une seule
// destination et même à zéro : un zéro AVEC sa destination dit « cette grandeur
// existe et vaut 0 », l'absence dirait « elle n'existe pas ».
QVariantMap counts;
for (auto it = n.counts.constBegin(); it != n.counts.constEnd(); ++it)
counts.insert(it.key(), it.value());
niveau.insert(QStringLiteral("counts"), counts);
if (!n.reason.isEmpty()) {
QVariantMap d;
d.insert(QStringLiteral("code"), n.reason.code);
d.insert(QStringLiteral("params"), n.reason.params);
niveau.insert(QStringLiteral("decision"), d);
}
// R5 — le RÉGIME d'avancement, porté par le niveau ÉCO et par lui seul.
if (n.hasProgress) {
QVariantMap progress;
progress.insert(QStringLiteral("regime"), n.progress.regime);
progress.insert(QStringLiteral("targetWh"), n.progress.targetWh);
// ABSENT sous `unmeasurable`, jamais 0 : une dégradation ne doit pas se lire
// comme un avancement nul (LM-1009 C1).
if (n.progress.delivered)
progress.insert(QStringLiteral("deliveredWh"), n.progress.deliveredWh);
// L'ACHETÉ est publié dans TOUS les régimes : c'est notre propre commande, donc
// un fait connu sans mesure. Sans lui, « ce que ça coûte par jour » redeviendrait
// une estimation fabriquée à partir du taux.
progress.insert(QStringLiteral("purchasedWh"), n.progress.purchasedWh);
// DEPUIS QUAND on mesure — présent seulement si on mesure. Ferme le piège de la
// bascule en cours de période : « 0,5 sur 4,0 » alors que 3 kWh ont pu être
// achetés avant. L'écran dit « mesuré depuis 09:14 », purchasedWh couvre l'avant.
if (n.progress.measuredSince.isValid())
progress.insert(QStringLiteral("measuredSince"),
n.progress.measuredSince.toUTC().toString(Qt::ISODate));
// L'INTERMITTENCE, publiée seulement si elle a eu lieu. `measuredSince` date le
// PREMIER instant mesuré ; sans ce compteur, il suggérerait une continuité que
// le battement d'un lien Modbus dément. Absent = mesure continue.
if (n.progress.measuredGaps > 0)
progress.insert(QStringLiteral("measuredGaps"), n.progress.measuredGaps);
niveau.insert(QStringLiteral("progress"), progress);
}
niveaux.append(niveau);
}
entry.insert(QStringLiteral("levels"), niveaux);
entry.insert(QStringLiteral("available"), adapter->telemetry().available);
// R7 — LE MOTIF HÉRITÉ DIT DE QUEL NIVEAU IL PARLE. `decision` reste au niveau de la
// charge — un client qui l'a toujours lu continue de le lire — mais il reflète le motif
// d'UN niveau, et sans le dire chaque client réinventerait sa règle de fusion : « le
// motif du dernier niveau servi ? du premier refusé ? ». Deux écrans afficheraient deux
// motifs pour le même cycle.
//
// Aujourd'hui il n'y a qu'un niveau et la question ne se pose pas — c'est précisément
// pourquoi le champ s'ajoute MAINTENANT : quand elle se posera, les clients le liront
// déjà. Voir R2, même raisonnement sur le coût de déplacement.
// Le niveau dont `decision` parle : le DERNIER parcouru. La règle est arbitraire mais
// elle doit être UNE, et publiée : sans elle chaque client réinventerait sa fusion, et
// deux écrans afficheraient deux motifs pour le même cycle. Le dernier parcouru est
// aussi celui qui a eu le dernier mot sur la consigne, donc celui qu'on explique.
QVariantMap decision = action.reason.toMap();
if (!action.levels.isEmpty())
decision.insert(QStringLiteral("level"), action.levels.last().level);
entry.insert(QStringLiteral("decision"), decision);
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);
attacherFinsDerogation(snap);
m_telemetry = snap;
}
namespace {
//! Tolérance de concordance. Un écart en deçà n'est pas publié comme divergence : bruit de
//! mesure, arrondi à l'ampère, et déphasage d'un cycle entre commande et mesure s'y logent.
//! Valeur de conception, à ajuster sur relevé.
constexpr double DivergenceAbsolueW = 200.0;
constexpr double DivergenceRelative = 0.15;
}
void EnergyArbitrator::attachCommandDivergence(QVariantMap &entry, const QString &loadId,
const QString &source, double mesureW) const
{
// RIEN NE MESURE : il n'y a rien à comparer, et l'objet est ABSENT — pas `divergent: false`,
// qui affirmerait une concordance qu'on ne peut pas constater. Même règle que partout.
if (source == QLatin1String("none"))
return;
// Ce qu'on a commandé. Sans commande, l'écart est SANS OBJET — et c'est le cas normal d'une
// borne en mode manuel que l'arbitre ne pilote pas. Une borne jamais commandée n'est pas
// une borne commandée à zéro.
double attenduW = 0;
bool connu = false;
if (EvAdapter *ev = m_adapters.value(loadId, nullptr)) {
uint ampere = 0, phases = 0;
if (ev->derniereCommande(ampere, phases)) {
attenduW = ampere * 230.0 * qMax(1u, phases);
connu = true;
}
} else if (ILoadAdapter *a = m_loadAdapters.value(loadId, nullptr)) {
attenduW = a->telemetry().currentPowerW;
connu = true;
}
if (!connu)
return;
const double ecart = qAbs(attenduW - mesureW);
const bool divergent = ecart > qMax(DivergenceAbsolueW, DivergenceRelative * attenduW);
// DEPUIS QUAND — sans cette date, une divergence permanente ne se distingue pas d'un
// déphasage d'un cycle entre l'écriture et sa mesure. C'est pourtant TOUTE la différence
// entre un fonctionnement normal et le cas relevé le 2026-08-31, où l'écart durait
// indéfiniment parce que plus personne ne commandait.
// JOURNALISÉ AUX DEUX TRANSITIONS, et pas seulement publié.
//
// `divergent` et `since` ne vivaient que dans la charge utile de télémétrie et dans ce
// QHash en mémoire — donc nulle part. Rien ne comptait les épisodes : un redémarrage de
// nymead effaçait l'état, et le hash est PURGÉ dès que l'écart cesse, si bien qu'une
// divergence qui s'ouvre et se referme entre deux lectures ne laissait aucune trace. Même
// un observateur externe échantillonnant en continu aurait raté les épisodes courts, qui
// sont probablement les plus nombreux.
//
// Sans ces deux lignes, « attendre que les traces parlent » attend indéfiniment : le
// silence du journal était garanti par construction, pas par l'absence du phénomène.
if (!divergent) {
if (m_divergenceDepuis.contains(loadId)) {
const qint64 dureeS = m_divergenceDepuis.value(loadId).secsTo(QDateTime::currentDateTime());
qCInfo(dcNymeaEnergy()) << "[Divergence] FIN" << loadId
<< "après" << dureeS << "s"
<< "— attendu" << attenduW << "W, mesuré" << mesureW << "W";
}
m_divergenceDepuis.remove(loadId);
} else if (!m_divergenceDepuis.contains(loadId)) {
const QDateTime debut = m_lastTelemetryPublish.isValid()
? m_lastTelemetryPublish : QDateTime::currentDateTime();
m_divergenceDepuis.insert(loadId, debut.toUTC()); // ancré à la capture
qCInfo(dcNymeaEnergy()) << "[Divergence] DÉBUT" << loadId
<< "— attendu" << attenduW << "W, mesuré" << mesureW << "W,"
<< "écart" << (mesureW - attenduW) << "W";
}
QVariantMap commande;
commande.insert(QStringLiteral("expectedW"), attenduW);
commande.insert(QStringLiteral("observedW"), mesureW);
// Le VERDICT est publié, et pas seulement les deux nombres : la tolérance est une
// connaissance du moteur (bruit de mesure, arrondi à l'ampère, déphasage d'un cycle). La
// laisser au client ferait réinventer un seuil par écran, et deux écrans ne diraient pas la
// même chose du même cycle.
commande.insert(QStringLiteral("divergent"), divergent);
if (divergent)
commande.insert(QStringLiteral("since"),
m_divergenceDepuis.value(loadId).toUTC().toString(Qt::ISODate));
entry.insert(QStringLiteral("command"), commande);
}
void EnergyArbitrator::attachMeasurements(QVariantMap &entry, const QString &loadId) const
{
if (!m_tm)
return;
LoadConfig cfg;
for (const LoadConfig &c : m_loadConfigStore ? m_loadConfigStore->configs() : LoadConfigs()) {
if (c.id() == loadId) { cfg = c; break; }
}
// --- MESURE : {valeur, source}, jamais un nombre seul (§11 / LM-1106) -----------------
//
// Trois régimes, et deux d'entre eux étaient publiés sous le même nom jusqu'ici. Le
// client ne pouvait pas distinguer « un compteur a été posé exprès » de « l'appareil se
// déclare », alors que l'écart commandé↔mesuré ne conclut pas la même chose dans les deux
// cas : sous "device" il dit que la charge n'obéit pas ; sous "meter" il ouvre trois
// hypothèses — la charge, le compteur, ou le câblage — dont il ne tranche aucune.
QString source = QStringLiteral("none");
double powerW = 0;
// 1. Le compteur EXPLICITE d'abord (LM-1105). Non pas d'abord parce qu'il mesure mieux —
// c'est vrai, et ce n'est pas la raison — mais parce qu'un réglage sans effet est pire
// que pas de réglage : préférer l'appareil au compteur que l'installateur a pris la
// peine de désigner rendrait le champ décoratif.
if (!cfg.meterThingId().isEmpty()) {
Thing *t = m_tm->findConfiguredThing(ThingId(cfg.meterThingId()));
if (t && t->hasState("currentPower")) {
source = QStringLiteral("meter");
powerW = t->stateValue("currentPower").toDouble();
}
}
// 2. À défaut, l'APPAREIL PILOTÉ — LM-1105, et c'est le lot. La priorité porte sur la
// SOURCE, pas sur la valeur : un compteur déclaré mais injoignable fait retomber ici
// plutôt que taire une mesure qu'on a. Le silence se lirait « charge non mesurable ».
if (source == QLatin1String("none")) {
ILoadAdapter *adapter = m_loadAdapters.value(loadId, nullptr);
if (!adapter)
adapter = m_adapters.value(loadId, nullptr);
if (adapter) {
const LoadMeasurement m = adapter->deviceMeasurement();
if (m.available) {
source = QStringLiteral("device");
powerW = m.powerW;
}
}
}
// 3. "none" est un état POSITIF, pas une clé qui manque : « cette charge n'est pas
// mesurable ». C1 de LM-1009 appliqué ici — une capacité manquante SE DIT. Sans lui,
// « pas mesurable » et « compteur en panne » se ressemblent, et l'app continue
// d'afficher un bloc de mesure vide sur un sg-ready qui n'en aura jamais.
//
// Et powerW reste OMIS : la puissance nominale existe, elle est déjà lisible dans
// mechanism.* qui dit d'où elle vient. La faire passer pour une mesure produirait un
// écart commandé↔mesuré identiquement nul à tous les cycles — un « tout concorde »
// permanent, c'est-à-dire le corollaire LM-104 : l'hypothèse de prudence devient un
// masquage de panne.
QVariantMap mesure;
mesure.insert(QStringLiteral("source"), source);
if (source != QLatin1String("none"))
mesure.insert(QStringLiteral("powerW"), powerW);
entry.insert(QStringLiteral("measurement"), mesure);
attachCommandDivergence(entry, loadId, source, powerW);
// 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);
// Le mode dégradé porte l'événement LUI AUSSI : `measurementLost` s'y produit précisément,
// et c'est le moment où l'utilisateur a le plus besoin de savoir que sa dérogation a cessé.
attacherFinsDerogation(snap);
m_telemetry = snap;
}
void EnergyArbitrator::attacherFinsDerogation(QVariantMap &snap) const
{
// \rule{LM-1500-f} — FINS DE DÉROGATION, publiées au NIVEAU DE LA TRAME, pas dans loads[].
//
// Deux raisons, et la seconde est décisive :
// · la charge peut avoir QUITTÉ loads[] au moment où sa dérogation se termine — c'est
// même le cas le plus fréquent, celui du débranchement — et l'événement disparaîtrait
// exactement quand il explique le plus de choses ;
// · `decision` porte ce que le cycle a DÉCIDÉ pour cette charge ; une fin est un
// ÉVÉNEMENT, pas une décision, et l'y loger obligerait à choisir entre les deux.
//
// Événement de CYCLE : présent le seul cycle de la fin, absent partout ailleurs. Une fin
// qui resterait affichée serait indistinguable d'une fin qui vient de se produire.
if (!m_derogationFin.isEmpty()) {
QVariantList fins;
QStringList finIds = m_derogationFin.keys();
finIds.sort(); // ordre stable : un instantané se compare
for (const QString &id : finIds) {
QVariantMap f;
f.insert(QStringLiteral("loadId"), id);
f.insert(QStringLiteral("cause"), m_derogationFin.value(id));
// Le mode auquel on REVIENT, publié plutôt que laissé à déduire : le client ne
// peut pas le retrouver, il n'a jamais vu celui d'avant la pose.
const int rendu = m_derogationModeRendu.value(id, -1);
if (rendu >= 0)
f.insert(QStringLiteral("restoredMode"), rendu);
fins.append(f);
}
snap.insert(QStringLiteral("derogationEnded"), fins);
}
}
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().toUTC(); // ancré à la capture
// 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
// Un ÉVÉNEMENT se notifie TOUJOURS. Il ne vit qu'un cycle : le filtrer sur « ça se ressemble
// assez » le perdrait définitivement, et le client ne saurait jamais que sa dérogation a
// pris fin — ni pourquoi.
if (b.contains(QStringLiteral("derogationEnded")))
return true;
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;
}