nymea-energy-plugin-nymea/energyplugin/smartchargingmanager.cpp

1841 lines
102 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/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#include "smartchargingmanager.h"
#include "evcharger.h"
#include "rootmeter.h"
#include "energysettings.h"
#include <qmath.h>
#include <QUrlQuery>
#include "loggingcategories.h"
NYMEA_LOGGING_CATEGORY(dcNymeaEnergy, "NymeaEnergy")
SmartChargingManager::SmartChargingManager(EnergyManager *energyManager, ThingManager *thingManager, SpotMarketManager *spotMarketManager, EnergyManagerConfiguration *configuration, QObject *parent):
QObject{parent},
m_energyManager{energyManager},
m_thingManager{thingManager},
m_spotMarketManager{spotMarketManager},
m_configuration{configuration}
{
EnergySettings settings;
m_phasePowerConsumptionLimit = settings.value("phasePowerConsumptionLimit", 0).toInt();
m_acquisitionTolerance = settings.value("acquisitionTolerance", 0.5).toDouble();
m_batteryLevelConsideration = settings.value("batteryLevelConsideration", 0.9).toDouble();
settings.beginGroup("ChargingInfos");
foreach (const QString &evChargerIdString, settings.childGroups()) {
ThingId evChargerId = ThingId(evChargerIdString);
if (thingManager->findConfiguredThing(evChargerId)) {
settings.beginGroup(evChargerIdString);
ChargingInfo info(evChargerId);
info.setAssignedCarId(settings.value("assignedCarId").toUuid());
info.setChargingMode(static_cast<ChargingInfo::ChargingMode>(settings.value("chargingMode").toInt()));
if (!settings.contains("endDateTime")) {
// Pre 0.4 (nymea 1.5) had only endTime
// TODO: migrate endTime to endDateTime with repeat
} else {
info.setEndDateTime(settings.value("endDateTime").toDateTime());
}
QList<int> days;
foreach (const QVariant &day, settings.value("repeatDays").toList()) {
days.append(day.toInt());
}
info.setRepeatDays(days);
info.setTargetPercentage(settings.value("targetPercentage").toUInt());
info.setLocale(settings.value("locale").toLocale());
info.setSpotMarketChargingEnabled(settings.value("spotMarketChargingEnabled", false).toBool());
info.setDailySpotMarketPercentage(settings.value("dailySpotMarketPercentage", 0).toUInt());
m_chargingInfos.insert(evChargerId, info);
settings.endGroup();
} else {
qCWarning(dcNymeaEnergy()) << "EV charger with ID" << evChargerId << "not found in system. Not loading configuration.";
settings.remove(evChargerIdString);
}
}
settings.endGroup();
Things evChargers = m_thingManager->configuredThings().filterByInterface("evcharger");
qCDebug(dcNymeaEnergy) << "SmartChargingManager loading. EV chargers in nymea:" << evChargers.count();
foreach (Thing *thing, evChargers) {
EvCharger *evCharger = new EvCharger(m_thingManager, thing);
evCharger->setChargingEnabledLockDuration(m_configuration->chargingEnabledLockDuration());
evCharger->setChargingCurrentLockDuration(m_configuration->chargingCurrentLockDuration());
m_evChargers.insert(thing->id(), evCharger);
// Make sure we have a chargingInfo for all the evchargers
if (!m_chargingInfos.contains(thing->id())) {
setupEvCharger(thing);
}
setupPluggedInHandlers(thing);
}
connect(m_thingManager, &ThingManager::thingAdded, this, &SmartChargingManager::onThingAdded);
connect(m_thingManager, &ThingManager::thingRemoved, this, &SmartChargingManager::onThingRemoved);
connect(m_thingManager, &ThingManager::actionExecuted, this, &SmartChargingManager::onActionExecuted);
if (m_energyManager->rootMeter()) {
setupRootMeter(m_energyManager->rootMeter());
}
connect(m_energyManager, &EnergyManager::rootMeterChanged, this, [=](){
setupRootMeter(m_energyManager->rootMeter());
});
#ifndef ENERGY_SIMULATION
connect(m_energyManager->logs(), &EnergyLogs::powerBalanceEntryAdded, this, [this](EnergyLogs::SampleRate sampleRate, const PowerBalanceLogEntry &entry) {
QDateTime now = QDateTime::currentDateTime();
if (sampleRate == EnergyLogs::SampleRate1Min &&
entry.timestamp().date() == now.date() &&
entry.timestamp().time().hour() == now.time().hour() &&
entry.timestamp().time().minute() == now.time().minute() ) {
update(QDateTime::currentDateTime());
}
});
connect(m_energyManager->logs(), &EnergyLogs::thingPowerEntryAdded, this, [this](EnergyLogs::SampleRate sampleRate, const ThingPowerLogEntry &entry){
QDateTime now = QDateTime::currentDateTime();
if (sampleRate == EnergyLogs::SampleRate1Min &&
entry.timestamp().date() == now.date() &&
entry.timestamp().time().hour() == now.time().hour() &&
entry.timestamp().time().minute() == now.time().minute() ) {
updateManualSoCsWithMeter(sampleRate, entry);
}
});
connect(m_energyManager, &EnergyManager::powerBalanceChanged, this, [this]() {
verifyOverloadProtection(QDateTime::currentDateTime());
});
#endif
connect(m_spotMarketManager, &SpotMarketManager::enabledChanged, this, [this](bool enabled){
if (enabled)
return;
qCDebug(dcNymeaEnergy()) << "Spotmarket has been disabled. Disable the spotmarket for all charging infos...";
foreach(const ChargingInfo &ci, m_chargingInfos.values()) {
ChargingInfo chargingInfo = ci;
chargingInfo.setSpotMarketChargingEnabled(false);
setChargingInfo(chargingInfo);
}
});
}
uint SmartChargingManager::phasePowerLimit() const
{
return m_phasePowerConsumptionLimit;
}
void SmartChargingManager::setPhasePowerLimit(uint phasePowerLimit)
{
if (m_phasePowerConsumptionLimit != phasePowerLimit) {
m_phasePowerConsumptionLimit = phasePowerLimit;
emit phasePowerLimitChanged(m_phasePowerConsumptionLimit);
EnergySettings settings;
settings.setValue("phasePowerConsumptionLimit", m_phasePowerConsumptionLimit);
update(QDateTime::currentDateTime());
}
}
double SmartChargingManager::acquisitionTolerance() const
{
return m_acquisitionTolerance;
}
void SmartChargingManager::setAcquisitionTolerance(double acquisitionTolerance)
{
if (m_acquisitionTolerance != acquisitionTolerance) {
m_acquisitionTolerance = qMax(0.0, qMin(1.0, acquisitionTolerance));
emit acquisitionToleranceChanged(m_acquisitionTolerance);
EnergySettings settings;
settings.setValue("acquisitionTolerance", m_acquisitionTolerance);
update(QDateTime::currentDateTime());
}
}
double SmartChargingManager::batteryLevelConsideration() const
{
return m_batteryLevelConsideration;
}
void SmartChargingManager::setBatteryLevelConsideration(double batteryLevelConsideration)
{
if (m_batteryLevelConsideration != batteryLevelConsideration) {
m_batteryLevelConsideration = qMax(0.0, qMin(1.0, batteryLevelConsideration));
emit batteryLevelConsiderationChanged(m_batteryLevelConsideration);
EnergySettings settings;
settings.setValue("batteryLevelConsideration", m_batteryLevelConsideration);
}
}
bool SmartChargingManager::lockOnUnplug() const
{
QSettings settings(NymeaSettings::settingsPath() + "/energy.conf", QSettings::IniFormat);
return settings.value("lockOnUnplug").toBool();
}
void SmartChargingManager::setLockOnUnplug(bool lockOnUnplug)
{
QSettings settings(NymeaSettings::settingsPath() + "/energy.conf", QSettings::IniFormat);
if (settings.value("lockOnUnplug").toBool() != lockOnUnplug) {
settings.setValue("lockOnUnplug", lockOnUnplug);
emit lockOnUnplugChanged(lockOnUnplug);
}
}
ChargingInfos SmartChargingManager::chargingInfos() const
{
return m_chargingInfos.values();
}
ChargingInfo SmartChargingManager::chargingInfo(const ThingId &evChargerId) const
{
return m_chargingInfos.value(evChargerId);
}
EnergyManager::EnergyError SmartChargingManager::setChargingInfo(const ChargingInfo &chargingInfo)
{
qCDebug(dcNymeaEnergy()) << "Setting charging info:" << chargingInfo;
Thing *evCharger = m_thingManager->findConfiguredThing(chargingInfo.evChargerId());
if (!evCharger || !evCharger->thingClass().interfaces().contains("evcharger")) {
qCWarning(dcNymeaEnergy()) << "No such EV charger:" << chargingInfo.evChargerId();
return EnergyManager::EnergyErrorInvalidParameter;
}
if (chargingInfo.targetPercentage() > 100) {
qCWarning(dcNymeaEnergy()) << "Charging info target percentage out of range:" << chargingInfo.targetPercentage();
return EnergyManager::EnergyErrorInvalidParameter;
}
foreach (int day, chargingInfo.repeatDays()) {
if (day < 1 || day > 7) {
qCWarning(dcNymeaEnergy()) << "Charging info repeat days invalid. All days must be within 1 (Mon) and 7 (Sun):" << chargingInfo.repeatDays();
return EnergyManager::EnergyErrorInvalidParameter;
}
}
if (chargingInfo.spotMarketChargingEnabled() && !m_spotMarketManager->enabled()) {
qCWarning(dcNymeaEnergy()) << "Charging info has spot market enabled, but there is no provider enabled in the system. Disabeling spotmarket for this charger.";
return EnergyManager::EnergyErrorInvalidParameter;
}
if (chargingInfo.dailySpotMarketPercentage() > 100) {
qCWarning(dcNymeaEnergy()) << "Charging info daily spot market percentage value out of range:" << chargingInfo.dailySpotMarketPercentage() << "is not in the range [0 - 100] %";
return EnergyManager::EnergyErrorInvalidParameter;
}
if (m_chargingInfos.value(chargingInfo.evChargerId()) != chargingInfo) {
// Make sure the assigned car exists
Thing *assignedCar = m_thingManager->findConfiguredThing(chargingInfo.assignedCarId());
if (!chargingInfo.assignedCarId().isNull() && !assignedCar) {
qCWarning(dcNymeaEnergy()) << "The given assigned car id cannot be found in the system.";
return EnergyManager::EnergyErrorInvalidParameter;
}
if (m_chargingInfos.value(evCharger->id()).chargingMode() != chargingInfo.chargingMode()) {
onChargingModeChanged(evCharger->id(), chargingInfo);
}
m_chargingInfos[chargingInfo.evChargerId()] = chargingInfo;
qCInfo(dcNymeaEnergy()) << "Charging info for" << evCharger->name() << "set to" << chargingInfo;
emit chargingInfoChanged(chargingInfo);
storeChargingInfo(chargingInfo);
#ifndef ENERGY_SIMULATION
update(QDateTime::currentDateTime());
#endif
}
return EnergyManager::EnergyErrorNoError;
}
ChargingSchedules SmartChargingManager::chargingSchedules() const
{
ChargingSchedules schedules;
foreach(const ChargingSchedules &cs, m_chargingSchedules.values()) {
schedules << cs;
}
return schedules;
}
SpotMarketManager *SmartChargingManager::spotMarketManager() const
{
return m_spotMarketManager;
}
#ifdef ENERGY_SIMULATION
void SmartChargingManager::simulationCallUpdate(const QDateTime &dateTime)
{
update(dateTime);
verifyOverloadProtection(dateTime);
}
void SmartChargingManager::simulationCallUpdateManualSoCsWithMeter(EnergyLogs::SampleRate sampleRate, const ThingPowerLogEntry &entry)
{
updateManualSoCsWithMeter(sampleRate, entry);
}
#endif
void SmartChargingManager::update(const QDateTime &currentDateTime)
{
qCDebug(dcNymeaEnergy()) << "Updating smart charging";
updateManualSoCsWithoutMeter(currentDateTime);
prepareInformation(currentDateTime);
verifyOverloadProtection(currentDateTime);
verifyOverloadProtectionRecovery(currentDateTime);
planSpotMarketCharging(currentDateTime);
planSurplusCharging(currentDateTime);
adjustEvChargers(currentDateTime);
}
void SmartChargingManager::verifyOverloadProtection(const QDateTime &currentDateTime)
{
if (!m_rootMeter) {
qCDebug(dcNymeaEnergy()) << "Overload protection: No root meter configured, the overload protection is not available.";
return;
}
// Verify if any of the phases is approaching the limit
if (m_phasePowerConsumptionLimit == 0) {
// Power limit not set up
qCDebug(dcNymeaEnergy()) << "Overload protection: No power limit configured, the overload protection is disabled.";
return;
}
QHash<QString, double> currentPhaseConsumption = {{"A", m_rootMeter->currentPowerPhaseA()},
{"B", m_rootMeter->currentPowerPhaseB()},
{"C", m_rootMeter->currentPowerPhaseC()}};
bool limitExceeded = false;
double consumptionLimitInWatt = 230 * m_phasePowerConsumptionLimit;
// Note: for now we assume the phases are always connected this way:
// EV phase A -> house phase A
// EV phase B -> house phase B
// EV phase C -> house phase C
// In case of single phase charging, we assume power on phase A
// EV phase A -> house phase A
// Once we have a mapping of consumer phases to house phases, we can be much more prcise here and also handle
// multiple chargers connected on different phases in single phase mode.
double maxRequiredThrottlePower = 0;
QHash<QString, double> requiredThrottlePower = {{"A", 0},
{"B", 0},
{"C", 0}};
foreach (const QString &phase, currentPhaseConsumption.keys()) {
if (currentPhaseConsumption.value(phase) > consumptionLimitInWatt) {
qCInfo(dcNymeaEnergy()) << "Overload protection: Phase" << phase << "exceeding limit:" << currentPhaseConsumption.value(phase) << "W (" << (currentPhaseConsumption.value(phase) / 230) << "A). Maximum allowance:" << consumptionLimitInWatt << "W (" << m_phasePowerConsumptionLimit << "A)";
limitExceeded = true;
double overshot = currentPhaseConsumption.value(phase) - consumptionLimitInWatt;
requiredThrottlePower[phase] = overshot;
if (overshot > maxRequiredThrottlePower) {
maxRequiredThrottlePower = overshot;
}
}
}
if (limitExceeded) {
qCDebug(dcNymeaEnergy()) << "Overload protection: Triggered, maximum power consumption per phase is:"
<< consumptionLimitInWatt << "[W], Overshot on phases:"
<< "A:" << requiredThrottlePower.value("A") << "[W],"
<< "B:" << requiredThrottlePower.value("B") << "[W],"
<< "C:" << requiredThrottlePower.value("C") << "[W]";
foreach (EvCharger *evCharger, m_evChargers) {
// Evaluate only charger which are actually charging and which are connected to the overloaded phase
if (!evCharger->chargingEnabled())
continue;
if (m_chargingInfos.value(evCharger->id()).chargingMode() == ChargingInfo::ChargingModeNormal && !m_overloadProtectionActive.value(evCharger, false)) {
qCDebug(dcNymeaEnergy()) << "Overload protection: Activated for charger in normal mode" << evCharger->name();
m_overloadProtectionActive[evCharger] = true;
}
ChargingAction action;
if (evCharger->phaseCount() == 1) {
// FIXME: get the actual phase, not assume it is phase A in single phase charging
if (requiredThrottlePower.value("A") > 0) {
int throttleAmpere = qCeil(requiredThrottlePower.value("A") / 230);
int desiredFallbackAmpere = evCharger->maxChargingCurrent() - throttleAmpere;
if (desiredFallbackAmpere < static_cast<int>(m_processInfos[evCharger].minimalChargingCurrent)) {
qCDebug(dcNymeaEnergy()) << "Overload protection: Need to switch of charging for" << evCharger->name();
action = ChargingAction(false, evCharger->maxChargingCurrent(), evCharger->phaseCount(), ChargingAction::ChargingActionIssuerOverloadProtection, true);
} else {
qCDebug(dcNymeaEnergy()) << "Overload protection: Adjusting charger from" << evCharger->maxChargingCurrent() << "[A] ->" << desiredFallbackAmpere << "[A]";
action = ChargingAction(true, desiredFallbackAmpere, evCharger->phaseCount(), ChargingAction::ChargingActionIssuerOverloadProtection);
}
}
} else {
// Charger is on all phases
int throttleAmpere = qCeil(maxRequiredThrottlePower / 230);
int desiredFallbackAmpere = evCharger->maxChargingCurrent() - throttleAmpere;
if (desiredFallbackAmpere < static_cast<int>(m_processInfos[evCharger].minimalChargingCurrent)) {
// TODO: Check if switching to one phase would solve the overshot issue on the phases
// if (evCharger->canSetPhaseCount()) {
// }
qCDebug(dcNymeaEnergy()) << "Overload protection: Need to switch of charging for" << evCharger->name();
action = ChargingAction(false, evCharger->maxChargingCurrent(), evCharger->phaseCount(), ChargingAction::ChargingActionIssuerOverloadProtection, true);
} else {
qCDebug(dcNymeaEnergy()) << "Overload protection: Adjusting charger from" << evCharger->maxChargingCurrent() << "[A] ->" << desiredFallbackAmpere << "[A]";
action = ChargingAction(true, desiredFallbackAmpere, evCharger->phaseCount(), ChargingAction::ChargingActionIssuerOverloadProtection);
}
}
executeChargingAction(evCharger, action, currentDateTime);
}
}
}
void SmartChargingManager::verifyOverloadProtectionRecovery(const QDateTime &currentDateTime)
{
// Let's evaluate if we can move back any charger which is in normal mode,
// for the eco mode chargers the rest of the logic should do the trick...
foreach (EvCharger *evCharger, m_evChargers) {
if (m_chargingInfos.value(evCharger->id()).chargingMode() == ChargingInfo::ChargingModeNormal && m_overloadProtectionActive.value(evCharger, false)) {
// Check if we can move back to the user desired charging level
qCDebug(dcNymeaEnergy()) << "Overload protection: Evaluating normal mode charger" << evCharger->name() << "which has been set originally to"
<< manualChargingEnabled(evCharger->id()) << manualMaxChargingCurrent(evCharger->id()) << "[A]";
QHash<QString, double> currentPhaseConsumption = {{"A", m_rootMeter->currentPowerPhaseA()},
{"B", m_rootMeter->currentPowerPhaseB()},
{"C", m_rootMeter->currentPowerPhaseC()}};
double consumptionLimitInWatt = 230 * m_phasePowerConsumptionLimit;
QHash<QString, double> availablePhasePower = {{"A", 0},
{"B", 0},
{"C", 0}};
// Calculate available power on each phase
foreach (const QString &phase, currentPhaseConsumption.keys()) {
double phasePower = currentPhaseConsumption.value(phase);
if (phasePower > consumptionLimitInWatt)
continue;
availablePhasePower[phase] = consumptionLimitInWatt - phasePower;
}
qCDebug(dcNymeaEnergy()) << "Overload protection: Available phase power:"
<< "A:" << availablePhasePower.value("A") << "[W],"
<< "B:" << availablePhasePower.value("B") << "[W],"
<< "C:" << availablePhasePower.value("C") << "[W]";
// Check if the charger has been switched of or just throttled
if (evCharger->chargingEnabled()) {
// Charger has been throttled
double requiredRestorePhasePower = (manualMaxChargingCurrent(evCharger->id()) - evCharger->maxChargingCurrent()) * 230;
qCDebug(dcNymeaEnergy()) << "Overload protection: For restoring the original charging power at least" << requiredRestorePhasePower << "[W] per phase are required.";
bool restoreCharger = false;
if (evCharger->phaseCount() == 1) {
// FIXME: get the actual phase, not assume it is phase A in single phase charging
if (availablePhasePower.value("A") >= requiredRestorePhasePower) {
qCDebug(dcNymeaEnergy()) << "Overload protection: Enought power available to restore the original configuration"
<< manualMaxChargingCurrent(evCharger->id()) << "[A]";
restoreCharger = true;
}
} else {
bool enoughtOnAllPhases = true;
foreach(const QString &phase, availablePhasePower.keys()) {
if (availablePhasePower.value(phase) < requiredRestorePhasePower) {
enoughtOnAllPhases = false;
qCDebug(dcNymeaEnergy()) << "Overload protection: Not enought power available on phase" << phase
<< availablePhasePower.value(phase) << "[W]";
}
}
if (enoughtOnAllPhases) {
restoreCharger = true;
}
}
if (restoreCharger) {
qCDebug(dcNymeaEnergy()) << "Overload protection: Restoring charger to" << manualMaxChargingCurrent(evCharger->id()) << "[A]";
ChargingAction action = ChargingAction(true, manualMaxChargingCurrent(evCharger->id()) , evCharger->phaseCount(), ChargingAction::ChargingActionIssuerOverloadProtection);
executeChargingAction(evCharger, action, currentDateTime);
m_overloadProtectionActive[evCharger] = false;
}
} else {
// Charging has been disabled by overload protection, let's see if we can switch back on to the minimal
// charging current, once we switched it back on, the section above should handle the recovery to the original values.
double requiredRestorePhasePower = m_processInfos.value(evCharger).minimalChargingCurrent * 230;
qCDebug(dcNymeaEnergy()) << "Overload protection: At least" << requiredRestorePhasePower << "[W] per phase are needed for switching the charger back on.";
bool restoreChargerPower = false;
if (evCharger->phaseCount() == 1) {
// FIXME: get the actual phase, not assume it is phase A in single phase charging
if (availablePhasePower.value("A") >= requiredRestorePhasePower) {
qCDebug(dcNymeaEnergy()) << "Overload protection: Enought power available to start charging using the minimal charging current of"
<< m_processInfos.value(evCharger).minimalChargingCurrent << "[A]";
restoreChargerPower = true;
}
} else {
bool enoughtOnAllPhases = true;
foreach(const QString &phase, availablePhasePower.keys()) {
if (availablePhasePower.value(phase) < requiredRestorePhasePower) {
enoughtOnAllPhases = false;
qCDebug(dcNymeaEnergy()) << "Overload protection: Not enought power available on phase" << phase
<< availablePhasePower.value(phase) << "[W] for switching the charger back on.";
}
}
if (enoughtOnAllPhases) {
restoreChargerPower = true;
}
}
if (restoreChargerPower) {
qCDebug(dcNymeaEnergy()) << "Overload protection: Reenable charging at a minimum of" << m_processInfos.value(evCharger).minimalChargingCurrent << "[A]";
ChargingAction action = ChargingAction(true, m_processInfos.value(evCharger).minimalChargingCurrent, evCharger->phaseCount(), ChargingAction::ChargingActionIssuerOverloadProtection);
executeChargingAction(evCharger, action, currentDateTime);
}
}
}
}
}
void SmartChargingManager::prepareInformation(const QDateTime &currentDateTime)
{
// We don't assume we have the entire phase power for charging...
double housePhaseLimitPower = m_phasePowerConsumptionLimit * 230;
qCDebug(dcNymeaEnergy()) << "---------------- Prepare information ---------------";
// 1. Filter out any EV chargers wich we can ignore entirely
EvChargers evChargers;
foreach (EvCharger *evCharger, m_evChargers) {
Thing *car = m_thingManager->findConfiguredThing(m_chargingInfos.value(evCharger->thing()->id()).assignedCarId());
if (!car) {
qCDebug(dcNymeaEnergy()) << evCharger->name() << "has no car assigned. Ignoring...";
continue;
}
QDateTime endDateTime = m_chargingInfos.value(evCharger->id()).nextEndTime(currentDateTime);
if (!endDateTime.isValid() && m_chargingInfos.value(evCharger->id()).chargingMode() != ChargingInfo::ChargingModeNormal) {
qCDebug(dcNymeaEnergy()).nospace() << "No valid target time set for " << evCharger->name();
if (m_chargingInfos.value(evCharger->id()).chargingMode() == ChargingInfo::ChargingModeEcoWithTargetTime) {
qCDebug(dcNymeaEnergy()) << "Resetting to ECO mode without time.";
}
m_chargingInfos[evCharger->id()].setChargingMode(ChargingInfo::ChargingModeEco);
storeChargingInfo(m_chargingInfos.value(evCharger->id()));
emit chargingInfoChanged(m_chargingInfos.value(evCharger->id()));
}
if (!evCharger->available()) {
qCDebug(dcNymeaEnergy()) << evCharger->name() << "is not available. Ignoring...";
continue;
}
evChargers.append(evCharger);
}
// 2. Prepare all information avaialble for each charger
foreach (EvCharger *evCharger, evChargers) {
qCDebug(dcNymeaEnergy()) << "Prepare information for" << evCharger->name();
// Car information
Thing *assignedCar = m_thingManager->findConfiguredThing(m_chargingInfos.value(evCharger->thing()->id()).assignedCarId());
m_processInfos[evCharger].carPhaseCount = assignedCar->stateValue("phaseCount").toUInt();
m_processInfos[evCharger].carBatteryLevel = assignedCar->stateValue("batteryLevel").toInt();
m_processInfos[evCharger].carCapacityInWh = assignedCar->stateValue("capacity").toDouble() * 1000;
m_processInfos[evCharger].carCapacityInkWh = assignedCar->stateValue("capacity").toDouble();
m_processInfos[evCharger].carTotalEnergy = assignedCar->stateValue("capacity").toDouble();
m_processInfos[evCharger].carChargingEnergyLoss = assignedCar->setting("chargingEnergyLoss").toUInt();
m_processInfos[evCharger].carChargingEnergyLossFactor = 1.0 - (m_processInfos.value(evCharger).carChargingEnergyLoss / 100.0);
m_processInfos[evCharger].minimalChargingCurrent = qMax(evCharger->maxChargingCurrentMinValue(), assignedCar->stateValue("minChargingCurrent").toUInt());
qCDebug(dcNymeaEnergy()).nospace() << "Car: state of charge: " << m_processInfos.value(evCharger).carBatteryLevel << "%"
<< ", capacity: "<< m_processInfos.value(evCharger).carCapacityInWh << "Wh"
<< ", charging energy loss factor " << m_processInfos.value(evCharger).carChargingEnergyLossFactor
<< ", phases: " << m_processInfos.value(evCharger).carPhaseCount;
// Evaluate phase count switching
if (evCharger->canSetPhaseCount() && m_processInfos.value(evCharger).carPhaseCount > 1) {
if (evCharger->thing()->state("desiredPhaseCount").possibleValues().contains(1) && evCharger->thing()->state("desiredPhaseCount").possibleValues().contains(3)) {
// Both, car and EV charger have 3 phases available and we have a desired phase count, lets set the
qCDebug(dcNymeaEnergy()).nospace() << "Phase count switching is possible with this car/charger combination.";
m_processInfos[evCharger].canSwitchPhaseCount = true;
m_processInfos[evCharger].effectivePhaseCount = qMin(evCharger->phaseCount(), m_processInfos.value(evCharger).carPhaseCount);
m_processInfos[evCharger].minPossiblePhaseCount = 1;
m_processInfos[evCharger].maxPossiblePhaseCount = 3;
m_processInfos[evCharger].maxPossiblePhases = Electricity::PhaseAll;
} else {
// Verify if both, evCharger and car have at least 3 phases, otherwise we can skip any further checks regarding phase switching
m_processInfos[evCharger].canSwitchPhaseCount = false;
m_processInfos[evCharger].maxPossiblePhaseCount = qMin(evCharger->phaseCount(), m_processInfos.value(evCharger).carPhaseCount);
m_processInfos[evCharger].minPossiblePhaseCount = m_processInfos.value(evCharger).maxPossiblePhaseCount;
m_processInfos[evCharger].maxPossiblePhases = getAscendingPhasesForCount(m_processInfos.value(evCharger).maxPossiblePhaseCount);
m_processInfos[evCharger].effectivePhaseCount = m_processInfos.value(evCharger).maxPossiblePhaseCount;
qCDebug(dcNymeaEnergy()).nospace() << "Phase count switching is not possible with this car/charger combination. Using " << m_processInfos.value(evCharger).effectivePhaseCount << " phases.";
}
} else {
// Either the charger can not set phases or the car has only one phase
qCDebug(dcNymeaEnergy()).nospace() << "Phase count switching is not possible with this car/charger combination. Using only 1-phase charging.";
m_processInfos[evCharger].canSwitchPhaseCount = false;
m_processInfos[evCharger].maxPossiblePhaseCount = qMin(evCharger->phaseCount(), m_processInfos.value(evCharger).carPhaseCount);
m_processInfos[evCharger].minPossiblePhaseCount = m_processInfos.value(evCharger).maxPossiblePhaseCount;
m_processInfos[evCharger].maxPossiblePhases = getAscendingPhasesForCount(m_processInfos.value(evCharger).maxPossiblePhaseCount);
m_processInfos[evCharger].effectivePhaseCount = m_processInfos.value(evCharger).maxPossiblePhaseCount;
}
// If possible and the charger provides meter information, check which phases get used based on the power information,
// otherwise fall back to an ascending phase order
Electricity::Phases meteredPhases = evCharger->meteredPhases();
if (meteredPhases != Electricity::PhaseNone) {
m_processInfos[evCharger].effectivePhases = meteredPhases;
m_processInfos[evCharger].effectivePhaseCount = Electricity::getPhaseCount(meteredPhases);
qCDebug(dcNymeaEnergy()) << "Effective phase count for charging including car phase limit (metered)"
<< m_processInfos.value(evCharger).effectivePhaseCount << "phases in use:"
<< Electricity::convertPhasesToString(m_processInfos.value(evCharger).effectivePhases );
} else {
// Fallback to ascending order
m_processInfos[evCharger].effectivePhases = getAscendingPhasesForCount(m_processInfos.value(evCharger).effectivePhaseCount);
qCDebug(dcNymeaEnergy()) << "Effective phase count for charging including car phase limit:" << m_processInfos.value(evCharger).effectivePhaseCount
<< Electricity::convertPhasesToString(m_processInfos.value(evCharger).effectivePhases);
}
m_processInfos[evCharger].chargerPhaseLimitPower = evCharger->maxChargingCurrentMaxValue() * m_processInfos.value(evCharger).voltage;
qCDebug(dcNymeaEnergy()) << "Household phase limit:" << housePhaseLimitPower << "[W] |" << m_phasePowerConsumptionLimit << "A - EV charger phase limit:"
<< m_processInfos.value(evCharger).chargerPhaseLimitPower << "[W] |" << evCharger->maxChargingCurrentMaxValue() << "A";
m_processInfos[evCharger].phaseLimitPower = qMin(housePhaseLimitPower, m_processInfos.value(evCharger).chargerPhaseLimitPower);
// Note: we have to consider the losses while charging according to the car. That means we need more time to charge the required energy
m_processInfos[evCharger].totalChargeHours = m_processInfos.value(evCharger).carCapacityInWh / (m_processInfos.value(evCharger).phaseLimitPower * m_processInfos.value(evCharger).carChargingEnergyLossFactor * m_processInfos.value(evCharger).maxPossiblePhaseCount);
qCDebug(dcNymeaEnergy()).nospace() << "This car needs " << QTime::fromMSecsSinceStartOfDay(m_processInfos.value(evCharger).totalChargeHours * 60 * 60000).toString("hh:mm:ss") << " to charge from 0 to 100% at a charging rate of "
<< m_processInfos.value(evCharger).phaseLimitPower << "W / phase (including " << m_processInfos.value(evCharger).carChargingEnergyLoss << "% loss) on phase(s) " << Electricity::convertPhasesToString(m_processInfos.value(evCharger).maxPossiblePhases);
m_processInfos[evCharger].remainingPercentageToCharge = qMax(0, static_cast<int>(m_chargingInfos.value(evCharger->id()).targetPercentage()) - m_processInfos.value(evCharger).carBatteryLevel);
// Calculate time
m_processInfos[evCharger].remainingHoursToCharge = m_processInfos.value(evCharger).totalChargeHours * m_processInfos.value(evCharger).remainingPercentageToCharge / 100.0;
m_processInfos[evCharger].projectedEndTime = currentDateTime.addSecs(m_processInfos.value(evCharger).remainingHoursToCharge * 60 * 60);
qCDebug(dcNymeaEnergy()) << "This car still need to charge" << QTime::fromMSecsSinceStartOfDay(m_processInfos.value(evCharger).remainingHoursToCharge * 60 * 60000).toString("hh:mm:ss")
<< "to charge" << m_processInfos.value(evCharger).remainingPercentageToCharge << "% using a power rate of" << m_processInfos.value(evCharger).phaseLimitPower
<< "W on phase(s)" << Electricity::convertPhasesToString(m_processInfos.value(evCharger).maxPossiblePhases);
qCDebug(dcNymeaEnergy()) << "Projected endtime" << m_processInfos.value(evCharger).projectedEndTime.toString("dd.MM.yyyy hh:mm:ss");
m_processInfos[evCharger].spotMarketPercentageRequiredToday = m_chargingInfos.value(evCharger->thing()->id()).dailySpotMarketPercentage();
m_processInfos[evCharger].chargeFixedAmountSpotmarketToday = (m_chargingInfos.value(evCharger->thing()->id()).spotMarketChargingEnabled() && m_chargingInfos.value(evCharger->thing()->id()).dailySpotMarketPercentage() > 0);
m_processInfos[evCharger].spotMaketEnergyRequiredToday = assignedCar->stateValue("capacity").toDouble() * m_chargingInfos.value(evCharger->thing()->id()).dailySpotMarketPercentage() / 100.0;
qCDebug(dcNymeaEnergy()).nospace() << "A total of " << m_processInfos.value(evCharger).spotMaketEnergyRequiredToday << " kWh (" << m_chargingInfos.value(evCharger->thing()->id()).dailySpotMarketPercentage() << "%) shall be charged from spotmarket today";
}
// Reset all actions
m_chargingActions.clear();
foreach(EvCharger *evCharger, evChargers) {
m_chargingActions[evCharger][ChargingAction::ChargingActionIssuerSurplusCharging].setIssuer(ChargingAction::ChargingActionIssuerSurplusCharging);
m_chargingActions[evCharger][ChargingAction::ChargingActionIssuerSurplusCharging].setChargingEnabled(false);
m_chargingActions[evCharger][ChargingAction::ChargingActionIssuerSurplusCharging].setMaxChargingCurrent(m_processInfos.value(evCharger).minimalChargingCurrent);
m_chargingActions[evCharger][ChargingAction::ChargingActionIssuerSurplusCharging].setDesiredPhaseCount(m_processInfos.value(evCharger).minPossiblePhaseCount);
m_chargingActions[evCharger][ChargingAction::ChargingActionIssuerSpotMarketCharging].setIssuer(ChargingAction::ChargingActionIssuerSpotMarketCharging);
m_chargingActions[evCharger][ChargingAction::ChargingActionIssuerSpotMarketCharging].setChargingEnabled(false);
m_chargingActions[evCharger][ChargingAction::ChargingActionIssuerSpotMarketCharging].setMaxChargingCurrent(m_processInfos.value(evCharger).minimalChargingCurrent);
m_chargingActions[evCharger][ChargingAction::ChargingActionIssuerSpotMarketCharging].setDesiredPhaseCount(m_processInfos.value(evCharger).minPossiblePhaseCount);
m_chargingActions[evCharger][ChargingAction::ChargingActionIssuerTimeRequirement].setIssuer(ChargingAction::ChargingActionIssuerTimeRequirement);
m_chargingActions[evCharger][ChargingAction::ChargingActionIssuerTimeRequirement].setChargingEnabled(false);
m_chargingActions[evCharger][ChargingAction::ChargingActionIssuerTimeRequirement].setMaxChargingCurrent(m_processInfos.value(evCharger).minimalChargingCurrent);
m_chargingActions[evCharger][ChargingAction::ChargingActionIssuerTimeRequirement].setDesiredPhaseCount(m_processInfos.value(evCharger).minPossiblePhaseCount);
}
}
void SmartChargingManager::planSpotMarketCharging(const QDateTime &currentDateTime)
{
if (!m_spotMarketManager->enabled())
return;
bool schedulesChanged = false;
qCDebug(dcNymeaEnergy()) << "---------------- Plan spot market ---------------";
if (!m_spotMarketManager->available()) {
qCDebug(dcNymeaEnergy()) << "Spot maket manager enabled but not available. Skip spot market charging planing and all previouse planed schedules.";
// Clean up all schedules related to spotmarket
foreach (EvCharger *evCharger, m_chargingSchedules.keys()) {
schedulesChanged |= (m_chargingSchedules[evCharger].removeAllIssuer(ChargingAction::ChargingActionIssuerSpotMarketCharging) > 0);
m_processInfos[evCharger].chargingUntilTargetTimeUsingSpotmarket = false;
}
if (schedulesChanged)
emit chargingSchedulesChanged();
return;
}
// Get all charger with spotmarket enabled
qCDebug(dcNymeaEnergy()) << "Current time" << currentDateTime.toString("dd.MM.yyyy hh:mm:ss");
if (m_lastSpotMarketPlanning.isValid()) {
if (m_lastSpotMarketPlanning.date() != currentDateTime.date()) {
qCDebug(dcNymeaEnergy()) << "Day changed. Reset spotmarket data counters";
foreach (EvCharger *evCharger, m_evChargers) {
if (m_processInfos.contains(evCharger)) {
m_processInfos[evCharger].spotMarketEnergyChargedToday = 0;
}
}
}
}
m_lastSpotMarketPlanning = currentDateTime;
// 1. Filter out any EV chargers wich have nothing to do with spot market charging
EvChargers evChargers;
foreach (EvCharger *evCharger, m_evChargers) {
Thing *car = m_thingManager->findConfiguredThing(m_chargingInfos.value(evCharger->thing()->id()).assignedCarId());
if (!car) {
qCDebug(dcNymeaEnergy()) << evCharger->name() << "has no car assigned. Ignoring...";
schedulesChanged |= (m_chargingSchedules[evCharger].removeAllIssuer(ChargingAction::ChargingActionIssuerSpotMarketCharging) > 0);
m_processInfos[evCharger].chargingUntilTargetTimeUsingSpotmarket = false;
continue;
}
if (m_chargingInfos.value(evCharger->id()).chargingMode() == ChargingInfo::ChargingModeNormal) {
qCDebug(dcNymeaEnergy()) << evCharger->name() << "is set to manual mode. Ignoring...";
schedulesChanged |= (m_chargingSchedules[evCharger].removeAllIssuer(ChargingAction::ChargingActionIssuerSpotMarketCharging) > 0);
m_processInfos[evCharger].chargingUntilTargetTimeUsingSpotmarket = false;
continue;
}
if (!m_chargingInfos.value(evCharger->id()).spotMarketChargingEnabled()) {
qCDebug(dcNymeaEnergy()) << evCharger->name() << "spot market charging disabled. Ignoring...";
schedulesChanged |= (m_chargingSchedules[evCharger].removeAllIssuer(ChargingAction::ChargingActionIssuerSpotMarketCharging) > 0);
m_processInfos[evCharger].chargingUntilTargetTimeUsingSpotmarket = false;
continue;
}
if (m_chargingInfos.value(evCharger->id()).chargingMode() == ChargingInfo::ChargingModeEco && m_chargingInfos.value(evCharger->id()).dailySpotMarketPercentage() == 0) {
qCDebug(dcNymeaEnergy()) << evCharger->name() << "has no target time set nor any daily percentage to charge with spot market. Ignoring...";
schedulesChanged |= (m_chargingSchedules[evCharger].removeAllIssuer(ChargingAction::ChargingActionIssuerSpotMarketCharging) > 0);
m_processInfos[evCharger].chargingUntilTargetTimeUsingSpotmarket = false;
continue;
}
// Note: we plan also for chargers which are not available or not plugged in currently,
// because we need to keep the future plan since they might get available and we need to consider that
evChargers.append(evCharger);
}
qCDebug(dcNymeaEnergy()) << evChargers.count() << "chargers are set to ECO mode and have spot market enabled";
// 2. Create the charging schedules for each individual charger
foreach (EvCharger *evCharger, evChargers) {
const ChargingInfo info = m_chargingInfos.value(evCharger->id());
if (info.chargingMode() == ChargingInfo::ChargingModeEcoWithTargetTime) {
QDateTime endDateTime = info.nextEndTime(currentDateTime);
if (!endDateTime.isValid()) {
qCDebug(dcNymeaEnergy()).nospace() << "No valid target time set for " << evCharger->name();
if (info.chargingMode() == ChargingInfo::ChargingModeEcoWithTargetTime) {
qCDebug(dcNymeaEnergy()) << "Resetting to ECO mode without time.";
}
m_chargingInfos[evCharger->id()].setChargingMode(ChargingInfo::ChargingModeEco);
storeChargingInfo(m_chargingInfos.value(evCharger->id()));
emit chargingInfoChanged(m_chargingInfos.value(evCharger->id()));
continue;
}
QDateTime internalEndDateTime = endDateTime.addSecs(-10 * 60);
// Check how many days in the future this target time is.
// If we have schedules up to the target time, we plan them as good as possible.
if (m_spotMarketManager->currentProvider()->scoreEntries().availableUpTo(endDateTime)) {
// We have enougth data to plan as good as possible to the target time
ScoreEntries availableEntries;
for (int i = 0; i < m_spotMarketManager->currentProvider()->scoreEntries().count(); i++) {
const ScoreEntry score = m_spotMarketManager->currentProvider()->scoreEntries().at(i);
if (score.endDateTime() <= endDateTime) {
availableEntries.append(score);
}
}
// Let's weight the available entries
ScoreEntries weightedScoreEntries = SpotMarketManager::weightScoreEntries(availableEntries);
double minutesToCharge = m_processInfos[evCharger].remainingHoursToCharge * 60;
qCDebug(dcNymeaEnergy()) << "Having spot market available until target time. Distribute" << minutesToCharge << "minutes using all available spot market data.";
// We scheduled the amount to charge using the spot market, we can skip later the target time schedule.
m_processInfos[evCharger].chargingUntilTargetTimeUsingSpotmarket = true;
bool lockCurrentSchedule = (info.chargingState() == ChargingInfo::ChargingStateSpotMarketCharging && evCharger->charging());
TimeFrames timeFrames = m_spotMarketManager->scheduleChargingTime(currentDateTime, weightedScoreEntries, minutesToCharge, m_configuration->minimumScheduleDuration(), lockCurrentSchedule);
ChargingSchedules schedules;
foreach (const TimeFrame &timeFrame, timeFrames) {
ChargingSchedule schedule(evCharger->id(), timeFrame);
schedule.setAction(ChargingAction(true, evCharger->maxChargingCurrentMaxValue(), m_processInfos.value(evCharger).maxPossiblePhaseCount, ChargingAction::ChargingActionIssuerSpotMarketCharging));
schedules.append(schedule);
}
qCDebug(dcNymeaEnergy()) << "Distributed charging schedules" << schedules;
ChargingSchedules currentSchedules = m_chargingSchedules.value(evCharger).filterByIssuer(ChargingAction::ChargingActionIssuerSpotMarketCharging);
if (currentSchedules != schedules) {
schedulesChanged = true;
m_chargingSchedules[evCharger].removeAllIssuer(ChargingAction::ChargingActionIssuerSpotMarketCharging);
m_chargingSchedules[evCharger].append(schedules);
}
// Done with this charger regarding spot market planing
continue;
} else {
// We don't have data up to the given end date time, le't split up the load into days
// Check how much we want to charge for today and how much we already loaded to meet the target in the future
double hoursUntilEndDateTime = currentDateTime.secsTo(endDateTime) / 3600.0;
qCDebug(dcNymeaEnergy()) << "Not enough data available to plan to the target time. Spot market data for"
<< m_spotMarketManager->currentProvider()->scoreEntries().count() << "of the required"
<< hoursUntilEndDateTime << "hours are available.";
if (hoursUntilEndDateTime < 36) {
// If the end date time is less than 36 h in the future and we have not enougth data, we probably get them soon...
// We plan proportial the amount of energy for that slot until we know it until the end.
//double ratio = 1 - ((hoursUntilEndDateTime - m_spotMarketManager->currentProvider()->scoreEntries().count()) / hoursUntilEndDateTime);
qCDebug(dcNymeaEnergy()) << "The target time is in" << hoursUntilEndDateTime << "hours. We probably get more information soon. Scheduling"
<< m_configuration->spotMarketChargePredictableEnergyPercentage() * 100 << "% of the energy within the next known" << m_spotMarketManager->currentProvider()->scoreEntries().count() << "hours.";
// Set the required energy for today
// If there is also a certain amount of enrgy to charge from spot market today, let's use the higher amount:
// either daily percentage or required energy to meet the target time
double remainingPercentagePartial = m_processInfos.value(evCharger).remainingPercentageToCharge / 100.0 * m_configuration->spotMarketChargePredictableEnergyPercentage();
double percentageToCharge = qMax(remainingPercentagePartial, m_chargingInfos.value(evCharger->id()).dailySpotMarketPercentage() / 100.0);
double energyRequiredToday = percentageToCharge * m_processInfos.value(evCharger).carCapacityInkWh;
m_processInfos[evCharger].spotMaketEnergyRequiredToday = energyRequiredToday;
m_processInfos[evCharger].chargeFixedAmountSpotmarketToday = (energyRequiredToday > 0);
m_processInfos[evCharger].spotMarketPercentageRequiredToday = energyRequiredToday * 100.0 / m_processInfos.value(evCharger).carCapacityInkWh;
if (m_processInfos.value(evCharger).chargeFixedAmountSpotmarketToday) {
qCDebug(dcNymeaEnergy()) << "A total of" << m_processInfos.value(evCharger).spotMaketEnergyRequiredToday
<< "kWh (" << m_processInfos.value(evCharger).spotMarketPercentageRequiredToday
<< "%) battery is required for today to meet the target time in the future.";
}
// Schedule the rest normal time requirement...
m_processInfos[evCharger].chargingUntilTargetTimeUsingSpotmarket = false;
} else {
// Calculate on day bases, 3 days int he future, lets charge every day 1/3
qCDebug(dcNymeaEnergy()) << "TODO: plan for more than 24h into the future";
}
// The logic for charging a certain amount of energy within one day will is the same as fo the daily percentage.
// The only difference is that this logic will say how much per day until we have enought schedules to reach the target time.
}
} else {
// We have not scheduled the amount to charge using the spot market,
// we must plan later the target time schedule.
m_processInfos[evCharger].chargingUntilTargetTimeUsingSpotmarket = false;
}
// Check if we have to charge a certain amount of energy today using spotmarket
if (m_processInfos.value(evCharger).chargeFixedAmountSpotmarketToday) {
// Either trough the dayly min percentage to charge or if our target time is far
// in the future and we charge some energy proportional to the distance
Thing *car = m_thingManager->findConfiguredThing(info.assignedCarId());
double totalEnergyCharged = car->property("totalEnergyCharged").toDouble();
if (!qFuzzyCompare(totalEnergyCharged, m_processInfos.value(evCharger).lastTotalEnergyCharged)) {
m_processInfos[evCharger].spotMarketEnergyChargedToday += totalEnergyCharged - m_processInfos.value(evCharger).lastTotalEnergyCharged;
m_processInfos[evCharger].lastTotalEnergyCharged = totalEnergyCharged;
// Check if we already reached our target for today, in which case we are done with this charger regarding spotmarket charging
if (m_processInfos.value(evCharger).spotMarketEnergyChargedToday >= m_processInfos.value(evCharger).spotMaketEnergyRequiredToday) {
// Remove any planed schedule, we are done here
m_chargingSchedules[evCharger].removeAllIssuer(ChargingAction::ChargingActionIssuerSpotMarketCharging);
continue;
}
}
// How many kWh we want to charge today (accoring to car percentage)
qCDebug(dcNymeaEnergy()) << "Charged" << m_processInfos.value(evCharger).spotMarketEnergyChargedToday << "kWh energy today so far. A total of"
<< m_processInfos.value(evCharger).spotMaketEnergyRequiredToday << "kWh spot market energy is required for today.";
if (m_processInfos.value(evCharger).spotMarketEnergyChargedToday > m_processInfos.value(evCharger).spotMaketEnergyRequiredToday) {
qCDebug(dcNymeaEnergy()) << "Already charged enougth energy for today.";
continue;
}
// We need to take the loss into account, we actually need more energy
double remainingEnergyToCharge = m_processInfos.value(evCharger).spotMaketEnergyRequiredToday - m_processInfos.value(evCharger).spotMarketEnergyChargedToday;
// Note: we already have the power loss in the total hours, so we should have that taken into account in the remaining time too
// x / remainingEnergyToCharge = totalHours / totalEnergy
double remeiningHours = m_processInfos.value(evCharger).totalChargeHours * remainingEnergyToCharge / m_processInfos.value(evCharger).carCapacityInkWh;
double percentageCharged = 0;
if (m_processInfos.value(evCharger).spotMaketEnergyRequiredToday != 0) {
// x : spotMarketEnergyChargedToday = 100 : totalEnergy
percentageCharged = m_processInfos.value(evCharger).spotMarketEnergyChargedToday * 100.0 / m_processInfos.value(evCharger).carTotalEnergy;
}
double remainingPercentage = m_processInfos.value(evCharger).spotMarketPercentageRequiredToday - percentageCharged;
double totalMinutesToday = m_processInfos.value(evCharger).totalChargeHours * 60.0 * m_processInfos.value(evCharger).spotMarketPercentageRequiredToday / 100.0;
double remainingChargeMinutesToday = remeiningHours * 60;
qCDebug(dcNymeaEnergy()).nospace() << "Remaining " << remainingPercentage << "% (" << remainingChargeMinutesToday << " min) to charge today from total spotmarket "
<< m_processInfos.value(evCharger).spotMarketPercentageRequiredToday
<< "% (" << totalMinutesToday << " min). Using "
<< m_processInfos.value(evCharger).chargerPhaseLimitPower << "W on "
<< m_processInfos.value(evCharger).maxPossiblePhaseCount << " phase(s) | "
<< Electricity::convertPhasesToString(m_processInfos.value(evCharger).maxPossiblePhases);
// Distribute the remaining time to the spot market data of this day. If we are currently charging from the spot market,
// lock the current schedule so it will be finished and not postboned creating a jitter
bool lockCurrentSchedule = (info.chargingState() == ChargingInfo::ChargingStateSpotMarketCharging && evCharger->charging());
TimeFrames timeFrames = m_spotMarketManager->scheduleCharingTimeForToday(currentDateTime, remainingChargeMinutesToday, m_configuration->minimumScheduleDuration(), lockCurrentSchedule);
ChargingSchedules schedules;
foreach (const TimeFrame &timeFrame, timeFrames) {
ChargingSchedule schedule(evCharger->id(), timeFrame);
schedule.setAction(ChargingAction(true, evCharger->maxChargingCurrentMaxValue(), m_processInfos.value(evCharger).maxPossiblePhaseCount, ChargingAction::ChargingActionIssuerSpotMarketCharging));
schedules.append(schedule);
}
qCDebug(dcNymeaEnergy()) << "Distributed spot market" << schedules;
ChargingSchedules currentSchedules = m_chargingSchedules.value(evCharger).filterByIssuer(ChargingAction::ChargingActionIssuerSpotMarketCharging);
if (currentSchedules != schedules) {
schedulesChanged = true;
m_chargingSchedules[evCharger].removeAllIssuer(ChargingAction::ChargingActionIssuerSpotMarketCharging);
m_chargingSchedules[evCharger].append(schedules);
}
}
}
if (schedulesChanged) {
emit chargingSchedulesChanged();
}
// Derive any actions related to spot market charging for the current datatime
foreach (EvCharger *evCharger, m_evChargers) {
// If there are schedules for this EV charger (spotmarket or user created), charge now...
if (m_chargingSchedules.contains(evCharger)) {
ChargingSchedule currentSchedule = m_chargingSchedules.value(evCharger).getChargingSchedule(currentDateTime);
if (currentSchedule.isValid() && currentSchedule.action().chargingEnabled() && currentSchedule.action().issuer() == ChargingAction::ChargingActionIssuerSpotMarketCharging) {
m_chargingActions[evCharger][ChargingAction::ChargingActionIssuerSpotMarketCharging] = currentSchedule.action();
m_chargingActions[evCharger][ChargingAction::ChargingActionIssuerSpotMarketCharging].setIssuer(ChargingAction::ChargingActionIssuerSpotMarketCharging);
m_processInfos[evCharger].currentSchedule = currentSchedule;
continue;
}
}
}
}
void SmartChargingManager::planSurplusCharging(const QDateTime &currentDateTime)
{
if (!m_rootMeter) {
qCDebug(dcNymeaEnergy()) << "No root meter configured. Skipping surplus charging planing";
return;
}
qCDebug(dcNymeaEnergy()) << "---------------- Plan surplus charging ---------------";
qCDebug(dcNymeaEnergy()) << "Current time" << currentDateTime.toString("dd.MM.yyyy hh:mm:ss");
// 1. Find all the chargers that are set to eco mode, we'll leave them alone in normal mode
EvChargers evChargers;
foreach (EvCharger *evCharger, m_evChargers) {
Thing *assignedCar = m_thingManager->findConfiguredThing(m_chargingInfos.value(evCharger->id()).assignedCarId());
if (!assignedCar) {
qCDebug(dcNymeaEnergy()) << evCharger->name() << "has no car assigned. Ignoring...";
if (m_chargingSchedules[evCharger].removeAllIssuer(ChargingAction::ChargingActionIssuerTimeRequirement) > 0)
emit chargingSchedulesChanged();
continue;
}
const ChargingInfo info = chargingInfo(evCharger->id());
if (info.chargingMode() == ChargingInfo::ChargingModeNormal) {
qCDebug(dcNymeaEnergy()) << evCharger->name() << "is set to manual mode. Ignoring...";
if (m_chargingSchedules[evCharger].removeAllIssuer(ChargingAction::ChargingActionIssuerTimeRequirement) > 0)
emit chargingSchedulesChanged();
continue;
}
evChargers.append(evCharger);
}
qCDebug(dcNymeaEnergy()) << evChargers.count() << "EV chargers are set to ECO mode";
// 2. Find the chargers that need to charge full speed (e.g. because user wants it fully charged soon)
double addedPower = 0;
QMutableListIterator<EvCharger *> it(evChargers);
QHash<EvCharger*, Electricity::Phases> chargersNeedingFullSpeed;
while (it.hasNext()) {
EvCharger *evCharger = it.next();
ChargingProcessInfo processInfo = m_processInfos.value(evCharger);
qCDebug(dcNymeaEnergy()) << "**** Evaluating" << evCharger->name();
ChargingInfo info = m_chargingInfos.value(evCharger->id());
if (info.chargingMode() != ChargingInfo::ChargingModeEcoWithTargetTime) {
qCDebug(dcNymeaEnergy()) << evCharger->name() << "does not use a target time.";
m_chargingSchedules[evCharger].removeAllIssuer(ChargingAction::ChargingActionIssuerTimeRequirement);
continue;
}
QDateTime endDateTime = info.nextEndTime(currentDateTime);
if (!endDateTime.isValid()) {
qCDebug(dcNymeaEnergy()).nospace() << "No valid target time set for " << evCharger->name();
if (info.chargingMode() == ChargingInfo::ChargingModeEcoWithTargetTime) {
qCDebug(dcNymeaEnergy()) << "Resetting to ECO mode without time.";
}
m_chargingInfos[evCharger->id()].setChargingMode(ChargingInfo::ChargingModeEco);
storeChargingInfo(m_chargingInfos.value(evCharger->id()));
emit chargingInfoChanged(m_chargingInfos.value(evCharger->id()));
continue;
}
if (m_processInfos[evCharger].chargingUntilTargetTimeUsingSpotmarket) {
qCDebug(dcNymeaEnergy()) << "Have a target time available and spotmarket charging enabled. Skipping force check...";
m_chargingSchedules[evCharger].removeAllIssuer(ChargingAction::ChargingActionIssuerTimeRequirement);
continue;
}
QDateTime internalEndDateTime = endDateTime.addSecs(-10 * 60);
ChargingAction action(true, evCharger->maxChargingCurrentMaxValue(), m_processInfos.value(evCharger).maxPossiblePhaseCount, ChargingAction::ChargingActionIssuerTimeRequirement);
// We'll be trying to finish 10 min earlier (TODO: To be evaluated if really needed in practice)
if (processInfo.remainingPercentageToCharge > 0 && processInfo.projectedEndTime > internalEndDateTime) {
qCDebug(dcNymeaEnergy()) << "We need to charge" << evCharger->name() << "immediately to meet the target! Internal target time:"
<< internalEndDateTime.toString("dd.MM.yyyy hh:mm:ss") << "projected endtime:" << processInfo.projectedEndTime.toString("dd.MM.yyyy hh:mm:ss");
qCDebug(dcNymeaEnergy()) << "Charging schedule active for" << evCharger->name() << m_processInfos.value(evCharger).currentSchedule;
// Create the schedule for ui from now till the end
ChargingSchedule schedule(evCharger->id());
schedule.setEndDateTime(internalEndDateTime);
schedule.setStartDateTime(currentDateTime);
schedule.setAction(action);
m_chargingSchedules[evCharger].removeAllIssuer(ChargingAction::ChargingActionIssuerTimeRequirement);
m_chargingSchedules[evCharger].append(schedule);
// Force charging now
m_chargingActions[evCharger][ChargingAction::ChargingActionIssuerTimeRequirement] = action;
// Wallbox has been handled. Remove from further logic
it.remove();
} else {
qCDebug(dcNymeaEnergy()) << "No need to force charging on" << evCharger->name() << ", there's still enough time for optimization. Internal target time:"
<< internalEndDateTime.toString("dd.MM.yyyy hh:mm:ss") << "projected endtime:" << processInfo.projectedEndTime.toString("dd.MM.yyyy hh:mm:ss");;
// Remove all time requiment based schedules, if needed, we re plan it here...
m_chargingSchedules[evCharger].removeAllIssuer(ChargingAction::ChargingActionIssuerTimeRequirement);
// Let's see how much we already scheduled so far using other planings like spotmarket, plan the rest as time requirement schedule
int alreadyScheduledMinutes = 0;
foreach (const ChargingSchedule &existingSchedule, m_chargingSchedules.value(evCharger)) {
alreadyScheduledMinutes += existingSchedule.durationMinutes();
}
int remainingMinutesTotal = qCeil(m_processInfos[evCharger].remainingHoursToCharge * 60);
int remainingMinutesToCharge = remainingMinutesTotal - alreadyScheduledMinutes;
if (alreadyScheduledMinutes > 0) {
qCDebug(dcNymeaEnergy()) << "Have already scheduled" << alreadyScheduledMinutes << "minutes from a total of"
<< remainingMinutesTotal << "minutes. Still need to schedule"
<< remainingMinutesToCharge << "minutes using time requirements.";
}
ChargingSchedule schedule(evCharger->id());
schedule.setEndDateTime(internalEndDateTime);
schedule.setStartDateTime(internalEndDateTime.addSecs(-remainingMinutesToCharge * 60));
schedule.setAction(action);
m_chargingSchedules[evCharger].append(schedule);
}
}
// 3. distribute remaining allowance between remaining chargers
// If negative, we've got some spare energy to use, if positive, we should lower charging rates or even stop
double currentLoad = m_rootMeter->currentPower();
// Adding some mechanism to prioritize house energy storage before the car, or steal
double fromBatteries = 0;
// Consider all batteries as one, to keep things as simple as possible
double totalBatteryLevel = 0;
double totalBatteryPower = 0;
int storageCount = 0;
int batteryLevelConsiderationPercentage = qRound(m_batteryLevelConsideration * 100);
foreach (Thing *storage, m_thingManager->configuredThings().filterByInterface("energystorage")) {
storageCount ++;
double batteryLevel = storage->stateValue("batteryLevel").toDouble();
double currentPower = storage->stateValue("currentPower").toDouble();
totalBatteryLevel += batteryLevel; // Sum up percentage and create average later using storageCount
totalBatteryPower += currentPower; // Create a total sum of all battery powers
}
totalBatteryLevel = qRound(totalBatteryLevel / storageCount);
if (storageCount > 0) {
qCDebug(dcNymeaEnergy()) << "Having" << storageCount << "batteries. Overall battery level is" << totalBatteryLevel << "% and overall battery power is" << totalBatteryPower << "W";
// If we are currently charging in eco mode and the battery level is under the consideration level,
// we should stop charging and prioritize the battery
if (totalBatteryLevel < batteryLevelConsiderationPercentage) {
// We are under the consideration level for the batteries, check if we have active chargers left, we should stop loading now
it.toFront();
while (it.hasNext()) {
EvCharger *evCharger = it.next();
const ChargingProcessInfo processInfo = m_processInfos.value(evCharger);
if (evCharger->charging()) {
// Let's pause the charging process, if there will be surplus available, it will be considerated in the next iterations...
qCDebug(dcNymeaEnergy()) << "The battery is under the consideration level. Pausing the charging on" << evCharger->name();
m_chargingActions[evCharger][ChargingAction::ChargingActionIssuerSurplusCharging] = ChargingAction(false, evCharger->maxChargingCurrent(), evCharger->phaseCount(), ChargingAction::ChargingActionIssuerSurplusCharging);
// Remove this charger from further logic
it.remove();
}
}
}
if (totalBatteryPower < 0) {
// If the battery is discharging, we'll have to add that to the load in order to not end up draining the battery
fromBatteries += -totalBatteryPower;
} else if (totalBatteryPower > 0) {
// Battery is charging
if (totalBatteryLevel >= batteryLevelConsiderationPercentage) {
qCDebug(dcNymeaEnergy()) << "The battery has reached the consideration level" << batteryLevelConsiderationPercentage << "%. The battery power will used for surplus charging.";
fromBatteries += -totalBatteryPower;
} else {
// If the battery is charging, we'll add 500W to the load to leave that for the battery to "eat" up so the battery has priority
qCDebug(dcNymeaEnergy()) << "The battery has not reached yet the consideration level" << batteryLevelConsiderationPercentage << "%. Prioritize the energy storage and add 500W buffer).";
fromBatteries += 500;
}
}
} else {
qCDebug(dcNymeaEnergy()) << "There is no energy storage available to consider.";
}
// Add the evaluated battery load to the current root meter load in order to have the actual available power
currentLoad += fromBatteries;
// In the previous step we may have added some load which doesn't reflect on the root meter yet but will likely be in the next metering cycle
currentLoad += addedPower;
qCDebug(dcNymeaEnergy()).nospace() << "Current load on root meter: " << currentLoad << "W ( " << (currentLoad / 230) << "[A] ) Phases: A: " << m_rootMeter->currentPhaseA() << "[A], B: " << m_rootMeter->currentPhaseB() << "[A], C: " << m_rootMeter->currentPhaseC() << "[A]. Added in previous step: " << addedPower << "[W] (" << (addedPower / 230) << "[A]). From batteries: " << fromBatteries << "[W] (" << (fromBatteries / 230) << "[A])";
if (!evChargers.isEmpty()) {
qCDebug(dcNymeaEnergy()) << evChargers.count() << "chargers in ECO mode and can be optimized.";
it.toFront();
double allowanceInWatt = -currentLoad;
double allowanceInAmpere = allowanceInWatt / 230;
while (it.hasNext()) {
EvCharger *evCharger = it.next();
const ChargingProcessInfo processInfo = m_processInfos.value(evCharger);
qCDebug(dcNymeaEnergy()).nospace() << "Current charger setpoint: " << evCharger->maxChargingCurrent() << "A (" << (evCharger->maxChargingCurrent() * 230) << "W), Metered: " << (evCharger->currentPower() / 230) << "A (" << evCharger->currentPower() << "W)";
double newRawValue = (evCharger->currentPower() / 230) + allowanceInAmpere;
// Check if we need to switch phases, theoretically
uint desiredPhaseCount = processInfo.maxPossiblePhaseCount;
Electricity::Phases desiredPhases = getAscendingPhasesForCount(desiredPhaseCount);
if (m_processInfos.value(evCharger).canSwitchPhaseCount) {
uint possiblePhaseCount = getBestPhaseCount(evCharger, newRawValue);
desiredPhaseCount = qMin(processInfo.carPhaseCount, possiblePhaseCount);
desiredPhaseCount = qMin(desiredPhaseCount, processInfo.carPhaseCount);
if (desiredPhaseCount == 1) {
desiredPhases = Electricity::PhaseA;
} else {
desiredPhases = Electricity::PhaseAll;
}
}
newRawValue /= desiredPhaseCount;
double minimumRequiredAmps = processInfo.minimalChargingCurrent * m_acquisitionTolerance;
qCDebug(dcNymeaEnergy()).nospace() << "Charger " << evCharger->name() << " uses " << desiredPhaseCount << " "
<< (desiredPhaseCount == 1 ? "phase" : " phases")
<< ". Min required: " << minimumRequiredAmps
<< "A (" << (minimumRequiredAmps * 230) << "W) per phase with "
<< m_acquisitionTolerance << " aquisition tolerance.";
bool chargingEnabled = newRawValue >= minimumRequiredAmps;
// Adjust down to not overload any phases.
double allowanceOnRootMeterAmpere = m_rootMeter->calculateAllowanceAmpere(desiredPhases, m_phasePowerConsumptionLimit);
double newValue = qMin(newRawValue, allowanceOnRootMeterAmpere);
uint newValueInt = newValue >= 0 ? qRound(newValue) : 0;
qCDebug(dcNymeaEnergy()).nospace() << "### Surplus would adjust evcharger " << evCharger->name() << " to total " << newRawValue << "A (" << (newRawValue * 230) << "W) * " << desiredPhaseCount << " phases.";
qCDebug(dcNymeaEnergy()).nospace() << "# Charger limits: " << evCharger->maxChargingCurrentMinValue() << " - " << evCharger->maxChargingCurrentMaxValue() << " Root meter allowance: " << allowanceOnRootMeterAmpere << " A (" << (allowanceInAmpere * 230) << " W)";
qCDebug(dcNymeaEnergy()).nospace() << "# Theoretically: " << newValue << "A (" << (newValue * 230) << "W) * " << desiredPhaseCount << " Phases. " << (chargingEnabled ? "ON" : "OFF");
if (newValueInt <= evCharger->maxChargingCurrentMinValue())
newValueInt = evCharger->maxChargingCurrentMinValue();
if (newValueInt >= evCharger->maxChargingCurrentMaxValue())
newValueInt = evCharger->maxChargingCurrentMaxValue();
qCDebug(dcNymeaEnergy()).nospace() << "# Effective: " << newValueInt << "A (" << (newValueInt * 230) << "W) * " << desiredPhaseCount << " Phases. " << (chargingEnabled ? "ON" : "OFF");
m_chargingActions[evCharger][ChargingAction::ChargingActionIssuerSurplusCharging] = ChargingAction(chargingEnabled, newValueInt, desiredPhaseCount, ChargingAction::ChargingActionIssuerSurplusCharging);
// If we're charging on solar power only, remove this charger from further logic
if (chargingEnabled) {
it.remove();
}
}
}
}
void SmartChargingManager::adjustEvChargers(const QDateTime &currentDateTime)
{
qCDebug(dcNymeaEnergy()) << "---------------- Adjusting chargers ---------------";
qCDebug(dcNymeaEnergy()) << "Current time" << currentDateTime.toString("dd.MM.yyyy hh:mm:ss");
foreach (EvCharger *evCharger, m_chargingActions.keys()) {
if (m_chargingInfos.value(evCharger->id()).chargingMode() == ChargingInfo::ChargingModeNormal) {
qCDebug(dcNymeaEnergy()) << evCharger->name() << "is set to manual mode. Ignoring...";
continue;
}
ChargingProcessInfo processInfo = m_processInfos.value(evCharger);
// Final schedules
qCDebug(dcNymeaEnergy()) << "Final charging schedules" << evCharger->thing();
qCDebug(dcNymeaEnergy()) << m_chargingSchedules.value(evCharger);
emit chargingSchedulesChanged();
// We prioritize here what we should do...
// Note: here is where we need to perform the schedule taks depending on what each charger wants to do.
// For now: first come first serve
// 1. Time requirement
if (m_chargingActions.value(evCharger).value(ChargingAction::ChargingActionIssuerTimeRequirement).chargingEnabled()) {
// Note: this will only be set in surplus charging, so we don't need to check for an existing root meter, that already happened in the surplus planing phase
// Determine what the charger already uses
double currentPower = evCharger->currentPower() / processInfo.effectivePhaseCount;
qCDebug(dcNymeaEnergy()).nospace() << "Charger " << evCharger->name() << " is connected on phase(s): " << processInfo.effectivePhaseCount << " and uses " << currentPower << " W (" << (currentPower / 230) << "A) per phase";
double allowancePerPhase = m_rootMeter->calculateAllowanceAmpere(processInfo.effectivePhases, m_phasePowerConsumptionLimit);
qCDebug(dcNymeaEnergy()).nospace() << "Current load on root meter: " << m_rootMeter->currentPower() << "[W] ( " << (m_rootMeter->currentPower() / 230) << "[A] ) Phases: A: " << m_rootMeter->currentPhaseA() << "[A], B: " << m_rootMeter->currentPhaseB() << "[A], C: " << m_rootMeter->currentPhaseC() << "[A]";
// Reducing the max allowance by 1A (230W) so that we're not super close to the limit and are hitting the ovreload protection
// when the user turns on a midium sized consumer.
allowancePerPhase -= 1;
allowancePerPhase += currentPower / 230;
double maxChargingCurrent = qMin((double)evCharger->maxChargingCurrentMaxValue(), allowancePerPhase);
qCDebug(dcNymeaEnergy()) << evCharger->name() << "current upper limit:" << evCharger->maxChargingCurrentMaxValue() << "A - Per phase upper limit:" << allowancePerPhase << "A" << "Using:" << maxChargingCurrent << "A";
maxChargingCurrent = qMax((double)evCharger->maxChargingCurrentMinValue(), maxChargingCurrent);
maxChargingCurrent = qFloor(maxChargingCurrent);
m_chargingActions[evCharger][ChargingAction::ChargingActionIssuerTimeRequirement].setMaxChargingCurrent(maxChargingCurrent);
executeChargingAction(evCharger, m_chargingActions.value(evCharger).value(ChargingAction::ChargingActionIssuerTimeRequirement), currentDateTime);
if (m_chargingInfos.value(evCharger->thing()->id()).chargingState() != ChargingInfo::ChargingStateTimeRequirement) {
m_chargingInfos[evCharger->thing()->id()].setChargingState(ChargingInfo::ChargingStateTimeRequirement);
emit chargingInfoChanged(m_chargingInfos[evCharger->thing()->id()]);
}
continue;
}
// 2. Surplus charging
if (m_chargingActions.value(evCharger).value(ChargingAction::ChargingActionIssuerSurplusCharging).chargingEnabled()) {
executeChargingAction(evCharger, m_chargingActions.value(evCharger).value(ChargingAction::ChargingActionIssuerSurplusCharging), currentDateTime);
// // TODO: adjust re-check if we still have enougth energy for this
if (m_chargingInfos[evCharger->thing()->id()].chargingState() != ChargingInfo::ChargingStateSurplusCharging) {
m_chargingInfos[evCharger->thing()->id()].setChargingState(ChargingInfo::ChargingStateSurplusCharging);
emit chargingInfoChanged(m_chargingInfos[evCharger->thing()->id()]);
}
continue;
}
// 3. Spotmarket charging
if (m_chargingActions.value(evCharger).value(ChargingAction::ChargingActionIssuerSpotMarketCharging).chargingEnabled()) {
executeChargingAction(evCharger, m_chargingActions.value(evCharger).value(ChargingAction::ChargingActionIssuerSpotMarketCharging), currentDateTime);
// // TODO: adjust re-check if we still have enougth energy for this
if (m_chargingInfos[evCharger->thing()->id()].chargingState() != ChargingInfo::ChargingStateSpotMarketCharging) {
m_chargingInfos[evCharger->thing()->id()].setChargingState(ChargingInfo::ChargingStateSpotMarketCharging);
emit chargingInfoChanged(m_chargingInfos[evCharger->thing()->id()]);
}
continue;
}
// 4. Else ... idle, switch off
qCDebug(dcNymeaEnergy()).nospace() << "Setting " << evCharger->name() << " to power OFF. Idle state.";
evCharger->setChargingEnabled(false, currentDateTime);
if (m_chargingInfos[evCharger->thing()->id()].chargingState() != ChargingInfo::ChargingStateIdle) {
m_chargingInfos[evCharger->thing()->id()].setChargingState(ChargingInfo::ChargingStateIdle);
emit chargingInfoChanged(m_chargingInfos[evCharger->thing()->id()]);
}
}
}
void SmartChargingManager::updateManualSoCsWithMeter(EnergyLogs::SampleRate sampleRate, const ThingPowerLogEntry &entry)
{
Q_UNUSED(sampleRate)
EvCharger *charger = m_evChargers.value(entry.thingId());
if (!charger) {
return;
}
Thing *car = m_thingManager->findConfiguredThing(m_chargingInfos.value(entry.thingId()).assignedCarId());
if (!car || !car->thingClass().hasStateType("batteryLevel") || !car->thingClass().stateTypes().findByName("batteryLevel").writable()) {
return;
}
double chargingPower = entry.currentPower();
double addedkWh = chargingPower / 60000.0;
addedkWh *= m_processInfos.value(charger).carChargingEnergyLossFactor;
double capacity = car->stateValue("capacity").toDouble();
// x : 100 = addedkWh : capacity
double addedPercentage = addedkWh * 100 / capacity;
double lastPreciseSoC = car->property("preciseSoC").toDouble();
if (lastPreciseSoC == 0) {
lastPreciseSoC = car->stateValue("batteryLevel").toDouble();
}
double newPreciseSoC = qMin(100.0, lastPreciseSoC + addedPercentage);
double newEnergyCharged = car->property("totalEnergyCharged").toDouble() + addedkWh;
qCDebug(dcNymeaEnergy()) << "Updating manual SoC (metered) for" << car->name() << chargingPower << "W"
<< QTime::fromMSecsSinceStartOfDay(60000).toString() << addedPercentage << "% -> new soc" << newPreciseSoC
<< "% total energy:" << newEnergyCharged << "kWh";
car->setProperty("totalEnergyCharged", newEnergyCharged);
car->setProperty("preciseSoC", newPreciseSoC);
if (car->stateValue("batteryLevel").toInt() != qRound(newPreciseSoC)) {
ActionType batteryLevelActionType = car->thingClass().actionTypes().findByName("batteryLevel");
Action action(batteryLevelActionType.id(), car->id(), Action::TriggeredByRule);
action.setParams({Param(batteryLevelActionType.id(), qRound(newPreciseSoC))});
m_thingManager->executeAction(action);
}
}
void SmartChargingManager::updateManualSoCsWithoutMeter(const QDateTime &currentDateTime)
{
foreach (EvCharger *charger, m_evChargers) {
// Chargers with metering capabilities will be updated with more precise values the energy logger
if (charger->hasPowerMeter()) {
continue;
}
ChargingInfo chargingInfo = m_chargingInfos.value(charger->id());
Thing *car = m_thingManager->findConfiguredThing(chargingInfo.assignedCarId());
if (!car || !car->thingClass().hasStateType("batteryLevel") || !car->thingClass().stateTypes().findByName("batteryLevel").writable()) {
continue;
}
double lastPreciseSoC = car->property("preciseSoC").toDouble();
QDateTime lastCalculation = car->property("lastSoCCalculation").toDateTime();
if (lastCalculation.isNull()) {
car->setProperty("preciseSoC", car->stateValue("batteryLevel"));
car->setProperty("lastSoCCalculation", currentDateTime);
continue;
}
if (!charger->charging()) {
car->setProperty("lastSoCCalculation", currentDateTime);
continue;
}
qulonglong duration = lastCalculation.msecsTo(currentDateTime);
double chargingPower = charger->currentPower();
double addedkWh = chargingPower * duration / 1000 / 60 / 60 / 1000;
// There are about 10% of losses during charging...
addedkWh *= m_processInfos.value(charger).carChargingEnergyLossFactor;
double capacity = car->stateValue("capacity").toDouble();
// x : 100 = addedkWh : capacity
double addedPercentage = addedkWh * 100 / capacity;
double newPreciseSoC = qMin(100.0, lastPreciseSoC + addedPercentage);
double newTotal = car->property("totalEnergyCharged").toDouble() + addedkWh;
qCDebug(dcNymeaEnergy()) << "Updating manual SoC for" << car->name() << chargingPower << "W" << QTime::fromMSecsSinceStartOfDay(duration).toString() << addedPercentage << "% -> new soc" << newPreciseSoC << "%" << "Total:" << newTotal << "kWh";
car->setProperty("totalEnergyCharged", newTotal);
car->setProperty("preciseSoC", newPreciseSoC);
car->setProperty("lastSoCCalculation", currentDateTime);
if (car->stateValue("batteryLevel").toInt() != qRound(newPreciseSoC)) {
ActionType batteryLevelActionType = car->thingClass().actionTypes().findByName("batteryLevel");
Action action(batteryLevelActionType.id(), car->id(), Action::TriggeredByRule);
action.setParams({Param(batteryLevelActionType.id(), qRound(newPreciseSoC))});
m_thingManager->executeAction(action);
}
}
}
void SmartChargingManager::onThingAdded(Thing *thing)
{
if (thing->thingClass().interfaces().contains("evcharger")) {
EvCharger *evCharger = new EvCharger(m_thingManager, thing);
evCharger->setChargingEnabledLockDuration(m_configuration->chargingEnabledLockDuration());
evCharger->setChargingCurrentLockDuration(m_configuration->chargingCurrentLockDuration());
m_evChargers.insert(thing->id(), evCharger);
setupEvCharger(thing);
setupPluggedInHandlers(thing);
}
}
void SmartChargingManager::onThingRemoved(const ThingId &thingId)
{
if (m_chargingInfos.contains(thingId)) {
m_chargingInfos.remove(thingId);
emit chargingInfoRemoved(thingId);
m_evChargers.remove(thingId); // Deleted by parenting
}
}
void SmartChargingManager::onActionExecuted(const Action &action, Thing::ThingError status)
{
if (status != Thing::ThingErrorNoError)
return;
Thing *thing = m_thingManager->findConfiguredThing(action.thingId());
if (!thing) {
return;
}
if (action.triggeredBy() != Action::TriggeredByUser) {
return;
}
qCDebug(dcNymeaEnergy()) << "User action executed on EV charger:" << thing->thingClass().actionTypes().findById(action.actionTypeId()).name() << action.params();
if (thing->thingClass().interfaces().contains("evcharger")) {
// Storing the values (power and max charging current) the user sets for charging station manually,
// so we can reset the charging current to the user desired value if the user changes from eco mode to normal mode.
if (chargingInfo(thing->id()).chargingMode() == ChargingInfo::ChargingModeNormal) {
// Monitor the power, max charging current and desired phase count
StateType powerStateType = thing->thingClass().stateTypes().findByName("power");
ActionType powerActionType = thing->thingClass().actionTypes().findById(powerStateType.id());
StateType maxChargingStateType = thing->thingClass().stateTypes().findByName("maxChargingCurrent");
ActionType maxChargingActionType = thing->thingClass().actionTypes().findById(maxChargingStateType.id());
StateType desiredPhaseCountStateType = thing->thingClass().stateTypes().findByName("desiredPhaseCount");
ActionType desiredPhaseCountActionType = thing->thingClass().actionTypes().findById(desiredPhaseCountStateType.id());
if (action.actionTypeId() == powerActionType.id()) {
bool manualChargingEnabled = action.paramValue(powerActionType.paramTypes().findByName("power").id()).toBool();
qCDebug(dcNymeaEnergy()) << "Manual charging is now" << (manualChargingEnabled ? "enabled": "disabled") << "for" << thing->name();
storeManualChargingParameters(thing->id(), manualChargingEnabled, manualMaxChargingCurrent(thing->id()), manualDesiredPhaseCount(thing->id()));
update(QDateTime::currentDateTime());
return;
}
if (action.actionTypeId() == maxChargingActionType.id()) {
uint manualChargingCurrent = qRound(action.paramValue(maxChargingActionType.paramTypes().findByName("maxChargingCurrent").id()).toDouble());
qCDebug(dcNymeaEnergy()) << "Manual charging current set to" << manualChargingCurrent << "for" << thing->name();
storeManualChargingParameters(thing->id(), manualChargingEnabled(thing->id()), manualChargingCurrent, manualDesiredPhaseCount(thing->id()));
update(QDateTime::currentDateTime());
return;
}
if (desiredPhaseCountStateType.isValid() && action.actionTypeId() == desiredPhaseCountActionType.id()) {
uint phaseCount = action.paramValue(desiredPhaseCountActionType.paramTypes().findByName("desiredPhaseCount").id()).toUInt();
qCDebug(dcNymeaEnergy()) << "Manual phase count set to" << phaseCount << "for" << thing->name();
storeManualChargingParameters(thing->id(), manualChargingEnabled(thing->id()), manualMaxChargingCurrent(thing->id()), phaseCount);
update(QDateTime::currentDateTime());
return;
}
}
}
if (thing->thingClass().interfaces().contains("electricvehicle")) {
// Storing the SoC the user sets, so we can reset to that value when it is unplugged (while it's plugged we'll increase the SoC if it's writable)
if (!thing->thingClass().hasStateType("batteryLevel")) {
return; // Shouldn't ever happen, but it's optional in the interface, so we'll have to verify
}
if (!thing->thingClass().stateTypes().findByName("batteryLevel").writable()) {
// We can't write the battery level which means it's delivered by an API to the car, nothing to do for us here then...
return;
}
EnergySettings settings;
settings.beginGroup("ManualSoCs");
settings.setValue(thing->id().toString(), thing->stateValue("batteryLevel").toInt());
settings.endGroup();
qCDebug(dcNymeaEnergy()) << "Resetting custom SoC calculation";
thing->setProperty("preciseSoC", thing->stateValue("batteryLevel").toInt());
thing->setProperty("lastSoCCalculation", QVariant());
}
}
void SmartChargingManager::onChargingModeChanged(const ThingId &evChargerId, const ChargingInfo &chargingInfo)
{
EvCharger *evCharger = m_evChargers.value(evChargerId);
if (!evCharger) {
qCWarning(dcNymeaEnergy()) << "Charging mode changed but the associated thing does not exist. Ignoring the event.";
return;
}
qCDebug(dcNymeaEnergy()) << "Charging mode changed for" << evCharger->name() << chargingInfo.chargingMode();
// Restore user desired ev charging settings if switching back to normal mode
if (chargingInfo.chargingMode() == ChargingInfo::ChargingModeNormal) {
bool enabled = manualChargingEnabled(evChargerId);
uint maxCurrent = manualMaxChargingCurrent(evChargerId);
uint desiredPhaseCount = manualDesiredPhaseCount(evChargerId);
qCDebug(dcNymeaEnergy()) << "EV charger" << evCharger->name() << "changed to normal mode. Restoring manual values of" << enabled << maxCurrent << "A" << desiredPhaseCount << "phases";
// In manual (aka fast) mode, we'll always select 3 phases for now
if (m_processInfos.value(evCharger).canSwitchPhaseCount) {
evCharger->setDesiredPhaseCount(desiredPhaseCount);
}
if (evCharger->chargingEnabled() != enabled) {
evCharger->setChargingEnabled(enabled, QDateTime::currentDateTime(), true);
}
if (evCharger->maxChargingCurrent() != maxCurrent) {
evCharger->setMaxChargingCurrent(maxCurrent, QDateTime::currentDateTime(), true);
}
}
}
void SmartChargingManager::setupRootMeter(Thing *thing)
{
if (m_rootMeter) {
m_rootMeter->deleteLater();
m_rootMeter = nullptr;
}
if (m_energyManager->rootMeter()) {
Q_ASSERT_X(thing->thingClass().interfaces().contains("energymeter"), "SmartChargingManager", "setupRootMeter called for a thing which isn't an energymeter.");
qCInfo(dcNymeaEnergy()) << "Setting root meter to" << thing->name();
m_rootMeter = new RootMeter(m_energyManager->rootMeter());
} else {
qCInfo(dcNymeaEnergy()) << "Root meter unset. Smart charging will cease to work until a new root meter is configured.";
}
}
void SmartChargingManager::setupEvCharger(Thing *thing)
{
Q_ASSERT_X(thing->thingClass().interfaces().contains("evcharger"), "SmartChargingManager", "setupEvCharger called for a thing which isn't an evcharger.");
qCDebug(dcNymeaEnergy()) << "Setting up EV charger:" << thing->name();
ChargingInfo chargingInfo(thing->id());
m_chargingInfos.insert(thing->id(), chargingInfo);
emit chargingInfoAdded(chargingInfo);
}
void SmartChargingManager::setupPluggedInHandlers(const Thing *thing)
{
Q_ASSERT_X(thing->thingClass().interfaces().contains("evcharger"), "SmartChargingManager", "setupPluggedInHandlers called for a thing which isn't an evcharger.");
qCDebug(dcNymeaEnergy()) << "Setting up push notification";
connect(thing, &Thing::stateValueChanged, this, [=](const StateTypeId &stateTypeId, const QVariant &value){
if (thing->thingClass().getStateType(stateTypeId).name() == "pluggedIn") {
if (value.toBool()) {
qCDebug(dcNymeaEnergy()) << "The car has been plugged in!";
ChargingInfo info = m_chargingInfos.value(thing->id());
if (info.chargingMode() == ChargingInfo::ChargingModeNormal) {
qCDebug(dcNymeaEnergy()) << "Charger is set to normal charging mode. No state of charge updating required.";
return;
}
Thing *car = m_thingManager->findConfiguredThing(info.assignedCarId());
if (!car) {
qCDebug(dcNymeaEnergy()) << "No car assigned to this EV charger.";
return;
}
if (!car->thingClass().actionTypes().findByName("batteryLevel").id().isNull()) {
qCDebug(dcNymeaEnergy()) << "This car requires manual State of charge input!";
ActionType batteryLevelActionType = car->thingClass().actionTypes().findByName("batteryLevel");
// Restore the last manually set SoC
EnergySettings settings;
settings.beginGroup("ManualSoCs");
int batteryLevel = settings.value(car->id().toString(), 20).toInt();
settings.endGroup();
Action batteryLevelAction(batteryLevelActionType.id(), car->id(), Action::TriggeredByRule);
batteryLevelAction.setParams({Param(batteryLevelActionType.id(), batteryLevel)});
m_thingManager->executeAction(batteryLevelAction);
// And fire the push notification so the user may adjust it
foreach (Thing *notificationThing, m_thingManager->configuredThings().filterByInterface("notifications")) {
ActionType actionType = notificationThing->thingClass().actionTypes().findByName("notify");
Action action(actionType.id(), notificationThing->id(), Action::TriggeredByRule);
QString title = QT_TR_NOOP("Car plugged in");
QString body = QT_TR_NOOP("Tap here to update the state of charge.");
ChargingInfo info = m_chargingInfos.value(thing->id());
QTranslator translator;
bool status = translator.load(info.locale(), "nymea-energy-plugin-nymea", "-", NymeaSettings::translationsPath(), ".qm");
if (!status) {
qCWarning(dcNymeaEnergy()) << "Error loading translations for notification from:" << NymeaSettings::translationsPath() + "/nymea-energy-plugin-nymea" + "-[" + info.locale().name() + "].qm";
}
QString translatedTitle = translator.translate("SmartChargingManager", title.toUtf8());
if (!translatedTitle.isEmpty()) {
title = translatedTitle;
}
QString translatedBody = translator.translate("SmartChargingManager", body.toUtf8());
if (!translatedBody.isEmpty()) {
body = translatedBody;
}
QUrlQuery data;
data.addQueryItem("open", "nymea.energy");
data.addQueryItem("thingId", thing->id().toString());
ParamList params = {
Param(actionType.paramTypes().findByName("title").id(), title),
Param(actionType.paramTypes().findByName("body").id(), body)
};
if (actionType.paramTypes().findByName("data").isValid()) {
params.append(Param(actionType.paramTypes().findByName("data").id(), data.toString()));
}
action.setParams(params);
m_thingManager->executeAction(action);
}
}
} else {
qCDebug(dcNymeaEnergy()) << "The car has been unplugged!";
if (lockOnUnplug()) {
qCDebug(dcNymeaEnergy()) << "Lock on unplug is set. Reverting to manual mode and disabling charging";
EvCharger *evCharger = m_evChargers.value(thing->id());
evCharger->setChargingEnabled(false, QDateTime::currentDateTime(), true);
m_chargingInfos[evCharger->id()].setChargingMode(ChargingInfo::ChargingModeNormal);
storeChargingInfo(m_chargingInfos.value(evCharger->id()));
emit chargingInfoChanged(m_chargingInfos.value(evCharger->id()));
}
}
}
});
}
void SmartChargingManager::storeChargingInfo(const ChargingInfo &chargingInfo)
{
EnergySettings settings;
settings.beginGroup("ChargingInfos");
settings.beginGroup(chargingInfo.evChargerId().toString());
settings.setValue("assignedCarId", chargingInfo.assignedCarId());
settings.setValue("chargingMode", chargingInfo.chargingMode());
settings.setValue("endDateTime", chargingInfo.endDateTime());
QVariantList days;
foreach (int day, chargingInfo.repeatDays()) {
days.append(day);
}
settings.setValue("repeatDays", days);
settings.setValue("targetPercentage", chargingInfo.targetPercentage());
settings.setValue("locale", chargingInfo.locale());
settings.setValue("spotMarketChargingEnabled", chargingInfo.spotMarketChargingEnabled());
settings.setValue("dailySpotMarketPercentage", chargingInfo.dailySpotMarketPercentage());
settings.endGroup();
settings.endGroup();
}
void SmartChargingManager::storeManualChargingParameters(const ThingId &evChargerId, bool enabled, int maxChargingCurrent, uint desiredPhaseCount)
{
EnergySettings settings;
settings.beginGroup("ChargingInfos");
settings.beginGroup(evChargerId.toString());
settings.setValue("manualChargingEnabled", enabled);
settings.setValue("manualMaxChargingCurrent", maxChargingCurrent);
settings.setValue("manualDesiredPhaseCount", desiredPhaseCount);
settings.endGroup();
settings.endGroup();
}
bool SmartChargingManager::manualChargingEnabled(const ThingId &evChargerId) const
{
EnergySettings settings;
settings.beginGroup("ChargingInfos");
settings.beginGroup(evChargerId.toString());
return settings.value("manualChargingEnabled", false).toBool();
}
uint SmartChargingManager::manualMaxChargingCurrent(const ThingId &evChargerId) const
{
EnergySettings settings;
settings.beginGroup("ChargingInfos");
settings.beginGroup(evChargerId.toString());
return settings.value("manualMaxChargingCurrent", 0).toUInt();
}
uint SmartChargingManager::manualDesiredPhaseCount(const ThingId &evChargerId) const
{
EnergySettings settings;
settings.beginGroup("ChargingInfos");
settings.beginGroup(evChargerId.toString());
return settings.value("manualDesiredPhaseCount", 3).toUInt();
}
Electricity::Phases SmartChargingManager::getAscendingPhasesForCount(uint phaseCount)
{
Electricity::Phases phases = Electricity::PhaseNone;
if (phaseCount == 3) {
phases = Electricity::PhaseAll;
} else if (phaseCount == 2) {
phases.setFlag(Electricity::PhaseA);
phases.setFlag(Electricity::PhaseB);
} else {
phases.setFlag(Electricity::PhaseA);
}
return phases;
}
uint SmartChargingManager::getBestPhaseCount(EvCharger *evCharger, double surplusAmpere)
{
uint desiredPhaseCount = 1;
// Check if we already have enough surplus for minimal 3 phase charging without acquisition
// In that case we just switch to 3 phase charging and skip the rest of the calculations
if (surplusAmpere > (3.0 * m_processInfos.value(evCharger).minimalChargingCurrent)) {
qCDebug(dcNymeaEnergy()) << "There is enough solar power for 3-phase charging without an acquisition";
desiredPhaseCount = 3;
return desiredPhaseCount;
}
// Check if we can do the job with 1 phase without acquisition
if (surplusAmpere > m_processInfos.value(evCharger).minimalChargingCurrent &&
surplusAmpere < evCharger->maxChargingCurrentMaxValue()) {
qCDebug(dcNymeaEnergy()) << "There is enough solar power for 1-phase charging without an acquisition";
desiredPhaseCount = 1;
return desiredPhaseCount;
}
// Check if we can do the job with 1 phase + acquisition
if (surplusAmpere > m_processInfos.value(evCharger).minimalChargingCurrent * m_acquisitionTolerance &&
surplusAmpere < evCharger->maxChargingCurrentMaxValue()) {
qCDebug(dcNymeaEnergy()) << "There is enough solar power for 1-phase charging with an acquisition of" << m_processInfos.value(evCharger).minimalChargingCurrent - surplusAmpere << "[A]";
desiredPhaseCount = 1;
return desiredPhaseCount;
}
// Check if we are between the 3 phase min and 1 phase max charging gap, if so, stay on single phase
// in order to prevent a charging stop
// 1 * 16A * 230V = 3680W
// 3 * 6 * 230V = 4140W
if (surplusAmpere < m_processInfos.value(evCharger).minimalChargingCurrent * m_acquisitionTolerance * 3 &&
surplusAmpere > evCharger->maxChargingCurrentMaxValue() * m_acquisitionTolerance) {
qCDebug(dcNymeaEnergy()) << "We are right between the 3-phase min current and 1-phase max current interval. Falling back to single phase charging in order to prevent a charing stop.";
desiredPhaseCount = 1;
return desiredPhaseCount;
}
//We ruled out any situation where single phase charging would make sense, let's use 3 phases here
desiredPhaseCount = 3;
return desiredPhaseCount;
}
void SmartChargingManager::executeChargingAction(EvCharger *evCharger, const ChargingAction &chargingAction, const QDateTime &currentDateTime)
{
qCDebug(dcNymeaEnergy()).nospace().noquote() << "Executing action " << evCharger->name()
<< " to power: " << (chargingAction.chargingEnabled() ? "ON," : "OFF,")
<< " Carging current: " << chargingAction.maxChargingCurrent() << "A, "
<< (m_processInfos.value(evCharger).canSwitchPhaseCount ? "Switch phases to: " + QString::number(chargingAction.desiredPhaseCount()) + ", " : "" )
<< "Issuer: " << chargingAction.issuerString() << ", forced: " << chargingAction.force();
if (m_processInfos.value(evCharger).canSwitchPhaseCount) {
evCharger->setDesiredPhaseCount(chargingAction.desiredPhaseCount());
m_processInfos[evCharger].effectivePhaseCount = chargingAction.desiredPhaseCount();
if (chargingAction.desiredPhaseCount() == 1) {
m_processInfos[evCharger].effectivePhases = Electricity::PhaseA;
} else {
m_processInfos[evCharger].effectivePhases = Electricity::PhaseAll;
}
}
// FIXME: execute sequential and check what to do if an action failes, retry?
evCharger->setMaxChargingCurrent(chargingAction.maxChargingCurrent(), currentDateTime, chargingAction.force());
evCharger->setChargingEnabled(chargingAction.chargingEnabled(), currentDateTime, chargingAction.force());
}