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;
|
EnergySettings reglages;
|
||||||
m_forecastSiteId = reglages.value(QStringLiteral("forecastSiteId")).toString();
|
m_forecastSiteId = reglages.value(QStringLiteral("forecastSiteId")).toString();
|
||||||
m_forecastSiteChoisi = reglages.contains(QStringLiteral("forecastSiteChosen"));
|
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
|
#ifndef ENERGY_SIMULATION
|
||||||
@ -2312,20 +2316,35 @@ void EnergyArbitrator::signalerSiteManquant()
|
|||||||
.arg(QLatin1String(DecisionCode::MissingForecastSite));
|
.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))))
|
if (!siteId.isEmpty() && (!m_tm || !m_tm->findConfiguredThing(ThingId(siteId))))
|
||||||
return false;
|
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_forecastSiteId = siteId;
|
||||||
m_forecastSiteChoisi = true;
|
m_forecastSiteChoisi = true;
|
||||||
|
m_forecastAcLimitW = acLimitW;
|
||||||
EnergySettings reglages;
|
EnergySettings reglages;
|
||||||
reglages.setValue(QStringLiteral("forecastSiteId"), siteId);
|
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
|
// 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.
|
// réarmerait au prochain démarrage et écraserait une décision explicite, en silence.
|
||||||
reglages.setValue(QStringLiteral("forecastSiteChosen"), true);
|
reglages.setValue(QStringLiteral("forecastSiteChosen"), true);
|
||||||
m_forecastSiteSignale = false; // un nouveau vide se resignalera une fois
|
m_forecastSiteSignale = false; // un nouveau vide se resignalera une fois
|
||||||
qCInfo(dcNymeaEnergy()) << "[Prévision] site retenu :"
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2375,25 +2394,12 @@ ForecastRevision EnergyArbitrator::forecastSnapshot(QString &fault, QVariantMap
|
|||||||
if (plans.isEmpty())
|
if (plans.isEmpty())
|
||||||
return vide; // le canal se tait : absence, sans motif ni courbe
|
return vide; // le canal se tait : absence, sans motif ni courbe
|
||||||
|
|
||||||
const QDateTime run = plans.first().issuedAt;
|
// Somme, puis plafond AC du site — la même fonction que l'enregistrement, pour que la
|
||||||
for (const ForecastRevision &r : plans)
|
// table et le snapshot ne puissent pas diverger. Runs ou grilles différents : rien.
|
||||||
if (r.issuedAt != run)
|
ForecastRevision site = ForecastStore::sommerPlans(plans, m_forecastAcLimitW);
|
||||||
return vide; // plans de runs différents : on ne somme pas
|
if (site.points.isEmpty())
|
||||||
|
return vide;
|
||||||
ForecastRevision site;
|
site.siteId = m_forecastSiteId;
|
||||||
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);
|
|
||||||
}
|
|
||||||
return site;
|
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
|
// 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
|
// 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.
|
// 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
|
// 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.
|
// toit sous le nom du tout, et l'écart passerait pour une contre-performance.
|
||||||
if (!refusDuCycle.isEmpty())
|
if (!refusDuCycle.isEmpty())
|
||||||
return;
|
return;
|
||||||
|
|
||||||
ForecastRevision site;
|
// Somme des plans du MÊME RUN et de la MÊME GRILLE, puis plafond AC du site (§28 décision 2).
|
||||||
site.siteId = m_forecastSiteId;
|
bool melangees = false;
|
||||||
site.issuedAt = run;
|
ForecastRevision site = ForecastStore::sommerPlans(plans, m_forecastAcLimitW, &melangees);
|
||||||
site.model = plans.first().model;
|
if (site.points.isEmpty()) {
|
||||||
QMap<qint64, double> somme; // trié par instant, et c'est ce qu'on veut publier
|
qCDebug(dcNymeaEnergy()) << "[Prévision] plans de runs ou de pas différents — on attend la"
|
||||||
for (const ForecastRevision &r : plans)
|
" prochaine passe plutôt que de sommer un mélange";
|
||||||
for (const ForecastPoint &p : r.points)
|
return;
|
||||||
somme[p.targetAt.toSecsSinceEpoch()] += p.wh;
|
}
|
||||||
for (auto it = somme.constBegin(); it != somme.constEnd(); ++it) {
|
site.siteId = m_forecastSiteId;
|
||||||
ForecastPoint p;
|
// Corrections DIVERGENTES entre plans (un plan v1, l'autre v2 ; ou un plan sans température) :
|
||||||
p.targetAt = QDateTime::fromSecsSinceEpoch(it.key(), QTimeZone::utc());
|
// la courbe annonce leur intersection. Dit au CHANGEMENT, pas à chaque cycle.
|
||||||
p.wh = it.value();
|
if (melangees != m_forecastCorrectionsMelangees) {
|
||||||
site.points.append(p);
|
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)
|
if (plans.count() > 1)
|
||||||
qCDebug(dcNymeaEnergy()) << "[Prévision]" << plans.count() << "plans sommés —"
|
qCDebug(dcNymeaEnergy()) << "[Prévision]" << plans.count() << "plans sommés —"
|
||||||
|
|||||||
@ -89,11 +89,26 @@ public:
|
|||||||
QString forecastSiteId() const { return m_forecastSiteId; }
|
QString forecastSiteId() const { return m_forecastSiteId; }
|
||||||
|
|
||||||
/*!
|
/*!
|
||||||
* \brief Désigne le site dont la prévision est retenue. Persistée.
|
* \brief Plafond AC (onduleur) du site désigné, en W. \c std::nullopt = aucun plafond.
|
||||||
* \param siteId ThingId du site ; vide pour ne plus rien retenir.
|
* \note Un paramètre de SITE, pas de plan (§28 décision 2) : l'écrêtage porte sur ce que
|
||||||
* \return Faux si l'identifiant n'est pas celui d'un Thing existant.
|
* 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.
|
* \brief Les sites CANDIDATS — ceux dont au moins un enfant publie une courbe.
|
||||||
@ -909,6 +924,10 @@ private:
|
|||||||
QHash<QString, QString> m_forecastRefusVu;
|
QHash<QString, QString> m_forecastRefusVu;
|
||||||
//! Site désigné ; vide = aucune prévision retenue, et c'est un cas NORMAL, jamais un défaut.
|
//! Site désigné ; vide = aucune prévision retenue, et c'est un cas NORMAL, jamais un défaut.
|
||||||
QString m_forecastSiteId;
|
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 ».
|
* \brief Vrai dès qu'un choix a été EXPRIMÉ, fût-il « aucun site ».
|
||||||
*
|
*
|
||||||
|
|||||||
@ -9,6 +9,8 @@
|
|||||||
#include <QUuid>
|
#include <QUuid>
|
||||||
#include <QCryptographicHash>
|
#include <QCryptographicHash>
|
||||||
#include <QVariant>
|
#include <QVariant>
|
||||||
|
#include <QMap>
|
||||||
|
#include <QTimeZone>
|
||||||
#include <QLoggingCategory>
|
#include <QLoggingCategory>
|
||||||
#include <nymeasettings.h>
|
#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.
|
//! calcul sont égaux au bit près ; ce seuil couvre un ré-encodage JSON, pas une révision.
|
||||||
constexpr double EgaliteWh = 0.001;
|
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().
|
//! 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()
|
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
|
// 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
|
// une lecture « au mieux » d'une version inconnue produirait une courbe fausse qui aurait
|
||||||
// l'air de marcher.
|
// 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());
|
refus = QStringLiteral("VERSION_%1").arg(charge.value(QStringLiteral("v")).toString());
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
@ -69,6 +92,33 @@ ForecastRevision ForecastStore::depuisTransport(const QVariantMap &charge, QStri
|
|||||||
refus = QStringLiteral("MALFORMED");
|
refus = QStringLiteral("MALFORMED");
|
||||||
return r;
|
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.
|
// `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
|
// 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;
|
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)
|
ForecastStore::ForecastStore(const QString &cheminFichier, QObject *parent)
|
||||||
: QObject(parent)
|
: QObject(parent)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -8,6 +8,9 @@
|
|||||||
#include <QSqlDatabase>
|
#include <QSqlDatabase>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QVariantMap>
|
#include <QVariantMap>
|
||||||
|
#include <QStringList>
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
/*!
|
/*!
|
||||||
* \file forecaststore.h
|
* \file forecaststore.h
|
||||||
@ -127,6 +130,16 @@ struct ForecastRevision {
|
|||||||
* NULL. Le combler après coup y rangerait une supposition sous un nom de provenance.
|
* NULL. Le combler après coup y rangerait une supposition sous un nom de provenance.
|
||||||
*/
|
*/
|
||||||
QString model;
|
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
|
* 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**.
|
* 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
|
* \par L'indexation est LUE, pas supposée
|
||||||
* \c convention voyage dans la charge utile : \c p[i] est la moyenne sur
|
* \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
|
* `[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);
|
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é.
|
//! \brief La base est-elle utilisable ? \return Faux si l'ouverture ou la migration a échoué.
|
||||||
bool isValid() const { return m_valide; }
|
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.
|
* \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
|
* \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é,
|
* 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 "
|
"missing designation to an installation that has nothing to designate would "
|
||||||
"be an unsatisfiable warning.";
|
"be an unsatisfiable warning.";
|
||||||
returns.insert("o:forecastSiteThingId", enumValueName(Uuid));
|
returns.insert("o:forecastSiteThingId", enumValueName(Uuid));
|
||||||
|
returns.insert("o:acLimitW", enumValueName(Uint)); // plafond AC du site ; omis = aucun
|
||||||
returns.insert("candidateSiteThingIds", QVariantList() << enumValueName(Uuid));
|
returns.insert("candidateSiteThingIds", QVariantList() << enumValueName(Uuid));
|
||||||
// Le MOTIF, présent seulement quand il y a un geste à faire : des plans existent et aucun
|
// 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.
|
// 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' "
|
"a missing forecast and a forecast of zero are different statements. 'fault' "
|
||||||
"carries MISSING_FORECAST_SITE (nobody designated a site, and one exists) or "
|
"carries MISSING_FORECAST_SITE (nobody designated a site, and one exists) or "
|
||||||
"FORECAST_PAYLOAD_REFUSED with its cause (the channel publishes something we "
|
"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;
|
QVariantMap snapPoint;
|
||||||
snapPoint.insert("targetAt", enumValueName(String));
|
snapPoint.insert("targetAt", enumValueName(String));
|
||||||
snapPoint.insert("forecastWh", enumValueName(Double));
|
snapPoint.insert("forecastWh", enumValueName(Double));
|
||||||
@ -399,6 +406,9 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
|
|||||||
returns.insert("o:issuedAt", enumValueName(String));
|
returns.insert("o:issuedAt", enumValueName(String));
|
||||||
returns.insert("o:model", enumValueName(String));
|
returns.insert("o:model", enumValueName(String));
|
||||||
returns.insert("o:forecastPoints", QVariantList() << snapPoint);
|
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:fault", enumValueName(String));
|
||||||
returns.insert("o:faultParams", enumValueName(Variant));
|
returns.insert("o:faultParams", enumValueName(Variant));
|
||||||
registerMethod("GetForecastSnapshot", description, params, returns, Types::PermissionScopeControlThings);
|
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 "
|
"measurement, which reads as an under-performing installation. Planes from "
|
||||||
"DIFFERENT runs are never summed: a partly refreshed roof published as the "
|
"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 "
|
"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:forecastSiteThingId", enumValueName(Uuid));
|
||||||
|
params.insert("o:acLimitW", enumValueName(Uint));
|
||||||
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
|
returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
|
||||||
registerMethod("SetForecastSite", description, params, returns, Types::PermissionScopeControlThings);
|
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.
|
// se lirait comme un identifiant qu'on n'a pas su rendre.
|
||||||
if (!id.isEmpty())
|
if (!id.isEmpty())
|
||||||
ret.insert(QStringLiteral("forecastSiteThingId"), id);
|
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
|
// 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.
|
// la question quand il y a un choix, sans un second appel ni une heuristique côté client.
|
||||||
const QStringList candidats = arb->forecastSiteCandidates();
|
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)},
|
pts.append(QVariantMap{{"targetAt", p.targetAt.toUTC().toString(Qt::ISODate)},
|
||||||
{"forecastWh", p.wh}});
|
{"forecastWh", p.wh}});
|
||||||
ret.insert(QStringLiteral("forecastPoints"), pts);
|
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
|
#endif
|
||||||
return createReply(ret);
|
return createReply(ret);
|
||||||
}
|
}
|
||||||
@ -1270,7 +1296,11 @@ JsonReply *NymeaEnergyJsonHandler::SetForecastSite(const QVariantMap ¶ms)
|
|||||||
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_smartChargingManager);
|
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_smartChargingManager);
|
||||||
if (!arb)
|
if (!arb)
|
||||||
return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}});
|
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
|
// 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
|
// recevrait une valeur hors catalogue ne saurait pas la rendre, et l'ajouter ici la ferait
|
||||||
// diverger du contrat publié par nymea-energy.
|
// diverger du contrat publié par nymea-energy.
|
||||||
|
|||||||
@ -1091,9 +1091,10 @@ void TestLoadModel::testAPayloadIsRefusedOnFormNeverOnAMissingAttribute()
|
|||||||
QCOMPARE(ok.points.at(1).targetAt.toSecsSinceEpoch(), t0 + 3600);
|
QCOMPARE(ok.points.at(1).targetAt.toSecsSinceEpoch(), t0 + 3600);
|
||||||
|
|
||||||
// 2. LA FORME se refuse, et le motif la NOMME.
|
// 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());
|
.points.isEmpty());
|
||||||
QCOMPARE(refus, QString("VERSION_2"));
|
QCOMPARE(refus, QString("VERSION_3"));
|
||||||
QVERIFY(ForecastStore::depuisTransport(charge(1, "Wh", t0, QVariantList{0, 250}), refus)
|
QVERIFY(ForecastStore::depuisTransport(charge(1, "Wh", t0, QVariantList{0, 250}), refus)
|
||||||
.points.isEmpty());
|
.points.isEmpty());
|
||||||
QCOMPARE(refus, QString("UNIT_Wh")); // lire des Wh comme des W : plausible et faux
|
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);
|
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()
|
void TestLoadModel::testTheCurrentCurveIsTheLastSeenNotTheLastNew()
|
||||||
{
|
{
|
||||||
// Cas mordu sur .75 le 2026-09-09 : un plan de test ajouté puis retiré fait RÉAPPARAÎTRE un
|
// 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();
|
void testTheCurrentCurveIsTheLastSeenNotTheLastNew();
|
||||||
//! La charge utile se REFUSE plutôt que de s'interpréter au mieux — mais pas pour un attribut.
|
//! La charge utile se REFUSE plutôt que de s'interpréter au mieux — mais pas pour un attribut.
|
||||||
void testAPayloadIsRefusedOnFormNeverOnAMissingAttribute();
|
void testAPayloadIsRefusedOnFormNeverOnAMissingAttribute();
|
||||||
|
void testTransportV2IsReadAndAnUnknownCorrectionRefused();
|
||||||
|
void testTheSiteCurveIsClippedAfterTheSum();
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -51,6 +51,7 @@ using namespace nymeaserver;
|
|||||||
#endif
|
#endif
|
||||||
#include "../../../energyplugin/etm/types/decisionreason.h"
|
#include "../../../energyplugin/etm/types/decisionreason.h"
|
||||||
#include "../../../energyplugin/etm/spotmarketbuild.h"
|
#include "../../../energyplugin/etm/spotmarketbuild.h"
|
||||||
|
#include "../../../energyplugin/energysettings.h"
|
||||||
#include "../../../energyplugin/spotmarket/spotmarketmanager.h"
|
#include "../../../energyplugin/spotmarket/spotmarketmanager.h"
|
||||||
#include <QNetworkAccessManager>
|
#include <QNetworkAccessManager>
|
||||||
|
|
||||||
@ -7151,6 +7152,65 @@ void Simulation::testSpotMarketIsOutOfTheBuild()
|
|||||||
.toMap().value("params").toMap().value("providers").toList().isEmpty());
|
.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()
|
void Simulation::testEveryGetterReturnsWhatItDeclares()
|
||||||
{
|
{
|
||||||
// LE TROU QUE LE 2026-09-09 A OUVERT.
|
// LE TROU QUE LE 2026-09-09 A OUVERT.
|
||||||
|
|||||||
@ -211,6 +211,8 @@ private slots:
|
|||||||
void testDerogationTakesTheHeadButNotTheSafety();
|
void testDerogationTakesTheHeadButNotTheSafety();
|
||||||
//! Brief 3 T0 — aucun objet réseau, aucune activation, un refus codé.
|
//! Brief 3 T0 — aucun objet réseau, aucune activation, un refus codé.
|
||||||
void testSpotMarketIsOutOfTheBuild();
|
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.
|
//! TOUTE méthode Get* du namespace rend ce que sa déclaration promet.
|
||||||
void testEveryGetterReturnsWhatItDeclares();
|
void testEveryGetterReturnsWhatItDeclares();
|
||||||
//! Le snapshot sert la DÉCISION : le canal, jamais la table — et l'absence n'est pas zéro.
|
//! 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