Deuxième défaut latent de 3g-1, corrigé à part du lot 3g-2 pour la même raison que le précédent. EvAdapter passait `currentPower / 230` comme courant de référence à la règle de bascule 1↔3 phases, au motif — écrit dans le commentaire — que l'allocation n'est pas encore connue à cet instant. Mais la bascule ne se décide pas sur ce que la borne TIRE : elle se décide sur ce qu'elle POURRAIT tirer. C'est la formule du proxy, `currentPower/230 + allowance`, et c'est la seule qui puisse répondre « redescends en monophasé, il n'y a plus que 800 W ». Avec le seul courant courant, une borne passée en triphasé y restait quel que soit l'effondrement du surplus — et son plancher publié restait à 4,1 kW, donc plus rien ne pouvait la servir. Le surplus, lui, EST connu avant l'allocation : c'est la mesure du compteur. Le terme de stockage de planSurplusCharging() (tampon de 500 W, réinjection au-dessus du seuil) n'entre volontairement pas dans cette référence : il la déplace à la marge et n'a d'effet que sur le choix 1↔3, jamais sur l'allocation — laquelle est faite par le waterfall, avec un budget qui tient compte du stockage. Suite simulation `run` 16/16, charging vert. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
944 lines
46 KiB
C++
944 lines
46 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;
|
||
|
||
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.";
|
||
return;
|
||
}
|
||
|
||
// ETM-only : sync adapters + proxy planification → log [Arbitre]
|
||
// getPlan() appelle planSpotMarketCharging() + planSurplusCharging() (position 5-6 amont).
|
||
syncAdapters();
|
||
SurplusContext ctx = buildContext(currentDateTime);
|
||
Plan plan = m_scheduler->getPlan(ctx);
|
||
Slot slot = plan.slotCovering(currentDateTime);
|
||
|
||
// Le LIBELLÉ n'est pas dans le motif : il est substitué AU RENDU (cf. decisionreason.h).
|
||
// On le prend dans le contexte du cycle, seul endroit qui connaisse toutes les charges.
|
||
QHash<QString, QString> libelles;
|
||
for (const LoadContext &lc : ctx.loads)
|
||
libelles.insert(lc.id, lc.label);
|
||
|
||
for (const LoadAction &action : slot.actions) {
|
||
qCInfo(dcNymeaEnergy()) << "[Arbitre]"
|
||
<< action.loadId << "→" << renderFr(action.reason, libelles.value(action.loadId))
|
||
<< "| activé:" << action.chargingEnabled
|
||
<< "| courant:" << action.currentA << "A"
|
||
<< "| phases:" << action.phaseCount
|
||
<< "| stratégie:" << plan.strategy;
|
||
}
|
||
|
||
// 7 : dispatch matériel (même position que l'amont — m_chargingActions rempli par getPlan())
|
||
applyActionsToAdapters(slot, currentDateTime); // charges arbitrées (Setpoint W et State)
|
||
adjustEvChargers(currentDateTime); // EV (kind==Setpoint) → proxy amont jusqu'à 3g
|
||
|
||
// 8 : frontière de télémétrie. APRÈS le dispatch, jamais avant : la charge utile de mécanisme
|
||
// (palier appliqué, état PAC) doit refléter ce qui a réellement été envoyé au matériel, pas
|
||
// ce que la stratégie espérait. Publier avant produirait un instantané qu'aucun relais ne
|
||
// confirme — la même famille de défaut qu'annoncer une puissance non appliquée (ECS-410).
|
||
buildTelemetry(plan, slot, currentDateTime);
|
||
publishTelemetry(false);
|
||
}
|
||
|
||
bool EnergyArbitrator::evParticipatesInWaterfall(EvCharger *ev) const
|
||
{
|
||
if (!ev)
|
||
return false;
|
||
|
||
const ChargingInfo info = chargingInfo(ev->id());
|
||
|
||
// Sans voiture assignée, le proxy ignorait la borne : ni SoC, ni plancher véhicule, ni
|
||
// échéance calculable. Rien de tout cela n'apparaît en entrant dans le waterfall.
|
||
if (!m_tm->findConfiguredThing(info.assignedCarId()))
|
||
return false;
|
||
|
||
// Mode manuel : l'utilisateur pilote, l'arbitre s'abstient. Hors arbitrage — pas rang 0.
|
||
if (info.chargingMode() == ChargingInfo::ChargingModeNormal)
|
||
return false;
|
||
|
||
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;
|
||
}
|
||
|
||
// La tolérance d'acquisition est le même facteur que celui qu'appliquait le proxy avant
|
||
// de décider d'enclencher : sans elle, le plancher publié serait plus bas que celui qui
|
||
// gouverne réellement le démarrage.
|
||
return minA * acquisitionTolerance() * 230.0 * phaseCount;
|
||
}
|
||
|
||
double EnergyArbitrator::evReferenceCurrentA(EvCharger *ev) const
|
||
{
|
||
if (!ev)
|
||
return 0;
|
||
double a = ev->currentPower() / 230.0;
|
||
if (RootMeter *meter = internalRootMeter())
|
||
a += -meter->currentPower() / 230.0; // < 0 au compteur = export = surplus
|
||
return a;
|
||
}
|
||
|
||
uint EnergyArbitrator::evTargetPhases(EvCharger *ev, double referenceA)
|
||
{
|
||
if (!ev)
|
||
return 1;
|
||
|
||
// Les bornes de ce qui est POSSIBLE viennent du manager, qui les a établies ce cycle avec
|
||
// la voiture branchée (canSwitchPhaseCount, min/maxPossiblePhaseCount). Les redériver ici
|
||
// en ferait une seconde version — et c'est exactement ce qui a divergé en 3g-1 : viser
|
||
// 3 phases sur une combinaison qui n'en permet qu'une triplait le plancher publié, et sous
|
||
// surcharge commandait 3 × 6 A là où le proxy commandait 1 × 6 A.
|
||
const ChargingProcessInfo pi = internalProcessInfo(ev);
|
||
if (!pi.canSwitchPhaseCount)
|
||
return qMax(1u, pi.maxPossiblePhaseCount);
|
||
|
||
const uint phases = bestPhaseCountFor(ev, referenceA);
|
||
return qMax(1u, qBound(pi.minPossiblePhaseCount, phases, pi.maxPossiblePhaseCount));
|
||
}
|
||
|
||
|
||
SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const
|
||
{
|
||
SurplusContext ctx;
|
||
ctx.timestamp = now;
|
||
|
||
// --- Compteur principal (AGENTS invariant 8 : mesure brute, aucune déduction) ---
|
||
RootMeter *meter = internalRootMeter();
|
||
if (meter) {
|
||
// currentPower() < 0 → export ; > 0 → import (convention amont SCM l.1141)
|
||
const double p = meter->currentPower();
|
||
ctx.meter.importW = qMax(0.0, p);
|
||
ctx.meter.exportW = qMax(0.0, -p);
|
||
ctx.meter.perPhaseA = {
|
||
meter->currentPhaseA(),
|
||
meter->currentPhaseB(),
|
||
meter->currentPhaseC()
|
||
};
|
||
}
|
||
// SurplusPv : interface inverter — déféré (remplissage prévu en 3d)
|
||
|
||
// --- Stockage (3g-1) ------------------------------------------------------------------
|
||
// Rempli ICI et plus dans le chemin VE. La prise en compte de la batterie n'a jamais été
|
||
// une règle « véhicule » : c'est une règle de BUDGET, et elle vivait dans
|
||
// planSurplusCharging() par accident d'histoire. Tant qu'elle y restait, une batterie sous
|
||
// sa réserve empêchait de charger la voiture mais laissait le chauffe-eau se servir.
|
||
//
|
||
// Toutes les batteries sont agrégées en une seule — même simplification que l'amont : un
|
||
// niveau moyen et une puissance totale. Distinguer deux stockages demanderait de savoir
|
||
// lequel arbitrer en premier, et rien dans le modèle ne le dit.
|
||
{
|
||
int n = 0;
|
||
double niveauTotal = 0, puissanceTotale = 0;
|
||
for (Thing *storage : m_tm->configuredThings().filterByInterface("energystorage")) {
|
||
++n;
|
||
niveauTotal += storage->stateValue("batteryLevel").toDouble();
|
||
puissanceTotale += storage->stateValue("currentPower").toDouble();
|
||
}
|
||
if (n > 0) {
|
||
ctx.battery.present = true;
|
||
ctx.battery.socPercent = niveauTotal / n;
|
||
ctx.battery.powerW = puissanceTotale; // > 0 charge, < 0 décharge
|
||
} else {
|
||
// Un filtre par interface qui ne trouve rien est TOUJOURS ambigu : « il n'y a pas
|
||
// de batterie » et « il y en a une, mal déclarée » se ressemblent, et la seconde
|
||
// désactive une règle de sécurité EN SILENCE. C'est le même mode de défaillance
|
||
// qu'un seuil de réserve mal réglé : rien ne se signale.
|
||
//
|
||
// Constaté sur le banc le 2026-08-26 : le Thing « SunSpec Storage » déclare
|
||
// `battery` mais pas `energystorage`, donc la réserve ne s'est jamais appliquée —
|
||
// ni ici, ni dans le proxy amont qui utilisait le même filtre, ni dans le bilan de
|
||
// puissance de l'experience-plugin qui filtre pareil.
|
||
const Things candidates = m_tm->configuredThings().filterByInterface("battery");
|
||
if (!candidates.isEmpty()) {
|
||
QStringList noms;
|
||
for (Thing *t : candidates)
|
||
noms << t->name();
|
||
qCWarning(dcNymeaEnergy())
|
||
<< "[Arbitre] Réserve de stockage INACTIVE :" << candidates.count()
|
||
<< "appareil(s) portant l'interface 'battery' mais PAS 'energystorage' —"
|
||
<< noms.join(", ") << ". Le waterfall ne peut pas les prendre en compte, et"
|
||
<< "le bilan de puissance de nymea non plus. À corriger dans le plugin qui"
|
||
<< "déclare la classe, pas ici.";
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- loads[] : bornes VE (3g-1) ------------------------------------------------------
|
||
// Elles entrent désormais dans le MÊME contexte que les autres charges — c'est toute la
|
||
// transplantation. Le filtre d'entrée du proxy est repris à l'identique : une borne sans
|
||
// voiture ou en mode manuel est HORS arbitrage, pas servie en dernier. Sa consommation est
|
||
// déjà dans le compteur racine, donc déjà dans le budget ; lui allouer quoi que ce soit la
|
||
// compterait deux fois.
|
||
for (auto it = m_adapters.constBegin(); it != m_adapters.constEnd(); ++it) {
|
||
EvCharger *ev = internalEvChargers().value(ThingId(it.key()));
|
||
if (!evParticipatesInWaterfall(ev)) {
|
||
qCDebug(dcNymeaEnergy()) << "[Arbitre] Borne" << (ev ? ev->name() : it.key())
|
||
<< "hors waterfall (pas de voiture assignée, ou mode manuel).";
|
||
continue;
|
||
}
|
||
ctx.loads.append(it.value()->toLoadContext(now));
|
||
}
|
||
|
||
// --- 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;
|
||
}
|
||
|
||
void EnergyArbitrator::syncAdapters()
|
||
{
|
||
// Crée les adapters manquants
|
||
for (auto it = internalEvChargers().constBegin(); it != internalEvChargers().constEnd(); ++it) {
|
||
const QString id = it.key().toString();
|
||
if (!m_adapters.contains(id))
|
||
m_adapters[id] = new EvAdapter(it.value(), this);
|
||
}
|
||
// Supprime les adapters obsolètes
|
||
for (const QString &id : m_adapters.keys()) {
|
||
if (!internalEvChargers().contains(ThingId(id)))
|
||
m_adapters.take(id)->deleteLater();
|
||
}
|
||
}
|
||
|
||
void EnergyArbitrator::applyActionsToAdapters(const Slot &slot, const QDateTime &now)
|
||
{
|
||
for (const LoadAction &action : slot.actions) {
|
||
// L'adaptateur applique, écrête et verrouille — il ne décide pas (règle 2).
|
||
// 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.
|
||
// Un Setpoint EV n'est PAS dans cette table → ignoré ici (dispatché par
|
||
// adjustEvChargers() amont jusqu'à 3g).
|
||
if (action.kind == LoadAction::State || action.kind == LoadAction::Setpoint) {
|
||
if (ILoadAdapter *adapter = m_loadAdapters.value(action.loadId, nullptr))
|
||
adapter->applyAction(action, now);
|
||
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);
|
||
if (!adapter)
|
||
continue;
|
||
couvertes.insert(action.loadId);
|
||
|
||
QVariantMap entry;
|
||
entry.insert(QStringLiteral("loadId"), action.loadId);
|
||
// ALLOCATION du waterfall, celle qui a décrémenté le budget — pas la mesure. La mesure
|
||
// vit dans la charge utile de mécanisme ; les confondre rendrait la somme des
|
||
// allocations irréconciliable avec budget.allocatedW.
|
||
entry.insert(QStringLiteral("allocatedW"), action.estimatedPowerW);
|
||
// Le FINANCEMENT, sans quoi la charge utile n'est pas réconciliable. Seules les actions
|
||
// financées au SURPLUS ont décrémenté la cascade et sont comptées dans
|
||
// budget.allocatedW ; celles financées au RÉSEAU — échéance et spot market, restées au
|
||
// proxy — portent une puissance qui vit dans budget.evReservedW. Sans ce champ, un
|
||
// client qui somme loads[].allocatedW trouve un écart et n'a aucun moyen de savoir
|
||
// s'il regarde un bug ou une borne servie au réseau.
|
||
entry.insert(QStringLiteral("funding"),
|
||
action.funding == LoadAction::Grid ? QStringLiteral("grid")
|
||
: QStringLiteral("surplus"));
|
||
entry.insert(QStringLiteral("available"), adapter->telemetry().available);
|
||
entry.insert(QStringLiteral("decision"), action.reason.toMap());
|
||
|
||
const LoadRuntimeView v = adapter->runtimeView(now);
|
||
if (!v.faultCode.isEmpty())
|
||
entry.insert(QStringLiteral("faultCode"), v.faultCode);
|
||
if (!v.lockKind.isEmpty()) {
|
||
QVariantMap lock;
|
||
lock.insert(QStringLiteral("kind"), v.lockKind);
|
||
lock.insert(QStringLiteral("remainingS"), v.lockRemainingS);
|
||
entry.insert(QStringLiteral("lock"), lock);
|
||
}
|
||
if (!v.mechanismKind.isEmpty()) {
|
||
QVariantMap mech = v.mechanism;
|
||
mech.insert(QStringLiteral("kind"), v.mechanismKind);
|
||
entry.insert(QStringLiteral("mechanism"), mech);
|
||
}
|
||
attachMeasurements(entry, action.loadId);
|
||
loads.append(entry);
|
||
}
|
||
|
||
// Règle 7-c — une charge arbitrée sans décision serait un TROU dans la charge utile, et le
|
||
// client ne pourrait pas distinguer « charge absente » de « charge oubliée du plan ». Cela ne
|
||
// devrait pas arriver (toute charge du contexte reçoit une action) : on l'annonce donc au
|
||
// journal plutôt que de le laisser passer sans bruit.
|
||
//
|
||
// Les BORNES sont volontairement hors de ce contrôle : une borne sans voiture assignée ou
|
||
// en mode manuel est HORS arbitrage (DESIGN_3g §4), et ne recevoir aucune action y est le
|
||
// comportement correct — pas un trou. Avertir à chaque cycle sur une box dont la borne est
|
||
// simplement débranchée noierait le vrai signal.
|
||
for (auto it = m_loadAdapters.constBegin(); it != m_loadAdapters.constEnd(); ++it)
|
||
if (!couvertes.contains(it.key()))
|
||
qCWarning(dcNymeaEnergy()) << "[Arbitre] Télémétrie : charge" << it.key()
|
||
<< "arbitrée mais SANS décision dans le plan — omise.";
|
||
|
||
snap.insert(QStringLiteral("loads"), loads);
|
||
m_telemetry = snap;
|
||
}
|
||
|
||
void EnergyArbitrator::attachMeasurements(QVariantMap &entry, const QString &loadId) const
|
||
{
|
||
if (!m_tm || !m_loadConfigStore)
|
||
return;
|
||
|
||
LoadConfig cfg;
|
||
bool trouvee = false;
|
||
for (const LoadConfig &c : m_loadConfigStore->configs()) {
|
||
if (c.id() == loadId) { cfg = c; trouvee = true; break; }
|
||
}
|
||
if (!trouvee)
|
||
return;
|
||
|
||
// Compteur — VÉRIFICATION seulement (LM-1101). Publiée à côté de allocatedW, jamais
|
||
// fondue dedans : la somme des allocations doit rester réconciliable avec
|
||
// budget.allocatedW, et une mesure en retard sur la commande la rendrait fausse.
|
||
if (!cfg.meterThingId().isEmpty()) {
|
||
Thing *t = m_tm->findConfiguredThing(ThingId(cfg.meterThingId()));
|
||
if (t && t->hasState("currentPower"))
|
||
entry.insert(QStringLiteral("measuredW"), t->stateValue("currentPower").toDouble());
|
||
}
|
||
|
||
// Sonde — lecture seule (LM-1103). Sa raison d'être immédiate est le diagnostic ; sa
|
||
// raison d'être durable est l'HISTORIQUE, sans lequel les seuils éco/confort du §10 ne
|
||
// se règleront jamais autrement qu'au doigt mouillé.
|
||
if (!cfg.sensorThingId().isEmpty()) {
|
||
Thing *t = m_tm->findConfiguredThing(ThingId(cfg.sensorThingId()));
|
||
if (t && t->hasState("temperature"))
|
||
entry.insert(QStringLiteral("temperatureC"), t->stateValue("temperature").toDouble());
|
||
}
|
||
}
|
||
|
||
void EnergyArbitrator::buildDegradedTelemetry(const QDateTime &now)
|
||
{
|
||
QVariantMap snap;
|
||
snap.insert(QStringLiteral("timestamp"), now.toUTC().toString(Qt::ISODate));
|
||
snap.insert(QStringLiteral("degradedMode"), true);
|
||
// PAS de « budget » : en mode dégradé la planification est SUSPENDUE, il n'existe aucun
|
||
// budget pour ce cycle. Publier des zéros se lirait « arbitrage exécuté, rien à allouer » —
|
||
// le contraire de la vérité. Le champ est omis, jamais nul (règle maison, cf. GetEnergyRatios).
|
||
|
||
QVariantList loads;
|
||
auto entree = [&](const QString &id, ILoadAdapter *adapter) {
|
||
QVariantMap entry;
|
||
entry.insert(QStringLiteral("loadId"), id);
|
||
entry.insert(QStringLiteral("allocatedW"), 0.0);
|
||
entry.insert(QStringLiteral("available"), adapter->telemetry().available);
|
||
entry.insert(QStringLiteral("decision"), DecisionReason(DecisionCode::DegradedL2).toMap());
|
||
const LoadRuntimeView v = adapter->runtimeView(now);
|
||
if (!v.faultCode.isEmpty())
|
||
entry.insert(QStringLiteral("faultCode"), v.faultCode);
|
||
if (!v.lockKind.isEmpty()) {
|
||
QVariantMap lock;
|
||
lock.insert(QStringLiteral("kind"), v.lockKind);
|
||
lock.insert(QStringLiteral("remainingS"), v.lockRemainingS);
|
||
entry.insert(QStringLiteral("lock"), lock);
|
||
}
|
||
if (!v.mechanismKind.isEmpty()) {
|
||
QVariantMap mech = v.mechanism;
|
||
mech.insert(QStringLiteral("kind"), v.mechanismKind);
|
||
entry.insert(QStringLiteral("mechanism"), mech);
|
||
}
|
||
// Publiées AUSSI en mode dégradé : c'est justement là qu'un exploitant veut voir ce
|
||
// que la charge fait vraiment, puisque l'arbitre, lui, ne décide plus rien.
|
||
attachMeasurements(entry, id);
|
||
loads.append(entry);
|
||
};
|
||
// Ordre STABLE : QHash n'en garantit aucun, et un instantané se compare.
|
||
//
|
||
// Les BORNES y figurent aussi depuis 3g-1. Le mode dégradé est précisément le moment où
|
||
// savoir qu'une borne existe et n'est plus pilotée a le plus de valeur : l'omettre ferait
|
||
// lire « pas de borne » là où la vérité est « borne non pilotée ».
|
||
// Pas de champ « funding » ici : en dégradé il n'y a aucun plan, donc aucun financement —
|
||
// et le champ est omis, jamais nul.
|
||
QStringList ids = m_loadAdapters.keys();
|
||
ids.sort();
|
||
for (const QString &id : ids)
|
||
entree(id, m_loadAdapters.value(id));
|
||
|
||
QStringList evIds = m_adapters.keys();
|
||
evIds.sort();
|
||
for (const QString &id : evIds)
|
||
entree(id, m_adapters.value(id));
|
||
|
||
snap.insert(QStringLiteral("loads"), loads);
|
||
m_telemetry = snap;
|
||
}
|
||
|
||
void EnergyArbitrator::publishTelemetry(bool force)
|
||
{
|
||
if (m_telemetry.isEmpty())
|
||
return; // aucun cycle encore exécuté — rien à dire, et rien à inventer.
|
||
if (!force && !telemetrySignificantlyDiffers(m_publishedTelemetry, m_telemetry))
|
||
return;
|
||
|
||
m_publishedTelemetry = m_telemetry;
|
||
m_lastTelemetryPublish = QDateTime::currentDateTime();
|
||
// Le battement est un PLANCHER de cadence, pas un ajout : toute notification relance son
|
||
// décompte. Sur une box qui cycle chaque minute, le total reste donc d'environ UNE
|
||
// notification par minute — ce que la cible RPi Zero 2W tient sans y penser. Porter cette
|
||
// règle par le timer plutôt que par un test d'horloge dans le battement le rend aussi
|
||
// injectable : un test peut le déclencher sans attendre soixante secondes.
|
||
if (m_telemetryHeartbeat)
|
||
m_telemetryHeartbeat->start();
|
||
emit loadTelemetryChanged(m_telemetry);
|
||
}
|
||
|
||
void EnergyArbitrator::publishTelemetryHeartbeat()
|
||
{
|
||
publishTelemetry(true);
|
||
}
|
||
|
||
bool EnergyArbitrator::telemetrySignificantlyDiffers(const QVariantMap &a, const QVariantMap &b)
|
||
{
|
||
if (a.isEmpty())
|
||
return true; // première publication
|
||
if (a.value(QStringLiteral("degradedMode")) != b.value(QStringLiteral("degradedMode")))
|
||
return true;
|
||
if (a.contains(QStringLiteral("budget")) != b.contains(QStringLiteral("budget")))
|
||
return true; // entrée ou sortie du mode où il n'y a pas de budget
|
||
|
||
const double allocA = a.value(QStringLiteral("budget")).toMap()
|
||
.value(QStringLiteral("allocatedW")).toDouble();
|
||
const double allocB = b.value(QStringLiteral("budget")).toMap()
|
||
.value(QStringLiteral("allocatedW")).toDouble();
|
||
if (qAbs(allocA - allocB) > TelemetryAllocationThresholdW)
|
||
return true;
|
||
|
||
const QVariantList la = a.value(QStringLiteral("loads")).toList();
|
||
const QVariantList lb = b.value(QStringLiteral("loads")).toList();
|
||
if (la.size() != lb.size())
|
||
return true;
|
||
|
||
for (int i = 0; i < la.size(); ++i) {
|
||
const QVariantMap ea = la.at(i).toMap();
|
||
const QVariantMap eb = lb.at(i).toMap();
|
||
// Identité, disponibilité, défaut, NATURE du verrou, mécanisme, CODE du motif : tout
|
||
// changement qualitatif se notifie immédiatement.
|
||
for (const char *k : {"loadId", "available", "faultCode"})
|
||
if (ea.value(QLatin1String(k)) != eb.value(QLatin1String(k)))
|
||
return true;
|
||
if (ea.value(QStringLiteral("lock")).toMap().value(QStringLiteral("kind"))
|
||
!= eb.value(QStringLiteral("lock")).toMap().value(QStringLiteral("kind")))
|
||
return true; // les SECONDES restantes, elles, décroissent en continu : ignorées.
|
||
if (ea.value(QStringLiteral("mechanism")).toMap().value(QStringLiteral("kind"))
|
||
!= eb.value(QStringLiteral("mechanism")).toMap().value(QStringLiteral("kind")))
|
||
return true;
|
||
if (ea.value(QStringLiteral("decision")).toMap().value(QStringLiteral("code"))
|
||
!= eb.value(QStringLiteral("decision")).toMap().value(QStringLiteral("code")))
|
||
return true; // les PARAMÈTRES du motif portent le budget, qui bouge à chaque cycle.
|
||
if (qAbs(ea.value(QStringLiteral("allocatedW")).toDouble()
|
||
- eb.value(QStringLiteral("allocatedW")).toDouble())
|
||
> TelemetryAllocationThresholdW)
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|