CONTRAT. Sous `unmeasurable`, remainingWh == targetWh : rien n'est soustrait, parce qu'on ne sait rien de ce qui a été livré. 2 000 Wh restants sur une cible de 2 000 Wh signifie « rien de mesurable », pas « rien livré » — deux lectures qui commandent des phrases d'écran opposées, et l'écran ne peut trancher que si le contrat le dit. Soustraire l'intégration interne ferait franchir la frontière à une estimation sous l'allure d'un fait : le chemin de carBatteryLevel, refusé en LM-1009 D3. Et que `regime` voyage dans les mêmes params ne suffirait pas — un client lisant remainingWh sans regarder regime obtiendrait un nombre calculé, sans rien pour le lui dire. Identité épinglée sous `measured` : deliveredWh + remainingWh == targetWh. Troisième contrôle du même genre que Σ counts == targetW. Elle ne tient QUE sous measured ; sous unmeasurable c'est remainingWh == targetWh qui prend le relais, et c'est le régime qui dit laquelle appliquer. Une honnêteté sur la portée de ce test : la contre-épreuve naïve — soustraire deliveredWh sous unmeasurable — NE MORD PAS, puisque deliveredWh y vaut 0. L'assertion est donc un corollaire de `delivered == false`, pas une garde indépendante. La garde réelle est R6, vérifiée mordante au bon niveau : publier l'estimation comme un fait fait sortir ECO_FLOOR_MET sous unmeasurable, ce qui est exactement la violation que R6 interdit. Commande perdue, étape 1 : l'EvAdapter retient enfin sa dernière commande. Les adaptateurs en watts la gardaient déjà — l'écrêtage de verrou l'exige — mais la borne, pilotée par le proxy amont, n'avait jamais eu à se relire. C'est précisément là que le relevé porte : sans ce souvenir, l'écart entre ce qu'on a commandé et ce que la borne fait n'est pas calculable. La commande ÉCRÊTÉE est retenue, jamais la demande : comparer la demande ferait voir un écart là où l'adaptateur a fait son travail. Publication seule, comportement constant. Suite : simulation 58/58, loadmodel 22/22, charging 48/48 identique à la référence ligne à ligne, spotmarket 32/32, doxygen 0 avertissement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
163 lines
8.2 KiB
C++
163 lines
8.2 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). Inutilisé ici : l'EV n'a pas de verrou
|
|
* de palier (hors waterfall ECS/SG-Ready). Présent pour l'uniformité de \c ILoadAdapter.
|
|
* \return LoadContext incluant declared, limits, needs et télémétrie EV.
|
|
*/
|
|
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 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;
|
|
};
|