diff --git a/debian-qt5/changelog b/debian-qt5/changelog index baef675..65560a5 100644 --- a/debian-qt5/changelog +++ b/debian-qt5/changelog @@ -1,3 +1,32 @@ +powersync-energy-plugin-nymea (1.15.2+etm43) trixie; urgency=medium + + * ECO_FLOOR_MISSED — le motif a désormais son ÉMETTEUR, et c'est pourquoi il est créé + maintenant. Ce matin il n'en avait pas : le créer aurait posé un invariant sur un code que + rien ne produit, un test toujours vert. La bascule de période (LM-1013-c) l'a fait naître — + une obligation abandonnée est un fait que le moteur produit, et un fait produit se dit. + * TROIS CAUSES, TROIS GESTES : `drawCap` envoie regarder l'abonnement, `loadCapacity` la fiche + technique de la charge, `unmeasurable` ce que la borne publie. `unavailable` s'y ajoute + (ECS-410). Sans la cause, l'écran n'affiche qu'un reproche sans recours. + * `missingWh` ABSENT sous `unmeasurable`, jamais 0 : on ne sait pas ce qui a été livré, donc + pas davantage ce qui manquait. Publier un chiffre inventerait la grandeur même dont + l'absence EST le problème. Mesuré : « non tenue sur 2000 Wh — l'avancement n'était pas + mesurable, et le manque n'est pas chiffrable ». + * R5 VÉRIFIÉE contre la maquette plutôt que supposée conforme : `progress { regime, targetWh, + deliveredWh absent si unmeasurable }` correspond exactement. + * UN ÉCART ASSUMÉ, signalé plutôt qu'aligné en silence : la maquette donnait + ECO_FLOOR_GRID { deliveredWh, targetWh, gridW } ; le moteur publie `remainingWh` au lieu de + `deliveredWh`. Raison : `deliveredWh` vit déjà dans `progress`, et le republier ferait deux + sources pour un même fait (LM-1012). Surtout, `remainingWh` n'est PAS dérivable sous + `unmeasurable` — où `deliveredWh` est absent — et c'est justement le cas où l'on achète. + * R8 — LE POINT DE RUPTURE SE LIT SUR `DRAW_CAP`, JAMAIS SUR UN SOLDE À ZÉRO. `remainingW` à 0 + arrive aussi quand tout a été alloué normalement : un écran qui le guetterait signalerait une + rupture inexistante et manquerait la vraie. DRAW_CAP n'est émis que lorsqu'un plafond a + réellement refusé, et il porte lequel. Écrit dans INTERFACE.md. + * Suite : simulation 58/58, loadmodel 22/22, charging 48/48 identique à la référence, + spotmarket 32/32, doxygen 0 avertissement. + + -- Patrick Schurig Mon, 31 Aug 2026 11:00:00 +0200 + powersync-energy-plugin-nymea (1.15.2+etm42) trixie; urgency=medium * §10 étape D (2/2) — LA PASSE ÉCO EXISTE. Deux passes sur la MÊME liste, MÊME ordre ; ce qui diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index c6a8193..c23632b 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -452,6 +452,14 @@ namespace { constexpr double BruitAvancementWh = 50.0; } +void EnergyArbitrator::noterCauseEco(const QString &loadId, const QString &cause) const +{ + if (cause.isEmpty()) + m_ecoCause.remove(loadId); + else + m_ecoCause.insert(loadId, cause); +} + EcoProgress EnergyArbitrator::ecoProgress(const LoadContext &lc, const QDateTime &now) const { EcoProgress p; @@ -474,6 +482,26 @@ EcoProgress EnergyArbitrator::ecoProgress(const LoadContext &lc, const QDateTime const QDateTime echeance = resolveDeadline(lc.needs, now); if (echeance.isValid() && m_ecoBaseEcheance.value(lc.id) != echeance) { if (m_ecoBaseEcheance.contains(lc.id)) { + // CLÔTURE — la période précédente s'achève. Si elle n'était pas tenue, c'est un FAIT + // 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); + const double livre = mesurait + ? qMax(0.0, lc.telemetry.sessionWh - m_ecoBaseWh.value(lc.id)) : 0.0; + if (!mesurait || livre < p.targetWh) { + p.closedMissed = true; + p.closedKnown = mesurait; + // Le manque n'est chiffré que s'il est CONNU. Sous un régime non mesuré, on ne + // sait pas ce qui a été livré, donc pas davantage ce qui manquait : publier un + // chiffre inventerait la grandeur même dont l'absence est le problème. + if (mesurait) + p.closedMissingWh = p.targetWh - livre; + p.closedCause = mesurait + ? (m_ecoCause.value(lc.id).isEmpty() + ? QString::fromLatin1(MissedCause::LoadCapacity) // servie sans blocage + : 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. @@ -483,6 +511,7 @@ EcoProgress EnergyArbitrator::ecoProgress(const LoadContext &lc, const QDateTime } m_ecoBaseEcheance.insert(lc.id, echeance); m_ecoBaseWh.remove(lc.id); + m_ecoCause.remove(lc.id); } // C1 de LM-1009 — la borne ne compte pas : l'obligation n'est PAS mesurable, et ça se dit. diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index 49e0848..4bdf841 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -379,6 +379,17 @@ public: */ EcoProgress ecoProgress(const LoadContext &lc, const QDateTime &now) const; + /*! + * \brief Note pourquoi l'obligation d'une charge n'a pas pu être servie ce cycle. + * \param loadId Charge. \param cause Code de \c MissedCause, vide si elle a été servie. + * + * \note Appelé par la passe éco à chaque cycle. À la CLÔTURE d'une période, c'est la + * dernière cause notée qui est publiée : celle qui valait au dernier cycle où l'on aurait + * encore pu servir. Retenir la plus fréquente serait plus juste en moyenne et moins utile + * en pratique — ce qu'on veut savoir est ce qui bloquait à la fin. + */ + void noterCauseEco(const QString &loadId, const QString &cause) const; + /*! * \brief [L4] Déleste les charges du waterfall tant que le plafond de tirage est dépassé. * @@ -733,6 +744,9 @@ private: //! quotidienne franchie), la période d'obligation change : la base se reprend, sinon le //! lendemain hérite de l'énergie de la veille et se croit déjà tenu. mutable QHash m_ecoBaseEcheance; + //! loadId → dernière cause de non-service notée par la passe éco (\c MissedCause). Vide + //! quand la charge a été servie. Publiée à la clôture d'une période non tenue. + mutable QHash m_ecoCause; ThingManager *m_tm = nullptr; //!< ThingManager (pour construire les adaptateurs config). LoadConfigStore *m_loadConfigStore = nullptr; //!< Store config charge pilotée (non adopté). diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index da0d406..0a9e936 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -258,6 +258,30 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) eco.hasProgress = true; eco.progress = avancement; + // CLÔTURE NON TENUE — l'événement du cycle, et il prime sur ce que la période neuve + // demande. Ce cycle-ci dit ce qui vient de se perdre ; le suivant repart sur la période + // ouverte. Le taire laisserait l'écran muet sur le seul instant où l'information existe. + if (avancement.closedMissed) { + QVariantMap params{{"targetWh", qRound(avancement.targetWh)}, + {"cause", avancement.closedCause}}; + // `missingWh` ABSENT quand le manque n'est pas chiffrable, jamais 0 : un zéro se + // lirait « il ne manquait rien », là où la vérité est « on n'en sait rien ». + if (avancement.closedKnown) + params.insert(QStringLiteral("missingWh"), qRound(avancement.closedMissingWh)); + eco.targetW = 0; + eco.counts[CountKey::BudgetAllocated] = 0; + eco.reason = { DecisionCode::EcoFloorMissed, params }; + niveauxEco.insert(lc.id, eco); + continue; + } + + // La charge est-elle seulement commandable ? Une indisponibilité empêche de servir + // l'obligation, et c'est une cause à part — on ne sait plus la commander (ECS-410). + if (!lc.telemetry.available) { + m_arbitrator->noterCauseEco(lc.id, QString::fromLatin1(MissedCause::Unavailable)); + continue; + } + if (cible.tenue) { // R6 — `ECO_FLOOR_MET` ne peut PAS sortir sous un régime non mesuré, et // `computeEcoTarget()` ne conclut jamais « tenue » dans ce cas : la garde est ici @@ -276,8 +300,21 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) // un droit de faire disjoncter : si l'autorisation est épuisée, on n'achète pas, et // c'est le délestage qui rattrapera ce qui a déjà été engagé. double achatW = cible.watts; + const bool bridePlafond = margeRestante.borne && achatW > qMax(0.0, margeRestante.watts); if (margeRestante.borne) achatW = qBound(0.0, achatW, qMax(0.0, margeRestante.watts)); + + // La CAUSE se note à chaque cycle, servie ou non. À la clôture, c'est la dernière qui + // est publiée : celle qui bloquait au dernier moment où l'on aurait encore pu servir. + if (bridePlafond) + m_arbitrator->noterCauseEco(lc.id, QString::fromLatin1(MissedCause::DrawCap)); + else if (lc.declared.maxPowerW > 0 && cible.watts >= lc.declared.maxPowerW) + // La cadence demandée atteint le plafond PHYSIQUE de la charge : ce n'est plus nous + // qui bridons, c'est elle qui ne peut pas suivre. Deux gestes différents. + m_arbitrator->noterCauseEco(lc.id, QString::fromLatin1(MissedCause::LoadCapacity)); + else + m_arbitrator->noterCauseEco(lc.id, QString()); // servie sans blocage + if (achatW <= 0) continue; // rien d'achetable ce cycle : pas de niveau éco plutôt qu'un zéro forgé diff --git a/energyplugin/etm/types/decisionreason.cpp b/energyplugin/etm/types/decisionreason.cpp index e9235c0..17f8914 100644 --- a/energyplugin/etm/types/decisionreason.cpp +++ b/energyplugin/etm/types/decisionreason.cpp @@ -2,6 +2,7 @@ // Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync #include "decisionreason.h" +#include "ecoprogress.h" #include /* @@ -134,6 +135,23 @@ QString renderFr(const DecisionReason &reason, const QString &label) "livrer en %4 (cadence recalculée à chaque cycle)") .arg(label).arg(w("gridW")).arg(w("remainingWh")).arg(delai); } + if (c == QLatin1String(DecisionCode::EcoFloorMissed)) { + const QString cause = p.value(QStringLiteral("cause")).toString(); + const QString pourquoi = + cause == QLatin1String(MissedCause::DrawCap) ? QStringLiteral("l'autorisation de soutirage était épuisée") + : cause == QLatin1String(MissedCause::LoadCapacity) ? QStringLiteral("la charge ne pouvait pas absorber la cadence") + : cause == QLatin1String(MissedCause::Unavailable) ? QStringLiteral("la charge était indisponible") + : cause == QLatin1String(MissedCause::Unmeasurable) ? QStringLiteral("l'avancement n'était pas mesurable") + : QStringLiteral("cause inconnue"); + // Le manque n'est chiffré que s'il est connu. Sous un régime non mesuré, l'annoncer à + // zéro ferait lire « il ne manquait rien » là où la vérité est « on n'en sait rien ». + if (!p.contains(QStringLiteral("missingWh"))) + return QStringLiteral("Obligation de %1 non tenue sur %2 Wh — %3, et le manque n'est " + "pas chiffrable") + .arg(label).arg(w("targetWh")).arg(pourquoi); + return QStringLiteral("Obligation de %1 non tenue : il manquait %2 Wh sur %3 — %4") + .arg(label).arg(w("missingWh")).arg(w("targetWh")).arg(pourquoi); + } if (c == QLatin1String(DecisionCode::EcoFloorMet)) return QStringLiteral("Obligation de %1 tenue : %2 Wh livrés sur %3 — plus rien à acheter") .arg(label).arg(w("deliveredWh")).arg(w("targetWh")); diff --git a/energyplugin/etm/types/decisionreason.h b/energyplugin/etm/types/decisionreason.h index 92df5c6..ac1ad3f 100644 --- a/energyplugin/etm/types/decisionreason.h +++ b/energyplugin/etm/types/decisionreason.h @@ -212,6 +212,29 @@ constexpr const char *EcoFloorGrid = "ECO_FLOOR_GRID"; */ constexpr const char *EcoFloorMet = "ECO_FLOOR_MET"; +/*! + * \brief Une période d'obligation s'est close SANS avoir été tenue. + * + * params : \c targetWh (l'obligation), \c cause (code opaque de \c MissedCause), + * \c o:missingWh (ce qui manquait — **absent** quand l'avancement n'était pas mesurable). + * + * \par Pourquoi ce motif n'existait pas ce matin + * Il n'avait **pas d'émetteur**. Le créer alors aurait posé un invariant sur un code que rien ne + * produit : un test toujours vert, donc un test qui ne protège rien. La bascule de période + * (\rule{LM-1013-c}) l'a fait naître — une obligation abandonnée est désormais un fait que le + * moteur produit, et un fait produit doit se dire. + * + * \par Trois causes, trois gestes + * `drawCap` envoie regarder l'abonnement ou les autres consommateurs ; `loadCapacity` envoie + * lire la fiche technique de la charge ; `unmeasurable` envoie regarder ce que la borne publie. + * Sans la cause, l'écran ne peut afficher qu'un reproche sans recours. + * + * \note \c missingWh est **absent** sous \c unmeasurable, jamais 0. On ne sait pas ce qui a été + * livré, donc pas davantage ce qui manquait : publier un chiffre inventerait la grandeur même + * dont l'absence est le problème. Même règle que \c deliveredWh (\rule{LM-1009} C1). + */ +constexpr const char *EcoFloorMissed = "ECO_FLOOR_MISSED"; + // --- SG-Ready (PAC) --------------------------------------------------------- //! params: \c state — le verrou minStateHold maintient l'état (court-cycling compresseur). constexpr const char *LockMinStateHold = "LOCK_MIN_STATE_HOLD"; diff --git a/energyplugin/etm/types/ecoprogress.h b/energyplugin/etm/types/ecoprogress.h index 33cdcdf..85f50a7 100644 --- a/energyplugin/etm/types/ecoprogress.h +++ b/energyplugin/etm/types/ecoprogress.h @@ -42,4 +42,35 @@ struct EcoProgress { //! Énergie à livrer (Wh), déclarée par \c needs. 0 = aucune obligation en cours. double targetWh = 0; + + // --- Clôture de période (LM-1014-b) : vrai UN SEUL CYCLE, celui de la bascule ----------- + + //! \brief Une période d'obligation vient de se clore SANS avoir été tenue. + //! Événement d'un cycle, pas un état : le suivant repart sur la période neuve. + bool closedMissed = false; + + //! \brief Ce qui manquait à la clôture (Wh). Sans objet si \c !closedKnown. + double closedMissingWh = 0; + + //! \brief Vrai si le manque est CHIFFRABLE. Faux sous un régime non mesuré : on ne sait pas + //! ce qui a été livré, donc pas davantage ce qui manquait. Publier un chiffre là serait + //! inventer la grandeur même dont l'absence est le problème. + bool closedKnown = false; + + //! \brief Pourquoi elle n'a pas été tenue — code opaque de \c MissedCause. + QString closedCause; }; + +//! \brief Causes de non-tenue publiées par \c ECO_FLOOR_MISSED. Opaques, comparées jamais +//! traduites. Chacune appelle un geste différent, et c'est leur raison d'être. +namespace MissedCause { +//! L'autorisation de soutirage était épuisée : on n'avait pas le DROIT d'acheter davantage. +constexpr const char *DrawCap = "drawCap"; +//! La charge ne pouvait pas absorber la cadence demandée — son plafond physique, pas le nôtre. +constexpr const char *LoadCapacity = "loadCapacity"; +//! L'avancement n'a jamais été mesurable : on ne peut affirmer ni qu'elle est tenue, ni de +//! combien elle manque. C'est le seul cas où \c closedKnown est faux. +constexpr const char *Unmeasurable = "unmeasurable"; +//! La charge était indisponible : on ne savait plus la commander (ECS-410). +constexpr const char *Unavailable = "unavailable"; +} diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 73b67b1..e5bf91b 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -6915,6 +6915,101 @@ void Simulation::testEcoPassBuysAndSaysTheTrajectory() #endif } +/*! + * \brief [§10] Une obligation abandonnée est un FAIT produit — et un fait produit se dit. + * + * \par Ce motif n'existait pas le matin même + * Il n'avait pas d'ÉMETTEUR : le créer alors aurait posé un invariant sur un code que rien ne + * produit — un test toujours vert, donc un test qui ne protège rien. La bascule de période + * (\rule{LM-1013-c}) l'a fait naître. Le journal disait déjà l'abandon ; l'écran, non. + * + * \par Trois causes, trois gestes + * `drawCap` envoie regarder l'abonnement ou les autres consommateurs ; `loadCapacity` envoie + * lire la fiche technique de la charge ; `unmeasurable` envoie regarder ce que la borne publie. + * Sans la cause, l'écran n'affiche qu'un reproche sans recours. + * + * \warning **Ce test devient faux** si l'obligation cesse d'être bornée par l'autorisation de + * soutirage — alors `drawCap` ne serait plus une cause possible, et c'est le cas 1 qu'il + * faudrait réécrire, pas le moteur. + */ +void Simulation::testEcoFloorMissedSaysWhatWasLostAndWhy() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + + EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); + QVERIFY(arb); + RuleBasedScheduler sched(arb); + + auto cycle = [&](const QString &id, const QDateTime &at, bool mesurable, + double livreWh, double margeW) -> LoadAction { + SurplusContext ctx; + ctx.timestamp = at; + ctx.meter.importW = 500; + ctx.drawMargin.borne = true; + ctx.drawMargin.watts = margeW; + ctx.drawMargin.source = DrawCapSource::Breaker; + ctx.drawMargin.phase = 0; + + LoadContext ecs; + ecs.id = id; + ecs.label = "ECS"; + ecs.adapter = "etmvariableload"; + ecs.priority = 1; + ecs.declared.maxPowerW = 3000; + ecs.needs.minEnergyWhPerDay = 2000; + ecs.needs.dailyDeadline = "07:00"; + ecs.telemetry.sessionMeasurable = mesurable; + ecs.telemetry.sessionWh = livreWh; + ctx.loads.append(ecs); + return sched.getPlan(ctx).timeSlots.first().actions.first(); + }; + + const QDateTime avant = utcDateTime(QDate(2026, 8, 31), QTime(5, 0, 0)); + const QDateTime apres = utcDateTime(QDate(2026, 8, 31), QTime(7, 30, 0)); + + // --- Cas 1 : MESURÉ, bridé par l'autorisation de soutirage ------------------------------ + // Marge à 200 W alors que la cadence en demande 1000 : on note `drawCap`, et à la bascule + // c'est cette cause-là qui est publiée. + cycle("ecs-cap", avant, true, 0, 200); + cycle("ecs-cap", avant, true, 300, 200); // 300 Wh livrés sur 2000 + const LoadAction close = cycle("ecs-cap", apres, true, 300, 200); + QCOMPARE(close.levels.at(0).reason.code, QString(DecisionCode::EcoFloorMissed)); + const QVariantMap pc = close.levels.at(0).reason.params; + QCOMPARE(pc.value("cause").toString(), QString(MissedCause::DrawCap)); + QCOMPARE(pc.value("missingWh").toInt(), 1700); // 2000 dus, 300 livrés + QCOMPARE(pc.value("targetWh").toInt(), 2000); + // Le TEXTE porte le manque ET le geste. « Non tenue » seul serait un reproche sans recours. + const QString tc = renderFr(close.levels.at(0).reason, "ECS"); + QVERIFY2(tc.contains("1700"), qUtf8Printable("motif: " + tc)); + QVERIFY2(tc.contains("soutirage"), qUtf8Printable("motif: " + tc)); + + // --- Cas 2 : NON MESURÉ ----------------------------------------------------------------- + cycle("ecs-inc", avant, false, 0, 5000); + const LoadAction closeInc = cycle("ecs-inc", apres, false, 0, 5000); + QCOMPARE(closeInc.levels.at(0).reason.code, QString(DecisionCode::EcoFloorMissed)); + const QVariantMap pi = closeInc.levels.at(0).reason.params; + QCOMPARE(pi.value("cause").toString(), QString(MissedCause::Unmeasurable)); + // `missingWh` ABSENT, jamais 0. On ne sait pas ce qui a été livré, donc pas davantage ce + // qui manquait : un zéro se lirait « il ne manquait rien », là où la vérité est « on n'en + // sait rien ». Publier un chiffre inventerait la grandeur dont l'absence EST le problème. + QVERIFY2(!pi.contains("missingWh"), + "sous unmeasurable, le manque n'est pas chiffrable — pas de zéro forgé"); + const QString ti = renderFr(closeInc.levels.at(0).reason, "ECS"); + QVERIFY2(ti.contains("pas chiffrable"), qUtf8Printable("motif: " + ti)); + + // --- Cas 3 : obligation TENUE — aucune clôture manquée ---------------------------------- + cycle("ecs-ok", avant, true, 0, 5000); + 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"); +#endif +} + void Simulation::testCountsSumToTarget() { #ifndef ETM_ARBITRATOR diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 2b40455..4e7b2a7 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -196,6 +196,7 @@ private slots: void testDrawAuthorisationSurvivesTheBatteryReserve(); void testEcoProgressBaseAndRegime(); void testEcoPassBuysAndSaysTheTrajectory(); + void testEcoFloorMissedSaysWhatWasLostAndWhy(); void testARefusedStateIsDistinguishableFromAnApplied(); void testL4ShedsAPacAndSaysItForcedTheLock();