Patrick Schurig 920cf854da Tarif : les heures creuses sont la ou les zones au prix le plus bas (lot e1)
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>
2026-10-24 08:26:01 +02:00

278 lines
11 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<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();
}