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

189 lines
9.7 KiB
C++
Raw Permalink 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 Une seule décision le lit : les HEURES CREUSES (lot e1, 2026-10-24)
* Ce type est EXPOSÉ (\c GetTariff, \c GetForecastSnapshot), jamais versé dans
* \c SurplusContext::tariff — l'optimiseur ne le reçoit pas par là. Le moteur n'en tire
* qu'une chose : quelles minutes sont « creuses » (\c zonesCreuses(), \c enHeuresCreuses()),
* et rien d'autre ne lit le prix. Sans tarif ou avec un tarif unique, il n'y a pas d'heure
* creuse, et le moteur se comporte exactement comme avant ce lot.
*/
//! \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.
*/
int zoneAt(const QDateTime &instant, const QTimeZone &fuseau) const;
/*!
* \brief Les zones « creuses » : celles au prix LE PLUS BAS d'un tarif à zones horaires.
* \return Index dans \c zones(), croissants ; VIDE sans tarif, avec un tarif unique, ou quand
* toutes les zones ont le même prix.
*
* \par Pourquoi le prix, et pas le libellé
* « HC » n'a pas de code : le libellé est une donnée de l'installateur, qui peut écrire
* « Creuses », « Nuit » ou « Off-peak ». Le prix, lui, se compare. Plusieurs zones au même
* prix minimal sont TOUTES creuses — deux fenêtres saisies comme deux zones (nuit et début
* d'après-midi) ne changent pas de nature parce qu'on les a nommées séparément.
* \par Pourquoi « des prix distincts » est exigé
* Un tarif dont toutes les zones coûtent le même prix n'a pas d'heure meilleure qu'une
* autre : le dire « creux » partout reviendrait à servir le minimum réseau à toute heure,
* c'est-à-dire à changer le comportement d'un tarif qui, de fait, est unique.
*/
QList<int> zonesCreuses() const;
/*!
* \brief L'instant tombe-t-il en heures creuses ?
* \param instant Instant absolu. \param fuseau Fuseau de la box.
* \return Vrai si la zone en vigueur est dans \c zonesCreuses() ; faux sans heure creuse.
*/
bool enHeuresCreuses(const QDateTime &instant, const QTimeZone &fuseau) const;
/*!
* \brief La prochaine BASCULE creuse ↔ pleine après \p instant.
* \param instant Instant absolu. \param fuseau Fuseau de la box.
* \return Premier instant (UTC, à la minute) où \c enHeuresCreuses() change de valeur ;
* invalide sans heure creuse.
* \par Calculée minute ABSOLUE par minute
* Comme \c importSeries() : chaque minute prend la zone de son heure murale. Un jour de
* 23 h saute l'heure qui n'existe pas, un jour de 25 h parcourt deux fois l'heure répétée —
* jamais de soustraction de dates, qui rendrait la durée d'horloge murale (AGENTS.md,
* « l'arithmétique de dates »). Recherche bornée à 50 h : toute zone saisie revient chaque
* jour, donc une bascule existe toujours dans cette fenêtre quand il y a des heures creuses.
*/
QDateTime prochaineBascule(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
};