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:
parent
ca7a501797
commit
f2e99a7f50
@ -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
|
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()`
|
* §10 étape D (1/2) — L'ÉTALEMENT UNIFORME, mesuré avant d'être asserté. `computeEcoTarget()`
|
||||||
|
|||||||
@ -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
|
// 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.
|
// relue, et chaque charge servie l'entame de ce qu'elle a été autorisée à prendre.
|
||||||
DrawMargin margeRestante = ctx.drawMargin;
|
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) {
|
for (const LoadContext &lc : nonEvLoads) {
|
||||||
if (lc.adapter == QStringLiteral("sg-ready"))
|
if (lc.adapter == QStringLiteral("sg-ready"))
|
||||||
slot.actions.append(buildSgReadyStateAction(lc, remainingSurplusW, compta));
|
slot.actions.append(buildSgReadyStateAction(lc, remainingSurplusW, compta));
|
||||||
else
|
else
|
||||||
slot.actions.append(emitSetpointAction(
|
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
|
// 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
|
// 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.
|
// 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())
|
if (margeRestante.borne && !slot.actions.isEmpty())
|
||||||
margeRestante.watts -= slot.actions.last().estimatedPowerW;
|
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
|
// 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,
|
RuleBasedScheduler::SetpointTarget RuleBasedScheduler::decideSetpoint(const LoadContext &lc,
|
||||||
double &remainingSurplusW,
|
double &remainingSurplusW,
|
||||||
DrawMargin &margeRestante,
|
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
|
// 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.
|
// 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
|
// 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
|
// n'est servie » — était faux. Attrapé par batteryLevelConsiderationStopCharging au moment
|
||||||
// où les bornes ont commencé à exécuter réellement les décisions du waterfall.
|
// 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;
|
const double budgetW = remainingSurplusW + recreditW;
|
||||||
budget.recreditedW += recreditW;
|
budget.recreditedW += recreditW;
|
||||||
|
|
||||||
|
|||||||
@ -83,6 +83,11 @@ private:
|
|||||||
*
|
*
|
||||||
* \param lc Charge \c etmvariableload du SurplusContext.
|
* \param lc Charge \c etmvariableload du SurplusContext.
|
||||||
* \param[in,out] remainingSurplusW Budget de surplus restant (W) ; mis à jour pour la suite.
|
* \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
|
* \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,
|
* 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
|
* 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,
|
SetpointTarget decideSetpoint(const LoadContext &lc, double &remainingSurplusW,
|
||||||
DrawMargin &margeRestante,
|
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.
|
* \brief Forme la commande à partir d'une cible décidée — et forme le motif.
|
||||||
|
|||||||
@ -6805,6 +6805,116 @@ void Simulation::testEcoProgressBaseAndRegime()
|
|||||||
#endif
|
#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()
|
void Simulation::testCountsSumToTarget()
|
||||||
{
|
{
|
||||||
#ifndef ETM_ARBITRATOR
|
#ifndef ETM_ARBITRATOR
|
||||||
|
|||||||
@ -195,6 +195,7 @@ private slots:
|
|||||||
void testDrawCapBoundsTheCascade();
|
void testDrawCapBoundsTheCascade();
|
||||||
void testDrawAuthorisationSurvivesTheBatteryReserve();
|
void testDrawAuthorisationSurvivesTheBatteryReserve();
|
||||||
void testEcoProgressBaseAndRegime();
|
void testEcoProgressBaseAndRegime();
|
||||||
|
void testEcoPassBuysAndSaysTheTrajectory();
|
||||||
void testARefusedStateIsDistinguishableFromAnApplied();
|
void testARefusedStateIsDistinguishableFromAnApplied();
|
||||||
void testL4ShedsAPacAndSaysItForcedTheLock();
|
void testL4ShedsAPacAndSaysItForcedTheLock();
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user