Heures creuses : la somme éco + confort de la borne tient dans son plafond (écart 4)

En heures creuses, le niveau éco d'une borne à pleine puissance vaut son
plafond de cycle, qui porte la limite de phase. À l'aube, la passe confort
lui ajoutait le surplus, et seul le courant maximal de la borne écrêtait
la somme : borne monophasée sur A, limite 16 A, plafond 13 A, 2 540 W de
surplus — 16 A servis, la phase A à 18 A. L'achat publié valait 2 990 W
quand le compteur n'importait que 450 W.

Pour une borne qui porte EV_OFF_PEAK_FULL_POWER :
- la passe confort sert le surplus DANS le plafond ;
- l'achat cède ce que le surplus finance (gridW, drawCommittedW,
  draw.committedW) : la somme reste le plafond ;
- la part confort n'a pas de plancher : il porte sur la somme, qui vaut
  le plafond. Sinon un surplus sous le plancher serait refusé, ou démarré
  par EV_GRID_START, un second achat en plus du premier.
Hors heures creuses et sans tarif, rien ne change.

Tests : testOffPeakFullPowerAtDawnStaysInTheCeiling (nuit, aube, plein
soleil : 13 A, achat 2 990 / 450 / 0 W, achat ≤ import du compteur) ;
testOffPeakFullPowerStaysUnderTheLimitOnABlindMeter étendu au total
aveugle (13, 15, 15… au lieu de 13, 16, 16…) ;
testOffPeakFullPowerGivesNothingBackWhileSwitchingPhases, la garde de
bascule enfin éprouvée (12 A, phase B à 24 A ; sans elle 15 A, B à 27 A).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-25 09:41:48 +01:00
parent b1c0adec00
commit e28e6375fd
3 changed files with 227 additions and 21 deletions

View File

