LM-1208-b appliqué : `assignedCarId` sort du filtre d'entrée du waterfall. Restent deux exclusions, et elles ne disent pas la même chose — le mode manuel est une INTENTION (l'utilisateur pilote), l'absence de véhicule branché un CONSTAT (rien à consommer de ce qu'on allouerait). Le critère venait du proxy, où il a un sens : sans voiture, pas de SoC, pas de capacité, pas d'échéance à planifier. Le waterfall n'a besoin d'aucune des trois pour allouer du surplus. Il contredisait LM-1206 — le rang et le plancher appartiennent à la borne, pas au véhicule — il n'était pas vérifiable, et il excluait la borne d'un invité au moment précis où elle consomme. Ce qu'on perd sans voiture est une FONCTION, échéance et cible d'énergie, pas l'arbitrage. LM-1208-c, trouvé en implantant, et c'est lui qui aurait mordu : retirer le critère ne suffit pas. Le proxy ne PRÉPARE pas une borne sans voiture — prepareInformation() la filtre en entrée — donc elle entre dans le waterfall sans ChargingProcessInfo. Or internalProcessInfo() rend un objet par DÉFAUT, et ses valeurs sont plausibles : une phase, pas de bascule. Une borne triphasée aurait été annoncée monophasée et le budget divisé par trois — plafond 3 680 W au lieu de 11 040, 16 A commandés au lieu de 9, 11 kW tirés d'une enveloppe qui en comptait 3,7. Le défaut de 3g-1 pris à l'envers, et silencieux. D'où internalHasProcessInfo() : la question « cette borne a-t-elle été préparée ce cycle » devient POSABLE au lieu d'être déduite d'une valeur crédible. Sans ChargingProcessInfo on ne bascule pas — basculer suppose de savoir ce que la voiture accepte — et on retient le phaseCount de la BORNE, hypothèse conservatrice au sens de LM-104 : elle surestime la consommation. Éprouvé par testChargerWithoutCarIsArbitrated, et VÉRIFIÉ ÉCHOUANT sans le garde-fou : 3 680 W au lieu de 6 000 W alloués. Le test porte les deux moitiés — la borne sans voiture est servie ET le mode manuel reste une exclusion, sans quoi il passerait aussi si toute borne était arbitrée sans condition. amd64 0 erreur / 0 avertissement. Suites, un processus par test : simulation 29/29 (dont run 16/16), charging 16/16, loadmodel 18/18, spotmarket 7/7. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
336 lines
17 KiB
C++
336 lines
17 KiB
C++
// 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(), internalHasProcessInfo()
|
||
// — 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 ¤tDateTime); // [ETM] virtual added
|
||
void prepareInformation(const QDateTime ¤tDateTime); // [ETM] private → protected
|
||
void planSpotMarketCharging(const QDateTime ¤tDateTime); // [ETM] private → protected
|
||
void planSurplusCharging(const QDateTime ¤tDateTime); // [ETM] private → protected
|
||
void adjustEvChargers(const QDateTime ¤tDateTime); // [ETM] private → protected
|
||
void updateManualSoCsWithoutMeter(const QDateTime ¤tDateTime); // [ETM] private → protected
|
||
void verifyOverloadProtection(const QDateTime ¤tDateTime); // [ETM] private → protected
|
||
void verifyOverloadProtectionRecovery(const QDateTime ¤tDateTime); // [ETM] private → protected
|
||
|
||
protected:
|
||
void executeChargingAction(EvCharger *evCharger, const ChargingAction &chargingAction, const QDateTime ¤tDateTime); // [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
|
||
|
||
/*!
|
||
* \brief [ETM · LM-1208-b] Cette borne a-t-elle été PRÉPARÉE ce cycle ?
|
||
*
|
||
* \param evCharger Borne.
|
||
* \return Vrai si \c prepareInformation() lui a construit un \c ChargingProcessInfo
|
||
* pendant ce cycle ; faux sinon — notamment quand aucune voiture ne lui est assignée,
|
||
* cas où le filtre d'entrée de \c prepareInformation() l'écarte.
|
||
*
|
||
* \note Existe parce que \c internalProcessInfo() ne sait pas dire « absent » : il rend un
|
||
* objet par DÉFAUT, dont les valeurs — 1 phase, pas de bascule — sont des réponses
|
||
* parfaitement plausibles qu'aucun appelant ne peut distinguer d'une préparation réelle.
|
||
* \note Le cas est devenu RÉEL avec **LM-1208-b** : une borne sans voiture assignée entre
|
||
* dans le waterfall, alors que \c prepareInformation() continue de l'écarter. Elle est
|
||
* donc arbitrée sans \c ChargingProcessInfo, et
|
||
* \c EnergyArbitrator::evTargetPhases() doit le SAVOIR plutôt que de le déduire de
|
||
* valeurs par défaut — sans quoi une borne triphasée est annoncée monophasée et le
|
||
* budget du cycle est divisé par trois.
|
||
*/
|
||
bool internalHasProcessInfo(EvCharger *evCharger) const { return m_processInfos.contains(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
|