feat(délestage §2): le disjoncteur passe par la résolution, au watt près

`phasePowerLimit` devient un `DrawCap{Breaker, perPhaseW}` et
`evPhaseAllowanceW()` traverse `resolveDrawMargin()` — la mécanique du
délestage est branchée, et rien ne bouge. C'est le but : à comportement
constant, tout écart observé ensuite est un défaut, pas une évolution.

L'égalité est obtenue par CONSTRUCTION. `phaseDrawW()` inverse
`calculateAllowanceAmpere()` phase par phase plutôt que de relire les états
du compteur : les lectures amont ne sont pas symétriques entre phases, et
les recopier ici en aurait fait deux versions vouées à diverger.

Deux déplacements sans changement de valeur : l'écrêtage `qMax(0, …)`
descend chez le consommateur (la résolution rend une marge SIGNÉE, dont
l'étape 4 a besoin pour dire de combien délester), et le facteur 230 remonte
dans le plafond, qui s'exprime en watts comme les autres.

Vérification : quatre suites diffées ligne à ligne contre une référence
capturée avant l'édition — charging 48/48 et simulation 50/50 identiques,
loadmodel 21/21, spotmarket 32/32. Contre-épreuve : à 1 V près la suite ne
discrimine pas (consignes en ampères entiers), donc cet essai ne prouve
rien ; à un facteur dix, 7 échecs. Le chemin est vivant.

Au passage, une `\note` du .h affirmait qu'on rend à la borne ce qu'elle
tire déjà. Le code ne l'a jamais fait, et c'est délibérément de sécurité :
sur une phase en dépassement la marge est négative, et c'est ce signe qui
ramène la borne à son minimum.

Décisions 6.1 à 6.3 gravées dans le design, et défaut amont consigné dans
SAFETY.md — non corrigé sciemment, il appartient à un [upstream-fix] isolé.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
This commit is contained in:
Patrick Schurig 2026-08-30 11:58:23 +02:00
parent e1c1030d6f
commit b29489bdb5
4 changed files with 226 additions and 41 deletions

View File

@ -105,10 +105,36 @@ sèche-linge, tout ce que le HEMS ne pilote pas. La couche L4 garde le dernier m
on sait lequel ». Testable **isolément** (`tests/auto/loadmodel`, sans serveur nymea).
**Comportement inchangé** : rien ne le consomme encore.
**Étape 2 — la SOURCE `Breaker` branchée, à comportement CONSTANT.** `phasePowerLimit` devient un
`DrawCap{Breaker, perPhaseW}` et `evPhaseAllowanceW()` passe par la résolution. Le résultat doit
être **identique au watt près** — c'est ce qui rend l'étape vérifiable : les 46 tests de
`charging` ne doivent pas bouger d'une ligne.
**Étape 2 — la SOURCE `Breaker` branchée, à comportement CONSTANT. ✅ FAITE (2026-08-30).**
`phasePowerLimit` devient un `DrawCap{Breaker, perPhaseW}` et `evPhaseAllowanceW()` passe par
`resolveDrawMargin()`, via `drawCaps()` / `phaseDrawW()` / `evDrawMargin()`.
*Comment l'égalité au watt est obtenue.* `phaseDrawW()` **inverse** `calculateAllowanceAmpere()`
phase par phase (`tirage = (limite − marge) × 230`) au lieu de relire les états du compteur.
Ce détour n'est pas de la coquetterie : les lectures amont ne sont pas symétriques entre phases
(voir `SAFETY.md`, « défaut amont connu »), et les recopier ici en aurait fait deux versions
vouées à diverger. L'inversion rend l'égalité vraie **par construction**, quelles que soient ces
lectures — y compris si le défaut amont est corrigé plus tard.
*Deux choses ont changé de place, sans changer de valeur.* L'écrêtage `qMax(0, …)` descend dans
`evPhaseAllowanceW()` : la résolution, elle, rend une marge **signée**, parce que l'étape 4 a
besoin de l'ampleur du dépassement (décision 6.3). Et le facteur 230 remonte dans le plafond,
qui s'exprime désormais en watts comme tous les autres.
*Vérification.* Quatre suites relancées, verdicts **diffés ligne à ligne** contre une référence
capturée avant l'édition : `charging` 48/48 identique, `simulation` 50/50 identique,
`loadmodel` 21/21, `spotmarket` 32/32.
*Contre-épreuve, en deux temps.* Une sortie identique s'obtiendrait aussi si l'ancien chemin
survivait et que le nouveau était mort. Perturber le plafond de **1 V** (230 → 229) ne fait
rien bouger : la suite ne discrimine pas à ce grain, les consignes étant en ampères entiers.
Ce premier essai ne prouve donc rien, et il faut le dire. C'est la perturbation d'un **facteur
dix** (230 → 23) qui tranche : **7 échecs** dans `testEcoMode`, `testEcoModePhaseSwitching` et
`testBatteryLevelConsideration`. Le chemin est vivant, et c'est bien lui qui décide.
> À retenir pour la suite : la sensibilité de `charging` à la limite de phase est de l'ordre de
> l'ampère, pas du watt. « Identique au watt près » y est donc vérifié comme une identité de
> **résultats**, pas de grandeurs intermédiaires — pour celles-ci, c'est l'inversion qui répond.
**Étape 3 — la cascade est bornée par la marge.** Le waterfall cesse d'allouer au-delà. C'est ici
que le comportement change pour les charges non-EV, et c'est ici que le motif `DRAW_CAP` naît
@ -123,27 +149,43 @@ réservée aux bornes ; ordre inverse du rang, `force = true`, verrous bypassés
---
## 6. CE QUI DEMANDE UNE DÉCISION
## 6. CE QUI A ÉTÉ TRANCHÉ (2026-08-30)
**6.1 — La marge doit-elle tenir compte de ce que le HEMS a lui-même commandé et qui n'est pas
encore mesuré ?** C'est la correction A, transposée au soutirage. Sans elle, deux cycles
consécutifs peuvent décider d'acheter la même marge deux fois. Avec elle, on réintroduit un
terme de latence dans une couche de **sécurité**, où une hypothèse fausse coupe du courant.
**Recommandation : NON dans la cascade, OUI nulle part ailleurs** — la cascade borne sur la marge
MESURÉE, et l'écart d'un cycle est absorbé par la marge que le disjoncteur garde par
construction (`allowancePerPhase -= 1` existe déjà côté proxy). À trancher explicitement.
**6.1 — La marge tient-elle compte du commandé non encore mesuré ? → NON.**
C'était la correction A transposée au soutirage. Le motif du refus est plus fort que celui de
la recommandation initiale : le commandé-non-encore-mesuré, aujourd'hui, **c'est
`budget.evReservedW`** — et on vient d'établir (LM-1011, `PlanDraw`) que ce champ mélange deux
natures et n'est pas le registre qu'il paraît être. Bâtir une décision de **sécurité** sur le
seul champ dont on sait qu'il n'est pas ce qu'il annonce, ce serait construire sur du sable.
La cascade borne donc sur la marge **MESURÉE**. L'écart d'un cycle est absorbé par la marge que
le disjoncteur garde par construction (`allowancePerPhase -= 1`, côté proxy).
**6.2 — Que fait le délestage L4 d'une charge sous verrou `minOn` ?** Le repli L2 bypasse les
verrous (`force = true`) parce qu'un compteur muet est une panne. Un dépassement de plafond
est-il de la même gravité ? **Recommandation : OUI pour `Breaker` et `GridOperator`** — le
disjoncteur ne connaît pas le court-cycling, et un ordre du gestionnaire ne se négocie pas.
**NON pour `EcoSelfImposed`** : un plafond qu'on s'impose ne justifie pas d'abîmer un
compresseur. C'est la première conséquence concrète du fait que la source soit un paramètre.
**6.2 — Le délestage L4 bypasse-t-il `minOn` ? → OUI, pour `Breaker` et `GridOperator`
seulement.** Le disjoncteur ne connaît pas le court-cycling, et un ordre du gestionnaire de
réseau ne se négocie pas. **NON pour `EcoSelfImposed`** : un plafond qu'on s'impose ne justifie
pas d'abîmer un compresseur — on renonce au plancher éco plutôt qu'au matériel. C'est la
première conséquence concrète du fait que la source soit un paramètre et non une constante.
**6.3 — Le motif `DRAW_CAP` naît à l'étape 3 : que porte-t-il exactement ?** R8 demande la
source. Faut-il aussi `capW`, `marginW`, et la **phase** qui borne quand la source est
`Breaker` ? **Recommandation : oui aux trois** — sans la phase, un installateur devant une maison
triphasée déséquilibrée ne sait pas où aller mesurer.
> **Précaution attachée à 6.2 — le bypass doit SE DIRE dans le motif.** Un verrou franchi en
> silence, c'est un compresseur qui court-cycle sans que personne ne sache pourquoi. Le motif
> porte donc un marqueur explicite du franchissement : sans lui, la trace montrerait une charge
> coupée avant son `minOn` et **rien** n'expliquerait l'écart à la règle, qui passerait pour un
> défaut de l'implémentation du verrou. C'est la même doctrine que partout ailleurs ici —
> **publier le régime, ne jamais le laisser déduire.**
**6.3 — Ce que porte le motif `DRAW_CAP` (naissance à l'étape 3) :**
| Champ | Pourquoi il y est |
|---|---|
| la **marge** | de combien on manquait — c'est la grandeur qui a décidé |
| la **source** | ce qu'on a le droit d'en faire diffère selon elle (cf. 6.2) ; exigence R8 |
| l'**ampleur du dépassement** | « on frôle » et « on dépasse de 1200 W » ne se traitent pas pareil, et la marge seule, écrêtée, ne les distingue plus |
| la **phase** qui borne | sans elle, un installateur devant une triphasée déséquilibrée ne sait pas où aller mesurer |
| le **`minOn` franchi**, le cas échéant | conséquence directe de la précaution ci-dessus |
Le dépassement est disponible **parce que** `DrawMargin::watts` n'est pas écrêté à zéro :
l'écrêtage appartient au consommateur (une borne ne prend jamais une puissance négative), la
résolution, elle, dit la vérité (`-1200` veut dire quelque chose que `0` a perdu).
---

View File

@ -172,6 +172,37 @@ Note : en mode simulation, `simulationCallUpdate()` (ligne 295-299) appelle `upd
puis `verifyOverloadProtection()` une seconde fois — double appel intentionnel dans
les tests, pas dans la production.
### Défaut amont connu — `calculateAllowanceAmpere()` est asymétrique entre phases
**Relevé le 2026-08-30, non corrigé, sciemment.** `rootmeter.cpp` calcule la place libre
différemment selon la phase :
| Phase | Condition testée | Valeur réellement lue |
|---|---|---|
| A | `hasState("currentPhaseA")` | `currentPhaseA` — **ampères**, correct |
| B | `hasState("currentPhaseB")` | `currentPowerPhaseB / 230` — **watts**, et `voltagePhaseB` ignoré |
| C | `hasState("currentPhaseC")` | `currentPowerPhaseC / 230` — idem |
Deux conséquences, d'inégale gravité :
1. **Bénigne** : sur B et C la tension réelle est ignorée au profit de 230 V, alors que la
branche suivante, elle, la lit. Erreur de quelques pour-cent.
2. **Sérieuse** : un compteur qui publierait `currentPhaseB` **sans** `currentPowerPhaseB`
entre dans la première branche, y lit `0`, et rend une place libre égale au plafond entier.
**La protection de surcharge serait alors aveugle sur B et C** — elle n'écrêterait rien,
sans rien signaler.
**Pourquoi ce n'est pas corrigé dans ce lot.** Le mock du banc publie les six états, donc le
cas 2 n'y est pas atteint et aucun relevé machine ne le montre. Surtout, le correctif **change
le comportement** : il rendrait la protection plus restrictive sur B et C, donc écrêterait des
bornes qui ne l'étaient pas. Il appartient à un commit `[upstream-fix]` isolé, avec son propre
test, et non à une étape dont tout l'intérêt est d'être à comportement constant.
**Ce que le moteur en fait en attendant.** `EnergyArbitrator::phaseDrawW()` obtient le tirage
par phase en **inversant** cette fonction plutôt qu'en relisant les états. C'est délibéré :
recopier les lectures amont ici en ferait deux versions vouées à diverger, et la version ETM
« corrigée » masquerait le défaut au lieu de le laisser visible là où il est.
### Règle de code ETM
`EnergyArbitrator::update()` appelle `verifyOverloadProtection()` en **position 3**,

View File

@ -551,35 +551,99 @@ uint EnergyArbitrator::evTargetPhases(EvCharger *ev, double referenceA)
return qMax(1u, qBound(pi.minPossiblePhaseCount, phases, pi.maxPossiblePhaseCount));
}
QList<DrawCap> EnergyArbitrator::drawCaps() const
{
QList<DrawCap> plafonds;
// 0 = non réglé : la protection de surcharge est désactivée (contrat amont de
// SetPhasePowerLimit). Rien à écrêter, et surtout rien à annoncer — un plafond publié
// ici serait une limite inventée.
const uint limiteA = phasePowerLimit();
if (limiteA > 0) {
DrawCap disjoncteur;
disjoncteur.source = DrawCapSource::Breaker;
disjoncteur.perPhaseW = limiteA * 230.0;
plafonds.append(disjoncteur);
}
// GridOperator et EcoSelfImposed : aucun émetteur à ce stade. Leur absence de la liste
// n'est pas un oubli — c'est l'état du système, et la liste dit lequel des plafonds
// existe RÉELLEMENT. Un plafond qu'on n'a pas ne doit pas figurer à zéro (LM-1013).
return plafonds;
}
QList<double> EnergyArbitrator::phaseDrawW(Electricity::Phases masque, uint limiteA) const
{
QList<double> tirages;
RootMeter *meter = internalRootMeter();
if (!meter || limiteA == 0)
return tirages;
// Masque vide == PhaseNone : le proxy interroge alors LES TROIS phases. Reproduire la
// convention plutôt que de renvoyer une liste vide, qui ferait passer « toutes les
// phases » pour « aucune contrainte ».
const QList<Electricity::Phase> toutes =
{ Electricity::PhaseA, Electricity::PhaseB, Electricity::PhaseC };
for (Electricity::Phase p : toutes) {
if (masque != Electricity::PhaseNone && !masque.testFlag(p))
continue;
// Tirage de la phase, en watts, obtenu en INVERSANT calculateAllowanceAmpere plutôt
// qu'en relisant les états du compteur. Relire serait dupliquer des lectures qui
// diffèrent d'une phase à l'autre côté amont (rootmeter.cpp : la phase A se lit en
// ampères, B et C en watts/230), et laisser les deux copies dériver. L'inversion rend
// l'égalité au watt VRAIE PAR CONSTRUCTION, quelles que soient ces lectures.
const double margeA = meter->calculateAllowanceAmpere(p, limiteA);
tirages.append((limiteA - margeA) * 230.0);
}
return tirages;
}
DrawMargin EnergyArbitrator::evDrawMargin(EvCharger *ev, uint phaseCount) const
{
DrawMargin sansObjet;
if (!ev || phaseCount == 0 || !internalRootMeter())
return sansObjet;
const QList<DrawCap> plafonds = drawCaps();
if (plafonds.isEmpty())
return sansObjet;
// MÊME masque que le proxy — la marge d'une borne se juge sur les phases qu'elle occupe,
// pas sur celles de la maison. Une monophasée sur A n'a pas à répondre d'une surcharge
// sur C, qu'elle ne peut ni causer ni soulager.
const Electricity::Phases masque =
const_cast<EnergyArbitrator *>(this)->phasesForCount(phaseCount);
return resolveDrawMargin(plafonds,
phaseDrawW(masque, phasePowerLimit()),
internalRootMeter()->currentPower());
}
double EnergyArbitrator::evPhaseAllowanceW(EvCharger *ev, uint phaseCount) const
{
if (!ev || phaseCount == 0)
return -1;
// 0 = non réglé : la protection de surcharge est désactivée (contrat amont de
// SetPhasePowerLimit). Rien à écrêter, et surtout rien à annoncer — un plafond publié
// ici serait une limite inventée.
const uint limite = phasePowerLimit();
if (limite == 0)
const DrawMargin marge = evDrawMargin(ev, phaseCount);
if (!marge.borne)
return -1;
RootMeter *meter = internalRootMeter();
if (!meter)
return -1;
// Place LIBRE par phase, telle que la calcule le proxy — même fonction, même masque de
// phases, et surtout SANS rendre à la borne ce qu'elle tire déjà.
// Place LIBRE offerte à la borne, et surtout SANS lui rendre ce qu'elle tire déjà.
//
// Ce dernier point est délibéré et il est de sécurité. Rendre sa propre consommation à une
// borne serait plus juste en régime établi, mais ferait disparaître la réponse à la
// surcharge : sur une phase en dépassement, la place libre est NÉGATIVE, et c'est ce signe
// surcharge : sur une phase en dépassement, la marge est NÉGATIVE, et c'est ce signe
// qui ramène la borne à son minimum. Avec le retour, elle se verrait autoriser ce qu'elle
// consomme déjà — c'est-à-dire précisément la puissance qui cause le dépassement.
// (`testOverloadProtectionEcoMode` : 16 A ramenés à 6 A ; avec le retour, 9 A maintenus.)
const double allowanceA = meter->calculateAllowanceAmpere(
const_cast<EnergyArbitrator *>(this)->phasesForCount(phaseCount), limite);
return qMax(0.0, allowanceA) * 230.0 * phaseCount;
//
// L'écrêtage à zéro est fait ICI, chez le consommateur, et non dans la résolution : la
// marge PORTE le dépassement (elle vaut -1200 quand on dépasse de 1200 W) parce que
// l'étape 4 en a besoin pour dire de combien délester. Ce qu'une borne peut prendre
// n'est jamais négatif ; ce qu'il faut retirer à la maison, si.
return qMax(0.0, marge.watts) * phaseCount;
}
bool EnergyArbitrator::evCommandedByWaterfall(EvCharger *evCharger) const

View File

@ -9,6 +9,7 @@
// Inclus (et non déclaré en avant) : m_builtFrom stocke des LoadConfig PAR VALEUR — c'est ce
// qui permet au rebuild incrémental (ECS-412) de comparer l'ancienne et la nouvelle config.
#include "types/loadconfig.h"
#include "types/drawcap.h"
#include <QDateTime>
#include <QVariantMap>
@ -312,6 +313,50 @@ public:
*/
double evFloorW(EvCharger *ev, uint phaseCount) const;
/*!
* \brief Plafonds de tirage en vigueur à cet instant.
*
* \return La liste des \c DrawCap réellement actifs — vide si personne ne borne.
*
* \par Un seul mécanisme, plusieurs sources
* Le disjoncteur, une limite de gestionnaire de réseau et un plancher éco auto-imposé
* bornent tous le tirage ; ils ne diffèrent que par ce qu'on a le droit d'en faire.
* Les faire cohabiter dans UNE liste évite trois écrêtages concurrents dont le dernier
* gagnerait sans qu'on sache lequel a parlé.
*
* \note Un plafond absent n'est pas un plafond à zéro. La liste ne contient que ce qui
* existe, pour qu'\c resolveDrawMargin puisse répondre « sans objet » plutôt que
* « 0 W autorisé » — deux choses que rien ne distinguerait ensuite.
*/
QList<DrawCap> drawCaps() const;
/*!
* \brief Tirage par phase, en watts, tel que le compteur amont le lit.
*
* \param masque Phases à relever ; \c PhaseNone == les trois (convention amont).
* \param limiteA Limite par phase en ampères, nécessaire à l'inversion (voir ci-dessous).
* \return Un watt par phase retenue, dans l'ordre A, B, C. Liste vide sans compteur.
*
* \note La valeur est obtenue en **inversant** \c RootMeter::calculateAllowanceAmpere()
* phase par phase, et non en relisant les états. Les lectures amont ne sont pas
* symétriques entre phases ; les recopier ici en ferait deux versions vouées à diverger.
* L'inversion rend l'égalité au watt vraie par construction.
*/
QList<double> phaseDrawW(Electricity::Phases masque, uint limiteA) const;
/*!
* \brief Marge de tirage vue par une borne, avec la source qui la borne.
*
* \param ev Borne. \param phaseCount Nombre de phases VISÉ.
* \return La marge retenue — \c borne à faux si aucun plafond n'est en vigueur.
*
* \note La marge est jugée sur les phases que la borne OCCUPE : une monophasée sur A n'a
* pas à répondre d'une surcharge sur C, qu'elle ne peut ni causer ni soulager.
* \note La marge peut être **négative** — elle porte alors l'ampleur du dépassement.
* L'écrêtage à zéro appartient au consommateur, pas à la résolution.
*/
DrawMargin evDrawMargin(EvCharger *ev, uint phaseCount) const;
/*!
* \brief [3g-2] Puissance maximale (W) que la protection de surcharge laisse à cette borne.
*
@ -327,9 +372,12 @@ public:
* waterfall pourrait allouer un surplus que le branchement ne supporte pas, et la seule
* chose qui rattraperait serait \c verifyOverloadProtection() **après coup**.
*
* \note Le plafond est un **plafond de charge**, pas une marge : l'allowance du compteur
* est la place LIBRE, à laquelle on rend ce que la borne tire déjà — sans quoi une borne
* en charge se verrait interdire ce qu'elle consomme déjà.
* \note Le plafond est la place LIBRE, et on n'y rend **pas** ce que la borne tire déjà.
* C'est délibéré et c'est de sécurité : sur une phase en dépassement la marge est
* négative, et c'est ce signe qui ramène la borne à son minimum. Rendre sa propre
* consommation lui rendrait exactement la puissance qui cause le dépassement
* (`testOverloadProtectionEcoMode`). La formulation inverse figurait ici et était
* fausse : le code ne l'a jamais fait.
* \note Valeur de CYCLE : elle dépend de ce que le reste de la maison consomme à cet
* instant. Elle est donc relue à chaque \c toLoadContext(), jamais mémorisée.
*/