From 093fa09b5e78324b0e47deed68321a0128076d85 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Tue, 9 Jun 2026 23:19:54 +0200 Subject: [PATCH] =?UTF-8?q?[3e-3]=20mapping=20s=C3=A9mantique=20SG-Ready?= =?UTF-8?q?=20+=20waterfall=20unifi=C3=A9=20ECS/SG-Ready?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getPlan : cascade unique sur charges non-EV (relay-stages + sg-ready) triées par priorité — budget de surplus partagé. buildSgReadyStateAction : mapping qualitatif (4 forcé hyst. 1,2/1,0 ; 3 reco ≥P3 ; 2 normal mains off ; 1 jamais via surplus), recrédit sur puissance allouée déclarée, clamp lock-aware minState/maxState. AGENTS.md : ROADMAP config priorités utilisateur (acquis tri unifié ; manque 3g VE + couche config JSON-RPC/UI Flutter pour drag-and-drop à chaud). Build 0/0. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 17 ++++ .../etm/scheduler/rulebasedscheduler.cpp | 87 +++++++++++++++++-- .../etm/scheduler/rulebasedscheduler.h | 15 ++++ 3 files changed, 110 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6da7b7b..7d4d1d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -370,6 +370,23 @@ supérieures n'affecte pas les couches inférieures. Voir `docs/SAFETY.md` pour (invariants, écrêtage, hypothèses que l'appelant peut faire). Les headers 3a servent de modèle — les convertir au format Doxygen lors du passage 3b. +## ROADMAP — configuration des priorités par l'utilisateur (post-beta) + +- **ACQUIS (3e)** : le waterfall trie déjà ECS + SG-Ready ensemble par `priority` (rang + ASC, 1 = servi en premier), **budget unifié** qui cascade à travers toutes ces charges. +- **LIMITE beta** : le VE reste **hors du tri** (décidé par l'amont *avant* le waterfall, + décision B) → on ne peut pas classer le VE derrière l'ECS. +- **MANQUE pour des priorités réglables par le client** : + - (a) **3g** : transplanter le VE dans le waterfall unifié → toutes les charges + (VE, ECS, SG-Ready, batterie) classables ensemble. + - (b) **Couche « config priorités »** : exposer et persister le `priority` de chaque + charge via l'API JSON-RPC, modifiable depuis l'app Flutter (drag-and-drop des + priorités de l'UI). C'est le **pont moteur↔UI**, un morceau à part entière + (ni 3e ni 3g). +- **État actuel** : `priority` est fixé à la création de l'adaptateur (`register*Adapter`), + pas d'interface de réglage à chaud. +- **Argument démo nymea** : le client réordonne ses charges, le surplus suit. + ## RÉFÉRENCES - `docs/OPTIMIZER_PROTOCOL.md` — le contrat. §5 (SurplusContext), §6 (plan/actions), diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index cbe3d9c..cb56dc8 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -96,20 +96,25 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) // transitoire sous minOn est géré par le verrou de l'adaptateur, pas par le budget.) double remainingSurplusW = (ctx.meter.exportW - ctx.meter.importW) - 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; + // Charges pilotables non-EV (ECS paliers + SG-Ready) triées par priorité ASCENDANTE : + // rang 1 = premier servi (OPTIMIZER_PROTOCOL §5 + annexe C — la priorité est un rang). + // Le budget de surplus est UNIQUE et cascade à travers TOUTES ces charges par priorité. + QList nonEvLoads; for (const LoadContext &lc : ctx.loads) { - if (lc.adapter == QStringLiteral("relay-stages")) - ecsLoads.append(lc); + if (lc.adapter == QStringLiteral("relay-stages") || lc.adapter == QStringLiteral("sg-ready")) + nonEvLoads.append(lc); } - std::sort(ecsLoads.begin(), ecsLoads.end(), + std::sort(nonEvLoads.begin(), nonEvLoads.end(), [](const LoadContext &a, const LoadContext &b) { return a.priority < b.priority; }); - for (const LoadContext &lc : ecsLoads) - slot.actions.append(buildEcsStageAction(lc, remainingSurplusW)); + for (const LoadContext &lc : nonEvLoads) { + if (lc.adapter == QStringLiteral("sg-ready")) + slot.actions.append(buildSgReadyStateAction(lc, remainingSurplusW)); + else + slot.actions.append(buildEcsStageAction(lc, remainingSurplusW)); + } - // Grid funding ECS : dormant jusqu'à 3f (waterfall réseau) — non implémenté ici. + // Grid funding (ECS/PAC) : dormant jusqu'à 3f (waterfall réseau) — non implémenté ici. Plan plan; plan.planId = QUuid::createUuid().toString(QUuid::WithoutBraces); @@ -217,3 +222,67 @@ LoadAction RuleBasedScheduler::buildEcsStageAction(const LoadContext &lc, remainingSurplusW = budgetChargeW - la.estimatedPowerW; return la; } + +LoadAction RuleBasedScheduler::buildSgReadyStateAction(const LoadContext &lc, + double &remainingSurplusW) const +{ + const int currentState = lc.telemetry.state; + const double p3 = lc.declared.estimatedPowerW.value(3, 0.0); + const double p4 = lc.declared.estimatedPowerW.value(4, 0.0); + + // Recrédit (correction B) : la puissance ALLOUÉE de l'état courant (déclaré, 0 pour 1/2) + // revient au budget — comme l'ECS. Base : "quel surplus si la PAC n'était pas pilotée ?" + const double allocatedNowW = lc.declared.estimatedPowerW.value(currentState, 0.0); + const double budgetW = remainingSurplusW + allocatedNowW; + + // Mapping SÉMANTIQUE (pas "le plus haut qui rentre") avec hystérésis d'état anti-oscillation : + // monter en 4 si budget ≥ P4×1,2 ; rester en 4 tant que budget ≥ P4×1,0 (zone morte) ; + // sinon recommandation (≥ P3) ; sinon normal (état 2, mains off). + // État 1 (effacement) jamais déclenché par le surplus seul — déféré (signal tarif/réseau). + constexpr double kForceMargin = 1.2; // hystérésis d'entrée en état 4 + int targetState; + if (p4 > 0.0 && budgetW >= p4 * kForceMargin) + targetState = 4; + else if (currentState == 4 && p4 > 0.0 && budgetW >= p4) // zone morte : reste forcé + targetState = 4; + else if (p3 > 0.0 && budgetW >= p3) + targetState = 3; + else + targetState = 2; + + // Clamp lock-aware (fenêtre minStateHold, calculée au MÊME ctx.timestamp). + const int loW = lc.telemetry.minState > 0 ? lc.telemetry.minState : 2; + const int hiW = lc.telemetry.maxState > 0 ? lc.telemetry.maxState : 2; + int bestState = qBound(qMin(loW, hiW), targetState, qMax(loW, hiW)); + // Sécurité : ne jamais commander un état non déclaré (snap au plus haut déclaré ≤ cible). + if (!lc.declared.states.contains(bestState)) { + int snapped = lc.declared.states.isEmpty() ? 2 : lc.declared.states.first(); + for (int s : lc.declared.states) + if (s <= bestState && s > snapped) snapped = s; + bestState = snapped; + } + + LoadAction la; + la.loadId = lc.id; + la.kind = LoadAction::State; + la.funding = LoadAction::Surplus; + la.state = bestState; + la.estimatedPowerW = lc.declared.estimatedPowerW.value(bestState, 0.0); + + if (bestState != targetState) + la.reason = QStringLiteral("Verrou minStateHold — %1 maintenue état %2 (court-cycling PAC)") + .arg(lc.label).arg(bestState); + else if (bestState == 4) + la.reason = QStringLiteral("Surplus abondant %1 W — %2 forcée (état 4, ~%3 W)") + .arg(qRound(budgetW)).arg(lc.label).arg(qRound(p4)); + else if (bestState == 3) + la.reason = QStringLiteral("Surplus PV %1 W — %2 recommandée (état 3, ~%3 W)") + .arg(qRound(budgetW)).arg(lc.label).arg(qRound(p3)); + else + la.reason = QStringLiteral("Surplus insuffisant (%1 W) — %2 en normal (état 2, non pilotée)") + .arg(qRound(budgetW)).arg(lc.label); + + // Budget restant : on ne soustrait que la puissance ALLOUÉE (états 1/2 = 0). + remainingSurplusW = budgetW - la.estimatedPowerW; + return la; +} diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.h b/energyplugin/etm/scheduler/rulebasedscheduler.h index 535f045..8d65177 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.h +++ b/energyplugin/etm/scheduler/rulebasedscheduler.h @@ -91,5 +91,20 @@ private: */ LoadAction buildEcsStageAction(const LoadContext &lc, double &remainingSurplusW) const; + /*! + * \brief Construit un LoadAction "state" SG-Ready (PAC) par mapping SÉMANTIQUE du surplus. + * + * 4 états normés (qualitatifs, pas des paliers) : surplus abondant stable → 4 (forcé, + * hystérésis P4×1,2 entrée / P4×1,0 sortie) ; surplus durable → 3 (recommandation, ≥P3) ; + * sinon → 2 (normal, mains off). L'état 1 (effacement) n'est PAS déclenché par le surplus + * seul (déféré : signal tarif/réseau). Recrédit (correction B) sur la puissance allouée + * (déclaré, 0 pour 1/2). Clamp lock-aware via \c minState/maxState (court-cycling PAC). + * + * \param lc Charge SG-Ready (adapter == "sg-ready") du SurplusContext. + * \param[in,out] remainingSurplusW Budget de surplus restant (W), mis à jour pour la suite. + * \return LoadAction kind=State, funding=Surplus, \c reason français non vide. + */ + LoadAction buildSgReadyStateAction(const LoadContext &lc, double &remainingSurplusW) const; + EnergyArbitrator *m_arbitrator; };