feat: purchasedWh dans tous les régimes, et measuredSince

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
This commit is contained in:
Patrick Schurig 2026-09-01 11:48:11 +02:00
parent 19b20d930f
commit 770cef13f3
6 changed files with 139 additions and 2 deletions

View File

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

View File

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

View File

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

View File

@ -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<QString, QDateTime> m_divergenceDepuis;
//! loadId → énergie achetée (Wh) pour l'obligation, depuis l'ouverture de la PÉRIODE.
mutable QHash<QString, double> m_ecoAchatWh;
//! loadId → instant du dernier cycle où un achat a été noté, pour intégrer la durée.
mutable QHash<QString, QDateTime> m_ecoDernierAchat;
//! loadId → instant où l'avancement est devenu MESURABLE dans la période courante.
mutable QHash<QString, QDateTime> m_ecoMesureDepuis;
ThingManager *m_tm = nullptr; //!< ThingManager (pour construire les adaptateurs config).
LoadConfigStore *m_loadConfigStore = nullptr; //!< Store config charge pilotée (non adopté).

View File

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

View File

@ -3,6 +3,7 @@
#pragma once
#include <QString>
#include <QDateTime>
/*!
* \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.