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
|
qCInfo(dcNymeaEnergy()) << "[EtmVariableLoadAdapter]" << m_label
|
||||||
<< "→ setpoint" << qRound(setpointW) << "W"
|
<< "→ setpoint" << qRound(setpointW) << "W"
|
||||||
<< (action.force ? "(force)" : "")
|
<< (action.force ? "(force)" : "")
|
||||||
<< "|" << renderFr(action.reason, m_label);
|
<< "|" << renderLevelsFr(action, m_label);
|
||||||
|
|
||||||
m_setpointPrev = m_currentSetpointW;
|
m_setpointPrev = m_currentSetpointW;
|
||||||
m_setpointTarget = setpointW;
|
m_setpointTarget = setpointW;
|
||||||
|
|||||||
@ -301,7 +301,7 @@ LoadAction RelayRouter::applyAction(const LoadAction &action, const QDateTime &n
|
|||||||
<< "→ consigne" << qRound(action.powerW) << "W → palier"
|
<< "→ consigne" << qRound(action.powerW) << "W → palier"
|
||||||
<< m_levels.at(newStage) << "W INCHANGÉ (aucune commutation)"
|
<< m_levels.at(newStage) << "W INCHANGÉ (aucune commutation)"
|
||||||
<< (action.force ? "(force)" : "")
|
<< (action.force ? "(force)" : "")
|
||||||
<< "|" << renderFr(action.reason, m_label);
|
<< "|" << renderLevelsFr(action, m_label);
|
||||||
return applied; // idempotent — pas de re-commutation
|
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"
|
<< "→ consigne" << qRound(action.powerW) << "W → palier"
|
||||||
<< m_levels.at(newStage) << "W"
|
<< m_levels.at(newStage) << "W"
|
||||||
<< (action.force ? "(force)" : "")
|
<< (action.force ? "(force)" : "")
|
||||||
<< "|" << renderFr(action.reason, m_label);
|
<< "|" << renderLevelsFr(action, m_label);
|
||||||
|
|
||||||
applyRelayStage(newStage);
|
applyRelayStage(newStage);
|
||||||
|
|
||||||
|
|||||||
@ -482,7 +482,7 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime)
|
|||||||
|
|
||||||
for (const LoadAction &action : slot.actions) {
|
for (const LoadAction &action : slot.actions) {
|
||||||
qCInfo(dcNymeaEnergy()) << "[Arbitre]"
|
qCInfo(dcNymeaEnergy()) << "[Arbitre]"
|
||||||
<< action.loadId << "→" << renderFr(action.reason, libelles.value(action.loadId))
|
<< action.loadId << "→" << renderLevelsFr(action, libelles.value(action.loadId))
|
||||||
<< "| activé:" << action.chargingEnabled
|
<< "| activé:" << action.chargingEnabled
|
||||||
<< "| courant:" << action.currentA << "A"
|
<< "| courant:" << action.currentA << "A"
|
||||||
<< "| phases:" << action.phaseCount
|
<< "| 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
|
// 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
|
// 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.
|
// 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
|
// Le niveau dont `decision` parle : CELUI DONT LE MOTIF EST PUBLIÉ (correctif a, qui
|
||||||
// elle doit être UNE, et publiée : sans elle chaque client réinventerait sa fusion, et
|
// remplace « le dernier parcouru »). La règle doit être UNE, et publiée : sans elle chaque
|
||||||
// deux écrans afficheraient deux motifs pour le même cycle. Le dernier parcouru est
|
// client réinventerait sa fusion, et deux écrans afficheraient deux motifs pour le même
|
||||||
// aussi celui qui a eu le dernier mot sur la consigne, donc celui qu'on explique.
|
// cycle. Le motif est celui de la part majoritaire de la consigne (INTERFACE.md R7).
|
||||||
QVariantMap decision = action.reason.toMap();
|
QVariantMap decision = action.reason.toMap();
|
||||||
if (!action.levels.isEmpty())
|
if (!action.levels.isEmpty())
|
||||||
decision.insert(QStringLiteral("level"), action.levels.last().level);
|
decision.insert(QStringLiteral("level"), publishedLevel(action));
|
||||||
entry.insert(QStringLiteral("decision"), decision);
|
entry.insert(QStringLiteral("decision"), decision);
|
||||||
|
|
||||||
const LoadRuntimeView v = adapter->runtimeView(now);
|
const LoadRuntimeView v = adapter->runtimeView(now);
|
||||||
|
|||||||
@ -12,6 +12,31 @@
|
|||||||
#include <QUuid>
|
#include <QUuid>
|
||||||
#include <algorithm>
|
#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)
|
RuleBasedScheduler::RuleBasedScheduler(EnergyArbitrator *arbitrator, QObject *parent)
|
||||||
: QObject(parent)
|
: QObject(parent)
|
||||||
, m_arbitrator(arbitrator)
|
, m_arbitrator(arbitrator)
|
||||||
@ -498,6 +523,7 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
|
|||||||
if (!slot.actions.isEmpty() && niveauxEco.contains(lc.id)) {
|
if (!slot.actions.isEmpty() && niveauxEco.contains(lc.id)) {
|
||||||
LoadAction &la = slot.actions.last();
|
LoadAction &la = slot.actions.last();
|
||||||
const LoadAction::Level eco = niveauxEco.take(lc.id);
|
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.levels.prepend(eco); // éco AVANT confort : l'ordre des passes
|
||||||
la.powerW += eco.targetW;
|
la.powerW += eco.targetW;
|
||||||
la.estimatedPowerW += eco.targetW;
|
la.estimatedPowerW += eco.targetW;
|
||||||
@ -505,6 +531,12 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
|
|||||||
la.counts[it.key()] += it.value();
|
la.counts[it.key()] += it.value();
|
||||||
if (eco.targetW > 0)
|
if (eco.targetW > 0)
|
||||||
la.chargingEnabled = true;
|
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())
|
if (margeRestante.borne && !slot.actions.isEmpty())
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
#include "decisionreason.h"
|
#include "decisionreason.h"
|
||||||
#include "ecoprogress.h"
|
#include "ecoprogress.h"
|
||||||
|
#include "loadaction.h"
|
||||||
#include <QLocale>
|
#include <QLocale>
|
||||||
#include <QStringList>
|
#include <QStringList>
|
||||||
|
|
||||||
@ -302,6 +303,34 @@ QString renderFr(const DecisionReason &reason, const QString &label)
|
|||||||
.arg(c, kv.isEmpty() ? QString() : QStringLiteral(" "), kv.join(QStringLiteral(", ")));
|
.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()
|
QStringList DecisionCode::knownCodes()
|
||||||
{
|
{
|
||||||
using namespace DecisionCode;
|
using namespace DecisionCode;
|
||||||
|
|||||||
@ -471,3 +471,37 @@ struct DecisionReason
|
|||||||
* ferait passer un motif manquant pour un motif absent.
|
* ferait passer un motif manquant pour un motif absent.
|
||||||
*/
|
*/
|
||||||
QString renderFr(const DecisionReason &reason, const QString &label = QString());
|
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
|
#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.
|
* \brief [Commande perdue, étape 2] L'écart se PUBLIE — il ne se diagnostique pas.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -197,6 +197,8 @@ private slots:
|
|||||||
void testEcoProgressBaseAndRegime();
|
void testEcoProgressBaseAndRegime();
|
||||||
void testEcoPassBuysAndSaysTheTrajectory();
|
void testEcoPassBuysAndSaysTheTrajectory();
|
||||||
void testEcoFloorMissedSaysWhatWasLostAndWhy();
|
void testEcoFloorMissedSaysWhatWasLostAndWhy();
|
||||||
|
//! Correctif a — le motif publié est celui de la part majoritaire de la consigne fusionnée.
|
||||||
|
void testTheDecisionDescribesTheMergedAction();
|
||||||
void testCommandDivergenceIsPublishedNotDiagnosed();
|
void testCommandDivergenceIsPublishedNotDiagnosed();
|
||||||
void testProgressMeasurabilityIsDeclaredAtConfigTime();
|
void testProgressMeasurabilityIsDeclaredAtConfigTime();
|
||||||
void testThermalProbeFeedsTelemetry();
|
void testThermalProbeFeedsTelemetry();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user