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();