// 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/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 } 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(); #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::registerSgReadyAdapter(SgReadyAdapter *adapter) { const QString id = adapter->descriptor().id; if (m_sgReadyAdapters.contains(id)) { qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] SgReadyAdapter déjà enregistré:" << id; return; } adapter->setParent(this); m_sgReadyAdapters[id] = adapter; qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] SgReadyAdapter enregistré:" << adapter->descriptor().label; } 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; } const QList ra = a.relaysList(); const QList rb = b.relaysList(); if (ra.size() != rb.size()) return false; for (int i = 0; i < ra.size(); ++i) if (ra.at(i).thingId != rb.at(i).thingId || ra.at(i).powerW != rb.at(i).powerW) return false; return true; } bool EnergyArbitrator::clearLoadFault(const QString &loadId) { ILoadAdapter *adapter = m_loadAdapters.value(loadId, nullptr); if (!adapter) { qCWarning(dcNymeaEnergy()) << "[Arbitre] ClearLoadFault : charge inconnue" << loadId; return false; } qCInfo(dcNymeaEnergy()) << "[Arbitre] ClearLoadFault demandé par l'opérateur pour" << loadId; adapter->clearFault(); 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()]; const bool soft = (old.priority() != c.priority()) || (old.needs().dailyDeadline() != c.needs().dailyDeadline()) || (old.needs().minEnergyWhPerDay() != c.needs().minEnergyWhPerDay()); if (soft) { existing->updateSoftConfig(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.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; } // LM-300 — la configuration fait autorité sur l'enregistrement en dur. Depuis que le // mécanisme sg-ready se configure, une PAC de config peut viser les MÊMES contacts que la // PAC de banc câblée dans energypluginnymea.cpp. Deux adaptateurs sur un même contact, // c'est deux commandes contradictoires ET une charge comptée deux fois dans le budget // (règle absolue 1). On retire donc l'exemplaire en dur, sans le mettre en état sûr : // les contacts ont un nouveau propriétaire légitime, les couper serait le contredire. QSet revendiquesParConfig; for (ILoadAdapter *a : kept) { if (auto *sg = dynamic_cast(a)) revendiquesParConfig.unite(sg->claimedRelays()); } if (!revendiquesParConfig.isEmpty()) { for (const QString &id : m_sgReadyAdapters.keys()) { SgReadyAdapter *dur = m_sgReadyAdapters.value(id); if (!dur || (dur->claimedRelays() & revendiquesParConfig).isEmpty()) continue; qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] SgReadyAdapter en dur" << id << "retiré : ses contacts sont désormais commandés par une " "charge sg-ready de configuration."; m_sgReadyAdapters.remove(id); dur->deleteLater(); } } // 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); for (const LoadAction &action : slot.actions) { qCInfo(dcNymeaEnergy()) << "[Arbitre]" << action.loadId << "→" << action.reason << "| 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); // PAC (kind==State) → m_sgReadyAdapters adjustEvChargers(currentDateTime); // EV (kind==Setpoint) → proxy amont jusqu'à 3g } 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) // SurplusBattery : déféré 3f // --- loads[] : EV adapters --- (now = ctx.timestamp : source unique des verrous) for (auto it = m_adapters.constBegin(); it != m_adapters.constEnd(); ++it) ctx.loads.append(it.value()->toLoadContext(now)); // --- loads[] : charges pilotées en watts (relay-router + etmvariableload), polymorphes --- for (auto it = m_loadAdapters.constBegin(); it != m_loadAdapters.constEnd(); ++it) ctx.loads.append(it.value()->toLoadContext(now)); // --- loads[] : SG-Ready adapters (PAC) --- for (auto it = m_sgReadyAdapters.constBegin(); it != m_sgReadyAdapters.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). if (action.kind == LoadAction::State) { // Routage par loadId d'abord : un SG-Ready construit depuis la config (LM-300) vit // dans la MÊME table que les autres charges — c'est ce qui lui vaut ECS-412 // (reconstruction incrémentale) et ECS-413 (état sûr) sans code dédié. if (ILoadAdapter *adapter = m_loadAdapters.value(action.loadId, nullptr)) { adapter->applyAction(action, now); continue; } // Repli : PAC enregistrée en dur par registerSgReadyAdapter() (hors config). SgReadyAdapter *adapter = m_sgReadyAdapters.value(action.loadId); if (adapter) adapter->applyAction(action, now); else qCWarning(dcNymeaEnergy()) << "[Arbitre] action State sans adaptateur SG-Ready:" << action.loadId; } else if (action.kind == LoadAction::Setpoint) { // Dispatch AGNOSTIQUE au type : routage par loadId, le polymorphisme ILoadAdapter // absorbe (relay-router OU etmvariableload). Un Setpoint EV n'est PAS dans cette table // → ignoré ici (dispatché par adjustEvChargers() amont). ILoadAdapter *adapter = m_loadAdapters.value(action.loadId); if (adapter) adapter->applyAction(action, now); } } } 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) } } 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 QString reason = QStringLiteral("Compteur muet depuis >90 s — consigne de repli (L2 watchdog)"); // Charges pilotées en watts (relay-router ET etmvariableload) : repli Setpoint(0) force=true // (contrat rév. 3 §9). Boucle sur TOUS les ILoadAdapter — ne PAS oublier un type, sinon des // relais ECS resteraient allumés en compteur muet (trou T2 recréé). force=true → pour le // relay-router, coupe TOUS les relais (bypass minOn/minOff) ; pour etmvariableload, setpoint 0. for (ILoadAdapter *adapter : m_loadAdapters) { LoadAction la; la.loadId = adapter->descriptor().id; la.kind = LoadAction::Setpoint; la.powerW = 0; la.force = true; la.reason = reason; 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); } // SG-Ready (PAC) : repli en é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). force=true → bypass minStateHold. for (SgReadyAdapter *adapter : m_sgReadyAdapters) { LoadAction la; la.loadId = adapter->descriptor().id; la.kind = LoadAction::State; la.state = 2; la.force = true; la.reason = reason; adapter->applyAction(la, now); } // Batterie (aucune charge réseau) : repli ajouté avec son adaptateur (3f). }