From 5ab5213aad87dbff7d14eb210387c1e2f0ee18c5 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Tue, 27 Oct 2026 12:48:30 +0100 Subject: [PATCH] =?UTF-8?q?moteur=20:=20la=20marge=20de=20soutirage=20born?= =?UTF-8?q?e=20aussi=20les=20consignes=20d'un=20optimiseur=20(d=C3=A9fauts?= =?UTF-8?q?=204=20et=205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La marge que le compteur laisse sous le seuil ne bornait que la cascade du rule-based. Sous un optimiseur qui demande le maximum, ce que la protection venait de retirer revenait au cycle suivant, dès que le compteur repassait sous la limite : 32,2 A pendant une minute sur l'installation d'essai, après un délestage du chauffe-eau. bornerOptimiseurAuSoutirage(), avant le dispatch : une consigne d'optimiseur ne fait monter une charge que de la marge libre (pire phase, consommée dans l'ordre du créneau). Elle ne réduit rien — c'est la protection qui réduit. L'engagé d'une borne est sa dernière commande tenue, si bien que le dispatch ne défait plus, dans le même cycle, ce que la protection vient de faire. Une consigne écrêtée publie DRAW_CAP et clamped, plus le motif de l'optimiseur (défaut 5). Co-Authored-By: Claude Opus 5.5 --- energyplugin/etm/energyarbitrator.cpp | 83 ++++++++++++++++++++ energyplugin/etm/energyarbitrator.h | 33 ++++++++ energyplugin/etm/scheduler/optimizerplan.cpp | 4 +- tests/auto/refoptimizer/testrefoptimizer.cpp | 5 +- 4 files changed, 122 insertions(+), 3 deletions(-) diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index aa804d2..9df8ff0 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -17,6 +17,7 @@ #include "scheduler/optimizerstatus.h" #include "scheduler/publishedplan.h" #include "scheduler/optimizertrace.h" +#include "scheduler/optimizerplan.h" #include "types/decisionreason.h" #include "types/surpluscontext.h" #include "types/plan.h" @@ -532,6 +533,12 @@ void EnergyArbitrator::update(const QDateTime ¤tDateTime) // adjustEvChargers() juste après, et c'est ce qui empêche le proxy de re-commander. m_waterfallEvIds = plan.waterfallEvIds; + // Lot délestage triphasé — la marge de soutirage s'oppose aussi aux consignes d'un OPTIMISEUR, + // avant qu'elles partent : ce que la protection a retiré ne revient pas tant que le compteur + // ne lui en laisse pas la place. Le rule-based, lui, est déjà borné dans sa cascade. + if (plan.strategy == QLatin1String(OptimizerPlan::StrategieOptimiseur)) + bornerOptimiseurAuSoutirage(slot, ctx); + // 7 : dispatch matériel (même position que l'amont — m_chargingActions rempli par getPlan()) applyActionsToAdapters(slot, currentDateTime); // charges arbitrées (Setpoint W, State) ET bornes du waterfall adjustEvChargers(currentDateTime); // bornes RESTÉES au proxy : échéance, spot market @@ -979,6 +986,82 @@ void EnergyArbitrator::shedWaterfallForOverload(const QDateTime &now) "ce qui pouvait l'être. La cause est hors du waterfall."; } +void EnergyArbitrator::bornerOptimiseurAuSoutirage(Slot &slot, const SurplusContext &ctx) +{ + const DrawMargin marge = ctx.drawMargin; + if (!marge.borne) + return; // aucun plafond en vigueur : rien à opposer + + QHash charges; + for (const LoadContext &lc : ctx.loads) + charges.insert(lc.id, &lc); + + double libreParPhaseW = qMax(0.0, marge.watts); + for (LoadAction &a : slot.actions) { + if (!OptimizerPlan::estActionOptimiseur(a) || m_shedActions.contains(a.loadId)) + continue; + const LoadContext *lc = charges.value(a.loadId, nullptr); + if (!lc || !lc->telemetry.available) + continue; + const bool borne = lc->adapter == QLatin1String("evcharger"); + const int phases = qMax(1, lc->declared.phases); + + // Ce que la charge TIENT déjà, et qu'on ne lui retire pas ici. + double engageW = qMax(0.0, lc->telemetry.currentPowerW); + if (borne) + if (const std::optional commandee = puissanceCommandee(a.loadId)) + engageW = qMax(0.0, *commandee); + + // Ce que l'action poserait, écrêtée au matériel comme l'adaptateur le fera. Pour une borne, + // le matériel est son courant maximal sur ses phases : `declared.maxPowerW` est l'enveloppe + // de la cascade, que l'EvAdapter ne réapplique pas. + const double materielW = borne ? lc->declared.maxA * 230.0 * phases : lc->declared.maxPowerW; + double demandeW = 0; + if (a.kind == LoadAction::Setpoint) + demandeW = materielW > 0 ? qMin(a.powerW, materielW) : a.powerW; + else if (a.kind == LoadAction::State) + demandeW = lc->declared.estimatedPowerW.value(a.state, 0.0); + else + continue; + + const double plafondW = engageW + libreParPhaseW * phases; + if (demandeW <= plafondW + 0.5) { + libreParPhaseW = qMax(0.0, libreParPhaseW - qMax(0.0, demandeW - engageW) / phases); + continue; + } + + double accordeW = plafondW; + if (a.kind == LoadAction::Setpoint) { + if (borne) // l'adaptateur arrondit au plus proche : on lui donne un ampère entier + accordeW = std::floor(plafondW / (230.0 * phases) + 1e-6) * 230.0 * phases; + a.powerW = accordeW; + a.estimatedPowerW = accordeW; + } else { + // L'état le plus haut qui tient sous le plafond. Les états 1 et 2 valent 0 W : c'est 2 + // qui est retenu, jamais 1 (ECS-413). + int etat = 2; + for (int e = 3; e <= 4; ++e) + if (lc->declared.estimatedPowerW.value(e, 0.0) <= plafondW + 0.5) + etat = e; + a.state = etat; + accordeW = lc->declared.estimatedPowerW.value(etat, 0.0); + } + libreParPhaseW = qMax(0.0, libreParPhaseW - qMax(0.0, accordeW - engageW) / phases); + + // DÉFAUT 5 — le motif est celui du mécanisme qui a produit l'issue, pas celui que + // l'optimiseur avait donné à sa demande. Le niveau unique `optimizer` reste : c'est + // l'optimiseur qui a décidé, la sécurité qui a borné. + a.reason = { DecisionCode::DrawCap, drawCapReasonParams(marge, false, 0, false) }; + a.clamped = true; + if (!a.levels.isEmpty()) + a.levels.first().reason = a.reason; + qCInfo(dcNymeaEnergy()).noquote() + << "[Arbitre] Garde de soutirage" << a.loadId << "— l'optimiseur demandait" + << qRound(demandeW) << "W, la charge tient" << qRound(engageW) << "W, accordé" + << qRound(accordeW) << "W |" << renderFr(a.reason, lc->label); + } +} + void EnergyArbitrator::fusionnerDelestageDansSlot(Slot &slot) const { for (auto it = m_shedActions.constBegin(); it != m_shedActions.constEnd(); ++it) { diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index ce3a69e..5504db9 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -803,6 +803,39 @@ public: */ void shedWaterfallForOverload(const QDateTime &now); + /*! + * \brief Garde de soutirage : une consigne d'OPTIMISEUR ne fait monter une charge que de la + * marge que le compteur laisse sous le seuil (lot délestage triphasé, défauts 4 et 5). + * + * \param slot Créneau du cycle, modifié en place AVANT le dispatch. Seules les actions + * d'optimiseur (niveau unique \c optimizer) sont lues ; celles que L4 revendique ce cycle + * (\c m_shedActions) sont laissées au délestage. + * \param ctx Contexte du cycle : \c drawMargin (seuil, phase la plus chargée, signée) et + * la télémétrie des charges. + * + * \par Pourquoi + * La marge de soutirage bornait la cascade du rule-based, et RIEN d'autre : un optimiseur qui + * demande le maximum voyait ses consignes écrêtées au matériel seulement. Relevé le 2026-10-27 : + * la protection déleste, le compteur repasse juste sous la limite, le délestage se relâche, + * l'optimiseur redemande 3 kW, et la surcharge reprend une minute. La règle de sécurité était + * celle du disjoncteur ; elle devient celle du moteur, quel que soit le décideur. + * + * \par Contrat + * - Seule une HAUSSE est bornée, jamais une baisse : réduire est le geste de la protection, et + * la garde ne réduit rien sous ce que la charge tient déjà. Sous un compteur en dépassement + * (marge négative), la charge garde ce qu'elle tient. + * - Plafond = engagé + marge libre × phases de la charge. L'engagé d'une borne est sa dernière + * commande TENUE (la protection l'a peut-être rabaissée dans ce même cycle) ; sinon sa mesure. + * Celui des autres charges est leur télémétrie. + * - La marge est la pire phase, et chaque hausse servie la consomme pour les suivantes, dans + * l'ordre du créneau. + * - Une consigne écrêtée porte \c DRAW_CAP (le mécanisme qui a produit l'issue, règle 7-b) et + * \c clamped ; une borne est écrêtée à l'ampère entier inférieur, une PAC à l'état le plus haut + * qui tient (jamais 1). + * - Sans plafond en vigueur (limite non réglée), rien n'est borné. + */ + void bornerOptimiseurAuSoutirage(Slot &slot, const SurplusContext &ctx); + //! \brief Replace dans \c slot les décisions des charges délestées par L4. //! \param[in,out] slot Actions du cycle. Une charge délestée porte le motif du DÉLESTAGE : //! le plan ne l'a pas commandée, publier sa consigne montrerait ce qui n'a pas eu lieu. diff --git a/energyplugin/etm/scheduler/optimizerplan.cpp b/energyplugin/etm/scheduler/optimizerplan.cpp index dab0ea8..edbb257 100644 --- a/energyplugin/etm/scheduler/optimizerplan.cpp +++ b/energyplugin/etm/scheduler/optimizerplan.cpp @@ -555,7 +555,9 @@ QList finaliser(Plan &plan, Slot &slot, const QHash 0 && borneApres < 16, qUtf8Printable(QString::number(borneApres)));