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>
This commit is contained in:
Patrick Schurig 2026-10-24 08:34:21 +02:00
parent 920cf854da
commit d9372c14ba
18 changed files with 1447 additions and 5 deletions

View File

@ -99,6 +99,19 @@ LoadContext EvAdapter::toLoadContext(const QDateTime &now) const
ctx.needs.minPowerWAlways = ctx.declared.minPowerW;
}
// --- Lot e1 : le besoin RÉSEAU se reporte en heures creuses -----------------------
//
// DÉCLARÉ ici, DÉCIDÉ par le scheduler (règle 2) — et seulement si le tarif saisi a des
// heures creuses ; sans elles, ces deux champs n'ont aucun effet. Trois modes : les deux
// « avec minimum » et « avec échéance ». `Eco` seul ne demande jamais le réseau, `Normal`
// est piloté par l'utilisateur. Le minimum d'heures creuses est le même plancher, dérivé.
if (mode == ChargingInfo::ChargingModeEcoWithMinCurrent
|| mode == ChargingInfo::ChargingModeEcoMinWithTargetTime
|| mode == ChargingInfo::ChargingModeEcoWithTargetTime) {
ctx.needs.offPeakShift = true;
ctx.needs.offPeakMinPowerW = ctx.declared.minPowerW;
}
// --- 3g-2 : le plafond du cycle, protection de surcharge COMPRISE -------------------
//
// L'écrêtage par la limite de phase vivait dans planSurplusCharging(), que la borne ne

View File

@ -0,0 +1,259 @@
// 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();
});
}

View File

@ -0,0 +1,127 @@
// 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.
};

View File

