Près de la limite de phase, la consigne d'une borne à pleine puissance alternait à chaque cycle : le plafond était la place libre au compteur, sans lui rendre sa propre consommation. Maison à 2 A par phase, borne 16 A : 16, 7, 16, 7 A sous 25 A ; 16, 14 sous 32 A ; 14, 6, 8, 6 sous 16 A. En heures creuses, et pour la pleine puissance du lot e2 seulement, le plafond est lu par EnergyArbitrator::evOffPeakAllowanceW() : limite moins le tirage de la phase sans la borne, moins 1 A, à l'ampère inférieur. La règle de l'échéance chez l'amont (adjustEvChargers()). Elle converge en un cycle : 16 A tenus sous 25 et 32 A, 13 A sous 16 A. - Courant rendu MESURÉ (currentPower), sur les phases où la borne le mesure ; rien rendu pendant une bascule ou sans mesure. - Tirage de la maison sans la borne écrêté à zéro : une borne qui annonce plus que ce que le compteur voit reste à 1 A sous la limite. - En dépassement, le plafond vaut au plus le courant mesuré moins 1 A : il baisse à chaque cycle, jusqu'au plancher, où L4 décide. - Hors heures creuses et sans tarif : evPhaseAllowanceW(), inchangée. Tests : testOffPeakFullPowerSettlesUnderThePhaseLimit (sept cas, douze cycles) et testOffPeakFullPowerStaysUnderTheLimitOnABlindMeter, nouveaux ; testOffPeakFullPowerYieldsToThePhaseLimit réécrit avec un compteur qui voit la borne (mesure et non consigne, dépassement, plancher, L4) ; testBlockedDischargeShowsAtTheMeterAndL4Decides : en dépassement, la borne finit au plancher (L4 16 → 13 A, stratégie 9 A, L4 6 A) au lieu d'être coupée. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
180 lines
9.4 KiB
C++
180 lines
9.4 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
|
#pragma once
|
|
|
|
#include <QObject>
|
|
#include <QDateTime>
|
|
#include "iloadadapter.h"
|
|
|
|
class EvCharger;
|
|
class EnergyArbitrator;
|
|
|
|
/*!
|
|
* \brief Adaptateur pour une borne de recharge VE (interface evcharger nymea).
|
|
*
|
|
* Traduit les LoadAction(Setpoint) en appels matériels via
|
|
* EnergyArbitrator::doExecuteChargingAction() — le seul chemin d'exécution.
|
|
*
|
|
* \invariant applyAction() rejette silencieusement toute LoadAction dont \c reason est vide.
|
|
* \invariant currentA est écrêté à [maxChargingCurrentMinValue, maxChargingCurrentMaxValue].
|
|
* \invariant phaseCount est écrêté selon canSetPhaseCount() et phaseCount() du EvCharger.
|
|
* \invariant Les kinds autres que Setpoint sont retournés sans effet.
|
|
*/
|
|
class EvAdapter : public QObject, public ILoadAdapter
|
|
{
|
|
Q_OBJECT
|
|
public:
|
|
/*!
|
|
* \brief Constructeur.
|
|
* \param evCharger Borne VE à piloter (doit rester valide tant que l'adaptateur existe).
|
|
* \param parent EnergyArbitrator propriétaire — utilisé pour l'exécution matérielle.
|
|
*/
|
|
explicit EvAdapter(EvCharger *evCharger, EnergyArbitrator *parent);
|
|
|
|
/*!
|
|
* \brief Retourne la description statique de la charge.
|
|
* \return LoadDescriptor construit depuis les capacités actuelles du EvCharger.
|
|
* \note Recalculé à chaque appel depuis l'état nymea du Thing.
|
|
*/
|
|
LoadDescriptor descriptor() const override;
|
|
|
|
/*!
|
|
* \brief Retourne la télémétrie runtime (puissance mesurée, disponibilité).
|
|
* \return LoadTelemetry avec currentPowerW, available et lastActionAt.
|
|
*/
|
|
LoadTelemetry telemetry() const override;
|
|
|
|
/*!
|
|
* \brief Construit l'entrée loads[] §5 du SurplusContext.
|
|
* \param now Temps de cycle (\c ctx.timestamp). L'EV n'a pas de verrou de palier (hors
|
|
* waterfall ECS/SG-Ready) ; l'instant ne sert qu'à savoir s'il tombe en heures creuses
|
|
* (lot e2) — la borne y vise sa pleine puissance, et ses phases s'y choisissent.
|
|
* \return LoadContext incluant declared, limits, needs et télémétrie EV.
|
|
* \note **Lot e2** — dans les trois modes qui reportent en heures creuses, le minimum
|
|
* d'heures creuses (\c needs.offPeakMinPowerW) est le PLAFOND du cycle
|
|
* (\c declared.maxPowerW : courant maximal, phases, limite de phase), jamais sous le
|
|
* plancher ; et le nombre de phases se choisit sur ce plafond, plus sur le surplus.
|
|
* \note **Lot e2, suite** — dans ces mêmes conditions, la limite de phase est lue par
|
|
* \c EnergyArbitrator::evOffPeakAllowanceW() : la place libre plus le courant MESURÉ de la
|
|
* borne, moins 1 A par phase. Hors heures creuses, \c evPhaseAllowanceW(), inchangée.
|
|
*/
|
|
LoadContext toLoadContext(const QDateTime &now) const override;
|
|
|
|
/*!
|
|
* \brief Applique une consigne Setpoint sur la borne VE.
|
|
*
|
|
* **Inactif jusqu'à 3g** : non appelée par \c EnergyArbitrator::update() en beta.
|
|
* Le dispatch EV passe par \c adjustEvChargers() amont (hérité). Cette méthode
|
|
* sera câblée lors de la transplantation EV dans \c RuleBasedScheduler (phase 3g).
|
|
* \c descriptor() et \c telemetry() sont eux actifs dès maintenant pour le SurplusContext.
|
|
*
|
|
* \param action LoadAction de kind Setpoint. Autres kinds : retour sans effet.
|
|
* \param now Temps de cycle (\c ctx.timestamp) — passé à \c doExecuteChargingAction()
|
|
* (locks anti-rebond de la borne). MÊME source que toLoadContext() (contrat ILoadAdapter).
|
|
* \return L'action après écrêtage matériel (currentA, phaseCount bornés).
|
|
*
|
|
* \invariant action.reason non vide requis — log warning et retour sans effet sinon.
|
|
* \invariant currentA écrêté à [minValue, maxValue] avant envoi à executeChargingAction.
|
|
* \invariant phaseCount ajusté selon canSetPhaseCount() du EvCharger.
|
|
*/
|
|
LoadAction applyAction(const LoadAction &action, const QDateTime &now) override;
|
|
|
|
//! \brief Sans effet : l'EV n'est pas construit depuis \c LoadConfig (rang fixé à 100,
|
|
//! cf. « Dette 3g »), et son nom d'affichage vient du Thing, pas de la config.
|
|
//! \param label Ignoré. \param priority Ignoré. \param needs Ignoré.
|
|
void updateSoftConfig(const QString &label, int priority, const LoadNeeds &needs) override
|
|
{ Q_UNUSED(label) Q_UNUSED(priority) Q_UNUSED(needs) }
|
|
|
|
//! \brief Sans effet : l'EV n'implémente pas encore l'échelle de défaut ECS-410.
|
|
//! La généralisation est portée par **ECS-414** (lot de mise en configuration
|
|
//! du SgReadyAdapter). Déclaré explicitement plutôt qu'hérité d'un défaut vide.
|
|
//! Aucun mécanisme de défaut côté EV : il n'y a jamais rien à lever. Le dire
|
|
//! explicitement plutôt que de laisser un corps vide (règle 7-c).
|
|
//! \return Vrai si un défaut a réellement été levé ; faux s'il n'y en avait pas.
|
|
bool clearFault() override { return false; }
|
|
|
|
/*!
|
|
* \brief Latences MESURÉES sur la V2C : montée ~30 s (le véhicule négocie), descente ~7 s.
|
|
* \param montee Vrai si la consigne augmente.
|
|
* \return Le temps d'établissement correspondant, en secondes.
|
|
* \note L'asymétrie joue dans le bon sens — le refus qui coûte de l'argent est celui de la
|
|
* descente, et c'est le plus vite constaté.
|
|
*/
|
|
int commandSettleS(bool montee) const override { return montee ? 30 : 7; }
|
|
|
|
/*!
|
|
* \brief Vue runtime — déclarée, mais HORS de la charge utile \c GetLoadTelemetry.
|
|
*
|
|
* \param now Ignoré : les verrous EV (chargingEnabledLock, currentChangeLock) vivent dans
|
|
* \c SmartChargingManager amont, pas ici.
|
|
* \return \c mechanismKind "evcharger" et l'état de la borne.
|
|
*
|
|
* \note L'EV n'apparaît PAS dans \c NymeaEnergy.GetLoadTelemetry en beta : il est décidé
|
|
* par l'amont EN AMONT du waterfall (décision B), sa puissance est réservée dans
|
|
* \c PlanBudget::evReservedW et non allouée depuis le budget publié. L'y faire figurer
|
|
* ferait une somme \c loads[] qui ne retomberait pas sur \c budget.allocatedW. La
|
|
* méthode est néanmoins IMPLÉMENTÉE, et non laissée en \c Q_UNIMPLEMENTED : quand 3g
|
|
* transplantera l'EV dans le waterfall unifié, il n'y aura rien à écrire ici.
|
|
*/
|
|
LoadRuntimeView runtimeView(const QDateTime &now) const override;
|
|
|
|
/*!
|
|
* \brief Mesure propre de la borne (§11 / LM-1105).
|
|
* \return La puissance que la borne rapporte elle-même, quand sa ThingClass porte l'état.
|
|
* \note Une borne est **traversée** par la puissance qu'elle délivre : sa mesure est bien
|
|
* celle de la charge, et non celle d'un organe de commande. C'est le cas qui a motivé
|
|
* LM-1105 — il fallait jusqu'ici désigner la borne comme son propre \c meterThingId
|
|
* pour publier une valeur qu'elle donne d'elle-même.
|
|
*/
|
|
LoadMeasurement deviceMeasurement() const override;
|
|
|
|
//! \brief Sans effet : l'EV n'est pas construit depuis \c LoadConfig et ne peut donc pas
|
|
//! être « désactivé » par ce chemin. \param now Ignoré.
|
|
void applySafeState(const QDateTime &now) override { Q_UNUSED(now) }
|
|
|
|
/*!
|
|
* \brief Borne VE sous-jacente (lecture).
|
|
* \return Pointeur non-propriétaire vers l'EvCharger ; jamais nul (fixé à la construction).
|
|
*/
|
|
EvCharger *evCharger() const { return m_charger; }
|
|
|
|
/*!
|
|
* \brief Dernière commande envoyée au matériel par cet adaptateur.
|
|
* \param[out] ampere Courant commandé (A), 0 si la charge a été coupée.
|
|
* \param[out] phases Nombre de phases commandé.
|
|
* \return Faux si AUCUNE commande n'est jamais partie — l'écart est alors sans objet, et
|
|
* c'est le cas normal d'une borne en mode manuel que l'arbitre ne pilote pas.
|
|
*/
|
|
bool derniereCommande(uint &ere, uint &phases) const
|
|
{ ampere = m_commandeA; phases = m_commandePhases; return m_commandeValide; }
|
|
|
|
private:
|
|
/*!
|
|
* \brief Dernière commande RÉELLEMENT envoyée au matériel — écrêtée, pas demandée.
|
|
*
|
|
* \par Pourquoi ce souvenir n'existait pas
|
|
* Les adaptateurs de charges en watts gardent leur consigne parce que l'écrêtage de verrou
|
|
* l'exige. La borne, pilotée par le proxy amont, n'a jamais eu à se relire — et c'est
|
|
* précisément là que le relevé du 2026-08-31 mord : après une coupure Modbus, la borne est
|
|
* restée sur une consigne d'arbitre pendant que le proxy en écrivait une autre, sans que
|
|
* l'écart soit calculable par personne.
|
|
*
|
|
* \note La commande ÉCRÊTÉE, jamais la demande : c'est elle qui est partie au matériel.
|
|
* Comparer la demande ferait voir un écart là où l'adaptateur a fait son travail.
|
|
*/
|
|
uint m_commandeA = 0;
|
|
uint m_commandePhases = 0;
|
|
//! Faux tant qu'aucune commande n'est partie. L'absence de commande n'est pas une commande
|
|
//! à zéro : sans ce booléen, une borne jamais pilotée paraîtrait divergente dès qu'elle
|
|
//! consomme, ce qui est le cas NORMAL du mode manuel.
|
|
bool m_commandeValide = false;
|
|
|
|
EvCharger *m_charger;
|
|
EnergyArbitrator *m_parent;
|
|
QDateTime m_lastActionAt;
|
|
//! Nombre de phases VISÉ, décidé à la construction du contexte et réutilisé à
|
|
//! l'exécution. Les deux doivent parler du MÊME, sinon le plancher publié ne décrit pas
|
|
//! la borne qui sera réellement commandée (§12 / 3g-1).
|
|
mutable uint m_targetPhases = 1;
|
|
};
|