diff --git a/energyplugin/etm/energyarbitrator.cpp b/energyplugin/etm/energyarbitrator.cpp index 1d17499..2eb002d 100644 --- a/energyplugin/etm/energyarbitrator.cpp +++ b/energyplugin/etm/energyarbitrator.cpp @@ -431,6 +431,24 @@ bool EnergyArbitrator::evParticipatesInWaterfall(EvCharger *ev) const if (info.chargingMode() == ChargingInfo::ChargingModeNormal) return false; + // Aucun véhicule branché : la borne ne peut RIEN consommer de ce qu'on lui allouerait. + // + // Le proxy n'avait pas besoin de ce test — son allocation n'était pas une cascade, et + // gaspiller ne privait personne. En cascade, si : au banc le 2026-08-27, une wallbox + // `pluggedIn: false` s'est vu allouer 4 853 W qu'elle ne tirait pas, et la PAC derrière + // elle n'a survécu que sur son propre recrédit (docs/RELEVE_3g2.md §3.1). + // + // L'asymétrie était d'ailleurs déjà visible dans le code : la boucle EV du scheduler, qui + // sert les financements RÉSEAU, teste `!ev->pluggedIn()` depuis toujours. Seul le chemin + // waterfall ne le faisait pas. + // + // Hors arbitrage, et non « servie à 0 avec un motif » : c'est la même famille que les deux + // cas ci-dessus (DESIGN_3g §4). Une borne débranchée ne consomme rien, sa consommation + // nulle est déjà au compteur, et lui donner un rang la ferait apparaître dans la cascade + // pour n'y rien faire. Le journal l'annonce, comme pour les deux autres. + if (!ev->pluggedIn()) + return false; + return true; } @@ -608,7 +626,8 @@ SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const EvCharger *ev = internalEvChargers().value(ThingId(it.key())); if (!evParticipatesInWaterfall(ev)) { qCDebug(dcNymeaEnergy()) << "[Arbitre] Borne" << (ev ? ev->name() : it.key()) - << "hors waterfall (pas de voiture assignée, ou mode manuel)."; + << "hors waterfall (pas de voiture assignée, mode manuel, " + "ou aucun véhicule branché)."; continue; } ctx.loads.append(it.value()->toLoadContext(now)); diff --git a/energyplugin/etm/energyarbitrator.h b/energyplugin/etm/energyarbitrator.h index 34f3daf..2eb973b 100644 --- a/energyplugin/etm/energyarbitrator.h +++ b/energyplugin/etm/energyarbitrator.h @@ -205,10 +205,18 @@ public: /*! * \brief [3g-1] Cette borne participe-t-elle au waterfall de surplus ? * - * Reprend **à l'identique** le filtre d'entrée du proxy : une borne sans voiture assignée - * ou en mode manuel (\c ChargingModeNormal) est **hors arbitrage**, pas servie en dernier. - * L'exclure du contexte est plus juste que lui donner un rang : sa consommation est déjà - * dans le compteur racine, donc dans le budget, sans qu'on ait à la lui allouer. + * Trois exclusions, et toutes disent la même chose — cette borne ne peut rien faire de ce + * qu'on lui allouerait : **pas de voiture assignée**, **mode manuel** + * (\c ChargingModeNormal), ou **aucun véhicule branché**. + * + * Les exclure du contexte est plus juste que leur donner un rang : leur consommation est + * déjà dans le compteur racine, donc dans le budget, sans qu'on ait à la leur allouer. + * + * \note **`pluggedIn` a été ajouté après le banc du 2026-08-27** : les deux premières + * exclusions venaient du filtre d'entrée du proxy, qui n'avait pas besoin de la + * troisième — son allocation n'était pas une cascade, donc gaspiller ne privait + * personne. En cascade, une borne débranchée absorbe le budget des charges suivantes + * (4 853 W mesurés, \c docs/RELEVE_3g2.md §3.1). */ bool evParticipatesInWaterfall(EvCharger *ev) const; diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index 4c7e973..0844ae1 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -5404,3 +5404,105 @@ void Simulation::testEvChargerConfigOutlivesItsThing() QCOMPARE(qRound(entree.value("allocatedW").toDouble()), 0); #endif } + +void Simulation::testUnpluggedChargerTakesNoBudget() +{ +#ifndef ETM_ARBITRATOR + QSKIP("testUnpluggedChargerTakesNoBudget nécessite ETM_ARBITRATOR."); +#else + // [banc 2026-08-27, RELEVE_3g2 §3.1] Une borne en Eco, voiture assignée, mais AUCUN + // véhicule branché. Elle ne peut rien tirer de ce qu'on lui alloue — et pourtant elle + // était servie : 4 853 W retirés du budget et donnés à personne, la charge suivante ne + // survivant que sur son propre recrédit. + // + // Le proxy n'avait pas besoin de ce filtre : son allocation n'était pas une cascade, donc + // gaspiller ne privait personne. C'est la transplantation qui rend le test nécessaire. + const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-unplugged.json"; + QFile::remove(cfgPath); + qputenv("NYMEA_ENERGY_LOAD_CONFIG", cfgPath.toUtf8()); + + cleanupTestCase(); + m_energyLogDbFilePath = ":/databases/2022-06-22-energylogs.sqlite"; + initTestCase(); + + EnergyArbitrator *arb = dynamic_cast(m_experiencePlugin->smartChargingManager()); + QVERIFY(arb); + ThingManager *tm = NymeaCore::instance()->thingManager(); + + QUuid meterId = addMeter(); + m_experiencePlugin->energyManager()->setRootMeter(meterId); + Thing *meter = tm->findConfiguredThing(meterId); + QVERIFY(meter); + meter->setStateValue("connected", true); + + QUuid borneId = addCharger("A", 16, 26657); + QUuid voitureId = addCar(26656); + QUuid routeurId = addEtmVariableLoad(27050); + Thing *borne = tm->findConfiguredThing(borneId); + Thing *voiture = tm->findConfiguredThing(voitureId); + Thing *routeur = tm->findConfiguredThing(routeurId); + QVERIFY(borne && voiture && routeur); + + armerBorne(borne); + borne->setStateValue("pluggedIn", false); // ← le cas du banc : rien n'est branché + routeur->setStateValue("currentPowerW", 0); + voiture->setStateValue("batteryLevel", 40); + voiture->setStateValue("capacity", 50); + voiture->setStateValue("minChargingCurrent", 6); + voiture->setStateValue("phaseCount", 1); + + QVariantMap info; + info.insert("evChargerId", borneId); + info.insert("assignedCarId", voitureId); + info.insert("chargingMode", "ChargingModeEco"); + injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}})); + + const QDateTime t0 = utcDateTime(QDate(2026, 6, 8), QTime(13, 0, 0)); + meter->setStateValue("currentPower", 0); + arb->simulationCallUpdate(t0); // fait entrer la borne en configuration + QCoreApplication::processEvents(); + + // Une charge derrière la borne, au rang suivant : c'est elle qui doit recevoir le budget. + QVariantList l = injectAndWait("NymeaEnergy.GetLoadConfig").toMap() + .value("params").toMap().value("loadConfigs").toList(); + for (int i = 0; i < l.count(); ++i) { + QVariantMap m = l.at(i).toMap(); + if (m.value("id").toString() == borneId.toString()) m.insert("priority", 1); + l[i] = m; + } + l.append(QVariantMap{{"id", routeurId.toString()}, {"label", "Routeur PV"}, + {"adapter", "etmvariableload"}, {"mode", "dynamic"}, + {"maxPowerW", 3000}, {"priority", 2}, {"enabled", true}}); + QCOMPARE(injectAndWait("NymeaEnergy.SetLoadConfig", QVariantMap({{"loadConfigs", l}})) + .toMap().value("params").toMap().value("energyError").toString(), + QString("EnergyErrorNoError")); + + // ---- Débranchée : elle ne doit PAS figurer dans le contexte --------------------------- + meter->setStateValue("currentPower", -3000); + arb->simulationCallUpdate(t0.addSecs(60)); + QCoreApplication::processEvents(); + QHash tele = telemetrieParId(arb->loadTelemetry()); + + QVERIFY2(!tele.contains(borneId.toString()), + "une borne sans véhicule branché a été arbitrée"); + // Et le budget est allé ENTIÈREMENT à la charge suivante. C'est la moitié du test qui + // compte : l'exclusion ne vaut que si elle ne coûte rien à personne. + QCOMPARE(qRound(tele.value(routeurId.toString()).value("allocatedW").toDouble()), 3000); + QCOMPARE(qRound(arb->loadTelemetry().value("budget").toMap() + .value("allocatedW").toDouble()), 3000); + + // ---- Branchée : elle redevient une charge du waterfall -------------------------------- + // Sans cette seconde moitié, le test passerait aussi si la borne était exclue pour une + // raison quelconque — il ne prouverait pas que c'est `pluggedIn` qui décide. + borne->setStateValue("pluggedIn", true); + routeur->setStateValue("currentPowerW", 0); + arb->simulationCallUpdate(t0.addSecs(120)); + QCoreApplication::processEvents(); + tele = telemetrieParId(arb->loadTelemetry()); + + QVERIFY2(tele.contains(borneId.toString()), + "une borne avec véhicule branché doit entrer dans le waterfall"); + QCOMPARE(qRound(tele.value(borneId.toString()).value("allocatedW").toDouble()), 3000); + QCOMPARE(qRound(tele.value(routeurId.toString()).value("allocatedW").toDouble()), 0); +#endif +} diff --git a/tests/auto/simulation/simulation.h b/tests/auto/simulation/simulation.h index a6639a2..79128b8 100644 --- a/tests/auto/simulation/simulation.h +++ b/tests/auto/simulation/simulation.h @@ -155,6 +155,12 @@ private slots: // un financement réseau, un motif qui chiffre le soutirage, et une comptabilité partagée. void testEvGridStartIsAnnouncedAndBounded(); + // [banc 2026-08-27] Une borne SANS VÉHICULE BRANCHÉ ne peut rien consommer de ce qu'on lui + // allouerait : elle doit être HORS arbitrage, comme une borne sans voiture ou en mode + // manuel. Sans ce filtre elle absorbe le budget de la cascade et le donne à personne — + // 4 853 W mesurés au banc, la charge suivante n'ayant survécu que sur son recrédit. + void testUnpluggedChargerTakesNoBudget(); + // [3g-2] L'entrée de configuration d'une borne SURVIT à la disparition de son Thing : elle // se lit comme une charge dont l'appareil manque, pas comme une charge jamais configurée. void testEvChargerConfigOutlivesItsThing();