// SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync #include #include "rulebasedscheduler.h" #include "../energyarbitrator.h" #include "../../evcharger.h" #include "../../types/chargingaction.h" #include "../../types/charginginfo.h" #include "plugininfo.h" #include #include 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 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; } 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; 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); } // 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; compta.surplusW = remainingSurplusW; compta.evReservedW = evReservedW; // Charges pilotables non-EV triées par priorité ASCENDANTE : rang 1 = premier servi // (OPTIMIZER_PROTOCOL §5 + annexe C — la priorité est un rang). Le budget de surplus est // UNIQUE et cascade à travers TOUTES ces charges par priorité : // - etmvariableload (ECS/routeur, kind Setpoint W → buildSetpointAction, règle d'arrondi §5) ; // - sg-ready (PAC, kind State → buildSgReadyStateAction, mapping sémantique). // Charges en WATTS (Setpoint) : etmvariableload (continu) ET relay-router (rév. 3, combinaison // de relais dérivée côté routeur). Charge à ÉTATS : sg-ready (PAC). Le scheduler distingue // Setpoint vs State (nature de l'action), PAS la classe concrète d'adaptateur (frontière rév. 3). // 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 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); } std::sort(nonEvLoads.begin(), nonEvLoads.end(), [](const LoadContext &a, const LoadContext &b) { return a.priority < b.priority; }); for (const LoadContext &lc : nonEvLoads) { if (lc.adapter == QStringLiteral("sg-ready")) slot.actions.append(buildSgReadyStateAction(lc, remainingSurplusW, compta)); else slot.actions.append(buildSetpointAction(lc, remainingSurplusW, compta)); // etmvariableload + relay-router } // Grid funding (ECS/PAC) : dormant jusqu'à 3f (waterfall réseau) — non implémenté ici. Plan plan; plan.planId = QUuid::createUuid().toString(QUuid::WithoutBraces); plan.strategy = QStringLiteral("rule-based"); plan.timeSlots.append(slot); compta.remainingW = remainingSurplusW; plan.budget = compta; return plan; } LoadAction RuleBasedScheduler::buildTimeRequirementAction(EvCharger *ev, const ChargingAction &ca) const { // Le courant final est affiné par adjustEvChargers() (allowance root-meter). // En 3b on log la valeur brute de la planification — iso-fonctionnel. LoadAction la; la.loadId = ev->thing()->id().toString(); la.kind = LoadAction::Setpoint; la.funding = LoadAction::Grid; la.chargingEnabled = true; la.currentA = ca.maxChargingCurrent(); la.phaseCount = ca.desiredPhaseCount(); la.reason = { DecisionCode::EvDeadline }; la.estimatedPowerW = la.currentA * 230.0 * la.phaseCount; return la; } LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc, double &remainingSurplusW, PlanBudget &budget) const { // Recrédit anti-clignotement (Correction B / contrat rév. 2 §5) : la conso de DÉBUT de cycle // est déjà soustraite de l'export mesuré → on la rend au budget local avant d'arrondir. // PAS de relecture post-setpoint (invariant 8 : aucune boucle de feedback). const double budgetW = remainingSurplusW + lc.telemetry.currentPowerW; budget.recreditedW += lc.telemetry.currentPowerW; const QList &levels = lc.declared.powerLevels; const bool dynamic = levels.isEmpty(); double setpointW; bool sousPlancher = false; //!< Budget > 0 mais sous le premier palier atteignable. double plancherW = 0; //!< Ce palier, précisément — le motif doit le NOMMER. if (dynamic) { // Dynamic (routeur PV / triac) : modulation continue bornée au plafond déclaré. const double maxW = lc.declared.maxPowerW > 0 ? lc.declared.maxPowerW : budgetW; setpointW = qBound(0.0, budgetW, maxW); // §12 / LM-1203 — le domaine n'est pas [0, max] mais {0} ∪ [min, max]. Sous le // plancher, la charge ne prend RIEN, et le budget PASSE à la suivante. // // Ne rien transmettre serait le vrai défaut : une charge de rang 1 au plancher élevé // retiendrait en otage un budget qu'elle ne peut pas utiliser, et tout le waterfall en // aval resterait à sec pendant qu'elle-même ne démarre pas. C'est le cas des deux // bornes — 2 kW chacune sous un plancher de 1,38 kW n'en fait démarrer aucune, quand // servir la première à fond en fait charger une. if (lc.declared.minPowerW > 0 && setpointW > 0 && setpointW < lc.declared.minPowerW) { setpointW = 0; plancherW = lc.declared.minPowerW; sousPlancher = true; } } else { // Fixed : plus haut palier déclaré ≤ budget (levels triés croissants, 0 inclus → // garantit une consigne valide même budget ≤ 0). Granularité connue → zéro cycle de retard. int chosen = 0; for (int l : levels) { if (l <= budgetW) chosen = l; else break; } setpointW = chosen; // §12 — même distinction qu'en dynamic, et le plancher y est DÉRIVÉ : levels[0] vaut // toujours 0, donc le premier palier atteignable est levels[1]. // // Ce qui se joue ici est la différence entre « il n'y a pas assez » et « il y en a, // mais pas au bon format ». Un budget de 300 W devant un premier palier de 500 W // n'est pas un manque de surplus : c'est une granularité. Les deux phrases envoient // l'installateur vers des gestes opposés — attendre le soleil, ou relire son câblage. const int premier = levels.size() > 1 ? levels.at(1) : 0; if (premier > 0 && budgetW > 0 && chosen == 0) { plancherW = premier; sousPlancher = true; } } // ECS-410 — charge INDISPONIBLE : elle sort de l'ARBITRAGE, pas de la COMPTABILITÉ. // Aucune allocation neuve, mais sa consommation reste retranchée du budget : une // puissance qu'on ne sait plus couper est de la conso fixe, au même titre que la base de // la maison. La retirer du bilan offrirait aux charges suivantes un budget qui n'existe // pas — c'est le trou d'ECS-306 rouvert par une autre porte. // // Le figeage est porté par l'adaptateur : lockMin == lockMax == puissance crue engagée. // Le clamp ci-dessous suffit donc à produire la bonne comptabilité, sans branche spéciale. if (!lc.telemetry.available) { const double gele = lc.telemetry.lockMinPowerW; LoadAction la; la.loadId = lc.id; la.kind = LoadAction::Setpoint; la.funding = LoadAction::Surplus; la.powerW = gele; la.estimatedPowerW = gele; // Le LIBELLÉ n'entre pas dans les paramètres : c'est de la langue naturelle saisie // par l'installateur, et la charge utile RPC doit en rester exempte. Il est substitué // au rendu, par renderFr(reason, label). la.reason = { DecisionCode::LoadUnavailable, {{"frozenW", qRound(gele)}} }; remainingSurplusW = budgetW - gele; budget.allocatedW += gele; // une puissance qu'on ne sait plus couper reste allouée return la; } // ECS-306 — clamp lock-aware AVANT de décrémenter le budget (AGENTS règle 4 : les // bornes de l'adaptateur écrêtent TOUTE sortie de stratégie, et leur résultat doit // revenir à l'arbitre). Sans ceci, l'adaptateur remonte le palier de son côté et le // scheduler décrémente une valeur plus basse : pendant toute la fenêtre minOn, les // charges suivantes reçoivent un résidu surestimé → soutirage réseau. // ECS-309 — ce que la stratégie voulait, AVANT que les bornes de verrou n'écrêtent. // Sans cette mémoire, le motif ne peut pas distinguer « le budget ne payait pas » de // « le verrou a refusé » : les deux finissent à la même consigne. const double vouluW = setpointW; if (lc.telemetry.lockMaxPowerW >= 0) setpointW = qMin(setpointW, lc.telemetry.lockMaxPowerW); if (setpointW < lc.telemetry.lockMinPowerW) setpointW = lc.telemetry.lockMinPowerW; // puissance engagée, non-coupable LoadAction la; la.loadId = lc.id; la.kind = LoadAction::Setpoint; la.funding = LoadAction::Surplus; la.powerW = setpointW; la.estimatedPowerW = setpointW; // ECS-309 — le motif nomme le MÉCANISME qui a produit l'issue, jamais un mécanisme // plausible. La règle 7 d'AGENTS.md exige un motif non vide ; celle-ci exige qu'il soit // vrai. Un motif faux envoie diagnostiquer le mauvais problème, avec l'autorité d'une // explication — constaté au banc le 2026-08-09 : palier 0 avec 7706 W disponibles, et // « Surplus insuffisant (7706 W) » publié. // ECS-309-b — le critère est ce que le verrou a FAIT à la consigne voulue, pas une // comparaison au budget. « setpointW > budgetW » nommait minOn dès que le budget était // négatif à palier nul : 0 > −389 est vrai, et le motif annonçait « maintenue à 0 W // (puissance engagée) » alors que rien n'était engagé et que le soutirage était la seule // cause. Constaté au banc le 2026-08-13, sept fois en quatre heures, toujours au cycle // suivant un palier 0. Les deux branches se lisent maintenant sur le même axe : // le verrou a relevé la consigne (minOn), ou il l'a rabaissée (minOff). if (setpointW > vouluW) la.reason = { DecisionCode::LockMinOn, {{"appliedW", qRound(setpointW)}, {"budgetW", qRound(budgetW)}} }; else if (setpointW < vouluW) // Le budget payait vouluW ; c'est la fenêtre minOff qui interdit de monter. Le motif // cite le budget DISPONIBLE, précisément pour couper court à l'explication budgétaire. la.reason = setpointW <= 0 ? DecisionReason(DecisionCode::LockMinOff, {{"budgetW", qRound(budgetW)}, {"requestedW", qRound(vouluW)}}) : DecisionReason(DecisionCode::LockMinOffCapped, {{"appliedW", qRound(setpointW)}, {"budgetW", qRound(budgetW)}, {"requestedW", qRound(vouluW)}}); else if (setpointW > 0) // « stepped » remplace le suffixe « (palier) » composé en dur : le client décide // comment il le dit, la box dit seulement que la consigne est arrondie sur un palier. la.reason = { DecisionCode::SurplusSetpoint, {{"budgetW", qRound(budgetW)}, {"setpointW", qRound(setpointW)}, {"stepped", !dynamic}} }; else if (m_reserveActive && m_reserveWithheldW > 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. la.reason = { DecisionCode::BatteryReserve, {{"socPercent", qRound(m_reserveSocPct)}, {"reservePercent", qRound(m_reserveSeuilPct)}, {"withheldW", qRound(m_reserveWithheldW)}} }; else if (sousPlancher) // §12 — dire QUE le budget est sous le plancher, pas qu'il est insuffisant : la // seconde phrase enverrait chercher du soleil là où il faut lire une fiche technique. la.reason = { DecisionCode::BelowMinPower, {{"budgetW", qRound(budgetW)}, {"minPowerW", qRound(plancherW)}} }; else la.reason = { DecisionCode::SurplusInsufficient, {{"budgetW", qRound(budgetW)}} }; // Résidu : budget − consigne engagée → charge suivante de la priorité (même cycle). remainingSurplusW = budgetW - setpointW; budget.allocatedW += setpointW; return la; } LoadAction RuleBasedScheduler::buildSgReadyStateAction(const LoadContext &lc, double &remainingSurplusW, PlanBudget &budget) const { const int currentState = lc.telemetry.state; const double p3 = lc.declared.estimatedPowerW.value(3, 0.0); const double p4 = lc.declared.estimatedPowerW.value(4, 0.0); // Recrédit (correction B) : la puissance ALLOUÉE de l'état courant (déclaré, 0 pour 1/2) // revient au budget — comme l'ECS. Base : "quel surplus si la PAC n'était pas pilotée ?" const double allocatedNowW = lc.declared.estimatedPowerW.value(currentState, 0.0); const double budgetW = remainingSurplusW + allocatedNowW; budget.recreditedW += allocatedNowW; // Mapping SÉMANTIQUE (pas "le plus haut qui rentre") avec hystérésis d'état anti-oscillation : // monter en 4 si budget ≥ P4×1,2 ; rester en 4 tant que budget ≥ P4×1,0 (zone morte) ; // sinon recommandation (≥ P3) ; sinon normal (état 2, mains off). // État 1 (effacement) jamais déclenché par le surplus seul — déféré (signal tarif/réseau). constexpr double kForceMargin = 1.2; // hystérésis d'entrée en état 4 int targetState; if (p4 > 0.0 && budgetW >= p4 * kForceMargin) targetState = 4; else if (currentState == 4 && p4 > 0.0 && budgetW >= p4) // zone morte : reste forcé targetState = 4; else if (p3 > 0.0 && budgetW >= p3) targetState = 3; else targetState = 2; // Clamp lock-aware (fenêtre minStateHold, calculée au MÊME ctx.timestamp). const int loW = lc.telemetry.minState > 0 ? lc.telemetry.minState : 2; const int hiW = lc.telemetry.maxState > 0 ? lc.telemetry.maxState : 2; int bestState = qBound(qMin(loW, hiW), targetState, qMax(loW, hiW)); // Sécurité : ne jamais commander un état non déclaré (snap au plus haut déclaré ≤ cible). if (!lc.declared.states.contains(bestState)) { int snapped = lc.declared.states.isEmpty() ? 2 : lc.declared.states.first(); for (int s : lc.declared.states) if (s <= bestState && s > snapped) snapped = s; bestState = snapped; } LoadAction la; la.loadId = lc.id; la.kind = LoadAction::State; la.funding = LoadAction::Surplus; la.state = bestState; la.estimatedPowerW = lc.declared.estimatedPowerW.value(bestState, 0.0); if (bestState != targetState) la.reason = { DecisionCode::LockMinStateHold, {{"state", bestState}} }; else if (bestState == 4) la.reason = { DecisionCode::SgForced, {{"budgetW", qRound(budgetW)}, {"estimatedW", qRound(p4)}} }; else if (bestState == 3) la.reason = { DecisionCode::SgRecommended, {{"budgetW", qRound(budgetW)}, {"estimatedW", qRound(p3)}} }; else if (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; return la; }