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:
parent
7597c87e58
commit
a8c58d02f0
@ -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 \
|
||||
|
||||
549
energyplugin/etm/scheduler/optimizerplan.cpp
Normal file
549
energyplugin/etm/scheduler/optimizerplan.cpp
Normal 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 ¶ms, 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();
|
||||
}
|
||||
|
||||
}
|
||||
194
energyplugin/etm/scheduler/optimizerplan.h
Normal file
194
energyplugin/etm/scheduler/optimizerplan.h
Normal 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 ¶ms,
|
||||
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);
|
||||
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
}
|
||||
|
||||
/*!
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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++.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
456
tests/auto/loadmodel/testoptimizerplan.cpp
Normal file
456
tests/auto/loadmodel/testoptimizerplan.cpp
Normal 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}}));
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user