diff --git a/docs/OPTIMIZER_PROTOCOL.md b/docs/OPTIMIZER_PROTOCOL.md index b2fff25..25c20c8 100644 --- a/docs/OPTIMIZER_PROTOCOL.md +++ b/docs/OPTIMIZER_PROTOCOL.md @@ -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`) diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index 9261f18..cede9e9 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -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 &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(cible.remainingS)); + } + eco.reason = { DecisionCode::EcoFloorBelowStage, sous }; + } niveauxEco.insert(lc.id, eco); draw.committedW += achatW; diff --git a/energyplugin/etm/types/decisionreason.cpp b/energyplugin/etm/types/decisionreason.cpp index 1c9dc1f..150fbe3 100644 --- a/energyplugin/etm/types/decisionreason.cpp +++ b/energyplugin/etm/types/decisionreason.cpp @@ -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, diff --git a/energyplugin/etm/types/decisionreason.h b/energyplugin/etm/types/decisionreason.h index 47a83a9..e7d9777 100644 --- a/energyplugin/etm/types/decisionreason.h +++ b/energyplugin/etm/types/decisionreason.h @@ -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. * diff --git a/tests/auto/decisions/etm-powersync-compare-hors-motifs.py b/tests/auto/decisions/etm-powersync-compare-hors-motifs.py index 5da79fb..e01cd8d 100755 --- a/tests/auto/decisions/etm-powersync-compare-hors-motifs.py +++ b/tests/auto/decisions/etm-powersync-compare-hors-motifs.py @@ -2,7 +2,8 @@ # Comparateur « IDENTIQUE HORS MOTIFS » (correctifs moteur a et b, etm70). # # etm-powersync-compare-hors-motifs.py [--cas ] -# [--achat] [--strategie] [--lignes ] +# [--achat] [--strategie] [--sous-palier] +# [--lignes ] # # 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 "")}') diff --git a/tests/auto/decisions/reference/decisions.ndjson.xz b/tests/auto/decisions/reference/decisions.ndjson.xz index 421c0ea..76309d6 100644 Binary files a/tests/auto/decisions/reference/decisions.ndjson.xz and b/tests/auto/decisions/reference/decisions.ndjson.xz differ diff --git a/tests/auto/loadmodel/testdecisioncatalogue.cpp b/tests/auto/loadmodel/testdecisioncatalogue.cpp index d347a86..c339e70 100644 --- a/tests/auto/loadmodel/testdecisioncatalogue.cpp +++ b/tests/auto/loadmodel/testdecisioncatalogue.cpp @@ -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é")); +} diff --git a/tests/auto/loadmodel/testloadmodel.h b/tests/auto/loadmodel/testloadmodel.h index d7511c8..711f4dc 100644 --- a/tests/auto/loadmodel/testloadmodel.h +++ b/tests/auto/loadmodel/testloadmodel.h @@ -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(); diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index efde7d3..d4f4002 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -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 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(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. * diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 5330777..1145cfc 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -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();