Patrick Schurig baf267a963 feat(3g-2): une charge, un commandeur
Le relevé machine du 2026-08-26 l'énonçait sans détour : « tant que ceci n'est
pas traité, la transplantation ne commande rien ». L'arbitre décidait,
publiait un motif juste, et adjustEvChargers() commandait autre chose une
milliseconde plus tard. Ce lot ferme les trois points que le banc a ouverts.

1. UNE CHARGE, UN COMMANDEUR

Les actions EV du waterfall sont enfin DISPATCHÉES — elles ne l'étaient pas du
tout : m_loadAdapters ne contient pas les bornes, et applyActionsToAdapters()
s'arrêtait là. Et les bornes ainsi commandées sortent du chemin proxy par deux
crochets virtuels posés dans l'amont, interrogés à deux moments du cycle où la
réponse n'est pas la même : evSurplusPlannedByWaterfall() avant le plan (à qui
la borne appartient-elle), evCommandedByWaterfall() après (qu'a-t-il commandé).
Sans cette distinction, une borne servie par l'échéance ne serait plus
commandée du tout.

Le jeu des bornes commandées est NOMMÉ par le scheduler (Plan::waterfallEvIds).
Le redéduire ailleurs — « motif différent d'EV_DEADLINE », « financement au
surplus » — dériverait du vrai au premier motif ajouté, et EV_GRID_START porte
justement funding=Grid tout en venant du waterfall. Il est vidé à l'entrée du
mode dégradé : le waterfall ne commandant plus, il ne peut plus revendiquer.

chargingState, que posait adjustEvChargers(), est repris par l'arbitre pour
les bornes qui n'y passent plus — sinon l'app afficherait « en charge » une
borne que l'arbitre vient d'éteindre.

2. evFloorW() REND L'EXIGENCE RÉELLE, ET LA TOLÉRANCE DEVIENT VISIBLE

Le plancher valait `min × acquisitionTolerance × 230 × phases` : 2 070 W
annoncés pour une borne qui en exige 4 140. Le waterfall allouait 2 828 W,
l'adaptateur refusait d'enclencher sous 6 A, et 2 828 W étaient retirés du
budget pour personne. Ce sont deux grandeurs différentes : le plancher est ce
que la borne EXIGE, la tolérance est la part qu'on accepte de ne pas couvrir.

La tolérance n'est pas supprimée pour autant — un réglage documenté (défaut
0,5) qui devient inerte sans le dire est un silence ambigu, et tout le parc
verrait son VE démarrer deux fois plus tard sans explication. Elle devient un
seuil de démarrage EXPLICITE : motif EV_GRID_START {budgetW, floorW, gridW},
funding=Grid, et une comptabilité qui se partage — budgetW dans
budget.allocatedW, gridW dans budget.evReservedW, budgetW + gridW ==
allocatedW.

C'est une brèche assumée dans le financement réseau que DESIGN_3g §3.1 déférait
à 3f. Elle est bornée par construction, pas par discipline : surplus réel exigé
(budgetW > 0), tout le budget restant consommé donc AU PLUS UNE borne par
cycle, et le plafond de la protection de surcharge s'applique avant. Écrite en
toutes lettres dans DESIGN_3g §3.1-bis.

3. LE RANG D'UNE BORNE EST CONFIGURABLE

Il vit là où vivent tous les rangs : LoadConfig.priority (LM-1206/LM-1207). Pas
de second système de priorité — un classement propre aux bornes ne saurait pas
exprimer « VE1 > ECS > VE2 », et deux classements qui ne peuvent pas
s'interclasser sont un défaut, pas deux fonctionnalités. ChargingInfo ne porte
donc aucun champ de rang.

Toute borne détectée reçoit d'office une entrée « evcharger », portant le rang
et RIEN d'autre : ses limites viennent du Thing et changent avec la voiture
branchée. Création journalisée, une ligne par borne, à la création seulement —
une écriture que personne n'a demandée doit laisser une trace. Le tri devient
TOTAL : à rang égal, l'identifiant départage, donc laquelle démarre est
reproductible même avant tout réglage.

L'entrée SURVIT à la disparition de son Thing : borne remplacée, ThingId
changé, appareil déposé. Elle reste publiée available: false / faultCode:
THING_MISSING / motif LOAD_UNAVAILABLE, et ne retient aucun budget. La
supprimer d'office rendrait « borne remplacée » indistinguable de « borne
jamais configurée ».

