diff --git a/tests/auto/conformance/README.md b/tests/auto/conformance/README.md index d5dc2e4..0ccaa61 100644 --- a/tests/auto/conformance/README.md +++ b/tests/auto/conformance/README.md @@ -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 diff --git a/tests/auto/conformance/testconformance.cpp b/tests/auto/conformance/testconformance.cpp index 97a86bb..217f04f 100644 --- a/tests/auto/conformance/testconformance.cpp +++ b/tests/auto/conformance/testconformance.cpp @@ -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 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 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 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)