[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:
parent
c6d7831df9
commit
093fa09b5e
17
AGENTS.md
17
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).
|
(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.
|
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
|
## RÉFÉRENCES
|
||||||
|
|
||||||
- `docs/OPTIMIZER_PROTOCOL.md` — le contrat. §5 (SurplusContext), §6 (plan/actions),
|
- `docs/OPTIMIZER_PROTOCOL.md` — le contrat. §5 (SurplusContext), §6 (plan/actions),
|
||||||
|
|||||||
@ -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.)
|
// 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;
|
double remainingSurplusW = (ctx.meter.exportW - ctx.meter.importW) - evReservedW;
|
||||||
|
|
||||||
// Charges ECS triées par priorité ASCENDANTE : rang 1 = premier servi
|
// Charges pilotables non-EV (ECS paliers + SG-Ready) triées par priorité ASCENDANTE :
|
||||||
// (OPTIMIZER_PROTOCOL §5 + annexe C — la priorité est un rang, pas un poids).
|
// rang 1 = premier servi (OPTIMIZER_PROTOCOL §5 + annexe C — la priorité est un rang).
|
||||||
QList<LoadContext> ecsLoads;
|
// Le budget de surplus est UNIQUE et cascade à travers TOUTES ces charges par priorité.
|
||||||
|
QList<LoadContext> nonEvLoads;
|
||||||
for (const LoadContext &lc : ctx.loads) {
|
for (const LoadContext &lc : ctx.loads) {
|
||||||
if (lc.adapter == QStringLiteral("relay-stages"))
|
if (lc.adapter == QStringLiteral("relay-stages") || lc.adapter == QStringLiteral("sg-ready"))
|
||||||
ecsLoads.append(lc);
|
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; });
|
[](const LoadContext &a, const LoadContext &b) { return a.priority < b.priority; });
|
||||||
|
|
||||||
for (const LoadContext &lc : ecsLoads)
|
for (const LoadContext &lc : nonEvLoads) {
|
||||||
slot.actions.append(buildEcsStageAction(lc, remainingSurplusW));
|
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 plan;
|
||||||
plan.planId = QUuid::createUuid().toString(QUuid::WithoutBraces);
|
plan.planId = QUuid::createUuid().toString(QUuid::WithoutBraces);
|
||||||
@ -217,3 +222,67 @@ LoadAction RuleBasedScheduler::buildEcsStageAction(const LoadContext &lc,
|
|||||||
remainingSurplusW = budgetChargeW - la.estimatedPowerW;
|
remainingSurplusW = budgetChargeW - la.estimatedPowerW;
|
||||||
return la;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -91,5 +91,20 @@ private:
|
|||||||
*/
|
*/
|
||||||
LoadAction buildEcsStageAction(const LoadContext &lc, double &remainingSurplusW) const;
|
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;
|
EnergyArbitrator *m_arbitrator;
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user