fix(eco): ECO_FLOOR_BELOW_STAGE — sous le premier palier, le niveau éco dit « rien n'est acheté »

Sur une charge à paliers (powerLevels déclarés : relay-router, etmvariableload
fixed), une cadence éco sous le premier palier laisse le mécanisme au palier 0.
Le niveau éco publiait pourtant ECO_FLOOR_GRID « 83 W achetés au réseau ».
Il porte désormais ECO_FLOOR_BELOW_STAGE {targetW, stageW, gridW: 0,
o:remainingWh, o:remainingS}. Le premier palier est declared.powerLevels[1],
la source de BELOW_MIN_POWER en passe confort. Une charge continue garde
ECO_FLOOR_GRID.

Aucune consigne ne change : même targetW, mêmes counts, même draw, même
palier, même purchasedWh. Seul le motif du niveau change, et avec lui (R7,
inchangée) decision, decision.level et les lignes [Arbitre]/[RelayRouter].
Hors liste blanche optimiseur : l'optimiseur ne reçoit pas les paliers (E9).

Tests : testEcoBelowTheFirstStageBuysNothing (neuf, échoue sur 61db6cf),
testTheDecisionDescribesTheMergedAction mis à jour (il exigeait l'achat sur
un relais de 1000 W à 83 W), rendu FR et refus optimiseur en loadmodel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-19 08:43:24 +02:00
parent 61db6cfd66
commit cd7cd1a37a
7 changed files with 262 additions and 11 deletions

View File

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

View File

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

View File

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

View File

@ -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é"));
}

View File

@ -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();

View File

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

View File

@ -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();