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:
parent
e6ee513dcc
commit
e6bc32dc85
@ -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();
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user