feat(prévision PV): SetForecastSite — acLimitW absent = inchangé, null = effacer (D3)

Décision de Patrick du 2026-10-13 : une app plus ancienne qui ignore
`acLimitW` effaçait le plafond à chaque enregistrement du site.
`acLimitW` devient l'exception explicite au remplacement en entier :
absent = inchangé, null = effacer, entier = poser dans [100, 1 000 000] W
(bornes validées). Désigner aucun site retire aussi le plafond
(invariant « plafond ⇒ site »).

Vérifié dans le harnais (nymea 1.15.2) : la validation refuse `null` sur
un Uint (« Invalid UInt », status error). Le paramètre est donc déclaré
`Variant` — changement du schéma déclaré par rapport à T1 — et sa forme
est contrôlée dans le handler (null ou entier ; chaîne, fraction,
booléen refusés sans rien modifier).

Tests (testTheSiteAcLimitTravelsWithTheDesignation, réécrit) : absent
conserve (mémoire, RPC, disque), null efface (clé retirée, champ omis),
refus de forme et de bornes sans modification, 2500.0 accepté, retrait
du site. Contre-épreuve : handler revenu à « absent = efface » → échec à
l'étape « absent conserve ». Le test D2 passe désormais par null pour
effacer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-13 06:54:28 +02:00
parent 96148b9153
commit b14db632ca
4 changed files with 110 additions and 25 deletions

View File

