[wip] 3c morceaux 0-2 compilés + plan 3c validé dans AGENTS.md

Morceaux 0-2 implémentés et compilés (0 erreur / 0 warning) :
- M0 : LoadAction.force=false (bypass verrous anti-rebond sécurité)
- M1 : EcsRelayAdapter (.h+.cpp) — N paliers powerswitch, anti-rebond, etm.pri
- M2 : buildContext() — SurplusMeter brut, loads EV+ECS, registerEcsAdapter()

AGENTS.md : section PLAN 3C ajoutée avec corrections A+B intégrées.
Corrections A (déduction EV unique dans scheduler) et B (recrédit conso
propre anti-clignotement) documentées avant implémentation morceau 3.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-06-08 13:34:42 +02:00
parent 19951a1e3e
commit 7709057335
7 changed files with 453 additions and 11 deletions

View File

@ -17,6 +17,7 @@ vers un gestionnaire d'énergie complet (EV, ECS, PAC SG-Ready, batterie).
| 2 — design arbitre validé | ✅ FAITE | `074fa71` |
| 3a — structs protocole + interfaces | ✅ FAITE | `4ae1939` |
| 3b — EnergyArbitrator + scheduler + adapter | ✅ FAITE — iso-fonctionnalité prouvée | `5f49e4c`, `d8ebd65`, `[3b-iv]` |
| 3c — EcsRelayAdapter + waterfall ECS | 🔄 EN COURS | (wip) |
**Détail 3b** :
- `EnergyArbitrator : public SmartChargingManager` — justification dans `## DÉCISIONS DE DESIGN`
@ -28,11 +29,17 @@ vers un gestionnaire d'énergie complet (EV, ECS, PAC SG-Ready, batterie).
- Tests charging : 57 lignes décisions identiques, diff = 0 ; 46/46 PASS ref ET ETM
- [Arbitre] présents avec raisons françaises pour les 4 cas (idle, surplus PV, aWATTar, deadline)
**PROCHAINE ACTION — 3c** :
- `EcsRelayAdapter` (paliers 0/1/2) — premier adaptateur non-EV, premier `applyAction()` vivant
- Pipeline ETM waterfall (budget surplus → ECS, déduction `addedPower` EV) dans `EnergyArbitrator::update()`
- Watchdog L2 : `QTimer` 30 s, mode dégradé variante B, notification `degradedMode` (cf. SAFETY.md §L2)
- Scénarios simulation : `testEcsSurplusPV` (chauffe-eau sur surplus PV) + `testMeterSilentFallback` (compteur muet → repli)
**Détail 3c (morceaux déjà compilés, 0 erreur / 0 warning)** :
- **Morceau 0**`LoadAction.force=false` (bypass verrous sécurité) ✅
- **Morceau 1**`EcsRelayAdapter` (.h + .cpp) : pilote N Things powerswitch,
`applyRelayStage()`, verrous `minOnS/minOffS`, bypass si `force==true`
- Enregistrement explicite via `EnergyArbitrator::registerEcsAdapter()` (tests + config)
- **Morceau 2**`buildContext()` : `SurplusMeter` brut (`exportW = max(0, -meter->currentPower())`),
`loads[]` EV + ECS, `SurpusPv` déféré 3d ✅
**PROCHAINE ACTION — suite 3c** :
- Morceau 3 : waterfall ECS dans `RuleBasedScheduler::getPlan()` (voir PLAN 3C ci-dessous)
- Morceaux 4-7 : voir PLAN 3C
**Remotes git** :
- `origin` (`https://git.etm-powersync.fr/...`) = remote de travail — push normal
@ -41,6 +48,78 @@ vers un gestionnaire d'énergie complet (EV, ECS, PAC SG-Ready, batterie).
---
## PLAN 3C (validé, morceaux 0-2 déjà compilés)
Plan approuvé par Patrick. Corrections A (double déduction EV) et B (anti-clignotement ECS)
**intégrées** dans le design ci-dessous.
### Morceaux déjà compilés (0 erreur/warning)
| # | Fichier(s) | Ce qui a été fait |
|---|-----------|-------------------|
| 0 | `types/loadaction.h` | `bool force = false` — bypass verrous sécurité |
| 1 | `adapters/ecsrelayadapter.h/.cpp` | Adaptateur N-paliers powerswitch, anti-rebond, `applyRelayStage()`, `etm.pri` |
| 2 | `energyarbitrator.h/.cpp` | `buildContext()` : `SurplusMeter` brut, `loads[]` EV+ECS, `registerEcsAdapter()`, `m_ecsAdapters` |
### Morceaux à venir
**3 — Waterfall ECS dans `RuleBasedScheduler::getPlan()`**
Après la boucle EV proxy, ajouter :
```
// Déduction unique (correction A) — ctx.meter.exportW = mesure brute
evReservedW = Σ EV en charge dans slot.actions : max(0, commandedA×phases×230 ev->currentPower())
remainingSurplusW = max(0, ctx.meter.exportW evReservedW)
// Tri loads ECS par priorité DESC (200 = servi en premier)
pour chaque LoadContext lc où lc.adapter == "relay-stages" :
budgetCharge = remainingSurplusW + lc.telemetry.currentPowerW // correction B anti-clignotement
bestStage = palier le plus haut dont stages[i] ≤ budgetCharge
reason = "Surplus PV — ECS palier N (W)" ou "Surplus insuffisant — ECS éteint"
remainingSurplusW = remainingSurplusW + lc.telemetry.currentPowerW stages[bestStage]
// Grid funding : dormant jusqu'à 3f (commente, n'implémente pas)
```
**4 — `syncAdapters()` extension + `applyActionsToAdapters(Slot)` dans `update()`**
- `syncAdapters()` : commentaire "découverte ECS via interface 'ecsrelay' déférée 3g"
- `applyActionsToAdapters(Slot)` : itère slot.actions, dispatche via `m_ecsAdapters` pour kind==Stage
**5 — Watchdog L2** (cf. SAFETY.md §L2)
- `QTimer m_meterWatchdog` 30 s — picoté sur `powerBalanceChanged` SIGNAL pour `m_lastMeterUpdate`
- `onMeterWatchdogTick()` : si `now m_lastMeterUpdate > 90 s``applyDegradedMode()`
- `applyDegradedMode()` : ECS stage 0 force=true + EV courant minimum, reason="Compteur muet..."
**6 — `degradedMode()` + notification + INTERFACE.md**
- `virtual bool degradedMode() const` dans `SmartChargingManager` (retourne false, `// [ETM]`)
- Override dans `EnergyArbitrator`
- Champ `"degradedMode": bool` dans `ChargingSchedulesChanged` (additif, rétro-compatible)
- Mise à jour `docs/INTERFACE.md` + limite dans `docs/SAFETY.md` : "valeur strictement constante non détectée"
**7 — Mock powerswitch + tests**
- Mock JSON : ThingClass `mockPowerSwitch` (état `power` bool, état `currentPower` double)
- `energytestbase.h` : `mockPowerSwitchThingClassId`
- `testEcsSurplusPV` : cas normal + cas "ECS déjà au palier 1, surplus stable → Y RESTE" (anti-clignotement)
- `testMeterSilentFallback` : compteur muet 90 s → mode dégradé → ECS off
### Arithmétique du budget (corrections A + B)
**Correction A — déduction EV unique, dans le scheduler** :
```
exportW dans ctx.meter = mesure brute (invariant 8, protocole §5)
evReservedW = déduction dans getPlan() APRÈS proxy EV, avec m_chargingActions fraîches
Pas de déduction dans buildContext().
```
Exemple : PV 9 kW, EV stable 7360 W, export mesuré 1140 W → evReservedW=0, budget ECS=1140 W ✓
**Correction B — anti-clignotement par recrédit de la conso actuelle** :
```
budgetCharge = remainingSurplusW + lc.telemetry.currentPowerW
```
Identique à SCM ~l.1245 pour l'EV. Sans ce recrédit : ECS palier 1 → export chute → palier 0 → oscillation.
---
> ⚠️ Tout plan antérieur mentionnant « créer etm/ avec PowerSyncClient et
> StaticHcHpProvider comme première étape » ou « injecter l'optimiseur dans
> SmartChargingManager » est **INVALIDE et ABANDONNÉ**. Ne pas le reprendre,

