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"));
|
: QStringLiteral("obligation"));
|
||||||
}
|
}
|
||||||
eco.reason = { DecisionCode::EcoFloorGrid, motif };
|
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);
|
niveauxEco.insert(lc.id, eco);
|
||||||
|
|
||||||
draw.committedW += achatW;
|
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
|
// 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.
|
// 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 int restantS = p.value(QStringLiteral("remainingS")).toInt();
|
||||||
const QString delai = restantS >= 3600
|
return restantS >= 3600 ? QStringLiteral("%1 h").arg(restantS / 3600)
|
||||||
? QStringLiteral("%1 h").arg(restantS / 3600)
|
: QStringLiteral("%1 min").arg(qMax(1, restantS / 60));
|
||||||
: QStringLiteral("%1 min").arg(qMax(1, restantS / 60));
|
};
|
||||||
|
if (c == QLatin1String(DecisionCode::EcoFloorGrid)) {
|
||||||
|
const QString delai = delaiEco();
|
||||||
if (p.value(QStringLiteral("atMaximum")).toBool())
|
if (p.value(QStringLiteral("atMaximum")).toBool())
|
||||||
return QStringLiteral("Obligation de %1 : %2 W ACHETÉS au maximum — il reste %3 Wh "
|
return QStringLiteral("Obligation de %1 : %2 W ACHETÉS au maximum — il reste %3 Wh "
|
||||||
"à livrer et %4, l'étalement ne suffit plus")
|
"à 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)")
|
"livrer en %4 (cadence recalculée à chaque cycle)")
|
||||||
.arg(label).arg(w("gridW")).arg(w("remainingWh")).arg(delai);
|
.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))
|
if (c == QLatin1String(DecisionCode::RecreditSuspended))
|
||||||
return QStringLiteral("%1 ne suit pas la commande depuis %2 s — %3 W NON rendus au "
|
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")
|
"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,
|
return {EvSpotMarket, EvDeadline, EvSurplus, EvEcoMin, EvIdle,
|
||||||
LoadUnavailable, LockMinOn, LockMinOff, LockMinOffCapped, SurplusSetpoint,
|
LoadUnavailable, LockMinOn, LockMinOff, LockMinOffCapped, SurplusSetpoint,
|
||||||
SurplusInsufficient, BatteryReserve, BelowMinPower, EvGridStart, PhaseLimit, DrawCap,
|
SurplusInsufficient, BatteryReserve, BelowMinPower, EvGridStart, PhaseLimit, DrawCap,
|
||||||
EcoFloorGrid, EcoFloorMet, EcoFloorMissed, RecreditSuspended, DerogationActive,
|
EcoFloorGrid, EcoFloorBelowStage, EcoFloorMet, EcoFloorMissed, RecreditSuspended,
|
||||||
|
DerogationActive,
|
||||||
DerogationEnded, MissingForecastSite, ForecastPayloadRefused, SpotMarketNotInBuild,
|
DerogationEnded, MissingForecastSite, ForecastPayloadRefused, SpotMarketNotInBuild,
|
||||||
ForecastDateUnreadable, TariffRefused,
|
ForecastDateUnreadable, TariffRefused,
|
||||||
LockMinStateHold, SgForced, SgRecommended, SgNormal,
|
LockMinStateHold, SgForced, SgRecommended, SgNormal,
|
||||||
|
|||||||
@ -203,6 +203,32 @@ constexpr const char *DrawCap = "DRAW_CAP";
|
|||||||
*/
|
*/
|
||||||
constexpr const char *EcoFloorGrid = "ECO_FLOOR_GRID";
|
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.
|
* \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::LoadUnavailable, DecisionCode::DrawCap,
|
||||||
DecisionCode::PhaseLimit, DecisionCode::DerogationActive,
|
DecisionCode::PhaseLimit, DecisionCode::DerogationActive,
|
||||||
DecisionCode::DerogationEnded, DecisionCode::EcoFloorMet,
|
DecisionCode::DerogationEnded, DecisionCode::EcoFloorMet,
|
||||||
|
DecisionCode::EcoFloorBelowStage, // l'optimiseur ignore les paliers (E9)
|
||||||
DecisionCode::EcoFloorMissed, DecisionCode::LockMinOn,
|
DecisionCode::EcoFloorMissed, DecisionCode::LockMinOn,
|
||||||
DecisionCode::LockMinOff, DecisionCode::LockMinOffCapped,
|
DecisionCode::LockMinOff, DecisionCode::LockMinOffCapped,
|
||||||
DecisionCode::LockMinStateHold, DecisionCode::RecreditSuspended,
|
DecisionCode::LockMinStateHold, DecisionCode::RecreditSuspended,
|
||||||
@ -80,3 +81,22 @@ void TestLoadModel::testOptimizerMayEmitOnlyWhitelistedCodes()
|
|||||||
QCOMPARE(renderFr(DecisionReason(DecisionCode::PriceSlot, {{"priceCtkWh", 9.5}}), QStringLiteral("PAC")),
|
QCOMPARE(renderFr(DecisionReason(DecisionCode::PriceSlot, {{"priceCtkWh", 9.5}}), QStringLiteral("PAC")),
|
||||||
QStringLiteral("Prix favorable (9,5 ct/kWh) — PAC servie sur ce créneau"));
|
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();
|
void testDecisionCodesListIsComplete();
|
||||||
//! Liste blanche de l'optimiseur (E14) : ni sécurité, ni verrou, ni état du moteur.
|
//! Liste blanche de l'optimiseur (E14) : ni sécurité, ni verrou, ni état du moteur.
|
||||||
void testOptimizerMayEmitOnlyWhitelistedCodes();
|
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)
|
// Brief 4 T3 — plan d'un optimiseur (OptimizerPlan, fonctions pures)
|
||||||
void testOptimizerParamKeysMatchInterface();
|
void testOptimizerParamKeysMatchInterface();
|
||||||
|
|||||||
@ -8209,24 +8209,31 @@ void Simulation::testTheDecisionDescribesTheMergedAction()
|
|||||||
arb->simulationCallUpdate(t0);
|
arb->simulationCallUpdate(t0);
|
||||||
QCoreApplication::processEvents();
|
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();
|
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;
|
bool vu = false;
|
||||||
for (const QString &ligne : texte.split('\n'))
|
for (const QString &ligne : texte.split('\n'))
|
||||||
if (ligne.contains(QLatin1String(prefixe)) && ligne.contains("ventilation")
|
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;
|
vu = true;
|
||||||
QVERIFY2(vu, qUtf8Printable(QStringLiteral("%1 : aucune ligne ne rend le motif éco et "
|
QVERIFY2(vu, qUtf8Printable(QStringLiteral("%1 : aucune ligne ne rend le motif éco %2 et "
|
||||||
"la ventilation\n%2").arg(prefixe, texte)));
|
"la ventilation\n%3").arg(prefixe, code, texte)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const QHash<QString, QVariantMap> parId = telemetrieParId(arb->loadTelemetry());
|
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);
|
const QVariantMap ch = parId.value(id);
|
||||||
QVERIFY2(!ch.isEmpty(), qUtf8Printable(id));
|
QVERIFY2(!ch.isEmpty(), qUtf8Printable(id));
|
||||||
const QVariantMap decision = ch.value("decision").toMap();
|
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(decision.value("level").toString(), QString("eco"));
|
||||||
QCOMPARE(ch.value("levels").toList().count(), 2);
|
QCOMPARE(ch.value("levels").toList().count(), 2);
|
||||||
}
|
}
|
||||||
@ -8415,6 +8422,166 @@ void Simulation::testPurchasedIsCountedOnTheMeasuredEnergy()
|
|||||||
#endif
|
#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.
|
* \brief [Commande perdue, étape 2] L'écart se PUBLIE — il ne se diagnostique pas.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -201,6 +201,8 @@ private slots:
|
|||||||
void testTheDecisionDescribesTheMergedAction();
|
void testTheDecisionDescribesTheMergedAction();
|
||||||
//! Correctif c — l'acheté se compte sur l'énergie mesurée, à la part éco de l'appliqué.
|
//! Correctif c — l'acheté se compte sur l'énergie mesurée, à la part éco de l'appliqué.
|
||||||
void testPurchasedIsCountedOnTheMeasuredEnergy();
|
void testPurchasedIsCountedOnTheMeasuredEnergy();
|
||||||
|
//! Sous le premier palier, le niveau éco dit « rien n'est acheté » — consigne inchangée.
|
||||||
|
void testEcoBelowTheFirstStageBuysNothing();
|
||||||
void testCommandDivergenceIsPublishedNotDiagnosed();
|
void testCommandDivergenceIsPublishedNotDiagnosed();
|
||||||
void testProgressMeasurabilityIsDeclaredAtConfigTime();
|
void testProgressMeasurabilityIsDeclaredAtConfigTime();
|
||||||
void testThermalProbeFeedsTelemetry();
|
void testThermalProbeFeedsTelemetry();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user