`sessionEnergy` est l'ACCUMULATEUR du plugin V2C, pas un compteur remis à zéro pour le moteur : l'écart avec `chargeEnergy` est constant au dix-millième (7,2044 kWh sur deux échantillons à deux heures d'écart), parce qu'il porte les incréments d'avant la dernière remise à zéro de la borne. Le lire directement ferait croire toute obligation éco tenue de 7,2 kWh dès le premier cycle — un nombre crédible et faux que rien ne signalerait. Contre-épreuve : lecture directe, le test annonce 7204. La base se prend à l'ouverture de l'OBLIGATION, jamais au démarrage de nymead : sinon un redémarrage en pleine session réinitialise l'avancement sur une valeur déjà accumulée, et la recharge de nuit relue au matin repart de zéro. Elle est RECAPTURÉE sur un recul franc de l'accumulateur — une nouvelle session — mais pas sur un recul infime, qui est du bruit de lecture et coûterait l'avancement acquis à chaque frémissement du compteur. Ce que le moteur ne refait PAS : la détection de remise à zéro de la BORNE vit dans le plugin, qui interroge à 30 s. Le moteur travaille à la minute et raterait des cycles entiers. Lire comment le plugin l'avait écrite a évité d'ajouter une seconde accumulation, moins fine, en concurrence de la première. Trois maillons manquants raccordés, tous déclarés et alimentés par personne : EvCharger n'exposait pas sessionEnergy ; LoadContextTelemetry::sessionWh n'était rempli nulle part ; et toLoadContext() ABANDONNAIT les besoins déclarés, si bien que LoadContext::needs n'était rempli par aucun adaptateur. L'obligation éco n'existait qu'en configuration. Mock : sessionEnergy ajouté à chargerPhaseSwitching UNIQUEMENT — `charger` reste sans, ce qui donne le cas unmeasurable nativement. La suite porte les deux régimes, comme le banc. Le régime est publié alors qu'aucune décision ne s'y appuie encore, et c'est délibéré : cela sépare publier le régime de décider avec, comme measurement l'a été de l'arbitrage, et rend R6 testable le jour où le motif naîtra au lieu d'un invariant posé sur du code que rien n'émet. Suite : simulation 56/56, charging 48/48 identique à la référence ligne à ligne, loadmodel 21/21, spotmarket 32/32, doxygen 0 avertissement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
1751 lines
92 KiB
C++
1751 lines
92 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||
|
||
#include "energyarbitrator.h"
|
||
#include "adapters/evadapter.h"
|
||
#include "adapters/sgreadyadapter.h"
|
||
#include "adapters/etmvariableloadadapter.h"
|
||
#include "adapters/relayrouter.h"
|
||
#include "config/loadconfigstore.h"
|
||
#include "types/loadconfig.h"
|
||
#include "scheduler/rulebasedscheduler.h"
|
||
#include "types/decisionreason.h"
|
||
#include "types/surpluscontext.h"
|
||
#include "types/plan.h"
|
||
#include "../rootmeter.h"
|
||
#include "../evcharger.h"
|
||
|
||
#include "plugininfo.h"
|
||
|
||
#include <energymanager.h>
|
||
#include <QTimer>
|
||
|
||
namespace {
|
||
//! Période du watchdog L2 (SAFETY.md §L2) : tick indépendant des signaux compteur.
|
||
constexpr int MeterWatchdogPeriodMs = 30 * 1000; // 30 s
|
||
//! Seuil de silence compteur au-delà duquel le mode dégradé L2 est déclenché.
|
||
constexpr int MeterSilenceThresholdS = 90; // 90 s
|
||
|
||
//! Battement de cœur de la télémétrie : période MAXIMALE entre deux notifications, même sans
|
||
//! changement. C'est ce qui permet au client de dater la dernière décision et donc de repérer
|
||
//! un arbitre figé — un état qui ne bouge pas n'émet rien de lui-même.
|
||
constexpr int TelemetryHeartbeatPeriodMs = 60 * 1000; // 60 s
|
||
|
||
//! Seuil d'allocation (W) en deçà duquel une variation ne déclenche PAS de notification.
|
||
//! Une charge à consigne continue module en permanence de quelques watts ; sans seuil, chaque
|
||
//! oscillation réveillerait tous les clients abonnés. 50 W est en dessous du plus petit palier
|
||
//! réel d'un ECS (≈ 500 W) et au-dessus du bruit de mesure d'un routeur.
|
||
constexpr double TelemetryAllocationThresholdW = 50.0;
|
||
}
|
||
|
||
EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm,
|
||
SpotMarketManager *sm,
|
||
EnergyManagerConfiguration *conf,
|
||
QObject *parent)
|
||
: SmartChargingManager(em, tm, sm, conf, parent)
|
||
, m_scheduler(new RuleBasedScheduler(this, this))
|
||
, m_tm(tm)
|
||
{
|
||
// --- L2 : watchdog fraîcheur compteur (SAFETY.md §L2) ---
|
||
// La LOGIQUE (recordMeterUpdate / evaluateMeterFreshness) prend le temps en paramètre
|
||
// et reste testable par injection (symétrique de simulationCallUpdate). Seuls les
|
||
// DÉCLENCHEURS RÉELS (signal + QTimer, horloge murale) sont câblés ici, et exclus en
|
||
// simulation — comme les connexions amont powerBalanceEntryAdded→update() (SCM l.108-130).
|
||
#ifndef ENERGY_SIMULATION
|
||
m_lastMeterUpdate = QDateTime::currentDateTime(); // grâce au démarrage (évite un dégradé immédiat)
|
||
// Fraîcheur picotée sur powerBalanceChanged (en plus de la connexion amont L4).
|
||
connect(em, &EnergyManager::powerBalanceChanged, this, [this]() {
|
||
recordMeterUpdate(QDateTime::currentDateTime());
|
||
});
|
||
// QTimer (et non signal) : doit rester actif quand le compteur est muet.
|
||
m_meterWatchdog = new QTimer(this);
|
||
m_meterWatchdog->setInterval(MeterWatchdogPeriodMs);
|
||
connect(m_meterWatchdog, &QTimer::timeout, this, &EnergyArbitrator::onMeterWatchdogTick);
|
||
m_meterWatchdog->start();
|
||
|
||
// Battement de cœur de la télémétrie. QTimer d'horloge murale, comme le watchdog et pour la
|
||
// MÊME raison : il doit continuer à battre quand le cycle d'arbitrage, lui, s'est arrêté.
|
||
// Un battement piloté par le cycle ne pourrait pas révéler l'arrêt du cycle.
|
||
m_telemetryHeartbeat = new QTimer(this);
|
||
m_telemetryHeartbeat->setInterval(TelemetryHeartbeatPeriodMs);
|
||
connect(m_telemetryHeartbeat, &QTimer::timeout, this, &EnergyArbitrator::publishTelemetryHeartbeat);
|
||
m_telemetryHeartbeat->start();
|
||
#else
|
||
Q_UNUSED(em)
|
||
#endif
|
||
|
||
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] Arbitre ETM initialisé.";
|
||
}
|
||
|
||
void EnergyArbitrator::runSurplusPlanning(const QDateTime &now)
|
||
{
|
||
planSurplusCharging(now);
|
||
}
|
||
|
||
void EnergyArbitrator::runSpotMarketPlanning(const QDateTime &now)
|
||
{
|
||
planSpotMarketCharging(now);
|
||
}
|
||
|
||
const QHash<EvCharger *, ChargingActions> &EnergyArbitrator::scheduledActions() const
|
||
{
|
||
return internalChargingActions();
|
||
}
|
||
|
||
void EnergyArbitrator::doExecuteChargingAction(EvCharger *charger,
|
||
const ChargingAction &action,
|
||
const QDateTime &now)
|
||
{
|
||
executeChargingAction(charger, action, now);
|
||
}
|
||
|
||
const QHash<ThingId, EvCharger *> &EnergyArbitrator::registeredEvChargers() const
|
||
{
|
||
return internalEvChargers();
|
||
}
|
||
|
||
RootMeter *EnergyArbitrator::registeredRootMeter() const
|
||
{
|
||
return internalRootMeter();
|
||
}
|
||
|
||
void EnergyArbitrator::registerEtmVariableLoadAdapter(EtmVariableLoadAdapter *adapter)
|
||
{
|
||
const QString id = adapter->descriptor().id;
|
||
if (m_loadAdapters.contains(id)) {
|
||
qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] charge pilotée déjà enregistrée:" << id;
|
||
return;
|
||
}
|
||
adapter->setParent(this);
|
||
m_loadAdapters[id] = adapter; // upcast EtmVariableLoadAdapter* → ILoadAdapter*
|
||
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] etmvariableload enregistré:" << adapter->descriptor().label;
|
||
}
|
||
|
||
void EnergyArbitrator::registerRelayRouter(RelayRouter *adapter)
|
||
{
|
||
const QString id = adapter->descriptor().id;
|
||
if (m_loadAdapters.contains(id)) {
|
||
qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] charge pilotée déjà enregistrée:" << id;
|
||
return;
|
||
}
|
||
adapter->setParent(this);
|
||
m_loadAdapters[id] = adapter; // upcast RelayRouter* → ILoadAdapter*
|
||
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] relay-router enregistré:" << adapter->descriptor().label;
|
||
}
|
||
|
||
void EnergyArbitrator::setLoadConfigStore(LoadConfigStore *store)
|
||
{
|
||
m_loadConfigStore = store;
|
||
if (!store)
|
||
return;
|
||
connect(store, &LoadConfigStore::changed, this, &EnergyArbitrator::rebuildLoadAdapters);
|
||
rebuildLoadAdapters(); // construction initiale depuis la config persistée
|
||
}
|
||
|
||
bool EnergyArbitrator::sameHardware(const LoadConfig &a, const LoadConfig &b)
|
||
{
|
||
// Champs dont un changement impose une VRAIE reconstruction : ils déterminent la table
|
||
// de paliers, le câblage ou les verrous. Tout le reste (rang, besoins, libellé) se met
|
||
// à jour en place.
|
||
if (a.adapter() != b.adapter() || a.mode() != b.mode())
|
||
return false;
|
||
if (a.minOnS() != b.minOnS() || a.minOffS() != b.minOffS())
|
||
return false;
|
||
if (a.maxPowerW() != b.maxPowerW() || a.powerLevelsInt() != b.powerLevelsInt())
|
||
return false;
|
||
|
||
// LM-300 — la charge utile fait partie du « matériel » : un état SG-Ready ré-encodé ou
|
||
// une estimation revue change ce que l'adaptateur commande, donc impose une reconstruction.
|
||
const LoadConfigSgReady sa = a.sgReadyPayload();
|
||
const LoadConfigSgReady sb = b.sgReadyPayload();
|
||
if (sa.minStateHoldS != sb.minStateHoldS || sa.states.size() != sb.states.size())
|
||
return false;
|
||
for (int i = 0; i < sa.states.size(); ++i) {
|
||
if (sa.states.at(i).state != sb.states.at(i).state
|
||
|| sa.states.at(i).relays != sb.states.at(i).relays
|
||
|| !qFuzzyCompare(1 + sa.states.at(i).estimatedPowerW, 1 + sb.states.at(i).estimatedPowerW))
|
||
return false;
|
||
}
|
||
|
||
// On ne compare pas les LISTES de relais, on compare la TABLE qu'elles produisent.
|
||
//
|
||
// Comparer index par index déclarait « matériel changé » sur un simple
|
||
// réordonnancement — mêmes Things, mêmes puissances — et coûtait donc une
|
||
// reconstruction complète : contacts ouverts par l'état sûr, verrous minOn/minOff
|
||
// réarmés à froid. Un installateur qui remonte une ligne dans l'app ne s'attend pas à
|
||
// couper sa charge, et annuler son geste en coûtait une seconde.
|
||
//
|
||
// Comparer les listes comme des ENSEMBLES aurait été faux dans l'autre sens :
|
||
// à somme égale la table retient la première combinaison rencontrée, donc échanger
|
||
// deux relais de MÊME puissance change le contact qui sert ce palier — autre
|
||
// contacteur qui s'use, autre résistance qui chauffe. Un ensemble ne le verrait pas.
|
||
//
|
||
// La table tranche les deux cas d'un coup, et sans pari : elle EST ce que le routeur
|
||
// exécute. deriveStages() est l'implémentation unique, partagée avec la construction
|
||
// du routeur et la publication en télémétrie.
|
||
return RelayRouter::deriveStages(a.relaysList())
|
||
== RelayRouter::deriveStages(b.relaysList());
|
||
}
|
||
|
||
bool EnergyArbitrator::clearLoadFault(const QString &loadId)
|
||
{
|
||
// Règle 7-c — l'ISSUE est journalisée ici, au niveau de l'arbitre, et pas dans chaque
|
||
// adaptateur. Une ligne écrite là couvre les quatre adaptateurs d'un coup et ne peut pas
|
||
// être oubliée par un adaptateur futur ; répétée dans chacun, elle manquerait le cinquième.
|
||
//
|
||
// Pourquoi cet appel mérite une issue plutôt qu'une simple trace de demande : c'est le
|
||
// SEUL levier de reprise à distance. ECS-410 pose un verrou collant que seuls ce RPC ou
|
||
// une reconstruction lèvent. Sans issue lisible, un opérateur à distance ne distingue pas
|
||
// « défaut levé » de « aucun défaut à lever » de « l'appel n'a pas atteint l'adaptateur » :
|
||
// il relance, puis conclut que le système est cassé.
|
||
ILoadAdapter *adapter = m_loadAdapters.value(loadId, nullptr);
|
||
if (!adapter) {
|
||
qCWarning(dcNymeaEnergy()) << "[Arbitre] ClearLoadFault" << loadId
|
||
<< "→ SANS EFFET : adaptateur introuvable "
|
||
"(identifiant inconnu ou charge désactivée).";
|
||
return false;
|
||
}
|
||
|
||
// \rule{ECS-410-b} — l'issue est journalisée, et par l'ARBITRE : c'est lui qui sait si
|
||
// un défaut existait, l'adaptateur ne rendant qu'un booléen.
|
||
qCInfo(dcNymeaEnergy()) << "[Arbitre] ClearLoadFault demandé par l'opérateur pour" << loadId;
|
||
const bool leve = adapter->clearFault();
|
||
if (leve)
|
||
qCInfo(dcNymeaEnergy()) << "[Arbitre] ClearLoadFault" << loadId
|
||
<< "→ défaut LEVÉ ; la charge revient à l'arbitrage.";
|
||
else
|
||
qCInfo(dcNymeaEnergy()) << "[Arbitre] ClearLoadFault" << loadId
|
||
<< "→ sans objet : AUCUN défaut à lever, la charge n'était "
|
||
"pas verrouillée.";
|
||
return true;
|
||
}
|
||
|
||
void EnergyArbitrator::rebuildLoadAdapters()
|
||
{
|
||
if (!m_loadConfigStore) {
|
||
for (ILoadAdapter *a : m_loadAdapters)
|
||
if (QObject *o = dynamic_cast<QObject *>(a))
|
||
o->deleteLater();
|
||
m_loadAdapters.clear();
|
||
m_builtFrom.clear();
|
||
return;
|
||
}
|
||
|
||
// ECS-412 — reconstruction INCRÉMENTALE. Détruire un adaptateur réarme ses verrous : sur
|
||
// un ballon thermodynamique à minOn de 300-600 s, un client qui réordonne ses priorités
|
||
// depuis l'app pourrait faire court-cycler son compresseur. On ne reconstruit donc que
|
||
// ce dont le MATÉRIEL a changé ; le reste est mis à jour en place.
|
||
QHash<QString, ILoadAdapter *> kept;
|
||
QHash<QString, LoadConfig> keptFrom;
|
||
int created = 0, updated = 0, reused = 0;
|
||
|
||
for (const LoadConfig &c : m_loadConfigStore->configs()) {
|
||
// enabled==false : rôle déclaré mais EXCLU de l'arbitrage (contrat §9) — pas d'adaptateur.
|
||
if (!c.enabled())
|
||
continue;
|
||
|
||
// 3g-2 — une borne ne se CONSTRUIT pas depuis la configuration : son adaptateur est bâti
|
||
// depuis le Thing par syncAdapters(), et son entrée ne porte qu'un rang. La construire
|
||
// ici en ferait un second adaptateur pour le même organe. C'est aussi ce qui rend
|
||
// l'aller-retour lire-réécrire inoffensif : réécrire une entrée evcharger ne reconstruit
|
||
// rien, donc ne réarme aucun verrou (ECS-412).
|
||
if (c.isEvCharger())
|
||
continue;
|
||
|
||
LoadNeeds needs;
|
||
needs.dailyDeadline = c.needs().dailyDeadline();
|
||
needs.minEnergyWhPerDay = c.needs().minEnergyWhPerDay();
|
||
|
||
ILoadAdapter *existing = m_loadAdapters.value(c.id(), nullptr);
|
||
if (existing && m_builtFrom.contains(c.id()) && sameHardware(m_builtFrom.value(c.id()), c)) {
|
||
// Matériel inchangé : on GARDE l'adaptateur — donc m_lastSwitch et le palier
|
||
// courant — et on ne met à jour que ce qui ne touche pas au matériel.
|
||
const LoadConfig &old = m_builtFrom[c.id()];
|
||
// Le LIBELLÉ compte ici, et nulle part ailleurs : il ne touche pas au matériel
|
||
// (donc pas à sameHardware()), mais il nomme la charge dans chaque ligne de
|
||
// journal. L'omettre de ce prédicat faisait compter un renommage seul comme
|
||
// « inchangée », l'adaptateur gardait son ancien nom, et le journal contredisait
|
||
// GetLoadConfig jusqu'à la prochaine reconstruction.
|
||
const bool soft = (old.label() != c.label())
|
||
|| (old.priority() != c.priority())
|
||
|| (old.needs().dailyDeadline() != c.needs().dailyDeadline())
|
||
|| (old.needs().minEnergyWhPerDay() != c.needs().minEnergyWhPerDay());
|
||
if (soft) {
|
||
existing->updateSoftConfig(c.label(), c.priority(), needs);
|
||
++updated;
|
||
} else {
|
||
++reused;
|
||
}
|
||
kept[c.id()] = existing;
|
||
keptFrom[c.id()] = c;
|
||
m_loadAdapters.remove(c.id()); // sorti de la table : ne sera pas détruit plus bas
|
||
continue;
|
||
}
|
||
|
||
// La distinction de TYPE vit ICI (rév. 3) ; au-dessus, tout est ILoadAdapter (Setpoint W).
|
||
ILoadAdapter *adapter = nullptr;
|
||
if (c.isSgReady()) {
|
||
// LM-300/301 — une branche de fabrique par mécanisme, rien d'autre à toucher.
|
||
const LoadConfigSgReady p = c.sgReadyPayload();
|
||
QHash<int, QList<QString>> stateRelays;
|
||
QHash<int, double> estimated;
|
||
for (const LoadConfigSgReadyState &e : p.states) {
|
||
stateRelays.insert(e.state, e.relays);
|
||
estimated.insert(e.state, e.estimatedPowerW);
|
||
}
|
||
adapter = new SgReadyAdapter(m_tm, c.id(), c.label(), stateRelays, estimated,
|
||
p.minStateHoldS, c.priority(), this);
|
||
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] sg-ready construit depuis config:"
|
||
<< c.label() << "(" << p.states.count() << "états )";
|
||
} else if (c.isRelayRouter()) {
|
||
adapter = new RelayRouter(m_tm, c.id(), c.label(), c.relaysList(),
|
||
c.minOnS(), c.minOffS(), c.priority(), needs, this);
|
||
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] relay-router construit depuis config:"
|
||
<< c.label() << "(" << c.relaysList().count() << "relais)";
|
||
} else {
|
||
adapter = new EtmVariableLoadAdapter(m_tm, c.id(), c.label(), c.powerLevelsInt(),
|
||
c.maxPowerW(), c.minPowerW(), c.priority(),
|
||
needs, this);
|
||
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] etmvariableload construit depuis config:"
|
||
<< c.label() << "(" << (c.isDynamic() ? "dynamic" : "fixed") << ")";
|
||
}
|
||
kept[c.id()] = adapter;
|
||
keptFrom[c.id()] = c;
|
||
++created;
|
||
}
|
||
|
||
// ECS-413 — ce qui reste dans m_loadAdapters n'est plus référencé par la config :
|
||
// désactivé (enabled: false) ou retiré. On l'amène à son ÉTAT SÛR **avant** de le
|
||
// détruire, sans quoi le matériel resterait dans son dernier état commandé — constaté
|
||
// au banc le 2026-08-09, trois relais laissés fermés juste avant un câblage.
|
||
//
|
||
// Ne s'applique QU'ICI : rien de tel à l'arrêt du plugin ni au redémarrage de nymead,
|
||
// où l'état doit être conservé (c'est ce qu'ECS-411 relit). La désactivation est un acte
|
||
// délibéré de l'opérateur ; un redémarrage n'en est pas un.
|
||
//
|
||
// Les charges CONSERVÉES ne passent pas par ici : un simple changement de rang ne coupe
|
||
// donc rien, et ECS-412 reste entier.
|
||
const int removed = m_loadAdapters.count();
|
||
const QDateTime now = QDateTime::currentDateTime();
|
||
for (ILoadAdapter *a : m_loadAdapters) {
|
||
a->applySafeState(now);
|
||
QObject *o = dynamic_cast<QObject *>(a);
|
||
if (!o)
|
||
continue;
|
||
// ECS-410 : les écritures sont asynchrones et `this` sert de contexte de connexion.
|
||
// Détruire tout de suite couperait les acquittements en vol — on ne saurait donc pas
|
||
// si la mise en sécurité a abouti, précisément dans le cas où elle échoue.
|
||
// deleteLater() laisse le cycle d'événements les délivrer d'abord.
|
||
o->deleteLater();
|
||
}
|
||
|
||
m_loadAdapters = kept;
|
||
m_builtFrom = keptFrom;
|
||
|
||
qCInfo(dcNymeaEnergy()) << "[EnergyArbitrator]" << m_loadAdapters.count()
|
||
<< "charge(s) pilotée(s) active(s) (config) —" << created << "créée(s),"
|
||
<< updated << "mise(s) à jour en place," << reused << "inchangée(s),"
|
||
<< removed << "retirée(s).";
|
||
}
|
||
|
||
void EnergyArbitrator::update(const QDateTime ¤tDateTime)
|
||
{
|
||
qCDebug(dcNymeaEnergy()) << "Updating smart charging";
|
||
// Ordre IDENTIQUE à SmartChargingManager::update() — INTERDIT de réordonner.
|
||
// SCM : 1.updateManual 2.prepareInfo 3.verifyOverload 4.verifyRecovery
|
||
// 5.planSpot 6.planSurplus 7.adjustEv
|
||
// ETM : idem 1-4 ; insertions ETM entre 4 et 7 ;
|
||
// planSpot + planSurplus appelés via m_scheduler->getPlan() (position 5-6).
|
||
|
||
// 1-4 : préparation + sécurité (même ordre que l'amont)
|
||
updateManualSoCsWithoutMeter(currentDateTime);
|
||
prepareInformation(currentDateTime);
|
||
verifyOverloadProtection(currentDateTime);
|
||
verifyOverloadProtectionRecovery(currentDateTime);
|
||
|
||
|
||
// Mode dégradé L2 : la sécurité (L4 ci-dessus) reste active, mais on SUSPEND la
|
||
// planification et le dispatch. Replanifier sur le cache d'un compteur mort
|
||
// rallumerait les charges que le watchdog vient de couper → oscillation. Les
|
||
// consignes de repli (posées à la transition) tiennent jusqu'au retour du compteur.
|
||
if (m_degradedMode) {
|
||
qCDebug(dcNymeaEnergy()) << "[Arbitre] Mode dégradé L2 actif — planification suspendue.";
|
||
// 3g-2 — le waterfall ne commande plus rien : il ne peut donc plus revendiquer aucune
|
||
// borne. Garder le jeu du dernier cycle sain les exclurait du proxy alors que plus
|
||
// personne ne les commande — elles resteraient figées sur leur dernière consigne,
|
||
// compteur muet, ce que SAFETY.md §L2 interdit. Le repli est posé à la transition par
|
||
// applyDegradedMode() ; c'est lui qui fait autorité tant que le compteur est muet.
|
||
m_waterfallEvIds.clear();
|
||
// SAFETY.md §L2 : la planification est suspendue, la SÉCURITÉ ne l'est pas. Le repli a
|
||
// posé des consignes à la transition ; si elles ne suffisent pas à tenir le plafond,
|
||
// c'est encore à L4 de trancher. Aucune télémétrie n'est publiée dans cet état, d'où
|
||
// le slot local — il ne sert qu'à recevoir les actions, personne ne le lira.
|
||
return;
|
||
}
|
||
|
||
// ETM-only : sync adapters + proxy planification → log [Arbitre]
|
||
// getPlan() appelle planSpotMarketCharging() + planSurplusCharging() (position 5-6 amont).
|
||
syncAdapters();
|
||
|
||
// L4 étendu au waterfall — ÉVALUÉ ICI : après syncAdapters(), avant toute écriture du cycle.
|
||
//
|
||
// Deux placements ont été essayés et écartés, chacun pour une raison distincte. APRÈS le
|
||
// dispatch semblait plus juste (« le dernier mot »), et c'était un
|
||
// piège : dans un même update() aucune boucle d'événements ne tourne, si bien qu'un
|
||
// adaptateur qui vient d'être commandé rapporte encore son état PRÉCÉDENT. Le délestage
|
||
// demandait alors un état que l'adaptateur croyait déjà tenir, se le voyait accorder sans
|
||
// rien faire, et publiait des watts rendus que le matériel n'avait pas rendus — une PAC
|
||
// restée en état 4 pendant qu'on annonçait 3 kW délestés. Avant tout dispatch, l'état lu
|
||
// est POSÉ, et ce qu'on constate est vrai. AVANT syncAdapters() échouait autrement : la
|
||
// reconstruction incrémentale (ECS-412) remplace l'adaptateur, et l'écriture en cours
|
||
// partait avec lui — les contacts restaient fermés pendant qu'on publiait un délestage.
|
||
//
|
||
// Ce que le plan ferait de ces charges est neutralisé autrement : L4 les REVENDIQUE, et le
|
||
// dispatch les saute — un seul commandeur par organe, exactement le mécanisme qui protège
|
||
// déjà les bornes du waterfall (m_waterfallEvIds). Le cycle suivant, la cascade ne les
|
||
// rallume pas non plus : la marge est encore négative, et l'étape 3 la borne.
|
||
shedWaterfallForOverload(currentDateTime);
|
||
|
||
SurplusContext ctx = buildContext(currentDateTime);
|
||
Plan plan = m_scheduler->getPlan(ctx);
|
||
Slot slot = plan.slotCovering(currentDateTime);
|
||
|
||
// Le LIBELLÉ n'est pas dans le motif : il est substitué AU RENDU (cf. decisionreason.h).
|
||
// On le prend dans le contexte du cycle, seul endroit qui connaisse toutes les charges.
|
||
QHash<QString, QString> libelles;
|
||
for (const LoadContext &lc : ctx.loads)
|
||
libelles.insert(lc.id, lc.label);
|
||
|
||
for (const LoadAction &action : slot.actions) {
|
||
qCInfo(dcNymeaEnergy()) << "[Arbitre]"
|
||
<< action.loadId << "→" << renderFr(action.reason, libelles.value(action.loadId))
|
||
<< "| activé:" << action.chargingEnabled
|
||
<< "| courant:" << action.currentA << "A"
|
||
<< "| phases:" << action.phaseCount
|
||
<< "| stratégie:" << plan.strategy;
|
||
}
|
||
|
||
// 3g-2 — le jeu des bornes que le waterfall a commandées, NOMMÉ par le scheduler. Il doit
|
||
// être posé AVANT le dispatch : c'est lui que le crochet evCommandedByWaterfall() rend à
|
||
// adjustEvChargers() juste après, et c'est ce qui empêche le proxy de re-commander.
|
||
m_waterfallEvIds = plan.waterfallEvIds;
|
||
|
||
// 7 : dispatch matériel (même position que l'amont — m_chargingActions rempli par getPlan())
|
||
applyActionsToAdapters(slot, currentDateTime); // charges arbitrées (Setpoint W, State) ET bornes du waterfall
|
||
adjustEvChargers(currentDateTime); // bornes RESTÉES au proxy : échéance, spot market
|
||
|
||
// Les charges délestées par L4 portent le motif du DÉLESTAGE, pas celui du plan : la
|
||
// charge utile doit dire ce qui a été exécuté, et le plan ne les a pas commandées.
|
||
fusionnerDelestageDansSlot(slot);
|
||
|
||
// 8 : frontière de télémétrie. APRÈS le dispatch, jamais avant : la charge utile de mécanisme
|
||
// (palier appliqué, état PAC) doit refléter ce qui a réellement été envoyé au matériel, pas
|
||
// ce que la stratégie espérait. Publier avant produirait un instantané qu'aucun relais ne
|
||
// confirme — la même famille de défaut qu'annoncer une puissance non appliquée (ECS-410).
|
||
buildTelemetry(plan, slot, currentDateTime);
|
||
publishTelemetry(false);
|
||
}
|
||
|
||
namespace {
|
||
//! Recul en deçà duquel on ne conclut PAS à une nouvelle session : bruit de lecture. Même
|
||
//! principe que le seuil du plugin V2C, transposé en Wh.
|
||
constexpr double BruitAvancementWh = 50.0;
|
||
}
|
||
|
||
EcoProgress EnergyArbitrator::ecoProgress(const LoadContext &lc) const
|
||
{
|
||
EcoProgress p;
|
||
p.targetWh = lc.needs.minEnergyWhPerDay;
|
||
|
||
// Pas d'obligation déclarée : rien à mesurer, et surtout rien à publier. Une charge sans
|
||
// obligation éco n'aura jamais d'entrée « eco » — l'absence EST l'information (R1).
|
||
if (p.targetWh <= 0) {
|
||
m_ecoBaseWh.remove(lc.id);
|
||
return p; // regime vide : l'appelant n'émet pas de niveau éco
|
||
}
|
||
|
||
// C1 de LM-1009 — la borne ne compte pas : l'obligation n'est PAS mesurable, et ça se dit.
|
||
// `deliveredWh` reste absent, jamais zéro : une dégradation ne doit pas se lire comme un
|
||
// avancement nul, sans quoi l'échéance continue de s'afficher comme si elle tenait.
|
||
if (!lc.telemetry.sessionMeasurable) {
|
||
p.regime = QString::fromLatin1(ProgressRegime::Unmeasurable);
|
||
m_ecoBaseWh.remove(lc.id);
|
||
return p;
|
||
}
|
||
|
||
const double sessionWh = lc.telemetry.sessionWh;
|
||
|
||
if (!m_ecoBaseWh.contains(lc.id)) {
|
||
// OUVERTURE de l'obligation : la base se prend ICI. Pas au démarrage de nymead — la
|
||
// reprendre là réinitialiserait l'avancement d'une session en cours sur une valeur
|
||
// déjà accumulée, et une recharge de nuit relue au matin repartirait de zéro.
|
||
m_ecoBaseWh.insert(lc.id, sessionWh);
|
||
} else if (sessionWh < m_ecoBaseWh.value(lc.id) - BruitAvancementWh) {
|
||
// RECUL FRANC de l'accumulateur : le plugin l'a remis à la valeur brute, donc une
|
||
// nouvelle session s'est ouverte. On recapture, sinon la différence devient négative.
|
||
// Ce n'est PAS la détection de remise à zéro de la borne — celle-là vit dans le plugin,
|
||
// à 30 s, là où le moteur travaille à la minute et raterait des cycles entiers.
|
||
qCInfo(dcNymeaEnergy()) << "[Arbitre] Avancement éco" << lc.id
|
||
<< "— l'accumulateur a reculé de"
|
||
<< (m_ecoBaseWh.value(lc.id) - sessionWh)
|
||
<< "Wh : nouvelle session, base reprise à" << sessionWh << "Wh.";
|
||
m_ecoBaseWh.insert(lc.id, sessionWh);
|
||
}
|
||
|
||
p.regime = QString::fromLatin1(ProgressRegime::Measured);
|
||
p.delivered = true;
|
||
// Un recul INFIME laisse la base au-dessus de la valeur lue ; l'écrêtage évite un
|
||
// avancement négatif sans masquer un vrai recul, qui a déjà provoqué la recapture.
|
||
p.deliveredWh = qMax(0.0, sessionWh - m_ecoBaseWh.value(lc.id));
|
||
return p;
|
||
}
|
||
|
||
void EnergyArbitrator::shedWaterfallForOverload(const QDateTime &now)
|
||
{
|
||
m_shedActions.clear();
|
||
const DrawMargin marge = houseDrawMargin();
|
||
if (!marge.borne || marge.watts >= 0)
|
||
return; // aucun plafond en vigueur, ou aucun dépassement : rien à délester
|
||
|
||
double resteW = -marge.watts; // ce qu'il faut RETIRER à la maison, en watts
|
||
|
||
// §6.2 — le franchissement des verrous dépend de QUI impose le plafond, et de rien d'autre.
|
||
// Le disjoncteur ne connaît pas le court-cycling ; un ordre du gestionnaire de réseau ne se
|
||
// négocie pas. Un plafond qu'on s'impose, si : on renonce au plancher éco plutôt qu'au
|
||
// matériel.
|
||
const bool franchitLesVerrous = marge.source == DrawCapSource::Breaker
|
||
|| marge.source == DrawCapSource::GridOperator;
|
||
|
||
// Ordre INVERSE du rang : la dernière servie est la première délestée. C'est le même ordre
|
||
// total que la cascade, lu à l'envers — donc la même règle de départage, pour que délester
|
||
// ne puisse pas contredire l'ordre dans lequel on a servi.
|
||
QList<QString> ids = m_loadAdapters.keys();
|
||
std::sort(ids.begin(), ids.end(), [this](const QString &a, const QString &b) {
|
||
const int ra = m_builtFrom.value(a).priority();
|
||
const int rb = m_builtFrom.value(b).priority();
|
||
if (ra != rb)
|
||
return ra > rb;
|
||
return a > b;
|
||
});
|
||
|
||
for (const QString &id : ids) {
|
||
if (resteW <= 0)
|
||
break;
|
||
|
||
ILoadAdapter *adaptateur = m_loadAdapters.value(id);
|
||
if (!adaptateur)
|
||
continue;
|
||
|
||
const LoadTelemetry t = adaptateur->telemetry();
|
||
// Une charge indisponible ne se déleste pas : on ne sait plus la commander, et
|
||
// prétendre l'avoir coupée ferait croire le dépassement résorbé (ECS-410).
|
||
if (!t.available)
|
||
continue;
|
||
|
||
// La puissance engagée, lue sur un état POSÉ. Aucune écriture n'a encore eu lieu ce
|
||
// cycle, donc l'inflation d'ECS-414 — « tant que des écritures sont en vol, annoncer
|
||
// la plus haute des puissances possibles » — ne s'applique pas ici. C'est la seconde
|
||
// raison d'évaluer L4 avant le dispatch, et non la moindre : ailleurs dans le cycle,
|
||
// ce nombre est délibérément majorant, ce qui est juste pour un budget et faux pour
|
||
// un délestage.
|
||
const double engageW = t.currentPowerW;
|
||
if (engageW <= 0)
|
||
continue; // rien d'engagé : il n'y a rien à retirer
|
||
|
||
LoadAction shed;
|
||
shed.loadId = id;
|
||
|
||
// DÉLESTER N'EST PAS TOUJOURS RETIRER DES WATTS. Une PAC ne se module pas : on la fait
|
||
// redescendre d'ÉTAT, et l'état sûr est 2 (normal), jamais 1 — bloquer une PAC n'est
|
||
// pas la mettre en sécurité, c'est arrêter le chauffage sans raison visible (ECS-413).
|
||
// C'est ici que le verrou minStateHold s'oppose au délestage, et donc ici que le
|
||
// franchissement prend tout son sens : sans lui, une PAC à 3 kW en état 4 tiendrait
|
||
// 900 secondes de plus, pendant que le disjoncteur, lui, ne patiente pas.
|
||
double cibleW = 0;
|
||
if (adaptateur->runtimeView(now).mechanismKind == QStringLiteral("sgReady")) {
|
||
shed.kind = LoadAction::State;
|
||
shed.state = 2; // ECS-413 : l'état sûr d'une PAC est 2, jamais 1
|
||
} else {
|
||
cibleW = qMax(0.0, engageW - resteW);
|
||
shed.kind = LoadAction::Setpoint;
|
||
shed.powerW = cibleW;
|
||
shed.estimatedPowerW = cibleW;
|
||
shed.chargingEnabled = cibleW > 0;
|
||
}
|
||
|
||
// Motif POSÉ AVANT l'application : les adaptateurs refusent une action sans motif, et
|
||
// c'est voulu. Les chiffres réalisés le corrigeront juste après.
|
||
shed.reason = { DecisionCode::DrawCap, drawCapReasonParams(marge, true, 0, false) };
|
||
|
||
// LE FRANCHISSEMENT SE CONSTATE, IL NE SE PRÉSUME PAS. On tente D'ABORD sans forcer :
|
||
// si le verrou laisse passer, il n'y a rien à annoncer. C'est seulement quand il
|
||
// refuse que le forçage a lieu — et alors le marqueur dit une chose vérifiée.
|
||
//
|
||
// Déduire le franchissement d'un verrou simplement ACTIF serait faux : un verrou peut
|
||
// être armé et permettre malgré tout le geste demandé (une descente vers l'état sûr,
|
||
// une réduction déjà dans sa fenêtre). Le marqueur annoncerait alors un court-cycle
|
||
// qui n'a pas eu lieu, et le premier compresseur réellement maltraité passerait
|
||
// inaperçu au milieu des fausses alertes.
|
||
// La CIBLE est-elle atteinte ? La question ne se lit pas au même endroit selon le
|
||
// geste, et s'y tromper fait mentir tout le reste. Une action State REFUSÉE revient
|
||
// avec l'état courant et un `estimatedPowerW` que l'adaptateur ne renseigne pas —
|
||
// le lire donnerait « 0 W, donc cible atteinte », c'est-à-dire l'exact contraire.
|
||
auto cibleAtteinte = [&](const LoadAction &resultat) {
|
||
if (shed.kind == LoadAction::State)
|
||
return resultat.state == shed.state;
|
||
return resultat.estimatedPowerW <= cibleW + 0.5;
|
||
};
|
||
|
||
LoadAction essai = shed;
|
||
essai.force = false;
|
||
LoadAction applique = adaptateur->applyAction(essai, now);
|
||
bool minOnFranchi = false;
|
||
|
||
if (!cibleAtteinte(applique)) {
|
||
// Le verrou a refusé. §6.2 — on ne force que pour un plafond SUBI.
|
||
if (!franchitLesVerrous)
|
||
continue; // plafond auto-imposé : on renonce au plancher, pas au matériel
|
||
shed.force = true;
|
||
applique = adaptateur->applyAction(shed, now);
|
||
minOnFranchi = cibleAtteinte(applique);
|
||
}
|
||
|
||
// Ce qui a RÉELLEMENT été rendu — lu, là encore, à l'endroit qui correspond au geste.
|
||
const double apresW = shed.kind == LoadAction::State
|
||
? adaptateur->toLoadContext(now).declared.estimatedPowerW.value(applique.state, 0.0)
|
||
: applique.estimatedPowerW;
|
||
const double rendu = qMax(0.0, engageW - apresW);
|
||
resteW -= rendu;
|
||
|
||
// Le motif publié porte ce qui a été RÉELLEMENT rendu, pas ce qu'on espérait rendre.
|
||
// Un verrou peut avoir écrêté le geste ; l'annoncer entier ferait chercher ailleurs la
|
||
// cause d'un dépassement qui persiste.
|
||
if (rendu <= 0)
|
||
continue; // rien n'a bougé : ne pas publier un délestage qui n'a pas eu lieu
|
||
|
||
LoadAction publie = applique;
|
||
publie.reason = { DecisionCode::DrawCap,
|
||
drawCapReasonParams(marge, true, rendu, minOnFranchi) };
|
||
|
||
// REVENDICATION : cette charge est commandée par L4 ce cycle, le dispatch la sautera.
|
||
m_shedActions.insert(id, publie);
|
||
|
||
qCInfo(dcNymeaEnergy()) << "[Arbitre] Délestage L4" << id
|
||
<< "→" << renderFr(publie.reason, id)
|
||
<< "| rendu:" << rendu << "W | reste à délester:" << resteW << "W";
|
||
}
|
||
|
||
if (resteW > 0)
|
||
qCWarning(dcNymeaEnergy()) << "[Arbitre] Délestage L4 INCOMPLET —" << resteW
|
||
<< "W restent au-dessus du plafond après avoir délesté tout "
|
||
"ce qui pouvait l'être. La cause est hors du waterfall.";
|
||
}
|
||
|
||
void EnergyArbitrator::fusionnerDelestageDansSlot(Slot &slot) const
|
||
{
|
||
for (auto it = m_shedActions.constBegin(); it != m_shedActions.constEnd(); ++it) {
|
||
bool remplace = false;
|
||
for (int i = 0; i < slot.actions.size(); ++i) {
|
||
if (slot.actions.at(i).loadId == it.key()) {
|
||
slot.actions[i] = it.value();
|
||
remplace = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!remplace)
|
||
slot.actions.append(it.value());
|
||
}
|
||
}
|
||
|
||
bool EnergyArbitrator::evParticipatesInWaterfall(EvCharger *ev) const
|
||
{
|
||
if (!ev)
|
||
return false;
|
||
|
||
const ChargingInfo info = chargingInfo(ev->id());
|
||
|
||
// LM-1208-b — « pas de voiture assignée » N'EST PLUS un critère d'exclusion.
|
||
//
|
||
// Le critère venait du filtre d'entrée du proxy, où il avait un sens : sans voiture, le
|
||
// proxy n'a ni SoC, ni capacité, ni échéance à planifier, et il n'a donc rien à faire de
|
||
// cette borne. Le waterfall, lui, n'a besoin d'aucune des trois pour allouer du surplus —
|
||
// le rang et le plancher appartiennent à la BORNE (LM-1206), pas au véhicule.
|
||
//
|
||
// Trois raisons de le retirer, et la troisième est décisive :
|
||
// · le cas de l'INVITÉ le rend intenable — une voiture qui n'est pas celle du
|
||
// propriétaire ne sera jamais déclarée, et la borne cesserait d'être arbitrée
|
||
// précisément quand elle consomme (3,9 kW mesurés au banc le 2026-08-28) ;
|
||
// · le critère n'est pas VÉRIFIABLE — l'assignation est déclarée à la main, rien ne dit
|
||
// ce qui est réellement branché : un critère d'arbitrage assis sur une déclaration
|
||
// invérifiable n'arbitre pas, il devine ;
|
||
// · il CONTREDIT LM-1206, en réintroduisant la dépendance au véhicule que LM-1206 avait
|
||
// écartée.
|
||
//
|
||
// Ce qu'on perd sans voiture assignée est une FONCTION — l'échéance et la cible d'énergie,
|
||
// qui restent au proxy et que le proxy continue d'ignorer pour cette borne — et non
|
||
// l'arbitrage. Dégradation, pas exclusion : la même distinction que C1 de LM-1009.
|
||
//
|
||
// CONSÉQUENCE À TENIR AILLEURS : le manager ne PRÉPARE pas une borne sans voiture
|
||
// (prepareInformation() la filtre en entrée), donc son ChargingProcessInfo n'existe pas.
|
||
// C'est evTargetPhases() qui porte ce cas — sans quoi une borne triphasée serait annoncée
|
||
// monophasée et le budget divisé par trois.
|
||
|
||
// Mode manuel : l'utilisateur pilote, l'arbitre s'abstient. Hors arbitrage — pas rang 0.
|
||
if (info.chargingMode() == ChargingInfo::ChargingModeNormal)
|
||
return false;
|
||
|
||
// Aucun véhicule branché : la borne ne peut RIEN consommer de ce qu'on lui allouerait.
|
||
//
|
||
// Le proxy n'avait pas besoin de ce test — son allocation n'était pas une cascade, et
|
||
// gaspiller ne privait personne. En cascade, si : au banc le 2026-08-27, une wallbox
|
||
// `pluggedIn: false` s'est vu allouer 4 853 W qu'elle ne tirait pas, et la PAC derrière
|
||
// elle n'a survécu que sur son propre recrédit (docs/RELEVE_3g2.md §3.1).
|
||
//
|
||
// L'asymétrie était d'ailleurs déjà visible dans le code : la boucle EV du scheduler, qui
|
||
// sert les financements RÉSEAU, teste `!ev->pluggedIn()` depuis toujours. Seul le chemin
|
||
// waterfall ne le faisait pas.
|
||
//
|
||
// Hors arbitrage, et non « servie à 0 avec un motif » : c'est la même famille que le cas
|
||
// ci-dessus (DESIGN_3g §4). Une borne débranchée ne consomme rien, sa consommation nulle
|
||
// est déjà au compteur, et lui donner un rang la ferait apparaître dans la cascade pour
|
||
// n'y rien faire. Le journal l'annonce, comme pour l'autre.
|
||
if (!ev->pluggedIn())
|
||
return false;
|
||
|
||
return true;
|
||
}
|
||
|
||
double EnergyArbitrator::evFloorW(EvCharger *ev, uint phaseCount) const
|
||
{
|
||
if (!ev || phaseCount == 0)
|
||
return 0;
|
||
|
||
// max(borne, voiture) — la même règle que m_processInfos.minimalChargingCurrent du proxy.
|
||
// Elle dépend du véhicule branché : c'est une valeur runtime, republiée à chaque cycle.
|
||
double minA = ev->maxChargingCurrentMinValue();
|
||
if (Thing *car = m_tm->findConfiguredThing(chargingInfo(ev->id()).assignedCarId())) {
|
||
const double carMinA = car->stateValue("minChargingCurrent").toDouble();
|
||
if (carMinA > minA)
|
||
minA = carMinA;
|
||
}
|
||
|
||
// 3g-2 — la tolérance d'acquisition N'EST PLUS ICI, et c'est la correction du lot.
|
||
//
|
||
// Ce sont deux grandeurs différentes, confondues en écrivant evFloorW() : le PLANCHER est
|
||
// ce que la borne exige pour démarrer (6 A × 230 V × phases, une donnée de matériel), la
|
||
// TOLÉRANCE est la part de ce plancher qu'on accepte de ne pas couvrir par le surplus (un
|
||
// réglage). Multiplier l'un par l'autre produisait un plancher qui n'existe nulle part :
|
||
// au banc, 2 070 W annoncés pour une borne qui en exige 4 140 — le waterfall lui allouait
|
||
// 2 828 W, l'adaptateur convertissait en 4,1 A, refusait d'enclencher sous les 6 A, et
|
||
// 2 828 W étaient retirés du budget pour personne (relevé du 2026-08-26, §3.4).
|
||
//
|
||
// La tolérance vit maintenant là où elle décide vraiment : au DÉMARRAGE, dans le waterfall,
|
||
// sous le motif EV_GRID_START qui chiffre ce qui est soutiré.
|
||
return minA * 230.0 * phaseCount;
|
||
}
|
||
|
||
double EnergyArbitrator::evReferenceCurrentA(EvCharger *ev) const
|
||
{
|
||
if (!ev)
|
||
return 0;
|
||
double a = ev->currentPower() / 230.0;
|
||
if (RootMeter *meter = internalRootMeter())
|
||
a += -meter->currentPower() / 230.0; // < 0 au compteur = export = surplus
|
||
return a;
|
||
}
|
||
|
||
uint EnergyArbitrator::evTargetPhases(EvCharger *ev, double referenceA)
|
||
{
|
||
if (!ev)
|
||
return 1;
|
||
|
||
// LM-1208-b — une borne SANS VOITURE ASSIGNÉE est arbitrée, mais le manager ne la prépare
|
||
// pas : prepareInformation() la filtre en entrée, donc elle n'a aucun ChargingProcessInfo.
|
||
// internalProcessInfo() rendrait alors un objet PAR DÉFAUT — canSwitchPhaseCount = false,
|
||
// maxPossiblePhaseCount = 1 — c'est-à-dire « monophasée » pour une borne qui peut être
|
||
// triphasée. Le budget serait divisé par trois, et EN SILENCE : le waterfall allouerait
|
||
// 3 680 W en croyant servir 16 A sur une phase, l'adaptateur commanderait 16 A, et le
|
||
// matériel en tirerait 11 040 W. C'est le défaut de 3g-1 pris à l'envers.
|
||
//
|
||
// Sans voiture, la capacité du véhicule est INCONNUE, et une valeur par défaut plausible
|
||
// est le pire des masques (LM-104). Deux conséquences, une seule décision : on ne BASCULE
|
||
// pas — basculer suppose de savoir ce que la voiture accepte, et executeChargingAction()
|
||
// ne commanderait de toute façon aucun desiredPhaseCount sans ChargingProcessInfo — et on
|
||
// retient le nombre de phases de la BORNE, qui est celui qu'elle utilisera réellement.
|
||
// C'est l'hypothèse conservatrice pour le budget : elle SURESTIME la consommation de la
|
||
// borne plutôt que de la sous-estimer.
|
||
// \rule{LM-1208-c}
|
||
if (!internalHasProcessInfo(ev))
|
||
return qMax(1u, ev->phaseCount());
|
||
|
||
// Les bornes de ce qui est POSSIBLE viennent du manager, qui les a établies ce cycle avec
|
||
// la voiture branchée (canSwitchPhaseCount, min/maxPossiblePhaseCount). Les redériver ici
|
||
// en ferait une seconde version — et c'est exactement ce qui a divergé en 3g-1 : viser
|
||
// 3 phases sur une combinaison qui n'en permet qu'une triplait le plancher publié, et sous
|
||
// surcharge commandait 3 × 6 A là où le proxy commandait 1 × 6 A.
|
||
const ChargingProcessInfo pi = internalProcessInfo(ev);
|
||
if (!pi.canSwitchPhaseCount)
|
||
return qMax(1u, pi.maxPossiblePhaseCount);
|
||
|
||
const uint phases = bestPhaseCountFor(ev, referenceA);
|
||
return qMax(1u, qBound(pi.minPossiblePhaseCount, phases, pi.maxPossiblePhaseCount));
|
||
}
|
||
|
||
QList<DrawCap> EnergyArbitrator::drawCaps() const
|
||
{
|
||
QList<DrawCap> plafonds;
|
||
|
||
// 0 = non réglé : la protection de surcharge est désactivée (contrat amont de
|
||
// SetPhasePowerLimit). Rien à écrêter, et surtout rien à annoncer — un plafond publié
|
||
// ici serait une limite inventée.
|
||
const uint limiteA = phasePowerLimit();
|
||
if (limiteA > 0) {
|
||
DrawCap disjoncteur;
|
||
disjoncteur.source = DrawCapSource::Breaker;
|
||
disjoncteur.perPhaseW = limiteA * 230.0;
|
||
plafonds.append(disjoncteur);
|
||
}
|
||
|
||
// GridOperator et EcoSelfImposed : aucun émetteur à ce stade. Leur absence de la liste
|
||
// n'est pas un oubli — c'est l'état du système, et la liste dit lequel des plafonds
|
||
// existe RÉELLEMENT. Un plafond qu'on n'a pas ne doit pas figurer à zéro (LM-1013).
|
||
return plafonds;
|
||
}
|
||
|
||
QList<double> EnergyArbitrator::phaseDrawW(Electricity::Phases masque, uint limiteA) const
|
||
{
|
||
QList<double> tirages;
|
||
RootMeter *meter = internalRootMeter();
|
||
if (!meter || limiteA == 0)
|
||
return tirages;
|
||
|
||
// Masque vide == PhaseNone : le proxy interroge alors LES TROIS phases. Reproduire la
|
||
// convention plutôt que de renvoyer une liste vide, qui ferait passer « toutes les
|
||
// phases » pour « aucune contrainte ».
|
||
const QList<Electricity::Phase> toutes =
|
||
{ Electricity::PhaseA, Electricity::PhaseB, Electricity::PhaseC };
|
||
|
||
for (Electricity::Phase p : toutes) {
|
||
if (masque != Electricity::PhaseNone && !masque.testFlag(p))
|
||
continue;
|
||
|
||
// Tirage de la phase, en watts, obtenu en INVERSANT calculateAllowanceAmpere plutôt
|
||
// qu'en relisant les états du compteur. Relire serait dupliquer des lectures qui
|
||
// diffèrent d'une phase à l'autre côté amont (rootmeter.cpp : la phase A se lit en
|
||
// ampères, B et C en watts/230), et laisser les deux copies dériver. L'inversion rend
|
||
// l'égalité au watt VRAIE PAR CONSTRUCTION, quelles que soient ces lectures.
|
||
const double margeA = meter->calculateAllowanceAmpere(p, limiteA);
|
||
tirages.append((limiteA - margeA) * 230.0);
|
||
}
|
||
return tirages;
|
||
}
|
||
|
||
DrawMargin EnergyArbitrator::houseDrawMargin() const
|
||
{
|
||
DrawMargin sansObjet;
|
||
RootMeter *meter = internalRootMeter();
|
||
if (!meter)
|
||
return sansObjet;
|
||
|
||
const QList<DrawCap> plafonds = drawCaps();
|
||
if (plafonds.isEmpty())
|
||
return sansObjet;
|
||
|
||
// TOUTES les phases, et la plus contrainte l'emporte. C'est le seul choix défendable tant
|
||
// que le modèle ne connaît qu'un NOMBRE de phases par charge et jamais LESQUELLES : une
|
||
// monophasée de phase inconnue peut tomber sur n'importe laquelle, donc sur la pire.
|
||
// Supposer mieux serait deviner, et deviner dans une couche de sécurité se paie au
|
||
// disjoncteur. Le pessimisme ne mord d'ailleurs que là où il doit : une phase qui exporte
|
||
// a une marge PLUS GRANDE que le plafond, et ne borne personne.
|
||
return resolveDrawMargin(plafonds,
|
||
phaseDrawW(Electricity::PhaseNone, phasePowerLimit()),
|
||
meter->currentPower());
|
||
}
|
||
|
||
DrawMargin EnergyArbitrator::evDrawMargin(EvCharger *ev, uint phaseCount) const
|
||
{
|
||
DrawMargin sansObjet;
|
||
if (!ev || phaseCount == 0 || !internalRootMeter())
|
||
return sansObjet;
|
||
|
||
const QList<DrawCap> plafonds = drawCaps();
|
||
if (plafonds.isEmpty())
|
||
return sansObjet;
|
||
|
||
// MÊME masque que le proxy — la marge d'une borne se juge sur les phases qu'elle occupe,
|
||
// pas sur celles de la maison. Une monophasée sur A n'a pas à répondre d'une surcharge
|
||
// sur C, qu'elle ne peut ni causer ni soulager.
|
||
const Electricity::Phases masque =
|
||
const_cast<EnergyArbitrator *>(this)->phasesForCount(phaseCount);
|
||
|
||
return resolveDrawMargin(plafonds,
|
||
phaseDrawW(masque, phasePowerLimit()),
|
||
internalRootMeter()->currentPower());
|
||
}
|
||
|
||
double EnergyArbitrator::evPhaseAllowanceW(EvCharger *ev, uint phaseCount) const
|
||
{
|
||
if (!ev || phaseCount == 0)
|
||
return -1;
|
||
|
||
const DrawMargin marge = evDrawMargin(ev, phaseCount);
|
||
if (!marge.borne)
|
||
return -1;
|
||
|
||
// Place LIBRE offerte à la borne, et surtout SANS lui rendre ce qu'elle tire déjà.
|
||
//
|
||
// Ce dernier point est délibéré et il est de sécurité. Rendre sa propre consommation à une
|
||
// borne serait plus juste en régime établi, mais ferait disparaître la réponse à la
|
||
// surcharge : sur une phase en dépassement, la marge est NÉGATIVE, et c'est ce signe
|
||
// qui ramène la borne à son minimum. Avec le retour, elle se verrait autoriser ce qu'elle
|
||
// consomme déjà — c'est-à-dire précisément la puissance qui cause le dépassement.
|
||
// (`testOverloadProtectionEcoMode` : 16 A ramenés à 6 A ; avec le retour, 9 A maintenus.)
|
||
//
|
||
// L'écrêtage à zéro est fait ICI, chez le consommateur, et non dans la résolution : la
|
||
// marge PORTE le dépassement (elle vaut -1200 quand on dépasse de 1200 W) parce que
|
||
// l'étape 4 en a besoin pour dire de combien délester. Ce qu'une borne peut prendre
|
||
// n'est jamais négatif ; ce qu'il faut retirer à la maison, si.
|
||
return qMax(0.0, marge.watts) * phaseCount;
|
||
}
|
||
|
||
bool EnergyArbitrator::evCommandedByWaterfall(EvCharger *evCharger) const
|
||
{
|
||
return evCharger && m_waterfallEvIds.contains(evCharger->thing()->id().toString());
|
||
}
|
||
|
||
bool EnergyArbitrator::evSurplusPlannedByWaterfall(EvCharger *evCharger) const
|
||
{
|
||
return evParticipatesInWaterfall(evCharger);
|
||
}
|
||
|
||
SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const
|
||
{
|
||
SurplusContext ctx;
|
||
ctx.timestamp = now;
|
||
|
||
// Marge de soutirage du cycle — résolue ICI, une fois, avec le reste des mesures.
|
||
ctx.drawMargin = houseDrawMargin();
|
||
|
||
// --- Compteur principal (AGENTS invariant 8 : mesure brute, aucune déduction) ---
|
||
RootMeter *meter = internalRootMeter();
|
||
if (meter) {
|
||
// currentPower() < 0 → export ; > 0 → import (convention amont SCM l.1141)
|
||
const double p = meter->currentPower();
|
||
ctx.meter.importW = qMax(0.0, p);
|
||
ctx.meter.exportW = qMax(0.0, -p);
|
||
ctx.meter.perPhaseA = {
|
||
meter->currentPhaseA(),
|
||
meter->currentPhaseB(),
|
||
meter->currentPhaseC()
|
||
};
|
||
}
|
||
// SurplusPv : interface inverter — déféré (remplissage prévu en 3d)
|
||
|
||
// --- Stockage (3g-1) ------------------------------------------------------------------
|
||
// Rempli ICI et plus dans le chemin VE. La prise en compte de la batterie n'a jamais été
|
||
// une règle « véhicule » : c'est une règle de BUDGET, et elle vivait dans
|
||
// planSurplusCharging() par accident d'histoire. Tant qu'elle y restait, une batterie sous
|
||
// sa réserve empêchait de charger la voiture mais laissait le chauffe-eau se servir.
|
||
//
|
||
// Toutes les batteries sont agrégées en une seule — même simplification que l'amont : un
|
||
// niveau moyen et une puissance totale. Distinguer deux stockages demanderait de savoir
|
||
// lequel arbitrer en premier, et rien dans le modèle ne le dit.
|
||
{
|
||
int n = 0;
|
||
double niveauTotal = 0, puissanceTotale = 0;
|
||
for (Thing *storage : m_tm->configuredThings().filterByInterface("energystorage")) {
|
||
++n;
|
||
niveauTotal += storage->stateValue("batteryLevel").toDouble();
|
||
puissanceTotale += storage->stateValue("currentPower").toDouble();
|
||
}
|
||
if (n > 0) {
|
||
ctx.battery.present = true;
|
||
ctx.battery.socPercent = niveauTotal / n;
|
||
ctx.battery.powerW = puissanceTotale; // > 0 charge, < 0 décharge
|
||
} else {
|
||
// Un filtre par interface qui ne trouve rien est TOUJOURS ambigu : « il n'y a pas
|
||
// de batterie » et « il y en a une, mal déclarée » se ressemblent, et la seconde
|
||
// désactive une règle de sécurité EN SILENCE. C'est le même mode de défaillance
|
||
// qu'un seuil de réserve mal réglé : rien ne se signale.
|
||
//
|
||
// Constaté sur le banc le 2026-08-26 : le Thing « SunSpec Storage » déclare
|
||
// `battery` mais pas `energystorage`, donc la réserve ne s'est jamais appliquée —
|
||
// ni ici, ni dans le proxy amont qui utilisait le même filtre, ni dans le bilan de
|
||
// puissance de l'experience-plugin qui filtre pareil.
|
||
const Things candidates = m_tm->configuredThings().filterByInterface("battery");
|
||
if (!candidates.isEmpty()) {
|
||
QStringList noms;
|
||
for (Thing *t : candidates)
|
||
noms << t->name();
|
||
qCWarning(dcNymeaEnergy())
|
||
<< "[Arbitre] Réserve de stockage INACTIVE :" << candidates.count()
|
||
<< "appareil(s) portant l'interface 'battery' mais PAS 'energystorage' —"
|
||
<< noms.join(", ") << ". Le waterfall ne peut pas les prendre en compte, et"
|
||
<< "le bilan de puissance de nymea non plus. À corriger dans le plugin qui"
|
||
<< "déclare la classe, pas ici.";
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- loads[] : bornes VE (3g-1) ------------------------------------------------------
|
||
// Elles entrent désormais dans le MÊME contexte que les autres charges — c'est toute la
|
||
// transplantation. DEUX exclusions seulement, et elles ne disent pas la même chose : le
|
||
// mode manuel est une INTENTION (l'utilisateur pilote, l'arbitre s'abstient), l'absence de
|
||
// véhicule branché un CONSTAT (la borne ne peut rien tirer de ce qu'on lui allouerait).
|
||
// « Pas de voiture assignée » n'en est plus une — LM-1208-b, cf. evParticipatesInWaterfall().
|
||
//
|
||
// Une borne exclue n'est pas pour autant sortie de la COMPTABILITÉ (LM-1208) : sa
|
||
// consommation est déjà dans le compteur racine, donc déjà retranchée du budget ; lui
|
||
// allouer quoi que ce soit la compterait deux fois.
|
||
for (auto it = m_adapters.constBegin(); it != m_adapters.constEnd(); ++it) {
|
||
EvCharger *ev = internalEvChargers().value(ThingId(it.key()));
|
||
if (!evParticipatesInWaterfall(ev)) {
|
||
qCDebug(dcNymeaEnergy()) << "[Arbitre] Borne" << (ev ? ev->name() : it.key())
|
||
<< "hors waterfall (mode manuel ou aucun véhicule "
|
||
"branché).";
|
||
continue;
|
||
}
|
||
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.
|
||
// Délestée par L4 ce cycle : un seul commandeur par organe. Réappliquer la consigne
|
||
// du plan rallumerait ce que la sécurité vient de couper, et c'est la règle absolue 1.
|
||
if (m_shedActions.contains(action.loadId))
|
||
continue;
|
||
|
||
if (action.kind == LoadAction::State || action.kind == LoadAction::Setpoint) {
|
||
if (ILoadAdapter *adapter = m_loadAdapters.value(action.loadId, nullptr)) {
|
||
adapter->applyAction(action, now);
|
||
} else if (m_waterfallEvIds.contains(action.loadId)) {
|
||
if (EvAdapter *ev = m_adapters.value(action.loadId, nullptr)) {
|
||
ev->applyAction(action, now);
|
||
// L'ÉTAT de charge publié dans ChargingInfo était posé par
|
||
// adjustEvChargers(), que ces bornes ne traversent plus. Sans cette
|
||
// reprise, chargingState resterait figé sur sa dernière valeur : l'app
|
||
// afficherait « en charge » une borne que l'arbitre vient d'éteindre.
|
||
setEvChargingState(ThingId(action.loadId),
|
||
action.chargingEnabled
|
||
? ChargingInfo::ChargingStateSurplusCharging
|
||
: ChargingInfo::ChargingStateIdle);
|
||
} else if (action.reason.code != QLatin1String(DecisionCode::LoadUnavailable)) {
|
||
// Le waterfall a nommé une borne dont l'adaptateur a disparu ENTRE la
|
||
// décision et le dispatch (Thing retiré en cours de cycle). Rien n'est
|
||
// commandé, et le silence serait ambigu : cette borne est aussi sortie
|
||
// du proxy par le crochet.
|
||
//
|
||
// Le cas d'une borne configurée dont le Thing manque depuis le début est
|
||
// EXCLU de cet avertissement : l'arbitre l'a vu et l'a dit — c'est le
|
||
// motif LOAD_UNAVAILABLE, publié à chaque cycle. Répéter l'information en
|
||
// qCWarning noierait le vrai signal, qui est la disparition INATTENDUE.
|
||
qCWarning(dcNymeaEnergy()) << "[Arbitre] borne" << action.loadId
|
||
<< "commandée par le waterfall mais son adaptateur "
|
||
"a disparu — aucune consigne émise ce cycle.";
|
||
}
|
||
} else if (action.kind == LoadAction::State) {
|
||
qCWarning(dcNymeaEnergy()) << "[Arbitre] action State sans adaptateur:" << action.loadId;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
void EnergyArbitrator::onMeterWatchdogTick()
|
||
{
|
||
// Déclencheur réel (QTimer, horloge murale) → délègue à la logique injectable.
|
||
evaluateMeterFreshness(QDateTime::currentDateTime());
|
||
}
|
||
|
||
void EnergyArbitrator::recordMeterUpdate(const QDateTime &now)
|
||
{
|
||
m_lastMeterUpdate = now;
|
||
if (m_degradedMode) {
|
||
qCInfo(dcNymeaEnergy()) << "[Arbitre] Compteur de nouveau actif — sortie du mode dégradé L2.";
|
||
m_degradedMode = false;
|
||
emit chargingSchedulesChanged(); // pousse degradedMode=false (planif reprend au cycle suivant)
|
||
|
||
// Symétrie de la bascule : la SORTIE se publie aussi tout de suite. La planification ne
|
||
// reprend qu'au cycle suivant ; sans cette republication, la télémétrie annoncerait
|
||
// encore degradedMode=true pendant une minute entière, après que le repli a été levé.
|
||
if (!m_telemetry.isEmpty()) {
|
||
m_telemetry.insert(QStringLiteral("degradedMode"), false);
|
||
publishTelemetry(true);
|
||
}
|
||
}
|
||
}
|
||
|
||
void EnergyArbitrator::evaluateMeterFreshness(const QDateTime &now)
|
||
{
|
||
if (!m_lastMeterUpdate.isValid())
|
||
return; // Aucune mesure reçue (démarrage) — pas de dégradé (invariant root meter absent).
|
||
|
||
const qint64 silentS = m_lastMeterUpdate.secsTo(now);
|
||
if (silentS <= MeterSilenceThresholdS)
|
||
return;
|
||
|
||
if (m_degradedMode)
|
||
return; // Déjà en repli — les consignes tiennent, pas de ré-émission (anti-oscillation).
|
||
|
||
qCWarning(dcNymeaEnergy()) << "[Arbitre] Compteur muet depuis" << silentS
|
||
<< "s (>" << MeterSilenceThresholdS << "s) — mode dégradé L2.";
|
||
applyDegradedMode(now);
|
||
}
|
||
|
||
void EnergyArbitrator::applyDegradedMode(const QDateTime &now)
|
||
{
|
||
m_degradedMode = true;
|
||
emit chargingSchedulesChanged(); // pousse degradedMode=true (notification client L2)
|
||
const DecisionReason reason = { DecisionCode::DegradedL2 };
|
||
|
||
// Repli L2 de TOUTES les charges arbitrées, chacune dans SON état sûr — et c'est le kind
|
||
// DÉCLARÉ par l'adaptateur qui décide lequel, jamais une supposition sur sa classe.
|
||
//
|
||
// Ce point a été un TROU DE SÉCURITÉ, ouvert par LM-300 et refermé ici. Le repli envoyait
|
||
// un Setpoint(0) à toute la table des charges pilotées et un State(2) à une table SÉPARÉE
|
||
// réservée à la PAC codée en dur. Or une PAC construite depuis la CONFIG vit dans la
|
||
// première table : elle recevait donc un Setpoint, que SgReadyAdapter::applyAction()
|
||
// rejette d'entrée (kind non supporté) — et rien d'autre. Sous compteur muet, cette PAC-là
|
||
// restait en état 4 (forcé) indéfiniment, alors que SAFETY.md §L2 exige l'état 2. Le défaut
|
||
// était invisible : le motif DEGRADED_L2 apparaissait bien dans la télémétrie, seule
|
||
// l'écriture manquait.
|
||
//
|
||
// La leçon tient en une ligne : dès qu'un mécanisme rejoint la table commune, tout code qui
|
||
// fabrique une action DOIT lire \c supportedKinds au lieu de présumer du kind.
|
||
// \rule{LM-301-b} — c'est la limite de LM-301 : « une charge utile, une branche de
|
||
// fabrique » vaut pour CONSTRUIRE un mécanisme, pas pour l'ALIMENTER en actions.
|
||
for (ILoadAdapter *adapter : m_loadAdapters) {
|
||
const LoadDescriptor d = adapter->descriptor();
|
||
LoadAction la;
|
||
la.loadId = d.id;
|
||
la.force = true;
|
||
la.reason = reason;
|
||
if (d.supportedKinds.contains(LoadAction::State)) {
|
||
// PAC SG-Ready : état 2 (NORMAL — mains off), JAMAIS état 1 (blocage). Sous
|
||
// compteur muet on cesse de piloter : la PAC chauffe selon son propre thermostat
|
||
// (la bloquer = maison qui ne chauffe plus sans raison visible, cf. SAFETY.md).
|
||
la.kind = LoadAction::State;
|
||
la.state = 2;
|
||
} else if (d.supportedKinds.contains(LoadAction::Setpoint)) {
|
||
// Charges en watts : relay-router → tous relais ouverts ; etmvariableload → 0 W.
|
||
la.kind = LoadAction::Setpoint;
|
||
la.powerW = 0;
|
||
} else {
|
||
qCWarning(dcNymeaEnergy()) << "[Arbitre] Repli L2 : la charge" << d.id
|
||
<< "ne déclare aucun kind connu — AUCUN repli appliqué.";
|
||
continue;
|
||
}
|
||
adapter->applyAction(la, now);
|
||
}
|
||
|
||
// EV : repli CONSERVATEUR — n'initie aucune charge. On clampe seulement une charge
|
||
// DÉJÀ en cours au courant minimum (force=true, bypass lock). Une borne branchée mais
|
||
// non chargeante reste off (off volontaire possible : HC/spot à venir) ; débranchée →
|
||
// aucune action. La garantie "jamais 0 A si branché" relève du failsafe L1 de la borne.
|
||
for (auto it = internalEvChargers().constBegin(); it != internalEvChargers().constEnd(); ++it) {
|
||
EvCharger *ev = it.value();
|
||
if (ev->available() && ev->charging())
|
||
ev->setMaxChargingCurrent(ev->maxChargingCurrentMinValue(), now, true);
|
||
}
|
||
|
||
// Batterie (aucune charge réseau) : repli ajouté avec son adaptateur (3f).
|
||
|
||
// Reprise à froid (critère ECS-411) : un client qui se connecte APRÈS la bascule doit lire
|
||
// degradedMode. update() rend la main avant toute publication tant que le repli tient ; sans
|
||
// cet instantané forcé, la frontière de télémétrie resterait figée sur l'avant-dernier cycle
|
||
// NORMAL, et publierait donc l'inverse de la vérité.
|
||
buildDegradedTelemetry(now);
|
||
publishTelemetry(true);
|
||
}
|
||
|
||
// ---- Frontière de télémétrie (NymeaEnergy.GetLoadTelemetry / LoadTelemetryChanged) ---------
|
||
|
||
void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const QDateTime &now)
|
||
{
|
||
QVariantMap snap;
|
||
// ISO 8601 UTC — auto-descriptif et sans ambiguïté de fuseau. C'est la FIN du cycle
|
||
// d'arbitrage : deux notifications au même timestamp disent « rien n'a été décidé entre-temps ».
|
||
snap.insert(QStringLiteral("timestamp"), now.toUTC().toString(Qt::ISODate));
|
||
snap.insert(QStringLiteral("degradedMode"), m_degradedMode);
|
||
|
||
QVariantMap budget;
|
||
budget.insert(QStringLiteral("surplusW"), plan.budget.surplusW);
|
||
budget.insert(QStringLiteral("evReservedW"), plan.budget.evReservedW);
|
||
budget.insert(QStringLiteral("allocatedW"), plan.budget.allocatedW);
|
||
budget.insert(QStringLiteral("recreditedW"), plan.budget.recreditedW);
|
||
budget.insert(QStringLiteral("remainingW"), plan.budget.remainingW);
|
||
snap.insert(QStringLiteral("budget"), budget);
|
||
|
||
// R3 — LE SOUTIRAGE, dans la MÊME TRAME que le budget. Les deux décrivent le même cycle ;
|
||
// les publier séparément ferait comparer deux instants, et l'identité de réconciliation
|
||
// redeviendrait temporelle par un autre chemin — ce que R3 existe pour empêcher.
|
||
//
|
||
// `committedW` est publié TOUJOURS : le registre des watts achetés existe à chaque cycle,
|
||
// et zéro y est une valeur. Les quatre autres ne sont publiés QUE si un plafond est en
|
||
// vigueur — leur absence dit « aucun plafond n'existe », là où un `authorisedW: 0` assorti
|
||
// d'un `binding` par défaut afficherait un plafond de soutirage inexistant, avec sa source.
|
||
// Le zéro est une valeur quand la grandeur existe ; l'absence en est une quand elle n'existe
|
||
// pas, et c'est la même règle des deux côtés.
|
||
//
|
||
// §10 étape B — ces champs ne sont plus un pari sur le §14a : le plafond de branchement est
|
||
// résolu à chaque cycle depuis `phasePowerLimit`, donc ils se LISENT. Le refus de les
|
||
// publier tenait à leur inexistence ; l'inexistence a cessé.
|
||
QVariantMap draw;
|
||
draw.insert(QStringLiteral("committedW"), plan.draw.committedW);
|
||
if (plan.draw.capBorne) {
|
||
draw.insert(QStringLiteral("authorisedW"), plan.draw.authorisedW);
|
||
draw.insert(QStringLiteral("remainingW"), plan.draw.remainingW);
|
||
draw.insert(QStringLiteral("binding"), plan.draw.bindingCode);
|
||
draw.insert(QStringLiteral("perPhaseBound"), plan.draw.perPhaseBound);
|
||
}
|
||
snap.insert(QStringLiteral("draw"), draw);
|
||
|
||
// On itère les ACTIONS, pas les tables d'adaptateurs : l'ordre du waterfall (rang 1 servi en
|
||
// premier) est porté par le slot, et QHash n'en garantit aucun. Un ordre instable ferait
|
||
// aussi diverger la comparaison de seuil d'un cycle à l'autre, pour rien.
|
||
QVariantList loads;
|
||
QSet<QString> couvertes;
|
||
for (const LoadAction &action : slot.actions) {
|
||
// 3g-1 — LES BORNES SONT DANS loads[]. Elles étaient exclues ici tant que l'EV était
|
||
// décidé en amont du waterfall ; depuis qu'il y entre, les omettre laisserait la somme
|
||
// des allocations irréconciliable avec budget.allocatedW et priverait le client du
|
||
// motif — c'est-à-dire de la SEULE chose qui distingue « l'arbitre a refusé » de « le
|
||
// matériel n'a pas suivi ». Attrapé au premier passage machine.
|
||
ILoadAdapter *adapter = m_loadAdapters.value(action.loadId, nullptr);
|
||
if (!adapter)
|
||
adapter = m_adapters.value(action.loadId, nullptr);
|
||
// 3g-2 — une borne CONFIGURÉE dont l'appareil manque n'a pas d'adaptateur, et doit
|
||
// pourtant être publiée : sans elle, une charge que GetLoadConfig annonce serait
|
||
// absente de GetLoadTelemetry, et l'app afficherait un vide sans cause. C'est le
|
||
// même invariant, pris par l'autre bout.
|
||
if (!adapter) {
|
||
if (!estUneBorneConfiguree(action.loadId))
|
||
continue;
|
||
couvertes.insert(action.loadId);
|
||
QVariantMap orphan;
|
||
orphan.insert(QStringLiteral("loadId"), action.loadId);
|
||
orphan.insert(QStringLiteral("allocatedW"), 0);
|
||
// R2 — le financement vit dans levels[], plus sur la charge. Une charge dont
|
||
// l'appareil manque n'a rien décidé : un seul niveau, à 0 W.
|
||
orphan.insert(QStringLiteral("levels"), QVariantList()
|
||
<< QVariantMap{{QStringLiteral("level"), QStringLiteral("comfort")},
|
||
{QStringLiteral("targetW"), 0.0},
|
||
{QStringLiteral("funding"), QStringLiteral("surplus")},
|
||
{QStringLiteral("counts"),
|
||
QVariantMap{{QString::fromLatin1(CountKey::BudgetAllocated), 0.0}}}});
|
||
orphan.insert(QStringLiteral("available"), false);
|
||
// R7 — decision.level ACCOMPAGNE levels[] : les deux présents, ou les deux
|
||
// absents. Un motif sans niveau à côté d'un tableau de niveaux forcerait le client
|
||
// à deviner auquel il se rapporte.
|
||
QVariantMap decisionOrphan = action.reason.toMap();
|
||
decisionOrphan.insert(QStringLiteral("level"), QStringLiteral("comfort"));
|
||
orphan.insert(QStringLiteral("decision"), decisionOrphan);
|
||
orphan.insert(QStringLiteral("faultCode"), QStringLiteral("THING_MISSING"));
|
||
QVariantMap mech;
|
||
mech.insert(QStringLiteral("kind"), QStringLiteral("evcharger"));
|
||
orphan.insert(QStringLiteral("mechanism"), mech);
|
||
attachMeasurements(orphan, action.loadId);
|
||
loads.append(orphan);
|
||
continue;
|
||
}
|
||
couvertes.insert(action.loadId);
|
||
|
||
QVariantMap entry;
|
||
entry.insert(QStringLiteral("loadId"), action.loadId);
|
||
// ALLOCATION du waterfall, celle qui a décrémenté le budget — pas la mesure. La mesure
|
||
// vit dans la charge utile de mécanisme ; les confondre rendrait la somme des
|
||
// allocations irréconciliable avec budget.allocatedW.
|
||
entry.insert(QStringLiteral("allocatedW"), action.estimatedPowerW);
|
||
// --- R2 : LE FINANCEMENT DESCEND AU NIVEAU -------------------------------------
|
||
//
|
||
// Il était par CHARGE. Au §10 la même charge sera financée au réseau pour son plancher
|
||
// éco et au surplus pour son confort DANS LE MÊME CYCLE : un financement par charge y
|
||
// devient indécidable, et c'est lui qui porte l'identité de réconciliation de l'app
|
||
// (« somme des parts financées au surplus == budget.allocatedW »). Laissé à la charge,
|
||
// cette identité cesserait d'être vérifiable sans que rien ne le signale.
|
||
//
|
||
// Déplacé MAINTENANT bien que le §10 ne soit pas écrit : c'est la seule des huit
|
||
// exigences de la maquette qui soit un changement de FORME d'un champ existant, donc la
|
||
// seule qui se renchérisse à chaque écran qui s'y appuie.
|
||
//
|
||
// Aujourd'hui UN SEUL niveau, et c'est « comfort » : la passe unique d'aujourd'hui est
|
||
// celle qui, au §10, servira le confort depuis le surplus. La passe éco est la
|
||
// nouveauté du lot, pas la passe existante rebaptisée. Une charge sans obligation éco
|
||
// n'aura jamais d'entrée « eco » — l'absence est l'information (R1).
|
||
//
|
||
// « funding » n'est PAS conservé en résumé sur la charge : voir INTERFACE.md. Le cas
|
||
// mixte est justement celui qui le rend indécidable, et deux endroits pour un fait
|
||
// divergent. Le motif « decision », lui, RESTE — parce qu'on saura dire de quel niveau
|
||
// il parle (R7), là où un financement de charge ne pourrait rien dire de vrai.
|
||
QVariantMap niveau;
|
||
niveau.insert(QStringLiteral("level"), QStringLiteral("comfort"));
|
||
// R4 — le niveau publie ce qu'il a DÉCIDÉ. C'est la même valeur qu'allocatedW tant
|
||
// qu'il n'y a qu'un niveau ; elle s'en séparera quand il y en aura deux.
|
||
niveau.insert(QStringLiteral("targetW"), action.estimatedPowerW);
|
||
niveau.insert(QStringLiteral("funding"),
|
||
action.funding == LoadAction::Grid ? QStringLiteral("grid")
|
||
: QStringLiteral("surplus"));
|
||
// R3 — LA VENTILATION : dans quel compteur chaque watt tombe. Distincte de « funding »,
|
||
// qui dit d'où ils VIENNENT — les deux ne se dérivent pas l'un de l'autre, et sur un
|
||
// EV_GRID_START l'un vaut « grid » quand l'autre porte deux destinations.
|
||
//
|
||
// Publiée TOUJOURS, même à une seule destination et même à zéro : un « counts » vide
|
||
// satisferait l'identité en perdant la destination, sur le cas le plus fréquent. Un
|
||
// zéro AVEC sa destination dit « cette grandeur existe et vaut 0 » ; l'absence dirait
|
||
// « elle n'existe pas », ce qui est une autre affirmation.
|
||
QVariantMap counts;
|
||
for (auto it = action.counts.constBegin(); it != action.counts.constEnd(); ++it)
|
||
counts.insert(it.key(), it.value());
|
||
niveau.insert(QStringLiteral("counts"), counts);
|
||
|
||
// R5 — LE RÉGIME D'AVANCEMENT, porté par le NIVEAU et non par la charge. Publié dès
|
||
// maintenant, alors qu'aucune décision ne s'y appuie encore : c'est délibéré, et c'est
|
||
// ce qui rendra R6 (`ECO_FLOOR_MET` interdit sous `unmeasurable`) testable le jour où le
|
||
// motif naîtra. On sépare PUBLIER LE RÉGIME de DÉCIDER AVEC, exactement comme
|
||
// `measurement` a été séparé de l'arbitrage.
|
||
//
|
||
// Absent quand aucune obligation n'est déclarée : une charge sans obligation éco ne
|
||
// porte pas un avancement à zéro, elle n'en porte pas (R1).
|
||
const EcoProgress avancement = ecoProgress(adapter->toLoadContext(now));
|
||
if (!avancement.regime.isEmpty()) {
|
||
QVariantMap progress;
|
||
progress.insert(QStringLiteral("regime"), avancement.regime);
|
||
progress.insert(QStringLiteral("targetWh"), avancement.targetWh);
|
||
// `deliveredWh` ABSENT sous `unmeasurable`, jamais 0 : une dégradation ne doit pas
|
||
// se lire comme un avancement nul (LM-1009 C1). C'est la moitié qui compte.
|
||
if (avancement.delivered)
|
||
progress.insert(QStringLiteral("deliveredWh"), avancement.deliveredWh);
|
||
niveau.insert(QStringLiteral("progress"), progress);
|
||
}
|
||
entry.insert(QStringLiteral("levels"), QVariantList() << niveau);
|
||
entry.insert(QStringLiteral("available"), adapter->telemetry().available);
|
||
// R7 — LE MOTIF HÉRITÉ DIT DE QUEL NIVEAU IL PARLE. `decision` reste au niveau de la
|
||
// charge — un client qui l'a toujours lu continue de le lire — mais il reflète le motif
|
||
// d'UN niveau, et sans le dire chaque client réinventerait sa règle de fusion : « le
|
||
// motif du dernier niveau servi ? du premier refusé ? ». Deux écrans afficheraient deux
|
||
// motifs pour le même cycle.
|
||
//
|
||
// Aujourd'hui il n'y a qu'un niveau et la question ne se pose pas — c'est précisément
|
||
// pourquoi le champ s'ajoute MAINTENANT : quand elle se posera, les clients le liront
|
||
// déjà. Voir R2, même raisonnement sur le coût de déplacement.
|
||
QVariantMap decision = action.reason.toMap();
|
||
decision.insert(QStringLiteral("level"), niveau.value(QStringLiteral("level")));
|
||
entry.insert(QStringLiteral("decision"), decision);
|
||
|
||
const LoadRuntimeView v = adapter->runtimeView(now);
|
||
if (!v.faultCode.isEmpty())
|
||
entry.insert(QStringLiteral("faultCode"), v.faultCode);
|
||
if (!v.lockKind.isEmpty()) {
|
||
QVariantMap lock;
|
||
lock.insert(QStringLiteral("kind"), v.lockKind);
|
||
lock.insert(QStringLiteral("remainingS"), v.lockRemainingS);
|
||
entry.insert(QStringLiteral("lock"), lock);
|
||
}
|
||
if (!v.mechanismKind.isEmpty()) {
|
||
QVariantMap mech = v.mechanism;
|
||
mech.insert(QStringLiteral("kind"), v.mechanismKind);
|
||
entry.insert(QStringLiteral("mechanism"), mech);
|
||
}
|
||
attachMeasurements(entry, action.loadId);
|
||
loads.append(entry);
|
||
}
|
||
|
||
// Règle 7-c — une charge arbitrée sans décision serait un TROU dans la charge utile, et le
|
||
// client ne pourrait pas distinguer « charge absente » de « charge oubliée du plan ». Cela ne
|
||
// devrait pas arriver (toute charge du contexte reçoit une action) : on l'annonce donc au
|
||
// journal plutôt que de le laisser passer sans bruit.
|
||
//
|
||
// Les BORNES sont volontairement hors de ce contrôle : une borne sans voiture assignée ou
|
||
// en mode manuel est HORS arbitrage (DESIGN_3g §4), et ne recevoir aucune action y est le
|
||
// comportement correct — pas un trou. Avertir à chaque cycle sur une box dont la borne est
|
||
// simplement débranchée noierait le vrai signal.
|
||
for (auto it = m_loadAdapters.constBegin(); it != m_loadAdapters.constEnd(); ++it)
|
||
if (!couvertes.contains(it.key()))
|
||
qCWarning(dcNymeaEnergy()) << "[Arbitre] Télémétrie : charge" << it.key()
|
||
<< "arbitrée mais SANS décision dans le plan — omise.";
|
||
|
||
snap.insert(QStringLiteral("loads"), loads);
|
||
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;
|
||
}
|