OptimizerPlan::kinds() (désormais appliquée par convertirAction) et verdictCodes(), dans l'ordre des contrôles ; SocketScheduler::knownTriggers() (appliquée par rangDeclencheur), knownFallbackCodes(), knownLastErrorCodes() ; OptimizerClient::knownErrorCodes(). Le contrat OpenRPC en régénère ses énumérations. Aucun comportement changé : mêmes listes, mêmes ordres. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
586 lines
25 KiB
C++
586 lines
25 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||
|
||
#include "optimizerplan.h"
|
||
#include "../types/decisionreason.h"
|
||
|
||
#include <QRegularExpression>
|
||
#include <algorithm>
|
||
#include <cmath>
|
||
|
||
namespace {
|
||
|
||
// Champs réservés de l'annexe F, et le contournement de verrou du §6 : reçus, ignorés, listés.
|
||
const QStringList kChampsReservesAction{QStringLiteral("force"), QStringLiteral("source"),
|
||
QStringLiteral("charge"), QStringLiteral("discharge")};
|
||
const QStringList kChampsReservesExpected{QStringLiteral("batterySocPercent")};
|
||
|
||
// Tension par phase de la conversion ampères → watts, la même que le rule-based et l'EvAdapter.
|
||
constexpr double kTensionV = 230.0;
|
||
|
||
bool estChaine(const QVariant &v) { return v.userType() == QMetaType::QString; }
|
||
|
||
bool lireNombre(const QVariant &v, double *out)
|
||
{
|
||
switch (v.userType()) {
|
||
case QMetaType::Double: case QMetaType::Float: case QMetaType::Int: case QMetaType::UInt:
|
||
case QMetaType::LongLong: case QMetaType::ULongLong:
|
||
break;
|
||
default:
|
||
return false; // un booléen ou une chaîne « 12 » n'est pas un nombre
|
||
}
|
||
const double d = v.toDouble();
|
||
if (!std::isfinite(d))
|
||
return false;
|
||
*out = d;
|
||
return true;
|
||
}
|
||
|
||
// Instant AVEC son décalage : une heure murale nue serait interprétée dans le fuseau de la box,
|
||
// qui n'est pas forcément celui de l'optimiseur (AGENTS.md, « un INSTANT est absolu »).
|
||
bool lireInstant(const QVariant &v, QDateTime *out)
|
||
{
|
||
if (!estChaine(v))
|
||
return false;
|
||
const QDateTime dt = QDateTime::fromString(v.toString(), Qt::ISODateWithMs);
|
||
if (!dt.isValid() || dt.timeSpec() == Qt::LocalTime)
|
||
return false;
|
||
*out = dt;
|
||
return true;
|
||
}
|
||
|
||
bool valeurAdmise(const QVariant &v)
|
||
{
|
||
static const QRegularExpression code(QStringLiteral("^[A-Za-z]+$"));
|
||
double d;
|
||
if (v.userType() == QMetaType::Bool || lireNombre(v, &d))
|
||
return true;
|
||
return estChaine(v) && code.match(v.toString()).hasMatch();
|
||
}
|
||
|
||
// Le kind que le mécanisme accepte. Vide : aucun (batterie, réservée à l'annexe F).
|
||
QString kindDuMecanisme(const QString &adapter)
|
||
{
|
||
if (adapter == QLatin1String("evcharger") || adapter == QLatin1String("relay-router")
|
||
|| adapter == QLatin1String("etmvariableload"))
|
||
return QStringLiteral("setpoint");
|
||
if (adapter == QLatin1String("sg-ready"))
|
||
return QStringLiteral("state");
|
||
return QString();
|
||
}
|
||
|
||
OptimizerPlan::Verdict refus(OptimizerPlan::Issue issue, const QString &code, const QString &detail,
|
||
const QStringList &reserves = {})
|
||
{
|
||
OptimizerPlan::Verdict v;
|
||
v.issue = issue;
|
||
v.code = code;
|
||
v.detail = detail;
|
||
v.champsReserves = reserves;
|
||
return v;
|
||
}
|
||
|
||
// Une action reçue → interne. Rend un code d'erreur, vide si l'action est recevable.
|
||
QString convertirAction(const QVariant &brut, const QHash<QString, const LoadContext *> &charges,
|
||
const QStringList *kindsAnnonces, OptimizerPlan::ActionRecue *out,
|
||
QString *detail, QStringList *reserves)
|
||
{
|
||
if (brut.userType() != QMetaType::QVariantMap) {
|
||
*detail = QStringLiteral("action qui n'est pas un objet");
|
||
return QStringLiteral("malformed-action");
|
||
}
|
||
const QVariantMap a = brut.toMap();
|
||
const QVariant idv = a.value(QStringLiteral("loadId"));
|
||
if (!estChaine(idv) || idv.toString().isEmpty()) {
|
||
*detail = QStringLiteral("action sans loadId");
|
||
return QStringLiteral("malformed-action");
|
||
}
|
||
const QString id = idv.toString();
|
||
const LoadContext *lc = charges.value(id, nullptr);
|
||
if (!lc) {
|
||
*detail = QStringLiteral("charge %1 inconnue du contexte").arg(id);
|
||
return QStringLiteral("unknown-load");
|
||
}
|
||
|
||
const QString kind = a.value(QStringLiteral("kind")).toString();
|
||
if (!estChaine(a.value(QStringLiteral("kind"))) || !OptimizerPlan::kinds().contains(kind)) {
|
||
*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 {
|
||
|
||
QStringList kinds()
|
||
{
|
||
return {QStringLiteral("setpoint"), QStringLiteral("state"), QStringLiteral("constraint")};
|
||
}
|
||
|
||
namespace {
|
||
// Les verdicts de valider(), dans l'ORDRE de ses contrôles : c'est l'ordre dans lequel un plan
|
||
// fautif se voit attribuer son code, et celui que rejoue tools/etm-powersync-schema-check.
|
||
struct CodeVerdict { const char *code; Issue issue; };
|
||
constexpr CodeVerdict kVerdicts[] = {
|
||
{"abstain", Issue::Abstention}, {"malformed-plan", Issue::Invalide},
|
||
{"empty-plan", Issue::Abstention}, {"bad-time", Issue::Invalide},
|
||
{"slots-unordered", Issue::Invalide}, {"malformed-action", Issue::Invalide},
|
||
{"unknown-load", Issue::Invalide}, {"unknown-kind", Issue::Invalide},
|
||
{"kind-mismatch", Issue::Invalide}, {"kind-not-announced", Issue::Invalide},
|
||
{"missing-reason", Issue::Invalide}, {"bad-value", Issue::Invalide},
|
||
{"duplicate-load", Issue::Invalide}, {"expired", Issue::Expire},
|
||
{"no-current-slot", Issue::Abstention}, {"incomplete-slot", Issue::Invalide},
|
||
};
|
||
}
|
||
|
||
QStringList verdictCodes(Issue issue)
|
||
{
|
||
QStringList out;
|
||
for (const CodeVerdict &v : kVerdicts)
|
||
if (v.issue == issue)
|
||
out << QString::fromLatin1(v.code);
|
||
return out;
|
||
}
|
||
|
||
QStringList verdictCodes()
|
||
{
|
||
QStringList out;
|
||
for (const CodeVerdict &v : kVerdicts)
|
||
out << QString::fromLatin1(v.code);
|
||
return out;
|
||
}
|
||
|
||
QString assainirTexte(const QVariant &v)
|
||
{
|
||
if (!estChaine(v))
|
||
return QString();
|
||
QString t = v.toString();
|
||
for (QChar &c : t)
|
||
if (c.category() == QChar::Other_Control || c.category() == QChar::Separator_Line
|
||
|| c.category() == QChar::Separator_Paragraph || c.category() == QChar::Other_Format)
|
||
c = QLatin1Char(' ');
|
||
t = t.simplified();
|
||
if (t.size() > TexteMax)
|
||
t = t.left(TexteMax) + QStringLiteral("…");
|
||
return t;
|
||
}
|
||
|
||
DecisionReason motifFiltre(const QString &code, const QVariantMap ¶ms, QStringList *supprimes,
|
||
bool *substitue)
|
||
{
|
||
DecisionReason r;
|
||
if (!DecisionCode::optimizerMayEmit(code)) {
|
||
if (substitue)
|
||
*substitue = true;
|
||
if (supprimes)
|
||
supprimes->append(params.keys());
|
||
r.code = QString::fromLatin1(DecisionCode::OptimizerDecision);
|
||
return r;
|
||
}
|
||
if (substitue)
|
||
*substitue = false;
|
||
const QStringList admises = DecisionCode::optimizerParamKeys(code);
|
||
for (auto it = params.constBegin(); it != params.constEnd(); ++it) {
|
||
if (admises.contains(it.key()) && valeurAdmise(it.value()))
|
||
r.params.insert(it.key(), it.value());
|
||
else if (supprimes)
|
||
supprimes->append(it.key());
|
||
}
|
||
r.code = code;
|
||
return r;
|
||
}
|
||
|
||
Verdict valider(const QVariantMap &result, const SurplusContext &ctx,
|
||
const QVariantMap &capabilities, bool hasCapabilities)
|
||
{
|
||
const QDateTime maintenant = ctx.timestamp;
|
||
if (result.value(QStringLiteral("abstain")).toBool())
|
||
return refus(Issue::Abstention, QStringLiteral("abstain"),
|
||
QStringLiteral("abstention : %1").arg(assainirTexte(result.value(QStringLiteral("reason")))));
|
||
|
||
const QVariant creneauxBruts = result.value(QStringLiteral("slots"));
|
||
if (creneauxBruts.userType() != QMetaType::QVariantList)
|
||
return refus(Issue::Invalide, QStringLiteral("malformed-plan"), QStringLiteral("slots absent ou non liste"));
|
||
if (creneauxBruts.toList().isEmpty())
|
||
return refus(Issue::Abstention, QStringLiteral("empty-plan"), QStringLiteral("plan sans créneau"));
|
||
|
||
QHash<QString, const LoadContext *> charges;
|
||
for (const LoadContext &lc : ctx.loads)
|
||
charges.insert(lc.id, &lc);
|
||
|
||
// §3 : la restriction ne vaut que si le handshake a ANNONCÉ des kinds.
|
||
QStringList kinds;
|
||
const bool restreint = hasCapabilities && capabilities.contains(QStringLiteral("kinds"));
|
||
if (restreint)
|
||
for (const QVariant &k : capabilities.value(QStringLiteral("kinds")).toList())
|
||
kinds.append(k.toString());
|
||
|
||
Verdict v;
|
||
QStringList reserves;
|
||
PlanRecu &plan = v.plan;
|
||
plan.planId = result.value(QStringLiteral("planId")).toString();
|
||
plan.strategy = result.value(QStringLiteral("strategy")).toString();
|
||
lireInstant(result.value(QStringLiteral("computedAt")), &plan.computedAt);
|
||
if (result.value(QStringLiteral("diagnostics")).userType() == QMetaType::QVariantMap)
|
||
plan.diagnostics = result.value(QStringLiteral("diagnostics")).toMap();
|
||
|
||
const QVariantList brut = creneauxBruts.toList();
|
||
for (int i = 0; i < brut.size(); ++i) {
|
||
if (brut.at(i).userType() != QMetaType::QVariantMap)
|
||
return refus(Issue::Invalide, QStringLiteral("malformed-plan"),
|
||
QStringLiteral("créneau %1 qui n'est pas un objet").arg(i), reserves);
|
||
const QVariantMap s = brut.at(i).toMap();
|
||
CreneauRecu c;
|
||
if (!lireInstant(s.value(QStringLiteral("from")), &c.from)
|
||
|| !lireInstant(s.value(QStringLiteral("to")), &c.to) || c.from >= c.to)
|
||
return refus(Issue::Invalide, QStringLiteral("bad-time"),
|
||
QStringLiteral("créneau %1 : from/to illisibles, sans décalage, ou from ≥ to").arg(i),
|
||
reserves);
|
||
if (!plan.creneaux.isEmpty() && c.from < plan.creneaux.last().to)
|
||
return refus(Issue::Invalide, QStringLiteral("slots-unordered"),
|
||
QStringLiteral("créneau %1 : commence avant la fin du précédent").arg(i), reserves);
|
||
|
||
const QVariant actions = s.value(QStringLiteral("actions"));
|
||
if (actions.isValid() && !actions.isNull() && actions.userType() != QMetaType::QVariantList)
|
||
return refus(Issue::Invalide, QStringLiteral("malformed-plan"),
|
||
QStringLiteral("créneau %1 : actions non liste").arg(i), reserves);
|
||
QSet<QString> vues;
|
||
for (const QVariant &a : actions.toList()) {
|
||
ActionRecue ar;
|
||
QString detail;
|
||
const QString erreur = convertirAction(a, charges, restreint ? &kinds : nullptr, &ar,
|
||
&detail, &reserves);
|
||
if (!erreur.isEmpty())
|
||
return refus(Issue::Invalide, erreur,
|
||
QStringLiteral("créneau %1 : %2").arg(i).arg(detail), reserves);
|
||
if (vues.contains(ar.action.loadId))
|
||
return refus(Issue::Invalide, QStringLiteral("duplicate-load"),
|
||
QStringLiteral("créneau %1 : deux actions pour %2").arg(i).arg(ar.action.loadId),
|
||
reserves);
|
||
vues.insert(ar.action.loadId);
|
||
c.actions.append(ar);
|
||
}
|
||
if (s.value(QStringLiteral("expected")).userType() == QMetaType::QVariantMap) {
|
||
c.expected = s.value(QStringLiteral("expected")).toMap();
|
||
for (const QString &champ : kChampsReservesExpected)
|
||
if (c.expected.contains(champ))
|
||
reserves.append(QStringLiteral("expected.") + champ);
|
||
}
|
||
plan.creneaux.append(c);
|
||
}
|
||
reserves.removeDuplicates();
|
||
|
||
const QVariant validUntil = result.value(QStringLiteral("validUntil"));
|
||
if (validUntil.isValid() && !validUntil.isNull()) {
|
||
if (!lireInstant(validUntil, &plan.validUntil))
|
||
return refus(Issue::Invalide, QStringLiteral("bad-time"),
|
||
QStringLiteral("validUntil illisible ou sans décalage"), reserves);
|
||
if (plan.validUntil <= maintenant)
|
||
return refus(Issue::Expire, QStringLiteral("expired"),
|
||
QStringLiteral("validUntil %1 dépassé").arg(validUntil.toString()), reserves);
|
||
}
|
||
|
||
for (int i = 0; i < plan.creneaux.size(); ++i)
|
||
if (plan.creneaux.at(i).from <= maintenant && maintenant < plan.creneaux.at(i).to)
|
||
plan.indexCourant = i;
|
||
if (plan.indexCourant < 0)
|
||
return refus(Issue::Abstention, QStringLiteral("no-current-slot"),
|
||
QStringLiteral("aucun créneau ne couvre %1").arg(maintenant.toString(Qt::ISODate)),
|
||
reserves);
|
||
|
||
// Q1 — option C : un seul décideur par cycle. Une charge disponible que le créneau courant
|
||
// ne nomme pas rendrait la main au rule-based pour elle seule, sur un budget que
|
||
// l'optimiseur a déjà engagé : deux décideurs. Le plan entier est donc refusé.
|
||
const CreneauRecu &courant = plan.creneaux.at(plan.indexCourant);
|
||
QSet<QString> nommees;
|
||
for (const ActionRecue &ar : courant.actions) {
|
||
nommees.insert(ar.action.loadId);
|
||
if (!charges.value(ar.action.loadId)->telemetry.available)
|
||
v.actionsSurIndisponibles.append(ar.action.loadId);
|
||
}
|
||
for (const LoadContext &lc : ctx.loads)
|
||
if (lc.telemetry.available && !nommees.contains(lc.id))
|
||
return refus(Issue::Invalide, QStringLiteral("incomplete-slot"),
|
||
QStringLiteral("créneau courant sans action pour la charge disponible %1").arg(lc.id),
|
||
reserves);
|
||
|
||
v.issue = Issue::Valide;
|
||
v.champsReserves = reserves;
|
||
return v;
|
||
}
|
||
|
||
Plan substituer(const Plan &ombre, const PlanRecu &recu, const SurplusContext &ctx)
|
||
{
|
||
int iOmbre = -1;
|
||
for (int i = 0; i < ombre.timeSlots.size(); ++i)
|
||
if (ombre.timeSlots.at(i).from <= ctx.timestamp && ctx.timestamp < ombre.timeSlots.at(i).to)
|
||
iOmbre = i;
|
||
if (recu.indexCourant < 0 || recu.indexCourant >= recu.creneaux.size() || iOmbre < 0)
|
||
return ombre;
|
||
|
||
QHash<QString, const LoadContext *> charges;
|
||
for (const LoadContext &lc : ctx.loads)
|
||
charges.insert(lc.id, &lc);
|
||
|
||
Plan plan = ombre;
|
||
plan.strategy = QString::fromLatin1(StrategieOptimiseur);
|
||
if (!recu.planId.isEmpty())
|
||
plan.planId = recu.planId;
|
||
// Q4 — recalculés après le dispatch ; ceux de l'ombre décrivent une décision non prise.
|
||
plan.budget = PlanBudget();
|
||
plan.draw.committedW = 0;
|
||
plan.draw.remainingW = plan.draw.authorisedW;
|
||
|
||
QHash<QString, LoadAction> parId;
|
||
for (const ActionRecue &ar : recu.creneaux.at(recu.indexCourant).actions) {
|
||
const LoadContext *lc = charges.value(ar.action.loadId, nullptr);
|
||
if (!lc || !lc->telemetry.available)
|
||
continue; // indisponible : l'action de l'ombre reste, elle ne commande rien
|
||
parId.insert(ar.action.loadId, ar.action);
|
||
if (lc->adapter == QLatin1String("evcharger"))
|
||
plan.waterfallEvIds.insert(ar.action.loadId);
|
||
}
|
||
|
||
Slot &slot = plan.timeSlots[iOmbre];
|
||
for (LoadAction &a : slot.actions)
|
||
if (parId.contains(a.loadId))
|
||
a = parId.take(a.loadId);
|
||
// Une charge disponible sans action dans l'ombre : rangée à sa priorité, après les autres.
|
||
QList<LoadAction> restantes = parId.values();
|
||
std::sort(restantes.begin(), restantes.end(), [&](const LoadAction &x, const LoadAction &y) {
|
||
const int px = charges.value(x.loadId)->priority, py = charges.value(y.loadId)->priority;
|
||
return px != py ? px < py : x.loadId < y.loadId;
|
||
});
|
||
slot.actions.append(restantes);
|
||
return plan;
|
||
}
|
||
|
||
bool estActionOptimiseur(const LoadAction &action)
|
||
{
|
||
return action.levels.size() == 1 && action.levels.first().level == QLatin1String(NiveauOptimiseur);
|
||
}
|
||
|
||
QList<Ecretage> finaliser(Plan &plan, Slot &slot, const QHash<QString, LoadAction> &appliquees,
|
||
const QSet<QString> &delestees, const SurplusContext &ctx)
|
||
{
|
||
QHash<QString, const LoadContext *> charges;
|
||
for (const LoadContext &lc : ctx.loads)
|
||
charges.insert(lc.id, &lc);
|
||
|
||
// Surplus net SIGNÉ, mesure brute (règle 8), plus ce que les charges commandées tirent déjà :
|
||
// leur consommation est dans la mesure, la leur rendre est la correction B du rule-based.
|
||
const double netW = ctx.meter.exportW - ctx.meter.importW;
|
||
double recreditW = 0;
|
||
for (const LoadAction &a : std::as_const(slot.actions))
|
||
if (estActionOptimiseur(a) && !delestees.contains(a.loadId))
|
||
if (const LoadContext *lc = charges.value(a.loadId, nullptr))
|
||
recreditW += qMax(0.0, lc->telemetry.currentPowerW);
|
||
|
||
const QString budget = QString::fromLatin1(CountKey::BudgetAllocated);
|
||
const QString achat = QString::fromLatin1(CountKey::DrawCommitted);
|
||
double resteW = netW + recreditW, alloueW = 0, acheteW = 0;
|
||
QList<Ecretage> ecretages;
|
||
for (LoadAction &a : slot.actions) {
|
||
if (!estActionOptimiseur(a)) {
|
||
// Action de l'ombre gardée (charge indisponible, borne hors contexte) : elle compte
|
||
// pour ce qu'elle déclare, comme sous le rule-based.
|
||
const double s = a.counts.value(budget);
|
||
alloueW += s;
|
||
resteW -= s;
|
||
acheteW += a.counts.value(achat);
|
||
continue;
|
||
}
|
||
if (delestees.contains(a.loadId))
|
||
continue; // L4 a le dernier mot : son action remplace celle-ci après
|
||
|
||
const QString adapter = charges.contains(a.loadId) ? charges.value(a.loadId)->adapter : QString();
|
||
const LoadAction demande = a;
|
||
const bool applique = appliquees.contains(a.loadId);
|
||
double puissanceW = 0;
|
||
if (applique) {
|
||
const LoadAction &r = appliquees.value(a.loadId);
|
||
puissanceW = qMax(0.0, r.estimatedPowerW);
|
||
a.powerW = r.powerW;
|
||
a.state = r.state;
|
||
a.currentA = r.currentA;
|
||
a.phaseCount = r.phaseCount;
|
||
a.chargingEnabled = r.chargingEnabled;
|
||
} else {
|
||
a.powerW = 0;
|
||
a.chargingEnabled = false;
|
||
}
|
||
|
||
const double surplusW = qBound(0.0, resteW, puissanceW);
|
||
const double reseauW = puissanceW - surplusW;
|
||
resteW -= surplusW;
|
||
alloueW += surplusW;
|
||
acheteW += reseauW;
|
||
|
||
LoadAction::Level niveau = a.levels.first();
|
||
niveau.targetW = puissanceW;
|
||
niveau.funding = reseauW > 0.5 ? LoadAction::Grid : LoadAction::Surplus;
|
||
niveau.counts.clear();
|
||
if (surplusW > 0 || reseauW <= 0.5)
|
||
niveau.counts.insert(budget, surplusW);
|
||
if (reseauW > 0.5)
|
||
niveau.counts.insert(achat, reseauW);
|
||
a.estimatedPowerW = puissanceW;
|
||
a.funding = niveau.funding;
|
||
a.counts = niveau.counts;
|
||
a.levels = {niveau};
|
||
|
||
a.clamped = !applique || differe(demande, appliquees.value(a.loadId), adapter);
|
||
if (a.clamped)
|
||
ecretages.append({a.loadId, decrire(demande, adapter),
|
||
applique ? decrire(a, adapter) : QStringLiteral("non appliquée")});
|
||
}
|
||
|
||
plan.budget = PlanBudget();
|
||
plan.budget.surplusW = netW;
|
||
plan.budget.recreditedW = recreditW;
|
||
plan.budget.allocatedW = alloueW;
|
||
plan.budget.remainingW = resteW;
|
||
plan.draw.committedW = acheteW;
|
||
plan.draw.remainingW = plan.draw.authorisedW - acheteW;
|
||
return ecretages;
|
||
}
|
||
|
||
QString nomIssue(Issue issue)
|
||
{
|
||
switch (issue) {
|
||
case Issue::Valide: return QStringLiteral("valide");
|
||
case Issue::Abstention: return QStringLiteral("abstention");
|
||
case Issue::Expire: return QStringLiteral("expiré");
|
||
case Issue::Invalide: return QStringLiteral("invalide");
|
||
}
|
||
return QString();
|
||
}
|
||
|
||
}
|