// SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync #pragma once #include #include #include #include #include #include "../types/surpluscontext.h" /*! * \file optimizerprotocol.h * \brief Sérialisation \c SurplusContext → paramètres de \c GetPlan (OPTIMIZER_PROTOCOL.md 1.1, * §5.1 à §5.3). Fonction PURE : aucune E/S, aucune horloge, aucun accès à l'arbitre. * * \par Ce qui n'est pas dans \c SurplusContext, et pourquoi * La prévision n'y entre pas (DESIGN_PREVISION_PV.md §11 : le rule-based ne doit pas pouvoir * la lire). Elle voyage À CÔTÉ, dans \c ContexteFil, avec ce que l'arbitre sait et que le * contexte de décision ne porte pas : déclencheur, compteur racine, domaine et mesure par charge. * * \par Inconnu = \c null ou absent, JAMAIS 0 * Un champ que rien ne remplit (puissance souscrite, PV instantanée, capacité batterie, tarif) * est émis \c null ; un champ que le protocole rend optionnel et que rien ne porte (\c learned, * \c comfort, \c group, champs réservés de l'annexe F) est ABSENT. Un zéro affirmerait une * mesure ou un profil qui n'existent pas. */ namespace OptimizerProtocol { //! Version du protocole parlée par ce sérialiseur (§3, §9). constexpr const char *ProtocolVersion = "1.1"; //! Mesure d'une charge, telle que la résout l'arbitre (\c EnergyArbitrator::mesurerCharge). struct MesureFil { QString source = QStringLiteral("none"); //!< \c meter | \c device | \c none double powerW = 0; //!< Sans objet, et non émis, sous \c none. }; /*! * \brief Ce que la requête porte EN PLUS du contexte de décision. * * Rempli par l'appelant (le futur \c SocketScheduler), jamais par le rule-based. Tout champ * laissé à sa valeur par défaut se sérialise comme INCONNU. */ struct ContexteFil { //! Cause de la requête (§5.1) : \c cycle | \c load-change | \c intent | \c config | \c forecast. QString trigger = QStringLiteral("cycle"); //! ThingId du compteur racine (\c meter.source) ; vide = inconnu → \c null. QString meterSource; //! Production PV instantanée (\c pv.currentW) ; absente = inconnue → \c null. Remplie dès //! qu'un Thing \c smartmeterproducer existe (onduleur ou compteur de production). Le champ //! \c SurplusContext::pv n'est pas lu : il n'est jamais rempli, et son 0 ne distingue pas //! « nuit » de « pas de producteur ». std::optional pvCurrentW; //! Limite par phase en A (\c site.phaseLimitA) ; vide = celle du contexte, sinon \c null. QList phaseLimitA; //! Réserve de stockage en % (\c battery.reserveSocPercent) ; absente = celle du contexte si //! elle est renseignée (> 0), sinon \c null. std::optional reserveSocPercent; //! Domaine déclaré par l'installateur, par charge (\c loads[].domain) ; absent = non émis. QHash domaines; //! Mesure par charge (\c loads[].telemetry.measurement) ; absente = non émise. QHash mesures; /*! * \brief L'instantané de prévision ASSEMBLÉ par \c EnergyArbitrator::instantanePrevision() * — la charge utile de \c GetForecastSnapshot, un seul assemblage pour la RPC et le fil. * \c std::nullopt → \c forecast: null. */ std::optional prevision; }; /*! * \brief Les \c params d'une requête \c GetPlan. * \param ctx Contexte du cycle (le même que celui que reçoit le rule-based). * \param fil Ce que la requête porte en plus (voir \c ContexteFil). * \return Un objet JSON prêt à sérialiser ; clés et sens de OPTIMIZER_PROTOCOL.md §5. * \note \c meter.exportW est la mesure BRUTE du compteur (AGENTS règle 8). */ QVariantMap getPlanParams(const SurplusContext &ctx, const ContexteFil &fil); /*! * \brief Le bloc \c forecast (§5.2) depuis l'instantané de prévision. * \param instantane Sortie de \c EnergyArbitrator::instantanePrevision(). * \return \c start, \c resolutionS, \c steps, \c pv (\c null sans courbe), \c baseload, * \c weather (\c null : aucune source), \c price. * \note La courbe interne est en Wh PAR PAS ; le fil porte une puissance moyenne : * \c p50W = Wh × 3600 / \c resolutionS. Une case sans point reste \c null. */ QVariantMap forecastDepuisInstantane(const QVariantMap &instantane); }