diff --git a/AGENTS.md b/AGENTS.md index 25693b5..c758a31 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -186,14 +186,17 @@ vers un gestionnaire d'énergie complet (EV, ECS, PAC SG-Ready, batterie). - **arm64** cross-compile validé en pré-déploiement (infra CI `etm-powersync-deploy`). - **`Doxyfile` + job CI `doxygen -W`** (automatiser l'audit doc). -**PROCHAINE ACTION** : **le trou de `pluggedIn`** — une borne sans véhicule branché absorbe le -budget de la cascade et le donne à personne (4 853 W mesurés au banc le 2026-08-27, -`docs/RELEVE_3g2.md` §3.1). Défaut de 3g-1, révélé par 3g-2 parce que la décision atteint enfin -le matériel. Correction proposée et non appliquée : `pluggedIn` rejoint le filtre d'entrée du -waterfall, la borne devient **hors arbitrage** comme une borne sans voiture assignée. +**PROCHAINE ACTION** : **le rang par défaut des bornes auto-provisionnées**, qui retombe à +égalité avec le rang 1 déjà occupé au lieu de mettre la borne « en tête » +(`docs/RELEVE_3g2.md` §3.2). Trois issues possibles, aucune tranchée — autoriser le rang 0, +renuméroter les autres, ou assumer l'égalité et laisser l'app poser les vrais rangs à la mise +en service. Sans conséquence sur la reproductibilité (le tri est total), donc à décider, pas à +réparer dans l'urgence. -Ensuite : le rang par défaut des bornes auto-provisionnées, qui retombe à égalité au lieu de -« en tête » (§3.2) ; puis **passe contrats** (OPTIMIZER_PROTOCOL + README). +Ensuite : **passe contrats** (OPTIMIZER_PROTOCOL + README). + +> Le **trou de `pluggedIn`** trouvé au même passage est CORRIGÉ — une borne sans véhicule +> branché est hors arbitrage, elle n'absorbe plus le budget de la cascade. **Restent à guetter au banc, jamais à fabriquer** : `EV_GRID_START` (fenêtre `[plancher × 0,5 ; plancher[`), `BATTERY_RESERVE` avec un surplus réel sous le seuil, et diff --git a/INTERFACE.md b/INTERFACE.md index e06fcb0..7ca0207 100644 --- a/INTERFACE.md +++ b/INTERFACE.md @@ -519,9 +519,13 @@ le cas normal. > *(Jusqu'à 3g-1, `EvAdapter::descriptor()` fixait `priority = 100` pour toutes : même clé de > tri, `std::sort` non stable, et laquelle démarrait n'était ni réglable ni reproductible.)* -**Bornes exclues de `loads[]`** : celles sans voiture assignée et celles en mode manuel -(`ChargingModeNormal`). Elles sont **hors arbitrage**, pas servies en dernier — leur -consommation est déjà au compteur racine, donc déjà dans le budget. +**Bornes exclues de `loads[]`** : celles sans voiture assignée, celles en mode manuel +(`ChargingModeNormal`), et celles **sans véhicule branché**. Elles sont **hors arbitrage**, pas +servies en dernier — leur consommation est déjà au compteur racine, donc déjà dans le budget. + +> `pluggedIn` a rejoint ce filtre après le banc du 2026-08-27 : une borne débranchée ne peut +> rien tirer de ce qu'on lui alloue, et en cascade cette allocation est prise aux charges +> suivantes. 4 853 W mesurés donnés à personne (`docs/RELEVE_3g2.md` §3.1). #### `loads[]` ⊆ `GetLoadConfig` — toute charge publiée est configurable diff --git a/docs/RELEVE_3g2.md b/docs/RELEVE_3g2.md index ec9596d..f3f90f5 100644 --- a/docs/RELEVE_3g2.md +++ b/docs/RELEVE_3g2.md @@ -140,10 +140,15 @@ L'asymétrie est visible dans le code même : la boucle EV du scheduler, elle, t **Défaut de 3g-1**, révélé par 3g-2 parce que la décision atteint enfin le matériel. -**Correction proposée, non appliquée** : `pluggedIn` rejoint le filtre d'entrée. Une borne sans -véhicule est **hors arbitrage**, comme une borne sans voiture assignée ou en mode manuel +**CORRIGÉ** (commit dédié, après ce relevé) : `pluggedIn` rejoint le filtre d'entrée. Une borne +sans véhicule est **hors arbitrage**, comme une borne sans voiture assignée ou en mode manuel (`DESIGN_3g` §4) — absente du contexte, pas servie à 0 : sa consommation est nulle et déjà au -compteur. À traiter dans son propre commit, avec le même journal de saut que les deux autres cas. +compteur. Le journal l'annonce comme les deux autres cas. + +Éprouvé par `testUnpluggedChargerTakesNoBudget`, **vérifié échouant sans le correctif** : la +borne débranchée disparaît du contexte, la charge suivante reçoit les 3 000 W entiers, et la +seconde moitié du test rebranche le véhicule pour prouver que c'est bien `pluggedIn` qui décide +et non une exclusion fortuite. ### 3.2 Mineur — le rang par défaut retombe à ÉGALITÉ, pas « en tête » 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();