Le signal vit dans GetLoadConfig, en r:, et non dans la télémétrie. `measurement` n'existe que pour les charges arbitrées ; or une charge qu'on DÉCLARE est précisément celle qui n'y est pas encore. L'écran serait muet au moment exact où il doit parler — celui de la saisie, avant qu'une obligation n'achète à l'aveugle tous les jours. Trois causes, trois gestes : noMeter se répare en une manipulation, meterWithoutEnergy demande de remplacer le compteur, noSessionEnergy ne se répare pas. Sans la cause, l'avertissement ne peut dire que « ça ne marchera pas ». Et meterThingId ne permet pas de le déduire : un compteur peut publier currentPower sans totalEnergyConsumed — mesuré sur l'ECS du banc, reproduit au test avec un powerSwitch désigné comme compteur. La puissance ne suffit pas, il faut une énergie. Contre-épreuve : déduction par meterThingId seul, le cas du banc passe pour mesurable. INVENTAIRE À FROID DU MOCK. Trois fois en trois jours qu'un cas réel s'est révélé inatteignable ; ce n'est plus un incident, c'est une propriété du banc — il est plus homogène que la réalité, ayant été construit pour faire marcher des scénarios et non pour représenter la diversité du parc. Quatre cas restent structurellement introuvables, dont `source: none` pour une charge SAINE — toutes les classes pilotables portent une puissance, si bien qu'une charge non mesurée mais en bon état est impossible à fabriquer, alors qu'un relais sec est banal sur le terrain. Et la branche `temperature`, écrite et jamais exécutée faute d'une classe qui porte l'état. La règle : avant d'écrire un test qui exerce une ABSENCE, vérifier que le mock peut la produire. Une assertion sur un cas inatteignable ne passe pas, elle NE S'EXÉCUTE PAS — ce qui est pire, car elle compte comme verte. Le symptôme se reconnaît : la contre-épreuve ne mord pas. Deux ajouts dans AGENTS.md. L'asymétrie des latences est une PROPRIÉTÉ, pas une chance : le refus qui coûte de l'argent est celui de la descente, et c'est le plus vite constaté — ce qui n'est vrai que parce que le seuil vient du mécanisme. Et la question opératoire appliquée aux trois autres identités, avec un résultat inégal qui est le but : celle de `draw` repose sur le fait que le compteur voie toute la maison, ce que rien ne vérifie ; Σ levels[].targetW == estimatedPowerW est interne au plan et ne dit rien du matériel — il a fallu un champ hors de la formule pour relier les deux. Suite : simulation 61/61, loadmodel 22/22, charging 48/48 identique à la référence, 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
342 lines
17 KiB
C++
342 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);
|
||
|
||
//! \brief [ETM] Gestionnaire de Things, pour les consommateurs qui doivent interroger une
|
||
//! ThingClass sans passer par NymeaCore (frontière RPC notamment).
|
||
//! \return Pointeur non-propriétaire ; jamais nul après construction.
|
||
ThingManager *thingManager() const { return m_thingManager; }
|
||
|
||
|
||
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
|