diff --git a/INTERFACE.md b/INTERFACE.md index dae46f6..74da306 100644 --- a/INTERFACE.md +++ b/INTERFACE.md @@ -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 diff --git a/specs/spec_loadmodel.md b/specs/spec_loadmodel.md index 0f43eb6..8466062 100644 --- a/specs/spec_loadmodel.md +++ b/specs/spec_loadmodel.md @@ -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 diff --git a/tests/auto/loadmodel/testloadmodel.cpp b/tests/auto/loadmodel/testloadmodel.cpp index dc452b2..dc8ebb2 100644 --- a/tests/auto/loadmodel/testloadmodel.cpp +++ b/tests/auto/loadmodel/testloadmodel.cpp @@ -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 #include #include #include #include +#include #include 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 diff --git a/tests/auto/loadmodel/testloadmodel.h b/tests/auto/loadmodel/testloadmodel.h index 37e18ff..dd8a830 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 testReportingDayBoundaryIsPublishedNotDeducible(); void testDrawMarginComparesHeadroomsNotCaps(); };