Heures creuses : le plafond de la borne lui rend ce qu'elle tire, moins 1 A (lot e2, suite)

Près de la limite de phase, la consigne d'une borne à pleine puissance
alternait à chaque cycle : le plafond était la place libre au compteur,
sans lui rendre sa propre consommation. Maison à 2 A par phase, borne
16 A : 16, 7, 16, 7 A sous 25 A ; 16, 14 sous 32 A ; 14, 6, 8, 6 sous 16 A.

En heures creuses, et pour la pleine puissance du lot e2 seulement, le
plafond est lu par EnergyArbitrator::evOffPeakAllowanceW() : limite moins
le tirage de la phase sans la borne, moins 1 A, à l'ampère inférieur. La
règle de l'échéance chez l'amont (adjustEvChargers()). Elle converge en
un cycle : 16 A tenus sous 25 et 32 A, 13 A sous 16 A.

- Courant rendu MESURÉ (currentPower), sur les phases où la borne le
  mesure ; rien rendu pendant une bascule ou sans mesure.
- Tirage de la maison sans la borne écrêté à zéro : une borne qui
  annonce plus que ce que le compteur voit reste à 1 A sous la limite.
- En dépassement, le plafond vaut au plus le courant mesuré moins 1 A :
  il baisse à chaque cycle, jusqu'au plancher, où L4 décide.
- Hors heures creuses et sans tarif : evPhaseAllowanceW(), inchangée.

Tests : testOffPeakFullPowerSettlesUnderThePhaseLimit (sept cas, douze
cycles) et testOffPeakFullPowerStaysUnderTheLimitOnABlindMeter, nouveaux ;
testOffPeakFullPowerYieldsToThePhaseLimit réécrit avec un compteur qui
voit la borne (mesure et non consigne, dépassement, plancher, L4) ;
testBlockedDischargeShowsAtTheMeterAndL4Decides : en dépassement, la
borne finit au plancher (L4 16 → 13 A, stratégie 9 A, L4 6 A) au lieu
d'être coupée.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-25 07:15:30 +01:00
parent d62365fe3d
commit 679e64b5b3
6 changed files with 344 additions and 30 deletions

View File

