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>
457 lines
23 KiB
C++
457 lines
23 KiB
C++
// 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}}));
|
||
}
|