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:
parent
19b20d930f
commit
770cef13f3
@ -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.**
|
||||
>
|
||||
|
||||
33
INTERFACE.md
33
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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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é).
|
||||
|
||||
|
||||
@ -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é
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user