feat(tarif): TariffConfig — tarif saisi validé, plages murales, moyenne pondérée

Brief 3 T4. Une zone = {label, priceCtkWh, ranges[]}, plusieurs plages par
zone ; tarif unique = une zone 00:00-24:00 (source fixed), sinon tou.
Validation stricte et codée (prix ≥ 0, "HH:MM", 24 h sans trou ni
chevauchement, minute en cause rendue). importSeries() lit les plages en
heure murale, minute absolue par minute : jours de 23 h et 25 h exacts ; un
créneau à cheval reçoit la moyenne pondérée par la durée. Aucune décision ne
le lit (§11).

Tests loadmodel : validation, jours de 23/25 h et créneau à cheval sur
oracles Python zoneinfo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-14 09:51:17 +02:00
parent b62e6f55a8
commit e3816ee031
5 changed files with 597 additions and 0 deletions

View File

@ -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 \

View File

@ -0,0 +1,235 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#include "tariffconfig.h"
#include <QRegularExpression>
#include <QtMath>
namespace {
constexpr int MinutesParJour = 1440;
//! Un NOMBRE, rien d'autre : ni chaîne, ni booléen. \c std::nullopt sinon.
std::optional<double> 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 &params, 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<double> 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<int> 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<double> 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<double> TariffConfig::importSeries(const QDateTime &start, int stepS, int steps,
const QTimeZone &fuseau) const
{
QList<double> 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();
}

View File

@ -0,0 +1,150 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QDateTime>
#include <QList>
#include <QString>
#include <QTimeZone>
#include <QVariantMap>
#include <QVector>
#include <optional>
/*!
* \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<TariffRange> 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<TariffZone> &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<double> 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<TariffZone> m_zones;
double m_exportCtkWh = 0;
QVector<int> m_parMinute; // 1 440 entrées : minute murale → index de zone
};

View File

@ -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 <QTest>
#include <QDir>
@ -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<QPair<QString, QString>> &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> 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<double> 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<double> *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<double> attendu; };
const QList<Jour> 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<double> 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<double> 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<double> automne{30.0, 30.0, 20.0, 20.0, 20.0};
while (automne.count() < 25) automne << 30.0;
QList<double> 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<double> quarts = demi.importSeries(QDateTime(QDate(2026, 6, 8), QTime(6, 0), paris), 900, 4, paris);
QCOMPARE(quarts, (QList<double>{15.0, 15.0, 25.0, 25.0}));
}

View File

@ -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