[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:
parent
7709057335
commit
6298d5d42f
10
AGENTS.md
10
AGENTS.md
@ -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())
|
evReservedW = Σ EV en charge dans slot.actions : max(0, commandedA×phases×230 − ev->currentPower())
|
||||||
remainingSurplusW = max(0, ctx.meter.exportW − evReservedW)
|
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" :
|
pour chaque LoadContext lc où lc.adapter == "relay-stages" :
|
||||||
budgetCharge = remainingSurplusW + lc.telemetry.currentPowerW // correction B anti-clignotement
|
budgetCharge = remainingSurplusW + lc.telemetry.currentPowerW // correction B anti-clignotement
|
||||||
bestStage = palier le plus haut dont stages[i] ≤ budgetCharge
|
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,
|
`RuleBasedScheduler` → priorités libres entre toutes les charges (EV, ECS, SG-Ready,
|
||||||
batterie).
|
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
|
### 3b-iii — EnergyArbitrator hérite de SmartChargingManager
|
||||||
|
|||||||
@ -55,18 +55,29 @@ LoadTelemetry EcsRelayAdapter::telemetry() const
|
|||||||
t.available = true;
|
t.available = true;
|
||||||
t.lastActionAt = m_lastActionAt;
|
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;
|
double power = 0;
|
||||||
|
bool metered = false;
|
||||||
const QList<QString> &activeRelays = m_currentStage < m_relayMapping.size()
|
const QList<QString> &activeRelays = m_currentStage < m_relayMapping.size()
|
||||||
? m_relayMapping.at(m_currentStage)
|
? m_relayMapping.at(m_currentStage)
|
||||||
: QList<QString>();
|
: QList<QString>();
|
||||||
for (const QString &thingId : activeRelays) {
|
for (const QString &thingId : activeRelays) {
|
||||||
Thing *t2 = m_thingManager->findConfiguredThing(ThingId(thingId));
|
Thing *t2 = m_thingManager->findConfiguredThing(ThingId(thingId));
|
||||||
if (t2)
|
if (!t2)
|
||||||
power += t2->stateValue("currentPower").toDouble();
|
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
|
// Repli déclaré : seulement si la mesure n'est pas disponible (pas si elle vaut 0).
|
||||||
if (power == 0 && m_currentStage > 0 && m_currentStage < m_stages.size())
|
if (!metered && m_currentStage > 0 && m_currentStage < m_stages.size())
|
||||||
power = m_stages.at(m_currentStage);
|
power = m_stages.at(m_currentStage);
|
||||||
|
|
||||||
t.currentPowerW = power;
|
t.currentPowerW = power;
|
||||||
|
|||||||
@ -42,7 +42,8 @@ public:
|
|||||||
* \param relayMapping relayMapping[i] = liste de ThingIds powerswitch ON pour le palier i.
|
* \param relayMapping relayMapping[i] = liste de ThingIds powerswitch ON pour le palier i.
|
||||||
* \param minOnS Durée minimale ON (s) — anti-rebond.
|
* \param minOnS Durée minimale ON (s) — anti-rebond.
|
||||||
* \param minOffS Durée minimale OFF (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.
|
* \param parent Propriétaire Qt.
|
||||||
*/
|
*/
|
||||||
explicit EcsRelayAdapter(ThingManager *thingManager,
|
explicit EcsRelayAdapter(ThingManager *thingManager,
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
#include "plugininfo.h"
|
#include "plugininfo.h"
|
||||||
#include <QUuid>
|
#include <QUuid>
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
RuleBasedScheduler::RuleBasedScheduler(EnergyArbitrator *arbitrator, QObject *parent)
|
RuleBasedScheduler::RuleBasedScheduler(EnergyArbitrator *arbitrator, QObject *parent)
|
||||||
: QObject(parent)
|
: QObject(parent)
|
||||||
@ -78,6 +79,35 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
|
|||||||
slot.actions.append(la);
|
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 plan;
|
||||||
plan.planId = QUuid::createUuid().toString(QUuid::WithoutBraces);
|
plan.planId = QUuid::createUuid().toString(QUuid::WithoutBraces);
|
||||||
plan.strategy = QStringLiteral("rule-based");
|
plan.strategy = QStringLiteral("rule-based");
|
||||||
@ -132,3 +162,39 @@ LoadAction RuleBasedScheduler::buildIdleAction(EvCharger *ev) const
|
|||||||
la.estimatedPowerW = 0;
|
la.estimatedPowerW = 0;
|
||||||
return la;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -76,5 +76,20 @@ private:
|
|||||||
*/
|
*/
|
||||||
LoadAction buildIdleAction(EvCharger *ev) const;
|
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;
|
EnergyArbitrator *m_arbitrator;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -60,8 +60,10 @@ struct LoadNeeds {
|
|||||||
* L'arbitre lit ce descripteur une fois par cycle pour construire le SurplusContext.
|
* 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).
|
* 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.
|
* \note \c priority : rang dans la liste ordonnée du client (OPTIMIZER_PROTOCOL §5 +
|
||||||
* Valeurs suggérées : 200 deadline, 100 normal, 50 confort, 0 stockage différable.
|
* 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 {
|
struct LoadDescriptor {
|
||||||
QString id; //!< ThingId de la charge (string).
|
QString id; //!< ThingId de la charge (string).
|
||||||
|
|||||||
@ -12,7 +12,8 @@
|
|||||||
/*!
|
/*!
|
||||||
* \brief Créneau d'un plan — contient les LoadAction à appliquer pendant [from, to[.
|
* \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".
|
* \invariant Un Slot vide (actions vide) est valide — signifie "aucune action ce créneau".
|
||||||
*/
|
*/
|
||||||
struct Slot {
|
struct Slot {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user