From b29489bdb5b2499e4a8a00bc9d97608487040024 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 30 Aug 2026 11:58:23 +0200 Subject: [PATCH] =?UTF-8?q?feat(d=C3=A9lestage=20=C2=A72):=20le=20disjonct?= =?UTF-8?q?eur=20passe=20par=20la=20r=C3=A9solution,=20au=20watt=20pr?= =?UTF-8?q?=C3=A8s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p --- docs/DESIGN_DELESTAGE.md | 86 ++++++++++++++++++------ docs/SAFETY.md | 31 +++++++++ energyplugin/etm/energyarbitrator.cpp | 96 ++++++++++++++++++++++----- energyplugin/etm/energyarbitrator.h | 54 ++++++++++++++- 4 files changed, 226 insertions(+), 41 deletions(-) diff --git a/docs/DESIGN_DELESTAGE.md b/docs/DESIGN_DELESTAGE.md index 7c620e2..fd5d02c 100644 --- a/docs/DESIGN_DELESTAGE.md +++ b/docs/DESIGN_DELESTAGE.md @@ -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). --- diff --git a/docs/SAFETY.md b/docs/SAFETY.md index d6c7587..e118eb4 100644 --- a/docs/SAFETY.md +++ b/docs/SAFETY.md @@ -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**, diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 44da51d..59b5eeb 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -551,35 +551,99 @@ uint EnergyArbitrator::evTargetPhases(EvCharger *ev, double referenceA) return qMax(1u, qBound(pi.minPossiblePhaseCount, phases, pi.maxPossiblePhaseCount)); } +QList EnergyArbitrator::drawCaps() const +{ + QList 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 EnergyArbitrator::phaseDrawW(Electricity::Phases masque, uint limiteA) const +{ + QList 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 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 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(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(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 diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index c39c92c..3fbcd2f 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -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 #include @@ -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 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 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. */