fix: ancrer les quatre instants stockés à la capture, pas au rendu
DÉFAUT DE CLASSE, trouvé par l'audit demandé après la bascule de fuseau. Quatre instants étaient stockés depuis QDateTime::currentDateTime(), donc en spécification Qt::LocalTime : un tel objet ne garde que des composantes d'HORLOGE MURALE et résout son époque au moment de la CONVERSION, avec le fuseau courant. m_baselinePosee → GetEnergyRatios.measuredSince m_ecoMesureDepuis → progress.measuredSince m_divergenceDepuis → command.since m_lastTelemetryPublish → alimente le précédent Mesuré sur la box le 2026-09-06, bascule Paris→London sans redémarrage : measuredSince 1788645607 → 1788649207, +3600 s. Le champ posé pour rendre une couture lisible était lui-même déplacé par la couture. ET `.toUTC()` AU RENDU NE PROTÉGEAIT DE RIEN — c'est le piège, parce que les quatre rendus l'appelaient déjà et avaient l'air corrects. Démontré par un banc Qt avec bascule de /etc/localtime sous un objet déjà stocké : AVANT stocké-local → 1788755728 stocké-UTC → 1788755728 (Europe/Paris) APRÈS stocké-local → 1788759328 stocké-UTC → 1788755728 (Europe/London) Correctif : `.toUTC()` À LA CAPTURE. periodStartedAt reste construit dans le fuseau courant — c'est une frontière locale, elle DOIT bouger. Les deux champs n'ont pas la même nature et le stockage le reflète maintenant. TEST avec son oracle : l'ancrage tient sous re-rendu, la contre-épreuve reproduit la dérive de +3600 s, et une quatrième assertion dit ce qui compte — le même décalage est un DÉFAUT sur l'instant de pose et le COMPORTEMENT ATTENDU sur la frontière. Le nombre ne dit pas lequel on regarde ; seule la nature du champ le dit. SIXIÈME FORME D'HOMOGÉNÉITÉ (AGENTS.md) : ce n'est plus le mock, c'est L'ENVIRONNEMENT DE VÉRIFICATION. Box et téléphone 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. Aucun défaut de conversion ne pouvait se voir, et rien dans le code ne pouvait le signaler — la prémisse fausse n'est pas dans le banc, elle est dans la coïncidence de deux réglages extérieurs. D'où le geste : basculer le fuseau de temps en temps est un RÉVÉLATEUR, pas seulement une manipulation de capture. Quinze minutes en Europe/London ont dénoncé deux défauts, dont un qui avait traversé toute la campagne. Suites : loadmodel 25/25 (+1), simulation 67/67, charging 48/48, spotmarket 32/32. NON DÉPLOYÉ.
This commit is contained in:
parent
e1079760e7
commit
89a5c06f7f
32
AGENTS.md
32
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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user