[upstream-fix] un verdict de test ne doit pas dépendre de l'heure

Six appels à `update(QDateTime::currentDateTime())` manquaient la garde
ENERGY_SIMULATION que leurs voisins ont (l.124, l.288) :
setPhasePowerLimit, setAcquisitionTolerance, et trois dans
onActionExecuted — qui partent sur toute action manuelle appliquée à une
borne.

Un cycle d'arbitrage complet partait donc à l'horloge MURALE au milieu
d'une suite qui injecte un temps simulé. Les deux horloges se mélangeaient
dans les verrous : l'armement paresseux (ECS-412) figeait m_lastSwitch sur
l'heure qu'il était, et un scénario daté en dur se déroulait « avant » son
propre armement — elapsed négatif, verrou jamais ouvert.

Ce n'est pas un défaut de production : il n'y a là-bas qu'une seule horloge.
C'est une faille de REPRODUCTIBILITÉ, et elle touchait tout test de
simulation réglant une limite de phase, une tolérance d'acquisition, ou
exécutant une action manuelle sur une borne.

PREUVE DU CORRECTIF : la garde rendue, testL4ShedsAPacAndSaysItForcedTheLock
repasse avec une DATE FIXE, trois fois sur trois — ce qu'il ne pouvait pas
faire. L'ancrage sur l'horloge réelle, posé la veille faute d'avoir la
cause, était un contournement ; il est retiré, et la date fixe revient parce
qu'elle rend les journaux reproductibles.

tools/check-simulation-clock.py, câblé en 4/4 de ci-quality.sh, et vérifié
mordant : garde retirée, il nomme la ligne exacte. Il a trouvé trois des six
déclencheurs dès son premier passage.

Pourquoi une vérification STATIQUE et pas un test : la suite ne peut pas
dire d'elle-même que son verdict dépend de l'heure, puisque c'est le verdict
qui ment. Le défaut n'a été trouvé que parce qu'il échouait cinq fois sur
cinq à code identique — une fenêtre de nuisance plus étroite ne basculerait
qu'une fois sur vingt, passerait pour une fragilité de temporisation, et
serait relancée jusqu'à ce qu'elle passe. C'est la famille de la semaine
sous une forme de plus : après l'instrument dont le silence ressemble à un
résultat, l'instrument dont le VERDICT ment selon l'heure.

Suite complète : simulation 54/54, charging 48/48 identique à la référence
ligne à ligne, loadmodel 21/21, spotmarket 32/32, doxygen 0 avertissement.
Aucun test ne dépendait de ces cycles.

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-08-31 00:10:55 +02:00
parent 9802d409b1
commit 0d2b6728ae
6 changed files with 190 additions and 25 deletions

View File

