diff --git a/INTERFACE.md b/INTERFACE.md index ee54de4..d6b4d25 100644 --- a/INTERFACE.md +++ b/INTERFACE.md @@ -51,11 +51,24 @@ ### `ChargingMode` | Valeur | Description | |---|---| -| `ChargingModeNormal` | Recharge immédiate au maximum disponible | -| `ChargingModeEco` | Recharge sur surplus solaire uniquement | +| `ChargingModeNormal` | **MANUEL** — la borne sort de l'arbitrage : le moteur ne commande **rien**. Seule la protection de surcharge la touche encore, et elle peut donc la couper. Ce n'est **pas** un « boost » : un boost commanderait le maximum | +| `ChargingModeEco` | Recharge sur **surplus** solaire uniquement | | `ChargingModeEcoWithTargetTime` | Eco + deadline de fin (`endDateTime` + `targetPercentage`) | -| `ChargingModeEcoWithMinCurrent` | Eco + courant minimum garanti (6 A) si pas de surplus | -| `ChargingModeEcoMinWithTargetTime` | Eco + courant minimum + deadline | +| `ChargingModeEcoWithMinCurrent` | **Surplus + minimum réseau** — le surplus d'abord, et le complément **acheté au réseau** jusqu'au plancher de la borne (`maxChargingCurrent.minValue × phaseCount × 230 V`). Ne promet **pas** que le minimum sera servi : le rang, le plafond de soutirage et la réserve batterie peuvent l'empêcher, et c'est le **motif** qui le rapporte | +| `ChargingModeEcoMinWithTargetTime` | Surplus + minimum réseau + deadline | + +> **`MinCurrent` nomme l'exigence de la BORNE, pas une promesse du mode.** Les 6 A sous lesquels +> elle refuse de charger sont une donnée de matériel. Le nom a longtemps laissé lire « le mode +> maintient un courant minimum » — ce qu'aucun chemin de code ne faisait : le mode était réécrit +> en `Eco` à chaque cycle chez l'amont, puis la branche qui l'appliquait est devenue +> inatteignable quand le waterfall a pris la borne. **Trois modes annoncés, deux implémentés, et +> celui qui manquait était celui que l'utilisateur croyait utiliser.** Voir +> `docs/DESIGN_MIN_RESEAU.md`. +> +> **Les mots « garanti » et « prioritaire » sont bannis** à propos de ce mode, dans le contrat +> comme à l'écran. Le premier promet une certitude que rien ne tient ; le second serait vrai face +> à tout le confort et faux face à un autre besoin éco mieux rangé — donc faux exactement dans le +> cas d'échec, celui qu'un libellé doit couvrir. ### `ChargingState` (lecture seule — calculé par le manager) | Valeur | Description | @@ -1090,11 +1103,28 @@ 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. **La LOI DE COMMANDE dépend du régime** — voir l'encadré ci-dessous | +| `ECO_FLOOR_GRID` *(§10)* | `gridW`, `atMaximum`, `o:remainingWh`, `o:targetWh`, `o:remainingS`, `o:regime`, `o:minAlwaysW`, `o:boundBy` | 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 | +> ### `ECO_FLOOR_GRID` sert DEUX besoins, et dit lequel a décidé +> +> Le plancher éco peut venir d'une **obligation en énergie** (une cadence) ou d'un **minimum +> réseau** (une puissance demandée tant que la charge est là). Les deux produisent un `gridW`, et +> rien dans le nombre ne dit lequel a fixé la consigne. +> +> | champ | présent quand | +> |---|---| +> | `remainingWh`, `targetWh`, `remainingS`, `regime` | il y a une **obligation en énergie** | +> | `minAlwaysW` | un **minimum** est demandé | +> | `boundBy` ∈ `obligation` \| `minimum` | un minimum est demandé — dit **lequel des deux mord** | +> +> **Les grandeurs d'obligation sont ABSENTES sur une charge à minimum seul**, jamais à zéro : un +> `remainingS: 0` inventerait une échéance imminente et un `remainingWh: 0` une obligation +> tenue — deux affirmations fausses au lieu d'un silence. `progress` est absent pour la même +> raison : il ne décrit que l'obligation. + > ### 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 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();