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:
parent
b62e6f55a8
commit
e3816ee031
@ -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 \
|
||||
|
||||
235
energyplugin/etm/tariff/tariffconfig.cpp
Normal file
235
energyplugin/etm/tariff/tariffconfig.cpp
Normal 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 ¶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<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();
|
||||
}
|
||||
150
energyplugin/etm/tariff/tariffconfig.h
Normal file
150
energyplugin/etm/tariff/tariffconfig.h
Normal 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
|
||||
};
|
||||
@ -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}));
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user