// 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 #include 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 &EnergyArbitrator::scheduledActions() const { return internalChargingActions(); } void EnergyArbitrator::doExecuteChargingAction(EvCharger *charger, const ChargingAction &action, const QDateTime &now) { executeChargingAction(charger, action, now); } const QHash &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(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 kept; QHash 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> stateRelays; QHash 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(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 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 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; }