Patrick Schurig b007aae720 Batterie : une consigne refusée est réessayée en s'espaçant, la levée jamais (lot e1, suite)
Un onduleur qui refuse la consigne (GEN24 sans « Allow Control », exception
0x04) la voyait reposée à chaque cycle, avec un qCWarning à chaque fois,
toute la nuit, et `held` la publiait comme tenue.

- Une pose refusée (taux ou limite) ouvre une série de refus : la pose
  suivante attend 1, 2, 5, 10 puis 15 min au plus (marge de 5 s pour la
  gigue du cycle). La série se clôt à la première séquence acceptée
  jusqu'au bout (retour au tiers du minuteur) ou à la levée.
- La levée n'est jamais espacée : elle part au cycle même où la décision
  tombe, série en cours ou non.
- Journal : un seul qCWarning par série, les refus suivants en qCDebug, un
  qCInfo à l'acceptation qui clôt la série.
- Télémétrie : pendant une série, `held` est faux (et `dischargeBlocked`
  avec lui), et la batterie publie o:refusedWrites, o:refusedSince,
  o:nextAttemptAt. `held` faux plutôt qu'un drapeau à côté : un client qui
  ignore le champ nouveau ne doit pas afficher une protection absente.

Mock : propriété dynamique `refuserEcritures` sur la batterie pilotable.
Test testRefusedWritesAreSpacedAndNeverDelayTheRelease ; les six
contre-épreuves (espacement, avertissement, held, levée, deux fins de
série) le font rougir.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 11:19:15 +02:00

