fix: la contradiction progressMeasurable/regime, et la garde qui la rend sûre

Le premier dit ce que le MATÉRIEL permet, le second ce que le MOTEUR fait.
Seul l'EvAdapter remplissait sessionMeasurable, donc sur toute charge non-VE
le premier disait oui et le second non — et l'avertissement de saisie de
l'app, qui se déclenche sur progressMeasurable, restait muet exactement sur
les charges achetées à l'aveugle. sessionWh est alimenté depuis le compteur
désigné.

L'avertissement de l'app est arrivé AVANT que j'écrive la garde, et il était
décisif. Un compteur qui disparaît peut publier 0 au lieu de se taire : la
V2C a forgé un zéro sur currentPower et ses booléens, mais laissé
sessionEnergy à sa dernière valeur. La politique dépend du plugin — donc
inutilisable comme garde.

Et le point à vérifier avait une réponse nette : il n'existe AUCUN signal de
joignabilité fiable. setupComplete parle du setup et non du lien ; connected
est maintenu par le plugin, la V2C basculant après trois échecs quand un
compteur générique peut ne jamais basculer. La garde ne pouvait pas reposer
là-dessus.

Elle porte donc sur la SÉMANTIQUE du compteur, seule chose que le moteur
sache sans parier : sur un compteur de session un recul est une session
neuve, sur un compteur de vie c'est une anomalie — on gèle, on ne recapture
jamais. Sans elle, le scénario chiffré sort tel quel : base reprise à 0
pendant l'absence, retour avec 10 kWh réels, deliveredWh à 10 000 en un
cycle, obligation déclarée tenue sans rien de livré. Et R6 ne l'attrape pas :
elle n'interdit ECO_FLOOR_MET que sous unmeasurable, alors que ce chemin est
MESURÉ — c'est le seul par lequel un faux « tenue » peut sortir, et c'est
celui qu'ouvrait l'alimentation depuis un compteur.

audit-controles.py dit maintenant en tête qu'il ne tranche pas et ne doit
jamais trancher : neuf des dix identités relevées contrôlaient réellement, et
un verdict automatique aurait supprimé neuf contrôles utiles.

AGENTS.md : la diversité du mock n'est pas un confort de test, c'est une
borne supérieure sur ce que la suite peut prouver — elle se fixe en écrivant
les classes, pas les tests.

Quatrième occurrence du piège d'insertion doxygen, rattrapée par le même
contrôle. Comme annoncé.

Suite : simulation 63/63, 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-03 07:21:13 +02:00
parent e7485e76f9
commit a510202ac7
8 changed files with 257 additions and 2 deletions

View File

