feat(optimiseur): validateur pur du plan d'un optimiseur — OptimizerPlan (brief 4 T3)

Fonctions pures (ni E/S ni horloge, « maintenant » = ctx.timestamp) qui
portent toute la sémantique du plan reçu ; le SocketScheduler les enchaînera.

- valider() : JSON → plan interne ou verdict à code stable. Créneaux à
  décalage horaire explicite, ordonnés, sans chevauchement ; loadId connu et
  unique ; kind cohérent avec le mécanisme et annoncé dans capabilities.kinds ;
  reason non vide ; valeurs dans leur domaine ; validUntil ; créneau courant ;
  COUVERTURE Q1 (toute charge disponible nommée). Abstention / expiré /
  invalide distingués ; un plan invalide n'est jamais converti en partie.
  `force` et champs réservés (annexe F) ignorés et listés.
- motifFiltre() : code hors liste blanche → OPTIMIZER_DECISION sans
  paramètres ; sinon seules les clés documentées (optimizerParamKeys, testé
  contre le tableau d'INTERFACE.md), valeurs nombre/booléen/code [A-Za-z]+.
  Le texte libre reste dans ActionRecue::texte, assaini, jamais dans le motif.
- substituer() : le créneau courant remplace les actions de l'ombre à leur
  place ; charges indisponibles → action de l'ombre ; bornes commandées
  sorties du proxy (waterfallEvIds) ; budget/draw de l'ombre jamais gardés.
- finaliser() : après dispatch, puissance APPLIQUÉE, niveau unique
  « optimizer », financement dérivé de la mesure (surplus puis réseau),
  counts cohérents, budget et draw recalculés (Q3, Q4), `clamped`.

LoadAction::clamped ajouté (interne, non publié dans ce lot).
Aucun appelant dans ce commit : le moteur est inchangé.

Tests (loadmodel) : 7 fonctions, dont 31 verdicts data-driven.

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

View File

@ -19,6 +19,7 @@ HEADERS += \
$$PWD/scheduler/rulebasedscheduler.h \
$$PWD/scheduler/optimizerprotocol.h \
$$PWD/scheduler/optimizerclient.h \
$$PWD/scheduler/optimizerplan.h \
$$PWD/ratios/energyratioscalculator.h \
$$PWD/energyarbitrator.h \
$$PWD/spotmarketbuild.h \
@ -38,5 +39,6 @@ SOURCES += \
$$PWD/scheduler/rulebasedscheduler.cpp \
$$PWD/scheduler/optimizerprotocol.cpp \
$$PWD/scheduler/optimizerclient.cpp \
$$PWD/scheduler/optimizerplan.cpp \
$$PWD/energyarbitrator.cpp \
$$PWD/spotmarketbuild.cpp \

View File

@ -0,0 +1,549 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#include "optimizerplan.h"
#include "../types/decisionreason.h"
#include <QRegularExpression>
#include <algorithm>
#include <cmath>
namespace {
// Champs réservés de l'annexe F, et le contournement de verrou du §6 : reçus, ignorés, listés.
const QStringList kChampsReservesAction{QStringLiteral("force"), QStringLiteral("source"),
QStringLiteral("charge"), QStringLiteral("discharge")};
const QStringList kChampsReservesExpected{QStringLiteral("batterySocPercent")};
// Tension par phase de la conversion ampères → watts, la même que le rule-based et l'EvAdapter.
constexpr double kTensionV = 230.0;
bool estChaine(const QVariant &v) { return v.userType() == QMetaType::QString; }
bool lireNombre(const QVariant &v, double *out)
{
switch (v.userType()) {
case QMetaType::Double: case QMetaType::Float: case QMetaType::Int: case QMetaType::UInt:
case QMetaType::LongLong: case QMetaType::ULongLong:
break;
default:
return false; // un booléen ou une chaîne « 12 » n'est pas un nombre
}
const double d = v.toDouble();
if (!std::isfinite(d))
return false;
*out = d;
return true;
}
// Instant AVEC son décalage : une heure murale nue serait interprétée dans le fuseau de la box,
// qui n'est pas forcément celui de l'optimiseur (AGENTS.md, « un INSTANT est absolu »).
bool lireInstant(const QVariant &v, QDateTime *out)
{
if (!estChaine(v))
return false;
const QDateTime dt = QDateTime::fromString(v.toString(), Qt::ISODateWithMs);
if (!dt.isValid() || dt.timeSpec() == Qt::LocalTime)
return false;
*out = dt;
return true;
}
bool valeurAdmise(const QVariant &v)
{
static const QRegularExpression code(QStringLiteral("^[A-Za-z]+$"));
double d;
if (v.userType() == QMetaType::Bool || lireNombre(v, &d))
return true;
return estChaine(v) && code.match(v.toString()).hasMatch();
}
// Le kind que le mécanisme accepte. Vide : aucun (batterie, réservée à l'annexe F).
QString kindDuMecanisme(const QString &adapter)
{
if (adapter == QLatin1String("evcharger") || adapter == QLatin1String("relay-router")
|| adapter == QLatin1String("etmvariableload"))
return QStringLiteral("setpoint");
if (adapter == QLatin1String("sg-ready"))
return QStringLiteral("state");
return QString();
}
OptimizerPlan::Verdict refus(OptimizerPlan::Issue issue, const QString &code, const QString &detail,
const QStringList &reserves = {})
{
OptimizerPlan::Verdict v;
v.issue = issue;
v.code = code;
v.detail = detail;
v.champsReserves = reserves;
return v;
}
// Une action reçue → interne. Rend un code d'erreur, vide si l'action est recevable.
QString convertirAction(const QVariant &brut, const QHash<QString, const LoadContext *> &charges,
const QStringList *kindsAnnonces, OptimizerPlan::ActionRecue *out,
QString *detail, QStringList *reserves)
{
if (brut.userType() != QMetaType::QVariantMap) {
*detail = QStringLiteral("action qui n'est pas un objet");
return QStringLiteral("malformed-action");
}
const QVariantMap a = brut.toMap();
const QVariant idv = a.value(QStringLiteral("loadId"));
if (!estChaine(idv) || idv.toString().isEmpty()) {
*detail = QStringLiteral("action sans loadId");
return QStringLiteral("malformed-action");
}
const QString id = idv.toString();
const LoadContext *lc = charges.value(id, nullptr);
if (!lc) {
*detail = QStringLiteral("charge %1 inconnue du contexte").arg(id);
return QStringLiteral("unknown-load");
}
const QString kind = a.value(QStringLiteral("kind")).toString();
if (!estChaine(a.value(QStringLiteral("kind")))
|| (kind != QLatin1String("setpoint") && kind != QLatin1String("state")
&& kind != QLatin1String("constraint"))) {
*detail = QStringLiteral("%1 : kind « %2 » inconnu").arg(id, kind);
return QStringLiteral("unknown-kind");
}
const QString attendu = kindDuMecanisme(lc->adapter);
if (kind != attendu) {
*detail = QStringLiteral("%1 : kind %2 sur un mécanisme %3").arg(id, kind, lc->adapter);
return QStringLiteral("kind-mismatch");
}
if (kindsAnnonces && !kindsAnnonces->contains(kind)) {
*detail = QStringLiteral("%1 : kind %2 non annoncé dans capabilities").arg(id, kind);
return QStringLiteral("kind-not-announced");
}
out->texte = OptimizerPlan::assainirTexte(a.value(QStringLiteral("reason")));
if (out->texte.isEmpty()) {
*detail = QStringLiteral("%1 : reason absent ou vide").arg(id);
return QStringLiteral("missing-reason");
}
for (const QString &champ : kChampsReservesAction)
if (a.contains(champ))
reserves->append(QStringLiteral("action.") + champ);
LoadAction la;
la.loadId = id;
la.force = false; // §6 : le contournement de verrou n'existe pas sur le fil
if (attendu == QLatin1String("state")) {
double s = 0;
if (!lireNombre(a.value(QStringLiteral("state")), &s) || s != std::floor(s) || s < 1 || s > 4) {
*detail = QStringLiteral("%1 : state absent ou hors 1-4").arg(id);
return QStringLiteral("bad-value");
}
la.kind = LoadAction::State;
la.state = int(s);
la.estimatedPowerW = lc->declared.estimatedPowerW.value(la.state, 0.0);
} else if (lc->adapter == QLatin1String("evcharger")) {
// E13 : `currentA` (0 = suspendre) ou `powerW`, que l'adaptateur convertit ; `phases`
// optionnel (absent = inchangé).
const bool aCourant = a.contains(QStringLiteral("currentA"));
const bool aPuissance = a.contains(QStringLiteral("powerW"));
double courantA = 0, puissanceW = 0, phases = 0;
const bool ok = (aCourant || aPuissance)
&& (!aCourant || (lireNombre(a.value(QStringLiteral("currentA")), &courantA) && courantA >= 0))
&& (!aPuissance || (lireNombre(a.value(QStringLiteral("powerW")), &puissanceW) && puissanceW >= 0))
&& (!a.contains(QStringLiteral("phases"))
|| (lireNombre(a.value(QStringLiteral("phases")), &phases) && (phases == 1 || phases == 3)));
if (!ok) {
*detail = QStringLiteral("%1 : currentA/powerW absents ou invalides, ou phases hors {1, 3}").arg(id);
return QStringLiteral("bad-value");
}
la.kind = LoadAction::Setpoint;
la.currentA = courantA;
la.powerW = puissanceW;
la.phaseCount = uint(phases);
la.chargingEnabled = aCourant ? courantA > 0 : puissanceW > 0;
const double ph = phases > 0 ? phases : (lc->declared.phases > 0 ? lc->declared.phases : 1);
la.estimatedPowerW = !la.chargingEnabled ? 0.0
: courantA > 0 ? courantA * kTensionV * ph : puissanceW;
} else {
double puissanceW = 0;
if (!lireNombre(a.value(QStringLiteral("powerW")), &puissanceW) || puissanceW < 0) {
*detail = QStringLiteral("%1 : powerW absent ou négatif").arg(id);
return QStringLiteral("bad-value");
}
la.kind = LoadAction::Setpoint;
la.powerW = puissanceW;
la.estimatedPowerW = puissanceW;
la.chargingEnabled = puissanceW > 0;
}
const QVariant code = a.value(QStringLiteral("reasonCode"));
out->codeRecu = estChaine(code) ? code.toString() : QString();
const QVariant params = a.value(QStringLiteral("reasonParams"));
if (params.isValid() && !params.isNull() && params.userType() != QMetaType::QVariantMap)
out->parametresSupprimes.append(QStringLiteral("reasonParams"));
la.reason = OptimizerPlan::motifFiltre(out->codeRecu, params.toMap(),
&out->parametresSupprimes, &out->codeSubstitue);
// Niveau UNIQUE et provisoire : finaliser() le réécrit sur la puissance appliquée (Q3).
LoadAction::Level niveau;
niveau.level = QString::fromLatin1(OptimizerPlan::NiveauOptimiseur);
niveau.targetW = la.estimatedPowerW;
niveau.funding = LoadAction::Surplus;
niveau.counts.insert(QString::fromLatin1(CountKey::BudgetAllocated), la.estimatedPowerW);
niveau.reason = la.reason;
la.funding = niveau.funding;
la.counts = niveau.counts;
la.levels = {niveau};
out->action = la;
out->adapter = lc->adapter;
return QString();
}
QString decrire(const LoadAction &a, const QString &adapter)
{
if (a.kind == LoadAction::State)
return QStringLiteral("état %1").arg(a.state);
if (adapter == QLatin1String("evcharger")) {
if (!a.chargingEnabled)
return QStringLiteral("suspendue");
if (a.currentA > 0)
return QStringLiteral("%1 A").arg(a.currentA)
+ (a.phaseCount > 0 ? QStringLiteral(" ×%1").arg(a.phaseCount) : QString());
return QStringLiteral("%1 W").arg(qRound(a.powerW));
}
return QStringLiteral("%1 W").arg(qRound(a.powerW));
}
// L'appliqué diffère-t-il de la demande ? Comparé dans l'unité de la demande.
bool differe(const LoadAction &demande, const LoadAction &applique, const QString &adapter)
{
if (demande.kind == LoadAction::State)
return applique.state != demande.state;
if (adapter == QLatin1String("evcharger")) {
if (applique.chargingEnabled != demande.chargingEnabled)
return true;
if (!demande.chargingEnabled)
return false;
if (demande.phaseCount > 0 && applique.phaseCount != demande.phaseCount)
return true;
if (demande.currentA > 0)
return qRound(applique.currentA) != qRound(demande.currentA);
// Demande en watts : l'adaptateur arrondit à l'ampère le plus proche, soit au plus une
// demi-intensité par phase. Au-delà, c'est un écrêtage (plancher, plafond).
return qAbs(applique.estimatedPowerW - demande.powerW)
> kTensionV / 2.0 * qMax(1u, applique.phaseCount);
}
return qAbs(applique.powerW - demande.powerW) >= 1.0;
}
} // namespace
namespace OptimizerPlan {
QString assainirTexte(const QVariant &v)
{
if (!estChaine(v))
return QString();
QString t = v.toString();
for (QChar &c : t)
if (c.category() == QChar::Other_Control || c.category() == QChar::Separator_Line
|| c.category() == QChar::Separator_Paragraph || c.category() == QChar::Other_Format)
c = QLatin1Char(' ');
t = t.simplified();
if (t.size() > TexteMax)
t = t.left(TexteMax) + QStringLiteral("…");
return t;
}
DecisionReason motifFiltre(const QString &code, const QVariantMap &params, QStringList *supprimes,
bool *substitue)
{
DecisionReason r;
if (!DecisionCode::optimizerMayEmit(code)) {
if (substitue)
*substitue = true;
if (supprimes)
supprimes->append(params.keys());
r.code = QString::fromLatin1(DecisionCode::OptimizerDecision);
return r;
}
if (substitue)
*substitue = false;
const QStringList admises = DecisionCode::optimizerParamKeys(code);
for (auto it = params.constBegin(); it != params.constEnd(); ++it) {
if (admises.contains(it.key()) && valeurAdmise(it.value()))
r.params.insert(it.key(), it.value());
else if (supprimes)
supprimes->append(it.key());
}
r.code = code;
return r;
}
Verdict valider(const QVariantMap &result, const SurplusContext &ctx,
const QVariantMap &capabilities, bool hasCapabilities)
{
const QDateTime maintenant = ctx.timestamp;
if (result.value(QStringLiteral("abstain")).toBool())
return refus(Issue::Abstention, QStringLiteral("abstain"),
QStringLiteral("abstention : %1").arg(assainirTexte(result.value(QStringLiteral("reason")))));
const QVariant creneauxBruts = result.value(QStringLiteral("slots"));
if (creneauxBruts.userType() != QMetaType::QVariantList)
return refus(Issue::Invalide, QStringLiteral("malformed-plan"), QStringLiteral("slots absent ou non liste"));
if (creneauxBruts.toList().isEmpty())
return refus(Issue::Abstention, QStringLiteral("empty-plan"), QStringLiteral("plan sans créneau"));
QHash<QString, const LoadContext *> charges;
for (const LoadContext &lc : ctx.loads)
charges.insert(lc.id, &lc);
// §3 : la restriction ne vaut que si le handshake a ANNONCÉ des kinds.
QStringList kinds;
const bool restreint = hasCapabilities && capabilities.contains(QStringLiteral("kinds"));
if (restreint)
for (const QVariant &k : capabilities.value(QStringLiteral("kinds")).toList())
kinds.append(k.toString());
Verdict v;
QStringList reserves;
PlanRecu &plan = v.plan;
plan.planId = result.value(QStringLiteral("planId")).toString();
plan.strategy = result.value(QStringLiteral("strategy")).toString();
lireInstant(result.value(QStringLiteral("computedAt")), &plan.computedAt);
if (result.value(QStringLiteral("diagnostics")).userType() == QMetaType::QVariantMap)
plan.diagnostics = result.value(QStringLiteral("diagnostics")).toMap();
const QVariantList brut = creneauxBruts.toList();
for (int i = 0; i < brut.size(); ++i) {
if (brut.at(i).userType() != QMetaType::QVariantMap)
return refus(Issue::Invalide, QStringLiteral("malformed-plan"),
QStringLiteral("créneau %1 qui n'est pas un objet").arg(i), reserves);
const QVariantMap s = brut.at(i).toMap();
CreneauRecu c;
if (!lireInstant(s.value(QStringLiteral("from")), &c.from)
|| !lireInstant(s.value(QStringLiteral("to")), &c.to) || c.from >= c.to)
return refus(Issue::Invalide, QStringLiteral("bad-time"),
QStringLiteral("créneau %1 : from/to illisibles, sans décalage, ou from ≥ to").arg(i),
reserves);
if (!plan.creneaux.isEmpty() && c.from < plan.creneaux.last().to)
return refus(Issue::Invalide, QStringLiteral("slots-unordered"),
QStringLiteral("créneau %1 : commence avant la fin du précédent").arg(i), reserves);
const QVariant actions = s.value(QStringLiteral("actions"));
if (actions.isValid() && !actions.isNull() && actions.userType() != QMetaType::QVariantList)
return refus(Issue::Invalide, QStringLiteral("malformed-plan"),
QStringLiteral("créneau %1 : actions non liste").arg(i), reserves);
QSet<QString> vues;
for (const QVariant &a : actions.toList()) {
ActionRecue ar;
QString detail;
const QString erreur = convertirAction(a, charges, restreint ? &kinds : nullptr, &ar,
&detail, &reserves);
if (!erreur.isEmpty())
return refus(Issue::Invalide, erreur,
QStringLiteral("créneau %1 : %2").arg(i).arg(detail), reserves);
if (vues.contains(ar.action.loadId))
return refus(Issue::Invalide, QStringLiteral("duplicate-load"),
QStringLiteral("créneau %1 : deux actions pour %2").arg(i).arg(ar.action.loadId),
reserves);
vues.insert(ar.action.loadId);
c.actions.append(ar);
}
if (s.value(QStringLiteral("expected")).userType() == QMetaType::QVariantMap) {
c.expected = s.value(QStringLiteral("expected")).toMap();
for (const QString &champ : kChampsReservesExpected)
if (c.expected.contains(champ))
reserves.append(QStringLiteral("expected.") + champ);
}
plan.creneaux.append(c);
}
reserves.removeDuplicates();
const QVariant validUntil = result.value(QStringLiteral("validUntil"));
if (validUntil.isValid() && !validUntil.isNull()) {
if (!lireInstant(validUntil, &plan.validUntil))
return refus(Issue::Invalide, QStringLiteral("bad-time"),
QStringLiteral("validUntil illisible ou sans décalage"), reserves);
if (plan.validUntil <= maintenant)
return refus(Issue::Expire, QStringLiteral("expired"),
QStringLiteral("validUntil %1 dépassé").arg(validUntil.toString()), reserves);
}
for (int i = 0; i < plan.creneaux.size(); ++i)
if (plan.creneaux.at(i).from <= maintenant && maintenant < plan.creneaux.at(i).to)
plan.indexCourant = i;
if (plan.indexCourant < 0)
return refus(Issue::Abstention, QStringLiteral("no-current-slot"),
QStringLiteral("aucun créneau ne couvre %1").arg(maintenant.toString(Qt::ISODate)),
reserves);
// Q1 — option C : un seul décideur par cycle. Une charge disponible que le créneau courant
// ne nomme pas rendrait la main au rule-based pour elle seule, sur un budget que
// l'optimiseur a déjà engagé : deux décideurs. Le plan entier est donc refusé.
const CreneauRecu &courant = plan.creneaux.at(plan.indexCourant);
QSet<QString> nommees;
for (const ActionRecue &ar : courant.actions) {
nommees.insert(ar.action.loadId);
if (!charges.value(ar.action.loadId)->telemetry.available)
v.actionsSurIndisponibles.append(ar.action.loadId);
}
for (const LoadContext &lc : ctx.loads)
if (lc.telemetry.available && !nommees.contains(lc.id))
return refus(Issue::Invalide, QStringLiteral("incomplete-slot"),
QStringLiteral("créneau courant sans action pour la charge disponible %1").arg(lc.id),
reserves);
v.issue = Issue::Valide;
v.champsReserves = reserves;
return v;
}
Plan substituer(const Plan &ombre, const PlanRecu &recu, const SurplusContext &ctx)
{
int iOmbre = -1;
for (int i = 0; i < ombre.timeSlots.size(); ++i)
if (ombre.timeSlots.at(i).from <= ctx.timestamp && ctx.timestamp < ombre.timeSlots.at(i).to)
iOmbre = i;
if (recu.indexCourant < 0 || recu.indexCourant >= recu.creneaux.size() || iOmbre < 0)
return ombre;
QHash<QString, const LoadContext *> charges;
for (const LoadContext &lc : ctx.loads)
charges.insert(lc.id, &lc);
Plan plan = ombre;
plan.strategy = QString::fromLatin1(StrategieOptimiseur);
if (!recu.planId.isEmpty())
plan.planId = recu.planId;
// Q4 — recalculés après le dispatch ; ceux de l'ombre décrivent une décision non prise.
plan.budget = PlanBudget();
plan.draw.committedW = 0;
plan.draw.remainingW = plan.draw.authorisedW;
QHash<QString, LoadAction> parId;
for (const ActionRecue &ar : recu.creneaux.at(recu.indexCourant).actions) {
const LoadContext *lc = charges.value(ar.action.loadId, nullptr);
if (!lc || !lc->telemetry.available)
continue; // indisponible : l'action de l'ombre reste, elle ne commande rien
parId.insert(ar.action.loadId, ar.action);
if (lc->adapter == QLatin1String("evcharger"))
plan.waterfallEvIds.insert(ar.action.loadId);
}
Slot &slot = plan.timeSlots[iOmbre];
for (LoadAction &a : slot.actions)
if (parId.contains(a.loadId))
a = parId.take(a.loadId);
// Une charge disponible sans action dans l'ombre : rangée à sa priorité, après les autres.
QList<LoadAction> restantes = parId.values();
std::sort(restantes.begin(), restantes.end(), [&](const LoadAction &x, const LoadAction &y) {
const int px = charges.value(x.loadId)->priority, py = charges.value(y.loadId)->priority;
return px != py ? px < py : x.loadId < y.loadId;
});
slot.actions.append(restantes);
return plan;
}
bool estActionOptimiseur(const LoadAction &action)
{
return action.levels.size() == 1 && action.levels.first().level == QLatin1String(NiveauOptimiseur);
}
QList<Ecretage> finaliser(Plan &plan, Slot &slot, const QHash<QString, LoadAction> &appliquees,
const QSet<QString> &delestees, const SurplusContext &ctx)
{
QHash<QString, const LoadContext *> charges;
for (const LoadContext &lc : ctx.loads)
charges.insert(lc.id, &lc);
// Surplus net SIGNÉ, mesure brute (règle 8), plus ce que les charges commandées tirent déjà :
// leur consommation est dans la mesure, la leur rendre est la correction B du rule-based.
const double netW = ctx.meter.exportW - ctx.meter.importW;
double recreditW = 0;
for (const LoadAction &a : std::as_const(slot.actions))
if (estActionOptimiseur(a) && !delestees.contains(a.loadId))
if (const LoadContext *lc = charges.value(a.loadId, nullptr))
recreditW += qMax(0.0, lc->telemetry.currentPowerW);
const QString budget = QString::fromLatin1(CountKey::BudgetAllocated);
const QString achat = QString::fromLatin1(CountKey::DrawCommitted);
double resteW = netW + recreditW, alloueW = 0, acheteW = 0;
QList<Ecretage> ecretages;
for (LoadAction &a : slot.actions) {
if (!estActionOptimiseur(a)) {
// Action de l'ombre gardée (charge indisponible, borne hors contexte) : elle compte
// pour ce qu'elle déclare, comme sous le rule-based.
const double s = a.counts.value(budget);
alloueW += s;
resteW -= s;
acheteW += a.counts.value(achat);
continue;
}
if (delestees.contains(a.loadId))
continue; // L4 a le dernier mot : son action remplace celle-ci après
const QString adapter = charges.contains(a.loadId) ? charges.value(a.loadId)->adapter : QString();
const LoadAction demande = a;
const bool applique = appliquees.contains(a.loadId);
double puissanceW = 0;
if (applique) {
const LoadAction &r = appliquees.value(a.loadId);
puissanceW = qMax(0.0, r.estimatedPowerW);
a.powerW = r.powerW;
a.state = r.state;
a.currentA = r.currentA;
a.phaseCount = r.phaseCount;
a.chargingEnabled = r.chargingEnabled;
} else {
a.powerW = 0;
a.chargingEnabled = false;
}
const double surplusW = qBound(0.0, resteW, puissanceW);
const double reseauW = puissanceW - surplusW;
resteW -= surplusW;
alloueW += surplusW;
acheteW += reseauW;
LoadAction::Level niveau = a.levels.first();
niveau.targetW = puissanceW;
niveau.funding = reseauW > 0.5 ? LoadAction::Grid : LoadAction::Surplus;
niveau.counts.clear();
if (surplusW > 0 || reseauW <= 0.5)
niveau.counts.insert(budget, surplusW);
if (reseauW > 0.5)
niveau.counts.insert(achat, reseauW);
a.estimatedPowerW = puissanceW;
a.funding = niveau.funding;
a.counts = niveau.counts;
a.levels = {niveau};
a.clamped = !applique || differe(demande, appliquees.value(a.loadId), adapter);
if (a.clamped)
ecretages.append({a.loadId, decrire(demande, adapter),
applique ? decrire(a, adapter) : QStringLiteral("non appliquée")});
}
plan.budget = PlanBudget();
plan.budget.surplusW = netW;
plan.budget.recreditedW = recreditW;
plan.budget.allocatedW = alloueW;
plan.budget.remainingW = resteW;
plan.draw.committedW = acheteW;
plan.draw.remainingW = plan.draw.authorisedW - acheteW;
return ecretages;
}
QString nomIssue(Issue issue)
{
switch (issue) {
case Issue::Valide: return QStringLiteral("valide");
case Issue::Abstention: return QStringLiteral("abstention");
case Issue::Expire: return QStringLiteral("expiré");
case Issue::Invalide: return QStringLiteral("invalide");
}
return QString();
}
}

View File

@ -0,0 +1,194 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QDateTime>
#include <QHash>
#include <QList>
#include <QSet>
#include <QString>
#include <QStringList>
#include <QVariantMap>
#include "../types/plan.h"
#include "../types/surpluscontext.h"
/*!
* \file optimizerplan.h
* \brief Plan d'un optimiseur externe : validation, conversion, substitution au plan ombre et
* comptabilité après dispatch (OPTIMIZER_PROTOCOL.md 1.1 §6-§7, brief 4 T3).
*
* Fonctions PURES : ni E/S, ni horloge (« maintenant » est \c ctx.timestamp), ni accès à
* l'arbitre ou à un adaptateur. Le \c SocketScheduler les enchaîne ; les tests les éprouvent
* seules.
*
* \par Le chemin des actions — un seul arbitre
* \c valider() convertit le \c result JSON en \c LoadAction internes ; \c substituer() les met
* à la place de celles du plan ombre (rule-based) dans le créneau courant ; l'arbitre les
* dispatche par \c applyActionsToAdapters(), exactement comme celles du rule-based — mêmes
* bornes, mêmes verrous, même L4 ; \c finaliser() relit ensuite ce que les adaptateurs ont
* RENDU pour publier la puissance appliquée, son financement et le budget du cycle (Q3, Q4).
*/
namespace OptimizerPlan {
//! Valeur de \c Plan::strategy d'un plan d'optimiseur.
constexpr const char *StrategieOptimiseur = "optimizer";
//! Niveau UNIQUE porté par une action d'optimiseur (décision Q3) : jamais ceux du plan ombre.
constexpr const char *NiveauOptimiseur = "optimizer";
//! Longueur maximale gardée d'un \c reason libre (journal, traces).
constexpr int TexteMax = 200;
/*! \brief Issue de la validation d'une réponse \c GetPlan. */
enum class Issue {
Valide, //!< Le créneau courant peut être appliqué.
Abstention, //!< Pas de plan pour maintenant (\c abstain, plan vide, aucun créneau courant) — §6-§7, pas un échec.
Expire, //!< \c validUntil dépassé — repli jusqu'au prochain plan valide, pas un échec.
Invalide //!< Plan refusé en entier, jamais appliqué partiellement — un échec (§7).
};
/*! \brief Une action reçue, convertie. */
struct ActionRecue {
LoadAction action; //!< Action interne : mécanisme, motif filtré, niveau \c optimizer provisoire.
QString adapter; //!< Mécanisme de la charge visée (\c LoadContext::adapter).
QString texte; //!< \c reason libre, assaini : journal et traces, JAMAIS une charge utile RPC.
QString codeRecu; //!< \c reasonCode tel que reçu (journal) ; vide s'il manquait.
bool codeSubstitue = false; //!< Code remplacé par \c OPTIMIZER_DECISION (hors liste blanche).
QStringList parametresSupprimes; //!< Clés de \c reasonParams retirées (non documentées ou valeur refusée).
};
/*! \brief Un créneau reçu. */
struct CreneauRecu {
QDateTime from; //!< Début, inclus.
QDateTime to; //!< Fin, exclue.
QList<ActionRecue> actions; //!< Actions converties, dans l'ordre reçu.
QVariantMap expected; //!< Trajectoire prévue — affichage et traces, JAMAIS pour commander.
};
/*! \brief Un plan reçu, validé. */
struct PlanRecu {
QString planId; //!< \c planId reçu (peut être vide).
QString strategy; //!< \c strategy reçue (informative).
QDateTime computedAt; //!< Invalide si absente ou illisible (informatif).
QDateTime validUntil; //!< Invalide = absente : valable tant qu'un créneau couvre maintenant.
QList<CreneauRecu> creneaux; //!< Ordonnés, sans chevauchement.
QVariantMap diagnostics; //!< Conservé pour l'app et les traces, jamais pour commander.
int indexCourant = -1; //!< Créneau couvrant \c ctx.timestamp ; -1 si aucun.
};
/*!
* \brief Verdict de \c valider().
*
* Codes (\c code, stables, destinés au compteur d'échecs §7 de T4) :
* - Abstention : \c abstain, \c empty-plan, \c no-current-slot ;
* - Expire : \c expired ;
* - Invalide : \c malformed-plan, \c bad-time, \c slots-unordered, \c malformed-action,
* \c unknown-load, \c duplicate-load, \c unknown-kind, \c kind-mismatch,
* \c kind-not-announced, \c missing-reason, \c bad-value, \c incomplete-slot.
*/
struct Verdict {
Issue issue = Issue::Invalide; //!< Issue.
QString code; //!< Vide si \c Valide.
QString detail; //!< Précision en français — JOURNAL seulement.
PlanRecu plan; //!< Plan converti ; complet seulement si \c Valide.
QStringList champsReserves; //!< Champs réservés ou \c force vus, ignorés (annexe F, §6).
//! Charges INDISPONIBLES dont le créneau courant portait une action : action rejetée, la
//! charge garde celle du plan ombre (brief §T3).
QStringList actionsSurIndisponibles;
};
/*!
* \brief Valide une réponse \c GetPlan et la convertit.
*
* Contrôles, dans cet ordre : forme (\c abstain, \c slots) ; chaque créneau (\c from < \c to,
* décalage horaire explicite, ordonnés et sans chevauchement) ; chaque action de CHAQUE
* créneau (\c loadId connu du contexte et unique dans son créneau, \c kind connu, cohérent avec
* le mécanisme et annoncé dans \c capabilities.kinds s'il est déclaré, \c reason non vide,
* valeurs numériques présentes et dans leur domaine) ; \c validUntil ; existence d'un créneau
* courant ; enfin COUVERTURE (décision Q1) : toute charge \c available du contexte figure dans
* le créneau courant.
*
* \param result \c result JSON-RPC de \c GetPlan, non validé.
* \param ctx Contexte du cycle : charges connues, disponibilité, et maintenant (\c ctx.timestamp).
* \param capabilities \c capabilities du handshake.
* \param hasCapabilities Vrai si le handshake portait \c capabilities.
* \return Le verdict. Un plan \c Invalide n'est JAMAIS appliqué, même en partie.
* \note \c force et les champs réservés (annexe F) sont ignorés et listés ; un champ inconnu est
* ignoré sans bruit (§9). Le texte \c reason ne quitte jamais \c ActionRecue::texte.
*/
Verdict valider(const QVariantMap &result, const SurplusContext &ctx,
const QVariantMap &capabilities, bool hasCapabilities);
/*!
* \brief Motif transmissible à l'app depuis un couple reçu (décision du 2026-10-16).
* \param code \c reasonCode reçu ; absent, inconnu ou hors liste blanche → \c OPTIMIZER_DECISION sans paramètres.
* \param params \c reasonParams reçus : seules les clés documentées du code passent
* (\c DecisionCode::optimizerParamKeys), avec une valeur nombre fini, booléen ou code \c [A-Za-z]+.
* \param[out] supprimes Clés retirées.
* \param[out] substitue Vrai si le code a été remplacé.
* \return Le motif — jamais de texte libre.
*/
DecisionReason motifFiltre(const QString &code, const QVariantMap &params,
QStringList *supprimes = nullptr, bool *substitue = nullptr);
/*!
* \brief Texte libre d'un optimiseur rendu sûr pour une ligne de journal.
* \param v Valeur reçue. \return Caractères de contrôle remplacés par des espaces, blancs
* resserrés, coupé à \c TexteMax ; vide si \p v n'est pas une chaîne.
*/
QString assainirTexte(const QVariant &v);
/*!
* \brief Plan du cycle : le créneau courant du plan reçu À LA PLACE des actions de l'ombre.
*
* Pour chaque charge \c available visée par le créneau courant, l'action de l'optimiseur
* remplace celle de l'ombre, à la même place (l'ordre de priorité est conservé). Les charges
* indisponibles, et celles que le contexte ne connaît pas, gardent l'action de l'ombre. Les
* bornes commandées par l'optimiseur rejoignent \c waterfallEvIds : le proxy ne les commande
* plus (une charge, un commandeur).
*
* \param ombre Plan du rule-based de ce cycle (ses effets de bord ont eu lieu).
* \param recu Plan validé (\c Issue::Valide, \c indexCourant ≥ 0).
* \param ctx Contexte du cycle.
* \return Un plan \c strategy == \c "optimizer". \c budget est remis à zéro et \c draw.committedW
* aussi : ils sont recalculés après le dispatch par \c finaliser() — ceux de l'ombre ne sont
* jamais publiés (décision Q4). Les champs d'autorisation de \c draw, qui viennent du contexte
* (\c ctx.drawMargin) et non d'une décision, sont gardés.
*/
Plan substituer(const Plan &ombre, const PlanRecu &recu, const SurplusContext &ctx);
/*! \brief Une action d'optimiseur écrêtée, pour le journal. */
struct Ecretage {
QString loadId; //!< Charge.
QString demande; //!< Ce que l'optimiseur demandait (texte de journal).
QString applique; //!< Ce que l'adaptateur a appliqué (texte de journal).
};
/*!
* \brief Après le dispatch : puissance APPLIQUÉE, financement et budget du cycle (Q3, Q4).
*
* Pour chaque action d'optimiseur du créneau (niveau \c optimizer), dans l'ordre de priorité :
* la puissance publiée est celle que l'adaptateur a rendue (\c appliquees) ; elle est financée
* au surplus tant que la mesure le couvre (export net + consommation courante des charges
* commandées, recrédit anti-clignotement), au réseau pour le reliquat ; \c levels devient un
* niveau unique \c optimizer, \c counts suit le partage (Σ = \c estimatedPowerW) ; \c clamped
* est posé si l'appliqué diffère de la demande, ou si rien n'a été appliqué. Une charge délestée
* par L4 est laissée à L4, qui remplace son action ensuite. Les actions de l'ombre gardées
* (charges indisponibles) entrent dans le budget par leurs \c counts.
*
* \param plan Plan du cycle (\c strategy == \c "optimizer") ; \c budget et \c draw réécrits.
* \param slot Créneau dispatché ; ses actions d'optimiseur sont réécrites.
* \param appliquees Retours d'\c applyAction() du dispatch (\c m_actionsAppliquees).
* \param delestees Charges revendiquées par L4 ce cycle.
* \param ctx Contexte du cycle (mesure, consommation courante).
* \return Les écrêtages, pour le journal.
* \invariant budget : \c surplusW + \c recreditedW − \c allocatedW == \c remainingW.
*/
QList<Ecretage> finaliser(Plan &plan, Slot &slot, const QHash<QString, LoadAction> &appliquees,
const QSet<QString> &delestees, const SurplusContext &ctx);
/*! \brief \param action Action du créneau. \return Vrai si elle vient d'un optimiseur (niveau \c optimizer). */
bool estActionOptimiseur(const LoadAction &action);
/*! \brief \param issue Issue. \return Son nom de journal (« valide », « abstention »…). */
QString nomIssue(Issue issue);
}

View File

@ -4,6 +4,7 @@
#include "decisionreason.h"
#include "ecoprogress.h"
#include "loadaction.h"
#include <QHash>
#include <QLocale>
#include <QStringList>
@ -358,3 +359,21 @@ bool DecisionCode::optimizerMayEmit(const QString &code)
{
return !code.isEmpty() && optimizerEmittableCodes().contains(code);
}
QStringList DecisionCode::optimizerParamKeys(const QString &code)
{
using namespace DecisionCode;
// Recopie du tableau d'INTERFACE.md (colonne `params`, préfixe o: retiré) — testé contre lui.
static const QHash<QString, QStringList> cles{
{SurplusSetpoint, {"budgetW", "setpointW", "stepped"}},
{SurplusInsufficient, {"budgetW"}},
{BelowMinPower, {"budgetW", "minPowerW", "state"}},
{SgForced, {"budgetW", "estimatedW"}},
{SgRecommended, {"budgetW", "estimatedW"}},
{SgNormal, {"budgetW"}},
{EcoFloorGrid, {"gridW", "atMaximum", "remainingWh", "targetWh", "remainingS",
"regime", "minAlwaysW", "boundBy"}},
{PriceSlot, {"priceCtkWh", "setpointW"}},
};
return optimizerMayEmit(code) ? cles.value(code) : QStringList();
}

View File

@ -418,6 +418,19 @@ QStringList optimizerEmittableCodes();
*/
bool optimizerMayEmit(const QString &code);
/*!
* \brief Clés de \c reasonParams qu'un optimiseur peut transmettre pour \p code — celles
* qu'\c INTERFACE.md documente pour ce code, préfixe \c o: retiré (décision du 2026-10-16).
*
* Toute autre clé est supprimée à la conversion, et comptée : un paramètre non documenté n'a
* pas de sens défini pour l'app, et une chaîne libre y ferait passer du texte (invariant 12).
* La liste est confrontée au tableau d'\c INTERFACE.md par un test — une clé ajoutée d'un seul
* côté le fait échouer.
* \param code Code de la liste blanche (\c optimizerEmittableCodes()).
* \return Les clés admises ; vide pour un code sans paramètre ou hors liste blanche.
*/
QStringList optimizerParamKeys(const QString &code);
}
/*!

View File

@ -135,4 +135,15 @@ struct LoadAction {
* \warning Réservé à la sécurité. Ne jamais mettre à \c true dans un scheduler.
*/
bool force = false;
/*!
* \brief Action d'optimiseur ÉCRÊTÉE au dispatch (OPTIMIZER_PROTOCOL §6, « clamped »).
*
* Vrai quand l'action APPLIQUÉE (retour d'\c ILoadAdapter::applyAction) diffère de celle que
* l'optimiseur demandait — bornes, verrous, palier — ou qu'elle n'a pas été appliquée du tout.
* Posé après le dispatch par \c OptimizerPlan::finaliser(), jamais par un scheduler.
* \note Interne dans ce lot : journalisé, gardé pour les traces (T6), non publié par RPC.
* Toujours faux sous le rule-based.
*/
bool clamped = false;
};

