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>
152 lines
8.3 KiB
C++
152 lines
8.3 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
|
#pragma once
|
|
|
|
#include <QObject>
|
|
#include <QDateTime>
|
|
#include <QHash>
|
|
#include <QList>
|
|
#include <QString>
|
|
#include <QVariantList>
|
|
|
|
#include <functional>
|
|
|
|
#include "../types/surpluscontext.h"
|
|
|
|
class Thing;
|
|
class ThingManager;
|
|
|
|
/*!
|
|
* \brief Bloque la DÉCHARGE d'une batterie, et rien d'autre — lot e1 (2026-10-24).
|
|
*
|
|
* Le moteur décide (\c RuleBasedScheduler::storageDecision()), ce garde EXÉCUTE (règle 2) : il
|
|
* écrit les consignes, les rafraîchit avant que le minuteur de retour de l'onduleur n'expire, et
|
|
* les LÈVE explicitement quand la décision tombe. Il n'entre pas dans le waterfall et n'a pas de
|
|
* \c LoadConfig : la batterie n'est pas une charge rangée (\c spec_loadmodel.md §14, lot 3f).
|
|
*
|
|
* \par Ce qu'il écrit — modèle SunSpec 124, lu dans le plugin
|
|
* `StorCtl_Mod` est un champ de bits : le bit \c DiSCHARGE **ACTIVE la limite** de décharge
|
|
* `OutWRte`, il n'« autorise » pas la décharge. L'état \c enableDischarging du plugin est ce
|
|
* bit, \c dischargingRate est `OutWRte` (% de la puissance maximale). Bloquer la décharge,
|
|
* c'est donc `dischargingRate = 0` PUIS `enableDischarging = true` — la séquence
|
|
* `NO_DISCHARGE` du plugin (`StorCtl_Mod = 2`, `OutWRte = 0`). Écrire
|
|
* `enableDischarging = false` LÈVE la limite : c'est le geste de RETOUR à l'automatisme, suivi
|
|
* de `dischargingRate` à son maximum (la séquence `NORMAL`, `OutWRte = 100`).
|
|
* \par Ce qu'il n'écrit JAMAIS
|
|
* \c gridCharging, \c enableCharging, \c chargingRate : la recharge depuis le réseau est hors
|
|
* produit (décision de Patrick, 2026-10-24). Une `dischargingRate` négative (charge FORCÉE)
|
|
* n'est jamais écrite non plus : seules 0 et le maximum de l'état le sont.
|
|
* \par La sûreté, c'est le minuteur de l'onduleur
|
|
* Chaque écriture du plugin réarme `InOutWRte_RvrtTms` (paramètre \c revertTime). Un moteur
|
|
* arrêté cesse de rafraîchir, et la batterie retrouve son automatisme au bout du minuteur.
|
|
* D'où le refus de commander une batterie SANS minuteur (\c noRevertTimer) ou avec un minuteur
|
|
* plus court que deux cycles (\c revertTimerTooShort) : la consigne y survivrait au moteur, ou
|
|
* expirerait entre deux rafraîchissements.
|
|
* \par Un onduleur qui REFUSE (suite du lot e1, 2026-10-24)
|
|
* Un GEN24 dont « Allow Control » est désactivé répond 0x04 à chaque écriture. 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 ; un seul \c qCWarning par série, les suivants en \c qCDebug. La série se CLÔT à la
|
|
* première séquence acceptée (retour au rythme du tiers du minuteur) ou à la levée. La levée
|
|
* n'est JAMAIS espacée. Pendant la série, la consigne n'est pas publiée comme tenue (\c vue()).
|
|
* \par Générique
|
|
* Par l'interface \c energystorage et les états PRÉSENTS — jamais par une ThingClass Fronius.
|
|
* Une batterie sans ces états est lue comme avant le lot.
|
|
*/
|
|
class StorageDischargeGuard : public QObject
|
|
{
|
|
Q_OBJECT
|
|
public:
|
|
//! Minuteur de retour minimal (s) : deux cycles de 60 s, le rafraîchissement venant au tiers.
|
|
static constexpr int MinRevertTimeS = 120;
|
|
|
|
/*!
|
|
* \brief Construit le garde. \param tm Registre des Things (non possédé). \param parent Parent Qt.
|
|
*/
|
|
explicit StorageDischargeGuard(ThingManager *tm, QObject *parent = nullptr);
|
|
|
|
/*!
|
|
* \brief Ce que la batterie PERMET — lu sur sa ThingClass et ses paramètres, jamais sur une
|
|
* valeur d'état (une limite inactive reste une limite pilotable).
|
|
* \param t Batterie (\c energystorage). \return Sa capacité, \c cause vide si pilotable.
|
|
*/
|
|
static SurplusContext::StorageControl capacite(Thing *t);
|
|
|
|
//! \brief Capacités de toutes les batteries configurées. \return Une entrée par batterie.
|
|
QList<SurplusContext::StorageControl> capacites() const;
|
|
|
|
/*!
|
|
* \brief Exécute la décision du cycle.
|
|
* \param configure Le tarif saisi a des heures creuses ce cycle.
|
|
* \param bloquer Bloquer la décharge (décision du scheduler).
|
|
* \param now Temps de cycle — date les écritures et décide du rafraîchissement.
|
|
*
|
|
* - \p bloquer : sur chaque batterie pilotable, la limite est posée si elle ne l'est pas, ou
|
|
* RAFRAÎCHIE dès que la dernière écriture a l'âge du tiers du minuteur de retour, ou si la
|
|
* batterie ne rapporte plus la consigne (onduleur revenu seul à l'automatisme).
|
|
* Pendant une série de refus, aucune pose avant l'échéance de l'espacement.
|
|
* - sinon : toute consigne TENUE est levée explicitement (jamais laissée expirer), refusée
|
|
* ou non, et sans attendre l'espacement.
|
|
*
|
|
* \note Sans heures creuses (\p configure faux), le garde ne lit et n'écrit RIEN — sauf pour
|
|
* lever une consigne qu'il tiendrait encore (tarif effacé en pleine charge). C'est ce qui
|
|
* rend « sans tarif = etm74 » vrai par construction.
|
|
* \note À la PREMIÈRE vue d'une batterie (démarrage de \c nymead), une limite à 0 % déjà
|
|
* active est ADOPTÉE comme sienne, si \p configure : c'est la consigne qu'un moteur
|
|
* précédent a laissée, et elle est alors rafraîchie ou levée comme les autres.
|
|
*/
|
|
void appliquer(bool configure, bool bloquer, const QDateTime &now);
|
|
|
|
/*!
|
|
* \brief Lève toute consigne tenue, sans condition — repli L2.
|
|
* \param now Instant de la levée.
|
|
*/
|
|
void leverTout(const QDateTime &now);
|
|
|
|
/*!
|
|
* \brief Une consigne est-elle tenue sur au moins une batterie ?
|
|
* \return Vrai si une limite est posée et que sa dernière pose n'a pas été refusée.
|
|
*/
|
|
bool tientUneConsigne() const;
|
|
|
|
/*!
|
|
* \brief Ce qui a été LU et ÉCRIT, par batterie — pour la télémétrie.
|
|
* \return Une carte par batterie : \c thingId, \c controllable, \c o:cause, \c held (consigne
|
|
* posée et NON refusée), \c o:heldSince, \c o:lastWriteAt (instants ISO UTC),
|
|
* \c observed {\c enableDischarging, \c dischargingRate} si la batterie les expose ; pendant
|
|
* une série de refus, \c held est faux et \c o:refusedWrites (poses refusées d'affilée),
|
|
* \c o:refusedSince, \c o:nextAttemptAt (ISO UTC) sont publiés.
|
|
* \note Un verdict arrive après le cycle qui a écrit : un refus se lit au cycle suivant.
|
|
*/
|
|
QVariantList vue() const;
|
|
|
|
private:
|
|
struct Etat {
|
|
bool vue = false; //!< La batterie a déjà été examinée (adoption faite).
|
|
bool tenue = false; //!< Le moteur tient une limite à 0 %.
|
|
QDateTime depuis; //!< Début de la consigne tenue.
|
|
QDateTime derniereEcriture; //!< Dernière séquence écrite (temps de cycle).
|
|
int enCours = 0; //!< Écritures dont l'issue n'est pas encore connue.
|
|
int refus = 0; //!< Poses refusées d'affilée (0 : pas de série en cours).
|
|
QDateTime refusDepuis; //!< Pose qui a ouvert la série de refus.
|
|
QDateTime prochainEssai; //!< Pas de nouvelle pose avant cet instant (série en cours).
|
|
};
|
|
|
|
//! Pose la limite à 0 % : \c dischargingRate puis \c enableDischarging, enchaînés.
|
|
void bloquerUne(Thing *t, Etat &e, const QDateTime &now, bool rafraichissement);
|
|
//! Lève la limite : \c enableDischarging à faux puis \c dischargingRate au maximum.
|
|
void leverUne(Thing *t, Etat &e, const QDateTime &now);
|
|
//! Un refus de la pose de \p id (état \p etat, \p cause lisible) : avertit une fois par
|
|
//! série, compte, et fixe le prochain essai.
|
|
void noterRefus(const QString &id, const QString &etat, const QString &cause);
|
|
//! La pose de \p id est acceptée jusqu'au bout : clôt la série de refus.
|
|
void noterAcceptation(const QString &id);
|
|
//! Écrit un état modifiable ; \p suite est appelée si l'écriture réussit, \p refus (état,
|
|
//! cause) si elle échoue — sans \p refus, l'échec est un \c qCWarning.
|
|
void ecrire(Thing *t, const QString &etat, const QVariant &valeur,
|
|
const std::function<void()> &suite = {},
|
|
const std::function<void(const QString &, const QString &)> &refus = {});
|
|
|
|
ThingManager *m_tm = nullptr;
|
|
QHash<QString, Etat> m_etats; //!< thingId → état de la commande.
|
|
};
|