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>
151 lines
7.4 KiB
C++
151 lines
7.4 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||
#pragma once
|
||
|
||
#include <QDateTime>
|
||
#include <QList>
|
||
#include <QString>
|
||
#include <QTimeZone>
|
||
#include <QVariantMap>
|
||
#include <QVector>
|
||
|
||
#include <optional>
|
||
|
||
/*!
|
||
* \file tariffconfig.h
|
||
* \brief Le tarif SAISI par l'installateur — brief 3, T4 (\c DESIGN_PREVISION_PV.md §32).
|
||
*
|
||
* \par Un RÉGLAGE, pas un instant
|
||
* Les plages (« 22:00 »–« 06:00 ») sont des heures MURALES : elles s'interprètent dans le
|
||
* fuseau de la box au moment où l'on construit une série, et le résultat est rendu en
|
||
* instants absolus. Le même réglage donne donc 23 ou 25 créneaux horaires selon le jour —
|
||
* c'est voulu (AGENTS.md, « un RÉGLAGE … un INSTANT »).
|
||
*
|
||
* \par Aucune décision ne le lit (§11)
|
||
* Ce type est EXPOSÉ (\c GetTariff, \c GetForecastSnapshot), jamais versé dans
|
||
* \c SurplusContext::tariff. Son usage par une décision (rattrapage éco en HC, e1) est un lot
|
||
* séparé ; \c zoneAt() existe pour lui, et rien ne l'appelle aujourd'hui depuis le plan.
|
||
*/
|
||
|
||
//! \brief Une plage horaire locale `[from, to)`, en minutes depuis minuit ; `from > to` passe minuit.
|
||
struct TariffRange {
|
||
int fromMin = 0; //!< 0 … 1439.
|
||
int toMin = 0; //!< 1 … 1440 (1440 = « 24:00 ») ; ou `< fromMin` si la plage passe minuit.
|
||
};
|
||
|
||
//! \brief Une zone tarifaire : un prix, et une ou plusieurs plages (HC en deux fenêtres).
|
||
struct TariffZone {
|
||
QString label; //!< Saisi par l'installateur — donnée, jamais une phrase du moteur.
|
||
double priceCtkWh = 0; //!< Prix COMPLET payé, c€/kWh TTC.
|
||
QList<TariffRange> ranges; //!< Au moins une.
|
||
};
|
||
|
||
/*!
|
||
* \brief Tarif manuel : tarif unique (une zone sur 24 h) ou zones horaires (HP/HC…).
|
||
*
|
||
* \invariant Une instance VALIDE couvre les 1 440 minutes de la journée locale, chacune par
|
||
* EXACTEMENT une zone. \c fromVariant() refuse tout le reste ; il n'existe pas d'autre
|
||
* constructeur peuplé.
|
||
* \invariant Une instance VIDE (\c isEmpty()) veut dire « aucun tarif saisi » : le prix publié
|
||
* est alors ABSENT, jamais 0.
|
||
*/
|
||
class TariffConfig
|
||
{
|
||
public:
|
||
static constexpr int MaxZones = 12; //!< Tempo n'en a que 6 ; au-delà, une saisie erronée.
|
||
static constexpr int MaxRangesParZone = 12; //!< Plages par zone ; au-delà, une saisie erronée.
|
||
static constexpr int MaxLabelLength = 64; //!< Longueur maximale d'un libellé.
|
||
static constexpr const char *Currency = "EUR"; //!< Devise publiée avec le prix.
|
||
|
||
//! \brief Aucun tarif saisi.
|
||
TariffConfig() = default;
|
||
|
||
//! \brief Rien n'a été saisi. \return Vrai si aucune zone.
|
||
bool isEmpty() const { return m_zones.isEmpty(); }
|
||
|
||
//! \brief Les zones, dans l'ordre de saisie. \return Liste, vide si aucun tarif.
|
||
const QList<TariffZone> &zones() const { return m_zones; }
|
||
|
||
//! \brief Tarif de revente saisi, c€/kWh ; 0 s'il n'a pas été saisi. \return Le prix.
|
||
double exportCtkWh() const { return m_exportCtkWh; }
|
||
|
||
/*!
|
||
* \brief Nature du tarif. \return \c "fixed" (une zone) ou \c "tou" (plusieurs) ; vide si
|
||
* aucun tarif. Un tarif unique EST une zone couvrant 24 h : le nombre de zones suffit.
|
||
*/
|
||
QString source() const;
|
||
|
||
//! \brief Ce que le prix comprend — toujours le prix complet payé. \return La liste.
|
||
static QStringList includes();
|
||
|
||
/*!
|
||
* \brief Lit et VALIDE une saisie. Rien de partiel : soit tout est valide, soit rien.
|
||
* \param saisie \c {zones: [{label, priceCtkWh, ranges: [{from, to}]}], exportCtkWh?}.
|
||
* \c zones vide = « aucun tarif » (accepté : c'est l'effacement).
|
||
* \param[out] cause Vide si accepté ; sinon un CODE (\c MALFORMED, \c TOO_MANY_ZONES,
|
||
* \c LABEL, \c PRICE, \c NO_RANGE, \c TOO_MANY_RANGES, \c RANGE_FORMAT, \c RANGE_EMPTY,
|
||
* \c OVERLAP, \c GAP, \c EXPORT_PRICE).
|
||
* \param[out] causeParams Où : \c zone et \c range (index), \c at (\c "HH:MM", première
|
||
* minute en cause), \c zones (les deux index qui se chevauchent).
|
||
* \return Le tarif ; vide si refusé ou si \c zones est vide — \p cause les distingue.
|
||
*
|
||
* \note **Strict sur les types** : un prix en chaîne, un booléen, une heure \c "6:00" ou
|
||
* \c "06:00:00" sont refusés plutôt que convertis. Une conversion indulgente transforme
|
||
* une erreur de saisie en tarif plausible, et c'est le pire des deux.
|
||
* \note `"24:00"` n'est admis qu'en FIN de plage ; `from == to` est refusé (vide ou 24 h ?
|
||
* on ne devine pas) — le tarif unique s'écrit `00:00`–`24:00`.
|
||
*/
|
||
static TariffConfig fromVariant(const QVariantMap &saisie, QString &cause,
|
||
QVariantMap &causeParams);
|
||
|
||
//! \brief Forme publiée et persistée — celle que \c fromVariant() relit. \return La carte.
|
||
QVariantMap toVariant() const;
|
||
|
||
/*!
|
||
* \brief La zone en vigueur à un INSTANT, lu dans le fuseau qui fait foi.
|
||
* \param instant Instant absolu.
|
||
* \param fuseau Fuseau de la box.
|
||
* \return Index dans \c zones() ; -1 si aucun tarif.
|
||
* \note Réservé au lot qui branchera une décision dessus (e1) — aucun appelant aujourd'hui
|
||
* hors des tests et de \c importSeries().
|
||
*/
|
||
int zoneAt(const QDateTime &instant, const QTimeZone &fuseau) const;
|
||
|
||
/*!
|
||
* \brief Le prix d'import sur une grille d'instants absolus.
|
||
* \param start Début du premier créneau.
|
||
* \param stepS Durée d'un créneau, en secondes (> 0).
|
||
* \param steps Nombre de créneaux.
|
||
* \param fuseau Fuseau dans lequel les plages se lisent.
|
||
* \return Un prix par créneau, c€/kWh ; vide si aucun tarif.
|
||
*
|
||
* \par Un créneau à cheval sur deux zones reçoit la MOYENNE PONDÉRÉE PAR LA DURÉE
|
||
* Toutes les séries de la grille sont des moyennes sur `[t, t + pas)` (\c pv, \c baseload).
|
||
* Pour une puissance constante sur le créneau — ce que la grille suppose — le coût est
|
||
* exactement `P × pas × moyenne pondérée`. C'est donc la seule valeur cohérente avec le
|
||
* reste de la grille ; découper le créneau changerait la grille, qui est UNE pour toutes les
|
||
* séries (\c OPTIMIZER_PROTOCOL.md §5.2).
|
||
* \par Calculé en heure locale, rendu en instants absolus
|
||
* La grille est parcourue minute ABSOLUE par minute ; chaque minute prend la zone de son
|
||
* heure murale. Les décalages réels sont des minutes entières : le résultat est exact,
|
||
* jours de 23 h et de 25 h compris (l'heure murale répétée est facturée deux fois, celle
|
||
* qui n'existe pas ne l'est jamais).
|
||
*/
|
||
QList<double> importSeries(const QDateTime &start, int stepS, int steps,
|
||
const QTimeZone &fuseau) const;
|
||
|
||
//! \brief Égalité de contenu (zones, plages, prix, revente). \param o Autre tarif. \return Vrai si identiques.
|
||
bool operator==(const TariffConfig &o) const;
|
||
|
||
//! \brief Lit `"HH:MM"` strictement. \param s Texte. \param permettre24 Admettre `"24:00"`.
|
||
//! \return Minutes depuis minuit, ou -1 si illisible.
|
||
static int lireHeure(const QString &s, bool permettre24);
|
||
//! \brief Écrit `"HH:MM"`. \param minutes 0 … 1440. \return Le texte.
|
||
static QString ecrireHeure(int minutes);
|
||
|
||
private:
|
||
QList<TariffZone> m_zones;
|
||
double m_exportCtkWh = 0;
|
||
QVector<int> m_parMinute; // 1 440 entrées : minute murale → index de zone
|
||
};
|