Décision de Patrick du 2026-10-24. Dans les trois modes qui reportent en
heures creuses (Éco avec minimum, Éco min + échéance, Éco avec échéance) :
- le minimum servi au réseau en heures creuses est le PLAFOND de cycle de
la borne (declared.maxPowerW), plus son plancher. 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. 3 680 W en monophasé 16 A,
11 040 W en triphasé 16 A ;
- les phases se choisissent sur la pleine puissance, plus sur « courant +
surplus » (EnergyArbitrator::enHeuresCreuses(), lu par EvAdapter). La nuit,
une batterie qui couvre la maison et la voiture met le compteur à 0, et la
règle de bascule gardait le monophasé : 3,7 kW au lieu de 11 ;
- la passe éco n'oppose plus à cette borne la marge d'UNE phase de la maison
(LM-1500-h, déjà appliquée en tête de passe et en confort) : son plafond
porte déjà l'autorisation, sur ses phases. Restreint aux heures creuses ;
- motif EV_OFF_PEAK_FULL_POWER {gridW, maxW, phases, until, endsAt,
o:boundBy phaseLimit|floor}, rendu en français, à la place
d'ECO_FLOOR_GRID sur la borne.
Hors heures creuses et sans heure creuse dans le tarif : rien ne change.
Le blocage de la décharge se lève toujours sur la puissance MESURÉE (230 W) :
une borne commandée à 16 A dont la voiture ne prend plus rien le lève.
Tests : testOffPeakChargesAtFullPower, testOffPeakFullPowerSwitchesToThreePhases,
testOffPeakFullPowerYieldsToThePhaseLimit. Les tests du lot e1 en heures
creuses attendent le plafond, et leur compteur inclut ce que la borne tire
désormais (TirageHcW) — sinon il fabriquait un surplus.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
306 lines
17 KiB
C++
306 lines
17 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` ne sert qu'à
|
||
// savoir si l'instant tombe en heures creuses (lot e2).
|
||
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;
|
||
const double plafondPhaseW = 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 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;
|
||
}
|
||
|
||
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. Les adaptateurs de
|
||
// charges en watts la gardent depuis toujours (ils en ont besoin pour l'écrêtage de
|
||
// verrou) ; la borne, pilotée par le proxy amont, n'a jamais eu à se relire. C'est
|
||
// exactement là que le constat du 2026-08-31 porte : sans ce souvenir, l'écart entre ce
|
||
// qu'on a commandé et ce que la borne fait n'est pas CALCULABLE.
|
||
//
|
||
// On retient la commande ÉCRÊTÉE, pas la demande : c'est elle qui est partie au matériel,
|
||
// et c'est donc elle que la borne devrait refléter. Comparer la demande ferait voir un
|
||
// écart là où l'adaptateur a simplement fait son travail.
|
||
m_commandeA = enclencher ? clampedA : 0;
|
||
m_commandePhases = phases;
|
||
m_commandeValide = true;
|
||
|
||
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;
|
||
}
|