TariffConfig::zonesCreuses() : les zones au prix minimal d'un tarif à zones horaires dont les prix diffèrent. Aucun tarif, tarif unique ou prix tous égaux : aucune heure creuse. Le libellé ne décide pas — « HC » n'a pas de code, le prix se compare. enHeuresCreuses() et prochaineBascule() lisent l'heure murale minute absolue par minute, comme importSeries() : bascules exactes les jours de 23 h (nuit de 7 h) et de 25 h (nuit de 9 h, 02:30 deux fois creuse), épinglées sur des époques calculées hors de Qt (zoneinfo). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
278 lines
11 KiB
C++
278 lines
11 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<int> TariffConfig::zonesCreuses() const
|
||
{
|
||
QList<int> creuses;
|
||
if (m_zones.count() < 2)
|
||
return creuses; // aucun tarif, ou tarif unique
|
||
double bas = m_zones.first().priceCtkWh;
|
||
double haut = bas;
|
||
for (const TariffZone &z : m_zones) {
|
||
bas = qMin(bas, z.priceCtkWh);
|
||
haut = qMax(haut, z.priceCtkWh);
|
||
}
|
||
if (!(bas < haut))
|
||
return creuses; // tous au même prix : aucune heure n'est meilleure
|
||
for (int i = 0; i < m_zones.count(); ++i)
|
||
if (m_zones.at(i).priceCtkWh == bas)
|
||
creuses << i;
|
||
return creuses;
|
||
}
|
||
|
||
bool TariffConfig::enHeuresCreuses(const QDateTime &instant, const QTimeZone &fuseau) const
|
||
{
|
||
const QList<int> creuses = zonesCreuses();
|
||
return !creuses.isEmpty() && creuses.contains(zoneAt(instant, fuseau));
|
||
}
|
||
|
||
QDateTime TariffConfig::prochaineBascule(const QDateTime &instant, const QTimeZone &fuseau) const
|
||
{
|
||
const QList<int> creuses = zonesCreuses();
|
||
if (creuses.isEmpty() || !instant.isValid())
|
||
return {};
|
||
const bool depart = creuses.contains(zoneAt(instant, fuseau));
|
||
// Minute ABSOLUE suivante, puis pas d'une minute : chaque minute prend la zone de son heure
|
||
// murale (même parcours qu'importSeries()).
|
||
const qint64 debut = (instant.toSecsSinceEpoch() / 60 + 1) * 60;
|
||
for (qint64 a = debut; a <= debut + 50 * 3600; a += 60) {
|
||
const QDateTime t = QDateTime::fromSecsSinceEpoch(a, QTimeZone::utc());
|
||
if (creuses.contains(zoneAt(t, fuseau)) != depart)
|
||
return t;
|
||
}
|
||
return {};
|
||
}
|
||
|
||
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();
|
||
}
|