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:
parent
1ec89bf030
commit
405c7f5bf6
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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 ¤tDateTime)
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user