@ -301,6 +301,9 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
// donc après un éco de rang 3 — c'est le sens même des deux passes, et le publier sous forme // donc après un éco de rang 3 — c'est le sens même des deux passes, et le publier sous forme
// de niveaux est ce qui rend cet ordre lisible sans qu'un client ait à le redériver. // de niveaux est ce qui rend cet ordre lisible sans qu'un client ait à le redériver.
QHash<QString, LoadAction::Level> niveauxEco; QHash<QString, LoadAction::Level> niveauxEco;
//! Lot e2, suite (écart 4) — les bornes servies à leur plafond de cycle en heures creuses. En
//! passe confort, leur surplus PREND la place de l'achat au lieu de s'y ajouter.
QSet<QString> plafondPartage;
// --- TÊTE DE PASSE ÉCO — les DÉROGATIONS -------------------------------------------------- // --- TÊTE DE PASSE ÉCO — les DÉROGATIONS --------------------------------------------------
// //
@ -538,6 +541,7 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
: QStringLiteral("phaseLimit")); : QStringLiteral("phaseLimit"));
} }
eco.reason = { DecisionCode::EvOffPeakFullPower, pp }; eco.reason = { DecisionCode::EvOffPeakFullPower, pp };
plafondPartage.insert(lc.id);
} }
// SOUS LE PREMIER PALIER, rien n'est acheté. Une charge à paliers ne délivre que ses // SOUS LE PREMIER PALIER, rien n'est acheté. Une charge à paliers ne délivre que ses
@ -598,11 +602,22 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
waterfallEvIds.insert(lc.id); waterfallEvIds.insert(lc.id);
continue; continue;
} }
// Lot e2, suite (écart 4, décision du 2026-10-25) — LA SOMME ÉCO + CONFORT EST BORNÉE AU
// PLAFOND DE CYCLE. Le niveau éco vaut déjà ce plafond, qui porte la limite de phase ; à
// l'aube, le surplus s'y ajoutait, et seul le courant maximal de la borne écrêtait la
// somme : 13 A de plafond plus 2 540 W de surplus faisaient 16 A sous une limite de 16 A.
// Le confort est donc servi DANS le plafond, et l'achat cède ce que le surplus finance
// (plus bas, à la fusion). Le plancher ne s'applique qu'à la somme, qui vaut le plafond,
// donc jamais moins que lui : la part confort n'en a pas — sans quoi un surplus sous le
// plancher serait refusé, ou démarré par EV_GRID_START, un second achat en plus du premier.
LoadContext lcConfort = lc;
if (plafondPartage.contains(lc.id))
lcConfort.declared.minPowerW = 0;
if (lc.adapter == QStringLiteral("sg-ready")) if (lc.adapter == QStringLiteral("sg-ready"))
slot.actions.append(buildSgReadyStateAction(lc, remainingSurplusW, compta)); slot.actions.append(buildSgReadyStateAction(lc, remainingSurplusW, compta));
else else
slot.actions.append(emitSetpointAction( slot.actions.append(emitSetpointAction(
decideSetpoint(lc, remainingSurplusW, margeRestante, compta, draw))); decideSetpoint(lcConfort, remainingSurplusW, margeRestante, compta, draw)));
// etmvariableload + relay-router + evcharger // etmvariableload + relay-router + evcharger
// Le décrément vaut pour TOUTES les charges servies, bornes et PAC comprises : elles // Le décrément vaut pour TOUTES les charges servies, bornes et PAC comprises : elles
@ -614,8 +629,19 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
// court-cycling fabriqué par l'arbitre lui-même. // court-cycling fabriqué par l'arbitre lui-même.
if (!slot.actions.isEmpty() && niveauxEco.contains(lc.id)) { if (!slot.actions.isEmpty() && niveauxEco.contains(lc.id)) {
LoadAction &la = slot.actions.last(); LoadAction &la = slot.actions.last();
const LoadAction::Level eco = niveauxEco.take(lc.id); LoadAction::Level eco = niveauxEco.take(lc.id);
const double confortW = la.estimatedPowerW; // la part confort, avant l'addition const double confortW = la.estimatedPowerW; // la part confort, avant l'addition
if (plafondPartage.contains(lc.id)) {
// L'ACHAT CÈDE CE QUE LE SURPLUS FINANCE : la somme reste le plafond, et l'achat
// publié (`gridW`, `drawCommittedW`, `draw.committedW`) est ce qui reste à acheter
// — jamais plus que le compteur n'importe. La marge de soutirage n'est pas rendue :
// la ligne qui suit la fusion décompte la consigne entière, prudence inchangée.
const double financeW = qBound(0.0, confortW, eco.targetW);
eco.targetW -= financeW;
eco.counts[CountKey::DrawCommitted] = eco.targetW;
eco.reason.params.insert(QStringLiteral("gridW"), qRound(eco.targetW));
draw.committedW -= financeW;
}
la.levels.prepend(eco); // éco AVANT confort : l'ordre des passes la.levels.prepend(eco); // éco AVANT confort : l'ordre des passes
la.powerW += eco.targetW; la.powerW += eco.targetW;
la.estimatedPowerW += eco.targetW; la.estimatedPowerW += eco.targetW;

View File

@ -11711,15 +11711,18 @@ void Simulation::testOffPeakFullPowerStaysUnderTheLimitOnABlindMeter()
// 16 + 11 − 1 = 26 A, et la borne seule irait à la limite. Écrêtée à zéro, la maison sans la // 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. // 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 // Deux variantes. Dans la première, le TOTAL du compteur contient la borne : c'est le banc de
// pour la garde : un compteur aveugle aussi au total ferait fabriquer au recrédit un surplus // la garde. Dans la seconde, il ne la voit pas non plus, et le recrédit fabrique alors un
// de la taille de la borne, et la passe confort l'ajouterait au plafond — un autre écart, // surplus de la taille de la borne. Avant que la somme éco + confort soit bornée au plafond
// ouvert (DESIGN_HC_BATTERIE §8.1, écart 4), qui masquerait celui-ci. // (écart 4, décision du 2026-10-25), la passe confort l'ajoutait au plafond de 15 A, et la
// borne recevait 16 A sous une limite de 16 A : 13, 16, 16… Le plafond tient désormais.
// Borne monophasée 16 A, limite 16 A, maison à 2 A par phase. // Borne monophasée 16 A, limite 16 A, maison à 2 A par phase.
FuseauDeTest fuseau("Europe/Paris"); FuseauDeTest fuseau("Europe/Paris");
ScenarioHc s = preparerScenarioHc(QStringLiteral("compteur-aveugle"), BatterieHc::Aucune, hphcT4(), for (const bool totalVoitLaBorne : {true, false}) {
BorneHc::Monophasee); const char *quoi = totalVoitLaBorne ? "total qui voit la borne" : "total aveugle";
QVERIFY(s.arb && s.borne); ScenarioHc s = preparerScenarioHc(QStringLiteral("compteur-aveugle-%1").arg(totalVoitLaBorne),
BatterieHc::Aucune, hphcT4(), BorneHc::Monophasee);
QVERIFY2(s.arb && s.borne, quoi);
QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 16}})) QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 16}}))
.toMap().value("params").toMap().value("energyError").toString(), .toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError")); QString("EnergyErrorNoError"));
@ -11729,7 +11732,7 @@ void Simulation::testOffPeakFullPowerStaysUnderTheLimitOnABlindMeter()
s.meter->setStateValue(QStringLiteral("currentPhase%1").arg(ph), 2.0); s.meter->setStateValue(QStringLiteral("currentPhase%1").arg(ph), 2.0);
s.meter->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), 460.0); s.meter->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), 460.0);
} }
return 1380.0 + s.borne->stateValue("currentPower").toDouble(); return 1380.0 + (totalVoitLaBorne ? s.borne->stateValue("currentPower").toDouble() : 0.0);
}; };
QList<int> consignes; QList<int> consignes;
QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(21, 0)); QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(21, 0));
@ -11737,8 +11740,179 @@ void Simulation::testOffPeakFullPowerStaysUnderTheLimitOnABlindMeter()
cycleHc(s, a, phasesAveugles()); cycleHc(s, a, phasesAveugles());
consignes << consigneA(s.borne); consignes << consigneA(s.borne);
} }
qInfo().noquote() << "[compteur aveugle]" << quoi << ":" << consignes;
// 16 − 2 − 1 = 13 A à l'arrêt ; puis la borne tire 13 A que la phase ne montre pas, et la // 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. // place rendue plafonne à 16 − 0 − 1 = 15 A — surplus fabriqué ou non.
QCOMPARE(consignes, (QList<int>{13, 15, 15, 15, 15, 15})); QVERIFY2(consignes == (QList<int>{13, 15, 15, 15, 15, 15}), quoi);
}
#endif
}
void Simulation::testOffPeakFullPowerAtDawnStaysInTheCeiling()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
// L'AUBE EN HEURES CREUSES (écart 4, décision du 2026-10-25). Le niveau éco d'une borne y vaut
// son plafond de cycle, qui porte la limite de phase. Le surplus du matin s'y AJOUTAIT, et
// seul le courant maximal de la borne écrêtait la somme : la limite de phase était perdue.
//
// Le cas rare du lot e2 : borne monophasée sur la phase A, limite 16 A, maison à 2 A sur A ;
// les phases B et C exportent 1 500 W chacune. Plafond : 16 − 2 − 1 = 13 A (2 990 W). Borne à
// 13 A, le compteur importe 460 + 2 990 − 3 000 = 450 W ; le surplus recrédité vaut 2 540 W.
// Avant : 2 990 + 2 540 W, soit 24 A demandés, 16 A servis, la phase A à 18 A pour 16.
// Après : 13 A, dont 2 540 W de surplus et 450 W achetés — ce que le compteur importe.
FuseauDeTest fuseau("Europe/Paris");
ScenarioHc s = preparerScenarioHc(QStringLiteral("aube"), 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 QString borne = s.borneId.toString();
//! Le compteur voit la borne sur A ; B et C portent chacune \p exportPhaseW d'export.
const auto compteur = [&](double exportPhaseW) {
const double a = 2.0 + s.borne->stateValue("currentPhaseA").toDouble();
s.meter->setStateValue("currentPhaseA", a);
s.meter->setStateValue("currentPowerPhaseA", a * 230);
for (const char *ph : {"B", "C"}) {
s.meter->setStateValue(QStringLiteral("currentPhase%1").arg(ph), -exportPhaseW / 230);
s.meter->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), -exportPhaseW);
}
return a * 230 - 2 * exportPhaseW;
};
//! La part d'un niveau, et son motif.
const auto niveau = [&](const QVariantMap &snap, const QString &quel) {
for (const QVariant &n : telemetrieParId(snap).value(borne).value("levels").toList())
if (n.toMap().value("level").toString() == quel)
return n.toMap();
return QVariantMap();
};
struct Etape { const char *quoi; double exportPhaseW; double achatW; double surplusW; };
const QList<Etape> etapes = {
// Témoin de nuit : rien à partager, tout est acheté — comme avant.
{"nuit", 0, 2990, 0},
// L'aube : le surplus finance 2 540 W, l'achat cède d'autant.
{"aube", 1500, 450, 2540},
// Plein soleil : le surplus dépasse le plafond. Rien n'est acheté, la borne reste à 13 A.
{"plein soleil", 3000, 0, 2990},
};
QDateTime a = utcDateTime(QDate(2026, 6, 9), QTime(2, 30)); // 04:30 à Paris
for (const Etape &e : etapes) {
QVariantMap snap;
QStringList trace;
for (int k = 0; k < 4; ++k) { // quatre cycles : la consigne se pose et TIENT
a = a.addSecs(360);
snap = cycleHc(s, a, compteur(e.exportPhaseW));
const double importW = compteur(e.exportPhaseW); // ce que la consigne produit
const QVariantMap eco = niveau(snap, "eco");
const QVariantMap confort = niveau(snap, "comfort");
trace << QStringLiteral("%1 A, achat %2 W, surplus %3 W, import %4 W, phase A %5 A")
.arg(consigneA(s.borne)).arg(eco.value("targetW").toDouble())
.arg(confort.value("targetW").toDouble()).arg(importW)
.arg(s.meter->stateValue("currentPhaseA").toDouble());
const QByteArray ctx = QStringLiteral("%1 — %2").arg(e.quoi, trace.join(" ; ")).toUtf8();
// LA CONSIGNE ≤ LE PLAFOND, et la phase sous la limite, avec la marge.
QVERIFY2(consigneA(s.borne) == 13, ctx.constData());
QVERIFY2(s.meter->stateValue("currentPhaseA").toDouble() <= 15 + 1e-6, ctx.constData());
QVERIFY2(qRound(telemetrieParId(snap).value(borne).value("allocatedW").toDouble()) == 2990,
ctx.constData());
// LE PARTAGE : la somme vaut le plafond, l'achat ce qui reste à acheter — et jamais plus
// que ce que le compteur importe une fois la consigne posée.
QVERIFY2(qRound(eco.value("targetW").toDouble()) == qRound(e.achatW), ctx.constData());
QVERIFY2(qRound(confort.value("targetW").toDouble()) == qRound(e.surplusW), ctx.constData());
QVERIFY2(eco.value("targetW").toDouble() <= qMax(0.0, importW) + 0.5, ctx.constData());
QVERIFY2(qRound(eco.value("counts").toMap().value(CountKey::DrawCommitted).toDouble())
== qRound(e.achatW), ctx.constData());
QVERIFY2(qRound(snap.value("draw").toMap().value("committedW").toDouble()) == qRound(e.achatW),
ctx.constData());
const QVariantMap motifEco = eco.value("decision").toMap();
QVERIFY2(motifEco.value("code").toString() == QString(DecisionCode::EvOffPeakFullPower),
ctx.constData());
QVERIFY2(motifEco.value("params").toMap().value("gridW").toInt() == qRound(e.achatW),
ctx.constData());
QVERIFY2(motifEco.value("params").toMap().value("boundBy").toString() == QStringLiteral("phaseLimit"),
ctx.constData());
}
qInfo().noquote() << "[aube]" << e.quoi << ":" << trace.join(QStringLiteral(" ; "));
}
#endif
}
void Simulation::testOffPeakFullPowerGivesNothingBackWhileSwitchingPhases()
{
#ifndef ETM_ARBITRATOR
QSKIP("nécessite ETM_ARBITRATOR.");
#else
// LA GARDE DE BASCULE (question 3 du lot e2, suite). Pendant une bascule, la borne tire sur
// une phase et vise les trois : rendre son courant sur la phase où elle le tire ne dit rien
// des deux autres. Borne triphasée à bascule, encore en monophasé à 10 A sur A, limite 25 A ;
// la maison tire 2 A sur A et C, 12 A sur B. Au compteur : A = 12, B = 12, C = 2 A.
//
// Avec la garde, rien n'est rendu : 25 − 12 − 1 = 12 A sur trois phases, et B finit à 24 A.
// Rendre les 10 A quand même (sur A seule, ou répartis sur les trois) donnerait 15 A, et B
// finirait à 27 A pour 25 — une place prise sur une phase que la borne n'occupait pas.
FuseauDeTest fuseau("Europe/Paris");
ScenarioHc s = preparerScenarioHc(QStringLiteral("garde-bascule"), BatterieHc::Aucune, hphcT4(),
BorneHc::Bascule);
QVERIFY(s.arb && s.borne);
QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 25}}))
.toMap().value("params").toMap().value("energyError").toString(),
QString("EnergyErrorNoError"));
const QString borne = s.borneId.toString();
const QList<double> maisonA = {2, 12, 2};
//! Le compteur voit la borne : la maison, plus ce que la borne tire, phase par phase.
const auto compteur = [&]() {
double totalW = 0;
const QStringList phases = {"A", "B", "C"};
for (int i = 0; i < 3; ++i) {
const double a = maisonA.at(i)
+ s.borne->stateValue(QStringLiteral("currentPhase%1").arg(phases.at(i))).toDouble();
s.meter->setStateValue(QStringLiteral("currentPhase%1").arg(phases.at(i)), a);
s.meter->setStateValue(QStringLiteral("currentPowerPhase%1").arg(phases.at(i)), a * 230);
totalW += a * 230;
}
return totalW;
};
// La borne charge en monophasé à 10 A : mesurée sur A seule, une phase, quand le cycle en vise trois.
s.borne->setStateValue("pluggedIn", true);
s.borne->setStateValue("power", true);
s.borne->setStateValue("charging", true);
s.borne->setStateValue("desiredPhaseCount", 1);
s.borne->setStateValue("usedPhases", QStringLiteral("A"));
s.borne->setStateValue("phaseCount", 1);
s.borne->setStateValue("maxChargingCurrent", 10);
s.borne->setStateValue("currentPhaseA", 10.0);
s.borne->setStateValue("currentPowerPhaseA", 2300.0);
for (const char *ph : {"B", "C"}) {
s.borne->setStateValue(QStringLiteral("currentPhase%1").arg(ph), 0.0);
s.borne->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), 0.0);
}
s.borne->setStateValue("currentPower", 2300.0);
const QVariantMap snap = cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(21, 0)), compteur());
const QVariantMap d = telemetrieParId(snap).value(borne).value("decision").toMap();
compteur(); // ce que la consigne produit, sur les trois phases
const QString trace = QStringLiteral("phases %1, consigne %2 A, retenu %3 W, au compteur A %4 B %5 C %6 A")
.arg(s.borne->stateValue("desiredPhaseCount").toUInt()).arg(consigneA(s.borne))
.arg(d.value("params").toMap().value("gridW").toInt())
.arg(s.meter->stateValue("currentPhaseA").toDouble())
.arg(s.meter->stateValue("currentPhaseB").toDouble())
.arg(s.meter->stateValue("currentPhaseC").toDouble());
qInfo().noquote() << "[garde de bascule]" << trace;
QVERIFY2(d.value("code").toString() == QString(DecisionCode::EvOffPeakFullPower), qUtf8Printable(trace));
QVERIFY2(s.borne->stateValue("desiredPhaseCount").toUInt() == 3u, qUtf8Printable(trace));
QVERIFY2(d.value("params").toMap().value("gridW").toInt() == 12 * 690, qUtf8Printable(trace));
QVERIFY2(consigneA(s.borne) == 12, qUtf8Printable(trace));
for (const char *ph : {"A", "B", "C"})
QVERIFY2(s.meter->stateValue(QStringLiteral("currentPhase%1").arg(ph)).toDouble() <= 24 + 1e-6,
qUtf8Printable(trace));
#endif #endif
} }

