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>
236 lines
9.3 KiB
C++
236 lines
9.3 KiB
C++
// 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();
|
||
}
|