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.