fix(etm): ECS-410 — échec d'écriture relais, échelle bornée et défaut collant

writeRelay() jetait le ThingActionInfo* : aucun acquittement, aucun retour
arrière, available codé en dur à true, et m_currentStage mis à jour comme si tout
avait réussi — on annonçait une puissance non appliquée.

MODÈLE ASYNCHRONE. executeAction est asynchrone et update() ne doit jamais
attendre (AGENTS règle 5). « Attendre le résultat » ne veut donc pas dire bloquer
le cycle : l'écriture est émise, l'adaptateur retient combien d'acquittements il
attend (m_pending), le verdict tombe quand le compteur retombe à zéro, et la
conséquence est traitée au cycle suivant. Motif repris tel quel d'EvCharger
(evcharger.cpp:293-299), y compris `this` en contexte de connexion — si
l'adaptateur meurt, les callbacks sont coupés proprement.

INDÉTERMINATION. Pendant une transition, telemetry() annonce
max(m_stagePrev, m_stageTarget) : on ne sait pas ce qui est fermé, on annonce donc
la plus haute des deux puissances possibles. Même direction qu'ECS-411 (relais
injoignable supposé fermé) — ne jamais annoncer moins que ce qui peut être
appliqué. Sous-estimer fait sur-allouer les charges suivantes ; surestimer ne fait
que retarder une montée.

ÉCHELLE BORNÉE à trois barreaux, une tentative chacun, aucune boucle : cible →
retour arrière → arrêt total → défaut. Le retour arrière est asynchrone au même
titre et passe par le même compteur.

DÉFAUT COLLANT, pas clignotant. m_faulted est un verrou posé une seule fois, sans
délai ni expiration : available ne peut pas osciller d'un cycle à l'autre. Seul
NymeaEnergy.ClearLoadFault le lève — acte délibéré et journalisé de l'opérateur.
La reconstruction le lève aussi, mais par construction : un adaptateur neuf n'a
pas d'historique. À la levée, l'état matériel est RELU (ECS-411), pas supposé.

CANAL OUVERT. LoadContext n'avait AUCUN champ available : le publier aurait été
décoratif. Ajouté à LoadContextTelemetry, avec sa sémantique écrite noir sur
blanc — il gouverne l'allocation, PAS la comptabilité. Une charge en défaut ne
reçoit rien mais reste comptée : une puissance qu'on ne sait plus couper est de la
conso fixe, au même titre que la base de la maison. Le figeage est porté par
lockMin == lockMax == puissance crue engagée, jamais un plafond nul sous un
plancher non nul. Le même canal servira ECS-601.

OPTIMIZER_PROTOCOL.md mis à jour dans le MÊME lot, comme l'exige le §11 de la
spec : available, lockMinPowerW et lockMaxPowerW documentés avec leur sémantique.

clearFault() est PURE VIRTUELLE sur ILoadAdapter : les trois autres adaptateurs la
déclarent sans effet plutôt que d'hériter d'un défaut vide. Leur généralisation est
portée par ECS-414.

Test testEcsPartialFailure : cas nominal sans défaut, puis échelle complète via un
relais introuvable — défaut atteint, relais valide bien ramené à l'ouverture par la
tentative d'arrêt total, available faux, plancher == plafond == puissance comptée,
plus aucune commande une heure plus tard, puis levée délibérée.

Build amd64 0 erreur. Simulation : 16/16.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-08-09 11:32:51 +02:00
parent cf6843d50c
commit 92d0bef4ac
15 changed files with 411 additions and 19 deletions

View File

