From 5125cb9d3efb7a45ab58e75f3129ff398cdcc74a Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Wed, 9 Sep 2026 09:22:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(pr=C3=A9vision=20PV):=20GetForecastSnapsho?= =?UTF-8?q?t=20=E2=80=94=20la=20table=20ou=20la=20d=C3=A9cision,=20jamais?= =?UTF-8?q?=20les=20deux?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetPvForecast sert la TABLE ; GetForecastSnapshot sert la DÉCISION. Un arbitre qui lit la table lirait sa propre trace au lieu de son entrée : il ne mesure plus, il se relit. Le snapshot lit le canal, directement, sans persistance. Écrit maintenant parce que la distinction est nette maintenant : dans six semaines, le premier consommateur prendrait GetPvForecast sans y penser. LES DEUX ABSENCES, TRANCHÉES. Aucun candidat : silence. Site désignable non désigné : MISSING_FORECAST_SITE. Charge utile refusée : FORECAST_PAYLOAD_REFUSED avec sa cause. Canal muet ou plans de runs différents : ni courbe ni motif. Dans tous les cas forecastPoints est ABSENT, jamais une liste de zéros — une prévision manquante et une prévision nulle sont deux affirmations différentes. Deux motifs, deux gestes : configurer, ou regarder le producteur. DATER LE CONTENU, PAS SEULEMENT LA LECTURE. capturedAt date la réponse et distingue « pas répondu » de « répondu, et rien ». Mais un producteur FIGÉ se lirait comme une prévision stable — le silence qui ressemble à un résultat, sous sa forme la plus coûteuse puisque celui qui le lit décide d'ACHETER. D'où contentChangedAt, qui vient du magasin non comme entrée mais parce qu'il SURVIT AU REDÉMARRAGE : en mémoire, il se serait remis à zéro à chaque relance, et un producteur figé aurait paru frais après le premier redémarrage venu. Omis quand le magasin n'a jamais vu ce contenu, plutôt que deviné. ET UN MÉCANISME ENTIER ÉTAIT EXCLU DU BANC D'ESSAI PAR UNE ACCOLADE. En écrivant le test de frontière, forecastStore() s'est révélé NUL : le magasin était créé dans un #ifndef ENERGY_SIMULATION, par accident de placement — ajouté à la suite du battement de télémétrie, qui lui est bien un déclencheur d'horloge murale. Toute la prévision était donc absente du binaire de test. Aucune suite ne pouvait l'exercer à travers l'arbitre, et le balayage des getters voyait GetPvForecast répondre correctement… par sa branche « magasin absent ». Un mécanisme entier hors du banc, tous les voyants au vert. Le test qui l'a trouvé ne le cherchait pas : il voulait vérifier une frontière et a buté sur l'absence de ce qu'il devait franchir. Le magasin sort de la garde — il n'est pas un déclencheur d'horloge murale. Sa purge, si, et elle y reste. loadmodel 31/31, charging 48/48, spotmarket 32/32, simulation 71/71, ci-quality 4/4, build 0 erreur 0 warning. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p --- docs/DESIGN_PREVISION_PV.md | 68 +++++++++++++++ energyplugin/etm/energyarbitrator.cpp | 100 +++++++++++++++++++--- energyplugin/etm/energyarbitrator.h | 17 ++++ energyplugin/etm/types/decisionreason.cpp | 6 ++ energyplugin/etm/types/decisionreason.h | 13 +++ energyplugin/nymeaenergyjsonhandler.cpp | 84 ++++++++++++++++++ energyplugin/nymeaenergyjsonhandler.h | 3 + tests/auto/simulation/simulation.cpp | 57 ++++++++++++ tests/auto/simulation/simulation.h | 2 + 9 files changed, 339 insertions(+), 11 deletions(-) diff --git a/docs/DESIGN_PREVISION_PV.md b/docs/DESIGN_PREVISION_PV.md index c8875a6..f8dfab4 100644 --- a/docs/DESIGN_PREVISION_PV.md +++ b/docs/DESIGN_PREVISION_PV.md @@ -1204,3 +1204,71 @@ l'absence de mesure ont la même apparence. d'observation pendant laquelle un second plan existe. Le seul relevé exploitable à ce jour reste celui de 08:08 — deux plans, même `issuedAt`, même `t0`, somme correcte — et il a été obtenu par le chemin **forcé**, pas par la boucle horaire naturelle. + + +--- + +## 26. `GetForecastSnapshot` — la table ou la décision, jamais les deux + +**Deux méthodes, deux usages, et la phrase qui les sépare tient au contrat :** + +> **`GetPvForecast` sert la TABLE. `GetForecastSnapshot` sert la DÉCISION.** +> Un arbitre qui lit la table lirait **sa propre trace au lieu de son entrée** : il ne mesure +> plus, il se relit. + +Le snapshot lit **le canal**, directement, sans persistance. Écrit maintenant parce que la +distinction est nette maintenant — dans six semaines, le premier consommateur prendrait +`GetPvForecast` sans y penser, et le piège se serait refermé sur lui. + +### 26.1 Les deux absences, tranchées + +| état | réponse | +|---|---| +| aucun candidat, aucun site | **silence** — ni courbe, ni motif | +| site désignable mais non désigné | `fault: MISSING_FORECAST_SITE` + `candidates` | +| charge utile **refusée** (version, unité, forme) | `fault: FORECAST_PAYLOAD_REFUSED` + `cause` | +| canal muet | ni courbe, ni motif | +| plans de runs différents | ni courbe, ni motif — on ne somme pas un mélange | + +**Dans tous les cas, `forecastPoints` est ABSENT — jamais une liste de zéros.** *Une prévision +manquante et une prévision nulle sont deux affirmations différentes*, et c'est le motif qui les +sépare. + +Et **deux motifs, deux gestes** : configurer d'un côté, **regarder le producteur** de l'autre. +Les confondre enverrait chercher un réglage là où c'est le plugin météo qui parle mal. + +### 26.2 Dater le CONTENU, pas seulement la lecture + +`capturedAt` est **toujours** rendu : il date la **réponse**, et distingue « le moteur n'a pas +répondu » de « le moteur a répondu, et il n'a rien ». + +Mais il ne suffit pas. **Un producteur FIGÉ se lirait comme une prévision stable** — l'instrument +dont le silence ressemble à un résultat, sous sa forme **la plus coûteuse** : celui qui le lit +décide d'**acheter**. + +D'où `contentChangedAt` : **quand le contenu courant est apparu**. Il vient du magasin — non pas +comme entrée, mais parce qu'il **survit au redémarrage**. Le garder en mémoire l'aurait remis à +zéro à chaque relance, c'est-à-dire qu'un producteur figé aurait **paru frais après le premier +redémarrage venu** — la protection annulée par le geste le plus banal. + +Il est **omis** quand le magasin n'a jamais vu ce contenu, plutôt que deviné. + +### 26.3 Un mécanisme entier était exclu du banc d'essai par une accolade + +En écrivant le test de frontière, `arb->forecastStore()` s'est révélé **nul**. Le magasin était +créé à l'intérieur d'un `#ifndef ENERGY_SIMULATION` — par **accident de placement**, ajouté à la +suite du battement de télémétrie qui, lui, est bien un déclencheur d'horloge murale. + +**Conséquence : toute la prévision était absente du binaire de test.** Aucune suite ne pouvait +l'exercer à travers l'arbitre, et le balayage des getters voyait `GetPvForecast` répondre +correctement… **par sa branche « magasin absent »**. + +> **Un mécanisme entier hors du banc d'essai, et tous les voyants au vert.** Le test qui l'a +> trouvé ne le cherchait pas : il voulait vérifier une frontière, et il a buté sur l'absence de +> ce qu'il devait franchir. + +Corrigé : le magasin est créé **hors garde** — il n'est pas un déclencheur d'horloge murale et +rien ne justifiait qu'il y soit. **Sa purge, si**, et elle y reste. + +*(La suite de simulation passe de 69 à 71 tests, et deux d'entre eux exercent enfin du code qui +tournait en production sans jamais être exécuté ici.)* diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index dacd4d5..cb3afef 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -53,6 +53,23 @@ EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm, // et reste testable par injection (symétrique de simulationCallUpdate). Seuls les // DÉCLENCHEURS RÉELS (signal + QTimer, horloge murale) sont câblés ici, et exclus en // simulation — comme les connexions amont powerBalanceEntryAdded→update() (SCM l.108-130). + // L'HISTORIQUE DES PRÉVISIONS PV — HORS de la garde de simulation, et c'est un correctif. + // + // Il y était, par accident de placement : ajouté à la suite du battement de télémétrie, il + // s'est retrouvé sous `#ifndef ENERGY_SIMULATION`. Conséquence — TOUTE la prévision était + // absente du binaire de test, et aucune suite ne pouvait l'exercer à travers l'arbitre. Le + // balayage des getters voyait `GetPvForecast` répondre correctement… par sa branche + // « magasin absent ». Un mécanisme entier exclu du banc d'essai par une accolade. + // + // 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")); + } + #ifndef ENERGY_SIMULATION m_lastMeterUpdate = QDateTime::currentDateTime(); // grâce au démarrage (évite un dégradé immédiat) // Fraîcheur picotée sur powerBalanceChanged (en plus de la connexion amont L4). @@ -73,17 +90,10 @@ EnergyArbitrator::EnergyArbitrator(EnergyManager *em, ThingManager *tm, connect(m_telemetryHeartbeat, &QTimer::timeout, this, &EnergyArbitrator::publishTelemetryHeartbeat); m_telemetryHeartbeat->start(); - // L'historique des prévisions PV. Créé ici parce que sa DURÉE DE VIE est celle de l'arbitre - // — pas parce qu'il décide : il n'entre dans aucun plan, ne pondère aucune échéance. - // La purge tourne une fois par jour ; une table qui ne dit pas quand elle purge finit par - // le décider toute seule un jour de disque plein. - m_forecastStore = new ForecastStore(ForecastStore::cheminParDefaut(), this); - { - EnergySettings reglages; - m_forecastSiteId = reglages.value(QStringLiteral("forecastSiteId")).toString(); - m_forecastSiteChoisi = reglages.contains(QStringLiteral("forecastSiteChosen")); - } - if (m_forecastStore->isValid()) { + if (m_forecastStore && m_forecastStore->isValid()) { + // La PURGE reste sous la garde : c'est un déclencheur d'horloge murale, comme le + // watchdog et le battement. Le MAGASIN, lui, n'en est pas un — il est créé plus bas, + // hors garde. m_forecastPurge = new QTimer(this); m_forecastPurge->setInterval(24 * 3600 * 1000); connect(m_forecastPurge, &QTimer::timeout, this, @@ -2319,6 +2329,74 @@ bool EnergyArbitrator::setForecastSiteId(const QString &siteId) return true; } +ForecastRevision EnergyArbitrator::forecastSnapshot(QString &fault, QVariantMap &faultParams) const +{ + fault.clear(); + faultParams.clear(); + ForecastRevision vide; + if (!m_tm) + return vide; + + // AUCUN SITE DÉSIGNÉ — même règle que MISSING_FORECAST_SITE, et pour la même raison : le + // motif ne sort que s'il y a un GESTE possible. Sans candidat, l'absence de désignation + // n'est pas un manque, c'est une installation sans prévision. + if (m_forecastSiteId.isEmpty()) { + if (!forecastSiteCandidates().isEmpty()) { + fault = QString::fromLatin1(DecisionCode::MissingForecastSite); + faultParams.insert(QStringLiteral("candidates"), forecastSiteCandidates().count()); + } + return vide; + } + + // LA COURBE VIENT DU CANAL, pas de la table. C'est toute la frontière. + QList plans; + QString refus; + 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é : une ABSENCE, pas une erreur + QString r; + const ForecastRevision rev = ForecastStore::depuisTransport(charge, r); + if (!r.isEmpty()) { refus = r; continue; } + plans.append(rev); + } + + // UNE CHARGE UTILE REFUSÉE N'EST PAS UNE PRÉVISION NULLE, et le motif existe pour que + // l'écran ne puisse pas les confondre. Un plan refusé interdit aussi la somme : additionner + // le reste publierait une partie du toit sous le nom du tout. + if (!refus.isEmpty()) { + fault = QString::fromLatin1(DecisionCode::ForecastPayloadRefused); + faultParams.insert(QStringLiteral("cause"), refus); + return vide; + } + if (plans.isEmpty()) + return vide; // le canal se tait : absence, sans motif ni courbe + + const QDateTime run = plans.first().issuedAt; + for (const ForecastRevision &r : plans) + if (r.issuedAt != run) + return vide; // plans de runs différents : on ne somme pas + + ForecastRevision site; + site.siteId = m_forecastSiteId; + site.issuedAt = run; + site.model = plans.first().model; + QMap somme; + for (const ForecastRevision &r : plans) + for (const ForecastPoint &p : r.points) + somme[p.targetAt.toSecsSinceEpoch()] += p.wh; + for (auto it = somme.constBegin(); it != somme.constEnd(); ++it) { + ForecastPoint p; + p.targetAt = QDateTime::fromSecsSinceEpoch(it.key(), QTimeZone::utc()); + p.wh = it.value(); + site.points.append(p); + } + return site; +} + void EnergyArbitrator::lireCourbePrevision() { if (!m_forecastStore || !m_forecastStore->isValid() || !m_tm) diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index 31e9cde..5af3bed 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 "types/decisionreason.h" #include "types/ecoprogress.h" #include "types/ecotarget.h" @@ -103,6 +104,22 @@ public: */ QStringList forecastSiteCandidates() const; + /*! + * \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. + * \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.** + * + * \par Pourquoi elle ne lit PAS la table + * \c GetPvForecast sert la TABLE — l'historique, ses révisions, sa reproductibilité. + * Celle-ci sert la DÉCISION. **Un arbitre qui lit la table lirait sa propre trace au lieu + * de son entrée : il ne mesure plus, il se relit.** La distinction est écrite maintenant + * 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; + /*! * \brief Construit l'arbitre central. Mêmes dépendances que \c SmartChargingManager amont, * dont il hérite (justification : AGENTS.md, « 3b-iii »). diff --git a/energyplugin/etm/types/decisionreason.cpp b/energyplugin/etm/types/decisionreason.cpp index 0988dfe..b9a35b4 100644 --- a/energyplugin/etm/types/decisionreason.cpp +++ b/energyplugin/etm/types/decisionreason.cpp @@ -224,6 +224,12 @@ QString renderFr(const DecisionReason &reason, const QString &label) "disponibles dans la configuration").arg(n); } + if (c == QLatin1String(DecisionCode::ForecastPayloadRefused)) + // LE GESTE : ce n'est pas une configuration à faire, c'est un producteur à regarder. + return QStringLiteral("Prévision illisible (%1) — la courbe n'est pas absente, elle est " + "REFUSÉE ; regarder le plugin météo") + .arg(p.value(QStringLiteral("cause")).toString()); + // --- Sécurité et cycle de vie ------------------------------------------ if (c == QLatin1String(DecisionCode::DegradedL2)) return QStringLiteral("Compteur muet depuis >90 s — consigne de repli (L2 watchdog)"); diff --git a/energyplugin/etm/types/decisionreason.h b/energyplugin/etm/types/decisionreason.h index 25fe1ce..5441420 100644 --- a/energyplugin/etm/types/decisionreason.h +++ b/energyplugin/etm/types/decisionreason.h @@ -295,6 +295,19 @@ constexpr const char *DerogationEnded = "DEROGATION_ENDED"; */ constexpr const char *MissingForecastSite = "MISSING_FORECAST_SITE"; +/*! + * \brief Le canal de prévision publie, mais sa charge utile est ILLISIBLE. + * params : \c cause — le motif de refus (\c VERSION_x, \c UNIT_x, \c MALFORMED, \c EMPTY). + * + * \note Distinct de \c MISSING_FORECAST_SITE : là, personne n'a désigné ; ici, quelqu'un publie + * quelque chose qu'on ne sait pas lire. **Deux causes, deux gestes** — configurer d'un côté, + * regarder le producteur de l'autre. + * + * \note Et distinct d'une prévision NULLE : une courbe absente n'est pas une courbe à zéro. Le + * motif existe précisément pour que l'écran ne puisse pas confondre les deux. + */ +constexpr const char *ForecastPayloadRefused = "FORECAST_PAYLOAD_REFUSED"; + // --- 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 631a9e0..3fc8d88 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -367,6 +367,36 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana returns.insert("o:fault", enumValueName(String)); // MISSING_FORECAST_SITE registerMethod("GetForecastSite", description, params, returns, Types::PermissionScopeControlThings); + // GetPvForecast sert la TABLE ; GetForecastSnapshot sert la DÉCISION. Un arbitre qui lit la + // table lirait sa propre trace au lieu de son entrée : il ne mesure plus, il se relit. + params.clear(); returns.clear(); + description = "The CURRENT PV forecast, read straight from the channel - never from the " + "stored table. GetPvForecast serves the TABLE (history, revisions, " + "reproducible reads); this one serves the DECISION. An arbiter reading the " + "table would read its own trace instead of its input: it would no longer " + "measure, it would re-read itself. " + "'capturedAt' is when this reply was built. 'contentChangedAt' is when the " + "current content FIRST APPEARED - and it is the one that matters: without it, " + "a FROZEN provider reads as a stable forecast, which is the costliest form of " + "'silence that looks like a result' since whoever reads it decides to BUY. It " + "is omitted when the store has never seen this content, rather than guessed. " + "'forecastPoints' is ABSENT when nothing is readable - never a curve of zeros; " + "a missing forecast and a forecast of zero are different statements. 'fault' " + "carries MISSING_FORECAST_SITE (nobody designated a site, and one exists) or " + "FORECAST_PAYLOAD_REFUSED with its cause (the channel publishes something we " + "cannot read). Two faults, two gestures: configure, or look at the provider."; + QVariantMap snapPoint; + snapPoint.insert("targetAt", enumValueName(String)); + snapPoint.insert("forecastWh", enumValueName(Double)); + returns.insert("capturedAt", enumValueName(String)); + returns.insert("o:contentChangedAt", enumValueName(String)); + returns.insert("o:issuedAt", enumValueName(String)); + returns.insert("o:model", enumValueName(String)); + returns.insert("o:forecastPoints", QVariantList() << snapPoint); + returns.insert("o:fault", enumValueName(String)); + returns.insert("o:faultParams", enumValueName(Variant)); + registerMethod("GetForecastSnapshot", description, params, returns, Types::PermissionScopeControlThings); + params.clear(); returns.clear(); description = "Designate the site whose PV forecast is kept. ALL the PV planes under that " "site are SUMMED - not chosen between: a site's production is physically the " @@ -1160,6 +1190,60 @@ JsonReply *NymeaEnergyJsonHandler::GetForecastSite(const QVariantMap ¶ms) return createReply(ret); } +JsonReply *NymeaEnergyJsonHandler::GetForecastSnapshot(const QVariantMap ¶ms) +{ + Q_UNUSED(params) + QVariantMap ret; + // `capturedAt` est TOUJOURS rendu : il date la réponse, pas la prévision. C'est ce qui + // permet de distinguer « le moteur n'a pas répondu » de « le moteur a répondu, et il n'a + // rien ». + ret.insert(QStringLiteral("capturedAt"), + QDateTime::currentDateTimeUtc().toString(Qt::ISODate)); +#ifdef ETM_ARBITRATOR + EnergyArbitrator *arb = dynamic_cast(m_smartChargingManager); + if (!arb) + return createReply(ret); + QString fault; + QVariantMap faultParams; + const ForecastRevision rev = arb->forecastSnapshot(fault, faultParams); + if (!fault.isEmpty()) { + ret.insert(QStringLiteral("fault"), fault); + if (!faultParams.isEmpty()) + ret.insert(QStringLiteral("faultParams"), faultParams); + return createReply(ret); // et AUCUN forecastPoints : l'absence est le message + } + if (rev.points.isEmpty()) + return createReply(ret); // le canal se tait : ni courbe, ni motif + if (rev.issuedAt.isValid()) + ret.insert(QStringLiteral("issuedAt"), rev.issuedAt.toUTC().toString(Qt::ISODate)); + if (!rev.model.isEmpty()) + ret.insert(QStringLiteral("model"), rev.model); + + // DATER LE CONTENU, pas seulement la lecture. Sans cela, un producteur FIGÉ se lirait comme + // une prévision stable — l'instrument dont le silence ressemble à un résultat, sous sa forme + // la plus coûteuse puisque celui qui le lit décide d'ACHETER. + // + // La date vient du magasin, qui sait quand ce contenu est apparu, et elle SURVIT au + // redémarrage. Le garder en mémoire l'aurait remise à zéro à chaque relance — c'est-à-dire + // qu'un producteur figé aurait paru frais après le premier redémarrage venu. + // La table n'est pas lue comme ENTRÉE : elle ne fournit pas la courbe, seulement sa date. + if (ForecastStore *magasin = arb->forecastStore()) { + if (magasin->isValid() && !rev.points.isEmpty()) { + const QList> revs = magasin->revisions(rev.points.first().targetAt); + for (const auto &r : revs) + if (qAbs(r.second - rev.points.first().wh) <= 0.001) + ret.insert(QStringLiteral("contentChangedAt"), r.first.toUTC().toString(Qt::ISODate)); + } + } + QVariantList pts; + for (const ForecastPoint &p : rev.points) + pts.append(QVariantMap{{"targetAt", p.targetAt.toUTC().toString(Qt::ISODate)}, + {"forecastWh", p.wh}}); + ret.insert(QStringLiteral("forecastPoints"), pts); +#endif + return createReply(ret); +} + JsonReply *NymeaEnergyJsonHandler::SetForecastSite(const QVariantMap ¶ms) { #ifdef ETM_ARBITRATOR diff --git a/energyplugin/nymeaenergyjsonhandler.h b/energyplugin/nymeaenergyjsonhandler.h index 4221a46..775730e 100644 --- a/energyplugin/nymeaenergyjsonhandler.h +++ b/energyplugin/nymeaenergyjsonhandler.h @@ -128,6 +128,9 @@ public: Q_INVOKABLE JsonReply *GetForecastSite(const QVariantMap ¶ms); //! \brief Désigne ce site. \param params \c o:forecastSiteThingId (vide = aucun). //! \return \c energyError — \c ThingNotFound si l'identifiant n'est pas celui d'un Thing. + //! \brief La prévision COURANTE, lue sur le canal — l'entrée d'une décision, pas la trace. + //! \param params Aucun. \return \c capturedAt, et la courbe OU un motif, jamais des zéros. + Q_INVOKABLE JsonReply *GetForecastSnapshot(const QVariantMap ¶ms); Q_INVOKABLE JsonReply *SetForecastSite(const QVariantMap ¶ms); Q_INVOKABLE JsonReply *SetLoadDerogation(const QVariantMap ¶ms); //! \brief Lève une dérogation avant son terme et rend le mode mémorisé. diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 84af2dd..4ce24a7 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -7010,6 +7010,63 @@ void Simulation::testAModeWriteEndsTheDerogationAndKeepsTheChoice() #endif } +void Simulation::testTheSnapshotServesTheChannelNeverTheTable() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // GetPvForecast sert la TABLE ; GetForecastSnapshot sert la DÉCISION. Un arbitre qui lirait + // la table lirait sa propre trace au lieu de son entrée : il ne mesurerait plus, il se + // relirait. Ce test tient la frontière, et les deux absences qui l'accompagnent. + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); + QVERIFY(arb); + + QString motif; QVariantMap params; + + // 1. AUCUN CANDIDAT, AUCUN SITE — SILENCE. Ni courbe, ni motif : une installation sans + // plugin météo n'a rien à désigner, et lui annoncer un manque serait un avertissement + // qu'aucun geste ne peut lever. + QVERIFY(arb->setForecastSiteId(QString())); + const ForecastRevision muet = arb->forecastSnapshot(motif, params); + QVERIFY2(muet.points.isEmpty(), "sans canal, aucune courbe"); + QVERIFY2(motif.isEmpty(), "et AUCUN motif : le silence ne porte que l'ignorance"); + + // 2. LA RÉPONSE RPC PORTE TOUJOURS capturedAt, même vide. C'est ce qui distingue « le moteur + // n'a pas répondu » de « le moteur a répondu, et il n'a rien » — deux états qu'une + // réponse vide confondrait. + const QVariantMap rep = injectAndWait("NymeaEnergy.GetForecastSnapshot") + .toMap().value("params").toMap(); + QVERIFY2(rep.contains(QStringLiteral("capturedAt")), + "capturedAt date la RÉPONSE, pas la prévision — il est toujours là"); + QVERIFY2(!rep.contains(QStringLiteral("forecastPoints")), + "une courbe absente est ABSENTE, jamais une liste de zéros"); + + // 3. CONTRE-ÉPREUVE de la frontière — la table peut contenir une courbe SANS que le + // snapshot en rende une. C'est le cœur : le snapshot lit le canal, pas la table. Si un + // jour quelqu'un le rebranche sur le magasin, cette ligne tombera. + ForecastStore *magasin = arb->forecastStore(); + QVERIFY(magasin && magasin->isValid()); + ForecastRevision fabriquee; + fabriquee.siteId = QStringLiteral("site-fantome"); + fabriquee.issuedAt = QDateTime::currentDateTimeUtc(); + ForecastPoint p; + p.targetAt = QDateTime::currentDateTimeUtc().addSecs(3600); + p.wh = 4242; + fabriquee.points.append(p); + QVERIFY2(magasin->enregistrer(fabriquee), "la table accepte bien cette courbe"); + QVERIFY2(!magasin->courbe(p.targetAt.addSecs(-60), p.targetAt.addSecs(60)).isEmpty(), + "et la table la rend — la contre-épreuve n'est donc pas vide de sens"); + + const ForecastRevision apres = arb->forecastSnapshot(motif, params); + QVERIFY2(apres.points.isEmpty(), + "le snapshot ne doit RIEN rendre : la table n'est pas son entrée"); + QVERIFY(motif.isEmpty()); +#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 7aa748a..6d19108 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -211,6 +211,8 @@ private slots: void testDerogationTakesTheHeadButNotTheSafety(); //! 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. + void testTheSnapshotServesTheChannelNeverTheTable(); void testAModeWriteEndsTheDerogationAndKeepsTheChoice(); // ── §12 / LM-1209 ──────────────────────────────────────────────────────