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:
parent
96148b9153
commit
b14db632ca
@ -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 »).
|
||||
|
||||
@ -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é.
|
||||
*/
|
||||
|
||||
@ -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 ¶ms)
|
||||
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.
|
||||
|
||||
@ -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"});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user