diff --git a/energyplugin/etm/adapters/etmvariableloadadapter.cpp b/energyplugin/etm/adapters/etmvariableloadadapter.cpp index 15e2ef5..3986804 100644 --- a/energyplugin/etm/adapters/etmvariableloadadapter.cpp +++ b/energyplugin/etm/adapters/etmvariableloadadapter.cpp @@ -10,6 +10,7 @@ #include #include #include +#include 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(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; +} diff --git a/energyplugin/etm/adapters/etmvariableloadadapter.h b/energyplugin/etm/adapters/etmvariableloadadapter.h index da0e4fa..823792f 100644 --- a/energyplugin/etm/adapters/etmvariableloadadapter.h +++ b/energyplugin/etm/adapters/etmvariableloadadapter.h @@ -8,6 +8,8 @@ #include #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; }; diff --git a/energyplugin/etm/adapters/sgreadyadapter.cpp b/energyplugin/etm/adapters/sgreadyadapter.cpp index e7221f0..354a3ba 100644 --- a/energyplugin/etm/adapters/sgreadyadapter.cpp +++ b/energyplugin/etm/adapters/sgreadyadapter.cpp @@ -12,6 +12,7 @@ #include #include #include +#include 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 SgReadyAdapter::allRelays() const return all; } -void SgReadyAdapter::applyStateRelays(int fromState, int toState) +QSet SgReadyAdapter::readActualOn() const +{ + QSet 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 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 ¤tOn, int toState) { const QList targetList = m_stateRelays.value(toState); const QSet targetOn(targetList.begin(), targetList.end()); - const QList fromList = m_stateRelays.value(fromState); - const QSet 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(); } diff --git a/energyplugin/etm/adapters/sgreadyadapter.h b/energyplugin/etm/adapters/sgreadyadapter.h index f1d0947..93fb417 100644 --- a/energyplugin/etm/adapters/sgreadyadapter.h +++ b/energyplugin/etm/adapters/sgreadyadapter.h @@ -7,8 +7,11 @@ #include #include #include +#include #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 ¤tOn, int toState); + + //! \return Ensemble des relais RÉELLEMENT fermés, lu sur les Things. + QSet 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; }; diff --git a/specs/spec_ecs.md b/specs/spec_ecs.md index 1f71638..5ed2968 100644 --- a/specs/spec_ecs.md +++ b/specs/spec_ecs.md @@ -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) | diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 86b270d..886af36 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -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(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>({ {1,{k1.toString()}}, {2,{}}, {3,{}}, {4,{k1.toString()}} }), + QHash({ {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>({ {1,{k2ko}}, {2,{}}, {3,{k1.toString()}}, {4,{k1.toString(), k2ko}} }), + QHash({ {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 diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 7832ce3..684d9d6 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -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);