etm-powersync-energy-plugin.../energyplugin/nymeaenergyjsonhandler.h
Patrick Schurig 9dcaee8be1 feat(prévision PV): la table bitemporelle, la déduplication sur l'intersection, la rétention
Ce qui ne dépend pas du canal, écrit maintenant. La lecture se branchera quand
la forme du canal openmeteo sera connue ; le magasin est vide d'ici là, et une
courbe vide EST la réponse honnête à « que prévoit-on ? » quand personne n'a
rien publié.

LA CLÉ EST LA PAIRE (targetAt, receivedAt). Avec targetAt seul, chaque
rafraîchissement écraserait le précédent : on perdrait les révisions, donc la
comparaison prévu/réalisé — ce que ce lot existe pour montrer. Le GPL n'apprend
pas, il rend l'apprentissage possible : cette table est la matière, et l'écraser
la détruirait pour toujours sans que rien ne le signale.

receivedAt, JAMAIS issuedAt, et l'interdiction est écrite et non seulement
nommée : aucun horizon ne se calcule depuis ce champ. Entre l'initialisation
d'un run et sa mise à disposition s'écoule la latence de publication du modèle —
des heures. targetAt - receivedAt est donc trop court, systématiquement et dans
le même sens. « Quelle prévision » est juste, « quel horizon » est faux.

LA DÉDUPLICATION PORTE SUR L'INTERSECTION, pas sur la charge utile. Si la
fenêtre glisse, la courbe de 11 h a un point de moins que celle de 10 h : une
égalité de charge utile verrait une révision toutes les heures, exactement ce
que la déduplication existe pour éviter. Et `communs > 0` est requis — sans
heure commune il n'y a rien à comparer, et conclure « identique » ferait
disparaître une révision dont on ne sait rien (cas du passage de minuit).

Ce choix ne dépend pas de la forme que le producteur publie aujourd'hui : il est
correct que la fenêtre glisse ou non, donc il ne peut pas être invalidé par un
changement chez le voisin dont personne ne nous préviendrait.

RÉTENTION : sept jours pleins, puis les DEUX BOUTS par heure visée — la première
révision (horizon le plus long) et la dernière (meilleure estimation). Les deux,
et pas une : elles répondent aux deux seules questions qui se posent, et n'en
garder qu'une détruirait la révision.

model reste NULL tant que le producteur publie une INTENTION et non une
provenance. Une chaîne vide serait déjà une valeur ; NULL dit « on ne sait pas »,
ce qui est vrai. Aucun remplissage rétroactif à prévoir.

GetPvForecast : un Get* SANS *Changed — amendement assumé à l'invariant 11, qui
vise l'ÉTAT DE L'ARBITRE, celui qui bouge à chaque cycle et laisse un client
déconnecté en retard. Une courbe historisée se lit sur demande, comme
GetPowerBalanceLogs ; notifier chaque révision produirait un message par heure
sur une donnée que personne n'attend en direct. `knownAt` rend la lecture
reproductible : « ce qu'on prévoyait ce matin » se demande en le fixant.

Base SÉPARÉE, jamais energylogs.sqlite : celle-là appartient à l'amont qui en
migre le schéma, et surtout la clé y est un instant de mesure quand la nôtre est
une paire. Une prévision révisée n'a rien à faire dans la base du mesuré.

Trois tests unitaires, et le premier vise le cas qu'un == naïf raterait : une
fenêtre glissée d'une heure, valeurs communes inchangées, charge utile
différente — ce n'est PAS une révision. Avec ses contre-épreuves : une valeur
commune modifiée EN EST une, et une fenêtre sans heure commune aussi.

loadmodel 29/29, charging 48/48, spotmarket 32/32, simulation 69/69,
ci-quality 4/4, build 0 erreur 0 warning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-09-08 17:23:35 +02:00

