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)));