@ -1,3 +1,35 @@
powersync-energy-plugin-nymea (1.15.2+etm38) trixie; urgency=medium
* §10 étapes A1 et A2 — décider et émettre se séparent, à comportement CONSTANT. La chaîne de
sélection du motif devient une fonction pure (`choisirMotif`), puis `decideSetpoint()` rend
une CIBLE dont `emitSetpointAction()` forme la commande. Tant que les deux étaient le même
geste, une seconde passe était littéralement inexprimable.
* La contrainte du lot est tenue et vérifiée sur le fichier produit : vouluW capturé AVANT
l'écrêtage de verrou (ECS-309), écrêtage AVANT le décrément du budget (ECS-306), les deux
dans la phase de décision. Un déplacement d'un cran publierait des motifs faux sans qu'aucun
nombre ne bouge. Remaniement fait par ALIAS DE RÉFÉRENCE, pour que les vingt lignes
d'affectation ne soient pas retouchées : ce qui n'est pas réécrit ne peut pas dériver.
* [reproductibilité] UN VERDICT DE TEST NE DOIT PAS DÉPENDRE DE L'HEURE. Six appels à
`update(QDateTime::currentDateTime())` manquaient la garde ENERGY_SIMULATION que leurs
voisins ont : setPhasePowerLimit, setAcquisitionTolerance, et trois dans onActionExecuted.
Un cycle d'arbitrage complet partait donc à l'horloge MURALE au milieu d'une suite qui
injecte un temps simulé ; l'armement paresseux (ECS-412) figeait m_lastSwitch sur l'heure
qu'il était, et un scénario daté en dur se déroulait « avant » son propre armement.
* PREUVE DU CORRECTIF : la garde rendue, testL4ShedsAPacAndSaysItForcedTheLock repasse avec
une DATE FIXE, trois fois sur trois — ce qu'il ne pouvait pas faire. L'ancrage sur l'horloge
réelle était un contournement et il est retiré.
* `tools/check-simulation-clock.py`, câblé en 4/4 de ci-quality.sh, et VÉRIFIÉ MORDANT.
Pourquoi une vérification statique et pas un test : la suite ne peut pas dire d'elle-même
que son verdict dépend de l'heure, puisque c'est le verdict qui ment. Le défaut n'a été
trouvé que parce qu'il échouait CINQ FOIS SUR CINQ — une fenêtre plus étroite ne basculerait
qu'une fois sur vingt, passerait pour une fragilité de temporisation, et serait relancée
jusqu'à ce qu'elle passe. La vérification a d'ailleurs trouvé trois des six déclencheurs
dès son premier passage.
* Suite complète : simulation 54/54, charging 48/48 identique à la référence ligne à ligne,
loadmodel 21/21, spotmarket 32/32, doxygen 0 avertissement.
-- Patrick Schurig <etm.schurig@gmail.com> Mon, 31 Aug 2026 01:10:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm37) trixie; urgency=medium
* Délestage §4 — LA PROTECTION DE SURCHARGE NE SE LIMITE PLUS AUX BORNES. Une PAC ou un

View File

@ -103,9 +103,30 @@ que ce qui le touche**. Le commentaire dit à quelle condition ce choix devient
l'arbitrage ne peut plus tourner à l'horloge murale dans la suite, une date fixe redevient
préférable parce qu'elle rend les journaux reproductibles.
**Ce qui reste ouvert, et ne se corrige pas dans un lot de test** : *pourquoi* un cycle
d'arbitrage complet s'exécute-t-il à l'horloge murale dans une suite compilée avec
`ENERGY_SIMULATION`, alors que les minuteurs et les connexions temps réel y sont explicitement
exclus (`energyarbitrator.cpp:52-75`) ? Un changement d'état de Thing suffit à le déclencher. Ce
n'est **pas** un défaut de production — il n'y a là-bas qu'une seule horloge — mais c'est une
faille de reproductibilité qui touchera **tout test de simulation manipulant un Thing**.
### RÉSOLU le 2026-08-31 — la cause, et six déclencheurs au lieu d'un
**`SmartChargingManager::setPhasePowerLimit()` appelait `update(QDateTime::currentDateTime())`
sans la garde `ENERGY_SIMULATION` que ses voisins ont** (`l.124`, `l.288`). Le test appelle
`SetPhasePowerLimit` : un cycle d'arbitrage complet partait donc à l'heure murale juste avant le
premier cycle simulé. `setAcquisitionTolerance()` avait le même défaut — et
`testCountsSumToTarget` l'appelle.
**Preuve du correctif, et elle est nette** : la garde rendue, le test repasse avec une **date
fixe**, trois fois sur trois — ce qu'il ne pouvait pas faire. L'ancrage sur l'horloge réelle
était un contournement ; il est retiré, et la date fixe revient parce qu'elle rend les journaux
reproductibles.
**La vérification statique a trouvé trois déclencheurs de plus** dès son premier passage :
`onActionExecuted()` en porte trois, qui partent sur toute action manuelle appliquée à une borne
(`power`, `maxChargingCurrent`, `desiredPhaseCount`). Six au total, tous gardés désormais.
> **Pourquoi une vérification STATIQUE, et pas un test.** La suite ne peut pas dire d'elle-même
> que son verdict dépend de l'heure : c'est le verdict lui-même qui ment. Et le défaut n'a été
> trouvé que parce qu'il échouait **cinq fois sur cinq** — un déclencheur dont la fenêtre de
> nuisance serait plus étroite ne basculerait qu'une fois sur vingt, passerait pour une
> fragilité de temporisation, et serait **relancé jusqu'à ce qu'il passe**. C'est la famille de
> la semaine sous une forme de plus : après l'instrument dont le silence ressemble à un
> résultat, l'instrument dont le **verdict** ment selon l'heure.
>
> `tools/check-simulation-clock.py`, câblé en 4/4 de `ci-quality.sh`, et **vérifié mordant** :
> garde retirée, il signale la ligne exacte.

