fix(moteur): ECO_FLOOR_MISSED dit, journalisé et notifié ; livré de clôture complet (correctif b, etm70)

Cinq causes cumulées faisaient qu'une clôture non tenue n'était lue nulle
part hors de levels[0] :
- le niveau de clôture (0 W) ne prenait pas la tête du motif : il la prend
  désormais au seul cycle de la bascule (sous LOAD_UNAVAILABLE, qui garde
  la préséance) ;
- la ligne de bascule disait « base reprise » sans tenue ni manque, et un
  commentaire affirmait à tort « Le journal, lui, le dit » : elle dit
  maintenant « CLOSE NON TENUE — livré X/Y Wh, manque Z Wh, cause C »
  (avertissement), ou « close TENUE — livré X/Y Wh » ;
- le filtre de notification ne voyait que le code de tête : un niveau
  ECO_FLOOR_MISSED force désormais LoadTelemetryChanged, comme
  derogationEnded.

Défaut corrigé au passage : le livré de clôture oubliait les tronçons clos
(m_ecoLivreWh). Avec une mesure intermittente (800 Wh, trou, +500 Wh sur
2000), missingWh publié valait 1500 au lieu de 700. C'est une valeur
PUBLIÉE (motif, journal), pas une consigne.

Test testEcoFloorMissedSaysWhatWasLostAndWhy étendu : tête du motif au
seul cycle de clôture, ligne de journal chiffrée, livré intermittent →
missingWh 700, notification d'une charge indisponible sans autre changement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-16 20:16:13 +02:00
parent e6ee513dcc
commit e6bc32dc85
3 changed files with 143 additions and 5 deletions

View File

