feat(motifs): OPTIMIZER_DECISION, PRICE_SLOT, knownCodes() et liste blanche optimiseur (brief 4 T5)
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
13114a2695
commit
50a1f91feb
@ -3,6 +3,7 @@
|
||||
|
||||
#include "decisionreason.h"
|
||||
#include "ecoprogress.h"
|
||||
#include <QLocale>
|
||||
#include <QStringList>
|
||||
|
||||
/*
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QVariantMap>
|
||||
|
||||
/*!
|
||||
@ -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);
|
||||
|
||||
}
|
||||
|
||||
/*!
|
||||
|
||||
@ -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
|
||||
|
||||
82
tests/auto/loadmodel/testdecisioncatalogue.cpp
Normal file
82
tests/auto/loadmodel/testdecisioncatalogue.cpp
Normal file
@ -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 <QFile>
|
||||
#include <QRegularExpression>
|
||||
#include <QSet>
|
||||
#include <QTest>
|
||||
|
||||
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<QString> 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<QString>(connus.cbegin(), connus.cend()).size(), connus.size()); // sans doublon
|
||||
QCOMPARE(QSet<QString>(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"));
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user