From 920cf854dada45f3e25bdfa2d8255ca12f24db71 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sat, 24 Oct 2026 08:26:01 +0200 Subject: [PATCH 01/26] Tarif : les heures creuses sont la ou les zones au prix le plus bas (lot e1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TariffConfig::zonesCreuses() : les zones au prix minimal d'un tarif à zones horaires dont les prix diffèrent. Aucun tarif, tarif unique ou prix tous égaux : aucune heure creuse. Le libellé ne décide pas — « HC » n'a pas de code, le prix se compare. enHeuresCreuses() et prochaineBascule() lisent l'heure murale minute absolue par minute, comme importSeries() : bascules exactes les jours de 23 h (nuit de 7 h) et de 25 h (nuit de 9 h, 02:30 deux fois creuse), épinglées sur des époques calculées hors de Qt (zoneinfo). Co-Authored-By: Claude Opus 5.5 --- energyplugin/etm/tariff/tariffconfig.cpp | 42 ++++++++++++ energyplugin/etm/tariff/tariffconfig.h | 48 +++++++++++-- tests/auto/loadmodel/testloadmodel.cpp | 85 ++++++++++++++++++++++++ tests/auto/loadmodel/testloadmodel.h | 2 + 4 files changed, 172 insertions(+), 5 deletions(-) diff --git a/energyplugin/etm/tariff/tariffconfig.cpp b/energyplugin/etm/tariff/tariffconfig.cpp index 5c2d658..71bd393 100644 --- a/energyplugin/etm/tariff/tariffconfig.cpp +++ b/energyplugin/etm/tariff/tariffconfig.cpp @@ -205,6 +205,48 @@ int TariffConfig::zoneAt(const QDateTime &instant, const QTimeZone &fuseau) cons return m_parMinute.at(murale.hour() * 60 + murale.minute()); } +QList TariffConfig::zonesCreuses() const +{ + QList creuses; + if (m_zones.count() < 2) + return creuses; // aucun tarif, ou tarif unique + double bas = m_zones.first().priceCtkWh; + double haut = bas; + for (const TariffZone &z : m_zones) { + bas = qMin(bas, z.priceCtkWh); + haut = qMax(haut, z.priceCtkWh); + } + if (!(bas < haut)) + return creuses; // tous au même prix : aucune heure n'est meilleure + for (int i = 0; i < m_zones.count(); ++i) + if (m_zones.at(i).priceCtkWh == bas) + creuses << i; + return creuses; +} + +bool TariffConfig::enHeuresCreuses(const QDateTime &instant, const QTimeZone &fuseau) const +{ + const QList creuses = zonesCreuses(); + return !creuses.isEmpty() && creuses.contains(zoneAt(instant, fuseau)); +} + +QDateTime TariffConfig::prochaineBascule(const QDateTime &instant, const QTimeZone &fuseau) const +{ + const QList creuses = zonesCreuses(); + if (creuses.isEmpty() || !instant.isValid()) + return {}; + const bool depart = creuses.contains(zoneAt(instant, fuseau)); + // Minute ABSOLUE suivante, puis pas d'une minute : chaque minute prend la zone de son heure + // murale (même parcours qu'importSeries()). + const qint64 debut = (instant.toSecsSinceEpoch() / 60 + 1) * 60; + for (qint64 a = debut; a <= debut + 50 * 3600; a += 60) { + const QDateTime t = QDateTime::fromSecsSinceEpoch(a, QTimeZone::utc()); + if (creuses.contains(zoneAt(t, fuseau)) != depart) + return t; + } + return {}; +} + QList TariffConfig::importSeries(const QDateTime &start, int stepS, int steps, const QTimeZone &fuseau) const { diff --git a/energyplugin/etm/tariff/tariffconfig.h b/energyplugin/etm/tariff/tariffconfig.h index 5730ada..1f0275a 100644 --- a/energyplugin/etm/tariff/tariffconfig.h +++ b/energyplugin/etm/tariff/tariffconfig.h @@ -21,10 +21,12 @@ * instants absolus. Le même réglage donne donc 23 ou 25 créneaux horaires selon le jour — * c'est voulu (AGENTS.md, « un RÉGLAGE … un INSTANT »). * - * \par Aucune décision ne le lit (§11) + * \par Une seule décision le lit : les HEURES CREUSES (lot e1, 2026-10-24) * Ce type est EXPOSÉ (\c GetTariff, \c GetForecastSnapshot), jamais versé dans - * \c SurplusContext::tariff. Son usage par une décision (rattrapage éco en HC, e1) est un lot - * séparé ; \c zoneAt() existe pour lui, et rien ne l'appelle aujourd'hui depuis le plan. + * \c SurplusContext::tariff — l'optimiseur ne le reçoit pas par là. Le moteur n'en tire + * qu'une chose : quelles minutes sont « creuses » (\c zonesCreuses(), \c enHeuresCreuses()), + * et rien d'autre ne lit le prix. Sans tarif ou avec un tarif unique, il n'y a pas d'heure + * creuse, et le moteur se comporte exactement comme avant ce lot. */ //! \brief Une plage horaire locale `[from, to)`, en minutes depuis minuit ; `from > to` passe minuit. @@ -106,11 +108,47 @@ public: * \param instant Instant absolu. * \param fuseau Fuseau de la box. * \return Index dans \c zones() ; -1 si aucun tarif. - * \note Réservé au lot qui branchera une décision dessus (e1) — aucun appelant aujourd'hui - * hors des tests et de \c importSeries(). */ int zoneAt(const QDateTime &instant, const QTimeZone &fuseau) const; + /*! + * \brief Les zones « creuses » : celles au prix LE PLUS BAS d'un tarif à zones horaires. + * \return Index dans \c zones(), croissants ; VIDE sans tarif, avec un tarif unique, ou quand + * toutes les zones ont le même prix. + * + * \par Pourquoi le prix, et pas le libellé + * « HC » n'a pas de code : le libellé est une donnée de l'installateur, qui peut écrire + * « Creuses », « Nuit » ou « Off-peak ». Le prix, lui, se compare. Plusieurs zones au même + * prix minimal sont TOUTES creuses — deux fenêtres saisies comme deux zones (nuit et début + * d'après-midi) ne changent pas de nature parce qu'on les a nommées séparément. + * \par Pourquoi « des prix distincts » est exigé + * Un tarif dont toutes les zones coûtent le même prix n'a pas d'heure meilleure qu'une + * autre : le dire « creux » partout reviendrait à servir le minimum réseau à toute heure, + * c'est-à-dire à changer le comportement d'un tarif qui, de fait, est unique. + */ + QList zonesCreuses() const; + + /*! + * \brief L'instant tombe-t-il en heures creuses ? + * \param instant Instant absolu. \param fuseau Fuseau de la box. + * \return Vrai si la zone en vigueur est dans \c zonesCreuses() ; faux sans heure creuse. + */ + bool enHeuresCreuses(const QDateTime &instant, const QTimeZone &fuseau) const; + + /*! + * \brief La prochaine BASCULE creuse ↔ pleine après \p instant. + * \param instant Instant absolu. \param fuseau Fuseau de la box. + * \return Premier instant (UTC, à la minute) où \c enHeuresCreuses() change de valeur ; + * invalide sans heure creuse. + * \par Calculée minute ABSOLUE par minute + * Comme \c importSeries() : chaque minute prend la zone de son heure murale. Un jour de + * 23 h saute l'heure qui n'existe pas, un jour de 25 h parcourt deux fois l'heure répétée — + * jamais de soustraction de dates, qui rendrait la durée d'horloge murale (AGENTS.md, + * « l'arithmétique de dates »). Recherche bornée à 50 h : toute zone saisie revient chaque + * jour, donc une bascule existe toujours dans cette fenêtre quand il y a des heures creuses. + */ + QDateTime prochaineBascule(const QDateTime &instant, const QTimeZone &fuseau) const; + /*! * \brief Le prix d'import sur une grille d'instants absolus. * \param start Début du premier créneau. diff --git a/tests/auto/loadmodel/testloadmodel.cpp b/tests/auto/loadmodel/testloadmodel.cpp index 9b97864..dc21b93 100644 --- a/tests/auto/loadmodel/testloadmodel.cpp +++ b/tests/auto/loadmodel/testloadmodel.cpp @@ -2656,6 +2656,91 @@ void TestLoadModel::testTheTariffFollowsLocalTimeAcrossDstDays() QCOMPARE(t.zoneAt(instant, QTimeZone::utc()), 0); } +void TestLoadModel::testOffPeakIsTheCheapestZone() +{ + // Lot e1 (2026-10-24) — les heures creuses sont LA OU LES ZONES AU PRIX LE PLUS BAS d'un + // tarif à zones horaires. Ni tarif, ni tarif unique, ni zones toutes au même prix : aucune + // heure creuse, et le moteur ne change rien (preuve `decisions`). + QString cause; + QVariantMap ou; + const QTimeZone paris("Europe/Paris"); + const auto lire = [&](const QVariantMap &saisie) { + const TariffConfig t = TariffConfig::fromVariant(saisie, cause, ou); + if (!cause.isEmpty()) + qWarning() << "tarif refusé" << cause << ou; + return t; + }; + const QDateTime midi = QDateTime(QDate(2026, 6, 8), QTime(12, 0), paris); + + // SANS HEURE CREUSE — trois formes, une seule réponse. + QVERIFY(TariffConfig().zonesCreuses().isEmpty()); + QVERIFY(!TariffConfig().enHeuresCreuses(midi, paris)); + QVERIFY(!TariffConfig().prochaineBascule(midi, paris).isValid()); + const TariffConfig unique = lire(saisieTarif({zoneTarif("Base", 20.16, {{"00:00", "24:00"}})})); + QVERIFY(cause.isEmpty()); + QCOMPARE(unique.source(), QStringLiteral("fixed")); + QVERIFY(unique.zonesCreuses().isEmpty()); + QVERIFY(!unique.prochaineBascule(midi, paris).isValid()); + const TariffConfig egaux = lire(saisieTarif({zoneTarif("Jour", 20.0, {{"06:00", "22:00"}}), + zoneTarif("Nuit", 20.0, {{"22:00", "06:00"}})})); + QVERIFY(cause.isEmpty()); + QCOMPARE(egaux.source(), QStringLiteral("tou")); + QVERIFY2(egaux.zonesCreuses().isEmpty(), "zones au même prix : aucune n'est creuse"); + + // LE LIBELLÉ NE DÉCIDE PAS : la zone nommée « HC » mais plus chère n'est pas creuse. + const TariffConfig trompeur = lire(saisieTarif({zoneTarif("HC", 30.0, {{"22:00", "06:00"}}), + zoneTarif("HP", 18.0, {{"06:00", "22:00"}})})); + QVERIFY(cause.isEmpty()); + QCOMPARE(trompeur.zonesCreuses(), QList{1}); + + // PLUSIEURS ZONES AU PRIX MINIMAL : toutes creuses. La plus chère (HP) ne l'est pas, ni la + // zone intermédiaire. + const TariffConfig trois = lire(saisieTarif({zoneTarif("Nuit", 12.0, {{"22:00", "06:00"}}), + zoneTarif("Pointe", 30.0, {{"06:00", "12:00"}, {"16:00", "22:00"}}), + zoneTarif("Midi", 12.0, {{"12:00", "14:00"}}), + zoneTarif("Après-midi", 20.0, {{"14:00", "16:00"}})})); + QVERIFY(cause.isEmpty()); + QCOMPARE(trois.zonesCreuses(), (QList{0, 2})); + QVERIFY(trois.enHeuresCreuses(QDateTime(QDate(2026, 6, 8), QTime(13, 0), paris), paris)); + QVERIFY(!trois.enHeuresCreuses(QDateTime(QDate(2026, 6, 8), QTime(15, 0), paris), paris)); + + // LE CAS DE LA DÉMO — 22:00-06:00. Bascules lues à la minute ; époques = ORACLES hors de Qt + // (Python zoneinfo, cf. rapport du lot) — jamais recalculées avec QDateTime. + const TariffConfig t = lire(hphc()); + QVERIFY(cause.isEmpty()); + QCOMPARE(t.zonesCreuses(), QList{0}); + const auto bascule = [&](qint64 depuis) { + return t.prochaineBascule(QDateTime::fromSecsSinceEpoch(depuis, QTimeZone::utc()), paris) + .toSecsSinceEpoch(); + }; + // Jour ordinaire : 18:00 → entrée à 22:00 (1780948800) ; 21:59:59 HP, 22:00 HC. + QCOMPARE(bascule(1780934400), qint64(1780948800)); + QVERIFY(!t.enHeuresCreuses(QDateTime::fromSecsSinceEpoch(1780948799, QTimeZone::utc()), paris)); + QVERIFY(t.enHeuresCreuses(QDateTime::fromSecsSinceEpoch(1780948800, QTimeZone::utc()), paris)); + // …puis sortie à 06:00 (1780977600) : la nuit dure 8 h. + QCOMPARE(bascule(1780948800), qint64(1780977600)); + // Une seconde avant l'entrée : la bascule est la minute suivante, pas celle d'après. + QCOMPARE(bascule(1780948799), qint64(1780948800)); + + // JOUR DE 23 H (2026-03-29) : entrée 22:00 CET (1774731600), sortie 06:00 CEST (1774756800) + // — 7 h réelles, pas 8. Une soustraction de dates dirait 8. + QCOMPARE(bascule(1774731600 - 3600), qint64(1774731600)); + QCOMPARE(bascule(1774731600), qint64(1774756800)); + // JOUR DE 25 H (2026-10-25) : entrée 22:00 CEST (1792872000), sortie 06:00 CET (1792904400) + // — 9 h réelles. L'heure murale 02:30 existe deux fois ; les deux sont creuses. + QCOMPARE(bascule(1792872000), qint64(1792904400)); + QVERIFY(t.enHeuresCreuses(QDateTime::fromSecsSinceEpoch(1792888200, QTimeZone::utc()), paris)); + QVERIFY(t.enHeuresCreuses(QDateTime::fromSecsSinceEpoch(1792888200 + 3600, QTimeZone::utc()), paris)); + // …et le soir du 25 : entrée à 22:00 CET (1792962000). + QCOMPARE(bascule(1792904400), qint64(1792962000)); + + // UN RÉGLAGE MURAL : le même instant, lu à Londres, n'est pas dans la même zone. + const QDateTime z2130 = QDateTime::fromSecsSinceEpoch(1780948800 - 30 * 60, QTimeZone::utc()); // 21:30 Paris + QVERIFY(!t.enHeuresCreuses(z2130, paris)); + QVERIFY(!t.enHeuresCreuses(z2130, QTimeZone("Europe/London"))); // 20:30 Londres + QVERIFY(t.enHeuresCreuses(z2130, QTimeZone("Asia/Tokyo"))); // 04:30 Tokyo +} + void TestLoadModel::testAStraddlingSlotGetsTheTimeWeightedMean() { QString cause; diff --git a/tests/auto/loadmodel/testloadmodel.h b/tests/auto/loadmodel/testloadmodel.h index 17b4c5d..51d14bb 100644 --- a/tests/auto/loadmodel/testloadmodel.h +++ b/tests/auto/loadmodel/testloadmodel.h @@ -142,6 +142,8 @@ private slots: void testTheTariffIsValidatedStrictly(); //! Les plages sont des heures MURALES : jours de 23 h et de 25 h, oracle hors Qt. void testTheTariffFollowsLocalTimeAcrossDstDays(); + //! Lot e1 : heures creuses = zones au prix le plus bas ; bascules exactes les jours de 23 et 25 h. + void testOffPeakIsTheCheapestZone(); //! Un créneau à cheval sur deux zones reçoit la moyenne pondérée par la durée. void testAStraddlingSlotGetsTheTimeWeightedMean(); From d9372c14bab997ad388bbe42ed3ce4ff355d6c88 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sat, 24 Oct 2026 08:34:21 +0200 Subject: [PATCH 02/26] =?UTF-8?q?Heures=20creuses=20:=20la=20borne=20atten?= =?UTF-8?q?d=20les=20HC,=20la=20batterie=20ne=20se=20d=C3=A9charge=20pas?= =?UTF-8?q?=20dans=20la=20voiture=20(lot=20e1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- energyplugin/etm/adapters/evadapter.cpp | 13 + .../etm/adapters/storagedischargeguard.cpp | 259 +++++++++ .../etm/adapters/storagedischargeguard.h | 127 +++++ energyplugin/etm/energyarbitrator.cpp | 68 ++- energyplugin/etm/energyarbitrator.h | 15 +- energyplugin/etm/etm.pri | 2 + .../etm/scheduler/rulebasedscheduler.cpp | 72 ++- .../etm/scheduler/rulebasedscheduler.h | 24 + energyplugin/etm/types/decisionreason.cpp | 29 + energyplugin/etm/types/decisionreason.h | 46 ++ energyplugin/etm/types/loaddescriptor.h | 15 + energyplugin/etm/types/plan.h | 15 + energyplugin/etm/types/surpluscontext.h | 46 ++ energyplugin/nymeaenergyjsonhandler.cpp | 24 + tests/auto/simulation/simulation.cpp | 520 +++++++++++++++++- tests/auto/simulation/simulation.h | 39 ++ .../integrationpluginenergymocks.cpp | 20 + .../integrationpluginenergymocks.json | 118 ++++ 18 files changed, 1447 insertions(+), 5 deletions(-) create mode 100644 energyplugin/etm/adapters/storagedischargeguard.cpp create mode 100644 energyplugin/etm/adapters/storagedischargeguard.h 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)", From 4ffa8391cfa2d62d8730d46fef8b503ab3037d43 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sat, 24 Oct 2026 08:38:29 +0200 Subject: [PATCH 03/26] =?UTF-8?q?docs=20:=20heures=20creuses=20et=20batter?= =?UTF-8?q?ie=20=E2=80=94=20design,=20s=C3=BBret=C3=A9=20L2,=20inventaire?= =?UTF-8?q?=20du=20mock=20(lot=20e1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DESIGN_HC_BATTERIE.md : la règle des heures creuses, les modes de borne concernés et le changement de comportement, la lecture du modèle SunSpec 124 (StorCtl_Mod = bit qui ACTIVE la limite, OutWRte en %, séquence NO_DISCHARGE du plugin) qui fixe l'ordre des écritures, le minuteur de retour comme sûreté (et l'hypothèse non levée du plugin sur le 124), l'invariant 8 et les règles de réserve, ce qui est publié, les preuves, et ce que le lot ne fait pas. SAFETY.md : le repli L2 lève la limite de décharge explicitement. INVENTAIRE_MOCK.md : la classe controllableStorage, et ce qu'elle n'émule pas. DESIGN_PREVISION_PV.md §32.4 : la décision e1 est prise, pointeur. Co-Authored-By: Claude Opus 5.5 --- docs/DESIGN_HC_BATTERIE.md | 226 ++++++++++++++++++++++++++++++++++++ docs/DESIGN_PREVISION_PV.md | 6 + docs/INVENTAIRE_MOCK.md | 18 +++ docs/SAFETY.md | 2 +- 4 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 docs/DESIGN_HC_BATTERIE.md diff --git a/docs/DESIGN_HC_BATTERIE.md b/docs/DESIGN_HC_BATTERIE.md new file mode 100644 index 0000000..75c5d28 --- /dev/null +++ b/docs/DESIGN_HC_BATTERIE.md @@ -0,0 +1,226 @@ +# Heures creuses pour la borne, batterie protégée pendant sa charge — lot e1 (2026-10-24) + +*Décisions de Patrick du 2026-10-24. Ce document décrit ce que le code fait, les preuves qui le +rejouent, et ce qu'il ne fait pas. Code : `etm/tariff/tariffconfig.*`, +`etm/scheduler/rulebasedscheduler.cpp`, `etm/adapters/storagedischargeguard.*`, +`etm/energyarbitrator.cpp` (`buildContext`, `update`, `applyDegradedMode`).* + +## 1. Les heures creuses + +**Règle** : les heures creuses sont **la ou les zones au prix le plus bas** d'un tarif à zones +horaires (`tou`) dont les prix **diffèrent** (`TariffConfig::zonesCreuses()`). + +- « HC » n'a pas de code : le libellé est une donnée de l'installateur. Le prix se compare. +- Plusieurs zones au même prix minimal sont toutes creuses (une nuit et un début d'après-midi + saisis comme deux zones). +- **Aucune heure creuse** sans tarif, avec un tarif unique (`fixed`), ou avec des zones toutes au + même prix. Dans ces trois cas **rien de ce lot ne s'exécute** : le moteur est celui d'etm74 + (preuve `decisions` IDENTIQUE, et `testWithoutOffPeakTheEngineIsEtm74`). +- Lecture murale, minute **absolue** par minute (`enHeuresCreuses()`, `prochaineBascule()`), dans + le fuseau de la box relu à chaque cycle. Les jours de 23 h et de 25 h sont exacts (nuit + 22:00-06:00 de 7 h et de 9 h), épinglés sur des époques calculées hors de Qt. +- Le **prix** n'est toujours lu par aucune décision, et `SurplusContext::tariff` n'est toujours + pas rempli : l'optimiseur ne reçoit rien de neuf. Les heures creuses vivent dans + `SurplusContext::offPeak`, interne au moteur. + +## 2. La borne : surplus le jour, complément en heures creuses + +**Modes concernés** — `ChargingModeEcoWithMinCurrent`, `ChargingModeEcoMinWithTargetTime`, +`ChargingModeEcoWithTargetTime` : ceux dont le besoin réseau peut attendre. `EvAdapter` le +DÉCLARE (`LoadNeeds::offPeakShift`, `offPeakMinPowerW` = le plancher de la borne), le scheduler +le DÉCIDE (règle 2). `ChargingModeEco` ne demande jamais le réseau, `ChargingModeNormal` est +piloté par l'utilisateur : tous deux sont inchangés. + +| | hors heures creuses | en heures creuses | +|---|---|---| +| minimum réseau (`minPowerWAlways`) | **retiré** — surplus seulement | le plancher de la borne, au moins | +| démarrage sous tolérance (`EV_GRID_START`) | **refusé** (le minimum par une autre porte) | inchangé | +| motif d'une consigne nulle | **`EV_WAITING_OFF_PEAK`** `{at, startsAt, budgetW}` | motifs ordinaires | +| échéance (`EV_DEADLINE`, proxy) | inchangée | inchangée | + +**Le changement de comportement** : une borne « Éco avec minimum » ne prend plus ses 6 A au +réseau le soir. Elle charge au surplus tant qu'il y en a, puis attend 22:00. Le motif le dit : +« Borne attend les heures creuses (22:00) — hors heures creuses, surplus seulement (… W) ». +`EV_WAITING_OFF_PEAK` remplace `SURPLUS_INSUFFICIENT`, `BELOW_MIN_POWER` et `BATTERY_RESERVE`, +parce que la cause de la consigne nulle n'est plus le manque de surplus. Le plafond de soutirage +(`DRAW_CAP`), le refus (`RECREDIT_SUSPENDED`), la limite de phase, l'indisponibilité et les +verrous gardent la tête : ils diraient la même chose en heures creuses. + +**En heures creuses**, le plancher est servi au réseau par la passe éco (`ECO_FLOOR_GRID`), +bornée par l'autorisation de soutirage ; le confort ajoute le surplus s'il y en a. La borne +déclare un plafond écrêté par `evPhaseAllowanceW()`, `verifyOverloadProtection()` garde le dernier +mot : l'invariant 2 tient sans rien ajouter. + +**L'échéance** : la puissance nécessaire pour tenir une échéance reste celle du proxy amont, qui +force la charge à pleine puissance au point de non-retour. Ce point est calculé sur l'échéance, +pas sur la fin des heures creuses : une échéance trop proche peut donc encore se servir en +heures pleines. Une échéance est une promesse de l'utilisateur, le tarif ne la rompt pas. Étaler +la charge sur les heures creuses demanderait une énergie restante mesurée (LM-1009) : c'est une +proposition, pas ce lot. + +**À 06:00**, le minimum est retiré : une borne qui charge encore s'arrête (au premier cycle où +son verrou d'enclenchement le permet, 300 s par défaut). + +## 3. La batterie : décharge bloquée pendant la charge de la voiture en heures creuses + +### 3.1 Ce que le modèle SunSpec 124 dit — et ce que le plugin écrit + +Lu dans le plugin `sunspec` (`integrationpluginsunspec.cpp`, `sunspecstoragelogic.*`, +`froniusrules.*`) et dans la définition du modèle 124 de `libnymea-sunspec` : + +- `StorCtl_Mod` est un **champ de bits** — « Activate hold/discharge/charge storage control + mode » : bit 0 `CHARGE`, bit 1 `DiSCHARGE`. Le bit **active la limite** correspondante, il + n'« autorise » pas l'opération. +- `OutWRte` est la limite de décharge, en **pourcentage** de la puissance maximale (`% WDisChaMax` + dans le modèle, « % de `WChaMax` » dans la documentation du plugin). **0 % vaut 0 W quelle que + soit la référence** : l'unité n'a pas d'incidence sur la valeur écrite ici, ni le facteur + d'échelle `InOutWRte_SF` (que le plugin relit avant chaque écriture, AC-12). +- L'état `enableDischarging` du plugin **est** ce bit (`readStates()`), et l'action le pose par + lecture-modification-écriture de `StorCtl_Mod`. L'état `dischargingRate` **est** `OutWRte`. +- Le contrat du plugin (AC-11, `FroniusRules::planBatteryMode()`) le confirme : + `NO_DISCHARGE` = `StorCtl_Mod = 2`, `OutWRte = 0` ; `NORMAL` = `StorCtl_Mod = 0`, `OutWRte = 100`. + +Donc **`enableDischarging = false` LÈVE la limite** — il ne bloque rien. La séquence qui bloque +réellement la décharge est **`dischargingRate = 0`, puis `enableDischarging = true`**. Le retour +à l'automatisme est **`enableDischarging = false`, puis `dischargingRate = 100`** (le maximum de +l'état). + +**Le plugin permet de l'écrire, en deux actions.** Chacune est un plan d'écriture complet (ordre +imposé, relecture, minuteur de retour réarmé en dernier). Elles ne sont pas atomiques : le garde +les **enchaîne** (la seconde part à la confirmation de la première), dans l'ordre qui rend +chaque état intermédiaire sûr : + +- pose : le taux d'abord. Tant que la limite est inactive, le taux est inerte ; quand elle + s'active, il vaut déjà 0. Si la première écriture échoue, la seconde ne part pas — la limite + reste inactive, la batterie en automatique ; +- levée : la limite d'abord, l'automatisme revient tout de suite ; le taux est remis au maximum + pour qu'une limite réactivée plus tard ne reparte pas à 0 %. + +`FroniusRules::planBatteryMode(NO_DISCHARGE)` écrirait les deux registres en une séquence, mais +aucune action du plugin ne l'emprunte. L'exposer serait plus propre (§7) ; ce n'est pas +nécessaire pour ce lot. + +### 3.2 Quand + +**Heure creuse ET bornes qui tirent réellement ≥ 230 W (mesuré, toutes bornes, mode manuel +compris) ET au moins une batterie pilotable.** Sinon la batterie est rendue à son automatisme, +et une consigne tenue est **levée explicitement**. La décision est prise par le rule-based à +chaque cycle (`RuleBasedScheduler::storageDecision()`), y compris en ombre d'un optimiseur. + +La batterie est bloquée au cycle où la voiture **tire** : au cycle de 22:00 la borne vient d'être +commandée et lit encore 0 W, le blocage part à 22:01. Elle est rendue au cycle où la borne ne +tire plus (fin de charge) ou à 06:00. + +Ce que le blocage bloque : **toute** décharge, maison comprise. « Ne pas se décharger dans la +voiture » ne s'exprime pas autrement sans asservir `OutWRte` à la consommation de la maison — +une boucle de régulation que le lot ne fait pas. + +### 3.3 Pilotable, ou non + +Par l'interface `energystorage` et les **états présents** — aucune ThingClass n'est nommée +(`StorageDischargeGuard::capacite()`, question posée à la classe) : + +| cause | condition | effet | +|---|---|---| +| — | `enableDischarging` (booléen) et `dischargingRate` (0 admis) modifiables, `revertTime` ≥ 120 s | commandée | +| `noDischargeControl` | un des deux états absent ou non modifiable | lue comme avant, `STORAGE_NOT_PROTECTED` | +| `noRevertTimer` | pas de paramètre `revertTime`, ou 0 | idem — un moteur arrêté la laisserait bloquée sans fin | +| `revertTimerTooShort` | `revertTime` < 120 s | idem — elle expirerait entre deux cycles | + +### 3.4 Le minuteur et le rafraîchissement + +Chaque écriture du plugin se termine par `InOutWRte_RvrtTms = revertTime` (300 s sous Fronius, +`FroniusRules::DefaultRevertTimeSeconds`). Le garde réécrit la séquence dès que la dernière a +**le tiers du minuteur** (100 s) : à un cycle par minute, deux écritures sont espacées de 120 s, +180 s si un cycle manque — toujours sous 300 s. Il la réécrit aussi, sans attendre, si la +batterie ne rapporte plus la limite (onduleur revenu seul à l'automatisme). + +**La sûreté est le minuteur**, pas le moteur : si `nymead` s'arrête, plus rien ne réécrit, et le +GEN24 reprend son automatisme au plus 300 s après la dernière écriture. Sous compteur muet (L2), +le garde lève la limite explicitement. Au redémarrage, une limite à 0 % trouvée active est +reprise (rafraîchie ou levée). + +⚠️ **Hypothèse non levée, et elle est du plugin** : la sémantique du minuteur est documentée par +le constructeur au chapitre général et illustrée sur le modèle 123, pas sur le 124 (AC-10 du +plugin, « décision D4 non validée »). Les tests la modélisent ; seule une mesure sur un GEN24 +réel la prouve. + +### 3.5 Sécurité et invariant 8 (« pas de boucle ») + +La décision ne lit **ni le budget ni la réserve**, et n'y verse rien. Le budget reste compteur + +PV mesurés. Les règles de réserve (`rulebasedscheduler.cpp`, « Prise en compte du stockage ») +restent justes sous blocage : + +- **la nuit**, batterie bloquée et sans PV : `powerW` ≈ 0, aucune branche ne modifie le budget, + qui vaut −(import). Le blocage augmente l'import, le budget le constate : aucun surplus n'est + fabriqué (`testBlockedDischargeShowsAtTheMeterAndL4Decides`, `surplusW == −import`) ; +- **sous la réserve**, le surplus positif est annulé : il n'y en a pas la nuit, la règle est + sans effet ; +- **au-dessus de la réserve et en charge** (PV avant 06:00 l'été) : la charge de la batterie + est rendue au budget. C'est vrai sous blocage aussi : bloquer la décharge ne touche pas la + charge, et le GEN24 réduit sa charge pour couvrir une consommation nouvelle — ce qu'il ne peut + plus couvrir vient alors du réseau, pas de la batterie, et le compteur le montre au cycle + suivant ; +- **en décharge** (`powerW` < 0, si l'onduleur ignorait la consigne) : retranchée, comme avant — + le sens prudent. + +**Le soutirage ajouté** — ce que la batterie couvrait passe au réseau — se mesure au cycle +suivant : la marge de soutirage du contexte (`houseDrawMargin()`) et `verifyOverloadProtection()` +le lisent au compteur, et coupent la borne s'il dépasse (éprouvé avec une limite de 25 A). La +borne coupée ne tire plus : la batterie est rendue au cycle d'après. + +**Oscillation possible, signalée** : sur une installation au bord de sa limite de phase, la suite +« bloquer → la phase dépasse → L4 coupe la borne → rendre → la batterie couvre → la borne +redémarre » peut se répéter. Sa période est bornée par le verrou d'enclenchement de la borne +(300 s), et L4 tient la sécurité à chaque tour. + +**Hystérésis de la réserve de 20 %** : non touchée. Effet indirect seulement : la batterie ne se +vide plus dans la voiture la nuit, elle passe plus souvent la réserve le matin. + +## 4. Ce qui est publié + +- **Journal** : `[Arbitre] Batterie → Décharge de la batterie bloquée : la voiture charge en + heures creuses (1380 W, jusqu'à 06:00)`, à chaque cycle où la question se pose ; `[Batterie] … + décharge BLOQUÉE` et `… RENDUE à l'automatisme` aux transitions (`qCInfo`), le rafraîchissement + en `qCDebug`, un refus d'écriture en `qCWarning` (règle 7-d). +- **Télémétrie** (`GetLoadTelemetry` et `LoadTelemetryChanged`, même charge utile) : un bloc + `storage`, **absent** sans heure creuse ou sans batterie — `dischargeBlocked` (ce que le moteur + TIENT après le dispatch), `decision` `{code, params}`, et `batteries[]` avec ce qui est LU + (`observed.enableDischarging`, `observed.dischargingRate`). Une transition du bloc notifie. +- **Motifs** : `EV_WAITING_OFF_PEAK`, `STORAGE_DISCHARGE_BLOCKED`, `STORAGE_AUTOMATIC` + (`peak` | `evNotCharging` | `degraded`), `STORAGE_NOT_PROTECTED`. Aucun n'est émissible par + un optimiseur. +- **Aucun flux sortant nouveau** (invariant 10) : le garde parle au ThingManager, comme tous les + adaptateurs. + +## 5. Jamais + +`gridCharging`, `enableCharging`, `chargingRate` ne sont jamais écrits, ni un `dischargingRate` +négatif (charge forcée). La recharge de la batterie sur le réseau est l'offre Pro. Le test de la +journée le vérifie sur toutes les écritures exécutées, et la contre-épreuve qui écrit +`gridCharging` le fait rougir. + +## 6. Preuves + +| Preuve | Ce qu'elle rejoue | +|---|---| +| `testOffPeakIsTheCheapestZone` (loadmodel) | la règle ; aucun / unique / prix égaux ; jours de 23 et 25 h | +| `testOffPeakDemoDay` | 12 h, attente à 18 h, refus d'`EV_GRID_START`, reprise à 22:00, blocage à 22:01, minuteur tenu 88 cycles, levée à la fin de charge, aucune écriture interdite | +| `testOffPeakEndsAtSixAndOnDstDays` | arrêt et levée à 06:00, jours de 23, 24 et 25 h | +| `testWithoutOffPeakTheEngineIsEtm74` | trois formes sans heure creuse : minimum à 18 h, ni bloc ni écriture, trames identiques | +| `testABatteryWithoutControlIsReadAsBefore` | sans états, sans minuteur, absente | +| `testBlockedDischargeShowsAtTheMeterAndL4Decides` | invariant 8, soutirage mesuré, L4 coupe, levée | +| `testAMeterSilenceHandsTheBatteryBack` | L2 lève explicitement | +| `decisions` | IDENTIQUE à la référence sans tarif | + +Les écritures sont lues **à la frontière nymea** (`ThingManager::actionExecuted`), et le GEN24 est +modélisé dans le test (`limiteTenue()`), jamais dans le moteur ni dans le mock. + +## 7. Ce que ce lot ne fait pas + +- La recharge de la batterie sur le réseau ; toute optimisation par le prix (offre Pro). +- L'étalement d'une échéance sur les heures creuses (§2). +- Une action atomique « limite de décharge » dans le plugin (`planBatteryMode(NO_DISCHARGE)` + existe et n'est pas exposée), et un état au nom moins trompeur que `enableDischarging` — à + proposer au dépôt du plugin. +- La batterie comme charge rangée du waterfall (lot 3f, `spec_loadmodel.md` §14). diff --git a/docs/DESIGN_PREVISION_PV.md b/docs/DESIGN_PREVISION_PV.md index a6fc727..3b668e2 100644 --- a/docs/DESIGN_PREVISION_PV.md +++ b/docs/DESIGN_PREVISION_PV.md @@ -1679,3 +1679,9 @@ jours de 23 et 25 h (oracles Python `zoneinfo` dans les tests). **Pour e1 (plus tard, sans être lu maintenant)** : `TariffConfig::zoneAt(instant, fuseau)` rend la zone en vigueur ; « HC » n'a pas de code — le libellé est une donnée de l'installateur. Le lot e1 devra décider d'une règle (par exemple : la ou les zones au prix le plus bas), pas la deviner ici. + +> **FAIT (lot e1, 2026-10-24)** — la règle retenue est celle-là : les heures creuses sont la ou +> les zones au prix le plus bas d'un tarif `tou` dont les prix diffèrent +> (`TariffConfig::zonesCreuses()`). Elles décident de deux choses, et le prix d'aucune : +> la borne en mode « avec minimum » ou « avec échéance » et la décharge de la batterie. Design, +> preuves et limites : `docs/DESIGN_HC_BATTERIE.md`. diff --git a/docs/INVENTAIRE_MOCK.md b/docs/INVENTAIRE_MOCK.md index e6e1bb9..bbc1dfe 100644 --- a/docs/INVENTAIRE_MOCK.md +++ b/docs/INVENTAIRE_MOCK.md @@ -28,6 +28,7 @@ rien sur les cas qu'il ne sait pas produire. | `powerSwitch` | ✅ | ❌ | — | | `etmVariableLoad` | ✅ *(`currentPowerW`)* | ❌ | — | | `energyStorage` | ✅ | ❌ | — | +| `controllableStorage` *(lot e1, 2026-10-24)* | ✅ | ❌ | — | | `car`, `notification` | ❌ | ❌ | — | **Ce que la table donne, et c'est la bonne nouvelle** : `sessionEnergy` et `totalEnergyConsumed` @@ -228,3 +229,20 @@ qui ne mesure qu'une charge. site, il ne l'a pas — `snow` n'est éprouvé qu'en unitaire), et un plan **sans** les paramètres v2 (la classe les porte toujours — le cas « openmeteo d'avant le brief 2 » n'est éprouvé qu'en unitaire, par `lirePlan()`). + +## 10. Batterie pilotable — `controllableStorage` (lot e1, 2026-10-24) + +| Classe | Porte | Ce qu'elle ouvre | +|---|---|---| +| `controllableStorage` | interface `energystorage` ; états MODIFIABLES `enableDischarging`, `dischargingRate`, `enableCharging`, `chargingRate`, `gridCharging` (les noms du `sunspecStorage` du plugin SunSpec, modèle 124) ; paramètre `revertTime` (minuteur de retour, 300 s par défaut, 0 = aucun) | la commande de la décharge en heures creuses, ses deux refus (`noRevertTimer` avec `revertTime = 0` ; `noDischargeControl` avec `energyStorage`, qui n'a pas ces états) | + +**Ce que le mock ne fait PAS, à dessein** : il n'émule pas l'onduleur. Il range ce qu'on écrit, +et c'est tout — le minuteur de retour n'expire jamais de lui-même. Le modèle de l'onduleur (« la +limite tient tant que la dernière écriture a moins de `revertTime` ») vit dans le TEST +(`limiteTenue()`), qui lit les écritures à la frontière nymea (`ThingManager::actionExecuted`) et +non dans la mémoire du garde : un modèle logé dans le mock ou dans le moteur se donnerait raison. + +**Ce qui reste introuvable ici** : une écriture ACCEPTÉE puis IGNORÉE par l'onduleur (le plugin +la relit et publie `controlHealthy = false`, AC-13 du plugin), et le retour SPONTANÉ de l'onduleur +à l'automatisme à l'échéance du minuteur. Le premier se lit dans `storage.batteries[].observed` ; +le second n'est éprouvé que par le modèle du test. diff --git a/docs/SAFETY.md b/docs/SAFETY.md index 1a1264b..a62a013 100644 --- a/docs/SAFETY.md +++ b/docs/SAFETY.md @@ -62,7 +62,7 @@ repli **conservatrice** : le repli n'INITIE rien, il borne ce qui tourne déjà. | EV débranché | Aucune action | | ECS | Relais coupé (palier 0, `force=true`) | | SG-Ready PAC | État **2** (normal — mains off, la PAC chauffe selon son thermostat). JAMAIS état 1 (blocage) : bloquer une PAC sous compteur muet = maison qui ne chauffe plus sans raison visible. | -| Batterie | Aucune charge réseau (surplus uniquement, plafonné à 0 si compteur muet) | +| Batterie | Rendue à son **automatisme** : une limite de décharge posée en heures creuses (lot e1) est **levée explicitement** à la transition (`enableDischarging = false`, puis `dischargingRate` au maximum). Le moteur ne commande jamais de charge réseau, il n'y a donc rien d'autre à replier. Si la levée échoue, le minuteur de retour de l'onduleur (`revertTime`) la fait tomber seul : c'est pour cela qu'une batterie sans minuteur n'est jamais commandée. | « Maintenu » ≠ « démarré » : le mode dégradé ne force jamais l'activation d'une charge. La garantie *"jamais 0 A si le VE est branché"* appartient au **failsafe L1 de la borne** From 3cbcdeeca37aef5d4098f8f223d4aac5ebbb1fd9 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sat, 24 Oct 2026 08:56:24 +0200 Subject: [PATCH 04/26] Test : le bloc storage se lit aussi par GetLoadTelemetry (lot e1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La journée de la démo relit la télémétrie par la frontière RPC pendant le blocage : le bloc passe la validation du schéma déclaré (o:storage), et le Get rend ce qui a été publié. Co-Authored-By: Claude Opus 5.5 --- tests/auto/simulation/simulation.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 59a99dd..a4ff877 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -10860,6 +10860,14 @@ void Simulation::testOffPeakDemoDay() 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 + // PAR LA FRONTIÈRE RPC, pas par l'accesseur : le bloc passe la validation du schéma déclaré + // (`o:storage`), et le Get rend ce qui a été publié. + const QVariantMap rpc = injectAndWait("NymeaEnergy.GetLoadTelemetry").toMap().value("params").toMap(); + QCOMPARE(rpc.value("storage").toMap().value("dischargeBlocked").toBool(), true); + QCOMPARE(rpc.value("storage").toMap().value("decision").toMap().value("code").toString(), + QString(DecisionCode::StorageDischargeBlocked)); + QCOMPARE(rpc.value("storage").toMap().value("batteries").toList().value(0).toMap() + .value("thingId").toUuid(), s.batterie->id()); 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")), From 2cde57ccf56684828911fa809e415e6e6edf052a Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sat, 24 Oct 2026 09:21:28 +0200 Subject: [PATCH 05/26] =?UTF-8?q?decisions=20:=20r=C3=A9f=C3=A9rence=20?= =?UTF-8?q?=C3=A9tendue=20aux=20six=20fonctions=20du=20lot=20e1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sans elles, l'enregistrement est identique octet pour octet à la référence de c5c3203 (23 799 lignes, cmp). Les 151 lignes ajoutées sont celles des six scénarios d'heures creuses (testOffPeakDemoDay, …EndsAtSixAndOnDstDays, testWithoutOffPeakTheEngineIsEtm74, testABatteryWithoutControlIsReadAsBefore, testBlockedDischargeShowsAtTheMeterAndL4Decides, testAMeterSilenceHandsTheBatteryBack). Deux enregistrements successifs identiques. Co-Authored-By: Claude Opus 5.5 --- .../decisions/reference/decisions.ndjson.xz | Bin 562692 -> 565104 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/tests/auto/decisions/reference/decisions.ndjson.xz b/tests/auto/decisions/reference/decisions.ndjson.xz index 7becd81dbccf31688723d90cdc3d42f50065a4f0..328fa68d491dc85f1e0eabd7538bc3d0d0c6ba07 100644 GIT binary patch delta 3061 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 DoLSng delta 630 zcmV-+0*U?bz9NLIA|U-YdRSTj00idNv_=HLzN;dUm9r`#0UQ7T000$|-F|@}g&+Zi zAOeLT1BD<2g&+lmAO?jX2elvwXJrM9;Dazww~S>7RxW?UYGM6pUB2}AFst=H9$g0L_Uy3u!Rocy2U(V&69HWKWy$hFBP%2~Fp!x~eDLq^UXIg`s;>~+ zqssfE0Y`s)=bmM=oK`;1^WwM;*jdc0QO_s%-=;N@jYc-r?d;zI)wuoPf zKQlr3=$!&3N3L=PK_dtUGpTBuz zrpVd92^;MrVZPNH#h(Ig>(EhQ++oY7{=aL8o>}sYyzR+k^D6`Wava;-J0iKAVs9(&v_V)LdbF9^ekCzYiRUs_q z40eB3UPfpw4C@t*@sEA3zf zQlniRNHa!=@DnaT8lD`{>Pga;>9ZJoCIBV?065mCHI(_bYybh;t0Iw=vnl`p0MocO QvlJIQFarPp000D8TI}^J0RR91 From b007aae72017936761d53ec2a85a9165eb311837 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sat, 24 Oct 2026 11:19:15 +0200 Subject: [PATCH 06/26] =?UTF-8?q?Batterie=20:=20une=20consigne=20refus?= =?UTF-8?q?=C3=A9e=20est=20r=C3=A9essay=C3=A9e=20en=20s'espa=C3=A7ant,=20l?= =?UTF-8?q?a=20lev=C3=A9e=20jamais=20(lot=20e1,=20suite)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un onduleur qui refuse la consigne (GEN24 sans « Allow Control », exception 0x04) la voyait reposée à chaque cycle, avec un qCWarning à chaque fois, toute la nuit, et `held` la publiait comme tenue. - Une pose refusée (taux ou limite) ouvre une série de refus : la pose suivante attend 1, 2, 5, 10 puis 15 min au plus (marge de 5 s pour la gigue du cycle). La série se clôt à la première séquence acceptée jusqu'au bout (retour au tiers du minuteur) ou à la levée. - La levée n'est jamais espacée : elle part au cycle même où la décision tombe, série en cours ou non. - Journal : un seul qCWarning par série, les refus suivants en qCDebug, un qCInfo à l'acceptation qui clôt la série. - Télémétrie : pendant une série, `held` est faux (et `dischargeBlocked` avec lui), et la batterie publie o:refusedWrites, o:refusedSince, o:nextAttemptAt. `held` faux plutôt qu'un drapeau à côté : un client qui ignore le champ nouveau ne doit pas afficher une protection absente. Mock : propriété dynamique `refuserEcritures` sur la batterie pilotable. Test testRefusedWritesAreSpacedAndNeverDelayTheRelease ; les six contre-épreuves (espacement, avertissement, held, levée, deux fins de série) le font rougir. Co-Authored-By: Claude Opus 5.5 --- docs/DESIGN_HC_BATTERIE.md | 39 ++++- .../etm/adapters/storagedischargeguard.cpp | 114 ++++++++++-- .../etm/adapters/storagedischargeguard.h | 36 +++- energyplugin/nymeaenergyjsonhandler.cpp | 5 + tests/auto/simulation/simulation.cpp | 163 ++++++++++++++++++ tests/auto/simulation/simulation.h | 3 + .../integrationpluginenergymocks.cpp | 6 + 7 files changed, 346 insertions(+), 20 deletions(-) diff --git a/docs/DESIGN_HC_BATTERIE.md b/docs/DESIGN_HC_BATTERIE.md index 75c5d28..4558647 100644 --- a/docs/DESIGN_HC_BATTERIE.md +++ b/docs/DESIGN_HC_BATTERIE.md @@ -145,6 +145,38 @@ le constructeur au chapitre général et illustrée sur le modèle 123, pas sur plugin, « décision D4 non validée »). Les tests la modélisent ; seule une mesure sur un GEN24 réel la prouve. +### 3.4 bis Un onduleur qui refuse (suite du lot e1) + +Un GEN24 dont « Allow Control » est désactivé répond 0x04 à toute écriture. Avant cette suite, la +batterie ne rapportant jamais la limite, le garde reposait la séquence à chaque cycle, avec un +`qCWarning` à chaque fois, toute la nuit. + +- **Espacement** : une pose refusée (taux OU limite) ouvre une **série de refus**. La pose + suivante attend **1, 2, 5, 10 puis 15 min** au plus, à compter de la pose refusée, avec une + marge de 5 s pour la gigue d'un cycle par minute. Quinze minutes au plus : un réglage corrigé + en cours de nuit est repris dans le quart d'heure. +- **Fin de série** : à la première séquence **acceptée jusqu'au bout** (taux puis limite), le + rythme normal reprend (tiers du minuteur) ; ou à la **levée**, qui clôt la série — la pose + suivante est une consigne neuve. +- **La levée n'est jamais espacée** : rendre l'automatisme part au cycle même où la décision + tombe, série en cours ou non. Elle n'est pas répétée si elle est refusée (le minuteur de + l'onduleur la fera) ; son refus reste un `qCWarning`. +- **Journal** : un seul `qCWarning` par série (« … REFUSÉE (…) : la décharge n'est PAS + bloquée … »), les refus suivants et les nouvelles tentatives en `qCDebug`, et un `qCInfo` à + l'acceptation qui clôt la série (« … ACCEPTÉE après N refus »). +- **Télémétrie** : pendant une série, **`held` est faux** — et `dischargeBlocked` avec lui — et + la batterie publie `refusedWrites` (poses refusées d'affilée), `refusedSince` et + `nextAttemptAt`. La décision reste `STORAGE_DISCHARGE_BLOCKED` : c'est ce que le moteur a + décidé, l'exécution est ce qui échoue. + +**Pourquoi `held` faux plutôt que `held` vrai plus un drapeau** : `held` et `dischargeBlocked` +sont lus comme « la batterie ne se décharge pas ». Un client qui ignore un champ nouveau — +c'est-à-dire tous les clients d'aujourd'hui — continuerait d'afficher une protection qui +n'existe pas. Avec `held` faux, le client existant dit vrai sans changement, et le client à jour +lit en plus pourquoi et jusqu'à quand. Le passage de `dischargeBlocked` à faux notifie, ce qu'un +drapeau à côté n'aurait pas fait. Un refus se lit au cycle **suivant** la pose : le verdict de +l'onduleur arrive après la publication du cycle qui a écrit, comme `observed`. + ### 3.5 Sécurité et invariant 8 (« pas de boucle ») La décision ne lit **ni le budget ni la réserve**, et n'y verse rien. Le budget reste compteur + @@ -182,11 +214,13 @@ vide plus dans la voiture la nuit, elle passe plus souvent la réserve le matin. - **Journal** : `[Arbitre] Batterie → Décharge de la batterie bloquée : la voiture charge en heures creuses (1380 W, jusqu'à 06:00)`, à chaque cycle où la question se pose ; `[Batterie] … décharge BLOQUÉE` et `… RENDUE à l'automatisme` aux transitions (`qCInfo`), le rafraîchissement - en `qCDebug`, un refus d'écriture en `qCWarning` (règle 7-d). + en `qCDebug`, un refus d'écriture en `qCWarning` (règle 7-d) — une fois par série de refus + pour la pose (§3.4 bis). - **Télémétrie** (`GetLoadTelemetry` et `LoadTelemetryChanged`, même charge utile) : un bloc `storage`, **absent** sans heure creuse ou sans batterie — `dischargeBlocked` (ce que le moteur TIENT après le dispatch), `decision` `{code, params}`, et `batteries[]` avec ce qui est LU - (`observed.enableDischarging`, `observed.dischargingRate`). Une transition du bloc notifie. + (`observed.enableDischarging`, `observed.dischargingRate`) ; pendant une série de refus, + `held` faux et `refusedWrites`, `refusedSince`, `nextAttemptAt`. Une transition du bloc notifie. - **Motifs** : `EV_WAITING_OFF_PEAK`, `STORAGE_DISCHARGE_BLOCKED`, `STORAGE_AUTOMATIC` (`peak` | `evNotCharging` | `degraded`), `STORAGE_NOT_PROTECTED`. Aucun n'est émissible par un optimiseur. @@ -211,6 +245,7 @@ journée le vérifie sur toutes les écritures exécutées, et la contre-épreuv | `testABatteryWithoutControlIsReadAsBefore` | sans états, sans minuteur, absente | | `testBlockedDischargeShowsAtTheMeterAndL4Decides` | invariant 8, soutirage mesuré, L4 coupe, levée | | `testAMeterSilenceHandsTheBatteryBack` | L2 lève explicitement | +| `testRefusedWritesAreSpacedAndNeverDelayTheRelease` | onduleur qui refuse : poses à 0, 1, 3, 8, 18, 33, 48 min, un avertissement par série, `held` faux ; acceptation → rythme normal ; levée immédiate en pleine série, qui clôt la série | | `decisions` | IDENTIQUE à la référence sans tarif | Les écritures sont lues **à la frontière nymea** (`ThingManager::actionExecuted`), et le GEN24 est diff --git a/energyplugin/etm/adapters/storagedischargeguard.cpp b/energyplugin/etm/adapters/storagedischargeguard.cpp index cbc050e..e7651b0 100644 --- a/energyplugin/etm/adapters/storagedischargeguard.cpp +++ b/energyplugin/etm/adapters/storagedischargeguard.cpp @@ -18,6 +18,17 @@ const QString EtatTaux = QStringLiteral("dischargingRate"); // OutWRte, % //! vite reviendrait à réécrire ce qui est en vol. constexpr int ReecritureMinS = 55; +//! Après le n-ième refus d'affilée d'une pose, la suivante attend `EspacementRefusS[n - 1]` +//! secondes (le dernier palier ensuite) : 1, 2, 5, 10 puis 15 min. Un onduleur qui refuse (GEN24 +//! sans « Allow Control », exception Modbus 0x04) refusera encore au cycle suivant ; insister à +//! chaque minute toute la nuit n'apprend rien et charge le bus. Quinze minutes au plus : un +//! réglage corrigé en cours de nuit est repris dans le quart d'heure. +constexpr int EspacementRefusS[] = {60, 120, 300, 600, 900}; +constexpr int PaliersRefus = int(sizeof(EspacementRefusS) / sizeof(EspacementRefusS[0])); +//! Un cycle « par minute » arrive à quelques secondes près : sans cette marge, l'attente d'une +//! minute en coûterait deux (même raison que \c ReecritureMinS). +constexpr int MargeCycleS = 5; + //! La valeur de `dischargingRate` qui rend la décharge LIBRE : le maximum de l'état, 100 % à défaut. double tauxLibre(Thing *t) { @@ -121,6 +132,10 @@ void StorageDischargeGuard::appliquer(bool configure, bool bloquer, const QDateT } if (bloquer) { + // SÉRIE DE REFUS : pas de nouvelle pose avant l'échéance de l'espacement. La levée, + // elle, n'attend jamais (branche ci-dessous). + if (e.refus > 0 && now < e.prochainEssai.addSecs(-MargeCycleS)) + continue; const qint64 age = e.derniereEcriture.isValid() ? e.derniereEcriture.secsTo(now) : -1; const int revertS = t->paramValue(t->thingClass().paramTypes() .findByName(QStringLiteral("revertTime")).id()).toInt(); @@ -154,7 +169,7 @@ void StorageDischargeGuard::leverTout(const QDateTime &now) bool StorageDischargeGuard::tientUneConsigne() const { for (const Etat &e : m_etats) - if (e.tenue) + if (e.tenue && e.refus == 0) return true; return false; } @@ -169,11 +184,18 @@ QVariantList StorageDischargeGuard::vue() const const Etat e = m_etats.value(cap.thingId); QVariantMap m{{QStringLiteral("thingId"), cap.thingId}, {QStringLiteral("controllable"), cap.controllable}, - {QStringLiteral("held"), e.tenue}}; + {QStringLiteral("held"), e.tenue && e.refus == 0}}; if (!cap.cause.isEmpty()) m.insert(QStringLiteral("cause"), cap.cause); - if (e.tenue && e.depuis.isValid()) + if (e.tenue && e.refus == 0 && e.depuis.isValid()) m.insert(QStringLiteral("heldSince"), e.depuis.toUTC().toString(Qt::ISODate)); + // UNE SÉRIE DE REFUS : `held` est faux — l'onduleur ne tient pas ce qu'il refuse — et ces + // trois champs disent pourquoi, et quand le moteur réessaiera. + if (e.tenue && e.refus > 0) { + m.insert(QStringLiteral("refusedWrites"), e.refus); + m.insert(QStringLiteral("refusedSince"), e.refusDepuis.toUTC().toString(Qt::ISODate)); + m.insert(QStringLiteral("nextAttemptAt"), e.prochainEssai.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. @@ -191,7 +213,10 @@ QVariantList StorageDischargeGuard::vue() const void StorageDischargeGuard::bloquerUne(Thing *t, Etat &e, const QDateTime &now, bool rafraichissement) { - if (rafraichissement) + if (e.tenue && e.refus > 0) + qCDebug(dcNymeaEnergy()) << "[Batterie]" << t->name() << "— nouvelle tentative de pose après" + << e.refus << "refus."; + else if (rafraichissement) qCDebug(dcNymeaEnergy()) << "[Batterie]" << t->name() << "— limite de décharge à 0 % rafraîchie (minuteur de retour réarmé)."; else @@ -206,11 +231,59 @@ void StorageDischargeGuard::bloquerUne(Thing *t, Etat &e, const QDateTime &now, // 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. + // La séquence est ACCEPTÉE quand la seconde écriture l'est ; un refus de l'une OU de l'autre + // compte pour la série. const QString id = t->id().toString(); - ecrire(t, EtatTaux, 0, [this, id]() { + const auto refus = [this, id](const QString &etat, const QString &cause) { + noterRefus(id, etat, cause); + }; + ecrire(t, EtatTaux, 0, [this, id, refus]() { if (Thing *suite = m_tm->findConfiguredThing(ThingId(id))) - ecrire(suite, EtatLimite, true); - }); + ecrire(suite, EtatLimite, true, [this, id]() { noterAcceptation(id); }, refus); + }, refus); +} + +void StorageDischargeGuard::noterRefus(const QString &id, const QString &etat, const QString &cause) +{ + if (!m_etats.contains(id) || !m_etats.value(id).tenue) + return; // la consigne a été levée entre-temps : ce verdict tardif ne pèse plus + Etat &e = m_etats[id]; + Thing *t = m_tm->findConfiguredThing(ThingId(id)); + const QString nom = t ? t->name() : id; + const int attente = EspacementRefusS[qMin(e.refus + 1, PaliersRefus) - 1]; + // UN SEUL AVERTISSEMENT PAR SÉRIE : le premier refus est aussi visible que l'application + // (règle 7-d) ; les suivants ne disent rien de neuf, et la télémétrie les compte. + if (e.refus == 0) { + e.refusDepuis = e.derniereEcriture; + qCWarning(dcNymeaEnergy()).noquote() + << "[Batterie]" << nom << "— écriture de" << etat + << QStringLiteral("REFUSÉE (%1) : la décharge").arg(cause) + << "n'est PAS bloquée. Nouvelles tentatives espacées (1, 2, 5, 10 puis 15 min) ; ce" + << "refus n'est plus signalé qu'en débogage jusqu'à la première écriture acceptée."; + } else { + qCDebug(dcNymeaEnergy()).noquote() + << "[Batterie]" << nom << "— écriture de" << etat + << QStringLiteral("encore refusée (%1),").arg(cause) + << e.refus + 1 << "refus d'affilée ; prochain essai dans" << attente << "s."; + } + ++e.refus; + e.prochainEssai = e.derniereEcriture.addSecs(attente); +} + +void StorageDischargeGuard::noterAcceptation(const QString &id) +{ + if (!m_etats.contains(id)) + return; + Etat &e = m_etats[id]; + if (e.refus > 0) { + Thing *t = m_tm->findConfiguredThing(ThingId(id)); + qCInfo(dcNymeaEnergy()) << "[Batterie]" << (t ? t->name() : id) + << "— limite de décharge à 0 % ACCEPTÉE après" << e.refus + << "refus : décharge bloquée, retour au rythme normal."; + } + e.refus = 0; + e.refusDepuis = QDateTime(); + e.prochainEssai = QDateTime(); } void StorageDischargeGuard::leverUne(Thing *t, Etat &e, const QDateTime &now) @@ -221,6 +294,11 @@ void StorageDischargeGuard::leverUne(Thing *t, Etat &e, const QDateTime &now) e.tenue = false; e.depuis = QDateTime(); e.derniereEcriture = now; + // La levée CLÔT une série de refus : la prochaine pose est une nouvelle consigne, au rythme + // normal, et son premier refus sera de nouveau signalé. + e.refus = 0; + e.refusDepuis = QDateTime(); + e.prochainEssai = QDateTime(); // 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. @@ -229,25 +307,37 @@ void StorageDischargeGuard::leverUne(Thing *t, Etat &e, const QDateTime &now) } void StorageDischargeGuard::ecrire(Thing *t, const QString &etat, const QVariant &valeur, - const std::function &suite) + const std::function &suite, + const std::function &refus) { 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)."; + const QString cause = QStringLiteral("aucune réponse du gestionnaire"); + if (refus) + refus(etat, cause); + else + qCWarning(dcNymeaEnergy()) << "[Batterie]" << t->name() << "— écriture de" << etat + << "impossible (" << cause << ")."; 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]() { + // est au moins aussi visible que l'application (règle 7-d) — une fois par série pour la pose + // (\c noterRefus), à chaque fois pour la levée, qui n'est jamais répétée. + connect(info, &ThingActionInfo::finished, this, [this, info, id, etat, valeur, suite, refus]() { if (m_etats.contains(id)) --m_etats[id].enCours; if (info->status() != Thing::ThingErrorNoError) { + if (refus) { + QString statut; + QDebug(&statut).nospace() << info->status(); + refus(etat, QStringLiteral("statut ") + statut); + return; + } Thing *t = m_tm->findConfiguredThing(ThingId(id)); qCWarning(dcNymeaEnergy()) << "[Batterie]" << (t ? t->name() : id) << "— écriture de" << etat << "=" << valeur << "REFUSÉE, statut" << info->status(); diff --git a/energyplugin/etm/adapters/storagedischargeguard.h b/energyplugin/etm/adapters/storagedischargeguard.h index 382c1db..8249321 100644 --- a/energyplugin/etm/adapters/storagedischargeguard.h +++ b/energyplugin/etm/adapters/storagedischargeguard.h @@ -42,6 +42,12 @@ class ThingManager; * 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 Un onduleur qui REFUSE (suite du lot e1, 2026-10-24) + * Un GEN24 dont « Allow Control » est désactivé répond 0x04 à chaque écriture. Une pose refusée + * (taux ou limite) ouvre une SÉRIE de refus : la pose suivante attend 1, 2, 5, 10 puis 15 min + * au plus ; un seul \c qCWarning par série, les suivants en \c qCDebug. La série se CLÔT à la + * première séquence acceptée (retour au rythme du tiers du minuteur) ou à la levée. La levée + * n'est JAMAIS espacée. Pendant la série, la consigne n'est pas publiée comme tenue (\c vue()). * \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. @@ -77,7 +83,9 @@ public: * - \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). + * Pendant une série de refus, aucune pose avant l'échéance de l'espacement. + * - sinon : toute consigne TENUE est levée explicitement (jamais laissée expirer), refusée + * ou non, et sans attendre l'espacement. * * \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 @@ -94,14 +102,20 @@ public: */ void leverTout(const QDateTime &now); - //! \brief Une consigne est-elle tenue sur au moins une batterie ? \return Vrai si oui. + /*! + * \brief Une consigne est-elle tenue sur au moins une batterie ? + * \return Vrai si une limite est posée et que sa dernière pose n'a pas été refusée. + */ 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. + * posée et NON refusée), \c o:heldSince, \c o:lastWriteAt (instants ISO UTC), + * \c observed {\c enableDischarging, \c dischargingRate} si la batterie les expose ; pendant + * une série de refus, \c held est faux et \c o:refusedWrites (poses refusées d'affilée), + * \c o:refusedSince, \c o:nextAttemptAt (ISO UTC) sont publiés. + * \note Un verdict arrive après le cycle qui a écrit : un refus se lit au cycle suivant. */ QVariantList vue() const; @@ -112,15 +126,25 @@ private: 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. + int refus = 0; //!< Poses refusées d'affilée (0 : pas de série en cours). + QDateTime refusDepuis; //!< Pose qui a ouvert la série de refus. + QDateTime prochainEssai; //!< Pas de nouvelle pose avant cet instant (série en cours). }; //! 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. + //! Un refus de la pose de \p id (état \p etat, \p cause lisible) : avertit une fois par + //! série, compte, et fixe le prochain essai. + void noterRefus(const QString &id, const QString &etat, const QString &cause); + //! La pose de \p id est acceptée jusqu'au bout : clôt la série de refus. + void noterAcceptation(const QString &id); + //! Écrit un état modifiable ; \p suite est appelée si l'écriture réussit, \p refus (état, + //! cause) si elle échoue — sans \p refus, l'échec est un \c qCWarning. void ecrire(Thing *t, const QString &etat, const QVariant &valeur, - const std::function &suite = {}); + const std::function &suite = {}, + const std::function &refus = {}); ThingManager *m_tm = nullptr; QHash m_etats; //!< thingId → état de la commande. diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index 4cd9e99..dbba234 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -1443,6 +1443,11 @@ QVariantMap NymeaEnergyJsonHandler::loadTelemetrySchema() batteryItem.insert("held", enumValueName(Bool)); batteryItem.insert("o:heldSince", enumValueName(String)); // ISO 8601 UTC batteryItem.insert("o:lastWriteAt", enumValueName(String)); // ISO 8601 UTC + // Série de refus de l'onduleur : « held » est alors FAUX, et ces trois champs disent depuis + // quand, combien de poses d'affilée, et quand le moteur réessaiera (espacement 1 → 15 min). + batteryItem.insert("o:refusedWrites", enumValueName(Int)); + batteryItem.insert("o:refusedSince", enumValueName(String)); // ISO 8601 UTC + batteryItem.insert("o:nextAttemptAt", enumValueName(String)); // ISO 8601 UTC batteryItem.insert("o:observed", observedItem); QVariantMap storageItem; storageItem.insert("dischargeBlocked", enumValueName(Bool)); diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index a4ff877..da4a88f 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -149,6 +149,15 @@ public: QString texte() const { return m_texte; } //! Les messages un par un : une fausse ligne de journal ne se voit qu'À L'INTÉRIEUR d'un message. QStringList messages() const { return m_messages; } + //! Combien de messages de niveau \p type contiennent \p fragment. + int compte(QtMsgType type, const QString &fragment) const + { + int n = 0; + for (int i = 0; i < m_messages.size(); ++i) + if (m_typeParMessage.at(i) == type && m_messages.at(i).contains(fragment)) + ++n; + return n; + } private: static void collecte(QtMsgType type, const QMessageLogContext &ctx, const QString &msg) @@ -157,6 +166,7 @@ private: for (JournalCapture *c : s_pile) { c->m_texte += msg + QLatin1Char('\n'); c->m_messages.append(msg); + c->m_typeParMessage.append(type); c->m_types.insert(type); } if (s_origine) @@ -164,6 +174,7 @@ private: } QString m_texte; QStringList m_messages; + QList m_typeParMessage; QSet m_types; static QList s_pile; static QtMessageHandler s_origine; @@ -11154,3 +11165,155 @@ void Simulation::testAMeterSilenceHandsTheBatteryBack() QCOMPARE(snap.value("storage").toMap().value("dischargeBlocked").toBool(), false); #endif } + +void Simulation::testRefusedWritesAreSpacedAndNeverDelayTheRelease() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // L'ONDULEUR QUI REFUSE — un GEN24 dont « Allow Control » est désactivé répond 0x04 à toute + // écriture. Avant cette suite, le garde reposait la consigne à chaque cycle, avec un + // avertissement à chaque fois, toute la nuit, et publiait `held` vrai. + FuseauDeTest fuseau("Europe/Paris"); + ScenarioHc s = preparerScenarioHc(QStringLiteral("refus"), BatterieHc::Pilotable, hphcT4()); + QVERIFY(s.arb && s.borne && s.batterie); + s.borne->setStateValue("pluggedIn", true); + s.batterie->setProperty("refuserEcritures", true); + + // TOUTES les tentatives, refusées comprises, lues à la frontière nymea : `s.ecrites` ne garde + // que les acceptées. + struct Tentative { QDateTime cycle; QString etat; QVariant valeur; bool acceptee; }; + QList tentatives; + QObject portee; + ThingManager *tm = NymeaCore::instance()->thingManager(); + const ThingId batterieId = s.batterie->id(); + connect(tm, &ThingManager::actionExecuted, &portee, [&](const Action &action, Thing::ThingError status) { + if (action.thingId() != batterieId) + return; + ActionTypes types = s.batterie->thingClass().actionTypes(); + const ActionType type = types.findById(action.actionTypeId()); + tentatives.append({*s.cycleCourant, type.name(), action.param(ParamTypeId(type.id())).value(), + status == Thing::ThingErrorNoError}); + }); + const auto poses = [&]() { + QList l; + for (const Tentative &t : tentatives) + if (t.etat == QLatin1String("dischargingRate") && qFuzzyIsNull(t.valeur.toDouble())) + l << t.cycle; + return l; + }; + const auto batterie = [](const QVariantMap &snap) { + return snap.value("storage").toMap().value("batteries").toList().value(0).toMap(); + }; + const QString serie = QStringLiteral("n'est PAS bloquée"); // l'avertissement d'une SÉRIE + JournalCapture journal; + + // 1. REFUS EN SÉRIE — 50 minutes en heures creuses, la voiture tire. + const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(21, 0)); // 23:00 à Paris + QVariantMap snap; + for (int m = 0; m <= 50; ++m) + snap = cycleHc(s, t0.addSecs(60 * m), 1380 + 300); + const QList p = poses(); + QVERIFY(!p.isEmpty()); + const QDateTime premiere = p.first(); + QVERIFY2(premiere <= t0.addSecs(120), qUtf8Printable(premiere.toString(Qt::ISODate))); + // L'ESPACEMENT : 1, 2, 5, 10, puis 15 min au plus entre deux poses. + QList ecarts; + for (const QDateTime &d : p) + ecarts << premiere.secsTo(d); + QCOMPARE(ecarts, (QList{0, 60, 180, 480, 1080, 1980, 2880})); + // Le taux est refusé : la limite n'est JAMAIS envoyée derrière (le sens sûr). + for (const Tentative &t : tentatives) + QVERIFY2(t.etat == QLatin1String("dischargingRate") && !t.acceptee, qUtf8Printable(t.etat)); + // UN SEUL avertissement pour la série, et une seule annonce de pose. + QCOMPARE(journal.compte(QtWarningMsg, serie), 1); + QCOMPARE(journal.compte(QtInfoMsg, QStringLiteral("décharge BLOQUÉE")), 1); + // LA TÉLÉMÉTRIE ne prétend pas tenir ce que l'onduleur refuse. La DÉCISION, elle, reste + // « bloquer » : c'est l'exécution qui échoue, et la batterie dit pourquoi. + QCOMPARE(snap.value("storage").toMap().value("decision").toMap().value("code").toString(), + QString(DecisionCode::StorageDischargeBlocked)); + QCOMPARE(snap.value("storage").toMap().value("dischargeBlocked").toBool(), false); + QVariantMap bat = batterie(snap); + QCOMPARE(bat.value("held").toBool(), false); + QVERIFY(!bat.contains("heldSince")); + QCOMPARE(bat.value("refusedWrites").toInt(), 7); + QCOMPARE(bat.value("refusedSince").toString(), premiere.toString(Qt::ISODate)); + QCOMPARE(bat.value("nextAttemptAt").toString(), premiere.addSecs(2880 + 900).toString(Qt::ISODate)); + // …et par la frontière RPC : les champs passent la validation du schéma déclaré. + const QVariantMap rpc = injectAndWait("NymeaEnergy.GetLoadTelemetry").toMap().value("params").toMap(); + QCOMPARE(rpc.value("storage").toMap().value("batteries").toList().value(0).toMap() + .value("refusedWrites").toInt(), 7); + + // 2. L'ONDULEUR ACCEPTE de nouveau (réglage corrigé) : la pose part à l'échéance prévue, pas + // avant, puis le rythme normal (tiers du minuteur) reprend. + s.batterie->setProperty("refuserEcritures", false); + const QDateTime reprise = premiere.addSecs(2880 + 900); + QDateTime a = t0.addSecs(60 * 50); + while (a < reprise) { + a = a.addSecs(60); + snap = cycleHc(s, a, 1380 + 300); + } + QCOMPARE(poses().size(), 8); + QCOMPARE(poses().last(), reprise); + QCOMPARE(tentatives.last().etat, QStringLiteral("enableDischarging")); + QVERIFY(tentatives.last().acceptee); + QCOMPARE(journal.compte(QtInfoMsg, QStringLiteral("ACCEPTÉE après 7 refus")), 1); + a = a.addSecs(60); + snap = cycleHc(s, a, 1380 + 300); + QCOMPARE(snap.value("storage").toMap().value("dischargeBlocked").toBool(), true); + bat = batterie(snap); + QCOMPARE(bat.value("held").toBool(), true); + QVERIFY(!bat.contains("refusedWrites") && !bat.contains("refusedSince") && !bat.contains("nextAttemptAt")); + // Le rythme normal : l'onduleur modélisé hors du moteur tient la limite à chaque cycle — + // un espacement de 15 min l'aurait laissée expirer au bout de 300 s. + const int poseesAvant = s.ecrites->size(); + for (int m = 0; m < 20; ++m) { + a = a.addSecs(60); + 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)))); + } + QVERIFY2(s.ecrites->size() - poseesAvant <= 2 * (20 / 2 + 1), qUtf8Printable(QString::number(s.ecrites->size()))); + + // 3. LA LEVÉE PENDANT UNE SÉRIE DE REFUS : immédiate. Une nouvelle série s'ouvre (un + // rafraîchissement refusé) et elle a son propre avertissement. + s.batterie->setProperty("refuserEcritures", true); + for (int garde = 0; batterie(snap).value("refusedWrites").toInt() < 3; ++garde) { + QVERIFY2(garde < 30, "la série de refus ne s'est pas ouverte"); + a = a.addSecs(60); + snap = cycleHc(s, a, 1380 + 300); + } + QCOMPARE(journal.compte(QtWarningMsg, serie), 2); + const QDateTime prochain = QDateTime::fromString(batterie(snap).value("nextAttemptAt").toString(), Qt::ISODate); + a = a.addSecs(60); + QVERIFY2(prochain > a, "la levée doit tomber AVANT le prochain essai, sinon elle ne prouve rien"); + s.borne->setStateValue("currentPower", 0); + s.borne->setStateValue("charging", false); + const int avantLevee = tentatives.size(); + snap = cycleHc(s, a, 300); + QCOMPARE(tentatives.size(), avantLevee + 2); + QCOMPARE(tentatives.at(avantLevee).cycle, a); + QCOMPARE(tentatives.at(avantLevee).etat, QStringLiteral("enableDischarging")); + QCOMPARE(tentatives.at(avantLevee).valeur.toBool(), false); + QCOMPARE(tentatives.at(avantLevee + 1).etat, QStringLiteral("dischargingRate")); + QCOMPARE(tentatives.at(avantLevee + 1).valeur.toDouble(), 100.0); + QCOMPARE(snap.value("storage").toMap().value("dischargeBlocked").toBool(), false); + bat = batterie(snap); + QCOMPARE(bat.value("held").toBool(), false); + QVERIFY(!bat.contains("refusedWrites") && !bat.contains("nextAttemptAt")); + + // 4. La levée a CLOS la série : la voiture tire de nouveau, la pose part tout de suite, et + // son premier refus est signalé comme celui d'une nouvelle série. + s.borne->setStateValue("currentPower", 1380); + s.borne->setStateValue("charging", true); + const int avantRepose = poses().size(); + a = a.addSecs(60); + snap = cycleHc(s, a, 1380 + 300); + QCOMPARE(poses().size(), avantRepose + 1); + QCOMPARE(poses().last(), a); + a = a.addSecs(60); + snap = cycleHc(s, a, 1380 + 300); + QCOMPARE(journal.compte(QtWarningMsg, serie), 3); + QCOMPARE(batterie(snap).value("refusedWrites").toInt(), 1); +#endif +} diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 14b56ab..3fe2e15 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -271,6 +271,9 @@ private slots: void testBlockedDischargeShowsAtTheMeterAndL4Decides(); //! L2 : compteur muet, la batterie est levée explicitement, la trame du repli le dit. void testAMeterSilenceHandsTheBatteryBack(); + //! Onduleur qui REFUSE : poses espacées (1 → 15 min), un avertissement par série, `held` faux ; + //! une pose acceptée rend le rythme normal ; la levée n'attend jamais. + void testRefusedWritesAreSpacedAndNeverDelayTheRelease(); //! 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 2576beb..e90a4ab 100644 --- a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp +++ b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp @@ -607,6 +607,12 @@ void IntegrationPluginEnergyMocks::executeAction(ThingActionInfo *info) // 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. + // Propriété dynamique `refuserEcritures`, posée par le test : l'onduleur qui REFUSE + // (GEN24 sans « Allow Control », exception Modbus 0x04) — rien n'est appliqué. + if (thing->property("refuserEcritures").toBool()) { + info->finish(Thing::ThingErrorHardwareFailure); + return; + } const QString nom = actionType.name(); thing->setStateValue(nom, action.paramValue(actionType.paramTypes().findByName(nom).id())); info->finish(Thing::ThingErrorNoError); From 1813fe80cf2e3f2cb95cb0955925934ed70bfdb0 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sat, 24 Oct 2026 11:25:05 +0200 Subject: [PATCH 07/26] =?UTF-8?q?decisions=20:=20r=C3=A9f=C3=A9rence=20?= =?UTF-8?q?=C3=A9tendue=20=C3=A0=20la=20fonction=20des=20=C3=A9critures=20?= =?UTF-8?q?refus=C3=A9es=20(lot=20e1,=20suite)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les 77 fonctions existantes donnent 23 950 lignes identiques octet pour octet à la référence de 2cde57c (cmp, lignes de la nouvelle fonction retirées). La référence s'étend des 96 lignes de testRefusedWritesAreSpacedAndNeverDelayTheRelease ; check : IDENTIQUE (24 046 lignes). Co-Authored-By: Claude Opus 5.5 --- .../decisions/reference/decisions.ndjson.xz | Bin 565104 -> 566588 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/tests/auto/decisions/reference/decisions.ndjson.xz b/tests/auto/decisions/reference/decisions.ndjson.xz index 328fa68d491dc85f1e0eabd7538bc3d0d0c6ba07..7d0bfc1300b9f0adff5e1fcfa63bcc666fb22eb3 100644 GIT binary patch delta 2138 zcmV-g2&MP%z9PKJA|U-YdRSTj00idNv_=HL^~oZatJ^9e0UQ7T003xe$7g{cg&+Zi zAOeLT1BD<2g&+lmAO?jX2elvwXJrMJ>xn;yw~S>7wl{ydP~fxz8~ofa;r7sD{O0&p z&qr$7a)FYTl%}PSkzuq3 zN@~Gmeocq#DpZ_l{`by-9@xln37L$X2cwB$aQ+OidzC`#KFxWLWGYl|EW{Hls z9`b$!6!ggMts*G3#ejwHu7?#kd)JId1XN9xNWJ!GsA;3!4-A){mDG=28O4PuZf{F@ zdE7>D_+~7u%czp|PkL~fHI1K4mYtH1^yaL5VJI%P8Gr)TMH_-+wLsJK+4}40DRy%~ z8OVRNN=V=uAZtKxba$h^r$Q1|^|;*sDM^1mfb3K&>CR3E?bUOkCC>&^c}DVwvyU{9 zIwT@s(g?l*vc^7JPUkMYl8F#<)eR3?nHB_6+yMNPwp4gGk0)SdJJ#s7f|Wa z0gq;9{|P(+`da@x7jue!kPsQNg@`oH#}j5!@R@M|dlc~wis6PntMl6{Xw7Gi`$p4Y$tj{~&5_f-fTVVXxJLf+d-;z=8?cXpj89)JA?kdHvun!PoiR z4j{$C?hR5!n%w0?UqN|XJU=lfjP2m0wz?{xvhjIA0R8>6UxBniYOBnvzx)lj$#6-@ zYz(iiyB1#u2*ko63uzehnf=cR958=pL1hhTYsnl(7ltJG=j>BdS!qhXw3nTGwwQ!c z8B_XS#7(jqjBe_`?LA=j)_aSJtiOF$a}UjIOJL(ep=q;ieglDy9Jp3@Z$HRi!2#QQ zQ1!GW%ypM*Y^zA_Jw%(3s2fRd=!*BF<&$D$7Z*`gO{d>bH*g*Hf+sb3OoM;_@(vn2 zMRLb|6|!?e(!?9Gl5Ibsp(;rc#?m;4b6WwDUet(9ru$8J#q)WIDn>`&q^=NqdbXdPrW$!{;+vs znILs1@J0Y5;LFio$*$HvB{WOU2_AA_oWUj~CorGNL< ztZYPanWXiZ(6sQ%WC^k+1aLf<*(Y3s2=CdIlE1J3Ea(k&2z?SS|wc&swMCYkwr07Hfx~Tq1Xy?h_~R$AH3L`2CwRAAOlfXn11@EN$;hUN2X`9Av?T z9~-1*Qv2YUX*#}mwFhI+zzX)4*X(fYvW){kN>GjQpP#n>@Uwp&K{AvD@#z>pgC1So zY&|-Kn7-FmNtzny-X2e6$dhh)K^z@r$KCWRR&- zHrvA9G0rei^v`>xZ5g3!OOwO4-vjiZ1^)9%@%>@AwK5_j(@nJ?l1*N*yBs{MAYN`z z%&!1u?M9-xQu%+Jjf$)Nor6aRu|6}WVJfIwOz#!lsbB?sNYm8&sGeeYjBmNOqST;v z+NUp{m;@Ft2`&DGL*dYUD6!(mN*CHfVHKnauXt$GYso$DB4K-M=u71q@)V3~39Tcs zb5{{qR*7(td6^*W-ky)jpb#Ql!jJkZT*9$+17m9}YQ zu_I1*qdS?O!A>xM`NHBGoM#CSZ>zcQ7e(*N7fAN1SVQ`zX=SujB1g)3plP37Dae$O z%3A^IR$}pK_GVJAseNx9W7mv&Pa`3#&6;Y*wSf#i7)bxN`pF-Nxm-QHRFyX3cx(> zHwIZ+RJ^7fQh-opfaj1z6JI&Vem7mq>#S{v1aN-<-5X&{fHkupQMy#esXbOt?^kEQ zDe0{>VMm=z4kdA{esf1DYNKm1R20x}ksyvpi&TKw-j06MtjS=u8qKv)gQ2qDH@aA8 z2w(ChY?yZ9=ZE;I_>+ZwPTZ;Lp8#Xd00000Ba%@Jo=e+*00EK8BA2V%DgXcg2)>qT Q6c-9RFarPp000D8S`s=9e*gdg delta 642 zcmV-|0)73w$|CT-A|U-YdRSTj00idNv_=HLtG*()_{u6F0UQ7T000MS)FXi)g&+Zi zAOeLT1BD<2g&+lmAO?jX2elvwXJrMF(kVH;w~S>7wl{y3GAiU%qwAx&Ls@Yy0Vao3 zSXi1F2oQiekXp8C0U}cU&ocX}q~E|d1dg5%+I!9LSRocD^Iyy8P=?OqXVUMl?V3T| z;O;q&z;BW3DGT;ZkAz_P6@^siLesC&Wt@_FC`$W!jcy%Boj>tVcf@d% zvru9&1tEVT9a*dgpSOT9UvxgQxsqeL$R%p3IfzBdoRuiCdZsMhpIKljM3K*z)y6)s z!s({EfuN=ZJ>RSSy0fHk$t-IX0qA&R$Yzll&^;OMt2ioOAg(+1(6!gD>DeGF%}kT7Q`8>Glz9!3*fHL?3VH|8X{Pt*5QA+Kh9P?*EmXoHps%!0EE_FDxKz?BI)EZKf>8G70@iTn+XIvS@F|tSJLO6~aB8&Zj~o>K#LjE(sq``S{P#XG z00Ehe^#d}yU~*!v<#}@>=#VUiKfo43-bcZlMC(bXx>Qy=_RQ#s`cT0BAn|L~EPja{vLy cz9P5y$|?W=04XQ*YZMndFarPp000D8TIVk@wEzGB From b144c091782975aa5aee026181c61b9f9522cd38 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sat, 24 Oct 2026 11:25:19 +0200 Subject: [PATCH 08/26] =?UTF-8?q?docs=20:=20heures=20creuses=20et=20batter?= =?UTF-8?q?ie=20=E2=80=94=20les=20questions=20du=20lot=20e1=20deviennent?= =?UTF-8?q?=20des=20d=C3=A9cisions=20dat=C3=A9es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Décisions de Patrick du 2026-10-24, §8 de DESIGN_HC_BATTERIE.md : toute la décharge bloquée, maison comprise ; EV_GRID_START refusé hors heures creuses (seul le mode boost charge tout de suite) ; blocage aussi par une borne en mode Normal ; seuil de 230 W accepté ; étalement de l'échéance renvoyé à un lot à part ; action « limite de décharge » et nouveau nom d'enableDischarging décidés dans le dépôt du plugin. Aucune ne change le code ; le garde reste compatible avec les deux actions actuelles. Co-Authored-By: Claude Opus 5.5 --- docs/DESIGN_HC_BATTERIE.md | 37 +++++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/docs/DESIGN_HC_BATTERIE.md b/docs/DESIGN_HC_BATTERIE.md index 4558647..de9aaf9 100644 --- a/docs/DESIGN_HC_BATTERIE.md +++ b/docs/DESIGN_HC_BATTERIE.md @@ -55,8 +55,11 @@ mot : l'invariant 2 tient sans rien ajouter. force la charge à pleine puissance au point de non-retour. Ce point est calculé sur l'échéance, pas sur la fin des heures creuses : une échéance trop proche peut donc encore se servir en heures pleines. Une échéance est une promesse de l'utilisateur, le tarif ne la rompt pas. Étaler -la charge sur les heures creuses demanderait une énergie restante mesurée (LM-1009) : c'est une -proposition, pas ce lot. +la charge sur les heures creuses demanderait une énergie restante mesurée (LM-1009) : ce sera un +lot à part (§8, D3). + +Le refus d'`EV_GRID_START` hors heures creuses est confirmé (§8, D2) : seul le mode boost +(`ChargingModeNormal`) charge tout de suite. **À 06:00**, le minimum est retiré : une borne qui charge encore s'arrête (au premier cycle où son verrou d'enclenchement le permet, 300 s par défaut). @@ -97,8 +100,8 @@ chaque état intermédiaire sûr : pour qu'une limite réactivée plus tard ne reparte pas à 0 %. `FroniusRules::planBatteryMode(NO_DISCHARGE)` écrirait les deux registres en une séquence, mais -aucune action du plugin ne l'emprunte. L'exposer serait plus propre (§7) ; ce n'est pas -nécessaire pour ce lot. +aucune action du plugin ne l'emprunte. L'exposer est décidé, dans le dépôt du plugin (§8, D6) ; +le garde reste compatible avec les deux actions actuelles. ### 3.2 Quand @@ -113,7 +116,8 @@ tire plus (fin de charge) ou à 06:00. Ce que le blocage bloque : **toute** décharge, maison comprise. « Ne pas se décharger dans la voiture » ne s'exprime pas autrement sans asservir `OutWRte` à la consommation de la maison — -une boucle de régulation que le lot ne fait pas. +une boucle de régulation que le lot ne fait pas. C'est le geste voulu (§8, D1), comme le +blocage par une borne en mode boost (D4) et le seuil de 230 W (D5). ### 3.3 Pilotable, ou non @@ -254,8 +258,25 @@ modélisé dans le test (`limiteTenue()`), jamais dans le moteur ni dans le mock ## 7. Ce que ce lot ne fait pas - La recharge de la batterie sur le réseau ; toute optimisation par le prix (offre Pro). -- L'étalement d'une échéance sur les heures creuses (§2). +- L'étalement d'une échéance sur les heures creuses (§2) — lot à part (§8, D3). - Une action atomique « limite de décharge » dans le plugin (`planBatteryMode(NO_DISCHARGE)` - existe et n'est pas exposée), et un état au nom moins trompeur que `enableDischarging` — à - proposer au dépôt du plugin. + existe et n'est pas exposée), et un état au nom moins trompeur que `enableDischarging` — + décidés, faits dans le dépôt du plugin (§8, D6). - La batterie comme charge rangée du waterfall (lot 3f, `spec_loadmodel.md` §14). + +## 8. Décisions de Patrick (2026-10-24) + +Les questions ouvertes du lot e1 sont tranchées. Chacune confirme le comportement décrit plus haut : +aucune ne change le code. + +| # | Question | Décision du 2026-10-24 | +|---|---|---| +| D1 | Bloquer **toute** la décharge, maison comprise, pendant la charge de la voiture en heures creuses (§3.2) ? | **Oui.** C'est le geste voulu ; aucune régulation de `OutWRte` sur la consommation de la maison. | +| D2 | Refuser `EV_GRID_START` hors heures creuses pour les trois modes concernés (§2) ? | **Confirmé.** En mode Éco, la borne attend les heures creuses ; seul le mode boost (`ChargingModeNormal`, piloté par l'utilisateur) charge tout de suite. | +| D3 | Étaler une échéance sur les heures creuses (§2) ? | **Plus tard, lot à part.** Il demande une énergie restante mesurée (LM-1009). | +| D4 | Une borne en `ChargingModeNormal` qui charge en heures creuses déclenche-t-elle le blocage (§3.2) ? | **Oui, c'est le but** : la mesure porte sur toutes les bornes, mode compris. | +| D5 | Seuil de « la voiture charge réellement » : 230 W sur la somme des bornes (§3.2) ? | **Accepté.** | +| D6 | Plugin SunSpec : une action « limite de décharge » en une séquence (`planBatteryMode(NO_DISCHARGE)`), et `enableDischarging` renommé ou redécrit (§3.1, §7) ? | **Oui aux deux**, dans le dépôt du plugin, à la charge de l'orchestrateur. Le garde ne change pas : il reste compatible avec les deux actions actuelles (`dischargingRate` puis `enableDischarging`). | + +La mesure du minuteur de retour sur un vrai GEN24 (hypothèse AC-10 du plugin, §3.4) reste +ouverte : elle relève du plugin et d'un essai sur matériel réel. From 1291f72d1f2ed348a6c1fa02642b8666176f4a86 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sat, 24 Oct 2026 22:22:12 +0200 Subject: [PATCH 09/26] =?UTF-8?q?Heures=20creuses=20:=20la=20borne=20charg?= =?UTF-8?q?e=20=C3=A0=20la=20pleine=20puissance=20permise=20(lot=20e2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Décision de Patrick du 2026-10-24. Dans les trois modes qui reportent en heures creuses (Éco avec minimum, Éco min + échéance, Éco avec échéance) : - le minimum servi au réseau en heures creuses est le PLAFOND de cycle de la borne (declared.maxPowerW), plus son plancher. Il porte le courant maximal, les phases et la limite de phase, et ne descend jamais sous le plancher : la stratégie rabaisse, L4 coupe. 3 680 W en monophasé 16 A, 11 040 W en triphasé 16 A ; - les phases se choisissent sur la pleine puissance, plus sur « courant + surplus » (EnergyArbitrator::enHeuresCreuses(), lu par EvAdapter). La nuit, une batterie qui couvre la maison et la voiture met le compteur à 0, et la règle de bascule gardait le monophasé : 3,7 kW au lieu de 11 ; - la passe éco n'oppose plus à cette borne la marge d'UNE phase de la maison (LM-1500-h, déjà appliquée en tête de passe et en confort) : son plafond porte déjà l'autorisation, sur ses phases. Restreint aux heures creuses ; - motif EV_OFF_PEAK_FULL_POWER {gridW, maxW, phases, until, endsAt, o:boundBy phaseLimit|floor}, rendu en français, à la place d'ECO_FLOOR_GRID sur la borne. Hors heures creuses et sans heure creuse dans le tarif : rien ne change. Le blocage de la décharge se lève toujours sur la puissance MESURÉE (230 W) : une borne commandée à 16 A dont la voiture ne prend plus rien le lève. Tests : testOffPeakChargesAtFullPower, testOffPeakFullPowerSwitchesToThreePhases, testOffPeakFullPowerYieldsToThePhaseLimit. Les tests du lot e1 en heures creuses attendent le plafond, et leur compteur inclut ce que la borne tire désormais (TirageHcW) — sinon il fabriquait un surplus. Co-Authored-By: Claude Opus 5.5 --- energyplugin/etm/adapters/evadapter.cpp | 59 +++- energyplugin/etm/adapters/evadapter.h | 9 +- energyplugin/etm/energyarbitrator.cpp | 11 +- energyplugin/etm/energyarbitrator.h | 13 + .../etm/scheduler/rulebasedscheduler.cpp | 44 ++- energyplugin/etm/types/decisionreason.cpp | 25 +- energyplugin/etm/types/decisionreason.h | 18 ++ energyplugin/etm/types/loaddescriptor.h | 5 +- tests/auto/simulation/simulation.cpp | 282 ++++++++++++++++-- tests/auto/simulation/simulation.h | 16 +- 10 files changed, 424 insertions(+), 58 deletions(-) diff --git a/energyplugin/etm/adapters/evadapter.cpp b/energyplugin/etm/adapters/evadapter.cpp index 1a4409a..eb65db2 100644 --- a/energyplugin/etm/adapters/evadapter.cpp +++ b/energyplugin/etm/adapters/evadapter.cpp @@ -51,7 +51,8 @@ LoadTelemetry EvAdapter::telemetry() const LoadContext EvAdapter::toLoadContext(const QDateTime &now) const { - Q_UNUSED(now) // Les verrous EV (enclenchement, changement de courant) vivent en aval. + // Les verrous EV (enclenchement, changement de courant) vivent en aval : `now` ne sert qu'à + // savoir si l'instant tombe en heures creuses (lot e2). LoadContext ctx; ctx.id = m_charger->thing()->id().toString(); ctx.adapter = QStringLiteral("evcharger"); @@ -74,7 +75,29 @@ LoadContext EvAdapter::toLoadContext(const QDateTime &now) const // avec CETTE voiture » est tranchée une fois par le manager, et evTargetPhases() la lit. // Le prédicat local n'en voyait qu'une moitié (l'état existe-t-il) et retombait sur le // nombre de phases COURANT, sans la voiture. - const double courantReferenceA = m_parent->evReferenceCurrentA(m_charger); + const ChargingInfo::ChargingMode mode = m_parent->chargingInfo(m_charger->id()).chargingMode(); + const bool reportable = mode == ChargingInfo::ChargingModeEcoWithMinCurrent + || mode == ChargingInfo::ChargingModeEcoMinWithTargetTime + || mode == ChargingInfo::ChargingModeEcoWithTargetTime; + + // Lot e2 — EN HEURES CREUSES, la référence est la PLEINE PUISSANCE de la borne, et non plus + // « courant + surplus ». La règle de bascule reste celle du manager ; c'est ce qu'on lui + // demande qui change. + // « Courant + surplus » répond à « combien le soleil donne-t-il ? », et la nuit la réponse + // décide mal : la maison importe, la référence est négative et la règle vise le triphasé — + // sauf quand une batterie couvre la maison et la voiture. Le compteur lit alors ≈ 0, la + // référence vaut le courant que la borne tire déjà (6 A en monophasé), et la règle la GARDE + // en monophasé : 3,7 kW au plus pour une borne qui en ferait 11. En heures creuses, la + // question n'est plus le surplus mais ce que la borne peut prendre. + // + // La limite de phase n'entre pas dans le choix, et c'est sans perte : par phase, elle borne + // de la même façon une monophasée et une triphasée, donc trois phases donnent toujours au + // moins autant (marge × 3 contre marge × 1). Sous le plancher, aucune des deux ne tient, et + // c'est la protection de surcharge qui tranche. + const bool pleinePuissanceHc = reportable && m_parent->enHeuresCreuses(now); + const double courantReferenceA = pleinePuissanceHc + ? m_charger->maxChargingCurrentMaxValue() * 3.0 + : m_parent->evReferenceCurrentA(m_charger); m_targetPhases = m_parent->evTargetPhases(m_charger, courantReferenceA); if (m_targetPhases == 0) m_targetPhases = 1; @@ -93,25 +116,11 @@ LoadContext EvAdapter::toLoadContext(const QDateTime &now) const // La valeur n'est PAS stockée : elle se dérive du matériel à chaque cycle, comme le // plancher juste au-dessus. L'écrire en configuration à l'appairage créerait une copie qui // se périme au premier recâblage — `phaseCount` change, la copie reste fausse en silence. - const ChargingInfo::ChargingMode mode = m_parent->chargingInfo(m_charger->id()).chargingMode(); if (mode == ChargingInfo::ChargingModeEcoWithMinCurrent || mode == ChargingInfo::ChargingModeEcoMinWithTargetTime) { 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 @@ -133,6 +142,24 @@ LoadContext EvAdapter::toLoadContext(const QDateTime &now) const qMax(plafondPhaseW, ctx.declared.minPowerW)); } + // --- Lots e1 et e2 : 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. + // + // Lot e2 (décision du 2026-10-24) — le minimum d'heures creuses est le PLAFOND du cycle, et + // non plus le plancher. Au plancher (6 A, 1,4 kW en monophasé), une session de 10 à 25 kWh + // n'était pas finie au départ, et la décharge de la batterie restait bloquée toute la nuit. + // À pleine puissance, la session finit tôt et la batterie est rendue pour le reste de la nuit. + // Le plafond est pris APRÈS l'écrêtage ci-dessus : il porte le courant maximal, les phases et + // la limite de phase, et ne descend jamais sous le plancher — la stratégie rabaisse, L4 coupe. + if (reportable) { + ctx.needs.offPeakShift = true; + ctx.needs.offPeakMinPowerW = ctx.declared.maxPowerW; + } + ctx.telemetry.currentPowerW = m_charger->currentPower(); ctx.telemetry.available = m_charger->available(); ctx.telemetry.pluggedIn = m_charger->pluggedIn(); diff --git a/energyplugin/etm/adapters/evadapter.h b/energyplugin/etm/adapters/evadapter.h index c14501e..e39d471 100644 --- a/energyplugin/etm/adapters/evadapter.h +++ b/energyplugin/etm/adapters/evadapter.h @@ -46,9 +46,14 @@ public: /*! * \brief Construit l'entrée loads[] §5 du SurplusContext. - * \param now Temps de cycle (\c ctx.timestamp). Inutilisé ici : l'EV n'a pas de verrou - * de palier (hors waterfall ECS/SG-Ready). Présent pour l'uniformité de \c ILoadAdapter. + * \param now Temps de cycle (\c ctx.timestamp). L'EV n'a pas de verrou de palier (hors + * waterfall ECS/SG-Ready) ; l'instant ne sert qu'à savoir s'il tombe en heures creuses + * (lot e2) — la borne y vise sa pleine puissance, et ses phases s'y choisissent. * \return LoadContext incluant declared, limits, needs et télémétrie EV. + * \note **Lot e2** — dans les trois modes qui reportent en heures creuses, le minimum + * d'heures creuses (\c needs.offPeakMinPowerW) est le PLAFOND du cycle + * (\c declared.maxPowerW : courant maximal, phases, limite de phase), jamais sous le + * plancher ; et le nombre de phases se choisit sur ce plafond, plus sur le surplus. */ LoadContext toLoadContext(const QDateTime &now) const override; diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 289c395..52bfe8c 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -1509,6 +1509,15 @@ void EnergyArbitrator::qualifierRefus(LoadContext &lc, const QDateTime &now) con lc.telemetry.commandRefused = ecoule > a->commandSettleS(montee); } +bool EnergyArbitrator::enHeuresCreuses(const QDateTime &now) const +{ + // Une seule lecture, partagée par le contexte et par la borne : deux copies divergeraient au + // premier changement de règle, et la borne viserait sa pleine puissance hors des heures que + // le scheduler sert. + return !m_tariff.zonesCreuses().isEmpty() + && m_tariff.enHeuresCreuses(now, QTimeZone::systemTimeZone()); +} + SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const { SurplusContext ctx; @@ -1540,7 +1549,7 @@ SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const if (!m_tariff.zonesCreuses().isEmpty()) { const QTimeZone fuseau = QTimeZone::systemTimeZone(); ctx.offPeak.configured = true; - ctx.offPeak.active = m_tariff.enHeuresCreuses(now, fuseau); + ctx.offPeak.active = enHeuresCreuses(now); 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")); diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index a89eff8..10ef4db 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -129,6 +129,19 @@ public: */ const TariffConfig &tariff() const { return m_tariff; } + /*! + * \brief L'instant \p now tombe-t-il en heures creuses du tarif saisi ? (lot e2) + * + * Même lecture que \c SurplusContext::offPeak (\c buildContext()) : zones au prix le plus + * bas, heure murale du fuseau de la box relu à l'appel. Elle existe pour la borne, qui doit + * le savoir AVANT l'allocation : en heures creuses elle vise sa pleine puissance, donc ses + * phases se choisissent sur son plafond et non sur le surplus (\c EvAdapter::toLoadContext()). + * + * \param now Temps de cycle (injectable). \return Faux sans heure creuse (aucun tarif, tarif + * unique, prix tous égaux) — rien du lot ne s'exécute alors. + */ + bool enHeuresCreuses(const QDateTime &now) const; + /*! * \brief Remplace le tarif saisi, à chaud, et le persiste. Un tarif vide l'efface. * \param t Tarif DÉJÀ validé (\c TariffConfig::fromVariant) — il n'en existe pas d'autre. diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index 4aad464..bf83b2f 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -457,8 +457,24 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) // un droit de faire disjoncter : si l'autorisation est épuisée, on n'achète pas, et // c'est le délestage qui rattrapera ce qui a déjà été engagé. double achatW = cible.watts; - const bool bridePlafond = margeRestante.borne && achatW > qMax(0.0, margeRestante.watts); - if (margeRestante.borne) + + // Lot e2 — LA BORNE À PLEINE PUISSANCE EN HEURES CREUSES. Son minimum y est son plafond de + // cycle, et ce plafond porte DÉJÀ l'autorisation de soutirage : `evPhaseAllowanceW()` la + // résout sur les phases qu'elle occupe et la multiplie par leur nombre. \rule{LM-1500-h} + // s'applique donc ici comme en tête de passe et en passe confort : lui opposer en plus + // `margeRestante`, la marge d'UNE phase, diviserait une borne triphasée par trois — 7 A + // par phase au lieu de 16 quand la maison tire 4 A sur une limite de 25 A, la nuit même + // où on veut qu'elle finisse tôt. + // + // Restreint à ce cas, et c'est délibéré : sans heures creuses, le plancher éco d'une borne + // reste écrêté comme avant (engagement « sans tarif, rien ne change »). Cet écart-là reste + // à trancher (DESIGN_HC_BATTERIE §8.1) ; il n'est pas corrigé en passant. + const bool pleinePuissanceHc = ctx.offPeak.active && lc.needs.offPeakShift + && lc.adapter == QStringLiteral("evcharger") + && cible.borneParLeMinimum; + const bool bridePlafond = !pleinePuissanceHc && margeRestante.borne + && achatW > qMax(0.0, margeRestante.watts); + if (!pleinePuissanceHc && margeRestante.borne) achatW = qBound(0.0, achatW, qMax(0.0, margeRestante.watts)); // La CAUSE se note à chaque cycle, servie ou non. À la clôture, c'est la dernière qui @@ -500,6 +516,30 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) } eco.reason = { DecisionCode::EcoFloorGrid, motif }; + // Lot e2 — le motif dit « pleine puissance », la puissance RETENUE, et ce qui la bride. + // ECO_FLOOR_GRID parlerait d'une obligation et d'un reste à livrer qu'une borne n'a pas ; + // et une borne servie sous sa pleine puissance se lirait comme une borne qui ne suit pas. + if (pleinePuissanceHc) { + // La pleine puissance du MATÉRIEL, aux phases visées : ce que la borne prendrait sans + // limite de phase. L'écart au plafond du cycle est ce que la protection a retiré. + const double materielW = lc.declared.maxA * 230.0 * qMax(1, lc.declared.phases); + QVariantMap pp{{"gridW", qRound(achatW)}, + {"maxW", qRound(materielW)}, + {"phases", qMax(1, lc.declared.phases)}, + {"until", ctx.offPeak.nextChangeWall}, + {"endsAt", ctx.offPeak.nextChange.toUTC().toString(Qt::ISODate)}}; + if (achatW < materielW - 0.5) { + // La limite de phase rabaisse la borne. Jusqu'au plancher, c'est la stratégie qui + // la tient (`phaseLimit`) ; au plancher, la stratégie ne descend plus — couper est + // le geste de L4, et le motif le dit (`floor`). + const bool auPlancher = lc.declared.minPowerW > 0 + && achatW <= lc.declared.minPowerW + 0.5; + pp.insert(QStringLiteral("boundBy"), auPlancher ? QStringLiteral("floor") + : QStringLiteral("phaseLimit")); + } + eco.reason = { DecisionCode::EvOffPeakFullPower, pp }; + } + // SOUS LE PREMIER PALIER, rien n'est acheté. Une charge à paliers ne délivre que ses // paliers, et son mécanisme arrondit vers le bas : 83 W devant un premier palier de // 500 W laissent le relais ouvert, et « 83 W achetés » se lisait sur un ballon froid. diff --git a/energyplugin/etm/types/decisionreason.cpp b/energyplugin/etm/types/decisionreason.cpp index ccf8bcf..6e9f90d 100644 --- a/energyplugin/etm/types/decisionreason.cpp +++ b/energyplugin/etm/types/decisionreason.cpp @@ -333,6 +333,27 @@ QString renderFr(const DecisionReason &reason, const QString &label) 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")); + // Lot e2 — la puissance RETENUE d'abord ; et si elle n'est pas la pleine puissance, QUI l'a + // retirée. Sans la seconde phrase, une borne bridée se lirait comme une borne qui ne suit pas. + if (c == QLatin1String(DecisionCode::EvOffPeakFullPower)) { + const QString jusqua = p.value(QStringLiteral("until")).toString(); + const QString bride = p.value(QStringLiteral("boundBy")).toString(); + if (bride == QLatin1String("floor")) + return QStringLiteral("%1 charge en heures creuses, tenue à son plancher (%2 W) par la " + "protection de surcharge du branchement : la limite de phase " + "est atteinte, la protection décide de la suite (pleine " + "puissance : %3 W, jusqu'à %4)") + .arg(label).arg(w("gridW")).arg(w("maxW")).arg(jusqua); + if (bride == QLatin1String("phaseLimit")) + return QStringLiteral("%1 charge en heures creuses à %2 W, bridée par la protection de " + "surcharge du branchement : la limite de phase ne laisse pas " + "plus (pleine puissance : %3 W, jusqu'à %4)") + .arg(label).arg(w("gridW")).arg(w("maxW")).arg(jusqua); + return QStringLiteral("%1 charge à pleine puissance en heures creuses : %2 W achetés au " + "réseau (%3 phase%4, jusqu'à %5)") + .arg(label).arg(w("gridW")).arg(w("phases")) + .arg(w("phases") > 1 ? QStringLiteral("s") : QString()).arg(jusqua); + } if (c == QLatin1String(DecisionCode::StorageDischargeBlocked)) return QStringLiteral("Décharge de la batterie bloquée : la voiture charge en heures " "creuses (%1 W, jusqu'à %2)") @@ -418,8 +439,8 @@ QStringList DecisionCode::knownCodes() ForecastDateUnreadable, TariffRefused, LoadConfigRefused, LockMinStateHold, SgForced, SgRecommended, SgNormal, OptimizerDecision, PriceSlot, - EvWaitingOffPeak, StorageDischargeBlocked, StorageAutomatic, StorageNotProtected, - DegradedL2, SafeStateRelay, SafeStateSetpoint, SafeStateSgReady}; + EvWaitingOffPeak, EvOffPeakFullPower, StorageDischargeBlocked, StorageAutomatic, + StorageNotProtected, DegradedL2, SafeStateRelay, SafeStateSetpoint, SafeStateSgReady}; } QStringList DecisionCode::optimizerEmittableCodes() diff --git a/energyplugin/etm/types/decisionreason.h b/energyplugin/etm/types/decisionreason.h index 3ac448e..9a7de1c 100644 --- a/energyplugin/etm/types/decisionreason.h +++ b/energyplugin/etm/types/decisionreason.h @@ -446,6 +446,24 @@ constexpr const char *PriceSlot = "PRICE_SLOT"; * la borne de charger même en heures creuses. */ constexpr const char *EvWaitingOffPeak = "EV_WAITING_OFF_PEAK"; +/*! + * \brief params: \c gridW, \c maxW, \c phases, \c until, \c endsAt, \c o:boundBy — la borne + * charge à PLEINE PUISSANCE en heures creuses (lot e2). + * + * Dans les mêmes trois modes que \c EV_WAITING_OFF_PEAK, le minimum servi au réseau en heures + * creuses est le plafond du cycle de la borne. \c gridW est la puissance RETENUE, achetée ; + * \c maxW la pleine puissance du matériel aux \c phases visées (courant maximal × 230 V × + * phases). \c until (\c "HH:MM", heure murale) et \c endsAt (instant) disent quand les heures + * creuses finissent. + * + * \c boundBy n'est présent que si \c gridW < \c maxW : la protection de surcharge du + * branchement a rabaissé la borne. \c phaseLimit — à ce que la limite de phase laisse ; + * \c floor — au plancher de la borne, que la stratégie ne franchit jamais : couper revient + * alors à la protection de surcharge (L4), qui garde le dernier mot. + * + * \note N'est émis QUE si le tarif saisi a des heures creuses. Aucun optimiseur ne l'émet. + */ +constexpr const char *EvOffPeakFullPower = "EV_OFF_PEAK_FULL_POWER"; /*! * \brief params: \c evPowerW, \c until, \c endsAt — la DÉCHARGE de la batterie est BLOQUÉE. * diff --git a/energyplugin/etm/types/loaddescriptor.h b/energyplugin/etm/types/loaddescriptor.h index e853320..50f040c 100644 --- a/energyplugin/etm/types/loaddescriptor.h +++ b/energyplugin/etm/types/loaddescriptor.h @@ -121,8 +121,9 @@ struct LoadNeeds { * 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. + //! Minimum servi en heures creuses quand \c offPeakShift est vrai (W), dérivé comme + //! \c minPowerWAlways. 0 = aucun. Pour une borne, c'est son PLAFOND de cycle depuis le lot e2 + //! (le plancher au lot e1) : elle y charge à pleine puissance, limite de phase comprise. double offPeakMinPowerW = 0; }; diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index da4a88f..13cccb6 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -10695,10 +10695,15 @@ QVariantMap batteriePilotable(uint revertS) QVariantMap{{"paramTypeId", QStringLiteral("{13b73424-5076-46b9-8eb8-38ee0078236e}")}, {"value", revertS}}}}}; } +//! Ce que la borne monophasée 16 A du scénario tire en heures creuses : son PLAFOND depuis le +//! lot e2 (son plancher, 1 380 W, au lot e1). Le compteur des tests l'inclut — sans quoi il +//! fabriquerait un surplus que la borne, recréditée de sa propre consommation, se servirait. +constexpr int TirageHcW = 3680; + } // namespace Simulation::ScenarioHc Simulation::preparerScenarioHc(const QString &nom, BatterieHc batterie, - const QVariantList &tarif) + const QVariantList &tarif, BorneHc typeBorne) { ScenarioHc s; const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-hc-" + nom + ".json"; @@ -10719,7 +10724,9 @@ Simulation::ScenarioHc Simulation::preparerScenarioHc(const QString &nom, Batter s.meter = tm->findConfiguredThing(meterId); s.meter->setStateValue("connected", true); - s.borneId = addCharger("A", 16, 26802); + s.borneId = typeBorne == BorneHc::Monophasee ? addCharger("A", 16, 26802) + : typeBorne == BorneHc::Triphasee ? addCharger("ABC", 16, 26802) + : addChargerWithPhaseCountSwitching("ABC", 16, 26802); const QUuid voitureId = addCar(26803); s.borne = tm->findConfiguredThing(s.borneId); Thing *voiture = tm->findConfiguredThing(voitureId); @@ -10728,7 +10735,7 @@ Simulation::ScenarioHc Simulation::preparerScenarioHc(const QString &nom, Batter voiture->setStateValue("batteryLevel", 30); voiture->setStateValue("capacity", 60); voiture->setStateValue("minChargingCurrent", 6); - voiture->setStateValue("phaseCount", 1); + voiture->setStateValue("phaseCount", typeBorne == BorneHc::Monophasee ? 1 : 3); QVariantMap info{{"evChargerId", s.borneId}, {"assignedCarId", voitureId}, {"chargingMode", "ChargingModeEcoWithMinCurrent"}, {"targetPercentage", 100}}; injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}})); @@ -10834,21 +10841,22 @@ void Simulation::testOffPeakDemoDay() 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). + // 22:00 — LA REPRISE. Le minimum est servi au réseau (passe éco) — à PLEINE PUISSANCE + // depuis le lot e2 : 16 A, le plafond de la borne, et non plus son plancher de 6 A. 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(decision(snap).value("code").toString(), QString(DecisionCode::EvOffPeakFullPower)); + QCOMPARE(qRound(telemetrieParId(snap).value(borne).value("allocatedW").toDouble()), 3680); QCOMPARE(s.borne->stateValue("power").toBool(), true); - QCOMPARE(s.borne->stateValue("maxChargingCurrent").toInt(), 6); + QCOMPARE(s.borne->stateValue("maxChargingCurrent").toInt(), 16); // 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. + // 22:01 → 23:30 — la voiture tire : la décharge est BLOQUÉE, et TENUE. QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(20, 1)); - snap = cycleHc(s, a, 1380 + 300); + snap = cycleHc(s, a, TirageHcW + 300); QCOMPARE(stockage(snap).value("decision").toMap().value("code").toString(), QString(DecisionCode::StorageDischargeBlocked)); QCOMPARE(stockage(snap).value("dischargeBlocked").toBool(), true); @@ -10866,7 +10874,7 @@ void Simulation::testOffPeakDemoDay() 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); + snap = cycleHc(s, a.addSecs(60), TirageHcW + 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); @@ -10888,7 +10896,7 @@ void Simulation::testOffPeakDemoDay() // 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); + snap = cycleHc(s, a, TirageHcW + 300); QVERIFY2(limiteTenue(*s.ecrites, a.addSecs(59), 300), qUtf8Printable(QStringLiteral("limite expirée à %1").arg(a.toString(Qt::ISODate)))); } @@ -10952,19 +10960,20 @@ void Simulation::testOffPeakEndsAtSixAndOnDstDays() }; 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. + // Une heure en heures creuses : la borne charge à pleine puissance, 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); + snap = cycleHc(s, six.addSecs(-60 * m), TirageHcW + 300); QVERIFY2(telemetrieParId(snap).value(borne).value("decision").toMap().value("code").toString() - == QString(DecisionCode::EcoFloorGrid), j.quoi); + == QString(DecisionCode::EvOffPeakFullPower), 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); + snap = cycleHc(s, six, TirageHcW + 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); @@ -11051,7 +11060,7 @@ void Simulation::testABatteryWithoutControlIsReadAsBefore() s.borne->setStateValue("pluggedIn", true); QVariantMap snap; for (int m = 0; m <= 2; ++m) - snap = cycleHc(s, hc.addSecs(60 * m), 1380 + 300); + snap = cycleHc(s, hc.addSecs(60 * m), TirageHcW + 300); const QVariantMap st = snap.value("storage").toMap(); QVERIFY2(!st.value("dischargeBlocked").toBool(), c.quoi); QVERIFY2(st.value("decision").toMap().value("code").toString() @@ -11063,7 +11072,7 @@ void Simulation::testABatteryWithoutControlIsReadAsBefore() 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); + .value("code").toString() == QString(DecisionCode::EvOffPeakFullPower), c.quoi); } // BATTERIE ABSENTE : la question ne se pose pas — aucun bloc `storage`, et la borne attend @@ -11077,7 +11086,7 @@ void Simulation::testABatteryWithoutControlIsReadAsBefore() 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)); + .value("code").toString(), QString(DecisionCode::EvOffPeakFullPower)); QVERIFY(!snap.contains("storage")); #endif } @@ -11109,15 +11118,15 @@ void Simulation::testBlockedDischargeShowsAtTheMeterAndL4Decides() 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); + snap = cycleHc(s, a.addSecs(60 * m), TirageHcW + 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)); + snap = cycleHc(s, a.addSecs(180), TirageHcW + 1800); + QCOMPARE(qRound(snap.value("budget").toMap().value("surplusW").toDouble()), -(TirageHcW + 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 @@ -11148,7 +11157,7 @@ void Simulation::testAMeterSilenceHandsTheBatteryBack() 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); + snap = cycleHc(s, a.addSecs(60 * m), TirageHcW + 300); QVERIFY(snap.value("storage").toMap().value("dischargeBlocked").toBool()); QCOMPARE(s.batterie->stateValue("enableDischarging").toBool(), true); @@ -11212,7 +11221,7 @@ void Simulation::testRefusedWritesAreSpacedAndNeverDelayTheRelease() const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(21, 0)); // 23:00 à Paris QVariantMap snap; for (int m = 0; m <= 50; ++m) - snap = cycleHc(s, t0.addSecs(60 * m), 1380 + 300); + snap = cycleHc(s, t0.addSecs(60 * m), TirageHcW + 300); const QList p = poses(); QVERIFY(!p.isEmpty()); const QDateTime premiere = p.first(); @@ -11251,7 +11260,7 @@ void Simulation::testRefusedWritesAreSpacedAndNeverDelayTheRelease() QDateTime a = t0.addSecs(60 * 50); while (a < reprise) { a = a.addSecs(60); - snap = cycleHc(s, a, 1380 + 300); + snap = cycleHc(s, a, TirageHcW + 300); } QCOMPARE(poses().size(), 8); QCOMPARE(poses().last(), reprise); @@ -11259,7 +11268,7 @@ void Simulation::testRefusedWritesAreSpacedAndNeverDelayTheRelease() QVERIFY(tentatives.last().acceptee); QCOMPARE(journal.compte(QtInfoMsg, QStringLiteral("ACCEPTÉE après 7 refus")), 1); a = a.addSecs(60); - snap = cycleHc(s, a, 1380 + 300); + snap = cycleHc(s, a, TirageHcW + 300); QCOMPARE(snap.value("storage").toMap().value("dischargeBlocked").toBool(), true); bat = batterie(snap); QCOMPARE(bat.value("held").toBool(), true); @@ -11269,7 +11278,7 @@ void Simulation::testRefusedWritesAreSpacedAndNeverDelayTheRelease() const int poseesAvant = s.ecrites->size(); for (int m = 0; m < 20; ++m) { a = a.addSecs(60); - snap = cycleHc(s, a, 1380 + 300); + snap = cycleHc(s, a, TirageHcW + 300); QVERIFY2(limiteTenue(*s.ecrites, a.addSecs(59), 300), qUtf8Printable(QStringLiteral("limite expirée à %1").arg(a.toString(Qt::ISODate)))); } @@ -11281,7 +11290,7 @@ void Simulation::testRefusedWritesAreSpacedAndNeverDelayTheRelease() for (int garde = 0; batterie(snap).value("refusedWrites").toInt() < 3; ++garde) { QVERIFY2(garde < 30, "la série de refus ne s'est pas ouverte"); a = a.addSecs(60); - snap = cycleHc(s, a, 1380 + 300); + snap = cycleHc(s, a, TirageHcW + 300); } QCOMPARE(journal.compte(QtWarningMsg, serie), 2); const QDateTime prochain = QDateTime::fromString(batterie(snap).value("nextAttemptAt").toString(), Qt::ISODate); @@ -11304,16 +11313,227 @@ void Simulation::testRefusedWritesAreSpacedAndNeverDelayTheRelease() // 4. La levée a CLOS la série : la voiture tire de nouveau, la pose part tout de suite, et // son premier refus est signalé comme celui d'une nouvelle série. - s.borne->setStateValue("currentPower", 1380); + s.borne->setStateValue("currentPower", TirageHcW); s.borne->setStateValue("charging", true); const int avantRepose = poses().size(); a = a.addSecs(60); - snap = cycleHc(s, a, 1380 + 300); + snap = cycleHc(s, a, TirageHcW + 300); QCOMPARE(poses().size(), avantRepose + 1); QCOMPARE(poses().last(), a); a = a.addSecs(60); - snap = cycleHc(s, a, 1380 + 300); + snap = cycleHc(s, a, TirageHcW + 300); QCOMPARE(journal.compte(QtWarningMsg, serie), 3); QCOMPARE(batterie(snap).value("refusedWrites").toInt(), 1); #endif } + +// ============================================================================================ +// Lot e2 (2026-10-24) — en heures creuses, la borne charge à la pleine puissance permise +// ============================================================================================ + +void Simulation::testOffPeakChargesAtFullPower() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // DÉCISION DU 2026-10-24 — en heures creuses, la borne reçoit son PLAFOND de cycle, plus son + // plancher. Au plancher (6 A), une session de 10 à 25 kWh n'était pas finie au départ et la + // batterie restait bloquée toute la nuit. Monophasée 16 A : 3 680 W ; triphasée 16 A : + // 11 040 W. Hors heures creuses, rien ne change. Quand la voiture ne prend plus rien, la + // batterie est rendue — même si la borne reste commandée à 16 A. + FuseauDeTest fuseau("Europe/Paris"); + struct Cas { const char *quoi; BorneHc borne; int phases; int pleineW; }; + const QList cas = { + {"monophasée 16 A", BorneHc::Monophasee, 1, 3680}, + {"triphasée 16 A", BorneHc::Triphasee, 3, 11040}, + }; + for (const Cas &c : cas) { + ScenarioHc s = preparerScenarioHc(QStringLiteral("pleine-%1").arg(c.phases), + BatterieHc::Pilotable, hphcT4(), c.borne); + QVERIFY2(s.arb && s.borne && s.batterie, c.quoi); + const QString borne = s.borneId.toString(); + const auto charge = [&](const QVariantMap &snap) { return telemetrieParId(snap).value(borne); }; + s.borne->setStateValue("pluggedIn", true); + + // 18:00 — HORS heures creuses : INCHANGÉ. Surplus seulement, la borne attend. + QVariantMap snap; + for (int m = 0; m <= 2; ++m) + snap = cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(16, m)), 600); + QVERIFY2(charge(snap).value("decision").toMap().value("code").toString() + == QString(DecisionCode::EvWaitingOffPeak), c.quoi); + QVERIFY2(qFuzzyIsNull(charge(snap).value("allocatedW").toDouble()), c.quoi); + QVERIFY2(!s.borne->stateValue("power").toBool(), c.quoi); + + // 22:00 — EN heures creuses : le PLAFOND, acheté au réseau, et le motif le dit. + snap = cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(20, 0)), 600); + const QVariantMap d = charge(snap).value("decision").toMap(); + QVERIFY2(d.value("code").toString() == QString(DecisionCode::EvOffPeakFullPower), + qUtf8Printable(QJsonDocument::fromVariant(charge(snap)).toJson(QJsonDocument::Compact))); + QCOMPARE(qRound(charge(snap).value("allocatedW").toDouble()), c.pleineW); + QCOMPARE(qRound(snap.value("draw").toMap().value("committedW").toDouble()), c.pleineW); + QVERIFY2(s.borne->stateValue("power").toBool(), c.quoi); + QCOMPARE(s.borne->stateValue("maxChargingCurrent").toInt(), 16); + const QVariantMap p = d.value("params").toMap(); + QCOMPARE(p.value("gridW").toInt(), c.pleineW); + QCOMPARE(p.value("maxW").toInt(), c.pleineW); + QCOMPARE(p.value("phases").toInt(), c.phases); + QCOMPARE(p.value("until").toString(), QStringLiteral("06:00")); + QCOMPARE(p.value("endsAt").toString(), QStringLiteral("2026-06-09T04:00:00Z")); + QVERIFY2(!p.contains("boundBy"), "pleine puissance : rien ne la bride, et aucune clé ne le prétend"); + QCOMPARE(renderFr(DecisionReason(DecisionCode::EvOffPeakFullPower, p), QStringLiteral("Borne")), + QStringLiteral("Borne charge à pleine puissance en heures creuses : %1 W achetés au " + "réseau (%2, jusqu'à 06:00)") + .arg(c.pleineW).arg(c.phases == 1 ? QStringLiteral("1 phase") : QStringLiteral("3 phases"))); + + // 22:01 — la voiture tire sa pleine puissance : la décharge est bloquée. + s.borne->setStateValue("currentPower", c.pleineW); + snap = cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(20, 1)), c.pleineW + 300); + QVERIFY2(snap.value("storage").toMap().value("dischargeBlocked").toBool(), c.quoi); + QCOMPARE(snap.value("storage").toMap().value("decision").toMap().value("params").toMap() + .value("evPowerW").toInt(), c.pleineW); + + // 23:00 — FIN DE SESSION : la voiture est pleine. La borne reste commandée à 16 A + // (elle est en heures creuses, son minimum tient), mais elle ne tire plus que 100 W, + // sous le seuil de 230 W : la batterie est RENDUE au cycle même. + s.borne->setStateValue("currentPower", 100); + s.borne->setStateValue("charging", false); + snap = cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(21, 0)), 400); + QCOMPARE(s.borne->stateValue("maxChargingCurrent").toInt(), 16); + QVERIFY2(s.borne->stateValue("power").toBool(), "commandée à 16 A, et c'est la voiture qui ne prend rien"); + QVERIFY2(!snap.value("storage").toMap().value("dischargeBlocked").toBool(), c.quoi); + QCOMPARE(snap.value("storage").toMap().value("decision").toMap().value("params").toMap() + .value("cause").toString(), QStringLiteral("evNotCharging")); + QCOMPARE(s.batterie->stateValue("enableDischarging").toBool(), false); + QCOMPARE(s.batterie->stateValue("dischargingRate").toInt(), 100); + } +#endif +} + +void Simulation::testOffPeakFullPowerSwitchesToThreePhases() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // LE CHOIX DES PHASES, là où il bridait. Borne triphasée à bascule, voiture triphasée, la + // borne tire déjà 6 A sur UNE phase et une batterie NON pilotable couvre la maison : le + // compteur lit 0. La référence « courant + surplus » vaut alors 6 A, et la règle de bascule + // du manager garde le monophasé — 3 680 W au plus, toute la nuit, tant que la batterie + // couvre. En heures creuses la référence est la pleine puissance : trois phases, 11 040 W. + // Hors heures creuses, la règle d'avant s'applique toujours. + FuseauDeTest fuseau("Europe/Paris"); + ScenarioHc s = preparerScenarioHc(QStringLiteral("bascule"), BatterieHc::SansEtats, hphcT4(), + BorneHc::Bascule); + QVERIFY(s.arb && s.borne && s.batterie); + const QString borne = s.borneId.toString(); + s.borne->setStateValue("pluggedIn", true); + + const auto tireEnMono = [&]() { + s.borne->setStateValue("power", true); + s.borne->setStateValue("charging", true); + s.borne->setStateValue("maxChargingCurrent", 6); + s.borne->setStateValue("currentPower", 1380); + }; + + // 22:00 — en heures creuses : TROIS phases, pleine puissance. + QVariantMap snap; + for (int m = 0; m <= 1; ++m) { + tireEnMono(); + snap = cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(20, m)), 0); + } + const QVariantMap d = telemetrieParId(snap).value(borne).value("decision").toMap(); + QVERIFY2(d.value("code").toString() == QString(DecisionCode::EvOffPeakFullPower), + qUtf8Printable(QJsonDocument::fromVariant(d).toJson(QJsonDocument::Compact))); + QCOMPARE(d.value("params").toMap().value("phases").toInt(), 3); + QCOMPARE(qRound(telemetrieParId(snap).value(borne).value("allocatedW").toDouble()), 11040); + QCOMPARE(s.borne->stateValue("desiredPhaseCount").toUInt(), 3u); + QCOMPARE(s.borne->stateValue("maxChargingCurrent").toInt(), 16); + // La batterie n'est pas pilotable : le lot ne la commande pas, et le dit. + QCOMPARE(snap.value("storage").toMap().value("decision").toMap().value("code").toString(), + QString(DecisionCode::StorageNotProtected)); + + // TÉMOIN — le lendemain 21:59, hors heures creuses, mêmes mesures : la règle d'avant, + // monophasée. Sans ce témoin, « trois phases » pourrait venir d'ailleurs que de la référence. + tireEnMono(); + snap = cycleHc(s, utcDateTime(QDate(2026, 6, 9), QTime(19, 59)), 0); + QCOMPARE(s.borne->stateValue("desiredPhaseCount").toUInt(), 1u); +#endif +} + +void Simulation::testOffPeakFullPowerYieldsToThePhaseLimit() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // SÉCURITÉ (invariant 2) — à pleine puissance, la borne reste sous la limite de phase, et une + // autre charge allumée la nuit la RÉDUIT sans que la stratégie la coupe. Couper reste le geste + // de la protection de surcharge (L4), qui garde le dernier mot. Borne triphasée 16 A, limite + // de branchement 25 A par phase. + FuseauDeTest fuseau("Europe/Paris"); + ScenarioHc s = preparerScenarioHc(QStringLiteral("limite"), BatterieHc::Pilotable, hphcT4(), + BorneHc::Triphasee); + 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); + // Les trois phases à la même intensité : la protection amont lit la PUISSANCE par phase, la + // marge de soutirage le COURANT — les deux sont posés. + const auto phases = [&](double amperes) { + for (const char *ph : {"A", "B", "C"}) { + s.meter->setStateValue(QStringLiteral("currentPhase%1").arg(ph), amperes); + s.meter->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), amperes * 230); + } + }; + const auto decision = [&](const QVariantMap &snap) { + return telemetrieParId(snap).value(borne).value("decision").toMap(); + }; + QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(21, 0)); + QVariantMap snap; + + // 1. LA MARGE D'UNE PHASE NE BRIDE PAS UNE BORNE TRIPHASÉE (LM-1500-h). La maison tire 4 A + // par phase : 21 A de marge sur CHAQUE phase, 4 830 W sur la pire. Comparer ces 4 830 W à + // l'enveloppe triphasée la servirait à 7 A ; la borne reçoit ses 11 040 W. + phases(4); + snap = cycleHc(s, a, 2760); + QCOMPARE(decision(snap).value("code").toString(), QString(DecisionCode::EvOffPeakFullPower)); + QCOMPARE(qRound(telemetrieParId(snap).value(borne).value("allocatedW").toDouble()), 11040); + QCOMPARE(s.borne->stateValue("maxChargingCurrent").toInt(), 16); + QVERIFY(!decision(snap).value("params").toMap().contains("boundBy")); + + // 2. UNE AUTRE CHARGE s'allume (four, 11 A par phase de plus) : 10 A de marge par phase. La + // borne est RÉDUITE au plafond que la limite laisse — 6 900 W, 10 A — et le motif dit qui. + a = a.addSecs(360); // au-delà des verrous de la borne + phases(15); + snap = cycleHc(s, a, 10350); + QCOMPARE(decision(snap).value("code").toString(), QString(DecisionCode::EvOffPeakFullPower)); + QCOMPARE(decision(snap).value("params").toMap().value("boundBy").toString(), QStringLiteral("phaseLimit")); + QCOMPARE(decision(snap).value("params").toMap().value("gridW").toInt(), 6900); + QCOMPARE(decision(snap).value("params").toMap().value("maxW").toInt(), 11040); + QCOMPARE(s.borne->stateValue("maxChargingCurrent").toInt(), 10); + QVERIFY(s.borne->stateValue("power").toBool()); + const QString texte = renderFr(DecisionReason(DecisionCode::EvOffPeakFullPower, + decision(snap).value("params").toMap()), "Borne"); + QVERIFY2(texte.contains(QStringLiteral("bridée par la protection de surcharge du branchement")), + qUtf8Printable(texte)); + + // 3. LA MAISON MONTE ENCORE (22 A par phase) : 3 A de marge, sous le plancher triphasé. La + // stratégie tient la borne À SON PLANCHER — 4 140 W, 6 A — et ne la coupe PAS. + a = a.addSecs(360); + phases(22); + snap = cycleHc(s, a, 15180); + QCOMPARE(decision(snap).value("params").toMap().value("boundBy").toString(), QStringLiteral("floor")); + QCOMPARE(decision(snap).value("params").toMap().value("gridW").toInt(), 4140); + QCOMPARE(s.borne->stateValue("maxChargingCurrent").toInt(), 6); + QVERIFY2(s.borne->stateValue("power").toBool(), "la stratégie ne coupe jamais : c'est le geste de L4"); + + // 4. LIMITE DE BRANCHEMENT ATTEINTE — la borne à 6 A et la maison à 22 A : 28 A par phase pour + // 25. La protection de surcharge (L4) a le dernier mot : 6 − 3 = 3 A, sous le minimum de + // la voiture, elle COUPE — et la stratégie, au même cycle, ne la rallume pas. + a = a.addSecs(360); + phases(28); + snap = cycleHc(s, a, 19320); + QVERIFY2(!s.borne->stateValue("power").toBool(), + qUtf8Printable(QJsonDocument::fromVariant(telemetrieParId(snap).value(borne)).toJson(QJsonDocument::Compact))); +#endif +} diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 3fe2e15..115645c 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -71,8 +71,12 @@ private: 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); + //! Quelle borne pose le scénario (lot e2) : 16 A, monophasée, triphasée, ou triphasée à + //! bascule de phases. La voiture suit : monophasée avec la première, triphasée sinon. + enum class BorneHc { Monophasee, Triphasee, Bascule }; + //! Serveur neuf, compteur, borne 16 A « Éco avec minimum » (voiture débranchée), batterie. + ScenarioHc preparerScenarioHc(const QString &nom, BatterieHc batterie, const QVariantList &tarif, + BorneHc borne = BorneHc::Monophasee); //! 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); @@ -274,6 +278,14 @@ private slots: //! Onduleur qui REFUSE : poses espacées (1 → 15 min), un avertissement par série, `held` faux ; //! une pose acceptée rend le rythme normal ; la levée n'attend jamais. void testRefusedWritesAreSpacedAndNeverDelayTheRelease(); + //! Lot e2 — en heures creuses, la borne reçoit son PLAFOND (3 680 W en mono, 11 040 W en tri) ; + //! hors heures creuses inchangé ; voiture pleine sous 230 W : la batterie est rendue. + void testOffPeakChargesAtFullPower(); + //! Lot e2 — borne à bascule : en heures creuses, trois phases même quand le compteur lit 0. + void testOffPeakFullPowerSwitchesToThreePhases(); + //! Lot e2 — limite de phase : la borne est réduite, jamais sous son plancher par la stratégie ; + //! la marge d'une phase ne bride pas une triphasée (LM-1500-h) ; L4 coupe au dépassement. + void testOffPeakFullPowerYieldsToThePhaseLimit(); //! 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. From 225fbc5a8d1c118a38167bbbc33e431613094a41 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sat, 24 Oct 2026 22:22:15 +0200 Subject: [PATCH 10/26] =?UTF-8?q?docs=20:=20heures=20creuses=20et=20batter?= =?UTF-8?q?ie=20=E2=80=94=20d=C3=A9cision=20D7,=20pleine=20puissance=20en?= =?UTF-8?q?=20heures=20creuses=20(lot=20e2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §8 gagne D7 (2026-10-24) et §8.1 : la règle (minimum = plafond de cycle), le choix des phases, la sécurité (LM-1500-h en heures creuses seulement, L4 garde le dernier mot), la fin de session au seuil mesuré, le motif EV_OFF_PEAK_FULL_POWER. Deux points restent à trancher : le double écrêtage hors heures creuses, et l'oscillation de la consigne près de la limite de phase (le plafond ne rend pas à la borne ce qu'elle tire). Lot futur, non implémenté : ne réserver que ce dont la maison a besoin jusqu'au soleil, d'après la prévision. §2 renvoie à D7. Co-Authored-By: Claude Opus 5.5 --- docs/DESIGN_HC_BATTERIE.md | 86 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 82 insertions(+), 4 deletions(-) diff --git a/docs/DESIGN_HC_BATTERIE.md b/docs/DESIGN_HC_BATTERIE.md index de9aaf9..ae064f4 100644 --- a/docs/DESIGN_HC_BATTERIE.md +++ b/docs/DESIGN_HC_BATTERIE.md @@ -27,15 +27,16 @@ horaires (`tou`) dont les prix **diffèrent** (`TariffConfig::zonesCreuses()`). **Modes concernés** — `ChargingModeEcoWithMinCurrent`, `ChargingModeEcoMinWithTargetTime`, `ChargingModeEcoWithTargetTime` : ceux dont le besoin réseau peut attendre. `EvAdapter` le -DÉCLARE (`LoadNeeds::offPeakShift`, `offPeakMinPowerW` = le plancher de la borne), le scheduler -le DÉCIDE (règle 2). `ChargingModeEco` ne demande jamais le réseau, `ChargingModeNormal` est +DÉCLARE (`LoadNeeds::offPeakShift`, `offPeakMinPowerW` = le plafond de cycle de la borne depuis +le lot e2, son plancher au lot e1 — §8, D7), le scheduler le DÉCIDE (règle 2). `ChargingModeEco` ne demande jamais le réseau, `ChargingModeNormal` est piloté par l'utilisateur : tous deux sont inchangés. | | hors heures creuses | en heures creuses | |---|---|---| -| minimum réseau (`minPowerWAlways`) | **retiré** — surplus seulement | le plancher de la borne, au moins | +| minimum réseau (`minPowerWAlways`) | **retiré** — surplus seulement | le **plafond** de la borne (lot e2, §8 D7) | | démarrage sous tolérance (`EV_GRID_START`) | **refusé** (le minimum par une autre porte) | inchangé | | motif d'une consigne nulle | **`EV_WAITING_OFF_PEAK`** `{at, startsAt, budgetW}` | motifs ordinaires | +| motif de la consigne servie | — | **`EV_OFF_PEAK_FULL_POWER`** (lot e2, §8 D7) | | échéance (`EV_DEADLINE`, proxy) | inchangée | inchangée | **Le changement de comportement** : une borne « Éco avec minimum » ne prend plus ses 6 A au @@ -47,7 +48,8 @@ parce que la cause de la consigne nulle n'est plus le manque de surplus. Le plaf verrous gardent la tête : ils diraient la même chose en heures creuses. **En heures creuses**, le plancher est servi au réseau par la passe éco (`ECO_FLOOR_GRID`), -bornée par l'autorisation de soutirage ; le confort ajoute le surplus s'il y en a. La borne +bornée par l'autorisation de soutirage ; le confort ajoute le surplus s'il y en a. *Lot e2 : +c'est désormais le plafond, sous `EV_OFF_PEAK_FULL_POWER` — §8, D7.* La borne déclare un plafond écrêté par `evPhaseAllowanceW()`, `verifyOverloadProtection()` garde le dernier mot : l'invariant 2 tient sans rien ajouter. @@ -277,6 +279,82 @@ aucune ne change le code. | D4 | Une borne en `ChargingModeNormal` qui charge en heures creuses déclenche-t-elle le blocage (§3.2) ? | **Oui, c'est le but** : la mesure porte sur toutes les bornes, mode compris. | | D5 | Seuil de « la voiture charge réellement » : 230 W sur la somme des bornes (§3.2) ? | **Accepté.** | | D6 | Plugin SunSpec : une action « limite de décharge » en une séquence (`planBatteryMode(NO_DISCHARGE)`), et `enableDischarging` renommé ou redécrit (§3.1, §7) ? | **Oui aux deux**, dans le dépôt du plugin, à la charge de l'orchestrateur. Le garde ne change pas : il reste compatible avec les deux actions actuelles (`dischargingRate` puis `enableDischarging`). | +| D7 | À quelle puissance la borne charge-t-elle en heures creuses (lot e2) ? | **À la pleine puissance permise**, et non plus au plancher. La session finit plus tôt, et la décharge de la batterie n'est bloquée que pendant ce temps-là : ensuite, elle couvre la maison pour le reste de la nuit. Détail en §8.1. | La mesure du minuteur de retour sur un vrai GEN24 (hypothèse AC-10 du plugin, §3.4) reste ouverte : elle relève du plugin et d'un essai sur matériel réel. + +### 8.1 D7 — pleine puissance en heures creuses (lot e2) + +**Pourquoi.** Au plancher (6 A, 1,4 kW en monophasé), une nuit de 22:00 à 06:00 livre au plus +11 kWh : une session de 10 à 25 kWh n'est pas finie au départ. Et la décharge de la batterie +reste bloquée toute la nuit, puisque la voiture charge toute la nuit : la batterie est encore +pleine le matin, et le surplus solaire part au réseau. + +**La règle.** Dans les trois modes qui reportent en heures creuses (§2), le minimum servi au +réseau en heures creuses est le **plafond de cycle** de la borne (`declared.maxPowerW`) : + +- il porte le courant maximal de la borne, ses phases, et la limite de phase + (`evPhaseAllowanceW()`) — rien n'est retiré de ces écrêtages ; +- il ne descend jamais sous le plancher : la limite de phase **rabaisse** la borne, elle ne la + coupe pas ; couper reste le geste de `verifyOverloadProtection()` (L4), qui garde le dernier + mot ; +- hors heures creuses, et sans heure creuse dans le tarif, rien ne change. + +Par exemple : 3 680 W en monophasé 16 A, 11 040 W en triphasé 16 A. + +**Les phases.** En heures creuses, une borne qui peut charger en triphasé charge en triphasé. +La règle de bascule du manager est inchangée ; c'est le courant qu'on lui présente qui change. +Hors heures creuses, c'est « courant de la borne + surplus ». En heures creuses, c'est la pleine +puissance de la borne. Raison : la nuit, « courant + surplus » bride dans un cas précis — une +batterie (non pilotable, ou pas encore bloquée) couvre la maison et la voiture, le compteur lit +≈ 0, et la référence vaut le courant que la borne tire déjà. À 6 A en monophasé, la règle garde +le monophasé : 3,7 kW au lieu de 11. La limite de phase n'entre pas dans le choix, et c'est sans +perte : par phase, elle borne pareil une monophasée et une triphasée, donc trois phases donnent +toujours au moins autant. + +**Sécurité.** Le plafond d'une borne porte déjà l'autorisation de soutirage, résolue sur les +phases qu'elle occupe et multipliée par leur nombre. La passe éco ne lui oppose donc pas en plus +la marge d'**une** phase de la maison (`LM-1500-h`, déjà appliquée en tête de passe et en passe +confort). Sans cela, une triphasée serait servie à 7 A au lieu de 16. Une autre charge allumée +la nuit réduit le plafond au cycle suivant ; sous le plancher, la borne est tenue au plancher, et +L4 coupe au dépassement. Cette exemption ne vaut **qu'en heures creuses** : sans heure creuse, le +plancher éco d'une borne reste écrêté par la marge de la maison, comme avant. + +**Reste à trancher** (rien n'est changé par ce lot) : + +- *Le double écrêtage hors heures creuses.* Le plancher éco d'une borne triphasée, sans heure + creuse, est encore comparé à la marge d'une phase. L'étendre à `LM-1500-h` changerait des + décisions sans tarif : c'est un choix à faire à part. +- *L'oscillation près de la limite de phase.* Le plafond d'une borne est la place libre **sans + lui rendre ce qu'elle tire** (`evPhaseAllowanceW()`, choix de sécurité documenté). À pleine + puissance, la borne voit donc sa propre consommation lui réduire le plafond au cycle suivant, + puis le lui rendre au cycle d'après. En simulation, avec un compteur qui voit la borne (maison + à 2 A par phase, borne triphasée 16 A), la consigne alterne à chaque cycle entre 16 et 7 A + sous une limite de 25 A, et entre 16 et 14 A sous 32 A. Au plancher (lot e1), le phénomène ne + se voyait pas. Une piste : rendre à la borne, en heures creuses, son propre courant moins une + marge (la règle de l'échéance chez l'amont, `adjustEvChargers()` : marge − 1 A + courant de la + borne). + +**Fin de session.** Le blocage de la décharge se lève sur la puissance **mesurée** des bornes, +au seuil de 230 W (D5). Une borne commandée à 16 A dont la voiture ne prend plus rien (pleine, +ou session arrêtée par le véhicule) ne tient donc pas la batterie bloquée. + +**Motif.** `EV_OFF_PEAK_FULL_POWER {gridW, maxW, phases, until, endsAt, o:boundBy}` remplace, +en heures creuses, `ECO_FLOOR_GRID` sur la borne. `gridW` est la puissance retenue, `maxW` la +pleine puissance du matériel aux phases visées. `boundBy` n'est présent que si la protection de +surcharge l'a rabaissée : `phaseLimit` (à ce que la limite laisse) ou `floor` (tenue au +plancher, L4 décide de la suite). Rendu : « Borne charge à pleine puissance en heures creuses : +11040 W achetés au réseau (3 phases, jusqu'à 06:00) ». + +**Preuves.** `testOffPeakChargesAtFullPower`, `testOffPeakFullPowerSwitchesToThreePhases`, +`testOffPeakFullPowerYieldsToThePhaseLimit` ; les tests du lot e1 en heures creuses attendent +désormais le plafond. Sans heure creuse, la preuve `decisions` est identique. + +**Lot futur, non implémenté** — *réserver seulement ce dont la maison a besoin jusqu'au soleil, +d'après la prévision.* Aujourd'hui la décharge est bloquée entière tant que la voiture charge +(D1). Une version plus fine laisserait la batterie se décharger dans la voiture au-delà de ce que +la maison consommera d'ici le retour du soleil — consommation de base prévue (`BaseloadLog`) et +heure de la première production utile (prévision PV). Elle demande une prévision fiable des deux, +et un pilotage de `OutWRte` plus fin qu'un tout-ou-rien : c'est un lot à part. + From d62365fe3dd3180ca354b542d43ad217f380ea76 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sat, 24 Oct 2026 22:32:16 +0200 Subject: [PATCH 11/26] =?UTF-8?q?decisions=20:=20r=C3=A9f=C3=A9rence=20?= =?UTF-8?q?=C3=A9tendue=20au=20lot=20e2=20(pleine=20puissance=20en=20heure?= =?UTF-8?q?s=20creuses)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 72 fonctions sans borne en heures creuses : 23 812 lignes identiques octet pour octet à la référence de b144c09. Changent, en heures creuses : les six fonctions du lot e1 qui ont une borne (ECO_FLOOR_GRID 1 380 W → EV_OFF_PEAK_FULL_POWER 3 680 W, et leur compteur d'entrée, qui inclut désormais ce que la borne tire) ; s'ajoutent les trois fonctions du lot e2. check et ETM_DECISIONS_TRACES=1 check : IDENTIQUE (24 068 lignes). Co-Authored-By: Claude Opus 5.5 --- .../decisions/reference/decisions.ndjson.xz | Bin 566588 -> 567408 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/tests/auto/decisions/reference/decisions.ndjson.xz b/tests/auto/decisions/reference/decisions.ndjson.xz index 7d0bfc1300b9f0adff5e1fcfa63bcc666fb22eb3..f41a2b9d0b24fa1cd58a7f2a3e228ed2fdd2cd63 100644 GIT binary patch delta 5033 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 FSz0*#4)Fj0 From 679e64b5b38838af8a07f0fa69e01e23eaf8948d Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 25 Oct 2026 07:15:30 +0100 Subject: [PATCH 12/26] Heures creuses : le plafond de la borne lui rend ce qu'elle tire, moins 1 A (lot e2, suite) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Près de la limite de phase, la consigne d'une borne à pleine puissance alternait à chaque cycle : le plafond était la place libre au compteur, sans lui rendre sa propre consommation. Maison à 2 A par phase, borne 16 A : 16, 7, 16, 7 A sous 25 A ; 16, 14 sous 32 A ; 14, 6, 8, 6 sous 16 A. En heures creuses, et pour la pleine puissance du lot e2 seulement, le plafond est lu par EnergyArbitrator::evOffPeakAllowanceW() : limite moins le tirage de la phase sans la borne, moins 1 A, à l'ampère inférieur. La règle de l'échéance chez l'amont (adjustEvChargers()). Elle converge en un cycle : 16 A tenus sous 25 et 32 A, 13 A sous 16 A. - Courant rendu MESURÉ (currentPower), sur les phases où la borne le mesure ; rien rendu pendant une bascule ou sans mesure. - Tirage de la maison sans la borne écrêté à zéro : une borne qui annonce plus que ce que le compteur voit reste à 1 A sous la limite. - En dépassement, le plafond vaut au plus le courant mesuré moins 1 A : il baisse à chaque cycle, jusqu'au plancher, où L4 décide. - Hors heures creuses et sans tarif : evPhaseAllowanceW(), inchangée. Tests : testOffPeakFullPowerSettlesUnderThePhaseLimit (sept cas, douze cycles) et testOffPeakFullPowerStaysUnderTheLimitOnABlindMeter, nouveaux ; testOffPeakFullPowerYieldsToThePhaseLimit réécrit avec un compteur qui voit la borne (mesure et non consigne, dépassement, plancher, L4) ; testBlockedDischargeShowsAtTheMeterAndL4Decides : en dépassement, la borne finit au plancher (L4 16 → 13 A, stratégie 9 A, L4 6 A) au lieu d'être coupée. Co-Authored-By: Claude Opus 5.5 --- energyplugin/etm/adapters/evadapter.cpp | 8 +- energyplugin/etm/adapters/evadapter.h | 3 + energyplugin/etm/energyarbitrator.cpp | 56 +++++ energyplugin/etm/energyarbitrator.h | 36 ++++ tests/auto/simulation/simulation.cpp | 259 +++++++++++++++++++++--- tests/auto/simulation/simulation.h | 12 +- 6 files changed, 344 insertions(+), 30 deletions(-) diff --git a/energyplugin/etm/adapters/evadapter.cpp b/energyplugin/etm/adapters/evadapter.cpp index eb65db2..bf2a0e9 100644 --- a/energyplugin/etm/adapters/evadapter.cpp +++ b/energyplugin/etm/adapters/evadapter.cpp @@ -129,7 +129,13 @@ LoadContext EvAdapter::toLoadContext(const QDateTime &now) const // APRÈS coup — c'est-à-dire après avoir commandé. Le plafond doit donc être DÉCLARÉ, comme // n'importe quelle borne d'adaptateur (règle 4). ctx.declared.maxPowerW = m_charger->maxChargingCurrentMaxValue() * 230.0 * m_targetPhases; - const double plafondPhaseW = m_parent->evPhaseAllowanceW(m_charger, m_targetPhases); + // Lot e2, suite (2026-10-25) — EN HEURES CREUSES, le plafond rend à la borne ce qu'elle tire, + // moins 1 A par phase. Sans ce retour, à pleine puissance, sa propre consommation lui + // retirait la place au cycle suivant et la lui rendait au cycle d'après : 16, 7, 16, 7 A sous + // une limite de 25 A, avec une maison à 2 A. Hors heures creuses, la règle d'avant. + const double plafondPhaseW = pleinePuissanceHc + ? m_parent->evOffPeakAllowanceW(m_charger, m_targetPhases) + : m_parent->evPhaseAllowanceW(m_charger, m_targetPhases); if (plafondPhaseW >= 0) { // …mais JAMAIS sous le plancher de la borne. C'est la règle de l'amont, reprise mot // pour mot (`if (newValueInt <= minValue) newValueInt = minValue`), et la répartition diff --git a/energyplugin/etm/adapters/evadapter.h b/energyplugin/etm/adapters/evadapter.h index e39d471..c80a3fe 100644 --- a/energyplugin/etm/adapters/evadapter.h +++ b/energyplugin/etm/adapters/evadapter.h @@ -54,6 +54,9 @@ public: * d'heures creuses (\c needs.offPeakMinPowerW) est le PLAFOND du cycle * (\c declared.maxPowerW : courant maximal, phases, limite de phase), jamais sous le * plancher ; et le nombre de phases se choisit sur ce plafond, plus sur le surplus. + * \note **Lot e2, suite** — dans ces mêmes conditions, la limite de phase est lue par + * \c EnergyArbitrator::evOffPeakAllowanceW() : la place libre plus le courant MESURÉ de la + * borne, moins 1 A par phase. Hors heures creuses, \c evPhaseAllowanceW(), inchangée. */ LoadContext toLoadContext(const QDateTime &now) const override; diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 52bfe8c..71e422b 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -1212,6 +1212,9 @@ double EnergyArbitrator::evPhaseAllowanceW(EvCharger *ev, uint phaseCount) const // qui ramène la borne à son minimum. Avec le retour, elle se verrait autoriser ce qu'elle // consomme déjà — c'est-à-dire précisément la puissance qui cause le dépassement. // (`testOverloadProtectionEcoMode` : 16 A ramenés à 6 A ; avec le retour, 9 A maintenus.) + // Ce test a un compteur qui ne voit pas la borne baisser : le retour y est un trou. En heures + // creuses, evOffPeakAllowanceW() rend le courant MESURÉ moins une marge, et sa propre preuve + // de baisse en dépassement suppose que le compteur voie la borne. // // L'écrêtage à zéro est fait ICI, chez le consommateur, et non dans la résolution : la // marge PORTE le dépassement (elle vaut -1200 quand on dépasse de 1200 W) parce que @@ -1220,6 +1223,59 @@ double EnergyArbitrator::evPhaseAllowanceW(EvCharger *ev, uint phaseCount) const return qMax(0.0, marge.watts) * phaseCount; } +namespace { +//! Marge du plafond d'heures creuses, par phase. Celle de l'amont pour l'échéance +//! (`adjustEvChargers()`), pour la même raison : ne pas coller à la limite, et laisser à un +//! appareil moyen allumé entre deux cycles la place de démarrer avant que L4 n'ait à couper. +constexpr double MargeHeuresCreusesA = 1.0; +} + +double EnergyArbitrator::evOffPeakAllowanceW(EvCharger *ev, uint phaseCount) const +{ + RootMeter *meter = internalRootMeter(); + if (!ev || phaseCount == 0 || !meter) + return -1; + + const QList plafonds = drawCaps(); + if (plafonds.isEmpty()) + return -1; + + // Ce que la borne tire, MESURÉ, et sur quelles phases. Pas la consigne : une voiture qui + // prend 8 A d'une borne commandée à 16 n'occupe que 8 A de la phase, et lui rendre 16 + // ouvrirait 8 A qu'elle peut prendre d'un coup. Une borne sans mesure ne rend rien. + const bool mesuree = !ev->thing()->thingClass().stateTypes().findByName("currentPower").id().isNull(); + const Electricity::Phases tirees = mesuree ? ev->meteredPhases() : Electricity::PhaseNone; + // Rendre sur une phase que la borne n'occupe pas créerait de la place qui n'existe pas — et + // la borne, en la prenant ailleurs, ne la refermerait jamais. Pendant une bascule (phases + // mesurées ≠ phases visées), on ne rend donc rien. + const bool attribuable = tirees != Electricity::PhaseNone + && Electricity::getPhaseCount(tirees) == phaseCount; + const Electricity::Phases masque = attribuable + ? tirees : const_cast(this)->phasesForCount(phaseCount); + const double tireW = attribuable ? qMax(0.0, ev->currentPower()) : 0.0; + + // Le tirage de la maison SANS la borne, phase par phase, écrêté à zéro : une borne qui + // annoncerait plus que ce que le compteur voit ne se fabrique pas de place au-delà de la + // limite. Au mieux, une phase vaut sa limite. + QList sansLaBorne = phaseDrawW(masque, phasePowerLimit()); + for (double &w : sansLaBorne) + w = qMax(0.0, w - tireW / phaseCount); + const DrawMargin marge = resolveDrawMargin(plafonds, sansLaBorne, + qMax(0.0, meter->currentPower() - tireW)); + if (!marge.borne) + return -1; + + // Moins la marge, à l'ampère INFÉRIEUR : l'adaptateur arrondit la consigne au plus proche, + // et un plafond de 13,6 A deviendrait 14 A — la marge serait mangée de moitié. + // + // En dépassement, ce plafond BAISSE, et c'est la raison de sécurité de evPhaseAllowanceW() + // qui tient toujours : si la phase tire I > limite, dont E pour la borne, la place rendue + // vaut limite − (I − E) − 1 < E − 1. La consigne descend d'au moins 1 A par cycle, jusqu'à + // sortir du dépassement ou jusqu'au plancher, où L4 décide. + const double parPhaseA = std::floor(marge.watts / 230.0 - MargeHeuresCreusesA); + return qMax(0.0, parPhaseA) * 230.0 * phaseCount; +} + bool EnergyArbitrator::evCommandedByWaterfall(EvCharger *evCharger) const { return evCharger && m_waterfallEvIds.contains(evCharger->thing()->id().toString()); diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index 10ef4db..f960818 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -842,6 +842,42 @@ public: */ double evPhaseAllowanceW(EvCharger *ev, uint phaseCount) const; + /*! + * \brief Lot e2, suite — plafond d'une borne À PLEINE PUISSANCE EN HEURES CREUSES : la place + * libre, plus ce que la borne tire, moins une marge de 1 A par phase. + * + * \param ev Borne. \param phaseCount Nombre de phases VISÉ. + * \return Plafond en watts, multiple de 230 W par phase, jamais négatif ; ou **−1 si aucune + * limite** (même convention que \c evPhaseAllowanceW()). + * + * \par Pourquoi + * \c evPhaseAllowanceW() ne rend pas à la borne ce qu'elle tire. À pleine puissance, et avec + * un compteur qui voit la borne, sa propre consommation lui retire donc la place au cycle + * suivant, puis la lui rend au cycle d'après : 16, 7, 16, 7 A sous une limite de 25 A. C'est + * la règle de l'échéance chez l'amont (\c adjustEvChargers() : marge − 1 A + courant de la + * borne) qui la fait se poser. + * + * \par Contrat + * - Le courant rendu est MESURÉ sur la borne (état \c currentPower de sa classe), jamais + * déduit de sa consigne. Il n'est rendu que sur les phases où la borne le mesure, et + * seulement si elles sont aussi nombreuses que les phases visées. Sinon — borne sans + * mesure, à l'arrêt, ou qui bascule — rien n'est rendu, et la marge s'applique quand même. + * - Une phase ne peut pas valoir plus que sa limite, moins la marge : ce que la maison tire + * sans la borne est écrêté à zéro. Une borne qui annoncerait plus que ce que le compteur + * voit ne se fabrique donc pas de place au-delà de la limite. + * - **En dépassement, le plafond baisse** : sur une phase au-dessus de la limite, il vaut au + * plus le courant mesuré de la borne moins 1 A. Il redescend donc à chaque cycle, jusqu'à + * sortir du dépassement ou jusqu'au plancher — où la stratégie tient la borne et où + * \c verifyOverloadProtection() (L4) décide. Cela suppose que le compteur voie la borne, + * comme L4 le suppose déjà. + * - Arrondi à l'ampère INFÉRIEUR : la consigne, arrondie au plus proche par l'adaptateur, ne + * mange jamais la marge. + * + * \note Réservée aux heures creuses et à la pleine puissance du lot e2. Hors de là, + * \c evPhaseAllowanceW() reste la règle, et les décisions sans tarif ne changent pas. + */ + double evOffPeakAllowanceW(EvCharger *ev, uint phaseCount) const; + /*! * \brief [3g-2] Rang configuré d'une borne dans le waterfall. * diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 13cccb6..35be5ab 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -11131,9 +11131,14 @@ void Simulation::testBlockedDischargeShowsAtTheMeterAndL4Decides() // 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. + // + // Depuis D8 (2026-10-25), le plafond d'heures creuses rend à la borne son courant MESURÉ. L4 + // la ramène d'abord de 16 à 13 A ; la stratégie lit 13 A tirés sous 28 A au compteur, et il + // reste 25 − 15 − 1 = 9 A : elle RÉDUIT, elle ne coupe plus d'elle-même. L4 garde le dernier + // mot et retire encore les 3 A du dépassement — la borne finit à son plancher, ou coupée. phaseA(28); snap = cycleHc(s, a.addSecs(240), 6440); - QVERIFY2(!s.borne->stateValue("power").toBool() || s.borne->stateValue("maxChargingCurrent").toInt() < 6, + 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); @@ -11459,6 +11464,39 @@ void Simulation::testOffPeakFullPowerSwitchesToThreePhases() #endif } +namespace { + +//! Le compteur VOIT la borne : chaque phase porte la maison, plus ce que la borne y tire — +//! MESURÉ sur la borne (états par phase du mock), jamais déduit de sa consigne. Rend le total. +double compteurVoitLaBorne(Thing *compteur, Thing *borne, double maisonA) +{ + double totalW = 0; + for (const char *ph : {"A", "B", "C"}) { + const double a = maisonA + borne->stateValue(QStringLiteral("currentPhase%1").arg(ph)).toDouble(); + compteur->setStateValue(QStringLiteral("currentPhase%1").arg(ph), a); + compteur->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), a * 230); + totalW += a * 230; + } + return totalW; +} + +//! Intensité de la phase la plus chargée au compteur. +double pirePhaseA(Thing *compteur) +{ + double pire = 0; + for (const char *ph : {"A", "B", "C"}) + pire = qMax(pire, compteur->stateValue(QStringLiteral("currentPhase%1").arg(ph)).toDouble()); + return pire; +} + +//! Consigne de la borne, en ampères — 0 si elle est coupée. +int consigneA(Thing *borne) +{ + return borne->stateValue("power").toBool() ? borne->stateValue("maxChargingCurrent").toInt() : 0; +} + +} // namespace + void Simulation::testOffPeakFullPowerYieldsToThePhaseLimit() { #ifndef ETM_ARBITRATOR @@ -11468,6 +11506,10 @@ void Simulation::testOffPeakFullPowerYieldsToThePhaseLimit() // autre charge allumée la nuit la RÉDUIT sans que la stratégie la coupe. Couper reste le geste // de la protection de surcharge (L4), qui garde le dernier mot. Borne triphasée 16 A, limite // de branchement 25 A par phase. + // + // Le compteur VOIT la borne (lot e2, suite) : chaque phase porte la maison plus ce que la + // borne y tire, mesuré sur elle. C'est la condition où le plafond d'heures creuses lui rend + // son courant, et c'est la seule où la preuve de baisse en dépassement a un sens. FuseauDeTest fuseau("Europe/Paris"); ScenarioHc s = preparerScenarioHc(QStringLiteral("limite"), BatterieHc::Pilotable, hphcT4(), BorneHc::Triphasee); @@ -11477,63 +11519,226 @@ void Simulation::testOffPeakFullPowerYieldsToThePhaseLimit() .toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError")); s.borne->setStateValue("pluggedIn", true); - // Les trois phases à la même intensité : la protection amont lit la PUISSANCE par phase, la - // marge de soutirage le COURANT — les deux sont posés. - const auto phases = [&](double amperes) { - for (const char *ph : {"A", "B", "C"}) { - s.meter->setStateValue(QStringLiteral("currentPhase%1").arg(ph), amperes); - s.meter->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), amperes * 230); - } - }; const auto decision = [&](const QVariantMap &snap) { return telemetrieParId(snap).value(borne).value("decision").toMap(); }; + //! Ce que la borne tire sur chaque phase, mesuré sur elle. + const auto tireA = [&]() { return s.borne->stateValue("currentPhaseA").toDouble(); }; QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(21, 0)); QVariantMap snap; + const auto cycle = [&](double maisonA) { + a = a.addSecs(360); // au-delà des verrous de la borne + snap = cycleHc(s, a, compteurVoitLaBorne(s.meter, s.borne, maisonA)); + }; // 1. LA MARGE D'UNE PHASE NE BRIDE PAS UNE BORNE TRIPHASÉE (LM-1500-h). La maison tire 4 A // par phase : 21 A de marge sur CHAQUE phase, 4 830 W sur la pire. Comparer ces 4 830 W à // l'enveloppe triphasée la servirait à 7 A ; la borne reçoit ses 11 040 W. - phases(4); - snap = cycleHc(s, a, 2760); + cycle(4); QCOMPARE(decision(snap).value("code").toString(), QString(DecisionCode::EvOffPeakFullPower)); QCOMPARE(qRound(telemetrieParId(snap).value(borne).value("allocatedW").toDouble()), 11040); QCOMPARE(s.borne->stateValue("maxChargingCurrent").toInt(), 16); QVERIFY(!decision(snap).value("params").toMap().contains("boundBy")); - // 2. UNE AUTRE CHARGE s'allume (four, 11 A par phase de plus) : 10 A de marge par phase. La - // borne est RÉDUITE au plafond que la limite laisse — 6 900 W, 10 A — et le motif dit qui. - a = a.addSecs(360); // au-delà des verrous de la borne - phases(15); - snap = cycleHc(s, a, 10350); + // 2. LA MESURE, PAS LA CONSIGNE. La voiture ne prend que 10 A des 16 commandés, et un four + // s'allume : 15 A de maison, 25 A au compteur. La place rendue est celle que la voiture + // OCCUPE — 25 − 15 − 1 = 9 A. Rendre la consigne en aurait offert 15, que la voiture + // pourrait prendre d'un coup : 30 A sur une limite de 25. + for (const char *ph : {"A", "B", "C"}) { + s.borne->setStateValue(QStringLiteral("currentPhase%1").arg(ph), 10.0); + s.borne->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), 2300.0); + } + s.borne->setStateValue("currentPower", 6900.0); + cycle(15); + QCOMPARE(s.borne->stateValue("maxChargingCurrent").toInt(), 9); + QCOMPARE(decision(snap).value("params").toMap().value("boundBy").toString(), QStringLiteral("phaseLimit")); + QCOMPARE(decision(snap).value("params").toMap().value("gridW").toInt(), 6210); + + // 3. Le four s'éteint : la borne remonte à sa pleine puissance. + cycle(4); + QCOMPARE(s.borne->stateValue("maxChargingCurrent").toInt(), 16); + QCOMPARE(tireA(), 16.0); + + // 4. DÉPASSEMENT — le four se rallume pendant que la borne tire 16 A : 31 A pour 25. Tant + // que la phase dépasse, ce que la STRATÉGIE retient baisse, d'au moins 1 A sous ce que la + // borne tirait, et la consigne finale aussi — L4 a pu trancher plus bas, il garde le dernier + // mot. Hors du dépassement, la consigne se pose à 25 − 15 − 1 = 9 A, sans alterner. + QStringList suite; + QList consignes; + for (int k = 0; k < 5; ++k) { + const double avantA = tireA(); + compteurVoitLaBorne(s.meter, s.borne, 15); + const bool depasse = pirePhaseA(s.meter) > 25; + cycle(15); + const int retenuA = qRound(decision(snap).value("params").toMap().value("gridW").toDouble() / 690.0); + consignes << consigneA(s.borne); + suite << QStringLiteral("%1 A tirés%2 → stratégie %3 A, consigne %4 A") + .arg(avantA).arg(depasse ? QStringLiteral(" (dépassement)") : QString()) + .arg(retenuA).arg(consignes.last()); + if (depasse) { + // Au plancher, la stratégie TIENT la borne (décision du 2026-10-25) : c'est L4 qui coupe. + QVERIFY2(retenuA <= avantA - 1 || retenuA == 6, qUtf8Printable(suite.join(" ; "))); + QVERIFY2(consignes.last() <= avantA - 1, qUtf8Printable(suite.join(" ; "))); + } + } + qInfo().noquote() << "[dépassement]" << suite.join(QStringLiteral(" ; ")); + QVERIFY2(consignes.mid(2) == (QList{9, 9, 9}), qUtf8Printable(suite.join(" ; "))); + compteurVoitLaBorne(s.meter, s.borne, 15); + QVERIFY2(pirePhaseA(s.meter) <= 24, qUtf8Printable(suite.join(", "))); QCOMPARE(decision(snap).value("code").toString(), QString(DecisionCode::EvOffPeakFullPower)); QCOMPARE(decision(snap).value("params").toMap().value("boundBy").toString(), QStringLiteral("phaseLimit")); - QCOMPARE(decision(snap).value("params").toMap().value("gridW").toInt(), 6900); + QCOMPARE(decision(snap).value("params").toMap().value("gridW").toInt(), 6210); QCOMPARE(decision(snap).value("params").toMap().value("maxW").toInt(), 11040); - QCOMPARE(s.borne->stateValue("maxChargingCurrent").toInt(), 10); QVERIFY(s.borne->stateValue("power").toBool()); const QString texte = renderFr(DecisionReason(DecisionCode::EvOffPeakFullPower, decision(snap).value("params").toMap()), "Borne"); QVERIFY2(texte.contains(QStringLiteral("bridée par la protection de surcharge du branchement")), qUtf8Printable(texte)); - // 3. LA MAISON MONTE ENCORE (22 A par phase) : 3 A de marge, sous le plancher triphasé. La - // stratégie tient la borne À SON PLANCHER — 4 140 W, 6 A — et ne la coupe PAS. - a = a.addSecs(360); - phases(22); - snap = cycleHc(s, a, 15180); + // 5. LA MAISON MONTE ENCORE (19 A par phase) : avec la borne au plancher, 25 A au compteur, + // pile la limite, et 5 A de place rendue — sous le plancher triphasé. La stratégie tient la + // borne À SON PLANCHER — 4 140 W, 6 A — et ne la coupe PAS. (Le premier cycle dépasse : la + // borne tire encore 9 A, et L4 tranche ; on lit l'état posé.) + for (int k = 0; k < 3; ++k) + cycle(19); QCOMPARE(decision(snap).value("params").toMap().value("boundBy").toString(), QStringLiteral("floor")); QCOMPARE(decision(snap).value("params").toMap().value("gridW").toInt(), 4140); QCOMPARE(s.borne->stateValue("maxChargingCurrent").toInt(), 6); QVERIFY2(s.borne->stateValue("power").toBool(), "la stratégie ne coupe jamais : c'est le geste de L4"); + compteurVoitLaBorne(s.meter, s.borne, 19); + QCOMPARE(pirePhaseA(s.meter), 25.0); - // 4. LIMITE DE BRANCHEMENT ATTEINTE — la borne à 6 A et la maison à 22 A : 28 A par phase pour + // 6. LIMITE DE BRANCHEMENT ATTEINTE — la borne à 6 A et la maison à 22 A : 28 A par phase pour // 25. La protection de surcharge (L4) a le dernier mot : 6 − 3 = 3 A, sous le minimum de // la voiture, elle COUPE — et la stratégie, au même cycle, ne la rallume pas. - a = a.addSecs(360); - phases(28); - snap = cycleHc(s, a, 19320); + cycle(22); QVERIFY2(!s.borne->stateValue("power").toBool(), qUtf8Printable(QJsonDocument::fromVariant(telemetrieParId(snap).value(borne)).toJson(QJsonDocument::Compact))); #endif } + +// ============================================================================================ +// Lot e2, suite (2026-10-25) — près de la limite de phase, la consigne se stabilise +// ============================================================================================ + +void Simulation::testOffPeakFullPowerSettlesUnderThePhaseLimit() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // L'OSCILLATION mesurée au lot e2 (question 2) : le plafond d'une borne était la place LIBRE + // au compteur, sans lui rendre ce qu'elle tire. Un compteur qui voit la borne fait donc + // alterner la consigne : à 16 A la place libre tombe à 7 A, à 7 A elle remonte à 16 A — sous + // une limite de 25 A, 16, 7, 16, 7… En heures creuses, le plafond lui rend son courant + // MESURÉ, moins 1 A : la consigne se pose, et sous une limite qui ne laisse pas 16 A elle se + // pose sous la limite, à 1 A d'elle. + // + // Maison à 2 A par phase (2,4 A pour le cas de l'arrondi), douze cycles espacés de 6 min + // (au-delà des verrous de la borne). + // Le compteur de chaque cycle porte ce que la borne tirait à la fin du précédent. + FuseauDeTest fuseau("Europe/Paris"); + struct Cas { const char *quoi; BorneHc borne; int phases; int limiteA; int attenduA; double maisonA; }; + const QList cas = { + {"monophasée, limite 25 A", BorneHc::Monophasee, 1, 25, 16, 2}, + {"monophasée, limite 32 A", BorneHc::Monophasee, 1, 32, 16, 2}, + {"triphasée, limite 25 A", BorneHc::Triphasee, 3, 25, 16, 2}, + {"triphasée, limite 32 A", BorneHc::Triphasee, 3, 32, 16, 2}, + // 16 − 2 = 14 A de place : la consigne se pose à 13 A, 1 A sous la limite. + {"monophasée, limite 16 A", BorneHc::Monophasee, 1, 16, 13, 2}, + {"triphasée, limite 16 A", BorneHc::Triphasee, 3, 16, 13, 2}, + // L'ARRONDI : 16 − 2,4 − 1 = 12,6 A. Pris au plus proche, 13 A porteraient la phase à + // 15,4 A — la marge mangée. À l'ampère inférieur : 12 A, 14,4 A au compteur. + {"monophasée, limite 16 A, maison à 2,4 A", BorneHc::Monophasee, 1, 16, 12, 2.4}, + }; + constexpr int Cycles = 12; + // Tous les cas sont MESURÉS avant qu'un seul soit jugé : le journal porte la série complète, + // cycle par cycle, même quand le premier cas échoue. + QList> toutesConsignes; + QList> toutesPires; + QStringList traces; + for (const Cas &c : cas) { + ScenarioHc s = preparerScenarioHc(QStringLiteral("oscillation-%1-%2-%3").arg(c.phases).arg(c.limiteA) + .arg(qRound(c.maisonA * 10)), + BatterieHc::Aucune, hphcT4(), c.borne); + QVERIFY2(s.arb && s.borne, c.quoi); + QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", c.limiteA}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + s.borne->setStateValue("pluggedIn", true); + + QList consignes; + QList pires; + QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(21, 0)); // 23:00 à Paris + for (int k = 0; k < Cycles; ++k, a = a.addSecs(360)) { + cycleHc(s, a, compteurVoitLaBorne(s.meter, s.borne, c.maisonA)); + consignes << consigneA(s.borne); + compteurVoitLaBorne(s.meter, s.borne, c.maisonA); // ce que la consigne produit + pires << pirePhaseA(s.meter); + } + QStringList trace; + for (int k = 0; k < Cycles; ++k) + trace << QStringLiteral("%1 A (%2)").arg(consignes.at(k)).arg(pires.at(k)); + traces << trace.join(QStringLiteral(", ")); + qInfo().noquote() << "[oscillation]" << c.quoi << ":" << traces.last(); + toutesConsignes << consignes; + toutesPires << pires; + } + + for (int i = 0; i < cas.size(); ++i) { + const Cas &c = cas.at(i); + const QList &consignes = toutesConsignes.at(i); + const QList &pires = toutesPires.at(i); + // Posée dès le premier cycle, et TENUE : aucune alternance sur les douze. + for (int k = 0; k < Cycles; ++k) + QVERIFY2(consignes.at(k) == c.attenduA, + qUtf8Printable(QStringLiteral("%1 — cycle %2 : %3").arg(c.quoi).arg(k).arg(traces.at(i)))); + // Et SOUS la limite, avec la marge : la phase la plus chargée n'y touche jamais. + for (int k = 0; k < Cycles; ++k) + QVERIFY2(pires.at(k) <= c.limiteA - 1 + 1e-6, + qUtf8Printable(QStringLiteral("%1 — cycle %2 : %3").arg(c.quoi).arg(k).arg(traces.at(i)))); + } +#endif +} + +void Simulation::testOffPeakFullPowerStaysUnderTheLimitOnABlindMeter() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // LES PHASES DU COMPTEUR NE VOIENT PAS LA BORNE. Le retour du courant suppose le contraire, et + // c'est là qu'il pourrait mentir : la borne annonce 13 A que la phase ne montre pas, et + // « maison sans la borne » vaudrait 2 − 13 = −11 A. Sans garde, la place rendue serait + // 16 + 11 − 1 = 26 A, et la borne seule irait à la limite. Écrêtée à zéro, la maison sans la + // borne vaut au moins 0 : la borne reste à 1 A sous la limite. + // + // Le TOTAL du compteur, lui, contient la borne. Ce n'est pas un compteur réel, c'est un banc + // pour la garde : un compteur aveugle aussi au total ferait fabriquer au recrédit un surplus + // de la taille de la borne, et la passe confort l'ajouterait au plafond — un autre écart, + // ouvert (DESIGN_HC_BATTERIE §8.1, écart 4), qui masquerait celui-ci. + // Borne monophasée 16 A, limite 16 A, maison à 2 A par phase. + FuseauDeTest fuseau("Europe/Paris"); + ScenarioHc s = preparerScenarioHc(QStringLiteral("compteur-aveugle"), BatterieHc::Aucune, hphcT4(), + BorneHc::Monophasee); + QVERIFY(s.arb && s.borne); + QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 16}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + s.borne->setStateValue("pluggedIn", true); + const auto phasesAveugles = [&]() { + for (const char *ph : {"A", "B", "C"}) { + s.meter->setStateValue(QStringLiteral("currentPhase%1").arg(ph), 2.0); + s.meter->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), 460.0); + } + return 1380.0 + s.borne->stateValue("currentPower").toDouble(); + }; + QList consignes; + QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(21, 0)); + for (int k = 0; k < 6; ++k, a = a.addSecs(360)) { + cycleHc(s, a, phasesAveugles()); + consignes << consigneA(s.borne); + } + // 16 − 2 − 1 = 13 A à l'arrêt ; puis la borne tire 13 A que la phase ne montre pas, et la + // place rendue plafonne à 16 − 0 − 1 = 15 A. + QCOMPARE(consignes, (QList{13, 15, 15, 15, 15, 15})); +#endif +} diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 115645c..cc97c80 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -283,9 +283,17 @@ private slots: void testOffPeakChargesAtFullPower(); //! Lot e2 — borne à bascule : en heures creuses, trois phases même quand le compteur lit 0. void testOffPeakFullPowerSwitchesToThreePhases(); - //! Lot e2 — limite de phase : la borne est réduite, jamais sous son plancher par la stratégie ; - //! la marge d'une phase ne bride pas une triphasée (LM-1500-h) ; L4 coupe au dépassement. + //! Lot e2 — limite de phase, compteur qui voit la borne : la borne est réduite, jamais sous son + //! plancher par la stratégie ; la marge d'une phase ne bride pas une triphasée (LM-1500-h) ; le + //! courant rendu est le MESURÉ ; en dépassement la consigne baisse ; L4 coupe au dépassement. void testOffPeakFullPowerYieldsToThePhaseLimit(); + //! Lot e2, suite — compteur qui voit la borne, maison à 2 A : la consigne se pose sans + //! alterner (mono et tri, limites 25 et 32 A), et sous une limite de 16 A elle se pose dessous, + //! à l'ampère inférieur. + void testOffPeakFullPowerSettlesUnderThePhaseLimit(); + //! Lot e2, suite — compteur qui ne voit pas la borne : le retour de son courant ne la mène pas + //! seule à la limite (maison sans la borne écrêtée à zéro). + void testOffPeakFullPowerStaysUnderTheLimitOnABlindMeter(); //! 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. From 1302e9a04ee53d64750993d776347c8a370c356a Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 25 Oct 2026 07:15:36 +0100 Subject: [PATCH 13/26] =?UTF-8?q?docs=20:=20heures=20creuses=20et=20batter?= =?UTF-8?q?ie=20=E2=80=94=20d=C3=A9cision=20D8,=20la=20consigne=20se=20pos?= =?UTF-8?q?e=20pr=C3=A8s=20de=20la=20limite=20de=20phase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - §8 : D8 (2026-10-25) — en heures creuses, le plafond de la borne lui rend son courant mesuré, moins 1 A par phase ; la tenue au plancher en surcharge (L4 décide) est confirmée. - §8.1 : écarts ouverts 3 (double écrêtage hors heures creuses, lot à part) et 4 (partage achat/surplus à l'aube, avec la dérogation) ; l'oscillation renvoie à §8.2. - §8.2 : la règle, la marge, la sécurité en dépassement, les hypothèses, les mesures avant/après et les preuves. Co-Authored-By: Claude Opus 5.5 --- docs/DESIGN_HC_BATTERIE.md | 103 +++++++++++++++++++++++++++++++------ 1 file changed, 87 insertions(+), 16 deletions(-) diff --git a/docs/DESIGN_HC_BATTERIE.md b/docs/DESIGN_HC_BATTERIE.md index ae064f4..821d2b2 100644 --- a/docs/DESIGN_HC_BATTERIE.md +++ b/docs/DESIGN_HC_BATTERIE.md @@ -50,8 +50,9 @@ verrous gardent la tête : ils diraient la même chose en heures creuses. **En heures creuses**, le plancher est servi au réseau par la passe éco (`ECO_FLOOR_GRID`), bornée par l'autorisation de soutirage ; le confort ajoute le surplus s'il y en a. *Lot e2 : c'est désormais le plafond, sous `EV_OFF_PEAK_FULL_POWER` — §8, D7.* La borne -déclare un plafond écrêté par `evPhaseAllowanceW()`, `verifyOverloadProtection()` garde le dernier -mot : l'invariant 2 tient sans rien ajouter. +déclare un plafond écrêté par `evPhaseAllowanceW()` — en heures creuses par +`evOffPeakAllowanceW()`, qui lui rend son courant mesuré moins 1 A (§8.2, D8) —, et +`verifyOverloadProtection()` garde le dernier mot : l'invariant 2 tient sans rien ajouter. **L'échéance** : la puissance nécessaire pour tenir une échéance reste celle du proxy amont, qui force la charge à pleine puissance au point de non-retour. Ce point est calculé sur l'échéance, @@ -280,6 +281,7 @@ aucune ne change le code. | D5 | Seuil de « la voiture charge réellement » : 230 W sur la somme des bornes (§3.2) ? | **Accepté.** | | D6 | Plugin SunSpec : une action « limite de décharge » en une séquence (`planBatteryMode(NO_DISCHARGE)`), et `enableDischarging` renommé ou redécrit (§3.1, §7) ? | **Oui aux deux**, dans le dépôt du plugin, à la charge de l'orchestrateur. Le garde ne change pas : il reste compatible avec les deux actions actuelles (`dischargingRate` puis `enableDischarging`). | | D7 | À quelle puissance la borne charge-t-elle en heures creuses (lot e2) ? | **À la pleine puissance permise**, et non plus au plancher. La session finit plus tôt, et la décharge de la batterie n'est bloquée que pendant ce temps-là : ensuite, elle couvre la maison pour le reste de la nuit. Détail en §8.1. | +| D8 | *(2026-10-25)* Près de la limite de phase, la consigne de la borne alterne à chaque cycle (16, 7, 16, 7 A sous 25 A). Corriger avant la vitrine ? | **Oui, maintenant.** En heures creuses, le plafond de la borne lui rend ce qu'elle tire, **mesuré**, moins 1 A par phase. Détail en §8.2. Le même jour : la borne **tenue au plancher** en surcharge (L4 décide de couper) est **confirmée** ; les écarts 3 et 4 de §8.1 sont reportés. | La mesure du minuteur de retour sur un vrai GEN24 (hypothèse AC-10 du plugin, §3.4) reste ouverte : elle relève du plugin et d'un essai sur matériel réel. @@ -295,7 +297,8 @@ pleine le matin, et le surplus solaire part au réseau. réseau en heures creuses est le **plafond de cycle** de la borne (`declared.maxPowerW`) : - il porte le courant maximal de la borne, ses phases, et la limite de phase - (`evPhaseAllowanceW()`) — rien n'est retiré de ces écrêtages ; + (`evPhaseAllowanceW()` ; depuis D8, `evOffPeakAllowanceW()`, §8.2) — rien n'est retiré de ces + écrêtages ; - il ne descend jamais sous le plancher : la limite de phase **rabaisse** la borne, elle ne la coupe pas ; couper reste le geste de `verifyOverloadProtection()` (L4), qui garde le dernier mot ; @@ -321,20 +324,26 @@ la nuit réduit le plafond au cycle suivant ; sous le plancher, la borne est ten L4 coupe au dépassement. Cette exemption ne vaut **qu'en heures creuses** : sans heure creuse, le plancher éco d'une borne reste écrêté par la marge de la maison, comme avant. -**Reste à trancher** (rien n'est changé par ce lot) : +**Surcharge au plancher — confirmé le 2026-10-25.** En heures creuses, quand la limite de phase +ne laisse plus le plancher, la stratégie tient la borne à son plancher (`boundBy: floor`) et L4 +décide de la couper, comme pour la dérogation. On n'est pas revenu au « rien acheté » du lot e1. -- *Le double écrêtage hors heures creuses.* Le plancher éco d'une borne triphasée, sans heure - creuse, est encore comparé à la marge d'une phase. L'étendre à `LM-1500-h` changerait des - décisions sans tarif : c'est un choix à faire à part. -- *L'oscillation près de la limite de phase.* Le plafond d'une borne est la place libre **sans - lui rendre ce qu'elle tire** (`evPhaseAllowanceW()`, choix de sécurité documenté). À pleine - puissance, la borne voit donc sa propre consommation lui réduire le plafond au cycle suivant, - puis le lui rendre au cycle d'après. En simulation, avec un compteur qui voit la borne (maison - à 2 A par phase, borne triphasée 16 A), la consigne alterne à chaque cycle entre 16 et 7 A - sous une limite de 25 A, et entre 16 et 14 A sous 32 A. Au plancher (lot e1), le phénomène ne - se voyait pas. Une piste : rendre à la borne, en heures creuses, son propre courant moins une - marge (la règle de l'échéance chez l'amont, `adjustEvChargers()` : marge − 1 A + courant de la - borne). +**Écarts ouverts** (rien n'est changé ; décision du 2026-10-25 : plus tard, chacun dans son lot) : + +- *Écart 3 — le double écrêtage hors heures creuses.* Sans heure creuse, le plancher éco d'une + borne triphasée est encore comparé à la marge d'**une** phase de la maison (le double + écrêtage que `LM-1500-h` refuse ailleurs). L'étendre changerait des décisions sans tarif : + **lot à part**. +- *Écart 4 — le partage achat/surplus en heures creuses.* À l'aube, le surplus s'ajoute au + niveau éco : la consigne (`estimatedPowerW`) peut dépasser le plafond de cycle, par exemple + 3 680 + 2 000 W. L'adaptateur écrête au courant maximal de la borne, et le partage publié entre + achat et surplus surestime l'achat. Le même cumul existe pour la dérogation. Ce cumul n'est + pas borné par le plafond de phase déjà servi au niveau éco : constaté au lot D8 avec un + compteur qui ne voit pas la borne (le recrédit y fabrique du surplus), plafond 15 A, consigne + 16 A. Avec un vrai surplus, seul L4 rattraperait un dépassement. **À reprendre avec la + dérogation.** +- *L'oscillation près de la limite de phase* était le troisième point de cette liste. Elle est + corrigée : §8.2. **Fin de session.** Le blocage de la décharge se lève sur la puissance **mesurée** des bornes, au seuil de 230 W (D5). Une borne commandée à 16 A dont la voiture ne prend plus rien (pleine, @@ -358,3 +367,65 @@ la maison consommera d'ici le retour du soleil — consommation de base prévue heure de la première production utile (prévision PV). Elle demande une prévision fiable des deux, et un pilotage de `OutWRte` plus fin qu'un tout-ou-rien : c'est un lot à part. +### 8.2 D8 — près de la limite de phase, la consigne se pose (2026-10-25) + +**Le défaut.** Le plafond d'une borne était la place libre au compteur, **sans lui rendre ce +qu'elle tire** (`evPhaseAllowanceW()`). Avec un compteur qui voit la borne, sa propre +consommation lui retirait la place au cycle suivant, puis la lui rendait au cycle d'après. Mesuré +en simulation, maison à 2 A par phase, borne 16 A, mono comme tri : + +| Limite | Avant (consigne cycle par cycle) | Après | +|---|---|---| +| 25 A | 16, 7, 16, 7… (11,5 A en moyenne) | 16 A tenus | +| 32 A | 16, 14, 16, 14… | 16 A tenus | +| 16 A | 14, 6, 8, 6, 8… (7 A en moyenne) | 13 A tenus, 15 A au compteur | + +**La règle.** En heures creuses, et pour la pleine puissance du lot e2 seulement, le plafond est +lu par `EnergyArbitrator::evOffPeakAllowanceW()` : + +> plafond par phase = limite − (tirage de la phase − courant **mesuré** de la borne) − **1 A**, +> arrondi à l'ampère inférieur, × phases. + +C'est la règle de l'échéance chez l'amont (`adjustEvChargers()`). Elle converge en **un** cycle : +le résultat ne dépend plus de ce que la borne tirait, seulement de ce que tire le reste de la +maison. + +- *Mesure, pas consigne.* Le courant rendu est l'état `currentPower` de la borne, sur les phases + où elle le mesure. Une voiture qui prend 10 A d'une borne commandée à 16 n'occupe que 10 A : + lui rendre 16 lui ouvrirait 6 A de trop. Une borne sans mesure, à l'arrêt, ou qui bascule de + phases (mesurées ≠ visées) ne rend rien, et la marge s'applique quand même. +- *La marge : 1 A par phase.* C'est celle de l'amont, pour la même raison : laisser à un appareil + moyen allumé entre deux cycles la place de démarrer avant que L4 ait à couper. Elle couvre aussi + l'écart entre la puissance annoncée par la borne et le courant lu au compteur (tension ≠ 230 V, + ±3 % à 16 A, soit 0,5 A). L'arrondi à l'ampère **inférieur** garantit qu'elle n'est pas mangée + par l'adaptateur, qui arrondit la consigne au plus proche. Plus grande, elle coûterait 230 W + par phase toute la nuit, sans raison mesurée ; plus petite, elle ne couvrirait plus l'écart de + mesure. +- *La sécurité en dépassement tient.* C'était la raison de ne rien rendre. Si une phase tire + `I > limite`, dont `E` pour la borne, la place rendue vaut `limite − (I − E) − 1 < E − 1` : la + consigne baisse d'au moins 1 A par cycle, jusqu'à sortir du dépassement ou jusqu'au plancher, où + la stratégie tient la borne et où L4 décide. Cela suppose que le compteur voie la borne — ce + que L4 suppose déjà. +- *Un compteur qui ne voit pas la borne* ne la mène pas seule à la limite : le tirage de la maison + sans la borne est écrêté à zéro, donc une phase vaut au plus sa limite moins la marge. +- *Où.* Seulement là où `EvAdapter` vise la pleine puissance d'heures creuses (trois modes, heure + creuse en cours). Hors heures creuses et sans tarif, `evPhaseAllowanceW()` reste la règle, et + les décisions sans tarif ne changent pas. En heures creuses, une dérogation sur la borne lit le + même plafond (c'est le même `declared.maxPowerW`). Sans heure creuse, la dérogation garde + l'ancienne règle : la faire passer par la même voie changerait son comportement sans tarif, et + ce n'est pas décidé. + +**Hypothèses.** Les phases de la borne sont celles du compteur (même hypothèse que L4 et que +l'amont). Une borne qui annonce plus que ce que le compteur voit d'elle reçoit au plus 1 A sous la +limite. Un compteur rafraîchi après la borne (borne qui monte, compteur pas encore) peut +surestimer la place d'un cycle, dans la même limite ; L4, lui, lit le compteur en temps réel. + +**Preuves.** `testOffPeakFullPowerSettlesUnderThePhaseLimit` (sept cas, douze cycles : la consigne +se pose dès le premier cycle, la phase la plus chargée reste à 1 A au moins sous la limite ; un +cas à 2,4 A de maison prouve l'arrondi) ; `testOffPeakFullPowerYieldsToThePhaseLimit`, réécrit +avec un compteur qui voit la borne (mesure et non consigne, dépassement, plancher, coupure par +L4) ; `testOffPeakFullPowerStaysUnderTheLimitOnABlindMeter` (écrêtage à zéro) ; +`testOverloadProtectionEcoMode`, hors heures creuses, inchangé. Dans +`testBlockedDischargeShowsAtTheMeterAndL4Decides`, la borne en dépassement finit à son plancher +(L4 la ramène de 16 à 13 A, la stratégie laisse 9 A, L4 la ramène à 6 A) au lieu d'être coupée : +l'assertion attend désormais « plancher ou coupée ». From b1c0adec006351e06c760b2d71a8447a56c8fefb Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 25 Oct 2026 07:27:13 +0100 Subject: [PATCH 14/26] =?UTF-8?q?decisions=20:=20r=C3=A9f=C3=A9rence=20?= =?UTF-8?q?=C3=A9tendue=20au=20lot=20e2,=20suite=20(consigne=20pos=C3=A9e?= =?UTF-8?q?=20pr=C3=A8s=20de=20la=20limite=20de=20phase)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sans tarif à heures creuses : 79 fonctions, 24 056 lignes identiques octet pour octet à d62365f. En heures creuses, deux fonctions changent (testBlockedDischargeShowsAtTheMeterAndL4Decides, 2 cycles ; testOffPeakFullPowerYieldsToThePhaseLimit, réécrit), plus les deux nouvelles. check et ETM_DECISIONS_TRACES=1 check : IDENTIQUE (24 168 lignes ; 22 254 lignes de trace). Co-Authored-By: Claude Opus 5.5 --- .../decisions/reference/decisions.ndjson.xz | Bin 567408 -> 569220 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/tests/auto/decisions/reference/decisions.ndjson.xz b/tests/auto/decisions/reference/decisions.ndjson.xz index f41a2b9d0b24fa1cd58a7f2a3e228ed2fdd2cd63..dfcc9e6d0ab75957e3d38e1975bdcfb86096446f 100644 GIT binary patch delta 4958 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 delta 3131 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 V001cCilP)3J1_$P00004Sz10hCiwsW From e28e6375fd464b6e8537836a6272a0d3db576d81 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 25 Oct 2026 09:41:48 +0100 Subject: [PATCH 15/26] =?UTF-8?q?Heures=20creuses=20:=20la=20somme=20?= =?UTF-8?q?=C3=A9co=20+=20confort=20de=20la=20borne=20tient=20dans=20son?= =?UTF-8?q?=20plafond=20(=C3=A9cart=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit En heures creuses, le niveau éco d'une borne à pleine puissance vaut son plafond de cycle, qui porte la limite de phase. À l'aube, la passe confort lui ajoutait le surplus, et seul le courant maximal de la borne écrêtait la somme : borne monophasée sur A, limite 16 A, plafond 13 A, 2 540 W de surplus — 16 A servis, la phase A à 18 A. L'achat publié valait 2 990 W quand le compteur n'importait que 450 W. Pour une borne qui porte EV_OFF_PEAK_FULL_POWER : - la passe confort sert le surplus DANS le plafond ; - l'achat cède ce que le surplus finance (gridW, drawCommittedW, draw.committedW) : la somme reste le plafond ; - la part confort n'a pas de plancher : il porte sur la somme, qui vaut le plafond. Sinon un surplus sous le plancher serait refusé, ou démarré par EV_GRID_START, un second achat en plus du premier. Hors heures creuses et sans tarif, rien ne change. Tests : testOffPeakFullPowerAtDawnStaysInTheCeiling (nuit, aube, plein soleil : 13 A, achat 2 990 / 450 / 0 W, achat ≤ import du compteur) ; testOffPeakFullPowerStaysUnderTheLimitOnABlindMeter étendu au total aveugle (13, 15, 15… au lieu de 13, 16, 16…) ; testOffPeakFullPowerGivesNothingBackWhileSwitchingPhases, la garde de bascule enfin éprouvée (12 A, phase B à 24 A ; sans elle 15 A, B à 27 A). Co-Authored-By: Claude Opus 5.5 --- .../etm/scheduler/rulebasedscheduler.cpp | 30 ++- tests/auto/simulation/simulation.cpp | 210 ++++++++++++++++-- tests/auto/simulation/simulation.h | 8 +- 3 files changed, 227 insertions(+), 21 deletions(-) diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index bf83b2f..bf1ca82 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -301,6 +301,9 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) // donc après un éco de rang 3 — c'est le sens même des deux passes, et le publier sous forme // de niveaux est ce qui rend cet ordre lisible sans qu'un client ait à le redériver. QHash niveauxEco; + //! Lot e2, suite (écart 4) — les bornes servies à leur plafond de cycle en heures creuses. En + //! passe confort, leur surplus PREND la place de l'achat au lieu de s'y ajouter. + QSet plafondPartage; // --- TÊTE DE PASSE ÉCO — les DÉROGATIONS -------------------------------------------------- // @@ -538,6 +541,7 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) : QStringLiteral("phaseLimit")); } eco.reason = { DecisionCode::EvOffPeakFullPower, pp }; + plafondPartage.insert(lc.id); } // SOUS LE PREMIER PALIER, rien n'est acheté. Une charge à paliers ne délivre que ses @@ -598,11 +602,22 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) waterfallEvIds.insert(lc.id); continue; } + // Lot e2, suite (écart 4, décision du 2026-10-25) — LA SOMME ÉCO + CONFORT EST BORNÉE AU + // PLAFOND DE CYCLE. Le niveau éco vaut déjà ce plafond, qui porte la limite de phase ; à + // l'aube, le surplus s'y ajoutait, et seul le courant maximal de la borne écrêtait la + // somme : 13 A de plafond plus 2 540 W de surplus faisaient 16 A sous une limite de 16 A. + // Le confort est donc servi DANS le plafond, et l'achat cède ce que le surplus finance + // (plus bas, à la fusion). Le plancher ne s'applique qu'à la somme, qui vaut le plafond, + // donc jamais moins que lui : la part confort n'en a pas — sans quoi un surplus sous le + // plancher serait refusé, ou démarré par EV_GRID_START, un second achat en plus du premier. + LoadContext lcConfort = lc; + if (plafondPartage.contains(lc.id)) + lcConfort.declared.minPowerW = 0; if (lc.adapter == QStringLiteral("sg-ready")) slot.actions.append(buildSgReadyStateAction(lc, remainingSurplusW, compta)); else slot.actions.append(emitSetpointAction( - decideSetpoint(lc, remainingSurplusW, margeRestante, compta, draw))); + decideSetpoint(lcConfort, remainingSurplusW, margeRestante, compta, draw))); // etmvariableload + relay-router + evcharger // Le décrément vaut pour TOUTES les charges servies, bornes et PAC comprises : elles @@ -614,8 +629,19 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) // court-cycling fabriqué par l'arbitre lui-même. if (!slot.actions.isEmpty() && niveauxEco.contains(lc.id)) { LoadAction &la = slot.actions.last(); - const LoadAction::Level eco = niveauxEco.take(lc.id); + LoadAction::Level eco = niveauxEco.take(lc.id); const double confortW = la.estimatedPowerW; // la part confort, avant l'addition + if (plafondPartage.contains(lc.id)) { + // L'ACHAT CÈDE CE QUE LE SURPLUS FINANCE : la somme reste le plafond, et l'achat + // publié (`gridW`, `drawCommittedW`, `draw.committedW`) est ce qui reste à acheter + // — jamais plus que le compteur n'importe. La marge de soutirage n'est pas rendue : + // la ligne qui suit la fusion décompte la consigne entière, prudence inchangée. + const double financeW = qBound(0.0, confortW, eco.targetW); + eco.targetW -= financeW; + eco.counts[CountKey::DrawCommitted] = eco.targetW; + eco.reason.params.insert(QStringLiteral("gridW"), qRound(eco.targetW)); + draw.committedW -= financeW; + } la.levels.prepend(eco); // éco AVANT confort : l'ordre des passes la.powerW += eco.targetW; la.estimatedPowerW += eco.targetW; diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 35be5ab..260ee51 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -11711,34 +11711,208 @@ void Simulation::testOffPeakFullPowerStaysUnderTheLimitOnABlindMeter() // 16 + 11 − 1 = 26 A, et la borne seule irait à la limite. Écrêtée à zéro, la maison sans la // borne vaut au moins 0 : la borne reste à 1 A sous la limite. // - // Le TOTAL du compteur, lui, contient la borne. Ce n'est pas un compteur réel, c'est un banc - // pour la garde : un compteur aveugle aussi au total ferait fabriquer au recrédit un surplus - // de la taille de la borne, et la passe confort l'ajouterait au plafond — un autre écart, - // ouvert (DESIGN_HC_BATTERIE §8.1, écart 4), qui masquerait celui-ci. + // Deux variantes. Dans la première, le TOTAL du compteur contient la borne : c'est le banc de + // la garde. Dans la seconde, il ne la voit pas non plus, et le recrédit fabrique alors un + // surplus de la taille de la borne. Avant que la somme éco + confort soit bornée au plafond + // (écart 4, décision du 2026-10-25), la passe confort l'ajoutait au plafond de 15 A, et la + // borne recevait 16 A sous une limite de 16 A : 13, 16, 16… Le plafond tient désormais. // Borne monophasée 16 A, limite 16 A, maison à 2 A par phase. FuseauDeTest fuseau("Europe/Paris"); - ScenarioHc s = preparerScenarioHc(QStringLiteral("compteur-aveugle"), BatterieHc::Aucune, hphcT4(), + for (const bool totalVoitLaBorne : {true, false}) { + const char *quoi = totalVoitLaBorne ? "total qui voit la borne" : "total aveugle"; + ScenarioHc s = preparerScenarioHc(QStringLiteral("compteur-aveugle-%1").arg(totalVoitLaBorne), + BatterieHc::Aucune, hphcT4(), BorneHc::Monophasee); + QVERIFY2(s.arb && s.borne, quoi); + QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 16}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + s.borne->setStateValue("pluggedIn", true); + const auto phasesAveugles = [&]() { + for (const char *ph : {"A", "B", "C"}) { + s.meter->setStateValue(QStringLiteral("currentPhase%1").arg(ph), 2.0); + s.meter->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), 460.0); + } + return 1380.0 + (totalVoitLaBorne ? s.borne->stateValue("currentPower").toDouble() : 0.0); + }; + QList consignes; + QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(21, 0)); + for (int k = 0; k < 6; ++k, a = a.addSecs(360)) { + cycleHc(s, a, phasesAveugles()); + consignes << consigneA(s.borne); + } + qInfo().noquote() << "[compteur aveugle]" << quoi << ":" << consignes; + // 16 − 2 − 1 = 13 A à l'arrêt ; puis la borne tire 13 A que la phase ne montre pas, et la + // place rendue plafonne à 16 − 0 − 1 = 15 A — surplus fabriqué ou non. + QVERIFY2(consignes == (QList{13, 15, 15, 15, 15, 15}), quoi); + } +#endif +} + +void Simulation::testOffPeakFullPowerAtDawnStaysInTheCeiling() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // L'AUBE EN HEURES CREUSES (écart 4, décision du 2026-10-25). Le niveau éco d'une borne y vaut + // son plafond de cycle, qui porte la limite de phase. Le surplus du matin s'y AJOUTAIT, et + // seul le courant maximal de la borne écrêtait la somme : la limite de phase était perdue. + // + // Le cas rare du lot e2 : borne monophasée sur la phase A, limite 16 A, maison à 2 A sur A ; + // les phases B et C exportent 1 500 W chacune. Plafond : 16 − 2 − 1 = 13 A (2 990 W). Borne à + // 13 A, le compteur importe 460 + 2 990 − 3 000 = 450 W ; le surplus recrédité vaut 2 540 W. + // Avant : 2 990 + 2 540 W, soit 24 A demandés, 16 A servis, la phase A à 18 A pour 16. + // Après : 13 A, dont 2 540 W de surplus et 450 W achetés — ce que le compteur importe. + FuseauDeTest fuseau("Europe/Paris"); + ScenarioHc s = preparerScenarioHc(QStringLiteral("aube"), BatterieHc::Aucune, hphcT4(), BorneHc::Monophasee); QVERIFY(s.arb && s.borne); QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 16}})) .toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError")); s.borne->setStateValue("pluggedIn", true); - const auto phasesAveugles = [&]() { - for (const char *ph : {"A", "B", "C"}) { - s.meter->setStateValue(QStringLiteral("currentPhase%1").arg(ph), 2.0); - s.meter->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), 460.0); + const QString borne = s.borneId.toString(); + + //! Le compteur voit la borne sur A ; B et C portent chacune \p exportPhaseW d'export. + const auto compteur = [&](double exportPhaseW) { + const double a = 2.0 + s.borne->stateValue("currentPhaseA").toDouble(); + s.meter->setStateValue("currentPhaseA", a); + s.meter->setStateValue("currentPowerPhaseA", a * 230); + for (const char *ph : {"B", "C"}) { + s.meter->setStateValue(QStringLiteral("currentPhase%1").arg(ph), -exportPhaseW / 230); + s.meter->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), -exportPhaseW); } - return 1380.0 + s.borne->stateValue("currentPower").toDouble(); + return a * 230 - 2 * exportPhaseW; }; - QList consignes; - QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(21, 0)); - for (int k = 0; k < 6; ++k, a = a.addSecs(360)) { - cycleHc(s, a, phasesAveugles()); - consignes << consigneA(s.borne); + //! La part d'un niveau, et son motif. + const auto niveau = [&](const QVariantMap &snap, const QString &quel) { + for (const QVariant &n : telemetrieParId(snap).value(borne).value("levels").toList()) + if (n.toMap().value("level").toString() == quel) + return n.toMap(); + return QVariantMap(); + }; + + struct Etape { const char *quoi; double exportPhaseW; double achatW; double surplusW; }; + const QList etapes = { + // Témoin de nuit : rien à partager, tout est acheté — comme avant. + {"nuit", 0, 2990, 0}, + // L'aube : le surplus finance 2 540 W, l'achat cède d'autant. + {"aube", 1500, 450, 2540}, + // Plein soleil : le surplus dépasse le plafond. Rien n'est acheté, la borne reste à 13 A. + {"plein soleil", 3000, 0, 2990}, + }; + QDateTime a = utcDateTime(QDate(2026, 6, 9), QTime(2, 30)); // 04:30 à Paris + for (const Etape &e : etapes) { + QVariantMap snap; + QStringList trace; + for (int k = 0; k < 4; ++k) { // quatre cycles : la consigne se pose et TIENT + a = a.addSecs(360); + snap = cycleHc(s, a, compteur(e.exportPhaseW)); + const double importW = compteur(e.exportPhaseW); // ce que la consigne produit + const QVariantMap eco = niveau(snap, "eco"); + const QVariantMap confort = niveau(snap, "comfort"); + trace << QStringLiteral("%1 A, achat %2 W, surplus %3 W, import %4 W, phase A %5 A") + .arg(consigneA(s.borne)).arg(eco.value("targetW").toDouble()) + .arg(confort.value("targetW").toDouble()).arg(importW) + .arg(s.meter->stateValue("currentPhaseA").toDouble()); + const QByteArray ctx = QStringLiteral("%1 — %2").arg(e.quoi, trace.join(" ; ")).toUtf8(); + + // LA CONSIGNE ≤ LE PLAFOND, et la phase sous la limite, avec la marge. + QVERIFY2(consigneA(s.borne) == 13, ctx.constData()); + QVERIFY2(s.meter->stateValue("currentPhaseA").toDouble() <= 15 + 1e-6, ctx.constData()); + QVERIFY2(qRound(telemetrieParId(snap).value(borne).value("allocatedW").toDouble()) == 2990, + ctx.constData()); + + // LE PARTAGE : la somme vaut le plafond, l'achat ce qui reste à acheter — et jamais plus + // que ce que le compteur importe une fois la consigne posée. + QVERIFY2(qRound(eco.value("targetW").toDouble()) == qRound(e.achatW), ctx.constData()); + QVERIFY2(qRound(confort.value("targetW").toDouble()) == qRound(e.surplusW), ctx.constData()); + QVERIFY2(eco.value("targetW").toDouble() <= qMax(0.0, importW) + 0.5, ctx.constData()); + QVERIFY2(qRound(eco.value("counts").toMap().value(CountKey::DrawCommitted).toDouble()) + == qRound(e.achatW), ctx.constData()); + QVERIFY2(qRound(snap.value("draw").toMap().value("committedW").toDouble()) == qRound(e.achatW), + ctx.constData()); + const QVariantMap motifEco = eco.value("decision").toMap(); + QVERIFY2(motifEco.value("code").toString() == QString(DecisionCode::EvOffPeakFullPower), + ctx.constData()); + QVERIFY2(motifEco.value("params").toMap().value("gridW").toInt() == qRound(e.achatW), + ctx.constData()); + QVERIFY2(motifEco.value("params").toMap().value("boundBy").toString() == QStringLiteral("phaseLimit"), + ctx.constData()); + } + qInfo().noquote() << "[aube]" << e.quoi << ":" << trace.join(QStringLiteral(" ; ")); } - // 16 − 2 − 1 = 13 A à l'arrêt ; puis la borne tire 13 A que la phase ne montre pas, et la - // place rendue plafonne à 16 − 0 − 1 = 15 A. - QCOMPARE(consignes, (QList{13, 15, 15, 15, 15, 15})); +#endif +} + +void Simulation::testOffPeakFullPowerGivesNothingBackWhileSwitchingPhases() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // LA GARDE DE BASCULE (question 3 du lot e2, suite). Pendant une bascule, la borne tire sur + // une phase et vise les trois : rendre son courant sur la phase où elle le tire ne dit rien + // des deux autres. Borne triphasée à bascule, encore en monophasé à 10 A sur A, limite 25 A ; + // la maison tire 2 A sur A et C, 12 A sur B. Au compteur : A = 12, B = 12, C = 2 A. + // + // Avec la garde, rien n'est rendu : 25 − 12 − 1 = 12 A sur trois phases, et B finit à 24 A. + // Rendre les 10 A quand même (sur A seule, ou répartis sur les trois) donnerait 15 A, et B + // finirait à 27 A pour 25 — une place prise sur une phase que la borne n'occupait pas. + FuseauDeTest fuseau("Europe/Paris"); + ScenarioHc s = preparerScenarioHc(QStringLiteral("garde-bascule"), BatterieHc::Aucune, hphcT4(), + BorneHc::Bascule); + QVERIFY(s.arb && s.borne); + QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 25}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + const QString borne = s.borneId.toString(); + const QList maisonA = {2, 12, 2}; + //! Le compteur voit la borne : la maison, plus ce que la borne tire, phase par phase. + const auto compteur = [&]() { + double totalW = 0; + const QStringList phases = {"A", "B", "C"}; + for (int i = 0; i < 3; ++i) { + const double a = maisonA.at(i) + + s.borne->stateValue(QStringLiteral("currentPhase%1").arg(phases.at(i))).toDouble(); + s.meter->setStateValue(QStringLiteral("currentPhase%1").arg(phases.at(i)), a); + s.meter->setStateValue(QStringLiteral("currentPowerPhase%1").arg(phases.at(i)), a * 230); + totalW += a * 230; + } + return totalW; + }; + + // La borne charge en monophasé à 10 A : mesurée sur A seule, une phase, quand le cycle en vise trois. + s.borne->setStateValue("pluggedIn", true); + s.borne->setStateValue("power", true); + s.borne->setStateValue("charging", true); + s.borne->setStateValue("desiredPhaseCount", 1); + s.borne->setStateValue("usedPhases", QStringLiteral("A")); + s.borne->setStateValue("phaseCount", 1); + s.borne->setStateValue("maxChargingCurrent", 10); + s.borne->setStateValue("currentPhaseA", 10.0); + s.borne->setStateValue("currentPowerPhaseA", 2300.0); + for (const char *ph : {"B", "C"}) { + s.borne->setStateValue(QStringLiteral("currentPhase%1").arg(ph), 0.0); + s.borne->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), 0.0); + } + s.borne->setStateValue("currentPower", 2300.0); + + const QVariantMap snap = cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(21, 0)), compteur()); + const QVariantMap d = telemetrieParId(snap).value(borne).value("decision").toMap(); + compteur(); // ce que la consigne produit, sur les trois phases + const QString trace = QStringLiteral("phases %1, consigne %2 A, retenu %3 W, au compteur A %4 B %5 C %6 A") + .arg(s.borne->stateValue("desiredPhaseCount").toUInt()).arg(consigneA(s.borne)) + .arg(d.value("params").toMap().value("gridW").toInt()) + .arg(s.meter->stateValue("currentPhaseA").toDouble()) + .arg(s.meter->stateValue("currentPhaseB").toDouble()) + .arg(s.meter->stateValue("currentPhaseC").toDouble()); + qInfo().noquote() << "[garde de bascule]" << trace; + + QVERIFY2(d.value("code").toString() == QString(DecisionCode::EvOffPeakFullPower), qUtf8Printable(trace)); + QVERIFY2(s.borne->stateValue("desiredPhaseCount").toUInt() == 3u, qUtf8Printable(trace)); + QVERIFY2(d.value("params").toMap().value("gridW").toInt() == 12 * 690, qUtf8Printable(trace)); + QVERIFY2(consigneA(s.borne) == 12, qUtf8Printable(trace)); + for (const char *ph : {"A", "B", "C"}) + QVERIFY2(s.meter->stateValue(QStringLiteral("currentPhase%1").arg(ph)).toDouble() <= 24 + 1e-6, + qUtf8Printable(trace)); #endif } diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index cc97c80..2a0fe77 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -292,8 +292,14 @@ private slots: //! à l'ampère inférieur. void testOffPeakFullPowerSettlesUnderThePhaseLimit(); //! Lot e2, suite — compteur qui ne voit pas la borne : le retour de son courant ne la mène pas - //! seule à la limite (maison sans la borne écrêtée à zéro). + //! seule à la limite (maison sans la borne écrêtée à zéro) ; total aveugle aussi : le surplus + //! fabriqué ne la porte plus au-delà du plafond (15 A, jamais 16). void testOffPeakFullPowerStaysUnderTheLimitOnABlindMeter(); + //! Lot e2, suite (écart 4) — à l'aube en heures creuses, le surplus prend la place de l'achat + //! dans le plafond, au lieu de s'y ajouter : consigne ≤ plafond, achat ≤ import du compteur. + void testOffPeakFullPowerAtDawnStaysInTheCeiling(); + //! Lot e2, suite — pendant une bascule de phases, le plafond ne rend rien du courant de la borne. + void testOffPeakFullPowerGivesNothingBackWhileSwitchingPhases(); //! 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. From 5c2a8df6d69b0f8ce03aef3b490260bd7fa60b84 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 25 Oct 2026 09:41:52 +0100 Subject: [PATCH 16/26] =?UTF-8?q?docs=20:=20heures=20creuses=20et=20batter?= =?UTF-8?q?ie=20=E2=80=94=20d=C3=A9cision=20D9,=20la=20somme=20=C3=A9co=20?= =?UTF-8?q?+=20confort=20tient=20dans=20le=20plafond?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §8 : D9 (2026-10-25, suite) — l'écart 4 est corrigé pour la pleine puissance d'heures creuses ; la dérogation sans heure creuse et les phases étiquetées restent à leur lot ; le compteur en retard est accepté. §8.1 : l'écart 4 renvoie à §8.3 ; ce qui reste de la dérogation y est dit. §8.3 : la règle, les mesures avant/après, les preuves — dont la garde de bascule de §8.2, désormais éprouvée. Co-Authored-By: Claude Opus 5.5 --- docs/DESIGN_HC_BATTERIE.md | 59 ++++++++++++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/docs/DESIGN_HC_BATTERIE.md b/docs/DESIGN_HC_BATTERIE.md index 821d2b2..5b95c27 100644 --- a/docs/DESIGN_HC_BATTERIE.md +++ b/docs/DESIGN_HC_BATTERIE.md @@ -282,6 +282,7 @@ aucune ne change le code. | D6 | Plugin SunSpec : une action « limite de décharge » en une séquence (`planBatteryMode(NO_DISCHARGE)`), et `enableDischarging` renommé ou redécrit (§3.1, §7) ? | **Oui aux deux**, dans le dépôt du plugin, à la charge de l'orchestrateur. Le garde ne change pas : il reste compatible avec les deux actions actuelles (`dischargingRate` puis `enableDischarging`). | | D7 | À quelle puissance la borne charge-t-elle en heures creuses (lot e2) ? | **À la pleine puissance permise**, et non plus au plancher. La session finit plus tôt, et la décharge de la batterie n'est bloquée que pendant ce temps-là : ensuite, elle couvre la maison pour le reste de la nuit. Détail en §8.1. | | D8 | *(2026-10-25)* Près de la limite de phase, la consigne de la borne alterne à chaque cycle (16, 7, 16, 7 A sous 25 A). Corriger avant la vitrine ? | **Oui, maintenant.** En heures creuses, le plafond de la borne lui rend ce qu'elle tire, **mesuré**, moins 1 A par phase. Détail en §8.2. Le même jour : la borne **tenue au plancher** en surcharge (L4 décide de couper) est **confirmée** ; les écarts 3 et 4 de §8.1 sont reportés. | +| D9 | *(2026-10-25, suite)* L'écart 4 n'est pas qu'un défaut de publication : à l'aube, le surplus s'ajoute au plafond, qui porte la limite de phase. Borner la somme avant la vitrine ? | **Oui, avant la vitrine.** La somme éco + confort ne dépasse jamais `declared.maxPowerW`, et l'achat publié ne dépasse pas ce qui est réellement acheté. Détail en §8.3. La dérogation sans heure creuse, les phases étiquetées : plus tard, chacune dans son lot. Le compteur en retard sur la borne est accepté tel quel. | La mesure du minuteur de retour sur un vrai GEN24 (hypothèse AC-10 du plugin, §3.4) reste ouverte : elle relève du plugin et d'un essai sur matériel réel. @@ -334,14 +335,12 @@ décide de la couper, comme pour la dérogation. On n'est pas revenu au « rien borne triphasée est encore comparé à la marge d'**une** phase de la maison (le double écrêtage que `LM-1500-h` refuse ailleurs). L'étendre changerait des décisions sans tarif : **lot à part**. -- *Écart 4 — le partage achat/surplus en heures creuses.* À l'aube, le surplus s'ajoute au - niveau éco : la consigne (`estimatedPowerW`) peut dépasser le plafond de cycle, par exemple - 3 680 + 2 000 W. L'adaptateur écrête au courant maximal de la borne, et le partage publié entre - achat et surplus surestime l'achat. Le même cumul existe pour la dérogation. Ce cumul n'est - pas borné par le plafond de phase déjà servi au niveau éco : constaté au lot D8 avec un - compteur qui ne voit pas la borne (le recrédit y fabrique du surplus), plafond 15 A, consigne - 16 A. Avec un vrai surplus, seul L4 rattraperait un dépassement. **À reprendre avec la - dérogation.** +- *Écart 4 — le partage achat/surplus en heures creuses.* **Corrigé pour la pleine puissance + d'heures creuses (D9, §8.3).** À l'aube, le surplus s'ajoutait au niveau éco : la consigne + (`estimatedPowerW`) pouvait dépasser le plafond de cycle, et donc la limite de phase qu'il porte. + La dérogation, elle, ne cumule pas (sa passe confort est sautée) ; son achat publié reste + entier quand le soleil la finance en partie — c'est la conséquence comptable déjà écrite dans + le code, et elle reste à reprendre avec la dérogation. - *L'oscillation près de la limite de phase* était le troisième point de cette liste. Elle est corrigée : §8.2. @@ -429,3 +428,47 @@ L4) ; `testOffPeakFullPowerStaysUnderTheLimitOnABlindMeter` (écrêtage à zéro `testBlockedDischargeShowsAtTheMeterAndL4Decides`, la borne en dépassement finit à son plancher (L4 la ramène de 16 à 13 A, la stratégie laisse 9 A, L4 la ramène à 6 A) au lieu d'être coupée : l'assertion attend désormais « plancher ou coupée ». + +### 8.3 D9 — la somme éco + confort tient dans le plafond (2026-10-25, suite) + +**Le défaut (écart 4).** En heures creuses, le niveau éco d'une borne vaut son plafond de cycle +(`declared.maxPowerW`), qui porte la limite de phase. À l'aube, la passe confort lui ajoutait le +surplus, et seul le courant maximal de la borne écrêtait la somme. Mesuré en simulation : borne +monophasée sur A, limite 16 A, maison à 2 A sur A, phases B et C exportant 1 500 W chacune. Le +plafond vaut 13 A ; la consigne montait à 16 A, et la phase A à **18 A pour 16**. Le partage +publiait 2 990 W achetés quand le compteur n'en importait que 450. + +**La règle.** Pour une borne servie à pleine puissance en heures creuses +(`EV_OFF_PEAK_FULL_POWER`) : + +- la passe confort sert le surplus **dans** le plafond, et non par-dessus ; +- l'achat **cède** ce que le surplus finance : achat = plafond − part confort, jamais négatif. + La somme reste donc le plafond, et l'achat publié (`gridW`, `draw.committedW`, `counts`) est ce + qui reste à acheter ; +- la part confort n'a pas de plancher : le plancher porte sur la somme, qui vaut le plafond, donc + jamais moins que lui. Sans cela, un surplus sous le plancher serait refusé, ou démarré par + `EV_GRID_START` — un second achat en plus du premier. + +Le motif suit la règle de la part majoritaire : à l'aube, quand le surplus finance plus de la +moitié du plafond, la décision publiée est celle du confort ; le niveau éco reste lisible dans +`levels`. La marge de soutirage du reste de la cascade est décomptée comme avant (prudence +inchangée). Hors heures creuses et sans tarif, rien ne change : la règle ne s'applique qu'aux +bornes qui portent `EV_OFF_PEAK_FULL_POWER`. + +| Cas (simulation) | Avant | Après | +|---|---|---| +| Aube, 2 540 W de surplus | 16 A, phase A à 18 A ; 2 990 W achetés | 13 A, phase A à 15 A ; 450 W achetés, 2 540 W de surplus | +| Plein soleil, surplus > plafond | 16 A | 13 A ; 0 W acheté, 2 990 W de surplus | +| Nuit | 13 A ; 2 990 W achetés | inchangé | +| Compteur aveugle aussi au total | 13, 16, 16… A | 13, 15, 15… A | + +Avec un compteur aveugle au total, le surplus est **fabriqué** par le recrédit : la consigne +tient le plafond, mais le partage publié, lui, ne peut pas être juste — le compteur ne dit pas ce +qui est acheté. + +**Preuves.** `testOffPeakFullPowerAtDawnStaysInTheCeiling` (nuit, aube, plein soleil, quatre +cycles chacun : consigne et phase, somme, achat ≤ import du compteur) ; +`testOffPeakFullPowerStaysUnderTheLimitOnABlindMeter`, étendu au total aveugle. La garde de +bascule de §8.2 est désormais éprouvée : `testOffPeakFullPowerGivesNothingBackWhileSwitchingPhases` +(borne en monophasé à 10 A qui bascule en triphasé, maison à 12 A sur B, limite 25 A : 12 A, et B +à 24 A ; sans la garde, 15 A et B à 27 A). From 76bf482b21ca1abb6fb7f6d44edcf2374ef9707e Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 25 Oct 2026 09:47:40 +0100 Subject: [PATCH 17/26] =?UTF-8?q?decisions=20:=20r=C3=A9f=C3=A9rence=20?= =?UTF-8?q?=C3=A9tendue=20au=20lot=20e2,=20suite=20(somme=20=C3=A9co=20+?= =?UTF-8?q?=20confort=20dans=20le=20plafond)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sans tarif à heures creuses, IDENTIQUE à b1c0ade : 81 fonctions, octet pour octet, `run` compris. En heures creuses : - testOffPeakFullPowerStaysUnderTheLimitOnABlindMeter : 7 → 13 lignes (variante au total aveugle ajoutée) ; - testOffPeakFullPowerSwitchesToThreePhases : le recrédit de 1 380 W, refusé sous le plancher triphasé, est servi en confort dans le plafond ; achat 11 040 → 9 660 W, consigne totale inchangée (11 040 W) ; - deux fonctions nouvelles : testOffPeakFullPowerAtDawnStaysInTheCeiling (13 lignes), testOffPeakFullPowerGivesNothingBackWhileSwitchingPhases (2). Co-Authored-By: Claude Opus 5.5 --- .../decisions/reference/decisions.ndjson.xz | Bin 569220 -> 569820 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/tests/auto/decisions/reference/decisions.ndjson.xz b/tests/auto/decisions/reference/decisions.ndjson.xz index dfcc9e6d0ab75957e3d38e1975bdcfb86096446f..91616ec0a75d62c3a68d946fcc8d9f0d86aaee33 100644 GIT binary patch delta 3482 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 I000D8TA4JFS^xk5 delta 2877 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 bBEHG(DgXcgUA~6F6c;-%0{{R300dcDWgKT{ From 0aa357d5b6ee0acdc877af89e6c7e17ae671d403 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 25 Oct 2026 11:05:53 +0100 Subject: [PATCH 18/26] =?UTF-8?q?D=C3=A9lestage=20visible=20:=20journal=20?= =?UTF-8?q?court=20des=20d=C3=A9lestages=20(SheddingLog)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un événement par ÉPISODE, en mémoire : il s'ouvre au premier geste de protection et se ferme à la fin du premier cycle complet où la protection ne tient plus la charge, daté du premier instant libre. « Avant » et « après » portent la charge utile de mécanisme de l'adaptateur, sans liste de types. Vingt épisodes ; le plus ancien épisode clos sort le premier. Aucune décision ne le lit, et il est perdu au redémarrage. Co-Authored-By: Claude Opus 5.5 --- energyplugin/etm/etm.pri | 2 + energyplugin/etm/types/sheddinglog.cpp | 178 +++++++++++++++++++++++++ energyplugin/etm/types/sheddinglog.h | 162 ++++++++++++++++++++++ 3 files changed, 342 insertions(+) create mode 100644 energyplugin/etm/types/sheddinglog.cpp create mode 100644 energyplugin/etm/types/sheddinglog.h diff --git a/energyplugin/etm/etm.pri b/energyplugin/etm/etm.pri index ef967ca..5f086b6 100644 --- a/energyplugin/etm/etm.pri +++ b/energyplugin/etm/etm.pri @@ -5,6 +5,7 @@ HEADERS += \ $$PWD/types/surpluscontext.h \ $$PWD/types/plan.h \ $$PWD/types/drawcap.h \ + $$PWD/types/sheddinglog.h \ $$PWD/types/ecoprogress.h \ $$PWD/types/ecotarget.h \ $$PWD/types/loadconfig.h \ @@ -33,6 +34,7 @@ HEADERS += \ SOURCES += \ $$PWD/types/decisionreason.cpp \ $$PWD/types/drawcap.cpp \ + $$PWD/types/sheddinglog.cpp \ $$PWD/types/ecotarget.cpp \ $$PWD/types/loadconfig.cpp \ $$PWD/tariff/tariffconfig.cpp \ diff --git a/energyplugin/etm/types/sheddinglog.cpp b/energyplugin/etm/types/sheddinglog.cpp new file mode 100644 index 0000000..b62ccc8 --- /dev/null +++ b/energyplugin/etm/types/sheddinglog.cpp @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync + +#include "sheddinglog.h" + +QVariantMap SheddingState::toMap() const +{ + return QVariantMap{{QStringLiteral("powerW"), qRound(powerW)}, + {QStringLiteral("mechanism"), mechanism}}; +} + +QVariantMap SheddingEvent::toMap() const +{ + QVariantMap m{{QStringLiteral("id"), id}, + {QStringLiteral("loadId"), loadId}, + {QStringLiteral("startedAt"), startedAt.toUTC().toString(Qt::ISODate)}, + {QStringLiteral("source"), source}, + {QStringLiteral("overshootW"), qRound(overshootW)}, + {QStringLiteral("cut"), cut}, + {QStringLiteral("before"), before.toMap()}, + {QStringLiteral("after"), after.toMap()}}; + // Absents quand ils n'existent pas : un épisode en cours n'a pas de fin, et un plafond total + // n'a pas de phase coupable. Une valeur par défaut dirait le contraire. + if (endedAt.isValid()) + m.insert(QStringLiteral("endedAt"), endedAt.toUTC().toString(Qt::ISODate)); + if (!phase.isEmpty()) + m.insert(QStringLiteral("phase"), phase); + return m; +} + +SheddingLog::SheddingLog(int capacity) + : m_capacity(qMax(1, capacity)) +{ +} + +SheddingEvent *SheddingLog::trouver(int id) +{ + for (SheddingEvent &e : m_evenements) + if (e.id == id) + return &e; + return nullptr; +} + +void SheddingLog::evincer() +{ + while (m_evenements.size() > m_capacity) { + // Le plus ancien épisode CLOS d'abord : un épisode en cours est ce que l'écran affiche + // en ce moment, l'évincer effacerait l'alerte pendant qu'elle a lieu. + int victime = 0; + for (int i = 0; i < m_evenements.size(); ++i) { + if (m_evenements.at(i).endedAt.isValid()) { + victime = i; + break; + } + } + // Le suivi n'est retiré que si l'évincé est en cours : un épisode CLOS de la même charge + // n'a plus de suivi, et retirer par loadId emporterait celui de l'épisode qui s'ouvre. + const SheddingEvent &evince = m_evenements.at(victime); + const auto suivi = m_ouverts.constFind(evince.loadId); + if (suivi != m_ouverts.constEnd() && suivi->id == evince.id) + m_ouverts.remove(evince.loadId); + m_evenements.removeAt(victime); + } +} + +void SheddingLog::tenir(const QString &loadId, const QDateTime &now, const SheddingAct &act) +{ + auto it = m_ouverts.find(loadId); + SheddingEvent *e = it != m_ouverts.end() ? trouver(it->id) : nullptr; + if (!e) { + SheddingEvent n; + n.id = m_prochainId++; + n.loadId = loadId; + n.startedAt = now.toUTC(); // ancré à la capture (AGENTS, piège du fuseau) + n.source = act.source; + n.phase = act.phase; + n.overshootW = act.overshootW; + n.cut = act.cut; + n.before = act.before; + n.after = act.after; + m_evenements.append(n); + m_ouverts.insert(loadId, Ouvert{n.id, m_cycle, QDateTime()}); + ++m_revision; + evincer(); + return; + } + + it->dernierCycleTenu = m_cycle; + it->libreDepuis = QDateTime(); + + // Ce qui est PUBLIÉ peut changer : la révision ne bouge que dans ce cas. + const QVariantMap avant = e->toMap(); + if (act.overshootW > e->overshootW) { + e->overshootW = act.overshootW; + e->phase = act.phase; + e->source = act.source; + } + e->cut = e->cut || act.cut; + // « Après » garde le plus BAS : c'est ce que la protection a fait de plus fort. À égalité, + // le plus récent, qui dit l'état tenu. + if (act.after.powerW <= e->after.powerW) + e->after = act.after; + if (e->toMap() != avant) + ++m_revision; +} + +void SheddingLog::prolonger(const QString &loadId) +{ + auto it = m_ouverts.find(loadId); + if (it == m_ouverts.end()) + return; + it->dernierCycleTenu = m_cycle; + it->libreDepuis = QDateTime(); +} + +void SheddingLog::relacher(const QString &loadId, const QDateTime &now) +{ + auto it = m_ouverts.find(loadId); + if (it == m_ouverts.end() || it->libreDepuis.isValid()) + return; // la fin se date du PREMIER instant libre, pas du dernier + it->libreDepuis = now.toUTC(); +} + +bool SheddingLog::finDeCycle() +{ + bool ferme = false; + for (auto it = m_ouverts.begin(); it != m_ouverts.end();) { + // Libre, et pas tenue pendant le cycle qui s'achève : un cycle complet sans protection. + if (it->libreDepuis.isValid() && it->dernierCycleTenu < m_cycle) { + if (SheddingEvent *e = trouver(it->id)) + e->endedAt = it->libreDepuis; + it = m_ouverts.erase(it); + ferme = true; + } else { + ++it; + } + } + if (ferme) + ++m_revision; + ++m_cycle; + return ferme; +} + +const SheddingEvent *SheddingLog::enCours(const QString &loadId) const +{ + const auto it = m_ouverts.constFind(loadId); + if (it == m_ouverts.constEnd()) + return nullptr; + for (const SheddingEvent &e : m_evenements) + if (e.id == it->id) + return &e; + return nullptr; +} + +QStringList SheddingLog::ouverts() const +{ + QStringList ids = m_ouverts.keys(); + ids.sort(); + return ids; +} + +QVariantMap SheddingLog::toMap() const +{ + QVariantList evts; + for (const SheddingEvent &e : m_evenements) + evts.append(e.toMap()); + return QVariantMap{{QStringLiteral("revision"), m_revision}, + {QStringLiteral("capacity"), m_capacity}, + {QStringLiteral("events"), evts}}; +} + +QVariantMap SheddingLog::resume() const +{ + if (m_revision == 0) + return QVariantMap(); + return QVariantMap{{QStringLiteral("revision"), m_revision}, + {QStringLiteral("open"), ouverts()}}; +} diff --git a/energyplugin/etm/types/sheddinglog.h b/energyplugin/etm/types/sheddinglog.h new file mode 100644 index 0000000..cc71999 --- /dev/null +++ b/energyplugin/etm/types/sheddinglog.h @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync +#pragma once + +#include +#include +#include +#include +#include +#include + +/*! + * \file sheddinglog.h + * \brief Journal COURT des délestages : un événement par ÉPISODE, en mémoire. + * + * \par Pourquoi il existe + * Un délestage ne vivait qu'un cycle dans la télémétrie (\c DRAW_CAP, \c shed: true), puis + * disparaissait. Une borne coupée la nuit par la protection de surcharge ne laissait aucune + * trace hors du journal système. Ce journal garde les N derniers épisodes, de quoi répondre + * le matin à « pourquoi ma borne s'est-elle arrêtée ? ». + * + * \par Ce qu'il ne fait pas + * Il ne décide rien. Il est alimenté par les deux couches de protection, après leur geste, + * et aucune décision ne le lit. Il est perdu au redémarrage de nymead : c'est accepté. + * + * \par Un épisode, pas un cycle + * Une charge tenue 20 minutes par la protection fait UNE entrée, avec un début et une fin. + * L'épisode s'ouvre au premier geste de protection, et se ferme à la fin du premier cycle + * COMPLET où la protection ne tient plus la charge. La fin est datée du premier instant où + * elle ne la tenait plus (\c relacher()). Un geste repris entre-temps prolonge l'épisode. + * + * \par Le mécanisme, sans liste de types + * « Avant » et « après » portent la charge utile de mécanisme TELLE QUE L'ADAPTATEUR LA + * PUBLIE (\c ILoadAdapter::runtimeView()) : un palier pour des relais, une consigne pour une + * charge continue, un état pour une PAC SG-Ready, un courant pour une borne. Un adaptateur + * ajouté demain est couvert sans rien changer ici. + */ + +//! \brief L'état d'une charge d'un côté du délestage. +struct SheddingState { + //! Puissance engagée, en watts — ce que la charge tenait (avant) ou tient (après). + double powerW = 0; + //! Charge utile de mécanisme, \c kind compris. Celle que \c GetLoadTelemetry publie. + QVariantMap mechanism; + + //! \return Forme sérialisée \c {powerW, mechanism}. + QVariantMap toMap() const; +}; + +//! \brief Ce qu'un geste de protection constate, au moment où il agit. +struct SheddingAct { + //! Source du plafond, code opaque : \c connection, \c gridOperator ou \c selfImposed. + QString source; + //! Phase qui dépasse (\c A, \c B, \c C), vide si aucune phase n'est coupable. + QString phase; + //! Dépassement mesuré au geste, en watts. + double overshootW = 0; + //! Vrai si le geste COUPE la charge. Un changement d'état SG-Ready n'est pas une coupure. + bool cut = false; + //! L'état avant le geste. + SheddingState before; + //! L'état laissé par le geste. + SheddingState after; +}; + +//! \brief Un épisode de délestage, tel que \c GetSheddingLog le publie. +struct SheddingEvent { + int id = 0; //!< Identifiant croissant, jamais réutilisé pendant la vie du processus. + QString loadId; //!< Charge délestée (identifiant de \c GetLoadConfig). + QDateTime startedAt; //!< Premier geste de l'épisode, en UTC. + QDateTime endedAt; //!< Fin de l'épisode, en UTC. Invalide = épisode en cours. + QString source; //!< Source du plafond au plus fort du dépassement. + QString phase; //!< Phase au plus fort du dépassement ; vide si aucune. + double overshootW = 0; //!< Le plus fort dépassement vu pendant l'épisode, en watts. + bool cut = false; //!< Vrai si la charge a été coupée au moins une fois. + SheddingState before; //!< L'état avant le premier geste. + SheddingState after; //!< L'état le plus bas tenu pendant l'épisode. + + //! \return Forme sérialisée. \c endedAt et \c phase sont absents quand ils n'existent pas. + QVariantMap toMap() const; +}; + +/*! + * \brief Journal circulaire des épisodes de délestage. + * + * \invariant Au plus \c capacity() événements. Le plus ancien épisode CLOS sort le premier ; + * un épisode en cours n'est évincé que si tous le sont. + * \invariant \c revision() change à chaque ouverture, fermeture, ou changement d'un contenu + * publié. Prolonger ou relâcher un épisode, sans le fermer, ne la change pas. + */ +class SheddingLog +{ +public: + //! Taille par défaut du journal. + static constexpr int DefaultCapacity = 20; + + //! \param capacity Nombre d'épisodes gardés, au moins 1. + explicit SheddingLog(int capacity = DefaultCapacity); + + /*! + * \brief Un geste de protection sur \p loadId : ouvre l'épisode, ou le prolonge. + * \param loadId Charge délestée. + * \param now Instant du geste. + * \param act Ce que le geste constate. + * \note Dans un épisode en cours, l'état « après » garde le plus bas, \c cut s'accumule, et + * le dépassement garde le plus fort, avec sa phase et sa source. + */ + void tenir(const QString &loadId, const QDateTime &now, const SheddingAct &act); + + //! \brief La protection tient encore \p loadId ce cycle, sans nouveau geste. + //! \param loadId Charge visée. Sans épisode en cours, rien ne se passe. + void prolonger(const QString &loadId); + + //! \brief La protection ne tient plus \p loadId : la fin sera datée de \p now. + //! \param loadId Charge visée. \param now Premier instant libre. Sans épisode, rien. + void relacher(const QString &loadId, const QDateTime &now); + + /*! + * \brief Fin d'un cycle d'arbitrage : ferme les épisodes libres depuis un cycle complet. + * \return Vrai si un épisode s'est fermé. + */ + bool finDeCycle(); + + //! \param loadId Charge visée. \return Vrai si un épisode est en cours sur elle. + bool estOuvert(const QString &loadId) const { return m_ouverts.contains(loadId); } + //! \param loadId Charge visée. \return Son épisode en cours, ou nul s'il n'y en a pas. + const SheddingEvent *enCours(const QString &loadId) const; + //! \return Les charges dont un épisode est en cours, triées. + QStringList ouverts() const; + //! \return Les épisodes gardés, du plus ancien au plus récent. + const QList &evenements() const { return m_evenements; } + //! \return Le compteur de révision ; 0 tant que rien n'a été noté. + int revision() const { return m_revision; } + //! \return Le nombre maximal d'épisodes gardés. + int capacity() const { return m_capacity; } + + //! \return La charge utile de \c GetSheddingLog : \c revision, \c capacity, \c events. + QVariantMap toMap() const; + /*! + * \brief Le résumé publié dans la télémétrie : \c revision et \c open. + * \return Vide tant qu'aucun épisode n'a été noté depuis le démarrage : l'absence du bloc + * dit « aucun délestage », un bloc à zéro le dirait mal. + */ + QVariantMap resume() const; + +private: + struct Ouvert { + int id = 0; + int dernierCycleTenu = 0; + QDateTime libreDepuis; //!< Invalide tant que la protection tient la charge. + }; + + SheddingEvent *trouver(int id); + void evincer(); + + int m_capacity; + int m_revision = 0; + int m_prochainId = 1; + int m_cycle = 0; + QList m_evenements; + QHash m_ouverts; +}; From da6558fa8e4864632fad310a77c4a22fe72d2091 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 25 Oct 2026 11:05:53 +0100 Subject: [PATCH 19/26] =?UTF-8?q?D=C3=A9lestage=20visible=20:=20la=20prote?= =?UTF-8?q?ction=20publie=20ce=20qu'elle=20fait=20(OVERLOAD=5FTHROTTLED,?= =?UTF-8?q?=20GetSheddingLog)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Borne : executeChargingAction() devient virtuel (marque [ETM], corps amont inchangé). L'arbitre laisse partir la consigne, puis la lit. Une baisse ou une coupure de la protection de surcharge ouvre la tenue, et la borne publie OVERLOAD_THROTTLED {fromA, toA, cut, overshootW, limitA, o:phase}. La tenue se lève quand le cycle reprend la borne, ou quand la reprise amont lui rend son courant manuel. Les verrous de la borne sont lus : un geste refusé n'est pas publié comme un délestage. - Cascade : shedWaterfallForOverload() note chaque délestage au journal, avant et après lus sur runtimeView(). Rien d'autre ne change. - Télémétrie : bloc shedding {revision, open}, absent tant que rien n'a été délesté. Sa révision déclenche LoadTelemetryChanged à l'ouverture et à la fermeture d'un épisode. - RPC : NymeaEnergy.GetSheddingLog, même droit que GetLoadTelemetry (table des droits et compte sans droit mis à jour). Aucune consigne, aucun émetteur, aucun verrou ne change (invariant 2). Co-Authored-By: Claude Opus 5.5 --- energyplugin/etm/energyarbitrator.cpp | 261 ++++++++++++++++++ energyplugin/etm/energyarbitrator.h | 92 ++++++ energyplugin/etm/types/decisionreason.cpp | 16 +- energyplugin/etm/types/decisionreason.h | 23 ++ energyplugin/nymeaenergyjsonhandler.cpp | 45 +++ energyplugin/nymeaenergyjsonhandler.h | 11 + energyplugin/smartchargingmanager.h | 29 +- tests/auto/charging/testcharging.cpp | 8 +- .../auto/loadmodel/testdecisioncatalogue.cpp | 1 + 9 files changed, 481 insertions(+), 5 deletions(-) diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 71e422b..0db272e 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -31,6 +31,7 @@ #include #include #include +#include #include namespace { @@ -432,6 +433,10 @@ void EnergyArbitrator::rebuildLoadAdapters() void EnergyArbitrator::update(const QDateTime ¤tDateTime) { qCDebug(dcNymeaEnergy()) << "Updating smart charging"; + // Un geste de protection fait PENDANT le cycle est publié par le cycle ; hors cycle + // (powerBalanceChanged), par executeChargingAction() lui-même. + m_dansCycle = true; + const auto finDuCycle = qScopeGuard([this]() { m_dansCycle = false; }); // Ordre IDENTIQUE à SmartChargingManager::update() — INTERDIT de réordonner. // SCM : 1.updateManual 2.prepareInfo 3.verifyOverload 4.verifyRecovery // 5.planSpot 6.planSurplus 7.adjustEv @@ -559,6 +564,10 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime) // 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); + // Lot délestage visible — APRÈS le dispatch et la fusion L4 : la tenue se juge sur ce qui a + // été exécuté. Publication seule, rien ici ne commande. + suivreDelestages(slot, currentDateTime); + attacherDelestage(m_telemetry); publishTelemetry(false); // Brief 4 T4 — le plan en vigueur (GetPlan) et la stratégie qui a commandé ce cycle (§7). @@ -809,6 +818,9 @@ void EnergyArbitrator::shedWaterfallForOverload(const QDateTime &now) { m_shedActions.clear(); const DrawMargin marge = houseDrawMargin(); + // Lot délestage visible — noté pour le journal seulement : un plafond encore épuisé TIENT les + // charges délestées, quel que soit le motif que la cascade leur donne ensuite. + m_plafondEpuiseAuCycle = marge.borne && marge.watts <= 0; if (!marge.borne || marge.watts >= 0) return; // aucun plafond en vigueur, ou aucun dépassement : rien à délester @@ -860,6 +872,11 @@ void EnergyArbitrator::shedWaterfallForOverload(const QDateTime &now) LoadAction shed; shed.loadId = id; + // Lot délestage visible — l'état AVANT le geste, lu comme l'adaptateur le publie. Lecture + // seule (runtimeView est const) : le journal n'entre dans aucune des décisions qui suivent. + LoadRuntimeView vueAvant = adaptateur->runtimeView(now); + vueAvant.mechanism.insert(QStringLiteral("kind"), vueAvant.mechanismKind); + // DÉLESTER N'EST PAS TOUJOURS RETIRER DES WATTS. Une PAC ne se module pas : on la fait // redescendre d'ÉTAT, et l'état sûr est 2 (normal), jamais 1 — bloquer une PAC n'est // pas la mettre en sécurité, c'est arrêter le chauffage sans raison visible (ECS-413). @@ -935,6 +952,22 @@ void EnergyArbitrator::shedWaterfallForOverload(const QDateTime &now) // REVENDICATION : cette charge est commandée par L4 ce cycle, le dispatch la sautera. m_shedActions.insert(id, publie); + // Le JOURNAL, après le geste : ce qui a été rendu, et le mécanisme tel qu'il est laissé. + // Aucun type de charge n'est nommé ici — l'adaptateur dit lui-même ce qu'il tient. + LoadRuntimeView vueApres = adaptateur->runtimeView(now); + vueApres.mechanism.insert(QStringLiteral("kind"), vueApres.mechanismKind); + SheddingAct geste; + geste.source = drawCapSourceCode(marge.source); + if (marge.phase >= 0 && marge.phase <= 2) + geste.phase = QString(QChar('A' + marge.phase)); + geste.overshootW = qMax(0.0, -marge.watts); + // Une coupure est une consigne en watts menée à zéro. Une PAC rendue à l'état 2 n'est + // pas coupée : elle chauffe sur son propre thermostat (ECS-413). + geste.cut = shed.kind == LoadAction::Setpoint && apresW <= 0; + geste.before = { engageW, vueAvant.mechanism }; + geste.after = { apresW, vueApres.mechanism }; + m_journalDelestage.tenir(id, now, geste); + qCInfo(dcNymeaEnergy()) << "[Arbitre] Délestage L4" << id << "→" << renderFr(publie.reason, id) << "| rendu:" << rendu << "W | reste à délester:" << resteW << "W"; @@ -962,6 +995,227 @@ void EnergyArbitrator::fusionnerDelestageDansSlot(Slot &slot) const } } +void EnergyArbitrator::suivreDelestages(const Slot &slot, const QDateTime &now) +{ + // Une charge de la cascade reste TENUE tant que le plafond la tient : il est encore épuisé + // au compteur, ou son motif est DRAW_CAP — délestée ce cycle (shed: true), ou la cascade + // refuse de la rallumer, l'autorisation étant prise par les charges servies avant elle + // (shed: false). C'est la suite que shedWaterfallForOverload() annonce lui-même : « le cycle + // suivant, la cascade ne les rallume pas non plus ». Le plafond épuisé compte à lui seul : + // une PAC rendue à l'état 2 publie ensuite son verrou, pas le plafond, et le dépassement dure. + QSet tenues; + for (const LoadAction &a : slot.actions) + if (a.reason.code == QLatin1String(DecisionCode::DrawCap)) + tenues.insert(a.loadId); + + for (const QString &id : m_journalDelestage.ouverts()) { + const bool borneVivante = internalEvChargers().contains(ThingId(id)); + if (borneVivante || m_bornesTenues.contains(id)) { + // Une borne : sa tenue suit ses consignes. Seule une borne disparue se relâche ici. + if (!borneVivante) { + m_bornesTenues.remove(id); + m_journalDelestage.relacher(id, now); + } + continue; + } + if (tenues.contains(id) || m_plafondEpuiseAuCycle) + m_journalDelestage.prolonger(id); + else + m_journalDelestage.relacher(id, now); + } + if (m_journalDelestage.finDeCycle()) + qCInfo(dcNymeaEnergy()).noquote() << "[Délestage] journal — épisode(s) clos ; en cours :" + << (m_journalDelestage.ouverts().isEmpty() + ? QStringLiteral("aucun") + : m_journalDelestage.ouverts().join(QStringLiteral(", "))); +} + +void EnergyArbitrator::attacherDelestage(QVariantMap &snap) const +{ + if (snap.isEmpty()) + return; // aucun cycle encore : rien à compléter, et rien à inventer + // ABSENT tant que rien n'a été délesté depuis le démarrage : l'absence dit « aucun + // délestage », un bloc à zéro le dirait mal — et la télémétrie d'une maison qui n'a jamais + // délesté reste exactement celle d'avant ce lot. + const QVariantMap resume = m_journalDelestage.resume(); + if (resume.isEmpty()) + snap.remove(QStringLiteral("shedding")); + else + snap.insert(QStringLiteral("shedding"), resume); + + if (m_bornesTenues.isEmpty()) + return; + QVariantList loads = snap.value(QStringLiteral("loads")).toList(); + for (QVariant &v : loads) { + QVariantMap entry = v.toMap(); + const auto t = m_bornesTenues.constFind(entry.value(QStringLiteral("loadId")).toString()); + if (t == m_bornesTenues.constEnd()) + continue; + // Préséance L4 : l'action entière est celle de la protection, sans niveaux (R7 — les + // deux absents ensemble), et l'allocation publiée est ce qu'elle laisse à la borne. + entry.insert(QStringLiteral("decision"), motifTenue(*t).toMap()); + entry.remove(QStringLiteral("levels")); + entry.insert(QStringLiteral("allocatedW"), t->cut ? 0.0 : t->toA * 230.0 * t->phases); + v = entry; + } + snap.insert(QStringLiteral("loads"), loads); +} + +DecisionReason EnergyArbitrator::motifTenue(const BorneTenue &t) const +{ + QVariantMap params{{QStringLiteral("fromA"), t.fromA}, + {QStringLiteral("toA"), t.toA}, + {QStringLiteral("cut"), t.cut}, + {QStringLiteral("overshootW"), qRound(t.overshootW)}, + {QStringLiteral("limitA"), phasePowerLimit()}}; + // Absente pour une borne triphasée : la protection la juge sur la phase la plus chargée, + // aucune phase n'est à elle seule coupable. Même règle que DRAW_CAP. + if (!t.phase.isEmpty()) + params.insert(QStringLiteral("phase"), t.phase); + return DecisionReason(DecisionCode::OverloadThrottled, params); +} + +void EnergyArbitrator::executeChargingAction(EvCharger *evCharger, const ChargingAction &chargingAction, + const QDateTime ¤tDateTime) +{ + // AVANT l'écriture : ce que la borne tenait, et ce que ses verrous laisseront passer. Les + // verrous sont lus ICI, avec le même instant que les setters amont : c'est ce qui permet de + // publier ce que la borne a RÉELLEMENT reçu, et non ce qui lui a été demandé. + const bool avantActive = evCharger->chargingEnabled(); + const uint avantA = evCharger->maxChargingCurrent(); + const bool courantPasse = chargingAction.force() || !evCharger->chargingCurrentLocked(currentDateTime); + const bool activationPasse = chargingAction.force() || !evCharger->chargingEnabledLocked(currentDateTime); + const uint demandeA = qBound(evCharger->maxChargingCurrentMinValue(), + static_cast(qMax(0, qRound(chargingAction.maxChargingCurrent()))), + evCharger->maxChargingCurrentMaxValue()); + + // La consigne part TELLE QUELLE, par le corps amont : la publication vient après, et ne + // touche ni à la consigne, ni à l'émetteur, ni aux verrous (règle 4). + SmartChargingManager::executeChargingAction(evCharger, chargingAction, currentDateTime); + + if (!noterGesteBorne(evCharger, chargingAction.issuer(), avantActive, avantA, + activationPasse ? chargingAction.chargingEnabled() : avantActive, + courantPasse ? demandeA : avantA, currentDateTime)) + return; + // HORS CYCLE — powerBalanceChanged : aucun cycle ne publiera ce geste avant une minute. + // L'instantané publié est complété et republié tel quel : son timestamp reste celui du + // dernier cycle, le geste est daté par le journal. + if (!m_dansCycle && !m_telemetry.isEmpty()) { + attacherDelestage(m_telemetry); + publishTelemetry(false); + } +} + +bool EnergyArbitrator::noterGesteBorne(EvCharger *evCharger, ChargingAction::ChargingActionIssuer emetteur, + bool avantActive, uint avantA, bool apresActive, uint apresA, + const QDateTime &now) +{ + const QString id = evCharger->id().toString(); + + // L'émetteur INCONNU n'est émis que par un chemin : la boucle de verifyOverloadProtection() + // sur une borne monophasée dont la phase ne dépasse pas, qui reçoit une consigne par défaut. + // Ce n'est pas un geste de la protection au sens de son émetteur : il n'ouvre ni ne ferme rien. + if (emetteur == ChargingAction::ChargingActionIssuerUnknown) + return false; + + const bool tenueAvant = m_bornesTenues.contains(id); + + if (emetteur != ChargingAction::ChargingActionIssuerOverloadProtection) { + // Le cycle reprend la main : la protection ne tient plus la borne. + if (!tenueAvant) + return false; + m_bornesTenues.remove(id); + m_journalDelestage.relacher(id, now); + qCInfo(dcNymeaEnergy()).noquote() << "[Délestage] borne" << evCharger->name() + << "— le cycle reprend la main, la protection ne la tient plus."; + return true; + } + + // Rien n'a passé les verrous de la borne : la protection a demandé, la borne n'a rien reçu. + // Un refus se dit au moins aussi fort qu'un geste (règle 7-d) — mais il ne s'enregistre pas + // comme un délestage, qui n'a pas eu lieu. + if (apresActive == avantActive && apresA == avantA) { + qCInfo(dcNymeaEnergy()).noquote() << "[Délestage] borne" << evCharger->name() + << "— geste de la protection REFUSÉ par les verrous de la " + "borne : elle garde" << (avantActive ? avantA : 0u) << "A."; + return false; + } + + const uint phases = qMax(1u, evCharger->phaseCount()); + const bool coupe = !apresActive; + const uint commandeA = apresA; + const uint versA = coupe ? 0u : commandeA; + + if (!coupe && avantActive && versA >= avantA) { + // verifyOverloadProtectionRecovery() rend le courant manuel : la tenue est levée. + if (!tenueAvant) + return false; + m_bornesTenues.remove(id); + m_journalDelestage.relacher(id, now); + qCInfo(dcNymeaEnergy()).noquote() << "[Délestage] borne" << evCharger->name() + << "— la protection lui rend" << versA << "A : tenue levée."; + return true; + } + if (!coupe && !avantActive && !tenueAvant) + return false; // une remise en route sans tenue en cours n'a rien à prolonger + + // Le dépassement que la protection a vu — relu sur le MÊME compteur, dans le même appel, et + // jugé comme elle : sur la phase de la borne si elle est monophasée, sur la plus chargée sinon. + QString phase; + double depassementW = 0; + if (RootMeter *compteur = internalRootMeter()) { + const double limiteW = 230.0 * phasePowerLimit(); + const QHash parPhase{{QStringLiteral("A"), compteur->currentPowerPhaseA()}, + {QStringLiteral("B"), compteur->currentPowerPhaseB()}, + {QStringLiteral("C"), compteur->currentPowerPhaseC()}}; + if (evCharger->phaseCount() == 1) { + phase = chargerPhaseFor(evCharger); + depassementW = qMax(0.0, parPhase.value(phase) - limiteW); + } else { + for (double w : parPhase) + depassementW = qMax(depassementW, w - limiteW); + } + } + + BorneTenue &t = m_bornesTenues[id]; + if (!tenueAvant) { + // Un épisode encore ouvert — le cycle a repris la borne, la protection la reprend avant + // qu'un cycle complet ne soit passé : c'est le MÊME épisode, et « avant » est le sien. + const SheddingEvent *ouvert = m_journalDelestage.enCours(id); + t.fromA = ouvert ? ouvert->before.mechanism.value(QStringLiteral("currentA")).toUInt() + : (avantActive ? avantA : 0); + t.phase = ouvert ? ouvert->phase : phase; + t.overshootW = ouvert ? ouvert->overshootW : 0; + } + t.toA = versA; + t.cut = coupe; + t.phases = phases; + if (depassementW > t.overshootW) { + t.overshootW = depassementW; + t.phase = phase; + } + + auto etat = [phases](bool active, uint ampere) { + return SheddingState{ active ? ampere * 230.0 * phases : 0.0, + QVariantMap{{QStringLiteral("kind"), QStringLiteral("evcharger")}, + {QStringLiteral("chargingEnabled"), active}, + {QStringLiteral("currentA"), ampere}, + {QStringLiteral("phaseCount"), phases}} }; + }; + SheddingAct geste; + geste.source = drawCapSourceCode(DrawCapSource::Breaker); // la limite par phase du branchement + geste.phase = phase; + geste.overshootW = depassementW; + geste.cut = coupe; + geste.before = etat(avantActive, avantA); + geste.after = etat(!coupe, commandeA); + m_journalDelestage.tenir(id, now, geste); + + qCInfo(dcNymeaEnergy()).noquote() << "[Délestage] borne" << id << "→" + << renderFr(motifTenue(t), evCharger->name()); + return true; // le motif publié suit chaque geste, même quand le journal ne bouge pas +} + bool EnergyArbitrator::evParticipatesInWaterfall(EvCharger *ev) const { if (!ev) @@ -2517,6 +2771,8 @@ void EnergyArbitrator::buildDegradedTelemetry(const QDateTime &now) repli.reason = { DecisionCode::StorageAutomatic, {{"cause", QStringLiteral("degraded")}} }; attacherStockage(snap, repli); } + // La protection de surcharge tourne aussi sous L2 : ce qu'elle tient se publie ici aussi. + attacherDelestage(snap); m_telemetry = snap; } @@ -3506,6 +3762,11 @@ bool EnergyArbitrator::telemetrySignificantlyDiffers(const QVariantMap &a, const // pris fin — ni pourquoi. if (b.contains(QStringLiteral("derogationEnded"))) return true; + // Lot délestage visible — l'OUVERTURE et la FERMETURE d'un épisode se notifient toujours, + // même quand rien d'autre n'a bougé : c'est l'alerte que l'app attend. + if (a.value(QStringLiteral("shedding")).toMap().value(QStringLiteral("revision")) + != b.value(QStringLiteral("shedding")).toMap().value(QStringLiteral("revision"))) + 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(); diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index f960818..0c25850 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -11,6 +11,7 @@ // qui permet au rebuild incrémental (ECS-412) de comparer l'ancienne et la nouvelle config. #include "types/loadconfig.h" #include "types/drawcap.h" +#include "types/sheddinglog.h" #include "types/derogation.h" #include "forecast/forecaststore.h" #include "forecast/forecastlearninglog.h" @@ -522,6 +523,23 @@ public: */ QVariantMap loadTelemetry() const { return m_telemetry; } + /*! + * \brief Le journal court des délestages — charge utile de \c NymeaEnergy.GetSheddingLog. + * + * \return \c revision, \c capacity et \c events : les \c capacity derniers épisodes de + * protection, du plus ancien au plus récent. Un épisode en cours n'a pas d'\c endedAt. + * + * \par Ce qui y entre + * Les deux couches de protection, et elles seules : la protection de surcharge de l'amont + * sur les bornes (\c executeChargingAction()), et le délestage du waterfall + * (\c shedWaterfallForOverload()) pour TOUTES les autres charges, sans liste de types. + * + * \note En mémoire seulement : perdu au redémarrage de nymead. La télémétrie en porte le + * résumé (\c shedding : \c revision et \c open), absent tant que le journal est vide — + * c'est lui qui notifie l'ouverture et la fermeture d'un épisode. + */ + QVariantMap sheddingLog() const { return m_journalDelestage.toMap(); } + /*! * \brief Battement de cœur : republie l'instantané COURANT sans condition ni recalcul. * @@ -790,6 +808,41 @@ public: //! le plan ne l'a pas commandée, publier sa consigne montrerait ce qui n'a pas eu lieu. void fusionnerDelestageDansSlot(Slot &slot) const; + /*! + * \brief Fin de cycle du journal des délestages : prolonge, relâche, ferme. + * \param slot Actions du cycle, délestage L4 fusionné. Une charge de la cascade reste tenue + * tant que son motif est \c DRAW_CAP — délestée ce cycle, ou que la cascade refuse de la + * rallumer sous le même plafond. + * \param now Temps de cycle. + * \note Les bornes ne passent pas par ici : leur tenue suit les consignes elles-mêmes + * (\c executeChargingAction()). Une borne qui a disparu est relâchée. + */ + void suivreDelestages(const Slot &slot, const QDateTime &now); + + /*! + * \brief Pose dans \p snap ce que la protection tient : le résumé du journal, et le motif + * \c OVERLOAD_THROTTLED des bornes tenues. + * \param[in,out] snap Instantané de télémétrie. Rien n'est ajouté à un instantané vide. + * \note Idempotent : rejouée hors cycle sur l'instantané publié, elle ne double rien. Une + * borne tenue perd ses niveaux, comme une charge délestée par L4 — l'action entière est + * celle de la protection, et \c allocatedW dit ce qu'elle laisse à la borne. + */ + void attacherDelestage(QVariantMap &snap) const; + + /*! + * \brief Classe une consigne de borne qui vient de partir, et note ce qu'elle dit de la protection. + * \param evCharger Borne. \param emetteur Émetteur de la consigne. + * \param avantActive La borne était-elle enclenchée avant la consigne ? + * \param avantA Son courant avant la consigne. + * \param apresActive Enclenchement que la borne a REÇU — l'ancien si son verrou a refusé. + * \param apresA Courant que la borne a REÇU, borné à ses limites — l'ancien si son verrou a refusé. + * \param now Temps de cycle. + * \return Vrai si la tenue ou le journal ont changé. + */ + bool noterGesteBorne(EvCharger *evCharger, ChargingAction::ChargingActionIssuer emetteur, + bool avantActive, uint avantA, bool apresActive, uint apresA, + const QDateTime &now); + /*! * \brief Marge de soutirage de la MAISON, toutes phases confondues. * @@ -937,6 +990,25 @@ protected: */ void update(const QDateTime ¤tDateTime) override; + /*! + * \brief Exécute une consigne de borne par le corps amont, puis PUBLIE un geste de protection. + * + * \param evCharger Borne commandée. \param chargingAction Consigne, transmise TELLE QUELLE. + * \param currentDateTime Temps de cycle. + * + * \par Ce qui est publié + * Une consigne émise par la protection de surcharge (\c ChargingActionIssuerOverloadProtection) + * qui baisse ou coupe la borne ouvre ou prolonge son épisode au journal, et la borne publie + * \c OVERLOAD_THROTTLED. La remise au minimum après coupure prolonge la tenue. La reprise au + * courant manuel, ou toute consigne d'un autre émetteur, la lève. + * + * \par Ce qui ne change pas + * La consigne part d'abord, inchangée ; la lecture vient après. Hors d'un cycle — la + * protection tourne aussi sur \c powerBalanceChanged — la télémétrie est republiée sur-le-champ. + */ + void executeChargingAction(EvCharger *evCharger, const ChargingAction &chargingAction, + const QDateTime ¤tDateTime) override; + private: /*! * \brief Construit le SurplusContext §5 : meter brut + loads EV + SG-Ready. @@ -1358,6 +1430,26 @@ private: //! loadId → action de DÉLESTAGE L4 du cycle. Revendication : le dispatch saute ces charges, //! un seul commandeur par organe (règle absolue 1). Vidée à chaque évaluation de L4. QHash m_shedActions; + //! Journal court des délestages (lot délestage visible). AUCUNE DÉCISION N'EN DÉPEND. + SheddingLog m_journalDelestage; + //! Ce que la protection de surcharge tient d'une borne : la base du motif OVERLOAD_THROTTLED. + struct BorneTenue { + uint fromA = 0; //!< Courant avant le premier geste. + uint toA = 0; //!< Courant tenu ; 0 si coupée. + uint phases = 1; //!< Phases de la borne, pour la puissance laissée. + bool cut = false; //!< Coupée par le dernier geste. + QString phase; //!< Phase jugée ; vide pour une borne triphasée. + double overshootW = 0; //!< Plus fort dépassement de la tenue. + }; + //! loadId de borne → tenue en cours. Vide dans le cas normal. + QHash m_bornesTenues; + //! Le motif OVERLOAD_THROTTLED d'une tenue — construit ICI, en un seul endroit. + DecisionReason motifTenue(const BorneTenue &t) const; + //! Vrai pendant update() : un geste de protection y est publié par le cycle lui-même. + bool m_dansCycle = false; + //! Le plafond de soutirage était-il épuisé au compteur, à l'évaluation L4 du cycle ? Lu par + //! le seul journal des délestages : une charge délestée reste tenue tant qu'il l'est. + bool m_plafondEpuiseAuCycle = false; //! loadId → action APPLIQUÉE AU CYCLE : ce que l'adaptateur a rendu au dispatch //! (\c applyActionsToAdapters()), après écrêtage et verrous — pas ce que le plan demandait. //! Vidée à chaque dispatch ; les délestages L4 et le repli L2 n'y entrent pas (ils ont leur diff --git a/energyplugin/etm/types/decisionreason.cpp b/energyplugin/etm/types/decisionreason.cpp index 6e9f90d..7bbd312 100644 --- a/energyplugin/etm/types/decisionreason.cpp +++ b/energyplugin/etm/types/decisionreason.cpp @@ -123,6 +123,20 @@ QString renderFr(const DecisionReason &reason, const QString &label) "%3 n'est pas servie") .arg(ou).arg(origine).arg(label); } + // La protection de surcharge de l'amont tient la borne. La phrase dit ce que la borne + // reçoit, puis pourquoi : le branchement, la phase, sa limite. + if (c == QLatin1String(DecisionCode::OverloadThrottled)) { + const QString pourquoi = p.contains(QStringLiteral("phase")) + ? QStringLiteral("le branchement atteignait sa limite (phase %1, %2 A)") + .arg(p.value(QStringLiteral("phase")).toString()).arg(w("limitA")) + : QStringLiteral("le branchement atteignait sa limite (%1 A par phase)").arg(w("limitA")); + const QString qui = label.isEmpty() ? QStringLiteral("Borne") : label; + if (p.value(QStringLiteral("cut")).toBool()) + return QStringLiteral("%1 coupée : %2, dépassement de %3 W") + .arg(qui, pourquoi).arg(w("overshootW")); + return QStringLiteral("%1 abaissée à %2 A : %3, dépassement de %4 W") + .arg(qui).arg(w("toA")).arg(pourquoi).arg(w("overshootW")); + } // La TRAJECTOIRE, pas l'instant. Les watts seuls laisseraient croire à une dépense stable // là où la cadence va se durcir d'un coup ; le reste et l'échéance disent où l'on va. auto delaiEco = [&p]() { @@ -433,7 +447,7 @@ QStringList DecisionCode::knownCodes() return {EvSpotMarket, EvDeadline, EvSurplus, EvEcoMin, EvIdle, LoadUnavailable, LockMinOn, LockMinOff, LockMinOffCapped, SurplusSetpoint, SurplusInsufficient, BatteryReserve, BelowMinPower, EvGridStart, PhaseLimit, DrawCap, - EcoFloorGrid, EcoFloorBelowStage, EcoFloorMet, EcoFloorMissed, RecreditSuspended, + OverloadThrottled, EcoFloorGrid, EcoFloorBelowStage, EcoFloorMet, EcoFloorMissed, RecreditSuspended, DerogationActive, DerogationEnded, MissingForecastSite, ForecastPayloadRefused, SpotMarketNotInBuild, ForecastDateUnreadable, TariffRefused, LoadConfigRefused, diff --git a/energyplugin/etm/types/decisionreason.h b/energyplugin/etm/types/decisionreason.h index 9a7de1c..edcfb76 100644 --- a/energyplugin/etm/types/decisionreason.h +++ b/energyplugin/etm/types/decisionreason.h @@ -176,6 +176,29 @@ constexpr const char *PhaseLimit = "PHASE_LIMIT"; */ constexpr const char *DrawCap = "DRAW_CAP"; +/*! + * \brief La PROTECTION DE SURCHARGE de l'amont tient la borne : abaissée ou coupée. + * + * params : \c fromA (courant avant le premier geste), \c toA (courant tenu, 0 si coupée), + * \c cut (booléen), \c overshootW (le plus fort dépassement constaté pendant la tenue), + * \c limitA (la limite par phase du branchement, \c phasePowerLimit), \c phase (code opaque + * \c A | \c B | \c C — **absent** pour une borne triphasée : la protection la rabaisse alors sur + * la phase la plus chargée, sans qu'une phase soit à elle seule coupable). + * + * \par Ce qu'il publie, et ce qu'il ne décide pas + * \c SmartChargingManager::verifyOverloadProtection() rabaisse la borne du dépassement arrondi + * à l'ampère supérieur, et la coupe sous son plancher. Ce geste ne laissait ni motif, ni + * télémétrie. Ce code le PUBLIE, après coup : la protection reste celle de l'amont, à l'identique. + * + * \par Quand il tient, quand il se lève + * Il tient tant que la dernière commande appliquée à la borne vient de la protection — la baisse, + * la coupure, et la remise au courant minimal par \c verifyOverloadProtectionRecovery(). Il se lève + * quand le cycle commande à nouveau la borne, ou quand la reprise lui rend son courant manuel. + * + * \note Préséance : celle du délestage L4 — l'action entière est remplacée, sans niveaux. + */ +constexpr const char *OverloadThrottled = "OVERLOAD_THROTTLED"; + /*! * \brief L'obligation éco est servie en ACHETANT au réseau. * diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index dbba234..dfd5bde 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -30,6 +30,7 @@ #include #endif #include "etm/types/decisionreason.h" // hors ETM_ARBITRATOR aussi : SPOT_MARKET_NOT_IN_BUILD +#include "etm/types/sheddinglog.h" // hors ETM_ARBITRATOR aussi : GetSheddingLog rend un journal vide #include "types/charginginfo.h" #include "smartchargingmanager.h" #include "spotmarket/spotmarketmanager.h" @@ -331,6 +332,39 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana returns = loadTelemetrySchema(); registerMethod("GetLoadTelemetry", description, params, returns, Types::PermissionScopeNone); + // [ETM] Journal court des délestages. Hors de la télémétrie, parce qu'il la doublerait : vingt + // épisodes portant chacun deux charges utiles de mécanisme, émis à chaque battement. La + // télémétrie n'en porte que le résumé (shedding.revision, shedding.open) — c'est lui qui + // notifie. Même droit que GetLoadTelemetry : c'est de la lecture d'état, la vitrine la lit. + params.clear(); returns.clear(); + description = "Get the short log of load-shedding episodes: the last 'capacity' episodes of " + "overload protection, oldest first, EV chargers and cascade loads alike. ONE " + "event per episode, not per cycle: 'endedAt' is omitted while the episode is " + "running. 'before' and 'after' carry the mechanism payload as the adapter " + "publishes it (stage for relays, setpoint, SG-Ready state, charger current); " + "'after' is the lowest state held during the episode. In memory only: lost " + "when nymead restarts. No own notification: GetLoadTelemetry carries " + "'shedding.revision', and LoadTelemetryChanged is emitted when an episode opens " + "or closes."; + QVariantMap etatItem; + etatItem.insert("powerW", enumValueName(Int)); + etatItem.insert("mechanism", enumValueName(Variant)); + QVariantMap evenementItem; + evenementItem.insert("id", enumValueName(Int)); + evenementItem.insert("loadId", enumValueName(String)); + evenementItem.insert("startedAt", enumValueName(String)); // ISO 8601 UTC + evenementItem.insert("o:endedAt", enumValueName(String)); // absent = en cours + evenementItem.insert("source", enumValueName(String)); // connection | gridOperator | selfImposed + evenementItem.insert("o:phase", enumValueName(String)); // A | B | C ; absent = aucune + evenementItem.insert("overshootW", enumValueName(Int)); + evenementItem.insert("cut", enumValueName(Bool)); + evenementItem.insert("before", etatItem); + evenementItem.insert("after", etatItem); + returns.insert("revision", enumValueName(Int)); + returns.insert("capacity", enumValueName(Int)); + returns.insert("events", QVariantList() << evenementItem); + registerMethod("GetSheddingLog", description, params, returns, Types::PermissionScopeNone); + // --- Prévision PV ------------------------------------------------------------------------ // // UN `Get*`, ET PAS DE `*Changed` — amendement assumé à l'invariant 11, qui vise l'ÉTAT DE @@ -1483,6 +1517,17 @@ JsonReply *NymeaEnergyJsonHandler::GetLoadTelemetry(const QVariantMap ¶ms) return createReply(currentLoadTelemetry()); } +JsonReply *NymeaEnergyJsonHandler::GetSheddingLog(const QVariantMap ¶ms) +{ + Q_UNUSED(params) +#ifdef ETM_ARBITRATOR + if (EnergyArbitrator *arb = dynamic_cast(m_smartChargingManager)) + return createReply(arb->sheddingLog()); +#endif + // Sans arbitre, aucun délestage ne peut être noté : le journal est vide, et le dit. + return createReply(SheddingLog().toMap()); +} + JsonReply *NymeaEnergyJsonHandler::GetPvForecast(const QVariantMap ¶ms) { const QVariantMap succes{{"forecastPoints", QVariantList()}, diff --git a/energyplugin/nymeaenergyjsonhandler.h b/energyplugin/nymeaenergyjsonhandler.h index cf1e16d..f5b8748 100644 --- a/energyplugin/nymeaenergyjsonhandler.h +++ b/energyplugin/nymeaenergyjsonhandler.h @@ -119,6 +119,17 @@ public: * notification seule le laisse dans un silence ambigu (famille ECS-411). */ Q_INVOKABLE JsonReply *GetLoadTelemetry(const QVariantMap ¶ms); + + /*! + * \brief [ETM] Journal court des délestages : les derniers épisodes de protection. + * \param params Aucun. + * \return \c revision, \c capacity, \c events — un événement par ÉPISODE, borne et charges + * de la cascade confondues. Rendu tel que tenu en mémoire, jamais recalculé. + * \note Pas de notification propre : la télémétrie porte \c shedding.revision, et + * \c LoadTelemetryChanged part à chaque ouverture et à chaque fermeture. Même droit que + * \c GetLoadTelemetry. Perdu au redémarrage de nymead. + */ + Q_INVOKABLE JsonReply *GetSheddingLog(const QVariantMap ¶ms); //! \brief Pose une dérogation (servie au maximum, financée au réseau, en tête de passe éco). //! \param params \c loadId, \c durationS (obligatoire, borné), \c targetWh (optionnel). //! \return \c energyError — \c InvalidParameter si la charge est inconnue ou la durée hors bornes. diff --git a/energyplugin/smartchargingmanager.h b/energyplugin/smartchargingmanager.h index 8c90e9c..45c0cf3 100644 --- a/energyplugin/smartchargingmanager.h +++ b/energyplugin/smartchargingmanager.h @@ -131,8 +131,12 @@ signals: // 4. internalEvChargers(), internalChargingActions(), internalRootMeter(), // internalProcessInfo(), internalHasProcessInfo() // — accesseurs ETM neufs, documentés ci-dessous. -// 5. bestPhaseCountFor(), phasesForCount() — passe-plats ETM vers deux règles amont +// 5. bestPhaseCountFor(), phasesForCount(), chargerPhaseFor() +// — passe-plats ETM vers trois règles amont // privées, documentés ci-dessous. +// 8. executeChargingAction() — virtual ajouté (le corps reste amont), pour +// que l'arbitre PUBLIE les gestes de la +// protection de surcharge. Documenté ci-dessous. // 7. setEvChargingState() — écriture ETM d'un état RUNTIME, documentée // ci-dessous. Pas un réglage : jamais persistée. // 6. evSurplusPlannedByWaterfall(), evCommandedByWaterfall() @@ -154,7 +158,28 @@ protected slots: void verifyOverloadProtectionRecovery(const QDateTime ¤tDateTime); // [ETM] private → protected protected: - void executeChargingAction(EvCharger *evCharger, const ChargingAction &chargingAction, const QDateTime ¤tDateTime); // [ETM] private → protected + /*! + * \brief [ETM] Exécute une consigne de borne — corps amont, inchangé. + * \param evCharger Borne commandée. \param chargingAction Consigne et émetteur. + * \param currentDateTime Temps de cycle, passé aux verrous de la borne. + * \note `private → protected`, puis `virtual` ajouté (lot délestage visible). C'est le seul + * point par où passent TOUS les gestes de la protection de surcharge — y compris ceux de + * \c verifyOverloadProtection() déclenchés par \c powerBalanceChanged, hors cycle. + * \c EnergyArbitrator le surcharge pour les PUBLIER : il appelle ce corps tel quel, puis + * lit ce qui a été commandé. Une surcharge qui modifierait la consigne avant de l'appeler + * ferait décider la publication à la place de la protection (règle 4). + */ + virtual void executeChargingAction(EvCharger *evCharger, const ChargingAction &chargingAction, const QDateTime ¤tDateTime); // [ETM] private → protected, virtual added + + /*! + * \brief [ETM] Phase de raccordement d'une borne monophasée, selon la protection de surcharge. + * \param evCharger Borne. \return \c "A", \c "B" ou \c "C". + * \note `private → protected`, même raison que \c bestPhaseCountFor : l'arbitre publie la + * phase sur laquelle la protection a jugé la borne. La redériver en ferait deux versions, + * et le motif nommerait une autre phase que celle qui a décidé. + */ + QString chargerPhaseFor(EvCharger *evCharger) const + { return chargerPhaseKey(evCharger); } /*! * \brief [ETM 3g-1] Nombre de phases le plus adapté à un courant de surplus donné. diff --git a/tests/auto/charging/testcharging.cpp b/tests/auto/charging/testcharging.cpp index 33e1611..8747dd3 100644 --- a/tests/auto/charging/testcharging.cpp +++ b/tests/auto/charging/testcharging.cpp @@ -2202,6 +2202,7 @@ void TestCharging::testNymeaEnergyPermissionScopes() {"GetSpotMarketScoreEntries", none}, {"GetLoadConfig", none}, {"GetLoadTelemetry", none}, + {"GetSheddingLog", none}, // même droit que la télémétrie : la vitrine le lit {"GetEnergyRatios", none}, {"GetPvForecast", none}, {"GetForecastSite", none}, @@ -2322,12 +2323,15 @@ void TestCharging::testNymeaEnergyPermissionsEnforced() // Le transport du harnais EXIGE l'authentification. Sans elle, nymea ne vérifie aucun droit // et rien de ce qui suit ne prouverait quoi que ce soit. QVERIFY(lecteur.authentificationExigee); - for (const QString &m : {QStringLiteral("GetLoadTelemetry"), QStringLiteral("GetTariff"), - QStringLiteral("GetBatteryLevelConsideration")}) { + for (const QString &m : {QStringLiteral("GetLoadTelemetry"), QStringLiteral("GetSheddingLog"), + QStringLiteral("GetTariff"), QStringLiteral("GetBatteryLevelConsideration")}) { const QVariantMap r = appeler(lecteur, m); QVERIFY2(reussi(r), qPrintable(m + " : " + QJsonDocument::fromVariant(r).toJson(QJsonDocument::Compact))); } QCOMPARE(appeler(lecteur, "GetTariff").value("params"), tarifAvant); // la vraie valeur, pas une coquille + // Le journal des délestages : un compte sans aucun droit — celui de la vitrine — le lit en entier. + QCOMPARE(appeler(lecteur, "GetSheddingLog").value("params"), lireAdmin("GetSheddingLog")); + QVERIFY(appeler(lecteur, "GetSheddingLog").value("params").toMap().contains("events")); QVERIFY(refuse(appeler(lecteur, "SetTariff", {{"zones", QVariantList()}}))); QCOMPARE(lireAdmin("GetTariff"), tarifAvant); QVERIFY(refuse(appeler(lecteur, "SetLoadDerogation", {{"loadId", "droits-relais"}, {"durationS", 600}}))); diff --git a/tests/auto/loadmodel/testdecisioncatalogue.cpp b/tests/auto/loadmodel/testdecisioncatalogue.cpp index 2dd46ce..88096fa 100644 --- a/tests/auto/loadmodel/testdecisioncatalogue.cpp +++ b/tests/auto/loadmodel/testdecisioncatalogue.cpp @@ -53,6 +53,7 @@ void TestLoadModel::testOptimizerMayEmitOnlyWhitelistedCodes() for (const char *c : {DecisionCode::DegradedL2, DecisionCode::SafeStateRelay, DecisionCode::SafeStateSetpoint, DecisionCode::SafeStateSgReady, DecisionCode::LoadUnavailable, DecisionCode::DrawCap, + DecisionCode::OverloadThrottled, // la protection de surcharge de l'amont DecisionCode::PhaseLimit, DecisionCode::DerogationActive, DecisionCode::DerogationEnded, DecisionCode::EcoFloorMet, DecisionCode::EcoFloorBelowStage, // l'optimiseur ignore les paliers (E9) From 1599acfba9affac14e381a7f4d2872d70fbcb955 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 25 Oct 2026 11:05:53 +0100 Subject: [PATCH 20/26] =?UTF-8?q?D=C3=A9lestage=20visible=20:=20tests=20de?= =?UTF-8?q?=20simulation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - borne abaissée, coupée, puis reprise par le cycle : une entrée au journal, et une fermeture qui notifie sans autre cause (contre-épreuve : un cycle identique ne notifie rien) ; - borne en mode manuel, hors de loads[] : l'épisode s'ouvre et se ferme par notification, et la reprise amont le referme ; - relais et SG-Ready délestés pendant 20 minutes : une entrée par charge, avant et après dans le langage de chaque mécanisme ; - 21 épisodes : le plus ancien sort. Co-Authored-By: Claude Opus 5.5 --- tests/auto/simulation/simulation.cpp | 477 +++++++++++++++++++++++++++ tests/auto/simulation/simulation.h | 13 + 2 files changed, 490 insertions(+) diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 260ee51..a16be05 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -11916,3 +11916,480 @@ void Simulation::testOffPeakFullPowerGivesNothingBackWhileSwitchingPhases() qUtf8Printable(trace)); #endif } + +// ============================================================================================ +// Lot délestage visible (2026-10-25) — la protection PUBLIE ce qu'elle fait, elle ne décide rien +// de plus. Question de Patrick : « Si le délestage coupe une charge, est-ce que le client voit une +// alerte quelque part ? » Avant ce lot : non. +// ============================================================================================ + +namespace { + +//! Les événements du journal des délestages, par la frontière RPC — comme l'app les lit. +QVariantList evenementsDelestage(const QVariant &reponse) +{ + return reponse.toMap().value("params").toMap().value("events").toList(); +} + +//! Un compteur aux trois phases posées à la main (A), les deux autres à 2 A. +void poserPhaseA(Thing *compteur, double amperesA) +{ + compteur->setStateValue("currentPhaseA", amperesA); + compteur->setStateValue("currentPowerPhaseA", amperesA * 230); + for (const char *ph : {"B", "C"}) { + compteur->setStateValue(QStringLiteral("currentPhase%1").arg(ph), 2.0); + compteur->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), 460.0); + } + compteur->setStateValue("currentPower", (amperesA + 4) * 230); +} + +} // namespace + +void Simulation::testOverloadProtectionReasonOnTheCharger() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // Une borne monophasée 16 A sur la phase A, en « éco avec minimum », servie à pleine puissance + // en heures creuses ; limite de branchement 25 A. Le compteur VOIT la borne. La protection de + // surcharge de l'amont la rabaisse, puis la coupe : avant ce lot, rien n'en sortait. + FuseauDeTest fuseau("Europe/Paris"); + ScenarioHc s = preparerScenarioHc(QStringLiteral("protection"), BatterieHc::Aucune, hphcT4(), + BorneHc::Monophasee); + QVERIFY(s.arb && s.borne); + 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); + + QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(21, 0)); // 23:00 à Paris : heures creuses + QVariantMap snap; + const auto cycle = [&](double maisonA) { + a = a.addSecs(360); // au-delà des verrous de la borne + snap = cycleHc(s, a, compteurVoitLaBorne(s.meter, s.borne, maisonA)); + }; + const auto entree = [&]() { return telemetrieParId(snap).value(borne); }; + const auto decision = [&]() { return entree().value("decision").toMap(); }; + const auto journal = [&]() { return evenementsDelestage(injectAndWait("NymeaEnergy.GetSheddingLog")); }; + + // 0. TÉMOIN — maison à 2 A : la borne à pleine puissance, aucun délestage. Le bloc `shedding` + // est ABSENT, et la télémétrie est exactement celle d'avant ce lot. + cycle(2); + cycle(2); + QCOMPARE(consigneA(s.borne), 16); + QCOMPARE(decision().value("code").toString(), QString(DecisionCode::EvOffPeakFullPower)); + QVERIFY2(!snap.contains("shedding"), "aucun délestage : le bloc doit être absent, pas à zéro"); + QVERIFY(journal().isEmpty()); + + // 1. RABAISSÉE — un four s'allume : 12 A de maison, 28 A sur la phase A pour 25. La + // protection rabaisse la borne du dépassement arrondi à l'ampère supérieur. + QSignalSpy notifs(s.arb, &EnergyArbitrator::loadTelemetryChanged); + cycle(12); + const int tenusA = consigneA(s.borne); + QVERIFY2(tenusA > 0 && tenusA < 16, qUtf8Printable(QString::number(tenusA))); + QCOMPARE(decision().value("code").toString(), QString(DecisionCode::OverloadThrottled)); + QVariantMap p = decision().value("params").toMap(); + QCOMPARE(p.value("fromA").toInt(), 16); + QCOMPARE(p.value("toA").toInt(), tenusA); // ce que la borne a réellement reçu + QCOMPARE(p.value("cut").toBool(), false); + QCOMPARE(p.value("phase").toString(), QStringLiteral("A")); + QCOMPARE(p.value("limitA").toInt(), 25); + QCOMPARE(p.value("overshootW").toInt(), 690); // (28 − 25) × 230 + // Préséance L4 : l'action entière est celle de la protection — sans niveaux, et l'allocation + // dit ce qu'elle laisse à la borne. + QVERIFY(!entree().contains("levels")); + QVERIFY(!decision().contains("level")); + QCOMPARE(qRound(entree().value("allocatedW").toDouble()), tenusA * 230); + QCOMPARE(renderFr(DecisionReason(DecisionCode::OverloadThrottled, p), "Borne"), + QStringLiteral("Borne abaissée à %1 A : le branchement atteignait sa limite (phase A, " + "25 A), dépassement de 690 W").arg(tenusA)); + // La notification est partie, et elle porte le résumé du journal. + QVERIFY(!notifs.isEmpty()); + QVariantMap resume = notifs.last().first().toMap().value("shedding").toMap(); + QCOMPARE(resume.value("open").toStringList(), QStringList{borne}); + QVERIFY(resume.value("revision").toInt() > 0); + QVariantList evts = journal(); + QCOMPARE(evts.size(), 1); + QVariantMap e = evts.first().toMap(); + QCOMPARE(e.value("loadId").toString(), borne); + QVERIFY(!e.contains("endedAt")); + QCOMPARE(e.value("source").toString(), QStringLiteral("connection")); + QCOMPARE(e.value("phase").toString(), QStringLiteral("A")); + QCOMPARE(e.value("before").toMap().value("mechanism").toMap().value("kind").toString(), + QStringLiteral("evcharger")); + QCOMPARE(e.value("before").toMap().value("mechanism").toMap().value("currentA").toInt(), 16); + QCOMPARE(e.value("before").toMap().value("powerW").toInt(), 16 * 230); + QCOMPARE(e.value("after").toMap().value("mechanism").toMap().value("currentA").toInt(), tenusA); + QCOMPARE(e.value("cut").toBool(), false); + const QString debut = e.value("startedAt").toString(); + + // 2. COUPÉE — la maison monte à 22 A : même au plancher de 6 A, la phase dépasse. La + // protection coupe, et c'est TOUJOURS le même épisode. + cycle(22); + QCOMPARE(consigneA(s.borne), 0); + QCOMPARE(decision().value("code").toString(), QString(DecisionCode::OverloadThrottled)); + p = decision().value("params").toMap(); + QCOMPARE(p.value("cut").toBool(), true); + QCOMPARE(p.value("toA").toInt(), 0); + QCOMPARE(p.value("fromA").toInt(), 16); + QCOMPARE(qRound(entree().value("allocatedW").toDouble()), 0); + const QString coupee = renderFr(DecisionReason(DecisionCode::OverloadThrottled, p), "Borne"); + QVERIFY2(coupee.startsWith(QStringLiteral("Borne coupée : le branchement atteignait sa limite " + "(phase A, 25 A)")), qUtf8Printable(coupee)); + evts = journal(); + QCOMPARE(evts.size(), 1); + e = evts.first().toMap(); + QCOMPARE(e.value("startedAt").toString(), debut); + QCOMPARE(e.value("cut").toBool(), true); + QCOMPARE(e.value("after").toMap().value("powerW").toInt(), 0); + QCOMPARE(e.value("after").toMap().value("mechanism").toMap().value("chargingEnabled").toBool(), false); + QVERIFY(!e.contains("endedAt")); + + // 3. RETOUR — le four s'éteint. Le cycle reprend la borne : le motif se lève AU MÊME CYCLE. + // L'épisode, lui, n'est clos qu'après un cycle complet sans protection — daté de la reprise. + cycle(2); + const QDateTime reprise = a; + QCOMPARE(consigneA(s.borne), 16); + QCOMPARE(decision().value("code").toString(), QString(DecisionCode::EvOffPeakFullPower)); + QVERIFY(entree().contains("levels")); + QVERIFY(!journal().first().toMap().contains("endedAt")); + + notifs.clear(); + const int revisionAvant = snap.value("shedding").toMap().value("revision").toInt(); + cycle(2); + evts = journal(); + QCOMPARE(evts.size(), 1); // UN épisode, pas un par cycle + e = evts.first().toMap(); + QCOMPARE(e.value("startedAt").toString(), debut); + QCOMPARE(e.value("endedAt").toString(), reprise.toUTC().toString(Qt::ISODate)); + QVERIFY(snap.value("shedding").toMap().value("open").toStringList().isEmpty()); + QCOMPARE(snap.value("shedding").toMap().value("revision").toInt(), revisionAvant + 1); + // 4. LA FERMETURE NOTIFIE, alors que rien d'autre ne change : même motif, même allocation. + QCOMPARE(notifs.size(), 1); + QVERIFY(notifs.first().first().toMap().value("shedding").toMap().value("open").toStringList().isEmpty()); + // Contre-épreuve : le même cycle, rejoué, ne notifie RIEN. La notification précédente + // venait donc de la fermeture seule. + notifs.clear(); + cycle(2); + QCOMPARE(notifs.size(), 0); +#endif +} + +void Simulation::testOverloadProtectionOnAManualChargerIsLogged() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // Une borne en mode MANUEL est hors de l'arbitrage, donc hors de loads[] : seule la protection + // de surcharge la touche encore, et elle peut la rabaisser la nuit sans que rien ne le dise. + // Ici le journal le dit, et la notification part même si rien d'autre ne change. + ScenarioHc s = preparerScenarioHc(QStringLiteral("manuel"), BatterieHc::Aucune, QVariantList(), + BorneHc::Monophasee); + QVERIFY(s.arb && s.borne); + const QString borne = s.borneId.toString(); + QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 25}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + // Ce que l'usager a réglé à la main — c'est ce que la reprise lui rendra. + { + EnergySettings reglages; + reglages.beginGroup("ChargingInfos"); + reglages.beginGroup(borne); + reglages.setValue("manualChargingEnabled", true); + reglages.setValue("manualMaxChargingCurrent", 16); + reglages.setValue("manualDesiredPhaseCount", 1); + reglages.endGroup(); + reglages.endGroup(); + } + s.borne->setStateValue("pluggedIn", true); + const QVariantMap info = injectAndWait("NymeaEnergy.GetChargingInfos").toMap().value("params").toMap() + .value("chargingInfos").toList().first().toMap(); + QVariantMap manuel = info; + manuel.insert("chargingMode", "ChargingModeNormal"); + QCOMPARE(injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", manuel}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + QCOMPARE(consigneA(s.borne), 16); + + // L'HORLOGE MURALE, et elle seule : le passage en mode manuel restitue les valeurs de + // l'usager avec QDateTime::currentDateTime() (code amont), et arme les verrous de la borne sur + // cet instant. Un temps de test antérieur les tiendrait fermés, et la protection serait + // refusée à chaque geste. On part donc au-delà du verrou d'enclenchement (300 s). + QDateTime a = QDateTime::currentDateTime().addSecs(600); + QVariantMap snap; + const auto cycle = [&](double phaseA) { + a = a.addSecs(60); + poserPhaseA(s.meter, phaseA); + s.arb->recordMeterUpdate(a); + s.arb->simulationCallUpdate(a); + QCoreApplication::processEvents(); + snap = s.arb->loadTelemetry(); + }; + const auto journal = [&]() { return evenementsDelestage(injectAndWait("NymeaEnergy.GetSheddingLog")); }; + + cycle(18); // 2 A de maison + 16 A de borne + QVERIFY(!telemetrieParId(snap).contains(borne)); // hors arbitrage : hors de loads[] + QVERIFY(!snap.contains("shedding")); + + // 1. 28 A sur la phase A : la protection rabaisse la borne, deux fois dans l'appel (dans le + // cycle, puis sur powerBalanceChanged — le compteur n'a pas encore bougé). + QSignalSpy notifs(s.arb, &EnergyArbitrator::loadTelemetryChanged); + cycle(28); + const int tenusA = consigneA(s.borne); + QVERIFY2(tenusA > 0 && tenusA < 16, qUtf8Printable(QString::number(tenusA))); + QVERIFY(!telemetrieParId(snap).contains(borne)); // toujours hors de loads[] + QCOMPARE(snap.value("shedding").toMap().value("open").toStringList(), QStringList{borne}); + // L'OUVERTURE NOTIFIE, et rien d'autre ne pouvait le faire : la borne n'est pas dans loads[]. + QVERIFY(!notifs.isEmpty()); + QCOMPARE(notifs.last().first().toMap().value("shedding").toMap().value("open").toStringList(), + QStringList{borne}); + QVariantList evts = journal(); + QCOMPARE(evts.size(), 1); + QVariantMap e = evts.first().toMap(); + QCOMPARE(e.value("before").toMap().value("mechanism").toMap().value("currentA").toInt(), 16); + QCOMPARE(e.value("after").toMap().value("mechanism").toMap().value("currentA").toInt(), tenusA); + QCOMPARE(e.value("phase").toString(), QStringLiteral("A")); + QCOMPARE(e.value("overshootW").toInt(), 690); + QVERIFY(!e.contains("endedAt")); + + // 2. La maison redescend : verifyOverloadProtectionRecovery() rend les 16 A manuels. La tenue + // est levée ; l'épisode se clôt au cycle complet suivant, daté de la reprise. + cycle(2 + tenusA); + const QDateTime reprise = a; + QCOMPARE(consigneA(s.borne), 16); + notifs.clear(); + cycle(18); + e = journal().first().toMap(); + QCOMPARE(e.value("endedAt").toString(), reprise.toUTC().toString(Qt::ISODate)); + QVERIFY(snap.value("shedding").toMap().value("open").toStringList().isEmpty()); + // LA FERMETURE NOTIFIE, seule cause possible : la borne n'est pas publiée dans loads[]. + QCOMPARE(notifs.size(), 1); + notifs.clear(); + cycle(18); + QCOMPARE(notifs.size(), 0); // contre-épreuve : rien ne change, rien ne part + QCOMPARE(journal().size(), 1); +#endif +} + +void Simulation::testSheddingLogCoversEveryMechanism() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // Deux mécanismes que L4 déleste de deux façons : un chauffe-eau à relais (palier en watts, + // coupé) et une PAC SG-Ready (état, rendue à 2 — jamais coupée). Le journal les enregistre + // sans connaître leur type : chacun dit « avant » et « après » dans la charge utile que son + // adaptateur publie. Puis le dépassement dure 20 minutes : UNE entrée par charge. + const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-journal-delestage.json"; + QFile::remove(cfgPath); + qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + + EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); + QVERIFY(arb); + ThingManager *tm = NymeaCore::instance()->thingManager(); + const QUuid meterId = addMeter(); + m_experiencePlugin->energyManager()->setRootMeter(meterId); + Thing *meter = tm->findConfiguredThing(meterId); + QVERIFY(meter); + meter->setStateValue("connected", true); + + const QUuid k1 = addPowerSwitch(0, 26694); + const QUuid k2 = addPowerSwitch(0, 26695); + const QUuid ecsRelais = addPowerSwitch(2000, 26696); + QVERIFY(tm->findConfiguredThing(k1) && tm->findConfiguredThing(k2) && tm->findConfiguredThing(ecsRelais)); + + LoadConfigStore *store = new LoadConfigStore(arb); + arb->setLoadConfigStore(store); + LoadConfigs cfgs; + cfgs.append(LoadConfig::fromMap(QVariantMap{ + {"id", "pac-delestee"}, {"label", "PAC"}, {"adapter", "sg-ready"}, {"mode", "fixed"}, + {"priority", 2}, {"enabled", true}, {"domain", "hvac"}, + {"sgReady", QVariantMap{ + {"minStateHoldS", 300}, + {"states", QVariantList() + << QVariantMap{{"state", 1}, {"relays", QVariantList() << k1.toString()}, {"estimatedPowerW", 0.0}} + << QVariantMap{{"state", 2}, {"relays", QVariantList()}, {"estimatedPowerW", 0.0}} + << QVariantMap{{"state", 3}, {"relays", QVariantList() << k2.toString()}, {"estimatedPowerW", 800.0}} + << QVariantMap{{"state", 4}, {"relays", QVariantList() << k1.toString() << k2.toString()}, + {"estimatedPowerW", 3000.0}}}}}})); + cfgs.append(LoadConfig::fromMap(QVariantMap{ + {"id", "ecs-delestee"}, {"label", "Chauffe-eau"}, {"adapter", "relay-router"}, + {"mode", "fixed"}, {"priority", 3}, {"enabled", true}, {"domain", "ecs"}, + {"relays", QVariantList() << QVariantMap{{"thingId", ecsRelais.toString()}, {"powerW", 2000}}}, + {"minOnS", 0}, {"minOffS", 0}})); + QString err; + QVERIFY2(store->setConfigs(cfgs, &err), err.toUtf8()); + QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 25}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + + QVariantMap snap; + const auto cycle = [&](const QDateTime &at, double phaseA, double totalW) { + meter->setStateValue("currentPhaseA", phaseA); + meter->setStateValue("currentPhaseB", 0); + meter->setStateValue("currentPhaseC", 0); + meter->setStateValue("currentPower", totalW); + arb->simulationCallUpdate(at); + QTest::qWait(20); + QCoreApplication::processEvents(); + snap = arb->loadTelemetry(); + return telemetrieParId(snap); + }; + const auto journal = [&]() { return evenementsDelestage(injectAndWait("NymeaEnergy.GetSheddingLog")); }; + const auto parCharge = [&]() { + QHash h; + for (const QVariant &v : journal()) + h.insert(v.toMap().value("loadId").toString(), v.toMap()); + return h; + }; + + // Le surplus porte les deux charges : la PAC en état 4 (après son armement à froid), le + // chauffe-eau à 2000 W. + const QDateTime t0 = utcDateTime(QDate(2026, 8, 30), QTime(13, 0, 0)); + cycle(t0, 0, -6000); + QHash tele = cycle(t0.addSecs(3600), 0, -6000); + QCOMPARE(tele.value("pac-delestee").value("mechanism").toMap().value("state").toInt(), 4); + QCOMPARE(qRound(tele.value("ecs-delestee").value("mechanism").toMap().value("stageW").toDouble()), 2000); + QVERIFY(!snap.contains("shedding")); + + // DÉPASSEMENT de 2 300 W sur la phase A (35 A pour 25) : L4 déleste en ordre inverse du rang, + // le chauffe-eau (rang 3, 2 000 W) puis la PAC (rang 2, 3 000 W). + QSignalSpy notifs(arb, &EnergyArbitrator::loadTelemetryChanged); + const QDateTime t2 = t0.addSecs(3610); + tele = cycle(t2, 35, 8050); + QCOMPARE(tele.value("ecs-delestee").value("decision").toMap().value("code").toString(), QString(DecisionCode::DrawCap)); + QCOMPARE(tele.value("pac-delestee").value("decision").toMap().value("code").toString(), QString(DecisionCode::DrawCap)); + QCOMPARE(snap.value("shedding").toMap().value("open").toStringList(), + (QStringList{"ecs-delestee", "pac-delestee"})); + QVERIFY(!notifs.isEmpty()); + QCOMPARE(notifs.last().first().toMap().value("shedding").toMap().value("open").toStringList(), + (QStringList{"ecs-delestee", "pac-delestee"})); + + QHash j = parCharge(); + QCOMPARE(j.size(), 2); + // Le chauffe-eau : un PALIER, en watts, COUPÉ. + const QVariantMap ecs = j.value("ecs-delestee"); + QCOMPARE(ecs.value("before").toMap().value("mechanism").toMap().value("kind").toString(), QStringLiteral("relay")); + QCOMPARE(qRound(ecs.value("before").toMap().value("mechanism").toMap().value("stageW").toDouble()), 2000); + QCOMPARE(qRound(ecs.value("after").toMap().value("mechanism").toMap().value("stageW").toDouble()), 0); + QCOMPARE(ecs.value("before").toMap().value("powerW").toInt(), 2000); + QCOMPARE(ecs.value("after").toMap().value("powerW").toInt(), 0); + QCOMPARE(ecs.value("cut").toBool(), true); + // La PAC : un ÉTAT, de 4 à 2 — rendue à son thermostat, pas coupée (ECS-413). + const QVariantMap pac = j.value("pac-delestee"); + QCOMPARE(pac.value("before").toMap().value("mechanism").toMap().value("kind").toString(), QStringLiteral("sgReady")); + QCOMPARE(pac.value("before").toMap().value("mechanism").toMap().value("state").toInt(), 4); + QCOMPARE(pac.value("after").toMap().value("mechanism").toMap().value("state").toInt(), 2); + QCOMPARE(pac.value("before").toMap().value("powerW").toInt(), 3000); + QCOMPARE(pac.value("cut").toBool(), false); + for (const QVariantMap &ev : {ecs, pac}) { + QCOMPARE(ev.value("source").toString(), QStringLiteral("connection")); + QCOMPARE(ev.value("phase").toString(), QStringLiteral("A")); + QCOMPARE(ev.value("overshootW").toInt(), 2300); + QCOMPARE(ev.value("startedAt").toString(), t2.toUTC().toString(Qt::ISODate)); + QVERIFY(!ev.contains("endedAt")); + } + + // LE DÉPASSEMENT DURE 20 MINUTES — un cycle par minute. Plus rien à délester (tout est à 0), + // et la cascade ne rallume rien sous un plafond épuisé : les deux épisodes restent OUVERTS, + // et il n'en naît aucun autre. + QDateTime t = t2; + for (int k = 0; k < 20; ++k) { + t = t.addSecs(60); + cycle(t, 35, 8050); + } + j = parCharge(); + QCOMPARE(journal().size(), 2); + QVERIFY(!j.value("ecs-delestee").contains("endedAt")); + QVERIFY(!j.value("pac-delestee").contains("endedAt")); + + // Le dépassement cesse : le plafond ne tient plus personne. Les deux épisodes se ferment à la + // fin de ce cycle, datés de lui — 21 minutes, une entrée chacun. + notifs.clear(); + const QDateTime fin = t.addSecs(60); + cycle(fin, 0, -6000); + j = parCharge(); + QCOMPARE(journal().size(), 2); + for (const QString &id : {QStringLiteral("ecs-delestee"), QStringLiteral("pac-delestee")}) { + QCOMPARE(j.value(id).value("startedAt").toString(), t2.toUTC().toString(Qt::ISODate)); + QCOMPARE(j.value(id).value("endedAt").toString(), fin.toUTC().toString(Qt::ISODate)); + } + QVERIFY(snap.value("shedding").toMap().value("open").toStringList().isEmpty()); + QVERIFY(!notifs.isEmpty()); + QVERIFY(notifs.last().first().toMap().value("shedding").toMap().value("open").toStringList().isEmpty()); +#endif +} + +void Simulation::testSheddingLogKeepsTheLastEpisodes() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // Vingt et un épisodes sur un chauffe-eau : un cycle en dépassement, un cycle libre. Le journal + // en garde vingt ; le plus ancien sort, et aucun identifiant n'est réutilisé. + const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-journal-tournant.json"; + QFile::remove(cfgPath); + qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + + EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); + QVERIFY(arb); + ThingManager *tm = NymeaCore::instance()->thingManager(); + const QUuid meterId = addMeter(); + m_experiencePlugin->energyManager()->setRootMeter(meterId); + Thing *meter = tm->findConfiguredThing(meterId); + QVERIFY(meter); + meter->setStateValue("connected", true); + const QUuid relais = addPowerSwitch(2000, 26697); + QVERIFY(tm->findConfiguredThing(relais)); + + LoadConfigStore *store = new LoadConfigStore(arb); + arb->setLoadConfigStore(store); + QString err; + QVERIFY2(store->setConfigs(LoadConfigs() << LoadConfig::fromMap(QVariantMap{ + {"id", "ecs-tournant"}, {"label", "Chauffe-eau"}, {"adapter", "relay-router"}, + {"mode", "fixed"}, {"priority", 1}, {"enabled", true}, + {"relays", QVariantList() << QVariantMap{{"thingId", relais.toString()}, {"powerW", 2000}}}, + {"minOnS", 0}, {"minOffS", 0}}), &err), err.toUtf8()); + QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 25}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + + QDateTime t = utcDateTime(QDate(2026, 8, 30), QTime(13, 0, 0)); + const auto cycle = [&](double phaseA, double totalW) { + t = t.addSecs(60); + meter->setStateValue("currentPhaseA", phaseA); + meter->setStateValue("currentPhaseB", 0); + meter->setStateValue("currentPhaseC", 0); + meter->setStateValue("currentPower", totalW); + arb->simulationCallUpdate(t); + QCoreApplication::processEvents(); + }; + + cycle(0, -6000); // le chauffe-eau s'enclenche sur le surplus + QStringList debuts; + for (int n = 0; n < SheddingLog::DefaultCapacity + 1; ++n) { + cycle(30, 6900); // 1 150 W de trop : délesté + debuts << t.toUTC().toString(Qt::ISODate); + cycle(0, -6000); // libre : rallumé, l'épisode se ferme + } + const QVariantMap reponse = injectAndWait("NymeaEnergy.GetSheddingLog").toMap().value("params").toMap(); + const QVariantList evts = reponse.value("events").toList(); + QCOMPARE(reponse.value("capacity").toInt(), SheddingLog::DefaultCapacity); + QCOMPARE(evts.size(), SheddingLog::DefaultCapacity); + // Le PREMIER épisode est sorti : le plus ancien gardé est le deuxième. + QCOMPARE(evts.first().toMap().value("id").toInt(), 2); + QCOMPARE(evts.first().toMap().value("startedAt").toString(), debuts.at(1)); + QCOMPARE(evts.last().toMap().value("id").toInt(), SheddingLog::DefaultCapacity + 1); + QCOMPARE(evts.last().toMap().value("startedAt").toString(), debuts.last()); + for (const QVariant &v : evts) + QVERIFY2(v.toMap().contains("endedAt"), + qUtf8Printable(QJsonDocument::fromVariant(v).toJson(QJsonDocument::Compact))); +#endif +} diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 2a0fe77..ac20465 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -333,6 +333,19 @@ private slots: // relais fantôme comprise — et ce chiffre entrait dans le RECRÉDIT, donc dans le budget. void testVanishedRelayFreezesTheLoad(); + // ── Lot délestage visible (2026-10-25) ────────────────────────────────── + //! La borne abaissée puis coupée par la protection de surcharge le DIT (OVERLOAD_THROTTLED), + //! le motif se lève au retour du cycle, et l'épisode fait UNE entrée du journal. + void testOverloadProtectionReasonOnTheCharger(); + //! Une borne en mode manuel, hors de loads[] : l'épisode se lit au journal, s'ouvre et se + //! ferme par une notification qui n'a pas d'autre cause, et la reprise amont le referme. + void testOverloadProtectionOnAManualChargerIsLogged(); + //! Deux mécanismes délestés par L4 (relais, SG-Ready) : un épisode de 20 min = une entrée + //! par charge, avant / après dans le langage de chaque mécanisme. + void testSheddingLogCoversEveryMechanism(); + //! N + 1 épisodes : le plus ancien sort, les identifiants ne sont jamais réutilisés. + void testSheddingLogKeepsTheLastEpisodes(); + void printStates(Thing *thing); void updateChargerMeter(Thing *thing); From c85c822d053161b3534d2fc79a38ac3a0e93ef7f Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 25 Oct 2026 11:05:53 +0100 Subject: [PATCH 21/26] =?UTF-8?q?docs=20:=20d=C3=A9lestage=20visible=20?= =?UTF-8?q?=E2=80=94=20motif=20de=20la=20borne,=20journal=20court,=20notif?= =?UTF-8?q?ication=20(=C2=A78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/DESIGN_DELESTAGE.md | 57 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/docs/DESIGN_DELESTAGE.md b/docs/DESIGN_DELESTAGE.md index a973594..7c3f352 100644 --- a/docs/DESIGN_DELESTAGE.md +++ b/docs/DESIGN_DELESTAGE.md @@ -299,3 +299,60 @@ la descente, mais `applyAction()` renvoyait l'action **demandée**, donc `state vivra **hors du moteur**. Les confondre ferait hériter à ce lot d'une contrainte qui ne le concerne pas. - **Il ne déplace pas la frontière de sécurité.** L4 garde le dernier mot, L0 reste physique. + +## 8. Le délestage VISIBLE (lot du 2026-10-25) + +Question de Patrick : « Si le délestage coupe une charge, est-ce que le client voit une alerte +quelque part ? » Avant ce lot, non. Décision du 2026-10-22 : l'alerte passe par l'app, par les +événements JSON-RPC, sans aucun flux sortant (invariant 10). **Ce lot publie, il ne décide rien +de plus** : aucune consigne, aucun émetteur, aucun verrou ne change (invariant 2, règle 4). + +**8.1 — La borne : motif `OVERLOAD_THROTTLED`.** `verifyOverloadProtection()` rabaisse une borne +du dépassement arrondi à l'ampère supérieur, et la coupe sous son plancher. Ses consignes portent +`ChargingActionIssuerOverloadProtection`, mais rien n'en sortait. `executeChargingAction()` est +devenu virtuel (marque `[ETM]`, corps amont inchangé) : l'arbitre laisse partir la consigne, +puis la lit. + +| Consigne | Effet | +|---|---| +| protection, baisse ou coupure | la tenue s'ouvre ou se prolonge ; la borne publie `OVERLOAD_THROTTLED` | +| protection, remise au minimum après coupure (`verifyOverloadProtectionRecovery`) | la tenue se prolonge | +| protection, retour au courant manuel (`verifyOverloadProtectionRecovery`) | la tenue est levée | +| tout autre émetteur (le cycle reprend la borne) | la tenue est levée | +| émetteur inconnu (consigne par défaut de la boucle amont, voir 8.5) | rien | + +Paramètres : `fromA`, `toA` (0 si coupée), `cut`, `overshootW` (le plus fort dépassement de la +tenue), `limitA`, `phase` (absente pour une triphasée). La préséance est celle de L4 : l'entrée +perd ses niveaux, et `allocatedW` dit ce que la protection laisse à la borne. Hors d'un cycle +(`powerBalanceChanged`), l'instantané publié est complété et republié tel quel : son +`timestamp` reste celui du dernier cycle, et le geste est daté par le journal. + +**8.2 — Le journal court.** En mémoire, 20 épisodes (`SheddingLog`), lu par +`NymeaEnergy.GetSheddingLog`, même droit que `GetLoadTelemetry`. **Perdu au redémarrage de +nymead : accepté.** Il est hors de la télémétrie parce qu'il la doublerait (20 épisodes à deux +charges utiles de mécanisme, à chaque battement). La télémétrie n'en porte que le résumé, +`shedding: {revision, open}`, **absent** tant que rien n'a été délesté depuis le démarrage. + +- **Un épisode, pas un cycle.** Il s'ouvre au premier geste. Il se ferme à la fin du premier cycle + complet où la protection ne tient plus la charge, et la fin est datée du premier instant libre. +- **Toutes les charges de la cascade**, enregistrées dans `shedWaterfallForOverload()` sans liste + de types : « avant » et « après » sont la charge utile de `runtimeView()` (palier des relais, + consigne, état SG-Ready). Une charge de la cascade reste tenue tant que le plafond est épuisé au + compteur, ou que son motif est `DRAW_CAP`. +- « Après » garde l'état le plus bas de l'épisode, et `cut` dit si la charge a été coupée au moins + une fois. Une PAC rendue à l'état 2 n'est pas coupée. + +**8.3 — La notification.** `shedding.revision` change à chaque ouverture et à chaque fermeture. +`telemetrySignificantlyDiffers()` le compare : `LoadTelemetryChanged` part, même quand rien +d'autre n'a bougé. + +**8.4 — Limites connues.** Sous le mode dégradé L2, aucun cycle ne tourne : le journal ne se +ferme pas, il reprend au retour du compteur. Une charge que la cascade laisse éteinte parce que +la marge restante est sous son palier (`BELOW_MIN_POWER`) n'est plus tenue au sens du journal : +l'épisode se ferme quand le plafond n'est plus épuisé. + +**8.5 — Défaut amont relevé, NON corrigé.** Dans la boucle de `verifyOverloadProtection()`, une +borne monophasée active dont la phase ne dépasse pas reçoit `ChargingAction()` par défaut : +non enclenchée, 0 A, émetteur inconnu. Elle est donc coupée (verrous permettant) quand une autre +phase dépasse. Ce lot ne le publie pas comme geste de protection et ne le corrige pas : c'est +une décision de sécurité, hors de son périmètre. From e775f66e58038b2b57520a5bcc0a6c7e6dec196e Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 25 Oct 2026 11:08:10 +0100 Subject: [PATCH 22/26] =?UTF-8?q?decisions=20:=20r=C3=A9f=C3=A9rence=20?= =?UTF-8?q?=C3=A9tendue=20au=20lot=20d=C3=A9lestage=20visible?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hors épisodes de protection, IDENTIQUE à 76bf482 : les 85 fonctions communes sont identiques octet pour octet, sauf trois. Celles-là gardent leurs actions (things, proxy) identiques ; seuls le motif des bornes tenues (OVERLOAD_THROTTLED, sans niveaux) et le bloc shedding changent : testL4ShedsAPacAndSaysItForcedTheLock (1 ligne), testBlockedDischargeShowsAtTheMeterAndL4Decides (2), testOffPeakFullPowerYieldsToThePhaseLimit (9). Quatre fonctions nouvelles. Co-Authored-By: Claude Opus 5.5 --- .../decisions/reference/decisions.ndjson.xz | Bin 569820 -> 571268 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/tests/auto/decisions/reference/decisions.ndjson.xz b/tests/auto/decisions/reference/decisions.ndjson.xz index 91616ec0a75d62c3a68d946fcc8d9f0d86aaee33..b70f106f37a6ec81268df0d626421ffcc9c72453 100644 GIT binary patch delta 10445 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 D!AV_y delta 8986 zcmV+#BjwzL?;_meA|U-YdRSTj00idNv_=HLm*XP#tMDoz0UQ7T006mJs}g}Ag&+Zi zAOeLT1BD<2g&+lmAO?jX2elvwXJrMPE9guqw~S>7%PD^wNL<=YAiZJxKl29@SH0{) zkp_iOiRn4edv-gjh0Togr0HogB%PzdOkzT_p-da6*`pA!9=No6PDgQR(E198r>;5l z>_I7^8aE2s1!GCm40&X4S1xlKs8vB@ePj3$KCoXe)`^w36bF^^DF&YGVOVm@ z;3ptswMCJox4iBFW_L|$rk@Z3_>x};xt#>S3zr7%6rVr)MjdDyz=fwDOqqGhX9UuisLHDUuwtv6Cb%8HH-4P%X)vfLrFVg-1qd(K5am0*s<-+nUyq; zCX*c4FVMZa)5UQQ%7&B`C@+jyrJ=#fPwCIlW~?+DOJk{~xlvafqMY z-~E=P2>?_j|Hnqz6^4+B7h8*GrRy^MnW}$v_3|7jgu!WPIhx=^ijO&#%W~xVam-ls z0Ijo=>c9_cB9Y1f)gf4mHws)zomW01kb&+TX8fWiP+@A#%z(pD%A>DA@L~lJ7s`I za?^aF`Ql<$r8O&Nu9i=yj$ZU`Q_z2mAy^@3{}z;WeX-Z?ot`03Vv_}{v?((4ZctOp zI=wE$21yb&ES<=pW&43Of3FG(5pr1L`c4!>iG9(qLKdkF;MYep34AK!@2qC$=eya+qDnZDO?YLv& zA~Zu#HszVyo;EXJk`jPlyPB%DxN;b0ON=Jr{-{?Ks>f!=`(n})0_9Oqc8)n(MEmwx z{s<=WP>23{3|(is{>`P+e~y2%2ZuqU31*>*zi$_N??42x!L@<{+MnS(i9Z@F#;4iefQZG1!tQZ&B^@Z z!o5Yw2OLCDiWI=t4Mz63iFC17acY}R48ZE=K(IQdKBcs|&stZsB=sD>OmwbK_}5|9YfaRMmg$QsL?qlAWIWFXRv>mu|i2aouFf>EGs zbTj|{!wo!4;qOHqYJDU-;W%ODN)>w@5mFbLn+!?Z#5`hj$?$LJ!9>MeH+m!?90q1N zW&lHk!84U;KFn*9^ys7iwCP{(vIU~2^o(uq%2#Svtg(MQbY;4|#*r3Lk859$t%oBM zQLL!XX5(ysDe~CAz4h__D#FGgv+DkMU*cud`xG&kqRG^Ji~&VqmWLP)!=?RYs^M>a zkZ6@FbS$a^AGVdh4u+;2$@PkIkRow(dV?r9s^k*m`{XNl-;HQ=C3ls`N!g9&a1^1H z#9q*;U^{=r5Egfn>%@oWWp~Vb01ZM1uvmJjQbOw2VZeIDwpsNe`Zc*bqlkhs(FN7& zJt;@x|Gay{{N}! z<&YM3Y>jNb`3rx>{6Vi1eMQkXFKGxW^y@4K3k!d8KW!@x(6$)4HF?IZ02<=)d#JjZ11IJUYXED_E1mYqbK2Z+p2a1j^S!pkNg&)T$iX7ht+ z^YS#i+0pgcO~Rnr7_?sfxs3Gd1=8%Ka+pwtVpRi4Vajj-1!g2QW}ucwR0bpz2pk>^ymS6Ked*kN}X@mxx4?x0`TDl zWt2c#vR&V`){3>OXsHP)b#2hzuR&%<@)0vk?_#(=b;U#z5==Q++A zpDZptWvZ<=%MRhH;>j_>Oo`*2H;R7+rCKzE4sReIdP+fBAI7A}|K};y?E{A22Nbu- z?-r9VbtHaoan4KOIO>&`^vO_4ev9>#;s}nDyWhuoCsC%-h}1&@98`O?QP1F@Q4^&+ zg*s-JDB01p7vw;qL0l=z_aT60k&>#FA2&MJ)CYy$lF(yO-!1XD=Q4j7__0!C zp4S}3J13taJ_(zzy4{Te!{@IYxwTNy3vJQMDoF1p3VAJW;iqr$OKzhpKn$NkCWnYN zXz@^fI8+9r!(109SZE$??q(MCF}c)(Mvv>K_XQKkq`07(Ho`Arp=M5$M^>tH(Z)Sr z9y);+`)#HzTK4T<192Pt*}TmNT%O7^tVU`2&Up zjVw*c8*n}&#FwEXU6Vn|(C($a7qJz2cgz6)9qIynWkefDKB-a9w8}4Omx>(ourTj) z70lvLu;mtPl1xLaAgIaYrWF8^2X*}!$w$XmDbmG9(B$TT7IZFUSmu8zG*Y_8oS74i zhvkIVs}bBUu=24zDL6loB5{yEmXq~D4##Yve{h!sCIksXzA)+wdFcIrifOz&;V|{a zJs~hEI}ZX*%a;UPcf?z>noO8wC<^(MTD)SRg+^q`M(debx;m20!0@-@IDOk?vmV&u zGxX?RxVq(zqs0&RjqHCfCWn=I`uRv^e2$S7`NR5!CR7v7z@$gC13Zs0S7rYSghdba zXDrE1Gdu|lAJdOpjsN8|l7e=-W!9F8L95R2Mw5>}m-gXbGzKXjP z*b0yieS%+t7CCSFV4P_|i=@q>bIEBkp3PNr?Iw4YJ}x6^)KWVkFH#<*2~|ohKlWN}V+3 ze>eG_;Y7lvQyW$#laU-&xd8VtwhP(AUW_{nqVH+yjOz;i-azRit=|$%M6&8zZWe*| zL`e9I`dy$QU?8)r@z9>%KlUYwj%MKbD>+G6N#Tagl;wX$33xghR7V^U#McW|pI0^4 z^K6dCu-BB;ITin}7ojvRq+=rr-{4Cwixi8f6V;LGT!V~qYW?`tTVJYl1xL4;`y&*g%5s0?z~=!WfmJg|7%%n$_yYEF`KF{L zYjFtt6Jx#HjHJ?>zuUkoH6qjzOMZ<8`aP*wbmpSjJr@~WF-MZbu}!LuR+=2C2lqAu4h;D*E8UX#hKVXt=!qQ%s*Jy}r^@ERnT)m#ir)X6J ztXqDCkZ`Y)c^3D%t1x{oGjqX=H$trvY8iieC0(`XydG&peOHyg8dGQ5{X=xlN z(FJ*qBlet#MkRA>!bPkT{=HpUusRg#k{|R9~dy4@bCoPa_|*0tak6kn-g@uZ{_mswh6S&X_|wQ4=+%+`&wF9$8~?Q z~R!3A;HC}kG@WjR%grYNK1^lB4ZJyzXV!|)Zwy6t45moy58C#?ER!J z^oppfPY?bPqba?$O)hd6%^djXG1_~L)al;qZlM0ahcm!j-oef}6KyLlz@AQ;)y$`;ja2)IrHBy%n^_$u(^9T-#-N~Qj zSzP9zMB~BqmOh-lQJ=dw;Vu$=&~}1aAbt1zjj!_2#Z$2M-_bkLFft@y(HJ_N2*5x+ zMyTAzC$@RnprHn{sL%&{?Z!;R^`HpM-tCN;Fb&8&29FLDHPhM?E*w_=*;s+jFL${cNv0J>YN}A5#R?eP! z$^@EPMTa$}SWiJc9vQwyKBN5v!Ms97Cm>ZI(tZ0R1^pLMe*gfufeM(|OblB3VjSPe zEBX=_;cN}-(X;WoAw`~Nf4YAzX{2eaGNCcBPg}8^phCRj)81*)+oGniIwF1wfLZ<^ zzvv2~d`=l3b&!Iv9g{>{PzB`kz;YPsSqZ5d;}U6_rUZ$yAU3$<=b5hHbRmtxzoULn zy1Hf1BiiaT&t_%1PnIFtYd>U%7kFw$?bwxeK%O3OVMm5NaenIqfB1jQRH=h8D_g16 z^rH=*$HRzbhZsi8t>V~gPrXlnQm5LJ{RsdGUKpPW{GW0{m+dbo_n01(bj5f+WpsKl z6)Lu8AzKmd3Z%O?KZ&XVV1VuA6UAgv!^PlfTV>1KLw~Z=t$_X$AL;Yn?xKqJ{QAC* zss^qWmAG!Ry3+y=hh8RosKXY z0PcB-(6dmiTWcR})8Or3?!=tQI+AK)0NAQeVzw)s*ojp{As>G!D3#10w`#HrU}5KZ zd9bwnbH!b36(Yy1x6}|z6K2^Ye(*d$E(HZ-iY^I;kiFQm)KN2FVkvnA)LsJfuP}*d z0j_;Vaq&z{+ZYb1dq#WN$IsbEnNFyMl%(Y#pt5|=ZPs#d8pD#P4@3t{aqSm<_lORO zho|adeMV3pkQ#rZE-%1_&O?G*Q-Ag8Wu?KH42{@KKur=~^$x^q{T(pLK3p>OrAdc7 z$y&#O93?heq1Rm)rS%FB$lQbp%^uRL4l-F9dTkKle^4`QY@xX;upHR(nIgzF2p728 z8aO|Ys=iJ?<^xSGnd2lx7i(Ei9Ovf{x*}O$-jec?D+YgZaQM?bV<-T<4{;kBhVy3*gUG01^N=MysxCKH#$$CKZS0DX0;{lz^3D*19vLfgdrLeQ zC=HiEj+`&&F$Vj8$f++KM!@{zhFe$u9i*5WHe`Q%u9o77amY-?l#?nyQ<64@utBW# z)L`%)=X!rW{be&FKEEfBKV65DA2dtB5zUcP#a)31Ecq)^C#yFxjR1ECQM|i6K%sb- z@@ga(xzDz>7xroA@e_!28Zsm2L89gp{4UgyzXI8`KQhCHK6Ab&wL?Z;4O)!s^L}Uu zGAp7=pI?#dH-NtDFBq*?@B!bbWZqb5RKNh=3mAVnhx-Mv<^@?&34mjaoGu&%56aHU zAq{?{A%(M%i6Y`H|2d2(0czvZNQlSZ3g=H#GepR|mhkYV4F&=$%d&xz({_Cz*7!V& zTtF6IM+)RU}F_x_s_4F+cGcY0|+$la? zjSe2A$Shr)innq!sKtfl3fOwZclF5>xmYiy_C=1@%+p7UqnLOUs})#vG|DKIwi&=I z&boHD9=1Z}r`chzEBu0yg(yARpbs|^%pZSp$9dNpV@5JSAD7Nb4!uW!Y#ZEJE@RMF zpjICCG#WYz6pSLh^Z%kw;F{2B{&u{NZwqH$My(WXIg(;izj8JMM*AV2Hp}{(@;0y4 z-a8GO0Us1#;@l007g!8RvlR8xlnfj!U4cz2rbH0l43A0!;6)74rt-olnh1|wP2?hCmnihDU}h$D*d|QkmTFOg zb^9U`iS7zn%@5qv2N@ORfomgT$c29Zz|cw;sPlBzf@a;hg?NvyHGqlq5VBswg^nV_*0kN00dxKRWFKk85N(I zpzCpwoRVzUS{_xbgEWb9T3~|9vj)2eIRHr%A%4<{?m!zhcnTkOo}<{45?y(lXk)E< zIbvrHMRW$E5zuK&Vi{64B&>fcD6~v56k^E1I`8wt_6e&8kG&OU$!%rDI@>N7sDvilm|+*t-F$v(RFoVpD&peR20*dPOQ& zeBG|wUauS0G0edLxhf73RS)47QVpTL4B!S>-!c4<&)GS-B4?ijT5G#vZWDAv^fLIu zN3I=VwS=58K$OBd=1b@sVm;mI(*m1!}79 zo)T&%Igf36TW7|Re~&T=IW!=Z$H1MPO65wqv{=*aSFHEi(c%V&qG{>&WK%Eyf%A$H z$6OrN{$!q!@1Ip$`GBXBBbAS@><+zVfodkX$5lVftygi#J70eW%YV8Cmp zgiC_wGA^oU@7z{>Rm#96=Y92uyLIpx5;eJN-so1a&&zZvd@89{q%#@F3DH>7r+>s9 zM}|LK(UX9j|VcZA{&h72gq$nK)YV-ZtTovjL8)HpPUFuU!*ur>H)6bcwQEv4@ zOP1lctDFRfs(F8JYn-8EFDdY%NQoIYLB`d?77H!!L6!A=yT_69NG^J*1jX^{ZcmQg z8(Egz4mSMri+XQiIuu-+;f`erPZO7@Cf79Kjm0=-{M>_|PSLfX+&`*;v;Nz5n!YqL zk7IPWKkw~bs}k=2_Z$@T9Us!$0yv6uwmYRUO*}=qZ4!SqpO=RLMi?x-dz09{GUd#W zMh)OCupr>!fJ3Z1>xwYA1aFR?i24e+rGtGv~JC}F7>JH`83#0V%Y+^i#$KdDJ1E9p1AcYh%=I2vsv=BHC z1pzQART;`Z2^lwnfyQ-O^;e2)Z+oKzg+SPPe9s0qnF4>*&EBAE3p)+zx1x>9ahLKkB1~V< zoqvHXZ$;5Kwyj+7x*{JQe;kBErNf;-4D_c!M*k_({D)p7o`{{9j8T~oWiJjSOYR$Z zj}o2#66Bm-i?dH!26c!XJK>> zrnG;N0-#I8QqNBBz^@+lFbx)?zwFW23-;r`=OEdDpVCk1IC3n_sa^zWKuQ_$;b2xo zBE#C1zG+F57vJVUD!3VgEDl0s{`O!?QJ()9l!x6NI-0Cmdiw|*4&cB9{u7iAgKEz_B4GDnYm)#UW7%rI zK~b1inhw}buIsF1#FCm;ae`kuK5ki@jH$52<;!rnG?W)aZ!s|kJ53@I_+UUG;?tE@ zQy!7GAHYA)V7lHM>FH6yO zbVqKdhhiFfl@{2_{r|f9Um&B)5-fjK#M!4YT@Ewy7C9ZC6FlGbu89+h z%QgASnYOeXeyoO|Fh4li4*1AGSoAGN|Cy5%)w_-9={C#H?250kHas>4sB8mS zqW|dJe=j+5gwP-Hse(X~xNhYDjs9WM5B|<=(4ry@3e5cI$8lM=TX$jvZW`q8FOnoL z3Rbyp&KTv)9x5DXkuEN!jyo%$&pZCge`~AIh-f2_>7wdvX7&u3<_~|f-Vb6D0&em* zo^=wfPzL(zz%b6iWY_Evc9H%)v~)PT^;Eqc_Ec*)YPX1|BA4^4R=E-Sdloqm_3h6X zs%JB6R?+n*G^)ufHoxAO@r4bTCuc909RL6!FPl{gmDy6AQW6l%NgXR)gS-?sCM+we z98xFW*c>W6v&8}N*IR$ADD;6|Bq!&mNZ(^3nyamFS3Rr%XKMwic0bG_wv>pDx}Kp~ z+t{gKm`N@Gc?O|EHGR5w0yk;Yuv7aomk>NB#A|)#;c=;9g(eSr5IE_Si=ijA`$ci$~Eh4PudJ)6_U5s%}k|f{?0D zA-2+y_77s?8``t~mQ3${|BAexJqXRDb*c8CIhIStNw-%v?`QhkRyhdik^viVQNSS< zp{P5mGZ>f%kh*`x7xPy{jDf~FKdY$>d-!N@Y36!*=l0t;ExZXYl|`W{TmQCPerNJv zfw4YOX8Mu@JqPMq<(@-Ht+ZqxhM?`m4^jre&E?Z+F-NR__3nWs*z?cip`46nq9qI# z5>CKZPA~!m5s~b?VRBHaR*{}`d|>fkn?ORvpZH5lLh64_%fD!ZAqKLJ$ldDvSdRd^ zX}*s~9QW8H?h#hFRC#zBP3KB_+*nkIK62EPnIw#bQ>Bf=Sj&u4w)!*EMbiu1w90AQ zn&@*JSD^Am+)PSh5w@BlVcwa81Z>#kQ?lHF4fF310k}4c@|Lx6H{pNqvleP+_s*%H z7JwRBcjkY7p{SlV*=@VsQ+NfuGiYTp0gnJEeyBx8WX#Uj&&wJMQ{(ML&WsS|fbEdw zp&c1tzZeo(`fP#VzF!m$%>nsj`Hz1VwnsP>pjbXuf1_S$kkerT~9cg}~082Q`u~_}qq)-mY-s<=~Tf z$LyOo7@1B)wiu}dU*K6V#Q-g#0+lrF8xFbAGCPIt*pH^aZfaf8D;e>F;k0;May^2* zmnzw38GnYgoV4rJ=qPE_0BCqpqXHeubD)1u3+z0Z^zADnG_!=kLOm!vy3OnuIe~y| zFqVJZDOUqb4;;i)kMZdKW{v4^k+VhWNKG_d7oQ{Jlh1Xm`N&@ZUj;esy^t^S<>5#w zYR41Hjm9WDw5;}KN$;}trIHqFLreXx6yP>Rm9Gd`du8}}0DUL?6deZLUi`Y%NIJrX zyG7i_TJ80AJ0C9?&^T0lzNrPEF(+9bPCH61+ziSfn`AOKSv%0hXhagOICy|#5yEaw zoT7l?DlwS4J-R{w0KY1-#fkyX&;S9m<0AH}@G1ZR09gRMloS^`FarPp000D8TBeIU AuK)l5 From facad90c2e4855da0c7e948f65184ac4014f28a4 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 25 Oct 2026 11:28:13 +0100 Subject: [PATCH 23/26] =?UTF-8?q?D=C3=A9lestage=20visible=20:=20shedding.o?= =?UTF-8?q?pen=20publi=C3=A9=20en=20liste=20de=20variantes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le JSON ne change pas. Mais une QStringList échappait au parcours par type de l'enregistreur `decisions`, qui anonymise les ThingId des listes : la référence portait les identifiants tirés au hasard d'une exécution, et le `check` sortait en ÉCART à chaque passage. Co-Authored-By: Claude Opus 5.5 --- energyplugin/etm/types/sheddinglog.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/energyplugin/etm/types/sheddinglog.cpp b/energyplugin/etm/types/sheddinglog.cpp index b62ccc8..a98a7e5 100644 --- a/energyplugin/etm/types/sheddinglog.cpp +++ b/energyplugin/etm/types/sheddinglog.cpp @@ -173,6 +173,12 @@ QVariantMap SheddingLog::resume() const { if (m_revision == 0) return QVariantMap(); + // Une QVariantList de chaînes, comme le reste de la télémétrie — pas une QStringList : le JSON + // est le même, mais un consommateur qui parcourt la carte par type (l'enregistreur de + // `decisions` anonymise les ThingId des listes) ne reconnaîtrait pas la seconde. + QVariantList ids; + for (const QString &id : ouverts()) + ids.append(id); return QVariantMap{{QStringLiteral("revision"), m_revision}, - {QStringLiteral("open"), ouverts()}}; + {QStringLiteral("open"), ids}}; } From 5c3cffbad39566cbc340d1b747cbb7001b8bbc72 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 25 Oct 2026 11:28:13 +0100 Subject: [PATCH 24/26] =?UTF-8?q?D=C3=A9lestage=20visible=20:=20le=20test?= =?UTF-8?q?=20de=20la=20borne=20manuelle=20n'a=20plus=20d'horloge=20murale?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le passage en mode manuel ne restitue rien quand la borne est déjà dans l'état de l'usager : il n'arme donc aucun verrou à l'heure réelle. Le scénario peut reprendre une date fixe, et la référence `decisions` se reproduit à l'octet près. Co-Authored-By: Claude Opus 5.5 --- tests/auto/simulation/simulation.cpp | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index a16be05..4cb6008 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -12103,6 +12103,12 @@ void Simulation::testOverloadProtectionOnAManualChargerIsLogged() reglages.endGroup(); } s.borne->setStateValue("pluggedIn", true); + // La borne est DÉJÀ dans l'état manuel : le passage en mode manuel n'a rien à restituer, donc + // n'écrit rien. Sinon le code amont écrirait à l'horloge murale et armerait les verrous de la + // borne sur cet instant — un scénario daté en dur se déroulerait « avant » eux, et la + // protection serait refusée à chaque geste. + s.borne->setStateValue("power", true); + s.borne->setStateValue("maxChargingCurrent", 16); const QVariantMap info = injectAndWait("NymeaEnergy.GetChargingInfos").toMap().value("params").toMap() .value("chargingInfos").toList().first().toMap(); QVariantMap manuel = info; @@ -12112,11 +12118,7 @@ void Simulation::testOverloadProtectionOnAManualChargerIsLogged() QString("EnergyErrorNoError")); QCOMPARE(consigneA(s.borne), 16); - // L'HORLOGE MURALE, et elle seule : le passage en mode manuel restitue les valeurs de - // l'usager avec QDateTime::currentDateTime() (code amont), et arme les verrous de la borne sur - // cet instant. Un temps de test antérieur les tiendrait fermés, et la protection serait - // refusée à chaque geste. On part donc au-delà du verrou d'enclenchement (300 s). - QDateTime a = QDateTime::currentDateTime().addSecs(600); + QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(10, 0)); QVariantMap snap; const auto cycle = [&](double phaseA) { a = a.addSecs(60); From 21b71a691abec126867111e979954ff32dddcb7d Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 25 Oct 2026 11:29:48 +0100 Subject: [PATCH 25/26] =?UTF-8?q?decisions=20:=20r=C3=A9f=C3=A9rence=20r?= =?UTF-8?q?=C3=A9g=C3=A9n=C3=A9r=C3=A9e=20(identifiants=20anonymis=C3=A9s,?= =?UTF-8?q?=20horloge=20fixe)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Elle remplace celle de e775f66, qui portait des ThingId bruts dans shedding.open et des dates murales dans un test : le `check` n'y était pas reproductible. Deux enregistrements successifs sont identiques (cmp). Contre 76bf482 : les 85 fonctions communes sont identiques, sauf 12 lignes dans 3 fonctions, où seuls le motif et le journal changent ; leurs actions sont identiques. Co-Authored-By: Claude Opus 5.5 --- .../decisions/reference/decisions.ndjson.xz | Bin 571268 -> 571152 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/tests/auto/decisions/reference/decisions.ndjson.xz b/tests/auto/decisions/reference/decisions.ndjson.xz index b70f106f37a6ec81268df0d626421ffcc9c72453..c727814e16394340b7fd439b2be98f876c1ea812 100644 GIT binary patch delta 6797 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 I000D8S|Fx)1^@s6 From b5ef257a364ff1c8e92fb1faa525ced2905f8c17 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 25 Oct 2026 11:42:34 +0100 Subject: [PATCH 26/26] =?UTF-8?q?docs=20:=20mentions=20de=20la=20d=C3=A9mo?= =?UTF-8?q?=20neutralis=C3=A9es=20(commentaires=20et=20design=20seulement)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HE6X3TcKdiuCucBHA2XX4n --- docs/DESIGN_HC_BATTERIE.md | 4 ++-- energyplugin/nymeaenergyjsonhandler.cpp | 2 +- tests/auto/charging/testcharging.cpp | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/DESIGN_HC_BATTERIE.md b/docs/DESIGN_HC_BATTERIE.md index 5b95c27..608676e 100644 --- a/docs/DESIGN_HC_BATTERIE.md +++ b/docs/DESIGN_HC_BATTERIE.md @@ -281,8 +281,8 @@ aucune ne change le code. | D5 | Seuil de « la voiture charge réellement » : 230 W sur la somme des bornes (§3.2) ? | **Accepté.** | | D6 | Plugin SunSpec : une action « limite de décharge » en une séquence (`planBatteryMode(NO_DISCHARGE)`), et `enableDischarging` renommé ou redécrit (§3.1, §7) ? | **Oui aux deux**, dans le dépôt du plugin, à la charge de l'orchestrateur. Le garde ne change pas : il reste compatible avec les deux actions actuelles (`dischargingRate` puis `enableDischarging`). | | D7 | À quelle puissance la borne charge-t-elle en heures creuses (lot e2) ? | **À la pleine puissance permise**, et non plus au plancher. La session finit plus tôt, et la décharge de la batterie n'est bloquée que pendant ce temps-là : ensuite, elle couvre la maison pour le reste de la nuit. Détail en §8.1. | -| D8 | *(2026-10-25)* Près de la limite de phase, la consigne de la borne alterne à chaque cycle (16, 7, 16, 7 A sous 25 A). Corriger avant la vitrine ? | **Oui, maintenant.** En heures creuses, le plafond de la borne lui rend ce qu'elle tire, **mesuré**, moins 1 A par phase. Détail en §8.2. Le même jour : la borne **tenue au plancher** en surcharge (L4 décide de couper) est **confirmée** ; les écarts 3 et 4 de §8.1 sont reportés. | -| D9 | *(2026-10-25, suite)* L'écart 4 n'est pas qu'un défaut de publication : à l'aube, le surplus s'ajoute au plafond, qui porte la limite de phase. Borner la somme avant la vitrine ? | **Oui, avant la vitrine.** La somme éco + confort ne dépasse jamais `declared.maxPowerW`, et l'achat publié ne dépasse pas ce qui est réellement acheté. Détail en §8.3. La dérogation sans heure creuse, les phases étiquetées : plus tard, chacune dans son lot. Le compteur en retard sur la borne est accepté tel quel. | +| D8 | *(2026-10-25)* Près de la limite de phase, la consigne de la borne alterne à chaque cycle (16, 7, 16, 7 A sous 25 A). Corriger avant la démo ? | **Oui, maintenant.** En heures creuses, le plafond de la borne lui rend ce qu'elle tire, **mesuré**, moins 1 A par phase. Détail en §8.2. Le même jour : la borne **tenue au plancher** en surcharge (L4 décide de couper) est **confirmée** ; les écarts 3 et 4 de §8.1 sont reportés. | +| D9 | *(2026-10-25, suite)* L'écart 4 n'est pas qu'un défaut de publication : à l'aube, le surplus s'ajoute au plafond, qui porte la limite de phase. Borner la somme avant la démo ? | **Oui, avant la démo.** La somme éco + confort ne dépasse jamais `declared.maxPowerW`, et l'achat publié ne dépasse pas ce qui est réellement acheté. Détail en §8.3. La dérogation sans heure creuse, les phases étiquetées : plus tard, chacune dans son lot. Le compteur en retard sur la borne est accepté tel quel. | La mesure du minuteur de retour sur un vrai GEN24 (hypothèse AC-10 du plugin, §3.4) reste ouverte : elle relève du plugin et d'un essai sur matériel réel. diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index dfd5bde..e32adb8 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -335,7 +335,7 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana // [ETM] Journal court des délestages. Hors de la télémétrie, parce qu'il la doublerait : vingt // épisodes portant chacun deux charges utiles de mécanisme, émis à chaque battement. La // télémétrie n'en porte que le résumé (shedding.revision, shedding.open) — c'est lui qui - // notifie. Même droit que GetLoadTelemetry : c'est de la lecture d'état, la vitrine la lit. + // notifie. Même droit que GetLoadTelemetry : c'est de la lecture d'état, un compte sans droit la lit. params.clear(); returns.clear(); description = "Get the short log of load-shedding episodes: the last 'capacity' episodes of " "overload protection, oldest first, EV chargers and cascade loads alike. ONE " diff --git a/tests/auto/charging/testcharging.cpp b/tests/auto/charging/testcharging.cpp index 8747dd3..ca1706a 100644 --- a/tests/auto/charging/testcharging.cpp +++ b/tests/auto/charging/testcharging.cpp @@ -2202,7 +2202,7 @@ void TestCharging::testNymeaEnergyPermissionScopes() {"GetSpotMarketScoreEntries", none}, {"GetLoadConfig", none}, {"GetLoadTelemetry", none}, - {"GetSheddingLog", none}, // même droit que la télémétrie : la vitrine le lit + {"GetSheddingLog", none}, // même droit que la télémétrie : un compte sans droit le lit {"GetEnergyRatios", none}, {"GetPvForecast", none}, {"GetForecastSite", none}, @@ -2329,7 +2329,7 @@ void TestCharging::testNymeaEnergyPermissionsEnforced() QVERIFY2(reussi(r), qPrintable(m + " : " + QJsonDocument::fromVariant(r).toJson(QJsonDocument::Compact))); } QCOMPARE(appeler(lecteur, "GetTariff").value("params"), tarifAvant); // la vraie valeur, pas une coquille - // Le journal des délestages : un compte sans aucun droit — celui de la vitrine — le lit en entier. + // Le journal des délestages : un compte sans aucun droit le lit en entier. QCOMPARE(appeler(lecteur, "GetSheddingLog").value("params"), lireAdmin("GetSheddingLog")); QVERIFY(appeler(lecteur, "GetSheddingLog").value("params").toMap().contains("events")); QVERIFY(refuse(appeler(lecteur, "SetTariff", {{"zones", QVariantList()}})));