// 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(); } }