From 1478d8f11c8d97b557f0bbf80157eb98bde147d7 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Wed, 26 Aug 2026 18:00:46 +0200 Subject: [PATCH] feat(3g-1): les bornes entrent dans le waterfall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La transplantation. Les bornes partagent le budget, décrémentent la cascade et portent un rang qui a enfin un sens — il était décoratif depuis le début. Le défaut corrigé n'était pas théorique : planSurplusCharging() calculait l'allocation une fois avant la boucle et ne la décrémentait jamais. Chaque borne recevait la totalité du surplus. Avec 4 kW et deux bornes, ce n'était pas « 2 kW à chacune » mais « 4 kW à chacune », et le rattrapage venait du compteur au cycle suivant, pas de l'arbitrage. Trois motifs EV disparaissent — des suppressions, pas des remplacements. EV_ECO_MIN en particulier : le courant minimum était la même idée que le plancher du §12, écrite une seconde fois pour un seul mécanisme. Le nombre de phases est décidé AVANT l'allocation et réutilisé à l'exécution, parce que c'est lui qui fixe le plancher — 6 A valent 1,4 kW en mono et 4,1 kW en tri. C'était le piège le plus silencieux du lot. evReservedW est restreint au financement réseau plutôt que supprimé : ce qui reste décidé en amont doit rester réservé, ce qui passe par la cascade ne le doit plus. Et une correction attrapée à la première observation en vrai de BATTERY_RESERVE : il était publié avec withheldW à 0 sur un surplus négatif, donc il disait « une règle vous bloque » là où il n'y avait simplement rien. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H --- INTERFACE.md | 28 ++++++-- debian-qt5/changelog | 42 +++++++++++ energyplugin/etm/adapters/evadapter.cpp | 64 +++++++++++++++-- energyplugin/etm/adapters/evadapter.h | 4 ++ energyplugin/etm/energyarbitrator.cpp | 55 ++++++++++++++- energyplugin/etm/energyarbitrator.h | 36 ++++++++++ .../etm/scheduler/rulebasedscheduler.cpp | 69 ++++++++++++------- .../etm/scheduler/rulebasedscheduler.h | 7 ++ energyplugin/smartchargingmanager.h | 9 +++ 9 files changed, 275 insertions(+), 39 deletions(-) diff --git a/INTERFACE.md b/INTERFACE.md index 4f68616..a41b8f4 100644 --- a/INTERFACE.md +++ b/INTERFACE.md @@ -458,11 +458,25 @@ le cas normal. - `budget` **absent** = mode dégradé L2 : la planification est **suspendue**, il n'existe aucun budget pour ce cycle. Publier des zéros se lirait « arbitrage exécuté, rien à allouer ». -**Charges présentes.** Uniquement les charges **arbitrées par le waterfall** : `relay-router`, -`etmvariableload`, `sg-ready`. Le VE n'y figure pas en beta — il est décidé par l'amont **avant** -le waterfall (décision B, `AGENTS.md`), et sa puissance est réservée dans `budget.evReservedW`, -pas allouée depuis le budget publié. L'y ajouter ferait une somme `loads[]` qui ne retomberait -pas sur `budget.allocatedW`. Il rejoindra la liste avec l'étape **3g**. +**Charges présentes.** Toutes les charges **arbitrées par le waterfall** : `relay-router`, +`etmvariableload`, `sg-ready` — et, **depuis 3g-1, les bornes de recharge**. + +> **Changement de contrat en 3g-1.** Le VE ne figurait pas dans `loads[]` : il était décidé en +> amont, et sa puissance seulement *réservée* dans `budget.evReservedW`. Il y figure désormais, +> son allocation décrémente la cascade comme celle de n'importe quelle charge, et la somme des +> `allocatedW` **retombe** sur `budget.allocatedW` — ce qui n'était pas vrai avant. +> +> `budget.evReservedW` **subsiste**, mais restreint : il ne compte plus que les bornes servies +> par un financement **réseau** (échéance de départ, spot market), qui restent décidées en +> amont. Une borne servie par la cascade y serait comptée deux fois. +> +> **Deux bornes ne se partageaient pas le budget avant 3g-1** : l'allocation était calculée une +> fois et offerte entière à chacune. Ce n'est plus le cas — la première est servie, la seconde +> reçoit le reliquat, et 0 si ce reliquat est sous son plancher. + +**Bornes exclues de `loads[]`** : celles sans voiture assignée et celles en mode manuel +(`ChargingModeNormal`). Elles sont **hors arbitrage**, pas servies en dernier — leur +consommation est déjà au compteur racine, donc déjà dans le budget. #### `loads[]` ⊆ `GetLoadConfig` — toute charge publiée est configurable @@ -512,7 +526,9 @@ le **libellé de la charge n'y figure jamais** (le client le résout via `GetLoa | `EV_SPOT_MARKET` | — | VE rechargé sur créneau tarifaire favorable | | `EV_DEADLINE` | — | VE prioritaire, échéance approchante | | `EV_ECO_MIN` | — | Pas de surplus, courant minimum maintenu (mode EcoMin) | -| `EV_IDLE` | — | Pas de surplus, recharge suspendue | +| ~~`EV_IDLE`~~ | — | **Retiré en 3g-1** — remplacé par `BELOW_MIN_POWER` / `SURPLUS_INSUFFICIENT`, qui disent *pourquoi* la borne ne charge pas | +| ~~`EV_SURPLUS`~~ | — | **Retiré en 3g-1** — la recharge sur surplus produit désormais `SURPLUS_SETPOINT`, comme toute autre charge | +| ~~`EV_ECO_MIN`~~ | — | **Retiré en 3g-1** — le courant minimum est devenu le **plancher** du §12, exprimé une seule fois pour tous les mécanismes | | `LOAD_UNAVAILABLE` | `frozenW` | Charge en défaut : aucune commande émise, `frozenW` reste compté au budget. Levée par `ClearLoadFault` | | `LOCK_MIN_ON` | `appliedW`, `budgetW` | Le verrou `minOn` a **relevé** la consigne : puissance engagée non coupable | | `LOCK_MIN_OFF` | `budgetW`, `requestedW` | Le verrou `minOff` interdit tout enclenchement — le budget, lui, payait `requestedW` | diff --git a/debian-qt5/changelog b/debian-qt5/changelog index 6c49827..df0940b 100644 --- a/debian-qt5/changelog +++ b/debian-qt5/changelog @@ -1,3 +1,45 @@ +powersync-energy-plugin-nymea (1.15.2+etm21) trixie; urgency=medium + + * 3g-1 — LA TRANSPLANTATION. Les bornes de recharge entrent dans le waterfall : elles + partagent le budget avec l'ECS et la PAC, décrémentent la cascade, et portent enfin un + rang qui a un sens. Il était décoratif depuis le début. + * Le défaut que cela corrige n'était pas théorique. planSurplusCharging() calculait + l'allocation UNE fois, avant la boucle, et ne la décrémentait jamais : chaque borne + recevait la TOTALITÉ du surplus, comme si elle était seule. Avec 4 kW et deux bornes, ce + n'était pas « 2 kW à chacune » mais « 4 kW à chacune » — et ce qui rattrapait n'était pas + l'arbitrage mais le compteur au cycle suivant, puis la protection de surcharge. Le système + convergeait par correction d'erreur, pas par décision. + * Trois motifs EV disparaissent, et ce sont des suppressions, pas des remplacements : + EV_SURPLUS devient SURPLUS_SETPOINT, EV_ECO_MIN devient le PLANCHER du §12 — la même idée + exprimée une seule fois pour tous les mécanismes — et EV_IDLE cède à BELOW_MIN_POWER / + SURPLUS_INSUFFICIENT, qui DISENT pourquoi la borne ne charge pas. + * Le nombre de phases est décidé AVANT l'allocation et réutilisé à l'exécution, parce que + c'est lui qui fixe le plancher : 6 A valent 1,4 kW en monophasé et 4,1 kW en triphasé. + Publier le plancher du nombre COURANT alors que l'exécution bascule vers un autre + produirait un défaut silencieux — le waterfall allouerait 2 kW en croyant démarrer une + borne qui en exige 4,1, et se croirait servi. + * La conversion watts → ampères descend dans l'adaptateur (règle 4 : l'adaptateur écrête, + il ne décide pas). Deux origines coexistent : le proxy remplit currentA, la cascade + remplit powerW. Le discriminant est currentA. + * evReservedW (correction A) est RESTREINT au financement réseau au lieu d'être supprimé : + 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. Échéance et spot market restent décidés en amont, donc + restent réservés. + * Restent au proxy, délibérément : l'échéance de départ (sa machinerie vient du SoC de la + voiture et n'a aucun équivalent ailleurs ; et la tenir revient à SOUTIRER, donc à franchir + la frontière du §10), le spot market, la protection de surcharge, l'estimation de SoC. + Le proxy maigrit, il ne disparaît pas — c'est une étape assumée, pas un provisoire. + * Bornes sans voiture assignée ou en mode manuel : HORS arbitrage, pas rang 0. Leur + consommation est déjà au compteur racine, donc déjà dans le budget. + * ⚠️ CHANGEMENT DE CONTRAT — loads[] de GetLoadTelemetry contient désormais les bornes, et la + somme des allocations retombe sur budget.allocatedW, ce qui n'était pas vrai avant. + * Correction du motif BATTERY_RESERVE, attrapée à sa PREMIÈRE observation en vrai : il était + publié même quand rien n'avait été retenu (surplus négatif), disant « une règle vous + bloque » là où la vérité était « il n'y a pas de surplus » — exactement la confusion qu'il + existe pour supprimer, retournée. Il est désormais conditionné à withheldW > 0. + + -- Patrick Schurig Wed, 26 Aug 2026 18:00:00 +0200 + powersync-energy-plugin-nymea (1.15.2+etm20) trixie; urgency=medium * 3g-1, première moitié — la prise en compte du stockage monte dans le budget. Elle vivait diff --git a/energyplugin/etm/adapters/evadapter.cpp b/energyplugin/etm/adapters/evadapter.cpp index 24efd7e..78e89c1 100644 --- a/energyplugin/etm/adapters/evadapter.cpp +++ b/energyplugin/etm/adapters/evadapter.cpp @@ -45,17 +45,43 @@ LoadTelemetry EvAdapter::telemetry() const LoadContext EvAdapter::toLoadContext(const QDateTime &now) const { - Q_UNUSED(now) // L'EV n'a pas de verrou de palier (pas de waterfall ECS) — now inutilisé ici. + Q_UNUSED(now) // Les verrous EV (enclenchement, changement de courant) vivent en aval. LoadContext ctx; ctx.id = m_charger->thing()->id().toString(); ctx.adapter = QStringLiteral("evcharger"); ctx.label = m_charger->name(); ctx.declared = descriptor().declared; ctx.limits = descriptor().limits; + ctx.priority = descriptor().priority; + + // --- 3g-1 : la borne entre dans le waterfall, donc elle parle en WATTS --------------- + // + // Le nombre de phases VISÉ est décidé ici, avant l'allocation, et non après : c'est lui + // qui fixe le plancher. Publier celui du nombre COURANT alors que l'exécution basculera + // vers un autre produirait un défaut silencieux — le waterfall allouerait 2 kW en croyant + // démarrer une borne qui en exige 4,1, et se croirait servi. + // + // Le courant de référence pour ce choix est ce que la borne tire DÉJÀ : c'est la seule + // grandeur connue avant l'allocation, et c'est celle que le proxy utilisait aussi. + const double courantActuelA = m_charger->currentPower() / 230.0; + m_targetPhases = m_charger->canSetPhaseCount() + ? m_parent->evTargetPhases(m_charger, courantActuelA) + : m_charger->phaseCount(); + if (m_targetPhases == 0) + m_targetPhases = 1; + + ctx.declared.minPowerW = m_parent->evFloorW(m_charger, m_targetPhases); + ctx.declared.maxPowerW = m_charger->maxChargingCurrentMaxValue() * 230.0 * m_targetPhases; + ctx.declared.phases = static_cast(m_targetPhases); ctx.telemetry.currentPowerW = m_charger->currentPower(); + ctx.telemetry.available = m_charger->available(); ctx.telemetry.pluggedIn = m_charger->pluggedIn(); ctx.telemetry.charging = m_charger->charging(); + + // Pas de fenêtre de verrou de palier côté EV : lockMaxPowerW négatif = aucun plafond + // (convention du contexte), et lockMinPowerW nul = rien d'engagé de non-coupable. + ctx.telemetry.lockMaxPowerW = -1; return ctx; } @@ -87,23 +113,47 @@ LoadAction EvAdapter::applyAction(const LoadAction &action, const QDateTime &now const uint minA = m_charger->maxChargingCurrentMinValue(); const uint maxA = m_charger->maxChargingCurrentMaxValue(); - const uint clampedA = static_cast( - qBound(static_cast(minA), action.currentA, static_cast(maxA))); + // --- 3g-1 : deux origines d'action, deux unités --------------------------------------- + // + // Le PROXY (échéance, spot market) décide encore en ampères et remplit currentA. Le + // WATERFALL, lui, alloue des WATTS : c'est ici, au dernier moment, que la conversion se + // fait — conformément à la règle 4 (les bornes de l'adaptateur écrêtent toute sortie de + // stratégie, et l'adaptateur seul connaît son matériel). + // + // Le discriminant est `currentA` : non nul, l'action vient du proxy et s'exécute telle + // quelle ; nul avec une puissance, elle vient de la cascade et se convertit. const uint phases = (m_charger->canSetPhaseCount() && action.phaseCount > 0) ? qBound(1u, action.phaseCount, m_charger->phaseCount()) - : m_charger->phaseCount(); + : (m_targetPhases > 0 ? m_targetPhases : m_charger->phaseCount()); + + double demandeA = action.currentA; + if (demandeA <= 0 && action.powerW > 0) + demandeA = action.powerW / (230.0 * phases); + + const uint clampedA = static_cast( + qBound(static_cast(minA), demandeA, static_cast(maxA))); + + // Sous le plancher, la cascade a déjà alloué 0 W et publié son motif : on n'enclenche pas. + // Le test porte sur la DEMANDE, pas sur la valeur écrêtée — qui, elle, est remontée à minA + // par le qBound et ferait croire à un courant suffisant. + const bool enclencher = action.chargingEnabled && demandeA > 0 + && demandeA >= static_cast(minA); const auto issuer = (action.funding == LoadAction::Surplus) ? ChargingAction::ChargingActionIssuerSurplusCharging : ChargingAction::ChargingActionIssuerTimeRequirement; - ChargingAction ca(action.chargingEnabled, clampedA, phases, issuer, false); + ChargingAction ca(enclencher, clampedA, phases, issuer, false); m_parent->doExecuteChargingAction(m_charger, ca, now); // now = temps de cycle (injectable) m_lastActionAt = now; LoadAction applied = action; - applied.currentA = clampedA; - applied.phaseCount = phases; + applied.currentA = clampedA; + applied.phaseCount = phases; + applied.chargingEnabled = enclencher; + // Ce qui a RÉELLEMENT été engagé, en watts — c'est ce chiffre que la télémétrie publie et + // que la comptabilité du budget doit voir, pas la demande d'origine. + applied.estimatedPowerW = enclencher ? clampedA * 230.0 * phases : 0.0; return applied; } diff --git a/energyplugin/etm/adapters/evadapter.h b/energyplugin/etm/adapters/evadapter.h index 8e662ee..20f851e 100644 --- a/energyplugin/etm/adapters/evadapter.h +++ b/energyplugin/etm/adapters/evadapter.h @@ -114,4 +114,8 @@ private: EvCharger *m_charger; EnergyArbitrator *m_parent; QDateTime m_lastActionAt; + //! Nombre de phases VISÉ, décidé à la construction du contexte et réutilisé à + //! l'exécution. Les deux doivent parler du MÊME, sinon le plancher publié ne décrit pas + //! la borne qui sera réellement commandée (§12 / 3g-1). + mutable uint m_targetPhases = 1; }; diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 76288a1..038e545 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -396,6 +396,45 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime) publishTelemetry(false); } +bool EnergyArbitrator::evParticipatesInWaterfall(EvCharger *ev) const +{ + if (!ev) + return false; + + const ChargingInfo info = chargingInfo(ev->id()); + + // Sans voiture assignée, le proxy ignorait la borne : ni SoC, ni plancher véhicule, ni + // échéance calculable. Rien de tout cela n'apparaît en entrant dans le waterfall. + if (!m_tm->findConfiguredThing(info.assignedCarId())) + return false; + + // Mode manuel : l'utilisateur pilote, l'arbitre s'abstient. Hors arbitrage — pas rang 0. + if (info.chargingMode() == ChargingInfo::ChargingModeNormal) + return false; + + return true; +} + +double EnergyArbitrator::evFloorW(EvCharger *ev, uint phaseCount) const +{ + if (!ev || phaseCount == 0) + return 0; + + // max(borne, voiture) — la même règle que m_processInfos.minimalChargingCurrent du proxy. + // Elle dépend du véhicule branché : c'est une valeur runtime, republiée à chaque cycle. + double minA = ev->maxChargingCurrentMinValue(); + if (Thing *car = m_tm->findConfiguredThing(chargingInfo(ev->id()).assignedCarId())) { + const double carMinA = car->stateValue("minChargingCurrent").toDouble(); + if (carMinA > minA) + minA = carMinA; + } + + // La tolérance d'acquisition est le même facteur que celui qu'appliquait le proxy avant + // de décider d'enclencher : sans elle, le plancher publié serait plus bas que celui qui + // gouverne réellement le démarrage. + return minA * acquisitionTolerance() * 230.0 * phaseCount; +} + SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const { SurplusContext ctx; @@ -462,9 +501,21 @@ SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const } } - // --- loads[] : EV adapters --- (now = ctx.timestamp : source unique des verrous) - for (auto it = m_adapters.constBegin(); it != m_adapters.constEnd(); ++it) + // --- loads[] : bornes VE (3g-1) ------------------------------------------------------ + // Elles entrent désormais dans le MÊME contexte que les autres charges — c'est toute la + // transplantation. Le filtre d'entrée du proxy est repris à l'identique : une borne sans + // voiture ou en mode manuel est HORS arbitrage, pas servie en dernier. Sa consommation est + // déjà dans le compteur racine, donc déjà dans le budget ; lui allouer quoi que ce soit la + // compterait deux fois. + for (auto it = m_adapters.constBegin(); it != m_adapters.constEnd(); ++it) { + EvCharger *ev = internalEvChargers().value(ThingId(it.key())); + if (!evParticipatesInWaterfall(ev)) { + qCDebug(dcNymeaEnergy()) << "[Arbitre] Borne" << (ev ? ev->name() : it.key()) + << "hors waterfall (pas de voiture assignée, ou mode manuel)."; + continue; + } ctx.loads.append(it.value()->toLoadContext(now)); + } // --- loads[] : TOUTES les charges arbitrées, polymorphes (relay-router, etmvariableload, // sg-ready) — une seule table, toutes construites depuis la configuration persistée. --- diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index 7cd4f07..c03603c 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -202,6 +202,42 @@ public: */ static bool sameHardware(const LoadConfig &a, const LoadConfig &b); + /*! + * \brief [3g-1] Cette borne participe-t-elle au waterfall de surplus ? + * + * Reprend **à l'identique** le filtre d'entrée du proxy : une borne sans voiture assignée + * ou en mode manuel (\c ChargingModeNormal) est **hors arbitrage**, pas servie en dernier. + * L'exclure du contexte est plus juste que lui donner un rang : sa consommation est déjà + * dans le compteur racine, donc dans le budget, sans qu'on ait à la lui allouer. + */ + bool evParticipatesInWaterfall(EvCharger *ev) const; + + /*! + * \brief [3g-1] Nombre de phases visé pour une borne, à un courant donné. + * + * Simple passe-plat vers la règle de bascule 1↔3 du manager. L'arbitre est la porte + * d'entrée de l'adaptateur vers cette règle : la réécrire dans l'adaptateur en ferait + * deux versions, et elles divergeraient au premier ajustement. + */ + uint evTargetPhases(EvCharger *ev, double referenceA) + { return bestPhaseCountFor(ev, referenceA); } + + /*! + * \brief [3g-1] Plancher de modulation d'une borne, en watts, pour le nombre de phases VISÉ. + * + * \param ev Borne. \param phaseCount Nombre de phases visé — **pas** le nombre courant. + * \return Puissance minimale sous laquelle la borne ne démarre pas, 0 si indéterminable. + * + * \warning Le nombre de phases change le plancher du simple au triple (6 A × 230 V = 1,4 kW + * en monophasé, 4,1 kW en triphasé). Publier le plancher du nombre COURANT alors que + * l'exécution basculera vers un autre produirait un défaut silencieux : le waterfall + * allouerait 2 kW en croyant démarrer une borne qui en exige 4,1, et se croirait servi. + * + * \note Valeur **runtime**, pas un réglage : \c minimalChargingCurrent vaut + * `max(borne, voiture)` et dépend donc du véhicule branché. + */ + double evFloorW(EvCharger *ev, uint phaseCount) const; + signals: /*! * \brief Émise quand l'état runtime d'arbitrage a CHANGÉ, ou au battement de cœur. diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index 310eae8..23ec281 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -1,6 +1,7 @@ // 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" @@ -30,6 +31,11 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) 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(); @@ -43,17 +49,6 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) la = buildTimeRequirementAction( ev, actions.value(ChargingAction::ChargingActionIssuerTimeRequirement)); - } else if (actions.value(ChargingAction::ChargingActionIssuerSurplusCharging).chargingEnabled()) { - const auto &ca = actions.value(ChargingAction::ChargingActionIssuerSurplusCharging); - la.loadId = ev->thing()->id().toString(); - la.kind = LoadAction::Setpoint; - la.funding = LoadAction::Surplus; - la.chargingEnabled = true; - la.currentA = ca.maxChargingCurrent(); - la.phaseCount = ca.desiredPhaseCount(); - la.reason = { DecisionCode::EvSurplus }; - la.estimatedPowerW = la.currentA * 230.0 * la.phaseCount; - } else if (actions.value(ChargingAction::ChargingActionIssuerSpotMarketCharging).chargingEnabled()) { const auto &ca = actions.value(ChargingAction::ChargingActionIssuerSpotMarketCharging); la.loadId = ev->thing()->id().toString(); @@ -66,16 +61,25 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) la.estimatedPowerW = la.currentA * 230.0 * la.phaseCount; } else { - const ChargingInfo::ChargingMode mode = - m_arbitrator->chargingInfo(ev->id()).chargingMode(); - if (mode == ChargingInfo::ChargingModeEcoWithMinCurrent - || mode == ChargingInfo::ChargingModeEcoMinWithTargetTime) { - la = buildMinCurrentAction(ev); - } else { - la = buildIdleAction(ev); - } + // 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); } @@ -87,6 +91,11 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) 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); @@ -163,11 +172,23 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) // 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) { - if (lc.adapter == QStringLiteral("etmvariableload") || lc.adapter == QStringLiteral("relay-router") - || lc.adapter == QStringLiteral("sg-ready")) - nonEvLoads.append(lc); + 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; }); @@ -374,7 +395,7 @@ LoadAction RuleBasedScheduler::buildSetpointAction(const LoadContext &lc, la.reason = { DecisionCode::SurplusSetpoint, {{"budgetW", qRound(budgetW)}, {"setpointW", qRound(setpointW)}, {"stepped", !dynamic}} }; - else if (m_reserveActive) + 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. @@ -452,7 +473,7 @@ LoadAction RuleBasedScheduler::buildSgReadyStateAction(const LoadContext &lc, else if (bestState == 3) la.reason = { DecisionCode::SgRecommended, {{"budgetW", qRound(budgetW)}, {"estimatedW", qRound(p3)}} }; - else if (m_reserveActive) + else if (m_reserveActive && m_reserveWithheldW > 0) la.reason = { DecisionCode::BatteryReserve, {{"socPercent", qRound(m_reserveSocPct)}, {"reservePercent", qRound(m_reserveSeuilPct)}, diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.h b/energyplugin/etm/scheduler/rulebasedscheduler.h index cf860b7..23d0325 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.h +++ b/energyplugin/etm/scheduler/rulebasedscheduler.h @@ -133,5 +133,12 @@ private: double m_reserveSeuilPct = 0; //!< Seuil configuré, en pourcentage. //! Surplus qui EXISTAIT et n'a pas été distribué. C'est ce chiffre, pas le SOC, qui rend //! la cause lisible : « 3 200 W retenus » se comprend là où « batterie à 47 % » se subit. + //! + //! \warning **Il conditionne le motif, il ne l'accompagne pas.** Nul, la réserve n'a rien + //! confisqué — l'installation importait, et les charges n'auraient rien reçu de toute + //! façon. Publier \c BATTERY_RESERVE dans ce cas dirait « une règle vous bloque » là où + //! la vérité est « il n'y a pas de surplus » : exactement la confusion que ce motif + //! existe pour supprimer, retournée. Constaté à la PREMIÈRE observation en vrai, le + //! 2026-08-26 — surplus à −3 555 W, motif publié, \c withheldW à 0. double m_reserveWithheldW = 0; }; diff --git a/energyplugin/smartchargingmanager.h b/energyplugin/smartchargingmanager.h index b39ead4..6d373ce 100644 --- a/energyplugin/smartchargingmanager.h +++ b/energyplugin/smartchargingmanager.h @@ -137,6 +137,15 @@ protected slots: protected: void executeChargingAction(EvCharger *evCharger, const ChargingAction &chargingAction, const QDateTime ¤tDateTime); // [ETM] private → protected + /*! + * \brief [ETM 3g-1] Nombre de phases le plus adapté à un courant de surplus donné. + * \note `private → protected`. La règle de bascule 1↔3 phases est la MÊME que le VE soit + * décidé par le proxy ou par le waterfall ; la dupliquer dans l'arbitre en ferait deux, + * et elles divergeraient. C'est la seule raison de cet élargissement de visibilité. + */ + uint bestPhaseCountFor(EvCharger *evCharger, double surplusAmpere) + { return getBestPhaseCount(evCharger, surplusAmpere); } + // [ETM] Read-only state accessors — inline, no copies, no logic. /*!