La borne retenait la consigne VOULUE par son adaptateur, et seulement quand le waterfall la commandait. Deux faux refus en sortaient : - une coupure refusée par le verrou de marche/arrêt (300 s) laissait la borne à 6 A pendant qu'on attendait 0 W ; - une borne rabaissée par la protection L4, ou commandée par le proxy (échéance, spot), restait jugée contre la dernière consigne du waterfall. La mémoire de commande est posée par EnergyArbitrator:: executeChargingAction(), point unique de tous les émetteurs, avec ce qui a PASSÉ les verrous et l'instant de la commande. Pendant que l'arrêt est verrouillé, la borne déclare lockMinPowerW = courant minimal × 230 × phases, comme un relais sous minOn : le waterfall gèle ces watts (LOCK_MIN_ON) au lieu de les prêter. Le verrou borne la SOMME éco + confort : la part confort n'est relevée que de ce que la passe éco ne couvre pas déjà. Sans cela, une borne servie à 6 A en éco sous arrêt verrouillé recevait 12 A (trouvé par la preuve decisions, aucun test ne le voyait). Tests : testLockedStopFreezesTheMinimumAndIsNoRefusal, testALockedStopDoesNotAddToTheEcoFloor, et une garde L4 dans testOverloadProtectionReasonOnTheCharger. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
315 lines
18 KiB
C++
315 lines
18 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");
|
||
// 3g-2 — le rang vient de la CONFIGURATION, comme celui de toute autre charge (LM-1207).
|
||
// Il valait 100 en dur, reliquat de l'ancienne convention « poids, valeur haute = premier » :
|
||
// dans un tri ASCENDANT cela plaçait toutes les bornes en dernier, à égalité, et laquelle
|
||
// démarrait n'était ni réglable ni reproductible (relevé du 2026-08-26, §3.2).
|
||
// Relu à chaque cycle : réordonner depuis l'app prend effet sans reconstruire l'adaptateur,
|
||
// donc sans réarmer le moindre verrou.
|
||
d.priority = m_parent->evPriority(d.id);
|
||
|
||
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
|
||
{
|
||
// Les verrous EV (enclenchement, changement de courant) vivent en aval : `now` sert à savoir
|
||
// si l'instant tombe en heures creuses (lot e2), et si l'arrêt est verrouillé (P2, plus bas).
|
||
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 ChargingInfo::ChargingMode mode = m_parent->chargingInfo(m_charger->id()).chargingMode();
|
||
const bool reportable = mode == ChargingInfo::ChargingModeEcoWithMinCurrent
|
||
|| mode == ChargingInfo::ChargingModeEcoMinWithTargetTime
|
||
|| mode == ChargingInfo::ChargingModeEcoWithTargetTime;
|
||
|
||
// Lot e2 — EN HEURES CREUSES, la référence est la PLEINE PUISSANCE de la borne, et non plus
|
||
// « courant + surplus ». La règle de bascule reste celle du manager ; c'est ce qu'on lui
|
||
// demande qui change.
|
||
// « Courant + surplus » répond à « combien le soleil donne-t-il ? », et la nuit la réponse
|
||
// décide mal : la maison importe, la référence est négative et la règle vise le triphasé —
|
||
// sauf quand une batterie couvre la maison et la voiture. Le compteur lit alors ≈ 0, la
|
||
// référence vaut le courant que la borne tire déjà (6 A en monophasé), et la règle la GARDE
|
||
// en monophasé : 3,7 kW au plus pour une borne qui en ferait 11. En heures creuses, la
|
||
// question n'est plus le surplus mais ce que la borne peut prendre.
|
||
//
|
||
// La limite de phase n'entre pas dans le choix, et c'est sans perte : par phase, elle borne
|
||
// de la même façon une monophasée et une triphasée, donc trois phases donnent toujours au
|
||
// moins autant (marge × 3 contre marge × 1). Sous le plancher, aucune des deux ne tient, et
|
||
// c'est la protection de surcharge qui tranche.
|
||
const bool pleinePuissanceHc = reportable && m_parent->enHeuresCreuses(now);
|
||
const double courantReferenceA = pleinePuissanceHc
|
||
? m_charger->maxChargingCurrentMaxValue() * 3.0
|
||
: 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.phases = static_cast<int>(m_targetPhases);
|
||
|
||
// --- « Surplus + minimum réseau » : le mode DEMANDE un minimum, la valeur se DÉRIVE ------
|
||
//
|
||
// `ChargingModeEcoWithMinCurrent` promettait « éco + courant minimum » depuis toujours, et
|
||
// ne l'a jamais servi : le mode était réécrit en Eco à chaque cycle chez l'amont, puis —
|
||
// une fois ce défaut corrigé — la branche qui l'appliquait est devenue inatteignable, le
|
||
// waterfall commandant désormais la borne (evCommandedByWaterfall). Mort par réécriture,
|
||
// puis mort par exclusion, sans que rien ne le signale. Voir docs/DESIGN_MIN_RESEAU.md.
|
||
//
|
||
// La valeur n'est PAS stockée : elle se dérive du matériel à chaque cycle, comme le
|
||
// plancher juste au-dessus. L'écrire en configuration à l'appairage créerait une copie qui
|
||
// se périme au premier recâblage — `phaseCount` change, la copie reste fausse en silence.
|
||
if (mode == ChargingInfo::ChargingModeEcoWithMinCurrent
|
||
|| mode == ChargingInfo::ChargingModeEcoMinWithTargetTime) {
|
||
ctx.needs.minPowerWAlways = ctx.declared.minPowerW;
|
||
}
|
||
|
||
// --- 3g-2 : le plafond du cycle, protection de surcharge COMPRISE -------------------
|
||
//
|
||
// L'écrêtage par la limite de phase vivait dans planSurplusCharging(), que la borne ne
|
||
// traverse plus. Sans lui, le waterfall allouerait une puissance que le branchement ne
|
||
// supporte pas, et la seule chose qui rattraperait serait verifyOverloadProtection()
|
||
// APRÈS coup — c'est-à-dire après avoir commandé. Le plafond doit donc être DÉCLARÉ, comme
|
||
// n'importe quelle borne d'adaptateur (règle 4).
|
||
ctx.declared.maxPowerW = m_charger->maxChargingCurrentMaxValue() * 230.0 * m_targetPhases;
|
||
// Lot e2, suite (2026-10-25) — EN HEURES CREUSES, le plafond rend à la borne ce qu'elle tire,
|
||
// moins 1 A par phase. Sans ce retour, à pleine puissance, sa propre consommation lui
|
||
// retirait la place au cycle suivant et la lui rendait au cycle d'après : 16, 7, 16, 7 A sous
|
||
// une limite de 25 A, avec une maison à 2 A. Hors heures creuses, la règle d'avant.
|
||
const double plafondPhaseW = pleinePuissanceHc
|
||
? m_parent->evOffPeakAllowanceW(m_charger, m_targetPhases)
|
||
: m_parent->evPhaseAllowanceW(m_charger, m_targetPhases);
|
||
if (plafondPhaseW >= 0) {
|
||
// …mais JAMAIS sous le plancher de la borne. C'est la règle de l'amont, reprise mot
|
||
// pour mot (`if (newValueInt <= minValue) newValueInt = minValue`), et la répartition
|
||
// des rôles qu'elle porte : la limite de phase RABAISSE la borne à son minimum, elle
|
||
// ne la coupe pas. Couper est le geste de la couche L4 — verifyOverloadProtection(),
|
||
// qui tourne en temps réel sur powerBalanceChanged et garde le dernier mot après ce
|
||
// dispatch. Écrêter jusqu'à 0 ici ferait décider la coupure par la stratégie, à
|
||
// contre-emploi du modèle de sécurité (AGENTS §MODÈLE DE SÉCURITÉ).
|
||
ctx.declared.maxPowerW = qMin(ctx.declared.maxPowerW,
|
||
qMax(plafondPhaseW, ctx.declared.minPowerW));
|
||
}
|
||
|
||
// --- Lots e1 et e2 : le besoin RÉSEAU se reporte en heures creuses -----------------
|
||
//
|
||
// DÉCLARÉ ici, DÉCIDÉ par le scheduler (règle 2) — et seulement si le tarif saisi a des
|
||
// heures creuses ; sans elles, ces deux champs n'ont aucun effet. Trois modes : les deux
|
||
// « avec minimum » et « avec échéance ». `Eco` seul ne demande jamais le réseau, `Normal`
|
||
// est piloté par l'utilisateur.
|
||
//
|
||
// Lot e2 (décision du 2026-10-24) — le minimum d'heures creuses est le PLAFOND du cycle, et
|
||
// non plus le plancher. Au plancher (6 A, 1,4 kW en monophasé), une session de 10 à 25 kWh
|
||
// n'était pas finie au départ, et la décharge de la batterie restait bloquée toute la nuit.
|
||
// À pleine puissance, la session finit tôt et la batterie est rendue pour le reste de la nuit.
|
||
// Le plafond est pris APRÈS l'écrêtage ci-dessus : il porte le courant maximal, les phases et
|
||
// la limite de phase, et ne descend jamais sous le plancher — la stratégie rabaisse, L4 coupe.
|
||
if (reportable) {
|
||
ctx.needs.offPeakShift = true;
|
||
ctx.needs.offPeakMinPowerW = ctx.declared.maxPowerW;
|
||
}
|
||
|
||
ctx.telemetry.currentPowerW = m_charger->currentPower();
|
||
ctx.telemetry.available = m_charger->available();
|
||
ctx.telemetry.pluggedIn = m_charger->pluggedIn();
|
||
ctx.telemetry.charging = m_charger->charging();
|
||
// §10-C — l'avancement MESURÉ, enfin alimenté. Le champ était déclaré depuis LM-1009 et
|
||
// rempli nulle part : une échéance dont l'avancement ne se mesure pas est pire qu'une
|
||
// échéance absente, parce qu'elle continue de s'afficher comme si elle tenait.
|
||
ctx.telemetry.sessionMeasurable = m_charger->hasSessionEnergy();
|
||
ctx.telemetry.sessionWh = m_charger->sessionEnergyWh();
|
||
|
||
// Pas de plafond de verrou côté EV : lockMaxPowerW négatif = aucun plafond (convention du
|
||
// contexte).
|
||
ctx.telemetry.lockMaxPowerW = -1;
|
||
// P2 — l'ARRÊT VERROUILLÉ engage le courant minimal, comme un relais sous minOn. Une borne en
|
||
// marche dont le verrou de marche/arrêt (300 s par défaut) n'est pas expiré ne s'arrêtera pas,
|
||
// quoi qu'on lui commande : elle tirera son minimum. Le déclarer fait GELER ces watts par le
|
||
// waterfall ; ne pas le déclarer les laissait prêter aux suivantes, et la coupure que le plan
|
||
// croyait faire se lisait ensuite comme un refus de la borne — c'est le moteur qui la tenait.
|
||
if (m_charger->chargingEnabled() && m_charger->chargingEnabledLocked(now)) {
|
||
const uint phases = m_commandeValide && m_commandePhases > 0 ? m_commandePhases
|
||
: m_charger->phaseCount();
|
||
ctx.telemetry.lockMinPowerW = m_charger->maxChargingCurrentMinValue() * 230.0
|
||
* qMax(1u, phases);
|
||
}
|
||
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;
|
||
}
|
||
|
||
LoadMeasurement EvAdapter::deviceMeasurement() const
|
||
{
|
||
LoadMeasurement m;
|
||
// Question posée à la CLASSE, pas à la valeur : une borne à l'arrêt mesure 0 W et reste
|
||
// mesurable. Confondre les deux ferait basculer la source publiée à chaque démarrage.
|
||
if (!m_charger->thing()->thingClass().stateTypes().findByName("currentPower").id().isNull()) {
|
||
m.available = true;
|
||
m.powerW = m_charger->currentPower();
|
||
}
|
||
return m;
|
||
}
|
||
|
||
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)
|
||
// 3g-2 — arrondi au PLUS PROCHE, comme le faisait le proxy. Tronquer paraissait plus
|
||
// prudent (« ne jamais dépasser l'enveloppe ») et coûtait systématiquement un ampère :
|
||
// 230 W par phase abandonnés à chaque cycle, sur toute la journée. Les 18 scénarios de
|
||
// simulation le chiffrent — voiture 1 % moins chargée le soir, huit fois sur huit.
|
||
//
|
||
// L'enveloppe est une ALLOCATION, pas une limite : elle sort d'une mesure de compteur
|
||
// dont le bruit dépasse largement 115 W. Ce qui est une limite, c'est le plafond
|
||
// matériel — et celui-là n'est jamais franchi : il est déclaré à l'enveloppe par
|
||
// toLoadContext(), et le qBound ci-dessous écrête au courant maximal de la borne.
|
||
demandeA = qRound(action.powerW / (230.0 * phases));
|
||
|
||
// La limite de phase n'est PAS réappliquée ici, et c'est délibéré : elle est déjà dans
|
||
// l'enveloppe, déclarée par toLoadContext() comme plafond de la charge, donc respectée par
|
||
// le waterfall AVANT d'allouer. La réappliquer en ampères entiers ferait un second
|
||
// écrêtage, plus strict que celui de l'amont (plancher au lieu d'arrondi), et coûterait à
|
||
// nouveau un ampère dans les scénarios de simulation — pour 69 W de marge théorique que la
|
||
// couche L4, elle, mesure réellement en temps réel.
|
||
//
|
||
// La répartition reste celle du modèle de sécurité : la stratégie respecte le plafond
|
||
// déclaré, l'adaptateur écrête au matériel, et verifyOverloadProtection() garde le dernier
|
||
// mot sur la mesure.
|
||
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);
|
||
|
||
// 3g-2 — l'émetteur suit le MOTIF, pas le financement. Depuis qu'un démarrage sous
|
||
// tolérance (EV_GRID_START) porte funding=Grid tout en venant du waterfall, déduire
|
||
// l'émetteur du financement étiquetait « échéance » une décision de surplus — et c'est
|
||
// cette étiquette que le journal amont affiche à chaque commande.
|
||
ChargingAction::ChargingActionIssuer issuer = ChargingAction::ChargingActionIssuerSurplusCharging;
|
||
if (action.reason.code == QLatin1String(DecisionCode::EvDeadline))
|
||
issuer = ChargingAction::ChargingActionIssuerTimeRequirement;
|
||
else if (action.reason.code == QLatin1String(DecisionCode::EvSpotMarket))
|
||
issuer = ChargingAction::ChargingActionIssuerSpotMarketCharging;
|
||
|
||
ChargingAction ca(enclencher, clampedA, phases, issuer, false);
|
||
m_parent->doExecuteChargingAction(m_charger, ca, now); // now = temps de cycle (injectable)
|
||
m_lastActionAt = now;
|
||
|
||
// §Commande perdue, étape 1 — MÉMOIRE de ce qu'on vient de commander. Elle n'est plus posée
|
||
// ici : EnergyArbitrator::executeChargingAction() la pose pour TOUT émetteur (ce chemin, le
|
||
// proxy, la protection L4), avec ce qui a passé les verrous de la borne (P2). Posée ici seul,
|
||
// elle restait périmée dès qu'un autre émetteur écrivait, et l'écart se lisait comme un refus.
|
||
|
||
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;
|
||
}
|