diff --git a/docs/DESIGN_PREVISION_PV.md b/docs/DESIGN_PREVISION_PV.md index 04a5475..aaecc53 100644 --- a/docs/DESIGN_PREVISION_PV.md +++ b/docs/DESIGN_PREVISION_PV.md @@ -1403,3 +1403,9 @@ restent l'état de leur date, et c'est ici qu'on lit ce qui les corrige.* sur un `Uint` : le paramètre est déclaré `Variant` et sa forme contrôlée par le handler (`null` ou entier, rien d'autre). Choix d'implémentation, signalé : désigner **aucun** site retire aussi le plafond (invariant persisté « plafond ⇒ site »). +4. **Date illisible dans `GetPvForecast` = erreur explicite.** Elle devenait l'epoch 0 en silence, + et un `to` illisible rendait une fenêtre vide, indistinguable de « rien d'enregistré ». `from`, + `to`, `knownAt` illisibles → `energyError: EnergyErrorInvalidParameter` (énumération amont, + rien d'inventé) + `fault: FORECAST_DATE_UNREADABLE`, `faultParams.fields`. Rien n'est lu dans + la table. Le schéma déclaré change, de façon additive : `energyError` (requis, toujours rendu), + `o:fault`, `o:faultParams`. `forecastPoints` reste requis, donc `[]` en erreur. diff --git a/energyplugin/etm/types/decisionreason.cpp b/energyplugin/etm/types/decisionreason.cpp index 3dcc472..a266b19 100644 --- a/energyplugin/etm/types/decisionreason.cpp +++ b/energyplugin/etm/types/decisionreason.cpp @@ -230,6 +230,12 @@ QString renderFr(const DecisionReason &reason, const QString &label) "REFUSÉE ; regarder le plugin météo") .arg(p.value(QStringLiteral("cause")).toString()); + if (c == QLatin1String(DecisionCode::ForecastDateUnreadable)) + // LE GESTE : c'est l'appel qui est faux, pas la table qui est vide. + return QStringLiteral("Date illisible (%1) — attendu un instant ISO 8601 avec décalage, " + "par exemple 2026-10-13T00:00:00Z") + .arg(p.value(QStringLiteral("fields")).toStringList().join(QStringLiteral(", "))); + if (c == QLatin1String(DecisionCode::SpotMarketNotInBuild)) // LE GESTE : aucun réglage ne l'active, c'est le paquet qui ne le contient pas. return QStringLiteral("Marché spot non disponible dans ce build — aucun fournisseur " diff --git a/energyplugin/etm/types/decisionreason.h b/energyplugin/etm/types/decisionreason.h index 22f2aac..c63dd2a 100644 --- a/energyplugin/etm/types/decisionreason.h +++ b/energyplugin/etm/types/decisionreason.h @@ -320,6 +320,17 @@ constexpr const char *ForecastPayloadRefused = "FORECAST_PAYLOAD_REFUSED"; */ constexpr const char *SpotMarketNotInBuild = "SPOT_MARKET_NOT_IN_BUILD"; +/*! + * \brief Une date passée à \c GetPvForecast est ILLISIBLE. + * params : \c fields — la liste des paramètres en cause (\c from, \c to, \c knownAt). + * + * \note Rendu à côté de \c EnergyErrorInvalidParameter. Avant (constaté le 2026-10-12), une date + * illisible devenait l'epoch 0 en silence : un \c to illisible rendait une fenêtre vide, + * indistinguable de « rien d'enregistré ». Une erreur de l'appelant ne doit pas se lire comme + * un état de la table. + */ +constexpr const char *ForecastDateUnreadable = "FORECAST_DATE_UNREADABLE"; + // --- SG-Ready (PAC) --------------------------------------------------------- //! params: \c state — le verrou minStateHold maintient l'état (court-cycling compresseur). constexpr const char *LockMinStateHold = "LOCK_MIN_STATE_HOLD"; diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index 3cd6f49..8c6601b 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -333,7 +333,12 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana "METEOROLOGICAL error, never the CONVERSION bias - shading, soiling, module " "temperature and inverter clipping are as wrong at three hours as at " "thirty-six, and all in the optimistic direction. 'Nearer forecast is more " - "reliable' must NOT be shown as 'nearer figure is more accurate'."; + "reliable' must NOT be shown as 'nearer figure is more accurate'. " + "An UNREADABLE date ('from', 'to' or 'knownAt') is an ERROR, never a window: " + "'energyError' is EnergyErrorInvalidParameter, 'fault' is " + "FORECAST_DATE_UNREADABLE with 'faultParams.fields' naming the parameters, " + "and 'forecastPoints' is empty and MEANS NOTHING - read 'energyError' first. " + "'energyError' is EnergyErrorNoError on every successful read."; params.insert("from", enumValueName(String)); // ISO 8601 UTC params.insert("to", enumValueName(String)); // ISO 8601 UTC params.insert("o:knownAt", enumValueName(String)); // ISO 8601 UTC ; absent = maintenant @@ -346,6 +351,10 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana pointItem.insert("firstSeenAt", enumValueName(String)); // ISO 8601 UTC pointItem.insert("lastSeenAt", enumValueName(String)); // ISO 8601 UTC returns.insert("forecastPoints", QVariantList() << pointItem); + // Ajoutés le 2026-10-13 (D4) : une date illisible était lue comme l'epoch 0, en silence. + returns.insert("energyError", enumRef()); + returns.insert("o:fault", enumValueName(String)); // FORECAST_DATE_UNREADABLE + returns.insert("o:faultParams", enumValueName(Variant)); // {fields: [from|to|knownAt]} registerMethod("GetPvForecast", description, params, returns, Types::PermissionScopeControlThings); // Désignation GLOBALE, sur le modèle d'Energy.SetRootMeter — même nature : une source unique @@ -1181,17 +1190,39 @@ JsonReply *NymeaEnergyJsonHandler::GetLoadTelemetry(const QVariantMap ¶ms) JsonReply *NymeaEnergyJsonHandler::GetPvForecast(const QVariantMap ¶ms) { -#ifdef ETM_ARBITRATOR - EnergyArbitrator *arb = dynamic_cast(m_smartChargingManager); - ForecastStore *magasin = arb ? arb->forecastStore() : nullptr; - if (!magasin || !magasin->isValid()) - return createReply({{"forecastPoints", QVariantList()}}); + const QVariantMap succes{{"forecastPoints", QVariantList()}, + {"energyError", enumValueName(EnergyManager::EnergyErrorNoError)}}; + // LES DATES D'ABORD, avant toute lecture. Une date illisible devenait l'epoch 0 en silence : + // un `to` illisible rendait une fenêtre VIDE, indistinguable de « rien d'enregistré », et un + // `from` illisible l'élargissait au passé entier. C'est une erreur de l'APPELANT, et elle doit + // se lire comme telle — jamais comme un état de la table. const QDateTime de = QDateTime::fromString(params.value("from").toString(), Qt::ISODate); const QDateTime a = QDateTime::fromString(params.value("to").toString(), Qt::ISODate); QDateTime connuA; if (params.contains("knownAt")) connuA = QDateTime::fromString(params.value("knownAt").toString(), Qt::ISODate); + QStringList illisibles; + if (!de.isValid()) illisibles << QStringLiteral("from"); + if (!a.isValid()) illisibles << QStringLiteral("to"); + if (params.contains("knownAt") && !connuA.isValid()) illisibles << QStringLiteral("knownAt"); + if (!illisibles.isEmpty()) { + const QVariantMap motif{{QStringLiteral("fields"), illisibles}}; + qCWarning(dcNymeaEnergy()).noquote() + << QStringLiteral("[Prévision] GetPvForecast refusé : %1 (motif %2)") + .arg(renderFr({ DecisionCode::ForecastDateUnreadable, motif })) + .arg(QLatin1String(DecisionCode::ForecastDateUnreadable)); + return createReply({{"forecastPoints", QVariantList()}, + {"energyError", enumValueName(EnergyManager::EnergyErrorInvalidParameter)}, + {"fault", QLatin1String(DecisionCode::ForecastDateUnreadable)}, + {"faultParams", motif}}); + } + +#ifdef ETM_ARBITRATOR + EnergyArbitrator *arb = dynamic_cast(m_smartChargingManager); + ForecastStore *magasin = arb ? arb->forecastStore() : nullptr; + if (!magasin || !magasin->isValid()) + return createReply(succes); QVariantList points; for (const ForecastPoint &p : magasin->courbe(de, a, connuA)) { @@ -1204,10 +1235,11 @@ JsonReply *NymeaEnergyJsonHandler::GetPvForecast(const QVariantMap ¶ms) {"firstSeenAt", p.firstSeenAt.toUTC().toString(Qt::ISODate)}, {"lastSeenAt", p.lastSeenAt.toUTC().toString(Qt::ISODate)}}); } - return createReply({{"forecastPoints", points}}); + QVariantMap ret = succes; + ret.insert(QStringLiteral("forecastPoints"), points); + return createReply(ret); #else - Q_UNUSED(params) - return createReply({{"forecastPoints", QVariantList()}}); + return createReply(succes); #endif } diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 405a401..1433c63 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -7356,6 +7356,52 @@ void Simulation::testTheSnapshotPublishesEachPlanesCorrections() #endif } +void Simulation::testAnUnreadableForecastDateIsAnError() +{ + // Décision du 2026-10-13 (§30.4). Constaté le 2026-10-12 : une date illisible devenait + // l'epoch 0, en silence — `to` illisible rendait une fenêtre vide, indistinguable de « rien + // d'enregistré ». Une erreur de l'APPELANT se lit désormais comme telle. + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + const QString ok = QStringLiteral("2026-10-13T00:00:00Z"); + const QString ko = QStringLiteral("pas-une-date"); + auto lire = [&](const QVariantMap &p) { + return injectAndWait("NymeaEnergy.GetPvForecast", p).toMap(); + }; + + // 1. Chaque date illisible, seule puis ensemble : erreur de paramètre, motif, champs nommés. + struct Cas { QVariantMap params; QStringList champs; }; + const QList cas { + { {{"from", ko}, {"to", ok}}, {"from"} }, + { {{"from", ok}, {"to", ko}}, {"to"} }, + { {{"from", ok}, {"to", ok}, {"knownAt", ko}}, {"knownAt"} }, + { {{"from", ko}, {"to", ko}, {"knownAt", ko}}, {"from", "to", "knownAt"} }, + { {{"from", ""}, {"to", ok}}, {"from"} }, + }; + for (const Cas &c : cas) { + const QVariantMap r = lire(c.params); + // L'appel n'est PAS rejeté par la validation nymea : c'est le handler qui répond. + QCOMPARE(r.value("status").toString(), QStringLiteral("success")); + const QVariantMap rep = r.value("params").toMap(); + QCOMPARE(rep.value("energyError").toString(), QStringLiteral("EnergyErrorInvalidParameter")); + QCOMPARE(rep.value("fault").toString(), QString::fromLatin1(DecisionCode::ForecastDateUnreadable)); + QCOMPARE(rep.value("faultParams").toMap().value("fields").toStringList(), c.champs); + QVERIFY(rep.value("forecastPoints").toList().isEmpty()); + } + + // 2. Dates lisibles — avec et sans `knownAt`, avec décalage explicite : aucune erreur, aucun + // motif. Une fenêtre vide y veut dire « rien d'enregistré », et seulement cela. + for (const QVariantMap &p : {QVariantMap{{"from", ok}, {"to", "2026-10-14T00:00:00+02:00"}}, + QVariantMap{{"from", ok}, {"to", ok}, {"knownAt", ok}}}) { + const QVariantMap rep = lire(p).value("params").toMap(); + QCOMPARE(rep.value("energyError").toString(), QStringLiteral("EnergyErrorNoError")); + QVERIFY(!rep.contains("fault")); + QVERIFY(!rep.contains("faultParams")); + QVERIFY(rep.contains("forecastPoints")); + } +} + 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 5874348..2553309 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -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 testAnUnreadableForecastDateIsAnError(); //! 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.