From 0f05e98e34855464d502286eb11bb978393b8bd1 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Tue, 1 Sep 2026 07:26:36 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20remainingWh=20sous=20unmeasurable=20=3D?= =?UTF-8?q?=20la=20cible=20intacte,=20et=20la=20m=C3=A9moire=20de=20comman?= =?UTF-8?q?de?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONTRAT. Sous `unmeasurable`, remainingWh == targetWh : rien n'est soustrait, parce qu'on ne sait rien de ce qui a été livré. 2 000 Wh restants sur une cible de 2 000 Wh signifie « rien de mesurable », pas « rien livré » — deux lectures qui commandent des phrases d'écran opposées, et l'écran ne peut trancher que si le contrat le dit. Soustraire l'intégration interne ferait franchir la frontière à une estimation sous l'allure d'un fait : le chemin de carBatteryLevel, refusé en LM-1009 D3. Et que `regime` voyage dans les mêmes params ne suffirait pas — un client lisant remainingWh sans regarder regime obtiendrait un nombre calculé, sans rien pour le lui dire. Identité épinglée sous `measured` : deliveredWh + remainingWh == targetWh. Troisième contrôle du même genre que Σ counts == targetW. Elle ne tient QUE sous measured ; sous unmeasurable c'est remainingWh == targetWh qui prend le relais, et c'est le régime qui dit laquelle appliquer. Une honnêteté sur la portée de ce test : la contre-épreuve naïve — soustraire deliveredWh sous unmeasurable — NE MORD PAS, puisque deliveredWh y vaut 0. L'assertion est donc un corollaire de `delivered == false`, pas une garde indépendante. La garde réelle est R6, vérifiée mordante au bon niveau : publier l'estimation comme un fait fait sortir ECO_FLOOR_MET sous unmeasurable, ce qui est exactement la violation que R6 interdit. Commande perdue, étape 1 : l'EvAdapter retient enfin sa dernière commande. Les adaptateurs en watts la gardaient déjà — l'écrêtage de verrou l'exige — mais la borne, pilotée par le proxy amont, n'avait jamais eu à se relire. C'est précisément là que le relevé porte : sans ce souvenir, l'écart entre ce qu'on a commandé et ce que la borne fait n'est pas calculable. La commande ÉCRÊTÉE est retenue, jamais la demande : comparer la demande ferait voir un écart là où l'adaptateur a fait son travail. Publication seule, comportement constant. Suite : simulation 58/58, loadmodel 22/22, charging 48/48 identique à la référence ligne à ligne, spotmarket 32/32, doxygen 0 avertissement. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p --- debian-qt5/changelog | 31 +++++++++++++++++++++++++ energyplugin/etm/adapters/evadapter.cpp | 13 +++++++++++ energyplugin/etm/adapters/evadapter.h | 30 ++++++++++++++++++++++++ tests/auto/simulation/simulation.cpp | 25 ++++++++++++++++++++ 4 files changed, 99 insertions(+) diff --git a/debian-qt5/changelog b/debian-qt5/changelog index 120b22f..2711e34 100644 --- a/debian-qt5/changelog +++ b/debian-qt5/changelog @@ -1,3 +1,34 @@ +powersync-energy-plugin-nymea (1.15.2+etm44) trixie; urgency=medium + + * CONTRAT — `remainingWh` sous `unmeasurable` vaut LA CIBLE INTACTE : `remainingWh == + targetWh`. 2 000 Wh restants sur une cible de 2 000 Wh signifie « rien de mesurable », PAS + « rien livré ». Les deux lectures commandent des phrases d'écran opposées, et l'écran ne peut + trancher que si le contrat le dit. Écrit dans INTERFACE.md. + * Rien n'est soustrait parce que soustraire l'intégration interne ferait franchir la frontière + à une ESTIMATION sous l'allure d'un fait — le chemin de `carBatteryLevel`, refusé en + LM-1009 D3. Que `regime` voyage dans les mêmes params ne suffirait pas : un client lisant + `remainingWh` sans regarder `regime` obtiendrait un nombre calculé sans rien pour le lui dire. + * IDENTITÉ ÉPINGLÉE sous `measured` : progress.deliveredWh + params.remainingWh == + params.targetWh. Troisième contrôle vérifiable, après Σ counts == targetW et + authorisedW − committedW == remainingW. Elle ne tient QUE sous `measured` — sous + `unmeasurable`, c'est `remainingWh == targetWh` qui prend le relais, et c'est le régime qui + dit laquelle appliquer. + * HONNÊTETÉ SUR LA PORTÉE DE CE TEST : la contre-épreuve naïve (soustraire `deliveredWh` sous + unmeasurable) NE MORD PAS, puisque `deliveredWh` y vaut 0. L'assertion est donc un + COROLLAIRE de `delivered == false`, pas une garde indépendante. La garde réelle est R6, et + elle a été vérifiée mordante au bon niveau : publier l'estimation comme un fait fait sortir + ECO_FLOOR_MET sous unmeasurable. + * Commande perdue, étape 1 — MÉMOIRE de la commande dans EvAdapter. Les adaptateurs en watts + la gardaient déjà (l'écrêtage de verrou l'exige) ; la borne, pilotée par le proxy amont, + n'avait jamais eu à se relire. C'est exactement là que le relevé du 2026-08-31 porte : sans + ce souvenir, l'écart commandé/constaté n'est pas CALCULABLE. Publication seule, comportement + constant. La commande ÉCRÊTÉE est retenue, jamais la demande — comparer la demande ferait + voir un écart là où l'adaptateur a fait son travail. + * Suite : simulation 58/58, loadmodel 22/22, charging 48/48 identique à la référence, + spotmarket 32/32, doxygen 0 avertissement. + + -- Patrick Schurig Tue, 01 Sep 2026 08:30:00 +0200 + powersync-energy-plugin-nymea (1.15.2+etm43) trixie; urgency=medium * DÉPLOYÉ SUR .75 le 2026-09-01, version relue sur la box et `nymead` actif. Les quatre motifs diff --git a/energyplugin/etm/adapters/evadapter.cpp b/energyplugin/etm/adapters/evadapter.cpp index 981e256..9eb949f 100644 --- a/energyplugin/etm/adapters/evadapter.cpp +++ b/energyplugin/etm/adapters/evadapter.cpp @@ -224,6 +224,19 @@ LoadAction EvAdapter::applyAction(const LoadAction &action, const QDateTime &now m_parent->doExecuteChargingAction(m_charger, ca, now); // now = temps de cycle (injectable) m_lastActionAt = now; + // §Commande perdue, étape 1 — MÉMOIRE de ce qu'on vient de commander. Les adaptateurs de + // charges en watts la gardent depuis toujours (ils en ont besoin pour l'écrêtage de + // verrou) ; la borne, pilotée par le proxy amont, n'a jamais eu à se relire. C'est + // exactement là que le constat du 2026-08-31 porte : sans ce souvenir, l'écart entre ce + // qu'on a commandé et ce que la borne fait n'est pas CALCULABLE. + // + // On retient la commande ÉCRÊTÉE, pas la demande : c'est elle qui est partie au matériel, + // et c'est donc elle que la borne devrait refléter. Comparer la demande ferait voir un + // écart là où l'adaptateur a simplement fait son travail. + m_commandeA = enclencher ? clampedA : 0; + m_commandePhases = phases; + m_commandeValide = true; + LoadAction applied = action; applied.currentA = clampedA; applied.phaseCount = phases; diff --git a/energyplugin/etm/adapters/evadapter.h b/energyplugin/etm/adapters/evadapter.h index 6275d9d..31a50c5 100644 --- a/energyplugin/etm/adapters/evadapter.h +++ b/energyplugin/etm/adapters/evadapter.h @@ -121,7 +121,37 @@ public: */ EvCharger *evCharger() const { return m_charger; } + /*! + * \brief Dernière commande envoyée au matériel par cet adaptateur. + * \param[out] ampere Courant commandé (A), 0 si la charge a été coupée. + * \param[out] phases Nombre de phases commandé. + * \return Faux si AUCUNE commande n'est jamais partie — l'écart est alors sans objet, et + * c'est le cas normal d'une borne en mode manuel que l'arbitre ne pilote pas. + */ + bool derniereCommande(uint &ere, uint &phases) const + { ampere = m_commandeA; phases = m_commandePhases; return m_commandeValide; } + private: + /*! + * \brief Dernière commande RÉELLEMENT envoyée au matériel — écrêtée, pas demandée. + * + * \par Pourquoi ce souvenir n'existait pas + * Les adaptateurs de charges en watts gardent leur consigne parce que l'écrêtage de verrou + * l'exige. La borne, pilotée par le proxy amont, n'a jamais eu à se relire — et c'est + * précisément là que le relevé du 2026-08-31 mord : après une coupure Modbus, la borne est + * restée sur une consigne d'arbitre pendant que le proxy en écrivait une autre, sans que + * l'écart soit calculable par personne. + * + * \note La commande ÉCRÊTÉE, jamais la demande : c'est elle qui est partie au matériel. + * Comparer la demande ferait voir un écart là où l'adaptateur a fait son travail. + */ + uint m_commandeA = 0; + uint m_commandePhases = 0; + //! Faux tant qu'aucune commande n'est partie. L'absence de commande n'est pas une commande + //! à zéro : sans ce booléen, une borne jamais pilotée paraîtrait divergente dès qu'elle + //! consomme, ce qui est le cas NORMAL du mode manuel. + bool m_commandeValide = false; + EvCharger *m_charger; EnergyArbitrator *m_parent; QDateTime m_lastActionAt; diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index e5bf91b..acb434b 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -6912,6 +6912,31 @@ void Simulation::testEcoPassBuysAndSaysTheTrajectory() QCOMPARE(inconnu.levels.at(0).progress.regime, QString(ProgressRegime::Unmeasurable)); QVERIFY2(!inconnu.levels.at(0).progress.delivered, "sous unmeasurable, deliveredWh n'a pas de sens et ne sera pas publié"); + + // 7. SOUS `unmeasurable`, `remainingWh` VAUT LA CIBLE INTACTE. Rien n'est soustrait, parce + // qu'on ne sait rien de ce qui a été livré. Soustraire une intégration interne ferait + // franchir la frontière à une ESTIMATION sous l'allure d'un fait — le chemin exact de + // `carBatteryLevel`, refusé en LM-1009 D3. + // + // Et que `regime` voyage dans les mêmes params ne suffirait pas : un client qui lit + // `remainingWh` sans regarder `regime` obtiendrait un nombre calculé, sans rien pour le + // lui dire. « 2000 Wh restants sur 2000 » doit signifier « rien de mesurable », pas + // « rien livré » — et l'écran ne peut trancher que si le contrat le dit. + const QVariantMap pu = inconnu.levels.at(0).reason.params; + QCOMPARE(pu.value("remainingWh").toInt(), pu.value("targetWh").toInt()); + QCOMPARE(pu.value("remainingWh").toInt(), 2000); + + // 8. IDENTITÉ SOUS `measured` — troisième contrôle vérifiable, au même titre que + // Σ counts == targetW et authorisedW − committedW == remainingW. C'est ce qui permet à + // un client de constater la cohérence au lieu de la supposer. + const LoadAction partiel = planifier(deuxHeuresAvant, true, 800, 5000); + const LoadAction suite = planifier(deuxHeuresAvant, true, 1400, 5000); + for (const LoadAction &act : {partiel, suite}) { + const QVariantMap pm = act.levels.at(0).reason.params; + QVERIFY2(act.levels.at(0).progress.delivered, "régime mesuré attendu"); + QCOMPARE(qRound(act.levels.at(0).progress.deliveredWh) + pm.value("remainingWh").toInt(), + pm.value("targetWh").toInt()); + } #endif }