183 lines
9.1 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright (C) 2013 - 2024, nymea GmbH
* Copyright (C) 2024 - 2025, chargebyte austria GmbH
*
* This file is part of nymea-energy-plugin-nymea.
*
* nymea-energy-plugin-nymea.s free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* nymea-energy-plugin-nymea.s distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with nymea-energy-plugin-nymea. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#ifndef NYMEAENERGYJSONHANDLER_H
#define NYMEAENERGYJSONHANDLER_H
#include <QObject>
#include <jsonrpc/jsonhandler.h>
#include "types/scoreentry.h"
#include "etm/ratios/energyratioscalculator.h"
class SmartChargingManager;
class SpotMarketManager;
class LoadConfigStore;
class LoadConfig;
class EnergyManager;
class NymeaEnergyJsonHandler : public JsonHandler
{
Q_OBJECT
public:
explicit NymeaEnergyJsonHandler(SpotMarketManager *spotMarketManager, SmartChargingManager *smartChargingManager, LoadConfigStore *loadConfigStore = nullptr, EnergyManager *energyManager = nullptr, QObject *parent = nullptr);
QString name() const override;
Q_INVOKABLE JsonReply *GetPhasePowerLimit(const QVariantMap &params);
Q_INVOKABLE JsonReply *SetPhasePowerLimit(const QVariantMap &params);
Q_INVOKABLE JsonReply *GetAcquisitionTolerance(const QVariantMap &params);
Q_INVOKABLE JsonReply *SetAcquisitionTolerance(const QVariantMap &params);
Q_INVOKABLE JsonReply *GetBatteryLevelConsideration(const QVariantMap &params);
Q_INVOKABLE JsonReply *SetBatteryLevelConsideration(const QVariantMap &params);
Q_INVOKABLE JsonReply *GetChargingInfos(const QVariantMap &params);
Q_INVOKABLE JsonReply *SetChargingInfo(const QVariantMap &params, const JsonContext &context);
Q_INVOKABLE JsonReply *GetLockOnUnplug(const QVariantMap &params);
Q_INVOKABLE JsonReply *SetLockOnUnplug(const QVariantMap &params);
Q_INVOKABLE JsonReply *GetAvailableSpotMarketProviders(const QVariantMap &params);
Q_INVOKABLE JsonReply *GetSpotMarketConfiguration(const QVariantMap &params);
Q_INVOKABLE JsonReply *SetSpotMarketConfiguration(const QVariantMap &params);
Q_INVOKABLE JsonReply *GetSpotMarketScoreEntries(const QVariantMap &params);
Q_INVOKABLE JsonReply *GetChargingSchedules(const QVariantMap &params);
Q_INVOKABLE JsonReply *GetLoadConfig(const QVariantMap &params);
/*!
* \brief L'avancement d'une obligation est-il MESURABLE pour cette charge ?
* \param c Configuration de la charge. \param[out] cause Code opaque si non mesurable.
* \return Vrai si un cumul d'énergie existe pour juger l'avancement.
*
* \par Pourquoi ce signal vit dans GetLoadConfig et non dans la télémétrie
* \c measurement n'existe que pour les charges de \c loads[] ; or une charge qu'on DÉCLARE
* est précisément celle qui n'y est pas encore — pas arbitrée, ou créée à l'instant.
* L'écran serait muet au moment exact où il doit parler : celui de la saisie.
*
* \par Trois causes, trois gestes
* \c noMeter (réparable en une manipulation), \c meterWithoutEnergy (compteur à remplacer),
* \c noSessionEnergy (rien à faire, c'est le matériel). Sans la cause, l'avertissement ne
* peut dire que « ça ne marchera pas ».
*
* \note \c meterThingId ne permet PAS de le déduire : un compteur peut publier
* \c currentPower sans \c totalEnergyConsumed (mesuré sur l'ECS du banc).
*/
bool progressMesurable(const LoadConfig &c, QString &cause) const;
Q_INVOKABLE JsonReply *SetLoadConfig(const QVariantMap &params);
// [Phase 2] Ratios canoniques (autoconsommation / autonomie), % [0,100].
Q_INVOKABLE JsonReply *GetEnergyRatios(const QVariantMap &params);
/*!
* \brief [ETM] Lève le verrou de défaut d'une charge pilotée (ECS-410).
* \param params \c {"loadId": string}.
* \return \c energyError — \c EnergyErrorNoError si la charge existe et son défaut est
* levé, \c EnergyErrorInvalidParameter si l'identifiant est inconnu.
* \note Acte DÉLIBÉRÉ de l'opérateur : le défaut d'ECS-410 est collant par conception, et
* cet appel est le seul moyen de le lever hors reconstruction de l'adaptateur. Il est
* journalisé côté arbitre — c'est ce qui rend le diagnostic possible après coup.
*/
Q_INVOKABLE JsonReply *ClearLoadFault(const QVariantMap &params);
/*!
* \brief [ETM] Instantané de l'état runtime de l'arbitrage (allocation, budget, verrous,
* mécanismes, motifs).
* \param params Aucun.
* \return La MÊME charge utile que la notification \c LoadTelemetryChanged — pas un
* recalcul : l'objet rendu est celui qui a été publié.
* \note Répond à la règle « tout élément runtime a un \c Get* ET un \c *Changed ». Un
* client connecté après un changement doit pouvoir lire l'état, sans quoi la
* notification seule le laisse dans un silence ambigu (famille ECS-411).
*/
Q_INVOKABLE JsonReply *GetLoadTelemetry(const QVariantMap &params);
//! \brief Pose une dérogation (servie au maximum, financée au réseau, en tête de passe éco).
//! \param params \c loadId, \c durationS (obligatoire, borné), \c targetWh (optionnel).
//! \return \c energyError — \c InvalidParameter si la charge est inconnue ou la durée hors bornes.
//! \brief Lit la courbe de prévision PV stockée. \param params \c from, \c to, \c o:knownAt.
//! \return \c forecastPoints — les heures SANS prévision sont absentes, jamais à zéro.
//! \note Un \c Get* SANS \c *Changed : l'invariant 11 vise l'état vivant, pas l'historique.
Q_INVOKABLE JsonReply *GetPvForecast(const QVariantMap &params);
Q_INVOKABLE JsonReply *SetLoadDerogation(const QVariantMap &params);
//! \brief Lève une dérogation avant son terme et rend le mode mémorisé.
//! \param params \c loadId. \return \c energyError — \c InvalidParameter si aucune n'était en cours.
Q_INVOKABLE JsonReply *ClearLoadDerogation(const QVariantMap &params);
signals:
void PhasePowerLimitChanged(const QVariantMap &params);
void AcquisitionToleranceChanged(const QVariantMap &params);
void LockOnUnplugChanged(const QVariantMap &params);
void ChargingInfoAdded(const QVariantMap &params);
void ChargingInfoRemoved(const QVariantMap &params);
void ChargingInfoChanged(const QVariantMap &params);
void SpotMarketConfigurationChanged(const QVariantMap &params);
void SpotMarketScoreEntriesChanged(const QVariantMap &params);
void ChargingSchedulesChanged(const QVariantMap &params);
void BatteryLevelConsiderationChanged(const QVariantMap &params);
void LoadConfigChanged(const QVariantMap &params);
void EnergyRatiosChanged(const QVariantMap &params);
//! [ETM] État runtime d'arbitrage — sur changement significatif ET au battement périodique.
void LoadTelemetryChanged(const QVariantMap &params);
private slots:
void onPowerBalanceChanged();
private:
SpotMarketManager *m_spotMarketManager;
SmartChargingManager *m_smartChargingManager = nullptr;
LoadConfigStore *m_loadConfigStore = nullptr;
EnergyManager *m_energyManager = nullptr;
EnergyRatiosCalculator m_ratiosCalculator;
EnergyRatiosCalculator::Ratios m_lastRatios;
bool m_lastRatiosValid = false; // un calcul a-t-il déjà été émis ?
QVariantMap currentEnergyRatios();
/*!
* \brief Schéma de la charge utile de télémétrie — UNE déclaration, deux usages.
* \return Le map de schéma, posé à l'identique dans les \c returns de
* \c GetLoadTelemetry et dans les \c params de \c LoadTelemetryChanged.
* \note C'est la règle « snapshot ≡ notification » rendue impossible à enfreindre :
* deux déclarations divergeraient, et c'est l'appel de reprise à froid qui casserait —
* celui qu'on ne teste pas tous les jours.
*/
static QVariantMap loadTelemetrySchema();
//! \brief Charge utile des ratios — DÉCLARÉE UNE FOIS, servie au \c Get et au \c Changed.
//! Deux déclarations divergent, et c'est l'appel de reprise à froid qui casse (invariant 11).
//! \return Le schéma des champs, prêt pour \c registerMethod et \c registerNotification.
static QVariantMap energyRatiosSchema();
//! \return Charge utile courante ; carte MINIMALE (sans \c timestamp) si aucun cycle
//! d'arbitrage n'a encore eu lieu — cf. \c EnergyArbitrator::loadTelemetry().
QVariantMap currentLoadTelemetry() const;
void sendSpotMarketConfigurationChangedNotification();
};
#endif // NYMEAENERGYJSONHANDLER_H