Patrick Schurig 639689baa9 feat(lot E §12): le plancher de modulation — rien, ou au moins X
Le domaine d'une consigne devient {0} ∪ [minPowerW, maxPowerW], un ensemble discontinu.
LM-1203 est la règle qui fait le travail : sous le plancher, allouer 0 et TRANSMETTRE le
budget à la charge suivante.

Constat en lisant le waterfall : il satisfaisait déjà LM-1203 pour les charges à
paliers — le plus haut palier ≤ budget vaut 0 quand le budget est sous le premier, et
le résidu cascade. Le trou n'était que dans la branche continue, où
qBound(0, budget, max) acceptait n'importe quelle miette. Le correctif est donc plus
petit que la spec ne le laissait craindre, et localisé.

Le plancher est déclaré pour etmvariableload dynamic et dérivé partout ailleurs — plus
petit palier non nul chez relay-router, plus petite estimation non nulle chez sg-ready.
SetLoadConfig refuse de le déclarer là où il se dérive, et refuse aussi minPowerW >
maxPowerW : le domaine serait réduit à {0}, la charge ne démarrerait jamais, et rien
dans le journal ne dirait pourquoi.

Motif BELOW_MIN_POWER distinct de SURPLUS_INSUFFICIENT : les confondre enverrait
chercher du soleil là où il faut lire une fiche technique.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
2026-08-26 10:48:51 +02:00

272 lines
10 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;
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.";
return action;
}
// 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)" : "")
<< "|" << renderFr(action.reason, 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é ---------------------------------------------------------------
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;
}