From 2b83c206e4e006002a52e8c1dccb2abf39a96f6e Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Tue, 13 Oct 2026 09:15:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(pr=C3=A9vision=20PV):=20le=20cycle=20alime?= =?UTF-8?q?nte=20l'enregistrement=20pour=20l'apprentissage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit observerPourApprentissage(now), après lireCourbePrevision() : copies seulement (§11), aucune requête dans update(). Paramètres de conversion lus sur le Thing plan par leur nom, jamais recopiés (§28 décision 1). Minutes du bilan branchées sur EnergyLogs::powerBalanceEntryAdded(1 min), sous la garde de simulation. Mock : pvPlane porte kwp/performanceRatio/tempCoeffPctPerK/noctC ; nouvelle classe solarInverter (smartmeterproducer), seul producteur du mock. Test : testTheLearningLogIsFedByTheCycle. Co-Authored-By: Claude Opus 5.5 --- energyplugin/etm/energyarbitrator.cpp | 71 ++++++++ energyplugin/etm/energyarbitrator.h | 19 +++ tests/auto/common/energytestbase.cpp | 11 ++ tests/auto/common/energytestbase.h | 3 + tests/auto/simulation/simulation.cpp | 155 ++++++++++++++++++ tests/auto/simulation/simulation.h | 2 + .../integrationpluginenergymocks.cpp | 7 + .../integrationpluginenergymocks.json | 59 +++++++ 8 files changed, 327 insertions(+) diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 6ee16c2..04a6953 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -20,6 +20,7 @@ #include "plugininfo.h" #include +#include #include namespace { @@ -64,6 +65,9 @@ EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm, // Le magasin n'est PAS un déclencheur d'horloge murale : rien ne justifiait qu'il y soit. // Sa purge, si — elle reste dans la garde ci-dessous. m_forecastStore = new ForecastStore(ForecastStore::cheminParDefaut(), this); + // L'ENREGISTREMENT POUR L'APPRENTISSAGE — même fichier, sa table, son fil : rien de ce qu'il + // écrit ne passe par le cycle. Hors garde de simulation, pour la même raison que le magasin. + m_forecastLearning = new ForecastLearningLog(ForecastStore::cheminParDefaut(), QTimeZone(), this); relireReglagesPrevision(); #ifndef ENERGY_SIMULATION @@ -96,6 +100,16 @@ EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm, [this]() { m_forecastStore->purger(); }); m_forecastPurge->start(); } + + // LA PRODUCTION MESURÉE, minute par minute, depuis le journal de l'amont : chaque + // échantillon y est déjà une moyenne pondérée dans le temps sur sa minute. Déclencheur + // d'horloge murale (le journal échantillonne sur un minuteur) : sous la garde, et le banc + // d'essai injecte ses minutes par forecastLearningLog()->observerProduction(). + connect(em->logs(), &EnergyLogs::powerBalanceEntryAdded, this, + [this](EnergyLogs::SampleRate rate, const PowerBalanceLogEntry &entry) { + if (rate == EnergyLogs::SampleRate1Min && m_forecastLearning) + m_forecastLearning->observerProduction(entry.timestamp(), entry.production()); + }); #else Q_UNUSED(em) #endif @@ -440,6 +454,7 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime) // vrai — un avertissement qui ne se déclencherait jamais. signalerSiteManquant(); lireCourbePrevision(); + observerPourApprentissage(currentDateTime); SurplusContext ctx = buildContext(currentDateTime); // Les dérogations vivent ENTRE la construction et la décision : elles se terminent sur ce @@ -2518,6 +2533,62 @@ void EnergyArbitrator::lireCourbePrevision() m_forecastStore->enregistrer(site); // un run déjà connu rend faux, sans bruit } +void EnergyArbitrator::observerPourApprentissage(const QDateTime &now) +{ + if (!m_forecastLearning) + return; + QList plans; + int attendus = 0; + std::optional neige; + bool producteur = false; + if (!m_forecastSiteId.isEmpty() && m_tm) { + // Un paramètre LU sur le Thing, par son NOM — paramètres puis réglages. Le producteur est + // la seule source de la conversion (§28 décision 1) : on ne la recopie nulle part. + auto parNom = [](Thing *t, const QString &nom) -> QVariant { + const ParamType pt = t->thingClass().paramTypes().findByName(nom); + if (pt.isValid()) + return t->paramValue(pt.id()); + const ParamType st = t->thingClass().settingsTypes().findByName(nom); + return st.isValid() ? t->setting(st.id()) : QVariant(); + }; + for (Thing *thing : m_tm->configuredThings()) { + if (thing->parentId().toString() != m_forecastSiteId + || !thing->thingClass().hasStateType("forecastCurveTransport")) + continue; + const QVariantMap charge = thing->stateValue("forecastCurveTransport").toMap(); + if (charge.isEmpty()) + continue; // rien publié : absent, comme pour la courbe + ++attendus; // refusé ou non, il fait partie du site + QVariantMap parametres; + for (const QString &nom : {QStringLiteral("kwp"), QStringLiteral("performanceRatio"), + QStringLiteral("tempCoeffPctPerK"), QStringLiteral("noctC")}) { + const QVariant v = parNom(thing, nom); + if (v.isValid()) + parametres.insert(nom, v); + } + QString refus; + ForecastPlanObservation obs = ForecastLearningLog::lirePlan(charge, parametres, refus); + if (!refus.isEmpty()) + continue; // déjà journalisé par lireCourbePrevision() + obs.planId = thing->id().toString(); + plans.append(obs); + } + // LA NEIGE QUI TOMBE, lue sur le site (état `snowfall`, une valeur de modèle) — pas la + // neige qui COUVRE les panneaux, que rien ici ne mesure. + if (Thing *site = m_tm->findConfiguredThing(ThingId(m_forecastSiteId))) { + if (site->thingClass().hasStateType("snowfall")) { + bool ok = false; + const double v = site->stateValue("snowfall").toDouble(&ok); + if (ok) + neige = v; + } + } + producteur = !m_tm->configuredThings().filterByInterface("smartmeterproducer").isEmpty(); + } + m_forecastLearning->observer(now, m_forecastSiteId, plans, attendus, m_forecastAcLimitW, + neige, producteur); +} + void EnergyArbitrator::attacherFinsDerogation(QVariantMap &snap) const { // \rule{LM-1500-f} — FINS DE DÉROGATION, publiées au NIVEAU DE LA TRAME, pas dans loads[]. diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index d1643fc..39335d4 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -12,6 +12,7 @@ #include "types/drawcap.h" #include "types/derogation.h" #include "forecast/forecaststore.h" +#include "forecast/forecastlearninglog.h" #include "types/decisionreason.h" #include "types/ecoprogress.h" #include "types/ecotarget.h" @@ -79,6 +80,14 @@ public: */ ForecastStore *forecastStore() const { return m_forecastStore; } + /*! + * \brief L'enregistrement pour l'apprentissage futur (brief 3 T2). \return Jamais nul. + * \note Même fichier que \c forecastStore(), table à part, fil d'écriture à part. Exposé + * pour le banc d'essai : c'est par lui qu'un test injecte les échantillons d'une minute + * que le journal de l'amont fournit en production. + */ + ForecastLearningLog *forecastLearningLog() const { return m_forecastLearning; } + /*! * \brief Le SITE dont la prévision est retenue. \return ThingId, vide si aucun n'est désigné. * \note Une désignation GLOBALE, sur le modèle de \c Energy.SetRootMeter — même nature : @@ -881,6 +890,14 @@ private: */ void lireCourbePrevision(); + /*! + * \brief Remet à l'enregistrement ce que les plans du site publient, et ce que le site lit. + * \param now Temps du cycle — c'est lui qui dit quels pas sont écoulés. + * \note COPIES seulement : rien ne revient vers le contexte ni le plan (§11). Aucune requête + * n'est exécutée ici ; l'écriture se fait sur le fil de \c ForecastLearningLog. + */ + void observerPourApprentissage(const QDateTime &now); + /*! @@ -952,6 +969,8 @@ private: QHash m_loadAdapters; //! Historique bitemporel des prévisions PV. Possédé ; purgé une fois par jour. ForecastStore *m_forecastStore = nullptr; + //! Enregistrement pour l'apprentissage futur — même fichier, autre table, autre fil. Possédé. + ForecastLearningLog *m_forecastLearning = nullptr; //! Dernier motif de refus vu par Thing — pour n'avertir qu'au CHANGEMENT de motif. QHash m_forecastRefusVu; //! Site désigné ; vide = aucune prévision retenue, et c'est un cas NORMAL, jamais un défaut. diff --git a/tests/auto/common/energytestbase.cpp b/tests/auto/common/energytestbase.cpp index cf8586e..9f012e0 100644 --- a/tests/auto/common/energytestbase.cpp +++ b/tests/auto/common/energytestbase.cpp @@ -528,6 +528,17 @@ QUuid EnergyTestBase::addPvPlane(const QString &nom) return response.toMap().value("params").toMap().value("thingId").toUuid(); } +QUuid EnergyTestBase::addSolarInverter(const QString &nom) +{ + QVariantMap params; + params.insert("thingClassId", mockSolarInverterThingClassId.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 d98e141..e15b0cf 100644 --- a/tests/auto/common/energytestbase.h +++ b/tests/auto/common/energytestbase.h @@ -60,6 +60,7 @@ static QUuid mockPowerSwitchThingClassId = QUuid("841f8905-d1d7-4053-909f-01123b //! (docs/INVENTAIRE_MOCK.md). static QUuid mockDryRelayThingClassId = QUuid("5b2c7e41-9a06-4d3f-b8c1-6e0f2a7d4b39"); static QUuid mockPvPlaneThingClassId = QUuid("4e9b2f71-8c3a-4d56-a1e0-7f2b9c6d3e18"); +static QUuid mockSolarInverterThingClassId = QUuid("7431ecd7-fc90-448e-b2bf-154948af786c"); static QUuid mockMeterNoEnergyThingClassId = QUuid("50dacc7b-7142-4716-8f59-97d6ee81f39c"); static QUuid mockThermalProbeThingClassId = QUuid("37e61ea3-e4b2-4d3e-bc82-66e350c29f66"); @@ -150,6 +151,8 @@ public: * écrit sa charge utile. Sans lui, aucune courbe de prévision n'était productible. */ QUuid addPvPlane(const QString &nom = QStringLiteral("PV plane")); + //! Un onduleur `smartmeterproducer` — la seule classe productrice du mock. + QUuid addSolarInverter(const QString &nom = QStringLiteral("Onduleur")); void removeDevices(); QVariant removeDevice(const QUuid &thingId); diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index e431abe..d934493 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -69,6 +69,10 @@ using namespace nymeaserver; #include #include #include +#include +#include +#include +#include #include @@ -9068,3 +9072,154 @@ void Simulation::testLevelAbsenceAndZeroDifferInMeaning() QVERIFY2(vuUneCharge, "le mode dégradé doit publier les charges, sans leurs niveaux"); #endif } + +void Simulation::testTheLearningLogIsFedByTheCycle() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // Brief 3 T2, de bout en bout : un vrai cycle lit les plans d'un site, leurs PARAMÈTRES sur le + // Thing (jamais recopiés), et remet une ligne par pas écoulé et par plan. Les unitaires de + // loadmodel éprouvent les règles ; celui-ci éprouve le CÂBLAGE. + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); + QVERIFY(arb); + ForecastLearningLog *log = arb->forecastLearningLog(); + QVERIFY(log); + ThingManager *tm = NymeaCore::instance()->thingManager(); + const QUuid site = addMeter(); + const QUuid sud = addPvPlane(QStringLiteral("Toit sud")); + const QUuid est = addPvPlane(QStringLiteral("Toit est")); + Thing *tSud = tm->findConfiguredThing(ThingId(sud)); + Thing *tEst = tm->findConfiguredThing(ThingId(est)); + QVERIFY(tSud && tEst && !site.isNull()); + tSud->setParentId(ThingId(site)); + tEst->setParentId(ThingId(site)); + const auto detacher = qScopeGuard([&]() { + tSud->setParentId(ThingId()); + tEst->setParentId(ThingId()); + arb->setForecastSiteId(QString()); + }); + tSud->setParamValue(QStringLiteral("kwp"), 2.0); + tSud->setParamValue(QStringLiteral("performanceRatio"), 0.85); + tEst->setParamValue(QStringLiteral("kwp"), 1.5); // porte aussi tempCoeff/noct (défauts) + const QString siteId = ThingId(site).toString(); + QVERIFY(arb->setForecastSiteId(siteId, 5000)); + + // Grille ancrée sur l'HORLOGE MURALE + 3 jours : les révisions du site sont datées par + // l'horloge murale (firstSeenAt), et « en vigueur à la veille / à H−1 » doit les trouver. + QDateTime h = QDateTime::currentDateTimeUtc(); + h.setTime(QTime(h.time().hour(), 0)); + const QDateTime t0 = h.addDays(3); + const qint64 t0s = t0.toSecsSinceEpoch(); + auto charge = [&](int v, const QVariantList &p) { + QVariantMap c{{"v", v}, {"unit", "W"}, {"convention", "mean over [t, t+step)"}, + {"t0", t0s}, {"step", 3600}, {"issuedAt", t0s - 3 * 86400}, + {"model", "meteofrance_seamless"}, {"p", p}}; + if (v >= 2) { + c["corrections"] = QVariantList{"temperature"}; + c["gti"] = QVariantList{700, 560, 420}; + c["tempC"] = QVariantList{18.5, 19.0, 19.5}; + } + return c; + }; + tSud->setStateValue("forecastCurveTransport", charge(2, {1500, 1200, 900})); + tEst->setStateValue("forecastCurveTransport", charge(1, {700, 600, 500})); + + auto cycle = [&](const QDateTime &n) { + arb->simulationCallUpdate(n); + QCoreApplication::processEvents(); + }; + auto lignes = [&](qint64 cible) { + QList sortie; + const QString nom = QStringLiteral("lecture-%1").arg(QUuid::createUuid().toString()); + { + QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), nom); + db.setDatabaseName(ForecastStore::cheminParDefaut()); + if (db.open()) { + QSqlQuery q(db); + q.prepare(QStringLiteral("SELECT l.*, (SELECT contentId FROM forecastPv f " + " WHERE f.targetAt = l.targetAt AND f.siteId = l.siteId " + " ORDER BY lastSeenAt DESC LIMIT 1) AS courant " + "FROM forecastLearning l WHERE siteId = ? AND targetAt = ? " + "ORDER BY planId")); + q.addBindValue(siteId); + q.addBindValue(cible); + if (q.exec()) { + while (q.next()) { + QVariantMap m; + const QSqlRecord r = q.record(); + for (int i = 0; i < r.count(); ++i) + m.insert(r.fieldName(i), q.value(i)); + sortie << m; + } + } + db.close(); + } + } + QSqlDatabase::removeDatabase(nom); + return sortie; + }; + + // 1. SANS PRODUCTEUR — les minutes arrivent, mais la « production » du bilan n'est alors + // qu'un zéro de construction : NULL, et la ligne n'est pas valide. + const int lots0 = log->lotsTraites(); + cycle(t0.addSecs(300)); + for (int m = 1; m <= 60; ++m) + log->observerProduction(t0.addSecs(60 * m), -1100); + cycle(t0.addSecs(3600 + 150)); + QTRY_COMPARE(log->lotsTraites(), lots0 + 1); + QList l = lignes(t0s); + QCOMPARE(l.count(), 2); + const QVariantMap rSud = l.at(0).value("planId").toString() == ThingId(sud).toString() ? l.at(0) : l.at(1); + const QVariantMap rEst = l.at(0).value("planId").toString() == ThingId(est).toString() ? l.at(0) : l.at(1); + QCOMPARE(rSud.value("planId").toString(), ThingId(sud).toString()); + QCOMPARE(rEst.value("planId").toString(), ThingId(est).toString()); + // Paramètres LUS sur le Thing plan. + QCOMPARE(rSud.value("kwp").toDouble(), 2.0); + QCOMPARE(rSud.value("performanceRatio").toDouble(), 0.85); + QCOMPARE(rSud.value("tempCoeffPctPerK").toDouble(), -0.37); + QCOMPARE(rSud.value("noctC").toDouble(), 45.0); + QCOMPARE(rSud.value("gti").toDouble(), 700.0); + QCOMPARE(rSud.value("tempC").toDouble(), 18.5); + QCOMPARE(rSud.value("pW").toDouble(), 1500.0); + QCOMPARE(rSud.value("corrections").toString(), QStringLiteral("temperature")); + // v1 : le Thing porte tempCoeff/noct, la charge utile ne les a pas appliqués — NULL. + QCOMPARE(rEst.value("kwp").toDouble(), 1.5); + QCOMPARE(rEst.value("pW").toDouble(), 700.0); + for (const char *c : {"gti", "tempC", "tempCoeffPctPerK", "noctC"}) + QVERIFY2(rEst.value(c).isNull(), c); + for (const QVariantMap &r : {rSud, rEst}) { + QCOMPARE(r.value("acLimitW").toInt(), 5000); + QCOMPARE(r.value("sitePlans").toInt(), 2); + QCOMPARE(r.value("sitePlansExpected").toInt(), 2); + QCOMPARE(r.value("measuredMinutes").toInt(), 60); + QVERIFY(r.value("measuredW").isNull()); + QVERIFY(r.value("measuredSource").isNull()); + QVERIFY(r.value("curtailed").isNull()); + QVERIFY(r.value("snow").isNull()); // le compteur-site n'a pas de snowfall + QCOMPARE(r.value("valid").toInt(), 0); + // Les RENVOIS vers la révision du site écrite par le même cycle — sa clé de contenu. + QVERIFY(!r.value("courant").toString().isEmpty()); + QCOMPARE(r.value("contentIdHourAhead").toString(), r.value("courant").toString()); + QCOMPARE(r.value("contentIdDayAhead").toString(), r.value("courant").toString()); + } + + // 2. AVEC UN ONDULEUR — la même mesure devient une mesure : publiée, sourcée, valide. + addSolarInverter(); + for (int m = 61; m <= 120; ++m) + log->observerProduction(t0.addSecs(60 * m), -1100); + cycle(t0.addSecs(7200 + 150)); + QTRY_COMPARE(log->lotsTraites(), lots0 + 2); + l = lignes(t0s + 3600); + QCOMPARE(l.count(), 2); + for (const QVariantMap &r : l) { + QCOMPARE(r.value("measuredW").toDouble(), 1100.0); + QCOMPARE(r.value("measuredSource").toString(), QStringLiteral("power-balance-1min")); + QCOMPARE(r.value("curtailed").toInt(), 0); + QCOMPARE(r.value("valid").toInt(), 1); + } +#endif +} diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index cc7ef64..3ce1af8 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -216,6 +216,8 @@ private slots: void testTheSnapshotPublishesEachPlanesCorrections(); void testTheForecastSiteIdIsMatchedWhateverItsBraces(); void testAnUnreadableForecastDateIsAnError(); + //! Brief 3 T2 — le cycle alimente l'enregistrement : paramètres du Thing, v1/v2, producteur. + void testTheLearningLogIsFedByTheCycle(); //! 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 c0c037e..d792aa1 100644 --- a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp +++ b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp @@ -447,6 +447,13 @@ void IntegrationPluginEnergyMocks::setupThing(ThingSetupInfo *info) info->finish(Thing::ThingErrorNoError); return; + } else if (thing->thingClassId() == solarInverterThingClassId) { + // ONDULEUR — la seule classe `smartmeterproducer` du mock. Sans elle, « le site a-t-il un + // producteur ? » ne pouvait valoir que NON dans le banc d'essai, et la production + // mesurée de l'enregistrement pour l'apprentissage n'y était jamais publiée. + 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 cfd85b4..a0fc9ea 100644 --- a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json +++ b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json @@ -1220,6 +1220,34 @@ "user" ], "paramTypes": [ + { + "id": "85dc9979-b806-442d-bacd-370c5fb53daf", + "name": "kwp", + "displayName": "Peak power (kWp)", + "type": "double", + "defaultValue": 0 + }, + { + "id": "7d8062fb-bb6d-4ee8-822f-e5b93e9887a9", + "name": "performanceRatio", + "displayName": "Performance ratio", + "type": "double", + "defaultValue": 1.0 + }, + { + "id": "5b294522-7bcf-4518-821b-1bf279528c89", + "name": "tempCoeffPctPerK", + "displayName": "Temperature coefficient (%/K)", + "type": "double", + "defaultValue": -0.37 + }, + { + "id": "ebf95b72-f9ac-4253-bd27-52f0a7ca5eff", + "name": "noctC", + "displayName": "NOCT (°C)", + "type": "double", + "defaultValue": 45 + } ], "stateTypes": [ { @@ -1231,6 +1259,37 @@ "cached": false } ] + }, + { + "name": "solarInverter", + "displayName": "Mocked solar inverter (smartmeterproducer)", + "id": "7431ecd7-fc90-448e-b2bf-154948af786c", + "createMethods": [ + "user" + ], + "interfaces": [ + "smartmeterproducer" + ], + "paramTypes": [ + ], + "stateTypes": [ + { + "id": "de8df554-bffb-4a53-a411-6cbff7f0002b", + "name": "currentPower", + "displayName": "Current power", + "type": "double", + "unit": "Watt", + "defaultValue": 0 + }, + { + "id": "3016a60a-549a-4b40-a37e-8cd3a16a8a26", + "name": "totalEnergyProduced", + "displayName": "Total energy produced", + "type": "double", + "unit": "KiloWattHour", + "defaultValue": 0 + } + ] } ] }