counts est publié par niveau : { destination → watts }, Σ counts == targetW
toujours. Clés opaques et jamais recyclées, publié même à une seule destination
et même à zéro — un counts vide satisferait l'identité en perdant la destination,
sur le cas le plus fréquent.
L'identité ne passe plus par funding : elle somme des COMPTEURS, plus des
origines. EV_GRID_START y entre sans exception. funding et counts ne se dérivent
pas l'un de l'autre — l'un dit d'où viennent les watts, l'autre où ils sont
comptés.
LA GARANTIE EST UNE MACHINE. Sept sites construisent une LoadAction ; vérifier
qu'ils renseignent tous counts en RELISANT le code se referait à chaque site
ajouté. testCountsSumToTarget relit la charge utile — toutes les charges, trois
régimes dont l'import où tout vaut 0 — et constate le résultat.
ET LA CONTRE-ÉPREUVE A TROUVÉ UN TROU DANS LE TEST LUI-MÊME. Amputer le partage
d'EV_GRID_START le laissait PASSER : la borne, auto-provisionnée en queue par
LM-1209, ne voyait jamais de budget, et le régime n'était pas exercé. Corrigé —
rang 1, et EV_GRID_START en PREMIER régime, parce qu'une fois la borne en charge
son recrédit porte le budget bien au-dessus du plancher et le démarrage sous
tolérance ne peut plus se produire. Plus une assertion qui EXIGE que le régime
ait été exercé, sinon le test ne prouve rien du partage.
Deux fois dans ce lot, la contre-épreuve a corrigé le test plutôt que le code :
la première fois elle avait échoué au MONTAGE (NYMEA_PLUGINS_PATH omis) et ne
prouvait rien non plus. Un garde-fou qu'on n'a pas vu échouer POUR LA BONNE
RAISON ne vaut pas mieux qu'un garde-fou qu'on n'a pas vu échouer.
GetChargingInfos filtre sur evChargerId. Le paramètre était déclaré au schéma,
documenté « for all or a single EV charger », et Q_UNUSED : accepté, sans effet,
sans erreur. Des trois issues c'était la pire — un paramètre absent se voit, un
paramètre refusé se voit, un paramètre ignoré ne se voit pas. Identifiant inconnu
→ liste vide, jamais une erreur, et la liste vide est non ambiguë puisque toute
borne configurée a une entrée.
Balayage du namespace : sur 20 méthodes, 9 déclarent des paramètres et
GetChargingInfos était la seule à en ignorer un. Mon premier balayage avait
conclu « aucune méthode ne déclare de paramètre » — regex fausse ; refait en
croisant, pour chaque méthode, les params.insert de sa déclaration avec les
params.value de son corps.
Le garde-fou doxygen de ci-quality a par ailleurs attrapé ma propre insertion :
le bloc CountKey s'était glissé entre le commentaire de LoadAction et la
structure, qui perdait sa documentation.
simulation 34/34, charging 17/17, loadmodel 20/20, spotmarket 7/7, amd64 0/0,
doxygen 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
1370 lines
72 KiB
C++
1370 lines
72 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||
|
||
#include "energyarbitrator.h"
|
||
#include "adapters/evadapter.h"
|
||
#include "adapters/sgreadyadapter.h"
|
||
#include "adapters/etmvariableloadadapter.h"
|
||
#include "adapters/relayrouter.h"
|
||
#include "config/loadconfigstore.h"
|
||
#include "types/loadconfig.h"
|
||
#include "scheduler/rulebasedscheduler.h"
|
||
#include "types/decisionreason.h"
|
||
#include "types/surpluscontext.h"
|
||
#include "types/plan.h"
|
||
#include "../rootmeter.h"
|
||
#include "../evcharger.h"
|
||
|
||
#include "plugininfo.h"
|
||
|
||
#include <energymanager.h>
|
||
#include <QTimer>
|
||
|
||
namespace {
|
||
//! Période du watchdog L2 (SAFETY.md §L2) : tick indépendant des signaux compteur.
|
||
constexpr int MeterWatchdogPeriodMs = 30 * 1000; // 30 s
|
||
//! Seuil de silence compteur au-delà duquel le mode dégradé L2 est déclenché.
|
||
constexpr int MeterSilenceThresholdS = 90; // 90 s
|
||
|
||
//! Battement de cœur de la télémétrie : période MAXIMALE entre deux notifications, même sans
|
||
//! changement. C'est ce qui permet au client de dater la dernière décision et donc de repérer
|
||
//! un arbitre figé — un état qui ne bouge pas n'émet rien de lui-même.
|
||
constexpr int TelemetryHeartbeatPeriodMs = 60 * 1000; // 60 s
|
||
|
||
//! Seuil d'allocation (W) en deçà duquel une variation ne déclenche PAS de notification.
|
||
//! Une charge à consigne continue module en permanence de quelques watts ; sans seuil, chaque
|
||
//! oscillation réveillerait tous les clients abonnés. 50 W est en dessous du plus petit palier
|
||
//! réel d'un ECS (≈ 500 W) et au-dessus du bruit de mesure d'un routeur.
|
||
constexpr double TelemetryAllocationThresholdW = 50.0;
|
||
}
|
||
|
||
EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm,
|
||
SpotMarketManager *sm,
|
||
EnergyManagerConfiguration *conf,
|
||
QObject *parent)
|
||
: SmartChargingManager(em, tm, sm, conf, parent)
|
||
, m_scheduler(new RuleBasedScheduler(this, this))
|
||
, m_tm(tm)
|
||
{
|
||
// --- L2 : watchdog fraîcheur compteur (SAFETY.md §L2) ---
|
||
// La LOGIQUE (recordMeterUpdate / evaluateMeterFreshness) prend le temps en paramètre
|
||
// et reste testable par injection (symétrique de simulationCallUpdate). Seuls les
|
||
// DÉCLENCHEURS RÉELS (signal + QTimer, horloge murale) sont câblés ici, et exclus en
|
||
// simulation — comme les connexions amont powerBalanceEntryAdded→update() (SCM l.108-130).
|
||
#ifndef ENERGY_SIMULATION
|
||
m_lastMeterUpdate = QDateTime::currentDateTime(); // grâce au démarrage (évite un dégradé immédiat)
|
||
// Fraîcheur picotée sur powerBalanceChanged (en plus de la connexion amont L4).
|
||
connect(em, &EnergyManager::powerBalanceChanged, this, [this]() {
|
||
recordMeterUpdate(QDateTime::currentDateTime());
|
||
});
|
||
// QTimer (et non signal) : doit rester actif quand le compteur est muet.
|
||
m_meterWatchdog = new QTimer(this);
|
||
m_meterWatchdog->setInterval(MeterWatchdogPeriodMs);
|
||
connect(m_meterWatchdog, &QTimer::timeout, this, &EnergyArbitrator::onMeterWatchdogTick);
|
||
m_meterWatchdog->start();
|
||
|
||
// Battement de cœur de la télémétrie. QTimer d'horloge murale, comme le watchdog et pour la
|
||
// MÊME raison : il doit continuer à battre quand le cycle d'arbitrage, lui, s'est arrêté.
|
||
// Un battement piloté par le cycle ne pourrait pas révéler l'arrêt du cycle.
|
||
m_telemetryHeartbeat = new QTimer(this);
|
||
m_telemetryHeartbeat->setInterval(TelemetryHeartbeatPeriodMs);
|
||
connect(m_telemetryHeartbeat, &QTimer::timeout, this, &EnergyArbitrator::publishTelemetryHeartbeat);
|
||
m_telemetryHeartbeat->start();
|
||
#else
|
||
Q_UNUSED(em)
|
||
#endif
|
||
|
||
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] Arbitre ETM initialisé.";
|
||
}
|
||
|
||
void EnergyArbitrator::runSurplusPlanning(const QDateTime &now)
|
||
{
|
||
planSurplusCharging(now);
|
||
}
|
||
|
||
void EnergyArbitrator::runSpotMarketPlanning(const QDateTime &now)
|
||
{
|
||
planSpotMarketCharging(now);
|
||
}
|
||
|
||
const QHash<EvCharger *, ChargingActions> &EnergyArbitrator::scheduledActions() const
|
||
{
|
||
return internalChargingActions();
|
||
}
|
||
|
||
void EnergyArbitrator::doExecuteChargingAction(EvCharger *charger,
|
||
const ChargingAction &action,
|
||
const QDateTime &now)
|
||
{
|
||
executeChargingAction(charger, action, now);
|
||
}
|
||
|
||
const QHash<ThingId, EvCharger *> &EnergyArbitrator::registeredEvChargers() const
|
||
{
|
||
return internalEvChargers();
|
||
}
|
||
|
||
RootMeter *EnergyArbitrator::registeredRootMeter() const
|
||
{
|
||
return internalRootMeter();
|
||
}
|
||
|
||
void EnergyArbitrator::registerEtmVariableLoadAdapter(EtmVariableLoadAdapter *adapter)
|
||
{
|
||
const QString id = adapter->descriptor().id;
|
||
if (m_loadAdapters.contains(id)) {
|
||
qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] charge pilotée déjà enregistrée:" << id;
|
||
return;
|
||
}
|
||
adapter->setParent(this);
|
||
m_loadAdapters[id] = adapter; // upcast EtmVariableLoadAdapter* → ILoadAdapter*
|
||
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] etmvariableload enregistré:" << adapter->descriptor().label;
|
||
}
|
||
|
||
void EnergyArbitrator::registerRelayRouter(RelayRouter *adapter)
|
||
{
|
||
const QString id = adapter->descriptor().id;
|
||
if (m_loadAdapters.contains(id)) {
|
||
qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] charge pilotée déjà enregistrée:" << id;
|
||
return;
|
||
}
|
||
adapter->setParent(this);
|
||
m_loadAdapters[id] = adapter; // upcast RelayRouter* → ILoadAdapter*
|
||
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] relay-router enregistré:" << adapter->descriptor().label;
|
||
}
|
||
|
||
void EnergyArbitrator::setLoadConfigStore(LoadConfigStore *store)
|
||
{
|
||
m_loadConfigStore = store;
|
||
if (!store)
|
||
return;
|
||
connect(store, &LoadConfigStore::changed, this, &EnergyArbitrator::rebuildLoadAdapters);
|
||
rebuildLoadAdapters(); // construction initiale depuis la config persistée
|
||
}
|
||
|
||
bool EnergyArbitrator::sameHardware(const LoadConfig &a, const LoadConfig &b)
|
||
{
|
||
// Champs dont un changement impose une VRAIE reconstruction : ils déterminent la table
|
||
// de paliers, le câblage ou les verrous. Tout le reste (rang, besoins, libellé) se met
|
||
// à jour en place.
|
||
if (a.adapter() != b.adapter() || a.mode() != b.mode())
|
||
return false;
|
||
if (a.minOnS() != b.minOnS() || a.minOffS() != b.minOffS())
|
||
return false;
|
||
if (a.maxPowerW() != b.maxPowerW() || a.powerLevelsInt() != b.powerLevelsInt())
|
||
return false;
|
||
|
||
// LM-300 — la charge utile fait partie du « matériel » : un état SG-Ready ré-encodé ou
|
||
// une estimation revue change ce que l'adaptateur commande, donc impose une reconstruction.
|
||
const LoadConfigSgReady sa = a.sgReadyPayload();
|
||
const LoadConfigSgReady sb = b.sgReadyPayload();
|
||
if (sa.minStateHoldS != sb.minStateHoldS || sa.states.size() != sb.states.size())
|
||
return false;
|
||
for (int i = 0; i < sa.states.size(); ++i) {
|
||
if (sa.states.at(i).state != sb.states.at(i).state
|
||
|| sa.states.at(i).relays != sb.states.at(i).relays
|
||
|| !qFuzzyCompare(1 + sa.states.at(i).estimatedPowerW, 1 + sb.states.at(i).estimatedPowerW))
|
||
return false;
|
||
}
|
||
|
||
// On ne compare pas les LISTES de relais, on compare la TABLE qu'elles produisent.
|
||
//
|
||
// Comparer index par index déclarait « matériel changé » sur un simple
|
||
// réordonnancement — mêmes Things, mêmes puissances — et coûtait donc une
|
||
// reconstruction complète : contacts ouverts par l'état sûr, verrous minOn/minOff
|
||
// réarmés à froid. Un installateur qui remonte une ligne dans l'app ne s'attend pas à
|
||
// couper sa charge, et annuler son geste en coûtait une seconde.
|
||
//
|
||
// Comparer les listes comme des ENSEMBLES aurait été faux dans l'autre sens :
|
||
// à somme égale la table retient la première combinaison rencontrée, donc échanger
|
||
// deux relais de MÊME puissance change le contact qui sert ce palier — autre
|
||
// contacteur qui s'use, autre résistance qui chauffe. Un ensemble ne le verrait pas.
|
||
//
|
||
// La table tranche les deux cas d'un coup, et sans pari : elle EST ce que le routeur
|
||
// exécute. deriveStages() est l'implémentation unique, partagée avec la construction
|
||
// du routeur et la publication en télémétrie.
|
||
return RelayRouter::deriveStages(a.relaysList())
|
||
== RelayRouter::deriveStages(b.relaysList());
|
||
}
|
||
|
||
bool EnergyArbitrator::clearLoadFault(const QString &loadId)
|
||
{
|
||
// Règle 7-c — l'ISSUE est journalisée ici, au niveau de l'arbitre, et pas dans chaque
|
||
// adaptateur. Une ligne écrite là couvre les quatre adaptateurs d'un coup et ne peut pas
|
||
// être oubliée par un adaptateur futur ; répétée dans chacun, elle manquerait le cinquième.
|
||
//
|
||
// Pourquoi cet appel mérite une issue plutôt qu'une simple trace de demande : c'est le
|
||
// SEUL levier de reprise à distance. ECS-410 pose un verrou collant que seuls ce RPC ou
|
||
// une reconstruction lèvent. Sans issue lisible, un opérateur à distance ne distingue pas
|
||
// « défaut levé » de « aucun défaut à lever » de « l'appel n'a pas atteint l'adaptateur » :
|
||
// il relance, puis conclut que le système est cassé.
|
||
ILoadAdapter *adapter = m_loadAdapters.value(loadId, nullptr);
|
||
if (!adapter) {
|
||
qCWarning(dcNymeaEnergy()) << "[Arbitre] ClearLoadFault" << loadId
|
||
<< "→ SANS EFFET : adaptateur introuvable "
|
||
"(identifiant inconnu ou charge désactivée).";
|
||
return false;
|
||
}
|
||
|
||
// \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 ¤tDateTime)
|
||
{
|
||
qCDebug(dcNymeaEnergy()) << "Updating smart charging";
|
||
// Ordre IDENTIQUE à SmartChargingManager::update() — INTERDIT de réordonner.
|
||
// SCM : 1.updateManual 2.prepareInfo 3.verifyOverload 4.verifyRecovery
|
||
// 5.planSpot 6.planSurplus 7.adjustEv
|
||
// ETM : idem 1-4 ; insertions ETM entre 4 et 7 ;
|
||
// planSpot + planSurplus appelés via m_scheduler->getPlan() (position 5-6).
|
||
|
||
// 1-4 : préparation + sécurité (même ordre que l'amont)
|
||
updateManualSoCsWithoutMeter(currentDateTime);
|
||
prepareInformation(currentDateTime);
|
||
verifyOverloadProtection(currentDateTime);
|
||
verifyOverloadProtectionRecovery(currentDateTime);
|
||
|
||
// Mode dégradé L2 : la sécurité (L4 ci-dessus) reste active, mais on SUSPEND la
|
||
// planification et le dispatch. Replanifier sur le cache d'un compteur mort
|
||
// rallumerait les charges que le watchdog vient de couper → oscillation. Les
|
||
// consignes de repli (posées à la transition) tiennent jusqu'au retour du compteur.
|
||
if (m_degradedMode) {
|
||
qCDebug(dcNymeaEnergy()) << "[Arbitre] Mode dégradé L2 actif — planification suspendue.";
|
||
// 3g-2 — le waterfall ne commande plus rien : il ne peut donc plus revendiquer aucune
|
||
// borne. Garder le jeu du dernier cycle sain les exclurait du proxy alors que plus
|
||
// personne ne les commande — elles resteraient figées sur leur dernière consigne,
|
||
// compteur muet, ce que SAFETY.md §L2 interdit. Le repli est posé à la transition par
|
||
// applyDegradedMode() ; c'est lui qui fait autorité tant que le compteur est muet.
|
||
m_waterfallEvIds.clear();
|
||
return;
|
||
}
|
||
|
||
// ETM-only : sync adapters + proxy planification → log [Arbitre]
|
||
// getPlan() appelle planSpotMarketCharging() + planSurplusCharging() (position 5-6 amont).
|
||
syncAdapters();
|
||
SurplusContext ctx = buildContext(currentDateTime);
|
||
Plan plan = m_scheduler->getPlan(ctx);
|
||
Slot slot = plan.slotCovering(currentDateTime);
|
||
|
||
// Le LIBELLÉ n'est pas dans le motif : il est substitué AU RENDU (cf. decisionreason.h).
|
||
// On le prend dans le contexte du cycle, seul endroit qui connaisse toutes les charges.
|
||
QHash<QString, QString> libelles;
|
||
for (const LoadContext &lc : ctx.loads)
|
||
libelles.insert(lc.id, lc.label);
|
||
|
||
for (const LoadAction &action : slot.actions) {
|
||
qCInfo(dcNymeaEnergy()) << "[Arbitre]"
|
||
<< action.loadId << "→" << renderFr(action.reason, libelles.value(action.loadId))
|
||
<< "| activé:" << action.chargingEnabled
|
||
<< "| courant:" << action.currentA << "A"
|
||
<< "| phases:" << action.phaseCount
|
||
<< "| stratégie:" << plan.strategy;
|
||
}
|
||
|
||
// 3g-2 — le jeu des bornes que le waterfall a commandées, NOMMÉ par le scheduler. Il doit
|
||
// être posé AVANT le dispatch : c'est lui que le crochet evCommandedByWaterfall() rend à
|
||
// adjustEvChargers() juste après, et c'est ce qui empêche le proxy de re-commander.
|
||
m_waterfallEvIds = plan.waterfallEvIds;
|
||
|
||
// 7 : dispatch matériel (même position que l'amont — m_chargingActions rempli par getPlan())
|
||
applyActionsToAdapters(slot, currentDateTime); // charges arbitrées (Setpoint W, State) ET bornes du waterfall
|
||
adjustEvChargers(currentDateTime); // bornes RESTÉES au proxy : échéance, spot market
|
||
|
||
// 8 : frontière de télémétrie. APRÈS le dispatch, jamais avant : la charge utile de mécanisme
|
||
// (palier appliqué, état PAC) doit refléter ce qui a réellement été envoyé au matériel, pas
|
||
// ce que la stratégie espérait. Publier avant produirait un instantané qu'aucun relais ne
|
||
// confirme — la même famille de défaut qu'annoncer une puissance non appliquée (ECS-410).
|
||
buildTelemetry(plan, slot, currentDateTime);
|
||
publishTelemetry(false);
|
||
}
|
||
|
||
bool EnergyArbitrator::evParticipatesInWaterfall(EvCharger *ev) const
|
||
{
|
||
if (!ev)
|
||
return false;
|
||
|
||
const ChargingInfo info = chargingInfo(ev->id());
|
||
|
||
// 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.
|
||
if (info.chargingMode() == ChargingInfo::ChargingModeNormal)
|
||
return false;
|
||
|
||
// Aucun véhicule branché : la borne ne peut RIEN consommer de ce qu'on lui allouerait.
|
||
//
|
||
// Le proxy n'avait pas besoin de ce test — son allocation n'était pas une cascade, et
|
||
// gaspiller ne privait personne. En cascade, si : au banc le 2026-08-27, une wallbox
|
||
// `pluggedIn: false` s'est vu allouer 4 853 W qu'elle ne tirait pas, et la PAC derrière
|
||
// elle n'a survécu que sur son propre recrédit (docs/RELEVE_3g2.md §3.1).
|
||
//
|
||
// L'asymétrie était d'ailleurs déjà visible dans le code : la boucle EV du scheduler, qui
|
||
// sert les financements RÉSEAU, teste `!ev->pluggedIn()` depuis toujours. Seul le chemin
|
||
// waterfall ne le faisait pas.
|
||
//
|
||
// Hors arbitrage, et non « servie à 0 avec un motif » : c'est la même famille que 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));
|
||
}
|
||
|
||
double EnergyArbitrator::evPhaseAllowanceW(EvCharger *ev, uint phaseCount) const
|
||
{
|
||
if (!ev || phaseCount == 0)
|
||
return -1;
|
||
|
||
// 0 = non réglé : la protection de surcharge est désactivée (contrat amont de
|
||
// SetPhasePowerLimit). Rien à écrêter, et surtout rien à annoncer — un plafond publié
|
||
// ici serait une limite inventée.
|
||
const uint limite = phasePowerLimit();
|
||
if (limite == 0)
|
||
return -1;
|
||
|
||
RootMeter *meter = internalRootMeter();
|
||
if (!meter)
|
||
return -1;
|
||
|
||
// Place LIBRE par phase, telle que la calcule le proxy — même fonction, même masque de
|
||
// phases, et surtout SANS rendre à la borne ce qu'elle tire déjà.
|
||
//
|
||
// Ce dernier point est délibéré et il est de sécurité. Rendre sa propre consommation à une
|
||
// borne serait plus juste en régime établi, mais ferait disparaître la réponse à la
|
||
// surcharge : sur une phase en dépassement, la place libre est NÉGATIVE, et c'est ce signe
|
||
// qui ramène la borne à son minimum. Avec le retour, elle se verrait autoriser ce qu'elle
|
||
// consomme déjà — c'est-à-dire précisément la puissance qui cause le dépassement.
|
||
// (`testOverloadProtectionEcoMode` : 16 A ramenés à 6 A ; avec le retour, 9 A maintenus.)
|
||
const double allowanceA = meter->calculateAllowanceAmpere(
|
||
const_cast<EnergyArbitrator *>(this)->phasesForCount(phaseCount), limite);
|
||
|
||
return qMax(0.0, allowanceA) * 230.0 * phaseCount;
|
||
}
|
||
|
||
bool EnergyArbitrator::evCommandedByWaterfall(EvCharger *evCharger) const
|
||
{
|
||
return evCharger && m_waterfallEvIds.contains(evCharger->thing()->id().toString());
|
||
}
|
||
|
||
bool EnergyArbitrator::evSurplusPlannedByWaterfall(EvCharger *evCharger) const
|
||
{
|
||
return evParticipatesInWaterfall(evCharger);
|
||
}
|
||
|
||
SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const
|
||
{
|
||
SurplusContext ctx;
|
||
ctx.timestamp = now;
|
||
|
||
// --- Compteur principal (AGENTS invariant 8 : mesure brute, aucune déduction) ---
|
||
RootMeter *meter = internalRootMeter();
|
||
if (meter) {
|
||
// currentPower() < 0 → export ; > 0 → import (convention amont SCM l.1141)
|
||
const double p = meter->currentPower();
|
||
ctx.meter.importW = qMax(0.0, p);
|
||
ctx.meter.exportW = qMax(0.0, -p);
|
||
ctx.meter.perPhaseA = {
|
||
meter->currentPhaseA(),
|
||
meter->currentPhaseB(),
|
||
meter->currentPhaseC()
|
||
};
|
||
}
|
||
// SurplusPv : interface inverter — déféré (remplissage prévu en 3d)
|
||
|
||
// --- Stockage (3g-1) ------------------------------------------------------------------
|
||
// Rempli ICI et plus dans le chemin VE. La prise en compte de la batterie n'a jamais été
|
||
// une règle « véhicule » : c'est une règle de BUDGET, et elle vivait dans
|
||
// planSurplusCharging() par accident d'histoire. Tant qu'elle y restait, une batterie sous
|
||
// sa réserve empêchait de charger la voiture mais laissait le chauffe-eau se servir.
|
||
//
|
||
// Toutes les batteries sont agrégées en une seule — même simplification que l'amont : un
|
||
// niveau moyen et une puissance totale. Distinguer deux stockages demanderait de savoir
|
||
// lequel arbitrer en premier, et rien dans le modèle ne le dit.
|
||
{
|
||
int n = 0;
|
||
double niveauTotal = 0, puissanceTotale = 0;
|
||
for (Thing *storage : m_tm->configuredThings().filterByInterface("energystorage")) {
|
||
++n;
|
||
niveauTotal += storage->stateValue("batteryLevel").toDouble();
|
||
puissanceTotale += storage->stateValue("currentPower").toDouble();
|
||
}
|
||
if (n > 0) {
|
||
ctx.battery.present = true;
|
||
ctx.battery.socPercent = niveauTotal / n;
|
||
ctx.battery.powerW = puissanceTotale; // > 0 charge, < 0 décharge
|
||
} else {
|
||
// Un filtre par interface qui ne trouve rien est TOUJOURS ambigu : « il n'y a pas
|
||
// de batterie » et « il y en a une, mal déclarée » se ressemblent, et la seconde
|
||
// désactive une règle de sécurité EN SILENCE. C'est le même mode de défaillance
|
||
// qu'un seuil de réserve mal réglé : rien ne se signale.
|
||
//
|
||
// Constaté sur le banc le 2026-08-26 : le Thing « SunSpec Storage » déclare
|
||
// `battery` mais pas `energystorage`, donc la réserve ne s'est jamais appliquée —
|
||
// ni ici, ni dans le proxy amont qui utilisait le même filtre, ni dans le bilan de
|
||
// puissance de l'experience-plugin qui filtre pareil.
|
||
const Things candidates = m_tm->configuredThings().filterByInterface("battery");
|
||
if (!candidates.isEmpty()) {
|
||
QStringList noms;
|
||
for (Thing *t : candidates)
|
||
noms << t->name();
|
||
qCWarning(dcNymeaEnergy())
|
||
<< "[Arbitre] Réserve de stockage INACTIVE :" << candidates.count()
|
||
<< "appareil(s) portant l'interface 'battery' mais PAS 'energystorage' —"
|
||
<< noms.join(", ") << ". Le waterfall ne peut pas les prendre en compte, et"
|
||
<< "le bilan de puissance de nymea non plus. À corriger dans le plugin qui"
|
||
<< "déclare la classe, pas ici.";
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- loads[] : bornes VE (3g-1) ------------------------------------------------------
|
||
// Elles entrent désormais dans le MÊME contexte que les autres charges — c'est toute la
|
||
// transplantation. 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;
|
||
}
|
||
ctx.loads.append(it.value()->toLoadContext(now));
|
||
}
|
||
|
||
// --- loads[] : bornes CONFIGURÉES dont l'appareil manque (3g-2) ----------------------
|
||
//
|
||
// L'entrée de configuration d'une borne n'est jamais supprimée d'office : c'est une
|
||
// configuration, et l'installateur a pu la classer. Quand le Thing disparaît — borne
|
||
// remplacée, ThingId changé, appareil déposé — il reste donc une charge sans appareil, et
|
||
// elle doit se LIRE comme telle : indisponible, avec son motif, à son rang. La faire
|
||
// disparaître du contexte la rendrait indistinguable d'une charge jamais configurée, et
|
||
// c'est le silence ambigu que la famille ECS-411 interdit.
|
||
//
|
||
// Aucune allocation ne lui est faite (branche `!available` du waterfall) et elle ne retient
|
||
// rien du budget : 0 W gelés, le budget passe entier à la suivante.
|
||
if (m_loadConfigStore) {
|
||
for (const LoadConfig &c : m_loadConfigStore->configs()) {
|
||
if (!c.isEvCharger() || !c.enabled() || m_adapters.contains(c.id()))
|
||
continue;
|
||
LoadContext lc;
|
||
lc.id = c.id();
|
||
lc.adapter = QStringLiteral("evcharger");
|
||
lc.label = c.label();
|
||
lc.priority = c.priority();
|
||
lc.telemetry.available = false;
|
||
lc.telemetry.currentPowerW = 0;
|
||
lc.telemetry.lockMinPowerW = 0; // rien d'engagé : rien à geler au budget
|
||
lc.telemetry.lockMaxPowerW = -1;
|
||
ctx.loads.append(lc);
|
||
}
|
||
}
|
||
|
||
// --- loads[] : TOUTES les charges arbitrées, polymorphes (relay-router, etmvariableload,
|
||
// sg-ready) — une seule table, toutes construites depuis la configuration persistée. ---
|
||
for (auto it = m_loadAdapters.constBegin(); it != m_loadAdapters.constEnd(); ++it)
|
||
ctx.loads.append(it.value()->toLoadContext(now));
|
||
|
||
return ctx;
|
||
}
|
||
|
||
int EnergyArbitrator::evPriority(const QString &chargerId) const
|
||
{
|
||
if (m_loadConfigStore) {
|
||
for (const LoadConfig &c : m_loadConfigStore->configs())
|
||
if (c.isEvCharger() && c.id() == chargerId)
|
||
return c.priority();
|
||
}
|
||
// Pas (encore) de configuration : rang de QUEUE. Une borne dont le rang n'est pas posé ne
|
||
// doit pas passer devant des charges que l'installateur a classées — l'inverse ferait de
|
||
// l'absence de configuration une décision d'arbitrage.
|
||
return 1000;
|
||
}
|
||
|
||
bool EnergyArbitrator::estUneBorneConfiguree(const QString &loadId) const
|
||
{
|
||
if (!m_loadConfigStore)
|
||
return false;
|
||
for (const LoadConfig &c : m_loadConfigStore->configs())
|
||
if (c.isEvCharger() && c.enabled() && c.id() == loadId)
|
||
return true;
|
||
return false;
|
||
}
|
||
|
||
void EnergyArbitrator::provisionEvChargerConfig(EvCharger *ev)
|
||
{
|
||
if (!m_loadConfigStore || !ev)
|
||
return;
|
||
|
||
const QString id = ev->thing()->id().toString();
|
||
LoadConfigs configs = m_loadConfigStore->configs();
|
||
|
||
// 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.
|
||
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.
|
||
//
|
||
// UN SEUL CHAMP, et c'est délibéré. authorisedW, remainingW, binding et perPhaseBound
|
||
// viendront avec le transport du §14a : absents, JAMAIS à 0. Un authorisedW: 0 assorti d'un
|
||
// binding par défaut ferait afficher un plafond de soutirage qui n'existe pas, avec sa
|
||
// source — un chiffre crédible et faux, c'est-à-dire la faute même que cet objet doit
|
||
// rendre impossible.
|
||
QVariantMap draw;
|
||
draw.insert(QStringLiteral("committedW"), plan.draw.committedW);
|
||
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);
|
||
orphan.insert(QStringLiteral("decision"), action.reason.toMap());
|
||
orphan.insert(QStringLiteral("faultCode"), QStringLiteral("THING_MISSING"));
|
||
QVariantMap mech;
|
||
mech.insert(QStringLiteral("kind"), QStringLiteral("evcharger"));
|
||
orphan.insert(QStringLiteral("mechanism"), mech);
|
||
attachMeasurements(orphan, action.loadId);
|
||
loads.append(orphan);
|
||
continue;
|
||
}
|
||
couvertes.insert(action.loadId);
|
||
|
||
QVariantMap entry;
|
||
entry.insert(QStringLiteral("loadId"), action.loadId);
|
||
// ALLOCATION du waterfall, celle qui a décrémenté le budget — pas la mesure. La mesure
|
||
// vit dans la charge utile de mécanisme ; les confondre rendrait la somme des
|
||
// allocations irréconciliable avec budget.allocatedW.
|
||
entry.insert(QStringLiteral("allocatedW"), action.estimatedPowerW);
|
||
// --- 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.
|
||
QVariantMap niveau;
|
||
niveau.insert(QStringLiteral("level"), QStringLiteral("comfort"));
|
||
// R4 — le niveau publie ce qu'il a DÉCIDÉ. C'est la même valeur qu'allocatedW tant
|
||
// qu'il n'y a qu'un niveau ; elle s'en séparera quand il y en aura deux.
|
||
niveau.insert(QStringLiteral("targetW"), action.estimatedPowerW);
|
||
niveau.insert(QStringLiteral("funding"),
|
||
action.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 — les deux ne se dérivent pas l'un de l'autre, et sur un
|
||
// EV_GRID_START l'un vaut « grid » quand l'autre porte deux destinations.
|
||
//
|
||
// Publiée TOUJOURS, même à une seule destination et même à zéro : un « counts » vide
|
||
// satisferait l'identité en perdant la destination, sur le cas le plus fréquent. Un
|
||
// zéro AVEC sa destination dit « cette grandeur existe et vaut 0 » ; l'absence dirait
|
||
// « elle n'existe pas », ce qui est une autre affirmation.
|
||
QVariantMap counts;
|
||
for (auto it = action.counts.constBegin(); it != action.counts.constEnd(); ++it)
|
||
counts.insert(it.key(), it.value());
|
||
niveau.insert(QStringLiteral("counts"), counts);
|
||
entry.insert(QStringLiteral("levels"), QVariantList() << niveau);
|
||
entry.insert(QStringLiteral("available"), adapter->telemetry().available);
|
||
entry.insert(QStringLiteral("decision"), action.reason.toMap());
|
||
|
||
const LoadRuntimeView v = adapter->runtimeView(now);
|
||
if (!v.faultCode.isEmpty())
|
||
entry.insert(QStringLiteral("faultCode"), v.faultCode);
|
||
if (!v.lockKind.isEmpty()) {
|
||
QVariantMap lock;
|
||
lock.insert(QStringLiteral("kind"), v.lockKind);
|
||
lock.insert(QStringLiteral("remainingS"), v.lockRemainingS);
|
||
entry.insert(QStringLiteral("lock"), lock);
|
||
}
|
||
if (!v.mechanismKind.isEmpty()) {
|
||
QVariantMap mech = v.mechanism;
|
||
mech.insert(QStringLiteral("kind"), v.mechanismKind);
|
||
entry.insert(QStringLiteral("mechanism"), mech);
|
||
}
|
||
attachMeasurements(entry, action.loadId);
|
||
loads.append(entry);
|
||
}
|
||
|
||
// Règle 7-c — une charge arbitrée sans décision serait un TROU dans la charge utile, et le
|
||
// client ne pourrait pas distinguer « charge absente » de « charge oubliée du plan ». Cela ne
|
||
// devrait pas arriver (toute charge du contexte reçoit une action) : on l'annonce donc au
|
||
// journal plutôt que de le laisser passer sans bruit.
|
||
//
|
||
// Les BORNES sont volontairement hors de ce contrôle : une borne sans voiture assignée ou
|
||
// en mode manuel est HORS arbitrage (DESIGN_3g §4), et ne recevoir aucune action y est le
|
||
// comportement correct — pas un trou. Avertir à chaque cycle sur une box dont la borne est
|
||
// simplement débranchée noierait le vrai signal.
|
||
for (auto it = m_loadAdapters.constBegin(); it != m_loadAdapters.constEnd(); ++it)
|
||
if (!couvertes.contains(it.key()))
|
||
qCWarning(dcNymeaEnergy()) << "[Arbitre] Télémétrie : charge" << it.key()
|
||
<< "arbitrée mais SANS décision dans le plan — omise.";
|
||
|
||
snap.insert(QStringLiteral("loads"), loads);
|
||
m_telemetry = snap;
|
||
}
|
||
|
||
void EnergyArbitrator::attachMeasurements(QVariantMap &entry, const QString &loadId) const
|
||
{
|
||
if (!m_tm)
|
||
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);
|
||
|
||
// Sonde — lecture seule (LM-1103). Sa raison d'être immédiate est le diagnostic ; sa
|
||
// raison d'être durable est l'HISTORIQUE, sans lequel les seuils éco/confort du §10 ne
|
||
// se règleront jamais autrement qu'au doigt mouillé.
|
||
if (!cfg.sensorThingId().isEmpty()) {
|
||
Thing *t = m_tm->findConfiguredThing(ThingId(cfg.sensorThingId()));
|
||
if (t && t->hasState("temperature"))
|
||
entry.insert(QStringLiteral("temperatureC"), t->stateValue("temperature").toDouble());
|
||
}
|
||
}
|
||
|
||
void EnergyArbitrator::buildDegradedTelemetry(const QDateTime &now)
|
||
{
|
||
QVariantMap snap;
|
||
snap.insert(QStringLiteral("timestamp"), now.toUTC().toString(Qt::ISODate));
|
||
snap.insert(QStringLiteral("degradedMode"), true);
|
||
// PAS de « budget » : en mode dégradé la planification est SUSPENDUE, il n'existe aucun
|
||
// budget pour ce cycle. Publier des zéros se lirait « arbitrage exécuté, rien à allouer » —
|
||
// le contraire de la vérité. Le champ est omis, jamais nul (règle maison, cf. GetEnergyRatios).
|
||
|
||
QVariantList loads;
|
||
auto entree = [&](const QString &id, ILoadAdapter *adapter) {
|
||
QVariantMap entry;
|
||
entry.insert(QStringLiteral("loadId"), id);
|
||
entry.insert(QStringLiteral("allocatedW"), 0.0);
|
||
entry.insert(QStringLiteral("available"), adapter->telemetry().available);
|
||
entry.insert(QStringLiteral("decision"), DecisionReason(DecisionCode::DegradedL2).toMap());
|
||
const LoadRuntimeView v = adapter->runtimeView(now);
|
||
if (!v.faultCode.isEmpty())
|
||
entry.insert(QStringLiteral("faultCode"), v.faultCode);
|
||
if (!v.lockKind.isEmpty()) {
|
||
QVariantMap lock;
|
||
lock.insert(QStringLiteral("kind"), v.lockKind);
|
||
lock.insert(QStringLiteral("remainingS"), v.lockRemainingS);
|
||
entry.insert(QStringLiteral("lock"), lock);
|
||
}
|
||
if (!v.mechanismKind.isEmpty()) {
|
||
QVariantMap mech = v.mechanism;
|
||
mech.insert(QStringLiteral("kind"), v.mechanismKind);
|
||
entry.insert(QStringLiteral("mechanism"), mech);
|
||
}
|
||
// Publiées AUSSI en mode dégradé : c'est justement là qu'un exploitant veut voir ce
|
||
// que la charge fait vraiment, puisque l'arbitre, lui, ne décide plus rien.
|
||
attachMeasurements(entry, id);
|
||
loads.append(entry);
|
||
};
|
||
// Ordre STABLE : QHash n'en garantit aucun, et un instantané se compare.
|
||
//
|
||
// Les BORNES y figurent aussi depuis 3g-1. Le mode dégradé est précisément le moment où
|
||
// savoir qu'une borne existe et n'est plus pilotée a le plus de valeur : l'omettre ferait
|
||
// lire « pas de borne » là où la vérité est « borne non pilotée ».
|
||
// Pas de champ « funding » ici : en dégradé il n'y a aucun plan, donc aucun financement —
|
||
// et le champ est omis, jamais nul.
|
||
QStringList ids = m_loadAdapters.keys();
|
||
ids.sort();
|
||
for (const QString &id : ids)
|
||
entree(id, m_loadAdapters.value(id));
|
||
|
||
QStringList evIds = m_adapters.keys();
|
||
evIds.sort();
|
||
for (const QString &id : evIds)
|
||
entree(id, m_adapters.value(id));
|
||
|
||
snap.insert(QStringLiteral("loads"), loads);
|
||
m_telemetry = snap;
|
||
}
|
||
|
||
void EnergyArbitrator::publishTelemetry(bool force)
|
||
{
|
||
if (m_telemetry.isEmpty())
|
||
return; // aucun cycle encore exécuté — rien à dire, et rien à inventer.
|
||
if (!force && !telemetrySignificantlyDiffers(m_publishedTelemetry, m_telemetry))
|
||
return;
|
||
|
||
m_publishedTelemetry = m_telemetry;
|
||
m_lastTelemetryPublish = QDateTime::currentDateTime();
|
||
// Le battement est un PLANCHER de cadence, pas un ajout : toute notification relance son
|
||
// décompte. Sur une box qui cycle chaque minute, le total reste donc d'environ UNE
|
||
// notification par minute — ce que la cible RPi Zero 2W tient sans y penser. Porter cette
|
||
// règle par le timer plutôt que par un test d'horloge dans le battement le rend aussi
|
||
// injectable : un test peut le déclencher sans attendre soixante secondes.
|
||
if (m_telemetryHeartbeat)
|
||
m_telemetryHeartbeat->start();
|
||
emit loadTelemetryChanged(m_telemetry);
|
||
}
|
||
|
||
void EnergyArbitrator::publishTelemetryHeartbeat()
|
||
{
|
||
publishTelemetry(true);
|
||
}
|
||
|
||
bool EnergyArbitrator::telemetrySignificantlyDiffers(const QVariantMap &a, const QVariantMap &b)
|
||
{
|
||
if (a.isEmpty())
|
||
return true; // première publication
|
||
if (a.value(QStringLiteral("degradedMode")) != b.value(QStringLiteral("degradedMode")))
|
||
return true;
|
||
if (a.contains(QStringLiteral("budget")) != b.contains(QStringLiteral("budget")))
|
||
return true; // entrée ou sortie du mode où il n'y a pas de budget
|
||
|
||
const double allocA = a.value(QStringLiteral("budget")).toMap()
|
||
.value(QStringLiteral("allocatedW")).toDouble();
|
||
const double allocB = b.value(QStringLiteral("budget")).toMap()
|
||
.value(QStringLiteral("allocatedW")).toDouble();
|
||
if (qAbs(allocA - allocB) > TelemetryAllocationThresholdW)
|
||
return true;
|
||
|
||
const QVariantList la = a.value(QStringLiteral("loads")).toList();
|
||
const QVariantList lb = b.value(QStringLiteral("loads")).toList();
|
||
if (la.size() != lb.size())
|
||
return true;
|
||
|
||
for (int i = 0; i < la.size(); ++i) {
|
||
const QVariantMap ea = la.at(i).toMap();
|
||
const QVariantMap eb = lb.at(i).toMap();
|
||
// Identité, disponibilité, défaut, NATURE du verrou, mécanisme, CODE du motif : tout
|
||
// changement qualitatif se notifie immédiatement.
|
||
for (const char *k : {"loadId", "available", "faultCode"})
|
||
if (ea.value(QLatin1String(k)) != eb.value(QLatin1String(k)))
|
||
return true;
|
||
if (ea.value(QStringLiteral("lock")).toMap().value(QStringLiteral("kind"))
|
||
!= eb.value(QStringLiteral("lock")).toMap().value(QStringLiteral("kind")))
|
||
return true; // les SECONDES restantes, elles, décroissent en continu : ignorées.
|
||
if (ea.value(QStringLiteral("mechanism")).toMap().value(QStringLiteral("kind"))
|
||
!= eb.value(QStringLiteral("mechanism")).toMap().value(QStringLiteral("kind")))
|
||
return true;
|
||
if (ea.value(QStringLiteral("decision")).toMap().value(QStringLiteral("code"))
|
||
!= eb.value(QStringLiteral("decision")).toMap().value(QStringLiteral("code")))
|
||
return true; // les PARAMÈTRES du motif portent le budget, qui bouge à chaque cycle.
|
||
if (qAbs(ea.value(QStringLiteral("allocatedW")).toDouble()
|
||
- eb.value(QStringLiteral("allocatedW")).toDouble())
|
||
> TelemetryAllocationThresholdW)
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|