Patrick Schurig a8c58d02f0 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>
2026-10-16 22:06:33 +02:00

457 lines
23 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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}}));
}