diff --git a/energyplugin/etm/etm.pri b/energyplugin/etm/etm.pri index e716e11..0415330 100644 --- a/energyplugin/etm/etm.pri +++ b/energyplugin/etm/etm.pri @@ -17,6 +17,7 @@ HEADERS += \ $$PWD/adapters/etmvariableloadadapter.h \ $$PWD/adapters/relayrouter.h \ $$PWD/scheduler/rulebasedscheduler.h \ + $$PWD/scheduler/optimizerprotocol.h \ $$PWD/ratios/energyratioscalculator.h \ $$PWD/energyarbitrator.h \ $$PWD/spotmarketbuild.h \ @@ -34,5 +35,6 @@ SOURCES += \ $$PWD/adapters/etmvariableloadadapter.cpp \ $$PWD/adapters/relayrouter.cpp \ $$PWD/scheduler/rulebasedscheduler.cpp \ + $$PWD/scheduler/optimizerprotocol.cpp \ $$PWD/energyarbitrator.cpp \ $$PWD/spotmarketbuild.cpp \ diff --git a/energyplugin/etm/scheduler/optimizerprotocol.cpp b/energyplugin/etm/scheduler/optimizerprotocol.cpp new file mode 100644 index 0000000..f3074c7 --- /dev/null +++ b/energyplugin/etm/scheduler/optimizerprotocol.cpp @@ -0,0 +1,317 @@ +// 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; +} + +} diff --git a/energyplugin/etm/scheduler/optimizerprotocol.h b/energyplugin/etm/scheduler/optimizerprotocol.h new file mode 100644 index 0000000..2d64f76 --- /dev/null +++ b/energyplugin/etm/scheduler/optimizerprotocol.h @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync +#pragma once + +#include +#include +#include +#include +#include +#include "../types/surpluscontext.h" + +/*! + * \file optimizerprotocol.h + * \brief Sérialisation \c SurplusContext → paramètres de \c GetPlan (OPTIMIZER_PROTOCOL.md 1.1, + * §5.1 à §5.3). Fonction PURE : aucune E/S, aucune horloge, aucun accès à l'arbitre. + * + * \par Ce qui n'est pas dans \c SurplusContext, et pourquoi + * La prévision n'y entre pas (DESIGN_PREVISION_PV.md §11 : le rule-based ne doit pas pouvoir + * la lire). Elle voyage À CÔTÉ, dans \c ContexteFil, avec ce que l'arbitre sait et que le + * contexte de décision ne porte pas : déclencheur, compteur racine, domaine et mesure par charge. + * + * \par Inconnu = \c null ou absent, JAMAIS 0 + * Un champ que rien ne remplit (puissance souscrite, PV instantanée, capacité batterie, tarif) + * est émis \c null ; un champ que le protocole rend optionnel et que rien ne porte (\c learned, + * \c comfort, \c group, champs réservés de l'annexe F) est ABSENT. Un zéro affirmerait une + * mesure ou un profil qui n'existent pas. + */ +namespace OptimizerProtocol { + +//! Version du protocole parlée par ce sérialiseur (§3, §9). +constexpr const char *ProtocolVersion = "1.1"; + +//! Mesure d'une charge, telle que la résout l'arbitre (\c EnergyArbitrator::mesurerCharge). +struct MesureFil { + QString source = QStringLiteral("none"); //!< \c meter | \c device | \c none + double powerW = 0; //!< Sans objet, et non émis, sous \c none. +}; + +/*! + * \brief Ce que la requête porte EN PLUS du contexte de décision. + * + * Rempli par l'appelant (le futur \c SocketScheduler), jamais par le rule-based. Tout champ + * laissé à sa valeur par défaut se sérialise comme INCONNU. + */ +struct ContexteFil { + //! Cause de la requête (§5.1) : \c cycle | \c load-change | \c intent | \c config | \c forecast. + QString trigger = QStringLiteral("cycle"); + //! ThingId du compteur racine (\c meter.source) ; vide = inconnu → \c null. + QString meterSource; + //! Production PV instantanée (\c pv.currentW) ; absente = inconnue → \c null. Le champ + //! \c SurplusContext::pv n'est pas lu : il n'est jamais rempli, et son 0 ne distingue pas + //! « nuit » de « pas d'onduleur ». + std::optional pvCurrentW; + //! Limite par phase en A (\c site.phaseLimitA) ; vide = celle du contexte, sinon \c null. + QList phaseLimitA; + //! Réserve de stockage en % (\c battery.reserveSocPercent) ; absente = celle du contexte si + //! elle est renseignée (> 0), sinon \c null. + std::optional reserveSocPercent; + //! Domaine déclaré par l'installateur, par charge (\c loads[].domain) ; absent = non émis. + QHash domaines; + //! Mesure par charge (\c loads[].telemetry.measurement) ; absente = non émise. + QHash mesures; + /*! + * \brief L'instantané de prévision ASSEMBLÉ par \c EnergyArbitrator::instantanePrevision() + * — la charge utile de \c GetForecastSnapshot, un seul assemblage pour la RPC et le fil. + * \c std::nullopt → \c forecast: null. + */ + std::optional prevision; +}; + +/*! + * \brief Les \c params d'une requête \c GetPlan. + * \param ctx Contexte du cycle (le même que celui que reçoit le rule-based). + * \param fil Ce que la requête porte en plus (voir \c ContexteFil). + * \return Un objet JSON prêt à sérialiser ; clés et sens de OPTIMIZER_PROTOCOL.md §5. + * \note \c meter.exportW est la mesure BRUTE du compteur (AGENTS règle 8). + */ +QVariantMap getPlanParams(const SurplusContext &ctx, const ContexteFil &fil); + +/*! + * \brief Le bloc \c forecast (§5.2) depuis l'instantané de prévision. + * \param instantane Sortie de \c EnergyArbitrator::instantanePrevision(). + * \return \c start, \c resolutionS, \c steps, \c pv (\c null sans courbe), \c baseload, + * \c weather (\c null : aucune source), \c price. + * \note La courbe interne est en Wh PAR PAS ; le fil porte une puissance moyenne : + * \c p50W = Wh × 3600 / \c resolutionS. Une case sans point reste \c null. + */ +QVariantMap forecastDepuisInstantane(const QVariantMap &instantane); + +} diff --git a/tests/auto/loadmodel/data/optimizer/charge-indisponible-figee.json b/tests/auto/loadmodel/data/optimizer/charge-indisponible-figee.json new file mode 100644 index 0000000..73cb103 --- /dev/null +++ b/tests/auto/loadmodel/data/optimizer/charge-indisponible-figee.json @@ -0,0 +1,122 @@ +{ + "battery": { + "present": false + }, + "forecast": { + "baseload": { + "model": "none", + "p50W": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + "price": null, + "pv": null, + "resolutionS": 3600, + "start": "2026-10-11T22:00:00Z", + "steps": 48, + "weather": null + }, + "loads": [ + { + "adapter": "evcharger", + "id": "{0d4c6a7e-3333-4c1a-9a01-000000000003}", + "label": "Borne garage", + "priority": 1, + "telemetry": { + "available": false, + "currentPowerW": 0 + } + }, + { + "adapter": "relay-router", + "declared": { + "maxPowerW": 2400, + "minPowerW": 800 + }, + "id": "{0d4c6a7e-5555-4c1a-9a01-000000000005}", + "label": "Routeur garage", + "limits": { + "minOffS": 300, + "minOnS": 300 + }, + "priority": 2, + "telemetry": { + "available": false, + "currentPowerW": 1200, + "lockMaxPowerW": 1200, + "lockMinPowerW": 1200, + "measurement": { + "source": "none" + } + } + } + ], + "meter": { + "exportW": 0, + "importW": 1350, + "perPhaseA": [ + 5.9, + 0.3, + 0.2 + ], + "source": "{0d4c6a7e-1111-4c1a-9a01-000000000001}" + }, + "pv": { + "currentW": null + }, + "site": { + "contractedPowerW": null, + "gridCapW": null, + "phaseLimitA": null + }, + "tariff": null, + "timestamp": "2026-10-12T13:42:00+02:00", + "trigger": "cycle" +} diff --git a/tests/auto/loadmodel/data/optimizer/prevision-absente.json b/tests/auto/loadmodel/data/optimizer/prevision-absente.json new file mode 100644 index 0000000..6d985de --- /dev/null +++ b/tests/auto/loadmodel/data/optimizer/prevision-absente.json @@ -0,0 +1,120 @@ +{ + "battery": { + "present": false + }, + "forecast": { + "baseload": { + "model": "none", + "p50W": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + "price": null, + "pv": null, + "resolutionS": 3600, + "start": "2026-10-11T22:00:00Z", + "steps": 48, + "weather": null + }, + "loads": [ + { + "adapter": "relay-router", + "declared": { + "maxPowerW": 2400, + "minPowerW": 800 + }, + "id": "{0d4c6a7e-2222-4c1a-9a01-000000000002}", + "label": "Chauffe-eau", + "limits": { + "minOffS": 300, + "minOnS": 300 + }, + "needs": { + "dailyDeadline": "18:00", + "energyTargets": [ + { + "by": "2026-10-12T18:00:00+02:00", + "minWh": 4000, + "source": "installer" + } + ], + "minEnergyWhPerDay": 4000 + }, + "priority": 2, + "telemetry": { + "available": true, + "currentPowerW": 1600, + "lockMaxPowerW": -1, + "lockMinPowerW": 1600 + } + } + ], + "meter": { + "exportW": 1200, + "importW": 0, + "perPhaseA": [ + 1, + 1, + 1 + ], + "source": "{0d4c6a7e-1111-4c1a-9a01-000000000001}" + }, + "pv": { + "currentW": null + }, + "site": { + "contractedPowerW": null, + "gridCapW": null, + "phaseLimitA": null + }, + "tariff": null, + "timestamp": "2026-10-12T13:42:00+02:00", + "trigger": "cycle" +} diff --git a/tests/auto/loadmodel/data/optimizer/site-pv-ecs-borne-sgready.json b/tests/auto/loadmodel/data/optimizer/site-pv-ecs-borne-sgready.json new file mode 100644 index 0000000..e4615ab --- /dev/null +++ b/tests/auto/loadmodel/data/optimizer/site-pv-ecs-borne-sgready.json @@ -0,0 +1,213 @@ +{ + "battery": { + "capacityWh": null, + "maxChargeW": null, + "maxDischargeW": null, + "powerW": -800, + "present": true, + "reserveSocPercent": 15, + "socPercent": 62 + }, + "forecast": { + "baseload": { + "model": "profile", + "p50W": [ + 420, + 380, + 395, + 450, + 610, + 720 + ], + "quality": { + "commandedMinutes": 0, + "computedAt": "2026-10-12T03:00:00Z", + "daysCovered": { + "weekend": 6, + "workday": 14 + }, + "incompleteMinutes": 12, + "loads": [ + { + "constantMeasurement": false, + "loadId": "{0d4c6a7e-2222-4c1a-9a01-000000000002}", + "minutesBySource": { + "meter": 28800 + }, + "source": "meter" + } + ], + "minDays": 7, + "minutes": 28800, + "negativeMinutes": 0, + "resolutionS": 3600, + "windowDays": 28 + } + }, + "price": null, + "pv": { + "acLimitW": 8000, + "contentChangedAt": "2026-10-12T10:07:00Z", + "conversion": "physics", + "corrections": [ + "temperature", + "ac-limit" + ], + "issuedAt": "2026-10-12T09:00:00Z", + "p50W": [ + 5200, + 4800, + 3900, + null, + 1500, + 300 + ], + "perField": [ + { + "corrections": [ + "temperature" + ], + "id": "{0d4c6a7e-6666-4c1a-9a01-000000000006}" + } + ], + "weatherModel": "meteofrance_seamless" + }, + "resolutionS": 3600, + "start": "2026-10-12T12:00:00Z", + "steps": 6, + "weather": null + }, + "loads": [ + { + "adapter": "evcharger", + "declared": { + "maxA": 16, + "maxPowerW": 11040, + "minA": 6, + "minPowerW": 4140, + "phases": 3 + }, + "domain": "ev", + "id": "{0d4c6a7e-3333-4c1a-9a01-000000000003}", + "label": "Borne garage", + "limits": { + "chargingEnabledLockS": 300, + "currentChangeLockS": 60 + }, + "needs": { + "deadline": "2026-10-13T07:00:00+02:00", + "targetSocPercent": 80 + }, + "priority": 1, + "telemetry": { + "available": true, + "charging": true, + "currentPowerW": 4100, + "measurement": { + "powerW": 4100, + "source": "device" + }, + "pluggedIn": true, + "sessionWh": 5200 + } + }, + { + "adapter": "relay-router", + "declared": { + "maxPowerW": 2400, + "minPowerW": 800 + }, + "domain": "ecs", + "id": "{0d4c6a7e-2222-4c1a-9a01-000000000002}", + "label": "Chauffe-eau", + "limits": { + "minOffS": 300, + "minOnS": 300 + }, + "needs": { + "dailyDeadline": "18:00", + "energyTargets": [ + { + "by": "2026-10-12T18:00:00+02:00", + "minWh": 4000, + "source": "installer" + }, + { + "by": "2026-10-12T13:00:00Z", + "minWh": 3000, + "source": "user-intent" + } + ], + "minEnergyWhPerDay": 4000 + }, + "priority": 2, + "telemetry": { + "available": true, + "currentPowerW": 1600, + "lockMaxPowerW": -1, + "lockMinPowerW": 1600, + "measurement": { + "powerW": 1580, + "source": "meter" + } + } + }, + { + "adapter": "sg-ready", + "declared": { + "estimatedPowerW": { + "3": 1800, + "4": 2600 + }, + "states": [ + 1, + 2, + 3, + 4 + ] + }, + "domain": "heating", + "id": "{0d4c6a7e-4444-4c1a-9a01-000000000004}", + "label": "PAC", + "limits": { + "minStateHoldS": 900 + }, + "priority": 3, + "telemetry": { + "available": true, + "currentPowerW": 0, + "maxState": 4, + "measurement": { + "source": "none" + }, + "minState": 2, + "state": 2 + } + } + ], + "meter": { + "exportW": 2310, + "importW": 0, + "perPhaseA": [ + 3.2, + 4.1, + 2.8 + ], + "source": "{0d4c6a7e-1111-4c1a-9a01-000000000001}" + }, + "pv": { + "currentW": 3400 + }, + "site": { + "contractedPowerW": null, + "gridCapW": null, + "phaseLimitA": [ + 63, + 63, + 63 + ] + }, + "tariff": null, + "timestamp": "2026-10-12T13:42:00+02:00", + "trigger": "load-change" +} diff --git a/tests/auto/loadmodel/data/optimizer/site-sans-pv.json b/tests/auto/loadmodel/data/optimizer/site-sans-pv.json new file mode 100644 index 0000000..54852dd --- /dev/null +++ b/tests/auto/loadmodel/data/optimizer/site-sans-pv.json @@ -0,0 +1,238 @@ +{ + "battery": { + "present": false + }, + "forecast": { + "baseload": { + "model": "none", + "p50W": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + "price": { + "currency": "EUR", + "exportCtkWh": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "importCtkWh": [ + 16.96, + 16.96, + 16.96, + 16.96, + 16.96, + 16.96, + 21.46, + 21.46, + 21.46, + 21.46, + 21.46, + 21.46, + 21.46, + 21.46, + 21.46, + 21.46, + 21.46, + 21.46, + 21.46, + 21.46, + 21.46, + 21.46, + 16.96, + 16.96, + 16.96, + 16.96, + 16.96, + 16.96, + 16.96, + 16.96, + 21.46, + 21.46, + 21.46, + 21.46, + 21.46, + 21.46, + 21.46, + 21.46, + 21.46, + 21.46, + 21.46, + 21.46, + 21.46, + 21.46, + 21.46, + 21.46, + 16.96, + 16.96 + ], + "includes": [ + "energy", + "grid", + "taxes" + ], + "knownUntil": "2026-10-13T22:00:00Z", + "source": "tou" + }, + "pv": null, + "resolutionS": 3600, + "start": "2026-10-11T22:00:00Z", + "steps": 48, + "weather": null + }, + "loads": [ + { + "adapter": "relay-router", + "declared": { + "maxPowerW": 2400, + "minPowerW": 800 + }, + "domain": "ecs", + "id": "{0d4c6a7e-2222-4c1a-9a01-000000000002}", + "label": "Chauffe-eau", + "limits": { + "minOffS": 300, + "minOnS": 300 + }, + "needs": { + "dailyDeadline": "18:00", + "energyTargets": [ + { + "by": "2026-10-12T18:00:00+02:00", + "minWh": 4000, + "source": "installer" + } + ], + "minEnergyWhPerDay": 4000 + }, + "priority": 2, + "telemetry": { + "available": true, + "currentPowerW": 0, + "lockMaxPowerW": -1, + "lockMinPowerW": 0, + "measurement": { + "powerW": 0, + "source": "meter" + } + } + } + ], + "meter": { + "exportW": 0, + "importW": 850, + "perPhaseA": [ + 1.2, + 2.3, + 0.4 + ], + "source": "{0d4c6a7e-1111-4c1a-9a01-000000000001}" + }, + "pv": { + "currentW": null + }, + "site": { + "contractedPowerW": null, + "gridCapW": null, + "phaseLimitA": [ + 32, + 32, + 32 + ] + }, + "tariff": null, + "timestamp": "2026-10-12T13:42:00+02:00", + "trigger": "cycle" +} diff --git a/tests/auto/loadmodel/loadmodel.pro b/tests/auto/loadmodel/loadmodel.pro index 7ea2ef7..e5c0ca6 100644 --- a/tests/auto/loadmodel/loadmodel.pro +++ b/tests/auto/loadmodel/loadmodel.pro @@ -11,7 +11,11 @@ TARGET = nymea-energy-loadmodel # par test (cf. AGENTS.md, « NE JAMAIS lancer la suite de simulation en un seul processus »). HEADERS += testloadmodel.h -SOURCES += testloadmodel.cpp +SOURCES += testloadmodel.cpp \ + testoptimizerprotocol.cpp +# Fichiers de référence du sérialiseur GetPlan (brief 4 T1), lus et — sous ETM_REGENERER_GOLDEN=1 — +# réécrits DANS LES SOURCES : une régénération se relit en diff avant d'être commitée. +DEFINES += ETM_GOLDEN_DIR=\\\"$$PWD/data/optimizer\\\" # Une base energy-forecast.sqlite ÉCRITE PAR etm67 (b7317f3) : la migration s'éprouve sur le # vrai fichier, pas sur une imitation (testTheLearningLogMigratesAnEtm67TableInPlace). RESOURCES += loadmodel.qrc diff --git a/tests/auto/loadmodel/testloadmodel.h b/tests/auto/loadmodel/testloadmodel.h index b842ec1..3b0d194 100644 --- a/tests/auto/loadmodel/testloadmodel.h +++ b/tests/auto/loadmodel/testloadmodel.h @@ -132,6 +132,23 @@ private slots: //! Tables dédiées, écriture asynchrone, recalcul, rétention — sans toucher forecastLearning. void testTheBaseloadLogWritesWithoutTouchingTheOtherTables(); + // ── Brief 4 T1 — sérialiseur GetPlan (OPTIMIZER_PROTOCOL 1.1 §5) ─────── + // Corps dans testoptimizerprotocol.cpp ; références dans data/optimizer/. + //! Golden : site sans PV — grille, base sans profil et tarif saisi, aucune courbe. + void testGetPlanParamsGoldenSiteSansPv(); + //! Golden : PV + ECS relay-router + borne + SG-Ready, batterie, dérogation à cible. + void testGetPlanParamsGoldenPvEcsBorneSgReady(); + //! Golden : prévision absente (motif) → `pv: null` ; sans instantané → `forecast: null`. + void testGetPlanParamsGoldenPrevisionAbsente(); + //! Golden : charge `available=false` figée, et borne configurée sans appareil. + void testGetPlanParamsGoldenChargeIndisponibleFigee(); + //! Inconnu = null ou absent, jamais 0 ; ni `group`, ni champs réservés, ni `force`. + void testGetPlanParamsNeverInventsAValue(); + //! `meter.exportW` est la mesure brute (règle 8). + void testGetPlanParamsMeterIsRaw(); + //! Wh par pas → puissance moyenne ; une case sans point reste null. + void testForecastWhPerStepBecomesMeanPower(); + }; #endif // TESTLOADMODEL_H diff --git a/tests/auto/loadmodel/testoptimizerprotocol.cpp b/tests/auto/loadmodel/testoptimizerprotocol.cpp new file mode 100644 index 0000000..52ed4a8 --- /dev/null +++ b/tests/auto/loadmodel/testoptimizerprotocol.cpp @@ -0,0 +1,412 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// +// Brief 4 T1 — sérialiseur GetPlan (OPTIMIZER_PROTOCOL 1.1 §5). +// Logique pure, comme le reste de cette suite : aucun serveur, aucun arbitre. + +#include "testloadmodel.h" + +#include "etm/scheduler/optimizerprotocol.h" + +#include +#include +#include +#include +#include +#include + +using namespace OptimizerProtocol; + +namespace { + +//! L'instant de l'exemple du §5.1 : 2026-10-12T13:42:00+02:00. +QDateTime instantExemple() +{ + return QDateTime(QDate(2026, 10, 12), QTime(13, 42), QTimeZone(7200)); +} + +const QString kCompteur = QStringLiteral("{0d4c6a7e-1111-4c1a-9a01-000000000001}"); +const QString kEcs = QStringLiteral("{0d4c6a7e-2222-4c1a-9a01-000000000002}"); +const QString kBorne = QStringLiteral("{0d4c6a7e-3333-4c1a-9a01-000000000003}"); +const QString kPac = QStringLiteral("{0d4c6a7e-4444-4c1a-9a01-000000000004}"); +const QString kRouteur = QStringLiteral("{0d4c6a7e-5555-4c1a-9a01-000000000005}"); +const QString kPlanSud = QStringLiteral("{0d4c6a7e-6666-4c1a-9a01-000000000006}"); + +//! Chauffe-eau sur routeur de relais : 2 400 W, plus petit palier 800 W, obligation de 4 kWh à 18 h. +LoadContext chauffeEau(const QString &id = kEcs) +{ + LoadContext lc; + lc.id = id; + lc.adapter = QStringLiteral("relay-router"); + lc.label = QStringLiteral("Chauffe-eau"); + lc.priority = 2; + lc.declared.powerLevels = {0, 800, 1600, 2400}; // non émis (écart E9) + lc.declared.minPowerW = 800; + lc.declared.maxPowerW = 2400; + lc.telemetry.currentPowerW = 1600; + lc.telemetry.lockMinPowerW = 1600; + lc.telemetry.lockMaxPowerW = -1; + lc.needs.dailyDeadline = QStringLiteral("18:00"); + lc.needs.minEnergyWhPerDay = 4000; + lc.limits.minOnS = 300; + lc.limits.minOffS = 300; + return lc; +} + +LoadContext borne() +{ + LoadContext lc; + lc.id = kBorne; + lc.adapter = QStringLiteral("evcharger"); + lc.label = QStringLiteral("Borne garage"); + lc.priority = 1; + lc.declared.minA = 6; + lc.declared.maxA = 16; + lc.declared.phases = 3; + lc.declared.minPowerW = 4140; + lc.declared.maxPowerW = 11040; + lc.telemetry.currentPowerW = 4100; + lc.telemetry.pluggedIn = true; + lc.telemetry.charging = true; + lc.telemetry.sessionMeasurable = true; + lc.telemetry.sessionWh = 5200; + lc.telemetry.lockMaxPowerW = -1; + lc.needs.targetSocPercent = 80; + lc.needs.deadline = QDateTime(QDate(2026, 10, 13), QTime(7, 0), QTimeZone(7200)); + lc.limits.chargingEnabledLockS = 300; + lc.limits.currentChangeLockS = 60; + return lc; +} + +LoadContext pac() +{ + LoadContext lc; + lc.id = kPac; + lc.adapter = QStringLiteral("sg-ready"); + lc.label = QStringLiteral("PAC"); + lc.priority = 3; + lc.declared.states = {1, 2, 3, 4}; + lc.declared.estimatedPowerW = {{3, 1800}, {4, 2600}}; + lc.declared.minPowerW = 1800; + lc.telemetry.state = 2; + lc.telemetry.minState = 2; + lc.telemetry.maxState = 4; + lc.limits.minStateHoldS = 900; + return lc; +} + +//! Grille sans courbe de \c instantanePrevision() : minuit local → minuit du surlendemain. +QVariantMap instantaneSansCourbe(const QVariant &price) +{ + QVariantList nuls; + for (int i = 0; i < 48; ++i) + nuls << QVariant(); + return {{"start", "2026-10-11T22:00:00Z"}, {"resolutionS", 3600}, {"steps", 48}, + {"baseload", QVariantMap{{"model", "none"}, {"p50W", nuls}}}, + {"price", price}}; +} + +//! Prix d'un tarif saisi à deux zones, tel que \c seriesConsoEtPrix() le pose sur la grille. +QVariantMap prixTou() +{ + QVariantList import, revente; + for (int i = 0; i < 48; ++i) { + const int heureLocale = i % 24; + import << (heureLocale < 6 || heureLocale >= 22 ? 16.96 : 21.46); + revente << 0.0; + } + return {{"currency", "EUR"}, {"importCtkWh", import}, {"exportCtkWh", revente}, + {"includes", QStringList{"energy", "grid", "taxes"}}, {"source", "tou"}, + {"knownUntil", "2026-10-13T22:00:00Z"}}; +} + +/*! + * Compare à un fichier de référence de \c data/optimizer. \c ETM_REGENERER_GOLDEN=1 le réécrit + * (et le dit) : une régénération se relit en diff, elle ne se fait jamais en silence. + */ +void comparerAuGolden(const QString &nom, const QVariantMap ¶ms) +{ + const QString chemin = QStringLiteral(ETM_GOLDEN_DIR "/%1.json").arg(nom); + const QByteArray obtenu = QJsonDocument::fromVariant(params).toJson(QJsonDocument::Indented); + if (qEnvironmentVariableIsSet("ETM_REGENERER_GOLDEN")) { + QDir().mkpath(QStringLiteral(ETM_GOLDEN_DIR)); + QFile f(chemin); + QVERIFY(f.open(QIODevice::WriteOnly | QIODevice::Truncate)); + f.write(obtenu); + qWarning() << "golden RÉÉCRIT :" << chemin; + return; + } + QFile f(chemin); + QVERIFY2(f.open(QIODevice::ReadOnly), qUtf8Printable(QStringLiteral("golden absent : ") + chemin)); + const QJsonDocument attendu = QJsonDocument::fromJson(f.readAll()); + QVERIFY2(!attendu.isNull(), qUtf8Printable(chemin)); + if (attendu != QJsonDocument::fromJson(obtenu)) { + const QString trace = QDir::temp().filePath(nom + QStringLiteral(".obtenu.json")); + QFile t(trace); + if (t.open(QIODevice::WriteOnly | QIODevice::Truncate)) + t.write(obtenu); + QFAIL(qUtf8Printable(QStringLiteral("écart au golden %1 — obtenu écrit dans %2") + .arg(chemin, trace))); + } +} + +//! Toutes les clés, à toute profondeur, d'une valeur JSON. +void cles(const QVariant &v, QSet &out) +{ + if (v.typeId() == QMetaType::QVariantMap) { + const QVariantMap m = v.toMap(); + for (auto it = m.constBegin(); it != m.constEnd(); ++it) { + out.insert(it.key()); + cles(it.value(), out); + } + } else if (v.typeId() == QMetaType::QVariantList) { + for (const QVariant &e : v.toList()) + cles(e, out); + } +} + +} // namespace + +void TestLoadModel::testGetPlanParamsGoldenSiteSansPv() +{ + SurplusContext ctx; + ctx.timestamp = instantExemple(); + ctx.meter.importW = 850; + ctx.meter.perPhaseA = {1.2, 2.3, 0.4}; + LoadContext ecs = chauffeEau(); + ecs.telemetry.currentPowerW = 0; + ecs.telemetry.lockMinPowerW = 0; + ctx.loads = {ecs}; + + ContexteFil fil; + fil.meterSource = kCompteur; + fil.phaseLimitA = {32, 32, 32}; + fil.domaines.insert(kEcs, QStringLiteral("ecs")); + fil.mesures.insert(kEcs, MesureFil{QStringLiteral("meter"), 0}); + // Aucun site de prévision désigné, aucun candidat : l'instantané se tait sur la courbe, mais + // la grille, la base (pas encore de profil) et le tarif saisi restent publiés. + fil.prevision = instantaneSansCourbe(prixTou()); + + comparerAuGolden(QStringLiteral("site-sans-pv"), getPlanParams(ctx, fil)); +} + +void TestLoadModel::testGetPlanParamsGoldenPvEcsBorneSgReady() +{ + SurplusContext ctx; + ctx.timestamp = instantExemple(); + ctx.meter.exportW = 2310; + ctx.meter.perPhaseA = {3.2, 4.1, 2.8}; + ctx.battery.present = true; + ctx.battery.socPercent = 62; + ctx.battery.powerW = -800; + LoadContext ecs = chauffeEau(); + // Dérogation avec cible : elle devient un besoin `user-intent`. + ecs.derogation = true; + ecs.derogationEndsAt = QDateTime(QDate(2026, 10, 12), QTime(13, 0), QTimeZone::UTC); + ecs.derogationTargetWh = 3000; + ctx.loads = {borne(), ecs, pac()}; + + ContexteFil fil; + fil.trigger = QStringLiteral("load-change"); + fil.meterSource = kCompteur; + fil.pvCurrentW = 3400; + fil.phaseLimitA = {63, 63, 63}; + fil.reserveSocPercent = 15; + fil.domaines = {{kBorne, "ev"}, {kEcs, "ecs"}, {kPac, "heating"}}; + fil.mesures = {{kBorne, MesureFil{"device", 4100}}, {kEcs, MesureFil{"meter", 1580}}, + {kPac, MesureFil{}}}; + + // Courbe de 6 pas, un trou à 15:00Z : la case reste null, les séries restent alignées. + QVariantList points; + const QList> pts = {{"2026-10-12T12:00:00Z", 5200}, {"2026-10-12T13:00:00Z", 4800}, + {"2026-10-12T14:00:00Z", 3900}, {"2026-10-12T16:00:00Z", 1500}, + {"2026-10-12T17:00:00Z", 300}}; + for (const auto &p : pts) + points << QVariantMap{{"targetAt", p.first}, {"forecastWh", p.second}}; + QVariantMap qualite{{"windowDays", 28}, {"minDays", 7}, {"resolutionS", 3600}, + {"daysCovered", QVariantMap{{"workday", 14}, {"weekend", 6}}}, + {"minutes", 28800}, {"commandedMinutes", 0}, {"negativeMinutes", 0}, + {"incompleteMinutes", 12}, {"computedAt", "2026-10-12T03:00:00Z"}, + {"loads", QVariantList{QVariantMap{{"loadId", kEcs}, {"source", "meter"}, + {"minutesBySource", QVariantMap{{"meter", 28800}}}, + {"constantMeasurement", false}}}}}; + fil.prevision = QVariantMap{ + {"start", "2026-10-12T12:00:00Z"}, {"resolutionS", 3600}, {"steps", 6}, + {"baseload", QVariantMap{{"model", "profile"}, + {"p50W", QVariantList{420.0, 380.0, 395.0, 450.0, 610.0, 720.0}}, + {"quality", qualite}}}, + {"price", QVariant()}, + {"issuedAt", "2026-10-12T09:00:00Z"}, {"model", "meteofrance_seamless"}, + {"contentChangedAt", "2026-10-12T10:07:00Z"}, {"forecastPoints", points}, + {"corrections", QStringList{"temperature", "ac-limit"}}, + {"perField", QVariantList{QVariantMap{{"id", kPlanSud}, + {"corrections", QStringList{"temperature"}}}}}, + {"conversion", "physics"}, {"acLimitW", 8000}}; + + comparerAuGolden(QStringLiteral("site-pv-ecs-borne-sgready"), getPlanParams(ctx, fil)); +} + +void TestLoadModel::testGetPlanParamsGoldenPrevisionAbsente() +{ + SurplusContext ctx; + ctx.timestamp = instantExemple(); + ctx.meter.exportW = 1200; + ctx.meter.perPhaseA = {1.0, 1.0, 1.0}; + ctx.loads = {chauffeEau()}; + + ContexteFil fil; + fil.meterSource = kCompteur; + // Des plans existent mais aucun site n'est désigné : l'instantané porte le motif et AUCUNE + // courbe. Sur le fil, `pv` est null — pas une courbe de zéros — et le reste de la grille tient. + QVariantMap instantane = instantaneSansCourbe(QVariant()); + instantane.insert("fault", "MISSING_FORECAST_SITE"); + instantane.insert("faultParams", QVariantMap{{"candidates", 2}}); + fil.prevision = instantane; + + comparerAuGolden(QStringLiteral("prevision-absente"), getPlanParams(ctx, fil)); + + // Et sans instantané du tout : `forecast` est null (§5.2 « forecast peut être null »). + fil.prevision.reset(); + const QVariantMap params = getPlanParams(ctx, fil); + QVERIFY(params.contains("forecast")); + QVERIFY(params.value("forecast").isNull()); +} + +void TestLoadModel::testGetPlanParamsGoldenChargeIndisponibleFigee() +{ + SurplusContext ctx; + ctx.timestamp = instantExemple(); + ctx.meter.importW = 1350; + ctx.meter.perPhaseA = {5.9, 0.3, 0.2}; + // Routeur en défaut d'écriture : FIGÉ — indisponible, plancher = plafond = puissance crue + // engagée, et toujours compté pour ce qu'il consomme (§5.3, champs ETM). + LoadContext fige = chauffeEau(kRouteur); + fige.label = QStringLiteral("Routeur garage"); + fige.needs = LoadNeeds(); + fige.telemetry.available = false; + fige.telemetry.currentPowerW = 1200; + fige.telemetry.lockMinPowerW = 1200; + fige.telemetry.lockMaxPowerW = 1200; + fige.telemetry.commandRefused = true; // code seul (écart E7) : non émis + fige.telemetry.refusedSinceS = 340; + // Borne configurée dont l'appareil a disparu (buildContext, 3g-2) : indisponible, sans + // déclaration — ses zéros sont des inconnues et ne sont pas émis. + LoadContext sansAppareil; + sansAppareil.id = kBorne; + sansAppareil.adapter = QStringLiteral("evcharger"); + sansAppareil.label = QStringLiteral("Borne garage"); + sansAppareil.priority = 1; + sansAppareil.telemetry.available = false; + sansAppareil.telemetry.lockMaxPowerW = -1; + ctx.loads = {sansAppareil, fige}; + + ContexteFil fil; + fil.meterSource = kCompteur; + fil.mesures.insert(kRouteur, MesureFil{QStringLiteral("none"), 0}); + fil.prevision = instantaneSansCourbe(QVariant()); + + comparerAuGolden(QStringLiteral("charge-indisponible-figee"), getPlanParams(ctx, fil)); +} + +void TestLoadModel::testGetPlanParamsNeverInventsAValue() +{ + // Un contexte tel que buildContext() le rend aujourd'hui : rien ne remplit la puissance + // souscrite, la PV, la capacité batterie, ni le tarif. Leur 0 est une INCONNUE. + SurplusContext ctx; + ctx.timestamp = instantExemple(); + ctx.battery.present = true; + ctx.battery.socPercent = 40; + ctx.loads = {chauffeEau(), borne(), pac()}; + const QVariantMap p = getPlanParams(ctx, ContexteFil()); + + QCOMPARE(p.value("trigger").toString(), QStringLiteral("cycle")); + const QVariantMap site = p.value("site").toMap(); + for (const char *k : {"contractedPowerW", "gridCapW", "phaseLimitA"}) { + QVERIFY2(site.contains(k) && site.value(k).isNull(), k); + } + QVERIFY(p.value("meter").toMap().value("source").isNull()); + QVERIFY(p.value("pv").toMap().contains("currentW")); + QVERIFY(p.value("pv").toMap().value("currentW").isNull()); + const QVariantMap bat = p.value("battery").toMap(); + for (const char *k : {"capacityWh", "maxChargeW", "maxDischargeW", "reserveSocPercent"}) { + QVERIFY2(bat.contains(k) && bat.value(k).isNull(), k); + } + QVERIFY(p.contains("tariff") && p.value("tariff").isNull()); + QVERIFY(p.contains("forecast") && p.value("forecast").isNull()); + + // Absents, pas null : group (non implémenté), learned (aucun profil reçu), comfort (aucun + // porteur), champs réservés de l'annexe F, et `force`, qui n'existe pas sur le fil. + QSet toutes; + cles(p, toutes); + for (const char *k : {"group", "learned", "comfort", "socTarget", "policies", "force", + "powerLevels", "drawMargin", "derogation", "commandRefused", + "nominalPowerW", "volumeL", "defaults", "vehicle", "minPowerWAlways"}) { + QVERIFY2(!toutes.contains(QString::fromLatin1(k)), k); + } + // Sans mesure fournie, `measurement` est absent — jamais un {source: none} inventé. + for (const QVariant &l : p.value("loads").toList()) + QVERIFY(!l.toMap().value("telemetry").toMap().contains("measurement")); + + // Et une réserve renseignée par le CONTEXTE, si un jour il la porte, est reprise. + ctx.battery.reserveSocPercent = 20; + QCOMPARE(getPlanParams(ctx, ContexteFil()).value("battery").toMap() + .value("reserveSocPercent").toInt(), 20); + + // Le tarif 1.0 est sérialisé dès qu'une source le remplit (écart E3 : aucune aujourd'hui). + ctx.tariff.provider = QStringLiteral("tempo"); + ctx.tariff.current = {QDateTime(), QStringLiteral("HP_BLEU"), 16.1}; + ctx.tariff.next = {{QDateTime(QDate(2026, 10, 13), QTime(6, 0), QTimeZone(7200)), + QStringLiteral("ROUGE"), 75.6}}; + const QVariantMap t = getPlanParams(ctx, ContexteFil()).value("tariff").toMap(); + QCOMPARE(t.value("provider").toString(), QStringLiteral("tempo")); + QCOMPARE(t.value("current").toMap().value("priceCtkWh").toDouble(), 16.1); + QCOMPARE(t.value("next").toList().first().toMap().value("from").toString(), + QStringLiteral("2026-10-13T06:00:00+02:00")); +} + +void TestLoadModel::testGetPlanParamsMeterIsRaw() +{ + // Règle 8 : la mesure brute du compteur, quelles que soient les charges en marche. Une + // charge qui tire 1 600 W ne « rend » rien à l'export. + SurplusContext ctx; + ctx.timestamp = instantExemple(); + ctx.meter.exportW = 700; + ctx.loads = {chauffeEau()}; + const QVariantMap m = getPlanParams(ctx, ContexteFil()).value("meter").toMap(); + QCOMPARE(m.value("exportW").toDouble(), 700.0); + QCOMPARE(m.value("importW").toDouble(), 0.0); +} + +void TestLoadModel::testForecastWhPerStepBecomesMeanPower() +{ + // Pas de 15 min : 250 Wh sur le pas = 1 000 W moyens. Le consommateur ne suppose jamais le + // pas (§5.2), le producteur non plus. + const QVariantMap instantane{ + {"start", "2026-10-12T12:00:00Z"}, {"resolutionS", 900}, {"steps", 4}, + {"forecastPoints", QVariantList{ + QVariantMap{{"targetAt", "2026-10-12T12:00:00Z"}, {"forecastWh", 250.0}}, + QVariantMap{{"targetAt", "2026-10-12T12:45:00Z"}, {"forecastWh", 100.0}}, + // Hors grille ou mal alignés : ignorés, jamais posés dans la case voisine. + QVariantMap{{"targetAt", "2026-10-12T12:20:00Z"}, {"forecastWh", 999.0}}, + QVariantMap{{"targetAt", "2026-10-12T13:00:00Z"}, {"forecastWh", 999.0}}}}, + {"corrections", QStringList()}, {"conversion", "physics"}, {"perField", QVariantList()}, + {"baseload", QVariantMap{{"model", "none"}, {"p50W", QVariantList{QVariant(), QVariant(), QVariant(), QVariant()}}}}, + {"price", QVariant()}}; + const QVariantMap f = forecastDepuisInstantane(instantane); + const QVariantList p50 = f.value("pv").toMap().value("p50W").toList(); + QCOMPARE(p50.size(), 4); + QCOMPARE(p50.at(0).toDouble(), 1000.0); + QVERIFY(p50.at(1).isNull()); + QVERIFY(p50.at(2).isNull()); + QCOMPARE(p50.at(3).toDouble(), 400.0); + // Liste vide = conversion brute, une affirmation : elle reste une liste, pas une absence. + QVERIFY(f.value("pv").toMap().contains("corrections")); + QVERIFY(f.value("pv").toMap().value("corrections").toList().isEmpty()); + // Inconnus du producteur : null, pas absents (§5.2). + QVERIFY(f.value("pv").toMap().value("issuedAt").isNull()); + QVERIFY(f.value("pv").toMap().value("weatherModel").isNull()); + QVERIFY(f.value("pv").toMap().value("contentChangedAt").isNull()); + QVERIFY(!f.value("pv").toMap().contains("acLimitW")); + QVERIFY(f.value("weather").isNull()); + // Le texte de faute de l'instantané ne passe jamais sur le fil. + QVERIFY(!f.contains("fault")); +}