Le relevé machine du 2026-08-26 l'énonçait sans détour : « tant que ceci n'est
pas traité, la transplantation ne commande rien ». L'arbitre décidait,
publiait un motif juste, et adjustEvChargers() commandait autre chose une
milliseconde plus tard. Ce lot ferme les trois points que le banc a ouverts.
1. UNE CHARGE, UN COMMANDEUR
Les actions EV du waterfall sont enfin DISPATCHÉES — elles ne l'étaient pas du
tout : m_loadAdapters ne contient pas les bornes, et applyActionsToAdapters()
s'arrêtait là. Et les bornes ainsi commandées sortent du chemin proxy par deux
crochets virtuels posés dans l'amont, interrogés à deux moments du cycle où la
réponse n'est pas la même : evSurplusPlannedByWaterfall() avant le plan (à qui
la borne appartient-elle), evCommandedByWaterfall() après (qu'a-t-il commandé).
Sans cette distinction, une borne servie par l'échéance ne serait plus
commandée du tout.
Le jeu des bornes commandées est NOMMÉ par le scheduler (Plan::waterfallEvIds).
Le redéduire ailleurs — « motif différent d'EV_DEADLINE », « financement au
surplus » — dériverait du vrai au premier motif ajouté, et EV_GRID_START porte
justement funding=Grid tout en venant du waterfall. Il est vidé à l'entrée du
mode dégradé : le waterfall ne commandant plus, il ne peut plus revendiquer.
chargingState, que posait adjustEvChargers(), est repris par l'arbitre pour
les bornes qui n'y passent plus — sinon l'app afficherait « en charge » une
borne que l'arbitre vient d'éteindre.
2. evFloorW() REND L'EXIGENCE RÉELLE, ET LA TOLÉRANCE DEVIENT VISIBLE
Le plancher valait `min × acquisitionTolerance × 230 × phases` : 2 070 W
annoncés pour une borne qui en exige 4 140. Le waterfall allouait 2 828 W,
l'adaptateur refusait d'enclencher sous 6 A, et 2 828 W étaient retirés du
budget pour personne. Ce sont deux grandeurs différentes : le plancher est ce
que la borne EXIGE, la tolérance est la part qu'on accepte de ne pas couvrir.
La tolérance n'est pas supprimée pour autant — un réglage documenté (défaut
0,5) qui devient inerte sans le dire est un silence ambigu, et tout le parc
verrait son VE démarrer deux fois plus tard sans explication. Elle devient un
seuil de démarrage EXPLICITE : motif EV_GRID_START {budgetW, floorW, gridW},
funding=Grid, et une comptabilité qui se partage — budgetW dans
budget.allocatedW, gridW dans budget.evReservedW, budgetW + gridW ==
allocatedW.
C'est une brèche assumée dans le financement réseau que DESIGN_3g §3.1 déférait
à 3f. Elle est bornée par construction, pas par discipline : surplus réel exigé
(budgetW > 0), tout le budget restant consommé donc AU PLUS UNE borne par
cycle, et le plafond de la protection de surcharge s'applique avant. Écrite en
toutes lettres dans DESIGN_3g §3.1-bis.
3. LE RANG D'UNE BORNE EST CONFIGURABLE
Il vit là où vivent tous les rangs : LoadConfig.priority (LM-1206/LM-1207). Pas
de second système de priorité — un classement propre aux bornes ne saurait pas
exprimer « VE1 > ECS > VE2 », et deux classements qui ne peuvent pas
s'interclasser sont un défaut, pas deux fonctionnalités. ChargingInfo ne porte
donc aucun champ de rang.
Toute borne détectée reçoit d'office une entrée « evcharger », portant le rang
et RIEN d'autre : ses limites viennent du Thing et changent avec la voiture
branchée. Création journalisée, une ligne par borne, à la création seulement —
une écriture que personne n'a demandée doit laisser une trace. Le tri devient
TOTAL : à rang égal, l'identifiant départage, donc laquelle démarre est
reproductible même avant tout réglage.
L'entrée SURVIT à la disparition de son Thing : borne remplacée, ThingId
changé, appareil déposé. Elle reste publiée available: false / faultCode:
THING_MISSING / motif LOAD_UNAVAILABLE, et ne retient aucun budget. La
supprimer d'office rendrait « borne remplacée » indistinguable de « borne
jamais configurée ».
CORRIGE UNE RUPTURE D'INVARIANT DE 3g-1, non signalée à l'époque : en faisant
entrer les bornes dans loads[], 3g-1 publiait des charges qui ne figuraient
dans AUCUNE GetLoadConfig — la charge fantôme que le lot B-bis avait
supprimée, réapparue par l'autre bout. L'app s'appuie sur cet invariant pour
résoudre libellé, domaine et rang. L'auto-provisionnement le rétablit par
construction.
ÉPROUVÉ
- testEvChargerRankDecidesWhoCharges : deux bornes, un budget qui ne paie
qu'un plancher, la seconde refusée avec BELOW_MIN_POWER {budgetW: 620,
minPowerW: 1380}, et l'inversion du rang change la borne servie. C'est la
moitié ARBITRAGE de LM-1204-b. Couvre aussi l'aller-retour neutre par la
frontière RPC et l'inclusion loads[] ⊆ GetLoadConfig.
- testEvGridStartIsAnnouncedAndBounded : le motif chiffre le soutirage, la
comptabilité se réconcilie, une seule borne soutire par cycle.
- testEvChargerConfigOutlivesItsThing : l'entrée survit, publiée en défaut.
- testEvChargerConfigIsRankOnly / RefusesPayloads / ClaimsItsThing.
Deux points de contrôle de `run` bougent, dans un seul scénario et par une
cause unique : la conversion watts→ampères se fait désormais après
l'allocation, et l'arrondi tombe parfois de l'autre côté. 14 des 16 scénarios
sont identiques au bit près, et ce qui compte est inchangé dans les deux —
la voiture est à 100 % à l'échéance.
Suite complète : simulation 27/27, charging 16/16, loadmodel 16/16,
spotmarket 7/7. Build amd64 0/0.
RIEN N'A TOURNÉ SUR MACHINE. docs/RECAP_3g2.md dit ce qui reste à éprouver au
banc — dont LM-1204-b avec deux bornes réelles, la Terra AC étant vérifiée
absente du réseau (ARP INCOMPLETE depuis le banc lui-même).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
219 lines
12 KiB
C++
219 lines
12 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
|
|
{
|
|
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.phases = static_cast<int>(m_targetPhases);
|
|
|
|
// --- 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));
|
|
}
|
|
|
|
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)
|
|
// 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;
|
|
|
|
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;
|
|
}
|