From fa13420eec8422b8cee6e58a8239dba51b34533f Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Wed, 2 Sep 2026 08:21:14 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20l'avancement=20survit=20au=20battement?= =?UTF-8?q?=20du=20r=C3=A9gime=20dans=20une=20m=C3=AAme=20p=C3=A9riode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sur measured → unmeasurable → measured dans la même fenêtre, deliveredWh repartait de zéro : la base était retirée puis reprise à la valeur courante, donc l'énergie livrée pendant les tronçons mesurés antérieurs disparaissait. L'écran sous-estimait, et — ce qui coûte — la passe éco rachetait le déjà livré. Ce n'est pas théorique : c'est le motif d'une V2C dont le lien Modbus bat. Chaque perte et retour fait basculer le régime deux fois, et chaque aller-retour effaçait ce qui précède. Contre-épreuve : tronçon jeté, l'avancement retombe de 1500 Wh à 0. On accumule par PÉRIODE, pas par tronçon — le tronçon est clos, pas abandonné. Et la clôture se fait sur la dernière valeur lue TANT QUE la mesure existait, jamais sur celle du cycle où le régime tombe : à cet instant le Thing ne publie plus, et s'en servir fabriquerait une énergie. Deux tranchages, épinglés. Le cumul de période repart de zéro à la bascule de période, et les trous avec lui — ils comptaient les interruptions de la période close. Et measuredSince garde le PREMIER instant mesuré, l' intermittence se disant à part : redater à chaque retour effacerait l'histoire au lieu de la raconter, ne rien dire ferait croire à une continuité que le battement dément. LM-1014-c : un cumul se teste aussi de part et d'autre d'un CHANGEMENT DE RÉGIME, pas seulement de période. LM-1014-b ne disait rien de la bascule de régime à l'intérieur d'une période — c'est exactement là que le défaut vivait. Le test a échoué deux fois avant d'être juste, et la seconde fois c'était le test : mon horodatage « après échéance » visait la même échéance que le premier, donc rien ne basculait. Suite : simulation 62/62, 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 | 8 +++++ debian-qt5/changelog | 28 ++++++++++++++++ energyplugin/etm/energyarbitrator.cpp | 39 +++++++++++++++++++++-- energyplugin/etm/energyarbitrator.h | 12 +++++++ energyplugin/etm/types/ecoprogress.h | 11 +++++++ specs/spec_loadmodel.md | 33 +++++++++++++++++++ tests/auto/simulation/simulation.cpp | 46 +++++++++++++++++++++++++++ 7 files changed, 174 insertions(+), 3 deletions(-) diff --git a/INTERFACE.md b/INTERFACE.md index b3460ec..2b7382e 100644 --- a/INTERFACE.md +++ b/INTERFACE.md @@ -698,6 +698,14 @@ C'est un **cumul sur la fenêtre d'obligation** : remis à zéro à la bascule d > `measuredSince` prend la seconde sortie : l'écran dit **« mesuré depuis 09:14 »**, et > `purchasedWh` couvre la période d'avant. **Les deux se répondent**, et ensemble ils disent la > vérité complète. Le champ est **absent** tant que rien n'a jamais été mesuré dans la période. +> +> **`measuredGaps` — présent seulement s'il y a eu des trous.** `measuredSince` date le PREMIER +> instant mesuré de la période ; sur une borne dont le lien bat, la continuité qu'il suggère est +> fausse. Ce compteur autorise la phrase honnête : **« mesuré par intermittence depuis 09:14 »**. +> +> **`deliveredWh` survit aux trous** : les tronçons mesurés d'une même période s'**additionnent** +> (`LM-1014-c`). Sans cela, chaque retour de lien effaçait ce qui précède — l'écran +> sous-estimait, et la passe éco **rachetait le déjà livré**. ### `progressMeasurable` / `progressUnmeasurableCause` — dans `GetLoadConfig`, pas dans la télémétrie diff --git a/debian-qt5/changelog b/debian-qt5/changelog index 3b2ec5c..154e4b4 100644 --- a/debian-qt5/changelog +++ b/debian-qt5/changelog @@ -1,3 +1,31 @@ +powersync-energy-plugin-nymea (1.15.2+etm49) trixie; urgency=medium + + * CORRECTIF — sur `measured → unmeasurable → measured` DANS LA MÊME PÉRIODE, `deliveredWh` + repartait de zéro. La base était retirée puis reprise à la valeur courante de `sessionWh`, + donc l'énergie livrée pendant les tronçons mesurés antérieurs DISPARAISSAIT. Deux + conséquences, et la seconde coûte : l'écran sous-estime, et la passe éco RACHÈTE ce qui a + déjà été livré. + * PAS THÉORIQUE : c'est le motif d'une V2C dont le lien Modbus bat — chaque perte et retour + fait basculer le régime deux fois, et chaque aller-retour effaçait ce qui précède. + Contre-épreuve : tronçon jeté, l'avancement retombe de 1500 Wh à 0. + * On accumule désormais par PÉRIODE, pas par tronçon. Et la clôture se fait sur la DERNIÈRE + valeur lue tant que la mesure existait, jamais sur celle du cycle où le régime tombe : à cet + instant le Thing ne publie plus, et s'en servir fabriquerait une énergie. + * TRANCHÉ — le cumul de période repart de zéro à la bascule de période, et les trous avec lui. + * TRANCHÉ — `measuredSince` garde le PREMIER instant mesuré, et l'intermittence se dit à part + (`measuredGaps`, publié seulement s'il y en a). Redater à chaque retour effacerait l'histoire + au lieu de la raconter ; ne rien dire ferait croire à une continuité que le battement dément. + L'écran peut écrire « mesuré PAR INTERMITTENCE depuis 09:14 », qui est la phrase vraie. + * LM-1014-c — un cumul se teste aussi de part et d'autre d'un CHANGEMENT DE RÉGIME, pas + seulement de période. LM-1014-b disait quoi faire à la bascule de période et ne disait rien + de la bascule de régime à l'intérieur d'une période : c'est là que le défaut vivait. + * Le test a échoué deux fois avant d'être juste, et la seconde était le test : mon horodatage + « après échéance » visait la MÊME échéance que le premier, donc rien ne basculait. + * Suite : simulation 62/62, loadmodel 22/22, charging 48/48 identique à la référence, + spotmarket 32/32, doxygen 0 avertissement. + + -- Patrick Schurig Wed, 02 Sep 2026 11:00:00 +0200 + powersync-energy-plugin-nymea (1.15.2+etm48) trixie; urgency=medium * MOCK — classe `dryRelay` : un relais SEC, qui commute et ne mesure pas. Elle ouvre le cas que diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index c85ba30..43d5f5d 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -532,6 +532,12 @@ EcoProgress EnergyArbitrator::ecoProgress(const LoadContext &lc, const QDateTime m_ecoAchatWh.remove(lc.id); m_ecoDernierAchat.remove(lc.id); m_ecoMesureDepuis.remove(lc.id); + // Le cumul de période repart de ZÉRO, comme tout ce qui est rapporté à la période + // (\rule{LM-1014-b}) — et les trous avec lui : ils comptaient les interruptions de la + // période close, pas de la neuve. + m_ecoLivreWh.remove(lc.id); + m_ecoDernierSessionWh.remove(lc.id); + m_ecoTrous.remove(lc.id); } // C1 de LM-1009 — la borne ne compte pas : l'obligation n'est PAS mesurable, et ça se dit. @@ -545,8 +551,24 @@ EcoProgress EnergyArbitrator::ecoProgress(const LoadContext &lc, const QDateTime if (!lc.telemetry.sessionMeasurable) { p.regime = QString::fromLatin1(ProgressRegime::Unmeasurable); - m_ecoBaseWh.remove(lc.id); - m_ecoMesureDepuis.remove(lc.id); // on ne mesure plus : la date n'a plus d'objet + // CLÔTURE DU TRONÇON, pas abandon. Un aller-retour de régime dans la même période — le + // motif d'un lien Modbus qui bat — jetait tout ce qui précède : la base était reprise à + // la valeur courante, et l'avancement repartait de zéro. Deux conséquences, et la + // seconde coûte : l'écran sous-estime, et la passe éco RACHÈTE le déjà livré. + // + // On additionne au cumul de période, en partant de la DERNIÈRE valeur lue tant que la + // mesure existait : la valeur du cycle courant ne vaut plus rien puisque le Thing ne + // publie plus, et s'en servir fabriquerait une énergie. + if (m_ecoBaseWh.contains(lc.id)) { + const double fin = m_ecoDernierSessionWh.value(lc.id, m_ecoBaseWh.value(lc.id)); + m_ecoLivreWh[lc.id] += qMax(0.0, fin - m_ecoBaseWh.value(lc.id)); + m_ecoTrous[lc.id] += 1; + m_ecoBaseWh.remove(lc.id); + } + // `m_ecoMesureDepuis` est CONSERVÉ : il date le PREMIER instant mesuré de la période, et + // les trous sont dits séparément. Le remettre à zéro ferait croire à une mesure continue + // qui commencerait au retour du lien. + p.purchasedWh = m_ecoAchatWh.value(lc.id, 0.0); return p; } @@ -575,12 +597,18 @@ EcoProgress EnergyArbitrator::ecoProgress(const LoadContext &lc, const QDateTime m_ecoBaseWh.insert(lc.id, sessionWh); } + m_ecoDernierSessionWh.insert(lc.id, sessionWh); // ce qui clôturera le tronçon s'il tombe + p.regime = QString::fromLatin1(ProgressRegime::Measured); p.delivered = true; p.measuredSince = m_ecoMesureDepuis.value(lc.id); + p.measuredGaps = m_ecoTrous.value(lc.id, 0); // Un recul INFIME laisse la base au-dessus de la valeur lue ; l'écrêtage évite un // avancement négatif sans masquer un vrai recul, qui a déjà provoqué la recapture. - p.deliveredWh = qMax(0.0, sessionWh - m_ecoBaseWh.value(lc.id)); + // Le livré de la période = les tronçons CLOS + celui en cours. Sans le premier terme, un + // lien qui bat efface son propre historique à chaque retour. + p.deliveredWh = m_ecoLivreWh.value(lc.id, 0.0) + + qMax(0.0, sessionWh - m_ecoBaseWh.value(lc.id)); return p; } @@ -1619,6 +1647,11 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const if (n.progress.measuredSince.isValid()) progress.insert(QStringLiteral("measuredSince"), n.progress.measuredSince.toUTC().toString(Qt::ISODate)); + // L'INTERMITTENCE, publiée seulement si elle a eu lieu. `measuredSince` date le + // PREMIER instant mesuré ; sans ce compteur, il suggérerait une continuité que + // le battement d'un lien Modbus dément. Absent = mesure continue. + if (n.progress.measuredGaps > 0) + progress.insert(QStringLiteral("measuredGaps"), n.progress.measuredGaps); niveau.insert(QStringLiteral("progress"), progress); } niveaux.append(niveau); diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index 4c38b53..28aaf4a 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -801,6 +801,18 @@ private: mutable QHash m_ecoDernierAchat; //! loadId → instant où l'avancement est devenu MESURABLE dans la période courante. mutable QHash m_ecoMesureDepuis; + //! loadId → énergie livrée (Wh) sur les TRONÇONS MESURÉS DÉJÀ CLOS de la période courante. + //! Sans ce cumul, un aller-retour de régime — un lien Modbus qui bat — effacerait tout ce + //! qui précède : l'écran sous-estimerait, et la passe éco RACHÈTERAIT le déjà livré. + mutable QHash m_ecoLivreWh; + //! loadId → dernière valeur de \c sessionWh lue TANT QUE la mesure était disponible. C'est + //! elle qui clôt le tronçon : au moment où le régime tombe, la valeur courante ne vaut plus + //! rien (le Thing ne publie plus), et l'utiliser fabriquerait une énergie. + mutable QHash m_ecoDernierSessionWh; + //! loadId → nombre d'interruptions de mesure dans la période. Publié quand non nul : c'est + //! ce qui autorise l'écran à dire « mesuré PAR INTERMITTENCE depuis 09:14 » plutôt qu'une + //! continuité qui n'a pas eu lieu. + mutable QHash m_ecoTrous; ThingManager *m_tm = nullptr; //!< ThingManager (pour construire les adaptateurs config). LoadConfigStore *m_loadConfigStore = nullptr; //!< Store config charge pilotée (non adopté). diff --git a/energyplugin/etm/types/ecoprogress.h b/energyplugin/etm/types/ecoprogress.h index d417b71..8bca8e4 100644 --- a/energyplugin/etm/types/ecoprogress.h +++ b/energyplugin/etm/types/ecoprogress.h @@ -76,6 +76,17 @@ struct EcoProgress { */ QDateTime measuredSince; + /*! + * \brief Nombre d'INTERRUPTIONS de mesure dans la période. 0 = mesure continue. + * + * \c measuredSince date le PREMIER instant mesuré de la période ; avec des trous, la + * continuité qu'il suggère serait fausse. Ce compteur permet à l'écran de dire « mesuré + * **par intermittence** depuis 09:14 » — la phrase honnête — plutôt qu'une continuité qui + * n'a pas eu lieu. Sans lui, la seule sortie serait de redater à chaque retour, ce qui + * effacerait l'histoire au lieu de la raconter. + */ + int measuredGaps = 0; + // --- Clôture de période (LM-1014-b) : vrai UN SEUL CYCLE, celui de la bascule ----------- //! \brief Une période d'obligation vient de se clore SANS avoir été tenue. diff --git a/specs/spec_loadmodel.md b/specs/spec_loadmodel.md index 9b2316a..3a87659 100644 --- a/specs/spec_loadmodel.md +++ b/specs/spec_loadmodel.md @@ -981,6 +981,39 @@ Le corollaire de test s'écrit tout seul : **un cumul se teste de part et d'autr jamais seulement en régime établi. Les deux défauts ci-dessus ont survécu à des suites vertes parce qu'aucun scénario ne traversait la frontière. +**LM-1014-c — un cumul se teste aussi de part et d'autre d'un CHANGEMENT DE RÉGIME, pas +seulement de période.** *(Corollaire de `LM-1014-b`, trouvé par lecture le 2026-09-02.)* + +`LM-1014-b` dit ce qu'un cumul devient à la bascule de **période**. Elle ne disait rien de la +bascule de **régime à l'intérieur d'une période** — et c'est là que le défaut vivait. + +Sur `measured → unmeasurable → measured` dans la même fenêtre, la base d'avancement était retirée +puis **reprise à la valeur courante** : l'énergie livrée pendant les tronçons mesurés antérieurs +disparaissait. Deux conséquences, et la seconde coûte : + +1. l'écran **sous-estime** ; +2. la passe éco **rachète ce qui a déjà été livré**. + +**Ce n'est pas théorique** : c'est le motif exact d'une V2C dont le lien Modbus bat — chaque perte +et retour fait basculer le régime **deux fois**, et chaque aller-retour effaçait ce qui précède. + +**La correction : accumuler par PÉRIODE, pas par tronçon.** En quittant `measured`, on ajoute +`dernierSessionWh − base` à un cumul de période au lieu de le jeter. Le tronçon est **clos**, pas +abandonné. + +> **Et il faut clôturer sur la DERNIÈRE valeur lue tant que la mesure existait**, jamais sur celle +> du cycle où le régime tombe : à cet instant le Thing ne publie plus, et s'en servir fabriquerait +> une énergie. + +**LM-1014-c-i — le cumul de période repart de zéro à la bascule de période**, comme tout ce qui +lui est rapporté, et **les trous avec lui** : ils comptaient les interruptions de la période +close. + +**LM-1014-c-ii — `measuredSince` garde le PREMIER instant mesuré, et l'intermittence se dit à +part** (`measuredGaps`). Redater à chaque retour effacerait l'histoire au lieu de la raconter ; +ne rien dire ferait croire à une continuité que le battement dément. L'écran peut alors écrire +« mesuré **par intermittence** depuis 09:14 », qui est la phrase vraie. + **LM-1013-b — une obligation en ÉNERGIE devient une cible en WATTS par étalement uniforme.** *(§10 étape D, tranché le 2026-08-31.)* diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 4ef3e4e..dde060e 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -6795,6 +6795,52 @@ void Simulation::testEcoProgressBaseAndRegime() quotidienne.telemetry.sessionWh = 3300; QCOMPARE(qRound(arb->ecoProgress(quotidienne, apres).deliveredWh), 500); + // 8. BASCULE DE RÉGIME DANS LA MÊME PÉRIODE — measured → unmeasurable → measured. + // C'est le motif d'une V2C dont le lien Modbus bat : chaque perte et retour fait + // basculer le régime DEUX fois. L'énergie livrée pendant les tronçons mesurés + // antérieurs ne doit pas disparaître — sinon l'écran sous-estime, et surtout la passe + // éco RACHÈTE ce qui a déjà été livré. + LoadContext bat; + bat.id = "borne-intermittente"; + bat.needs.minEnergyWhPerDay = 4000; + bat.needs.dailyDeadline = "07:00"; + + bat.telemetry.sessionMeasurable = true; + bat.telemetry.sessionWh = 1000; + QCOMPARE(qRound(arb->ecoProgress(bat, t0).deliveredWh), 0); // base prise ici + bat.telemetry.sessionWh = 2500; + QCOMPARE(qRound(arb->ecoProgress(bat, t0).deliveredWh), 1500); // 1500 Wh livrés + + // Le lien tombe : plus rien ne mesure. + bat.telemetry.sessionMeasurable = false; + QCOMPARE(arb->ecoProgress(bat, t0).regime, QString(ProgressRegime::Unmeasurable)); + + // Le lien revient, dans la MÊME période. Les 1500 Wh déjà livrés doivent SURVIVRE. + bat.telemetry.sessionMeasurable = true; + bat.telemetry.sessionWh = 2500; + QCOMPARE(qRound(arb->ecoProgress(bat, t0).deliveredWh), 1500); + bat.telemetry.sessionWh = 3000; + const EcoProgress reprise = arb->ecoProgress(bat, t0); + QCOMPARE(qRound(reprise.deliveredWh), 2000); // 1500 (tronçon clos) + 500 (en cours) + + // TRANCHÉ — `measuredSince` garde le PREMIER instant mesuré, et l'intermittence se dit à + // part. Redater à chaque retour effacerait l'histoire au lieu de la raconter ; ne rien dire + // ferait croire à une continuité que le battement dément. L'écran peut alors écrire + // « mesuré PAR INTERMITTENCE depuis 09:14 », qui est la phrase vraie. + QCOMPARE(reprise.measuredSince, t0); + QCOMPARE(reprise.measuredGaps, 1); + + // TRANCHÉ — à la BASCULE DE PÉRIODE, le cumul repart de zéro comme tout ce qui est rapporté + // à la période (LM-1014-b), et les trous avec lui : ils comptaient les interruptions de la + // période close, pas de la neuve. + // Au-delà de l'échéance visée depuis t0 (2026-08-31 13:00 → 2026-09-01 07:00) : c'est ce + // franchissement qui ouvre une période neuve. Un instant qui viserait la MÊME échéance ne + // ferait rien basculer — première version de ce test, qui échouait pour cette raison. + const QDateTime apresEcheance = utcDateTime(QDate(2026, 9, 1), QTime(8, 0, 0)); + const EcoProgress neuve = arb->ecoProgress(bat, apresEcheance); + QCOMPARE(qRound(neuve.deliveredWh), 0); + QCOMPARE(neuve.measuredGaps, 0); + // 6. L'obligation retirée LIBÈRE la base : une nouvelle obligation repart d'un repère neuf, // et n'hérite pas de l'avancement d'une obligation qui n'existe plus. LoadContext retiree = mesuree;