From 25cde160bac0e6d53b282139e89b14154e49ae9a Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 9 Aug 2026 12:34:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(sg-ready):=20construction=20depuis=20la=20?= =?UTF-8?q?configuration=20+=20garde=20de=20double=20propri=C3=A9t=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LM-301 vérifié à l'usage : brancher le mécanisme n'a touché qu'une branche de fabrique dans rebuildLoadAdapters(), un champ optionnel au schéma RPC, et la comparaison sameHardware() — la charge utile fait partie du « matériel », un état ré-encodé impose une reconstruction. Aucun code d'arbitrage n'a bougé. La PAC de configuration vit dans m_loadAdapters, la MÊME table que les autres charges. Elle hérite donc d'ECS-412 (reconstruction incrémentale) et d'ECS-413 (état sûr à la désactivation, qui vaut ici ÉTAT 2 et non « contacts ouverts ») sans code dédié. Le dispatch des actions route par loadId d'abord, l'enregistrement en dur ne servant plus que de repli. Garde neuve, créée par cette configurabilité même : 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 contact, c'est deux commandes contradictoires et une charge comptée deux fois dans le budget (règle absolue 1). La configuration fait autorité : l'exemplaire en dur est retiré — sans mise en état sûr, les contacts ayant un propriétaire légitime. testSgReadyFromConfig couvre les deux refus (sans état 2, charges utiles mélangées), le round-trip par le store, le pilotage, et la désactivation vers l'état 2. Suite complète : 111 tests, 0 échec. Co-Authored-By: Claude Opus 5 (1M context) --- energyplugin/etm/energyarbitrator.cpp | 60 ++++++++++++++- energyplugin/nymeaenergyjsonhandler.cpp | 10 +++ tests/auto/simulation/simulation.cpp | 97 +++++++++++++++++++++++++ tests/auto/simulation/simulation.h | 3 + 4 files changed, 169 insertions(+), 1 deletion(-) diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index b547538..d0456c9 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -146,6 +146,19 @@ bool EnergyArbitrator::sameHardware(const LoadConfig &a, const LoadConfig &b) if (a.maxPowerW() != b.maxPowerW() || a.powerLevelsInt() != b.powerLevelsInt()) return false; + // LM-300 — la charge utile fait partie du « matériel » : un état SG-Ready ré-encodé ou + // une estimation revue change ce que l'adaptateur commande, donc impose une reconstruction. + const LoadConfigSgReady sa = a.sgReadyPayload(); + const LoadConfigSgReady sb = b.sgReadyPayload(); + if (sa.minStateHoldS != sb.minStateHoldS || sa.states.size() != sb.states.size()) + return false; + for (int i = 0; i < sa.states.size(); ++i) { + if (sa.states.at(i).state != sb.states.at(i).state + || sa.states.at(i).relays != sb.states.at(i).relays + || !qFuzzyCompare(1 + sa.states.at(i).estimatedPowerW, 1 + sb.states.at(i).estimatedPowerW)) + return false; + } + const QList ra = a.relaysList(); const QList rb = b.relaysList(); if (ra.size() != rb.size()) @@ -218,7 +231,20 @@ void EnergyArbitrator::rebuildLoadAdapters() // La distinction de TYPE vit ICI (rév. 3) ; au-dessus, tout est ILoadAdapter (Setpoint W). ILoadAdapter *adapter = nullptr; - if (c.isRelayRouter()) { + if (c.isSgReady()) { + // LM-300/301 — une branche de fabrique par mécanisme, rien d'autre à toucher. + const LoadConfigSgReady p = c.sgReadyPayload(); + QHash> stateRelays; + QHash estimated; + for (const LoadConfigSgReadyState &e : p.states) { + stateRelays.insert(e.state, e.relays); + estimated.insert(e.state, e.estimatedPowerW); + } + adapter = new SgReadyAdapter(m_tm, c.id(), c.label(), stateRelays, estimated, + p.minStateHoldS, c.priority(), this); + qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] sg-ready construit depuis config:" + << c.label() << "(" << p.states.count() << "états )"; + } else if (c.isRelayRouter()) { adapter = new RelayRouter(m_tm, c.id(), c.label(), c.relaysList(), c.minOnS(), c.minOffS(), c.priority(), needs, this); qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] relay-router construit depuis config:" @@ -234,6 +260,30 @@ 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é @@ -369,6 +419,14 @@ 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); diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index 357ef40..9421e14 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -180,6 +180,16 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana loadItem.insert("o:maxPowerW", enumValueName(Uint)); // etmvariableload dynamic loadItem.insert("o:needs", needsItem); loadItem.insert("o:relays", QVariantList() << relayItem); // relay-router (rév. 3) + // LM-300 — charge utile du mécanisme sg-ready. estimatedPowerW porte son nom : c'est une + // ESTIMATION (une PAC ne consomme pas pareil à −5 °C et +12 °C), jamais un engagement. + QVariantMap sgStateItem; + sgStateItem.insert("state", enumValueName(Uint)); + sgStateItem.insert("relays", QVariantList() << enumValueName(String)); + sgStateItem.insert("estimatedPowerW", enumValueName(Double)); + QVariantMap sgItem; + sgItem.insert("states", QVariantList() << sgStateItem); + sgItem.insert("o:minStateHoldS", enumValueName(Uint)); + loadItem.insert("o:sgReady", sgItem); // sg-ready (LM-300) loadItem.insert("o:minOnS", enumValueName(Uint)); loadItem.insert("o:minOffS", enumValueName(Uint)); params.insert("loadConfigs", QVariantList() << loadItem); diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 886af36..672c492 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -1379,6 +1379,103 @@ void Simulation::testSgReadyPartialFailure() #endif } +void Simulation::testSgReadyFromConfig() +{ +#ifndef ETM_ARBITRATOR + QSKIP("testSgReadyFromConfig nécessite ETM_ARBITRATOR."); +#else + // [LM-300] Première implémentation réelle de spec_loadmodel.md : union discriminée par + // MÉCANISME, une charge utile par mécanisme, mutuellement exclusives. + 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 k1 = addPowerSwitch(0, 26661); + QUuid k2 = addPowerSwitch(0, 26662); + Thing *tK1 = tm->findConfiguredThing(k1); + Thing *tK2 = tm->findConfiguredThing(k2); + QVERIFY(tK1 && tK2); + + const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-sgready.json"; + QFile::remove(cfgPath); + qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); + LoadConfigStore *store = new LoadConfigStore(arb); + arb->setLoadConfigStore(store); + + auto etat = [](int s, const QVariantList &relays, double w) { + return QVariantMap{{"state", s}, {"relays", relays}, {"estimatedPowerW", w}}; + }; + auto pacCfg = [&](const QVariantList &etats) { + return QVariantMap{ + {"id", "pac"}, {"label", "PAC"}, {"adapter", "sg-ready"}, {"mode", "fixed"}, + {"priority", 1}, {"enabled", true}, + {"sgReady", QVariantMap{{"states", etats}, {"minStateHoldS", 0}}}}; + }; + const QVariantList etatsOk = QVariantList() + << etat(1, QVariantList() << k1.toString(), 0) + << etat(2, QVariantList(), 0) + << etat(3, QVariantList() << k2.toString(), 1500) + << etat(4, QVariantList() << k1.toString() << k2.toString(), 3000); + + QString err; + + // --- REFUS 1 : pas d'état 2 → la charge ne peut pas être mise en sécurité (ECS-110) ---- + { + QVariantList sansDeux = QVariantList() + << etat(3, QVariantList() << k2.toString(), 1500) + << etat(4, QVariantList() << k1.toString() << k2.toString(), 3000); + LoadConfigs cs; cs.append(LoadConfig::fromMap(pacCfg(sansDeux))); + QVERIFY2(!store->setConfigs(cs, &err), "une PAC sans état 2 DOIT être refusée"); + QVERIFY2(err.contains("état 2"), err.toUtf8()); + } + + // --- REFUS 2 : charges utiles mélangées → états invalides inexprimables (LM-302) ------- + { + QVariantMap m = pacCfg(etatsOk); + m.insert("relays", QVariantList() << QVariantMap{{"thingId", k1.toString()}, {"powerW", 500}}); + LoadConfigs cs; cs.append(LoadConfig::fromMap(m)); + QVERIFY2(!store->setConfigs(cs, &err), "relays[] avec sg-ready DOIT être refusé"); + } + + // --- ACCEPTÉ : configuration complète, round-trip par le store ------------------------- + LoadConfigs cs; cs.append(LoadConfig::fromMap(pacCfg(etatsOk))); + QVERIFY2(store->setConfigs(cs, &err), err.toUtf8()); + const LoadConfigSgReady relu = store->configs().first().sgReadyPayload(); + QCOMPARE(relu.states.size(), 4); + QVERIFY(relu.hasState(2)); + // Le nom du champ dit ce qu'il est : une ESTIMATION, pas un engagement. + QCOMPARE(qRound(relu.states.at(3).estimatedPowerW), 3000); + + // --- La PAC configurée est pilotée comme n'importe quelle charge ------------------------ + meter->setStateValue("currentPower", -4000); // surplus large → état 4 attendu + arb->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0))); + QTest::qWait(300); + QCOMPARE(tK1->stateValue("power").toBool(), true); + QCOMPARE(tK2->stateValue("power").toBool(), true); + + // --- Désactivation : état sûr = ÉTAT 2, pas « contacts ouverts par principe » ----------- + QVariantMap off = pacCfg(etatsOk); off.insert("enabled", false); + LoadConfigs cs2; cs2.append(LoadConfig::fromMap(off)); + QVERIFY2(store->setConfigs(cs2, &err), err.toUtf8()); + QTest::qWait(300); + // État 2 = motif vide sur cet encodage → les deux contacts ouverts. + QCOMPARE(tK1->stateValue("power").toBool(), false); + QCOMPARE(tK2->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 684d9d6..e7a8bc7 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -109,6 +109,9 @@ private slots: // [ECS-414] L'échelle d'échec généralisée à SgReadyAdapter et EtmVariableLoadAdapter. // Plancher propre à chaque adaptateur : ÉTAT 2 pour la PAC, jamais « contacts ouverts ». void testSgReadyPartialFailure(); + // [LM-300] Union discriminée : le mécanisme sg-ready se configure comme les autres, ses + // charges utiles sont mutuellement exclusives, et l'état 2 est obligatoire (ECS-110). + void testSgReadyFromConfig(); void printStates(Thing *thing); void updateChargerMeter(Thing *thing);