fix(etm): ECS-414 — échelle d'échec généralisée à SgReady et EtmVariableLoad
Même modèle qu'ECS-410, rien de nouveau conçu : m_pending, verdict à zéro,
échelle bornée à trois barreaux, `this` en contexte de connexion.
PLANCHER PROPRE À CHAQUE ADAPTATEUR. Pour EtmVariableLoadAdapter, consigne 0 W.
Pour SgReadyAdapter, le barreau 3 est l'ÉTAT 2 — pas « tous contacts ouverts ».
Ouvrir les deux contacts d'une PAC est une COMMANDE, et selon l'encodage câblé
c'est potentiellement le BLOCAGE, donc l'inverse d'une mise en sécurité.
ATOMICITÉ DU REPLI — le point qui n'existait pas sur le routeur relais. Un état
SG-Ready est porté par deux bits ; si une écriture échoue, on est dans un motif
valide mais NON VOULU, et le retour depuis ce motif peut exiger de traverser le
blocage. applyStateRelays() prend désormais un ENSEMBLE de relais réellement
fermés au lieu d'un index d'état, de sorte que transientHarm ordonne le repli
exactement comme il ordonne l'aller. Sans cela, la récupération serait plus
dangereuse que la panne qu'elle corrige.
Le repli écrit les contacts directement, sans repasser par applyAction : le verrou
minStateHoldS n'est donc jamais consulté — équivalent d'un force = true, comme le
repli L2. À 900 s sur une PAC, attendre un quart d'heure pour sortir d'un état non
voulu ne serait pas défendable.
DEUX INCOHÉRENCES TROUVÉES PAR LE TEST, et corrigées :
1. readActualOn() traitait un contact injoignable comme OUVERT, alors qu'ECS-411
le suppose FERMÉ. Conséquence : un repli ne demandant aucune écriture
« réussissait » à vide alors qu'on ne savait rien du matériel.
2. Corollaire inverse : une fois supposé fermé, un contact injoignable n'était
plus jamais écrit quand la cible le voulait fermé — l'échec était masqué et
la transition paraissait réussie sans qu'aucune écriture n'ait été tentée.
Un contact non vérifiable est désormais TOUJOURS commandé.
Test testSgReadyPartialFailure. Ce qu'il verrouille n'est pas le défaut mais le
PLANCHER : le repli ramène K1 à l'ouverture, soit l'état 2 sur cet encodage. Un
plancher « contacts ouverts par principe » aurait pu, sur un autre câblage, valoir
l'état 1 — le blocage. Vérifie aussi que minStateHoldS = 900 s ne retarde pas le
repli, que la charge figée a plancher == plafond, et que la levée relit l'état.
Une correction de mon propre scénario au passage : j'avais écrit un cas
« récupération au barreau 2 » qui n'est pas constructible avec un contact
définitivement injoignable — rien ne peut alors être confirmé, et le défaut est
l'issue honnête.
Build amd64 0 erreur. Simulation : 18/18.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
195a399bcd
commit
2a4659ff8f
@ -10,6 +10,7 @@
|
||||
#include <integrations/thing.h>
|
||||
#include <types/action.h>
|
||||
#include <types/param.h>
|
||||
#include <integrations/thingactioninfo.h>
|
||||
|
||||
EtmVariableLoadAdapter::EtmVariableLoadAdapter(ThingManager *thingManager,
|
||||
const QString &id,
|
||||
@ -54,8 +55,12 @@ LoadTelemetry EtmVariableLoadAdapter::telemetry() const
|
||||
{
|
||||
LoadTelemetry t;
|
||||
Thing *thing = m_thingManager->findConfiguredThing(ThingId(m_id));
|
||||
t.available = (thing != nullptr);
|
||||
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;
|
||||
}
|
||||
@ -71,7 +76,13 @@ LoadContext EtmVariableLoadAdapter::toLoadContext(const QDateTime &now) const
|
||||
ctx.priority = m_priority;
|
||||
ctx.declared = descriptor().declared;
|
||||
|
||||
ctx.telemetry.currentPowerW = readCurrentPowerW();
|
||||
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;
|
||||
}
|
||||
|
||||
@ -86,6 +97,13 @@ LoadAction EtmVariableLoadAdapter::applyAction(const LoadAction &action, const Q
|
||||
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));
|
||||
@ -95,6 +113,9 @@ LoadAction EtmVariableLoadAdapter::applyAction(const LoadAction &action, const Q
|
||||
<< (action.force ? "(force)" : "")
|
||||
<< "|" << action.reason;
|
||||
|
||||
m_setpointPrev = m_currentSetpointW;
|
||||
m_setpointTarget = setpointW;
|
||||
m_writeFailed = false;
|
||||
writeSetpoint(setpointW);
|
||||
|
||||
m_currentSetpointW = setpointW;
|
||||
@ -131,19 +152,86 @@ double EtmVariableLoadAdapter::readCurrentPowerW() const
|
||||
|
||||
void EtmVariableLoadAdapter::writeSetpoint(double powerW)
|
||||
{
|
||||
Thing *thing = m_thingManager->findConfiguredThing(ThingId(m_id));
|
||||
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()) {
|
||||
Action setpointAction(setpointStateType.id(), thing->id(), Action::TriggeredByRule);
|
||||
setpointAction.setParams(ParamList() << Param(setpointStateType.id(), powerW));
|
||||
m_thingManager->executeAction(setpointAction);
|
||||
} else {
|
||||
thing->setStateValue("powerSetpoint", powerW); // repli mock
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
void EtmVariableLoadAdapter::clearFault()
|
||||
{
|
||||
if (!m_faulted)
|
||||
return;
|
||||
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;
|
||||
}
|
||||
|
||||
@ -8,6 +8,8 @@
|
||||
#include <QString>
|
||||
#include "iloadadapter.h"
|
||||
|
||||
class ThingActionInfo;
|
||||
|
||||
class Thing;
|
||||
class ThingManager;
|
||||
|
||||
@ -114,7 +116,11 @@ public:
|
||||
//! \brief Sans effet : cet adaptateur n'implémente pas encore l'échelle de défaut ECS-410.
|
||||
//! La généralisation est portée par **ECS-414** (lot de mise en configuration
|
||||
//! du SgReadyAdapter). Déclaré explicitement plutôt qu'hérité d'un défaut vide.
|
||||
void clearFault() override {}
|
||||
//! \brief Lève le verrou de défaut (ECS-414). La consigne réelle est RELUE, pas supposée.
|
||||
void clearFault() override;
|
||||
|
||||
//! \return Vrai si la charge est en défaut (plus aucune consigne écrite).
|
||||
bool faulted() const { return m_faulted; }
|
||||
|
||||
//! \brief État sûr : consigne **0 W**, en \c force = true (ECS-413). \param now Temps de cycle.
|
||||
void applySafeState(const QDateTime &now) override;
|
||||
@ -130,8 +136,14 @@ private:
|
||||
//! Vrai si aucun palier déclaré n'est fourni ⇒ modulation continue (contrat §2).
|
||||
bool isDynamic() const { return m_powerLevels.isEmpty(); }
|
||||
|
||||
//! Écrit le state \c powerSetpoint (W) via executeAction (repli \c setStateValue pour mock).
|
||||
//! Écrit le state \c powerSetpoint (W) via executeAction (repli \c setStateValue pour mock),
|
||||
//! et suit son acquittement (ECS-414, modèle d'ECS-410).
|
||||
void writeSetpoint(double powerW);
|
||||
//! Verdict quand l'écriture est acquittée ; fait avancer l'échelle d'un cran.
|
||||
void settleTransition();
|
||||
|
||||
//! Barreaux de l'échelle. Plancher de cet adaptateur : consigne 0 W.
|
||||
enum Phase { PhaseNominale, PhaseRepli, PhasePlancher, PhaseDefaut };
|
||||
|
||||
//! Lit le state \c currentPowerW du thing cible (0 si absent / non exposé).
|
||||
double readCurrentPowerW() const;
|
||||
@ -145,5 +157,13 @@ private:
|
||||
LoadNeeds m_needs;
|
||||
|
||||
double m_currentSetpointW = 0; //!< Dernière consigne écrite (W).
|
||||
|
||||
// --- ECS-414 : suivi asynchrone (modèle d'ECS-410) ----------------------------------
|
||||
int m_pending = 0;
|
||||
bool m_writeFailed = false;
|
||||
double m_setpointPrev = 0;
|
||||
double m_setpointTarget = 0;
|
||||
Phase m_phase = PhaseNominale;
|
||||
bool m_faulted = false;
|
||||
QDateTime m_lastActionAt;
|
||||
};
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
#include <integrations/thing.h>
|
||||
#include <types/action.h>
|
||||
#include <types/param.h>
|
||||
#include <integrations/thingactioninfo.h>
|
||||
|
||||
SgReadyAdapter::SgReadyAdapter(ThingManager *thingManager,
|
||||
const QString &id,
|
||||
@ -55,11 +56,18 @@ LoadDescriptor SgReadyAdapter::descriptor() const
|
||||
LoadTelemetry SgReadyAdapter::telemetry() const
|
||||
{
|
||||
LoadTelemetry t;
|
||||
t.available = true;
|
||||
t.available = !m_faulted; // ECS-414 : sort de l'ARBITRAGE, pas de la COMPTABILITÉ.
|
||||
t.lastActionAt = m_lastActionAt;
|
||||
// Base du recrédit budget = puissance ALLOUÉE de l'état (déclaré), pas la conso mesurée
|
||||
// (états 1/2 → 0 ; états 3/4 → P3/P4). Cf. invariant 8.
|
||||
t.currentPowerW = m_estimatedPowerW.value(m_currentState, 0.0);
|
||||
|
||||
// ECS-414 — INDÉTERMINATION : tant que des écritures sont en vol, on annonce la plus
|
||||
// HAUTE des deux puissances allouées possibles. Ne jamais annoncer moins que ce qui peut
|
||||
// être appliqué (même direction qu'ECS-410/411).
|
||||
if (m_pending > 0)
|
||||
t.currentPowerW = qMax(m_estimatedPowerW.value(m_statePrev, 0.0),
|
||||
m_estimatedPowerW.value(m_stateTarget, 0.0));
|
||||
return t;
|
||||
}
|
||||
|
||||
@ -74,8 +82,15 @@ LoadContext SgReadyAdapter::toLoadContext(const QDateTime &now) const
|
||||
ctx.limits = descriptor().limits;
|
||||
|
||||
ctx.telemetry.currentPowerW = telemetry().currentPowerW;
|
||||
ctx.telemetry.available = !m_faulted;
|
||||
ctx.telemetry.state = m_currentState;
|
||||
ctx.telemetry.lastSwitch = m_lastSwitch;
|
||||
if (m_faulted) {
|
||||
// Charge FIGÉE : ni montée ni descente. Plancher == plafond, comme pour le routeur.
|
||||
ctx.telemetry.minState = ctx.telemetry.maxState = m_currentState;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// Fenêtre de verrou évaluée au temps de cycle (protection court-cycling PAC).
|
||||
lockWindow(now, ctx.telemetry.minState, ctx.telemetry.maxState);
|
||||
return ctx;
|
||||
@ -92,6 +107,14 @@ LoadAction SgReadyAdapter::applyAction(const LoadAction &action, const QDateTime
|
||||
return action;
|
||||
}
|
||||
|
||||
// ECS-414 — en défaut, plus aucune commande, y compris forcée : trois tentatives ont
|
||||
// déjà échoué, en réémettre masquerait l'état sans rien réparer.
|
||||
if (m_faulted) {
|
||||
LoadAction refused = action;
|
||||
refused.state = m_currentState;
|
||||
return refused;
|
||||
}
|
||||
|
||||
// Écrêtage à un état déclaré (borne puis exigence d'appartenance).
|
||||
int newState = qBound(m_states.first(), action.state, m_states.last());
|
||||
if (!m_stateRelays.contains(newState)) {
|
||||
@ -116,7 +139,10 @@ LoadAction SgReadyAdapter::applyAction(const LoadAction &action, const QDateTime
|
||||
<< "(" << m_estimatedPowerW.value(newState, 0.0) << "W estimés)"
|
||||
<< "|" << action.reason;
|
||||
|
||||
applyStateRelays(m_currentState, newState);
|
||||
m_statePrev = m_currentState;
|
||||
m_stateTarget = newState;
|
||||
m_writeFailed = false;
|
||||
applyStateRelays(readActualOn(), newState);
|
||||
|
||||
m_currentState = newState;
|
||||
m_lastSwitch = now;
|
||||
@ -139,7 +165,7 @@ void SgReadyAdapter::applySafeState(const QDateTime &now)
|
||||
sur.state = 2;
|
||||
sur.force = true;
|
||||
sur.reason = QStringLiteral("Charge désactivée — mise en état sûr (état 2) avant retrait (ECS-413)");
|
||||
applyAction(sur, now);
|
||||
applyAction(sur, now); // force = true : minStateHoldS vaut 900 s, attendre est indéfendable
|
||||
}
|
||||
|
||||
// ---- privé ---------------------------------------------------------------
|
||||
@ -200,37 +226,148 @@ QSet<QString> SgReadyAdapter::allRelays() const
|
||||
return all;
|
||||
}
|
||||
|
||||
void SgReadyAdapter::applyStateRelays(int fromState, int toState)
|
||||
QSet<QString> SgReadyAdapter::readActualOn() const
|
||||
{
|
||||
QSet<QString> on;
|
||||
for (const QString &id : allRelays()) {
|
||||
Thing *relay = m_thingManager ? m_thingManager->findConfiguredThing(ThingId(id)) : nullptr;
|
||||
if (!relay) {
|
||||
// Contact INJOIGNABLE : son état est inconnu, on le suppose FERMÉ. Même choix
|
||||
// conservateur qu'ECS-411 (relayrouter.cpp) — ne jamais supposer moins de
|
||||
// puissance appliquée qu'il n'y en a peut-être. Corollaire utile : un repli vers
|
||||
// un état ne demandant aucune écriture ne peut plus « réussir » à vide alors
|
||||
// qu'on ne sait rien du matériel.
|
||||
on.insert(id);
|
||||
continue;
|
||||
}
|
||||
if (relay->stateValue("power").toBool())
|
||||
on.insert(id);
|
||||
}
|
||||
return on;
|
||||
}
|
||||
|
||||
void SgReadyAdapter::writeRelay(const QString &thingId, bool on)
|
||||
{
|
||||
Thing *relay = m_thingManager ? m_thingManager->findConfiguredThing(ThingId(thingId)) : nullptr;
|
||||
if (!relay) {
|
||||
qCWarning(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label << "— relais non trouvé:" << thingId;
|
||||
m_writeFailed = true;
|
||||
return;
|
||||
}
|
||||
StateType powerStateType = relay->thingClass().stateTypes().findByName("power");
|
||||
if (powerStateType.id().isNull()) {
|
||||
relay->setStateValue("power", on); // repli mock : synchrone
|
||||
return;
|
||||
}
|
||||
|
||||
Action powerAction(powerStateType.id(), relay->id(), Action::TriggeredByRule);
|
||||
powerAction.setParams(ParamList() << Param(powerStateType.id(), on));
|
||||
ThingActionInfo *info = m_thingManager->executeAction(powerAction);
|
||||
if (!info) {
|
||||
m_writeFailed = true;
|
||||
return;
|
||||
}
|
||||
// ECS-414 — même modèle qu'ECS-410 : on n'attend pas, le verdict arrive par signal, et
|
||||
// `this` en contexte coupe proprement les callbacks si l'adaptateur meurt.
|
||||
++m_pending;
|
||||
connect(info, &ThingActionInfo::finished, this, [this, info]() {
|
||||
if (info->status() != Thing::ThingErrorNoError) {
|
||||
m_writeFailed = true;
|
||||
qCWarning(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label
|
||||
<< "— écriture contact en échec, status" << info->status();
|
||||
}
|
||||
if (--m_pending == 0)
|
||||
settleTransition();
|
||||
});
|
||||
}
|
||||
|
||||
void SgReadyAdapter::settleTransition()
|
||||
{
|
||||
if (!m_writeFailed) {
|
||||
m_statePrev = m_currentState;
|
||||
m_stateTarget = m_currentState;
|
||||
return;
|
||||
}
|
||||
m_writeFailed = false;
|
||||
|
||||
// Le repli part TOUJOURS du motif RÉELLEMENT lu : après un échec partiel, les deux bits
|
||||
// peuvent former un état valide mais non voulu, voire un motif hors table.
|
||||
//
|
||||
// Il écrit DIRECTEMENT les contacts, sans repasser par applyAction() : le verrou
|
||||
// minStateHoldS n'est donc jamais consulté — équivalent d'un force = true, comme le
|
||||
// repli L2. Sur une PAC, minStateHoldS vaut 900 s : attendre un quart d'heure pour
|
||||
// sortir d'un état non voulu n'est pas défendable.
|
||||
const QSet<QString> reel = readActualOn();
|
||||
|
||||
switch (m_phase) {
|
||||
case PhaseNominale:
|
||||
qCWarning(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label
|
||||
<< "— échec d'écriture : retour à l'état" << m_statePrev << ".";
|
||||
m_phase = PhaseRepli;
|
||||
m_currentState = m_statePrev;
|
||||
applyStateRelays(reel, m_statePrev);
|
||||
break;
|
||||
|
||||
case PhaseRepli:
|
||||
// PLANCHER d'une PAC : l'ÉTAT 2, pas « contacts ouverts ». Ouvrir les deux contacts
|
||||
// est une COMMANDE, et selon l'encodage câblé ce peut être le BLOCAGE (SAFETY.md).
|
||||
qCWarning(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label
|
||||
<< "— retour arrière en échec : repli sur l'état 2 (normal).";
|
||||
m_phase = PhasePlancher;
|
||||
m_currentState = 2;
|
||||
m_stateTarget = 2;
|
||||
applyStateRelays(reel, 2);
|
||||
break;
|
||||
|
||||
case PhasePlancher:
|
||||
case PhaseDefaut:
|
||||
qCCritical(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label
|
||||
<< "— repli état 2 en échec : PAC EN DÉFAUT, plus aucune"
|
||||
<< "commande. Levée par NymeaEnergy.ClearLoadFault.";
|
||||
m_phase = PhaseDefaut;
|
||||
m_faulted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void SgReadyAdapter::clearFault()
|
||||
{
|
||||
if (!m_faulted)
|
||||
return;
|
||||
qCInfo(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label
|
||||
<< "— défaut levé par l'opérateur (ClearLoadFault).";
|
||||
m_faulted = false;
|
||||
m_phase = PhaseNominale;
|
||||
m_writeFailed = false;
|
||||
// État RELU, jamais supposé : le motif 2 bits peut être valide mais non voulu.
|
||||
const int lu = stateForRelays(readActualOn().values());
|
||||
if (lu > 0)
|
||||
m_currentState = lu;
|
||||
m_statePrev = m_stateTarget = m_currentState;
|
||||
}
|
||||
|
||||
void SgReadyAdapter::applyStateRelays(const QSet<QString> ¤tOn, int toState)
|
||||
{
|
||||
const QList<QString> targetList = m_stateRelays.value(toState);
|
||||
const QSet<QString> targetOn(targetList.begin(), targetList.end());
|
||||
const QList<QString> fromList = m_stateRelays.value(fromState);
|
||||
const QSet<QString> currentOn(fromList.begin(), fromList.end());
|
||||
|
||||
// Relais dont l'état change lors de la transition.
|
||||
//
|
||||
// Un contact INJOIGNABLE est toujours inclus, même si l'état supposé coïncide avec la
|
||||
// cible : on ne peut pas le vérifier, donc on doit le COMMANDER. Sans cela, l'hypothèse
|
||||
// conservatrice « injoignable = fermé » masquerait l'échec — la transition paraîtrait
|
||||
// réussie sans qu'aucune écriture n'ait été tentée sur le contact en panne.
|
||||
QStringList changed;
|
||||
for (const QString &relay : allRelays())
|
||||
if (targetOn.contains(relay) != currentOn.contains(relay))
|
||||
for (const QString &relay : allRelays()) {
|
||||
const bool verifiable = m_thingManager && m_thingManager->findConfiguredThing(ThingId(relay));
|
||||
if (!verifiable || targetOn.contains(relay) != currentOn.contains(relay))
|
||||
changed << relay;
|
||||
|
||||
auto writeRelay = [&](const QString &thingId, bool on) {
|
||||
Thing *relay = m_thingManager->findConfiguredThing(ThingId(thingId));
|
||||
if (!relay) {
|
||||
qCWarning(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label << "— relais non trouvé:" << thingId;
|
||||
return;
|
||||
}
|
||||
StateType powerStateType = relay->thingClass().stateTypes().findByName("power");
|
||||
if (!powerStateType.id().isNull()) {
|
||||
Action powerAction(powerStateType.id(), relay->id(), Action::TriggeredByRule);
|
||||
powerAction.setParams(ParamList() << Param(powerStateType.id(), on));
|
||||
m_thingManager->executeAction(powerAction);
|
||||
} else {
|
||||
relay->setStateValue("power", on); // repli mock
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Contrat d'atomicité : si 2 relais (ou +) changent, commuter d'abord celui dont le
|
||||
// TRANSITOIRE est le plus doux (neutre/reco plutôt que blocage/forcé), puis les autres.
|
||||
// Vaut pour le chemin ALLER **comme** pour le REPLI (ECS-414) : une récupération qui
|
||||
// traverserait le blocage serait plus dangereuse que la panne qu'elle corrige.
|
||||
if (changed.size() >= 2) {
|
||||
QString best;
|
||||
int bestHarm = INT_MAX;
|
||||
@ -246,4 +383,8 @@ void SgReadyAdapter::applyStateRelays(int fromState, int toState)
|
||||
// Relais restants amenés à leur valeur cible.
|
||||
for (const QString &r : changed)
|
||||
writeRelay(r, targetOn.contains(r));
|
||||
|
||||
// Chemin entièrement synchrone (mock) : aucun acquittement n'arrivera, verdict immédiat.
|
||||
if (m_pending == 0)
|
||||
settleTransition();
|
||||
}
|
||||
|
||||
@ -7,8 +7,11 @@
|
||||
#include <QList>
|
||||
#include <QHash>
|
||||
#include <QString>
|
||||
#include <QSet>
|
||||
#include "iloadadapter.h"
|
||||
|
||||
class ThingActionInfo;
|
||||
|
||||
class Thing;
|
||||
class ThingManager;
|
||||
|
||||
@ -98,7 +101,15 @@ public:
|
||||
//! \brief Sans effet : la PAC SG-Ready n'implémente pas encore l'échelle de défaut ECS-410.
|
||||
//! La généralisation est portée par **ECS-414** (lot de mise en configuration
|
||||
//! du SgReadyAdapter). Déclaré explicitement plutôt qu'hérité d'un défaut vide.
|
||||
void clearFault() override {}
|
||||
/*!
|
||||
* \brief Lève le verrou de défaut (ECS-410/414, RPC \c ClearLoadFault).
|
||||
* \note L'état réel est RELU depuis les contacts, jamais supposé : après un défaut, le
|
||||
* motif 2 bits peut être valide mais non voulu.
|
||||
*/
|
||||
void clearFault() override;
|
||||
|
||||
//! \return Vrai si la PAC est en défaut (plus aucune commande émise).
|
||||
bool faulted() const { return m_faulted; }
|
||||
|
||||
/*!
|
||||
* \brief État sûr de la PAC : **état 2** (normal, mains off) — JAMAIS l'état 1 (blocage).
|
||||
@ -128,9 +139,28 @@ private:
|
||||
|
||||
bool lockActive(int newState, const QDateTime &now) const;
|
||||
|
||||
//! Applique l'ensemble de relais de \p toState en passant par le transitoire le plus
|
||||
//! doux (cf. contrat d'atomicité). \p fromState = état courant (pour l'ordre).
|
||||
void applyStateRelays(int fromState, int toState);
|
||||
/*!
|
||||
* \brief Amène les contacts à l'encodage de \p toState depuis un motif RÉEL quelconque.
|
||||
* \param currentOn Ensemble des relais actuellement fermés — pas un index d'état.
|
||||
* \param toState État visé.
|
||||
* \note Prend un ENSEMBLE et non un état, parce que le chemin de REPLI (ECS-414) part
|
||||
* d'un motif qui peut être valide mais non voulu — voire hors table — après une
|
||||
* écriture partiellement échouée. Le contrat d'atomicité (\c transientHarm) doit
|
||||
* protéger le repli exactement comme le chemin aller : sans quoi la récupération
|
||||
* traverserait le blocage et serait plus dangereuse que la panne qu'elle corrige.
|
||||
*/
|
||||
void applyStateRelays(const QSet<QString> ¤tOn, int toState);
|
||||
|
||||
//! \return Ensemble des relais RÉELLEMENT fermés, lu sur les Things.
|
||||
QSet<QString> readActualOn() const;
|
||||
|
||||
//! Écrit un contact et suit son acquittement (ECS-414, modèle d'ECS-410).
|
||||
void writeRelay(const QString &thingId, bool on);
|
||||
//! Verdict quand toutes les écritures d'une étape sont acquittées ; fait avancer l'échelle.
|
||||
void settleTransition();
|
||||
|
||||
//! Barreaux de l'échelle. Le plancher d'une PAC est l'ÉTAT 2, jamais « contacts ouverts ».
|
||||
enum Phase { PhaseNominale, PhaseRepli, PhasePlancher, PhaseDefaut };
|
||||
|
||||
//! Rang de nocivité d'un état comme TRANSITOIRE (2 neutre < 3 reco < 1 blocage < 4 forcé).
|
||||
static int transientHarm(int state);
|
||||
@ -150,6 +180,14 @@ private:
|
||||
int m_priority;
|
||||
|
||||
int m_currentState = 2; //!< Démarrage en NORMAL (mains off).
|
||||
|
||||
// --- ECS-414 : suivi asynchrone des écritures (modèle d'ECS-410) ---------------------
|
||||
int m_pending = 0;
|
||||
bool m_writeFailed = false;
|
||||
int m_statePrev = 2;
|
||||
int m_stateTarget = 2;
|
||||
Phase m_phase = PhaseNominale;
|
||||
bool m_faulted = false;
|
||||
QDateTime m_lastSwitch; //!< Dernier changement d'état (null = jamais).
|
||||
QDateTime m_lastActionAt;
|
||||
};
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# SPEC — ECS multi-palier
|
||||
|
||||
Version : 0.5.4
|
||||
Version : 0.5.5
|
||||
Dépôt : `etm-powersync-energy-plugin-etm` (experience-plugin ems)
|
||||
Branche : `feature/beta-rulebased`
|
||||
Contrats faisant autorité : `docs/OPTIMIZER_PROTOCOL.md`, `docs/SAFETY.md`, `AGENTS.md`
|
||||
@ -428,8 +428,8 @@ il est simplement ignoré.
|
||||
(`relayrouter.h:106`) et aucun état de relais n'est relu. Le palier courant DOIT
|
||||
être déduit de l'état réel des Things.
|
||||
|
||||
**ECS-414 — Généralisation de l'échelle d'échec.** ECS-410 n'est implémenté que
|
||||
par `RelayRouter`. `SgReadyAdapter` (`sgreadyadapter.cpp:212`) et
|
||||
**ECS-414 — Généralisation de l'échelle d'échec. FAIT (2026-08-09).** ECS-410 était
|
||||
implémenté par le seul `RelayRouter`. `SgReadyAdapter` (`sgreadyadapter.cpp:212`) et
|
||||
`EtmVariableLoadAdapter` (`etmvariableloadadapter.cpp:134`) jettent toujours le
|
||||
`ThingActionInfo*` retourné par `executeAction` : ils gardent le défaut d'origine
|
||||
— annoncer un état non appliqué.
|
||||
@ -438,9 +438,30 @@ L'échelle DOIT leur être étendue. `clearFault()` et `applySafeState()` sont d
|
||||
déclarées sur ces adaptateurs, sans effet pour la première : le point d'accroche
|
||||
existe, seul le suivi asynchrone manque.
|
||||
|
||||
> **Rattaché au lot de mise en configuration du `SgReadyAdapter`**, qui vient de
|
||||
> toute façon ensuite et touche les mêmes fichiers. Ce lot porte déjà sa propre
|
||||
> précondition — retirer `Q_ASSERT(m_stateRelays.contains(2))`
|
||||
**Le plancher est propre à chaque adaptateur** (même principe qu'ECS-413) :
|
||||
consigne 0 W pour `EtmVariableLoadAdapter`, mais **état 2** pour `SgReadyAdapter`.
|
||||
Ouvrir les deux contacts d'une PAC n'est pas une mise en sécurité, c'est une
|
||||
commande — et selon l'encodage câblé, potentiellement le **blocage**.
|
||||
|
||||
**Le repli honore le même contrat d'atomicité que le chemin aller.** Un état
|
||||
SG-Ready est porté par deux bits : si une écriture échoue, on se retrouve dans un
|
||||
motif valide mais non voulu, et le retour depuis ce motif peut exiger de traverser
|
||||
le blocage. `applyStateRelays()` prend donc un **ensemble de relais réellement
|
||||
fermés**, et non un index d'état, afin que `transientHarm` ordonne le repli comme
|
||||
il ordonne l'aller. Sans cela, la récupération serait plus dangereuse que la panne
|
||||
qu'elle corrige.
|
||||
|
||||
**Les écritures de repli contournent `minStateHoldS`** — 900 s sur une PAC :
|
||||
attendre un quart d'heure pour sortir d'un état non voulu n'est pas défendable.
|
||||
|
||||
\note Un contact **injoignable est supposé FERMÉ** pour la télémétrie (cohérence
|
||||
ECS-411) **mais toujours COMMANDÉ** : ce qu'on ne peut pas vérifier doit être
|
||||
écrit, sinon l'hypothèse conservatrice masquerait l'échec et la transition
|
||||
paraîtrait réussie sans qu'aucune écriture n'ait été tentée.
|
||||
|
||||
> **Reste au lot de mise en configuration du `SgReadyAdapter`** : les paliers
|
||||
> `{3: 1500, 4: 3000}` demeurent codés en dur (`energypluginnymea.cpp:66-76`).
|
||||
> Ce lot porte sa propre précondition — retirer `Q_ASSERT(m_stateRelays.contains(2))`
|
||||
> (`sgreadyadapter.cpp:36`) au profit d'un refus explicite, cf. ECS-110.
|
||||
|
||||
**ECS-413 — Désactivation en état sûr.** Désactiver une charge (`enabled: false`)
|
||||
@ -606,7 +627,7 @@ mécanique reste **L0**. Perte de sonde → mode sans sonde, pas arrêt d'urgenc
|
||||
| ECS-410 | simulation | `testEcsPartialFailure` — cible, retour arrière, arrêt total, défaut collant |
|
||||
| ECS-411 | simulation | `testEcsRestartRecovery` |
|
||||
| ECS-413 | simulation | `testEcsDisableLeavesSafeState` — + cas négatif : un rebuild sans désactivation ne coupe pas |
|
||||
| ECS-414 | simulation | à écrire avec le lot SG-Ready en configuration |
|
||||
| ECS-414 | simulation | `testSgReadyPartialFailure` — plancher = état 2, atomicité du repli, contact injoignable |
|
||||
| ECS-110, ECS-111 | unitaire | `testEcsConfigValidation` — DOIT s'exécuter aussi en build release (`QT_NO_DEBUG`), sinon il ne prouve rien du binaire livré |
|
||||
| ECS-501, ECS-502 | simulation | `testEcsStageFault` |
|
||||
| ECS-601, ECS-602 | simulation | `testEcsTemperatureTarget` |
|
||||
@ -677,6 +698,7 @@ protocole dans le même lot.**
|
||||
| 2026-08-08 | **Étape 1 OUVERTE** — trois exigences : ECS-306, ECS-411, ECS-412 |
|
||||
| 2026-08-08 | ECS-412 : au démarrage à froid, le verrou est **ARMÉ** (défaut sûr), jamais purgé |
|
||||
| 2026-08-09 | ECS-412 précisé : cet armement est **TRANSITOIRE** et posé paresseusement au premier `now`. Défaut de blocage circulaire constaté **au banc**, corrigé, couvert par `testEcsColdStartLockExpires` |
|
||||
| 2026-08-09 | **ECS-414 FAIT** — échelle généralisée à SgReady et EtmVariableLoad ; plancher = état 2 pour la PAC ; repli soumis au même `transientHarm` que l'aller ; contact injoignable supposé fermé mais toujours commandé |
|
||||
| 2026-08-09 | **ECS-414 créé** — généralisation d'ECS-410 à SgReady et EtmVariableLoad, rattachée au lot SG-Ready en configuration |
|
||||
| 2026-08-09 | **ECS-413 créé** — `enabled: false` laissait les relais fermés (constat de banc, juste avant une intervention de câblage). État sûr propre à chaque adaptateur ; ne s'applique PAS à un redémarrage de `nymead`, qu'ECS-411 couvre |
|
||||
| 2026-08-08 | ~~Câblage 500/1000/2000~~ — **donnée fausse**, corrigée le jour même (cf. ligne suivante) |
|
||||
|
||||
@ -1298,6 +1298,87 @@ void Simulation::testEcsDisableLeavesSafeState()
|
||||
#endif
|
||||
}
|
||||
|
||||
void Simulation::testSgReadyPartialFailure()
|
||||
{
|
||||
#ifndef ETM_ARBITRATOR
|
||||
QSKIP("testSgReadyPartialFailure nécessite ETM_ARBITRATOR.");
|
||||
#else
|
||||
// [ECS-414] Même échelle qu'ECS-410, mais le PLANCHER d'une PAC est l'ÉTAT 2 — jamais
|
||||
// « contacts ouverts », qui est une COMMANDE et peut valoir le BLOCAGE selon l'encodage.
|
||||
cleanupTestCase();
|
||||
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||||
initTestCase();
|
||||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||||
QVERIFY(arb);
|
||||
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||||
|
||||
QUuid k1 = addPowerSwitch(0, 26661);
|
||||
Thing *tK1 = tm->findConfiguredThing(k1);
|
||||
QVERIFY(tK1);
|
||||
const QString k2ko = QStringLiteral("{deadbeef-1111-1111-1111-111111111111}"); // jamais configuré
|
||||
|
||||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
|
||||
auto st = [&](int s) {
|
||||
LoadAction a; a.kind = LoadAction::State; a.funding = LoadAction::Surplus;
|
||||
a.state = s; a.reason = QStringLiteral("test échec SG-Ready"); return a;
|
||||
};
|
||||
auto laisserRetomber = [&]() { QTest::qWait(300); };
|
||||
|
||||
// --- Cas nominal : encodage entièrement valide -----------------------------------------
|
||||
{
|
||||
SgReadyAdapter *sain = new SgReadyAdapter(tm, "pac-ok", "PAC saine",
|
||||
QHash<int, QList<QString>>({ {1,{k1.toString()}}, {2,{}}, {3,{}}, {4,{k1.toString()}} }),
|
||||
QHash<int, double>({ {1,0.0}, {2,0.0}, {3,1500.0}, {4,3000.0} }), 0, 2, arb);
|
||||
sain->applyAction(st(4), t0);
|
||||
laisserRetomber();
|
||||
QCOMPARE(sain->currentState(), 4);
|
||||
QVERIFY2(!sain->faulted(), "une écriture réussie ne doit pas lever de défaut");
|
||||
QCOMPARE(sain->telemetry().available, true);
|
||||
}
|
||||
|
||||
// --- Échelle complète, et surtout : QUEL est le plancher ------------------------------
|
||||
// Encodage : état 1 = K2 seul (BLOCAGE), état 3 = K1 seul, état 4 = K1+K2.
|
||||
// K2 est injoignable. Un contact qu'on ne peut jamais confirmer rend TOUT état
|
||||
// inconfirmable : l'échelle va donc jusqu'au défaut, et c'est l'issue honnête.
|
||||
//
|
||||
// Ce que ce test verrouille n'est pas le défaut lui-même, c'est le PLANCHER : le repli
|
||||
// ramène K1 à l'OUVERTURE, c'est-à-dire l'état 2 sur cet encodage. Un plancher « contacts
|
||||
// ouverts par principe » aurait pu, sur un autre câblage, valoir l'état 1 — le BLOCAGE.
|
||||
SgReadyAdapter *pac = new SgReadyAdapter(tm, "pac-ko", "PAC contact HS",
|
||||
QHash<int, QList<QString>>({ {1,{k2ko}}, {2,{}}, {3,{k1.toString()}}, {4,{k1.toString(), k2ko}} }),
|
||||
QHash<int, double>({ {1,0.0}, {2,0.0}, {3,1500.0}, {4,3000.0} }), 900, 2, arb);
|
||||
tK1->setStateValue("power", false);
|
||||
|
||||
pac->applyAction(st(4), t0);
|
||||
laisserRetomber();
|
||||
|
||||
QVERIFY2(pac->faulted(), "un contact jamais confirmable doit mener au défaut");
|
||||
|
||||
// Le repli a bien COMMANDÉ K1 : il est ouvert, donc l'encodage de l'état 2 a été visé.
|
||||
// Le verrou minStateHoldS de 900 s ne l'a pas retardé — le repli écrit les contacts
|
||||
// directement, équivalent d'un force = true, comme le repli L2. Attendre un quart d'heure
|
||||
// pour sortir d'un état non voulu ne serait pas défendable.
|
||||
QCOMPARE(tK1->stateValue("power").toBool(), false);
|
||||
QCOMPARE(pac->telemetry().available, false);
|
||||
|
||||
LoadContext c = pac->toLoadContext(t0.addSecs(60));
|
||||
QCOMPARE(c.telemetry.available, false);
|
||||
// Charge FIGÉE : plancher == plafond.
|
||||
QCOMPARE(c.telemetry.minState, c.telemetry.maxState);
|
||||
|
||||
// Plus aucune commande, même bien après le verrou.
|
||||
pac->applyAction(st(3), t0.addSecs(7200));
|
||||
laisserRetomber();
|
||||
QCOMPARE(tK1->stateValue("power").toBool(), false);
|
||||
QVERIFY(pac->faulted());
|
||||
|
||||
// Levée délibérée : l'état est RELU depuis les contacts, pas supposé.
|
||||
pac->clearFault();
|
||||
QVERIFY(!pac->faulted());
|
||||
QCOMPARE(pac->telemetry().available, true);
|
||||
#endif
|
||||
}
|
||||
|
||||
void Simulation::run_data()
|
||||
{
|
||||
// Simulation infos
|
||||
|
||||
@ -106,6 +106,9 @@ private slots:
|
||||
// son dernier état commandé — et un rebuild SANS désactivation ne coupe rien (cas négatif
|
||||
// obligatoire, sans lui ECS-412 serait annulé).
|
||||
void testEcsDisableLeavesSafeState();
|
||||
// [ECS-414] L'échelle d'échec généralisée à SgReadyAdapter et EtmVariableLoadAdapter.
|
||||
// Plancher propre à chaque adaptateur : ÉTAT 2 pour la PAC, jamais « contacts ouverts ».
|
||||
void testSgReadyPartialFailure();
|
||||
|
||||
void printStates(Thing *thing);
|
||||
void updateChargerMeter(Thing *thing);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user