fix(moteur): le motif publié décrit l'action fusionnée (correctif a, etm70)
Quand la passe éco et la passe confort se cumulent sur une charge en consigne de puissance (relay-router, etmvariableload, borne « éco + minimum »), la fusion additionnait les watts sans toucher au motif : une consigne achetée au réseau se publiait « surplus insuffisant ». Règle décidée par Patrick le 2026-10-16 : le motif publié est celui de la part MAJORITAIRE de la consigne, égalité → éco, part éco nulle jamais en tête ; LOAD_UNAVAILABLE garde la tête (préséance L4 > indisponible / dégradé > éco | confort). Les deux parts restent dans levels[]. - decision.level = le niveau dont le motif est publié (publishedLevel), au lieu du « dernier parcouru » ; règle écrite dans INTERFACE.md R7. - Journaux [Arbitre], [RelayRouter], [EtmVariableLoadAdapter] : un helper unique renderLevelsFr() rend la ventilation quand il y a deux niveaux ; ligne inchangée pour une action à un seul niveau. - Aucune consigne, aucun powerW, aucun budget/soutirage ne change. - Test testTheDecisionDescribesTheMergedAction : 3 charges × (éco seule, égalité, confort majoritaire, sans éco, ECO_FLOOR_MET), journal et RPC. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
92f13cf677
commit
e6ee513dcc
@ -124,7 +124,7 @@ LoadAction EtmVariableLoadAdapter::applyAction(const LoadAction &action, const Q
|
||||
qCInfo(dcNymeaEnergy()) << "[EtmVariableLoadAdapter]" << m_label
|
||||
<< "→ setpoint" << qRound(setpointW) << "W"
|
||||
<< (action.force ? "(force)" : "")
|
||||
<< "|" << renderFr(action.reason, m_label);
|
||||
<< "|" << renderLevelsFr(action, m_label);
|
||||
|
||||
m_setpointPrev = m_currentSetpointW;
|
||||
m_setpointTarget = setpointW;
|
||||
|
||||
@ -301,7 +301,7 @@ LoadAction RelayRouter::applyAction(const LoadAction &action, const QDateTime &n
|
||||
<< "→ consigne" << qRound(action.powerW) << "W → palier"
|
||||
<< m_levels.at(newStage) << "W INCHANGÉ (aucune commutation)"
|
||||
<< (action.force ? "(force)" : "")
|
||||
<< "|" << renderFr(action.reason, m_label);
|
||||
<< "|" << renderLevelsFr(action, m_label);
|
||||
return applied; // idempotent — pas de re-commutation
|
||||
}
|
||||
|
||||
@ -309,7 +309,7 @@ LoadAction RelayRouter::applyAction(const LoadAction &action, const QDateTime &n
|
||||
<< "→ consigne" << qRound(action.powerW) << "W → palier"
|
||||
<< m_levels.at(newStage) << "W"
|
||||
<< (action.force ? "(force)" : "")
|
||||
<< "|" << renderFr(action.reason, m_label);
|
||||
<< "|" << renderLevelsFr(action, m_label);
|
||||
|
||||
applyRelayStage(newStage);
|
||||
|
||||
|
||||
@ -482,7 +482,7 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime)
|
||||
|
||||
for (const LoadAction &action : slot.actions) {
|
||||
qCInfo(dcNymeaEnergy()) << "[Arbitre]"
|
||||
<< action.loadId << "→" << renderFr(action.reason, libelles.value(action.loadId))
|
||||
<< action.loadId << "→" << renderLevelsFr(action, libelles.value(action.loadId))
|
||||
<< "| activé:" << action.chargingEnabled
|
||||
<< "| courant:" << action.currentA << "A"
|
||||
<< "| phases:" << action.phaseCount
|
||||
@ -2024,13 +2024,13 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const
|
||||
// Aujourd'hui il n'y a qu'un niveau et la question ne se pose pas — c'est précisément
|
||||
// pourquoi le champ s'ajoute MAINTENANT : quand elle se posera, les clients le liront
|
||||
// déjà. Voir R2, même raisonnement sur le coût de déplacement.
|
||||
// Le niveau dont `decision` parle : le DERNIER parcouru. La règle est arbitraire mais
|
||||
// elle doit être UNE, et publiée : sans elle chaque client réinventerait sa fusion, et
|
||||
// deux écrans afficheraient deux motifs pour le même cycle. Le dernier parcouru est
|
||||
// aussi celui qui a eu le dernier mot sur la consigne, donc celui qu'on explique.
|
||||
// Le niveau dont `decision` parle : CELUI DONT LE MOTIF EST PUBLIÉ (correctif a, qui
|
||||
// remplace « le dernier parcouru »). La règle doit être UNE, et publiée : sans elle chaque
|
||||
// client réinventerait sa fusion, et deux écrans afficheraient deux motifs pour le même
|
||||
// cycle. Le motif est celui de la part majoritaire de la consigne (INTERFACE.md R7).
|
||||
QVariantMap decision = action.reason.toMap();
|
||||
if (!action.levels.isEmpty())
|
||||
decision.insert(QStringLiteral("level"), action.levels.last().level);
|
||||
decision.insert(QStringLiteral("level"), publishedLevel(action));
|
||||
entry.insert(QStringLiteral("decision"), decision);
|
||||
|
||||
const LoadRuntimeView v = adapter->runtimeView(now);
|
||||
|
||||
@ -12,6 +12,31 @@
|
||||
#include <QUuid>
|
||||
#include <algorithm>
|
||||
|
||||
namespace {
|
||||
|
||||
/*
|
||||
* PRÉSÉANCE DU MOTIF À LA FUSION (correctif a, règle décidée par Patrick le 2026-10-16).
|
||||
*
|
||||
* délestage L4 > indisponible / dégradé > (optimiseur, brief 4 T3) > éco | confort selon la part
|
||||
*
|
||||
* Seuls les deux derniers termes se jouent ICI. L4 remplace l'action entière après le plan
|
||||
* (fusionnerDelestageDansSlot) et le mode dégradé ne planifie pas : ni l'un ni l'autre ne
|
||||
* passe par cette fusion. L'indisponibilité, elle, y passe — c'est le motif confort
|
||||
* LOAD_UNAVAILABLE, et il garde la tête : une charge qu'on ne sait plus commander n'achète rien.
|
||||
*
|
||||
* Entre éco et confort, la PART MAJORITAIRE de la consigne, l'égalité allant à l'éco ; une part
|
||||
* éco nulle ne prend jamais la tête (ECO_FLOOR_MET à 0 W laisse parler le confort).
|
||||
*/
|
||||
bool leMotifEcoPrendLaTete(const DecisionReason &motifConfort, const LoadAction::Level &eco,
|
||||
double confortW)
|
||||
{
|
||||
if (motifConfort.code == QLatin1String(DecisionCode::LoadUnavailable))
|
||||
return false;
|
||||
return eco.targetW > 0 && eco.targetW >= confortW;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
RuleBasedScheduler::RuleBasedScheduler(EnergyArbitrator *arbitrator, QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_arbitrator(arbitrator)
|
||||
@ -498,6 +523,7 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
|
||||
if (!slot.actions.isEmpty() && niveauxEco.contains(lc.id)) {
|
||||
LoadAction &la = slot.actions.last();
|
||||
const LoadAction::Level eco = niveauxEco.take(lc.id);
|
||||
const double confortW = la.estimatedPowerW; // la part confort, avant l'addition
|
||||
la.levels.prepend(eco); // éco AVANT confort : l'ordre des passes
|
||||
la.powerW += eco.targetW;
|
||||
la.estimatedPowerW += eco.targetW;
|
||||
@ -505,6 +531,12 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
|
||||
la.counts[it.key()] += it.value();
|
||||
if (eco.targetW > 0)
|
||||
la.chargingEnabled = true;
|
||||
// MOTIF = ACTION (correctif a, règle décidée le 2026-10-16). La fusion additionnait
|
||||
// les watts sans toucher au motif : une consigne achetée aux deux tiers se publiait
|
||||
// « surplus insuffisant ». Le motif publié est désormais celui de la part qui pèse
|
||||
// le plus dans la consigne — et les deux parts restent lisibles dans `levels`.
|
||||
if (la.kind == LoadAction::Setpoint && leMotifEcoPrendLaTete(la.reason, eco, confortW))
|
||||
la.reason = eco.reason;
|
||||
}
|
||||
|
||||
if (margeRestante.borne && !slot.actions.isEmpty())
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
|
||||
#include "decisionreason.h"
|
||||
#include "ecoprogress.h"
|
||||
#include "loadaction.h"
|
||||
#include <QLocale>
|
||||
#include <QStringList>
|
||||
|
||||
@ -302,6 +303,34 @@ QString renderFr(const DecisionReason &reason, const QString &label)
|
||||
.arg(c, kv.isEmpty() ? QString() : QStringLiteral(" "), kv.join(QStringLiteral(", ")));
|
||||
}
|
||||
|
||||
QString publishedLevel(const LoadAction &action)
|
||||
{
|
||||
// Le niveau qui PORTE le motif publié. La fusion ne fabrique jamais de motif : elle choisit
|
||||
// celui d'un niveau, donc il s'y retrouve à l'identique (code ET paramètres).
|
||||
for (const LoadAction::Level &n : action.levels)
|
||||
if (n.reason.code == action.reason.code && n.reason.params == action.reason.params)
|
||||
return n.level;
|
||||
return action.levels.isEmpty() ? QString() : action.levels.last().level;
|
||||
}
|
||||
|
||||
QString renderLevelsFr(const LoadAction &action, const QString &label)
|
||||
{
|
||||
const QString motif = renderFr(action.reason, label);
|
||||
if (action.levels.size() < 2)
|
||||
return motif;
|
||||
QStringList parts;
|
||||
for (const LoadAction::Level &n : action.levels) {
|
||||
const QString nom = n.level == QLatin1String("eco") ? QStringLiteral("éco")
|
||||
: n.level == QLatin1String("comfort") ? QStringLiteral("confort")
|
||||
: n.level;
|
||||
parts << QStringLiteral("%1 %2 W [%3]").arg(nom).arg(qRound(n.targetW))
|
||||
.arg(n.reason.isEmpty() ? QStringLiteral("—") : n.reason.code);
|
||||
}
|
||||
return QStringLiteral("%1 — ventilation : %2 = %3 W")
|
||||
.arg(motif, parts.join(QStringLiteral(" + ")))
|
||||
.arg(qRound(action.estimatedPowerW));
|
||||
}
|
||||
|
||||
QStringList DecisionCode::knownCodes()
|
||||
{
|
||||
using namespace DecisionCode;
|
||||
|
||||
@ -471,3 +471,37 @@ struct DecisionReason
|
||||
* ferait passer un motif manquant pour un motif absent.
|
||||
*/
|
||||
QString renderFr(const DecisionReason &reason, const QString &label = QString());
|
||||
|
||||
struct LoadAction;
|
||||
|
||||
/*!
|
||||
* \brief Le NIVEAU dont le motif d'une action est publié — `decision.level` (R7).
|
||||
*
|
||||
* Règle publiée (INTERFACE.md R7, correctif a du 2026-10-16) : le niveau dont `action.reason` est
|
||||
* le motif, retrouvé dans \c action.levels. À la fusion des passes, ce motif est celui de la part
|
||||
* MAJORITAIRE de la consigne (égalité → éco), sous la préséance
|
||||
* L4 > indisponible / dégradé > éco | confort.
|
||||
*
|
||||
* \param action Action émise, niveaux compris.
|
||||
* \return Le \c level (« eco », « comfort ») du niveau dont le motif est publié ; à défaut de
|
||||
* niveau qui le porte, le dernier parcouru (forme d'avant le correctif) ; vide sans niveau —
|
||||
* et alors `decision.level` est absent avec `levels[]` (R7).
|
||||
*/
|
||||
QString publishedLevel(const LoadAction &action);
|
||||
|
||||
/*!
|
||||
* \brief Rend la ligne FRANÇAISE d'une action, avec la VENTILATION par niveau quand il y en a
|
||||
* plusieurs.
|
||||
*
|
||||
* Helper UNIQUE des journaux `[Arbitre]`, `[RelayRouter]` et `[EtmVariableLoadAdapter]` : trois
|
||||
* rendus divergeraient. Une action à un seul niveau rend exactement \c renderFr() — le journal
|
||||
* ne change pas quand il n'y a rien à ventiler. À deux niveaux ou plus :
|
||||
* « <motif publié> — ventilation : éco 500 W [ECO_FLOOR_GRID] + confort 500 W [LOCK_MIN_ON]
|
||||
* = 1000 W ». Les codes, pas leurs phrases : la phrase du motif publié est déjà en tête, et une
|
||||
* ligne qui en rendrait trois deviendrait illisible.
|
||||
*
|
||||
* \param action Action à rendre (\c reason, \c levels, \c estimatedPowerW).
|
||||
* \param label Libellé de la charge, passé à \c renderFr().
|
||||
* \return La ligne ; jamais vide (même règle que \c renderFr()).
|
||||
*/
|
||||
QString renderLevelsFr(const LoadAction &action, const QString &label = QString());
|
||||
|
||||
@ -7933,6 +7933,208 @@ void Simulation::testEcoFloorMissedSaysWhatWasLostAndWhy()
|
||||
#endif
|
||||
}
|
||||
|
||||
/*!
|
||||
* \brief [Correctif a] Le motif publié décrit l'action FUSIONNÉE — celui de la part majoritaire.
|
||||
*
|
||||
* \par Le défaut
|
||||
* La fusion des passes additionnait les watts de l'éco à ceux du confort sans toucher au motif :
|
||||
* une consigne achetée en entier au réseau se publiait « surplus insuffisant », `decision.level`
|
||||
* valait « comfort », et le journal du routeur affichait la consigne fusionnée à côté du motif
|
||||
* de la seule part confort. Constaté au banc (RAPPORT_CHAUFFE_EAU.md).
|
||||
*
|
||||
* \par La règle (décision du 2026-10-16)
|
||||
* Le motif de tête est celui de la part MAJORITAIRE de la consigne, l'égalité allant à l'éco ;
|
||||
* une part éco nulle ne prend jamais la tête. Les deux parts restent dans `levels`. Trois
|
||||
* charges en consigne de puissance passent par la même fusion — relay-router, etmvariableload,
|
||||
* borne « éco + minimum » — et chacune est éprouvée sur les mêmes cas.
|
||||
*/
|
||||
void Simulation::testTheDecisionDescribesTheMergedAction()
|
||||
{
|
||||
#ifndef ETM_ARBITRATOR
|
||||
QSKIP("nécessite ETM_ARBITRATOR.");
|
||||
#else
|
||||
cleanupTestCase();
|
||||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||||
initTestCase();
|
||||
|
||||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||||
QVERIFY(arb);
|
||||
RuleBasedScheduler sched(arb);
|
||||
|
||||
// 05:00 pour une échéance à 07:00 : 2000 Wh en 2 h, soit une cadence de 1000 W (mesurée).
|
||||
const QDateTime t = utcDateTime(QDate(2026, 8, 31), QTime(5, 0, 0));
|
||||
|
||||
enum class Besoin { Aucun, Obligation, Minimum };
|
||||
auto charge = [](const QString &type, const QString &id, Besoin besoin) {
|
||||
LoadContext lc;
|
||||
lc.id = id;
|
||||
lc.label = type;
|
||||
lc.adapter = type;
|
||||
lc.priority = 1;
|
||||
if (type == QLatin1String("relay-router")) {
|
||||
lc.declared.powerLevels = {0, 500, 1000, 1500, 2000};
|
||||
lc.declared.maxPowerW = 2000;
|
||||
} else if (type == QLatin1String("etmvariableload")) {
|
||||
lc.declared.maxPowerW = 3000;
|
||||
} else {
|
||||
lc.declared.maxPowerW = 7000;
|
||||
lc.declared.minPowerW = 1000;
|
||||
lc.declared.phases = 1;
|
||||
lc.telemetry.pluggedIn = true;
|
||||
}
|
||||
if (besoin == Besoin::Obligation) {
|
||||
lc.needs.minEnergyWhPerDay = 2000;
|
||||
lc.needs.dailyDeadline = "07:00";
|
||||
lc.telemetry.sessionMeasurable = true;
|
||||
} else if (besoin == Besoin::Minimum) {
|
||||
lc.needs.minPowerWAlways = 1000; // la borne « éco + minimum »
|
||||
}
|
||||
return lc;
|
||||
};
|
||||
auto planifier = [&](const LoadContext &lc, double exportW) -> LoadAction {
|
||||
SurplusContext ctx;
|
||||
ctx.timestamp = t;
|
||||
ctx.meter.exportW = exportW;
|
||||
ctx.meter.importW = exportW > 0 ? 0 : 500;
|
||||
ctx.loads.append(lc);
|
||||
return sched.getPlan(ctx).timeSlots.first().actions.first();
|
||||
};
|
||||
auto niveau = [](const LoadAction &a, const char *nom) -> LoadAction::Level {
|
||||
for (const LoadAction::Level &n : a.levels)
|
||||
if (n.level == QLatin1String(nom))
|
||||
return n;
|
||||
return {};
|
||||
};
|
||||
|
||||
// Sur les trois charges, la part éco vaut 1000 W : la cadence de l'obligation pour les deux
|
||||
// premières, le minimum pour la borne. Les exports ci-dessous dosent la part confort.
|
||||
for (const QString &type : {QStringLiteral("relay-router"), QStringLiteral("etmvariableload"),
|
||||
QStringLiteral("evcharger")}) {
|
||||
const Besoin eco = type == QLatin1String("evcharger") ? Besoin::Minimum : Besoin::Obligation;
|
||||
const QByteArray quoi = type.toUtf8();
|
||||
|
||||
// (i) éco seule, confort nul → le motif de l'achat, et le niveau qui le porte.
|
||||
const LoadAction seul = planifier(charge(type, type + "-i", eco), 0);
|
||||
QCOMPARE(qRound(niveau(seul, "eco").targetW), 1000);
|
||||
QCOMPARE(qRound(niveau(seul, "comfort").targetW), 0);
|
||||
QVERIFY2(seul.reason.code == QLatin1String(DecisionCode::EcoFloorGrid),
|
||||
qUtf8Printable(QStringLiteral("%1 (i) : %2").arg(type, seul.reason.code)));
|
||||
QCOMPARE(publishedLevel(seul), QString("eco"));
|
||||
// La part confort RESTE lisible : la règle choisit la tête, elle n'efface rien.
|
||||
QVERIFY2(!niveau(seul, "comfort").reason.isEmpty(), quoi.constData());
|
||||
|
||||
// (ii) éco = confort → égalité, la tête va à l'éco.
|
||||
const LoadAction egal = planifier(charge(type, type + "-ii", eco), 1000);
|
||||
QCOMPARE(qRound(niveau(egal, "comfort").targetW), 1000);
|
||||
QVERIFY2(egal.reason.code == QLatin1String(DecisionCode::EcoFloorGrid),
|
||||
qUtf8Printable(QStringLiteral("%1 (ii) : %2").arg(type, egal.reason.code)));
|
||||
QCOMPARE(publishedLevel(egal), QString("eco"));
|
||||
QCOMPARE(niveau(egal, "comfort").reason.code, QString(DecisionCode::SurplusSetpoint));
|
||||
|
||||
// (ii-b) confort > éco → le motif confort reste en tête, l'éco reste dans les niveaux.
|
||||
const LoadAction confort = planifier(charge(type, type + "-iib", eco), 1500);
|
||||
QCOMPARE(qRound(niveau(confort, "comfort").targetW), 1500);
|
||||
QVERIFY2(confort.reason.code == QLatin1String(DecisionCode::SurplusSetpoint),
|
||||
qUtf8Printable(QStringLiteral("%1 (ii-b) : %2").arg(type, confort.reason.code)));
|
||||
QCOMPARE(publishedLevel(confort), QString("comfort"));
|
||||
QCOMPARE(niveau(confort, "eco").reason.code, QString(DecisionCode::EcoFloorGrid));
|
||||
|
||||
// (iii) pas d'éco du tout → motif confort inchangé (non-régression).
|
||||
const LoadAction sans = planifier(charge(type, type + "-iii", Besoin::Aucun), 1500);
|
||||
QCOMPARE(sans.levels.count(), 1);
|
||||
QCOMPARE(sans.reason.code, QString(DecisionCode::SurplusSetpoint));
|
||||
QCOMPARE(publishedLevel(sans), QString("comfort"));
|
||||
|
||||
// (iv) obligation TENUE — niveau éco à 0 W (ECO_FLOOR_MET) : une part nulle ne prend
|
||||
// jamais la tête, même devant un confort nul.
|
||||
LoadContext tenue = charge(type, type + "-iv", Besoin::Obligation);
|
||||
tenue.telemetry.sessionWh = 0;
|
||||
planifier(tenue, 0); // base prise ici
|
||||
tenue.telemetry.sessionWh = 2500; // 2500 Wh livrés sur 2000
|
||||
const LoadAction met = planifier(tenue, 0);
|
||||
QCOMPARE(niveau(met, "eco").reason.code, QString(DecisionCode::EcoFloorMet));
|
||||
QVERIFY2(met.reason.code != QLatin1String(DecisionCode::EcoFloorMet),
|
||||
qUtf8Printable(QStringLiteral("%1 (iv) : %2").arg(type, met.reason.code)));
|
||||
QCOMPARE(met.reason.code, niveau(met, "comfort").reason.code);
|
||||
QCOMPARE(publishedLevel(met), QString("comfort"));
|
||||
}
|
||||
|
||||
// --- Le JOURNAL et la RPC, par le cycle réel de l'arbitre --------------------------------
|
||||
// Le motif publié et la ventilation doivent sortir là où on les lit : la ligne [Arbitre],
|
||||
// celle de l'adaptateur, et `decision` dans GetLoadTelemetry.
|
||||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-motif-action.json";
|
||||
QFile::remove(cfgPath);
|
||||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||||
cleanupTestCase();
|
||||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||||
initTestCase();
|
||||
arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||||
QVERIFY(arb);
|
||||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||||
|
||||
QUuid meterId = addMeter();
|
||||
m_experiencePlugin->energyManager()->setRootMeter(meterId);
|
||||
Thing *meter = tm->findConfiguredThing(meterId);
|
||||
QVERIFY(meter);
|
||||
meter->setStateValue("connected", true);
|
||||
QUuid relaisId = addPowerSwitch(1000, 27070);
|
||||
QUuid varId = addEtmVariableLoad(27071);
|
||||
Thing *var = tm->findConfiguredThing(varId);
|
||||
QVERIFY(var && tm->findConfiguredThing(relaisId));
|
||||
var->setStateValue("currentPowerW", 0);
|
||||
|
||||
// Sans compteur d'énergie, l'avancement n'est pas mesurable : la cadence est constante,
|
||||
// 2000 Wh / 24 h ≈ 83 W — une part éco non nulle, et un confort nul sous import.
|
||||
const QVariantMap besoins{{"dailyDeadline", "07:00"}, {"minEnergyWhPerDay", 2000}};
|
||||
QVariantList l;
|
||||
l.append(QVariantMap{{"id", "ecs-motif"}, {"label", "ECS"}, {"adapter", "relay-router"},
|
||||
{"mode", "fixed"}, {"priority", 1}, {"enabled", true},
|
||||
{"relays", QVariantList() << QVariantMap{{"thingId", relaisId.toString()},
|
||||
{"powerW", 1000}}},
|
||||
{"minOnS", 0}, {"minOffS", 0}, {"needs", besoins}});
|
||||
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"));
|
||||
|
||||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||||
meter->setStateValue("currentPower", 3000); // import franc : aucun surplus
|
||||
{
|
||||
JournalCapture journal;
|
||||
arb->recordMeterUpdate(t0);
|
||||
arb->simulationCallUpdate(t0);
|
||||
QCoreApplication::processEvents();
|
||||
|
||||
const QString texte = journal.texte();
|
||||
for (const char *prefixe : {"[RelayRouter]", "[EtmVariableLoadAdapter]", "[Arbitre]"}) {
|
||||
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"))
|
||||
vu = true;
|
||||
QVERIFY2(vu, qUtf8Printable(QStringLiteral("%1 : aucune ligne ne rend le motif éco et "
|
||||
"la ventilation\n%2").arg(prefixe, texte)));
|
||||
}
|
||||
}
|
||||
|
||||
const QHash<QString, QVariantMap> parId = telemetrieParId(arb->loadTelemetry());
|
||||
for (const QString &id : {QStringLiteral("ecs-motif"), varId.toString()}) {
|
||||
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("level").toString(), QString("eco"));
|
||||
QCOMPARE(ch.value("levels").toList().count(), 2);
|
||||
}
|
||||
|
||||
qunsetenv("NYMEA_ENERGY_LOAD_CONFIG");
|
||||
QFile::remove(cfgPath);
|
||||
#endif
|
||||
}
|
||||
|
||||
/*!
|
||||
* \brief [Commande perdue, étape 2] L'écart se PUBLIE — il ne se diagnostique pas.
|
||||
*
|
||||
|
||||
@ -197,6 +197,8 @@ private slots:
|
||||
void testEcoProgressBaseAndRegime();
|
||||
void testEcoPassBuysAndSaysTheTrajectory();
|
||||
void testEcoFloorMissedSaysWhatWasLostAndWhy();
|
||||
//! Correctif a — le motif publié est celui de la part majoritaire de la consigne fusionnée.
|
||||
void testTheDecisionDescribesTheMergedAction();
|
||||
void testCommandDivergenceIsPublishedNotDiagnosed();
|
||||
void testProgressMeasurabilityIsDeclaredAtConfigTime();
|
||||
void testThermalProbeFeedsTelemetry();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user