fix: ECS-410-b — l'issue de ClearLoadFault, et l'asymétrie refus/succès (règles 7-c, 7-d)

ClearLoadFault est le SEUL levier de reprise à distance : ECS-410 pose un verrou collant que
seuls ce RPC ou une reconstruction lèvent. L'arbitre journalisait la demande de l'opérateur,
jamais son issue — si bien qu'appelé depuis l'étranger, on ne distinguait pas « le défaut a
été levé » de « il n'y en avait pas » de « l'appel n'a pas atteint l'adaptateur ». L'opérateur
relance trois fois et conclut que le système est cassé.

L'issue est écrite dans l'ARBITRE, pas dans chaque adaptateur : une ligne là couvre les quatre
d'un coup et ne peut pas être oubliée par un adaptateur futur ; répétée dans chacun, elle
manquerait le cinquième. Les trois cas sont distincts à la lecture.

ILoadAdapter::clearFault() rend un bool au lieu de void. Le type de retour force chaque
implémentation, présente et future, à répondre à la question « un défaut a-t-il réellement été
levé ». EvAdapter, qui n'a pas de mécanisme de défaut, le dit désormais explicitement plutôt
que par un corps vide.

Règle 7-d — un refus doit être au moins aussi visible que l'application correspondante. Le
refus par verrou minStateHold de SgReadyAdapter était en qCDebug quand l'application, dix
lignes plus bas, était en qCInfo. L'asymétrie est le défaut, pas le niveau : un resserrement
de la journalisation — et on vient d'en faire un pour tenir deux semaines — aurait fait
disparaître le refus AVANT le succès, ne laissant au journal que les décisions abouties. Or un
refus est plus informatif qu'une application réussie : il dit qu'une décision a été prise et
n'a pas été exécutée. Vérification faite sur les autres adaptateurs : c'était le seul cas. Les
qCDebug restants sont symétriques, et le marqueur L2 par cycle reste en debug à dessein, son
entrée étant en qCWarning et sa sortie en qCInfo.

Le retour idempotent du routeur reste DIFFÉRÉ, et ira en qCDebug : c'est le cas le plus
fréquent — plusieurs milliers de lignes par jour sur un budget ramené à 6,5 Mo — et son
ambiguïté est partiellement couverte, le scheduler journalisant sa décision et l'armement à
froid s'exécutant avant ce retour. Faible gain, coût réel : l'inverse des deux cas corrigés.

Le test porte sur le TEXTE des trois issues et vérifie qu'elles sont deux à deux distinctes.
Suite complète : 112 tests, 0 échec.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-08-13 20:27:53 +02:00
parent b52e900e17
commit cd1a0c83a5
13 changed files with 167 additions and 25 deletions

View File

@ -347,7 +347,25 @@ Règles absolues :
> encodages solo, que la déduplication peut évincer — d'où un palier 0 annoncé pendant
> que 1500 W circulaient. **Rendre la lecture visible est ce qui rend le défaut
> trouvable** ; c'est le sens de la règle, pas un effet secondaire.
- Les tests de ces trois points portent sur le **TEXTE** publié, jamais sur la
- **7-d — un refus DOIT être au moins aussi visible que l'application correspondante.**
Jamais un refus en `qCDebug` quand le succès qu'il remplace est en `qCInfo`.
L'asymétrie est le défaut, pas le niveau : un resserrement de la journalisation fait
alors disparaître le refus **avant** le succès, et il ne reste au journal que les
décisions qui ont abouti. Or **un refus est plus informatif qu'une application
réussie** — il dit qu'une décision a été prise et n'a PAS été exécutée.
> `sgreadyadapter.cpp:150` journalisait le refus par verrou `minStateHold` en
> `qCDebug` quand l'application, dix lignes plus bas, était en `qCInfo`. Corrigé le
> 2026-08-13 ; c'était le seul cas, les autres `qCDebug` du moteur étant symétriques
> (traces d'enregistrement et de construction, où les deux branches sont au même
> niveau). Le marqueur « mode dégradé L2 actif » reste en `qCDebug` à dessein :
> l'entrée est en `qCWarning` et la sortie en `qCInfo`, donc l'épisode reste
> délimité sans une ligne par cycle.
- **Une issue rendue à un opérateur distant se journalise là où elle est décidée**, pas
dans chaque implémentation. `ClearLoadFault` est le seul levier de reprise à distance :
son issue est écrite dans l'arbitre, qui couvre tous les adaptateurs d'un coup et ne
peut pas être oublié par un adaptateur futur. Quand le type de retour permet de forcer
la réponse — `clearFault()` rend un `bool` — le préférer à la discipline.
- Les tests de ces quatre points portent sur le **TEXTE** publié, jamais sur la
non-vacuité d'une chaîne : un test « motif non vide » n'aurait rien vu, dans aucun
de ces cas.
8. **Pas de boucle de feedback** : surplus = PV mesurée + compteur, jamais le net

