spec+test: les deux dates ne bornent pas la même chose, et la journée ne fait pas toujours 24 h

TA CORRECTION EST RETENUE, et elle porte sur le point qui compte. `measuredSince`
n'a pas la même sémantique des deux côtés : sur une charge il répond « depuis
quand on MESURE » et borne une fenêtre dont `targetWh` donne l'autre bout ; sur
les ratios il répond « depuis quand on COMPTE », et la paire ne borne aucune
fenêtre. Transposer un nom n'est pas transposer une sémantique.

Conséquence gravée : `periodStartedAt` n'est PAS la borne basse des ratios —
c'est `measuredSince`. Présenter comme journaliers des chiffres qui ne comptent
que depuis le dernier redémarrage serait exactement le silence que la parade
prétendait fermer. Mesuré à l'instant sur .75 : les taux couvrent 1,97 h quand
l'écran dirait 9 h.

Et la séparation d'écrans est écrite là où elle se lit : `periodStartedAt` sert le
bilan journalier, `measuredSince` sert les ratios. Ils arrivent dans la même
trame, leurs noms se ressemblent, et c'est par là que l'erreur passe.

VÉRIFIÉ À LA SONDE PLUTÔT QUE DÉDUIT DU NOM, comme demandé — et il y avait de
quoi vérifier, puisque la justification même de publier était « le recalcul
serait faux les jours de changement d'heure ». Encore fallait-il que le nôtre soit
juste ces jours-là.

  · en vrai sur .75 : `periodStartedAt` == minuit local, recalculé indépendamment ;
  · en test : les époques viennent d'un ORACLE calculé hors de Qt, pas re-dérivé
    avec les primitives du code testé — une identité qui se referme sur elle-même
    ne vérifierait rien ;
  · 2026-03-29 fait 23 h, 2026-10-25 fait 25 h, les 363 autres 24 h. Un client
    posant « la journée fait 86 400 s » serait juste presque toujours, et faux
    deux fois par an, dont une en plein hiver de chauffage.

TROIS FOIS mes propres contrôles se sont trompés en cherchant ces deux dates : un
balayage qui n'a rien trouvé, puis deux calculs rendant 24 h partout. Cause :
l'arithmétique entre deux datetimes du MÊME fuseau est naïve — elle rend la durée
d'horloge murale, jamais la durée réelle. Le contrôle qui le prouve passe donc par
les époques, jamais par une soustraction de dates. C'est la même leçon que celle
qu'on vient de graver : un instrument peut répondre « normal » parce qu'il ne
mesure pas ce qu'on croit.

Suites : loadmodel 24/24 (+1), simulation 67/67, charging 48/48, spotmarket 32/32.
Rien à déployer : le moteur est inchangé depuis +etm56.
This commit is contained in:
Patrick Schurig 2026-09-05 09:13:17 +02:00
parent 88c96c2264
commit fb18a282e1
4 changed files with 101 additions and 7 deletions

View File

@ -1318,7 +1318,26 @@ se distingue pas d'une panne.
> 25 heures. Trois clients la recalculeraient de trois façons ; la seule qui compte est celle qui
> a servi au calcul. **Même règle que `counts`.**
> #### `periodStartedAt` ≠ `measuredSince`, et l'écart est la seule chose qui empêche les ratios de mentir
> #### Les deux champs ne bornent pas la même chose, et ils arrivent dans la même trame
>
> **C'est le piège de cette charge utile, et il a déjà été tendu une fois.** Les deux dates sont
> voisines dans le JSON ; leurs noms se ressemblent ; elles n'appartiennent pas au même écran.
>
> | champ | borne | sert à |
> |---|---|---|
> | `measuredSince` | **la borne basse des RATIOS** | l'écran des taux — c'est depuis là qu'on COMPTE |
> | `periodStartedAt` | **la frontière de la JOURNÉE** | le bilan journalier — c'est là que la journée commence |
>
> **`periodStartedAt` n'est PAS la borne basse des ratios.** Les présenter comme journaliers alors
> qu'ils ne comptent que depuis le dernier redémarrage serait exactement le silence que ce champ
> prétend fermer.
>
> L'analogie avec `progress.measuredSince` de l'avancement éco s'arrête là, et c'est par là que
> l'erreur passe : sur une charge, `measuredSince` répond « depuis quand on MESURE » et borne une
> fenêtre dont `targetWh` donne l'autre bout. Ici il répond « depuis quand on COMPTE », et la
> paire ne borne aucune fenêtre — les deux dates parlent de deux objets différents.
> #### Pourquoi les deux ne coïncident pas, et ce que l'écart révèle
>
> La baseline se repose à **chaque redémarrage de nymead** et à chaque recul de compteur — donc en
> plein jour. Un jour où nymead a redémarré à 14 h, ces ratios ne couvrent pas la journée : ils

