From 920cf854dada45f3e25bdfa2d8255ca12f24db71 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sat, 24 Oct 2026 08:26:01 +0200 Subject: [PATCH] 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();