Skip to content

Commit 3e166c1

Browse files
Merge pull request #5189 from springfall2008/fix/solis-slot-current-rating-5187
Solis: cap slot currents at the inverter rating and distrust implausible recovery SOC
2 parents 8313757 + 8076139 commit 3e166c1

2 files changed

Lines changed: 230 additions & 6 deletions

File tree

‎apps/predbat/solis.py‎

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -813,6 +813,16 @@ async def encode_time_windows(self, inverter_sn, time_windows):
813813
first_slot = time_windows.get(1, time_windows.get(min(time_windows.keys())))
814814
field_length = first_slot.get("field_length", 18) # Default to variant 1
815815

816+
# Never encode a current above what the inverter can deliver at its rated power (issue #5187)
817+
rated_current = self.get_rated_current(inverter_sn)
818+
819+
def encoded_current(slot_data, key):
820+
"""Return a slot current as CID 103 holds it, in whole amps and no higher than the inverter's rating."""
821+
current = int(slot_data.get(key, 0))
822+
if rated_current is not None:
823+
current = min(current, int(rated_current))
824+
return str(current)
825+
816826
fields = []
817827

818828
if field_length == 18:
@@ -822,8 +832,8 @@ async def encode_time_windows(self, inverter_sn, time_windows):
822832
slot_data = time_windows.get(slot_index, {})
823833

824834
# Extract fields with defaults
825-
charge_current = str(int(slot_data.get("charge_current", 0)))
826-
discharge_current = str(int(slot_data.get("discharge_current", 0)))
835+
charge_current = encoded_current(slot_data, "charge_current")
836+
discharge_current = encoded_current(slot_data, "discharge_current")
827837
charge_start = slot_data.get("charge_start_time", "00:00")
828838
charge_end = slot_data.get("charge_end_time", "00:00")
829839
discharge_start = slot_data.get("discharge_start_time", "00:00")
@@ -839,8 +849,8 @@ async def encode_time_windows(self, inverter_sn, time_windows):
839849
slot_data = time_windows.get(slot_index, {})
840850

