La transplantation. Les bornes partagent le budget, décrémentent la cascade et portent un rang qui a enfin un sens — il était décoratif depuis le début. Le défaut corrigé n'était pas théorique : planSurplusCharging() calculait l'allocation une fois avant la boucle et ne la décrémentait jamais. Chaque borne recevait la totalité du surplus. Avec 4 kW et deux bornes, ce n'était pas « 2 kW à chacune » mais « 4 kW à chacune », et le rattrapage venait du compteur au cycle suivant, pas de l'arbitrage. Trois motifs EV disparaissent — des suppressions, pas des remplacements. EV_ECO_MIN en particulier : le courant minimum était la même idée que le plancher du §12, écrite une seconde fois pour un seul mécanisme. Le nombre de phases est décidé AVANT l'allocation et réutilisé à l'exécution, parce que c'est lui qui fixe le plancher — 6 A valent 1,4 kW en mono et 4,1 kW en tri. C'était le piège le plus silencieux du lot. evReservedW est restreint au financement réseau plutôt que supprimé : ce qui reste décidé en amont doit rester réservé, ce qui passe par la cascade ne le doit plus. Et une correction attrapée à la première observation en vrai de BATTERY_RESERVE : il était publié avec withheldW à 0 sur un surplus négatif, donc il disait « une règle vous bloque » là où il n'y avait simplement rien. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
160 lines
6.8 KiB
C++
160 lines
6.8 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.
|
|
//
|
|
// 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.
|
|
const double courantActuelA = m_charger->currentPower() / 230.0;
|
|
m_targetPhases = m_charger->canSetPhaseCount()
|
|
? m_parent->evTargetPhases(m_charger, courantActuelA)
|
|
: m_charger->phaseCount();
|
|
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.
|
|
const uint phases = (m_charger->canSetPhaseCount() && action.phaseCount > 0)
|
|
? qBound(1u, action.phaseCount, m_charger->phaseCount())
|
|
: (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;
|
|
}
|