// SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync #pragma once #include #include #include #include #include #include #include #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 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 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). * - sinon : toute consigne TENUE est levée explicitement (jamais laissée expirer). * * \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 oui. 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 * tenue par le moteur), \c o:heldSince, \c o:lastWriteAt (instants ISO UTC), * \c observed {\c enableDischarging, \c dischargingRate} si la batterie les expose. */ 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. }; //! 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); //! Écrit un état modifiable ; \p suite est appelée si l'écriture réussit. void ecrire(Thing *t, const QString &etat, const QVariant &valeur, const std::function &suite = {}); ThingManager *m_tm = nullptr; QHash m_etats; //!< thingId → état de la commande. };