OptimizerProtocol::getPlanParams(SurplusContext, ContexteFil) rend les params d'une requête GetPlan. Fonction pure, compilée dans le plugin mais appelée par personne : sans endpoint, rien ne change. - La prévision voyage À CÔTÉ du contexte (§11) : ContexteFil::prevision porte la sortie de EnergyArbitrator::instantanePrevision(), l'assemblage partagé avec GetForecastSnapshot. forecastDepuisInstantane() la pose sur la grille §5.2 : Wh par pas → p50W moyens (Wh × 3600 / resolutionS), case sans point = null, pv null sans courbe, weather null. - ContexteFil porte aussi trigger (sa source viendra en T3), meter.source, la PV instantanée, la limite de phase, la réserve, le domaine et la mesure par charge. - Inconnu = null ou absent, jamais 0 : contractedPowerW, pv.currentW, capacité et puissances batterie, tarif → null ; learned, comfort, group, champs réservés, force, powerLevels → absents ; borne configurée sans appareil → ni déclaration ni verrous. - meter.exportW brut (règle 8). Champs 1.0 conservés ; energyTargets dérivés par resolveDeadline (installer) et de la dérogation à cible (user-intent). Tests (loadmodel, logique pure) : 4 goldens dans data/optimizer (site sans PV ; PV + ECS relay-router + borne + SG-Ready ; prévision absente ; charge available=false figée), régénérables sous ETM_REGENERER_GOLDEN=1, plus 3 tests d'assertion. Contre-épreuves : contractedPowerW à 0, Wh non convertis, exportW net → chacune fait échouer au moins un test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
318 lines
15 KiB
C++
318 lines
15 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
|
|
|
#include "optimizerprotocol.h"
|
|
#include "../types/ecotarget.h"
|
|
#include <QStringList>
|
|
|
|
namespace OptimizerProtocol {
|
|
|
|
namespace {
|
|
|
|
//! Instant absolu, décalage compris (§5.2 « Instants ») ; invalide → \c null.
|
|
QVariant instant(const QDateTime &dt)
|
|
{
|
|
return dt.isValid() ? QVariant(dt.toString(Qt::ISODate)) : QVariant();
|
|
}
|
|
|
|
QVariantList liste(const QList<double> &v)
|
|
{
|
|
QVariantList out;
|
|
for (double x : v)
|
|
out << x;
|
|
return out;
|
|
}
|
|
|
|
bool piloteeEnWatts(const QString &adapter)
|
|
{
|
|
return adapter == QLatin1String("relay-router") || adapter == QLatin1String("etmvariableload");
|
|
}
|
|
|
|
/*!
|
|
* Borne CONFIGURÉE dont l'appareil manque (buildContext, 3g-2) : elle arrive sans déclaration,
|
|
* verrous ni état de branchement. Ses zéros disent « inconnu », pas « 0 A » ni « pas de verrou »,
|
|
* et ne sont pas émis. Une borne réelle a toujours un courant maximal.
|
|
*/
|
|
bool borneSansAppareil(const LoadContext &lc)
|
|
{
|
|
return lc.adapter == QLatin1String("evcharger") && lc.declared.maxA <= 0;
|
|
}
|
|
|
|
QVariantMap declared(const LoadContext &lc)
|
|
{
|
|
const LoadDeclared &d = lc.declared;
|
|
QVariantMap out;
|
|
if (borneSansAppareil(lc))
|
|
return out;
|
|
if (lc.adapter == QLatin1String("evcharger")) {
|
|
out.insert(QStringLiteral("minA"), d.minA);
|
|
out.insert(QStringLiteral("maxA"), d.maxA);
|
|
out.insert(QStringLiteral("phases"), d.phases);
|
|
// Le plancher et le plafond du cycle en W (phases VISÉES, limite de phase comprise).
|
|
out.insert(QStringLiteral("minPowerW"), d.minPowerW);
|
|
out.insert(QStringLiteral("maxPowerW"), d.maxPowerW);
|
|
} else if (piloteeEnWatts(lc.adapter)) {
|
|
// minPowerW = 0 est une valeur (« aucun plancher », LM-1200), pas une inconnue.
|
|
// `powerLevels` n'est pas émis : le protocole ne le décrit pas (écart E9).
|
|
out.insert(QStringLiteral("minPowerW"), d.minPowerW);
|
|
out.insert(QStringLiteral("maxPowerW"), d.maxPowerW);
|
|
} else if (lc.adapter == QLatin1String("sg-ready")) {
|
|
QVariantList etats;
|
|
for (int s : d.states)
|
|
etats << s;
|
|
out.insert(QStringLiteral("states"), etats);
|
|
// Clés d'objet JSON : l'état devient une chaîne ("3"), comme dans l'exemple du §5.3.
|
|
QVariantMap estimes;
|
|
for (auto it = d.estimatedPowerW.constBegin(); it != d.estimatedPowerW.constEnd(); ++it)
|
|
estimes.insert(QString::number(it.key()), it.value());
|
|
out.insert(QStringLiteral("estimatedPowerW"), estimes);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
QVariantMap telemetry(const LoadContext &lc, const ContexteFil &fil)
|
|
{
|
|
const LoadContextTelemetry &t = lc.telemetry;
|
|
QVariantMap out;
|
|
out.insert(QStringLiteral("currentPowerW"), t.currentPowerW);
|
|
out.insert(QStringLiteral("available"), t.available);
|
|
if (piloteeEnWatts(lc.adapter)) {
|
|
out.insert(QStringLiteral("lockMinPowerW"), t.lockMinPowerW);
|
|
out.insert(QStringLiteral("lockMaxPowerW"), t.lockMaxPowerW);
|
|
} else if (lc.adapter == QLatin1String("evcharger") && !borneSansAppareil(lc)) {
|
|
out.insert(QStringLiteral("pluggedIn"), t.pluggedIn);
|
|
out.insert(QStringLiteral("charging"), t.charging);
|
|
// Une borne qui ne compte pas sa session n'a pas de sessionWh : son 0 se lirait
|
|
// « rien chargé » (LM-1009 C1).
|
|
if (t.sessionMeasurable)
|
|
out.insert(QStringLiteral("sessionWh"), t.sessionWh);
|
|
} else if (lc.adapter == QLatin1String("sg-ready")) {
|
|
out.insert(QStringLiteral("state"), t.state);
|
|
out.insert(QStringLiteral("minState"), t.minState);
|
|
out.insert(QStringLiteral("maxState"), t.maxState);
|
|
}
|
|
if (fil.mesures.contains(lc.id)) {
|
|
const MesureFil m = fil.mesures.value(lc.id);
|
|
QVariantMap mesure{{QStringLiteral("source"), m.source}};
|
|
if (m.source != QLatin1String("none"))
|
|
mesure.insert(QStringLiteral("powerW"), m.powerW);
|
|
out.insert(QStringLiteral("measurement"), mesure);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
QVariantMap needs(const LoadContext &lc, const QDateTime &now)
|
|
{
|
|
const LoadNeeds &n = lc.needs;
|
|
QVariantMap out;
|
|
// Champs 1.0, émis tels quels quand ils sont déclarés.
|
|
if (!n.dailyDeadline.isEmpty()) out.insert(QStringLiteral("dailyDeadline"), n.dailyDeadline);
|
|
if (n.minEnergyWhPerDay > 0) out.insert(QStringLiteral("minEnergyWhPerDay"), n.minEnergyWhPerDay);
|
|
if (n.targetSocPercent > 0) out.insert(QStringLiteral("targetSocPercent"), n.targetSocPercent);
|
|
if (n.deadline.isValid()) out.insert(QStringLiteral("deadline"), instant(n.deadline));
|
|
|
|
// energyTargets (1.1) : ce que le moteur sait exprimer en Wh, à une échéance ABSOLUE.
|
|
// L'échéance quotidienne est résolue par la fonction du moteur, pas recalculée ici.
|
|
// Un SOC cible de VE n'y entre pas : sans capacité de batterie, aucune conversion en Wh.
|
|
QVariantList cibles;
|
|
if (n.minEnergyWhPerDay > 0) {
|
|
const QDateTime by = resolveDeadline(n, now);
|
|
if (by.isValid())
|
|
cibles << QVariantMap{{QStringLiteral("by"), instant(by)},
|
|
{QStringLiteral("minWh"), n.minEnergyWhPerDay},
|
|
{QStringLiteral("source"), QStringLiteral("installer")}};
|
|
}
|
|
if (lc.derogation && lc.derogationTargetWh > 0 && lc.derogationEndsAt.isValid())
|
|
cibles << QVariantMap{{QStringLiteral("by"), instant(lc.derogationEndsAt)},
|
|
{QStringLiteral("minWh"), lc.derogationTargetWh},
|
|
{QStringLiteral("source"), QStringLiteral("user-intent")}};
|
|
if (!cibles.isEmpty())
|
|
out.insert(QStringLiteral("energyTargets"), cibles);
|
|
return out;
|
|
}
|
|
|
|
QVariantMap limits(const LoadContext &lc)
|
|
{
|
|
const LoadLimits &l = lc.limits;
|
|
if (piloteeEnWatts(lc.adapter))
|
|
return {{QStringLiteral("minOnS"), l.minOnS}, {QStringLiteral("minOffS"), l.minOffS}};
|
|
if (lc.adapter == QLatin1String("evcharger") && !borneSansAppareil(lc))
|
|
return {{QStringLiteral("chargingEnabledLockS"), l.chargingEnabledLockS},
|
|
{QStringLiteral("currentChangeLockS"), l.currentChangeLockS}};
|
|
if (lc.adapter == QLatin1String("sg-ready"))
|
|
return {{QStringLiteral("minStateHoldS"), l.minStateHoldS}};
|
|
return {};
|
|
}
|
|
|
|
QVariantMap charge(const LoadContext &lc, const ContexteFil &fil, const QDateTime &now)
|
|
{
|
|
QVariantMap out;
|
|
out.insert(QStringLiteral("id"), lc.id);
|
|
out.insert(QStringLiteral("adapter"), lc.adapter);
|
|
out.insert(QStringLiteral("label"), lc.label);
|
|
out.insert(QStringLiteral("priority"), lc.priority);
|
|
const QString domaine = fil.domaines.value(lc.id);
|
|
if (!domaine.isEmpty())
|
|
out.insert(QStringLiteral("domain"), domaine);
|
|
// `group` : absent tant que les groupes ne sont pas implémentés (§5.3).
|
|
const QVariantMap d = declared(lc);
|
|
if (!d.isEmpty())
|
|
out.insert(QStringLiteral("declared"), d);
|
|
// `learned` : absent tant qu'aucun profil n'a été reçu (écart E12) — 0 Wh à confiance 0
|
|
// affirmerait un profil vide.
|
|
if (lc.learned.confidence > 0 || lc.learned.dailyEnergyWh > 0)
|
|
out.insert(QStringLiteral("learned"),
|
|
QVariantMap{{QStringLiteral("dailyEnergyWh"), lc.learned.dailyEnergyWh},
|
|
{QStringLiteral("confidence"), lc.learned.confidence}});
|
|
out.insert(QStringLiteral("telemetry"), telemetry(lc, fil));
|
|
const QVariantMap n = needs(lc, now);
|
|
if (!n.isEmpty())
|
|
out.insert(QStringLiteral("needs"), n);
|
|
const QVariantMap l = limits(lc);
|
|
if (!l.isEmpty())
|
|
out.insert(QStringLiteral("limits"), l);
|
|
return out;
|
|
}
|
|
|
|
QVariant battery(const SurplusBattery &b, const ContexteFil &fil)
|
|
{
|
|
if (!b.present)
|
|
return QVariantMap{{QStringLiteral("present"), false}};
|
|
auto connu = [](double v) { return v > 0 ? QVariant(v) : QVariant(); };
|
|
QVariantMap out;
|
|
out.insert(QStringLiteral("present"), true);
|
|
out.insert(QStringLiteral("socPercent"), b.socPercent);
|
|
out.insert(QStringLiteral("powerW"), b.powerW);
|
|
out.insert(QStringLiteral("capacityWh"), connu(b.capacityWh));
|
|
out.insert(QStringLiteral("maxChargeW"), connu(b.maxChargeW));
|
|
out.insert(QStringLiteral("maxDischargeW"), connu(b.maxDischargeW));
|
|
out.insert(QStringLiteral("reserveSocPercent"),
|
|
fil.reserveSocPercent ? QVariant(*fil.reserveSocPercent)
|
|
: connu(b.reserveSocPercent));
|
|
return out;
|
|
}
|
|
|
|
QVariant tariff(const SurplusTariff &t)
|
|
{
|
|
// Aucun écrivain de SurplusContext::tariff aujourd'hui (écart E3) : null, jamais un prix 0.
|
|
if (t.provider.isEmpty() && !t.current.from.isValid() && t.current.label.isEmpty()
|
|
&& t.next.isEmpty())
|
|
return QVariant();
|
|
auto entree = [](const TariffEntry &e, bool avecDebut) {
|
|
QVariantMap m{{QStringLiteral("label"), e.label},
|
|
{QStringLiteral("priceCtkWh"), e.priceCtkWh}};
|
|
if (avecDebut)
|
|
m.insert(QStringLiteral("from"), instant(e.from));
|
|
return m;
|
|
};
|
|
QVariantList suivants;
|
|
for (const TariffEntry &e : t.next)
|
|
suivants << entree(e, true);
|
|
return QVariantMap{{QStringLiteral("provider"),
|
|
t.provider.isEmpty() ? QVariant() : QVariant(t.provider)},
|
|
{QStringLiteral("current"), entree(t.current, false)},
|
|
{QStringLiteral("next"), suivants}};
|
|
}
|
|
|
|
} // namespace
|
|
|
|
QVariantMap forecastDepuisInstantane(const QVariantMap &instantane)
|
|
{
|
|
QVariantMap out;
|
|
const QDateTime start = QDateTime::fromString(instantane.value(QStringLiteral("start")).toString(),
|
|
Qt::ISODate);
|
|
const int pas = instantane.value(QStringLiteral("resolutionS")).toInt();
|
|
const int creneaux = instantane.value(QStringLiteral("steps")).toInt();
|
|
out.insert(QStringLiteral("start"), instantane.value(QStringLiteral("start")));
|
|
out.insert(QStringLiteral("resolutionS"), pas);
|
|
out.insert(QStringLiteral("steps"), creneaux);
|
|
|
|
// --- pv : la courbe, posée sur la grille ; une case sans point reste null -----------------
|
|
const QVariantList points = instantane.value(QStringLiteral("forecastPoints")).toList();
|
|
if (points.isEmpty() || !start.isValid() || pas <= 0 || creneaux <= 0) {
|
|
// Pas de courbe (site non désigné, charge utile refusée, canal muet) : `pv` est null.
|
|
// Le motif (`fault`) n'a pas de place sur le fil 1.1 (écart E24).
|
|
out.insert(QStringLiteral("pv"), QVariant());
|
|
} else {
|
|
QVariantList p50(creneaux);
|
|
for (const QVariant &v : points) {
|
|
const QVariantMap p = v.toMap();
|
|
const QDateTime t = QDateTime::fromString(p.value(QStringLiteral("targetAt")).toString(),
|
|
Qt::ISODate);
|
|
const qint64 decalage = start.secsTo(t);
|
|
if (!t.isValid() || decalage < 0 || decalage % pas != 0 || decalage / pas >= creneaux)
|
|
continue;
|
|
// Wh par pas → puissance moyenne sur le pas (§5.2 « pv » ; écart E21).
|
|
p50[int(decalage / pas)] = p.value(QStringLiteral("forecastWh")).toDouble() * 3600.0 / pas;
|
|
}
|
|
QVariantMap pv;
|
|
pv.insert(QStringLiteral("p50W"), p50);
|
|
pv.insert(QStringLiteral("conversion"), instantane.value(QStringLiteral("conversion")));
|
|
pv.insert(QStringLiteral("corrections"), instantane.value(QStringLiteral("corrections")).toList());
|
|
// Le modèle qui a RÉPONDU, null si inconnu — jamais le modèle configuré.
|
|
pv.insert(QStringLiteral("weatherModel"), instantane.contains(QStringLiteral("model"))
|
|
? instantane.value(QStringLiteral("model"))
|
|
: QVariant());
|
|
pv.insert(QStringLiteral("issuedAt"), instantane.contains(QStringLiteral("issuedAt"))
|
|
? instantane.value(QStringLiteral("issuedAt"))
|
|
: QVariant());
|
|
pv.insert(QStringLiteral("contentChangedAt"),
|
|
instantane.contains(QStringLiteral("contentChangedAt"))
|
|
? instantane.value(QStringLiteral("contentChangedAt")) : QVariant());
|
|
if (instantane.contains(QStringLiteral("acLimitW")))
|
|
pv.insert(QStringLiteral("acLimitW"), instantane.value(QStringLiteral("acLimitW")));
|
|
// perField : identifiant et corrections seulement — l'instantané ne porte ni libellé, ni
|
|
// géométrie, ni courbe par plan (optionnels au §5.2).
|
|
if (instantane.contains(QStringLiteral("perField")))
|
|
pv.insert(QStringLiteral("perField"), instantane.value(QStringLiteral("perField")));
|
|
// `provider` : non émis, rien ne le porte dans l'instantané (écart E25).
|
|
out.insert(QStringLiteral("pv"), pv);
|
|
}
|
|
|
|
out.insert(QStringLiteral("baseload"), instantane.value(QStringLiteral("baseload")));
|
|
out.insert(QStringLiteral("weather"), QVariant()); // aucune source de température
|
|
out.insert(QStringLiteral("price"), instantane.value(QStringLiteral("price")));
|
|
return out;
|
|
}
|
|
|
|
QVariantMap getPlanParams(const SurplusContext &ctx, const ContexteFil &fil)
|
|
{
|
|
QVariantMap out;
|
|
out.insert(QStringLiteral("timestamp"), instant(ctx.timestamp));
|
|
out.insert(QStringLiteral("trigger"), fil.trigger);
|
|
|
|
const QList<double> phases = !fil.phaseLimitA.isEmpty() ? fil.phaseLimitA : ctx.site.phaseLimitA;
|
|
out.insert(QStringLiteral("site"),
|
|
QVariantMap{{QStringLiteral("contractedPowerW"),
|
|
ctx.site.contractedPowerW > 0 ? QVariant(ctx.site.contractedPowerW)
|
|
: QVariant()},
|
|
{QStringLiteral("gridCapW"), QVariant()}, // réservé, §5.1
|
|
{QStringLiteral("phaseLimitA"),
|
|
phases.isEmpty() ? QVariant() : QVariant(liste(phases))}});
|
|
|
|
// Mesure BRUTE du compteur (règle 8) : jamais le net après pilotage.
|
|
out.insert(QStringLiteral("meter"),
|
|
QVariantMap{{QStringLiteral("source"),
|
|
fil.meterSource.isEmpty() ? QVariant() : QVariant(fil.meterSource)},
|
|
{QStringLiteral("importW"), ctx.meter.importW},
|
|
{QStringLiteral("exportW"), ctx.meter.exportW},
|
|
{QStringLiteral("perPhaseA"), liste(ctx.meter.perPhaseA)}});
|
|
|
|
out.insert(QStringLiteral("pv"),
|
|
QVariantMap{{QStringLiteral("currentW"),
|
|
fil.pvCurrentW ? QVariant(*fil.pvCurrentW) : QVariant()}});
|
|
out.insert(QStringLiteral("battery"), battery(ctx.battery, fil));
|
|
|
|
QVariantList charges;
|
|
for (const LoadContext &lc : ctx.loads)
|
|
charges << charge(lc, fil, ctx.timestamp);
|
|
out.insert(QStringLiteral("loads"), charges);
|
|
|
|
out.insert(QStringLiteral("forecast"),
|
|
fil.prevision ? QVariant(forecastDepuisInstantane(*fil.prevision)) : QVariant());
|
|
out.insert(QStringLiteral("tariff"), tariff(ctx.tariff));
|
|
return out;
|
|
}
|
|
|
|
}
|