diff --git a/debian-qt5/changelog b/debian-qt5/changelog index 4b7ece4..a9fd48b 100644 --- a/debian-qt5/changelog +++ b/debian-qt5/changelog @@ -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 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 diff --git a/docs/SAFETY.md b/docs/SAFETY.md index e118eb4..06a0ee6 100644 --- a/docs/SAFETY.md +++ b/docs/SAFETY.md @@ -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 diff --git a/energyplugin/rootmeter.cpp b/energyplugin/rootmeter.cpp index 54fc386..9176aac 100644 --- a/energyplugin/rootmeter.cpp +++ b/energyplugin/rootmeter.cpp @@ -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(); diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index af1e67c..6b7ca9f 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -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 diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index e2a28eb..fa35a06 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -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