[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:
parent
ab5b9757f4
commit
2d714146fe
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user