Patrick Schurig 4f987e5b1d fix(3g-1): la bascule de phases se décide sur le courant POSSIBLE
Deuxième défaut latent de 3g-1, corrigé à part du lot 3g-2 pour la même
raison que le précédent.

EvAdapter passait `currentPower / 230` comme courant de référence à la règle
de bascule 1↔3 phases, au motif — écrit dans le commentaire — que
l'allocation n'est pas encore connue à cet instant. Mais la bascule ne se
décide pas sur ce que la borne TIRE : elle se décide sur ce qu'elle POURRAIT
tirer. C'est la formule du proxy, `currentPower/230 + allowance`, et c'est la
seule qui puisse répondre « redescends en monophasé, il n'y a plus que
800 W ».

Avec le seul courant courant, une borne passée en triphasé y restait quel que
soit l'effondrement du surplus — et son plancher publié restait à 4,1 kW,
donc plus rien ne pouvait la servir. Le surplus, lui, EST connu avant
l'allocation : c'est la mesure du compteur.

Le terme de stockage de planSurplusCharging() (tampon de 500 W, réinjection
au-dessus du seuil) n'entre volontairement pas dans cette référence : il la
déplace à la marge et n'a d'effet que sur le choix 1↔3, jamais sur
l'allocation — laquelle est faite par le waterfall, avec un budget qui tient
compte du stockage.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
2026-08-27 10:21:08 +02:00

