diff --git a/energyplugin/etm/adapters/evadapter.cpp b/energyplugin/etm/adapters/evadapter.cpp index 565eb98..1a4409a 100644 --- a/energyplugin/etm/adapters/evadapter.cpp +++ b/energyplugin/etm/adapters/evadapter.cpp @@ -99,6 +99,19 @@ LoadContext EvAdapter::toLoadContext(const QDateTime &now) const ctx.needs.minPowerWAlways = ctx.declared.minPowerW; } + // --- Lot e1 : le besoin RÉSEAU se reporte en heures creuses ----------------------- + // + // DÉCLARÉ ici, DÉCIDÉ par le scheduler (règle 2) — et seulement si le tarif saisi a des + // heures creuses ; sans elles, ces deux champs n'ont aucun effet. Trois modes : les deux + // « avec minimum » et « avec échéance ». `Eco` seul ne demande jamais le réseau, `Normal` + // est piloté par l'utilisateur. Le minimum d'heures creuses est le même plancher, dérivé. + if (mode == ChargingInfo::ChargingModeEcoWithMinCurrent + || mode == ChargingInfo::ChargingModeEcoMinWithTargetTime + || mode == ChargingInfo::ChargingModeEcoWithTargetTime) { + ctx.needs.offPeakShift = true; + ctx.needs.offPeakMinPowerW = ctx.declared.minPowerW; + } + // --- 3g-2 : le plafond du cycle, protection de surcharge COMPRISE ------------------- // // L'écrêtage par la limite de phase vivait dans planSurplusCharging(), que la borne ne diff --git a/energyplugin/etm/adapters/storagedischargeguard.cpp b/energyplugin/etm/adapters/storagedischargeguard.cpp new file mode 100644 index 0000000..cbc050e --- /dev/null +++ b/energyplugin/etm/adapters/storagedischargeguard.cpp @@ -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 +#include +#include + +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 StorageDischargeGuard::capacites() const +{ + QList 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 &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(); + }); +} diff --git a/energyplugin/etm/adapters/storagedischargeguard.h b/energyplugin/etm/adapters/storagedischargeguard.h new file mode 100644 index 0000000..382c1db --- /dev/null +++ b/energyplugin/etm/adapters/storagedischargeguard.h @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +#include "../types/surpluscontext.h" + +class Thing; +class ThingManager; + +/*! + * \brief Bloque la DÉCHARGE d'une batterie, et rien d'autre — lot e1 (2026-10-24). + * + * Le moteur décide (\c RuleBasedScheduler::storageDecision()), ce garde EXÉCUTE (règle 2) : il + * écrit les consignes, les rafraîchit avant que le minuteur de retour de l'onduleur n'expire, et + * les LÈVE explicitement quand la décision tombe. Il n'entre pas dans le waterfall et n'a pas de + * \c LoadConfig : la batterie n'est pas une charge rangée (\c spec_loadmodel.md §14, lot 3f). + * + * \par Ce qu'il écrit — modèle SunSpec 124, lu dans le plugin + * `StorCtl_Mod` est un champ de bits : le bit \c DiSCHARGE **ACTIVE la limite** de décharge + * `OutWRte`, il n'« autorise » pas la décharge. L'état \c enableDischarging du plugin est ce + * bit, \c dischargingRate est `OutWRte` (% de la puissance maximale). Bloquer la décharge, + * c'est donc `dischargingRate = 0` PUIS `enableDischarging = true` — la séquence + * `NO_DISCHARGE` du plugin (`StorCtl_Mod = 2`, `OutWRte = 0`). Écrire + * `enableDischarging = false` LÈVE la limite : c'est le geste de RETOUR à l'automatisme, suivi + * de `dischargingRate` à son maximum (la séquence `NORMAL`, `OutWRte = 100`). + * \par Ce qu'il n'écrit JAMAIS + * \c gridCharging, \c enableCharging, \c chargingRate : la recharge depuis le réseau est hors + * produit (décision de Patrick, 2026-10-24). Une `dischargingRate` négative (charge FORCÉE) + * n'est jamais écrite non plus : seules 0 et le maximum de l'état le sont. + * \par La sûreté, c'est le minuteur de l'onduleur + * Chaque écriture du plugin réarme `InOutWRte_RvrtTms` (paramètre \c revertTime). Un moteur + * arrêté cesse de rafraîchir, et la batterie retrouve son automatisme au bout du minuteur. + * D'où le refus de commander une batterie SANS minuteur (\c noRevertTimer) ou avec un minuteur + * plus court que deux cycles (\c revertTimerTooShort) : la consigne y survivrait au moteur, ou + * expirerait entre deux rafraîchissements. + * \par Générique + * Par l'interface \c energystorage et les états PRÉSENTS — jamais par une ThingClass Fronius. + * Une batterie sans ces états est lue comme avant le lot. + */ +class StorageDischargeGuard : public QObject +{ + Q_OBJECT +public: + //! Minuteur de retour minimal (s) : deux cycles de 60 s, le rafraîchissement venant au tiers. + static constexpr int MinRevertTimeS = 120; + + /*! + * \brief Construit le garde. \param tm Registre des Things (non possédé). \param parent Parent Qt. + */ + explicit StorageDischargeGuard(ThingManager *tm, QObject *parent = nullptr); + + /*! + * \brief Ce que la batterie PERMET — lu sur sa ThingClass et ses paramètres, jamais sur une + * valeur d'état (une limite inactive reste une limite pilotable). + * \param t Batterie (\c energystorage). \return Sa capacité, \c cause vide si pilotable. + */ + static SurplusContext::StorageControl capacite(Thing *t); + + //! \brief Capacités de toutes les batteries configurées. \return Une entrée par batterie. + QList capacites() const; + + /*! + * \brief Exécute la décision du cycle. + * \param configure Le tarif saisi a des heures creuses ce cycle. + * \param bloquer Bloquer la décharge (décision du scheduler). + * \param now Temps de cycle — date les écritures et décide du rafraîchissement. + * + * - \p bloquer : sur chaque batterie pilotable, la limite est posée si elle ne l'est pas, ou + * RAFRAÎCHIE dès que la dernière écriture a l'âge du tiers du minuteur de retour, ou si la + * batterie ne rapporte plus la consigne (onduleur revenu seul à l'automatisme). + * - sinon : toute consigne TENUE est levée explicitement (jamais laissée expirer). + * + * \note Sans heures creuses (\p configure faux), le garde ne lit et n'écrit RIEN — sauf pour + * lever une consigne qu'il tiendrait encore (tarif effacé en pleine charge). C'est ce qui + * rend « sans tarif = etm74 » vrai par construction. + * \note À la PREMIÈRE vue d'une batterie (démarrage de \c nymead), une limite à 0 % déjà + * active est ADOPTÉE comme sienne, si \p configure : c'est la consigne qu'un moteur + * précédent a laissée, et elle est alors rafraîchie ou levée comme les autres. + */ + void appliquer(bool configure, bool bloquer, const QDateTime &now); + + /*! + * \brief Lève toute consigne tenue, sans condition — repli L2. + * \param now Instant de la levée. + */ + void leverTout(const QDateTime &now); + + //! \brief Une consigne est-elle tenue sur au moins une batterie ? \return Vrai si oui. + bool tientUneConsigne() const; + + /*! + * \brief Ce qui a été LU et ÉCRIT, par batterie — pour la télémétrie. + * \return Une carte par batterie : \c thingId, \c controllable, \c o:cause, \c held (consigne + * tenue par le moteur), \c o:heldSince, \c o:lastWriteAt (instants ISO UTC), + * \c observed {\c enableDischarging, \c dischargingRate} si la batterie les expose. + */ + QVariantList vue() const; + +private: + struct Etat { + bool vue = false; //!< La batterie a déjà été examinée (adoption faite). + bool tenue = false; //!< Le moteur tient une limite à 0 %. + QDateTime depuis; //!< Début de la consigne tenue. + QDateTime derniereEcriture; //!< Dernière séquence écrite (temps de cycle). + int enCours = 0; //!< Écritures dont l'issue n'est pas encore connue. + }; + + //! Pose la limite à 0 % : \c dischargingRate puis \c enableDischarging, enchaînés. + void bloquerUne(Thing *t, Etat &e, const QDateTime &now, bool rafraichissement); + //! Lève la limite : \c enableDischarging à faux puis \c dischargingRate au maximum. + void leverUne(Thing *t, Etat &e, const QDateTime &now); + //! Écrit un état modifiable ; \p suite est appelée si l'écriture réussit. + void ecrire(Thing *t, const QString &etat, const QVariant &valeur, + const std::function &suite = {}); + + ThingManager *m_tm = nullptr; + QHash m_etats; //!< thingId → état de la commande. +}; diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index ab5cda3..289c395 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -8,6 +8,7 @@ #include "adapters/sgreadyadapter.h" #include "adapters/etmvariableloadadapter.h" #include "adapters/relayrouter.h" +#include "adapters/storagedischargeguard.h" #include "config/loadconfigstore.h" #include "types/loadconfig.h" #include "scheduler/rulebasedscheduler.h" @@ -58,6 +59,7 @@ EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm, , m_scheduler(new RuleBasedScheduler(this, this)) , m_strategy(m_scheduler) , m_em(em) + , m_storageGuard(new StorageDischargeGuard(tm, this)) , m_tm(tm) { // --- L2 : watchdog fraîcheur compteur (SAFETY.md §L2) --- @@ -529,6 +531,15 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime) applyActionsToAdapters(slot, currentDateTime); // charges arbitrées (Setpoint W, State) ET bornes du waterfall adjustEvChargers(currentDateTime); // bornes RESTÉES au proxy : échéance, spot market + // Lot e1 — la batterie, À LA MÊME POSITION que les autres écritures du cycle. La décision est + // celle du rule-based, qui tourne aussi en ombre d'un optimiseur. Sans heures creuses le garde + // ne lit ni n'écrit rien, sauf pour lever une consigne qu'il tiendrait encore. + const StorageDecision batterie = m_scheduler->storageDecision(); + m_storageGuard->appliquer(ctx.offPeak.configured, batterie.evaluated && batterie.block, + currentDateTime); + if (batterie.evaluated) + qCInfo(dcNymeaEnergy()) << "[Arbitre] Batterie →" << renderFr(batterie.reason); + // Brief 4 T3 — sous un plan d'OPTIMISEUR seulement : la puissance publiée est celle que les // adaptateurs viennent d'APPLIQUER, le financement et le budget se recalculent sur elle (Q3, // Q4), et une action écrêtée est marquée. AVANT la fusion L4, qui remplace ensuite les @@ -547,6 +558,7 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime) // ce que la stratégie espérait. Publier avant produirait un instantané qu'aucun relais ne // confirme — la même famille de défaut qu'annoncer une puissance non appliquée (ECS-410). buildTelemetry(plan, slot, currentDateTime); + attacherStockage(m_telemetry, batterie); publishTelemetry(false); // Brief 4 T4 — le plan en vigueur (GetPlan) et la stratégie qui a commandé ce cycle (§7). @@ -1520,6 +1532,28 @@ SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const } // SurplusPv : interface inverter — déféré (remplissage prévu en 3d) + // --- Heures creuses (lot e1) -------------------------------------------------------- + // La SEULE lecture du tarif par une décision. Le fuseau de la box est relu à chaque cycle : + // il peut changer en marche (le banc l'a fait), et une plage tarifaire est un RÉGLAGE mural. + // Sans heure creuse (aucun tarif, tarif unique, prix tous égaux), `configured` reste faux et + // rien de ce lot ne s'exécute. + if (!m_tariff.zonesCreuses().isEmpty()) { + const QTimeZone fuseau = QTimeZone::systemTimeZone(); + ctx.offPeak.configured = true; + ctx.offPeak.active = m_tariff.enHeuresCreuses(now, fuseau); + ctx.offPeak.nextChange = m_tariff.prochaineBascule(now, fuseau); + if (ctx.offPeak.nextChange.isValid()) + ctx.offPeak.nextChangeWall = ctx.offPeak.nextChange.toTimeZone(fuseau).toString(QStringLiteral("HH:mm")); + + // Ce que la décision sur la batterie lit — et seulement quand elle peut se poser. La + // puissance des bornes est MESURÉE, toutes bornes confondues (mode manuel compris) : + // une voiture qui charge en heures creuses vide la batterie quel que soit son mode. + for (EvCharger *ev : internalEvChargers()) + if (ev->available()) + ctx.evMeasuredW += qMax(0.0, ev->currentPower()); + ctx.storageControl = m_storageGuard->capacites(); + } + // --- Stockage (3g-1) ------------------------------------------------------------------ // Rempli ICI et plus dans le chemin VE. La prise en compte de la batterie n'a jamais été // une règle « véhicule » : c'est une règle de BUDGET, et elle vivait dans @@ -1926,7 +1960,11 @@ void EnergyArbitrator::applyDegradedMode(const QDateTime &now) ev->setMaxChargingCurrent(ev->maxChargingCurrentMinValue(), now, true); } - // Batterie (aucune charge réseau) : repli ajouté avec son adaptateur (3f). + // Batterie : rendue à son AUTOMATISME, explicitement (lot e1). Sous compteur muet le moteur + // cesse de commander ; une limite de décharge tenue serait sinon rafraîchie par personne et + // ne tomberait qu'au minuteur de l'onduleur. Aucune charge réseau n'existe à replier : le + // moteur n'en commande jamais. + m_storageGuard->leverTout(now); // Reprise à froid (critère ECS-411) : un client qui se connecte APRÈS la bascule doit lire // degradedMode. update() rend la main avant toute publication tant que le repli tient ; sans @@ -2407,9 +2445,29 @@ void EnergyArbitrator::buildDegradedTelemetry(const QDateTime &now) // Le mode dégradé porte l'événement LUI AUSSI : `measurementLost` s'y produit précisément, // et c'est le moment où l'utilisateur a le plus besoin de savoir que sa dérogation a cessé. attacherFinsDerogation(snap); + // La batterie, si la question se posait : rendue à son automatisme par le repli. + if (!m_tariff.zonesCreuses().isEmpty() && !m_storageGuard->capacites().isEmpty()) { + StorageDecision repli; + repli.evaluated = true; + repli.reason = { DecisionCode::StorageAutomatic, {{"cause", QStringLiteral("degraded")}} }; + attacherStockage(snap, repli); + } m_telemetry = snap; } +void EnergyArbitrator::attacherStockage(QVariantMap &snap, const StorageDecision &d) const +{ + if (!d.evaluated) + return; // pas d'heure creuse, ou pas de batterie : la question ne s'est pas posée + QVariantMap st; + // CE QUE LE MOTEUR TIENT après le dispatch, pas ce que le plan demandait : une batterie non + // pilotable sous une décision « bloquer » n'est pas bloquée, et le motif dit pourquoi. + st.insert(QStringLiteral("dischargeBlocked"), m_storageGuard->tientUneConsigne()); + st.insert(QStringLiteral("decision"), d.reason.toMap()); + st.insert(QStringLiteral("batteries"), m_storageGuard->vue()); + snap.insert(QStringLiteral("storage"), st); +} + QStringList EnergyArbitrator::forecastSiteCandidates() const { QStringList sites; @@ -3383,6 +3441,14 @@ bool EnergyArbitrator::telemetrySignificantlyDiffers(const QVariantMap &a, const // pris fin — ni pourquoi. if (b.contains(QStringLiteral("derogationEnded"))) return true; + // Lot e1 — la batterie : apparition du bloc, consigne tenue ou levée, CODE du motif. + const QVariantMap sa = a.value(QStringLiteral("storage")).toMap(); + const QVariantMap sb = b.value(QStringLiteral("storage")).toMap(); + if (a.contains(QStringLiteral("storage")) != b.contains(QStringLiteral("storage")) + || sa.value(QStringLiteral("dischargeBlocked")) != sb.value(QStringLiteral("dischargeBlocked")) + || sa.value(QStringLiteral("decision")).toMap().value(QStringLiteral("code")) + != sb.value(QStringLiteral("decision")).toMap().value(QStringLiteral("code"))) + return true; // Même règle pour la CLÔTURE NON TENUE (correctif b) : un niveau ECO_FLOOR_MISSED ne vit // qu'un cycle. Le code de TÊTE ne suffit pas à le voir passer — sous LOAD_UNAVAILABLE il reste // en tête, et rien d'autre ne bouge forcément. diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index 2eca7a6..a89eff8 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -29,6 +29,7 @@ class EvAdapter; class SgReadyAdapter; class EtmVariableLoadAdapter; class RelayRouter; +class StorageDischargeGuard; class ILoadAdapter; class LoadConfigStore; class RuleBasedScheduler; @@ -122,7 +123,9 @@ public: /*! * \brief Le tarif saisi. \return Vide si aucun tarif n'est saisi. - * \note EXPOSÉ, jamais lu par une décision : \c SurplusContext::tariff n'est pas rempli (§11). + * \note Une seule décision le lit — les HEURES CREUSES (lot e1, \c SurplusContext::offPeak), + * et seulement par \c TariffConfig::zonesCreuses() : jamais le prix. \c SurplusContext::tariff + * n'est toujours pas rempli, l'optimiseur ne le reçoit pas par là. */ const TariffConfig &tariff() const { return m_tariff; } @@ -1029,6 +1032,14 @@ private: */ void buildDegradedTelemetry(const QDateTime &now); + /*! + * \brief Ajoute à la trame l'état de la DÉCHARGE de la batterie (lot e1), si la question se pose. + * \param[in,out] snap Trame en construction. \param d Décision du cycle. + * \note Rien n'est ajouté quand \c d.evaluated est faux : sans heure creuse ou sans batterie, + * la trame est celle d'avant le lot, au bit près. + */ + void attacherStockage(QVariantMap &snap, const StorageDecision &d) const; + /*! * \brief Ajoute à une entrée de télémétrie les MESURES de la charge, si elle en déclare. * @@ -1234,6 +1245,8 @@ private: QString m_dossierTrace; //! Gestionnaire d'énergie (production PV instantanée du fil optimiseur). Non possédé. EnergyManager *m_em = nullptr; + //! Exécute la décision sur la décharge de la batterie (lot e1). Possédé. + StorageDischargeGuard *m_storageGuard = nullptr; QHash m_adapters; //!< loadId (ThingId string) → EvAdapter*. /*! * \brief [3g-2] Bornes commandées par le waterfall AU CYCLE COURANT. diff --git a/energyplugin/etm/etm.pri b/energyplugin/etm/etm.pri index 845f353..ef967ca 100644 --- a/energyplugin/etm/etm.pri +++ b/energyplugin/etm/etm.pri @@ -16,6 +16,7 @@ HEADERS += \ $$PWD/adapters/sgreadyadapter.h \ $$PWD/adapters/etmvariableloadadapter.h \ $$PWD/adapters/relayrouter.h \ + $$PWD/adapters/storagedischargeguard.h \ $$PWD/scheduler/rulebasedscheduler.h \ $$PWD/scheduler/optimizerprotocol.h \ $$PWD/scheduler/optimizerclient.h \ @@ -41,6 +42,7 @@ SOURCES += \ $$PWD/adapters/sgreadyadapter.cpp \ $$PWD/adapters/etmvariableloadadapter.cpp \ $$PWD/adapters/relayrouter.cpp \ + $$PWD/adapters/storagedischargeguard.cpp \ $$PWD/scheduler/rulebasedscheduler.cpp \ $$PWD/scheduler/optimizerprotocol.cpp \ $$PWD/scheduler/optimizerclient.cpp \ diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index cede9e9..4aad464 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -246,6 +246,31 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) continue; nonEvLoads.append(lc); } + + // ---- Heures creuses (lot e1) --------------------------------------------------------- + // Le minimum RÉSEAU d'une charge qui le déclare reportable (\c offPeakShift) se sert en + // heures creuses, et seulement là. Hors heures creuses il est RETIRÉ — surplus seulement — et + // la charge est notée « en attente » pour que son motif le dise. Sans heures creuses dans le + // tarif saisi, rien de ceci ne s'exécute : c'est la garantie « sans tarif = etm74 ». + m_attendCreuses.clear(); + m_creusesAt.clear(); + m_creusesStartsAt.clear(); + if (ctx.offPeak.configured) { + if (!ctx.offPeak.active) { + m_creusesAt = ctx.offPeak.nextChangeWall; + m_creusesStartsAt = ctx.offPeak.nextChange.toUTC().toString(Qt::ISODate); + } + for (LoadContext &lc : nonEvLoads) { + if (!lc.needs.offPeakShift) + continue; + if (ctx.offPeak.active) { + lc.needs.minPowerWAlways = qMax(lc.needs.minPowerWAlways, lc.needs.offPeakMinPowerW); + } else { + lc.needs.minPowerWAlways = 0; + m_attendCreuses.insert(lc.id); + } + } + } // 3g-2 — tri TOTAL et STABLE. Le rang seul ne suffit pas : deux charges de même rang // laissaient l'ordre à std::sort, qui n'est pas stable, sur une liste dont l'origine est // un QHash — donc sans ordre garanti d'un cycle à l'autre. Au banc, deux bornes à @@ -577,6 +602,39 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) // Grid funding (ECS/PAC) : dormant jusqu'à 3f (waterfall réseau) — non implémenté ici. + // ---- La DÉCHARGE de la batterie en heures creuses (lot e1) ----------------------------- + // En heures creuses, pendant qu'une voiture tire RÉELLEMENT de la puissance, la batterie ne + // se décharge pas dans la voiture. Rien d'autre : sa charge, et sa charge sur le réseau, ne + // sont jamais touchées. La décision ne lit ni le budget ni la réserve, et elle n'y verse + // rien : bloquer la décharge augmente le SOUTIRAGE, mesuré au cycle suivant par le compteur, + // et ne fabrique aucun surplus (invariant 8). Hors heures creuses, sans voiture, la batterie + // est rendue à son automatisme — et le garde lève ce qu'il tenait. + m_storage = StorageDecision(); + if (ctx.offPeak.configured && ctx.battery.present) { + m_storage.evaluated = true; + QString cause; + bool pilotable = false; + for (const SurplusContext::StorageControl &c : ctx.storageControl) { + if (c.controllable) + pilotable = true; + else if (cause.isEmpty()) + cause = c.cause; + } + if (!ctx.offPeak.active) { + m_storage.reason = { DecisionCode::StorageAutomatic, {{"cause", QStringLiteral("peak")}} }; + } else if (ctx.evMeasuredW < EvChargingThresholdW) { + m_storage.reason = { DecisionCode::StorageAutomatic, {{"cause", QStringLiteral("evNotCharging")}} }; + } else if (!pilotable) { + m_storage.reason = { DecisionCode::StorageNotProtected, {{"cause", cause}} }; + } else { + m_storage.block = true; + m_storage.reason = { DecisionCode::StorageDischargeBlocked, + {{"evPowerW", qRound(ctx.evMeasuredW)}, + {"until", ctx.offPeak.nextChangeWall}, + {"endsAt", ctx.offPeak.nextChange.toUTC().toString(Qt::ISODate)}} }; + } + } + Plan plan; plan.planId = QUuid::createUuid().toString(QUuid::WithoutBraces); plan.strategy = QStringLiteral("rules"); @@ -643,6 +701,14 @@ DecisionReason RuleBasedScheduler::choisirMotif(const MotifEntrees &e) const return { DecisionCode::SurplusSetpoint, {{"budgetW", qRound(e.budgetW)}, {"setpointW", qRound(e.setpointW)}, {"stepped", !e.dynamic}} }; + else if (e.attendCreuses && !e.capEpuise && !(e.refuse && e.retenuW > 0)) + // Lot e1 — la consigne est nulle parce que le minimum réseau est REPORTÉ aux heures + // creuses, pas parce que le surplus manque : sans le tarif, le minimum aurait été servi. + // Le plafond de soutirage et le refus gardent la tête : ils diraient la même chose en + // heures creuses, et ce sont eux qu'il faudrait lire. + return { DecisionCode::EvWaitingOffPeak, + {{"at", m_creusesAt}, {"startsAt", m_creusesStartsAt}, + {"budgetW", qRound(e.budgetW)}} }; else if (m_reserveActive && (m_reserveWithheldW + e.consoW) > 0) // 3g-1 — il Y A du surplus, et une règle l'interdit. À ne pas confondre avec // « il n'y en a pas » : le geste attendu n'est pas le même, et le second envoie @@ -797,7 +863,10 @@ RuleBasedScheduler::SetpointTarget RuleBasedScheduler::decideSetpoint(const Load // suivante trouve un budget nul et repart sur son motif ordinaire ; // · il reste sous le plafond de la protection de surcharge, contrôlé plus haut. const double tolerance = m_arbitrator->acquisitionTolerance(); - if (estUneBorne && tolerance > 0 && budgetW > 0 && budgetW >= plancherW * tolerance) { + // Lot e1 — hors heures creuses, une borne qui attend les heures creuses n'est servie + // qu'au SURPLUS : démarrer en soutirant serait le minimum réseau par une autre porte. + if (estUneBorne && tolerance > 0 && budgetW > 0 && budgetW >= plancherW * tolerance + && !m_attendCreuses.contains(lc.id)) { const double soutireW = plancherW - budgetW; SetpointTarget t; @@ -987,6 +1056,7 @@ RuleBasedScheduler::SetpointTarget RuleBasedScheduler::decideSetpoint(const Load t.entrees.refuse = refuse; t.entrees.refuseDepuisS = lc.telemetry.refusedSinceS; t.entrees.retenuW = refuse ? lc.telemetry.currentPowerW : 0.0; + t.entrees.attendCreuses = m_attendCreuses.contains(lc.id); // Résidu : budget − consigne engagée → charge suivante de la priorité (même cycle). remainingSurplusW = budgetW - setpointW; diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.h b/energyplugin/etm/scheduler/rulebasedscheduler.h index a42c8df..fd8858b 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.h +++ b/energyplugin/etm/scheduler/rulebasedscheduler.h @@ -3,6 +3,7 @@ #pragma once #include +#include #include "ischeduler.h" class EvCharger; @@ -61,6 +62,18 @@ public: */ Plan getPlan(const SurplusContext &ctx) override; + /*! + * \brief La décision du dernier \c getPlan() sur la décharge de la batterie (lot e1). + * \return \c evaluated faux sans heures creuses ou sans batterie. + * \note Un état du rule-based, comme la réserve : il est calculé à CHAQUE cycle, y compris en + * ombre d'un optimiseur — c'est donc lui qui commande la batterie dans les deux stratégies. + */ + StorageDecision storageDecision() const { return m_storage; } + + //! Puissance mesurée des bornes (W) au-delà de laquelle « une voiture charge » : un ampère + //! sur une phase. Sous le plancher de 6 A d'une borne, au-dessus du bruit d'un compteur. + static constexpr double EvChargingThresholdW = 230.0; + private: /*! * \brief Construit un LoadAction pour le cas "délai requis" (TimeRequirement). @@ -119,6 +132,8 @@ private: bool refuse = false; int refuseDepuisS = 0; //!< Depuis combien de secondes l'écart dure. double retenuW = 0; //!< Watts NON rendus au budget du fait du refus. + //! Lot e1 — le minimum réseau de la charge est REPORTÉ aux heures creuses ce cycle. + bool attendCreuses = false; }; /*! @@ -216,4 +231,13 @@ private: //! existe pour supprimer, retournée. Constaté à la PREMIÈRE observation en vrai, le //! 2026-08-26 — surplus à −3 555 W, motif publié, \c withheldW à 0. double m_reserveWithheldW = 0; + + // --- Heures creuses : retenu d'un cycle pour le MOTIF (lot e1) ----------------------- + //! Charges dont le minimum réseau est reporté aux heures creuses CE cycle (hors heures creuses). + QSet 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; }; diff --git a/energyplugin/etm/types/decisionreason.cpp b/energyplugin/etm/types/decisionreason.cpp index 01409c2..ccf8bcf 100644 --- a/energyplugin/etm/types/decisionreason.cpp +++ b/energyplugin/etm/types/decisionreason.cpp @@ -328,6 +328,34 @@ QString renderFr(const DecisionReason &reason, const QString &label) return QStringLiteral("Prix favorable (%1 ct/kWh) — %2 servie sur ce créneau").arg(prix, label); } + // --- Heures creuses et stockage (lot e1) -------------------------------- + if (c == QLatin1String(DecisionCode::EvWaitingOffPeak)) + return QStringLiteral("%1 attend les heures creuses (%2) — hors heures creuses, surplus " + "seulement (%3 W)") + .arg(label, p.value(QStringLiteral("at")).toString()).arg(w("budgetW")); + if (c == QLatin1String(DecisionCode::StorageDischargeBlocked)) + return QStringLiteral("Décharge de la batterie bloquée : la voiture charge en heures " + "creuses (%1 W, jusqu'à %2)") + .arg(w("evPowerW")).arg(p.value(QStringLiteral("until")).toString()); + if (c == QLatin1String(DecisionCode::StorageAutomatic)) { + const QString cause = p.value(QStringLiteral("cause")).toString(); + QString pourquoi = QStringLiteral("cause inconnue (%1)").arg(cause); + if (cause == QLatin1String("peak")) pourquoi = QStringLiteral("hors heures creuses"); + else if (cause == QLatin1String("evNotCharging")) pourquoi = QStringLiteral("aucune voiture ne charge"); + else if (cause == QLatin1String("degraded")) pourquoi = QStringLiteral("mode dégradé L2"); + return QStringLiteral("Batterie en automatique — %1").arg(pourquoi); + } + if (c == QLatin1String(DecisionCode::StorageNotProtected)) { + const QString cause = p.value(QStringLiteral("cause")).toString(); + QString pourquoi = QStringLiteral("cause inconnue (%1)").arg(cause); + if (cause == QLatin1String("noDischargeControl")) + pourquoi = QStringLiteral("elle n'expose pas de limite de décharge pilotable"); + else if (cause == QLatin1String("noRevertTimer")) + pourquoi = QStringLiteral("elle n'a pas de minuteur de retour, le moteur ne la commande pas"); + return QStringLiteral("Batterie non protégée : la voiture charge en heures creuses, mais %1") + .arg(pourquoi); + } + // --- Sécurité et cycle de vie ------------------------------------------ if (c == QLatin1String(DecisionCode::DegradedL2)) return QStringLiteral("Compteur muet depuis >90 s — consigne de repli (L2 watchdog)"); @@ -390,6 +418,7 @@ QStringList DecisionCode::knownCodes() ForecastDateUnreadable, TariffRefused, LoadConfigRefused, LockMinStateHold, SgForced, SgRecommended, SgNormal, OptimizerDecision, PriceSlot, + EvWaitingOffPeak, StorageDischargeBlocked, StorageAutomatic, StorageNotProtected, DegradedL2, SafeStateRelay, SafeStateSetpoint, SafeStateSgReady}; } diff --git a/energyplugin/etm/types/decisionreason.h b/energyplugin/etm/types/decisionreason.h index 8bd7876..3ac448e 100644 --- a/energyplugin/etm/types/decisionreason.h +++ b/energyplugin/etm/types/decisionreason.h @@ -428,6 +428,52 @@ constexpr const char *OptimizerDecision = "OPTIMIZER_DECISION"; */ constexpr const char *PriceSlot = "PRICE_SLOT"; +// --- Heures creuses et stockage (lot e1) ----------------------------------- +/*! + * \brief params: \c at, \c startsAt, \c budgetW — la borne ATTEND LES HEURES CREUSES. + * + * Hors heures creuses, une borne en mode « avec minimum » ou « avec échéance » n'est servie + * qu'au surplus : son minimum réseau est REPORTÉ aux heures creuses. Ce motif remplace alors + * \c SURPLUS_INSUFFICIENT, \c BELOW_MIN_POWER et \c BATTERY_RESERVE, parce que la cause de la + * consigne nulle n'est plus le manque de surplus : sans le tarif, le minimum aurait été servi. + * + * \c at est l'heure MURALE de la box (\c "HH:MM", le réglage tel qu'il a été saisi), + * \c startsAt le même début en INSTANT (ISO UTC) — un réglage et un instant ne se rendent pas + * au même endroit (AGENTS.md). \c budgetW est le budget de surplus vu par la borne. + * + * \note N'est émis QUE si le tarif saisi a des heures creuses. Un motif de limite (phase, + * plafond de soutirage, verrou, indisponibilité) garde la tête : il dit ce qui empêcherait + * la borne de charger même en heures creuses. + */ +constexpr const char *EvWaitingOffPeak = "EV_WAITING_OFF_PEAK"; +/*! + * \brief params: \c evPowerW, \c until, \c endsAt — la DÉCHARGE de la batterie est BLOQUÉE. + * + * En heures creuses, pendant qu'une voiture tire réellement de la puissance (\c evPowerW, + * mesurée), la batterie ne se décharge pas dans la voiture : le moteur active sa limite de + * décharge à 0 %. Il ne touche jamais à sa charge, ni à la charge sur le réseau. \c until + * (\c "HH:MM", heure murale) et \c endsAt (instant) disent quand les heures creuses finissent — + * au plus tard, la batterie retrouve alors son automatisme. + */ +constexpr const char *StorageDischargeBlocked = "STORAGE_DISCHARGE_BLOCKED"; +/*! + * \brief params: \c cause — la batterie est rendue à son AUTOMATISME. + * + * \c cause ∈ \c peak (hors heures creuses), \c evNotCharging (aucune voiture ne tire de + * puissance), \c degraded (mode dégradé L2 : le moteur ne commande plus rien). Le moteur ne + * pose alors aucune consigne ; s'il en tenait une, il l'a LEVÉE explicitement. + */ +constexpr const char *StorageAutomatic = "STORAGE_AUTOMATIC"; +/*! + * \brief params: \c cause — la décharge AURAIT dû être bloquée, et la batterie ne le permet pas. + * + * \c cause ∈ \c noDischargeControl (pas d'états \c enableDischarging et \c dischargingRate + * modifiables), \c noRevertTimer (aucun minuteur de retour : un moteur arrêté laisserait la + * batterie bloquée sans limite de temps, et le moteur refuse donc de la commander). La batterie + * est lue comme avant le lot, et se décharge dans la voiture. + */ +constexpr const char *StorageNotProtected = "STORAGE_NOT_PROTECTED"; + // --- Sécurité et cycle de vie ---------------------------------------------- //! params: — · consigne de repli du watchdog L2 (compteur muet, SAFETY.md §L2). constexpr const char *DegradedL2 = "DEGRADED_L2"; diff --git a/energyplugin/etm/types/loaddescriptor.h b/energyplugin/etm/types/loaddescriptor.h index 10982ed..e853320 100644 --- a/energyplugin/etm/types/loaddescriptor.h +++ b/energyplugin/etm/types/loaddescriptor.h @@ -109,6 +109,21 @@ struct LoadNeeds { * L'écrire en configuration créerait une copie qui se périme au premier recâblage. */ double minPowerWAlways = 0; + + /*! + * \brief Le besoin RÉSEAU de cette charge se reporte-t-il en heures creuses ? (lot e1) + * + * Déclaré par l'adaptateur depuis le mode de l'utilisateur, comme \c minPowerWAlways ; c'est + * le SCHEDULER qui en fait une décision, et seulement si le tarif saisi a des heures creuses. + * Sans heures creuses, ce champ n'a aucun effet. + * + * Avec : hors heures creuses, \c minPowerWAlways est retiré (surplus seulement) ; en heures + * creuses, il vaut au moins \c offPeakMinPowerW. Non sérialisé vers l'optimiseur. + */ + bool offPeakShift = false; + //! Minimum servi en heures creuses quand \c offPeakShift est vrai (W) — le plancher de la + //! charge, dérivé comme \c minPowerWAlways. 0 = aucun. + double offPeakMinPowerW = 0; }; /*! diff --git a/energyplugin/etm/types/plan.h b/energyplugin/etm/types/plan.h index c2048f2..89344c6 100644 --- a/energyplugin/etm/types/plan.h +++ b/energyplugin/etm/types/plan.h @@ -148,6 +148,21 @@ struct PlanDraw { bool perPhaseBound = false; }; +/*! + * \brief La décision du cycle sur la DÉCHARGE de la batterie (lot e1) — interne au moteur. + * + * Prise par le rule-based à chaque cycle (y compris en ombre d'un optimiseur), exécutée par + * \c StorageDischargeGuard. Jamais sérialisée vers l'optimiseur. + * + * \invariant \c evaluated faux ⇔ il n'y a pas d'heure creuse dans le tarif saisi, ou aucune + * batterie : rien n'est publié, rien n'est écrit (sauf la levée d'une consigne encore tenue). + */ +struct StorageDecision { + bool evaluated = false; //!< La question s'est posée ce cycle. + bool block = false; //!< Bloquer la décharge des batteries pilotables. + DecisionReason reason; //!< Pourquoi — non vide quand \c evaluated. +}; + /*! * \brief Plan d'optimisation retourné par IScheduler::getPlan(). * diff --git a/energyplugin/etm/types/surpluscontext.h b/energyplugin/etm/types/surpluscontext.h index 0ee2711..9eb7976 100644 --- a/energyplugin/etm/types/surpluscontext.h +++ b/energyplugin/etm/types/surpluscontext.h @@ -39,6 +39,27 @@ struct SurplusBattery { double maxDischargeW = 0; //!< Puissance de décharge maximale admissible (W). }; +/*! + * \brief Les HEURES CREUSES du cycle, tirées du tarif saisi (lot e1) — INTERNE au moteur. + * + * Jamais sérialisé vers l'optimiseur (il n'est pas dans le §5 du protocole, comme \c PlanBudget) : + * c'est la lecture que le rule-based fait du tarif, pas une donnée d'entrée du contrat. + * + * \invariant \c configured faux ⇔ aucun tarif, tarif unique, ou zones toutes au même prix + * (\c TariffConfig::zonesCreuses() vide). Les autres champs sont alors sans objet, et + * AUCUNE décision ne change : c'est la garantie « sans tarif = etm74 ». + */ +struct SurplusOffPeak { + bool configured = false; //!< Il existe des heures creuses dans le tarif saisi. + bool active = false; //!< \c SurplusContext::timestamp tombe en heures creuses. + //! Prochaine bascule (UTC, à la minute) : DÉBUT des heures creuses si \c active est faux, + //! leur FIN sinon. Un INSTANT, ancré à la capture. + QDateTime nextChange; + //! La même bascule en heure MURALE de la box, \c "HH:MM" — le réglage tel que l'installateur + //! l'a saisi (« 22:00 »), pour le motif publié. + QString nextChangeWall; +}; + /*! \brief Entrée tarifaire (créneau HP/HC ou spot market). */ struct TariffEntry { QDateTime from; //!< Début du créneau ; il court jusqu'au \c from du suivant. @@ -257,4 +278,29 @@ struct SurplusContext { QList loads; //!< Une entrée par charge pilotable, tous types confondus. SurplusTariff tariff; //!< Contexte tarifaire (créneau courant + suivants). // forecast : opaque, transmis tel quel si présent — réservé V2 + + //! Heures creuses du cycle (lot e1). Interne, non sérialisé — voir \c SurplusOffPeak. + SurplusOffPeak offPeak; + /*! + * \brief Puissance MESURÉE tirée par l'ensemble des bornes (W), toutes, y compris celles + * hors waterfall (mode manuel). Interne, non sérialisé. + * \note Lue au compteur de chaque borne (\c currentPower), jamais déduite d'une commande : + * la décharge de la batterie n'est bloquée que pour une voiture qui tire RÉELLEMENT. + */ + double evMeasuredW = 0; + /*! + * \brief Le stockage EXPOSE-t-il une limite de décharge pilotable ? Interne, non sérialisé. + * + * Vide = aucune batterie, ou aucune n'est pilotable. Sinon, une entrée par batterie, + * déclarée par \c StorageDischargeGuard (capacité de la ThingClass et du minuteur de retour, + * jamais une valeur d'état). + */ + struct StorageControl { + QString thingId; //!< ThingId de la batterie. + bool controllable = false; //!< Peut-on bloquer sa décharge en sûreté ? + //! Sinon, pourquoi : \c noDischargeControl (états absents ou non modifiables), + //! \c noRevertTimer (aucun minuteur de retour : un moteur arrêté la laisserait bloquée). + QString cause; + }; + QList storageControl; //!< Une entrée par batterie \c energystorage. }; diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index f4d853a..4cd9e99 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -1426,6 +1426,30 @@ QVariantMap NymeaEnergyJsonHandler::loadTelemetrySchema() derogEndItem.insert("o:restoredMode", enumValueName(Int)); schema.insert("o:derogationEnded", QVariantList() << derogEndItem); + // Lot e1 — la DÉCHARGE DE LA BATTERIE en heures creuses. ABSENT tant que le tarif saisi n'a + // pas d'heure creuse ou qu'aucune batterie n'existe : la question ne se pose pas, et un + // « non bloquée » publié sans elle se lirait comme une décision. Hors de « loads » : la + // batterie n'est pas une charge rangée, et « loads » reste l'ensemble de GetLoadConfig. + // « dischargeBlocked » est ce que le moteur TIENT après le dispatch ; « observed » ce que la + // batterie RAPPORTE — les deux, parce qu'un onduleur peut accepter une écriture et l'ignorer. + QVariantMap observedItem; + observedItem.insert("o:enableDischarging", enumValueName(Bool)); + observedItem.insert("o:dischargingRate", enumValueName(Double)); + QVariantMap batteryItem; + batteryItem.insert("thingId", enumValueName(Uuid)); + batteryItem.insert("controllable", enumValueName(Bool)); + batteryItem.insert("o:cause", enumValueName(String)); // noDischargeControl | noRevertTimer | + // revertTimerTooShort + batteryItem.insert("held", enumValueName(Bool)); + batteryItem.insert("o:heldSince", enumValueName(String)); // ISO 8601 UTC + batteryItem.insert("o:lastWriteAt", enumValueName(String)); // ISO 8601 UTC + batteryItem.insert("o:observed", observedItem); + QVariantMap storageItem; + storageItem.insert("dischargeBlocked", enumValueName(Bool)); + storageItem.insert("decision", decisionItem); + storageItem.insert("batteries", QVariantList() << batteryItem); + schema.insert("o:storage", storageItem); + schema.insert("loads", QVariantList() << loadItem); return schema; } diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 220d9ce..59a99dd 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -10543,8 +10543,16 @@ void Simulation::testNoDecisionReadsBaseloadOrPrice() // même cycle, aux mêmes entrées, avant et après qu'un tarif et un profil existent — et la // charge utile de l'arbitrage doit être identique. S'il casse, ce n'est pas le test qu'il // faut corriger : c'est un lot qui a branché le prix ou la base sur une décision sans le - // dire. Ce test devient FAUX le jour où un lot dédié le décide (e1, « en HC ») — alors il se - // réécrit avec cette décision, il ne se supprime pas. + // dire. + // + // RÉÉCRIT PAR LE LOT e1 (2026-10-24), qui a décidé — et seulement ceci : le tarif dit QUAND + // sont les heures creuses (zones au prix le plus bas), et deux choses en dépendent, la borne + // en mode « avec minimum » ou « avec échéance » et la décharge de la batterie. Ce test n'a + // ni l'une ni l'autre : un chauffe-eau et un routeur, à 15:00, en heures pleines. Le tarif ne + // doit donc RIEN y changer, et le prix lui-même ne décide toujours de rien. Les deux + // décisions du lot sont éprouvées ailleurs (testOffPeakDemoDay et la suite). Ce test devient + // faux le jour où un lot lit le PRIX (Pro, optimisation du coût) ou étend les heures creuses + // à d'autres charges : il se réécrit alors avec cette décision, il ne se supprime pas. FuseauDeTest fuseau("Europe/Paris"); const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-s11.json"; QFile::remove(cfgPath); @@ -10624,9 +10632,517 @@ void Simulation::testNoDecisionReadsBaseloadOrPrice() // LE MÊME CYCLE, LES MÊMES ENTRÉES : la même décision, au watt et au motif près. const QVariantMap apres = cycle(7); + QVERIFY2(!apres.contains("storage"), "aucune batterie : la question de la décharge ne se pose pas"); QVERIFY2(apres == avant, qPrintable(QStringLiteral("le tarif ou la base a changé une décision (§11) :\navant %1\naprès %2") .arg(QString::fromUtf8(QJsonDocument::fromVariant(avant).toJson(QJsonDocument::Compact)), QString::fromUtf8(QJsonDocument::fromVariant(apres).toJson(QJsonDocument::Compact))))); #endif } + +// ============================================================================================ +// Lot e1 (2026-10-24) — heures creuses pour la borne, batterie protégée pendant sa charge +// ============================================================================================ + +//! Une écriture RÉELLEMENT exécutée sur une batterie, lue à la frontière nymea +//! (\c ThingManager::actionExecuted) — jamais dans la mémoire du garde, qui se donnerait raison. +struct EcritureBatterie { + QDateTime cycle; //!< Cycle pendant lequel elle a été exécutée. + QString etat; //!< Nom de l'état écrit. + QVariant valeur; +}; + +namespace { + +//! Ce que l'onduleur ferait de ces écritures : la limite de décharge à 0 % TIENT tant que la +//! dernière écriture date de moins que le minuteur de retour, et que la dernière séquence est +//! « taux 0 puis limite active ». Modèle du GEN24 (minuteur réarmé à CHAQUE écriture, plugin +//! sunspec `planSetpoint()`), indépendant du code du moteur. +bool limiteTenue(const QList &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(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>(); + s.cycleCourant = std::make_shared(); + 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 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> 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 = { + {"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 +} diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index edfacb8..14b56ab 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -27,6 +27,11 @@ #include "simulationbase.h" +#include + +class EnergyArbitrator; +struct EcritureBatterie; + using namespace nymeaserver; class ChargerPlugEvent @@ -52,6 +57,25 @@ public: typedef QList ChargerPlugEvents; typedef QList 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> ecrites; //!< Lues à la frontière nymea. + std::shared_ptr cycleCourant; //!< Date les écritures. + }; + //! Serveur neuf, compteur, borne monophasée « Éco avec minimum » (voiture débranchée), batterie. + ScenarioHc preparerScenarioHc(const QString &nom, BatterieHc batterie, const QVariantList &tarif); + //! Un cycle à l'instant \p a, compteur à \p compteurW (import > 0) ; rend la trame publiée. + QVariantMap cycleHc(ScenarioHc &s, const QDateTime &a, double compteurW); + private slots: void run_data(); void run(); @@ -232,6 +256,21 @@ private slots: void testTheSnapshotCarriesBaseloadAndPriceOnThePvGrid(); //! Brief 3 T4, §11 — un tarif et un profil n'ont AUCUN effet sur le plan. void testNoDecisionReadsBaseloadOrPrice(); + + // ── Lot e1 — heures creuses pour la borne, batterie protégée pendant sa charge ──────── + //! La journée de la démo : surplus à midi, attente des HC à 18 h, reprise à 22:00, décharge + //! bloquée tenue (minuteur réarmé) puis LEVÉE à la fin de charge ; jamais la charge réseau. + void testOffPeakDemoDay(); + //! 06:00 : la borne s'arrête et la batterie est rendue — jour de 24, 23 et 25 heures. + void testOffPeakEndsAtSixAndOnDstDays(); + //! Sans tarif, tarif unique, zones au même prix : le moteur est etm74 (minimum, ni bloc ni écriture). + void testWithoutOffPeakTheEngineIsEtm74(); + //! Batterie sans états de commande, sans minuteur, absente : lue comme avant, motif publié. + void testABatteryWithoutControlIsReadAsBefore(); + //! Le soutirage que le blocage ajoute se MESURE au cycle suivant ; L4 et le plafond décident. + void testBlockedDischargeShowsAtTheMeterAndL4Decides(); + //! L2 : compteur muet, la batterie est levée explicitement, la trame du repli le dit. + void testAMeterSilenceHandsTheBatteryBack(); //! TOUTE méthode Get* du namespace rend ce que sa déclaration promet. void testEveryGetterReturnsWhatItDeclares(); //! Le snapshot sert la DÉCISION : le canal, jamais la table — et l'absence n'est pas zéro. diff --git a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp index d792aa1..2576beb 100644 --- a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp +++ b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp @@ -430,6 +430,16 @@ void IntegrationPluginEnergyMocks::setupThing(ThingSetupInfo *info) return; + } else if (thing->thingClassId() == controllableStorageThingClassId) { + // BATTERIE À LIMITE DE DÉCHARGE PILOTABLE (lot e1) — les états modifiables d'un + // `sunspecStorage` (modèle 124) : `enableDischarging` ACTIVE la limite, `dischargingRate` + // la fixe en % (OutWRte), et le paramètre `revertTime` porte le minuteur de retour. Le + // mock n'émule PAS l'onduleur : il range ce qu'on écrit, et le test lit les écritures + // au fil des actions exécutées (ThingManager::actionExecuted). Pas de contrôleur HTTP : + // les tests posent les états directement. + info->finish(Thing::ThingErrorNoError); + return; + } else if (thing->thingClassId() == dryRelayThingClassId) { // RELAIS SEC — il commute, il ne mesure pas. C'est toute sa raison d'être : le mock ne // savait produire aucune charge pilotable SANS puissance, si bien qu'une charge « non @@ -593,6 +603,16 @@ void IntegrationPluginEnergyMocks::executeAction(ThingActionInfo *info) c->logActionExecuted(actionType, action); // Update states + if (thing->thingClassId() == controllableStorageThingClassId) { + // Chaque état modifiable prend la valeur écrite, rien d'autre : la batterie qui + // obéit. Une écriture de `gridCharging`, `enableCharging` ou `chargingRate` passe + // aussi — c'est au TEST de dire qu'elle n'aurait jamais dû partir. + const QString nom = actionType.name(); + thing->setStateValue(nom, action.paramValue(actionType.paramTypes().findByName(nom).id())); + info->finish(Thing::ThingErrorNoError); + return; + } + if (thing->thingClassId() == dryRelayThingClassId) { if (actionType.name() == "power") { // Aucune écriture de `currentPower` : la classe ne porte pas l'état. C'est diff --git a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json index c3d2e6a..bdfd49b 100644 --- a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json +++ b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json @@ -1013,6 +1013,124 @@ } ] }, + { + "name": "controllableStorage", + "displayName": "Mocked energy storage with a discharge limit", + "id": "648e1a54-96c9-4f0f-9fc8-4326ab7c643e", + "createMethods": [ + "user" + ], + "interfaces": [ + "energystorage" + ], + "paramTypes": [ + { + "id": "6250973b-c0f5-4f2d-9bc9-b077898025de", + "name": "port", + "displayName": "Port", + "type": "uint", + "defaultValue": 26690 + }, + { + "id": "13b73424-5076-46b9-8eb8-38ee0078236e", + "name": "revertTime", + "displayName": "Revert time", + "type": "uint", + "unit": "Seconds", + "minValue": 0, + "maxValue": 28800, + "defaultValue": 300 + } + ], + "stateTypes": [ + { + "id": "f288ff92-0404-496b-a26b-5590b6896d76", + "name": "capacity", + "displayName": "Capacity", + "type": "double", + "unit": "KiloWattHour", + "defaultValue": 7.68 + }, + { + "id": "ca7fcade-1599-4e7d-a0d7-a70461c16493", + "name": "batteryLevel", + "displayName": "Battery level", + "displayNameAction": "Set battery level", + "type": "int", + "unit": "Percentage", + "minValue": 0, + "maxValue": 100, + "writable": true, + "defaultValue": 60 + }, + { + "id": "1f74f29f-55fa-420d-a2b3-4d2a53018dad", + "name": "currentPower", + "displayName": "Current power consumption", + "type": "double", + "unit": "Watt", + "defaultValue": 0 + }, + { + "id": "e1381991-e137-43c7-a75a-62ea97271dca", + "name": "batteryCritical", + "displayName": "Battery level critical", + "type": "bool", + "defaultValue": false + }, + { + "id": "ad1597dd-b1ff-454e-99e7-293425c64c6b", + "name": "enableCharging", + "displayName": "Charge limit active", + "displayNameAction": "Set charge limit active", + "type": "bool", + "writable": true, + "defaultValue": false + }, + { + "id": "11ba1548-03be-4232-a615-b8edbc564437", + "name": "enableDischarging", + "displayName": "Discharge limit active", + "displayNameAction": "Set discharge limit active", + "type": "bool", + "writable": true, + "defaultValue": false + }, + { + "id": "0c2d52b4-c117-477f-a13c-173bce8816a6", + "name": "chargingRate", + "displayName": "Charging rate", + "displayNameAction": "Set charging rate", + "type": "int", + "unit": "Percentage", + "minValue": -100, + "maxValue": 100, + "writable": true, + "defaultValue": 100 + }, + { + "id": "55ac7803-9fb4-4f4e-b38a-4af2b45eeb2e", + "name": "dischargingRate", + "displayName": "Discharging rate", + "displayNameAction": "Set discharging rate", + "type": "int", + "unit": "Percentage", + "minValue": -100, + "maxValue": 100, + "writable": true, + "defaultValue": 100 + }, + { + "id": "26c0e94e-fdb0-485d-afa3-ab157052d365", + "name": "gridCharging", + "displayName": "Grid charging", + "displayNameAction": "Set grid charging", + "type": "bool", + "writable": true, + "defaultValue": false + } + ] + }, { "name": "powerSwitch", "displayName": "Mocked Power Switch (relais ECS)",