Patrick Schurig 99ad9a04cb fix(3g-1): le nombre de phases visé se décide avec la voiture branchée
Défaut de 3g-1, resté latent parce que sa sortie n'atteignait que la
télémétrie. Il est corrigé à part du lot 3g-2 pour pouvoir être relu,
contesté ou rétroporté sans lui.

EvAdapter interrogeait EvCharger::canSetPhaseCount(), qui dit seulement que
la borne PORTE l'état desiredPhaseCount. La vraie question — la borne ET la
voiture acceptent-elles 1 et 3 phases — est tranchée une fois par cycle par
prepareInformation(), qui en tire canSwitchPhaseCount et le couple
min/maxPossiblePhaseCount. Les deux prédicats ont divergé : là où le proxy
appliquait `qMin(carPhaseCount, getBestPhaseCount(...))`, 3g-1 n'a repris que
l'appel.

Conséquence : une borne dont la combinaison ne permet qu'une phase se voyait
viser 3 phases. Son plancher publié triplait (1,4 kW → 4,1 kW) sans que rien
ne puisse jamais le franchir — la borne restait éternellement « sous son
plancher », avec un motif juste et une cause fausse.

Second défaut, dans applyAction() : le nombre de phases demandé était borné
par m_charger->phaseCount(), qui est l'état COURANT de la borne. Une borne en
monophasé ne pouvait donc jamais être commandée en triphasé, et le plancher
publié pour trois phases n'aurait de toute façon pas pu être servi.

evTargetPhases() cesse d'être un passe-plat et lit les bornes du manager, via
un accesseur [ETM] read-only de plus (internalProcessInfo). L'arbitre ne
redérive rien : une seconde version de cette règle divergerait de la première
au premier ajustement, ce qui est exactement ce qui vient d'arriver.

Suite charging 16/16, simulation `run` 16/16, testLoadTelemetryRpc vert.

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

239 lines
11 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.
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); }
// [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