fix(prévision PV): l'identifiant du site est normalisé, avec ou sans accolades

SetForecastSite stockait l'identifiant tel que reçu ; forecastSnapshot() et
lireCourbePrevision() le comparent en chaîne à parentId().toString(), qui a
des accolades. Un UUID envoyé sans elles était accepté, puis le snapshot
restait muet et la table vide.

Normalisation par ThingId(...).toString() (siteIdCanonique) à l'écriture
(setForecastSiteId) ET à la relecture des réglages (relireReglagesPrevision,
extraite du constructeur) : une valeur persistée sans accolades par une
version antérieure est relue correctement après mise à jour.

Test testTheForecastSiteIdIsMatchedWhateverItsBraces : forme à accolades,
sans accolades, majuscules (RPC), arbitre direct, valeur persistée relue,
plus une contre-épreuve sur un autre site. Contre-épreuves vérifiées : sans
la normalisation, le test échoue à la première forme sans accolades ; sans
la normalisation à la relecture seule, il échoue sur la valeur persistée.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-13 07:36:04 +02:00
parent c8da4ab2a0
commit 9c440367fa
4 changed files with 134 additions and 13 deletions

View File

@ -64,15 +64,7 @@ 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);
{
EnergySettings reglages;
m_forecastSiteId = reglages.value(QStringLiteral("forecastSiteId")).toString();
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;
}
relireReglagesPrevision();
#ifndef ENERGY_SIMULATION
m_lastMeterUpdate = QDateTime::currentDateTime(); // grâce au démarrage (évite un dégradé immédiat)
@ -2316,8 +2308,34 @@ void EnergyArbitrator::signalerSiteManquant()
.arg(QLatin1String(DecisionCode::MissingForecastSite));
}
bool EnergyArbitrator::setForecastSiteId(const QString &siteId, std::optional<int> acLimitW)
QString EnergyArbitrator::siteIdCanonique(const QString &siteId)
{
// LA FORME DE `parentId().toString()` — accolades, minuscules —, parce que c'est à elle que
// le site est comparé, EN CHAÎNE, dans `forecastSnapshot()` et `lireCourbePrevision()`.
// Constaté le 2026-10-13 : un UUID reçu sans accolades était accepté (`ThingId` le lit),
// stocké tel quel, et ne correspondait ensuite à aucun plan — snapshot muet, table vide.
// Ce qui n'est pas un UUID est rendu tel quel : il ne correspondra à rien, comme avant.
const ThingId id(siteId);
return id.isNull() ? siteId : id.toString();
}
void EnergyArbitrator::relireReglagesPrevision()
{
EnergySettings reglages;
// NORMALISÉE À LA RELECTURE AUSSI : une version antérieure a pu persister la forme sans
// accolades. La réécrire n'est pas nécessaire — la prochaine désignation le fera.
m_forecastSiteId = siteIdCanonique(reglages.value(QStringLiteral("forecastSiteId")).toString());
m_forecastSiteChoisi = reglages.contains(QStringLiteral("forecastSiteChosen"));
m_forecastAcLimitW.reset();
bool ok = false;
const int plafond = reglages.value(QStringLiteral("forecastAcLimitW")).toInt(&ok);
if (ok && plafond >= AcLimitMinW && plafond <= AcLimitMaxW)
m_forecastAcLimitW = plafond;
}
bool EnergyArbitrator::setForecastSiteId(const QString &siteIdRecu, std::optional<int> acLimitW)
{
const QString siteId = siteIdCanonique(siteIdRecu);
if (!siteId.isEmpty() && (!m_tm || !m_tm->findConfiguredThing(ThingId(siteId))))
return false;
// Un plafond est une propriété d'un SITE : sans site, il n'aurait pas d'objet. Et ZÉRO n'est

View File

@ -108,9 +108,30 @@ public:
* \c SetForecastSite, lui, lit « absent = inchangé » : c'est le handler qui fusionne.
* \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é.
* \note L'identifiant est NORMALISÉ (\c siteIdCanonique) avant contrôle, stockage et
* persistance : avec ou sans accolades, majuscules ou non, c'est le même site, et
* \c forecastSiteId() le rend toujours sous la forme \c {uuid} en minuscules.
*/
bool setForecastSiteId(const QString &siteId, std::optional<int> acLimitW = std::nullopt);
/*!
* \brief Forme canonique d'un identifiant de site : celle de \c ThingId::toString().
* \param siteId UUID avec ou sans accolades, casse indifférente ; ou vide.
* \return \c {uuid} en minuscules ; vide si \p siteId est vide ; \p siteId inchangé s'il
* n'est pas un UUID (il ne désignera alors aucun Thing).
* \note Le site est comparé EN CHAÎNE à \c parentId().toString() : toute valeur stockée
* dans \c m_forecastSiteId passe par ici, à l'écriture comme à la relecture.
*/
static QString siteIdCanonique(const QString &siteId);
/*!
* \brief Relit la désignation persistée (site, choix exprimé, plafond AC).
* \note Appelée par le constructeur. Exposée pour le banc : c'est ainsi qu'il prouve qu'une
* valeur persistée par une version antérieure (UUID sans accolades) est relue normalisée,
* sans redémarrer un serveur dont le harnais efface les réglages.
*/
void relireReglagesPrevision();
/*!
* \brief Les sites CANDIDATS — ceux dont au moins un enfant publie une courbe.
* \return Liste de ThingId, vide s'il n'y en a aucun.

View File

@ -7281,9 +7281,8 @@ void Simulation::testTheSnapshotPublishesEachPlanesCorrections()
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).
// L'identifiant AVEC accolades, tel que `candidateSiteThingIds` le rend. La forme sans
// accolades est tenue à part, par `testTheForecastSiteIdIsMatchedWhateverItsBraces`.
const QString siteId = ThingId(site).toString();
const qint64 t0 = QDateTime(QDate(2026, 10, 13), QTime(10, 0), QTimeZone::utc()).toSecsSinceEpoch();
@ -7356,6 +7355,88 @@ void Simulation::testTheSnapshotPublishesEachPlanesCorrections()
#endif
}
void Simulation::testTheForecastSiteIdIsMatchedWhateverItsBraces()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
// Constaté le 2026-10-13 (rapport des décisions, écart n° 1) : `SetForecastSite` stockait
// l'identifiant TEL QUE REÇU, puis le comparait EN CHAÎNE à `parentId().toString()`, qui a
// des accolades. Un UUID envoyé sans elles était accepté — et le snapshot restait MUET, la
// table vide. Une désignation qui a l'air faite et ne fait rien.
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 plan = addPvPlane(QStringLiteral("Toit"));
Thing *tPlan = tm->findConfiguredThing(ThingId(plan));
QVERIFY(tPlan && !site.isNull());
tPlan->setParentId(ThingId(site));
const auto detacher = qScopeGuard([&]() {
tPlan->setParentId(ThingId());
arb->setForecastSiteId(QString());
});
const qint64 t0 = QDateTime(QDate(2026, 10, 13), QTime(10, 0), QTimeZone::utc()).toSecsSinceEpoch();
tPlan->setStateValue("forecastCurveTransport",
QVariantMap{{"v", 2}, {"unit", "W"}, {"convention", "mean over [t, t+step)"},
{"t0", t0}, {"step", 3600}, {"issuedAt", t0 - 3600},
{"model", "meteofrance_seamless"}, {"p", QVariantList{1500, 1200}},
{"corrections", QVariantList{}}});
const QString canonique = ThingId(site).toString(); // {uuid}, minuscules
QVERIFY(canonique.startsWith('{'));
const QString nu = site.toString(QUuid::WithoutBraces);
auto snapshot = [&]() {
return injectAndWait("NymeaEnergy.GetForecastSnapshot").toMap().value("params").toMap();
};
auto courbeLue = [&](const char *forme) {
QString motif; QVariantMap params;
const ForecastRevision rev = arb->forecastSnapshot(motif, params);
QVERIFY2(motif.isEmpty(), qUtf8Printable(QStringLiteral("%1 : motif %2").arg(forme, motif)));
QVERIFY2(rev.points.count() == 2, forme);
QCOMPARE(rev.siteId, canonique);
const QVariantMap snap = snapshot();
QVERIFY2(snap.value("forecastPoints").toList().count() == 2,
qUtf8Printable(QStringLiteral("%1 : %2").arg(forme,
QString::fromUtf8(QJsonDocument::fromVariant(snap).toJson()))));
QCOMPARE(injectAndWait("NymeaEnergy.GetForecastSite").toMap().value("params").toMap()
.value("forecastSiteThingId").toString(), canonique);
QCOMPARE(arb->forecastSiteId(), canonique);
};
// 1. PAR LE RPC, avec puis sans accolades, puis en majuscules : le même site, la même courbe,
// et la désignation est rendue — et persistée — sous UNE seule forme.
for (const QString &forme : {canonique, nu, nu.toUpper()}) {
const QVariantMap r = injectAndWait("NymeaEnergy.SetForecastSite",
{{"forecastSiteThingId", forme}}).toMap();
QCOMPARE(r.value("params").toMap().value("energyError").toString(),
QStringLiteral("EnergyErrorNoError"));
courbeLue(qPrintable(forme));
QCOMPARE(EnergySettings().value("forecastSiteId").toString(), canonique);
}
// 2. Par l'arbitre directement, sans accolades : même normalisation, même chemin.
QVERIFY(arb->setForecastSiteId(nu));
courbeLue("setForecastSiteId sans accolades");
// 3. UNE VALEUR PERSISTÉE PAR UNE VERSION ANTÉRIEURE, sans accolades, puis relue : c'est le
// cas d'une box mise à jour, dont le réglage a été écrit avant le correctif.
QVERIFY(arb->setForecastSiteId(QString()) && arb->forecastSiteId().isEmpty());
EnergySettings().setValue(QStringLiteral("forecastSiteId"), nu);
arb->relireReglagesPrevision();
courbeLue("valeur persistée sans accolades");
// 4. Contre-épreuve : un AUTRE site, sous quelque forme que ce soit, ne lit pas ce plan.
// Le plan lui-même tient lieu d'autre site : un Thing existant, parent d'aucun plan.
QVERIFY(arb->setForecastSiteId(plan.toString(QUuid::WithoutBraces)));
QString motif; QVariantMap params;
QVERIFY(arb->forecastSnapshot(motif, params).points.isEmpty());
#endif
}
void Simulation::testAnUnreadableForecastDateIsAnError()
{
// Décision du 2026-10-13 (§30.4). Constaté le 2026-10-12 : une date illisible devenait

View File

@ -214,6 +214,7 @@ private slots:
//! Brief 3 T1 — acLimitW voyage avec la désignation de site, absent = aucun plafond.
void testTheSiteAcLimitTravelsWithTheDesignation();
void testTheSnapshotPublishesEachPlanesCorrections();
void testTheForecastSiteIdIsMatchedWhateverItsBraces();
void testAnUnreadableForecastDateIsAnError();
//! TOUTE méthode Get* du namespace rend ce que sa déclaration promet.
void testEveryGetterReturnsWhatItDeclares();