// 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-app. * * nymea-app is 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-app is 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-app. If not, see . * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ import QtQuick import QtQuick.Layouts import QtQuick.Controls import QtCharts import Nymea import "qrc:/ui/components/" StatsBase { id: root property EnergyManager energyManager: null property bool titleVisible: true property string gridIcon property string pvIcon property string homeIcon property ThingsProxy producers: ThingsProxy { engine: _engine shownInterfaces: ["smartmeterproducer"] } readonly property bool hasProducers: producers.count > 0 QtObject { id: d property var config: root.configs[selectionTabs.currentValue.config] property int startOffset: 0 property var selectedSet: null property date startTime: root.calculateTimestamp(config.startTime(), config.sampleRate, startOffset) property date endTime: root.calculateTimestamp(config.startTime(), config.sampleRate, startOffset + config.count) property bool fetchPending: false property bool loading: fetchPending || wheelStopTimer.running || powerBalanceLogs.fetchingData onLoadingChanged: { if (!loading) { refresh() } } onConfigChanged: valueAxis.max = 1 onStartOffsetChanged: { refresh() } function selectSet(set) { if (d.selectedSet === set) { d.selectedSet = null } else { d.selectedSet = set } } function refresh() { if (powerBalanceLogs.loadingInhibited) { return; } var upcomingTimestamp = root.calculateTimestamp(d.config.startTime(), d.config.sampleRate, d.config.count) // print("refreshing config start", d.config.startTime(), "upcoming:", upcomingTimestamp, "fetchPending", d.fetchPending) for (var i = 0; i < d.config.count; i++) { var timestamp = root.calculateTimestamp(d.config.startTime(), d.config.sampleRate, d.startOffset + i + 1) var previousTimestamp = root.calculateTimestamp(timestamp, d.config.sampleRate, -1) var idx = powerBalanceLogs.indexOf(timestamp); var entry = powerBalanceLogs.get(idx) // print("timestamp:", timestamp, "previousTimestamp:", previousTimestamp) var previousEntry = powerBalanceLogs.find(previousTimestamp); if (timestamp < upcomingTimestamp && entry && (previousEntry || !d.loading)) { // print("found entry:", entry.timestamp, entry.totalConsumption) // if (previousEntry) { // print("found previous:", previousEntry.timestamp, previousEntry.totalConsumption) // } var consumption = entry.totalConsumption var production = entry.totalProduction var acquisition = entry.totalAcquisition var returned = entry.totalReturn if (previousEntry) { consumption -= previousEntry.totalConsumption production -= previousEntry.totalProduction acquisition -= previousEntry.totalAcquisition returned -= previousEntry.totalReturn } consumptionSet.replace(i, consumption) productionSet.replace(i, production) acquisitionSet.replace(i, acquisition) returnSet.replace(i, returned) valueAxis.adjustMax(consumption) valueAxis.adjustMax(production) valueAxis.adjustMax(acquisition) valueAxis.adjustMax(returned) } else if (timestamp.getTime() == upcomingTimestamp.getTime() && (previousEntry || !d.loading)) { // print("it's today!") var consumption = energyManager.totalConsumption var production = energyManager.totalProduction var acquisition = energyManager.totalAcquisition var returned = energyManager.totalReturn if (previousEntry) { consumption -= previousEntry.totalConsumption production -= previousEntry.totalProduction acquisition -= previousEntry.totalAcquisition returned -= previousEntry.totalReturn } consumptionSet.replace(i, consumption) productionSet.replace(i, production) acquisitionSet.replace(i, acquisition) returnSet.replace(i, returned) valueAxis.adjustMax(consumption) valueAxis.adjustMax(production) valueAxis.adjustMax(acquisition) valueAxis.adjustMax(returned) } else { consumptionSet.replace(i, 0) productionSet.replace(i, 0) acquisitionSet.replace(i, 0) returnSet.replace(i, 0) } } } } ColumnLayout { anchors.fill: parent spacing: 0 Label { Layout.fillWidth: true Layout.margins: Style.smallMargins horizontalAlignment: Text.AlignHCenter text: qsTr("Totals") visible: root.titleVisible MouseArea { anchors.fill: parent onClicked: pageStack.push(Qt.resolvedUrl("PowerBalanceStatsPage.qml"), {energyManager: root.energyManager, producers: root.producers}) } } SelectionTabs { id: selectionTabs Layout.fillWidth: true Layout.leftMargin: Style.smallMargins Layout.rightMargin: Style.smallMargins currentIndex: 1 model: ListModel { ListElement { modelData: qsTr("Hours"); config: "hours" } ListElement { modelData: qsTr("Days"); config: "days" } ListElement { modelData: qsTr("Weeks"); config: "weeks" } ListElement { modelData: qsTr("Months"); config: "months" } ListElement { modelData: qsTr("Years"); config: "years" } // ListElement { modelData: qsTr("Minutes"); config: "minutes" } } onTabSelected: { d.startOffset = 0 powerBalanceLogs.fetchLogs() } } Connections { target: energyManager onPowerBalanceChanged: { // print("updating because of power balance change. fetchingData", powerBalanceLogs.fetchingData, "fetchPending", d.fetchPending) d.refresh(); } } PowerBalanceLogs { id: powerBalanceLogs engine: _engine startTime: root.calculateTimestamp(d.startTime, d.config.sampleRate, -d.config.count) endTime: root.calculateTimestamp(d.startTime, d.config.sampleRate, d.config.count) sampleRate: d.config.sampleRate Component.onCompleted: fetchLogs() onFetchingDataChanged: { if (!fetchingData) { d.fetchPending = false d.refresh() } } onEntriesAdded: { if (fetchingData) { return } // Update the timeline by faking a left/right scroll d.startOffset-- d.startOffset++ //d.refresh() } } Item { Layout.fillWidth: true Layout.fillHeight: true Label { x: chartView.x + chartView.plotArea.x + (chartView.plotArea.width - width) / 2 y: chartView.y + chartView.plotArea.y + Style.smallMargins text: d.config.toRangeLabel(d.startTime) font: Style.smallFont opacity: d.startOffset < -d.config.count ? .5 : 0 Behavior on opacity { NumberAnimation {} } } ChartView { id: chartView animationOptions: ChartView.NoAnimation anchors.fill: parent backgroundColor: "transparent" legend.visible: false legend.alignment: Qt.AlignBottom legend.font: Style.extraSmallFont legend.labelColor: Style.foregroundColor margins.left: Math.max(Style.smallMargins * 2, valueLabelMetrics.width + Style.smallMargins * 2) margins.right: 0 margins.bottom: Style.smallIconSize + Style.margins margins.top: 0 ActivityIndicator { x: chartView.plotArea.x + (chartView.plotArea.width - width) / 2 y: chartView.plotArea.y + (chartView.plotArea.height - height) / 2 + (chartView.plotArea.height / 8) visible: powerBalanceLogs.fetchingData opacity: .5 } Label { x: chartView.plotArea.x + (chartView.plotArea.width - width) / 2 y: chartView.plotArea.y + (chartView.plotArea.height - height) / 2 + (chartView.plotArea.height / 8) text: qsTr("No data available") visible: !powerBalanceLogs.fetchingData && (powerBalanceLogs.count == 0 || powerBalanceLogs.get(0).timestamp > d.endTime) && d.startOffset != 0 font: Style.smallFont opacity: .5 Behavior on opacity { NumberAnimation {}} } TextMetrics { id: valueLabelMetrics font: Style.extraSmallFont text: (valueAxis.max).toFixed(1) + "kWh" } Item { id: labelsLayout x: Style.smallMargins y: chartView.plotArea.y height: chartView.plotArea.height width: Math.max(0, chartView.margins.left - Style.smallMargins) Repeater { model: valueAxis.tickCount delegate: Label { y: parent.height / (valueAxis.tickCount - 1) * index - font.pixelSize / 2 width: parent.width - Style.smallMargins horizontalAlignment: Text.AlignRight text: ((valueAxis.max - (index * valueAxis.max / (valueAxis.tickCount - 1)))).toFixed(1) + "kWh" verticalAlignment: Text.AlignTop font: Style.extraSmallFont } } } BarSeries { id: barSeries axisX: BarCategoryAxis { id: categoryAxis labelsColor: Style.foregroundColor labelsFont: Style.extraSmallFont gridVisible: false gridLineColor: Style.tileOverlayColor lineVisible: false titleVisible: false shadesVisible: false categories: { var ret = [] // print("Updating categories from", d.config.startTime()) for (var i = 0; i < d.config.count; i++) { var timestamp = root.calculateTimestamp(d.config.startTime(), d.config.sampleRate, d.startOffset + i); // print("*** adding", timestamp, d.startOffset, i) ret.push(d.config.toLabel(timestamp)) } return ret; } } axisY: ValueAxis { id: valueAxis min: 0 gridLineColor: Style.tileOverlayColor labelsVisible: false labelsColor: Style.foregroundColor labelsFont: Style.extraSmallFont lineVisible: false titleVisible: false shadesVisible: false function adjustMax(newValue) { if (max < newValue) { // print("adjusting to new max", newValue) max = newValue // Math.ceil(newValue / 100) * 100 } } } BarSet { id: consumptionSet label: qsTr("Consumed") color: Qt.rgba(Style.powerConsumptionColor.r, Style.powerConsumptionColor.g, Style.powerConsumptionColor.b, opacity) borderColor: color borderWidth: 0 property real opacity: d.selectedSet == null || d.selectedSet == consumptionSet ? 1 : 0.3 values: { var ret = [] for (var i = 0; i < d.config.count; i++) { ret.push(0) } return ret } } BarSet { id: productionSet label: qsTr("Produced") color: Qt.rgba(Style.powerSelfProductionConsumptionColor.r, Style.powerSelfProductionConsumptionColor.g, Style.powerSelfProductionConsumptionColor.b, opacity) borderColor: color borderWidth: 0 property real opacity: d.selectedSet == null || d.selectedSet == productionSet ? 1 : 0.3 values: { var ret = [] for (var i = 0; i < d.config.count; i++) { ret.push(0) } return ret } } BarSet { id: acquisitionSet label: qsTr("From grid") color: Qt.rgba(Style.powerAcquisitionColor.r, Style.powerAcquisitionColor.g, Style.powerAcquisitionColor.b, opacity) borderColor: color borderWidth: 0 property real opacity: d.selectedSet == null || d.selectedSet == acquisitionSet ? 1 : 0.3 values: { var ret = [] for (var i = 0; i < d.config.count; i++) { ret.push(0) } return ret } } BarSet { id: returnSet label: qsTr("To grid") color: Qt.rgba(Style.powerReturnColor.r, Style.powerReturnColor.g, Style.powerReturnColor.b, opacity) borderColor: color borderWidth: 0 property real opacity: d.selectedSet == null || d.selectedSet == returnSet ? 1 : 0.3 values: { var ret = [] for (var i = 0; i < d.config.count; i++) { ret.push(0) } return ret } } } } RowLayout { id: legend anchors { left: parent.left; bottom: parent.bottom; right: parent.right } anchors.leftMargin: chartView.plotArea.x height: Style.smallIconSize anchors.margins: Style.margins Item { Layout.fillWidth: true Layout.fillHeight: true MouseArea { anchors.fill: parent anchors.topMargin: -Style.smallMargins anchors.bottomMargin: -Style.smallMargins onClicked: d.selectSet(consumptionSet) } Row { anchors.centerIn: parent spacing: Style.smallMargins opacity: consumptionSet.opacity ColorIcon { name: root.homeIcon === "" ? "qrc:/icons/powersocket.svg": root.homeIcon size: Style.smallIconSize color: Style.powerConsumptionColor } Label { width: parent.parent.width - x elide: Text.ElideRight visible: legend.width > 500 text: qsTr("Consumed") anchors.verticalCenter: parent.verticalCenter font: Style.smallFont } } } Item { Layout.fillWidth: true Layout.fillHeight: true MouseArea { anchors.fill: parent anchors.topMargin: -Style.smallMargins anchors.bottomMargin: -Style.smallMargins onClicked: d.selectSet(productionSet) } Row { anchors.centerIn: parent spacing: Style.smallMargins opacity: productionSet.opacity ColorIcon { name: root.pvIcon === "" ? "qrc:/icons/weathericons/weather-clear-day.svg" : root.pvIcon size: Style.smallIconSize color: Style.powerSelfProductionConsumptionColor } Label { width: parent.parent.width - x elide: Text.ElideRight visible: legend.width > 500 text: qsTr("Produced") anchors.verticalCenter: parent.verticalCenter font: Style.smallFont } } } Item { Layout.fillWidth: true Layout.fillHeight: true MouseArea { anchors.fill: parent anchors.topMargin: -Style.smallMargins anchors.bottomMargin: -Style.smallMargins onClicked: d.selectSet(acquisitionSet) } Row { anchors.centerIn: parent spacing: Style.smallMargins opacity: acquisitionSet.opacity Row { ColorIcon { name: root.gridIcon === "" ? "qrc:/icons/power-grid.svg" : root.gridIcon size: Style.smallIconSize color: Style.powerAcquisitionColor } ColorIcon { name: "arrow-down" size: Style.smallIconSize color: Style.powerAcquisitionColor } } Label { width: parent.parent.width - x elide: Text.ElideRight visible: legend.width > 500 text: qsTr("From grid") anchors.verticalCenter: parent.verticalCenter font: Style.smallFont } } } Item { Layout.fillWidth: true Layout.fillHeight: true MouseArea { anchors.fill: parent anchors.topMargin: -Style.smallMargins anchors.bottomMargin: -Style.smallMargins onClicked: d.selectSet(returnSet) } Row { anchors.centerIn: parent spacing: Style.smallMargins opacity: returnSet.opacity Row { ColorIcon { name: root.gridIcon === "" ? "qrc:/icons/power-grid.svg" : root.gridIcon size: Style.smallIconSize color: Style.powerReturnColor } ColorIcon { name: "arrow-up" size: Style.smallIconSize color: Style.powerReturnColor } } Label { width: parent.parent.width - x elide: Text.ElideRight visible: legend.width > 500 text: qsTr("To grid") anchors.verticalCenter: parent.verticalCenter font: Style.smallFont } } } } Item { anchors.fill: parent anchors.leftMargin: chartView.x + chartView.plotArea.x anchors.topMargin: chartView.y + chartView.plotArea.y anchors.rightMargin: chartView.width - chartView.plotArea.width - chartView.plotArea.x anchors.bottomMargin: chartView.height - chartView.plotArea.height - chartView.plotArea.y z: -1 Rectangle { height: parent.height + Style.margins * 2 y: -Style.smallMargins radius: Style.smallCornerRadius width: chartView.plotArea.width / categoryAxis.count color: Style.tileBackgroundColor property int idx: Math.min(Math.max(0,Math.floor(mouseArea.mouseX * categoryAxis.count / mouseArea.width)), categoryAxis.count - 1) visible: toolTip.visible x: idx * parent.width / categoryAxis.count Behavior on x { enabled: toolTip.animationsEnabled; NumberAnimation { duration: Style.animationDuration } } } } MouseArea { id: mouseArea anchors.fill: parent anchors.leftMargin: chartView.x + chartView.plotArea.x anchors.topMargin: chartView.y + chartView.plotArea.y anchors.rightMargin: chartView.width - chartView.plotArea.width - chartView.plotArea.x anchors.bottomMargin: chartView.height - chartView.plotArea.height - chartView.plotArea.y hoverEnabled: true preventStealing: tooltipping || dragging property int startMouseX: 0 property bool dragging: false property bool tooltipping: false property int dragStartOffset: 0 Timer { interval: 300 running: mouseArea.pressed onTriggered: { if (!mouseArea.dragging) { mouseArea.tooltipping = true } } } onReleased: { if (mouseArea.dragging) { powerBalanceLogs.fetchLogs() mouseArea.dragging = false; } mouseArea.tooltipping = false; } onPressed: { startMouseX = mouseX dragStartOffset = d.startOffset } onDoubleClicked: { var idx = Math.ceil(mouseArea.mouseX * d.config.count / mouseArea.width) - 1 var timestamp = root.calculateTimestamp(d.config.startTime(), d.config.sampleRate, d.startOffset + idx) selectionTabs.currentIndex-- var startTime = d.config.startTime() d.startOffset = (timestamp.getTime() - startTime.getTime()) / (d.config.sampleRate * 60 * 1000) powerBalanceLogs.fetchLogs(); } onMouseXChanged: { if (!pressed || mouseArea.tooltipping) { return; } if (Math.abs(startMouseX - mouseX) < 10) { return; } dragging = true var dragDelta = startMouseX - mouseX var slotWidth = mouseArea.width / d.config.count var offset = Math.floor(dragDelta / slotWidth); d.startOffset = Math.min(dragStartOffset + offset, 0) d.fetchPending = true; } property int wheelDelta: 0 onWheel: (wheel) => { wheelDelta += wheel.pixelDelta.x var slotWidth = mouseArea.width / d.config.count while (wheelDelta > slotWidth) { d.startOffset-- wheelDelta -= slotWidth } while (wheelDelta < -slotWidth) { d.startOffset = Math.min(d.startOffset + 1, 0) wheelDelta += slotWidth } d.fetchPending = true; wheelStopTimer.restart() } Timer { id: wheelStopTimer interval: 300 repeat: false onTriggered: powerBalanceLogs.fetchLogs() } NymeaToolTip { id: toolTip backgroundItem: chartView backgroundRect: Qt.rect(chartView.plotArea.x + toolTip.x, chartView.plotArea.y + toolTip.y, toolTip.width, toolTip.height) property int idx: visible ? Math.min(d.config.count -1, Math.max(0, Math.ceil(mouseArea.mouseX * d.config.count / mouseArea.width) - 1)) : 0 property date timestamp: root.calculateTimestamp(d.config.startTime(), d.config.sampleRate, d.startOffset + idx) visible: (mouseArea.containsMouse || mouseArea.tooltipping) && !mouseArea.dragging property int chartWidth: chartView.plotArea.width property int barWidth: chartWidth / categoryAxis.count x: chartWidth - (idx * barWidth + barWidth + Style.smallMargins) > width ? idx * barWidth + barWidth + Style.smallMargins : idx * barWidth - Style.smallMargins - width property double setMaxValue: d.startOffset !== undefined ? Math.max(consumptionSet.at(idx), productionSet.at(idx), acquisitionSet.at(idx), returnSet.at(idx)) : 0 y: Math.min(Math.max(mouseArea.height - (setMaxValue * mouseArea.height / valueAxis.max) - height - Style.smallMargins, 0), mouseArea.height - height) width: tooltipLayout.implicitWidth + Style.smallMargins * 2 height: tooltipLayout.implicitHeight + Style.smallMargins * 2 ColumnLayout { id: tooltipLayout anchors { left: parent.left top: parent.top margins: Style.smallMargins } Label { text: d.config.toLongLabel(toolTip.timestamp) font: Style.smallFont } RowLayout { visible: root.hasProducers Rectangle { width: Style.extraSmallFont.pixelSize height: width color: Style.powerConsumptionColor } Label { text: d.startOffset !== undefined ? qsTr("Consumed: %1 kWh").arg(consumptionSet.at(toolTip.idx).toFixed(2)) : "" font: Style.extraSmallFont } } RowLayout { visible: root.hasProducers Rectangle { width: Style.extraSmallFont.pixelSize height: width color: Style.powerSelfProductionConsumptionColor } Label { text: d.startOffset !== undefined ? qsTr("Produced: %1 kWh").arg(productionSet.at(toolTip.idx).toFixed(2)) : "" font: Style.extraSmallFont } } RowLayout { Rectangle { width: Style.extraSmallFont.pixelSize height: width color: Style.powerAcquisitionColor } Label { text: d.startOffset !== undefined ? qsTr("From grid: %1 kWh").arg(acquisitionSet.at(toolTip.idx).toFixed(2)) :"" font: Style.extraSmallFont } } RowLayout { Rectangle { width: Style.extraSmallFont.pixelSize height: width color: Style.powerReturnColor } Label { text: d.startOffset !== undefined ? qsTr("To grid: %1 kWh").arg(returnSet.at(toolTip.idx).toFixed(2)) : "" font: Style.extraSmallFont } } } } } } } }