Patrick Schurig da6558fa8e Délestage visible : la protection publie ce qu'elle fait (OVERLOAD_THROTTLED, GetSheddingLog)
- Borne : executeChargingAction() devient virtuel (marque [ETM], corps amont
  inchangé). L'arbitre laisse partir la consigne, puis la lit. Une baisse ou
  une coupure de la protection de surcharge ouvre la tenue, et la borne
  publie OVERLOAD_THROTTLED {fromA, toA, cut, overshootW, limitA, o:phase}.
  La tenue se lève quand le cycle reprend la borne, ou quand la reprise
  amont lui rend son courant manuel. Les verrous de la borne sont lus : un
  geste refusé n'est pas publié comme un délestage.
- Cascade : shedWaterfallForOverload() note chaque délestage au journal,
  avant et après lus sur runtimeView(). Rien d'autre ne change.
- Télémétrie : bloc shedding {revision, open}, absent tant que rien n'a été
  délesté. Sa révision déclenche LoadTelemetryChanged à l'ouverture et à la
  fermeture d'un épisode.
- RPC : NymeaEnergy.GetSheddingLog, même droit que GetLoadTelemetry (table
  des droits et compte sans droit mis à jour).

Aucune consigne, aucun émetteur, aucun verrou ne change (invariant 2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 11:05:53 +01:00

104 lines
6.1 KiB
C++

// 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::OverloadThrottled, // la protection de surcharge de l'amont
DecisionCode::PhaseLimit, DecisionCode::DerogationActive,
DecisionCode::DerogationEnded, DecisionCode::EcoFloorMet,
DecisionCode::EcoFloorBelowStage, // l'optimiseur ignore les paliers (E9)
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::LoadConfigRefused, 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"));
}
void TestLoadModel::testEcoFloorBelowStageIsRenderedAsNoPurchase()
{
// La cadence d'une obligation sous le premier palier : la phrase dit qu'on n'achète RIEN, et
// pourquoi. C'est ce que « 83 W achetés au réseau » disait de travers, constaté au banc.
const DecisionReason obligation(DecisionCode::EcoFloorBelowStage,
{{"targetW", 83}, {"stageW", 500}, {"gridW", 0},
{"remainingWh", 2000}, {"remainingS", 6 * 3600}});
QCOMPARE(renderFr(obligation, QStringLiteral("Chauffe-eau")),
QStringLiteral("Obligation de Chauffe-eau : 83 W nécessaires, sous le premier palier "
"(500 W) — rien n'est acheté ; 2000 Wh restent à livrer en 6 h"));
// Minimum seul : ni reste ni échéance, et la phrase n'en invente pas.
const DecisionReason minimum(DecisionCode::EcoFloorBelowStage,
{{"targetW", 300}, {"stageW", 500}, {"gridW", 0}});
QCOMPARE(renderFr(minimum, QStringLiteral("Chauffe-eau")),
QStringLiteral("Minimum de Chauffe-eau : 300 W demandés, sous le premier palier "
"(500 W) — rien n'est acheté"));
}