From 3084b885d832c1d2b2f657e6306e3ea1a54292b8 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Tue, 27 Oct 2026 14:16:36 +0100 Subject: [PATCH] =?UTF-8?q?test=20:=20reproductions=20du=20lot=20d=C3=A9le?= =?UTF-8?q?stage=20V2C=20(rouges=20sur=20b054780)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mock : classe chargerUnmetered, une borne qui mesure sa puissance mais publie ses courants sous currentL1..3 (comme le plugin V2C Trydan), jamais sous currentPhaseX. EvCharger::meteredPhases() y est vide ; aucune borne du mock ne produisait ce cas. - testOffPeakUnmeteredChargerSettlesUnderThePhaseLimit : heures creuses, limite 25 A, marge 10 %, borne non mesurée (mono sur A, tri) ; rouge : 16, 6, 13, 6, 13… A, et 6 A tenus avec le lave-vaisselle. - testUnmeteredChargerGivesNothingBackWhenUnsure : triphasée avec voiture monophasée, courants par phase publiés et nuls ; vert (témoin). - testWaterfallChargerSettlesUnderThePhaseLimit (question 5) : hors heures creuses ; rouge : 16, 9, 16, 9… A (maison légère), 16 A puis coupée par L4 à 26 A pour 25 (maison chargée). Co-Authored-By: Claude Opus 5.5 --- docs/INVENTAIRE_MOCK.md | 6 + tests/auto/common/energytestbase.cpp | 17 ++ tests/auto/common/energytestbase.h | 6 + tests/auto/simulation/simulation.cpp | 253 +++++++++++++++++- tests/auto/simulation/simulation.h | 15 +- .../integrationpluginenergymocks.cpp | 64 +++++ .../integrationpluginenergymocks.h | 1 + .../integrationpluginenergymocks.json | 140 ++++++++++ 8 files changed, 499 insertions(+), 3 deletions(-) diff --git a/docs/INVENTAIRE_MOCK.md b/docs/INVENTAIRE_MOCK.md index bbc1dfe..76fb0a8 100644 --- a/docs/INVENTAIRE_MOCK.md +++ b/docs/INVENTAIRE_MOCK.md @@ -25,12 +25,18 @@ rien sur les cas qu'il ne sait pas produire. | `charger` | ✅ | ✅ | ❌ | | `chargerPhaseSwitching` | ✅ | ✅ | ✅ *(ajouté le 2026-08-31)* | | `simpleCharger` | ❌ | ❌ | ❌ | +| `chargerUnmetered` *(lot délestage V2C, 2026-10-27)* | ✅ | ✅ | ❌ | | `powerSwitch` | ✅ | ❌ | — | | `etmVariableLoad` | ✅ *(`currentPowerW`)* | ❌ | — | | `energyStorage` | ✅ | ❌ | — | | `controllableStorage` *(lot e1, 2026-10-24)* | ✅ | ❌ | — | | `car`, `notification` | ❌ | ❌ | — | +`chargerUnmetered` mesure sa puissance mais publie ses courants sous `currentL1..3`, comme le +plugin V2C Trydan, et jamais sous `currentPhaseX` : `EvCharger::meteredPhases()` y est vide. Les +autres bornes du mock publiaient toutes leurs phases, si bien qu'une borne **non mesurée par +phase** — le cas du banc — était introuvable par la suite. + **Ce que la table donne, et c'est la bonne nouvelle** : `sessionEnergy` et `totalEnergyConsumed` sont désormais **discriminants** — il existe des classes avec et sans. Les régimes `measured` / `unmeasurable` et les causes `noSessionEnergy` / `meterWithoutEnergy` sont donc exerçables. diff --git a/tests/auto/common/energytestbase.cpp b/tests/auto/common/energytestbase.cpp index 9f012e0..b00160f 100644 --- a/tests/auto/common/energytestbase.cpp +++ b/tests/auto/common/energytestbase.cpp @@ -451,6 +451,23 @@ QUuid EnergyTestBase::addSimpleCharger(double maxChargingCurrentUpperLimit, quin return response.toMap().value("params").toMap().value("thingId").toUuid(); } +QUuid EnergyTestBase::addUnmeteredCharger(const QString &phases, double maxChargingCurrentUpperLimit, quint16 port) +{ + const QVariantList thingParams = { + QVariantMap{{"paramTypeId", "{4c0132c6-e1a8-4bd0-964b-8e9827a0a1c5}"}, {"value", port}}, + QVariantMap{{"paramTypeId", "{ec3e73a0-cca9-415a-bc64-9e841d666853}"}, {"value", phases}}, + QVariantMap{{"paramTypeId", "{803ba501-ae0f-4d58-8097-6ebb0928b3d8}"}, {"value", maxChargingCurrentUpperLimit}}, + }; + QVariantMap params; + params.insert("thingClassId", mockChargerUnmeteredThingClassId.toString()); + params.insert("name", "Unmetered charger"); + params.insert("thingParams", thingParams); + + QVariant response = injectAndWait("Integrations.AddThing", params); + verifyThingError(response); + return response.toMap().value("params").toMap().value("thingId").toUuid(); +} + QUuid EnergyTestBase::addEnergyStorage(uint capacity, double maxChargingPowerUpperLimit, double maxDischargingPowerUpperLimit, quint16 port) { QVariantList thingParams; diff --git a/tests/auto/common/energytestbase.h b/tests/auto/common/energytestbase.h index e15b0cf..be90cd1 100644 --- a/tests/auto/common/energytestbase.h +++ b/tests/auto/common/energytestbase.h @@ -51,6 +51,9 @@ static QUuid mockMeterPowerOnlyThingClassId = QUuid("3ac91e57-08b4-4f26-9d31-7e5 static QUuid mockChargerThingClassId = QUuid("5a3ae99f-c7da-46df-9104-f477be4606b7"); static QUuid mockChargerWithPhaseSwitchingThingClassId = QUuid("9208d9f0-280c-469d-a145-106f3277470c"); static QUuid mockSimpleChargerThingClassId = QUuid("29bcf255-b654-4764-be92-399bc26fe7c3"); +//! \brief Borne qui mesure sa PUISSANCE mais pas ses phases au sens du moteur — `currentL1..3` +//! comme le plugin V2C Trydan, jamais `currentPhaseX`. `EvCharger::meteredPhases()` y est vide. +static QUuid mockChargerUnmeteredThingClassId = QUuid("e297f8c4-934f-4af3-a655-29b5b799289b"); static QUuid mockCarThingClassId = QUuid("4513f801-836e-40a7-8784-c02650a9bdc6"); static QUuid mockEnergyStorageThingClassId = QUuid("d0d5bbf0-249c-46ed-ac6a-5f271b2b0b0f"); static QUuid mockPowerSwitchThingClassId = QUuid("841f8905-d1d7-4053-909f-01123b497747"); @@ -110,6 +113,9 @@ public: QUuid addCharger(const QString &phases = "All", double maxChargingCurrentUpperLimit = 32, quint16 port = 26657); QUuid addChargerWithPhaseCountSwitching(const QString &phases = "All", double maxChargingCurrentUpperLimit = 32, quint16 port = 26658); QUuid addSimpleCharger(double maxChargingCurrentUpperLimit = 32, quint16 port = 26659); + //! \brief Ajoute une borne sans courants par phase lisibles par le moteur (façon V2C Trydan). + //! \param phases Câblage, `A`, `B`, `C` ou `ABC` — fixe, sans bascule. + QUuid addUnmeteredCharger(const QString &phases = "A", double maxChargingCurrentUpperLimit = 16, quint16 port = 26677); QUuid addEnergyStorage(uint capacity = 10, double maxChargingPowerUpperLimit = 5000, double maxDischargingPowerUpperLimit = 11500, quint16 port = 26660); QUuid addPowerSwitch(double nominalPower = 2000, quint16 port = 26661); diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 2094418..7830fbc 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -11431,9 +11431,11 @@ Simulation::ScenarioHc Simulation::preparerScenarioHc(const QString &nom, Batter s.meter = tm->findConfiguredThing(meterId); s.meter->setStateValue("connected", true); + const bool monophasee = typeBorne == BorneHc::Monophasee || typeBorne == BorneHc::MonophaseeNonMesuree; s.borneId = typeBorne == BorneHc::Monophasee ? addCharger("A", 16, 26802) : typeBorne == BorneHc::Triphasee ? addCharger("ABC", 16, 26802) - : addChargerWithPhaseCountSwitching("ABC", 16, 26802); + : typeBorne == BorneHc::Bascule ? addChargerWithPhaseCountSwitching("ABC", 16, 26802) + : addUnmeteredCharger(monophasee ? "A" : "ABC", 16, 26802); const QUuid voitureId = addCar(26803); s.borne = tm->findConfiguredThing(s.borneId); Thing *voiture = tm->findConfiguredThing(voitureId); @@ -11442,7 +11444,7 @@ Simulation::ScenarioHc Simulation::preparerScenarioHc(const QString &nom, Batter voiture->setStateValue("batteryLevel", 30); voiture->setStateValue("capacity", 60); voiture->setStateValue("minChargingCurrent", 6); - voiture->setStateValue("phaseCount", typeBorne == BorneHc::Monophasee ? 1 : 3); + voiture->setStateValue("phaseCount", monophasee ? 1 : 3); QVariantMap info{{"evChargerId", s.borneId}, {"assignedCarId", voitureId}, {"chargingMode", "ChargingModeEcoWithMinCurrent"}, {"targetPercentage", 100}}; injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}})); @@ -12669,6 +12671,253 @@ void Simulation::testOffPeakFullPowerGivesNothingBackWhileSwitchingPhases() #endif } +// ============================================================================================ +// Lot délestage V2C (2026-10-27) — la borne qui ne publie pas ses courants par phase +// ============================================================================================ + +namespace { + +//! Le compteur voit la borne, quelle que soit la façon dont elle publie ses courants : +//! `currentPhaseX` (mock `charger`) ou `currentL1..3` (mock `chargerUnmetered`, façon V2C +//! Trydan). Chaque phase porte la maison, plus ce que la borne y tire. Rend le total. +double compteurMaisonEtBorne(Thing *compteur, Thing *borne, const QList &maisonA) +{ + double totalW = 0; + const QStringList phases = {"A", "B", "C"}; + for (int i = 0; i < 3; ++i) { + const QString etat = borne->hasState("currentL1") ? QStringLiteral("currentL%1").arg(i + 1) + : QStringLiteral("currentPhase%1").arg(phases.at(i)); + const double a = maisonA.at(i) + borne->stateValue(etat).toDouble(); + compteur->setStateValue(QStringLiteral("currentPhase%1").arg(phases.at(i)), a); + compteur->setStateValue(QStringLiteral("currentPowerPhase%1").arg(phases.at(i)), a * 230); + totalW += a * 230; + } + return totalW; +} + +} // namespace + +void Simulation::testOffPeakUnmeteredChargerSettlesUnderThePhaseLimit() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // LE DÉFAUT. Le V2C Trydan publie `currentL1..3`, pas `currentPhaseX` : meteredPhases() est + // vide, et le plafond d'heures creuses ne rendait rien de ce que la borne tire. Il lisait donc + // la borne comme une charge de la maison sur sa phase, et la consigne alternait : à 16 A la + // place tombait au plancher, à 6 A elle remontait. + // + // Limite 25 A, marge 10 % (seuil 22,5 A). La maison du simulateur la nuit : 2,3 A sur A, + // 0,5 A sur B et C ; puis le lave-vaisselle sur A (+8,3 A). Attendu : la place que la maison + // laisse, moins 1 A, à l'ampère inférieur — 22,5 − 2,3 − 1 = 19,2 A, donc 16 A (le maximum de + // la borne) ; avec le lave-vaisselle, 22,5 − 10,6 − 1 = 10,9 A, donc 10 A. En triphasé, la + // phase A est la plus chargée et décide, de la même façon. + FuseauDeTest fuseau("Europe/Paris"); + struct Cas { const char *quoi; BorneHc borne; }; + const QList cas = { + {"monophasée non mesurée", BorneHc::MonophaseeNonMesuree}, + {"triphasée non mesurée", BorneHc::TriphaseeNonMesuree}, + }; + struct Segment { const char *quoi; QList maisonA; int cycles; int attenduA; }; + const QList segments = { + {"maison seule", {2.3, 0.5, 0.5}, 8, 16}, + {"lave-vaisselle", {10.6, 0.5, 0.5}, 8, 10}, + {"maison seule", {2.3, 0.5, 0.5}, 6, 16}, + }; + // Tous les cas sont MESURÉS avant d'être jugés : le journal porte les séries complètes. + QList>> toutesConsignes; + QList>> toutesPires; + QStringList traces; + for (const Cas &c : cas) { + ScenarioHc s = preparerScenarioHc(QStringLiteral("non-mesuree-%1").arg(static_cast(c.borne)), + BatterieHc::Aucune, hphcT4(), c.borne); + QVERIFY2(s.arb && s.borne, c.quoi); + QVERIFY2(!s.borne->hasState("currentPhaseA") && s.borne->hasState("currentL1"), c.quoi); + QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 25}, {"phaseSafetyMarginPercent", 10}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + s.borne->setStateValue("pluggedIn", true); + + QList> consignesCas; + QList> piresCas; + QStringList trace; + QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(21, 0)); // 23:00 à Paris + for (const Segment &seg : segments) { + QList consignes; + QList pires; + QStringList t; + for (int k = 0; k < seg.cycles; ++k, a = a.addSecs(360)) { + cycleHc(s, a, compteurMaisonEtBorne(s.meter, s.borne, seg.maisonA)); + consignes << consigneA(s.borne); + compteurMaisonEtBorne(s.meter, s.borne, seg.maisonA); // ce que la consigne produit + pires << pirePhaseA(s.meter); + t << QStringLiteral("%1 A (%2)").arg(consignes.last()).arg(pires.last()); + } + trace << QStringLiteral("%1 : %2").arg(seg.quoi, t.join(QStringLiteral(", "))); + consignesCas << consignes; + piresCas << pires; + } + traces << trace.join(QStringLiteral(" | ")); + qInfo().noquote() << "[non mesurée]" << c.quoi << ":" << traces.last(); + toutesConsignes << consignesCas; + toutesPires << piresCas; + } + + for (int i = 0; i < cas.size(); ++i) { + for (int j = 0; j < segments.size(); ++j) { + const QList &consignes = toutesConsignes.at(i).at(j); + const QList &pires = toutesPires.at(i).at(j); + // Le premier cycle d'un segment juge un compteur qui vient de changer (le lave-vaisselle + // s'allume sous une borne à 16 A : 26,6 A au compteur, L4 tranche). La consigne se pose + // au cycle suivant et TIENT. + for (int k = 1; k < consignes.size(); ++k) { + const QByteArray ctx = QStringLiteral("%1 — %2, cycle %3 : %4") + .arg(cas.at(i).quoi, segments.at(j).quoi).arg(k).arg(traces.at(i)).toUtf8(); + QVERIFY2(consignes.at(k) == segments.at(j).attenduA, ctx.constData()); + QVERIFY2(pires.at(k) <= 22.5 - 1 + 1e-6, ctx.constData()); + } + } + } +#endif +} + +void Simulation::testUnmeteredChargerGivesNothingBackWhenUnsure() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // LA RÈGLE NE REND QUE CE QUI EST SÛR. Deux cas où elle ne rend rien, comme avant le lot : + // + // 1. Une borne TRIPHASÉE non mesurée, avec une voiture MONOPHASÉE : le cycle vise une phase, + // la borne en annonce trois. Sur laquelle la voiture tire, et combien sur chacune ? Rien ne + // le dit — et répartir un tiers par phase sous-estimerait la phase qui porte tout. + // 2. Une borne qui PUBLIE des courants par phase, mais nuls alors qu'elle tire 2 300 W (mesure + // figée, ou incohérente). Elle n'est pas « non mesurée » : sa mesure se contredit. + // + // Limite 25 A, marge 10 % ; la maison tire 2 A par phase ; la voiture tire 10 A sur A, et le + // compteur le voit. Rien rendu : 22,5 − 12 − 1 = 9,5 A, donc 9 A. + FuseauDeTest fuseau("Europe/Paris"); + struct Cas { const char *quoi; BorneHc borne; }; + const QList cas = { + {"triphasée non mesurée, voiture monophasée", BorneHc::TriphaseeNonMesuree}, + {"courants par phase publiés et nuls", BorneHc::Monophasee}, + }; + for (const Cas &c : cas) { + ScenarioHc s = preparerScenarioHc(QStringLiteral("pas-sur-%1").arg(static_cast(c.borne)), + BatterieHc::Aucune, hphcT4(), c.borne); + QVERIFY2(s.arb && s.borne, c.quoi); + QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 25}, {"phaseSafetyMarginPercent", 10}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + ThingManager *tm = NymeaCore::instance()->thingManager(); + if (c.borne == BorneHc::TriphaseeNonMesuree) { + const Things voitures = tm->configuredThings().filterByThingClassId(ThingClassId(mockCarThingClassId)); + QCOMPARE(voitures.count(), 1); + voitures.first()->setStateValue("phaseCount", 1); + } + const QString borne = s.borneId.toString(); + + // La voiture charge à 10 A sur A : 2 300 W, et c'est ce que la borne mesure. + s.borne->setStateValue("pluggedIn", true); + s.borne->setStateValue("power", true); + s.borne->setStateValue("charging", true); + s.borne->setStateValue("maxChargingCurrent", 10); + for (const QString &etat : {QStringLiteral("currentPhase"), QStringLiteral("currentPowerPhase")}) + for (const char *ph : {"A", "B", "C"}) + if (s.borne->hasState(etat + ph)) + s.borne->setStateValue(etat + ph, 0.0); + for (const char *l : {"currentL1", "currentL2", "currentL3"}) + if (s.borne->hasState(l)) + s.borne->setStateValue(l, 0.0); + s.borne->setStateValue("currentPower", 2300.0); + const QList phasesA = {12, 2, 2}; // maison 2 A partout, voiture 10 A sur A + double totalW = 0; + const QStringList phases = {"A", "B", "C"}; + for (int i = 0; i < 3; ++i) { + s.meter->setStateValue(QStringLiteral("currentPhase%1").arg(phases.at(i)), phasesA.at(i)); + s.meter->setStateValue(QStringLiteral("currentPowerPhase%1").arg(phases.at(i)), phasesA.at(i) * 230); + totalW += phasesA.at(i) * 230; + } + + const QVariantMap snap = cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(21, 0)), totalW); + const QVariantMap d = telemetrieParId(snap).value(borne).value("decision").toMap(); + const QString trace = QStringLiteral("%1 : motif %2, retenu %3 W, consigne %4 A") + .arg(c.quoi, d.value("code").toString()) + .arg(d.value("params").toMap().value("gridW").toInt()).arg(consigneA(s.borne)); + qInfo().noquote() << "[pas sûr]" << trace; + QVERIFY2(d.value("code").toString() == QString(DecisionCode::EvOffPeakFullPower), qUtf8Printable(trace)); + QVERIFY2(d.value("params").toMap().value("gridW").toInt() == 9 * 230, qUtf8Printable(trace)); + QVERIFY2(consigneA(s.borne) == 9, qUtf8Printable(trace)); + } +#endif +} + +void Simulation::testWaterfallChargerSettlesUnderThePhaseLimit() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // QUESTION 5 DU LOT DÉLESTAGE TRIPHASÉ. Hors heures creuses, sous le rule-based, le plafond + // de phase d'une borne est la place LIBRE au compteur (evPhaseAllowanceW()), sans lui rendre + // ce qu'elle tire. Une borne qui tient une bonne part de sa phase devrait donc alterner : + // pleine, la place libre tombe ; au plancher, elle remonte. + // + // Midi, sans tarif, surplus abondant : B et C exportent E A chacune, la maison tire H A sur A + // (déjà net du solaire de A). Limite 25 A, marge 10 % (seuil 22,5 A). Le surplus offert à la + // borne vaut 2E − H ≥ 16 A : c'est la phase qui borne, jamais le surplus. + // Attendu : 22,5 − H − 1 à l'ampère inférieur, plafonné à 16 A. + FuseauDeTest fuseau("Europe/Paris"); + struct Cas { const char *quoi; BorneHc borne; double maisonA; double exportA; int attenduA; }; + const QList cas = { + {"mesurée, maison légère", BorneHc::Monophasee, 4, 12, 16}, + {"mesurée, maison chargée", BorneHc::Monophasee, 10, 14, 11}, + {"non mesurée, maison chargée", BorneHc::MonophaseeNonMesuree, 10, 14, 11}, + }; + constexpr int Cycles = 10; + QList> toutesConsignes; + QList> toutesPires; + QStringList traces; + for (const Cas &c : cas) { + ScenarioHc s = preparerScenarioHc(QStringLiteral("jour-phase-%1-%2").arg(static_cast(c.borne)).arg(c.maisonA), + BatterieHc::Aucune, QVariantList(), c.borne); + QVERIFY2(s.arb && s.borne, c.quoi); + QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 25}, {"phaseSafetyMarginPercent", 10}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + s.borne->setStateValue("pluggedIn", true); + const QList maison = {c.maisonA, -c.exportA, -c.exportA}; + + QList consignes; + QList pires; + QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(10, 0)); // midi à Paris + for (int k = 0; k < Cycles; ++k, a = a.addSecs(360)) { + cycleHc(s, a, compteurMaisonEtBorne(s.meter, s.borne, maison)); + consignes << consigneA(s.borne); + compteurMaisonEtBorne(s.meter, s.borne, maison); + 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() << "[rule-based, phase]" << c.quoi << ":" << traces.last(); + toutesConsignes << consignes; + toutesPires << pires; + } + + for (int i = 0; i < cas.size(); ++i) { + // Les deux premiers cycles posent la borne (le premier juge une borne à l'arrêt, et + // l'arrondi au plus proche de la place libre peut franchir le seuil d'un demi-ampère, que + // L4 reprend). Ensuite la consigne TIENT, sous le seuil moins 1 A. + for (int k = 2; k < Cycles; ++k) { + const QByteArray ctx = QStringLiteral("%1 — cycle %2 : %3").arg(cas.at(i).quoi).arg(k).arg(traces.at(i)).toUtf8(); + QVERIFY2(toutesConsignes.at(i).at(k) == cas.at(i).attenduA, ctx.constData()); + QVERIFY2(toutesPires.at(i).at(k) <= 22.5 - 1 + 1e-6, ctx.constData()); + } + } +#endif +} + // ============================================================================================ // Lot délestage visible (2026-10-25) — la protection PUBLIE ce qu'elle fait, elle ne décide rien // de plus. Question de Patrick : « Si le délestage coupe une charge, est-ce que le client voit une diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index 8b97b44..331cbe6 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -74,7 +74,9 @@ private: }; //! 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 }; + //! Lot délestage V2C : les mêmes, monophasée sur A et triphasée, SANS courants par phase + //! lisibles par le moteur (`currentL1..3`, façon V2C Trydan). + enum class BorneHc { Monophasee, Triphasee, Bascule, MonophaseeNonMesuree, TriphaseeNonMesuree }; //! 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); @@ -351,6 +353,17 @@ private slots: void testOffPeakFullPowerAtDawnStaysInTheCeiling(); //! Lot e2, suite — pendant une bascule de phases, le plafond ne rend rien du courant de la borne. void testOffPeakFullPowerGivesNothingBackWhileSwitchingPhases(); + //! Lot délestage V2C — borne SANS courants par phase lisibles (façon V2C Trydan), limite 25 A, + //! marge 10 % : la consigne se pose au plafond qui tient compte de la borne, sans alterner + //! (monophasée sur A, triphasée ; maison seule, puis lave-vaisselle sur A). + void testOffPeakUnmeteredChargerSettlesUnderThePhaseLimit(); + //! Lot délestage V2C — quand l'attribution n'est pas sûre, rien n'est rendu : triphasée avec + //! une voiture monophasée (phases visées ≠ phases annoncées), borne qui publie des courants + //! par phase nuls alors qu'elle tire. + void testUnmeteredChargerGivesNothingBackWhenUnsure(); + //! Lot délestage V2C (question 5) — hors heures creuses, sous le rule-based, une borne limitée + //! par sa phase se pose sous la limite sans alterner, maison légère ou chargée. + void testWaterfallChargerSettlesUnderThePhaseLimit(); //! 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. diff --git a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp index e90a4ab..54fb5b9 100644 --- a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp +++ b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.cpp @@ -187,6 +187,43 @@ void IntegrationPluginEnergyMocks::setupThing(ThingSetupInfo *info) return; + } else if (thing->thingClassId() == chargerUnmeteredThingClassId) { + // Lot délestage V2C — une borne qui mesure sa puissance mais PAS ses phases au sens du + // moteur : `currentL1..3` (noms du plugin V2C Trydan), jamais `currentPhaseX`. Son nombre + // de phases est celui de son câblage, fixe : aucune bascule. + EnergyMockController *controller = new EnergyMockController(thing, this); + ParamType paramType = thing->thingClass().paramTypes().findByName("port"); + quint16 port = thing->paramValue(paramType.id()).toUInt(); + if (!controller->listen(QHostAddress::Any, port)) { + qCWarning(dcEnergyMocks()) << "Failed to start mock controller on port" << controller->errorString(); + delete controller; + info->finish(Thing::ThingErrorThingInUse); + return; + } + + connect(controller, &EnergyMockController::updateStateRequestReceived, thing, [=](const QUrlQuery &query){ + if (query.hasQueryItem("connected")) + thing->setStateValue("connected", QVariant(query.queryItemValue("connected")).toBool()); + + if (query.hasQueryItem("power")) + thing->setStateValue("power", QVariant(query.queryItemValue("power")).toBool()); + + if (query.hasQueryItem("pluggedIn")) + thing->setStateValue("pluggedIn", QVariant(query.queryItemValue("pluggedIn")).toBool()); + + if (query.hasQueryItem("maxChargingCurrent")) + thing->setStateValue("maxChargingCurrent", QVariant(query.queryItemValue("maxChargingCurrent")).toInt()); + + updateUnmeteredChargerMeter(thing); + }); + + m_controllers.insert(thing, controller); + info->finish(Thing::ThingErrorNoError); + + thing->setStateMaxValue("maxChargingCurrent", QVariant(thing->paramValue("maxChargingCurrentUpperLimit")).toInt()); + updateUnmeteredChargerMeter(thing); + return; + } else if (thing->thingClassId() == simpleChargerThingClassId) { EnergyMockController *controller = new EnergyMockController(thing, this); ParamType paramType = thing->thingClass().paramTypes().findByName("port"); @@ -699,6 +736,15 @@ void IntegrationPluginEnergyMocks::executeAction(ThingActionInfo *info) } + if (thing->thingClassId() == chargerUnmeteredThingClassId) { + if (actionType.name() == "maxChargingCurrent") + thing->setStateValue("maxChargingCurrent", + action.paramValue(actionType.paramTypes().findByName("maxChargingCurrent").id()).toUInt()); + if (actionType.name() == "power") + thing->setStateValue("power", action.paramValue(actionType.paramTypes().findByName("power").id()).toBool()); + updateUnmeteredChargerMeter(thing); + } + if (thing->thingClassId() == simpleChargerThingClassId) { if (actionType.name() == "maxChargingCurrent") { uint maxChargingCurrent = action.paramValue(actionType.paramTypes().findByName("maxChargingCurrent").id()).toUInt(); @@ -751,6 +797,24 @@ void IntegrationPluginEnergyMocks::executeAction(ThingActionInfo *info) info->finish(Thing::ThingErrorNoError); } +void IntegrationPluginEnergyMocks::updateUnmeteredChargerMeter(Thing *thing) +{ + // Le courant commandé, tiré sur les phases câblées — comme updateChargerMeter(), mais publié + // sous `currentL1..3`, que le moteur ne lit pas : EvCharger::meteredPhases() reste vide. + const Electricity::Phases cablees = Electricity::convertPhasesFromString(thing->paramValue("phases").toString()); + const uint phaseCount = Electricity::getPhaseCount(cablees); + thing->setStateValue("phaseCount", phaseCount); + + const bool tire = thing->stateValue("power").toBool() && thing->stateValue("connected").toBool() + && thing->stateValue("pluggedIn").toBool(); + const double courantA = tire ? thing->stateValue("maxChargingCurrent").toDouble() : 0.0; + thing->setStateValue("currentL1", cablees.testFlag(Electricity::PhaseA) ? courantA : 0.0); + thing->setStateValue("currentL2", cablees.testFlag(Electricity::PhaseB) ? courantA : 0.0); + thing->setStateValue("currentL3", cablees.testFlag(Electricity::PhaseC) ? courantA : 0.0); + thing->setStateValue("currentPower", courantA * 230 * phaseCount); + thing->setStateValue("charging", tire); +} + void IntegrationPluginEnergyMocks::updateChargerMeter(Thing *thing) { Electricity::Phases connectedPhases = Electricity::convertPhasesFromString(thing->paramValue("phases").toString()); diff --git a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.h b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.h index e6653d3..b53c444 100644 --- a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.h +++ b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.h @@ -78,6 +78,7 @@ private: // Update the meter data of the charger based on the current max charging current, connected, power and plugged in state void updateChargerMeter(Thing *thing); + void updateUnmeteredChargerMeter(Thing *thing); }; diff --git a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json index bdfd49b..9f3abc5 100644 --- a/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json +++ b/tests/mocks/plugins/energymocks/integrationpluginenergymocks.json @@ -717,6 +717,146 @@ } ] }, + { + "name": "chargerUnmetered", + "displayName": "Mocked charger without per-phase metering", + "id": "e297f8c4-934f-4af3-a655-29b5b799289b", + "createMethods": [ + "user" + ], + "interfaces": [ + "evcharger", + "smartmeterconsumer", + "connectable" + ], + "paramTypes": [ + { + "id": "4c0132c6-e1a8-4bd0-964b-8e9827a0a1c5", + "name": "port", + "displayName": "Port", + "type": "uint", + "defaultValue": 6658 + }, + { + "id": "ec3e73a0-cca9-415a-bc64-9e841d666853", + "name": "phases", + "displayName": "Phases connected", + "type": "QString", + "allowedValues": [ + "A", + "B", + "C", + "ABC" + ], + "defaultValue": "A" + }, + { + "id": "803ba501-ae0f-4d58-8097-6ebb0928b3d8", + "name": "maxChargingCurrentUpperLimit", + "displayName": "Maximum charging current", + "type": "double", + "unit": "Ampere", + "defaultValue": 32 + } + ], + "stateTypes": [ + { + "id": "b35e0da3-7fc1-4c42-b6b4-2c43f9cb937a", + "name": "connected", + "displayName": "Connected", + "type": "bool", + "defaultValue": false, + "cached": false + }, + { + "id": "6ce62b4f-39e1-4693-b204-e399ea4e6637", + "name": "power", + "displayName": "Charging enabled", + "displayNameAction": "Enable/disable charging", + "type": "bool", + "defaultValue": true, + "writable": true + }, + { + "id": "1b1c75b1-362e-4341-960d-4024b2f28341", + "name": "maxChargingCurrent", + "displayName": "Maximum charging current", + "displayNameAction": "Set maximum charging current", + "type": "double", + "defaultValue": 6, + "minValue": 6, + "maxValue": 32, + "unit": "Ampere", + "writable": true + }, + { + "id": "4406b3d3-0102-4932-94ee-e678cc850106", + "name": "totalEnergyConsumed", + "displayName": "Total consumed energy", + "type": "double", + "unit": "KiloWattHour", + "defaultValue": 0, + "cached": true + }, + { + "id": "06b4ceea-5893-4776-b854-4e6c67469b47", + "name": "pluggedIn", + "displayName": "Car is plugged in", + "type": "bool", + "defaultValue": false, + "cached": false + }, + { + "id": "cca231ab-9fb5-4565-90b4-56ac33ccbcd4", + "name": "charging", + "displayName": "Charging", + "type": "bool", + "defaultValue": false, + "cached": false + }, + { + "id": "79601cfe-433b-4fcb-b61f-5b6a0b8b0292", + "name": "phaseCount", + "displayName": "Active phases", + "type": "uint", + "minValue": 1, + "maxValue": 3, + "defaultValue": 1 + }, + { + "id": "923e31e9-04be-43fc-a79d-10098f7aadc1", + "name": "currentPower", + "displayName": "Current power consumption", + "type": "double", + "unit": "Watt", + "defaultValue": 0 + }, + { + "id": "edfb0a5d-6897-4b27-9ae4-2f636ab700a0", + "name": "currentL1", + "displayName": "Current L1", + "type": "double", + "unit": "Ampere", + "defaultValue": 0 + }, + { + "id": "8bf7ff7b-78a3-4917-a4bd-1e9b7d95ca53", + "name": "currentL2", + "displayName": "Current L2", + "type": "double", + "unit": "Ampere", + "defaultValue": 0 + }, + { + "id": "20292cd6-dd59-4608-9d38-18096f484cbf", + "name": "currentL3", + "displayName": "Current L3", + "type": "double", + "unit": "Ampere", + "defaultValue": 0 + } + ] + }, { "name": "simpleCharger", "displayName": "Mocked charger simple",