350 lines
16 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#include "storagedischargeguard.h"
#include "plugininfo.h"
#include <integrations/thing.h>
#include <integrations/thingactioninfo.h>
#include <integrations/thingmanager.h>
namespace {
const QString EtatLimite = QStringLiteral("enableDischarging"); // bit DiSCHARGE de StorCtl_Mod
const QString EtatTaux = QStringLiteral("dischargingRate"); // OutWRte, % de la puissance max
//! Délai minimal entre deux réécritures déclenchées par une batterie qui ne rapporte plus la
//! consigne : l'état relu d'un onduleur suit l'écriture d'un poll (≈ un cycle). Réécrire plus
//! vite reviendrait à réécrire ce qui est en vol.
constexpr int ReecritureMinS = 55;
//! Après le n-ième refus d'affilée d'une pose, la suivante attend `EspacementRefusS[n - 1]`
//! secondes (le dernier palier ensuite) : 1, 2, 5, 10 puis 15 min. Un onduleur qui refuse (GEN24
//! sans « Allow Control », exception Modbus 0x04) refusera encore au cycle suivant ; insister à
//! chaque minute toute la nuit n'apprend rien et charge le bus. Quinze minutes au plus : un
//! réglage corrigé en cours de nuit est repris dans le quart d'heure.
constexpr int EspacementRefusS[] = {60, 120, 300, 600, 900};
constexpr int PaliersRefus = int(sizeof(EspacementRefusS) / sizeof(EspacementRefusS[0]));
//! Un cycle « par minute » arrive à quelques secondes près : sans cette marge, l'attente d'une
//! minute en coûterait deux (même raison que \c ReecritureMinS).
constexpr int MargeCycleS = 5;
//! La valeur de `dischargingRate` qui rend la décharge LIBRE : le maximum de l'état, 100 % à défaut.
double tauxLibre(Thing *t)
{
const QVariant max = t->thingClass().stateTypes().findByName(EtatTaux).maxValue();
return max.isValid() ? max.toDouble() : 100.0;
}
}
StorageDischargeGuard::StorageDischargeGuard(ThingManager *tm, QObject *parent)
: QObject(parent)
, m_tm(tm)
{
}
SurplusContext::StorageControl StorageDischargeGuard::capacite(Thing *t)
{
SurplusContext::StorageControl c;
if (!t)
return c;
c.thingId = t->id().toString();
// La question est posée à la CLASSE : les deux états existent, sont modifiables, ont leur
// action, et la limite admet 0 %. Une valeur d'état ne dit rien de ce qu'on peut écrire.
StateTypes etats = t->thingClass().stateTypes();
ActionTypes actions = t->thingClass().actionTypes();
const StateType limite = etats.findByName(EtatLimite);
const StateType taux = etats.findByName(EtatTaux);
const bool pilotable = !limite.id().isNull() && limite.writable()
&& limite.type() == QMetaType::Bool
&& !actions.findByName(EtatLimite).id().isNull()
&& !taux.id().isNull() && taux.writable()
&& !actions.findByName(EtatTaux).id().isNull()
&& (!taux.minValue().isValid() || taux.minValue().toDouble() <= 0.0);
if (!pilotable) {
c.cause = QStringLiteral("noDischargeControl");
return c;
}
// LE MINUTEUR DE RETOUR, et c'est lui la sûreté : sans lui, un moteur arrêté laisserait la
// batterie bloquée indéfiniment. On ne commande pas ce qui ne sait pas revenir seul.
const ParamType minuteur = t->thingClass().paramTypes().findByName(QStringLiteral("revertTime"));
const uint revertS = minuteur.id().isNull() ? 0 : t->paramValue(minuteur.id()).toUInt();
if (revertS == 0) {
c.cause = QStringLiteral("noRevertTimer");
return c;
}
if (revertS < uint(MinRevertTimeS)) {
c.cause = QStringLiteral("revertTimerTooShort");
return c;
}
c.controllable = true;
return c;
}
QList<SurplusContext::StorageControl> StorageDischargeGuard::capacites() const
{
QList<SurplusContext::StorageControl> l;
if (!m_tm)
return l;
for (Thing *t : m_tm->configuredThings().filterByInterface(QStringLiteral("energystorage")))
l << capacite(t);
return l;
}
void StorageDischargeGuard::appliquer(bool configure, bool bloquer, const QDateTime &now)
{
if (!m_tm)
return;
// Une batterie disparue n'a plus d'état à tenir.
for (auto it = m_etats.begin(); it != m_etats.end();) {
if (!m_tm->findConfiguredThing(ThingId(it.key())))
it = m_etats.erase(it);
else
++it;
}
for (Thing *t : m_tm->configuredThings().filterByInterface(QStringLiteral("energystorage"))) {
const QString id = t->id().toString();
// SANS heures creuses, rien n'est lu ni écrit — sauf une consigne qu'on tiendrait encore.
if (!configure && !(m_etats.contains(id) && m_etats.value(id).tenue))
continue;
const SurplusContext::StorageControl cap = capacite(t);
if (!cap.controllable)
continue; // lue comme avant le lot ; le motif STORAGE_NOT_PROTECTED le dit
Etat &e = m_etats[id];
const bool limiteActive = t->stateValue(EtatLimite).toBool();
const bool aZero = qFuzzyIsNull(t->stateValue(EtatTaux).toDouble());
if (!e.vue) {
e.vue = true;
// ADOPTION — une limite à 0 % déjà active au premier regard est celle qu'un moteur
// précédent a laissée (redémarrage de nymead). La laisser expirer contredirait
// « consigne levée explicitement » ; la reprendre la fait rafraîchir ou lever.
if (configure && limiteActive && aZero) {
e.tenue = true;
e.depuis = now;
qCInfo(dcNymeaEnergy()) << "[Batterie]" << t->name()
<< "— limite de décharge à 0 % trouvée active au démarrage : reprise.";
}
}
if (bloquer) {
// SÉRIE DE REFUS : pas de nouvelle pose avant l'échéance de l'espacement. La levée,
// elle, n'attend jamais (branche ci-dessous).
if (e.refus > 0 && now < e.prochainEssai.addSecs(-MargeCycleS))
continue;
const qint64 age = e.derniereEcriture.isValid() ? e.derniereEcriture.secsTo(now) : -1;
const int revertS = t->paramValue(t->thingClass().paramTypes()
.findByName(QStringLiteral("revertTime")).id()).toInt();
// Le TIERS du minuteur : à 300 s, une réécriture toutes les 100 s au plus tard ; à un
// cycle par minute, l'écart réel entre deux écritures reste sous 180 s, cycle manqué
// compris — bien avant l'expiration.
const bool echu = age < 0 || age >= revertS / 3;
// L'onduleur ne rapporte plus la consigne : il est revenu seul à l'automatisme (un
// minuteur expiré, un redémarrage) — on la repose sans attendre l'échéance.
const bool perdue = e.tenue && e.enCours == 0 && !(limiteActive && aZero)
&& age >= ReecritureMinS;
if (!e.tenue || echu || perdue)
bloquerUne(t, e, now, e.tenue && !perdue);
} else if (e.tenue) {
leverUne(t, e, now);
}
}
}
void StorageDischargeGuard::leverTout(const QDateTime &now)
{
if (!m_tm)
return;
for (auto it = m_etats.begin(); it != m_etats.end(); ++it) {
Thing *t = m_tm->findConfiguredThing(ThingId(it.key()));
if (t && it.value().tenue)
leverUne(t, it.value(), now);
}
}
bool StorageDischargeGuard::tientUneConsigne() const
{
for (const Etat &e : m_etats)
if (e.tenue && e.refus == 0)
return true;
return false;
}
QVariantList StorageDischargeGuard::vue() const
{
QVariantList l;
if (!m_tm)
return l;
for (Thing *t : m_tm->configuredThings().filterByInterface(QStringLiteral("energystorage"))) {
const SurplusContext::StorageControl cap = capacite(t);
const Etat e = m_etats.value(cap.thingId);
QVariantMap m{{QStringLiteral("thingId"), cap.thingId},
{QStringLiteral("controllable"), cap.controllable},
{QStringLiteral("held"), e.tenue && e.refus == 0}};
if (!cap.cause.isEmpty())
m.insert(QStringLiteral("cause"), cap.cause);
if (e.tenue && e.refus == 0 && e.depuis.isValid())
m.insert(QStringLiteral("heldSince"), e.depuis.toUTC().toString(Qt::ISODate));
// UNE SÉRIE DE REFUS : `held` est faux — l'onduleur ne tient pas ce qu'il refuse — et ces
// trois champs disent pourquoi, et quand le moteur réessaiera.
if (e.tenue && e.refus > 0) {
m.insert(QStringLiteral("refusedWrites"), e.refus);
m.insert(QStringLiteral("refusedSince"), e.refusDepuis.toUTC().toString(Qt::ISODate));
m.insert(QStringLiteral("nextAttemptAt"), e.prochainEssai.toUTC().toString(Qt::ISODate));
}
if (e.derniereEcriture.isValid())
m.insert(QStringLiteral("lastWriteAt"), e.derniereEcriture.toUTC().toString(Qt::ISODate));
// CE QUI A ÉTÉ LU, pas ce qu'on a écrit : c'est la seule preuve que la batterie a suivi.
QVariantMap lu;
if (!t->thingClass().stateTypes().findByName(EtatLimite).id().isNull())
lu.insert(EtatLimite, t->stateValue(EtatLimite).toBool());
if (!t->thingClass().stateTypes().findByName(EtatTaux).id().isNull())
lu.insert(EtatTaux, t->stateValue(EtatTaux).toDouble());
if (!lu.isEmpty())
m.insert(QStringLiteral("observed"), lu);
l << m;
}
return l;
}
void StorageDischargeGuard::bloquerUne(Thing *t, Etat &e, const QDateTime &now, bool rafraichissement)
{
if (e.tenue && e.refus > 0)
qCDebug(dcNymeaEnergy()) << "[Batterie]" << t->name() << "— nouvelle tentative de pose après"
<< e.refus << "refus.";
else if (rafraichissement)
qCDebug(dcNymeaEnergy()) << "[Batterie]" << t->name()
<< "— limite de décharge à 0 % rafraîchie (minuteur de retour réarmé).";
else
qCInfo(dcNymeaEnergy()) << "[Batterie]" << t->name()
<< "— décharge BLOQUÉE : limite de décharge à 0 %"
<< "(dischargingRate = 0, puis enableDischarging = true).";
if (!e.tenue)
e.depuis = now;
e.tenue = true;
e.derniereEcriture = now;
// ORDRE : le taux d'abord, la limite ensuite. Tant que la limite est inactive, le taux est
// inerte ; quand elle s'active, il vaut déjà 0. L'inverse activerait une limite au taux
// précédent, peut-être 100 %, le temps d'une écriture. Si la première échoue, la seconde ne
// part pas : la limite reste inactive, donc la batterie en automatique — le sens sûr.
// La séquence est ACCEPTÉE quand la seconde écriture l'est ; un refus de l'une OU de l'autre
// compte pour la série.
const QString id = t->id().toString();
const auto refus = [this, id](const QString &etat, const QString &cause) {
noterRefus(id, etat, cause);
};
ecrire(t, EtatTaux, 0, [this, id, refus]() {
if (Thing *suite = m_tm->findConfiguredThing(ThingId(id)))
ecrire(suite, EtatLimite, true, [this, id]() { noterAcceptation(id); }, refus);
}, refus);
}
void StorageDischargeGuard::noterRefus(const QString &id, const QString &etat, const QString &cause)
{
if (!m_etats.contains(id) || !m_etats.value(id).tenue)
return; // la consigne a été levée entre-temps : ce verdict tardif ne pèse plus
Etat &e = m_etats[id];
Thing *t = m_tm->findConfiguredThing(ThingId(id));
const QString nom = t ? t->name() : id;
const int attente = EspacementRefusS[qMin(e.refus + 1, PaliersRefus) - 1];
// UN SEUL AVERTISSEMENT PAR SÉRIE : le premier refus est aussi visible que l'application
// (règle 7-d) ; les suivants ne disent rien de neuf, et la télémétrie les compte.
if (e.refus == 0) {
e.refusDepuis = e.derniereEcriture;
qCWarning(dcNymeaEnergy()).noquote()
<< "[Batterie]" << nom << "— écriture de" << etat
<< QStringLiteral("REFUSÉE (%1) : la décharge").arg(cause)
<< "n'est PAS bloquée. Nouvelles tentatives espacées (1, 2, 5, 10 puis 15 min) ; ce"
<< "refus n'est plus signalé qu'en débogage jusqu'à la première écriture acceptée.";
} else {
qCDebug(dcNymeaEnergy()).noquote()
<< "[Batterie]" << nom << "— écriture de" << etat
<< QStringLiteral("encore refusée (%1),").arg(cause)
<< e.refus + 1 << "refus d'affilée ; prochain essai dans" << attente << "s.";
}
++e.refus;
e.prochainEssai = e.derniereEcriture.addSecs(attente);
}
void StorageDischargeGuard::noterAcceptation(const QString &id)
{
if (!m_etats.contains(id))
return;
Etat &e = m_etats[id];
if (e.refus > 0) {
Thing *t = m_tm->findConfiguredThing(ThingId(id));
qCInfo(dcNymeaEnergy()) << "[Batterie]" << (t ? t->name() : id)
<< "— limite de décharge à 0 % ACCEPTÉE après" << e.refus
<< "refus : décharge bloquée, retour au rythme normal.";
}
e.refus = 0;
e.refusDepuis = QDateTime();
e.prochainEssai = QDateTime();
}
void StorageDischargeGuard::leverUne(Thing *t, Etat &e, const QDateTime &now)
{
qCInfo(dcNymeaEnergy()) << "[Batterie]" << t->name()
<< "— décharge RENDUE à l'automatisme : limite levée"
<< "(enableDischarging = false, puis dischargingRate =" << tauxLibre(t) << ").";
e.tenue = false;
e.depuis = QDateTime();
e.derniereEcriture = now;
// La levée CLÔT une série de refus : la prochaine pose est une nouvelle consigne, au rythme
// normal, et son premier refus sera de nouveau signalé.
e.refus = 0;
e.refusDepuis = QDateTime();
e.prochainEssai = QDateTime();
// ORDRE INVERSE : lever la limite rend l'automatisme TOUT DE SUITE ; le taux suit, pour
// qu'une limite réactivée plus tard par quiconque ne reparte pas à 0 %. La seconde part même
// si la première échoue : remettre le taux au maximum ne bloque rien.
ecrire(t, EtatLimite, false);
ecrire(t, EtatTaux, tauxLibre(t));
}
void StorageDischargeGuard::ecrire(Thing *t, const QString &etat, const QVariant &valeur,
const std::function<void()> &suite,
const std::function<void(const QString &, const QString &)> &refus)
{
const StateType st = t->thingClass().stateTypes().findByName(etat);
Action action(st.id(), t->id(), Action::TriggeredByRule);
action.setParams(ParamList() << Param(st.id(), valeur));
ThingActionInfo *info = m_tm->executeAction(action);
if (!info) {
const QString cause = QStringLiteral("aucune réponse du gestionnaire");
if (refus)
refus(etat, cause);
else
qCWarning(dcNymeaEnergy()) << "[Batterie]" << t->name() << "— écriture de" << etat
<< "impossible (" << cause << ").";
return;
}
const QString id = t->id().toString();
++m_etats[id].enCours;
// Aucune attente : verdict par signal, `this` en contexte (même modèle qu'ECS-414). Un REFUS
// est au moins aussi visible que l'application (règle 7-d) — une fois par série pour la pose
// (\c noterRefus), à chaque fois pour la levée, qui n'est jamais répétée.
connect(info, &ThingActionInfo::finished, this, [this, info, id, etat, valeur, suite, refus]() {
if (m_etats.contains(id))
--m_etats[id].enCours;
if (info->status() != Thing::ThingErrorNoError) {
if (refus) {
QString statut;
QDebug(&statut).nospace() << info->status();
refus(etat, QStringLiteral("statut ") + statut);
return;
}
Thing *t = m_tm->findConfiguredThing(ThingId(id));
qCWarning(dcNymeaEnergy()) << "[Batterie]" << (t ? t->name() : id) << "— écriture de"
<< etat << "=" << valeur << "REFUSÉE, statut" << info->status();
return;
}
if (suite)
suite();
});
}