diff --git a/energyplugin/etm/etm.pri b/energyplugin/etm/etm.pri index 4565c30..e716e11 100644 --- a/energyplugin/etm/etm.pri +++ b/energyplugin/etm/etm.pri @@ -8,6 +8,7 @@ HEADERS += \ $$PWD/types/ecoprogress.h \ $$PWD/types/ecotarget.h \ $$PWD/types/loadconfig.h \ + $$PWD/tariff/tariffconfig.h \ $$PWD/config/loadconfigstore.h \ $$PWD/adapters/iloadadapter.h \ $$PWD/scheduler/ischeduler.h \ @@ -25,6 +26,7 @@ SOURCES += \ $$PWD/types/drawcap.cpp \ $$PWD/types/ecotarget.cpp \ $$PWD/types/loadconfig.cpp \ + $$PWD/tariff/tariffconfig.cpp \ $$PWD/config/loadconfigstore.cpp \ $$PWD/ratios/energyratioscalculator.cpp \ $$PWD/adapters/evadapter.cpp \ diff --git a/energyplugin/etm/tariff/tariffconfig.cpp b/energyplugin/etm/tariff/tariffconfig.cpp new file mode 100644 index 0000000..5c2d658 --- /dev/null +++ b/energyplugin/etm/tariff/tariffconfig.cpp @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync +#include "tariffconfig.h" + +#include +#include + +namespace { +constexpr int MinutesParJour = 1440; + +//! Un NOMBRE, rien d'autre : ni chaîne, ni booléen. \c std::nullopt sinon. +std::optional nombreStrict(const QVariant &v) +{ + const int t = v.typeId(); + const bool nombre = t == QMetaType::Int || t == QMetaType::UInt || t == QMetaType::LongLong + || t == QMetaType::ULongLong || t == QMetaType::Double + || t == QMetaType::Float; + if (!nombre) + return std::nullopt; + const double d = v.toDouble(); + if (!qIsFinite(d)) + return std::nullopt; + return d; +} + +void refus(QString &cause, QVariantMap ¶ms, const char *code, const QVariantMap &ou = {}) +{ + cause = QString::fromLatin1(code); + params = ou; +} +} + +QString TariffConfig::source() const +{ + if (m_zones.isEmpty()) + return QString(); + return m_zones.count() == 1 ? QStringLiteral("fixed") : QStringLiteral("tou"); +} + +QStringList TariffConfig::includes() +{ + // Le prix COMPLET payé (brief 3 T4) : une liste, pas un booléen, pour qu'un tarif partiel + // futur (énergie seule, day-ahead) se dise sans changer la forme. + return {QStringLiteral("energy"), QStringLiteral("grid"), QStringLiteral("taxes")}; +} + +int TariffConfig::lireHeure(const QString &s, bool permettre24) +{ + // STRICT : « 6:00 », « 06:00:00 », « 06h00 » sont refusés. Une lecture indulgente ferait + // d'une faute de frappe une plage plausible. + static const QRegularExpression forme(QStringLiteral("^([0-9]{2}):([0-9]{2})$")); + const QRegularExpressionMatch m = forme.match(s); + if (!m.hasMatch()) + return -1; + const int h = m.captured(1).toInt(); + const int mn = m.captured(2).toInt(); + if (h == 24 && mn == 0 && permettre24) + return MinutesParJour; + if (h > 23 || mn > 59) + return -1; + return h * 60 + mn; +} + +QString TariffConfig::ecrireHeure(int minutes) +{ + return QStringLiteral("%1:%2").arg(minutes / 60, 2, 10, QLatin1Char('0')) + .arg(minutes % 60, 2, 10, QLatin1Char('0')); +} + +TariffConfig TariffConfig::fromVariant(const QVariantMap &saisie, QString &cause, + QVariantMap &causeParams) +{ + cause.clear(); + causeParams.clear(); + TariffConfig t; + + const QVariant brutZones = saisie.value(QStringLiteral("zones")); + if (brutZones.typeId() != QMetaType::QVariantList) { + refus(cause, causeParams, "MALFORMED", {{QStringLiteral("field"), QStringLiteral("zones")}}); + return {}; + } + const QVariantList zones = brutZones.toList(); + if (zones.count() > MaxZones) { + refus(cause, causeParams, "TOO_MANY_ZONES", {{QStringLiteral("max"), MaxZones}}); + return {}; + } + + // LA REVENTE : saisie, ou 0 (brief 3 T4). Elle n'a de sens qu'avec un tarif : sans zone, on + // efface TOUT — une revente seule publierait un prix d'export à côté d'un import absent. + if (saisie.contains(QStringLiteral("exportCtkWh"))) { + const std::optional e = nombreStrict(saisie.value(QStringLiteral("exportCtkWh"))); + if (!e || *e < 0) { + refus(cause, causeParams, "EXPORT_PRICE"); + return {}; + } + t.m_exportCtkWh = *e; + } + if (zones.isEmpty()) + return {}; // effacement : accepté, cause vide + + QVector parMinute(MinutesParJour, -1); + QStringList libelles; + for (int z = 0; z < zones.count(); ++z) { + if (zones.at(z).typeId() != QMetaType::QVariantMap) { + refus(cause, causeParams, "MALFORMED", {{QStringLiteral("zone"), z}}); + return {}; + } + const QVariantMap zm = zones.at(z).toMap(); + TariffZone zone; + + // Le LIBELLÉ est une donnée de l'installateur (« HC », « Heures creuses ») : non vide, + // unique — deux zones de même nom seraient indiscernables sur un écran. + const QVariant lab = zm.value(QStringLiteral("label")); + zone.label = lab.toString().trimmed(); + if (lab.typeId() != QMetaType::QString || zone.label.isEmpty() + || zone.label.size() > MaxLabelLength || libelles.contains(zone.label)) { + refus(cause, causeParams, "LABEL", {{QStringLiteral("zone"), z}}); + return {}; + } + libelles << zone.label; + + const std::optional prix = nombreStrict(zm.value(QStringLiteral("priceCtkWh"))); + if (!prix || *prix < 0) { + refus(cause, causeParams, "PRICE", {{QStringLiteral("zone"), z}}); + return {}; + } + zone.priceCtkWh = *prix; + + const QVariant brutPlages = zm.value(QStringLiteral("ranges")); + if (brutPlages.typeId() != QMetaType::QVariantList || brutPlages.toList().isEmpty()) { + refus(cause, causeParams, "NO_RANGE", {{QStringLiteral("zone"), z}}); + return {}; + } + const QVariantList plages = brutPlages.toList(); + if (plages.count() > MaxRangesParZone) { + refus(cause, causeParams, "TOO_MANY_RANGES", + {{QStringLiteral("zone"), z}, {QStringLiteral("max"), MaxRangesParZone}}); + return {}; + } + for (int r = 0; r < plages.count(); ++r) { + const QVariantMap pm = plages.at(r).toMap(); + const QVariant f = pm.value(QStringLiteral("from")); + const QVariant to = pm.value(QStringLiteral("to")); + const int de = f.typeId() == QMetaType::QString ? lireHeure(f.toString(), false) : -1; + const int a = to.typeId() == QMetaType::QString ? lireHeure(to.toString(), true) : -1; + const QVariantMap ou{{QStringLiteral("zone"), z}, {QStringLiteral("range"), r}}; + if (plages.at(r).typeId() != QMetaType::QVariantMap || de < 0 || a < 0) { + refus(cause, causeParams, "RANGE_FORMAT", ou); + return {}; + } + // `from == to` : vide, ou 24 h ? On ne devine pas. Le tarif unique s'écrit 00:00–24:00. + if (de == a) { + refus(cause, causeParams, "RANGE_EMPTY", ou); + return {}; + } + zone.ranges << TariffRange{de, a}; + // Marquer chaque minute couverte ; une minute déjà prise est un CHEVAUCHEMENT. La + // première minute en cause est rendue : c'est ce que l'écran doit pointer. + const int fin = a > de ? a : a + MinutesParJour; + for (int m = de; m < fin; ++m) { + const int mn = m % MinutesParJour; + if (parMinute.at(mn) >= 0) { + refus(cause, causeParams, "OVERLAP", + {{QStringLiteral("at"), ecrireHeure(mn)}, + {QStringLiteral("zones"), QVariantList{parMinute.at(mn), z}}}); + return {}; + } + parMinute[mn] = z; + } + } + t.m_zones << zone; + } + // LA COUVERTURE : les 24 h, sans trou. Une minute sans zone n'aurait pas de prix, et un + // prix absent au milieu d'une journée se lirait comme une donnée manquante, pas une saisie. + for (int m = 0; m < MinutesParJour; ++m) { + if (parMinute.at(m) < 0) { + refus(cause, causeParams, "GAP", {{QStringLiteral("at"), ecrireHeure(m)}}); + return {}; + } + } + t.m_parMinute = parMinute; + return t; +} + +QVariantMap TariffConfig::toVariant() const +{ + QVariantList zones; + for (const TariffZone &z : m_zones) { + QVariantList plages; + for (const TariffRange &r : z.ranges) + plages << QVariantMap{{QStringLiteral("from"), ecrireHeure(r.fromMin)}, + {QStringLiteral("to"), ecrireHeure(r.toMin)}}; + zones << QVariantMap{{QStringLiteral("label"), z.label}, + {QStringLiteral("priceCtkWh"), z.priceCtkWh}, + {QStringLiteral("ranges"), plages}}; + } + return {{QStringLiteral("zones"), zones}, {QStringLiteral("exportCtkWh"), m_exportCtkWh}}; +} + +int TariffConfig::zoneAt(const QDateTime &instant, const QTimeZone &fuseau) const +{ + if (m_parMinute.isEmpty()) + return -1; + const QTime murale = instant.toTimeZone(fuseau).time(); + return m_parMinute.at(murale.hour() * 60 + murale.minute()); +} + +QList TariffConfig::importSeries(const QDateTime &start, int stepS, int steps, + const QTimeZone &fuseau) const +{ + QList serie; + if (m_parMinute.isEmpty() || stepS <= 0 || steps <= 0) + return serie; + const qint64 t0 = start.toSecsSinceEpoch(); + for (int i = 0; i < steps; ++i) { + const qint64 debut = t0 + qint64(i) * stepS; + const qint64 fin = debut + stepS; + // Parcours par MINUTE ABSOLUE, chaque morceau pondéré par sa durée : un début de grille + // non aligné sur la minute reste exact. + double somme = 0; + for (qint64 a = debut; a < fin;) { + const qint64 b = qMin(fin, (a / 60 + 1) * 60); + const int z = zoneAt(QDateTime::fromSecsSinceEpoch(a, QTimeZone::utc()), fuseau); + somme += m_zones.at(z).priceCtkWh * double(b - a); + a = b; + } + serie << somme / double(stepS); + } + return serie; +} + +bool TariffConfig::operator==(const TariffConfig &o) const +{ + return toVariant() == o.toVariant(); +} diff --git a/energyplugin/etm/tariff/tariffconfig.h b/energyplugin/etm/tariff/tariffconfig.h new file mode 100644 index 0000000..5730ada --- /dev/null +++ b/energyplugin/etm/tariff/tariffconfig.h @@ -0,0 +1,150 @@ +// 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 + +/*! + * \file tariffconfig.h + * \brief Le tarif SAISI par l'installateur — brief 3, T4 (\c DESIGN_PREVISION_PV.md §32). + * + * \par Un RÉGLAGE, pas un instant + * Les plages (« 22:00 »–« 06:00 ») sont des heures MURALES : elles s'interprètent dans le + * fuseau de la box au moment où l'on construit une série, et le résultat est rendu en + * 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) + * 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. + */ + +//! \brief Une plage horaire locale `[from, to)`, en minutes depuis minuit ; `from > to` passe minuit. +struct TariffRange { + int fromMin = 0; //!< 0 … 1439. + int toMin = 0; //!< 1 … 1440 (1440 = « 24:00 ») ; ou `< fromMin` si la plage passe minuit. +}; + +//! \brief Une zone tarifaire : un prix, et une ou plusieurs plages (HC en deux fenêtres). +struct TariffZone { + QString label; //!< Saisi par l'installateur — donnée, jamais une phrase du moteur. + double priceCtkWh = 0; //!< Prix COMPLET payé, c€/kWh TTC. + QList ranges; //!< Au moins une. +}; + +/*! + * \brief Tarif manuel : tarif unique (une zone sur 24 h) ou zones horaires (HP/HC…). + * + * \invariant Une instance VALIDE couvre les 1 440 minutes de la journée locale, chacune par + * EXACTEMENT une zone. \c fromVariant() refuse tout le reste ; il n'existe pas d'autre + * constructeur peuplé. + * \invariant Une instance VIDE (\c isEmpty()) veut dire « aucun tarif saisi » : le prix publié + * est alors ABSENT, jamais 0. + */ +class TariffConfig +{ +public: + static constexpr int MaxZones = 12; //!< Tempo n'en a que 6 ; au-delà, une saisie erronée. + static constexpr int MaxRangesParZone = 12; //!< Plages par zone ; au-delà, une saisie erronée. + static constexpr int MaxLabelLength = 64; //!< Longueur maximale d'un libellé. + static constexpr const char *Currency = "EUR"; //!< Devise publiée avec le prix. + + //! \brief Aucun tarif saisi. + TariffConfig() = default; + + //! \brief Rien n'a été saisi. \return Vrai si aucune zone. + bool isEmpty() const { return m_zones.isEmpty(); } + + //! \brief Les zones, dans l'ordre de saisie. \return Liste, vide si aucun tarif. + const QList &zones() const { return m_zones; } + + //! \brief Tarif de revente saisi, c€/kWh ; 0 s'il n'a pas été saisi. \return Le prix. + double exportCtkWh() const { return m_exportCtkWh; } + + /*! + * \brief Nature du tarif. \return \c "fixed" (une zone) ou \c "tou" (plusieurs) ; vide si + * aucun tarif. Un tarif unique EST une zone couvrant 24 h : le nombre de zones suffit. + */ + QString source() const; + + //! \brief Ce que le prix comprend — toujours le prix complet payé. \return La liste. + static QStringList includes(); + + /*! + * \brief Lit et VALIDE une saisie. Rien de partiel : soit tout est valide, soit rien. + * \param saisie \c {zones: [{label, priceCtkWh, ranges: [{from, to}]}], exportCtkWh?}. + * \c zones vide = « aucun tarif » (accepté : c'est l'effacement). + * \param[out] cause Vide si accepté ; sinon un CODE (\c MALFORMED, \c TOO_MANY_ZONES, + * \c LABEL, \c PRICE, \c NO_RANGE, \c TOO_MANY_RANGES, \c RANGE_FORMAT, \c RANGE_EMPTY, + * \c OVERLAP, \c GAP, \c EXPORT_PRICE). + * \param[out] causeParams Où : \c zone et \c range (index), \c at (\c "HH:MM", première + * minute en cause), \c zones (les deux index qui se chevauchent). + * \return Le tarif ; vide si refusé ou si \c zones est vide — \p cause les distingue. + * + * \note **Strict sur les types** : un prix en chaîne, un booléen, une heure \c "6:00" ou + * \c "06:00:00" sont refusés plutôt que convertis. Une conversion indulgente transforme + * une erreur de saisie en tarif plausible, et c'est le pire des deux. + * \note `"24:00"` n'est admis qu'en FIN de plage ; `from == to` est refusé (vide ou 24 h ? + * on ne devine pas) — le tarif unique s'écrit `00:00`–`24:00`. + */ + static TariffConfig fromVariant(const QVariantMap &saisie, QString &cause, + QVariantMap &causeParams); + + //! \brief Forme publiée et persistée — celle que \c fromVariant() relit. \return La carte. + QVariantMap toVariant() const; + + /*! + * \brief La zone en vigueur à un INSTANT, lu dans le fuseau qui fait foi. + * \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 Le prix d'import sur une grille d'instants absolus. + * \param start Début du premier créneau. + * \param stepS Durée d'un créneau, en secondes (> 0). + * \param steps Nombre de créneaux. + * \param fuseau Fuseau dans lequel les plages se lisent. + * \return Un prix par créneau, c€/kWh ; vide si aucun tarif. + * + * \par Un créneau à cheval sur deux zones reçoit la MOYENNE PONDÉRÉE PAR LA DURÉE + * Toutes les séries de la grille sont des moyennes sur `[t, t + pas)` (\c pv, \c baseload). + * Pour une puissance constante sur le créneau — ce que la grille suppose — le coût est + * exactement `P × pas × moyenne pondérée`. C'est donc la seule valeur cohérente avec le + * reste de la grille ; découper le créneau changerait la grille, qui est UNE pour toutes les + * séries (\c OPTIMIZER_PROTOCOL.md §5.2). + * \par Calculé en heure locale, rendu en instants absolus + * La grille est parcourue minute ABSOLUE par minute ; chaque minute prend la zone de son + * heure murale. Les décalages réels sont des minutes entières : le résultat est exact, + * jours de 23 h et de 25 h compris (l'heure murale répétée est facturée deux fois, celle + * qui n'existe pas ne l'est jamais). + */ + QList importSeries(const QDateTime &start, int stepS, int steps, + const QTimeZone &fuseau) const; + + //! \brief Égalité de contenu (zones, plages, prix, revente). \param o Autre tarif. \return Vrai si identiques. + bool operator==(const TariffConfig &o) const; + + //! \brief Lit `"HH:MM"` strictement. \param s Texte. \param permettre24 Admettre `"24:00"`. + //! \return Minutes depuis minuit, ou -1 si illisible. + static int lireHeure(const QString &s, bool permettre24); + //! \brief Écrit `"HH:MM"`. \param minutes 0 … 1440. \return Le texte. + static QString ecrireHeure(int minutes); + +private: + QList m_zones; + double m_exportCtkWh = 0; + QVector m_parMinute; // 1 440 entrées : minute murale → index de zone +}; diff --git a/tests/auto/loadmodel/testloadmodel.cpp b/tests/auto/loadmodel/testloadmodel.cpp index d6b2d0f..9200da3 100644 --- a/tests/auto/loadmodel/testloadmodel.cpp +++ b/tests/auto/loadmodel/testloadmodel.cpp @@ -18,6 +18,7 @@ #include "etm/types/ecotarget.h" #include "etm/config/loadconfigstore.h" #include "etm/ratios/energyratioscalculator.h" +#include "etm/tariff/tariffconfig.h" #include #include @@ -1966,3 +1967,204 @@ void TestLoadModel::testTheLearningLogAttachesTheObservedGtiToItsStep() QCOMPARE(ligne(utc(2026, 10, 13, 10)).value("gtiObserved").toDouble(), 420.0); QCOMPARE(ligne(utc(2026, 10, 13, 10)).value("valid").toInt(), 1); // valid n'en dépend pas } + +// ============================================================================================ +// Brief 3 T4 — le tarif saisi par l'installateur +// ============================================================================================ + +namespace { +QVariantMap zoneTarif(const QString &label, const QVariant &prix, + const QList> &plages) +{ + QVariantList r; + for (const auto &p : plages) + r << QVariantMap{{"from", p.first}, {"to", p.second}}; + return QVariantMap{{"label", label}, {"priceCtkWh", prix}, {"ranges", r}}; +} + +QVariantMap saisieTarif(const QVariantList &zones) +{ + return QVariantMap{{"zones", zones}}; +} + +//! HP/HC « 22:00-06:00 » à 15, le reste à 25 — le cas d'école des oracles. +QVariantMap hphc() +{ + return saisieTarif({zoneTarif("HC", 15.0, {{"22:00", "06:00"}}), + zoneTarif("HP", 25.0, {{"06:00", "22:00"}})}); +} +} + +void TestLoadModel::testTheTariffIsValidatedStrictly() +{ + QString cause; + QVariantMap ou; + + // 1. ACCEPTÉS — le tarif unique, les HP/HC, l'HC en DEUX fenêtres. + TariffConfig t = TariffConfig::fromVariant( + saisieTarif({zoneTarif("Base", 20.16, {{"00:00", "24:00"}})}), cause, ou); + QVERIFY2(cause.isEmpty(), qPrintable(cause)); + QCOMPARE(t.source(), QStringLiteral("fixed")); + QCOMPARE(t.exportCtkWh(), 0.0); // revente non saisie → 0 (brief) + + QVariantMap deuxFenetres = saisieTarif({zoneTarif("HC", 15.0, {{"12:00", "14:00"}, {"22:00", "06:00"}}), + zoneTarif("HP", 25.0, {{"06:00", "12:00"}, {"14:00", "22:00"}})}); + deuxFenetres.insert("exportCtkWh", 4); // un ENTIER est un nombre + t = TariffConfig::fromVariant(deuxFenetres, cause, ou); + QVERIFY2(cause.isEmpty(), qPrintable(cause)); + QCOMPARE(t.source(), QStringLiteral("tou")); + QCOMPARE(t.exportCtkWh(), 4.0); + // L'aller-retour : la forme publiée est celle qu'on relit, sans perte. + const TariffConfig relu = TariffConfig::fromVariant(t.toVariant(), cause, ou); + QVERIFY(cause.isEmpty()); + QVERIFY(relu == t); + QCOMPARE(relu.toVariant(), t.toVariant()); + + // Une plage qui se termine à « 00:00 » passe minuit et vaut « 24:00 ». + t = TariffConfig::fromVariant(saisieTarif({zoneTarif("Soir", 30.0, {{"22:00", "00:00"}}), + zoneTarif("Jour", 20.0, {{"00:00", "22:00"}})}), cause, ou); + QVERIFY2(cause.isEmpty(), qPrintable(cause)); + + // 2. L'EFFACEMENT est accepté et VIDE — distinct d'un refus par sa cause vide. + t = TariffConfig::fromVariant(saisieTarif({}), cause, ou); + QVERIFY(cause.isEmpty()); + QVERIFY(t.isEmpty()); + QVERIFY(t.source().isEmpty()); + QVERIFY(t.importSeries(utc(2026, 6, 8, 0), 3600, 24, QTimeZone("Europe/Paris")).isEmpty()); + + // 3. REFUSÉS — chacun avec SON code et l'endroit en cause. + struct Cas { const char *quoi; QVariantMap saisie; const char *cause; QVariantMap ou; }; + QVariantMap exportNegatif = hphc(); + exportNegatif.insert("exportCtkWh", -1); + QVariantMap exportTexte = hphc(); + exportTexte.insert("exportCtkWh", "6"); + QVariantList treize; + for (int i = 0; i < 13; ++i) + treize << zoneTarif(QString::number(i), 1.0, {{"00:00", "01:00"}}); + const QList cas = { + {"zones absent", QVariantMap{}, "MALFORMED", {{"field", "zones"}}}, + {"zones non liste", QVariantMap{{"zones", "HC"}}, "MALFORMED", {{"field", "zones"}}}, + {"trop de zones", saisieTarif(treize), "TOO_MANY_ZONES", {{"max", 12}}}, + {"trou", saisieTarif({zoneTarif("HC", 15.0, {{"22:00", "06:00"}}), + zoneTarif("HP", 25.0, {{"06:00", "21:00"}})}), + "GAP", {{"at", "21:00"}}}, + {"chevauchement", saisieTarif({zoneTarif("HC", 15.0, {{"22:00", "06:00"}}), + zoneTarif("HP", 25.0, {{"05:00", "22:00"}})}), + "OVERLAP", {{"at", "05:00"}, {"zones", QVariantList{0, 1}}}}, + {"chevauchement dans une zone", saisieTarif({zoneTarif("Base", 15.0, {{"00:00", "24:00"}, {"10:00", "11:00"}})}), + "OVERLAP", {{"at", "10:00"}, {"zones", QVariantList{0, 0}}}}, + {"prix négatif", saisieTarif({zoneTarif("HC", 15.0, {{"22:00", "06:00"}}), + zoneTarif("HP", -0.01, {{"06:00", "22:00"}})}), + "PRICE", {{"zone", 1}}}, + {"prix en texte", saisieTarif({zoneTarif("Base", "20.16", {{"00:00", "24:00"}})}), "PRICE", {{"zone", 0}}}, + {"prix booléen", saisieTarif({zoneTarif("Base", true, {{"00:00", "24:00"}})}), "PRICE", {{"zone", 0}}}, + {"prix absent", saisieTarif({QVariantMap{{"label", "Base"}, {"ranges", QVariantList{QVariantMap{{"from", "00:00"}, {"to", "24:00"}}}}}}), + "PRICE", {{"zone", 0}}}, + {"libellé vide", saisieTarif({zoneTarif(" ", 20.0, {{"00:00", "24:00"}})}), "LABEL", {{"zone", 0}}}, + {"libellé en double", saisieTarif({zoneTarif("HC", 15.0, {{"22:00", "06:00"}}), + zoneTarif("HC", 25.0, {{"06:00", "22:00"}})}), + "LABEL", {{"zone", 1}}}, + {"aucune plage", saisieTarif({zoneTarif("Base", 20.0, {})}), "NO_RANGE", {{"zone", 0}}}, + {"heure sans zéro", saisieTarif({zoneTarif("Base", 20.0, {{"0:00", "24:00"}})}), + "RANGE_FORMAT", {{"zone", 0}, {"range", 0}}}, + {"secondes", saisieTarif({zoneTarif("Base", 20.0, {{"00:00:00", "24:00"}})}), + "RANGE_FORMAT", {{"zone", 0}, {"range", 0}}}, + {"24:00 en début", saisieTarif({zoneTarif("Base", 20.0, {{"24:00", "24:00"}})}), + "RANGE_FORMAT", {{"zone", 0}, {"range", 0}}}, + {"25:00", saisieTarif({zoneTarif("Base", 20.0, {{"00:00", "25:00"}})}), + "RANGE_FORMAT", {{"zone", 0}, {"range", 0}}}, + {"from == to", saisieTarif({zoneTarif("Base", 20.0, {{"06:00", "06:00"}})}), + "RANGE_EMPTY", {{"zone", 0}, {"range", 0}}}, + {"00:00 → 00:00", saisieTarif({zoneTarif("Base", 20.0, {{"00:00", "00:00"}})}), + "RANGE_EMPTY", {{"zone", 0}, {"range", 0}}}, + {"revente négative", exportNegatif, "EXPORT_PRICE", {}}, + {"revente en texte", exportTexte, "EXPORT_PRICE", {}}, + }; + for (const Cas &c : cas) { + t = TariffConfig::fromVariant(c.saisie, cause, ou); + QVERIFY2(t.isEmpty(), c.quoi); + QVERIFY2(cause == QLatin1String(c.cause), + qPrintable(QStringLiteral("%1 : %2 attendu, %3 obtenu").arg(c.quoi, c.cause, cause))); + QVERIFY2(ou == c.ou, + qPrintable(QStringLiteral("%1 : où = %2").arg(c.quoi, + QString::fromUtf8(QJsonDocument::fromVariant(ou).toJson(QJsonDocument::Compact))))); + } +} + +void TestLoadModel::testTheTariffFollowsLocalTimeAcrossDstDays() +{ + // ORACLES CALCULÉS HORS DE Qt (Python zoneinfo, minute par minute — script dans le rapport + // T4) : la grille va du minuit LOCAL au minuit local suivant. L'attendu ne se fabrique pas + // avec les primitives du code testé. + QString cause; + QVariantMap ou; + const TariffConfig t = TariffConfig::fromVariant(hphc(), cause, ou); + QVERIFY(cause.isEmpty()); + const QTimeZone paris("Europe/Paris"); + QVERIFY(paris.isValid()); + QList hc5hp16hc2, hc7hp16hc2, hc6hp16hc2; + for (int i = 0; i < 5; ++i) hc5hp16hc2 << 15.0; + for (int i = 0; i < 7; ++i) hc7hp16hc2 << 15.0; + for (int i = 0; i < 6; ++i) hc6hp16hc2 << 15.0; + for (QList *l : {&hc5hp16hc2, &hc7hp16hc2, &hc6hp16hc2}) { + for (int i = 0; i < 16; ++i) *l << 25.0; + *l << 15.0 << 15.0; + } + struct Jour { const char *quoi; qint64 minuitLocal; int pas; QList attendu; }; + const QList jours = { + {"2026-03-29, 23 h", 1774738800, 23, hc5hp16hc2}, + {"2026-10-25, 25 h", 1792879200, 25, hc7hp16hc2}, + {"2026-06-08, 24 h", 1780869600, 24, hc6hp16hc2}, + }; + for (const Jour &j : jours) { + QCOMPARE(j.attendu.count(), j.pas); + const QList s = t.importSeries(QDateTime::fromSecsSinceEpoch(j.minuitLocal, QTimeZone::utc()), + 3600, j.pas, paris); + QVERIFY2(s == j.attendu, j.quoi); + } + // zoneAt() — la même lecture, pour un instant : 05:59 locale est HC, 06:00 est HP. + QCOMPARE(t.zoneAt(QDateTime(QDate(2026, 6, 8), QTime(5, 59), paris), paris), 0); + QCOMPARE(t.zoneAt(QDateTime(QDate(2026, 6, 8), QTime(6, 0), paris), paris), 1); + // Le MÊME instant lu dans un autre fuseau change de zone : c'est un réglage MURAL. + const QDateTime instant = QDateTime(QDate(2026, 6, 8), QTime(4, 30), QTimeZone::utc()); // 06:30 Paris + QCOMPARE(t.zoneAt(instant, paris), 1); + QCOMPARE(t.zoneAt(instant, QTimeZone::utc()), 0); +} + +void TestLoadModel::testAStraddlingSlotGetsTheTimeWeightedMean() +{ + QString cause; + QVariantMap ou; + const QTimeZone paris("Europe/Paris"); + // HC 22:30-06:30 à 15, HP à 25 : les créneaux 06:00 et 22:00 sont MI-HC, MI-HP → 20. + const TariffConfig demi = TariffConfig::fromVariant( + saisieTarif({zoneTarif("HC", 15.0, {{"22:30", "06:30"}}), + zoneTarif("HP", 25.0, {{"06:30", "22:30"}})}), cause, ou); + QVERIFY(cause.isEmpty()); + QList attendu; + for (int i = 0; i < 6; ++i) attendu << 15.0; + attendu << 20.0; + for (int i = 0; i < 15; ++i) attendu << 25.0; + attendu << 20.0 << 15.0; + QCOMPARE(demi.importSeries(QDateTime::fromSecsSinceEpoch(1780869600, QTimeZone::utc()), 3600, 24, paris), + attendu); + + // À CHEVAL **ET** SUR LE CHANGEMENT D'HEURE — zone A 02:30-03:30 à 10, B à 30 (oracle hors Qt). + // 25 h : 02:00-03:00 existe DEUX fois (mi-A chaque fois), 03:00-04:00 une fois (mi-A) → 20, 20, 20. + // 23 h : 02:00-03:00 n'existe pas ; le créneau qui suit minuit+2 h est 03:00-04:00 → 20. + const TariffConfig dst = TariffConfig::fromVariant( + saisieTarif({zoneTarif("A", 10.0, {{"02:30", "03:30"}}), + zoneTarif("B", 30.0, {{"03:30", "02:30"}})}), cause, ou); + QVERIFY(cause.isEmpty()); + QList automne{30.0, 30.0, 20.0, 20.0, 20.0}; + while (automne.count() < 25) automne << 30.0; + QList printemps{30.0, 30.0, 20.0}; + while (printemps.count() < 23) printemps << 30.0; + QCOMPARE(dst.importSeries(QDateTime::fromSecsSinceEpoch(1792879200, QTimeZone::utc()), 3600, 25, paris), automne); + QCOMPARE(dst.importSeries(QDateTime::fromSecsSinceEpoch(1774738800, QTimeZone::utc()), 3600, 23, paris), printemps); + + // Un pas de 15 min tombe sur les plages : pas de mélange, la grille suit (brief : pas lu). + const QList quarts = demi.importSeries(QDateTime(QDate(2026, 6, 8), QTime(6, 0), paris), 900, 4, paris); + QCOMPARE(quarts, (QList{15.0, 15.0, 25.0, 25.0})); +} + diff --git a/tests/auto/loadmodel/testloadmodel.h b/tests/auto/loadmodel/testloadmodel.h index cdaebd9..d720f94 100644 --- a/tests/auto/loadmodel/testloadmodel.h +++ b/tests/auto/loadmodel/testloadmodel.h @@ -114,6 +114,14 @@ private slots: //! etm68 — le GTI observé va au pas qu'il moyenne, complète seulement ses colonnes, si NULL. void testTheLearningLogAttachesTheObservedGtiToItsStep(); + // ── Brief 3 T4 — tarif saisi ─────────────────────────────────────────── + //! Validation STRICTE : prix ≥ 0, plages lisibles, 24 h sans trou ni chevauchement — refus codés. + void testTheTariffIsValidatedStrictly(); + //! Les plages sont des heures MURALES : jours de 23 h et de 25 h, oracle hors Qt. + void testTheTariffFollowsLocalTimeAcrossDstDays(); + //! Un créneau à cheval sur deux zones reçoit la moyenne pondérée par la durée. + void testAStraddlingSlotGetsTheTimeWeightedMean(); + }; #endif // TESTLOADMODEL_H