diff --git a/AGENTS.md b/AGENTS.md index 07d8b09..a065a89 100644 --- a/AGENTS.md +++ b/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()) 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 diff --git a/energyplugin/etm/adapters/ecsrelayadapter.cpp b/energyplugin/etm/adapters/ecsrelayadapter.cpp index 089a4aa..85fb5b0 100644 --- a/energyplugin/etm/adapters/ecsrelayadapter.cpp +++ b/energyplugin/etm/adapters/ecsrelayadapter.cpp @@ -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 &activeRelays = m_currentStage < m_relayMapping.size() ? m_relayMapping.at(m_currentStage) : QList(); 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; diff --git a/energyplugin/etm/adapters/ecsrelayadapter.h b/energyplugin/etm/adapters/ecsrelayadapter.h index 46510b8..e3eb6ec 100644 --- a/energyplugin/etm/adapters/ecsrelayadapter.h +++ b/energyplugin/etm/adapters/ecsrelayadapter.h @@ -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, diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index 363abe2..669a991 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -9,6 +9,7 @@ #include "plugininfo.h" #include +#include 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 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 &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; +} diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.h b/energyplugin/etm/scheduler/rulebasedscheduler.h index 571dafb..535f045 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.h +++ b/energyplugin/etm/scheduler/rulebasedscheduler.h @@ -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; }; diff --git a/energyplugin/etm/types/loaddescriptor.h b/energyplugin/etm/types/loaddescriptor.h index 9c981e7..d7982c6 100644 --- a/energyplugin/etm/types/loaddescriptor.h +++ b/energyplugin/etm/types/loaddescriptor.h @@ -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). diff --git a/energyplugin/etm/types/plan.h b/energyplugin/etm/types/plan.h index 3cfeb48..97a3273 100644 --- a/energyplugin/etm/types/plan.h +++ b/energyplugin/etm/types/plan.h @@ -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 {