feat(prévision PV): lire forecastCurveTransport v1 ET v2, plafond AC de site (brief 3, T1)

Prérequis du déploiement d'openmeteo v2 (§28) :
- `depuisTransport()` lit v1 et v2. v2 exige `corrections` (liste) ; une
  correction inconnue refuse le plan (`CORRECTION_<nom>`), `ac-limit` compris,
  qui appartient au site. Autre version : `VERSION_<v>`, jamais devinée.
  `gti[]`/`tempC[]` ne sont pas lus ici (enregistrement : T2).
- `acLimitW`, paramètre de SITE porté par `Get/SetForecastSite` : optionnel,
  absent = aucun plafond (jamais 0), borné [100, 1 000 000] W, refusé sans
  site ; la désignation se remplace en entier. Persisté (`forecastAcLimitW`).
- `ForecastStore::sommerPlans()` : somme des plans du même run ET du même pas,
  puis écrêtage à `acLimitW × pas / 3600` APRÈS la somme. Fonction unique pour
  la table et le snapshot.
- `GetForecastSnapshot` publie `corrections` (intersection si les plans
  diffèrent, journalisé au changement ; `ac-limit` si plafond configuré),
  `conversion: "physics"` et `acLimitW`.

Aucune décision ne lit la prévision (§11) ; boucle de sécurité ni appelée ni
modifiée ; pas de `*Changed`.

Tests : loadmodel `testTransportV2IsReadAndAnUnknownCorrectionRefused`,
`testTheSiteCurveIsClippedAfterTheSum` (contre-épreuve faite : plafond
neutralisé → échec) ; simulation `testTheSiteAcLimitTravelsWithTheDesignation`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-12 12:59:27 +02:00
parent a23161b3b6
commit 6b66d49f27
10 changed files with 445 additions and 55 deletions

View File

