Tarif : les heures creuses sont la ou les zones au prix le plus bas (lot e1)

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 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-24 08:26:01 +02:00
parent c5c32035d1
commit 920cf854da
4 changed files with 172 additions and 5 deletions

View File

@ -205,6 +205,48 @@ int TariffConfig::zoneAt(const QDateTime &instant, const QTimeZone &fuseau) cons
return m_parMinute.at(murale.hour() * 60 + murale.minute()); return m_parMinute.at(murale.hour() * 60 + murale.minute());
} }
QList<int> TariffConfig::zonesCreuses() const
{
QList<int> 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<int> creuses = zonesCreuses();
return !creuses.isEmpty() && creuses.contains(zoneAt(instant, fuseau));
}
QDateTime TariffConfig::prochaineBascule(const QDateTime &instant, const QTimeZone &fuseau) const
{
const QList<int> 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<double> TariffConfig::importSeries(const QDateTime &start, int stepS, int steps, QList<double> TariffConfig::importSeries(const QDateTime &start, int stepS, int steps,
const QTimeZone &fuseau) const const QTimeZone &fuseau) const
{ {

View File

@ -21,10 +21,12 @@
* instants absolus. Le même réglage donne donc 23 ou 25 créneaux horaires selon le jour — * 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 »). * 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 * 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 * \c SurplusContext::tariff — l'optimiseur ne le reçoit pas par là. Le moteur n'en tire
* séparé ; \c zoneAt() existe pour lui, et rien ne l'appelle aujourd'hui depuis le plan. * 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. //! \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 instant Instant absolu.
* \param fuseau Fuseau de la box. * \param fuseau Fuseau de la box.
* \return Index dans \c zones() ; -1 si aucun tarif. * \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; 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<int> 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. * \brief Le prix d'import sur une grille d'instants absolus.
* \param start Début du premier créneau. * \param start Début du premier créneau.

View File

@ -2656,6 +2656,91 @@ void TestLoadModel::testTheTariffFollowsLocalTimeAcrossDstDays()
QCOMPARE(t.zoneAt(instant, QTimeZone::utc()), 0); 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<int>{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<int>{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<int>{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() void TestLoadModel::testAStraddlingSlotGetsTheTimeWeightedMean()
{ {
QString cause; QString cause;

View File

@ -142,6 +142,8 @@ private slots:
void testTheTariffIsValidatedStrictly(); void testTheTariffIsValidatedStrictly();
//! Les plages sont des heures MURALES : jours de 23 h et de 25 h, oracle hors Qt. //! Les plages sont des heures MURALES : jours de 23 h et de 25 h, oracle hors Qt.
void testTheTariffFollowsLocalTimeAcrossDstDays(); 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. //! Un créneau à cheval sur deux zones reçoit la moyenne pondérée par la durée.
void testAStraddlingSlotGetsTheTimeWeightedMean(); void testAStraddlingSlotGetsTheTimeWeightedMean();