feat: remainingWh sous unmeasurable = la cible intacte, et la mémoire de commande

CONTRAT. Sous `unmeasurable`, remainingWh == targetWh : rien n'est
soustrait, parce qu'on ne sait rien de ce qui a été livré. 2 000 Wh restants
sur une cible de 2 000 Wh signifie « rien de mesurable », pas « rien livré » —
deux lectures qui commandent des phrases d'écran opposées, et l'écran ne peut
trancher que si le contrat le dit.

Soustraire l'intégration interne ferait franchir la frontière à une
estimation sous l'allure d'un fait : le chemin de carBatteryLevel, refusé en
LM-1009 D3. Et que `regime` voyage dans les mêmes params ne suffirait pas —
un client lisant remainingWh sans regarder regime obtiendrait un nombre
calculé, sans rien pour le lui dire.

Identité épinglée sous `measured` : deliveredWh + remainingWh == targetWh.
Troisième contrôle du même genre que Σ counts == targetW. Elle ne tient QUE
sous measured ; sous unmeasurable c'est remainingWh == targetWh qui prend le
relais, et c'est le régime qui dit laquelle appliquer.

Une honnêteté sur la portée de ce test : la contre-épreuve naïve — soustraire
deliveredWh sous unmeasurable — NE MORD PAS, puisque deliveredWh y vaut 0.
L'assertion est donc un corollaire de `delivered == false`, pas une garde
indépendante. La garde réelle est R6, vérifiée mordante au bon niveau :
publier l'estimation comme un fait fait sortir ECO_FLOOR_MET sous
unmeasurable, ce qui est exactement la violation que R6 interdit.

Commande perdue, étape 1 : l'EvAdapter retient enfin sa dernière commande.
Les adaptateurs en watts la gardaient déjà — l'écrêtage de verrou l'exige —
mais la borne, pilotée par le proxy amont, n'avait jamais eu à se relire.
C'est précisément là que le relevé porte : sans ce souvenir, l'écart entre ce
qu'on a commandé et ce que la borne fait n'est pas calculable. La commande
ÉCRÊTÉE est retenue, jamais la demande : comparer la demande ferait voir un
écart là où l'adaptateur a fait son travail. Publication seule, comportement
constant.

Suite : simulation 58/58, loadmodel 22/22, charging 48/48 identique à la
référence ligne à ligne, 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 07:26:36 +02:00
parent 4d73ff2482
commit eaf06cfb75
5 changed files with 127 additions and 0 deletions

View File

@ -956,6 +956,34 @@ le **libellé de la charge n'y figure jamais** (le client le résout via `GetLoa
| `ECO_FLOOR_GRID` *(§10)* | `gridW`, `remainingWh`, `targetWh`, `remainingS`, `regime`, `atMaximum` | L'obligation éco est servie **en achetant**. Les six champs donnent la **trajectoire**, pas l'instant : la cadence de l'étalement fait un mur — 2 kWh restants valent 333 W à six heures de l'échéance et **le plafond dès seize minutes**. « 1000 W achetés » seul ne permet pas de prévoir la facture ; avec `remainingWh` et `remainingS`, si. `atMaximum` dit que l'étalement a cessé (point de non-retour), `regime` sur quoi on achète |
| `ECO_FLOOR_MET` *(§10)* | `deliveredWh`, `targetWh` | L'obligation est **tenue**. **INTERDIT sous `regime: unmeasurable`** (R6) : dire « tenue » suppose de savoir ce qui a été livré. Une échéance qui cesse d'être mesurable et continue de s'afficher comme tenue est pire qu'une échéance absente — elle rassure |
| `ECO_FLOOR_MISSED` *(§10)* | `targetWh`, `cause`, `o:missingWh` | Une période d'obligation s'est close **sans avoir été tenue**. `cause` ∈ `drawCap` \| `loadCapacity` \| `unmeasurable` \| `unavailable` — **trois causes, trois gestes** : revoir l'abonnement, lire la fiche technique de la charge, ou regarder ce que la borne publie. Sans elle, l'écran n'affiche qu'un reproche sans recours. `missingWh` est **absent** sous `unmeasurable` : on ne sait pas ce qui a été livré, donc pas ce qui manquait — un zéro se lirait « il ne manquait rien ». Motif d'ÉVÉNEMENT : publié le seul cycle de la bascule |
> ### `remainingWh` sous `unmeasurable` : **la cible INTACTE**, et il faut le lire ainsi
>
> Sous `regime: unmeasurable`, **`remainingWh == targetWh`**. Rien n'est soustrait, parce qu'on
> ne sait rien de ce qui a été livré.
>
> **2 000 Wh restants sur une cible de 2 000 Wh signifie « rien de mesurable », PAS « rien
> livré ».** Les deux lectures commandent des phrases d'écran opposées — l'une dit qu'il n'y a
> aucune information, l'autre accuse une charge de n'avoir rien reçu — et l'écran ne peut
> trancher que si le contrat le dit. Il le dit ici.
>
> **Pourquoi rien n'est soustrait.** Le moteur dispose bien d'une intégration interne. La
> soustraire ferait franchir la frontière à une **estimation sous l'allure d'un fait** : le
> chemin exact de `carBatteryLevel`, refusé en `LM-1009` D3. Et que `regime` voyage dans les
> mêmes params ne suffirait pas — un client qui lit `remainingWh` sans regarder `regime`
> obtiendrait un nombre calculé, sans rien pour le lui dire.
>
> **Identité vérifiable sous `measured`** — troisième contrôle du même genre que
> `Σ counts == targetW` et `authorisedW − committedW == remainingW` :
>
> ```
> progress.deliveredWh + params.remainingWh == params.targetWh
> ```
>
> Elle ne tient QUE sous `measured` : sous `unmeasurable`, `deliveredWh` est absent, et c'est
> l'égalité `remainingWh == targetWh` qui prend le relais. Un client peut donc vérifier la
> cohérence dans les deux régimes — mais pas avec la même formule, et c'est le régime qui dit
> laquelle appliquer.
| `LOCK_MIN_STATE_HOLD` | `state` | Verrou `minStateHold` : état PAC maintenu (protection court-cycling) |
| `SG_FORCED` | `budgetW`, `estimatedW` | PAC en état 4 (forcé) |
| `SG_RECOMMENDED` | `budgetW`, `estimatedW` | PAC en état 3 (recommandation) |

