From e856c48084efaf6e0eb415a9dfde9de24f591799 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 25 Oct 2026 19:31:05 +0100 Subject: [PATCH] test : un vrai refus reste vu, sur la borne et sur les autres charges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit testARealRefusalIsStillSeen — borne figée sous une consigne à la hausse : refus au cycle qui suit la commande, comme avant le correctif ; montée qui progresse puis cale : latence tant qu'elle se rapproche, refus au cycle où elle cale ; descente suivie de 500 W seulement : refus au cycle suivant, la progression n'excuse jamais une descente. testWattLoadsFollowedOrRefusedUnderAMeter — relais et consigne continue sous compteur dédié : marche suivie dans les deux sens sans refus ; refus réel vu au deuxième cycle (la latence par défaut de 60 s n'est pas dépassée par un cycle de 60 s) ; sans compteur, rien ne change. testSgReadyDivergenceIsPublishedOnly — sous compteur dédié, une PAC qui monte en minutes n'est pas divergente, une PAC qui ne démarre pas l'est ; son recrédit (puissance déclarée) n'en dépend pas. Co-Authored-By: Claude Opus 5.5 --- tests/auto/simulation/simulation.cpp | 369 +++++++++++++++++++++++++++ tests/auto/simulation/simulation.h | 6 + 2 files changed, 375 insertions(+) diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 60ff950..9d942c9 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -9324,6 +9324,375 @@ void Simulation::testRecreditSuspendedAfterAnObeyedStep() #endif } +/*! + * \brief Un VRAI refus reste vu, au cycle qui suit la commande — et la descente n'a pas d'excuse. + * + * Même banc que testRecreditSuspendedAfterAnObeyedStep(). Trois bornes qui n'obéissent pas : + * - **montée refusée** : la borne tient 7 A, la consigne passe à 13 A, le véhicule reste à 7 A — + * mesure figée. Refus au cycle qui suit la commande, c'est-à-dire au même cycle qu'avant le + * correctif ; + * - **montée qui progresse puis cale** : rampe vers 13 A, calée à 2 000 W. Tant qu'elle se + * rapproche, c'est une latence ; le cycle où elle cale, c'est un refus ; + * - **descente non suivie** : la production s'effondre, la consigne descend, la borne ne lâche + * que 500 W. Elle se « rapproche » de sa consigne, et c'est pourtant un refus, au cycle qui + * suit la commande de descente : la progression n'excuse jamais une descente. + * + * \note Les deux marches dont on lit le MOTIF tombent après les 300 s du verrou de marche/arrêt + * armé au démarrage. Avant, l'arrêt verrouillé engage le courant minimal (P2) et le motif + * publié est \c LOCK_MIN_ON, qui précède le refus ; le verdict, lui, est le même. + */ +void Simulation::testARealRefusalIsStillSeen() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // ── 1. Montée refusée : 7 A suivis jusqu'à k=5, consigne de 13 A à k=5, figée à 7 A. ── + // La production retombe à k=6 : sans recrédit, le budget ne paie plus le plancher, et le + // motif qui l'explique est la suspension. + const QList figee = rejouerBorne("refus-montee", + [](int k, double commandeW, double tireW) { return k <= 5 ? commandeW : tireW; }, + [](int k) { return k < 5 ? 1910.0 : (k == 5 ? 3300.0 : 2000.0); }, 7); + // ── 2. Montée qui progresse (la moitié de l'écart par cycle) puis cale à 2 000 W. ────── + const QList cale = rejouerBorne("refus-cale", + [](int, double commandeW, double tireW) { + const double rampe = commandeW > tireW ? tireW + (commandeW - tireW) * 0.5 : commandeW; + return qMin(rampe, 2000.0); + }, [](int) { return 3300.0; }, 5); + // ── 3. Descente non suivie : production 3 300 → 1 900 W à k=6, la borne ne lâche que 500 W. + const QList descente = rejouerBorne("refus-descente", + [](int k, double commandeW, double tireW) { + if (k <= 6) + return commandeW; // elle a obéi jusqu'à la commande de descente + return commandeW < tireW ? qMax(commandeW, tireW - 500.0) : commandeW; + }, [](int k) { return k >= 6 ? 1900.0 : 3300.0; }, 8); + + QCOMPARE(figee.size(), 7); + QCOMPARE(cale.size(), 5); + QCOMPARE(descente.size(), 8); + + // 1. 7 A suivis : aucune suspension. Commande de 13 A à k=5 ; à k=6 la borne tire toujours + // 1 610 W : refus AU CYCLE SUIVANT. Rien ne s'est rapproché, aucune latence ne l'excuse. + for (int k = 1; k <= 4; ++k) { + QCOMPARE(figee.at(k).consigneA, 7); + QVERIFY(figee.at(k).motif != QLatin1String(DecisionCode::RecreditSuspended)); + } + QCOMPARE(figee.at(5).consigneA, 13); + QCOMPARE(figee.at(6).mesureW, 1610); + QCOMPARE(figee.at(6).motif, QString(DecisionCode::RecreditSuspended)); + QCOMPARE(figee.at(6).retenuW, 1610); + QCOMPARE(figee.at(6).recrediteW, 0); + QVERIFY(figee.at(6).divergent); + // Daté à l'instant de la commande (k=5), en temps de cycle : un cycle, 60 s. + QCOMPARE(figee.at(6).refusDepuisS, 60); + + // 2. 0 → 1 495 W à k=1 (rapprochée de 1 495 W) : latence, recrédit rendu, pas divergent. + // 1 495 → 2 000 W à k=2 (rapprochée de 505 W) : latence encore. 2 000 W à k=3 : calée, refus. + QCOMPARE(cale.at(0).consigneA, 13); + QCOMPARE(cale.at(1).mesureW, 1495); + QCOMPARE(cale.at(1).recrediteW, 1495); + QVERIFY(!cale.at(1).divergent); + QCOMPARE(cale.at(2).mesureW, 2000); + QCOMPARE(cale.at(2).recrediteW, 2000); + QVERIFY(!cale.at(2).divergent); + QCOMPARE(cale.at(3).mesureW, 2000); + QCOMPARE(cale.at(3).recrediteW, 0); + QVERIFY(cale.at(3).divergent); + + // 3. Jusqu'à k=5, 13 A suivis. À k=6 la production tombe : la consigne descend. À k=7 la + // borne n'a lâché que 500 W — elle s'est rapprochée de sa consigne, et c'est un refus, + // AU CYCLE QUI SUIT la commande de descente : jugé sur la latence de descente (7 s), sans + // l'excuse de la progression. + QCOMPARE(descente.at(5).consigneA, 13); + QVERIFY2(descente.at(6).consigneA < 13, "la production tombe : la consigne doit descendre"); + QCOMPARE(descente.at(7).mesureW, 2490); + QCOMPARE(descente.at(7).motif, QString(DecisionCode::RecreditSuspended)); + QCOMPARE(descente.at(7).retenuW, 2490); + QCOMPARE(descente.at(7).recrediteW, 0); + QVERIFY(descente.at(7).divergent); + QCOMPARE(descente.at(7).refusDepuisS, 60); +#endif +} + +/*! + * \brief Relais et consignes continues sous compteur dédié : marche suivie, refus réel, et sans + * compteur rien ne change. + * + * Même défaut que la borne avant le correctif : l'écart comparait la consigne qui venait de + * partir à la mesure de la précédente, et le verdict dépendait de la phase du battement de + * publication. Jugé au début du cycle, il ne dépend plus que de la charge : + * - **marche suivie** (le compteur voit la consigne au cycle suivant) : jamais de refus, dans + * les deux sens ; + * - **refus réel** (le compteur reste à 0) : latence au cycle qui suit la commande — le défaut + * de 60 s n'est pas dépassé par un cycle de 60 s — puis refus au suivant ; + * - **sans compteur** : relais sec, rien ne mesure, l'écart est absent ; consigne continue, la + * mesure est celle de l'appareil, qui est aussi sa commande — l'écart est publié, jamais + * divergent. Exactement comme avant. + * + * \note Pour une consigne continue, la « commande » est la puissance que l'appareil annonce + * appliquer (\c puissanceCommandee()). Le modèle le fait annoncer la consigne qu'il a reçue. + */ +void Simulation::testWattLoadsFollowedOrRefusedUnderAMeter() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + constexpr double MaisonW = 300; + struct Pas { + int consigneW = 0; + int recrediteW = 0; + QString motif; + int refusDepuisS = -1; + int retenuW = 0; + bool commande = false; //!< objet `command` publié + bool divergent = false; + }; + // mecanisme : "relais" (relais sec 1 000 W) ou "variable" (consigne continue 0-2 000 W). + auto rejouer = [&](const QString &mecanisme, bool avecCompteur, bool obeit, + const std::function &productionW, int cycles) -> QList { + QList trace; + const QString nom = mecanisme + (avecCompteur ? "-compteur" : "-seul") + (obeit ? "-suivie" : "-refus"); + const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-" + nom + ".json"; + QFile::remove(cfgPath); + qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); + ThingManager *tm = NymeaCore::instance()->thingManager(); + const QUuid meterId = addMeter(27320); + m_experiencePlugin->energyManager()->setRootMeter(meterId); + const QUuid compteurId = addMeter(27321); + Thing *meter = tm->findConfiguredThing(meterId); + Thing *compteur = tm->findConfiguredThing(compteurId); + if (!arb || !meter || !compteur) + return trace; + meter->setStateValue("connected", true); + compteur->setStateValue("connected", true); + compteur->setStateValue("currentPower", 0); + + QString loadId; + Thing *pilote = nullptr; + QVariantMap cfg; + if (mecanisme == QLatin1String("relais")) { + const QUuid r = addDryRelay(27322); + loadId = "ecs-relais"; + cfg = {{"id", loadId}, {"label", "ECS"}, {"adapter", "relay-router"}, {"mode", "fixed"}, + {"priority", 1}, {"enabled", true}, {"minOnS", 0}, {"minOffS", 0}, + {"relays", QVariantList() << QVariantMap{{"thingId", r.toString()}, {"powerW", 1000}}}}; + } else { + const QUuid v = addEtmVariableLoad(27323); + pilote = tm->findConfiguredThing(v); + if (!pilote) + return trace; + pilote->setStateValue("currentPowerW", 0); + loadId = v.toString(); + cfg = {{"id", loadId}, {"label", "Routeur PV"}, {"adapter", "etmvariableload"}, + {"mode", "dynamic"}, {"maxPowerW", 2000}, {"priority", 1}, {"enabled", true}}; + } + if (avecCompteur) + cfg.insert("meterThingId", compteurId.toString()); + LoadConfigStore *store = new LoadConfigStore(arb); + arb->setLoadConfigStore(store); + LoadConfigs cfgs; + cfgs.append(LoadConfig::fromMap(cfg)); + QString err; + if (!store->setConfigs(cfgs, &err)) + return trace; + + const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); + double commandeW = 0; + for (int k = 0; k < cycles; ++k) { + // Suivie : ce qu'on a commandé au cycle précédent coule. Refusée : rien ne coule. + const double tireW = obeit ? commandeW : 0.0; + compteur->setStateValue("currentPower", tireW); + if (pilote) + pilote->setStateValue("currentPowerW", commandeW); // l'appareil annonce sa consigne + meter->setStateValue("currentPower", MaisonW + tireW - productionW(k)); + arb->simulationCallUpdate(t0.addSecs(60 * k)); + QCoreApplication::processEvents(); + + const QVariantMap snap = arb->loadTelemetry(); + const QVariantMap e = telemetrieParId(snap).value(loadId); + const QVariantMap mech = e.value("mechanism").toMap(); + commandeW = mech.value(mecanisme == QLatin1String("relais") ? "stageW" : "setpointW").toDouble(); + const QVariantMap params = e.value("decision").toMap().value("params").toMap(); + Pas p; + p.consigneW = qRound(commandeW); + p.recrediteW = qRound(snap.value("budget").toMap().value("recreditedW").toDouble()); + p.motif = e.value("decision").toMap().value("code").toString(); + p.refusDepuisS = params.value("refusedSinceS", -1).toInt(); + p.retenuW = params.value("withheldW").toInt(); + p.commande = e.contains("command"); + p.divergent = e.value("command").toMap().value("divergent").toBool(); + trace.append(p); + qInfo().noquote() << QStringLiteral("[%1] k=%2 consigne %3 W | recrédit %4 | %5 retenu %6 | divergent %7") + .arg(nom).arg(k).arg(p.consigneW).arg(p.recrediteW).arg(p.motif) + .arg(p.retenuW).arg(p.divergent ? "oui" : "non"); + } + qunsetenv("NYMEA_ENERGY_LOAD_CONFIG"); + QFile::remove(cfgPath); + return trace; + }; + + // Marche suivie : montée à k=0, descente à k=3, remontée à k=5. + auto paliers = [](int k) { return (k >= 3 && k < 5) ? 1000.0 : 1800.0; }; + // Refus réel : de quoi commander au début, plus rien ensuite — le budget ne tient alors que + // par le recrédit, et sa suspension se lit dans le motif. + auto relaisRefus = [](int k) { return k < 2 ? 1800.0 : 1000.0; }; + auto variableRefus = [](int k) { return k < 2 ? 2300.0 : 300.0; }; + + const QList r1 = rejouer("relais", true, true, paliers, 7); + const QList r2 = rejouer("relais", true, false, relaisRefus, 4); + const QList r3 = rejouer("relais", false, false, relaisRefus, 4); + const QList v1 = rejouer("variable", true, true, paliers, 7); + const QList v2 = rejouer("variable", true, false, variableRefus, 4); + const QList v3 = rejouer("variable", false, false, variableRefus, 4); + QCOMPARE(r1.size(), 7); QCOMPARE(r2.size(), 4); QCOMPARE(r3.size(), 4); + QCOMPARE(v1.size(), 7); QCOMPARE(v2.size(), 4); QCOMPARE(v3.size(), 4); + + // ── Marche suivie : elle a bien eu lieu, dans les deux sens, et jamais de refus. ───────── + QCOMPARE(r1.at(0).consigneW, 1000); + QCOMPARE(r1.at(3).consigneW, 0); + QCOMPARE(r1.at(5).consigneW, 1000); + QVERIFY(v1.at(3).consigneW < v1.at(2).consigneW); + QVERIFY(v1.at(5).consigneW > v1.at(4).consigneW); + for (const QList *t : {&r1, &v1}) { + for (int k = 1; k < t->size(); ++k) { + QVERIFY2(t->at(k).motif != QLatin1String(DecisionCode::RecreditSuspended), + qUtf8Printable(QStringLiteral("k=%1 : recrédit suspendu sur une charge qui obéit").arg(k))); + QVERIFY(t->at(k).commande); + QVERIFY2(!t->at(k).divergent, qUtf8Printable(QStringLiteral("k=%1 : divergent").arg(k))); + } + } + + // ── Refus réel : latence au cycle qui suit la commande (60 s ne dépasse pas le défaut de + // 60 s), refus au suivant — recrédit suspendu, et le motif le dit. ───────────────────── + for (const QList *t : {&r2, &v2}) { + QVERIFY(t->at(0).consigneW > 0); + QVERIFY(t->at(1).recrediteW > 0); + QVERIFY(!t->at(1).divergent); + QCOMPARE(t->at(2).recrediteW, 0); + QVERIFY(t->at(2).divergent); + QCOMPARE(t->at(2).motif, QString(DecisionCode::RecreditSuspended)); + QCOMPARE(t->at(2).retenuW, t->at(0).consigneW); + // Daté à l'instant de la commande (k=0) : deux cycles. + QCOMPARE(t->at(2).refusDepuisS, 120); + } + + // ── Sans compteur : rien ne change. Relais sec : rien ne mesure, aucun écart publié ; + // consigne continue : l'appareil mesure ce qu'il annonce, l'écart ne diverge jamais. ── + for (int k = 0; k < r3.size(); ++k) { + QVERIFY(!r3.at(k).commande); + QVERIFY(r3.at(k).motif != QLatin1String(DecisionCode::RecreditSuspended)); + } + for (int k = 1; k < v3.size(); ++k) { + QVERIFY(v3.at(k).commande); + QVERIFY(!v3.at(k).divergent); + QVERIFY(v3.at(k).motif != QLatin1String(DecisionCode::RecreditSuspended)); + } +#endif +} + +/*! + * \brief SG-Ready sous compteur dédié : la divergence se PUBLIE, le recrédit n'en dépend pas. + * + * Le recrédit d'une PAC est la puissance DÉCLARÉE de son état (invariant 8), pas sa mesure : il + * ne consulte pas le refus, avant comme après le correctif. Ce qui change est ce que l'écart + * publié dit d'une PAC qui démarre : + * - **qui monte en puissance en plusieurs minutes** (1 000 W de plus par cycle) : en progression, + * donc une latence — jamais divergente ; + * - **qui ne démarre pas** (compteur figé à 0) : latence au cycle qui suit la commande (défaut de + * 60 s), divergente au suivant. Le recrédit, lui, reste de 3 000 W. + */ +void Simulation::testSgReadyDivergenceIsPublishedOnly() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + constexpr double MaisonW = 300; + struct Pas { int etat = 0; int recrediteW = 0; QString motif; bool divergent = false; bool commande = false; }; + auto rejouer = [&](const QString &nom, const std::function &pacW, int cycles) -> QList { + QList trace; + const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-" + nom + ".json"; + QFile::remove(cfgPath); + qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); + ThingManager *tm = NymeaCore::instance()->thingManager(); + const QUuid meterId = addMeter(27330); + m_experiencePlugin->energyManager()->setRootMeter(meterId); + const QUuid compteurId = addMeter(27331); + const QUuid k1 = addPowerSwitch(0, 27332); + const QUuid k2 = addPowerSwitch(0, 27333); + Thing *meter = tm->findConfiguredThing(meterId); + Thing *compteur = tm->findConfiguredThing(compteurId); + if (!arb || !meter || !compteur || !tm->findConfiguredThing(k1) || !tm->findConfiguredThing(k2)) + return trace; + meter->setStateValue("connected", true); + compteur->setStateValue("connected", true); + compteur->setStateValue("currentPower", 0); + QVariantMap cfg = sgReadyConfigMap("pac", "PAC", k1, k2, 1500.0, 3000.0, 0, 1); + cfg.insert("meterThingId", compteurId.toString()); + LoadConfigStore *store = new LoadConfigStore(arb); + arb->setLoadConfigStore(store); + LoadConfigs cfgs; + cfgs.append(LoadConfig::fromMap(cfg)); + QString err; + if (!store->setConfigs(cfgs, &err)) + return trace; + + const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); + for (int k = 0; k < cycles; ++k) { + const double tireW = pacW(k); + compteur->setStateValue("currentPower", tireW); + meter->setStateValue("currentPower", MaisonW + tireW - 5000.0); // 4 700 W sans la PAC + arb->simulationCallUpdate(t0.addSecs(60 * k)); + QCoreApplication::processEvents(); + const QVariantMap snap = arb->loadTelemetry(); + const QVariantMap e = telemetrieParId(snap).value("pac"); + Pas p; + p.etat = e.value("mechanism").toMap().value("state").toInt(); + p.recrediteW = qRound(snap.value("budget").toMap().value("recreditedW").toDouble()); + p.motif = e.value("decision").toMap().value("code").toString(); + p.commande = e.contains("command"); + p.divergent = e.value("command").toMap().value("divergent").toBool(); + trace.append(p); + qInfo().noquote() << QStringLiteral("[%1] k=%2 état %3 | PAC %4 W | recrédit %5 | %6 | divergent %7") + .arg(nom).arg(k).arg(p.etat).arg(qRound(tireW)).arg(p.recrediteW) + .arg(p.motif).arg(p.divergent ? "oui" : "non"); + } + qunsetenv("NYMEA_ENERGY_LOAD_CONFIG"); + QFile::remove(cfgPath); + return trace; + }; + + const QList monte = rejouer("pac-monte", [](int k) { return qMin(3000.0, 1000.0 * k); }, 5); + const QList figee = rejouer("pac-figee", [](int) { return 0.0; }, 4); + QCOMPARE(monte.size(), 5); + QCOMPARE(figee.size(), 4); + + for (const QList *t : {&monte, &figee}) { + QCOMPARE(t->at(0).etat, 4); // 4 700 W de surplus : état forcé + for (int k = 1; k < t->size(); ++k) { + QCOMPARE(t->at(k).etat, 4); + QVERIFY(t->at(k).commande); + // Le recrédit est la puissance DÉCLARÉE de l'état 4, refus ou pas. + QCOMPARE(t->at(k).recrediteW, 3000); + QVERIFY(t->at(k).motif != QLatin1String(DecisionCode::RecreditSuspended)); + } + } + // Une PAC qui monte en minutes est en progression : jamais divergente. + for (int k = 1; k < monte.size(); ++k) + QVERIFY2(!monte.at(k).divergent, qUtf8Printable(QStringLiteral("k=%1 : divergente").arg(k))); + // Une PAC qui ne démarre pas : latence au cycle qui suit la commande, divergente ensuite. + QVERIFY(!figee.at(1).divergent); + QVERIFY(figee.at(2).divergent); + QVERIFY(figee.at(3).divergent); +#endif +} + /*! * \brief P2 — un ARRÊT VERROUILLÉ engage le courant minimal : gelé au budget, et aucun faux refus. * diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index a4f9f64..6050a42 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -269,6 +269,12 @@ private slots: void testRecreditIsALoanAgainstObedience(); //! Une marche de consigne suivie ne fait plus suspendre le recrédit ; la consigne tient. void testRecreditSuspendedAfterAnObeyedStep(); + //! Un vrai refus reste vu au cycle qui suit la commande ; la descente n'a pas d'excuse. + void testARealRefusalIsStillSeen(); + //! Relais et consignes sous compteur dédié : marche suivie, refus réel, sans compteur. + void testWattLoadsFollowedOrRefusedUnderAMeter(); + //! SG-Ready sous compteur dédié : la divergence se publie, le recrédit n'en dépend pas. + void testSgReadyDivergenceIsPublishedOnly(); //! P2 — un arrêt verrouillé engage le courant minimal : gelé au budget, aucun faux refus. void testLockedStopFreezesTheMinimumAndIsNoRefusal(); //! P2 — le verrou d'arrêt borne la somme éco + confort, il ne s'y ajoute pas.