buildTelemetry() n'interrogeait que la table des charges de configuration et sautait l'EV avec un commentaire devenu faux (« décidé en amont du waterfall »). Le journal montrait les décisions des deux bornes ; GetLoadTelemetry n'en montrait aucune. Le changement de contrat annoncé par +etm21 n'était donc pas livré — et LM-1204-b était inéprouvable, puisque son discriminant est justement le motif publié. Nouveau champ loads[].funding (« surplus » / « grid ») : dès lors que les bornes sont dans loads[], deux d'entre elles peuvent y être pour des raisons différentes, l'une comptée dans budget.allocatedW, l'autre dans budget.evReservedW. Sans lui, sommer allocatedW donne un écart ininterprétable. Constat non corrigé, écrit au contrat : le rang d'une borne n'est pas configurable (priority = 100 en dur) et std::sort n'est pas stable — laquelle des deux bornes est servie n'est ni réglable ni garanti reproductible. Aussi : « batterie à 50 %% » — %% n'est pas une échappée pour QString::arg. Vu au banc. Et retrait du code mort buildMinCurrentAction/buildIdleAction, seuls porteurs de EV_ECO_MIN et EV_IDLE depuis +etm21. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
915 lines
45 KiB
C++
915 lines
45 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;
|
|
}
|
|
|
|
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;
|
|
}
|