View File

@ -172,7 +172,17 @@ void SmartChargingManager::setPhasePowerLimit(uint phasePowerLimit)
EnergySettings settings;
settings.setValue("phasePowerConsumptionLimit", m_phasePowerConsumptionLimit);
#ifndef ENERGY_SIMULATION
// [ETM] DÉCLENCHEUR RÉEL — exclu en simulation, comme ses voisins (l.124, l.288).
//
// Cet appel manquait la garde, et c'est un défaut de REPRODUCTIBILITÉ, pas de
// production : il lance un cycle d'arbitrage complet à l'horloge MURALE au milieu
// d'une suite qui, elle, injecte un temps simulé. Les deux horloges se mélangent alors
// dans les verrous — l'armement paresseux (ECS-412) fige `m_lastSwitch` sur l'heure
// qu'il est, et un scénario daté en dur se déroule « avant » son propre armement,
// `elapsed` négatif. Le test passe ou échoue SELON L'HEURE DE LA JOURNÉE.
update(QDateTime::currentDateTime());
#endif
}
}
@ -190,7 +200,17 @@ void SmartChargingManager::setAcquisitionTolerance(double acquisitionTolerance)
EnergySettings settings;
settings.setValue("acquisitionTolerance", m_acquisitionTolerance);
#ifndef ENERGY_SIMULATION
// [ETM] DÉCLENCHEUR RÉEL — exclu en simulation, comme ses voisins (l.124, l.288).
//
// Cet appel manquait la garde, et c'est un défaut de REPRODUCTIBILITÉ, pas de
// production : il lance un cycle d'arbitrage complet à l'horloge MURALE au milieu
// d'une suite qui, elle, injecte un temps simulé. Les deux horloges se mélangent alors
// dans les verrous — l'armement paresseux (ECS-412) fige `m_lastSwitch` sur l'heure
// qu'il est, et un scénario daté en dur se déroule « avant » son propre armement,
// `elapsed` négatif. Le test passe ou échoue SELON L'HEURE DE LA JOURNÉE.
update(QDateTime::currentDateTime());
#endif
}
}
@ -1608,21 +1628,30 @@ void SmartChargingManager::onActionExecuted(const Action &action, Thing::ThingEr
bool manualChargingEnabled = action.paramValue(powerActionType.paramTypes().findByName("power").id()).toBool();
qCDebug(dcNymeaEnergy()) << "Manual charging is now" << (manualChargingEnabled ? "enabled": "disabled") << "for" << thing->name();
storeManualChargingParameters(thing->id(), manualChargingEnabled, manualMaxChargingCurrent(thing->id()), manualDesiredPhaseCount(thing->id()));
#ifndef ENERGY_SIMULATION
// [ETM] DÉCLENCHEUR RÉEL — exclu en simulation (cf. check-simulation-clock.py).
update(QDateTime::currentDateTime());
#endif
return;
}
if (action.actionTypeId() == maxChargingActionType.id()) {
uint manualChargingCurrent = qRound(action.paramValue(maxChargingActionType.paramTypes().findByName("maxChargingCurrent").id()).toDouble());
qCDebug(dcNymeaEnergy()) << "Manual charging current set to" << manualChargingCurrent << "for" << thing->name();
storeManualChargingParameters(thing->id(), manualChargingEnabled(thing->id()), manualChargingCurrent, manualDesiredPhaseCount(thing->id()));
#ifndef ENERGY_SIMULATION
// [ETM] DÉCLENCHEUR RÉEL — exclu en simulation (cf. check-simulation-clock.py).
update(QDateTime::currentDateTime());
#endif
return;
}
if (desiredPhaseCountStateType.isValid() && action.actionTypeId() == desiredPhaseCountActionType.id()) {
uint phaseCount = action.paramValue(desiredPhaseCountActionType.paramTypes().findByName("desiredPhaseCount").id()).toUInt();
qCDebug(dcNymeaEnergy()) << "Manual phase count set to" << phaseCount << "for" << thing->name();
storeManualChargingParameters(thing->id(), manualChargingEnabled(thing->id()), manualMaxChargingCurrent(thing->id()), phaseCount);
#ifndef ENERGY_SIMULATION
// [ETM] DÉCLENCHEUR RÉEL — exclu en simulation (cf. check-simulation-clock.py).
update(QDateTime::currentDateTime());
#endif
return;
}
}

