diff --git a/debian-qt5/changelog b/debian-qt5/changelog index 6827da7..9238eae 100644 --- a/debian-qt5/changelog +++ b/debian-qt5/changelog @@ -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 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 diff --git a/energyplugin/smartchargingmanager.cpp b/energyplugin/smartchargingmanager.cpp index 3ddb338..ae453df 100644 --- a/energyplugin/smartchargingmanager.cpp +++ b/energyplugin/smartchargingmanager.cpp @@ -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; } } diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index a514a3a..544be23 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -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); diff --git a/tools/check-simulation-clock.py b/tools/check-simulation-clock.py new file mode 100755 index 0000000..57cfdba --- /dev/null +++ b/tools/check-simulation-clock.py @@ -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()) diff --git a/tools/ci-quality.sh b/tools/ci-quality.sh index 0e99465..511e29d 100755 --- a/tools/ci-quality.sh +++ b/tools/ci-quality.sh @@ -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"