diff --git a/INTERFACE.md b/INTERFACE.md index f00966b..dae46f6 100644 --- a/INTERFACE.md +++ b/INTERFACE.md @@ -1296,6 +1296,54 @@ charge utile, et **seulement la sienne**. Toutes les clés internes sont optionn --- +### Taux d'énergie — `GetEnergyRatios` / `EnergyRatiosChanged` + +| champ | présence | sens | +|---|---|---| +| `selfConsumptionRate` | **omis** si n/a | part de la production consommée sur place, % dans [0, 100] | +| `autonomyRate` | **omis** si n/a | part de la consommation couverte sans soutirage, % | +| `periodStartedAt` | **toujours** | epoch (s) du **minuit local** qui ouvre la période de reporting | +| `measuredSince` | **toujours** | epoch (s) où la **baseline courante a été posée** | +| `timeZone` | **toujours** | identifiant IANA du fuseau où la frontière a été construite | + +Les trois derniers sont présents **même quand les deux taux sont omis** : « la période a commencé +à minuit et rien n'est encore mesurable » est une phrase vraie et utile, là où une réponse vide ne +se distingue pas d'une panne. + +> #### Pourquoi la frontière est PUBLIÉE et non recalculée +> +> `System.GetTime` publie un **nom IANA**. Un décalage ne s'en déduit pas sans base de fuseaux, et +> embarquer une base pour recalculer une frontière que le moteur connaît déjà serait le mauvais +> geste — le recalcul resterait **faux les jours de changement d'heure**, où la journée fait 23 ou +> 25 heures. Trois clients la recalculeraient de trois façons ; la seule qui compte est celle qui +> a servi au calcul. **Même règle que `counts`.** + +> #### `periodStartedAt` ≠ `measuredSince`, et l'écart est la seule chose qui empêche les ratios de mentir +> +> La baseline se repose à **chaque redémarrage de nymead** et à chaque recul de compteur — donc en +> plein jour. Un jour où nymead a redémarré à 14 h, ces ratios ne couvrent pas la journée : ils +> couvrent **depuis 14 h**. +> +> Avec la seule frontière, l'écran écrirait « aujourd'hui » sur des chiffres qui ne parlent que de +> l'après-midi. Avec la paire, il peut écrire la phrase vraie : **« depuis 14:00 »**. Quand les +> deux coïncident — le cas normal — il n'y a rien à dire de plus, et c'est ce qui rend l'écart +> lisible le jour où il apparaît. +> +> Même paire, même raison que `progress.measuredSince` / `targetWh` de l'avancement éco. + +> #### Ce que `timeZone` rend possible, et qui n'existait pas +> +> La période de reporting est la **troisième nature d'heure murale** du système, après l'habitude +> du client et le calendrier réglementé (`LM-1005-b`). Elle suit l'installation, ce qui est +> correct — **mais seulement si le fuseau de la box est celui de l'installation.** Relevé le +> 2026-09-04 : `.75` tourne en `Europe/London`, donc son bilan journalier se remet à zéro à +> **01:00 heure française**, et rien nulle part ne le disait. +> +> Publier le fuseau est ce qui rend cette vérification **possible** — c'est le seul des trois cas +> où personne ne contrôlait quoi que ce soit. + +--- + ### Spot market #### `NymeaEnergy.GetAvailableSpotMarketProviders` diff --git a/debian-qt5/changelog b/debian-qt5/changelog index 2b4aeae..9f4df87 100644 --- a/debian-qt5/changelog +++ b/debian-qt5/changelog @@ -1,3 +1,22 @@ +powersync-energy-plugin-nymea (1.15.2+etm56) trixie; urgency=medium + + * Divergence commande/mesure JOURNALISÉE à l'ouverture et à la fermeture + (qCInfo). Elle n'était que publiée dans la télémétrie et tenue dans un QHash + en mémoire : rien ne comptait les épisodes, le hash était purgé dès que + l'écart cessait, et chaque redémarrage effaçait l'état. Le silence du journal + était garanti par construction. + * GetEnergyRatios / EnergyRatiosChanged publient trois champs de plus : + periodStartedAt (epoch du minuit local), measuredSince (epoch de pose de la + baseline) et timeZone (IANA). Toujours présents, même quand les deux taux + sont omis. + * periodStartedAt ≠ measuredSince : la baseline se repose à chaque redémarrage + de nymead, donc en plein jour. Sans la paire, l'écran écrit « aujourd'hui » + sur des chiffres qui ne parlent que de l'après-midi. + * Le schéma des ratios est désormais déclaré UNE fois (energyRatiosSchema), + servi au Get et au Changed — invariant 11. + + -- ETM Schurig Sat, 05 Sep 2026 09:30:00 +0200 + powersync-energy-plugin-nymea (1.15.2+etm55) trixie; urgency=medium * §10 / LM-1013-b-v : sous le régime `unmeasurable`, la passe éco commande à diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 5d506d9..c6df08b 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -1814,11 +1814,33 @@ void EnergyArbitrator::attachCommandDivergence(QVariantMap &entry, const QString // 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) + // JOURNALISÉ AUX DEUX TRANSITIONS, et pas seulement publié. + // + // `divergent` et `since` ne vivaient que dans la charge utile de télémétrie et dans ce + // QHash en mémoire — donc nulle part. Rien ne comptait les épisodes : un redémarrage de + // nymead effaçait l'état, et le hash est PURGÉ dès que l'écart cesse, si bien qu'une + // divergence qui s'ouvre et se referme entre deux lectures ne laissait aucune trace. Même + // un observateur externe échantillonnant en continu aurait raté les épisodes courts, qui + // sont probablement les plus nombreux. + // + // Sans ces deux lignes, « attendre que les traces parlent » attend indéfiniment : le + // silence du journal était garanti par construction, pas par l'absence du phénomène. + if (!divergent) { + if (m_divergenceDepuis.contains(loadId)) { + const qint64 dureeS = m_divergenceDepuis.value(loadId).secsTo(QDateTime::currentDateTime()); + qCInfo(dcNymeaEnergy()) << "[Divergence] FIN" << loadId + << "après" << dureeS << "s" + << "— attendu" << attenduW << "W, mesuré" << mesureW << "W"; + } m_divergenceDepuis.remove(loadId); - else if (!m_divergenceDepuis.contains(loadId)) - m_divergenceDepuis.insert(loadId, m_lastTelemetryPublish.isValid() - ? m_lastTelemetryPublish : QDateTime::currentDateTime()); + } else if (!m_divergenceDepuis.contains(loadId)) { + const QDateTime debut = m_lastTelemetryPublish.isValid() + ? m_lastTelemetryPublish : QDateTime::currentDateTime(); + m_divergenceDepuis.insert(loadId, debut); + qCInfo(dcNymeaEnergy()) << "[Divergence] DÉBUT" << loadId + << "— attendu" << attenduW << "W, mesuré" << mesureW << "W," + << "écart" << (mesureW - attenduW) << "W"; + } QVariantMap commande; commande.insert(QStringLiteral("expectedW"), attenduW); diff --git a/energyplugin/etm/ratios/energyratioscalculator.cpp b/energyplugin/etm/ratios/energyratioscalculator.cpp index e74f41e..66ad162 100644 --- a/energyplugin/etm/ratios/energyratioscalculator.cpp +++ b/energyplugin/etm/ratios/energyratioscalculator.cpp @@ -24,6 +24,8 @@ #include "energyratioscalculator.h" +#include + #include void EnergyRatiosCalculator::reset() @@ -34,6 +36,9 @@ void EnergyRatiosCalculator::reset() m_baseConsumption = 0.0; m_baseAcquisition = 0.0; m_baseDay = 0; + // Sans cette ligne, un `mesureDepuis` périmé survivrait au reset et daterait la mesure + // d'avant l'installation qu'on vient de changer. + m_baselinePosee = QDateTime(); } EnergyRatiosCalculator::Ratios EnergyRatiosCalculator::compute(double totalProduction, @@ -59,6 +64,9 @@ EnergyRatiosCalculator::Ratios EnergyRatiosCalculator::compute(double totalProdu m_baseReturn = totalReturn; m_baseConsumption = totalConsumption; m_baseAcquisition = totalAcquisition; + // L'instant de pose, distinct de la frontière de journée : un redémarrage de nymead + // repose la baseline en plein jour, et les ratios ne couvrent alors que depuis là. + m_baselinePosee = now; } const double dProduction = totalProduction - m_baseProduction; @@ -69,6 +77,11 @@ EnergyRatiosCalculator::Ratios EnergyRatiosCalculator::compute(double totalProdu Ratios result; result.autoconsommationValid = ratio(dProduction - dReturn, dProduction, &result.autoconsommation); result.autonomieValid = ratio(dConsumption - dAcquisition, dConsumption, &result.autonomie); + // La frontière se construit dans le fuseau de `now` — le même que celui qui a servi à + // décider du reseed. En prendre un autre ferait publier une frontière que le calcul + // n'a pas utilisée. + result.debutPeriode = QDateTime(now.date(), QTime(0, 0), now.timeZone()); + result.mesureDepuis = m_baselinePosee; return result; } diff --git a/energyplugin/etm/ratios/energyratioscalculator.h b/energyplugin/etm/ratios/energyratioscalculator.h index 8faedf4..b1f2089 100644 --- a/energyplugin/etm/ratios/energyratioscalculator.h +++ b/energyplugin/etm/ratios/energyratioscalculator.h @@ -57,6 +57,29 @@ public: double autoconsommation = 0.0; //!< Part de la production consommée sur place, % dans [0, 100]. bool autonomieValid = false; //!< Faux = n/a (dénominateur nul) — ne pas afficher \c autonomie. double autonomie = 0.0; //!< Part de la consommation couverte sans soutirage, % dans [0, 100]. + + /*! + * \brief Début de la PÉRIODE de reporting en cours — le minuit local qui l'ouvre. + * + * Publié plutôt que laissé à déduire : \c System.GetTime donne un nom IANA, et un + * décalage ne s'en déduit pas sans base de fuseaux. Embarquer une base pour recalculer + * une frontière que le moteur connaît déjà serait le mauvais geste — et le recalcul + * resterait faux les jours de changement d'heure, où la journée fait 23 ou 25 heures. + */ + QDateTime debutPeriode; + + /*! + * \brief Instant où la BASELINE courante a été posée. Ce n'est PAS toujours + * \c debutPeriode, et l'écart est la seule chose qui empêche les ratios de mentir. + * + * La baseline se repose à chaque redémarrage de nymead et à chaque recul de compteur. + * Un jour où nymead a redémarré à 14 h, les ratios ne couvrent PAS la journée : ils + * couvrent depuis 14 h. Publier la seule frontière de journée ferait afficher + * « aujourd'hui » sur des chiffres qui ne parlent que de l'après-midi. + * + * Même paire, même raison que \c measuredSince / \c targetWh de l'avancement éco. + */ + QDateTime mesureDepuis; }; /*! \brief Construit sans baseline : le premier \c compute() la posera. */ @@ -91,6 +114,8 @@ private: static qint64 localDay(const QDateTime &now); bool m_hasBaseline = false; + //! Instant de pose de la baseline courante — publié en \c mesureDepuis. + QDateTime m_baselinePosee; double m_baseProduction = 0.0; double m_baseReturn = 0.0; double m_baseConsumption = 0.0; diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index cada151..1eb480a 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -35,6 +35,7 @@ #include #include +#include Q_DECLARE_LOGGING_CATEGORY(dcNymeaEnergy) NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketManager, SmartChargingManager *smartChargingManager, LoadConfigStore *loadConfigStore, EnergyManager *energyManager, QObject *parent): @@ -269,8 +270,7 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana description = "Get the current energy ratios (self-consumption / autonomy) in percent, " "derived from the energy manager's cumulative counters over the current local " "day. A rate field is omitted (never null) when not available (denominator <= 0)."; - returns.insert("o:selfConsumptionRate", enumValueName(Double)); - returns.insert("o:autonomyRate", enumValueName(Double)); + returns = energyRatiosSchema(); registerMethod("GetEnergyRatios", description, params, returns, Types::PermissionScopeControlThings); // [ETM] ECS-410 — levée du verrou de défaut d'une charge. Délibérée et journalisée : le @@ -434,8 +434,7 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana params.clear(); description = "Emitted whenever the energy ratios (self-consumption / autonomy) change. " "A rate field is omitted (never null) when not available."; - params.insert("o:selfConsumptionRate", enumValueName(Double)); - params.insert("o:autonomyRate", enumValueName(Double)); + params = energyRatiosSchema(); registerNotification("EnergyRatiosChanged", description, params); if (m_energyManager) { connect(m_energyManager, &EnergyManager::powerBalanceChanged, @@ -754,6 +753,29 @@ JsonReply *NymeaEnergyJsonHandler::SetLoadConfig(const QVariantMap ¶ms) // [Phase 2] Construit le map de ratios courant depuis les cumuls de l'energymanager. // Champ omis (jamais null) quand n/a. Met à jour l'état caché (m_lastRatios). +QVariantMap NymeaEnergyJsonHandler::energyRatiosSchema() +{ + QVariantMap champs; + champs.insert("o:selfConsumptionRate", enumValueName(Double)); + champs.insert("o:autonomyRate", enumValueName(Double)); + // La FRONTIÈRE de la période, publiée plutôt que recalculée par chaque client. Trois + // clients la recalculeraient de trois façons, et la seule qui compte est celle qui a servi + // au calcul. `System.GetTime` donne un nom IANA : un décalage ne s'en déduit pas sans base + // de fuseaux, et le recalcul resterait faux les jours de changement d'heure, où la journée + // fait 23 ou 25 heures. Même règle que `counts`. + champs.insert("periodStartedAt", enumValueName(Uint)); + // DEPUIS QUAND on mesure réellement — pas toujours la frontière. La baseline se repose à + // chaque redémarrage de nymead et à chaque recul de compteur ; un jour où nymead a + // redémarré à 14 h, ces ratios ne couvrent que l'après-midi. Sans ce champ, l'écran écrit + // « aujourd'hui » sur des chiffres qui ne parlent pas de la journée. Même paire, même + // raison que `measuredSince` / `targetWh` de l'avancement éco. + champs.insert("measuredSince", enumValueName(Uint)); + // Le fuseau dans lequel la frontière a été construite. Il rend la période VÉRIFIABLE : sans + // lui, un bilan journalier découpé au mauvais endroit se lit comme un bilan normal. + champs.insert("timeZone", enumValueName(String)); + return champs; +} + QVariantMap NymeaEnergyJsonHandler::currentEnergyRatios() { QVariantMap result; @@ -774,6 +796,14 @@ QVariantMap NymeaEnergyJsonHandler::currentEnergyRatios() result.insert("selfConsumptionRate", r.autoconsommation); if (r.autonomieValid) result.insert("autonomyRate", r.autonomie); + // Ces trois-là sont TOUJOURS présents, même quand les deux taux sont omis : « la période a + // commencé à minuit et rien n'est encore mesurable » est une phrase vraie et utile, alors + // qu'une réponse vide ne se distingue pas d'une panne. + result.insert("periodStartedAt", static_cast(r.debutPeriode.toSecsSinceEpoch())); + result.insert("measuredSince", + static_cast((r.mesureDepuis.isValid() ? r.mesureDepuis : r.debutPeriode) + .toSecsSinceEpoch())); + result.insert("timeZone", QString::fromUtf8(QTimeZone::systemTimeZoneId())); return result; } @@ -972,7 +1002,12 @@ void NymeaEnergyJsonHandler::onPowerBalanceChanged() const bool moved = !hadPrev || changed(prev.autoconsommationValid, prev.autoconsommation, now.autoconsommationValid, now.autoconsommation) - || changed(prev.autonomieValid, prev.autonomie, now.autonomieValid, now.autonomie); + || changed(prev.autonomieValid, prev.autonomie, now.autonomieValid, now.autonomie) + // La bascule de période et la repose de baseline sont des changements de la charge + // utile : ne pas les notifier laisserait un client connecté sur une frontière périmée + // — le silence ambigu que l'invariant 11 interdit. + || prev.debutPeriode != now.debutPeriode + || prev.mesureDepuis != now.mesureDepuis; if (moved) emit EnergyRatiosChanged(ratios); diff --git a/energyplugin/nymeaenergyjsonhandler.h b/energyplugin/nymeaenergyjsonhandler.h index 6b82337..0157091 100644 --- a/energyplugin/nymeaenergyjsonhandler.h +++ b/energyplugin/nymeaenergyjsonhandler.h @@ -156,6 +156,11 @@ private: * celui qu'on ne teste pas tous les jours. */ static QVariantMap loadTelemetrySchema(); + + //! \brief Charge utile des ratios — DÉCLARÉE UNE FOIS, servie au \c Get et au \c Changed. + //! Deux déclarations divergent, et c'est l'appel de reprise à froid qui casse (invariant 11). + //! \return Le schéma des champs, prêt pour \c registerMethod et \c registerNotification. + static QVariantMap energyRatiosSchema(); //! \return Charge utile courante ; carte MINIMALE (sans \c timestamp) si aucun cycle //! d'arbitrage n'a encore eu lieu — cf. \c EnergyArbitrator::loadTelemetry(). QVariantMap currentLoadTelemetry() const; diff --git a/specs/spec_loadmodel.md b/specs/spec_loadmodel.md index 2bd5109..0f43eb6 100644 --- a/specs/spec_loadmodel.md +++ b/specs/spec_loadmodel.md @@ -650,6 +650,29 @@ Seul le second peut coûter de l'argent, et seul le second est **indevinable par fournisseur. Les fusionner en un unique « les fuseaux diffèrent » ferait disparaître celui des deux qui compte derrière celui qui se corrige tout seul. +**LM-1005-b-iv — la frontière d'une période de reporting se PUBLIE, elle ne se recalcule pas.** +*(Demandé par l'agent app, posé le 2026-09-05.)* + +`GetEnergyRatios` publie `periodStartedAt` (epoch), `measuredSince` (epoch) et `timeZone` (IANA). + +**Pourquoi publier plutôt que laisser déduire.** `System.GetTime` donne un NOM de fuseau ; un +décalage ne s'en déduit pas sans base de fuseaux. Embarquer une base pour recalculer une frontière +que le moteur connaît déjà serait le mauvais geste — et le recalcul resterait **faux les jours de +changement d'heure**, où la journée fait 23 ou 25 heures. Trois clients la recalculeraient de +trois façons, et la seule qui compte est celle qui a servi au calcul. **Même règle que `counts`.** + +**Et c'est la PARADE à la troisième nature.** La période de reporting suit l'installation, ce qui +est correct — mais repose sur la prémisse que le fuseau de la box soit celui de l'installation, et +c'était le seul des trois cas où **personne ne vérifiait**. Publier la frontière et son fuseau est +exactement ce qui rend la vérification possible. + +**LM-1005-b-v — `periodStartedAt` et `measuredSince` sont DEUX champs, et les confondre fait +mentir les ratios.** La baseline se repose à chaque redémarrage de nymead et à chaque recul de +compteur — donc en plein jour. Un jour où nymead a redémarré à 14 h, les ratios ne couvrent pas la +journée : ils couvrent depuis 14 h. Avec la seule frontière, l'écran écrit « aujourd'hui » sur des +chiffres qui ne parlent que de l'après-midi. Même paire, même raison que +`progress.measuredSince` / `targetWh`. + **Conséquence de conception** : la passe 1 n'est **pas** bornée par le surplus. Elle est bornée par une **autorisation de soutirage** — un objet distinct, que le rule-based rendra binaire et que le MILP rendra continu. La passe 2, elle, reste strictement bornée par le reliquat de diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 9abb36c..0d6810d 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -977,6 +977,41 @@ void Simulation::testEnergyRatiosAlignment() // F — même jour (30) : auto=(600-200)/600≈66.667%, autonomie=(1000-500)/1000=50%. EnergyRatiosCalculator::Ratios f = calc.compute(2600, 700, 7000, 4500, d30_10); eqAuto(f, 66.6667); eqAuton(f, 50.0); + + // ── LA FRONTIÈRE DE PÉRIODE ET L'INSTANT DE MESURE, qui ne sont PAS le même instant ── + // + // Publier la seule frontière ferait écrire « aujourd'hui » sur des chiffres qui ne parlent + // pas de la journée : la baseline se repose à chaque redémarrage de nymead et à chaque + // recul de compteur, donc en plein jour. C'est la paire qui dit la vérité, pas le champ. + const QDateTime minuit29(QDate(2026, 6, 29), QTime(0, 0)); + const QDateTime minuit30(QDate(2026, 6, 30), QTime(0, 0)); + + // Cas NORMAL — pose à minuit : les deux coïncident, et c'est ce qui rend l'écart lisible + // quand il apparaît. + QCOMPARE(a.debutPeriode, minuit29); + QCOMPARE(a.mesureDepuis, d29_00); + + // Sans repose, l'instant de mesure NE BOUGE PAS : il date la baseline, pas le calcul. + QCOMPARE(b.debutPeriode, minuit29); + QCOMPARE(b.mesureDepuis, d29_00); + + // REPOSE EN PLEIN JOUR (compteur non monotone à 10:00) — LES DEUX DIVERGENT. C'est le cas + // qui justifie la paire : la frontière dit toujours minuit, la mesure ne couvre que depuis + // 10 h, et un écran qui n'aurait que la frontière l'affirmerait à tort sur la journée. + QCOMPARE(d.debutPeriode, minuit29); + QCOMPARE(d.mesureDepuis, d29_10); + QVERIFY2(d.mesureDepuis > d.debutPeriode, + "une repose en plein jour doit se VOIR : sinon les ratios mentent sur leur portée"); + + // BASCULE DE JOUR — la frontière suit le nouveau jour, et la mesure date du premier calcul + // APRÈS minuit, jamais de minuit lui-même. Sur une box qui calcule en continu l'écart est + // de quelques secondes ; l'affirmer nul serait une approximation que rien n'oblige à faire. + QCOMPARE(e.debutPeriode, minuit30); + QCOMPARE(e.mesureDepuis, d30_02); + + // Et la frontière ne rebouge pas dans la journée. + QCOMPARE(f.debutPeriode, minuit30); + QCOMPARE(f.mesureDepuis, d30_02); #endif }