diff --git a/docs/INTERFACE_etmvariableload.md b/docs/INTERFACE_etmvariableload.md new file mode 100644 index 0000000..75504e4 --- /dev/null +++ b/docs/INTERFACE_etmvariableload.md @@ -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<uint> | 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` (1–4) | +| `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 1–4, 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 »). diff --git a/energyplugin/etm/adapters/etmvariableloadadapter.cpp b/energyplugin/etm/adapters/etmvariableloadadapter.cpp new file mode 100644 index 0000000..c2ac7ab --- /dev/null +++ b/energyplugin/etm/adapters/etmvariableloadadapter.cpp @@ -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 +#include +#include +#include +#include +#include + +EtmVariableLoadAdapter::EtmVariableLoadAdapter(ThingManager *thingManager, + const QString &id, + const QString &label, + const QList &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(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 + } +} diff --git a/energyplugin/etm/adapters/etmvariableloadadapter.h b/energyplugin/etm/adapters/etmvariableloadadapter.h new file mode 100644 index 0000000..498a2fe --- /dev/null +++ b/energyplugin/etm/adapters/etmvariableloadadapter.h @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync +#pragma once + +#include +#include +#include +#include +#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 &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 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; +}; diff --git a/energyplugin/etm/etm.pri b/energyplugin/etm/etm.pri index 37a0792..4c157a0 100644 --- a/energyplugin/etm/etm.pri +++ b/energyplugin/etm/etm.pri @@ -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 \ diff --git a/energyplugin/etm/types/loaddescriptor.h b/energyplugin/etm/types/loaddescriptor.h index ef2d906..e3ffce6 100644 --- a/energyplugin/etm/types/loaddescriptor.h +++ b/energyplugin/etm/types/loaddescriptor.h @@ -23,6 +23,16 @@ struct LoadDeclared { //! Puissances en W par palier : [0, 1200, 2400] — index 0 = off. QList 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 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).