Patrick Schurig e3816ee031 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>
2026-10-14 09:51:17 +02:00

236 lines
9.3 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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();
}