@ -564,8 +564,13 @@ EcoProgress EnergyArbitrator::ecoProgress(const LoadContext &lc, const QDateTime
// que le moteur produit, et un fait produit doit se dire. Ce motif n'existait pas ce
// matin faute d'émetteur ; la bascule en est un.
const bool mesurait = lc.telemetry.sessionMeasurable && m_ecoBaseWh.contains(lc.id);
// Le livré de la période = les tronçons CLOS + celui en cours, exactement comme
// `deliveredWh` (plus bas). Sans le premier terme, une mesure intermittente faisait
// clore sur le seul dernier tronçon : fausse non-tenue et manque surévalué.
const double livre = mesurait
? qMax(0.0, lc.telemetry.sessionWh - m_ecoBaseWh.value(lc.id)) : 0.0;
? m_ecoLivreWh.value(lc.id, 0.0)
+ qMax(0.0, lc.telemetry.sessionWh - m_ecoBaseWh.value(lc.id))
: 0.0;
if (!mesurait || livre < p.targetWh) {
p.closedMissed = true;
p.closedKnown = mesurait;
@ -580,9 +585,28 @@ EcoProgress EnergyArbitrator::ecoProgress(const LoadContext &lc, const QDateTime
: m_ecoCause.value(lc.id)) // et servie mais pas assez
: QString::fromLatin1(MissedCause::Unmeasurable);
}
// L'obligation précédente s'achève. Si elle n'était pas tenue, elle est abandonnée
// ici — SANS motif dédié à ce stade, et c'est une limite assumée : le dire demande
// un `ECO_FLOOR_MISSED` que rien n'émet encore. Le journal, lui, le dit.
// L'obligation précédente s'achève, et le journal dit COMMENT : livré, cible, et
// pour une non-tenue le manque et la cause. Ce cycle-ci porte aussi ECO_FLOOR_MISSED
// en tête du motif publié, et sa trame est notifiée d'office
// (telemetrySignificantlyDiffers) — c'est le seul cycle où l'information existe.
if (p.closedMissed) {
QVariantMap motifCloture{{"targetWh", qRound(p.targetWh)}, {"cause", p.closedCause}};
if (p.closedKnown)
motifCloture.insert(QStringLiteral("missingWh"), qRound(p.closedMissingWh));
qCWarning(dcNymeaEnergy()).noquote()
<< "[Arbitre] Obligation éco" << lc.id << "CLOSE NON TENUE —"
<< (p.closedKnown
? QStringLiteral("livré %1/%2 Wh, manque %3 Wh,")
.arg(qRound(livre)).arg(qRound(p.targetWh))
.arg(qRound(p.closedMissingWh))
: QStringLiteral("livré non chiffrable sur %1 Wh,").arg(qRound(p.targetWh)))
<< "cause" << p.closedCause
<< "|" << renderFr({ DecisionCode::EcoFloorMissed, motifCloture }, lc.label);
} else {
qCInfo(dcNymeaEnergy()).noquote()
<< "[Arbitre] Obligation éco" << lc.id << "close TENUE —"
<< QStringLiteral("livré %1/%2 Wh").arg(qRound(livre)).arg(qRound(p.targetWh));
}
qCInfo(dcNymeaEnergy()) << "[Arbitre] Obligation éco" << lc.id
<< "— nouvelle période (échéance" << echeance.toString()
<< "), base d'avancement reprise.";
@ -2920,6 +2944,14 @@ bool EnergyArbitrator::telemetrySignificantlyDiffers(const QVariantMap &a, const
// pris fin — ni pourquoi.
if (b.contains(QStringLiteral("derogationEnded")))
return true;
// Même règle pour la CLÔTURE NON TENUE (correctif b) : un niveau ECO_FLOOR_MISSED ne vit
// qu'un cycle. Le code de TÊTE ne suffit pas à le voir passer — sous LOAD_UNAVAILABLE il reste
// en tête, et rien d'autre ne bouge forcément.
for (const QVariant &v : b.value(QStringLiteral("loads")).toList())
for (const QVariant &n : v.toMap().value(QStringLiteral("levels")).toList())
if (n.toMap().value(QStringLiteral("decision")).toMap().value(QStringLiteral("code"))
== QLatin1String(DecisionCode::EcoFloorMissed))
return true;
const double allocA = a.value(QStringLiteral("budget")).toMap()
.value(QStringLiteral("allocatedW")).toDouble();

View File

@ -25,13 +25,18 @@ namespace {
* LOAD_UNAVAILABLE, et il garde la tête : une charge qu'on ne sait plus commander n'achète rien.
*
* Entre éco et confort, la PART MAJORITAIRE de la consigne, l'égalité allant à l'éco ; une part
* éco nulle ne prend jamais la tête (ECO_FLOOR_MET à 0 W laisse parler le confort).
* éco nulle ne prend jamais la tête (ECO_FLOOR_MET à 0 W laisse parler le confort). Seule
* exception (correctif b) : la CLÔTURE NON TENUE, qui est un événement et pas une part — elle
* vaut 0 W, et elle prend la tête au seul cycle où elle existe. Elle reste SOUS l'indisponibilité,
* comme toute la passe éco ; la notification et le journal la disent alors quand même.
*/
bool leMotifEcoPrendLaTete(const DecisionReason &motifConfort, const LoadAction::Level &eco,
double confortW)
{
if (motifConfort.code == QLatin1String(DecisionCode::LoadUnavailable))
return false;
if (eco.reason.code == QLatin1String(DecisionCode::EcoFloorMissed))
return true;
return eco.targetW > 0 && eco.targetW >= confortW;
}

View File

@ -7930,6 +7930,107 @@ void Simulation::testEcoFloorMissedSaysWhatWasLostAndWhy()
const LoadAction closeOk = cycle("ecs-ok", apres, true, 2500, 5000);
QVERIFY2(closeOk.levels.at(0).reason.code != QString(DecisionCode::EcoFloorMissed),
"une obligation tenue ne produit pas de motif de non-tenue");
// ===== Correctif b — ce que le cas 1 publiait sans le DIRE ===============================
// Le niveau portait le motif, mais personne ne le lisait là : ni la tête de l'action (donc
// ni `decision` en RPC ni la ligne [Arbitre]), ni le journal à la bascule.
//
// 1-b. EN TÊTE du motif au cycle de clôture — et à ce cycle-là seulement.
QCOMPARE(close.reason.code, QString(DecisionCode::EcoFloorMissed));
QCOMPARE(publishedLevel(close), QString("eco"));
QCOMPARE(close.reason.params.value("missingWh").toInt(), 1700);
const LoadAction lendemain = cycle("ecs-cap", apres.addSecs(60), true, 300, 200);
QVERIFY2(lendemain.reason.code != QString(DecisionCode::EcoFloorMissed),
"la non-tenue est un événement de CYCLE : elle ne se répète pas au suivant");
// 1-c. Le JOURNAL le dit à la bascule : livré, cible, manque et cause.
cycle("ecs-jrn", avant, true, 0, 200);
cycle("ecs-jrn", avant, true, 300, 200);
{
JournalCapture journal;
cycle("ecs-jrn", apres, true, 300, 200);
QVERIFY2(journal.contains("CLOSE NON TENUE"), qUtf8Printable(journal.texte()));
QVERIFY2(journal.contains("livré 300/2000 Wh, manque 1700 Wh"), qUtf8Printable(journal.texte()));
QVERIFY2(journal.contains(QString(MissedCause::DrawCap)), qUtf8Printable(journal.texte()));
}
// --- Cas 4 : MESURE INTERMITTENTE — le livré de clôture compte les tronçons CLOS --------
// 800 Wh mesurés, un trou, puis 500 Wh de plus sur un compteur qui a avancé pendant le
// trou (base reprise au retour). La période a reçu 1300 Wh mesurés : il manque 700 Wh, pas
// 1500 — la clôture ne comptait que le dernier tronçon.
cycle("ecs-trou", avant, true, 0, 5000); // base prise ici
cycle("ecs-trou", avant, true, 800, 5000); // 800 Wh
cycle("ecs-trou", avant, false, 800, 5000); // le lien tombe : tronçon clos
cycle("ecs-trou", avant, true, 5000, 5000); // retour, base reprise à 5000
cycle("ecs-trou", avant, true, 5500, 5000); // 500 Wh de plus
const LoadAction closeTrou = cycle("ecs-trou", apres, true, 5500, 5000);
QCOMPARE(closeTrou.reason.code, QString(DecisionCode::EcoFloorMissed));
QCOMPARE(closeTrou.reason.params.value("missingWh").toInt(), 700);
// --- Cas 5 : la NOTIFICATION, même quand rien d'autre ne bouge ---------------------------
// Une charge INDISPONIBLE garde LOAD_UNAVAILABLE en tête (préséance) et sa puissance gelée :
// à la clôture, seul un niveau change. Le filtre de notification ne comparait que le code de
// tête — la trame du seul cycle qui porte la non-tenue n'était jamais notifiée.
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-missed.json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
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);
meter->setStateValue("currentPower", 2000);
QUuid relaisId = addPowerSwitch(1000, 27072);
QVERIFY(tm->findConfiguredThing(relaisId));
QVariantList l;
l.append(QVariantMap{{"id", "ecs-indispo"}, {"label", "ECS"}, {"adapter", "relay-router"},
{"mode", "fixed"}, {"priority", 1}, {"enabled", true},
{"relays", QVariantList() << QVariantMap{{"thingId", relaisId.toString()},
{"powerW", 1000}}},
{"minOnS", 0}, {"minOffS", 0},
{"needs", QVariantMap{{"dailyDeadline", "07:00"},
{"minEnergyWhPerDay", 2000}}}});
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
QVERIFY(removeDevice(relaisId).toMap().value("params").toMap().value("thingError").toString()
== "ThingErrorNoError");
const QDateTime t1 = utcDateTime(QDate(2026, 8, 31), QTime(6, 58, 0));
arb->simulationCallUpdate(t1);
arb->simulationCallUpdate(t1.addSecs(60)); // trame publiée, état stable
QCoreApplication::processEvents();
QCOMPARE(telemetrieParId(arb->loadTelemetry()).value("ecs-indispo").value("decision").toMap()
.value("code").toString(), QString(DecisionCode::LoadUnavailable));
QSignalSpy notifs(arb, &EnergyArbitrator::loadTelemetryChanged);
{
JournalCapture journal;
arb->simulationCallUpdate(t1.addSecs(180)); // 07:01 — la période se clôt
QCoreApplication::processEvents();
QVERIFY2(journal.contains("CLOSE NON TENUE"), qUtf8Printable(journal.texte()));
QVERIFY2(journal.contains("non chiffrable"), qUtf8Printable(journal.texte()));
}
const QVariantMap ch = telemetrieParId(arb->loadTelemetry()).value("ecs-indispo");
QCOMPARE(ch.value("decision").toMap().value("code").toString(),
QString(DecisionCode::LoadUnavailable)); // préséance : indisponible d'abord
bool niveauManque = false;
for (const QVariant &n : ch.value("levels").toList())
if (n.toMap().value("decision").toMap().value("code").toString()
== QLatin1String(DecisionCode::EcoFloorMissed))
niveauManque = true;
QVERIFY2(niveauManque, "le niveau éco de clôture doit être publié");
QVERIFY2(notifs.count() >= 1,
"la trame du cycle de clôture non tenue doit être notifiée, même sans autre changement");
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
QFile::remove(cfgPath);
#endif
}