From 19b20d930facd06a8ac5417c7b6c15d9ef6bb7e9 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Tue, 1 Sep 2026 07:57:40 +0200 Subject: [PATCH] =?UTF-8?q?feat(commande=20perdue=20=C2=A72):=20l'=C3=A9ca?= =?UTF-8?q?rt=20se=20publie,=20gouvern=C3=A9=20par=20la=20source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `command` publie expectedW, observedW, divergent et since — seulement quand une source mesure et qu'une commande est partie. Absent sous `none` : il n'y a rien à comparer, et `divergent: false` affirmerait une concordance qu'on ne peut pas constater. L'asymétrie va dans les DEUX sens, et le contrat la dit maintenant en entier. Sous `device`, une concordance ne prouve rien — plusieurs bornes renvoient leur consigne en guise de puissance — mais une divergence dit que la charge n'obéit pas. Sous `meter`, c'est l'inverse. Le test le démontre au lieu de l'énoncer : la borne du mock dérive sa puissance de sa consigne, donc expectedW == observedW par construction, et la divergence a dû être exercée par un compteur explicite. Un écart n'est pas un défaut, et le champ ne le qualifie pas. Véhicule qui limite, câble en 16 A, mode manuel : le même nombre qu'une commande perdue. La cause n'est pas dans les watts. `since` est ce qui rend le champ utile — sans lui, une divergence permanente ne se distingue pas d'un déphasage d'un cycle, et c'est toute la différence entre le normal et le cas relevé. Et une leçon dans AGENTS.md, appliquée à moi-même dans le même lot : une contre-épreuve qui NE MORD PAS ne prouve pas que le code est bon, elle prouve qu'on a perturbé au mauvais endroit. Mon assertion « sans mesure, pas de command » était VACANTE — aucune charge du test n'avait source: none, la boucle ne s'exécutait jamais, et la garde retirée le test passait toujours. Toutes les classes du mock portent un état de puissance ; il a fallu une charge au câblage manquant pour produire le cas. La contre-épreuve mord désormais. Suite : simulation 59/59, loadmodel 22/22, charging 48/48 identique à la référence ligne à ligne, spotmarket 32/32, doxygen 0 avertissement. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p --- AGENTS.md | 18 ++++ INTERFACE.md | 32 ++++++ debian-qt5/changelog | 30 ++++++ energyplugin/etm/energyarbitrator.cpp | 63 +++++++++++ energyplugin/etm/energyarbitrator.h | 28 +++++ tests/auto/simulation/simulation.cpp | 145 ++++++++++++++++++++++++++ tests/auto/simulation/simulation.h | 1 + 7 files changed, 317 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 1f085c1..9c5df60 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -862,6 +862,24 @@ aucune, la décision est une intention, et elle se périmera à la vitesse du co > d'exploiter une valeur de retour qu'aucun code n'exploitait, **l'épingler d'abord par un > test** — le premier appelant est aussi le premier vérificateur. +> **Corollaire fin, appris le 2026-09-01 : une contre-épreuve qui NE MORD PAS ne prouve pas que +> le code est bon — elle prouve qu'on a perturbé au mauvais endroit.** +> +> Cas d'école : l'assertion « sous `unmeasurable`, `remainingWh == targetWh` ». La contre-épreuve +> naturelle — faire soustraire `deliveredWh` — n'a **rien** changé, parce que sous ce régime +> `deliveredWh` vaut 0. La perturbation était **inerte**. Il aurait été facile d'en conclure « le +> code est robuste » ; la vérité était « l'assertion n'est qu'un COROLLAIRE d'une autre garde ». +> +> Rejouée à l'endroit où le risque vit réellement — publier l'estimation interne comme un fait — +> elle a mordu instantanément, en faisant sortir `ECO_FLOOR_MET` sous `unmeasurable`. +> +> **La règle** : quand une contre-épreuve ne mord pas, la question n'est pas « le code est-il +> bon ? » mais **« qu'ai-je réellement perturbé, et cette grandeur pouvait-elle changer le +> résultat ? »**. Si elle ne le pouvait pas, l'essai n'a rien testé — et l'assertion qu'il devait +> valider est peut-être garantie ailleurs, par un invariant qu'on n'a pas identifié. C'est un +> cran plus fin que « vérifier le test échouant » : **on peut voir un test échouer pour la +> mauvaise raison, et on peut le voir passer pour une raison qui n'est pas la sienne.** + > **Un troisième mode d'érosion, et il est le SYMÉTRIQUE des deux autres (2026-08-31).** Le > garde-fou dit « une règle que rien ne rejoue se périme ». Voici l'inverse : **une règle rendue > IMMORTELLE par sa propre garde.** diff --git a/INTERFACE.md b/INTERFACE.md index b441ffd..1c4a4b4 100644 --- a/INTERFACE.md +++ b/INTERFACE.md @@ -666,6 +666,38 @@ afficherait un plafond de soutirage **qui n'existe pas, avec sa source**. | `binding` | Code opaque de la source qui borne : `connection` \| `gridOperator` \| `selfImposed`. Ce qu'on a le droit de faire d'un dépassement en dépend. | | `perPhaseBound` | Le plafond qui borne est-il **par phase** ? Un plafond total n'a pas de phase coupable, et le dire évite d'envoyer mesurer au hasard sur une triphasée. | +### `command` — l'écart entre le commandé et le mesuré + +Publié **seulement** quand une source mesure (`measurement.source` ≠ `none`) **et** qu'une +commande est partie. Absent sinon — pas `divergent: false`, qui affirmerait une concordance +qu'on ne peut pas constater. Une charge jamais commandée n'est pas une charge commandée à zéro. + +```jsonc +"command": { "expectedW": 3680, "observedW": 800, "divergent": true, "since": "2026-09-01T…" } +``` + +**Ce que l'écart vaut dépend de la source, dans les DEUX sens :** + +| | concordance | divergence | +|---|---|---| +| `device` | **ne prouve rien** — plusieurs bornes renvoient leur consigne en guise de puissance | la charge n'obéit pas, et elle le dit elle-même | +| `meter` | **preuve** — un tiers le constate | trois hypothèses : la charge, le compteur, le câblage | + +> **Un écart n'est PAS un défaut, et ce champ ne le qualifie pas.** Une borne qui n'obéit pas +> parce que le véhicule limite, parce que le câble est en 16 A, ou parce qu'elle est en mode +> manuel, produit **le même nombre** qu'une commande perdue. L'écart se publie, il ne se +> diagnostique pas : **la cause n'est pas dans les watts.** Un écran qui en ferait une alerte +> accuserait des installations saines. + +**`since` est ce qui rend le champ utile.** Sans lui, une divergence permanente ne se distingue +pas d'un déphasage d'un cycle entre l'écriture et sa mesure — et c'est toute la différence entre +un fonctionnement normal et le cas relevé le 2026-08-31, où l'écart durait indéfiniment parce +que plus personne ne commandait. + +**`divergent` est publié plutôt que laissé au client** : la tolérance (bruit de mesure, arrondi +à l'ampère, déphasage d'un cycle) est une connaissance du moteur. La laisser au client ferait +réinventer un seuil par écran, et deux écrans ne diraient pas la même chose du même cycle. + > ### Le point de rupture se lit sur `DRAW_CAP`, jamais sur un solde tombé à zéro > > `draw.remainingW` à 0 dit qu'il ne reste rien **à cet instant** — ce qui arrive aussi quand diff --git a/debian-qt5/changelog b/debian-qt5/changelog index 2711e34..9e5afed 100644 --- a/debian-qt5/changelog +++ b/debian-qt5/changelog @@ -1,3 +1,33 @@ +powersync-energy-plugin-nymea (1.15.2+etm45) trixie; urgency=medium + + * Commande perdue, étape 2 — L'ÉCART COMMANDÉ/MESURÉ EST PUBLIÉ (`command`), et gouverné par + `measurement.source`. Absent sous `none` : il n'y a rien à comparer, et `divergent: false` + affirmerait une concordance qu'on ne peut pas constater. + * L'ASYMÉTRIE VA DANS LES DEUX SENS, et le contrat la dit maintenant en entier. Sous `device`, + une CONCORDANCE ne prouve rien — plusieurs bornes renvoient leur consigne en guise de + puissance — mais une DIVERGENCE dit que la charge n'obéit pas. Sous `meter`, c'est l'inverse : + la concordance est une preuve, la divergence ouvre trois hypothèses. + * Le test le DÉMONTRE au lieu de l'énoncer : la borne du mock dérive sa puissance de sa propre + consigne, donc expectedW == observedW PAR CONSTRUCTION. La divergence a dû être exercée par + un compteur explicite — seul régime où elle a valeur de preuve. + * UN ÉCART N'EST PAS UN DÉFAUT, et le champ ne le qualifie pas. Véhicule qui limite, câble en + 16 A, mode manuel : le même nombre qu'une commande perdue. La cause n'est pas dans les watts, + et un écran qui en ferait une alerte accuserait des installations saines. + * `since` est ce qui rend le champ utile : sans lui, une divergence PERMANENTE ne se distingue + pas d'un déphasage d'un cycle. C'est toute la différence entre le normal et le cas relevé. + * `divergent` est publié plutôt que laissé au client : la tolérance est une connaissance du + moteur. La laisser au client ferait réinventer un seuil par écran. + * AGENTS.md — une contre-épreuve qui NE MORD PAS ne prouve pas que le code est bon, elle + prouve qu'on a perturbé au mauvais endroit. Appliqué à moi-même dans le même lot : mon + assertion « sans mesure, pas de `command` » était VACANTE — aucune charge du test n'avait + `source: none`, la boucle ne s'exécutait jamais, et la garde retirée le test passait + toujours. Toutes les classes du mock portent un état de puissance ; il a fallu une charge au + CÂBLAGE MANQUANT pour produire le cas. La contre-épreuve mord désormais. + * Suite : simulation 59/59, loadmodel 22/22, charging 48/48 identique à la référence, + spotmarket 32/32, doxygen 0 avertissement. + + -- Patrick Schurig Tue, 01 Sep 2026 10:30:00 +0200 + powersync-energy-plugin-nymea (1.15.2+etm44) trixie; urgency=medium * CONTRAT — `remainingWh` sous `unmeasurable` vaut LA CIBLE INTACTE : `remainingWh == diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index c23632b..295013f 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -1604,6 +1604,67 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const m_telemetry = snap; } +namespace { +//! Tolérance de concordance. Un écart en deçà n'est pas publié comme divergence : bruit de +//! mesure, arrondi à l'ampère, et déphasage d'un cycle entre commande et mesure s'y logent. +//! Valeur de conception, à ajuster sur relevé. +constexpr double DivergenceAbsolueW = 200.0; +constexpr double DivergenceRelative = 0.15; +} + +void EnergyArbitrator::attachCommandDivergence(QVariantMap &entry, const QString &loadId, + const QString &source, double mesureW) const +{ + // RIEN NE MESURE : il n'y a rien à comparer, et l'objet est ABSENT — pas `divergent: false`, + // qui affirmerait une concordance qu'on ne peut pas constater. Même règle que partout. + if (source == QLatin1String("none")) + return; + + // Ce qu'on a commandé. Sans commande, l'écart est SANS OBJET — et c'est le cas normal d'une + // borne en mode manuel que l'arbitre ne pilote pas. Une borne jamais commandée n'est pas + // une borne commandée à zéro. + double attenduW = 0; + bool connu = false; + if (EvAdapter *ev = m_adapters.value(loadId, nullptr)) { + uint ampere = 0, phases = 0; + if (ev->derniereCommande(ampere, phases)) { + attenduW = ampere * 230.0 * qMax(1u, phases); + connu = true; + } + } else if (ILoadAdapter *a = m_loadAdapters.value(loadId, nullptr)) { + attenduW = a->telemetry().currentPowerW; + connu = true; + } + if (!connu) + return; + + const double ecart = qAbs(attenduW - mesureW); + const bool divergent = ecart > qMax(DivergenceAbsolueW, DivergenceRelative * attenduW); + + // DEPUIS QUAND — sans cette date, une divergence permanente ne se distingue pas d'un + // déphasage d'un cycle entre l'écriture et sa mesure. C'est pourtant TOUTE la différence + // entre un fonctionnement normal et le cas relevé le 2026-08-31, où l'écart durait + // indéfiniment parce que plus personne ne commandait. + if (!divergent) + m_divergenceDepuis.remove(loadId); + else if (!m_divergenceDepuis.contains(loadId)) + m_divergenceDepuis.insert(loadId, m_lastTelemetryPublish.isValid() + ? m_lastTelemetryPublish : QDateTime::currentDateTime()); + + QVariantMap commande; + commande.insert(QStringLiteral("expectedW"), attenduW); + commande.insert(QStringLiteral("observedW"), mesureW); + // Le VERDICT est publié, et pas seulement les deux nombres : la tolérance est une + // connaissance du moteur (bruit de mesure, arrondi à l'ampère, déphasage d'un cycle). La + // laisser au client ferait réinventer un seuil par écran, et deux écrans ne diraient pas la + // même chose du même cycle. + commande.insert(QStringLiteral("divergent"), divergent); + if (divergent) + commande.insert(QStringLiteral("since"), + m_divergenceDepuis.value(loadId).toUTC().toString(Qt::ISODate)); + entry.insert(QStringLiteral("command"), commande); +} + void EnergyArbitrator::attachMeasurements(QVariantMap &entry, const QString &loadId) const { if (!m_tm) @@ -1668,6 +1729,8 @@ void EnergyArbitrator::attachMeasurements(QVariantMap &entry, const QString &loa mesure.insert(QStringLiteral("powerW"), powerW); entry.insert(QStringLiteral("measurement"), mesure); + attachCommandDivergence(entry, loadId, source, powerW); + // Sonde — lecture seule (LM-1103). Sa raison d'être immédiate est le diagnostic ; sa // raison d'être durable est l'HISTORIQUE, sans lequel les seuils éco/confort du §10 ne // se règleront jamais autrement qu'au doigt mouillé. diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index 4bdf841..637f9d8 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -686,6 +686,29 @@ private: */ void attachMeasurements(QVariantMap &entry, const QString &loadId) const; + /*! + * \brief Publie l'écart entre ce qu'on a COMMANDÉ et ce qui est MESURÉ. + * + * \param[in,out] entry Entrée de télémétrie de la charge. + * \param loadId Charge. \param source Source de mesure déjà résolue. \param mesureW Sa valeur. + * + * \par L'écart ne vaut pas la même chose selon la source, dans les DEUX sens + * | | concordance | divergence | + * |---|---|---| + * | \c device | **ne prouve rien** — plusieurs bornes renvoient leur consigne en guise de puissance | la charge n'obéit pas, et elle le dit elle-même | + * | \c meter | **preuve** — un tiers le constate | trois hypothèses : la charge, le compteur, le câblage | + * + * L'objet est donc **absent** sous \c none : il n'y a rien à comparer, et publier + * \c divergent:false affirmerait une concordance qu'on ne peut pas constater. + * + * \warning **Un écart n'est PAS un défaut, et ce champ ne le qualifie pas.** Une borne qui + * n'obéit pas parce que le véhicule limite, parce que le câble est en 16 A, ou parce + * qu'elle est en mode manuel, produit le MÊME nombre qu'une commande perdue. L'écart se + * publie, il ne se diagnostique pas — la cause n'est pas dans les watts. + */ + void attachCommandDivergence(QVariantMap &entry, const QString &loadId, + const QString &source, double mesureW) const; + /*! * \brief Publie \c m_telemetry si elle diffère significativement de la dernière publiée. * \param force Vrai pour publier sans condition (bascule de mode dégradé, battement). @@ -747,6 +770,11 @@ private: //! loadId → dernière cause de non-service notée par la passe éco (\c MissedCause). Vide //! quand la charge a été servie. Publiée à la clôture d'une période non tenue. mutable QHash m_ecoCause; + //! loadId → instant où la divergence commandé/mesuré a commencé. Vidé dès qu'elle cesse. + //! Sans cette date, une divergence PERMANENTE ne se distingue pas d'un déphasage d'un + //! cycle entre l'écriture et sa mesure — et c'est toute la différence entre le normal et + //! le cas relevé. + mutable QHash m_divergenceDepuis; ThingManager *m_tm = nullptr; //!< ThingManager (pour construire les adaptateurs config). LoadConfigStore *m_loadConfigStore = nullptr; //!< Store config charge pilotée (non adopté). diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index acb434b..e19d514 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -7035,6 +7035,151 @@ void Simulation::testEcoFloorMissedSaysWhatWasLostAndWhy() #endif } +/*! + * \brief [Commande perdue, étape 2] L'écart se PUBLIE — il ne se diagnostique pas. + * + * \par Ce que l'écart vaut dépend de la SOURCE, dans les deux sens + * | | concordance | divergence | + * |---|---|---| + * | `device` | **ne prouve rien** — plusieurs bornes renvoient leur consigne en guise de puissance | la charge n'obéit pas, et elle le dit elle-même | + * | `meter` | **preuve** — un tiers le constate | trois hypothèses : la charge, le compteur, le câblage | + * + * Ce test le DÉMONTRE au lieu de l'énoncer : la borne du mock dérive sa puissance de sa propre + * consigne, si bien que `expectedW == observedW` **par construction**. Une concordance obtenue + * ainsi n'est pas une preuve d'obéissance — c'est un écho. + * + * \par Un écart n'est pas un défaut + * Une borne qui n'obéit pas parce que le véhicule limite, parce que le câble est en 16 A, ou + * parce qu'elle est en mode manuel, produit le **même nombre** qu'une commande perdue. Le champ + * ne porte donc aucune cause : la cause n'est pas dans les watts. + */ +void Simulation::testCommandDivergenceIsPublishedNotDiagnosed() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-div.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()); + QVERIFY(arb); + ThingManager *tm = NymeaCore::instance()->thingManager(); + + QUuid meterId = addMeter(); + m_experiencePlugin->energyManager()->setRootMeter(meterId); + Thing *meter = tm->findConfiguredThing(meterId); + QVERIFY(meter); + meter->setStateValue("connected", true); + meter->setStateValue("currentPower", -8000); // 8 kW exportés + + QUuid borneId = addCharger("A", 16, 26698); + Thing *borne = tm->findConfiguredThing(borneId); + QVERIFY(borne); + armerBorne(borne); + + QVariantMap info; + info.insert("evChargerId", borneId); + info.insert("chargingMode", "ChargingModeEco"); + injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}})); + + const QDateTime t0 = utcDateTime(QDate(2026, 9, 1), QTime(13, 0, 0)); + arb->simulationCallUpdate(t0); + QCoreApplication::processEvents(); + + QHash tele = telemetrieParId(arb->loadTelemetry()); + const QVariantMap b = tele.value(borneId.toString()); + // 1. SOUS `device`, LA CONCORDANCE NE PROUVE RIEN — et c'est mesurable ici même : la borne + // du mock dérive `currentPower` de sa propre consigne, donc les deux nombres coïncident + // PAR CONSTRUCTION. Le champ dit ce qu'il voit ; il ne dit pas que la borne obéit. + QCOMPARE(b.value("measurement").toMap().value("source").toString(), QString("device")); + const QVariantMap cmd = b.value("command").toMap(); + QVERIFY2(!cmd.isEmpty(), "sous une source mesurante, l'écart doit être publié"); + QCOMPARE(qRound(cmd.value("expectedW").toDouble()), qRound(cmd.value("observedW").toDouble())); + QCOMPARE(cmd.value("divergent").toBool(), false); + QVERIFY2(!cmd.contains("since"), "pas de divergence, donc pas de date de début"); + + // 2. DIVERGENCE — la borne annonce autre chose que ce qu'on lui a commandé. On publie les + // deux nombres, le verdict, et DEPUIS QUAND : sans la date, une divergence permanente ne + // se distingue pas d'un déphasage d'un cycle entre l'écriture et sa mesure, ce qui est + // toute la différence entre le normal et le cas relevé le 2026-08-31. + // On passe par un COMPTEUR EXPLICITE, et pas par la mesure de la borne : le mock recalcule + // `currentPower` depuis la consigne à chaque recommande, si bien qu'une divergence injectée + // là serait effacée avant d'être publiée — ce qui EST la démonstration du point 1, mais + // interdit d'exercer le point 2 par ce chemin. Sous `meter`, un tiers observe, et c'est + // aussi le seul régime où la divergence a valeur de preuve. + QUuid compteurBorne = addMeter(26699); + Thing *cb = tm->findConfiguredThing(compteurBorne); + QVERIFY(cb); + cb->setStateValue("connected", true); + cb->setStateValue("currentPower", 800); // le compteur voit 800 W… + + QVariantList cfgs = injectAndWait("NymeaEnergy.GetLoadConfig").toMap() + .value("params").toMap().value("loadConfigs").toList(); + QVariantList majs; + for (const QVariant &v : cfgs) { + QVariantMap m = v.toMap(); + if (m.value("id").toString() == borneId.toString()) + m.insert("meterThingId", compteurBorne.toString()); + majs.append(m); + } + injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", majs}})); + + arb->simulationCallUpdate(t0.addSecs(60)); + QCoreApplication::processEvents(); + const QVariantMap b2 = telemetrieParId(arb->loadTelemetry()).value(borneId.toString()); + QCOMPARE(b2.value("measurement").toMap().value("source").toString(), QString("meter")); + const QVariantMap cmd2 = b2.value("command").toMap(); + QCOMPARE(cmd2.value("divergent").toBool(), true); + QCOMPARE(qRound(cmd2.value("observedW").toDouble()), 800); + QVERIFY2(cmd2.contains("since"), "une divergence doit dire depuis quand elle dure"); + + // 3. Et le champ NE QUALIFIE PAS. Aucun code de cause, aucun défaut : véhicule qui limite, + // câble en 16 A ou commande perdue produisent le même nombre. Le publier comme un défaut + // ferait accuser une installation saine. + QVERIFY2(!cmd2.contains("cause") && !cmd2.contains("faultCode"), + "l'écart se publie, il ne se diagnostique pas — la cause n'est pas dans les watts"); + + // 4. SANS SOURCE MESURANTE, l'objet est ABSENT — pas `divergent: false`, qui affirmerait une + // concordance qu'on ne peut pas constater. Une charge de configuration sans compteur + // désigné ni état de puissance est exactement ce cas. + // Une charge SANS mesure est ajoutée exprès : sans elle, la boucle ci-dessous ne trouverait + // aucun cas et l'assertion serait VACANTE — elle passerait sans rien vérifier. C'est le + // piège de la contre-épreuve inerte, et il a été constaté ici même : la garde retirée, le + // test passait toujours. + // Le relais est VOLONTAIREMENT inexistant : toutes les classes du mock portent un état de + // puissance, donc aucune charge normale ne produit `source: none`. Une charge dont le + // câblage manque, si — et c'est un cas réel, pas un artifice (ECS-111). + const QString relaisSansMesure = "{deadbeef-0000-4000-8000-00000000c0de}"; + LoadConfigStore *store = new LoadConfigStore(arb); + arb->setLoadConfigStore(store); + LoadConfigs sans; + sans.append(LoadConfig::fromMap(QVariantMap{ + {"id", "ecs-sans-mesure"}, {"label", "ECS"}, {"adapter", "relay-router"}, + {"mode", "fixed"}, {"priority", 5}, {"enabled", true}, {"domain", "ecs"}, + {"relays", QVariantList() << QVariantMap{{"thingId", relaisSansMesure}, + {"powerW", 1000}}}})); + QString err; + QVERIFY2(store->setConfigs(sans, &err), err.toUtf8()); + arb->simulationCallUpdate(t0.addSecs(120)); + QCoreApplication::processEvents(); + + int examinees = 0; + for (const QVariantMap &e : telemetrieParId(arb->loadTelemetry())) { + if (e.value("measurement").toMap().value("source").toString() != QLatin1String("none")) + continue; + ++examinees; + QVERIFY2(!e.contains("command"), + "rien ne mesure : il n'y a rien à comparer, et l'objet doit être absent"); + } + QVERIFY2(examinees > 0, + "aucune charge sans mesure dans ce cycle : l'assertion aurait été vacante"); +#endif +} + void Simulation::testCountsSumToTarget() { #ifndef ETM_ARBITRATOR diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 4e7b2a7..5c44e5c 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -197,6 +197,7 @@ private slots: void testEcoProgressBaseAndRegime(); void testEcoPassBuysAndSaysTheTrajectory(); void testEcoFloorMissedSaysWhatWasLostAndWhy(); + void testCommandDivergenceIsPublishedNotDiagnosed(); void testARefusedStateIsDistinguishableFromAnApplied(); void testL4ShedsAPacAndSaysItForcedTheLock();