841851
# Extract fields with defaults
842-
charge_current = str(int(slot_data.get("charge_current", 0)))
843-
discharge_current = str(int(slot_data.get("discharge_current", 0)))
852+
charge_current = encoded_current(slot_data, "charge_current")
853+
discharge_current = encoded_current(slot_data, "discharge_current")
844854
charge_start = slot_data.get("charge_start_time", "00:00")
845855
charge_end = slot_data.get("charge_end_time", "00:00")
846856
discharge_start = slot_data.get("discharge_start_time", "00:00")
@@ -941,6 +951,11 @@ async def resolve_discharge_soc_floor(self, inverter_sn, target_soc):
941951
over-discharge SOC is never written here, leaving the battery protection floor
942952
where the user set it.
943953
954+
A recovery SOC reading at or below the over-discharge SOC, or above 100, cannot be
955+
right, yet most inverters report one - 0, 1, the over-discharge SOC itself or 65521
956+
(issue #5187). Such a reading is replaced by over-discharge + 1, where the inverter
957+
holds the recovery SOC, and is never written back.
958+
944959
Args:
945960
inverter_sn: Inverter serial number
946961
target_soc: Cut-off SOC Predbat wants for the discharge slot, as a percentage
@@ -952,11 +967,17 @@ async def resolve_discharge_soc_floor(self, inverter_sn, target_soc):
952967
# Cache only - the infrequent poll runs before the first control write, so a missing
953968
# value means we cannot tell where the floor is and the target is left alone
954969
recovery_soc = parse_cid_int(values.get(SOLIS_CID_BATTERY_RECOVERY_SOC))
970+
# The inverter will not accept a recovery SOC at or below the over-discharge SOC
971+
over_discharge_soc = parse_cid_int(values.get(SOLIS_CID_BATTERY_OVER_DISCHARGE_SOC))
972+
973+
recovery_reading = recovery_soc
974+
if recovery_soc is not None and (recovery_soc > 100 or (over_discharge_soc is not None and recovery_soc <= over_discharge_soc)):
975+
recovery_soc = (over_discharge_soc + 1) if over_discharge_soc is not None else None
955976
if recovery_soc is None or target_soc >= recovery_soc:
956977
return target_soc
978+
if recovery_soc != recovery_reading:
979+
self.log(f"Solis API: Recovery SOC on {inverter_sn} reads {recovery_reading}%, which cannot be right with over-discharge SOC at {over_discharge_soc}%, so taking it as {recovery_soc}%")
957980

958-
# The inverter will not accept a recovery SOC at or below the over-discharge SOC
959-
over_discharge_soc = parse_cid_int(values.get(SOLIS_CID_BATTERY_OVER_DISCHARGE_SOC))
960981
hard_floor = (over_discharge_soc + 1) if over_discharge_soc is not None else recovery_soc
961982

962983
wanted_recovery = max(target_soc, hard_floor)
@@ -973,6 +994,28 @@ async def resolve_discharge_soc_floor(self, inverter_sn, target_soc):
973994
return recovery_soc
974995
return target_soc
975996

997+
def get_rated_current(self, inverter_sn):
998+
"""Return the battery current the inverter can deliver at its rated power, in whole amps, or None if its size is unknown.
999+
1000+
The rating is inverterDetail power - the sensor published as inverter_size and bound to
1001+
inverter_limit - converted at get_nominal_voltage(), which holds still on an LV pack.
1002+
Floored to whole amps so the capped value is never above the rating, and so an HV pack,
1003+
converted at its live voltage, does not produce a new value to write every cycle.
1004+
"""
1005+
detail = self.inverter_details.get(inverter_sn, {})
1006+
try:
1007+
power = float(detail.get("power"))
1008+
except (ValueError, TypeError):
1009+
return None
1010+
unit = str(detail.get("powerStr") or "kW").strip().lower()
1011+
if unit == "kw":
1012+
power *= 1000.0
1013+
elif unit != "w":
1014+
return None
1015+
if power <= 0:
1016+
return None
1017+
return float(int(round(power / self.get_nominal_voltage(inverter_sn), 6)))
1018+
9761019
async def write_time_windows_if_changed(self, inverter_sn):
9771020
"""Write charge/discharge time windows, SOC, and current to inverter, only if values changed from cache.
9781021
Automatically handles V1 vs V2 modes and only writes registers that have changed.
@@ -1009,6 +1052,14 @@ async def write_time_windows_if_changed(self, inverter_sn):
10091052
current_cid = SOLIS_CID_DISCHARGE_CURRENT[slot - 1]
10101053
max_discharge_current_amps = min(self.cached_infos.get(inverter_sn, {}).get(current_cid, {}).get('sysCommand', {}).get('max', max_discharge_current_amps), max_discharge_current_amps)
10111054

1055+
# Then cap both at what the inverter can deliver at its rated power. CID 7224/7226 are battery limits: a
1056+
# 3.6kW inverter reading 100A there refused every 100A write and left its discharge slot at 0A (issue #5187)
1057+
rated_current = self.get_rated_current(inverter_sn)
1058+
if rated_current is not None and rated_current < max(max_charge_current_amps, max_discharge_current_amps):
1059+
self.log(f"Solis API: Capping slot currents on {inverter_sn} at {rated_current}A, the most the inverter can deliver at its rated power")
1060+
max_charge_current_amps = min(max_charge_current_amps, rated_current)
1061+
max_discharge_current_amps = min(max_discharge_current_amps, rated_current)
1062+
10121063
# Prep: extract active currents from slot 1 and zero out times for disabled slots
10131064
# so that the two-pass write below has clean data to compare against.
10141065
for slot in slots_to_check:

‎apps/predbat/tests/test_solis.py‎

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from solis import SOLIS_CID_TOU_V2_MODE, SOLIS_CID_LIST_TOU_V2
1919
from solis import SOLIS_CID_ALLOW_EXPORT, SOLIS_ALLOW_EXPORT_ON, SOLIS_ALLOW_EXPORT_OFF, SOLIS_CID_BATTERY_RESERVE_SOC
2020
from solis import SOLIS_CID_BATTERY_MAX_CHARGE_CURRENT, SOLIS_CID_BATTERY_RECOVERY_SOC, SOLIS_CID_DISCHARGE_SOC
21+
from solis import SOLIS_CID_BATTERY_MAX_DISCHARGE_CURRENT, SOLIS_CID_DISCHARGE_CURRENT
2122
from solis import SOLIS_CID_POWER_LIMIT, SOLIS_BIT_BACKUP_MODE
2223
from solis import SOLIS_READ_ENDPOINT, SOLIS_READ_BATCH_ENDPOINT, SOLIS_CONTROL_ENDPOINT, SOLIS_INVERTER_LIST_ENDPOINT, SOLIS_INVERTER_DETAIL_ENDPOINT
2324
from solis import get_solis_mode_enum, compute_solis_mode_value
@@ -1490,6 +1491,11 @@ def run_solis_tests(my_predbat):
14901491
failed |= asyncio.run(test_discharge_soc_unchanged_above_recovery())
14911492
failed |= asyncio.run(test_discharge_soc_unclamped_when_recovery_unknown())
14921493
failed |= asyncio.run(test_recovery_soc_not_lowered_below_inverter_minimum())
1494+
failed |= asyncio.run(test_implausible_recovery_soc_falls_back_to_inverter_minimum())
1495+
failed |= test_get_rated_current()
1496+
failed |= asyncio.run(test_v2_slot_currents_capped_at_inverter_rating())
1497+
failed |= asyncio.run(test_v1_slot_currents_capped_at_inverter_rating())
1498+
failed |= asyncio.run(test_slot_currents_uncapped_when_inverter_size_unknown())
14931499
failed |= asyncio.run(test_control_write_failure_withholds_success_timestamp())
14941500
failed |= asyncio.run(test_control_write_success_updates_success_timestamp())
14951501
failed |= asyncio.run(test_storage_mode_failure_does_not_fail_control_write())
@@ -2607,6 +2613,173 @@ async def test_recovery_soc_not_lowered_below_inverter_minimum():
26072613
return False
26082614

26092615

2616+
async def test_implausible_recovery_soc_falls_back_to_inverter_minimum():
2617+
"""A recovery SOC reading that cannot be right is replaced by over-discharge + 1 (issue #5187).
2618+
2619+
Most of the fleet reports 0, 1, the over-discharge SOC itself or 65521 for CID 7229. Taken at
2620+
face value any of those let a target at the over-discharge SOC through unclamped, which the
2621+
inverter refuses, so the slot kept a stale cut-off of 40-50%. The fallback is never written
2622+
back to the inverter, since the reading it would be verified against is the one that is wrong.
2623+
"""
2624+
print("\n=== Test: implausible recovery SOC falls back to over-discharge + 1 ===")
2625+
2626+
# (description, discharge target, recovery reading, over-discharge, expected SOC write)
2627+
cases = [
2628+
("recovery reads 0", 12, 0, 12, "13"),
2629+
("recovery reads the over-discharge SOC", 15, 15, 15, "16"),
2630+
("recovery reads 65521", 10, 65521, 10, "11"),
2631+
("recovery reads 0 but the target is reachable anyway", 40, 0, 12, "40"),
2632+
("recovery reads 65521 and over-discharge is unknown", 20, 65521, None, "20"),
2633+
]
2634+
for description, target, recovery, over_discharge, expected in cases:
2635+
api = _discharge_slot_api(discharge_soc=target, recovery_soc=recovery, over_discharge_soc=over_discharge)
2636+
assert await api.write_time_windows_if_changed("TEST123") is True, f"{description}: write_time_windows_if_changed should succeed"
2637+
assert _written_soc(api) == expected, f"{description}: discharge SOC should be {expected}, got {_written_soc(api)}"
2638+
assert _written_recovery(api) is None, f"{description}: an implausible recovery SOC must not be written back, got {_written_recovery(api)}"
2639+
2640+
print("PASSED: Implausible recovery SOC readings clamp to over-discharge + 1 without being written")
2641+
return False
2642+
2643+
2644+
def _rated_inverter_api(inverter_sn="TEST123", power=3.6):
2645+
"""Build a V2-mode MockSolisAPI for a 3.6kW inverter whose battery limits read 100A, as in issue #5187.
2646+
2647+
Args:
2648+
inverter_sn: Inverter serial number to use
2649+
power: inverterDetail power in kW, or None to leave the inverter size unknown
2650+
2651+
Returns: The configured MockSolisAPI, with an enabled discharge slot 1 asking for 100A
2652+
"""
2653+
api = _discharge_slot_api(discharge_soc=40, recovery_soc=21, over_discharge_soc=20, inverter_sn=inverter_sn)
2654+
api.charge_discharge_time_windows[inverter_sn][1]["discharge_current"] = 100
2655+
api.cached_values[inverter_sn][SOLIS_CID_BATTERY_MAX_CHARGE_CURRENT] = "100"
2656+
api.cached_values[inverter_sn][SOLIS_CID_BATTERY_MAX_DISCHARGE_CURRENT] = "100"
2657+
if power is not None:
2658+
api.inverter_details[inverter_sn] = {"power": power, "powerStr": "kW"}
2659+
return api
2660+
2661+
2662+
def _written_value(api, cid):
2663+
"""Return the last value written to a CID, or None if it was not written.
2664+
2665+
Args:
2666+
api: MockSolisAPI whose recorded calls should be searched
2667+
cid: CID to look for
2668+
2669+
Returns: The written value as a string, or None
2670+
"""
2671+
values = [c["value"] for c in api.read_and_write_cid_calls if c["cid"] == cid]
2672+
return values[-1] if values else None
2673+
2674+
2675+
def test_get_rated_current():
2676+
"""The rated current is the inverter size over the nominal pack voltage, floored to whole amps."""
2677+
print("\n=== Test: get_rated_current ===")
2678+
2679+
sn = "TEST123"
2680+
# (description, inverterDetail, expected amps)
2681+
cases = [
2682+
("3.6kW on a 48V pack", {"power": 3.6, "powerStr": "kW"}, 75.0),
2683+
("3.6kW on a 16S pack", {"power": "3.6", "powerStr": "kW", "batteryAcvSet": 56.8}, 70.0),
2684+
("size reported in watts", {"power": 3600, "powerStr": "W"}, 75.0),
2685+
("unit missing, taken as kW", {"power": 3.6}, 75.0),
2686+
("size missing", {}, None),
2687+
("size zero", {"power": 0, "powerStr": "kW"}, None),
2688+
("size not a number", {"power": "junk", "powerStr": "kW"}, None),
2689+
("unrecognised unit", {"power": 3.6, "powerStr": "kVA"}, None),
2690+
]
2691+
for description, detail, expected in cases:
2692+
api = MockSolisAPI()
2693+
api.inverter_details[sn] = detail
2694+
rated = api.get_rated_current(sn)
2695+
assert rated == expected, f"{description}: expected {expected}, got {rated}"
2696+
2697+
print("PASSED: Rated current derived from inverter size and nominal voltage")
2698+
return False
2699+
2700+
2701+
async def test_v2_slot_currents_capped_at_inverter_rating():
2702+
"""A V2 slot current is never written above what the inverter can deliver (issue #5187).
2703+
2704+
The battery limit (CID 7226) reads 100A, but on a 3.6kW inverter at 48V the most it can move is
2705+
75A; the inverter refused 100A every minute and left the slot at 0A instead.
2706+
"""
2707+
print("\n=== Test: V2 slot currents capped at the inverter rating ===")
2708+
2709+
sn = "TEST123"
2710+
api = _rated_inverter_api(inverter_sn=sn)
2711+
api.cached_values[sn][SOLIS_CID_DISCHARGE_CURRENT[0]] = "0"
2712+
assert await api.write_time_windows_if_changed(sn) is True, "write_time_windows_if_changed should succeed"
2713+
assert _written_value(api, SOLIS_CID_DISCHARGE_CURRENT[0]) == "75.0", f"Discharge current should be capped at 75.0A, got {_written_value(api, SOLIS_CID_DISCHARGE_CURRENT[0])}"
2714+
assert any("Capping slot currents on TEST123 at 75.0A" in m for m in api.log_messages), "The cap should be logged"
2715+
2716+
# The same cap applies to a charge slot
2717+
api = _rated_inverter_api(inverter_sn=sn)
2718+
api.cached_values[sn][SOLIS_CID_CHARGE_CURRENT[0]] = "0"
2719+
slot = api.charge_discharge_time_windows[sn][1]
2720+
slot.update({"charge_enable": 1, "charge_start_time": "02:00", "charge_end_time": "05:00", "charge_current": 100, "discharge_enable": 0})
2721+
assert await api.write_time_windows_if_changed(sn) is True, "write_time_windows_if_changed should succeed"
2722+
assert _written_value(api, SOLIS_CID_CHARGE_CURRENT[0]) == "75.0", f"Charge current should be capped at 75.0A, got {_written_value(api, SOLIS_CID_CHARGE_CURRENT[0])}"
2723+
2724+
# The windows Predbat asked for are left as they were, only the write is capped
2725+
assert api.charge_discharge_time_windows[sn][1]["charge_current"] == 100, "The requested current must not be rewritten in the local windows"
2726+
2727+
print("PASSED: 100A requests written as 75A on a 3.6kW inverter")
2728+
return False
2729+
2730+
2731+
async def test_v1_slot_currents_capped_at_inverter_rating():
2732+
"""The V1 path encodes capped currents into CID 103 as well."""
2733+
print("\n=== Test: V1 slot currents capped at the inverter rating ===")
2734+
2735+
sn = "TEST123"
2736+
api = MockSolisAPI()
2737+
api._test_v2_mode = False
2738+
api._mock_storage_mode = True
2739+
api.inverter_sn = [sn]
2740+
api.inverter_details[sn] = {"power": 3.6, "powerStr": "kW"}
2741+
windows = {}
2742+
for slot in range(1, 4):
2743+
windows[slot] = {
2744+
"charge_start_time": "00:00",
2745+
"charge_end_time": "00:00",
2746+
"charge_current": 0,
2747+
"discharge_start_time": "00:00",
2748+
"discharge_end_time": "00:00",
2749+
"discharge_current": 0,
2750+
"field_length": 18,
2751+
}
2752+
windows[1].update({"charge_enable": 1, "charge_start_time": "02:00", "charge_end_time": "05:00", "charge_current": 100, "discharge_current": 100})
2753+
api.charge_discharge_time_windows[sn] = windows
2754+
api.cached_values[sn] = {}
2755+
# Outside the window, so the in-slot SOC handling cannot zero a current either way
2756+
api._test_now_utc_exact = datetime(2026, 9, 21, 12, 0, tzinfo=api.local_tz)
2757+
2758+
assert await api.write_time_windows_if_changed(sn) is True, "write_time_windows_if_changed should succeed"
2759+
encoded = _written_value(api, SOLIS_CID_CHARGE_DISCHARGE_SETTINGS)
2760+
assert encoded is not None, "CID 103 should be written"
2761+
assert encoded.startswith("75,75,02:00,05:00,"), f"Slot 1 currents should be encoded as 75A, got {encoded}"
2762+
2763+
print("PASSED: V1 currents encoded at the 75A rating")
2764+
return False
2765+
2766+
2767+
async def test_slot_currents_uncapped_when_inverter_size_unknown():
2768+
"""Without an inverter size there is no rating to cap at, so the existing limits alone apply."""
2769+
print("\n=== Test: slot currents uncapped when the inverter size is unknown ===")
2770+
2771+
sn = "TEST123"
2772+
api = _rated_inverter_api(inverter_sn=sn, power=None)
2773+
# An absent register is taken to be at the battery limit already, so give it something to change from
2774+
api.cached_values[sn][SOLIS_CID_DISCHARGE_CURRENT[0]] = "0"
2775+
assert await api.write_time_windows_if_changed(sn) is True, "write_time_windows_if_changed should succeed"
2776+
assert _written_value(api, SOLIS_CID_DISCHARGE_CURRENT[0]) == "100.0", f"Discharge current should be the uncapped 100.0A, got {_written_value(api, SOLIS_CID_DISCHARGE_CURRENT[0])}"
2777+
assert not any("Capping" in m for m in api.log_messages), "Nothing should be capped"
2778+
2779+
print("PASSED: Unknown inverter size leaves the current at the battery limit")
2780+
return False
2781+
2782+
26102783
async def test_control_write_failure_withholds_success_timestamp():
26112784
"""A refused control register write stops the success timestamp advancing, so the component ages out.
26122785

0 commit comments

Comments
 (0)