[3e-3] mapping sémantique SG-Ready + waterfall unifié ECS/SG-Ready

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) <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-06-09 23:19:54 +02:00
parent c6d7831df9
commit 093fa09b5e
3 changed files with 110 additions and 9 deletions

View File

@ -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),

View File

@ -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<LoadContext> 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<LoadContext> 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;
}

View File

@ -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;
};