fix(etm): ECS-411 — reprise du palier depuis l'état réel des relais

m_currentStage valait 0 à la construction alors que des contacts peuvent être
fermés : le moteur croyait 0 W pendant que le ballon tirait sa puissance. Même
famille de défaut qu'ECS-410 — annoncer une puissance non appliquée.

deduceStageFromThings() lit l'état réel des Things au constructeur, en trois
temps :

  1. correspondance EXACTE de l'ensemble de relais fermés avec un palier — cas
     nominal ;
  2. à défaut, correspondance par PUISSANCE. Ce cas se produit avec les encodages
     dédupliqués : deux combinaisons de même puissance existent, une seule est
     conservée dans la table. Toute somme de sous-ensemble figure nécessairement
     dans m_levels, qui est construit de ces sommes — la reprise aboutit donc
     toujours, et le premier applyAction() normalise l'encodage des relais ;
  3. hors table (troncature à MaxRelays) : palier MAXIMAL, jamais 0.

Un relais introuvable est supposé FERMÉ, avec avertissement. Le principe est
constant sur les trois branches : ne jamais annoncer moins que ce qui peut être
appliqué. Sous-estimer est le défaut qu'ECS-411 corrige ; surestimer ne fait que
retarder une montée en puissance.

Le repli initial sur le palier 0 — écrit dans un premier jet — reproduisait
exactement le défaut visé et a été corrigé avant ce commit.

Test : testEcsRestartRecovery — trois cas, dont une combinaison à deux relais
(R500+R1500 = 2000 W) et le cas tout-ouvert.

Build amd64 0 erreur. Simulation : 13/13.

Réf. specs/spec_ecs.md §5 ECS-411 (0.5.1).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-08-08 12:45:15 +02:00
parent c9b8e63f89
commit 701759ddbb
4 changed files with 138 additions and 0 deletions

View File

@ -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<QString> 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<QString, int> 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<QString> 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<QString> &l = m_relayMapping.at(i);
if (QSet<QString>(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).

View File

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

View File

@ -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<EnergyArbitrator *>(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<LoadConfigRelay> 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

View File

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