From c9b8e63f89e789afee7a551128401d7d556f6e63 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sat, 8 Aug 2026 12:42:56 +0200 Subject: [PATCH] =?UTF-8?q?fix(etm):=20ECS-412=20=E2=80=94=20rebuild=20inc?= =?UTF-8?q?r=C3=A9mental=20et=20armement=20du=20verrou=20=C3=A0=20froid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cause racine, pas symptôme. rebuildLoadAdapters() détruisait TOUS les adaptateurs à chaque SetLoadConfig, réarmant leurs verrous. Sur un ballon thermodynamique à minOn de 300-600 s, un client qui réordonne ses priorités depuis l'app pouvait faire court-cycler son compresseur. C'est de la protection matérielle. L'arbitre mémorise désormais la config ayant servi à construire chaque adaptateur (m_builtFrom) et ne reconstruit que si le MATÉRIEL a changé — type, câblage, paliers, plafond, verrous (sameHardware()). Un changement de rang ou de besoins passe par updateSoftConfig(), en place : ni m_currentStage ni m_lastSwitch ne bougent, aucun relais n'est réécrit. Le log distingue créées / mises à jour / inchangées / retirées. updateSoftConfig est PURE VIRTUELLE, sans implémentation par défaut. Un défaut vide silencieux ferait qu'un futur adaptateur construit depuis LoadConfig ignorerait sans bruit les changements de rang ; là, le compilateur force la décision. EvAdapter et SgReadyAdapter la déclarent sans effet, avec le motif. Démarrage à froid : après un redémarrage de nymead, m_lastSwitch est irrécupérable. lockWindow() traite désormais un horodatage nul comme « commutation venant d'avoir lieu » (elapsed = 0), donc verrou ARMÉ pour sa durée configurée. L'écriture naturelle (`valid && elapsed < minOnS`) fait l'inverse et laisserait une boucle de redémarrage court-circuiter la protection compresseur quand elle est la plus nécessaire. Armer n'est pas verrouiller inconditionnellement : avec une durée nulle, `0 < 0` est faux et le verrou reste inactif — un premier jet qui forçait le verrou a fait tomber trois tests existants, qui avaient raison. Test : testEcsRebuildPreservesLock — SetLoadConfig pendant une fenêtre de verrou active, le relais reste fermé ; le délestage reprend une fois minOn écoulé. Build amd64 0 erreur. Simulation : 12/12. Réf. specs/spec_ecs.md §3 ECS-412 (0.5.1). Co-Authored-By: Claude Opus 5 (1M context) --- .../etm/adapters/etmvariableloadadapter.h | 5 ++ energyplugin/etm/adapters/evadapter.h | 5 ++ energyplugin/etm/adapters/iloadadapter.h | 20 +++++ energyplugin/etm/adapters/relayrouter.cpp | 28 +++++-- energyplugin/etm/adapters/relayrouter.h | 16 +++- energyplugin/etm/adapters/sgreadyadapter.h | 6 ++ energyplugin/etm/energyarbitrator.cpp | 83 ++++++++++++++++--- energyplugin/etm/energyarbitrator.h | 12 +++ tests/auto/simulation/simulation.cpp | 73 ++++++++++++++++ tests/auto/simulation/simulation.h | 4 + 10 files changed, 236 insertions(+), 16 deletions(-) diff --git a/energyplugin/etm/adapters/etmvariableloadadapter.h b/energyplugin/etm/adapters/etmvariableloadadapter.h index 808eae8..2359938 100644 --- a/energyplugin/etm/adapters/etmvariableloadadapter.h +++ b/energyplugin/etm/adapters/etmvariableloadadapter.h @@ -106,6 +106,11 @@ public: */ LoadAction applyAction(const LoadAction &action, const QDateTime &now) override; + //! \brief Met à jour rang et besoins sans reconstruire (ECS-412). + //! \param priority Nouveau rang de service. \param needs Nouveaux besoins. + void updateSoftConfig(int priority, const LoadNeeds &needs) override + { m_priority = priority; m_needs = needs; } + /*! * \brief Dernière consigne (W) effectivement écrite (avant écrêtage thing). * \return Consigne commandée en W, bornée à \c maxPowerW ; 0 tant qu'aucune action diff --git a/energyplugin/etm/adapters/evadapter.h b/energyplugin/etm/adapters/evadapter.h index c0570b2..ec94e27 100644 --- a/energyplugin/etm/adapters/evadapter.h +++ b/energyplugin/etm/adapters/evadapter.h @@ -71,6 +71,11 @@ public: */ LoadAction applyAction(const LoadAction &action, const QDateTime &now) override; + //! \brief Sans effet : l'EV n'est pas construit depuis \c LoadConfig (rang fixé à 100, + //! cf. « Dette 3g »). \param priority Ignoré. \param needs Ignoré. + void updateSoftConfig(int priority, const LoadNeeds &needs) override + { Q_UNUSED(priority) Q_UNUSED(needs) } + /*! * \brief Borne VE sous-jacente (lecture). * \return Pointeur non-propriétaire vers l'EvCharger ; jamais nul (fixé à la construction). diff --git a/energyplugin/etm/adapters/iloadadapter.h b/energyplugin/etm/adapters/iloadadapter.h index 93a421a..38bdfac 100644 --- a/energyplugin/etm/adapters/iloadadapter.h +++ b/energyplugin/etm/adapters/iloadadapter.h @@ -72,4 +72,24 @@ public: * \note Retour silencieux sans effet si \c action.reason est vide. */ virtual LoadAction applyAction(const LoadAction &action, const QDateTime &now) = 0; + + /*! + * \brief Met à jour les champs de configuration qui ne touchent PAS le matériel. + * + * Sert au rebuild INCRÉMENTAL (ECS-412) : quand seul le rang de service ou les besoins + * changent, l'arbitre met à jour l'adaptateur en place au lieu de le détruire. Détruire + * réarmerait les verrous — sur un ballon thermodynamique à \c minOn de 300 à 600 s, un + * client qui réordonne ses priorités depuis l'app pourrait faire court-cycler son + * compresseur — et provoquerait des réécritures de relais inutiles. + * + * \param priority Nouveau rang de service (ASC, 1 = premier servi). + * \param needs Nouveaux besoins déclarés. + * \warning NE DOIT jamais toucher au câblage, aux paliers ni aux verrous : ces + * changements-là exigent une vraie reconstruction. + * \note **Pure virtuelle à dessein**, sans implémentation par défaut. Un adaptateur qui + * n'est pas construit depuis \c LoadConfig n'a rien à mettre à jour, mais il doit le + * DÉCLARER : un défaut vide silencieux ferait qu'un futur adaptateur construit depuis + * la config ignorerait sans bruit les changements de rang. + */ + virtual void updateSoftConfig(int priority, const LoadNeeds &needs) = 0; }; diff --git a/energyplugin/etm/adapters/relayrouter.cpp b/energyplugin/etm/adapters/relayrouter.cpp index 732dd08..8bbc6de 100644 --- a/energyplugin/etm/adapters/relayrouter.cpp +++ b/energyplugin/etm/adapters/relayrouter.cpp @@ -190,16 +190,34 @@ int RelayRouter::stageForPower(double powerW) const return stage; } +void RelayRouter::updateSoftConfig(int priority, const LoadNeeds &needs) +{ + // ECS-412 — mise à jour EN PLACE : ni m_currentStage ni m_lastSwitch ne bougent, + // aucun relais n'est réécrit. + m_priority = priority; + m_needs = needs; +} + void RelayRouter::lockWindow(const QDateTime &now, int &minStage, int &maxStage) const { - const int topStage = m_levels.size() - 1; - const bool valid = m_lastSwitch.isValid(); - const qint64 elapsed = valid ? m_lastSwitch.secsTo(now) : 0; + const int topStage = m_levels.size() - 1; + + // ECS-412 — DÉMARRAGE À FROID. m_lastSwitch nul signifie « on ne sait pas quand la + // dernière commutation a eu lieu » (redémarrage de nymead, reconstruction). Le défaut + // SÛR est d'ARMER le verrou — traiter la commutation comme venant d'avoir lieu, donc + // elapsed = 0 — et non de le purger. L'écriture naturelle (`valid && elapsed < minOnS`) + // fait l'inverse et laisserait une boucle de redémarrage court-circuiter la protection + // compresseur exactement quand elle est la plus nécessaire. + // Armer ne signifie PAS verrouiller inconditionnellement : avec une durée nulle, + // `0 < 0` est faux et le verrou reste inactif. + const qint64 elapsed = m_lastSwitch.isValid() ? m_lastSwitch.secsTo(now) : 0; + const bool onHeld = elapsed < m_minOnS; + const bool offHeld = elapsed < m_minOffS; // Plancher : si ON et minOn non écoulé → interdit de descendre (puissance engagée non-coupable). - minStage = (m_currentStage > 0 && valid && elapsed < m_minOnS) ? m_currentStage : 0; + minStage = (m_currentStage > 0 && onHeld) ? m_currentStage : 0; // Plafond : si à l'arrêt et minOff non écoulé → interdit de redémarrer. - maxStage = (m_currentStage == 0 && valid && elapsed < m_minOffS) ? 0 : topStage; + maxStage = (m_currentStage == 0 && offHeld) ? 0 : topStage; } void RelayRouter::applyRelayStage(int stage) diff --git a/energyplugin/etm/adapters/relayrouter.h b/energyplugin/etm/adapters/relayrouter.h index c6c1bbe..420a1d9 100644 --- a/energyplugin/etm/adapters/relayrouter.h +++ b/energyplugin/etm/adapters/relayrouter.h @@ -86,10 +86,24 @@ public: //! \brief Puissance (W) du palier courant. double currentSetpointW() const { return m_currentStage < m_levels.size() ? m_levels.at(m_currentStage) : 0.0; } + //! \brief Met à jour les champs qui ne touchent PAS le matériel (ECS-412). + //! \param priority Nouveau rang de service. + //! \param needs Nouveaux besoins déclarés. + //! \note Permet à l'arbitre de refléter un changement de priorité SANS détruire + //! l'adaptateur — donc sans réarmer les verrous ni recommuter les relais. + void updateSoftConfig(int priority, const LoadNeeds &needs) override; + private: //! Palier le plus haut dont la puissance ≤ \p powerW (≥ 0). int stageForPower(double powerW) const; - //! Fenêtre de paliers autorisée à \p now par minOn/minOff (verrou INTERNE, plus exposé). + /*! + * \brief Fenêtre de paliers autorisée à \p now par minOn/minOff (verrou INTERNE). + * \param now Temps de cycle. + * \param[out] minStage Palier plancher (puissance engagée non-coupable). + * \param[out] maxStage Palier plafond (interdiction de redémarrer). + * \note Un \c m_lastSwitch NUL vaut « commutation venant d'avoir lieu », donc verrou + * **ARMÉ** pour sa durée configurée — cf. ECS-412, démarrage à froid. + */ void lockWindow(const QDateTime &now, int &minStage, int &maxStage) const; void applyRelayStage(int stage); diff --git a/energyplugin/etm/adapters/sgreadyadapter.h b/energyplugin/etm/adapters/sgreadyadapter.h index 5f48e9a..35205bf 100644 --- a/energyplugin/etm/adapters/sgreadyadapter.h +++ b/energyplugin/etm/adapters/sgreadyadapter.h @@ -89,6 +89,12 @@ public: */ LoadAction applyAction(const LoadAction &action, const QDateTime &now) override; + //! \brief Sans effet : la PAC du banc est codée en dur, pas construite depuis + //! \c LoadConfig (à basculer avec la couche config). + //! \param priority Ignoré. \param needs Ignoré. + void updateSoftConfig(int priority, const LoadNeeds &needs) override + { Q_UNUSED(priority) Q_UNUSED(needs) } + /*! * \brief État SG-Ready courant (1-4). * \return Dernier état réellement commuté ; valeur initiale tant qu'aucune transition diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 61e58b8..84ec568 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -134,17 +134,46 @@ void EnergyArbitrator::setLoadConfigStore(LoadConfigStore *store) rebuildLoadAdapters(); // construction initiale depuis la config persistée } +bool EnergyArbitrator::sameHardware(const LoadConfig &a, const LoadConfig &b) +{ + // Champs dont un changement impose une VRAIE reconstruction : ils déterminent la table + // de paliers, le câblage ou les verrous. Tout le reste (rang, besoins, libellé) se met + // à jour en place. + if (a.adapter() != b.adapter() || a.mode() != b.mode()) + return false; + if (a.minOnS() != b.minOnS() || a.minOffS() != b.minOffS()) + return false; + if (a.maxPowerW() != b.maxPowerW() || a.powerLevelsInt() != b.powerLevelsInt()) + return false; + + const QList ra = a.relaysList(); + const QList rb = b.relaysList(); + if (ra.size() != rb.size()) + return false; + for (int i = 0; i < ra.size(); ++i) + if (ra.at(i).thingId != rb.at(i).thingId || ra.at(i).powerW != rb.at(i).powerW) + return false; + return true; +} + void EnergyArbitrator::rebuildLoadAdapters() { - // Purge des adaptateurs construits depuis la config précédente (ILoadAdapter n'est pas QObject : - // les concrets le sont — dynamic_cast pour deleteLater). - for (ILoadAdapter *a : m_loadAdapters) - if (QObject *o = dynamic_cast(a)) - o->deleteLater(); - m_loadAdapters.clear(); - - if (!m_loadConfigStore) + if (!m_loadConfigStore) { + for (ILoadAdapter *a : m_loadAdapters) + if (QObject *o = dynamic_cast(a)) + o->deleteLater(); + m_loadAdapters.clear(); + m_builtFrom.clear(); return; + } + + // ECS-412 — reconstruction INCRÉMENTALE. Détruire un adaptateur réarme ses verrous : sur + // un ballon thermodynamique à minOn de 300-600 s, un client qui réordonne ses priorités + // depuis l'app pourrait faire court-cycler son compresseur. On ne reconstruit donc que + // ce dont le MATÉRIEL a changé ; le reste est mis à jour en place. + QHash kept; + QHash keptFrom; + int created = 0, updated = 0, reused = 0; for (const LoadConfig &c : m_loadConfigStore->configs()) { // enabled==false : rôle déclaré mais EXCLU de l'arbitrage (contrat §9) — pas d'adaptateur. @@ -155,6 +184,26 @@ void EnergyArbitrator::rebuildLoadAdapters() needs.dailyDeadline = c.needs().dailyDeadline(); needs.minEnergyWhPerDay = c.needs().minEnergyWhPerDay(); + ILoadAdapter *existing = m_loadAdapters.value(c.id(), nullptr); + if (existing && m_builtFrom.contains(c.id()) && sameHardware(m_builtFrom.value(c.id()), c)) { + // Matériel inchangé : on GARDE l'adaptateur — donc m_lastSwitch et le palier + // courant — et on ne met à jour que ce qui ne touche pas au matériel. + const LoadConfig &old = m_builtFrom[c.id()]; + const bool soft = (old.priority() != c.priority()) + || (old.needs().dailyDeadline() != c.needs().dailyDeadline()) + || (old.needs().minEnergyWhPerDay() != c.needs().minEnergyWhPerDay()); + if (soft) { + existing->updateSoftConfig(c.priority(), needs); + ++updated; + } else { + ++reused; + } + kept[c.id()] = existing; + keptFrom[c.id()] = c; + m_loadAdapters.remove(c.id()); // sorti de la table : ne sera pas détruit plus bas + continue; + } + // La distinction de TYPE vit ICI (rév. 3) ; au-dessus, tout est ILoadAdapter (Setpoint W). ILoadAdapter *adapter = nullptr; if (c.isRelayRouter()) { @@ -168,10 +217,24 @@ void EnergyArbitrator::rebuildLoadAdapters() qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] etmvariableload construit depuis config:" << c.label() << "(" << (c.isDynamic() ? "dynamic" : "fixed") << ")"; } - m_loadAdapters[c.id()] = adapter; + kept[c.id()] = adapter; + keptFrom[c.id()] = c; + ++created; } + + // Ce qui reste dans m_loadAdapters n'est plus référencé par la config : à détruire. + for (ILoadAdapter *a : m_loadAdapters) + if (QObject *o = dynamic_cast(a)) + o->deleteLater(); + const int removed = m_loadAdapters.count(); + + m_loadAdapters = kept; + m_builtFrom = keptFrom; + qCInfo(dcNymeaEnergy()) << "[EnergyArbitrator]" << m_loadAdapters.count() - << "charge(s) pilotée(s) active(s) (config)."; + << "charge(s) pilotée(s) active(s) (config) —" << created << "créée(s)," + << updated << "mise(s) à jour en place," << reused << "inchangée(s)," + << removed << "retirée(s)."; } void EnergyArbitrator::update(const QDateTime ¤tDateTime) diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index aefe42a..46e7f90 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -6,6 +6,9 @@ #include "scheduler/ischeduler.h" #include "types/surpluscontext.h" #include "types/plan.h" +// Inclus (et non déclaré en avant) : m_builtFrom stocke des LoadConfig PAR VALEUR — c'est ce +// qui permet au rebuild incrémental (ECS-412) de comparer l'ancienne et la nouvelle config. +#include "types/loadconfig.h" #include @@ -195,6 +198,12 @@ private: */ void applyActionsToAdapters(const Slot &slot, const QDateTime &now); + //! \brief Vrai si deux configs décrivent le MÊME matériel (câblage, paliers, verrous). + //! \param a Config ayant servi à construire l'adaptateur. \param b Nouvelle config. + //! \return Vrai si l'adaptateur peut être conservé — ECS-412. Rang, besoins et libellé + //! sont volontairement ignorés : ils se mettent à jour en place. + static bool sameHardware(const LoadConfig &a, const LoadConfig &b); + /*! * \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 @@ -235,6 +244,9 @@ private: QHash m_adapters; //!< loadId (ThingId string) → EvAdapter*. QHash m_sgReadyAdapters; //!< loadId → SgReadyAdapter* (PAC). QHash m_loadAdapters; //!< loadId → charge pilotée (relay-router | etmvariableload). + //! 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; ThingManager *m_tm = nullptr; //!< ThingManager (pour construire les adaptateurs config). LoadConfigStore *m_loadConfigStore = nullptr; //!< Store config charge pilotée (non adopté). diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 3c5457f..7b54cc1 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -868,6 +868,79 @@ void Simulation::testLoadConfigRelayRouter() #endif } +void Simulation::testEcsRebuildPreservesLock() +{ +#ifndef ETM_ARBITRATOR + QSKIP("testEcsRebuildPreservesLock nécessite ETM_ARBITRATOR."); +#else + // [ECS-412] SetLoadConfig PENDANT une fenêtre de verrou active : un changement de rang + // ne doit pas détruire l'adaptateur, donc ne doit pas réarmer le verrou. C'est de la + // protection matérielle : sur un ballon thermodynamique à minOn de 300-600 s, réordonner + // ses priorités depuis l'app ferait court-cycler le compresseur. + 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 meterId = addMeter(); + m_experiencePlugin->energyManager()->setRootMeter(meterId); + Thing *meter = tm->findConfiguredThing(meterId); + QVERIFY(meter); + meter->setStateValue("connected", true); + + QUuid rA = addPowerSwitch(2000, 26661); + Thing *tA = tm->findConfiguredThing(rA); + QVERIFY(tA); + + const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-rebuildlock.json"; + QFile::remove(cfgPath); + qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); + LoadConfigStore *store = new LoadConfigStore(arb); + arb->setLoadConfigStore(store); + + auto cfg = [&](int priority) { + LoadConfigs cs; + cs.append(LoadConfig::fromMap(QVariantMap{ + {"id", "ecs-verrou"}, {"label", "ECS verrouillé"}, {"adapter", "relay-router"}, + {"mode", "fixed"}, {"priority", priority}, {"enabled", true}, + {"relays", QVariantList() << QVariantMap{{"thingId", rA.toString()}, {"powerW", 2000}}}, + {"minOnS", 300}, {"minOffS", 0}})); + return cs; + }; + + QString err; + QVERIFY2(store->setConfigs(cfg(1), &err), err.toUtf8()); + + // Cycle 1 : surplus large → le relais se ferme, le verrou minOn s'arme. + meter->setStateValue("currentPower", -2500); + arb->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0))); + QCoreApplication::processEvents(); + QCOMPARE(tA->stateValue("power").toBool(), true); + + // SetLoadConfig PENDANT la fenêtre de verrou : SEUL le rang change. + QVERIFY2(store->setConfigs(cfg(2), &err), err.toUtf8()); + QCoreApplication::processEvents(); + + // Cycle 2, 60 s plus tard, surplus effondré. Si le rebuild avait détruit l'adaptateur, + // le verrou serait réarmé sur un palier 0 et le relais s'ouvrirait. Il doit RESTER fermé : + // minOn court toujours depuis le cycle 1. + meter->setStateValue("currentPower", 100); // import : budget négatif + arb->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 1, 0))); + QCoreApplication::processEvents(); + QCOMPARE(tA->stateValue("power").toBool(), true); + + // Au-delà de minOn, le délestage reprend normalement. + arb->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 6, 0))); + QCoreApplication::processEvents(); + QCOMPARE(tA->stateValue("power").toBool(), false); + + qunsetenv("NYMEA_ENERGY_LOAD_CONFIG"); + QFile::remove(cfgPath); +#endif +} + void Simulation::run_data() { // Simulation infos diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 6349ee3..0cc4e47 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -85,6 +85,10 @@ private slots: // (EnergyRatiosInterim.compute) — seed, normal, clamp, den≤0→n/a, non-monotone, nouveau jour. void testEnergyRatiosAlignment(); + // [étape 1 / ECS-412] SetLoadConfig pendant une fenêtre de verrou active ne réarme pas + // le verrou : seul le matériel modifié est reconstruit. Couvre aussi l'armement à froid. + void testEcsRebuildPreservesLock(); + void printStates(Thing *thing); void updateChargerMeter(Thing *thing);