From f3cddedee02b9b059d22d79f03f517d08bfe2580 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Thu, 3 Sep 2026 07:50:56 +0200 Subject: [PATCH] =?UTF-8?q?test(mock):=20la=20tension=20par=20phase,=20et?= =?UTF-8?q?=20LM-1210-a=20enfin=20exerc=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le mock posait currentPhaseX = currentPowerPhaseX / 230 : les deux familles d'états étaient cohérentes par construction, donc ni triphasée déséquilibrée ni tension ≠ 230 V n'étaient fabricables. Les paramètres sont optionnels — sans eux le comportement d'origine tient, et aucun scénario existant ne bouge. LM-1210-a est enfin exercée : le choix de la pire phase faute de masque était écrit, raisonné, et jamais vérifié. Phase B saturée à 30 A sous un plafond de 25 pendant que A et C exportent, et la maison exporte GLOBALEMENT — la marge retenue est celle de B, négative, et non celle du total qui rassurerait. Et l'ouverture a corrigé une ERREUR DE RAISONNEMENT, pas seulement comblé un trou. Mon premier test attendait qu'une phase en export donne une marge supérieure au plafond : c'est ce que le design affirmait. Il a échoué — calculateAllowanceAmpere() part du plafond et ne fait que des qMin, donc la marge y est écrêtée. Physiquement juste, mais la phrase était fausse. La conclusion tenait, la raison non, et rien ne pouvait le révéler tant que le déséquilibre était infabricable. Corrigée dans la spec et dans le code. C'est la deuxième fois qu'ajouter de la diversité au mock trouve un défaut plutôt que de seulement débloquer un test — après dryRelay, qui avait révélé le déréférencement du contrôleur. Et une note à côté de la garde du compteur : l'app dépend de `connected` pour REFUSER D'AFFICHER, jamais pour COMPTER, et elle a raison. Si connected ment, l'écran dit « hors ligne » à tort et rien n'est mal compté. Une comptabilité n'a pas droit à cette tolérance — même donnée non fiable, deux usages, deux verdicts. S'abstenir sur un doute est sûr, accumuler sur un doute ne l'est pas. Quelqu'un lirait l'asymétrie comme une incohérence sans cette ligne. Suite : simulation 64/64, loadmodel 22/22, charging 48/48 identique à la référence, spotmarket 32/32, doxygen 0 avertissement. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p --- debian-qt5/changelog | 30 +++++++ docs/INVENTAIRE_MOCK.md | 25 ++++-- energyplugin/etm/energyarbitrator.cpp | 3 +- energyplugin/etm/types/surpluscontext.h | 11 +++ specs/spec_loadmodel.md | 6 +- tests/auto/common/energytestbase.cpp | 9 +++ tests/auto/simulation/simulation.cpp | 80 +++++++++++++++++++ tests/auto/simulation/simulation.h | 1 + .../integrationpluginenergymocks.cpp | 32 +++++++- 9 files changed, 185 insertions(+), 12 deletions(-) diff --git a/debian-qt5/changelog b/debian-qt5/changelog index 5083573..171ca6c 100644 --- a/debian-qt5/changelog +++ b/debian-qt5/changelog @@ -1,3 +1,33 @@ +powersync-energy-plugin-nymea (1.15.2+etm52) trixie; urgency=medium + + * MOCK — tension et courant PAR PHASE réglables indépendamment des puissances. Le mock posait + `currentPhaseX = currentPowerPhaseX / 230` : les deux familles d'états étaient cohérentes PAR + CONSTRUCTION, si bien qu'aucun scénario ne pouvait produire une triphasée déséquilibrée ni + une tension ≠ 230 V. Paramètres OPTIONNELS : sans eux, le comportement d'origine est conservé + et aucun scénario existant ne bouge. + * LM-1210-a ENFIN EXERCÉE — le choix de la pire phase faute de masque était écrit, raisonné, et + jamais vérifié. Phase B saturée à 30 A sous un plafond de 25 pendant que A et C exportent, et + la maison exporte GLOBALEMENT : la marge retenue est celle de B, négative, et non celle du + total qui rassurerait à tort. Ce manque était nommé DEUX FOIS par deux chemins indépendants — + l'inventaire du mock et le balayage des contrôles — avant d'être comblé. + * ET L'OUVERTURE A CORRIGÉ UNE ERREUR DE RAISONNEMENT, pas seulement comblé un trou. Le premier + test attendait qu'une phase en export donne une marge SUPÉRIEURE au plafond — c'est ce que le + design affirmait. Il a échoué : `calculateAllowanceAmpere()` part du plafond et ne fait que + des `qMin`, donc la marge y est ÉCRÊTÉE. Physiquement juste, mais la phrase était fausse : la + conclusion tenait, la raison non. Corrigée dans la spec et dans le code. + * C'est la DEUXIÈME fois qu'ajouter de la diversité au mock trouve un défaut plutôt que de + seulement débloquer un test — après `dryRelay`, qui avait révélé le déréférencement du + contrôleur. La diversité du mock n'est pas un confort de test. + * Note écrite à côté de la garde du compteur : l'app dépend de `connected` pour REFUSER + D'AFFICHER, jamais pour COMPTER — et elle a raison. Si `connected` ment, l'écran dit « hors + ligne » à tort, rien n'est mal compté. Une comptabilité n'a pas droit à cette tolérance : + même donnée non fiable, deux usages, deux verdicts. S'abstenir sur un doute est sûr, + accumuler sur un doute ne l'est pas. + * Suite : simulation 64/64, loadmodel 22/22, charging 48/48 identique à la référence, + spotmarket 32/32, doxygen 0 avertissement. + + -- Patrick Schurig Thu, 03 Sep 2026 17:00:00 +0200 + powersync-energy-plugin-nymea (1.15.2+etm51) trixie; urgency=medium * URGENT — CONTRADICTION SUPPRIMÉE entre `progressMeasurable` et `regime`. Le premier dit ce diff --git a/docs/INVENTAIRE_MOCK.md b/docs/INVENTAIRE_MOCK.md index 047dc45..d6a1f7c 100644 --- a/docs/INVENTAIRE_MOCK.md +++ b/docs/INVENTAIRE_MOCK.md @@ -49,17 +49,30 @@ mélangeait. > était invisible tant qu'aucune classe ne la contredisait. **Le mock avait sa propre prémisse > fausse**, protégée par son homogénéité. -**2. Un compteur par phase incohérent.** `currentPhaseX` et `currentPowerPhaseX` sont posés -ensemble par le mock, dans un rapport fixe de 230. Aucun scénario ne peut produire une tension -réelle ≠ 230 V, ni un compteur qui ne publierait qu'une des deux familles. +**2. ~~Un compteur par phase incohérent.~~ — OUVERT le 2026-09-03.** `voltagePhaseX` et +`currentPhaseX` sont désormais réglables **indépendamment** des puissances, par des paramètres +OPTIONNELS — sans eux, le comportement dérivé d'origine est conservé et aucun scénario existant +ne bouge. + +> **Et l'ouverture a corrigé une erreur de RAISONNEMENT, pas seulement comblé un trou.** Le +> premier test de `LM-1210-a` attendait qu'une phase en export donne une marge *supérieure* au +> plafond — c'est ce que le design affirmait. Il a échoué : `calculateAllowanceAmpere()` part du +> plafond et ne fait que des `qMin`, donc la marge y est **écrêtée**. Physiquement juste — l'export +> ne permet pas de tirer plus que le disjoncteur — mais la phrase du design était fausse. **La +> conclusion tenait, la raison non**, et rien ne pouvait le révéler tant que le déséquilibre était +> infabricable. **3. `temperature`.** Le code teste `hasState("temperature")` et **aucune classe du mock ne le porte** : la branche « sonde présente » n'a jamais été exécutée. Elle est écrite, elle n'est pas testée. -**4. Une borne triphasée à phases déséquilibrées.** Les états existent, mais aucun helper de test -ne les pose indépendamment — d'où le pessimisme de `LM-1210-a` qui n'a jamais été exercé sur un -vrai déséquilibre. +**4. ~~Une triphasée déséquilibrée.~~ — OUVERT le 2026-09-03.** +`testWorstPhaseBindsWhenPhasesAreUnbalanced` exerce enfin `LM-1210-a` : phase B saturée à 30 A +sous un plafond de 25 pendant que A et C exportent, et la maison **exporte globalement**. La +marge retenue est celle de B — négative — et non celle du total, qui rassurerait à tort. + +Ce manque était nommé **deux fois par deux chemins indépendants** — cet inventaire et le balayage +des contrôles — avant d'être comblé. C'est ce qui lui a donné sa priorité. ## La règle qui en sort diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 63f5ddd..a670ac7 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -986,7 +986,8 @@ DrawMargin EnergyArbitrator::houseDrawMargin() const // monophasée de phase inconnue peut tomber sur n'importe laquelle, donc sur la pire. // Supposer mieux serait deviner, et deviner dans une couche de sécurité se paie au // disjoncteur. Le pessimisme ne mord d'ailleurs que là où il doit : une phase qui exporte - // a une marge PLUS GRANDE que le plafond, et ne borne personne. + // est AU MAXIMUM de la marge — celle-ci étant écrêtée au plafond, l'export ne permettant pas + // de tirer plus que le disjoncteur — et ne borne donc jamais. return resolveDrawMargin(plafonds, phaseDrawW(Electricity::PhaseNone, phasePowerLimit()), meter->currentPower()); diff --git a/energyplugin/etm/types/surpluscontext.h b/energyplugin/etm/types/surpluscontext.h index 1592dd4..bf7f4cc 100644 --- a/energyplugin/etm/types/surpluscontext.h +++ b/energyplugin/etm/types/surpluscontext.h @@ -131,6 +131,17 @@ struct LoadContextTelemetry { * ses booléens, mais a laissé \c sessionEnergy à sa dernière valeur. La politique dépend du * plugin, ce qui la rend inutilisable comme garde. * + * \par L'asymétrie n'est PAS une incohérence, et elle mérite d'être dite + * L'app **dépend** de `connected` dans son écran de borne, et elle a raison : elle s'en sert + * pour **refuser d'afficher**, jamais pour **compter**. Si `connected` ment, l'écran dit + * « hors ligne » à tort — rien n'est mal compté, aucune obligation ne se déclare tenue. + * + * **Une comptabilité n'a pas droit à cette tolérance.** Même donnée non fiable, deux usages, + * deux verdicts : s'abstenir sur un doute est sûr, accumuler sur un doute ne l'est pas. C'est + * pourquoi la garde ci-dessus ne s'appuie pas sur un état de plugin — non parce que l'app + * aurait tort de le faire, mais parce que le coût d'une erreur n'est pas le même des deux + * côtés. + * * \note Ce que ce champ évite, chiffré : base reprise à 0 pendant l'absence, retour du * compteur avec ses 10 kWh réels, et \c deliveredWh saute à 10 000 en un cycle — * l'obligation se déclare **tenue sans que rien n'ait été livré**. Et `R6` ne s'y oppose diff --git a/specs/spec_loadmodel.md b/specs/spec_loadmodel.md index c5c9301..c243a2a 100644 --- a/specs/spec_loadmodel.md +++ b/specs/spec_loadmodel.md @@ -875,8 +875,10 @@ charge est câblée. Une monophasée de phase inconnue peut tomber sur n'importe la pire. Supposer mieux serait deviner, et deviner dans une couche de sécurité se paie au disjoncteur. -Le pessimisme est **borné en pratique** : une phase qui exporte a une marge *plus grande* que le -plafond et ne borne personne. Il ne mord que sur une triphasée réellement déséquilibrée — et il +Le pessimisme est **borné en pratique** : une phase qui exporte est **au maximum de la marge** — +laquelle est écrêtée au plafond, l'export ne permettant pas de tirer plus que le disjoncteur — et +ne borne donc jamais. *(Formulation corrigée le 2026-09-03 : le texte disait « une marge plus +grande que le plafond », ce qui est faux. La conclusion tenait, la raison non.)* Il ne mord que sur une triphasée réellement déséquilibrée — et il coûte alors du **rendement, jamais de la sécurité**. Lever la limite demande un masque de phases déclaré, donc config, interface et migration : hors de ce lot. diff --git a/tests/auto/common/energytestbase.cpp b/tests/auto/common/energytestbase.cpp index a45e2e8..c0a25c9 100644 --- a/tests/auto/common/energytestbase.cpp +++ b/tests/auto/common/energytestbase.cpp @@ -140,6 +140,15 @@ QNetworkReply *EnergyTestBase::setMeterStates(const QVariantMap &phasesPower, bo query.addQueryItem("currentPowerPhaseA", QString::number(phasesPower.value("A").toInt())); query.addQueryItem("currentPowerPhaseB", QString::number(phasesPower.value("B").toInt())); query.addQueryItem("currentPowerPhaseC", QString::number(phasesPower.value("C").toInt())); + // Tension et courant par phase, OPTIONNELS : passés dans `phasesPower` sous les clés + // "uA"/"uB"/"uC" et "iA"/"iB"/"iC". Absents, le mock dérive comme avant — aucun scénario + // existant ne bouge. Présents, ils permettent enfin une triphasée DÉSÉQUILIBRÉE et une + // tension ≠ 230 V, que le banc ne savait pas produire. + for (const auto &p : {std::make_pair("uA", "voltagePhaseA"), std::make_pair("uB", "voltagePhaseB"), + std::make_pair("uC", "voltagePhaseC"), std::make_pair("iA", "currentPhaseA"), + std::make_pair("iB", "currentPhaseB"), std::make_pair("iC", "currentPhaseC")}) + if (phasesPower.contains(p.first)) + query.addQueryItem(p.second, QString::number(phasesPower.value(p.first).toDouble())); requestUrl.setQuery(query); QNetworkRequest request(requestUrl); diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 9d34c6d..8f9a385 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -7634,6 +7634,86 @@ void Simulation::testLifetimeCounterDropNeverRebasesTheProgress() #endif } +/*! + * \brief [\rule{LM-1210-a}] Sur une triphasée DÉSÉQUILIBRÉE, c'est la PIRE phase qui borne. + * + * \par Un choix écrit, raisonné, et jamais exercé — jusqu'ici + * `LoadDeclared::phases` est un NOMBRE, pas un masque : le moteur ignore sur quelle phase une + * charge est câblée. Il retient donc la pire, seul choix qui ne devine pas — deviner dans une + * couche de sécurité se paie au disjoncteur. + * + * Ce test n'existait pas parce que **le mock ne savait produire qu'une triphasée équilibrée** : + * il posait `currentPhaseX = currentPowerPhaseX / 230`, cohérent par construction. Le manque + * était nommé deux fois — par l'inventaire du mock et par le balayage des contrôles — avant + * d'être comblé. + * + * \par Ce qu'il épingle + * La maison EXPORTE globalement, et une seule phase sature. La marge doit être celle de cette + * phase-là, négative, et non celle du total qui paraîtrait confortable. + */ +void Simulation::testWorstPhaseBindsWhenPhasesAreUnbalanced() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + + EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); + QVERIFY(arb); + ThingManager *tm = NymeaCore::instance()->thingManager(); + + QUuid meterId = addMeter(); + m_experiencePlugin->energyManager()->setRootMeter(meterId); + Thing *meter = tm->findConfiguredThing(meterId); + QVERIFY(meter); + meter->setStateValue("connected", true); + + QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", + QVariantMap({{"phasePowerLimit", 25}})).toMap() + .value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + + // DÉSÉQUILIBRE : la phase B sature à 30 A sous un plafond de 25, pendant que A et C + // EXPORTENT. Impossible à écrire avant : le mock dérivait les courants des puissances. + meter->setStateValue("currentPhaseA", -10); // export + meter->setStateValue("currentPhaseB", 30); // SATURÉE + meter->setStateValue("currentPhaseC", -10); // export + meter->setStateValue("currentPower", -2300); // la maison exporte GLOBALEMENT + + const DrawMargin marge = arb->houseDrawMargin(); + QVERIFY2(marge.borne, "un plafond est réglé : la marge doit exister"); + + // 1. LA PIRE PHASE L'EMPORTE — et c'est bien B, pas le total. 25 A − 30 A = −5 A, soit + // −1150 W. Le total, lui, afficherait un confort trompeur : la maison exporte. + QCOMPARE(qRound(marge.watts), -1150); + QCOMPARE(marge.phase, 1); // phase B (0 = A, 1 = B, 2 = C) + + // 2. ET LA MARGE EST NÉGATIVE alors que le compteur global est en EXPORT. C'est tout l'objet + // du choix : sans la lecture par phase, l'arbitre croirait disposer de 2 300 W à dépenser + // pendant qu'une phase est déjà au-delà de son disjoncteur. + QVERIFY2(meter->stateValue("currentPower").toDouble() < 0, + "la maison exporte : c'est le cas qui rend le pessimisme non trivial"); + QVERIFY2(marge.watts < 0, + "la pire phase borne, même quand le total rassure"); + + // 3. Contre-jour : rééquilibrée, la même maison retrouve une marge confortable. Sans ce + // second point, le test ne distinguerait pas « la pire phase l'emporte » de « la marge + // est toujours négative ». + meter->setStateValue("currentPhaseB", -10); + const DrawMargin equilibree = arb->houseDrawMargin(); + // La marge est ÉCRÊTÉE AU PLAFOND, et non augmentée par l'export : `calculateAllowanceAmpere` + // part de `phasePowerLimit` et ne fait que des `qMin`. Physiquement juste — l'export ne + // permet pas de tirer plus que le disjoncteur — mais ce n'est PAS ce que le design disait + // (« une phase qui exporte a une marge plus grande que le plafond »), et la phrase a été + // corrigée. La conclusion, elle, tient : une phase qui exporte est au maximum de la marge, + // donc elle ne borne jamais. + QCOMPARE(qRound(equilibree.watts), qRound(25.0 * 230.0)); + QVERIFY2(equilibree.watts > 0, "rééquilibrée, la marge redevient positive"); +#endif +} + void Simulation::testCountsSumToTarget() { #ifndef ETM_ARBITRATOR diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index cc63b4e..44d59b6 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -201,6 +201,7 @@ private slots: void testProgressMeasurabilityIsDeclaredAtConfigTime(); void testHealthyButUnmeasuredDiffersFromFaulty(); void testLifetimeCounterDropNeverRebasesTheProgress(); + void testWorstPhaseBindsWhenPhasesAreUnbalanced(); void testRecreditIsALoanAgainstObedience(); void testARefusedStateIsDistinguishableFromAnApplied(); void testL4ShedsAPacAndSaysItForcedTheLock(); diff --git a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp index ed01837..c79f611 100644 --- a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp +++ b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp @@ -233,9 +233,35 @@ void IntegrationPluginEnergyMocks::setupThing(ThingSetupInfo *info) int currentPowerPhaseB = query.queryItemValue("currentPowerPhaseB").toInt(); int currentPowerPhaseC = query.queryItemValue("currentPowerPhaseC").toInt(); - thing->setStateValue("currentPhaseA", currentPowerPhaseA / 230.0); - thing->setStateValue("currentPhaseB", currentPowerPhaseB / 230.0); - thing->setStateValue("currentPhaseC", currentPowerPhaseC / 230.0); + // TENSION ET COURANT PAR PHASE, réglables INDÉPENDAMMENT des puissances. + // + // Sans cela, le mock posait `currentPhaseX = currentPowerPhaseX / 230` : les deux + // familles d'états étaient COHÉRENTES PAR CONSTRUCTION, et aucun scénario ne + // pouvait produire une tension réelle ≠ 230 V, un déséquilibre entre phases, ni + // un compteur qui ne publierait qu'une des deux familles. C'est ce qui a rendu le + // défaut d'asymétrie de `calculateAllowanceAmpere()` invisible à la suite + // pendant des mois (docs/INVENTAIRE_MOCK.md, manque n°2). + // + // Les paramètres sont OPTIONNELS : sans eux, le comportement dérivé d'origine est + // conservé, et aucun scénario existant ne bouge. + auto tension = [&](const char *nom) { + return query.hasQueryItem(nom) ? query.queryItemValue(nom).toDouble() : 230.0; + }; + const double uA = tension("voltagePhaseA"); + const double uB = tension("voltagePhaseB"); + const double uC = tension("voltagePhaseC"); + thing->setStateValue("voltagePhaseA", uA); + thing->setStateValue("voltagePhaseB", uB); + thing->setStateValue("voltagePhaseC", uC); + + // Le COURANT peut être imposé directement — un compteur réel le mesure, il ne le + // dérive pas de la puissance. + thing->setStateValue("currentPhaseA", query.hasQueryItem("currentPhaseA") + ? query.queryItemValue("currentPhaseA").toDouble() : currentPowerPhaseA / uA); + thing->setStateValue("currentPhaseB", query.hasQueryItem("currentPhaseB") + ? query.queryItemValue("currentPhaseB").toDouble() : currentPowerPhaseB / uB); + thing->setStateValue("currentPhaseC", query.hasQueryItem("currentPhaseC") + ? query.queryItemValue("currentPhaseC").toDouble() : currentPowerPhaseC / uC); thing->setStateValue("currentPowerPhaseA", currentPowerPhaseA); thing->setStateValue("currentPowerPhaseB", currentPowerPhaseB);