Patrick Schurig b45d0496c6 feat(moteur): listes fermées du protocole exposées par le code
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>
2026-10-17 21:30:44 +02:00

586 lines
25 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

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

// SPDX-License-Identifier: GPL-3.0-or-later
// 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 &params, 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();
}
}