diff --git a/RECAP_phase2_ratios.md b/RECAP_phase2_ratios.md new file mode 100644 index 0000000..7ece63a --- /dev/null +++ b/RECAP_phase2_ratios.md @@ -0,0 +1,112 @@ +# RECAP — Phase 2 ratios (autoconsommation / autonomie) + +> Point de reprise. Dernière session : 2026-06-30. Branche : `feature/beta-rulebased`. +> Commit feature : **`b61c0ab`** (local, **non poussé**, **non mergé**). + +## 1. Objectif (rappel) + +Exposer **autoconsommation** + **autonomie** comme **source canonique de +l'energymanager**, pour remplacer le seam interim app-side +`EnergyRatiosInterim.compute()` (`etm-powersync-app/lib/services/energy_ratios.dart`). + +## 2. Décision d'architecture — voie (c) (actée) + +Comparé **(a)** champs sur `Energy.PowerBalance` vs **(c)** méthode/notif sur le +handler `NymeaEnergy` de NOTRE plugin. **Retenu : (c).** + +- (a) aurait imposé de **forker `nymea-experience-plugin-energy`** (plugin **cœur**), + passage mirror→build + rebase 1.14.2→1.15.2 + **rebase perpétuel** à chaque bump + nymea. Coût d'infra que Patrick fuit. +- (c) vit dans `powersync-energy-plugin-nymea` (déjà forké/buildé), calcule depuis + l'**interface abstraite stable** `EnergyManager` (`totalProduction/Return/ + Consumption/Acquisition` + signal `powerBalanceChanged()`). **Valeurs identiques** + à (a) ; seul le canal diffère. **Zéro fork du cœur.** +- Coût app : un appel `GetEnergyRatios` + un abonnement `EnergyRatiosChanged` (vs + lire 2 champs `PowerBalance`). Borné, ponctuel, dans un repo qu'on contrôle. +- **Feature request upstream** nymea (champs sur `PowerBalance`) à ouvrir **séparément** + — PAS besoin de forker pour la demander. Si acceptée → migration app triviale. + +## 3. Ce qui est FAIT (commit b61c0ab, 10 fichiers) + +| Fichier | Rôle | +|---|---| +| `energyplugin/etm/ratios/energyratioscalculator.{h,cpp}` | **Calc GPL pur mesure**, porté 1:1 de l'interim | +| `energyplugin/etm/etm.pri` | Sources | +| `energyplugin/nymeaenergyjsonhandler.{h,cpp}` | Méthode + notif + ctor `EnergyManager*` | +| `energyplugin/energypluginnymea.cpp` | Câblage `energyManager()` | +| `tests/auto/simulation/experience/energyexperienceenergymock.cpp` | Câblage mock | +| `tests/auto/simulation/simulation.{h,cpp}` | `testEnergyRatiosAlignment` | +| `docs/INTERFACE_energyratios.md` | **Contrat pour l'agent app** | + +### Contrat exposé (namespace `NymeaEnergy`) +- `GetEnergyRatios` → `{o:selfConsumptionRate, o:autonomyRate}` Double (%). + **Champ OMIS si n/a** (dénominateur ≤ 0). **Jamais `null`.** +- `EnergyRatiosChanged` → mêmes champs ; branchée sur `powerBalanceChanged()` ; + émise **seulement si une valeur (ou sa disponibilité) change**. + +### Sémantique (1:1 interim) +Δ de cumuls sur la **journée locale** (minuit local). Reseed si 1er appel / +nouveau jour / **non-monotone** (Δ<0 : restart nymead / re-add / rollover). +`den≤0 → n/a`. Clamp `[0,100]`. Unité %. +``` +autonomie = (Δ totalConsumption − Δ totalAcquisition) / Δ totalConsumption +autoconsommation = (Δ totalProduction − Δ totalReturn) / Δ totalProduction +``` + +### Validation (session) +- Build **prod 0/0**, `energyratioscalculator.o` linké dans le `.so`. +- `testEnergyRatiosAlignment` **PASS** (seed, normal, clamp-bas, den≤0→n/a, + non-monotone, nouveau jour). +- Suite simulation **25/0** (`testLoadConfigRpc` traverse le handler → pas de + régression). + +## 4. RESTE À FAIRE (reprise) + +1. **Merge `landing-silo`** — Patrick le fait lui-même, **quand validé**. + (Workflow : `git push origin feature/beta-rulebased:landing-silo`, NE PAS toucher + master/stable.) Le commit ratios n'est PAS encore poussé. +2. **`.deb`** : pas rebuildé. hems tourne `1.15.2+etm2` (rév.3 validée live). Quand + on voudra livrer les ratios sur hems → cross-build arm64 + bump version + publier + APT (voir DEPLOY.md). À décider au moment voulu. +3. **Côté repo app** (`etm-powersync-app`, séparé, Patrick) : + - Mirrorer `docs/INTERFACE_energyratios.md`. + - Swap : `GetEnergyRatios` au boot + abonnement `EnergyRatiosChanged` ; + **supprimer** `EnergyRatiosInterim.compute()` + état baseline ; mapper + champ-absent → `null`. +4. **Feature request upstream nymea** (champs `PowerBalance`) — à ouvrir, séparé. + +### Dette/notes non bloquantes (héritées) +- Worktrees git cassés `landing-silo`/`experimental-silo` (`git worktree prune/repair`). +- `docs/TEST_TERRAIN.md` stale (dit `[EcsRelayAdapter]` → devrait être `[RelayRouter]`). +- Mirror `docs/INTERFACE_etmvariableload.md` rév.3 vers le repo app (manuel). + +## 5. Build & test (commandes vérifiées en sandbox amd64) + +```bash +# --- Build prod (0/0 attendu) --- +BD=/tmp/build-prod; rm -rf $BD; mkdir -p $BD; cd $BD +qmake6 /etm-powersync-energy-plugin-etm.pro && make -j$(nproc) + +# --- Build suite simulation --- +BD=/tmp/build-sim; rm -rf $BD; mkdir -p $BD; cd $BD +qmake6 CONFIG+=build_tests /tests/auto/simulation/simulation.pro && make -j$(nproc) + +# --- PIÈGE : rebuild du .so mock si stale (sinon les tests ETM échouent au setup +# de création de thing : !ecsId.isNull(), relayA && relayB, "valid ThingId") --- +cd /tests/mocks/plugins/energymocks +qmake6 energymocks.pro && make -j$(nproc) # régénère aussi plugininfo.h si besoin + +# --- Run (NYMEA_PLUGINS_PATH = energymocks + plugins système nymea) --- +cd /tmp/build-sim +export NYMEA_PLUGINS_PATH="/usr/lib/x86_64-linux-gnu/nymea/plugins:/tests/mocks/plugins/energymocks" +rm -rf /tmp/nymea-test +./nymea-energy-simulation # suite complète → 25/0 +./nymea-energy-simulation testEnergyRatiosAlignment # ratios seul → 3/3 +``` + +## 6. Garde-fous projet (ne pas oublier) +- **Frontière GPL** : ratios = pur mesure (Δ compteurs). Aucun optim/forecast/ + pondération dans le GPL — ça reste dans l'optimiseur propriétaire. +- **Remotes** : `origin` = travail ; `etm-public` = miroir, **jamais** push direct ; + `etm-pro` = reliquat, ne pas utiliser. +- **Jamais** merger vers master/stable depuis cette branche ; rév.3 + ratios = testing. 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);