168 lines
7.6 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#include "evadapter.h"
#include "../energyarbitrator.h"
#include "../../evcharger.h"
#include "../../types/chargingaction.h"
#include "plugininfo.h"
EvAdapter::EvAdapter(EvCharger *evCharger, EnergyArbitrator *parent)
: QObject(parent)
, m_charger(evCharger)
, m_parent(parent)
{
}
LoadDescriptor EvAdapter::descriptor() const
{
LoadDescriptor d;
d.id = m_charger->thing()->id().toString();
d.label = m_charger->name();
d.adapter = QStringLiteral("evcharger");
d.priority = 100;
d.declared.minA = m_charger->maxChargingCurrentMinValue();
d.declared.maxA = m_charger->maxChargingCurrentMaxValue();
d.declared.phases = static_cast<int>(m_charger->phaseCount());
d.limits.chargingEnabledLockS = static_cast<int>(m_charger->chargingEnabledLockDuration());
d.limits.currentChangeLockS = static_cast<int>(m_charger->chargingCurrentLockDuration());
d.supportedKinds = { LoadAction::Setpoint };
return d;
}
LoadTelemetry EvAdapter::telemetry() const
{
LoadTelemetry t;
t.currentPowerW = m_charger->currentPower();
t.available = m_charger->available();
t.lastActionAt = m_lastActionAt;
return t;
}
LoadContext EvAdapter::toLoadContext(const QDateTime &now) const
{
Q_UNUSED(now) // Les verrous EV (enclenchement, changement de courant) vivent en aval.
LoadContext ctx;
ctx.id = m_charger->thing()->id().toString();
ctx.adapter = QStringLiteral("evcharger");
ctx.label = m_charger->name();
ctx.declared = descriptor().declared;
ctx.limits = descriptor().limits;
ctx.priority = descriptor().priority;
// --- 3g-1 : la borne entre dans le waterfall, donc elle parle en WATTS ---------------
//
// Le nombre de phases VISÉ est décidé ici, avant l'allocation, et non après : c'est lui
// qui fixe le plancher. Publier celui du nombre COURANT alors que l'exécution basculera
// vers un autre produirait un défaut silencieux — le waterfall allouerait 2 kW en croyant
// démarrer une borne qui en exige 4,1, et se croirait servi.
//
// 3g-2 — la référence est ce que la borne POURRAIT tirer : son courant actuel PLUS le
// surplus disponible. C'est la formule du proxy, et c'est la seule qui sache redescendre
// en monophasé quand le surplus s'effondre (voir evReferenceCurrentA()).
// 3g-2 — 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 courantReferenceA = m_parent->evReferenceCurrentA(m_charger);
m_targetPhases = m_parent->evTargetPhases(m_charger, courantReferenceA);
if (m_targetPhases == 0)
m_targetPhases = 1;
ctx.declared.minPowerW = m_parent->evFloorW(m_charger, m_targetPhases);
ctx.declared.maxPowerW = m_charger->maxChargingCurrentMaxValue() * 230.0 * m_targetPhases;
ctx.declared.phases = static_cast<int>(m_targetPhases);
ctx.telemetry.currentPowerW = m_charger->currentPower();
ctx.telemetry.available = m_charger->available();
ctx.telemetry.pluggedIn = m_charger->pluggedIn();
ctx.telemetry.charging = m_charger->charging();
// Pas de fenêtre de verrou de palier côté EV : lockMaxPowerW négatif = aucun plafond
// (convention du contexte), et lockMinPowerW nul = rien d'engagé de non-coupable.
ctx.telemetry.lockMaxPowerW = -1;
return ctx;
}
LoadRuntimeView EvAdapter::runtimeView(const QDateTime &now) const
{
Q_UNUSED(now) // les verrous EV vivent dans SmartChargingManager amont, pas dans cet adaptateur.
LoadRuntimeView v;
v.mechanismKind = QStringLiteral("evcharger");
v.mechanism.insert(QStringLiteral("chargingEnabled"), m_charger->charging());
v.mechanism.insert(QStringLiteral("pluggedIn"), m_charger->pluggedIn());
v.mechanism.insert(QStringLiteral("currentA"), m_charger->maxChargingCurrent());
v.mechanism.insert(QStringLiteral("phaseCount"), m_charger->phaseCount());
if (!m_charger->available())
v.faultCode = QStringLiteral("THING_MISSING");
return v;
}
LoadAction EvAdapter::applyAction(const LoadAction &action, const QDateTime &now)
{
if (action.kind != LoadAction::Setpoint)
return action;
if (action.reason.isEmpty()) {
qCWarning(dcNymeaEnergy()) << "[EvAdapter]" << m_charger->name()
<< "— LoadAction sans reason rejetée.";
return action;
}
const uint minA = m_charger->maxChargingCurrentMinValue();
const uint maxA = m_charger->maxChargingCurrentMaxValue();
// --- 3g-1 : deux origines d'action, deux unités ---------------------------------------
//
// Le PROXY (échéance, spot market) décide encore en ampères et remplit currentA. Le
// WATERFALL, lui, alloue des WATTS : c'est ici, au dernier moment, que la conversion se
// fait — conformément à la règle 4 (les bornes de l'adaptateur écrêtent toute sortie de
// stratégie, et l'adaptateur seul connaît son matériel).
//
// 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, 3u)
: (m_targetPhases > 0 ? m_targetPhases : m_charger->phaseCount());
double demandeA = action.currentA;
if (demandeA <= 0 && action.powerW > 0)
demandeA = action.powerW / (230.0 * phases);
const uint clampedA = static_cast<uint>(
qBound(static_cast<double>(minA), demandeA, static_cast<double>(maxA)));
// Sous le plancher, la cascade a déjà alloué 0 W et publié son motif : on n'enclenche pas.
// Le test porte sur la DEMANDE, pas sur la valeur écrêtée — qui, elle, est remontée à minA
// par le qBound et ferait croire à un courant suffisant.
const bool enclencher = action.chargingEnabled && demandeA > 0
&& demandeA >= static_cast<double>(minA);
const auto issuer = (action.funding == LoadAction::Surplus)
? ChargingAction::ChargingActionIssuerSurplusCharging
: ChargingAction::ChargingActionIssuerTimeRequirement;
ChargingAction ca(enclencher, clampedA, phases, issuer, false);
m_parent->doExecuteChargingAction(m_charger, ca, now); // now = temps de cycle (injectable)
m_lastActionAt = now;
LoadAction applied = action;
applied.currentA = clampedA;
applied.phaseCount = phases;
applied.chargingEnabled = enclencher;
// Ce qui a RÉELLEMENT été engagé, en watts — c'est ce chiffre que la télémétrie publie et
// que la comptabilité du budget doit voir, pas la demande d'origine.
applied.estimatedPowerW = enclencher ? clampedA * 230.0 * phases : 0.0;
return applied;
}