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:
parent
cf6843d50c
commit
92d0bef4ac
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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.
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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 ¶ms)
|
||||
{
|
||||
#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 ¶ms)
|
||||
{
|
||||
Q_UNUSED(params)
|
||||
|
||||
@ -73,6 +73,17 @@ public:
|
||||
// [Phase 2] Ratios canoniques (autoconsommation / autonomie), % [0,100].
|
||||
Q_INVOKABLE JsonReply *GetEnergyRatios(const QVariantMap ¶ms);
|
||||
|
||||
/*!
|
||||
* \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 ¶ms);
|
||||
|
||||
signals:
|
||||
void PhasePowerLimitChanged(const QVariantMap ¶ms);
|
||||
void AcquisitionToleranceChanged(const QVariantMap ¶ms);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user