CORRIGE UNE RUPTURE D'INVARIANT DE 3g-1, non signalée à l'époque : en faisant
entrer les bornes dans loads[], 3g-1 publiait des charges qui ne figuraient
dans AUCUNE GetLoadConfig — la charge fantôme que le lot B-bis avait
supprimée, réapparue par l'autre bout. L'app s'appuie sur cet invariant pour
résoudre libellé, domaine et rang. L'auto-provisionnement le rétablit par
construction.

ÉPROUVÉ

  - testEvChargerRankDecidesWhoCharges : deux bornes, un budget qui ne paie
    qu'un plancher, la seconde refusée avec BELOW_MIN_POWER {budgetW: 620,
    minPowerW: 1380}, et l'inversion du rang change la borne servie. C'est la
    moitié ARBITRAGE de LM-1204-b. Couvre aussi l'aller-retour neutre par la
    frontière RPC et l'inclusion loads[] ⊆ GetLoadConfig.
  - testEvGridStartIsAnnouncedAndBounded : le motif chiffre le soutirage, la
    comptabilité se réconcilie, une seule borne soutire par cycle.
  - testEvChargerConfigOutlivesItsThing : l'entrée survit, publiée en défaut.
  - testEvChargerConfigIsRankOnly / RefusesPayloads / ClaimsItsThing.

Deux points de contrôle de `run` bougent, dans un seul scénario et par une
cause unique : la conversion watts→ampères se fait désormais après
l'allocation, et l'arrondi tombe parfois de l'autre côté. 14 des 16 scénarios
sont identiques au bit près, et ce qui compte est inchangé dans les deux —
la voiture est à 100 % à l'échéance.

Suite complète : simulation 27/27, charging 16/16, loadmodel 16/16,
spotmarket 7/7. Build amd64 0/0.

RIEN N'A TOURNÉ SUR MACHINE. docs/RECAP_3g2.md dit ce qui reste à éprouver au
banc — dont LM-1204-b avec deux bornes réelles, la Terra AC étant vérifiée
absente du réseau (ARP INCOMPLETE depuis le banc lui-même).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017TVywtayEeoRJ2yWUW4iZj
2026-08-27 10:43:25 +02:00

