Fusion fix/eco-sous-palier : ECO_FLOOR_BELOW_STAGE, sous le premier palier rien n'est acheté
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
341f4ddbae
@ -952,6 +952,10 @@ Règles d'exécution côté plugin :
|
||||
- **Vers l'app, seul `{reasonCode, reasonParams}` est transmis** — jamais le texte libre
|
||||
(`AGENTS.md` invariant 12). Une action sans `reasonCode` connu du catalogue est
|
||||
transmise sous le code générique `OPTIMIZER_DECISION`, sans texte.
|
||||
- Les codes qui décrivent ce que le **moteur** constate ne sont pas émissibles (liste blanche
|
||||
ci-dessous). `ECO_FLOOR_BELOW_STAGE` en est un : une obligation dont la cadence tombe sous le
|
||||
premier palier de la charge, donc rien d'acheté. L'optimiseur ne reçoit pas les paliers
|
||||
(`powerLevels` n'est pas émis, §5.3) et ne peut pas le savoir ; le moteur le dit.
|
||||
|
||||
### Types d'action (`kind`)
|
||||
|
||||
|
||||
@ -474,6 +474,26 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
|
||||
: QStringLiteral("obligation"));
|
||||
}
|
||||
eco.reason = { DecisionCode::EcoFloorGrid, motif };
|
||||
|
||||
// SOUS LE PREMIER PALIER, rien n'est acheté. Une charge à paliers ne délivre que ses
|
||||
// paliers, et son mécanisme arrondit vers le bas : 83 W devant un premier palier de
|
||||
// 500 W laissent le relais ouvert, et « 83 W achetés » se lisait sur un ballon froid.
|
||||
// Le MOTIF le dit ; la consigne ne bouge pas — même `targetW`, même palier, même
|
||||
// comptage de l'acheté (il lit l'action appliquée, correctif c). Le palier vient de
|
||||
// l'adaptateur, comme `sousPlancher` en passe confort : levels[0] vaut toujours 0, le
|
||||
// premier atteignable est levels[1]. Une charge continue n'en déclare pas.
|
||||
const QList<int> &paliers = lc.declared.powerLevels;
|
||||
const int premierPalier = paliers.size() > 1 ? paliers.at(1) : 0;
|
||||
if (premierPalier > 0 && achatW < premierPalier) {
|
||||
QVariantMap sous{{"targetW", qRound(achatW)},
|
||||
{"stageW", premierPalier},
|
||||
{"gridW", 0}};
|
||||
if (lc.needs.minEnergyWhPerDay > 0) { // même règle de présence que ci-dessus
|
||||
sous.insert(QStringLiteral("remainingWh"), qRound(cible.remainingWh));
|
||||
sous.insert(QStringLiteral("remainingS"), static_cast<int>(cible.remainingS));
|
||||
}
|
||||
eco.reason = { DecisionCode::EcoFloorBelowStage, sous };
|
||||
}
|
||||
niveauxEco.insert(lc.id, eco);
|
||||
|
||||
draw.committedW += achatW;
|
||||
|
||||
@ -125,11 +125,13 @@ QString renderFr(const DecisionReason &reason, const QString &label)
|
||||
}
|
||||
// La TRAJECTOIRE, pas l'instant. Les watts seuls laisseraient croire à une dépense stable
|
||||
// là où la cadence va se durcir d'un coup ; le reste et l'échéance disent où l'on va.
|
||||
if (c == QLatin1String(DecisionCode::EcoFloorGrid)) {
|
||||
auto delaiEco = [&p]() {
|
||||
const int restantS = p.value(QStringLiteral("remainingS")).toInt();
|
||||
const QString delai = restantS >= 3600
|
||||
? QStringLiteral("%1 h").arg(restantS / 3600)
|
||||
: QStringLiteral("%1 min").arg(qMax(1, restantS / 60));
|
||||
return restantS >= 3600 ? QStringLiteral("%1 h").arg(restantS / 3600)
|
||||
: QStringLiteral("%1 min").arg(qMax(1, restantS / 60));
|
||||
};
|
||||
if (c == QLatin1String(DecisionCode::EcoFloorGrid)) {
|
||||
const QString delai = delaiEco();
|
||||
if (p.value(QStringLiteral("atMaximum")).toBool())
|
||||
return QStringLiteral("Obligation de %1 : %2 W ACHETÉS au maximum — il reste %3 Wh "
|
||||
"à livrer et %4, l'étalement ne suffit plus")
|
||||
@ -138,6 +140,17 @@ 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);
|
||||
}
|
||||
// Pas un achat : la cadence existe, le mécanisme ne sait pas la délivrer. La phrase le dit
|
||||
// en premier, parce que c'est ce que l'écran affichait de travers.
|
||||
if (c == QLatin1String(DecisionCode::EcoFloorBelowStage)) {
|
||||
if (!p.contains(QStringLiteral("remainingWh")))
|
||||
return QStringLiteral("Minimum de %1 : %2 W demandés, sous le premier palier (%3 W) "
|
||||
"— rien n'est acheté")
|
||||
.arg(label).arg(w("targetW")).arg(w("stageW"));
|
||||
return QStringLiteral("Obligation de %1 : %2 W nécessaires, sous le premier palier "
|
||||
"(%3 W) — rien n'est acheté ; %4 Wh restent à livrer en %5")
|
||||
.arg(label).arg(w("targetW")).arg(w("stageW")).arg(w("remainingWh")).arg(delaiEco());
|
||||
}
|
||||
if (c == QLatin1String(DecisionCode::RecreditSuspended))
|
||||
return QStringLiteral("%1 ne suit pas la commande depuis %2 s — %3 W NON rendus au "
|
||||
"budget : on ne prête pas ce qu'on ne peut pas reprendre")
|
||||
@ -338,7 +351,8 @@ QStringList DecisionCode::knownCodes()
|
||||
return {EvSpotMarket, EvDeadline, EvSurplus, EvEcoMin, EvIdle,
|
||||
LoadUnavailable, LockMinOn, LockMinOff, LockMinOffCapped, SurplusSetpoint,
|
||||
SurplusInsufficient, BatteryReserve, BelowMinPower, EvGridStart, PhaseLimit, DrawCap,
|
||||
EcoFloorGrid, EcoFloorMet, EcoFloorMissed, RecreditSuspended, DerogationActive,
|
||||
EcoFloorGrid, EcoFloorBelowStage, EcoFloorMet, EcoFloorMissed, RecreditSuspended,
|
||||
DerogationActive,
|
||||
DerogationEnded, MissingForecastSite, ForecastPayloadRefused, SpotMarketNotInBuild,
|
||||
ForecastDateUnreadable, TariffRefused,
|
||||
LockMinStateHold, SgForced, SgRecommended, SgNormal,
|
||||
|
||||
@ -203,6 +203,32 @@ constexpr const char *DrawCap = "DRAW_CAP";
|
||||
*/
|
||||
constexpr const char *EcoFloorGrid = "ECO_FLOOR_GRID";
|
||||
|
||||
/*!
|
||||
* \brief L'obligation éco demande moins que le PREMIER PALIER de la charge : rien n'est acheté.
|
||||
*
|
||||
* params : \c targetW (la cadence demandée), \c stageW (le premier palier atteignable),
|
||||
* \c gridW (toujours 0), \c o:remainingWh et \c o:remainingS (seulement s'il y a une obligation
|
||||
* en énergie — même règle de présence qu'\c ECO_FLOOR_GRID).
|
||||
*
|
||||
* \par Le défaut qu'il corrige
|
||||
* Une charge à paliers (\c powerLevels déclarés : \c relay-router, \c etmvariableload en mode
|
||||
* fixed) ne délivre que ses paliers. 2000 Wh étalés sur 24 h font 83 W ; devant un premier
|
||||
* palier de 500 W, le mécanisme arrondit vers le bas et le relais reste OUVERT. Le niveau éco
|
||||
* publiait pourtant « 83 W achetés au réseau » (\c ECO_FLOOR_GRID) — constaté au banc, un
|
||||
* ballon froid affiché comme servi. Ce motif dit ce qui se passe : la cadence existe, elle est
|
||||
* sous le palier, et rien n'est acheté.
|
||||
*
|
||||
* \par Ce qu'il ne change pas
|
||||
* La consigne : le niveau garde son \c targetW, l'action la même puissance, le mécanisme le même
|
||||
* palier. Seuls le motif et son \c gridW disent autre chose. Le premier palier est
|
||||
* \c declared.powerLevels[1], la source de \c BELOW_MIN_POWER en passe confort. Une charge
|
||||
* CONTINUE n'est pas concernée : elle garde \c ECO_FLOOR_GRID.
|
||||
*
|
||||
* \note Hors de la liste blanche optimiseur : un optimiseur ne reçoit pas les paliers (écart
|
||||
* E9), il ne peut donc pas savoir qu'une consigne tombe dessous. C'est un fait du moteur.
|
||||
*/
|
||||
constexpr const char *EcoFloorBelowStage = "ECO_FLOOR_BELOW_STAGE";
|
||||
|
||||
/*!
|
||||
* \brief L'obligation éco est TENUE — plus rien à acheter pour elle ce cycle.
|
||||
*
|
||||
|
||||
@ -2,7 +2,8 @@
|
||||
# Comparateur « IDENTIQUE HORS MOTIFS » (correctifs moteur a et b, etm70).
|
||||
#
|
||||
# etm-powersync-compare-hors-motifs.py <attendu.ndjson[.xz]> <obtenu.ndjson[.xz]> [--cas <sortie.md>]
|
||||
# [--achat] [--strategie] [--lignes <sortie.txt>]
|
||||
# [--achat] [--strategie] [--sous-palier]
|
||||
# [--lignes <sortie.txt>]
|
||||
#
|
||||
# Un lot qui ne change QUE des motifs doit laisser tout le reste de la référence `decisions`
|
||||
# intact à l'octet près. Ce script retire de chaque ligne, et SEULEMENT d'elle :
|
||||
@ -15,6 +16,11 @@
|
||||
# change cette VALEUR PUBLIÉE sans toucher aux consignes doit passer ce mode-là.
|
||||
# Avec --strategie (brief 4 T6, décision du 2026-10-17), il ramène EN PLUS `plan.strategy`
|
||||
# `rule-based` à `rules` d'une ligne de plan direct : le renommage du plan rule-based, et lui seul.
|
||||
# Avec --sous-palier (lot éco sous palier, 2026-10-19), il retire EN PLUS le motif des niveaux
|
||||
# « eco » portant ECO_FLOOR_GRID ou ECO_FLOOR_BELOW_STAGE, puis les confronte deux à deux : seul
|
||||
# ECO_FLOOR_GRID → ECO_FLOOR_BELOW_STAGE est admis ; un ECO_FLOOR_GRID dont les paramètres
|
||||
# changent, ou tout autre passage, est un ÉCART. Le reste du niveau (targetW, counts, progress)
|
||||
# reste comparé.
|
||||
# Tout le reste — consignes, powerW, estimatedPowerW, états, relais, courants, budget, draw,
|
||||
# counts, niveaux et leurs motifs, avancement — doit être IDENTIQUE. Code 0 si c'est le cas,
|
||||
# 1 sinon (et le premier écart est montré). La liste des motifs qui changent est imprimée, ou
|
||||
@ -50,11 +56,23 @@ def sans_achat(niveau, charge, achats):
|
||||
achats.append((charge, prog.pop('purchasedWh', ABSENT)))
|
||||
|
||||
|
||||
def normaliser(ligne, achat=False, strategie=False):
|
||||
"""Rend (ligne sans motifs, [(charge, motif retiré)], [(charge, purchasedWh retiré)])."""
|
||||
ECO_SOUS_PALIER = ('ECO_FLOOR_GRID', 'ECO_FLOOR_BELOW_STAGE')
|
||||
|
||||
|
||||
def sans_motif_eco(niveau, cle_motif, charge, motifs_eco):
|
||||
motif = niveau.get(cle_motif)
|
||||
if niveau.get('level') == 'eco' and isinstance(motif, dict) \
|
||||
and motif.get('code') in ECO_SOUS_PALIER:
|
||||
motifs_eco.append((charge, niveau.pop(cle_motif)))
|
||||
|
||||
|
||||
def normaliser(ligne, achat=False, strategie=False, sous_palier=False):
|
||||
"""Rend (ligne sans motifs, [(charge, motif retiré)], [(charge, purchasedWh retiré)],
|
||||
[(charge, motif de niveau éco retiré)])."""
|
||||
d = json.loads(ligne)
|
||||
retires = []
|
||||
achats = []
|
||||
motifs_eco = []
|
||||
if strategie and d.get('src') == 'plan' and (d.get('plan') or {}).get('strategy') == 'rule-based':
|
||||
d['plan']['strategy'] = 'rules'
|
||||
if d.get('src') == 'cycle':
|
||||
@ -64,6 +82,8 @@ def normaliser(ligne, achat=False, strategie=False):
|
||||
sans_manque(n, 'decision')
|
||||
if achat:
|
||||
sans_achat(n, ch.get('loadId'), achats)
|
||||
if sous_palier:
|
||||
sans_motif_eco(n, 'decision', ch.get('loadId'), motifs_eco)
|
||||
elif d.get('src') == 'plan':
|
||||
for s in (d.get('plan') or {}).get('timeSlots', []) or []:
|
||||
for a in s.get('actions', []) or []:
|
||||
@ -72,7 +92,9 @@ def normaliser(ligne, achat=False, strategie=False):
|
||||
sans_manque(n, 'reason')
|
||||
if achat:
|
||||
sans_achat(n, a.get('loadId'), achats)
|
||||
return json.dumps(d, sort_keys=True, ensure_ascii=False), retires, achats
|
||||
if sous_palier:
|
||||
sans_motif_eco(n, 'reason', a.get('loadId'), motifs_eco)
|
||||
return json.dumps(d, sort_keys=True, ensure_ascii=False), retires, achats, motifs_eco
|
||||
|
||||
|
||||
def achat_court(v):
|
||||
@ -100,6 +122,7 @@ def main():
|
||||
sortie_lignes = sys.argv[sys.argv.index('--lignes') + 1] if '--lignes' in sys.argv else None
|
||||
achat = '--achat' in sys.argv
|
||||
strategie = '--strategie' in sys.argv
|
||||
sous_palier = '--sous-palier' in sys.argv
|
||||
lignes_changees = []
|
||||
|
||||
if len(attendu) != len(obtenu):
|
||||
@ -112,15 +135,25 @@ def main():
|
||||
if a == b:
|
||||
brutes_identiques += 1
|
||||
continue
|
||||
na, ra, aa = normaliser(a, achat, strategie)
|
||||
nb, rb, ab = normaliser(b, achat, strategie)
|
||||
na, ra, aa, ea = normaliser(a, achat, strategie, sous_palier)
|
||||
nb, rb, ab, eb = normaliser(b, achat, strategie, sous_palier)
|
||||
lignes_changees.append((i, a, b))
|
||||
if na != nb:
|
||||
if na != nb or len(ea) != len(eb):
|
||||
print(f'ÉCART HORS MOTIFS{" ET HORS ACHAT" if achat else ""}'
|
||||
f'{" ET HORS STRATÉGIE" if strategie else ""} à la ligne {i} :')
|
||||
f'{" ET HORS STRATÉGIE" if strategie else ""}'
|
||||
f'{" ET HORS MOTIF ÉCO SOUS PALIER" if sous_palier else ""} à la ligne {i} :')
|
||||
print('- ' + a[:3000])
|
||||
print('+ ' + b[:3000])
|
||||
return 1
|
||||
# Le motif d'un niveau éco : identique, ou ECO_FLOOR_GRID devenu ECO_FLOOR_BELOW_STAGE.
|
||||
for (charge, ma), (_, mb) in zip(ea, eb):
|
||||
if ma == mb:
|
||||
continue
|
||||
if (ma.get('code'), mb.get('code')) != ECO_SOUS_PALIER:
|
||||
print(f'ÉCART DE MOTIF ÉCO à la ligne {i} ({charge}) : {ma} → {mb}')
|
||||
return 1
|
||||
cas[(json.loads(a).get('fn'), charge, f'niveau éco {ma["code"]}',
|
||||
f'niveau éco {mb["code"]}')] += 1
|
||||
fn = json.loads(a).get('fn')
|
||||
for (charge, ma), (_, mb) in zip(ra, rb):
|
||||
if ma != mb:
|
||||
@ -134,10 +167,11 @@ def main():
|
||||
if sa != sb:
|
||||
cas[(fn, '(plan)', f'strategy {sa}', f'strategy {sb}')] += 1
|
||||
# Le manque d'un niveau change sans que le motif de tête change : à dire aussi.
|
||||
elif ra == rb and aa == ab:
|
||||
elif ra == rb and aa == ab and ea == eb:
|
||||
cas[(fn, '(niveau)', 'missingWh', 'missingWh corrigé')] += 1
|
||||
|
||||
hors = 'HORS MOTIFS' + (' ET HORS ACHAT' if achat else '') + (' ET HORS STRATÉGIE' if strategie else '')
|
||||
hors = ('HORS MOTIFS' + (' ET HORS ACHAT' if achat else '') + (' ET HORS STRATÉGIE' if strategie else '')
|
||||
+ (' ET HORS MOTIF ÉCO SOUS PALIER' if sous_palier else ''))
|
||||
print(f'IDENTIQUE {hors} : {len(attendu)} lignes, dont {brutes_identiques} identiques '
|
||||
f'à l\'octet et {len(attendu) - brutes_identiques} qui ne diffèrent que par '
|
||||
f'{"les motifs" + (", purchasedWh" if achat else "") + (" ou plan.strategy" if strategie else "")}')
|
||||
|
||||
Binary file not shown.
@ -55,6 +55,7 @@ void TestLoadModel::testOptimizerMayEmitOnlyWhitelistedCodes()
|
||||
DecisionCode::LoadUnavailable, DecisionCode::DrawCap,
|
||||
DecisionCode::PhaseLimit, DecisionCode::DerogationActive,
|
||||
DecisionCode::DerogationEnded, DecisionCode::EcoFloorMet,
|
||||
DecisionCode::EcoFloorBelowStage, // l'optimiseur ignore les paliers (E9)
|
||||
DecisionCode::EcoFloorMissed, DecisionCode::LockMinOn,
|
||||
DecisionCode::LockMinOff, DecisionCode::LockMinOffCapped,
|
||||
DecisionCode::LockMinStateHold, DecisionCode::RecreditSuspended,
|
||||
@ -80,3 +81,22 @@ void TestLoadModel::testOptimizerMayEmitOnlyWhitelistedCodes()
|
||||
QCOMPARE(renderFr(DecisionReason(DecisionCode::PriceSlot, {{"priceCtkWh", 9.5}}), QStringLiteral("PAC")),
|
||||
QStringLiteral("Prix favorable (9,5 ct/kWh) — PAC servie sur ce créneau"));
|
||||
}
|
||||
|
||||
void TestLoadModel::testEcoFloorBelowStageIsRenderedAsNoPurchase()
|
||||
{
|
||||
// La cadence d'une obligation sous le premier palier : la phrase dit qu'on n'achète RIEN, et
|
||||
// pourquoi. C'est ce que « 83 W achetés au réseau » disait de travers, constaté au banc.
|
||||
const DecisionReason obligation(DecisionCode::EcoFloorBelowStage,
|
||||
{{"targetW", 83}, {"stageW", 500}, {"gridW", 0},
|
||||
{"remainingWh", 2000}, {"remainingS", 6 * 3600}});
|
||||
QCOMPARE(renderFr(obligation, QStringLiteral("Chauffe-eau")),
|
||||
QStringLiteral("Obligation de Chauffe-eau : 83 W nécessaires, sous le premier palier "
|
||||
"(500 W) — rien n'est acheté ; 2000 Wh restent à livrer en 6 h"));
|
||||
|
||||
// Minimum seul : ni reste ni échéance, et la phrase n'en invente pas.
|
||||
const DecisionReason minimum(DecisionCode::EcoFloorBelowStage,
|
||||
{{"targetW", 300}, {"stageW", 500}, {"gridW", 0}});
|
||||
QCOMPARE(renderFr(minimum, QStringLiteral("Chauffe-eau")),
|
||||
QStringLiteral("Minimum de Chauffe-eau : 300 W demandés, sous le premier palier "
|
||||
"(500 W) — rien n'est acheté"));
|
||||
}
|
||||
|
||||
@ -154,6 +154,8 @@ private slots:
|
||||
void testDecisionCodesListIsComplete();
|
||||
//! Liste blanche de l'optimiseur (E14) : ni sécurité, ni verrou, ni état du moteur.
|
||||
void testOptimizerMayEmitOnlyWhitelistedCodes();
|
||||
//! ECO_FLOOR_BELOW_STAGE se rend « rien n'est acheté », avec ou sans obligation en énergie.
|
||||
void testEcoFloorBelowStageIsRenderedAsNoPurchase();
|
||||
|
||||
// Brief 4 T3 — plan d'un optimiseur (OptimizerPlan, fonctions pures)
|
||||
void testOptimizerParamKeysMatchInterface();
|
||||
|
||||
@ -8209,24 +8209,31 @@ void Simulation::testTheDecisionDescribesTheMergedAction()
|
||||
arb->simulationCallUpdate(t0);
|
||||
QCoreApplication::processEvents();
|
||||
|
||||
// 83 W devant le relais de 1000 W : le routeur n'achète rien, et son motif le dit
|
||||
// (ECO_FLOOR_BELOW_STAGE, cf. testEcoBelowTheFirstStageBuysNothing). La charge continue
|
||||
// achète ses 83 W. Les deux motifs passent la fusion et la ventilation de la même façon.
|
||||
const QString texte = journal.texte();
|
||||
for (const char *prefixe : {"[RelayRouter]", "[EtmVariableLoadAdapter]", "[Arbitre]"}) {
|
||||
for (const auto &[prefixe, code] : {std::pair{"[RelayRouter]", "[ECO_FLOOR_BELOW_STAGE]"},
|
||||
std::pair{"[EtmVariableLoadAdapter]", "[ECO_FLOOR_GRID]"},
|
||||
std::pair{"[Arbitre]", "[ECO_FLOOR_BELOW_STAGE]"},
|
||||
std::pair{"[Arbitre]", "[ECO_FLOOR_GRID]"}}) {
|
||||
bool vu = false;
|
||||
for (const QString &ligne : texte.split('\n'))
|
||||
if (ligne.contains(QLatin1String(prefixe)) && ligne.contains("ventilation")
|
||||
&& ligne.contains("[ECO_FLOOR_GRID]") && ligne.contains("Obligation de"))
|
||||
&& ligne.contains(QLatin1String(code)) && ligne.contains("Obligation de"))
|
||||
vu = true;
|
||||
QVERIFY2(vu, qUtf8Printable(QStringLiteral("%1 : aucune ligne ne rend le motif éco et "
|
||||
"la ventilation\n%2").arg(prefixe, texte)));
|
||||
QVERIFY2(vu, qUtf8Printable(QStringLiteral("%1 : aucune ligne ne rend le motif éco %2 et "
|
||||
"la ventilation\n%3").arg(prefixe, code, texte)));
|
||||
}
|
||||
}
|
||||
|
||||
const QHash<QString, QVariantMap> parId = telemetrieParId(arb->loadTelemetry());
|
||||
for (const QString &id : {QStringLiteral("ecs-motif"), varId.toString()}) {
|
||||
for (const auto &[id, code] : {std::pair{QStringLiteral("ecs-motif"), DecisionCode::EcoFloorBelowStage},
|
||||
std::pair{varId.toString(), DecisionCode::EcoFloorGrid}}) {
|
||||
const QVariantMap ch = parId.value(id);
|
||||
QVERIFY2(!ch.isEmpty(), qUtf8Printable(id));
|
||||
const QVariantMap decision = ch.value("decision").toMap();
|
||||
QCOMPARE(decision.value("code").toString(), QString(DecisionCode::EcoFloorGrid));
|
||||
QCOMPARE(decision.value("code").toString(), QString(code));
|
||||
QCOMPARE(decision.value("level").toString(), QString("eco"));
|
||||
QCOMPARE(ch.value("levels").toList().count(), 2);
|
||||
}
|
||||
@ -8415,6 +8422,166 @@ void Simulation::testPurchasedIsCountedOnTheMeasuredEnergy()
|
||||
#endif
|
||||
}
|
||||
|
||||
/*!
|
||||
* \brief L'obligation sous le PREMIER PALIER se dit « rien n'est acheté », pas « 83 W achetés ».
|
||||
*
|
||||
* \par Le défaut
|
||||
* Chauffe-eau sur relay-router 500/1000/2000 W, 2000 Wh dus avant 07:00. Juste après
|
||||
* l'échéance, l'étalement donne 83 W. Le niveau éco publiait `ECO_FLOOR_GRID` « 83 W achetés au
|
||||
* réseau » ; le routeur arrondit vers le bas, le relais restait ouvert et rien n'était acheté.
|
||||
* Constaté au banc en etm69 puis etm70 (QUESTION-moteur-vocabulaire-niveaux.md §3).
|
||||
*
|
||||
* \par Ce qui est éprouvé
|
||||
* - à 83 W : `ECO_FLOOR_BELOW_STAGE`, `gridW` 0, le premier palier nommé, relais OUVERT, la
|
||||
* consigne du niveau inchangée (83 W), `purchasedWh` à 0 — dans `decision`, `levels[]`, et
|
||||
* les lignes `[Arbitre]` et `[RelayRouter]` ;
|
||||
* - une charge CONTINUE (etmvariableload) à la même cadence garde `ECO_FLOOR_GRID` ;
|
||||
* - quand la cadence atteint 500 W, `ECO_FLOOR_GRID` revient et le premier palier se ferme.
|
||||
*/
|
||||
void Simulation::testEcoBelowTheFirstStageBuysNothing()
|
||||
{
|
||||
#ifndef ETM_ARBITRATOR
|
||||
QSKIP("nécessite ETM_ARBITRATOR.");
|
||||
#else
|
||||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-sous-palier.json";
|
||||
QFile::remove(cfgPath);
|
||||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||||
cleanupTestCase();
|
||||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||||
initTestCase();
|
||||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||||
QVERIFY(arb);
|
||||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||||
|
||||
const QUuid racineId = addMeter();
|
||||
m_experiencePlugin->energyManager()->setRootMeter(racineId);
|
||||
Thing *racine = tm->findConfiguredThing(racineId);
|
||||
const QUuid compteurId = addMeter(27090);
|
||||
Thing *compteur = tm->findConfiguredThing(compteurId);
|
||||
const QUuid r500 = addPowerSwitch(500, 27091);
|
||||
const QUuid r1000 = addPowerSwitch(1000, 27092);
|
||||
const QUuid r2000 = addPowerSwitch(2000, 27093);
|
||||
const QUuid varId = addEtmVariableLoad(27094);
|
||||
Thing *var = tm->findConfiguredThing(varId);
|
||||
QVERIFY(racine && compteur && var && tm->findConfiguredThing(r500)
|
||||
&& tm->findConfiguredThing(r1000) && tm->findConfiguredThing(r2000));
|
||||
racine->setStateValue("connected", true);
|
||||
racine->setStateValue("currentPower", 3000); // import franc : aucun confort
|
||||
compteur->setStateValue("connected", true);
|
||||
compteur->setStateValue("currentPower", 0); // le mock n'intègre rien
|
||||
compteur->setStateValue("totalEnergyConsumed", 10.0);
|
||||
var->setStateValue("currentPowerW", 0);
|
||||
|
||||
const QVariantMap besoins{{"dailyDeadline", "07:00"}, {"minEnergyWhPerDay", 2000}};
|
||||
QVariantList l;
|
||||
l.append(QVariantMap{{"id", "ecs-palier"}, {"label", "ECS"}, {"adapter", "relay-router"},
|
||||
{"mode", "fixed"}, {"priority", 1}, {"enabled", true},
|
||||
{"minOnS", 0}, {"minOffS", 0}, {"meterThingId", compteurId.toString()},
|
||||
{"relays", QVariantList{QVariantMap{{"thingId", r500.toString()}, {"powerW", 500}},
|
||||
QVariantMap{{"thingId", r1000.toString()}, {"powerW", 1000}},
|
||||
QVariantMap{{"thingId", r2000.toString()}, {"powerW", 2000}}}},
|
||||
{"needs", besoins}});
|
||||
// Sans compteur d'énergie : avancement non mesurable, cadence constante 2000 Wh / 24 h.
|
||||
l.append(QVariantMap{{"id", varId.toString()}, {"label", "Routeur"},
|
||||
{"adapter", "etmvariableload"}, {"mode", "dynamic"},
|
||||
{"maxPowerW", 3000}, {"priority", 2}, {"enabled", true},
|
||||
{"needs", besoins}});
|
||||
QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}}))
|
||||
.toMap().value("params").toMap().value("energyError").toString(),
|
||||
QString("EnergyErrorNoError"));
|
||||
QTest::qWait(100);
|
||||
|
||||
auto cycle = [&](const QDateTime &t) {
|
||||
arb->recordMeterUpdate(t);
|
||||
arb->simulationCallUpdate(t);
|
||||
QTest::qWait(50); // les écritures de relais sont asynchrones : on lit l'état POSÉ
|
||||
};
|
||||
auto charge = [&](const QString &id) { return telemetrieParId(arb->loadTelemetry()).value(id); };
|
||||
auto niveauEco = [&](const QString &id) {
|
||||
for (const QVariant &n : charge(id).value("levels").toList())
|
||||
if (n.toMap().value("level").toString() == QLatin1String("eco"))
|
||||
return n.toMap();
|
||||
return QVariantMap();
|
||||
};
|
||||
auto ferme = [&](const QUuid &r) { return tm->findConfiguredThing(r)->stateValue("power").toBool(); };
|
||||
auto appliqueW = [&]() {
|
||||
return (ferme(r500) ? 500 : 0) + (ferme(r1000) ? 1000 : 0) + (ferme(r2000) ? 2000 : 0);
|
||||
};
|
||||
|
||||
// --- (i) 07:01, la période s'ouvre : 2000 Wh en 23 h 59 ≈ 83 W, sous le palier de 500 W ---
|
||||
const QDateTime t0 = utcDateTime(QDate(2026, 8, 31), QTime(7, 1, 0));
|
||||
cycle(t0);
|
||||
{
|
||||
JournalCapture journal;
|
||||
cycle(t0.addSecs(60));
|
||||
const QStringList lignes = journal.texte().split('\n');
|
||||
// Chaque ligne qui parle du chauffe-eau dit le motif neuf, et jamais l'achat.
|
||||
int vues = 0;
|
||||
for (const QString &ligne : lignes) {
|
||||
const bool ecs = (ligne.contains("[RelayRouter]") && ligne.contains("ECS"))
|
||||
|| (ligne.contains("[Arbitre]") && ligne.contains("ecs-palier"));
|
||||
if (!ecs)
|
||||
continue;
|
||||
++vues;
|
||||
QVERIFY2(ligne.contains("[ECO_FLOOR_BELOW_STAGE]")
|
||||
&& ligne.contains("sous le premier palier (500 W)")
|
||||
&& !ligne.contains("[ECO_FLOOR_GRID]"),
|
||||
qUtf8Printable(ligne));
|
||||
}
|
||||
QVERIFY2(vues >= 2, qUtf8Printable("ni [Arbitre] ni [RelayRouter] pour l'ECS :\n"
|
||||
+ journal.texte()));
|
||||
// La charge continue, elle, achète vraiment : son motif ne change pas.
|
||||
bool varVue = false;
|
||||
for (const QString &ligne : lignes)
|
||||
if (ligne.contains("[EtmVariableLoadAdapter]") && ligne.contains("[ECO_FLOOR_GRID]"))
|
||||
varVue = true;
|
||||
QVERIFY2(varVue, qUtf8Printable(journal.texte()));
|
||||
}
|
||||
|
||||
const QVariantMap decision = charge("ecs-palier").value("decision").toMap();
|
||||
QCOMPARE(decision.value("code").toString(), QString(DecisionCode::EcoFloorBelowStage));
|
||||
QCOMPARE(decision.value("level").toString(), QString("eco"));
|
||||
const QVariantMap eco = niveauEco("ecs-palier");
|
||||
QCOMPARE(qRound(eco.value("targetW").toDouble()), 83); // la consigne ne bouge pas
|
||||
const QVariantMap motif = eco.value("decision").toMap();
|
||||
QCOMPARE(motif.value("code").toString(), QString(DecisionCode::EcoFloorBelowStage));
|
||||
const QVariantMap params = motif.value("params").toMap();
|
||||
QCOMPARE(params.value("gridW").toInt(), 0); // pas un achat
|
||||
QCOMPARE(params.value("stageW").toInt(), 500); // le palier, nommé
|
||||
QCOMPARE(params.value("targetW").toInt(), 83);
|
||||
QCOMPARE(params.value("remainingWh").toInt(), 2000);
|
||||
QVERIFY(params.contains("remainingS"));
|
||||
QCOMPARE(appliqueW(), 0); // relais OUVERT
|
||||
const QVariantMap progres = eco.value("progress").toMap();
|
||||
QCOMPARE(progres.value("regime").toString(), QString("measured"));
|
||||
QCOMPARE(progres.value("purchasedWh").toDouble(), 0.0);
|
||||
|
||||
const QVariantMap continu = niveauEco(varId.toString());
|
||||
QCOMPARE(qRound(continu.value("targetW").toDouble()), 83);
|
||||
QCOMPARE(continu.value("decision").toMap().value("code").toString(),
|
||||
QString(DecisionCode::EcoFloorGrid));
|
||||
QCOMPARE(continu.value("decision").toMap().value("params").toMap().value("gridW").toInt(), 83);
|
||||
QCOMPARE(charge(varId.toString()).value("decision").toMap().value("code").toString(),
|
||||
QString(DecisionCode::EcoFloorGrid));
|
||||
|
||||
// --- (ii) 03:00, rien n'a été livré : 2000 Wh en 4 h = 500 W, le premier palier ----------
|
||||
const QDateTime t1 = utcDateTime(QDate(2026, 9, 1), QTime(3, 0, 0));
|
||||
cycle(t1);
|
||||
const QVariantMap eco500 = niveauEco("ecs-palier");
|
||||
QCOMPARE(qRound(eco500.value("targetW").toDouble()), 500);
|
||||
QCOMPARE(eco500.value("decision").toMap().value("code").toString(),
|
||||
QString(DecisionCode::EcoFloorGrid));
|
||||
QCOMPARE(eco500.value("decision").toMap().value("params").toMap().value("gridW").toInt(), 500);
|
||||
QCOMPARE(charge("ecs-palier").value("decision").toMap().value("code").toString(),
|
||||
QString(DecisionCode::EcoFloorGrid));
|
||||
QVERIFY(ferme(r500));
|
||||
QCOMPARE(appliqueW(), 500); // le premier palier est fermé
|
||||
|
||||
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
|
||||
QFile::remove(cfgPath);
|
||||
#endif
|
||||
}
|
||||
|
||||
/*!
|
||||
* \brief [Commande perdue, étape 2] L'écart se PUBLIE — il ne se diagnostique pas.
|
||||
*
|
||||
|
||||
@ -201,6 +201,8 @@ private slots:
|
||||
void testTheDecisionDescribesTheMergedAction();
|
||||
//! Correctif c — l'acheté se compte sur l'énergie mesurée, à la part éco de l'appliqué.
|
||||
void testPurchasedIsCountedOnTheMeasuredEnergy();
|
||||
//! Sous le premier palier, le niveau éco dit « rien n'est acheté » — consigne inchangée.
|
||||
void testEcoBelowTheFirstStageBuysNothing();
|
||||
void testCommandDivergenceIsPublishedNotDiagnosed();
|
||||
void testProgressMeasurabilityIsDeclaredAtConfigTime();
|
||||
void testThermalProbeFeedsTelemetry();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user