View File

@ -666,12 +666,34 @@ est correct — mais repose sur la prémisse que le fuseau de la box soit celui
c'était le seul des trois cas où **personne ne vérifiait**. Publier la frontière et son fuseau est
exactement ce qui rend la vérification possible.
**LM-1005-b-v — `periodStartedAt` et `measuredSince` sont DEUX champs, et les confondre fait
mentir les ratios.** La baseline se repose à chaque redémarrage de nymead et à chaque recul de
compteur — donc en plein jour. Un jour où nymead a redémarré à 14 h, les ratios ne couvrent pas la
journée : ils couvrent depuis 14 h. Avec la seule frontière, l'écran écrit « aujourd'hui » sur des
chiffres qui ne parlent que de l'après-midi. Même paire, même raison que
`progress.measuredSince` / `targetWh`.
**LM-1005-b-v — `periodStartedAt` et `measuredSince` ne bornent PAS la même chose, et ils
arrivent dans la même trame.** `measuredSince` est la borne basse des RATIOS — c'est depuis là
qu'on compte. `periodStartedAt` est la frontière de la JOURNÉE, et sert le bilan journalier, qui
est un autre écran. **`periodStartedAt` n'est pas la borne basse des ratios**, et les présenter
comme journaliers alors qu'ils ne comptent que depuis le dernier redémarrage serait exactement le
silence que ce champ prétend fermer.
> L'analogie avec `progress.measuredSince` s'arrête là, et c'est par là que l'erreur passe —
> commise une fois, à la conception même de ce champ. Sur une charge, `measuredSince` répond
> « depuis quand on MESURE » et borne une fenêtre dont `targetWh` donne l'autre bout. Ici il
> répond « depuis quand on COMPTE », et la paire ne borne aucune fenêtre : les deux dates parlent
> de deux objets différents. **Transposer un nom, ce n'est pas transposer une sémantique.**
**LM-1005-b-v-bis — pourquoi la frontière ne se recalcule pas, en trois nombres.** Une journée
locale ne fait pas toujours 24 heures. Vérifié, avec un oracle calculé hors de Qt puis gravé en
test : **2026-03-29 fait 23 h, 2026-10-25 fait 25 h**, et les 363 autres jours 24 h. Un client qui
poserait « la journée fait 86 400 s » serait donc juste presque toujours — et faux deux fois par
an, dont une en plein hiver de chauffage. C'est l'erreur silencieuse par excellence : elle ne se
voit pas à l'écran, elle se voit sur la facture, un mois plus tard.
Et la raison de fond demeure : `System.GetTime` publie un NOM de fuseau. Un décalage ne s'en déduit
pas sans base de fuseaux, et embarquer une base pour recalculer ce que le moteur sait déjà est le
mauvais geste.
**LM-1005-b-vi — la baseline se repose en plein jour, et ça se dit.** Elle est reprise à chaque
redémarrage de nymead et à chaque recul de compteur. Un jour où nymead a redémarré à 14 h, les
ratios ne couvrent pas la journée : ils couvrent depuis 14 h. Sans `measuredSince`, l'écran écrit
« aujourd'hui » sur des chiffres qui ne parlent que de l'après-midi.
**Conséquence de conception** : la passe 1 n'est **pas** bornée par le surplus. Elle est bornée
par une **autorisation de soutirage** — un objet distinct, que le rule-based rendra binaire et

View File

