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",