diff --git a/debian-qt5/changelog b/debian-qt5/changelog index 9f4df87..bcfb9e1 100644 --- a/debian-qt5/changelog +++ b/debian-qt5/changelog @@ -1,3 +1,24 @@ +powersync-energy-plugin-nymea (1.15.2+etm57) trixie; urgency=medium + + * « Surplus + minimum réseau » (LM-1013-b-vi) : le mode + ChargingModeEcoWithMinCurrent sert enfin un minimum. La passe éco rend + max(cadence de l'obligation, minimum), et le minimum ne se tient jamais — + quand l'obligation est tenue, la consigne retombe sur lui, pas sur zéro. + * Pour une borne, la valeur se DÉRIVE de maxChargingCurrent.minValue × + phaseCount × 230 V à chaque cycle. Jamais stockée : une copie écrite à + l'appairage se périmerait au premier recâblage. + * Le motif ECO_FLOOR_GRID porte minAlwaysW et boundBy, et OMET les grandeurs + d'obligation sur une charge à minimum seul — un remainingS à 0 inventerait + une échéance imminente. + * Contrat : les trois descriptions de ChargingMode corrigées. Normal est + MANUEL, pas un boost — le moteur ne commande rien, seule la protection de + surcharge la touche encore. + * Les quatre instants stockés sont ancrés en UTC à la capture. Un QDateTime + local résout son époque à la conversion : measuredSince et command.since + dérivaient d'une heure au changement de fuseau. + + -- ETM Schurig Mon, 07 Sep 2026 08:30:00 +0200 + powersync-energy-plugin-nymea (1.15.2+etm56) trixie; urgency=medium * Divergence commande/mesure JOURNALISÉE à l'ouverture et à la fermeture diff --git a/energyplugin/etm/adapters/evadapter.cpp b/energyplugin/etm/adapters/evadapter.cpp index 9eb949f..565eb98 100644 --- a/energyplugin/etm/adapters/evadapter.cpp +++ b/energyplugin/etm/adapters/evadapter.cpp @@ -82,6 +82,23 @@ LoadContext EvAdapter::toLoadContext(const QDateTime &now) const ctx.declared.minPowerW = m_parent->evFloorW(m_charger, m_targetPhases); ctx.declared.phases = static_cast(m_targetPhases); + // --- « Surplus + minimum réseau » : le mode DEMANDE un minimum, la valeur se DÉRIVE ------ + // + // `ChargingModeEcoWithMinCurrent` promettait « éco + courant minimum » depuis toujours, et + // ne l'a jamais servi : le mode était réécrit en Eco à chaque cycle chez l'amont, puis — + // une fois ce défaut corrigé — la branche qui l'appliquait est devenue inatteignable, le + // waterfall commandant désormais la borne (evCommandedByWaterfall). Mort par réécriture, + // puis mort par exclusion, sans que rien ne le signale. Voir docs/DESIGN_MIN_RESEAU.md. + // + // La valeur n'est PAS stockée : elle se dérive du matériel à chaque cycle, comme le + // plancher juste au-dessus. L'écrire en configuration à l'appairage créerait une copie qui + // se périme au premier recâblage — `phaseCount` change, la copie reste fausse en silence. + const ChargingInfo::ChargingMode mode = m_parent->chargingInfo(m_charger->id()).chargingMode(); + if (mode == ChargingInfo::ChargingModeEcoWithMinCurrent + || mode == ChargingInfo::ChargingModeEcoMinWithTargetTime) { + ctx.needs.minPowerWAlways = ctx.declared.minPowerW; + } + // --- 3g-2 : le plafond du cycle, protection de surcharge COMPRISE ------------------- // // L'écrêtage par la limite de phase vivait dans planSurplusCharging(), que la borne ne diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index c347821..71091eb 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -257,7 +257,10 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) LoadAction::Level eco; eco.level = QStringLiteral("eco"); - eco.hasProgress = true; + // `progress` ne décrit QUE l'obligation en énergie. Sur une charge à minimum seul il est + // ABSENT, jamais à zéro : un avancement nul se lirait « rien livré » là où il n'y a rien + // à avancer. Même règle que `progressUnmeasurableCause` — l'absence est l'information. + eco.hasProgress = lc.needs.minEnergyWhPerDay > 0; eco.progress = avancement; // CLÔTURE NON TENUE — l'événement du cycle, et il prime sur ce que la période neuve @@ -284,7 +287,11 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) continue; } - if (cible.tenue) { + // `tenue` décrit l'obligation en ÉNERGIE, jamais le minimum — celui-ci ne se complète + // pas. Quand l'obligation est tenue ET qu'un minimum reste demandé, la cadence vaut 0 + // et `computeEcoTarget()` a fait retomber la consigne sur le minimum : il n'y a donc + // rien à clore, et publier ECO_FLOOR_MET couperait ce qu'on doit continuer de servir. + if (cible.tenue && cible.watts <= 0) { // R6 — `ECO_FLOOR_MET` ne peut PAS sortir sous un régime non mesuré, et // `computeEcoTarget()` ne conclut jamais « tenue » dans ce cas : la garde est ici // ET là-bas, parce qu'un invariant qui ne tient qu'à un seul endroit tient mal. @@ -328,13 +335,27 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) eco.targetW = achatW; eco.funding = LoadAction::Grid; eco.counts[CountKey::DrawCommitted] = achatW; - eco.reason = { DecisionCode::EcoFloorGrid, - {{"gridW", qRound(achatW)}, - {"remainingWh", qRound(cible.remainingWh)}, - {"targetWh", qRound(avancement.targetWh)}, - {"remainingS", static_cast(cible.remainingS)}, - {"regime", avancement.regime}, - {"atMaximum", cible.pointDeNonRetour}} }; + QVariantMap motif{{"gridW", qRound(achatW)}, + {"atMaximum", cible.pointDeNonRetour}}; + // Les grandeurs de l'OBLIGATION n'ont de sens que s'il y en a une. Sur une charge à + // minimum seul, publier `remainingWh: 0` et `remainingS: 0` inventerait une échéance + // imminente et une obligation tenue — deux affirmations fausses au lieu d'un silence. + if (lc.needs.minEnergyWhPerDay > 0) { + motif.insert(QStringLiteral("remainingWh"), qRound(cible.remainingWh)); + motif.insert(QStringLiteral("targetWh"), qRound(avancement.targetWh)); + motif.insert(QStringLiteral("remainingS"), static_cast(cible.remainingS)); + motif.insert(QStringLiteral("regime"), avancement.regime); + } + // LE MINIMUM, et LEQUEL DES DEUX MORD. Les deux produisent le même nombre sans dire qui + // a décidé ; un écran qui l'ignore ne peut pas expliquer la facture. `boundBy` est + // publié plutôt que déduit d'une comparaison que le client referait de travers. + if (cible.minimumW > 0) { + motif.insert(QStringLiteral("minAlwaysW"), qRound(cible.minimumW)); + motif.insert(QStringLiteral("boundBy"), + cible.borneParLeMinimum ? QStringLiteral("minimum") + : QStringLiteral("obligation")); + } + eco.reason = { DecisionCode::EcoFloorGrid, motif }; niveauxEco.insert(lc.id, eco); // LM-1004 — le recrédit est attribué à la PREMIÈRE passe où la charge apparaît, et le diff --git a/energyplugin/etm/types/ecotarget.cpp b/energyplugin/etm/types/ecotarget.cpp index b27c9b7..0381a2a 100644 --- a/energyplugin/etm/types/ecotarget.cpp +++ b/energyplugin/etm/types/ecotarget.cpp @@ -29,8 +29,10 @@ QDateTime resolveDeadline(const LoadNeeds &needs, const QDateTime &now) return echeance; } -EcoTarget computeEcoTarget(const LoadNeeds &needs, const EcoProgress &progress, - double maxPowerW, const QDateTime &now) +//! \brief La cible issue de la seule OBLIGATION EN ÉNERGIE — corps historique, inchangé. +//! Isolé pour que l'ajout du minimum n'ait aucun effet quand aucun minimum n'est demandé. +static EcoTarget calculerObligation(const LoadNeeds &needs, const EcoProgress &progress, + double maxPowerW, const QDateTime &now) { EcoTarget t; @@ -123,3 +125,39 @@ EcoTarget computeEcoTarget(const LoadNeeds &needs, const EcoProgress &progress, return t; } + +EcoTarget computeEcoTarget(const LoadNeeds &needs, const EcoProgress &progress, + double maxPowerW, const QDateTime &now) +{ + EcoTarget t = calculerObligation(needs, progress, maxPowerW, now); + + // Aucun minimum demandé : on rend EXACTEMENT ce que rendait la version précédente. Cette + // sortie anticipée n'est pas une optimisation, c'est la garantie que l'ajout ne déplace + // rien pour les charges qui n'en veulent pas. + const double minimumW = needs.minPowerWAlways; + if (minimumW <= 0) + return t; + + // ── LE MINIMUM (\rule{LM-1013-b-vi}) ──────────────────────────────────────────────── + // + // Une puissance demandée TANT QUE LA CHARGE EST LÀ, et non une énergie avec échéance : + // on ne sait pas combien de temps une voiture reste branchée, donc rien ne peut fournir + // le dénominateur d'une cadence. « 6 A tant qu'elle est là » ne suppose rien. + // + // Il ne se « tient » JAMAIS — il n'a pas de cible et ne se complète pas. C'est pourquoi il + // s'applique APRÈS le calcul d'obligation, y compris quand celle-ci est tenue : dans ce cas + // la cadence vaut 0 et la consigne retombe sur le minimum, PAS sur zéro. C'est là, et + // seulement là, que la différence de nature se voit dans le code. + t.applicable = true; + t.minimumW = minimumW; + + double demande = minimumW; + if (maxPowerW > 0) + demande = qMin(demande, maxPowerW); // même écrêtage physique que l'obligation + + if (demande > t.watts) { + t.watts = demande; + t.borneParLeMinimum = true; + } + return t; +} diff --git a/energyplugin/etm/types/ecotarget.h b/energyplugin/etm/types/ecotarget.h index 32a202d..1fb9234 100644 --- a/energyplugin/etm/types/ecotarget.h +++ b/energyplugin/etm/types/ecotarget.h @@ -54,6 +54,18 @@ struct EcoTarget { //! Vrai si une obligation exploitable existe — sinon la passe éco n'émet rien pour cette //! charge, et son niveau est ABSENT (R1) plutôt que présent à zéro. bool applicable = false; + + //! \brief Le minimum demandé (W), recopié pour que le motif puisse le NOMMER. 0 = aucun. + double minimumW = 0; + + /*! + * \brief C'est le MINIMUM qui fixe la consigne, pas la cadence de l'obligation. + * + * Publié parce que les deux produisent le même nombre sans dire lequel a décidé — et un + * écran qui ne sait pas lequel mord ne peut pas expliquer la facture. Faux quand il n'y a + * pas de minimum, ou quand l'obligation demande davantage. + */ + bool borneParLeMinimum = false; }; /*! diff --git a/energyplugin/etm/types/loaddescriptor.h b/energyplugin/etm/types/loaddescriptor.h index 632fa7a..10982ed 100644 --- a/energyplugin/etm/types/loaddescriptor.h +++ b/energyplugin/etm/types/loaddescriptor.h @@ -85,6 +85,30 @@ struct LoadNeeds { QDateTime deadline; //!< Échéance absolue de recharge. QString dailyDeadline; //!< Heure limite quotidienne, format "HH:MM". int minEnergyWhPerDay = 0; //!< Énergie minimale par jour (Wh). + + /*! + * \brief Puissance minimale servie TANT QUE LA CHARGE EST LÀ (W). 0 = aucune. + * + * \par Pourquoi une PUISSANCE ici, quand l'obligation est une ÉNERGIE + * Une obligation en énergie suppose une échéance — c'est elle qui donne son dénominateur à + * la cadence. **Une borne n'en a aucune** : on ne sait pas combien de temps la voiture + * reste branchée. « 6 A tant qu'elle est là » est la seule formulation qui ne suppose rien. + * Un chauffe-eau, lui, a une échéance naturelle et une capacité finie : l'énergie y est la + * bonne grandeur, et \c minEnergyWhPerDay la sert déjà. + * + * \par Ce que ce champ ne promet PAS + * Il DÉCLARE une demande, exactement comme \c minEnergyWhPerDay — il ne promet aucune + * livraison. Le rang, le plafond de soutirage et la réserve batterie peuvent tous + * l'empêcher, et c'est le motif qui rapporte ce qui s'est réellement passé. Les mots + * « garanti » et « prioritaire » sont bannis à son sujet : le premier promet une certitude + * que rien ne tient, le second serait vrai face au confort et faux face à un autre besoin + * éco mieux rangé — donc faux exactement dans le cas d'échec. + * + * \par DÉRIVÉ pour une borne, jamais stocké + * \c EvAdapter le calcule à chaque cycle depuis \c evFloorW() quand le mode le demande. + * L'écrire en configuration créerait une copie qui se périme au premier recâblage. + */ + double minPowerWAlways = 0; }; /*! diff --git a/specs/spec_loadmodel.md b/specs/spec_loadmodel.md index 1daadbc..27c7f8b 100644 --- a/specs/spec_loadmodel.md +++ b/specs/spec_loadmodel.md @@ -1197,6 +1197,30 @@ 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-vi — un MINIMUM est une puissance, l'obligation une énergie ; le minimum ne se tient +jamais.** *(« Surplus + minimum réseau », tranché le 2026-09-07.)* + +Une obligation en énergie **suppose une échéance** : c'est elle qui donne son dénominateur à la +cadence. **Une borne n'en a aucune** — on ne sait pas combien de temps la voiture reste branchée. +« 6 A tant qu'elle est là » est la seule formulation qui ne suppose rien. Un chauffe-eau, lui, a +une échéance naturelle et une capacité finie : l'énergie y est la bonne grandeur. + +`computeEcoTarget()` rend donc **`max(cadence de l'obligation, minimum)`**, et quatre invariants +en découlent : + +1. **Le minimum ne se « tient » JAMAIS.** `tenue` ne décrit que l'obligation. Quand celle-ci est + tenue, la cadence vaut 0 et la consigne **retombe sur le minimum, pas sur zéro** — c'est là, + et seulement là, que la différence de nature se voit dans le code. +2. **`progress` ne décrit que l'obligation**, et il est **absent** sur une charge à minimum seul. +3. **Le motif dit lequel des deux mord** (`boundBy`), et nomme le minimum (`minAlwaysW`). Les + deux produisent le même nombre sans dire qui a décidé. +4. **Un minimum non servi se dit** — rang, plafond de soutirage ou réserve batterie peuvent + l'empêcher, et `DRAW_CAP` le nomme. Le champ **déclare une demande**, il ne promet aucune + livraison : les mots « garanti » et « prioritaire » sont bannis à son sujet. + +**Pour une borne, la valeur se DÉRIVE** (`maxChargingCurrent.minValue × phaseCount × 230 V`) à +chaque cycle, jamais stockée : une copie écrite à l'appairage se périmerait au premier recâblage. + **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.)* diff --git a/tests/auto/loadmodel/testloadmodel.cpp b/tests/auto/loadmodel/testloadmodel.cpp index 8a219d2..4a27ce3 100644 --- a/tests/auto/loadmodel/testloadmodel.cpp +++ b/tests/auto/loadmodel/testloadmodel.cpp @@ -701,6 +701,78 @@ void TestLoadModel::testEcoUnmeasurableBuysAtAConstantRate() QCOMPARE(qRound(inconnue(5, 0).remainingWh), 4000); } +void TestLoadModel::testMinimumIsServedEvenWhenTheObligationIsMet() +{ + // \rule{LM-1013-b-vi} — « Surplus + minimum réseau ». Une PUISSANCE demandée tant que la + // charge est là, distincte de l'obligation en ÉNERGIE : on ne sait pas combien de temps une + // voiture reste branchée, donc rien ne fournit le dénominateur d'une cadence. + const QDate j(2026, 9, 7); + const QDateTime cinqHeuresAvant(j, QTime(2, 0), QTimeZone::utc()); // échéance à 07:00 + + EcoProgress mesure; + mesure.delivered = true; + mesure.targetWh = 2000; + + LoadNeeds nu; // aucun besoin déclaré du tout + LoadNeeds obligationSeule; + obligationSeule.minEnergyWhPerDay = 2000; + obligationSeule.dailyDeadline = "07:00"; + LoadNeeds minimumSeul; + minimumSeul.minPowerWAlways = 4140; // 6 A x 3 phases x 230 V + LoadNeeds lesDeux = obligationSeule; + lesDeux.minPowerWAlways = 4140; + + // 1. AUCUN MINIMUM DEMANDÉ — comportement STRICTEMENT inchangé. C'est la garantie que + // l'ajout ne déplace rien pour les charges qui n'en veulent pas, et c'est la première + // chose à vérifier : un correctif qui change ce qu'il ne devait pas toucher est un + // défaut, pas une amélioration. + mesure.deliveredWh = 0; + const EcoTarget sansMin = computeEcoTarget(obligationSeule, mesure, 7000, cinqHeuresAvant); + QCOMPARE(qRound(sansMin.watts), 400); // 2000 Wh en 5 h + QCOMPARE(qRound(sansMin.minimumW), 0); + QVERIFY(!sansMin.borneParLeMinimum); + QVERIFY2(!computeEcoTarget(nu, mesure, 7000, cinqHeuresAvant).applicable, + "sans aucun besoin, la passe éco n'émet rien — R1"); + + // 2. MINIMUM SEUL, sans obligation ni échéance. C'est le cas de la borne : rien ne permet + // de calculer une cadence, et le minimum n'en a pas besoin. + const EcoTarget minSeul = computeEcoTarget(minimumSeul, EcoProgress(), 7000, cinqHeuresAvant); + QVERIFY2(minSeul.applicable, "un minimum suffit à rendre la charge éligible à la passe éco"); + QCOMPARE(qRound(minSeul.watts), 4140); + QCOMPARE(qRound(minSeul.minimumW), 4140); + QVERIFY(minSeul.borneParLeMinimum); + QVERIFY2(!minSeul.tenue, "un minimum ne se « tient » jamais : il n'a pas de cible"); + + // 3. LE CŒUR DU LOT — obligation TENUE et minimum encore demandé. La cadence vaut 0 et la + // consigne doit retomber SUR LE MINIMUM, pas sur zéro. C'est ici, et seulement ici, que + // la différence de nature entre une énergie et une puissance se voit dans le code. + mesure.deliveredWh = 2000; // obligation entièrement livrée + const EcoTarget tenu = computeEcoTarget(lesDeux, mesure, 7000, cinqHeuresAvant); + QVERIFY2(tenu.tenue, "l'obligation en énergie est bien tenue"); + QCOMPARE(qRound(tenu.watts), 4140); + QVERIFY2(tenu.borneParLeMinimum, "c'est le minimum qui fixe la consigne, et le motif doit le dire"); + // Contre-épreuve : la MÊME situation sans minimum retombe à zéro. Sans elle, on ne saurait + // pas si 4 140 vient du minimum ou d'un reliquat d'obligation mal calculé. + QCOMPARE(qRound(computeEcoTarget(obligationSeule, mesure, 7000, cinqHeuresAvant).watts), 0); + + // 4. L'OBLIGATION PLUS EXIGEANTE L'EMPORTE, et le motif dit alors « obligation ». À une + // heure de l'échéance, 2 000 Wh demandent 2 000 W — sous le minimum, donc c'est encore + // lui qui mord ; à dix minutes, le point de non-retour prend le plafond physique. + mesure.deliveredWh = 0; + const EcoTarget uneHeure = computeEcoTarget(lesDeux, mesure, 7000, QDateTime(j, QTime(6, 0), QTimeZone::utc())); + QCOMPARE(qRound(uneHeure.watts), 4140); + QVERIFY2(uneHeure.borneParLeMinimum, "2 000 W de cadence sont SOUS le minimum de 4 140"); + const EcoTarget dixMin = computeEcoTarget(lesDeux, mesure, 7000, QDateTime(j, QTime(6, 50), QTimeZone::utc())); + QCOMPARE(qRound(dixMin.watts), 7000); + QVERIFY2(!dixMin.borneParLeMinimum, "au point de non-retour, c'est l'obligation qui commande"); + + // 5. ÉCRÊTÉ AU PLAFOND PHYSIQUE, comme l'obligation. Demander plus que ce que la charge sait + // prendre n'inventerait qu'un chiffre. + LoadNeeds minTropGrand; + minTropGrand.minPowerWAlways = 9000; + QCOMPARE(qRound(computeEcoTarget(minTropGrand, EcoProgress(), 7000, cinqHeuresAvant).watts), 7000); +} + void TestLoadModel::testStoredInstantsSurviveATimeZoneChange() { // Un instant ABSOLU ne bouge pas quand l'installation change de fuseau ; une FRONTIÈRE diff --git a/tests/auto/loadmodel/testloadmodel.h b/tests/auto/loadmodel/testloadmodel.h index 9a94867..c8f4165 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 testMinimumIsServedEvenWhenTheObligationIsMet(); void testStoredInstantsSurviveATimeZoneChange(); void testReportingDayBoundaryIsPublishedNotDeducible(); void testDrawMarginComparesHeadroomsNotCaps();