@ -1396,3 +1396,10 @@ restent l'état de leur date, et c'est ici qu'on lit ce qui les corrige.*
données. `OPTIMIZER_PROTOCOL.md` §5.2 ajoute `corrections` aux entrées de `perField`. Le
journal garde son message. Le mock porte une classe `pvPlane` : pour la première fois, un
test fait passer une **courbe** par le RPC (trou n° 8 du rapport T1, refermé pour le snapshot).
3. **`acLimitW` dans `SetForecastSite` : absent = INCHANGÉ, `null` = EFFACER.** Le §29 remplaçait
la désignation en entier, si bien qu'une app plus ancienne, ignorant le champ, aurait effacé le
plafond à chaque enregistrement du site. `acLimitW` devient l'**exception explicite** au
remplacement en entier. Bornes `[100, 1 000 000]` W validées. La validation nymea refuse `null`
sur un `Uint` : le paramètre est déclaré `Variant` et sa forme contrôlée par le handler
(`null` ou entier, rien d'autre). Choix d'implémentation, signalé : désigner **aucun** site
retire aussi le plafond (invariant persisté « plafond ⇒ site »).

View File

@ -103,8 +103,9 @@ public:
/*!
* \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.
* \param acLimitW Plafond AC du site en W ; \c std::nullopt = aucun plafond. **Ici, la
* désignation est remplacée en ENTIER** : omettre le plafond l'efface. Le RPC
* \c SetForecastSite, lui, lit « absent = inchangé » : c'est le handler qui fusionne.
* \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é.
*/

View File

@ -39,6 +39,7 @@
#include <QLoggingCategory>
#include <QTimeZone>
#include <cmath>
Q_DECLARE_LOGGING_CATEGORY(dcNymeaEnergy)
NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketManager, SmartChargingManager *smartChargingManager, LoadConfigStore *loadConfigStore, EnergyManager *energyManager, QObject *parent):
@ -428,12 +429,18 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
"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. '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.";
"limit in W, applied AFTER the sum of the planes. It is the ONE field that is "
"not replaced: OMITTED = UNCHANGED (an older client that ignores it must not "
"clear it each time it saves the site), null = CLEAR the limit, an integer = "
"set it. Declared Variant because nymea refuses null on a Uint; anything but "
"null or an integer is refused. Refused (EnergyErrorInvalidParameter, nothing "
"changed) outside [100, 1000000] W or without a site. Designating no site "
"clears the limit too: a limit has no object without a site.";
params.insert("o:forecastSiteThingId", enumValueName(Uuid));
params.insert("o:acLimitW", enumValueName(Uint));
// Variant, PAS Uint : la validation nymea refuse `null` sur un Uint (« Invalid UInt »,
// vérifié dans le harnais, nymea 1.15.2). Or `null` porte ici un sens — effacer — distinct
// de l'absence — inchangé. Le type est donc contrôlé dans le handler.
params.insert("o:acLimitW", enumValueName(Variant));
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
registerMethod("SetForecastSite", description, params, returns, Types::PermissionScopeControlThings);
@ -1310,11 +1317,42 @@ JsonReply *NymeaEnergyJsonHandler::SetForecastSite(const QVariantMap &params)
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_smartChargingManager);
if (!arb)
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
// `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);
const QString site = params.value("forecastSiteThingId").toString();
// `acLimitW` est la SEULE exception au remplacement en entier (décision du 2026-10-13) :
// ABSENT = INCHANGÉ — une app plus ancienne, qui ignore le champ, effacerait sinon le
// plafond à chaque enregistrement du site ;
// null = EFFACER ;
// entier = POSER, dans les bornes de l'arbitre.
// Jamais un 0 implicite : « aucun plafond » est une absence, pas une valeur.
std::optional<int> plafond = arb->forecastAcLimitW();
if (params.contains(QStringLiteral("acLimitW"))) {
const QVariant brut = params.value(QStringLiteral("acLimitW"));
if (brut.isNull()) {
plafond.reset();
} else {
// Déclaré Variant : la forme n'a pas été contrôlée en amont. Un nombre ENTIER, rien
// d'autre — ni chaîne, ni fraction, ni booléen. Refus sans rien modifier.
const int type = brut.typeId();
const bool nombre = type == QMetaType::Int || type == QMetaType::UInt
|| type == QMetaType::LongLong || type == QMetaType::ULongLong
|| type == QMetaType::Double;
const double w = brut.toDouble();
if (!nombre || w != std::floor(w)
|| w < EnergyArbitrator::AcLimitMinW || w > EnergyArbitrator::AcLimitMaxW) {
qCWarning(dcNymeaEnergy()) << "[Prévision] plafond AC refusé :" << brut
<< "— attendu : null ou un entier dans ["
<< EnergyArbitrator::AcLimitMinW << ","
<< EnergyArbitrator::AcLimitMaxW << "] W";
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
}
plafond = static_cast<int>(w);
}
} else if (site.isEmpty()) {
// Plus de site, plus de plafond : sans site il n'a pas d'objet, et l'arbitre refuse un
// plafond sans site. L'invariant persisté « plafond ⇒ site » est ainsi gardé.
plafond.reset();
}
const bool ok = arb->setForecastSiteId(site, 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

@ -7158,9 +7158,11 @@ 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).
// Brief 3 T1 — `acLimitW`, paramètre de SITE porté par la désignation existante. Jamais un 0.
// Décision du 2026-10-13 (§30.3) : c'est la SEULE exception au remplacement en entier —
// ABSENT = INCHANGÉ, null = EFFACER. Une app plus ancienne, qui ignore le champ, effacerait
// sinon le plafond à chaque enregistrement du site. 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();
@ -7174,35 +7176,71 @@ void Simulation::testTheSiteAcLimitTravelsWithTheDesignation()
auto get = [&]() {
return injectAndWait("NymeaEnergy.GetForecastSite").toMap().value("params").toMap();
};
auto erreur = [](const QVariantMap &r) {
return r.value("params").toMap().value("energyError").toString();
};
// 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(erreur(r), 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.
// 2. REFUS sans rien modifier : zéro, hors bornes, fraction, chaîne, booléen, plafond sans
// site. Le type est déclaré Variant (pour laisser passer null) : la forme est contrôlée
// par le handler, et un refus de forme ne doit rien toucher non plus.
for (const QVariantMap &p : {QVariantMap{{"forecastSiteThingId", site}, {"acLimitW", 0}},
QVariantMap{{"forecastSiteThingId", site}, {"acLimitW", 99}},
QVariantMap{{"forecastSiteThingId", site}, {"acLimitW", 1000001}},
QVariantMap{{"forecastSiteThingId", site}, {"acLimitW", -3000}},
QVariantMap{{"forecastSiteThingId", site}, {"acLimitW", 2500.5}},
QVariantMap{{"forecastSiteThingId", site}, {"acLimitW", "3000"}},
QVariantMap{{"forecastSiteThingId", site}, {"acLimitW", true}},
QVariantMap{{"acLimitW", 3000}}}) {
r = set(p);
QCOMPARE(r.value("params").toMap().value("energyError").toString(),
QStringLiteral("EnergyErrorInvalidParameter"));
QCOMPARE(r.value("status").toString(), QStringLiteral("success"));
QCOMPARE(erreur(r), QStringLiteral("EnergyErrorInvalidParameter"));
QCOMPARE(get().value("acLimitW").toInt(), 3000);
QCOMPARE(get().value("forecastSiteThingId").toUuid(), site);
QCOMPARE(EnergySettings().value("forecastAcLimitW").toInt(), 3000);
}
// 3. Désignation SANS plafond : remplacée en entier, le plafond est EFFACÉ — et omis, pas 0.
// 3. Désignation SANS `acLimitW` : le plafond est CONSERVÉ — en mémoire, au RPC, sur disque.
r = set({{"forecastSiteThingId", site}});
QCOMPARE(r.value("params").toMap().value("energyError").toString(), QStringLiteral("EnergyErrorNoError"));
QCOMPARE(erreur(r), QStringLiteral("EnergyErrorNoError"));
QCOMPARE(get().value("acLimitW").toInt(), 3000);
QCOMPARE(arb->forecastAcLimitW().value_or(-1), 3000);
QCOMPARE(EnergySettings().value("forecastAcLimitW").toInt(), 3000);
// 4. Une valeur entière transmise en double (2500.0) est un entier : acceptée.
r = set({{"forecastSiteThingId", site}, {"acLimitW", 2500.0}});
QCOMPARE(erreur(r), QStringLiteral("EnergyErrorNoError"));
QCOMPARE(get().value("acLimitW").toInt(), 2500);
// 5. `acLimitW: null` : le plafond est EFFACÉ — et omis à la lecture, pas 0 ; clé retirée.
r = set({{"forecastSiteThingId", site}, {"acLimitW", QVariant()}});
QCOMPARE(r.value("status").toString(), QStringLiteral("success"));
QCOMPARE(erreur(r), 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"));
QCOMPARE(get().value("forecastSiteThingId").toUuid(), site); // le site, lui, reste
// 4. Pas de canal dans le harnais : le snapshot ne rend ni courbe, ni champs de courbe.
// 6. Retirer le SITE retire le plafond : sans site, il n'a pas d'objet.
QCOMPARE(erreur(set({{"forecastSiteThingId", site}, {"acLimitW", 4000}})),
QStringLiteral("EnergyErrorNoError"));
QCOMPARE(erreur(set({{"forecastSiteThingId", ""}})), QStringLiteral("EnergyErrorNoError"));
QVERIFY(!get().contains("acLimitW"));
QVERIFY(!get().contains("forecastSiteThingId"));
QVERIFY(!EnergySettings().contains("forecastAcLimitW"));
// … et `null` sans site est accepté : effacer ce qui n'existe pas n'est pas une erreur.
QCOMPARE(erreur(set({{"acLimitW", QVariant()}})), QStringLiteral("EnergyErrorNoError"));
// 7. Pas de canal dans ce test : le snapshot ne rend ni courbe, ni champs de courbe.
QCOMPARE(erreur(set({{"forecastSiteThingId", site}, {"acLimitW", 3000}})),
QStringLiteral("EnergyErrorNoError"));
const QVariantMap snap = injectAndWait("NymeaEnergy.GetForecastSnapshot").toMap().value("params").toMap();
QVERIFY(!snap.contains("forecastPoints"));
QVERIFY(!snap.contains("corrections"));
@ -7295,10 +7333,11 @@ void Simulation::testTheSnapshotPublishesEachPlanesCorrections()
QVERIFY(brut.at(0).toMap().value("id").toUuid().toString()
< brut.at(1).toMap().value("id").toUuid().toString());
// 2. Plans CONCORDANTS, sans plafond : la température remonte au site, `ac-limit` disparaît,
// et `acLimitW` est omis — jamais 0.
// 2. Plans CONCORDANTS, plafond effacé (`null` — l'absence le laisserait en place, §30.3) :
// la température remonte au site, `ac-limit` disparaît, et `acLimitW` est omis — jamais 0.
tOuest->setStateValue("forecastCurveTransport", charge({"temperature"}, {1200, 300}));
r = injectAndWait("NymeaEnergy.SetForecastSite", {{"forecastSiteThingId", siteId}}).toMap();
r = injectAndWait("NymeaEnergy.SetForecastSite",
{{"forecastSiteThingId", siteId}, {"acLimitW", QVariant()}}).toMap();
QCOMPARE(r.value("params").toMap().value("energyError").toString(), QStringLiteral("EnergyErrorNoError"));
snap = snapshot();
QCOMPARE(snap.value("corrections").toStringList(), QStringList{"temperature"});