[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
|
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
|
* 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
|
puis `verifyOverloadProtection()` une seconde fois — double appel intentionnel dans
|
||||||
les tests, pas dans la production.
|
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
|
**Relevé et corrigé le 2026-08-30** (`[upstream-fix]`, commit isolé). `rootmeter.cpp` calculait
|
||||||
différemment selon la phase :
|
la place libre différemment selon la phase :
|
||||||
|
|
||||||
| Phase | Condition testée | Valeur réellement lue |
|
| Phase | Condition testée | Valeur lue AVANT | Valeur lue APRÈS |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
| A | `hasState("currentPhaseA")` | `currentPhaseA` — **ampères**, correct |
|
| A | `hasState("currentPhaseA")` | `currentPhaseA` — ampères | inchangé |
|
||||||
| B | `hasState("currentPhaseB")` | `currentPowerPhaseB / 230` — **watts**, et `voltagePhaseB` ignoré |
|
| B | `hasState("currentPhaseB")` | `currentPowerPhaseB / 230` — **watts** | `currentPhaseB` |
|
||||||
| C | `hasState("currentPhaseC")` | `currentPowerPhaseC / 230` — idem |
|
| 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
|
**Ce que le correctif change.** Des bornes seront écrêtées qui ne l'étaient pas. Ce n'est pas
|
||||||
branche suivante, elle, la lit. Erreur de quelques pour-cent.
|
une régression : c'est la protection qui **redevient effective** sur B et C. Une installation
|
||||||
2. **Sérieuse** : un compteur qui publierait `currentPhaseB` **sans** `currentPowerPhaseB`
|
triphasée déséquilibrée dont la phase B était chargée voyait jusqu'ici le HEMS lui accorder du
|
||||||
entre dans la première branche, y lit `0`, et rend une place libre égale au plafond entier.
|
courant qu'elle n'avait pas — le disjoncteur restait le seul rempart, c'est-à-dire L0 employé
|
||||||
**La protection de surcharge serait alors aveugle sur B et C** — elle n'écrêterait rien,
|
comme régulateur au lieu de dernier recours.
|
||||||
sans rien signaler.
|
|
||||||
|
|
||||||
**Pourquoi ce n'est pas corrigé dans ce lot.** Le mock du banc publie les six états, donc le
|
### L'angle mort qui l'a rendu invisible
|
||||||
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
|
Le mock pose `currentPhaseB = currentPowerPhaseB / 230`. Les deux états y sont **cohérents par
|
||||||
par phase en **inversant** cette fonction plutôt qu'en relisant les états. C'est délibéré :
|
construction**, donc lire l'un ou l'autre y donne exactement le même nombre : **aucun test de ce
|
||||||
recopier les lectures amont ici en ferait deux versions vouées à diverger, et la version ETM
|
dépôt ne pouvait trouver le défaut**, quelle que soit sa qualité. Il a fallu, pour l'épingler,
|
||||||
« corrigée » masquerait le défaut au lieu de le laisser visible là où il est.
|
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
|
### 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 (phases.testFlag(Electricity::PhaseB) || phases == Electricity::PhaseNone) {
|
||||||
if (m_thing->hasState("currentPhaseB")) {
|
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")) {
|
} else if (m_thing->hasState("currentPowerPhaseB")) {
|
||||||
if (m_thing->hasState("voltagePhaseB"))
|
if (m_thing->hasState("voltagePhaseB"))
|
||||||
voltage = m_thing->stateValue("voltagePhaseB").toDouble();
|
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 (phases.testFlag(Electricity::PhaseC) || phases == Electricity::PhaseNone) {
|
||||||
if (m_thing->hasState("currentPhaseC")) {
|
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")) {
|
} else if (m_thing->hasState("currentPowerPhaseC")) {
|
||||||
if (m_thing->hasState("voltagePhaseC"))
|
if (m_thing->hasState("voltagePhaseC"))
|
||||||
voltage = m_thing->stateValue("voltagePhaseC").toDouble();
|
voltage = m_thing->stateValue("voltagePhaseC").toDouble();
|
||||||
|
|||||||
@ -42,6 +42,7 @@ using namespace nymeaserver;
|
|||||||
#include "../../../energyplugin/etm/adapters/relayrouter.h"
|
#include "../../../energyplugin/etm/adapters/relayrouter.h"
|
||||||
#include "../../../energyplugin/etm/adapters/evadapter.h"
|
#include "../../../energyplugin/etm/adapters/evadapter.h"
|
||||||
#include "../../../energyplugin/evcharger.h"
|
#include "../../../energyplugin/evcharger.h"
|
||||||
|
#include "../../../energyplugin/rootmeter.h"
|
||||||
#include "../../../energyplugin/etm/types/loadconfig.h"
|
#include "../../../energyplugin/etm/types/loadconfig.h"
|
||||||
#include "../../../energyplugin/etm/config/loadconfigstore.h"
|
#include "../../../energyplugin/etm/config/loadconfigstore.h"
|
||||||
#include "../../../energyplugin/etm/ratios/energyratioscalculator.h"
|
#include "../../../energyplugin/etm/ratios/energyratioscalculator.h"
|
||||||
@ -6132,6 +6133,62 @@ void Simulation::testVanishedRelayFreezesTheLoad()
|
|||||||
#endif
|
#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()
|
void Simulation::testCountsSumToTarget()
|
||||||
{
|
{
|
||||||
#ifndef ETM_ARBITRATOR
|
#ifndef ETM_ARBITRATOR
|
||||||
|
|||||||
@ -191,6 +191,7 @@ private slots:
|
|||||||
// parcourue ». Et decision.level accompagne levels[] : les deux présents, ou les deux
|
// 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.
|
// absents — en mode dégradé il n'y a pas de passe, donc ni l'un ni l'autre.
|
||||||
void testLevelAbsenceAndZeroDifferInMeaning();
|
void testLevelAbsenceAndZeroDifferInMeaning();
|
||||||
|
void testPhaseAllowanceReadsTheStateItTests();
|
||||||
|
|
||||||
// ── §12 / LM-1209 ──────────────────────────────────────────────────────
|
// ── §12 / LM-1209 ──────────────────────────────────────────────────────
|
||||||
// Une borne détectée après la mise en service entre EN QUEUE : elle ne passe devant
|
// 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