feat(etm): adaptateur etmvariableload (interface charge pilotée, Setpoint W)

Côté energymanager : EtmVariableLoadAdapter consomme l'interface etmvariableload
(states currentPowerW read / powerSetpoint write) pour les charges à puissance
pilotable (EV / ECS résistif / routeur PV). La combinatoire matérielle, l'anti-rebond
et les phases vivent dans le thing (contrat §1/§3) — l'adaptateur n'écrit qu'un
setpoint W et relit currentPowerW.

- LoadDescriptor : champs additifs powerLevels / maxPowerW (déclaration §3/§5).
- Contrat partagé versionné : docs/INTERFACE_etmvariableload.md (rév. 2).
- La déclaration de l'interface nymee + le driver thing relèvent d'une session device.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-06-28 08:51:22 +02:00
parent c43449cafe
commit b7bfd58139
5 changed files with 449 additions and 0 deletions

View File

@ -0,0 +1,169 @@
# INTERFACE `etmvariableload` — contrat charge pilotée · **rév. 2**
> **Source de vérité unique.** Déposé **à l'identique dans les deux repos**
> (`etm-powersync-app` et `etm-powersync-energy-plugin-etm`). Toute divergence se tranche **ici**,
> pas dans le code. Un agent qui a besoin d'un champ absent met à jour ce doc **d'abord**, dans
> les deux repos.
>
> **rév. 2 — corrige la rév. 1 sur un point structurant :** `etmvariableload` ne couvre **que les
> charges à puissance pilotable** (EV, ECS résistif, routeur PV). **La PAC SG-Ready en est exclue** :
> elle reste un modèle **à états**, interface séparée et conservée (voir §8). `LoadAction` garde
> donc **deux** kinds (`Setpoint` ET l'état SG-Ready), pas un seul.
---
## 1. Principe & frontière
Trois niveaux, trois responsables. **Aucune notion de matériel ne remonte au-dessus du thing.**
| Niveau | Où | Quoi |
|---|---|---|
| **Câblage** | thing / plugin device (wizard Things) | GPIO, registres Modbus, quelle résistance sur quelle phase |
| **Déclaration** | config charge pilotée (app) | mode, paliers atteignables **en W**, priorité, besoins |
| **Runtime** | interface `etmvariableload` | setpoint W envoyé ↔ puissance réelle remontée |
> **Invariant directeur.** L'energymanager raisonne **uniquement en watts**. Aucune résistance,
> aucun relais, aucun index de palier matériel ne franchit l'app ni n'atteint l'arbitre.
> **Le thing décide du matériel ; l'energymanager décide de la puissance.**
---
## 2. Portée de `etmvariableload`
**EST une `etmvariableload`** (charge à puissance pilotable) :
- **EV charger** (setpoint courant/puissance),
- **ECS résistif** — classique (on/off) ou multipalier,
- **Routeur PV / triac** (modulation continue).
**N'EST PAS une `etmvariableload`** :
- **PAC SG-Ready** → modèle à **états** (2 bits, hystérésis, `minStateHold`). Interface distincte,
conservée telle quelle. Voir §8. Un signal SG-Ready est une *suggestion d'état* à la PAC, pas
une consigne de puissance en watts — le forcer en setpoint W serait faux.
---
## 3. Interface device `etmvariableload`
Implémentée par le thing (ECS / EV / routeur). **Côté moteur, seul l'`EtmVariableLoadAdapter`
consommateur est livré** ; la déclaration de l'interface nymea + le driver (résistances/triac/
anti-rebond/phases) sont une **session device dédiée, hors repo moteur**.
| State | Accès | Type | Unité | Sémantique |
|---|---|---|---|---|
| `maxPowerW` | read | uint | W | Plafond physique de la charge. |
| `powerLevels` | read | list&lt;uint&gt; | W | Paliers atteignables, triés croissants, **`0` inclus**. Présent en *fixed*. Vide/absent ⇒ modulation continue. |
| `powerSetpoint` | **write** | uint | W | Consigne demandée par l'energymanager. |
| `currentPowerW` | read | uint | W | Puissance **réellement** appliquée. Ferme la boucle de quantification. |
**Le thing, en recevant un `powerSetpoint` :** choisit la combinaison matérielle concrète,
applique ses **verrous anti-rebond** (minOn/minOff) et son **équilibrage de phases**, puis publie
`currentPowerW`. L'energymanager n'a aucune visibilité là-dessus — c'est voulu.
---
## 4. `LoadDescriptor` — config app → energymanager
Émis par l'écran « Configurer charge pilotée » (sous-ensemble de `OPTIMIZER_PROTOCOL.md §5`,
noms identiques) :
```jsonc
{
"id": "uuid-thing-ecs",
"label": "Chauffe-eau",
"adapter": "etmvariableload",
"mode": "fixed", // "fixed" | "dynamic"
"powerLevels": [0, 600, 1200, 1800, 2400], // REQUIS si fixed — absent si dynamic
"maxPowerW": 3000, // REQUIS si dynamic ; plafond aussi en fixed
"priority": 2, // rang ; 1 = servi en premier
"enabled": true,
"needs": { "dailyDeadline": "06:00", "minEnergyWhPerDay": 4000 }
}
```
- `powerLevels` saisi par l'installateur — directement ou via l'**assistant résistances** (UI-only :
additionne les unités, pré-remplit, puis disparaît ; **aucune résistance n'est stockée ni envoyée**).
- Config **persistée côté plugin** (`/var/lib/nymea/energy-load-configuration.json`), jamais en
SharedPreferences. App = éditeur : `NymeaEnergy.GetLoadConfig` / `SetLoadConfig`.
---
## 5. Règle d'arrondi (energymanager)
À chaque cycle, pour une charge dont c'est le tour dans la priorité :
- **Fixed** : `level = max( l ∈ powerLevels | l ≤ budget )` puis `setPowerSetpoint(level)`.
L'arbitre connaît la **granularité déclarée** → pas de sur-allocation, **zéro cycle de retard**.
- **Dynamic** : `setPowerSetpoint( clamp(budget, 0, maxPowerW) )`.
- **Résidu** : lire le `currentPowerW` **de début de cycle** (télémétrie de l'adapter, comme la
Correction B — **PAS** une relecture post-setpoint : invariant 8, aucune boucle de feedback).
Le résidu `budget currentPowerW` repart vers la charge suivante de la priorité **du même cycle**.
**Déclaré vs réel :** la **déclaration** (`powerLevels`/`maxPowerW`) est la référence de
**planification**. `currentPowerW` est le **juge de paix runtime**. Si le réel plafonne durablement
sous le déclaré → erreur de déclaration, l'installateur corrige. L'energymanager **n'écrase jamais**
la déclaration.
---
## 6. `LoadAction`**deux** kinds
| Kind | Pour | Porte |
|---|---|---|
| `Setpoint` | EV / ECS / routeur (`etmvariableload`) | `powerW` |
| *état SG-Ready* | PAC (interface à états, §8) | `state` (14) |
| `Constraint` | batterie (déféré 3f) | conservé |
- **Supprimer `Stage`** (ECS → `Setpoint`). Retirer `stage/minStage/maxStage` de
`LoadContextTelemetry` (le verrou ECS redescend dans le thing).
- **Conserver le kind d'état SG-Ready** et `state/minState/maxState` pour la PAC — son
`minStateHold` (protection court-cycling compresseur) reste dans son chemin, **inchangé**.
- `reason` reste porté par chaque `LoadAction` (déjà le cas).
---
## 7. Expo du `reason` — tableau additif `loadActions[]`
Sérialiser dans `GetChargingSchedules` / `ChargingSchedulesChanged` un **champ additif**
(rétro-compatible, ne touche pas la structure EV `ChargingAction`) :
```jsonc
"loadActions": [
{ "loadId": "uuid-ecs", "setpointW": 1200, "currentPowerW": 1180, "reason": "Surplus PV 1.3 kW — ECS rang 2" },
{ "loadId": "uuid-pac", "state": 3, "reason": "Surplus PV — PAC en état 3" }
]
```
- Couvre **toutes** les charges, y compris la PAC. `setpointW`/`currentPowerW` **optionnels**
(absents pour une charge à états) ; `loadId` + `reason` **toujours** présents.
- Nécessite que l'arbitre **retienne le dernier plan** (aujourd'hui loggé puis jeté,
`energyarbitrator.cpp:140-153`) — petite addition d'état.
- C'est le canal que consomme la **decision card** de l'app : « pour chaque charge, le pourquoi ».
---
## 8. PAC SG-Ready — modèle à états (conservé)
Hors `etmvariableload`. Le `SgReadyAdapter` existant (états 14, combos K1/K2, hystérésis,
`minStateHold`) **n'est pas réécrit**. L'arbitre lui envoie un **état** (kind §6), pas un setpoint W.
Repli L2 : état 2 (normal, mains off), jamais état 1 (blocage) — comportement actuel conservé.
> Une PAC pilotée par Modbus qui exposerait une vraie puissance pilotable pourra, *plus tard*,
> implémenter `etmvariableload`. Tant qu'on pilote par signal SG-Ready, c'est un modèle à états.
---
## 9. Sécurité (déjà en place)
- **Watchdog L2** (`evaluateMeterFreshness`, 90 s, `applyDegradedMode`) existant. En dégradé,
repli charge pilotée = **`setPowerSetpoint(0)` `force=true`** (remplace l'ECS stage-0 actuel) ;
PAC → état 2. Conserver l'ordre `update()` (sécurité avant planif) intact.
- `enabled:false` → rôle déclaré mais **exclu** de l'arbitrage (aucune action envoyée).
---
## 10. Dépendances liées (à coder en même temps, côté plugin)
- Handler `nymeaenergyjsonhandler` : `Get/SetLoadConfig` + persistance.
- `loadActions[]` dans `GetChargingSchedules` / `ChargingSchedulesChanged`.
- Câbler l'arbitre pour construire ses adapters depuis `LoadConfig` au lieu du registre en dur
(`energypluginnymea.cpp:58-80` — le « 3g »).

View File

@ -0,0 +1,138 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#include "etmvariableloadadapter.h"
#include "plugininfo.h"
#include <QDateTime>
#include <algorithm>
#include <integrations/thingmanager.h>
#include <integrations/thing.h>
#include <types/action.h>
#include <types/param.h>
EtmVariableLoadAdapter::EtmVariableLoadAdapter(ThingManager *thingManager,
const QString &id,
const QString &label,
const QList<int> &powerLevels,
int maxPowerW,
int priority,
const LoadNeeds &needs,
QObject *parent)
: QObject(parent)
, m_thingManager(thingManager)
, m_id(id)
, m_label(label)
, m_powerLevels(powerLevels)
, m_maxPowerW(maxPowerW)
, m_priority(priority)
, m_needs(needs)
{
// Re-tri par sécurité : l'UI émet trié, l'energymanager re-trie (contrat §6 cas limites).
std::sort(m_powerLevels.begin(), m_powerLevels.end());
// Contrat §2 : les paliers incluent 0. En *fixed* (liste non vide), 0 doit être présent.
Q_ASSERT(m_powerLevels.isEmpty() || m_powerLevels.first() == 0);
}
LoadDescriptor EtmVariableLoadAdapter::descriptor() const
{
LoadDescriptor d;
d.id = m_id;
d.label = m_label;
d.adapter = QStringLiteral("etmvariableload");
d.priority = m_priority;
d.declared.powerLevels = m_powerLevels;
d.declared.maxPowerW = m_maxPowerW;
d.needs = m_needs;
d.supportedKinds = { LoadAction::Setpoint };
return d;
}
LoadTelemetry EtmVariableLoadAdapter::telemetry() const
{
LoadTelemetry t;
Thing *thing = m_thingManager->findConfiguredThing(ThingId(m_id));
t.available = (thing != nullptr);
t.currentPowerW = readCurrentPowerW(); // juge runtime (contrat §4)
t.lastActionAt = m_lastActionAt;
return t;
}
LoadContext EtmVariableLoadAdapter::toLoadContext(const QDateTime &now) const
{
Q_UNUSED(now) // aucune fenêtre de verrou côté moteur : l'anti-rebond vit dans le thing.
LoadContext ctx;
ctx.id = m_id;
ctx.adapter = QStringLiteral("etmvariableload");
ctx.label = m_label;
ctx.priority = m_priority;
ctx.declared = descriptor().declared;
ctx.telemetry.currentPowerW = readCurrentPowerW();
return ctx;
}
LoadAction EtmVariableLoadAdapter::applyAction(const LoadAction &action, const QDateTime &now)
{
if (action.kind != LoadAction::Setpoint)
return action;
if (action.reason.isEmpty()) {
qCWarning(dcNymeaEnergy()) << "[EtmVariableLoadAdapter]" << m_label
<< "— LoadAction sans reason rejetée.";
return action;
}
// Second filet (invariant ILoadAdapter) : borner la consigne au plafond physique.
// L'arrondi au powerLevels (mode *fixed*) est fait par le scheduler (contrat §4, T3).
const double setpointW = qBound(0.0, action.powerW, static_cast<double>(m_maxPowerW));
qCInfo(dcNymeaEnergy()) << "[EtmVariableLoadAdapter]" << m_label
<< "→ setpoint" << qRound(setpointW) << "W"
<< (action.force ? "(force)" : "")
<< "|" << action.reason;
writeSetpoint(setpointW);
m_currentSetpointW = setpointW;
m_lastActionAt = now;
LoadAction applied = action;
applied.powerW = setpointW;
applied.estimatedPowerW = setpointW;
return applied;
}
// ---- privé ---------------------------------------------------------------
double EtmVariableLoadAdapter::readCurrentPowerW() const
{
Thing *thing = m_thingManager->findConfiguredThing(ThingId(m_id));
if (!thing)
return 0.0;
if (thing->thingClass().stateTypes().findByName("currentPowerW").id().isNull())
return 0.0;
return thing->stateValue("currentPowerW").toDouble();
}
void EtmVariableLoadAdapter::writeSetpoint(double powerW)
{
Thing *thing = m_thingManager->findConfiguredThing(ThingId(m_id));
if (!thing) {
qCWarning(dcNymeaEnergy()) << "[EtmVariableLoadAdapter]" << m_label
<< "— thing non trouvé:" << m_id;
return;
}
StateType setpointStateType = thing->thingClass().stateTypes().findByName("powerSetpoint");
if (!setpointStateType.id().isNull()) {
Action setpointAction(setpointStateType.id(), thing->id(), Action::TriggeredByRule);
setpointAction.setParams(ParamList() << Param(setpointStateType.id(), powerW));
m_thingManager->executeAction(setpointAction);
} else {
thing->setStateValue("powerSetpoint", powerW); // repli mock
}
}

View File

@ -0,0 +1,130 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QObject>
#include <QDateTime>
#include <QList>
#include <QString>
#include "iloadadapter.h"
class Thing;
class ThingManager;
/*!
* \brief Adaptateur charge à puissance pilotable interface \c etmvariableload (contrat
* INTERFACE_etmvariableload.md). Action \c kind:Setpoint en WATTS.
*
* Couvre les charges dont la consigne est une puissance : ECS multipalier, routeur PV /
* triac, résistance modulable. \b Pas la PAC SG-Ready (modèle à états, \c SgReadyAdapter).
*
* \par Frontière (contrat §1 / §5) non négociable
* L'energymanager raisonne UNIQUEMENT en watts. \b Aucune résistance, relais, combinaison
* ni index de palier matériel ne traverse cet adaptateur. La connaissance matérielle (quelle
* résistance, quel triac, sur quelle phase), l'anti-rebond (minOn/minOff) et l'équilibrage
* de phases vivent ENTIÈREMENT dans le \b thing qui implémente l'interface. Cet adaptateur
* se contente d'écrire une consigne \c powerSetpoint (W) et de relire \c currentPowerW.
*
* \par States nymea consommés sur le thing cible (\c m_id)
* - \c currentPowerW (read) : puissance réellement appliquée juge runtime (contrat §4).
* - \c powerSetpoint (write) : consigne demandée par l'energymanager.
* - \c maxPowerW / \c powerLevels (read) : déclarés par le thing pour le wizard ; la
* planification utilise la DÉCLARATION de config (passée au constructeur, contrat §4),
* pas ces states l'adaptateur n'écrase jamais la déclaration.
*
* \invariant applyAction() rejette silencieusement toute action dont \c reason est vide.
* \invariant Seul le kind \c Setpoint est traité ; les autres kinds retournent sans effet.
* \invariant La consigne est écrêtée à \c [0, maxPowerW] avant envoi matériel (second filet
* après l'écrêtage de l'arbitre). L'arrondi au \c powerLevels (mode *fixed*) est la
* responsabilité du SCHEDULER (contrat §4), pas de l'adaptateur.
* \invariant **Temps = paramètre, jamais l'horloge** (cf. \c ILoadAdapter) : aucune logique
* temporelle ici (les verrous vivent dans le thing) \c now sert uniquement aux estampilles.
*
* \note T1 (squelette) : l'adaptateur consomme l'interface côté moteur. La déclaration de
* l'interface nymea \c etmvariableload et le driver thing relèvent d'une session device dédiée.
*/
class EtmVariableLoadAdapter : public QObject, public ILoadAdapter
{
Q_OBJECT
public:
/*!
* \brief Constructeur.
* \param thingManager Gestionnaire nymea pour résoudre \p id en Thing.
* \param id ThingId (string) du thing implémentant \c etmvariableload, et
* identifiant logique de la charge.
* \param label Nom lisible (logs, app).
* \param powerLevels Paliers atteignables en W (DÉCLARÉS, triés croissants, \c 0 inclus)
* en mode *fixed* ; liste vide *dynamic* (modulation 0..maxPowerW).
* \param maxPowerW Plafond physique (W) contrat §2.
* \param priority Rang dans le waterfall (contrat §5 / OPTIMIZER_PROTOCOL §5) : valeur
* plus BASSE = servi en premier (rang 1 = premier servi).
* \param needs Besoins déclarés (échéances, énergie min) exposés au scheduler
* via \c descriptor().needs. Vide par défaut.
* \param parent Propriétaire Qt.
*/
explicit EtmVariableLoadAdapter(ThingManager *thingManager,
const QString &id,
const QString &label,
const QList<int> &powerLevels,
int maxPowerW,
int priority,
const LoadNeeds &needs = LoadNeeds(),
QObject *parent = nullptr);
/*!
* \brief Description statique : adapter="etmvariableload", powerLevels, maxPowerW, priority.
* \return LoadDescriptor déclaratif (référence de planification, contrat §4).
*/
LoadDescriptor descriptor() const override;
/*!
* \brief Télémétrie runtime. \c currentPowerW = state \c currentPowerW MESURÉ du thing
* (juge de quantification, contrat §4) ; \c available faux si le thing est absent.
*/
LoadTelemetry telemetry() const override;
/*!
* \brief Construit l'entrée loads[] §5 (adapter="etmvariableload").
* \param now Temps de cycle (\c ctx.timestamp) estampille uniquement ; aucune fenêtre de
* verrou côté moteur (l'anti-rebond vit dans le thing, contrat §2).
* \return LoadContext incluant declared (powerLevels/maxPowerW) et \c currentPowerW.
*/
LoadContext toLoadContext(const QDateTime &now) const override;
/*!
* \brief Écrit la consigne \c powerSetpoint (W) sur le thing.
* \param action LoadAction de kind \c Setpoint ; consigne lue dans \c action.powerW. Autres
* kinds : retour sans effet.
* \param now Temps de cycle (\c ctx.timestamp) estampille \c m_lastActionAt.
* \return L'action après écrêtage matériel (\c powerW borné à [0, maxPowerW]).
*
* \invariant Si \c action.reason est vide retour sans effet (log warning).
* \invariant \c action.force == true (repli L2 : \c setPowerSetpoint(0)) est transmis tel
* quel ; le bypass anti-rebond est honoré par le thing, pas ici.
*/
LoadAction applyAction(const LoadAction &action, const QDateTime &now) override;
/*! \brief Dernière consigne (W) effectivement écrite (avant écrêtage thing). */
double currentSetpointW() const { return m_currentSetpointW; }
private:
//! Vrai si aucun palier déclaré n'est fourni ⇒ modulation continue (contrat §2).
bool isDynamic() const { return m_powerLevels.isEmpty(); }
//! Écrit le state \c powerSetpoint (W) via executeAction (repli \c setStateValue pour mock).
void writeSetpoint(double powerW);
//! Lit le state \c currentPowerW du thing cible (0 si absent / non exposé).
double readCurrentPowerW() const;
ThingManager *m_thingManager;
QString m_id;
QString m_label;
QList<int> m_powerLevels; //!< Paliers W déclarés (vide = dynamic).
int m_maxPowerW;
int m_priority;
LoadNeeds m_needs;
double m_currentSetpointW = 0; //!< Dernière consigne écrite (W).
QDateTime m_lastActionAt;
};

View File

@ -8,6 +8,7 @@ HEADERS += \
$$PWD/adapters/evadapter.h \
$$PWD/adapters/ecsrelayadapter.h \
$$PWD/adapters/sgreadyadapter.h \
$$PWD/adapters/etmvariableloadadapter.h \
$$PWD/scheduler/rulebasedscheduler.h \
$$PWD/energyarbitrator.h \
@ -15,5 +16,6 @@ SOURCES += \
$$PWD/adapters/evadapter.cpp \
$$PWD/adapters/ecsrelayadapter.cpp \
$$PWD/adapters/sgreadyadapter.cpp \
$$PWD/adapters/etmvariableloadadapter.cpp \
$$PWD/scheduler/rulebasedscheduler.cpp \
$$PWD/energyarbitrator.cpp \

View File

@ -23,6 +23,16 @@ struct LoadDeclared {
//! Puissances en W par palier : [0, 1200, 2400] — index 0 = off.
QList<int> stages;
// --- etmvariableload (charge à puissance pilotable : EV / ECS résistif / routeur PV) ---
//! Paliers atteignables en W (contrat etmvariableload §3), triés croissants, \c 0 inclus.
//! Présent en mode *fixed*. Vide ⇒ modulation continue (*dynamic*, borne \c maxPowerW).
//! C'est la DÉCLARATION installateur (référence de planification, contrat §5) — distincte
//! de la conso runtime \c currentPowerW.
QList<int> powerLevels;
//! Plafond physique de la charge (W) — contrat etmvariableload §2 \c maxPowerW. REQUIS en
//! *dynamic* ; sert aussi de plafond en *fixed*.
double maxPowerW = 0;
// --- battery ---
double maxChargeW = 0; //!< Puissance max de charge (W).
double maxDischargeW = 0; //!< Puissance max de décharge (W).