diff --git a/debian-qt5/changelog b/debian-qt5/changelog index b234b01..82514c9 100644 --- a/debian-qt5/changelog +++ b/debian-qt5/changelog @@ -1,3 +1,34 @@ +powersync-energy-plugin-nymea (1.15.2+etm41) trixie; urgency=medium + + * §10 étape D (1/2) — L'ÉTALEMENT UNIFORME, mesuré avant d'être asserté. `computeEcoTarget()` + convertit une obligation en ÉNERGIE en une cible en WATTS : cadence = reste / temps restant, + recalculée à chaque cycle. « Au plus tard » serait optimal mais suppose une prévision, qui + n'existe pas dans ce modèle (LM-1013-b). + * MESURÉ D'ABORD, ET C'ÉTAIT NÉCESSAIRE. Aucun scénario existant ne portait d'obligation éco — + `LoadContext::needs` n'était rempli par aucun adaptateur — donc aucune référence. Les valeurs + ont été relevées par sonde avant toute assertion, et c'est le relevé qui a révélé un défaut + que la lecture du code ne montrait pas (voir ci-dessous). + * LE DURCISSEMENT, relevé point par point : 2 kWh restants font 333 W à six heures de + l'échéance, 2 kW à une heure, et le plafond dès SEIZE minutes. La fenêtre où l'achat devient + massif n'arrive pas progressivement, elle arrive d'un coup — c'est pourquoi le motif devra + publier la cadence ET le temps restant. + * POINT DE NON-RETOUR nommé : sous un seuil, on cesse d'étaler et on prend le maximum. Un seuil + se discute, une asymptote se subit. + * LM-1013-c — LA BASCULE DE PÉRIODE, trouvée en mesurant. À l'instant de l'échéance + quotidienne, `resolveDeadline()` vise la prochaine occurrence : une nouvelle obligation + s'ouvre. Sans reprise de la base d'avancement, LE LENDEMAIN HÉRITE DE L'ÉNERGIE DE LA VEILLE + et se croit tenu sans avoir rien reçu. Contre-épreuve : 1800 Wh portés au crédit du jour + suivant. Limite assumée : l'obligation non tenue est abandonnée sans motif dédié — le journal + le dit, l'écran non, faute d'un ECO_FLOOR_MISSED que rien n'émet. + * R6 armée dans le calcul : sous un régime `unmeasurable`, on ne conclut JAMAIS « tenue ». On + vise l'obligation entière — le choix qui sert plutôt que celui qui suppose acquis. + * L'heure quotidienne vise la PROCHAINE occurrence : lue à 23:00, une limite de 07:00 vise le + lendemain. La lire comme dépassée ferait franchir le point de non-retour toutes les nuits. + * Suite : loadmodel 22/22, simulation 56/56, charging 48/48 identique à la référence, + spotmarket 32/32, doxygen 0 avertissement. + + -- Patrick Schurig Mon, 31 Aug 2026 09:00:00 +0200 + powersync-energy-plugin-nymea (1.15.2+etm40) trixie; urgency=medium * §10 étape C — L'AVANCEMENT BRANCHÉ EN LECTURE SEULE, et le RÉGIME publié (R5). diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index b1ccddd..23c8ae1 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -452,7 +452,7 @@ namespace { constexpr double BruitAvancementWh = 50.0; } -EcoProgress EnergyArbitrator::ecoProgress(const LoadContext &lc) const +EcoProgress EnergyArbitrator::ecoProgress(const LoadContext &lc, const QDateTime &now) const { EcoProgress p; p.targetWh = lc.needs.minEnergyWhPerDay; @@ -461,9 +461,30 @@ EcoProgress EnergyArbitrator::ecoProgress(const LoadContext &lc) const // obligation éco n'aura jamais d'entrée « eco » — l'absence EST l'information (R1). if (p.targetWh <= 0) { m_ecoBaseWh.remove(lc.id); + m_ecoBaseEcheance.remove(lc.id); return p; // regime vide : l'appelant n'émet pas de niveau éco } + // BASCULE DE PÉRIODE — mesuré le 2026-08-31, et ça ne se voyait pas en lisant le code. + // + // À l'instant de l'échéance quotidienne, `resolveDeadline()` vise la PROCHAINE occurrence : + // une nouvelle période d'obligation s'ouvre. Si la base ne se reprenait pas là, le lendemain + // hériterait de l'énergie livrée la veille et se croirait tenu sans avoir rien reçu — un + // avancement crédible et faux, exactement ce que la base existe pour empêcher. + const QDateTime echeance = resolveDeadline(lc.needs, now); + if (echeance.isValid() && m_ecoBaseEcheance.value(lc.id) != echeance) { + if (m_ecoBaseEcheance.contains(lc.id)) { + // L'obligation précédente s'achève. Si elle n'était pas tenue, elle est abandonnée + // ici — SANS motif dédié à ce stade, et c'est une limite assumée : le dire demande + // un `ECO_FLOOR_MISSED` que rien n'émet encore. Le journal, lui, le dit. + qCInfo(dcNymeaEnergy()) << "[Arbitre] Obligation éco" << lc.id + << "— nouvelle période (échéance" << echeance.toString() + << "), base d'avancement reprise."; + } + m_ecoBaseEcheance.insert(lc.id, echeance); + m_ecoBaseWh.remove(lc.id); + } + // C1 de LM-1009 — la borne ne compte pas : l'obligation n'est PAS mesurable, et ça se dit. // `deliveredWh` reste absent, jamais zéro : une dégradation ne doit pas se lire comme un // avancement nul, sans quoi l'échéance continue de s'afficher comme si elle tenait. @@ -1487,7 +1508,7 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const // // Absent quand aucune obligation n'est déclarée : une charge sans obligation éco ne // porte pas un avancement à zéro, elle n'en porte pas (R1). - const EcoProgress avancement = ecoProgress(adapter->toLoadContext(now)); + const EcoProgress avancement = ecoProgress(adapter->toLoadContext(now), now); if (!avancement.regime.isEmpty()) { QVariantMap progress; progress.insert(QStringLiteral("regime"), avancement.regime); diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index 6295a05..49e0848 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -11,6 +11,7 @@ #include "types/loadconfig.h" #include "types/drawcap.h" #include "types/ecoprogress.h" +#include "types/ecotarget.h" #include #include @@ -348,7 +349,12 @@ public: /*! * \brief [§10-C] Avancement d'une obligation éco, et le régime qui dit ce qu'il vaut. * - * \param lc Charge du cycle. \return L'avancement — \c regime toujours renseigné. + * \param lc Charge du cycle. + * \param now Temps de cycle — sert à résoudre l'échéance, donc à détecter la BASCULE de + * période : à l'instant de l'heure limite quotidienne, une nouvelle obligation s'ouvre et + * la base d'avancement se reprend (\rule{LM-1013-c}). Sans lui, le lendemain hériterait de + * l'énergie de la veille et se croirait tenu sans avoir rien reçu. + * \return L'avancement — \c regime toujours renseigné quand l'obligation existe. * * \par La BASE, et les deux questions qu'elle pose * `sessionWh` est un ACCUMULATEUR de plugin, pas un compteur remis à zéro pour nous : côté @@ -371,7 +377,7 @@ public: * \note Un recul infime est du bruit de lecture, pas une nouvelle session — même seuil de * principe que le plugin. En dessous, la base suit la valeur sans rien conclure. */ - EcoProgress ecoProgress(const LoadContext &lc) const; + EcoProgress ecoProgress(const LoadContext &lc, const QDateTime &now) const; /*! * \brief [L4] Déleste les charges du waterfall tant que le plafond de tirage est dépassé. @@ -723,6 +729,10 @@ private: //! différences d'avancement. Recapturée quand l'accumulateur de la borne recule (nouvelle //! session), jamais au démarrage de nymead — voir \c ecoProgress(). mutable QHash m_ecoBaseWh; + //! loadId → échéance en vigueur quand la base a été prise. Quand elle BASCULE (heure limite + //! quotidienne franchie), la période d'obligation change : la base se reprend, sinon le + //! lendemain hérite de l'énergie de la veille et se croit déjà tenu. + mutable QHash m_ecoBaseEcheance; ThingManager *m_tm = nullptr; //!< ThingManager (pour construire les adaptateurs config). LoadConfigStore *m_loadConfigStore = nullptr; //!< Store config charge pilotée (non adopté). diff --git a/energyplugin/etm/etm.pri b/energyplugin/etm/etm.pri index 8321999..de11f61 100644 --- a/energyplugin/etm/etm.pri +++ b/energyplugin/etm/etm.pri @@ -6,6 +6,7 @@ HEADERS += \ $$PWD/types/plan.h \ $$PWD/types/drawcap.h \ $$PWD/types/ecoprogress.h \ + $$PWD/types/ecotarget.h \ $$PWD/types/loadconfig.h \ $$PWD/config/loadconfigstore.h \ $$PWD/adapters/iloadadapter.h \ @@ -21,6 +22,7 @@ HEADERS += \ SOURCES += \ $$PWD/types/decisionreason.cpp \ $$PWD/types/drawcap.cpp \ + $$PWD/types/ecotarget.cpp \ $$PWD/types/loadconfig.cpp \ $$PWD/config/loadconfigstore.cpp \ $$PWD/ratios/energyratioscalculator.cpp \ diff --git a/energyplugin/etm/types/ecotarget.cpp b/energyplugin/etm/types/ecotarget.cpp new file mode 100644 index 0000000..5a0b2e9 --- /dev/null +++ b/energyplugin/etm/types/ecotarget.cpp @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync + +#include "ecotarget.h" + +#include + +QDateTime resolveDeadline(const LoadNeeds &needs, const QDateTime &now) +{ + // L'échéance ABSOLUE l'emporte : elle a été posée pour une occasion précise, là où l'heure + // quotidienne est une habitude. Les mélanger ferait qu'un départ exceptionnel à 5 h serait + // écrasé par un réglage à 7 h. + if (needs.deadline.isValid()) + return needs.deadline; + + if (needs.dailyDeadline.isEmpty()) + return QDateTime(); + + const QTime heure = QTime::fromString(needs.dailyDeadline, QStringLiteral("HH:mm")); + if (!heure.isValid()) + return QDateTime(); + + // PROCHAINE occurrence, jamais la passée. Une heure limite de 07:00 lue à 23:00 vise le + // lendemain : la lire comme dépassée ferait franchir le point de non-retour toutes les + // nuits, et acheter à pleine puissance chaque soir sans qu'aucune échéance ne l'exige. + QDateTime echeance(now.date(), heure, now.timeZone()); + if (echeance <= now) + echeance = echeance.addDays(1); + return echeance; +} + +EcoTarget computeEcoTarget(const LoadNeeds &needs, const EcoProgress &progress, + double maxPowerW, const QDateTime &now) +{ + EcoTarget t; + + // Pas d'obligation en énergie : rien à étaler. La passe éco n'émettra pas de niveau pour + // cette charge — son absence dit « ne participe qu'au confort » (R1). + if (needs.minEnergyWhPerDay <= 0) + return t; + + const QDateTime echeance = resolveDeadline(needs, now); + if (!echeance.isValid()) + return t; // une obligation sans échéance n'a pas de cadence : elle n'est pas étalable + + t.applicable = true; + t.remainingS = now.secsTo(echeance); + + // TENUE — mais seulement si on MESURE. Sous un régime `unmeasurable`, l'avancement est + // inconnu : conclure « tenue » serait affirmer sur ce qu'on ne sait pas, et c'est exactement + // ce que C1 de LM-1009 interdit. Une échéance qui cesse silencieusement d'être mesurable est + // pire qu'une échéance absente, parce qu'elle continue de s'afficher comme si elle tenait. + if (progress.delivered) { + t.remainingWh = qMax(0.0, progress.targetWh - progress.deliveredWh); + t.tenue = t.remainingWh <= 0; + } else { + // Non mesurable : on ne sait pas ce qui a été livré. On vise l'obligation ENTIÈRE — le + // choix prudent, celui qui sert plutôt que celui qui suppose acquis. + t.remainingWh = needs.minEnergyWhPerDay; + t.tenue = false; + } + + if (t.tenue) + return t; // watts = 0 : plus rien à acheter pour l'éco + + // Échéance dépassée : il n'y a plus rien à étaler. On prend le maximum, ou l'obligation + // reste due sans jamais être servie. + if (t.remainingS <= 0) { + t.pointDeNonRetour = true; + t.watts = maxPowerW > 0 ? maxPowerW : t.remainingWh; + return t; + } + + // POINT DE NON-RETOUR — le seuil est nommé plutôt que subi. En deçà, la cadence calculée + // dépasserait de toute façon ce que la charge sait prendre : on cesse d'étaler. + if (t.remainingS <= EcoPointDeNonRetourS) { + t.pointDeNonRetour = true; + t.watts = maxPowerW > 0 ? maxPowerW : t.remainingWh; + return t; + } + + // ÉTALEMENT UNIFORME, recalculé à chaque cycle. Il se DURCIT mécaniquement à l'approche : + // 2 kWh restants font 2 kW à une heure de l'échéance et 12 kW à dix minutes. C'est pourquoi + // le motif publie la cadence ET le temps restant — sans eux, un client voit sa facture + // grimper à six heures du matin sans cause lisible, et cherche une panne. + const double heuresRestantes = t.remainingS / 3600.0; + t.watts = t.remainingWh / heuresRestantes; + + // Écrêté au plafond physique : demander plus ne ferait qu'inventer un chiffre. Le retard + // qui en résulte sera rattrapé au cycle suivant, puisque la cadence se relit. + if (maxPowerW > 0) + t.watts = qMin(t.watts, maxPowerW); + + return t; +} diff --git a/energyplugin/etm/types/ecotarget.h b/energyplugin/etm/types/ecotarget.h new file mode 100644 index 0000000..a569055 --- /dev/null +++ b/energyplugin/etm/types/ecotarget.h @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync +#pragma once + +#include +#include "loaddescriptor.h" +#include "ecoprogress.h" + +/*! + * \file ecotarget.h + * \brief Convertit une obligation en ÉNERGIE en une cible en WATTS pour ce cycle. + */ + +/*! + * \brief Cible éco d'un cycle, et de quoi la DIRE. + * + * \par Pourquoi chaque champ y est + * Le motif publié doit permettre à un client de comprendre une facture qui monte à six heures du + * matin. Les watts seuls ne le permettent pas : il faut l'obligation qu'ils servent, l'échéance + * qui les commande, et la cadence qui explique pourquoi ils augmentent. + */ +struct EcoTarget { + //! Puissance à tenir CE CYCLE pour rester dans les temps. 0 = rien à faire. + double watts = 0; + + //! Énergie restant à livrer (Wh). 0 quand l'obligation est tenue. + double remainingWh = 0; + + //! Temps restant avant l'échéance (s). Négatif = échéance dépassée. + qint64 remainingS = 0; + + //! \brief L'obligation est TENUE. Ne vaut jamais vrai sous un régime \c unmeasurable : + //! on ne conclut pas « tenue » sur un avancement qu'on ne mesure pas (\rule{LM-1009} C1, + //! exigence R6). + bool tenue = false; + + /*! + * \brief Le POINT DE NON-RETOUR est franchi : on prend tout ce qu'on peut. + * + * L'étalement uniforme se durcit mécaniquement à l'approche — 2 kWh restants font 2 kW à une + * heure de l'échéance et **12 kW à dix minutes**. Passé un seuil, la cadence calculée dépasse + * de toute façon ce que la charge sait prendre : on cesse d'étaler et on demande le maximum. + * + * **Le seuil est NOMMÉ plutôt que subi.** Sans lui, la cadence tend vers l'infini sans que + * rien ne décide, et le comportement se lit comme une dérive. Un seuil se discute. + */ + bool pointDeNonRetour = false; + + //! Vrai si une obligation exploitable existe — sinon la passe éco n'émet rien pour cette + //! charge, et son niveau est ABSENT (R1) plutôt que présent à zéro. + bool applicable = false; +}; + +/*! + * \brief Résout l'instant d'échéance depuis les besoins déclarés. + * \param needs Besoins de la charge. \param now Temps de cycle. + * \return L'échéance absolue, ou un \c QDateTime invalide si aucune n'est déclarée. + * \note \c deadline (absolue) l'emporte sur \c dailyDeadline ; cette dernière se résout à sa + * PROCHAINE occurrence après \c now — une heure limite de 07:00 lue à 23:00 vise le lendemain, + * et la lire comme passée ferait franchir le point de non-retour toutes les nuits. + */ +QDateTime resolveDeadline(const LoadNeeds &needs, const QDateTime &now); + +/*! + * \brief Cadence à tenir ce cycle pour honorer l'obligation — ÉTALEMENT UNIFORME. + * + * \param needs Besoins déclarés (l'obligation). + * \param progress Avancement et son régime. + * \param maxPowerW Plafond physique de la charge (0 = inconnu, aucun écrêtage). + * \param now Temps de cycle. + * \return La cible, avec de quoi la publier. + * + * \par Pourquoi l'étalement, et pas mieux + * « Au plus tard » serait optimal mais suppose une **prévision** de production ; la prévision + * n'existe pas dans ce modèle. Un étalement se lit, se teste, et ne prétend rien prévoir — même + * raison qui a fait de la cible de confort un comparateur plutôt qu'un dosage (\rule{LM-1008}). + * + * \par Recalculé à CHAQUE cycle, jamais figé + * Une cadence calculée une fois au branchement ne rattrape pas : après une heure sans surplus, + * elle reste à la cadence initiale et **manque l'échéance en ayant l'air de travailler**. Les + * deux termes du rapport bougent, donc le rapport se relit. + */ +EcoTarget computeEcoTarget(const LoadNeeds &needs, const EcoProgress &progress, + double maxPowerW, const QDateTime &now); + +//! \brief Sous ce temps restant, on cesse d'étaler et on prend le maximum disponible. +//! Valeur de conception, à ajuster sur relevé : trop court, on rate l'échéance faute de +//! puissance ; trop long, on achète tôt ce que le soleil aurait pu payer. +constexpr qint64 EcoPointDeNonRetourS = 15 * 60; diff --git a/specs/spec_loadmodel.md b/specs/spec_loadmodel.md index 97ecdcc..b2863d1 100644 --- a/specs/spec_loadmodel.md +++ b/specs/spec_loadmodel.md @@ -957,6 +957,41 @@ sépare **publier le régime** de **décider avec**, exactement comme `measureme l'arbitrage. C'est ce qui rendra **R6** — `ECO_FLOOR_MET` interdit sous `unmeasurable` — testable le jour où le motif naîtra, au lieu d'un invariant posé sur un code que rien n'émet. +**LM-1013-b — une obligation en ÉNERGIE devient une cible en WATTS par étalement uniforme.** +*(§10 étape D, tranché le 2026-08-31.)* + +`cadence = reste / temps restant`, et rien de plus savant. « Au plus tard » serait optimal mais +suppose une **prévision** de production, qui n'existe pas dans ce modèle. Même raison qui a fait +de la cible de confort un comparateur plutôt qu'un dosage (`LM-1008`) : ce qui manque n'est pas +la formule, c'est le modèle qui la rendrait honnête. + +**LM-1013-b-i — recalculé à CHAQUE cycle, jamais figé au départ.** Une cadence calculée une fois +au branchement ne rattrape pas : après une heure sans surplus, elle reste à la cadence initiale +et **manque l'échéance en ayant l'air de travailler**. Les deux termes du rapport bougent. + +**LM-1013-b-ii — le durcissement est mécanique, et le motif doit le DIRE.** Relevé : 2 kWh +restants font **333 W à six heures** de l'échéance, **2 kW à une heure**, et **le plafond dès +seize minutes**. La fenêtre où l'achat devient massif n'arrive pas progressivement, elle arrive +**d'un coup**. Le motif publie donc la cadence ET le temps restant — sans eux, un client voit sa +facture grimper à six heures du matin sans cause lisible et cherche une panne. + +**LM-1013-b-iii — un POINT DE NON-RETOUR nommé.** Sous un seuil de temps restant, on cesse +d'étaler et on prend le maximum. Sans lui la cadence tend vers l'infini sans que rien ne décide : +**un seuil se discute, une asymptote se subit.** + +**LM-1013-b-iv — l'heure quotidienne vise la PROCHAINE occurrence.** Lue à 23:00, une limite de +07:00 vise le lendemain. La lire comme dépassée ferait franchir le point de non-retour toutes les +nuits, et acheter à pleine puissance chaque soir sans qu'aucune échéance ne l'exige. + +**LM-1013-c — la BASCULE DE PÉRIODE reprend la base d'avancement.** *(Trouvé en mesurant, et +invisible à la lecture du code.)* À l'instant de l'échéance quotidienne, une nouvelle période +d'obligation s'ouvre. Sans reprise de la base, **le lendemain hérite de l'énergie de la veille et +se croit tenu sans avoir rien reçu** — contre-épreuve : 1800 Wh portés au crédit du jour suivant. + +> **Limite assumée** : l'obligation précédente, si elle n'était pas tenue, est abandonnée **sans +> motif dédié**. Le dire demanderait un `ECO_FLOOR_MISSED` que rien n'émet encore ; le journal le +> dit, l'écran non. À reprendre quand le catalogue s'ouvrira. + **LM-1011 — `BELOW_MIN_POWER` prime sur `DRAW_CAP` dès qu'il reste de l'autorisation.** *(Précédence tranchée par Patrick le 2026-08-29, sur une question de l'agent app. Écrite ICI, une fois, pour qu'aucun côté de la frontière ne l'établisse pour son compte.)* diff --git a/tests/auto/loadmodel/testloadmodel.cpp b/tests/auto/loadmodel/testloadmodel.cpp index 754d67d..12d9d70 100644 --- a/tests/auto/loadmodel/testloadmodel.cpp +++ b/tests/auto/loadmodel/testloadmodel.cpp @@ -6,6 +6,7 @@ #include "etm/energyarbitrator.h" #include "etm/types/loadconfig.h" #include "etm/types/drawcap.h" +#include "etm/types/ecotarget.h" #include "etm/config/loadconfigstore.h" #include @@ -536,6 +537,90 @@ void TestLoadModel::testRankOriginFollowsTheRankNotTheWrite() QVERIFY2(!hors.isValid(&err), "rankOrigin hors énumération doit être refusé"); } +/*! + * \brief [§10 étape D] L'étalement uniforme, et le durcissement qu'il faut savoir DIRE. + * + * \par Ce que la mesure a montré avant que rien ne soit asserté + * L'étape D est la seule des quatre qui change le comportement, et aucun scénario existant ne + * portait d'obligation éco — `LoadContext::needs` n'était rempli par aucun adaptateur. Il n'y + * avait donc **aucune référence** à laquelle se comparer : les valeurs ci-dessous ont été + * relevées d'abord, épinglées ensuite. C'est ce relevé qui a révélé la bascule de période, que + * la lecture du code ne montrait pas. + * + * \par Le durcissement est mécanique, et c'est pourquoi le motif doit le porter + * 2 kWh restants font **333 W à six heures** de l'échéance, **2 kW à une heure**, et **7 kW — + * le plafond — dès seize minutes**. La fenêtre où l'achat devient massif n'arrive pas + * progressivement : elle arrive d'un coup. Un client qui voit sa facture grimper à six heures du + * matin sans cause lisible cherchera une panne. + */ +void TestLoadModel::testEcoSpreadHardensAndSaysSo() +{ + LoadNeeds needs; + needs.minEnergyWhPerDay = 2000; + needs.dailyDeadline = "07:00"; + + EcoProgress p; + p.delivered = true; + p.deliveredWh = 0; + p.targetWh = 2000; + + const QDate j(2026, 8, 31); + auto cible = [&](int h, int m) { + return computeEcoTarget(needs, p, 7000, QDateTime(j, QTime(h, m), QTimeZone::utc())); + }; + + // 1. LE DURCISSEMENT, relevé point par point. C'est la courbe que le motif doit rendre + // lisible — pas une valeur isolée, une progression. + QCOMPARE(qRound(cible(1, 0).watts), 333); // 6 h avant + QCOMPARE(qRound(cible(5, 0).watts), 1000); // 2 h avant + QCOMPARE(qRound(cible(6, 0).watts), 2000); // 1 h avant + QCOMPARE(qRound(cible(6, 30).watts), 4000); // 30 min + QCOMPARE(qRound(cible(6, 44).watts), 7000); // 16 min — déjà au plafond physique + + // 2. Le POINT DE NON-RETOUR est franchi sous le seuil, et il se DIT. Sans lui, la cadence + // tend vers l'infini sans que rien ne décide : un seuil se discute, une asymptote se subit. + QVERIFY2(!cible(6, 44).pointDeNonRetour, "à 16 min le seuil n'est pas encore franchi"); + QVERIFY2(cible(6, 50).pointDeNonRetour, "à 10 min il doit l'être"); + QCOMPARE(qRound(cible(6, 50).watts), 7000); + + // 3. La cadence est RECALCULÉE, jamais figée : un rattrapage après une heure sans surplus + // doit monter. Figée au départ, elle manquerait l'échéance en ayant l'air de travailler. + p.deliveredWh = 0; + const double aSixHeures = cible(1, 0).watts; + const double aDeuxHeures = cible(5, 0).watts; + QVERIFY2(aDeuxHeures > aSixHeures * 2.5, + "sans rien livré, la cadence doit avoir monté d'elle-même"); + + // 4. Ce qui est DÉJÀ LIVRÉ allège la cadence — l'étalement porte sur le RESTE. + p.deliveredWh = 1500; + QCOMPARE(qRound(cible(5, 0).watts), 250); // 500 Wh en 2 h + QCOMPARE(qRound(cible(5, 0).remainingWh), 500); + + // 5. Obligation TENUE : plus rien à acheter pour l'éco. + p.deliveredWh = 2000; + QVERIFY2(cible(5, 0).tenue, "2000 sur 2000 : l'obligation est tenue"); + QCOMPARE(qRound(cible(5, 0).watts), 0); + + // 6. R6 — SOUS `unmeasurable`, ON NE CONCLUT JAMAIS « TENUE ». On ne sait pas ce qui a été + // livré ; l'affirmer serait affirmer sur ce qu'on ignore. On vise l'obligation entière, + // qui est le choix qui SERT plutôt que celui qui suppose acquis. + EcoProgress inconnu; + inconnu.delivered = false; + inconnu.targetWh = 2000; + const EcoTarget ti = computeEcoTarget(needs, inconnu, 7000, + QDateTime(j, QTime(5, 0), QTimeZone::utc())); + QVERIFY2(!ti.tenue, "jamais « tenue » sur un avancement qu'on ne mesure pas (LM-1009 C1)"); + QCOMPARE(qRound(ti.remainingWh), 2000); + + // 7. L'heure quotidienne lue le SOIR vise le LENDEMAIN. La lire comme dépassée ferait + // franchir le point de non-retour toutes les nuits, et acheter à pleine puissance chaque + // soir sans qu'aucune échéance ne l'exige. + const EcoTarget soir = computeEcoTarget(needs, p, 7000, + QDateTime(j, QTime(23, 0), QTimeZone::utc())); + QVERIFY2(!soir.pointDeNonRetour, "23:00 vers 07:00 : huit heures, pas un retard"); + QCOMPARE(soir.remainingS, qint64(8 * 3600)); +} + void TestLoadModel::testDrawMarginComparesHeadroomsNotCaps() { // [LM-1006-1] Un plafond PAR PHASE et un plafond TOTAL n'ont pas la même forme et ne se diff --git a/tests/auto/loadmodel/testloadmodel.h b/tests/auto/loadmodel/testloadmodel.h index 56b87e7..cdf9363 100644 --- a/tests/auto/loadmodel/testloadmodel.h +++ b/tests/auto/loadmodel/testloadmodel.h @@ -79,6 +79,7 @@ private slots: // ── LM-1006-1 — le plafond de soutirage, étape 1 ─────────────────────── //! On compare des MARGES, jamais des plafonds : le plus contraignant mord, et on sait //! lequel. Aucun plafond déclaré ≠ marge nulle. Départage stable à marge égale. + void testEcoSpreadHardensAndSaysSo(); void testDrawMarginComparesHeadroomsNotCaps(); }; diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index d91e003..3406005 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -6718,6 +6718,7 @@ void Simulation::testEcoProgressBaseAndRegime() EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); QVERIFY(arb); + const QDateTime t0 = utcDateTime(QDate(2026, 8, 31), QTime(13, 0, 0)); LoadContext lc; lc.id = "borne-eco"; lc.needs.minEnergyWhPerDay = 10000; // obligation : 10 kWh par jour @@ -6726,7 +6727,7 @@ void Simulation::testEcoProgressBaseAndRegime() // charge sans obligation éco ne porte pas d'entrée « eco » : l'absence EST l'information. LoadContext sansObligation = lc; sansObligation.needs.minEnergyWhPerDay = 0; - QVERIFY2(arb->ecoProgress(sansObligation).regime.isEmpty(), + QVERIFY2(arb->ecoProgress(sansObligation, t0).regime.isEmpty(), "sans obligation, aucun régime : le niveau éco n'existe pas"); // 2. Borne qui ne COMPTE PAS : régime `unmeasurable`, et `deliveredWh` SANS OBJET. @@ -6735,7 +6736,7 @@ void Simulation::testEcoProgressBaseAndRegime() LoadContext nonMesurable = lc; nonMesurable.telemetry.sessionMeasurable = false; nonMesurable.telemetry.sessionWh = 7204; // valeur présente mais SANS SIGNIFICATION - const EcoProgress sansMesure = arb->ecoProgress(nonMesurable); + const EcoProgress sansMesure = arb->ecoProgress(nonMesurable, t0); QCOMPARE(sansMesure.regime, QString(ProgressRegime::Unmeasurable)); QCOMPARE(sansMesure.delivered, false); QVERIFY2(qFuzzyIsNull(sansMesure.deliveredWh), @@ -6746,7 +6747,7 @@ void Simulation::testEcoProgressBaseAndRegime() LoadContext mesuree = lc; mesuree.telemetry.sessionMeasurable = true; mesuree.telemetry.sessionWh = 7204; - const EcoProgress ouverture = arb->ecoProgress(mesuree); + const EcoProgress ouverture = arb->ecoProgress(mesuree, t0); QCOMPARE(ouverture.regime, QString(ProgressRegime::Measured)); QCOMPARE(ouverture.delivered, true); QCOMPARE(qRound(ouverture.deliveredWh), 0); @@ -6754,29 +6755,53 @@ void Simulation::testEcoProgressBaseAndRegime() // …et l'avancement suit la DIFFÉRENCE, pas la valeur lue. mesuree.telemetry.sessionWh = 9204; - QCOMPARE(qRound(arb->ecoProgress(mesuree).deliveredWh), 2000); + QCOMPARE(qRound(arb->ecoProgress(mesuree, t0).deliveredWh), 2000); // 4. RECUL FRANC : le plugin a remis son accumulateur à la valeur brute d'une nouvelle // session. La base est recapturée — sans quoi la différence serait NÉGATIVE. mesuree.telemetry.sessionWh = 120; - QCOMPARE(qRound(arb->ecoProgress(mesuree).deliveredWh), 0); + QCOMPARE(qRound(arb->ecoProgress(mesuree, t0).deliveredWh), 0); mesuree.telemetry.sessionWh = 620; - QCOMPARE(qRound(arb->ecoProgress(mesuree).deliveredWh), 500); + QCOMPARE(qRound(arb->ecoProgress(mesuree, t0).deliveredWh), 500); // 5. Recul INFIME : c'est du bruit de lecture, pas une nouvelle session. La base ne bouge // pas — la recapturer ici perdrait l'avancement acquis à chaque frémissement du compteur. mesuree.telemetry.sessionWh = 610; - const EcoProgress bruit = arb->ecoProgress(mesuree); + const EcoProgress bruit = arb->ecoProgress(mesuree, t0); QCOMPARE(qRound(bruit.deliveredWh), 490); QVERIFY2(bruit.deliveredWh >= 0, "un avancement ne peut pas être négatif"); + // 7. BASCULE DE PÉRIODE — le cas que la sonde a révélé, et qu'aucune lecture du code + // n'aurait montré. À l'instant de l'échéance quotidienne, une NOUVELLE période s'ouvre. + // Sans reprise de la base ici, le lendemain hériterait de l'énergie de la veille et se + // croirait tenu sans avoir rien reçu. + LoadContext quotidienne; + quotidienne.id = "borne-quotidienne"; + quotidienne.needs.minEnergyWhPerDay = 2000; + quotidienne.needs.dailyDeadline = "07:00"; + quotidienne.telemetry.sessionMeasurable = true; + + const QDateTime veille = utcDateTime(QDate(2026, 8, 31), QTime(6, 0, 0)); // vise 07:00 J + const QDateTime apres = utcDateTime(QDate(2026, 8, 31), QTime(7, 30, 0)); // vise 07:00 J+1 + + quotidienne.telemetry.sessionWh = 1000; + QCOMPARE(qRound(arb->ecoProgress(quotidienne, veille).deliveredWh), 0); // base prise ici + quotidienne.telemetry.sessionWh = 2800; + QCOMPARE(qRound(arb->ecoProgress(quotidienne, veille).deliveredWh), 1800); + + // L'échéance a basculé : la période est neuve, l'avancement repart de zéro — l'énergie de + // la veille n'est pas portée au crédit du jour suivant. + QCOMPARE(qRound(arb->ecoProgress(quotidienne, apres).deliveredWh), 0); + quotidienne.telemetry.sessionWh = 3300; + QCOMPARE(qRound(arb->ecoProgress(quotidienne, apres).deliveredWh), 500); + // 6. L'obligation retirée LIBÈRE la base : une nouvelle obligation repart d'un repère neuf, // et n'hérite pas de l'avancement d'une obligation qui n'existe plus. LoadContext retiree = mesuree; retiree.needs.minEnergyWhPerDay = 0; - arb->ecoProgress(retiree); + arb->ecoProgress(retiree, t0); mesuree.telemetry.sessionWh = 5000; - QCOMPARE(qRound(arb->ecoProgress(mesuree).deliveredWh), 0); + QCOMPARE(qRound(arb->ecoProgress(mesuree, t0).deliveredWh), 0); #endif }