[3c-3] waterfall ECS dans RuleBasedScheduler::getPlan() + tri priorité ASC

Corrections A (déduction EV unique) et B (anti-clignotement) intégrées.
Tri priorité ascendant (rang 1 = premier servi, OPTIMIZER_PROTOCOL §5/annexe C) —
corrige l'inversion du PLAN 3C et 3 doc-comments (plan.h, loaddescriptor.h,
ecsrelayadapter.h). Build 0 erreur / 0 warning.

telemetry() ECS : currentPowerW MESURÉE si au moins un relais expose "currentPower"
(thermostat coupé → 0, pas de fantôme), DÉCLARÉE en repli seulement sans comptage.
Dette evadapter.cpp priority=100 (ancienne convention) inscrite en 3g.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-06-08 16:20:08 +02:00
parent 7709057335
commit 6298d5d42f
7 changed files with 114 additions and 10 deletions

View File

@ -72,7 +72,7 @@ Après la boucle EV proxy, ajouter :
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)
// Tri loads ECS par priorité ASC (priority 1 = servi en premier ; protocole §5 + annexe C)
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
@ -238,6 +238,14 @@ au compteur).
`RuleBasedScheduler` → priorités libres entre toutes les charges (EV, ECS, SG-Ready,
batterie).
**Dette 3g — convention de priorité EV** : `EvAdapter::descriptor()` met
`priority = 100` (`evadapter.cpp:24`), reliquat de l'ancienne convention « poids,
valeur haute = premier ». Inoffensif en beta : l'EV est servi par le proxy *avant* le
waterfall ECS et n'entre pas dans le tri ECS (ascendant, rang 1 = premier servi,
protocole §5). À reconcilier quand l'EV rejoindra le waterfall unifié : la priorité
devient un **rang** (1, 2, 3…), pas un poids — sinon `priority=100` placerait l'EV en
dernier d'un tri ascendant.
---
### 3b-iii — EnergyArbitrator hérite de SmartChargingManager

View File

@ -55,18 +55,29 @@ LoadTelemetry EcsRelayAdapter::telemetry() const
t.available = true;
t.lastActionAt = m_lastActionAt;
// Puissance mesurée = somme des relais actifs pour le stage courant
// Source de currentPowerW (consommée par le recrédit anti-clignotement du waterfall,
// RuleBasedScheduler::buildEcsStageAction — correction B) :
// - MESURÉE dès qu'au moins un relais du stage expose un state "currentPower" :
// somme des mesures. Un thermostat interne coupé (relais ON mais 0 W réel) renvoie
// alors 0 → recrédit 0, jamais de puissance fantôme.
// - DÉCLARÉE (repli) : stages[stage] UNIQUEMENT si aucun relais actif ne mesure
// (relais nus / mock sans powermetering). Approximation = palier à sa nominale.
double power = 0;
bool metered = false;
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();
if (!t2)
continue;
if (!t2->thingClass().stateTypes().findByName("currentPower").id().isNull()) {
metered = true;
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())
// Repli déclaré : seulement si la mesure n'est pas disponible (pas si elle vaut 0).
if (!metered && m_currentStage > 0 && m_currentStage < m_stages.size())
power = m_stages.at(m_currentStage);
t.currentPowerW = power;

View File

