// 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 "spotmarketmanager.h" #include "../plugininfo.h" #include "../energysettings.h" #include "spotmarketdataproviderawattar.h" SpotMarketManager::SpotMarketManager(QNetworkAccessManager *networkManager, QObject *parent) : QObject{parent}, m_networkManager{networkManager} { // Init known spot market providers registerProvider(new SpotMarketDataProviderAwattar(m_networkManager, SpotMarketDataProviderAwattar::AwattarCountryAustria, this)); registerProvider(new SpotMarketDataProviderAwattar(m_networkManager, SpotMarketDataProviderAwattar::AwattarCountryGermany, this)); // Load settings EnergySettings settings; settings.beginGroup("SpotMarket"); setEnabled(settings.value("enabled", false).toBool()); settings.endGroup(); // SpotMarket } bool SpotMarketManager::enabled() const { return m_enabled; } void SpotMarketManager::setEnabled(bool enabled) { EnergySettings settings; settings.beginGroup("SpotMarket"); if (enabled) { qCDebug(dcNymeaEnergy()) << "SpotMarketManager: Enable spot market manager"; QUuid providerId = settings.value("providerId").toUuid(); if (providerId.isNull()) { qCDebug(dcNymeaEnergy()) << "SpotMarketManager: Currently no spot market provider configured. Selecting the first available."; changeProvider(m_availableProviders.keys().first()); } else { changeProvider(providerId); } m_currentProvider->enable(); qCInfo(dcNymeaEnergy()) << "SpotMarketManager: Enabled using" << m_currentProvider; } else { qCDebug(dcNymeaEnergy()) << "SpotMarketManager: Disable spot market manager."; if (m_currentProvider) { m_currentProvider->disable(); } } if (m_enabled != enabled) { m_enabled = enabled; settings.setValue("enabled", m_enabled); emit enabledChanged(m_enabled); } settings.endGroup(); // SpotMarket } bool SpotMarketManager::available() const { if (!m_currentProvider) return false; return m_currentProvider->available(); } SpotMarketDataProvider *SpotMarketManager::currentProvider() const { return m_currentProvider; } QUuid SpotMarketManager::currentProviderId() const { if (m_currentProvider) return m_currentProvider->providerId(); return QUuid(); } SpotMarketProviderInfos SpotMarketManager::availableProviders() const { return m_availableProviderInfos; } bool SpotMarketManager::changeProvider(const QUuid &providerId) { if (providerId.isNull() || !m_availableProviders.contains(providerId)) { qCWarning(dcNymeaEnergy()) << "SpotMarketManager: Requested to change provider to" << providerId.toString() << "but there is no such provider available."; return false; } if (m_currentProvider) { qCDebug(dcNymeaEnergy()) << "SpotMarketManager: Unset current provider" << m_currentProvider; m_currentProvider->disable(); // Disconnect everything from availabeChanged disconnect(m_currentProvider, &SpotMarketDataProvider::availableChanged, this, &SpotMarketManager::availableChanged); disconnect(m_currentProvider, &SpotMarketDataProvider::enabledChanged, this, &SpotMarketManager::enabledChanged); disconnect(m_currentProvider, &SpotMarketDataProvider::scoreEntriesChanged, this, &SpotMarketManager::onProviderScoreEntriesChanged); m_currentProvider = nullptr; } m_weightedScores.clear(); qCDebug(dcNymeaEnergy()) << "SpotMarketManager: Changing provider to" << m_availableProviders.value(providerId); m_currentProvider = m_availableProviders.value(providerId); emit currentProviderChanged(m_currentProvider); connect(m_currentProvider, &SpotMarketDataProvider::availableChanged, this, &SpotMarketManager::availableChanged); connect(m_currentProvider, &SpotMarketDataProvider::enabledChanged, this, &SpotMarketManager::enabledChanged); connect(m_currentProvider, &SpotMarketDataProvider::scoreEntriesChanged, this, &SpotMarketManager::onProviderScoreEntriesChanged); EnergySettings settings; settings.beginGroup("SpotMarket"); settings.setValue("providerId", m_currentProvider->providerId()); settings.endGroup(); // SpotMarket return true; } bool SpotMarketManager::registerProvider(SpotMarketDataProvider *provider) { if (m_availableProviders.contains(provider->providerId())) { qCWarning(dcNymeaEnergy()) << "SpotMarketManager: Try to register already registered provider. Ignoring request."; return false; } m_availableProviders.insert(provider->providerId(), provider); m_availableProviderInfos.append(provider->info()); emit availableProvidersChanged(); qCDebug(dcNymeaEnergy()) << "SpotMarketManager: Registered" << provider; return true; } ScoreEntries SpotMarketManager::weightedScoreEntries(const QDate &date) const { if (!m_currentProvider) return ScoreEntries(); if (!date.isValid()) return weightScoreEntries(m_currentProvider->scoreEntries()); return m_weightedScores.value(date); } ScoreEntries SpotMarketManager::weightScoreEntries(const ScoreEntries &scoreEntries) { bool dataInitialized = false; double bestPrice = 0; double worstPrice = 0; foreach (const ScoreEntry &entry, scoreEntries) { if (!dataInitialized) { bestPrice = entry.value(); worstPrice = entry.value(); dataInitialized = true; continue; } if (entry.value() < bestPrice) { bestPrice = entry.value(); } if (entry.value() > worstPrice) { worstPrice = entry.value(); } } ScoreEntries weigtedEntries; foreach (const ScoreEntry &entry, scoreEntries) { ScoreEntry newEntry = entry; newEntry.setWeighting((newEntry.value() - worstPrice) / (bestPrice - worstPrice)); weigtedEntries.append(newEntry); } weigtedEntries.sortByStartDateTime(); if (weigtedEntries.isEmpty()) { qCDebug(dcNymeaEnergy()) << "Weigted" << scoreEntries.count() << "score entries"; } else { qCDebug(dcNymeaEnergy()) << "Weigted" << scoreEntries.count() << "score entries" << weigtedEntries.first().startDateTime().toString("dd.MM.yyyy hh:mm") << "-" << weigtedEntries.last().endDateTime().toString("dd.MM.yyyy hh:mm") << "Best price:" << bestPrice << "(1.0)" << "Worst price:" << worstPrice << "(0.0)"; } return weigtedEntries; } TimeFrames SpotMarketManager::scheduleCharingTimeForToday(const QDateTime ¤tDateTime, const uint minutes, uint minimumScheduleDuration, bool currentFrameLocked) { const ScoreEntries weightedScores = m_weightedScores.value(currentDateTime.date()); return scheduleChargingTime(currentDateTime, weightedScores, minutes, minimumScheduleDuration, currentFrameLocked); } TimeFrames SpotMarketManager::scheduleChargingTime(const QDateTime ¤tDateTime, const ScoreEntries weightedScores, const uint minutes, uint minimumScheduleDuration, bool currentFrameLocked) { TimeFrames timeFrames; ScoreEntries availableScores; for (int i = 0; i < weightedScores.count(); i++) { const ScoreEntry scoreEntry = weightedScores.at(i); // We can not change the past, even if we would like to that some times... if (scoreEntry.endDateTime() < currentDateTime) continue; // If this is the current hour, let's update the start time so we have later the correct timeframe available if (scoreEntry.isActive(currentDateTime)) { ScoreEntry newEntry = scoreEntry; newEntry.setStartDateTime(currentDateTime); availableScores.append(newEntry); } else { availableScores.append(scoreEntry); } } uint minutesLeftToSchedule = 0; foreach (const ScoreEntry &score, availableScores) { minutesLeftToSchedule += score.durationMinutes(); } // Make sure we have enougth schedules left, otherwise return what we have... if (minutes >= minutesLeftToSchedule) { qCDebug(dcNymeaEnergy()) << "Not enought score entries left in order to schedule" << minutes << "minutes. Using the leftover schedules beeing a total of" << minutesLeftToSchedule << "minutes."; foreach(const ScoreEntry &scoreEntry, availableScores) { timeFrames.append(TimeFrame(scoreEntry.startDateTime(), scoreEntry.endDateTime())); } } else { // Sort the schedules by weighting availableScores.sortByWeighting(); //qCDebug(dcNymeaEnergy()) << "Sorted by weighting" << availableScores; uint minutesLeft = minutes; int currentIndex = 0; while(minutesLeft > 0 && currentIndex < availableScores.count()) { const ScoreEntry currentScore = availableScores.at(currentIndex); TimeFrame newFrame; if (minutesLeft >= 60) { newFrame = TimeFrame(currentScore.startDateTime(), currentScore.endDateTime()); } else { // Partial hour, let's see how many minutes we have left within this hour uint minutesDuration = 60 - currentScore.startDateTime().time().minute(); if (minutesDuration < minutesLeft) { newFrame = TimeFrame(currentScore.startDateTime(), currentScore.startDateTime().addSecs(minutesDuration * 60)); } else { newFrame = TimeFrame(currentScore.startDateTime(), currentScore.startDateTime().addSecs(minutesLeft * 60)); } } timeFrames.append(newFrame); minutesLeft -= newFrame.durationMinutes(); currentIndex++; } } TimeFrames fusedFrames = fuseTimeFrames(timeFrames); // Now make sure all frames respect the minimum schedule duration. // The reason for a minimum duration might be the fact that we want to // charge as continuouse as possible. We don't want to switch the charging on and off // in less i.e. than 10 min. We append or prepend the rest minutes on an existing frame // using the best possible side. // This is the price we have to pay for continuouse charging foreach (const TimeFrame &frame, fusedFrames) { // If we are currently loading and the frame has moved less than 10 minutes into the future, we move the window to now in otder to prevent jutter if (currentFrameLocked && currentDateTime.msecsTo(frame.startDateTime()) < 600000) { int secondsOffset = qRound((frame.startDateTime().toMSecsSinceEpoch() - currentDateTime.toMSecsSinceEpoch()) / 1000.0); // Move the current frame by the offset to now since we where already charging TimeFrame movedFrame = frame; movedFrame.setStartDateTime(frame.startDateTime().addSecs(-secondsOffset)); movedFrame.setEndDateTime(frame.endDateTime().addSecs(-secondsOffset)); fusedFrames.removeAll(frame); fusedFrames.append(movedFrame); qCDebug(dcNymeaEnergy()) << "Moving frame to current date time" << secondsOffset / 60.0 << "minutes"; // We are done here... break; } if (minimumScheduleDuration > 1 && frame.durationMinutes() < minimumScheduleDuration) { // If this is the current frame, and it is not locked (because we are currently charging), allow to finish it if (frame.isActive(currentDateTime) && currentFrameLocked) { // Keep this schedule even it does not meet the minimum schedule durarion because the current frame is locked break; } // This frame does not meet our minimum duration, distribute the rest to the next best schedule fusedFrames.removeAll(frame); // Possible option: for now, we just append the remaining time to the shortest time frame // in favour of append/prepend it to the best price hour. A small price we are willing to pay in favour of // a more constant charging, a car battery is more expensice than this small amount of energy. int shortestFrameIndex = -1; uint shortestFrameDuration = 0; for (int i = 0; i < fusedFrames.count(); i++) { const TimeFrame currentFrame = fusedFrames.at(i); // The sooner the better if 2 have the same duration if (currentFrame.durationMinutes() < shortestFrameDuration || shortestFrameIndex < 0) { shortestFrameDuration = currentFrame.durationMinutes(); shortestFrameIndex = i; } } // There is no other frame we can append, let's re-add it... if (shortestFrameIndex < 0) { // FIXME: maybe try to add this one into the next day, it's not the current locked frame so do not toggle charging fusedFrames.append(frame); break; } // Add the to short frame to the shortest time frame in order to be as continoiuse as possible regarding charging changes. fusedFrames[shortestFrameIndex].setEndDateTime(fusedFrames.value(shortestFrameIndex).endDateTime().addSecs(frame.durationMinutes() * 60)); // Note: there can only be one schedule which might has to be distributed...we are done break; } } // Return them sorted by date std::sort(fusedFrames.begin(), fusedFrames.end(), [](const TimeFrame &a, const TimeFrame &b) -> bool { return a.startDateTime() < b.startDateTime(); }); return fusedFrames; } TimeFrames SpotMarketManager::fuseTimeFrames(const TimeFrames &timeFrames) { if (timeFrames.count() <= 1) return timeFrames; TimeFrames fusedFrames, partialHours; // First fuse all full hours for (int i = 0; i < timeFrames.count(); i++) { const TimeFrame currentFrame = timeFrames.at(i); int frameDurationMinutes = (currentFrame.endDateTime().toMSecsSinceEpoch() - currentFrame.startDateTime().toMSecsSinceEpoch()) / 60000; // We take care about the partial hours later if (frameDurationMinutes < 60) { partialHours.append(currentFrame); continue; } // First full hour...just add, nothing to fuse if (fusedFrames.isEmpty()) { fusedFrames.append(currentFrame); continue; } // Check if we can extend an existing frame bool extended = false; for (int j = 0; j < fusedFrames.count(); j++) { if (currentFrame.endDateTime() == fusedFrames.at(j).startDateTime()) { // Prepend hour fusedFrames[j].setStartDateTime(currentFrame.startDateTime()); extended = true; break; } else if (currentFrame.startDateTime() == fusedFrames.at(j).endDateTime()) { // Append hour fusedFrames[j].setEndDateTime(currentFrame.endDateTime()); extended = true; break; } } if (!extended) { // Add the full hour to the fused frames fusedFrames.append(currentFrame); continue; } } TimeFrames partialHoursUnfused; // Now try to fuse the partial hours into the fused full hours ... for (int i = 0; i < partialHours.count(); i++) { const TimeFrame currentFrame = partialHours.at(i); TimeFrame currentFrameMovedToEnd; if (currentFrame.endDateTime().time().minute() == 0) { // This time frame is already at the end. No need to move currentFrameMovedToEnd = currentFrame; } else { int endtimeOffset = 60 - currentFrame.endDateTime().time().minute(); QDateTime endDateTime = currentFrame.endDateTime().addSecs(endtimeOffset * 60); currentFrameMovedToEnd.setEndDateTime(endDateTime); currentFrameMovedToEnd.setStartDateTime(endDateTime.addSecs(static_cast(currentFrame.durationMinutes()) * -60)); } // Check if we can extend an existing frame bool extended = false; // Try to fuse the start partial frame for (int j = 0; j < fusedFrames.count(); j++) { if (currentFrame.endDateTime() == fusedFrames.at(j).startDateTime()) { // Prepend partial hour fusedFrames[j].setStartDateTime(currentFrame.startDateTime()); extended = true; break; } else if (currentFrame.startDateTime() == fusedFrames.at(j).endDateTime()) { // Append partial hour fusedFrames[j].setEndDateTime(currentFrame.endDateTime()); extended = true; break; } } if (!extended && currentFrame != currentFrameMovedToEnd) { // Try to fuse the end partial frame for (int j = 0; j < fusedFrames.count(); j++) { if (currentFrameMovedToEnd.endDateTime() == fusedFrames.at(j).startDateTime()) { // Prepend partial hour fusedFrames[j].setStartDateTime(currentFrameMovedToEnd.startDateTime()); extended = true; break; } else if (currentFrameMovedToEnd.startDateTime() == fusedFrames.at(j).endDateTime()) { // Append partial hour fusedFrames[j].setEndDateTime(currentFrameMovedToEnd.endDateTime()); extended = true; break; } } } if (!extended) { // Note: we have a standalone partial hour...add it as is, // it might overlap into an other hour or bet the current, // we don't know here, so let's not change it. Do not use // the moved to end frame since that destroyes the upper logic. partialHoursUnfused.append(currentFrame); continue; } } if (!partialHoursUnfused.isEmpty()) { TimeFrames fusedPartialFrames = fusePartialTimeFrames(partialHoursUnfused); fusedFrames.append(fusedPartialFrames); } // Return them sorted by date std::sort(fusedFrames.begin(), fusedFrames.end(), [](const TimeFrame &a, const TimeFrame &b) -> bool { return a.startDateTime() < b.startDateTime(); }); return fusedFrames; } TimeFrames SpotMarketManager::fusePartialTimeFrames(const TimeFrames &timeFrames) { TimeFrames fusedPartialFrames; QList handledIndices; // If we have any partial hours left which could not be extended yet, try to extend with any other partially unfused hour for (int i = 0; i < timeFrames.count(); i++) { if (handledIndices.contains(i)) continue; const TimeFrame currentFrame = timeFrames.at(i); TimeFrame currentFrameMovedToEnd; if (currentFrame.endDateTime().time().minute() == 0) { // This time frame is already at the end. No need to move currentFrameMovedToEnd = currentFrame; } else { int endtimeOffset = 60 - currentFrame.endDateTime().time().minute(); QDateTime endDateTime = currentFrame.endDateTime().addSecs(endtimeOffset * 60); currentFrameMovedToEnd.setEndDateTime(endDateTime); currentFrameMovedToEnd.setStartDateTime(endDateTime.addSecs(static_cast(currentFrame.durationMinutes()) * -60)); } // Check if we can extend an existing frame bool extended = false; // Try to fuse the start partial frame with any other partial frame for (int j = 0; j < timeFrames.count(); j++) { // We don't want to fuse with our selfs or already fused frames if (j == i || handledIndices.contains(j)) continue; if (currentFrame.endDateTime() == timeFrames.at(j).startDateTime()) { // Prepend partial hour TimeFrame extendedFrame = timeFrames.at(j); extendedFrame.setStartDateTime(currentFrame.startDateTime()); fusedPartialFrames.append(extendedFrame); handledIndices << j << i; extended = true; break; } else if (currentFrame.startDateTime() == timeFrames.at(j).endDateTime()) { // Append partial hour TimeFrame extendedFrame = timeFrames.at(j); extendedFrame.setEndDateTime(currentFrame.endDateTime()); fusedPartialFrames.append(extendedFrame); handledIndices << j << i; extended = true; break; } } if (!extended && currentFrame != currentFrameMovedToEnd) { // Try to fuse the end partial frame for (int j = 0; j < timeFrames.count(); j++) { // We don't want to fuse with our selfs if (j == i || handledIndices.contains(j)) continue; if (currentFrameMovedToEnd.endDateTime() == timeFrames.at(j).startDateTime()) { // Prepend partial hour TimeFrame extendedFrame = timeFrames.at(j); extendedFrame.setStartDateTime(currentFrameMovedToEnd.startDateTime()); fusedPartialFrames.append(extendedFrame); handledIndices << j << i; extended = true; break; } else if (currentFrameMovedToEnd.startDateTime() == timeFrames.at(j).endDateTime()) { // Append partial hour TimeFrame extendedFrame = timeFrames.at(j); extendedFrame.setEndDateTime(currentFrameMovedToEnd.endDateTime()); fusedPartialFrames.append(extendedFrame); handledIndices << j << i; extended = true; break; } } } if (!extended) { // Note: we have a standalone partial hour...add it as is, // it might overlap into an other hour or bet the current, // we don't know here, so let's not change it. Do not use // the moved to end frame since that destroyes the upper logic. fusedPartialFrames.append(currentFrame); handledIndices << i; continue; } } // Note: these frames must all be partial, we fuse them if possible, otherwise return them as single partial slots... return fusedPartialFrames; } void SpotMarketManager::onProviderScoreEntriesChanged(const ScoreEntries &scoreEntries) { // Received new score entries, check if we habe to update the day based weighted scores. QHash weightedScores; foreach (const ScoreEntry &scoreEntry, scoreEntries) { if (!weightedScores.contains(scoreEntry.startDateTime().date())) { // New day, new situation... let's fill in the data and weight them weightedScores[scoreEntry.startDateTime().date()] = ScoreEntries({ scoreEntry }); } else { weightedScores[scoreEntry.startDateTime().date()].append(scoreEntry); } } // Insert all received scored weighted into our peristant hash foreach (const QDate &date, weightedScores.keys()) { if (!m_weightedScores.contains(date)) { m_weightedScores[date] = weightScoreEntries(weightedScores.value(date)); } } // Clean up old QList datesToRemove; foreach (const QDate &date, m_weightedScores.keys()) { if (date < scoreEntries.first().startDateTime().date()) { datesToRemove.append(date); continue; } // Weight the current data //m_weightedScores[date] = weightScoreEntries(m_weightedScores.value(date)); } // Remove the past... live goes on foreach (const QDate &date, datesToRemove) { m_weightedScores.remove(date); } qCDebug(dcNymeaEnergy()) << "SpotMarketManager: Score entries updated"; emit scoreEntriesUpdated(); }