315 lines
16 KiB
C++
Raw 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) 2013 - 2024, nymea GmbH
* Copyright (C) 2024 - 2025, chargebyte austria GmbH
*
* This file is part of nymea-energy-plugin-nymea.
*
* nymea-energy-plugin-nymea.s free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* nymea-energy-plugin-nymea.s distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with nymea-energy-plugin-nymea. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#ifndef SMARTCHARGINGMANAGER_H
#define SMARTCHARGINGMANAGER_H
#include <QObject>
#include <QTimer>
#include "energymanagerconfiguration.h"
#include "types/charginginfo.h"
#include "types/chargingaction.h"
#include "types/chargingschedule.h"
#include "types/chargingprocessinfo.h"
#include "spotmarket/spotmarketmanager.h"
// from libnymea
#include <integrations/thingmanager.h>
#include <hardware/electricity.h>
// from libnymea-energy
#include <energymanager.h>
class EvCharger;
class RootMeter;
// Minimum charging current (A) applied when EcoWithMinCurrent is active and no surplus is available
static constexpr uint EcoMinChargingCurrent = 6;
class SmartChargingManager : public QObject
{
Q_OBJECT
public:
explicit SmartChargingManager(EnergyManager *energyManager, ThingManager *thingManager, SpotMarketManager *spotMarketManager, EnergyManagerConfiguration *configuration, QObject *parent = nullptr);
uint phasePowerLimit() const;
void setPhasePowerLimit(uint phasePowerLimit);
double acquisitionTolerance() const;
void setAcquisitionTolerance(double acquisitionTolerance);
double batteryLevelConsideration() const;
void setBatteryLevelConsideration(double batteryLevelConsideration);
bool lockOnUnplug() const;
void setLockOnUnplug(bool lockOnUnplug);
ChargingInfos chargingInfos() const;
ChargingInfo chargingInfo(const ThingId &evChargerId) const;
EnergyManager::EnergyError setChargingInfo(const ChargingInfo &chargingInfo);
ChargingSchedules chargingSchedules() const;
/*!
* \brief [ETM] Mode dégradé L2 — le compteur principal est muet depuis plus de 90 s.
*
* Base amont : toujours faux (l'amont n'a pas de watchdog). Redéfini par
* \c EnergyArbitrator, qui le pose depuis \c applyDegradedMode() (SAFETY.md §L2).
* Exposé publiquement pour la notification JSON-RPC \c ChargingSchedulesChanged.
*
* \return Vrai si la planification est suspendue et les consignes de repli posées.
* La sécurité L4 (\c verifyOverloadProtection) reste active dans cet état.
*/
virtual bool degradedMode() const { return false; }
SpotMarketManager *spotMarketManager() const;
#ifdef ENERGY_SIMULATION
void simulationCallUpdate(const QDateTime &dateTime);
void simulationCallUpdateManualSoCsWithMeter(EnergyLogs::SampleRate sampleRate, const ThingPowerLogEntry &entry);
#endif
signals:
void phasePowerLimitChanged(int phasePowerLimit);
void acquisitionToleranceChanged(double acquisitionTolerance);
void batteryLevelConsiderationChanged(double batteryLevelConsideration);
void lockOnUnplugChanged(bool lockOnUnplug);
void chargingInfoAdded(const ChargingInfo &chargingInfo);
void chargingInfoRemoved(const ThingId &evChargerThingId);
void chargingInfoChanged(const ChargingInfo &chargingInfo);
void chargingSchedulesChanged();
#ifdef ENERGY_SIMULATION
void chargingUpdated();
#endif
// [ETM] BEGIN — SmartChargingManager protected API for EnergyArbitrator (etm/).
// All changes below are visibility-only (private → protected / virtual added).
// Zero logic change. Revert by deleting this block and restoring private slots.
//
// INVENTAIRE des ajouts ETM de ce fichier — ils vivent HORS de energyplugin/etm/ et
// échappent donc au périmètre du Doxyfile ; ils sont documentés à la main, et la
// définition de fait d'AGENTS.md les couvre explicitement. Toute nouvelle marque
// [ETM] dans un en-tête amont DOIT être ajoutée ici :
//
// 1. degradedMode() — ajout ETM, virtuel, documenté ci-dessus.
// 2. update() — virtual ajouté (le corps reste amont).
// 3. prepareInformation, planSpotMarketCharging, planSurplusCharging,
// adjustEvChargers, updateManualSoCsWithoutMeter, verifyOverloadProtection,
// verifyOverloadProtectionRecovery, executeChargingAction
// — visibilité seule (private → protected).
// Sémantique amont inchangée : NON documentés
// ici, la doc appartient à l'amont.
// 4. internalEvChargers(), internalChargingActions(), internalRootMeter(),
// internalProcessInfo() — accesseurs ETM neufs, documentés ci-dessous.
// 5. bestPhaseCountFor(), phasesForCount() — passe-plats ETM vers deux règles amont
// privées, documentés ci-dessous.
// 7. setEvChargingState() — écriture ETM d'un état RUNTIME, documentée
// ci-dessous. Pas un réglage : jamais persistée.
// 6. evSurplusPlannedByWaterfall(), evCommandedByWaterfall()
// — crochets ETM, virtuels, documentés ci-dessous.
// Ce sont EUX qui garantissent « une charge, un
// commandeur » : sans eux, planSurplusCharging()
// et adjustEvChargers() re-décident après le
// waterfall. Deux crochets et non un : ils sont
// interrogés à deux moments du cycle où la
// réponse n'est pas la même (voir leur doc).
protected slots:
virtual void update(const QDateTime &currentDateTime); // [ETM] virtual added
void prepareInformation(const QDateTime &currentDateTime); // [ETM] private → protected
void planSpotMarketCharging(const QDateTime &currentDateTime); // [ETM] private → protected
void planSurplusCharging(const QDateTime &currentDateTime); // [ETM] private → protected
void adjustEvChargers(const QDateTime &currentDateTime); // [ETM] private → protected
void updateManualSoCsWithoutMeter(const QDateTime &currentDateTime); // [ETM] private → protected
void verifyOverloadProtection(const QDateTime &currentDateTime); // [ETM] private → protected
void verifyOverloadProtectionRecovery(const QDateTime &currentDateTime); // [ETM] private → protected
protected:
void executeChargingAction(EvCharger *evCharger, const ChargingAction &chargingAction, const QDateTime &currentDateTime); // [ETM] private → protected
/*!
* \brief [ETM 3g-1] Nombre de phases le plus adapté à un courant de surplus donné.
* \note `private → protected`. La règle de bascule 1↔3 phases est la MÊME que le VE soit
* décidé par le proxy ou par le waterfall ; la dupliquer dans l'arbitre en ferait deux,
* et elles divergeraient. C'est la seule raison de cet élargissement de visibilité.
*/
uint bestPhaseCountFor(EvCharger *evCharger, double surplusAmpere)
{ return getBestPhaseCount(evCharger, surplusAmpere); }
/*!
* \brief [ETM 3g-2] Masque de phases correspondant à un nombre de phases.
* \note `private → protected`, même raison que \c bestPhaseCountFor : l'arbitre en a
* besoin pour interroger \c RootMeter::calculateAllowanceAmpere() avec le MÊME masque
* que le proxy. Redériver le masque dans l'arbitre en ferait deux versions.
*/
Electricity::Phases phasesForCount(uint phaseCount)
{ return getAscendingPhasesForCount(phaseCount); }
/*!
* \brief [ETM 3g-2] Cette borne est-elle déjà commandée par le waterfall ce cycle ?
*
* \return Faux dans l'amont : sans arbitre ETM, \c adjustEvChargers() est le seul
* commandeur et rien ne change. \c EnergyArbitrator le surcharge.
*
* \par Pourquoi ce crochet existe
* 3g-1 a fait entrer les bornes dans le waterfall mais a laissé tourner le chemin
* d'exécution du proxy : l'arbitre décidait, publiait un motif juste, et
* \c adjustEvChargers() commandait autre chose une milliseconde plus tard (relevé
* machine du 2026-08-26, §3.3). **Deux commandeurs sur un même organe** — c'est la
* règle absolue 1 d'AGENTS.md, appliquée à l'exécution au lieu du budget.
*
* Ce que le crochet EXCLUT du proxy est exactement ce que le waterfall a commandé :
* les bornes servies au surplus. Celles qui restent au proxy — échéance et spot market,
* financées au réseau (DESIGN_3g §3.1) — continuent d'y passer, allowance de compteur
* comprise.
*/
virtual bool evCommandedByWaterfall(EvCharger *evCharger) const { Q_UNUSED(evCharger) return false; }
/*!
* \brief [ETM 3g-2] Le surplus de cette borne sera-t-il décidé par le waterfall ?
*
* \return Faux dans l'amont. \c EnergyArbitrator le surcharge par le filtre d'entrée du
* waterfall (voiture assignée + mode non manuel).
*
* \note **Distinct de \c evCommandedByWaterfall, et la différence est temporelle.**
* \c planSurplusCharging() tourne AVANT que le waterfall n'ait décidé : à cet instant
* on ne peut savoir que si la borne lui APPARTIENT, pas ce qu'il en aura fait. Une
* borne qui lui appartient peut encore sortir par l'échéance — mais celle-ci est
* traitée à l'étape 2 de la même fonction, qui la retire de la liste avant l'étape 3.
* \c adjustEvChargers(), lui, tourne APRÈS : il interroge le crochet plus strict, sans
* quoi une borne servie par l'échéance ne serait plus commandée du tout.
*/
virtual bool evSurplusPlannedByWaterfall(EvCharger *evCharger) const { Q_UNUSED(evCharger) return false; }
/*!
* \brief [ETM 3g-2] Met à jour l'état de charge PUBLIÉ d'une borne, et notifie si changé.
*
* \param evChargerId Borne concernée. \param state Nouvel état de charge.
*
* Reproduit **exactement** ce que fait \c adjustEvChargers() pour les bornes qu'il
* commande : écriture en mémoire, notification \c chargingInfoChanged sur transition
* seulement, et **aucune persistance** — \c chargingState est un état runtime, pas un
* réglage (il n'a d'ailleurs pas d'accesseur en écriture au schéma RPC).
*
* Existe parce que les bornes servies par le waterfall ne traversent plus
* \c adjustEvChargers() : sans elle, \c chargingState resterait figé sur sa dernière
* valeur et l'app afficherait « en charge » une borne que l'arbitre vient d'éteindre.
*
* \note Ne PAS passer par \c setChargingInfo() pour cela : cette méthode valide,
* persiste et relance \c update() — appelée depuis un cycle en cours, elle récurse.
*/
void setEvChargingState(const ThingId &evChargerId, ChargingInfo::ChargingState state);
// [ETM] Read-only state accessors — inline, no copies, no logic.
/*!
* \brief [ETM] Bornes VE enregistrées, en lecture seule.
* \return Référence sur la table interne — aucune copie. Valide tant que le manager
* vit ; ne pas conserver au-delà d'un cycle \c update().
*/
const QHash<ThingId, EvCharger *> &internalEvChargers() const { return m_evChargers; } // [ETM] new
/*!
* \brief [ETM] Consignes de charge du cycle courant, en lecture seule.
* \return Référence sur la table remplie par \c planSurplusCharging() /
* \c planSpotMarketCharging(). N'a de sens qu'APRÈS ces appels dans le même cycle.
*/
const QHash<EvCharger *, ChargingActions> &internalChargingActions() const { return m_chargingActions; } // [ETM] new
/*!
* \brief [ETM] Compteur principal du site, en lecture seule.
* \return Pointeur non-propriétaire ; **peut être nul** si aucun root meter n'est
* configuré — tout appelant doit le tester.
*/
RootMeter *internalRootMeter() const { return m_rootMeter; } // [ETM] new
/*!
* \brief [ETM 3g-2] Informations de processus d'une borne pour le cycle courant.
*
* \param evCharger Borne. \return Copie de son \c ChargingProcessInfo ; valeurs par
* défaut si la borne n'a pas été préparée ce cycle.
*
* \note N'a de sens qu'APRÈS \c prepareInformation() dans le même cycle — position 2 de
* \c update(), donc avant toute construction de contexte.
* \note Existe pour que l'arbitre n'ait pas à REDÉRIVER le nombre de phases possible.
* \c EvCharger::canSetPhaseCount() dit seulement que la borne PORTE l'état
* \c desiredPhaseCount ; la vraie question — la borne ET la voiture acceptent-elles 1 et
* 3 phases — est tranchée ici, une fois, avec le véhicule branché. Les deux prédicats ont
* divergé en 3g-1 : une borne monophasée sans voiture triphasée s'est vue viser 3 phases,
* et sous surcharge cela commandait 3 × 6 A là où le proxy commandait 1 × 6 A.
*/
ChargingProcessInfo internalProcessInfo(EvCharger *evCharger) const { return m_processInfos.value(evCharger); } // [ETM] new
// [ETM] END
private slots:
void updateManualSoCsWithMeter(EnergyLogs::SampleRate sampleRate, const ThingPowerLogEntry &entry);
void onThingAdded(Thing *thing);
void onThingRemoved(const ThingId &thingId);
void onActionExecuted(const Action &action, Thing::ThingError status);
void onChargingModeChanged(const ThingId &evChargerId, const ChargingInfo &chargingInfo);
private:
void setupRootMeter(Thing *thing);
void setupEvCharger(Thing *thing);
void setupPluggedInHandlers(const Thing *thing);
void storeChargingInfo(const ChargingInfo &chargingInfo);
void storeManualChargingParameters(const ThingId &evChargerId, bool enabled, int maxChargingCurrent, uint desiredPhaseCount);
bool manualChargingEnabled(const ThingId &evChargerId) const;
uint manualMaxChargingCurrent(const ThingId &evChargerId) const;
uint manualDesiredPhaseCount(const ThingId &evChargerId) const;
Electricity::Phases getAscendingPhasesForCount(uint phaseCount);
uint getBestPhaseCount(EvCharger *evCharger, double surplusAmpere);
QString chargerPhaseKey(EvCharger *evCharger) const;
EnergyManager *m_energyManager = nullptr;
ThingManager *m_thingManager = nullptr;
SpotMarketManager *m_spotMarketManager = nullptr;
EnergyManagerConfiguration *m_configuration = nullptr;
QHash<EvCharger *, ChargingSchedules> m_chargingSchedules;
QHash<EvCharger *, ChargingProcessInfo> m_processInfos;
QHash<EvCharger *, ChargingActions> m_chargingActions;
QDateTime m_lastSpotMarketPlanning;
// Overload protection
QHash<EvCharger *, bool> m_overloadProtectionActive;
uint m_phasePowerConsumptionLimit = 25;
double m_acquisitionTolerance = 0.5;
double m_batteryLevelConsideration = 0.9;
QHash<ThingId, ChargingInfo> m_chargingInfos;
RootMeter *m_rootMeter = nullptr;
QHash<ThingId, EvCharger *> m_evChargers;
};
#endif // SMARTCHARGINGMANAGER_H