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:
parent
920cf854da
commit
d9372c14ba
@ -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
|
||||
|
||||
259
energyplugin/etm/adapters/storagedischargeguard.cpp
Normal file
259
energyplugin/etm/adapters/storagedischargeguard.cpp
Normal 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();
|
||||
});
|
||||
}
|
||||
127
energyplugin/etm/adapters/storagedischargeguard.h
Normal file
127
energyplugin/etm/adapters/storagedischargeguard.h
Normal 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.
|
||||
};
|
||||
@ -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 ¤tDateTime)
|
||||
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 ¤tDateTime)
|
||||
// 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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 \
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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};
|
||||
}
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
/*!
|
||||
|
||||
@ -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().
|
||||
*
|
||||
|
||||
@ -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.
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user