@ -8,12 +8,14 @@
#include "etm/types/drawcap.h"
#include "etm/types/ecotarget.h"
#include "etm/config/loadconfigstore.h"
#include "etm/ratios/energyratioscalculator.h"
#include <QTest>
#include <QDir>
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QTimeZone>
#include <QJsonArray>
namespace {
@ -699,6 +701,56 @@ void TestLoadModel::testEcoUnmeasurableBuysAtAConstantRate()
QCOMPARE(qRound(inconnue(5, 0).remainingWh), 4000);
}
void TestLoadModel::testReportingDayBoundaryIsPublishedNotDeducible()
{
// \rule{LM-1005-b-iv} — la frontière de la période de reporting se PUBLIE. Ce test dit
// POURQUOI : sur deux jours de l'année, la journée locale ne fait pas 24 heures, et tout
// client qui la recalcule en supposant 24 h se trompe d'une heure sur un chiffre de facture.
const QTimeZone paris("Europe/Paris");
QVERIFY2(paris.isValid(), "sans base de fuseaux, ce test ne prouverait rien — il passerait");
auto frontiere = [&](const QDate &jour) {
// Un calculateur NEUF par appel : on interroge la frontière, pas l'état d'une baseline.
EnergyRatiosCalculator calc;
return calc.compute(0, 0, 0, 0, QDateTime(jour, QTime(12, 0), paris)).debutPeriode;
};
// 1. La frontière est bien MINUIT LOCAL — comparé à un oracle calculé indépendamment
// (Python/zoneinfo), et non re-dérivé avec les mêmes primitives Qt que le code testé.
// Une identité qui se referme sur elle-même ne vérifierait rien.
QCOMPARE(frontiere(QDate(2026, 9, 5)).toSecsSinceEpoch(), qint64(1788559200));
QCOMPARE(frontiere(QDate(2026, 3, 29)).toSecsSinceEpoch(), qint64(1774738800));
QCOMPARE(frontiere(QDate(2026, 3, 30)).toSecsSinceEpoch(), qint64(1774821600));
QCOMPARE(frontiere(QDate(2026,10, 25)).toSecsSinceEpoch(), qint64(1792879200));
QCOMPARE(frontiere(QDate(2026,10, 26)).toSecsSinceEpoch(), qint64(1792969200));
auto dureeH = [&](const QDate &jour) {
return (frontiere(jour.addDays(1)).toSecsSinceEpoch()
- frontiere(jour).toSecsSinceEpoch()) / 3600;
};
// 2. LA RAISON DE PUBLIER, en trois nombres. Un client qui poserait « la journée fait
// 86 400 s » serait juste 363 jours sur 365, et faux les deux autres — dont l'un tombe en
// plein hiver de chauffage. C'est l'erreur silencieuse par excellence : elle ne se voit
// pas à l'écran, elle se voit sur la facture, un mois plus tard.
QCOMPARE(dureeH(QDate(2026, 9, 5)), qint64(24));
QCOMPARE(dureeH(QDate(2026, 3, 29)), qint64(23));
QCOMPARE(dureeH(QDate(2026,10, 25)), qint64(25));
// 3. Et le recalcul naïf DIVERGE, explicitement : « frontière + 24 h » ne retombe pas sur la
// frontière suivante ces jours-là. L'écrire ainsi évite qu'on lise le point 2 comme une
// curiosité de calendrier plutôt que comme un défaut de client.
QVERIFY2(frontiere(QDate(2026, 3, 29)).addSecs(24 * 3600) != frontiere(QDate(2026, 3, 30)),
"au printemps, +24 h dépasse la frontière suivante d'une heure");
QVERIFY2(frontiere(QDate(2026,10, 25)).addSecs(24 * 3600) != frontiere(QDate(2026,10, 26)),
"à l'automne, +24 h reste une heure en deçà");
// 4. Le fuseau publié est celui QUI A SERVI, pas celui du système. Sans cette assertion, un
// changement de fuseau de la machine de test ferait passer ou échouer le test pour une
// raison qui n'a rien à voir avec le code.
QCOMPARE(frontiere(QDate(2026, 3, 29)).timeZone().id(), QByteArray("Europe/Paris"));
}
void TestLoadModel::testDrawMarginComparesHeadroomsNotCaps()
{
// [LM-1006-1] Un plafond PAR PHASE et un plafond TOTAL n'ont pas la même forme et ne se

View File

@ -81,6 +81,7 @@ private slots:
//! lequel. Aucun plafond déclaré ≠ marge nulle. Départage stable à marge égale.
void testEcoSpreadHardensAndSaysSo();
void testEcoUnmeasurableBuysAtAConstantRate();
void testReportingDayBoundaryIsPublishedNotDeducible();
void testDrawMarginComparesHeadroomsNotCaps();
};