@ -150,6 +150,25 @@ Notes de schéma :
- `forecast` : contenu défini par `CONTRACT_A_openmeteo.md`. Peut être `null`
(l'optimiseur peut sourcer ses propres prévisions — il est libre).
### `loads[].telemetry` — champs ETM non encore normalisés
Trois champs ajoutés par l'implémentation ETM et **absents des exemples ci-dessus**.
Ils sont documentés ici parce qu'ils changent la lecture du contexte, et qu'une
implémentation d'optimiseur qui les ignore prendra de mauvaises décisions.
| Champ | Type | Sens |
|---|---|---|
| `available` | bool | **Gouverne l'ALLOCATION, pas la COMPTABILITÉ.** Faux ⇒ la charge ne doit recevoir **aucune** consigne neuve — mais elle **continue d'être comptée** pour ce qu'elle consomme via `currentPowerW`. Un optimiseur qui se contenterait de « sauter » les charges indisponibles sauterait aussi leur comptabilité, et offrirait aux charges suivantes un budget qui n'existe pas. |
| `lockMinPowerW` | W | Plancher imposé **maintenant** par un verrou anti-rebond : puissance déjà engagée que l'adaptateur ne peut pas couper. Le budget DOIT être décrémenté de cette valeur **même si elle dépasse le budget disponible** — sinon les charges de rang suivant reçoivent un résidu surestimé et l'installation soutire au réseau. |
| `lockMaxPowerW` | W | Plafond imposé **maintenant** par un verrou : au-delà, l'adaptateur refusera de démarrer. **Négatif ⇒ aucun plafond** (et non « plafond nul »). |
Une charge en défaut d'écriture est **figée** : `available = false` et
`lockMinPowerW == lockMaxPowerW ==` la puissance crue engagée. Le plancher n'est
jamais supérieur au plafond — un tel couple ferait dérailler tout écrêtage.
Ces champs sont exprimés **en watts**, jamais en index de palier : aucun
identifiant de relais ni index de combinaison ne franchit cette frontière.
## 6. Réponse : plan par créneaux
```json

View File

@ -111,6 +111,11 @@ public:
void updateSoftConfig(int priority, const LoadNeeds &needs) override
{ m_priority = priority; m_needs = needs; }
//! \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 Dernière consigne (W) effectivement écrite (avant écrêtage thing).
* \return Consigne commandée en W, bornée à \c maxPowerW ; 0 tant qu'aucune action

View File

@ -76,6 +76,11 @@ public:
void updateSoftConfig(int priority, const LoadNeeds &needs) override
{ Q_UNUSED(priority) Q_UNUSED(needs) }
//! \brief Sans effet : l'EV 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 Borne VE sous-jacente (lecture).
* \return Pointeur non-propriétaire vers l'EvCharger ; jamais nul (fixé à la construction).

View File

@ -92,4 +92,18 @@ public:
* la config ignorerait sans bruit les changements de rang.
*/
virtual void updateSoftConfig(int priority, const LoadNeeds &needs) = 0;
/*!
* \brief Lève le verrou de défaut de la charge (ECS-410).
*
* Le défaut est COLLANT par conception « cesser toute commande jusqu'à intervention ».
* Sa levée est un acte délibéré de l'opérateur, jamais une expiration : sans quoi
* \c available oscillerait d'un cycle à l'autre et le waterfall réagirait à chaque
* battement.
*
* \note **Pure virtuelle à dessein.** Tous les adaptateurs ne savent pas encore tomber en
* défaut seul \c RelayRouter implémente l'échelle ECS-410 mais chacun doit le
* DÉCLARER. La généralisation est portée par **ECS-414**.
*/
virtual void clearFault() = 0;
};

View File

@ -11,6 +11,7 @@
#include <integrations/thing.h>
#include <types/action.h>
#include <types/param.h>
#include <integrations/thingactioninfo.h>
namespace {
//! Au-delà, 2^N combinaisons explose. 16 relais = 65536 combos — bien au-delà de tout ECS réel.
@ -93,7 +94,7 @@ LoadDescriptor RelayRouter::descriptor() const
LoadTelemetry RelayRouter::telemetry() const
{
LoadTelemetry t;
t.available = true;
t.available = !m_faulted; // ECS-410 : en défaut, la charge sort de l'ARBITRAGE…
t.lastActionAt = m_lastActionAt;
// currentPowerW :
@ -118,6 +119,19 @@ LoadTelemetry RelayRouter::telemetry() const
if (!metered && m_currentStage > 0 && m_currentStage < m_levels.size())
power = m_levels.at(m_currentStage); // nominal commandé
// ECS-410 — INDÉTERMINATION. Tant que des écritures sont en vol, on ne sait pas ce qui est
// réellement fermé : on annonce la PLUS HAUTE des deux puissances possibles. Même direction
// qu'ECS-411 (relais injoignable supposé fermé) : ne JAMAIS annoncer moins que ce qui peut
// être appliqué. Sous-estimer fait sur-allouer les charges suivantes et soutirer au réseau ;
// surestimer ne fait que retarder une montée.
if (!metered && m_pending > 0) {
const int hi = qMax(m_stagePrev, m_stageTarget);
if (hi >= 0 && hi < m_levels.size())
power = qMax(power, static_cast<double>(m_levels.at(hi)));
}
// …mais PAS de la COMPTABILITÉ : une puissance qu'on ne sait plus couper reste consommée.
// La retirer du bilan offrirait aux charges suivantes un budget qui n'existe pas.
t.currentPowerW = power;
return t;
}
@ -139,6 +153,17 @@ LoadContext RelayRouter::toLoadContext(const QDateTime &now) const
// internes en puissances via la table de paliers. Sans ce canal, le scheduler décrémente
// le budget d'une valeur que l'adaptateur va écrêter, et les charges suivantes reçoivent
// un résidu surestimé pendant toute la fenêtre minOn (AGENTS règle 4).
ctx.telemetry.available = !m_faulted;
if (m_faulted) {
// ECS-410 — charge FIGÉE, ni montée ni descente : plancher ET plafond à la puissance
// crue engagée. Un plafond nul avec un plancher non nul serait contradictoire et
// ferait dérailler le qBound du scheduler.
ctx.telemetry.lockMinPowerW = ctx.telemetry.currentPowerW;
ctx.telemetry.lockMaxPowerW = ctx.telemetry.currentPowerW;
return ctx;
}
int minStage, maxStage;
lockWindow(now, minStage, maxStage);
ctx.telemetry.lockMinPowerW = m_levels.at(minStage);
@ -157,6 +182,14 @@ LoadAction RelayRouter::applyAction(const LoadAction &action, const QDateTime &n
return action;
}
// ECS-410 — en défaut, on CESSE TOUTE COMMANDE, y compris les replis forcés : le matériel
// n'a pas répondu à trois tentatives, en réémettre n'apporterait rien et masquerait l'état.
if (m_faulted) {
LoadAction refused = action;
refused.powerW = refused.estimatedPowerW = currentSetpointW();
return refused;
}
// ECS-412 — ARMEMENT PARESSEUX du verrou (démarrage à froid). Au premier \c now reçu,
// on estampille la « dernière commutation » : le verrou est donc armé pour sa durée
// configurée, puis EXPIRE naturellement.
@ -324,13 +357,110 @@ int RelayRouter::deduceStageFromThings() const
return m_levels.size() - 1;
}
void RelayRouter::applyRelayStage(int stage)
void RelayRouter::writeRelay(const QString &thingId, bool on)
{
Thing *relay = m_thingManager ? m_thingManager->findConfiguredThing(ThingId(thingId)) : nullptr;
if (!relay) {
qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label << "— relais non trouvé:" << thingId;
m_writeFailed = true; // compte comme un échec : ECS-410 §1
return;
}
StateType powerStateType = relay->thingClass().stateTypes().findByName("power");
if (powerStateType.id().isNull()) {
relay->setStateValue("power", on); // repli mock : synchrone, toujours réussi
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-410 — on n'attend PAS : le cycle rend la main immédiatement (AGENTS règle 5).
// Le verdict arrive par signal, et la conséquence est traitée au cycle suivant.
// `this` en contexte de connexion : si l'adaptateur meurt, le callback est coupé
// proprement — motif repris d'EvCharger::setChargingEnabled (evcharger.cpp:293-299).
++m_pending;
connect(info, &ThingActionInfo::finished, this, [this, info]() {
if (info->status() != Thing::ThingErrorNoError) {
m_writeFailed = true;
qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label
<< "— écriture relais en échec, status" << info->status();
}
if (--m_pending == 0)
settleTransition();
});
}
void RelayRouter::settleTransition()
{
// Appelé quand TOUTES les écritures d'une étape sont acquittées. Fait avancer l'échelle
// d'ECS-410 d'un cran, une tentative par cran, sans jamais boucler.
if (!m_writeFailed) {
m_stagePrev = m_currentStage; // transition confirmée : l'indétermination cesse
m_stageTarget = m_currentStage;
return;
}
m_writeFailed = false;
switch (m_phase) {
case PhaseNominale:
// Barreau 2 — retour à l'état précédent.
qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label
<< "— échec d'écriture : retour au palier" << m_levels.at(m_stagePrev) << "W.";
m_phase = PhaseRepli;
m_currentStage = m_stagePrev;
emitRelayWrites(m_stagePrev);
break;
case PhaseRepli:
// Barreau 3 — arrêt total. Dernière tentative.
qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << m_label
<< "— retour arrière en échec : arrêt total.";
m_phase = PhaseArretTotal;
m_currentStage = 0;
m_stageTarget = 0;
emitRelayWrites(0);
break;
case PhaseArretTotal:
case PhaseDefaut:
// Fin de l'échelle : verdict. Verrou COLLANT — aucune commande jusqu'à
// ClearLoadFault (ECS-410 §4). Il ne se lève pas tout seul : pas de clignotement.
qCCritical(dcNymeaEnergy()) << "[RelayRouter]" << m_label
<< "— arrêt total en échec : charge EN DÉFAUT, plus aucune"
<< "commande émise. Levée par NymeaEnergy.ClearLoadFault.";
m_phase = PhaseDefaut;
m_faulted = true;
break;
}
}
void RelayRouter::clearFault()
{
if (!m_faulted)
return;
qCInfo(dcNymeaEnergy()) << "[RelayRouter]" << m_label
<< "— défaut levé par l'opérateur (ClearLoadFault).";
m_faulted = false;
m_phase = PhaseNominale;
m_writeFailed = false;
// L'état matériel réel est inconnu après un défaut : on le relit plutôt que de le supposer
// (même principe qu'ECS-411, et jamais moins que ce qui peut être appliqué).
m_currentStage = deduceStageFromThings();
m_stagePrev = m_stageTarget = m_currentStage;
}
void RelayRouter::emitRelayWrites(int stage)
{
// Set CIBLE du palier (delta complet : chaque relais connu amené à son état on/off cible).
// Gère les mappings NON-CASCADÉS (monter d'un palier peut éteindre des relais).
const QSet<QString> wantOn = [&]() {
QSet<QString> s;
if (stage < m_relayMapping.size())
if (stage >= 0 && stage < m_relayMapping.size())
for (const QString &id : m_relayMapping.at(stage))
s.insert(id);
return s;
@ -341,22 +471,6 @@ void RelayRouter::applyRelayStage(int stage)
for (const QString &id : list)
allRelays.insert(id);
auto writeRelay = [&](const QString &thingId, bool on) {
Thing *relay = m_thingManager->findConfiguredThing(ThingId(thingId));
if (!relay) {
qCWarning(dcNymeaEnergy()) << "[RelayRouter]" << 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
}
};
// off-before-on : couper d'abord les relais hors-cible, puis enclencher la cible → pas de
// sur-puissance transitoire (somme des deux paliers) sur une transition non-cascadée.
for (const QString &id : allRelays)
@ -364,4 +478,17 @@ void RelayRouter::applyRelayStage(int stage)
writeRelay(id, false);
for (const QString &id : wantOn)
writeRelay(id, true);
// Chemin entièrement synchrone (mock, ou aucun relais) : aucun acquittement n'arrivera,
// le verdict se prend donc tout de suite.
if (m_pending == 0)
settleTransition();
}
void RelayRouter::applyRelayStage(int stage)
{
m_stagePrev = m_currentStage;
m_stageTarget = stage;
m_writeFailed = false;
emitRelayWrites(stage);
}

View File

@ -7,6 +7,8 @@
#include <QList>
#include <QString>
#include "iloadadapter.h"
class ThingActionInfo;
#include "../types/loadconfig.h" // LoadConfigRelay
class Thing;
@ -93,7 +95,27 @@ public:
//! l'adaptateur — donc sans réarmer les verrous ni recommuter les relais.
void updateSoftConfig(int priority, const LoadNeeds &needs) override;
/*!
* \brief Lève le verrou de défaut (ECS-410, RPC \c NymeaEnergy.ClearLoadFault).
* \note Le défaut est COLLANT par conception « cesser toute commande jusqu'à
* intervention ». Sa levée est donc un acte délibéré et tracé de l'opérateur, jamais
* une expiration. L'état matériel réel étant inconnu après un défaut, il est RELU
* (même principe qu'ECS-411) et non supposé.
*/
void clearFault() override;
//! \return Vrai si la charge est en défaut (plus aucune commande émise).
bool faulted() const { return m_faulted; }
private:
//! Barreaux de l'échelle ECS-410. Une tentative par barreau, jamais de boucle.
enum Phase { PhaseNominale, PhaseRepli, PhaseArretTotal, PhaseDefaut };
void writeRelay(const QString &thingId, bool on);
void emitRelayWrites(int stage);
//! Verdict pris quand toutes les écritures d'une étape sont acquittées.
void settleTransition();
//! Palier le plus haut dont la puissance ≤ \p powerW (≥ 0).
int stageForPower(double powerW) const;
/*!
@ -124,4 +146,12 @@ private:
int m_currentStage = 0;
QDateTime m_lastSwitch; //!< Dernier changement (null = jamais).
QDateTime m_lastActionAt;
// --- ECS-410 : suivi asynchrone des écritures ---------------------------------------
int m_pending = 0; //!< Écritures émises non encore acquittées.
bool m_writeFailed = false; //!< Au moins un acquittement en erreur sur l'étape courante.
int m_stagePrev = 0; //!< Palier avant la transition en cours.
int m_stageTarget = 0; //!< Palier visé par la transition en cours.
Phase m_phase = PhaseNominale;
bool m_faulted = false; //!< Verrou de défaut. COLLANT : seul clearFault() le lève.
};

View File

@ -95,6 +95,11 @@ public:
void updateSoftConfig(int priority, const LoadNeeds &needs) override
{ Q_UNUSED(priority) Q_UNUSED(needs) }
//! \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 État SG-Ready courant (1-4).
* \return Dernier état réellement commuté ; valeur initiale tant qu'aucune transition

View File

@ -156,6 +156,18 @@ bool EnergyArbitrator::sameHardware(const LoadConfig &a, const LoadConfig &b)
return true;
}
bool EnergyArbitrator::clearLoadFault(const QString &loadId)
{
ILoadAdapter *adapter = m_loadAdapters.value(loadId, nullptr);
if (!adapter) {
qCWarning(dcNymeaEnergy()) << "[Arbitre] ClearLoadFault : charge inconnue" << loadId;
return false;
}
qCInfo(dcNymeaEnergy()) << "[Arbitre] ClearLoadFault demandé par l'opérateur pour" << loadId;
adapter->clearFault();
return true;
}
void EnergyArbitrator::rebuildLoadAdapters()
{
if (!m_loadConfigStore) {

View File

@ -117,6 +117,15 @@ public:
*/
void setLoadConfigStore(LoadConfigStore *store);
/*!
* \brief Lève le verrou de défaut d'une charge pilotée (ECS-410).
* \param loadId Identifiant logique de la charge.
* \return Vrai si la charge existe ; faux si l'identifiant est inconnu.
* \note Acte délibéré de l'opérateur, exposé par \c NymeaEnergy.ClearLoadFault. Le défaut
* ne se lève jamais seul : c'est ce qui empêche \c available de clignoter.
*/
bool clearLoadFault(const QString &loadId);
/*!
* \brief Mode dégradé L2 actif (compteur muet > 90 s) override de SmartChargingManager.
* \return \c true tant que les consignes de repli L2 tiennent ; \c false en régime normal.

View File

@ -204,6 +204,29 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc,
setpointW = chosen;
}
// ECS-410 — charge INDISPONIBLE : elle sort de l'ARBITRAGE, pas de la COMPTABILITÉ.
// Aucune allocation neuve, mais sa consommation reste retranchée du budget : une
// puissance qu'on ne sait plus couper est de la conso fixe, au même titre que la base de
// la maison. La retirer du bilan offrirait aux charges suivantes un budget qui n'existe
// pas — c'est le trou d'ECS-306 rouvert par une autre porte.
//
// Le figeage est porté par l'adaptateur : lockMin == lockMax == puissance crue engagée.
// Le clamp ci-dessous suffit donc à produire la bonne comptabilité, sans branche spéciale.
if (!lc.telemetry.available) {
const double gele = lc.telemetry.lockMinPowerW;
LoadAction la;
la.loadId = lc.id;
la.kind = LoadAction::Setpoint;
la.funding = LoadAction::Surplus;
la.powerW = gele;
la.estimatedPowerW = gele;
la.reason = QStringLiteral("%1 INDISPONIBLE — aucune commande émise ; %2 W toujours "
"comptés au budget (levée par ClearLoadFault)")
.arg(lc.label).arg(qRound(gele));
remainingSurplusW = budgetW - gele;
return la;
}
// ECS-306 — clamp lock-aware AVANT de décrémenter le budget (AGENTS règle 4 : les
// bornes de l'adaptateur écrêtent TOUTE sortie de stratégie, et leur résultat doit
// revenir à l'arbitre). Sans ceci, l'adaptateur remonte le palier de son côté et le

View File

@ -55,6 +55,22 @@ struct SurplusTariff {
/*! \brief Télémétrie d'une charge dans le contexte §5 loads[].telemetry. */
struct LoadContextTelemetry {
double currentPowerW = 0; //!< Puissance mesurée (W).
/*!
* \brief Disponibilité de la charge pour l'ARBITRAGE (ECS-410, et plus tard ECS-601).
*
* **Sémantique à ne pas confondre elle gouverne l'ALLOCATION, pas la COMPTABILITÉ.**
* Une charge indisponible ne reçoit **rien** de neuf, mais elle continue d'être **comptée
* pour ce qu'elle consomme réellement** via \c currentPowerW. Un scheduler qui se
* contenterait de « sauter » les charges indisponibles sauterait aussi leur comptabilité
* et rouvrirait le trou d'ECS-306 par une autre porte : les charges suivantes se verraient
* offrir un budget qui n'existe pas.
*
* Faux quand : la charge est en défaut d'écriture (ECS-410, verrou collant levé seulement
* par \c ClearLoadFault), ou à venir quand une sonde thermique déclare la consigne
* atteinte (ECS-601). Les deux cas convergent : un ballon en température a une conso réelle
* nulle, donc la distinction ne mord que sur le défaut.
*/
bool available = true;
// --- evcharger ---
bool pluggedIn = false; //!< Véhicule branché. Une borne non branchée est ignorée du plan.
bool charging = false; //!< Charge effectivement en cours (courant non nul côté borne).

View File

@ -23,6 +23,9 @@
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#include "nymeaenergyjsonhandler.h"
#ifdef ETM_ARBITRATOR
#include "etm/energyarbitrator.h"
#endif
#include "types/charginginfo.h"
#include "smartchargingmanager.h"
#include "spotmarket/spotmarketmanager.h"
@ -194,6 +197,16 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
returns.insert("o:autonomyRate", enumValueName(Double));
registerMethod("GetEnergyRatios", description, params, returns, Types::PermissionScopeControlThings);
// [ETM] ECS-410 — levée du verrou de défaut d'une charge. Délibérée et journalisée : le
// défaut est collant par conception (« cesser toute commande jusqu'à intervention »), sa
// levée doit donc être un acte tracé, pas un effet de bord d'un autre appel.
params.clear(); returns.clear();
description = "Clear the fault latch of a controllable load (ECS-410). Deliberate operator "
"action: the fault never clears by itself.";
params.insert("loadId", enumValueName(String));
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
registerMethod("ClearLoadFault", description, params, returns, Types::PermissionScopeControlThings);
// Notifications
params.clear();
@ -577,6 +590,23 @@ QVariantMap NymeaEnergyJsonHandler::currentEnergyRatios()
return result;
}
JsonReply *NymeaEnergyJsonHandler::ClearLoadFault(const QVariantMap &params)
{
#ifdef ETM_ARBITRATOR
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_smartChargingManager);
if (arb && arb->clearLoadFault(params.value("loadId").toString())) {
QVariantMap ret;
ret.insert("energyError", enumValueName(EnergyManager::EnergyErrorNoError));
return createReply(ret);
}
#else
Q_UNUSED(params)
#endif
QVariantMap ret;
ret.insert("energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter));
return createReply(ret);
}
JsonReply *NymeaEnergyJsonHandler::GetEnergyRatios(const QVariantMap &params)
{
Q_UNUSED(params)

View File

@ -73,6 +73,17 @@ public:
// [Phase 2] Ratios canoniques (autoconsommation / autonomie), % [0,100].
Q_INVOKABLE JsonReply *GetEnergyRatios(const QVariantMap &params);
/*!
* \brief [ETM] Lève le verrou de défaut d'une charge pilotée (ECS-410).
* \param params \c {"loadId": string}.
* \return \c energyError \c EnergyErrorNoError si la charge existe et son défaut est
* levé, \c EnergyErrorInvalidParameter si l'identifiant est inconnu.
* \note Acte DÉLIBÉRÉ de l'opérateur : le défaut d'ECS-410 est collant par conception, et
* cet appel est le seul moyen de le lever hors reconstruction de l'adaptateur. Il est
* journalisé côté arbitre c'est ce qui rend le diagnostic possible après coup.
*/
Q_INVOKABLE JsonReply *ClearLoadFault(const QVariantMap &params);
signals:
void PhasePowerLimitChanged(const QVariantMap &params);
void AcquisitionToleranceChanged(const QVariantMap &params);

View File

@ -1143,6 +1143,89 @@ void Simulation::testEcsColdStartLockExpires()
#endif
}
void Simulation::testEcsPartialFailure()
{
#ifndef ETM_ARBITRATOR
QSKIP("testEcsPartialFailure nécessite ETM_ARBITRATOR.");
#else
// [ECS-410] Un relais introuvable fait échouer toute écriture le concernant. On exerce
// ainsi l'échelle complète : cible → retour arrière → arrêt total → défaut.
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 rOk = addPowerSwitch(1000, 26661);
Thing *tOk = tm->findConfiguredThing(rOk);
QVERIFY(tOk);
tOk->setStateValue("power", false);
const QString rKo = QStringLiteral("{deadbeef-0000-0000-0000-000000000000}"); // jamais configuré
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0));
auto sp = [&](double w) {
LoadAction a; a.kind = LoadAction::Setpoint; a.funding = LoadAction::Surplus;
a.powerW = w; a.reason = QStringLiteral("test échec d'écriture"); return a;
};
// ECS-410 est ASYNCHRONE par construction (AGENTS règle 5 : jamais d'attente dans
// update()). Les acquittements arrivent par signal : sans faire tourner la boucle
// d'événements, l'échelle ne progresse pas d'un seul barreau.
auto laisserRetomber = [&]() { QTest::qWait(300); };
// --- Cas nominal : un seul relais VALIDE, aucune échelle déclenchée -------------------
{
RelayRouter *sain = new RelayRouter(tm, "sain", "Charge saine",
QList<LoadConfigRelay>({ {rOk.toString(), 1000} }), 0, 0, 1, LoadNeeds(), arb);
sain->applyAction(sp(1000), t0);
laisserRetomber();
QCOMPARE(qRound(sain->currentSetpointW()), 1000);
QCOMPARE(tOk->stateValue("power").toBool(), true);
QVERIFY2(!sain->faulted(), "une écriture réussie ne doit pas lever de défaut");
QCOMPARE(sain->telemetry().available, true);
tOk->setStateValue("power", false);
}
// --- Échelle complète : relais introuvable → défaut ----------------------------------
RelayRouter *r = new RelayRouter(tm, "ko", "Charge en panne",
QList<LoadConfigRelay>({ {rOk.toString(), 1000}, {rKo, 2000} }), 0, 0, 1, LoadNeeds(), arb);
QVERIFY(!r->faulted());
// Cible 3000 W = les DEUX relais. L'écriture sur rKo échoue → barreau 2 (retour arrière),
// qui échoue aussi → barreau 3 (arrêt total), qui échoue → défaut.
r->applyAction(sp(3000), t0);
laisserRetomber();
QVERIFY2(r->faulted(), "l'échelle ECS-410 doit aboutir au défaut");
// Le relais VALIDE a bien été ramené à l'ouverture par la tentative d'arrêt total :
// le barreau 3 n'est pas décoratif.
QCOMPARE(tOk->stateValue("power").toBool(), false);
// available bascule à faux — et ne clignote pas : il est COLLANT.
QCOMPARE(r->telemetry().available, false);
LoadContext c = r->toLoadContext(t0.addSecs(600));
QCOMPARE(c.telemetry.available, false);
// Charge FIGÉE : plancher == plafond. Un plafond nul sous un plancher non nul ferait
// dérailler le qBound du scheduler.
QCOMPARE(qRound(c.telemetry.lockMinPowerW), qRound(c.telemetry.lockMaxPowerW));
// …et la charge continue d'être COMPTÉE : elle sort de l'arbitrage, pas de la comptabilité.
QCOMPARE(qRound(c.telemetry.lockMinPowerW), qRound(c.telemetry.currentPowerW));
// Plus aucune commande n'est émise, même longtemps après : le défaut ne s'évapore pas.
tOk->setStateValue("power", false);
r->applyAction(sp(1000), t0.addSecs(3600));
laisserRetomber();
QCOMPARE(tOk->stateValue("power").toBool(), false);
QVERIFY(r->faulted());
// Levée DÉLIBÉRÉE, par l'opérateur — le seul chemin hors reconstruction.
r->clearFault();
QVERIFY(!r->faulted());
QCOMPARE(r->telemetry().available, true);
#endif
}
void Simulation::run_data()
{
// Simulation infos

View File

@ -99,6 +99,9 @@ private slots:
// suffisant : la charge reste éteinte PENDANT minOffS puis s'enclenche. L'armement à
// froid doit être transitoire, jamais permanent (défaut constaté au banc 2026-08-09).
void testEcsColdStartLockExpires();
// [ECS-410] Échec d'écriture en cours de transition : retour arrière, arrêt total, puis
// état de défaut collant. Chaque marche de l'échelle a son cas, et la levée est délibérée.
void testEcsPartialFailure();
void printStates(Thing *thing);
void updateChargerMeter(Thing *thing);