Deux demandes de l'app, toutes deux fondées. purchasedWh est publié même sous `unmeasurable`, et ce n'est pas une entorse à la règle qui y interdit deliveredWh — c'en est le complément. Ce qui est ACHETÉ est un fait connu sans mesure : c'est notre propre commande. Ce qui est LIVRÉ est une observation qu'on ne peut pas faire. Sans lui, « ce que ça coûte par jour » redeviendrait une estimation fabriquée à partir du taux. C'est un cumul sur la fenêtre d'obligation, donc LM-1014-b s'applique : remis à zéro à la bascule, comme la base. Le premier cycle d'une période ne contribue rien — il n'a pas de durée derrière lui, et en inventer une serait fabriquer de l'énergie. measuredSince ferme un piège d'affichage. Quand une obligation bascule de unmeasurable à measured EN COURS de période, deliveredWh repart de 0 pendant que targetWh reste la cible entière : une barre afficherait « 0,5 sur 4,0 » après une bascule où 3 kWh ont pu être achetés. Rebaser targetWh serait pire — on perdrait l'obligation réelle, et l'écran ne pourrait plus dire ce qui reste dû. Le champ prend la seconde sortie : « mesuré depuis 09:14 », et purchasedWh couvre l'avant. Les deux se répondent. Et un piège de build noté dans AGENTS.md : changer la DISPOSITION d'une structure dans un en-tête inclus transitivement laisse des objets compilés contre l'ancienne, et le symptôme est un plantage dans un DESTRUCTEUR — qui ressemble en tout point à un défaut mémoire du code qu'on vient d'écrire. Avant de chercher le bug : make clean. Le plantage n'avait jamais existé. Suite : simulation 59/59, loadmodel 22/22, charging 48/48 identique à la référence, spotmarket 32/32, doxygen 0 avertissement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
888 lines
50 KiB
C++
888 lines
50 KiB
C++
// 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;
|
||
}
|
||
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");
|
||
eco.hasProgress = true;
|
||
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;
|
||
}
|
||
|
||
if (cible.tenue) {
|
||
// 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;
|
||
eco.reason = { DecisionCode::EcoFloorGrid,
|
||
{{"gridW", qRound(achatW)},
|
||
{"remainingWh", qRound(cible.remainingWh)},
|
||
{"targetWh", qRound(avancement.targetWh)},
|
||
{"remainingS", static_cast<int>(cible.remainingS)},
|
||
{"regime", avancement.regime},
|
||
{"atMaximum", cible.pointDeNonRetour}} };
|
||
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.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.
|
||
const double recreditW = (m_reserveActive || recreditDejaAttribue)
|
||
? 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 };
|
||
|
||
// 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;
|
||
}
|