From e6bc32dc85d5ed1128c094ffc3a5786d842acfe5 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Fri, 16 Oct 2026 20:16:13 +0200 Subject: [PATCH] =?UTF-8?q?fix(moteur):=20ECO=5FFLOOR=5FMISSED=20dit,=20jo?= =?UTF-8?q?urnalis=C3=A9=20et=20notifi=C3=A9=20;=20livr=C3=A9=20de=20cl?= =?UTF-8?q?=C3=B4ture=20complet=20(correctif=20b,=20etm70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- energyplugin/etm/energyarbitrator.cpp | 40 ++++++- .../etm/scheduler/rulebasedscheduler.cpp | 7 +- tests/auto/simulation/simulation.cpp | 101 ++++++++++++++++++ 3 files changed, 143 insertions(+), 5 deletions(-) diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 3a8c1f7..5538220 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -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(); diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index ffc3bb6..c24378a 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -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; } diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 6910498..10c9349 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -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(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 }