From 7398b1dd241f2b7f950b7a01e46ae5d4708bd980 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Fri, 4 Sep 2026 19:18:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(=C2=A710):=20sous=20unmeasurable,=20la=20p?= =?UTF-8?q?asse=20=C3=A9co=20commande=20=C3=A0=20TAUX=20CONSTANT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LM-1013-b-v. Tranché le 2026-09-01, jamais écrit — la décision a passé au second plan quand sessionWh a été alimenté depuis le compteur de charge et que le régime `unmeasurable` s'est réduit aux trois cas matériels. C'est la première classe d'erreur d'AGENTS.md : une règle qui expire sans être exécutée. Elle n'était ni dans le code, ni dans la spec, ni au contrat. LE PROBLÈME. L'étalement `reste / temps restant` suppose que le NUMÉRATEUR bouge. Sous `unmeasurable` il ne bouge pas : `remainingWh` est figé à la cible entière, par contrat, parce qu'on ne sait pas ce qui a été livré et que le rebaser serait affirmer sur ce qu'on ignore (LM-1009 C1). Un rapport dont seul le dénominateur décroît ne suit plus un retard : IL SUIT L'HORLOGE. On achetait davantage parce que le temps passait, jamais parce que quelque chose était en retard. CHIFFRÉ, et le chiffre a été refait plutôt que repris. Obligation 4 kWh, plafond 7 kW, fenêtre d'un jour : l'ancienne loi achetait 19 010 Wh, soit ×4,75 l'obligation. Le facteur suit ln(T·P/E) + 1 — il grandit avec la fenêtre, ce qui est le pire sens possible : plus on laisse de temps pour bien faire, plus on paie. Une charge qu'on mesure mal coûtait ainsi structurellement plus cher qu'une charge qu'on mesure, l'incitation exactement à l'envers. (Le ×2,5 retenu au moment de trancher valait pour une fenêtre bien plus courte. Sur la fenêtre quotidienne, qui est celle du modèle, c'est ×4,75. La conclusion ne change pas ; le chiffre, si — et un chiffre faux dans une spec finit par servir de prémisse à quelqu'un.) LA LOI. Cible ÷ fenêtre de l'obligation, constante, et `atMaximum` jamais vrai. La fenêtre est le JOUR parce que l'obligation est déclarée par jour — ce n'est pas un réglage, c'est l'unité de la grandeur qu'on étale. Le total acheté sur la fenêtre vaut alors EXACTEMENT la cible. On garde « viser l'obligation entière », le choix qui sert ; on perd l'escalade, qui ne reposait sur rien. CE QUE LE TEST TIENT, et il a été vu mordre — désarmé, il rend 667 W au lieu de 167 W à six heures de l'échéance : · la cadence est identique à 6 h, 2 h, 1 h, 30 min et 5 min de l'échéance ; · `atMaximum` reste faux jusqu'à la dernière minute ; · l'intégrale sur les 1440 minutes de la fenêtre vaut la cible à 5 Wh près — la propriété qui justifie la loi ne se lit sur aucune valeur isolée ; · CONTRE-ÉPREUVE : même obligation, même instant, même numérateur (4000 non livrés) sous `measured` — durcissement et point de non-retour intacts. Sans elle, avoir supprimé l'étalement POUR TOUT LE MONDE passerait inaperçu. AU CONTRAT. La loi de commande dépend désormais du régime, et un écran qui suppose la même dans les deux se trompera sur la trajectoire — qui est ce qu'il vend. INTERFACE.md porte la table des deux lois, et dit ce que l'écran ne peut plus affirmer sous `unmeasurable` : ni que l'achat va s'intensifier, ni que `atMaximum: false` veut dire que tout va bien — il veut dire qu'il n'y a pas d'étalement du tout, donc rien qui puisse cesser. Suites : loadmodel 23/23, simulation 66/66, charging 48/48, spotmarket 32/32. --- INTERFACE.md | 25 ++++++++- debian-qt5/changelog | 16 ++++++ energyplugin/etm/types/ecotarget.cpp | 30 +++++++++++ energyplugin/etm/types/ecotarget.h | 26 ++++++++- specs/spec_loadmodel.md | 34 ++++++++++++ tests/auto/loadmodel/testloadmodel.cpp | 73 ++++++++++++++++++++++++++ tests/auto/loadmodel/testloadmodel.h | 1 + 7 files changed, 203 insertions(+), 2 deletions(-) diff --git a/INTERFACE.md b/INTERFACE.md index 68232a0..f00966b 100644 --- a/INTERFACE.md +++ b/INTERFACE.md @@ -1090,11 +1090,34 @@ le **libellé de la charge n'y figure jamais** (le client le résout via `GetLoa | `EV_GRID_START` *(3g-2)* | `budgetW`, `floorW`, `gridW` | La borne **démarre en soutirant** : le surplus ne paie pas son plancher, mais `acquisitionTolerance` autorise l'appoint. `gridW = floorW − budgetW` est ce qui vient du réseau. Voir la note de comptabilité ci-dessous | | `PHASE_LIMIT` *(3g-2)* | `limitW`, `requiredW` | C'est la **limite de phase** qui interdit, pas le budget : la place libre laissée par la protection de surcharge est sous ce que la borne exige. Geste attendu : regarder l'abonnement et les autres consommateurs, pas attendre le soleil | | `DRAW_CAP` *(délestage §3)* | `marginW`, `overshootW`, `source`, `o:phase` | L'**autorisation de soutirage** est épuisée — une seconde ressource, distincte du surplus. `source` est un code opaque : `connection` (le branchement), `gridOperator` (imposé), `selfImposed` (auto-imposé) ; ce qu'on a le droit d'en faire en dépend. `marginW` est **signé** et `overshootW` dit de combien on dépasse. `phase` (`A`/`B`/`C`) n'est présent que si le plafond est **par phase** : son absence dit qu'aucune phase n'est coupable, elle ne vaut pas « phase A ». **Précédence (LM-1011)** : tant qu'il reste de l'autorisation, même insuffisante, le motif est `BELOW_MIN_POWER` — `DRAW_CAP` est réservé à l'autorisation **nulle** | -| `ECO_FLOOR_GRID` *(§10)* | `gridW`, `remainingWh`, `targetWh`, `remainingS`, `regime`, `atMaximum` | L'obligation éco est servie **en achetant**. Les six champs donnent la **trajectoire**, pas l'instant : la cadence de l'étalement fait un mur — 2 kWh restants valent 333 W à six heures de l'échéance et **le plafond dès seize minutes**. « 1000 W achetés » seul ne permet pas de prévoir la facture ; avec `remainingWh` et `remainingS`, si. `atMaximum` dit que l'étalement a cessé (point de non-retour), `regime` sur quoi on achète | +| `ECO_FLOOR_GRID` *(§10)* | `gridW`, `remainingWh`, `targetWh`, `remainingS`, `regime`, `atMaximum` | L'obligation éco est servie **en achetant**. Les six champs donnent la **trajectoire**, pas l'instant : la cadence de l'étalement fait un mur — 2 kWh restants valent 333 W à six heures de l'échéance et **le plafond dès seize minutes**. « 1000 W achetés » seul ne permet pas de prévoir la facture ; avec `remainingWh` et `remainingS`, si. `atMaximum` dit que l'étalement a cessé (point de non-retour), `regime` sur quoi on achète. **La LOI DE COMMANDE dépend du régime** — voir l'encadré ci-dessous | | `ECO_FLOOR_MET` *(§10)* | `deliveredWh`, `targetWh` | L'obligation est **tenue**. **INTERDIT sous `regime: unmeasurable`** (R6) : dire « tenue » suppose de savoir ce qui a été livré. Une échéance qui cesse d'être mesurable et continue de s'afficher comme tenue est pire qu'une échéance absente — elle rassure | | `ECO_FLOOR_MISSED` *(§10)* | `targetWh`, `cause`, `o:missingWh` | Une période d'obligation s'est close **sans avoir été tenue**. `cause` ∈ `drawCap` \| `loadCapacity` \| `unmeasurable` \| `unavailable` — **trois causes, trois gestes** : revoir l'abonnement, lire la fiche technique de la charge, ou regarder ce que la borne publie. Sans elle, l'écran n'affiche qu'un reproche sans recours. `missingWh` est **absent** sous `unmeasurable` : on ne sait pas ce qui a été livré, donc pas ce qui manquait — un zéro se lirait « il ne manquait rien ». Motif d'ÉVÉNEMENT : publié le seul cycle de la bascule | | `RECREDIT_SUSPENDED` | `refusedSinceS`, `withheldW` | Le recrédit anti-clignotement est **suspendu** : la charge ne suit pas la commande depuis plus longtemps que la latence de son mécanisme. Les watts ne sont **pas rendus au budget**, donc **toutes les charges suivantes reçoivent moins** — et sans ce motif, on chercherait un défaut de cascade là où une charge n'obéit pas. Un écart RÉCENT est une latence, pas un refus : le seuil vient du mécanisme (V2C mesurée : montée ~30 s, descente ~7 s), jamais d'une constante | +> ### Sous `unmeasurable`, la cadence est CONSTANTE — la loi de commande change avec le régime +> +> Un écran qui suppose la même loi dans les deux régimes se trompera sur la trajectoire, et c'est +> la trajectoire qu'il vend. +> +> | régime | loi | `atMaximum` | +> |---|---|---| +> | `measured` / `estimated` | `reste ÷ temps restant`, **recalculée** — elle se durcit à l'approche | peut passer à `true` | +> | `unmeasurable` | `cible ÷ jour`, **constante** — elle ne bouge pas de la fenêtre | **jamais `true`** | +> +> **Pourquoi.** Sous `unmeasurable`, `remainingWh` est figé à la cible (encadré suivant). Un +> rapport dont seul le dénominateur décroît ne suit plus un retard : **il suit l'horloge**. On +> achèterait davantage parce que le temps passe, pas parce que quelque chose est en retard. +> Chiffré : obligation 4 kWh, plafond 7 kW, fenêtre d'un jour — l'ancienne loi achetait +> **19 010 Wh**, soit ×4,75 l'obligation, et le facteur grandissait avec la fenêtre. +> +> Sous la loi constante, **le total acheté sur la fenêtre vaut exactement la cible**. +> +> **Ce que l'écran peut donc affirmer, et ce qu'il ne peut pas.** Sous `unmeasurable`, `gridW` ne +> montera pas : une phrase du type « l'achat va s'intensifier à l'approche de l'échéance » y +> serait fausse. Et `atMaximum: false` n'y signifie pas « tout va bien » — il signifie qu'il n'y +> a **pas d'étalement du tout**, donc rien qui puisse cesser. + > ### `remainingWh` sous `unmeasurable` : **la cible INTACTE**, et il faut le lire ainsi > > Sous `regime: unmeasurable`, **`remainingWh == targetWh`**. Rien n'est soustrait, parce qu'on diff --git a/debian-qt5/changelog b/debian-qt5/changelog index 63514d1..2b4aeae 100644 --- a/debian-qt5/changelog +++ b/debian-qt5/changelog @@ -1,3 +1,19 @@ +powersync-energy-plugin-nymea (1.15.2+etm55) trixie; urgency=medium + + * §10 / LM-1013-b-v : sous le régime `unmeasurable`, la passe éco commande à + TAUX CONSTANT — cible ÷ jour — au lieu de l'étalement `reste / temps restant`. + Sous ce régime le numérateur est figé à la cible par contrat : un rapport dont + seul le dénominateur décroît ne suit plus un retard, il suit l'horloge. + * `atMaximum` n'est plus jamais vrai sous ce régime : le drapeau annonce que + l'étalement a cessé, et sans étalement il n'a rien à annoncer. + * Chiffré : obligation 4 kWh, plafond 7 kW, fenêtre d'un jour — l'ancienne loi + achetait 19 010 Wh, soit ×4,75 l'obligation, le facteur suivant ln(T·P/E)+1. + * Le régime mesuré est INCHANGÉ : durcissement et point de non-retour intacts, + et une contre-épreuve du test le vérifie au même instant et sur le même + numérateur. + + -- ETM Schurig Fri, 04 Sep 2026 17:15:00 +0200 + powersync-energy-plugin-nymea (1.15.2+etm54) trixie; urgency=medium * +etm53 DÉPLOYÉ sur .75 le 2026-09-04, version relue et `nymead` actif : `bindingPhase` et le diff --git a/energyplugin/etm/types/ecotarget.cpp b/energyplugin/etm/types/ecotarget.cpp index 5a0b2e9..b27c9b7 100644 --- a/energyplugin/etm/types/ecotarget.cpp +++ b/energyplugin/etm/types/ecotarget.cpp @@ -63,6 +63,36 @@ EcoTarget computeEcoTarget(const LoadNeeds &needs, const EcoProgress &progress, if (t.tenue) return t; // watts = 0 : plus rien à acheter pour l'éco + // ── SOUS `unmeasurable` : TAUX CONSTANT, et surtout PAS l'étalement (\rule{LM-1013-b-v}) ──── + // + // L'étalement `restant / temps restant` suppose que le NUMÉRATEUR bouge. Sous ce régime il + // ne bouge pas : `remainingWh` est figé à la cible entière, par contrat — on ne sait pas ce + // qui a été livré, et le rebaser serait affirmer sur ce qu'on ignore. + // + // Un rapport dont seul le dénominateur décroît ne suit plus un RETARD : il suit l'HORLOGE. + // On achèterait davantage parce que le temps passe, jamais parce que quelque chose est en + // retard — et l'escalade n'aurait aucun fait derrière elle. Chiffré : obligation 4 kWh, + // plafond 7 kW, fenêtre d'un jour → l'ancienne loi achetait 19 010 Wh, soit ×4,75 + // l'obligation. Le facteur suit ln(T·P/E) + 1, donc il GRANDIT avec la fenêtre : plus on + // laisse de temps pour bien faire, plus on paie. Une charge qu'on mesure mal coûterait + // ainsi structurellement plus cher qu'une charge qu'on mesure. + // + // La loi retenue : la cible divisée par la FENÊTRE de l'obligation, constante. L'obligation + // est déclarée par JOUR (`minEnergyWhPerDay`), donc la fenêtre est le jour — et le total + // acheté sur la fenêtre vaut alors EXACTEMENT la cible, ni plus ni moins. + // + // On garde « viser l'obligation entière », qui est le choix qui sert. On perd l'escalade, + // qui ne reposait sur rien. + if (!progress.delivered) { + t.watts = t.remainingWh / (EcoFenetreObligationS / 3600.0); + if (maxPowerW > 0) + t.watts = qMin(t.watts, maxPowerW); + // `pointDeNonRetour` reste FAUX, quel que soit le temps restant. Il annonce que + // l'étalement a CESSÉ ; sans étalement, il n'a rien à annoncer. Le publier ici ferait + // dire au motif qu'on est en retard sur un avancement qu'on ne mesure pas. + return t; + } + // Échéance dépassée : il n'y a plus rien à étaler. On prend le maximum, ou l'obligation // reste due sans jamais être servie. if (t.remainingS <= 0) { diff --git a/energyplugin/etm/types/ecotarget.h b/energyplugin/etm/types/ecotarget.h index a569055..32a202d 100644 --- a/energyplugin/etm/types/ecotarget.h +++ b/energyplugin/etm/types/ecotarget.h @@ -43,6 +43,11 @@ struct EcoTarget { * * **Le seuil est NOMMÉ plutôt que subi.** Sans lui, la cadence tend vers l'infini sans que * rien ne décide, et le comportement se lit comme une dérive. Un seuil se discute. + * + * \note **Jamais vrai sous un régime `unmeasurable`** (\rule{LM-1013-b-v}). Ce drapeau + * annonce que l'ÉTALEMENT a cessé ; sous taux constant il n'y a pas d'étalement, donc rien + * à annoncer. Le publier ferait dire au motif qu'on est en retard sur un avancement qu'on + * ne mesure pas. */ bool pointDeNonRetour = false; @@ -75,10 +80,16 @@ QDateTime resolveDeadline(const LoadNeeds &needs, const QDateTime &now); * n'existe pas dans ce modèle. Un étalement se lit, se teste, et ne prétend rien prévoir — même * raison qui a fait de la cible de confort un comparateur plutôt qu'un dosage (\rule{LM-1008}). * - * \par Recalculé à CHAQUE cycle, jamais figé + * \par Recalculé à CHAQUE cycle, jamais figé — sous régime MESURÉ * Une cadence calculée une fois au branchement ne rattrape pas : après une heure sans surplus, * elle reste à la cadence initiale et **manque l'échéance en ayant l'air de travailler**. Les * deux termes du rapport bougent, donc le rapport se relit. + * + * \par Sous `unmeasurable`, la loi CHANGE : taux constant + * \rule{LM-1013-b-v} — là, un seul terme bouge — `remainingWh` est figé à la cible par contrat. Un rapport dont seul + * le dénominateur décroît ne suit plus un retard, il suit l'horloge : on achèterait davantage + * parce que le temps passe. La cadence est donc `cible / jour`, constante, et le total acheté + * sur la fenêtre vaut exactement la cible. */ EcoTarget computeEcoTarget(const LoadNeeds &needs, const EcoProgress &progress, double maxPowerW, const QDateTime &now); @@ -86,4 +97,17 @@ EcoTarget computeEcoTarget(const LoadNeeds &needs, const EcoProgress &progress, //! \brief Sous ce temps restant, on cesse d'étaler et on prend le maximum disponible. //! Valeur de conception, à ajuster sur relevé : trop court, on rate l'échéance faute de //! puissance ; trop long, on achète tôt ce que le soleil aurait pu payer. +//! \note Ne s'applique QU'AU régime mesuré. Sous \c unmeasurable la cadence est constante, +//! donc rien ne se durcit et il n'y a pas de point de non-retour à franchir. constexpr qint64 EcoPointDeNonRetourS = 15 * 60; + +/*! + * \brief Fenêtre sur laquelle une obligation est étalée à TAUX CONSTANT, sous \c unmeasurable. + * + * Un jour, parce que l'obligation elle-même est déclarée par jour (\c minEnergyWhPerDay). Le + * total acheté sur cette fenêtre vaut alors exactement la cible. + * + * Ce n'est pas un réglage : c'est l'unité de la grandeur qu'on étale. La changer sans changer + * l'unité de l'obligation ferait acheter autre chose que ce qui a été demandé. + */ +constexpr qint64 EcoFenetreObligationS = 24 * 3600; diff --git a/specs/spec_loadmodel.md b/specs/spec_loadmodel.md index 9d247af..20af717 100644 --- a/specs/spec_loadmodel.md +++ b/specs/spec_loadmodel.md @@ -1097,6 +1097,40 @@ d'étaler et on prend le maximum. Sans lui la cadence tend vers l'infini sans qu 07:00 vise le lendemain. La lire comme dépassée ferait franchir le point de non-retour toutes les nuits, et acheter à pleine puissance chaque soir sans qu'aucune échéance ne l'exige. +**LM-1013-b-v — sous `unmeasurable`, la loi est un TAUX CONSTANT, pas l'étalement.** +*(Tranché le 2026-09-01 ; écrit et livré le 2026-09-04.)* + +L'étalement `reste / temps restant` suppose que le **numérateur bouge**. Sous ce régime il ne +bouge pas : `remainingWh` est figé à la cible entière, par contrat — on ne sait pas ce qui a été +livré, et le rebaser serait affirmer sur ce qu'on ignore (`LM-1009` C1). + +**Un rapport dont seul le dénominateur décroît ne suit plus un retard : il suit l'horloge.** On +achèterait davantage *parce que le temps passe*, jamais parce que quelque chose est en retard — +et l'escalade n'aurait aucun fait derrière elle. + +**Chiffré.** Obligation 4 kWh, plafond 7 kW, fenêtre d'un jour : l'ancienne loi achète +**19 010 Wh**, soit **×4,75** l'obligation. Le facteur suit `ln(T·P/E) + 1` — il **grandit avec +la fenêtre**, ce qui est le pire sens possible : plus on laisse de temps pour bien faire, plus on +paie. *(Le ×2,5 retenu au moment de trancher valait pour une fenêtre bien plus courte ; sur la +fenêtre quotidienne, qui est celle du modèle, c'est ×4,75. La conclusion ne change pas — le +chiffre, si.)* + +Une charge qu'on mesure mal coûterait ainsi structurellement plus cher qu'une charge qu'on +mesure : l'incitation exactement à l'envers. + +La loi : **cible ÷ fenêtre de l'obligation, constante**, et `atMaximum` **jamais vrai**. La +fenêtre est le **jour**, parce que l'obligation est déclarée par jour (`minEnergyWhPerDay`) — ce +n'est pas un réglage, c'est l'unité de la grandeur qu'on étale. Le total acheté sur la fenêtre +vaut alors **exactement la cible**, ni plus ni moins. + +On garde « viser l'obligation entière », qui est le choix qui **sert** plutôt que celui qui +suppose acquis. On perd l'escalade, qui ne reposait sur rien. + +> `LM-1013-b-i` (recalculé à chaque cycle), `LM-1013-b-ii` (durcissement) et `LM-1013-b-iii` +> (point de non-retour) ne valent donc QUE sous un régime mesuré. Les trois décrivent le +> comportement d'un rapport dont les deux termes bougent ; sous `unmeasurable`, un seul bouge, et +> ce qu'ils décrivent n'existe pas. + **LM-1013-c — la BASCULE DE PÉRIODE reprend la base d'avancement.** *(Trouvé en mesurant, et invisible à la lecture du code.)* À l'instant de l'échéance quotidienne, une nouvelle période d'obligation s'ouvre. Sans reprise de la base, **le lendemain hérite de l'énergie de la veille et diff --git a/tests/auto/loadmodel/testloadmodel.cpp b/tests/auto/loadmodel/testloadmodel.cpp index efd9118..dc452b2 100644 --- a/tests/auto/loadmodel/testloadmodel.cpp +++ b/tests/auto/loadmodel/testloadmodel.cpp @@ -626,6 +626,79 @@ void TestLoadModel::testEcoSpreadHardensAndSaysSo() QCOMPARE(soir.remainingS, qint64(8 * 3600)); } +void TestLoadModel::testEcoUnmeasurableBuysAtAConstantRate() +{ + // \rule{LM-1013-b-v} — sous `unmeasurable`, `remainingWh` est FIGÉ à la cible par contrat. + // Un rapport dont seul le dénominateur décroît ne suit plus un retard : il suit l'horloge. + LoadNeeds needs; + needs.minEnergyWhPerDay = 4000; + needs.dailyDeadline = "07:00"; + + EcoProgress inconnu; + inconnu.delivered = false; // le régime `unmeasurable` + inconnu.targetWh = 4000; + + EcoProgress mesure; // même obligation, même instant, régime MESURÉ + mesure.delivered = true; + mesure.targetWh = 4000; + mesure.deliveredWh = 0; // rien livré : le numérateur vaut AUSSI 4000 + + const QDate j(2026, 9, 4); + const double PLAFOND = 7000; + auto inconnue = [&](int h, int m) { + return computeEcoTarget(needs, inconnu, PLAFOND, QDateTime(j, QTime(h, m), QTimeZone::utc())); + }; + auto mesuree = [&](int h, int m) { + return computeEcoTarget(needs, mesure, PLAFOND, QDateTime(j, QTime(h, m), QTimeZone::utc())); + }; + + // 1. CONSTANTE sur toute la fenêtre. C'est l'assertion qui mord : sous l'ancienne loi ces + // cinq valeurs montaient de 167 W au plafond. + const int attendu = qRound(4000.0 / 24.0); // 167 W + QCOMPARE(qRound(inconnue(1, 0).watts), attendu); // 6 h avant + QCOMPARE(qRound(inconnue(5, 0).watts), attendu); // 2 h avant + QCOMPARE(qRound(inconnue(6, 0).watts), attendu); // 1 h avant + QCOMPARE(qRound(inconnue(6, 30).watts), attendu); // 30 min + QCOMPARE(qRound(inconnue(6, 55).watts), attendu); // 5 min — DANS le seuil de non-retour + + // 2. `atMaximum` JAMAIS vrai, y compris sous le seuil. Le drapeau annonce que l'étalement a + // cessé ; sans étalement il n'a rien à annoncer, et le publier dirait qu'on est en retard + // sur un avancement qu'on ne mesure pas. + QVERIFY2(!inconnue(6, 55).pointDeNonRetour, "5 min avant : pas de point de non-retour"); + QVERIFY2(!inconnue(6, 59).pointDeNonRetour, "1 min avant non plus"); + + // 3. LE TOTAL ACHETÉ VAUT EXACTEMENT LA CIBLE. C'est la propriété qui justifie la loi, et + // elle ne se lit sur aucune valeur isolée : il faut intégrer la fenêtre entière. + double total = 0; + QDateTime t(j, QTime(7, 1), QTimeZone::utc()); + for (int minute = 0; minute < 24 * 60; ++minute) { + total += computeEcoTarget(needs, inconnu, PLAFOND, t).watts / 60.0; // Wh sur 1 min + t = t.addSecs(60); + } + QVERIFY2(qAbs(total - 4000.0) < 5.0, + qUtf8Printable(QString("total acheté sur la fenêtre : %1 Wh, attendu 4000") + .arg(total, 0, 'f', 1))); + + // 4. CONTRE-ÉPREUVE — la loi n'a changé QUE pour ce régime. Même obligation, même instant, + // même numérateur (4000 non livrés) : sous `measured`, le durcissement est intact. Sans + // cette épreuve, avoir supprimé l'étalement POUR TOUT LE MONDE passerait inaperçu. + QCOMPARE(qRound(mesuree(1, 0).watts), 667); // 4 kWh en 6 h + QCOMPARE(qRound(mesuree(6, 0).watts), 4000); // 4 kWh en 1 h + QVERIFY2(mesuree(6, 55).pointDeNonRetour, "sous `measured`, le point de non-retour tient"); + QCOMPARE(qRound(mesuree(6, 55).watts), qRound(PLAFOND)); + + // 5. L'ÉCART, au même instant, dit tout le lot : à cinq minutes de l'échéance, le régime + // mesuré prend 7000 W parce qu'il SAIT qu'il reste 4 kWh à livrer ; le régime inconnu en + // prend 167 parce qu'il ne sait rien — et ne pas savoir n'est pas une raison d'acheter. + QVERIFY2(mesuree(6, 55).watts > inconnue(6, 55).watts * 40, + "au même instant, les deux régimes doivent diverger d'un ordre de grandeur"); + + // 6. R6 tient toujours : on ne conclut jamais « tenue » sur un avancement qu'on ne mesure + // pas, et la cible reste INTACTE — ce que l'écran affiche comme reste dû. + QVERIFY2(!inconnue(5, 0).tenue, "jamais « tenue » sous `unmeasurable` (LM-1009 C1)"); + QCOMPARE(qRound(inconnue(5, 0).remainingWh), 4000); +} + 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 cdf9363..37e18ff 100644 --- a/tests/auto/loadmodel/testloadmodel.h +++ b/tests/auto/loadmodel/testloadmodel.h @@ -80,6 +80,7 @@ private slots: //! On compare des MARGES, jamais des plafonds : le plus contraignant mord, et on sait //! lequel. Aucun plafond déclaré ≠ marge nulle. Départage stable à marge égale. void testEcoSpreadHardensAndSaysSo(); + void testEcoUnmeasurableBuysAtAConstantRate(); void testDrawMarginComparesHeadroomsNotCaps(); };