From e28e6375fd464b6e8537836a6272a0d3db576d81 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 25 Oct 2026 09:41:48 +0100 Subject: [PATCH] =?UTF-8?q?Heures=20creuses=20:=20la=20somme=20=C3=A9co=20?= =?UTF-8?q?+=20confort=20de=20la=20borne=20tient=20dans=20son=20plafond=20?= =?UTF-8?q?(=C3=A9cart=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit En heures creuses, le niveau éco d'une borne à pleine puissance vaut son plafond de cycle, qui porte la limite de phase. À l'aube, la passe confort lui ajoutait le surplus, et seul le courant maximal de la borne écrêtait la somme : borne monophasée sur A, limite 16 A, plafond 13 A, 2 540 W de surplus — 16 A servis, la phase A à 18 A. L'achat publié valait 2 990 W quand le compteur n'importait que 450 W. Pour une borne qui porte EV_OFF_PEAK_FULL_POWER : - la passe confort sert le surplus DANS le plafond ; - l'achat cède ce que le surplus finance (gridW, drawCommittedW, draw.committedW) : la somme reste le plafond ; - la part confort n'a pas de plancher : il porte sur la somme, qui vaut le plafond. Sinon un surplus sous le plancher serait refusé, ou démarré par EV_GRID_START, un second achat en plus du premier. Hors heures creuses et sans tarif, rien ne change. Tests : testOffPeakFullPowerAtDawnStaysInTheCeiling (nuit, aube, plein soleil : 13 A, achat 2 990 / 450 / 0 W, achat ≤ import du compteur) ; testOffPeakFullPowerStaysUnderTheLimitOnABlindMeter étendu au total aveugle (13, 15, 15… au lieu de 13, 16, 16…) ; testOffPeakFullPowerGivesNothingBackWhileSwitchingPhases, la garde de bascule enfin éprouvée (12 A, phase B à 24 A ; sans elle 15 A, B à 27 A). Co-Authored-By: Claude Opus 5.5 --- .../etm/scheduler/rulebasedscheduler.cpp | 30 ++- tests/auto/simulation/simulation.cpp | 210 ++++++++++++++++-- tests/auto/simulation/simulation.h | 8 +- 3 files changed, 227 insertions(+), 21 deletions(-) diff --git a/energyplugin/etm/scheduler/rulebasedscheduler.cpp b/energyplugin/etm/scheduler/rulebasedscheduler.cpp index bf83b2f..bf1ca82 100644 --- a/energyplugin/etm/scheduler/rulebasedscheduler.cpp +++ b/energyplugin/etm/scheduler/rulebasedscheduler.cpp @@ -301,6 +301,9 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) // donc après un éco de rang 3 — c'est le sens même des deux passes, et le publier sous forme // de niveaux est ce qui rend cet ordre lisible sans qu'un client ait à le redériver. QHash niveauxEco; + //! Lot e2, suite (écart 4) — les bornes servies à leur plafond de cycle en heures creuses. En + //! passe confort, leur surplus PREND la place de l'achat au lieu de s'y ajouter. + QSet plafondPartage; // --- TÊTE DE PASSE ÉCO — les DÉROGATIONS -------------------------------------------------- // @@ -538,6 +541,7 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) : QStringLiteral("phaseLimit")); } eco.reason = { DecisionCode::EvOffPeakFullPower, pp }; + plafondPartage.insert(lc.id); } // SOUS LE PREMIER PALIER, rien n'est acheté. Une charge à paliers ne délivre que ses @@ -598,11 +602,22 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) waterfallEvIds.insert(lc.id); continue; } + // Lot e2, suite (écart 4, décision du 2026-10-25) — LA SOMME ÉCO + CONFORT EST BORNÉE AU + // PLAFOND DE CYCLE. Le niveau éco vaut déjà ce plafond, qui porte la limite de phase ; à + // l'aube, le surplus s'y ajoutait, et seul le courant maximal de la borne écrêtait la + // somme : 13 A de plafond plus 2 540 W de surplus faisaient 16 A sous une limite de 16 A. + // Le confort est donc servi DANS le plafond, et l'achat cède ce que le surplus finance + // (plus bas, à la fusion). Le plancher ne s'applique qu'à la somme, qui vaut le plafond, + // donc jamais moins que lui : la part confort n'en a pas — sans quoi un surplus sous le + // plancher serait refusé, ou démarré par EV_GRID_START, un second achat en plus du premier. + LoadContext lcConfort = lc; + if (plafondPartage.contains(lc.id)) + lcConfort.declared.minPowerW = 0; if (lc.adapter == QStringLiteral("sg-ready")) slot.actions.append(buildSgReadyStateAction(lc, remainingSurplusW, compta)); else slot.actions.append(emitSetpointAction( - decideSetpoint(lc, remainingSurplusW, margeRestante, compta, draw))); + decideSetpoint(lcConfort, remainingSurplusW, margeRestante, compta, draw))); // etmvariableload + relay-router + evcharger // Le décrément vaut pour TOUTES les charges servies, bornes et PAC comprises : elles @@ -614,8 +629,19 @@ Plan RuleBasedScheduler::getPlan(const SurplusContext &ctx) // court-cycling fabriqué par l'arbitre lui-même. if (!slot.actions.isEmpty() && niveauxEco.contains(lc.id)) { LoadAction &la = slot.actions.last(); - const LoadAction::Level eco = niveauxEco.take(lc.id); + LoadAction::Level eco = niveauxEco.take(lc.id); const double confortW = la.estimatedPowerW; // la part confort, avant l'addition + if (plafondPartage.contains(lc.id)) { + // L'ACHAT CÈDE CE QUE LE SURPLUS FINANCE : la somme reste le plafond, et l'achat + // publié (`gridW`, `drawCommittedW`, `draw.committedW`) est ce qui reste à acheter + // — jamais plus que le compteur n'importe. La marge de soutirage n'est pas rendue : + // la ligne qui suit la fusion décompte la consigne entière, prudence inchangée. + const double financeW = qBound(0.0, confortW, eco.targetW); + eco.targetW -= financeW; + eco.counts[CountKey::DrawCommitted] = eco.targetW; + eco.reason.params.insert(QStringLiteral("gridW"), qRound(eco.targetW)); + draw.committedW -= financeW; + } la.levels.prepend(eco); // éco AVANT confort : l'ordre des passes la.powerW += eco.targetW; la.estimatedPowerW += eco.targetW; diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 35be5ab..260ee51 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -11711,34 +11711,208 @@ void Simulation::testOffPeakFullPowerStaysUnderTheLimitOnABlindMeter() // 16 + 11 − 1 = 26 A, et la borne seule irait à la limite. Écrêtée à zéro, la maison sans la // 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. + // Deux variantes. Dans la première, le TOTAL du compteur contient la borne : c'est le banc de + // la garde. Dans la seconde, il ne la voit pas non plus, et le recrédit fabrique alors un + // surplus de la taille de la borne. Avant que la somme éco + confort soit bornée au plafond + // (écart 4, décision du 2026-10-25), la passe confort l'ajoutait au plafond de 15 A, et la + // borne recevait 16 A sous une limite de 16 A : 13, 16, 16… Le plafond tient désormais. // Borne monophasée 16 A, limite 16 A, maison à 2 A par phase. FuseauDeTest fuseau("Europe/Paris"); - ScenarioHc s = preparerScenarioHc(QStringLiteral("compteur-aveugle"), BatterieHc::Aucune, hphcT4(), + for (const bool totalVoitLaBorne : {true, false}) { + const char *quoi = totalVoitLaBorne ? "total qui voit la borne" : "total aveugle"; + ScenarioHc s = preparerScenarioHc(QStringLiteral("compteur-aveugle-%1").arg(totalVoitLaBorne), + BatterieHc::Aucune, hphcT4(), BorneHc::Monophasee); + QVERIFY2(s.arb && s.borne, quoi); + QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 16}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + s.borne->setStateValue("pluggedIn", true); + const auto phasesAveugles = [&]() { + for (const char *ph : {"A", "B", "C"}) { + s.meter->setStateValue(QStringLiteral("currentPhase%1").arg(ph), 2.0); + s.meter->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), 460.0); + } + return 1380.0 + (totalVoitLaBorne ? s.borne->stateValue("currentPower").toDouble() : 0.0); + }; + QList consignes; + QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(21, 0)); + for (int k = 0; k < 6; ++k, a = a.addSecs(360)) { + cycleHc(s, a, phasesAveugles()); + consignes << consigneA(s.borne); + } + qInfo().noquote() << "[compteur aveugle]" << quoi << ":" << consignes; + // 16 − 2 − 1 = 13 A à l'arrêt ; puis la borne tire 13 A que la phase ne montre pas, et la + // place rendue plafonne à 16 − 0 − 1 = 15 A — surplus fabriqué ou non. + QVERIFY2(consignes == (QList{13, 15, 15, 15, 15, 15}), quoi); + } +#endif +} + +void Simulation::testOffPeakFullPowerAtDawnStaysInTheCeiling() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // L'AUBE EN HEURES CREUSES (écart 4, décision du 2026-10-25). Le niveau éco d'une borne y vaut + // son plafond de cycle, qui porte la limite de phase. Le surplus du matin s'y AJOUTAIT, et + // seul le courant maximal de la borne écrêtait la somme : la limite de phase était perdue. + // + // Le cas rare du lot e2 : borne monophasée sur la phase A, limite 16 A, maison à 2 A sur A ; + // les phases B et C exportent 1 500 W chacune. Plafond : 16 − 2 − 1 = 13 A (2 990 W). Borne à + // 13 A, le compteur importe 460 + 2 990 − 3 000 = 450 W ; le surplus recrédité vaut 2 540 W. + // Avant : 2 990 + 2 540 W, soit 24 A demandés, 16 A servis, la phase A à 18 A pour 16. + // Après : 13 A, dont 2 540 W de surplus et 450 W achetés — ce que le compteur importe. + FuseauDeTest fuseau("Europe/Paris"); + ScenarioHc s = preparerScenarioHc(QStringLiteral("aube"), BatterieHc::Aucune, hphcT4(), BorneHc::Monophasee); QVERIFY(s.arb && s.borne); QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 16}})) .toMap().value("params").toMap().value("energyError").toString(), QString("EnergyErrorNoError")); s.borne->setStateValue("pluggedIn", true); - const 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); + const QString borne = s.borneId.toString(); + + //! Le compteur voit la borne sur A ; B et C portent chacune \p exportPhaseW d'export. + const auto compteur = [&](double exportPhaseW) { + const double a = 2.0 + s.borne->stateValue("currentPhaseA").toDouble(); + s.meter->setStateValue("currentPhaseA", a); + s.meter->setStateValue("currentPowerPhaseA", a * 230); + for (const char *ph : {"B", "C"}) { + s.meter->setStateValue(QStringLiteral("currentPhase%1").arg(ph), -exportPhaseW / 230); + s.meter->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), -exportPhaseW); } - return 1380.0 + s.borne->stateValue("currentPower").toDouble(); + return a * 230 - 2 * exportPhaseW; }; - 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); + //! La part d'un niveau, et son motif. + const auto niveau = [&](const QVariantMap &snap, const QString &quel) { + for (const QVariant &n : telemetrieParId(snap).value(borne).value("levels").toList()) + if (n.toMap().value("level").toString() == quel) + return n.toMap(); + return QVariantMap(); + }; + + struct Etape { const char *quoi; double exportPhaseW; double achatW; double surplusW; }; + const QList etapes = { + // Témoin de nuit : rien à partager, tout est acheté — comme avant. + {"nuit", 0, 2990, 0}, + // L'aube : le surplus finance 2 540 W, l'achat cède d'autant. + {"aube", 1500, 450, 2540}, + // Plein soleil : le surplus dépasse le plafond. Rien n'est acheté, la borne reste à 13 A. + {"plein soleil", 3000, 0, 2990}, + }; + QDateTime a = utcDateTime(QDate(2026, 6, 9), QTime(2, 30)); // 04:30 à Paris + for (const Etape &e : etapes) { + QVariantMap snap; + QStringList trace; + for (int k = 0; k < 4; ++k) { // quatre cycles : la consigne se pose et TIENT + a = a.addSecs(360); + snap = cycleHc(s, a, compteur(e.exportPhaseW)); + const double importW = compteur(e.exportPhaseW); // ce que la consigne produit + const QVariantMap eco = niveau(snap, "eco"); + const QVariantMap confort = niveau(snap, "comfort"); + trace << QStringLiteral("%1 A, achat %2 W, surplus %3 W, import %4 W, phase A %5 A") + .arg(consigneA(s.borne)).arg(eco.value("targetW").toDouble()) + .arg(confort.value("targetW").toDouble()).arg(importW) + .arg(s.meter->stateValue("currentPhaseA").toDouble()); + const QByteArray ctx = QStringLiteral("%1 — %2").arg(e.quoi, trace.join(" ; ")).toUtf8(); + + // LA CONSIGNE ≤ LE PLAFOND, et la phase sous la limite, avec la marge. + QVERIFY2(consigneA(s.borne) == 13, ctx.constData()); + QVERIFY2(s.meter->stateValue("currentPhaseA").toDouble() <= 15 + 1e-6, ctx.constData()); + QVERIFY2(qRound(telemetrieParId(snap).value(borne).value("allocatedW").toDouble()) == 2990, + ctx.constData()); + + // LE PARTAGE : la somme vaut le plafond, l'achat ce qui reste à acheter — et jamais plus + // que ce que le compteur importe une fois la consigne posée. + QVERIFY2(qRound(eco.value("targetW").toDouble()) == qRound(e.achatW), ctx.constData()); + QVERIFY2(qRound(confort.value("targetW").toDouble()) == qRound(e.surplusW), ctx.constData()); + QVERIFY2(eco.value("targetW").toDouble() <= qMax(0.0, importW) + 0.5, ctx.constData()); + QVERIFY2(qRound(eco.value("counts").toMap().value(CountKey::DrawCommitted).toDouble()) + == qRound(e.achatW), ctx.constData()); + QVERIFY2(qRound(snap.value("draw").toMap().value("committedW").toDouble()) == qRound(e.achatW), + ctx.constData()); + const QVariantMap motifEco = eco.value("decision").toMap(); + QVERIFY2(motifEco.value("code").toString() == QString(DecisionCode::EvOffPeakFullPower), + ctx.constData()); + QVERIFY2(motifEco.value("params").toMap().value("gridW").toInt() == qRound(e.achatW), + ctx.constData()); + QVERIFY2(motifEco.value("params").toMap().value("boundBy").toString() == QStringLiteral("phaseLimit"), + ctx.constData()); + } + qInfo().noquote() << "[aube]" << e.quoi << ":" << trace.join(QStringLiteral(" ; ")); } - // 16 − 2 − 1 = 13 A à l'arrêt ; puis la borne tire 13 A que la phase ne montre pas, et la - // place rendue plafonne à 16 − 0 − 1 = 15 A. - QCOMPARE(consignes, (QList{13, 15, 15, 15, 15, 15})); +#endif +} + +void Simulation::testOffPeakFullPowerGivesNothingBackWhileSwitchingPhases() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // LA GARDE DE BASCULE (question 3 du lot e2, suite). Pendant une bascule, la borne tire sur + // une phase et vise les trois : rendre son courant sur la phase où elle le tire ne dit rien + // des deux autres. Borne triphasée à bascule, encore en monophasé à 10 A sur A, limite 25 A ; + // la maison tire 2 A sur A et C, 12 A sur B. Au compteur : A = 12, B = 12, C = 2 A. + // + // Avec la garde, rien n'est rendu : 25 − 12 − 1 = 12 A sur trois phases, et B finit à 24 A. + // Rendre les 10 A quand même (sur A seule, ou répartis sur les trois) donnerait 15 A, et B + // finirait à 27 A pour 25 — une place prise sur une phase que la borne n'occupait pas. + FuseauDeTest fuseau("Europe/Paris"); + ScenarioHc s = preparerScenarioHc(QStringLiteral("garde-bascule"), BatterieHc::Aucune, hphcT4(), + BorneHc::Bascule); + QVERIFY(s.arb && s.borne); + QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 25}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + const QString borne = s.borneId.toString(); + const QList maisonA = {2, 12, 2}; + //! Le compteur voit la borne : la maison, plus ce que la borne tire, phase par phase. + const auto compteur = [&]() { + double totalW = 0; + const QStringList phases = {"A", "B", "C"}; + for (int i = 0; i < 3; ++i) { + const double a = maisonA.at(i) + + s.borne->stateValue(QStringLiteral("currentPhase%1").arg(phases.at(i))).toDouble(); + s.meter->setStateValue(QStringLiteral("currentPhase%1").arg(phases.at(i)), a); + s.meter->setStateValue(QStringLiteral("currentPowerPhase%1").arg(phases.at(i)), a * 230); + totalW += a * 230; + } + return totalW; + }; + + // La borne charge en monophasé à 10 A : mesurée sur A seule, une phase, quand le cycle en vise trois. + s.borne->setStateValue("pluggedIn", true); + s.borne->setStateValue("power", true); + s.borne->setStateValue("charging", true); + s.borne->setStateValue("desiredPhaseCount", 1); + s.borne->setStateValue("usedPhases", QStringLiteral("A")); + s.borne->setStateValue("phaseCount", 1); + s.borne->setStateValue("maxChargingCurrent", 10); + s.borne->setStateValue("currentPhaseA", 10.0); + s.borne->setStateValue("currentPowerPhaseA", 2300.0); + for (const char *ph : {"B", "C"}) { + s.borne->setStateValue(QStringLiteral("currentPhase%1").arg(ph), 0.0); + s.borne->setStateValue(QStringLiteral("currentPowerPhase%1").arg(ph), 0.0); + } + s.borne->setStateValue("currentPower", 2300.0); + + const QVariantMap snap = cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(21, 0)), compteur()); + const QVariantMap d = telemetrieParId(snap).value(borne).value("decision").toMap(); + compteur(); // ce que la consigne produit, sur les trois phases + const QString trace = QStringLiteral("phases %1, consigne %2 A, retenu %3 W, au compteur A %4 B %5 C %6 A") + .arg(s.borne->stateValue("desiredPhaseCount").toUInt()).arg(consigneA(s.borne)) + .arg(d.value("params").toMap().value("gridW").toInt()) + .arg(s.meter->stateValue("currentPhaseA").toDouble()) + .arg(s.meter->stateValue("currentPhaseB").toDouble()) + .arg(s.meter->stateValue("currentPhaseC").toDouble()); + qInfo().noquote() << "[garde de bascule]" << trace; + + QVERIFY2(d.value("code").toString() == QString(DecisionCode::EvOffPeakFullPower), qUtf8Printable(trace)); + QVERIFY2(s.borne->stateValue("desiredPhaseCount").toUInt() == 3u, qUtf8Printable(trace)); + QVERIFY2(d.value("params").toMap().value("gridW").toInt() == 12 * 690, qUtf8Printable(trace)); + QVERIFY2(consigneA(s.borne) == 12, qUtf8Printable(trace)); + for (const char *ph : {"A", "B", "C"}) + QVERIFY2(s.meter->stateValue(QStringLiteral("currentPhase%1").arg(ph)).toDouble() <= 24 + 1e-6, + qUtf8Printable(trace)); #endif } diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index cc97c80..2a0fe77 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -292,8 +292,14 @@ private slots: //! à 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). + //! seule à la limite (maison sans la borne écrêtée à zéro) ; total aveugle aussi : le surplus + //! fabriqué ne la porte plus au-delà du plafond (15 A, jamais 16). void testOffPeakFullPowerStaysUnderTheLimitOnABlindMeter(); + //! Lot e2, suite (écart 4) — à l'aube en heures creuses, le surplus prend la place de l'achat + //! dans le plafond, au lieu de s'y ajouter : consigne ≤ plafond, achat ≤ import du compteur. + void testOffPeakFullPowerAtDawnStaysInTheCeiling(); + //! Lot e2, suite — pendant une bascule de phases, le plafond ne rend rien du courant de la borne. + void testOffPeakFullPowerGivesNothingBackWhileSwitchingPhases(); //! TOUTE méthode Get* du namespace rend ce que sa déclaration promet. void testEveryGetterReturnsWhatItDeclares(); //! Le snapshot sert la DÉCISION : le canal, jamais la table — et l'absence n'est pas zéro.