diff --git a/energyplugin/etm/adapters/relayrouter.cpp b/energyplugin/etm/adapters/relayrouter.cpp index 8bbc6de..1da7348 100644 --- a/energyplugin/etm/adapters/relayrouter.cpp +++ b/energyplugin/etm/adapters/relayrouter.cpp @@ -64,6 +64,11 @@ RelayRouter::RelayRouter(ThingManager *thingManager, } // byPower contient toujours la clé 0 (masque vide) → m_levels[0] == 0. Q_ASSERT(!m_levels.isEmpty() && m_levels.first() == 0); + + // ECS-411 — reprise du palier depuis l'état RÉEL des relais. Partir de 0 alors que des + // contacts sont fermés, c'est le moteur qui croit 0 W pendant que le ballon tire 3 kW : + // même famille de défaut qu'ECS-410 (annoncer une puissance non appliquée). + m_currentStage = deduceStageFromThings(); } LoadDescriptor RelayRouter::descriptor() const @@ -220,6 +225,76 @@ void RelayRouter::lockWindow(const QDateTime &now, int &minStage, int &maxStage) maxStage = (m_currentStage == 0 && offHeld) ? 0 : topStage; } +int RelayRouter::deduceStageFromThings() const +{ + // Ensemble des relais réellement FERMÉS, lu une seule fois. + QSet allRelays; + for (const auto &list : m_relayMapping) + for (const QString &id : list) + allRelays.insert(id); + + // Puissance nominale par relais, pour sommer l'état lu. + QHash nominal; + for (int i = 0; i < m_relayMapping.size(); ++i) + for (const QString &id : m_relayMapping.at(i)) + if (m_relayMapping.at(i).size() == 1) + nominal.insert(id, m_levels.at(i)); + + QSet on; + int sumW = 0; + bool unreadable = false; + for (const QString &thingId : allRelays) { + Thing *relay = m_thingManager ? m_thingManager->findConfiguredThing(ThingId(thingId)) : nullptr; + if (!relay) { + // Thing absent : son état est INCONNU. On le suppose FERMÉ — sous-estimer la + // puissance appliquée est le défaut qu'ECS-411 corrige, le surestimer ne fait + // que retarder une montée en puissance. + unreadable = true; + on.insert(thingId); + sumW += nominal.value(thingId, 0); + continue; + } + if (relay->stateValue("power").toBool()) { + on.insert(thingId); + sumW += nominal.value(thingId, 0); + } + } + if (unreadable) + qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label + << "— relais introuvable(s) au démarrage : supposé(s) FERMÉ(s)."; + + // 1) Correspondance EXACTE de l'ensemble de relais avec un palier : cas nominal. + for (int i = 0; i < m_relayMapping.size(); ++i) { + const QList &l = m_relayMapping.at(i); + if (QSet(l.begin(), l.end()) == on) { + if (i > 0) + qCInfo(dcNymeaEnergy()) << "[RelayRouter]" << m_label + << "— palier repris au démarrage:" << m_levels.at(i) << "W"; + return i; + } + } + + // 2) Ensemble non retenu dans la table — cas des encodages DÉDUPLIQUÉS (deux + // combinaisons de même puissance, une seule conservée). On se rabat sur la + // PUISSANCE : toute somme de sous-ensemble figure nécessairement dans m_levels, + // puisque m_levels est construit de toutes ces sommes. On annonce donc la bonne + // puissance, et le premier applyAction() normalisera l'encodage des relais. + const int idx = m_levels.indexOf(sumW); + if (idx >= 0) { + qCInfo(dcNymeaEnergy()) << "[RelayRouter]" << m_label + << "— encodage de démarrage hors table, repris par la puissance:" + << sumW << "W (normalisé au premier cycle)."; + return idx; + } + + // 3) Inatteignable sauf troncature à MaxRelays : la somme lue dépasse la table. On prend + // le palier le plus haut plutôt que 0 — ne JAMAIS annoncer moins que ce qui est appliqué. + qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label + << "— puissance de démarrage" << sumW + << "W hors table (relais tronqués ?) : palier maximal adopté."; + return m_levels.size() - 1; +} + void RelayRouter::applyRelayStage(int stage) { // Set CIBLE du palier (delta complet : chaque relais connu amené à son état on/off cible). diff --git a/energyplugin/etm/adapters/relayrouter.h b/energyplugin/etm/adapters/relayrouter.h index 420a1d9..1d8587d 100644 --- a/energyplugin/etm/adapters/relayrouter.h +++ b/energyplugin/etm/adapters/relayrouter.h @@ -105,6 +105,10 @@ private: * **ARMÉ** pour sa durée configurée — cf. ECS-412, démarrage à froid. */ void lockWindow(const QDateTime &now, int &minStage, int &maxStage) const; + //! \brief Déduit le palier courant de l'état RÉEL des Things relais (ECS-411). + //! \return Palier dont l'encodage correspond aux relais fermés ; à défaut, celui de + //! même PUISSANCE. Ne retourne jamais un palier inférieur à ce qui est appliqué. + int deduceStageFromThings() const; void applyRelayStage(int stage); ThingManager *m_thingManager; diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 7b54cc1..d4d4810 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -868,6 +868,62 @@ void Simulation::testLoadConfigRelayRouter() #endif } +void Simulation::testEcsRestartRecovery() +{ +#ifndef ETM_ARBITRATOR + QSKIP("testEcsRestartRecovery nécessite ETM_ARBITRATOR."); +#else + // [ECS-411] Des relais déjà fermés au démarrage → le palier courant est DÉDUIT, pas + // remis à 0. Sinon le moteur croit 0 W pendant que le ballon tire sa puissance. + 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 r500 = addPowerSwitch(500, 26661); + QUuid r1000 = addPowerSwitch(1000, 26662); + QUuid r1500 = addPowerSwitch(1500, 26663); + Thing *t500 = tm->findConfiguredThing(r500); + Thing *t1000 = tm->findConfiguredThing(r1000); + Thing *t1500 = tm->findConfiguredThing(r1500); + QVERIFY(t500 && t1000 && t1500); + + const QList relays({ {r500.toString(), 500}, + {r1000.toString(), 1000}, + {r1500.toString(), 1500} }); + + // Cas 1 — tous ouverts : palier 0, comportement inchangé. + { + t500->setStateValue("power", false); + t1000->setStateValue("power", false); + t1500->setStateValue("power", false); + RelayRouter *r = new RelayRouter(tm, "ecs-off", "ECS éteint", relays, 0, 0, 1, LoadNeeds(), arb); + QCOMPARE(r->currentStage(), 0); + QCOMPARE(qRound(r->currentSetpointW()), 0); + } + + // Cas 2 — R1000 fermé au démarrage : le routeur DOIT repartir à 1000 W, pas à 0. + { + t500->setStateValue("power", false); + t1000->setStateValue("power", true); + t1500->setStateValue("power", false); + RelayRouter *r = new RelayRouter(tm, "ecs-1000", "ECS repris", relays, 0, 0, 1, LoadNeeds(), arb); + QCOMPARE(qRound(r->currentSetpointW()), 1000); + } + + // Cas 3 — R500 + R1500 fermés : 2000 W, combinaison à deux relais correctement reconnue. + { + t500->setStateValue("power", true); + t1000->setStateValue("power", false); + t1500->setStateValue("power", true); + RelayRouter *r = new RelayRouter(tm, "ecs-2000", "ECS repris 2000", relays, 0, 0, 1, LoadNeeds(), arb); + QCOMPARE(qRound(r->currentSetpointW()), 2000); + } +#endif +} + void Simulation::testEcsRebuildPreservesLock() { #ifndef ETM_ARBITRATOR diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 0cc4e47..cd1eed2 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -88,6 +88,9 @@ private slots: // [é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(); + // [étape 1 / ECS-411] Le palier courant est déduit de l'état réel des Things relais au + // démarrage, pas réinitialisé à 0. + void testEcsRestartRecovery(); void printStates(Thing *thing); void updateChargerMeter(Thing *thing);