diff --git a/docs/INVENTAIRE_MOCK.md b/docs/INVENTAIRE_MOCK.md index 76fb0a8..5328ddc 100644 --- a/docs/INVENTAIRE_MOCK.md +++ b/docs/INVENTAIRE_MOCK.md @@ -35,7 +35,7 @@ rien sur les cas qu'il ne sait pas produire. `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. +phase** — le cas d'un V2C Trydan — é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` / diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 8b13b45..d605d31 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -12715,8 +12715,8 @@ void Simulation::testOffPeakUnmeteredChargerSettlesUnderThePhaseLimit() // 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 + // Limite 25 A, marge 10 % (seuil 22,5 A). Une maison de nuit : 2,3 A sur A, 0,5 A sur B et + // C ; puis un 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. @@ -12933,6 +12933,65 @@ void Simulation::testWaterfallChargerSettlesUnderThePhaseLimit() #endif } +void Simulation::testWaterfallChargerGetsNothingBackInOverload() +{ +#ifndef ETM_ARBITRATOR + QSKIP("nécessite ETM_ARBITRATOR."); +#else + // LA GARDE DE DÉPASSEMENT de la règle de jour. Hors heures creuses, evPhaseAllowanceW() rend + // à la borne son tirage — mais pas sur une phase au-dessus du seuil. Le cas qui la justifie est + // celui de `testOverloadProtectionEcoMode` : des phases qui ne voient pas la borne. La maison + // sans la borne s'y calcule trop basse, et la place rendue maintiendrait la borne pendant le + // dépassement. + // + // Midi, sans tarif, limite 25 A, marge 10 % (seuil 22,5 A). La borne tire 16 A sur A ; la + // phase A du compteur lit 24 A, sans la borne ; B et C exportent 14 A. L4 la rabaisse + // d'abord, puis la stratégie décide sur ce même compteur. Avec la garde : la place libre, + // négative — la borne est tenue à son plancher, 6 A (1 380 W). Sans elle : 22,5 − (24 − E) − 1, + // soit 11 A avec E = 14 A après L4 (2 530 W), en plein dépassement. + FuseauDeTest fuseau("Europe/Paris"); + ScenarioHc s = preparerScenarioHc(QStringLiteral("jour-depassement"), BatterieHc::Aucune, + QVariantList(), BorneHc::Monophasee); + QVERIFY(s.arb && s.borne); + QCOMPARE(injectAndWait("NymeaEnergy.SetPhasePowerLimit", QVariantMap({{"phasePowerLimit", 25}, {"phaseSafetyMarginPercent", 10}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + const QString borne = s.borneId.toString(); + s.borne->setStateValue("pluggedIn", true); + s.borne->setStateValue("power", true); + s.borne->setStateValue("charging", true); + s.borne->setStateValue("maxChargingCurrent", 16); + s.borne->setStateValue("currentPhaseA", 16.0); + s.borne->setStateValue("currentPowerPhaseA", 3680.0); + s.borne->setStateValue("currentPower", 3680.0); + const QList phasesA = {24, -14, -14}; // aveugles à la borne + 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; + } + + // La décision de la STRATÉGIE, lue dans la trame que publie le cycle : le harnais rappelle + // ensuite L4 sur le même compteur (simulationCallUpdate()), qui coupe la borne et republie. + QSignalSpy notifs(s.arb, &EnergyArbitrator::loadTelemetryChanged); + cycleHc(s, utcDateTime(QDate(2026, 6, 8), QTime(10, 0)), totalW); + QVariantMap strategie; + QStringList trace; + for (const QList &n : notifs) { + const QVariantMap t = telemetrieParId(n.first().toMap()).value(borne); + trace << QJsonDocument::fromVariant(t.value("decision")).toJson(QJsonDocument::Compact) + + QStringLiteral(" alloué %1 W").arg(t.value("allocatedW").toDouble()); + if (t.value("decision").toMap().value("code").toString() != QString(DecisionCode::OverloadThrottled)) + strategie = t; + } + qInfo().noquote() << "[dépassement, jour]" << trace.join(QStringLiteral(" ; ")); + QVERIFY2(!strategie.isEmpty(), qUtf8Printable(trace.join(" ; "))); + QVERIFY2(qRound(strategie.value("allocatedW").toDouble()) == 1380, qUtf8Printable(trace.join(" ; "))); +#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 331cbe6..94557ca 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -364,6 +364,9 @@ private slots: //! 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(); + //! Lot délestage V2C (question 5) — EN DÉPASSEMENT, la règle de jour ne rend rien : phases du + //! compteur aveugles à la borne, la stratégie la tient à son plancher. + void testWaterfallChargerGetsNothingBackInOverload(); //! 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.