moteur : le tirage d'une borne non mesurée par phase est rendu à ses phases (V2C)

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 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-27 14:56:55 +01:00
parent 3084b885d8
commit 70cdaed481
3 changed files with 174 additions and 38 deletions

View File

@ -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<EnergyArbitrator *>(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<DrawCap> 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<EnergyArbitrator *>(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<EnergyArbitrator *>(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<double> sansLaBorne = phaseDrawW(masque, phasePowerLimit());
const QList<double> avecLaBorne = phaseDrawW(masque, phasePowerLimit());
plafond.depasse = resolveDrawMargin(plafonds, avecLaBorne, meter->currentPower()).watts < 0;
QList<double> 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

View File

@ -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 &currentDateTime) 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.
*

View File

@ -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> 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<QList<int>> toutesConsignes;
QList<QList<double>> toutesPires;
QStringList traces;
for (const Cas &c : cas) {
ScenarioHc s = preparerScenarioHc(QStringLiteral("jour-phase-%1-%2").arg(static_cast<int>(c.borne)).arg(c.maisonA),
ScenarioHc s = preparerScenarioHc(QStringLiteral("jour-phase-%1-%2").arg(static_cast<int>(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());