Patrick Schurig 1f6c7ec27a refactor(plan): le plan rule-based porte strategy « rules », comme activeStrategy (brief 4 T6)
Décision de Patrick du 2026-10-17 : un seul vocabulaire. Plan::strategy du rule-based,
GetPlan.strategy et la doc passent de « rule-based » à « rules » (INTERFACE.md, protocole §6).
Les libellés de journal (« repli rule-based ») ne sont pas des valeurs et restent.

Référence decisions régénérée. Preuve AVANT régénération, moteur de e592618 + ce seul
renommage, contre l'ancienne référence : comparateur --strategie (nouvelle option, qui ne tolère
que plan.strategy rule-based → rules) IDENTIQUE HORS STRATÉGIE — 23 792 lignes, 23 728
identiques à l'octet, 64 qui ne diffèrent que par .plan.strategy (décompte indépendant des
chemins JSON : 64 × .plan.strategy "rule-based" → "rules", rien d'autre). 0 rejeu. Le même
build, relu contre la nouvelle référence : IDENTIQUE ; 6 suites vertes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:46:36 +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 = "rules";
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 = "rules";
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}}));
}