// 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(); // L'historique des prévisions PV. Créé ici parce que sa DURÉE DE VIE est celle de l'arbitre // — pas parce qu'il décide : il n'entre dans aucun plan, ne pondère aucune échéance. // La purge tourne une fois par jour ; une table qui ne dit pas quand elle purge finit par // le décider toute seule un jour de disque plein. m_forecastStore = new ForecastStore(ForecastStore::cheminParDefaut(), this); if (m_forecastStore->isValid()) { m_forecastPurge = new QTimer(this); m_forecastPurge->setInterval(24 * 3600 * 1000); connect(m_forecastPurge, &QTimer::timeout, this, [this]() { m_forecastStore->purger(); }); m_forecastPurge->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; } // \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(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; // 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> 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."; // 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); // Les dérogations vivent ENTRE la construction et la décision : elles se terminent sur ce // que le contexte rapporte, et les survivantes y sont marquées avant que le plan ne soit // fait. Les évaluer après le plan servirait un cycle de plus une dérogation déjà finie. evaluerDerogations(ctx, 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; } // 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; } void EnergyArbitrator::noterAchatEco(const QString &loadId, double achatW, const QDateTime &now) const { const QDateTime precedent = m_ecoDernierAchat.value(loadId); // Le premier cycle d'une période ne contribue RIEN : il n'a pas de durée derrière lui, et // en inventer une serait fabriquer de l'énergie. L'écart d'un cycle est borné et connu. if (precedent.isValid() && achatW > 0) { const double heures = precedent.secsTo(now) / 3600.0; if (heures > 0) m_ecoAchatWh[loadId] += achatW * heures; } m_ecoDernierAchat.insert(loadId, now); } void EnergyArbitrator::noterCauseEco(const QString &loadId, const QString &cause) const { if (cause.isEmpty()) m_ecoCause.remove(loadId); else m_ecoCause.insert(loadId, cause); } EcoProgress EnergyArbitrator::ecoProgress(const LoadContext &lc, const QDateTime &now) 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); m_ecoBaseEcheance.remove(lc.id); return p; // regime vide : l'appelant n'émet pas de niveau éco } // BASCULE DE PÉRIODE — mesuré le 2026-08-31, et ça ne se voyait pas en lisant le code. // // À l'instant de l'échéance quotidienne, `resolveDeadline()` vise la PROCHAINE occurrence : // une nouvelle période d'obligation s'ouvre. Si la base ne se reprenait pas là, le lendemain // hériterait de l'énergie livrée la veille et se croirait tenu sans avoir rien reçu — un // avancement crédible et faux, exactement ce que la base existe pour empêcher. const QDateTime echeance = resolveDeadline(lc.needs, now); if (echeance.isValid() && m_ecoBaseEcheance.value(lc.id) != echeance) { if (m_ecoBaseEcheance.contains(lc.id)) { // CLÔTURE — la période précédente s'achève. Si elle n'était pas tenue, c'est un FAIT // que le moteur produit, et un fait produit doit se dire. Ce motif n'existait pas ce // matin faute d'émetteur ; la bascule en est un. const bool mesurait = lc.telemetry.sessionMeasurable && m_ecoBaseWh.contains(lc.id); const double livre = mesurait ? qMax(0.0, lc.telemetry.sessionWh - m_ecoBaseWh.value(lc.id)) : 0.0; if (!mesurait || livre < p.targetWh) { p.closedMissed = true; p.closedKnown = mesurait; // Le manque n'est chiffré que s'il est CONNU. Sous un régime non mesuré, on ne // sait pas ce qui a été livré, donc pas davantage ce qui manquait : publier un // chiffre inventerait la grandeur même dont l'absence est le problème. if (mesurait) p.closedMissingWh = p.targetWh - livre; p.closedCause = mesurait ? (m_ecoCause.value(lc.id).isEmpty() ? QString::fromLatin1(MissedCause::LoadCapacity) // servie sans blocage : m_ecoCause.value(lc.id)) // et servie mais pas assez : QString::fromLatin1(MissedCause::Unmeasurable); } // L'obligation précédente s'achève. Si elle n'était pas tenue, elle est abandonnée // ici — SANS motif dédié à ce stade, et c'est une limite assumée : le dire demande // un `ECO_FLOOR_MISSED` que rien n'émet encore. Le journal, lui, le dit. qCInfo(dcNymeaEnergy()) << "[Arbitre] Obligation éco" << lc.id << "— nouvelle période (échéance" << echeance.toString() << "), base d'avancement reprise."; } m_ecoBaseEcheance.insert(lc.id, echeance); m_ecoBaseWh.remove(lc.id); m_ecoCause.remove(lc.id); // LM-1014-b — les deux CUMULS de la période repartent à zéro, et la date de mesure // avec eux. Les laisser courir ferait porter au lendemain la dépense de la veille et // dater la mesure d'une période close. m_ecoAchatWh.remove(lc.id); m_ecoDernierAchat.remove(lc.id); m_ecoMesureDepuis.remove(lc.id); // Le cumul de période repart de ZÉRO, comme tout ce qui est rapporté à la période // (\rule{LM-1014-b}) — et les trous avec lui : ils comptaient les interruptions de la // période close, pas de la neuve. m_ecoLivreWh.remove(lc.id); m_ecoDernierSessionWh.remove(lc.id); m_ecoTrous.remove(lc.id); } // 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. // L'ACHETÉ est connu sans mesure — c'est notre propre commande — donc il est publié dans // TOUS les régimes, y compris `unmeasurable`. C'est ce qui distingue « ce qu'on a dépensé » // de « ce qui a été livré » : le premier est un fait du moteur, le second une observation // qu'il ne peut pas faire ici. p.purchasedWh = m_ecoAchatWh.value(lc.id, 0.0); if (!lc.telemetry.sessionMeasurable) { p.regime = QString::fromLatin1(ProgressRegime::Unmeasurable); // CLÔTURE DU TRONÇON, pas abandon. Un aller-retour de régime dans la même période — le // motif d'un lien Modbus qui bat — jetait tout ce qui précède : la base était reprise à // la valeur courante, et l'avancement repartait de zéro. Deux conséquences, et la // seconde coûte : l'écran sous-estime, et la passe éco RACHÈTE le déjà livré. // // On additionne au cumul de période, en partant de la DERNIÈRE valeur lue tant que la // mesure existait : la valeur du cycle courant ne vaut plus rien puisque le Thing ne // publie plus, et s'en servir fabriquerait une énergie. if (m_ecoBaseWh.contains(lc.id)) { const double fin = m_ecoDernierSessionWh.value(lc.id, m_ecoBaseWh.value(lc.id)); m_ecoLivreWh[lc.id] += qMax(0.0, fin - m_ecoBaseWh.value(lc.id)); m_ecoTrous[lc.id] += 1; m_ecoBaseWh.remove(lc.id); } // `m_ecoMesureDepuis` est CONSERVÉ : il date le PREMIER instant mesuré de la période, et // les trous sont dits séparément. Le remettre à zéro ferait croire à une mesure continue // qui commencerait au retour du lien. p.purchasedWh = m_ecoAchatWh.value(lc.id, 0.0); 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); // DEPUIS QUAND on mesure. Sans cette date, une bascule `unmeasurable` → `measured` en // cours de période ferait afficher « 0,5 sur 4,0 » alors que 3 kWh ont pu être achetés // avant : crédible et faux. Rebaser `targetWh` serait pire — on perdrait l'obligation // réelle, et l'écran ne pourrait plus dire ce qui reste dû. if (!m_ecoMesureDepuis.contains(lc.id)) // Ancré en UTC à la CAPTURE : un QDateTime local résout son époque à la // conversion, donc il dérive au changement de fuseau (cf. energyratioscalculator). m_ecoMesureDepuis.insert(lc.id, now.toUTC()); } else if (lc.telemetry.sessionCumulative && sessionWh < m_ecoBaseWh.value(lc.id) - BruitAvancementWh) { // COMPTEUR DE VIE QUI RECULE — impossible physiquement, donc la valeur ne vaut rien : // zéro forgé à la perte du lien, ou compteur remplacé. On NE RECAPTURE PAS, et on gèle // l'avancement le temps de l'anomalie, comme si la mesure avait disparu. // // Recapturer ici serait le chemin du faux « tenue » : base reprise à 0 pendant // l'absence, retour du compteur avec son cumul réel, et l'obligation se déclarerait // tenue sans que rien n'ait été livré. R6 ne l'attraperait pas — elle n'interdit // ECO_FLOOR_MET que sous `unmeasurable`, et ce chemin-là est MESURÉ. qCInfo(dcNymeaEnergy()) << "[Arbitre] Avancement éco" << lc.id << "— compteur de VIE en recul (" << m_ecoBaseWh.value(lc.id) << "→" << sessionWh << " Wh) : valeur ignorée, avancement gelé."; p.regime = QString::fromLatin1(ProgressRegime::Unmeasurable); p.purchasedWh = m_ecoAchatWh.value(lc.id, 0.0); return p; // le tronçon reste OUVERT sur sa dernière valeur saine } else if (!lc.telemetry.sessionCumulative && 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); } m_ecoDernierSessionWh.insert(lc.id, sessionWh); // ce qui clôturera le tronçon s'il tombe p.regime = QString::fromLatin1(ProgressRegime::Measured); p.delivered = true; p.measuredSince = m_ecoMesureDepuis.value(lc.id); p.measuredGaps = m_ecoTrous.value(lc.id, 0); // 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. // Le livré de la période = les tronçons CLOS + celui en cours. Sans le premier terme, un // lien qui bat efface son propre historique à chaque retour. p.deliveredWh = m_ecoLivreWh.value(lc.id, 0.0) + 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 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. // // Sauf DÉROGATION en cours. La pose bascule bien le mode en éco (cf. poseDerogation), donc // ce test suffirait ; on le garde explicite parce qu'il dit l'ordre des choses : la // dérogation SURMONTE le mode. Si un jour la pose cessait d'écrire le mode, la borne // resterait servie au lieu de sortir silencieusement de l'arbitrage. if (info.chargingMode() == ChargingInfo::ChargingModeNormal && !m_derogations.contains(ev->id().toString())) 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 EnergyArbitrator::drawCaps() const { QList 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 EnergyArbitrator::phaseDrawW(Electricity::Phases masque, uint limiteA) const { QList 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 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 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 // est AU MAXIMUM de la marge — celle-ci étant écrêtée au plafond, l'export ne permettant pas // de tirer plus que le disjoncteur — et ne borne donc jamais. 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 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(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); } namespace { //! Bornes de la durée du filet : au moins une minute, au plus six heures. Une dérogation qui //! durerait davantage n'est plus un besoin ponctuel — c'est un mode qu'on n'a pas voulu nommer. constexpr int DerogationDureeMinS = 60; constexpr int DerogationDureeMaxS = 6 * 3600; } bool EnergyArbitrator::poseDerogation(const QString &loadId, int dureeS, double targetWh, const QDateTime &now) { const bool estBorne = m_adapters.contains(loadId); if (!estBorne && !m_loadAdapters.contains(loadId)) { qCWarning(dcNymeaEnergy()) << "[Dérogation] charge inconnue :" << loadId; return false; } // \rule{LM-1500} — la durée est BORNÉE des deux côtés, et l'absence de durée n'est pas // acceptée : une dérogation qui ne sait pas dire quand elle finit ne devrait pas exister. // Refuser ici est le seul endroit où on peut encore la faire respecter. if (dureeS < DerogationDureeMinS || dureeS > DerogationDureeMaxS) { qCWarning(dcNymeaEnergy()) << "[Dérogation] durée hors bornes :" << dureeS << "s (attendu" << DerogationDureeMinS << ".." << DerogationDureeMaxS << ")"; return false; } Derogation d; d.loadId = loadId; // ANCRÉS EN UTC À LA CAPTURE. Un QDateTime local résout son époque à la CONVERSION : une // échéance qui se déplace d'une heure au changement de fuseau n'est plus un filet. // // ET SUR L'HORLOGE DU CYCLE, pas sur une autre. En production les deux sont la même — // update() est déclenché avec QDateTime::currentDateTime(). Sous test, le cycle est // INJECTÉ : une échéance prise sur l'horloge murale et comparée à un instant simulé // n'expire jamais, et la fin par durée passerait pour absente alors qu'elle est seulement // hors d'atteinte. Trouvé par le test, pas par la lecture. d.startedAt = (now.isValid() ? now : QDateTime::currentDateTime()).toUTC(); d.expiresAt = d.startedAt.addSecs(dureeS); d.targetWh = qMax(0.0, targetWh); // baseWh reste à capturer : au moment de la pose, aucun cycle n'a construit de contexte et // le cumul de session n'est lisible que là (cf. Derogation::baseCapturee). ChargingInfo pendant; bool aBasculer = false; if (estBorne) { const ChargingInfo avant = chargingInfo(ThingId(loadId)); d.previousMode = static_cast(avant.chargingMode()); // Une borne en MANUEL est hors waterfall : le moteur ne la commande pas, et une // dérogation n'y produirait rien du tout. On la fait entrer dans l'arbitrage pour la // durée — et c'est le SEUL cas où le mode est touché. if (avant.chargingMode() == ChargingInfo::ChargingModeNormal) { pendant = avant; pendant.setChargingMode(ChargingInfo::ChargingModeEco); d.modeForce = true; aBasculer = true; } // Ce que la dérogation s'attend à voir tant qu'elle dure : le mode forcé, ou celui qui // était déjà là. Toute autre valeur au cycle suivant est une écriture extérieure. d.modeAttendu = d.modeForce ? static_cast(ChargingInfo::ChargingModeEco) : d.previousMode; } m_derogations.insert(loadId, d); m_derogationFin.remove(loadId); qCInfo(dcNymeaEnergy()).noquote() << QStringLiteral("[Dérogation] POSÉE %1 — durée %2 s, %3, mode mémorisé %4%5") .arg(loadId).arg(dureeS) .arg(d.targetWh > 0 ? QStringLiteral("cible %1 Wh").arg(d.targetWh, 0, 'f', 0) : QStringLiteral("sans cible")) .arg(d.previousMode) .arg(d.modeForce ? QStringLiteral(" (forcé en éco pour la durée)") : QString()); // L'ÉCRITURE DU MODE VIENT EN DERNIER, et l'ordre est décisif : setChargingInfo() déclenche // lui-même un cycle. La dérogation est donc DÉJÀ posée quand ce cycle passe, et il la sert // immédiatement. L'inverse la lui aurait fait manquer d'un cycle — soixante secondes // d'attente sur un mécanisme dont la définition est « tout de suite ». if (aBasculer) setChargingInfo(pendant); return true; } bool EnergyArbitrator::leveDerogation(const QString &loadId) { if (!m_derogations.contains(loadId)) return false; const Derogation d = m_derogations.take(loadId); rendreModeMemorise(d); m_derogationFin.insert(loadId, QStringLiteral("cancelled")); m_derogationModeRendu.insert(loadId, d.previousMode); qCInfo(dcNymeaEnergy()) << "[Dérogation] LEVÉE à la main" << loadId << "— mode rendu" << d.previousMode; return true; } void EnergyArbitrator::rendreModeMemorise(const Derogation &d) { // \rule{LM-1500-d} — RETOUR AU MODE MÉMORISÉ, et à lui seul. Revenir à un défaut sortirait // une borne manuelle de la main de son utilisateur — le contraire exact de ce qu'une // dérogation promet, qui est de rendre la main. if (!d.modeForce || d.previousMode < 0) return; const ThingId id(d.loadId); const int mode = d.previousMode; // DIFFÉRÉ D'UN TOUR DE BOUCLE, et ce n'est pas une précaution de style : les fins sont // évaluées DEPUIS update(), et setChargingInfo() déclenche lui-même un update(). Écrire ici // imbriquerait un cycle dans un cycle — deux plans construits et deux dispatchs pour le // même instant, sur des charges qu'un seul des deux croirait avoir commandées. // // Le décalage ne se voit nulle part : l'événement de fin est publié dans la trame de CE // cycle, et le mode est rendu avant le suivant. QTimer::singleShot(0, this, [this, id, mode]() { if (!internalEvChargers().contains(id)) return; // la borne a disparu entre-temps : il n'y a plus de réglage à rendre ChargingInfo info = chargingInfo(id); info.setChargingMode(static_cast(mode)); setChargingInfo(info); }); } void EnergyArbitrator::evaluerDerogations(SurplusContext &ctx, const QDateTime &now) { // Motifs d'ÉVÉNEMENT : ils valent pour CE cycle et disparaissent. Une fin qui resterait // affichée serait indistinguable d'une fin qui vient de se produire. m_derogationFin.clear(); m_derogationModeRendu.clear(); if (m_derogations.isEmpty()) return; // On lit le CONTEXTE DU CYCLE, pas les adaptateurs : c'est exactement ce que le scheduler // va lire pour décider. Une seconde lecture donnerait une seconde vérité, et la dérogation // pourrait se terminer sur une mesure que le plan ne voit pas. QHash parId; for (LoadContext &lc : ctx.loads) parId.insert(lc.id, &lc); const QDateTime nowUtc = now.toUTC(); for (const QString &id : m_derogations.keys()) { Derogation &d = m_derogations[id]; LoadContext *lc = parId.value(id, nullptr); // ABSENT DU CONTEXTE = la charge n'est plus arbitrable. Pour une borne c'est le // débranchement (evParticipatesInWaterfall la filtre), et la mesure du 2026-09-07 dit // que ce constat-là est FERME : `pluggedIn` tombe à faux et y reste. On le confirme // quand même — le seuil est bas, et l'asymétrie joue dans le bon sens. const bool branchee = lc && lc->telemetry.available; const bool mesurable = lc && lc->telemetry.sessionMeasurable; d.absencesConsecutives = branchee ? 0 : d.absencesConsecutives + 1; d.sansMesureConsecutifs = mesurable ? 0 : d.sansMesureConsecutifs + 1; // Base capturée au premier cycle qui la rend lisible, jamais supposée. if (!d.baseCapturee && mesurable) { d.baseWh = lc->telemetry.sessionWh; d.baseCapturee = true; } const double livreWh = (d.baseCapturee && mesurable) ? qMax(0.0, lc->telemetry.sessionWh - d.baseWh) : 0.0; // \rule{LM-1500-g} — QUELQU'UN A REPRIS LE VOLANT, testé AVANT tout le reste, parce que c'est une // décision et non un constat. Choisir un mode est durable ; une dérogation répond à un // besoin ponctuel, et le durable prime. // // Le laisser passer coûte cher, et le banc l'a montré (2026-09-08) : un // ChargingModeNormal écrit pendant une dérogation fait restituer les valeurs manuelles // par l'amont — chargingEnabled à faux — et arme un verrou de 300 s. La dérogation // continuait de commander 9 A dans une borne éteinte et verrouillée : deux commandeurs // pour un organe, que seul le journal de divergence voyait. bool volantRepris = false; if (d.modeAttendu >= 0 && internalEvChargers().contains(ThingId(id))) volantRepris = static_cast(chargingInfo(ThingId(id)).chargingMode()) != d.modeAttendu; QString cause; // ORDRE : la plus informative d'abord. C'est la cause que l'utilisateur doit lire, et // la durée n'est pas un diagnostic — elle vient donc en dernier, comme filet. if (volantRepris) { cause = QString::fromLatin1(DerogationEnd::ModeChanged); } else if (d.targetWh > 0 && d.baseCapturee && livreWh >= d.targetWh) { cause = QString::fromLatin1(DerogationEnd::TargetReached); } else if (d.targetWh > 0 && d.sansMesureConsecutifs >= DerogationConfirmationsMesure) { // \rule{LM-1500-b} — une dérogation dont la CIBLE cesse d'être mesurable doit // cesser : elle ACHÈTE, et // acheter sans savoir où l'on en est, c'est acheter à l'aveugle. Sans cible, rien // ne dépend de la mesure — le filet de durée suffit, et la couper ici serait // punir la charge du silence d'un instrument dont elle n'a pas besoin. cause = QString::fromLatin1(DerogationEnd::MeasurementLost); } else if (d.absencesConsecutives >= DerogationConfirmationsAbsence) { cause = QString::fromLatin1(DerogationEnd::Unplugged); } else if (nowUtc >= d.expiresAt) { cause = QString::fromLatin1(DerogationEnd::DurationElapsed); } if (cause.isEmpty()) { // Elle continue : on la POSE dans le contexte, et c'est de là que le scheduler la // servira en tête de passe éco. if (lc) { lc->derogation = true; lc->derogationEndsAt = d.expiresAt; lc->derogationTargetWh = d.targetWh; lc->derogationDeliveredWh = livreWh; } continue; } const Derogation finie = m_derogations.take(id); // PAS de restitution quand le volant a été repris : le mode mémorisé écraserait celui // que l'utilisateur vient de choisir — on lui reprendrait sa décision au nom de la // nôtre, à un tour de boucle près et sans qu'il puisse le voir. if (cause != QString::fromLatin1(DerogationEnd::ModeChanged)) rendreModeMemorise(finie); m_derogationFin.insert(id, cause); m_derogationModeRendu.insert(id, finie.previousMode); qCInfo(dcNymeaEnergy()).noquote() << QStringLiteral("[Dérogation] FIN %1 — cause %2, livré %3 Wh sur %4, mode rendu %5") .arg(id, cause).arg(livreWh, 0, 'f', 0) .arg(finie.targetWh > 0 ? QStringLiteral("%1 Wh").arg(finie.targetWh, 0, 'f', 0) : QStringLiteral("aucune cible")) .arg(finie.previousMode); } } void EnergyArbitrator::alimenterAvancement(LoadContext &lc) const { // CONTRADICTION CORRIGÉE (2026-09-03, remontée par l'app). `progressMeasurable` dit ce que // le MATÉRIEL permet ; `regime` dit ce que le MOTEUR fait. Seul l'EvAdapter remplissait // `sessionMeasurable`, si bien que toute charge non-VE était `unmeasurable` alors que sa // configuration annonçait l'inverse. // // Ce n'est pas qu'une incohérence d'affichage : l'avertissement de saisie de l'app se // déclenche sur `progressMeasurable == false`, donc il restait MUET exactement sur les // charges achetées à l'aveugle — celles dont l'obligation ne pouvait pas être suivie. if (lc.telemetry.sessionMeasurable || !m_tm || !m_loadConfigStore) return; // déjà renseigné par l'adaptateur (VE), ou rien pour le résoudre for (const LoadConfig &c : m_loadConfigStore->configs()) { if (c.id() != lc.id || c.meterThingId().isEmpty()) continue; Thing *compteur = m_tm->findConfiguredThing(ThingId(c.meterThingId())); // Il faut un CUMUL, pas une puissance : `currentPower` ne dit rien de ce qui a été // livré. C'est la même exigence que celle publiée en `progressUnmeasurableCause`, et // elle doit être la MÊME des deux côtés — sinon la contradiction se déplace au lieu de // disparaître. if (compteur && compteur->thingClass().hasStateType("totalEnergyConsumed")) { lc.telemetry.sessionMeasurable = true; // Compteur de VIE : un recul y est une ANOMALIE, jamais une nouvelle session. lc.telemetry.sessionCumulative = true; // Compteur de VIE, pas de session — et c'est sans importance : l'avancement est une // DIFFÉRENCE depuis une base capturée à l'ouverture de l'obligation (LM-1012-b). // Un cumul monotone est même plus sûr qu'un compteur de session, qui recule. lc.telemetry.sessionWh = compteur->stateValue("totalEnergyConsumed").toDouble() * 1000.0; } return; } } void EnergyArbitrator::qualifierRefus(LoadContext &lc, const QDateTime &now) const { const QDateTime depuis = m_divergenceDepuis.value(lc.id); if (!depuis.isValid()) return; // aucun écart en cours : rien à qualifier const int ecoule = static_cast(depuis.secsTo(now)); lc.telemetry.refusedSinceS = qMax(0, ecoule); // Le seuil vient du MÉCANISME. Un écart récent est une latence — une charge qui vient // d'être commandée n'a pas encore atteint sa consigne, et suspendre là ferait osciller une // charge saine. Au-delà, ce n'est plus une latence, c'est un refus. ILoadAdapter *a = m_loadAdapters.value(lc.id, nullptr); if (!a) a = m_adapters.value(lc.id, nullptr); if (!a) return; // Sens de la commande : on juge la DESCENTE sur la latence de descente. Le cas dangereux — // commander la baisse sans être suivi — est justement celui qui se constate le plus vite. const bool montee = lc.telemetry.currentPowerW < lc.declared.maxPowerW / 2.0; lc.telemetry.commandRefused = ecoule > a->commandSettleS(montee); } 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; } LoadContext lcq = it.value()->toLoadContext(now); alimenterAvancement(lcq); qualifierRefus(lcq, now); ctx.loads.append(lcq); } // --- 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; LoadContext lcq = lc; alimenterAvancement(lcq); qualifierRefus(lcq, now); ctx.loads.append(lcq); } } // --- 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) { LoadContext lcq = it.value()->toLoadContext(now); alimenterAvancement(lcq); qualifierRefus(lcq, now); ctx.loads.append(lcq); } 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); // LAQUELLE borne — présent seulement s'il y en a une. `perPhaseBound` dit qu'une phase // borne, jamais laquelle : l'écran ne pourrait dire que « bridé » sans dire par quoi, et // enverrait mesurer au hasard sur trois phases dont deux vont bien. if (!plan.draw.bindingPhase.isEmpty()) draw.insert(QStringLiteral("bindingPhase"), plan.draw.bindingPhase); } 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 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. // §10 — les niveaux sont LUS DU PLAN, plus fabriqués ici. Les fabriquer à la frontière // ne pouvait donner qu'un seul niveau, puisqu'il n'y avait qu'un endroit pour le former ; // et une frontière qui invente une structure finit par diverger de ce que le moteur a // réellement décidé. Elle traduit désormais, elle ne décide plus. QVariantList niveaux; for (const LoadAction::Level &n : action.levels) { QVariantMap niveau; niveau.insert(QStringLiteral("level"), n.level); // R4 — le niveau publie ce qu'il a DÉCIDÉ. Σ targetW == allocatedW, exactement. niveau.insert(QStringLiteral("targetW"), n.targetW); niveau.insert(QStringLiteral("funding"), n.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. Publiée TOUJOURS, même à une seule // destination et même à zéro : un zéro AVEC sa destination dit « cette grandeur // existe et vaut 0 », l'absence dirait « elle n'existe pas ». QVariantMap counts; for (auto it = n.counts.constBegin(); it != n.counts.constEnd(); ++it) counts.insert(it.key(), it.value()); niveau.insert(QStringLiteral("counts"), counts); if (!n.reason.isEmpty()) { QVariantMap d; d.insert(QStringLiteral("code"), n.reason.code); d.insert(QStringLiteral("params"), n.reason.params); niveau.insert(QStringLiteral("decision"), d); } // R5 — le RÉGIME d'avancement, porté par le niveau ÉCO et par lui seul. if (n.hasProgress) { QVariantMap progress; progress.insert(QStringLiteral("regime"), n.progress.regime); progress.insert(QStringLiteral("targetWh"), n.progress.targetWh); // ABSENT sous `unmeasurable`, jamais 0 : une dégradation ne doit pas se lire // comme un avancement nul (LM-1009 C1). if (n.progress.delivered) progress.insert(QStringLiteral("deliveredWh"), n.progress.deliveredWh); // L'ACHETÉ est publié dans TOUS les régimes : c'est notre propre commande, donc // un fait connu sans mesure. Sans lui, « ce que ça coûte par jour » redeviendrait // une estimation fabriquée à partir du taux. progress.insert(QStringLiteral("purchasedWh"), n.progress.purchasedWh); // DEPUIS QUAND on mesure — présent seulement si on mesure. Ferme le piège de la // bascule en cours de période : « 0,5 sur 4,0 » alors que 3 kWh ont pu être // achetés avant. L'écran dit « mesuré depuis 09:14 », purchasedWh couvre l'avant. if (n.progress.measuredSince.isValid()) progress.insert(QStringLiteral("measuredSince"), n.progress.measuredSince.toUTC().toString(Qt::ISODate)); // L'INTERMITTENCE, publiée seulement si elle a eu lieu. `measuredSince` date le // PREMIER instant mesuré ; sans ce compteur, il suggérerait une continuité que // le battement d'un lien Modbus dément. Absent = mesure continue. if (n.progress.measuredGaps > 0) progress.insert(QStringLiteral("measuredGaps"), n.progress.measuredGaps); niveau.insert(QStringLiteral("progress"), progress); } niveaux.append(niveau); } entry.insert(QStringLiteral("levels"), niveaux); 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. // Le niveau dont `decision` parle : le DERNIER parcouru. La règle est arbitraire mais // elle doit être UNE, et publiée : sans elle chaque client réinventerait sa fusion, et // deux écrans afficheraient deux motifs pour le même cycle. Le dernier parcouru est // aussi celui qui a eu le dernier mot sur la consigne, donc celui qu'on explique. QVariantMap decision = action.reason.toMap(); if (!action.levels.isEmpty()) decision.insert(QStringLiteral("level"), action.levels.last().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); attacherFinsDerogation(snap); m_telemetry = snap; } namespace { //! Tolérance de concordance. Un écart en deçà n'est pas publié comme divergence : bruit de //! mesure, arrondi à l'ampère, et déphasage d'un cycle entre commande et mesure s'y logent. //! Valeur de conception, à ajuster sur relevé. constexpr double DivergenceAbsolueW = 200.0; constexpr double DivergenceRelative = 0.15; } void EnergyArbitrator::attachCommandDivergence(QVariantMap &entry, const QString &loadId, const QString &source, double mesureW) const { // RIEN NE MESURE : il n'y a rien à comparer, et l'objet est ABSENT — pas `divergent: false`, // qui affirmerait une concordance qu'on ne peut pas constater. Même règle que partout. if (source == QLatin1String("none")) return; // Ce qu'on a commandé. Sans commande, l'écart est SANS OBJET — et c'est le cas normal d'une // borne en mode manuel que l'arbitre ne pilote pas. Une borne jamais commandée n'est pas // une borne commandée à zéro. double attenduW = 0; bool connu = false; if (EvAdapter *ev = m_adapters.value(loadId, nullptr)) { uint ampere = 0, phases = 0; if (ev->derniereCommande(ampere, phases)) { attenduW = ampere * 230.0 * qMax(1u, phases); connu = true; } } else if (ILoadAdapter *a = m_loadAdapters.value(loadId, nullptr)) { attenduW = a->telemetry().currentPowerW; connu = true; } if (!connu) return; const double ecart = qAbs(attenduW - mesureW); const bool divergent = ecart > qMax(DivergenceAbsolueW, DivergenceRelative * attenduW); // DEPUIS QUAND — sans cette date, une divergence permanente ne se distingue pas d'un // déphasage d'un cycle entre l'écriture et sa mesure. C'est pourtant TOUTE la différence // entre un fonctionnement normal et le cas relevé le 2026-08-31, où l'écart durait // indéfiniment parce que plus personne ne commandait. // JOURNALISÉ AUX DEUX TRANSITIONS, et pas seulement publié. // // `divergent` et `since` ne vivaient que dans la charge utile de télémétrie et dans ce // QHash en mémoire — donc nulle part. Rien ne comptait les épisodes : un redémarrage de // nymead effaçait l'état, et le hash est PURGÉ dès que l'écart cesse, si bien qu'une // divergence qui s'ouvre et se referme entre deux lectures ne laissait aucune trace. Même // un observateur externe échantillonnant en continu aurait raté les épisodes courts, qui // sont probablement les plus nombreux. // // Sans ces deux lignes, « attendre que les traces parlent » attend indéfiniment : le // silence du journal était garanti par construction, pas par l'absence du phénomène. if (!divergent) { if (m_divergenceDepuis.contains(loadId)) { const qint64 dureeS = m_divergenceDepuis.value(loadId).secsTo(QDateTime::currentDateTime()); qCInfo(dcNymeaEnergy()) << "[Divergence] FIN" << loadId << "après" << dureeS << "s" << "— attendu" << attenduW << "W, mesuré" << mesureW << "W"; } m_divergenceDepuis.remove(loadId); } else if (!m_divergenceDepuis.contains(loadId)) { const QDateTime debut = m_lastTelemetryPublish.isValid() ? m_lastTelemetryPublish : QDateTime::currentDateTime(); m_divergenceDepuis.insert(loadId, debut.toUTC()); // ancré à la capture qCInfo(dcNymeaEnergy()) << "[Divergence] DÉBUT" << loadId << "— attendu" << attenduW << "W, mesuré" << mesureW << "W," << "écart" << (mesureW - attenduW) << "W"; } QVariantMap commande; commande.insert(QStringLiteral("expectedW"), attenduW); commande.insert(QStringLiteral("observedW"), mesureW); // Le VERDICT est publié, et pas seulement les deux nombres : la tolérance est une // connaissance du moteur (bruit de mesure, arrondi à l'ampère, déphasage d'un cycle). La // laisser au client ferait réinventer un seuil par écran, et deux écrans ne diraient pas la // même chose du même cycle. commande.insert(QStringLiteral("divergent"), divergent); if (divergent) commande.insert(QStringLiteral("since"), m_divergenceDepuis.value(loadId).toUTC().toString(Qt::ISODate)); entry.insert(QStringLiteral("command"), commande); } 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); attachCommandDivergence(entry, loadId, source, powerW); // 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); // Le mode dégradé porte l'événement LUI AUSSI : `measurementLost` s'y produit précisément, // et c'est le moment où l'utilisateur a le plus besoin de savoir que sa dérogation a cessé. attacherFinsDerogation(snap); m_telemetry = snap; } void EnergyArbitrator::attacherFinsDerogation(QVariantMap &snap) const { // \rule{LM-1500-f} — FINS DE DÉROGATION, publiées au NIVEAU DE LA TRAME, pas dans loads[]. // // Deux raisons, et la seconde est décisive : // · la charge peut avoir QUITTÉ loads[] au moment où sa dérogation se termine — c'est // même le cas le plus fréquent, celui du débranchement — et l'événement disparaîtrait // exactement quand il explique le plus de choses ; // · `decision` porte ce que le cycle a DÉCIDÉ pour cette charge ; une fin est un // ÉVÉNEMENT, pas une décision, et l'y loger obligerait à choisir entre les deux. // // Événement de CYCLE : présent le seul cycle de la fin, absent partout ailleurs. Une fin // qui resterait affichée serait indistinguable d'une fin qui vient de se produire. if (!m_derogationFin.isEmpty()) { QVariantList fins; QStringList finIds = m_derogationFin.keys(); finIds.sort(); // ordre stable : un instantané se compare for (const QString &id : finIds) { QVariantMap f; f.insert(QStringLiteral("loadId"), id); f.insert(QStringLiteral("cause"), m_derogationFin.value(id)); // Le mode auquel on REVIENT, publié plutôt que laissé à déduire : le client ne // peut pas le retrouver, il n'a jamais vu celui d'avant la pose. const int rendu = m_derogationModeRendu.value(id, -1); if (rendu >= 0) f.insert(QStringLiteral("restoredMode"), rendu); fins.append(f); } snap.insert(QStringLiteral("derogationEnded"), fins); } } 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().toUTC(); // ancré à la capture // 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 // Un ÉVÉNEMENT se notifie TOUJOURS. Il ne vit qu'un cycle : le filtrer sur « ça se ressemble // assez » le perdrait définitivement, et le client ne saurait jamais que sa dérogation a // pris fin — ni pourquoi. if (b.contains(QStringLiteral("derogationEnded"))) return true; 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; }