Skip to content

Commit 29226fa

Browse files
fix(sunsynk): hold-charge keeps Predbat's charge rate
Reverts the power half of c0bc164, which was an over-correction. Hold-charge - a charge window whose target is at or below the current SoC - must not trigger a charge, so grid charge stays off. But the slot should still carry Predbat's chosen charge rate for that window: only the behaviour differs, not the power. c0bc164 made it report zero power so it would classify as a self-use slot and be written with the inverter's full rating. That discarded the rate Predbat asked for. Zero is also the freeze encoding, so overloading it as a classification signal made two opposite meanings share one value at the same call site. The grid-charge-off part of that commit was correct and is unchanged; only the power reverts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 57a92a6 commit 29226fa

2 files changed

Lines changed: 27 additions & 21 deletions

File tree

apps/predbat/sunsynk.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -648,13 +648,12 @@ def derive_control_state(self, schedule, current_soc):
648648
# Zero power IS the freeze: the slot is enabled for grid charge but given no
649649
# power, so the battery neither charges nor discharges and simply holds.
650650
return {"behaviour": "freeze_charge", "work_mode": SUNSYNK_WORKMODE["zero_export_ct"], "grid_charge": True, "solar_sell": False, "slot_soc": reserve, "power": 0}
651-
# The battery is already at or above the requested target, so there is nothing
652-
# to charge: this behaves exactly like self-use. Reporting zero power here is
653-
# what makes build_tou_slots classify it as a self-use slot, which then writes
654-
# the inverter's full power - it does NOT emit a zero-power (frozen) slot. A
655-
# non-zero power would classify it as an action slot and needlessly constrain
656-
# the battery while it is only being asked not to charge.
657-
return {"behaviour": "hold_charge", "work_mode": SUNSYNK_WORKMODE["zero_export_ct"], "grid_charge": False, "solar_sell": False, "slot_soc": reserve, "power": 0}
651+
# The battery is already at or above the requested target, so grid charge stays
652+
# OFF - the charge is simply not triggered. The slot still carries Predbat's
653+
# charge rate, because that is the rate it has chosen for this window; only the
654+
# behaviour differs, not the power. Zero here would mean freeze, which is a
655+
# different state, and the inverter's full rating would discard Predbat's rate.
656+
return {"behaviour": "hold_charge", "work_mode": SUNSYNK_WORKMODE["zero_export_ct"], "grid_charge": False, "solar_sell": False, "slot_soc": reserve, "power": int(charge.get("power", 0))}
658657

659658
return {"behaviour": "idle", "work_mode": SUNSYNK_WORKMODE["zero_export_ct"], "grid_charge": False, "solar_sell": False, "slot_soc": reserve, "power": 0}
660659

apps/predbat/tests/test_sunsynk_control.py

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -919,13 +919,12 @@ def test_freeze_states_are_expressed_as_zero_power():
919919
assert not failed, "test_freeze_states_are_expressed_as_zero_power"
920920

921921

922-
def test_hold_charge_behaves_as_self_use():
923-
"""A charge target at or below current SoC must not constrain or freeze the battery.
922+
def test_hold_charge_keeps_predbat_rate_without_charging():
923+
"""A charge target at or below current SoC must not trigger a charge, but keeps the rate.
924924
925-
There is nothing to charge, so the slot should be an ordinary self-use one: no grid
926-
charge, and the inverter's full power so the battery keeps serving the house. A
927-
zero-power slot would freeze it; an action slot carrying the requested charge power
928-
would needlessly cap it.
925+
Grid charge stays off - there is nothing to charge - while the slot still carries
926+
Predbat's chosen charge rate for that window. Zero would mean freeze, a different
927+
state; the inverter's full rating would discard the rate Predbat asked for.
929928
"""
930929
failed = False
931930
s = MockSunsynk()
@@ -936,19 +935,27 @@ def test_hold_charge_behaves_as_self_use():
936935
if state["behaviour"] != "hold_charge":
937936
print(f"ERROR: expected hold_charge, got {state['behaviour']}")
938937
failed = True
939-
if state["grid_charge"] or state["power"] != 0:
940-
print(f"ERROR: hold_charge should not charge and should classify as self-use, got {state}")
938+
if state["grid_charge"]:
939+
print("ERROR: hold_charge must not enable grid charge - there is nothing to charge")
940+
failed = True
941+
if state["power"] != 3000:
942+
print(f"ERROR: hold_charge should keep Predbat's 3000W rate, got {state['power']}")
941943
failed = True
942944
sched = _schedule(reserve=20, charge={"enable": True, "soc": 40, "power": 3000, "start": "03:00:00", "end": "04:00:00"})
943945
payload = s.build_settings_payload("INV1", sched, current_soc=60, now_minutes=3 * 60 + 30)
944-
for n in range(1, TOU_SLOT_COUNT + 1):
946+
hold = [n for n in range(1, TOU_SLOT_COUNT + 1) if payload[TOU_FIELD["time"].format(n=n)] == "03:00"]
947+
if not hold:
948+
print("ERROR: no slot at the window start")
949+
failed = True
950+
else:
951+
n = hold[0]
945952
if payload[TOU_FIELD["grid_charge"].format(n=n)]:
946-
print(f"ERROR: hold_charge produced a grid-charge slot at {payload[TOU_FIELD['time'].format(n=n)]}")
953+
print("ERROR: the hold-charge slot enabled grid charge")
947954
failed = True
948-
if int(payload[TOU_FIELD["power"].format(n=n)]) != 8000:
949-
print(f"ERROR: slot {n} power {payload[TOU_FIELD['power'].format(n=n)]}, expected the full 8000W rating")
955+
if int(payload[TOU_FIELD["power"].format(n=n)]) != 3000:
956+
print(f"ERROR: hold-charge slot power {payload[TOU_FIELD['power'].format(n=n)]}, expected Predbat's 3000W")
950957
failed = True
951-
assert not failed, "test_hold_charge_behaves_as_self_use"
958+
assert not failed, "test_hold_charge_keeps_predbat_rate_without_charging"
952959

953960

954961
def run_sunsynk_control_tests(my_predbat):
@@ -960,7 +967,7 @@ def run_sunsynk_control_tests(my_predbat):
960967
("tou_slots_shape", test_build_tou_slots_shape),
961968
("self_use_never_zero", test_self_use_slots_are_never_zero_power),
962969
("freeze_zero_power", test_freeze_states_are_expressed_as_zero_power),
963-
("hold_charge_self_use", test_hold_charge_behaves_as_self_use),
970+
("hold_charge_rate", test_hold_charge_keeps_predbat_rate_without_charging),
964971
("tou_slots_seconds", test_build_tou_slots_seconds_are_dropped),
965972
("tou_slots_idle", test_build_tou_slots_idle_is_still_six_distinct),
966973
("tou_slots_zero_length_window", test_build_tou_slots_zero_length_window_has_no_effect),

0 commit comments

Comments
 (0)