feat(prévision PV): lire forecastCurveTransport v1 ET v2, plafond AC de site (brief 3, T1)
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_<nom>`), `ac-limit` compris, qui appartient au site. Autre version : `VERSION_<v>`, 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 <noreply@anthropic.com>
This commit is contained in:
parent
a23161b3b6
commit
6b66d49f27
@ -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<int> 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<qint64, double> 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<qint64, double> 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 —"
|
||||
|
||||
@ -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<int> 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<int> acLimitW = std::nullopt);
|
||||
|
||||
/*!
|
||||
* \brief Les sites CANDIDATS — ceux dont au moins un enfant publie une courbe.
|
||||
@ -909,6 +924,10 @@ private:
|
||||
QHash<QString, QString> 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<int> 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 ».
|
||||
*
|
||||
|
||||
@ -9,6 +9,8 @@
|
||||
#include <QUuid>
|
||||
#include <QCryptographicHash>
|
||||
#include <QVariant>
|
||||
#include <QMap>
|
||||
#include <QTimeZone>
|
||||
#include <QLoggingCategory>
|
||||
#include <nymeasettings.h>
|
||||
|
||||
@ -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<ForecastRevision> &plans,
|
||||
std::optional<int> 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<qint64, double> 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<double> plafondWh =
|
||||
(acLimitW && step > 0) ? std::optional<double>(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)
|
||||
{
|
||||
|
||||
@ -8,6 +8,9 @@
|
||||
#include <QSqlDatabase>
|
||||
#include <QString>
|
||||
#include <QVariantMap>
|
||||
#include <QStringList>
|
||||
|
||||
#include <optional>
|
||||
|
||||
/*!
|
||||
* \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<ForecastRevision> &plans,
|
||||
std::optional<int> 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; }
|
||||
|
||||
|
||||
@ -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é,
|
||||
|
||||
@ -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<EnergyManager::EnergyError>());
|
||||
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<int> 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<int> 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<EnergyArbitrator *>(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<int> 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.
|
||||
|
||||
@ -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<double> &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<ForecastRevision> 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
|
||||
|
||||
@ -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();
|
||||
|
||||
};
|
||||
|
||||
|
||||
@ -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 <QNetworkAccessManager>
|
||||
|
||||
@ -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<EnergyArbitrator *>(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.
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user