Patrick Schurig ccf05cea87 feat(R6): la mesurabilité se déclare à la saisie, et l'inventaire du mock
Le signal vit dans GetLoadConfig, en r:, et non dans la télémétrie.
`measurement` n'existe que pour les charges arbitrées ; or une charge qu'on
DÉCLARE est précisément celle qui n'y est pas encore. L'écran serait muet au
moment exact où il doit parler — celui de la saisie, avant qu'une obligation
n'achète à l'aveugle tous les jours.

Trois causes, trois gestes : noMeter se répare en une manipulation,
meterWithoutEnergy demande de remplacer le compteur, noSessionEnergy ne se
répare pas. Sans la cause, l'avertissement ne peut dire que « ça ne marchera
pas ».

Et meterThingId ne permet pas de le déduire : un compteur peut publier
currentPower sans totalEnergyConsumed — mesuré sur l'ECS du banc, reproduit
au test avec un powerSwitch désigné comme compteur. La puissance ne suffit
pas, il faut une énergie. Contre-épreuve : déduction par meterThingId seul,
le cas du banc passe pour mesurable.

INVENTAIRE À FROID DU MOCK. Trois fois en trois jours qu'un cas réel s'est
révélé inatteignable ; ce n'est plus un incident, c'est une propriété du banc
— il est plus homogène que la réalité, ayant été construit pour faire marcher
des scénarios et non pour représenter la diversité du parc.

Quatre cas restent structurellement introuvables, dont `source: none` pour
une charge SAINE — toutes les classes pilotables portent une puissance, si
bien qu'une charge non mesurée mais en bon état est impossible à fabriquer,
alors qu'un relais sec est banal sur le terrain. Et la branche `temperature`,
écrite et jamais exécutée faute d'une classe qui porte l'état.

La règle : avant d'écrire un test qui exerce une ABSENCE, vérifier que le
mock peut la produire. Une assertion sur un cas inatteignable ne passe pas,
elle NE S'EXÉCUTE PAS — ce qui est pire, car elle compte comme verte. Le
symptôme se reconnaît : la contre-épreuve ne mord pas.

Deux ajouts dans AGENTS.md. L'asymétrie des latences est une PROPRIÉTÉ, pas
une chance : le refus qui coûte de l'argent est celui de la descente, et
c'est le plus vite constaté — ce qui n'est vrai que parce que le seuil vient
du mécanisme. Et la question opératoire appliquée aux trois autres identités,
avec un résultat inégal qui est le but : celle de `draw` repose sur le fait
que le compteur voie toute la maison, ce que rien ne vérifie ;
Σ levels[].targetW == estimatedPowerW est interne au plan et ne dit rien du
matériel — il a fallu un champ hors de la formule pour relier les deux.

Suite : simulation 61/61, 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
2026-09-01 17:27:56 +02:00

