From f2e99a7f5030d1f2012c03f610682574ad759e44 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Mon, 31 Aug 2026 19:17:46 +0200 Subject: [PATCH] =?UTF-8?q?feat(=C2=A710=20=C3=A9tape=20D,=202/2):=20la=20?= =?UTF-8?q?passe=20=C3=A9co=20existe,=20et=20le=20motif=20dit=20la=20traje?= =?UTF-8?q?ctoire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux passes sur la même liste, même ordre ; ce qui change est la BORNE — l'autorisation de soutirage pour l'éco, le reliquat de surplus pour le confort. L'ordre lexicographique sur (niveau, rang) en découle : le rang classe les charges, la hiérarchie éco/confort classe les besoins et surplombe le rang. Une seule commande par charge et par cycle : le niveau éco ne commande pas, il est fusionné dans l'action de la passe confort. Émettre deux fois ferait commuter un relais en passe 1 puis le recommuter en passe 2. ECO_FLOOR_GRID rend lisible la TRAJECTOIRE. La cadence ne monte pas, elle fait un mur — 333 W à six heures, le plafond dès seize minutes — et « 1000 W achetés » seul ne permet pas de prévoir la facture. Avec le reste à livrer et le temps restant, si. Le motif porte aussi le régime (sur quoi on achète) et atMaximum (l'étalement a cessé). R6 est armée à DEUX endroits : le calcul ne conclut jamais « tenue » sous un régime non mesuré, et la passe refuse d'émettre ECO_FLOOR_MET dans ce cas. Un invariant qui ne tient qu'à un seul endroit tient mal. L'autorisation de soutirage borne l'éco : le plancher éco est une raison d'acheter, pas un droit de faire disjoncter. Contre-épreuve : bornage retiré, 1000 W au lieu de 400. Et passe débranchée : un seul niveau au lieu de deux. Recrédit attribué à la première passe où la charge apparaît, par champ explicite — la discipline ne se teste pas, le champ si. Mesuré avant d'être asserté, encore : aucun scénario existant ne portait d'obligation, donc aucune référence. Et l'invariance reste utile là où elle a un sens — aucune charge existante ne déclare d'obligation, donc charging reste identique à la référence ligne à ligne dans une étape qui, par ailleurs, change le comportement. Suite : simulation 57/57, loadmodel 22/22, charging 48/48 identique à la référence, spotmarket 32/32, doxygen 0 avertissement. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p --- debian-qt5/changelog | 33 ++++++ .../etm/scheduler/rulebasedscheduler.cpp | 99 +++++++++++++++- .../etm/scheduler/rulebasedscheduler.h | 8 +- tests/auto/simulation/simulation.cpp | 110 ++++++++++++++++++ tests/auto/simulation/simulation.h | 1 + 5 files changed, 247 insertions(+), 4 deletions(-) diff --git a/debian-qt5/changelog b/debian-qt5/changelog index 82514c9..baef675 100644 --- a/debian-qt5/changelog +++ b/debian-qt5/changelog @@ -1,3 +1,36 @@ +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 + change est la BORNE — l'autorisation de soutirage pour l'éco, le reliquat de surplus pour le + confort. L'ordre lexicographique sur (niveau, rang) en découle : le rang classe les CHARGES, + la hiérarchie éco/confort classe les BESOINS et surplombe le rang. + * UNE SEULE COMMANDE PAR CHARGE ET PAR CYCLE (LM-1003). Le niveau éco ne commande pas : il est + fusionné dans l'action de la passe confort. Émettre deux fois ferait commuter un relais en + passe 1 puis le recommuter en passe 2 — du court-cycling fabriqué par l'arbitre lui-même. + * ECO_FLOOR_GRID REND LISIBLE LA TRAJECTOIRE, PAS L'INSTANT. La cadence ne monte pas, elle fait + un mur : 333 W à six heures de l'échéance, le plafond dès seize minutes. Un client qui lit + « 1000 W achetés » ne peut pas deviner ce qu'il paiera dans deux heures. Le motif porte donc + gridW, remainingWh, targetWh, remainingS, regime et atMaximum — combien, pour quelle + obligation, à quelle échéance, sur quoi on achète, et si l'étalement a cessé. + * R6 ARMÉE À DEUX ENDROITS : `computeEcoTarget()` ne conclut jamais « tenue » sous un régime + non mesuré, et la passe refuse d'émettre ECO_FLOOR_MET dans ce cas. Un invariant qui ne tient + qu'à un seul endroit tient mal. + * L'AUTORISATION DE SOUTIRAGE BORNE L'ÉCO : le plancher éco est une raison d'acheter, pas un + droit de faire disjoncter. Contre-épreuve : bornage retiré, 1000 W au lieu de 400. + * Recrédit attribué à la PREMIÈRE passe où la charge apparaît, par champ explicite (LM-1004) : + la discipline ne se teste pas, le champ si. Le recréditer deux fois rendrait au budget de + surplus une puissance que l'éco a financée au réseau. + * MESURÉ AVANT D'ÊTRE ASSERTÉ, encore : deux niveaux, Σ targetW == estimatedPowerW, + counts ventilés entre budget.allocatedW et draw.committedW. Aucun scénario existant ne + portait d'obligation, donc aucune référence — les valeurs ont été relevées d'abord. + * INVARIANCE PRÉSERVÉE LÀ OÙ ELLE A ENCORE UN SENS : aucune charge existante ne déclare + d'obligation, donc la passe éco ne produit rien pour elles et charging reste IDENTIQUE à la + référence ligne à ligne. C'est le garde-fou qui reste utile dans une étape qui, par ailleurs, + change le comportement. + * Suite : simulation 57/57, loadmodel 22/22, charging 48/48 identique, spotmarket 32/32. + + -- Patrick Schurig Mon, 31 Aug 2026 10:05:00 +0200 + powersync-energy-plugin-nymea (1.15.2+etm41) trixie; urgency=medium * §10 étape D (1/2) — L'ÉTALEMENT UNIFORME, mesuré avant d'être asserté. `computeEcoTarget()` diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index d524130..da0d406 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -232,16 +232,104 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) // le surplus. C'est une marge de CYCLE : elle est prise une fois dans le contexte, jamais // relue, et chaque charge servie l'entame de ce qu'elle a été autorisée à prendre. DrawMargin margeRestante = ctx.drawMargin; + + // ================= PASSE 1 — ÉCO ========================================================== + // + // MÊME liste, MÊME ordre. Ce qui change entre les deux passes n'est pas le tri, c'est la + // BORNE : la passe éco est bornée par l'autorisation de SOUTIRAGE (on achète), la passe + // confort par le reliquat de SURPLUS (on dépense ce qui est déjà là). + // + // L'ordre lexicographique sur (niveau, rang) en découle : le rang classe les CHARGES, la + // hiérarchie éco/confort classe les BESOINS et surplombe le rang. Un confort de rang 1 passe + // donc après un éco de rang 3 — c'est le sens même des deux passes, et le publier sous forme + // de niveaux est ce qui rend cet ordre lisible sans qu'un client ait à le redériver. + QHash niveauxEco; + QSet recreditAttribue; + + for (const LoadContext &lc : nonEvLoads) { + const EcoProgress avancement = m_arbitrator->ecoProgress(lc, ctx.timestamp); + const EcoTarget cible = computeEcoTarget(lc.needs, avancement, + lc.declared.maxPowerW, ctx.timestamp); + if (!cible.applicable) + continue; // pas d'obligation : cette charge ne participe qu'au confort (R1) + + LoadAction::Level eco; + eco.level = QStringLiteral("eco"); + eco.hasProgress = true; + eco.progress = avancement; + + 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 + // ET là-bas, parce qu'un invariant qui ne tient qu'à un seul endroit tient mal. + Q_ASSERT(avancement.delivered); + eco.targetW = 0; + eco.counts[CountKey::BudgetAllocated] = 0; + eco.reason = { DecisionCode::EcoFloorMet, + {{"deliveredWh", qRound(avancement.deliveredWh)}, + {"targetWh", qRound(avancement.targetWh)}} }; + niveauxEco.insert(lc.id, eco); + continue; + } + + // BORNÉE PAR L'AUTORISATION DE SOUTIRAGE. Le plancher éco est une raison d'acheter, pas + // 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; + if (margeRestante.borne) + achatW = qBound(0.0, achatW, qMax(0.0, margeRestante.watts)); + if (achatW <= 0) + continue; // rien d'achetable ce cycle : pas de niveau éco plutôt qu'un zéro forgé + + eco.targetW = achatW; + eco.funding = LoadAction::Grid; + eco.counts[CountKey::DrawCommitted] = achatW; + eco.reason = { DecisionCode::EcoFloorGrid, + {{"gridW", qRound(achatW)}, + {"remainingWh", qRound(cible.remainingWh)}, + {"targetWh", qRound(avancement.targetWh)}, + {"remainingS", static_cast(cible.remainingS)}, + {"regime", avancement.regime}, + {"atMaximum", cible.pointDeNonRetour}} }; + niveauxEco.insert(lc.id, eco); + + // LM-1004 — le recrédit est attribué à la PREMIÈRE passe où la charge apparaît, et le + // champ le rend vérifiable plutôt que discipliné : la discipline ne se teste pas. + recreditAttribue.insert(lc.id); + + draw.committedW += achatW; + if (margeRestante.borne) + margeRestante.watts -= achatW; + } + + // ================= PASSE 2 — CONFORT ====================================================== for (const LoadContext &lc : nonEvLoads) { if (lc.adapter == QStringLiteral("sg-ready")) slot.actions.append(buildSgReadyStateAction(lc, remainingSurplusW, compta)); else slot.actions.append(emitSetpointAction( - decideSetpoint(lc, remainingSurplusW, margeRestante, compta, draw))); // etmvariableload + relay-router + evcharger + decideSetpoint(lc, remainingSurplusW, margeRestante, compta, draw, + recreditAttribue.contains(lc.id)))); // etmvariableload + relay-router + evcharger // Le décrément vaut pour TOUTES les charges servies, bornes et PAC comprises : elles // consomment la même autorisation, qu'elles aient été bornées par elle ou non. Ne // décompter que les charges qu'on borne offrirait aux suivantes une marge déjà dépensée. + // LM-1003 — UNE SEULE COMMANDE PAR CHARGE ET PAR CYCLE. Le niveau éco ne commande pas : + // il est fusionné dans l'action que la passe confort vient de produire. Émettre deux + // fois ferait commuter un relais en passe 1 puis le recommuter en passe 2 — du + // court-cycling fabriqué par l'arbitre lui-même. + if (!slot.actions.isEmpty() && niveauxEco.contains(lc.id)) { + LoadAction &la = slot.actions.last(); + const LoadAction::Level eco = niveauxEco.take(lc.id); + la.levels.prepend(eco); // éco AVANT confort : l'ordre des passes + la.powerW += eco.targetW; + la.estimatedPowerW += eco.targetW; + for (auto it = eco.counts.constBegin(); it != eco.counts.constEnd(); ++it) + la.counts[it.key()] += it.value(); + if (eco.targetW > 0) + la.chargingEnabled = true; + } + if (margeRestante.borne && !slot.actions.isEmpty()) margeRestante.watts -= slot.actions.last().estimatedPowerW; // 3g-2 — c'est ICI que le jeu se constitue, au moment où la décision est prise. Le @@ -356,7 +444,8 @@ DecisionReason RuleBasedScheduler::choisirMotif(const MotifEntrees &e) const RuleBasedScheduler::SetpointTarget RuleBasedScheduler::decideSetpoint(const LoadContext &lc, double &remainingSurplusW, DrawMargin &margeRestante, - PlanBudget &budget, PlanDraw &draw) const + PlanBudget &budget, PlanDraw &draw, + bool recreditDejaAttribue) const { // Recrédit anti-clignotement (Correction B / contrat rév. 2 §5) : la conso de DÉBUT de cycle // est déjà soustraite de l'export mesuré → on la rend au budget local avant d'arrondir. @@ -370,7 +459,11 @@ RuleBasedScheduler::SetpointTarget RuleBasedScheduler::decideSetpoint(const Load // la réserve ne mordait que sur les augmentations, et le motif publié — « aucune charge // n'est servie » — était faux. Attrapé par batteryLevelConsiderationStopCharging au moment // où les bornes ont commencé à exécuter réellement les décisions du waterfall. - const double recreditW = m_reserveActive ? 0.0 : lc.telemetry.currentPowerW; + // LM-1004 — le recrédit est attribué à la PREMIÈRE passe où la charge apparaît, et une + // seule fois. Le recréditer aussi ici offrirait deux fois la même puissance : une charge + // servie en éco verrait sa consommation rendue au budget de surplus qu'elle ne finance pas. + const double recreditW = (m_reserveActive || recreditDejaAttribue) + ? 0.0 : lc.telemetry.currentPowerW; const double budgetW = remainingSurplusW + recreditW; budget.recreditedW += recreditW; diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.h b/energyplugin/etm/scheduler/rulebasedscheduler.h index 64c5b31..7d4efcd 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.h +++ b/energyplugin/etm/scheduler/rulebasedscheduler.h @@ -83,6 +83,11 @@ private: * * \param lc Charge \c etmvariableload du SurplusContext. * \param[in,out] remainingSurplusW Budget de surplus restant (W) ; mis à jour pour la suite. + * \param recreditDejaAttribue Vrai si la passe ÉCO a déjà crédité cette charge. Le recrédit + * anti-clignotement s'attribue à la PREMIÈRE passe où la charge apparaît et une seule fois + * (\rule{LM-1004}) — le recréditer deux fois rendrait au budget de surplus une puissance + * que l'éco a financée au réseau. Champ EXPLICITE plutôt que discipline : la discipline ne + * se teste pas. * \param[in,out] margeRestante Autorisation de SOUTIRAGE restante (W), consommée le long * de la cascade au même titre que le surplus. \c borne à faux = aucun plafond en vigueur, * ce qui n'est PAS une marge nulle : sans plafond la cascade n'est pas bornée, à marge @@ -156,7 +161,8 @@ private: */ SetpointTarget decideSetpoint(const LoadContext &lc, double &remainingSurplusW, DrawMargin &margeRestante, - PlanBudget &budget, PlanDraw &draw) const; + PlanBudget &budget, PlanDraw &draw, + bool recreditDejaAttribue = false) const; /*! * \brief Forme la commande à partir d'une cible décidée — et forme le motif. diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 3406005..73b67b1 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -6805,6 +6805,116 @@ void Simulation::testEcoProgressBaseAndRegime() #endif } +/*! + * \brief [§10 étape D] La passe ÉCO achète, et le motif dit la TRAJECTOIRE. + * + * \par Deux passes, une commande + * Même liste, même ordre ; ce qui change est la BORNE — l'autorisation de soutirage pour l'éco, + * le reliquat de surplus pour le confort. Les deux niveaux sont publiés, mais **une seule + * commande** part au matériel (\rule{LM-1003}) : émettre deux fois ferait commuter un relais en + * passe 1 puis le recommuter en passe 2, du court-cycling fabriqué par l'arbitre lui-même. + * + * \par Ce que le motif doit permettre, et pourquoi + * `ECO_FLOOR_GRID` est lu le matin où la facture monte. Les watts de l'instant n'y suffisent + * pas : la cadence fait un mur — 333 W à six heures de l'échéance, le plafond dès seize minutes. + * Un client qui lit « 1000 W achetés » ne peut pas deviner ce qu'il paiera dans deux heures ; + * avec le RESTE à livrer et le TEMPS restant, il le peut. + */ +void Simulation::testEcoPassBuysAndSaysTheTrajectory() +{ +#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 planifier = [&](const QDateTime &at, bool mesurable, double livreWh, + double margeW) -> LoadAction { + SurplusContext ctx; + ctx.timestamp = at; + ctx.meter.importW = 500; // la maison ACHÈTE : aucun surplus + ctx.drawMargin.borne = true; + ctx.drawMargin.watts = margeW; + ctx.drawMargin.source = DrawCapSource::Breaker; + ctx.drawMargin.phase = 0; + + LoadContext ecs; + ecs.id = QStringLiteral("ecs-%1-%2").arg(mesurable).arg(qRound(margeW)); + 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); + + const Plan plan = sched.getPlan(ctx); + return plan.timeSlots.first().actions.first(); + }; + + const QDateTime deuxHeuresAvant = utcDateTime(QDate(2026, 8, 31), QTime(5, 0, 0)); + + // 1. DEUX NIVEAUX, et l'éco d'abord — l'ordre des passes, pas celui des rangs. + const LoadAction a = planifier(deuxHeuresAvant, true, 0, 5000); + QCOMPARE(a.levels.count(), 2); + QCOMPARE(a.levels.at(0).level, QString("eco")); + QCOMPARE(a.levels.at(1).level, QString("comfort")); + + // 2. UNE SEULE COMMANDE, dont la puissance est la SOMME des niveaux. C'est l'invariant qui + // rend la charge utile vérifiable — et LM-1003 qui rend le matériel sûr. + QCOMPARE(qRound(a.estimatedPowerW), 1000); // 2 kWh en 2 h + double sommeNiveaux = 0; + for (const LoadAction::Level &n : a.levels) + sommeNiveaux += n.targetW; + QCOMPARE(qRound(sommeNiveaux), qRound(a.estimatedPowerW)); + + // 3. LE FINANCEMENT VIT DANS LE NIVEAU (R2). L'éco est acheté, le confort ne l'est pas — + // un `funding` par CHARGE serait indécidable ici, et c'est tout l'objet du changement. + QCOMPARE(a.levels.at(0).funding, LoadAction::Grid); + QCOMPARE(a.levels.at(1).funding, LoadAction::Surplus); + QCOMPARE(qRound(a.counts.value(CountKey::DrawCommitted)), 1000); + + // 4. LA TRAJECTOIRE, et pas seulement l'instant. Chacun de ces champs répond à une question + // qu'un client se posera devant sa facture : combien, pour quoi, et jusqu'à quand. + const QVariantMap p = a.levels.at(0).reason.params; + QCOMPARE(a.levels.at(0).reason.code, QString(DecisionCode::EcoFloorGrid)); + QCOMPARE(p.value("gridW").toInt(), 1000); + QCOMPARE(p.value("remainingWh").toInt(), 2000); + QCOMPARE(p.value("targetWh").toInt(), 2000); + QCOMPARE(p.value("remainingS").toInt(), 7200); + QCOMPARE(p.value("regime").toString(), QString("measured")); + QCOMPARE(p.value("atMaximum").toBool(), false); + + // …et le TEXTE le dit, sans quoi le motif ne serait lisible que par un programme. + const QString texte = renderFr(a.levels.at(0).reason, "ECS"); + QVERIFY2(texte.contains("2000 Wh"), qUtf8Printable("motif: " + texte)); + QVERIFY2(texte.contains("2 h"), qUtf8Printable("motif: " + texte)); + + // 5. L'AUTORISATION DE SOUTIRAGE BORNE L'ÉCO. Le plancher éco est une raison d'acheter, pas + // un droit de faire disjoncter : marge à 400 W, on n'achète que 400. + const LoadAction borne = planifier(deuxHeuresAvant, true, 0, 400); + QCOMPARE(qRound(borne.levels.at(0).targetW), 400); + + // 6. R6 — SOUS `unmeasurable`, JAMAIS « tenue ». Même avec 3 kWh au compteur de session, on + // ne sait pas ce qui a été livré POUR CETTE OBLIGATION : conclure serait affirmer sur ce + // qu'on ignore, et une échéance qui s'affiche comme tenue sans l'être rassure à tort. + const LoadAction inconnu = planifier(deuxHeuresAvant, false, 3000, 5000); + QCOMPARE(inconnu.levels.at(0).reason.code, QString(DecisionCode::EcoFloorGrid)); + QVERIFY2(inconnu.levels.at(0).reason.code != QString(DecisionCode::EcoFloorMet), + "ECO_FLOOR_MET est interdit sous un régime non mesuré (R6)"); + QCOMPARE(inconnu.levels.at(0).progress.regime, QString(ProgressRegime::Unmeasurable)); + QVERIFY2(!inconnu.levels.at(0).progress.delivered, + "sous unmeasurable, deliveredWh n'a pas de sens et ne sera pas publié"); +#endif +} + void Simulation::testCountsSumToTarget() { #ifndef ETM_ARBITRATOR diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 17ae4b6..2b40455 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -195,6 +195,7 @@ private slots: void testDrawCapBoundsTheCascade(); void testDrawAuthorisationSurvivesTheBatteryReserve(); void testEcoProgressBaseAndRegime(); + void testEcoPassBuysAndSaysTheTrajectory(); void testARefusedStateIsDistinguishableFromAnApplied(); void testL4ShedsAPacAndSaysItForcedTheLock();