Skip to content

Commit

Permalink
Add PV inverters support #346
Browse files Browse the repository at this point in the history
Add solar data from devices at com.victronenergy.pvinverter.* on D-Bus
and mqtt/pvinverter on MQTT. The total solar output on the Brief page
now shows the combined output from PV chargers and PV inverters, and
PV inverters are also listed in the solar device list that is shown
when the solar overview widget is clicked.

Adjust the UI to accommodate for differences between PV inverters and
the PV chargers that were already supported. The main difference is
that PV inverters do not have solar history data. So:

- The solar gauge on the Brief page now shows runtime history from
approximately the last 15 mintues, instead of from the last few days
- The solar widget on the Overview page now shows PV inverter phase
data instead of history data, if no PV chargers are present
  • Loading branch information
blammit committed Jul 13, 2023
1 parent bcf8693 commit 9d33e21
Show file tree
Hide file tree
Showing 24 changed files with 801 additions and 82 deletions.
1 change: 1 addition & 0 deletions Global.qml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ QtObject {
property var generators
property var inverters
property var notifications
property var pvInverters
property var relays
property var solarChargers
property var system
Expand Down
19 changes: 19 additions & 0 deletions components/SolarMeasurements.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*
** Copyright (C) 2023 Victron Energy B.V.
*/

import QtQuick
import Victron.VenusOS

//
// Provides combined solar measurement data from PV chargers and PV inverters.
//
QtObject {
id: root

readonly property real power: isNaN(Global.solarChargers.power) && isNaN(Global.pvInverters.power) ? NaN
: (Global.solarChargers.power || 0) + (Global.pvInverters.power || 0)

readonly property real current: isNaN(Global.solarChargers.current) && isNaN(Global.pvInverters.current) ? NaN
: (Global.solarChargers.current || 0) + (Global.pvInverters.current || 0)
}
46 changes: 40 additions & 6 deletions components/SolarYieldGauge.qml
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,7 @@ Item {
Repeater {
id: gaugeRepeater

model: SolarYieldModel {
id: yieldModel
dayRange: [0, Theme.geometry.briefPage.solarHistoryGauge.maximumGaugeCount + 1]
}
model: powerSampler.sampledAverages.length + 1

delegate: ScaledArcGauge {
width: Theme.geometry.briefPage.edgeGauge.width
Expand All @@ -36,7 +33,38 @@ Item {
direction: PathArc.Counterclockwise
strokeWidth: Theme.geometry.arc.strokeWidth
arcY: root.alignment & Qt.AlignVCenter ? undefined : -radius + strokeWidth/2
value: yieldModel.maximumYield ? Utils.scaleToRange(model.yieldKwh, 0, yieldModel.maximumYield, 0, 100) : 100
value: {
// First gauge shows the current runtime power, other gauges show historical values.
const power = model.index === 0 ? solarMeasurements.power : powerSampler.sampledAverages[model.index - 1]
return Utils.scaleToRange(power, 0, solarMeasurements.maxPower, 0, 100)
}
}
}

// Take 30-second samples of the solar power. Every 5 minutes, take the average of these samples
// and add a new gauge bar with that value.
Timer {
id: powerSampler

property var sampledAverages: []
property var _activeSamples: []

running: true
repeat: true
interval: 30 * 1000
onTriggered: {
_activeSamples.push(solarMeasurements.power)
if (_activeSamples.length < 10) {
return
}
const averagePower = _activeSamples.reduce((a, b) => a + b) / _activeSamples.length
let newAverages = sampledAverages
newAverages.unshift(averagePower)
if (newAverages.length >= Theme.geometry.briefPage.solarHistoryGauge.maximumGaugeCount) {
newAverages.pop()
}
_activeSamples = []
sampledAverages = newAverages
}
}

Expand All @@ -45,7 +73,13 @@ Item {

alignment: root.alignment
icon.source: "qrc:/images/solaryield.svg"
quantityLabel.dataObject: Global.solarChargers
quantityLabel.dataObject: SolarMeasurements {
id: solarMeasurements

property real maxPower: NaN

onPowerChanged: maxPower = isNaN(maxPower) ? power : Math.max(maxPower, power)
}
}
}

56 changes: 46 additions & 10 deletions components/widgets/SolarYieldWidget.qml
Original file line number Diff line number Diff line change
Expand Up @@ -15,28 +15,64 @@ OverviewWidget {
border.width: enabled ? Theme.geometry.overviewPage.widget.border.width : 0
border.color: Theme.color.overviewPage.widget.border

quantityLabel.dataObject: Global.solarChargers
quantityLabel.dataObject: SolarMeasurements {}

// Solar yield history is only available for PV chargers, and phase data is only available for
// PV inverters. So, if there are only solar chargers, show the solar history; otherwise if
// there is a single PV inverter, show its phase data.
extraContent.children: [
SolarYieldGraph {
Loader {
readonly property int margin: sourceComponent === historyComponent
? Theme.geometry.overviewPage.widget.solar.graph.margins
: Theme.geometry.overviewPage.widget.extraContent.bottomMargin

anchors {
horizontalCenter: parent.horizontalCenter
left: parent.left
leftMargin: margin
right: parent.right
rightMargin: margin
bottom: parent.bottom
bottomMargin: Theme.geometry.overviewPage.widget.solar.graph.margins
bottomMargin: margin
}
sourceComponent: {
if (root.size >= VenusOS.OverviewWidget_Size_L) {
if (Global.pvInverters.model.count === 1 && Global.solarChargers.model.count === 0) {
return phaseComponent
} else if (Global.pvInverters.model.count === 0) {
return historyComponent
}
}
return null
}
visible: root.size >= VenusOS.OverviewWidget_Size_L
width: parent.width - Theme.geometry.overviewPage.widget.solar.graph.margins*2
height: parent.height - (2 * Theme.geometry.overviewPage.widget.solar.graph.margins)
}
]

Component {
id: phaseComponent

ThreePhaseDisplay {
model: Global.pvInverters.model.objectAt(0).phases
}
}

Component {
id: historyComponent

SolarYieldGraph {
height: root.extraContent.height - (2 * Theme.geometry.overviewPage.widget.solar.graph.margins)
}
}

MouseArea {
anchors.fill: parent
enabled: Global.solarChargers.model.count > 0
onClicked: {
if (Global.solarChargers.model.count === 1) {
const singleDeviceOnly = (Global.solarChargers.model.count + Global.pvInverters.model.count) === 1
if (singleDeviceOnly && Global.solarChargers.model.count === 1) {
Global.pageManager.pushPage("/pages/solar/SolarChargerPage.qml",
{ "solarCharger": Global.solarChargers.model.get(0).solarCharger })
{ "solarCharger": Global.solarChargers.model.objectAt(0) })
} else if (singleDeviceOnly && Global.pvInverters.model === 1) {
Global.pageManager.pushPage("/pages/solar/PvInverterPage.qml",
{ "pvInverter": Global.pvInverters.model.objectAt(0) })
} else {
Global.pageManager.pushPage("/pages/solar/SolarDeviceListPage.qml", { "title": root.title })
}
Expand Down
2 changes: 2 additions & 0 deletions data/DataManager.qml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Item {
&& !!Global.generators
&& !!Global.inverters
&& !!Global.notifications
&& !!Global.pvInverters
&& !!Global.relays
&& !!Global.solarChargers
&& !!Global.system
Expand Down Expand Up @@ -80,6 +81,7 @@ Item {
Generators {}
Inverters {}
Notifications {}
PvInverters {}
Relays {}
SolarChargers {}
System {}
Expand Down
93 changes: 93 additions & 0 deletions data/PvInverters.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/*
** Copyright (C) 2022 Victron Energy B.V.
*/

import QtQuick
import Victron.VenusOS
import "common"

QtObject {
id: root

property DeviceModel model: DeviceModel {
objectProperty: "pvInverter"
}

property real energy: NaN
property real power: NaN
property real current: NaN
property real voltage: NaN

function addInverter(inverter) {
if (model.addObject(inverter)) {
updateTotalMeasurements()
}
}

function removeInverter(inverter) {
if (model.removeObject(inverter.serviceUid)) {
updateTotalMeasurements()
}
}

function updateTotalMeasurements() {
const totals = model.totalValues(["energy", "power", "current", "voltage"])
energy = totals.energy
power = totals.power
current = totals.current
voltage = totals.voltage
}

function reset() {
model.clear()
updateTotalMeasurements()
}

function statusCodeToText(statusCode) {
switch (statusCode) {
case VenusOS.PvInverter_StatusCode_Startup0:
//% "Startup 0"
return qsTrId("pvinverters_statusCode_startup_0")
case VenusOS.PvInverter_StatusCode_Startup1:
//% "Startup 1"
return qsTrId("pvinverters_statusCode_startup_1")
case VenusOS.PvInverter_StatusCode_Startup2:
//% "Startup 2"
return qsTrId("pvinverters_statusCode_startup_2")
case VenusOS.PvInverter_StatusCode_Startup3:
//% "Startup 3"
return qsTrId("pvinverters_statusCode_startup_3")
case VenusOS.PvInverter_StatusCode_Startup4:
//% "Startup 4"
return qsTrId("pvinverters_statusCode_startup_4")
case VenusOS.PvInverter_StatusCode_Startup5:
//% "Startup 5"
return qsTrId("pvinverters_statusCode_startup_5")
case VenusOS.PvInverter_StatusCode_Startup6:
//% "Startup 6"
return qsTrId("pvinverters_statusCode_startup_6")
case VenusOS.PvInverter_StatusCode_Running:
//% "Running"
return qsTrId("pvinverters_statusCode_running")
case VenusOS.PvInverter_StatusCode_Standby:
//% "Standby"
return qsTrId("pvinverters_statusCode_standby")
case VenusOS.PvInverter_StatusCode_BootLoading:
//% "Boot loading"
return qsTrId("pvinverters_statusCode_boot_loading")
case VenusOS.PvInverter_StatusCode_Error:
//% "Error"
return qsTrId("pvinverters_statusCode_error")
case VenusOS.PvInverter_StatusCode_RunningMPPT:
//% "Running (MPPT)"
return qsTrId("pvinverters_statusCode_running_mppt")
case VenusOS.PvInverter_StatusCode_RunningThrottled:
//% "Running (Throttled)"
return qsTrId("pvinverters_statusCode_running_throttled")
default:
return ""
}
}

Component.onCompleted: Global.pvInverters = root
}
22 changes: 22 additions & 0 deletions data/common/DeviceModel.qml
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,28 @@ ListModel {
return get(index)[objectProperty]
}

function totalValues(propertyNames) {
let totals = {}
let propertyIndex
for (propertyIndex = 0; propertyIndex < propertyNames.length; ++propertyIndex) {
totals[propertyNames[propertyIndex]] = NaN
}
for (let i = 0; i < count; ++i) {
const object = objectAt(i)
for (propertyIndex = 0; propertyIndex < propertyNames.length; ++propertyIndex) {
const propertyName = propertyNames[propertyIndex]
const value = object[propertyName]
if (!isNaN(value)) {
if (isNaN(totals[propertyName])) {
totals[propertyName] = 0
}
totals[propertyName] += value
}
}
}
return totals
}

function _refreshFirstObject() {
firstObject = count === 0 ? null : objectAt(0)
}
Expand Down
Loading

0 comments on commit 9d33e21

Please sign in to comment.