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;
|
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 -------------------
|
// --- 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
|
// 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/sgreadyadapter.h"
|
||||||
#include "adapters/etmvariableloadadapter.h"
|
#include "adapters/etmvariableloadadapter.h"
|
||||||
#include "adapters/relayrouter.h"
|
#include "adapters/relayrouter.h"
|
||||||
|
#include "adapters/storagedischargeguard.h"
|
||||||
#include "config/loadconfigstore.h"
|
#include "config/loadconfigstore.h"
|
||||||
#include "types/loadconfig.h"
|
#include "types/loadconfig.h"
|
||||||
#include "scheduler/rulebasedscheduler.h"
|
#include "scheduler/rulebasedscheduler.h"
|
||||||
@ -58,6 +59,7 @@ EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm,
|
|||||||
, m_scheduler(new RuleBasedScheduler(this, this))
|
, m_scheduler(new RuleBasedScheduler(this, this))
|
||||||
, m_strategy(m_scheduler)
|
, m_strategy(m_scheduler)
|
||||||
, m_em(em)
|
, m_em(em)
|
||||||
|
, m_storageGuard(new StorageDischargeGuard(tm, this))
|
||||||
, m_tm(tm)
|
, m_tm(tm)
|
||||||
{
|
{
|
||||||
// --- L2 : watchdog fraîcheur compteur (SAFETY.md §L2) ---
|
// --- 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
|
applyActionsToAdapters(slot, currentDateTime); // charges arbitrées (Setpoint W, State) ET bornes du waterfall
|
||||||
adjustEvChargers(currentDateTime); // bornes RESTÉES au proxy : échéance, spot market
|
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
|
// 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,
|
// 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
|
// 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
|
// 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).
|
// confirme — la même famille de défaut qu'annoncer une puissance non appliquée (ECS-410).
|
||||||
buildTelemetry(plan, slot, currentDateTime);
|
buildTelemetry(plan, slot, currentDateTime);
|
||||||
|
attacherStockage(m_telemetry, batterie);
|
||||||
publishTelemetry(false);
|
publishTelemetry(false);
|
||||||
|
|
||||||
// Brief 4 T4 — le plan en vigueur (GetPlan) et la stratégie qui a commandé ce cycle (§7).
|
// 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)
|
// 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) ------------------------------------------------------------------
|
// --- Stockage (3g-1) ------------------------------------------------------------------
|
||||||
// Rempli ICI et plus dans le chemin VE. La prise en compte de la batterie n'a jamais été
|
// 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
|
// 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);
|
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
|
// 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
|
// 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,
|
// 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é.
|
// et c'est le moment où l'utilisateur a le plus besoin de savoir que sa dérogation a cessé.
|
||||||
attacherFinsDerogation(snap);
|
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;
|
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 EnergyArbitrator::forecastSiteCandidates() const
|
||||||
{
|
{
|
||||||
QStringList sites;
|
QStringList sites;
|
||||||
@ -3383,6 +3441,14 @@ bool EnergyArbitrator::telemetrySignificantlyDiffers(const QVariantMap &a, const
|
|||||||
// pris fin — ni pourquoi.
|
// pris fin — ni pourquoi.
|
||||||
if (b.contains(QStringLiteral("derogationEnded")))
|
if (b.contains(QStringLiteral("derogationEnded")))
|
||||||
return true;
|
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
|
// 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
|
// 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.
|
// en tête, et rien d'autre ne bouge forcément.
|
||||||
|
|||||||
@ -29,6 +29,7 @@ class EvAdapter;
|
|||||||
class SgReadyAdapter;
|
class SgReadyAdapter;
|
||||||
class EtmVariableLoadAdapter;
|
class EtmVariableLoadAdapter;
|
||||||
class RelayRouter;
|
class RelayRouter;
|
||||||
|
class StorageDischargeGuard;
|
||||||
class ILoadAdapter;
|
class ILoadAdapter;
|
||||||
class LoadConfigStore;
|
class LoadConfigStore;
|
||||||
class RuleBasedScheduler;
|
class RuleBasedScheduler;
|
||||||
@ -122,7 +123,9 @@ public:
|
|||||||
|
|
||||||
/*!
|
/*!
|
||||||
* \brief Le tarif saisi. \return Vide si aucun tarif n'est saisi.
|
* \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; }
|
const TariffConfig &tariff() const { return m_tariff; }
|
||||||
|
|
||||||
@ -1029,6 +1032,14 @@ private:
|
|||||||
*/
|
*/
|
||||||
void buildDegradedTelemetry(const QDateTime &now);
|
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.
|
* \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;
|
QString m_dossierTrace;
|
||||||
//! Gestionnaire d'énergie (production PV instantanée du fil optimiseur). Non possédé.
|
//! Gestionnaire d'énergie (production PV instantanée du fil optimiseur). Non possédé.
|
||||||
EnergyManager *m_em = nullptr;
|
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*.
|
QHash<QString, EvAdapter *> m_adapters; //!< loadId (ThingId string) → EvAdapter*.
|
||||||
/*!
|
/*!
|
||||||
* \brief [3g-2] Bornes commandées par le waterfall AU CYCLE COURANT.
|
* \brief [3g-2] Bornes commandées par le waterfall AU CYCLE COURANT.
|
||||||
|
|||||||
@ -16,6 +16,7 @@ HEADERS += \
|
|||||||
$$PWD/adapters/sgreadyadapter.h \
|
$$PWD/adapters/sgreadyadapter.h \
|
||||||
$$PWD/adapters/etmvariableloadadapter.h \
|
$$PWD/adapters/etmvariableloadadapter.h \
|
||||||
$$PWD/adapters/relayrouter.h \
|
$$PWD/adapters/relayrouter.h \
|
||||||
|
$$PWD/adapters/storagedischargeguard.h \
|
||||||
$$PWD/scheduler/rulebasedscheduler.h \
|
$$PWD/scheduler/rulebasedscheduler.h \
|
||||||
$$PWD/scheduler/optimizerprotocol.h \
|
$$PWD/scheduler/optimizerprotocol.h \
|
||||||
$$PWD/scheduler/optimizerclient.h \
|
$$PWD/scheduler/optimizerclient.h \
|
||||||
@ -41,6 +42,7 @@ SOURCES += \
|
|||||||
$$PWD/adapters/sgreadyadapter.cpp \
|
$$PWD/adapters/sgreadyadapter.cpp \
|
||||||
$$PWD/adapters/etmvariableloadadapter.cpp \
|
$$PWD/adapters/etmvariableloadadapter.cpp \
|
||||||
$$PWD/adapters/relayrouter.cpp \
|
$$PWD/adapters/relayrouter.cpp \
|
||||||
|
$$PWD/adapters/storagedischargeguard.cpp \
|
||||||
$$PWD/scheduler/rulebasedscheduler.cpp \
|
$$PWD/scheduler/rulebasedscheduler.cpp \
|
||||||
$$PWD/scheduler/optimizerprotocol.cpp \
|
$$PWD/scheduler/optimizerprotocol.cpp \
|
||||||
$$PWD/scheduler/optimizerclient.cpp \
|
$$PWD/scheduler/optimizerclient.cpp \
|
||||||
|
|||||||
@ -246,6 +246,31 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
|
|||||||
continue;
|
continue;
|
||||||
nonEvLoads.append(lc);
|
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
|
// 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
|
// 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 à
|
// 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.
|
// 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 plan;
|
||||||
plan.planId = QUuid::createUuid().toString(QUuid::WithoutBraces);
|
plan.planId = QUuid::createUuid().toString(QUuid::WithoutBraces);
|
||||||
plan.strategy = QStringLiteral("rules");
|
plan.strategy = QStringLiteral("rules");
|
||||||
@ -643,6 +701,14 @@ DecisionReason RuleBasedScheduler::choisirMotif(const MotifEntrees &e) const
|
|||||||
return { DecisionCode::SurplusSetpoint,
|
return { DecisionCode::SurplusSetpoint,
|
||||||
{{"budgetW", qRound(e.budgetW)}, {"setpointW", qRound(e.setpointW)},
|
{{"budgetW", qRound(e.budgetW)}, {"setpointW", qRound(e.setpointW)},
|
||||||
{"stepped", !e.dynamic}} };
|
{"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)
|
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
|
// 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
|
// « 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 ;
|
// 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.
|
// · il reste sous le plafond de la protection de surcharge, contrôlé plus haut.
|
||||||
const double tolerance = m_arbitrator->acquisitionTolerance();
|
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;
|
const double soutireW = plancherW - budgetW;
|
||||||
|
|
||||||
SetpointTarget t;
|
SetpointTarget t;
|
||||||
@ -987,6 +1056,7 @@ RuleBasedScheduler::SetpointTarget RuleBasedScheduler::decideSetpoint(const Load
|
|||||||
t.entrees.refuse = refuse;
|
t.entrees.refuse = refuse;
|
||||||
t.entrees.refuseDepuisS = lc.telemetry.refusedSinceS;
|
t.entrees.refuseDepuisS = lc.telemetry.refusedSinceS;
|
||||||
t.entrees.retenuW = refuse ? lc.telemetry.currentPowerW : 0.0;
|
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).
|
// Résidu : budget − consigne engagée → charge suivante de la priorité (même cycle).
|
||||||
remainingSurplusW = budgetW - setpointW;
|
remainingSurplusW = budgetW - setpointW;
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
|
#include <QSet>
|
||||||
#include "ischeduler.h"
|
#include "ischeduler.h"
|
||||||
|
|
||||||
class EvCharger;
|
class EvCharger;
|
||||||
@ -61,6 +62,18 @@ public:
|
|||||||
*/
|
*/
|
||||||
Plan getPlan(const SurplusContext &ctx) override;
|
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:
|
private:
|
||||||
/*!
|
/*!
|
||||||
* \brief Construit un LoadAction pour le cas "délai requis" (TimeRequirement).
|
* \brief Construit un LoadAction pour le cas "délai requis" (TimeRequirement).
|
||||||
@ -119,6 +132,8 @@ private:
|
|||||||
bool refuse = false;
|
bool refuse = false;
|
||||||
int refuseDepuisS = 0; //!< Depuis combien de secondes l'écart dure.
|
int refuseDepuisS = 0; //!< Depuis combien de secondes l'écart dure.
|
||||||
double retenuW = 0; //!< Watts NON rendus au budget du fait du refus.
|
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
|
//! 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.
|
//! 2026-08-26 — surplus à −3 555 W, motif publié, \c withheldW à 0.
|
||||||
double m_reserveWithheldW = 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);
|
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 ------------------------------------------
|
// --- Sécurité et cycle de vie ------------------------------------------
|
||||||
if (c == QLatin1String(DecisionCode::DegradedL2))
|
if (c == QLatin1String(DecisionCode::DegradedL2))
|
||||||
return QStringLiteral("Compteur muet depuis >90 s — consigne de repli (L2 watchdog)");
|
return QStringLiteral("Compteur muet depuis >90 s — consigne de repli (L2 watchdog)");
|
||||||
@ -390,6 +418,7 @@ QStringList DecisionCode::knownCodes()
|
|||||||
ForecastDateUnreadable, TariffRefused, LoadConfigRefused,
|
ForecastDateUnreadable, TariffRefused, LoadConfigRefused,
|
||||||
LockMinStateHold, SgForced, SgRecommended, SgNormal,
|
LockMinStateHold, SgForced, SgRecommended, SgNormal,
|
||||||
OptimizerDecision, PriceSlot,
|
OptimizerDecision, PriceSlot,
|
||||||
|
EvWaitingOffPeak, StorageDischargeBlocked, StorageAutomatic, StorageNotProtected,
|
||||||
DegradedL2, SafeStateRelay, SafeStateSetpoint, SafeStateSgReady};
|
DegradedL2, SafeStateRelay, SafeStateSetpoint, SafeStateSgReady};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -428,6 +428,52 @@ constexpr const char *OptimizerDecision = "OPTIMIZER_DECISION";
|
|||||||
*/
|
*/
|
||||||
constexpr const char *PriceSlot = "PRICE_SLOT";
|
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 ----------------------------------------------
|
// --- Sécurité et cycle de vie ----------------------------------------------
|
||||||
//! params: — · consigne de repli du watchdog L2 (compteur muet, SAFETY.md §L2).
|
//! params: — · consigne de repli du watchdog L2 (compteur muet, SAFETY.md §L2).
|
||||||
constexpr const char *DegradedL2 = "DEGRADED_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.
|
* L'écrire en configuration créerait une copie qui se périme au premier recâblage.
|
||||||
*/
|
*/
|
||||||
double minPowerWAlways = 0;
|
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;
|
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().
|
* \brief Plan d'optimisation retourné par IScheduler::getPlan().
|
||||||
*
|
*
|
||||||
|
|||||||
@ -39,6 +39,27 @@ struct SurplusBattery {
|
|||||||
double maxDischargeW = 0; //!< Puissance de décharge maximale admissible (W).
|
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). */
|
/*! \brief Entrée tarifaire (créneau HP/HC ou spot market). */
|
||||||
struct TariffEntry {
|
struct TariffEntry {
|
||||||
QDateTime from; //!< Début du créneau ; il court jusqu'au \c from du suivant.
|
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.
|
QList<LoadContext> loads; //!< Une entrée par charge pilotable, tous types confondus.
|
||||||
SurplusTariff tariff; //!< Contexte tarifaire (créneau courant + suivants).
|
SurplusTariff tariff; //!< Contexte tarifaire (créneau courant + suivants).
|
||||||
// forecast : opaque, transmis tel quel si présent — réservé V2
|
// 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));
|
derogEndItem.insert("o:restoredMode", enumValueName(Int));
|
||||||
schema.insert("o:derogationEnded", QVariantList() << derogEndItem);
|
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);
|
schema.insert("loads", QVariantList() << loadItem);
|
||||||
return schema;
|
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
|
// 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
|
// 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
|
// 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
|
// dire.
|
||||||
// réécrit avec cette décision, il ne se supprime pas.
|
//
|
||||||
|
// 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");
|
FuseauDeTest fuseau("Europe/Paris");
|
||||||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-s11.json";
|
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-s11.json";
|
||||||
QFile::remove(cfgPath);
|
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.
|
// 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);
|
const QVariantMap apres = cycle(7);
|
||||||
|
QVERIFY2(!apres.contains("storage"), "aucune batterie : la question de la décharge ne se pose pas");
|
||||||
QVERIFY2(apres == avant,
|
QVERIFY2(apres == avant,
|
||||||
qPrintable(QStringLiteral("le tarif ou la base a changé une décision (§11) :\navant %1\naprès %2")
|
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)),
|
.arg(QString::fromUtf8(QJsonDocument::fromVariant(avant).toJson(QJsonDocument::Compact)),
|
||||||
QString::fromUtf8(QJsonDocument::fromVariant(apres).toJson(QJsonDocument::Compact)))));
|
QString::fromUtf8(QJsonDocument::fromVariant(apres).toJson(QJsonDocument::Compact)))));
|
||||||
#endif
|
#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 "simulationbase.h"
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
class EnergyArbitrator;
|
||||||
|
struct EcritureBatterie;
|
||||||
|
|
||||||
using namespace nymeaserver;
|
using namespace nymeaserver;
|
||||||
|
|
||||||
class ChargerPlugEvent
|
class ChargerPlugEvent
|
||||||
@ -52,6 +57,25 @@ public:
|
|||||||
typedef QList<ChargerPlugEvent> ChargerPlugEvents;
|
typedef QList<ChargerPlugEvent> ChargerPlugEvents;
|
||||||
typedef QList<int> DetailsStepList;
|
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:
|
private slots:
|
||||||
void run_data();
|
void run_data();
|
||||||
void run();
|
void run();
|
||||||
@ -232,6 +256,21 @@ private slots:
|
|||||||
void testTheSnapshotCarriesBaseloadAndPriceOnThePvGrid();
|
void testTheSnapshotCarriesBaseloadAndPriceOnThePvGrid();
|
||||||
//! Brief 3 T4, §11 — un tarif et un profil n'ont AUCUN effet sur le plan.
|
//! Brief 3 T4, §11 — un tarif et un profil n'ont AUCUN effet sur le plan.
|
||||||
void testNoDecisionReadsBaseloadOrPrice();
|
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.
|
//! TOUTE méthode Get* du namespace rend ce que sa déclaration promet.
|
||||||
void testEveryGetterReturnsWhatItDeclares();
|
void testEveryGetterReturnsWhatItDeclares();
|
||||||
//! Le snapshot sert la DÉCISION : le canal, jamais la table — et l'absence n'est pas zéro.
|
//! 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;
|
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) {
|
} else if (thing->thingClassId() == dryRelayThingClassId) {
|
||||||
// RELAIS SEC — il commute, il ne mesure pas. C'est toute sa raison d'être : le mock ne
|
// 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
|
// 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);
|
c->logActionExecuted(actionType, action);
|
||||||
|
|
||||||
// Update states
|
// 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 (thing->thingClassId() == dryRelayThingClassId) {
|
||||||
if (actionType.name() == "power") {
|
if (actionType.name() == "power") {
|
||||||
// Aucune écriture de `currentPower` : la classe ne porte pas l'état. C'est
|
// 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",
|
"name": "powerSwitch",
|
||||||
"displayName": "Mocked Power Switch (relais ECS)",
|
"displayName": "Mocked Power Switch (relais ECS)",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user