feat(prévision PV): GetForecastSnapshot publie les corrections par plan (perField, D2)
Décision de Patrick du 2026-10-13. Le `corrections` du site reste
l'intersection (+ `ac-limit` si plafond) ; le détail se lit désormais en
donnée : `perField`, une entrée par plan sommé, `{id, corrections}`,
triée par id, présente seulement avec une courbe. Pas de booléen
« divergent ». Le journal garde son message.
- EnergyArbitrator::forecastSnapshot() : paramètre de sortie optionnel
`parPlan` (ForecastPlanLu) ;
- handler : `o:perField` déclaré et servi ;
- mock : classe `pvPlane` (état forecastCurveTransport) + addPvPlane() ;
premier test qui fait passer une COURBE par le RPC
(testTheSnapshotPublishesEachPlanesCorrections : divergence,
concordance, plafond, refus). Contre-épreuve : parPlan non rempli →
le test échoue (0 entrée au lieu de 2) ;
- OPTIMIZER_PROTOCOL §5.2, INTERFACE.md, design §30.2.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
5468192cf8
commit
96148b9153
@ -1389,3 +1389,10 @@ restent l'état de leur date, et c'est ici qu'on lit ce qui les corrige.*
|
|||||||
Une prévision **dégradée** (température absente) se lit dans `corrections: []`, pas dans
|
Une prévision **dégradée** (température absente) se lit dans `corrections: []`, pas dans
|
||||||
`conversion`. Correction **avant publication** (`OPTIMIZER_PROTOCOL.md` §0, 1.1) : aucun
|
`conversion`. Correction **avant publication** (`OPTIMIZER_PROTOCOL.md` §0, 1.1) : aucun
|
||||||
émetteur n'a jamais produit `fallback`.
|
émetteur n'a jamais produit `fallback`.
|
||||||
|
2. **Corrections PAR PLAN, en donnée.** Le §29 publiait l'intersection des corrections et ne
|
||||||
|
disait la divergence qu'au journal. `GetForecastSnapshot` porte désormais `perField` : une
|
||||||
|
entrée par plan sommé, `{id, corrections}`. Le `corrections` du site reste l'intersection
|
||||||
|
(+ `ac-limit` si plafond). **Pas de booléen « divergent »** : la divergence se lit dans les
|
||||||
|
données. `OPTIMIZER_PROTOCOL.md` §5.2 ajoute `corrections` aux entrées de `perField`. Le
|
||||||
|
journal garde son message. Le mock porte une classe `pvPlane` : pour la première fois, un
|
||||||
|
test fait passer une **courbe** par le RPC (trou n° 8 du rapport T1, refermé pour le snapshot).
|
||||||
|
|||||||
@ -211,7 +211,8 @@ index par index ; `null` dans une case = valeur inconnue — **jamais 0, jamais
|
|||||||
"acLimitW":8000,
|
"acLimitW":8000,
|
||||||
"p50W":[ ... ],
|
"p50W":[ ... ],
|
||||||
"perField":[
|
"perField":[
|
||||||
{"id":"<thingId plan>","label":"Toit sud","peakW":6000,"tiltDeg":35,"azimuthDeg":0,"p50W":[ ... ]}
|
{"id":"<thingId plan>","label":"Toit sud","peakW":6000,"tiltDeg":35,"azimuthDeg":0,
|
||||||
|
"corrections":["temperature"],"p50W":[ ... ]}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"baseload":{"model":"none","p50W":[null, ...]},
|
"baseload":{"model":"none","p50W":[null, ...]},
|
||||||
@ -256,6 +257,10 @@ Règles :
|
|||||||
- `perField` : optionnel, affichage et diagnostic. Convention d'orientation (Open-Meteo,
|
- `perField` : optionnel, affichage et diagnostic. Convention d'orientation (Open-Meteo,
|
||||||
PVGIS) : `tiltDeg` 0 = horizontal, 90 = vertical ; `azimuthDeg` 0 = sud, −90 = est,
|
PVGIS) : `tiltDeg` 0 = horizontal, 90 = vertical ; `azimuthDeg` 0 = sud, −90 = est,
|
||||||
+90 = ouest, ±180 = nord.
|
+90 = ouest, ±180 = nord.
|
||||||
|
- `corrections` (1.1) : corrections déclarées **par ce plan** (jamais `ac-limit`, qui est
|
||||||
|
une correction de site). Le `corrections` de `pv` est l'**intersection** de celles des
|
||||||
|
plans, plus `ac-limit` si un plafond est configuré : une divergence entre plans se lit
|
||||||
|
**ici**, dans les données — aucun booléen ne la résume.
|
||||||
- **`baseload`** : consommation **hors charges pilotées**. `model` : `profile` | `learned` |
|
- **`baseload`** : consommation **hors charges pilotées**. `model` : `profile` | `learned` |
|
||||||
`none` ; `none` + tableau de `null` autorisé (installation neuve). Le plugin la calcule
|
`none` ; `none` + tableau de `null` autorisé (installation neuve). Le plugin la calcule
|
||||||
depuis le compteur racine moins les charges, chacune selon `measurement.source` (§5.3) ;
|
depuis le compteur racine moins les charges, chacune selon `measurement.source` (§5.3) ;
|
||||||
|
|||||||
@ -2348,10 +2348,13 @@ bool EnergyArbitrator::setForecastSiteId(const QString &siteId, std::optional<in
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
ForecastRevision EnergyArbitrator::forecastSnapshot(QString &fault, QVariantMap &faultParams) const
|
ForecastRevision EnergyArbitrator::forecastSnapshot(QString &fault, QVariantMap &faultParams,
|
||||||
|
QList<ForecastPlanLu> *parPlan) const
|
||||||
{
|
{
|
||||||
fault.clear();
|
fault.clear();
|
||||||
faultParams.clear();
|
faultParams.clear();
|
||||||
|
if (parPlan)
|
||||||
|
parPlan->clear();
|
||||||
ForecastRevision vide;
|
ForecastRevision vide;
|
||||||
if (!m_tm)
|
if (!m_tm)
|
||||||
return vide;
|
return vide;
|
||||||
@ -2369,6 +2372,7 @@ ForecastRevision EnergyArbitrator::forecastSnapshot(QString &fault, QVariantMap
|
|||||||
|
|
||||||
// LA COURBE VIENT DU CANAL, pas de la table. C'est toute la frontière.
|
// LA COURBE VIENT DU CANAL, pas de la table. C'est toute la frontière.
|
||||||
QList<ForecastRevision> plans;
|
QList<ForecastRevision> plans;
|
||||||
|
QList<ForecastPlanLu> lus;
|
||||||
QString refus;
|
QString refus;
|
||||||
for (Thing *thing : m_tm->configuredThings()) {
|
for (Thing *thing : m_tm->configuredThings()) {
|
||||||
if (thing->parentId().toString() != m_forecastSiteId
|
if (thing->parentId().toString() != m_forecastSiteId
|
||||||
@ -2381,6 +2385,7 @@ ForecastRevision EnergyArbitrator::forecastSnapshot(QString &fault, QVariantMap
|
|||||||
const ForecastRevision rev = ForecastStore::depuisTransport(charge, r);
|
const ForecastRevision rev = ForecastStore::depuisTransport(charge, r);
|
||||||
if (!r.isEmpty()) { refus = r; continue; }
|
if (!r.isEmpty()) { refus = r; continue; }
|
||||||
plans.append(rev);
|
plans.append(rev);
|
||||||
|
lus.append({thing->id().toString(), rev.corrections});
|
||||||
}
|
}
|
||||||
|
|
||||||
// UNE CHARGE UTILE REFUSÉE N'EST PAS UNE PRÉVISION NULLE, et le motif existe pour que
|
// UNE CHARGE UTILE REFUSÉE N'EST PAS UNE PRÉVISION NULLE, et le motif existe pour que
|
||||||
@ -2400,6 +2405,15 @@ ForecastRevision EnergyArbitrator::forecastSnapshot(QString &fault, QVariantMap
|
|||||||
if (site.points.isEmpty())
|
if (site.points.isEmpty())
|
||||||
return vide;
|
return vide;
|
||||||
site.siteId = m_forecastSiteId;
|
site.siteId = m_forecastSiteId;
|
||||||
|
// CHAQUE PLAN SOMMÉ, avec ses corrections — rendu seulement avec une courbe. La courbe du
|
||||||
|
// site n'annonce que l'intersection ; sans le détail, « un plan sans température » et
|
||||||
|
// « aucun plan corrigé » se liraient pareil. Trié, pour qu'un instantané se compare.
|
||||||
|
if (parPlan) {
|
||||||
|
std::sort(lus.begin(), lus.end(), [](const ForecastPlanLu &a, const ForecastPlanLu &b) {
|
||||||
|
return a.planThingId < b.planThingId;
|
||||||
|
});
|
||||||
|
*parPlan = lus;
|
||||||
|
}
|
||||||
return site;
|
return site;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -119,10 +119,19 @@ public:
|
|||||||
*/
|
*/
|
||||||
QStringList forecastSiteCandidates() const;
|
QStringList forecastSiteCandidates() const;
|
||||||
|
|
||||||
|
//! Un plan SOMMÉ, tel qu'il a été lu : son Thing et les corrections qu'il déclare.
|
||||||
|
struct ForecastPlanLu {
|
||||||
|
QString planThingId;
|
||||||
|
QStringList corrections; //!< ordre canonique ; vide = conversion brute
|
||||||
|
};
|
||||||
|
|
||||||
/*!
|
/*!
|
||||||
* \brief La prévision COURANTE, lue DIRECTEMENT sur le canal — l'entrée, pas la trace.
|
* \brief La prévision COURANTE, lue DIRECTEMENT sur le canal — l'entrée, pas la trace.
|
||||||
* \param[out] fault Code de motif, vide si tout va bien.
|
* \param[out] fault Code de motif, vide si tout va bien.
|
||||||
* \param[out] faultParams Paramètres du motif.
|
* \param[out] faultParams Paramètres du motif.
|
||||||
|
* \param[out] parPlan Si non nul : un élément par plan SOMMÉ, trié par identifiant. Rempli
|
||||||
|
* seulement quand une courbe est rendue — vide sinon. C'est là que se lit une divergence
|
||||||
|
* de corrections entre plans : la courbe du site n'en porte que l'intersection (§30.2).
|
||||||
* \return La révision telle que le producteur la publie ; \c points vide si rien n'est
|
* \return La révision telle que le producteur la publie ; \c points vide si rien n'est
|
||||||
* lisible. **Une absence, jamais une courbe de zéros.**
|
* lisible. **Une absence, jamais une courbe de zéros.**
|
||||||
*
|
*
|
||||||
@ -133,7 +142,8 @@ public:
|
|||||||
* parce qu'elle est nette maintenant ; dans six semaines, le premier consommateur prendrait
|
* parce qu'elle est nette maintenant ; dans six semaines, le premier consommateur prendrait
|
||||||
* \c GetPvForecast sans y penser.
|
* \c GetPvForecast sans y penser.
|
||||||
*/
|
*/
|
||||||
ForecastRevision forecastSnapshot(QString &fault, QVariantMap &faultParams) const;
|
ForecastRevision forecastSnapshot(QString &fault, QVariantMap &faultParams,
|
||||||
|
QList<ForecastPlanLu> *parPlan = nullptr) const;
|
||||||
|
|
||||||
/*!
|
/*!
|
||||||
* \brief Construit l'arbitre central. Mêmes dépendances que \c SmartChargingManager amont,
|
* \brief Construit l'arbitre central. Mêmes dépendances que \c SmartChargingManager amont,
|
||||||
|
|||||||
@ -395,7 +395,9 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
|
|||||||
"With a curve come 'corrections' (physical corrections applied, canonical "
|
"With a curve come 'corrections' (physical corrections applied, canonical "
|
||||||
"order: 'temperature' by the provider, 'ac-limit' when the site has an AC "
|
"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 "
|
"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 "
|
"ALL of them received), 'perField' (one entry per summed plane: its thing 'id' "
|
||||||
|
"and the 'corrections' it declared - where a divergence between planes is "
|
||||||
|
"read), 'conversion' ('physics' - this engine has no other) and "
|
||||||
"'acLimitW' when an AC limit is configured: the curve is the sum of the planes "
|
"'acLimitW' when an AC limit is configured: the curve is the sum of the planes "
|
||||||
"CLIPPED to it.";
|
"CLIPPED to it.";
|
||||||
QVariantMap snapPoint;
|
QVariantMap snapPoint;
|
||||||
@ -407,6 +409,10 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana
|
|||||||
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:corrections", QVariantList() << enumValueName(String));
|
||||||
|
QVariantMap snapPlan;
|
||||||
|
snapPlan.insert("id", enumValueName(Uuid)); // Thing plan
|
||||||
|
snapPlan.insert("corrections", QVariantList() << enumValueName(String));
|
||||||
|
returns.insert("o:perField", QVariantList() << snapPlan);
|
||||||
returns.insert("o:conversion", enumValueName(String)); // physics, seule valeur émise
|
returns.insert("o:conversion", enumValueName(String)); // physics, seule valeur émise
|
||||||
returns.insert("o:acLimitW", enumValueName(Uint));
|
returns.insert("o:acLimitW", enumValueName(Uint));
|
||||||
returns.insert("o:fault", enumValueName(String));
|
returns.insert("o:fault", enumValueName(String));
|
||||||
@ -1243,7 +1249,8 @@ JsonReply *NymeaEnergyJsonHandler::GetForecastSnapshot(const QVariantMap ¶ms
|
|||||||
return createReply(ret);
|
return createReply(ret);
|
||||||
QString fault;
|
QString fault;
|
||||||
QVariantMap faultParams;
|
QVariantMap faultParams;
|
||||||
const ForecastRevision rev = arb->forecastSnapshot(fault, faultParams);
|
QList<EnergyArbitrator::ForecastPlanLu> parPlan;
|
||||||
|
const ForecastRevision rev = arb->forecastSnapshot(fault, faultParams, &parPlan);
|
||||||
if (!fault.isEmpty()) {
|
if (!fault.isEmpty()) {
|
||||||
ret.insert(QStringLiteral("fault"), fault);
|
ret.insert(QStringLiteral("fault"), fault);
|
||||||
if (!faultParams.isEmpty())
|
if (!faultParams.isEmpty())
|
||||||
@ -1281,6 +1288,13 @@ JsonReply *NymeaEnergyJsonHandler::GetForecastSnapshot(const QVariantMap ¶ms
|
|||||||
// Ce qui a été fait à la courbe, publié AVEC elle (OPTIMIZER_PROTOCOL §5.2) : une liste vide
|
// 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.
|
// est une affirmation — conversion brute — pas une absence.
|
||||||
ret.insert(QStringLiteral("corrections"), QVariant(rev.corrections).toList());
|
ret.insert(QStringLiteral("corrections"), QVariant(rev.corrections).toList());
|
||||||
|
// PAR PLAN, en DONNÉE : le `corrections` du site est l'intersection, et une divergence entre
|
||||||
|
// plans se lit ici — pas dans un booléen qui résumerait ce que les données disent déjà.
|
||||||
|
QVariantList plansLus;
|
||||||
|
for (const EnergyArbitrator::ForecastPlanLu &p : parPlan)
|
||||||
|
plansLus.append(QVariantMap{{QStringLiteral("id"), p.planThingId},
|
||||||
|
{QStringLiteral("corrections"), QVariant(p.corrections).toList()}});
|
||||||
|
ret.insert(QStringLiteral("perField"), plansLus);
|
||||||
// `physics` : le moteur GPL n'a pas d'autre conversion. Jamais `hybrid` (correction apprise,
|
// `physics` : le moteur GPL n'a pas d'autre conversion. Jamais `hybrid` (correction apprise,
|
||||||
// hors GPL, §5). Une prévision dégradée se lit dans `corrections`, pas ici (§30.1).
|
// hors GPL, §5). Une prévision dégradée se lit dans `corrections`, pas ici (§30.1).
|
||||||
ret.insert(QStringLiteral("conversion"), QStringLiteral("physics"));
|
ret.insert(QStringLiteral("conversion"), QStringLiteral("physics"));
|
||||||
|
|||||||
@ -517,6 +517,17 @@ QUuid EnergyTestBase::addMeterNoEnergy(quint16 port)
|
|||||||
return response.toMap().value("params").toMap().value("thingId").toUuid();
|
return response.toMap().value("params").toMap().value("thingId").toUuid();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QUuid EnergyTestBase::addPvPlane(const QString &nom)
|
||||||
|
{
|
||||||
|
QVariantMap params;
|
||||||
|
params.insert("thingClassId", mockPvPlaneThingClassId.toString());
|
||||||
|
params.insert("name", nom);
|
||||||
|
|
||||||
|
QVariant response = injectAndWait("Integrations.AddThing", params);
|
||||||
|
verifyThingError(response);
|
||||||
|
return response.toMap().value("params").toMap().value("thingId").toUuid();
|
||||||
|
}
|
||||||
|
|
||||||
QUuid EnergyTestBase::addThermalProbe(quint16 port)
|
QUuid EnergyTestBase::addThermalProbe(quint16 port)
|
||||||
{
|
{
|
||||||
QVariantMap portParam;
|
QVariantMap portParam;
|
||||||
|
|||||||
@ -59,6 +59,7 @@ static QUuid mockPowerSwitchThingClassId = QUuid("841f8905-d1d7-4053-909f-01123b
|
|||||||
//! puissance, si bien qu'aucun test ne pouvait distinguer « non mesurée » de « en défaut »
|
//! puissance, si bien qu'aucun test ne pouvait distinguer « non mesurée » de « en défaut »
|
||||||
//! (docs/INVENTAIRE_MOCK.md).
|
//! (docs/INVENTAIRE_MOCK.md).
|
||||||
static QUuid mockDryRelayThingClassId = QUuid("5b2c7e41-9a06-4d3f-b8c1-6e0f2a7d4b39");
|
static QUuid mockDryRelayThingClassId = QUuid("5b2c7e41-9a06-4d3f-b8c1-6e0f2a7d4b39");
|
||||||
|
static QUuid mockPvPlaneThingClassId = QUuid("4e9b2f71-8c3a-4d56-a1e0-7f2b9c6d3e18");
|
||||||
static QUuid mockMeterNoEnergyThingClassId = QUuid("50dacc7b-7142-4716-8f59-97d6ee81f39c");
|
static QUuid mockMeterNoEnergyThingClassId = QUuid("50dacc7b-7142-4716-8f59-97d6ee81f39c");
|
||||||
static QUuid mockThermalProbeThingClassId = QUuid("37e61ea3-e4b2-4d3e-bc82-66e350c29f66");
|
static QUuid mockThermalProbeThingClassId = QUuid("37e61ea3-e4b2-4d3e-bc82-66e350c29f66");
|
||||||
|
|
||||||
@ -141,6 +142,14 @@ public:
|
|||||||
QUuid addThermalProbe(quint16 port = 26681);
|
QUuid addThermalProbe(quint16 port = 26681);
|
||||||
//! Mock thing implémentant l'interface etmvariableload (states currentPowerW / powerSetpoint).
|
//! Mock thing implémentant l'interface etmvariableload (states currentPowerW / powerSetpoint).
|
||||||
QUuid addEtmVariableLoad(quint16 port = 27001);
|
QUuid addEtmVariableLoad(quint16 port = 27001);
|
||||||
|
/*!
|
||||||
|
* \brief Ajoute un PLAN PV : un Thing qui porte l'état \c forecastCurveTransport.
|
||||||
|
* \param nom Nom du Thing.
|
||||||
|
* \return L'identifiant du Thing créé.
|
||||||
|
* \note Le plan naît SANS parent : le test le range sous un site (\c Thing::setParentId) et
|
||||||
|
* écrit sa charge utile. Sans lui, aucune courbe de prévision n'était productible.
|
||||||
|
*/
|
||||||
|
QUuid addPvPlane(const QString &nom = QStringLiteral("PV plane"));
|
||||||
|
|
||||||
void removeDevices();
|
void removeDevices();
|
||||||
QVariant removeDevice(const QUuid &thingId);
|
QVariant removeDevice(const QUuid &thingId);
|
||||||
|
|||||||
@ -61,6 +61,7 @@ using namespace nymeaserver;
|
|||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
|
|
||||||
#include <QHash>
|
#include <QHash>
|
||||||
|
#include <QScopeGuard>
|
||||||
#include <QtMath>
|
#include <QtMath>
|
||||||
#include <QtGlobal>
|
#include <QtGlobal>
|
||||||
#include <QProcess>
|
#include <QProcess>
|
||||||
@ -7211,6 +7212,111 @@ void Simulation::testTheSiteAcLimitTravelsWithTheDesignation()
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Simulation::testTheSnapshotPublishesEachPlanesCorrections()
|
||||||
|
{
|
||||||
|
#ifndef ETM_ARBITRATOR
|
||||||
|
QSKIP("nécessite ETM_ARBITRATOR.");
|
||||||
|
#else
|
||||||
|
// Décision du 2026-10-13 (§30.2) — les corrections PAR PLAN, en donnée. Le `corrections` du
|
||||||
|
// site est l'intersection : sans le détail, « un plan sans température » et « aucun plan
|
||||||
|
// corrigé » se liraient pareil. Pas de booléen « divergent » : la divergence se LIT.
|
||||||
|
//
|
||||||
|
// Premier test qui fait passer une COURBE par le RPC : le mock porte désormais une classe
|
||||||
|
// `pvPlane` (canal `forecastCurveTransport`), rangée ici sous un site par `setParentId`.
|
||||||
|
cleanupTestCase();
|
||||||
|
m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite";
|
||||||
|
initTestCase();
|
||||||
|
EnergyArbitrator *arb = dynamic_cast<EnergyArbitrator *>(m_experiencePlugin->smartChargingManager());
|
||||||
|
QVERIFY(arb);
|
||||||
|
ThingManager *tm = NymeaCore::instance()->thingManager();
|
||||||
|
const QUuid site = addMeter(); // n'importe quel Thing configuré tient lieu de site
|
||||||
|
const QUuid est = addPvPlane(QStringLiteral("Toit est"));
|
||||||
|
const QUuid ouest = addPvPlane(QStringLiteral("Toit ouest"));
|
||||||
|
Thing *tEst = tm->findConfiguredThing(ThingId(est));
|
||||||
|
Thing *tOuest = tm->findConfiguredThing(ThingId(ouest));
|
||||||
|
QVERIFY(tEst && tOuest && !site.isNull());
|
||||||
|
tEst->setParentId(ThingId(site));
|
||||||
|
tOuest->setParentId(ThingId(site));
|
||||||
|
// Détacher les plans quoi qu'il arrive : un enfant restant ferait échouer le nettoyage.
|
||||||
|
const auto detacher = qScopeGuard([&]() {
|
||||||
|
tEst->setParentId(ThingId());
|
||||||
|
tOuest->setParentId(ThingId());
|
||||||
|
arb->setForecastSiteId(QString());
|
||||||
|
});
|
||||||
|
// L'identifiant AVEC accolades, tel que `candidateSiteThingIds` le rend. ⚠️ Sans accolades,
|
||||||
|
// la désignation est acceptée mais le snapshot reste muet : `forecastSnapshot()` compare des
|
||||||
|
// CHAÎNES (défaut préexistant, signalé dans le rapport des décisions du 2026-10-13).
|
||||||
|
const QString siteId = ThingId(site).toString();
|
||||||
|
|
||||||
|
const qint64 t0 = QDateTime(QDate(2026, 10, 13), QTime(10, 0), QTimeZone::utc()).toSecsSinceEpoch();
|
||||||
|
auto charge = [&](const QVariantList &corrections, const QVariantList &p) {
|
||||||
|
return QVariantMap{{"v", 2}, {"unit", "W"}, {"convention", "mean over [t, t+step)"},
|
||||||
|
{"t0", t0}, {"step", 3600}, {"issuedAt", t0 - 3600},
|
||||||
|
{"model", "meteofrance_seamless"}, {"p", p},
|
||||||
|
{"corrections", corrections}};
|
||||||
|
};
|
||||||
|
auto snapshot = [&]() {
|
||||||
|
return injectAndWait("NymeaEnergy.GetForecastSnapshot").toMap().value("params").toMap();
|
||||||
|
};
|
||||||
|
// `perField` rendu sous forme { id → corrections }, l'ordre étant vérifié à part.
|
||||||
|
auto parPlan = [](const QVariantMap &snap) {
|
||||||
|
QMap<QUuid, QStringList> m;
|
||||||
|
for (const QVariant &e : snap.value("perField").toList())
|
||||||
|
m.insert(e.toMap().value("id").toUuid(), e.toMap().value("corrections").toStringList());
|
||||||
|
return m;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Plans DIVERGENTS : l'un corrigé en température, l'autre brut. Plafond 2000 W.
|
||||||
|
tEst->setStateValue("forecastCurveTransport", charge({"temperature"}, {1500, 1200}));
|
||||||
|
tOuest->setStateValue("forecastCurveTransport", charge({}, {1200, 300}));
|
||||||
|
QVariantMap r = injectAndWait("NymeaEnergy.SetForecastSite",
|
||||||
|
{{"forecastSiteThingId", siteId}, {"acLimitW", 2000}}).toMap();
|
||||||
|
QCOMPARE(r.value("params").toMap().value("energyError").toString(), QStringLiteral("EnergyErrorNoError"));
|
||||||
|
QVariantMap snap = snapshot();
|
||||||
|
QVERIFY2(snap.contains("forecastPoints"), qUtf8Printable(QJsonDocument::fromVariant(snap).toJson()));
|
||||||
|
const QVariantList pts = snap.value("forecastPoints").toList();
|
||||||
|
QCOMPARE(pts.count(), 2);
|
||||||
|
QCOMPARE(pts.at(0).toMap().value("forecastWh").toDouble(), 2000.0); // 2700 écrêté
|
||||||
|
QCOMPARE(pts.at(1).toMap().value("forecastWh").toDouble(), 1500.0);
|
||||||
|
// Le site : l'intersection, plus le plafond. La température N'Y EST PAS — un plan ne l'a pas.
|
||||||
|
QCOMPARE(snap.value("corrections").toStringList(), QStringList{"ac-limit"});
|
||||||
|
QCOMPARE(snap.value("conversion").toString(), QStringLiteral("physics"));
|
||||||
|
QCOMPARE(snap.value("acLimitW").toInt(), 2000);
|
||||||
|
// Le détail : une entrée par plan sommé, et la divergence se lit dans les données.
|
||||||
|
QMap<QUuid, QStringList> plans = parPlan(snap);
|
||||||
|
QCOMPARE(plans.count(), 2);
|
||||||
|
QCOMPARE(plans.value(est), QStringList{"temperature"});
|
||||||
|
QCOMPARE(plans.value(ouest), QStringList{});
|
||||||
|
// ac-limit est une correction du SITE : elle n'apparaît dans aucune entrée de plan.
|
||||||
|
for (const QStringList &c : plans)
|
||||||
|
QVERIFY(!c.contains("ac-limit"));
|
||||||
|
// Trié par identifiant : un instantané se compare.
|
||||||
|
const QVariantList brut = snap.value("perField").toList();
|
||||||
|
QVERIFY(brut.at(0).toMap().value("id").toUuid().toString()
|
||||||
|
< brut.at(1).toMap().value("id").toUuid().toString());
|
||||||
|
|
||||||
|
// 2. Plans CONCORDANTS, sans plafond : la température remonte au site, `ac-limit` disparaît,
|
||||||
|
// et `acLimitW` est omis — jamais 0.
|
||||||
|
tOuest->setStateValue("forecastCurveTransport", charge({"temperature"}, {1200, 300}));
|
||||||
|
r = injectAndWait("NymeaEnergy.SetForecastSite", {{"forecastSiteThingId", siteId}}).toMap();
|
||||||
|
QCOMPARE(r.value("params").toMap().value("energyError").toString(), QStringLiteral("EnergyErrorNoError"));
|
||||||
|
snap = snapshot();
|
||||||
|
QCOMPARE(snap.value("corrections").toStringList(), QStringList{"temperature"});
|
||||||
|
QVERIFY(!snap.contains("acLimitW"));
|
||||||
|
QCOMPARE(snap.value("forecastPoints").toList().at(0).toMap().value("forecastWh").toDouble(), 2700.0);
|
||||||
|
plans = parPlan(snap);
|
||||||
|
QCOMPARE(plans.value(est), QStringList{"temperature"});
|
||||||
|
QCOMPARE(plans.value(ouest), QStringList{"temperature"});
|
||||||
|
|
||||||
|
// 3. Un plan REFUSÉ : ni courbe, ni détail par plan — le motif seul.
|
||||||
|
tOuest->setStateValue("forecastCurveTransport", charge({"inconnue"}, {1200, 300}));
|
||||||
|
snap = snapshot();
|
||||||
|
QCOMPARE(snap.value("fault").toString(), QString::fromLatin1(DecisionCode::ForecastPayloadRefused));
|
||||||
|
QVERIFY(!snap.contains("forecastPoints"));
|
||||||
|
QVERIFY(!snap.contains("perField"));
|
||||||
|
#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.
|
||||||
|
|||||||
@ -213,6 +213,7 @@ private slots:
|
|||||||
void testSpotMarketIsOutOfTheBuild();
|
void testSpotMarketIsOutOfTheBuild();
|
||||||
//! Brief 3 T1 — acLimitW voyage avec la désignation de site, absent = aucun plafond.
|
//! Brief 3 T1 — acLimitW voyage avec la désignation de site, absent = aucun plafond.
|
||||||
void testTheSiteAcLimitTravelsWithTheDesignation();
|
void testTheSiteAcLimitTravelsWithTheDesignation();
|
||||||
|
void testTheSnapshotPublishesEachPlanesCorrections();
|
||||||
//! 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.
|
||||||
|
|||||||
@ -439,6 +439,14 @@ void IntegrationPluginEnergyMocks::setupThing(ThingSetupInfo *info)
|
|||||||
info->finish(Thing::ThingErrorNoError);
|
info->finish(Thing::ThingErrorNoError);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
} else if (thing->thingClassId() == pvPlaneThingClassId) {
|
||||||
|
// PLAN PV — un canal `forecastCurveTransport`, rien d'autre. Le test le RANGE sous un
|
||||||
|
// site (`Thing::setParentId`) et y écrit la charge utile lui-même : sans cette classe,
|
||||||
|
// aucun test ne pouvait produire une courbe de prévision, et le snapshot n'était exercé
|
||||||
|
// que dans ses cas vides.
|
||||||
|
info->finish(Thing::ThingErrorNoError);
|
||||||
|
return;
|
||||||
|
|
||||||
} else if (thing->thingClassId() == powerSwitchThingClassId) {
|
} else if (thing->thingClassId() == powerSwitchThingClassId) {
|
||||||
EnergyMockController *controller = new EnergyMockController(thing, this);
|
EnergyMockController *controller = new EnergyMockController(thing, this);
|
||||||
ParamType paramType = thing->thingClass().paramTypes().findByName("port");
|
ParamType paramType = thing->thingClass().paramTypes().findByName("port");
|
||||||
|
|||||||
@ -1211,8 +1211,28 @@
|
|||||||
"writable": true
|
"writable": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "pvPlane",
|
||||||
|
"displayName": "Mocked PV plane (canal forecastCurveTransport)",
|
||||||
|
"id": "4e9b2f71-8c3a-4d56-a1e0-7f2b9c6d3e18",
|
||||||
|
"createMethods": [
|
||||||
|
"user"
|
||||||
|
],
|
||||||
|
"paramTypes": [
|
||||||
|
],
|
||||||
|
"stateTypes": [
|
||||||
|
{
|
||||||
|
"id": "6a1c3e85-2d7f-4b90-9e14-0c8a5f3b7d22",
|
||||||
|
"name": "forecastCurveTransport",
|
||||||
|
"displayName": "Forecast curve — transport channel, not a value",
|
||||||
|
"type": "QVariantMap",
|
||||||
|
"defaultValue": {},
|
||||||
|
"cached": false
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user