diff --git a/debian-qt5/changelog b/debian-qt5/changelog index 1fbf8c4..b234b01 100644 --- a/debian-qt5/changelog +++ b/debian-qt5/changelog @@ -1,3 +1,39 @@ +powersync-energy-plugin-nymea (1.15.2+etm40) trixie; urgency=medium + + * §10 étape C — L'AVANCEMENT BRANCHÉ EN LECTURE SEULE, et le RÉGIME publié (R5). + `progress.regime` ∈ measured | estimated | unmeasurable, porté par le NIVEAU. Sous + `unmeasurable`, `deliveredWh` est ABSENT et jamais 0 : une dégradation ne doit pas se lire + comme un avancement nul (LM-1009 C1). + * LE PIÈGE, mesuré et évité (LM-1012-b). `sessionEnergy` est l'ACCUMULATEUR du plugin V2C, + pas un compteur remis à zéro pour nous : l'écart avec `chargeEnergy` est constant au + dix-millième (7,2044 kWh sur deux échantillons à deux heures d'écart). Le lire directement + ferait croire toute obligation tenue de 7,2 kWh dès le premier cycle. L'avancement est donc + une DIFFÉRENCE depuis une base — contre-épreuve : lecture directe, le test annonce 7204. + * Base prise à l'ouverture de l'OBLIGATION, jamais au démarrage de nymead : sinon une recharge + de nuit relue au matin repart de zéro. RECAPTURÉE sur un recul franc de l'accumulateur + (nouvelle session), pas sur un recul infime, qui est du bruit de lecture. + * CE QUE LE MOTEUR NE REFAIT PAS : la détection de remise à zéro de la BORNE vit dans le + plugin, qui interroge à 30 s ; le moteur travaille à la minute et raterait des cycles + entiers. Regarder comment le plugin l'avait écrite a évité une seconde accumulation, moins + fine, en concurrence de la première. + * Trois maillons manquants raccordés au passage, tous déclarés et alimentés par personne : + `EvCharger` n'exposait pas `sessionEnergy` ; `LoadContextTelemetry::sessionWh` n'était rempli + nulle part ; et `toLoadContext()` ABANDONNAIT les besoins déclarés, si bien que + `LoadContext::needs` n'était rempli par aucun adaptateur — l'obligation éco n'existait qu'en + configuration. + * Mock : `sessionEnergy` ajouté à `chargerPhaseSwitching` UNIQUEMENT. `charger` reste sans, ce + qui donne le cas `unmeasurable` nativement — la suite porte les deux régimes, comme le banc. + * Le régime est publié ALORS QU'AUCUNE DÉCISION NE S'Y APPUIE, délibérément : cela sépare + publier le régime de décider avec, comme `measurement` l'a été de l'arbitrage, et rend R6 + testable le jour où le motif naîtra plutôt qu'un invariant sur du code que rien n'émet. + * Rapport amont rédigé pour le SIGSEGV de `Logger::log()` (docs/upstream/) : « hors de ce + lot » ne veut pas dire sans conséquence — un plantage du moteur de journalisation, c'est + nymead qui tombe chez un client et le HEMS qui cesse de piloter. + * Suite : simulation 56/56, charging 48/48 identique à la référence, loadmodel 21/21, + spotmarket 32/32, doxygen 0 avertissement. + + -- Patrick Schurig Mon, 31 Aug 2026 08:15:00 +0200 + powersync-energy-plugin-nymea (1.15.2+etm39) trixie; urgency=medium * §10 étape B — `draw` publie l'AUTORISATION, et elle se lit au lieu d'être forgée. diff --git a/docs/upstream/RAPPORT_nymea_logger_sigsegv.md b/docs/upstream/RAPPORT_nymea_logger_sigsegv.md new file mode 100644 index 0000000..3946612 --- /dev/null +++ b/docs/upstream/RAPPORT_nymea_logger_sigsegv.md @@ -0,0 +1,70 @@ +# Rapport amont — SIGSEGV dans `Logger::log()` (libnymea 1.15.2) + +**À remonter à nymea.** Rédigé le 2026-08-31 ; le vidage est conservé (voir « Pièces »). + +> **Pourquoi ce rapport plutôt qu'une ligne de changelog.** Le plantage n'a aucune trame ETM dans +> sa pile — il est donc « hors de notre lot ». Mais *hors de notre lot* ne veut pas dire *sans +> conséquence* : un plantage dans le moteur de journalisation, c'est **`nymead` qui tombe chez un +> client**, et un HEMS qui cesse de piloter. Une box qui ne pilote plus ne se signale pas d'elle-même. + +## Symptôme + +`SIGSEGV` dans `Logger::log(const QList&, const QMap&)`, atteint par +un **événement posté** (`sendPostedEvents` → `QObject::event`) depuis `libnymea-core`. + +``` +#0 Logger::log(QList const&, QMap const&) libnymea.so.1 + 0x14ea24 +#1 ?? libnymea-core.so.1 + 0x1edae6 +#2 ?? libQt6Core.so.6 + 0x1e4403 +#3 QObject::event(QEvent*) libQt6Core.so.6 +#4 QCoreApplication::notifyInternal2(QObject*, QEvent*) libQt6Core.so.6 +#5 QCoreApplicationPrivate::sendPostedEvents(...) libQt6Core.so.6 +#6 ?? libQt6Core.so.6 + 0x3a8c57 +#7 g_main_context_iteration libglib-2.0.so.0 +``` + +Un seul fil actif ; les cinq autres sont en attente. Le processus tenait ouverte la base +`/tmp/nymea-test/energylogs.sqlite` (moteur de journalisation SQLite). + +## Reproductibilité — FAIBLE, et c'est le point + +Observé **une fois** sur une exécution de suite de tests en tâche de fond. **Non reproduit** en +trois séquences identiques ensuite, ni en exécution isolée (48/48, code de retour 0). + +C'est précisément ce qui rend le rapport utile : à cette fréquence, le réflexe est de relancer +jusqu'à ce que ça passe et de conclure à une « fragilité de test ». Le vidage a été récupéré au +lieu d'être ignoré, et il montre que le plantage n'est **pas** dans le code de test. + +## Hypothèse — à confirmer par quelqu'un qui a les symboles + +L'enchaînement — événement **posté**, délivré depuis la boucle glib, sur un objet du moteur de +journalisation, en fin de vie de processus — a la forme d'un **usage après libération** : une +invocation en file d'attente délivrée à un `Logger` déjà détruit pendant l'arrêt. + +**C'est une hypothèse, pas une conclusion.** La trame `libnymea-core.so.1 + 0x1edae6` n'a pas pu +être symbolisée ici (paquet sans symboles de débogage). Avec `libnymea-dbgsym`, elle nommerait +l'appelant en une commande. + +Si l'hypothèse tient, elle a une conséquence de production : l'arrêt d'un `nymead` chargé peut +laisser des écritures de journal en file, et le risque n'est pas limité aux tests. + +## Environnement + +| | | +|---|---| +| `libnymea1` | `1.15.2+202606191336~trixie1` | +| Qt | `6.8.2+dfsg-9+deb13u2` | +| Système | Debian 13 (trixie), x86_64 | +| Binaire | suite de tests `charging` du plugin énergie ETM | + +## Pièces + +- Vidage conservé : `coredumpctl` du 2026-08-31 07:06:01 CEST, PID 115071, 2,7 Mio compressés. +- Commande de symbolisation utile côté nymea : + `gdb -batch -ex 'bt full' ` avec `libnymea-dbgsym` installé. + +## Ce qui est demandé + +1. Symboliser la trame `libnymea-core.so.1 + 0x1edae6` — elle nomme l'appelant, et l'hypothèse + tombe ou se confirme immédiatement. +2. Vérifier la durée de vie du `Logger` vis-à-vis des événements postés à l'arrêt. diff --git a/energyplugin/etm/adapters/etmvariableloadadapter.cpp b/energyplugin/etm/adapters/etmvariableloadadapter.cpp index 97db1ec..3cc4999 100644 --- a/energyplugin/etm/adapters/etmvariableloadadapter.cpp +++ b/energyplugin/etm/adapters/etmvariableloadadapter.cpp @@ -80,6 +80,11 @@ LoadContext EtmVariableLoadAdapter::toLoadContext(const QDateTime &now) const ctx.label = m_label; ctx.priority = m_priority; ctx.declared = descriptor().declared; + // §10-C — les BESOINS déclarés atteignent enfin le cycle. Ils étaient posés dans le + // descripteur et abandonnés ici : `LoadContext::needs` n'était rempli par personne, donc + // l'obligation éco n'existait qu'en configuration. Transmis tels quels, sans être lus par + // aucune décision à ce stade — la passe éco est l'étape suivante. + ctx.needs = descriptor().needs; ctx.telemetry.currentPowerW = telemetry().currentPowerW; ctx.telemetry.available = telemetry().available; diff --git a/energyplugin/etm/adapters/evadapter.cpp b/energyplugin/etm/adapters/evadapter.cpp index 458ec1a..981e256 100644 --- a/energyplugin/etm/adapters/evadapter.cpp +++ b/energyplugin/etm/adapters/evadapter.cpp @@ -107,6 +107,11 @@ LoadContext EvAdapter::toLoadContext(const QDateTime &now) const ctx.telemetry.available = m_charger->available(); ctx.telemetry.pluggedIn = m_charger->pluggedIn(); ctx.telemetry.charging = m_charger->charging(); + // §10-C — l'avancement MESURÉ, enfin alimenté. Le champ était déclaré depuis LM-1009 et + // rempli nulle part : une échéance dont l'avancement ne se mesure pas est pire qu'une + // échéance absente, parce qu'elle continue de s'afficher comme si elle tenait. + ctx.telemetry.sessionMeasurable = m_charger->hasSessionEnergy(); + ctx.telemetry.sessionWh = m_charger->sessionEnergyWh(); // Pas de fenêtre de verrou de palier côté EV : lockMaxPowerW négatif = aucun plafond // (convention du contexte), et lockMinPowerW nul = rien d'engagé de non-coupable. diff --git a/energyplugin/etm/adapters/relayrouter.cpp b/energyplugin/etm/adapters/relayrouter.cpp index 7bcaf77..0d8c962 100644 --- a/energyplugin/etm/adapters/relayrouter.cpp +++ b/energyplugin/etm/adapters/relayrouter.cpp @@ -196,7 +196,12 @@ LoadContext RelayRouter::toLoadContext(const QDateTime &now) const ctx.adapter = QStringLiteral("relay-router"); ctx.label = m_label; ctx.priority = m_priority; - ctx.declared = descriptor().declared; // powerLevels/maxPowerW dérivés (watts) + ctx.declared = descriptor().declared; + // §10-C — les BESOINS déclarés atteignent enfin le cycle. Ils étaient posés dans le + // descripteur et abandonnés ici : `LoadContext::needs` n'était rempli par personne, donc + // l'obligation éco n'existait qu'en configuration. Transmis tels quels, sans être lus par + // aucune décision à ce stade — la passe éco est l'étape suivante. + ctx.needs = descriptor().needs; // powerLevels/maxPowerW dérivés (watts) ctx.needs = m_needs; ctx.telemetry.currentPowerW = telemetry().currentPowerW; diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 1481be1..b1ccddd 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -446,6 +446,60 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime) publishTelemetry(false); } +namespace { +//! Recul en deçà duquel on ne conclut PAS à une nouvelle session : bruit de lecture. Même +//! principe que le seuil du plugin V2C, transposé en Wh. +constexpr double BruitAvancementWh = 50.0; +} + +EcoProgress EnergyArbitrator::ecoProgress(const LoadContext &lc) const +{ + EcoProgress p; + p.targetWh = lc.needs.minEnergyWhPerDay; + + // Pas d'obligation déclarée : rien à mesurer, et surtout rien à publier. Une charge sans + // obligation éco n'aura jamais d'entrée « eco » — l'absence EST l'information (R1). + if (p.targetWh <= 0) { + m_ecoBaseWh.remove(lc.id); + return p; // regime vide : l'appelant n'émet pas de niveau éco + } + + // C1 de LM-1009 — la borne ne compte pas : l'obligation n'est PAS mesurable, et ça se dit. + // `deliveredWh` reste absent, jamais zéro : une dégradation ne doit pas se lire comme un + // avancement nul, sans quoi l'échéance continue de s'afficher comme si elle tenait. + if (!lc.telemetry.sessionMeasurable) { + p.regime = QString::fromLatin1(ProgressRegime::Unmeasurable); + m_ecoBaseWh.remove(lc.id); + return p; + } + + const double sessionWh = lc.telemetry.sessionWh; + + if (!m_ecoBaseWh.contains(lc.id)) { + // OUVERTURE de l'obligation : la base se prend ICI. Pas au démarrage de nymead — la + // reprendre là réinitialiserait l'avancement d'une session en cours sur une valeur + // déjà accumulée, et une recharge de nuit relue au matin repartirait de zéro. + m_ecoBaseWh.insert(lc.id, sessionWh); + } else if (sessionWh < m_ecoBaseWh.value(lc.id) - BruitAvancementWh) { + // RECUL FRANC de l'accumulateur : le plugin l'a remis à la valeur brute, donc une + // nouvelle session s'est ouverte. On recapture, sinon la différence devient négative. + // Ce n'est PAS la détection de remise à zéro de la borne — celle-là vit dans le plugin, + // à 30 s, là où le moteur travaille à la minute et raterait des cycles entiers. + qCInfo(dcNymeaEnergy()) << "[Arbitre] Avancement éco" << lc.id + << "— l'accumulateur a reculé de" + << (m_ecoBaseWh.value(lc.id) - sessionWh) + << "Wh : nouvelle session, base reprise à" << sessionWh << "Wh."; + m_ecoBaseWh.insert(lc.id, sessionWh); + } + + p.regime = QString::fromLatin1(ProgressRegime::Measured); + p.delivered = true; + // Un recul INFIME laisse la base au-dessus de la valeur lue ; l'écrêtage évite un + // avancement négatif sans masquer un vrai recul, qui a déjà provoqué la recapture. + p.deliveredWh = qMax(0.0, sessionWh - m_ecoBaseWh.value(lc.id)); + return p; +} + void EnergyArbitrator::shedWaterfallForOverload(const QDateTime &now) { m_shedActions.clear(); @@ -1424,6 +1478,26 @@ void EnergyArbitrator::buildTelemetry(const Plan &plan, const Slot &slot, const for (auto it = action.counts.constBegin(); it != action.counts.constEnd(); ++it) counts.insert(it.key(), it.value()); niveau.insert(QStringLiteral("counts"), counts); + + // R5 — LE RÉGIME D'AVANCEMENT, porté par le NIVEAU et non par la charge. Publié dès + // maintenant, alors qu'aucune décision ne s'y appuie encore : c'est délibéré, et c'est + // ce qui rendra R6 (`ECO_FLOOR_MET` interdit sous `unmeasurable`) testable le jour où le + // motif naîtra. On sépare PUBLIER LE RÉGIME de DÉCIDER AVEC, exactement comme + // `measurement` a été séparé de l'arbitrage. + // + // Absent quand aucune obligation n'est déclarée : une charge sans obligation éco ne + // porte pas un avancement à zéro, elle n'en porte pas (R1). + const EcoProgress avancement = ecoProgress(adapter->toLoadContext(now)); + if (!avancement.regime.isEmpty()) { + QVariantMap progress; + progress.insert(QStringLiteral("regime"), avancement.regime); + progress.insert(QStringLiteral("targetWh"), avancement.targetWh); + // `deliveredWh` ABSENT sous `unmeasurable`, jamais 0 : une dégradation ne doit pas + // se lire comme un avancement nul (LM-1009 C1). C'est la moitié qui compte. + if (avancement.delivered) + progress.insert(QStringLiteral("deliveredWh"), avancement.deliveredWh); + niveau.insert(QStringLiteral("progress"), progress); + } entry.insert(QStringLiteral("levels"), QVariantList() << niveau); entry.insert(QStringLiteral("available"), adapter->telemetry().available); // R7 — LE MOTIF HÉRITÉ DIT DE QUEL NIVEAU IL PARLE. `decision` reste au niveau de la diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index 49769b8..6295a05 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -10,6 +10,7 @@ // qui permet au rebuild incrémental (ECS-412) de comparer l'ancienne et la nouvelle config. #include "types/loadconfig.h" #include "types/drawcap.h" +#include "types/ecoprogress.h" #include #include @@ -344,6 +345,34 @@ public: */ QList phaseDrawW(Electricity::Phases masque, uint limiteA) const; + /*! + * \brief [§10-C] Avancement d'une obligation éco, et le régime qui dit ce qu'il vaut. + * + * \param lc Charge du cycle. \return L'avancement — \c regime toujours renseigné. + * + * \par La BASE, et les deux questions qu'elle pose + * `sessionWh` est un ACCUMULATEUR de plugin, pas un compteur remis à zéro pour nous : côté + * V2C, l'écart entre `sessionEnergy` et `chargeEnergy` est constant (7,2044 kWh mesurés sur + * deux échantillons à deux heures d'écart), parce qu'il porte les incréments d'avant la + * dernière remise à zéro de la borne. **Le lire directement ferait croire toute obligation + * tenue de 7,2 kWh dès le premier cycle** — un nombre crédible et faux que rien ne + * signalerait. L'avancement est donc une DIFFÉRENCE depuis une base. + * + * **Quand la base est-elle prise ?** À l'ouverture de l'OBLIGATION, jamais au démarrage de + * nymead : la reprendre au démarrage réinitialiserait l'avancement d'une session en cours + * sur une valeur déjà accumulée, et une recharge de nuit relue au matin repartirait de zéro. + * + * **Et si `sessionWh` RECULE ?** C'est une nouvelle session : le plugin remet son + * accumulateur à la valeur brute à l'ouverture. La base est alors RECAPTURÉE, sinon la + * différence devient négative. On ne refait pas la détection de remise à zéro de la borne — + * le plugin la fait déjà, à 30 s, là où le moteur travaille à la minute et raterait des + * cycles entiers. On ne détecte ici que le recul de l'accumulateur LUI-MÊME. + * + * \note Un recul infime est du bruit de lecture, pas une nouvelle session — même seuil de + * principe que le plugin. En dessous, la base suit la valeur sans rien conclure. + */ + EcoProgress ecoProgress(const LoadContext &lc) const; + /*! * \brief [L4] Déleste les charges du waterfall tant que le plafond de tirage est dépassé. * @@ -690,6 +719,10 @@ private: //! loadId → action de DÉLESTAGE L4 du cycle. Revendication : le dispatch saute ces charges, //! un seul commandeur par organe (règle absolue 1). Vidée à chaque évaluation de L4. QHash m_shedActions; + //! loadId → énergie de session (Wh) au moment où l'obligation s'est OUVERTE. Base des + //! différences d'avancement. Recapturée quand l'accumulateur de la borne recule (nouvelle + //! session), jamais au démarrage de nymead — voir \c ecoProgress(). + mutable QHash m_ecoBaseWh; ThingManager *m_tm = nullptr; //!< ThingManager (pour construire les adaptateurs config). LoadConfigStore *m_loadConfigStore = nullptr; //!< Store config charge pilotée (non adopté). diff --git a/energyplugin/etm/etm.pri b/energyplugin/etm/etm.pri index a49d7c3..8321999 100644 --- a/energyplugin/etm/etm.pri +++ b/energyplugin/etm/etm.pri @@ -5,6 +5,7 @@ HEADERS += \ $$PWD/types/surpluscontext.h \ $$PWD/types/plan.h \ $$PWD/types/drawcap.h \ + $$PWD/types/ecoprogress.h \ $$PWD/types/loadconfig.h \ $$PWD/config/loadconfigstore.h \ $$PWD/adapters/iloadadapter.h \ diff --git a/energyplugin/etm/types/ecoprogress.h b/energyplugin/etm/types/ecoprogress.h new file mode 100644 index 0000000..33cdcdf --- /dev/null +++ b/energyplugin/etm/types/ecoprogress.h @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync +#pragma once + +#include + +/*! + * \file ecoprogress.h + * \brief Avancement d'une obligation éco — et le RÉGIME qui dit ce que vaut cet avancement. + */ + +//! \brief Codes de régime publiés (R5). Opaques, comparés jamais traduits. +namespace ProgressRegime { +//! L'avancement est LU sur un compteur d'appareil. Le seul régime sur lequel on peut conclure. +constexpr const char *Measured = "measured"; +//! L'avancement est une INTÉGRATION du moteur (LM-1009 D3) : une estimation, jamais une lecture. +constexpr const char *Estimated = "estimated"; +//! Rien ne mesure l'avancement. État à part entière, et POSITIF : c'est C1 de LM-1009. +constexpr const char *Unmeasurable = "unmeasurable"; +} + +/*! + * \brief Avancement d'une obligation, avec son régime. + * + * \par Pourquoi le régime accompagne TOUJOURS la valeur + * Un avancement mesuré et un avancement estimé ne se distinguent par aucune de leurs valeurs : + * les deux sont un nombre qui monte. Si le régime ne se lit pas, le second se fait passer pour + * le premier — C2 de \rule{LM-1009}, et la même doctrine que \c measurement.source pour la + * puissance : **publier le régime, ne jamais le laisser déduire.** + */ +struct EcoProgress { + //! Un des codes de \c ProgressRegime. Toujours renseigné quand l'obligation existe. + QString regime; + + //! \brief Vrai si \c deliveredWh a un sens. Faux sous \c unmeasurable — et le champ est + //! alors ABSENT de la charge utile, jamais à 0 : une dégradation ne doit pas se lire + //! comme un avancement nul (\rule{LM-1009} C1). + bool delivered = false; + + //! Énergie livrée depuis l'ouverture de l'obligation (Wh). Sans objet si \c !delivered. + double deliveredWh = 0; + + //! Énergie à livrer (Wh), déclarée par \c needs. 0 = aucune obligation en cours. + double targetWh = 0; +}; diff --git a/energyplugin/etm/types/surpluscontext.h b/energyplugin/etm/types/surpluscontext.h index 90c3530..0fecc5e 100644 --- a/energyplugin/etm/types/surpluscontext.h +++ b/energyplugin/etm/types/surpluscontext.h @@ -75,7 +75,24 @@ struct LoadContextTelemetry { // --- evcharger --- bool pluggedIn = false; //!< Véhicule branché. Une borne non branchée est ignorée du plan. bool charging = false; //!< Charge effectivement en cours (courant non nul côté borne). - double sessionWh = 0; //!< Énergie chargée dans la session courante (Wh). + /*! + * \brief La borne COMPTE-T-ELLE l'énergie de sa session ? CAPACITÉ, pas valeur. + * + * Faux ⇒ \c sessionWh est sans objet, et l'avancement est \c unmeasurable. La question est + * posée à la ThingClass : une session qui démarre mesure 0 Wh et reste mesurable. Sans ce + * booléen, les deux seraient indistinguables et le régime basculerait d'un cycle à l'autre + * sur une installation dont rien n'a changé (\rule{LM-1009} C1). + */ + bool sessionMeasurable = false; + + /*! + * \brief Énergie chargée dans la session courante (Wh) — ACCUMULATEUR, pas compteur brut. + * + * Côté V2C, le plugin l'accumule déjà à travers les remises à zéro de la borne, et le + * **remet à la valeur brute à chaque nouvelle session**. Il peut donc RECULER. Le moteur ne + * refait pas cette accumulation : il en prend une BASE et détecte le recul. + */ + double sessionWh = 0; // --- charges pilotées en watts : fenêtre de verrou (ECS-306) --- //! Plancher (W) imposé MAINTENANT par le verrou \c minOn : puissance déjà engagée que //! l'adaptateur ne peut pas couper. Le scheduler DOIT décrémenter le budget de cette diff --git a/energyplugin/evcharger.cpp b/energyplugin/evcharger.cpp index f5c0649..2533fc5 100644 --- a/energyplugin/evcharger.cpp +++ b/energyplugin/evcharger.cpp @@ -299,6 +299,19 @@ ThingActionInfo* EvCharger::setChargingEnabled(bool power, const QDateTime &curr return info; } +bool EvCharger::hasSessionEnergy() const +{ + return thing()->thingClass().hasStateType("sessionEnergy"); +} + +double EvCharger::sessionEnergyWh() const +{ + if (!hasSessionEnergy()) + return 0; + // La borne publie des kWh (unit KiloWattHour) ; le modèle raisonne en Wh. + return thing()->stateValue("sessionEnergy").toDouble() * 1000.0; +} + bool EvCharger::pluggedIn() const { if (thing()->hasState("pluggedIn")) diff --git a/energyplugin/evcharger.h b/energyplugin/evcharger.h index 1c8ce82..91d39a5 100644 --- a/energyplugin/evcharger.h +++ b/energyplugin/evcharger.h @@ -59,6 +59,26 @@ public: ThingActionInfo *setChargingEnabled(bool power, const QDateTime ¤tDateTime, bool force = false); bool pluggedIn() const; + + /*! + * \brief [ETM §10-C] La borne COMPTE-T-ELLE l'énergie de sa session ? + * \return Vrai si la ThingClass porte \c sessionEnergy — une CAPACITÉ, pas une valeur. + * \note Question posée à la ThingClass, jamais à la valeur du moment : une session qui + * démarre mesure 0 kWh et reste parfaitement mesurable. Même distinction que + * \c LoadMeasurement::available (\rule{LM-1106}), et pour la même raison — sans elle, le + * régime d'avancement basculerait d'un cycle à l'autre sur une installation inchangée. + */ + bool hasSessionEnergy() const; + + /*! + * \brief [ETM §10-C] Énergie livrée dans la session courante (Wh). + * \return La valeur publiée par la borne, convertie en Wh ; 0 si l'état n'existe pas. + * \note C'est un ACCUMULATEUR de plugin, pas un compteur brut : côté V2C il est monotone à + * travers les remises à zéro de la borne, et **remis à la valeur brute à chaque nouvelle + * session**. Il peut donc RECULER d'une session à l'autre. Le moteur ne refait pas cette + * accumulation — il détecte le recul (\rule{LM-1012-b}). + */ + double sessionEnergyWh() const; bool charging() const; uint maxChargingCurrentMaxValue() const; diff --git a/specs/spec_loadmodel.md b/specs/spec_loadmodel.md index ec0f5e5..97ecdcc 100644 --- a/specs/spec_loadmodel.md +++ b/specs/spec_loadmodel.md @@ -923,6 +923,40 @@ Comportement observé, non postulé : forçage interdit, la PAC de test **reste fait redescendre d'**état**, vers 2 et jamais 1 (`ECS-413`). C'est le cas que le bornage de la cascade (`LM-1210`) ne pouvait pas traiter — il empêche d'allouer, il ne retire rien à personne. +**LM-1012-b — l'avancement est une DIFFÉRENCE depuis une base, jamais une lecture directe.** +*(§10 étape C, 2026-08-31. Mesuré sur le banc, et ça ne se déduisait pas du code.)* + +`sessionEnergy` est l'**accumulateur du plugin**, pas un compteur remis à zéro pour le moteur. +Côté V2C, l'écart avec `chargeEnergy` est constant au dix-millième — 7,2044 kWh sur deux +échantillons à deux heures d'écart — parce qu'il porte les incréments d'avant la dernière remise +à zéro de la borne. **Le lire directement ferait croire toute obligation éco tenue de 7,2 kWh dès +le premier cycle** : un nombre crédible et faux, que rien ne signalerait. + +**LM-1012-b-i — la base se prend à l'ouverture de l'OBLIGATION, jamais au démarrage de nymead.** +La prendre au démarrage réinitialiserait l'avancement d'une session en cours sur une valeur déjà +accumulée : une recharge de nuit relue au matin repartirait de zéro. + +**LM-1012-b-ii — un recul FRANC de l'accumulateur recapture la base.** Le plugin le remet à la +valeur brute à chaque nouvelle session ; sans recapture, la différence deviendrait négative. Un +recul **infime** est du bruit de lecture et ne conclut à rien — la base reste, sinon l'avancement +acquis se perdrait à chaque frémissement du compteur. + +> **Ce que le moteur ne refait PAS.** La détection de remise à zéro de la BORNE vit dans le +> plugin, qui interroge à 30 s. Le moteur travaille à la minute et raterait des cycles entiers ; +> il ne détecte donc que le recul de l'**accumulateur lui-même**. Regarder comment le plugin l'a +> fait avant de le refaire est ce qui a évité d'écrire une seconde accumulation, moins fine, en +> concurrence de la première. + +**LM-1012-c — le RÉGIME d'avancement se publie, porté par le niveau (R5).** +`progress.regime` ∈ `measured` \| `estimated` \| `unmeasurable`. Sous `unmeasurable`, +`deliveredWh` est **absent, jamais 0** : une dégradation ne doit pas se lire comme un avancement +nul, sans quoi l'échéance continue de s'afficher comme si elle tenait (`LM-1009` C1). + +Le régime est publié **alors qu'aucune décision ne s'y appuie encore**, et c'est délibéré : cela +sépare **publier le régime** de **décider avec**, exactement comme `measurement` a été séparé de +l'arbitrage. C'est ce qui rendra **R6** — `ECO_FLOOR_MET` interdit sous `unmeasurable` — testable +le jour où le motif naîtra, au lieu d'un invariant posé sur un code que rien n'émet. + **LM-1011 — `BELOW_MIN_POWER` prime sur `DRAW_CAP` dès qu'il reste de l'autorisation.** *(Précédence tranchée par Patrick le 2026-08-29, sur une question de l'agent app. Écrite ICI, une fois, pour qu'aucun côté de la frontière ne l'établisse pour son compte.)* diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 9e67983..d91e003 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -6683,6 +6683,103 @@ void Simulation::testDrawAuthorisationSurvivesTheBatteryReserve() #endif } +/*! + * \brief [§10 étape C] L'avancement est une DIFFÉRENCE depuis une base, et le régime se publie. + * + * \par Le piège, mesuré sur le banc le 2026-08-31 + * `sessionEnergy` est l'ACCUMULATEUR du plugin V2C, pas un compteur remis à zéro pour nous : + * l'écart avec `chargeEnergy` est constant au dix-millième (7,2044 kWh sur deux échantillons à + * deux heures d'écart), parce qu'il porte les incréments d'avant la dernière remise à zéro de la + * borne. **Le lire directement ferait croire toute obligation tenue de 7,2 kWh dès le premier + * cycle** — un nombre crédible et faux que rien ne signalerait. + * + * \par Les deux questions que la base pose, et leurs réponses + * 1. **Quand est-elle prise ?** À l'ouverture de l'OBLIGATION, jamais au démarrage de nymead : + * sinon un redémarrage en pleine session réinitialise l'avancement sur une valeur déjà + * accumulée, et la recharge de nuit relue au matin repart de zéro. + * 2. **Que devient-elle si `sessionWh` recule ?** Le plugin remet son accumulateur à la valeur + * brute à chaque nouvelle session : la base est RECAPTURÉE, sinon la différence devient + * négative. On ne refait pas la détection de remise à zéro de la BORNE — elle vit dans le + * plugin, à 30 s, là où le moteur travaille à la minute et raterait des cycles entiers. + * + * \warning **Ce test devient faux** si `sessionEnergy` cesse d'être un accumulateur de plugin + * pour devenir un compteur remis à zéro par session : la recapture serait alors sans objet, et + * c'est l'assertion 4 qu'il faudrait retirer, pas le moteur. + */ +void Simulation::testEcoProgressBaseAndRegime() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + + EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); + QVERIFY(arb); + + LoadContext lc; + lc.id = "borne-eco"; + lc.needs.minEnergyWhPerDay = 10000; // obligation : 10 kWh par jour + + // 1. SANS obligation déclarée, il n'y a rien à publier — pas un avancement à zéro. Une + // charge sans obligation éco ne porte pas d'entrée « eco » : l'absence EST l'information. + LoadContext sansObligation = lc; + sansObligation.needs.minEnergyWhPerDay = 0; + QVERIFY2(arb->ecoProgress(sansObligation).regime.isEmpty(), + "sans obligation, aucun régime : le niveau éco n'existe pas"); + + // 2. Borne qui ne COMPTE PAS : régime `unmeasurable`, et `deliveredWh` SANS OBJET. + // C1 de LM-1009 — une dégradation ne doit jamais se lire comme un avancement nul, sinon + // l'échéance continue de s'afficher comme si elle tenait. + LoadContext nonMesurable = lc; + nonMesurable.telemetry.sessionMeasurable = false; + nonMesurable.telemetry.sessionWh = 7204; // valeur présente mais SANS SIGNIFICATION + const EcoProgress sansMesure = arb->ecoProgress(nonMesurable); + QCOMPARE(sansMesure.regime, QString(ProgressRegime::Unmeasurable)); + QCOMPARE(sansMesure.delivered, false); + QVERIFY2(qFuzzyIsNull(sansMesure.deliveredWh), + "sous unmeasurable, deliveredWh n'est pas publié — et vaut donc 0 en interne"); + + // 3. LE PIÈGE LUI-MÊME. L'accumulateur vaut déjà 7204 Wh à l'ouverture de l'obligation. + // L'avancement doit être NUL, pas 7204 : la base est prise ICI. + LoadContext mesuree = lc; + mesuree.telemetry.sessionMeasurable = true; + mesuree.telemetry.sessionWh = 7204; + const EcoProgress ouverture = arb->ecoProgress(mesuree); + QCOMPARE(ouverture.regime, QString(ProgressRegime::Measured)); + QCOMPARE(ouverture.delivered, true); + QCOMPARE(qRound(ouverture.deliveredWh), 0); + QCOMPARE(qRound(ouverture.targetWh), 10000); + + // …et l'avancement suit la DIFFÉRENCE, pas la valeur lue. + mesuree.telemetry.sessionWh = 9204; + QCOMPARE(qRound(arb->ecoProgress(mesuree).deliveredWh), 2000); + + // 4. RECUL FRANC : le plugin a remis son accumulateur à la valeur brute d'une nouvelle + // session. La base est recapturée — sans quoi la différence serait NÉGATIVE. + mesuree.telemetry.sessionWh = 120; + QCOMPARE(qRound(arb->ecoProgress(mesuree).deliveredWh), 0); + mesuree.telemetry.sessionWh = 620; + QCOMPARE(qRound(arb->ecoProgress(mesuree).deliveredWh), 500); + + // 5. Recul INFIME : c'est du bruit de lecture, pas une nouvelle session. La base ne bouge + // pas — la recapturer ici perdrait l'avancement acquis à chaque frémissement du compteur. + mesuree.telemetry.sessionWh = 610; + const EcoProgress bruit = arb->ecoProgress(mesuree); + QCOMPARE(qRound(bruit.deliveredWh), 490); + QVERIFY2(bruit.deliveredWh >= 0, "un avancement ne peut pas être négatif"); + + // 6. L'obligation retirée LIBÈRE la base : une nouvelle obligation repart d'un repère neuf, + // et n'hérite pas de l'avancement d'une obligation qui n'existe plus. + LoadContext retiree = mesuree; + retiree.needs.minEnergyWhPerDay = 0; + arb->ecoProgress(retiree); + mesuree.telemetry.sessionWh = 5000; + QCOMPARE(qRound(arb->ecoProgress(mesuree).deliveredWh), 0); +#endif +} + void Simulation::testCountsSumToTarget() { #ifndef ETM_ARBITRATOR diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index f36459e..17ae4b6 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -194,6 +194,7 @@ private slots: void testPhaseAllowanceReadsTheStateItTests(); void testDrawCapBoundsTheCascade(); void testDrawAuthorisationSurvivesTheBatteryReserve(); + void testEcoProgressBaseAndRegime(); void testARefusedStateIsDistinguishableFromAnApplied(); void testL4ShedsAPacAndSaysItForcedTheLock(); diff --git a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json index 0dbae05..410f984 100644 --- a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json +++ b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json @@ -12,8 +12,13 @@ "name": "meter", "displayName": "Mocked Meter", "id": "2721a051-6e12-471a-baba-21d87c4cebc9", - "createMethods": ["user"], - "interfaces": ["energymeter", "connectable"], + "createMethods": [ + "user" + ], + "interfaces": [ + "energymeter", + "connectable" + ], "paramTypes": [ { "id": "7abcc8a1-08b1-45bc-9116-10f9848359f9", @@ -112,7 +117,7 @@ "displayName": "Current power phase B", "type": "double", "unit": "Watt", - "defaultValue": 0 + "defaultValue": 0 }, { "id": "cfdc65bf-8e5a-40dd-acf3-79f3b86fd808", @@ -128,7 +133,7 @@ "displayName": "Total energy consumed", "type": "double", "unit": "KiloWattHour", - "defaultValue": 0.00 + "defaultValue": 0.0 }, { "id": "0420b758-e77f-4cf5-a30b-a6e1235b1efd", @@ -136,7 +141,7 @@ "displayName": "Total energy produced", "type": "double", "unit": "KiloWattHour", - "defaultValue": 0.00 + "defaultValue": 0.0 } ] }, @@ -144,8 +149,14 @@ "name": "charger", "displayName": "Mocked charger", "id": "5a3ae99f-c7da-46df-9104-f477be4606b7", - "createMethods": ["user"], - "interfaces": ["evcharger", "smartmeterconsumer", "connectable"], + "createMethods": [ + "user" + ], + "interfaces": [ + "evcharger", + "smartmeterconsumer", + "connectable" + ], "paramTypes": [ { "id": "652624a2-8f9a-4bc3-b34f-5e3492af4d30", @@ -159,7 +170,15 @@ "name": "phases", "displayName": "Phases connected", "type": "QString", - "allowedValues": ["A", "B", "C", "AB", "BC", "AC", "ABC"], + "allowedValues": [ + "A", + "B", + "C", + "AB", + "BC", + "AC", + "ABC" + ], "defaultValue": "A" }, { @@ -195,7 +214,7 @@ "displayName": "Maximum charging current", "displayNameAction": "Set maximum charging current", "type": "double", - "defaultValue":6, + "defaultValue": 6, "minValue": 6, "maxValue": 32, "unit": "Ampere", @@ -239,7 +258,15 @@ "name": "usedPhases", "displayName": "Used phases", "type": "QString", - "possibleValues": ["A", "B", "C", "AB", "BC", "AC", "ABC"], + "possibleValues": [ + "A", + "B", + "C", + "AB", + "BC", + "AC", + "ABC" + ], "defaultValue": "A" }, { @@ -304,7 +331,7 @@ "displayName": "Current power phase B", "type": "double", "unit": "Watt", - "defaultValue": 0 + "defaultValue": 0 }, { "id": "859d3a00-5896-44a9-b1f4-e44757079c44", @@ -335,8 +362,14 @@ "name": "chargerPhaseSwitching", "displayName": "Mocked charger with phase count switching", "id": "9208d9f0-280c-469d-a145-106f3277470c", - "createMethods": ["user"], - "interfaces": ["evcharger", "smartmeterconsumer", "connectable"], + "createMethods": [ + "user" + ], + "interfaces": [ + "evcharger", + "smartmeterconsumer", + "connectable" + ], "paramTypes": [ { "id": "d4492038-51bf-4f3c-8b93-89af4d8edd6b", @@ -350,7 +383,15 @@ "name": "phases", "displayName": "Phases connected", "type": "QString", - "allowedValues": ["A", "B", "C", "AB", "BC", "AC", "ABC"], + "allowedValues": [ + "A", + "B", + "C", + "AB", + "BC", + "AC", + "ABC" + ], "defaultValue": "ABC" }, { @@ -388,7 +429,7 @@ "displayName": "Maximum charging current", "displayNameAction": "Set maximum charging current", "type": "double", - "defaultValue":6, + "defaultValue": 6, "minValue": 6, "maxValue": 32, "unit": "Ampere", @@ -402,6 +443,14 @@ "unit": "KiloWattHour", "defaultValue": 0 }, + { + "id": "7c1e4a92-3f6b-4d18-9a54-2b8e0d5f1c73", + "name": "sessionEnergy", + "displayName": "Session energy", + "type": "double", + "unit": "KiloWattHour", + "defaultValue": 0.0 + }, { "id": "fdd5c075-72f8-48e1-bc07-5ec69bae6179", "name": "pluggedIn", @@ -434,7 +483,15 @@ "name": "usedPhases", "displayName": "Used phases", "type": "QString", - "possibleValues": ["A", "B", "C", "AB", "BC", "AC", "ABC"], + "possibleValues": [ + "A", + "B", + "C", + "AB", + "BC", + "AC", + "ABC" + ], "defaultValue": "A" }, { @@ -443,7 +500,10 @@ "displayName": "Desired number of charging phases", "displayNameAction": "Set desired number of charging phases", "type": "uint", - "possibleValues": [1, 3], + "possibleValues": [ + 1, + 3 + ], "defaultValue": 1, "minValue": 1, "maxValue": 3, @@ -511,7 +571,7 @@ "displayName": "Current power phase B", "type": "double", "unit": "Watt", - "defaultValue": 0 + "defaultValue": 0 }, { "id": "acc9e040-5ed9-4813-843b-89fa974c9ed5", @@ -542,8 +602,13 @@ "name": "simpleCharger", "displayName": "Mocked charger simple", "id": "29bcf255-b654-4764-be92-399bc26fe7c3", - "createMethods": ["user"], - "interfaces": ["evcharger", "connectable"], + "createMethods": [ + "user" + ], + "interfaces": [ + "evcharger", + "connectable" + ], "paramTypes": [ { "id": "e94f863b-47a2-44e4-8103-bb4b8a817f47", @@ -585,7 +650,7 @@ "displayName": "Maximum charging current", "displayNameAction": "Set maximum charging current", "type": "double", - "defaultValue":6, + "defaultValue": 6, "minValue": 6, "maxValue": 32, "unit": "Ampere", @@ -597,8 +662,12 @@ "name": "car", "displayName": "Mocked car", "id": "4513f801-836e-40a7-8784-c02650a9bdc6", - "createMethods": ["user"], - "interfaces": ["electricvehicle"], + "createMethods": [ + "user" + ], + "interfaces": [ + "electricvehicle" + ], "paramTypes": [ { "id": "29533bc8-d71e-4ce5-8ce3-bf87f0370391", @@ -702,8 +771,12 @@ "name": "notification", "displayName": "Mocked notification", "id": "ee1871b8-46f9-4784-bbe1-e33db16b8753", - "createMethods": ["user"], - "interfaces": ["notifications"], + "createMethods": [ + "user" + ], + "interfaces": [ + "notifications" + ], "paramTypes": [ { "id": "b3d60a6b-05f5-43f9-9ba0-04fb185c7825", @@ -713,7 +786,7 @@ "defaultValue": 6659 } ], - "stateTypes": [ ], + "stateTypes": [], "actionTypes": [ { "id": "28a02de9-0e93-42d7-88bf-539e024f8945", @@ -744,8 +817,12 @@ "name": "energyStorage", "displayName": "Mocked energy storage", "id": "d0d5bbf0-249c-46ed-ac6a-5f271b2b0b0f", - "createMethods": ["user"], - "interfaces": ["energystorage"], + "createMethods": [ + "user" + ], + "interfaces": [ + "energystorage" + ], "paramTypes": [ { "id": "b074a76f-6382-48b9-b101-1d13904f30c8", @@ -821,8 +898,12 @@ "name": "powerSwitch", "displayName": "Mocked Power Switch (relais ECS)", "id": "841f8905-d1d7-4053-909f-01123b497747", - "createMethods": ["user"], - "interfaces": ["power"], + "createMethods": [ + "user" + ], + "interfaces": [ + "power" + ], "paramTypes": [ { "id": "e3398429-45fd-4add-a789-4d11bfd9560f", @@ -864,7 +945,9 @@ "name": "etmVariableLoad", "displayName": "Mocked etmvariableload (ECS/routeur à puissance pilotable)", "id": "b7c41f0a-3e2d-4a91-9f6b-2c8e1d5a7b40", - "createMethods": ["user"], + "createMethods": [ + "user" + ], "paramTypes": [ { "id": "c8d52a1b-4f3e-4b02-a07c-3d9f2e6b8c51",