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"});