diff --git a/energyplugin/etm/adapters/evadapter.cpp b/energyplugin/etm/adapters/evadapter.cpp index eb65db2..bf2a0e9 100644 --- a/energyplugin/etm/adapters/evadapter.cpp +++ b/energyplugin/etm/adapters/evadapter.cpp @@ -129,7 +129,13 @@ LoadContext EvAdapter::toLoadContext(const QDateTime &now) const // APRÈS coup — c'est-à-dire après avoir commandé. Le plafond doit donc être DÉCLARÉ, comme // n'importe quelle borne d'adaptateur (règle 4). ctx.declared.maxPowerW = m_charger->maxChargingCurrentMaxValue() * 230.0 * m_targetPhases; - const double plafondPhaseW = m_parent->evPhaseAllowanceW(m_charger, m_targetPhases); + // Lot e2, suite (2026-10-25) — EN HEURES CREUSES, le plafond rend à la borne ce qu'elle tire, + // moins 1 A par phase. Sans ce retour, à pleine puissance, sa propre consommation lui + // retirait la place au cycle suivant et la lui rendait au cycle d'après : 16, 7, 16, 7 A sous + // une limite de 25 A, avec une maison à 2 A. Hors heures creuses, la règle d'avant. + const double plafondPhaseW = pleinePuissanceHc + ? m_parent->evOffPeakAllowanceW(m_charger, m_targetPhases) + : m_parent->evPhaseAllowanceW(m_charger, m_targetPhases); if (plafondPhaseW >= 0) { // …mais JAMAIS sous le plancher de la borne. C'est la règle de l'amont, reprise mot // pour mot (`if (newValueInt <= minValue) newValueInt = minValue`), et la répartition diff --git a/energyplugin/etm/adapters/evadapter.h b/energyplugin/etm/adapters/evadapter.h index e39d471..c80a3fe 100644 --- a/energyplugin/etm/adapters/evadapter.h +++ b/energyplugin/etm/adapters/evadapter.h @@ -54,6 +54,9 @@ public: * d'heures creuses (\c needs.offPeakMinPowerW) est le PLAFOND du cycle * (\c declared.maxPowerW : courant maximal, phases, limite de phase), jamais sous le * plancher ; et le nombre de phases se choisit sur ce plafond, plus sur le surplus. + * \note **Lot e2, suite** — dans ces mêmes conditions, la limite de phase est lue par + * \c EnergyArbitrator::evOffPeakAllowanceW() : la place libre plus le courant MESURÉ de la + * borne, moins 1 A par phase. Hors heures creuses, \c evPhaseAllowanceW(), inchangée. */ LoadContext toLoadContext(const QDateTime &now) const override; diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 52bfe8c..71e422b 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -1212,6 +1212,9 @@ double EnergyArbitrator::evPhaseAllowanceW(EvCharger *ev, uint phaseCount) const // qui ramène la borne à son minimum. Avec le retour, elle se verrait autoriser ce qu'elle // consomme déjà — c'est-à-dire précisément la puissance qui cause le dépassement. // (`testOverloadProtectionEcoMode` : 16 A ramenés à 6 A ; avec le retour, 9 A maintenus.) + // Ce test a un compteur qui ne voit pas la borne baisser : le retour y est un trou. En heures + // creuses, evOffPeakAllowanceW() rend le courant MESURÉ moins une marge, et sa propre preuve + // de baisse en dépassement suppose que le compteur voie la borne. // // L'écrêtage à zéro est fait ICI, chez le consommateur, et non dans la résolution : la // marge PORTE le dépassement (elle vaut -1200 quand on dépasse de 1200 W) parce que @@ -1220,6 +1223,59 @@ double EnergyArbitrator::evPhaseAllowanceW(EvCharger *ev, uint phaseCount) const return qMax(0.0, marge.watts) * phaseCount; } +namespace { +//! Marge du plafond d'heures creuses, par phase. Celle de l'amont pour l'échéance +//! (`adjustEvChargers()`), pour la même raison : ne pas coller à la limite, et laisser à un +//! appareil moyen allumé entre deux cycles la place de démarrer avant que L4 n'ait à couper. +constexpr double MargeHeuresCreusesA = 1.0; +} + +double EnergyArbitrator::evOffPeakAllowanceW(EvCharger *ev, uint phaseCount) const +{ + RootMeter *meter = internalRootMeter(); + if (!ev || phaseCount == 0 || !meter) + return -1; + + const QList plafonds = drawCaps(); + if (plafonds.isEmpty()) + return -1; + + // Ce que la borne tire, MESURÉ, et sur quelles phases. Pas la consigne : une voiture qui + // prend 8 A d'une borne commandée à 16 n'occupe que 8 A de la phase, et lui rendre 16 + // ouvrirait 8 A qu'elle peut prendre d'un coup. Une borne sans mesure ne rend rien. + const bool mesuree = !ev->thing()->thingClass().stateTypes().findByName("currentPower").id().isNull(); + const Electricity::Phases tirees = mesuree ? ev->meteredPhases() : Electricity::PhaseNone; + // Rendre sur une phase que la borne n'occupe pas créerait de la place qui n'existe pas — et + // la borne, en la prenant ailleurs, ne la refermerait jamais. Pendant une bascule (phases + // mesurées ≠ phases visées), on ne rend donc rien. + const bool attribuable = tirees != Electricity::PhaseNone + && Electricity::getPhaseCount(tirees) == phaseCount; + const Electricity::Phases masque = attribuable + ? tirees : const_cast(this)->phasesForCount(phaseCount); + const double tireW = attribuable ? qMax(0.0, ev->currentPower()) : 0.0; + + // Le tirage de la maison SANS la borne, phase par phase, écrêté à zéro : une borne qui + // annoncerait plus que ce que le compteur voit ne se fabrique pas de place au-delà de la + // limite. Au mieux, une phase vaut sa limite. + QList sansLaBorne = phaseDrawW(masque, phasePowerLimit()); + for (double &w : sansLaBorne) + w = qMax(0.0, w - tireW / phaseCount); + const DrawMargin marge = resolveDrawMargin(plafonds, sansLaBorne, + qMax(0.0, meter->currentPower() - tireW)); + if (!marge.borne) + return -1; + + // Moins la marge, à l'ampère INFÉRIEUR : l'adaptateur arrondit la consigne au plus proche, + // et un plafond de 13,6 A deviendrait 14 A — la marge serait mangée de moitié. + // + // En dépassement, ce plafond BAISSE, et c'est la raison de sécurité de evPhaseAllowanceW() + // qui tient toujours : si la phase tire I > limite, dont E pour la borne, la place rendue + // vaut limite − (I − E) − 1 < E − 1. La consigne descend d'au moins 1 A par cycle, jusqu'à + // sortir du dépassement ou jusqu'au plancher, où L4 décide. + const double parPhaseA = std::floor(marge.watts / 230.0 - MargeHeuresCreusesA); + return qMax(0.0, parPhaseA) * 230.0 * phaseCount; +} + bool EnergyArbitrator::evCommandedByWaterfall(EvCharger *evCharger) const { return evCharger && m_waterfallEvIds.contains(evCharger->thing()->id().toString()); diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index 10ef4db..f960818 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -842,6 +842,42 @@ public: */ double evPhaseAllowanceW(EvCharger *ev, uint phaseCount) const; + /*! + * \brief Lot e2, suite — plafond d'une borne À PLEINE PUISSANCE EN HEURES CREUSES : la place + * libre, plus ce que la borne tire, moins une marge de 1 A par phase. + * + * \param ev Borne. \param phaseCount Nombre de phases VISÉ. + * \return Plafond en watts, multiple de 230 W par phase, jamais négatif ; ou **−1 si aucune + * limite** (même convention que \c evPhaseAllowanceW()). + * + * \par Pourquoi + * \c evPhaseAllowanceW() ne rend pas à la borne ce qu'elle tire. À pleine puissance, et avec + * un compteur qui voit la borne, sa propre consommation lui retire donc la place au cycle + * suivant, puis la lui rend au cycle d'après : 16, 7, 16, 7 A sous une limite de 25 A. C'est + * la règle de l'échéance chez l'amont (\c adjustEvChargers() : marge − 1 A + courant de la + * borne) qui la fait se poser. + * + * \par Contrat + * - Le courant rendu est MESURÉ sur la borne (état \c currentPower de sa classe), jamais + * déduit de sa consigne. Il n'est rendu que sur les phases où la borne le mesure, et + * seulement si elles sont aussi nombreuses que les phases visées. Sinon — borne sans + * mesure, à l'arrêt, ou qui bascule — rien n'est rendu, et la marge s'applique quand même. + * - Une phase ne peut pas valoir plus que sa limite, moins la marge : ce que la maison tire + * sans la borne est écrêté à zéro. Une borne qui annoncerait plus que ce que le compteur + * voit ne se fabrique donc pas de place au-delà de la limite. + * - **En dépassement, le plafond baisse** : sur une phase au-dessus de la limite, il vaut au + * plus le courant mesuré de la borne moins 1 A. Il redescend donc à chaque cycle, jusqu'à + * sortir du dépassement ou jusqu'au plancher — où la stratégie tient la borne et où + * \c verifyOverloadProtection() (L4) décide. Cela suppose que le compteur voie la borne, + * comme L4 le suppose déjà. + * - Arrondi à l'ampère INFÉRIEUR : la consigne, arrondie au plus proche par l'adaptateur, ne + * mange jamais la marge. + * + * \note Réservée aux heures creuses et à la pleine puissance du lot e2. Hors de là, + * \c evPhaseAllowanceW() reste la règle, et les décisions sans tarif ne changent pas. + */ + double evOffPeakAllowanceW(EvCharger *ev, uint phaseCount) const; + /*! * \brief [3g-2] Rang configuré d'une borne dans le waterfall. * diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 13cccb6..35be5ab 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -11131,9 +11131,14 @@ void Simulation::testBlockedDischargeShowsAtTheMeterAndL4Decides() // Le soutirage monte : la phase A passe à 28 A pour une limite de 25 A. La borne n'est plus // servie (plafond de soutirage, protection de surcharge) — et la batterie, qui ne voit plus // de voiture tirer, est rendue au cycle d'après. + // + // Depuis D8 (2026-10-25), le plafond d'heures creuses rend à la borne son courant MESURÉ. L4 + // la ramène d'abord de 16 à 13 A ; la stratégie lit 13 A tirés sous 28 A au compteur, et il + // reste 25 − 15 − 1 = 9 A : elle RÉDUIT, elle ne coupe plus d'elle-même. L4 garde le dernier + // mot et retire encore les 3 A du dépassement — la borne finit à son plancher, ou coupée. phaseA(28); snap = cycleHc(s, a.addSecs(240), 6440); - QVERIFY2(!s.borne->stateValue("power").toBool() || s.borne->stateValue("maxChargingCurrent").toInt() < 6, + QVERIFY2(!s.borne->stateValue("power").toBool() || s.borne->stateValue("maxChargingCurrent").toInt() <= 6, qUtf8Printable(QJsonDocument::fromVariant(telemetrieParId(snap).value(borne)).toJson(QJsonDocument::Compact))); s.borne->setStateValue("currentPower", 0); phaseA(20); @@ -11459,6 +11464,39 @@ void Simulation::testOffPeakFullPowerSwitchesToThreePhases() #endif } +namespace { + +//! Le compteur VOIT la borne : chaque phase porte la maison, plus ce que la borne y tire — +//! MESURÉ sur la borne (états par phase du mock), jamais déduit de sa consigne. Rend le total. +double compteurVoitLaBorne(Thing *compteur, Thing *borne, double maisonA) +{ + double totalW = 0; + for (const char *ph : {"A", "B", "C"}) { + const double a = maisonA + borne->stateValue(QStringLiteral("currentPhase%1").arg(ph)).toDouble(); + compteur->setStateValue(QStringLiteral("currentPhase%1").arg(ph), a); + compteur->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), a * 230); + totalW += a * 230; + } + return totalW; +} + +//! Intensité de la phase la plus chargée au compteur. +double pirePhaseA(Thing *compteur) +{ + double pire = 0; + for (const char *ph : {"A", "B", "C"}) + pire = qMax(pire, compteur->stateValue(QStringLiteral("currentPhase%1").arg(ph)).toDouble()); + return pire; +} + +//! Consigne de la borne, en ampères — 0 si elle est coupée. +int consigneA(Thing *borne) +{ + return borne->stateValue("power").toBool() ? borne->stateValue("maxChargingCurrent").toInt() : 0; +} + +} // namespace + void Simulation::testOffPeakFullPowerYieldsToThePhaseLimit() { #ifndef ETM_ARBITRATOR @@ -11468,6 +11506,10 @@ void Simulation::testOffPeakFullPowerYieldsToThePhaseLimit() // autre charge allumée la nuit la RÉDUIT sans que la stratégie la coupe. Couper reste le geste // de la protection de surcharge (L4), qui garde le dernier mot. Borne triphasée 16 A, limite // de branchement 25 A par phase. + // + // Le compteur VOIT la borne (lot e2, suite) : chaque phase porte la maison plus ce que la + // borne y tire, mesuré sur elle. C'est la condition où le plafond d'heures creuses lui rend + // son courant, et c'est la seule où la preuve de baisse en dépassement a un sens. FuseauDeTest fuseau("Europe/Paris"); ScenarioHc s = preparerScenarioHc(QStringLiteral("limite"), BatterieHc::Pilotable, hphcT4(), BorneHc::Triphasee); @@ -11477,63 +11519,226 @@ void Simulation::testOffPeakFullPowerYieldsToThePhaseLimit() .toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError")); s.borne->setStateValue("pluggedIn", true); - // Les trois phases à la même intensité : la protection amont lit la PUISSANCE par phase, la - // marge de soutirage le COURANT — les deux sont posés. - const auto phases = [&](double amperes) { - for (const char *ph : {"A", "B", "C"}) { - s.meter->setStateValue(QStringLiteral("currentPhase%1").arg(ph), amperes); - s.meter->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), amperes * 230); - } - }; const auto decision = [&](const QVariantMap &snap) { return telemetrieParId(snap).value(borne).value("decision").toMap(); }; + //! Ce que la borne tire sur chaque phase, mesuré sur elle. + const auto tireA = [&]() { return s.borne->stateValue("currentPhaseA").toDouble(); }; QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(21, 0)); QVariantMap snap; + const auto cycle = [&](double maisonA) { + a = a.addSecs(360); // au-delà des verrous de la borne + snap = cycleHc(s, a, compteurVoitLaBorne(s.meter, s.borne, maisonA)); + }; // 1. LA MARGE D'UNE PHASE NE BRIDE PAS UNE BORNE TRIPHASÉE (LM-1500-h). La maison tire 4 A // par phase : 21 A de marge sur CHAQUE phase, 4 830 W sur la pire. Comparer ces 4 830 W à // l'enveloppe triphasée la servirait à 7 A ; la borne reçoit ses 11 040 W. - phases(4); - snap = cycleHc(s, a, 2760); + cycle(4); QCOMPARE(decision(snap).value("code").toString(), QString(DecisionCode::EvOffPeakFullPower)); QCOMPARE(qRound(telemetrieParId(snap).value(borne).value("allocatedW").toDouble()), 11040); QCOMPARE(s.borne->stateValue("maxChargingCurrent").toInt(), 16); QVERIFY(!decision(snap).value("params").toMap().contains("boundBy")); - // 2. UNE AUTRE CHARGE s'allume (four, 11 A par phase de plus) : 10 A de marge par phase. La - // borne est RÉDUITE au plafond que la limite laisse — 6 900 W, 10 A — et le motif dit qui. - a = a.addSecs(360); // au-delà des verrous de la borne - phases(15); - snap = cycleHc(s, a, 10350); + // 2. LA MESURE, PAS LA CONSIGNE. La voiture ne prend que 10 A des 16 commandés, et un four + // s'allume : 15 A de maison, 25 A au compteur. La place rendue est celle que la voiture + // OCCUPE — 25 − 15 − 1 = 9 A. Rendre la consigne en aurait offert 15, que la voiture + // pourrait prendre d'un coup : 30 A sur une limite de 25. + for (const char *ph : {"A", "B", "C"}) { + s.borne->setStateValue(QStringLiteral("currentPhase%1").arg(ph), 10.0); + s.borne->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), 2300.0); + } + s.borne->setStateValue("currentPower", 6900.0); + cycle(15); + QCOMPARE(s.borne->stateValue("maxChargingCurrent").toInt(), 9); + QCOMPARE(decision(snap).value("params").toMap().value("boundBy").toString(), QStringLiteral("phaseLimit")); + QCOMPARE(decision(snap).value("params").toMap().value("gridW").toInt(), 6210); + + // 3. Le four s'éteint : la borne remonte à sa pleine puissance. + cycle(4); + QCOMPARE(s.borne->stateValue("maxChargingCurrent").toInt(), 16); + QCOMPARE(tireA(), 16.0); + + // 4. DÉPASSEMENT — le four se rallume pendant que la borne tire 16 A : 31 A pour 25. Tant + // que la phase dépasse, ce que la STRATÉGIE retient baisse, d'au moins 1 A sous ce que la + // borne tirait, et la consigne finale aussi — L4 a pu trancher plus bas, il garde le dernier + // mot. Hors du dépassement, la consigne se pose à 25 − 15 − 1 = 9 A, sans alterner. + QStringList suite; + QList consignes; + for (int k = 0; k < 5; ++k) { + const double avantA = tireA(); + compteurVoitLaBorne(s.meter, s.borne, 15); + const bool depasse = pirePhaseA(s.meter) > 25; + cycle(15); + const int retenuA = qRound(decision(snap).value("params").toMap().value("gridW").toDouble() / 690.0); + consignes << consigneA(s.borne); + suite << QStringLiteral("%1 A tirés%2 → stratégie %3 A, consigne %4 A") + .arg(avantA).arg(depasse ? QStringLiteral(" (dépassement)") : QString()) + .arg(retenuA).arg(consignes.last()); + if (depasse) { + // Au plancher, la stratégie TIENT la borne (décision du 2026-10-25) : c'est L4 qui coupe. + QVERIFY2(retenuA <= avantA - 1 || retenuA == 6, qUtf8Printable(suite.join(" ; "))); + QVERIFY2(consignes.last() <= avantA - 1, qUtf8Printable(suite.join(" ; "))); + } + } + qInfo().noquote() << "[dépassement]" << suite.join(QStringLiteral(" ; ")); + QVERIFY2(consignes.mid(2) == (QList{9, 9, 9}), qUtf8Printable(suite.join(" ; "))); + compteurVoitLaBorne(s.meter, s.borne, 15); + QVERIFY2(pirePhaseA(s.meter) <= 24, qUtf8Printable(suite.join(", "))); QCOMPARE(decision(snap).value("code").toString(), QString(DecisionCode::EvOffPeakFullPower)); QCOMPARE(decision(snap).value("params").toMap().value("boundBy").toString(), QStringLiteral("phaseLimit")); - QCOMPARE(decision(snap).value("params").toMap().value("gridW").toInt(), 6900); + QCOMPARE(decision(snap).value("params").toMap().value("gridW").toInt(), 6210); QCOMPARE(decision(snap).value("params").toMap().value("maxW").toInt(), 11040); - QCOMPARE(s.borne->stateValue("maxChargingCurrent").toInt(), 10); QVERIFY(s.borne->stateValue("power").toBool()); const QString texte = renderFr(DecisionReason(DecisionCode::EvOffPeakFullPower, decision(snap).value("params").toMap()), "Borne"); QVERIFY2(texte.contains(QStringLiteral("bridée par la protection de surcharge du branchement")), qUtf8Printable(texte)); - // 3. LA MAISON MONTE ENCORE (22 A par phase) : 3 A de marge, sous le plancher triphasé. La - // stratégie tient la borne À SON PLANCHER — 4 140 W, 6 A — et ne la coupe PAS. - a = a.addSecs(360); - phases(22); - snap = cycleHc(s, a, 15180); + // 5. LA MAISON MONTE ENCORE (19 A par phase) : avec la borne au plancher, 25 A au compteur, + // pile la limite, et 5 A de place rendue — sous le plancher triphasé. La stratégie tient la + // borne À SON PLANCHER — 4 140 W, 6 A — et ne la coupe PAS. (Le premier cycle dépasse : la + // borne tire encore 9 A, et L4 tranche ; on lit l'état posé.) + for (int k = 0; k < 3; ++k) + cycle(19); QCOMPARE(decision(snap).value("params").toMap().value("boundBy").toString(), QStringLiteral("floor")); QCOMPARE(decision(snap).value("params").toMap().value("gridW").toInt(), 4140); QCOMPARE(s.borne->stateValue("maxChargingCurrent").toInt(), 6); QVERIFY2(s.borne->stateValue("power").toBool(), "la stratégie ne coupe jamais : c'est le geste de L4"); + compteurVoitLaBorne(s.meter, s.borne, 19); + QCOMPARE(pirePhaseA(s.meter), 25.0); - // 4. LIMITE DE BRANCHEMENT ATTEINTE — la borne à 6 A et la maison à 22 A : 28 A par phase pour + // 6. LIMITE DE BRANCHEMENT ATTEINTE — la borne à 6 A et la maison à 22 A : 28 A par phase pour // 25. La protection de surcharge (L4) a le dernier mot : 6 − 3 = 3 A, sous le minimum de // la voiture, elle COUPE — et la stratégie, au même cycle, ne la rallume pas. - a = a.addSecs(360); - phases(28); - snap = cycleHc(s, a, 19320); + cycle(22); QVERIFY2(!s.borne->stateValue("power").toBool(), qUtf8Printable(QJsonDocument::fromVariant(telemetrieParId(snap).value(borne)).toJson(QJsonDocument::Compact))); #endif } + +// ============================================================================================ +// Lot e2, suite (2026-10-25) — près de la limite de phase, la consigne se stabilise +// ============================================================================================ + +void Simulation::testOffPeakFullPowerSettlesUnderThePhaseLimit() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // L'OSCILLATION mesurée au lot e2 (question 2) : le plafond d'une borne était la place LIBRE + // au compteur, sans lui rendre ce qu'elle tire. Un compteur qui voit la borne fait donc + // alterner la consigne : à 16 A la place libre tombe à 7 A, à 7 A elle remonte à 16 A — sous + // une limite de 25 A, 16, 7, 16, 7… En heures creuses, le plafond lui rend son courant + // MESURÉ, moins 1 A : la consigne se pose, et sous une limite qui ne laisse pas 16 A elle se + // pose sous la limite, à 1 A d'elle. + // + // Maison à 2 A par phase (2,4 A pour le cas de l'arrondi), douze cycles espacés de 6 min + // (au-delà des verrous de la borne). + // Le compteur de chaque cycle porte ce que la borne tirait à la fin du précédent. + FuseauDeTest fuseau("Europe/Paris"); + struct Cas { const char *quoi; BorneHc borne; int phases; int limiteA; int attenduA; double maisonA; }; + const QList cas = { + {"monophasée, limite 25 A", BorneHc::Monophasee, 1, 25, 16, 2}, + {"monophasée, limite 32 A", BorneHc::Monophasee, 1, 32, 16, 2}, + {"triphasée, limite 25 A", BorneHc::Triphasee, 3, 25, 16, 2}, + {"triphasée, limite 32 A", BorneHc::Triphasee, 3, 32, 16, 2}, + // 16 − 2 = 14 A de place : la consigne se pose à 13 A, 1 A sous la limite. + {"monophasée, limite 16 A", BorneHc::Monophasee, 1, 16, 13, 2}, + {"triphasée, limite 16 A", BorneHc::Triphasee, 3, 16, 13, 2}, + // L'ARRONDI : 16 − 2,4 − 1 = 12,6 A. Pris au plus proche, 13 A porteraient la phase à + // 15,4 A — la marge mangée. À l'ampère inférieur : 12 A, 14,4 A au compteur. + {"monophasée, limite 16 A, maison à 2,4 A", BorneHc::Monophasee, 1, 16, 12, 2.4}, + }; + constexpr int Cycles = 12; + // Tous les cas sont MESURÉS avant qu'un seul soit jugé : le journal porte la série complète, + // cycle par cycle, même quand le premier cas échoue. + QList> toutesConsignes; + QList> toutesPires; + QStringList traces; + for (const Cas &c : cas) { + ScenarioHc s = preparerScenarioHc(QStringLiteral("oscillation-%1-%2-%3").arg(c.phases).arg(c.limiteA) + .arg(qRound(c.maisonA * 10)), + BatterieHc::Aucune, hphcT4(), c.borne); + QVERIFY2(s.arb && s.borne, c.quoi); + QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", c.limiteA}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + s.borne->setStateValue("pluggedIn", true); + + QList consignes; + QList pires; + QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(21, 0)); // 23:00 à Paris + for (int k = 0; k < Cycles; ++k, a = a.addSecs(360)) { + cycleHc(s, a, compteurVoitLaBorne(s.meter, s.borne, c.maisonA)); + consignes << consigneA(s.borne); + compteurVoitLaBorne(s.meter, s.borne, c.maisonA); // ce que la consigne produit + pires << pirePhaseA(s.meter); + } + QStringList trace; + for (int k = 0; k < Cycles; ++k) + trace << QStringLiteral("%1 A (%2)").arg(consignes.at(k)).arg(pires.at(k)); + traces << trace.join(QStringLiteral(", ")); + qInfo().noquote() << "[oscillation]" << c.quoi << ":" << traces.last(); + toutesConsignes << consignes; + toutesPires << pires; + } + + for (int i = 0; i < cas.size(); ++i) { + const Cas &c = cas.at(i); + const QList &consignes = toutesConsignes.at(i); + const QList &pires = toutesPires.at(i); + // Posée dès le premier cycle, et TENUE : aucune alternance sur les douze. + for (int k = 0; k < Cycles; ++k) + QVERIFY2(consignes.at(k) == c.attenduA, + qUtf8Printable(QStringLiteral("%1 — cycle %2 : %3").arg(c.quoi).arg(k).arg(traces.at(i)))); + // Et SOUS la limite, avec la marge : la phase la plus chargée n'y touche jamais. + for (int k = 0; k < Cycles; ++k) + QVERIFY2(pires.at(k) <= c.limiteA - 1 + 1e-6, + qUtf8Printable(QStringLiteral("%1 — cycle %2 : %3").arg(c.quoi).arg(k).arg(traces.at(i)))); + } +#endif +} + +void Simulation::testOffPeakFullPowerStaysUnderTheLimitOnABlindMeter() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // LES PHASES DU COMPTEUR NE VOIENT PAS LA BORNE. Le retour du courant suppose le contraire, et + // c'est là qu'il pourrait mentir : la borne annonce 13 A que la phase ne montre pas, et + // « maison sans la borne » vaudrait 2 − 13 = −11 A. Sans garde, la place rendue serait + // 16 + 11 − 1 = 26 A, et la borne seule irait à la limite. Écrêtée à zéro, la maison sans la + // borne vaut au moins 0 : la borne reste à 1 A sous la limite. + // + // Le TOTAL du compteur, lui, contient la borne. Ce n'est pas un compteur réel, c'est un banc + // pour la garde : un compteur aveugle aussi au total ferait fabriquer au recrédit un surplus + // de la taille de la borne, et la passe confort l'ajouterait au plafond — un autre écart, + // ouvert (DESIGN_HC_BATTERIE §8.1, écart 4), qui masquerait celui-ci. + // Borne monophasée 16 A, limite 16 A, maison à 2 A par phase. + FuseauDeTest fuseau("Europe/Paris"); + ScenarioHc s = preparerScenarioHc(QStringLiteral("compteur-aveugle"), BatterieHc::Aucune, hphcT4(), + BorneHc::Monophasee); + QVERIFY(s.arb && s.borne); + QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 16}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + s.borne->setStateValue("pluggedIn", true); + const auto phasesAveugles = [&]() { + for (const char *ph : {"A", "B", "C"}) { + s.meter->setStateValue(QStringLiteral("currentPhase%1").arg(ph), 2.0); + s.meter->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), 460.0); + } + return 1380.0 + s.borne->stateValue("currentPower").toDouble(); + }; + QList consignes; + QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(21, 0)); + for (int k = 0; k < 6; ++k, a = a.addSecs(360)) { + cycleHc(s, a, phasesAveugles()); + consignes << consigneA(s.borne); + } + // 16 − 2 − 1 = 13 A à l'arrêt ; puis la borne tire 13 A que la phase ne montre pas, et la + // place rendue plafonne à 16 − 0 − 1 = 15 A. + QCOMPARE(consignes, (QList{13, 15, 15, 15, 15, 15})); +#endif +} diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 115645c..cc97c80 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -283,9 +283,17 @@ private slots: void testOffPeakChargesAtFullPower(); //! Lot e2 — borne à bascule : en heures creuses, trois phases même quand le compteur lit 0. void testOffPeakFullPowerSwitchesToThreePhases(); - //! Lot e2 — limite de phase : la borne est réduite, jamais sous son plancher par la stratégie ; - //! la marge d'une phase ne bride pas une triphasée (LM-1500-h) ; L4 coupe au dépassement. + //! Lot e2 — limite de phase, compteur qui voit la borne : la borne est réduite, jamais sous son + //! plancher par la stratégie ; la marge d'une phase ne bride pas une triphasée (LM-1500-h) ; le + //! courant rendu est le MESURÉ ; en dépassement la consigne baisse ; L4 coupe au dépassement. void testOffPeakFullPowerYieldsToThePhaseLimit(); + //! Lot e2, suite — compteur qui voit la borne, maison à 2 A : la consigne se pose sans + //! alterner (mono et tri, limites 25 et 32 A), et sous une limite de 16 A elle se pose dessous, + //! à l'ampère inférieur. + void testOffPeakFullPowerSettlesUnderThePhaseLimit(); + //! Lot e2, suite — compteur qui ne voit pas la borne : le retour de son courant ne la mène pas + //! seule à la limite (maison sans la borne écrêtée à zéro). + void testOffPeakFullPowerStaysUnderTheLimitOnABlindMeter(); //! TOUTE méthode Get* du namespace rend ce que sa déclaration promet. void testEveryGetterReturnsWhatItDeclares(); //! Le snapshot sert la DÉCISION : le canal, jamais la table — et l'absence n'est pas zéro.