Patrick Schurig 053263bc38 feat(rpc): NymeaEnergy.GetOptimizerProtocol sert le contrat embarqué
Le contrat OpenRPC est une ressource Qt du plugin (jamais lu sur disque à
l'exécution) ; GetOptimizerProtocol rend { protocolVersion, schema }, lecture,
PermissionScopeControlThings, aucun secret, sans *Changed (statique pour un
binaire). Documenté dans INTERFACE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:30:44 +02:00

347 lines
16 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 <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QLoggingCategory>
#include <QStringList>
Q_DECLARE_LOGGING_CATEGORY(dcNymeaEnergy)
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);
// Champ 1.0 conservé, mais le contexte y porte les phases VISÉES ce cycle
// (EvAdapter::toLoadContext), pas les phases disponibles du §5.3 : écart E26.
out.insert(QStringLiteral("phases"), d.phases);
// PAS de minPowerW/maxPowerW : pour une borne, ils sont RECALCULÉS à chaque cycle
// (phases visées, limite de phase) — ce ne sont pas des valeurs de plaque, et `declared`
// ne porte que ce que l'installateur a saisi (§5.3). Écart E26.
} 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;
}
QByteArray schemaDocumentBytes()
{
QFile f(QString::fromLatin1(SchemaResource));
if (!f.open(QIODevice::ReadOnly))
return QByteArray();
return f.readAll();
}
QVariantMap schemaDocument()
{
QJsonParseError erreur;
const QJsonDocument doc = QJsonDocument::fromJson(schemaDocumentBytes(), &erreur);
if (erreur.error != QJsonParseError::NoError || !doc.isObject()) {
qCWarning(dcNymeaEnergy()).noquote()
<< "[Optimiseur] contrat OpenRPC embarqué ILLISIBLE (" + erreur.errorString()
+ ") — GetOptimizerProtocol rend un schéma vide";
return QVariantMap();
}
return doc.object().toVariantMap();
}
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;
}
}