Patrick Schurig e65dbb4a6a feat(§10): « Surplus + minimum réseau » — le mode sert enfin un minimum
LM-1013-b-vi. Le mode ChargingModeEcoWithMinCurrent promettait un courant minimum
depuis toujours sans qu'aucun chemin ne le serve : mort par réécriture chez
l'amont, puis mort par exclusion quand le waterfall a pris la borne.

LA LOI. computeEcoTarget() rend max(cadence de l'obligation, minimum). Le corps
historique est isolé dans calculerObligation() et une sortie anticipée rend
EXACTEMENT l'ancien résultat quand aucun minimum n'est demandé — ce n'est pas une
optimisation, c'est la garantie que l'ajout ne déplace rien pour les charges qui
n'en veulent pas. Les quatre suites le confirment.

LE MINIMUM NE SE TIENT JAMAIS. `tenue` ne décrit que l'obligation en énergie ;
quand elle est tenue, la cadence vaut 0 et la consigne retombe SUR LE MINIMUM, pas
sur zéro. C'est là, et seulement là, que la différence de nature entre une énergie
avec échéance et une puissance permanente se voit dans le code. L'ordonnanceur ne
publie donc ECO_FLOOR_MET que si watts vaut aussi 0 : sinon il couperait ce qu'il
doit continuer de servir.

DÉRIVÉ, JAMAIS STOCKÉ. EvAdapter calcule le minimum depuis evFloorW() à chaque
cycle quand le mode le demande. L'écrire en configuration à l'appairage créerait
une copie qui se périme au premier recâblage — phaseCount change, la copie reste
fausse en silence. Correction apportée par le brief app f2bd07d, et elle est
juste.

LE MOTIF DIT LEQUEL DES DEUX MORD. ECO_FLOOR_GRID gagne minAlwaysW et boundBy, et
OMET remainingWh/targetWh/remainingS/regime sur une charge à minimum seul : un
remainingS à 0 inventerait une échéance imminente et un remainingWh à 0 une
obligation tenue. progress est absent pour la même raison — il ne décrit que
l'obligation.

CONTRAT. Les trois descriptions de ChargingMode sont corrigées. Normal n'est pas
« recharge immédiate au maximum » : c'est MANUEL, le moteur ne commande rien, et
seule la protection de surcharge la touche encore — ce qui doit être dit, un mode
qui promet l'abstention et coupe quand même étant une surprise. Et les mots
« garanti » et « prioritaire » sont bannis à propos de ce mode : le premier promet
une certitude que rien ne tient, le second est vrai face au confort et faux face à
un autre besoin éco mieux rangé, donc faux exactement dans le cas d'échec.

TEST vu mordre : désarmé, minSeul.watts rend 0 au lieu de 4 140. Cinq points, dont
la contre-épreuve qui distingue « 4 140 vient du minimum » de « 4 140 vient d'un
reliquat d'obligation », et le cas 1 qui vérifie que rien ne bouge sans minimum.

Suites : loadmodel 26/26 (+1), simulation 67/67, charging 48/48, spotmarket 32/32.
NON DÉPLOYÉ — +etm57 attend le feu vert.
2026-09-07 11:20:08 +02:00

930 lines
54 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 <QSet>
#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();
// 3g-1 — bornes déjà servies par un financement RÉSEAU (échéance, spot market). Elles ne
// doivent pas recevoir en plus une allocation de surplus : la précédence du proxy est
// conservée, et c'est ce jeu qui la porte jusqu'au waterfall.
QSet<QString> gridFundedEvs;
// 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::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;
la.counts[CountKey::DrawCommitted] = la.estimatedPowerW; // R3 — acheté
} else {
// 3g-1 — LE SURPLUS N'EST PLUS DÉCIDÉ ICI. La borne descend dans le waterfall, où
// elle partage le budget avec l'ECS et la PAC, décrémente la cascade et porte un
// vrai rang. Les trois branches supprimées produisaient toutes des actions
// financées au surplus :
//
// EV_SURPLUS → remplacé par SURPLUS_SETPOINT du waterfall
// buildMinCurrent → remplacé par le PLANCHER (§12), qui est la même idée
// exprimée une seule fois pour tous les mécanismes
// buildIdle → remplacé par BELOW_MIN_POWER / SURPLUS_INSUFFICIENT, qui
// DISENT pourquoi la borne ne charge pas
//
// Rien n'est émis dans ce cas : l'absence d'action ici est ce qui laisse la place
// à celle du waterfall. En émettre une produirait deux actions pour la même borne.
continue;
}
// Seules les actions financées au RÉSEAU sortent encore d'ici — échéance et spot
// market, qui restent au proxy (DESIGN_3g §3.1).
gridFundedEvs.insert(la.loadId);
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;
double drawCommittedW = 0; // R3 — watts ACHETÉS ce cycle
for (const LoadAction &la : slot.actions) {
if (la.kind != LoadAction::Setpoint || !la.chargingEnabled)
continue;
// 3g-1 — RESTREINT au financement réseau. Une borne servie par la cascade décrémente
// déjà le budget par son allocation ; la réserver EN PLUS la compterait deux fois.
// La correction A ne disparaît pas, elle se limite à ce qui reste décidé en amont.
if (la.funding != LoadAction::Grid)
continue;
EvCharger *ev = evs.value(ThingId(la.loadId));
const double measuredW = ev ? ev->currentPower() : 0.0;
evReservedW += qMax(0.0, la.estimatedPowerW - measuredW);
// R3 — LE REGISTRE DES WATTS ACHETÉS. Distinct de la correction ci-dessus, et la
// distinction est la découverte du lot : evReservedW décroît à mesure que la mesure
// rattrape la commande, alors que ce qui a été ACHETÉ ne bouge pas. Confondre les deux
// donnerait une identité vraie au premier cycle et fausse après convergence — qui
// passerait tous les tests, puisqu'ils portent sur des cycles isolés.
//
// Toute l'allocation du proxy y entre : l'échéance décide de charger qu'il y ait du
// soleil ou non, et le moteur ne calcule aucun partage pour elle. Là où il SAIT
// partager, il partage — voir EV_GRID_START.
drawCommittedW += la.estimatedPowerW;
}
// 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.
// Retenu pour le MOTIF : sans lui, une installation qui exporte en plein soleil
// n'enclencherait rien en publiant « surplus insuffisant », et l'installateur chercherait
// une panne qui n'existe pas.
m_reserveActive = false;
m_reserveSocPct = 0;
m_reserveSeuilPct = 0;
m_reserveWithheldW = 0;
if (ctx.battery.present) {
const double seuil = m_arbitrator->batteryLevelConsideration() * 100.0;
if (ctx.battery.socPercent < seuil) {
m_reserveActive = true;
m_reserveSocPct = ctx.battery.socPercent;
m_reserveSeuilPct = seuil;
m_reserveWithheldW = qMax(0.0, remainingSurplusW);
// 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;
PlanDraw draw; // registre des watts achetés — R3
draw.committedW = drawCommittedW; // ce que le proxy a déjà engagé au réseau
// §10 étape B — l'AUTORISATION du cycle, prise là où elle est résolue, et publiée telle
// quelle. Elle n'existe que si un plafond existe : sans lui, les quatre champs restent
// absents plutôt que nuls (LM-1013).
draw.capBorne = ctx.drawMargin.borne;
if (draw.capBorne) {
draw.authorisedW = ctx.drawMargin.watts;
draw.bindingCode = drawCapSourceCode(ctx.drawMargin.source);
draw.perPhaseBound = ctx.drawMargin.phase >= 0;
if (draw.perPhaseBound && ctx.drawMargin.phase <= 2)
draw.bindingPhase = QString(QChar('A' + ctx.drawMargin.phase));
}
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 → decideSetpoint/emitSetpointAction, 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).
// 3g-1 — le waterfall n'est plus « non-EV » : les bornes y sont, et c'est toute la
// transplantation. Elles partagent le budget, décrémentent la cascade et portent un rang
// qui a enfin un sens. Le nom de la variable est conservé pour limiter le diff ; sa
// sémantique est désormais « charges arbitrées par le waterfall ».
QList<LoadContext> nonEvLoads;
for (const LoadContext &lc : ctx.loads) {
const bool arbitree = lc.adapter == QStringLiteral("etmvariableload")
|| lc.adapter == QStringLiteral("relay-router")
|| lc.adapter == QStringLiteral("sg-ready")
|| lc.adapter == QStringLiteral("evcharger");
if (!arbitree)
continue;
// Déjà servie au réseau ce cycle : lui allouer du surplus en plus reviendrait à la
// commander deux fois, avec deux consignes différentes.
if (gridFundedEvs.contains(lc.id))
continue;
nonEvLoads.append(lc);
}
// 3g-2 — tri TOTAL et STABLE. Le rang seul ne suffit pas : deux charges de même rang
// laissaient l'ordre à std::sort, qui n'est pas stable, sur une liste dont l'origine est
// un QHash — donc sans ordre garanti d'un cycle à l'autre. Au banc, deux bornes à
// priority = 100 : la cascade était correcte, mais LAQUELLE démarrait n'était ni réglable
// ni reproductible (relevé du 2026-08-26, §3.2). L'identifiant départage : il est stable
// par construction, et l'égalité parfaite de deux charges n'existe pas.
std::sort(nonEvLoads.begin(), nonEvLoads.end(),
[](const LoadContext &a, const LoadContext &b) {
if (a.priority != b.priority)
return a.priority < b.priority;
return a.id < b.id;
});
QSet<QString> waterfallEvIds;
// Étape 3 — l'autorisation de SOUTIRAGE, consommée le long de la cascade au même titre que
// le surplus. C'est une marge de CYCLE : elle est prise une fois dans le contexte, jamais
// relue, et chaque charge servie l'entame de ce qu'elle a été autorisée à prendre.
DrawMargin margeRestante = ctx.drawMargin;
// ================= PASSE 1 — ÉCO ==========================================================
//
// MÊME liste, MÊME ordre. Ce qui change entre les deux passes n'est pas le tri, c'est la
// BORNE : la passe éco est bornée par l'autorisation de SOUTIRAGE (on achète), la passe
// confort par le reliquat de SURPLUS (on dépense ce qui est déjà là).
//
// L'ordre lexicographique sur (niveau, rang) en découle : le rang classe les CHARGES, la
// hiérarchie éco/confort classe les BESOINS et surplombe le rang. Un confort de rang 1 passe
// donc après un éco de rang 3 — c'est le sens même des deux passes, et le publier sous forme
// de niveaux est ce qui rend cet ordre lisible sans qu'un client ait à le redériver.
QHash<QString, LoadAction::Level> niveauxEco;
QSet<QString> recreditAttribue;
for (const LoadContext &lc : nonEvLoads) {
const EcoProgress avancement = m_arbitrator->ecoProgress(lc, ctx.timestamp);
const EcoTarget cible = computeEcoTarget(lc.needs, avancement,
lc.declared.maxPowerW, ctx.timestamp);
if (!cible.applicable)
continue; // pas d'obligation : cette charge ne participe qu'au confort (R1)
LoadAction::Level eco;
eco.level = QStringLiteral("eco");
// `progress` ne décrit QUE l'obligation en énergie. Sur une charge à minimum seul il est
// ABSENT, jamais à zéro : un avancement nul se lirait « rien livré » là où il n'y a rien
// à avancer. Même règle que `progressUnmeasurableCause` — l'absence est l'information.
eco.hasProgress = lc.needs.minEnergyWhPerDay > 0;
eco.progress = avancement;
// CLÔTURE NON TENUE — l'événement du cycle, et il prime sur ce que la période neuve
// demande. Ce cycle-ci dit ce qui vient de se perdre ; le suivant repart sur la période
// ouverte. Le taire laisserait l'écran muet sur le seul instant où l'information existe.
if (avancement.closedMissed) {
QVariantMap params{{"targetWh", qRound(avancement.targetWh)},
{"cause", avancement.closedCause}};
// `missingWh` ABSENT quand le manque n'est pas chiffrable, jamais 0 : un zéro se
// lirait « il ne manquait rien », là où la vérité est « on n'en sait rien ».
if (avancement.closedKnown)
params.insert(QStringLiteral("missingWh"), qRound(avancement.closedMissingWh));
eco.targetW = 0;
eco.counts[CountKey::BudgetAllocated] = 0;
eco.reason = { DecisionCode::EcoFloorMissed, params };
niveauxEco.insert(lc.id, eco);
continue;
}
// La charge est-elle seulement commandable ? Une indisponibilité empêche de servir
// l'obligation, et c'est une cause à part — on ne sait plus la commander (ECS-410).
if (!lc.telemetry.available) {
m_arbitrator->noterCauseEco(lc.id, QString::fromLatin1(MissedCause::Unavailable));
continue;
}
// `tenue` décrit l'obligation en ÉNERGIE, jamais le minimum — celui-ci ne se complète
// pas. Quand l'obligation est tenue ET qu'un minimum reste demandé, la cadence vaut 0
// et `computeEcoTarget()` a fait retomber la consigne sur le minimum : il n'y a donc
// rien à clore, et publier ECO_FLOOR_MET couperait ce qu'on doit continuer de servir.
if (cible.tenue && cible.watts <= 0) {
// R6 — `ECO_FLOOR_MET` ne peut PAS sortir sous un régime non mesuré, et
// `computeEcoTarget()` ne conclut jamais « tenue » dans ce cas : la garde est ici
// ET là-bas, parce qu'un invariant qui ne tient qu'à un seul endroit tient mal.
Q_ASSERT(avancement.delivered);
eco.targetW = 0;
eco.counts[CountKey::BudgetAllocated] = 0;
eco.reason = { DecisionCode::EcoFloorMet,
{{"deliveredWh", qRound(avancement.deliveredWh)},
{"targetWh", qRound(avancement.targetWh)}} };
niveauxEco.insert(lc.id, eco);
continue;
}
// BORNÉE PAR L'AUTORISATION DE SOUTIRAGE. Le plancher éco est une raison d'acheter, pas
// un droit de faire disjoncter : si l'autorisation est épuisée, on n'achète pas, et
// c'est le délestage qui rattrapera ce qui a déjà été engagé.
double achatW = cible.watts;
const bool bridePlafond = margeRestante.borne && achatW > qMax(0.0, margeRestante.watts);
if (margeRestante.borne)
achatW = qBound(0.0, achatW, qMax(0.0, margeRestante.watts));
// La CAUSE se note à chaque cycle, servie ou non. À la clôture, c'est la dernière qui
// est publiée : celle qui bloquait au dernier moment où l'on aurait encore pu servir.
if (bridePlafond)
m_arbitrator->noterCauseEco(lc.id, QString::fromLatin1(MissedCause::DrawCap));
else if (lc.declared.maxPowerW > 0 && cible.watts >= lc.declared.maxPowerW)
// La cadence demandée atteint le plafond PHYSIQUE de la charge : ce n'est plus nous
// qui bridons, c'est elle qui ne peut pas suivre. Deux gestes différents.
m_arbitrator->noterCauseEco(lc.id, QString::fromLatin1(MissedCause::LoadCapacity));
else
m_arbitrator->noterCauseEco(lc.id, QString()); // servie sans blocage
// L'ACHAT est noté à chaque cycle, y compris nul — c'est le passage du temps qui
// intègre l'énergie, et sauter les cycles sans achat ferait compter la durée d'un arrêt
// comme si l'on avait acheté pendant.
m_arbitrator->noterAchatEco(lc.id, achatW, ctx.timestamp);
if (achatW <= 0)
continue; // rien d'achetable ce cycle : pas de niveau éco plutôt qu'un zéro forgé
eco.targetW = achatW;
eco.funding = LoadAction::Grid;
eco.counts[CountKey::DrawCommitted] = achatW;
QVariantMap motif{{"gridW", qRound(achatW)},
{"atMaximum", cible.pointDeNonRetour}};
// Les grandeurs de l'OBLIGATION n'ont de sens que s'il y en a une. Sur une charge à
// minimum seul, publier `remainingWh: 0` et `remainingS: 0` inventerait une échéance
// imminente et une obligation tenue — deux affirmations fausses au lieu d'un silence.
if (lc.needs.minEnergyWhPerDay > 0) {
motif.insert(QStringLiteral("remainingWh"), qRound(cible.remainingWh));
motif.insert(QStringLiteral("targetWh"), qRound(avancement.targetWh));
motif.insert(QStringLiteral("remainingS"), static_cast<int>(cible.remainingS));
motif.insert(QStringLiteral("regime"), avancement.regime);
}
// LE MINIMUM, et LEQUEL DES DEUX MORD. Les deux produisent le même nombre sans dire qui
// a décidé ; un écran qui l'ignore ne peut pas expliquer la facture. `boundBy` est
// publié plutôt que déduit d'une comparaison que le client referait de travers.
if (cible.minimumW > 0) {
motif.insert(QStringLiteral("minAlwaysW"), qRound(cible.minimumW));
motif.insert(QStringLiteral("boundBy"),
cible.borneParLeMinimum ? QStringLiteral("minimum")
: QStringLiteral("obligation"));
}
eco.reason = { DecisionCode::EcoFloorGrid, motif };
niveauxEco.insert(lc.id, eco);
// LM-1004 — le recrédit est attribué à la PREMIÈRE passe où la charge apparaît, et le
// champ le rend vérifiable plutôt que discipliné : la discipline ne se teste pas.
recreditAttribue.insert(lc.id);
draw.committedW += achatW;
if (margeRestante.borne)
margeRestante.watts -= achatW;
}
// ================= PASSE 2 — CONFORT ======================================================
for (const LoadContext &lc : nonEvLoads) {
if (lc.adapter == QStringLiteral("sg-ready"))
slot.actions.append(buildSgReadyStateAction(lc, remainingSurplusW, compta));
else
slot.actions.append(emitSetpointAction(
decideSetpoint(lc, remainingSurplusW, margeRestante, compta, draw,
recreditAttribue.contains(lc.id)))); // etmvariableload + relay-router + evcharger
// Le décrément vaut pour TOUTES les charges servies, bornes et PAC comprises : elles
// consomment la même autorisation, qu'elles aient été bornées par elle ou non. Ne
// décompter que les charges qu'on borne offrirait aux suivantes une marge déjà dépensée.
// LM-1003 — UNE SEULE COMMANDE PAR CHARGE ET PAR CYCLE. Le niveau éco ne commande pas :
// il est fusionné dans l'action que la passe confort vient de produire. Émettre deux
// fois ferait commuter un relais en passe 1 puis le recommuter en passe 2 — du
// court-cycling fabriqué par l'arbitre lui-même.
if (!slot.actions.isEmpty() && niveauxEco.contains(lc.id)) {
LoadAction &la = slot.actions.last();
const LoadAction::Level eco = niveauxEco.take(lc.id);
la.levels.prepend(eco); // éco AVANT confort : l'ordre des passes
la.powerW += eco.targetW;
la.estimatedPowerW += eco.targetW;
for (auto it = eco.counts.constBegin(); it != eco.counts.constEnd(); ++it)
la.counts[it.key()] += it.value();
if (eco.targetW > 0)
la.chargingEnabled = true;
}
if (margeRestante.borne && !slot.actions.isEmpty())
margeRestante.watts -= slot.actions.last().estimatedPowerW;
// 3g-2 — c'est ICI que le jeu se constitue, au moment où la décision est prise. Le
// redéduire plus tard depuis les motifs ou le financement dériverait du vrai au
// premier motif ajouté (EV_GRID_START porte funding=Grid tout en venant du waterfall).
if (lc.adapter == QStringLiteral("evcharger"))
waterfallEvIds.insert(lc.id);
}
// 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;
// R3 — le soutirage est publié DANS LA MÊME TRAME que le budget : les deux décrivent le
// même cycle, et les lire à deux instants ferait comparer deux mondes.
// L'identité se ferme ICI, au même endroit que celle du budget, et sur le même cycle.
draw.remainingW = draw.authorisedW - draw.committedW;
plan.draw = draw;
plan.waterfallEvIds = waterfallEvIds;
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;
la.counts[CountKey::DrawCommitted] = la.estimatedPowerW; // R3 — acheté (échéance)
poserNiveauConfort(la);
return la;
}
DecisionReason RuleBasedScheduler::choisirMotif(const MotifEntrees &e) const
{
// 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é.
//
// §10 / étape A — extraite en FONCTION PURE, et c'est le préalable au reste. Chaque passe
// devra choisir SON motif sur SES grandeurs (R7 : le motif dit de quel niveau il parle) ;
// tant que la chaîne vivait au milieu de l'émission, il n'y avait qu'un motif possible
// parce qu'il n'y avait qu'un endroit pour le former. L'ordre des branches est conservé
// au branchement près : c'est lui qui porte les précédences (LM-1011 notamment).
if (e.setpointW > e.vouluW)
return { DecisionCode::LockMinOn,
{{"appliedW", qRound(e.setpointW)}, {"budgetW", qRound(e.budgetW)}} };
else if (e.setpointW < e.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.
return e.setpointW <= 0
? DecisionReason(DecisionCode::LockMinOff,
{{"budgetW", qRound(e.budgetW)}, {"requestedW", qRound(e.vouluW)}})
: DecisionReason(DecisionCode::LockMinOffCapped,
{{"appliedW", qRound(e.setpointW)}, {"budgetW", qRound(e.budgetW)},
{"requestedW", qRound(e.vouluW)}});
else if (e.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.
return { DecisionCode::SurplusSetpoint,
{{"budgetW", qRound(e.budgetW)}, {"setpointW", qRound(e.setpointW)},
{"stepped", !e.dynamic}} };
else if (m_reserveActive && (m_reserveWithheldW + e.consoW) > 0)
// 3g-1 — il Y A du surplus, et une règle l'interdit. À ne pas confondre avec
// « il n'y en a pas » : le geste attendu n'est pas le même, et le second envoie
// chercher une panne inexistante.
//
// 3g-2 — ce que la réserve retient à CETTE charge a deux termes, et il faut les deux :
// le surplus annulé (le même pour toutes), ET sa propre consommation, que le recrédit
// lui aurait rendue. Sans le second, une charge en marche coupée par la réserve sur un
// surplus nul publiait « surplus insuffisant » — vrai sur le surplus, faux sur la
// cause : sans la réserve, elle aurait continué de tourner.
return { DecisionCode::BatteryReserve,
{{"socPercent", qRound(m_reserveSocPct)},
{"reservePercent", qRound(m_reserveSeuilPct)},
{"withheldW", qRound(m_reserveWithheldW + e.consoW)}} };
else if (e.capEpuise) {
// L'autorisation de soutirage est épuisée. Le motif porte la marge, la source,
// l'ampleur du dépassement et la phase qui borne — chacune commande un geste
// différent, et aucune ne se déduit des autres.
// shed = FAUX : ici la cascade REFUSE d'allouer, elle ne retire rien à personne. Le
// même code de motif porte les deux gestes, et le booléen les distingue — le déduire
// de la présence de `shedW` marcherait aujourd'hui et casserait à la première clé
// ajoutée.
return { DecisionCode::DrawCap,
drawCapReasonParams(e.marge, false, 0, false) };
}
else if (e.refuse && e.retenuW > 0)
// Le refus explique l'issue MIEUX que le budget : sans lui, la charge aurait eu sa
// conso recréditée et se serait probablement servie. Accuser le budget enverrait
// chercher du soleil là où une charge n'obéit pas.
return { DecisionCode::RecreditSuspended,
{{"refusedSinceS", e.refuseDepuisS}, {"withheldW", qRound(e.retenuW)}} };
else if (e.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.
return { DecisionCode::BelowMinPower,
{{"budgetW", qRound(e.budgetW)}, {"minPowerW", qRound(e.plancherW)}} };
else
return { DecisionCode::SurplusInsufficient, {{"budgetW", qRound(e.budgetW)}} };
}
RuleBasedScheduler::SetpointTarget RuleBasedScheduler::decideSetpoint(const LoadContext &lc,
double &remainingSurplusW,
DrawMargin &margeRestante,
PlanBudget &budget, PlanDraw &draw,
bool recreditDejaAttribue) 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).
//
// 3g-2 — SAUF sous la réserve de stockage, et c'est la seule exception. Le recrédit
// suppose que le budget est une MESURE dont la conso de la charge a déjà été retranchée ;
// sous la réserve, le budget n'est pas une mesure mais une DÉCISION de retenir
// (LM-1203-b : « le budget est annulé, pas réduit »). Recréditer dans un budget décidé nul
// rend exactement ce qu'on venait de confisquer : toute charge déjà en marche le restait,
// la réserve ne mordait que sur les augmentations, et le motif publié — « aucune charge
// n'est servie » — était faux. Attrapé par batteryLevelConsiderationStopCharging au moment
// où les bornes ont commencé à exécuter réellement les décisions du waterfall.
// LM-1004 — le recrédit est attribué à la PREMIÈRE passe où la charge apparaît, et une
// seule fois. Le recréditer aussi ici offrirait deux fois la même puissance : une charge
// servie en éco verrait sa consommation rendue au budget de surplus qu'elle ne finance pas.
// TROISIÈME exception, et elle protège d'une prémisse fausse. Le recrédit rend au budget la
// conso d'une charge SUR L'HYPOTHÈSE qu'on pourra la lui reprendre. Contre un refus établi
// — écart au-delà de la latence du mécanisme — c'est un prêt qu'on n'est pas sûr de se voir
// rembourser, et les watts prêtés sont réalloués aux suivantes pendant que la charge
// continue de les tirer. L'identité comptable TIENT malgré tout : aucun contrôle de
// cohérence ne peut l'attraper, c'est l'hypothèse qui est fausse, pas l'arithmétique.
const bool refuse = lc.telemetry.commandRefused;
const double recreditW = (m_reserveActive || recreditDejaAttribue || refuse)
? 0.0 : lc.telemetry.currentPowerW;
const double budgetW = remainingSurplusW + recreditW;
budget.recreditedW += recreditW;
const QList<int> &levels = lc.declared.powerLevels;
const bool dynamic = levels.isEmpty();
//! 3g-2 — une borne se distingue ici par une seule chose : elle peut DÉMARRER en soutirant
//! (acquisitionTolerance). Tout le reste du waterfall la traite comme n'importe quelle
//! charge continue, et c'est le sens de la transplantation.
const bool estUneBorne = lc.adapter == QStringLiteral("evcharger");
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;
// 3g-2 — le PLAFOND est passé SOUS le plancher. Pour une charge de configuration
// c'est impossible (isValid() le refuse) ; pour une borne, le plafond du cycle est
// écrêté par la protection de surcharge, et il peut donc descendre sous ce que la
// borne exige. Aucune allocation ne la démarrera, et le dire « budget sous le
// plancher » enverrait chercher du soleil là où il faut regarder l'abonnement
// (règle 7-b). Le budget n'est pas consommé : il passe entier à la suivante.
if (lc.declared.minPowerW > 0 && maxW < lc.declared.minPowerW) {
SetpointTarget t;
LoadAction &la = t.action; // alias : les affectations ci-dessous ne bougent pas
la.loadId = lc.id;
la.kind = LoadAction::Setpoint;
la.funding = LoadAction::Surplus;
la.chargingEnabled = false;
la.powerW = 0;
la.estimatedPowerW = 0;
// R3 — zéro AVEC sa destination : la grandeur existe et vaut 0. Un counts vide
// dirait qu'elle n'existe pas, ce qui est une autre affirmation.
la.counts[CountKey::BudgetAllocated] = 0.0;
la.reason = { DecisionCode::PhaseLimit,
{{"limitW", qRound(maxW)}, {"requiredW", qRound(lc.declared.minPowerW)}} };
remainingSurplusW = budgetW; // rien d'engagé : le recrédit repart intact
return t;
}
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;
// 3g-2 — DÉMARRAGE SOUS TOLÉRANCE, et c'est une décision d'ACHETER.
//
// `acquisitionTolerance` est la part du plancher qu'on accepte de ne pas couvrir
// par le surplus pour démarrer quand même : à 0,5, une borne qui exige 4 140 W
// démarre dès 2 070 W de surplus et SOUTIRE les 2 070 restants. Le comportement
// existait déjà chez le proxy amont — il y était invisible, confondu avec le
// plancher dans evFloorW(). On ne l'introduit pas, on le rend lisible et chiffré.
//
// Brèche assumée dans DESIGN_3g §3.1, qui défère le financement réseau à 3f :
// c'est un cas particulier BORNÉ, et il l'est par construction —
// · il exige du surplus réel (budgetW > 0), donc jamais un démarrage à sec ;
// · il consomme TOUT le budget restant, donc au plus UNE borne par cycle : la
// suivante trouve un budget nul et repart sur son motif ordinaire ;
// · il reste sous le plafond de la protection de surcharge, contrôlé plus haut.
const double tolerance = m_arbitrator->acquisitionTolerance();
if (estUneBorne && tolerance > 0 && budgetW > 0 && budgetW >= plancherW * tolerance) {
const double soutireW = plancherW - budgetW;
SetpointTarget t;
LoadAction &la = t.action; // alias : les affectations ci-dessous ne bougent pas
la.loadId = lc.id;
la.kind = LoadAction::Setpoint;
// Le financement dit la VÉRITÉ sur l'origine : une partie de cette puissance
// vient du réseau. La comptabilité qui suit la partage en conséquence.
la.funding = LoadAction::Grid;
la.chargingEnabled = true;
la.phaseCount = static_cast<uint>(qMax(1, lc.declared.phases));
la.powerW = plancherW;
la.estimatedPowerW = plancherW;
// R3 — LE PARTAGE, publié plutôt que déduit du motif : la part de surplus dans
// le registre d'allocation, la part achetée dans celui du soutirage.
la.counts[CountKey::BudgetAllocated] = budgetW;
la.counts[CountKey::DrawCommitted] = soutireW;
la.reason = { DecisionCode::EvGridStart,
{{"budgetW", qRound(budgetW)}, {"floorW", qRound(plancherW)},
{"gridW", qRound(soutireW)}} };
// Le surplus est ENTIÈREMENT dépensé — pas au-delà : le complément vient du
// réseau et se compte là où vivent déjà les puissances commandées non encore
// mesurées. Laisser le budget filer en négatif ferait porter le soutirage par
// la cascade, où il se lirait comme un manque de surplus chez les suivantes.
// R3 — le moteur SAIT partager ici, donc il partage : la part surplus entre
// dans le registre d'allocation, la part achetée dans le registre de soutirage.
// Elle allait dans evReservedW, qui est une CORRECTION : une tranche
// d'allocation y rendait toute définition du champ fausse pour l'un des deux
// termes, et interdisait de le nommer honnêtement à l'écran.
budget.allocatedW += budgetW;
draw.committedW += soutireW;
remainingSurplusW = 0;
return t;
}
}
} 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;
}
}
// --- Étape 3 : la cascade ne CRÉE pas le dépassement ----------------------------------
//
// Le surplus dit ce qu'on peut dépenser sans acheter ; l'autorisation de soutirage dit ce
// que le branchement supporte. Ce sont DEUX ressources, et une charge doit tenir dans les
// deux. Jusqu'ici seules les bornes étaient bornées par la seconde — d'où des charges de
// waterfall qui pouvaient créer une surcharge que seule la protection L4 rattrapait, après
// coup.
//
// Les BORNES en sont exclues, et ce n'est pas un oubli : leur plafond de cycle est déjà
// écrêté par `evPhaseAllowanceW()`, qui connaît LES PHASES QU'ELLES OCCUPENT. Leur
// appliquer en plus la marge de la maison — la pire phase — les priverait d'une phase
// saine parce qu'une autre sature. Mieux vaut la borne mieux informée que la borne la
// plus sévère.
bool capEpuise = false;
if (!estUneBorne && margeRestante.borne && setpointW > 0) {
if (margeRestante.watts <= 0) {
// \rule{LM-1011} — autorisation résiduelle NULLE : c'est DRAW_CAP, et rien d'autre.
setpointW = 0;
capEpuise = true;
sousPlancher = false; // ce n'est pas une affaire de format, il ne reste RIEN
} else if (setpointW > margeRestante.watts) {
setpointW = margeRestante.watts;
// Fixed : retomber sur un palier ATTEIGNABLE. Écrêter à la marge donnerait une
// consigne que le mécanisme ne sait pas tenir, et il prendrait le palier au-dessus
// — c'est-à-dire exactement le dépassement qu'on vient de refuser.
if (!dynamic) {
int retenu = 0;
for (int l : levels) {
if (l <= setpointW) retenu = l;
else break;
}
setpointW = retenu;
} else if (lc.declared.minPowerW > 0 && setpointW < lc.declared.minPowerW) {
setpointW = 0;
}
// \rule{LM-1011} — il RESTE de l'autorisation, simplement pas au bon format. Le
// motif est donc BELOW_MIN_POWER, jamais DRAW_CAP : dire « épuisée » enverrait
// revoir l'abonnement là où il faut lire une fiche technique.
if (setpointW <= 0) {
sousPlancher = true;
plancherW = dynamic ? lc.declared.minPowerW
: (levels.size() > 1 ? levels.at(1) : 0);
}
}
}
// 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;
SetpointTarget t;
LoadAction &la = t.action; // alias : les affectations ci-dessous ne bougent pas
la.loadId = lc.id;
la.kind = LoadAction::Setpoint;
la.funding = LoadAction::Surplus;
la.powerW = gele;
la.estimatedPowerW = gele;
la.counts[CountKey::BudgetAllocated] = gele; // R3 — gelée, mais toujours allouée
// 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 t;
}
// 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
SetpointTarget t;
LoadAction &la = t.action; // alias : les affectations ci-dessous ne bougent pas
la.loadId = lc.id;
la.kind = LoadAction::Setpoint;
la.funding = LoadAction::Surplus;
la.powerW = setpointW;
la.estimatedPowerW = setpointW;
la.counts[CountKey::BudgetAllocated] = setpointW; // R3
// 3g-2 — une borne lit `chargingEnabled` avant tout le reste (EvAdapter::applyAction) ;
// laissé à sa valeur par défaut, il faisait refuser TOUTE consigne issue du waterfall,
// consigne non nulle comprise. Sans objet pour les autres mécanismes, qui l'ignorent.
la.chargingEnabled = setpointW > 0;
la.phaseCount = static_cast<uint>(qMax(0, lc.declared.phases));
// 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).
// §10 / étape A2 — la DÉCISION s'arrête ici. Le motif se choisit à l'émission, sur ces
// entrées-là : c'est ce qui permettra à deux passes de contribuer à UNE commande, chacune
// avec son propre motif (R7). Rien d'autre ne bouge — et surtout pas l'ordre au-dessus.
t.motifAChoisir = true;
t.entrees = { setpointW, vouluW, budgetW, plancherW,
lc.telemetry.currentPowerW, margeRestante,
sousPlancher, capEpuise, dynamic };
// APRÈS l'initialisation agrégée, qui écraserait ces champs. Le refus n'y figure pas comme
// membre initialisé pour que l'ordre des grandeurs du motif reste celui d'origine — il
// porte les précédences, et le réordonner changerait le motif publié sans changer un nombre.
t.entrees.refuse = refuse;
t.entrees.refuseDepuisS = lc.telemetry.refusedSinceS;
t.entrees.retenuW = refuse ? lc.telemetry.currentPowerW : 0.0;
// Résidu : budget − consigne engagée → charge suivante de la priorité (même cycle).
remainingSurplusW = budgetW - setpointW;
budget.allocatedW += setpointW;
return t;
}
void RuleBasedScheduler::poserNiveauConfort(LoadAction &la)
{
// UN point unique où le niveau se pose. Le recopier à chaque site d'émission le ferait
// diverger au premier site ajouté — et l'invariant Σ levels[].targetW == estimatedPowerW
// tomberait sans que rien ne le dise, puisqu'une charge sans niveau publie simplement une
// liste vide. Le test qui exige un niveau NON VIDE est ce qui a trouvé le site oublié.
LoadAction::Level confort;
confort.level = QStringLiteral("comfort");
confort.targetW = la.estimatedPowerW;
confort.funding = la.funding;
confort.counts = la.counts;
confort.reason = la.reason;
la.levels.append(confort);
}
LoadAction RuleBasedScheduler::emitSetpointAction(const SetpointTarget &t) const
{
LoadAction la = t.action;
// Les sorties anticipées (PHASE_LIMIT, EV_GRID_START, LOAD_UNAVAILABLE) portent DÉJÀ leur
// motif : il n'y a rien à choisir, la cause est connue au moment où la décision s'arrête.
if (t.motifAChoisir)
la.reason = choisirMotif(t.entrees);
// §10 — LE NIVEAU DEVIENT UN OBJET DU PLAN, au lieu d'être synthétisé à la frontière. Un
// seul niveau aujourd'hui, « comfort » : la passe unique d'aujourd'hui est celle qui sert le
// confort depuis le surplus. La passe éco est la nouveauté du lot, pas la passe existante
// rebaptisée — une charge sans obligation n'aura jamais d'entrée « eco » (R1).
poserNiveauConfort(la);
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 ?"
// 3g-2 — même exception que pour les charges en watts : sous la réserve, pas de recrédit.
// Une PAC en état 3 ou 4 y resterait sinon indéfiniment, la réserve n'agissant que sur les
// montées d'état. Voir la note détaillée dans decideSetpoint().
const double allocatedNowW = m_reserveActive
? 0.0
: 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);
la.counts[CountKey::BudgetAllocated] = la.estimatedPowerW; // R3
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 (m_reserveActive && m_reserveWithheldW > 0)
la.reason = { DecisionCode::BatteryReserve,
{{"socPercent", qRound(m_reserveSocPct)},
{"reservePercent", qRound(m_reserveSeuilPct)},
{"withheldW", qRound(m_reserveWithheldW)}} };
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;
poserNiveauConfort(la);
return la;
}