[upstream-fix] rootmeter: lire l'état qu'on vient de tester (phases B et C)

Les branches des phases B et C testaient `currentPhaseB`/`currentPhaseC`
— des ampères — puis lisaient `currentPowerPhaseB`/`currentPowerPhaseC`
divisés par 230 — des watts. La phase A, elle, lit bien l'état qu'elle
teste. L'asymétrie n'était pas cosmétique : un compteur publiant les
courants par phase SANS les puissances par phase entrait dans la première
branche, y lisait 0, et se voyait accorder une place libre égale au plafond
entier. La protection de surcharge était AVEUGLE sur B et C, sans rien
signaler.

Le correctif change le comportement, et dans le bon sens : des bornes
seront écrêtées qui ne l'étaient pas, parce que la protection redevient
effective. Sur une triphasée déséquilibrée, le disjoncteur était jusqu'ici
le seul rempart — L0 employé comme régulateur au lieu de dernier recours.

Aucun test de ce dépôt ne pouvait le trouver : le mock pose currentPhaseB =
currentPowerPhaseB / 230, donc les deux états y sont cohérents par
construction et lire l'un ou l'autre y donne le même nombre. Le test
désaccorde les états à la main, ce qu'aucun chemin normal du mock ne
permet ; sans le correctif il annonce 25 A de place libre sur une phase qui
en tire 20.

L'angle mort est nommé comme une CLASSE dans SAFETY.md, et non comme un
incident : l'installation de référence n'exerce pas le cas, une suite verte
ne dit alors rien sur lui, et `stateValue()` d'un état absent rend 0 —
valeur qui, dans un calcul de marge, se confond avec « rien ne consomme ».

Un défaut voisin de la même fonction est consigné sans être corrigé :
`voltage` est déclarée hors des trois blocs et mutée par celui de la phase
A. Il aura son propre correctif isolé.

Fait avant l'étape 3 délibérément : c'est la couche de sécurité, c'est le
chemin qu'on étend, et brancher la marge dessus l'aurait rendu plus
difficile à isoler.

Suite complète : simulation 51/51, charging 48/48 identique à la référence
ligne à ligne, loadmodel 21/21, spotmarket 32/32, doxygen 0 avertissement.

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 12:11:43 +02:00
parent ab5b9757f4
commit 2d714146fe
5 changed files with 142 additions and 26 deletions

View File

@ -1,3 +1,32 @@
powersync-energy-plugin-nymea (1.15.2+etm34) trixie; urgency=medium
* [upstream-fix] rootmeter — `calculateAllowanceAmpere()` lisait un AUTRE état que celui
qu'elle testait. Les branches des phases B et C testaient `currentPhaseB`/`currentPhaseC`
(ampères) puis lisaient `currentPowerPhaseB`/`currentPowerPhaseC` divisés par 230 (watts),
là où la phase A lit bien l'état qu'elle teste.
* CE CORRECTIF CHANGE LE COMPORTEMENT, ET DANS LE BON SENS. Des bornes seront écrêtées qui ne
l'étaient pas : ce n'est pas une régression, c'est la protection de surcharge qui REDEVIENT
EFFECTIVE sur B et C. Un compteur publiant les courants par phase sans les puissances par
phase entrait dans la première branche, y lisait 0, et se voyait accorder une place libre
égale au plafond ENTIER — la protection n'écrêtait rien et ne signalait rien. Sur une
triphasée déséquilibrée, le disjoncteur restait le seul rempart, c'est-à-dire L0 employé
comme régulateur au lieu de dernier recours. Qui lira cette ligne comme une régression lira
le symptôme, pas la cause.
* AUCUN TEST DE CE DÉPÔT NE POUVAIT LE TROUVER. Le mock pose `currentPhaseB` =
`currentPowerPhaseB / 230` : les deux états y sont cohérents par construction, donc lire
l'un ou l'autre y donne le même nombre. Il a fallu désaccorder les états à la main, ce
qu'aucun chemin normal du mock ne permet (`testPhaseAllowanceReadsTheStateItTests`, qui
échoue sans le correctif en annonçant 25 A de place libre sur une phase qui en tire 20).
L'angle mort est nommé comme une CLASSE dans SAFETY.md : l'installation de référence
n'exerce pas le cas, et une suite verte ne dit alors rien sur lui.
* Un défaut voisin de la même fonction est consigné SANS être corrigé : `voltage` est déclarée
hors des trois blocs et mutée par celui de la phase A, donc la tension de A peut servir à
calculer B. Plus contraint, aucun relevé ne le montre, et il aura son propre correctif isolé.
* Suite complète : simulation 51/51, charging 48/48 (identique à la référence, ligne à ligne),
loadmodel 21/21, spotmarket 32/32, doxygen 0 avertissement.
-- Patrick Schurig <etm.schurig@gmail.com> Sun, 30 Aug 2026 19:40:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm33) trixie; urgency=medium
* R7 — `decision.level` : le motif hérité dit de quel NIVEAU il parle. `decision` reste au

