Patrick Schurig 2c4d04a53f feat: le recrédit est conditionné à l'obéissance constatée
Le recrédit rendait au budget la conso d'une charge sur l'hypothèse qu'on
pourra la lui reprendre. Contre un refus établi, les watts prêtés étaient
réalloués aux suivantes pendant que la charge continuait de les tirer :
4 000 W alloués pour 500 W de surplus réel. Suspension faite, la charge
suivante ne reçoit plus que les 500 W qui existent.

Le seuil vient du MÉCANISME, jamais d'une constante. Un écart récent est une
latence — une charge qui vient d'être commandée n'a pas atteint sa consigne,
et suspendre là ferait osciller ce qui va bien. Latences mesurées sur la
V2C : montée ~30 s, descente ~7 s. L'asymétrie joue dans le bon sens, le
refus qui coûte de l'argent étant celui de la descente. Défaut prudent à
60 s : l'oscillation d'une charge saine est un coût certain, le refus n'est
qu'un risque.

Et ça se dit — RECREDIT_SUSPENDED, avec depuis quand et combien de watts
retenus. Une suspension change l'allocation de toutes les charges suivantes ;
sans le motif, on chercherait un défaut de cascade là où une charge n'obéit
pas.

Et une cinquième classe dans AGENTS.md, la seule qu'aucun contrôle ne pouvait
attraper : une PRÉMISSE FAUSSE SOUS UNE ARITHMÉTIQUE EXACTE. Les quatre
autres se laissent prendre par un instrument — un champ qui ment, un retour
indiscernable de son contraire, un instrument muet, une assertion vacante.
Celle-ci n'a aucune trace : l'identité publiée tient exactement, que la
charge obéisse ou non.

La parade tient en une phrase : une identité qui tient ne prouve pas que le
modèle est juste, seulement qu'il est cohérent. Ce qu'une identité ne peut
jamais attraper, c'est une hypothèse sur le monde. D'où la question à poser à
chaque identité publiée : sur quoi repose-t-elle qui ne soit pas dans ses
propres termes ? Ici, sur l'obéissance — grandeur absente de la formule,
qu'il a fallu aller mesurer ailleurs pour la rendre vérifiable.

Suite : simulation 60/60, loadmodel 22/22, charging 48/48 identique à la
référence, spotmarket 32/32, doxygen 0 avertissement. Contre-épreuve :
suspension débranchée, le recrédit revient à 4 400 W.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-09-01 16:55:47 +02:00

172 lines
8.7 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 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 &ampere, 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;
};