fix(3g-1): une borne sans véhicule branché est hors arbitrage
Trouvé au banc le 2026-08-27, en exerçant 3g-2 (docs/RELEVE_3g2.md §3.1). Une wallbox en Eco, voiture assignée, mais `pluggedIn: false`, s'est vu allouer 4 853 W qu'elle ne pouvait pas tirer — `currentPower: 0`, `charging: false`. La PAC derrière elle n'a survécu que sur son propre recrédit. Trois cycles consécutifs : 2 350 / 4 853 / 5 464 W retirés du budget et donnés à personne. evParticipatesInWaterfall() reprenait le filtre d'entrée du proxy — voiture assignée, mode non manuel — mais pas `pluggedIn`. Le proxy n'en avait pas besoin : son allocation n'était pas une cascade, donc gaspiller ne privait personne. En cascade, si. L'asymétrie était 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. C'est le défaut du relevé 3g-1 §3.4 par une autre porte : là le plancher mentait, ici c'est le filtre d'entrée. Défaut de 3g-1, révélé par 3g-2 parce que la décision atteint enfin le matériel — et corrigé à part du lot, comme les quatre précédents. Hors arbitrage, et non « servie à 0 avec un motif » : même famille que les deux autres exclusions (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 les deux autres cas. testUnpluggedChargerTakesNoBudget, VÉRIFIÉ ÉCHOUANT SANS LE CORRECTIF : la borne débranchée disparaît du contexte et la charge suivante reçoit les 3 000 W entiers ; la seconde moitié rebranche le véhicule pour prouver que c'est bien `pluggedIn` qui décide, et non une exclusion fortuite. Suite complète : simulation 28/28, charging 16/16, loadmodel 16/16, spotmarket 7/7. Build amd64 0/0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
This commit is contained in:
parent
93b257172c
commit
c2f08cb804
17
AGENTS.md
17
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
|
||||
|
||||
10
INTERFACE.md
10
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
|
||||
|
||||
|
||||
@ -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 »
|
||||
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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<EnergyArbitrator *>(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<QString, QVariantMap> 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
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user