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:
Patrick Schurig 2026-10-16 20:11:51 +02:00
parent 92f13cf677
commit e6ee513dcc
8 changed files with 308 additions and 9 deletions

View File

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

View File

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

View File

@ -482,7 +482,7 @@ void EnergyArbitrator::update(const QDateTime &currentDateTime)
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);

View File

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

View File

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

View File

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

View File

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

View File

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