Patrick Schurig e6ee513dcc 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>
2026-10-16 20:11:51 +02:00

296 lines
12 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#include "etmvariableloadadapter.h"
#include "plugininfo.h"
#include <QDateTime>
#include <algorithm>
#include <integrations/thingmanager.h>
#include <integrations/thing.h>
#include <types/action.h>
#include <types/param.h>
#include <integrations/thingactioninfo.h>
EtmVariableLoadAdapter::EtmVariableLoadAdapter(ThingManager *thingManager,
const QString &id,
const QString &label,
const QList<int> &powerLevels,
int maxPowerW,
int minPowerW,
int priority,
const LoadNeeds &needs,
QObject *parent)
: QObject(parent)
, m_thingManager(thingManager)
, m_id(id)
, m_label(label)
, m_powerLevels(powerLevels)
, m_maxPowerW(maxPowerW)
, m_minPowerW(minPowerW)
, m_priority(priority)
, m_needs(needs)
{
// Re-tri par sécurité : l'UI émet trié, l'energymanager re-trie (contrat §6 cas limites).
std::sort(m_powerLevels.begin(), m_powerLevels.end());
// Contrat §2 : les paliers incluent 0. En *fixed* (liste non vide), 0 doit être présent.
Q_ASSERT(m_powerLevels.isEmpty() || m_powerLevels.first() == 0);
}
LoadDescriptor EtmVariableLoadAdapter::descriptor() const
{
LoadDescriptor d;
d.id = m_id;
d.label = m_label;
d.adapter = QStringLiteral("etmvariableload");
d.priority = m_priority;
d.declared.powerLevels = m_powerLevels;
d.declared.maxPowerW = m_maxPowerW;
// §12 / LM-1202 — seul mécanisme où le plancher est DÉCLARÉ : ni les paliers ni le
// plafond ne le portent, et le thing ne l'expose pas.
d.declared.minPowerW = m_minPowerW;
d.needs = m_needs;
d.supportedKinds = { LoadAction::Setpoint };
return d;
}
LoadTelemetry EtmVariableLoadAdapter::telemetry() const
{
LoadTelemetry t;
Thing *thing = m_thingManager->findConfiguredThing(ThingId(m_id));
t.available = (thing != nullptr) && !m_faulted;
t.currentPowerW = readCurrentPowerW(); // juge runtime (contrat §4)
// ECS-414 — INDÉTERMINATION : écriture en vol, on annonce la plus HAUTE des deux
// consignes possibles si le thing ne mesure pas. Jamais moins que ce qui peut couler.
if (m_pending > 0 && t.currentPowerW <= 0)
t.currentPowerW = qMax(m_setpointPrev, m_setpointTarget);
t.lastActionAt = m_lastActionAt;
return t;
}
LoadContext EtmVariableLoadAdapter::toLoadContext(const QDateTime &now) const
{
Q_UNUSED(now) // aucune fenêtre de verrou côté moteur : l'anti-rebond vit dans le thing.
LoadContext ctx;
ctx.id = m_id;
ctx.adapter = QStringLiteral("etmvariableload");
ctx.label = m_label;
ctx.priority = m_priority;
ctx.declared = descriptor().declared;
// §10-C — les BESOINS déclarés atteignent enfin le cycle. Ils étaient posés dans le
// descripteur et abandonnés ici : `LoadContext::needs` n'était rempli par personne, donc
// l'obligation éco n'existait qu'en configuration. Transmis tels quels, sans être lus par
// aucune décision à ce stade — la passe éco est l'étape suivante.
ctx.needs = descriptor().needs;
ctx.telemetry.currentPowerW = telemetry().currentPowerW;
ctx.telemetry.available = telemetry().available;
if (m_faulted) {
// Charge FIGÉE : plancher == plafond == puissance crue engagée.
ctx.telemetry.lockMinPowerW = ctx.telemetry.currentPowerW;
ctx.telemetry.lockMaxPowerW = ctx.telemetry.currentPowerW;
}
return ctx;
}
LoadAction EtmVariableLoadAdapter::applyAction(const LoadAction &action, const QDateTime &now)
{
if (action.kind != LoadAction::Setpoint)
return action;
if (action.reason.isEmpty()) {
qCWarning(dcNymeaEnergy()) << "[EtmVariableLoadAdapter]" << m_label
<< "— LoadAction sans reason rejetée.";
// ECS-415 — un refus se lit comme un refus : le retour décrit la consigne TENUE.
LoadAction refuse = action;
refuse.powerW = refuse.estimatedPowerW = m_currentSetpointW;
return refuse;
}
// ECS-414 — en défaut, plus aucune consigne écrite, y compris forcée.
if (m_faulted) {
LoadAction refused = action;
refused.powerW = refused.estimatedPowerW = m_currentSetpointW;
return refused;
}
// Second filet (invariant ILoadAdapter) : borner la consigne au plafond physique.
// L'arrondi au powerLevels (mode *fixed*) est fait par le scheduler (contrat §4, T3).
const double setpointW = qBound(0.0, action.powerW, static_cast<double>(m_maxPowerW));
qCInfo(dcNymeaEnergy()) << "[EtmVariableLoadAdapter]" << m_label
<< "→ setpoint" << qRound(setpointW) << "W"
<< (action.force ? "(force)" : "")
<< "|" << renderLevelsFr(action, m_label);
m_setpointPrev = m_currentSetpointW;
m_setpointTarget = setpointW;
m_writeFailed = false;
writeSetpoint(setpointW);
m_currentSetpointW = setpointW;
m_lastActionAt = now;
LoadAction applied = action;
applied.powerW = setpointW;
applied.estimatedPowerW = setpointW;
return applied;
}
LoadRuntimeView EtmVariableLoadAdapter::runtimeView(const QDateTime &now) const
{
Q_UNUSED(now) // aucun verrou côté moteur : l'anti-rebond vit dans le thing (cf. toLoadContext).
LoadRuntimeView v;
v.mechanismKind = QStringLiteral("variable");
v.mechanism.insert(QStringLiteral("setpointW"), m_currentSetpointW);
// Le pourcentage est la lecture NATURELLE d'une modulation continue — c'est ce qu'un
// utilisateur reconnaît d'un routeur PV. Il reste dérivé de la consigne, jamais l'inverse :
// les watts sont la grandeur commandée, le pourcentage n'est qu'une vue.
if (m_maxPowerW > 0)
v.mechanism.insert(QStringLiteral("percent"),
qRound(1000.0 * m_currentSetpointW / m_maxPowerW) / 10.0);
v.mechanism.insert(QStringLiteral("maxPowerW"), static_cast<double>(m_maxPowerW));
// §12 — publié seulement s'il existe : « omis, jamais nul ». Un plancher de 0 W se lirait
// « cette charge accepte n'importe quelle miette », ce qui est justement ce qu'on corrige.
if (m_minPowerW > 0)
v.mechanism.insert(QStringLiteral("minPowerW"), static_cast<double>(m_minPowerW));
// ECS-414 — deux causes DISTINCTES d'indisponibilité, deux codes. Les confondre enverrait
// diagnostiquer un matériel muet là où c'est un Thing absent de la configuration.
if (m_faulted)
v.faultCode = QStringLiteral("WRITE_FAILED");
else if (!m_thingManager || !m_thingManager->findConfiguredThing(ThingId(m_id)))
v.faultCode = QStringLiteral("THING_MISSING");
return v;
}
void EtmVariableLoadAdapter::applySafeState(const QDateTime &now)
{
LoadAction sur;
sur.loadId = m_id;
sur.kind = LoadAction::Setpoint;
sur.powerW = 0;
sur.force = true;
sur.reason = { DecisionCode::SafeStateSetpoint };
applyAction(sur, now);
}
// ---- privé ---------------------------------------------------------------
LoadMeasurement EtmVariableLoadAdapter::deviceMeasurement() const
{
LoadMeasurement m;
Thing *thing = m_thingManager->findConfiguredThing(ThingId(m_id));
if (!thing)
return m; // Thing absent : rien ne mesure. THING_MISSING le dit déjà par ailleurs.
// Question posée à la CLASSE : une charge à consigne 0 mesure 0 W et reste mesurable.
if (thing->thingClass().stateTypes().findByName("currentPowerW").id().isNull())
return m;
m.available = true;
m.powerW = thing->stateValue("currentPowerW").toDouble();
return m;
}
double EtmVariableLoadAdapter::readCurrentPowerW() const
{
Thing *thing = m_thingManager->findConfiguredThing(ThingId(m_id));
if (!thing)
return 0.0;
if (thing->thingClass().stateTypes().findByName("currentPowerW").id().isNull())
return 0.0;
return thing->stateValue("currentPowerW").toDouble();
}
void EtmVariableLoadAdapter::writeSetpoint(double powerW)
{
Thing *thing = m_thingManager ? m_thingManager->findConfiguredThing(ThingId(m_id)) : nullptr;
if (!thing) {
qCWarning(dcNymeaEnergy()) << "[EtmVariableLoadAdapter]" << m_label
<< "— thing non trouvé:" << m_id;
m_writeFailed = true;
settleTransition();
return;
}
StateType setpointStateType = thing->thingClass().stateTypes().findByName("powerSetpoint");
if (setpointStateType.id().isNull()) {
thing->setStateValue("powerSetpoint", powerW); // repli mock : synchrone
return;
}
Action setpointAction(setpointStateType.id(), thing->id(), Action::TriggeredByRule);
setpointAction.setParams(ParamList() << Param(setpointStateType.id(), powerW));
ThingActionInfo *info = m_thingManager->executeAction(setpointAction);
if (!info) {
m_writeFailed = true;
settleTransition();
return;
}
// ECS-414 — même modèle qu'ECS-410 : aucune attente, verdict par signal, `this` en contexte.
++m_pending;
connect(info, &ThingActionInfo::finished, this, [this, info]() {
if (info->status() != Thing::ThingErrorNoError) {
m_writeFailed = true;
qCWarning(dcNymeaEnergy()) << "[EtmVariableLoadAdapter]" << m_label
<< "— écriture consigne en échec, status" << info->status();
}
if (--m_pending == 0)
settleTransition();
});
}
void EtmVariableLoadAdapter::settleTransition()
{
if (!m_writeFailed) {
m_setpointPrev = m_setpointTarget = m_currentSetpointW;
return;
}
m_writeFailed = false;
switch (m_phase) {
case PhaseNominale:
qCWarning(dcNymeaEnergy()) << "[EtmVariableLoadAdapter]" << m_label
<< "— échec d'écriture : retour à" << m_setpointPrev << "W.";
m_phase = PhaseRepli;
m_currentSetpointW = m_setpointPrev;
writeSetpoint(m_setpointPrev);
break;
case PhaseRepli:
qCWarning(dcNymeaEnergy()) << "[EtmVariableLoadAdapter]" << m_label
<< "— retour arrière en échec : consigne 0 W.";
m_phase = PhasePlancher;
m_currentSetpointW = 0;
m_setpointTarget = 0;
writeSetpoint(0);
break;
case PhasePlancher:
case PhaseDefaut:
qCCritical(dcNymeaEnergy()) << "[EtmVariableLoadAdapter]" << m_label
<< "— consigne 0 W en échec : charge EN DÉFAUT, plus aucune"
<< "commande. Levée par NymeaEnergy.ClearLoadFault.";
m_phase = PhaseDefaut;
m_faulted = true;
break;
}
}
bool EtmVariableLoadAdapter::clearFault()
{
if (!m_faulted)
return false; // rien à lever — l'arbitre le dira (règle 7-c)
qCInfo(dcNymeaEnergy()) << "[EtmVariableLoadAdapter]" << m_label
<< "— défaut levé par l'opérateur (ClearLoadFault).";
m_faulted = false;
m_phase = PhaseNominale;
m_writeFailed = false;
m_currentSetpointW = readCurrentPowerW(); // relu, pas supposé
m_setpointPrev = m_setpointTarget = m_currentSetpointW;
return true;
}