@ -8,6 +8,7 @@
#include "adapters/sgreadyadapter.h"
#include "adapters/etmvariableloadadapter.h"
#include "adapters/relayrouter.h"
#include "adapters/storagedischargeguard.h"
#include "config/loadconfigstore.h"
#include "types/loadconfig.h"
#include "scheduler/rulebasedscheduler.h"
@ -58,6 +59,7 @@ EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm,
, m_scheduler(new RuleBasedScheduler(this, this))
, m_strategy(m_scheduler)
, m_em(em)
, m_storageGuard(new StorageDischargeGuard(tm, this))
, m_tm(tm)
{
// --- L2 : watchdog fraîcheur compteur (SAFETY.md §L2) ---
@ -529,6 +531,15 @@ void EnergyArbitrator::update(const QDateTime &currentDateTime)
applyActionsToAdapters(slot, currentDateTime); // charges arbitrées (Setpoint W, State) ET bornes du waterfall
adjustEvChargers(currentDateTime); // bornes RESTÉES au proxy : échéance, spot market
// Lot e1 — la batterie, À LA MÊME POSITION que les autres écritures du cycle. La décision est
// celle du rule-based, qui tourne aussi en ombre d'un optimiseur. Sans heures creuses le garde
// ne lit ni n'écrit rien, sauf pour lever une consigne qu'il tiendrait encore.
const StorageDecision batterie = m_scheduler->storageDecision();
m_storageGuard->appliquer(ctx.offPeak.configured, batterie.evaluated && batterie.block,
currentDateTime);
if (batterie.evaluated)
qCInfo(dcNymeaEnergy()) << "[Arbitre] Batterie →" << renderFr(batterie.reason);
// Brief 4 T3 — sous un plan d'OPTIMISEUR seulement : la puissance publiée est celle que les
// adaptateurs viennent d'APPLIQUER, le financement et le budget se recalculent sur elle (Q3,
// Q4), et une action écrêtée est marquée. AVANT la fusion L4, qui remplace ensuite les
@ -547,6 +558,7 @@ void EnergyArbitrator::update(const QDateTime &currentDateTime)
// ce que la stratégie espérait. Publier avant produirait un instantané qu'aucun relais ne
// confirme — la même famille de défaut qu'annoncer une puissance non appliquée (ECS-410).
buildTelemetry(plan, slot, currentDateTime);
attacherStockage(m_telemetry, batterie);
publishTelemetry(false);
// Brief 4 T4 — le plan en vigueur (GetPlan) et la stratégie qui a commandé ce cycle (§7).
@ -1520,6 +1532,28 @@ SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const
}
// SurplusPv : interface inverter — déféré (remplissage prévu en 3d)
// --- Heures creuses (lot e1) --------------------------------------------------------
// La SEULE lecture du tarif par une décision. Le fuseau de la box est relu à chaque cycle :
// il peut changer en marche (le banc l'a fait), et une plage tarifaire est un RÉGLAGE mural.
// Sans heure creuse (aucun tarif, tarif unique, prix tous égaux), `configured` reste faux et
// rien de ce lot ne s'exécute.
if (!m_tariff.zonesCreuses().isEmpty()) {
const QTimeZone fuseau = QTimeZone::systemTimeZone();
ctx.offPeak.configured = true;
ctx.offPeak.active = m_tariff.enHeuresCreuses(now, fuseau);
ctx.offPeak.nextChange = m_tariff.prochaineBascule(now, fuseau);
if (ctx.offPeak.nextChange.isValid())
ctx.offPeak.nextChangeWall = ctx.offPeak.nextChange.toTimeZone(fuseau).toString(QStringLiteral("HH:mm"));
// Ce que la décision sur la batterie lit — et seulement quand elle peut se poser. La
// puissance des bornes est MESURÉE, toutes bornes confondues (mode manuel compris) :
// une voiture qui charge en heures creuses vide la batterie quel que soit son mode.
for (EvCharger *ev : internalEvChargers())
if (ev->available())
ctx.evMeasuredW += qMax(0.0, ev->currentPower());
ctx.storageControl = m_storageGuard->capacites();
}
// --- Stockage (3g-1) ------------------------------------------------------------------
// Rempli ICI et plus dans le chemin VE. La prise en compte de la batterie n'a jamais été
// une règle « véhicule » : c'est une règle de BUDGET, et elle vivait dans
@ -1926,7 +1960,11 @@ void EnergyArbitrator::applyDegradedMode(const QDateTime &now)
ev->setMaxChargingCurrent(ev->maxChargingCurrentMinValue(), now, true);
}
// Batterie (aucune charge réseau) : repli ajouté avec son adaptateur (3f).
// Batterie : rendue à son AUTOMATISME, explicitement (lot e1). Sous compteur muet le moteur
// cesse de commander ; une limite de décharge tenue serait sinon rafraîchie par personne et
// ne tomberait qu'au minuteur de l'onduleur. Aucune charge réseau n'existe à replier : le
// moteur n'en commande jamais.
m_storageGuard->leverTout(now);
// Reprise à froid (critère ECS-411) : un client qui se connecte APRÈS la bascule doit lire
// degradedMode. update() rend la main avant toute publication tant que le repli tient ; sans
@ -2407,9 +2445,29 @@ void EnergyArbitrator::buildDegradedTelemetry(const QDateTime &now)
// Le mode dégradé porte l'événement LUI AUSSI : `measurementLost` s'y produit précisément,
// et c'est le moment où l'utilisateur a le plus besoin de savoir que sa dérogation a cessé.
attacherFinsDerogation(snap);
// La batterie, si la question se posait : rendue à son automatisme par le repli.
if (!m_tariff.zonesCreuses().isEmpty() && !m_storageGuard->capacites().isEmpty()) {
StorageDecision repli;
repli.evaluated = true;
repli.reason = { DecisionCode::StorageAutomatic, {{"cause", QStringLiteral("degraded")}} };
attacherStockage(snap, repli);
}
m_telemetry = snap;
}
void EnergyArbitrator::attacherStockage(QVariantMap &snap, const StorageDecision &d) const
{
if (!d.evaluated)
return; // pas d'heure creuse, ou pas de batterie : la question ne s'est pas posée
QVariantMap st;
// CE QUE LE MOTEUR TIENT après le dispatch, pas ce que le plan demandait : une batterie non
// pilotable sous une décision « bloquer » n'est pas bloquée, et le motif dit pourquoi.
st.insert(QStringLiteral("dischargeBlocked"), m_storageGuard->tientUneConsigne());
st.insert(QStringLiteral("decision"), d.reason.toMap());
st.insert(QStringLiteral("batteries"), m_storageGuard->vue());
snap.insert(QStringLiteral("storage"), st);
}
QStringList EnergyArbitrator::forecastSiteCandidates() const
{
QStringList sites;
@ -3383,6 +3441,14 @@ bool EnergyArbitrator::telemetrySignificantlyDiffers(const QVariantMap &a, const
// pris fin — ni pourquoi.
if (b.contains(QStringLiteral("derogationEnded")))
return true;
// Lot e1 — la batterie : apparition du bloc, consigne tenue ou levée, CODE du motif.
const QVariantMap sa = a.value(QStringLiteral("storage")).toMap();
const QVariantMap sb = b.value(QStringLiteral("storage")).toMap();
if (a.contains(QStringLiteral("storage")) != b.contains(QStringLiteral("storage"))
|| sa.value(QStringLiteral("dischargeBlocked")) != sb.value(QStringLiteral("dischargeBlocked"))
|| sa.value(QStringLiteral("decision")).toMap().value(QStringLiteral("code"))
!= sb.value(QStringLiteral("decision")).toMap().value(QStringLiteral("code")))
return true;
// Même règle pour la CLÔTURE NON TENUE (correctif b) : un niveau ECO_FLOOR_MISSED ne vit
// qu'un cycle. Le code de TÊTE ne suffit pas à le voir passer — sous LOAD_UNAVAILABLE il reste
// en tête, et rien d'autre ne bouge forcément.

View File

@ -29,6 +29,7 @@ class EvAdapter;
class SgReadyAdapter;
class EtmVariableLoadAdapter;
class RelayRouter;
class StorageDischargeGuard;
class ILoadAdapter;
class LoadConfigStore;
class RuleBasedScheduler;
@ -122,7 +123,9 @@ public:
/*!
* \brief Le tarif saisi. \return Vide si aucun tarif n'est saisi.
* \note EXPOSÉ, jamais lu par une décision : \c SurplusContext::tariff n'est pas rempli (§11).
* \note Une seule décision le lit — les HEURES CREUSES (lot e1, \c SurplusContext::offPeak),
* et seulement par \c TariffConfig::zonesCreuses() : jamais le prix. \c SurplusContext::tariff
* n'est toujours pas rempli, l'optimiseur ne le reçoit pas par là.
*/
const TariffConfig &tariff() const { return m_tariff; }
@ -1029,6 +1032,14 @@ private:
*/
void buildDegradedTelemetry(const QDateTime &now);
/*!
* \brief Ajoute à la trame l'état de la DÉCHARGE de la batterie (lot e1), si la question se pose.
* \param[in,out] snap Trame en construction. \param d Décision du cycle.
* \note Rien n'est ajouté quand \c d.evaluated est faux : sans heure creuse ou sans batterie,
* la trame est celle d'avant le lot, au bit près.
*/
void attacherStockage(QVariantMap &snap, const StorageDecision &d) const;
/*!
* \brief Ajoute à une entrée de télémétrie les MESURES de la charge, si elle en déclare.
*
@ -1234,6 +1245,8 @@ private:
QString m_dossierTrace;
//! Gestionnaire d'énergie (production PV instantanée du fil optimiseur). Non possédé.
EnergyManager *m_em = nullptr;
//! Exécute la décision sur la décharge de la batterie (lot e1). Possédé.
StorageDischargeGuard *m_storageGuard = nullptr;
QHash<QString, EvAdapter *> m_adapters; //!< loadId (ThingId string) → EvAdapter*.
/*!
* \brief [3g-2] Bornes commandées par le waterfall AU CYCLE COURANT.

View File

@ -16,6 +16,7 @@ HEADERS += \
$$PWD/adapters/sgreadyadapter.h \
$$PWD/adapters/etmvariableloadadapter.h \
$$PWD/adapters/relayrouter.h \
$$PWD/adapters/storagedischargeguard.h \
$$PWD/scheduler/rulebasedscheduler.h \
$$PWD/scheduler/optimizerprotocol.h \
$$PWD/scheduler/optimizerclient.h \
@ -41,6 +42,7 @@ SOURCES += \
$$PWD/adapters/sgreadyadapter.cpp \
$$PWD/adapters/etmvariableloadadapter.cpp \
$$PWD/adapters/relayrouter.cpp \
$$PWD/adapters/storagedischargeguard.cpp \
$$PWD/scheduler/rulebasedscheduler.cpp \
$$PWD/scheduler/optimizerprotocol.cpp \
$$PWD/scheduler/optimizerclient.cpp \

View File

@ -246,6 +246,31 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
continue;
nonEvLoads.append(lc);
}
// ---- Heures creuses (lot e1) ---------------------------------------------------------
// Le minimum RÉSEAU d'une charge qui le déclare reportable (\c offPeakShift) se sert en
// heures creuses, et seulement là. Hors heures creuses il est RETIRÉ — surplus seulement — et
// la charge est notée « en attente » pour que son motif le dise. Sans heures creuses dans le
// tarif saisi, rien de ceci ne s'exécute : c'est la garantie « sans tarif = etm74 ».
m_attendCreuses.clear();
m_creusesAt.clear();
m_creusesStartsAt.clear();
if (ctx.offPeak.configured) {
if (!ctx.offPeak.active) {
m_creusesAt = ctx.offPeak.nextChangeWall;
m_creusesStartsAt = ctx.offPeak.nextChange.toUTC().toString(Qt::ISODate);
}
for (LoadContext &lc : nonEvLoads) {
if (!lc.needs.offPeakShift)
continue;
if (ctx.offPeak.active) {
lc.needs.minPowerWAlways = qMax(lc.needs.minPowerWAlways, lc.needs.offPeakMinPowerW);
} else {
lc.needs.minPowerWAlways = 0;
m_attendCreuses.insert(lc.id);
}
}
}
// 3g-2 — tri TOTAL et STABLE. Le rang seul ne suffit pas : deux charges de même rang
// laissaient l'ordre à std::sort, qui n'est pas stable, sur une liste dont l'origine est
// un QHash — donc sans ordre garanti d'un cycle à l'autre. Au banc, deux bornes à
@ -577,6 +602,39 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
// Grid funding (ECS/PAC) : dormant jusqu'à 3f (waterfall réseau) — non implémenté ici.
// ---- La DÉCHARGE de la batterie en heures creuses (lot e1) -----------------------------
// En heures creuses, pendant qu'une voiture tire RÉELLEMENT de la puissance, la batterie ne
// se décharge pas dans la voiture. Rien d'autre : sa charge, et sa charge sur le réseau, ne
// sont jamais touchées. La décision ne lit ni le budget ni la réserve, et elle n'y verse
// rien : bloquer la décharge augmente le SOUTIRAGE, mesuré au cycle suivant par le compteur,
// et ne fabrique aucun surplus (invariant 8). Hors heures creuses, sans voiture, la batterie
// est rendue à son automatisme — et le garde lève ce qu'il tenait.
m_storage = StorageDecision();
if (ctx.offPeak.configured && ctx.battery.present) {
m_storage.evaluated = true;
QString cause;
bool pilotable = false;
for (const SurplusContext::StorageControl &c : ctx.storageControl) {
if (c.controllable)
pilotable = true;
else if (cause.isEmpty())
cause = c.cause;
}
if (!ctx.offPeak.active) {
m_storage.reason = { DecisionCode::StorageAutomatic, {{"cause", QStringLiteral("peak")}} };
} else if (ctx.evMeasuredW < EvChargingThresholdW) {
m_storage.reason = { DecisionCode::StorageAutomatic, {{"cause", QStringLiteral("evNotCharging")}} };
} else if (!pilotable) {
m_storage.reason = { DecisionCode::StorageNotProtected, {{"cause", cause}} };
} else {
m_storage.block = true;
m_storage.reason = { DecisionCode::StorageDischargeBlocked,
{{"evPowerW", qRound(ctx.evMeasuredW)},
{"until", ctx.offPeak.nextChangeWall},
{"endsAt", ctx.offPeak.nextChange.toUTC().toString(Qt::ISODate)}} };
}
}
Plan plan;
plan.planId = QUuid::createUuid().toString(QUuid::WithoutBraces);
plan.strategy = QStringLiteral("rules");
@ -643,6 +701,14 @@ DecisionReason RuleBasedScheduler::choisirMotif(const MotifEntrees &e) const
return { DecisionCode::SurplusSetpoint,
{{"budgetW", qRound(e.budgetW)}, {"setpointW", qRound(e.setpointW)},
{"stepped", !e.dynamic}} };
else if (e.attendCreuses && !e.capEpuise && !(e.refuse && e.retenuW > 0))
// Lot e1 — la consigne est nulle parce que le minimum réseau est REPORTÉ aux heures
// creuses, pas parce que le surplus manque : sans le tarif, le minimum aurait été servi.
// Le plafond de soutirage et le refus gardent la tête : ils diraient la même chose en
// heures creuses, et ce sont eux qu'il faudrait lire.
return { DecisionCode::EvWaitingOffPeak,
{{"at", m_creusesAt}, {"startsAt", m_creusesStartsAt},
{"budgetW", qRound(e.budgetW)}} };
else if (m_reserveActive && (m_reserveWithheldW + e.consoW) > 0)
// 3g-1 — il Y A du surplus, et une règle l'interdit. À ne pas confondre avec
// « il n'y en a pas » : le geste attendu n'est pas le même, et le second envoie
@ -797,7 +863,10 @@ RuleBasedScheduler::SetpointTarget RuleBasedScheduler::decideSetpoint(const Load
// suivante trouve un budget nul et repart sur son motif ordinaire ;
// · il reste sous le plafond de la protection de surcharge, contrôlé plus haut.
const double tolerance = m_arbitrator->acquisitionTolerance();
if (estUneBorne && tolerance > 0 && budgetW > 0 && budgetW >= plancherW * tolerance) {
// Lot e1 — hors heures creuses, une borne qui attend les heures creuses n'est servie
// qu'au SURPLUS : démarrer en soutirant serait le minimum réseau par une autre porte.
if (estUneBorne && tolerance > 0 && budgetW > 0 && budgetW >= plancherW * tolerance
&& !m_attendCreuses.contains(lc.id)) {
const double soutireW = plancherW - budgetW;
SetpointTarget t;
@ -987,6 +1056,7 @@ RuleBasedScheduler::SetpointTarget RuleBasedScheduler::decideSetpoint(const Load
t.entrees.refuse = refuse;
t.entrees.refuseDepuisS = lc.telemetry.refusedSinceS;
t.entrees.retenuW = refuse ? lc.telemetry.currentPowerW : 0.0;
t.entrees.attendCreuses = m_attendCreuses.contains(lc.id);
// Résidu : budget − consigne engagée → charge suivante de la priorité (même cycle).
remainingSurplusW = budgetW - setpointW;

View File

@ -3,6 +3,7 @@
#pragma once
#include <QObject>
#include <QSet>
#include "ischeduler.h"
class EvCharger;
@ -61,6 +62,18 @@ public:
*/
Plan getPlan(const SurplusContext &ctx) override;
/*!
* \brief La décision du dernier \c getPlan() sur la décharge de la batterie (lot e1).
* \return \c evaluated faux sans heures creuses ou sans batterie.
* \note Un état du rule-based, comme la réserve : il est calculé à CHAQUE cycle, y compris en
* ombre d'un optimiseur — c'est donc lui qui commande la batterie dans les deux stratégies.
*/
StorageDecision storageDecision() const { return m_storage; }
//! Puissance mesurée des bornes (W) au-delà de laquelle « une voiture charge » : un ampère
//! sur une phase. Sous le plancher de 6 A d'une borne, au-dessus du bruit d'un compteur.
static constexpr double EvChargingThresholdW = 230.0;
private:
/*!
* \brief Construit un LoadAction pour le cas "délai requis" (TimeRequirement).
@ -119,6 +132,8 @@ private:
bool refuse = false;
int refuseDepuisS = 0; //!< Depuis combien de secondes l'écart dure.
double retenuW = 0; //!< Watts NON rendus au budget du fait du refus.
//! Lot e1 — le minimum réseau de la charge est REPORTÉ aux heures creuses ce cycle.
bool attendCreuses = false;
};
/*!
@ -216,4 +231,13 @@ private:
//! existe pour supprimer, retournée. Constaté à la PREMIÈRE observation en vrai, le
//! 2026-08-26 — surplus à −3 555 W, motif publié, \c withheldW à 0.
double m_reserveWithheldW = 0;
// --- Heures creuses : retenu d'un cycle pour le MOTIF (lot e1) -----------------------
//! Charges dont le minimum réseau est reporté aux heures creuses CE cycle (hors heures creuses).
QSet<QString> m_attendCreuses;
QString m_creusesAt; //!< Début des heures creuses, heure murale \c "HH:MM".
QString m_creusesStartsAt; //!< Le même début, instant ISO UTC.
//! Décision du dernier cycle sur la décharge de la batterie (lot e1).
StorageDecision m_storage;
};

View File

@ -328,6 +328,34 @@ QString renderFr(const DecisionReason &reason, const QString &label)
return QStringLiteral("Prix favorable (%1 ct/kWh) — %2 servie sur ce créneau").arg(prix, label);
}
// --- Heures creuses et stockage (lot e1) --------------------------------
if (c == QLatin1String(DecisionCode::EvWaitingOffPeak))
return QStringLiteral("%1 attend les heures creuses (%2) — hors heures creuses, surplus "
"seulement (%3 W)")
.arg(label, p.value(QStringLiteral("at")).toString()).arg(w("budgetW"));
if (c == QLatin1String(DecisionCode::StorageDischargeBlocked))
return QStringLiteral("Décharge de la batterie bloquée : la voiture charge en heures "
"creuses (%1 W, jusqu'à %2)")
.arg(w("evPowerW")).arg(p.value(QStringLiteral("until")).toString());
if (c == QLatin1String(DecisionCode::StorageAutomatic)) {
const QString cause = p.value(QStringLiteral("cause")).toString();
QString pourquoi = QStringLiteral("cause inconnue (%1)").arg(cause);
if (cause == QLatin1String("peak")) pourquoi = QStringLiteral("hors heures creuses");
else if (cause == QLatin1String("evNotCharging")) pourquoi = QStringLiteral("aucune voiture ne charge");
else if (cause == QLatin1String("degraded")) pourquoi = QStringLiteral("mode dégradé L2");
return QStringLiteral("Batterie en automatique — %1").arg(pourquoi);
}
if (c == QLatin1String(DecisionCode::StorageNotProtected)) {
const QString cause = p.value(QStringLiteral("cause")).toString();
QString pourquoi = QStringLiteral("cause inconnue (%1)").arg(cause);
if (cause == QLatin1String("noDischargeControl"))
pourquoi = QStringLiteral("elle n'expose pas de limite de décharge pilotable");
else if (cause == QLatin1String("noRevertTimer"))
pourquoi = QStringLiteral("elle n'a pas de minuteur de retour, le moteur ne la commande pas");
return QStringLiteral("Batterie non protégée : la voiture charge en heures creuses, mais %1")
.arg(pourquoi);
}
// --- Sécurité et cycle de vie ------------------------------------------
if (c == QLatin1String(DecisionCode::DegradedL2))
return QStringLiteral("Compteur muet depuis >90 s — consigne de repli (L2 watchdog)");
@ -390,6 +418,7 @@ QStringList DecisionCode::knownCodes()
ForecastDateUnreadable, TariffRefused, LoadConfigRefused,
LockMinStateHold, SgForced, SgRecommended, SgNormal,
OptimizerDecision, PriceSlot,
EvWaitingOffPeak, StorageDischargeBlocked, StorageAutomatic, StorageNotProtected,
DegradedL2, SafeStateRelay, SafeStateSetpoint, SafeStateSgReady};
}

View File

@ -428,6 +428,52 @@ constexpr const char *OptimizerDecision = "OPTIMIZER_DECISION";
*/
constexpr const char *PriceSlot = "PRICE_SLOT";
// --- Heures creuses et stockage (lot e1) -----------------------------------
/*!
* \brief params: \c at, \c startsAt, \c budgetW — la borne ATTEND LES HEURES CREUSES.
*
* Hors heures creuses, une borne en mode « avec minimum » ou « avec échéance » n'est servie
* qu'au surplus : son minimum réseau est REPORTÉ aux heures creuses. Ce motif remplace alors
* \c SURPLUS_INSUFFICIENT, \c BELOW_MIN_POWER et \c BATTERY_RESERVE, parce que la cause de la
* consigne nulle n'est plus le manque de surplus : sans le tarif, le minimum aurait été servi.
*
* \c at est l'heure MURALE de la box (\c "HH:MM", le réglage tel qu'il a été saisi),
* \c startsAt le même début en INSTANT (ISO UTC) — un réglage et un instant ne se rendent pas
* au même endroit (AGENTS.md). \c budgetW est le budget de surplus vu par la borne.
*
* \note N'est émis QUE si le tarif saisi a des heures creuses. Un motif de limite (phase,
* plafond de soutirage, verrou, indisponibilité) garde la tête : il dit ce qui empêcherait
* la borne de charger même en heures creuses.
*/
constexpr const char *EvWaitingOffPeak = "EV_WAITING_OFF_PEAK";
/*!
* \brief params: \c evPowerW, \c until, \c endsAt — la DÉCHARGE de la batterie est BLOQUÉE.
*
* En heures creuses, pendant qu'une voiture tire réellement de la puissance (\c evPowerW,
* mesurée), la batterie ne se décharge pas dans la voiture : le moteur active sa limite de
* décharge à 0 %. Il ne touche jamais à sa charge, ni à la charge sur le réseau. \c until
* (\c "HH:MM", heure murale) et \c endsAt (instant) disent quand les heures creuses finissent —
* au plus tard, la batterie retrouve alors son automatisme.
*/
constexpr const char *StorageDischargeBlocked = "STORAGE_DISCHARGE_BLOCKED";
/*!
* \brief params: \c cause — la batterie est rendue à son AUTOMATISME.
*
* \c cause ∈ \c peak (hors heures creuses), \c evNotCharging (aucune voiture ne tire de
* puissance), \c degraded (mode dégradé L2 : le moteur ne commande plus rien). Le moteur ne
* pose alors aucune consigne ; s'il en tenait une, il l'a LEVÉE explicitement.
*/
constexpr const char *StorageAutomatic = "STORAGE_AUTOMATIC";
/*!
* \brief params: \c cause — la décharge AURAIT dû être bloquée, et la batterie ne le permet pas.
*
* \c cause ∈ \c noDischargeControl (pas d'états \c enableDischarging et \c dischargingRate
* modifiables), \c noRevertTimer (aucun minuteur de retour : un moteur arrêté laisserait la
* batterie bloquée sans limite de temps, et le moteur refuse donc de la commander). La batterie
* est lue comme avant le lot, et se décharge dans la voiture.
*/
constexpr const char *StorageNotProtected = "STORAGE_NOT_PROTECTED";
// --- Sécurité et cycle de vie ----------------------------------------------
//! params: — · consigne de repli du watchdog L2 (compteur muet, SAFETY.md §L2).
constexpr const char *DegradedL2 = "DEGRADED_L2";

View File

@ -109,6 +109,21 @@ struct LoadNeeds {
* L'écrire en configuration créerait une copie qui se périme au premier recâblage.
*/
double minPowerWAlways = 0;
/*!
* \brief Le besoin RÉSEAU de cette charge se reporte-t-il en heures creuses ? (lot e1)
*
* Déclaré par l'adaptateur depuis le mode de l'utilisateur, comme \c minPowerWAlways ; c'est
* le SCHEDULER qui en fait une décision, et seulement si le tarif saisi a des heures creuses.
* Sans heures creuses, ce champ n'a aucun effet.
*
* Avec : hors heures creuses, \c minPowerWAlways est retiré (surplus seulement) ; en heures
* creuses, il vaut au moins \c offPeakMinPowerW. Non sérialisé vers l'optimiseur.
*/
bool offPeakShift = false;
//! Minimum servi en heures creuses quand \c offPeakShift est vrai (W) — le plancher de la
//! charge, dérivé comme \c minPowerWAlways. 0 = aucun.
double offPeakMinPowerW = 0;
};
/*!

View File

@ -148,6 +148,21 @@ struct PlanDraw {
bool perPhaseBound = false;
};
/*!
* \brief La décision du cycle sur la DÉCHARGE de la batterie (lot e1) — interne au moteur.
*
* Prise par le rule-based à chaque cycle (y compris en ombre d'un optimiseur), exécutée par
* \c StorageDischargeGuard. Jamais sérialisée vers l'optimiseur.
*
* \invariant \c evaluated faux ⇔ il n'y a pas d'heure creuse dans le tarif saisi, ou aucune
* batterie : rien n'est publié, rien n'est écrit (sauf la levée d'une consigne encore tenue).
*/
struct StorageDecision {
bool evaluated = false; //!< La question s'est posée ce cycle.
bool block = false; //!< Bloquer la décharge des batteries pilotables.
DecisionReason reason; //!< Pourquoi — non vide quand \c evaluated.
};
/*!
* \brief Plan d'optimisation retourné par IScheduler::getPlan().
*

View File

@ -39,6 +39,27 @@ struct SurplusBattery {
double maxDischargeW = 0; //!< Puissance de décharge maximale admissible (W).
};
/*!
* \brief Les HEURES CREUSES du cycle, tirées du tarif saisi (lot e1) — INTERNE au moteur.
*
* Jamais sérialisé vers l'optimiseur (il n'est pas dans le §5 du protocole, comme \c PlanBudget) :
* c'est la lecture que le rule-based fait du tarif, pas une donnée d'entrée du contrat.
*
* \invariant \c configured faux ⇔ aucun tarif, tarif unique, ou zones toutes au même prix
* (\c TariffConfig::zonesCreuses() vide). Les autres champs sont alors sans objet, et
* AUCUNE décision ne change : c'est la garantie « sans tarif = etm74 ».
*/
struct SurplusOffPeak {
bool configured = false; //!< Il existe des heures creuses dans le tarif saisi.
bool active = false; //!< \c SurplusContext::timestamp tombe en heures creuses.
//! Prochaine bascule (UTC, à la minute) : DÉBUT des heures creuses si \c active est faux,
//! leur FIN sinon. Un INSTANT, ancré à la capture.
QDateTime nextChange;
//! La même bascule en heure MURALE de la box, \c "HH:MM" — le réglage tel que l'installateur
//! l'a saisi (« 22:00 »), pour le motif publié.
QString nextChangeWall;
};
/*! \brief Entrée tarifaire (créneau HP/HC ou spot market). */
struct TariffEntry {
QDateTime from; //!< Début du créneau ; il court jusqu'au \c from du suivant.
@ -257,4 +278,29 @@ struct SurplusContext {
QList<LoadContext> loads; //!< Une entrée par charge pilotable, tous types confondus.
SurplusTariff tariff; //!< Contexte tarifaire (créneau courant + suivants).
// forecast : opaque, transmis tel quel si présent — réservé V2
//! Heures creuses du cycle (lot e1). Interne, non sérialisé — voir \c SurplusOffPeak.
SurplusOffPeak offPeak;
/*!
* \brief Puissance MESURÉE tirée par l'ensemble des bornes (W), toutes, y compris celles
* hors waterfall (mode manuel). Interne, non sérialisé.
* \note Lue au compteur de chaque borne (\c currentPower), jamais déduite d'une commande :
* la décharge de la batterie n'est bloquée que pour une voiture qui tire RÉELLEMENT.
*/
double evMeasuredW = 0;
/*!
* \brief Le stockage EXPOSE-t-il une limite de décharge pilotable ? Interne, non sérialisé.
*
* Vide = aucune batterie, ou aucune n'est pilotable. Sinon, une entrée par batterie,
* déclarée par \c StorageDischargeGuard (capacité de la ThingClass et du minuteur de retour,
* jamais une valeur d'état).
*/
struct StorageControl {
QString thingId; //!< ThingId de la batterie.
bool controllable = false; //!< Peut-on bloquer sa décharge en sûreté ?
//! Sinon, pourquoi : \c noDischargeControl (états absents ou non modifiables),
//! \c noRevertTimer (aucun minuteur de retour : un moteur arrêté la laisserait bloquée).
QString cause;
};
QList<StorageControl> storageControl; //!< Une entrée par batterie \c energystorage.
};

View File

@ -1426,6 +1426,30 @@ QVariantMap NymeaEnergyJsonHandler::loadTelemetrySchema()
derogEndItem.insert("o:restoredMode", enumValueName(Int));
schema.insert("o:derogationEnded", QVariantList() << derogEndItem);
// Lot e1 — la DÉCHARGE DE LA BATTERIE en heures creuses. ABSENT tant que le tarif saisi n'a
// pas d'heure creuse ou qu'aucune batterie n'existe : la question ne se pose pas, et un
// « non bloquée » publié sans elle se lirait comme une décision. Hors de « loads » : la
// batterie n'est pas une charge rangée, et « loads » reste l'ensemble de GetLoadConfig.
// « dischargeBlocked » est ce que le moteur TIENT après le dispatch ; « observed » ce que la
// batterie RAPPORTE — les deux, parce qu'un onduleur peut accepter une écriture et l'ignorer.
QVariantMap observedItem;
observedItem.insert("o:enableDischarging", enumValueName(Bool));
observedItem.insert("o:dischargingRate", enumValueName(Double));
QVariantMap batteryItem;
batteryItem.insert("thingId", enumValueName(Uuid));
batteryItem.insert("controllable", enumValueName(Bool));
batteryItem.insert("o:cause", enumValueName(String)); // noDischargeControl | noRevertTimer |
// revertTimerTooShort
batteryItem.insert("held", enumValueName(Bool));
batteryItem.insert("o:heldSince", enumValueName(String)); // ISO 8601 UTC
batteryItem.insert("o:lastWriteAt", enumValueName(String)); // ISO 8601 UTC
batteryItem.insert("o:observed", observedItem);
QVariantMap storageItem;
storageItem.insert("dischargeBlocked", enumValueName(Bool));
storageItem.insert("decision", decisionItem);
storageItem.insert("batteries", QVariantList() << batteryItem);
schema.insert("o:storage", storageItem);
schema.insert("loads", QVariantList() << loadItem);
return schema;
}

View File

@ -10543,8 +10543,16 @@ void Simulation::testNoDecisionReadsBaseloadOrPrice()
// même cycle, aux mêmes entrées, avant et après qu'un tarif et un profil existent — et la
// charge utile de l'arbitrage doit être identique. S'il casse, ce n'est pas le test qu'il
// faut corriger : c'est un lot qui a branché le prix ou la base sur une décision sans le
// dire. Ce test devient FAUX le jour où un lot dédié le décide (e1, « en HC ») — alors il se
// réécrit avec cette décision, il ne se supprime pas.
// dire.
//
// RÉÉCRIT PAR LE LOT e1 (2026-10-24), qui a décidé — et seulement ceci : le tarif dit QUAND
// sont les heures creuses (zones au prix le plus bas), et deux choses en dépendent, la borne
// en mode « avec minimum » ou « avec échéance » et la décharge de la batterie. Ce test n'a
// ni l'une ni l'autre : un chauffe-eau et un routeur, à 15:00, en heures pleines. Le tarif ne
// doit donc RIEN y changer, et le prix lui-même ne décide toujours de rien. Les deux
// décisions du lot sont éprouvées ailleurs (testOffPeakDemoDay et la suite). Ce test devient
// faux le jour où un lot lit le PRIX (Pro, optimisation du coût) ou étend les heures creuses
// à d'autres charges : il se réécrit alors avec cette décision, il ne se supprime pas.
FuseauDeTest fuseau("Europe/Paris");
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-s11.json";
QFile::remove(cfgPath);
@ -10624,9 +10632,517 @@ void Simulation::testNoDecisionReadsBaseloadOrPrice()
// LE MÊME CYCLE, LES MÊMES ENTRÉES : la même décision, au watt et au motif près.
const QVariantMap apres = cycle(7);
QVERIFY2(!apres.contains("storage"), "aucune batterie : la question de la décharge ne se pose pas");
QVERIFY2(apres == avant,
qPrintable(QStringLiteral("le tarif ou la base a changé une décision (§11) :\navant %1\naprès %2")
.arg(QString::fromUtf8(QJsonDocument::fromVariant(avant).toJson(QJsonDocument::Compact)),
QString::fromUtf8(QJsonDocument::fromVariant(apres).toJson(QJsonDocument::Compact)))));
#endif
}
// ============================================================================================
// Lot e1 (2026-10-24) — heures creuses pour la borne, batterie protégée pendant sa charge
// ============================================================================================
//! Une écriture RÉELLEMENT exécutée sur une batterie, lue à la frontière nymea
//! (\c ThingManager::actionExecuted) — jamais dans la mémoire du garde, qui se donnerait raison.
struct EcritureBatterie {
QDateTime cycle; //!< Cycle pendant lequel elle a été exécutée.
QString etat; //!< Nom de l'état écrit.
QVariant valeur;
};
namespace {
//! Ce que l'onduleur ferait de ces écritures : la limite de décharge à 0 % TIENT tant que la
//! dernière écriture date de moins que le minuteur de retour, et que la dernière séquence est
//! « taux 0 puis limite active ». Modèle du GEN24 (minuteur réarmé à CHAQUE écriture, plugin
//! sunspec `planSetpoint()`), indépendant du code du moteur.
bool limiteTenue(const QList<EcritureBatterie> &ecrites, const QDateTime &a, int revertS)
{
bool active = false;
double taux = 100;
QDateTime derniere;
for (const EcritureBatterie &e : ecrites) {
if (e.cycle > a)
break;
if (e.etat == QLatin1String("enableDischarging")) active = e.valeur.toBool();
if (e.etat == QLatin1String("dischargingRate")) taux = e.valeur.toDouble();
derniere = e.cycle;
}
return active && qFuzzyIsNull(taux) && derniere.isValid() && derniere.secsTo(a) < revertS;
}
//! La batterie pilotable du mock : `enableDischarging`, `dischargingRate`, minuteur `revertTime`.
QVariantMap batteriePilotable(uint revertS)
{
return QVariantMap{
{"thingClassId", QStringLiteral("{648e1a54-96c9-4f0f-9fc8-4326ab7c643e}")},
{"name", QStringLiteral("Batterie pilotable")},
{"thingParams", QVariantList{
QVariantMap{{"paramTypeId", QStringLiteral("{6250973b-c0f5-4f2d-9bc9-b077898025de}")}, {"value", 26809}},
QVariantMap{{"paramTypeId", QStringLiteral("{13b73424-5076-46b9-8eb8-38ee0078236e}")}, {"value", revertS}}}}};
}
} // namespace
Simulation::ScenarioHc Simulation::preparerScenarioHc(const QString &nom, BatterieHc batterie,
const QVariantList &tarif)
{
ScenarioHc s;
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-hc-" + nom + ".json";
QFile::remove(cfgPath);
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
s.arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
if (!s.arb)
return s;
s.arb->setTariff(TariffConfig());
ThingManager *tm = NymeaCore::instance()->thingManager();
const QUuid meterId = addMeter(26801);
m_experiencePlugin->energyManager()->setRootMeter(meterId);
s.meter = tm->findConfiguredThing(meterId);
s.meter->setStateValue("connected", true);
s.borneId = addCharger("A", 16, 26802);
const QUuid voitureId = addCar(26803);
s.borne = tm->findConfiguredThing(s.borneId);
Thing *voiture = tm->findConfiguredThing(voitureId);
armerBorne(s.borne);
s.borne->setStateValue("pluggedIn", false); // la voiture arrive à 18 h
voiture->setStateValue("batteryLevel", 30);
voiture->setStateValue("capacity", 60);
voiture->setStateValue("minChargingCurrent", 6);
voiture->setStateValue("phaseCount", 1);
QVariantMap info{{"evChargerId", s.borneId}, {"assignedCarId", voitureId},
{"chargingMode", "ChargingModeEcoWithMinCurrent"}, {"targetPercentage", 100}};
injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}}));
QUuid batterieId;
if (batterie == BatterieHc::Pilotable || batterie == BatterieHc::SansMinuteur) {
const QVariant r = injectAndWait("Integrations.AddThing",
batteriePilotable(batterie == BatterieHc::Pilotable ? 300 : 0));
batterieId = r.toMap().value("params").toMap().value("thingId").toUuid();
} else if (batterie == BatterieHc::SansEtats) {
batterieId = addEnergyStorage(8, 3300, 3300, 26804);
}
if (!batterieId.isNull()) {
s.batterie = tm->findConfiguredThing(batterieId);
s.batterie->setStateValue("batteryLevel", 60);
s.batterie->setStateValue("currentPower", 0);
}
if (!tarif.isEmpty())
injectAndWait("NymeaEnergy.SetTariff", QVariantMap{{"zones", tarif}});
// Les écritures sur la batterie, lues là où nymea les exécute.
s.ecrites = std::make_shared<QList<EcritureBatterie>>();
s.cycleCourant = std::make_shared<QDateTime>();
auto ecrites = s.ecrites;
auto cycleCourant = s.cycleCourant;
connect(tm, &ThingManager::actionExecuted, s.arb, [tm, batterieId, ecrites, cycleCourant]
(const Action &action, Thing::ThingError status) {
if (batterieId.isNull() || action.thingId() != ThingId(batterieId) || status != Thing::ThingErrorNoError)
return;
Thing *t = tm->findConfiguredThing(action.thingId());
ActionTypes types = t->thingClass().actionTypes();
const ActionType type = types.findById(action.actionTypeId());
ecrites->append({*cycleCourant, type.name(), action.param(ParamTypeId(type.id())).value()});
});
return s;
}
QVariantMap Simulation::cycleHc(ScenarioHc &s, const QDateTime &a, double compteurW)
{
*s.cycleCourant = a;
s.meter->setStateValue("currentPower", compteurW);
s.arb->recordMeterUpdate(a);
s.arb->simulationCallUpdate(a);
// Les écritures de la batterie s'enchaînent sur la fin de la précédente (signal en file) :
// deux tours de boucle suffisent au mock, qui répond sur-le-champ.
for (int i = 0; i < 4; ++i)
QCoreApplication::processEvents();
return s.arb->loadTelemetry();
}
void Simulation::testOffPeakDemoDay()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
// LA JOURNÉE DE LA DÉMO — tarif 22:00-06:00, surplus à midi, voiture branchée à 18 h en
// « Éco avec minimum », batterie à 60 %. Ce que Patrick veut montrer : la borne charge au
// surplus la journée, ATTEND les heures creuses le soir, et pendant qu'elle charge en heures
// creuses la batterie ne se décharge pas dans la voiture.
FuseauDeTest fuseau("Europe/Paris");
ScenarioHc s = preparerScenarioHc(QStringLiteral("journee"), BatterieHc::Pilotable, hphcT4());
QVERIFY(s.arb && s.meter && s.borne && s.batterie);
const QString borne = s.borneId.toString();
const auto decision = [&](const QVariantMap &snap) {
return telemetrieParId(snap).value(borne).value("decision").toMap();
};
const auto stockage = [](const QVariantMap &snap) { return snap.value("storage").toMap(); };
// 12:00 — surplus, voiture absente : rien n'est demandé à la batterie, et elle le dit.
QVariantMap snap = cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(10, 0)), -3000);
QCOMPARE(stockage(snap).value("decision").toMap().value("code").toString(),
QString(DecisionCode::StorageAutomatic));
QCOMPARE(stockage(snap).value("decision").toMap().value("params").toMap().value("cause").toString(),
QStringLiteral("peak"));
QCOMPARE(stockage(snap).value("dischargeBlocked").toBool(), false);
// 18:00 — la voiture arrive. Pas de surplus : SANS le tarif, le minimum réseau partirait
// (ECO_FLOOR_GRID). AVEC, la borne attend les heures creuses — et le motif le dit.
s.borne->setStateValue("pluggedIn", true);
for (int m = 0; m <= 2; ++m)
snap = cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(16, m)), 600);
QCOMPARE(decision(snap).value("code").toString(), QString(DecisionCode::EvWaitingOffPeak));
QCOMPARE(decision(snap).value("params").toMap().value("at").toString(), QStringLiteral("22:00"));
QCOMPARE(decision(snap).value("params").toMap().value("startsAt").toString(),
QStringLiteral("2026-06-08T20:00:00Z"));
QCOMPARE(telemetrieParId(snap).value(borne).value("allocatedW").toDouble(), 0.0);
QCOMPARE(s.borne->stateValue("power").toBool(), false);
const QString texte = renderFr(DecisionReason(DecisionCode::EvWaitingOffPeak,
decision(snap).value("params").toMap()), "Borne");
QVERIFY2(texte.contains(QStringLiteral("attend les heures creuses (22:00)")), qUtf8Printable(texte));
// 19:30 — 900 W de surplus, sous le plancher de 1 380 W, et une tolérance d'acquisition à
// 0,5 : sans le tarif, EV_GRID_START démarrerait en soutirant 480 W. Hors heures creuses,
// c'est le minimum réseau par une autre porte : refusé.
injectAndWait("NymeaEnergy.SetAcquisitionTolerance", QVariantMap({{"acquisitionTolerance", 0.5}}));
snap = cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(17, 30)), -900);
QCOMPARE(decision(snap).value("code").toString(), QString(DecisionCode::EvWaitingOffPeak));
QCOMPARE(s.borne->stateValue("power").toBool(), false);
// 21:59 — toujours rien, et la batterie n'a reçu AUCUNE écriture de la journée.
snap = cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(19, 59)), 600);
QCOMPARE(decision(snap).value("code").toString(), QString(DecisionCode::EvWaitingOffPeak));
QVERIFY2(s.ecrites->isEmpty(), "une écriture sur la batterie hors heures creuses");
// 22:00 — LA REPRISE. Le minimum est servi au réseau (passe éco).
snap = cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(20, 0)), 600);
QCOMPARE(decision(snap).value("code").toString(), QString(DecisionCode::EcoFloorGrid));
QCOMPARE(qRound(telemetrieParId(snap).value(borne).value("allocatedW").toDouble()), 1380);
QCOMPARE(s.borne->stateValue("power").toBool(), true);
QCOMPARE(s.borne->stateValue("maxChargingCurrent").toInt(), 6);
// La batterie n'est bloquée qu'au cycle où la voiture TIRE : à 22:00 le compteur de la
// borne lisait encore 0.
QCOMPARE(stockage(snap).value("decision").toMap().value("params").toMap().value("cause").toString(),
QStringLiteral("evNotCharging"));
QVERIFY(s.ecrites->isEmpty());
// 22:01 → 23:30 — la voiture tire 1 380 W : la décharge est BLOQUÉE, et TENUE.
QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(20, 1));
snap = cycleHc(s, a, 1380 + 300);
QCOMPARE(stockage(snap).value("decision").toMap().value("code").toString(),
QString(DecisionCode::StorageDischargeBlocked));
QCOMPARE(stockage(snap).value("dischargeBlocked").toBool(), true);
QCOMPARE(stockage(snap).value("decision").toMap().value("params").toMap().value("until").toString(),
QStringLiteral("06:00"));
// L'ORDRE, lu à la frontière : le taux à 0 AVANT d'activer la limite.
QCOMPARE(s.ecrites->size(), 2);
QCOMPARE(s.ecrites->at(0).etat, QStringLiteral("dischargingRate"));
QCOMPARE(s.ecrites->at(0).valeur.toDouble(), 0.0);
QCOMPARE(s.ecrites->at(1).etat, QStringLiteral("enableDischarging"));
QCOMPARE(s.ecrites->at(1).valeur.toBool(), true);
// `held` est ce que le moteur TIENT ; `observed` ce que la batterie RAPPORTE au moment de
// publier. La seconde écriture part à la confirmation de la première : au cycle de la pose,
// la limite n'est pas encore relue active — c'est au cycle suivant qu'elle l'est.
QVariantMap bat = stockage(snap).value("batteries").toList().value(0).toMap();
QCOMPARE(bat.value("held").toBool(), true);
QCOMPARE(bat.value("observed").toMap().value("dischargingRate").toDouble(), 0.0);
snap = cycleHc(s, a.addSecs(60), 1380 + 300);
bat = stockage(snap).value("batteries").toList().value(0).toMap();
QCOMPARE(bat.value("observed").toMap().value("enableDischarging").toBool(), true);
QCOMPARE(bat.value("observed").toMap().value("dischargingRate").toDouble(), 0.0);
QCOMPARE(s.ecrites->size(), 2); // rien de réécrit une minute après la pose
const QString texteBat = renderFr(DecisionReason(DecisionCode::StorageDischargeBlocked,
stockage(snap).value("decision").toMap().value("params").toMap()));
QVERIFY2(texteBat.startsWith(QStringLiteral("Décharge de la batterie bloquée : la voiture charge en heures creuses")),
qUtf8Printable(texteBat));
// LE MINUTEUR : à chaque cycle, l'onduleur modélisé hors du moteur tient encore la limite —
// donc le moteur a réécrit avant chaque échéance de 300 s.
for (int m = 3; m <= 89; ++m) {
a = utcDateTime(QDate(2026, 6, 8), QTime(20, 0)).addSecs(60 * m);
snap = cycleHc(s, a, 1380 + 300);
QVERIFY2(limiteTenue(*s.ecrites, a.addSecs(59), 300),
qUtf8Printable(QStringLiteral("limite expirée à %1").arg(a.toString(Qt::ISODate))));
}
// …et sans réécrire à chaque cycle : au tiers du minuteur, pas plus souvent.
QVERIFY2(s.ecrites->size() <= 2 + 2 * (88 / 2 + 1), qUtf8Printable(QString::number(s.ecrites->size())));
// 23:30 — la voiture est pleine : la borne ne tire plus rien. La limite est LEVÉE au cycle
// suivant, explicitement : la limite d'abord, le taux remis au maximum ensuite.
s.borne->setStateValue("currentPower", 0);
s.borne->setStateValue("charging", false);
const int avantFin = s.ecrites->size();
a = utcDateTime(QDate(2026, 6, 8), QTime(21, 30));
snap = cycleHc(s, a, 300);
QCOMPARE(stockage(snap).value("dischargeBlocked").toBool(), false);
QCOMPARE(stockage(snap).value("decision").toMap().value("params").toMap().value("cause").toString(),
QStringLiteral("evNotCharging"));
QCOMPARE(s.ecrites->size(), avantFin + 2);
QCOMPARE(s.ecrites->at(avantFin).etat, QStringLiteral("enableDischarging"));
QCOMPARE(s.ecrites->at(avantFin).valeur.toBool(), false);
QCOMPARE(s.ecrites->at(avantFin + 1).etat, QStringLiteral("dischargingRate"));
QCOMPARE(s.ecrites->at(avantFin + 1).valeur.toDouble(), 100.0);
QCOMPARE(s.batterie->stateValue("enableDischarging").toBool(), false);
QVERIFY(!limiteTenue(*s.ecrites, a, 300));
// Rendue, elle ne reçoit plus rien.
s.borne->setStateValue("currentPower", 0);
cycleHc(s, a.addSecs(60), 300);
QCOMPARE(s.ecrites->size(), avantFin + 2);
// JAMAIS la charge, ni la charge sur le réseau — sur toute la journée.
for (const EcritureBatterie &e : *s.ecrites)
QVERIFY2(e.etat == QLatin1String("enableDischarging") || e.etat == QLatin1String("dischargingRate"),
qUtf8Printable(QStringLiteral("écriture interdite : %1").arg(e.etat)));
for (const EcritureBatterie &e : *s.ecrites)
if (e.etat == QLatin1String("dischargingRate"))
QVERIFY2(e.valeur.toDouble() >= 0, "un taux négatif force la CHARGE");
QCOMPARE(s.batterie->stateValue("gridCharging").toBool(), false);
QCOMPARE(s.batterie->stateValue("enableCharging").toBool(), false);
QCOMPARE(s.batterie->stateValue("chargingRate").toInt(), 100);
#endif
}
void Simulation::testOffPeakEndsAtSixAndOnDstDays()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
// 06:00 — la voiture charge encore : la borne s'ARRÊTE (surplus seulement, il n'y en a pas)
// et la batterie est rendue. Puis les deux jours de l'heure d'été, aux instants calculés
// hors de Qt (Python zoneinfo) : 05:59 encore creuse, 06:00 déjà pleine.
FuseauDeTest fuseau("Europe/Paris");
ScenarioHc s = preparerScenarioHc(QStringLiteral("six"), BatterieHc::Pilotable, hphcT4());
QVERIFY(s.arb && s.borne && s.batterie);
const QString borne = s.borneId.toString();
s.borne->setStateValue("pluggedIn", true);
struct Bascule { const char *quoi; qint64 sixHeures; };
const QList<Bascule> jours = {
{"2026-03-29, jour de 23 h (06:00 CEST)", 1774756800},
{"2026-06-09, jour de 24 h", 1780977600},
{"2026-10-25, jour de 25 h (06:00 CET)", 1792904400},
};
for (const Bascule &j : jours) {
const QDateTime six = QDateTime::fromSecsSinceEpoch(j.sixHeures, QTimeZone::utc());
// Une heure en heures creuses : la borne charge au minimum, la voiture tire. Le verrou
// d'enclenchement de la borne (300 s) est écoulé à 06:00 : c'est l'HEURE qui l'arrête.
QVariantMap snap;
for (int m : {60, 30, 3, 2, 1})
snap = cycleHc(s, six.addSecs(-60 * m), 1380 + 300);
QVERIFY2(telemetrieParId(snap).value(borne).value("decision").toMap().value("code").toString()
== QString(DecisionCode::EcoFloorGrid), j.quoi);
QVERIFY2(snap.value("storage").toMap().value("dischargeBlocked").toBool(), j.quoi);
QVERIFY2(s.borne->stateValue("power").toBool(), j.quoi);
// 06:00 — pleines : surplus seulement, la borne s'arrête, la batterie est rendue.
const int avant = s.ecrites->size();
snap = cycleHc(s, six, 1380 + 300);
const QVariantMap d = telemetrieParId(snap).value(borne).value("decision").toMap();
QVERIFY2(d.value("code").toString() == QString(DecisionCode::EvWaitingOffPeak), j.quoi);
QVERIFY2(d.value("params").toMap().value("at").toString() == QStringLiteral("22:00"), j.quoi);
QVERIFY2(!s.borne->stateValue("power").toBool(), j.quoi);
QVERIFY2(!snap.value("storage").toMap().value("dischargeBlocked").toBool(), j.quoi);
QVERIFY2(snap.value("storage").toMap().value("decision").toMap().value("params").toMap()
.value("cause").toString() == QStringLiteral("peak"), j.quoi);
QVERIFY2(s.ecrites->size() == avant + 2 && s.ecrites->at(avant).etat == QStringLiteral("enableDischarging")
&& !s.ecrites->at(avant).valeur.toBool(), j.quoi);
// La borne est remise en état de tirer pour le jour suivant (elle l'a été par le moteur).
s.borne->setStateValue("currentPower", 0);
}
#endif
}
void Simulation::testWithoutOffPeakTheEngineIsEtm74()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
// SANS HEURE CREUSE, RIEN NE CHANGE — trois formes : aucun tarif, un tarif unique, deux zones
// au même prix. Le minimum réseau part à 18 h comme en etm74, aucune trame ne porte
// `storage`, la batterie ne reçoit rien. La preuve au bit près est `decisions` ; ce test dit
// pourquoi elle tient.
FuseauDeTest fuseau("Europe/Paris");
const QList<QPair<const char *, QVariantList>> tarifs = {
{"aucun tarif", QVariantList()},
{"tarif unique", QVariantList{zoneT4("Base", 20.0, {{"00:00", "24:00"}})}},
{"zones au même prix", QVariantList{zoneT4("Jour", 20.0, {{"06:00", "22:00"}}),
zoneT4("Nuit", 20.0, {{"22:00", "06:00"}})}},
};
QVariantMap reference;
for (const auto &t : tarifs) {
ScenarioHc s = preparerScenarioHc(QStringLiteral("etm74"), BatterieHc::Pilotable, t.second);
QVERIFY(s.arb && s.borne && s.batterie);
if (!t.second.isEmpty())
QVERIFY2(!s.arb->tariff().isEmpty(), t.first); // le tarif est VIVANT
s.borne->setStateValue("pluggedIn", true);
QVariantMap snap;
for (int m = 0; m <= 3; ++m)
snap = cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(16, m)), 600 + (m > 0 ? 1380 : 0));
const QVariantMap d = telemetrieParId(snap).value(s.borneId.toString()).value("decision").toMap();
QVERIFY2(d.value("code").toString() == QString(DecisionCode::EcoFloorGrid), t.first);
QVERIFY2(s.borne->stateValue("power").toBool(), t.first);
QVERIFY2(!snap.contains("storage"), t.first);
QVERIFY2(s.ecrites->isEmpty(), t.first);
// La trame est la MÊME d'un cas à l'autre, aux grandeurs datées et aux identifiants près.
snap.remove("timestamp");
QVariantList charges;
for (const QVariant &v : snap.value("loads").toList()) {
QVariantMap c = v.toMap();
c.remove("loadId");
c.remove("command");
c.remove("lock");
charges << c;
}
snap.insert("loads", charges);
if (reference.isEmpty())
reference = snap;
else
QVERIFY2(snap == reference, t.first);
}
#endif
}
void Simulation::testABatteryWithoutControlIsReadAsBefore()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
// Trois batteries que le moteur ne doit PAS commander, et une absente. Chacune se lit comme
// avant le lot ; le motif dit pourquoi elle n'est pas protégée.
FuseauDeTest fuseau("Europe/Paris");
const QDateTime hc = utcDateTime(QDate(2026, 6, 8), QTime(21, 0)); // 23:00 Paris
struct Cas { const char *quoi; BatterieHc batterie; const char *cause; };
const QList<Cas> cas = {
{"sans états de commande", BatterieHc::SansEtats, "noDischargeControl"},
{"sans minuteur de retour", BatterieHc::SansMinuteur, "noRevertTimer"},
};
for (const Cas &c : cas) {
ScenarioHc s = preparerScenarioHc(QStringLiteral("nonpilotable"), c.batterie, hphcT4());
QVERIFY(s.arb && s.borne && s.batterie);
s.borne->setStateValue("pluggedIn", true);
QVariantMap snap;
for (int m = 0; m <= 2; ++m)
snap = cycleHc(s, hc.addSecs(60 * m), 1380 + 300);
const QVariantMap st = snap.value("storage").toMap();
QVERIFY2(!st.value("dischargeBlocked").toBool(), c.quoi);
QVERIFY2(st.value("decision").toMap().value("code").toString()
== QString(DecisionCode::StorageNotProtected), c.quoi);
QVERIFY2(st.value("decision").toMap().value("params").toMap().value("cause").toString()
== QLatin1String(c.cause), c.quoi);
const QVariantMap b = st.value("batteries").toList().value(0).toMap();
QVERIFY2(!b.value("controllable").toBool() && b.value("cause").toString() == QLatin1String(c.cause), c.quoi);
QVERIFY2(s.ecrites->isEmpty(), c.quoi);
// …et la borne, elle, charge quand même en heures creuses : le lot ne la prive de rien.
QVERIFY2(telemetrieParId(snap).value(s.borneId.toString()).value("decision").toMap()
.value("code").toString() == QString(DecisionCode::EcoFloorGrid), c.quoi);
}
// BATTERIE ABSENTE : la question ne se pose pas — aucun bloc `storage`, et la borne attend
// puis reprend comme avec une batterie.
ScenarioHc s = preparerScenarioHc(QStringLiteral("sansbatterie"), BatterieHc::Aucune, hphcT4());
QVERIFY(s.arb && s.borne && !s.batterie);
s.borne->setStateValue("pluggedIn", true);
QVariantMap snap = cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(16, 0)), 600);
QCOMPARE(telemetrieParId(snap).value(s.borneId.toString()).value("decision").toMap()
.value("code").toString(), QString(DecisionCode::EvWaitingOffPeak));
QVERIFY(!snap.contains("storage"));
snap = cycleHc(s, hc, 600);
QCOMPARE(telemetrieParId(snap).value(s.borneId.toString()).value("decision").toMap()
.value("code").toString(), QString(DecisionCode::EcoFloorGrid));
QVERIFY(!snap.contains("storage"));
#endif
}
void Simulation::testBlockedDischargeShowsAtTheMeterAndL4Decides()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
// SÉCURITÉ — bloquer la décharge AUGMENTE le soutirage : ce que la batterie couvrait passe au
// réseau. Le moteur ne le compense par aucun calcul ; il le MESURE au cycle suivant, et la
// protection de surcharge et le plafond de soutirage gardent le dernier mot sur la borne.
// Invariant 8 : le blocage ne fabrique aucun surplus — le budget reste compteur + PV.
FuseauDeTest fuseau("Europe/Paris");
ScenarioHc s = preparerScenarioHc(QStringLiteral("securite"), BatterieHc::Pilotable, hphcT4());
QVERIFY(s.arb && s.borne && s.batterie);
const QString borne = s.borneId.toString();
QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 25}}))
.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
s.borne->setStateValue("pluggedIn", true);
// La protection amont lit la PUISSANCE par phase, la marge de soutirage le COURANT : les deux.
const auto phaseA = [&](double amperes) {
s.meter->setStateValue("currentPhaseA", amperes);
s.meter->setStateValue("currentPowerPhaseA", amperes * 230);
};
phaseA(8);
QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(21, 0));
QVariantMap snap;
for (int m = 0; m <= 2; ++m)
snap = cycleHc(s, a.addSecs(60 * m), 1380 + 300);
QVERIFY(snap.value("storage").toMap().value("dischargeBlocked").toBool());
QVERIFY(s.borne->stateValue("power").toBool());
// INVARIANT 8 — la batterie bloquée ne charge ni ne décharge (0 W), la maison importe : le
// budget publié est exactement −(import), et non « import − ce que la batterie aurait donné ».
s.batterie->setStateValue("currentPower", 0);
snap = cycleHc(s, a.addSecs(180), 1380 + 1800);
QCOMPARE(qRound(snap.value("budget").toMap().value("surplusW").toDouble()), -(1380 + 1800));
// Le soutirage monte : la phase A passe à 28 A pour une limite de 25 A. La borne n'est plus
// servie (plafond de soutirage, protection de surcharge) — et la batterie, qui ne voit plus
// de voiture tirer, est rendue au cycle d'après.
phaseA(28);
snap = cycleHc(s, a.addSecs(240), 6440);
QVERIFY2(!s.borne->stateValue("power").toBool() || s.borne->stateValue("maxChargingCurrent").toInt() < 6,
qUtf8Printable(QJsonDocument::fromVariant(telemetrieParId(snap).value(borne)).toJson(QJsonDocument::Compact)));
s.borne->setStateValue("currentPower", 0);
phaseA(20);
snap = cycleHc(s, a.addSecs(300), 4600);
QVERIFY(!snap.value("storage").toMap().value("dischargeBlocked").toBool());
QCOMPARE(s.batterie->stateValue("enableDischarging").toBool(), false);
#endif
}
void Simulation::testAMeterSilenceHandsTheBatteryBack()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
// L2 — compteur muet : le moteur cesse de commander, et la batterie est LEVÉE explicitement,
// pas laissée au minuteur. La trame du repli le dit (STORAGE_AUTOMATIC, cause degraded).
FuseauDeTest fuseau("Europe/Paris");
ScenarioHc s = preparerScenarioHc(QStringLiteral("l2"), BatterieHc::Pilotable, hphcT4());
QVERIFY(s.arb && s.borne && s.batterie);
s.borne->setStateValue("pluggedIn", true);
const QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(21, 0));
QVariantMap snap;
for (int m = 0; m <= 2; ++m)
snap = cycleHc(s, a.addSecs(60 * m), 1380 + 300);
QVERIFY(snap.value("storage").toMap().value("dischargeBlocked").toBool());
QCOMPARE(s.batterie->stateValue("enableDischarging").toBool(), true);
*s.cycleCourant = a.addSecs(120 + 91);
s.arb->evaluateMeterFreshness(a.addSecs(120 + 91));
for (int i = 0; i < 4; ++i)
QCoreApplication::processEvents();
QVERIFY(s.arb->degradedMode());
QCOMPARE(s.batterie->stateValue("enableDischarging").toBool(), false);
QCOMPARE(s.batterie->stateValue("dischargingRate").toInt(), 100);
snap = s.arb->loadTelemetry();
QCOMPARE(snap.value("storage").toMap().value("decision").toMap().value("params").toMap()
.value("cause").toString(), QStringLiteral("degraded"));
QCOMPARE(snap.value("storage").toMap().value("dischargeBlocked").toBool(), false);
#endif
}

