Heures creuses : la borne charge à la pleine puissance permise (lot e2)
Décision de Patrick du 2026-10-24. Dans les trois modes qui reportent en
heures creuses (Éco avec minimum, Éco min + échéance, Éco avec échéance) :
- le minimum servi au réseau en heures creuses est le PLAFOND de cycle de
la borne (declared.maxPowerW), plus son plancher. Il porte le courant
maximal, les phases et la limite de phase, et ne descend jamais sous le
plancher : la stratégie rabaisse, L4 coupe. 3 680 W en monophasé 16 A,
11 040 W en triphasé 16 A ;
- les phases se choisissent sur la pleine puissance, plus sur « courant +
surplus » (EnergyArbitrator::enHeuresCreuses(), lu par EvAdapter). La nuit,
une batterie qui couvre la maison et la voiture met le compteur à 0, et la
règle de bascule gardait le monophasé : 3,7 kW au lieu de 11 ;
- la passe éco n'oppose plus à cette borne la marge d'UNE phase de la maison
(LM-1500-h, déjà appliquée en tête de passe et en confort) : son plafond
porte déjà l'autorisation, sur ses phases. Restreint aux heures creuses ;
- motif EV_OFF_PEAK_FULL_POWER {gridW, maxW, phases, until, endsAt,
o:boundBy phaseLimit|floor}, rendu en français, à la place
d'ECO_FLOOR_GRID sur la borne.
Hors heures creuses et sans heure creuse dans le tarif : rien ne change.
Le blocage de la décharge se lève toujours sur la puissance MESURÉE (230 W) :
une borne commandée à 16 A dont la voiture ne prend plus rien le lève.
Tests : testOffPeakChargesAtFullPower, testOffPeakFullPowerSwitchesToThreePhases,
testOffPeakFullPowerYieldsToThePhaseLimit. Les tests du lot e1 en heures
creuses attendent le plafond, et leur compteur inclut ce que la borne tire
désormais (TirageHcW) — sinon il fabriquait un surplus.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b144c09178
commit
1291f72d1f
@ -51,7 +51,8 @@ LoadTelemetry EvAdapter::telemetry() const
|
|||||||
|
|
||||||
LoadContext EvAdapter::toLoadContext(const QDateTime &now) const
|
LoadContext EvAdapter::toLoadContext(const QDateTime &now) const
|
||||||
{
|
{
|
||||||
Q_UNUSED(now) // Les verrous EV (enclenchement, changement de courant) vivent en aval.
|
// Les verrous EV (enclenchement, changement de courant) vivent en aval : `now` ne sert qu'à
|
||||||
|
// savoir si l'instant tombe en heures creuses (lot e2).
|
||||||
LoadContext ctx;
|
LoadContext ctx;
|
||||||
ctx.id = m_charger->thing()->id().toString();
|
ctx.id = m_charger->thing()->id().toString();
|
||||||
ctx.adapter = QStringLiteral("evcharger");
|
ctx.adapter = QStringLiteral("evcharger");
|
||||||
@ -74,7 +75,29 @@ LoadContext EvAdapter::toLoadContext(const QDateTime &now) const
|
|||||||
// avec CETTE voiture » est tranchée une fois par le manager, et evTargetPhases() la lit.
|
// avec CETTE voiture » est tranchée une fois par le manager, et evTargetPhases() la lit.
|
||||||
// Le prédicat local n'en voyait qu'une moitié (l'état existe-t-il) et retombait sur le
|
// Le prédicat local n'en voyait qu'une moitié (l'état existe-t-il) et retombait sur le
|
||||||
// nombre de phases COURANT, sans la voiture.
|
// nombre de phases COURANT, sans la voiture.
|
||||||
const double courantReferenceA = m_parent->evReferenceCurrentA(m_charger);
|
const ChargingInfo::ChargingMode mode = m_parent->chargingInfo(m_charger->id()).chargingMode();
|
||||||
|
const bool reportable = mode == ChargingInfo::ChargingModeEcoWithMinCurrent
|
||||||
|
|| mode == ChargingInfo::ChargingModeEcoMinWithTargetTime
|
||||||
|
|| mode == ChargingInfo::ChargingModeEcoWithTargetTime;
|
||||||
|
|
||||||
|
// Lot e2 — EN HEURES CREUSES, la référence est la PLEINE PUISSANCE de la borne, et non plus
|
||||||
|
// « courant + surplus ». La règle de bascule reste celle du manager ; c'est ce qu'on lui
|
||||||
|
// demande qui change.
|
||||||
|
// « Courant + surplus » répond à « combien le soleil donne-t-il ? », et la nuit la réponse
|
||||||
|
// décide mal : la maison importe, la référence est négative et la règle vise le triphasé —
|
||||||
|
// sauf quand une batterie couvre la maison et la voiture. Le compteur lit alors ≈ 0, la
|
||||||
|
// référence vaut le courant que la borne tire déjà (6 A en monophasé), et la règle la GARDE
|
||||||
|
// en monophasé : 3,7 kW au plus pour une borne qui en ferait 11. En heures creuses, la
|
||||||
|
// question n'est plus le surplus mais ce que la borne peut prendre.
|
||||||
|
//
|
||||||
|
// La limite de phase n'entre pas dans le choix, et c'est sans perte : par phase, elle borne
|
||||||
|
// de la même façon une monophasée et une triphasée, donc trois phases donnent toujours au
|
||||||
|
// moins autant (marge × 3 contre marge × 1). Sous le plancher, aucune des deux ne tient, et
|
||||||
|
// c'est la protection de surcharge qui tranche.
|
||||||
|
const bool pleinePuissanceHc = reportable && m_parent->enHeuresCreuses(now);
|
||||||
|
const double courantReferenceA = pleinePuissanceHc
|
||||||
|
? m_charger->maxChargingCurrentMaxValue() * 3.0
|
||||||
|
: m_parent->evReferenceCurrentA(m_charger);
|
||||||
m_targetPhases = m_parent->evTargetPhases(m_charger, courantReferenceA);
|
m_targetPhases = m_parent->evTargetPhases(m_charger, courantReferenceA);
|
||||||
if (m_targetPhases == 0)
|
if (m_targetPhases == 0)
|
||||||
m_targetPhases = 1;
|
m_targetPhases = 1;
|
||||||
@ -93,25 +116,11 @@ LoadContext EvAdapter::toLoadContext(const QDateTime &now) const
|
|||||||
// La valeur n'est PAS stockée : elle se dérive du matériel à chaque cycle, comme le
|
// La valeur n'est PAS stockée : elle se dérive du matériel à chaque cycle, comme le
|
||||||
// plancher juste au-dessus. L'écrire en configuration à l'appairage créerait une copie qui
|
// plancher juste au-dessus. L'écrire en configuration à l'appairage créerait une copie qui
|
||||||
// se périme au premier recâblage — `phaseCount` change, la copie reste fausse en silence.
|
// se périme au premier recâblage — `phaseCount` change, la copie reste fausse en silence.
|
||||||
const ChargingInfo::ChargingMode mode = m_parent->chargingInfo(m_charger->id()).chargingMode();
|
|
||||||
if (mode == ChargingInfo::ChargingModeEcoWithMinCurrent
|
if (mode == ChargingInfo::ChargingModeEcoWithMinCurrent
|
||||||
|| mode == ChargingInfo::ChargingModeEcoMinWithTargetTime) {
|
|| mode == ChargingInfo::ChargingModeEcoMinWithTargetTime) {
|
||||||
ctx.needs.minPowerWAlways = ctx.declared.minPowerW;
|
ctx.needs.minPowerWAlways = ctx.declared.minPowerW;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Lot e1 : le besoin RÉSEAU se reporte en heures creuses -----------------------
|
|
||||||
//
|
|
||||||
// DÉCLARÉ ici, DÉCIDÉ par le scheduler (règle 2) — et seulement si le tarif saisi a des
|
|
||||||
// heures creuses ; sans elles, ces deux champs n'ont aucun effet. Trois modes : les deux
|
|
||||||
// « avec minimum » et « avec échéance ». `Eco` seul ne demande jamais le réseau, `Normal`
|
|
||||||
// est piloté par l'utilisateur. Le minimum d'heures creuses est le même plancher, dérivé.
|
|
||||||
if (mode == ChargingInfo::ChargingModeEcoWithMinCurrent
|
|
||||||
|| mode == ChargingInfo::ChargingModeEcoMinWithTargetTime
|
|
||||||
|| mode == ChargingInfo::ChargingModeEcoWithTargetTime) {
|
|
||||||
ctx.needs.offPeakShift = true;
|
|
||||||
ctx.needs.offPeakMinPowerW = ctx.declared.minPowerW;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 3g-2 : le plafond du cycle, protection de surcharge COMPRISE -------------------
|
// --- 3g-2 : le plafond du cycle, protection de surcharge COMPRISE -------------------
|
||||||
//
|
//
|
||||||
// L'écrêtage par la limite de phase vivait dans planSurplusCharging(), que la borne ne
|
// L'écrêtage par la limite de phase vivait dans planSurplusCharging(), que la borne ne
|
||||||
@ -133,6 +142,24 @@ LoadContext EvAdapter::toLoadContext(const QDateTime &now) const
|
|||||||
qMax(plafondPhaseW, ctx.declared.minPowerW));
|
qMax(plafondPhaseW, ctx.declared.minPowerW));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Lots e1 et e2 : le besoin RÉSEAU se reporte en heures creuses -----------------
|
||||||
|
//
|
||||||
|
// DÉCLARÉ ici, DÉCIDÉ par le scheduler (règle 2) — et seulement si le tarif saisi a des
|
||||||
|
// heures creuses ; sans elles, ces deux champs n'ont aucun effet. Trois modes : les deux
|
||||||
|
// « avec minimum » et « avec échéance ». `Eco` seul ne demande jamais le réseau, `Normal`
|
||||||
|
// est piloté par l'utilisateur.
|
||||||
|
//
|
||||||
|
// Lot e2 (décision du 2026-10-24) — le minimum d'heures creuses est le PLAFOND du cycle, et
|
||||||
|
// non plus le plancher. Au plancher (6 A, 1,4 kW en monophasé), une session de 10 à 25 kWh
|
||||||
|
// n'était pas finie au départ, et la décharge de la batterie restait bloquée toute la nuit.
|
||||||
|
// À pleine puissance, la session finit tôt et la batterie est rendue pour le reste de la nuit.
|
||||||
|
// Le plafond est pris APRÈS l'écrêtage ci-dessus : il porte le courant maximal, les phases et
|
||||||
|
// la limite de phase, et ne descend jamais sous le plancher — la stratégie rabaisse, L4 coupe.
|
||||||
|
if (reportable) {
|
||||||
|
ctx.needs.offPeakShift = true;
|
||||||
|
ctx.needs.offPeakMinPowerW = ctx.declared.maxPowerW;
|
||||||
|
}
|
||||||
|
|
||||||
ctx.telemetry.currentPowerW = m_charger->currentPower();
|
ctx.telemetry.currentPowerW = m_charger->currentPower();
|
||||||
ctx.telemetry.available = m_charger->available();
|
ctx.telemetry.available = m_charger->available();
|
||||||
ctx.telemetry.pluggedIn = m_charger->pluggedIn();
|
ctx.telemetry.pluggedIn = m_charger->pluggedIn();
|
||||||
|
|||||||
@ -46,9 +46,14 @@ public:
|
|||||||
|
|
||||||
/*!
|
/*!
|
||||||
* \brief Construit l'entrée loads[] §5 du SurplusContext.
|
* \brief Construit l'entrée loads[] §5 du SurplusContext.
|
||||||
* \param now Temps de cycle (\c ctx.timestamp). Inutilisé ici : l'EV n'a pas de verrou
|
* \param now Temps de cycle (\c ctx.timestamp). L'EV n'a pas de verrou de palier (hors
|
||||||
* de palier (hors waterfall ECS/SG-Ready). Présent pour l'uniformité de \c ILoadAdapter.
|
* waterfall ECS/SG-Ready) ; l'instant ne sert qu'à savoir s'il tombe en heures creuses
|
||||||
|
* (lot e2) — la borne y vise sa pleine puissance, et ses phases s'y choisissent.
|
||||||
* \return LoadContext incluant declared, limits, needs et télémétrie EV.
|
* \return LoadContext incluant declared, limits, needs et télémétrie EV.
|
||||||
|
* \note **Lot e2** — dans les trois modes qui reportent en heures creuses, le minimum
|
||||||
|
* 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.
|
||||||
*/
|
*/
|
||||||
LoadContext toLoadContext(const QDateTime &now) const override;
|
LoadContext toLoadContext(const QDateTime &now) const override;
|
||||||
|
|
||||||
|
|||||||
@ -1509,6 +1509,15 @@ void EnergyArbitrator::qualifierRefus(LoadContext &lc, const QDateTime &now) con
|
|||||||
lc.telemetry.commandRefused = ecoule > a->commandSettleS(montee);
|
lc.telemetry.commandRefused = ecoule > a->commandSettleS(montee);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool EnergyArbitrator::enHeuresCreuses(const QDateTime &now) const
|
||||||
|
{
|
||||||
|
// Une seule lecture, partagée par le contexte et par la borne : deux copies divergeraient au
|
||||||
|
// premier changement de règle, et la borne viserait sa pleine puissance hors des heures que
|
||||||
|
// le scheduler sert.
|
||||||
|
return !m_tariff.zonesCreuses().isEmpty()
|
||||||
|
&& m_tariff.enHeuresCreuses(now, QTimeZone::systemTimeZone());
|
||||||
|
}
|
||||||
|
|
||||||
SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const
|
SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const
|
||||||
{
|
{
|
||||||
SurplusContext ctx;
|
SurplusContext ctx;
|
||||||
@ -1540,7 +1549,7 @@ SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const
|
|||||||
if (!m_tariff.zonesCreuses().isEmpty()) {
|
if (!m_tariff.zonesCreuses().isEmpty()) {
|
||||||
const QTimeZone fuseau = QTimeZone::systemTimeZone();
|
const QTimeZone fuseau = QTimeZone::systemTimeZone();
|
||||||
ctx.offPeak.configured = true;
|
ctx.offPeak.configured = true;
|
||||||
ctx.offPeak.active = m_tariff.enHeuresCreuses(now, fuseau);
|
ctx.offPeak.active = enHeuresCreuses(now);
|
||||||
ctx.offPeak.nextChange = m_tariff.prochaineBascule(now, fuseau);
|
ctx.offPeak.nextChange = m_tariff.prochaineBascule(now, fuseau);
|
||||||
if (ctx.offPeak.nextChange.isValid())
|
if (ctx.offPeak.nextChange.isValid())
|
||||||
ctx.offPeak.nextChangeWall = ctx.offPeak.nextChange.toTimeZone(fuseau).toString(QStringLiteral("HH:mm"));
|
ctx.offPeak.nextChangeWall = ctx.offPeak.nextChange.toTimeZone(fuseau).toString(QStringLiteral("HH:mm"));
|
||||||
|
|||||||
@ -129,6 +129,19 @@ public:
|
|||||||
*/
|
*/
|
||||||
const TariffConfig &tariff() const { return m_tariff; }
|
const TariffConfig &tariff() const { return m_tariff; }
|
||||||
|
|
||||||
|
/*!
|
||||||
|
* \brief L'instant \p now tombe-t-il en heures creuses du tarif saisi ? (lot e2)
|
||||||
|
*
|
||||||
|
* Même lecture que \c SurplusContext::offPeak (\c buildContext()) : zones au prix le plus
|
||||||
|
* bas, heure murale du fuseau de la box relu à l'appel. Elle existe pour la borne, qui doit
|
||||||
|
* le savoir AVANT l'allocation : en heures creuses elle vise sa pleine puissance, donc ses
|
||||||
|
* phases se choisissent sur son plafond et non sur le surplus (\c EvAdapter::toLoadContext()).
|
||||||
|
*
|
||||||
|
* \param now Temps de cycle (injectable). \return Faux sans heure creuse (aucun tarif, tarif
|
||||||
|
* unique, prix tous égaux) — rien du lot ne s'exécute alors.
|
||||||
|
*/
|
||||||
|
bool enHeuresCreuses(const QDateTime &now) const;
|
||||||
|
|
||||||
/*!
|
/*!
|
||||||
* \brief Remplace le tarif saisi, à chaud, et le persiste. Un tarif vide l'efface.
|
* \brief Remplace le tarif saisi, à chaud, et le persiste. Un tarif vide l'efface.
|
||||||
* \param t Tarif DÉJÀ validé (\c TariffConfig::fromVariant) — il n'en existe pas d'autre.
|
* \param t Tarif DÉJÀ validé (\c TariffConfig::fromVariant) — il n'en existe pas d'autre.
|
||||||
|
|||||||
@ -457,8 +457,24 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
|
|||||||
// un droit de faire disjoncter : si l'autorisation est épuisée, on n'achète pas, et
|
// un droit de faire disjoncter : si l'autorisation est épuisée, on n'achète pas, et
|
||||||
// c'est le délestage qui rattrapera ce qui a déjà été engagé.
|
// c'est le délestage qui rattrapera ce qui a déjà été engagé.
|
||||||
double achatW = cible.watts;
|
double achatW = cible.watts;
|
||||||
const bool bridePlafond = margeRestante.borne && achatW > qMax(0.0, margeRestante.watts);
|
|
||||||
if (margeRestante.borne)
|
// Lot e2 — LA BORNE À PLEINE PUISSANCE EN HEURES CREUSES. Son minimum y est son plafond de
|
||||||
|
// cycle, et ce plafond porte DÉJÀ l'autorisation de soutirage : `evPhaseAllowanceW()` la
|
||||||
|
// résout sur les phases qu'elle occupe et la multiplie par leur nombre. \rule{LM-1500-h}
|
||||||
|
// s'applique donc ici comme en tête de passe et en passe confort : lui opposer en plus
|
||||||
|
// `margeRestante`, la marge d'UNE phase, diviserait une borne triphasée par trois — 7 A
|
||||||
|
// par phase au lieu de 16 quand la maison tire 4 A sur une limite de 25 A, la nuit même
|
||||||
|
// où on veut qu'elle finisse tôt.
|
||||||
|
//
|
||||||
|
// Restreint à ce cas, et c'est délibéré : sans heures creuses, le plancher éco d'une borne
|
||||||
|
// reste écrêté comme avant (engagement « sans tarif, rien ne change »). Cet écart-là reste
|
||||||
|
// à trancher (DESIGN_HC_BATTERIE §8.1) ; il n'est pas corrigé en passant.
|
||||||
|
const bool pleinePuissanceHc = ctx.offPeak.active && lc.needs.offPeakShift
|
||||||
|
&& lc.adapter == QStringLiteral("evcharger")
|
||||||
|
&& cible.borneParLeMinimum;
|
||||||
|
const bool bridePlafond = !pleinePuissanceHc && margeRestante.borne
|
||||||
|
&& achatW > qMax(0.0, margeRestante.watts);
|
||||||
|
if (!pleinePuissanceHc && margeRestante.borne)
|
||||||
achatW = qBound(0.0, achatW, qMax(0.0, margeRestante.watts));
|
achatW = qBound(0.0, achatW, qMax(0.0, margeRestante.watts));
|
||||||
|
|
||||||
// La CAUSE se note à chaque cycle, servie ou non. À la clôture, c'est la dernière qui
|
// La CAUSE se note à chaque cycle, servie ou non. À la clôture, c'est la dernière qui
|
||||||
@ -500,6 +516,30 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx)
|
|||||||
}
|
}
|
||||||
eco.reason = { DecisionCode::EcoFloorGrid, motif };
|
eco.reason = { DecisionCode::EcoFloorGrid, motif };
|
||||||
|
|
||||||
|
// Lot e2 — le motif dit « pleine puissance », la puissance RETENUE, et ce qui la bride.
|
||||||
|
// ECO_FLOOR_GRID parlerait d'une obligation et d'un reste à livrer qu'une borne n'a pas ;
|
||||||
|
// et une borne servie sous sa pleine puissance se lirait comme une borne qui ne suit pas.
|
||||||
|
if (pleinePuissanceHc) {
|
||||||
|
// La pleine puissance du MATÉRIEL, aux phases visées : ce que la borne prendrait sans
|
||||||
|
// limite de phase. L'écart au plafond du cycle est ce que la protection a retiré.
|
||||||
|
const double materielW = lc.declared.maxA * 230.0 * qMax(1, lc.declared.phases);
|
||||||
|
QVariantMap pp{{"gridW", qRound(achatW)},
|
||||||
|
{"maxW", qRound(materielW)},
|
||||||
|
{"phases", qMax(1, lc.declared.phases)},
|
||||||
|
{"until", ctx.offPeak.nextChangeWall},
|
||||||
|
{"endsAt", ctx.offPeak.nextChange.toUTC().toString(Qt::ISODate)}};
|
||||||
|
if (achatW < materielW - 0.5) {
|
||||||
|
// La limite de phase rabaisse la borne. Jusqu'au plancher, c'est la stratégie qui
|
||||||
|
// la tient (`phaseLimit`) ; au plancher, la stratégie ne descend plus — couper est
|
||||||
|
// le geste de L4, et le motif le dit (`floor`).
|
||||||
|
const bool auPlancher = lc.declared.minPowerW > 0
|
||||||
|
&& achatW <= lc.declared.minPowerW + 0.5;
|
||||||
|
pp.insert(QStringLiteral("boundBy"), auPlancher ? QStringLiteral("floor")
|
||||||
|
: QStringLiteral("phaseLimit"));
|
||||||
|
}
|
||||||
|
eco.reason = { DecisionCode::EvOffPeakFullPower, pp };
|
||||||
|
}
|
||||||
|
|
||||||
// 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
|
||||||
// paliers, et son mécanisme arrondit vers le bas : 83 W devant un premier palier de
|
// paliers, et son mécanisme arrondit vers le bas : 83 W devant un premier palier de
|
||||||
// 500 W laissent le relais ouvert, et « 83 W achetés » se lisait sur un ballon froid.
|
// 500 W laissent le relais ouvert, et « 83 W achetés » se lisait sur un ballon froid.
|
||||||
|
|||||||
@ -333,6 +333,27 @@ QString renderFr(const DecisionReason &reason, const QString &label)
|
|||||||
return QStringLiteral("%1 attend les heures creuses (%2) — hors heures creuses, surplus "
|
return QStringLiteral("%1 attend les heures creuses (%2) — hors heures creuses, surplus "
|
||||||
"seulement (%3 W)")
|
"seulement (%3 W)")
|
||||||
.arg(label, p.value(QStringLiteral("at")).toString()).arg(w("budgetW"));
|
.arg(label, p.value(QStringLiteral("at")).toString()).arg(w("budgetW"));
|
||||||
|
// Lot e2 — la puissance RETENUE d'abord ; et si elle n'est pas la pleine puissance, QUI l'a
|
||||||
|
// retirée. Sans la seconde phrase, une borne bridée se lirait comme une borne qui ne suit pas.
|
||||||
|
if (c == QLatin1String(DecisionCode::EvOffPeakFullPower)) {
|
||||||
|
const QString jusqua = p.value(QStringLiteral("until")).toString();
|
||||||
|
const QString bride = p.value(QStringLiteral("boundBy")).toString();
|
||||||
|
if (bride == QLatin1String("floor"))
|
||||||
|
return QStringLiteral("%1 charge en heures creuses, tenue à son plancher (%2 W) par la "
|
||||||
|
"protection de surcharge du branchement : la limite de phase "
|
||||||
|
"est atteinte, la protection décide de la suite (pleine "
|
||||||
|
"puissance : %3 W, jusqu'à %4)")
|
||||||
|
.arg(label).arg(w("gridW")).arg(w("maxW")).arg(jusqua);
|
||||||
|
if (bride == QLatin1String("phaseLimit"))
|
||||||
|
return QStringLiteral("%1 charge en heures creuses à %2 W, bridée par la protection de "
|
||||||
|
"surcharge du branchement : la limite de phase ne laisse pas "
|
||||||
|
"plus (pleine puissance : %3 W, jusqu'à %4)")
|
||||||
|
.arg(label).arg(w("gridW")).arg(w("maxW")).arg(jusqua);
|
||||||
|
return QStringLiteral("%1 charge à pleine puissance en heures creuses : %2 W achetés au "
|
||||||
|
"réseau (%3 phase%4, jusqu'à %5)")
|
||||||
|
.arg(label).arg(w("gridW")).arg(w("phases"))
|
||||||
|
.arg(w("phases") > 1 ? QStringLiteral("s") : QString()).arg(jusqua);
|
||||||
|
}
|
||||||
if (c == QLatin1String(DecisionCode::StorageDischargeBlocked))
|
if (c == QLatin1String(DecisionCode::StorageDischargeBlocked))
|
||||||
return QStringLiteral("Décharge de la batterie bloquée : la voiture charge en heures "
|
return QStringLiteral("Décharge de la batterie bloquée : la voiture charge en heures "
|
||||||
"creuses (%1 W, jusqu'à %2)")
|
"creuses (%1 W, jusqu'à %2)")
|
||||||
@ -418,8 +439,8 @@ QStringList DecisionCode::knownCodes()
|
|||||||
ForecastDateUnreadable, TariffRefused, LoadConfigRefused,
|
ForecastDateUnreadable, TariffRefused, LoadConfigRefused,
|
||||||
LockMinStateHold, SgForced, SgRecommended, SgNormal,
|
LockMinStateHold, SgForced, SgRecommended, SgNormal,
|
||||||
OptimizerDecision, PriceSlot,
|
OptimizerDecision, PriceSlot,
|
||||||
EvWaitingOffPeak, StorageDischargeBlocked, StorageAutomatic, StorageNotProtected,
|
EvWaitingOffPeak, EvOffPeakFullPower, StorageDischargeBlocked, StorageAutomatic,
|
||||||
DegradedL2, SafeStateRelay, SafeStateSetpoint, SafeStateSgReady};
|
StorageNotProtected, DegradedL2, SafeStateRelay, SafeStateSetpoint, SafeStateSgReady};
|
||||||
}
|
}
|
||||||
|
|
||||||
QStringList DecisionCode::optimizerEmittableCodes()
|
QStringList DecisionCode::optimizerEmittableCodes()
|
||||||
|
|||||||
@ -446,6 +446,24 @@ constexpr const char *PriceSlot = "PRICE_SLOT";
|
|||||||
* la borne de charger même en heures creuses.
|
* la borne de charger même en heures creuses.
|
||||||
*/
|
*/
|
||||||
constexpr const char *EvWaitingOffPeak = "EV_WAITING_OFF_PEAK";
|
constexpr const char *EvWaitingOffPeak = "EV_WAITING_OFF_PEAK";
|
||||||
|
/*!
|
||||||
|
* \brief params: \c gridW, \c maxW, \c phases, \c until, \c endsAt, \c o:boundBy — la borne
|
||||||
|
* charge à PLEINE PUISSANCE en heures creuses (lot e2).
|
||||||
|
*
|
||||||
|
* Dans les mêmes trois modes que \c EV_WAITING_OFF_PEAK, le minimum servi au réseau en heures
|
||||||
|
* creuses est le plafond du cycle de la borne. \c gridW est la puissance RETENUE, achetée ;
|
||||||
|
* \c maxW la pleine puissance du matériel aux \c phases visées (courant maximal × 230 V ×
|
||||||
|
* phases). \c until (\c "HH:MM", heure murale) et \c endsAt (instant) disent quand les heures
|
||||||
|
* creuses finissent.
|
||||||
|
*
|
||||||
|
* \c boundBy n'est présent que si \c gridW < \c maxW : la protection de surcharge du
|
||||||
|
* branchement a rabaissé la borne. \c phaseLimit — à ce que la limite de phase laisse ;
|
||||||
|
* \c floor — au plancher de la borne, que la stratégie ne franchit jamais : couper revient
|
||||||
|
* alors à la protection de surcharge (L4), qui garde le dernier mot.
|
||||||
|
*
|
||||||
|
* \note N'est émis QUE si le tarif saisi a des heures creuses. Aucun optimiseur ne l'émet.
|
||||||
|
*/
|
||||||
|
constexpr const char *EvOffPeakFullPower = "EV_OFF_PEAK_FULL_POWER";
|
||||||
/*!
|
/*!
|
||||||
* \brief params: \c evPowerW, \c until, \c endsAt — la DÉCHARGE de la batterie est BLOQUÉE.
|
* \brief params: \c evPowerW, \c until, \c endsAt — la DÉCHARGE de la batterie est BLOQUÉE.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -121,8 +121,9 @@ struct LoadNeeds {
|
|||||||
* creuses, il vaut au moins \c offPeakMinPowerW. Non sérialisé vers l'optimiseur.
|
* creuses, il vaut au moins \c offPeakMinPowerW. Non sérialisé vers l'optimiseur.
|
||||||
*/
|
*/
|
||||||
bool offPeakShift = false;
|
bool offPeakShift = false;
|
||||||
//! Minimum servi en heures creuses quand \c offPeakShift est vrai (W) — le plancher de la
|
//! Minimum servi en heures creuses quand \c offPeakShift est vrai (W), dérivé comme
|
||||||
//! charge, dérivé comme \c minPowerWAlways. 0 = aucun.
|
//! \c minPowerWAlways. 0 = aucun. Pour une borne, c'est son PLAFOND de cycle depuis le lot e2
|
||||||
|
//! (le plancher au lot e1) : elle y charge à pleine puissance, limite de phase comprise.
|
||||||
double offPeakMinPowerW = 0;
|
double offPeakMinPowerW = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -10695,10 +10695,15 @@ QVariantMap batteriePilotable(uint revertS)
|
|||||||
QVariantMap{{"paramTypeId", QStringLiteral("{13b73424-5076-46b9-8eb8-38ee0078236e}")}, {"value", revertS}}}}};
|
QVariantMap{{"paramTypeId", QStringLiteral("{13b73424-5076-46b9-8eb8-38ee0078236e}")}, {"value", revertS}}}}};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//! Ce que la borne monophasée 16 A du scénario tire en heures creuses : son PLAFOND depuis le
|
||||||
|
//! lot e2 (son plancher, 1 380 W, au lot e1). Le compteur des tests l'inclut — sans quoi il
|
||||||
|
//! fabriquerait un surplus que la borne, recréditée de sa propre consommation, se servirait.
|
||||||
|
constexpr int TirageHcW = 3680;
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
Simulation::ScenarioHc Simulation::preparerScenarioHc(const QString &nom, BatterieHc batterie,
|
Simulation::ScenarioHc Simulation::preparerScenarioHc(const QString &nom, BatterieHc batterie,
|
||||||
const QVariantList &tarif)
|
const QVariantList &tarif, BorneHc typeBorne)
|
||||||
{
|
{
|
||||||
ScenarioHc s;
|
ScenarioHc s;
|
||||||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-hc-" + nom + ".json";
|
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-hc-" + nom + ".json";
|
||||||
@ -10719,7 +10724,9 @@ Simulation::ScenarioHc Simulation::preparerScenarioHc(const QString &nom, Batter
|
|||||||
s.meter = tm->findConfiguredThing(meterId);
|
s.meter = tm->findConfiguredThing(meterId);
|
||||||
s.meter->setStateValue("connected", true);
|
s.meter->setStateValue("connected", true);
|
||||||
|
|
||||||
s.borneId = addCharger("A", 16, 26802);
|
s.borneId = typeBorne == BorneHc::Monophasee ? addCharger("A", 16, 26802)
|
||||||
|
: typeBorne == BorneHc::Triphasee ? addCharger("ABC", 16, 26802)
|
||||||
|
: addChargerWithPhaseCountSwitching("ABC", 16, 26802);
|
||||||
const QUuid voitureId = addCar(26803);
|
const QUuid voitureId = addCar(26803);
|
||||||
s.borne = tm->findConfiguredThing(s.borneId);
|
s.borne = tm->findConfiguredThing(s.borneId);
|
||||||
Thing *voiture = tm->findConfiguredThing(voitureId);
|
Thing *voiture = tm->findConfiguredThing(voitureId);
|
||||||
@ -10728,7 +10735,7 @@ Simulation::ScenarioHc Simulation::preparerScenarioHc(const QString &nom, Batter
|
|||||||
voiture->setStateValue("batteryLevel", 30);
|
voiture->setStateValue("batteryLevel", 30);
|
||||||
voiture->setStateValue("capacity", 60);
|
voiture->setStateValue("capacity", 60);
|
||||||
voiture->setStateValue("minChargingCurrent", 6);
|
voiture->setStateValue("minChargingCurrent", 6);
|
||||||
voiture->setStateValue("phaseCount", 1);
|
voiture->setStateValue("phaseCount", typeBorne == BorneHc::Monophasee ? 1 : 3);
|
||||||
QVariantMap info{{"evChargerId", s.borneId}, {"assignedCarId", voitureId},
|
QVariantMap info{{"evChargerId", s.borneId}, {"assignedCarId", voitureId},
|
||||||
{"chargingMode", "ChargingModeEcoWithMinCurrent"}, {"targetPercentage", 100}};
|
{"chargingMode", "ChargingModeEcoWithMinCurrent"}, {"targetPercentage", 100}};
|
||||||
injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}}));
|
injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}}));
|
||||||
@ -10834,21 +10841,22 @@ void Simulation::testOffPeakDemoDay()
|
|||||||
QCOMPARE(decision(snap).value("code").toString(), QString(DecisionCode::EvWaitingOffPeak));
|
QCOMPARE(decision(snap).value("code").toString(), QString(DecisionCode::EvWaitingOffPeak));
|
||||||
QVERIFY2(s.ecrites->isEmpty(), "une écriture sur la batterie hors heures creuses");
|
QVERIFY2(s.ecrites->isEmpty(), "une écriture sur la batterie hors heures creuses");
|
||||||
|
|
||||||
// 22:00 — LA REPRISE. Le minimum est servi au réseau (passe éco).
|
// 22:00 — LA REPRISE. Le minimum est servi au réseau (passe éco) — à PLEINE PUISSANCE
|
||||||
|
// depuis le lot e2 : 16 A, le plafond de la borne, et non plus son plancher de 6 A.
|
||||||
snap = cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(20, 0)), 600);
|
snap = cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(20, 0)), 600);
|
||||||
QCOMPARE(decision(snap).value("code").toString(), QString(DecisionCode::EcoFloorGrid));
|
QCOMPARE(decision(snap).value("code").toString(), QString(DecisionCode::EvOffPeakFullPower));
|
||||||
QCOMPARE(qRound(telemetrieParId(snap).value(borne).value("allocatedW").toDouble()), 1380);
|
QCOMPARE(qRound(telemetrieParId(snap).value(borne).value("allocatedW").toDouble()), 3680);
|
||||||
QCOMPARE(s.borne->stateValue("power").toBool(), true);
|
QCOMPARE(s.borne->stateValue("power").toBool(), true);
|
||||||
QCOMPARE(s.borne->stateValue("maxChargingCurrent").toInt(), 6);
|
QCOMPARE(s.borne->stateValue("maxChargingCurrent").toInt(), 16);
|
||||||
// La batterie n'est bloquée qu'au cycle où la voiture TIRE : à 22:00 le compteur de la
|
// La batterie n'est bloquée qu'au cycle où la voiture TIRE : à 22:00 le compteur de la
|
||||||
// borne lisait encore 0.
|
// borne lisait encore 0.
|
||||||
QCOMPARE(stockage(snap).value("decision").toMap().value("params").toMap().value("cause").toString(),
|
QCOMPARE(stockage(snap).value("decision").toMap().value("params").toMap().value("cause").toString(),
|
||||||
QStringLiteral("evNotCharging"));
|
QStringLiteral("evNotCharging"));
|
||||||
QVERIFY(s.ecrites->isEmpty());
|
QVERIFY(s.ecrites->isEmpty());
|
||||||
|
|
||||||
// 22:01 → 23:30 — la voiture tire 1 380 W : la décharge est BLOQUÉE, et TENUE.
|
// 22:01 → 23:30 — la voiture tire : la décharge est BLOQUÉE, et TENUE.
|
||||||
QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(20, 1));
|
QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(20, 1));
|
||||||
snap = cycleHc(s, a, 1380 + 300);
|
snap = cycleHc(s, a, TirageHcW + 300);
|
||||||
QCOMPARE(stockage(snap).value("decision").toMap().value("code").toString(),
|
QCOMPARE(stockage(snap).value("decision").toMap().value("code").toString(),
|
||||||
QString(DecisionCode::StorageDischargeBlocked));
|
QString(DecisionCode::StorageDischargeBlocked));
|
||||||
QCOMPARE(stockage(snap).value("dischargeBlocked").toBool(), true);
|
QCOMPARE(stockage(snap).value("dischargeBlocked").toBool(), true);
|
||||||
@ -10866,7 +10874,7 @@ void Simulation::testOffPeakDemoDay()
|
|||||||
QVariantMap bat = stockage(snap).value("batteries").toList().value(0).toMap();
|
QVariantMap bat = stockage(snap).value("batteries").toList().value(0).toMap();
|
||||||
QCOMPARE(bat.value("held").toBool(), true);
|
QCOMPARE(bat.value("held").toBool(), true);
|
||||||
QCOMPARE(bat.value("observed").toMap().value("dischargingRate").toDouble(), 0.0);
|
QCOMPARE(bat.value("observed").toMap().value("dischargingRate").toDouble(), 0.0);
|
||||||
snap = cycleHc(s, a.addSecs(60), 1380 + 300);
|
snap = cycleHc(s, a.addSecs(60), TirageHcW + 300);
|
||||||
bat = stockage(snap).value("batteries").toList().value(0).toMap();
|
bat = stockage(snap).value("batteries").toList().value(0).toMap();
|
||||||
QCOMPARE(bat.value("observed").toMap().value("enableDischarging").toBool(), true);
|
QCOMPARE(bat.value("observed").toMap().value("enableDischarging").toBool(), true);
|
||||||
QCOMPARE(bat.value("observed").toMap().value("dischargingRate").toDouble(), 0.0);
|
QCOMPARE(bat.value("observed").toMap().value("dischargingRate").toDouble(), 0.0);
|
||||||
@ -10888,7 +10896,7 @@ void Simulation::testOffPeakDemoDay()
|
|||||||
// donc le moteur a réécrit avant chaque échéance de 300 s.
|
// donc le moteur a réécrit avant chaque échéance de 300 s.
|
||||||
for (int m = 3; m <= 89; ++m) {
|
for (int m = 3; m <= 89; ++m) {
|
||||||
a = utcDateTime(QDate(2026, 6, 8), QTime(20, 0)).addSecs(60 * m);
|
a = utcDateTime(QDate(2026, 6, 8), QTime(20, 0)).addSecs(60 * m);
|
||||||
snap = cycleHc(s, a, 1380 + 300);
|
snap = cycleHc(s, a, TirageHcW + 300);
|
||||||
QVERIFY2(limiteTenue(*s.ecrites, a.addSecs(59), 300),
|
QVERIFY2(limiteTenue(*s.ecrites, a.addSecs(59), 300),
|
||||||
qUtf8Printable(QStringLiteral("limite expirée à %1").arg(a.toString(Qt::ISODate))));
|
qUtf8Printable(QStringLiteral("limite expirée à %1").arg(a.toString(Qt::ISODate))));
|
||||||
}
|
}
|
||||||
@ -10952,19 +10960,20 @@ void Simulation::testOffPeakEndsAtSixAndOnDstDays()
|
|||||||
};
|
};
|
||||||
for (const Bascule &j : jours) {
|
for (const Bascule &j : jours) {
|
||||||
const QDateTime six = QDateTime::fromSecsSinceEpoch(j.sixHeures, QTimeZone::utc());
|
const QDateTime six = QDateTime::fromSecsSinceEpoch(j.sixHeures, QTimeZone::utc());
|
||||||
// Une heure en heures creuses : la borne charge au minimum, la voiture tire. Le verrou
|
// Une heure en heures creuses : la borne charge à pleine puissance, la voiture tire. Le
|
||||||
// d'enclenchement de la borne (300 s) est écoulé à 06:00 : c'est l'HEURE qui l'arrête.
|
// verrou d'enclenchement de la borne (300 s) est écoulé à 06:00 : c'est l'HEURE qui
|
||||||
|
// l'arrête.
|
||||||
QVariantMap snap;
|
QVariantMap snap;
|
||||||
for (int m : {60, 30, 3, 2, 1})
|
for (int m : {60, 30, 3, 2, 1})
|
||||||
snap = cycleHc(s, six.addSecs(-60 * m), 1380 + 300);
|
snap = cycleHc(s, six.addSecs(-60 * m), TirageHcW + 300);
|
||||||
QVERIFY2(telemetrieParId(snap).value(borne).value("decision").toMap().value("code").toString()
|
QVERIFY2(telemetrieParId(snap).value(borne).value("decision").toMap().value("code").toString()
|
||||||
== QString(DecisionCode::EcoFloorGrid), j.quoi);
|
== QString(DecisionCode::EvOffPeakFullPower), j.quoi);
|
||||||
QVERIFY2(snap.value("storage").toMap().value("dischargeBlocked").toBool(), j.quoi);
|
QVERIFY2(snap.value("storage").toMap().value("dischargeBlocked").toBool(), j.quoi);
|
||||||
QVERIFY2(s.borne->stateValue("power").toBool(), j.quoi);
|
QVERIFY2(s.borne->stateValue("power").toBool(), j.quoi);
|
||||||
|
|
||||||
// 06:00 — pleines : surplus seulement, la borne s'arrête, la batterie est rendue.
|
// 06:00 — pleines : surplus seulement, la borne s'arrête, la batterie est rendue.
|
||||||
const int avant = s.ecrites->size();
|
const int avant = s.ecrites->size();
|
||||||
snap = cycleHc(s, six, 1380 + 300);
|
snap = cycleHc(s, six, TirageHcW + 300);
|
||||||
const QVariantMap d = telemetrieParId(snap).value(borne).value("decision").toMap();
|
const QVariantMap d = telemetrieParId(snap).value(borne).value("decision").toMap();
|
||||||
QVERIFY2(d.value("code").toString() == QString(DecisionCode::EvWaitingOffPeak), j.quoi);
|
QVERIFY2(d.value("code").toString() == QString(DecisionCode::EvWaitingOffPeak), j.quoi);
|
||||||
QVERIFY2(d.value("params").toMap().value("at").toString() == QStringLiteral("22:00"), j.quoi);
|
QVERIFY2(d.value("params").toMap().value("at").toString() == QStringLiteral("22:00"), j.quoi);
|
||||||
@ -11051,7 +11060,7 @@ void Simulation::testABatteryWithoutControlIsReadAsBefore()
|
|||||||
s.borne->setStateValue("pluggedIn", true);
|
s.borne->setStateValue("pluggedIn", true);
|
||||||
QVariantMap snap;
|
QVariantMap snap;
|
||||||
for (int m = 0; m <= 2; ++m)
|
for (int m = 0; m <= 2; ++m)
|
||||||
snap = cycleHc(s, hc.addSecs(60 * m), 1380 + 300);
|
snap = cycleHc(s, hc.addSecs(60 * m), TirageHcW + 300);
|
||||||
const QVariantMap st = snap.value("storage").toMap();
|
const QVariantMap st = snap.value("storage").toMap();
|
||||||
QVERIFY2(!st.value("dischargeBlocked").toBool(), c.quoi);
|
QVERIFY2(!st.value("dischargeBlocked").toBool(), c.quoi);
|
||||||
QVERIFY2(st.value("decision").toMap().value("code").toString()
|
QVERIFY2(st.value("decision").toMap().value("code").toString()
|
||||||
@ -11063,7 +11072,7 @@ void Simulation::testABatteryWithoutControlIsReadAsBefore()
|
|||||||
QVERIFY2(s.ecrites->isEmpty(), c.quoi);
|
QVERIFY2(s.ecrites->isEmpty(), c.quoi);
|
||||||
// …et la borne, elle, charge quand même en heures creuses : le lot ne la prive de rien.
|
// …et la borne, elle, charge quand même en heures creuses : le lot ne la prive de rien.
|
||||||
QVERIFY2(telemetrieParId(snap).value(s.borneId.toString()).value("decision").toMap()
|
QVERIFY2(telemetrieParId(snap).value(s.borneId.toString()).value("decision").toMap()
|
||||||
.value("code").toString() == QString(DecisionCode::EcoFloorGrid), c.quoi);
|
.value("code").toString() == QString(DecisionCode::EvOffPeakFullPower), c.quoi);
|
||||||
}
|
}
|
||||||
|
|
||||||
// BATTERIE ABSENTE : la question ne se pose pas — aucun bloc `storage`, et la borne attend
|
// BATTERIE ABSENTE : la question ne se pose pas — aucun bloc `storage`, et la borne attend
|
||||||
@ -11077,7 +11086,7 @@ void Simulation::testABatteryWithoutControlIsReadAsBefore()
|
|||||||
QVERIFY(!snap.contains("storage"));
|
QVERIFY(!snap.contains("storage"));
|
||||||
snap = cycleHc(s, hc, 600);
|
snap = cycleHc(s, hc, 600);
|
||||||
QCOMPARE(telemetrieParId(snap).value(s.borneId.toString()).value("decision").toMap()
|
QCOMPARE(telemetrieParId(snap).value(s.borneId.toString()).value("decision").toMap()
|
||||||
.value("code").toString(), QString(DecisionCode::EcoFloorGrid));
|
.value("code").toString(), QString(DecisionCode::EvOffPeakFullPower));
|
||||||
QVERIFY(!snap.contains("storage"));
|
QVERIFY(!snap.contains("storage"));
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@ -11109,15 +11118,15 @@ void Simulation::testBlockedDischargeShowsAtTheMeterAndL4Decides()
|
|||||||
QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(21, 0));
|
QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(21, 0));
|
||||||
QVariantMap snap;
|
QVariantMap snap;
|
||||||
for (int m = 0; m <= 2; ++m)
|
for (int m = 0; m <= 2; ++m)
|
||||||
snap = cycleHc(s, a.addSecs(60 * m), 1380 + 300);
|
snap = cycleHc(s, a.addSecs(60 * m), TirageHcW + 300);
|
||||||
QVERIFY(snap.value("storage").toMap().value("dischargeBlocked").toBool());
|
QVERIFY(snap.value("storage").toMap().value("dischargeBlocked").toBool());
|
||||||
QVERIFY(s.borne->stateValue("power").toBool());
|
QVERIFY(s.borne->stateValue("power").toBool());
|
||||||
|
|
||||||
// INVARIANT 8 — la batterie bloquée ne charge ni ne décharge (0 W), la maison importe : le
|
// INVARIANT 8 — la batterie bloquée ne charge ni ne décharge (0 W), la maison importe : le
|
||||||
// budget publié est exactement −(import), et non « import − ce que la batterie aurait donné ».
|
// budget publié est exactement −(import), et non « import − ce que la batterie aurait donné ».
|
||||||
s.batterie->setStateValue("currentPower", 0);
|
s.batterie->setStateValue("currentPower", 0);
|
||||||
snap = cycleHc(s, a.addSecs(180), 1380 + 1800);
|
snap = cycleHc(s, a.addSecs(180), TirageHcW + 1800);
|
||||||
QCOMPARE(qRound(snap.value("budget").toMap().value("surplusW").toDouble()), -(1380 + 1800));
|
QCOMPARE(qRound(snap.value("budget").toMap().value("surplusW").toDouble()), -(TirageHcW + 1800));
|
||||||
|
|
||||||
// Le soutirage monte : la phase A passe à 28 A pour une limite de 25 A. La borne n'est plus
|
// 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
|
// servie (plafond de soutirage, protection de surcharge) — et la batterie, qui ne voit plus
|
||||||
@ -11148,7 +11157,7 @@ void Simulation::testAMeterSilenceHandsTheBatteryBack()
|
|||||||
const QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(21, 0));
|
const QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(21, 0));
|
||||||
QVariantMap snap;
|
QVariantMap snap;
|
||||||
for (int m = 0; m <= 2; ++m)
|
for (int m = 0; m <= 2; ++m)
|
||||||
snap = cycleHc(s, a.addSecs(60 * m), 1380 + 300);
|
snap = cycleHc(s, a.addSecs(60 * m), TirageHcW + 300);
|
||||||
QVERIFY(snap.value("storage").toMap().value("dischargeBlocked").toBool());
|
QVERIFY(snap.value("storage").toMap().value("dischargeBlocked").toBool());
|
||||||
QCOMPARE(s.batterie->stateValue("enableDischarging").toBool(), true);
|
QCOMPARE(s.batterie->stateValue("enableDischarging").toBool(), true);
|
||||||
|
|
||||||
@ -11212,7 +11221,7 @@ void Simulation::testRefusedWritesAreSpacedAndNeverDelayTheRelease()
|
|||||||
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(21, 0)); // 23:00 à Paris
|
const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(21, 0)); // 23:00 à Paris
|
||||||
QVariantMap snap;
|
QVariantMap snap;
|
||||||
for (int m = 0; m <= 50; ++m)
|
for (int m = 0; m <= 50; ++m)
|
||||||
snap = cycleHc(s, t0.addSecs(60 * m), 1380 + 300);
|
snap = cycleHc(s, t0.addSecs(60 * m), TirageHcW + 300);
|
||||||
const QList<QDateTime> p = poses();
|
const QList<QDateTime> p = poses();
|
||||||
QVERIFY(!p.isEmpty());
|
QVERIFY(!p.isEmpty());
|
||||||
const QDateTime premiere = p.first();
|
const QDateTime premiere = p.first();
|
||||||
@ -11251,7 +11260,7 @@ void Simulation::testRefusedWritesAreSpacedAndNeverDelayTheRelease()
|
|||||||
QDateTime a = t0.addSecs(60 * 50);
|
QDateTime a = t0.addSecs(60 * 50);
|
||||||
while (a < reprise) {
|
while (a < reprise) {
|
||||||
a = a.addSecs(60);
|
a = a.addSecs(60);
|
||||||
snap = cycleHc(s, a, 1380 + 300);
|
snap = cycleHc(s, a, TirageHcW + 300);
|
||||||
}
|
}
|
||||||
QCOMPARE(poses().size(), 8);
|
QCOMPARE(poses().size(), 8);
|
||||||
QCOMPARE(poses().last(), reprise);
|
QCOMPARE(poses().last(), reprise);
|
||||||
@ -11259,7 +11268,7 @@ void Simulation::testRefusedWritesAreSpacedAndNeverDelayTheRelease()
|
|||||||
QVERIFY(tentatives.last().acceptee);
|
QVERIFY(tentatives.last().acceptee);
|
||||||
QCOMPARE(journal.compte(QtInfoMsg, QStringLiteral("ACCEPTÉE après 7 refus")), 1);
|
QCOMPARE(journal.compte(QtInfoMsg, QStringLiteral("ACCEPTÉE après 7 refus")), 1);
|
||||||
a = a.addSecs(60);
|
a = a.addSecs(60);
|
||||||
snap = cycleHc(s, a, 1380 + 300);
|
snap = cycleHc(s, a, TirageHcW + 300);
|
||||||
QCOMPARE(snap.value("storage").toMap().value("dischargeBlocked").toBool(), true);
|
QCOMPARE(snap.value("storage").toMap().value("dischargeBlocked").toBool(), true);
|
||||||
bat = batterie(snap);
|
bat = batterie(snap);
|
||||||
QCOMPARE(bat.value("held").toBool(), true);
|
QCOMPARE(bat.value("held").toBool(), true);
|
||||||
@ -11269,7 +11278,7 @@ void Simulation::testRefusedWritesAreSpacedAndNeverDelayTheRelease()
|
|||||||
const int poseesAvant = s.ecrites->size();
|
const int poseesAvant = s.ecrites->size();
|
||||||
for (int m = 0; m < 20; ++m) {
|
for (int m = 0; m < 20; ++m) {
|
||||||
a = a.addSecs(60);
|
a = a.addSecs(60);
|
||||||
snap = cycleHc(s, a, 1380 + 300);
|
snap = cycleHc(s, a, TirageHcW + 300);
|
||||||
QVERIFY2(limiteTenue(*s.ecrites, a.addSecs(59), 300),
|
QVERIFY2(limiteTenue(*s.ecrites, a.addSecs(59), 300),
|
||||||
qUtf8Printable(QStringLiteral("limite expirée à %1").arg(a.toString(Qt::ISODate))));
|
qUtf8Printable(QStringLiteral("limite expirée à %1").arg(a.toString(Qt::ISODate))));
|
||||||
}
|
}
|
||||||
@ -11281,7 +11290,7 @@ void Simulation::testRefusedWritesAreSpacedAndNeverDelayTheRelease()
|
|||||||
for (int garde = 0; batterie(snap).value("refusedWrites").toInt() < 3; ++garde) {
|
for (int garde = 0; batterie(snap).value("refusedWrites").toInt() < 3; ++garde) {
|
||||||
QVERIFY2(garde < 30, "la série de refus ne s'est pas ouverte");
|
QVERIFY2(garde < 30, "la série de refus ne s'est pas ouverte");
|
||||||
a = a.addSecs(60);
|
a = a.addSecs(60);
|
||||||
snap = cycleHc(s, a, 1380 + 300);
|
snap = cycleHc(s, a, TirageHcW + 300);
|
||||||
}
|
}
|
||||||
QCOMPARE(journal.compte(QtWarningMsg, serie), 2);
|
QCOMPARE(journal.compte(QtWarningMsg, serie), 2);
|
||||||
const QDateTime prochain = QDateTime::fromString(batterie(snap).value("nextAttemptAt").toString(), Qt::ISODate);
|
const QDateTime prochain = QDateTime::fromString(batterie(snap).value("nextAttemptAt").toString(), Qt::ISODate);
|
||||||
@ -11304,16 +11313,227 @@ void Simulation::testRefusedWritesAreSpacedAndNeverDelayTheRelease()
|
|||||||
|
|
||||||
// 4. La levée a CLOS la série : la voiture tire de nouveau, la pose part tout de suite, et
|
// 4. La levée a CLOS la série : la voiture tire de nouveau, la pose part tout de suite, et
|
||||||
// son premier refus est signalé comme celui d'une nouvelle série.
|
// son premier refus est signalé comme celui d'une nouvelle série.
|
||||||
s.borne->setStateValue("currentPower", 1380);
|
s.borne->setStateValue("currentPower", TirageHcW);
|
||||||
s.borne->setStateValue("charging", true);
|
s.borne->setStateValue("charging", true);
|
||||||
const int avantRepose = poses().size();
|
const int avantRepose = poses().size();
|
||||||
a = a.addSecs(60);
|
a = a.addSecs(60);
|
||||||
snap = cycleHc(s, a, 1380 + 300);
|
snap = cycleHc(s, a, TirageHcW + 300);
|
||||||
QCOMPARE(poses().size(), avantRepose + 1);
|
QCOMPARE(poses().size(), avantRepose + 1);
|
||||||
QCOMPARE(poses().last(), a);
|
QCOMPARE(poses().last(), a);
|
||||||
a = a.addSecs(60);
|
a = a.addSecs(60);
|
||||||
snap = cycleHc(s, a, 1380 + 300);
|
snap = cycleHc(s, a, TirageHcW + 300);
|
||||||
QCOMPARE(journal.compte(QtWarningMsg, serie), 3);
|
QCOMPARE(journal.compte(QtWarningMsg, serie), 3);
|
||||||
QCOMPARE(batterie(snap).value("refusedWrites").toInt(), 1);
|
QCOMPARE(batterie(snap).value("refusedWrites").toInt(), 1);
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================================
|
||||||
|
// Lot e2 (2026-10-24) — en heures creuses, la borne charge à la pleine puissance permise
|
||||||
|
// ============================================================================================
|
||||||
|
|
||||||
|
void Simulation::testOffPeakChargesAtFullPower()
|
||||||
|
{
|
||||||
|
#ifndef ETM_ARBITRATOR
|
||||||
|
QSKIP("nécessite ETM_ARBITRATOR.");
|
||||||
|
#else
|
||||||
|
// DÉCISION DU 2026-10-24 — en heures creuses, la borne reçoit son PLAFOND de cycle, plus son
|
||||||
|
// plancher. Au plancher (6 A), une session de 10 à 25 kWh n'était pas finie au départ et la
|
||||||
|
// batterie restait bloquée toute la nuit. Monophasée 16 A : 3 680 W ; triphasée 16 A :
|
||||||
|
// 11 040 W. Hors heures creuses, rien ne change. Quand la voiture ne prend plus rien, la
|
||||||
|
// batterie est rendue — même si la borne reste commandée à 16 A.
|
||||||
|
FuseauDeTest fuseau("Europe/Paris");
|
||||||
|
struct Cas { const char *quoi; BorneHc borne; int phases; int pleineW; };
|
||||||
|
const QList<Cas> cas = {
|
||||||
|
{"monophasée 16 A", BorneHc::Monophasee, 1, 3680},
|
||||||
|
{"triphasée 16 A", BorneHc::Triphasee, 3, 11040},
|
||||||
|
};
|
||||||
|
for (const Cas &c : cas) {
|
||||||
|
ScenarioHc s = preparerScenarioHc(QStringLiteral("pleine-%1").arg(c.phases),
|
||||||
|
BatterieHc::Pilotable, hphcT4(), c.borne);
|
||||||
|
QVERIFY2(s.arb && s.borne && s.batterie, c.quoi);
|
||||||
|
const QString borne = s.borneId.toString();
|
||||||
|
const auto charge = [&](const QVariantMap &snap) { return telemetrieParId(snap).value(borne); };
|
||||||
|
s.borne->setStateValue("pluggedIn", true);
|
||||||
|
|
||||||
|
// 18:00 — HORS heures creuses : INCHANGÉ. Surplus seulement, la borne attend.
|
||||||
|
QVariantMap snap;
|
||||||
|
for (int m = 0; m <= 2; ++m)
|
||||||
|
snap = cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(16, m)), 600);
|
||||||
|
QVERIFY2(charge(snap).value("decision").toMap().value("code").toString()
|
||||||
|
== QString(DecisionCode::EvWaitingOffPeak), c.quoi);
|
||||||
|
QVERIFY2(qFuzzyIsNull(charge(snap).value("allocatedW").toDouble()), c.quoi);
|
||||||
|
QVERIFY2(!s.borne->stateValue("power").toBool(), c.quoi);
|
||||||
|
|
||||||
|
// 22:00 — EN heures creuses : le PLAFOND, acheté au réseau, et le motif le dit.
|
||||||
|
snap = cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(20, 0)), 600);
|
||||||
|
const QVariantMap d = charge(snap).value("decision").toMap();
|
||||||
|
QVERIFY2(d.value("code").toString() == QString(DecisionCode::EvOffPeakFullPower),
|
||||||
|
qUtf8Printable(QJsonDocument::fromVariant(charge(snap)).toJson(QJsonDocument::Compact)));
|
||||||
|
QCOMPARE(qRound(charge(snap).value("allocatedW").toDouble()), c.pleineW);
|
||||||
|
QCOMPARE(qRound(snap.value("draw").toMap().value("committedW").toDouble()), c.pleineW);
|
||||||
|
QVERIFY2(s.borne->stateValue("power").toBool(), c.quoi);
|
||||||
|
QCOMPARE(s.borne->stateValue("maxChargingCurrent").toInt(), 16);
|
||||||
|
const QVariantMap p = d.value("params").toMap();
|
||||||
|
QCOMPARE(p.value("gridW").toInt(), c.pleineW);
|
||||||
|
QCOMPARE(p.value("maxW").toInt(), c.pleineW);
|
||||||
|
QCOMPARE(p.value("phases").toInt(), c.phases);
|
||||||
|
QCOMPARE(p.value("until").toString(), QStringLiteral("06:00"));
|
||||||
|
QCOMPARE(p.value("endsAt").toString(), QStringLiteral("2026-06-09T04:00:00Z"));
|
||||||
|
QVERIFY2(!p.contains("boundBy"), "pleine puissance : rien ne la bride, et aucune clé ne le prétend");
|
||||||
|
QCOMPARE(renderFr(DecisionReason(DecisionCode::EvOffPeakFullPower, p), QStringLiteral("Borne")),
|
||||||
|
QStringLiteral("Borne charge à pleine puissance en heures creuses : %1 W achetés au "
|
||||||
|
"réseau (%2, jusqu'à 06:00)")
|
||||||
|
.arg(c.pleineW).arg(c.phases == 1 ? QStringLiteral("1 phase") : QStringLiteral("3 phases")));
|
||||||
|
|
||||||
|
// 22:01 — la voiture tire sa pleine puissance : la décharge est bloquée.
|
||||||
|
s.borne->setStateValue("currentPower", c.pleineW);
|
||||||
|
snap = cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(20, 1)), c.pleineW + 300);
|
||||||
|
QVERIFY2(snap.value("storage").toMap().value("dischargeBlocked").toBool(), c.quoi);
|
||||||
|
QCOMPARE(snap.value("storage").toMap().value("decision").toMap().value("params").toMap()
|
||||||
|
.value("evPowerW").toInt(), c.pleineW);
|
||||||
|
|
||||||
|
// 23:00 — FIN DE SESSION : la voiture est pleine. La borne reste commandée à 16 A
|
||||||
|
// (elle est en heures creuses, son minimum tient), mais elle ne tire plus que 100 W,
|
||||||
|
// sous le seuil de 230 W : la batterie est RENDUE au cycle même.
|
||||||
|
s.borne->setStateValue("currentPower", 100);
|
||||||
|
s.borne->setStateValue("charging", false);
|
||||||
|
snap = cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(21, 0)), 400);
|
||||||
|
QCOMPARE(s.borne->stateValue("maxChargingCurrent").toInt(), 16);
|
||||||
|
QVERIFY2(s.borne->stateValue("power").toBool(), "commandée à 16 A, et c'est la voiture qui ne prend rien");
|
||||||
|
QVERIFY2(!snap.value("storage").toMap().value("dischargeBlocked").toBool(), c.quoi);
|
||||||
|
QCOMPARE(snap.value("storage").toMap().value("decision").toMap().value("params").toMap()
|
||||||
|
.value("cause").toString(), QStringLiteral("evNotCharging"));
|
||||||
|
QCOMPARE(s.batterie->stateValue("enableDischarging").toBool(), false);
|
||||||
|
QCOMPARE(s.batterie->stateValue("dischargingRate").toInt(), 100);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void Simulation::testOffPeakFullPowerSwitchesToThreePhases()
|
||||||
|
{
|
||||||
|
#ifndef ETM_ARBITRATOR
|
||||||
|
QSKIP("nécessite ETM_ARBITRATOR.");
|
||||||
|
#else
|
||||||
|
// LE CHOIX DES PHASES, là où il bridait. Borne triphasée à bascule, voiture triphasée, la
|
||||||
|
// borne tire déjà 6 A sur UNE phase et une batterie NON pilotable couvre la maison : le
|
||||||
|
// compteur lit 0. La référence « courant + surplus » vaut alors 6 A, et la règle de bascule
|
||||||
|
// du manager garde le monophasé — 3 680 W au plus, toute la nuit, tant que la batterie
|
||||||
|
// couvre. En heures creuses la référence est la pleine puissance : trois phases, 11 040 W.
|
||||||
|
// Hors heures creuses, la règle d'avant s'applique toujours.
|
||||||
|
FuseauDeTest fuseau("Europe/Paris");
|
||||||
|
ScenarioHc s = preparerScenarioHc(QStringLiteral("bascule"), BatterieHc::SansEtats, hphcT4(),
|
||||||
|
BorneHc::Bascule);
|
||||||
|
QVERIFY(s.arb && s.borne && s.batterie);
|
||||||
|
const QString borne = s.borneId.toString();
|
||||||
|
s.borne->setStateValue("pluggedIn", true);
|
||||||
|
|
||||||
|
const auto tireEnMono = [&]() {
|
||||||
|
s.borne->setStateValue("power", true);
|
||||||
|
s.borne->setStateValue("charging", true);
|
||||||
|
s.borne->setStateValue("maxChargingCurrent", 6);
|
||||||
|
s.borne->setStateValue("currentPower", 1380);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 22:00 — en heures creuses : TROIS phases, pleine puissance.
|
||||||
|
QVariantMap snap;
|
||||||
|
for (int m = 0; m <= 1; ++m) {
|
||||||
|
tireEnMono();
|
||||||
|
snap = cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(20, m)), 0);
|
||||||
|
}
|
||||||
|
const QVariantMap d = telemetrieParId(snap).value(borne).value("decision").toMap();
|
||||||
|
QVERIFY2(d.value("code").toString() == QString(DecisionCode::EvOffPeakFullPower),
|
||||||
|
qUtf8Printable(QJsonDocument::fromVariant(d).toJson(QJsonDocument::Compact)));
|
||||||
|
QCOMPARE(d.value("params").toMap().value("phases").toInt(), 3);
|
||||||
|
QCOMPARE(qRound(telemetrieParId(snap).value(borne).value("allocatedW").toDouble()), 11040);
|
||||||
|
QCOMPARE(s.borne->stateValue("desiredPhaseCount").toUInt(), 3u);
|
||||||
|
QCOMPARE(s.borne->stateValue("maxChargingCurrent").toInt(), 16);
|
||||||
|
// La batterie n'est pas pilotable : le lot ne la commande pas, et le dit.
|
||||||
|
QCOMPARE(snap.value("storage").toMap().value("decision").toMap().value("code").toString(),
|
||||||
|
QString(DecisionCode::StorageNotProtected));
|
||||||
|
|
||||||
|
// TÉMOIN — le lendemain 21:59, hors heures creuses, mêmes mesures : la règle d'avant,
|
||||||
|
// monophasée. Sans ce témoin, « trois phases » pourrait venir d'ailleurs que de la référence.
|
||||||
|
tireEnMono();
|
||||||
|
snap = cycleHc(s, utcDateTime(QDate(2026, 6, 9), QTime(19, 59)), 0);
|
||||||
|
QCOMPARE(s.borne->stateValue("desiredPhaseCount").toUInt(), 1u);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
void Simulation::testOffPeakFullPowerYieldsToThePhaseLimit()
|
||||||
|
{
|
||||||
|
#ifndef ETM_ARBITRATOR
|
||||||
|
QSKIP("nécessite ETM_ARBITRATOR.");
|
||||||
|
#else
|
||||||
|
// SÉCURITÉ (invariant 2) — à pleine puissance, la borne reste sous la limite de phase, et une
|
||||||
|
// 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.
|
||||||
|
FuseauDeTest fuseau("Europe/Paris");
|
||||||
|
ScenarioHc s = preparerScenarioHc(QStringLiteral("limite"), BatterieHc::Pilotable, hphcT4(),
|
||||||
|
BorneHc::Triphasee);
|
||||||
|
QVERIFY(s.arb && s.borne && s.batterie);
|
||||||
|
const QString borne = s.borneId.toString();
|
||||||
|
QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 25}}))
|
||||||
|
.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();
|
||||||
|
};
|
||||||
|
QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(21, 0));
|
||||||
|
QVariantMap snap;
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
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);
|
||||||
|
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("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);
|
||||||
|
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");
|
||||||
|
|
||||||
|
// 4. 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);
|
||||||
|
QVERIFY2(!s.borne->stateValue("power").toBool(),
|
||||||
|
qUtf8Printable(QJsonDocument::fromVariant(telemetrieParId(snap).value(borne)).toJson(QJsonDocument::Compact)));
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|||||||
@ -71,8 +71,12 @@ private:
|
|||||||
std::shared_ptr<QList<struct EcritureBatterie>> ecrites; //!< Lues à la frontière nymea.
|
std::shared_ptr<QList<struct EcritureBatterie>> ecrites; //!< Lues à la frontière nymea.
|
||||||
std::shared_ptr<QDateTime> cycleCourant; //!< Date les écritures.
|
std::shared_ptr<QDateTime> cycleCourant; //!< Date les écritures.
|
||||||
};
|
};
|
||||||
//! Serveur neuf, compteur, borne monophasée « Éco avec minimum » (voiture débranchée), batterie.
|
//! Quelle borne pose le scénario (lot e2) : 16 A, monophasée, triphasée, ou triphasée à
|
||||||
ScenarioHc preparerScenarioHc(const QString &nom, BatterieHc batterie, const QVariantList &tarif);
|
//! bascule de phases. La voiture suit : monophasée avec la première, triphasée sinon.
|
||||||
|
enum class BorneHc { Monophasee, Triphasee, Bascule };
|
||||||
|
//! Serveur neuf, compteur, borne 16 A « Éco avec minimum » (voiture débranchée), batterie.
|
||||||
|
ScenarioHc preparerScenarioHc(const QString &nom, BatterieHc batterie, const QVariantList &tarif,
|
||||||
|
BorneHc borne = BorneHc::Monophasee);
|
||||||
//! Un cycle à l'instant \p a, compteur à \p compteurW (import > 0) ; rend la trame publiée.
|
//! Un cycle à l'instant \p a, compteur à \p compteurW (import > 0) ; rend la trame publiée.
|
||||||
QVariantMap cycleHc(ScenarioHc &s, const QDateTime &a, double compteurW);
|
QVariantMap cycleHc(ScenarioHc &s, const QDateTime &a, double compteurW);
|
||||||
|
|
||||||
@ -274,6 +278,14 @@ private slots:
|
|||||||
//! Onduleur qui REFUSE : poses espacées (1 → 15 min), un avertissement par série, `held` faux ;
|
//! Onduleur qui REFUSE : poses espacées (1 → 15 min), un avertissement par série, `held` faux ;
|
||||||
//! une pose acceptée rend le rythme normal ; la levée n'attend jamais.
|
//! une pose acceptée rend le rythme normal ; la levée n'attend jamais.
|
||||||
void testRefusedWritesAreSpacedAndNeverDelayTheRelease();
|
void testRefusedWritesAreSpacedAndNeverDelayTheRelease();
|
||||||
|
//! Lot e2 — en heures creuses, la borne reçoit son PLAFOND (3 680 W en mono, 11 040 W en tri) ;
|
||||||
|
//! hors heures creuses inchangé ; voiture pleine sous 230 W : la batterie est rendue.
|
||||||
|
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.
|
||||||
|
void testOffPeakFullPowerYieldsToThePhaseLimit();
|
||||||
//! 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