LM-1105/LM-1106 implantés. measuredW disparaît, sans alias. ILoadAdapter::deviceMeasurement() — PURE VIRTUELLE, comme runtimeView() et pour la même raison : un mécanisme ajouté qui hériterait d'un « non mesurable » par défaut sortirait de la mesure sans bruit, et son écran afficherait « pas mesurable » avec l'autorité d'un constat. Le compilateur pose la question. Le type porte une CAPACITÉ, pas une valeur nulle : `available` répond à « cette charge est-elle mesurable », question posée à la ThingClass, jamais à la valeur du moment. Un routeur au palier 0 mesure 0 W et reste mesurable ; sans ce booléen les deux seraient indistinguables. attachMeasurements() résout meter → device → none. Le compteur explicite garde la priorité, et pas d'abord parce qu'il mesure mieux : un réglage sans effet est pire que pas de réglage. La priorité porte sur la SOURCE, pas sur la valeur — compteur déclaré injoignable, on retombe sur l'appareil, parce que se taire perdrait une mesure qu'on a et que le silence se lirait « non mesurable ». Sous `none`, powerW est OMIS. La puissance nominale reste lisible dans mechanism.*, qui dit d'où elle vient ; la faire passer pour une mesure produirait un écart commandé↔mesuré identiquement nul à tous les cycles — un « tout concorde » permanent, corollaire LM-104. DEUX AFFINEMENTS IMPOSÉS PAR L'IMPLANTATION, tous deux de modèle : LM-1105-a — une mesure PARTIELLE n'est pas une mesure. Là où plusieurs Things portent la charge, il faut qu'ils la portent TOUS : trois relais dont un seul mesure annonceraient 500 W là où 1 500 W coulent, et l'écart enverrait chercher une panne inexistante. À ne pas confondre avec LoadTelemetry::currentPowerW, qui accepte « au moins un » et retombe sur le nominal — celui-là est le juge runtime du budget et doit rendre un nombre à chaque cycle. Les fusionner rouvrirait la correction B. LM-1105-b — la règle présuppose que le Thing piloté est TRAVERSÉ par la puissance. Un relais de relay-router commute la charge, donc sa puissance EST celle de la charge ; un contact SG-Ready est un contact de SIGNAL — la PAC est alimentée ailleurs et un contact qui mesurerait quelque chose mesurerait sa propre bobine. sg-ready est donc `none` par CONSTRUCTION, même sur des relais qui mesurent — c'est le cas du banc — et c'est le seul mécanisme pour lequel meterThingId reste la seule voie. RelayRouter : la capacité se juge sur la ThingClass de TOUS les relais déclarés, la valeur se lit sur les relais FERMÉS. Jugée sur les relais actifs, la source vaudrait `device` au palier 1 et `none` au palier 0 — un bloc de mesure qui apparaît et disparaît au rythme du thermostat. VÉRIFIÉ ÉCHOUANT ainsi. testMeasurementSourceIsPublished : les trois sources sans une ligne de config de compteur, le palier 0 qui ne fait pas basculer la source, la PAC en `none` alors que ses contacts mesurent, la disparition de measuredW, puis les deux règles de priorité — le compteur explicite l'emporte (1 234 contre 2 900), un compteur injoignable fait retomber sur l'appareil. Ne touche PAS ce que l'arbitre lit : LM-1101 tient, LoadTelemetry::currentPowerW est inchangé, et le recrédit anti-clignotement n'a jamais transité par meterThingId. amd64 0 erreur / 0 avertissement. Suites, un processus par test : simulation 30/30, charging 16/16, loadmodel 18/18, spotmarket 7/7. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
288 lines
11 KiB
C++
288 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.";
|
|
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é ---------------------------------------------------------------
|
|
|
|
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;
|
|
}
|