View File

@ -27,6 +27,11 @@
#include "simulationbase.h"
#include <memory>
class EnergyArbitrator;
struct EcritureBatterie;
using namespace nymeaserver;
class ChargerPlugEvent
@ -52,6 +57,25 @@ public:
typedef QList<ChargerPlugEvent> ChargerPlugEvents;
typedef QList<int> DetailsStepList;
private:
// ── Lot e1 — scénario commun ──────────────────────────────────────────────────────────
//! Quelle batterie pose le scénario.
enum class BatterieHc { Aucune, Pilotable, SansEtats, SansMinuteur };
//! Le banc d'un scénario d'heures creuses, et le journal des écritures sur la batterie.
struct ScenarioHc {
EnergyArbitrator *arb = nullptr;
Thing *meter = nullptr;
Thing *borne = nullptr;
Thing *batterie = nullptr;
QUuid borneId;
std::shared_ptr<QList<struct EcritureBatterie>> ecrites; //!< Lues à la frontière nymea.
std::shared_ptr<QDateTime> cycleCourant; //!< Date les écritures.
};
//! Serveur neuf, compteur, borne monophasée « Éco avec minimum » (voiture débranchée), batterie.
ScenarioHc preparerScenarioHc(const QString &nom, BatterieHc batterie, const QVariantList &tarif);
//! Un cycle à l'instant \p a, compteur à \p compteurW (import > 0) ; rend la trame publiée.
QVariantMap cycleHc(ScenarioHc &s, const QDateTime &a, double compteurW);
private slots:
void run_data();
void run();
@ -232,6 +256,21 @@ private slots:
void testTheSnapshotCarriesBaseloadAndPriceOnThePvGrid();
//! Brief 3 T4, §11 — un tarif et un profil n'ont AUCUN effet sur le plan.
void testNoDecisionReadsBaseloadOrPrice();
// ── Lot e1 — heures creuses pour la borne, batterie protégée pendant sa charge ────────
//! La journée de la démo : surplus à midi, attente des HC à 18 h, reprise à 22:00, décharge
//! bloquée tenue (minuteur réarmé) puis LEVÉE à la fin de charge ; jamais la charge réseau.
void testOffPeakDemoDay();
//! 06:00 : la borne s'arrête et la batterie est rendue — jour de 24, 23 et 25 heures.
void testOffPeakEndsAtSixAndOnDstDays();
//! Sans tarif, tarif unique, zones au même prix : le moteur est etm74 (minimum, ni bloc ni écriture).
void testWithoutOffPeakTheEngineIsEtm74();
//! Batterie sans états de commande, sans minuteur, absente : lue comme avant, motif publié.
void testABatteryWithoutControlIsReadAsBefore();
//! Le soutirage que le blocage ajoute se MESURE au cycle suivant ; L4 et le plafond décident.
void testBlockedDischargeShowsAtTheMeterAndL4Decides();
//! L2 : compteur muet, la batterie est levée explicitement, la trame du repli le dit.
void testAMeterSilenceHandsTheBatteryBack();
//! TOUTE méthode Get* du namespace rend ce que sa déclaration promet.
void testEveryGetterReturnsWhatItDeclares();
//! Le snapshot sert la DÉCISION : le canal, jamais la table — et l'absence n'est pas zéro.