View File

@ -20,7 +20,26 @@ powersync-energy-plugin-nymea (1.15.2+etm12) trixie; urgency=medium
* Protocole de mesure (RELEVE_ECS306 §3.0) : un état nymea n'émet changed() que si la
valeur bouge, donc une grandeur forcée constante déclenche le watchdog L2 en 90 s et
fait mesurer le watchdog au lieu de l'arbitrage. Constaté au banc.
* Suite complète : 112 tests, 0 échec. Les deux correctifs sont vérifiés échouant sans eux.
* ECS-410-b — l'issue de ClearLoadFault est journalisée, et par l'ARBITRE. Elle distingue
trois cas : défaut levé, aucun défaut à lever, adaptateur introuvable. C'est le seul
levier de reprise à distance : ECS-410 pose un verrou collant que seuls ce RPC ou une
reconstruction lèvent, et sans issue lisible l'opérateur ne sait pas si son geste a agi
— il relance, puis conclut que le système est cassé. Écrite au niveau de l'arbitre, la
ligne couvre les quatre adaptateurs d'un coup et ne peut pas être oubliée par un
adaptateur futur. ILoadAdapter::clearFault() rend désormais un bool plutôt que void : le
type de retour force chaque implémentation à répondre à la question, là où une consigne
de journalisation se serait oubliée à la cinquième.
* Règle 7-d d'AGENTS.md : un refus DOIT être au moins aussi visible que l'application
correspondante. Le refus par verrou minStateHold de SgReadyAdapter était en qCDebug quand
l'application, dix lignes plus bas, était en qCInfo — un resserrement de la journalisation
aurait fait disparaître le refus AVANT le succès, ne laissant au journal que les décisions
abouties. Or un refus est plus informatif : il dit qu'une décision a été prise et n'a pas
été exécutée. Les autres qCDebug du moteur ont été vérifiés symétriques.
* Retour idempotent du routeur (relayrouter.cpp:228) : DIFFÉRÉ, et en qCDebug le jour venu.
C'est le cas le plus fréquent — plusieurs milliers de lignes par jour — et l'ambiguïté y
est partiellement couverte, le scheduler journalisant sa décision et l'armement à froid
s'exécutant avant ce retour.
* Suite complète : 112 tests, 0 échec. Tous les correctifs sont vérifiés échouant sans eux.
-- Patrick Schurig <etm.schurig@gmail.com> Thu, 13 Aug 2026 21:00:00 +0200

View File

@ -223,10 +223,10 @@ void EtmVariableLoadAdapter::settleTransition()
}
}
void EtmVariableLoadAdapter::clearFault()
bool EtmVariableLoadAdapter::clearFault()
{
if (!m_faulted)
return;
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;
@ -234,4 +234,5 @@ void EtmVariableLoadAdapter::clearFault()
m_writeFailed = false;
m_currentSetpointW = readCurrentPowerW(); // relu, pas supposé
m_setpointPrev = m_setpointTarget = m_currentSetpointW;
return true;
}

View File

@ -117,7 +117,7 @@ public:
//! 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.
//! \brief Lève le verrou de défaut (ECS-414). La consigne réelle est RELUE, pas supposée.
void clearFault() override;
bool clearFault() override;
//! \return Vrai si la charge est en défaut (plus aucune consigne écrite).
bool faulted() const { return m_faulted; }

View File

@ -79,7 +79,9 @@ public:
//! \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 {}
//! Aucun mécanisme de défaut côté EV : il n'y a jamais rien à lever. Le dire
//! explicitement plutôt que de laisser un corps vide (règle 7-c).
bool clearFault() override { return false; }
//! \brief Sans effet : l'EV n'est pas construit depuis \c LoadConfig et ne peut donc pas
//! être « désactivé » par ce chemin. \param now Ignoré.

View File

