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