@ -68,6 +68,10 @@ EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm,
EnergySettings reglages;
m_forecastSiteId = reglages.value(QStringLiteral("forecastSiteId")).toString();
m_forecastSiteChoisi = reglages.contains(QStringLiteral("forecastSiteChosen"));
bool ok = false;
const int plafond = reglages.value(QStringLiteral("forecastAcLimitW")).toInt(&ok);
if (ok && plafond >= AcLimitMinW && plafond <= AcLimitMaxW)
m_forecastAcLimitW = plafond;
}
#ifndef ENERGY_SIMULATION
@ -2312,20 +2316,35 @@ void EnergyArbitrator::signalerSiteManquant()
.arg(QLatin1String(DecisionCode::MissingForecastSite));
}
bool EnergyArbitrator::setForecastSiteId(const QString &siteId)
bool EnergyArbitrator::setForecastSiteId(const QString &siteId, std::optional<int> acLimitW)
{
if (!siteId.isEmpty() && (!m_tm || !m_tm->findConfiguredThing(ThingId(siteId))))
return false;
// Un plafond est une propriété d'un SITE : sans site, il n'aurait pas d'objet. Et ZÉRO n'est
// pas « pas de plafond » — c'est une prévision nulle imposée. L'absence se dit en omettant.
if (acLimitW && (siteId.isEmpty() || *acLimitW < AcLimitMinW || *acLimitW > AcLimitMaxW)) {
qCWarning(dcNymeaEnergy()) << "[Prévision] plafond AC refusé :" << *acLimitW << "W"
<< (siteId.isEmpty() ? "(aucun site désigné)" : "(hors bornes)");
return false;
}
m_forecastSiteId = siteId;
m_forecastSiteChoisi = true;
m_forecastAcLimitW = acLimitW;
EnergySettings reglages;
reglages.setValue(QStringLiteral("forecastSiteId"), siteId);
if (acLimitW)
reglages.setValue(QStringLiteral("forecastAcLimitW"), *acLimitW);
else
reglages.remove(QStringLiteral("forecastAcLimitW"));
// LE CHOIX EST MARQUÉ MÊME QUAND IL EST « AUCUN ». Sans cela, la désignation d'office se
// réarmerait au prochain démarrage et écraserait une décision explicite, en silence.
reglages.setValue(QStringLiteral("forecastSiteChosen"), true);
m_forecastSiteSignale = false; // un nouveau vide se resignalera une fois
qCInfo(dcNymeaEnergy()) << "[Prévision] site retenu :"
<< (siteId.isEmpty() ? QStringLiteral("(aucun)") : siteId);
<< (siteId.isEmpty() ? QStringLiteral("(aucun)") : siteId)
<< "— plafond AC :"
<< (acLimitW ? QStringLiteral("%1 W").arg(*acLimitW)
: QStringLiteral("aucun"));
return true;
}
@ -2375,25 +2394,12 @@ ForecastRevision EnergyArbitrator::forecastSnapshot(QString &fault, QVariantMap
if (plans.isEmpty())
return vide; // le canal se tait : absence, sans motif ni courbe
const QDateTime run = plans.first().issuedAt;
for (const ForecastRevision &r : plans)
if (r.issuedAt != run)
return vide; // plans de runs différents : on ne somme pas
ForecastRevision site;
site.siteId = m_forecastSiteId;
site.issuedAt = run;
site.model = plans.first().model;
QMap<qint64, double> somme;
for (const ForecastRevision &r : plans)
for (const ForecastPoint &p : r.points)
somme[p.targetAt.toSecsSinceEpoch()] += p.wh;
for (auto it = somme.constBegin(); it != somme.constEnd(); ++it) {
ForecastPoint p;
p.targetAt = QDateTime::fromSecsSinceEpoch(it.key(), QTimeZone::utc());
p.wh = it.value();
site.points.append(p);
}
// Somme, puis plafond AC du site — la même fonction que l'enregistrement, pour que la
// table et le snapshot ne puissent pas diverger. Runs ou grilles différents : rien.
ForecastRevision site = ForecastStore::sommerPlans(plans, m_forecastAcLimitW);
if (site.points.isEmpty())
return vide;
site.siteId = m_forecastSiteId;
return site;
}
@ -2448,32 +2454,31 @@ void EnergyArbitrator::lireCourbePrevision()
// donneraient une chimère : une partie du toit à jour, l'autre périmée, présentées comme le
// tout. Le cas se referme seul au cycle suivant — le producteur rafraîchit tous ses plans en
// une passe — donc on ATTEND plutôt que d'écrire un mélange.
const QDateTime run = plans.first().issuedAt;
for (const ForecastRevision &r : plans) {
if (r.issuedAt == run)
continue;
qCDebug(dcNymeaEnergy()) << "[Prévision] plans de runs différents — on attend la"
" prochaine passe plutôt que de sommer un mélange";
return;
}
// Un refus sur UN plan interdit la somme : additionner ce qui reste publierait une partie du
// toit sous le nom du tout, et l'écart passerait pour une contre-performance.
if (!refusDuCycle.isEmpty())
return;
ForecastRevision site;
site.siteId = m_forecastSiteId;
site.issuedAt = run;
site.model = plans.first().model;
QMap<qint64, double> somme; // trié par instant, et c'est ce qu'on veut publier
for (const ForecastRevision &r : plans)
for (const ForecastPoint &p : r.points)
somme[p.targetAt.toSecsSinceEpoch()] += p.wh;
for (auto it = somme.constBegin(); it != somme.constEnd(); ++it) {
ForecastPoint p;
p.targetAt = QDateTime::fromSecsSinceEpoch(it.key(), QTimeZone::utc());
p.wh = it.value();
site.points.append(p);
// Somme des plans du MÊME RUN et de la MÊME GRILLE, puis plafond AC du site (§28 décision 2).
bool melangees = false;
ForecastRevision site = ForecastStore::sommerPlans(plans, m_forecastAcLimitW, &melangees);
if (site.points.isEmpty()) {
qCDebug(dcNymeaEnergy()) << "[Prévision] plans de runs ou de pas différents — on attend la"
" prochaine passe plutôt que de sommer un mélange";
return;
}
site.siteId = m_forecastSiteId;
// Corrections DIVERGENTES entre plans (un plan v1, l'autre v2 ; ou un plan sans température) :
// la courbe annonce leur intersection. Dit au CHANGEMENT, pas à chaque cycle.
if (melangees != m_forecastCorrectionsMelangees) {
m_forecastCorrectionsMelangees = melangees;
if (melangees)
qCInfo(dcNymeaEnergy()) << "[Prévision] les plans ne déclarent pas les mêmes corrections"
" — la courbe du site annonce leur intersection :"
<< site.corrections;
else
qCInfo(dcNymeaEnergy()) << "[Prévision] corrections de nouveau identiques sur tous les plans :"
<< site.corrections;
}
if (plans.count() > 1)
qCDebug(dcNymeaEnergy()) << "[Prévision]" << plans.count() << "plans sommés —"

View File

@ -89,11 +89,26 @@ public:
QString forecastSiteId() const { return m_forecastSiteId; }
/*!
* \brief Désigne le site dont la prévision est retenue. Persistée.
* \param siteId ThingId du site ; vide pour ne plus rien retenir.
* \return Faux si l'identifiant n'est pas celui d'un Thing existant.
* \brief Plafond AC (onduleur) du site désigné, en W. \c std::nullopt = aucun plafond.
* \note Un paramètre de SITE, pas de plan (§28 décision 2) : l'écrêtage porte sur ce que
* l'onduleur reçoit de tous ses champs. Appliqué après la somme, jamais 0.
*/
bool setForecastSiteId(const QString &siteId);
std::optional<int> forecastAcLimitW() const { return m_forecastAcLimitW; }
//! Bornes acceptées pour \c acLimitW, en W. Sous 100 W : très probablement des kW saisis
//! en W ; au-delà de 1 MW : hors du périmètre d'un site résidentiel ou tertiaire.
static constexpr int AcLimitMinW = 100;
static constexpr int AcLimitMaxW = 1000000;
/*!
* \brief Désigne le site dont la prévision est retenue, et son plafond AC. Persistée.
* \param siteId ThingId du site ; vide pour ne plus rien retenir.
* \param acLimitW Plafond AC du site en W ; \c std::nullopt = aucun plafond. **La
* désignation est remplacée en ENTIER** : omettre le plafond l'efface.
* \return Faux si l'identifiant n'est pas celui d'un Thing existant, si le plafond est hors
* de [\c AcLimitMinW, \c AcLimitMaxW], ou s'il est donné sans site. Rien n'est alors modifié.
*/
bool setForecastSiteId(const QString &siteId, std::optional<int> acLimitW = std::nullopt);
/*!
* \brief Les sites CANDIDATS — ceux dont au moins un enfant publie une courbe.
@ -909,6 +924,10 @@ private:
QHash<QString, QString> m_forecastRefusVu;
//! Site désigné ; vide = aucune prévision retenue, et c'est un cas NORMAL, jamais un défaut.
QString m_forecastSiteId;
//! Plafond AC du site ; absent = aucun plafond (jamais 0).
std::optional<int> m_forecastAcLimitW;
//! Les corrections des plans divergeaient-elles au dernier cycle ? Pour n'annoncer qu'au changement.
bool m_forecastCorrectionsMelangees = false;
/*!
* \brief Vrai dès qu'un choix a été EXPRIMÉ, fût-il « aucun site ».
*

View File

@ -9,6 +9,8 @@
#include <QUuid>
#include <QCryptographicHash>
#include <QVariant>
#include <QMap>
#include <QTimeZone>
#include <QLoggingCategory>
#include <nymeasettings.h>
@ -26,9 +28,28 @@ constexpr int RetentionPleineJours = 7;
//! calcul sont égaux au bit près ; ce seuil couvre un ré-encodage JSON, pas une révision.
constexpr double EgaliteWh = 0.001;
//! Version de la charge utile de TRANSPORT qu'on sait lire. Une autre est REFUSÉE, jamais
//! Versions de la charge utile de TRANSPORT qu'on sait lire. Une autre est REFUSÉE, jamais
//! interprétée au mieux — cf. \c ForecastStore::depuisTransport().
constexpr int TransportVersion = 1;
//! v1 : `p = GTI × kWc × PR`. v2 (brief 2) : mêmes champs, plus `corrections` (liste),
//! `gti[]`, `tempC[]` — `p` corrigé en température quand `corrections` le dit.
constexpr int TransportVersionMin = 1;
constexpr int TransportVersionMax = 2;
//! Corrections qu'un PRODUCTEUR peut déclarer. `ac-limit` n'en fait pas partie : le plafond est
//! un paramètre de SITE, appliqué ici après la somme (§28 décision 2). Un producteur qui
//! l'annoncerait l'aurait appliqué par plan — ce qui est faux — et on l'appliquerait deux fois.
const QStringList CorrectionsProducteur = { QStringLiteral("temperature") };
//! Ordre canonique des corrections publiées : une liste se compare.
const QStringList OrdreCorrections = { QStringLiteral("temperature"), QStringLiteral("ac-limit") };
QStringList ordonner(const QStringList &c)
{
QStringList r;
for (const QString &x : OrdreCorrections)
if (c.contains(x))
r << x;
return r;
}
}
QString ForecastStore::cheminParDefaut()
@ -52,7 +73,9 @@ ForecastRevision ForecastStore::depuisTransport(const QVariantMap &charge, QStri
// traverse une frontière de processus n'a plus de schéma : personne ne vérifie sa forme, et
// une lecture « au mieux » d'une version inconnue produirait une courbe fausse qui aurait
// l'air de marcher.
if (charge.value(QStringLiteral("v")).toInt() != TransportVersion) {
bool vLu = false;
const int v = charge.value(QStringLiteral("v")).toInt(&vLu);
if (!vLu || v < TransportVersionMin || v > TransportVersionMax) {
refus = QStringLiteral("VERSION_%1").arg(charge.value(QStringLiteral("v")).toString());
return r;
}
@ -69,6 +92,33 @@ ForecastRevision ForecastStore::depuisTransport(const QVariantMap &charge, QStri
refus = QStringLiteral("MALFORMED");
return r;
}
// v2 : `corrections` est une LISTE, obligatoire. v1 n'en avait pas (chaîne "none") : sa
// conversion était brute par contrat, la liste est donc vide sans rien supposer.
//
// Une correction INCONNUE refuse la charge utile au lieu d'être ignorée : `p` porte alors un
// effet qu'on ne sait pas nommer, et l'annoncer sous une liste plus courte serait mentir sur
// la courbe. Même règle que la version : refuser, jamais interpréter au mieux.
if (v >= 2) {
const QVariant brut = charge.value(QStringLiteral("corrections"));
if (brut.userType() != QMetaType::QVariantList && brut.userType() != QMetaType::QStringList) {
refus = QStringLiteral("MALFORMED");
return r;
}
for (const QVariant &c : brut.toList()) {
const QString nom = c.toString();
if (!CorrectionsProducteur.contains(nom)) {
refus = QStringLiteral("CORRECTION_%1").arg(nom);
return r;
}
if (!r.corrections.contains(nom))
r.corrections << nom;
}
r.corrections = ordonner(r.corrections);
}
// `gti[]` et `tempC[]` (v2) ne sont PAS lus ici : la courbe n'en a pas besoin, et un défaut
// de leur forme ne doit pas faire perdre une prévision utilisable. Leur lecteur viendra avec
// l'enregistrement (brief 3, T2).
r.stepS = step;
// `issuedAt == 0` SIGNIFIE « ÂGE INCONNU » — et ce n'est PAS un refus.
//
// Il l'a été, tant que ce champ servait de clé. Depuis qu'il est mesuré qu'il n'identifie
@ -101,6 +151,58 @@ ForecastRevision ForecastStore::depuisTransport(const QVariantMap &charge, QStri
return r;
}
ForecastRevision ForecastStore::sommerPlans(const QList<ForecastRevision> &plans,
std::optional<int> acLimitW,
bool *correctionsMelangees)
{
ForecastRevision site;
if (correctionsMelangees)
*correctionsMelangees = false;
if (plans.isEmpty())
return site;
const QDateTime run = plans.first().issuedAt;
const int step = plans.first().stepS;
QStringList communes = plans.first().corrections;
bool melangees = false;
for (const ForecastRevision &r : plans) {
if (r.issuedAt != run || r.stepS != step)
return site; // une chimère ou deux grilles : rien plutôt qu'un mélange
if (r.corrections != plans.first().corrections)
melangees = true;
QStringList garde;
for (const QString &c : communes)
if (r.corrections.contains(c))
garde << c;
communes = garde;
}
site.issuedAt = run;
site.model = plans.first().model;
site.stepS = step;
QMap<qint64, double> somme; // trié par instant, et c'est ce qu'on veut publier
for (const ForecastRevision &r : plans)
for (const ForecastPoint &p : r.points)
somme[p.targetAt.toSecsSinceEpoch()] += p.wh;
// APRÈS la somme : l'onduleur plafonne ce qu'il reçoit de tous ses champs, pas chacun d'eux.
const std::optional<double> plafondWh =
(acLimitW && step > 0) ? std::optional<double>(double(*acLimitW) * step / 3600.0)
: std::nullopt;
for (auto it = somme.constBegin(); it != somme.constEnd(); ++it) {
ForecastPoint p;
p.targetAt = QDateTime::fromSecsSinceEpoch(it.key(), QTimeZone::utc());
p.wh = plafondWh ? qMin(it.value(), *plafondWh) : it.value();
site.points.append(p);
}
if (acLimitW)
communes << QStringLiteral("ac-limit");
site.corrections = ordonner(communes);
if (correctionsMelangees)
*correctionsMelangees = melangees;
return site;
}
ForecastStore::ForecastStore(const QString &cheminFichier, QObject *parent)
: QObject(parent)
{

View File

@ -8,6 +8,9 @@
#include <QSqlDatabase>
#include <QString>
#include <QVariantMap>
#include <QStringList>
#include <optional>
/*!
* \file forecaststore.h
@ -127,6 +130,16 @@ struct ForecastRevision {
* NULL. Le combler après coup y rangerait une supposition sous un nom de provenance.
*/
QString model;
/*!
* \brief Corrections PHYSIQUES appliquées à la courbe, ordre canonique
* (\c temperature, puis \c ac-limit). Vide = conversion brute `GTI × kWc × PR`.
*
* Sur un PLAN : lu dans la charge utile (v2), vide en v1 — la v1 n'a jamais corrigé.
* Sur le SITE : posé par \c sommerPlans(), \c ac-limit compris si un plafond est configuré.
*/
QStringList corrections;
//! Pas de la grille en secondes, LU dans la charge utile (\c step). 0 = inconnu.
int stepS = 0;
};
/*!
@ -169,6 +182,12 @@ public:
* lecture partielle qui aurait l'air de marcher. Sans cela, le symptôme d'un changement de
* forme serait « courbe vide », **indistinguable d'une nuit**.
*
* \par Versions lues : 1 et 2
* v2 ajoute \c corrections (liste, obligatoire), \c gti[] et \c tempC[]. Motifs de refus :
* \c VERSION_x (autre version), \c UNIT_x, \c MALFORMED (dont \c corrections absente ou
* non-liste en v2), \c CORRECTION_x (correction inconnue — \c ac-limit compris : il
* appartient au site, pas au plan), \c NO_POINT, \c EMPTY.
*
* \par L'indexation est LUE, pas supposée
* \c convention voyage dans la charge utile : \c p[i] est la moyenne sur
* `[t0 + i·step, t0 + (i+1)·step)`. On n'a donc ni à la redécouvrir, ni à faire confiance
@ -176,6 +195,36 @@ public:
*/
static ForecastRevision depuisTransport(const QVariantMap &charge, QString &refus);
/*!
* \brief La courbe du SITE : somme des plans, puis plafond AC (\c DESIGN_PREVISION_PV.md
* §17, §28 décision 2).
* \param plans Révisions des plans du site, déjà lues par \c depuisTransport().
* \param acLimitW Plafond onduleur du site, en W ; \c std::nullopt = aucun plafond.
* \param[out] correctionsMelangees Si non nul : vrai quand les plans ne déclarent pas les
* mêmes corrections (la courbe porte alors leur INTERSECTION).
* \return La révision du site (\c siteId vide, à poser par l'appelant) ; **sans point** si la
* somme n'a pas lieu.
*
* \par On ne somme que des plans du MÊME RUN et de la MÊME GRILLE
* Runs différents : une chimère, on attend la passe suivante (§17.2). Pas différents : la
* somme par instant mêlerait des énergies de périodes différentes, et le plafond
* (W × pas) n'aurait plus de période. Même réponse : rien, plutôt qu'un mélange.
*
* \par Le plafond s'applique APRÈS la somme
* L'écrêtage onduleur porte sur ce que l'onduleur reçoit de tous ses champs : un créneau
* vaut au plus `acLimitW × pas / 3600` Wh. Un créneau ABSENT reste absent — le plafond ne
* fabrique pas de point.
*
* \par Les corrections déclarées sont celles que TOUS les plans ont reçues
* Si les plans diffèrent, la courbe annonce la plus faible commune (intersection) : dire
* « corrigée en température » d'une somme dont un plan ne l'est pas serait faux. Puis
* \c ac-limit si un plafond est configuré — qu'il morde ou non sur ce run, la courbe est
* bornée par lui.
*/
static ForecastRevision sommerPlans(const QList<ForecastRevision> &plans,
std::optional<int> acLimitW,
bool *correctionsMelangees = nullptr);
//! \brief La base est-elle utilisable ? \return Faux si l'ouverture ou la migration a échoué.
bool isValid() const { return m_valide; }