@ -104,8 +104,19 @@ public:
* \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**.
*
* \return \c true si un défaut a RÉELLEMENT é levé, \c false s'il n'y en avait pas.
*
* \note **Pourquoi un booléen et non \c void** (règle 7-c d'AGENTS.md). C'est le seul
* levier de reprise à distance : ECS-410 pose un verrou collant que seuls ce RPC ou une
* reconstruction lèvent. Sans valeur de retour, l'arbitre ne peut pas distinguer
* « défaut levé » de « aucun défaut à lever », et un opérateur à distance ne peut pas
* savoir si son geste a agi il relancera, puis conclura que le système est cassé.
* Le type de retour force chaque implémentation, présente et future, à répondre à la
* question ; une ligne de journal dans chaque adaptateur se serait oubliée à la
* cinquième.
*/
virtual void clearFault() = 0;
virtual bool clearFault() = 0;
/*!
* \brief Amène le matériel dans l'ÉTAT SÛR de cet adaptateur (ECS-413).

View File

@ -489,10 +489,10 @@ void RelayRouter::applySafeState(const QDateTime &now)
applyAction(sur, now);
}
void RelayRouter::clearFault()
bool RelayRouter::clearFault()
{
if (!m_faulted)
return;
return false; // rien à lever — l'arbitre le dira (règle 7-c)
qCInfo(dcNymeaEnergy()) << "[RelayRouter]" << m_label
<< "— défaut levé par l'opérateur (ClearLoadFault).";
m_faulted = false;
@ -502,6 +502,7 @@ void RelayRouter::clearFault()
// (même principe qu'ECS-411, et jamais moins que ce qui peut être appliqué).
m_currentStage = deduceStageFromThings();
m_stagePrev = m_stageTarget = m_currentStage;
return true;
}
void RelayRouter::emitRelayWrites(int stage)

View File

@ -102,7 +102,7 @@ public:
* 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;
bool clearFault() override;
//! \brief État sûr du routeur : TOUS relais ouverts (ECS-413).
//! \param now Temps de cycle — l'action passe en \c force = true, verrous bypassés.

View File

@ -148,8 +148,14 @@ LoadAction SgReadyAdapter::applyAction(const LoadAction &action, const QDateTime
// Verrou minStateHold évalué au temps de cycle (même fenêtre que le scheduler) —
// bypassé si force == true (L2 watchdog → état 2).
if (!action.force && lockActive(newState, now)) {
qCDebug(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label
<< "— verrou minStateHold actif, état" << newState << "ignoré.";
// Règle 7-c — un REFUS doit être au moins aussi visible que l'application
// correspondante. Ce refus était en qCDebug quand l'application juste en dessous est
// en qCInfo : un resserrement de la journalisation faisait disparaître le refus AVANT
// le succès. Or un refus est plus informatif qu'une application réussie — il dit
// qu'une décision a été prise et n'a PAS été exécutée.
qCInfo(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label
<< "— verrou minStateHold actif, état" << newState
<< "REFUSÉ (état" << m_currentState << "maintenu).";
return action;
}
@ -349,10 +355,10 @@ void SgReadyAdapter::settleTransition()
}
}
void SgReadyAdapter::clearFault()
bool SgReadyAdapter::clearFault()
{
if (!m_faulted)
return;
return false; // rien à lever — l'arbitre le dira (règle 7-c)
qCInfo(dcNymeaEnergy()) << "[SgReadyAdapter]" << m_label
<< "— défaut levé par l'opérateur (ClearLoadFault).";
m_faulted = false;
@ -363,6 +369,7 @@ void SgReadyAdapter::clearFault()
if (lu > 0)
m_currentState = lu;
m_statePrev = m_stateTarget = m_currentState;
return true;
}
void SgReadyAdapter::applyStateRelays(const QSet<QString> &currentOn, int toState)

View File

@ -106,7 +106,7 @@ public:
* \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;
bool clearFault() override;
//! \return Vrai si la PAC est en défaut (plus aucune commande émise).
bool faulted() const { return m_faulted; }

View File

@ -171,13 +171,32 @@ bool EnergyArbitrator::sameHardware(const LoadConfig &a, const LoadConfig &b)
bool EnergyArbitrator::clearLoadFault(const QString &loadId)
{
// Règle 7-c — l'ISSUE est journalisée ici, au niveau de l'arbitre, et pas dans chaque
// adaptateur. Une ligne écrite là couvre les quatre adaptateurs d'un coup et ne peut pas
// être oubliée par un adaptateur futur ; répétée dans chacun, elle manquerait le cinquième.
//
// Pourquoi cet appel mérite une issue plutôt qu'une simple trace de demande : c'est le
// SEUL levier de reprise à distance. ECS-410 pose un verrou collant que seuls ce RPC ou
// une reconstruction lèvent. Sans issue lisible, un opérateur à distance ne distingue pas
// « défaut levé » de « aucun défaut à lever » de « l'appel n'a pas atteint l'adaptateur » :
// il relance, puis conclut que le système est cassé.
ILoadAdapter *adapter = m_loadAdapters.value(loadId, nullptr);
if (!adapter) {
qCWarning(dcNymeaEnergy()) << "[Arbitre] ClearLoadFault : charge inconnue" << loadId;
qCWarning(dcNymeaEnergy()) << "[Arbitre] ClearLoadFault" << loadId
<< "→ SANS EFFET : adaptateur introuvable "
"(identifiant inconnu ou charge désactivée).";
return false;
}
qCInfo(dcNymeaEnergy()) << "[Arbitre] ClearLoadFault demandé par l'opérateur pour" << loadId;
adapter->clearFault();
const bool leve = adapter->clearFault();
if (leve)
qCInfo(dcNymeaEnergy()) << "[Arbitre] ClearLoadFault" << loadId
<< "→ défaut LEVÉ ; la charge revient à l'arbitrage.";
else
qCInfo(dcNymeaEnergy()) << "[Arbitre] ClearLoadFault" << loadId
<< "→ sans objet : AUCUN défaut à lever, la charge n'était "
"pas verrouillée.";
return true;
}

View File

@ -517,14 +517,35 @@ DOIT être publiée en **avertissement**, l'état déduit étant incertain.
> (`m_relayNominalW`). Rendre la lecture visible est ce qui a rendu le défaut
> trouvable.
> **Autres silences ambigus — SIGNALÉS, non corrigés.** Relevés en passant, à
> traiter dans leur propre lot :
> **Autres silences ambigus.** Relevés en passant le 2026-08-13 ; deux corrigés le
> jour même, un différé.
>
> | Endroit | Silence |
> |---|---|
> | `relayrouter.cpp:228` | `if (newStage == m_currentStage) return applied;` — le cas idempotent, le plus fréquent, ne produit aucune ligne côté adaptateur. L'issue reste visible par le `[Arbitre]` du cycle, mais la décision de l'adaptateur, non. |
> | `etmvariableloadadapter.cpp:228` | `clearFault()` sort en silence si la charge n'est pas en défaut. L'arbitre journalise la *demande* de l'opérateur (`energyarbitrator.cpp:179`) mais jamais son issue : « reçu, rien à lever » ne se distingue pas de « jamais arrivé ». C'est un geste opérateur, donc le cas le plus visible de la règle après ECS-411. |
> | `sgreadyadapter.cpp:150` | Le refus par verrou `minStateHold` est en `qCDebug`, l'application en `qCInfo`. Le refus disparaît donc avant le succès si la journalisation est resserrée. Atténué aujourd'hui : le scheduler annonce le verrou en `qCInfo` de son côté. |
> | Endroit | Silence | État |
> |---|---|---|
> | `clearFault()`, tous adaptateurs | « reçu, rien à lever » ne se distinguait pas de « jamais arrivé ». **Seul levier de reprise à distance** : ECS-410 pose un verrou collant que seuls ce RPC ou une reconstruction lèvent. | **FAIT** — voir ECS-410-b ci-dessous |
> | `sgreadyadapter.cpp:150` | Refus par verrou `minStateHold` en `qCDebug`, application en `qCInfo` : un resserrement de la journalisation faisait disparaître le refus AVANT le succès. | **FAIT** — passé en `qCInfo`, règle 7-d |
> | `relayrouter.cpp:228` | `if (newStage == m_currentStage) return applied;` — le cas idempotent ne produit aucune ligne côté adaptateur. | **DIFFÉRÉ** (après vacances) |
>
> **Pourquoi le retour idempotent est différé, et en `qCDebug` le jour venu.** C'est
> le cas le **plus fréquent** : une ligne par cycle et par charge, soit plusieurs
> milliers par jour, sur un budget de journal qu'on vient de ramener à ~6,5 Mo/jour
> pour tenir la campagne. Et l'ambiguïté y est **partiellement couverte** : le
> scheduler journalise sa décision à chaque cycle, et l'armement à froid d'ECS-412
> s'exécute **avant** le retour idempotent — il n'est donc jamais manqué. Le gain
> est faible, le coût réel : l'inverse des deux cas corrigés.
**ECS-410-b — L'issue de `ClearLoadFault` est journalisée, et par l'arbitre.**
L'issue DOIT distinguer trois cas : **défaut levé**, **aucun défaut à lever**,
**adaptateur introuvable**. Elle DOIT être écrite au niveau de l'**arbitre**, pas
dans chaque adaptateur : une ligne écrite là couvre tous les adaptateurs d'un coup
et ne peut pas être oubliée par un adaptateur futur. `ILoadAdapter::clearFault()`
rend un `bool` — un défaut a-t-il réellement été levé — plutôt que `void` : le type
de retour force chaque implémentation, présente et future, à répondre à la question,
là où une consigne de journalisation se serait oubliée à la cinquième.
> C'est le seul levier de reprise à distance. Appelé depuis l'étranger sans retour
> lisible, l'opérateur ne distingue pas les trois cas, relance, et conclut que le
> système est cassé.
**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

View File

@ -1379,9 +1379,52 @@ void Simulation::testEcsPartialFailure()
QVERIFY(r->faulted());
// Levée DÉLIBÉRÉE, par l'opérateur — le seul chemin hors reconstruction.
r->clearFault();
// Enregistré auprès de l'arbitre : c'est LUI qui porte l'issue (règle 7-c), donc la
// levée passe par le chemin RPC réel et non par un appel direct à l'adaptateur.
arb->registerRelayRouter(r);
// [Règle 7-c] L'issue DOIT être journalisée par l'ARBITRE, et distinguer trois cas :
// défaut levé, aucun défaut à lever, adaptateur introuvable. C'est le seul levier de
// reprise à distance : sans issue lisible, un opérateur ne sait pas si son geste a agi,
// relance, puis conclut que le système est cassé. Le test porte sur le TEXTE publié.
QVERIFY(r->faulted());
s_journal.clear();
s_handlerPrecedent = qInstallMessageHandler(collecterJournal);
const bool leve = arb->clearLoadFault(QStringLiteral("ko"));
qInstallMessageHandler(s_handlerPrecedent);
QVERIFY(leve);
QVERIFY(!r->faulted());
QCOMPARE(r->telemetry().available, true);
const QString issue1 = s_journal.filter(QStringLiteral("ClearLoadFault")).join(QStringLiteral(" | "));
QVERIFY2(issue1.contains(QStringLiteral("défaut LEVÉ")), qUtf8Printable(issue1));
// Second appel, charge SAINE : « aucun défaut à lever » doit se lire, et ne pas se
// confondre avec le premier cas. Le RPC réussit — il n'y a pas d'erreur — mais l'issue
// est différente, et c'est précisément la distinction que l'opérateur ne pouvait pas
// faire avant.
s_journal.clear();
s_handlerPrecedent = qInstallMessageHandler(collecterJournal);
const bool leve2 = arb->clearLoadFault(QStringLiteral("ko"));
qInstallMessageHandler(s_handlerPrecedent);
QVERIFY2(leve2, "la charge existe : l'appel aboutit, même sans défaut à lever");
const QString issue2 = s_journal.filter(QStringLiteral("ClearLoadFault")).join(QStringLiteral(" | "));
QVERIFY2(issue2.contains(QStringLiteral("AUCUN défaut")), qUtf8Printable(issue2));
QVERIFY2(!issue2.contains(QStringLiteral("défaut LEVÉ")), qUtf8Printable(issue2));
// Troisième cas : adaptateur introuvable. Ni l'un ni l'autre des deux précédents.
s_journal.clear();
s_handlerPrecedent = qInstallMessageHandler(collecterJournal);
const bool leve3 = arb->clearLoadFault(QStringLiteral("charge-qui-n-existe-pas"));
qInstallMessageHandler(s_handlerPrecedent);
QVERIFY2(!leve3, "un identifiant inconnu ne doit pas réussir");
const QString issue3 = s_journal.filter(QStringLiteral("ClearLoadFault")).join(QStringLiteral(" | "));
QVERIFY2(issue3.contains(QStringLiteral("introuvable")), qUtf8Printable(issue3));
// Les trois issues sont distinctes : c'est ce que la règle 7-c exige.
QVERIFY(issue1 != issue2 && issue2 != issue3 && issue1 != issue3);
#endif
}