View File

@ -172,36 +172,55 @@ 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
### Défaut amont corrigé — `calculateAllowanceAmpere()` lisait un autre état que celui qu'elle testait
**Relevé le 2026-08-30, non corrigé, sciemment.** `rootmeter.cpp` calcule la place libre
différemment selon la phase :
**Relevé et corrigé le 2026-08-30** (`[upstream-fix]`, commit isolé). `rootmeter.cpp` calculait
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 |
| Phase | Condition testée | Valeur lue AVANT | Valeur lue APRÈS |
|---|---|---|---|
| A | `hasState("currentPhaseA")` | `currentPhaseA` — ampères | inchangé |
| B | `hasState("currentPhaseB")` | `currentPowerPhaseB / 230` — **watts** | `currentPhaseB` |
| C | `hasState("currentPhaseC")` | `currentPowerPhaseC / 230` — **watts** | `currentPhaseC` |
Deux conséquences, d'inégale gravité :
**La conséquence sérieuse.** Un compteur publiant `currentPhaseB` **sans** `currentPowerPhaseB`
entrait dans la première branche, y lisait `0`, et se voyait accorder une place libre égale au
plafond entier. **La protection de surcharge était aveugle sur B et C** — elle n'écrêtait rien,
et ne signalait rien. Une conséquence bénigne s'y ajoutait : sur B et C la tension réelle était
ignorée au profit de 230 V, alors que la branche suivante, elle, la lit.
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.
**Ce que le correctif change.** Des bornes seront écrêtées qui ne l'étaient pas. Ce n'est pas
une régression : c'est la protection qui **redevient effective** sur B et C. Une installation
triphasée déséquilibrée dont la phase B était chargée voyait jusqu'ici le HEMS lui accorder du
courant qu'elle n'avait pas — le disjoncteur restait le seul rempart, c'est-à-dire L0 employé
comme régulateur au lieu de dernier recours.
**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.
### L'angle mort qui l'a rendu invisible
**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.
Le mock pose `currentPhaseB = currentPowerPhaseB / 230`. Les deux états y sont **cohérents par
construction**, donc lire l'un ou l'autre y donne exactement le même nombre : **aucun test de ce
dépôt ne pouvait trouver le défaut**, quelle que soit sa qualité. Il a fallu, pour l'épingler,
désaccorder les états à la main — ce qu'aucun chemin normal du mock ne permet
(`testPhaseAllowanceReadsTheStateItTests`).
C'est le même angle mort que ceux relevés cette semaine, et il mérite d'être nommé comme une
classe et non comme un incident : **l'installation de référence n'exerce pas le cas.** Le banc
et les mocks reproduisent *une* installation — celle qu'on a. Un défaut qui ne se manifeste que
sur une installation qu'on n'a pas est invisible aux deux à la fois, et la suite verte ne dit
alors rien sur lui.
La parade n'est pas d'élargir le mock indéfiniment, mais de se poser la question à l'endroit où
elle se pose : **quand un code lit un état d'appareil, demander ce qu'il fait si cet état est
absent** — et se souvenir que `stateValue()` d'un état absent rend `0`, valeur qui, dans un
calcul de marge, se confond avec « rien ne consomme ».
### Défaut amont voisin, NON corrigé — la tension fuit d'une phase à l'autre
Dans la même fonction, `voltage` est déclarée une fois **hors** des trois blocs et mutée par
celui de la phase A. Un compteur ayant `voltagePhaseA` mais pas `voltagePhaseB` verrait donc la
phase B calculée avec la tension de A. Laissé en l'état, sciemment : le cas est plus contraint
que le précédent, aucun relevé ne le montre, et il appartient à son propre `[upstream-fix]`
avec son propre test.
### Règle de code ETM