View File

@ -297,7 +297,8 @@ constexpr const char *MissingForecastSite = "MISSING_FORECAST_SITE";
/*!
* \brief Le canal de prévision publie, mais sa charge utile est ILLISIBLE.
* params : \c cause — le motif de refus (\c VERSION_x, \c UNIT_x, \c MALFORMED, \c EMPTY).
* params : \c cause — le motif de refus (\c VERSION_x, \c UNIT_x, \c MALFORMED,
* \c CORRECTION_x, \c NO_POINT, \c EMPTY ; liste faisant foi : \c ForecastStore::depuisTransport()).
*
* \note Distinct de \c MISSING_FORECAST_SITE : là, personne n'a désigné ; ici, quelqu'un publie
* quelque chose qu'on ne sait pas lire. **Deux causes, deux gestes** — configurer d'un côté,

View File

@ -365,6 +365,7 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
"missing designation to an installation that has nothing to designate would "
"be an unsatisfiable warning.";
returns.insert("o:forecastSiteThingId", enumValueName(Uuid));
returns.insert("o:acLimitW", enumValueName(Uint)); // plafond AC du site ; omis = aucun
returns.insert("candidateSiteThingIds", QVariantList() << enumValueName(Uuid));
// Le MOTIF, présent seulement quand il y a un geste à faire : des plans existent et aucun
// site n'est désigné. Un CODE, jamais une phrase — le client la fabrique dans sa langue.
@ -390,7 +391,13 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
"a missing forecast and a forecast of zero are different statements. 'fault' "
"carries MISSING_FORECAST_SITE (nobody designated a site, and one exists) or "
"FORECAST_PAYLOAD_REFUSED with its cause (the channel publishes something we "
"cannot read). Two faults, two gestures: configure, or look at the provider.";
"cannot read). Two faults, two gestures: configure, or look at the provider. "
"With a curve come 'corrections' (physical corrections applied, canonical "
"order: 'temperature' by the provider, 'ac-limit' when the site has an AC "
"limit; empty = raw GTI x kWp x PR; when planes disagree, only the corrections "
"ALL of them received), 'conversion' ('physics' - this engine has no other) and "
"'acLimitW' when an AC limit is configured: the curve is the sum of the planes "
"CLIPPED to it.";
QVariantMap snapPoint;
snapPoint.insert("targetAt", enumValueName(String));
snapPoint.insert("forecastWh", enumValueName(Double));
@ -399,6 +406,9 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
returns.insert("o:issuedAt", enumValueName(String));
returns.insert("o:model", enumValueName(String));
returns.insert("o:forecastPoints", QVariantList() << snapPoint);
returns.insert("o:corrections", QVariantList() << enumValueName(String));
returns.insert("o:conversion", enumValueName(String)); // physics | fallback
returns.insert("o:acLimitW", enumValueName(Uint));
returns.insert("o:fault", enumValueName(String));
returns.insert("o:faultParams", enumValueName(Variant));
registerMethod("GetForecastSnapshot", description, params, returns, Types::PermissionScopeControlThings);
@ -411,8 +421,13 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
"measurement, which reads as an under-performing installation. Planes from "
"DIFFERENT runs are never summed: a partly refreshed roof published as the "
"whole would be a chimera, so the engine waits for the next pass. Pass an "
"empty id to keep no forecast at all.";
"empty id to keep no forecast at all. 'acLimitW' is the site's inverter AC "
"limit in W, applied AFTER the sum of the planes; omitted = no limit. The "
"designation is REPLACED as a whole: omitting 'acLimitW' clears it. Refused "
"(EnergyErrorInvalidParameter, nothing changed) outside [100, 1000000] W or "
"without a site.";
params.insert("o:forecastSiteThingId", enumValueName(Uuid));
params.insert("o:acLimitW", enumValueName(Uint));
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
registerMethod("SetForecastSite", description, params, returns, Types::PermissionScopeControlThings);
@ -1194,6 +1209,9 @@ JsonReply *NymeaEnergyJsonHandler::GetForecastSite(const QVariantMap &params)
// se lirait comme un identifiant qu'on n'a pas su rendre.
if (!id.isEmpty())
ret.insert(QStringLiteral("forecastSiteThingId"), id);
// OMIS sans plafond, jamais 0 : un zéro se lirait « production plafonnée à rien ».
if (const std::optional<int> plafond = arb->forecastAcLimitW())
ret.insert(QStringLiteral("acLimitW"), *plafond);
// LES CANDIDATS, toujours rendus — même vides. C'est ce qui permet à un écran de poser
// la question quand il y a un choix, sans un second appel ni une heuristique côté client.
const QStringList candidats = arb->forecastSiteCandidates();
@ -1260,6 +1278,14 @@ JsonReply *NymeaEnergyJsonHandler::GetForecastSnapshot(const QVariantMap &params
pts.append(QVariantMap{{"targetAt", p.targetAt.toUTC().toString(Qt::ISODate)},
{"forecastWh", p.wh}});
ret.insert(QStringLiteral("forecastPoints"), pts);
// Ce qui a été fait à la courbe, publié AVEC elle (OPTIMIZER_PROTOCOL §5.2) : une liste vide
// est une affirmation — conversion brute — pas une absence.
ret.insert(QStringLiteral("corrections"), QVariant(rev.corrections).toList());
// `physics` : le moteur GPL n'a pas d'autre conversion. Ni `hybrid` (correction apprise, hors
// GPL, §5), ni `fallback` (aucun émetteur aujourd'hui).
ret.insert(QStringLiteral("conversion"), QStringLiteral("physics"));
if (const std::optional<int> plafond = arb->forecastAcLimitW())
ret.insert(QStringLiteral("acLimitW"), *plafond);
#endif
return createReply(ret);
}
@ -1270,7 +1296,11 @@ JsonReply *NymeaEnergyJsonHandler::SetForecastSite(const QVariantMap &params)
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_smartChargingManager);
if (!arb)
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
const bool ok = arb->setForecastSiteId(params.value("forecastSiteThingId").toString());
// `acLimitW` ABSENT = aucun plafond ; jamais un 0 implicite.
std::optional<int> plafond;
if (params.contains(QStringLiteral("acLimitW")))
plafond = params.value(QStringLiteral("acLimitW")).toInt();
const bool ok = arb->setForecastSiteId(params.value("forecastSiteThingId").toString(), plafond);
// L'énumération de l'amont n'a pas de ThingNotFound — on ne l'INVENTE pas : un client qui
// recevrait une valeur hors catalogue ne saurait pas la rendre, et l'ajouter ici la ferait
// diverger du contrat publié par nymea-energy.

View File

@ -1091,9 +1091,10 @@ void TestLoadModel::testAPayloadIsRefusedOnFormNeverOnAMissingAttribute()
QCOMPARE(ok.points.at(1).targetAt.toSecsSinceEpoch(), t0 + 3600);
// 2. LA FORME se refuse, et le motif la NOMME.
QVERIFY(ForecastStore::depuisTransport(charge(2, "W", t0, QVariantList{0, 250}), refus)
// (v2 est LUE depuis le brief 3 T1 : la version inconnue est désormais la 3.)
QVERIFY(ForecastStore::depuisTransport(charge(3, "W", t0, QVariantList{0, 250}), refus)
.points.isEmpty());
QCOMPARE(refus, QString("VERSION_2"));
QCOMPARE(refus, QString("VERSION_3"));
QVERIFY(ForecastStore::depuisTransport(charge(1, "Wh", t0, QVariantList{0, 250}), refus)
.points.isEmpty());
QCOMPARE(refus, QString("UNIT_Wh")); // lire des Wh comme des W : plausible et faux
@ -1118,6 +1119,125 @@ void TestLoadModel::testAPayloadIsRefusedOnFormNeverOnAMissingAttribute()
QCOMPARE(troue.points.count(), 2);
}
void TestLoadModel::testTransportV2IsReadAndAnUnknownCorrectionRefused()
{
// Brief 3 T1 — `ems` lit v1 ET v2 (prérequis du déploiement d'openmeteo v2). v2 garde tous
// les champs v1 et ajoute `corrections` (liste), `gti[]`, `tempC[]`.
const qint64 t0 = QDateTime(QDate(2026, 10, 25), QTime(0, 0), QTimeZone::utc()).toSecsSinceEpoch();
const QVariantList p{0, 412.5, 1768.25};
auto v1 = [&]() {
return QVariantMap{{"v", 1}, {"t0", t0}, {"step", 3600}, {"unit", "W"},
{"convention", "mean over [t, t+step)"}, {"regime", "x"},
{"model", "meteofrance_seamless"}, {"issuedAt", t0}, {"p", p},
{"corrections", "none"}};
};
auto v2 = [&](const QVariant &corrections) {
QVariantMap m = v1();
m["v"] = 2;
if (corrections.isValid()) m["corrections"] = corrections; else m.remove("corrections");
m["gti"] = QVariantList{0, 500, 1000};
m["tempC"] = QVariantList{5.0, 12.5, QVariant()}; // un null : T2 le lira, pas T1
return m;
};
QString refus;
// 1. v1 : lue comme avant, corrections VIDES — la v1 n'a jamais corrigé, par contrat.
const ForecastRevision a = ForecastStore::depuisTransport(v1(), refus);
QVERIFY2(refus.isEmpty(), qUtf8Printable(refus));
QVERIFY(a.corrections.isEmpty());
QCOMPARE(a.stepS, 3600);
// 2. v2 sans correction : MÊME courbe que v1, au bit près (le brief 2 l'exige du producteur ;
// ici, que le lecteur ne la déforme pas).
const ForecastRevision b = ForecastStore::depuisTransport(v2(QVariantList()), refus);
QVERIFY2(refus.isEmpty(), qUtf8Printable(refus));
QVERIFY(b.corrections.isEmpty());
QCOMPARE(b.points.count(), a.points.count());
for (int i = 0; i < a.points.count(); ++i) {
QCOMPARE(b.points.at(i).targetAt, a.points.at(i).targetAt);
QVERIFY(b.points.at(i).wh == a.points.at(i).wh);
}
// 3. v2 corrigée en température : lue, et la correction est DITE.
const ForecastRevision c = ForecastStore::depuisTransport(v2(QVariantList{"temperature"}), refus);
QVERIFY2(refus.isEmpty(), qUtf8Printable(refus));
QCOMPARE(c.corrections, QStringList{"temperature"});
// 4. Les REFUS, chacun nommé — jamais une lecture au mieux.
QVERIFY(ForecastStore::depuisTransport(v2(QVariant()), refus).points.isEmpty());
QCOMPARE(refus, QString("MALFORMED")); // corrections absente en v2
QVERIFY(ForecastStore::depuisTransport(v2(QString("none")), refus).points.isEmpty());
QCOMPARE(refus, QString("MALFORMED")); // la chaîne v1 n'est plus une forme v2
QVERIFY(ForecastStore::depuisTransport(v2(QVariantList{"temperature", "soiling"}), refus)
.points.isEmpty());
QCOMPARE(refus, QString("CORRECTION_soiling")); // un effet qu'on ne sait pas nommer
QVERIFY(ForecastStore::depuisTransport(v2(QVariantList{"ac-limit"}), refus).points.isEmpty());
QCOMPARE(refus, QString("CORRECTION_ac-limit")); // le plafond est au SITE, pas au plan
QVariantMap sansV = v1(); sansV.remove("v");
QVERIFY(ForecastStore::depuisTransport(sansV, refus).points.isEmpty());
QCOMPARE(refus, QString("VERSION_")); // v absent : jamais deviné
}
void TestLoadModel::testTheSiteCurveIsClippedAfterTheSum()
{
// Brief 3 T1 — plafond AC de SITE, appliqué APRÈS la somme des plans (§28 décision 2).
const QDateTime t0(QDate(2026, 10, 12), QTime(10, 0), QTimeZone::utc());
auto plan = [&](const QList<double> &w, const QStringList &corr, int step = 3600,
const QDateTime &run = QDateTime(QDate(2026, 10, 12), QTime(6, 0), QTimeZone::utc())) {
ForecastRevision r;
r.issuedAt = run; r.stepS = step; r.corrections = corr; r.model = "m";
for (int i = 0; i < w.count(); ++i) {
if (w.at(i) < 0) continue; // < 0 : créneau ABSENT du plan
ForecastPoint p;
p.targetAt = t0.addSecs(qint64(i) * step);
p.wh = w.at(i) * step / 3600.0;
r.points << p;
}
return r;
};
const QStringList T{"temperature"};
const QList<ForecastRevision> deux{plan({1500, 800, -1}, T), plan({1200, 300, 900}, T)};
// 1. SANS plafond : la somme, rien d'autre, et pas d'`ac-limit` annoncé.
ForecastRevision s = ForecastStore::sommerPlans(deux, std::nullopt);
QCOMPARE(s.points.count(), 3);
QCOMPARE(s.points.at(0).wh, 2700.0);
QCOMPARE(s.corrections, T);
// 2. AVEC plafond 2000 W : 2700 → 2000 ; 1100 intact ; et le plafond porte sur la SOMME —
// aucun des deux plans ne dépasse 2000 seul, l'écrêtage par plan n'aurait rien retiré.
bool melangees = true;
s = ForecastStore::sommerPlans(deux, 2000, &melangees);
QCOMPARE(s.points.count(), 3);
QCOMPARE(s.points.at(0).wh, 2000.0);
QCOMPARE(s.points.at(1).wh, 1100.0);
QCOMPARE(s.points.at(2).wh, 900.0); // présent dans UN plan : la somme de ce qui existe
QCOMPARE(s.corrections, (QStringList{"temperature", "ac-limit"}));
QVERIFY(!melangees);
// 3. Le plafond suit le PAS lu : au quart d'heure, 2000 W font 500 Wh.
s = ForecastStore::sommerPlans({plan({3000}, {}, 900)}, 2000);
QCOMPARE(s.points.at(0).wh, 500.0);
QCOMPARE(s.corrections, QStringList{"ac-limit"});
// 4. Un créneau absent RESTE absent : le plafond ne fabrique pas de point (jamais 0).
s = ForecastStore::sommerPlans({plan({-1, 400}, {})}, 2000);
QCOMPARE(s.points.count(), 1);
QCOMPARE(s.points.at(0).targetAt, t0.addSecs(3600));
// 5. Corrections DIVERGENTES : la plus faible commune, et c'est signalé.
s = ForecastStore::sommerPlans({plan({100}, T), plan({100}, {})}, std::nullopt, &melangees);
QVERIFY(s.corrections.isEmpty());
QVERIFY(melangees);
s = ForecastStore::sommerPlans({plan({100}, T), plan({100}, {})}, 5000, &melangees);
QCOMPARE(s.corrections, QStringList{"ac-limit"});
// 6. Runs ou pas différents : RIEN, plutôt qu'un mélange.
QVERIFY(ForecastStore::sommerPlans({plan({100}, T), plan({100}, T, 3600, t0)}, 2000).points.isEmpty());
QVERIFY(ForecastStore::sommerPlans({plan({100}, T), plan({100}, T, 900)}, 2000).points.isEmpty());
QVERIFY(ForecastStore::sommerPlans({}, 2000).points.isEmpty());
}
void TestLoadModel::testTheCurrentCurveIsTheLastSeenNotTheLastNew()
{
// Cas mordu sur .75 le 2026-09-09 : un plan de test ajouté puis retiré fait RÉAPPARAÎTRE un

View File

@ -96,6 +96,8 @@ private slots:
void testTheCurrentCurveIsTheLastSeenNotTheLastNew();
//! La charge utile se REFUSE plutôt que de s'interpréter au mieux — mais pas pour un attribut.
void testAPayloadIsRefusedOnFormNeverOnAMissingAttribute();
void testTransportV2IsReadAndAnUnknownCorrectionRefused();
void testTheSiteCurveIsClippedAfterTheSum();
};

View File

@ -51,6 +51,7 @@ using namespace nymeaserver;
#endif
#include "../../../energyplugin/etm/types/decisionreason.h"
#include "../../../energyplugin/etm/spotmarketbuild.h"
#include "../../../energyplugin/energysettings.h"
#include "../../../energyplugin/spotmarket/spotmarketmanager.h"
#include <QNetworkAccessManager>
@ -7151,6 +7152,65 @@ void Simulation::testSpotMarketIsOutOfTheBuild()
.toMap().value("params").toMap().value("providers").toList().isEmpty());
}
void Simulation::testTheSiteAcLimitTravelsWithTheDesignation()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
// Brief 3 T1 — `acLimitW`, paramètre de SITE porté par la désignation existante. Optionnel :
// absent = aucun plafond, et JAMAIS un 0. Ce test exerce l'optionnel par le RPC (l'AGENTS :
// un paramètre optionnel qu'aucun test n'appelle est un paramètre qu'on peut perdre).
cleanupTestCase();
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
initTestCase();
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
QVERIFY(arb);
const QUuid site = addMeter(); // n'importe quel Thing configuré tient lieu de site
QVERIFY(!site.isNull());
auto set = [&](const QVariantMap &p) {
return injectAndWait("NymeaEnergy.SetForecastSite", p).toMap();
};
auto get = [&]() {
return injectAndWait("NymeaEnergy.GetForecastSite").toMap().value("params").toMap();
};
// 1. Désignation AVEC plafond : acceptée, relue, persistée.
QVariantMap r = set({{"forecastSiteThingId", site}, {"acLimitW", 3000}});
QCOMPARE(r.value("status").toString(), QStringLiteral("success"));
QCOMPARE(r.value("params").toMap().value("energyError").toString(), QStringLiteral("EnergyErrorNoError"));
QCOMPARE(get().value("acLimitW").toInt(), 3000);
QCOMPARE(arb->forecastAcLimitW().value_or(-1), 3000);
QCOMPARE(EnergySettings().value("forecastAcLimitW").toInt(), 3000);
// 2. REFUS sans rien modifier : zéro, hors bornes, plafond sans site.
for (const QVariantMap &p : {QVariantMap{{"forecastSiteThingId", site}, {"acLimitW", 0}},
QVariantMap{{"forecastSiteThingId", site}, {"acLimitW", 99}},
QVariantMap{{"forecastSiteThingId", site}, {"acLimitW", 1000001}},
QVariantMap{{"acLimitW", 3000}}}) {
r = set(p);
QCOMPARE(r.value("params").toMap().value("energyError").toString(),
QStringLiteral("EnergyErrorInvalidParameter"));
QCOMPARE(get().value("acLimitW").toInt(), 3000);
QCOMPARE(get().value("forecastSiteThingId").toUuid(), site);
}
// 3. Désignation SANS plafond : remplacée en entier, le plafond est EFFACÉ — et omis, pas 0.
r = set({{"forecastSiteThingId", site}});
QCOMPARE(r.value("params").toMap().value("energyError").toString(), QStringLiteral("EnergyErrorNoError"));
QVERIFY2(!get().contains("acLimitW"), "aucun plafond se dit par une ABSENCE, jamais par 0");
QVERIFY(!arb->forecastAcLimitW().has_value());
QVERIFY(!EnergySettings().contains("forecastAcLimitW"));
// 4. Pas de canal dans le harnais : le snapshot ne rend ni courbe, ni champs de courbe.
const QVariantMap snap = injectAndWait("NymeaEnergy.GetForecastSnapshot").toMap().value("params").toMap();
QVERIFY(!snap.contains("forecastPoints"));
QVERIFY(!snap.contains("corrections"));
QVERIFY(!snap.contains("conversion"));
QVERIFY(arb->setForecastSiteId(QString()));
#endif
}
void Simulation::testEveryGetterReturnsWhatItDeclares()
{
// LE TROU QUE LE 2026-09-09 A OUVERT.

View File

@ -211,6 +211,8 @@ private slots:
void testDerogationTakesTheHeadButNotTheSafety();
//! Brief 3 T0 — aucun objet réseau, aucune activation, un refus codé.
void testSpotMarketIsOutOfTheBuild();
//! Brief 3 T1 — acLimitW voyage avec la désignation de site, absent = aucun plafond.
void testTheSiteAcLimitTravelsWithTheDesignation();
//! TOUTE méthode Get* du namespace rend ce que sa déclaration promet.
void testEveryGetterReturnsWhatItDeclares();
//! Le snapshot sert la DÉCISION : le canal, jamais la table — et l'absence n'est pas zéro.