Patrick Schurig c81c7b6225 fix(optimiseur): pv.currentW publié dès qu'un smartmeterproducer existe, pas seulement un solarinverter
`solarinverter` étend `smartmeterproducer` dans libnymea. Un compteur de production posé sur un
onduleur non communicant n'est que `smartmeterproducer` — c'est le PV du banc (sdm120Producer) :
le filtre précédent y rendait `pv.currentW: null` alors que l'EnergyManager additionnait bien
sa production. Même critère que la présence d'un producteur côté apprentissage.

- OPTIMIZER_PROTOCOL.md §5.1 : `pv.currentW` documenté ; un seul producteur déclaré par source
  PV (nymea additionne les producteurs).
- EnergyManagerMock::setCurrentPowerProduction() (test) : la production du mock valait toujours 0.
- Test testPvIsReadFromAnyProducer : sans producteur → null ; smartmeterproducer seul → 2400.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 01:51:21 +02:00

92 lines
4.3 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 <QHash>
#include <QList>
#include <QString>
#include <QVariantMap>
#include <optional>
#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<double> pvCurrentW;
//! Limite par phase en A (\c site.phaseLimitA) ; vide = celle du contexte, sinon \c null.
QList<double> phaseLimitA;
//! Réserve de stockage en % (\c battery.reserveSocPercent) ; absente = celle du contexte si
//! elle est renseignée (> 0), sinon \c null.
std::optional<int> reserveSocPercent;
//! Domaine déclaré par l'installateur, par charge (\c loads[].domain) ; absent = non émis.
QHash<QString, QString> domaines;
//! Mesure par charge (\c loads[].telemetry.measurement) ; absente = non émise.
QHash<QString, MesureFil> 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<QVariantMap> 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);
}