feat(config): lot B-bis — la PAC entre en configuration — 1.15.2+etm15

Le bloc d'enregistrement codé en dur d'energypluginnymea.cpp est supprimé, avec
registerSgReadyAdapter(), la table m_sgReadyAdapters et claimedRelays(). Il n'existe plus
aucun chemin d'enregistrement en dur : m_loadAdapters est la table UNIQUE des charges
arbitrées, et toutes ses entrées viennent de LoadConfigStore.

Contrat : loads[] de GetLoadTelemetry est désormais INCLUS dans GetLoadConfig, par
CONSTRUCTION et non par discipline. La règle de détection publiée à +etm14 — « un loadId sans
équivalent dans GetLoadConfig est une charge non configurable » — est retirée d'INTERFACE.md :
elle n'avait qu'un porteur, pac-terrain, qui vient de disparaître. Aucun configurable:false
n'est ajouté, il n'aurait plus rien à signaler. L'inclusion ne vaut que dans ce sens : une
charge enabled:false est configurée sans être publiée.

SÉCURITÉ — repli L2 d'une PAC de configuration, trou ouvert par LM-300 et refermé ici. Le
repli envoyait un Setpoint(0) à toute la table et un State(2) à une table séparée réservée à
la PAC en dur ; une PAC construite depuis la CONFIG vivant dans la première recevait donc une
action que SgReadyAdapter rejette d'entrée, et rien d'autre — elle restait en état 4 sous
compteur muet quand SAFETY.md §L2 exige l'état 2. Le motif DEGRADED_L2 s'affichait
correctement : seule l'écriture manquait. Le repli lit maintenant supportedKinds (LM-301-b).

ECS-411/-411-b au SG-Ready : l'état de départ est relu sur les contacts, avec relevé contact
par contact et l'issue — y compris quand l'issue est l'état neutre. ECS-412 au minStateHold :
armement paresseux au démarrage à froid, jamais permanent.

Harnais de test : JournalCapture se chaînait sur elle-même (récursion infinie à croissance
quadratique dès la deuxième capture vivante, donc OOM) ; tm/owner non initialisés sur trois
sites de freshSetup().

Vérifié sur .75 avant publication — docs/RELEVE_LOTBbis.md. Suites : simulation 85,
charging 74, spotmarket 44, zéro échec, un processus par test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
This commit is contained in:
Patrick Schurig 2026-08-25 18:35:04 +02:00
parent 1ec89bf030
commit 405c7f5bf6
10 changed files with 739 additions and 218 deletions

View File

@ -1,3 +1,99 @@
powersync-energy-plugin-nymea (1.15.2+etm15) trixie; urgency=medium
* La PAC entre en configuration : le bloc d'enregistrement codé en dur d'energypluginnymea.cpp
est SUPPRIMÉ, avec registerSgReadyAdapter(), la table m_sgReadyAdapters et claimedRelays().
Il n'existe plus aucun chemin d'enregistrement en dur : m_loadAdapters est la table unique
des charges arbitrées, et toutes ses entrées viennent de LoadConfigStore. La PAC du banc est
devenue une LoadConfig « sg-ready » ordinaire — nommable, classable, rangeable,
désactivable — comme n'importe quelle autre charge.
* Contrat : loads[] de GetLoadTelemetry est désormais INCLUS dans GetLoadConfig, et c'est un
invariant de CONSTRUCTION, pas une discipline. La règle de détection publiée à +etm14 — « un
loadId sans équivalent dans GetLoadConfig est une charge non configurable » — est RETIRÉE
d'INTERFACE.md : elle n'avait qu'un porteur, pac-terrain, qui vient de disparaître. Aucun
champ « configurable: false » n'a été ajouté : il n'aurait plus rien à signaler. Un client
qui a déjà implémenté la règle peut la garder sans dommage, elle ne se déclenchera plus.
L'inclusion ne vaut que dans ce sens : GetLoadConfig peut contenir des charges absentes de
loads[] — celles en enabled:false, exclues de l'arbitrage (contrat §9). Une charge configurée
absente de loads[] se lit « désactivée », jamais « perdue ».
* SÉCURITÉ — repli L2 d'une PAC de configuration : trou ouvert par LM-300, refermé ici. Le
repli du mode dégradé envoyait un Setpoint(0) à toute la table des charges pilotées et un
State(2) à une table séparée, réservée à la PAC codée en dur. Or une PAC construite depuis la
CONFIG vit dans la première table : elle recevait donc un Setpoint, que SgReadyAdapter
rejette d'entrée (kind non supporté) — et rien d'autre. Sous compteur muet, cette PAC-là
restait en état 4 (forcé) indéfiniment, alors que SAFETY.md §L2 exige l'état 2. Le défaut
était invisible : le motif DEGRADED_L2 apparaissait correctement dans la télémétrie, seule
l'ÉCRITURE manquait. Le repli lit maintenant supportedKinds déclaré par l'adaptateur et
refuse explicitement ce qu'il ne sait pas replier. Règle portée en LM-301-b : dès qu'un
mécanisme rejoint la table commune, tout code qui FABRIQUE une action doit lire
supportedKinds au lieu de présumer du kind à partir de la table où la charge se trouve.
* ECS-411 appliqué au SG-Ready : l'état de départ est RELU sur les contacts, plus supposé à 2.
Tant que la PAC était codée en dur et construite une fois au démarrage, l'hypothèse se voyait
peu ; depuis qu'elle vient de la configuration, elle est reconstruite à chaque changement de
matériel. Le vrai piège est le corollaire LM-104 : applyAction(état 2) sur un adaptateur qui
se CROIT en 2 est idempotent, donc muet, donc les contacts restent en 4 — l'hypothèse de
prudence devient un masquage de panne. ECS-411-b : le relevé publie ce qui a été LU, contact
par contact, avec l'issue — y compris quand l'issue est l'état neutre, qui est justement
celui qu'on vérifie après un démarrage — et passe en avertissement si un contact est
injoignable (supposé fermé, LM-104).
* ECS-412 appliqué au minStateHold : un m_lastSwitch nul vaut « commutation venant d'avoir
lieu », jamais « jamais commuté ». Sans cet armement, un redémarrage de nymead pendant que le
compresseur tourne autoriserait une coupure immédiate — du court-cycling provoqué par un
redémarrage de service, exactement ce que le verrou existe pour empêcher. L'armement est
PARESSEUX : applyAction() estampille au premier « now » reçu, refusé ou non, et le verrou
expire ensuite normalement. Jamais permanent — c'est le blocage circulaire corrigé côté
routeur le 2026-08-09, où une charge démarrant contacts ouverts ne pouvait plus jamais
s'enclencher. runtimeView() adopte la MÊME convention que lockWindow(), de sorte que
l'affichage ne peut pas contredire le comportement. Conséquence à connaître : toute
reconstruction d'adaptateur réarme le verrou pour sa durée configurée (900 s sur une PAC
réelle) — sans effet au banc, réel sur une installation.
* Les puissances SG-Ready du banc ne sont PAS des données d'installation. estimatedPowerW
{3: 1500, 4: 3000} et minStateHoldS 300 sont reconduits du bloc supprimé, où ils décrivaient
une PAC INVENTÉE ; aucune PAC n'est sous tension sur K1/K2 (polarité des contacts encore non
vérifiée). Ils sont marqués PROVISOIRES ET NON MESURÉS dans docs/TEST_TERRAIN.md §1.d, à
remplacer par la machine réellement câblée. Ce n'est pas cosmétique : estimatedPowerW sert de
base au recrédit budget et au choix d'état, une estimation fausse décale l'allocation de tout
le waterfall en aval, pas seulement celle de la PAC.
* La suppression du bloc en dur ne passe pas par applySafeState(), et ne le peut pas : c'est un
événement de COMPILATION, pas d'exécution — ECS-413 ne s'applique qu'à un adaptateur qui sort
d'une configuration vivante. Le filet est procédural et documenté (TEST_TERRAIN.md §1.d) :
poser la config sg-ready AVANT d'installer ce paquet, sur le +etm14 en cours d'exécution, de
sorte que K1/K2 ne soient jamais sans propriétaire. Installer d'abord et configurer ensuite
laisse une fenêtre où plus personne ne commande les contacts : ils restent dans leur dernier
état commandé, possiblement fermés. enabled:false ne met pas davantage une charge en
sécurité : il la retire de la configuration.
* TEST_TERRAIN.md §1.d : la recette « coller un bloc C++ puis rebuild » est morte — elle ne
compilait plus, et elle produisait une charge arbitrée que GetLoadConfig ignorait. Remplacée
par la déclaration JSON-RPC des deux charges du banc en un SetLoadConfig. Changer un palier,
un rang ou un ThingId ne demande plus de cycle build cross-arm64 → .deb → deploy.sh.
* Tests : testSgReadyRestartRecovery (état relu sur contacts : 4, neutre, contact injoignable →
avertissement ; relevé contact par contact avec l'issue ; et la remise au neutre qui ÉCRIT au
lieu de se croire idempotente). testSgReadySurplus et le waterfall partagé passent par le
chemin de PRODUCTION (SetConfigs → changed → rebuild), plus par un enregistrement direct, et
lisent l'état de la PAC par la frontière publique — la télémétrie est vérifiée en même temps
que l'arbitrage. Volet 0 ajouté : l'armement à froid gèle, puis EXPIRE. testLoadTelemetryRpc
vérifie le repli L2 sur les CONTACTS et le mécanisme publié, pas sur le motif : c'est
précisément ce que le motif seul ne prouvait pas.
* Harnais de test — JournalCapture se chaînait SUR ELLE-MÊME. qInstallMessageHandler() rend le
handler courant ; à la DEUXIÈME capture vivante, ce handler est collecte() elle-même, si bien
que chaque message se rappelait indéfiniment en concaténant dans m_texte : récursion infinie
à croissance quadratique, donc OOM. Une seule capture vivante ne déclenchait rien, et le seul
test qui en ouvre deux est celui ajouté par ce lot. Le handler n'est désormais installé
qu'une fois, chaîné au handler d'ORIGINE, et toutes les captures vivantes reçoivent le
message — ce que le chaînage visait. À rapprocher de la contrainte d'exécution portée dans
AGENTS.md : la suite de simulation ne se lance JAMAIS en un seul processus, un serveur nymea
complet étant instancié par test sans que rien ne soit rendu entre deux.
* Harnais de test — tm et owner non initialisés (simulation.cpp, trois sites) : freshSetup()
peut sortir sur un QVERIFY en échec sans les renseigner, et ils étaient déréférencés juste
après. Initialisés par l'appelant et vérifiés après l'appel : un échec de test lisible au lieu
d'un comportement indéfini.
* Vérifié sur le banc .75 avant publication (docs/RELEVE_LOTBbis.md) : l'ordre de migration
exercé dans le bon sens (config posée sur le +etm14, qui cède les contacts, PUIS déploiement),
GetLoadConfig et GetLoadTelemetry concordants, applySafeState confirmé sur les contacts réels
au retrait d'une charge, et le verrou d'armement à froid qui EXPIRE (5 min 40 puis état 4 sous
surplus abondant) — l'armement est paresseux, jamais permanent.
-- Patrick Schurig <etm.schurig@gmail.com> Tue, 25 Aug 2026 16:30:00 +0200
powersync-energy-plugin-nymea (1.15.2+etm14) trixie; urgency=medium
* Contrat : loads[] de GetLoadTelemetry n'est PAS en correspondance 1:1 avec GetLoadConfig.

