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(); };