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:
parent
c5c32035d1
commit
920cf854da
@ -205,6 +205,48 @@ int TariffConfig::zoneAt(const QDateTime &instant, const QTimeZone &fuseau) cons
|
||||
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,
|
||||
const QTimeZone &fuseau) const
|
||||
{
|
||||
|
||||
@ -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<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.
|
||||
* \param start Début du premier créneau.
|
||||
|
||||
@ -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<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()
|
||||
{
|
||||
QString cause;
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user