fix(3g-2): une borne sans voiture assignée reste arbitrée
LM-1208-b appliqué : `assignedCarId` sort du filtre d'entrée du waterfall. Restent deux exclusions, et elles ne disent pas la même chose — le mode manuel est une INTENTION (l'utilisateur pilote), l'absence de véhicule branché un CONSTAT (rien à consommer de ce qu'on allouerait). Le critère venait du proxy, où il a un sens : sans voiture, pas de SoC, pas de capacité, pas d'échéance à planifier. Le waterfall n'a besoin d'aucune des trois pour allouer du surplus. Il contredisait LM-1206 — le rang et le plancher appartiennent à la borne, pas au véhicule — il n'était pas vérifiable, et il excluait la borne d'un invité au moment précis où elle consomme. Ce qu'on perd sans voiture est une FONCTION, échéance et cible d'énergie, pas l'arbitrage. LM-1208-c, trouvé en implantant, et c'est lui qui aurait mordu : retirer le critère ne suffit pas. Le proxy ne PRÉPARE pas une borne sans voiture — prepareInformation() la filtre en entrée — donc elle entre dans le waterfall sans ChargingProcessInfo. Or internalProcessInfo() rend un objet par DÉFAUT, et ses valeurs sont plausibles : une phase, pas de bascule. Une borne triphasée aurait été annoncée monophasée et le budget divisé par trois — plafond 3 680 W au lieu de 11 040, 16 A commandés au lieu de 9, 11 kW tirés d'une enveloppe qui en comptait 3,7. Le défaut de 3g-1 pris à l'envers, et silencieux. D'où internalHasProcessInfo() : la question « cette borne a-t-elle été préparée ce cycle » devient POSABLE au lieu d'être déduite d'une valeur crédible. Sans ChargingProcessInfo on ne bascule pas — basculer suppose de savoir ce que la voiture accepte — et on retient le phaseCount de la BORNE, hypothèse conservatrice au sens de LM-104 : elle surestime la consommation. Éprouvé par testChargerWithoutCarIsArbitrated, et VÉRIFIÉ ÉCHOUANT sans le garde-fou : 3 680 W au lieu de 6 000 W alloués. Le test porte les deux moitiés — la borne sans voiture est servie ET le mode manuel reste une exclusion, sans quoi il passerait aussi si toute borne était arbitrée sans condition. amd64 0 erreur / 0 avertissement. Suites, un processus par test : simulation 29/29 (dont run 16/16), charging 16/16, loadmodel 18/18, spotmarket 7/7. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
This commit is contained in:
parent
7b00ed4fe0
commit
d952a8cd54
@ -422,10 +422,31 @@ bool EnergyArbitrator::evParticipatesInWaterfall(EvCharger *ev) const
|
||||
|
||||
const ChargingInfo info = chargingInfo(ev->id());
|
||||
|
||||
// Sans voiture assignée, le proxy ignorait la borne : ni SoC, ni plancher véhicule, ni
|
||||
// échéance calculable. Rien de tout cela n'apparaît en entrant dans le waterfall.
|
||||
if (!m_tm->findConfiguredThing(info.assignedCarId()))
|
||||
return false;
|
||||
// LM-1208-b — « pas de voiture assignée » N'EST PLUS un critère d'exclusion.
|
||||
//
|
||||
// Le critère venait du filtre d'entrée du proxy, où il avait un sens : sans voiture, le
|
||||
// proxy n'a ni SoC, ni capacité, ni échéance à planifier, et il n'a donc rien à faire de
|
||||
// cette borne. Le waterfall, lui, n'a besoin d'aucune des trois pour allouer du surplus —
|
||||
// le rang et le plancher appartiennent à la BORNE (LM-1206), pas au véhicule.
|
||||
//
|
||||
// Trois raisons de le retirer, et la troisième est décisive :
|
||||
// · le cas de l'INVITÉ le rend intenable — une voiture qui n'est pas celle du
|
||||
// propriétaire ne sera jamais déclarée, et la borne cesserait d'être arbitrée
|
||||
// précisément quand elle consomme (3,9 kW mesurés au banc le 2026-08-28) ;
|
||||
// · le critère n'est pas VÉRIFIABLE — l'assignation est déclarée à la main, rien ne dit
|
||||
// ce qui est réellement branché : un critère d'arbitrage assis sur une déclaration
|
||||
// invérifiable n'arbitre pas, il devine ;
|
||||
// · il CONTREDIT LM-1206, en réintroduisant la dépendance au véhicule que LM-1206 avait
|
||||
// écartée.
|
||||
//
|
||||
// Ce qu'on perd sans voiture assignée est une FONCTION — l'échéance et la cible d'énergie,
|
||||
// qui restent au proxy et que le proxy continue d'ignorer pour cette borne — et non
|
||||
// l'arbitrage. Dégradation, pas exclusion : la même distinction que C1 de LM-1009.
|
||||
//
|
||||
// CONSÉQUENCE À TENIR AILLEURS : le manager ne PRÉPARE pas une borne sans voiture
|
||||
// (prepareInformation() la filtre en entrée), donc son ChargingProcessInfo n'existe pas.
|
||||
// C'est evTargetPhases() qui porte ce cas — sans quoi une borne triphasée serait annoncée
|
||||
// monophasée et le budget divisé par trois.
|
||||
|
||||
// Mode manuel : l'utilisateur pilote, l'arbitre s'abstient. Hors arbitrage — pas rang 0.
|
||||
if (info.chargingMode() == ChargingInfo::ChargingModeNormal)
|
||||
@ -442,10 +463,10 @@ bool EnergyArbitrator::evParticipatesInWaterfall(EvCharger *ev) const
|
||||
// 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.
|
||||
// Hors arbitrage, et non « servie à 0 avec un motif » : c'est la même famille que le 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 l'autre.
|
||||
if (!ev->pluggedIn())
|
||||
return false;
|
||||
|
||||
@ -496,6 +517,24 @@ uint EnergyArbitrator::evTargetPhases(EvCharger *ev, double referenceA)
|
||||
if (!ev)
|
||||
return 1;
|
||||
|
||||
// LM-1208-b — une borne SANS VOITURE ASSIGNÉE est arbitrée, mais le manager ne la prépare
|
||||
// pas : prepareInformation() la filtre en entrée, donc elle n'a aucun ChargingProcessInfo.
|
||||
// internalProcessInfo() rendrait alors un objet PAR DÉFAUT — canSwitchPhaseCount = false,
|
||||
// maxPossiblePhaseCount = 1 — c'est-à-dire « monophasée » pour une borne qui peut être
|
||||
// triphasée. Le budget serait divisé par trois, et EN SILENCE : le waterfall allouerait
|
||||
// 3 680 W en croyant servir 16 A sur une phase, l'adaptateur commanderait 16 A, et le
|
||||
// matériel en tirerait 11 040 W. C'est le défaut de 3g-1 pris à l'envers.
|
||||
//
|
||||
// Sans voiture, la capacité du véhicule est INCONNUE, et une valeur par défaut plausible
|
||||
// est le pire des masques (LM-104). Deux conséquences, une seule décision : on ne BASCULE
|
||||
// pas — basculer suppose de savoir ce que la voiture accepte, et executeChargingAction()
|
||||
// ne commanderait de toute façon aucun desiredPhaseCount sans ChargingProcessInfo — et on
|
||||
// retient le nombre de phases de la BORNE, qui est celui qu'elle utilisera réellement.
|
||||
// C'est l'hypothèse conservatrice pour le budget : elle SURESTIME la consommation de la
|
||||
// borne plutôt que de la sous-estimer.
|
||||
if (!internalHasProcessInfo(ev))
|
||||
return qMax(1u, ev->phaseCount());
|
||||
|
||||
// Les bornes de ce qui est POSSIBLE viennent du manager, qui les a établies ce cycle avec
|
||||
// la voiture branchée (canSwitchPhaseCount, min/maxPossiblePhaseCount). Les redériver ici
|
||||
// en ferait une seconde version — et c'est exactement ce qui a divergé en 3g-1 : viser
|
||||
@ -618,16 +657,20 @@ SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const
|
||||
|
||||
// --- loads[] : bornes VE (3g-1) ------------------------------------------------------
|
||||
// Elles entrent désormais dans le MÊME contexte que les autres charges — c'est toute la
|
||||
// transplantation. Le filtre d'entrée du proxy est repris à l'identique : une borne sans
|
||||
// voiture ou en mode manuel est HORS arbitrage, pas servie en dernier. Sa consommation est
|
||||
// déjà dans le compteur racine, donc déjà dans le budget ; lui allouer quoi que ce soit la
|
||||
// compterait deux fois.
|
||||
// transplantation. DEUX exclusions seulement, et elles ne disent pas la même chose : le
|
||||
// mode manuel est une INTENTION (l'utilisateur pilote, l'arbitre s'abstient), l'absence de
|
||||
// véhicule branché un CONSTAT (la borne ne peut rien tirer de ce qu'on lui allouerait).
|
||||
// « Pas de voiture assignée » n'en est plus une — LM-1208-b, cf. evParticipatesInWaterfall().
|
||||
//
|
||||
// Une borne exclue n'est pas pour autant sortie de la COMPTABILITÉ (LM-1208) : sa
|
||||
// consommation est déjà dans le compteur racine, donc déjà retranchée du budget ; lui
|
||||
// allouer quoi que ce soit la compterait deux fois.
|
||||
for (auto it = m_adapters.constBegin(); it != m_adapters.constEnd(); ++it) {
|
||||
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, mode manuel, "
|
||||
"ou aucun véhicule branché).";
|
||||
<< "hors waterfall (mode manuel ou aucun véhicule "
|
||||
"branché).";
|
||||
continue;
|
||||
}
|
||||
ctx.loads.append(it.value()->toLoadContext(now));
|
||||
|
||||
@ -205,18 +205,29 @@ public:
|
||||
/*!
|
||||
* \brief [3g-1] Cette borne participe-t-elle au waterfall de surplus ?
|
||||
*
|
||||
* 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é**.
|
||||
* **Deux** exclusions, et elles ne disent pas la même chose : le **mode manuel**
|
||||
* (\c ChargingModeNormal) est une INTENTION — l'utilisateur pilote, l'arbitre s'abstient ;
|
||||
* **aucun véhicule branché** (\c pluggedIn) est un CONSTAT — la borne ne peut rien tirer
|
||||
* de ce qu'on lui allouerait.
|
||||
*
|
||||
* 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.
|
||||
* déjà dans le compteur racine, donc dans le budget, sans qu'on ait à la leur allouer
|
||||
* (LM-1208 — sortir de la décision n'est pas sortir de la comptabilité).
|
||||
*
|
||||
* \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).
|
||||
* \note **`pluggedIn` a été ajouté après le banc du 2026-08-27** : les exclusions
|
||||
* d'origine venaient du filtre d'entrée du proxy, qui n'avait pas besoin de celle-ci —
|
||||
* 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).
|
||||
* \note **« Pas de voiture assignée » a été RETIRÉ des exclusions le 2026-08-28**
|
||||
* (LM-1208-b) : le critère contredisait LM-1206 — le rang et le plancher appartiennent à
|
||||
* la borne, pas au véhicule — il n'était pas vérifiable, et il excluait la borne d'un
|
||||
* invité au moment précis où elle consomme. Ce qu'on perd sans voiture assignée est une
|
||||
* FONCTION (échéance, cible d'énergie, toutes deux restées au proxy), pas l'arbitrage.
|
||||
* \warning Corollaire à ne pas perdre : une borne sans voiture n'a **pas** de
|
||||
* \c ChargingProcessInfo — \c SmartChargingManager::prepareInformation() l'écarte
|
||||
* toujours. Tout code qui lit \c internalProcessInfo() pour une borne du waterfall doit
|
||||
* d'abord interroger \c internalHasProcessInfo() ; voir \c evTargetPhases().
|
||||
*/
|
||||
bool evParticipatesInWaterfall(EvCharger *ev) const;
|
||||
|
||||
@ -234,6 +245,13 @@ public:
|
||||
* puisse jamais la démarrer. Le plafond est donc rendu ici, au seul endroit qui parle
|
||||
* de phases pour le waterfall.
|
||||
*
|
||||
* \note **[LM-1208-b] Sans \c ChargingProcessInfo, la règle du manager n'existe pas** —
|
||||
* c'est le cas d'une borne sans voiture assignée, que \c prepareInformation() écarte
|
||||
* alors qu'elle est désormais arbitrée. La capacité du véhicule étant inconnue, on ne
|
||||
* bascule pas et on retient le nombre de phases de la BORNE. Laisser
|
||||
* \c internalProcessInfo() rendre son objet par défaut annoncerait « monophasée » une
|
||||
* borne triphasée, et diviserait le budget du cycle par trois — silencieusement.
|
||||
*
|
||||
* \param ev Borne concernée.
|
||||
* \param referenceA Courant de référence, en ampères TOTAUX toutes phases confondues —
|
||||
* voir \c evReferenceCurrentA(). Peut être négatif en soutirage.
|
||||
|
||||
@ -123,7 +123,8 @@ signals:
|
||||
// Sémantique amont inchangée : NON documentés
|
||||
// ici, la doc appartient à l'amont.
|
||||
// 4. internalEvChargers(), internalChargingActions(), internalRootMeter(),
|
||||
// internalProcessInfo() — accesseurs ETM neufs, documentés ci-dessous.
|
||||
// internalProcessInfo(), internalHasProcessInfo()
|
||||
// — accesseurs ETM neufs, documentés ci-dessous.
|
||||
// 5. bestPhaseCountFor(), phasesForCount() — passe-plats ETM vers deux règles amont
|
||||
// privées, documentés ci-dessous.
|
||||
// 7. setEvChargingState() — écriture ETM d'un état RUNTIME, documentée
|
||||
@ -261,6 +262,26 @@ protected:
|
||||
* et sous surcharge cela commandait 3 × 6 A là où le proxy commandait 1 × 6 A.
|
||||
*/
|
||||
ChargingProcessInfo internalProcessInfo(EvCharger *evCharger) const { return m_processInfos.value(evCharger); } // [ETM] new
|
||||
|
||||
/*!
|
||||
* \brief [ETM · LM-1208-b] Cette borne a-t-elle été PRÉPARÉE ce cycle ?
|
||||
*
|
||||
* \param evCharger Borne.
|
||||
* \return Vrai si \c prepareInformation() lui a construit un \c ChargingProcessInfo
|
||||
* pendant ce cycle ; faux sinon — notamment quand aucune voiture ne lui est assignée,
|
||||
* cas où le filtre d'entrée de \c prepareInformation() l'écarte.
|
||||
*
|
||||
* \note Existe parce que \c internalProcessInfo() ne sait pas dire « absent » : il rend un
|
||||
* objet par DÉFAUT, dont les valeurs — 1 phase, pas de bascule — sont des réponses
|
||||
* parfaitement plausibles qu'aucun appelant ne peut distinguer d'une préparation réelle.
|
||||
* \note Le cas est devenu RÉEL avec **LM-1208-b** : une borne sans voiture assignée entre
|
||||
* dans le waterfall, alors que \c prepareInformation() continue de l'écarter. Elle est
|
||||
* donc arbitrée sans \c ChargingProcessInfo, et
|
||||
* \c EnergyArbitrator::evTargetPhases() doit le SAVOIR plutôt que de le déduire de
|
||||
* valeurs par défaut — sans quoi une borne triphasée est annoncée monophasée et le
|
||||
* budget du cycle est divisé par trois.
|
||||
*/
|
||||
bool internalHasProcessInfo(EvCharger *evCharger) const { return m_processInfos.contains(evCharger); } // [ETM] new
|
||||
// [ETM] END
|
||||
|
||||
private slots:
|
||||
|
||||
@ -1085,6 +1085,32 @@ que C1 de LM-1009 : une capacité manquante se dit, elle ne fait pas disparaîtr
|
||||
> personne n'a choisi un mode éco** — ce qui est correct par construction, mais doit être visible
|
||||
> à la mise en service, sans quoi « la borne ne charge pas toute seule » se lira comme un défaut.
|
||||
|
||||
**LM-1208-c — Sans voiture, le nombre de phases vient de la BORNE.** *(Conséquence de
|
||||
LM-1208-b trouvée à la mise en œuvre, 2026-08-28. Elle n'est pas un détail d'implantation : elle
|
||||
décide du budget.)*
|
||||
|
||||
Retirer le critère ne suffit pas, et ce qui manque ne se voit pas. Le proxy amont **ne prépare
|
||||
pas** une borne sans voiture — `prepareInformation()` la filtre en entrée, et c'est correct :
|
||||
sans véhicule il n'a ni SoC, ni capacité, ni échéance à planifier. La borne entre donc dans le
|
||||
waterfall **sans `ChargingProcessInfo`**, c'est-à-dire sans la règle de bascule 1↔3 phases que
|
||||
LM-1206 avait pris soin de ne pas redériver.
|
||||
|
||||
Or l'accesseur rend un objet **par défaut**, pas une absence — et ses valeurs par défaut sont
|
||||
*plausibles* : une phase, pas de bascule. Une borne triphasée serait donc annoncée monophasée,
|
||||
et **le budget divisé par trois** : plafond à 3 680 W au lieu de 11 040, courant commandé à 16 A
|
||||
au lieu de 9, et 11 kW tirés d'une enveloppe qui en comptait 3,7. C'est le défaut de 3g-1
|
||||
(« viser 3 phases sur une combinaison qui n'en permet qu'une ») pris exactement à l'envers.
|
||||
|
||||
**La règle** : sans `ChargingProcessInfo`, on **ne bascule pas** — basculer suppose de savoir ce
|
||||
que la voiture accepte — et on retient `phaseCount` de la **borne**, qui est ce qu'elle utilisera
|
||||
réellement. C'est l'hypothèse conservatrice au sens de LM-104 : elle **surestime** la
|
||||
consommation de la borne plutôt que de la sous-estimer.
|
||||
|
||||
> **Ce que ce cas apprend au-delà des bornes** : un accesseur qui rend un défaut là où il
|
||||
> faudrait dire « absent » est un masquage de panne (corollaire LM-104), et il est d'autant plus
|
||||
> dangereux que le défaut est crédible. La question « cette borne a-t-elle été préparée ? » doit
|
||||
> être POSABLE, pas déduite d'une valeur.
|
||||
|
||||
**LM-1207 — Ne PAS ajouter de `priority` à `ChargingInfo`.** Ce serait un **second système de
|
||||
priorité**, et il serait immédiatement insuffisant : il ne saurait pas exprimer
|
||||
**« VE1 > ECS > VE2 »**, puisqu'il ne classerait que les bornes entre elles. Deux classements
|
||||
|
||||
@ -5506,3 +5506,123 @@ void Simulation::testUnpluggedChargerTakesNoBudget()
|
||||
QCOMPARE(qRound(tele.value(routeurId.toString()).value("allocatedW").toDouble()), 0);
|
||||
#endif
|
||||
}
|
||||
|
||||
void Simulation::testChargerWithoutCarIsArbitrated()
|
||||
{
|
||||
#ifndef ETM_ARBITRATOR
|
||||
QSKIP("testChargerWithoutCarIsArbitrated nécessite ETM_ARBITRATOR.");
|
||||
#else
|
||||
// [LM-1208-b · banc 2026-08-28] Le cas de l'INVITÉ. Une borne en Eco, un véhicule branché,
|
||||
// et AUCUNE voiture assignée — parce qu'une voiture qui n'est pas celle du propriétaire ne
|
||||
// sera jamais déclarée. Elle tirait 3,9 kW au banc et l'arbitre l'ignorait : le critère
|
||||
// « pas de voiture assignée » la sortait du waterfall précisément quand elle consommait.
|
||||
//
|
||||
// Deux choses sont éprouvées ici, et la seconde est celle qui se serait perdue :
|
||||
// 1. la borne EST arbitrée sans voiture assignée ;
|
||||
// 2. elle l'est avec le nombre de phases de la BORNE. Le manager ne prépare pas une
|
||||
// borne sans voiture (prepareInformation la filtre), donc son ChargingProcessInfo
|
||||
// n'existe pas — et le lire par défaut annonce « 1 phase » pour une borne triphasée.
|
||||
// Le défaut serait SILENCIEUX : plafond à 3 680 W au lieu de 11 040, courant commandé
|
||||
// à 16 A au lieu de 9, et 11 kW tirés d'un budget qui en comptait 3,7.
|
||||
const QString cfgPath = QDir::tempPath() + "/etm-loadcfg-nocar.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);
|
||||
|
||||
// Tolérance d'acquisition à 1,0 : aucun démarrage sous tolérance ne vient brouiller la
|
||||
// lecture du plancher — EV_GRID_START a son propre test.
|
||||
QCOMPARE(injectAndWait("NymeaEnergy.SetAcquisitionTolerance",
|
||||
QVariantMap({{"acquisitionTolerance", 1.0}})).toMap()
|
||||
.value("params").toMap().value("energyError").toString(),
|
||||
QString("EnergyErrorNoError"));
|
||||
|
||||
// La borne est TRIPHASÉE — c'est tout l'enjeu du point 2. Plancher 6 A × 230 V × 3
|
||||
// = 4 140 W, plafond 16 A × 230 V × 3 = 11 040 W.
|
||||
QUuid borneId = addCharger("ABC", 16, 26657);
|
||||
QUuid routeurId = addEtmVariableLoad(27050);
|
||||
Thing *borne = tm->findConfiguredThing(borneId);
|
||||
Thing *routeur = tm->findConfiguredThing(routeurId);
|
||||
QVERIFY(borne && routeur);
|
||||
armerBorne(borne); // branchée, à l'arrêt, 6 A
|
||||
QCOMPARE(borne->stateValue("phaseCount").toUInt(), 3u);
|
||||
routeur->setStateValue("currentPowerW", 0);
|
||||
|
||||
// AUCUN assignedCarId — et aucune voiture n'est même ajoutée au système.
|
||||
QVariantMap info;
|
||||
info.insert("evChargerId", borneId);
|
||||
info.insert("chargingMode", "ChargingModeEco");
|
||||
QCOMPARE(injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}}))
|
||||
.toMap().value("params").toMap().value("energyError").toString(),
|
||||
QString("EnergyErrorNoError"));
|
||||
|
||||
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 : sans elle, « la borne prend tout » ne se distinguerait pas
|
||||
// de « personne ne prend rien ».
|
||||
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"));
|
||||
|
||||
// ---- Sans voiture assignée : la borne est ARBITRÉE ------------------------------------
|
||||
meter->setStateValue("currentPower", -6000);
|
||||
arb->simulationCallUpdate(t0.addSecs(60));
|
||||
QCoreApplication::processEvents();
|
||||
QHash<QString, QVariantMap> tele = telemetrieParId(arb->loadTelemetry());
|
||||
|
||||
QVERIFY2(tele.contains(borneId.toString()),
|
||||
"une borne sans voiture assignée a été exclue du waterfall (LM-1208-b)");
|
||||
|
||||
// Le budget entier lui revient : 6 000 W passent sous son plafond TRIPHASÉ (11 040 W).
|
||||
// Annoncée monophasée, elle aurait plafonné à 3 680 W et laissé 2 320 W au routeur — c'est
|
||||
// exactement la lecture qui distingue les deux.
|
||||
QCOMPARE(qRound(tele.value(borneId.toString()).value("allocatedW").toDouble()), 6000);
|
||||
QCOMPARE(qRound(tele.value(routeurId.toString()).value("allocatedW").toDouble()), 0);
|
||||
|
||||
// Et le COURANT commandé le confirme au matériel, pas seulement dans la charge utile :
|
||||
// 6 000 / (230 × 3) = 8,7 → 9 A. Sur une phase supposée, c'eût été 16 A écrêtés au plafond
|
||||
// de la borne — soit 11 kW tirés d'un budget qui en comptait 3,7.
|
||||
QCOMPARE(borne->stateValue("maxChargingCurrent").toUInt(), 9u);
|
||||
|
||||
// ---- Le mode manuel, lui, RESTE une exclusion ------------------------------------------
|
||||
// Sans cette moitié, le test passerait aussi si toute borne était arbitrée sans condition :
|
||||
// il ne prouverait pas que c'est le critère « voiture » qui a été retiré, et lui seul.
|
||||
info.insert("chargingMode", "ChargingModeNormal");
|
||||
QCOMPARE(injectAndWait("NymeaEnergy.SetChargingInfo", QVariantMap({{"chargingInfo", info}}))
|
||||
.toMap().value("params").toMap().value("energyError").toString(),
|
||||
QString("EnergyErrorNoError"));
|
||||
routeur->setStateValue("currentPowerW", 0);
|
||||
arb->simulationCallUpdate(t0.addSecs(120));
|
||||
QCoreApplication::processEvents();
|
||||
tele = telemetrieParId(arb->loadTelemetry());
|
||||
|
||||
QVERIFY2(!tele.contains(borneId.toString()),
|
||||
"une borne en mode manuel doit rester hors arbitrage");
|
||||
QCOMPARE(qRound(tele.value(routeurId.toString()).value("allocatedW").toDouble()), 3000);
|
||||
#endif
|
||||
}
|
||||
|
||||
@ -165,6 +165,13 @@ private slots:
|
||||
// se lit comme une charge dont l'appareil manque, pas comme une charge jamais configurée.
|
||||
void testEvChargerConfigOutlivesItsThing();
|
||||
|
||||
// [LM-1208-b, banc 2026-08-28] Une borne SANS VOITURE ASSIGNÉE est arbitrée — le cas de
|
||||
// l'invité. Et elle l'est avec le nombre de phases de la BORNE : le manager ne la prépare
|
||||
// pas, donc son ChargingProcessInfo n'existe pas, et le lire par défaut annoncerait
|
||||
// monophasée une borne triphasée en divisant le budget par trois, silencieusement.
|
||||
// Le mode manuel, lui, reste une exclusion : la seconde moitié du test le prouve.
|
||||
void testChargerWithoutCarIsArbitrated();
|
||||
|
||||
void printStates(Thing *thing);
|
||||
void updateChargerMeter(Thing *thing);
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user