fix(etm): ECS-412 — rebuild incrémental et armement du verrou à froid

Cause racine, pas symptôme. rebuildLoadAdapters() détruisait TOUS les adaptateurs
à chaque SetLoadConfig, réarmant leurs verrous. Sur un ballon thermodynamique à
minOn de 300-600 s, un client qui réordonne ses priorités depuis l'app pouvait
faire court-cycler son compresseur. C'est de la protection matérielle.

L'arbitre mémorise désormais la config ayant servi à construire chaque adaptateur
(m_builtFrom) et ne reconstruit que si le MATÉRIEL a changé — type, câblage,
paliers, plafond, verrous (sameHardware()). Un changement de rang ou de besoins
passe par updateSoftConfig(), en place : ni m_currentStage ni m_lastSwitch ne
bougent, aucun relais n'est réécrit. Le log distingue créées / mises à jour /
inchangées / retirées.

updateSoftConfig est PURE VIRTUELLE, sans implémentation par défaut. Un défaut
vide silencieux ferait qu'un futur adaptateur construit depuis LoadConfig
ignorerait sans bruit les changements de rang ; là, le compilateur force la
décision. EvAdapter et SgReadyAdapter la déclarent sans effet, avec le motif.

Démarrage à froid : après un redémarrage de nymead, m_lastSwitch est
irrécupérable. lockWindow() traite désormais un horodatage nul comme
« commutation venant d'avoir lieu » (elapsed = 0), donc verrou ARMÉ pour sa durée
configurée. L'écriture naturelle (`valid && elapsed < minOnS`) fait l'inverse et
laisserait une boucle de redémarrage court-circuiter la protection compresseur
quand elle est la plus nécessaire. Armer n'est pas verrouiller
inconditionnellement : avec une durée nulle, `0 < 0` est faux et le verrou reste
inactif — un premier jet qui forçait le verrou a fait tomber trois tests
existants, qui avaient raison.

Test : testEcsRebuildPreservesLock — SetLoadConfig pendant une fenêtre de verrou
active, le relais reste fermé ; le délestage reprend une fois minOn écoulé.

Build amd64 0 erreur. Simulation : 12/12.

Réf. specs/spec_ecs.md §3 ECS-412 (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:42:56 +02:00
parent 1011a6ccd7
commit c9b8e63f89
10 changed files with 236 additions and 16 deletions

View File

@ -106,6 +106,11 @@ public:
*/
LoadAction applyAction(const LoadAction &action, const QDateTime &now) override;
//! \brief Met à jour rang et besoins sans reconstruire (ECS-412).
//! \param priority Nouveau rang de service. \param needs Nouveaux besoins.
void updateSoftConfig(int priority, const LoadNeeds &needs) override
{ m_priority = priority; m_needs = needs; }
/*!
* \brief Dernière consigne (W) effectivement écrite (avant écrêtage thing).
* \return Consigne commandée en W, bornée à \c maxPowerW ; 0 tant qu'aucune action

View File

@ -71,6 +71,11 @@ public:
*/
LoadAction applyAction(const LoadAction &action, const QDateTime &now) override;
//! \brief Sans effet : l'EV n'est pas construit depuis \c LoadConfig (rang fixé à 100,
//! cf. « Dette 3g »). \param priority Ignoré. \param needs Ignoré.
void updateSoftConfig(int priority, const LoadNeeds &needs) override
{ Q_UNUSED(priority) Q_UNUSED(needs) }
/*!
* \brief Borne VE sous-jacente (lecture).
* \return Pointeur non-propriétaire vers l'EvCharger ; jamais nul (fixé à la construction).

View File

@ -72,4 +72,24 @@ public:
* \note Retour silencieux sans effet si \c action.reason est vide.
*/
virtual LoadAction applyAction(const LoadAction &action, const QDateTime &now) = 0;
/*!
* \brief Met à jour les champs de configuration qui ne touchent PAS le matériel.
*
* Sert au rebuild INCRÉMENTAL (ECS-412) : quand seul le rang de service ou les besoins
* changent, l'arbitre met à jour l'adaptateur en place au lieu de le détruire. Détruire
* réarmerait les verrous sur un ballon thermodynamique à \c minOn de 300 à 600 s, un
* client qui réordonne ses priorités depuis l'app pourrait faire court-cycler son
* compresseur et provoquerait des réécritures de relais inutiles.
*
* \param priority Nouveau rang de service (ASC, 1 = premier servi).
* \param needs Nouveaux besoins déclarés.
* \warning NE DOIT jamais toucher au câblage, aux paliers ni aux verrous : ces
* changements- exigent une vraie reconstruction.
* \note **Pure virtuelle à dessein**, sans implémentation par défaut. Un adaptateur qui
* n'est pas construit depuis \c LoadConfig n'a rien à mettre à jour, mais il doit le
* DÉCLARER : un défaut vide silencieux ferait qu'un futur adaptateur construit depuis
* la config ignorerait sans bruit les changements de rang.
*/
virtual void updateSoftConfig(int priority, const LoadNeeds &needs) = 0;
};

