Patrick Schurig e392c1c7b8 feat(§11): la mesure d'une charge se lit sur l'appareil, et publie sa source
LM-1105/LM-1106 implantés. measuredW disparaît, sans alias.

ILoadAdapter::deviceMeasurement() — PURE VIRTUELLE, comme runtimeView() et pour
la même raison : un mécanisme ajouté qui hériterait d'un « non mesurable » par
défaut sortirait de la mesure sans bruit, et son écran afficherait « pas
mesurable » avec l'autorité d'un constat. Le compilateur pose la question.

Le type porte une CAPACITÉ, pas une valeur nulle : `available` répond à « cette
charge est-elle mesurable », question posée à la ThingClass, jamais à la valeur
du moment. Un routeur au palier 0 mesure 0 W et reste mesurable ; sans ce
booléen les deux seraient indistinguables.

attachMeasurements() résout meter → device → none. Le compteur explicite garde la
priorité, et pas d'abord parce qu'il mesure mieux : un réglage sans effet est
pire que pas de réglage. La priorité porte sur la SOURCE, pas sur la valeur —
compteur déclaré injoignable, on retombe sur l'appareil, parce que se taire
perdrait une mesure qu'on a et que le silence se lirait « non mesurable ».

Sous `none`, powerW est OMIS. La puissance nominale reste lisible dans
mechanism.*, qui dit d'où elle vient ; la faire passer pour une mesure produirait
un écart commandé↔mesuré identiquement nul à tous les cycles — un « tout
concorde » permanent, corollaire LM-104.

DEUX AFFINEMENTS IMPOSÉS PAR L'IMPLANTATION, tous deux de modèle :

LM-1105-a — une mesure PARTIELLE n'est pas une mesure. Là où plusieurs Things
portent la charge, il faut qu'ils la portent TOUS : trois relais dont un seul
mesure annonceraient 500 W là où 1 500 W coulent, et l'écart enverrait chercher
une panne inexistante. À ne pas confondre avec LoadTelemetry::currentPowerW, qui
accepte « au moins un » et retombe sur le nominal — celui-là est le juge runtime
du budget et doit rendre un nombre à chaque cycle. Les fusionner rouvrirait la
correction B.

LM-1105-b — la règle présuppose que le Thing piloté est TRAVERSÉ par la
puissance. Un relais de relay-router commute la charge, donc sa puissance EST
celle de la charge ; un contact SG-Ready est un contact de SIGNAL — la PAC est
alimentée ailleurs et un contact qui mesurerait quelque chose mesurerait sa
propre bobine. sg-ready est donc `none` par CONSTRUCTION, même sur des relais qui
mesurent — c'est le cas du banc — et c'est le seul mécanisme pour lequel
meterThingId reste la seule voie.

RelayRouter : la capacité se juge sur la ThingClass de TOUS les relais déclarés,
la valeur se lit sur les relais FERMÉS. Jugée sur les relais actifs, la source
vaudrait `device` au palier 1 et `none` au palier 0 — un bloc de mesure qui
apparaît et disparaît au rythme du thermostat. VÉRIFIÉ ÉCHOUANT ainsi.

testMeasurementSourceIsPublished : les trois sources sans une ligne de config de
compteur, le palier 0 qui ne fait pas basculer la source, la PAC en `none` alors
que ses contacts mesurent, la disparition de measuredW, puis les deux règles de
priorité — le compteur explicite l'emporte (1 234 contre 2 900), un compteur
injoignable fait retomber sur l'appareil.

Ne touche PAS ce que l'arbitre lit : LM-1101 tient, LoadTelemetry::currentPowerW
est inchangé, et le recrédit anti-clignotement n'a jamais transité par
meterThingId.

amd64 0 erreur / 0 avertissement. Suites, un processus par test : simulation
30/30, charging 16/16, loadmodel 18/18, spotmarket 7/7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-28 14:49:28 +02:00

194 lines
10 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();
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)