// SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync #include "tariffconfig.h" #include #include namespace { constexpr int MinutesParJour = 1440; //! Un NOMBRE, rien d'autre : ni chaîne, ni booléen. \c std::nullopt sinon. std::optional 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 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 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 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 TariffConfig::importSeries(const QDateTime &start, int stepS, int steps, const QTimeZone &fuseau) const { QList 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(); }