Une dérogation n'est pas un mode. Un mode se choisit une fois et vaut jusqu'au prochain choix ; une dérogation répond à un besoin ponctuel et s'éteint avec lui. Un boost encore actif après la fin est un mode qu'on a oublié de quitter. Elle se superpose au mode courant, sert la charge à son MAXIMUM en tête de passe éco — devant toute autre charge, quel que soit son rang — financée au réseau. Le rang classe ce que l'installateur décide (LM-1002) ; une dérogation est une action ponctuelle de l'utilisateur, et passe par une porte que LM-1002 ne ferme pas. Mais elle ne surplombe PAS la sûreté : DRAW_CAP la borne et le délestage la reprend. Servie la première dans ce qui RESTE, jamais au-delà — sans quoi on rouvrirait le second chemin d'achat refusé au minimum réseau (LM-1500-e). LA DURÉE EST OBLIGATOIRE ET BORNÉE, refusée à la pose hors de [60 s, 6 h]. C'est la règle du lot : une dérogation qui ne sait pas dire quand elle finit ne devrait pas exister (LM-1500). Elle reste armée même avec une cible, parce qu'une cible repose sur un instrument et qu'un instrument peut se taire — à la perte du lien, la V2C FIGE sessionEnergy au lieu de l'effacer (LM-1012-d) : une dérogation « 20 kWh puis stop » dont le compteur reste à 12 kWh n'atteint jamais sa cible. Trois décisions prises en écrivant, absentes du design : - Les fins se lisent dans le CONTEXTE DU CYCLE, pas dans les adaptateurs. evaluerDerogations() tourne entre buildContext() et getPlan(), sur ce que le scheduler va lire. Une seconde lecture donnerait une seconde vérité, et une dérogation pourrait finir sur une mesure que le plan ne voit pas. C'est aussi ce qui rend le débranchement gratuit : la borne est déjà filtrée du contexte, son absence EST le signal. - measurementLost ne mord QUE s'il y a une cible (LM-1500-b). Sans cible, rien ne dépend de la mesure : couper là punirait la charge du silence d'un instrument dont elle n'a pas besoin. Et le critère est telemetry.sessionMeasurable, jamais progress.regime : `progress` est absent d'une charge sans obligation en énergie, c'est-à-dire exactement le cas du boost — le critère aurait été inerte. - La base de la cible se capture au PREMIER CYCLE, pas à la pose : au moment où l'utilisateur appuie, aucun cycle n'a construit de contexte. Prendre 0 compterait depuis le début de la session — une dérogation « 5 kWh » posée sur une session déjà à 6 kWh serait atteinte avant d'avoir commencé (LM-1012-b). Le mode précédent se MÉMORISE, il ne se déduit pas (LM-1500-d). Revenir à un défaut détruirait un réglage en silence, et sur une borne la sortirait de l'arbitrage : une dérogation qui laisse l'installation moins pilotée qu'avant elle. Une borne en manuel est basculée en éco pour la durée — seul cas où le mode est écrit, et modeForce retient qu'on l'a fait. La passe confort est SAUTÉE sous dérogation : la fusion des niveaux est une addition, et la dérogation demande déjà le maximum. Conséquence comptable à connaître : elle est comptée entièrement en achat alors que le surplus présent la finance en partie — gridW la surestime au soleil. Approximation héritée du §10, non aggravée, et la raison pour laquelle la première vérification au banc se fait le matin. La fin est un ÉVÉNEMENT et dit sa CAUSE (LM-1500-f), publiée dans derogationEnded[] au NIVEAU DE LA TRAME : au débranchement la charge n'est déjà plus dans loads[], et l'événement disparaîtrait quand il explique le plus de choses. Cinq causes, cinq gestes. Le mode dégradé la porte aussi — c'est là que measurementLost se produit. telemetrySignificantlyDiffers ne la filtre jamais : un événement ne vit qu'un cycle. Éprouvé par testDerogationTakesTheHeadButNotTheSafety, dont la tête de passe est prouvée par la RARETÉ : 7 500 W d'autorisation pour 8 000 demandés, la borne de rang 9 servie ENTIÈREMENT et l'ECS de rang 1 réduite aux 500 restants — l'inverse exact de l'ordre des rangs. Contre-épreuve : la même borne sans dérogation reçoit 0 W. Et à autorisation nulle, AUCUN niveau — pas un niveau à zéro. simulation 68/68, loadmodel 26/26, charging 48/48, spotmarket 32/32, ci-quality 4/4, build 0 erreur 0 warning. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
243 lines
13 KiB
C++
243 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 testThermalProbeFeedsTelemetry();
|
|
void testHealthyButUnmeasuredDiffersFromFaulty();
|
|
void testLifetimeCounterDropNeverRebasesTheProgress();
|
|
void testWorstPhaseBindsWhenPhasesAreUnbalanced();
|
|
void testPhaseVoltageDoesNotLeakBetweenPhases();
|
|
void testMeterEnergyActuallyAccumulates();
|
|
void testRecreditIsALoanAgainstObedience();
|
|
void testARefusedStateIsDistinguishableFromAnApplied();
|
|
void testL4ShedsAPacAndSaysItForcedTheLock();
|
|
void testDerogationTakesTheHeadButNotTheSafety();
|
|
|
|
// ── §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)
|