// 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 . * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ #include "smartchargingmanager.h" #include "evcharger.h" #include "rootmeter.h" #include "energysettings.h" #include #include #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(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 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 ¤tDateTime) { qCDebug(dcNymeaEnergy()) << "Updating smart charging"; updateManualSoCsWithoutMeter(currentDateTime); prepareInformation(currentDateTime); verifyOverloadProtection(currentDateTime); verifyOverloadProtectionRecovery(currentDateTime); planSpotMarketCharging(currentDateTime); planSurplusCharging(currentDateTime); adjustEvChargers(currentDateTime); } void SmartChargingManager::verifyOverloadProtection(const QDateTime ¤tDateTime) { 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 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 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(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(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 ¤tDateTime) { // 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 currentPhaseConsumption = {{"A", m_rootMeter->currentPowerPhaseA()}, {"B", m_rootMeter->currentPowerPhaseB()}, {"C", m_rootMeter->currentPowerPhaseC()}}; double consumptionLimitInWatt = 230 * m_phasePowerConsumptionLimit; QHash 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 ¤tDateTime) { // 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(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 ¤tDateTime) { 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 ¤tDateTime) { 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 it(evChargers); QHash 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 ¤tDateTime) { 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 ¤tDateTime) { 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 ¤tDateTime) { 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()); }