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>
This commit is contained in:
Patrick Schurig 2026-10-21 22:10:15 +02:00
parent 8f24709874
commit 9d8b60fdd4
3 changed files with 160 additions and 2 deletions

View File

@ -15,10 +15,20 @@ namespace OptimizerProtocol {
namespace { namespace {
//! Instant absolu, décalage compris (§5.2 « Instants ») ; invalide → \c null. /*!
* Instant absolu, décalage compris (§5.2 « Instants ») ; invalide → \c null.
*
* Le décalage est celui de l'instant reçu, rendu EXPLICITE : Qt n'en écrit aucun pour une heure
* locale (\c Qt::LocalTime, celle de \c currentDateTime()), et une heure murale nue est refusée
* par tout optimiseur conforme (-32602, constat du 2026-10-21 sous Europe/Berlin). Une heure
* locale garde l'heure du site (« 13:42:00+02:00 »), une heure UTC reste en « Z ». Le décalage
* est lu sur l'INSTANT, pas sur la date : la nuit du passage à l'heure d'hiver, 02:30 sort
* « +02:00 » puis « +01:00 », deux instants distincts.
*/
QVariant instant(const QDateTime &dt) QVariant instant(const QDateTime &dt)
{ {
return dt.isValid() ? QVariant(dt.toString(Qt::ISODate)) : QVariant(); return dt.isValid() ? QVariant(dt.toOffsetFromUtc(dt.offsetFromUtc()).toString(Qt::ISODate))
: QVariant();
} }
QVariantList liste(const QList<double> &v) QVariantList liste(const QList<double> &v)

View File

@ -148,6 +148,12 @@ private slots:
void testGetPlanParamsMeterIsRaw(); void testGetPlanParamsMeterIsRaw();
//! Wh par pas → puissance moyenne ; une case sans point reste null. //! Wh par pas → puissance moyenne ; une case sans point reste null.
void testForecastWhPerStepBecomesMeanPower(); void testForecastWhPerStepBecomesMeanPower();
//! Un contexte en HEURE LOCALE (celle de currentDateTime()) émet des instants AVEC décalage,
//! sous plusieurs fuseaux, été comme hiver (constat VM du 2026-10-21 : -32602 à chaque cycle).
void testGetPlanParamsInstantsCarryTheirOffset_data();
void testGetPlanParamsInstantsCarryTheirOffset();
//! Nuit du changement d'heure : 02:30 ambiguë (hiver) et inexistante (été) → instants exacts.
void testGetPlanParamsInstantsAcrossTheDstChange();
// ── Brief 4 T5 — catalogue des motifs (corps dans testdecisioncatalogue.cpp) ── // ── Brief 4 T5 — catalogue des motifs (corps dans testdecisioncatalogue.cpp) ──
//! Anti-dérive : knownCodes() == les constantes de l'en-tête, chacune avec un rendu connu. //! Anti-dérive : knownCodes() == les constantes de l'en-tête, chacune avec un rendu connu.

View File

@ -10,6 +10,7 @@
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
#include <QJsonDocument> #include <QJsonDocument>
#include <QRegularExpression>
#include <QSet> #include <QSet>
#include <QTest> #include <QTest>
#include <QTimeZone> #include <QTimeZone>
@ -164,6 +165,38 @@ void cles(const QVariant &v, QSet<QString> &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 } // namespace
void TestLoadModel::testGetPlanParamsGoldenSiteSansPv() void TestLoadModel::testGetPlanParamsGoldenSiteSansPv()
@ -410,3 +443,112 @@ void TestLoadModel::testForecastWhPerStepBecomesMeanPower()
// Le texte de faute de l'instantané ne passe jamais sur le fil. // Le texte de faute de l'instantané ne passe jamais sur le fil.
QVERIFY(!f.contains("fault")); 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;
}
}
}