From 770cef13f3c76bc6271ab901c329daccfcf7d33b Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Tue, 1 Sep 2026 11:48:11 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20purchasedWh=20dans=20tous=20les=20r?= =?UTF-8?q?=C3=A9gimes,=20et=20measuredSince?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux demandes de l'app, toutes deux fondées. purchasedWh est publié même sous `unmeasurable`, et ce n'est pas une entorse à la règle qui y interdit deliveredWh — c'en est le complément. Ce qui est ACHETÉ est un fait connu sans mesure : c'est notre propre commande. Ce qui est LIVRÉ est une observation qu'on ne peut pas faire. Sans lui, « ce que ça coûte par jour » redeviendrait une estimation fabriquée à partir du taux. C'est un cumul sur la fenêtre d'obligation, donc LM-1014-b s'applique : remis à zéro à la bascule, comme la base. Le premier cycle d'une période ne contribue rien — il n'a pas de durée derrière lui, et en inventer une serait fabriquer de l'énergie. measuredSince ferme un piège d'affichage. Quand une obligation bascule de unmeasurable à measured EN COURS de période, deliveredWh repart de 0 pendant que targetWh reste la cible entière : une barre afficherait « 0,5 sur 4,0 » après une bascule où 3 kWh ont pu être achetés. Rebaser targetWh serait pire — on perdrait l'obligation réelle, et l'écran ne pourrait plus dire ce qui reste dû. Le champ prend la seconde sortie : « mesuré depuis 09:14 », et purchasedWh couvre l'avant. Les deux se répondent. Et un piège de build noté dans AGENTS.md : changer la DISPOSITION d'une structure dans un en-tête inclus transitivement laisse des objets compilés contre l'ancienne, et le symptôme est un plantage dans un DESTRUCTEUR — qui ressemble en tout point à un défaut mémoire du code qu'on vient d'écrire. Avant de chercher le bug : make clean. Le plantage n'avait jamais existé. Suite : simulation 59/59, 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 --- AGENTS.md | 6 +++ INTERFACE.md | 33 +++++++++++++ energyplugin/etm/energyarbitrator.cpp | 48 ++++++++++++++++++- energyplugin/etm/energyarbitrator.h | 16 +++++++ .../etm/scheduler/rulebasedscheduler.cpp | 5 ++ energyplugin/etm/types/ecoprogress.h | 33 +++++++++++++ 6 files changed, 139 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9c5df60..e1d45be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -862,6 +862,12 @@ aucune, la décision est une intention, et elle se périmera à la vitesse du co > d'exploiter une valeur de retour qu'aucun code n'exploitait, **l'épingler d'abord par un > test** — le premier appelant est aussi le premier vérificateur. +> **Piège de build, 2026-09-01** : changer la DISPOSITION d'une structure dans un en-tête inclus +> transitivement (ici un `QDateTime` ajouté à `EcoProgress`, atteint par `LoadAction`) peut +> laisser des objets compilés contre l'ancienne. Le symptôme est un **plantage dans un +> destructeur** — qui ressemble en tout point à un défaut mémoire du code qu'on vient d'écrire. +> Avant de chercher le bug : `make clean`. Si le plantage disparaît, il n'a jamais existé. + > **Corollaire fin, appris le 2026-09-01 : une contre-épreuve qui NE MORD PAS ne prouve pas que > le code est bon — elle prouve qu'on a perturbé au mauvais endroit.** > diff --git a/INTERFACE.md b/INTERFACE.md index 1c4a4b4..a64f950 100644 --- a/INTERFACE.md +++ b/INTERFACE.md @@ -666,6 +666,39 @@ afficherait un plafond de soutirage **qui n'existe pas, avec sa source**. | `binding` | Code opaque de la source qui borne : `connection` \| `gridOperator` \| `selfImposed`. Ce qu'on a le droit de faire d'un dépassement en dépend. | | `perPhaseBound` | Le plafond qui borne est-il **par phase** ? Un plafond total n'a pas de phase coupable, et le dire évite d'envoyer mesurer au hasard sur une triphasée. | +### `progress.purchasedWh` et `progress.measuredSince` + +```jsonc +"progress": { "regime": "measured", "targetWh": 4000, "deliveredWh": 500, + "purchasedWh": 3200, "measuredSince": "2026-09-01T09:14:00Z" } +``` + +**`purchasedWh` est publié dans TOUS les régimes, `unmeasurable` compris.** Ce n'est pas une +entorse à la règle qui interdit `deliveredWh` là-bas — c'en est le complément exact : + +| | ce que c'est | connu sans mesure ? | +|---|---|---| +| `deliveredWh` | ce qui a été **livré à la charge** | **non** — c'est une observation qu'on ne peut pas faire | +| `purchasedWh` | ce qui a été **acheté au réseau** | **oui** — c'est notre propre commande | + +Sans lui, « ce que ça coûte par jour » redeviendrait une estimation fabriquée à partir du taux. +C'est un **cumul sur la fenêtre d'obligation** : remis à zéro à la bascule de période +(`LM-1014-b`). + +> ### `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 +> d'avancement est capturée à cet instant : `deliveredWh` repart de 0 pendant que `targetWh` +> reste la cible **entière**. Une barre afficherait alors **« 0,5 sur 4,0 »** après une bascule +> où 3 kWh ont pu être achetés — crédible, et faux. +> +> **Rebaser `targetWh` serait pire** : on perdrait l'obligation réelle — l'utilisateur a demandé +> 4 kWh, pas 1 — et l'écran ne pourrait plus dire ce qui reste 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. + ### `command` — l'écart entre le commandé et le mesuré Publié **seulement** quand une source mesure (`measurement.source` ≠ `none`) **et** qu'une diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 295013f..89ff684 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -452,6 +452,20 @@ namespace { constexpr double BruitAvancementWh = 50.0; } +void EnergyArbitrator::noterAchatEco(const QString &loadId, double achatW, + const QDateTime &now) const +{ + const QDateTime precedent = m_ecoDernierAchat.value(loadId); + // Le premier cycle d'une période ne contribue RIEN : il n'a pas de durée derrière lui, et + // en inventer une serait fabriquer de l'énergie. L'écart d'un cycle est borné et connu. + if (precedent.isValid() && achatW > 0) { + const double heures = precedent.secsTo(now) / 3600.0; + if (heures > 0) + m_ecoAchatWh[loadId] += achatW * heures; + } + m_ecoDernierAchat.insert(loadId, now); +} + void EnergyArbitrator::noterCauseEco(const QString &loadId, const QString &cause) const { if (cause.isEmpty()) @@ -512,14 +526,27 @@ EcoProgress EnergyArbitrator::ecoProgress(const LoadContext &lc, const QDateTime m_ecoBaseEcheance.insert(lc.id, echeance); m_ecoBaseWh.remove(lc.id); m_ecoCause.remove(lc.id); + // LM-1014-b — les deux CUMULS de la période repartent à zéro, et la date de mesure + // avec eux. Les laisser courir ferait porter au lendemain la dépense de la veille et + // dater la mesure d'une période close. + m_ecoAchatWh.remove(lc.id); + m_ecoDernierAchat.remove(lc.id); + m_ecoMesureDepuis.remove(lc.id); } // C1 de LM-1009 — la borne ne compte pas : l'obligation n'est PAS mesurable, et ça se dit. // `deliveredWh` reste absent, jamais zéro : une dégradation ne doit pas se lire comme un // avancement nul, sans quoi l'échéance continue de s'afficher comme si elle tenait. + // L'ACHETÉ est connu sans mesure — c'est notre propre commande — donc il est publié dans + // TOUS les régimes, y compris `unmeasurable`. C'est ce qui distingue « ce qu'on a dépensé » + // de « ce qui a été livré » : le premier est un fait du moteur, le second une observation + // qu'il ne peut pas faire ici. + p.purchasedWh = m_ecoAchatWh.value(lc.id, 0.0); + 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 return p; } @@ -530,6 +557,12 @@ EcoProgress EnergyArbitrator::ecoProgress(const LoadContext &lc, const QDateTime // reprendre là réinitialiserait l'avancement d'une session en cours sur une valeur // déjà accumulée, et une recharge de nuit relue au matin repartirait de zéro. m_ecoBaseWh.insert(lc.id, sessionWh); + // DEPUIS QUAND on mesure. Sans cette date, une bascule `unmeasurable` → `measured` en + // cours de période ferait afficher « 0,5 sur 4,0 » alors que 3 kWh ont pu être achetés + // avant : crédible et faux. Rebaser `targetWh` serait pire — on perdrait l'obligation + // réelle, et l'écran ne pourrait plus dire ce qui reste dû. + if (!m_ecoMesureDepuis.contains(lc.id)) + m_ecoMesureDepuis.insert(lc.id, now); } else if (sessionWh < m_ecoBaseWh.value(lc.id) - BruitAvancementWh) { // RECUL FRANC de l'accumulateur : le plugin l'a remis à la valeur brute, donc une // nouvelle session s'est ouverte. On recapture, sinon la différence devient négative. @@ -542,8 +575,9 @@ EcoProgress EnergyArbitrator::ecoProgress(const LoadContext &lc, const QDateTime m_ecoBaseWh.insert(lc.id, sessionWh); } - p.regime = QString::fromLatin1(ProgressRegime::Measured); - p.delivered = true; + p.regime = QString::fromLatin1(ProgressRegime::Measured); + p.delivered = true; + p.measuredSince = m_ecoMesureDepuis.value(lc.id); // 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)); @@ -1544,6 +1578,16 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const // comme un avancement nul (LM-1009 C1). if (n.progress.delivered) progress.insert(QStringLiteral("deliveredWh"), n.progress.deliveredWh); + // L'ACHETÉ est publié dans TOUS les régimes : c'est notre propre commande, donc + // un fait connu sans mesure. Sans lui, « ce que ça coûte par jour » redeviendrait + // une estimation fabriquée à partir du taux. + progress.insert(QStringLiteral("purchasedWh"), n.progress.purchasedWh); + // DEPUIS QUAND on mesure — présent seulement si on mesure. Ferme le piège de la + // bascule en cours de période : « 0,5 sur 4,0 » alors que 3 kWh ont pu être + // achetés avant. L'écran dit « mesuré depuis 09:14 », purchasedWh couvre l'avant. + if (n.progress.measuredSince.isValid()) + progress.insert(QStringLiteral("measuredSince"), + n.progress.measuredSince.toUTC().toString(Qt::ISODate)); niveau.insert(QStringLiteral("progress"), progress); } niveaux.append(niveau); diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index 637f9d8..d2e7511 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -390,6 +390,16 @@ public: */ void noterCauseEco(const QString &loadId, const QString &cause) const; + /*! + * \brief Note les watts ACHETÉS pour l'obligation d'une charge, et cumule l'énergie. + * \param loadId Charge. \param achatW Puissance achetée ce cycle. \param now Temps de cycle. + * \note Le cumul est remis à zéro à la bascule de période (\rule{LM-1014-b}) : le laisser + * courir ferait porter au lendemain la dépense de la veille. Le premier cycle d'une + * période ne contribue rien — il n'a pas de durée derrière lui, et en inventer une serait + * fabriquer de l'énergie. + */ + void noterAchatEco(const QString &loadId, double achatW, const QDateTime &now) const; + /*! * \brief [L4] Déleste les charges du waterfall tant que le plafond de tirage est dépassé. * @@ -775,6 +785,12 @@ private: //! cycle entre l'écriture et sa mesure — et c'est toute la différence entre le normal et //! le cas relevé. mutable QHash m_divergenceDepuis; + //! loadId → énergie achetée (Wh) pour l'obligation, depuis l'ouverture de la PÉRIODE. + mutable QHash m_ecoAchatWh; + //! loadId → instant du dernier cycle où un achat a été noté, pour intégrer la durée. + mutable QHash m_ecoDernierAchat; + //! loadId → instant où l'avancement est devenu MESURABLE dans la période courante. + mutable QHash m_ecoMesureDepuis; 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/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index 0a9e936..d5e4e1d 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -315,6 +315,11 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) else m_arbitrator->noterCauseEco(lc.id, QString()); // servie sans blocage + // L'ACHAT est noté à chaque cycle, y compris nul — c'est le passage du temps qui + // intègre l'énergie, et sauter les cycles sans achat ferait compter la durée d'un arrêt + // comme si l'on avait acheté pendant. + m_arbitrator->noterAchatEco(lc.id, achatW, ctx.timestamp); + if (achatW <= 0) continue; // rien d'achetable ce cycle : pas de niveau éco plutôt qu'un zéro forgé diff --git a/energyplugin/etm/types/ecoprogress.h b/energyplugin/etm/types/ecoprogress.h index 85f50a7..d417b71 100644 --- a/energyplugin/etm/types/ecoprogress.h +++ b/energyplugin/etm/types/ecoprogress.h @@ -3,6 +3,7 @@ #pragma once #include +#include /*! * \file ecoprogress.h @@ -43,6 +44,38 @@ struct EcoProgress { //! Énergie à livrer (Wh), déclarée par \c needs. 0 = aucune obligation en cours. double targetWh = 0; + /*! + * \brief Énergie ACHETÉE au réseau pour cette obligation depuis l'ouverture de la période. + * + * \par Pourquoi celui-ci est légitime là où \c deliveredWh ne l'est pas + * Ce qui est **acheté** est un fait que le moteur connaît **sans mesure** : c'est sa propre + * commande. Ce qui est **livré** est ce qu'il ne peut pas vérifier. La distinction qui + * interdit de publier un avancement non mesuré n'interdit donc pas de publier ce qu'on a + * dépensé — elle l'exige plutôt, sans quoi « ce que ça coûte par jour » redeviendrait une + * estimation fabriquée à partir du taux. + * + * \note **Cumul sur la fenêtre d'obligation** (\rule{LM-1014-b}) : remis à zéro à la + * bascule de période, comme la base d'avancement. Le laisser courir ferait porter au + * lendemain la dépense de la veille. + */ + double purchasedWh = 0; + + /*! + * \brief Depuis quand l'avancement est MESURÉ dans cette période. Invalide si jamais. + * + * \par Le piège qu'il ferme + * Quand une obligation bascule de \c unmeasurable à \c measured en cours de période, la base + * est capturée à cet instant : \c deliveredWh repart de 0 alors que \c targetWh reste la + * cible **entière**. Une barre afficherait « 0,5 sur 4,0 » après une bascule où 3 kWh ont pu + * être achetés — **crédible et faux**. + * + * Rebaser \c targetWh serait pire : on perdrait l'obligation RÉELLE (l'utilisateur a demandé + * 4 kWh, pas 1), et l'écran ne pourrait plus dire ce qui reste dû. Ce champ prend la seconde + * sortie : l'écran dit « mesuré depuis 09:14 », et \c purchasedWh couvre la période d'avant. + * Les deux se répondent. + */ + QDateTime measuredSince; + // --- 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.