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

151 lines
7.4 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
#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
};