[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:
parent
dde967da41
commit
54ba2296fa
@ -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;
|
||||
|
||||
@ -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().
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user