Patrick Schurig cd1a0c83a5 fix: ECS-410-b — l'issue de ClearLoadFault, et l'asymétrie refus/succès (règles 7-c, 7-d)
ClearLoadFault est le SEUL levier de reprise à distance : ECS-410 pose un verrou collant que
seuls ce RPC ou une reconstruction lèvent. L'arbitre journalisait la demande de l'opérateur,
jamais son issue — si bien qu'appelé depuis l'étranger, on ne distinguait pas « le défaut a
été levé » de « il n'y en avait pas » de « l'appel n'a pas atteint l'adaptateur ». L'opérateur
relance trois fois et conclut que le système est cassé.

L'issue est écrite dans l'ARBITRE, pas dans chaque adaptateur : une ligne là couvre les quatre
d'un coup et ne peut pas être oubliée par un adaptateur futur ; répétée dans chacun, elle
manquerait le cinquième. Les trois cas sont distincts à la lecture.

ILoadAdapter::clearFault() rend un bool au lieu de void. Le type de retour force chaque
implémentation, présente et future, à répondre à la question « un défaut a-t-il réellement été
levé ». EvAdapter, qui n'a pas de mécanisme de défaut, le dit désormais explicitement plutôt
que par un corps vide.

Règle 7-d — un refus doit être au moins aussi visible que l'application correspondante. Le
refus par verrou minStateHold de SgReadyAdapter était en qCDebug quand l'application, dix
lignes plus bas, était en qCInfo. L'asymétrie est le défaut, pas le niveau : un resserrement
de la journalisation — et on vient d'en faire un pour tenir deux semaines — aurait fait
disparaître le refus AVANT le succès, ne laissant au journal que les décisions abouties. Or un
refus est plus informatif qu'une application réussie : il dit qu'une décision a été prise et
n'a pas été exécutée. Vérification faite sur les autres adaptateurs : c'était le seul cas. Les
qCDebug restants sont symétriques, et le marqueur L2 par cycle reste en debug à dessein, son
entrée étant en qCWarning et sa sortie en qCInfo.

Le retour idempotent du routeur reste DIFFÉRÉ, et ira en qCDebug : c'est le cas le plus
fréquent — plusieurs milliers de lignes par jour sur un budget ramené à 6,5 Mo — et son
ambiguïté est partiellement couverte, le scheduler journalisant sa décision et l'armement à
froid s'exécutant avant ce retour. Faible gain, coût réel : l'inverse des deux cas corrigés.

Le test porte sur le TEXTE des trois issues et vérifie qu'elles sont deux à deux distinctes.
Suite complète : 112 tests, 0 échec.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 20:27:53 +02:00

239 lines
8.6 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 priority,
const LoadNeeds &needs,
QObject *parent)
: QObject(parent)
, m_thingManager(thingManager)
, m_id(id)
, m_label(label)
, m_powerLevels(powerLevels)
, m_maxPowerW(maxPowerW)
, 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;
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)" : "")
<< "|" << action.reason;
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;
}
void EtmVariableLoadAdapter::applySafeState(const QDateTime &now)
{
LoadAction sur;
sur.loadId = m_id;
sur.kind = LoadAction::Setpoint;
sur.powerW = 0;
sur.force = true;
sur.reason = QStringLiteral("Charge désactivée — mise en état sûr (0 W) avant retrait (ECS-413)");
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;
}