236 lines
13 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright (C) 2013 - 2024, nymea GmbH
* Copyright (C) 2024 - 2025, chargebyte austria GmbH
*
* This file is part of nymea-energy-plugin-nymea.
*
* nymea-energy-plugin-nymea.s free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* nymea-energy-plugin-nymea.s distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with nymea-energy-plugin-nymea. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#include <QtTest>
#include <QDateTime>
#include "simulationbase.h"
using namespace nymeaserver;
class ChargerPlugEvent
{
public:
ChargerPlugEvent() = default;
ChargerPlugEvent(const QDateTime &dt, bool pi = false, int pu = 0)
: dateTime{dt},
pluggedIn{pi},
percentageUsed{pu}
{}
QDateTime dateTime;
bool pluggedIn;
int percentageUsed = 0;
};
class Simulation: public SimulationBase
{
Q_OBJECT
public:
explicit Simulation(QObject *parent = nullptr) : SimulationBase(parent) { };
typedef QList<ChargerPlugEvent> ChargerPlugEvents;
typedef QList<int> DetailsStepList;
private slots:
void run_data();
void run();
// [T3] etmvariableload (ECS/routeur) : arrondi fixed (powerLevels) + clamp dynamic
// (maxPowerW), recrédit currentPowerW (anti-clignotement), délestage, round-trip powerSetpoint.
void testEcsSurplusPV();
// [T4] Watchdog L2 : compteur muet >90 s → etmvariableload setPowerSetpoint(0) force=true,
// planif suspendue (reste 0 sur N cycles), reprise au retour compteur.
void testMeterSilentFallback();
// [T4] Persistance LoadConfig (§4) : SetConfigs → fichier → relecture round-trip + validation.
void testLoadConfigPersistence();
// [T4] Construction des adaptateurs depuis la config : enabled servi, disabled exclu (§9).
void testLoadConfigBuildsAdapters();
// [T4] Injection RPC end-to-end : NymeaEnergy.Get/SetLoadConfig (couche consommée par l'app).
void testLoadConfigRpc();
// [rév.3] Chaîne complète : SetConfigs(relays[]) → rebuild → RelayRouter → cycle → commutation relais.
void testLoadConfigRelayRouter();
// SG-Ready (PAC) : montée d'états sur surplus, hystérésis 3↔4, protection court-cycling,
// + Volet 4 budget PARTAGÉ etmvariableload(ECS)↔PAC (waterfall unifié, inversion priorité).
void testSgReadySurplus();
// [rév.3] Combinatoire watts→relais DANS le RelayRouter : paliers dérivés, off-before-on,
// transition non-cascadée, et DÉDUPLICATION des niveaux (relais identiques fusionnés).
void testEcsRelayTopologies();
// [Phase 2] Ratios canoniques : EnergyRatiosCalculator aligné 1:1 sur le seam interim app
// (EnergyRatiosInterim.compute) — seed, normal, clamp, den≤0→n/a, non-monotone, nouveau jour.
void testEnergyRatiosAlignment();
// [étape 1 / ECS-306] Le budget décrémenté par le scheduler correspond au palier
// RÉELLEMENT applicable sous verrou minOn — les charges suivantes ne reçoivent pas un
// résidu surestimé.
void testEcsBudgetUnderLock();
// [étape 1 / ECS-411] Le palier courant est déduit de l'état réel des Things relais au
// démarrage, pas réinitialisé à 0.
void testEcsRestartRecovery();
// [étape 1 / ECS-412] SetLoadConfig pendant une fenêtre de verrou active ne réarme pas
// le verrou : seul le matériel modifié est reconstruit. Couvre aussi l'armement à froid.
void testEcsRebuildPreservesLock();
// [étape 1 / ECS-412] Démarrage à froid, palier 0, minOffS > 0, budget largement
// suffisant : la charge reste éteinte PENDANT minOffS puis s'enclenche. L'armement à
// froid doit être transitoire, jamais permanent (défaut constaté au banc 2026-08-09).
void testEcsColdStartLockExpires();
// [ECS-410] Échec d'écriture en cours de transition : retour arrière, arrêt total, puis
// état de défaut collant. Chaque marche de l'échelle a son cas, et la levée est délibérée.
void testEcsPartialFailure();
// [ECS-413] Désactiver une charge la laisse dans l'état SÛR de son adaptateur, pas dans
// son dernier état commandé — et un rebuild SANS désactivation ne coupe rien (cas négatif
// obligatoire, sans lui ECS-412 serait annulé).
void testEcsDisableLeavesSafeState();
// [ECS-414] L'échelle d'échec généralisée à SgReadyAdapter et EtmVariableLoadAdapter.
// Plancher propre à chaque adaptateur : ÉTAT 2 pour la PAC, jamais « contacts ouverts ».
void testSgReadyPartialFailure();
// [LM-300] Union discriminée : le mécanisme sg-ready se configure comme les autres, ses
// charges utiles sont mutuellement exclusives, et l'état 2 est obligatoire (ECS-110).
void testSgReadyFromConfig();
// [ECS-110 étendu] Un Thing n'appartient qu'à UNE charge active. Couvre le conflit
// ENTRE charges (relay-router ↔ sg-ready ↔ etmvariableload), les doublons intra-charge,
// les identifiants en double, et la tolérance d'une charge désactivée.
void testThingOwnershipIsExclusive();
// [Lot B §1] Rendu français des motifs : chaque code du catalogue produit MOT POUR MOT la
// phrase d'avant la refonte {code, params}. Le journal se prouve par comparaison, pas par
// relecture — et une phrase reformulée au passage ferait échouer ce test, pas un humain.
void testDecisionReasonRendering();
// [Lot B §2] Frontière de télémétrie : GetLoadTelemetry ≡ LoadTelemetryChanged, verrous en
// secondes, union discriminée de mécanisme, reprise à froid du mode dégradé, battement de
// cœur, et ZÉRO langue naturelle dans la charge utile.
void testLoadTelemetryRpc();
// [Lot B §3] o:domain : aller-retour lire→réécrire sans perte, valeur hors énumération
// rejetée, charge sans domaine acceptée — et aucun effet sur l'arbitrage.
void testLoadConfigDomain();
// [Lot B §2] La pure virtuelle force à RÉPONDRE, pas à répondre quelque chose : une vue
// vide compilerait tout aussi bien et sortirait de la télémétrie sans bruit. Ce test exige
// une vue SUBSTANTIELLE — mécanisme nommé, charge utile portant les valeurs runtime
// réelles, verrou en secondes qui expire — pour les QUATRE adaptateurs.
void testRuntimeViewIsSubstantial();
// [lot B-bis / ECS-411 + ECS-411-b appliqués au SG-Ready] L'état de départ est RELU sur les
// contacts, pas supposé — et le relevé publie ce qui a été LU, y compris quand l'issue est
// l'état neutre. Couvre aussi le corollaire LM-104 : une remise au neutre doit ÉCRIRE, sans
// quoi l'hypothèse de prudence devient un masquage de panne.
void testSgReadyRestartRecovery();
// ── 3g-2 ───────────────────────────────────────────────────────────────
// [LM-1206/LM-1207] Le rang d'une borne vit dans LoadConfig, comme celui de toute autre
// charge — et l'inverser change laquelle démarre. C'est la moitié ARBITRAGE de LM-1204-b :
// le banc en garde la moitié matérielle, qu'aucune wallbox simulée ne peut prouver.
// Couvre aussi « une charge, un commandeur » : la borne refusée est éteinte par l'arbitre,
// et le proxy ne repasse pas derrière avec une consigne de son cru.
void testEvChargerRankDecidesWhoCharges();
// [3g-2] Le démarrage sous tolérance d'acquisition est une décision d'ACHETER : elle porte
// un financement réseau, un motif qui chiffre le soutirage, et une comptabilité partagée.
void testEvGridStartIsAnnouncedAndBounded();
// [banc 2026-08-27] Une borne SANS VÉHICULE BRANCHÉ ne peut rien consommer de ce qu'on lui
// allouerait : elle doit être HORS arbitrage, comme une borne sans voiture ou en mode
// manuel. Sans ce filtre elle absorbe le budget de la cascade et le donne à personne —
// 4 853 W mesurés au banc, la charge suivante n'ayant survécu que sur son recrédit.
void testUnpluggedChargerTakesNoBudget();
// [3g-2] L'entrée de configuration d'une borne SURVIT à la disparition de son Thing : elle
// se lit comme une charge dont l'appareil manque, pas comme une charge jamais configurée.
void testEvChargerConfigOutlivesItsThing();
// [LM-1208-b, banc 2026-08-28] Une borne SANS VOITURE ASSIGNÉE est arbitrée — le cas de
// l'invité. Et elle l'est avec le nombre de phases de la BORNE : le manager ne la prépare
// pas, donc son ChargingProcessInfo n'existe pas, et le lire par défaut annoncerait
// monophasée une borne triphasée en divisant le budget par trois, silencieusement.
// Le mode manuel, lui, reste une exclusion : la seconde moitié du test le prouve.
void testChargerWithoutCarIsArbitrated();
// ── §11 / LM-1105 + LM-1106 ────────────────────────────────────────────
// La mesure d'une charge se lit sur l'APPAREIL PILOTÉ, sans qu'on ait à le désigner comme
// son propre compteur — et ce qui est publié est un COUPLE {valeur, source}. Les trois
// sources sont éprouvées, plus les deux règles de priorité : le compteur explicite gagne,
// mais un compteur injoignable fait retomber sur l'appareil au lieu de taire la mesure.
void testMeasurementSourceIsPublished();
// [R3] Σ counts == targetW sur TOUTES les charges publiées, à chaque cycle, quel que soit
// le chemin qui a construit l'action. C'est une relecture de la charge utile, pas une
// confiance faite à sept sites de construction : un site oublié se voit ici, jamais en
// relisant le code. Couvre aussi le partage d'EV_GRID_START et la présence de counts à
// zéro avec sa destination.
void testCountsSumToTarget();
// [R1 + R7] L'ABSENCE et le ZÉRO disent deux choses différentes : un niveau à targetW 0 est
// publié (« passe parcourue, rien à prendre »), un niveau absent dirait « passe non
// parcourue ». Et decision.level accompagne levels[] : les deux présents, ou les deux
// absents — en mode dégradé il n'y a pas de passe, donc ni l'un ni l'autre.
void testLevelAbsenceAndZeroDifferInMeaning();
void testPhaseAllowanceReadsTheStateItTests();
void testDrawCapBoundsTheCascade();
void testDrawAuthorisationSurvivesTheBatteryReserve();
void testEcoProgressBaseAndRegime();
void testEcoPassBuysAndSaysTheTrajectory();
void testEcoFloorMissedSaysWhatWasLostAndWhy();
void testCommandDivergenceIsPublishedNotDiagnosed();
void testProgressMeasurabilityIsDeclaredAtConfigTime();
void testRecreditIsALoanAgainstObedience();
void testARefusedStateIsDistinguishableFromAnApplied();
void testL4ShedsAPacAndSaysItForcedTheLock();
// ── §12 / LM-1209 ──────────────────────────────────────────────────────
// Une borne détectée après la mise en service entre EN QUEUE : elle ne passe devant
// aucune charge qu'un humain a classée, aucun autre rang ne bouge, le rang produit est
// libre par construction, et l'entrée porte rankOrigin="auto" — « ce rang est un défaut ».
void testAutoProvisionedRankGoesToTheQueue();
// [LM-1209-e] Supprimer l'entrée d'une borne dont le Thing VIT la fait RECRÉER au cycle
// suivant — en queue, et de nouveau en rankOrigin "auto" : personne ne l'a classée. Sans
// ce rattrapage, la borne reste arbitrée mais absente de GetLoadConfig, à un rang non
// réglable — le trou de 3g-2 rouvert par le côté client.
void testDeletedEvChargerConfigComesBack();
// [ECS-111] Un relais DÉCLARÉ qui disparaît du registre nymea : la charge sort de
// l'arbitrage (available faux, THING_MISSING) et reste dans la comptabilité, figée à la
// puissance crue engagée. Avant, elle publiait le nominal du palier entier — part du
// relais fantôme comprise — et ce chiffre entrait dans le RECRÉDIT, donc dans le budget.
void testVanishedRelayFreezesTheLoad();
void printStates(Thing *thing);
void updateChargerMeter(Thing *thing);
QStringList plotOriginalData(int powerBalanceCount);
QStringList plotSimulation(const QString &title, int powerBalanceCount);
QStringList plotSpotMarketData(int powerBalanceCount);
};
Q_DECLARE_METATYPE(ChargerPlugEvent)
Q_DECLARE_METATYPE(Simulation::ChargerPlugEvents)
Q_DECLARE_METATYPE(Simulation::DetailsStepList)