Constat VM du 2026-10-21 (Europe/Berlin) : l'optimiseur conforme refusait chaque GetPlan, -32602 sur params.timestamp. `instant()` écrivait toString(Qt::ISODate), qui n'émet aucun décalage pour une heure locale — celle de currentDateTime(), donc de ctx.timestamp en production. Les goldens, construits en UTC ou à décalage fixe, ne pouvaient pas le voir. Le décalage de l'instant reçu est désormais rendu explicite : une heure locale garde l'heure du site (+02:00), une heure UTC reste en Z. Il est lu sur l'instant, donc 02:30 la nuit du retour à l'heure d'hiver sort +02:00 puis +01:00. Goldens inchangés. Tests (loadmodel) : cinq fuseaux dont un négatif et un à la demi-heure, été et hiver, tous les instants du contexte ; la nuit des deux changements d'heure, minute par minute. Vérifiés échouant sans le correctif. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
555 lines
25 KiB
C++
555 lines
25 KiB
C++
// 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 <QRegularExpression>
|
||
#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);
|
||
}
|
||
}
|
||
|
||
//! Le fuseau de la BOX, posé par le test et rendu en sortant (même garde que `simulation`).
|
||
struct FuseauDeTest {
|
||
QByteArray avant;
|
||
bool avait;
|
||
explicit FuseauDeTest(const QByteArray &tz) : avant(qgetenv("TZ")), avait(qEnvironmentVariableIsSet("TZ"))
|
||
{ qputenv("TZ", tz); }
|
||
~FuseauDeTest() { if (avait) qputenv("TZ", avant); else qunsetenv("TZ"); }
|
||
};
|
||
|
||
//! Le motif `pattern` des instants du contrat OpenRPC (identique à `INSTANT` du validateur).
|
||
const QRegularExpression kMotifInstant(
|
||
QStringLiteral("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}(:[0-9]{2}(\\.[0-9]+)?)?(Z|[+-][0-9]{2}:[0-9]{2})$"));
|
||
|
||
//! Chaque instant émis (clés `timestamp`, `deadline`, `by`, `from`), avec son chemin.
|
||
void instantsEmis(const QVariant &v, const QString &chemin, QList<QPair<QString, QString>> &out)
|
||
{
|
||
if (v.typeId() == QMetaType::QVariantMap) {
|
||
const QVariantMap m = v.toMap();
|
||
for (auto it = m.constBegin(); it != m.constEnd(); ++it) {
|
||
const QString ici = chemin + QLatin1Char('.') + it.key();
|
||
if (QStringList{"timestamp", "deadline", "by", "from"}.contains(it.key()) && !it.value().isNull())
|
||
out << qMakePair(ici, it.value().toString());
|
||
else
|
||
instantsEmis(it.value(), ici, out);
|
||
}
|
||
} else if (v.typeId() == QMetaType::QVariantList) {
|
||
const QVariantList l = v.toList();
|
||
for (int i = 0; i < l.size(); ++i)
|
||
instantsEmis(l.at(i), chemin + QStringLiteral("[%1]").arg(i), 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"));
|
||
}
|
||
|
||
void TestLoadModel::testGetPlanParamsInstantsCarryTheirOffset_data()
|
||
{
|
||
QTest::addColumn<QByteArray>("fuseau");
|
||
QTest::addColumn<QDate>("jour");
|
||
QTest::addColumn<QString>("attendu");
|
||
QTest::newRow("Europe/Berlin, été") << QByteArray("Europe/Berlin") << QDate(2026, 7, 15)
|
||
<< QStringLiteral("2026-07-15T13:42:00+02:00");
|
||
QTest::newRow("Europe/Berlin, hiver") << QByteArray("Europe/Berlin") << QDate(2026, 1, 15)
|
||
<< QStringLiteral("2026-01-15T13:42:00+01:00");
|
||
QTest::newRow("America/New_York, hiver") << QByteArray("America/New_York") << QDate(2026, 1, 15)
|
||
<< QStringLiteral("2026-01-15T13:42:00-05:00");
|
||
QTest::newRow("Asia/Kolkata, demi-heure") << QByteArray("Asia/Kolkata") << QDate(2026, 7, 15)
|
||
<< QStringLiteral("2026-07-15T13:42:00+05:30");
|
||
QTest::newRow("UTC") << QByteArray("UTC") << QDate(2026, 7, 15)
|
||
<< QStringLiteral("2026-07-15T13:42:00Z");
|
||
}
|
||
|
||
void TestLoadModel::testGetPlanParamsInstantsCarryTheirOffset()
|
||
{
|
||
// Constat VM du 2026-10-21 (fuseau Europe/Berlin) : l'optimiseur conforme refusait CHAQUE
|
||
// GetPlan, `params.timestamp` sans décalage. En production `now` vient de
|
||
// QDateTime::currentDateTime(), donc en Qt::LocalTime — pour laquelle Qt n'écrit aucun
|
||
// décalage. Les goldens, construits en UTC ou à décalage fixe, ne pouvaient pas le voir.
|
||
QFETCH(QByteArray, fuseau);
|
||
QFETCH(QDate, jour);
|
||
QFETCH(QString, attendu);
|
||
FuseauDeTest garde(fuseau);
|
||
const QDateTime now(jour, QTime(13, 42));
|
||
QCOMPARE(now.timeSpec(), Qt::LocalTime);
|
||
|
||
// Tous les instants du contexte en heure locale : échéance quotidienne résolue sur `now`
|
||
// (energyTargets installer), fin de dérogation (user-intent), échéance VE, tarif suivant.
|
||
SurplusContext ctx;
|
||
ctx.timestamp = now;
|
||
LoadContext ecs = chauffeEau();
|
||
ecs.derogation = true;
|
||
ecs.derogationEndsAt = now.addSecs(3600);
|
||
ecs.derogationTargetWh = 3000;
|
||
LoadContext ev = borne();
|
||
ev.needs.deadline = QDateTime(jour.addDays(1), QTime(7, 0));
|
||
ctx.loads = {ecs, ev, pac()};
|
||
ctx.tariff.provider = QStringLiteral("tempo");
|
||
ctx.tariff.current = {QDateTime(), QStringLiteral("HP_BLEU"), 16.1};
|
||
ctx.tariff.next = {{QDateTime(jour.addDays(1), QTime(6, 0)), QStringLiteral("ROUGE"), 75.6}};
|
||
const QVariantMap p = getPlanParams(ctx, ContexteFil());
|
||
|
||
QCOMPARE(p.value("timestamp").toString(), attendu);
|
||
QList<QPair<QString, QString>> instants;
|
||
instantsEmis(p, QStringLiteral("params"), instants);
|
||
QCOMPARE(instants.size(), 5); // timestamp, deadline, by ×2, tariff.next[0].from
|
||
for (const auto &i : instants) {
|
||
QVERIFY2(kMotifInstant.match(i.second).hasMatch(), qUtf8Printable(i.first + " = " + i.second));
|
||
// Relu, l'instant ne dépend plus du fuseau du lecteur : c'est un instant absolu.
|
||
QVERIFY2(QDateTime::fromString(i.second, Qt::ISODate).timeSpec() != Qt::LocalTime,
|
||
qUtf8Printable(i.first + " = " + i.second));
|
||
}
|
||
QCOMPARE(QDateTime::fromString(p.value("timestamp").toString(), Qt::ISODate).toSecsSinceEpoch(),
|
||
now.toSecsSinceEpoch());
|
||
}
|
||
|
||
void TestLoadModel::testGetPlanParamsInstantsAcrossTheDstChange()
|
||
{
|
||
FuseauDeTest garde("Europe/Berlin");
|
||
auto emis = [](const QDateTime &t) {
|
||
SurplusContext ctx;
|
||
ctx.timestamp = t;
|
||
return getPlanParams(ctx, ContexteFil()).value("timestamp").toString();
|
||
};
|
||
|
||
// HEURE D'HIVER, 2026-10-25 : à 03:00 CEST on revient à 02:00 CET. 02:30 murale a lieu DEUX
|
||
// fois ; sans décalage, deux cycles distants d'une heure émettaient la même chaîne.
|
||
const QDateTime premiere = QDateTime(QDate(2026, 10, 25), QTime(0, 30), QTimeZone::UTC).toLocalTime();
|
||
const QDateTime seconde = QDateTime(QDate(2026, 10, 25), QTime(1, 30), QTimeZone::UTC).toLocalTime();
|
||
QCOMPARE(premiere.time(), QTime(2, 30));
|
||
QCOMPARE(seconde.time(), QTime(2, 30));
|
||
QCOMPARE(emis(premiere), QStringLiteral("2026-10-25T02:30:00+02:00"));
|
||
QCOMPARE(emis(seconde), QStringLiteral("2026-10-25T02:30:00+01:00"));
|
||
|
||
// HEURE D'ÉTÉ, 2026-03-29 : à 02:00 CET on passe à 03:00 CEST, 02:30 murale n'existe pas.
|
||
// Une horloge système ne la rend jamais ; un instant construit dessus est résolu par Qt, et
|
||
// l'émis désigne CET instant-là, sous une heure qui existe.
|
||
const QDateTime inexistante(QDate(2026, 3, 29), QTime(2, 30));
|
||
QVERIFY(inexistante.isValid());
|
||
const QString e = emis(inexistante);
|
||
QVERIFY2(kMotifInstant.match(e).hasMatch(), qUtf8Printable(e));
|
||
QCOMPARE(QDateTime::fromString(e, Qt::ISODate).toSecsSinceEpoch(), inexistante.toSecsSinceEpoch());
|
||
QCOMPARE(emis(QDateTime(QDate(2026, 3, 29), QTime(1, 30), QTimeZone::UTC).toLocalTime()),
|
||
QStringLiteral("2026-03-29T03:30:00+02:00"));
|
||
|
||
// Un cycle par minute À TRAVERS chacune des deux nuits : chaînes toutes distinctes, conformes,
|
||
// et relues dans l'ordre strict des instants — aucune ambiguïté, aucun trou.
|
||
for (const QDate &nuit : {QDate(2026, 10, 25), QDate(2026, 3, 29)}) {
|
||
const QDateTime debut(nuit, QTime(0, 0), QTimeZone::UTC);
|
||
QSet<QString> vues;
|
||
qint64 precedent = -1;
|
||
for (int m = 0; m <= 180; ++m) {
|
||
const QDateTime t = debut.addSecs(60 * m).toLocalTime();
|
||
const QString s = emis(t);
|
||
QVERIFY2(kMotifInstant.match(s).hasMatch(), qUtf8Printable(s));
|
||
QVERIFY2(!vues.contains(s), qUtf8Printable(QStringLiteral("instant émis deux fois : ") + s));
|
||
vues.insert(s);
|
||
const qint64 relu = QDateTime::fromString(s, Qt::ISODate).toSecsSinceEpoch();
|
||
QCOMPARE(relu, t.toSecsSinceEpoch());
|
||
QVERIFY(relu > precedent);
|
||
precedent = relu;
|
||
}
|
||
}
|
||
}
|