Décisions de Patrick du 2026-10-24. Sans heure creuse dans le tarif saisi
(aucun tarif, tarif unique, prix tous égaux), rien de ceci ne s'exécute.
LA BORNE — modes « Éco avec minimum », « Éco min + échéance », « Éco avec
échéance » (EvAdapter déclare needs.offPeakShift ; le scheduler décide) :
- hors heures creuses : surplus seulement. Le minimum réseau est retiré,
EV_GRID_START aussi (le minimum par une autre porte). Motif
EV_WAITING_OFF_PEAK {at "22:00", startsAt, budgetW}, rendu « … attend les
heures creuses (22:00) ». Le plafond de soutirage et le refus gardent la tête.
- en heures creuses : le plancher de la borne est servi au réseau (passe éco,
bornée par l'autorisation de soutirage). L'échéance reste au proxy, inchangée.
- Eco et Normal ne sont pas touchés.
LA BATTERIE — StorageDischargeGuard exécute, le rule-based décide (y compris en
ombre d'un optimiseur) :
- heure creuse ET bornes qui TIRENT ≥ 230 W (mesuré) ET batterie pilotable :
dischargingRate = 0 PUIS enableDischarging = true (StorCtl_Mod bit DiSCHARGE
= limite active, OutWRte = 0 : la séquence NO_DISCHARGE du plugin) ;
rafraîchi au tiers du minuteur de retour (revertTime) ;
- sinon : levée EXPLICITE, enableDischarging = false puis dischargingRate au
maximum. L2 lève aussi. Une limite à 0 % trouvée active au démarrage est
reprise (rafraîchie ou levée).
- jamais gridCharging, enableCharging, chargingRate, ni un taux négatif.
- générique : interface energystorage et états présents. Sans les états
(noDischargeControl), sans minuteur (noRevertTimer) ou minuteur < 120 s
(revertTimerTooShort) : non commandée, STORAGE_NOT_PROTECTED.
- publié : bloc `storage` de la télémétrie (dischargeBlocked, decision,
batteries[] avec ce qui est LU), absent sans heure creuse ou sans batterie ;
ligne « [Arbitre] Batterie → … » au journal.
Le blocage ne verse rien au budget : surplus = compteur + PV (invariant 8),
le soutirage ajouté se mesure au cycle suivant, L4 garde le dernier mot.
Mock : classe controllableStorage (états modifiables du sunspecStorage,
paramètre revertTime). Tests : journée de la démo, 06:00 sur jours de 24/23/25 h,
etm74 sans heure creuse, batteries non pilotables ou absente, soutirage et L4,
repli L2 — les écritures lues à la frontière nymea (actionExecuted), l'onduleur
modélisé hors du moteur. Le commentaire du test §11 est réécrit comme il le
demandait.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
260 lines
11 KiB
C++
260 lines
11 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;
|
|
|
|
//! 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) {
|
|
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)
|
|
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}};
|
|
if (!cap.cause.isEmpty())
|
|
m.insert(QStringLiteral("cause"), cap.cause);
|
|
if (e.tenue && e.depuis.isValid())
|
|
m.insert(QStringLiteral("heldSince"), e.depuis.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 (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.
|
|
const QString id = t->id().toString();
|
|
ecrire(t, EtatTaux, 0, [this, id]() {
|
|
if (Thing *suite = m_tm->findConfiguredThing(ThingId(id)))
|
|
ecrire(suite, EtatLimite, true);
|
|
});
|
|
}
|
|
|
|
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;
|
|
// 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 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) {
|
|
qCWarning(dcNymeaEnergy()) << "[Batterie]" << t->name() << "— écriture de" << etat
|
|
<< "impossible (aucune réponse du gestionnaire).";
|
|
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).
|
|
connect(info, &ThingActionInfo::finished, this, [this, info, id, etat, valeur, suite]() {
|
|
if (m_etats.contains(id))
|
|
--m_etats[id].enCours;
|
|
if (info->status() != Thing::ThingErrorNoError) {
|
|
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();
|
|
});
|
|
}
|