@ -1,3 +1,39 @@
powersync-energy-plugin-nymea (1.15.2+etm51) trixie; urgency=medium
* URGENT — CONTRADICTION SUPPRIMÉE entre `progressMeasurable` et `regime`. Le premier dit ce
que le MATÉRIEL permet, le second ce que le MOTEUR fait ; seul l'EvAdapter remplissait
`sessionMeasurable`, donc sur toute charge non-VE le premier disait oui et le second non.
L'avertissement de saisie de l'app se déclenchant sur `progressMeasurable == false`, il
restait MUET exactement sur les charges achetées à l'aveugle. `sessionWh` est désormais
alimenté depuis le compteur DÉSIGNÉ pour les charges non-VE.
* LM-1012-d — UN ACCUMULATEUR NE PARIE JAMAIS SUR CE QU'UN PLUGIN FAIT DE SES ÉTATS EN TOMBANT.
Avertissement de l'app intégré AVANT l'écriture, et il était décisif : un compteur qui
disparaît peut publier 0 au lieu de se taire. Mesuré sur la V2C — `currentPower` 4 396 → 0 et
les booléens à false, mais `sessionEnergy` resté à sa dernière valeur. La politique dépend du
plugin, ce qui la rend inutilisable comme garde.
* ET IL N'EXISTE AUCUN SIGNAL DE JOIGNABILITÉ FIABLE — vérifié : `setupComplete` parle du setup
et non du lien, `connected` est maintenu par le plugin (V2C après trois échecs, un compteur
générique peut ne jamais basculer). La garde ne pouvait donc pas reposer là-dessus.
* LA GARDE PORTE SUR LA SÉMANTIQUE DU COMPTEUR, seule chose que le moteur sache sans parier :
un recul sur compteur de SESSION est une session neuve (recapture) ; sur compteur de VIE,
c'est une anomalie (GEL, jamais de recapture). Sans elle : base reprise à 0 pendant
l'absence, retour du compteur avec 10 kWh réels, `deliveredWh` saute à 10 000 en un cycle et
l'obligation se déclare TENUE sans que rien n'ait été livré. R6 ne l'attrape pas — elle
n'interdit ECO_FLOOR_MET que sous `unmeasurable`, et ce chemin-là est MESURÉ. Contre-épreuve :
garde retirée, le régime reste « measured » pendant la panne.
* `tools/audit-controles.py` — écrit en tête qu'il NE TRANCHE PAS et ne doit jamais trancher :
au balayage, neuf des dix identités relevées contrôlaient réellement. Un outil qui aurait
rendu un verdict aurait supprimé neuf contrôles utiles.
* AGENTS.md — la diversité du mock n'est pas un confort de test, c'est une BORNE SUPÉRIEURE sur
ce que la suite peut prouver, et elle se fixe en écrivant les classes, pas les tests.
Troisième occurrence documentée.
* Quatrième occurrence du piège d'insertion doxygen, rattrapée par le même contrôle — comme
annoncé.
* Suite : simulation 63/63, loadmodel 22/22, charging 48/48 identique à la référence,
spotmarket 32/32, doxygen 0 avertissement.
-- Patrick Schurig <etm.schurig@gmail.com> Thu, 03 Sep 2026 15:00:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm50) trixie; urgency=medium
* BALAYAGE — « qu'est-ce que ce contrôle aurait attrapé s'il avait échoué ? » passée à toute la

View File

