feat(optimiseur): SocketScheduler — le plan d'un optimiseur appliqué par le chemin commun (brief 4 T3)

Réglage `Optimizer/endpoint` (energy.conf), lu au démarrage par
EnergyArbitrator::relireReglageOptimiseur() — lecture seule, la RPC viendra
en T4. ABSENT : aucun SocketScheduler ni OptimizerClient n'existe, update()
interroge le rule-based comme avant (decisions IDENTIQUE, 23 772 lignes).

SocketScheduler (IScheduler) : à chaque cycle, le rule-based en OMBRE (effets
de bord conservés), puis GetPlan envoyé sans attendre (getPlanParams + fil de
l'arbitre : compteur racine, PV, phases, réserve, domaines, mesures,
prévision réelle) ; si le plan en cache est valide pour CE cycle, ses actions
remplacent celles de l'ombre (OptimizerPlan::substituer). Sinon rule-based :
plan absent, invalide, expiré, abstention, connexion perdue. Un plan invalide
vide le cache. Les actions passent par applyActionsToAdapters comme celles du
rule-based ; apresDispatch() relit l'appliqué (Q3, Q4, clamped), avant la
fusion L4. L4 et L2 inchangés : L4 garde le dernier mot, L2 suspend tout.

Déclencheur réel (§5.1 trigger) : intent (ChargingInfo), load-change (Thing
ajouté/retiré), config (SetLoadConfig, limites, réserve), forecast (révision
nouvelle) ; le plus significatif l'emporte, consommé à l'envoi.

Journal : le texte libre de l'optimiseur y va (assaini), jamais dans la
télémétrie ; lignes en clair au changement seulement (stratégie, motifs,
écrêtage). Version du moteur du handshake lue dans debian/changelog.

Points d'accroche T4 : reponseJugee(), echecRequete(), strategieDuCycle().
INTERFACE.md : niveau unique « optimizer », budget/draw sous l'optimiseur,
préséance L4 > indisponible/dégradé > optimiseur > éco|confort.

Tests : suite etm-powersync-socketscheduler (12 fonctions, 43 passés) —
faux optimiseur unix et tcp dans le processus, arbitre réel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-16 22:06:45 +02:00
parent a8c58d02f0
commit ff7c8011e2
8 changed files with 1263 additions and 2 deletions

View File

@ -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<QObject *>(a))
@ -498,6 +507,15 @@ void EnergyArbitrator::update(const QDateTime &currentDateTime)
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<QString>(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;

View File

@ -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<QString, EvAdapter *> m_adapters; //!< loadId (ThingId string) → EvAdapter*.
/*!
* \brief [3g-2] Bornes commandées par le waterfall AU CYCLE COURANT.

View File

@ -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\\\"

View File

@ -0,0 +1,270 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#include "socketscheduler.h"
#include <QLoggingCategory>
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<QString, LoadAction> &appliquees,
const QSet<QString> &delestees, const SurplusContext &ctx)
{
if (plan.strategy != QLatin1String(OptimizerPlan::StrategieOptimiseur))
return;
const QList<OptimizerPlan::Ecretage> ecretages =
OptimizerPlan::finaliser(plan, slot, appliquees, delestees, ctx);
QSet<QString> 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<QString, QString> 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<QString> 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<QString> 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";
}

View File

@ -0,0 +1,156 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QHash>
#include <QObject>
#include <QSet>
#include <QString>
#include <QVariantMap>
#include <functional>
#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<OptimizerProtocol::ContexteFil(const SurplusContext &)>;
/*!
* \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<QString, LoadAction> &appliquees,
const QSet<QString> &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<QString, QString> m_motifVu; // loadId → dernière ligne de motif annoncée
QSet<QString> m_indisponiblesVus; // charges indisponibles visées, dernier jeu annoncé
QHash<QString, QString> m_ecretageVu; // loadId → dernier écrêtage annoncé
QSet<QString> m_reservesSignales; // une fois par session
};

View File

@ -1,2 +1,2 @@
TEMPLATE = subdirs
SUBDIRS += charging decisions loadmodel optimizerclient simulation spotmarket
SUBDIRS += charging decisions loadmodel optimizerclient simulation socketscheduler spotmarket

View File

@ -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

View File

@ -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 <QJsonDocument>
#include <QSignalSpy>
#include <QTemporaryDir>
#include <experiences/experiencemanager.h>
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<QString, QVariantMap> telemetrieParId(const QVariantMap &snap)
{
QHash<QString, QVariantMap> 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<QString, QVariantMap> 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<QVariantMap> m_requetes;
//! Ce que le faux optimiseur répond à un GetPlan ; vide = il ne répond pas.
std::function<QVariantMap(const QVariantMap &)> 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<EnergyArbitrator *>(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<OptimizerClient *>().isEmpty());
QVERIFY(m_arb->findChildren<SocketScheduler *>().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<OptimizerClient *>().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<OptimizerClient *>().count(), 1);
EnergySettings().remove("Optimizer/endpoint");
m_arb->relireReglageOptimiseur();
QVERIFY(!m_arb->socketScheduler());
QVERIFY(m_arb->findChildren<OptimizerClient *>().isEmpty());
}
void TestSocketScheduler::testValidPlanIsAppliedOnTheNextCycle_data()
{
QTest::addColumn<int>("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<QString, QVariantMap> 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<QString>("cas");
QTest::addColumn<QString>("codeJuge");
QTest::addColumn<QString>("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<QString, QVariantMap> 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<QString, QVariantMap> 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"