Skip to content

Commit d400762

Browse files
fix(sunsynk): pvMaxLimit is the export cap, not the inverter limit
Sunsynk's documentation (checked by the system owner) confirms the app's "Inverter Power Limiter" control limits EXPORT despite its label - which is why the app shows the same value under that name in System Mode and as "Export power limiter" in Grid Settings, and why pvMaxLimit was the only settings field holding the 7000 both screens displayed. So the two limits swap owners: inverter_limit -> ratePower (8000W) - the inverter can still deliver its full rating to the house, so reducing it by the export cap would under-rate self-consumption export_limit -> pvMaxLimit (7000W), bounded by the rating - previously unmapped, leaving inverter.py's effectively unlimited 99999W default in place That default is the reason this matters: a G98/G99 site capped at 3.68kW behind a much larger inverter would otherwise have Predbat plan exports the inverter simply clips. Tests pin both directions: the rating is not reduced by the cap, the cap is not raised above the rating, and the G98 case survives intact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 272bd68 commit d400762

4 files changed

Lines changed: 78 additions & 64 deletions

File tree

apps/predbat/sunsynk.py

Lines changed: 31 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
SUNSYNK_CAPACITY_AH_FIELD,
4545
SUNSYNK_CHARGE_VOLT_FIELD,
4646
SUNSYNK_CHARGE_CURRENT_FIELDS,
47-
SUNSYNK_POWER_LIMIT_FIELD,
47+
SUNSYNK_EXPORT_LIMIT_FIELD,
4848
SUNSYNK_RATED_POWER_FIELD,
4949
SUNSYNK_BATTERY_LOW_CAP_FIELD,
5050
LIFEPO4_CELL_COUNTS,
@@ -583,15 +583,26 @@ def battery_rate_max(self, sn):
583583
return amps * volts
584584

