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:
parent
61db6cfd66
commit
cd7cd1a37a
@ -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.
|
||||
*
|
||||
|
||||
@ -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