fix: l'avancement survit au battement du régime dans une même période

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
This commit is contained in:
Patrick Schurig 2026-09-02 08:21:14 +02:00
parent cdd3bc88f3
commit fa13420eec
7 changed files with 174 additions and 3 deletions

View File

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

View File

@ -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 <etm.schurig@gmail.com> 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

View File

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

View File

@ -801,6 +801,18 @@ private:
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;
//! 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<QString, double> 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<QString, double> 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<QString, int> m_ecoTrous;
ThingManager *m_tm = nullptr; //!< ThingManager (pour construire les adaptateurs config).
LoadConfigStore *m_loadConfigStore = nullptr; //!< Store config charge pilotée (non adopté).

View File

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

View File

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

View File

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