[3c-7c] testMeterSilentFallback + seam watchdog injectable

Extraction recordMeterUpdate(now)/evaluateMeterFreshness(now) : logique L2 injectable
(temps en paramètre), déclencheurs réels (QTimer + powerBalanceChanged) sous
#ifndef ENERGY_SIMULATION (pattern amont). onMeterWatchdogTick délègue.

testMeterSilentFallback : compteur muet >90s → dégradé (ECS off force=true, bypass
minOn) → STABILITÉ (ECS reste 0 sur 4 cycles, planif suspendue) → REPRISE (recalcul
depuis le surplus, pas de restauration d'ancienne consigne). Suite simulation 18/18,
charging 46/46, plugin prod 0/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-06-09 22:21:22 +02:00
parent dde967da41
commit 54ba2296fa
4 changed files with 135 additions and 10 deletions

View File

@ -30,22 +30,24 @@ EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm,
, m_scheduler(new RuleBasedScheduler(this, this))
{
// --- L2 : watchdog fraîcheur compteur (SAFETY.md §L2) ---
// Fraîcheur picotée sur powerBalanceChanged (en plus de la connexion amont L4) ;
// grâce au démarrage : on initialise le timestamp pour éviter un dégradé immédiat.
m_lastMeterUpdate = QDateTime::currentDateTime();
// La LOGIQUE (recordMeterUpdate / evaluateMeterFreshness) prend le temps en paramètre
// et reste testable par injection (symétrique de simulationCallUpdate). Seuls les
// DÉCLENCHEURS RÉELS (signal + QTimer, horloge murale) sont câblés ici, et exclus en
// simulation — comme les connexions amont powerBalanceEntryAdded→update() (SCM l.108-130).
#ifndef ENERGY_SIMULATION
m_lastMeterUpdate = QDateTime::currentDateTime(); // grâce au démarrage (évite un dégradé immédiat)
// Fraîcheur picotée sur powerBalanceChanged (en plus de la connexion amont L4).
connect(em, &EnergyManager::powerBalanceChanged, this, [this]() {
m_lastMeterUpdate = QDateTime::currentDateTime();
if (m_degradedMode) {
qCInfo(dcNymeaEnergy()) << "[Arbitre] Compteur de nouveau actif — sortie du mode dégradé L2.";
m_degradedMode = false;
emit chargingSchedulesChanged(); // pousse degradedMode=false (planif reprend au cycle suivant)
}
recordMeterUpdate(QDateTime::currentDateTime());
});
// QTimer (et non signal) : doit rester actif quand le compteur est muet.
m_meterWatchdog = new QTimer(this);
m_meterWatchdog->setInterval(MeterWatchdogPeriodMs);
connect(m_meterWatchdog, &QTimer::timeout, this, &EnergyArbitrator::onMeterWatchdogTick);
m_meterWatchdog->start();
#else
Q_UNUSED(em)
#endif
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] Arbitre ETM initialisé.";
}
@ -204,11 +206,26 @@ void EnergyArbitrator::applyActionsToAdapters(const Slot &slot, const QDateTime
}
void EnergyArbitrator::onMeterWatchdogTick()
{
// Déclencheur réel (QTimer, horloge murale) → délègue à la logique injectable.
evaluateMeterFreshness(QDateTime::currentDateTime());
}
void EnergyArbitrator::recordMeterUpdate(const QDateTime &now)
{
m_lastMeterUpdate = now;
if (m_degradedMode) {
qCInfo(dcNymeaEnergy()) << "[Arbitre] Compteur de nouveau actif — sortie du mode dégradé L2.";
m_degradedMode = false;
emit chargingSchedulesChanged(); // pousse degradedMode=false (planif reprend au cycle suivant)
}
}
void EnergyArbitrator::evaluateMeterFreshness(const QDateTime &now)
{
if (!m_lastMeterUpdate.isValid())
return; // Aucune mesure reçue (démarrage) — pas de dégradé (invariant root meter absent).
const QDateTime now = QDateTime::currentDateTime();
const qint64 silentS = m_lastMeterUpdate.secsTo(now);
if (silentS <= MeterSilenceThresholdS)
return;

View File

@ -92,6 +92,29 @@ public:
*/
bool degradedMode() const override { return m_degradedMode; }
/*!
* \brief Enregistre une mesure fraîche du compteur à l'instant \p now (logique L2).
*
* Met à jour \c m_lastMeterUpdate et, si le mode dégradé était actif, en sort
* (\c degradedMode=false + notification). \p now = temps de cycle.
* \note Logique injectable (temps en paramètre) en production appelée par le
* handler \c powerBalanceChanged ; en simulation/test appelée directement. Le
* déclencheur réel (signal) est câblé sous \c \#ifndef ENERGY_SIMULATION.
*/
void recordMeterUpdate(const QDateTime &now);
/*!
* \brief Évalue la fraîcheur du compteur à \p now et bascule en mode dégradé si muet >90 s.
*
* Si \c now \c m_lastMeterUpdate > 90 s et pas déjà dégradé \c applyDegradedMode().
* Appliqué à la TRANSITION uniquement (idempotent ensuite). \p now = temps de cycle.
* \note Logique injectable en production appelée par \c onMeterWatchdogTick() (QTimer
* horloge murale, indépendant car le compteur muet fige aussi \c update()) ; en
* simulation/test appelée directement avec le temps simulé. Symétrique de
* \c simulationCallUpdate : déclencheur réel en prod, logique testable par injection.
*/
void evaluateMeterFreshness(const QDateTime &now);
protected:
/*!
* \brief Boucle principale ETM surcharge SmartChargingManager::update().

View File

@ -141,6 +141,87 @@ void Simulation::testEcsSurplusPV()
#endif
}
void Simulation::testMeterSilentFallback()
{
#ifndef ETM_ARBITRATOR
QSKIP("testMeterSilentFallback nécessite ETM_ARBITRATOR.");
#else
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arbitrator = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY2(arbitrator, "smartChargingManager n'est pas un EnergyArbitrator (ETM_ARBITRATOR requis)");
ThingManager *thingManager = NymeaCore::instance()->thingManager();
// --- Root meter + un relais ECS (palier 1 = 2400 W) ---
QUuid meterThingId = addMeter();
QVERIFY2(!meterThingId.isNull(), "meter thingId invalide");
m_experiencePlugin->energyManager()->setRootMeter(meterThingId);
Thing *meterThing = thingManager->findConfiguredThing(meterThingId);
QVERIFY(meterThing);
meterThing->setStateValue("connected", true);
QUuid relayAId = addPowerSwitch(2400, 26661);
QVERIFY(!relayAId.isNull());
Thing *relayA = thingManager->findConfiguredThing(relayAId);
QVERIFY(relayA);
// minOn = 300 s (protection compresseur) ; minOff = 0.
EcsRelayAdapter *ecs = new EcsRelayAdapter(
thingManager, "ecs-fallback-test", "Chauffe-eau (test repli)",
QList<int>({0, 2400}),
QList<QList<QString>>({ {}, {relayAId.toString()} }),
300, 0, 1, arbitrator);
arbitrator->registerEcsAdapter(ecs);
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
auto setMeterW = [&](double signedW){ meterThing->setStateValue("currentPower", signedW); };
auto cycle = [&](const QDateTime &now){ arbitrator->simulationCallUpdate(now); QCoreApplication::processEvents(); };
// --- ECS allumé sur surplus, compteur frais à T0 ---
arbitrator->recordMeterUpdate(t0);
setMeterW(-2500); cycle(t0); // surplus 2500 → palier 1 (lastSwitch ECS = T0)
QCOMPARE(ecs->currentStage(), 1);
QCOMPARE(relayA->stateValue("power").toBool(), true);
QVERIFY(!arbitrator->degradedMode());
// --- Compteur muet > 90 s → mode dégradé ; le repli force=true coupe l'ECS MÊME sous minOn ---
// (ECS commuté à T0, elapsed 91 s < minOn 300 → normalement verrouillé ; force=true bypasse.)
arbitrator->evaluateMeterFreshness(t0.addSecs(91));
QCoreApplication::processEvents();
QVERIFY(arbitrator->degradedMode());
QCOMPARE(ecs->currentStage(), 0); // coupé malgré minOn (sécurité bypasse l'anti-flapping)
QCOMPARE(relayA->stateValue("power").toBool(), false);
// --- STABILITÉ : compteur toujours muet, plusieurs update() → l'ECS RESTE à 0 ---
// (planification suspendue : pas de getPlan() sur cache mort qui rallumerait l'ECS.)
// On garde même un faux surplus au compteur pour piéger une éventuelle replanification.
setMeterW(-3000);
foreach (int dt, QList<int>({92, 120, 200, 280})) {
cycle(t0.addSecs(dt));
QVERIFY2(arbitrator->degradedMode(), "degradedMode doit rester actif pendant le silence");
QCOMPARE(ecs->currentStage(), 0);
QCOMPARE(relayA->stateValue("power").toBool(), false);
}
// --- REPRISE : le compteur re-parle → degradedMode retombe → recalcul normal ---
arbitrator->recordMeterUpdate(t0.addSecs(300));
QVERIFY(!arbitrator->degradedMode());
// Preuve "recalcul, pas restauration d'ancienne consigne" : surplus FAIBLE → l'ECS
// RESTE éteint (recalculé), il ne revient PAS à son ancien palier 1.
setMeterW(-1000); cycle(t0.addSecs(301)); // 1000 W < palier 1 → éteint
QCOMPARE(ecs->currentStage(), 0);
// Puis surplus suffisant → l'ECS resuit le surplus normalement.
setMeterW(-2500); cycle(t0.addSecs(302));
QCOMPARE(ecs->currentStage(), 1);
QCOMPARE(relayA->stateValue("power").toBool(), true);
#endif
}
void Simulation::run_data()
{
// Simulation infos

View File

@ -60,6 +60,10 @@ private slots:
// et protection compresseur (import < minOn → RESTE ; import > minOn → déleste).
void testEcsSurplusPV();
// Watchdog L2 : compteur muet >90 s → mode dégradé (ECS off force=true, bypass minOn),
// planification suspendue (ECS reste 0 sur N cycles), reprise au retour du compteur.
void testMeterSilentFallback();
void printStates(Thing *thing);
void updateChargerMeter(Thing *thing);