diff --git a/energyplugin/etm/etm.pri b/energyplugin/etm/etm.pri index c5b6dc4..8e520ab 100644 --- a/energyplugin/etm/etm.pri +++ b/energyplugin/etm/etm.pri @@ -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 \ diff --git a/energyplugin/etm/scheduler/optimizerplan.cpp b/energyplugin/etm/scheduler/optimizerplan.cpp new file mode 100644 index 0000000..06dec95 --- /dev/null +++ b/energyplugin/etm/scheduler/optimizerplan.cpp @@ -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 +#include +#include + +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 &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 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 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 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 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 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 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 finaliser(Plan &plan, Slot &slot, const QHash &appliquees, + const QSet &delestees, const SurplusContext &ctx) +{ + QHash 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 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(); +} + +} diff --git a/energyplugin/etm/scheduler/optimizerplan.h b/energyplugin/etm/scheduler/optimizerplan.h new file mode 100644 index 0000000..d694014 --- /dev/null +++ b/energyplugin/etm/scheduler/optimizerplan.h @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#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 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 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 finaliser(Plan &plan, Slot &slot, const QHash &appliquees, + const QSet &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); + +} diff --git a/energyplugin/etm/types/decisionreason.cpp b/energyplugin/etm/types/decisionreason.cpp index 9e5f2a9..1c9dc1f 100644 --- a/energyplugin/etm/types/decisionreason.cpp +++ b/energyplugin/etm/types/decisionreason.cpp @@ -4,6 +4,7 @@ #include "decisionreason.h" #include "ecoprogress.h" #include "loadaction.h" +#include #include #include @@ -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 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(); +} diff --git a/energyplugin/etm/types/decisionreason.h b/energyplugin/etm/types/decisionreason.h index e808163..47a83a9 100644 --- a/energyplugin/etm/types/decisionreason.h +++ b/energyplugin/etm/types/decisionreason.h @@ -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); + } /*! diff --git a/energyplugin/etm/types/loadaction.h b/energyplugin/etm/types/loadaction.h index da6ba19..d0d9ef9 100644 --- a/energyplugin/etm/types/loadaction.h +++ b/energyplugin/etm/types/loadaction.h @@ -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; }; diff --git a/energyplugin/etm/types/plan.h b/energyplugin/etm/types/plan.h index 7f8373c..d2f0141 100644 --- a/energyplugin/etm/types/plan.h +++ b/energyplugin/etm/types/plan.h @@ -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++. diff --git a/tests/auto/loadmodel/loadmodel.pro b/tests/auto/loadmodel/loadmodel.pro index fcb9f5b..a16314a 100644 --- a/tests/auto/loadmodel/loadmodel.pro +++ b/tests/auto/loadmodel/loadmodel.pro @@ -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 diff --git a/tests/auto/loadmodel/testloadmodel.h b/tests/auto/loadmodel/testloadmodel.h index 9085781..d7511c8 100644 --- a/tests/auto/loadmodel/testloadmodel.h +++ b/tests/auto/loadmodel/testloadmodel.h @@ -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 diff --git a/tests/auto/loadmodel/testoptimizerplan.cpp b/tests/auto/loadmodel/testoptimizerplan.cpp new file mode 100644 index 0000000..cf681eb --- /dev/null +++ b/tests/auto/loadmodel/testoptimizerplan.cpp @@ -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 +#include +#include +#include + +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 &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 &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 &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("plan"); + QTest::addColumn("capabilities"); + QTest::addColumn("issue"); + QTest::addColumn("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 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 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{{CountKey::BudgetAllocated, 1500.0}})); + QCOMPARE(a0.funding, LoadAction::Surplus); + QCOMPARE(slot.actions.at(1).counts, (QMap{{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{{CountKey::BudgetAllocated, 1000.0}, {CountKey::DrawCommitted, 500.0}})); +}