Patrick Schurig a8f8734fc2 feat(§10 étape C): l'avancement est une différence, et le régime se publie
`sessionEnergy` est l'ACCUMULATEUR du plugin V2C, pas un compteur remis à
zéro pour le moteur : l'écart avec `chargeEnergy` est constant au
dix-millième (7,2044 kWh sur deux échantillons à deux heures d'écart), parce
qu'il porte les incréments d'avant la dernière remise à zéro de la borne.
Le lire directement ferait croire toute obligation éco tenue de 7,2 kWh dès
le premier cycle — un nombre crédible et faux que rien ne signalerait.
Contre-épreuve : lecture directe, le test annonce 7204.

La base se prend à l'ouverture de l'OBLIGATION, jamais au démarrage de
nymead : sinon un redémarrage en pleine session réinitialise l'avancement
sur une valeur déjà accumulée, et la recharge de nuit relue au matin repart
de zéro. Elle est RECAPTURÉE sur un recul franc de l'accumulateur — une
nouvelle session — mais pas sur un recul infime, qui est du bruit de lecture
et coûterait l'avancement acquis à chaque frémissement du compteur.

Ce que le moteur ne refait PAS : la détection de remise à zéro de la BORNE
vit dans le plugin, qui interroge à 30 s. Le moteur travaille à la minute et
raterait des cycles entiers. Lire comment le plugin l'avait écrite a évité
d'ajouter une seconde accumulation, moins fine, en concurrence de la
première.

Trois maillons manquants raccordés, tous déclarés et alimentés par personne :
EvCharger n'exposait pas sessionEnergy ; LoadContextTelemetry::sessionWh
n'était rempli nulle part ; et toLoadContext() ABANDONNAIT les besoins
déclarés, si bien que LoadContext::needs n'était rempli par aucun adaptateur.
L'obligation éco n'existait qu'en configuration.

Mock : sessionEnergy ajouté à chargerPhaseSwitching UNIQUEMENT — `charger`
reste sans, ce qui donne le cas unmeasurable nativement. La suite porte les
deux régimes, comme le banc.

Le régime est publié alors qu'aucune décision ne s'y appuie encore, et c'est
délibéré : cela sépare publier le régime de décider avec, comme measurement
l'a été de l'arbitrage, et rend R6 testable le jour où le motif naîtra au
lieu d'un invariant posé sur du code que rien n'émet.

Suite : simulation 56/56, charging 48/48 identique à la référence ligne à
ligne, loadmodel 21/21, spotmarket 32/32, doxygen 0 avertissement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-31 07:53:15 +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)" : "")
<< "|" << 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é ---------------------------------------------------------------
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;
}