Skip to content

Commit 0f22ee1

Browse files
Merge pull request #5085 from springfall2008/fix/sigenergy-freeze-export-4761
fix(sigenergy): select Self-Consumption Grid for a Cloud freeze export
2 parents 84c9380 + dfe46ad commit 0f22ee1

2 files changed

Lines changed: 109 additions & 0 deletions

File tree

‎apps/predbat/sigenergy.py‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2221,6 +2221,50 @@ def parse_window(start_str, end_str):
22212221
active_mode = SIGENERGY_ACTIVE_MODE_CHARGE
22222222
charge_power_kw = charge_rate_w / 1000.0
22232223
charge_priority_type = "PV"
2224+
elif charge_rate_w == 0:
2225+
# No window is active, so the only thing left that can distinguish demand from a
2226+
# freeze export is the rates Predbat has written. Predbat expresses Freeze Export as
2227+
# "demand mode, but with charging disabled": execute.py turns the forced-export window
2228+
# off (adjust_force_export(False)) and calls adjust_charge_rate(0), because SIGCLOUD
2229+
# declares has_timed_pause False and charge_discharge_with_rate False, so that zero is
2230+
# the only lever it has left. The freeze_export branch above is therefore never
2231+
# reached from a planned freeze - export_enable is already False by the time we run -
2232+
# and the command came out identical to plain demand, which routes the surplus PV a
2233+
# freeze exists to export into the battery instead (GH#4761).
2234+
#
2235+
# This is the same inference fox.py, deye.py and sunsynk.py make from the same
2236+
# entities, for the same reason - see fox.py's freeze_export_requested(). Unlike those,
2237+
# a zero charge rate is read as a freeze here without also requiring a non-zero export
2238+
# rate. Their guard exists because their rate entities are published at 0, so all-zero
2239+
# is an absence of a plan rather than a plan; the Sigenergy rate controls default to
2240+
# the battery maximum instead (_control_info), so a zero only ever arrives by Predbat
2241+
# deliberately writing one.
2242+
duration_min = 720
2243+
if export_rate_w > 0:
2244+
# Charging disabled, discharging still allowed - a plain freeze export.
2245+
# Self-Consumption Grid is the right mode for it: surplus PV serves the load, then
2246+
# sells to the grid rather than charging the battery, while the battery still
2247+
# discharges to serve the house when solar is short. charge_power_kw is
2248+
# deliberately left None: the API documents it as bidirectional ("max energy
2249+
# storage charging/discharging power"), so zeroing it here would also stop the
2250+
# battery serving the house, which is a freeze CHARGE, not a freeze export.
2251+
new_mode = "freeze_export"
2252+
active_mode = SIGENERGY_ACTIVE_MODE_SELF_GRID
2253+
else:
2254+
# Both rates zero: a freeze export with a discharge hold on top. execute.py writes
2255+
# the export rate to 0 for a car-charging or iBoost hold (no timed pause on
2256+
# SIGCLOUD, so the rate is again the only lever), and the freeze export leaves the
2257+
# charge rate at 0 alongside it. Rare in practice - a car held off the battery is
2258+
# being charged from solar or grid - but reachable, and Self-Consumption Grid
2259+
# would be wrong for it because that mode lets the battery discharge to serve the
2260+
# house, which is exactly what the hold forbids.
2261+
#
2262+
# Plain self-consumption with the bidirectional power pinned to 0 expresses both
2263+
# halves at once: no charging from the PV surplus and no discharging to the hold,
2264+
# the same lever the freeze_charge branch above already uses to hold SoC flat.
2265+
new_mode = "freeze_export_hold"
2266+
active_mode = SIGENERGY_ACTIVE_MODE_SELF
2267+
charge_power_kw = 0
22242268
else:
22252269
duration_min = 720
22262270
new_mode = "eco"

