From 04852c518adaa57dd30e6a984f758dab4e3a1395 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Fri, 4 Sep 2026 06:19:08 +0200 Subject: [PATCH] =?UTF-8?q?test(mock):=20le=20compteur=20int=C3=A8gre=20en?= =?UTF-8?q?fin,=20et=20purchasedWh=20gagne=20un=20second=20usage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit totalEnergyConsumed n'était écrit par personne : il valait sa valeur par défaut, indéfiniment. Un compteur figé est un compteur qui n'existe pas pour tout ce qui l'intègre — deliveredWh restait à 0, remainingWh à 4 000, et l'étalement adaptatif n'était jamais exercé. Le comportement se confondait avec unmeasurable pour une raison entièrement différente et bénigne. Quatrième occurrence d'un mock plus homogène que la réalité, après les six états cohérents du rootmeter, les classes portant toutes une puissance, et la triphasée équilibrée. En export il ne recule pas — il ne bouge pas : un compteur de consommation est monotone, et le faire reculer produirait un cas que le terrain ne produit pas, celui-là même que la garde de sémantique traite comme une anomalie. Le mock fabriquerait le défaut que le moteur est censé détecter. LM-1014-b appliquée au mock, question posée et répondue : le cumul est déclaré `cached`, donc il continue au redémarrage. Repartir de zéro ferait reculer un cumul monotone. Et purchasedWh gagne un second usage, sous `measured` cette fois. Mesuré sur le banc : il accumule pendant que deliveredWh peut rester plat, et cette divergence est un contrôle que l'écran n'aurait pas su fabriquer — de l'acheté qui monte avec du livré plat signifie soit que la charge ne consomme pas ce qu'on paie, soit que le compteur ne compte pas. C'est écrit au contrat parce que le jour où les deux coïncideront, quelqu'un jugera le champ redondant : c'est précisément quand ils divergent que la paire devient informative, et un champ qui ne sert que dans l'anomalie n'a pas l'air utile le reste du temps. La borne basse du test de cumul est large, et délibérément : il ne contrôle ni la cadence du minuteur ni l'alignement du premier tic, qui n'intègre rien. Une borne serrée en ferait un test instable — qu'on relancerait jusqu'à ce qu'il passe. Suite : simulation 66/66, 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 --- INTERFACE.md | 15 +++++ debian-qt5/changelog | 31 ++++++++++ docs/INVENTAIRE_MOCK.md | 18 ++++++ tests/auto/simulation/simulation.cpp | 62 +++++++++++++++++++ tests/auto/simulation/simulation.h | 1 + .../integrationpluginenergymocks.cpp | 36 +++++++++++ .../integrationpluginenergymocks.h | 28 +++++++++ .../integrationpluginenergymocks.json | 18 ++++-- 8 files changed, 203 insertions(+), 6 deletions(-) diff --git a/INTERFACE.md b/INTERFACE.md index 1455097..68232a0 100644 --- a/INTERFACE.md +++ b/INTERFACE.md @@ -703,6 +703,21 @@ Sans lui, « ce que ça coûte par jour » redeviendrait une estimation fabriqu C'est un **cumul sur la fenêtre d'obligation** : remis à zéro à la bascule de période (`LM-1014-b`). +> ### Un SECOND usage, sous `measured` cette fois — et il n'est pas redondant +> +> Le champ a été demandé pour `unmeasurable`. Mesuré sur le banc, il gagne un usage que personne +> n'avait prévu : sous `measured`, `purchasedWh` **accumule** — 0 → 9,15 → 18,33 → 27,52 Wh — +> pendant que `deliveredWh` peut rester **plat**. +> +> **Cette divergence est un contrôle que l'écran n'aurait pas su fabriquer.** De l'acheté qui +> monte avec du livré plat signifie l'une de deux choses, et les deux méritent d'être vues : +> soit **la charge ne consomme pas ce qu'on paie**, soit **le compteur ne compte pas**. +> +> C'est écrit ici parce que le jour où les deux valeurs coïncideront, quelqu'un jugera le champ +> redondant et proposera de le retirer. Il ne l'est pas : **c'est précisément quand ils +> divergent que la paire devient informative**, et un champ qui ne sert que dans l'anomalie n'a +> pas l'air utile le reste du temps. + > ### `measuredSince` ferme un piège d'affichage, et il faut savoir lequel > > Quand une obligation bascule de `unmeasurable` à `measured` **en cours de période**, la base diff --git a/debian-qt5/changelog b/debian-qt5/changelog index 210143a..63514d1 100644 --- a/debian-qt5/changelog +++ b/debian-qt5/changelog @@ -1,3 +1,34 @@ +powersync-energy-plugin-nymea (1.15.2+etm54) trixie; urgency=medium + + * +etm53 DÉPLOYÉ sur .75 le 2026-09-04, version relue et `nymead` actif : `bindingPhase` et le + correctif amont de la tension sont sur la box. + * MOCK — LE COMPTEUR INTÈGRE ENFIN. `totalEnergyConsumed` n'était écrit par personne : il + valait sa valeur par défaut indéfiniment. Un compteur figé est un compteur qui N'EXISTE PAS + pour tout ce qui l'intègre — `deliveredWh` restait à 0, `remainingWh` à 4 000, et l'étalement + adaptatif n'était JAMAIS exercé. Le comportement se confondait avec `unmeasurable` pour une + raison entièrement différente et bénigne. QUATRIÈME occurrence d'un mock plus homogène que la + réalité. + * EN EXPORT IL NE RECULE PAS, il ne bouge pas : un compteur de consommation est monotone, et + le faire reculer produirait un cas que le terrain ne produit pas — celui que la garde de + sémantique traite comme une anomalie. Le mock fabriquerait le défaut que le moteur détecte. + * LM-1014-b APPLIQUÉE AU MOCK, question posée et répondue : `totalEnergyConsumed` est déclaré + `cached`, donc il CONTINUE au redémarrage. Repartir de zéro ferait reculer un cumul monotone. + * CONTRAT — `purchasedWh` gagne un SECOND USAGE, sous `measured` cette fois, et il n'est pas + redondant. Mesuré : il accumule (0 → 9,15 → 18,33 → 27,52 Wh) pendant que `deliveredWh` peut + rester PLAT. Cette divergence est un contrôle que l'écran n'aurait pas su fabriquer : de + l'acheté qui monte avec du livré plat signifie soit que la charge ne consomme pas ce qu'on + paie, soit que le compteur ne compte pas. Écrit parce que le jour où les deux coïncideront, + quelqu'un jugera le champ redondant — or c'est précisément quand ils DIVERGENT que la paire + devient informative, et un champ qui ne sert que dans l'anomalie n'a pas l'air utile le reste + du temps. + * Le test du cumul a une borne basse LARGE, et c'est délibéré : il ne contrôle ni la cadence du + minuteur ni l'alignement du premier tic, qui n'intègre rien. Une borne serrée en ferait un + test instable — c'est-à-dire un test qu'on relancerait jusqu'à ce qu'il passe. + * Suite : simulation 66/66, loadmodel 22/22, charging 48/48 identique à la référence, + spotmarket 32/32, doxygen 0 avertissement. + + -- Patrick Schurig Fri, 04 Sep 2026 14:00:00 +0200 + powersync-energy-plugin-nymea (1.15.2+etm53) trixie; urgency=medium * [upstream-fix] rootmeter — LA TENSION EST DÉSORMAIS LOCALE À CHAQUE PHASE. `voltage` était diff --git a/docs/INVENTAIRE_MOCK.md b/docs/INVENTAIRE_MOCK.md index d6a1f7c..22fffd8 100644 --- a/docs/INVENTAIRE_MOCK.md +++ b/docs/INVENTAIRE_MOCK.md @@ -87,6 +87,24 @@ des contrôles — avant d'être comblé. C'est ce qui lui a donné sa priorité combien on en a examinés**, et échouer si le compte est nul. C'est ce qui a rattrapé le test de `command`, et ce qui garde celui de la mesurabilité. +**5. ~~Un compteur d'énergie FIGÉ.~~ — OUVERT le 2026-09-04.** `totalEnergyConsumed` n'était +écrit par personne : il valait sa valeur par défaut, indéfiniment. **Un compteur figé est un +compteur qui n'existe pas pour tout ce qui l'intègre** — `deliveredWh` restait à 0, `remainingWh` +à 4 000, et l'étalement adaptatif n'était **jamais exercé**. Le comportement se confondait avec +`unmeasurable` pour une raison entièrement différente et bénigne. + +Le mock intègre désormais `currentPower` dans le cumul, une fois par seconde. **En export il ne +recule pas — il ne bouge pas** : un compteur de consommation est monotone, et le faire reculer +produirait un cas que le terrain ne produit pas, que la garde de sémantique traiterait comme une +anomalie. + +**Et `LM-1014-b` s'y applique, question posée et répondue** : `totalEnergyConsumed` est déclaré +`cached`, donc il **continue** au redémarrage du mock. Repartir de zéro ferait reculer un cumul +monotone — le mock fabriquerait l'anomalie que le moteur est censé détecter. + +**Quatrième occurrence.** Après les six états cohérents du `rootmeter`, les classes portant toutes +une puissance, et la triphasée équilibrée. + ## Ce qu'il faudrait ajouter, par ordre d'utilité 1. **Une classe pilotable SANS aucune puissance** — un relais sec. Débloque `source: none` sur diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index e98a7bd..9abb36c 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -7791,6 +7791,68 @@ void Simulation::testPhaseVoltageDoesNotLeakBetweenPhases() #endif } +/*! + * \brief Le compteur du mock INTÈGRE réellement — un cumul figé n'existe pas. + * + * \par Le blocage qu'il lève + * `totalEnergyConsumed` valait **10 kWh depuis le 1er septembre** : une constante. `deliveredWh` + * restait donc à 0, `remainingWh` à 4 000, et **l'étalement adaptatif n'était jamais exercé** — + * le comportement se confondait avec `unmeasurable` pour une raison entièrement différente et + * bénigne. Un compteur figé est un compteur qui n'existe pas pour tout ce qui l'intègre. + * + * \par \rule{LM-1014-b} appliquée au mock lui-même + * `totalEnergyConsumed` est déclaré **`cached`** : c'est un compteur de VIE, il **continue** au + * redémarrage et ne repart pas de zéro. Repartir de zéro ferait **reculer un cumul monotone**, + * ce que la garde de sémantique traite comme une anomalie — le mock produirait alors un cas que + * le terrain ne produit pas. + */ +void Simulation::testMeterEnergyActuallyAccumulates() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + + ThingManager *tm = NymeaCore::instance()->thingManager(); + QUuid meterId = addMeter(26693); + Thing *meter = tm->findConfiguredThing(meterId); + QVERIFY(meter); + meter->setStateValue("connected", true); + + // Le compteur CONSOMME 3 600 W : un watt-heure par seconde, de quoi voir bouger le cumul. + meter->setStateValue("currentPower", 3600); + const double depart = meter->stateValue("totalEnergyConsumed").toDouble(); + + QTest::qWait(2500); + const double apres = meter->stateValue("totalEnergyConsumed").toDouble(); + QVERIFY2(apres > depart, + qUtf8Printable(QStringLiteral("le cumul doit AVANCER : %1 → %2 kWh") + .arg(depart).arg(apres))); + + // …et il avance dans un ORDRE DE GRANDEUR plausible : 3 600 W pendant ~2,5 s, soit ~2,5 Wh + // si toutes les secondes intègrent. La borne basse est LARGE et volontairement lâche : le + // test ne contrôle ni la cadence exacte du minuteur ni l'alignement du premier tic — qui + // n'intègre rien, faute de durée derrière lui. Une borne serrée en ferait un test instable, + // c'est-à-dire un test qu'on relancerait jusqu'à ce qu'il passe. + // + // Ce qui est réellement épinglé : il intègre, et il n'emballe pas. Le premier point est ce + // qui manquait ; le second est ce qui rendrait le mock inutilisable dans l'autre sens. + const double gagneWh = (apres - depart) * 1000.0; + QVERIFY2(gagneWh >= 0.5 && gagneWh < 10.0, + qUtf8Printable(QStringLiteral("ordre de grandeur plausible attendu (~2,5 Wh), " + "obtenu %1").arg(gagneWh))); + + // EN EXPORT, il ne recule pas — il ne bouge pas. Un compteur de CONSOMMATION est monotone, + // et le faire reculer produirait un cas que le terrain ne produit pas. + meter->setStateValue("currentPower", -3000); + const double avantExport = meter->stateValue("totalEnergyConsumed").toDouble(); + QTest::qWait(1500); + QCOMPARE(meter->stateValue("totalEnergyConsumed").toDouble(), avantExport); +#endif +} + void Simulation::testCountsSumToTarget() { #ifndef ETM_ARBITRATOR diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 5756f18..cd687ca 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -203,6 +203,7 @@ private slots: void testLifetimeCounterDropNeverRebasesTheProgress(); void testWorstPhaseBindsWhenPhasesAreUnbalanced(); void testPhaseVoltageDoesNotLeakBetweenPhases(); + void testMeterEnergyActuallyAccumulates(); void testRecreditIsALoanAgainstObedience(); void testARefusedStateIsDistinguishableFromAnApplied(); void testL4ShedsAPacAndSaysItForcedTheLock(); diff --git a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp index 2f0a135..29af85d 100644 --- a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp +++ b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp @@ -36,8 +36,44 @@ IntegrationPluginEnergyMocks::IntegrationPluginEnergyMocks(QObject *parent) : In } +void IntegrationPluginEnergyMocks::integrerEnergie() +{ + const QDateTime maintenant = QDateTime::currentDateTime(); + for (Thing *thing : myThings()) { + if (!thing->thingClass().hasStateType("totalEnergyConsumed") + || !thing->thingClass().hasStateType("currentPower")) + continue; + + const QDateTime avant = m_derniereIntegration.value(thing); + m_derniereIntegration.insert(thing, maintenant); + // Première passe : aucune durée derrière elle. Intégrer y fabriquerait de l'énergie. + if (!avant.isValid()) + continue; + + const double heures = avant.msecsTo(maintenant) / 3600000.0; + const double puissanceW = thing->stateValue("currentPower").toDouble(); + // Un compteur de CONSOMMATION n'intègre que ce qui est consommé : en export, il ne + // recule pas — il ne bouge pas. Le faire reculer produirait un cas que le terrain ne + // produit pas, et que la garde de sémantique traiterait comme une anomalie. + if (puissanceW <= 0 || heures <= 0) + continue; + + const double avantKWh = thing->stateValue("totalEnergyConsumed").toDouble(); + thing->setStateValue("totalEnergyConsumed", avantKWh + puissanceW * heures / 1000.0); + } +} + void IntegrationPluginEnergyMocks::setupThing(ThingSetupInfo *info) { + // Intégrateur d'énergie, armé une fois. Une seconde : assez fin pour qu'un cycle + // d'arbitrage (à la minute) voie le compteur BOUGER, ce qui est tout l'objet. + if (!m_integrateur) { + m_integrateur = new QTimer(this); + m_integrateur->setInterval(1000); + connect(m_integrateur, &QTimer::timeout, this, &IntegrationPluginEnergyMocks::integrerEnergie); + m_integrateur->start(); + } + Thing *thing = info->thing(); qCDebug(dcEnergyMocks()) << "Setting up" << thing << thing->params(); diff --git a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.h b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.h index 898e312..e6653d3 100644 --- a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.h +++ b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.h @@ -26,6 +26,9 @@ #define INTEGRATIONPLUGINENERGYMOCKS_H #include "integrations/integrationplugin.h" + +#include +#include #include #include "extern-plugininfo.h" @@ -48,6 +51,31 @@ public: private: QHash m_controllers; + /*! + * \brief Intègre `currentPower` dans `totalEnergyConsumed`, pour TOUS les compteurs. + * + * \par Pourquoi le mock doit le faire + * Le compteur du banc valait **10 kWh depuis le 1er septembre** : une constante, pas un + * cumul. `deliveredWh` restait donc à 0 et `remainingWh` à 4 000 — l'étalement adaptatif + * n'était **jamais exercé**, et le comportement se confondait avec `unmeasurable` pour une + * raison entièrement différente et bénigne. + * + * **Un compteur figé est un compteur qui n'existe pas pour tout ce qui l'intègre.** Quatrième + * occurrence d'un mock plus homogène que la réalité (`docs/INVENTAIRE_MOCK.md`). + * + * \note `totalEnergyConsumed` est déclaré **`cached`** : c'est un compteur de VIE, il + * **continue** au redémarrage du mock et ne repart pas de zéro (\rule{LM-1014-b} : la + * question posée, et la réponse choisie explicitement). Repartir de zéro ferait reculer un + * cumul monotone, ce que la garde de sémantique traite justement comme une anomalie — + * le mock produirait alors un cas que le terrain ne produit pas. + */ + void integrerEnergie(); + + //! Instant de la dernière intégration, par Thing. Absent = première passe, qui n'intègre + //! rien : sans durée derrière elle, elle fabriquerait de l'énergie. + QHash m_derniereIntegration; + QTimer *m_integrateur = nullptr; + // Update the meter data of the charger based on the current max charging current, connected, power and plugged in state void updateChargerMeter(Thing *thing); diff --git a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json index ff71356..3c14974 100644 --- a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json +++ b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json @@ -133,7 +133,8 @@ "displayName": "Total energy consumed", "type": "double", "unit": "KiloWattHour", - "defaultValue": 0.0 + "defaultValue": 0.0, + "cached": true }, { "id": "0420b758-e77f-4cf5-a30b-a6e1235b1efd", @@ -141,7 +142,8 @@ "displayName": "Total energy produced", "type": "double", "unit": "KiloWattHour", - "defaultValue": 0.0 + "defaultValue": 0.0, + "cached": true } ] }, @@ -238,7 +240,8 @@ "displayName": "Total energy consumed", "type": "double", "unit": "KiloWattHour", - "defaultValue": 0.0 + "defaultValue": 0.0, + "cached": true }, { "id": "5a8f841d-60d2-443f-81bf-61b678268307", @@ -246,7 +249,8 @@ "displayName": "Total energy produced", "type": "double", "unit": "KiloWattHour", - "defaultValue": 0.0 + "defaultValue": 0.0, + "cached": true }, { "id": "c5b2b28b-11b4-4274-84ea-17786281201a", @@ -339,7 +343,8 @@ "displayName": "Total consumed energy", "type": "double", "unit": "KiloWattHour", - "defaultValue": 0 + "defaultValue": 0, + "cached": true }, { "id": "13f8f008-aa70-4772-9fb8-81a9674dd6ad", @@ -554,7 +559,8 @@ "displayName": "Total consumed energy", "type": "double", "unit": "KiloWattHour", - "defaultValue": 0 + "defaultValue": 0, + "cached": true }, { "id": "7c1e4a92-3f6b-4d18-9a54-2b8e0d5f1c73",