From 70cdaed4812856bed2a84de8fb133137797f8fde Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Tue, 27 Oct 2026 14:56:55 +0100 Subject: [PATCH] =?UTF-8?q?moteur=20:=20le=20tirage=20d'une=20borne=20non?= =?UTF-8?q?=20mesur=C3=A9e=20par=20phase=20est=20rendu=20=C3=A0=20ses=20ph?= =?UTF-8?q?ases=20(V2C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une borne qui ne publie pas ses courants par phase (V2C Trydan : currentL1..3) avait meteredPhases() vide, et le plafond d'heures creuses ne lui rendait rien de ce qu'elle tire : il la lisait comme une charge de la maison sur sa phase, et la consigne alternait chaque nuit (16, 6, 13, 6 A sous 25 A et 10 % de marge ; 6 A tenus avec 8 A de plus sur A). evAttributableDraw() : le currentPower de la borne est attribué aux phases que le moteur lui suppose déjà (phasesForCount(), la phase A en monophasé, comme chargerPhaseKey() pour L4), un tiers par phase en triphasé. Seulement si c'est sûr : aucun état par phase publié, autant de phases annoncées que visées (pas de triphasée avec voiture monophasée, pas de bascule), une ou trois phases. Sinon rien, comme avant. Question 5 du lot délestage triphasé : hors heures creuses, evPhaseAllowanceW() rend aussi ce tirage, moins 1 A, hors dépassement (16, 9, 16, 9 A → 16 A tenus). En dépassement, la place libre reste la règle. Le calcul est mis en commun avec evOffPeakAllowanceW() (evCeilingGivingBackDraw()). run : le scénario 1 phase 32 A alternait 19/20 A sous la limite de phase et tient 20-21 A ; ses points de charge passent de 80/85/87 à 81/86/88 % (88 est la valeur de l'amont), 100 % à l'échéance inchangé. Co-Authored-By: Claude Opus 5.5 --- energyplugin/etm/energyarbitrator.cpp | 104 +++++++++++++++++++++----- energyplugin/etm/energyarbitrator.h | 73 +++++++++++++++--- tests/auto/simulation/simulation.cpp | 35 ++++++--- 3 files changed, 174 insertions(+), 38 deletions(-) diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 9df8ff0..38c39de 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -1581,7 +1581,20 @@ double EnergyArbitrator::evPhaseAllowanceW(EvCharger *ev, uint phaseCount) const // marge PORTE le dépassement (elle vaut -1200 quand on dépasse de 1200 W) parce que // l'étape 4 en a besoin pour dire de combien délester. Ce qu'une borne peut prendre // n'est jamais négatif ; ce qu'il faut retirer à la maison, si. - return qMax(0.0, marge.watts) * phaseCount; + const double libreW = qMax(0.0, marge.watts) * phaseCount; + + // Lot délestage V2C (question 5 du lot délestage triphasé) — HORS DÉPASSEMENT, la place libre + // seule fait alterner une borne qui tient une bonne part de sa phase : pleine, la place tombe ; + // au plancher, elle remonte (16, 9, 16, 9 A sous 25 A et 10 % de marge, maison à 4 A). Le + // plafond qui lui rend son tirage, moins 1 A, se pose en un cycle — c'est la règle des heures + // creuses (lot e2, D8). EN DÉPASSEMENT, la place libre reste la règle, et la raison de sécurité + // ci-dessus avec elle : on ne rend rien à une borne sur une phase au-dessus du seuil. + // Le MAXIMUM des deux garde la règle d'avant quand le tirage n'est pas attribuable (le plafond + // rendu vaut alors la place libre moins 1 A) ou quand la borne tire moins d'un ampère. + const PlafondRendu rendu = evCeilingGivingBackDraw(ev, phaseCount); + if (!rendu.attribuable || rendu.depasse || rendu.watts < 0) + return libreW; + return qMax(libreW, rendu.watts); } namespace { @@ -1593,38 +1606,90 @@ constexpr double MargeHeuresCreusesA = 1.0; double EnergyArbitrator::evOffPeakAllowanceW(EvCharger *ev, uint phaseCount) const { + return evCeilingGivingBackDraw(ev, phaseCount).watts; +} + +EnergyArbitrator::TirageBorne EnergyArbitrator::evAttributableDraw(EvCharger *ev, uint phaseCount) const +{ + TirageBorne tirage; + if (!ev || phaseCount == 0) + return tirage; + + // Ce que la borne tire, MESURÉ. Pas la consigne : une voiture qui prend 8 A d'une borne + // commandée à 16 n'occupe que 8 A de la phase, et lui rendre 16 ouvrirait 8 A qu'elle peut + // prendre d'un coup. Une borne sans mesure ne rend rien. + if (ev->thing()->thingClass().stateTypes().findByName("currentPower").id().isNull()) + return tirage; + + const Electricity::Phases mesurees = ev->meteredPhases(); + if (mesurees != Electricity::PhaseNone) { + // Rendre sur une phase que la borne n'occupe pas créerait de la place qui n'existe pas — + // et la borne, en la prenant ailleurs, ne la refermerait jamais. Pendant une bascule + // (phases mesurées ≠ phases visées), on ne rend donc rien. + if (Electricity::getPhaseCount(mesurees) != phaseCount) + return tirage; + tirage.phases = mesurees; + } else { + // Lot délestage V2C — une borne qui ne publie PAS ses courants par phase (le V2C Trydan + // publie `currentL1..3`, que l'interface ne connaît pas). meteredPhases() est vide, et + // jusqu'ici rien n'était rendu : le plafond lisait la borne comme une charge de la maison + // sur sa phase, et la consigne alternait chaque nuit (16, 6, 13, 6 A sous 25 A). + // + // Ses phases sont celles que le moteur lui suppose DÉJÀ — phasesForCount(), la phase A en + // monophasé : c'est le masque de ce plafond, celui de L4 (chargerPhaseKey()) et celui de + // l'amont (prepareInformation()). Attribuer son tirage ailleurs ferait deux hypothèses. + // + // Seulement quand c'est sûr : + // - une borne qui publie des états par phase, tous nuls alors qu'elle tire, a une mesure + // qui se contredit — elle n'est pas « non mesurée » ; + // - elle doit annoncer autant de phases que le cycle en vise. Une triphasée qui charge + // une voiture monophasée (le cycle vise 1, elle annonce 3), ou une borne en cours de + // bascule, tire sur des phases que rien ne dit ; + // - une ou trois phases : la monophasée sur sa phase, la triphasée un tiers par phase. + // En biphasé, le masque A+B est une convention de l'amont, pas une observation : rien + // n'est rendu (décision du 2026-10-27 : mono et tri seulement). + for (const char *etat : {"currentPhaseA", "currentPhaseB", "currentPhaseC", + "currentPowerPhaseA", "currentPowerPhaseB", "currentPowerPhaseC"}) { + if (ev->thing()->hasState(etat)) + return tirage; + } + if ((phaseCount != 1 && phaseCount != 3) || ev->phaseCount() != phaseCount) + return tirage; + tirage.phases = const_cast(this)->phasesForCount(phaseCount); + } + tirage.watts = qMax(0.0, ev->currentPower()); + return tirage; +} + +EnergyArbitrator::PlafondRendu EnergyArbitrator::evCeilingGivingBackDraw(EvCharger *ev, uint phaseCount) const +{ + PlafondRendu plafond; RootMeter *meter = internalRootMeter(); if (!ev || phaseCount == 0 || !meter) - return -1; + return plafond; const QList plafonds = drawCaps(); if (plafonds.isEmpty()) - return -1; + return plafond; - // Ce que la borne tire, MESURÉ, et sur quelles phases. Pas la consigne : une voiture qui - // prend 8 A d'une borne commandée à 16 n'occupe que 8 A de la phase, et lui rendre 16 - // ouvrirait 8 A qu'elle peut prendre d'un coup. Une borne sans mesure ne rend rien. - const bool mesuree = !ev->thing()->thingClass().stateTypes().findByName("currentPower").id().isNull(); - const Electricity::Phases tirees = mesuree ? ev->meteredPhases() : Electricity::PhaseNone; - // Rendre sur une phase que la borne n'occupe pas créerait de la place qui n'existe pas — et - // la borne, en la prenant ailleurs, ne la refermerait jamais. Pendant une bascule (phases - // mesurées ≠ phases visées), on ne rend donc rien. - const bool attribuable = tirees != Electricity::PhaseNone - && Electricity::getPhaseCount(tirees) == phaseCount; - const Electricity::Phases masque = attribuable - ? tirees : const_cast(this)->phasesForCount(phaseCount); - const double tireW = attribuable ? qMax(0.0, ev->currentPower()) : 0.0; + const TirageBorne tirage = evAttributableDraw(ev, phaseCount); + plafond.attribuable = tirage.phases != Electricity::PhaseNone; + const Electricity::Phases masque = plafond.attribuable + ? tirage.phases : const_cast(this)->phasesForCount(phaseCount); + const double tireW = plafond.attribuable ? tirage.watts : 0.0; // Le tirage de la maison SANS la borne, phase par phase, écrêté à zéro : une borne qui // annoncerait plus que ce que le compteur voit ne se fabrique pas de place au-delà de la // limite. Au mieux, une phase vaut sa limite. - QList sansLaBorne = phaseDrawW(masque, phasePowerLimit()); + const QList avecLaBorne = phaseDrawW(masque, phasePowerLimit()); + plafond.depasse = resolveDrawMargin(plafonds, avecLaBorne, meter->currentPower()).watts < 0; + QList sansLaBorne = avecLaBorne; for (double &w : sansLaBorne) w = qMax(0.0, w - tireW / phaseCount); const DrawMargin marge = resolveDrawMargin(plafonds, sansLaBorne, qMax(0.0, meter->currentPower() - tireW)); if (!marge.borne) - return -1; + return plafond; // Moins la marge, à l'ampère INFÉRIEUR : l'adaptateur arrondit la consigne au plus proche, // et un plafond de 13,6 A deviendrait 14 A — la marge serait mangée de moitié. @@ -1634,7 +1699,8 @@ double EnergyArbitrator::evOffPeakAllowanceW(EvCharger *ev, uint phaseCount) con // vaut limite − (I − E) − 1 < E − 1. La consigne descend d'au moins 1 A par cycle, jusqu'à // sortir du dépassement ou jusqu'au plancher, où L4 décide. const double parPhaseA = std::floor(marge.watts / 230.0 - MargeHeuresCreusesA); - return qMax(0.0, parPhaseA) * 230.0 * phaseCount; + plafond.watts = qMax(0.0, parPhaseA) * 230.0 * phaseCount; + return plafond; } bool EnergyArbitrator::evCommandedByWaterfall(EvCharger *evCharger) const diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index 5504db9..564c625 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -917,12 +917,17 @@ public: * waterfall pourrait allouer un surplus que le branchement ne supporte pas, et la seule * chose qui rattraperait serait \c verifyOverloadProtection() **après coup**. * - * \note Le plafond est la place LIBRE, et on n'y rend **pas** ce que la borne tire déjà. - * C'est délibéré et c'est de sécurité : sur une phase en dépassement la marge est - * négative, et c'est ce signe qui ramène la borne à son minimum. Rendre sa propre + * \note EN DÉPASSEMENT, le plafond est la place LIBRE, et on n'y rend **pas** ce que la + * borne tire déjà. C'est délibéré et c'est de sécurité : sur une phase en dépassement la + * marge est négative, et c'est ce signe qui ramène la borne à son minimum. Rendre sa propre * consommation lui rendrait exactement la puissance qui cause le dépassement - * (`testOverloadProtectionEcoMode`). La formulation inverse figurait ici et était - * fausse : le code ne l'a jamais fait. + * (`testOverloadProtectionEcoMode`, dont le compteur ne voit pas la borne baisser). + * \note HORS DÉPASSEMENT (lot délestage V2C, question 5), le plafond vaut au moins celui qui + * rend à la borne son tirage attribuable, moins 1 A (\c evCeilingGivingBackDraw()). Sans + * lui, une borne qui tient une bonne part de sa phase alternait : pleine, la place libre + * tombait ; au plancher, elle remontait (16, 9, 16, 9 A sous 25 A et 10 % de marge). Le + * maximum des deux garde la règle d'avant quand rien n'est attribuable, ou quand la borne + * tire moins d'un ampère. * \note Valeur de CYCLE : elle dépend de ce que le reste de la maison consomme à cet * instant. Elle est donc relue à chaque \c toLoadContext(), jamais mémorisée. */ @@ -945,9 +950,11 @@ public: * * \par Contrat * - Le courant rendu est MESURÉ sur la borne (état \c currentPower de sa classe), jamais - * déduit de sa consigne. Il n'est rendu que sur les phases où la borne le mesure, et - * seulement si elles sont aussi nombreuses que les phases visées. Sinon — borne sans - * mesure, à l'arrêt, ou qui bascule — rien n'est rendu, et la marge s'applique quand même. + * déduit de sa consigne. Il n'est rendu que sur les phases où la borne le tire, quand c'est + * sûr (\c evAttributableDraw()) : mesurées, et aussi nombreuses que les phases visées ; ou, + * pour une borne qui ne publie pas ses phases, celles que le moteur lui suppose (lot + * délestage V2C). Sinon — borne sans mesure, à l'arrêt, ou qui bascule — rien n'est rendu, + * et la marge s'applique quand même. * - Une phase ne peut pas valoir plus que sa limite, moins la marge : ce que la maison tire * sans la borne est écrêté à zéro. Une borne qui annoncerait plus que ce que le compteur * voit ne se fabrique donc pas de place au-delà de la limite. @@ -960,7 +967,8 @@ public: * mange jamais la marge. * * \note Réservée aux heures creuses et à la pleine puissance du lot e2. Hors de là, - * \c evPhaseAllowanceW() reste la règle, et les décisions sans tarif ne changent pas. + * \c evPhaseAllowanceW() reste la règle ; depuis le lot délestage V2C, elle rend elle aussi + * le tirage hors dépassement. */ double evOffPeakAllowanceW(EvCharger *ev, uint phaseCount) const; @@ -1043,6 +1051,53 @@ protected: const QDateTime ¤tDateTime) override; private: + //! Lot délestage V2C — ce qu'une borne tire, et sur quelles phases. \c phases vaut + //! \c PhaseNone quand l'attribution n'est pas sûre : rien n'est alors rendu. + struct TirageBorne { + Electricity::Phases phases = Electricity::PhaseNone; + double watts = 0; + }; + + /*! + * \brief Lot délestage V2C — le tirage MESURÉ d'une borne, attribué à ses phases quand c'est sûr. + * + * \param ev Borne. \param phaseCount Nombre de phases VISÉ par le cycle. + * \return Les phases et la puissance (\c currentPower de la borne, jamais sa consigne) ; un + * tirage vide si la borne n'a pas de \c currentPower ou si l'attribution n'est pas sûre. + * + * \par Deux cas + * - Borne MESURÉE par phase (\c meteredPhases() non vide) : ses phases mesurées, si elles + * sont aussi nombreuses que les phases visées. Sinon elle bascule, et rien n'est attribué. + * C'est la règle du lot e2, inchangée. + * - Borne NON MESURÉE par phase (aucun état \c currentPhaseX ni \c currentPowerPhaseX : le + * V2C Trydan publie \c currentL1..3) : les phases que le moteur lui suppose déjà + * (\c phasesForCount() — la phase A en monophasé, comme \c chargerPhaseKey() pour L4 et + * \c prepareInformation() pour l'amont), à condition que la borne annonce autant de phases + * que le cycle en vise (\c EvCharger::phaseCount()), et que ce soit une ou trois. Une + * triphasée qui charge une voiture monophasée, ou une borne en cours de bascule, n'est donc + * pas attribuée. + * \note Une borne qui PUBLIE des courants par phase, tous nuls alors qu'elle tire, n'est pas + * « non mesurée » : sa mesure se contredit, et rien n'est attribué. + * \note L'hypothèse de la phase A est celle de tout le moteur. Une monophasée câblée sur une + * autre phase la met en défaut, ici comme dans L4 : c'est le plugin qui peut la lever, en + * publiant ses courants par phase. + */ + TirageBorne evAttributableDraw(EvCharger *ev, uint phaseCount) const; + + //! Lot délestage V2C — le plafond d'une borne qui lui rend ce qu'elle tire (règle du lot e2). + struct PlafondRendu { + double watts = -1; //!< Plafond en W, multiple de 230 W par phase ; −1 sans limite. + bool depasse = false; //!< Une phase de la borne, borne comprise, est au-dessus du seuil. + bool attribuable = false;//!< Le tirage de la borne a été rendu (\c evAttributableDraw()). + }; + + /*! + * \brief Lot délestage V2C — limite − (tirage de la phase − tirage attribué à la borne) − 1 A, + * à l'ampère inférieur, × phases. Le calcul d'\c evOffPeakAllowanceW(), mis en commun avec + * \c evPhaseAllowanceW() ; son contrat est écrit sur la première. + */ + PlafondRendu evCeilingGivingBackDraw(EvCharger *ev, uint phaseCount) const; + /*! * \brief Construit le SurplusContext §5 : meter brut + loads EV + SG-Ready. * diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 7830fbc..8b13b45 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -2560,16 +2560,22 @@ void Simulation::run_data() }, { 520, { + // [ETM lot délestage V2C] 80 % → 81 % (et 85 → 86 au pas 700, 87 → 88 + // au pas 900). Vers 10:10 UTC la borne est bornée par la limite de + // phase (32 A) et le compteur la voit : sa place libre alternait 19 et + // 20 A d'un cycle à l'autre. Le plafond lui rend désormais son tirage + // mesuré hors dépassement (evPhaseAllowanceW()) ; elle tient 20-21 A, + // borné par le surplus, et la voiture prend un point d'avance. SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6) , SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false), - SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 80) + SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 81) } }, { 700, { SimulationTestPoint(SimulationTestPoint::TestTypeMaxChargingCurrent, 6) , SimulationTestPoint(SimulationTestPoint::TestTypeCharging, false), - SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 85) + SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 86) } }, { @@ -2579,8 +2585,10 @@ void Simulation::run_data() // l'écart s'accumule sur 900 pas de rétroaction. Ce qui compte est // au pas 960, et c'est inchangé : la voiture est à 100 % à // l'échéance. Les 14 autres scénarios sont identiques au bit près. + // [ETM lot délestage V2C] 87 % → 88 %, la valeur de l'amont : le point + // gagné vers 10:10 (cf. pas 520) se retrouve ici. SimulationTestPoint(SimulationTestPoint::TestTypeCharging, true), - SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 87) + SimulationTestPoint(SimulationTestPoint::TestTypeStateOfCharge, 88) } }, { @@ -12866,19 +12874,27 @@ void Simulation::testWaterfallChargerSettlesUnderThePhaseLimit() // (déjà net du solaire de A). Limite 25 A, marge 10 % (seuil 22,5 A). Le surplus offert à la // borne vaut 2E − H ≥ 16 A : c'est la phase qui borne, jamais le surplus. // Attendu : 22,5 − H − 1 à l'ampère inférieur, plafonné à 16 A. + // + // La maison chargée tire 10,3 A, et non 10 : au premier cycle la borne est à l'arrêt, rien ne + // lui est rendu, et son plafond est la place libre ARRONDIE AU PLUS PROCHE par l'adaptateur + // (règle d'avant ce lot). À 10 A, 12,5 A deviennent 13 A, la phase passe à 23 A pour un seuil + // de 22,5 A, et L4 rabaisse d'un ampère. En production, la consigne se pose ensuite à 11 A ; + // ici, le harnais rappelle la protection sur le MÊME compteur (simulationCallUpdate()) et + // coupe la borne déjà au plancher, qui repart au cycle suivant — une boucle du harnais, pas + // du moteur. À 10,3 A, 12,2 A deviennent 12 A, sous le seuil, et la règle se lit seule. FuseauDeTest fuseau("Europe/Paris"); struct Cas { const char *quoi; BorneHc borne; double maisonA; double exportA; int attenduA; }; const QList cas = { - {"mesurée, maison légère", BorneHc::Monophasee, 4, 12, 16}, - {"mesurée, maison chargée", BorneHc::Monophasee, 10, 14, 11}, - {"non mesurée, maison chargée", BorneHc::MonophaseeNonMesuree, 10, 14, 11}, + {"mesurée, maison légère", BorneHc::Monophasee, 4, 12, 16}, + {"mesurée, maison chargée", BorneHc::Monophasee, 10.3, 14, 11}, + {"non mesurée, maison chargée", BorneHc::MonophaseeNonMesuree, 10.3, 14, 11}, }; constexpr int Cycles = 10; QList> toutesConsignes; QList> toutesPires; QStringList traces; for (const Cas &c : cas) { - ScenarioHc s = preparerScenarioHc(QStringLiteral("jour-phase-%1-%2").arg(static_cast(c.borne)).arg(c.maisonA), + ScenarioHc s = preparerScenarioHc(QStringLiteral("jour-phase-%1-%2").arg(static_cast(c.borne)).arg(qRound(c.maisonA * 10)), BatterieHc::Aucune, QVariantList(), c.borne); QVERIFY2(s.arb && s.borne, c.quoi); QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 25}, {"phaseSafetyMarginPercent", 10}})) @@ -12906,9 +12922,8 @@ void Simulation::testWaterfallChargerSettlesUnderThePhaseLimit() } for (int i = 0; i < cas.size(); ++i) { - // Les deux premiers cycles posent la borne (le premier juge une borne à l'arrêt, et - // l'arrondi au plus proche de la place libre peut franchir le seuil d'un demi-ampère, que - // L4 reprend). Ensuite la consigne TIENT, sous le seuil moins 1 A. + // Les deux premiers cycles posent la borne : le premier juge une borne à l'arrêt, qui + // n'a rien à se voir rendre. Ensuite la consigne TIENT, sous le seuil moins 1 A. for (int k = 2; k < Cycles; ++k) { const QByteArray ctx = QStringLiteral("%1 — cycle %2 : %3").arg(cas.at(i).quoi).arg(k).arg(traces.at(i)).toUtf8(); QVERIFY2(toutesConsignes.at(i).at(k) == cas.at(i).attenduA, ctx.constData());