feat(§10 étape D, 2/2): la passe éco existe, et le motif dit la trajectoire

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
This commit is contained in:
Patrick Schurig 2026-08-31 19:17:46 +02:00
parent ca7a501797
commit f2e99a7f50
5 changed files with 247 additions and 4 deletions

View File

@ -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 <etm.schurig@gmail.com> 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()`

View File

@ -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<QString, LoadAction::Level> niveauxEco;
QSet<QString> 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<int>(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;

View File

@ -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.

View File

@ -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<EnergyArbitrator *>(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

View File

@ -195,6 +195,7 @@ private slots:
void testDrawCapBoundsTheCascade();
void testDrawAuthorisationSurvivesTheBatteryReserve();
void testEcoProgressBaseAndRegime();
void testEcoPassBuysAndSaysTheTrajectory();
void testARefusedStateIsDistinguishableFromAnApplied();
void testL4ShedsAPacAndSaysItForcedTheLock();