Patrick Schurig 36394cc351 feat(3g-1): la prise en compte du stockage monte dans le budget
Première moitié de 3g-1. La règle batterie vivait dans planSurplusCharging(), donc dans
le chemin VE — par accident d'histoire, pas par conception. Ce n'a jamais été une règle
« véhicule » : c'est une règle de BUDGET. Tant qu'elle y restait, une batterie sous sa
réserve empêchait de charger la voiture mais laissait le chauffe-eau se servir : deux
poids, deux mesures pour la même énergie.

buildContext() remplit désormais ctx.battery (champ qui existait, resté vide depuis
« déféré 3f »), et le waterfall applique les quatre comportements de l'amont :
sous la réserve le budget est ANNULÉ (et non réduit — servir « un peu » retarderait la
recharge sans démarrer personne, planchers du §12) ; batterie en charge sous le seuil,
tampon de 500 W ; en charge au-dessus, sa puissance rejoint le budget ; en décharge, sa
puissance est retranchée quel que soit le niveau, car ce qu'elle fournit n'est pas du
surplus.

⚠️ CHANGEMENT DE COMPORTEMENT assumé pour l'ECS et la PAC, qui n'ont jamais connu cette
règle. Une installation qui servait ses charges sous le seuil de réserve ne le fera plus.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
2026-08-26 11:34:52 +02:00