View File

@ -96,7 +96,12 @@ double RootMeter::calculateAllowanceAmpere(Electricity::Phases phases, uint phas
}
if (phases.testFlag(Electricity::PhaseB) || phases == Electricity::PhaseNone) {
if (m_thing->hasState("currentPhaseB")) {
allowance = qMin(allowance, 1.0 * phasePowerLimit - m_thing->stateValue("currentPowerPhaseB").toDouble() / 230);
// [upstream-fix] Lire l'état qu'on vient de tester. Cette branche testait
// currentPhaseB (ampères) et lisait currentPowerPhaseB/230 (watts) — asymétrique
// avec la phase A, et surtout AVEUGLE sur un compteur qui publie les courants
// sans les puissances par phase : la lecture y rendait 0, donc la place libre
// entière, donc aucun écrêtage.
allowance = qMin(allowance, 1.0 * phasePowerLimit - m_thing->stateValue("currentPhaseB").toDouble());
} else if (m_thing->hasState("currentPowerPhaseB")) {
if (m_thing->hasState("voltagePhaseB"))
voltage = m_thing->stateValue("voltagePhaseB").toDouble();
@ -109,7 +114,12 @@ double RootMeter::calculateAllowanceAmpere(Electricity::Phases phases, uint phas
if (phases.testFlag(Electricity::PhaseC) || phases == Electricity::PhaseNone) {
if (m_thing->hasState("currentPhaseC")) {
allowance = qMin(allowance, 1.0 * phasePowerLimit - m_thing->stateValue("currentPowerPhaseC").toDouble() / 230);
// [upstream-fix] Lire l'état qu'on vient de tester. Cette branche testait
// currentPhaseC (ampères) et lisait currentPowerPhaseC/230 (watts) — asymétrique
// avec la phase A, et surtout AVEUGLE sur un compteur qui publie les courants
// sans les puissances par phase : la lecture y rendait 0, donc la place libre
// entière, donc aucun écrêtage.
allowance = qMin(allowance, 1.0 * phasePowerLimit - m_thing->stateValue("currentPhaseC").toDouble());
} else if (m_thing->hasState("currentPowerPhaseC")) {
if (m_thing->hasState("voltagePhaseC"))
voltage = m_thing->stateValue("voltagePhaseC").toDouble();

View File

@ -42,6 +42,7 @@ using namespace nymeaserver;
#include "../../../energyplugin/etm/adapters/relayrouter.h"
#include "../../../energyplugin/etm/adapters/evadapter.h"
#include "../../../energyplugin/evcharger.h"
#include "../../../energyplugin/rootmeter.h"
#include "../../../energyplugin/etm/types/loadconfig.h"
#include "../../../energyplugin/etm/config/loadconfigstore.h"
#include "../../../energyplugin/etm/ratios/energyratioscalculator.h"
@ -6132,6 +6133,62 @@ void Simulation::testVanishedRelayFreezesTheLoad()
#endif
}
/*!
* \brief [upstream-fix] La place libre par phase se calcule sur l'état QUI A ÉTÉ TESTÉ.
*
* \par Le défaut
* \c RootMeter::calculateAllowanceAmpere() testait \c currentPhaseB / \c currentPhaseC
* (ampères) puis lisait \c currentPowerPhaseB / \c currentPowerPhaseC divisés par 230
* (watts) — asymétrique avec la phase A, qui lit bien l'état qu'elle teste. Sur un compteur
* publiant les courants par phase SANS les puissances par phase, la lecture rendait 0 : la
* place libre valait le plafond entier et la protection de surcharge n'écrêtait **rien** sur
* B et C, sans rien signaler.
*
* \par Pourquoi aucun test existant ne pouvait le trouver
* Le mock pose \c currentPhaseB = \c currentPowerPhaseB / 230 : les deux états y sont
* **cohérents par construction**, donc lire l'un ou l'autre y donne le même nombre. C'est
* l'angle mort — l'installation de référence n'exerce pas le cas. Ce test le fabrique
* explicitement, en désaccordant les deux états, ce qu'aucun chemin normal du mock ne permet.
*
* \par Ce qui est épinglé
* Une phase B tirant 20 A avec une puissance par phase à zéro doit consommer de la marge.
* Avant le correctif : 25 A de place libre annoncés sur une phase qui en tire 20.
*/
void Simulation::testPhaseAllowanceReadsTheStateItTests()
{
ThingManager *thingManager = NymeaCore::instance()->thingManager();
QUuid meterThingId = addMeter();
QVERIFY(!meterThingId.isNull());
Thing *meterThing = thingManager->findConfiguredThing(meterThingId);
QVERIFY(meterThing);
meterThing->setStateValue("connected", true);
RootMeter *meter = new RootMeter(meterThing);
// Compteur qui publie les COURANTS par phase, mais pas les puissances par phase. On le
// fabrique en désaccordant les états à la main : le mock, lui, les tient toujours
// cohérents, et c'est précisément ce qui rendait le défaut invisible.
meterThing->setStateValue("currentPhaseA", 0);
meterThing->setStateValue("currentPhaseB", 20);
meterThing->setStateValue("currentPhaseC", 0);
meterThing->setStateValue("currentPowerPhaseA", 0);
meterThing->setStateValue("currentPowerPhaseB", 0);
meterThing->setStateValue("currentPowerPhaseC", 0);
const uint plafondA = 25;
// La phase B tire 20 A sous un plafond de 25 : il reste 5 A, pas 25.
QCOMPARE(meter->calculateAllowanceAmpere(Electricity::PhaseB, plafondA), 5.0);
// La phase A, elle, était déjà juste. On le vérifie pour que le test dise que les trois
// phases se comportent DÉSORMAIS pareil, et pas seulement que B a changé.
QCOMPARE(meter->calculateAllowanceAmpere(Electricity::PhaseA, plafondA), 25.0);
// Et la phase la plus chargée l'emporte quand on interroge les trois ensemble.
QCOMPARE(meter->calculateAllowanceAmpere(
Electricity::PhaseA | Electricity::PhaseB | Electricity::PhaseC, plafondA), 5.0);
}
void Simulation::testCountsSumToTarget()
{
#ifndef ETM_ARBITRATOR

View File

@ -191,6 +191,7 @@ private slots:
// parcourue ». Et decision.level accompagne levels[] : les deux présents, ou les deux
// absents — en mode dégradé il n'y a pas de passe, donc ni l'un ni l'autre.
void testLevelAbsenceAndZeroDifferInMeaning();
void testPhaseAllowanceReadsTheStateItTests();
// ── §12 / LM-1209 ──────────────────────────────────────────────────────
// Une borne détectée après la mise en service entre EN QUEUE : elle ne passe devant