View File

@ -1,3 +1,34 @@
powersync-energy-plugin-nymea (1.15.2+etm44) trixie; urgency=medium
* CONTRAT — `remainingWh` sous `unmeasurable` vaut LA CIBLE INTACTE : `remainingWh ==
targetWh`. 2 000 Wh restants sur une cible de 2 000 Wh signifie « rien de mesurable », PAS
« rien livré ». Les deux lectures commandent des phrases d'écran opposées, et l'écran ne peut
trancher que si le contrat le dit. Écrit dans INTERFACE.md.
* Rien n'est soustrait parce que soustraire l'intégration interne ferait franchir la frontière
à une ESTIMATION sous l'allure d'un fait — le chemin de `carBatteryLevel`, refusé en
LM-1009 D3. Que `regime` voyage dans les mêmes params ne suffirait pas : un client lisant
`remainingWh` sans regarder `regime` obtiendrait un nombre calculé sans rien pour le lui dire.
* IDENTITÉ ÉPINGLÉE sous `measured` : progress.deliveredWh + params.remainingWh ==
params.targetWh. Troisième contrôle vérifiable, après Σ counts == targetW et
authorisedW − committedW == remainingW. Elle ne tient QUE sous `measured` — sous
`unmeasurable`, c'est `remainingWh == targetWh` qui prend le relais, et c'est le régime qui
dit laquelle appliquer.
* HONNÊTETÉ SUR LA PORTÉE DE CE TEST : la contre-épreuve naïve (soustraire `deliveredWh` sous
unmeasurable) NE MORD PAS, puisque `deliveredWh` y vaut 0. L'assertion est donc un
COROLLAIRE de `delivered == false`, pas une garde indépendante. La garde réelle est R6, et
elle a été vérifiée mordante au bon niveau : publier l'estimation comme un fait fait sortir
ECO_FLOOR_MET sous unmeasurable.
* Commande perdue, étape 1 — MÉMOIRE de la commande dans EvAdapter. Les adaptateurs en watts
la gardaient déjà (l'écrêtage de verrou l'exige) ; la borne, pilotée par le proxy amont,
n'avait jamais eu à se relire. C'est exactement là que le relevé du 2026-08-31 porte : sans
ce souvenir, l'écart commandé/constaté n'est pas CALCULABLE. Publication seule, comportement
constant. La commande ÉCRÊTÉE est retenue, jamais la demande — comparer la demande ferait
voir un écart là où l'adaptateur a fait son travail.
* Suite : simulation 58/58, loadmodel 22/22, charging 48/48 identique à la référence,
spotmarket 32/32, doxygen 0 avertissement.
-- Patrick Schurig <etm.schurig@gmail.com> Tue, 01 Sep 2026 08:30:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm43) trixie; urgency=medium
* DÉPLOYÉ SUR .75 le 2026-09-01, version relue sur la box et `nymead` actif. Les quatre motifs

View File

