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:
parent
a8c58d02f0
commit
ff7c8011e2
@ -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 ¤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<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;
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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\\\"
|
||||
|
||||
270
energyplugin/etm/scheduler/socketscheduler.cpp
Normal file
270
energyplugin/etm/scheduler/socketscheduler.cpp
Normal 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";
|
||||
}
|
||||
156
energyplugin/etm/scheduler/socketscheduler.h
Normal file
156
energyplugin/etm/scheduler/socketscheduler.h
Normal 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
|
||||
};
|
||||
@ -1,2 +1,2 @@
|
||||
TEMPLATE = subdirs
|
||||
SUBDIRS += charging decisions loadmodel optimizerclient simulation spotmarket
|
||||
SUBDIRS += charging decisions loadmodel optimizerclient simulation socketscheduler spotmarket
|
||||
|
||||
42
tests/auto/socketscheduler/socketscheduler.pro
Normal file
42
tests/auto/socketscheduler/socketscheduler.pro
Normal 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
|
||||
637
tests/auto/socketscheduler/testsocketscheduler.cpp
Normal file
637
tests/auto/socketscheduler/testsocketscheduler.cpp
Normal 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"
|
||||
Loading…
x
Reference in New Issue
Block a user