View File

@ -292,8 +292,14 @@ private slots:
//! à l'ampère inférieur. //! à l'ampère inférieur.
void testOffPeakFullPowerSettlesUnderThePhaseLimit(); void testOffPeakFullPowerSettlesUnderThePhaseLimit();
//! Lot e2, suite — compteur qui ne voit pas la borne : le retour de son courant ne la mène pas //! 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). //! seule à la limite (maison sans la borne écrêtée à zéro) ; total aveugle aussi : le surplus
//! fabriqué ne la porte plus au-delà du plafond (15 A, jamais 16).
void testOffPeakFullPowerStaysUnderTheLimitOnABlindMeter(); void testOffPeakFullPowerStaysUnderTheLimitOnABlindMeter();
//! Lot e2, suite (écart 4) — à l'aube en heures creuses, le surplus prend la place de l'achat
//! dans le plafond, au lieu de s'y ajouter : consigne ≤ plafond, achat ≤ import du compteur.
void testOffPeakFullPowerAtDawnStaysInTheCeiling();
//! Lot e2, suite — pendant une bascule de phases, le plafond ne rend rien du courant de la borne.
void testOffPeakFullPowerGivesNothingBackWhileSwitchingPhases();
//! TOUTE méthode Get* du namespace rend ce que sa déclaration promet. //! TOUTE méthode Get* du namespace rend ce que sa déclaration promet.
void testEveryGetterReturnsWhatItDeclares(); void testEveryGetterReturnsWhatItDeclares();
//! Le snapshot sert la DÉCISION : le canal, jamais la table — et l'absence n'est pas zéro. //! Le snapshot sert la DÉCISION : le canal, jamais la table — et l'absence n'est pas zéro.