@ -129,7 +129,13 @@ LoadContext EvAdapter::toLoadContext(const QDateTime &now) const
// APRÈS coup — c'est-à-dire après avoir commandé. Le plafond doit donc être DÉCLARÉ, comme
// n'importe quelle borne d'adaptateur (règle 4).
ctx.declared.maxPowerW = m_charger->maxChargingCurrentMaxValue() * 230.0 * m_targetPhases;
const double plafondPhaseW = m_parent->evPhaseAllowanceW(m_charger, m_targetPhases);
// Lot e2, suite (2026-10-25) — EN HEURES CREUSES, le plafond rend à la borne ce qu'elle tire,
// moins 1 A par phase. Sans ce retour, à pleine puissance, sa propre consommation lui
// retirait la place au cycle suivant et la lui rendait au cycle d'après : 16, 7, 16, 7 A sous
// une limite de 25 A, avec une maison à 2 A. Hors heures creuses, la règle d'avant.
const double plafondPhaseW = pleinePuissanceHc
? m_parent->evOffPeakAllowanceW(m_charger, m_targetPhases)
: m_parent->evPhaseAllowanceW(m_charger, m_targetPhases);
if (plafondPhaseW >= 0) {
// …mais JAMAIS sous le plancher de la borne. C'est la règle de l'amont, reprise mot
// pour mot (`if (newValueInt <= minValue) newValueInt = minValue`), et la répartition

View File

@ -54,6 +54,9 @@ public:
* d'heures creuses (\c needs.offPeakMinPowerW) est le PLAFOND du cycle
* (\c declared.maxPowerW : courant maximal, phases, limite de phase), jamais sous le
* plancher ; et le nombre de phases se choisit sur ce plafond, plus sur le surplus.
* \note **Lot e2, suite** — dans ces mêmes conditions, la limite de phase est lue par
* \c EnergyArbitrator::evOffPeakAllowanceW() : la place libre plus le courant MESURÉ de la
* borne, moins 1 A par phase. Hors heures creuses, \c evPhaseAllowanceW(), inchangée.
*/
LoadContext toLoadContext(const QDateTime &now) const override;

View File

@ -1212,6 +1212,9 @@ double EnergyArbitrator::evPhaseAllowanceW(EvCharger *ev, uint phaseCount) const
// qui ramène la borne à son minimum. Avec le retour, elle se verrait autoriser ce qu'elle
// consomme déjà — c'est-à-dire précisément la puissance qui cause le dépassement.
// (`testOverloadProtectionEcoMode` : 16 A ramenés à 6 A ; avec le retour, 9 A maintenus.)
// Ce test a un compteur qui ne voit pas la borne baisser : le retour y est un trou. En heures
// creuses, evOffPeakAllowanceW() rend le courant MESURÉ moins une marge, et sa propre preuve
// de baisse en dépassement suppose que le compteur voie la borne.
//
// L'écrêtage à zéro est fait ICI, chez le consommateur, et non dans la résolution : la
// marge PORTE le dépassement (elle vaut -1200 quand on dépasse de 1200 W) parce que
@ -1220,6 +1223,59 @@ double EnergyArbitrator::evPhaseAllowanceW(EvCharger *ev, uint phaseCount) const
return qMax(0.0, marge.watts) * phaseCount;
}
namespace {
//! Marge du plafond d'heures creuses, par phase. Celle de l'amont pour l'échéance
//! (`adjustEvChargers()`), pour la même raison : ne pas coller à la limite, et laisser à un
//! appareil moyen allumé entre deux cycles la place de démarrer avant que L4 n'ait à couper.
constexpr double MargeHeuresCreusesA = 1.0;
}
double EnergyArbitrator::evOffPeakAllowanceW(EvCharger *ev, uint phaseCount) const
{
RootMeter *meter = internalRootMeter();
if (!ev || phaseCount == 0 || !meter)
return -1;
const QList<DrawCap> plafonds = drawCaps();
if (plafonds.isEmpty())
return -1;
// 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;
// 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());
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;
// 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é.
//
// En dépassement, ce plafond BAISSE, et c'est la raison de sécurité de evPhaseAllowanceW()
// qui tient toujours : si la phase tire I > limite, dont E pour la borne, la place rendue
// 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;
}
bool EnergyArbitrator::evCommandedByWaterfall(EvCharger *evCharger) const
{
return evCharger && m_waterfallEvIds.contains(evCharger->thing()->id().toString());

View File

@ -842,6 +842,42 @@ public:
*/
double evPhaseAllowanceW(EvCharger *ev, uint phaseCount) const;
/*!
* \brief Lot e2, suite — plafond d'une borne À PLEINE PUISSANCE EN HEURES CREUSES : la place
* libre, plus ce que la borne tire, moins une marge de 1 A par phase.
*
* \param ev Borne. \param phaseCount Nombre de phases VISÉ.
* \return Plafond en watts, multiple de 230 W par phase, jamais négatif ; ou **−1 si aucune
* limite** (même convention que \c evPhaseAllowanceW()).
*
* \par Pourquoi
* \c evPhaseAllowanceW() ne rend pas à la borne ce qu'elle tire. À pleine puissance, et avec
* un compteur qui voit la borne, sa propre consommation lui retire donc la place au cycle
* suivant, puis la lui rend au cycle d'après : 16, 7, 16, 7 A sous une limite de 25 A. C'est
* la règle de l'échéance chez l'amont (\c adjustEvChargers() : marge − 1 A + courant de la
* borne) qui la fait se poser.
*
* \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.
* - 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.
* - **En dépassement, le plafond baisse** : sur une phase au-dessus de la limite, il vaut au
* plus le courant mesuré de la borne moins 1 A. Il redescend donc à chaque cycle, jusqu'à
* sortir du dépassement ou jusqu'au plancher — où la stratégie tient la borne et où
* \c verifyOverloadProtection() (L4) décide. Cela suppose que le compteur voie la borne,
* comme L4 le suppose déjà.
* - Arrondi à l'ampère INFÉRIEUR : la consigne, arrondie au plus proche par l'adaptateur, ne
* 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.
*/
double evOffPeakAllowanceW(EvCharger *ev, uint phaseCount) const;
/*!
* \brief [3g-2] Rang configuré d'une borne dans le waterfall.
*

View File

@ -11131,9 +11131,14 @@ void Simulation::testBlockedDischargeShowsAtTheMeterAndL4Decides()
// Le soutirage monte : la phase A passe à 28 A pour une limite de 25 A. La borne n'est plus
// servie (plafond de soutirage, protection de surcharge) — et la batterie, qui ne voit plus
// de voiture tirer, est rendue au cycle d'après.
//
// Depuis D8 (2026-10-25), le plafond d'heures creuses rend à la borne son courant MESURÉ. L4
// la ramène d'abord de 16 à 13 A ; la stratégie lit 13 A tirés sous 28 A au compteur, et il
// reste 25 − 15 − 1 = 9 A : elle RÉDUIT, elle ne coupe plus d'elle-même. L4 garde le dernier
// mot et retire encore les 3 A du dépassement — la borne finit à son plancher, ou coupée.
phaseA(28);
snap = cycleHc(s, a.addSecs(240), 6440);
QVERIFY2(!s.borne->stateValue("power").toBool() || s.borne->stateValue("maxChargingCurrent").toInt() < 6,
QVERIFY2(!s.borne->stateValue("power").toBool() || s.borne->stateValue("maxChargingCurrent").toInt() <= 6,
qUtf8Printable(QJsonDocument::fromVariant(telemetrieParId(snap).value(borne)).toJson(QJsonDocument::Compact)));
s.borne->setStateValue("currentPower", 0);
phaseA(20);
@ -11459,6 +11464,39 @@ void Simulation::testOffPeakFullPowerSwitchesToThreePhases()
#endif
}
namespace {
//! Le compteur VOIT la borne : chaque phase porte la maison, plus ce que la borne y tire —
//! MESURÉ sur la borne (états par phase du mock), jamais déduit de sa consigne. Rend le total.
double compteurVoitLaBorne(Thing *compteur, Thing *borne, double maisonA)
{
double totalW = 0;
for (const char *ph : {"A", "B", "C"}) {
const double a = maisonA + borne->stateValue(QStringLiteral("currentPhase%1").arg(ph)).toDouble();
compteur->setStateValue(QStringLiteral("currentPhase%1").arg(ph), a);
compteur->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), a * 230);
totalW += a * 230;
}
return totalW;
}
//! Intensité de la phase la plus chargée au compteur.
double pirePhaseA(Thing *compteur)
{
double pire = 0;
for (const char *ph : {"A", "B", "C"})
pire = qMax(pire, compteur->stateValue(QStringLiteral("currentPhase%1").arg(ph)).toDouble());
return pire;
}
//! Consigne de la borne, en ampères — 0 si elle est coupée.
int consigneA(Thing *borne)
{
return borne->stateValue("power").toBool() ? borne->stateValue("maxChargingCurrent").toInt() : 0;
}
} // namespace
void Simulation::testOffPeakFullPowerYieldsToThePhaseLimit()
{
#ifndef ETM_ARBITRATOR
@ -11468,6 +11506,10 @@ void Simulation::testOffPeakFullPowerYieldsToThePhaseLimit()
// autre charge allumée la nuit la RÉDUIT sans que la stratégie la coupe. Couper reste le geste
// de la protection de surcharge (L4), qui garde le dernier mot. Borne triphasée 16 A, limite
// de branchement 25 A par phase.
//
// Le compteur VOIT la borne (lot e2, suite) : chaque phase porte la maison plus ce que la
// borne y tire, mesuré sur elle. C'est la condition où le plafond d'heures creuses lui rend
// son courant, et c'est la seule où la preuve de baisse en dépassement a un sens.
FuseauDeTest fuseau("Europe/Paris");
ScenarioHc s = preparerScenarioHc(QStringLiteral("limite"), BatterieHc::Pilotable, hphcT4(),
BorneHc::Triphasee);
@ -11477,63 +11519,226 @@ void Simulation::testOffPeakFullPowerYieldsToThePhaseLimit()
.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
s.borne->setStateValue("pluggedIn", true);
// Les trois phases à la même intensité : la protection amont lit la PUISSANCE par phase, la
// marge de soutirage le COURANT — les deux sont posés.
const auto phases = [&](double amperes) {
for (const char *ph : {"A", "B", "C"}) {
s.meter->setStateValue(QStringLiteral("currentPhase%1").arg(ph), amperes);
s.meter->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), amperes * 230);
}
};
const auto decision = [&](const QVariantMap &snap) {
return telemetrieParId(snap).value(borne).value("decision").toMap();
};
//! Ce que la borne tire sur chaque phase, mesuré sur elle.
const auto tireA = [&]() { return s.borne->stateValue("currentPhaseA").toDouble(); };
QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(21, 0));
QVariantMap snap;
const auto cycle = [&](double maisonA) {
a = a.addSecs(360); // au-delà des verrous de la borne
snap = cycleHc(s, a, compteurVoitLaBorne(s.meter, s.borne, maisonA));
};
// 1. LA MARGE D'UNE PHASE NE BRIDE PAS UNE BORNE TRIPHASÉE (LM-1500-h). La maison tire 4 A
// par phase : 21 A de marge sur CHAQUE phase, 4 830 W sur la pire. Comparer ces 4 830 W à
// l'enveloppe triphasée la servirait à 7 A ; la borne reçoit ses 11 040 W.
phases(4);
snap = cycleHc(s, a, 2760);
cycle(4);
QCOMPARE(decision(snap).value("code").toString(), QString(DecisionCode::EvOffPeakFullPower));
QCOMPARE(qRound(telemetrieParId(snap).value(borne).value("allocatedW").toDouble()), 11040);
QCOMPARE(s.borne->stateValue("maxChargingCurrent").toInt(), 16);
QVERIFY(!decision(snap).value("params").toMap().contains("boundBy"));
// 2. UNE AUTRE CHARGE s'allume (four, 11 A par phase de plus) : 10 A de marge par phase. La
// borne est RÉDUITE au plafond que la limite laisse — 6 900 W, 10 A — et le motif dit qui.
a = a.addSecs(360); // au-delà des verrous de la borne
phases(15);
snap = cycleHc(s, a, 10350);
// 2. LA MESURE, PAS LA CONSIGNE. La voiture ne prend que 10 A des 16 commandés, et un four
// s'allume : 15 A de maison, 25 A au compteur. La place rendue est celle que la voiture
// OCCUPE — 25 − 15 − 1 = 9 A. Rendre la consigne en aurait offert 15, que la voiture
// pourrait prendre d'un coup : 30 A sur une limite de 25.
for (const char *ph : {"A", "B", "C"}) {
s.borne->setStateValue(QStringLiteral("currentPhase%1").arg(ph), 10.0);
s.borne->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), 2300.0);
}
s.borne->setStateValue("currentPower", 6900.0);
cycle(15);
QCOMPARE(s.borne->stateValue("maxChargingCurrent").toInt(), 9);
QCOMPARE(decision(snap).value("params").toMap().value("boundBy").toString(), QStringLiteral("phaseLimit"));
QCOMPARE(decision(snap).value("params").toMap().value("gridW").toInt(), 6210);
// 3. Le four s'éteint : la borne remonte à sa pleine puissance.
cycle(4);
QCOMPARE(s.borne->stateValue("maxChargingCurrent").toInt(), 16);
QCOMPARE(tireA(), 16.0);
// 4. DÉPASSEMENT — le four se rallume pendant que la borne tire 16 A : 31 A pour 25. Tant
// que la phase dépasse, ce que la STRATÉGIE retient baisse, d'au moins 1 A sous ce que la
// borne tirait, et la consigne finale aussi — L4 a pu trancher plus bas, il garde le dernier
// mot. Hors du dépassement, la consigne se pose à 25 − 15 − 1 = 9 A, sans alterner.
QStringList suite;
QList<int> consignes;
for (int k = 0; k < 5; ++k) {
const double avantA = tireA();
compteurVoitLaBorne(s.meter, s.borne, 15);
const bool depasse = pirePhaseA(s.meter) > 25;
cycle(15);
const int retenuA = qRound(decision(snap).value("params").toMap().value("gridW").toDouble() / 690.0);
consignes << consigneA(s.borne);
suite << QStringLiteral("%1 A tirés%2 → stratégie %3 A, consigne %4 A")
.arg(avantA).arg(depasse ? QStringLiteral(" (dépassement)") : QString())
.arg(retenuA).arg(consignes.last());
if (depasse) {
// Au plancher, la stratégie TIENT la borne (décision du 2026-10-25) : c'est L4 qui coupe.
QVERIFY2(retenuA <= avantA - 1 || retenuA == 6, qUtf8Printable(suite.join(" ; ")));
QVERIFY2(consignes.last() <= avantA - 1, qUtf8Printable(suite.join(" ; ")));
}
}
qInfo().noquote() << "[dépassement]" << suite.join(QStringLiteral(" ; "));
QVERIFY2(consignes.mid(2) == (QList<int>{9, 9, 9}), qUtf8Printable(suite.join(" ; ")));
compteurVoitLaBorne(s.meter, s.borne, 15);
QVERIFY2(pirePhaseA(s.meter) <= 24, qUtf8Printable(suite.join(", ")));
QCOMPARE(decision(snap).value("code").toString(), QString(DecisionCode::EvOffPeakFullPower));
QCOMPARE(decision(snap).value("params").toMap().value("boundBy").toString(), QStringLiteral("phaseLimit"));
QCOMPARE(decision(snap).value("params").toMap().value("gridW").toInt(), 6900);
QCOMPARE(decision(snap).value("params").toMap().value("gridW").toInt(), 6210);
QCOMPARE(decision(snap).value("params").toMap().value("maxW").toInt(), 11040);
QCOMPARE(s.borne->stateValue("maxChargingCurrent").toInt(), 10);
QVERIFY(s.borne->stateValue("power").toBool());
const QString texte = renderFr(DecisionReason(DecisionCode::EvOffPeakFullPower,
decision(snap).value("params").toMap()), "Borne");
QVERIFY2(texte.contains(QStringLiteral("bridée par la protection de surcharge du branchement")),
qUtf8Printable(texte));
// 3. LA MAISON MONTE ENCORE (22 A par phase) : 3 A de marge, sous le plancher triphasé. La
// stratégie tient la borne À SON PLANCHER — 4 140 W, 6 A — et ne la coupe PAS.
a = a.addSecs(360);
phases(22);
snap = cycleHc(s, a, 15180);
// 5. LA MAISON MONTE ENCORE (19 A par phase) : avec la borne au plancher, 25 A au compteur,
// pile la limite, et 5 A de place rendue — sous le plancher triphasé. La stratégie tient la
// borne À SON PLANCHER — 4 140 W, 6 A — et ne la coupe PAS. (Le premier cycle dépasse : la
// borne tire encore 9 A, et L4 tranche ; on lit l'état posé.)
for (int k = 0; k < 3; ++k)
cycle(19);
QCOMPARE(decision(snap).value("params").toMap().value("boundBy").toString(), QStringLiteral("floor"));
QCOMPARE(decision(snap).value("params").toMap().value("gridW").toInt(), 4140);
QCOMPARE(s.borne->stateValue("maxChargingCurrent").toInt(), 6);
QVERIFY2(s.borne->stateValue("power").toBool(), "la stratégie ne coupe jamais : c'est le geste de L4");
compteurVoitLaBorne(s.meter, s.borne, 19);
QCOMPARE(pirePhaseA(s.meter), 25.0);
// 4. LIMITE DE BRANCHEMENT ATTEINTE — la borne à 6 A et la maison à 22 A : 28 A par phase pour
// 6. LIMITE DE BRANCHEMENT ATTEINTE — la borne à 6 A et la maison à 22 A : 28 A par phase pour
// 25. La protection de surcharge (L4) a le dernier mot : 6 − 3 = 3 A, sous le minimum de
// la voiture, elle COUPE — et la stratégie, au même cycle, ne la rallume pas.
a = a.addSecs(360);
phases(28);
snap = cycleHc(s, a, 19320);
cycle(22);
QVERIFY2(!s.borne->stateValue("power").toBool(),
qUtf8Printable(QJsonDocument::fromVariant(telemetrieParId(snap).value(borne)).toJson(QJsonDocument::Compact)));
#endif
}
// ============================================================================================
// Lot e2, suite (2026-10-25) — près de la limite de phase, la consigne se stabilise
// ============================================================================================
void Simulation::testOffPeakFullPowerSettlesUnderThePhaseLimit()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
// L'OSCILLATION mesurée au lot e2 (question 2) : le plafond d'une borne était la place LIBRE
// au compteur, sans lui rendre ce qu'elle tire. Un compteur qui voit la borne fait donc
// alterner la consigne : à 16 A la place libre tombe à 7 A, à 7 A elle remonte à 16 A — sous
// une limite de 25 A, 16, 7, 16, 7… En heures creuses, le plafond lui rend son courant
// MESURÉ, moins 1 A : la consigne se pose, et sous une limite qui ne laisse pas 16 A elle se
// pose sous la limite, à 1 A d'elle.
//
// Maison à 2 A par phase (2,4 A pour le cas de l'arrondi), douze cycles espacés de 6 min
// (au-delà des verrous de la borne).
// Le compteur de chaque cycle porte ce que la borne tirait à la fin du précédent.
FuseauDeTest fuseau("Europe/Paris");
struct Cas { const char *quoi; BorneHc borne; int phases; int limiteA; int attenduA; double maisonA; };
const QList<Cas> cas = {
{"monophasée, limite 25 A", BorneHc::Monophasee, 1, 25, 16, 2},
{"monophasée, limite 32 A", BorneHc::Monophasee, 1, 32, 16, 2},
{"triphasée, limite 25 A", BorneHc::Triphasee, 3, 25, 16, 2},
{"triphasée, limite 32 A", BorneHc::Triphasee, 3, 32, 16, 2},
// 16 − 2 = 14 A de place : la consigne se pose à 13 A, 1 A sous la limite.
{"monophasée, limite 16 A", BorneHc::Monophasee, 1, 16, 13, 2},
{"triphasée, limite 16 A", BorneHc::Triphasee, 3, 16, 13, 2},
// L'ARRONDI : 16 − 2,4 − 1 = 12,6 A. Pris au plus proche, 13 A porteraient la phase à
// 15,4 A — la marge mangée. À l'ampère inférieur : 12 A, 14,4 A au compteur.
{"monophasée, limite 16 A, maison à 2,4 A", BorneHc::Monophasee, 1, 16, 12, 2.4},
};
constexpr int Cycles = 12;
// Tous les cas sont MESURÉS avant qu'un seul soit jugé : le journal porte la série complète,
// cycle par cycle, même quand le premier cas échoue.
QList<QList<int>> toutesConsignes;
QList<QList<double>> toutesPires;
QStringList traces;
for (const Cas &c : cas) {
ScenarioHc s = preparerScenarioHc(QStringLiteral("oscillation-%1-%2-%3").arg(c.phases).arg(c.limiteA)
.arg(qRound(c.maisonA * 10)),
BatterieHc::Aucune, hphcT4(), c.borne);
QVERIFY2(s.arb && s.borne, c.quoi);
QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", c.limiteA}}))
.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
s.borne->setStateValue("pluggedIn", true);
QList<int> consignes;
QList<double> pires;
QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(21, 0)); // 23:00 à Paris
for (int k = 0; k < Cycles; ++k, a = a.addSecs(360)) {
cycleHc(s, a, compteurVoitLaBorne(s.meter, s.borne, c.maisonA));
consignes << consigneA(s.borne);
compteurVoitLaBorne(s.meter, s.borne, c.maisonA); // ce que la consigne produit
pires << pirePhaseA(s.meter);
}
QStringList trace;
for (int k = 0; k < Cycles; ++k)
trace << QStringLiteral("%1 A (%2)").arg(consignes.at(k)).arg(pires.at(k));
traces << trace.join(QStringLiteral(", "));
qInfo().noquote() << "[oscillation]" << c.quoi << ":" << traces.last();
toutesConsignes << consignes;
toutesPires << pires;
}
for (int i = 0; i < cas.size(); ++i) {
const Cas &c = cas.at(i);
const QList<int> &consignes = toutesConsignes.at(i);
const QList<double> &pires = toutesPires.at(i);
// Posée dès le premier cycle, et TENUE : aucune alternance sur les douze.
for (int k = 0; k < Cycles; ++k)
QVERIFY2(consignes.at(k) == c.attenduA,
qUtf8Printable(QStringLiteral("%1 — cycle %2 : %3").arg(c.quoi).arg(k).arg(traces.at(i))));
// Et SOUS la limite, avec la marge : la phase la plus chargée n'y touche jamais.
for (int k = 0; k < Cycles; ++k)
QVERIFY2(pires.at(k) <= c.limiteA - 1 + 1e-6,
qUtf8Printable(QStringLiteral("%1 — cycle %2 : %3").arg(c.quoi).arg(k).arg(traces.at(i))));
}
#endif
}
void Simulation::testOffPeakFullPowerStaysUnderTheLimitOnABlindMeter()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
// LES PHASES DU COMPTEUR NE VOIENT PAS LA BORNE. Le retour du courant suppose le contraire, et
// c'est là qu'il pourrait mentir : la borne annonce 13 A que la phase ne montre pas, et
// « maison sans la borne » vaudrait 2 − 13 = −11 A. Sans garde, la place rendue serait
// 16 + 11 − 1 = 26 A, et la borne seule irait à la limite. Écrêtée à zéro, la maison sans la
// borne vaut au moins 0 : la borne reste à 1 A sous la limite.
//
// Le TOTAL du compteur, lui, contient la borne. Ce n'est pas un compteur réel, c'est un banc
// pour la garde : un compteur aveugle aussi au total ferait fabriquer au recrédit un surplus
// de la taille de la borne, et la passe confort l'ajouterait au plafond — un autre écart,
// ouvert (DESIGN_HC_BATTERIE §8.1, écart 4), qui masquerait celui-ci.
// Borne monophasée 16 A, limite 16 A, maison à 2 A par phase.
FuseauDeTest fuseau("Europe/Paris");
ScenarioHc s = preparerScenarioHc(QStringLiteral("compteur-aveugle"), BatterieHc::Aucune, hphcT4(),
BorneHc::Monophasee);
QVERIFY(s.arb && s.borne);
QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 16}}))
.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
s.borne->setStateValue("pluggedIn", true);
const auto phasesAveugles = [&]() {
for (const char *ph : {"A", "B", "C"}) {
s.meter->setStateValue(QStringLiteral("currentPhase%1").arg(ph), 2.0);
s.meter->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), 460.0);
}
return 1380.0 + s.borne->stateValue("currentPower").toDouble();
};
QList<int> consignes;
QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(21, 0));
for (int k = 0; k < 6; ++k, a = a.addSecs(360)) {
cycleHc(s, a, phasesAveugles());
consignes << consigneA(s.borne);
}
// 16 − 2 − 1 = 13 A à l'arrêt ; puis la borne tire 13 A que la phase ne montre pas, et la
// place rendue plafonne à 16 − 0 − 1 = 15 A.
QCOMPARE(consignes, (QList<int>{13, 15, 15, 15, 15, 15}));
#endif
}

View File

@ -283,9 +283,17 @@ private slots:
void testOffPeakChargesAtFullPower();
//! Lot e2 — borne à bascule : en heures creuses, trois phases même quand le compteur lit 0.
void testOffPeakFullPowerSwitchesToThreePhases();
//! Lot e2 — limite de phase : la borne est réduite, jamais sous son plancher par la stratégie ;
//! la marge d'une phase ne bride pas une triphasée (LM-1500-h) ; L4 coupe au dépassement.
//! Lot e2 — limite de phase, compteur qui voit la borne : la borne est réduite, jamais sous son
//! plancher par la stratégie ; la marge d'une phase ne bride pas une triphasée (LM-1500-h) ; le
//! courant rendu est le MESURÉ ; en dépassement la consigne baisse ; L4 coupe au dépassement.
void testOffPeakFullPowerYieldsToThePhaseLimit();
//! Lot e2, suite — compteur qui voit la borne, maison à 2 A : la consigne se pose sans
//! alterner (mono et tri, limites 25 et 32 A), et sous une limite de 16 A elle se pose dessous,
//! à l'ampère inférieur.
void testOffPeakFullPowerSettlesUnderThePhaseLimit();
//! Lot e2, suite — compteur qui ne voit pas la borne : le retour de son courant ne la mène pas
//! seule à la limite (maison sans la borne écrêtée à zéro).
void testOffPeakFullPowerStaysUnderTheLimitOnABlindMeter();
//! TOUTE méthode Get* du namespace rend ce que sa déclaration promet.
void testEveryGetterReturnsWhatItDeclares();
//! Le snapshot sert la DÉCISION : le canal, jamais la table — et l'absence n'est pas zéro.