// 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(m_charger->phaseCount()); d.limits.chargingEnabledLockS = static_cast(m_charger->chargingEnabledLockDuration()); d.limits.currentChangeLockS = static_cast(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(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; } // --- 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( qBound(static_cast(minA), demandeA, static_cast(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(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; }