LM-1209-e. Le provisionnement vivait dans la branche « l'adaptateur vient d'être créé » de syncAdapters(). 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 — mesuré sur .75, rien en 140 s. Le trou de 3g-2 rouvert par le côté client : la borne restait ARBITRÉE mais ABSENTE de GetLoadConfig, à un rang de queue non réglable. Elle consommait du budget sans que personne puisse la classer, et rien ne le disait. La condition devient un ÉTAT et non un ÉVÉNEMENT — « cette borne n'a pas d'entrée » au lieu de « l'adaptateur vient de naître ». C'est la leçon qui dépasse le défaut : un rattrapage dont la condition est un événement ne rattrape rien, puisqu'il ne se rejoue pas. Même distinction qu'entre ECS-411, qui relit l'état réel des relais, et l'hypothèse de départ qu'il a remplacée. L'entrée recréée revient en rankOrigin "auto" — personne ne l'a classée, son rang est de nouveau un défaut — et le rang posé par l'installateur est PERDU avec l'entrée. Supprimer n'est donc pas un moyen de retirer une borne de l'arbitrage : elle revient, déclassée, en queue et avec son badge. C'est exactement pourquoi la marque doit se voir. enabled: false reste la voie et conserve le rang. Le refus de provisionnement n'est annoncé qu'une fois par borne (m_provisionRefuse) : le rattrapage étant cyclique, le répéter produirait un avertissement par minute et pour toujours — la règle est déjà posée pour les charges sans décision dans buildTelemetry(). Le drapeau tombe au premier succès. testDeletedEvChargerConfigComesBack couvre les deux moitiés : le rattrapage, et le cas négatif — il ne se rejoue PAS quand l'entrée existe, sans quoi il réécrirait la configuration à chaque cycle, donc un changed(), donc un rebuild d'adaptateurs, donc des verrous réarmés en boucle (ECS-412). Vérifié échouant avec l'ancien comportement. Le brief §6-C, qui promettait à l'app l'inverse de ce qui se passait sur le seul geste client qui détruit de la configuration, est corrigé — et dit désormais ce que la suppression coûte vraiment : le rang. Passe aussi LM-1209-d (grouper à l'affichage, jamais à l'écriture) et l'avertissement de sonde en tête d'INTERFACE.md. amd64 0/0. simulation 32/32, charging 16/16, loadmodel 20/20, spotmarket 7/7. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
1312 lines
68 KiB
C++
1312 lines
68 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||
|
||
#include "energyarbitrator.h"
|
||
#include "adapters/evadapter.h"
|
||
#include "adapters/sgreadyadapter.h"
|
||
#include "adapters/etmvariableloadadapter.h"
|
||
#include "adapters/relayrouter.h"
|
||
#include "config/loadconfigstore.h"
|
||
#include "types/loadconfig.h"
|
||
#include "scheduler/rulebasedscheduler.h"
|
||
#include "types/decisionreason.h"
|
||
#include "types/surpluscontext.h"
|
||
#include "types/plan.h"
|
||
#include "../rootmeter.h"
|
||
#include "../evcharger.h"
|
||
|
||
#include "plugininfo.h"
|
||
|
||
#include <energymanager.h>
|
||
#include <QTimer>
|
||
|
||
namespace {
|
||
//! Période du watchdog L2 (SAFETY.md §L2) : tick indépendant des signaux compteur.
|
||
constexpr int MeterWatchdogPeriodMs = 30 * 1000; // 30 s
|
||
//! Seuil de silence compteur au-delà duquel le mode dégradé L2 est déclenché.
|
||
constexpr int MeterSilenceThresholdS = 90; // 90 s
|
||
|
||
//! Battement de cœur de la télémétrie : période MAXIMALE entre deux notifications, même sans
|
||
//! changement. C'est ce qui permet au client de dater la dernière décision et donc de repérer
|
||
//! un arbitre figé — un état qui ne bouge pas n'émet rien de lui-même.
|
||
constexpr int TelemetryHeartbeatPeriodMs = 60 * 1000; // 60 s
|
||
|
||
//! Seuil d'allocation (W) en deçà duquel une variation ne déclenche PAS de notification.
|
||
//! Une charge à consigne continue module en permanence de quelques watts ; sans seuil, chaque
|
||
//! oscillation réveillerait tous les clients abonnés. 50 W est en dessous du plus petit palier
|
||
//! réel d'un ECS (≈ 500 W) et au-dessus du bruit de mesure d'un routeur.
|
||
constexpr double TelemetryAllocationThresholdW = 50.0;
|
||
}
|
||
|
||
EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm,
|
||
SpotMarketManager *sm,
|
||
EnergyManagerConfiguration *conf,
|
||
QObject *parent)
|
||
: SmartChargingManager(em, tm, sm, conf, parent)
|
||
, m_scheduler(new RuleBasedScheduler(this, this))
|
||
, m_tm(tm)
|
||
{
|
||
// --- L2 : watchdog fraîcheur compteur (SAFETY.md §L2) ---
|
||
// La LOGIQUE (recordMeterUpdate / evaluateMeterFreshness) prend le temps en paramètre
|
||
// et reste testable par injection (symétrique de simulationCallUpdate). Seuls les
|
||
// DÉCLENCHEURS RÉELS (signal + QTimer, horloge murale) sont câblés ici, et exclus en
|
||
// simulation — comme les connexions amont powerBalanceEntryAdded→update() (SCM l.108-130).
|
||
#ifndef ENERGY_SIMULATION
|
||
m_lastMeterUpdate = QDateTime::currentDateTime(); // grâce au démarrage (évite un dégradé immédiat)
|
||
// Fraîcheur picotée sur powerBalanceChanged (en plus de la connexion amont L4).
|
||
connect(em, &EnergyManager::powerBalanceChanged, this, [this]() {
|
||
recordMeterUpdate(QDateTime::currentDateTime());
|
||
});
|
||
// QTimer (et non signal) : doit rester actif quand le compteur est muet.
|
||
m_meterWatchdog = new QTimer(this);
|
||
m_meterWatchdog->setInterval(MeterWatchdogPeriodMs);
|
||
connect(m_meterWatchdog, &QTimer::timeout, this, &EnergyArbitrator::onMeterWatchdogTick);
|
||
m_meterWatchdog->start();
|
||
|
||
// Battement de cœur de la télémétrie. QTimer d'horloge murale, comme le watchdog et pour la
|
||
// MÊME raison : il doit continuer à battre quand le cycle d'arbitrage, lui, s'est arrêté.
|
||
// Un battement piloté par le cycle ne pourrait pas révéler l'arrêt du cycle.
|
||
m_telemetryHeartbeat = new QTimer(this);
|
||
m_telemetryHeartbeat->setInterval(TelemetryHeartbeatPeriodMs);
|
||
connect(m_telemetryHeartbeat, &QTimer::timeout, this, &EnergyArbitrator::publishTelemetryHeartbeat);
|
||
m_telemetryHeartbeat->start();
|
||
#else
|
||
Q_UNUSED(em)
|
||
#endif
|
||
|
||
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] Arbitre ETM initialisé.";
|
||
}
|
||
|
||
void EnergyArbitrator::runSurplusPlanning(const QDateTime &now)
|
||
{
|
||
planSurplusCharging(now);
|
||
}
|
||
|
||
void EnergyArbitrator::runSpotMarketPlanning(const QDateTime &now)
|
||
{
|
||
planSpotMarketCharging(now);
|
||
}
|
||
|
||
const QHash<EvCharger *, ChargingActions> &EnergyArbitrator::scheduledActions() const
|
||
{
|
||
return internalChargingActions();
|
||
}
|
||
|
||
void EnergyArbitrator::doExecuteChargingAction(EvCharger *charger,
|
||
const ChargingAction &action,
|
||
const QDateTime &now)
|
||
{
|
||
executeChargingAction(charger, action, now);
|
||
}
|
||
|
||
const QHash<ThingId, EvCharger *> &EnergyArbitrator::registeredEvChargers() const
|
||
{
|
||
return internalEvChargers();
|
||
}
|
||
|
||
RootMeter *EnergyArbitrator::registeredRootMeter() const
|
||
{
|
||
return internalRootMeter();
|
||
}
|
||
|
||
void EnergyArbitrator::registerEtmVariableLoadAdapter(EtmVariableLoadAdapter *adapter)
|
||
{
|
||
const QString id = adapter->descriptor().id;
|
||
if (m_loadAdapters.contains(id)) {
|
||
qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] charge pilotée déjà enregistrée:" << id;
|
||
return;
|
||
}
|
||
adapter->setParent(this);
|
||
m_loadAdapters[id] = adapter; // upcast EtmVariableLoadAdapter* → ILoadAdapter*
|
||
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] etmvariableload enregistré:" << adapter->descriptor().label;
|
||
}
|
||
|
||
void EnergyArbitrator::registerRelayRouter(RelayRouter *adapter)
|
||
{
|
||
const QString id = adapter->descriptor().id;
|
||
if (m_loadAdapters.contains(id)) {
|
||
qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] charge pilotée déjà enregistrée:" << id;
|
||
return;
|
||
}
|
||
adapter->setParent(this);
|
||
m_loadAdapters[id] = adapter; // upcast RelayRouter* → ILoadAdapter*
|
||
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] relay-router enregistré:" << adapter->descriptor().label;
|
||
}
|
||
|
||
void EnergyArbitrator::setLoadConfigStore(LoadConfigStore *store)
|
||
{
|
||
m_loadConfigStore = store;
|
||
if (!store)
|
||
return;
|
||
connect(store, &LoadConfigStore::changed, this, &EnergyArbitrator::rebuildLoadAdapters);
|
||
rebuildLoadAdapters(); // construction initiale depuis la config persistée
|
||
}
|
||
|
||
bool EnergyArbitrator::sameHardware(const LoadConfig &a, const LoadConfig &b)
|
||
{
|
||
// Champs dont un changement impose une VRAIE reconstruction : ils déterminent la table
|
||
// de paliers, le câblage ou les verrous. Tout le reste (rang, besoins, libellé) se met
|
||
// à jour en place.
|
||
if (a.adapter() != b.adapter() || a.mode() != b.mode())
|
||
return false;
|
||
if (a.minOnS() != b.minOnS() || a.minOffS() != b.minOffS())
|
||
return false;
|
||
if (a.maxPowerW() != b.maxPowerW() || a.powerLevelsInt() != b.powerLevelsInt())
|
||
return false;
|
||
|
||
// LM-300 — la charge utile fait partie du « matériel » : un état SG-Ready ré-encodé ou
|
||
// une estimation revue change ce que l'adaptateur commande, donc impose une reconstruction.
|
||
const LoadConfigSgReady sa = a.sgReadyPayload();
|
||
const LoadConfigSgReady sb = b.sgReadyPayload();
|
||
if (sa.minStateHoldS != sb.minStateHoldS || sa.states.size() != sb.states.size())
|
||
return false;
|
||
for (int i = 0; i < sa.states.size(); ++i) {
|
||
if (sa.states.at(i).state != sb.states.at(i).state
|
||
|| sa.states.at(i).relays != sb.states.at(i).relays
|
||
|| !qFuzzyCompare(1 + sa.states.at(i).estimatedPowerW, 1 + sb.states.at(i).estimatedPowerW))
|
||
return false;
|
||
}
|
||
|
||
// On ne compare pas les LISTES de relais, on compare la TABLE qu'elles produisent.
|
||
//
|
||
// Comparer index par index déclarait « matériel changé » sur un simple
|
||
// réordonnancement — mêmes Things, mêmes puissances — et coûtait donc une
|
||
// reconstruction complète : contacts ouverts par l'état sûr, verrous minOn/minOff
|
||
// réarmés à froid. Un installateur qui remonte une ligne dans l'app ne s'attend pas à
|
||
// couper sa charge, et annuler son geste en coûtait une seconde.
|
||
//
|
||
// Comparer les listes comme des ENSEMBLES aurait été faux dans l'autre sens :
|
||
// à somme égale la table retient la première combinaison rencontrée, donc échanger
|
||
// deux relais de MÊME puissance change le contact qui sert ce palier — autre
|
||
// contacteur qui s'use, autre résistance qui chauffe. Un ensemble ne le verrait pas.
|
||
//
|
||
// La table tranche les deux cas d'un coup, et sans pari : elle EST ce que le routeur
|
||
// exécute. deriveStages() est l'implémentation unique, partagée avec la construction
|
||
// du routeur et la publication en télémétrie.
|
||
return RelayRouter::deriveStages(a.relaysList())
|
||
== RelayRouter::deriveStages(b.relaysList());
|
||
}
|
||
|
||
bool EnergyArbitrator::clearLoadFault(const QString &loadId)
|
||
{
|
||
// Règle 7-c — l'ISSUE est journalisée ici, au niveau de l'arbitre, et pas dans chaque
|
||
// adaptateur. Une ligne écrite là couvre les quatre adaptateurs d'un coup et ne peut pas
|
||
// être oubliée par un adaptateur futur ; répétée dans chacun, elle manquerait le cinquième.
|
||
//
|
||
// Pourquoi cet appel mérite une issue plutôt qu'une simple trace de demande : c'est le
|
||
// SEUL levier de reprise à distance. ECS-410 pose un verrou collant que seuls ce RPC ou
|
||
// une reconstruction lèvent. Sans issue lisible, un opérateur à distance ne distingue pas
|
||
// « défaut levé » de « aucun défaut à lever » de « l'appel n'a pas atteint l'adaptateur » :
|
||
// il relance, puis conclut que le système est cassé.
|
||
ILoadAdapter *adapter = m_loadAdapters.value(loadId, nullptr);
|
||
if (!adapter) {
|
||
qCWarning(dcNymeaEnergy()) << "[Arbitre] ClearLoadFault" << loadId
|
||
<< "→ SANS EFFET : adaptateur introuvable "
|
||
"(identifiant inconnu ou charge désactivée).";
|
||
return false;
|
||
}
|
||
|
||
qCInfo(dcNymeaEnergy()) << "[Arbitre] ClearLoadFault demandé par l'opérateur pour" << loadId;
|
||
const bool leve = adapter->clearFault();
|
||
if (leve)
|
||
qCInfo(dcNymeaEnergy()) << "[Arbitre] ClearLoadFault" << loadId
|
||
<< "→ défaut LEVÉ ; la charge revient à l'arbitrage.";
|
||
else
|
||
qCInfo(dcNymeaEnergy()) << "[Arbitre] ClearLoadFault" << loadId
|
||
<< "→ sans objet : AUCUN défaut à lever, la charge n'était "
|
||
"pas verrouillée.";
|
||
return true;
|
||
}
|
||
|
||
void EnergyArbitrator::rebuildLoadAdapters()
|
||
{
|
||
if (!m_loadConfigStore) {
|
||
for (ILoadAdapter *a : m_loadAdapters)
|
||
if (QObject *o = dynamic_cast<QObject *>(a))
|
||
o->deleteLater();
|
||
m_loadAdapters.clear();
|
||
m_builtFrom.clear();
|
||
return;
|
||
}
|
||
|
||
// ECS-412 — reconstruction INCRÉMENTALE. Détruire un adaptateur réarme ses verrous : sur
|
||
// un ballon thermodynamique à minOn de 300-600 s, un client qui réordonne ses priorités
|
||
// depuis l'app pourrait faire court-cycler son compresseur. On ne reconstruit donc que
|
||
// ce dont le MATÉRIEL a changé ; le reste est mis à jour en place.
|
||
QHash<QString, ILoadAdapter *> kept;
|
||
QHash<QString, LoadConfig> keptFrom;
|
||
int created = 0, updated = 0, reused = 0;
|
||
|
||
for (const LoadConfig &c : m_loadConfigStore->configs()) {
|
||
// enabled==false : rôle déclaré mais EXCLU de l'arbitrage (contrat §9) — pas d'adaptateur.
|
||
if (!c.enabled())
|
||
continue;
|
||
|
||
// 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.
|
||
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.
|
||
for (ILoadAdapter *adapter : m_loadAdapters) {
|
||
const LoadDescriptor d = adapter->descriptor();
|
||
LoadAction la;
|
||
la.loadId = d.id;
|
||
la.force = true;
|
||
la.reason = reason;
|
||
if (d.supportedKinds.contains(LoadAction::State)) {
|
||
// PAC SG-Ready : état 2 (NORMAL — mains off), JAMAIS état 1 (blocage). Sous
|
||
// compteur muet on cesse de piloter : la PAC chauffe selon son propre thermostat
|
||
// (la bloquer = maison qui ne chauffe plus sans raison visible, cf. SAFETY.md).
|
||
la.kind = LoadAction::State;
|
||
la.state = 2;
|
||
} else if (d.supportedKinds.contains(LoadAction::Setpoint)) {
|
||
// Charges en watts : relay-router → tous relais ouverts ; etmvariableload → 0 W.
|
||
la.kind = LoadAction::Setpoint;
|
||
la.powerW = 0;
|
||
} else {
|
||
qCWarning(dcNymeaEnergy()) << "[Arbitre] Repli L2 : la charge" << d.id
|
||
<< "ne déclare aucun kind connu — AUCUN repli appliqué.";
|
||
continue;
|
||
}
|
||
adapter->applyAction(la, now);
|
||
}
|
||
|
||
// EV : repli CONSERVATEUR — n'initie aucune charge. On clampe seulement une charge
|
||
// DÉJÀ en cours au courant minimum (force=true, bypass lock). Une borne branchée mais
|
||
// non chargeante reste off (off volontaire possible : HC/spot à venir) ; débranchée →
|
||
// aucune action. La garantie "jamais 0 A si branché" relève du failsafe L1 de la borne.
|
||
for (auto it = internalEvChargers().constBegin(); it != internalEvChargers().constEnd(); ++it) {
|
||
EvCharger *ev = it.value();
|
||
if (ev->available() && ev->charging())
|
||
ev->setMaxChargingCurrent(ev->maxChargingCurrentMinValue(), now, true);
|
||
}
|
||
|
||
// Batterie (aucune charge réseau) : repli ajouté avec son adaptateur (3f).
|
||
|
||
// Reprise à froid (critère ECS-411) : un client qui se connecte APRÈS la bascule doit lire
|
||
// degradedMode. update() rend la main avant toute publication tant que le repli tient ; sans
|
||
// cet instantané forcé, la frontière de télémétrie resterait figée sur l'avant-dernier cycle
|
||
// NORMAL, et publierait donc l'inverse de la vérité.
|
||
buildDegradedTelemetry(now);
|
||
publishTelemetry(true);
|
||
}
|
||
|
||
// ---- Frontière de télémétrie (NymeaEnergy.GetLoadTelemetry / LoadTelemetryChanged) ---------
|
||
|
||
void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const QDateTime &now)
|
||
{
|
||
QVariantMap snap;
|
||
// ISO 8601 UTC — auto-descriptif et sans ambiguïté de fuseau. C'est la FIN du cycle
|
||
// d'arbitrage : deux notifications au même timestamp disent « rien n'a été décidé entre-temps ».
|
||
snap.insert(QStringLiteral("timestamp"), now.toUTC().toString(Qt::ISODate));
|
||
snap.insert(QStringLiteral("degradedMode"), m_degradedMode);
|
||
|
||
QVariantMap budget;
|
||
budget.insert(QStringLiteral("surplusW"), plan.budget.surplusW);
|
||
budget.insert(QStringLiteral("evReservedW"), plan.budget.evReservedW);
|
||
budget.insert(QStringLiteral("allocatedW"), plan.budget.allocatedW);
|
||
budget.insert(QStringLiteral("recreditedW"), plan.budget.recreditedW);
|
||
budget.insert(QStringLiteral("remainingW"), plan.budget.remainingW);
|
||
snap.insert(QStringLiteral("budget"), budget);
|
||
|
||
// On itère les ACTIONS, pas les tables d'adaptateurs : l'ordre du waterfall (rang 1 servi en
|
||
// premier) est porté par le slot, et QHash n'en garantit aucun. Un ordre instable ferait
|
||
// aussi diverger la comparaison de seuil d'un cycle à l'autre, pour rien.
|
||
QVariantList loads;
|
||
QSet<QString> couvertes;
|
||
for (const LoadAction &action : slot.actions) {
|
||
// 3g-1 — LES BORNES SONT DANS loads[]. Elles étaient exclues ici tant que l'EV était
|
||
// décidé en amont du waterfall ; depuis qu'il y entre, les omettre laisserait la somme
|
||
// des allocations irréconciliable avec budget.allocatedW et priverait le client du
|
||
// motif — c'est-à-dire de la SEULE chose qui distingue « l'arbitre a refusé » de « le
|
||
// matériel n'a pas suivi ». Attrapé au premier passage machine.
|
||
ILoadAdapter *adapter = m_loadAdapters.value(action.loadId, nullptr);
|
||
if (!adapter)
|
||
adapter = m_adapters.value(action.loadId, nullptr);
|
||
// 3g-2 — une borne CONFIGURÉE dont l'appareil manque n'a pas d'adaptateur, et doit
|
||
// pourtant être publiée : sans elle, une charge que GetLoadConfig annonce serait
|
||
// absente de GetLoadTelemetry, et l'app afficherait un vide sans cause. C'est le
|
||
// même invariant, pris par l'autre bout.
|
||
if (!adapter) {
|
||
if (!estUneBorneConfiguree(action.loadId))
|
||
continue;
|
||
couvertes.insert(action.loadId);
|
||
QVariantMap orphan;
|
||
orphan.insert(QStringLiteral("loadId"), action.loadId);
|
||
orphan.insert(QStringLiteral("allocatedW"), 0);
|
||
orphan.insert(QStringLiteral("funding"), QStringLiteral("surplus"));
|
||
orphan.insert(QStringLiteral("available"), false);
|
||
orphan.insert(QStringLiteral("decision"), action.reason.toMap());
|
||
orphan.insert(QStringLiteral("faultCode"), QStringLiteral("THING_MISSING"));
|
||
QVariantMap mech;
|
||
mech.insert(QStringLiteral("kind"), QStringLiteral("evcharger"));
|
||
orphan.insert(QStringLiteral("mechanism"), mech);
|
||
attachMeasurements(orphan, action.loadId);
|
||
loads.append(orphan);
|
||
continue;
|
||
}
|
||
couvertes.insert(action.loadId);
|
||
|
||
QVariantMap entry;
|
||
entry.insert(QStringLiteral("loadId"), action.loadId);
|
||
// ALLOCATION du waterfall, celle qui a décrémenté le budget — pas la mesure. La mesure
|
||
// vit dans la charge utile de mécanisme ; les confondre rendrait la somme des
|
||
// allocations irréconciliable avec budget.allocatedW.
|
||
entry.insert(QStringLiteral("allocatedW"), action.estimatedPowerW);
|
||
// Le FINANCEMENT, sans quoi la charge utile n'est pas réconciliable. Seules les actions
|
||
// financées au SURPLUS ont décrémenté la cascade et sont comptées dans
|
||
// budget.allocatedW ; celles financées au RÉSEAU — échéance et spot market, restées au
|
||
// proxy — portent une puissance qui vit dans budget.evReservedW. Sans ce champ, un
|
||
// client qui somme loads[].allocatedW trouve un écart et n'a aucun moyen de savoir
|
||
// s'il regarde un bug ou une borne servie au réseau.
|
||
entry.insert(QStringLiteral("funding"),
|
||
action.funding == LoadAction::Grid ? QStringLiteral("grid")
|
||
: QStringLiteral("surplus"));
|
||
entry.insert(QStringLiteral("available"), adapter->telemetry().available);
|
||
entry.insert(QStringLiteral("decision"), action.reason.toMap());
|
||
|
||
const LoadRuntimeView v = adapter->runtimeView(now);
|
||
if (!v.faultCode.isEmpty())
|
||
entry.insert(QStringLiteral("faultCode"), v.faultCode);
|
||
if (!v.lockKind.isEmpty()) {
|
||
QVariantMap lock;
|
||
lock.insert(QStringLiteral("kind"), v.lockKind);
|
||
lock.insert(QStringLiteral("remainingS"), v.lockRemainingS);
|
||
entry.insert(QStringLiteral("lock"), lock);
|
||
}
|
||
if (!v.mechanismKind.isEmpty()) {
|
||
QVariantMap mech = v.mechanism;
|
||
mech.insert(QStringLiteral("kind"), v.mechanismKind);
|
||
entry.insert(QStringLiteral("mechanism"), mech);
|
||
}
|
||
attachMeasurements(entry, action.loadId);
|
||
loads.append(entry);
|
||
}
|
||
|
||
// Règle 7-c — une charge arbitrée sans décision serait un TROU dans la charge utile, et le
|
||
// client ne pourrait pas distinguer « charge absente » de « charge oubliée du plan ». Cela ne
|
||
// devrait pas arriver (toute charge du contexte reçoit une action) : on l'annonce donc au
|
||
// journal plutôt que de le laisser passer sans bruit.
|
||
//
|
||
// Les BORNES sont volontairement hors de ce contrôle : une borne sans voiture assignée ou
|
||
// en mode manuel est HORS arbitrage (DESIGN_3g §4), et ne recevoir aucune action y est le
|
||
// comportement correct — pas un trou. Avertir à chaque cycle sur une box dont la borne est
|
||
// simplement débranchée noierait le vrai signal.
|
||
for (auto it = m_loadAdapters.constBegin(); it != m_loadAdapters.constEnd(); ++it)
|
||
if (!couvertes.contains(it.key()))
|
||
qCWarning(dcNymeaEnergy()) << "[Arbitre] Télémétrie : charge" << it.key()
|
||
<< "arbitrée mais SANS décision dans le plan — omise.";
|
||
|
||
snap.insert(QStringLiteral("loads"), loads);
|
||
m_telemetry = snap;
|
||
}
|
||
|
||
void EnergyArbitrator::attachMeasurements(QVariantMap &entry, const QString &loadId) const
|
||
{
|
||
if (!m_tm)
|
||
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;
|
||
}
|