From 50a1f91febd59f8515c1e0f69f40b85f0f0e5440 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Fri, 16 Oct 2026 16:21:11 +0200 Subject: [PATCH] feat(motifs): OPTIMIZER_DECISION, PRICE_SLOT, knownCodes() et liste blanche optimiseur (brief 4 T5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OPTIMIZER_DECISION : code générique d'une action d'optimiseur dont le reasonCode est absent, inconnu ou hors liste blanche ; sans paramètres ni texte (invariant 12). - PRICE_SLOT (priceCtkWh, o:setpointW) : décision prise sur le prix du créneau, demandée par l'app (exigence 5). Aucun émetteur interne — le rule-based ne lit pas le prix (§11). EV_SPOT_MARKET ne convenait pas : il nomme un fournisseur hors du build et ne vaut que pour une borne. - DecisionCode::knownCodes() : la liste énumérable du catalogue (« connu » se lit ici). - DecisionCode::optimizerEmittableCodes() / optimizerMayEmit() : liste blanche E14 — SURPLUS_SETPOINT, SURPLUS_INSUFFICIENT, BELOW_MIN_POWER, EV_DEADLINE, SG_FORCED, SG_RECOMMENDED, SG_NORMAL, ECO_FLOOR_GRID, PRICE_SLOT, OPTIMIZER_DECISION. Jamais la sécurité, les verrous, l'indisponibilité, le plafond, la réserve, la dérogation ni les états du moteur. - renderFr : deux branches ; INTERFACE.md : les deux lignes (libellés FR/DE/EN proposés pour l'app) et l'encadré de la liste blanche. Rien n'appelle ces fonctions : sans endpoint, aucun effet. Tests (loadmodel) : anti-dérive — knownCodes() confronté aux constantes de l'EN-TÊTE lu par le test, chacune avec un rendu autre que « motif inconnu » ; liste blanche ⊂ catalogue et sans aucun code moteur. Contre-épreuves : constante ajoutée sans entrée, branche SG_NORMAL retirée → échec. Co-Authored-By: Claude Opus 5.5 --- energyplugin/etm/types/decisionreason.cpp | 47 +++++++++++ energyplugin/etm/types/decisionreason.h | 50 ++++++++++- tests/auto/loadmodel/loadmodel.pro | 5 +- .../auto/loadmodel/testdecisioncatalogue.cpp | 82 +++++++++++++++++++ tests/auto/loadmodel/testloadmodel.h | 6 ++ 5 files changed, 188 insertions(+), 2 deletions(-) create mode 100644 tests/auto/loadmodel/testdecisioncatalogue.cpp diff --git a/energyplugin/etm/types/decisionreason.cpp b/energyplugin/etm/types/decisionreason.cpp index 0aa136e..282c710 100644 --- a/energyplugin/etm/types/decisionreason.cpp +++ b/energyplugin/etm/types/decisionreason.cpp @@ -3,6 +3,7 @@ #include "decisionreason.h" #include "ecoprogress.h" +#include #include /* @@ -261,6 +262,24 @@ QString renderFr(const DecisionReason &reason, const QString &label) return QStringLiteral("Marché spot non disponible dans ce build — aucun fournisseur " "compilé (invariant 10, aucun flux sortant)"); + // --- Optimiseur externe ------------------------------------------------- + // Aucun texte de l'optimiseur ici : son `reason` libre est journalisé par l'appelant, à côté. + if (c == QLatin1String(DecisionCode::OptimizerDecision)) + return label.isEmpty() ? QStringLiteral("Décision de l'optimiseur (motif hors catalogue)") + : QStringLiteral("Décision de l'optimiseur — %1 (motif hors catalogue)") + .arg(label); + if (c == QLatin1String(DecisionCode::PriceSlot)) { + // Le prix a des décimales (16,96 ct) : écriture française la plus courte, sans zéro inventé. + QLocale fr(QLocale::French); + fr.setNumberOptions(QLocale::OmitGroupSeparator); + const QString prix = fr.toString(p.value(QStringLiteral("priceCtkWh")).toDouble(), 'f', + QLocale::FloatingPointShortest); + if (p.contains(QStringLiteral("setpointW"))) + return QStringLiteral("Prix favorable (%1 ct/kWh) — %2 consigne %3 W") + .arg(prix, label).arg(w("setpointW")); + return QStringLiteral("Prix favorable (%1 ct/kWh) — %2 servie sur ce créneau").arg(prix, label); + } + // --- Sécurité et cycle de vie ------------------------------------------ if (c == QLatin1String(DecisionCode::DegradedL2)) return QStringLiteral("Compteur muet depuis >90 s — consigne de repli (L2 watchdog)"); @@ -282,3 +301,31 @@ QString renderFr(const DecisionReason &reason, const QString &label) return QStringLiteral("(motif inconnu %1%2%3)") .arg(c, kv.isEmpty() ? QString() : QStringLiteral(" "), kv.join(QStringLiteral(", "))); } + +QStringList DecisionCode::knownCodes() +{ + using namespace DecisionCode; + return {EvSpotMarket, EvDeadline, EvSurplus, EvEcoMin, EvIdle, + LoadUnavailable, LockMinOn, LockMinOff, LockMinOffCapped, SurplusSetpoint, + SurplusInsufficient, BatteryReserve, BelowMinPower, EvGridStart, PhaseLimit, DrawCap, + EcoFloorGrid, EcoFloorMet, EcoFloorMissed, RecreditSuspended, DerogationActive, + DerogationEnded, MissingForecastSite, ForecastPayloadRefused, SpotMarketNotInBuild, + ForecastDateUnreadable, TariffRefused, + LockMinStateHold, SgForced, SgRecommended, SgNormal, + OptimizerDecision, PriceSlot, + DegradedL2, SafeStateRelay, SafeStateSetpoint, SafeStateSgReady}; +} + +QStringList DecisionCode::optimizerEmittableCodes() +{ + using namespace DecisionCode; + // EV_SPOT_MARKET n'y est pas : il nomme un fournisseur absent du build et ne vaut que pour une + // borne. Une décision sur le prix s'écrit PRICE_SLOT, pour tout mécanisme. + return {SurplusSetpoint, SurplusInsufficient, BelowMinPower, EvDeadline, + SgForced, SgRecommended, SgNormal, EcoFloorGrid, PriceSlot, OptimizerDecision}; +} + +bool DecisionCode::optimizerMayEmit(const QString &code) +{ + return !code.isEmpty() && optimizerEmittableCodes().contains(code); +} diff --git a/energyplugin/etm/types/decisionreason.h b/energyplugin/etm/types/decisionreason.h index cb9392d..b48a1e6 100644 --- a/energyplugin/etm/types/decisionreason.h +++ b/energyplugin/etm/types/decisionreason.h @@ -3,6 +3,7 @@ #pragma once #include +#include #include /*! @@ -37,7 +38,9 @@ * * Un code par site d'émission — la liste est EXTRAITE des \c QStringLiteral qui existaient * avant la refonte, pas inventée. Ajouter un motif = ajouter une constante ici, une branche - * de rendu dans \c renderFr(), et une ligne au tableau d'\c INTERFACE.md. + * de rendu dans \c renderFr(), une entrée dans \c knownCodes(), et une ligne au tableau + * d'\c INTERFACE.md. Un code qu'un optimiseur externe peut émettre va AUSSI dans + * \c optimizerEmittableCodes() — c'est une décision, pas une conséquence de l'ajout. */ namespace DecisionCode { @@ -355,6 +358,29 @@ constexpr const char *SgRecommended = "SG_RECOMMENDED"; //! params: \c budgetW — état 2 (normal, mains off). constexpr const char *SgNormal = "SG_NORMAL"; +// --- Optimiseur externe (OPTIMIZER_PROTOCOL 1.1 §6, brief 4 T5) ------------ +/*! + * \brief params : — · action d'un optimiseur externe dont le \c reasonCode est absent, inconnu + * du catalogue, ou hors de la liste blanche (\c optimizerEmittableCodes()). + * + * \note **Sans paramètres, et sans texte.** Le \c reason libre du socket va aux traces et au + * journal, jamais dans une charge utile RPC (invariant 12) : le client affiche « décision de + * l'optimiseur » dans sa langue. Des paramètres d'un code inconnu n'auraient aucun sens défini. + */ +constexpr const char *OptimizerDecision = "OPTIMIZER_DECISION"; +/*! + * \brief params : \c priceCtkWh, \c o:setpointW — la charge est servie SUR CE CRÉNEAU parce + * que son prix est favorable (exigence app 5). + * + * \c priceCtkWh est le prix COMPLET payé sur le créneau, celui de \c forecast.price.importCtkWh + * (§5.2) — c'est le chiffre qui rend la décision lisible, et il se vérifie sur la grille publiée. + * + * \note **Aucun émetteur interne** : le rule-based ne lit pas le prix (§11). Ce code n'existe que + * pour un optimiseur, et figure dans sa liste blanche. Distinct d'\c EV_SPOT_MARKET, qui nomme + * un fournisseur hors du build et ne vaut que pour une borne. + */ +constexpr const char *PriceSlot = "PRICE_SLOT"; + // --- Sécurité et cycle de vie ---------------------------------------------- //! params: — · consigne de repli du watchdog L2 (compteur muet, SAFETY.md §L2). constexpr const char *DegradedL2 = "DEGRADED_L2"; @@ -365,6 +391,28 @@ constexpr const char *SafeStateSetpoint = "SAFE_STATE_SETPOINT"; //! params: — · état sûr d'une PAC SG-Ready avant retrait (ECS-413) : état 2, JAMAIS le blocage. constexpr const char *SafeStateSgReady = "SAFE_STATE_SG_READY"; +/*! + * \brief Tous les codes du catalogue, dans l'ordre de déclaration. + * \note Confrontée à CET EN-TÊTE par un test (loadmodel) : une constante sans entrée ici, ou sans + * branche de rendu, le fait échouer. « Connu du catalogue » se lit ici, nulle part ailleurs. + */ +QStringList knownCodes(); + +/*! + * \brief LISTE BLANCHE des codes qu'un optimiseur externe a le droit d'émettre (écart E14). + * + * Un optimiseur décrit SA décision : surplus servi ou insuffisant, plancher hors d'atteinte, + * échéance, état SG-Ready, achat pour une obligation, prix. Il ne parle JAMAIS au nom du moteur : + * sécurité (L2, états sûrs), verrous, indisponibilité, plafond de soutirage, limite de phase, + * réserve batterie, dérogation, obligation tenue ou manquée, recrédit — ce sont des faits que le + * moteur constate, et un optimiseur qui les publierait ferait mentir l'écran sur ce qui protège. + * Tout autre code devient \c OPTIMIZER_DECISION, sans paramètres. + */ +QStringList optimizerEmittableCodes(); + +//! \return Vrai si \p code est dans \c optimizerEmittableCodes(). +bool optimizerMayEmit(const QString &code); + } /*! diff --git a/tests/auto/loadmodel/loadmodel.pro b/tests/auto/loadmodel/loadmodel.pro index e5c0ca6..fcb9f5b 100644 --- a/tests/auto/loadmodel/loadmodel.pro +++ b/tests/auto/loadmodel/loadmodel.pro @@ -12,10 +12,13 @@ TARGET = nymea-energy-loadmodel HEADERS += testloadmodel.h SOURCES += testloadmodel.cpp \ - testoptimizerprotocol.cpp + testoptimizerprotocol.cpp \ + testdecisioncatalogue.cpp # Fichiers de référence du sérialiseur GetPlan (brief 4 T1), lus et — sous ETM_REGENERER_GOLDEN=1 — # réécrits DANS LES SOURCES : une régénération se relit en diff avant d'être commitée. DEFINES += ETM_GOLDEN_DIR=\\\"$$PWD/data/optimizer\\\" +# Le test anti-dérive du catalogue (brief 4 T5) lit l'EN-TÊTE, pas une copie de sa liste. +DEFINES += ETM_DECISIONREASON_H=\\\"$$PWD/../../../energyplugin/etm/types/decisionreason.h\\\" # Une base energy-forecast.sqlite ÉCRITE PAR etm67 (b7317f3) : la migration s'éprouve sur le # vrai fichier, pas sur une imitation (testTheLearningLogMigratesAnEtm67TableInPlace). RESOURCES += loadmodel.qrc diff --git a/tests/auto/loadmodel/testdecisioncatalogue.cpp b/tests/auto/loadmodel/testdecisioncatalogue.cpp new file mode 100644 index 0000000..d347a86 --- /dev/null +++ b/tests/auto/loadmodel/testdecisioncatalogue.cpp @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// +// Brief 4 T5 — catalogue des motifs : liste énumérable, anti-dérive, liste blanche optimiseur. +// Logique pure, comme le reste de cette suite. + +#include "testloadmodel.h" + +#include "etm/types/decisionreason.h" + +#include +#include +#include +#include + +void TestLoadModel::testDecisionCodesListIsComplete() +{ + // ANTI-DÉRIVE : la liste énumérable est confrontée à l'EN-TÊTE lui-même, pas à une copie. + // Une constante ajoutée sans ligne dans knownCodes() fait échouer ce test. + QFile f(QStringLiteral(ETM_DECISIONREASON_H)); + QVERIFY2(f.open(QIODevice::ReadOnly), ETM_DECISIONREASON_H); + const QString source = QString::fromUtf8(f.readAll()); + const int debut = source.indexOf(QStringLiteral("namespace DecisionCode {")); + const int fin = source.indexOf(QStringLiteral("\n}"), debut); + QVERIFY(debut >= 0 && fin > debut); + const QRegularExpression constante(QStringLiteral(R"re(constexpr\s+const\s+char\s*\*\s*\w+\s*=\s*"(\w+)")re")); + QSet declares; + auto it = constante.globalMatch(source.mid(debut, fin - debut)); + while (it.hasNext()) + declares.insert(it.next().captured(1)); + QVERIFY(declares.size() > 30); // garde de vraisemblance : l'expression a bien mordu + + const QStringList connus = DecisionCode::knownCodes(); + QCOMPARE(QSet(connus.cbegin(), connus.cend()).size(), connus.size()); // sans doublon + QCOMPARE(QSet(connus.cbegin(), connus.cend()), declares); + + // Et chacun a une branche de rendu : jamais le repli « motif inconnu ». + for (const QString &code : connus) { + const QString rendu = renderFr(DecisionReason(code.toLatin1().constData()), QStringLiteral("Charge")); + QVERIFY2(!rendu.startsWith(QStringLiteral("(motif inconnu")), qUtf8Printable(code)); + } +} + +void TestLoadModel::testOptimizerMayEmitOnlyWhitelistedCodes() +{ + const QStringList liste = DecisionCode::optimizerEmittableCodes(); + const QStringList connus = DecisionCode::knownCodes(); + for (const QString &c : liste) { + QVERIFY2(connus.contains(c), qUtf8Printable(c)); + QVERIFY(DecisionCode::optimizerMayEmit(c)); + } + // E14 — un optimiseur ne peut pas parler au nom de la sécurité, du cycle de vie, d'un + // verrou, ni d'un état du moteur : ces motifs décrivent ce que le MOTEUR a fait. + for (const char *c : {DecisionCode::DegradedL2, DecisionCode::SafeStateRelay, + DecisionCode::SafeStateSetpoint, DecisionCode::SafeStateSgReady, + DecisionCode::LoadUnavailable, DecisionCode::DrawCap, + DecisionCode::PhaseLimit, DecisionCode::DerogationActive, + DecisionCode::DerogationEnded, DecisionCode::EcoFloorMet, + DecisionCode::EcoFloorMissed, DecisionCode::LockMinOn, + DecisionCode::LockMinOff, DecisionCode::LockMinOffCapped, + DecisionCode::LockMinStateHold, DecisionCode::RecreditSuspended, + DecisionCode::BatteryReserve, DecisionCode::EvGridStart, + DecisionCode::EvSurplus, DecisionCode::EvEcoMin, DecisionCode::EvIdle, + DecisionCode::MissingForecastSite, DecisionCode::ForecastPayloadRefused, + DecisionCode::ForecastDateUnreadable, DecisionCode::TariffRefused, + DecisionCode::SpotMarketNotInBuild}) { + QVERIFY2(!DecisionCode::optimizerMayEmit(QString::fromLatin1(c)), c); + } + QVERIFY(!DecisionCode::optimizerMayEmit(QStringLiteral("CODE_QUI_NEXISTE_PAS"))); + QVERIFY(!DecisionCode::optimizerMayEmit(QString())); + // Le code générique et le motif prix (exigence app 5) en font partie. + QVERIFY(DecisionCode::optimizerMayEmit(QString::fromLatin1(DecisionCode::OptimizerDecision))); + QVERIFY(DecisionCode::optimizerMayEmit(QString::fromLatin1(DecisionCode::PriceSlot))); + + // Rendus : le générique ne porte aucun texte de l'optimiseur ; le prix se lit avec sa valeur. + QCOMPARE(renderFr(DecisionReason(DecisionCode::OptimizerDecision), QStringLiteral("Chauffe-eau")), + QStringLiteral("Décision de l'optimiseur — Chauffe-eau (motif hors catalogue)")); + QCOMPARE(renderFr(DecisionReason(DecisionCode::PriceSlot, {{"priceCtkWh", 16.96}, {"setpointW", 2400}}), + QStringLiteral("Chauffe-eau")), + QStringLiteral("Prix favorable (16,96 ct/kWh) — Chauffe-eau consigne 2400 W")); + QCOMPARE(renderFr(DecisionReason(DecisionCode::PriceSlot, {{"priceCtkWh", 9.5}}), QStringLiteral("PAC")), + QStringLiteral("Prix favorable (9,5 ct/kWh) — PAC servie sur ce créneau")); +} diff --git a/tests/auto/loadmodel/testloadmodel.h b/tests/auto/loadmodel/testloadmodel.h index 3b0d194..9085781 100644 --- a/tests/auto/loadmodel/testloadmodel.h +++ b/tests/auto/loadmodel/testloadmodel.h @@ -149,6 +149,12 @@ private slots: //! Wh par pas → puissance moyenne ; une case sans point reste null. void testForecastWhPerStepBecomesMeanPower(); + // ── Brief 4 T5 — catalogue des motifs (corps dans testdecisioncatalogue.cpp) ── + //! Anti-dérive : knownCodes() == les constantes de l'en-tête, chacune avec un rendu connu. + void testDecisionCodesListIsComplete(); + //! Liste blanche de l'optimiseur (E14) : ni sécurité, ni verrou, ni état du moteur. + void testOptimizerMayEmitOnlyWhitelistedCodes(); + }; #endif // TESTLOADMODEL_H