Fix battery sign and separate PV from inverter output

Verified against simultaneous SolarEdge app readings (PV 40 W,
battery discharge 520 W, house 560 W): battery1_power negative
means discharge, and power_ac on this DC-coupled Energy Hub is
the inverter's TOTAL AC output - it already contains the battery
contribution. The old formula double-counted the battery, so
house_load collapsed (44 W shown vs 560 W real) and the PV tile
showed battery-inflated totals, which looked like PV and house
consumption were swapped.

New derived fields: house_load = power_ac + grid, and
pv_power = power_ac - battery (true PV production). Dashboard PV
tiles and both yield panels now use pv_power / integral(pv_power)
- the energy_total lifetime counter also counts battery
throughput and is not a PV yield.
This commit is contained in:
dj7nt committed 2026-10-10 17:48:23 +02:00
1 parent 412accc003
commit 0ec3bcb8ef
4 files changed
+57 -42

No files matched your search

+11 -8
View File
@@ -56,15 +56,18 @@ Import `grafana_dashboard.json` (Dashboards -> New -> Import). Select your
InfluxDB datasource when prompted (e.g. `InfluxDB-HJK`); the `$serial` variable InfluxDB datasource when prompted (e.g. `InfluxDB-HJK`); the `$serial` variable
is filled from the `serialnumber` tag. InfluxQL only, InfluxDB 1.x. is filled from the `serialnumber` tag. InfluxQL only, InfluxDB 1.x.
The daemon also writes derived flow fields (`house_load`, `grid_import`, The daemon also writes derived flow fields (`house_load`, `pv_power`,
`grid_export`, `battery_charge`, `battery_discharge`, `solar_to_house`) so the `grid_import`, `grid_export`, `battery_charge`, `battery_discharge`,
dashboard's consumption-source panels are plain `mean()`/`integral()` queries. `solar_to_house`) so the dashboard's consumption-source panels are plain
`mean()`/`integral()` queries.
**Sign calibration:** `meter1_power` positive = import and `battery1_power` **Sign conventions (verified against the SolarEdge app):** `meter1_power`
positive = discharge are assumptions observed once; verify on the first sunny positive = export, `battery1_power` negative = discharge, and `power_ac` is
day (export while surplus / charge while battery fills). If inverted, flip the inverter's *total* AC output (PV + battery, DC-coupled Energy Hub) - which
`METER_IMPORT_POSITIVE` / `BATTERY_DISCHARGE_POSITIVE` in is why `pv_power = power_ac - battery` and `house_load = power_ac + grid`.
`solaredge_modbus_mqtt/__init__.py` and restart - all derived fields follow. If a different system disagrees, flip `METER_IMPORT_POSITIVE` /
`BATTERY_DISCHARGE_POSITIVE` in `solaredge_modbus_mqtt/__init__.py` and
restart - the derived fields follow.
### Uninstall ### Uninstall
+4 -4
View File
@@ -66,7 +66,7 @@
"type": "influxdb", "type": "influxdb",
"uid": "${DS_INFLUXDB}" "uid": "${DS_INFLUXDB}"
}, },
"query": "SELECT last(\"power_ac\") AS \"PV\" FROM \"$measurement\" WHERE serialnumber =~ /^$serial$/ AND $timeFilter", "query": "SELECT last(\"pv_power\") AS \"PV\" FROM \"$measurement\" WHERE serialnumber =~ /^$serial$/ AND $timeFilter",
"refId": "A", "refId": "A",
"rawQuery": true, "rawQuery": true,
"resultFormat": "time_series" "resultFormat": "time_series"
@@ -387,7 +387,7 @@
"type": "influxdb", "type": "influxdb",
"uid": "${DS_INFLUXDB}" "uid": "${DS_INFLUXDB}"
}, },
"query": "SELECT spread(\"energy_total\") / 1000 AS \"Ertrag\" FROM \"$measurement\" WHERE serialnumber =~ /^$serial$/ AND $timeFilter", "query": "SELECT integral(\"pv_power\", 1h) / 1000 AS \"Ertrag\" FROM \"$measurement\" WHERE serialnumber =~ /^$serial$/ AND $timeFilter",
"refId": "A", "refId": "A",
"rawQuery": true, "rawQuery": true,
"resultFormat": "time_series" "resultFormat": "time_series"
@@ -808,7 +808,7 @@
"type": "influxdb", "type": "influxdb",
"uid": "${DS_INFLUXDB}" "uid": "${DS_INFLUXDB}"
}, },
"query": "SELECT mean(\"power_ac\") AS \"PV\" FROM \"$measurement\" WHERE serialnumber =~ /^$serial$/ AND $timeFilter GROUP BY time($__interval) fill(null)", "query": "SELECT mean(\"pv_power\") AS \"PV\" FROM \"$measurement\" WHERE serialnumber =~ /^$serial$/ AND $timeFilter GROUP BY time($__interval) fill(null)",
"refId": "A", "refId": "A",
"rawQuery": true, "rawQuery": true,
"resultFormat": "time_series" "resultFormat": "time_series"
@@ -1177,7 +1177,7 @@
"type": "influxdb", "type": "influxdb",
"uid": "${DS_INFLUXDB}" "uid": "${DS_INFLUXDB}"
}, },
"query": "SELECT non_negative_derivative(max(\"energy_total\"), 1h) / 1000 AS \"Ertrag\" FROM \"$measurement\" WHERE serialnumber =~ /^$serial$/ AND $timeFilter GROUP BY time(1h) fill(null)", "query": "SELECT integral(\"pv_power\", 1h) / 1000 AS \"Ertrag\" FROM \"$measurement\" WHERE serialnumber =~ /^$serial$/ AND $timeFilter GROUP BY time(1h) fill(null)",
"refId": "A", "refId": "A",
"rawQuery": true, "rawQuery": true,
"resultFormat": "time_series" "resultFormat": "time_series"
+21 -16
View File
@@ -86,13 +86,14 @@ storage_mode_map = [
"Remote Control" "Remote Control"
] ]
# Observed on the probed system: meter1_power positive = grid EXPORT # Verified against the SolarEdge app (simultaneous readings: PV 40 W, battery
# (verified against a full-battery self-consumption system: sustained +2kW # discharging 520 W, house 560 W): meter1_power positive = grid EXPORT, and
# readings at high PV were exports; a kettle appears as a negative spike). # battery1_power negative = DISCHARGE. Also established: power_ac is the
# battery1_power positive = discharge. If day-one graphs disagree, flip here - # inverter's TOTAL AC output - on this DC-coupled Energy Hub it already
# all derived flow fields (house_load, grid_import, ...) follow. # contains the battery's contribution. If a future system disagrees, flip here
# - all derived flow fields follow.
METER_IMPORT_POSITIVE = False METER_IMPORT_POSITIVE = False
BATTERY_DISCHARGE_POSITIVE = True BATTERY_DISCHARGE_POSITIVE = False
class MyInverter: class MyInverter:
@@ -417,33 +418,37 @@ class SolarData:
@staticmethod @staticmethod
def _flows(d): def _flows(d):
'''Decompose the house supply into grid/battery/solar components. '''Decompose house supply and PV production.
power_ac is the inverter's TOTAL AC output and already contains the
battery's contribution (DC-coupled Energy Hub), so:
pv_power = power_ac - battery (discharge-positive)
house_load = power_ac + grid
Computed at ingest because all fields land in one InfluxDB point and Computed at ingest because all fields land in one InfluxDB point and
InfluxQL cannot clip series (max(x, 0)) or split them afterwards - InfluxQL cannot clip series (max(x, 0)) or split them afterwards -
dashboard queries stay plain mean()/integral(). dashboard queries stay plain mean()/integral().
''' '''
pv = d.get('power_ac') pv_ac = d.get('power_ac')
grid = d.get('meter1_power') meter = d.get('meter1_power')
batt = d.get('battery1_power') batt_raw = d.get('battery1_power')
if pv is None or grid is None or batt is None: if pv_ac is None or meter is None or batt_raw is None:
return {} return {}
if not METER_IMPORT_POSITIVE: grid = meter if METER_IMPORT_POSITIVE else -meter # + = import
grid = -grid batt = batt_raw if BATTERY_DISCHARGE_POSITIVE else -batt_raw # + = discharge
if not BATTERY_DISCHARGE_POSITIVE:
batt = -batt
load = pv + grid + batt load = pv_ac + grid
grid_import = max(grid, 0.0) grid_import = max(grid, 0.0)
grid_export = max(-grid, 0.0) grid_export = max(-grid, 0.0)
battery_discharge = max(batt, 0.0) battery_discharge = max(batt, 0.0)
battery_charge = max(-batt, 0.0) battery_charge = max(-batt, 0.0)
pv_power = max(pv_ac - batt, 0.0)
solar_to_house = max(load - grid_import - battery_discharge, 0.0) solar_to_house = max(load - grid_import - battery_discharge, 0.0)
return { return {
# + 0.0 normalizes -0.0 from max(-x, 0.0) # + 0.0 normalizes -0.0 from max(-x, 0.0)
'house_load': round(max(load, 0.0), 4) + 0.0, 'house_load': round(max(load, 0.0), 4) + 0.0,
'pv_power': round(pv_power, 4) + 0.0,
'grid_import': round(grid_import, 4) + 0.0, 'grid_import': round(grid_import, 4) + 0.0,
'grid_export': round(grid_export, 4) + 0.0, 'grid_export': round(grid_export, 4) + 0.0,
'battery_discharge': round(battery_discharge, 4) + 0.0, 'battery_discharge': round(battery_discharge, 4) + 0.0,
+21 -14
View File
@@ -209,32 +209,39 @@ class TestSolarData(unittest.TestCase):
self.assertNotIn('storage_control_mode', solarData.data) self.assertNotIn('storage_control_mode', solarData.data)
self.assertAlmostEqual(597.0, solarData.data['power_ac']) self.assertAlmostEqual(597.0, solarData.data['power_ac'])
def testFlowsImport(self): def testFlowsNightDischarge(self):
# kettle: PV 861.2 W, grid meter reads -689.5 (import), battery idle # ground truth from the SolarEdge app: PV 40 W, battery discharging
solarData = SolarData(probe_data(power_ac=8612, m1_power=-6895, b1_power=0.0)) # 520 W, house 560 W, grid ~0. power_ac (529) = PV + battery output.
solarData = SolarData(probe_data(power_ac=5290, m1_power=0, b1_power=-485.0))
self.assertAlmostEqual(1550.7, solarData.data['house_load']) self.assertAlmostEqual(529.0, solarData.data['house_load'])
self.assertAlmostEqual(689.5, solarData.data['grid_import']) self.assertAlmostEqual(44.0, solarData.data['pv_power'])
self.assertAlmostEqual(0.0, solarData.data['grid_export']) self.assertAlmostEqual(485.0, solarData.data['battery_discharge'])
self.assertAlmostEqual(0.0, solarData.data['battery_charge']) self.assertAlmostEqual(0.0, solarData.data['battery_charge'])
self.assertAlmostEqual(0.0, solarData.data['battery_discharge']) self.assertAlmostEqual(0.0, solarData.data['grid_import'])
self.assertAlmostEqual(861.2, solarData.data['solar_to_house']) self.assertAlmostEqual(44.0, solarData.data['solar_to_house'])
def testFlowsExport(self): def testFlowsSurplus(self):
# observed afternoon: PV 5000, exporting 2000, charging battery with 1000 # PV 5000, charging battery with 1000, exporting 2000, house 2000.
solarData = SolarData(probe_data(power_ac=50000, m1_power=20000, b1_power=-1000.0)) # Charging happens on the DC side: power_ac only carries 4000.
solarData = SolarData(probe_data(power_ac=40000, m1_power=20000, b1_power=1000.0))
self.assertAlmostEqual(2000.0, solarData.data['house_load']) self.assertAlmostEqual(2000.0, solarData.data['house_load'])
self.assertAlmostEqual(5000.0, solarData.data['pv_power'])
self.assertAlmostEqual(0.0, solarData.data['grid_import']) self.assertAlmostEqual(0.0, solarData.data['grid_import'])
self.assertAlmostEqual(2000.0, solarData.data['grid_export']) self.assertAlmostEqual(2000.0, solarData.data['grid_export'])
self.assertAlmostEqual(1000.0, solarData.data['battery_charge']) self.assertAlmostEqual(1000.0, solarData.data['battery_charge'])
self.assertAlmostEqual(0.0, solarData.data['battery_discharge']) self.assertAlmostEqual(0.0, solarData.data['battery_discharge'])
self.assertAlmostEqual(2000.0, solarData.data['solar_to_house']) self.assertAlmostEqual(2000.0, solarData.data['solar_to_house'])
def testFlowsNightDischarge(self): def testFlowsImport(self):
solarData = SolarData(probe_data(power_ac=0, m1_power=0, b1_power=300.0)) # no sun: battery covers 300 W of the house, 200 W imported.
# power_ac = 300 (battery output), meter reads -200 (import).
solarData = SolarData(probe_data(power_ac=3000, m1_power=-2000, b1_power=-300.0))
self.assertAlmostEqual(300.0, solarData.data['house_load']) self.assertAlmostEqual(500.0, solarData.data['house_load'])
self.assertAlmostEqual(0.0, solarData.data['pv_power'])
self.assertAlmostEqual(200.0, solarData.data['grid_import'])
self.assertAlmostEqual(300.0, solarData.data['battery_discharge']) self.assertAlmostEqual(300.0, solarData.data['battery_discharge'])
self.assertAlmostEqual(0.0, solarData.data['solar_to_house']) self.assertAlmostEqual(0.0, solarData.data['solar_to_house'])