Patrick Schurig d9372c14ba Heures creuses : la borne attend les HC, la batterie ne se décharge pas dans la voiture (lot e1)
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>
2026-10-24 08:34:21 +02:00

128 lines
6.4 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 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).
* - 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<void()> &suite = {});
ThingManager *m_tm = nullptr;
QHash<QString, Etat> m_etats; //!< thingId → état de la commande.
};