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:
parent
b1c0adec00
commit
e28e6375fd
@ -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;
|
||||||
|
|||||||
@ -11711,34 +11711,208 @@ 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}) {
|
||||||
|
const char *quoi = totalVoitLaBorne ? "total qui voit la borne" : "total aveugle";
|
||||||
|
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}}))
|
||||||
|
.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 + (totalVoitLaBorne ? s.borne->stateValue("currentPower").toDouble() : 0.0);
|
||||||
|
};
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
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
|
||||||
|
// place rendue plafonne à 16 − 0 − 1 = 15 A — surplus fabriqué ou non.
|
||||||
|
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);
|
BorneHc::Monophasee);
|
||||||
QVERIFY(s.arb && s.borne);
|
QVERIFY(s.arb && s.borne);
|
||||||
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"));
|
||||||
s.borne->setStateValue("pluggedIn", true);
|
s.borne->setStateValue("pluggedIn", true);
|
||||||
const auto phasesAveugles = [&]() {
|
const QString borne = s.borneId.toString();
|
||||||
for (const char *ph : {"A", "B", "C"}) {
|
|
||||||
s.meter->setStateValue(QStringLiteral("currentPhase%1").arg(ph), 2.0);
|
//! Le compteur voit la borne sur A ; B et C portent chacune \p exportPhaseW d'export.
|
||||||
s.meter->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), 460.0);
|
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 1380.0 + s.borne->stateValue("currentPower").toDouble();
|
return a * 230 - 2 * exportPhaseW;
|
||||||
};
|
};
|
||||||
QList<int> consignes;
|
//! La part d'un niveau, et son motif.
|
||||||
QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(21, 0));
|
const auto niveau = [&](const QVariantMap &snap, const QString &quel) {
|
||||||
for (int k = 0; k < 6; ++k, a = a.addSecs(360)) {
|
for (const QVariant &n : telemetrieParId(snap).value(borne).value("levels").toList())
|
||||||
cycleHc(s, a, phasesAveugles());
|
if (n.toMap().value("level").toString() == quel)
|
||||||
consignes << consigneA(s.borne);
|
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(" ; "));
|
||||||
}
|
}
|
||||||
// 16 − 2 − 1 = 13 A à l'arrêt ; puis la borne tire 13 A que la phase ne montre pas, et la
|
#endif
|
||||||
// place rendue plafonne à 16 − 0 − 1 = 15 A.
|
}
|
||||||
QCOMPARE(consignes, (QList<int>{13, 15, 15, 15, 15, 15}));
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user