@ -585,7 +585,25 @@ EcoProgress EnergyArbitrator::ecoProgress(const LoadContext &lc, const QDateTime
// 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) {
} else if (lc.telemetry.sessionCumulative
&& sessionWh < m_ecoBaseWh.value(lc.id) - BruitAvancementWh) {
// COMPTEUR DE VIE QUI RECULE — impossible physiquement, donc la valeur ne vaut rien :
// zéro forgé à la perte du lien, ou compteur remplacé. On NE RECAPTURE PAS, et on gèle
// l'avancement le temps de l'anomalie, comme si la mesure avait disparu.
//
// Recapturer ici serait le chemin du faux « tenue » : base reprise à 0 pendant
// l'absence, retour du compteur avec son cumul réel, et l'obligation se déclarerait
// tenue sans que rien n'ait été livré. R6 ne l'attraperait pas — elle n'interdit
// ECO_FLOOR_MET que sous `unmeasurable`, et ce chemin-là est MESURÉ.
qCInfo(dcNymeaEnergy()) << "[Arbitre] Avancement éco" << lc.id
<< "— compteur de VIE en recul ("
<< m_ecoBaseWh.value(lc.id) << "→" << sessionWh
<< " Wh) : valeur ignorée, avancement gelé.";
p.regime = QString::fromLatin1(ProgressRegime::Unmeasurable);
p.purchasedWh = m_ecoAchatWh.value(lc.id, 0.0);
return p; // le tronçon reste OUVERT sur sa dernière valeur saine
} else if (!lc.telemetry.sessionCumulative
&& 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.
// Ce n'est PAS la détection de remise à zéro de la borne — celle-là vit dans le plugin,
@ -1030,6 +1048,40 @@ bool EnergyArbitrator::evSurplusPlannedByWaterfall(EvCharger *evCharger) const
return evParticipatesInWaterfall(evCharger);
}
void EnergyArbitrator::alimenterAvancement(LoadContext &lc) const
{
// CONTRADICTION CORRIGÉE (2026-09-03, remontée par l'app). `progressMeasurable` dit ce que
// le MATÉRIEL permet ; `regime` dit ce que le MOTEUR fait. Seul l'EvAdapter remplissait
// `sessionMeasurable`, si bien que toute charge non-VE était `unmeasurable` alors que sa
// configuration annonçait l'inverse.
//
// Ce n'est pas qu'une incohérence d'affichage : l'avertissement de saisie de l'app se
// déclenche sur `progressMeasurable == false`, donc il restait MUET exactement sur les
// charges achetées à l'aveugle — celles dont l'obligation ne pouvait pas être suivie.
if (lc.telemetry.sessionMeasurable || !m_tm || !m_loadConfigStore)
return; // déjà renseigné par l'adaptateur (VE), ou rien pour le résoudre
for (const LoadConfig &c : m_loadConfigStore->configs()) {
if (c.id() != lc.id || c.meterThingId().isEmpty())
continue;
Thing *compteur = m_tm->findConfiguredThing(ThingId(c.meterThingId()));
// Il faut un CUMUL, pas une puissance : `currentPower` ne dit rien de ce qui a été
// livré. C'est la même exigence que celle publiée en `progressUnmeasurableCause`, et
// elle doit être la MÊME des deux côtés — sinon la contradiction se déplace au lieu de
// disparaître.
if (compteur && compteur->thingClass().hasStateType("totalEnergyConsumed")) {
lc.telemetry.sessionMeasurable = true;
// Compteur de VIE : un recul y est une ANOMALIE, jamais une nouvelle session.
lc.telemetry.sessionCumulative = true;
// Compteur de VIE, pas de session — et c'est sans importance : l'avancement est une
// DIFFÉRENCE depuis une base capturée à l'ouverture de l'obligation (LM-1012-b).
// Un cumul monotone est même plus sûr qu'un compteur de session, qui recule.
lc.telemetry.sessionWh = compteur->stateValue("totalEnergyConsumed").toDouble() * 1000.0;
}
return;
}
}
void EnergyArbitrator::qualifierRefus(LoadContext &lc, const QDateTime &now) const
{
const QDateTime depuis = m_divergenceDepuis.value(lc.id);
@ -1142,6 +1194,7 @@ SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const
continue;
}
LoadContext lcq = it.value()->toLoadContext(now);
alimenterAvancement(lcq);
qualifierRefus(lcq, now);
ctx.loads.append(lcq);
}
@ -1171,7 +1224,8 @@ SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const
lc.telemetry.lockMinPowerW = 0; // rien d'engagé : rien à geler au budget
lc.telemetry.lockMaxPowerW = -1;
LoadContext lcq = lc;
qualifierRefus(lcq, now);
alimenterAvancement(lcq);
qualifierRefus(lcq, now);
ctx.loads.append(lcq);
}
}
@ -1180,6 +1234,7 @@ SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const
// sg-ready) — une seule table, toutes construites depuis la configuration persistée. ---
for (auto it = m_loadAdapters.constBegin(); it != m_loadAdapters.constEnd(); ++it) {
LoadContext lcq = it.value()->toLoadContext(now);
alimenterAvancement(lcq);
qualifierRefus(lcq, now);
ctx.loads.append(lcq);
}

View File