View File

@ -0,0 +1,191 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#include "ecsrelayadapter.h"
#include "plugininfo.h"
#include <QDateTime>
#include <integrations/thingmanager.h>
#include <integrations/thing.h>
#include <types/action.h>
#include <types/param.h>
EcsRelayAdapter::EcsRelayAdapter(ThingManager *thingManager,
const QString &id,
const QString &label,
const QList<int> &stages,
const QList<QList<QString>> &relayMapping,
int minOnS,
int minOffS,
int priority,
QObject *parent)
: QObject(parent)
, m_thingManager(thingManager)
, m_id(id)
, m_label(label)
, m_stages(stages)
, m_relayMapping(relayMapping)
, m_minOnS(minOnS)
, m_minOffS(minOffS)
, m_priority(priority)
{
Q_ASSERT(!m_stages.isEmpty() && m_stages.first() == 0);
Q_ASSERT(m_relayMapping.size() == m_stages.size());
}
LoadDescriptor EcsRelayAdapter::descriptor() const
{
LoadDescriptor d;
d.id = m_id;
d.label = m_label;
d.adapter = QStringLiteral("relay-stages");
d.priority = m_priority;
d.declared.stages = m_stages;
d.limits.minOnS = m_minOnS;
d.limits.minOffS = m_minOffS;
d.supportedKinds = { LoadAction::Stage };
return d;
}
LoadTelemetry EcsRelayAdapter::telemetry() const
{
LoadTelemetry t;
t.available = true;
t.lastActionAt = m_lastActionAt;
// Puissance mesurée = somme des relais actifs pour le stage courant
double power = 0;
const QList<QString> &activeRelays = m_currentStage < m_relayMapping.size()
? m_relayMapping.at(m_currentStage)
: QList<QString>();
for (const QString &thingId : activeRelays) {
Thing *t2 = m_thingManager->findConfiguredThing(ThingId(thingId));
if (t2)
power += t2->stateValue("currentPower").toDouble();
}
// Si pas de powermetering dans le mock, on estime depuis les stages déclarés
if (power == 0 && m_currentStage > 0 && m_currentStage < m_stages.size())
power = m_stages.at(m_currentStage);
t.currentPowerW = power;
return t;
}
LoadContext EcsRelayAdapter::toLoadContext() const
{
LoadContext ctx;
ctx.id = m_id;
ctx.adapter = QStringLiteral("relay-stages");
ctx.label = m_label;
ctx.priority = m_priority;
ctx.declared = descriptor().declared;
ctx.limits = descriptor().limits;
const LoadTelemetry tel = telemetry();
ctx.telemetry.currentPowerW = tel.currentPowerW;
ctx.telemetry.stage = m_currentStage;
ctx.telemetry.lastSwitch = m_lastSwitch;
return ctx;
}
LoadAction EcsRelayAdapter::applyAction(const LoadAction &action)
{
if (action.kind != LoadAction::Stage)
return action;
if (action.reason.isEmpty()) {
qCWarning(dcNymeaEnergy()) << "[EcsRelayAdapter]" << m_label
<< "— LoadAction sans reason rejetée.";
return action;
}
const int newStage = qBound(0, action.stage, m_stages.size() - 1);
if (newStage == m_currentStage)
return action; // Aucun changement → idempotent
// Verrous anti-rebond — bypassés si force == true (L2 watchdog)
if (!action.force && lockActive(newStage)) {
qCDebug(dcNymeaEnergy()) << "[EcsRelayAdapter]" << m_label
<< "— verrou anti-rebond actif, stage" << newStage << "ignoré.";
return action;
}
qCDebug(dcNymeaEnergy()) << "[EcsRelayAdapter]" << m_label
<< "→ stage" << newStage
<< "(" << (m_currentStage < m_stages.size() ? m_stages.at(m_currentStage) : 0) << "W"
<< "" << m_stages.at(newStage) << "W)"
<< "|" << action.reason;
applyRelayStage(newStage);
m_currentStage = newStage;
m_lastSwitch = QDateTime::currentDateTime();
m_lastActionAt = m_lastSwitch;
LoadAction applied = action;
applied.stage = newStage;
applied.estimatedPowerW = m_stages.at(newStage);
return applied;
}
// ---- privé ---------------------------------------------------------------
bool EcsRelayAdapter::lockActive(int newStage) const
{
if (!m_lastSwitch.isValid())
return false;
const int elapsed = static_cast<int>(m_lastSwitch.secsTo(QDateTime::currentDateTime()));
if (newStage > m_currentStage) {
// Passage à un palier supérieur : minOffS si l'on quitte l'état OFF (stage 0→n)
// ou simplement le délai de stabilisation
if (m_currentStage == 0 && elapsed < m_minOffS)
return true;
} else {
// Réduction de palier : minOnS depuis le dernier ON
if (m_currentStage > 0 && elapsed < m_minOnS)
return true;
}
return false;
}
void EcsRelayAdapter::applyRelayStage(int stage)
{
// Ensemble des relais ON pour le nouveau stage
const QSet<QString> wantOn = [&]() {
QSet<QString> s;
if (stage < m_relayMapping.size())
for (const QString &id : m_relayMapping.at(stage))
s.insert(id);
return s;
}();
// Union de tous les relais connus
QSet<QString> allRelays;
for (const auto &list : m_relayMapping)
for (const QString &id : list)
allRelays.insert(id);
for (const QString &thingId : allRelays) {
Thing *relay = m_thingManager->findConfiguredThing(ThingId(thingId));
if (!relay) {
qCWarning(dcNymeaEnergy()) << "[EcsRelayAdapter]" << m_label
<< "— relais non trouvé:" << thingId;
continue;
}
const bool targetOn = wantOn.contains(thingId);
StateType powerStateType = relay->thingClass().stateTypes().findByName("power");
if (!powerStateType.id().isNull()) {
Action powerAction(powerStateType.id(), relay->id(), Action::TriggeredByRule);
powerAction.setParams(ParamList() << Param(powerStateType.id(), targetOn));
m_thingManager->executeAction(powerAction);
} else {
// Fallback mock : setStateValue direct (Things sans actionType "power")
relay->setStateValue("power", targetOn);
}
}
}

View File

@ -0,0 +1,108 @@
// 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 pour chauffe-eau ou tout relais N paliers (interface relay-stages).
*
* Pilote en production N Things powerswitch nymea : \c m_relayMapping[stage] contient
* la liste des ThingIds à mettre ON pour ce palier (les autres sont mis OFF).
*
* Exemple chauffe-eau 2400W, 2 résistances Waveshare :
* stage 0 : {} A=OFF, B=OFF
* stage 1 : {"thingId-A"} A=ON, B=OFF (1200 W)
* stage 2 : {"thingId-A", "thingId-B"} A=ON, B=ON (2400 W)
*
* \invariant applyAction() rejette silencieusement toute action dont \c reason est vide.
* \invariant applyAction() applique les verrous anti-rebond \c minOnS / \c minOffS
* SAUF si \c action.force == true (réservé L2 watchdog).
* \invariant Le stage est écrêté à [0, stages().size()-1] avant envoi matériel.
* \invariant Seul le kind Stage est traité ; les autres kinds retournent sans effet.
*/
class EcsRelayAdapter : public QObject, public ILoadAdapter
{
Q_OBJECT
public:
/*!
* \brief Constructeur.
* \param thingManager Gestionnaire nymea pour résoudre les ThingIds en Things.
* \param id Identifiant logique de la charge (ThingId de l'objet ECS dans nymea,
* ou identifiant arbitraire unique pour le mock).
* \param label Nom lisible affiché dans les logs et l'app.
* \param stages Puissances en W par palier, index 0 = off : [0, 1200, 2400].
* \param relayMapping relayMapping[i] = liste de ThingIds powerswitch ON pour le palier i.
* \param minOnS Durée minimale ON (s) anti-rebond.
* \param minOffS Durée minimale OFF (s) anti-rebond.
* \param priority Priorité dans le waterfall (200=deadline, 100=normal, 0=différable).
* \param parent Propriétaire Qt.
*/
explicit EcsRelayAdapter(ThingManager *thingManager,
const QString &id,
const QString &label,
const QList<int> &stages,
const QList<QList<QString>> &relayMapping,
int minOnS,
int minOffS,
int priority,
QObject *parent = nullptr);
/*!
* \brief Description statique de la charge.
* \return LoadDescriptor avec adapter="relay-stages", stages, minOnS/minOffS, priority.
*/
LoadDescriptor descriptor() const override;
/*!
* \brief Télémétrie runtime : puissance mesurée, stage courant, lastSwitch.
* \return LoadTelemetry avec currentPowerW issu de la somme des Things actifs.
*/
LoadTelemetry telemetry() const override;
/*!
* \brief Construit l'entrée loads[] §5 du SurplusContext.
* \return LoadContext incluant declared, limits et télémétrie ECS (stage, currentPowerW, lastSwitch).
*/
LoadContext toLoadContext() const override;
/*!
* \brief Applique un changement de palier sur les relais.
*
* \param action LoadAction de kind Stage. Autres kinds : retour sans effet.
* \return L'action après écrêtage (stage borné à [0, stages.size()-1]).
*
* \invariant Si \c action.reason est vide retour sans effet (log warning).
* \invariant Si verrous anti-rebond actifs ET \c action.force == false retour sans effet (log).
* \invariant Si \c action.force == true bypass verrous (L2 watchdog uniquement).
* \invariant Toute modification de stage met à jour \c m_lastSwitch.
*/
LoadAction applyAction(const LoadAction &action) override;
/*! \brief Stage courant (0 = off). */
int currentStage() const { return m_currentStage; }
private:
bool lockActive(int newStage) const;
void applyRelayStage(int stage);
ThingManager *m_thingManager;
QString m_id;
QString m_label;
QList<int> m_stages; //!< Puissances W par palier, [0]=off.
QList<QList<QString>> m_relayMapping; //!< ThingIds ON par palier.
int m_minOnS;
int m_minOffS;
int m_priority;
int m_currentStage = 0;
QDateTime m_lastSwitch; //!< Dernier changement de palier (null = jamais).
QDateTime m_lastActionAt;
};

