SgReadyAdapter::applyAction() renvoyait, quand le verrou minStateHold refusait la transition, l'action DEMANDÉE telle quelle. Un appelant qui relisait `state` y retrouvait l'état qu'il venait de demander et concluait au succès — alors que rien n'avait été écrit et que les contacts n'avaient pas bougé. Ce n'est pas un défaut du délestage, c'est un défaut de contrat. N'importe quelle descente d'état pouvait échouer en silence, et `available` restait vrai : la charge avait l'air saine pendant que la PAC tirait 3 kW. Même famille qu'ECS-111 — une issue indiscernable de son contraire — transposée au retour d'une commande au lieu de la télémétrie. Toute sortie décrit désormais l'état RÉELLEMENT tenu, `estimatedPowerW` compris. L'idempotence n'y échappe pas : demander « état 4, 0 W » à une PAC qui en tire 3000 doit rendre 3000, pas le zéro de l'appelant — sinon il lit son enveloppe, pas une réponse. Même correction sur la branche « motif vide » du RelayRouter et de l'EtmVariableLoadAdapter. Le défaut était LATENT : aucun appelant ne lisait ce retour, le dispatch l'ignore. Le premier à s'y fier fut le délestage L4 en cours d'écriture, qui a publié 3 kW rendus par une PAC restée en état 4. Un contrat qu'aucun appelant n'exerce n'est pas un contrat tenu, c'est un contrat non testé. Deux pistes écartées, mesurées et non supposées. m_currentState n'était pas en cause : il est écrit synchroniquement, sans attendre d'acquittement. ECS-411-b non plus : la relecture des contacts répond à une divergence entre état interne et matériel, et il n'y en avait aucune — l'état interne était juste, c'est le retour qui mentait. Les trois pièges rencontrés à l'étape 4 sont consignés dans DESIGN_DELESTAGE §6bis, les deux placements écartés compris. L'étape 4 elle-même reste non livrée. Suite complète : simulation 53/53, charging 48/48, loadmodel 21/21, spotmarket 32/32, doxygen 0 avertissement. Contre-épreuve : sur l'ancien retour, refuse.state vaut 2 — l'état demandé — au lieu de 4. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
291 lines
11 KiB
C++
291 lines
11 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.";
|
|
// 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;
|
|
}
|