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);