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);