View File

@ -430,6 +430,16 @@ void IntegrationPluginEnergyMocks::setupThing(ThingSetupInfo *info)
return;
} else if (thing->thingClassId() == controllableStorageThingClassId) {
// BATTERIE À LIMITE DE DÉCHARGE PILOTABLE (lot e1) — les états modifiables d'un
// `sunspecStorage` (modèle 124) : `enableDischarging` ACTIVE la limite, `dischargingRate`
// la fixe en % (OutWRte), et le paramètre `revertTime` porte le minuteur de retour. Le
// mock n'émule PAS l'onduleur : il range ce qu'on écrit, et le test lit les écritures
// au fil des actions exécutées (ThingManager::actionExecuted). Pas de contrôleur HTTP :
// les tests posent les états directement.
info->finish(Thing::ThingErrorNoError);
return;
} else if (thing->thingClassId() == dryRelayThingClassId) {
// RELAIS SEC — il commute, il ne mesure pas. C'est toute sa raison d'être : le mock ne
// savait produire aucune charge pilotable SANS puissance, si bien qu'une charge « non
@ -593,6 +603,16 @@ void IntegrationPluginEnergyMocks::executeAction(ThingActionInfo *info)
c->logActionExecuted(actionType, action);
// Update states
if (thing->thingClassId() == controllableStorageThingClassId) {
// Chaque état modifiable prend la valeur écrite, rien d'autre : la batterie qui
// obéit. Une écriture de `gridCharging`, `enableCharging` ou `chargingRate` passe
// aussi — c'est au TEST de dire qu'elle n'aurait jamais dû partir.
const QString nom = actionType.name();
thing->setStateValue(nom, action.paramValue(actionType.paramTypes().findByName(nom).id()));
info->finish(Thing::ThingErrorNoError);
return;
}
if (thing->thingClassId() == dryRelayThingClassId) {
if (actionType.name() == "power") {
// Aucune écriture de `currentPower` : la classe ne porte pas l'état. C'est

View File

@ -1013,6 +1013,124 @@
}
]
},
{
"name": "controllableStorage",
"displayName": "Mocked energy storage with a discharge limit",
"id": "648e1a54-96c9-4f0f-9fc8-4326ab7c643e",
"createMethods": [
"user"
],
"interfaces": [
"energystorage"
],
"paramTypes": [
{
"id": "6250973b-c0f5-4f2d-9bc9-b077898025de",
"name": "port",
"displayName": "Port",
"type": "uint",
"defaultValue": 26690
},
{
"id": "13b73424-5076-46b9-8eb8-38ee0078236e",
"name": "revertTime",
"displayName": "Revert time",
"type": "uint",
"unit": "Seconds",
"minValue": 0,
"maxValue": 28800,
"defaultValue": 300
}
],
"stateTypes": [
{
"id": "f288ff92-0404-496b-a26b-5590b6896d76",
"name": "capacity",
"displayName": "Capacity",
"type": "double",
"unit": "KiloWattHour",
"defaultValue": 7.68
},
{
"id": "ca7fcade-1599-4e7d-a0d7-a70461c16493",
"name": "batteryLevel",
"displayName": "Battery level",
"displayNameAction": "Set battery level",
"type": "int",
"unit": "Percentage",
"minValue": 0,
"maxValue": 100,
"writable": true,
"defaultValue": 60
},
{
"id": "1f74f29f-55fa-420d-a2b3-4d2a53018dad",
"name": "currentPower",
"displayName": "Current power consumption",
"type": "double",
"unit": "Watt",
"defaultValue": 0
},
{
"id": "e1381991-e137-43c7-a75a-62ea97271dca",
"name": "batteryCritical",
"displayName": "Battery level critical",
"type": "bool",
"defaultValue": false
},
{
"id": "ad1597dd-b1ff-454e-99e7-293425c64c6b",
"name": "enableCharging",
"displayName": "Charge limit active",
"displayNameAction": "Set charge limit active",
"type": "bool",
"writable": true,
"defaultValue": false
},
{
"id": "11ba1548-03be-4232-a615-b8edbc564437",
"name": "enableDischarging",
"displayName": "Discharge limit active",
"displayNameAction": "Set discharge limit active",
"type": "bool",
"writable": true,
"defaultValue": false
},
{
"id": "0c2d52b4-c117-477f-a13c-173bce8816a6",
"name": "chargingRate",
"displayName": "Charging rate",
"displayNameAction": "Set charging rate",
"type": "int",
"unit": "Percentage",
"minValue": -100,
"maxValue": 100,
"writable": true,
"defaultValue": 100
},
{
"id": "55ac7803-9fb4-4f4e-b38a-4af2b45eeb2e",
"name": "dischargingRate",
"displayName": "Discharging rate",
"displayNameAction": "Set discharging rate",
"type": "int",
"unit": "Percentage",
"minValue": -100,
"maxValue": 100,
"writable": true,
"defaultValue": 100
},
{
"id": "26c0e94e-fdb0-485d-afa3-ab157052d365",
"name": "gridCharging",
"displayName": "Grid charging",
"displayNameAction": "Set grid charging",
"type": "bool",
"writable": true,
"defaultValue": false
}
]
},
{
"name": "powerSwitch",
"displayName": "Mocked Power Switch (relais ECS)",