@ -224,6 +224,19 @@ LoadAction EvAdapter::applyAction(const LoadAction &action, const QDateTime &now
m_parent->doExecuteChargingAction(m_charger, ca, now); // now = temps de cycle (injectable)
m_lastActionAt = now;
// §Commande perdue, étape 1 — MÉMOIRE de ce qu'on vient de commander. Les adaptateurs de
// charges en watts la gardent depuis toujours (ils en ont besoin pour l'écrêtage de
// verrou) ; la borne, pilotée par le proxy amont, n'a jamais eu à se relire. C'est
// exactement là que le constat du 2026-08-31 porte : sans ce souvenir, l'écart entre ce
// qu'on a commandé et ce que la borne fait n'est pas CALCULABLE.
//
// On retient la commande ÉCRÊTÉE, pas la demande : c'est elle qui est partie au matériel,
// et c'est donc elle que la borne devrait refléter. Comparer la demande ferait voir un
// écart là où l'adaptateur a simplement fait son travail.
m_commandeA = enclencher ? clampedA : 0;
m_commandePhases = phases;
m_commandeValide = true;
LoadAction applied = action;
applied.currentA = clampedA;
applied.phaseCount = phases;

View File

@ -121,7 +121,37 @@ public:
*/
EvCharger *evCharger() const { return m_charger; }
/*!
* \brief Dernière commande envoyée au matériel par cet adaptateur.
* \param[out] ampere Courant commandé (A), 0 si la charge a été coupée.
* \param[out] phases Nombre de phases commandé.
* \return Faux si AUCUNE commande n'est jamais partie — l'écart est alors sans objet, et
* c'est le cas normal d'une borne en mode manuel que l'arbitre ne pilote pas.
*/
bool derniereCommande(uint &ampere, uint &phases) const
{ ampere = m_commandeA; phases = m_commandePhases; return m_commandeValide; }
private:
/*!
* \brief Dernière commande RÉELLEMENT envoyée au matériel — écrêtée, pas demandée.
*
* \par Pourquoi ce souvenir n'existait pas
* Les adaptateurs de charges en watts gardent leur consigne parce que l'écrêtage de verrou
* l'exige. La borne, pilotée par le proxy amont, n'a jamais eu à se relire — et c'est
* précisément là que le relevé du 2026-08-31 mord : après une coupure Modbus, la borne est
* restée sur une consigne d'arbitre pendant que le proxy en écrivait une autre, sans que
* l'écart soit calculable par personne.
*
* \note La commande ÉCRÊTÉE, jamais la demande : c'est elle qui est partie au matériel.
* Comparer la demande ferait voir un écart là où l'adaptateur a fait son travail.
*/
uint m_commandeA = 0;
uint m_commandePhases = 0;
//! Faux tant qu'aucune commande n'est partie. L'absence de commande n'est pas une commande
//! à zéro : sans ce booléen, une borne jamais pilotée paraîtrait divergente dès qu'elle
//! consomme, ce qui est le cas NORMAL du mode manuel.
bool m_commandeValide = false;
EvCharger *m_charger;
EnergyArbitrator *m_parent;
QDateTime m_lastActionAt;

View File

@ -6912,6 +6912,31 @@ void Simulation::testEcoPassBuysAndSaysTheTrajectory()
QCOMPARE(inconnu.levels.at(0).progress.regime, QString(ProgressRegime::Unmeasurable));
QVERIFY2(!inconnu.levels.at(0).progress.delivered,
"sous unmeasurable, deliveredWh n'a pas de sens et ne sera pas publié");
// 7. SOUS `unmeasurable`, `remainingWh` VAUT LA CIBLE INTACTE. Rien n'est soustrait, parce
// qu'on ne sait rien de ce qui a été livré. Soustraire une intégration interne ferait
// franchir la frontière à une ESTIMATION sous l'allure d'un fait — le chemin exact de
// `carBatteryLevel`, refusé en LM-1009 D3.
//
// Et que `regime` voyage dans les mêmes params ne suffirait pas : un client qui lit
// `remainingWh` sans regarder `regime` obtiendrait un nombre calculé, sans rien pour le
// lui dire. « 2000 Wh restants sur 2000 » doit signifier « rien de mesurable », pas
// « rien livré » — et l'écran ne peut trancher que si le contrat le dit.
const QVariantMap pu = inconnu.levels.at(0).reason.params;
QCOMPARE(pu.value("remainingWh").toInt(), pu.value("targetWh").toInt());
QCOMPARE(pu.value("remainingWh").toInt(), 2000);
// 8. IDENTITÉ SOUS `measured` — troisième contrôle vérifiable, au même titre que
// Σ counts == targetW et authorisedW − committedW == remainingW. C'est ce qui permet à
// un client de constater la cohérence au lieu de la supposer.
const LoadAction partiel = planifier(deuxHeuresAvant, true, 800, 5000);
const LoadAction suite = planifier(deuxHeuresAvant, true, 1400, 5000);
for (const LoadAction &act : {partiel, suite}) {
const QVariantMap pm = act.levels.at(0).reason.params;
QVERIFY2(act.levels.at(0).progress.delivered, "régime mesuré attendu");
QCOMPARE(qRound(act.levels.at(0).progress.deliveredWh) + pm.value("remainingWh").toInt(),
pm.value("targetWh").toInt());
}
#endif
}