View File

@ -6497,22 +6497,13 @@ void Simulation::testL4ShedsAPacAndSaysItForcedTheLock()
return telemetrieParId(arb->loadTelemetry()).value("pac-delestee");
};
// ⚠️ ANCRÉ SUR L'HORLOGE RÉELLE, et c'est délibéré — mesuré le 2026-08-30.
//
// Un cycle d'arbitrage se déclenche à l'horloge MURALE avant le premier cycle simulé (relevé
// par sonde : la PAC voit une action SG_NORMAL estampillée « 22:52:07 »). L'armement
// paresseux d'ECS-412 fige alors `m_lastSwitch` sur cette heure-là. Un scénario daté en dur
// se déroule donc « avant » son propre armement : `elapsed` est NÉGATIF, le verrou ne
// s'ouvre jamais, et la PAC n'atteint pas l'état 4 — le test échoue ou passe selon l'HEURE
// QU'IL EST, ce qui est pire que les deux.
//
// La chronologie relative est tout ce dont le scénario a besoin ; l'origine, elle, doit
// vivre dans la même horloge que ce qui le touche.
//
// **Ce test devient faux le jour où l'arbitrage cesse de pouvoir tourner à l'horloge murale
// dans la suite** — alors l'ancrage redevient inutile et une date fixe est préférable, parce
// qu'elle rend les journaux reproductibles.
const QDateTime t0 = QDateTime::currentDateTimeUtc();
// Date FIXE, et elle le redevient : `setPhasePowerLimit()` lançait un cycle d'arbitrage
// complet à l'horloge murale, faute de la garde ENERGY_SIMULATION que ses voisins ont.
// L'armement paresseux (ECS-412) figeait alors `m_lastSwitch` sur l'heure qu'il était, et
// ce scénario se déroulait « avant » son propre armement — il passait ou échouait SELON
// L'HEURE DE LA JOURNÉE. La garde rendue, une date fixe redevient préférable : elle rend
// les journaux reproductibles.
const QDateTime t0 = utcDateTime(QDate(2026, 8, 30), QTime(13, 0, 0));
meter->setStateValue("currentPhaseA", 0);
meter->setStateValue("currentPhaseB", 0);
meter->setStateValue("currentPhaseC", 0);

82
tools/check-simulation-clock.py Executable file
View File

