From b14db632ca542e989a2f63cad1f2861e53b46361 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Tue, 13 Oct 2026 06:54:28 +0200 Subject: [PATCH] =?UTF-8?q?feat(pr=C3=A9vision=20PV):=20SetForecastSite=20?= =?UTF-8?q?=E2=80=94=20acLimitW=20absent=20=3D=20inchang=C3=A9,=20null=20?= =?UTF-8?q?=3D=20effacer=20(D3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/DESIGN_PREVISION_PV.md | 7 +++ energyplugin/etm/energyarbitrator.h | 5 +- energyplugin/nymeaenergyjsonhandler.cpp | 58 ++++++++++++++++++---- tests/auto/simulation/simulation.cpp | 65 ++++++++++++++++++++----- 4 files changed, 110 insertions(+), 25 deletions(-) diff --git a/docs/DESIGN_PREVISION_PV.md b/docs/DESIGN_PREVISION_PV.md index 4353d27..04a5475 100644 --- a/docs/DESIGN_PREVISION_PV.md +++ b/docs/DESIGN_PREVISION_PV.md @@ -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 »). diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index 69b379b..aa44e6c 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -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é. */ diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index c2e18e6..3cd6f49 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -39,6 +39,7 @@ #include #include +#include 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()); registerMethod("SetForecastSite", description, params, returns, Types::PermissionScopeControlThings); @@ -1310,11 +1317,42 @@ JsonReply *NymeaEnergyJsonHandler::SetForecastSite(const QVariantMap ¶ms) EnergyArbitrator *arb = dynamic_cast(m_smartChargingManager); if (!arb) return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}}); - // `acLimitW` ABSENT = aucun plafond ; jamais un 0 implicite. - std::optional 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 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(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. diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index b5c61a6..405a401 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -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"});