diff --git a/energyplugin/etm/adapters/etmvariableloadadapter.cpp b/energyplugin/etm/adapters/etmvariableloadadapter.cpp index 3cc4999..b64c46e 100644 --- a/energyplugin/etm/adapters/etmvariableloadadapter.cpp +++ b/energyplugin/etm/adapters/etmvariableloadadapter.cpp @@ -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; diff --git a/energyplugin/etm/adapters/relayrouter.cpp b/energyplugin/etm/adapters/relayrouter.cpp index 0d8c962..6ff7b10 100644 --- a/energyplugin/etm/adapters/relayrouter.cpp +++ b/energyplugin/etm/adapters/relayrouter.cpp @@ -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); diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 69bebba..3a8c1f7 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -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); diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index 73acdb5..ffc3bb6 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -12,6 +12,31 @@ #include #include +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()) diff --git a/energyplugin/etm/types/decisionreason.cpp b/energyplugin/etm/types/decisionreason.cpp index 282c710..9e5f2a9 100644 --- a/energyplugin/etm/types/decisionreason.cpp +++ b/energyplugin/etm/types/decisionreason.cpp @@ -3,6 +3,7 @@ #include "decisionreason.h" #include "ecoprogress.h" +#include "loadaction.h" #include #include @@ -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; diff --git a/energyplugin/etm/types/decisionreason.h b/energyplugin/etm/types/decisionreason.h index b48a1e6..8d28099 100644 --- a/energyplugin/etm/types/decisionreason.h +++ b/energyplugin/etm/types/decisionreason.h @@ -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 : + * « — 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()); diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index b5f03d0..6910498 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -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(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(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 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. * diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 250f48a..eb02600 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -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();