diff --git a/README.md b/README.md index 745b573..b0c7476 100644 --- a/README.md +++ b/README.md @@ -56,15 +56,18 @@ Import `grafana_dashboard.json` (Dashboards -> New -> Import). Select your InfluxDB datasource when prompted (e.g. `InfluxDB-HJK`); the `$serial` variable is filled from the `serialnumber` tag. InfluxQL only, InfluxDB 1.x. -The daemon also writes derived flow fields (`house_load`, `grid_import`, -`grid_export`, `battery_charge`, `battery_discharge`, `solar_to_house`) so the -dashboard's consumption-source panels are plain `mean()`/`integral()` queries. +The daemon also writes derived flow fields (`house_load`, `pv_power`, +`grid_import`, `grid_export`, `battery_charge`, `battery_discharge`, +`solar_to_house`) so the dashboard's consumption-source panels are plain +`mean()`/`integral()` queries. -**Sign calibration:** `meter1_power` positive = import and `battery1_power` -positive = discharge are assumptions observed once; verify on the first sunny -day (export while surplus / charge while battery fills). If inverted, flip -`METER_IMPORT_POSITIVE` / `BATTERY_DISCHARGE_POSITIVE` in -`solaredge_modbus_mqtt/__init__.py` and restart - all derived fields follow. +**Sign conventions (verified against the SolarEdge app):** `meter1_power` +positive = export, `battery1_power` negative = discharge, and `power_ac` is +the inverter's *total* AC output (PV + battery, DC-coupled Energy Hub) - which +is why `pv_power = power_ac - battery` and `house_load = power_ac + grid`. +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 diff --git a/grafana_dashboard.json b/grafana_dashboard.json index b15fbac..7373ae6 100644 --- a/grafana_dashboard.json +++ b/grafana_dashboard.json @@ -66,7 +66,7 @@ "type": "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", "rawQuery": true, "resultFormat": "time_series" @@ -387,7 +387,7 @@ "type": "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", "rawQuery": true, "resultFormat": "time_series" @@ -808,7 +808,7 @@ "type": "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", "rawQuery": true, "resultFormat": "time_series" @@ -1177,7 +1177,7 @@ "type": "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", "rawQuery": true, "resultFormat": "time_series" diff --git a/solaredge_modbus_mqtt/__init__.py b/solaredge_modbus_mqtt/__init__.py index a6b67b5..11f20bf 100644 --- a/solaredge_modbus_mqtt/__init__.py +++ b/solaredge_modbus_mqtt/__init__.py @@ -86,13 +86,14 @@ storage_mode_map = [ "Remote Control" ] -# Observed on the probed system: meter1_power positive = grid EXPORT -# (verified against a full-battery self-consumption system: sustained +2kW -# readings at high PV were exports; a kettle appears as a negative spike). -# battery1_power positive = discharge. If day-one graphs disagree, flip here - -# all derived flow fields (house_load, grid_import, ...) follow. +# Verified against the SolarEdge app (simultaneous readings: PV 40 W, battery +# discharging 520 W, house 560 W): meter1_power positive = grid EXPORT, and +# battery1_power negative = DISCHARGE. Also established: power_ac is the +# inverter's TOTAL AC output - on this DC-coupled Energy Hub it already +# contains the battery's contribution. If a future system disagrees, flip here +# - all derived flow fields follow. METER_IMPORT_POSITIVE = False -BATTERY_DISCHARGE_POSITIVE = True +BATTERY_DISCHARGE_POSITIVE = False class MyInverter: @@ -417,33 +418,37 @@ class SolarData: @staticmethod 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 InfluxQL cannot clip series (max(x, 0)) or split them afterwards - dashboard queries stay plain mean()/integral(). ''' - pv = d.get('power_ac') - grid = d.get('meter1_power') - batt = d.get('battery1_power') - if pv is None or grid is None or batt is None: + pv_ac = d.get('power_ac') + meter = d.get('meter1_power') + batt_raw = d.get('battery1_power') + if pv_ac is None or meter is None or batt_raw is None: return {} - if not METER_IMPORT_POSITIVE: - grid = -grid - if not BATTERY_DISCHARGE_POSITIVE: - batt = -batt + grid = meter if METER_IMPORT_POSITIVE else -meter # + = import + batt = batt_raw if BATTERY_DISCHARGE_POSITIVE else -batt_raw # + = discharge - load = pv + grid + batt + load = pv_ac + grid grid_import = max(grid, 0.0) grid_export = max(-grid, 0.0) battery_discharge = 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) return { # + 0.0 normalizes -0.0 from max(-x, 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_export': round(grid_export, 4) + 0.0, 'battery_discharge': round(battery_discharge, 4) + 0.0, diff --git a/solaredge_modbus_mqtt/test_SolarData.py b/solaredge_modbus_mqtt/test_SolarData.py index d69eecd..7202149 100644 --- a/solaredge_modbus_mqtt/test_SolarData.py +++ b/solaredge_modbus_mqtt/test_SolarData.py @@ -209,32 +209,39 @@ class TestSolarData(unittest.TestCase): self.assertNotIn('storage_control_mode', solarData.data) self.assertAlmostEqual(597.0, solarData.data['power_ac']) - def testFlowsImport(self): - # kettle: PV 861.2 W, grid meter reads -689.5 (import), battery idle - solarData = SolarData(probe_data(power_ac=8612, m1_power=-6895, b1_power=0.0)) + def testFlowsNightDischarge(self): + # ground truth from the SolarEdge app: PV 40 W, battery discharging + # 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(689.5, solarData.data['grid_import']) - self.assertAlmostEqual(0.0, solarData.data['grid_export']) + self.assertAlmostEqual(529.0, solarData.data['house_load']) + self.assertAlmostEqual(44.0, solarData.data['pv_power']) + self.assertAlmostEqual(485.0, solarData.data['battery_discharge']) self.assertAlmostEqual(0.0, solarData.data['battery_charge']) - self.assertAlmostEqual(0.0, solarData.data['battery_discharge']) - self.assertAlmostEqual(861.2, solarData.data['solar_to_house']) + self.assertAlmostEqual(0.0, solarData.data['grid_import']) + self.assertAlmostEqual(44.0, solarData.data['solar_to_house']) - def testFlowsExport(self): - # observed afternoon: PV 5000, exporting 2000, charging battery with 1000 - solarData = SolarData(probe_data(power_ac=50000, m1_power=20000, b1_power=-1000.0)) + def testFlowsSurplus(self): + # PV 5000, charging battery with 1000, exporting 2000, house 2000. + # 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(5000.0, solarData.data['pv_power']) self.assertAlmostEqual(0.0, solarData.data['grid_import']) self.assertAlmostEqual(2000.0, solarData.data['grid_export']) self.assertAlmostEqual(1000.0, solarData.data['battery_charge']) self.assertAlmostEqual(0.0, solarData.data['battery_discharge']) self.assertAlmostEqual(2000.0, solarData.data['solar_to_house']) - def testFlowsNightDischarge(self): - solarData = SolarData(probe_data(power_ac=0, m1_power=0, b1_power=300.0)) + def testFlowsImport(self): + # 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(0.0, solarData.data['solar_to_house'])