fix(3g-1): le nombre de phases visé se décide avec la voiture branchée

Défaut de 3g-1, resté latent parce que sa sortie n'atteignait que la
télémétrie. Il est corrigé à part du lot 3g-2 pour pouvoir être relu,
contesté ou rétroporté sans lui.

EvAdapter interrogeait EvCharger::canSetPhaseCount(), qui dit seulement que
la borne PORTE l'état desiredPhaseCount. La vraie question — la borne ET la
voiture acceptent-elles 1 et 3 phases — est tranchée une fois par cycle par
prepareInformation(), qui en tire canSwitchPhaseCount et le couple
min/maxPossiblePhaseCount. Les deux prédicats ont divergé : là où le proxy
appliquait `qMin(carPhaseCount, getBestPhaseCount(...))`, 3g-1 n'a repris que
l'appel.

Conséquence : une borne dont la combinaison ne permet qu'une phase se voyait
viser 3 phases. Son plancher publié triplait (1,4 kW → 4,1 kW) sans que rien
ne puisse jamais le franchir — la borne restait éternellement « sous son
plancher », avec un motif juste et une cause fausse.

Second défaut, dans applyAction() : le nombre de phases demandé était borné
par m_charger->phaseCount(), qui est l'état COURANT de la borne. Une borne en
monophasé ne pouvait donc jamais être commandée en triphasé, et le plancher
publié pour trois phases n'aurait de toute façon pas pu être servi.

evTargetPhases() cesse d'être un passe-plat et lit les bornes du manager, via
un accesseur [ETM] read-only de plus (internalProcessInfo). L'arbitre ne
redérive rien : une seconde version de cette règle divergerait de la première
au premier ajustement, ce qui est exactement ce qui vient d'arriver.

Suite charging 16/16, simulation `run` 16/16, testLoadTelemetryRpc vert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
This commit is contained in:
Patrick Schurig 2026-08-27 10:16:41 +02:00
parent 50dc3cc489
commit 99ad9a04cb
4 changed files with 65 additions and 11 deletions

View File