View File

@ -3,9 +3,11 @@
#include "energyarbitrator.h"
#include "adapters/evadapter.h"
#include "adapters/ecsrelayadapter.h"
#include "scheduler/rulebasedscheduler.h"
#include "types/surpluscontext.h"
#include "types/plan.h"
#include "../rootmeter.h"
#include "plugininfo.h"
@ -51,6 +53,18 @@ RootMeter *EnergyArbitrator::registeredRootMeter() const
return internalRootMeter();
}
void EnergyArbitrator::registerEcsAdapter(EcsRelayAdapter *adapter)
{
const QString id = adapter->descriptor().id;
if (m_ecsAdapters.contains(id)) {
qCWarning(dcNymeaEnergy()) << "[EnergyArbitrator] EcsRelayAdapter déjà enregistré:" << id;
return;
}
adapter->setParent(this);
m_ecsAdapters[id] = adapter;
qCDebug(dcNymeaEnergy()) << "[EnergyArbitrator] EcsRelayAdapter enregistré:" << adapter->descriptor().label;
}
void EnergyArbitrator::update(const QDateTime &currentDateTime)
{
qCDebug(dcNymeaEnergy()) << "Updating smart charging";
@ -88,10 +102,33 @@ void EnergyArbitrator::update(const QDateTime &currentDateTime)
SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const
{
// 3b stub — seul le timestamp est renseigné.
// Contexte §5 complet (site/meter/pv/battery/loads) prévu en 3d.
SurplusContext ctx;
ctx.timestamp = now;
// --- Compteur principal (AGENTS invariant 8 : mesure brute, aucune déduction) ---
RootMeter *meter = internalRootMeter();
if (meter) {
// currentPower() < 0 → export ; > 0 → import (convention amont SCM l.1141)
const double p = meter->currentPower();
ctx.meter.importW = qMax(0.0, p);
ctx.meter.exportW = qMax(0.0, -p);
ctx.meter.perPhaseA = {
meter->currentPhaseA(),
meter->currentPhaseB(),
meter->currentPhaseC()
};
}
// SurplusPv : interface inverter — déféré (remplissage prévu en 3d)
// SurplusBattery : déféré 3f
// --- loads[] : EV adapters ---
for (auto it = m_adapters.constBegin(); it != m_adapters.constEnd(); ++it)
ctx.loads.append(it.value()->toLoadContext());
// --- loads[] : ECS relay adapters ---
for (auto it = m_ecsAdapters.constBegin(); it != m_ecsAdapters.constEnd(); ++it)
ctx.loads.append(it.value()->toLoadContext());
return ctx;
}

View File

@ -8,6 +8,7 @@
#include "types/plan.h"
class EvAdapter;
class EcsRelayAdapter;
class RuleBasedScheduler;
/*!
@ -70,6 +71,15 @@ public:
*/
RootMeter *registeredRootMeter() const;
/*!
* \brief Enregistre un EcsRelayAdapter pour inclusion dans le contexte et le dispatch.
*
* Appelé par le test (setup) ou la configuration de production.
* L'adaptateur est adopté comme enfant Qt de l'arbitre.
* \param adapter Adaptateur à enregistrer. Son \c descriptor().id doit être unique.
*/
void registerEcsAdapter(EcsRelayAdapter *adapter);
protected:
/*!
* \brief Boucle principale ETM surcharge SmartChargingManager::update().
@ -87,17 +97,22 @@ protected:
private:
/*!
* \brief Construit un SurplusContext minimal (3b stub timestamp seul).
* \note Contexte complet prévu en 3d (§5 complet avec site/meter/pv/battery/loads).
* \brief Construit le SurplusContext §5 : meter brut + loads EV + loads ECS.
*
* \c ctx.meter.exportW = mesure brute du compteur (AGENTS invariant 8 aucune
* déduction interne). La déduction evReservedW est faite dans le scheduler.
*/
SurplusContext buildContext(const QDateTime &now) const;
/*!
* \brief Synchronise m_adapters avec les EvCharger actuellement enregistrés.
* Crée les adapters manquants, supprime les adapters obsolètes.
* \note Découverte ECS via interface 'ecsrelay' ThingManager déféré 3g config.
* En beta, les EcsRelayAdapters sont enregistrés via registerEcsAdapter().
*/
void syncAdapters();
RuleBasedScheduler *m_scheduler = nullptr;
QHash<QString, EvAdapter *> m_adapters; //!< loadId (ThingId string) → EvAdapter*.
RuleBasedScheduler *m_scheduler = nullptr;
QHash<QString, EvAdapter *> m_adapters; //!< loadId (ThingId string) → EvAdapter*.
QHash<QString, EcsRelayAdapter *> m_ecsAdapters; //!< loadId → EcsRelayAdapter*.
};

View File

@ -6,10 +6,12 @@ HEADERS += \
$$PWD/adapters/iloadadapter.h \
$$PWD/scheduler/ischeduler.h \
$$PWD/adapters/evadapter.h \
$$PWD/adapters/ecsrelayadapter.h \
$$PWD/scheduler/rulebasedscheduler.h \
$$PWD/energyarbitrator.h \
SOURCES += \
$$PWD/adapters/evadapter.cpp \
$$PWD/adapters/ecsrelayadapter.cpp \
$$PWD/scheduler/rulebasedscheduler.cpp \
$$PWD/energyarbitrator.cpp \

View File

@ -59,4 +59,14 @@ struct LoadAction {
* Rempli par le scheduler ; peut être 0 si inconnu.
*/
double estimatedPowerW = 0;
/*!
* \brief Forçage sécurité bypasse les verrous anti-rebond (minOn/minOff).
*
* Positionné à \c true uniquement par \c applyDegradedMode() (L2 watchdog)
* et les contraintes de sécurité. Les adaptateurs doivent appliquer l'action
* immédiatement sans vérifier les verrous temporels.
* \warning Réservé à la sécurité. Ne jamais mettre à \c true dans un scheduler.
*/
bool force = false;
};