585585
def inverter_limit(self, sn):
586-
"""Return the inverter's usable AC limit in watts, or 0 when unknown.
586+
"""Return the inverter's AC power rating in watts, or 0 when unknown.
587587
588-
The hardware rating (ratePower) is not the whole story: an installer-set power
589-
limiter (pvMaxLimit) can cap the inverter below it, and that cap is what actually
590-
binds. A real system was seen with ratePower 8000 and pvMaxLimit 7000, so taking the
591-
rating alone would have Predbat plan a kilowatt the inverter will never deliver.
592-
Whichever is lower wins; if only one is known, that one is used.
588+
This is the hardware rating (ratePower) only. It is deliberately NOT reduced by
589+
pvMaxLimit: despite that setting's "Inverter Power Limiter" label in the app, Sunsynk
590+
documents it as an EXPORT cap, so the inverter can still deliver its full rating to
591+
the house. See export_limit.
593592
"""
594-
limits = [value for value in (self.device_rated_power.get(sn, 0.0), self._as_float(self.device_settings.get(sn, {}).get(SUNSYNK_POWER_LIMIT_FIELD))) if value > 0]
593+
return self.device_rated_power.get(sn, 0.0)
594+
595+
def export_limit(self, sn):
596+
"""Return the maximum export power in watts, or 0 when unknown.
597+
598+
Predbat's inverter.py defaults export_limit to 99999W - effectively unlimited - so
599+
leaving this unmapped lets it plan an export the inverter will simply clip. A real
600+
system had a 7000W export cap behind an 8000W inverter.
601+
602+
Bounded by the inverter rating too: whatever the setting nominally allows, the
603+
inverter cannot export more AC than it can produce.
604+
"""
605+
limits = [value for value in (self.inverter_limit(sn), self._as_float(self.device_settings.get(sn, {}).get(SUNSYNK_EXPORT_LIMIT_FIELD))) if value > 0]
595606
return min(limits) if limits else 0.0
596607

597608
def battery_reserve_min(self, sn):
@@ -974,6 +985,9 @@ async def publish_data(self):
974985
rated_power = self.inverter_limit(sn)
975986
if rated_power > 0:
976987
self.dashboard_item(self._sensor_name(sn, "inverter_limit"), state=rated_power, attributes={"unit_of_measurement": "W", "friendly_name": f"Sunsynk {sn} Inverter Limit"}, app="sunsynk")
988+
export_cap = self.export_limit(sn)
989+
if export_cap > 0:
990+
self.dashboard_item(self._sensor_name(sn, "export_limit"), state=export_cap, attributes={"unit_of_measurement": "W", "friendly_name": f"Sunsynk {sn} Export Limit"}, app="sunsynk")
977991
floor = self.battery_reserve_min(sn)
978992
if floor > 0:
979993
self.dashboard_item(self._sensor_name(sn, "battery_reserve_min"), state=floor, attributes={"unit_of_measurement": "%", "friendly_name": f"Sunsynk {sn} Battery Reserve Min"}, app="sunsynk")
@@ -1343,20 +1357,15 @@ async def automatic_config(self):
13431357
self.set_arg_auto("inverter_limit", [self._sensor_name(sn, "inverter_limit") for sn in devices])
13441358
else:
13451359
self.log("Warn: Sunsynk no ratePower reported, inverter_limit must be set manually in apps.yaml")
1346-
# export_limit is deliberately NOT auto-mapped. The Sunsynk app has an "Export power
1347-
# limiter" in Grid Settings which can legitimately sit BELOW the inverter limit -
1348-
# a G98/G99 site is commonly capped at 3.68kW behind a much larger inverter - but no
1349-
# settings field could be identified that carries it: on the one system inspected the
1350-
# app showed 7000 for both the inverter and export limiters while pvMaxLimit was the
1351-
# only field holding 7000, so the two could not be told apart.
1352-
#
1353-
# Guessing it from pvMaxLimit would be worse than leaving it alone, because
1354-
# set_arg_auto always beats apps.yaml: a user who correctly set export_limit to their
1355-
# real 3.68kW cap would have it silently replaced by the inverter limit. Left unset,
1356-
# Predbat keeps its own default and the user's apps.yaml value is honoured.
1357-
self.log(
1358-
"Info: Sunsynk cannot identify the inverter's export power limiter from the settings object, so export_limit is left for apps.yaml. If your Export power limiter (app: Grid Settings) is lower than the inverter limit - a G98/G99 export cap, for instance - set export_limit manually."
1359-
)
1360+
# Without this Predbat falls back to inverter.py's effectively unlimited 99999W
1361+
# default and plans exports the inverter simply clips. pvMaxLimit is the export cap
1362+
# despite its "Inverter Power Limiter" label in the app - a G98/G99 site capped at
1363+
# 3.68kW behind a much larger inverter is exactly the case this protects.
1364+
# See SUNSYNK_EXPORT_LIMIT_FIELD.
1365+
if all(self.export_limit(sn) > 0 for sn in self.device_list):
1366+
self.set_arg_auto("export_limit", [self._sensor_name(sn, "export_limit") for sn in devices])
1367+
else:
1368+
self.log("Warn: Sunsynk no export power limit available, export_limit must be set manually in apps.yaml")
13601369
if all(self.battery_reserve_min(sn) > 0 for sn in self.device_list):
13611370
self.set_arg_auto("battery_min_soc", [self._sensor_name(sn, "battery_reserve_min") for sn in devices])
13621371

apps/predbat/sunsynk_const.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -167,14 +167,16 @@ def encode_setting(name, value):
167167
# 8000 with pvMaxLimit 7000, so using the rating alone would have Predbat plan a kilowatt
168168
# the inverter will never deliver.
169169
#
170-
# This is the INVERTER limit (confirmed by the system owner). The Sunsynk app surfaces the
171-
# same 7000 under two labels with an identical 500-16000W range - "Inverter Power Limiter"
172-
# (System Mode) and "Export power limiter" (Grid Settings) - and pvMaxLimit is the only
173-
# settings field holding 7000, so one register is evidently shown on both screens. It
174-
# therefore backs both inverter_limit and export_limit.
170+
# The EXPORT limit. The Sunsynk app shows this same value under two labels with an identical
171+
# 500-16000W range - "Inverter Power Limiter" (System Mode) and "Export power limiter" (Grid
172+
# Settings) - which is why pvMaxLimit was the only field holding the 7000 both screens
173+
# displayed. Per Sunsynk's documentation (confirmed by the system owner) the control despite
174+
# its System Mode label caps EXPORT, not the inverter's AC output: the inverter can still
175+
# deliver its full ratePower to the house. So this backs export_limit, and inverter_limit
176+
# stays on ratePower.
175177
#
176178
# Not to be confused with solarMaxSellPower (SUNSYNK_MAX_SOLAR_FIELD), a separate setting.
177-
SUNSYNK_POWER_LIMIT_FIELD = "pvMaxLimit" # settings, watts
179+
SUNSYNK_EXPORT_LIMIT_FIELD = "pvMaxLimit" # settings, watts
178180

179181
# Grid import cap - "Import power limiter" in the app's Grid Settings. CONFIRMED live:
180182
# app 10350 W, settings importPower '10350'. Not consumed yet; recorded so the mapping is

apps/predbat/tests/test_sunsynk_api.py

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1071,35 +1071,40 @@ def test_nominal_pack_voltage_across_the_lifepo4_charge_window():
10711071
assert not failed, "test_nominal_pack_voltage_across_the_lifepo4_charge_window"
10721072

10731073

1074-
def test_inverter_limit_respects_the_installer_power_limiter():
1075-
"""The installer's Inverter Power Limiter can cap the inverter below its rating.
1074+
def test_inverter_limit_is_the_rating_and_export_limit_is_the_cap():
1075+
"""pvMaxLimit caps EXPORT, not the inverter's output, despite its app label.
10761076
1077-
Confirmed live: ratePower 8000 with pvMaxLimit 7000. Using the rating alone would have
1078-
Predbat plan a kilowatt the inverter will never deliver.
1077+
Sunsynk's documentation confirms the "Inverter Power Limiter" control is an export cap,
1078+
which is why the app shows the same value on both the System Mode and Grid Settings
1079+
screens. The inverter can still deliver its full ratePower to the house, so reducing
1080+
inverter_limit by it would under-rate the inverter for self-consumption.
10791081
"""
10801082
failed = False
10811083
s = MockSunsynk()
10821084
s.device_rated_power["INV1"] = 8000.0
10831085
s.device_settings["INV1"] = {"pvMaxLimit": "7000"}
1084-
if s.inverter_limit("INV1") != 7000.0:
1085-
print(f"ERROR: expected the lower of 8000/7000, got {s.inverter_limit('INV1')}")
1086+
if s.inverter_limit("INV1") != 8000.0:
1087+
print(f"ERROR: inverter_limit should be the 8000W rating, got {s.inverter_limit('INV1')}")
10861088
failed = True
1087-
# A limiter above the rating must not raise the limit beyond what the hardware can do.
1088-
s.device_rated_power["INV2"] = 5000.0
1089+
if s.export_limit("INV1") != 7000.0:
1090+
print(f"ERROR: export_limit should be the 7000W cap, got {s.export_limit('INV1')}")
1091+
failed = True
1092+
# Export can never exceed what the inverter can produce, whatever the setting allows.
1093+
s.device_rated_power["INV2"] = 3600.0
10891094
s.device_settings["INV2"] = {"pvMaxLimit": "16000"}
1090-
if s.inverter_limit("INV2") != 5000.0:
1091-
print(f"ERROR: rating must cap the limiter, got {s.inverter_limit('INV2')}")
1095+
if s.export_limit("INV2") != 3600.0:
1096+
print(f"ERROR: export must be bounded by the rating, got {s.export_limit('INV2')}")
10921097
failed = True
1093-
# Either one alone is still usable.
1098+
# A G98 site: a 3.68kW export cap behind a much larger inverter must survive intact.
10941099
s.device_rated_power["INV3"] = 8000.0
1095-
s.device_settings["INV3"] = {}
1096-
if s.inverter_limit("INV3") != 8000.0:
1097-
print(f"ERROR: rating alone should be used, got {s.inverter_limit('INV3')}")
1100+
s.device_settings["INV3"] = {"pvMaxLimit": "3680"}
1101+
if s.export_limit("INV3") != 3680.0 or s.inverter_limit("INV3") != 8000.0:
1102+
print(f"ERROR: G98 case wrong - export {s.export_limit('INV3')}, inverter {s.inverter_limit('INV3')}")
10981103
failed = True
1099-
if s.inverter_limit("UNKNOWN") != 0:
1100-
print(f"ERROR: an unknown serial should derive 0, got {s.inverter_limit('UNKNOWN')}")
1104+
if s.inverter_limit("UNKNOWN") != 0 or s.export_limit("UNKNOWN") != 0:
1105+
print("ERROR: an unknown serial should derive 0 for both")
11011106
failed = True
1102-
assert not failed, "test_inverter_limit_respects_the_installer_power_limiter"
1107+
assert not failed, "test_inverter_limit_is_the_rating_and_export_limit_is_the_cap"
11031108

