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:
parent
c9b8e63f89
commit
701759ddbb
@ -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).
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user