View File

@ -190,16 +190,34 @@ int RelayRouter::stageForPower(double powerW) const
return stage;
}
void RelayRouter::updateSoftConfig(int priority, const LoadNeeds &needs)
{
// ECS-412 — mise à jour EN PLACE : ni m_currentStage ni m_lastSwitch ne bougent,
// aucun relais n'est réécrit.
m_priority = priority;
m_needs = needs;
}
void RelayRouter::lockWindow(const QDateTime &now, int &minStage, int &maxStage) const
{
const int topStage = m_levels.size() - 1;
const bool valid = m_lastSwitch.isValid();
const qint64 elapsed = valid ? m_lastSwitch.secsTo(now) : 0;
const int topStage = m_levels.size() - 1;
// ECS-412 — DÉMARRAGE À FROID. m_lastSwitch nul signifie « on ne sait pas quand la
// dernière commutation a eu lieu » (redémarrage de nymead, reconstruction). Le défaut
// SÛR est d'ARMER le verrou — traiter la commutation comme venant d'avoir lieu, donc
// elapsed = 0 — et non de le purger. L'écriture naturelle (`valid && elapsed < minOnS`)
// fait l'inverse et laisserait une boucle de redémarrage court-circuiter la protection
// compresseur exactement quand elle est la plus nécessaire.
// Armer ne signifie PAS verrouiller inconditionnellement : avec une durée nulle,
// `0 < 0` est faux et le verrou reste inactif.
const qint64 elapsed = m_lastSwitch.isValid() ? m_lastSwitch.secsTo(now) : 0;
const bool onHeld = elapsed < m_minOnS;
const bool offHeld = elapsed < m_minOffS;
// Plancher : si ON et minOn non écoulé → interdit de descendre (puissance engagée non-coupable).
minStage = (m_currentStage > 0 && valid && elapsed < m_minOnS) ? m_currentStage : 0;
minStage = (m_currentStage > 0 && onHeld) ? m_currentStage : 0;
// Plafond : si à l'arrêt et minOff non écoulé → interdit de redémarrer.
maxStage = (m_currentStage == 0 && valid && elapsed < m_minOffS) ? 0 : topStage;
maxStage = (m_currentStage == 0 && offHeld) ? 0 : topStage;
}
void RelayRouter::applyRelayStage(int stage)

View File

