From 405c7f5bf6920a20aa8807f2f042b5b9bcb208de Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Tue, 25 Aug 2026 18:35:04 +0200 Subject: [PATCH] =?UTF-8?q?feat(config):=20lot=20B-bis=20=E2=80=94=20la=20?= =?UTF-8?q?PAC=20entre=20en=20configuration=20=E2=80=94=201.15.2+etm15?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff --- debian-qt5/changelog | 96 ++++ energyplugin/energypluginnymea.cpp | 26 +- energyplugin/etm/adapters/sgreadyadapter.cpp | 137 +++++- energyplugin/etm/adapters/sgreadyadapter.h | 39 +- energyplugin/etm/energyarbitrator.cpp | 138 ++---- energyplugin/etm/energyarbitrator.h | 43 +- specs/spec_ecs.md | 20 +- specs/spec_loadmodel.md | 15 + tests/auto/simulation/simulation.cpp | 437 ++++++++++++++++--- tests/auto/simulation/simulation.h | 6 + 10 files changed, 739 insertions(+), 218 deletions(-) diff --git a/debian-qt5/changelog b/debian-qt5/changelog index 9d258cb..e4c528f 100644 --- a/debian-qt5/changelog +++ b/debian-qt5/changelog @@ -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 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. diff --git a/energyplugin/energypluginnymea.cpp b/energyplugin/energypluginnymea.cpp index e5b21de..50417e6 100644 --- a/energyplugin/energypluginnymea.cpp +++ b/energyplugin/energypluginnymea.cpp @@ -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>({ {1,{K1}}, {2,{}}, {3,{K2}}, {4,{K1,K2}} }), - QHash({ {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 diff --git a/energyplugin/etm/adapters/sgreadyadapter.cpp b/energyplugin/etm/adapters/sgreadyadapter.cpp index 0ab3a4a..aa65c3d 100644 --- a/energyplugin/etm/adapters/sgreadyadapter.cpp +++ b/energyplugin/etm/adapters/sgreadyadapter.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -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 tous = allRelays(); + QSet 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 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(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(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 { diff --git a/energyplugin/etm/adapters/sgreadyadapter.h b/energyplugin/etm/adapters/sgreadyadapter.h index 4822f45..c08870b 100644 --- a/energyplugin/etm/adapters/sgreadyadapter.h +++ b/energyplugin/etm/adapters/sgreadyadapter.h @@ -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 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 &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 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; }; diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index aa78fab..6678f9a 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -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 revendiquesParConfig; - for (ILoadAdapter *a : kept) { - if (auto *sg = dynamic_cast(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 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; diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index 17c96e3..ce70655 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -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 m_adapters; //!< loadId (ThingId string) → EvAdapter*. - QHash m_sgReadyAdapters; //!< loadId → SgReadyAdapter* (PAC). - QHash 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 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 m_builtFrom; diff --git a/specs/spec_ecs.md b/specs/spec_ecs.md index db68043..a1662ab 100644 --- a/specs/spec_ecs.md +++ b/specs/spec_ecs.md @@ -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. --- diff --git a/specs/spec_loadmodel.md b/specs/spec_loadmodel.md index 726efa3..54176a8 100644 --- a/specs/spec_loadmodel.md +++ b/specs/spec_loadmodel.md @@ -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 diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index fc30e09..27c1ffe 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -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 m_types; + static QList s_pile; + static QtMessageHandler s_origine; +}; +QList 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>({ {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({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>({ {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>({ {1,{k2ko}}, {2,{}}, {3,{k1.toString()}}, {4,{k1.toString(), k2ko}} }), QHash({ {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(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> encodage({ {1, {k1.toString()}}, {2, {}}, + {3, {k2.toString()}}, + {4, {k1.toString(), k2.toString()}} }); + const QHash 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>({ {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(); diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index b7488dd..3765039 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -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);