diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 5538220..aa75f8b 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -11,6 +11,8 @@ #include "config/loadconfigstore.h" #include "types/loadconfig.h" #include "scheduler/rulebasedscheduler.h" +#include "scheduler/optimizerclient.h" +#include "scheduler/socketscheduler.h" #include "types/decisionreason.h" #include "types/surpluscontext.h" #include "types/plan.h" @@ -49,6 +51,7 @@ EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm, : SmartChargingManager(em, tm, sm, conf, parent) , m_scheduler(new RuleBasedScheduler(this, this)) , m_strategy(m_scheduler) + , m_em(em) , m_tm(tm) { // --- L2 : watchdog fraîcheur compteur (SAFETY.md §L2) --- @@ -124,6 +127,9 @@ EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm, Q_UNUSED(em) #endif + // Brief 4 T3 — la stratégie optimiseur n'existe que si un endpoint est configuré. + relireReglageOptimiseur(); + qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] Arbitre ETM initialisé."; } @@ -272,6 +278,9 @@ bool EnergyArbitrator::clearLoadFault(const QString &loadId) void EnergyArbitrator::rebuildLoadAdapters() { + // Brief 4 T3 — une configuration changée est une cause de requête (§5.1 `trigger: config`). + if (m_socketScheduler) + m_socketScheduler->noterDeclencheur(QStringLiteral("config")); if (!m_loadConfigStore) { for (ILoadAdapter *a : m_loadAdapters) if (QObject *o = dynamic_cast(a)) @@ -498,6 +507,15 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime) applyActionsToAdapters(slot, currentDateTime); // charges arbitrées (Setpoint W, State) ET bornes du waterfall adjustEvChargers(currentDateTime); // bornes RESTÉES au proxy : échéance, spot market + // Brief 4 T3 — sous un plan d'OPTIMISEUR seulement : la puissance publiée est celle que les + // adaptateurs viennent d'APPLIQUER, le financement et le budget se recalculent sur elle (Q3, + // Q4), et une action écrêtée est marquée. AVANT la fusion L4, qui remplace ensuite les + // charges délestées. Sans endpoint ce pointeur est nul : rien ne change. + if (m_socketScheduler) + m_socketScheduler->apresDispatch(plan, slot, m_actionsAppliquees, + QSet(m_shedActions.keyBegin(), m_shedActions.keyEnd()), + ctx); + // 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); @@ -2648,7 +2666,10 @@ void EnergyArbitrator::lireCourbePrevision() if (plans.count() > 1) qCDebug(dcNymeaEnergy()) << "[Prévision]" << plans.count() << "plans sommés —" << site.points.count() << "créneaux"; - m_forecastStore->enregistrer(site); // un run déjà connu rend faux, sans bruit + const bool nouvelle = m_forecastStore->enregistrer(site); // un run déjà connu rend faux, sans bruit + // Brief 4 T3 — une prévision NOUVELLE est une cause de requête (§5.1 `trigger: forecast`). + if (nouvelle && m_socketScheduler) + m_socketScheduler->noterDeclencheur(QStringLiteral("forecast")); } void EnergyArbitrator::observerPourApprentissage(const QDateTime &now) @@ -2765,6 +2786,93 @@ void EnergyArbitrator::noterMinuteBilan(const QDateTime &finMinute, double acqui m_baseload->observerMinute(m); } +void EnergyArbitrator::relireReglageOptimiseur() +{ + EnergySettings reglages; + const QString texte = reglages.value(QStringLiteral("Optimizer/endpoint")).toString().trimmed(); + if (m_optimizerReglageLu && texte == m_optimizerEndpoint) + return; // rien de neuf — et SANS endpoint, rien du tout : aucune ligne, aucun objet + m_optimizerReglageLu = true; + + if (m_socketScheduler) { + qCInfo(dcNymeaEnergy()).noquote() << "[Optimiseur] stratégie optimiseur retirée (" + << m_optimizerEndpoint << ") — rule-based seul"; + m_strategy = m_scheduler; + delete m_socketScheduler; // emporte son client, qui ferme sa connexion + m_socketScheduler = nullptr; + } + m_optimizerEndpoint = texte; + if (texte.isEmpty()) + return; + + const OptimizerEndpoint endpoint = OptimizerEndpoint::parse(texte); + if (!endpoint.isValid()) { + // Dit une fois par valeur lue : le réglage ne change pas tant qu'on ne le réécrit pas. + qCWarning(dcNymeaEnergy()).noquote() << "[Optimiseur] Optimizer/endpoint illisible :" << texte + << "— attendu unix:///chemin ou tcp://hôte:port ; " + "aucune stratégie optimiseur, rule-based seul"; + return; + } + + auto *client = new OptimizerClient(endpoint, OptimizerClient::defaultTokenPath(), + QStringLiteral(ETM_VERSION_MOTEUR)); + m_socketScheduler = new SocketScheduler( + m_scheduler, client, [this](const SurplusContext &ctx) { return contexteFil(ctx); }, this); + m_strategy = m_socketScheduler; + + // Les causes de requête (§5.1 `trigger`). Celles qui DÉCLENCHENT un cycle émettent leur signal + // avant d'appeler update() : la cause est donc posée quand la requête part. Les autres + // attendent la prochaine requête. Contexte = la stratégie : ces liens meurent avec elle. + SocketScheduler *s = m_socketScheduler; + connect(this, &SmartChargingManager::chargingInfoChanged, s, + [s]() { s->noterDeclencheur(QStringLiteral("intent")); }); + connect(this, &SmartChargingManager::phasePowerLimitChanged, s, + [s]() { s->noterDeclencheur(QStringLiteral("config")); }); + connect(this, &SmartChargingManager::acquisitionToleranceChanged, s, + [s]() { s->noterDeclencheur(QStringLiteral("config")); }); + connect(this, &SmartChargingManager::batteryLevelConsiderationChanged, s, + [s]() { s->noterDeclencheur(QStringLiteral("config")); }); + if (m_tm) { + connect(m_tm, &ThingManager::thingAdded, s, + [s]() { s->noterDeclencheur(QStringLiteral("load-change")); }); + connect(m_tm, &ThingManager::thingRemoved, s, + [s]() { s->noterDeclencheur(QStringLiteral("load-change")); }); + } + qCInfo(dcNymeaEnergy()).noquote() << "[Optimiseur] stratégie optimiseur active vers" + << endpoint.toString() + << "— rule-based en ombre à chaque cycle, repli sur lui"; +} + +OptimizerProtocol::ContexteFil EnergyArbitrator::contexteFil(const SurplusContext &ctx) const +{ + OptimizerProtocol::ContexteFil fil; + if (m_em && m_em->rootMeter()) + fil.meterSource = m_em->rootMeter()->id().toString(); + // La PV instantanée n'a de sens qu'avec un onduleur : sans lui, 0 W ne distinguerait pas la + // nuit de l'absence de production — le champ reste alors inconnu (null). + if (m_em && m_tm && !m_tm->configuredThings().filterByInterface(QStringLiteral("solarinverter")).isEmpty()) + fil.pvCurrentW = qAbs(m_em->currentPowerProduction()); + if (phasePowerLimit() > 0) + fil.phaseLimitA = {double(phasePowerLimit()), double(phasePowerLimit()), double(phasePowerLimit())}; + if (ctx.battery.present) + fil.reserveSocPercent = qRound(batteryLevelConsideration() * 100.0); + if (m_loadConfigStore) + for (const LoadConfig &c : m_loadConfigStore->configs()) + if (!c.domain().isEmpty()) + fil.domaines.insert(c.id(), c.domain()); + for (const LoadContext &lc : ctx.loads) { + const MesureCharge mc = mesurerCharge(lc.id); + OptimizerProtocol::MesureFil m; + m.source = mc.source; + m.powerW = mc.powerW; + fil.mesures.insert(lc.id, m); + } + const QVariantMap prevision = instantanePrevision(ctx.timestamp); + if (!prevision.isEmpty()) + fil.prevision = prevision; + return fil; +} + void EnergyArbitrator::relireTarif() { EnergySettings reglages; diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index 9002c6f..e3e7e36 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -4,6 +4,7 @@ #include "../smartchargingmanager.h" #include "scheduler/ischeduler.h" +#include "scheduler/optimizerprotocol.h" #include "types/surpluscontext.h" #include "types/plan.h" // Inclus (et non déclaré en avant) : m_builtFrom stocke des LoadConfig PAR VALEUR — c'est ce @@ -31,6 +32,7 @@ class RelayRouter; class ILoadAdapter; class LoadConfigStore; class RuleBasedScheduler; +class SocketScheduler; /*! * \brief Arbitre central ETM — remplace SmartChargingManager::update() (ETM_ARBITRATOR). @@ -251,6 +253,31 @@ public: */ QVariantMap instantanePrevision(const QDateTime &now) const; + /*! + * \brief (Re)lit le réglage \c Optimizer/endpoint (\c energy.conf) et en tire la stratégie. + * + * Absent ou vide : AUCUN \c SocketScheduler ni \c OptimizerClient n'existe, \c update() + * interroge le rule-based directement — comportement identique au bit près à un moteur sans + * optimiseur (brief 4 T3, preuve \c decisions). Présent et valide : la stratégie optimiseur est + * construite et son client démarré ; un endpoint inchangé ne reconstruit rien. Invalide : + * signalé une fois, rien n'est construit. + * \note Appelée par le constructeur. Lecture seule dans ce lot : la RPC d'écriture (T4) + * rappellera cette méthode. + */ + void relireReglageOptimiseur(); + + /*! \brief \return La stratégie optimiseur, ou \c nullptr sans endpoint configuré. */ + SocketScheduler *socketScheduler() const { return m_socketScheduler; } + + /*! + * \brief Ce que la requête \c GetPlan porte en plus du contexte (OPTIMIZER_PROTOCOL §5) : + * compteur racine, PV instantanée, limite de phase, réserve, domaine et mesure par charge, + * prévision (\c instantanePrevision(), la même que \c GetForecastSnapshot). + * \param ctx Contexte du cycle. \return Le fil ; \c trigger est laissé à la stratégie. + * \note La prévision n'entre jamais dans \c SurplusContext : le rule-based ne la lit pas (§11). + */ + OptimizerProtocol::ContexteFil contexteFil(const SurplusContext &ctx) const; + /*! * \brief Construit l'arbitre central. Mêmes dépendances que \c SmartChargingManager amont, * dont il hérite (justification : AGENTS.md, « 3b-iii »). @@ -1046,6 +1073,15 @@ private: * proxy, comptabilité éco, réserve batterie — doivent tourner à chaque cycle). */ IScheduler *m_strategy = nullptr; + //! Stratégie optimiseur ; \c nullptr sans endpoint (aucun client n'existe alors). Possédée. + //! Quand elle existe, \c m_strategy pointe sur elle et elle appelle \c m_scheduler en ombre. + SocketScheduler *m_socketScheduler = nullptr; + //! Endpoint qui a servi à la construire, pour ne pas la reconstruire à l'identique. + QString m_optimizerEndpoint; + //! Le réglage a-t-il déjà été lu ? Un endpoint illisible n'est signalé qu'à sa première lecture. + bool m_optimizerReglageLu = false; + //! Gestionnaire d'énergie (production PV instantanée du fil optimiseur). Non possédé. + EnergyManager *m_em = nullptr; QHash m_adapters; //!< loadId (ThingId string) → EvAdapter*. /*! * \brief [3g-2] Bornes commandées par le waterfall AU CYCLE COURANT. diff --git a/energyplugin/etm/etm.pri b/energyplugin/etm/etm.pri index 8e520ab..35e4060 100644 --- a/energyplugin/etm/etm.pri +++ b/energyplugin/etm/etm.pri @@ -20,6 +20,7 @@ HEADERS += \ $$PWD/scheduler/optimizerprotocol.h \ $$PWD/scheduler/optimizerclient.h \ $$PWD/scheduler/optimizerplan.h \ + $$PWD/scheduler/socketscheduler.h \ $$PWD/ratios/energyratioscalculator.h \ $$PWD/energyarbitrator.h \ $$PWD/spotmarketbuild.h \ @@ -40,5 +41,16 @@ SOURCES += \ $$PWD/scheduler/optimizerprotocol.cpp \ $$PWD/scheduler/optimizerclient.cpp \ $$PWD/scheduler/optimizerplan.cpp \ + $$PWD/scheduler/socketscheduler.cpp \ $$PWD/energyarbitrator.cpp \ $$PWD/spotmarketbuild.cpp \ + +# Version du moteur annoncée au handshake de l'optimiseur (OPTIMIZER_PROTOCOL §3, `engine`) : lue +# dans le changelog Debian, seule source de la version livrée — une constante dans le code +# dériverait au premier `+etmN`. +ETM_CHANGELOG = $$cat($$PWD/../../debian/changelog, lines) +ETM_LIGNE_VERSION = $$first(ETM_CHANGELOG) +ETM_VERSION_MOTEUR = $$section(ETM_LIGNE_VERSION, "(", 1, 1) +ETM_VERSION_MOTEUR = $$section(ETM_VERSION_MOTEUR, ")", 0, 0) +isEmpty(ETM_VERSION_MOTEUR): ETM_VERSION_MOTEUR = inconnue +DEFINES += ETM_VERSION_MOTEUR=\\\"$$ETM_VERSION_MOTEUR\\\" diff --git a/energyplugin/etm/scheduler/socketscheduler.cpp b/energyplugin/etm/scheduler/socketscheduler.cpp new file mode 100644 index 0000000..afc84be --- /dev/null +++ b/energyplugin/etm/scheduler/socketscheduler.cpp @@ -0,0 +1,270 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync + +#include "socketscheduler.h" + +#include + +Q_DECLARE_LOGGING_CATEGORY(dcNymeaEnergy) + +namespace { +// Rang d'un déclencheur : le plus significatif l'emporte quand plusieurs se cumulent. +int rangDeclencheur(const QString &t) +{ + static const QStringList ordre{QStringLiteral("cycle"), QStringLiteral("forecast"), + QStringLiteral("config"), QStringLiteral("load-change"), + QStringLiteral("intent")}; + return ordre.indexOf(t); +} +} + +SocketScheduler::SocketScheduler(IScheduler *ombre, OptimizerClient *client, FournisseurFil fil, + QObject *parent) + : QObject(parent) + , m_ombre(ombre) + , m_client(client) + , m_fil(std::move(fil)) +{ + m_client->setParent(this); + connect(m_client, &OptimizerClient::planReceived, this, &SocketScheduler::onPlanRecu); + connect(m_client, &OptimizerClient::planFailed, this, &SocketScheduler::onPlanEchoue); + // §7 « socket absent / fermé → rules » : un plan ne survit pas à la connexion qui l'a porté. + connect(m_client, &OptimizerClient::stateChanged, this, [this](OptimizerClient::State s) { + if (s != OptimizerClient::Ready && s != OptimizerClient::Handshaking) + oublierCache(QStringLiteral("connexion perdue")); + }); + m_client->start(); +} + +Plan SocketScheduler::getPlan(const SurplusContext &ctx) +{ + // L'OMBRE D'ABORD, et toujours : ses effets de bord (proxy VE, comptabilité éco, réserve) + // sont dus à chaque cycle, quelle que soit la stratégie qui commandera. + const Plan ombre = m_ombre->getPlan(ctx); + envoyer(ctx); + + if (!m_aCache) { + noterCycle(false, QStringLiteral("no-plan"), QStringLiteral("aucun plan en cache")); + return ombre; + } + // Rejugé sur le contexte DE CE CYCLE : maintenant, charges, disponibilités ont pu changer + // depuis la requête. + const OptimizerPlan::Verdict v = OptimizerPlan::valider(m_cache, ctx, m_client->capabilities(), + m_client->hasCapabilities()); + if (v.issue != OptimizerPlan::Issue::Valide) { + noterCycle(false, v.code, v.detail); + if (v.issue == OptimizerPlan::Issue::Expire) + oublierCache(QStringLiteral("plan expiré")); + return ombre; + } + const Plan plan = OptimizerPlan::substituer(ombre, v.plan, ctx); + if (plan.strategy != QLatin1String(OptimizerPlan::StrategieOptimiseur)) { + noterCycle(false, QStringLiteral("no-shadow-slot"), QStringLiteral("l'ombre n'a pas de créneau courant")); + return ombre; + } + m_planApplique = v.plan; + journaliserCreneau(v, ctx); + noterCycle(true, QString(), QString()); + return plan; +} + +void SocketScheduler::apresDispatch(Plan &plan, Slot &slot, const QHash &appliquees, + const QSet &delestees, const SurplusContext &ctx) +{ + if (plan.strategy != QLatin1String(OptimizerPlan::StrategieOptimiseur)) + return; + const QList ecretages = + OptimizerPlan::finaliser(plan, slot, appliquees, delestees, ctx); + QSet vus; + for (const OptimizerPlan::Ecretage &e : ecretages) { + vus.insert(e.loadId); + const QString texte = e.demande + QStringLiteral(" → ") + e.applique; + // Annoncé au CHANGEMENT : un palier qui arrondit la même demande à chaque cycle ne doit + // pas produire une ligne par minute ; le premier écrêtage, lui, se dit en clair. + if (m_ecretageVu.value(e.loadId) == texte) { + qCDebug(dcNymeaEnergy()).noquote() << "[Optimiseur]" << e.loadId << "écrêtée (inchangé) :" << texte; + continue; + } + m_ecretageVu.insert(e.loadId, texte); + qCInfo(dcNymeaEnergy()).noquote() << "[Optimiseur]" << e.loadId + << "ÉCRÊTÉE par l'adaptateur — demandé" << e.demande + << ", appliqué" << e.applique << "(clamped)"; + } + for (auto it = m_ecretageVu.begin(); it != m_ecretageVu.end();) { + if (vus.contains(it.key())) { ++it; continue; } + qCInfo(dcNymeaEnergy()).noquote() << "[Optimiseur]" << it.key() << "n'est plus écrêtée"; + it = m_ecretageVu.erase(it); + } +} + +void SocketScheduler::noterDeclencheur(const QString &trigger) +{ + const int rang = rangDeclencheur(trigger); + if (rang > rangDeclencheur(m_declencheur)) + m_declencheur = trigger; +} + +void SocketScheduler::envoyer(const SurplusContext &ctx) +{ + if (m_client->state() != OptimizerClient::Ready) + return; // le déclencheur attend la prochaine requête qui partira vraiment + OptimizerProtocol::ContexteFil fil = m_fil ? m_fil(ctx) : OptimizerProtocol::ContexteFil(); + fil.trigger = m_declencheur; + const quint64 id = m_client->requestPlan(OptimizerProtocol::getPlanParams(ctx, fil)); + if (id == 0) + return; + m_idRequete = id; + m_ctxRequete = ctx; + m_declencheur = QStringLiteral("cycle"); +} + +void SocketScheduler::onPlanRecu(quint64 id, const QVariantMap &result) +{ + if (id != m_idRequete) + return; + const OptimizerPlan::Verdict v = OptimizerPlan::valider(result, m_ctxRequete, + m_client->capabilities(), + m_client->hasCapabilities()); + signalerReserves(v.champsReserves); + emit reponseJugee(OptimizerPlan::nomIssue(v.issue), v.code); + + switch (v.issue) { + case OptimizerPlan::Issue::Valide: { + m_cache = result; + m_aCache = true; + // Un optimiseur qui répond à chaque cycle redonne souvent le même plan : la ligne n'est en + // clair qu'au CHANGEMENT de plan (ou au retour après un repli), sinon en debug. + const bool nouveau = v.plan.planId != m_dernierPlanRecu || v.plan.planId.isEmpty(); + m_dernierPlanRecu = v.plan.planId; + const QString ligne = QStringLiteral("[Optimiseur] plan %1 reçu (GetPlan %2) — %3 créneau(x), %4 ; " + "appliqué au cycle suivant") + .arg(v.plan.planId.isEmpty() ? QStringLiteral("(sans id)") : v.plan.planId).arg(id) + .arg(v.plan.creneaux.count()) + .arg(v.plan.validUntil.isValid() ? QStringLiteral("valable jusqu'à ") + v.plan.validUntil.toString(Qt::ISODate) + : QStringLiteral("sans validUntil")); + if (nouveau) + qCInfo(dcNymeaEnergy()).noquote() << ligne; + else + qCDebug(dcNymeaEnergy()).noquote() << ligne; + return; + } + case OptimizerPlan::Issue::Invalide: + m_dernierPlanRecu.clear(); + // 7-d : un refus au moins aussi visible qu'une acceptation. Jamais appliqué en partie. + qCWarning(dcNymeaEnergy()).noquote() + << "[Optimiseur] plan REFUSÉ (GetPlan" << id << ") :" << v.code << "—" << v.detail + << "— repli rule-based"; + oublierCache(QString()); + return; + case OptimizerPlan::Issue::Abstention: + case OptimizerPlan::Issue::Expire: + m_dernierPlanRecu.clear(); + qCInfo(dcNymeaEnergy()).noquote() + << "[Optimiseur] pas de plan applicable (GetPlan" << id << ") :" << v.code << "—" << v.detail + << "— repli rule-based"; + oublierCache(QString()); + return; + } +} + +void SocketScheduler::onPlanEchoue(quint64 id, const QString &code) +{ + if (id != m_idRequete) + return; + // Le client a déjà journalisé la cause. Le plan en cache, s'il y en a un, reste valable + // jusqu'à son échéance : un échec isolé n'est pas un optimiseur mort (§7, T4). + emit echecRequete(code); +} + +void SocketScheduler::oublierCache(const QString &pourquoi) +{ + if (!m_aCache) + return; + m_aCache = false; + m_cache.clear(); + if (!pourquoi.isEmpty()) + qCInfo(dcNymeaEnergy()).noquote() << "[Optimiseur] plan en cache abandonné :" << pourquoi; +} + +void SocketScheduler::noterCycle(bool optimiseur, const QString &code, const QString &detail) +{ + // 7-c : l'issue est dite à chaque CHANGEMENT de stratégie, en clair ; le cycle ordinaire + // reste en debug — une ligne par minute noierait le reste. + const bool change = !m_cycleNote || optimiseur != m_optimiseurAuCycle || code != m_codeCycle; + m_cycleNote = true; + m_optimiseurAuCycle = optimiseur; + if (!optimiseur) { + // Au retour de l'optimiseur, ce qu'il dit se redit en clair. + m_motifVu.clear(); + m_indisponiblesVus.clear(); + } + m_codeCycle = code; + if (change) { + if (optimiseur) + qCInfo(dcNymeaEnergy()) << "[Optimiseur] stratégie du cycle : optimiseur"; + else + qCInfo(dcNymeaEnergy()).noquote() << "[Optimiseur] stratégie du cycle : rule-based (" + code + ") —" << detail; + } else { + qCDebug(dcNymeaEnergy()).noquote() << "[Optimiseur] cycle :" << (optimiseur ? QStringLiteral("optimiseur") : code); + } + emit strategieDuCycle(optimiseur, code); +} + +void SocketScheduler::signalerReserves(const QStringList &champs) +{ + for (const QString &c : champs) { + if (m_reservesSignales.contains(c)) + continue; + m_reservesSignales.insert(c); + qCWarning(dcNymeaEnergy()).noquote() + << "[Optimiseur] champ" << c << "reçu et IGNORÉ (réservé ou interdit sur le fil, §6 / annexe F)" + << "— signalé une fois par session"; + } +} + +void SocketScheduler::journaliserCreneau(const OptimizerPlan::Verdict &v, const SurplusContext &ctx) +{ + const OptimizerPlan::CreneauRecu &c = v.plan.creneaux.at(v.plan.indexCourant); + QHash libelles; + for (const LoadContext &lc : ctx.loads) + libelles.insert(lc.id, lc.label); + + // Le TEXTE de l'optimiseur vit ici, au journal — jamais dans l'action (invariant 12). Une + // ligne en clair par charge quand ce que l'optimiseur DIT change (code, paramètres retirés, + // texte) ; un plan recalculé chaque minute à l'identique ne répète rien. + QStringList lignes; + QSet vues; + for (const OptimizerPlan::ActionRecue &ar : c.actions) { + const QString ligne = ar.action.loadId + QStringLiteral(" → ") + + renderFr(ar.action.reason, libelles.value(ar.action.loadId)) + + QStringLiteral(" | reçu : ") + (ar.codeRecu.isEmpty() ? QStringLiteral("(sans code)") : ar.codeRecu) + + (ar.codeSubstitue ? QStringLiteral(" → OPTIMIZER_DECISION") : QString()) + + (ar.parametresSupprimes.isEmpty() ? QString() + : QStringLiteral(" | paramètres supprimés : ") + ar.parametresSupprimes.join(QLatin1Char(','))) + + QStringLiteral(" | « ") + ar.texte + QStringLiteral(" »"); + vues.insert(ar.action.loadId); + if (m_motifVu.value(ar.action.loadId) != ligne) { + m_motifVu.insert(ar.action.loadId, ligne); + lignes << ligne; + } + } + for (auto it = m_motifVu.begin(); it != m_motifVu.end();) + it = vues.contains(it.key()) ? std::next(it) : m_motifVu.erase(it); + + const QString entete = QStringLiteral("[Optimiseur] créneau %1 → %2 du plan %3 appliqué : %4 action(s)") + .arg(c.from.toString(Qt::ISODate), c.to.toString(Qt::ISODate), v.plan.planId).arg(c.actions.count()); + const QSet indisponibles(v.actionsSurIndisponibles.cbegin(), v.actionsSurIndisponibles.cend()); + const bool indisponiblesChangent = indisponibles != m_indisponiblesVus; + m_indisponiblesVus = indisponibles; + if (lignes.isEmpty() && !indisponiblesChangent) { + qCDebug(dcNymeaEnergy()).noquote() << entete << "(motifs inchangés)"; + return; + } + qCInfo(dcNymeaEnergy()).noquote() << entete; + for (const QString &l : lignes) + qCInfo(dcNymeaEnergy()).noquote() << "[Optimiseur] " << l; + for (const QString &id : (indisponiblesChangent ? v.actionsSurIndisponibles : QStringList())) + qCInfo(dcNymeaEnergy()).noquote() << "[Optimiseur] " << id + << ": charge INDISPONIBLE — action de l'optimiseur rejetée, " + "celle du rule-based reste"; +} diff --git a/energyplugin/etm/scheduler/socketscheduler.h b/energyplugin/etm/scheduler/socketscheduler.h new file mode 100644 index 0000000..06b0774 --- /dev/null +++ b/energyplugin/etm/scheduler/socketscheduler.h @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync +#pragma once + +#include +#include +#include +#include +#include +#include +#include "ischeduler.h" +#include "optimizerclient.h" +#include "optimizerplan.h" +#include "optimizerprotocol.h" + +/*! + * \brief Stratégie « optimiseur externe » (OPTIMIZER_PROTOCOL.md 1.1, lot 3d, brief 4 T3). + * + * N'existe QUE si un endpoint est configuré (\c Optimizer/endpoint) : sans lui, l'arbitre + * n'instancie ni cette classe ni son \c OptimizerClient, et interroge le rule-based directement. + * + * **À chaque cycle**, \c getPlan() : + * 1. appelle le rule-based — le plan OMBRE. Il tourne toujours, parce que ses effets de bord sur + * l'arbitre (actions du proxy VE, comptabilité éco, réserve batterie) sont dus à chaque cycle, + * et parce qu'il est le repli immédiat ; + * 2. envoie \c GetPlan avec le contexte du cycle, SANS ATTENDRE : la réponse arrive par signal + * et sert au cycle SUIVANT (modèle cache, règle 5) ; + * 3. si le plan en cache est valide pour maintenant (\c OptimizerPlan::valider() sur le contexte + * du cycle : créneau courant, \c validUntil, couverture Q1), substitue ses actions à celles de + * l'ombre (\c OptimizerPlan::substituer()) ; sinon rend l'ombre telle quelle. + * + * Après le dispatch, \c apresDispatch() relit ce que les adaptateurs ont appliqué (Q3, Q4). + * + * **Repli minimal de ce lot** : plan absent, invalide, expiré, abstention, connexion perdue → le + * rule-based pour le cycle. La machine §7 complète (compteur d'échecs, \c optimizerAlive, + * notification) est T4 ; ses points d'accroche sont les signaux \c reponseJugee(), + * \c echecRequete() et \c strategieDuCycle(). + * + * \invariant \c getPlan() n'attend jamais : aucune E/S n'y est bloquante. + * \invariant Le plan rendu est toujours valide : au pire, c'est celui du rule-based. + * \invariant Aucun appel à un adaptateur : les actions passent par le dispatch commun de + * l'arbitre (« un seul arbitre »), qui les écrête comme celles du rule-based. + * \invariant Le texte libre d'un optimiseur va au journal, jamais dans une action (invariant 12). + */ +class SocketScheduler : public QObject, public IScheduler +{ + Q_OBJECT +public: + //! Ce que la requête porte en plus du contexte (déclencheur excepté) — fourni par l'arbitre. + using FournisseurFil = std::function; + + /*! + * \brief Construit la stratégie et démarre le client. + * \param ombre Le rule-based, appelé à chaque cycle ; non possédé, doit survivre. + * \param client Transport vers l'optimiseur ; POSSÉDÉ (reparenté), démarré ici. + * \param fil Fournisseur du \c ContexteFil de la requête ; vide = tout inconnu. + * \param parent Propriétaire Qt. + */ + SocketScheduler(IScheduler *ombre, OptimizerClient *client, FournisseurFil fil, + QObject *parent = nullptr); + + /*! + * \brief Plan du cycle — voir la description de la classe. + * \param ctx Contexte du cycle. \return Le plan d'optimiseur substitué, ou celui de l'ombre. + */ + Plan getPlan(const SurplusContext &ctx) override; + + /*! + * \brief Après le dispatch : puissance appliquée, financement, budget et \c clamped (Q3, Q4). + * + * Sans effet si \p plan n'est pas un plan d'optimiseur. À appeler entre + * \c applyActionsToAdapters() et la fusion du délestage L4. + * \param plan Plan du cycle. \param slot Créneau dispatché. + * \param appliquees Retours d'\c applyAction(). \param delestees Charges revendiquées par L4. + * \param ctx Contexte du cycle. + */ + void apresDispatch(Plan &plan, Slot &slot, const QHash &appliquees, + const QSet &delestees, const SurplusContext &ctx); + + /*! + * \brief Note la cause de la prochaine requête (§5.1 \c trigger). + * + * Plusieurs causes entre deux requêtes : la plus significative l'emporte — + * \c intent > \c load-change > \c config > \c forecast > \c cycle. Consommée par l'envoi. + * \param trigger Une des cinq valeurs ci-dessus ; toute autre est ignorée. + */ + void noterDeclencheur(const QString &trigger); + + /*! \brief \return La cause que portera la prochaine requête. */ + QString declencheurEnAttente() const { return m_declencheur; } + + /*! \brief \return Le transport (lecture : état, capacités). */ + OptimizerClient *client() const { return m_client; } + + /*! \brief \return Vrai si un plan valide à sa réception est en cache. */ + bool aUnPlanEnCache() const { return m_aCache; } + + /*! \brief \return Vrai si le plan du dernier cycle était celui de l'optimiseur. */ + bool optimiseurAuDernierCycle() const { return m_optimiseurAuCycle; } + + /*! \brief \return Code du dernier cycle rendu au rule-based (vide si optimiseur). */ + QString dernierCodeDeRepli() const { return m_codeCycle; } + + /*! + * \brief Le dernier plan d'optimiseur appliqué, tel que reçu et converti. + * \return \c expected (par créneau) et \c diagnostics y sont CONSERVÉS pour l'app et les + * traces ; rien de ce qu'ils contiennent ne commande quoi que ce soit. + */ + OptimizerPlan::PlanRecu planApplique() const { return m_planApplique; } + +signals: + /*! + * \brief Une réponse \c GetPlan a été jugée — accroche du compteur d'échecs §7 (T4). + * \param issue \c valide | \c abstention | \c expiré | \c invalide. + * \param code Code du verdict (\c OptimizerPlan::Verdict), vide si valide. + */ + void reponseJugee(const QString &issue, const QString &code); + + /*! \brief La requête attendue n'a pas eu de réponse utilisable. \param code Code du client + * (\c timeout, \c disconnected, \c rpc-error, \c invalid-response). */ + void echecRequete(const QString &code); + + /*! \brief Stratégie retenue au cycle. \param optimiseur Vrai si le plan de l'optimiseur a + * été substitué. \param code Motif du repli, vide si optimiseur. */ + void strategieDuCycle(bool optimiseur, const QString &code); + +private: + void envoyer(const SurplusContext &ctx); + void onPlanRecu(quint64 id, const QVariantMap &result); + void onPlanEchoue(quint64 id, const QString &code); + void oublierCache(const QString &pourquoi); + void noterCycle(bool optimiseur, const QString &code, const QString &detail); + void signalerReserves(const QStringList &champs); + void journaliserCreneau(const OptimizerPlan::Verdict &v, const SurplusContext &ctx); + + IScheduler *m_ombre = nullptr; + OptimizerClient *m_client = nullptr; + FournisseurFil m_fil; + + QString m_declencheur = QStringLiteral("cycle"); + quint64 m_idRequete = 0; + SurplusContext m_ctxRequete; // contexte de la requête en vol : juge sa réponse + + bool m_aCache = false; + QVariantMap m_cache; // dernier `result` valide à sa réception, brut + + bool m_optimiseurAuCycle = false; + QString m_codeCycle = QStringLiteral("no-plan"); + bool m_cycleNote = false; + OptimizerPlan::PlanRecu m_planApplique; + QString m_dernierPlanRecu; // planId de la dernière réponse valide annoncée + QHash m_motifVu; // loadId → dernière ligne de motif annoncée + QSet m_indisponiblesVus; // charges indisponibles visées, dernier jeu annoncé + QHash m_ecretageVu; // loadId → dernier écrêtage annoncé + QSet m_reservesSignales; // une fois par session +}; diff --git a/tests/auto/auto.pro b/tests/auto/auto.pro index a709e4b..4cf9efd 100644 --- a/tests/auto/auto.pro +++ b/tests/auto/auto.pro @@ -1,2 +1,2 @@ TEMPLATE = subdirs -SUBDIRS += charging decisions loadmodel optimizerclient simulation spotmarket +SUBDIRS += charging decisions loadmodel optimizerclient simulation socketscheduler spotmarket diff --git a/tests/auto/socketscheduler/socketscheduler.pro b/tests/auto/socketscheduler/socketscheduler.pro new file mode 100644 index 0000000..9da5d33 --- /dev/null +++ b/tests/auto/socketscheduler/socketscheduler.pro @@ -0,0 +1,42 @@ +# Brief 4 T3 — la stratégie optimiseur DANS l'arbitre réel : faux optimiseur du processus +# (tests/auto/common/etm-powersync-fakeoptimizer.h), serveur nymea et mocks de la suite +# `simulation`, cycles injectés. +# +# Suite À PART, et c'est délibéré : `decisions` recompile les scénarios de `simulation` pour la +# preuve « sans endpoint = identique au bit près ». Des scénarios AVEC endpoint y ajouteraient +# des cycles à la référence ; ici ils ne la touchent pas. + +include(../common/common.pri) +include(../../../energyplugin/energyplugin.pri) +include(../../mocks/spotmarketprovider/spotmarketdataprovidermock.pri) + +CONFIG += testcase + +TARGET = etm-powersync-socketscheduler + +DEFINES += ENERGY_SIMULATION + +SIM = $$PWD/../simulation +INCLUDEPATH += $$SIM + +RESOURCES += \ + $$SIM/simulationresources.qrc + +HEADERS += \ + $$SIM/experience/energyexperienceenergymock.h \ + $$SIM/experience/energyjsonhandler.h \ + $$SIM/experience/energylogger.h \ + $$SIM/experience/energymanagermock.h \ + $$SIM/simulationbase.h \ + $$PWD/../common/etm-powersync-fakeoptimizer.h + +SOURCES += \ + $$SIM/experience/energyexperienceenergymock.cpp \ + $$SIM/experience/energyjsonhandler.cpp \ + $$SIM/experience/energylogger.cpp \ + $$SIM/experience/energymanagermock.cpp \ + $$SIM/simulationbase.cpp \ + testsocketscheduler.cpp + +target.path = $$[QT_INSTALL_PREFIX]/bin +INSTALLS += target diff --git a/tests/auto/socketscheduler/testsocketscheduler.cpp b/tests/auto/socketscheduler/testsocketscheduler.cpp new file mode 100644 index 0000000..5400a5b --- /dev/null +++ b/tests/auto/socketscheduler/testsocketscheduler.cpp @@ -0,0 +1,637 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync +// +// Brief 4 T3 — la stratégie optimiseur dans l'arbitre RÉEL : endpoint lu dans energy.conf, faux +// optimiseur du processus, cycles injectés (temps simulé pour l'arbitre ; seules les E/S de +// socket passent par la boucle d'événements). Chaque test monte son banc : un compteur, un ECS +// à un relais de 1000 W (rang 1), un routeur etmvariableload de 3000 W (rang 2). + +#include "simulationbase.h" +#include "../common/etm-powersync-fakeoptimizer.h" + +#include "energysettings.h" +#include "etm/energyarbitrator.h" +#include "etm/scheduler/socketscheduler.h" +#include "etm/types/decisionreason.h" + +#include +#include +#include + +#include + +using namespace nymeaserver; + +namespace { + +const QString kTexteLibre = QStringLiteral("Texte LIBRE de l'optimiseur, qui ne doit jamais sortir"); + +//! Capture du journal le temps d'une portée (même principe que la suite simulation). +class Journal +{ +public: + Journal() { s_courant = this; s_origine = qInstallMessageHandler(&Journal::collecte); } + ~Journal() { qInstallMessageHandler(s_origine); s_courant = nullptr; } + QString texte() const { return m_texte; } + int compte(const QString &fragment) const { return m_texte.count(fragment); } + +private: + static void collecte(QtMsgType type, const QMessageLogContext &ctx, const QString &msg) + { + if (s_courant) + s_courant->m_texte += msg + QLatin1Char('\n'); + if (s_origine) + s_origine(type, ctx, msg); + } + QString m_texte; + static Journal *s_courant; + static QtMessageHandler s_origine; +}; +Journal *Journal::s_courant = nullptr; +QtMessageHandler Journal::s_origine = nullptr; + +QHash telemetrieParId(const QVariantMap &snap) +{ + QHash h; + for (const QVariant &v : snap.value("loads").toList()) + h.insert(v.toMap().value("loadId").toString(), v.toMap()); + return h; +} + +QVariantMap action(const QString &id, const QVariantMap &champs) +{ + QVariantMap a{{"loadId", id}, {"kind", "setpoint"}, {"reason", kTexteLibre}}; + for (auto it = champs.constBegin(); it != champs.constEnd(); ++it) + a.insert(it.key(), it.value()); + return a; +} + +} // namespace + +class TestSocketScheduler : public SimulationBase +{ + Q_OBJECT + +private slots: + void testNoEndpointNoObject(); + void testValidPlanIsAppliedOnTheNextCycle_data(); + void testValidPlanIsAppliedOnTheNextCycle(); + void testRefusedPlanFallsBackToRules_data(); + void testRefusedPlanFallsBackToRules(); + void testOutOfBoundsActionIsClamped(); + void testUnavailableLoadKeepsTheShadowAction(); + void testReasonIsFilteredOnTheWayToTheApp(); + void testL4ShedsALoadTheOptimizerCommands(); + void testDegradedModeSuspendsPlanning(); + void testBudgetAndDrawAreRecomputedFromTheApplied(); + void testTheTriggerNamesTheCause(); + void testANewInvalidPlanReplacesTheCache(); + void testConnectionLossDropsThePlan(); + +private: + //! Banc commun ; \p avecOptimiseur faux = rien n'est configuré. + void monter(bool avecOptimiseur = true, FakeOptimizer::Transport transport = FakeOptimizer::Unix); + //! Un cycle à \p t, compteur à \p compteurW (négatif = export) ; attend le jugement de la + //! réponse si le faux optimiseur doit répondre. + void cycle(const QDateTime &t, double compteurW); + //! Plan d'un créneau couvrant [ts − 60 s, ts + 15 min[, valable 30 min, depuis la requête. + static QVariantMap plan(const QVariantMap &requete, const QVariantList &actions, + int validiteS = 1800); + QHash telemetrie() const { return telemetrieParId(m_arb->loadTelemetry()); } + + EnergyArbitrator *m_arb = nullptr; + Thing *m_compteur = nullptr; + QUuid m_relais; + QString m_var; + QTemporaryDir m_dir; + FakeOptimizer *m_serveur = nullptr; + QJsonObject m_handshake; + QList m_requetes; + //! Ce que le faux optimiseur répond à un GetPlan ; vide = il ne répond pas. + std::function m_repondre; + int m_jugees = 0; + QStringList m_codesJuges; +}; + +void TestSocketScheduler::monter(bool avecOptimiseur, FakeOptimizer::Transport transport) +{ + const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-socketscheduler.json"; + QFile::remove(cfgPath); + qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + m_arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); + QVERIFY(m_arb); + ThingManager *tm = NymeaCore::instance()->thingManager(); + + const QUuid meterId = addMeter(); + m_experiencePlugin->energyManager()->setRootMeter(meterId); + m_compteur = tm->findConfiguredThing(meterId); + QVERIFY(m_compteur); + m_compteur->setStateValue("connected", true); + m_relais = addPowerSwitch(1000, 26661); + const QUuid varId = addEtmVariableLoad(27043); + m_var = varId.toString(); + Thing *routeur = tm->findConfiguredThing(varId); + QVERIFY(tm->findConfiguredThing(m_relais) && routeur); + routeur->setStateValue("currentPowerW", 0); + + QVariantList l; + l.append(QVariantMap{{"id", "ecs"}, {"label", "ECS"}, {"adapter", "relay-router"}, {"mode", "fixed"}, + {"priority", 1}, {"enabled", true}, + {"relays", QVariantList() << QVariantMap{{"thingId", m_relais.toString()}, {"powerW", 1000}}}, + {"minOnS", 0}, {"minOffS", 0}}); + l.append(QVariantMap{{"id", m_var}, {"label", "Routeur"}, {"adapter", "etmvariableload"}, + {"mode", "dynamic"}, {"maxPowerW", 3000}, {"priority", 2}, {"enabled", true}}); + QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + + m_requetes.clear(); + m_repondre = nullptr; + m_jugees = 0; + m_codesJuges.clear(); + if (!avecOptimiseur) + return; + + QVERIFY(m_dir.isValid()); + delete m_serveur; + m_serveur = new FakeOptimizer(transport, m_dir.path()); + QVERIFY(m_serveur->listen()); + if (m_handshake.isEmpty()) + m_handshake = handshakeResult(); + m_serveur->responder = [this](const QJsonObject &m) { + const qint64 id = m.value("id").toInteger(); + if (m.value("method").toString() == QLatin1String("Handshake")) { + m_serveur->replyResult(id, m_handshake); + return true; + } + if (m.value("method").toString() == QLatin1String("GetPlan")) { + const QVariantMap params = m.value("params").toObject().toVariantMap(); + m_requetes.append(params); + if (m_repondre) { + const QVariantMap r = m_repondre(params); + if (!r.isEmpty()) + m_serveur->replyResult(id, QJsonObject::fromVariantMap(r)); + } + return true; + } + return false; + }; + EnergySettings().setValue("Optimizer/endpoint", m_serveur->endpoint()); + m_arb->relireReglageOptimiseur(); + SocketScheduler *s = m_arb->socketScheduler(); + QVERIFY(s); + connect(s, &SocketScheduler::reponseJugee, this, [this](const QString &, const QString &code) { + ++m_jugees; + m_codesJuges.append(code); + }); + QVERIFY(waitFor([s] { return s->client()->state() == OptimizerClient::Ready; })); +} + +void TestSocketScheduler::cycle(const QDateTime &t, double compteurW) +{ + m_compteur->setStateValue("currentPower", compteurW); + const int avant = m_jugees; + const int requetes = m_requetes.count(); + SocketScheduler *s = m_arb->socketScheduler(); + const bool envoi = s && s->client()->state() == OptimizerClient::Ready; + m_arb->recordMeterUpdate(t); + m_arb->simulationCallUpdate(t); + if (envoi) + QVERIFY2(waitFor([&] { return m_requetes.count() > requetes; }), "aucune requête GetPlan reçue"); + if (envoi && m_repondre) + QVERIFY2(waitFor([&] { return m_jugees > avant; }), "réponse du faux optimiseur jamais jugée"); + settle(); +} + +QVariantMap TestSocketScheduler::plan(const QVariantMap &requete, const QVariantList &actions, int validiteS) +{ + const QDateTime ts = QDateTime::fromString(requete.value("timestamp").toString(), Qt::ISODate); + const QVariantMap creneau{{"from", ts.addSecs(-60).toString(Qt::ISODate)}, + {"to", ts.addSecs(900).toString(Qt::ISODate)}, + {"actions", actions}, {"expected", QVariantMap{{"gridImportW", 0}}}}; + return {{"planId", "plan-test"}, {"strategy", "faux"}, + {"validUntil", ts.addSecs(validiteS).toString(Qt::ISODate)}, + {"slots", QVariantList{creneau}}, + {"diagnostics", QVariantMap{{"status", "optimal"}, {"computeMs", 3}}}}; +} + +void TestSocketScheduler::testNoEndpointNoObject() +{ + // Sans endpoint : AUCUN objet, aucune ligne, aucune connexion — la preuve structurelle de + // « identique au bit près ». La preuve comportementale est la référence `decisions`. + monter(false); + { + Journal j; + QVERIFY(!m_arb->socketScheduler()); + QVERIFY(m_arb->findChildren().isEmpty()); + QVERIFY(m_arb->findChildren().isEmpty()); + m_arb->relireReglageOptimiseur(); + QVERIFY(!m_arb->socketScheduler()); + cycle(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)), -3000); + QVERIFY2(!j.texte().contains("[Optimiseur]"), qUtf8Printable(j.texte())); + QVERIFY(j.texte().contains("[Arbitre]")); // le journal coulait bien + } + // Endpoint illisible : dit UNE fois, rien n'est construit. + { + Journal j; + EnergySettings().setValue("Optimizer/endpoint", "http://optimiseur:80"); + m_arb->relireReglageOptimiseur(); + m_arb->relireReglageOptimiseur(); + QVERIFY(!m_arb->socketScheduler()); + QVERIFY(m_arb->findChildren().isEmpty()); + QCOMPARE(j.compte("Optimizer/endpoint illisible"), 1); + } + // Revenu à « aucun endpoint » : de nouveau rien, et pas une ligne. + { + Journal j; + EnergySettings().remove("Optimizer/endpoint"); + m_arb->relireReglageOptimiseur(); + QVERIFY(!m_arb->socketScheduler()); + QVERIFY2(!j.texte().contains("[Optimiseur]"), qUtf8Printable(j.texte())); + } + // Endpoint posé puis RETIRÉ : la stratégie et son client disparaissent avec lui. + QVERIFY(m_dir.isValid()); + FakeOptimizer serveur(FakeOptimizer::Unix, m_dir.path()); + QVERIFY(serveur.listen()); + EnergySettings().setValue("Optimizer/endpoint", serveur.endpoint()); + m_arb->relireReglageOptimiseur(); + QVERIFY(m_arb->socketScheduler()); + QCOMPARE(m_arb->findChildren().count(), 1); + EnergySettings().remove("Optimizer/endpoint"); + m_arb->relireReglageOptimiseur(); + QVERIFY(!m_arb->socketScheduler()); + QVERIFY(m_arb->findChildren().isEmpty()); +} + +void TestSocketScheduler::testValidPlanIsAppliedOnTheNextCycle_data() +{ + QTest::addColumn("transport"); + QTest::newRow("unix") << int(FakeOptimizer::Unix); + QTest::newRow("tcp") << int(FakeOptimizer::Tcp); +} + +void TestSocketScheduler::testValidPlanIsAppliedOnTheNextCycle() +{ + QFETCH(int, transport); + monter(true, FakeOptimizer::Transport(transport)); + m_repondre = [this](const QVariantMap &req) { + return plan(req, {action("ecs", {{"powerW", 1000}, {"reasonCode", "SURPLUS_SETPOINT"}, + {"reasonParams", QVariantMap{{"budgetW", 3000}, {"setpointW", 1000}}}}), + action(m_var, {{"powerW", 1800}, {"reasonCode", "PRICE_SLOT"}, + {"reasonParams", QVariantMap{{"priceCtkWh", 16.96}}}})}); + }; + const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); + + // Cycle 1 : la requête part, rien n'est en cache — le rule-based décide (modèle cache). + Journal j; + cycle(t0, -3000); + QCOMPARE(m_requetes.count(), 1); + const QVariantMap req = m_requetes.first(); + QCOMPARE(req.value("trigger").toString(), QString("cycle")); + QCOMPARE(req.value("meter").toMap().value("exportW").toDouble(), 3000.0); // brut, règle 8 + QStringList ids; + for (const QVariant &l : req.value("loads").toList()) + ids << l.toMap().value("id").toString(); + QVERIFY(ids.contains("ecs") && ids.contains(m_var)); + // Le fil assemblé par l'arbitre, bout en bout (T1 l'avait laissé au premier appelant) : la + // prévision vient de l'instantané RÉEL (sans site désigné : grille, base et prix, `pv: null`), + // la mesure de chaque charge de mesurerCharge(), le compteur racine de l'EnergyManager. + const QVariantMap prevision = req.value("forecast").toMap(); + QVERIFY2(prevision.contains("resolutionS") && prevision.contains("pv"), + QJsonDocument::fromVariant(req.value("forecast")).toJson().constData()); + QVERIFY(prevision.value("pv").isNull()); + QVERIFY(!req.value("meter").toMap().value("source").toString().isEmpty()); + for (const QVariant &l : req.value("loads").toList()) + QVERIFY2(l.toMap().value("telemetry").toMap().value("measurement").toMap().contains("source"), + qUtf8Printable(l.toMap().value("id").toString())); + QVERIFY(!m_arb->socketScheduler()->optimiseurAuDernierCycle()); + QCOMPARE(m_arb->socketScheduler()->dernierCodeDeRepli(), QString("no-plan")); + const int routeurOmbre = qRound(telemetrie().value(m_var).value("allocatedW").toDouble()); + QVERIFY2(routeurOmbre != 1800, "le scénario doit distinguer l'optimiseur de l'ombre"); + QVERIFY(m_arb->socketScheduler()->aUnPlanEnCache()); + + // Cycle 2 : le plan reçu s'applique, par le dispatch commun. + cycle(t0.addSecs(60), -3000); + QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle()); + const QHash tele = telemetrie(); + const QVariantMap ecs = tele.value("ecs"), var = tele.value(m_var); + QCOMPARE(ecs.value("decision").toMap().value("code").toString(), QString(DecisionCode::SurplusSetpoint)); + QCOMPARE(ecs.value("decision").toMap().value("level").toString(), QString("optimizer")); + QCOMPARE(qRound(ecs.value("allocatedW").toDouble()), 1000); + QCOMPARE(var.value("decision").toMap().value("code").toString(), QString(DecisionCode::PriceSlot)); + QCOMPARE(var.value("decision").toMap().value("params").toMap().value("priceCtkWh").toDouble(), 16.96); + QCOMPARE(qRound(var.value("allocatedW").toDouble()), 1800); + QCOMPARE(var.value("levels").toList().count(), 1); + QCOMPARE(NymeaCore::instance()->thingManager()->findConfiguredThing(m_relais)->stateValue("power").toBool(), true); + // `expected` et `diagnostics` conservés — jamais pour commander. + QCOMPARE(m_arb->socketScheduler()->planApplique().diagnostics.value("status").toString(), QString("optimal")); + // Invariant 12 : le texte libre va au journal, JAMAIS dans la charge utile publiée. + const QByteArray publie = QJsonDocument::fromVariant(m_arb->loadTelemetry()).toJson(); + QVERIFY(!publie.contains("LIBRE")); + QVERIFY2(j.texte().contains(kTexteLibre), "le texte de l'optimiseur doit être au journal"); + QVERIFY(j.texte().contains("stratégie du cycle : optimiseur")); +} + +void TestSocketScheduler::testRefusedPlanFallsBackToRules_data() +{ + QTest::addColumn("cas"); + QTest::addColumn("codeJuge"); + QTest::addColumn("codeCycle"); + QTest::newRow("incomplet (Q1)") << "incomplet" << "incomplete-slot" << "no-plan"; + QTest::newRow("kind interdit") << "kind" << "kind-not-announced" << "no-plan"; + QTest::newRow("loadId inconnu") << "inconnu" << "unknown-load" << "no-plan"; + QTest::newRow("reason vide") << "reason" << "missing-reason" << "no-plan"; + QTest::newRow("expiré à la réception") << "expire" << "expired" << "no-plan"; + QTest::newRow("expiré à l'application") << "expire-tard" << "" << "expired"; + QTest::newRow("abstention") << "abstention" << "abstain" << "no-plan"; +} + +void TestSocketScheduler::testRefusedPlanFallsBackToRules() +{ + QFETCH(QString, cas); + QFETCH(QString, codeJuge); + QFETCH(QString, codeCycle); + m_handshake = handshakeResult(); + if (cas == "kind") // l'optimiseur n'annonce que `state` : ses consignes en watts sont interdites + m_handshake.insert("capabilities", QJsonObject{{"kinds", QJsonArray{"state"}}}); + monter(); + m_repondre = [this, cas](const QVariantMap &req) -> QVariantMap { + QVariantMap ecs = action("ecs", {{"powerW", 1000}}); + const QVariantMap var = action(m_var, {{"powerW", 1800}}); + if (cas == "abstention") + return {{"abstain", true}, {"reason", "recalcul"}}; + if (cas == "incomplet") + return plan(req, {ecs}); + if (cas == "inconnu") + ecs["loadId"] = "fantome"; + if (cas == "reason") + ecs["reason"] = ""; + if (cas == "expire") + return plan(req, {ecs, var}, -1); + if (cas == "expire-tard") + return plan(req, {ecs, var}, 30); // valable à la réception, plus au cycle suivant + return plan(req, {ecs, var}); + }; + const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); + Journal j; + cycle(t0, -3000); + QCOMPARE(m_codesJuges, QStringList{codeJuge}); + if (!codeJuge.isEmpty() && codeJuge != "abstain" && codeJuge != "expired") + QVERIFY(j.texte().contains("plan REFUSÉ")); // 7-d : le refus se voit + + cycle(t0.addSecs(60), -3000); + QVERIFY(!m_arb->socketScheduler()->optimiseurAuDernierCycle()); + QCOMPARE(m_arb->socketScheduler()->dernierCodeDeRepli(), codeCycle); + // Jamais appliqué EN PARTIE : aucune charge ne porte le niveau de l'optimiseur. + for (const QVariantMap &t : telemetrie()) + QVERIFY2(t.value("decision").toMap().value("level").toString() != "optimizer", + qUtf8Printable(t.value("loadId").toString())); +} + +void TestSocketScheduler::testOutOfBoundsActionIsClamped() +{ + monter(); + m_repondre = [this](const QVariantMap &req) { + return plan(req, {action("ecs", {{"powerW", 2500}}), action(m_var, {{"powerW", 0}})}); + }; + const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); + Journal j; + cycle(t0, -3000); + cycle(t0.addSecs(60), -3000); + // L'adaptateur écrête (un seul relais de 1000 W) : c'est l'APPLIQUÉ qui est publié. + const QVariantMap ecs = telemetrie().value("ecs"); + QCOMPARE(ecs.value("decision").toMap().value("level").toString(), QString("optimizer")); + QCOMPARE(qRound(ecs.value("allocatedW").toDouble()), 1000); + QCOMPARE(qRound(ecs.value("levels").toList().first().toMap().value("targetW").toDouble()), 1000); + QCOMPARE(j.compte("ÉCRÊTÉE par l'adaptateur — demandé 2500 W , appliqué 1000 W"), 1); + // Le même écrêtage au cycle suivant ne se répète pas en clair. + cycle(t0.addSecs(120), -3000); + QCOMPARE(j.compte("ÉCRÊTÉE"), 1); +} + +void TestSocketScheduler::testUnavailableLoadKeepsTheShadowAction() +{ + monter(); + // Le relais disparaît : l'ECS sort de l'arbitrage (LOAD_UNAVAILABLE, ECS-111). + QVERIFY(removeDevice(m_relais).toMap().value("params").toMap().value("thingError").toString() + == "ThingErrorNoError"); + m_repondre = [this](const QVariantMap &req) { + return plan(req, {action("ecs", {{"powerW", 1000}}), action(m_var, {{"powerW", 1200}})}); + }; + const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); + Journal j; + cycle(t0, -3000); + QCOMPARE(m_codesJuges, QStringList{QString()}); // valide : Q1 n'exige que les charges disponibles + cycle(t0.addSecs(60), -3000); + QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle()); + const QHash tele = telemetrie(); + QCOMPARE(tele.value("ecs").value("decision").toMap().value("code").toString(), + QString(DecisionCode::LoadUnavailable)); + QCOMPARE(tele.value(m_var).value("decision").toMap().value("level").toString(), QString("optimizer")); + QCOMPARE(qRound(tele.value(m_var).value("allocatedW").toDouble()), 1200); + QVERIFY(j.texte().contains("ecs : charge INDISPONIBLE — action de l'optimiseur rejetée")); + // Q1 n'exige QUE les charges disponibles : un plan qui ne nomme pas l'ECS en panne est valide. + m_repondre = [this](const QVariantMap &req) { return plan(req, {action(m_var, {{"powerW", 1200}})}); }; + cycle(t0.addSecs(120), -3000); + QCOMPARE(m_codesJuges.last(), QString()); + cycle(t0.addSecs(180), -3000); + QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle()); +} + +void TestSocketScheduler::testReasonIsFilteredOnTheWayToTheApp() +{ + monter(); + m_repondre = [this](const QVariantMap &req) { + return plan(req, {action("ecs", {{"powerW", 1000}, {"reasonCode", "PRICE_SLOT"}, + {"reasonParams", QVariantMap{{"priceCtkWh", 12.5}, + {"rang", 3}, // non documentée + {"setpointW", "mille watts"}}}}), + action(m_var, {{"powerW", 500}, {"reasonCode", "DEGRADED_L2"}, + {"reasonParams", QVariantMap{{"x", 1}}}})}); + }; + const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); + Journal j; + cycle(t0, -3000); + cycle(t0.addSecs(60), -3000); + const QHash tele = telemetrie(); + const QVariantMap ecs = tele.value("ecs").value("decision").toMap(); + QCOMPARE(ecs.value("code").toString(), QString(DecisionCode::PriceSlot)); + QCOMPARE(ecs.value("params").toMap(), (QVariantMap{{"priceCtkWh", 12.5}})); + // Hors liste blanche : un optimiseur ne parle pas au nom de la sécurité. + const QVariantMap var = tele.value(m_var).value("decision").toMap(); + QCOMPARE(var.value("code").toString(), QString(DecisionCode::OptimizerDecision)); + QVERIFY(var.value("params").toMap().isEmpty()); + // Une clé non documentée (même numérique) et une valeur texte : supprimées, et dites. + QVERIFY(j.texte().contains("paramètres supprimés : rang,setpointW")); + QVERIFY(j.texte().contains("reçu : DEGRADED_L2 → OPTIMIZER_DECISION")); + QVERIFY(!QJsonDocument::fromVariant(m_arb->loadTelemetry()).toJson().contains("mille watts")); +} + +void TestSocketScheduler::testL4ShedsALoadTheOptimizerCommands() +{ + monter(); + QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 25}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + m_repondre = [this](const QVariantMap &req) { + return plan(req, {action("ecs", {{"powerW", 1000}}), action(m_var, {{"powerW", 0}})}); + }; + for (const char *p : {"currentPhaseA", "currentPhaseB", "currentPhaseC"}) + m_compteur->setStateValue(p, 0); + const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); + cycle(t0, -3000); + cycle(t0.addSecs(60), -3000); + Thing *relais = NymeaCore::instance()->thingManager()->findConfiguredThing(m_relais); + QCOMPARE(relais->stateValue("power").toBool(), true); // l'optimiseur l'a enclenché + + // Surcharge : L4 déleste, et il a le dernier mot sur le plan de l'optimiseur, toujours en cache. + m_compteur->setStateValue("currentPhaseA", 30); + cycle(t0.addSecs(70), 6900); + QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle()); + QCOMPARE(relais->stateValue("power").toBool(), false); + QCOMPARE(telemetrie().value("ecs").value("decision").toMap().value("code").toString(), + QString(DecisionCode::DrawCap)); +} + +void TestSocketScheduler::testDegradedModeSuspendsPlanning() +{ + monter(); + m_repondre = [this](const QVariantMap &req) { + return plan(req, {action("ecs", {{"powerW", 1000}}), action(m_var, {{"powerW", 500}})}); + }; + const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); + cycle(t0, -3000); + cycle(t0.addSecs(60), -3000); + QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle()); + const int requetes = m_requetes.count(); + + // Compteur muet > 90 s : mode dégradé L2, la planification est suspendue — AUCUNE stratégie, + // ni requête ni application, quel que soit le plan en cache. + m_arb->evaluateMeterFreshness(t0.addSecs(200)); + QVERIFY(m_arb->degradedMode()); + m_arb->simulationCallUpdate(t0.addSecs(240)); + settle(200); + QCOMPARE(m_requetes.count(), requetes); + QCOMPARE(NymeaCore::instance()->thingManager()->findConfiguredThing(m_relais)->stateValue("power").toBool(), + false); // le repli L2 a coupé l'ECS, et personne ne l'a rallumé +} + +void TestSocketScheduler::testBudgetAndDrawAreRecomputedFromTheApplied() +{ + monter(); + m_repondre = [this](const QVariantMap &req) { + return plan(req, {action("ecs", {{"powerW", 1000}}), action(m_var, {{"powerW", 1500}})}); + }; + const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); + cycle(t0, -500); + const QVariantMap drawOmbre = m_arb->loadTelemetry().value("draw").toMap(); + cycle(t0.addSecs(60), -500); + QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle()); + + const QVariantMap snap = m_arb->loadTelemetry(); + const QVariantMap budget = snap.value("budget").toMap(), draw = snap.value("draw").toMap(); + // Q4 — la mesure, puis ce qui a été APPLIQUÉ : 2500 W appliqués sur 500 W d'export. + QCOMPARE(budget.value("surplusW").toDouble(), 500.0); + QCOMPARE(budget.value("evReservedW").toDouble(), 0.0); + const double surplus = budget.value("surplusW").toDouble() + budget.value("recreditedW").toDouble(); + QCOMPARE(qRound(surplus - budget.value("allocatedW").toDouble()), qRound(budget.value("remainingW").toDouble())); + QCOMPARE(qRound(budget.value("allocatedW").toDouble() + draw.value("committedW").toDouble()), 2500); + QCOMPARE(qRound(budget.value("allocatedW").toDouble()), qRound(qMin(surplus, 2500.0))); + QVERIFY(draw.value("committedW").toDouble() > 0); + QVERIFY2(qRound(drawOmbre.value("committedW").toDouble()) == 0, "l'ombre n'achetait rien"); + // Les registres se réconcilient par charge : Σ counts == budget.allocatedW et draw.committedW. + double alloue = 0, achete = 0; + for (const QVariantMap &t : telemetrie()) { + for (const QVariant &n : t.value("levels").toList()) { + const QVariantMap c = n.toMap().value("counts").toMap(); + alloue += c.value(CountKey::BudgetAllocated).toDouble(); + achete += c.value(CountKey::DrawCommitted).toDouble(); + } + // Q3 — financement dérivé de la mesure : ici l'export ne couvre pas, le reliquat est acheté. + QCOMPARE(t.value("levels").toList().count(), 1); + } + QCOMPARE(qRound(alloue), qRound(budget.value("allocatedW").toDouble())); + QCOMPARE(qRound(achete), qRound(draw.value("committedW").toDouble())); + QCOMPARE(telemetrie().value(m_var).value("levels").toList().first().toMap().value("funding").toString(), + QString("grid")); +} + +void TestSocketScheduler::testTheTriggerNamesTheCause() +{ + monter(); + const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); + cycle(t0, -3000); + QCOMPARE(m_requetes.last().value("trigger").toString(), QString("cycle")); + + // Une configuration réécrite (SetLoadConfig → reconstruction) : `config`. + const QVariantList cfg = injectAndWait("NymeaEnergy.GetLoadConfig").toMap().value("params").toMap() + .value("loadConfigs").toList(); + QVERIFY(!cfg.isEmpty()); + QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", cfg}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + cycle(t0.addSecs(60), -3000); + QCOMPARE(m_requetes.last().value("trigger").toString(), QString("config")); + // Consommé : le cycle suivant redevient `cycle`. + cycle(t0.addSecs(120), -3000); + QCOMPARE(m_requetes.last().value("trigger").toString(), QString("cycle")); + + // Un Thing ajouté : `load-change`. + addPowerSwitch(500, 26662); + cycle(t0.addSecs(180), -3000); + QCOMPARE(m_requetes.last().value("trigger").toString(), QString("load-change")); + + // Plusieurs causes : la plus significative l'emporte. + m_arb->socketScheduler()->noterDeclencheur("forecast"); + m_arb->socketScheduler()->noterDeclencheur("intent"); + m_arb->socketScheduler()->noterDeclencheur("config"); + cycle(t0.addSecs(240), -3000); + QCOMPARE(m_requetes.last().value("trigger").toString(), QString("intent")); +} + +void TestSocketScheduler::testANewInvalidPlanReplacesTheCache() +{ + // Un plan valide est en cache ; la réponse suivante est invalide. Elle ne s'applique pas, et + // l'ancien non plus : un optimiseur qui vient de répondre faux n'est pas cru sur sa réponse + // d'avant (§7 — le compteur d'échecs viendra en T4, le repli est immédiat). + monter(); + m_repondre = [this](const QVariantMap &req) { + return plan(req, {action("ecs", {{"powerW", 1000}}), action(m_var, {{"powerW", 500}})}); + }; + const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); + cycle(t0, -3000); + m_repondre = [this](const QVariantMap &req) { return plan(req, {action("ecs", {{"powerW", 1000}})}); }; + cycle(t0.addSecs(60), -3000); + QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle()); // le premier plan, appliqué + QCOMPARE(m_codesJuges.last(), QString("incomplete-slot")); + cycle(t0.addSecs(120), -3000); + QVERIFY(!m_arb->socketScheduler()->optimiseurAuDernierCycle()); + QCOMPARE(m_arb->socketScheduler()->dernierCodeDeRepli(), QString("no-plan")); +} + +void TestSocketScheduler::testConnectionLossDropsThePlan() +{ + // §7 « socket absent / fermé → rules » : un plan ne survit pas à la connexion qui l'a porté. + monter(); + m_repondre = [this](const QVariantMap &req) { + return plan(req, {action("ecs", {{"powerW", 1000}}), action(m_var, {{"powerW", 500}})}); + }; + const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); + cycle(t0, -3000); + cycle(t0.addSecs(60), -3000); + QVERIFY(m_arb->socketScheduler()->optimiseurAuDernierCycle()); + m_serveur->responder = nullptr; // plus de handshake : la connexion ne revient pas + m_serveur->abortClient(); + SocketScheduler *s = m_arb->socketScheduler(); + QVERIFY(waitFor([s] { return s->client()->state() != OptimizerClient::Ready; })); + QVERIFY(!s->aUnPlanEnCache()); + cycle(t0.addSecs(120), -3000); + QVERIFY(!s->optimiseurAuDernierCycle()); + QCOMPARE(s->dernierCodeDeRepli(), QString("no-plan")); +} + +QTEST_MAIN(TestSocketScheduler) +#include "testsocketscheduler.moc"