// 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; 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 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 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 niveauxEco; // --- TÊTE DE PASSE ÉCO — les DÉROGATIONS -------------------------------------------------- // // \rule{LM-1500-e} — elles passent AVANT tout le reste de la passe éco, RANG COMPRIS, et // c'est le sens même du // mot : « le plus vite possible, tout de suite ». Une dérogation qui attendrait son rang // serait un mode de plus, pas une dérogation. // // Ce qu'elle ne surplombe PAS, et il faut le lire dans le code : l'autorisation de // soutirage la borne (elle est servie dans ce qui RESTE, jamais au-delà), et le délestage // la reprend comme n'importe quelle charge — L4 ne consulte pas les motifs. Sans ces deux // bornes on ouvrirait un second chemin d'achat, celui-là même qu'on a refusé au minimum // réseau. QSet sousDerogation; for (const LoadContext &lc : nonEvLoads) { if (!lc.derogation) continue; sousDerogation.insert(lc.id); // marqué même non servie : la passe confort doit la sauter // LE MAXIMUM DE LA CHARGE, et rien d'autre. Une dérogation ne module pas : c'est sa // définition côté client, et une dérogation qui doserait redeviendrait un mode. double achatW = lc.telemetry.available ? lc.declared.maxPowerW : 0.0; // \rule{LM-1500-h} — LES BORNES SONT HORS DE LA MARGE DE LA MAISON, comme dans la // passe confort, et pour // la même raison — qui y est écrite : leur plafond de cycle est DÉJÀ écrêté par // `evPhaseAllowanceW()`, qui connaît les phases qu'elles occupent et multiplie la // marge par leur nombre. Leur appliquer en plus `margeRestante`, c'est comparer une // enveloppe TRIPHASÉE à la marge d'UNE SEULE phase. // // Mesuré au banc le 2026-09-08 : marge de 6 210 W sur la phase la plus chargée, borne // triphasée dont le plafond du cycle valait 13 800 W. Le double écrêtage l'a servie à // 6 210 W, soit 9 A par phase au lieu de 27 — 4 kW au lieu des 11 que la voiture // pouvait prendre. Sûr, mais faux : le boost ne faisait plus ce que son nom promet. const bool estUneBorne = lc.adapter == QStringLiteral("evcharger"); const bool bridePlafond = !estUneBorne && margeRestante.borne && achatW > qMax(0.0, margeRestante.watts); if (!estUneBorne && margeRestante.borne) achatW = qBound(0.0, achatW, qMax(0.0, margeRestante.watts)); // L'achat est noté à CHAQUE cycle, y compris nul : c'est le passage du temps qui intègre // l'énergie. Et il est noté même si la charge porte par ailleurs une obligation — le // boost achète pour de vrai, et ne pas l'y compter ferait racheter la même énergie deux // fois quand la dérogation s'effacera. m_arbitrator->noterAchatEco(lc.id, achatW, ctx.timestamp); if (achatW <= 0) continue; // rien d'achetable ce cycle : pas de niveau plutôt qu'un zéro forgé LoadAction::Level der; der.level = QStringLiteral("eco"); der.targetW = achatW; der.funding = LoadAction::Grid; der.counts[CountKey::DrawCommitted] = achatW; QVariantMap motif{{"maxW", qRound(lc.declared.maxPowerW)}, {"gridW", qRound(achatW)}, {"endsAt", lc.derogationEndsAt.toUTC().toString(Qt::ISODate)}}; // La cible n'est publiée QUE si elle existe. Un `targetWh: 0` se lirait « cible // atteinte » sur une dérogation qui n'en a pas — et `deliveredWh` sans cible ne // répondrait à aucune question. if (lc.derogationTargetWh > 0) { motif.insert(QStringLiteral("targetWh"), qRound(lc.derogationTargetWh)); motif.insert(QStringLiteral("deliveredWh"), qRound(lc.derogationDeliveredWh)); } // QUI a bridé, publié plutôt que déduit : sans cela, une dérogation servie à moitié se // lit comme une borne qui ne suit pas, alors qu'elle est au bout de ce qu'on lui // permet. Deux valeurs, deux gestes : l'abonnement, ou la charge elle-même. if (bridePlafond) motif.insert(QStringLiteral("boundBy"), QStringLiteral("drawCap")); else if (lc.declared.maxPowerW > 0 && achatW >= lc.declared.maxPowerW) // Elle est à SON plafond de cycle. Pour une borne, ce plafond porte déjà // l'autorisation de phase : `maxW` est donc la vraie réponse à « pourquoi pas // plus », et il est publié à côté. motif.insert(QStringLiteral("boundBy"), QStringLiteral("loadCeiling")); der.reason = { DecisionCode::DerogationActive, motif }; niveauxEco.insert(lc.id, der); draw.committedW += achatW; if (margeRestante.borne) margeRestante.watts -= achatW; } for (const LoadContext &lc : nonEvLoads) { // Une dérogation REMPLACE l'obligation le temps qu'elle dure : elle demande déjà le // maximum, donc tout ce que la cadence pourrait réclamer est déjà servi. Continuer à // évaluer l'obligation ici produirait un second niveau « eco » pour la même charge, et // l'addition des deux dépasserait son plafond matériel. if (sousDerogation.contains(lc.id)) continue; 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(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); draw.committedW += achatW; if (margeRestante.borne) margeRestante.watts -= achatW; } // ================= PASSE 2 — CONFORT ====================================================== for (const LoadContext &lc : nonEvLoads) { if (sousDerogation.contains(lc.id)) { // PASSE CONFORT SAUTÉE. La dérogation demande déjà le maximum de la charge : il n'y // a rien à y ajouter, et l'ajouter quand même dépasserait son plafond matériel — la // fusion des niveaux est une ADDITION (cf. plus bas). Le surplus non dépensé reste // au budget et passe aux charges suivantes, qui en ont l'usage. // // CONSÉQUENCE COMPTABLE À CONNAÎTRE : la dérogation est comptée entièrement en // ACHAT alors que le surplus présent la finance en partie, physiquement. `gridW` la // surestime donc quand il y a du soleil. Ce n'est pas une approximation neuve : le // §10 la porte déjà pour le plancher éco, et elle sera reprise avec lui. if (niveauxEco.contains(lc.id)) { const LoadAction::Level der = niveauxEco.take(lc.id); LoadAction la; la.loadId = lc.id; la.kind = LoadAction::Setpoint; la.funding = LoadAction::Grid; la.powerW = der.targetW; la.estimatedPowerW = der.targetW; la.chargingEnabled = der.targetW > 0; la.reason = der.reason; la.counts = der.counts; la.levels.append(der); slot.actions.append(la); } // La marge N'EST PAS redécrémentée : la tête de passe l'a déjà fait. La décompter // deux fois priverait les charges suivantes d'une autorisation encore disponible. if (lc.adapter == QStringLiteral("evcharger")) waterfallEvIds.insert(lc.id); continue; } if (lc.adapter == QStringLiteral("sg-ready")) slot.actions.append(buildSgReadyStateAction(lc, remainingSurplusW, compta)); else slot.actions.append(emitSetpointAction( decideSetpoint(lc, remainingSurplusW, margeRestante, compta, draw))); // 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) 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. // LM-1004 CORRIGÉE (2026-09-07) — le recrédit appartient à la passe qui DÉPENSE DU SURPLUS. // // La règle disait « à la première passe où la charge apparaît ». Or la passe éco ne dépense // pas de surplus : elle est bornée par l'autorisation de soutirage, et ne touche jamais // `remainingSurplusW`. Lui « attribuer » le recrédit revenait donc à ne le créditer NULLE // PART — une charge servie en éco ne récupérait jamais sa propre consommation, et son // tirage déprimait le budget dont elle avait besoin pour recevoir du surplus par-dessus. // // Mesuré sur .75 le 2026-09-07 : borne à 3 899 W, surplus publié −3 148 W. Sans la borne, // il valait +700 W. Elle se privait exactement du surplus qu'elle pouvait recevoir, et le // mode « minimum + surplus » ne servait jamais que le minimum. // // Le défaut préexistait pour le chauffe-eau servi en éco ; il était invisible parce que son // compteur affiche 0 W — une charge qui ne mesure rien ne peut pas se priver de rien. const bool refuse = lc.telemetry.commandRefused; const double recreditW = (m_reserveActive || refuse) ? 0.0 : lc.telemetry.currentPowerW; const double budgetW = remainingSurplusW + recreditW; budget.recreditedW += recreditW; const QList &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(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(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; }