@ -86,10 +86,24 @@ public:
//! \brief Puissance (W) du palier courant.
double currentSetpointW() const { return m_currentStage < m_levels.size() ? m_levels.at(m_currentStage) : 0.0; }
//! \brief Met à jour les champs qui ne touchent PAS le matériel (ECS-412).
//! \param priority Nouveau rang de service.
//! \param needs Nouveaux besoins déclarés.
//! \note Permet à l'arbitre de refléter un changement de priorité SANS détruire
//! l'adaptateur — donc sans réarmer les verrous ni recommuter les relais.
void updateSoftConfig(int priority, const LoadNeeds &needs) override;
private:
//! Palier le plus haut dont la puissance ≤ \p powerW (≥ 0).
int stageForPower(double powerW) const;
//! Fenêtre de paliers autorisée à \p now par minOn/minOff (verrou INTERNE, plus exposé).
/*!
* \brief Fenêtre de paliers autorisée à \p now par minOn/minOff (verrou INTERNE).
* \param now Temps de cycle.
* \param[out] minStage Palier plancher (puissance engagée non-coupable).
* \param[out] maxStage Palier plafond (interdiction de redémarrer).
* \note Un \c m_lastSwitch NUL vaut « commutation venant d'avoir lieu », donc verrou
* **ARMÉ** pour sa durée configurée cf. ECS-412, démarrage à froid.
*/
void lockWindow(const QDateTime &now, int &minStage, int &maxStage) const;
void applyRelayStage(int stage);

View File

