Patrick Schurig ccf05cea87 feat(R6): la mesurabilité se déclare à la saisie, et l'inventaire du mock
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
2026-09-01 17:27:56 +02:00

342 lines
17 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);
//! \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 &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
/*!
* \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