From 96148b915303c2ca5361c4229cd2a2683fab616c Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Tue, 13 Oct 2026 06:48:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(pr=C3=A9vision=20PV):=20GetForecastSnapsho?= =?UTF-8?q?t=20publie=20les=20corrections=20par=20plan=20(perField,=20D2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/DESIGN_PREVISION_PV.md | 7 ++ docs/OPTIMIZER_PROTOCOL.md | 7 +- energyplugin/etm/energyarbitrator.cpp | 16 ++- energyplugin/etm/energyarbitrator.h | 12 +- energyplugin/nymeaenergyjsonhandler.cpp | 18 ++- tests/auto/common/energytestbase.cpp | 11 ++ tests/auto/common/energytestbase.h | 9 ++ tests/auto/simulation/simulation.cpp | 106 ++++++++++++++++++ tests/auto/simulation/simulation.h | 1 + .../integrationpluginenergymocks.cpp | 8 ++ .../integrationpluginenergymocks.json | 22 +++- 11 files changed, 211 insertions(+), 6 deletions(-) diff --git a/docs/DESIGN_PREVISION_PV.md b/docs/DESIGN_PREVISION_PV.md index 20f47a3..4353d27 100644 --- a/docs/DESIGN_PREVISION_PV.md +++ b/docs/DESIGN_PREVISION_PV.md @@ -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 `conversion`. Correction **avant publication** (`OPTIMIZER_PROTOCOL.md` §0, 1.1) : aucun é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). diff --git a/docs/OPTIMIZER_PROTOCOL.md b/docs/OPTIMIZER_PROTOCOL.md index 1db7bd1..b32784d 100644 --- a/docs/OPTIMIZER_PROTOCOL.md +++ b/docs/OPTIMIZER_PROTOCOL.md @@ -211,7 +211,8 @@ index par index ; `null` dans une case = valeur inconnue — **jamais 0, jamais "acLimitW":8000, "p50W":[ ... ], "perField":[ - {"id":"","label":"Toit sud","peakW":6000,"tiltDeg":35,"azimuthDeg":0,"p50W":[ ... ]} + {"id":"","label":"Toit sud","peakW":6000,"tiltDeg":35,"azimuthDeg":0, + "corrections":["temperature"],"p50W":[ ... ]} ] }, "baseload":{"model":"none","p50W":[null, ...]}, @@ -256,6 +257,10 @@ Règles : - `perField` : optionnel, affichage et diagnostic. Convention d'orientation (Open-Meteo, PVGIS) : `tiltDeg` 0 = horizontal, 90 = vertical ; `azimuthDeg` 0 = sud, −90 = est, +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` | `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) ; diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 42cf51d..4205258 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -2348,10 +2348,13 @@ bool EnergyArbitrator::setForecastSiteId(const QString &siteId, std::optional *parPlan) const { fault.clear(); faultParams.clear(); + if (parPlan) + parPlan->clear(); ForecastRevision vide; if (!m_tm) 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. QList plans; + QList lus; QString refus; for (Thing *thing : m_tm->configuredThings()) { if (thing->parentId().toString() != m_forecastSiteId @@ -2381,6 +2385,7 @@ ForecastRevision EnergyArbitrator::forecastSnapshot(QString &fault, QVariantMap const ForecastRevision rev = ForecastStore::depuisTransport(charge, r); if (!r.isEmpty()) { refus = r; continue; } 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 @@ -2400,6 +2405,15 @@ ForecastRevision EnergyArbitrator::forecastSnapshot(QString &fault, QVariantMap if (site.points.isEmpty()) return vide; 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; } diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index 3686e81..69b379b 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -119,10 +119,19 @@ public: */ 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. * \param[out] fault Code de motif, vide si tout va bien. * \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 * 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 * \c GetPvForecast sans y penser. */ - ForecastRevision forecastSnapshot(QString &fault, QVariantMap &faultParams) const; + ForecastRevision forecastSnapshot(QString &fault, QVariantMap &faultParams, + QList *parPlan = nullptr) const; /*! * \brief Construit l'arbitre central. Mêmes dépendances que \c SmartChargingManager amont, diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index e1cf734..c2e18e6 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -395,7 +395,9 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana "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 " + "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 " "CLIPPED to it."; QVariantMap snapPoint; @@ -407,6 +409,10 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana returns.insert("o:model", enumValueName(String)); returns.insert("o:forecastPoints", QVariantList() << snapPoint); 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:acLimitW", enumValueName(Uint)); returns.insert("o:fault", enumValueName(String)); @@ -1243,7 +1249,8 @@ JsonReply *NymeaEnergyJsonHandler::GetForecastSnapshot(const QVariantMap ¶ms return createReply(ret); QString fault; QVariantMap faultParams; - const ForecastRevision rev = arb->forecastSnapshot(fault, faultParams); + QList parPlan; + const ForecastRevision rev = arb->forecastSnapshot(fault, faultParams, &parPlan); if (!fault.isEmpty()) { ret.insert(QStringLiteral("fault"), fault); 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 // est une affirmation — conversion brute — pas une absence. 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, // hors GPL, §5). Une prévision dégradée se lit dans `corrections`, pas ici (§30.1). ret.insert(QStringLiteral("conversion"), QStringLiteral("physics")); diff --git a/tests/auto/common/energytestbase.cpp b/tests/auto/common/energytestbase.cpp index 81d20d1..cf8586e 100644 --- a/tests/auto/common/energytestbase.cpp +++ b/tests/auto/common/energytestbase.cpp @@ -517,6 +517,17 @@ QUuid EnergyTestBase::addMeterNoEnergy(quint16 port) 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) { QVariantMap portParam; diff --git a/tests/auto/common/energytestbase.h b/tests/auto/common/energytestbase.h index ea6831a..d98e141 100644 --- a/tests/auto/common/energytestbase.h +++ b/tests/auto/common/energytestbase.h @@ -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 » //! (docs/INVENTAIRE_MOCK.md). 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 mockThermalProbeThingClassId = QUuid("37e61ea3-e4b2-4d3e-bc82-66e350c29f66"); @@ -141,6 +142,14 @@ public: QUuid addThermalProbe(quint16 port = 26681); //! Mock thing implémentant l'interface etmvariableload (states currentPowerW / powerSetpoint). 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(); QVariant removeDevice(const QUuid &thingId); diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 8df4a4a..b5c61a6 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -61,6 +61,7 @@ using namespace nymeaserver; #include #include +#include #include #include #include @@ -7211,6 +7212,111 @@ void Simulation::testTheSiteAcLimitTravelsWithTheDesignation() #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(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 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 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() { // LE TROU QUE LE 2026-09-09 A OUVERT. diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 7f56f07..5874348 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -213,6 +213,7 @@ private slots: void testSpotMarketIsOutOfTheBuild(); //! Brief 3 T1 — acLimitW voyage avec la désignation de site, absent = aucun plafond. void testTheSiteAcLimitTravelsWithTheDesignation(); + void testTheSnapshotPublishesEachPlanesCorrections(); //! 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. diff --git a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp index 80f242c..c0c037e 100644 --- a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp +++ b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp @@ -439,6 +439,14 @@ void IntegrationPluginEnergyMocks::setupThing(ThingSetupInfo *info) info->finish(Thing::ThingErrorNoError); 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) { EnergyMockController *controller = new EnergyMockController(thing, this); ParamType paramType = thing->thingClass().paramTypes().findByName("port"); diff --git a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json index 8d4f428..cfd85b4 100644 --- a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json +++ b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json @@ -1211,8 +1211,28 @@ "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 + } + ] } ] } ] -} \ No newline at end of file +}