test(mock): le compteur intègre enfin, et purchasedWh gagne un second usage

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
This commit is contained in:
Patrick Schurig 2026-09-04 06:19:08 +02:00
parent c88ba6ad45
commit 04852c518a
8 changed files with 203 additions and 6 deletions

View File

@ -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

View File

@ -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 <etm.schurig@gmail.com> 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

View File

@ -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

View File

@ -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

View File

@ -203,6 +203,7 @@ private slots:
void testLifetimeCounterDropNeverRebasesTheProgress();
void testWorstPhaseBindsWhenPhasesAreUnbalanced();
void testPhaseVoltageDoesNotLeakBetweenPhases();
void testMeterEnergyActuallyAccumulates();
void testRecreditIsALoanAgainstObedience();
void testARefusedStateIsDistinguishableFromAnApplied();
void testL4ShedsAPacAndSaysItForcedTheLock();

View File

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

View File

@ -26,6 +26,9 @@
#define INTEGRATIONPLUGINENERGYMOCKS_H
#include "integrations/integrationplugin.h"
#include <QTimer>
#include <QDateTime>
#include <QObject>
#include "extern-plugininfo.h"
@ -48,6 +51,31 @@ public:
private:
QHash<Thing*, EnergyMockController *> 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<Thing*, QDateTime> 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);

View File

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