View File

@ -31,7 +31,6 @@
// [ETM] BEGIN — EnergyArbitrator flip. Remove block to revert to upstream SmartChargingManager.
#ifdef ETM_ARBITRATOR
#include "etm/energyarbitrator.h"
#include "etm/adapters/sgreadyadapter.h"
#include "etm/config/loadconfigstore.h"
#endif
// [ETM] END
@ -62,18 +61,19 @@ void EnergyPluginNymea::init()
loadConfigStore = new LoadConfigStore(this);
chargingManager->setLoadConfigStore(loadConfigStore);
// §1.d — Banc hems : SG-Ready PAC (ThingIds briefing terrain) — PAC hors config (§8, gelée).
{
ThingManager *tm = thingManager();
const QString K1 = "{beaf92e1-aedc-4b84-9ce4-e423648638cc}";
const QString K2 = "{bf236e64-5ae7-4bf1-82a6-4ede03de75a6}";
auto *pac = new SgReadyAdapter(
tm, "pac-terrain", "PAC banc",
QHash<int,QList<QString>>({ {1,{K1}}, {2,{}}, {3,{K2}}, {4,{K1,K2}} }),
QHash<int,double>({ {1,0.0}, {2,0.0}, {3,1500.0}, {4,3000.0} }),
300, 2, chargingManager);
chargingManager->registerSgReadyAdapter(pac);
}
// AUCUN adaptateur codé en dur ici, et c'est un INVARIANT DE CONTRAT, pas une propreté.
//
// Une PAC SG-Ready de banc était enregistrée à cet endroit (ThingIds K1/K2, états
// {3: 1500 W, 4: 3000 W}). Elle était pleinement arbitrée — budget, verrous, télémétrie —
// et pourtant absente de GetLoadConfig : ni renommable, ni classable, ni désactivable.
// GetLoadTelemetry publiait donc une charge que l'application ne pouvait pas configurer,
// et rien dans le contrat ne le disait.
//
// Depuis LM-300 le mécanisme sg-ready se configure comme les autres : elle est devenue une
// LoadConfig ordinaire. Toute charge arbitrée vient désormais de LoadConfigStore, donc
// l'ensemble publié par GetLoadTelemetry est exactement celui de GetLoadConfig — par
// construction, pas par discipline. Ne PAS rouvrir cette porte : un adaptateur enregistré
// ici serait de nouveau invisible à la configuration.
#else
SmartChargingManager *chargingManager = new SmartChargingManager(energyManager(), thingManager(), spotMarketManager, configuration, this);
#endif

View File