@ -705,6 +705,23 @@ private:
*/
void qualifierRefus(LoadContext &lc, const QDateTime &now) const;
/*!
* \brief Alimente l'avancement d'une charge NON-VE depuis son compteur désigné.
* \param[in,out] lc Charge du cycle.
*
* \par La contradiction que cette méthode supprime
* \c progressMeasurable dit ce que le MATÉRIEL permet ; \c regime dit ce que le MOTEUR fait.
* Seul l'\c EvAdapter remplissait \c sessionMeasurable — donc sur toute charge non-VE le
* premier disait oui et le second non. L'avertissement de saisie de l'app se déclenchant sur
* \c progressMeasurable, il restait **muet exactement sur les charges achetées à l'aveugle**.
*
* \note Il faut un CUMUL (\c totalEnergyConsumed), pas une puissance : \c currentPower ne dit
* rien de ce qui a été livré. Même exigence que \c progressUnmeasurableCause, et elle doit
* être la même des deux côtés, sinon la contradiction se déplace au lieu de disparaître.
*/
void alimenterAvancement(LoadContext &lc) const;
/*!
* \brief Publie l'écart entre ce qu'on a COMMANDÉ et ce qui est MESURÉ.

View File

@ -111,6 +111,34 @@ struct LoadContextTelemetry {
*/
bool sessionMeasurable = false;
/*!
* \brief La source d'avancement est-elle un compteur de VIE (monotone) ou de SESSION ?
*
* \par Pourquoi cette distinction est la garde, et pas la joignabilité
* Un recul ne veut pas dire la même chose selon la source, et **c'est la seule chose que le
* moteur sache sans parier sur le plugin** :
*
* | Source | Un recul signifie | Geste |
* |---|---|---|
* | session (\c sessionEnergy) | une **nouvelle session** s'est ouverte | recapturer la base |
* | vie (\c totalEnergyConsumed) | la valeur n'est **pas fiable** — zéro forgé, compteur perdu | **geler**, ne jamais recapturer |
*
* **Il n'existe pas de signal de joignabilité sur lequel s'appuyer** : `setupComplete` parle
* du setup et non du lien, et `connected` est maintenu par le plugin — la V2C bascule après
* trois échecs, un compteur Modbus peut avoir un autre seuil, un compteur générique peut ne
* jamais basculer. **Un accumulateur ne doit pas parier sur ce que chaque plugin fait de ses
* états en tombant** : mesuré le 2026-08-30, la V2C a forgé un zéro sur \c currentPower et
* ses booléens, mais a laissé \c sessionEnergy à sa dernière valeur. La politique dépend du
* plugin, ce qui la rend inutilisable comme garde.
*
* \note Ce que ce champ évite, chiffré : base reprise à 0 pendant l'absence, retour du
* compteur avec ses 10 kWh réels, et \c deliveredWh saute à 10 000 en un cycle —
* l'obligation se déclare **tenue sans que rien n'ait été livré**. Et `R6` ne s'y oppose
* pas : elle n'interdit \c ECO_FLOOR_MET que sous \c unmeasurable. Le chemin **mesuré**
* est donc le seul par lequel un faux « tenue » peut sortir.
*/
bool sessionCumulative = false;
/*!
* \brief Énergie chargée dans la session courante (Wh) — ACCUMULATEUR, pas compteur brut.
*

View File

@ -1014,6 +1014,31 @@ part** (`measuredGaps`). Redater à chaque retour effacerait l'histoire au lieu
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-1012-d — un accumulateur ne parie JAMAIS sur ce qu'un plugin fait de ses états en tombant.**
*(2026-09-03, avertissement de l'app intégré AVANT l'écriture.)*
Un compteur qui disparaît peut **publier 0 au lieu de se taire**. Mesuré sur la V2C le
2026-08-30 : `currentPower` 4 396 → 0 et les booléens à `false` à la perte du lien — mais
`sessionEnergy` **resté à sa dernière valeur**. **La politique dépend du plugin, ce qui la rend
inutilisable comme garde.**
**Et il n'existe aucun signal de joignabilité sur lequel s'appuyer** : `setupComplete` parle du
setup et non du lien ; `connected` est maintenu par le plugin — la V2C bascule après trois échecs,
un compteur Modbus peut avoir un autre seuil, un compteur générique peut ne jamais basculer.
**La garde porte donc sur ce que le moteur sait sans parier : la SÉMANTIQUE du compteur.**
| Source | Un recul signifie | Geste |
|---|---|---|
| **session** (`sessionEnergy`) | une nouvelle session s'est ouverte | recapturer la base |
| **vie** (`totalEnergyConsumed`) | la valeur n'est pas fiable | **geler**, ne jamais recapturer |
> **Ce que cela évite, chiffré.** Base reprise à 0 pendant l'absence, retour du compteur avec ses
> 10 kWh réels, et `deliveredWh` saute à 10 000 en un cycle : **l'obligation se déclare tenue sans
> que rien n'ait été livré**. Et `R6` ne s'y oppose pas — elle n'interdit `ECO_FLOOR_MET` que sous
> `unmeasurable`. **Le chemin mesuré est le seul par lequel un faux « tenue » peut sortir**, et
> c'est précisément celui qu'ouvre l'alimentation de `sessionWh` depuis un compteur.
**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

@ -7551,6 +7551,89 @@ void Simulation::testHealthyButUnmeasuredDiffersFromFaulty()
#endif
}
/*!
* \brief Un compteur de VIE qui recule ne rebase JAMAIS l'avancement.
*
* \par Le chemin du faux « tenue », et il est le seul
* Un compteur qui disparaît peut publier **0 au lieu de se taire** — mesuré sur la V2C le
* 2026-08-30 : `currentPower` 4 396 → 0 et les booléens à `false` à la perte du lien. Si la base
* se reprenait sur ce zéro, le retour du compteur avec son cumul réel — 10 kWh sur l'ECS du banc
* — ferait sauter `deliveredWh` à 10 000 en un cycle : **l'obligation se déclarerait tenue sans
* que rien n'ait été livré**.
*
* Et `R6` ne s'y oppose pas : elle n'interdit `ECO_FLOOR_MET` que sous `unmeasurable`. Le chemin
* **mesuré** est donc le seul par lequel un faux « tenue » peut sortir — c'est-à-dire exactement
* celui qu'ouvre l'alimentation de `sessionWh` depuis un compteur.
*
* \par Pourquoi la garde porte sur la SÉMANTIQUE et non sur la joignabilité
* Il n'existe **aucun signal de joignabilité fiable** : `setupComplete` parle du setup et non du
* lien, et `connected` est maintenu par le plugin — la V2C bascule après trois échecs, un
* compteur générique peut ne jamais basculer. **Un accumulateur ne doit pas parier sur ce que
* chaque plugin fait de ses états en tombant** : la même V2C a forgé un zéro sur `currentPower`
* et laissé `sessionEnergy` à sa dernière valeur.
*
* Ce que le moteur sait sans parier : **un compteur de VIE ne recule pas.** Un recul y est une
* anomalie ; sur un compteur de SESSION, c'est une session neuve. Deux sources, deux gestes.
*/
void Simulation::testLifetimeCounterDropNeverRebasesTheProgress()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arb);
const QDateTime t0 = utcDateTime(QDate(2026, 9, 3), QTime(13, 0, 0));
LoadContext ecs;
ecs.id = "ecs-compteur-vie";
ecs.needs.minEnergyWhPerDay = 4000;
ecs.needs.dailyDeadline = "07:00";
ecs.telemetry.sessionMeasurable = true;
ecs.telemetry.sessionCumulative = true; // totalEnergyConsumed : monotone par nature
ecs.telemetry.sessionWh = 9000; // le compteur en est à 9 kWh de vie
QCOMPARE(qRound(arb->ecoProgress(ecs, t0).deliveredWh), 0); // base prise à 9000
ecs.telemetry.sessionWh = 9500;
QCOMPARE(qRound(arb->ecoProgress(ecs, t0).deliveredWh), 500); // 500 Wh livrés
// LE LIEN TOMBE et le plugin publie ZÉRO au lieu de se taire.
ecs.telemetry.sessionWh = 0;
const EcoProgress pendant = arb->ecoProgress(ecs, t0);
// On GÈLE : la valeur ne vaut rien, donc on ne conclut rien. Pas d'avancement publié, pas
// de recapture — le tronçon reste ouvert sur sa dernière valeur saine.
QCOMPARE(pendant.regime, QString(ProgressRegime::Unmeasurable));
QVERIFY2(!pendant.delivered, "sur une valeur non fiable, on ne publie pas d'avancement");
// LE COMPTEUR REVIENT avec son cumul réel. C'est ici que le faux « tenue » sortirait si la
// base avait été reprise à 0 : 10 000 Wh d'un coup sur une obligation de 4 000.
ecs.telemetry.sessionWh = 10000;
const EcoProgress apresRetour = arb->ecoProgress(ecs, t0);
QCOMPARE(apresRetour.regime, QString(ProgressRegime::Measured));
// 10 000 − 9 000 (base intacte) = 1 000 Wh. PAS 10 000.
QCOMPARE(qRound(apresRetour.deliveredWh), 1000);
QVERIFY2(apresRetour.deliveredWh < ecs.needs.minEnergyWhPerDay,
"l'obligation ne doit PAS se déclarer tenue sur un retour de compteur");
// Et la distinction tient : sur un compteur de SESSION, le même recul EST une session neuve.
LoadContext borne;
borne.id = "borne-session";
borne.needs.minEnergyWhPerDay = 4000;
borne.needs.dailyDeadline = "07:00";
borne.telemetry.sessionMeasurable = true;
borne.telemetry.sessionCumulative = false; // sessionEnergy : remis à zéro par session
borne.telemetry.sessionWh = 9000;
QCOMPARE(qRound(arb->ecoProgress(borne, t0).deliveredWh), 0);
borne.telemetry.sessionWh = 120; // nouvelle session
const EcoProgress neuve = arb->ecoProgress(borne, t0);
QCOMPARE(neuve.regime, QString(ProgressRegime::Measured)); // pas d'anomalie : on recapture
QCOMPARE(qRound(neuve.deliveredWh), 0);
#endif
}
void Simulation::testCountsSumToTarget()
{
#ifndef ETM_ARBITRATOR

View File

@ -200,6 +200,7 @@ private slots:
void testCommandDivergenceIsPublishedNotDiagnosed();
void testProgressMeasurabilityIsDeclaredAtConfigTime();
void testHealthyButUnmeasuredDiffersFromFaulty();
void testLifetimeCounterDropNeverRebasesTheProgress();
void testRecreditIsALoanAgainstObedience();
void testARefusedStateIsDistinguishableFromAnApplied();
void testL4ShedsAPacAndSaysItForcedTheLock();

View File

@ -19,6 +19,16 @@ Deux motifs, tous deux rencontrés en vrai :
`remainingSurplusW`, filé le long de la cascade, avec `allocatedW` accumulé en trois sites
distincts. Le motif signale, il ne juge pas — c'est à la lecture de trancher.
⚠️ CET OUTIL NE TRANCHE PAS, ET NE DOIT JAMAIS TRANCHER.
Il SIGNALE un motif ; c'est la LECTURE qui décide. Au balayage du 2026-09-03, il a relevé dix
identités auto-référentielles : NEUF contrôlaient réellement quelque chose, une seule était vaine.
Un outil qui aurait rendu un verdict aurait supprimé neuf contrôles utiles.
Le prochain qui lira ce fichier sera tenté d'automatiser le verdict. Ne le fais pas : la question
« qu'aurait-il attrapé s'il avait échoué ? » demande de savoir d'où viennent les deux membres, et
cette connaissance n'est pas dans le texte du test.
Cet outil ne remplace pas la question, il la pose là où elle se pose souvent.
"""
import re