‎apps/predbat/tests/test_sigenergy.py‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
SIGENERGY_ACTIVE_MODE_CHARGE,
2020
SIGENERGY_ACTIVE_MODE_DISCHARGE,
2121
SIGENERGY_ACTIVE_MODE_SELF,
22+
SIGENERGY_ACTIVE_MODE_SELF_GRID,
2223
SIGENERGY_CODE_IN_OTHER_VPP,
2324
SIGENERGY_CODE_SYSTEM_PENDING_REVIEW,
2425
SIGENERGY_LOG_REDACT_KEYS,
@@ -1289,6 +1290,69 @@ async def mock_send_battery_command(sid, active_mode, duration_min, charging_pow
12891290
return failed
12901291

12911292

1293+
def test_sigenergy_apply_controls_freeze_export_no_window(my_predbat):
1294+
"""Test apply_controls sends Self-Consumption Grid for a freeze export left by execute.py."""
1295+
failed = False
1296+
1297+
def run_case(charge_rate, export_rate):
1298+
"""Apply controls with the given rates and no active window, returning the battery commands."""
1299+
api = MockSigenergyAPI()
1300+
system_id = "SIG001"
1301+
api.systems[system_id] = {"systemName": "Home", "batteryCapacity": 10.0}
1302+
api.devices[system_id] = [{"deviceType": "Battery", "attrMap": {"ratedChargePower": 3.0}}]
1303+
api.energy_flow[system_id] = {"batterySoc": 80.0}
1304+
# This is the control state execute.py actually leaves behind for a Freeze Export on
1305+
# SIGCLOUD: adjust_force_export(False) has already cleared the export window, so the
1306+
# only remaining signal is adjust_charge_rate(0) against a reset export rate.
1307+
api.controls[system_id] = {
1308+
"charge": {"enable": False, "start_time": "00:00:00", "end_time": "00:00:00", "target_soc": 100, "rate": charge_rate},
1309+
"export": {"enable": False, "start_time": "00:00:00", "end_time": "00:00:00", "target_soc": 0, "rate": export_rate},
1310+
"reserve": 10,
1311+
}
1312+
1313+
commands_sent = []
1314+
1315+
async def mock_send_battery_command(sid, active_mode, duration_min, charging_power_kw=None, **kwargs):
1316+
commands_sent.append(("battery_cmd", sid, active_mode, duration_min, charging_power_kw))
1317+
return True
1318+
1319+
api.send_battery_command = mock_send_battery_command
1320+
1321+
ok = run_async(api.apply_controls(system_id))
1322+
assert ok is True, "apply_controls freeze export returned True"
1323+
return [c for c in commands_sent if c[0] == "battery_cmd"]
1324+
1325+
# Charge rate pinned to zero with the export rate still live — a freeze export
1326+
bat_cmds = run_case(0, 3000)
1327+
assert len(bat_cmds) >= 1, "send_battery_command called for freeze export"
1328+
assert bat_cmds[0][2] == SIGENERGY_ACTIVE_MODE_SELF_GRID, "selfConsumption-grid sent for freeze export, got {}".format(bat_cmds[0][2])
1329+
# chargingPower is bidirectional on this API, so a freeze export must not cap it — that
1330+
# would also stop the battery serving the house, which is a freeze charge not a freeze export
1331+
assert bat_cmds[0][4] is None, "no charging power cap sent for freeze export, got {}".format(bat_cmds[0][4])
1332+
1333+
# Both rates zero — a freeze export with a car-charging or iBoost discharge hold on top.
1334+
# selfConsumption-grid would let the battery discharge to serve the house, which the hold
1335+
# forbids, so this is plain self-consumption with the bidirectional power pinned to zero
1336+
bat_cmds = run_case(0, 0)
1337+
assert len(bat_cmds) >= 1, "send_battery_command called for freeze export with a discharge hold"
1338+
assert bat_cmds[0][2] == SIGENERGY_ACTIVE_MODE_SELF, "selfConsumption sent for freeze export with a discharge hold, got {}".format(bat_cmds[0][2])
1339+
assert bat_cmds[0][4] == 0, "charging power pinned to 0 for freeze export with a discharge hold, got {}".format(bat_cmds[0][4])
1340+
1341+
# A discharge hold on its own, with charging still allowed, is not a freeze and must not be
1342+
# capped — the bidirectional power cannot express "charge allowed, discharge blocked"
1343+
bat_cmds = run_case(3000, 0)
1344+
assert len(bat_cmds) >= 1, "send_battery_command called for a discharge hold alone"
1345+
assert bat_cmds[0][2] == SIGENERGY_ACTIVE_MODE_SELF, "selfConsumption sent for a discharge hold alone, got {}".format(bat_cmds[0][2])
1346+
assert bat_cmds[0][4] is None, "no charging power cap sent for a discharge hold alone, got {}".format(bat_cmds[0][4])
1347+
1348+
# A normal demand period with both rates live stays in demand
1349+
bat_cmds = run_case(3000, 3000)
1350+
assert len(bat_cmds) >= 1, "send_battery_command called for demand"
1351+
assert bat_cmds[0][2] == SIGENERGY_ACTIVE_MODE_SELF, "selfConsumption sent for demand, got {}".format(bat_cmds[0][2])
1352+
1353+
return failed
1354+
1355+
12921356
# ---------------------------------------------------------------------------
12931357
# MQTT tests
12941358
# ---------------------------------------------------------------------------
@@ -3154,6 +3218,7 @@ def run_sigenergy_tests(my_predbat):
31543218
("apply_controls_eco_mode", test_sigenergy_apply_controls_eco_mode),
31553219
("apply_controls_deduplication", test_sigenergy_apply_controls_deduplication),
31563220
("apply_controls_export_mode", test_sigenergy_apply_controls_export_mode),
3221+
("apply_controls_freeze_export_no_window", test_sigenergy_apply_controls_freeze_export_no_window),
31573222
("publish_mqtt_success", test_sigenergy_publish_mqtt_success),
31583223
("redact", test_sigenergy_redact),
31593224
("publish_mqtt_redacts_token", test_sigenergy_publish_mqtt_redacts_token),

0 commit comments

Comments
 (0)