@ -6,6 +6,7 @@
#include <QDateTime>
#include <QSet>
#include <QStringList>
#include <algorithm>
#include <climits>
#include <integrations/thingmanager.h>
@ -43,11 +44,98 @@ SgReadyAdapter::SgReadyAdapter(ThingManager *thingManager,
// contrôle ci-dessous n'est qu'un dernier filet pour les constructions directes (tests,
// enregistrement codé en dur) : il PARLE au lieu de disparaître.
m_usable = !m_states.isEmpty() && m_stateRelays.contains(2);
if (!m_usable)
if (!m_usable) {
qCCritical(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label
<< "— encodage INUTILISABLE : l'état 2 (normal) n'est pas"
<< "exprimable. C'est le repli sûr du mode dégradé L2 ;"
<< "l'adaptateur refusera toute commande.";
return; // rien à relire : aucun état ne sera jamais commandé.
}
// ECS-411 — REPRISE DE L'ÉTAT depuis les contacts RÉELS. Partir de l'état 2 par défaut,
// c'est le moteur qui croit la PAC au repos pendant qu'elle tourne en forcé : même famille
// de défaut que le palier ECS repris à 0 alors que 3 kW circulent.
//
// Le cas VRAIMENT méchant n'est pas l'annonce fausse, c'est son corollaire (LM-104) :
// applyAction(état 2) sur un adaptateur qui se CROIT déjà en 2 est idempotent, donc MUET,
// donc les contacts restent en 4. L'hypothèse de prudence deviendrait un masquage de panne.
// Relire supprime les deux d'un coup.
m_currentState = deduceStateFromRelays();
m_statePrev = m_stateTarget = m_currentState;
}
int SgReadyAdapter::deduceStateFromRelays() const
{
// ECS-411-b — journaliser ce qui a été LU, pas seulement ce qui en est déduit. « état repris
// 2 » ne dit pas si les contacts étaient ouverts ou si l'un d'eux était injoignable et
// supposé fermé : deux situations qui appellent des gestes opposés. On publie donc le relevé
// contact par contact AVEC l'issue — y compris quand l'issue est l'état neutre, qui est
// justement celui qu'on vient vérifier après un démarrage.
const QSet<QString> tous = allRelays();
QSet<QString> fermes;
bool injoignable = false;
QStringList lecture;
for (const QString &thingId : tous) {
const QString court = thingId.left(8);
Thing *relay = m_thingManager ? m_thingManager->findConfiguredThing(ThingId(thingId)) : nullptr;
if (!relay) {
// Contact INJOIGNABLE : état inconnu, supposé FERMÉ (LM-104, même choix
// conservateur que readActualOn() et qu'ECS-411 côté routeur).
injoignable = true;
fermes.insert(thingId);
lecture << QStringLiteral("%1=INJOIGNABLE→supposé fermé").arg(court);
continue;
}
const bool ferme = relay->stateValue("power").toBool();
if (ferme)
fermes.insert(thingId);
lecture << QStringLiteral("%1=%2").arg(court,
ferme ? QStringLiteral("fermé") : QStringLiteral("ouvert"));
}
lecture.sort(); // ordre stable : QSet n'en garantit aucun, et un relevé se compare
int etat = -1;
QString issue;
// 1) Correspondance EXACTE du motif de contacts avec un état déclaré : cas nominal. Avec
// un encodage 2 bits complet (00/01/10/11), c'est le seul cas atteignable.
for (auto it = m_stateRelays.constBegin(); it != m_stateRelays.constEnd() && etat < 0; ++it) {
const QSet<QString> s(it.value().begin(), it.value().end());
if (s == fermes) {
etat = it.key();
issue = QStringLiteral("correspondance exacte");
}
}
// 2) Motif NON DÉCLARÉ — possible seulement sur un encodage partiel (tous les états ne
// sont pas décrits). On ne sait pas dans quel état est la PAC. On retient l'état
// déclaré de plus forte puissance estimée : ne JAMAIS annoncer moins que ce qui peut
// être appliqué (règle d'ECS-411). Le premier applyAction normalisera les contacts.
if (etat < 0) {
double pire = -1.0;
for (int s : m_states) {
const double p = m_estimatedPowerW.value(s, 0.0);
if (p > pire) { pire = p; etat = s; }
}
issue = QStringLiteral("motif de contacts NON DÉCLARÉ — état de plus forte puissance "
"estimée retenu (normalisé au premier cycle)");
}
const QString releve = QStringLiteral("[SgReadyAdapter] %1 — reprise au démarrage : état %2 "
"(~%3 W estimés) [%4] ; contacts lus : %5")
.arg(m_label).arg(etat)
.arg(qRound(m_estimatedPowerW.value(etat, 0.0))).arg(issue)
.arg(lecture.isEmpty() ? QStringLiteral("(aucun contact déclaré)")
: lecture.join(QStringLiteral(", ")));
// Un contact injoignable rend la reprise incertaine : elle mérite un avertissement, pas une
// information. Le relevé est le même, seul le niveau change.
if (injoignable)
qCWarning(dcNymeaEnergy()).noquote() << releve << "— état INCERTAIN (contact injoignable).";
else
qCInfo(dcNymeaEnergy()).noquote() << releve;
return etat;
}
LoadDescriptor SgReadyAdapter::descriptor() const
@ -134,6 +222,13 @@ LoadAction SgReadyAdapter::applyAction(const LoadAction &action, const QDateTime
return refused;
}
// ECS-412 — l'armement paresseux se DÉSARME ici, dans le chemin NON-const qui reçoit le
// temps de cycle : au premier \c now vu, on estampille, et le verrou expire alors
// naturellement. Sans ça l'armement de \c lockWindow() serait permanent. L'invariant
// « temps = paramètre, jamais l'horloge » est préservé : aucune horloge n'est lue.
if (!m_lastSwitch.isValid())
m_lastSwitch = now;
// Écrêtage à un état déclaré (borne puis exigence d'appartenance).
int newState = qBound(m_states.first(), action.state, m_states.last());
if (!m_stateRelays.contains(newState)) {
@ -206,15 +301,14 @@ LoadRuntimeView SgReadyAdapter::runtimeView(const QDateTime &now) const
else if (!m_usable)
v.faultCode = QStringLiteral("UNUSABLE_ENCODING");
// Verrou en secondes restantes, calculé sur le MÊME m_lastSwitch que lockWindow(). Un
// m_lastSwitch nul vaut « jamais commuté » et ne verrouille rien — convention identique
// à lockWindow(), pour que l'affichage ne contredise pas le comportement.
if (m_lastSwitch.isValid()) {
const qint64 elapsed = m_lastSwitch.secsTo(now);
if (elapsed < m_minStateHoldS) {
v.lockKind = QStringLiteral("minStateHold");
v.lockRemainingS = static_cast<int>(m_minStateHoldS - elapsed);
}
// Verrou en secondes restantes, calculé sur le MÊME m_lastSwitch et la MÊME convention
// d'armement que lockWindow() — y compris le démarrage à froid, où un m_lastSwitch nul
// vaut « commutation venant d'avoir lieu » (ECS-412). L'affichage ne peut donc pas
// contredire le comportement : le client lit le verrou qui a réellement borné la décision.
const qint64 elapsed = m_lastSwitch.isValid() ? m_lastSwitch.secsTo(now) : 0;
if (elapsed < m_minStateHoldS) {
v.lockKind = QStringLiteral("minStateHold");
v.lockRemainingS = static_cast<int>(m_minStateHoldS - elapsed);
}
return v;
}
@ -239,10 +333,27 @@ void SgReadyAdapter::lockWindow(const QDateTime &now, int &minState, int &maxSta
{
const int lo = m_states.first();
const int hi = m_states.last();
const bool valid = m_lastSwitch.isValid();
const qint64 elapsed = valid ? m_lastSwitch.secsTo(now) : 0;
if (valid && elapsed < m_minStateHoldS) {
// ECS-412 — ARMEMENT PARESSEUX au démarrage à froid, symétrique du RelayRouter.
// Un \c m_lastSwitch nul signifie « on ne sait pas quand la dernière commutation a eu
// lieu » (redémarrage de nymead, reconstruction depuis la config) — PAS « il n'y en a
// jamais eu ». Le défaut SÛR est donc d'ARMER : traiter la commutation comme venant
// d'avoir lieu, et laisser le verrou expirer.
//
// Ce que ça évite : la PAC tourne en forcé depuis deux minutes, nymead redémarre, le
// budget a chuté — sans armement le moteur la coupe immédiatement, et c'est du
// court-cycling compresseur provoqué par un redémarrage de service. Or le verrou
// minStateHold n'existe que pour ça.
//
// Cet armement est TRANSITOIRE : applyAction() estampille \c m_lastSwitch au premier
// \c now reçu, si bien que le verrou expire après sa durée configurée. Il ne doit JAMAIS
// devenir permanent — c'est le défaut corrigé côté routeur le 2026-08-09, où une charge
// démarrant contacts ouverts avec un verrou armé en permanence ne pouvait plus jamais
// s'enclencher (blocage circulaire : pas d'enclenchement → pas de commutation → pas
// d'estampille). Les replis de sécurité passent en \c force = true et le bypassent.
const qint64 elapsed = m_lastSwitch.isValid() ? m_lastSwitch.secsTo(now) : 0;
if (elapsed < m_minStateHoldS) {
// Gel total : la PAC doit tenir son état (protection court-cycling compresseur).
minState = maxState = m_currentState;
} else {

View File

@ -127,20 +127,13 @@ public:
*/
void applySafeState(const QDateTime &now) override;
/*! \brief Contacts que cet adaptateur revendique, tous états confondus.
*
* Sert à détecter qu'une PAC de configuration (LM-300) et une PAC enregistrée en dur
* commandent le même matériel — situation où deux adaptateurs se disputeraient les mêmes
* contacts et où le budget compterait la charge deux fois.
*
* \return Union des relais de tous les états, sans doublon.
*/
QSet<QString> claimedRelays() const { return allRelays(); }
/*!
* \brief État SG-Ready courant (1-4).
* \return Dernier état réellement commuté ; valeur initiale tant qu'aucune transition
* n'a eu lieu. Reflète le COMMANDÉ — la PAC reste seule juge de son fonctionnement.
* \return État commandé. À la CONSTRUCTION il est RELU depuis les contacts (ECS-411), pas
* supposé : un adaptateur neuf qui se croirait en état 2 alors que la PAC tourne en forcé
* annoncerait une puissance nulle qui circule, et — pire — refuserait comme idempotente
* une remise à l'état 2 qui n'a jamais été écrite (LM-104). Reflète le COMMANDÉ : la PAC
* reste seule juge de son fonctionnement.
*/
int currentState() const { return m_currentState; }
@ -186,6 +179,18 @@ private:
//! État correspondant à un ensemble de relais ON (-1 si aucun état ne correspond).
int stateForRelays(const QList<QString> &onRelays) const;
/*!
* \brief Déduit l'état courant du motif RÉEL des contacts, au démarrage (ECS-411).
* \return État dont l'encodage correspond aux contacts fermés ; à défaut — encodage
* partiel, motif non déclaré — l'état déclaré de plus forte puissance estimée, jamais
* moins que ce qui peut circuler.
* \note Publie le relevé contact par contact AVEC l'issue, y compris quand l'issue est
* l'état neutre (ECS-411-b) : c'est précisément l'état qu'on vient vérifier après un
* démarrage, et s'en taire rendrait « contacts lus, tous ouverts » indistinguable de
* « reprise non exécutée ». Un contact injoignable passe l'issue en avertissement.
*/
int deduceStateFromRelays() const;
QSet<QString> allRelays() const;
ThingManager *m_thingManager;
@ -197,7 +202,10 @@ private:
int m_minStateHoldS;
int m_priority;
int m_currentState = 2; //!< Démarrage en NORMAL (mains off).
//! État commandé. Initialisé à 2 (neutre) puis RELU depuis les contacts par le
//! constructeur (ECS-411) : la valeur littérale n'est qu'un défaut de sécurité pour le cas
//! où l'encodage est inutilisable et où aucune commande ne sera jamais émise.
int m_currentState = 2;
// --- ECS-414 : suivi asynchrone des écritures (modèle d'ECS-410) ---------------------
int m_pending = 0;
@ -210,6 +218,9 @@ private:
//! L'adaptateur refuse alors TOUTE commande, et le dit (ECS-110 : jamais un Q_ASSERT,
//! qui disparaît du binaire release).
bool m_usable = true;
QDateTime m_lastSwitch; //!< Dernier changement d'état (null = jamais).
//! Dernière commutation. NUL = « on ne sait pas quand » (démarrage, reconstruction), ce qui
//! ARME le verrou pour sa durée configurée — pas l'inverse (ECS-412). L'armement est
//! transitoire : \c applyAction() estampille au premier \c now reçu.
QDateTime m_lastSwitch;
QDateTime m_lastActionAt;
};

View File

@ -109,18 +109,6 @@ RootMeter *EnergyArbitrator::registeredRootMeter() const
return internalRootMeter();
}
void EnergyArbitrator::registerSgReadyAdapter(SgReadyAdapter *adapter)
{
const QString id = adapter->descriptor().id;
if (m_sgReadyAdapters.contains(id)) {
qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] SgReadyAdapter déjà enregistré:" << id;
return;
}
adapter->setParent(this);
m_sgReadyAdapters[id] = adapter;
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] SgReadyAdapter enregistré:" << adapter->descriptor().label;
}
void EnergyArbitrator::registerEtmVariableLoadAdapter(EtmVariableLoadAdapter *adapter)
{
const QString id = adapter->descriptor().id;
@ -299,30 +287,6 @@ void EnergyArbitrator::rebuildLoadAdapters()
++created;
}
// LM-300 — la configuration fait autorité sur l'enregistrement en dur. Depuis que le
// mécanisme sg-ready se configure, une PAC de config peut viser les MÊMES contacts que la
// PAC de banc câblée dans energypluginnymea.cpp. Deux adaptateurs sur un même contact,
// c'est deux commandes contradictoires ET une charge comptée deux fois dans le budget
// (règle absolue 1). On retire donc l'exemplaire en dur, sans le mettre en état sûr :
// les contacts ont un nouveau propriétaire légitime, les couper serait le contredire.
QSet<QString> revendiquesParConfig;
for (ILoadAdapter *a : kept) {
if (auto *sg = dynamic_cast<SgReadyAdapter *>(a))
revendiquesParConfig.unite(sg->claimedRelays());
}
if (!revendiquesParConfig.isEmpty()) {
for (const QString &id : m_sgReadyAdapters.keys()) {
SgReadyAdapter *dur = m_sgReadyAdapters.value(id);
if (!dur || (dur->claimedRelays() & revendiquesParConfig).isEmpty())
continue;
qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] SgReadyAdapter en dur" << id
<< "retiré : ses contacts sont désormais commandés par une "
"charge sg-ready de configuration.";
m_sgReadyAdapters.remove(id);
dur->deleteLater();
}
}
// ECS-413 — ce qui reste dans m_loadAdapters n'est plus référencé par la config :
// désactivé (enabled: false) ou retiré. On l'amène à son ÉTAT SÛR **avant** de le
// détruire, sans quoi le matériel resterait dans son dernier état commandé — constaté
@ -404,7 +368,7 @@ void EnergyArbitrator::update(const QDateTime &currentDateTime)
}
// 7 : dispatch matériel (même position que l'amont — m_chargingActions rempli par getPlan())
applyActionsToAdapters(slot, currentDateTime); // PAC (kind==State) → m_sgReadyAdapters
applyActionsToAdapters(slot, currentDateTime); // charges arbitrées (Setpoint W et State)
adjustEvChargers(currentDateTime); // EV (kind==Setpoint) → proxy amont jusqu'à 3g
// 8 : frontière de télémétrie. APRÈS le dispatch, jamais avant : la charge utile de mécanisme
@ -440,14 +404,11 @@ SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const
for (auto it = m_adapters.constBegin(); it != m_adapters.constEnd(); ++it)
ctx.loads.append(it.value()->toLoadContext(now));
// --- loads[] : charges pilotées en watts (relay-router + etmvariableload), polymorphes ---
// --- loads[] : TOUTES les charges arbitrées, polymorphes (relay-router, etmvariableload,
// sg-ready) — une seule table, toutes construites depuis la configuration persistée. ---
for (auto it = m_loadAdapters.constBegin(); it != m_loadAdapters.constEnd(); ++it)
ctx.loads.append(it.value()->toLoadContext(now));
// --- loads[] : SG-Ready adapters (PAC) ---
for (auto it = m_sgReadyAdapters.constBegin(); it != m_sgReadyAdapters.constEnd(); ++it)
ctx.loads.append(it.value()->toLoadContext(now));
return ctx;
}
@ -470,28 +431,18 @@ void EnergyArbitrator::applyActionsToAdapters(const Slot &slot, const QDateTime
{
for (const LoadAction &action : slot.actions) {
// L'adaptateur applique, écrête et verrouille — il ne décide pas (règle 2).
if (action.kind == LoadAction::State) {
// Routage par loadId d'abord : un SG-Ready construit depuis la config (LM-300) vit
// dans la MÊME table que les autres charges — c'est ce qui lui vaut ECS-412
// (reconstruction incrémentale) et ECS-413 (état sûr) sans code dédié.
if (ILoadAdapter *adapter = m_loadAdapters.value(action.loadId, nullptr)) {
adapter->applyAction(action, now);
continue;
}
// Repli : PAC enregistrée en dur par registerSgReadyAdapter() (hors config).
SgReadyAdapter *adapter = m_sgReadyAdapters.value(action.loadId);
if (adapter)
adapter->applyAction(action, now);
else
qCWarning(dcNymeaEnergy()) << "[Arbitre] action State sans adaptateur SG-Ready:" << action.loadId;
} else if (action.kind == LoadAction::Setpoint) {
// Dispatch AGNOSTIQUE au type : routage par loadId, le polymorphisme ILoadAdapter
// absorbe (relay-router OU etmvariableload). Un Setpoint EV n'est PAS dans cette table
// → ignoré ici (dispatché par adjustEvChargers() amont).
ILoadAdapter *adapter = m_loadAdapters.value(action.loadId);
if (adapter)
// Dispatch AGNOSTIQUE au mécanisme : routage par loadId, le polymorphisme ILoadAdapter
// absorbe (relay-router, etmvariableload OU sg-ready). Il n'existe plus qu'UNE table de
// charges arbitrées, toutes construites depuis LoadConfigStore — c'est ce qui leur vaut
// ECS-412 (reconstruction incrémentale) et ECS-413 (état sûr) sans code dédié, et ce
// qui rend l'ensemble publié par GetLoadTelemetry égal à celui de GetLoadConfig.
// Un Setpoint EV n'est PAS dans cette table → ignoré ici (dispatché par
// adjustEvChargers() amont jusqu'à 3g).
if (action.kind == LoadAction::State || action.kind == LoadAction::Setpoint) {
if (ILoadAdapter *adapter = m_loadAdapters.value(action.loadId, nullptr))
adapter->applyAction(action, now);
else if (action.kind == LoadAction::State)
qCWarning(dcNymeaEnergy()) << "[Arbitre] action State sans adaptateur:" << action.loadId;
}
}
}
@ -543,17 +494,41 @@ void EnergyArbitrator::applyDegradedMode(const QDateTime &now)
emit chargingSchedulesChanged(); // pousse degradedMode=true (notification client L2)
const DecisionReason reason = { DecisionCode::DegradedL2 };
// Charges pilotées en watts (relay-router ET etmvariableload) : repli Setpoint(0) force=true
// (contrat rév. 3 §9). Boucle sur TOUS les ILoadAdapter — ne PAS oublier un type, sinon des
// relais ECS resteraient allumés en compteur muet (trou T2 recréé). force=true → pour le
// relay-router, coupe TOUS les relais (bypass minOn/minOff) ; pour etmvariableload, setpoint 0.
// Repli L2 de TOUTES les charges arbitrées, chacune dans SON état sûr — et c'est le kind
// DÉCLARÉ par l'adaptateur qui décide lequel, jamais une supposition sur sa classe.
//
// Ce point a été un TROU DE SÉCURITÉ, ouvert par LM-300 et refermé ici. Le repli envoyait
// un Setpoint(0) à toute la table des charges pilotées et un State(2) à une table SÉPARÉE
// réservée à la PAC codée en dur. Or une PAC construite depuis la CONFIG vit dans la
// première table : elle recevait donc un Setpoint, que SgReadyAdapter::applyAction()
// rejette d'entrée (kind non supporté) — et rien d'autre. Sous compteur muet, cette PAC-là
// restait en état 4 (forcé) indéfiniment, alors que SAFETY.md §L2 exige l'état 2. Le défaut
// était invisible : le motif DEGRADED_L2 apparaissait bien dans la télémétrie, seule
// l'écriture manquait.
//
// La leçon tient en une ligne : dès qu'un mécanisme rejoint la table commune, tout code qui
// fabrique une action DOIT lire \c supportedKinds au lieu de présumer du kind.
for (ILoadAdapter *adapter : m_loadAdapters) {
const LoadDescriptor d = adapter->descriptor();
LoadAction la;
la.loadId = adapter->descriptor().id;
la.kind = LoadAction::Setpoint;
la.powerW = 0;
la.loadId = d.id;
la.force = true;
la.reason = reason;
if (d.supportedKinds.contains(LoadAction::State)) {
// PAC SG-Ready : état 2 (NORMAL — mains off), JAMAIS état 1 (blocage). Sous
// compteur muet on cesse de piloter : la PAC chauffe selon son propre thermostat
// (la bloquer = maison qui ne chauffe plus sans raison visible, cf. SAFETY.md).
la.kind = LoadAction::State;
la.state = 2;
} else if (d.supportedKinds.contains(LoadAction::Setpoint)) {
// Charges en watts : relay-router → tous relais ouverts ; etmvariableload → 0 W.
la.kind = LoadAction::Setpoint;
la.powerW = 0;
} else {
qCWarning(dcNymeaEnergy()) << "[Arbitre] Repli L2 : la charge" << d.id
<< "ne déclare aucun kind connu — AUCUN repli appliqué.";
continue;
}
adapter->applyAction(la, now);
}
@ -567,19 +542,6 @@ void EnergyArbitrator::applyDegradedMode(const QDateTime &now)
ev->setMaxChargingCurrent(ev->maxChargingCurrentMinValue(), now, true);
}
// SG-Ready (PAC) : repli en état 2 (NORMAL — mains off), JAMAIS état 1 (blocage).
// Sous compteur muet on cesse de piloter : la PAC chauffe selon son propre thermostat
// (la bloquer = maison qui ne chauffe plus sans raison visible). force=true → bypass minStateHold.
for (SgReadyAdapter *adapter : m_sgReadyAdapters) {
LoadAction la;
la.loadId = adapter->descriptor().id;
la.kind = LoadAction::State;
la.state = 2;
la.force = true;
la.reason = reason;
adapter->applyAction(la, now);
}
// Batterie (aucune charge réseau) : repli ajouté avec son adaptateur (3f).
// Reprise à froid (critère ECS-411) : un client qui se connecte APRÈS la bascule doit lire
@ -615,8 +577,6 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const
QSet<QString> couvertes;
for (const LoadAction &action : slot.actions) {
ILoadAdapter *adapter = m_loadAdapters.value(action.loadId, nullptr);
if (!adapter)
adapter = m_sgReadyAdapters.value(action.loadId, nullptr);
if (!adapter)
continue; // EV : décidé en amont du waterfall, hors de cette charge utile.
couvertes.insert(action.loadId);
@ -655,10 +615,6 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const
if (!couvertes.contains(it.key()))
qCWarning(dcNymeaEnergy()) << "[Arbitre] Télémétrie : charge" << it.key()
<< "arbitrée mais SANS décision dans le plan — omise.";
for (auto it = m_sgReadyAdapters.constBegin(); it != m_sgReadyAdapters.constEnd(); ++it)
if (!couvertes.contains(it.key()))
qCWarning(dcNymeaEnergy()) << "[Arbitre] Télémétrie : charge" << it.key()
<< "arbitrée mais SANS décision dans le plan — omise.";
snap.insert(QStringLiteral("loads"), loads);
m_telemetry = snap;
@ -701,10 +657,6 @@ void EnergyArbitrator::buildDegradedTelemetry(const QDateTime &now)
ids.sort();
for (const QString &id : ids)
entree(id, m_loadAdapters.value(id));
QStringList sgIds = m_sgReadyAdapters.keys();
sgIds.sort();
for (const QString &id : sgIds)
entree(id, m_sgReadyAdapters.value(id));
snap.insert(QStringLiteral("loads"), loads);
m_telemetry = snap;

View File

@ -83,13 +83,6 @@ public:
*/
RootMeter *registeredRootMeter() const;
/*!
* \brief Enregistre un SgReadyAdapter (PAC) pour inclusion dans le contexte et le dispatch.
* \param adapter Adaptateur à enregistrer ; son \c descriptor().id doit être unique.
* Adopté comme enfant Qt de l'arbitre. Appelé par le test (setup) ou la config production.
*/
void registerSgReadyAdapter(SgReadyAdapter *adapter);
/*!
* \brief Enregistre un EtmVariableLoadAdapter (ECS/routeur, interface \c etmvariableload)
* pour inclusion dans le contexte et le dispatch \c Setpoint (W).
@ -112,8 +105,9 @@ public:
* \brief Branche le store de config charge pilotée : construit les adaptateurs
* \c etmvariableload depuis la config et les reconstruit à chaque \c changed().
*
* Remplace l'enregistrement en dur (le « 3g »). Les charges \c enabled==false ne sont
* PAS construites (exclues de l'arbitrage, contrat §9). La PAC SG-Ready reste hors config.
* Les charges \c enabled==false ne sont PAS construites (exclues de l'arbitrage,
* contrat §9). Depuis le lot B-bis, **toutes** les charges arbitrées passent par ici — y
* compris la PAC SG-Ready, qui n'a plus d'exemplaire codé en dur.
* \param store Store persistant (propriété de l'appelant ; non adopté).
*/
void setLoadConfigStore(LoadConfigStore *store);
@ -236,18 +230,18 @@ private:
/*!
* \brief Synchronise m_adapters avec les EvCharger actuellement enregistrés.
* Crée les adapters manquants, supprime les adapters obsolètes.
* \note Découverte etmvariableload/SG-Ready via config — câblé en T4.
* \note Les charges non-EV, elles, viennent toutes de \c LoadConfigStore.
*/
void syncAdapters();
/*!
* \brief Applique les actions d'un Slot aux LoadAdapters non-EV.
*
* Itère \c slot.actions et dispatche selon le kind : \c State → \c m_sgReadyAdapters
* (SgReadyAdapter, PAC). Les actions EV (\c Setpoint) restent dispatchées par
* \c adjustEvChargers() amont jusqu'à 3g ; les charges etmvariableload (\c Setpoint W)
* seront dispatchées via leur adaptateur en T3/T4. L'adaptateur écrête/verrouille lui-même
* et ignore toute action sans \c reason ou de kind non supporté — aucune décision ici (règle 2).
* Itère \c slot.actions et dispatche par \c loadId dans la table UNIQUE des charges
* arbitrées ; le polymorphisme \c ILoadAdapter absorbe le mécanisme. Les actions EV
* (\c Setpoint) restent dispatchées par \c adjustEvChargers() amont jusqu'à 3g — l'EV
* n'est pas dans cette table. L'adaptateur écrête/verrouille lui-même et ignore toute
* action sans \c reason ou de kind non supporté — aucune décision ici (règle 2).
* \param slot Créneau courant retourné par le scheduler.
* \param now Temps de cycle (\c ctx.timestamp) — transmis aux adaptateurs pour une
* évaluation des verrous cohérente avec celle vue par le scheduler.
@ -262,10 +256,12 @@ private:
/*!
* \brief (Re)construit \c m_loadAdapters depuis \c m_loadConfigStore (rév. 3).
* Purge les adaptateurs existants (deleteLater) puis crée, par config \c enabled==true, un
* \c RelayRouter (si \c relays[]) ou un \c EtmVariableLoadAdapter (sinon). La distinction de
* TYPE vit ici ; au-dessus tout est \c ILoadAdapter (Setpoint W). Appelé au branchement du
* store et à chaque \c LoadConfigStore::changed().
* Crée, par config \c enabled==true, un \c SgReadyAdapter (si \c sgReady), un
* \c RelayRouter (si \c relays[]) ou un \c EtmVariableLoadAdapter (sinon) — une branche
* de fabrique par mécanisme, LM-301. La distinction de TYPE vit ici ; au-dessus tout est
* \c ILoadAdapter. Reconstruction INCRÉMENTALE (ECS-412) ; ce qui sort de la config passe
* par \c applySafeState() avant destruction (ECS-413). Appelé au branchement du store et
* à chaque \c LoadConfigStore::changed().
*/
void rebuildLoadAdapters();
@ -338,8 +334,13 @@ private:
RuleBasedScheduler *m_scheduler = nullptr;
QHash<QString, EvAdapter *> m_adapters; //!< loadId (ThingId string) → EvAdapter*.
QHash<QString, SgReadyAdapter *> m_sgReadyAdapters; //!< loadId → SgReadyAdapter* (PAC).
QHash<QString, ILoadAdapter *> m_loadAdapters; //!< loadId → charge pilotée (relay-router | etmvariableload).
//! loadId → charge ARBITRÉE, tous mécanismes confondus (relay-router | etmvariableload |
//! sg-ready). Table UNIQUE, et c'est un invariant de contrat : toutes ses entrées viennent
//! de \c LoadConfigStore, donc l'ensemble publié par \c NymeaEnergy.GetLoadTelemetry est
//! exactement celui de \c GetLoadConfig. Une seconde table existait pour la PAC de banc
//! codée en dur ; elle a disparu avec elle (lot B-bis), et avec elle la possibilité qu'une
//! charge soit arbitrée sans être configurable.
QHash<QString, ILoadAdapter *> m_loadAdapters;
//! loadId → config AYANT SERVI à construire l'adaptateur. Permet le rebuild incrémental
//! (ECS-412) : on ne reconstruit que si le matériel a changé.
QHash<QString, LoadConfig> m_builtFrom;

View File

@ -578,10 +578,12 @@ attendre un quart d'heure pour sortir d'un état non voulu n'est pas défendable
écrit, sinon l'hypothèse conservatrice masquerait l'échec et la transition
paraîtrait réussie sans qu'aucune écriture n'ait été tentée.
> **Reste au lot de mise en configuration du `SgReadyAdapter`** : les paliers
> `{3: 1500, 4: 3000}` demeurent codés en dur (`energypluginnymea.cpp:66-76`).
> Ce lot porte sa propre précondition — retirer `Q_ASSERT(m_stateRelays.contains(2))`
> (`sgreadyadapter.cpp:36`) au profit d'un refus explicite, cf. ECS-110.
> **FAIT — lot B-bis (`1.15.2+etm15`)** : le `SgReadyAdapter` se configure comme les autres
> charges ; le bloc codé en dur d'`energypluginnymea.cpp` est supprimé, et avec lui les paliers
> `{3: 1500, 4: 3000}`, qui décrivaient une PAC **inventée**. Reconduits dans la config du banc,
> ils y sont marqués **provisoires et non mesurés** (`docs/TEST_TERRAIN.md` §1.d). La
> précondition avait été levée en amont : `Q_ASSERT(m_stateRelays.contains(2))` a cédé la place
> au refus explicite `m_usable`, présent dans le binaire release (ECS-110).
> **Le principe dégagé par ces deux exigences ne vit pas ici.** « Supposer
> conservateur pour annoncer, tenter systématiquement pour agir » vaut pour tout
@ -813,6 +815,9 @@ mécanique reste **L0**. Perte de sonde → mode sans sonde, pas arrêt d'urgenc
| ECS-410 | simulation | `testEcsPartialFailure` — cible, retour arrière, arrêt total, défaut collant |
| ECS-411 | simulation | `testEcsRestartRecovery` |
| ECS-413 | simulation | `testEcsDisableLeavesSafeState` — + cas négatif : un rebuild sans désactivation ne coupe pas |
| ECS-411 / -411-b **au SG-Ready** | simulation | `testSgReadyRestartRecovery` — état RELU sur les contacts (4, puis neutre, puis contact injoignable → avertissement), relevé contact par contact avec l'issue, **et** le corollaire LM-104 : la remise au neutre ÉCRIT au lieu de se croire idempotente |
| ECS-412 **au `minStateHold`** | simulation | `testSgReadySurplus` volet 0 (armement à froid : gel puis EXPIRATION) + `testSgReadyPartialFailure` + `testRuntimeViewIsSubstantial` — l'armement est paresseux, jamais permanent |
| Repli L2 d'une PAC **de configuration** | simulation | `testLoadTelemetryRpc` — sous compteur muet, `state == 2` **et contacts K1/K2 ouverts** : le motif seul ne prouvait rien, c'est l'écriture qui manquait (lot B-bis) |
| ECS-414 | simulation | `testSgReadyPartialFailure` — plancher = état 2, atomicité du repli, contact injoignable |
| LM-302 (frontière RPC) | simulation | `testLoadConfigRpc` §3bis — va-et-vient Get → Set réinjecté tel quel (vérifié : le test échoue sans le correctif) |
| ECS-110-b | simulation | `testThingOwnershipIsExclusive` — conflit entre charges (3 mécanismes), formes d'uuid, id dupliqué, doublons intra-charge, tolérance d'une charge désactivée |
@ -850,9 +855,10 @@ nombre de commutations **par relais** (ECS-305 le rend enfin mesurable).
Allocation du surplus, prévision et plan journalier (optimiseur ETM), contacteur heures
creuses, transport matériel (Waveshare D8, session dédiée), PAC hors SG-Ready.
Signalé au passage, hors ECS : les `ThingId` K1/K2 et les paliers
`{3:1500, 4:3000}` de la PAC du banc sont codés en dur dans
`energypluginnymea.cpp:68-74`. À traiter avec la couche config, pas ici.
Signalé au passage, hors ECS — **résolu depuis, lot B-bis** : les `ThingId` K1/K2 et les paliers
`{3:1500, 4:3000}` de la PAC du banc étaient codés en dur dans `energypluginnymea.cpp`. Ils sont
désormais une `LoadConfig` `sg-ready` ordinaire, et il n'existe plus aucun chemin
d'enregistrement en dur — donc plus de charge arbitrée hors configuration.
---

View File

@ -129,6 +129,21 @@ une branche de fabrique. Rien d'autre ne bouge. **Vérifié à l'usage** : l'ajo
branche de fabrique dans `rebuildLoadAdapters()`, et un champ optionnel au schéma
RPC. Aucun code d'arbitrage n'a bougé.
**LM-301-b — La limite de LM-301, trouvée au lot B-bis (2026-08-25).** « Une charge utile, une
branche de fabrique » vaut pour **construire** un mécanisme, pas pour l'**alimenter en actions**.
Quand `sg-ready` a rejoint la table commune des charges arbitrées, un code d'arbitrage a bel et
bien dû bouger : le repli du mode dégradé L2 fabriquait un `Setpoint(0)` pour **toute** la table,
en présumant du kind. Une PAC de configuration recevait donc une action que `SgReadyAdapter`
rejette d'entrée — et rien d'autre : elle restait en **état 4 (forcé)** sous compteur muet, quand
`SAFETY.md` §L2 exige l'état 2. Le défaut ne se voyait pas dans la télémétrie, où le motif
`DEGRADED_L2` s'affichait correctement ; seule l'**écriture** manquait.
**Règle qui en sort** : tout code qui **fabrique** une action pour une charge quelconque DOIT
lire `descriptor().supportedKinds` et se refuser explicitement s'il n'y trouve rien de connu —
jamais présumer du kind à partir de la table où la charge se trouve. C'est le pendant, côté
actions, de ce que LM-302-b dit côté validation : ce qui est vrai « pour toutes les charges »
n'appartient à aucune charge utile.
**LM-302-b — Ce qu'une charge utile ne peut pas valider.** L'exclusivité d'un Thing est
une propriété de l'**ensemble** des charges, pas d'une charge : aucune charge utile ne
peut la vérifier, quelle que soit sa rigueur. Elle vit donc au niveau du store

View File

@ -67,6 +67,106 @@ using namespace nymeaserver;
#include "simulationtestpoint.h"
namespace {
#ifdef ETM_ARBITRATOR
/*! \brief Charge utile de configuration d'une PAC SG-Ready 2 bits (K1/K2), forme §4.
*
* Depuis le lot B-bis il n'existe plus d'enregistrement codé en dur : une PAC se déclare comme
* n'importe quelle autre charge. Ce fabricant évite d'étaler le même littéral dans chaque test
* — et rend visible qu'il n'y a QU'UNE façon de déclarer une PAC.
*/
QVariantMap sgReadyConfigMap(const QString &id, const QString &label,
const QUuid &k1, const QUuid &k2,
double p3, double p4, int minStateHoldS, int priority)
{
return QVariantMap{
{"id", id}, {"label", label}, {"adapter", "sg-ready"}, {"mode", "fixed"},
{"priority", priority}, {"enabled", true},
{"sgReady", QVariantMap{
{"minStateHoldS", minStateHoldS},
{"states", QVariantList()
<< QVariantMap{{"state", 1}, {"relays", QVariantList() << k1.toString()},
{"estimatedPowerW", 0.0}}
<< QVariantMap{{"state", 2}, {"relays", QVariantList()},
{"estimatedPowerW", 0.0}}
<< QVariantMap{{"state", 3}, {"relays", QVariantList() << k2.toString()},
{"estimatedPowerW", p3}}
<< QVariantMap{{"state", 4},
{"relays", QVariantList() << k1.toString() << k2.toString()},
{"estimatedPowerW", p4}}}}}};
}
/*! \brief Capture les messages Qt le temps d'une portée, pour asserter sur le TEXTE publié.
*
* AGENTS.md est explicite : les tests des règles 7-b à 7-d portent sur le TEXTE, jamais sur la
* non-vacuité d'une chaîne — « un test motif non vide n'aurait rien vu, dans aucun de ces cas ».
* Un relevé de reprise (ECS-411-b) relève de la même exigence : ce qui compte est qu'il DISE
* ce qui a été lu, pas qu'il existe.
*/
class JournalCapture
{
public:
/*! \brief Ouvre une capture. Plusieurs peuvent être vivantes en même temps.
*
* Le handler n'est installé QU'UNE FOIS, et chaîné au handler d'ORIGINE mémorisé à cette
* occasion. Le réinstaller à chaque construction serait un piège : \c qInstallMessageHandler
* rend le handler courant, qui est alors \c collecte() elle-même — la capture se chaînerait
* sur elle-même, chaque message se rappellerait indéfiniment en concaténant dans
* \c m_texte, et le processus mourrait en OOM. Défaut constaté le 2026-08-25 : la suite
* entière atteignait 7-8 Gio et emportait la session graphique, alors qu'un seul test —
* celui-ci, le seul à ouvrir DEUX captures — en était responsable.
*/
JournalCapture()
{
if (s_pile.isEmpty())
s_origine = qInstallMessageHandler(&JournalCapture::collecte);
s_pile.append(this);
}
//! Referme la capture ; le handler d'origine n'est rendu qu'à la dernière.
~JournalCapture()
{
s_pile.removeAll(this);
if (s_pile.isEmpty())
qInstallMessageHandler(s_origine);
}
bool contains(const QString &fragment) const { return m_texte.contains(fragment); }
bool contient(QtMsgType type) const { return m_types.contains(type); }
QString texte() const { return m_texte; }
private:
static void collecte(QtMsgType type, const QMessageLogContext &ctx, const QString &msg)
{
// Toutes les captures vivantes reçoivent le message — c'est ce que le chaînage visait.
for (JournalCapture *c : s_pile) {
c->m_texte += msg + QLatin1Char('\n');
c->m_types.insert(type);
}
if (s_origine)
s_origine(type, ctx, msg); // le message reste visible dans la sortie de test
}
QString m_texte;
QSet<QtMsgType> m_types;
static QList<JournalCapture *> s_pile;
static QtMessageHandler s_origine;
};
QList<JournalCapture *> JournalCapture::s_pile;
QtMessageHandler JournalCapture::s_origine = nullptr;
//! \brief Lit une grandeur entière du mécanisme d'une charge, par la frontière PUBLIQUE.
//! \return La valeur, ou -1 si la charge n'est pas publiée — un -1 fait échouer l'assertion
//! appelante, ce qu'un 0 silencieux ne ferait pas.
int mechanismInt(EnergyArbitrator *arb, const QString &loadId, const char *cle)
{
for (const QVariant &v : arb->loadTelemetry().value(QStringLiteral("loads")).toList())
if (v.toMap().value(QStringLiteral("loadId")).toString() == loadId)
return v.toMap().value(QStringLiteral("mechanism")).toMap()
.value(QLatin1String(cle)).toInt();
return -1;
}
#endif
}
void Simulation::testEcsSurplusPV()
{
#ifndef ETM_ARBITRATOR
@ -570,47 +670,73 @@ void Simulation::testSgReadySurplus()
Thing *relayK2 = tm->findConfiguredThing(k2);
QVERIFY(relayK1 && relayK2);
SgReadyAdapter *pac = new SgReadyAdapter(
tm, "pac-test", "PAC test",
QHash<int, QList<QString>>({ {1, {k1.toString()}}, {2, {}},
{3, {k2.toString()}}, {4, {k1.toString(), k2.toString()}} }),
pacPower, 300, 1, arbitrator);
arbitrator->registerSgReadyAdapter(pac);
// [lot B-bis] La PAC est une charge de CONFIGURATION comme les autres — plus aucun
// adaptateur codé en dur, donc plus d'enregistrement direct : le test emprunte le chemin
// de production (SetConfigs → changed → rebuild → SgReadyAdapter).
const QString cfgPacPath = QDir::tempPath() + "/etm-loadcfg-sgready.json";
QFile::remove(cfgPacPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPacPath.toUtf8());
LoadConfigStore *storePac = new LoadConfigStore(arbitrator);
arbitrator->setLoadConfigStore(storePac);
LoadConfigs cfgsPac;
cfgsPac.append(LoadConfig::fromMap(sgReadyConfigMap("pac-test", "PAC test", k1, k2,
/*p3*/ 1500.0, /*p4*/ 3000.0,
/*minStateHoldS*/ 300, /*priority*/ 1)));
QString errPac;
QVERIFY2(storePac->setConfigs(cfgsPac, &errPac), errPac.toUtf8());
auto setMeterW = [&](double signedW){ meter->setStateValue("currentPower", signedW); }; // <0 export
auto cycle = [&](const QDateTime &now){ arbitrator->simulationCallUpdate(now); QCoreApplication::processEvents(); };
// L'état de la PAC se lit désormais par la frontière publique — celle-là même que l'app
// consomme. Le test gagne au passage la vérification que la télémétrie suit l'arbitrage.
auto etatPac = [&](){ return mechanismInt(arbitrator, "pac-test", "state"); };
// --- Volet 0 : ARMEMENT À FROID du verrou (ECS-412 appliqué au minStateHold) ---
// Un adaptateur neuf ne sait PAS quand la PAC a commuté pour la dernière fois : un
// m_lastSwitch nul vaut « commutation venant d'avoir lieu », pas « jamais commuté ». Sans
// cela, un redémarrage de nymead pendant que le compresseur tourne autoriserait une coupure
// immédiate — du court-cycling provoqué par un redémarrage de service, exactement ce que
// minStateHold existe pour empêcher.
setMeterW(-3000); cycle(t0); // budget largement suffisant pour l'état 4…
QCOMPARE(etatPac(), 2); // …mais GELÉ par l'armement à froid
// L'armement est PARESSEUX : il expire. C'est le défaut d'ECS-412 qu'on refuse de refaire —
// un armement PERMANENT gelait toute charge à froid, sans issue (pas d'enclenchement → pas
// de commutation → pas d'estampille → blocage circulaire). L'estampille est posée au
// PREMIER cycle reçu, refusé ou non, si bien que le compte à rebours court dès maintenant.
const QDateTime tA = t0.addSecs(301); // > minStateHoldS (300) : le verrou a expiré
// --- Volet 1 : montée d'états 2 → 3 → 4 (mapping sémantique) ---
setMeterW(-1000); cycle(t0); // budget 1000 < P3 → état 2 (normal)
QCOMPARE(pac->currentState(), 2);
setMeterW(-2000); cycle(t0); // budget 2000 ≥ P3 → état 3 (reco)
QCOMPARE(pac->currentState(), 3);
setMeterW(-1000); cycle(tA); // budget 1000 < P3 → état 2 (normal)
QCOMPARE(etatPac(), 2);
setMeterW(-2000); cycle(tA); // budget 2000 ≥ P3 → état 3 (reco)
QCOMPARE(etatPac(), 3);
QCOMPARE(relayK2->stateValue("power").toBool(), true);
QCOMPARE(relayK1->stateValue("power").toBool(), false);
setMeterW(-2500); cycle(t0.addSecs(400)); // budget 2500+1500=4000 ≥ P4×1,2 → état 4 (hold écoulé)
QCOMPARE(pac->currentState(), 4);
setMeterW(-2500); cycle(tA.addSecs(400)); // budget 2500+1500=4000 ≥ P4×1,2 → état 4 (hold écoulé)
QCOMPARE(etatPac(), 4);
QCOMPARE(relayK1->stateValue("power").toBool(), true);
QCOMPARE(relayK2->stateValue("power").toBool(), true);
// --- Volet 2 : hystérésis 3↔4 (budget oscille dans la zone morte [P4×1,0 ; P4×1,2)) ---
// hold écoulé à chaque cycle (lastSwitch=T0+400) → c'est la ZONE MORTE qui tient l'état 4, pas le verrou.
setMeterW(-300); cycle(t0.addSecs(800)); // budget 300+3000=3300 ∈ [3000,3600) → reste 4
QCOMPARE(pac->currentState(), 4);
setMeterW(-100); cycle(t0.addSecs(1200)); // budget 3100 → reste 4
QCOMPARE(pac->currentState(), 4);
setMeterW(-500); cycle(t0.addSecs(1600)); // budget 3500 → reste 4
QCOMPARE(pac->currentState(), 4);
// hold écoulé à chaque cycle (lastSwitch=tA+400) → c'est la ZONE MORTE qui tient l'état 4, pas le verrou.
setMeterW(-300); cycle(tA.addSecs(800)); // budget 300+3000=3300 ∈ [3000,3600) → reste 4
QCOMPARE(etatPac(), 4);
setMeterW(-100); cycle(tA.addSecs(1200)); // budget 3100 → reste 4
QCOMPARE(etatPac(), 4);
setMeterW(-500); cycle(tA.addSecs(1600)); // budget 3500 → reste 4
QCOMPARE(etatPac(), 4);
// En-dessous de P4×1,0 → sort enfin de l'état 4 (vers 3).
setMeterW(200); cycle(t0.addSecs(2000)); // import 200 → budget -200+3000=2800 < 3000 → état 3
QCOMPARE(pac->currentState(), 3);
setMeterW(200); cycle(tA.addSecs(2000)); // import 200 → budget -200+3000=2800 < 3000 → état 3
QCOMPARE(etatPac(), 3);
// --- Volet 3 : protection court-cycling (changement avant minStateHold → GELÉ) ---
// lastSwitch=T0+2000. À T0+2100 (elapsed 100 < hold 300) : surplus abondant mais GELÉ en 3.
setMeterW(-3000); cycle(t0.addSecs(2100));
QCOMPARE(pac->currentState(), 3); // gelé malgré budget ≥ P4×1,2 (protection compresseur)
// À T0+2400 (elapsed 400 > hold) : MÊME surplus → bascule en 4. Seul le temps simulé a changé.
setMeterW(-3000); cycle(t0.addSecs(2400));
QCOMPARE(pac->currentState(), 4);
// lastSwitch=tA+2000. À tA+2100 (elapsed 100 < hold 300) : surplus abondant mais GELÉ en 3.
setMeterW(-3000); cycle(tA.addSecs(2100));
QCOMPARE(etatPac(), 3); // gelé malgré budget ≥ P4×1,2 (protection compresseur)
// À tA+2400 (elapsed 400 > hold) : MÊME surplus → bascule en 4. Seul le temps simulé a changé.
setMeterW(-3000); cycle(tA.addSecs(2400));
QCOMPARE(etatPac(), 4);
// ===================== Volet 4 : budget PARTAGÉ ECS(etmvariableload)↔PAC =====================
// [T3] Surplus 3000 W ; ECS palier 2400 W (etmvariableload, kind Setpoint), PAC P3 = 1500.
@ -637,23 +763,38 @@ void Simulation::testSgReadySurplus()
QVERIFY(eThing);
eThing->setStateValue("currentPowerW", 0);
// ECS : 1 palier à 2400 W (fixed). La charge ne tire encore rien (currentPowerW=0).
EtmVariableLoadAdapter *ecsWf = new EtmVariableLoadAdapter(
tm2, eId.toString(), "ECS waterfall", QList<int>({0, 2400}), 2400, ecsPrio, LoadNeeds(), arb);
arb->registerEtmVariableLoadAdapter(ecsWf);
// LES DEUX charges viennent de la CONFIGURATION (lot B-bis) : c'est le chemin de
// production, et le seul depuis que la PAC n'a plus d'exemplaire codé en dur. Les
// mélanger — un adaptateur enregistré en direct + un store — ne marche pas, et c'est
// voulu : rebuildLoadAdapters() met en état sûr puis détruit tout ce que la config ne
// référence pas (ECS-413). Une charge « en dur » disparaîtrait au premier SetLoadConfig.
const QString cfgWfPath = QDir::tempPath() + "/etm-loadcfg-waterfall.json";
QFile::remove(cfgWfPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgWfPath.toUtf8());
LoadConfigStore *storeWf = new LoadConfigStore(arb);
arb->setLoadConfigStore(storeWf);
SgReadyAdapter *pacWf = new SgReadyAdapter(
tm2, "pac-wf", "PAC waterfall",
QHash<int, QList<QString>>({ {1, {j1.toString()}}, {2, {}},
{3, {j2.toString()}}, {4, {j1.toString(), j2.toString()}} }),
pacPower, 300, pacPrio, arb);
arb->registerSgReadyAdapter(pacWf);
LoadConfigs cfgsWf;
// ECS : 1 palier à 2400 W (fixed). La charge ne tire encore rien (currentPowerW=0).
cfgsWf.append(LoadConfig::fromMap(QVariantMap{
{"id", eId.toString()}, {"label", "ECS waterfall"}, {"adapter", "etmvariableload"},
{"mode", "fixed"}, {"powerLevels", QVariantList() << 0 << 2400}, {"maxPowerW", 2400},
{"priority", ecsPrio}, {"enabled", true}}));
cfgsWf.append(LoadConfig::fromMap(sgReadyConfigMap("pac-wf", "PAC waterfall", j1, j2,
1500.0, 3000.0, 300, pacPrio)));
QString errWf;
QVERIFY2(storeWf->setConfigs(cfgsWf, &errWf), errWf.toUtf8());
m2->setStateValue("currentPower", -3000); // export 3000 W
// Cycle d'AMORÇAGE : il désarme le verrou à froid de la PAC (ECS-412) sans rien
// décider d'intéressant. La mesure se fait au cycle suivant, verrou expiré — sinon on
// mesurerait la protection compresseur au lieu de l'ordre de service.
arb->simulationCallUpdate(t0);
QCoreApplication::processEvents();
ecsSetpointOut = ecsWf->currentSetpointW();
pacStateOut = pacWf->currentState();
arb->simulationCallUpdate(t0.addSecs(301));
QCoreApplication::processEvents();
ecsSetpointOut = mechanismInt(arb, eId.toString(), "setpointW");
pacStateOut = mechanismInt(arb, "pac-wf", "state");
};
double ecsSetpoint = -1;
@ -689,6 +830,10 @@ void Simulation::testEcsRelayTopologies()
a.powerW = w; a.reason = { DecisionCode::SurplusSetpoint };
return a;
};
// Les deux sorties sont initialisées par l'APPELANT et vérifiées APRÈS l'appel : un
// QVERIFY en échec sort de CE lambda, pas du test, et laisserait sinon des pointeurs
// non initialisés déréférencés juste après — comportement indéfini au lieu d'un échec
// de test lisible.
auto freshSetup = [&](ThingManager *&tm, QObject *&owner) {
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
@ -701,7 +846,9 @@ void Simulation::testEcsRelayTopologies()
// ===================== Topologie 1 : 1 relais (dégénéré [0, 2000]) =====================
{
ThingManager *tm; QObject *owner; freshSetup(tm, owner);
ThingManager *tm = nullptr; QObject *owner = nullptr;
freshSetup(tm, owner);
QVERIFY(tm && owner);
QUuid r = addPowerSwitch(2000, 26661);
Thing *relay = tm->findConfiguredThing(r);
QVERIFY(relay);
@ -719,7 +866,9 @@ void Simulation::testEcsRelayTopologies()
// ============= Topologie 2 : 3 relais 500/1000/2000 (NON-CASCADÉ, off-before-on) =============
{
ThingManager *tm; QObject *owner; freshSetup(tm, owner);
ThingManager *tm = nullptr; QObject *owner = nullptr;
freshSetup(tm, owner);
QVERIFY(tm && owner);
QUuid r500 = addPowerSwitch(500, 26661);
QUuid r1000 = addPowerSwitch(1000, 26662);
QUuid r2000 = addPowerSwitch(2000, 26663);
@ -750,7 +899,9 @@ void Simulation::testEcsRelayTopologies()
// ============= Topologie 3 : DÉDUPLICATION (deux relais identiques 1000 W) =============
{
ThingManager *tm; QObject *owner; freshSetup(tm, owner);
ThingManager *tm = nullptr; QObject *owner = nullptr;
freshSetup(tm, owner);
QVERIFY(tm && owner);
QUuid rA = addPowerSwitch(1000, 26661);
QUuid rB = addPowerSwitch(1000, 26662);
Thing *tA = tm->findConfiguredThing(rA);
@ -1555,12 +1706,23 @@ void Simulation::testSgReadyPartialFailure()
// Ce que ce test verrouille n'est pas le défaut lui-même, c'est le PLANCHER : le repli
// ramène K1 à l'OUVERTURE, c'est-à-dire l'état 2 sur cet encodage. Un plancher « contacts
// ouverts par principe » aurait pu, sur un autre câblage, valoir l'état 1 — le BLOCAGE.
tK1->setStateValue("power", false); // AVANT construction : c'est ce que la reprise lira
SgReadyAdapter *pac = new SgReadyAdapter(tm, "pac-ko", "PAC contact HS",
QHash<int, QList<QString>>({ {1,{k2ko}}, {2,{}}, {3,{k1.toString()}}, {4,{k1.toString(), k2ko}} }),
QHash<int, double>({ {1,0.0}, {2,0.0}, {3,1500.0}, {4,3000.0} }), 900, 2, arb);
tK1->setStateValue("power", false);
// ECS-411 appliqué au SG-Ready : l'état de départ est RELU, pas supposé. K1 est lu ouvert,
// K2 est INJOIGNABLE donc supposé fermé (LM-104) — le motif {K2} est exactement l'encodage
// de l'état 1 sur ce câblage. L'adaptateur démarre donc en 1, et non en 2 « par défaut ».
QCOMPARE(pac->currentState(), 1);
// ECS-412 — armement à froid : la première commande est REFUSÉE (minStateHold 900 s), et
// c'est ce refus qui pose l'estampille. Le compte à rebours court donc dès maintenant :
// l'armement est paresseux, pas permanent.
pac->applyAction(st(4), t0);
QCOMPARE(pac->currentState(), 1);
pac->applyAction(st(4), t0.addSecs(901)); // verrou expiré → l'échelle d'échec démarre
laisserRetomber();
QVERIFY2(pac->faulted(), "un contact jamais confirmable doit mener au défaut");
@ -1572,13 +1734,13 @@ void Simulation::testSgReadyPartialFailure()
QCOMPARE(tK1->stateValue("power").toBool(), false);
QCOMPARE(pac->telemetry().available, false);
LoadContext c = pac->toLoadContext(t0.addSecs(60));
LoadContext c = pac->toLoadContext(t0.addSecs(961));
QCOMPARE(c.telemetry.available, false);
// Charge FIGÉE : plancher == plafond.
QCOMPARE(c.telemetry.minState, c.telemetry.maxState);
// Plus aucune commande, même bien après le verrou.
pac->applyAction(st(3), t0.addSecs(7200));
pac->applyAction(st(3), t0.addSecs(8100));
laisserRetomber();
QCOMPARE(tK1->stateValue("power").toBool(), false);
QVERIFY(pac->faulted());
@ -4005,20 +4167,55 @@ void Simulation::testLoadTelemetryRpc()
QVariantList notifs = checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged");
QVERIFY2(!notifs.isEmpty(), "aucune notification LoadTelemetryChanged — c'est le constat "
"d'origine, arbitre actif et frontière RPC muette");
QVariantMap premier = notifs.last().toMap().value("params").toMap();
// PREMIER cycle : la PAC est GELÉE par l'armement à froid du minStateHold (ECS-412). Ce
// n'est pas un détail de test — c'est ce que l'app verra après CHAQUE redémarrage de
// nymead, pendant la durée configurée. La télémétrie doit donc le dire, et le dire en
// clair : un verrou nommé, des secondes restantes, et un motif qui accuse le verrou et
// non le budget.
{
QVariantMap parIdC1;
for (const QVariant &v : premier.value("loads").toList())
parIdC1.insert(v.toMap().value("loadId").toString(), v.toMap());
const QVariantMap pacC1 = parIdC1.value("pac-telemetrie").toMap();
QCOMPARE(pacC1.value("mechanism").toMap().value("state").toInt(), 2);
QCOMPARE(qRound(pacC1.value("allocatedW").toDouble()), 0);
QCOMPARE(pacC1.value("decision").toMap().value("code").toString(),
QString(DecisionCode::LockMinStateHold));
QCOMPARE(pacC1.value("lock").toMap().value("kind").toString(), QString("minStateHold"));
QCOMPARE(pacC1.value("lock").toMap().value("remainingS").toInt(), 300);
// Le budget du cycle en tient compte : la PAC n'a rien reçu.
QCOMPARE(qRound(premier.value("budget").toMap().value("allocatedW").toDouble()), 4000);
// Le relais, lui, vient de commuter : sa fenêtre minOn est pleine.
const QVariantMap relaisC1 = parIdC1.value("ecs-relais-telemetrie").toMap();
QCOMPARE(relaisC1.value("lock").toMap().value("kind").toString(), QString("minOn"));
QCOMPARE(relaisC1.value("lock").toMap().value("remainingS").toInt(), 300);
}
// ---- Le verrou EXPIRE, et la PAC prend sa part ------------------------------------------
notificationSpy.clear();
const QDateTime t1 = t0.addSecs(301); // > minStateHoldS (300)
arb->simulationCallUpdate(t1);
QCoreApplication::processEvents();
notifs = checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged");
QVERIFY2(!notifs.isEmpty(), "le déblocage de la PAC change l'allocation : il doit notifier");
QVariantMap notif = notifs.last().toMap().value("params").toMap();
// Cohérence avec la cascade : Routeur PV (rang 1) prend 3000 (clamp maxPowerW), reliquat
// 3000 → PAC (rang 2) forcée état 4 (~1600), reliquat 1400 → ECS relais (rang 3) 1000.
QCOMPARE(notif.value("degradedMode").toBool(), false);
QVERIFY(notif.contains("timestamp"));
QCOMPARE(notif.value("timestamp").toString(), t0.toUTC().toString(Qt::ISODate));
QCOMPARE(notif.value("timestamp").toString(), t1.toUTC().toString(Qt::ISODate));
const QVariantMap budget = notif.value("budget").toMap();
QCOMPARE(qRound(budget.value("surplusW").toDouble()), 6000);
QCOMPARE(qRound(budget.value("evReservedW").toDouble()), 0);
QCOMPARE(qRound(budget.value("allocatedW").toDouble()), 5600);
QCOMPARE(qRound(budget.value("remainingW").toDouble()), 400);
QCOMPARE(qRound(budget.value("recreditedW").toDouble()), 0); // 1er cycle : rien ne coulait
QCOMPARE(qRound(budget.value("remainingW").toDouble()), 1400);
// Le relais tire déjà ses 1000 W depuis le cycle précédent : ils lui sont RECRÉDITÉS avant
// arrondi (anti-clignotement). Sans ce terme publié, l'identité serait fausse ici même.
QCOMPARE(qRound(budget.value("recreditedW").toDouble()), 1000);
// L'invariant publié : surplus + recrédit − alloué == restant. Le recrédit y figure parce
// que sans lui l'identité serait FAUSSE dès qu'une charge consomme déjà — c'est-à-dire dans
// le cas normal — et le client chercherait un écart inexplicable.
@ -4072,10 +4269,13 @@ void Simulation::testLoadTelemetryRpc()
// Verrous en SECONDES restantes, pas en watts. Les deux charges viennent de commuter à t0,
// donc leur fenêtre est pleine. Le routeur PV n'a AUCUN verrou côté moteur : la clé est
// absente, ce qui est la vérité — et non un verrou à zéro, qui se lirait « fenêtre expirée ».
QCOMPARE(eRelay.value("lock").toMap().value("kind").toString(), QString("minOn"));
QCOMPARE(eRelay.value("lock").toMap().value("remainingS").toInt(), 300);
// La PAC vient de commuter en 4 : sa fenêtre repart pleine.
QCOMPARE(ePac.value("lock").toMap().value("kind").toString(), QString("minStateHold"));
QCOMPARE(ePac.value("lock").toMap().value("remainingS").toInt(), 300);
// Le relais, lui, a commuté au cycle PRÉCÉDENT (t0) : 301 s plus tard son minOn de 300 s a
// EXPIRÉ, et la clé disparaît. Un verrou qui ne s'efface pas serait un verrou permanent —
// le défaut d'ECS-412 —, et l'app afficherait une charge éternellement bloquée.
QVERIFY2(!eRelay.contains("lock"), "le verrou minOn du relais aurait dû expirer");
QVERIFY(!eVar.contains("lock"));
// Disponibilité, et absence de code de défaut sur une installation saine.
@ -4135,14 +4335,14 @@ void Simulation::testLoadTelemetryRpc()
// motif. Sans seuil, chaque cycle notifierait pour rien sur une cible RPi Zero 2W.
notificationSpy.clear();
meter->setStateValue("currentPower", -6010); // +10 W, sous le seuil
arb->simulationCallUpdate(t0.addSecs(60));
arb->simulationCallUpdate(t1.addSecs(60));
QCoreApplication::processEvents();
QVERIFY2(checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged").isEmpty(),
"une variation de 10 W a notifié — le seuil ne mord pas");
// …mais le Get, lui, a bien avancé : l'état INTERNE est à jour même sans notification.
resp = injectAndWait("NymeaEnergy.GetLoadTelemetry");
const QVariantMap c2 = resp.toMap().value("params").toMap();
QCOMPARE(c2.value("timestamp").toString(), t0.addSecs(60).toUTC().toString(Qt::ISODate));
QCOMPARE(c2.value("timestamp").toString(), t1.addSecs(60).toUTC().toString(Qt::ISODate));
// Deuxième cycle : les charges consomment, donc le RECRÉDIT n'est plus nul — et c'est
// exactement le cas où l'identité serait fausse sans lui. L'allocation TOTALE, elle, n'a
// pas bougé : c'est pour cela que le seuil ne mord pas.
@ -4158,8 +4358,8 @@ void Simulation::testLoadTelemetryRpc()
// GetChargingSchedules. C'est la famille ECS-411 : une notification seule laisse le
// nouveau venu dans un silence ambigu.
notificationSpy.clear();
arb->recordMeterUpdate(t0.addSecs(60));
arb->evaluateMeterFreshness(t0.addSecs(60 + 91));
arb->recordMeterUpdate(t1.addSecs(60));
arb->evaluateMeterFreshness(t1.addSecs(60 + 91));
QCoreApplication::processEvents();
QVERIFY(arb->degradedMode());
@ -4171,11 +4371,31 @@ void Simulation::testLoadTelemetryRpc()
// Publier des zéros se lirait « arbitrage exécuté, rien à allouer » — l'inverse du vrai.
QVERIFY2(!dgr.contains("budget"), "budget publié en mode dégradé (planification suspendue)");
QVERIFY(!dgr.value("loads").toList().isEmpty());
QVariantMap dgrParId;
for (const QVariant &v : dgr.value("loads").toList()) {
QCOMPARE(v.toMap().value("decision").toMap().value("code").toString(),
QString(DecisionCode::DegradedL2));
QCOMPARE(qRound(v.toMap().value("allocatedW").toDouble()), 0);
dgrParId.insert(v.toMap().value("loadId").toString(), v.toMap());
}
// TROU DE SÉCURITÉ REFERMÉ (lot B-bis). Le repli L2 envoyait un Setpoint(0) à toute la
// table des charges pilotées et un State(2) à une table SÉPARÉE, réservée à la PAC codée en
// dur. Une PAC de CONFIGURATION vit dans la première : elle recevait donc un Setpoint, que
// SgReadyAdapter rejette d'entrée — et rien d'autre. Sous compteur muet elle restait en
// état 4 (forcé) indéfiniment, alors que SAFETY.md §L2 exige l'état 2.
//
// Le défaut était INVISIBLE dans la charge utile : le motif DEGRADED_L2 s'y affichait
// correctement, seule l'ÉCRITURE manquait. C'est pourquoi on vérifie ici les CONTACTS et
// le mécanisme publié, pas le motif.
QCOMPARE(dgrParId.value("pac-telemetrie").toMap()
.value("mechanism").toMap().value("state").toInt(), 2);
QCOMPARE(tm->findConfiguredThing(k1)->stateValue("power").toBool(), false);
QCOMPARE(tm->findConfiguredThing(k2)->stateValue("power").toBool(), false);
// Et la charge en watts est bien coupée elle aussi (le repli ne régresse pas de l'autre côté).
QCOMPARE(qRound(dgrParId.value("ecs-relais-telemetrie").toMap()
.value("mechanism").toMap().value("stageW").toDouble()), 0);
QCOMPARE(relayA->stateValue("power").toBool(), false);
verifierCodes(dgr, QStringLiteral("telemetry-degrade"));
// Le nouveau venu lit l'état par les DEUX portes.
@ -4191,7 +4411,7 @@ void Simulation::testLoadTelemetryRpc()
// reprend qu'au cycle suivant ; sans cette republication, la télémétrie annoncerait encore
// degradedMode=true pendant une minute après la levée du repli.
notificationSpy.clear();
arb->recordMeterUpdate(t0.addSecs(300));
arb->recordMeterUpdate(t1.addSecs(300));
QCoreApplication::processEvents();
QVERIFY(!arb->degradedMode());
notifs = checkNotifications(notificationSpy, "NymeaEnergy.LoadTelemetryChanged");
@ -4423,17 +4643,23 @@ void Simulation::testRuntimeViewIsSubstantial()
LoadAction etat;
etat.kind = LoadAction::State; etat.funding = LoadAction::Surplus;
etat.state = 3; etat.reason = { DecisionCode::SgRecommended };
// ECS-412 — la première commande tombe dans l'armement à froid : REFUSÉE. C'est elle qui
// pose l'estampille, donc qui lance le compte à rebours (armement PARESSEUX, jamais
// permanent : c'est le blocage circulaire d'ECS-412 qu'on refuse de refaire).
pac->applyAction(etat, t0);
QCOMPARE(pac->currentState(), 2);
const QDateTime tPac = t0.addSecs(901); // verrou expiré
pac->applyAction(etat, tPac);
QCOMPARE(pac->currentState(), 3); // état NON trivial (le défaut est 2)
LoadRuntimeView vPac = pac->runtimeView(t0.addSecs(120));
LoadRuntimeView vPac = pac->runtimeView(tPac.addSecs(120));
QCOMPARE(vPac.mechanismKind, QString("sgReady"));
QCOMPARE(vPac.mechanism.value("state").toInt(), 3);
QCOMPARE(qRound(vPac.mechanism.value("estimatedPowerW").toDouble()), 1500);
QCOMPARE(vPac.lockKind, QString("minStateHold"));
QCOMPARE(vPac.lockRemainingS, 780); // 900 − 120
QVERIFY(vPac.faultCode.isEmpty());
QVERIFY(pac->runtimeView(t0.addSecs(901)).lockKind.isEmpty()); // le verrou expire
QVERIFY(pac->runtimeView(tPac.addSecs(901)).lockKind.isEmpty()); // le verrou expire
// ECS-110 — une PAC dont l'encodage ne permet pas l'état 2 n'est JAMAIS pilotable. Le taire
// la ferait passer pour une PAC au repos ; le code le dit.
@ -4482,6 +4708,103 @@ void Simulation::testRuntimeViewIsSubstantial()
#endif
}
void Simulation::testSgReadyRestartRecovery()
{
#ifndef ETM_ARBITRATOR
QSKIP("testSgReadyRestartRecovery nécessite ETM_ARBITRATOR.");
#else
// [lot B-bis / ECS-411 appliqué au mécanisme SG-Ready]
//
// Le SgReadyAdapter démarrait à l'état 2 « par défaut », quel que soit l'état réel des
// contacts. Tant que la PAC était codée en dur et construite une seule fois au démarrage du
// plugin, ça se voyait peu. Depuis qu'elle vient de la CONFIGURATION, elle est reconstruite
// à chaque changement de matériel — et l'hypothèse devient un mensonge répété.
//
// Deux dégâts, dont le second est le pire :
// 1. la télémétrie annonce l'état 2 (0 W alloué) pendant que la PAC tourne en forcé ;
// 2. LM-104 — applyAction(état 2) sur un adaptateur qui se CROIT en 2 est idempotent,
// donc MUET : les contacts restent en 4. L'hypothèse de prudence devient un
// MASQUAGE DE PANNE, ce que LM-104 interdit explicitement.
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arb);
ThingManager *tm = NymeaCore::instance()->thingManager();
QUuid k1 = addPowerSwitch(0, 26661);
QUuid k2 = addPowerSwitch(0, 26662);
Thing *tK1 = tm->findConfiguredThing(k1);
Thing *tK2 = tm->findConfiguredThing(k2);
QVERIFY(tK1 && tK2);
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
const QHash<int, QList<QString>> encodage({ {1, {k1.toString()}}, {2, {}},
{3, {k2.toString()}},
{4, {k1.toString(), k2.toString()}} });
const QHash<int, double> estim({ {1, 0.0}, {2, 0.0}, {3, 1500.0}, {4, 3000.0} });
// --- 1. Contacts en état 4 : l'adaptateur neuf le LIT, il ne suppose pas ---------------
tK1->setStateValue("power", true);
tK2->setStateValue("power", true);
JournalCapture j1;
SgReadyAdapter *pac4 = new SgReadyAdapter(tm, "pac-r4", "PAC reprise 4",
encodage, estim, 300, 1, arb);
QCOMPARE(pac4->currentState(), 4);
// ECS-411 : la télémétrie annonce la puissance ALLOUÉE de l'état lu, pas zéro. Sans la
// reprise, le waterfall croirait 3 kW libres qui circulent déjà.
QCOMPARE(qRound(pac4->telemetry().currentPowerW), 3000);
// ECS-411-b : le relevé publie ce qui a été LU, contact par contact, avec l'issue.
QVERIFY2(j1.contains("reprise au démarrage"), qUtf8Printable(j1.texte()));
QVERIFY2(j1.contains("état 4"), qUtf8Printable(j1.texte()));
QVERIFY2(j1.contains("fermé"), qUtf8Printable(j1.texte()));
QVERIFY2(j1.contains("correspondance exacte"), qUtf8Printable(j1.texte()));
// --- 2. LM-104 — le corollaire, qui est le vrai piège ----------------------------------
// Une remise à l'état 2 DOIT écrire. Sans la reprise, l'adaptateur se croirait déjà en 2,
// rendrait la main en idempotent, et les contacts resteraient fermés — panne masquée.
LoadAction versNeutre;
versNeutre.kind = LoadAction::State; versNeutre.funding = LoadAction::Surplus;
versNeutre.state = 2; versNeutre.force = true; // force : on teste l'écriture, pas le verrou
versNeutre.reason = { DecisionCode::SafeStateSgReady };
pac4->applyAction(versNeutre, t0);
QCoreApplication::processEvents();
QCOMPARE(tK1->stateValue("power").toBool(), false);
QCOMPARE(tK2->stateValue("power").toBool(), false);
QCOMPARE(pac4->currentState(), 2);
// --- 3. Contacts OUVERTS : l'issue est l'état neutre, et elle se dit quand même --------
// C'est le cas d'ECS-411-b : au palier/état NUL, l'ancien code se taisait, et « contacts
// lus, tous ouverts » ne se distinguait pas de « reprise non exécutée ». Or c'est
// précisément l'état qu'on vient vérifier après un démarrage.
tK1->setStateValue("power", false);
tK2->setStateValue("power", false);
JournalCapture j2;
SgReadyAdapter *pac2 = new SgReadyAdapter(tm, "pac-r2", "PAC reprise 2",
encodage, estim, 300, 1, arb);
QCOMPARE(pac2->currentState(), 2);
QVERIFY2(j2.contains("reprise au démarrage"), "l'issue NULLE doit se journaliser aussi");
QVERIFY2(j2.contains("état 2"), qUtf8Printable(j2.texte()));
QVERIFY2(j2.contains("ouvert"), qUtf8Printable(j2.texte()));
// --- 4. Contact INJOIGNABLE : supposé fermé, et la reprise passe en AVERTISSEMENT ------
// LM-104 — supposer conservateur pour ANNONCER. Le motif {K1 fermé, K2 absent supposé
// fermé} est l'encodage de l'état 4 : on annonce donc 3000 W, jamais 0.
tK1->setStateValue("power", true);
const QString k2absent = QStringLiteral("{deadbeef-2222-2222-2222-222222222222}");
JournalCapture j3;
SgReadyAdapter *pacKo = new SgReadyAdapter(tm, "pac-r-ko", "PAC contact absent",
QHash<int, QList<QString>>({ {1, {k1.toString()}}, {2, {}},
{3, {k2absent}}, {4, {k1.toString(), k2absent}} }),
estim, 300, 1, arb);
QCOMPARE(pacKo->currentState(), 4);
QVERIFY2(j3.contains("INJOIGNABLE"), qUtf8Printable(j3.texte()));
QVERIFY2(j3.contains("INCERTAIN"), qUtf8Printable(j3.texte()));
QVERIFY2(j3.contient(QtWarningMsg), "une reprise incertaine doit être un AVERTISSEMENT, "
"pas une information");
#endif
}
void Simulation::printStates(Thing *thing)
{
qCDebug(dcSimulation()) << "Thing states for" << thing->name();

View File

@ -137,6 +137,12 @@ private slots:
// 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();
void printStates(Thing *thing);
void updateChargerMeter(Thing *thing);