From 2564ba1f16274d648766edfd9904ab9a76d75c8d Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Wed, 9 Sep 2026 06:17:58 +0200 Subject: [PATCH] =?UTF-8?q?feat(pr=C3=A9vision=20PV):=20la=20pr=C3=A9visio?= =?UTF-8?q?n=20est=20une=20propri=C3=A9t=C3=A9=20du=20SITE=20=E2=80=94=20d?= =?UTF-8?q?=C3=A9signation=20globale=20et=20somme=20des=20plans?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Désignation GLOBALE sur le modèle d'Energy.SetRootMeter — même nature : une source unique pour toute l'installation, désignée une fois. Un forecastThingId par charge supposerait que deux charges s'appuient sur des prévisions différentes ; il n'y a qu'un ciel au-dessus de l'installation, et le champ dit d'où vient la PRODUCTION, pas ce que la charge consomme. GetForecastSite / SetForecastSite, persisté dans energy.conf. PLUSIEURS PLANS : LA SOMME, et ce n'était pas un choix libre. La courbe est superposée à la production MESURÉE, qui est le total. N'en retenir qu'un donnerait une prévision systématiquement SOUS la mesure — qui se lirait comme une installation qui sous-performe, soit le mode d'erreur déjà nommé au §3 pour le biais optimiste, obtenu cette fois par le découpage. UN DÉFAUT TROUVÉ EN ÉCRIVANT, ET IL ÉTAIT DÉJÀ LÀ. L'implémentation lisait tous les Things portant forecastCurveTransport et les enregistrait l'un après l'autre. Avec deux plans du même run, les deux révisions entrent en collision sur la clé (targetAt, firstIssuedAt) : INSERT OR REPLACE garde LE DERNIER LU, en silence. Ni somme ni choix — un tirage. Le banc n'a qu'un plan, donc rien ne l'aurait montré. ON NE SOMME QUE DES PLANS DU MÊME RUN. Un plan rafraîchi et l'autre pas donneraient une chimère : une partie du toit à jour, l'autre périmée, publiées comme le tout. Le moteur attend la passe suivante. Même règle si UN plan est refusé — additionner le reste publierait une partie du toit sous le nom du tout. L'ABSENCE DE DÉSIGNATION EST SILENCIEUSE. Rien retenu, rien journalisé : une installation sans prévision est un cas normal, et un avertissement par cycle ferait chercher une panne là où il n'y a qu'une fonction non configurée. GetForecastSite OMET le champ plutôt que de rendre une chaîne vide. Note de contrat : EnergyError de l'amont n'a pas de ThingNotFound, donc un identifiant inconnu rend InvalidParameter et la description le dit. Inventer une valeur d'énumération ferait diverger le contrat de celui de nymea-energy. loadmodel 32/32, charging 48/48, spotmarket 32/32, simulation 69/69, ci-quality 4/4, build 0 erreur 0 warning. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p --- docs/DESIGN_PREVISION_PV.md | 55 +++++++++++++++++ energyplugin/etm/energyarbitrator.cpp | 79 ++++++++++++++++++++++--- energyplugin/etm/energyarbitrator.h | 18 ++++++ energyplugin/nymeaenergyjsonhandler.cpp | 59 ++++++++++++++++++ energyplugin/nymeaenergyjsonhandler.h | 6 ++ 5 files changed, 209 insertions(+), 8 deletions(-) diff --git a/docs/DESIGN_PREVISION_PV.md b/docs/DESIGN_PREVISION_PV.md index dc68515..bd4c15d 100644 --- a/docs/DESIGN_PREVISION_PV.md +++ b/docs/DESIGN_PREVISION_PV.md @@ -696,3 +696,58 @@ avancer la fraîcheur **reste à notre charge**, et nous sommes le dernier rempa À ranger avec les autres de la même famille : la latence de négociation de la V2C, le double écrêtage de la borne, l'`update()` imbriqué. **Un défaut dont les deux valeurs sont plausibles ne se voit que par recoupement de deux sources**, et aucune des deux ne suffit seule. + + +--- + +## 17. La prévision est une propriété du SITE — désignation et somme (2026-09-09) + +**Une désignation globale**, sur le modèle d'`Energy.SetRootMeter` : même nature — une source +unique pour toute l'installation, désignée une fois. Un `o:forecastThingId` **par charge** +supposerait que deux charges puissent s'appuyer sur des prévisions différentes ; il n'y a qu'un +ciel au-dessus de l'installation, et le champ dit d'où vient la **production**, pas ce que la +charge consomme. Ce serait offrir un réglage dont aucune valeur différenciée n'a de sens — donc +une occasion de se tromper sans contrepartie. + +`NymeaEnergy.GetForecastSite` / `SetForecastSite`, persisté dans `energy.conf`. + +### 17.1 Plusieurs plans : la SOMME, et ce n'était pas un choix libre + +Une installation peut avoir deux orientations, est et ouest, donc deux Things de plan. **La +prévision du site est leur somme**, et l'argument est celui de la comparaison : + +> La courbe est superposée à la production **MESURÉE**, qui est le total. N'en retenir qu'un +> plan donnerait une prévision systématiquement **sous** la mesure — qui se lirait comme une +> installation qui sous-performe. C'est exactement le mode d'erreur déjà nommé au §3 pour le +> biais optimiste de la conversion, obtenu cette fois par le découpage. + +**Un défaut trouvé en écrivant, et il était déjà là.** L'implémentation précédente lisait *tous* +les Things portant `forecastCurveTransport` et les enregistrait l'un après l'autre. Avec deux +plans du même run, les deux révisions **entrent en collision sur la clé** `(targetAt, +firstIssuedAt)` : `INSERT OR REPLACE` garde **le dernier lu**, en silence. Ni somme, ni choix — +un tirage. Le banc n'a qu'un plan, donc rien ne l'aurait montré. + +### 17.2 On ne somme que des plans du MÊME RUN + +Deux plans dont l'un a été rafraîchi et l'autre pas donneraient une **chimère** : une partie du +toit à jour, l'autre périmée, publiées comme le tout. Le moteur **attend la passe suivante** +plutôt que d'écrire un mélange — le cas se referme seul, puisque le producteur rafraîchit tous +ses plans en une passe. + +Même règle si **un** plan est refusé (version, unité, clé inconnue) : la somme n'a pas lieu. +Additionner ce qui reste publierait une partie du toit sous le nom du tout, et l'écart passerait +pour une contre-performance. + +### 17.3 L'absence de désignation est SILENCIEUSE + +Aucun site désigné ⇒ on ne retient rien, **et rien n'est journalisé**. Une installation sans +prévision est un cas **normal**, pas un défaut : un avertissement par cycle ferait chercher une +panne là où il n'y a qu'une fonction non configurée. + +Et `GetForecastSite` **omet** le champ plutôt que de rendre une chaîne vide — une chaîne vide se +lirait comme un identifiant qu'on n'a pas su rendre. + +*(Note de contrat : l'énumération `EnergyError` de l'amont ne porte pas de `ThingNotFound`. Un +identifiant inconnu rend donc `InvalidParameter`, et la description le dit. Inventer une valeur +d'énumération ferait diverger le contrat de celui que publie `nymea-energy`, et un client +recevant une valeur hors catalogue ne saurait pas la rendre.)* diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 2b0946e..9e4a111 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -2,6 +2,8 @@ // Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync #include "energyarbitrator.h" +#include "energysettings.h" +#include #include "adapters/evadapter.h" #include "adapters/sgreadyadapter.h" #include "adapters/etmvariableloadadapter.h" @@ -76,6 +78,7 @@ EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm, // La purge tourne une fois par jour ; une table qui ne dit pas quand elle purge finit par // le décider toute seule un jour de disque plein. m_forecastStore = new ForecastStore(ForecastStore::cheminParDefaut(), this); + m_forecastSiteId = EnergySettings().value(QStringLiteral("forecastSiteId")).toString(); if (m_forecastStore->isValid()) { m_forecastPurge = new QTimer(this); m_forecastPurge->setInterval(24 * 3600 * 1000); @@ -2244,15 +2247,40 @@ void EnergyArbitrator::buildDegradedTelemetry(const QDateTime &now) m_telemetry = snap; } +bool EnergyArbitrator::setForecastSiteId(const QString &siteId) +{ + if (!siteId.isEmpty() && (!m_tm || !m_tm->findConfiguredThing(ThingId(siteId)))) + return false; + m_forecastSiteId = siteId; + EnergySettings().setValue(QStringLiteral("forecastSiteId"), siteId); + qCInfo(dcNymeaEnergy()) << "[Prévision] site retenu :" + << (siteId.isEmpty() ? QStringLiteral("(aucun)") : siteId); + return true; +} + void EnergyArbitrator::lireCourbePrevision() { if (!m_forecastStore || !m_forecastStore->isValid() || !m_tm) return; - // On cherche l'état par son NOM, pas la ThingClass par son identifiant : le producteur peut - // renommer sa classe ou en ajouter une seconde sans que cela nous concerne. Ce qui fait - // contrat, c'est le nom de l'état — il est déclaré dans le JSON du plugin. + // AUCUN SITE DÉSIGNÉ : on ne retient rien, EN SILENCE. Une installation sans prévision est + // un cas NORMAL, pas une panne — et un avertissement par cycle ferait chercher un défaut là + // où il n'y a qu'une fonction non configurée. Le silence ne porte que l'ignorance. + if (m_forecastSiteId.isEmpty()) + return; + + // TOUS LES PLANS DU SITE, et leur SOMME — pas un choix. + // + // Une installation peut avoir deux orientations, est et ouest. La production du site est + // physiquement la somme de ses plans, et c'est à la production MESURÉE que la courbe sera + // superposée. N'en retenir qu'un donnerait une prévision systématiquement SOUS la mesure, + // qui se lirait comme une installation qui sous-performe — exactement le mode d'erreur déjà + // nommé pour le biais optimiste de la conversion. + QList plans; + QString refusDuCycle; for (Thing *thing : m_tm->configuredThings()) { + if (thing->parentId().toString() != m_forecastSiteId) + continue; if (!thing->thingClass().hasStateType("forecastCurveTransport")) continue; const QVariantMap charge = thing->stateValue("forecastCurveTransport").toMap(); @@ -2262,20 +2290,55 @@ void EnergyArbitrator::lireCourbePrevision() QString refus; const ForecastRevision rev = ForecastStore::depuisTransport(charge, refus); if (!refus.isEmpty()) { - // UNE FOIS PAR MOTIF, pas une fois par cycle : une charge utile refusée le reste, - // et répéter l'avertissement chaque minute noierait le journal — donc le signal. if (m_forecastRefusVu.value(thing->id().toString()) != refus) { m_forecastRefusVu.insert(thing->id().toString(), refus); qCWarning(dcNymeaEnergy()) << "[Prévision] charge utile REFUSÉE de" << thing->name() << ":" << refus - << "— aucune courbe enregistrée (jamais une lecture " - "partielle qui aurait l'air de marcher)"; + << "— aucune courbe enregistrée"; } + refusDuCycle = refus; continue; } m_forecastRefusVu.remove(thing->id().toString()); - m_forecastStore->enregistrer(rev); // le run déjà connu rend faux, sans bruit + plans.append(rev); } + if (plans.isEmpty()) + return; + + // ON NE SOMME QUE DES PLANS DU MÊME RUN. Deux plans dont l'un a été rafraîchi et l'autre pas + // 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.issuedAt = run; + site.model = plans.first().model; + QMap 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); + } + if (plans.count() > 1) + qCDebug(dcNymeaEnergy()) << "[Prévision]" << plans.count() << "plans sommés —" + << site.points.count() << "créneaux"; + m_forecastStore->enregistrer(site); // un run déjà connu rend faux, sans bruit } void EnergyArbitrator::attacherFinsDerogation(QVariantMap &snap) const diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index 45faea9..610bc0c 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -78,6 +78,22 @@ public: */ ForecastStore *forecastStore() const { return m_forecastStore; } + /*! + * \brief Le SITE dont la prévision est retenue. \return ThingId, vide si aucun n'est désigné. + * \note Une désignation GLOBALE, sur le modèle de \c Energy.SetRootMeter — même nature : + * une source unique pour toute l'installation, désignée une fois. La prévision est une + * propriété du SITE, pas d'une charge : il n'y a qu'un ciel au-dessus de l'installation, + * et un réglage par charge offrirait un choix dont aucune valeur différenciée n'a de sens. + */ + 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. + */ + bool setForecastSiteId(const QString &siteId); + /*! * \brief Construit l'arbitre central. Mêmes dépendances que \c SmartChargingManager amont, * dont il hérite (justification : AGENTS.md, « 3b-iii »). @@ -865,6 +881,8 @@ private: ForecastStore *m_forecastStore = nullptr; //! Dernier motif de refus vu par Thing — pour n'avertir qu'au CHANGEMENT de motif. QHash m_forecastRefusVu; + //! Site désigné ; vide = aucune prévision retenue, et c'est un cas NORMAL, jamais un défaut. + QString m_forecastSiteId; QTimer *m_forecastPurge = nullptr; //! loadId → config AYANT SERVI à construire l'adaptateur. Permet le rebuild incrémental //! (ECS-412) : on ne reconstruit que si le matériel a changé. diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index dcfc881..63b114b 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -335,6 +335,30 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana returns.insert("forecastPoints", QVariantList() << pointItem); registerMethod("GetPvForecast", description, params, returns, Types::PermissionScopeControlThings); + // Désignation GLOBALE, sur le modèle d'Energy.SetRootMeter — même nature : une source unique + // pour toute l'installation, désignée une fois. La prévision est une propriété du SITE, pas + // d'une charge : il n'y a qu'un ciel au-dessus de l'installation, et un réglage par charge + // offrirait un choix dont aucune valeur différenciée n'a de sens. + params.clear(); returns.clear(); + description = "Read which site's PV forecast is kept. Empty means none is designated - a " + "NORMAL state, not a fault: an installation without a forecast is a legitimate " + "configuration and nothing is logged about it."; + returns.insert("o:forecastSiteThingId", enumValueName(Uuid)); + registerMethod("GetForecastSite", description, params, returns, Types::PermissionScopeControlThings); + + params.clear(); returns.clear(); + description = "Designate the site whose PV forecast is kept. ALL the PV planes under that " + "site are SUMMED - not chosen between: a site's production is physically the " + "sum of its planes, and the curve is superimposed on the MEASURED total. " + "Keeping one plane would put the forecast systematically below the " + "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."; + params.insert("o:forecastSiteThingId", enumValueName(Uuid)); + returns.insert("energyError", enumRef()); + registerMethod("SetForecastSite", description, params, returns, Types::PermissionScopeControlThings); + // --- Dérogations ------------------------------------------------------------------------- // // Une dérogation n'est PAS un mode : elle ne passe donc pas par SetChargingInfo, qui écrit @@ -1088,6 +1112,41 @@ JsonReply *NymeaEnergyJsonHandler::GetPvForecast(const QVariantMap ¶ms) #endif } +JsonReply *NymeaEnergyJsonHandler::GetForecastSite(const QVariantMap ¶ms) +{ + Q_UNUSED(params) + QVariantMap ret; +#ifdef ETM_ARBITRATOR + if (EnergyArbitrator *arb = dynamic_cast(m_smartChargingManager)) { + const QString id = arb->forecastSiteId(); + // OMIS, jamais une chaîne vide : « aucun site désigné » est un état, et une chaîne vide + // se lirait comme un identifiant qu'on n'a pas su rendre. + if (!id.isEmpty()) + ret.insert(QStringLiteral("forecastSiteThingId"), id); + } +#endif + return createReply(ret); +} + +JsonReply *NymeaEnergyJsonHandler::SetForecastSite(const QVariantMap ¶ms) +{ +#ifdef ETM_ARBITRATOR + EnergyArbitrator *arb = dynamic_cast(m_smartChargingManager); + if (!arb) + return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}}); + const bool ok = arb->setForecastSiteId(params.value("forecastSiteThingId").toString()); + // 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. + return createReply(QVariantMap{{QStringLiteral("energyError"), + enumValueName(ok ? EnergyManager::EnergyErrorNoError + : EnergyManager::EnergyErrorInvalidParameter)}}); +#else + Q_UNUSED(params) + return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}}); +#endif +} + JsonReply *NymeaEnergyJsonHandler::SetLoadDerogation(const QVariantMap ¶ms) { #ifdef ETM_ARBITRATOR diff --git a/energyplugin/nymeaenergyjsonhandler.h b/energyplugin/nymeaenergyjsonhandler.h index 6f64ec1..4221a46 100644 --- a/energyplugin/nymeaenergyjsonhandler.h +++ b/energyplugin/nymeaenergyjsonhandler.h @@ -123,6 +123,12 @@ public: //! \return \c forecastPoints — les heures SANS prévision sont absentes, jamais à zéro. //! \note Un \c Get* SANS \c *Changed : l'invariant 11 vise l'état vivant, pas l'historique. Q_INVOKABLE JsonReply *GetPvForecast(const QVariantMap ¶ms); + //! \brief Lit le site dont la prévision est retenue. \param params Aucun. + //! \return \c o:forecastSiteThingId — OMIS quand aucun n'est désigné, jamais vide. + Q_INVOKABLE JsonReply *GetForecastSite(const QVariantMap ¶ms); + //! \brief Désigne ce site. \param params \c o:forecastSiteThingId (vide = aucun). + //! \return \c energyError — \c ThingNotFound si l'identifiant n'est pas celui d'un Thing. + Q_INVOKABLE JsonReply *SetForecastSite(const QVariantMap ¶ms); Q_INVOKABLE JsonReply *SetLoadDerogation(const QVariantMap ¶ms); //! \brief Lève une dérogation avant son terme et rend le mode mémorisé. //! \param params \c loadId. \return \c energyError — \c InvalidParameter si aucune n'était en cours.