453 lines
23 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#include "rulebasedscheduler.h"
#include "../energyarbitrator.h"
#include "../../evcharger.h"
#include "../../types/chargingaction.h"
#include "../../types/charginginfo.h"
#include "plugininfo.h"
#include <QUuid>
#include <algorithm>
RuleBasedScheduler::RuleBasedScheduler(EnergyArbitrator *arbitrator, QObject *parent)
: QObject(parent)
, m_arbitrator(arbitrator)
{
}
Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
{
// Planification (même logique que l'amont — écrit dans m_chargingActions)
m_arbitrator->runSpotMarketPlanning(ctx.timestamp);
m_arbitrator->runSurplusPlanning(ctx.timestamp);
Slot slot;
slot.from = ctx.timestamp;
slot.to = ctx.timestamp.addSecs(60);
const auto &cas = m_arbitrator->scheduledActions();
const auto &evs = m_arbitrator->registeredEvChargers();
// Même priorité que adjustEvChargers() — iso-fonctionnel 3b
for (auto it = evs.constBegin(); it != evs.constEnd(); ++it) {
EvCharger *ev = it.value();
if (!ev->available() || !ev->pluggedIn())
continue;
const ChargingActions &actions = cas.value(ev);
LoadAction la;
if (actions.value(ChargingAction::ChargingActionIssuerTimeRequirement).chargingEnabled()) {
la = buildTimeRequirementAction(
ev, actions.value(ChargingAction::ChargingActionIssuerTimeRequirement));
} else if (actions.value(ChargingAction::ChargingActionIssuerSurplusCharging).chargingEnabled()) {
const auto &ca = actions.value(ChargingAction::ChargingActionIssuerSurplusCharging);
la.loadId = ev->thing()->id().toString();
la.kind = LoadAction::Setpoint;
la.funding = LoadAction::Surplus;
la.chargingEnabled = true;
la.currentA = ca.maxChargingCurrent();
la.phaseCount = ca.desiredPhaseCount();
la.reason = { DecisionCode::EvSurplus };
la.estimatedPowerW = la.currentA * 230.0 * la.phaseCount;
} else if (actions.value(ChargingAction::ChargingActionIssuerSpotMarketCharging).chargingEnabled()) {
const auto &ca = actions.value(ChargingAction::ChargingActionIssuerSpotMarketCharging);
la.loadId = ev->thing()->id().toString();
la.kind = LoadAction::Setpoint;
la.funding = LoadAction::Grid;
la.chargingEnabled = true;
la.currentA = ca.maxChargingCurrent();
la.phaseCount = ca.desiredPhaseCount();
la.reason = { DecisionCode::EvSpotMarket };
la.estimatedPowerW = la.currentA * 230.0 * la.phaseCount;
} else {
const ChargingInfo::ChargingMode mode =
m_arbitrator->chargingInfo(ev->id()).chargingMode();
if (mode == ChargingInfo::ChargingModeEcoWithMinCurrent
|| mode == ChargingInfo::ChargingModeEcoMinWithTargetTime) {
la = buildMinCurrentAction(ev);
} else {
la = buildIdleAction(ev);
}
}
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);
}
// Surplus net SIGNÉ : exportW − importW = −puissance compteur. Négatif en import →
// l'ECS déleste (budget < palier) au lieu de rester allumé sur le réseau. (Le maintien
// 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;
// ---- Prise en compte du stockage (3g-1) ---------------------------------------
// Remontée du chemin VE, où elle vivait par accident d'histoire. Ce n'a jamais été une
// règle « véhicule » : c'est une règle de BUDGET. Tant qu'elle restait dans
// planSurplusCharging(), une batterie sous sa réserve empêchait de charger la voiture mais
// laissait le chauffe-eau se servir — deux poids, deux mesures pour la même énergie.
//
// ⚠️ CHANGEMENT DE COMPORTEMENT pour l'ECS et la PAC, qui n'ont jamais connu cette règle.
// Une installation qui servait ses charges sous le seuil de réserve ne le fera plus.
if (ctx.battery.present) {
const double seuil = m_arbitrator->batteryLevelConsideration() * 100.0;
if (ctx.battery.socPercent < seuil) {
// Sous la réserve : le stockage passe AVANT toute charge pilotée. On ne réduit pas
// le budget, on l'annule — servir « un peu » reviendrait à retarder la recharge de
// la batterie tout en ne démarrant probablement personne (planchers du §12).
if (remainingSurplusW > 0)
qCDebug(dcNymeaEnergy()).nospace()
<< "[Waterfall] Stockage à " << ctx.battery.socPercent << "% sous la réserve de "
<< seuil << "% : budget de surplus annulé (" << remainingSurplusW << " W).";
remainingSurplusW = qMin(remainingSurplusW, 0.0);
// La batterie charge sous son seuil : lui laisser de quoi le faire. Le tampon est
// repris tel quel de l'amont — il évite que le waterfall ne mange exactement ce
// que la batterie s'apprêtait à prendre.
if (ctx.battery.powerW > 0)
remainingSurplusW -= 500;
} else if (ctx.battery.powerW > 0) {
// Au-dessus de la réserve et en charge : cette puissance est disponible pour les
// charges pilotées — les servir vaut mieux que de remplir une batterie déjà sûre.
remainingSurplusW += ctx.battery.powerW;
}
if (ctx.battery.powerW < 0) {
// Décharge : ne pas vider la batterie pour alimenter une charge pilotée, quel que
// soit le niveau. Ce qu'elle fournit n'est pas du surplus.
remainingSurplusW += ctx.battery.powerW; // powerW < 0 → retranche
}
}
// Comptabilité PUBLIÉE du budget (télémétrie RPC). Elle s'alimente DANS la cascade,
// pendant que celle-ci se déroule : c'est le seul endroit qui sache ce qui a réellement
// été alloué et ce qui a été recrédité. La recalculer ailleurs donnerait deux comptes à
// tenir d'accord, et le chiffre publié serait le premier à mentir.
PlanBudget compta;
compta.surplusW = remainingSurplusW;
compta.evReservedW = evReservedW;
// Charges pilotables non-EV 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é :
// - etmvariableload (ECS/routeur, kind Setpoint W → buildSetpointAction, règle d'arrondi §5) ;
// - sg-ready (PAC, kind State → buildSgReadyStateAction, mapping sémantique).
// Charges en WATTS (Setpoint) : etmvariableload (continu) ET relay-router (rév. 3, combinaison
// de relais dérivée côté routeur). Charge à ÉTATS : sg-ready (PAC). Le scheduler distingue
// Setpoint vs State (nature de l'action), PAS la classe concrète d'adaptateur (frontière rév. 3).
QList<LoadContext> nonEvLoads;
for (const LoadContext &lc : ctx.loads) {
if (lc.adapter == QStringLiteral("etmvariableload") || lc.adapter == QStringLiteral("relay-router")
|| lc.adapter == QStringLiteral("sg-ready"))
nonEvLoads.append(lc);
}
std::sort(nonEvLoads.begin(), nonEvLoads.end(),
[](const LoadContext &a, const LoadContext &b) { return a.priority < b.priority; });
for (const LoadContext &lc : nonEvLoads) {
if (lc.adapter == QStringLiteral("sg-ready"))
slot.actions.append(buildSgReadyStateAction(lc, remainingSurplusW, compta));
else
slot.actions.append(buildSetpointAction(lc, remainingSurplusW, compta)); // etmvariableload + relay-router
}
// Grid funding (ECS/PAC) : dormant jusqu'à 3f (waterfall réseau) — non implémenté ici.
Plan plan;
plan.planId = QUuid::createUuid().toString(QUuid::WithoutBraces);
plan.strategy = QStringLiteral("rule-based");
plan.timeSlots.append(slot);
compta.remainingW = remainingSurplusW;
plan.budget = compta;
return plan;
}
LoadAction RuleBasedScheduler::buildTimeRequirementAction(EvCharger *ev,
const ChargingAction &ca) const
{
// Le courant final est affiné par adjustEvChargers() (allowance root-meter).
// En 3b on log la valeur brute de la planification — iso-fonctionnel.
LoadAction la;
la.loadId = ev->thing()->id().toString();
la.kind = LoadAction::Setpoint;
la.funding = LoadAction::Grid;
la.chargingEnabled = true;
la.currentA = ca.maxChargingCurrent();
la.phaseCount = ca.desiredPhaseCount();
la.reason = { DecisionCode::EvDeadline };
la.estimatedPowerW = la.currentA * 230.0 * la.phaseCount;
return la;
}
LoadAction RuleBasedScheduler::buildMinCurrentAction(EvCharger *ev) const
{
const uint minA = qMax(EcoMinChargingCurrent, ev->maxChargingCurrentMinValue());
const uint phases = ev->phaseCount();
LoadAction la;
la.loadId = ev->thing()->id().toString();
la.kind = LoadAction::Setpoint;
la.funding = LoadAction::Surplus;
la.chargingEnabled = true;
la.currentA = minA;
la.phaseCount = phases;
la.reason = { DecisionCode::EvEcoMin };
la.estimatedPowerW = la.currentA * 230.0 * la.phaseCount;
return la;
}
LoadAction RuleBasedScheduler::buildIdleAction(EvCharger *ev) const
{
LoadAction la;
la.loadId = ev->thing()->id().toString();
la.kind = LoadAction::Setpoint;
la.funding = LoadAction::Surplus;
la.chargingEnabled = false;
la.currentA = 0;
la.phaseCount = 0;
la.reason = { DecisionCode::EvIdle };
la.estimatedPowerW = 0;
return la;
}
LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc,
double &remainingSurplusW,
PlanBudget &budget) const
{
// Recrédit anti-clignotement (Correction B / contrat rév. 2 §5) : la conso de DÉBUT de cycle
// est déjà soustraite de l'export mesuré → on la rend au budget local avant d'arrondir.
// PAS de relecture post-setpoint (invariant 8 : aucune boucle de feedback).
const double budgetW = remainingSurplusW + lc.telemetry.currentPowerW;
budget.recreditedW += lc.telemetry.currentPowerW;
const QList<int> &levels = lc.declared.powerLevels;
const bool dynamic = levels.isEmpty();
double setpointW;
bool sousPlancher = false; //!< Budget > 0 mais sous le premier palier atteignable.
double plancherW = 0; //!< Ce palier, précisément — le motif doit le NOMMER.
if (dynamic) {
// Dynamic (routeur PV / triac) : modulation continue bornée au plafond déclaré.
const double maxW = lc.declared.maxPowerW > 0 ? lc.declared.maxPowerW : budgetW;
setpointW = qBound(0.0, budgetW, maxW);
// §12 / LM-1203 — le domaine n'est pas [0, max] mais {0} ∪ [min, max]. Sous le
// plancher, la charge ne prend RIEN, et le budget PASSE à la suivante.
//
// Ne rien transmettre serait le vrai défaut : une charge de rang 1 au plancher élevé
// retiendrait en otage un budget qu'elle ne peut pas utiliser, et tout le waterfall en
// aval resterait à sec pendant qu'elle-même ne démarre pas. C'est le cas des deux
// bornes — 2 kW chacune sous un plancher de 1,38 kW n'en fait démarrer aucune, quand
// servir la première à fond en fait charger une.
if (lc.declared.minPowerW > 0 && setpointW > 0 && setpointW < lc.declared.minPowerW) {
setpointW = 0;
plancherW = lc.declared.minPowerW;
sousPlancher = true;
}
} else {
// Fixed : plus haut palier déclaré ≤ budget (levels triés croissants, 0 inclus →
// garantit une consigne valide même budget ≤ 0). Granularité connue → zéro cycle de retard.
int chosen = 0;
for (int l : levels) {
if (l <= budgetW) chosen = l;
else break;
}
setpointW = chosen;
// §12 — même distinction qu'en dynamic, et le plancher y est DÉRIVÉ : levels[0] vaut
// toujours 0, donc le premier palier atteignable est levels[1].
//
// Ce qui se joue ici est la différence entre « il n'y a pas assez » et « il y en a,
// mais pas au bon format ». Un budget de 300 W devant un premier palier de 500 W
// n'est pas un manque de surplus : c'est une granularité. Les deux phrases envoient
// l'installateur vers des gestes opposés — attendre le soleil, ou relire son câblage.
const int premier = levels.size() > 1 ? levels.at(1) : 0;
if (premier > 0 && budgetW > 0 && chosen == 0) {
plancherW = premier;
sousPlancher = true;
}
}
// ECS-410 — charge INDISPONIBLE : elle sort de l'ARBITRAGE, pas de la COMPTABILITÉ.
// Aucune allocation neuve, mais sa consommation reste retranchée du budget : une
// puissance qu'on ne sait plus couper est de la conso fixe, au même titre que la base de
// la maison. La retirer du bilan offrirait aux charges suivantes un budget qui n'existe
// pas — c'est le trou d'ECS-306 rouvert par une autre porte.
//
// Le figeage est porté par l'adaptateur : lockMin == lockMax == puissance crue engagée.
// Le clamp ci-dessous suffit donc à produire la bonne comptabilité, sans branche spéciale.
if (!lc.telemetry.available) {
const double gele = lc.telemetry.lockMinPowerW;
LoadAction la;
la.loadId = lc.id;
la.kind = LoadAction::Setpoint;
la.funding = LoadAction::Surplus;
la.powerW = gele;
la.estimatedPowerW = gele;
// Le LIBELLÉ n'entre pas dans les paramètres : c'est de la langue naturelle saisie
// par l'installateur, et la charge utile RPC doit en rester exempte. Il est substitué
// au rendu, par renderFr(reason, label).
la.reason = { DecisionCode::LoadUnavailable, {{"frozenW", qRound(gele)}} };
remainingSurplusW = budgetW - gele;
budget.allocatedW += gele; // une puissance qu'on ne sait plus couper reste allouée
return la;
}
// ECS-306 — clamp lock-aware AVANT de décrémenter le budget (AGENTS règle 4 : les
// bornes de l'adaptateur écrêtent TOUTE sortie de stratégie, et leur résultat doit
// revenir à l'arbitre). Sans ceci, l'adaptateur remonte le palier de son côté et le
// scheduler décrémente une valeur plus basse : pendant toute la fenêtre minOn, les
// charges suivantes reçoivent un résidu surestimé → soutirage réseau.
// ECS-309 — ce que la stratégie voulait, AVANT que les bornes de verrou n'écrêtent.
// Sans cette mémoire, le motif ne peut pas distinguer « le budget ne payait pas » de
// « le verrou a refusé » : les deux finissent à la même consigne.
const double vouluW = setpointW;
if (lc.telemetry.lockMaxPowerW >= 0)
setpointW = qMin(setpointW, lc.telemetry.lockMaxPowerW);
if (setpointW < lc.telemetry.lockMinPowerW)
setpointW = lc.telemetry.lockMinPowerW; // puissance engagée, non-coupable
LoadAction la;
la.loadId = lc.id;
la.kind = LoadAction::Setpoint;
la.funding = LoadAction::Surplus;
la.powerW = setpointW;
la.estimatedPowerW = setpointW;
// ECS-309 — le motif nomme le MÉCANISME qui a produit l'issue, jamais un mécanisme
// plausible. La règle 7 d'AGENTS.md exige un motif non vide ; celle-ci exige qu'il soit
// vrai. Un motif faux envoie diagnostiquer le mauvais problème, avec l'autorité d'une
// explication — constaté au banc le 2026-08-09 : palier 0 avec 7706 W disponibles, et
// « Surplus insuffisant (7706 W) » publié.
// ECS-309-b — le critère est ce que le verrou a FAIT à la consigne voulue, pas une
// comparaison au budget. « setpointW > budgetW » nommait minOn dès que le budget était
// négatif à palier nul : 0 > −389 est vrai, et le motif annonçait « maintenue à 0 W
// (puissance engagée) » alors que rien n'était engagé et que le soutirage était la seule
// cause. Constaté au banc le 2026-08-13, sept fois en quatre heures, toujours au cycle
// suivant un palier 0. Les deux branches se lisent maintenant sur le même axe :
// le verrou a relevé la consigne (minOn), ou il l'a rabaissée (minOff).
if (setpointW > vouluW)
la.reason = { DecisionCode::LockMinOn,
{{"appliedW", qRound(setpointW)}, {"budgetW", qRound(budgetW)}} };
else if (setpointW < vouluW)
// Le budget payait vouluW ; c'est la fenêtre minOff qui interdit de monter. Le motif
// cite le budget DISPONIBLE, précisément pour couper court à l'explication budgétaire.
la.reason = setpointW <= 0
? DecisionReason(DecisionCode::LockMinOff,
{{"budgetW", qRound(budgetW)}, {"requestedW", qRound(vouluW)}})
: DecisionReason(DecisionCode::LockMinOffCapped,
{{"appliedW", qRound(setpointW)}, {"budgetW", qRound(budgetW)},
{"requestedW", qRound(vouluW)}});
else if (setpointW > 0)
// « stepped » remplace le suffixe « (palier) » composé en dur : le client décide
// comment il le dit, la box dit seulement que la consigne est arrondie sur un palier.
la.reason = { DecisionCode::SurplusSetpoint,
{{"budgetW", qRound(budgetW)}, {"setpointW", qRound(setpointW)},
{"stepped", !dynamic}} };
else if (sousPlancher)
// §12 — dire QUE le budget est sous le plancher, pas qu'il est insuffisant : la
// seconde phrase enverrait chercher du soleil là où il faut lire une fiche technique.
la.reason = { DecisionCode::BelowMinPower,
{{"budgetW", qRound(budgetW)}, {"minPowerW", qRound(plancherW)}} };
else
la.reason = { DecisionCode::SurplusInsufficient, {{"budgetW", qRound(budgetW)}} };
// Résidu : budget − consigne engagée → charge suivante de la priorité (même cycle).
remainingSurplusW = budgetW - setpointW;
budget.allocatedW += setpointW;
return la;
}
LoadAction RuleBasedScheduler::buildSgReadyStateAction(const LoadContext &lc,
double &remainingSurplusW,
PlanBudget &budget) 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;
budget.recreditedW += 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 = { DecisionCode::LockMinStateHold, {{"state", bestState}} };
else if (bestState == 4)
la.reason = { DecisionCode::SgForced,
{{"budgetW", qRound(budgetW)}, {"estimatedW", qRound(p4)}} };
else if (bestState == 3)
la.reason = { DecisionCode::SgRecommended,
{{"budgetW", qRound(budgetW)}, {"estimatedW", qRound(p3)}} };
else if (budgetW > 0 && p3 > 0)
// §12 — il y a du budget, mais aucun état ne sait le prendre : une PAC SG-Ready n'a
// pas d'« état 3 à moitié ». Le motif NOMME l'état hors d'atteinte et sa puissance
// estimée — dire « minPowerW » sans dire lequel remplacerait une phrase vague par
// une autre.
la.reason = { DecisionCode::BelowMinPower,
{{"budgetW", qRound(budgetW)}, {"minPowerW", qRound(p3)},
{"state", 3}} };
else
// Budget nul ou négatif : il n'y a réellement RIEN à allouer. « Surplus insuffisant »
// est alors la vérité, et c'est le seul cas où elle l'est.
la.reason = { DecisionCode::SgNormal, {{"budgetW", qRound(budgetW)}} };
// Budget restant : on ne soustrait que la puissance ALLOUÉE (états 1/2 = 0).
remainingSurplusW = budgetW - la.estimatedPowerW;
budget.allocatedW += la.estimatedPowerW;
return la;
}