diff --git a/AGENTS.md b/AGENTS.md index ae7fad3..8dd60ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1130,6 +1130,38 @@ aucune, la décision est une intention, et elle se périmera à la vitesse du co > §6. Un critère rédigé après coup se plie aux résultats sans jamais mentir, et sans qu'on puisse > le prouver. +> ### SIXIÈME occurrence — ce n'est plus le mock, c'est L'ENVIRONNEMENT (2026-09-07) +> +> Les cinq précédentes portent sur le banc simulé. Celle-ci porte sur **le poste de +> vérification lui-même**, et elle masque une classe entière de défauts. +> +> **Deux horloges identiques.** Box et téléphone étaient tous deux en `Europe/Paris` pendant +> toute la campagne. À décalage nul, un instant rendu en UTC, en heure box ou en heure +> téléphone donne le **même texte** — donc aucun défaut de conversion ne pouvait se voir. Une +> bascule de fuseau d'un quart d'heure en a révélé deux d'un coup, dont l'un avait traversé +> toute la campagne. +> +> **Rien dans le code ne pouvait le signaler**, et c'est ce qui distingue cette forme : il n'y a +> pas d'assertion à écrire, pas de classe de mock à ajouter. La prémisse fausse n'est pas dans le +> banc, elle est dans **la coïncidence de deux réglages extérieurs**. +> +> **Le geste qui en découle** : *basculer le fuseau de la box de temps en temps est un +> RÉVÉLATEUR, pas seulement une manipulation de capture.* Quinze minutes en `Europe/London`, +> et tout ce qui confond « instant » et « heure murale » se dénonce. À faire quand rien ne +> mesure, et à noter aux deux bouts (cf. les coutures de `DESIGN_COMMANDE_PERDUE.md` §7). +> +> **La distinction qu'il faut tenir**, et elle vaut pour tout ce dépôt publie : +> +> | | nature | où elle se rend | +> |---|---|---| +> | un RÉGLAGE — `dailyDeadline`, une plage tarifaire | chaîne d'horloge murale qu'on **interprète** | dans le fuseau qui fait foi pour ce réglage | +> | un INSTANT — `measuredSince`, `command.since`, `timestamp` | **absolu**, ancré à la capture | chez le lecteur, dans SON fuseau | +> +> Et le piège de mise en œuvre, mesuré : **`.toUTC()` au RENDU ne protège de rien.** Un +> `QDateTime` en spécification `Qt::LocalTime` ne garde que des composantes murales et résout son +> époque à la conversion, avec le fuseau courant. Seul l'ancrage **à la capture** tient — +> `now.toUTC()` au moment où l'on stocke. + > **Piège de build, 2026-09-01** : changer la DISPOSITION d'une structure dans un en-tête inclus > transitivement (ici un `QDateTime` ajouté à `EcoProgress`, atteint par `LoadAction`) peut > laisser des objets compilés contre l'ancienne. Le symptôme est un **plantage dans un diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index c6df08b..556fd8d 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -584,7 +584,9 @@ EcoProgress EnergyArbitrator::ecoProgress(const LoadContext &lc, const QDateTime // avant : crédible et faux. Rebaser `targetWh` serait pire — on perdrait l'obligation // réelle, et l'écran ne pourrait plus dire ce qui reste dû. if (!m_ecoMesureDepuis.contains(lc.id)) - m_ecoMesureDepuis.insert(lc.id, now); + // Ancré en UTC à la CAPTURE : un QDateTime local résout son époque à la + // conversion, donc il dérive au changement de fuseau (cf. energyratioscalculator). + m_ecoMesureDepuis.insert(lc.id, now.toUTC()); } else if (lc.telemetry.sessionCumulative && sessionWh < m_ecoBaseWh.value(lc.id) - BruitAvancementWh) { // COMPTEUR DE VIE QUI RECULE — impossible physiquement, donc la valeur ne vaut rien : @@ -1836,7 +1838,7 @@ void EnergyArbitrator::attachCommandDivergence(QVariantMap &entry, const QString } else if (!m_divergenceDepuis.contains(loadId)) { const QDateTime debut = m_lastTelemetryPublish.isValid() ? m_lastTelemetryPublish : QDateTime::currentDateTime(); - m_divergenceDepuis.insert(loadId, debut); + m_divergenceDepuis.insert(loadId, debut.toUTC()); // ancré à la capture qCInfo(dcNymeaEnergy()) << "[Divergence] DÉBUT" << loadId << "— attendu" << attenduW << "W, mesuré" << mesureW << "W," << "écart" << (mesureW - attenduW) << "W"; @@ -1996,7 +1998,7 @@ void EnergyArbitrator::publishTelemetry(bool force) return; m_publishedTelemetry = m_telemetry; - m_lastTelemetryPublish = QDateTime::currentDateTime(); + m_lastTelemetryPublish = QDateTime::currentDateTime().toUTC(); // ancré à la capture // Le battement est un PLANCHER de cadence, pas un ajout : toute notification relance son // décompte. Sur une box qui cycle chaque minute, le total reste donc d'environ UNE // notification par minute — ce que la cible RPi Zero 2W tient sans y penser. Porter cette diff --git a/energyplugin/etm/ratios/energyratioscalculator.cpp b/energyplugin/etm/ratios/energyratioscalculator.cpp index 66ad162..edaa5c6 100644 --- a/energyplugin/etm/ratios/energyratioscalculator.cpp +++ b/energyplugin/etm/ratios/energyratioscalculator.cpp @@ -66,7 +66,17 @@ EnergyRatiosCalculator::Ratios EnergyRatiosCalculator::compute(double totalProdu m_baseAcquisition = totalAcquisition; // L'instant de pose, distinct de la frontière de journée : un redémarrage de nymead // repose la baseline en plein jour, et les ratios ne couvrent alors que depuis là. - m_baselinePosee = now; + // + // ANCRÉ EN UTC À LA CAPTURE, et c'est indispensable. Un QDateTime en spécification + // `Qt::LocalTime` ne garde que des composantes d'HORLOGE MURALE : son époque se résout + // au moment de la CONVERSION, avec le fuseau courant. Stocké tel quel, cet instant + // se déplaçait d'une heure au changement de fuseau — mesuré le 2026-09-06, bascule + // Paris→London : 1788645607 → 1788649207. Le champ posé pour rendre une couture + // lisible était lui-même déplacé par la couture. + // + // Un `.toUTC()` AU RENDU n'y aurait rien changé : il interprète les composantes murales + // avec le fuseau du moment. Seul l'ancrage à la capture tient. + m_baselinePosee = now.toUTC(); } const double dProduction = totalProduction - m_baseProduction; diff --git a/tests/auto/loadmodel/testloadmodel.cpp b/tests/auto/loadmodel/testloadmodel.cpp index dc8ebb2..8a219d2 100644 --- a/tests/auto/loadmodel/testloadmodel.cpp +++ b/tests/auto/loadmodel/testloadmodel.cpp @@ -701,6 +701,47 @@ void TestLoadModel::testEcoUnmeasurableBuysAtAConstantRate() QCOMPARE(qRound(inconnue(5, 0).remainingWh), 4000); } +void TestLoadModel::testStoredInstantsSurviveATimeZoneChange() +{ + // Un instant ABSOLU ne bouge pas quand l'installation change de fuseau ; une FRONTIÈRE + // locale, si. Les deux vivent dans la même charge utile et se ressemblent — c'est par là + // que le défaut est passé (mesuré le 2026-09-06, bascule Paris→London sans redémarrage : + // measuredSince a bougé de +3600 s alors qu'il datait une pose déjà faite). + const QTimeZone paris("Europe/Paris"), londres("Europe/London"); + QVERIFY2(paris.isValid() && londres.isValid(), "sans base de fuseaux ce test ne prouve rien"); + + // L'instant de pose, tel que le code le capture désormais : ancré à la capture. + const QDateTime pose = QDateTime(QDate(2026, 9, 6), QTime(15, 36, 42), paris).toUTC(); + const qint64 epoquePose = pose.toSecsSinceEpoch(); + + // 1. L'ANCRAGE TIENT : re-rendre dans un autre fuseau ne déplace pas l'instant. + QCOMPARE(pose.toTimeZone(londres).toSecsSinceEpoch(), epoquePose); + QCOMPARE(pose.toUTC().toSecsSinceEpoch(), epoquePose); + + // 2. CE QUI ARRIVAIT SANS L'ANCRAGE, et c'est la contre-épreuve : un QDateTime en + // spécification LocalTime ne garde que des composantes d'horloge murale, donc son + // époque se résout AVEC LE FUSEAU COURANT. Reproduit ici en construisant la même + // lecture murale dans les deux fuseaux — c'est exactement ce que fait Qt quand + // /etc/localtime bascule sous un objet déjà stocké. + const QDateTime muraleParis(QDate(2026, 9, 6), QTime(15, 36, 42), paris); + const QDateTime muraleLondres(QDate(2026, 9, 6), QTime(15, 36, 42), londres); + QCOMPARE(muraleLondres.toSecsSinceEpoch() - muraleParis.toSecsSinceEpoch(), qint64(3600)); + QVERIFY2(muraleLondres.toSecsSinceEpoch() != epoquePose, + "sans ancrage, la même lecture murale rend une autre époque — c'est le défaut"); + + // 3. LA FRONTIÈRE, ELLE, DOIT BOUGER. Minuit local n'est pas un instant absolu : c'est une + // propriété du fuseau, et la publier fixe serait l'autre erreur, symétrique. + const QDateTime minuitParis(QDate(2026, 9, 6), QTime(0, 0), paris); + const QDateTime minuitLondres(QDate(2026, 9, 6), QTime(0, 0), londres); + QCOMPARE(minuitLondres.toSecsSinceEpoch() - minuitParis.toSecsSinceEpoch(), qint64(3600)); + + // 4. Et c'est là toute la distinction, en une assertion : le même décalage de +3600 s est + // un DÉFAUT sur l'instant de pose et le COMPORTEMENT ATTENDU sur la frontière. Le nombre + // ne dit pas lequel des deux on regarde — seule la nature du champ le dit. + QCOMPARE(muraleLondres.toSecsSinceEpoch() - muraleParis.toSecsSinceEpoch(), + minuitLondres.toSecsSinceEpoch() - minuitParis.toSecsSinceEpoch()); +} + void TestLoadModel::testReportingDayBoundaryIsPublishedNotDeducible() { // \rule{LM-1005-b-iv} — la frontière de la période de reporting se PUBLIE. Ce test dit diff --git a/tests/auto/loadmodel/testloadmodel.h b/tests/auto/loadmodel/testloadmodel.h index dd8a830..9a94867 100644 --- a/tests/auto/loadmodel/testloadmodel.h +++ b/tests/auto/loadmodel/testloadmodel.h @@ -81,6 +81,7 @@ private slots: //! lequel. Aucun plafond déclaré ≠ marge nulle. Départage stable à marge égale. void testEcoSpreadHardensAndSaysSo(); void testEcoUnmeasurableBuysAtAConstantRate(); + void testStoredInstantsSurviveATimeZoneChange(); void testReportingDayBoundaryIsPublishedNotDeducible(); void testDrawMarginComparesHeadroomsNotCaps(); };