@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""Refuse un déclencheur d'arbitrage câblé sur l'HORLOGE MURALE hors garde de simulation.
Pourquoi cette vérification existe
----------------------------------
`setPhasePowerLimit()` lançait un cycle d'arbitrage complet avec
`QDateTime::currentDateTime()`, sans la garde `ENERGY_SIMULATION` que ses voisins ont. Dans une
suite qui injecte un temps SIMULÉ, les deux horloges se mélangent alors dans les verrous :
l'armement paresseux (ECS-412) fige `m_lastSwitch` sur l'heure qu'il est, et un scénario daté en
dur se déroule « avant » son propre armement.
**Le test passait ou échouait selon l'heure de la journée.** Il n'a été trouvé que parce qu'il
échouait cinq fois sur cinq — un défaut qui ne bascule qu'une fois sur vingt passerait pour une
fragilité de temporisation, et serait relancé jusqu'à ce qu'il passe. C'est un instrument dont le
verdict ment selon l'heure, et rien dans la suite ne peut le dire d'elle-même : la vérification
doit donc être STATIQUE.
"""
import re
import sys
from pathlib import Path
RACINE = Path(__file__).resolve().parent.parent
# Fonctions qui entraînent un CYCLE complet. En appeler une avec l'horloge murale est un
# déclencheur réel, légitime en production et interdit en simulation.
DECLENCHEURS = re.compile(r'\b(update|runSurplusPlanning|verifyOverloadProtection)\s*\(\s*QDateTime::currentDateTime\(\)')
FICHIERS = list((RACINE / 'energyplugin').rglob('*.cpp'))
def analyser(chemin):
"""Retourne les lignes fautives : un déclencheur hors de tout `#ifndef ENERGY_SIMULATION`."""
fautes = []
# Pile des conditions de préprocesseur : True quand on est dans une branche qui EXCLUT la
# simulation. Le suivi est volontairement simple — il ne gère que ce que ce dépôt écrit.
pile = []
for numero, ligne in enumerate(chemin.read_text(encoding='utf-8').splitlines(), 1):
nu = ligne.strip()
if nu.startswith('#ifndef ENERGY_SIMULATION'):
pile.append(True)
continue
if nu.startswith('#ifdef ENERGY_SIMULATION'):
pile.append(False)
continue
if nu.startswith('#if'):
pile.append(False)
continue
if nu.startswith('#else') and pile:
pile[-1] = not pile[-1]
continue
if nu.startswith('#endif') and pile:
pile.pop()
continue
if nu.startswith('//'):
continue
if DECLENCHEURS.search(ligne) and not any(pile):
fautes.append((numero, nu))
return fautes
def principal():
total = []
for f in sorted(FICHIERS):
for numero, texte in analyser(f):
total.append((f.relative_to(RACINE), numero, texte))
if not total:
print("✓ aucun déclencheur d'arbitrage à l'horloge murale hors garde de simulation")
return 0
print("✗ %d déclencheur(s) d'arbitrage câblé(s) sur l'HORLOGE MURALE sans garde :" % len(total))
for chemin, numero, texte in total:
print(" %s:%d %s" % (chemin, numero, texte))
print()
print(" Un cycle à l'horloge murale dans une suite à temps simulé mélange les deux horloges")
print(" dans les verrous : le test passe ou échoue SELON L'HEURE DE LA JOURNÉE.")
print(" → réparer : entourer l'appel de #ifndef ENERGY_SIMULATION / #endif, comme ses voisins.")
return 1
if __name__ == '__main__':
sys.exit(principal())

View File

@ -58,7 +58,7 @@ ok() { printf '%s✓ %s%s\n' "$VERT" "$1" "$FIN"; }
reparer() { printf ' → réparer : %s\n' "$1"; }
# --- 1. L'index des règles est-il vrai ? ------------------------------------
titre "1/3 · index des règles"
titre "1/4 · index des règles"
if python3 tools/gen-rules.py; then
ok "index complet — aucun orphelin, aucun domicile ambigu"
else
@ -68,7 +68,7 @@ else
fi
# --- 2. La doc est-elle complète ? ------------------------------------------
titre "2/3 · documentation Doxygen"
titre "2/4 · documentation Doxygen"
if ! command -v doxygen >/dev/null 2>&1 || ! command -v dot >/dev/null 2>&1; then
ko "doxygen ou graphviz absent — la vérification n'a PAS eu lieu"
reparer "apt install doxygen graphviz"
@ -91,7 +91,7 @@ else
fi
# --- 3. Le générateur est-il déterministe ? ---------------------------------
titre "3/3 · reproductibilité de l'index"
titre "3/4 · reproductibilité de l'index"
A="$(mktemp)"; B="$(mktemp)"
python3 tools/gen-rules.py >/dev/null 2>&1; cp doc-generated/RULES.md "$A"
python3 tools/gen-rules.py >/dev/null 2>&1; cp doc-generated/RULES.md "$B"
@ -104,6 +104,16 @@ else
fi
rm -f "$A" "$B"
# --- 4. Un verdict de test peut-il dépendre de l'HEURE ? ---------------------
titre "4/4 · horloge des déclencheurs"
if sortie="$(python3 tools/check-simulation-clock.py 2>&1)"; then
ok "${sortie#✓ }"
else
ko "un déclencheur d'arbitrage tourne à l'horloge murale sans garde"
printf '\n'; printf '%s\n' "$sortie" | sed '1d;s/^/ /'
reparer "entourer l'appel de #ifndef ENERGY_SIMULATION, comme ses voisins"
fi
printf '\n'
if [ "$echec" -eq 0 ]; then
printf '%s✓ qualité documentaire : tout passe.%s\n' "$VERT" "$FIN"