@ -63,10 +63,12 @@ LoadContext EvAdapter::toLoadContext(const QDateTime &now) const
//
// Le courant de référence pour ce choix est ce que la borne tire DÉJÀ : c'est la seule
// grandeur connue avant l'allocation, et c'est celle que le proxy utilisait aussi.
// Plus de test canSetPhaseCount() ici : la question « cette borne peut-elle basculer,
// avec CETTE voiture » est tranchée une fois par le manager, et evTargetPhases() la lit.
// Le prédicat local n'en voyait qu'une moitié (l'état existe-t-il) et retombait sur le
// nombre de phases COURANT, sans la voiture.
const double courantActuelA = m_charger->currentPower() / 230.0;
m_targetPhases = m_charger->canSetPhaseCount()
? m_parent->evTargetPhases(m_charger, courantActuelA)
: m_charger->phaseCount();
m_targetPhases = m_parent->evTargetPhases(m_charger, courantActuelA);
if (m_targetPhases == 0)
m_targetPhases = 1;
@ -123,8 +125,13 @@ LoadAction EvAdapter::applyAction(const LoadAction &action, const QDateTime &now
//
// Le discriminant est `currentA` : non nul, l'action vient du proxy et s'exécute telle
// quelle ; nul avec une puissance, elle vient de la cascade et se convertit.
// 3g-2 — le nombre de phases VISÉ, pas le nombre courant. Le qBound bornait la cible par
// m_charger->phaseCount(), qui est l'état COURANT de la borne : une borne en monophasé ne
// pouvait donc jamais être commandée en triphasé, et le plancher publié pour 3 phases
// n'aurait jamais pu être servi. Le plafond véhicule est déjà appliqué en amont, par
// EnergyArbitrator::evTargetPhases().
const uint phases = (m_charger->canSetPhaseCount() && action.phaseCount > 0)
? qBound(1u, action.phaseCount, m_charger->phaseCount())
? qBound(1u, action.phaseCount, 3u)
: (m_targetPhases > 0 ? m_targetPhases : m_charger->phaseCount());
double demandeA = action.currentA;

View File

@ -435,6 +435,25 @@ double EnergyArbitrator::evFloorW(EvCharger *ev, uint phaseCount) const
return minA * acquisitionTolerance() * 230.0 * phaseCount;
}
uint EnergyArbitrator::evTargetPhases(EvCharger *ev, double referenceA)
{
if (!ev)
return 1;
// 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
// 3 phases sur une combinaison qui n'en permet qu'une triplait le plancher publié, et sous
// surcharge commandait 3 × 6 A là où le proxy commandait 1 × 6 A.
const ChargingProcessInfo pi = internalProcessInfo(ev);
if (!pi.canSwitchPhaseCount)
return qMax(1u, pi.maxPossiblePhaseCount);
const uint phases = bestPhaseCountFor(ev, referenceA);
return qMax(1u, qBound(pi.minPossiblePhaseCount, phases, pi.maxPossiblePhaseCount));
}
SurplusContext EnergyArbitrator::buildContext(const QDateTime &now) const
{
SurplusContext ctx;

View File

@ -215,12 +215,23 @@ public:
/*!
* \brief [3g-1] Nombre de phases visé pour une borne, à un courant donné.
*
* Simple passe-plat vers la règle de bascule 1↔3 du manager. L'arbitre est la porte
* d'entrée de l'adaptateur vers cette règle : la réécrire dans l'adaptateur en ferait
* deux versions, et elles divergeraient au premier ajustement.
* Passe-plat vers la règle de bascule 1↔3 du manager, PLUS le plafond du véhicule.
* L'arbitre est la porte d'entrée de l'adaptateur vers cette règle : la réécrire dans
* l'adaptateur en ferait deux versions, et elles divergeraient au premier ajustement.
*
* \note **[3g-2] Le plafond véhicule fait partie de la règle.** \c getBestPhaseCount()
* ne le connaît pas : le proxy appliquait `qMin(carPhaseCount, …)` juste après l'appel.
* 3g-1 n'a repris que l'appel, si bien qu'une voiture monophasée pouvait se voir viser
* 3 phases — et le plancher publié triplait avec elle, sans qu'aucune allocation ne
* puisse jamais la démarrer. Le plafond est donc rendu ici, au seul endroit qui parle
* de phases pour le waterfall.
*
* \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.
* \return Nombre de phases visé, dans [1, 3], jamais 0.
*/
uint evTargetPhases(EvCharger *ev, double referenceA)
{ return bestPhaseCountFor(ev, referenceA); }
uint evTargetPhases(EvCharger *ev, double referenceA);
/*!
* \brief [3g-1] Plancher de modulation d'une borne, en watts, pour le nombre de phases VISÉ.

View File

@ -122,8 +122,8 @@ signals:
// — visibilité seule (private → protected).
// Sémantique amont inchangée : NON documentés
// ici, la doc appartient à l'amont.
// 4. internalEvChargers(), internalChargingActions(), internalRootMeter()
// — accesseurs ETM neufs, documentés ci-dessous.
// 4. internalEvChargers(), internalChargingActions(), internalRootMeter(),
// internalProcessInfo() — accesseurs ETM neufs, documentés ci-dessous.
protected slots:
virtual void update(const QDateTime &currentDateTime); // [ETM] virtual added
void prepareInformation(const QDateTime &currentDateTime); // [ETM] private → protected
@ -168,6 +168,23 @@ protected:
* configuré — tout appelant doit le tester.
*/
RootMeter *internalRootMeter() const { return m_rootMeter; } // [ETM] new
/*!
* \brief [ETM 3g-2] Informations de processus d'une borne pour le cycle courant.
*
* \param evCharger Borne. \return Copie de son \c ChargingProcessInfo ; valeurs par
* défaut si la borne n'a pas été préparée ce cycle.
*
* \note N'a de sens qu'APRÈS \c prepareInformation() dans le même cycle — position 2 de
* \c update(), donc avant toute construction de contexte.
* \note Existe pour que l'arbitre n'ait pas à REDÉRIVER le nombre de phases possible.
* \c EvCharger::canSetPhaseCount() dit seulement que la borne PORTE l'état
* \c desiredPhaseCount ; la vraie question — la borne ET la voiture acceptent-elles 1 et
* 3 phases — est tranchée ici, une fois, avec le véhicule branché. Les deux prédicats ont
* divergé en 3g-1 : une borne monophasée sans voiture triphasée s'est vue viser 3 phases,
* 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
// [ETM] END
private slots: