test(conformance): mode attendu plan | abstain, un optimiseur qui s'abstient est jugé conforme
S'abstenir est une réponse permise (§7). Avec ETM_POWERSYNC_CONFORMANCE_EXPECT=abstain, testEveryPlanIsAccepted exige une abstention à chaque cycle (rules, fallbackCode abstain, verdict abstention/abstain, aucun échec, optimizerAlive inchangé) ; testTheSafetyLoopStaysOnTop vérifie L4 sous le rule-based (shed exigé) puis se termine en QSKIP, rien à écrêter. Mode `plan` (défaut) inchangé. Le mode retenu est affiché en tête de chaque fonction. Pas de déduction depuis le handshake : `planning` est informatif (multi-créneaux ou non) et `kinds` absent veut dire « tout admis » ; la référence en mode abstain annonce des kinds, le mode protocol-1.0 (planning false, sans capabilities) planifie. README. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
308444f39d
commit
61db6cfd66
@ -12,6 +12,8 @@ adaptateurs, sur les mocks de la suite `simulation` — et vérifie, une fonctio
|
||||
| `testEveryPlanIsAccepted` | à chaque cycle, le plan est **appliqué** : l'optimiseur commande, `GetPlan` sans `fallbackCode` |
|
||||
| `testTheSafetyLoopStaysOnTop` | la boucle de sécurité reste au-dessus : une consigne hors bornes (réécrite en 100 kW par le relais de test) est **écrêtée** par l'adaptateur (`clamped`), une surcharge de phase est **délestée** par L4 |
|
||||
|
||||
Ces deux dernières lignes valent pour le mode `plan` (défaut). Voir **Optimiseur qui s'abstient**.
|
||||
|
||||
Les fonctions 2 à 5 passent par un relais transparent (une ligne reçue = une ligne renvoyée) qui
|
||||
capture la session et mesure les délais ; la 5 est la seule où il réécrit une réponse. Banc : deux
|
||||
charges `etmvariableload` (0–2 000 W ; plancher 800, 3 000 W) et une PAC SG-Ready (P3 1 500 W,
|
||||
@ -45,11 +47,37 @@ commande vit dans le même réseau privé (la boucle locale y fonctionne, `tcp:/
|
||||
| `ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE` | aucun | Fichier du token que l'optimiseur attend ; le moteur l'envoie au handshake (16 caractères au moins) |
|
||||
| `ETM_POWERSYNC_OPTIMIZER_START_S` | `15` | Délai pour que l'endpoint accepte une connexion |
|
||||
| `ETM_POWERSYNC_SOURCE_DIR` | sources du build | Racine où trouver le validateur et l'optimiseur de référence |
|
||||
| `ETM_POWERSYNC_CONFORMANCE_EXPECT` | `plan` | Ce que la suite attend : `plan` (l'optimiseur commande) ou `abstain` (il s'abstient à chaque cycle). Toute autre valeur fait échouer |
|
||||
|
||||
L'attente de démarrage ouvre une connexion de sonde, refermée sans handshake : un serveur conforme
|
||||
la tolère. Un échec dit ce qui manque — code d'erreur du client, écart au contrat (chemin, mot-clé),
|
||||
cycles non appliqués avec leur `fallbackCode`, latences.
|
||||
|
||||
## Optimiseur qui s'abstient
|
||||
|
||||
S'abstenir est une réponse permise (`{"abstain": true}`, §7) : un optimiseur qui s'abstient
|
||||
toujours est conforme. Avec `ETM_POWERSYNC_CONFORMANCE_EXPECT=abstain` :
|
||||
|
||||
- `testEveryPlanIsAccepted` exige que **chaque** cycle (hors le premier, sans réponse encore)
|
||||
finisse en `strategy: rules` avec `fallbackCode: abstain`, que chaque réponse soit jugée
|
||||
`abstention/abstain`, sans échec compté, et qu'`optimizerAlive` ne change pas. Un plan appliqué,
|
||||
un autre `fallbackCode` ou un échec font échouer ;
|
||||
- `testTheSafetyLoopStaysOnTop` vérifie toujours L4 sous le rule-based, sans consigne de
|
||||
l'optimiseur : la trace doit porter `shed` (la seule baisse des consignes ne suffit pas, l'import
|
||||
la produit déjà). Puis la fonction se termine en **SKIP** : aucune consigne à écrêter ;
|
||||
- les trois autres fonctions sont inchangées.
|
||||
|
||||
À l'inverse, un optimiseur qui s'abstient en mode `plan` échoue à `testEveryPlanIsAccepted` et
|
||||
`testTheSafetyLoopStaysOnTop`. Chaque fonction affiche en tête le mode retenu (`[Conformité] mode
|
||||
attendu : …`).
|
||||
|
||||
**Le mode ne se déduit pas du handshake.** Le protocole n'a pas de capacité qui dise « je
|
||||
planifie » : `planning` est informatif et distingue le plan multi-créneaux du plan à un créneau
|
||||
(§3), et `capabilities.kinds` absent veut dire « tous les kinds admis », pas « aucun ». L'optimiseur
|
||||
minimal de l'annexe A répond `planning: false`, sans `capabilities`, et planifie ; l'optimiseur de
|
||||
référence en mode `abstain` annonce `kinds: ["setpoint", "state"]` et ne planifie jamais. Une règle
|
||||
fondée sur ces champs jugerait faux dans les deux cas : le mode se donne, il ne se devine pas.
|
||||
|
||||
## Ce qu'elle ne vérifie pas
|
||||
|
||||
La **qualité** des plans (aucune comparaison au rule-based : c'est `refoptimizer`, pour la référence
|
||||
|
||||
@ -18,6 +18,12 @@
|
||||
// 5. la boucle de sécurité au-dessus : une consigne hors bornes est écrêtée par l'adaptateur,
|
||||
// une surcharge de phase est délestée par L4, quoi que dise l'optimiseur.
|
||||
//
|
||||
// Mode attendu ($ETM_POWERSYNC_CONFORMANCE_EXPECT, défaut `plan`) : `abstain` juge un optimiseur
|
||||
// qui s'abstient toujours (§7, réponse permise) — le point 4 exige alors une abstention à chaque
|
||||
// cycle, sans échec ni changement d'optimizerAlive, et le point 5 ne garde que L4 (rien à écrêter).
|
||||
// Pas de déduction depuis le handshake : `planning` et `capabilities.kinds` ne disent pas si
|
||||
// l'optimiseur planifie (README.md).
|
||||
//
|
||||
// Les points 2 à 5 passent par un RELAIS transparent (une ligne reçue = une ligne renvoyée) : il
|
||||
// capture la session pour le validateur, mesure les délais, et pour le point 5 seulement réécrit
|
||||
// la consigne d'un plan en 100 kW — l'épreuve doit tenir quel que soit l'optimiseur, y compris un
|
||||
@ -343,6 +349,9 @@ private:
|
||||
QVariantMap rpc(const QString &methode, const QVariantMap ¶ms = QVariantMap());
|
||||
QList<QVariantMap> traces() const;
|
||||
|
||||
//! Ce que la suite attend de l'optimiseur : des plans appliqués, ou une abstention à chaque cycle.
|
||||
enum class Attendu { Plan, Abstention };
|
||||
Attendu m_attendu = Attendu::Plan;
|
||||
EnergyArbitrator *m_arb = nullptr;
|
||||
Thing *m_compteur = nullptr;
|
||||
QString m_var1, m_var2;
|
||||
@ -356,10 +365,24 @@ private:
|
||||
int m_requetes = 0;
|
||||
QStringList m_echecs;
|
||||
QStringList m_planRefuse; //!< cycles où l'optimiseur n'a pas commandé, avec le code de repli
|
||||
QStringList m_nonAbstenu; //!< mode abstain : cycles qui ne finissent pas en abstention
|
||||
QStringList m_verdicts; //!< chaque réponse jugée : « issue/code »
|
||||
};
|
||||
|
||||
void TestOptimizerConformance::monter()
|
||||
{
|
||||
// Le mode d'abord : un journal doit dire ce qui a été jugé, même si la suite échoue ensuite.
|
||||
const QString expect = variable("ETM_POWERSYNC_CONFORMANCE_EXPECT");
|
||||
if (expect.isEmpty() || expect == QLatin1String("plan"))
|
||||
m_attendu = Attendu::Plan;
|
||||
else if (expect == QLatin1String("abstain"))
|
||||
m_attendu = Attendu::Abstention;
|
||||
else
|
||||
QFAIL(qUtf8Printable("ETM_POWERSYNC_CONFORMANCE_EXPECT inconnu : « " + expect + " » (plan | abstain)"));
|
||||
qInfo().noquote() << "[Conformité] mode attendu :"
|
||||
<< (m_attendu == Attendu::Plan ? "plan" : "abstain")
|
||||
<< (expect.isEmpty() ? "(défaut)" : "(ETM_POWERSYNC_CONFORMANCE_EXPECT)");
|
||||
|
||||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-conformance.json";
|
||||
QFile::remove(cfgPath);
|
||||
qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8());
|
||||
@ -439,7 +462,10 @@ void TestOptimizerConformance::brancher(bool parLeRelais)
|
||||
QCOMPARE(rpc("SetOptimizerEndpoint", {{"endpoint", cible}}).value("energyError").toString(), QString("EnergyErrorNoError"));
|
||||
SocketScheduler *s = m_arb->socketScheduler();
|
||||
QVERIFY(s);
|
||||
connect(s, &SocketScheduler::reponseJugee, this, [this](const QString &, const QString &) { ++m_jugees; });
|
||||
connect(s, &SocketScheduler::reponseJugee, this, [this](const QString &issue, const QString &code) {
|
||||
++m_jugees;
|
||||
m_verdicts << issue + QLatin1Char('/') + code;
|
||||
});
|
||||
connect(s, &SocketScheduler::echecRequete, this, [this](const QString &code) { ++m_jugees; m_echecs << code; });
|
||||
QVERIFY2(waitFor([s] { return s->client()->state() == OptimizerClient::Ready; }, 15000),
|
||||
qUtf8Printable(QStringLiteral("le moteur ne passe pas le handshake (lastErrorCode %1) :\n%2")
|
||||
@ -475,6 +501,8 @@ void TestOptimizerConformance::parcours(const QDateTime &t0)
|
||||
{
|
||||
const QList<double> surplus{-1000, 1500, 2600, 4200, 6600, 9000, 3000, -500};
|
||||
m_planRefuse.clear();
|
||||
m_nonAbstenu.clear();
|
||||
const bool vivantAuDepart = rpc("GetOptimizerStatus").value("optimizerAlive").toBool();
|
||||
for (int i = 0; i < surplus.count(); ++i) {
|
||||
m_surplusW = surplus.at(i);
|
||||
cycle(t0.addSecs(30 * i));
|
||||
@ -484,10 +512,16 @@ void TestOptimizerConformance::parcours(const QDateTime &t0)
|
||||
if (i == 0)
|
||||
continue;
|
||||
const QVariantMap p = rpc("GetPlan");
|
||||
const QString issue = QStringLiteral("cycle %1 (surplus %2 W) : %3, fallbackCode %4")
|
||||
.arg(i).arg(surplus.at(i)).arg(p.value("strategy").toString(),
|
||||
p.value("fallbackCode").toString());
|
||||
if (!m_arb->socketScheduler()->optimiseurAuDernierCycle() || p.contains("fallbackCode"))
|
||||
m_planRefuse << QStringLiteral("cycle %1 (surplus %2 W) : %3, fallbackCode %4")
|
||||
.arg(i).arg(surplus.at(i)).arg(p.value("strategy").toString(),
|
||||
p.value("fallbackCode").toString());
|
||||
m_planRefuse << issue;
|
||||
// §7 : une abstention rend le cycle au rule-based sans toucher à optimizerAlive.
|
||||
const bool vivant = rpc("GetOptimizerStatus").value("optimizerAlive").toBool();
|
||||
if (m_arb->socketScheduler()->optimiseurAuDernierCycle() || p.value("strategy").toString() != QLatin1String("rules")
|
||||
|| p.value("fallbackCode").toString() != QLatin1String("abstain") || vivant != vivantAuDepart)
|
||||
m_nonAbstenu << issue + QStringLiteral(", optimizerAlive %1 → %2").arg(vivantAuDepart ? "true" : "false", vivant ? "true" : "false");
|
||||
}
|
||||
}
|
||||
|
||||
@ -600,6 +634,19 @@ void TestOptimizerConformance::testEveryPlanIsAccepted()
|
||||
if (QTest::currentTestFailed())
|
||||
return;
|
||||
parcours(utcDateTime(QDate(2026, 6, 8), QTime(9, 0, 0)));
|
||||
if (m_attendu == Attendu::Abstention) {
|
||||
if (QTest::currentTestFailed())
|
||||
return;
|
||||
// Chaque cycle rendu au rule-based PAR UNE ABSTENTION : ni plan appliqué, ni autre repli,
|
||||
// ni échec compté (§7). Une réponse jugée autrement qu'`abstention/abstain` fait échouer.
|
||||
QVERIFY2(m_nonAbstenu.isEmpty(), qUtf8Printable(QStringLiteral("cycles non abstenus :\n") + m_nonAbstenu.join('\n')));
|
||||
QVERIFY2(m_echecs.isEmpty(), qUtf8Printable("requêtes en échec : " + m_echecs.join(", ")));
|
||||
QVERIFY2(!m_verdicts.isEmpty() && m_verdicts.count(QStringLiteral("abstention/abstain")) == m_verdicts.size(),
|
||||
qUtf8Printable("réponses jugées : " + m_verdicts.join(", ")));
|
||||
QCOMPARE(m_arb->socketScheduler()->vivacite().echecsConsecutifs(), 0);
|
||||
QCOMPARE(rpc("GetOptimizerStatus").value("activeStrategy").toString(), QString("rules"));
|
||||
return;
|
||||
}
|
||||
QVERIFY2(m_planRefuse.isEmpty(), qUtf8Printable(QStringLiteral("plans non appliqués :\n") + m_planRefuse.join('\n')));
|
||||
QCOMPARE(rpc("GetOptimizerStatus").value("activeStrategy").toString(), QString("optimizer"));
|
||||
}
|
||||
@ -622,20 +669,24 @@ void TestOptimizerConformance::testTheSafetyLoopStaysOnTop()
|
||||
|
||||
// a) Hors bornes : chaque consigne d'un plan reçu devient 100 kW. L'adaptateur l'écrête à la
|
||||
// puissance déclarée de la charge, et le moteur le dit (`clamped` dans la trace).
|
||||
m_relais->horsBornes = true;
|
||||
// En mode abstain, aucun plan donc aucune consigne : les mêmes cycles passent, sans épreuve.
|
||||
const bool abstention = m_attendu == Attendu::Abstention;
|
||||
m_relais->horsBornes = !abstention;
|
||||
for (int k = 3; k < 6; ++k)
|
||||
cycle(t0.addSecs(30 * k));
|
||||
m_relais->horsBornes = false;
|
||||
QVERIFY2(m_relais->reecrites > 0, "l'optimiseur n'a émis aucune consigne setpoint : rien à écrêter");
|
||||
const QVariantMap e = etatApplique();
|
||||
QVERIFY2(e.value("var1").toDouble() <= 2000 && e.value("var2").toDouble() <= 3000, qUtf8Printable(cle(e)));
|
||||
QList<QVariantMap> l;
|
||||
QVERIFY(waitFor([&] { l = traces(); return l.count() >= 6; }, 5000));
|
||||
bool ecretee = false;
|
||||
for (const QVariant &x : l.at(5).value("executed").toList())
|
||||
ecretee |= x.toMap().value("clamped").toBool() && x.toMap().value("powerW").toDouble() <= 3000;
|
||||
QVERIFY2(ecretee && l.at(5).value("strategy").toString() == QLatin1String("optimizer"),
|
||||
qUtf8Printable(cle(l.at(5))));
|
||||
if (!abstention) {
|
||||
QVERIFY2(m_relais->reecrites > 0, "l'optimiseur n'a émis aucune consigne setpoint : rien à écrêter");
|
||||
const QVariantMap e = etatApplique();
|
||||
QVERIFY2(e.value("var1").toDouble() <= 2000 && e.value("var2").toDouble() <= 3000, qUtf8Printable(cle(e)));
|
||||
QVERIFY(waitFor([&] { l = traces(); return l.count() >= 6; }, 5000));
|
||||
bool ecretee = false;
|
||||
for (const QVariant &x : l.at(5).value("executed").toList())
|
||||
ecretee |= x.toMap().value("clamped").toBool() && x.toMap().value("powerW").toDouble() <= 3000;
|
||||
QVERIFY2(ecretee && l.at(5).value("strategy").toString() == QLatin1String("optimizer"),
|
||||
qUtf8Printable(cle(l.at(5))));
|
||||
}
|
||||
|
||||
// b) L4 : phase A à 32 A sous un disjoncteur de 25 A. Quoi que commande l'optimiseur, L4 déleste.
|
||||
const QVariantMap avant = etatApplique();
|
||||
@ -650,6 +701,13 @@ void TestOptimizerConformance::testTheSafetyLoopStaysOnTop()
|
||||
QVERIFY2(delestee || apres.value("var1").toDouble() + apres.value("var2").toDouble()
|
||||
< avant.value("var1").toDouble() + avant.value("var2").toDouble(),
|
||||
qUtf8Printable(cle(l.at(6))));
|
||||
if (abstention) {
|
||||
// L4 sous le rule-based, sans consigne de l'optimiseur. Le `shed` est exigé : à −2 000 W, le
|
||||
// rule-based baisse déjà les consignes de lui-même, la baisse seule ne prouverait pas L4.
|
||||
QVERIFY2(delestee, qUtf8Printable("L4 n'a rien délesté : " + cle(l.at(6))));
|
||||
qInfo().noquote() << "[Conformité] L4 vérifié sous" << l.at(6).value("strategy").toString() << "(shed dans la trace)";
|
||||
QSKIP("aucune consigne à écrêter : l'optimiseur s'abstient");
|
||||
}
|
||||
}
|
||||
|
||||
QTEST_MAIN(TestOptimizerConformance)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user