Patrick Schurig c4e517a8d9 borne : retenir ce que la borne tient, et geler l'arrêt verrouillé (P2)
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>
2026-10-25 19:31:00 +01:00

209 lines
11 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 &ampere, 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;
};