View File

@ -156,7 +156,9 @@ struct PlanDraw {
*/
struct Plan {
QString planId; //!< Identifiant unique (UUID string).
QString strategy; //!< "rule-based" | "socket" | "socket-fallback".
//! \c "rule-based" (plan du rule-based, y compris le repli d'une stratégie externe) ou
//! \c "optimizer" (créneau courant d'un plan d'optimiseur, \c OptimizerPlan::substituer()).
QString strategy;
//! Créneaux du plan. Nommé \c timeSlots (pas \c slots, mot-clé Qt).
//! Sérialisation JSON : sous le nom "slots" — OPTIMIZER_PROTOCOL.md §6
//! fait autorité sur le nom de fil, le renommage est purement interne C++.

View File

@ -13,12 +13,16 @@ TARGET = nymea-energy-loadmodel
HEADERS += testloadmodel.h
SOURCES += testloadmodel.cpp \
testoptimizerprotocol.cpp \
testdecisioncatalogue.cpp
testdecisioncatalogue.cpp \
testoptimizerplan.cpp
# Fichiers de référence du sérialiseur GetPlan (brief 4 T1), lus et — sous ETM_REGENERER_GOLDEN=1 —
# réécrits DANS LES SOURCES : une régénération se relit en diff avant d'être commitée.
DEFINES += ETM_GOLDEN_DIR=\\\"$$PWD/data/optimizer\\\"
# Le test anti-dérive du catalogue (brief 4 T5) lit l'EN-TÊTE, pas une copie de sa liste.
DEFINES += ETM_DECISIONREASON_H=\\\"$$PWD/../../../energyplugin/etm/types/decisionreason.h\\\"
# Les clés de paramètres qu'un optimiseur peut transmettre (brief 4 T3) sont confrontées au TABLEAU
# d'INTERFACE.md, pas à une copie.
DEFINES += ETM_INTERFACE_MD=\\\"$$PWD/../../../INTERFACE.md\\\"
# Une base energy-forecast.sqlite ÉCRITE PAR etm67 (b7317f3) : la migration s'éprouve sur le
# vrai fichier, pas sur une imitation (testTheLearningLogMigratesAnEtm67TableInPlace).
RESOURCES += loadmodel.qrc

View File

@ -155,6 +155,16 @@ private slots:
//! Liste blanche de l'optimiseur (E14) : ni sécurité, ni verrou, ni état du moteur.
void testOptimizerMayEmitOnlyWhitelistedCodes();
// Brief 4 T3 — plan d'un optimiseur (OptimizerPlan, fonctions pures)
void testOptimizerParamKeysMatchInterface();
void testOptimizerPlanIsConverted();
void testOptimizerPlanVerdicts_data();
void testOptimizerPlanVerdicts();
void testOptimizerReasonIsFiltered();
void testOptimizerForceAndReservedAreIgnored();
void testOptimizerUnavailableLoadKeepsTheShadow();
void testOptimizerFinaliseUsesTheAppliedAction();
};
#endif // TESTLOADMODEL_H

View File

@ -0,0 +1,456 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Brief 4 T3 — plan d'un optimiseur : validation, conversion, substitution, comptabilité après
// dispatch. Fonctions PURES (OptimizerPlan), éprouvées sans serveur ; le câblage dans l'arbitre
// est éprouvé par la suite `socketscheduler`.
#include "testloadmodel.h"
#include "etm/scheduler/optimizerplan.h"
#include "etm/types/decisionreason.h"
#include <QFile>
#include <QJsonDocument>
#include <QRegularExpression>
#include <QTest>
namespace {
const QDateTime kMaintenant = QDateTime::fromString(QStringLiteral("2026-06-08T13:00:00Z"), Qt::ISODate);
QString iso(const QDateTime &t) { return t.toString(Qt::ISODate); }
// Trois charges disponibles — un routeur à paliers, une PAC SG-Ready, une borne — et un
// chauffe-eau indisponible (en défaut).
SurplusContext contexte()
{
SurplusContext ctx;
ctx.timestamp = kMaintenant;
ctx.meter.exportW = 1000;
LoadContext ecs;
ecs.id = "ecs"; ecs.adapter = "relay-router"; ecs.label = "ECS"; ecs.priority = 1;
ecs.telemetry.available = true; ecs.telemetry.currentPowerW = 500;
LoadContext pac;
pac.id = "pac"; pac.adapter = "sg-ready"; pac.label = "PAC"; pac.priority = 2;
pac.declared.estimatedPowerW = {{3, 1500.0}, {4, 3000.0}};
pac.telemetry.available = true;
LoadContext ve;
ve.id = "ve"; ve.adapter = "evcharger"; ve.label = "VE"; ve.priority = 3;
ve.declared.phases = 3;
ve.telemetry.available = true; ve.telemetry.pluggedIn = true;
LoadContext panne;
panne.id = "panne"; panne.adapter = "etmvariableload"; panne.label = "Routeur"; panne.priority = 4;
panne.telemetry.available = false;
ctx.loads = {ecs, pac, ve, panne};
return ctx;
}
QVariantMap action(const QString &id, const QString &kind, const QVariantMap &champs)
{
QVariantMap a{{"loadId", id}, {"kind", kind}, {"reason", QStringLiteral("décision de test pour %1").arg(id)}};
for (auto it = champs.constBegin(); it != champs.constEnd(); ++it)
a.insert(it.key(), it.value());
return a;
}
// Plan VALIDE de référence : un créneau courant qui couvre les trois charges disponibles.
QVariantMap planValide()
{
QVariantList actions{
action("ecs", "setpoint", {{"powerW", 1500}, {"reasonCode", "SURPLUS_SETPOINT"},
{"reasonParams", QVariantMap{{"budgetW", 1600}, {"setpointW", 1500}}}}),
action("pac", "state", {{"state", 3}}),
action("ve", "setpoint", {{"currentA", 8}, {"phases", 3}, {"reasonCode", "EV_DEADLINE"}}),
};
QVariantMap creneau{{"from", iso(kMaintenant.addSecs(-120))}, {"to", iso(kMaintenant.addSecs(900))},
{"actions", actions}, {"expected", QVariantMap{{"gridImportW", 0}}}};
QVariantMap suivant{{"from", iso(kMaintenant.addSecs(900))}, {"to", iso(kMaintenant.addSecs(1800))},
{"actions", QVariantList()}};
return {{"planId", "p-1"}, {"strategy", "test"}, {"validUntil", iso(kMaintenant.addSecs(1800))},
{"slots", QVariantList{creneau, suivant}},
{"diagnostics", QVariantMap{{"status", "optimal"}, {"computeMs", 12}}}};
}
// Applique une modification au i-ème action du créneau 0.
QVariantMap avecAction(int i, const std::function<void(QVariantMap &)> &f)
{
QVariantMap p = planValide();
QVariantList creneaux = p.value("slots").toList();
QVariantMap s0 = creneaux[0].toMap();
QVariantList acts = s0.value("actions").toList();
QVariantMap a = acts[i].toMap();
f(a);
acts[i] = a;
s0["actions"] = acts;
creneaux[0] = s0;
p["slots"] = creneaux;
return p;
}
QVariantMap avecCreneau(int i, const std::function<void(QVariantMap &)> &f)
{
QVariantMap p = planValide();
QVariantList creneaux = p.value("slots").toList();
QVariantMap s = creneaux[i].toMap();
f(s);
creneaux[i] = s;
p["slots"] = creneaux;
return p;
}
} // namespace
void TestLoadModel::testOptimizerParamKeysMatchInterface()
{
// ANTI-DÉRIVE : les clés admises sont confrontées au TABLEAU d'INTERFACE.md, pas à une
// copie. Une clé ajoutée d'un seul côté fait échouer ce test.
QFile f(QStringLiteral(ETM_INTERFACE_MD));
QVERIFY2(f.open(QIODevice::ReadOnly), ETM_INTERFACE_MD);
const QStringList lignes = QString::fromUtf8(f.readAll()).split('\n');
int vus = 0;
for (const QString &code : DecisionCode::optimizerEmittableCodes()) {
const QString tete = QStringLiteral("| `%1`").arg(code);
QString ligne;
for (const QString &l : lignes)
if (l.startsWith(tete + QLatin1Char(' ')) || l.startsWith(tete + QStringLiteral(" *")))
ligne = l;
QVERIFY2(!ligne.isEmpty(), qUtf8Printable(code));
const QString params = ligne.section(QLatin1Char('|'), 2, 2);
QStringList documentees;
auto it = QRegularExpression(QStringLiteral("`(?:o:)?(\\w+)`")).globalMatch(params);
while (it.hasNext())
documentees << it.next().captured(1);
QStringList admises = DecisionCode::optimizerParamKeys(code);
documentees.sort();
admises.sort();
QVERIFY2(documentees == admises,
qUtf8Printable(QStringLiteral("%1 : INTERFACE %2 / code %3")
.arg(code, documentees.join(','), admises.join(','))));
++vus;
}
QCOMPARE(vus, DecisionCode::optimizerEmittableCodes().size());
QVERIFY(!DecisionCode::optimizerParamKeys(DecisionCode::SurplusSetpoint).isEmpty()); // a mordu
QVERIFY(DecisionCode::optimizerParamKeys(DecisionCode::DrawCap).isEmpty()); // hors liste
}
void TestLoadModel::testOptimizerPlanIsConverted()
{
const SurplusContext ctx = contexte();
const OptimizerPlan::Verdict v = OptimizerPlan::valider(planValide(), ctx, {}, false);
QVERIFY2(v.issue == OptimizerPlan::Issue::Valide, qUtf8Printable(v.code + " " + v.detail));
QCOMPARE(v.plan.planId, QString("p-1"));
QCOMPARE(v.plan.indexCourant, 0);
QCOMPARE(v.plan.creneaux.count(), 2);
// expected et diagnostics CONSERVÉS (app, traces) — ils ne commandent rien.
QCOMPARE(v.plan.creneaux.at(0).expected.value("gridImportW").toInt(), 0);
QCOMPARE(v.plan.diagnostics.value("status").toString(), QString("optimal"));
const QList<OptimizerPlan::ActionRecue> &a = v.plan.creneaux.at(0).actions;
QCOMPARE(a.count(), 3);
QCOMPARE(a[0].action.kind, LoadAction::Setpoint);
QCOMPARE(a[0].action.powerW, 1500.0);
QCOMPARE(a[0].action.reason.code, QString(DecisionCode::SurplusSetpoint));
QCOMPARE(a[0].action.reason.params.value("budgetW").toInt(), 1600);
QCOMPARE(a[1].action.kind, LoadAction::State);
QCOMPARE(a[1].action.state, 3);
QCOMPARE(a[1].action.estimatedPowerW, 1500.0);
QCOMPARE(a[1].action.reason.code, QString(DecisionCode::OptimizerDecision)); // sans code reçu
QVERIFY(a[1].codeSubstitue);
QCOMPARE(a[2].action.currentA, 8.0);
QCOMPARE(a[2].action.phaseCount, 3u);
QVERIFY(a[2].action.chargingEnabled);
QCOMPARE(a[2].action.estimatedPowerW, 8 * 230.0 * 3);
for (const OptimizerPlan::ActionRecue &ar : a) {
// Q3 — un niveau UNIQUE « optimizer », jamais ceux d'un autre plan.
QCOMPARE(ar.action.levels.count(), 1);
QCOMPARE(ar.action.levels.first().level, QString("optimizer"));
QVERIFY(OptimizerPlan::estActionOptimiseur(ar.action));
QVERIFY(!ar.action.force);
// Invariant 12 — le texte va au journal, JAMAIS dans le motif.
QVERIFY(ar.texte.startsWith("décision de test"));
const QByteArray motif = QJsonDocument::fromVariant(ar.action.reason.toMap()).toJson();
QVERIFY2(!motif.contains("test"), motif.constData());
}
}
void TestLoadModel::testOptimizerPlanVerdicts_data()
{
QTest::addColumn<QVariantMap>("plan");
QTest::addColumn<QVariantMap>("capabilities");
QTest::addColumn<int>("issue");
QTest::addColumn<QString>("code");
const int V = int(OptimizerPlan::Issue::Valide), A = int(OptimizerPlan::Issue::Abstention),
E = int(OptimizerPlan::Issue::Expire), I = int(OptimizerPlan::Issue::Invalide);
const QVariantMap sans;
QTest::newRow("valide") << planValide() << sans << V << QString();
QTest::newRow("abstain") << QVariantMap{{"abstain", true}, {"reason", "recalcul"}} << sans << A << "abstain";
QTest::newRow("plan vide") << QVariantMap{{"slots", QVariantList()}} << sans << A << "empty-plan";
QTest::newRow("slots non liste") << QVariantMap{{"slots", "x"}} << sans << I << "malformed-plan";
QTest::newRow("aucun créneau courant") << avecCreneau(0, [](QVariantMap &s) {
s["from"] = iso(kMaintenant.addSecs(60)); }) << sans << A << "no-current-slot";
QVariantMap expire = planValide();
expire["validUntil"] = iso(kMaintenant);
QTest::newRow("expiré") << expire << sans << E << "expired";
QVariantMap sansDecalage = planValide();
sansDecalage["validUntil"] = "2026-06-08T14:00:00";
QTest::newRow("validUntil sans décalage") << sansDecalage << sans << I << "bad-time";
QTest::newRow("from ≥ to") << avecCreneau(1, [](QVariantMap &s) {
s["to"] = s["from"]; }) << sans << I << "bad-time";
QTest::newRow("chevauchement") << avecCreneau(1, [](QVariantMap &s) {
s["from"] = iso(kMaintenant.addSecs(600)); }) << sans << I << "slots-unordered";
// Q1 — option C : une charge disponible absente du créneau courant invalide le plan.
QTest::newRow("incomplet (Q1)") << avecCreneau(0, [](QVariantMap &s) {
QVariantList a = s["actions"].toList(); a.removeAt(1); s["actions"] = a; }) << sans << I << "incomplete-slot";
QTest::newRow("charge inconnue") << avecAction(0, [](QVariantMap &a) { a["loadId"] = "fantome"; }) << sans << I << "unknown-load";
QTest::newRow("charge inconnue dans un créneau FUTUR") << avecCreneau(1, [](QVariantMap &s) {
s["actions"] = QVariantList{action("fantome", "setpoint", {{"powerW", 1}})}; }) << sans << I << "unknown-load";
QTest::newRow("doublon") << avecCreneau(0, [](QVariantMap &s) {
QVariantList a = s["actions"].toList(); a.append(a.first()); s["actions"] = a; }) << sans << I << "duplicate-load";
QTest::newRow("kind inconnu") << avecAction(0, [](QVariantMap &a) { a["kind"] = "boost"; }) << sans << I << "unknown-kind";
QTest::newRow("kind state sur un routeur") << avecAction(0, [](QVariantMap &a) {
a["kind"] = "state"; a["state"] = 3; }) << sans << I << "kind-mismatch";
QTest::newRow("kind setpoint sur SG-Ready") << avecAction(1, [](QVariantMap &a) {
a["kind"] = "setpoint"; a["powerW"] = 1500; }) << sans << I << "kind-mismatch";
QTest::newRow("constraint (réservé)") << avecAction(0, [](QVariantMap &a) { a["kind"] = "constraint"; }) << sans << I << "kind-mismatch";
QTest::newRow("kind non annoncé") << planValide()
<< QVariantMap{{"kinds", QVariantList{"setpoint"}}} << I << "kind-not-announced";
QTest::newRow("kind annoncé") << planValide()
<< QVariantMap{{"kinds", QVariantList{"setpoint", "state"}}} << V << QString();
QTest::newRow("capabilities sans kinds") << planValide() << QVariantMap{{"maxHorizonH", 48}} << V << QString();
QTest::newRow("reason vide") << avecAction(0, [](QVariantMap &a) { a["reason"] = " \n "; }) << sans << I << "missing-reason";
QTest::newRow("reason absent") << avecAction(0, [](QVariantMap &a) { a.remove("reason"); }) << sans << I << "missing-reason";
QTest::newRow("powerW négatif") << avecAction(0, [](QVariantMap &a) { a["powerW"] = -5; }) << sans << I << "bad-value";
QTest::newRow("powerW texte") << avecAction(0, [](QVariantMap &a) { a["powerW"] = "1500"; }) << sans << I << "bad-value";
QTest::newRow("state 5") << avecAction(1, [](QVariantMap &a) { a["state"] = 5; }) << sans << I << "bad-value";
QTest::newRow("borne sans consigne") << avecAction(2, [](QVariantMap &a) { a.remove("currentA"); }) << sans << I << "bad-value";
QTest::newRow("borne phases 2") << avecAction(2, [](QVariantMap &a) { a["phases"] = 2; }) << sans << I << "bad-value";
QTest::newRow("borne en watts") << avecAction(2, [](QVariantMap &a) {
a.remove("currentA"); a.remove("phases"); a["powerW"] = 4200; }) << sans << V << QString();
// Une action HORS BORNES n'est pas un plan invalide : l'adaptateur l'écrête au dispatch.
QTest::newRow("hors bornes : écrêtée, pas refusée") << avecAction(0, [](QVariantMap &a) { a["powerW"] = 99999; }) << sans << V << QString();
}
void TestLoadModel::testOptimizerPlanVerdicts()
{
QFETCH(QVariantMap, plan);
QFETCH(QVariantMap, capabilities);
QFETCH(int, issue);
QFETCH(QString, code);
const OptimizerPlan::Verdict v = OptimizerPlan::valider(plan, contexte(), capabilities, !capabilities.isEmpty());
QVERIFY2(int(v.issue) == issue && v.code == code,
qUtf8Printable(QStringLiteral("obtenu %1 « %2 » — %3").arg(OptimizerPlan::nomIssue(v.issue), v.code, v.detail)));
}
void TestLoadModel::testOptimizerReasonIsFiltered()
{
QStringList supprimes;
bool substitue = false;
// Hors liste blanche : un optimiseur ne parle pas au nom de la sécurité.
DecisionReason r = OptimizerPlan::motifFiltre("DEGRADED_L2", {{"x", 1}}, &supprimes, &substitue);
QCOMPARE(r.code, QString(DecisionCode::OptimizerDecision));
QVERIFY(r.params.isEmpty());
QVERIFY(substitue);
QCOMPARE(supprimes, QStringList{"x"});
// Inconnu du catalogue, et absent.
QCOMPARE(OptimizerPlan::motifFiltre("MON_CODE", {}).code, QString(DecisionCode::OptimizerDecision));
QCOMPARE(OptimizerPlan::motifFiltre(QString(), {}).code, QString(DecisionCode::OptimizerDecision));
// Liste blanche : seules les clés DOCUMENTÉES, et des valeurs nombre / booléen / code.
supprimes.clear();
r = OptimizerPlan::motifFiltre("ECO_FLOOR_GRID",
{{"gridW", 800}, {"atMaximum", false}, {"regime", "measured"},
{"boundBy", "la limite du disjoncteur"}, // texte libre
{"remainingWh", QVariantList{1}}, // ni nombre ni code
{"commentaire", "ok"}}, // clé non documentée
&supprimes, &substitue);
QCOMPARE(r.code, QString(DecisionCode::EcoFloorGrid));
QVERIFY(!substitue);
QCOMPARE(r.params, (QVariantMap{{"gridW", 800}, {"atMaximum", false}, {"regime", "measured"}}));
supprimes.sort();
QCOMPARE(supprimes, (QStringList{"boundBy", "commentaire", "remainingWh"}));
// Un paramètre texte dans une action reçue : supprimé, compté, jamais publié.
const QVariantMap p = avecAction(0, [](QVariantMap &a) {
a["reasonParams"] = QVariantMap{{"budgetW", 1600}, {"setpointW", "mille cinq cents"}};
});
const OptimizerPlan::Verdict v = OptimizerPlan::valider(p, contexte(), {}, false);
QVERIFY(v.issue == OptimizerPlan::Issue::Valide);
const OptimizerPlan::ActionRecue &ar = v.plan.creneaux.at(0).actions.at(0);
QCOMPARE(ar.action.reason.params, (QVariantMap{{"budgetW", 1600}}));
QCOMPARE(ar.parametresSupprimes, QStringList{"setpointW"});
// Texte assaini pour le journal : contrôles retirés, longueur bornée.
QCOMPARE(OptimizerPlan::assainirTexte("a\nb\tc
d"), QString("a b c d"));
// simplified() ne voit que les BLANCS : un échappement terminal ou une sonnerie passeraient.
QCOMPARE(OptimizerPlan::assainirTexte(QStringLiteral("a\x1b[31mb\x07" "c")), QString("a [31mb c"));
QCOMPARE(OptimizerPlan::assainirTexte(QString(500, 'x')).size(), OptimizerPlan::TexteMax + 1);
QVERIFY(OptimizerPlan::assainirTexte(42).isEmpty());
}
void TestLoadModel::testOptimizerForceAndReservedAreIgnored()
{
QVariantMap p = avecAction(0, [](QVariantMap &a) { a["force"] = true; a["source"] = "grid"; });
p = [&] {
QVariantList creneaux = p["slots"].toList();
QVariantMap s0 = creneaux[0].toMap();
s0["expected"] = QVariantMap{{"batterySocPercent", 80}};
creneaux[0] = s0;
p["slots"] = creneaux;
return p;
}();
const OptimizerPlan::Verdict v = OptimizerPlan::valider(p, contexte(), {}, false);
QVERIFY(v.issue == OptimizerPlan::Issue::Valide);
QVERIFY(!v.plan.creneaux.at(0).actions.at(0).action.force); // §6 : jamais forcé depuis le fil
QStringList r = v.champsReserves;
r.sort();
QCOMPARE(r, (QStringList{"action.force", "action.source", "expected.batterySocPercent"}));
}
void TestLoadModel::testOptimizerUnavailableLoadKeepsTheShadow()
{
const SurplusContext ctx = contexte();
// L'optimiseur commande aussi la charge INDISPONIBLE : le plan reste valide, l'action est
// rejetée et l'ombre garde la main sur elle.
const QVariantMap p = avecCreneau(0, [](QVariantMap &s) {
QVariantList a = s["actions"].toList();
a.append(action("panne", "setpoint", {{"powerW", 2000}}));
s["actions"] = a;
});
const OptimizerPlan::Verdict v = OptimizerPlan::valider(p, ctx, {}, false);
QVERIFY2(v.issue == OptimizerPlan::Issue::Valide, qUtf8Printable(v.code));
QCOMPARE(v.actionsSurIndisponibles, QStringList{"panne"});
// Plan ombre : une action par charge, dont LOAD_UNAVAILABLE pour la charge en défaut.
Plan ombre;
ombre.strategy = "rule-based";
ombre.planId = "ombre";
ombre.budget.surplusW = 777; // ne doit JAMAIS être publié sous l'optimiseur
ombre.draw.committedW = 333;
ombre.draw.capBorne = true;
ombre.draw.authorisedW = 6000;
Slot s;
s.from = kMaintenant;
s.to = kMaintenant.addSecs(60);
for (const char *id : {"ecs", "pac", "ve"}) {
LoadAction a;
a.loadId = id;
a.reason = {DecisionCode::SurplusInsufficient};
a.levels = {LoadAction::Level{"comfort", 0, LoadAction::Surplus, {}, a.reason, {}, false}};
s.actions.append(a);
}
LoadAction gelee;
gelee.loadId = "panne";
gelee.reason = {DecisionCode::LoadUnavailable, {{"frozenW", 400}}};
gelee.estimatedPowerW = 400;
gelee.counts = {{CountKey::BudgetAllocated, 400.0}};
s.actions.append(gelee);
ombre.timeSlots = {s};
const Plan plan = OptimizerPlan::substituer(ombre, v.plan, ctx);
QCOMPARE(plan.strategy, QString("optimizer"));
QCOMPARE(plan.planId, QString("p-1"));
const Slot &c = plan.timeSlots.first();
QCOMPARE(c.actions.count(), 4);
QStringList ordre;
for (const LoadAction &a : c.actions)
ordre << a.loadId;
QCOMPARE(ordre, (QStringList{"ecs", "pac", "ve", "panne"})); // l'ordre de priorité est gardé
for (int i = 0; i < 3; ++i)
QVERIFY(OptimizerPlan::estActionOptimiseur(c.actions.at(i)));
QCOMPARE(c.actions.at(3).reason.code, QString(DecisionCode::LoadUnavailable)); // l'ombre
QVERIFY(plan.waterfallEvIds.contains("ve")); // une charge, un commandeur : le proxy la lâche
// Q4 — rien de l'ombre n'est gardé du budget ; l'autorisation, elle, vient du contexte.
QCOMPARE(plan.budget.surplusW, 0.0);
QCOMPARE(plan.draw.committedW, 0.0);
QVERIFY(plan.draw.capBorne);
QCOMPARE(plan.draw.authorisedW, 6000.0);
}
void TestLoadModel::testOptimizerFinaliseUsesTheAppliedAction()
{
const SurplusContext ctx = contexte(); // export 1000 W, l'ECS tire déjà 500 W
const OptimizerPlan::Verdict v = OptimizerPlan::valider(
avecAction(1, [](QVariantMap &a) { a["state"] = 4; }), ctx, {}, false);
QVERIFY(v.issue == OptimizerPlan::Issue::Valide);
Plan ombre;
ombre.strategy = "rule-based";
Slot s;
s.from = kMaintenant;
s.to = kMaintenant.addSecs(60);
for (const char *id : {"ecs", "pac", "ve"}) {
LoadAction a;
a.loadId = id;
s.actions.append(a);
}
LoadAction gelee;
gelee.loadId = "panne";
gelee.reason = {DecisionCode::LoadUnavailable};
gelee.counts = {{CountKey::BudgetAllocated, 200.0}};
gelee.estimatedPowerW = 200;
s.actions.append(gelee);
ombre.timeSlots = {s};
Plan plan = OptimizerPlan::substituer(ombre, v.plan, ctx);
Slot slot = plan.timeSlots.first();
// Ce que les adaptateurs ont RENDU : l'ECS applique sa demande ; la PAC, verrouillée, reste
// en 3 au lieu de 4 ; la borne n'est pas dispatchée (adaptateur disparu).
QHash<QString, LoadAction> appliquees;
LoadAction ecs = slot.actions.at(0);
ecs.estimatedPowerW = 1500;
appliquees.insert("ecs", ecs);
LoadAction pac = slot.actions.at(1);
pac.state = 3;
pac.estimatedPowerW = 1500;
appliquees.insert("pac", pac);
const QList<OptimizerPlan::Ecretage> e = OptimizerPlan::finaliser(plan, slot, appliquees, {}, ctx);
QCOMPARE(e.count(), 2);
QCOMPARE(e.at(0).loadId, QString("pac"));
QCOMPARE(e.at(0).demande, QString("état 4"));
QCOMPARE(e.at(0).applique, QString("état 3"));
QCOMPARE(e.at(1).loadId, QString("ve"));
QVERIFY(!slot.actions.at(0).clamped);
QVERIFY(slot.actions.at(1).clamped);
QVERIFY(slot.actions.at(2).clamped);
QCOMPARE(slot.actions.at(1).state, 3); // l'action publiée dit l'APPLIQUÉ
// Q3/Q4 — budget = export net (1000) + recrédit de l'ECS (500) = 1500, servi dans l'ordre de
// priorité : l'ECS (1500 appliqués) le prend en entier, la PAC (1500 appliqués en état 3)
// achète tout, la charge gelée (rang 4) compte ses 200 W comme sous le rule-based.
QCOMPARE(plan.budget.surplusW, 1000.0);
QCOMPARE(plan.budget.recreditedW, 500.0);
QCOMPARE(plan.budget.allocatedW, 1700.0);
QCOMPARE(plan.budget.remainingW, -200.0);
QCOMPARE(plan.budget.surplusW + plan.budget.recreditedW - plan.budget.allocatedW, plan.budget.remainingW);
QCOMPARE(plan.draw.committedW, 1500.0);
QCOMPARE(plan.draw.remainingW, plan.draw.authorisedW - 1500.0);
const LoadAction &a0 = slot.actions.at(0);
QCOMPARE(a0.counts, (QMap<QString, double>{{CountKey::BudgetAllocated, 1500.0}}));
QCOMPARE(a0.funding, LoadAction::Surplus);
QCOMPARE(slot.actions.at(1).counts, (QMap<QString, double>{{CountKey::DrawCommitted, 1500.0}}));
QCOMPARE(slot.actions.at(1).funding, LoadAction::Grid);
QCOMPARE(slot.actions.at(1).levels.first().funding, LoadAction::Grid);
QCOMPARE(slot.actions.at(2).estimatedPowerW, 0.0);
for (const LoadAction &a : slot.actions) {
double somme = 0;
for (double w : a.counts)
somme += w;
QCOMPARE(somme, a.estimatedPowerW); // Σ counts == estimatedPowerW, partout
if (OptimizerPlan::estActionOptimiseur(a)) {
QCOMPARE(a.levels.count(), 1);
QCOMPARE(a.levels.first().targetW, a.estimatedPowerW);
}
}
// Une charge DÉLESTÉE par L4 est laissée à L4 : ni recréditée, ni comptée, ni marquée.
Plan plan2 = OptimizerPlan::substituer(ombre, v.plan, ctx);
Slot slot2 = plan2.timeSlots.first();
OptimizerPlan::finaliser(plan2, slot2, appliquees, {"ecs"}, ctx);
QCOMPARE(plan2.budget.recreditedW, 0.0);
QVERIFY(!slot2.actions.at(0).clamped);
// Le surplus que l'ECS délestée ne prend pas revient à la suivante : la PAC est servie à
// 1000 W au surplus, et n'achète que le reliquat.
QCOMPARE(slot2.actions.at(1).counts,
(QMap<QString, double>{{CountKey::BudgetAllocated, 1000.0}, {CountKey::DrawCommitted, 500.0}}));
}