diff --git a/energyplugin/etm/adapters/evadapter.cpp b/energyplugin/etm/adapters/evadapter.cpp index 1a4409a..eb65db2 100644 --- a/energyplugin/etm/adapters/evadapter.cpp +++ b/energyplugin/etm/adapters/evadapter.cpp @@ -51,7 +51,8 @@ LoadTelemetry EvAdapter::telemetry() 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; ctx.id = m_charger->thing()->id().toString(); 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. // 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. - 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); if (m_targetPhases == 0) 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 // 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. - const ChargingInfo::ChargingMode mode = m_parent->chargingInfo(m_charger->id()).chargingMode(); if (mode == ChargingInfo::ChargingModeEcoWithMinCurrent || mode == ChargingInfo::ChargingModeEcoMinWithTargetTime) { 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 ------------------- // // 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)); } + // --- 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.available = m_charger->available(); ctx.telemetry.pluggedIn = m_charger->pluggedIn(); diff --git a/energyplugin/etm/adapters/evadapter.h b/energyplugin/etm/adapters/evadapter.h index c14501e..e39d471 100644 --- a/energyplugin/etm/adapters/evadapter.h +++ b/energyplugin/etm/adapters/evadapter.h @@ -46,9 +46,14 @@ public: /*! * \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 - * de palier (hors waterfall ECS/SG-Ready). Présent pour l'uniformité de \c ILoadAdapter. + * \param now Temps de cycle (\c ctx.timestamp). L'EV n'a pas de verrou de palier (hors + * 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. + * \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; diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 289c395..52bfe8c 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -1509,6 +1509,15 @@ void EnergyArbitrator::qualifierRefus(LoadContext &lc, const QDateTime &now) con 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 ctx; @@ -1540,7 +1549,7 @@ SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const if (!m_tariff.zonesCreuses().isEmpty()) { const QTimeZone fuseau = QTimeZone::systemTimeZone(); 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); if (ctx.offPeak.nextChange.isValid()) ctx.offPeak.nextChangeWall = ctx.offPeak.nextChange.toTimeZone(fuseau).toString(QStringLiteral("HH:mm")); diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index a89eff8..10ef4db 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -129,6 +129,19 @@ public: */ 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. * \param t Tarif DÉJÀ validé (\c TariffConfig::fromVariant) — il n'en existe pas d'autre. diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index 4aad464..bf83b2f 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -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 // c'est le délestage qui rattrapera ce qui a déjà été engagé. 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)); // 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 }; + // 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 // 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. diff --git a/energyplugin/etm/types/decisionreason.cpp b/energyplugin/etm/types/decisionreason.cpp index ccf8bcf..6e9f90d 100644 --- a/energyplugin/etm/types/decisionreason.cpp +++ b/energyplugin/etm/types/decisionreason.cpp @@ -333,6 +333,27 @@ QString renderFr(const DecisionReason &reason, const QString &label) return QStringLiteral("%1 attend les heures creuses (%2) — hors heures creuses, surplus " "seulement (%3 W)") .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)) return QStringLiteral("Décharge de la batterie bloquée : la voiture charge en heures " "creuses (%1 W, jusqu'à %2)") @@ -418,8 +439,8 @@ QStringList DecisionCode::knownCodes() ForecastDateUnreadable, TariffRefused, LoadConfigRefused, LockMinStateHold, SgForced, SgRecommended, SgNormal, OptimizerDecision, PriceSlot, - EvWaitingOffPeak, StorageDischargeBlocked, StorageAutomatic, StorageNotProtected, - DegradedL2, SafeStateRelay, SafeStateSetpoint, SafeStateSgReady}; + EvWaitingOffPeak, EvOffPeakFullPower, StorageDischargeBlocked, StorageAutomatic, + StorageNotProtected, DegradedL2, SafeStateRelay, SafeStateSetpoint, SafeStateSgReady}; } QStringList DecisionCode::optimizerEmittableCodes() diff --git a/energyplugin/etm/types/decisionreason.h b/energyplugin/etm/types/decisionreason.h index 3ac448e..9a7de1c 100644 --- a/energyplugin/etm/types/decisionreason.h +++ b/energyplugin/etm/types/decisionreason.h @@ -446,6 +446,24 @@ constexpr const char *PriceSlot = "PRICE_SLOT"; * la borne de charger même en heures creuses. */ 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. * diff --git a/energyplugin/etm/types/loaddescriptor.h b/energyplugin/etm/types/loaddescriptor.h index e853320..50f040c 100644 --- a/energyplugin/etm/types/loaddescriptor.h +++ b/energyplugin/etm/types/loaddescriptor.h @@ -121,8 +121,9 @@ struct LoadNeeds { * creuses, il vaut au moins \c offPeakMinPowerW. Non sérialisé vers l'optimiseur. */ bool offPeakShift = false; - //! Minimum servi en heures creuses quand \c offPeakShift est vrai (W) — le plancher de la - //! charge, dérivé comme \c minPowerWAlways. 0 = aucun. + //! Minimum servi en heures creuses quand \c offPeakShift est vrai (W), dérivé comme + //! \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; }; diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index da4a88f..13cccb6 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -10695,10 +10695,15 @@ QVariantMap batteriePilotable(uint 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 Simulation::ScenarioHc Simulation::preparerScenarioHc(const QString &nom, BatterieHc batterie, - const QVariantList &tarif) + const QVariantList &tarif, BorneHc typeBorne) { ScenarioHc s; 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->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); s.borne = tm->findConfiguredThing(s.borneId); Thing *voiture = tm->findConfiguredThing(voitureId); @@ -10728,7 +10735,7 @@ Simulation::ScenarioHc Simulation::preparerScenarioHc(const QString &nom, Batter voiture->setStateValue("batteryLevel", 30); voiture->setStateValue("capacity", 60); voiture->setStateValue("minChargingCurrent", 6); - voiture->setStateValue("phaseCount", 1); + voiture->setStateValue("phaseCount", typeBorne == BorneHc::Monophasee ? 1 : 3); QVariantMap info{{"evChargerId", s.borneId}, {"assignedCarId", voitureId}, {"chargingMode", "ChargingModeEcoWithMinCurrent"}, {"targetPercentage", 100}}; injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}})); @@ -10834,21 +10841,22 @@ void Simulation::testOffPeakDemoDay() QCOMPARE(decision(snap).value("code").toString(), QString(DecisionCode::EvWaitingOffPeak)); 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); - QCOMPARE(decision(snap).value("code").toString(), QString(DecisionCode::EcoFloorGrid)); - QCOMPARE(qRound(telemetrieParId(snap).value(borne).value("allocatedW").toDouble()), 1380); + QCOMPARE(decision(snap).value("code").toString(), QString(DecisionCode::EvOffPeakFullPower)); + QCOMPARE(qRound(telemetrieParId(snap).value(borne).value("allocatedW").toDouble()), 3680); 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 // borne lisait encore 0. QCOMPARE(stockage(snap).value("decision").toMap().value("params").toMap().value("cause").toString(), QStringLiteral("evNotCharging")); 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)); - snap = cycleHc(s, a, 1380 + 300); + snap = cycleHc(s, a, TirageHcW + 300); QCOMPARE(stockage(snap).value("decision").toMap().value("code").toString(), QString(DecisionCode::StorageDischargeBlocked)); QCOMPARE(stockage(snap).value("dischargeBlocked").toBool(), true); @@ -10866,7 +10874,7 @@ void Simulation::testOffPeakDemoDay() QVariantMap bat = stockage(snap).value("batteries").toList().value(0).toMap(); QCOMPARE(bat.value("held").toBool(), true); 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(); QCOMPARE(bat.value("observed").toMap().value("enableDischarging").toBool(), true); 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. for (int m = 3; m <= 89; ++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), qUtf8Printable(QStringLiteral("limite expirée à %1").arg(a.toString(Qt::ISODate)))); } @@ -10952,19 +10960,20 @@ void Simulation::testOffPeakEndsAtSixAndOnDstDays() }; for (const Bascule &j : jours) { const QDateTime six = QDateTime::fromSecsSinceEpoch(j.sixHeures, QTimeZone::utc()); - // Une heure en heures creuses : la borne charge au minimum, la voiture tire. Le verrou - // d'enclenchement de la borne (300 s) est écoulé à 06:00 : c'est l'HEURE qui l'arrête. + // Une heure en heures creuses : la borne charge à pleine puissance, la voiture tire. Le + // verrou d'enclenchement de la borne (300 s) est écoulé à 06:00 : c'est l'HEURE qui + // l'arrête. QVariantMap snap; 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() - == QString(DecisionCode::EcoFloorGrid), j.quoi); + == QString(DecisionCode::EvOffPeakFullPower), j.quoi); QVERIFY2(snap.value("storage").toMap().value("dischargeBlocked").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. 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(); QVERIFY2(d.value("code").toString() == QString(DecisionCode::EvWaitingOffPeak), 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); QVariantMap snap; 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(); QVERIFY2(!st.value("dischargeBlocked").toBool(), c.quoi); QVERIFY2(st.value("decision").toMap().value("code").toString() @@ -11063,7 +11072,7 @@ void Simulation::testABatteryWithoutControlIsReadAsBefore() QVERIFY2(s.ecrites->isEmpty(), c.quoi); // …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() - .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 @@ -11077,7 +11086,7 @@ void Simulation::testABatteryWithoutControlIsReadAsBefore() QVERIFY(!snap.contains("storage")); snap = cycleHc(s, hc, 600); 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")); #endif } @@ -11109,15 +11118,15 @@ void Simulation::testBlockedDischargeShowsAtTheMeterAndL4Decides() QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(21, 0)); QVariantMap snap; 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(s.borne->stateValue("power").toBool()); // 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é ». s.batterie->setStateValue("currentPower", 0); - snap = cycleHc(s, a.addSecs(180), 1380 + 1800); - QCOMPARE(qRound(snap.value("budget").toMap().value("surplusW").toDouble()), -(1380 + 1800)); + snap = cycleHc(s, a.addSecs(180), TirageHcW + 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 // 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)); QVariantMap snap; 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()); 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 QVariantMap snap; 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 p = poses(); QVERIFY(!p.isEmpty()); const QDateTime premiere = p.first(); @@ -11251,7 +11260,7 @@ void Simulation::testRefusedWritesAreSpacedAndNeverDelayTheRelease() QDateTime a = t0.addSecs(60 * 50); while (a < reprise) { a = a.addSecs(60); - snap = cycleHc(s, a, 1380 + 300); + snap = cycleHc(s, a, TirageHcW + 300); } QCOMPARE(poses().size(), 8); QCOMPARE(poses().last(), reprise); @@ -11259,7 +11268,7 @@ void Simulation::testRefusedWritesAreSpacedAndNeverDelayTheRelease() QVERIFY(tentatives.last().acceptee); QCOMPARE(journal.compte(QtInfoMsg, QStringLiteral("ACCEPTÉE après 7 refus")), 1); 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); bat = batterie(snap); QCOMPARE(bat.value("held").toBool(), true); @@ -11269,7 +11278,7 @@ void Simulation::testRefusedWritesAreSpacedAndNeverDelayTheRelease() const int poseesAvant = s.ecrites->size(); for (int m = 0; m < 20; ++m) { a = a.addSecs(60); - snap = cycleHc(s, a, 1380 + 300); + snap = cycleHc(s, a, TirageHcW + 300); QVERIFY2(limiteTenue(*s.ecrites, a.addSecs(59), 300), 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) { QVERIFY2(garde < 30, "la série de refus ne s'est pas ouverte"); a = a.addSecs(60); - snap = cycleHc(s, a, 1380 + 300); + snap = cycleHc(s, a, TirageHcW + 300); } QCOMPARE(journal.compte(QtWarningMsg, serie), 2); 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 // 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); const int avantRepose = poses().size(); a = a.addSecs(60); - snap = cycleHc(s, a, 1380 + 300); + snap = cycleHc(s, a, TirageHcW + 300); QCOMPARE(poses().size(), avantRepose + 1); QCOMPARE(poses().last(), a); a = a.addSecs(60); - snap = cycleHc(s, a, 1380 + 300); + snap = cycleHc(s, a, TirageHcW + 300); QCOMPARE(journal.compte(QtWarningMsg, serie), 3); QCOMPARE(batterie(snap).value("refusedWrites").toInt(), 1); #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 = { + {"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 +} diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 3fe2e15..115645c 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -71,8 +71,12 @@ private: std::shared_ptr> ecrites; //!< Lues à la frontière nymea. std::shared_ptr cycleCourant; //!< Date les écritures. }; - //! Serveur neuf, compteur, borne monophasée « Éco avec minimum » (voiture débranchée), batterie. - ScenarioHc preparerScenarioHc(const QString &nom, BatterieHc batterie, const QVariantList &tarif); + //! Quelle borne pose le scénario (lot e2) : 16 A, monophasée, triphasée, ou triphasée à + //! 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. 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 ; //! une pose acceptée rend le rythme normal ; la levée n'attend jamais. 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. void testEveryGetterReturnsWhatItDeclares(); //! Le snapshot sert la DÉCISION : le canal, jamais la table — et l'absence n'est pas zéro.