@ -89,6 +89,12 @@ public:
*/
LoadAction applyAction(const LoadAction &action, const QDateTime &now) override;
//! \brief Sans effet : la PAC du banc est codée en dur, pas construite depuis
//! \c LoadConfig (à basculer avec la couche config).
//! \param priority Ignoré. \param needs Ignoré.
void updateSoftConfig(int priority, const LoadNeeds &needs) override
{ Q_UNUSED(priority) Q_UNUSED(needs) }
/*!
* \brief État SG-Ready courant (1-4).
* \return Dernier état réellement commuté ; valeur initiale tant qu'aucune transition

View File

@ -134,17 +134,46 @@ void EnergyArbitrator::setLoadConfigStore(LoadConfigStore *store)
rebuildLoadAdapters(); // construction initiale depuis la config persistée
}
bool EnergyArbitrator::sameHardware(const LoadConfig &a, const LoadConfig &b)
{
// Champs dont un changement impose une VRAIE reconstruction : ils déterminent la table
// de paliers, le câblage ou les verrous. Tout le reste (rang, besoins, libellé) se met
// à jour en place.
if (a.adapter() != b.adapter() || a.mode() != b.mode())
return false;
if (a.minOnS() != b.minOnS() || a.minOffS() != b.minOffS())
return false;
if (a.maxPowerW() != b.maxPowerW() || a.powerLevelsInt() != b.powerLevelsInt())
return false;
const QList<LoadConfigRelay> ra = a.relaysList();
const QList<LoadConfigRelay> rb = b.relaysList();
if (ra.size() != rb.size())
return false;
for (int i = 0; i < ra.size(); ++i)
if (ra.at(i).thingId != rb.at(i).thingId || ra.at(i).powerW != rb.at(i).powerW)
return false;
return true;
}
void EnergyArbitrator::rebuildLoadAdapters()
{
// Purge des adaptateurs construits depuis la config précédente (ILoadAdapter n'est pas QObject :
// les concrets le sont — dynamic_cast pour deleteLater).
for (ILoadAdapter *a : m_loadAdapters)
if (QObject *o = dynamic_cast<QObject *>(a))
o->deleteLater();
m_loadAdapters.clear();
if (!m_loadConfigStore)
if (!m_loadConfigStore) {
for (ILoadAdapter *a : m_loadAdapters)
if (QObject *o = dynamic_cast<QObject *>(a))
o->deleteLater();
m_loadAdapters.clear();
m_builtFrom.clear();
return;
}
// ECS-412 — reconstruction INCRÉMENTALE. Détruire un adaptateur réarme ses verrous : sur
// un ballon thermodynamique à minOn de 300-600 s, un client qui réordonne ses priorités
// depuis l'app pourrait faire court-cycler son compresseur. On ne reconstruit donc que
// ce dont le MATÉRIEL a changé ; le reste est mis à jour en place.
QHash<QString, ILoadAdapter *> kept;
QHash<QString, LoadConfig> keptFrom;
int created = 0, updated = 0, reused = 0;
for (const LoadConfig &c : m_loadConfigStore->configs()) {
// enabled==false : rôle déclaré mais EXCLU de l'arbitrage (contrat §9) — pas d'adaptateur.
@ -155,6 +184,26 @@ void EnergyArbitrator::rebuildLoadAdapters()
needs.dailyDeadline = c.needs().dailyDeadline();
needs.minEnergyWhPerDay = c.needs().minEnergyWhPerDay();
ILoadAdapter *existing = m_loadAdapters.value(c.id(), nullptr);
if (existing && m_builtFrom.contains(c.id()) && sameHardware(m_builtFrom.value(c.id()), c)) {
// Matériel inchangé : on GARDE l'adaptateur — donc m_lastSwitch et le palier
// courant — et on ne met à jour que ce qui ne touche pas au matériel.
const LoadConfig &old = m_builtFrom[c.id()];
const bool soft = (old.priority() != c.priority())
|| (old.needs().dailyDeadline() != c.needs().dailyDeadline())
|| (old.needs().minEnergyWhPerDay() != c.needs().minEnergyWhPerDay());
if (soft) {
existing->updateSoftConfig(c.priority(), needs);
++updated;
} else {
++reused;
}
kept[c.id()] = existing;
keptFrom[c.id()] = c;
m_loadAdapters.remove(c.id()); // sorti de la table : ne sera pas détruit plus bas
continue;
}
// La distinction de TYPE vit ICI (rév. 3) ; au-dessus, tout est ILoadAdapter (Setpoint W).
ILoadAdapter *adapter = nullptr;
if (c.isRelayRouter()) {
@ -168,10 +217,24 @@ void EnergyArbitrator::rebuildLoadAdapters()
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] etmvariableload construit depuis config:"
<< c.label() << "(" << (c.isDynamic() ? "dynamic" : "fixed") << ")";
}
m_loadAdapters[c.id()] = adapter;
kept[c.id()] = adapter;
keptFrom[c.id()] = c;
++created;
}
// Ce qui reste dans m_loadAdapters n'est plus référencé par la config : à détruire.
for (ILoadAdapter *a : m_loadAdapters)
if (QObject *o = dynamic_cast<QObject *>(a))
o->deleteLater();
const int removed = m_loadAdapters.count();
m_loadAdapters = kept;
m_builtFrom = keptFrom;
qCInfo(dcNymeaEnergy()) << "[EnergyArbitrator]" << m_loadAdapters.count()
<< "charge(s) pilotée(s) active(s) (config).";
<< "charge(s) pilotée(s) active(s) (config) —" << created << "créée(s),"
<< updated << "mise(s) à jour en place," << reused << "inchangée(s),"
<< removed << "retirée(s).";
}
void EnergyArbitrator::update(const QDateTime &currentDateTime)

View File

@ -6,6 +6,9 @@
#include "scheduler/ischeduler.h"
#include "types/surpluscontext.h"
#include "types/plan.h"
// Inclus (et non déclaré en avant) : m_builtFrom stocke des LoadConfig PAR VALEUR — c'est ce
// qui permet au rebuild incrémental (ECS-412) de comparer l'ancienne et la nouvelle config.
#include "types/loadconfig.h"
#include <QDateTime>
@ -195,6 +198,12 @@ private:
*/
void applyActionsToAdapters(const Slot &slot, const QDateTime &now);
//! \brief Vrai si deux configs décrivent le MÊME matériel (câblage, paliers, verrous).
//! \param a Config ayant servi à construire l'adaptateur. \param b Nouvelle config.
//! \return Vrai si l'adaptateur peut être conservé — ECS-412. Rang, besoins et libellé
//! sont volontairement ignorés : ils se mettent à jour en place.
static bool sameHardware(const LoadConfig &a, const LoadConfig &b);
/*!
* \brief (Re)construit \c m_loadAdapters depuis \c m_loadConfigStore (rév. 3).
* Purge les adaptateurs existants (deleteLater) puis crée, par config \c enabled==true, un
@ -235,6 +244,9 @@ private:
QHash<QString, EvAdapter *> m_adapters; //!< loadId (ThingId string) → EvAdapter*.
QHash<QString, SgReadyAdapter *> m_sgReadyAdapters; //!< loadId → SgReadyAdapter* (PAC).
QHash<QString, ILoadAdapter *> m_loadAdapters; //!< loadId → charge pilotée (relay-router | etmvariableload).
//! loadId → config AYANT SERVI à construire l'adaptateur. Permet le rebuild incrémental
//! (ECS-412) : on ne reconstruit que si le matériel a changé.
QHash<QString, LoadConfig> m_builtFrom;
ThingManager *m_tm = nullptr; //!< ThingManager (pour construire les adaptateurs config).
LoadConfigStore *m_loadConfigStore = nullptr; //!< Store config charge pilotée (non adopté).

View File

@ -868,6 +868,79 @@ void Simulation::testLoadConfigRelayRouter()
#endif
}
void Simulation::testEcsRebuildPreservesLock()
{
#ifndef ETM_ARBITRATOR
QSKIP("testEcsRebuildPreservesLock nécessite ETM_ARBITRATOR.");
#else
// [ECS-412] SetLoadConfig PENDANT une fenêtre de verrou active : un changement de rang
// ne doit pas détruire l'adaptateur, donc ne doit pas réarmer le verrou. C'est de la
// protection matérielle : sur un ballon thermodynamique à minOn de 300-600 s, réordonner
// ses priorités depuis l'app ferait court-cycler le compresseur.
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 meterId = addMeter();
m_experiencePlugin->energyManager()->setRootMeter(meterId);
Thing *meter = tm->findConfiguredThing(meterId);
QVERIFY(meter);
meter->setStateValue("connected", true);
QUuid rA = addPowerSwitch(2000, 26661);
Thing *tA = tm->findConfiguredThing(rA);
QVERIFY(tA);
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-rebuildlock.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
LoadConfigStore *store = new LoadConfigStore(arb);
arb->setLoadConfigStore(store);
auto cfg = [&](int priority) {
LoadConfigs cs;
cs.append(LoadConfig::fromMap(QVariantMap{
{"id", "ecs-verrou"}, {"label", "ECS verrouillé"}, {"adapter", "relay-router"},
{"mode", "fixed"}, {"priority", priority}, {"enabled", true},
{"relays", QVariantList() << QVariantMap{{"thingId", rA.toString()}, {"powerW", 2000}}},
{"minOnS", 300}, {"minOffS", 0}}));
return cs;
};
QString err;
QVERIFY2(store->setConfigs(cfg(1), &err), err.toUtf8());
// Cycle 1 : surplus large → le relais se ferme, le verrou minOn s'arme.
meter->setStateValue("currentPower", -2500);
arb->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)));
QCoreApplication::processEvents();
QCOMPARE(tA->stateValue("power").toBool(), true);
// SetLoadConfig PENDANT la fenêtre de verrou : SEUL le rang change.
QVERIFY2(store->setConfigs(cfg(2), &err), err.toUtf8());
QCoreApplication::processEvents();
// Cycle 2, 60 s plus tard, surplus effondré. Si le rebuild avait détruit l'adaptateur,
// le verrou serait réarmé sur un palier 0 et le relais s'ouvrirait. Il doit RESTER fermé :
// minOn court toujours depuis le cycle 1.
meter->setStateValue("currentPower", 100); // import : budget négatif
arb->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 1, 0)));
QCoreApplication::processEvents();
QCOMPARE(tA->stateValue("power").toBool(), true);
// Au-delà de minOn, le délestage reprend normalement.
arb->simulationCallUpdate(utcDateTime(QDate(2026, 6, 8), QTime(13, 6, 0)));
QCoreApplication::processEvents();
QCOMPARE(tA->stateValue("power").toBool(), false);
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
QFile::remove(cfgPath);
#endif
}
void Simulation::run_data()
{
// Simulation infos

View File

@ -85,6 +85,10 @@ private slots:
// (EnergyRatiosInterim.compute) — seed, normal, clamp, den≤0→n/a, non-monotone, nouveau jour.
void testEnergyRatiosAlignment();
// [é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();
void printStates(Thing *thing);
void updateChargerMeter(Thing *thing);