diff --git a/docs/INTERFACE_energyratios.md b/docs/INTERFACE_energyratios.md new file mode 100644 index 0000000..7adde64 --- /dev/null +++ b/docs/INTERFACE_energyratios.md @@ -0,0 +1,98 @@ +# Contrat — Ratios énergétiques canoniques (`NymeaEnergy.GetEnergyRatios`) + +> **Statut :** Phase 2, voie (c). Source canonique côté energymanager. +> Remplace le seam interim app-side `EnergyRatiosInterim.compute()`. +> Exposé par `powersync-energy-plugin-nymea` (experience-plugin, namespace +> JSON-RPC `NymeaEnergy`) — **pas** sur `Energy.PowerBalance` (voir §5). + +## 1. Pourquoi (c) et pas des champs sur `PowerBalance` + +`PowerBalance` (namespace `Energy`) appartient à `nymea-experience-plugin-energy`, +un plugin **cœur** nymea. Y ajouter des champs imposerait de forker et rebaser ce +plugin à chaque montée de version nymea — coût d'infra perpétuel. La voie (c) +expose les ratios sur le handler `NymeaEnergy` de **notre** plugin (déjà forké, +déjà buildé), calculés à partir des **mêmes cumuls canoniques** de l'energymanager. +Valeurs strictement identiques à ce que (a) aurait produit ; seul le canal diffère. + +## 2. Sémantique + +Ratios dérivés par **Δ de cumuls** sur la **journée locale courante** (depuis +minuit local), pas par intégration du signal live → déterministe, pas de drift. + +``` +autonomie = (Δ totalConsumption − Δ totalAcquisition) / Δ totalConsumption +autoconsommation = (Δ totalProduction − Δ totalReturn) / Δ totalProduction +``` + +- Unité : **pourcent**, borné `[0, 100]`. +- `Δ X = X_courant − X_baseline`, baseline = cumuls au début de la période. +- **Reseed de la baseline** si : 1er calcul, **nouveau jour local**, OU compteur + **non monotone** (`Δ<0` : restart nymead / re-add thing / rollover). +- **Dénominateur ≤ 0** (nuit sans prod, etc.) → ratio **n/a** : le champ est + **OMIS** de la réponse. **Jamais `null`, jamais `NaN`.** + +C'est le portage 1:1 de `EnergyRatiosInterim.compute()` — l'app peut supprimer +ce calcul et lire la source. + +## 3. Méthode : `NymeaEnergy.GetEnergyRatios` + +**Params :** aucun. + +**Returns :** + +| Champ | Type | Présence | +|---|---|---| +| `selfConsumptionRate` | `Double` (%) | **optionnel** — omis si n/a | +| `autonomyRate` | `Double` (%) | **optionnel** — omis si n/a | + +Exemple (les deux disponibles) : +```json +{"id": 1, "method": "NymeaEnergy.GetEnergyRatios", "params": {}} +``` +```json +{"id": 1, "status": "success", + "params": {"selfConsumptionRate": 66.6667, "autonomyRate": 50.0}} +``` + +Exemple nuit (autoconso n/a, le champ disparaît) : +```json +{"id": 1, "status": "success", "params": {"autonomyRate": 50.0}} +``` + +> ⚠️ Champ absent = **n/a**. Ne pas confondre avec 0 %. Mapper l'absence sur +> ton `double?` (Dart `null`). + +## 4. Notification : `NymeaEnergy.EnergyRatiosChanged` + +Mêmes champs que `GetEnergyRatios`. Émise sur `powerBalanceChanged()` de +l'energymanager, **uniquement quand une valeur (ou sa disponibilité) change**. + +```json +{"notification": "NymeaEnergy.EnergyRatiosChanged", + "params": {"selfConsumptionRate": 80.0, "autonomyRate": 50.0}} +``` + +S'abonner via `JSONRPC.SetNotificationStatus` avec le namespace `NymeaEnergy` +(le même que `LoadConfigChanged`, `ChargingSchedulesChanged`, etc. — donc même +session, pas de nouvelle connexion). + +## 5. Migration côté app + +1. Au boot : `NymeaEnergy.GetEnergyRatios` pour l'état initial. +2. S'abonner à `NymeaEnergy.EnergyRatiosChanged` pour les MAJ. +3. **Supprimer** `EnergyRatiosInterim.compute()` et son état de baseline + (`_baseProduction`/`_baseReturn`/…/`_baseDay`) : la baseline/reseed/minuit-local + vivent désormais côté plugin. +4. Mapper champ-absent → `null` (n/a). Conserver `EnergyRatios{autoconsommation, + autonomie}` côté app ; seul le **producteur** change. + +> Si nymea expose un jour `selfConsumptionRate`/`autonomyRate` directement sur +> `PowerBalance` (feature request upstream ouverte séparément), la migration +> app sera triviale : lire les champs `PowerBalance` au lieu d'appeler +> `GetEnergyRatios`. Jamais bloqué, jamais de fork du cœur à porter. + +## 6. Frontière GPL + +Pur mesure (Δ de compteurs cumulés). Aucune logique d'optimisation / prévision / +pondération — celle-ci reste dans l'optimiseur propriétaire. Implémenté dans +`energyplugin/etm/ratios/energyratioscalculator.{h,cpp}` (GPL-3.0-or-later). diff --git a/energyplugin/energypluginnymea.cpp b/energyplugin/energypluginnymea.cpp index 5538f38..e5b21de 100644 --- a/energyplugin/energypluginnymea.cpp +++ b/energyplugin/energypluginnymea.cpp @@ -78,5 +78,5 @@ void EnergyPluginNymea::init() SmartChargingManager *chargingManager = new SmartChargingManager(energyManager(), thingManager(), spotMarketManager, configuration, this); #endif - jsonRpcServer()->registerExperienceHandler(new NymeaEnergyJsonHandler(spotMarketManager, chargingManager, loadConfigStore, this), 0, 8); + jsonRpcServer()->registerExperienceHandler(new NymeaEnergyJsonHandler(spotMarketManager, chargingManager, loadConfigStore, energyManager(), this), 0, 8); } diff --git a/energyplugin/etm/etm.pri b/energyplugin/etm/etm.pri index 78835e9..5068a5a 100644 --- a/energyplugin/etm/etm.pri +++ b/energyplugin/etm/etm.pri @@ -12,11 +12,13 @@ HEADERS += \ $$PWD/adapters/etmvariableloadadapter.h \ $$PWD/adapters/relayrouter.h \ $$PWD/scheduler/rulebasedscheduler.h \ + $$PWD/ratios/energyratioscalculator.h \ $$PWD/energyarbitrator.h \ SOURCES += \ $$PWD/types/loadconfig.cpp \ $$PWD/config/loadconfigstore.cpp \ + $$PWD/ratios/energyratioscalculator.cpp \ $$PWD/adapters/evadapter.cpp \ $$PWD/adapters/sgreadyadapter.cpp \ $$PWD/adapters/etmvariableloadadapter.cpp \ diff --git a/energyplugin/etm/ratios/energyratioscalculator.cpp b/energyplugin/etm/ratios/energyratioscalculator.cpp new file mode 100644 index 0000000..e74f41e --- /dev/null +++ b/energyplugin/etm/ratios/energyratioscalculator.cpp @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +* +* Copyright (C) 2013 - 2024, nymea GmbH +* Copyright (C) 2024 - 2025, chargebyte austria GmbH +* +* This file is part of nymea-energy-plugin-nymea. +* +* nymea-energy-plugin-nymea.s free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* nymea-energy-plugin-nymea.s distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with nymea-energy-plugin-nymea. If not, see . +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#include "energyratioscalculator.h" + +#include + +void EnergyRatiosCalculator::reset() +{ + m_hasBaseline = false; + m_baseProduction = 0.0; + m_baseReturn = 0.0; + m_baseConsumption = 0.0; + m_baseAcquisition = 0.0; + m_baseDay = 0; +} + +EnergyRatiosCalculator::Ratios EnergyRatiosCalculator::compute(double totalProduction, + double totalReturn, + double totalConsumption, + double totalAcquisition, + const QDateTime &now) +{ + const qint64 day = localDay(now); + + // Reseed si : 1er appel, nouveau jour local, OU compteur non monotone + // (Δ<0 → restart nymead / re-add thing / rollover). Jamais de ratio négatif. + const bool nonMonotone = m_hasBaseline && + (totalProduction < m_baseProduction || + totalReturn < m_baseReturn || + totalConsumption < m_baseConsumption || + totalAcquisition < m_baseAcquisition); + + if (!m_hasBaseline || m_baseDay != day || nonMonotone) { + m_hasBaseline = true; + m_baseDay = day; + m_baseProduction = totalProduction; + m_baseReturn = totalReturn; + m_baseConsumption = totalConsumption; + m_baseAcquisition = totalAcquisition; + } + + const double dProduction = totalProduction - m_baseProduction; + const double dReturn = totalReturn - m_baseReturn; + const double dConsumption = totalConsumption - m_baseConsumption; + const double dAcquisition = totalAcquisition - m_baseAcquisition; + + Ratios result; + result.autoconsommationValid = ratio(dProduction - dReturn, dProduction, &result.autoconsommation); + result.autonomieValid = ratio(dConsumption - dAcquisition, dConsumption, &result.autonomie); + return result; +} + +// Ratio en % borné [0, 100]. Dénominateur ≤ 0 (nuit sans prod, etc.) → n/a +// (retourne false) — jamais de NaN. +bool EnergyRatiosCalculator::ratio(double numerator, double denominator, double *outPercent) +{ + if (denominator <= 0) + return false; + *outPercent = std::clamp(numerator / denominator * 100.0, 0.0, 100.0); + return true; +} + +qint64 EnergyRatiosCalculator::localDay(const QDateTime &now) +{ + // Index de jour local stable (minuit local) — équivalent au _localDay interim. + return now.date().toJulianDay(); +} diff --git a/energyplugin/etm/ratios/energyratioscalculator.h b/energyplugin/etm/ratios/energyratioscalculator.h new file mode 100644 index 0000000..49915fb --- /dev/null +++ b/energyplugin/etm/ratios/energyratioscalculator.h @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +* +* Copyright (C) 2013 - 2024, nymea GmbH +* Copyright (C) 2024 - 2025, chargebyte austria GmbH +* +* This file is part of nymea-energy-plugin-nymea. +* +* nymea-energy-plugin-nymea.s free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* nymea-energy-plugin-nymea.s distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with nymea-energy-plugin-nymea. If not, see . +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#ifndef ENERGYRATIOSCALCULATOR_H +#define ENERGYRATIOSCALCULATOR_H + +#include + +// [Phase 2] Ratios énergétiques canoniques (autoconsommation / autonomie), en %. +// +// Source canonique côté energymanager : remplace le seam interim app-side +// (EnergyRatiosInterim.compute). PUR MESURE (frontière GPL) : dérive par Δ de +// cumuls sur la période affichée (journée depuis minuit local), pas par +// intégration du signal live → déterministe, pas de drift de polling. +// +// autonomie = (Δ totalConsumption − Δ totalAcquisition) / Δ totalConsumption +// autoconsommation = (Δ totalProduction − Δ totalReturn) / Δ totalProduction +// +// Reseed de la baseline : 1er appel, nouveau jour local, OU compteur non +// monotone (Δ<0 : restart nymead / re-add thing / rollover). Dénominateur ≤ 0 +// → n/a (valid=false), jamais de NaN. Porté 1:1 de l'interim app. +class EnergyRatiosCalculator +{ +public: + struct Ratios { + bool autoconsommationValid = false; // false = n/a (dénominateur nul) + double autoconsommation = 0.0; // % dans [0, 100] + bool autonomieValid = false; // false = n/a + double autonomie = 0.0; // % dans [0, 100] + }; + + EnergyRatiosCalculator() = default; + + // Réinitialise la baseline (ex. switch d'installation). + void reset(); + + // Calcule depuis les cumuls courants (mêmes unités en entrée — elles + // s'annulent dans le ratio). `now` (heure LOCALE) sert à détecter le jour. + Ratios compute(double totalProduction, + double totalReturn, + double totalConsumption, + double totalAcquisition, + const QDateTime &now); + +private: + static bool ratio(double numerator, double denominator, double *outPercent); + static qint64 localDay(const QDateTime &now); + + bool m_hasBaseline = false; + double m_baseProduction = 0.0; + double m_baseReturn = 0.0; + double m_baseConsumption = 0.0; + double m_baseAcquisition = 0.0; + qint64 m_baseDay = 0; +}; + +#endif // ENERGYRATIOSCALCULATOR_H diff --git a/energyplugin/nymeaenergyjsonhandler.cpp b/energyplugin/nymeaenergyjsonhandler.cpp index d19059f..26d86ce 100644 --- a/energyplugin/nymeaenergyjsonhandler.cpp +++ b/energyplugin/nymeaenergyjsonhandler.cpp @@ -34,11 +34,12 @@ #include Q_DECLARE_LOGGING_CATEGORY(dcNymeaEnergy) -NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketManager, SmartChargingManager *smartChargingManager, LoadConfigStore *loadConfigStore, QObject *parent): +NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketManager, SmartChargingManager *smartChargingManager, LoadConfigStore *loadConfigStore, EnergyManager *energyManager, QObject *parent): JsonHandler{parent}, m_spotMarketManager{spotMarketManager}, m_smartChargingManager{smartChargingManager}, - m_loadConfigStore{loadConfigStore} + m_loadConfigStore{loadConfigStore}, + m_energyManager{energyManager} { registerEnum(); @@ -182,6 +183,17 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana returns.insert("energyError", enumRef()); registerMethod("SetLoadConfig", description, params, returns, Types::PermissionScopeControlThings); + // [Phase 2] Ratios canoniques autoconsommation / autonomie (% [0,100]). Champ omis + // (jamais null) quand n/a (dénominateur ≤ 0). Source canonique de l'energymanager — + // remplace le seam interim app-side. + params.clear(); returns.clear(); + 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)); + registerMethod("GetEnergyRatios", description, params, returns, Types::PermissionScopeControlThings); + // Notifications params.clear(); @@ -291,6 +303,19 @@ NymeaEnergyJsonHandler::NymeaEnergyJsonHandler(SpotMarketManager *spotMarketMana }); } + // [Phase 2] EnergyRatiosChanged : émise sur powerBalanceChanged() de l'energymanager, + // uniquement quand une valeur (ou sa disponibilité) change. Mêmes champs que GetEnergyRatios. + 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)); + registerNotification("EnergyRatiosChanged", description, params); + if (m_energyManager) { + connect(m_energyManager, &EnergyManager::powerBalanceChanged, + this, &NymeaEnergyJsonHandler::onPowerBalanceChanged); + } + // Spot market manager params.clear(); description = "Emitted whenever the spot market manager status changed."; @@ -527,6 +552,60 @@ JsonReply *NymeaEnergyJsonHandler::SetLoadConfig(const QVariantMap ¶ms) return createReply({{"energyError", enumValueName(EnergyManager::EnergyErrorNoError)}}); } +// [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::currentEnergyRatios() +{ + QVariantMap result; + if (!m_energyManager) + return result; + + const EnergyRatiosCalculator::Ratios r = m_ratiosCalculator.compute( + m_energyManager->totalProduction(), + m_energyManager->totalReturn(), + m_energyManager->totalConsumption(), + m_energyManager->totalAcquisition(), + QDateTime::currentDateTime()); + + m_lastRatios = r; + m_lastRatiosValid = true; + + if (r.autoconsommationValid) + result.insert("selfConsumptionRate", r.autoconsommation); + if (r.autonomieValid) + result.insert("autonomyRate", r.autonomie); + return result; +} + +JsonReply *NymeaEnergyJsonHandler::GetEnergyRatios(const QVariantMap ¶ms) +{ + Q_UNUSED(params) + return createReply(currentEnergyRatios()); +} + +void NymeaEnergyJsonHandler::onPowerBalanceChanged() +{ + const EnergyRatiosCalculator::Ratios prev = m_lastRatios; + const bool hadPrev = m_lastRatiosValid; + + const QVariantMap ratios = currentEnergyRatios(); + const EnergyRatiosCalculator::Ratios &now = m_lastRatios; + + // N'émet que si une valeur (ou sa disponibilité) a réellement bougé. + auto changed = [](bool prevValid, double prevVal, bool nowValid, double nowVal) { + if (prevValid != nowValid) + return true; + return nowValid && qAbs(prevVal - nowVal) > 1e-6; + }; + + const bool moved = !hadPrev + || changed(prev.autoconsommationValid, prev.autoconsommation, now.autoconsommationValid, now.autoconsommation) + || changed(prev.autonomieValid, prev.autonomie, now.autonomieValid, now.autonomie); + + if (moved) + emit EnergyRatiosChanged(ratios); +} + void NymeaEnergyJsonHandler::sendSpotMarketConfigurationChangedNotification() { QVariantMap params; diff --git a/energyplugin/nymeaenergyjsonhandler.h b/energyplugin/nymeaenergyjsonhandler.h index 045cf51..855c4cd 100644 --- a/energyplugin/nymeaenergyjsonhandler.h +++ b/energyplugin/nymeaenergyjsonhandler.h @@ -30,16 +30,18 @@ #include #include "types/scoreentry.h" +#include "etm/ratios/energyratioscalculator.h" class SmartChargingManager; class SpotMarketManager; class LoadConfigStore; +class EnergyManager; class NymeaEnergyJsonHandler : public JsonHandler { Q_OBJECT public: - explicit NymeaEnergyJsonHandler(SpotMarketManager *spotMarketManager, SmartChargingManager *smartChargingManager, LoadConfigStore *loadConfigStore = nullptr, QObject *parent = nullptr); + explicit NymeaEnergyJsonHandler(SpotMarketManager *spotMarketManager, SmartChargingManager *smartChargingManager, LoadConfigStore *loadConfigStore = nullptr, EnergyManager *energyManager = nullptr, QObject *parent = nullptr); QString name() const override; @@ -68,6 +70,9 @@ public: Q_INVOKABLE JsonReply *GetLoadConfig(const QVariantMap ¶ms); Q_INVOKABLE JsonReply *SetLoadConfig(const QVariantMap ¶ms); + // [Phase 2] Ratios canoniques (autoconsommation / autonomie), % [0,100]. + Q_INVOKABLE JsonReply *GetEnergyRatios(const QVariantMap ¶ms); + signals: void PhasePowerLimitChanged(const QVariantMap ¶ms); void AcquisitionToleranceChanged(const QVariantMap ¶ms); @@ -80,12 +85,22 @@ signals: void ChargingSchedulesChanged(const QVariantMap ¶ms); void BatteryLevelConsiderationChanged(const QVariantMap ¶ms); void LoadConfigChanged(const QVariantMap ¶ms); + void EnergyRatiosChanged(const QVariantMap ¶ms); + +private slots: + void onPowerBalanceChanged(); private: SpotMarketManager *m_spotMarketManager; SmartChargingManager *m_smartChargingManager = nullptr; LoadConfigStore *m_loadConfigStore = nullptr; + EnergyManager *m_energyManager = nullptr; + EnergyRatiosCalculator m_ratiosCalculator; + EnergyRatiosCalculator::Ratios m_lastRatios; + bool m_lastRatiosValid = false; // un calcul a-t-il déjà été émis ? + + QVariantMap currentEnergyRatios(); void sendSpotMarketConfigurationChangedNotification(); }; diff --git a/tests/auto/simulation/experience/energyexperienceenergymock.cpp b/tests/auto/simulation/experience/energyexperienceenergymock.cpp index 650b45d..d3838e8 100644 --- a/tests/auto/simulation/experience/energyexperienceenergymock.cpp +++ b/tests/auto/simulation/experience/energyexperienceenergymock.cpp @@ -100,7 +100,7 @@ void ExperiencePluginEnergyMock::init() #endif // [ETM] END - m_nymeaEnergyJsonHandler = new NymeaEnergyJsonHandler(m_spotMarketManager, m_smartChargingManager, loadConfigStore, this); + m_nymeaEnergyJsonHandler = new NymeaEnergyJsonHandler(m_spotMarketManager, m_smartChargingManager, loadConfigStore, m_energyManager, this); jsonRpcServer()->registerExperienceHandler(m_nymeaEnergyJsonHandler, 0, 2); } diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 195a3d6..3c5457f 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -42,6 +42,7 @@ using namespace nymeaserver; #include "../../../energyplugin/etm/adapters/relayrouter.h" #include "../../../energyplugin/etm/types/loadconfig.h" #include "../../../energyplugin/etm/config/loadconfigstore.h" +#include "../../../energyplugin/etm/ratios/energyratioscalculator.h" #endif #include @@ -756,6 +757,59 @@ void Simulation::testEcsRelayTopologies() #endif } +void Simulation::testEnergyRatiosAlignment() +{ +#ifndef ETM_ARBITRATOR + QSKIP("ETM_ARBITRATOR désactivé — ratios canoniques non compilés."); +#else + // Vecteurs joués 1:1 contre la sémantique du seam interim app + // (EnergyRatiosInterim.compute) : seed, normal, clamp-bas, den≤0→n/a, + // non-monotone (Δ<0)→reseed, nouveau jour local→reseed. + EnergyRatiosCalculator calc; + + auto naAuto = [](const EnergyRatiosCalculator::Ratios &r) { QVERIFY(!r.autoconsommationValid); }; + auto naAuton = [](const EnergyRatiosCalculator::Ratios &r) { QVERIFY(!r.autonomieValid); }; + auto eqAuto = [](const EnergyRatiosCalculator::Ratios &r, double v) { + QVERIFY(r.autoconsommationValid); QVERIFY(qAbs(r.autoconsommation - v) < 1e-3); + }; + auto eqAuton = [](const EnergyRatiosCalculator::Ratios &r, double v) { + QVERIFY(r.autonomieValid); QVERIFY(qAbs(r.autonomie - v) < 1e-3); + }; + + const QDateTime d29_00(QDate(2026, 6, 29), QTime(0, 0)); + const QDateTime d29_06(QDate(2026, 6, 29), QTime(6, 0)); + const QDateTime d29_08(QDate(2026, 6, 29), QTime(8, 0)); + const QDateTime d29_10(QDate(2026, 6, 29), QTime(10, 0)); + const QDateTime d30_02(QDate(2026, 6, 30), QTime(2, 0)); + const QDateTime d30_10(QDate(2026, 6, 30), QTime(10, 0)); + + // A — 1er appel : seed baseline → deltas nuls → den≤0 → n/a (les deux). + EnergyRatiosCalculator::Ratios a = calc.compute(1000, 200, 3000, 2200, d29_00); + naAuto(a); naAuton(a); + + // B — même jour, croissance monotone : auto=(500-100)/500=80%, autonomie=(1000-500)/1000=50%. + EnergyRatiosCalculator::Ratios b = calc.compute(1500, 300, 4000, 2700, d29_06); + eqAuto(b, 80.0); eqAuton(b, 50.0); + + // C — clamp bas : dReturn(700) > dProd(600) → num<0 → auto borné à 0.0 ; + // autonomie=(1200-900)/1200=25%. + EnergyRatiosCalculator::Ratios c = calc.compute(1600, 900, 4200, 3100, d29_08); + eqAuto(c, 0.0); eqAuton(c, 25.0); + + // D — compteur non monotone (production 900 < base 1000) → reseed → n/a. + EnergyRatiosCalculator::Ratios d = calc.compute(900, 900, 4200, 3100, d29_10); + naAuto(d); naAuton(d); + + // E — nouveau jour local → reseed → n/a (même si cumuls croissants). + EnergyRatiosCalculator::Ratios e = calc.compute(2000, 500, 6000, 4000, d30_02); + naAuto(e); naAuton(e); + + // 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); +#endif +} + void Simulation::testLoadConfigRelayRouter() { #ifndef ETM_ARBITRATOR diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 7534bcb..6349ee3 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -81,6 +81,10 @@ private slots: // transition non-cascadée, et DÉDUPLICATION des niveaux (relais identiques fusionnés). void testEcsRelayTopologies(); + // [Phase 2] Ratios canoniques : EnergyRatiosCalculator aligné 1:1 sur le seam interim app + // (EnergyRatiosInterim.compute) — seed, normal, clamp, den≤0→n/a, non-monotone, nouveau jour. + void testEnergyRatiosAlignment(); + void printStates(Thing *thing); void updateChargerMeter(Thing *thing);