// SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync #pragma once #include #include #include #include #include #include #include /*! * \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 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 &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 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 m_zones; double m_exportCtkWh = 0; QVector m_parMinute; // 1 440 entrées : minute murale → index de zone };