La borne retenait la consigne VOULUE par son adaptateur, et seulement quand le waterfall la commandait. Deux faux refus en sortaient : - une coupure refusée par le verrou de marche/arrêt (300 s) laissait la borne à 6 A pendant qu'on attendait 0 W ; - une borne rabaissée par la protection L4, ou commandée par le proxy (échéance, spot), restait jugée contre la dernière consigne du waterfall. La mémoire de commande est posée par EnergyArbitrator:: executeChargingAction(), point unique de tous les émetteurs, avec ce qui a PASSÉ les verrous et l'instant de la commande. Pendant que l'arrêt est verrouillé, la borne déclare lockMinPowerW = courant minimal × 230 × phases, comme un relais sous minOn : le waterfall gèle ces watts (LOCK_MIN_ON) au lieu de les prêter. Le verrou borne la SOMME éco + confort : la part confort n'est relevée que de ce que la passe éco ne couvre pas déjà. Sans cela, une borne servie à 6 A en éco sous arrêt verrouillé recevait 12 A (trouvé par la preuve decisions, aucun test ne le voyait). Tests : testLockedStopFreezesTheMinimumAndIsNoRefusal, testALockedStopDoesNotAddToTheEcoFloor, et une garde L4 dans testOverloadProtectionReasonOnTheCharger. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
209 lines
11 KiB
C++
209 lines
11 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). Il sert à savoir s'il tombe en heures
|
||
* creuses (lot e2) — la borne y vise sa pleine puissance, et ses phases s'y choisissent —
|
||
* et si l'ARRÊT est verrouillé (voir la note P2).
|
||
* \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.
|
||
* \note **P2** — borne en marche dont le verrou de marche/arrêt n'a pas expiré :
|
||
* \c telemetry.lockMinPowerW = courant minimal × 230 × phases. Elle ne s'arrêtera pas ce
|
||
* cycle, et le waterfall gèle ce qu'elle tirera, comme pour un relais sous \c minOn.
|
||
*/
|
||
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 tenue par la borne, quel qu'en soit l'émetteur, telle qu'elle a
|
||
* PASSÉ ses verrous (\c noterCommandeTenue()).
|
||
* \param[out] ampere Courant tenu (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; }
|
||
|
||
//! \brief Instant de la dernière commande, à l'horloge de l'émetteur (temps de cycle pour le
|
||
//! waterfall). \return Invalide tant qu'aucune commande n'est partie.
|
||
QDateTime derniereCommandeAt() const { return m_commandeAt; }
|
||
|
||
/*!
|
||
* \brief Note la commande que la borne TIENT, quel qu'en soit l'émetteur (P2).
|
||
* \param active Marche après les verrous. \param courantA Courant après les verrous (A).
|
||
* \param phases Phases commandées. \param at Instant de la commande.
|
||
* \note Appelée par \c EnergyArbitrator::executeChargingAction(), le point unique par où
|
||
* passent le waterfall, le proxy et la protection L4 : posée par un seul d'entre eux, la
|
||
* mémoire se périmait dès qu'un autre écrivait.
|
||
*/
|
||
void noterCommandeTenue(bool active, uint courantA, uint phases, const QDateTime &at)
|
||
{
|
||
m_commandeA = active ? courantA : 0;
|
||
m_commandePhases = phases;
|
||
m_commandeValide = true;
|
||
m_commandeAt = at;
|
||
}
|
||
|
||
private:
|
||
/*!
|
||
* \brief Dernière commande RÉELLEMENT tenue par le matériel — écrêtée et passée par les
|
||
* verrous de la borne, 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.
|
||
* \note **P2** — et ce qui a PASSÉ les verrous de marche/arrêt et de courant : un arrêt
|
||
* refusé par le verrou de 300 s laisse la borne en marche à son courant minimal, et c'est
|
||
* ce courant qui est retenu, pas 0.
|
||
*/
|
||
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;
|
||
QDateTime m_commandeAt; //!< Instant de la dernière commande.
|
||
|
||
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;
|
||
};
|