powersync-energy-plugin-etm/energyplugin/spotmarket/spotmarketmanager.cpp

621 lines
25 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright (C) 2013 - 2024, nymea GmbH
* Copyright (C) 2024 - 2025, chargebyte austria GmbH
*
* This file is part of nymea-energy-plugin-nymea.
*
* nymea-energy-plugin-nymea.s free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* nymea-energy-plugin-nymea.s distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with nymea-energy-plugin-nymea. If not, see <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#include "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 &currentDateTime, 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 &currentDateTime, 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<int>(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<int> 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<int>(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<QDate, ScoreEntries> 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<QDate> 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();
}