11041109

11051110
def run_sunsynk_api_tests(my_predbat):
@@ -1128,7 +1133,7 @@ def run_sunsynk_api_tests(my_predbat):
11281133
("battery_rate_max", test_battery_rate_max_from_charge_current),
11291134
("rate_max_field_priority", test_battery_rate_max_prefers_a_populated_current_field),
11301135
("pack_voltage_window", test_nominal_pack_voltage_across_the_lifepo4_charge_window),
1131-
("inverter_limit_limiter", test_inverter_limit_respects_the_installer_power_limiter),
1136+
("inverter_limit_vs_export", test_inverter_limit_is_the_rating_and_export_limit_is_the_cap),
11321137
("battery_reserve_min", test_battery_reserve_min_from_settings),
11331138
("device_list_no_serials_terminates", test_get_device_list_terminates_when_serials_are_missing),
11341139
("device_list_deduplicates", test_get_device_list_deduplicates_repeated_pages),

apps/predbat/tests/test_sunsynk_config.py

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -750,14 +750,12 @@ async def fake_publish_data():
750750
assert not failed, "test_run_isolates_one_inverters_failure_from_the_rest"
751751

752752

753-
def test_export_limit_is_never_auto_mapped():
754-
"""export_limit must be left to apps.yaml, because we cannot identify the real field.
755-
756-
The app's Export power limiter can sit below the inverter limit (a G98/G99 site is
757-
commonly capped at 3.68kW behind a much larger inverter), but no settings field carrying
758-
it could be identified. Guessing it from pvMaxLimit would be worse than leaving it:
759-
set_arg_auto always beats apps.yaml, so a user who correctly set their real export cap
760-
would have it silently replaced by the larger inverter limit.
753+
def test_export_limit_is_auto_mapped_from_the_export_cap():
754+
"""export_limit must be mapped, or Predbat plans exports the inverter clips.
755+
756+
inverter.py defaults export_limit to 99999W. pvMaxLimit is the export cap (Sunsynk
757+
documents the app's "Inverter Power Limiter" as limiting export), so it is mapped, while
758+
inverter_limit stays on the hardware rating.
761759
"""
762760
failed = False
763761
s = ConfigSunsynk()
@@ -767,17 +765,17 @@ def test_export_limit_is_never_auto_mapped():
767765
s.device_rated_power["INV1"] = 8000.0
768766
s.device_settings["INV1"] = {"batteryLowCap": "20", "pvMaxLimit": "7000"}
769767
run_async_local(s.automatic_config())
770-
if "export_limit" in s.args_set:
771-
print(f"ERROR: export_limit was auto-mapped to {s.args_set['export_limit']}, which would override the user's apps.yaml value")
772-
failed = True
773-
# The inverter limit IS mapped, and reflects the app's power limiter rather than ratePower.
774-
if "inverter_limit" not in s.args_set:
775-
print("ERROR: inverter_limit should still be mapped")
768+
for arg in ("inverter_limit", "export_limit"):
769+
if arg not in s.args_set:
770+
print(f"ERROR: {arg} was not mapped")
771+
failed = True
772+
if s.inverter_limit("INV1") != 8000.0:
773+
print(f"ERROR: inverter_limit should be the rating 8000, got {s.inverter_limit('INV1')}")
776774
failed = True
777-
if s.inverter_limit("INV1") != 7000.0:
778-
print(f"ERROR: inverter_limit should honour pvMaxLimit 7000, got {s.inverter_limit('INV1')}")
775+
if s.export_limit("INV1") != 7000.0:
776+
print(f"ERROR: export_limit should be the cap 7000, got {s.export_limit('INV1')}")
779777
failed = True
780-
assert not failed, "test_export_limit_is_never_auto_mapped"
778+
assert not failed, "test_export_limit_is_auto_mapped_from_the_export_cap"
781779

782780

783781
def run_sunsynk_config_tests(my_predbat):
@@ -788,7 +786,7 @@ def run_sunsynk_config_tests(my_predbat):
788786
("component_registered", test_component_registered),
789787
("apps_schema", test_apps_schema_keys),
790788
("automatic_config", test_automatic_config_maps_control_entities),
791-
("export_limit_not_mapped", test_export_limit_is_never_auto_mapped),
789+
("export_limit_mapped", test_export_limit_is_auto_mapped_from_the_export_cap),
792790
("partial_capabilities", test_automatic_config_skips_partial_capabilities),
793791
("ignore_pv", test_automatic_config_respects_ignore_pv),
794792
("run_first_cycle", test_run_first_cycle_polls_and_publishes),

0 commit comments

Comments
 (0)