@ -42,7 +42,8 @@ public:
* \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 priority Rang dans le waterfall (OPTIMIZER_PROTOCOL §5) : valeur plus BASSE
* = servi en premier (rang 1 = premier servi).
* \param parent Propriétaire Qt.
*/
explicit EcsRelayAdapter(ThingManager *thingManager,

View File

@ -9,6 +9,7 @@
#include "plugininfo.h"
#include <QUuid>
#include <algorithm>
RuleBasedScheduler::RuleBasedScheduler(EnergyArbitrator *arbitrator, QObject *parent)
: QObject(parent)
@ -78,6 +79,35 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
slot.actions.append(la);
}
// ---- Waterfall ECS (charges non-EV à paliers) ---------------------------------
// Correction A — déduction EV unique : ctx.meter.exportW est la mesure BRUTE
// (invariant 8). Les consignes EV de CE cycle ne sont pas encore visibles au
// compteur ; on réserve donc leur puissance commandée non encore mesurée.
double evReservedW = 0;
for (const LoadAction &la : slot.actions) {
if (la.kind != LoadAction::Setpoint || !la.chargingEnabled)
continue;
EvCharger *ev = evs.value(ThingId(la.loadId));
const double measuredW = ev ? ev->currentPower() : 0.0;
evReservedW += qMax(0.0, la.estimatedPowerW - measuredW);
}
double remainingSurplusW = qMax(0.0, ctx.meter.exportW - evReservedW);
// Charges ECS triées par priorité ASCENDANTE : rang 1 = premier servi
// (OPTIMIZER_PROTOCOL §5 + annexe C — la priorité est un rang, pas un poids).
QList<LoadContext> ecsLoads;
for (const LoadContext &lc : ctx.loads) {
if (lc.adapter == QStringLiteral("relay-stages"))
ecsLoads.append(lc);
}
std::sort(ecsLoads.begin(), ecsLoads.end(),
[](const LoadContext &a, const LoadContext &b) { return a.priority < b.priority; });
for (const LoadContext &lc : ecsLoads)
slot.actions.append(buildEcsStageAction(lc, remainingSurplusW));
// Grid funding ECS : dormant jusqu'à 3f (waterfall réseau) — non implémenté ici.
Plan plan;
plan.planId = QUuid::createUuid().toString(QUuid::WithoutBraces);
plan.strategy = QStringLiteral("rule-based");
@ -132,3 +162,39 @@ LoadAction RuleBasedScheduler::buildIdleAction(EvCharger *ev) const
la.estimatedPowerW = 0;
return la;
}
LoadAction RuleBasedScheduler::buildEcsStageAction(const LoadContext &lc,
double &remainingSurplusW) const
{
// Correction B (anti-clignotement) : on recrédite la conso actuelle de l'ECS au budget.
// Sans ce recrédit, allumer un palier ferait chuter l'export mesuré → palier 0 → oscillation.
const double budgetChargeW = remainingSurplusW + lc.telemetry.currentPowerW;
// Palier déclaré le plus haut qui tient dans le budget. stages[0] == 0 par construction
// (Q_ASSERT EcsRelayAdapter) → bestStage ≥ 0 toujours, donc reason toujours définie.
const QList<int> &stages = lc.declared.stages;
int bestStage = 0;
for (int i = 0; i < stages.size(); ++i) {
if (stages.at(i) <= budgetChargeW)
bestStage = i;
}
LoadAction la;
la.loadId = lc.id;
la.kind = LoadAction::Stage;
la.funding = LoadAction::Surplus;
la.stage = bestStage;
la.estimatedPowerW = bestStage < stages.size() ? stages.at(bestStage) : 0;
if (bestStage > 0)
la.reason = QStringLiteral("Surplus PV %1 W — %2 palier %3 (%4 W)")
.arg(qRound(budgetChargeW)).arg(lc.label)
.arg(bestStage).arg(stages.at(bestStage));
else
la.reason = QStringLiteral("Surplus insuffisant (%1 W) — %2 éteint")
.arg(qRound(budgetChargeW)).arg(lc.label);
// Budget restant pour les charges ECS suivantes (rang supérieur / priorité plus basse).
remainingSurplusW = budgetChargeW - la.estimatedPowerW;
return la;
}

View File

@ -76,5 +76,20 @@ private:
*/
LoadAction buildIdleAction(EvCharger *ev) const;
/*!
* \brief Construit un LoadAction "stage" ECS par cascade de surplus (waterfall §6).
*
* Retient le palier déclaré le plus haut dont la puissance tient dans le budget courant.
* Correction B (anti-clignotement) : recrédite d'abord \c lc.telemetry.currentPowerW au
* budget (la conso actuelle de l'ECS est déjà soustraite de l'export mesuré), puis
* décrémente \c remainingSurplusW de la puissance du palier retenu.
*
* \param lc Charge ECS (adapter == "relay-stages") du SurplusContext.
* \param[in,out] remainingSurplusW Budget de surplus restant (W) ; mis à jour pour la
* charge ECS suivante (priorité inférieure / rang supérieur).
* \return LoadAction kind=Stage, funding=Surplus, \c reason français non vide.
*/
LoadAction buildEcsStageAction(const LoadContext &lc, double &remainingSurplusW) const;
EnergyArbitrator *m_arbitrator;
};

View File

@ -60,8 +60,10 @@ struct LoadNeeds {
* L'arbitre lit ce descripteur une fois par cycle pour construire le SurplusContext.
* Les valeurs doivent refléter la configuration matérielle réelle (non les setpoints).
*
* \note \c priority : entier positif, valeur plus haute = traité en premier.
* Valeurs suggérées : 200 deadline, 100 normal, 50 confort, 0 stockage différable.
* \note \c priority : rang dans la liste ordonnée du client (OPTIMIZER_PROTOCOL §5 +
* annexe C). Valeur plus BASSE = servi en premier (rang 1 = premier servi).
* Les promotions conditionnelles (deadline, Tempo ROUGE) sont gérées par le scheduler,
* pas par un poids numérique.
*/
struct LoadDescriptor {
QString id; //!< ThingId de la charge (string).

View File

@ -12,7 +12,8 @@
/*!
* \brief Créneau d'un plan contient les LoadAction à appliquer pendant [from, to[.
*
* \invariant Les actions sont ordonnées par priorité décroissante (LoadDescriptor.priority).
* \invariant Les actions sont ordonnées par priorité croissante (rang 1 = premier servi,
* OPTIMIZER_PROTOCOL §5 + annexe C).
* \invariant Un Slot vide (actions vide) est valide signifie "aucune action ce créneau".
*/
struct Slot {