// SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync #include "optimizerprotocol.h" #include "../types/ecotarget.h" #include 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 &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 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; } }