From 6b66d49f27166aa4a509f71aec9a1917f9eedfba Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Mon, 12 Oct 2026 12:59:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(pr=C3=A9vision=20PV):=20lire=20forecastCur?= =?UTF-8?q?veTransport=20v1=20ET=20v2,=20plafond=20AC=20de=20site=20(brief?= =?UTF-8?q?=203,=20T1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prérequis du déploiement d'openmeteo v2 (§28) : - `depuisTransport()` lit v1 et v2. v2 exige `corrections` (liste) ; une correction inconnue refuse le plan (`CORRECTION_`), `ac-limit` compris, qui appartient au site. Autre version : `VERSION_`, jamais devinée. `gti[]`/`tempC[]` ne sont pas lus ici (enregistrement : T2). - `acLimitW`, paramètre de SITE porté par `Get/SetForecastSite` : optionnel, absent = aucun plafond (jamais 0), borné [100, 1 000 000] W, refusé sans site ; la désignation se remplace en entier. Persisté (`forecastAcLimitW`). - `ForecastStore::sommerPlans()` : somme des plans du même run ET du même pas, puis écrêtage à `acLimitW × pas / 3600` APRÈS la somme. Fonction unique pour la table et le snapshot. - `GetForecastSnapshot` publie `corrections` (intersection si les plans diffèrent, journalisé au changement ; `ac-limit` si plafond configuré), `conversion: "physics"` et `acLimitW`. Aucune décision ne lit la prévision (§11) ; boucle de sécurité ni appelée ni modifiée ; pas de `*Changed`. Tests : loadmodel `testTransportV2IsReadAndAnUnknownCorrectionRefused`, `testTheSiteCurveIsClippedAfterTheSum` (contre-épreuve faite : plafond neutralisé → échec) ; simulation `testTheSiteAcLimitTravelsWithTheDesignation`. Co-Authored-By: Claude Opus 5.5 --- energyplugin/etm/energyarbitrator.cpp | 89 +++++++------- energyplugin/etm/energyarbitrator.h | 27 ++++- energyplugin/etm/forecast/forecaststore.cpp | 108 ++++++++++++++++- energyplugin/etm/forecast/forecaststore.h | 49 ++++++++ energyplugin/etm/types/decisionreason.h | 3 +- energyplugin/nymeaenergyjsonhandler.cpp | 36 +++++- tests/auto/loadmodel/testloadmodel.cpp | 124 +++++++++++++++++++- tests/auto/loadmodel/testloadmodel.h | 2 + tests/auto/simulation/simulation.cpp | 60 ++++++++++ tests/auto/simulation/simulation.h | 2 + 10 files changed, 445 insertions(+), 55 deletions(-) diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index cb3afef..42cf51d 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -68,6 +68,10 @@ EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm, EnergySettings reglages; m_forecastSiteId = reglages.value(QStringLiteral("forecastSiteId")).toString(); m_forecastSiteChoisi = reglages.contains(QStringLiteral("forecastSiteChosen")); + bool ok = false; + const int plafond = reglages.value(QStringLiteral("forecastAcLimitW")).toInt(&ok); + if (ok && plafond >= AcLimitMinW && plafond <= AcLimitMaxW) + m_forecastAcLimitW = plafond; } #ifndef ENERGY_SIMULATION @@ -2312,20 +2316,35 @@ void EnergyArbitrator::signalerSiteManquant() .arg(QLatin1String(DecisionCode::MissingForecastSite)); } -bool EnergyArbitrator::setForecastSiteId(const QString &siteId) +bool EnergyArbitrator::setForecastSiteId(const QString &siteId, std::optional acLimitW) { if (!siteId.isEmpty() && (!m_tm || !m_tm->findConfiguredThing(ThingId(siteId)))) return false; + // Un plafond est une propriété d'un SITE : sans site, il n'aurait pas d'objet. Et ZÉRO n'est + // pas « pas de plafond » — c'est une prévision nulle imposée. L'absence se dit en omettant. + if (acLimitW && (siteId.isEmpty() || *acLimitW < AcLimitMinW || *acLimitW > AcLimitMaxW)) { + qCWarning(dcNymeaEnergy()) << "[Prévision] plafond AC refusé :" << *acLimitW << "W" + << (siteId.isEmpty() ? "(aucun site désigné)" : "(hors bornes)"); + return false; + } m_forecastSiteId = siteId; m_forecastSiteChoisi = true; + m_forecastAcLimitW = acLimitW; EnergySettings reglages; reglages.setValue(QStringLiteral("forecastSiteId"), siteId); + if (acLimitW) + reglages.setValue(QStringLiteral("forecastAcLimitW"), *acLimitW); + else + reglages.remove(QStringLiteral("forecastAcLimitW")); // LE CHOIX EST MARQUÉ MÊME QUAND IL EST « AUCUN ». Sans cela, la désignation d'office se // réarmerait au prochain démarrage et écraserait une décision explicite, en silence. reglages.setValue(QStringLiteral("forecastSiteChosen"), true); m_forecastSiteSignale = false; // un nouveau vide se resignalera une fois qCInfo(dcNymeaEnergy()) << "[Prévision] site retenu :" - << (siteId.isEmpty() ? QStringLiteral("(aucun)") : siteId); + << (siteId.isEmpty() ? QStringLiteral("(aucun)") : siteId) + << "— plafond AC :" + << (acLimitW ? QStringLiteral("%1 W").arg(*acLimitW) + : QStringLiteral("aucun")); return true; } @@ -2375,25 +2394,12 @@ ForecastRevision EnergyArbitrator::forecastSnapshot(QString &fault, QVariantMap if (plans.isEmpty()) return vide; // le canal se tait : absence, sans motif ni courbe - const QDateTime run = plans.first().issuedAt; - for (const ForecastRevision &r : plans) - if (r.issuedAt != run) - return vide; // plans de runs différents : on ne somme pas - - ForecastRevision site; - site.siteId = m_forecastSiteId; - site.issuedAt = run; - site.model = plans.first().model; - QMap somme; - 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); - } + // Somme, puis plafond AC du site — la même fonction que l'enregistrement, pour que la + // table et le snapshot ne puissent pas diverger. Runs ou grilles différents : rien. + ForecastRevision site = ForecastStore::sommerPlans(plans, m_forecastAcLimitW); + if (site.points.isEmpty()) + return vide; + site.siteId = m_forecastSiteId; return site; } @@ -2448,32 +2454,31 @@ void EnergyArbitrator::lireCourbePrevision() // 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.siteId = m_forecastSiteId; - 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); + // Somme des plans du MÊME RUN et de la MÊME GRILLE, puis plafond AC du site (§28 décision 2). + bool melangees = false; + ForecastRevision site = ForecastStore::sommerPlans(plans, m_forecastAcLimitW, &melangees); + if (site.points.isEmpty()) { + qCDebug(dcNymeaEnergy()) << "[Prévision] plans de runs ou de pas différents — on attend la" + " prochaine passe plutôt que de sommer un mélange"; + return; + } + site.siteId = m_forecastSiteId; + // Corrections DIVERGENTES entre plans (un plan v1, l'autre v2 ; ou un plan sans température) : + // la courbe annonce leur intersection. Dit au CHANGEMENT, pas à chaque cycle. + if (melangees != m_forecastCorrectionsMelangees) { + m_forecastCorrectionsMelangees = melangees; + if (melangees) + qCInfo(dcNymeaEnergy()) << "[Prévision] les plans ne déclarent pas les mêmes corrections" + " — la courbe du site annonce leur intersection :" + << site.corrections; + else + qCInfo(dcNymeaEnergy()) << "[Prévision] corrections de nouveau identiques sur tous les plans :" + << site.corrections; } if (plans.count() > 1) qCDebug(dcNymeaEnergy()) << "[Prévision]" << plans.count() << "plans sommés —" diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index 5af3bed..3686e81 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -89,11 +89,26 @@ public: 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. + * \brief Plafond AC (onduleur) du site désigné, en W. \c std::nullopt = aucun plafond. + * \note Un paramètre de SITE, pas de plan (§28 décision 2) : l'écrêtage porte sur ce que + * l'onduleur reçoit de tous ses champs. Appliqué après la somme, jamais 0. */ - bool setForecastSiteId(const QString &siteId); + std::optional forecastAcLimitW() const { return m_forecastAcLimitW; } + + //! Bornes acceptées pour \c acLimitW, en W. Sous 100 W : très probablement des kW saisis + //! en W ; au-delà de 1 MW : hors du périmètre d'un site résidentiel ou tertiaire. + static constexpr int AcLimitMinW = 100; + static constexpr int AcLimitMaxW = 1000000; + + /*! + * \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. + * \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é. + */ + bool setForecastSiteId(const QString &siteId, std::optional acLimitW = std::nullopt); /*! * \brief Les sites CANDIDATS — ceux dont au moins un enfant publie une courbe. @@ -909,6 +924,10 @@ private: QHash m_forecastRefusVu; //! Site désigné ; vide = aucune prévision retenue, et c'est un cas NORMAL, jamais un défaut. QString m_forecastSiteId; + //! Plafond AC du site ; absent = aucun plafond (jamais 0). + std::optional m_forecastAcLimitW; + //! Les corrections des plans divergeaient-elles au dernier cycle ? Pour n'annoncer qu'au changement. + bool m_forecastCorrectionsMelangees = false; /*! * \brief Vrai dès qu'un choix a été EXPRIMÉ, fût-il « aucun site ». * diff --git a/energyplugin/etm/forecast/forecaststore.cpp b/energyplugin/etm/forecast/forecaststore.cpp index 751f973..ed64217 100644 --- a/energyplugin/etm/forecast/forecaststore.cpp +++ b/energyplugin/etm/forecast/forecaststore.cpp @@ -9,6 +9,8 @@ #include #include #include +#include +#include #include #include @@ -26,9 +28,28 @@ constexpr int RetentionPleineJours = 7; //! calcul sont égaux au bit près ; ce seuil couvre un ré-encodage JSON, pas une révision. constexpr double EgaliteWh = 0.001; -//! Version de la charge utile de TRANSPORT qu'on sait lire. Une autre est REFUSÉE, jamais +//! Versions de la charge utile de TRANSPORT qu'on sait lire. Une autre est REFUSÉE, jamais //! interprétée au mieux — cf. \c ForecastStore::depuisTransport(). -constexpr int TransportVersion = 1; +//! v1 : `p = GTI × kWc × PR`. v2 (brief 2) : mêmes champs, plus `corrections` (liste), +//! `gti[]`, `tempC[]` — `p` corrigé en température quand `corrections` le dit. +constexpr int TransportVersionMin = 1; +constexpr int TransportVersionMax = 2; + +//! Corrections qu'un PRODUCTEUR peut déclarer. `ac-limit` n'en fait pas partie : le plafond est +//! un paramètre de SITE, appliqué ici après la somme (§28 décision 2). Un producteur qui +//! l'annoncerait l'aurait appliqué par plan — ce qui est faux — et on l'appliquerait deux fois. +const QStringList CorrectionsProducteur = { QStringLiteral("temperature") }; +//! Ordre canonique des corrections publiées : une liste se compare. +const QStringList OrdreCorrections = { QStringLiteral("temperature"), QStringLiteral("ac-limit") }; + +QStringList ordonner(const QStringList &c) +{ + QStringList r; + for (const QString &x : OrdreCorrections) + if (c.contains(x)) + r << x; + return r; +} } QString ForecastStore::cheminParDefaut() @@ -52,7 +73,9 @@ ForecastRevision ForecastStore::depuisTransport(const QVariantMap &charge, QStri // traverse une frontière de processus n'a plus de schéma : personne ne vérifie sa forme, et // une lecture « au mieux » d'une version inconnue produirait une courbe fausse qui aurait // l'air de marcher. - if (charge.value(QStringLiteral("v")).toInt() != TransportVersion) { + bool vLu = false; + const int v = charge.value(QStringLiteral("v")).toInt(&vLu); + if (!vLu || v < TransportVersionMin || v > TransportVersionMax) { refus = QStringLiteral("VERSION_%1").arg(charge.value(QStringLiteral("v")).toString()); return r; } @@ -69,6 +92,33 @@ ForecastRevision ForecastStore::depuisTransport(const QVariantMap &charge, QStri refus = QStringLiteral("MALFORMED"); return r; } + // v2 : `corrections` est une LISTE, obligatoire. v1 n'en avait pas (chaîne "none") : sa + // conversion était brute par contrat, la liste est donc vide sans rien supposer. + // + // Une correction INCONNUE refuse la charge utile au lieu d'être ignorée : `p` porte alors un + // effet qu'on ne sait pas nommer, et l'annoncer sous une liste plus courte serait mentir sur + // la courbe. Même règle que la version : refuser, jamais interpréter au mieux. + if (v >= 2) { + const QVariant brut = charge.value(QStringLiteral("corrections")); + if (brut.userType() != QMetaType::QVariantList && brut.userType() != QMetaType::QStringList) { + refus = QStringLiteral("MALFORMED"); + return r; + } + for (const QVariant &c : brut.toList()) { + const QString nom = c.toString(); + if (!CorrectionsProducteur.contains(nom)) { + refus = QStringLiteral("CORRECTION_%1").arg(nom); + return r; + } + if (!r.corrections.contains(nom)) + r.corrections << nom; + } + r.corrections = ordonner(r.corrections); + } + // `gti[]` et `tempC[]` (v2) ne sont PAS lus ici : la courbe n'en a pas besoin, et un défaut + // de leur forme ne doit pas faire perdre une prévision utilisable. Leur lecteur viendra avec + // l'enregistrement (brief 3, T2). + r.stepS = step; // `issuedAt == 0` SIGNIFIE « ÂGE INCONNU » — et ce n'est PAS un refus. // // Il l'a été, tant que ce champ servait de clé. Depuis qu'il est mesuré qu'il n'identifie @@ -101,6 +151,58 @@ ForecastRevision ForecastStore::depuisTransport(const QVariantMap &charge, QStri return r; } +ForecastRevision ForecastStore::sommerPlans(const QList &plans, + std::optional acLimitW, + bool *correctionsMelangees) +{ + ForecastRevision site; + if (correctionsMelangees) + *correctionsMelangees = false; + if (plans.isEmpty()) + return site; + + const QDateTime run = plans.first().issuedAt; + const int step = plans.first().stepS; + QStringList communes = plans.first().corrections; + bool melangees = false; + for (const ForecastRevision &r : plans) { + if (r.issuedAt != run || r.stepS != step) + return site; // une chimère ou deux grilles : rien plutôt qu'un mélange + if (r.corrections != plans.first().corrections) + melangees = true; + QStringList garde; + for (const QString &c : communes) + if (r.corrections.contains(c)) + garde << c; + communes = garde; + } + + site.issuedAt = run; + site.model = plans.first().model; + site.stepS = step; + 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; + + // APRÈS la somme : l'onduleur plafonne ce qu'il reçoit de tous ses champs, pas chacun d'eux. + const std::optional plafondWh = + (acLimitW && step > 0) ? std::optional(double(*acLimitW) * step / 3600.0) + : std::nullopt; + for (auto it = somme.constBegin(); it != somme.constEnd(); ++it) { + ForecastPoint p; + p.targetAt = QDateTime::fromSecsSinceEpoch(it.key(), QTimeZone::utc()); + p.wh = plafondWh ? qMin(it.value(), *plafondWh) : it.value(); + site.points.append(p); + } + if (acLimitW) + communes << QStringLiteral("ac-limit"); + site.corrections = ordonner(communes); + if (correctionsMelangees) + *correctionsMelangees = melangees; + return site; +} + ForecastStore::ForecastStore(const QString &cheminFichier, QObject *parent) : QObject(parent) { diff --git a/energyplugin/etm/forecast/forecaststore.h b/energyplugin/etm/forecast/forecaststore.h index 80da08e..893976f 100644 --- a/energyplugin/etm/forecast/forecaststore.h +++ b/energyplugin/etm/forecast/forecaststore.h @@ -8,6 +8,9 @@ #include #include #include +#include + +#include /*! * \file forecaststore.h @@ -127,6 +130,16 @@ struct ForecastRevision { * NULL. Le combler après coup y rangerait une supposition sous un nom de provenance. */ QString model; + /*! + * \brief Corrections PHYSIQUES appliquées à la courbe, ordre canonique + * (\c temperature, puis \c ac-limit). Vide = conversion brute `GTI × kWc × PR`. + * + * Sur un PLAN : lu dans la charge utile (v2), vide en v1 — la v1 n'a jamais corrigé. + * Sur le SITE : posé par \c sommerPlans(), \c ac-limit compris si un plafond est configuré. + */ + QStringList corrections; + //! Pas de la grille en secondes, LU dans la charge utile (\c step). 0 = inconnu. + int stepS = 0; }; /*! @@ -169,6 +182,12 @@ public: * lecture partielle qui aurait l'air de marcher. Sans cela, le symptôme d'un changement de * forme serait « courbe vide », **indistinguable d'une nuit**. * + * \par Versions lues : 1 et 2 + * v2 ajoute \c corrections (liste, obligatoire), \c gti[] et \c tempC[]. Motifs de refus : + * \c VERSION_x (autre version), \c UNIT_x, \c MALFORMED (dont \c corrections absente ou + * non-liste en v2), \c CORRECTION_x (correction inconnue — \c ac-limit compris : il + * appartient au site, pas au plan), \c NO_POINT, \c EMPTY. + * * \par L'indexation est LUE, pas supposée * \c convention voyage dans la charge utile : \c p[i] est la moyenne sur * `[t0 + i·step, t0 + (i+1)·step)`. On n'a donc ni à la redécouvrir, ni à faire confiance @@ -176,6 +195,36 @@ public: */ static ForecastRevision depuisTransport(const QVariantMap &charge, QString &refus); + /*! + * \brief La courbe du SITE : somme des plans, puis plafond AC (\c DESIGN_PREVISION_PV.md + * §17, §28 décision 2). + * \param plans Révisions des plans du site, déjà lues par \c depuisTransport(). + * \param acLimitW Plafond onduleur du site, en W ; \c std::nullopt = aucun plafond. + * \param[out] correctionsMelangees Si non nul : vrai quand les plans ne déclarent pas les + * mêmes corrections (la courbe porte alors leur INTERSECTION). + * \return La révision du site (\c siteId vide, à poser par l'appelant) ; **sans point** si la + * somme n'a pas lieu. + * + * \par On ne somme que des plans du MÊME RUN et de la MÊME GRILLE + * Runs différents : une chimère, on attend la passe suivante (§17.2). Pas différents : la + * somme par instant mêlerait des énergies de périodes différentes, et le plafond + * (W × pas) n'aurait plus de période. Même réponse : rien, plutôt qu'un mélange. + * + * \par Le plafond s'applique APRÈS la somme + * L'écrêtage onduleur porte sur ce que l'onduleur reçoit de tous ses champs : un créneau + * vaut au plus `acLimitW × pas / 3600` Wh. Un créneau ABSENT reste absent — le plafond ne + * fabrique pas de point. + * + * \par Les corrections déclarées sont celles que TOUS les plans ont reçues + * Si les plans diffèrent, la courbe annonce la plus faible commune (intersection) : dire + * « corrigée en température » d'une somme dont un plan ne l'est pas serait faux. Puis + * \c ac-limit si un plafond est configuré — qu'il morde ou non sur ce run, la courbe est + * bornée par lui. + */ + static ForecastRevision sommerPlans(const QList &plans, + std::optional acLimitW, + bool *correctionsMelangees = nullptr); + //! \brief La base est-elle utilisable ? \return Faux si l'ouverture ou la migration a échoué. bool isValid() const { return m_valide; } diff --git a/energyplugin/etm/types/decisionreason.h b/energyplugin/etm/types/decisionreason.h index 7ce5622..22f2aac 100644 --- a/energyplugin/etm/types/decisionreason.h +++ b/energyplugin/etm/types/decisionreason.h @@ -297,7 +297,8 @@ constexpr const char *MissingForecastSite = "MISSING_FORECAST_SITE"; /*! * \brief Le canal de prévision publie, mais sa charge utile est ILLISIBLE. - * params : \c cause — le motif de refus (\c VERSION_x, \c UNIT_x, \c MALFORMED, \c EMPTY). + * params : \c cause — le motif de refus (\c VERSION_x, \c UNIT_x, \c MALFORMED, + * \c CORRECTION_x, \c NO_POINT, \c EMPTY ; liste faisant foi : \c ForecastStore::depuisTransport()). * * \note Distinct de \c MISSING_FORECAST_SITE : là, personne n'a désigné ; ici, quelqu'un publie * quelque chose qu'on ne sait pas lire. **Deux causes, deux gestes** — configurer d'un côté, diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index 1ed305b..f4ed4b8 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -365,6 +365,7 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana "missing designation to an installation that has nothing to designate would " "be an unsatisfiable warning."; returns.insert("o:forecastSiteThingId", enumValueName(Uuid)); + returns.insert("o:acLimitW", enumValueName(Uint)); // plafond AC du site ; omis = aucun returns.insert("candidateSiteThingIds", QVariantList() << enumValueName(Uuid)); // Le MOTIF, présent seulement quand il y a un geste à faire : des plans existent et aucun // site n'est désigné. Un CODE, jamais une phrase — le client la fabrique dans sa langue. @@ -390,7 +391,13 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana "a missing forecast and a forecast of zero are different statements. 'fault' " "carries MISSING_FORECAST_SITE (nobody designated a site, and one exists) or " "FORECAST_PAYLOAD_REFUSED with its cause (the channel publishes something we " - "cannot read). Two faults, two gestures: configure, or look at the provider."; + "cannot read). Two faults, two gestures: configure, or look at the provider. " + "With a curve come 'corrections' (physical corrections applied, canonical " + "order: 'temperature' by the provider, 'ac-limit' when the site has an AC " + "limit; empty = raw GTI x kWp x PR; when planes disagree, only the corrections " + "ALL of them received), 'conversion' ('physics' - this engine has no other) and " + "'acLimitW' when an AC limit is configured: the curve is the sum of the planes " + "CLIPPED to it."; QVariantMap snapPoint; snapPoint.insert("targetAt", enumValueName(String)); snapPoint.insert("forecastWh", enumValueName(Double)); @@ -399,6 +406,9 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana returns.insert("o:issuedAt", enumValueName(String)); returns.insert("o:model", enumValueName(String)); returns.insert("o:forecastPoints", QVariantList() << snapPoint); + returns.insert("o:corrections", QVariantList() << enumValueName(String)); + returns.insert("o:conversion", enumValueName(String)); // physics | fallback + returns.insert("o:acLimitW", enumValueName(Uint)); returns.insert("o:fault", enumValueName(String)); returns.insert("o:faultParams", enumValueName(Variant)); registerMethod("GetForecastSnapshot", description, params, returns, Types::PermissionScopeControlThings); @@ -411,8 +421,13 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana "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."; + "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."; params.insert("o:forecastSiteThingId", enumValueName(Uuid)); + params.insert("o:acLimitW", enumValueName(Uint)); returns.insert("energyError", enumRef()); registerMethod("SetForecastSite", description, params, returns, Types::PermissionScopeControlThings); @@ -1194,6 +1209,9 @@ JsonReply *NymeaEnergyJsonHandler::GetForecastSite(const QVariantMap ¶ms) // se lirait comme un identifiant qu'on n'a pas su rendre. if (!id.isEmpty()) ret.insert(QStringLiteral("forecastSiteThingId"), id); + // OMIS sans plafond, jamais 0 : un zéro se lirait « production plafonnée à rien ». + if (const std::optional plafond = arb->forecastAcLimitW()) + ret.insert(QStringLiteral("acLimitW"), *plafond); // LES CANDIDATS, toujours rendus — même vides. C'est ce qui permet à un écran de poser // la question quand il y a un choix, sans un second appel ni une heuristique côté client. const QStringList candidats = arb->forecastSiteCandidates(); @@ -1260,6 +1278,14 @@ JsonReply *NymeaEnergyJsonHandler::GetForecastSnapshot(const QVariantMap ¶ms pts.append(QVariantMap{{"targetAt", p.targetAt.toUTC().toString(Qt::ISODate)}, {"forecastWh", p.wh}}); ret.insert(QStringLiteral("forecastPoints"), pts); + // Ce qui a été fait à la courbe, publié AVEC elle (OPTIMIZER_PROTOCOL §5.2) : une liste vide + // est une affirmation — conversion brute — pas une absence. + ret.insert(QStringLiteral("corrections"), QVariant(rev.corrections).toList()); + // `physics` : le moteur GPL n'a pas d'autre conversion. Ni `hybrid` (correction apprise, hors + // GPL, §5), ni `fallback` (aucun émetteur aujourd'hui). + ret.insert(QStringLiteral("conversion"), QStringLiteral("physics")); + if (const std::optional plafond = arb->forecastAcLimitW()) + ret.insert(QStringLiteral("acLimitW"), *plafond); #endif return createReply(ret); } @@ -1270,7 +1296,11 @@ JsonReply *NymeaEnergyJsonHandler::SetForecastSite(const QVariantMap ¶ms) EnergyArbitrator *arb = dynamic_cast(m_smartChargingManager); if (!arb) return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}}); - const bool ok = arb->setForecastSiteId(params.value("forecastSiteThingId").toString()); + // `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); // 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/loadmodel/testloadmodel.cpp b/tests/auto/loadmodel/testloadmodel.cpp index 016d4b7..4469416 100644 --- a/tests/auto/loadmodel/testloadmodel.cpp +++ b/tests/auto/loadmodel/testloadmodel.cpp @@ -1091,9 +1091,10 @@ void TestLoadModel::testAPayloadIsRefusedOnFormNeverOnAMissingAttribute() QCOMPARE(ok.points.at(1).targetAt.toSecsSinceEpoch(), t0 + 3600); // 2. LA FORME se refuse, et le motif la NOMME. - QVERIFY(ForecastStore::depuisTransport(charge(2, "W", t0, QVariantList{0, 250}), refus) + // (v2 est LUE depuis le brief 3 T1 : la version inconnue est désormais la 3.) + QVERIFY(ForecastStore::depuisTransport(charge(3, "W", t0, QVariantList{0, 250}), refus) .points.isEmpty()); - QCOMPARE(refus, QString("VERSION_2")); + QCOMPARE(refus, QString("VERSION_3")); QVERIFY(ForecastStore::depuisTransport(charge(1, "Wh", t0, QVariantList{0, 250}), refus) .points.isEmpty()); QCOMPARE(refus, QString("UNIT_Wh")); // lire des Wh comme des W : plausible et faux @@ -1118,6 +1119,125 @@ void TestLoadModel::testAPayloadIsRefusedOnFormNeverOnAMissingAttribute() QCOMPARE(troue.points.count(), 2); } +void TestLoadModel::testTransportV2IsReadAndAnUnknownCorrectionRefused() +{ + // Brief 3 T1 — `ems` lit v1 ET v2 (prérequis du déploiement d'openmeteo v2). v2 garde tous + // les champs v1 et ajoute `corrections` (liste), `gti[]`, `tempC[]`. + const qint64 t0 = QDateTime(QDate(2026, 10, 25), QTime(0, 0), QTimeZone::utc()).toSecsSinceEpoch(); + const QVariantList p{0, 412.5, 1768.25}; + auto v1 = [&]() { + return QVariantMap{{"v", 1}, {"t0", t0}, {"step", 3600}, {"unit", "W"}, + {"convention", "mean over [t, t+step)"}, {"regime", "x"}, + {"model", "meteofrance_seamless"}, {"issuedAt", t0}, {"p", p}, + {"corrections", "none"}}; + }; + auto v2 = [&](const QVariant &corrections) { + QVariantMap m = v1(); + m["v"] = 2; + if (corrections.isValid()) m["corrections"] = corrections; else m.remove("corrections"); + m["gti"] = QVariantList{0, 500, 1000}; + m["tempC"] = QVariantList{5.0, 12.5, QVariant()}; // un null : T2 le lira, pas T1 + return m; + }; + QString refus; + + // 1. v1 : lue comme avant, corrections VIDES — la v1 n'a jamais corrigé, par contrat. + const ForecastRevision a = ForecastStore::depuisTransport(v1(), refus); + QVERIFY2(refus.isEmpty(), qUtf8Printable(refus)); + QVERIFY(a.corrections.isEmpty()); + QCOMPARE(a.stepS, 3600); + + // 2. v2 sans correction : MÊME courbe que v1, au bit près (le brief 2 l'exige du producteur ; + // ici, que le lecteur ne la déforme pas). + const ForecastRevision b = ForecastStore::depuisTransport(v2(QVariantList()), refus); + QVERIFY2(refus.isEmpty(), qUtf8Printable(refus)); + QVERIFY(b.corrections.isEmpty()); + QCOMPARE(b.points.count(), a.points.count()); + for (int i = 0; i < a.points.count(); ++i) { + QCOMPARE(b.points.at(i).targetAt, a.points.at(i).targetAt); + QVERIFY(b.points.at(i).wh == a.points.at(i).wh); + } + + // 3. v2 corrigée en température : lue, et la correction est DITE. + const ForecastRevision c = ForecastStore::depuisTransport(v2(QVariantList{"temperature"}), refus); + QVERIFY2(refus.isEmpty(), qUtf8Printable(refus)); + QCOMPARE(c.corrections, QStringList{"temperature"}); + + // 4. Les REFUS, chacun nommé — jamais une lecture au mieux. + QVERIFY(ForecastStore::depuisTransport(v2(QVariant()), refus).points.isEmpty()); + QCOMPARE(refus, QString("MALFORMED")); // corrections absente en v2 + QVERIFY(ForecastStore::depuisTransport(v2(QString("none")), refus).points.isEmpty()); + QCOMPARE(refus, QString("MALFORMED")); // la chaîne v1 n'est plus une forme v2 + QVERIFY(ForecastStore::depuisTransport(v2(QVariantList{"temperature", "soiling"}), refus) + .points.isEmpty()); + QCOMPARE(refus, QString("CORRECTION_soiling")); // un effet qu'on ne sait pas nommer + QVERIFY(ForecastStore::depuisTransport(v2(QVariantList{"ac-limit"}), refus).points.isEmpty()); + QCOMPARE(refus, QString("CORRECTION_ac-limit")); // le plafond est au SITE, pas au plan + QVariantMap sansV = v1(); sansV.remove("v"); + QVERIFY(ForecastStore::depuisTransport(sansV, refus).points.isEmpty()); + QCOMPARE(refus, QString("VERSION_")); // v absent : jamais deviné +} + +void TestLoadModel::testTheSiteCurveIsClippedAfterTheSum() +{ + // Brief 3 T1 — plafond AC de SITE, appliqué APRÈS la somme des plans (§28 décision 2). + const QDateTime t0(QDate(2026, 10, 12), QTime(10, 0), QTimeZone::utc()); + auto plan = [&](const QList &w, const QStringList &corr, int step = 3600, + const QDateTime &run = QDateTime(QDate(2026, 10, 12), QTime(6, 0), QTimeZone::utc())) { + ForecastRevision r; + r.issuedAt = run; r.stepS = step; r.corrections = corr; r.model = "m"; + for (int i = 0; i < w.count(); ++i) { + if (w.at(i) < 0) continue; // < 0 : créneau ABSENT du plan + ForecastPoint p; + p.targetAt = t0.addSecs(qint64(i) * step); + p.wh = w.at(i) * step / 3600.0; + r.points << p; + } + return r; + }; + const QStringList T{"temperature"}; + const QList deux{plan({1500, 800, -1}, T), plan({1200, 300, 900}, T)}; + + // 1. SANS plafond : la somme, rien d'autre, et pas d'`ac-limit` annoncé. + ForecastRevision s = ForecastStore::sommerPlans(deux, std::nullopt); + QCOMPARE(s.points.count(), 3); + QCOMPARE(s.points.at(0).wh, 2700.0); + QCOMPARE(s.corrections, T); + + // 2. AVEC plafond 2000 W : 2700 → 2000 ; 1100 intact ; et le plafond porte sur la SOMME — + // aucun des deux plans ne dépasse 2000 seul, l'écrêtage par plan n'aurait rien retiré. + bool melangees = true; + s = ForecastStore::sommerPlans(deux, 2000, &melangees); + QCOMPARE(s.points.count(), 3); + QCOMPARE(s.points.at(0).wh, 2000.0); + QCOMPARE(s.points.at(1).wh, 1100.0); + QCOMPARE(s.points.at(2).wh, 900.0); // présent dans UN plan : la somme de ce qui existe + QCOMPARE(s.corrections, (QStringList{"temperature", "ac-limit"})); + QVERIFY(!melangees); + + // 3. Le plafond suit le PAS lu : au quart d'heure, 2000 W font 500 Wh. + s = ForecastStore::sommerPlans({plan({3000}, {}, 900)}, 2000); + QCOMPARE(s.points.at(0).wh, 500.0); + QCOMPARE(s.corrections, QStringList{"ac-limit"}); + + // 4. Un créneau absent RESTE absent : le plafond ne fabrique pas de point (jamais 0). + s = ForecastStore::sommerPlans({plan({-1, 400}, {})}, 2000); + QCOMPARE(s.points.count(), 1); + QCOMPARE(s.points.at(0).targetAt, t0.addSecs(3600)); + + // 5. Corrections DIVERGENTES : la plus faible commune, et c'est signalé. + s = ForecastStore::sommerPlans({plan({100}, T), plan({100}, {})}, std::nullopt, &melangees); + QVERIFY(s.corrections.isEmpty()); + QVERIFY(melangees); + s = ForecastStore::sommerPlans({plan({100}, T), plan({100}, {})}, 5000, &melangees); + QCOMPARE(s.corrections, QStringList{"ac-limit"}); + + // 6. Runs ou pas différents : RIEN, plutôt qu'un mélange. + QVERIFY(ForecastStore::sommerPlans({plan({100}, T), plan({100}, T, 3600, t0)}, 2000).points.isEmpty()); + QVERIFY(ForecastStore::sommerPlans({plan({100}, T), plan({100}, T, 900)}, 2000).points.isEmpty()); + QVERIFY(ForecastStore::sommerPlans({}, 2000).points.isEmpty()); +} + void TestLoadModel::testTheCurrentCurveIsTheLastSeenNotTheLastNew() { // Cas mordu sur .75 le 2026-09-09 : un plan de test ajouté puis retiré fait RÉAPPARAÎTRE un diff --git a/tests/auto/loadmodel/testloadmodel.h b/tests/auto/loadmodel/testloadmodel.h index 5010f6d..371ced1 100644 --- a/tests/auto/loadmodel/testloadmodel.h +++ b/tests/auto/loadmodel/testloadmodel.h @@ -96,6 +96,8 @@ private slots: void testTheCurrentCurveIsTheLastSeenNotTheLastNew(); //! La charge utile se REFUSE plutôt que de s'interpréter au mieux — mais pas pour un attribut. void testAPayloadIsRefusedOnFormNeverOnAMissingAttribute(); + void testTransportV2IsReadAndAnUnknownCorrectionRefused(); + void testTheSiteCurveIsClippedAfterTheSum(); }; diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 40ff716..8df4a4a 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -51,6 +51,7 @@ using namespace nymeaserver; #endif #include "../../../energyplugin/etm/types/decisionreason.h" #include "../../../energyplugin/etm/spotmarketbuild.h" +#include "../../../energyplugin/energysettings.h" #include "../../../energyplugin/spotmarket/spotmarketmanager.h" #include @@ -7151,6 +7152,65 @@ void Simulation::testSpotMarketIsOutOfTheBuild() .toMap().value("params").toMap().value("providers").toList().isEmpty()); } +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). + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); + QVERIFY(arb); + const QUuid site = addMeter(); // n'importe quel Thing configuré tient lieu de site + QVERIFY(!site.isNull()); + auto set = [&](const QVariantMap &p) { + return injectAndWait("NymeaEnergy.SetForecastSite", p).toMap(); + }; + auto get = [&]() { + return injectAndWait("NymeaEnergy.GetForecastSite").toMap().value("params").toMap(); + }; + + // 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(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. + for (const QVariantMap &p : {QVariantMap{{"forecastSiteThingId", site}, {"acLimitW", 0}}, + QVariantMap{{"forecastSiteThingId", site}, {"acLimitW", 99}}, + QVariantMap{{"forecastSiteThingId", site}, {"acLimitW", 1000001}}, + QVariantMap{{"acLimitW", 3000}}}) { + r = set(p); + QCOMPARE(r.value("params").toMap().value("energyError").toString(), + QStringLiteral("EnergyErrorInvalidParameter")); + QCOMPARE(get().value("acLimitW").toInt(), 3000); + QCOMPARE(get().value("forecastSiteThingId").toUuid(), site); + } + + // 3. Désignation SANS plafond : remplacée en entier, le plafond est EFFACÉ — et omis, pas 0. + r = set({{"forecastSiteThingId", site}}); + QCOMPARE(r.value("params").toMap().value("energyError").toString(), 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")); + + // 4. Pas de canal dans le harnais : le snapshot ne rend ni courbe, ni champs de courbe. + const QVariantMap snap = injectAndWait("NymeaEnergy.GetForecastSnapshot").toMap().value("params").toMap(); + QVERIFY(!snap.contains("forecastPoints")); + QVERIFY(!snap.contains("corrections")); + QVERIFY(!snap.contains("conversion")); + + QVERIFY(arb->setForecastSiteId(QString())); +#endif +} + void Simulation::testEveryGetterReturnsWhatItDeclares() { // LE TROU QUE LE 2026-09-09 A OUVERT. diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 62ecba2..7f56f07 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -211,6 +211,8 @@ private slots: void testDerogationTakesTheHeadButNotTheSafety(); //! Brief 3 T0 — aucun objet réseau, aucune activation, un refus codé. void testSpotMarketIsOutOfTheBuild(); + //! Brief 3 T1 — acLimitW voyage avec la désignation de site, absent = aucun plafond. + void testTheSiteAcLimitTravelsWithTheDesignation(); //! TOUTE méthode Get* du namespace rend ce que sa déclaration promet. void testEveryGetterReturnsWhatItDeclares(); //! Le snapshot sert la DÉCISION : le canal, jamais la table — et l'absence n'est pas zéro.