Patrick Schurig 9d8b60fdd4 protocole optimiseur : tout instant émis porte son décalage
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>
2026-10-21 22:10:15 +02:00

555 lines
25 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 &params)
{
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;
}
}
}