test : reproductions du lot délestage V2C (rouges sur b054780)
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 <noreply@anthropic.com>
This commit is contained in:
parent
b0547806d8
commit
3084b885d8
@ -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.
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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<double> &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> cas = {
|
||||
{"monophasée non mesurée", BorneHc::MonophaseeNonMesuree},
|
||||
{"triphasée non mesurée", BorneHc::TriphaseeNonMesuree},
|
||||
};
|
||||
struct Segment { const char *quoi; QList<double> maisonA; int cycles; int attenduA; };
|
||||
const QList<Segment> 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<QList<QList<int>>> toutesConsignes;
|
||||
QList<QList<QList<double>>> toutesPires;
|
||||
QStringList traces;
|
||||
for (const Cas &c : cas) {
|
||||
ScenarioHc s = preparerScenarioHc(QStringLiteral("non-mesuree-%1").arg(static_cast<int>(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<QList<int>> consignesCas;
|
||||
QList<QList<double>> piresCas;
|
||||
QStringList trace;
|
||||
QDateTime a = utcDateTime(QDate(2026, 6, 8), QTime(21, 0)); // 23:00 à Paris
|
||||
for (const Segment &seg : segments) {
|
||||
QList<int> consignes;
|
||||
QList<double> 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<int> &consignes = toutesConsignes.at(i).at(j);
|
||||
const QList<double> &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> 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<int>(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<double> 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> 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<QList<int>> toutesConsignes;
|
||||
QList<QList<double>> toutesPires;
|
||||
QStringList traces;
|
||||
for (const Cas &c : cas) {
|
||||
ScenarioHc s = preparerScenarioHc(QStringLiteral("jour-phase-%1-%2").arg(static_cast<int>(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<double> maison = {c.maisonA, -c.exportA, -c.exportA};
|
||||
|
||||
QList<int> consignes;
|
||||
QList<double> 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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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);
|
||||
|
||||
};
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user