Patrick Schurig d9372c14ba Heures creuses : la borne attend les HC, la batterie ne se décharge pas dans la voiture (lot e1)
Décisions de Patrick du 2026-10-24. Sans heure creuse dans le tarif saisi
(aucun tarif, tarif unique, prix tous égaux), rien de ceci ne s'exécute.

LA BORNE — modes « Éco avec minimum », « Éco min + échéance », « Éco avec
échéance » (EvAdapter déclare needs.offPeakShift ; le scheduler décide) :
- hors heures creuses : surplus seulement. Le minimum réseau est retiré,
  EV_GRID_START aussi (le minimum par une autre porte). Motif
  EV_WAITING_OFF_PEAK {at "22:00", startsAt, budgetW}, rendu « … attend les
  heures creuses (22:00) ». Le plafond de soutirage et le refus gardent la tête.
- en heures creuses : le plancher de la borne est servi au réseau (passe éco,
  bornée par l'autorisation de soutirage). L'échéance reste au proxy, inchangée.
- Eco et Normal ne sont pas touchés.

LA BATTERIE — StorageDischargeGuard exécute, le rule-based décide (y compris en
ombre d'un optimiseur) :
- heure creuse ET bornes qui TIRENT ≥ 230 W (mesuré) ET batterie pilotable :
  dischargingRate = 0 PUIS enableDischarging = true (StorCtl_Mod bit DiSCHARGE
  = limite active, OutWRte = 0 : la séquence NO_DISCHARGE du plugin) ;
  rafraîchi au tiers du minuteur de retour (revertTime) ;
- sinon : levée EXPLICITE, enableDischarging = false puis dischargingRate au
  maximum. L2 lève aussi. Une limite à 0 % trouvée active au démarrage est
  reprise (rafraîchie ou levée).
- jamais gridCharging, enableCharging, chargingRate, ni un taux négatif.
- générique : interface energystorage et états présents. Sans les états
  (noDischargeControl), sans minuteur (noRevertTimer) ou minuteur < 120 s
  (revertTimerTooShort) : non commandée, STORAGE_NOT_PROTECTED.
- publié : bloc `storage` de la télémétrie (dischargeBlocked, decision,
  batteries[] avec ce qui est LU), absent sans heure creuse ou sans batterie ;
  ligne « [Arbitre] Batterie → … » au journal.

Le blocage ne verse rien au budget : surplus = compteur + PV (invariant 8),
le soutirage ajouté se mesure au cycle suivant, L4 garde le dernier mot.

Mock : classe controllableStorage (états modifiables du sunspecStorage,
paramètre revertTime). Tests : journée de la démo, 06:00 sur jours de 24/23/25 h,
etm74 sans heure creuse, batteries non pilotables ou absente, soutirage et L4,
repli L2 — les écritures lues à la frontière nymea (actionExecuted), l'onduleur
modélisé hors du moteur. Le commentaire du test §11 est réécrit comme il le
demandait.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 08:34:21 +02:00

279 lines
15 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);
// --- « 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.
const ChargingInfo::ChargingMode mode = m_parent->chargingInfo(m_charger->id()).chargingMode();
if (mode == ChargingInfo::ChargingModeEcoWithMinCurrent
|| mode == ChargingInfo::ChargingModeEcoMinWithTargetTime) {
ctx.needs.minPowerWAlways = ctx.declared.minPowerW;
}
// --- Lot e1 : 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. Le minimum d'heures creuses est le même plancher, dérivé.
if (mode == ChargingInfo::ChargingModeEcoWithMinCurrent
|| mode == ChargingInfo::ChargingModeEcoMinWithTargetTime
|| mode == ChargingInfo::ChargingModeEcoWithTargetTime) {
ctx.needs.offPeakShift = true;
ctx.needs.offPeakMinPowerW = 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));
}
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;
}