feat(prévision PV): le cycle alimente l'enregistrement pour l'apprentissage

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 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-13 09:15:27 +02:00
parent ff94377267
commit 2b83c206e4
8 changed files with 327 additions and 0 deletions

View File

@ -20,6 +20,7 @@
#include "plugininfo.h"
#include <energymanager.h>
#include <energylogs.h>
#include <QTimer>
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 &currentDateTime)
// 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<ForecastPlanObservation> plans;
int attendus = 0;
std::optional<double> 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[].

View File

@ -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<QString, ILoadAdapter *> 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<QString, QString> m_forecastRefusVu;
//! Site désigné ; vide = aucune prévision retenue, et c'est un cas NORMAL, jamais un défaut.

View File

@ -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;

View File

@ -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);

View File

@ -69,6 +69,10 @@ using namespace nymeaserver;
#include <QSignalSpy>
#include <QProcessEnvironment>
#include <QCoreApplication>
#include <QSqlDatabase>
#include <QSqlQuery>
#include <QSqlRecord>
#include <QUuid>
#include <nymeacore.h>
@ -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<EnergyArbitrator *>(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<QVariantMap> 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<QVariantMap> 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
}

View File

@ -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.

View File

@ -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");

View File

@ -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
}
]
}
]
}