[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
df37454537
commit
4e02b008b9
@ -30,22 +30,24 @@ EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm,
|
|||||||
, m_scheduler(new RuleBasedScheduler(this, this))
|
, m_scheduler(new RuleBasedScheduler(this, this))
|
||||||
{
|
{
|
||||||
// --- L2 : watchdog fraîcheur compteur (SAFETY.md §L2) ---
|
// --- L2 : watchdog fraîcheur compteur (SAFETY.md §L2) ---
|
||||||
// Fraîcheur picotée sur powerBalanceChanged (en plus de la connexion amont L4) ;
|
// La LOGIQUE (recordMeterUpdate / evaluateMeterFreshness) prend le temps en paramètre
|
||||||
// grâce au démarrage : on initialise le timestamp pour éviter un dégradé immédiat.
|
// et reste testable par injection (symétrique de simulationCallUpdate). Seuls les
|
||||||
m_lastMeterUpdate = QDateTime::currentDateTime();
|
// 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]() {
|
connect(em, &EnergyManager::powerBalanceChanged, this, [this]() {
|
||||||
m_lastMeterUpdate = QDateTime::currentDateTime();
|
recordMeterUpdate(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)
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
// QTimer (et non signal) : doit rester actif quand le compteur est muet.
|
// QTimer (et non signal) : doit rester actif quand le compteur est muet.
|
||||||
m_meterWatchdog = new QTimer(this);
|
m_meterWatchdog = new QTimer(this);
|
||||||
m_meterWatchdog->setInterval(MeterWatchdogPeriodMs);
|
m_meterWatchdog->setInterval(MeterWatchdogPeriodMs);
|
||||||
connect(m_meterWatchdog, &QTimer::timeout, this, &EnergyArbitrator::onMeterWatchdogTick);
|
connect(m_meterWatchdog, &QTimer::timeout, this, &EnergyArbitrator::onMeterWatchdogTick);
|
||||||
m_meterWatchdog->start();
|
m_meterWatchdog->start();
|
||||||
|
#else
|
||||||
|
Q_UNUSED(em)
|
||||||
|
#endif
|
||||||
|
|
||||||
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] Arbitre ETM initialisé.";
|
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] Arbitre ETM initialisé.";
|
||||||
}
|
}
|
||||||
@ -204,11 +206,26 @@ void EnergyArbitrator::applyActionsToAdapters(const Slot &slot, const QDateTime
|
|||||||
}
|
}
|
||||||
|
|
||||||
void EnergyArbitrator::onMeterWatchdogTick()
|
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())
|
if (!m_lastMeterUpdate.isValid())
|
||||||
return; // Aucune mesure reçue (démarrage) — pas de dégradé (invariant root meter absent).
|
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);
|
const qint64 silentS = m_lastMeterUpdate.secsTo(now);
|
||||||
if (silentS <= MeterSilenceThresholdS)
|
if (silentS <= MeterSilenceThresholdS)
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -92,6 +92,29 @@ public:
|
|||||||
*/
|
*/
|
||||||
bool degradedMode() const override { return m_degradedMode; }
|
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:
|
protected:
|
||||||
/*!
|
/*!
|
||||||
* \brief Boucle principale ETM — surcharge SmartChargingManager::update().
|
* \brief Boucle principale ETM — surcharge SmartChargingManager::update().
|
||||||
|
|||||||
@ -141,6 +141,87 @@ void Simulation::testEcsSurplusPV()
|
|||||||
#endif
|
#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()
|
void Simulation::run_data()
|
||||||
{
|
{
|
||||||
// Simulation infos
|
// Simulation infos
|
||||||
|
|||||||
@ -60,6 +60,10 @@ private slots:
|
|||||||
// et protection compresseur (import < minOn → RESTE ; import > minOn → déleste).
|
// et protection compresseur (import < minOn → RESTE ; import > minOn → déleste).
|
||||||
void testEcsSurplusPV();
|
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 printStates(Thing *thing);
|
||||||
void updateChargerMeter(Thing *thing);
|
void updateChargerMeter(Thing *thing);
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user