feat(optimiseur): sérialiseur GetPlan, protocole 1.1 §5.1-§5.3 (brief 4 T1)
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>
This commit is contained in:
parent
7622e11ea9
commit
13114a2695
@ -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 \
|
||||
|
||||
317
energyplugin/etm/scheduler/optimizerprotocol.cpp
Normal file
317
energyplugin/etm/scheduler/optimizerprotocol.cpp
Normal file
@ -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 <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;
|
||||
}
|
||||
|
||||
}
|
||||
90
energyplugin/etm/scheduler/optimizerprotocol.h
Normal file
90
energyplugin/etm/scheduler/optimizerprotocol.h
Normal file
@ -0,0 +1,90 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
#pragma once
|
||||
|
||||
#include <QHash>
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
#include <QVariantMap>
|
||||
#include <optional>
|
||||
#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<double> pvCurrentW;
|
||||
//! Limite par phase en A (\c site.phaseLimitA) ; vide = celle du contexte, sinon \c null.
|
||||
QList<double> phaseLimitA;
|
||||
//! Réserve de stockage en % (\c battery.reserveSocPercent) ; absente = celle du contexte si
|
||||
//! elle est renseignée (> 0), sinon \c null.
|
||||
std::optional<int> reserveSocPercent;
|
||||
//! Domaine déclaré par l'installateur, par charge (\c loads[].domain) ; absent = non émis.
|
||||
QHash<QString, QString> domaines;
|
||||
//! Mesure par charge (\c loads[].telemetry.measurement) ; absente = non émise.
|
||||
QHash<QString, MesureFil> 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<QVariantMap> 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);
|
||||
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
120
tests/auto/loadmodel/data/optimizer/prevision-absente.json
Normal file
120
tests/auto/loadmodel/data/optimizer/prevision-absente.json
Normal file
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
238
tests/auto/loadmodel/data/optimizer/site-sans-pv.json
Normal file
238
tests/auto/loadmodel/data/optimizer/site-sans-pv.json
Normal file
@ -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"
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
412
tests/auto/loadmodel/testoptimizerprotocol.cpp
Normal file
412
tests/auto/loadmodel/testoptimizerprotocol.cpp
Normal file
@ -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 <QDir>
|
||||
#include <QFile>
|
||||
#include <QJsonDocument>
|
||||
#include <QSet>
|
||||
#include <QTest>
|
||||
#include <QTimeZone>
|
||||
|
||||
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<QString> &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<QPair<QString, double>> 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<QString> 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"));
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user