feat(§10): « Surplus + minimum réseau » — le mode sert enfin un minimum
LM-1013-b-vi. Le mode ChargingModeEcoWithMinCurrent promettait un courant minimum depuis toujours sans qu'aucun chemin ne le serve : mort par réécriture chez l'amont, puis mort par exclusion quand le waterfall a pris la borne. LA LOI. computeEcoTarget() rend max(cadence de l'obligation, minimum). Le corps historique est isolé dans calculerObligation() et une sortie anticipée rend EXACTEMENT l'ancien résultat quand aucun minimum n'est demandé — ce n'est pas une optimisation, c'est la garantie que l'ajout ne déplace rien pour les charges qui n'en veulent pas. Les quatre suites le confirment. LE MINIMUM NE SE TIENT JAMAIS. `tenue` ne décrit que l'obligation en énergie ; quand elle 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 entre une énergie avec échéance et une puissance permanente se voit dans le code. L'ordonnanceur ne publie donc ECO_FLOOR_MET que si watts vaut aussi 0 : sinon il couperait ce qu'il doit continuer de servir. DÉRIVÉ, JAMAIS STOCKÉ. EvAdapter calcule le minimum depuis evFloorW() à chaque cycle quand le mode le demande. 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. Correction apportée par le brief app f2bd07d, et elle est juste. LE MOTIF DIT LEQUEL DES DEUX MORD. ECO_FLOOR_GRID gagne minAlwaysW et boundBy, et OMET remainingWh/targetWh/remainingS/regime sur une charge à minimum seul : un remainingS à 0 inventerait une échéance imminente et un remainingWh à 0 une obligation tenue. progress est absent pour la même raison — il ne décrit que l'obligation. CONTRAT. Les trois descriptions de ChargingMode sont corrigées. Normal n'est pas « recharge immédiate au maximum » : c'est MANUEL, le moteur ne commande rien, et seule la protection de surcharge la touche encore — ce qui doit être dit, un mode qui promet l'abstention et coupe quand même étant une surprise. Et les mots « garanti » et « prioritaire » sont bannis à propos de ce mode : le premier promet une certitude que rien ne tient, le second est vrai face au confort et faux face à un autre besoin éco mieux rangé, donc faux exactement dans le cas d'échec. TEST vu mordre : désarmé, minSeul.watts rend 0 au lieu de 4 140. Cinq points, dont la contre-épreuve qui distingue « 4 140 vient du minimum » de « 4 140 vient d'un reliquat d'obligation », et le cas 1 qui vérifie que rien ne bouge sans minimum. Suites : loadmodel 26/26 (+1), simulation 67/67, charging 48/48, spotmarket 32/32. NON DÉPLOYÉ — +etm57 attend le feu vert.
This commit is contained in:
parent
86aab086a2
commit
e65dbb4a6a
40
INTERFACE.md
40
INTERFACE.md
@ -51,11 +51,24 @@
|
|||||||
### `ChargingMode`
|
### `ChargingMode`
|
||||||
| Valeur | Description |
|
| Valeur | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `ChargingModeNormal` | Recharge immédiate au maximum disponible |
|
| `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 |
|
| `ChargingModeEco` | Recharge sur **surplus** solaire uniquement |
|
||||||
| `ChargingModeEcoWithTargetTime` | Eco + deadline de fin (`endDateTime` + `targetPercentage`) |
|
| `ChargingModeEcoWithTargetTime` | Eco + deadline de fin (`endDateTime` + `targetPercentage`) |
|
||||||
| `ChargingModeEcoWithMinCurrent` | Eco + courant minimum garanti (6 A) si pas de surplus |
|
| `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` | Eco + courant minimum + deadline |
|
| `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)
|
### `ChargingState` (lecture seule — calculé par le manager)
|
||||||
| Valeur | Description |
|
| 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 |
|
| `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 |
|
| `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** |
|
| `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_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 |
|
| `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 |
|
| `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
|
> ### 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
|
> Un écran qui suppose la même loi dans les deux régimes se trompera sur la trajectoire, et c'est
|
||||||
|
|||||||
@ -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 <dev@etm-schurig.eu> Mon, 07 Sep 2026 08:30:00 +0200
|
||||||
|
|
||||||
powersync-energy-plugin-nymea (1.15.2+etm56) trixie; urgency=medium
|
powersync-energy-plugin-nymea (1.15.2+etm56) trixie; urgency=medium
|
||||||
|
|
||||||
* Divergence commande/mesure JOURNALISÉE à l'ouverture et à la fermeture
|
* Divergence commande/mesure JOURNALISÉE à l'ouverture et à la fermeture
|
||||||
|
|||||||
@ -82,6 +82,23 @@ LoadContext EvAdapter::toLoadContext(const QDateTime &now) const
|
|||||||
ctx.declared.minPowerW = m_parent->evFloorW(m_charger, m_targetPhases);
|
ctx.declared.minPowerW = m_parent->evFloorW(m_charger, m_targetPhases);
|
||||||
ctx.declared.phases = static_cast<int>(m_targetPhases);
|
ctx.declared.phases = static_cast<int>(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 -------------------
|
// --- 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
|
// L'écrêtage par la limite de phase vivait dans planSurplusCharging(), que la borne ne
|
||||||
|
|||||||
@ -257,7 +257,10 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
|
|||||||
|
|
||||||
LoadAction::Level eco;
|
LoadAction::Level eco;
|
||||||
eco.level = QStringLiteral("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;
|
eco.progress = avancement;
|
||||||
|
|
||||||
// CLÔTURE NON TENUE — l'événement du cycle, et il prime sur ce que la période neuve
|
// 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;
|
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
|
// 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
|
// `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.
|
// 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.targetW = achatW;
|
||||||
eco.funding = LoadAction::Grid;
|
eco.funding = LoadAction::Grid;
|
||||||
eco.counts[CountKey::DrawCommitted] = achatW;
|
eco.counts[CountKey::DrawCommitted] = achatW;
|
||||||
eco.reason = { DecisionCode::EcoFloorGrid,
|
QVariantMap motif{{"gridW", qRound(achatW)},
|
||||||
{{"gridW", qRound(achatW)},
|
{"atMaximum", cible.pointDeNonRetour}};
|
||||||
{"remainingWh", qRound(cible.remainingWh)},
|
// Les grandeurs de l'OBLIGATION n'ont de sens que s'il y en a une. Sur une charge à
|
||||||
{"targetWh", qRound(avancement.targetWh)},
|
// minimum seul, publier `remainingWh: 0` et `remainingS: 0` inventerait une échéance
|
||||||
{"remainingS", static_cast<int>(cible.remainingS)},
|
// imminente et une obligation tenue — deux affirmations fausses au lieu d'un silence.
|
||||||
{"regime", avancement.regime},
|
if (lc.needs.minEnergyWhPerDay > 0) {
|
||||||
{"atMaximum", cible.pointDeNonRetour}} };
|
motif.insert(QStringLiteral("remainingWh"), qRound(cible.remainingWh));
|
||||||
|
motif.insert(QStringLiteral("targetWh"), qRound(avancement.targetWh));
|
||||||
|
motif.insert(QStringLiteral("remainingS"), static_cast<int>(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);
|
niveauxEco.insert(lc.id, eco);
|
||||||
|
|
||||||
// LM-1004 — le recrédit est attribué à la PREMIÈRE passe où la charge apparaît, et le
|
// LM-1004 — le recrédit est attribué à la PREMIÈRE passe où la charge apparaît, et le
|
||||||
|
|||||||
@ -29,8 +29,10 @@ QDateTime resolveDeadline(const LoadNeeds &needs, const QDateTime &now)
|
|||||||
return echeance;
|
return echeance;
|
||||||
}
|
}
|
||||||
|
|
||||||
EcoTarget computeEcoTarget(const LoadNeeds &needs, const EcoProgress &progress,
|
//! \brief La cible issue de la seule OBLIGATION EN ÉNERGIE — corps historique, inchangé.
|
||||||
double maxPowerW, const QDateTime &now)
|
//! 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;
|
EcoTarget t;
|
||||||
|
|
||||||
@ -123,3 +125,39 @@ EcoTarget computeEcoTarget(const LoadNeeds &needs, const EcoProgress &progress,
|
|||||||
|
|
||||||
return t;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -54,6 +54,18 @@ struct EcoTarget {
|
|||||||
//! Vrai si une obligation exploitable existe — sinon la passe éco n'émet rien pour cette
|
//! 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.
|
//! charge, et son niveau est ABSENT (R1) plutôt que présent à zéro.
|
||||||
bool applicable = false;
|
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
/*!
|
/*!
|
||||||
|
|||||||
@ -85,6 +85,30 @@ struct LoadNeeds {
|
|||||||
QDateTime deadline; //!< Échéance absolue de recharge.
|
QDateTime deadline; //!< Échéance absolue de recharge.
|
||||||
QString dailyDeadline; //!< Heure limite quotidienne, format "HH:MM".
|
QString dailyDeadline; //!< Heure limite quotidienne, format "HH:MM".
|
||||||
int minEnergyWhPerDay = 0; //!< Énergie minimale par jour (Wh).
|
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
/*!
|
/*!
|
||||||
|
|||||||
@ -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
|
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.
|
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.**
|
**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.)*
|
*(Tranché le 2026-09-01 ; écrit et livré le 2026-09-04.)*
|
||||||
|
|
||||||
|
|||||||
@ -701,6 +701,78 @@ void TestLoadModel::testEcoUnmeasurableBuysAtAConstantRate()
|
|||||||
QCOMPARE(qRound(inconnue(5, 0).remainingWh), 4000);
|
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()
|
void TestLoadModel::testStoredInstantsSurviveATimeZoneChange()
|
||||||
{
|
{
|
||||||
// Un instant ABSOLU ne bouge pas quand l'installation change de fuseau ; une FRONTIÈRE
|
// Un instant ABSOLU ne bouge pas quand l'installation change de fuseau ; une FRONTIÈRE
|
||||||
|
|||||||
@ -81,6 +81,7 @@ private slots:
|
|||||||
//! lequel. Aucun plafond déclaré ≠ marge nulle. Départage stable à marge égale.
|
//! lequel. Aucun plafond déclaré ≠ marge nulle. Départage stable à marge égale.
|
||||||
void testEcoSpreadHardensAndSaysSo();
|
void testEcoSpreadHardensAndSaysSo();
|
||||||
void testEcoUnmeasurableBuysAtAConstantRate();
|
void testEcoUnmeasurableBuysAtAConstantRate();
|
||||||
|
void testMinimumIsServedEvenWhenTheObligationIsMet();
|
||||||
void testStoredInstantsSurviveATimeZoneChange();
|
void testStoredInstantsSurviveATimeZoneChange();
|
||||||
void testReportingDayBoundaryIsPublishedNotDeducible();
|
void testReportingDayBoundaryIsPublishedNotDeducible();
|
||||||
void testDrawMarginComparesHeadroomsNotCaps();
|
void testDrawMarginComparesHeadroomsNotCaps();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user