Skip to content

Commit 108f591

Browse files
CIclaude
andcommitted
fix(solis): probe from the battery limit, and let a measured ceiling replace the rated estimate
The probe started from the rated-current estimate from #5189 (rated power / nominal voltage). So it could only ever confirm or lower that estimate, never find a ceiling above it. An 8kW hybrid on a 51.2V pack reads 180A in CID 7224/7226 and ran slots at 180A before #5189. The rated estimate held it at 156A and capped PV charging below what the inverter takes. - A first probe now starts at the battery-side cap (the register, lowered by any slot metadata) and steps down only on a refusal. - Once an inverter has a measured ceiling, it replaces the rated estimate in slot_current_limits(). The estimate is only a fallback until then. inverter_limit still bounds the AC side in the plan. - A daily re-check starts at the known ceiling. An unchanged one costs two writes (ceiling kept, ceiling + 1 refused) rather than a fresh search, which on a 60A inverter behind a 100A register would be about eight refused writes per direction. - The limits line marks the rated estimate "not used once probed". Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 3bb1ef1 commit 108f591

3 files changed

Lines changed: 81 additions & 37 deletions

File tree

‎apps/predbat/solis.py‎

Lines changed: 50 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1188,15 +1188,21 @@ def slot_current_limits(self, inverter_sn, include_probed=True):
11881188
11891189
The cap starts from the battery limit register (CID 7224 charge, 7226 discharge - read as is,
11901190
not multiplied by the pack count, and SOLIS_SLOT_CURRENT_DEFAULT_AMPS when it has not been
1191-
read), is lowered to the smallest sysCommand max SolisCloud advertises across the six slot
1192-
CIDs - slot 1 advertising more than the others is not accepted - and then to the rated current
1193-
(issue #5187). Returns a dict: for each of "charge" and "discharge", "<direction>_register"
1194-
(the raw register value, None when not read), "<direction>_base" (the starting point),
1195-
"<direction>_metadata" (the smallest advertised max, None when none was returned) and
1196-
"<direction>" (the cap); plus "rated" (None when the size is unknown) and "rated_capped".
1197-
Finally each direction is held to the ceiling learned by ensure_slot_current_probed(), which
1198-
"<direction>_probed" reports (None when not probed); include_probed=False leaves it out,
1199-
which is what the probe itself starts from.
1191+
read) and is lowered to the smallest sysCommand max SolisCloud advertises across the six slot
1192+
CIDs - slot 1 advertising more than the others is not accepted. That is the battery-side cap.
1193+
1194+
Then, once ensure_slot_current_probed() has measured what the slots really accept, the cap is
1195+
held to that ceiling. Until then it is held to the rated current instead - an estimate that
1196+
can be too high (a 3.6kW inverter rated for 75A keeps only 60A, issue #5187) or too low (an
1197+
8kW hybrid rated for 156A takes 180A, charging from PV), so a measured ceiling replaces it.
1198+
1199+
Returns a dict: for each of "charge" and "discharge", "<direction>_register" (the raw register
1200+
value, None when not read), "<direction>_base" (the starting point), "<direction>_metadata"
1201+
(the smallest advertised max, None when none was returned), "<direction>_battery" (the
1202+
battery-side cap), "<direction>_probed" (the measured ceiling, None when not probed) and
1203+
"<direction>" (the cap); plus "rated" (None when the size is unknown) and "rated_capped"
1204+
(whether the rated current lowered a cap). include_probed=False leaves the measured ceiling
1205+
out, which is what the probe itself starts from.
12001206
"""
12011207
values = self.cached_values.get(inverter_sn, {})
12021208
infos = self.cached_infos.get(inverter_sn, {})
@@ -1213,20 +1219,24 @@ def slot_current_limits(self, inverter_sn, include_probed=True):
12131219
limits[direction + "_register"] = values.get(register_cid)
12141220
limits[direction + "_base"] = base
12151221
limits[direction + "_metadata"] = min(advertised) if advertised else None
1222+
limits[direction + "_battery"] = cap
12161223
limits[direction] = cap
12171224

12181225
rated = self.get_rated_current(inverter_sn)
12191226
limits["rated"] = rated
1220-
limits["rated_capped"] = rated is not None and rated < max(limits["charge"], limits["discharge"])
1221-
if limits["rated_capped"]:
1222-
limits["charge"] = min(limits["charge"], rated)
1223-
limits["discharge"] = min(limits["discharge"], rated)
1224-
12251227
probed = self.slot_current_probed.get(inverter_sn, {}) if include_probed else {}
12261228
for direction in ("charge", "discharge"):
12271229
limits[direction + "_probed"] = probed.get(direction)
1228-
if probed.get(direction) is not None:
1229-
limits[direction] = min(limits[direction], probed[direction])
1230+
if limits["charge_probed"] is not None and limits["discharge_probed"] is not None:
1231+
# Measured, so the rated estimate no longer applies
1232+
limits["rated_capped"] = False
1233+
for direction in ("charge", "discharge"):
1234+
limits[direction] = min(limits[direction], limits[direction + "_probed"])
1235+
else:
1236+
limits["rated_capped"] = rated is not None and rated < max(limits["charge"], limits["discharge"])
1237+
if limits["rated_capped"]:
1238+
limits["charge"] = min(limits["charge"], rated)
1239+
limits["discharge"] = min(limits["discharge"], rated)
12301240
return limits
12311241

12321242
def describe_slot_current_limits(self, inverter_sn, limits):
@@ -1253,7 +1263,8 @@ def describe_slot_current_limits(self, inverter_sn, limits):
12531263
inputs.append(f"probed max {probed}A")
12541264
parts.append(f"{direction} {cap}A from the {source} ({', '.join(inputs)})")
12551265
if limits["rated"] is not None:
1256-
rated_text = f"rated {limits['rated']}A from {detail.get('power')}{detail.get('powerStr') or 'kW'} at {self.get_nominal_voltage(inverter_sn)}V"
1266+
superseded = ", not used once probed" if limits.get("charge_probed") is not None and limits.get("discharge_probed") is not None else ""
1267+
rated_text = f"rated {limits['rated']}A from {detail.get('power')}{detail.get('powerStr') or 'kW'} at {self.get_nominal_voltage(inverter_sn)}V{superseded}"
12571268
else:
12581269
rated_text = "rated current unknown"
12591270
return f"Solis API: Slot current limits for {inverter_sn}: {', '.join(parts)}; {rated_text}"
@@ -1364,14 +1375,16 @@ async def probe_slot_write(self, inverter_sn, cid, value, field_description):
13641375
self.log(f"Warn: Solis API: Probe of CID {cid} on {inverter_sn} got no answer: {e}")
13651376
return None
13661377

1367-
async def probe_slot_current_ceiling(self, inverter_sn, direction, slot, expected):
1378+
async def probe_slot_current_ceiling(self, inverter_sn, direction, slot, start, upper):
13681379
"""Find the most one direction's slot current may be set to, on a disabled slot, then restore it.
13691380
1370-
Writes the expected maximum first; if it is refused, steps down SOLIS_SLOT_PROBE_STEP_DOWN_AMPS
1371-
at a time until one is kept, then up 1A at a time until one is refused, so the result is the
1372-
exact ceiling in whole amps. Returns None - nothing learned - if any write gets no verdict,
1373-
or if every current down to SOLIS_SLOT_PROBE_MIN_AMPS is refused, which is not a ceiling
1374-
any real inverter has.
1381+
Writes start first - the battery-side cap on a first probe, the known ceiling on a re-check.
1382+
If it is refused, steps down SOLIS_SLOT_PROBE_STEP_DOWN_AMPS at a time until one is kept;
1383+
then steps up 1A at a time until one is refused or upper (the battery-side cap, never
1384+
exceeded) is reached, so the result is the exact ceiling in whole amps. A first probe whose
1385+
start is kept is done in one write, and a re-check that finds the ceiling unchanged in two.
1386+
Returns None - nothing learned - if any write gets no verdict, or if every current down to
1387+
SOLIS_SLOT_PROBE_MIN_AMPS is refused, which is not a ceiling any real inverter has.
13751388
"""
13761389
cid = (SOLIS_CID_CHARGE_CURRENT if direction == "charge" else SOLIS_CID_DISCHARGE_CURRENT)[slot - 1]
13771390
try:
@@ -1385,14 +1398,16 @@ async def attempt(amps):
13851398
return await self.probe_slot_write(inverter_sn, cid, amps, f"probe {direction} slot {slot} current {amps}A")
13861399

13871400
try:
1388-
amps = int(expected)
1401+
upper = int(upper)
1402+
amps = min(int(start), upper)
13891403
verdict = await attempt(amps)
13901404
if verdict is None:
13911405
return None
13921406
if verdict:
1393-
return float(amps)
1394-
refused = amps
1395-
while True:
1407+
refused = upper + 1 # Nothing above the battery-side cap is tried
1408+
else:
1409+
refused = amps
1410+
while not verdict:
13961411
amps -= SOLIS_SLOT_PROBE_STEP_DOWN_AMPS
13971412
if amps < SOLIS_SLOT_PROBE_MIN_AMPS:
13981413
self.log(f"Warn: Solis API: {inverter_sn} refused every {direction} slot current down to {amps + SOLIS_SLOT_PROBE_STEP_DOWN_AMPS}A - not a real ceiling, nothing learned")
@@ -1433,18 +1448,23 @@ async def ensure_slot_current_probed(self, inverter_sn):
14331448
return
14341449
limits = self.slot_current_limits(inverter_sn, include_probed=False)
14351450
if limits["charge_register"] is None or limits["discharge_register"] is None:
1436-
return # The expected maximum starts from the registers, so wait until they have been read
1451+
return # The probe starts from the registers, so wait until they have been read
14371452
slot = self.find_probe_slot(inverter_sn)
14381453
if slot is None:
14391454
if inverter_sn not in self.slot_probe_no_slot_logged:
14401455
self.slot_probe_no_slot_logged.add(inverter_sn)
14411456
self.log(f"Solis API: No disabled slot on {inverter_sn} to probe the slot current ceiling on, keeping the {limits['charge']}A/{limits['discharge']}A caps")
14421457
return
14431458

1444-
self.log(f"Solis API: Probing the slot current ceiling on {inverter_sn} using disabled slot {slot}, expecting charge {limits['charge']}A and discharge {limits['discharge']}A")
1459+
# A first probe starts at the battery-side cap, not the rated estimate, which can be below
1460+
# what the inverter really takes; a re-check starts at the known ceiling, so an unchanged
1461+
# one costs two writes rather than a fresh search
1462+
known = self.slot_current_probed.get(inverter_sn) or {}
1463+
starts = {direction: known.get(direction, limits[direction + "_battery"]) for direction in ("charge", "discharge")}
1464+
self.log(f"Solis API: {'Re-checking' if known else 'Probing'} the slot current ceiling on {inverter_sn} using disabled slot {slot}, starting at charge {starts['charge']}A and discharge {starts['discharge']}A (battery limits {limits['charge_battery']}A/{limits['discharge_battery']}A)")
14451465
probed = {}
14461466
for direction in ("charge", "discharge"):
1447-
ceiling = await self.probe_slot_current_ceiling(inverter_sn, direction, slot, limits[direction])
1467+
ceiling = await self.probe_slot_current_ceiling(inverter_sn, direction, slot, starts[direction], limits[direction + "_battery"])
14481468
if ceiling is None:
14491469
kept = self.slot_current_probed.get(inverter_sn)
14501470
keeping = f", keeping charge {kept['charge']}A, discharge {kept['discharge']}A" if kept else ""

‎apps/predbat/tests/test_solis.py‎

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6747,6 +6747,11 @@ def published():
67476747
assert published() == {"slot_charge_power_max": 2880, "slot_discharge_power_max": 2880, "battery_rate_max": 2880, "max_charge_power": 4800}, f"after the probe 60A should apply, got {published()}"
67486748
slider = api.dashboard_items[f"number.{prefix}_solis_{lower}_discharge_slot1_power"]["attributes"]["max"]
67496749
assert slider == 2880, f"the slot power slider should stop at what the slot accepts, got {slider}"
6750+
6751+
# A measured ceiling above the rated estimate is what gets planned with
6752+
api.slot_current_probed[sn] = {"charge": 100.0, "discharge": 90.0, "probed_at": "2026-09-29T20:00:00+00:00"}
6753+
await api.publish_entities()
6754+
assert published() == {"slot_charge_power_max": 4800, "slot_discharge_power_max": 4320, "battery_rate_max": 4800, "max_charge_power": 4800}, f"a probed ceiling above the rated 75A should be used, got {published()}"
67506755
print("PASSED: published slot power limits follow the slot current cap")
67516756
return False
67526757

@@ -6826,7 +6831,7 @@ async def test_slot_probe_finds_exact_ceiling():
68266831
assert probed == {"charge": 60.0, "discharge": 60.0} and probed_at, f"expected 60A both ways with a probe time, got {api.slot_current_probed.get(sn)}"
68276832
discharge_cid = SOLIS_CID_DISCHARGE_CURRENT[5]
68286833
discharge_writes = [value for cid, value in inverter.writes if cid == discharge_cid]
6829-
assert discharge_writes == ["75", "70", "65", "60", "61", "50"], f"expected 75 (rated), down 5 to 60, up to 61, then restore 50; got {discharge_writes}"
6834+
assert discharge_writes == ["100", "95", "90", "85", "80", "75", "70", "65", "60", "61", "50"], f"expected 100 (the battery limit), down 5 to 60, up to 61, then restore 50; got {discharge_writes}"
68306835
assert {cid for cid, _ in inverter.writes} == {SOLIS_CID_CHARGE_CURRENT[5], discharge_cid}, f"only disabled slot 6 may be written, got {inverter.writes}"
68316836
assert inverter.registers[discharge_cid] == "50" and inverter.registers[SOLIS_CID_CHARGE_CURRENT[5]] == "50", "slot 6 must be restored in both directions"
68326837
assert storage.contents.get(("solis", "slot_current_limits")) == {sn: {"charge": 60.0, "discharge": 60.0, "probed_at": probed_at}}, f"the ceiling should be saved, got {storage.contents}"
@@ -6836,22 +6841,30 @@ async def test_slot_probe_finds_exact_ceiling():
68366841
assert (limits["charge"], limits["discharge"]) == (60.0, 60.0), f"slot writes should be capped at 60A, got {limits}"
68376842
text = api.describe_slot_current_limits(sn, limits)
68386843
assert "charge 60.0A from the probed ceiling (register 100A, no slot metadata max, probed max 60.0A)" in text, f"the limits line should say the probed ceiling set the cap: {text}"
6839-
assert "from the rated current" not in text, f"the rated 75A must not read as the cap once the probe has lowered it: {text}"
6844+
assert "from the rated current" not in text and "not used once probed" in text, f"the rated 75A must not read as the cap once probed: {text}"
68406845
assert any("probe discharge slot 6 current 61A on SN0PROBE: refused, still 60" in m for m in api.log_messages), "each refusal should be logged with the value kept"
68416846
assert any("probe discharge slot 6 current 60A on SN0PROBE: kept" in m for m in api.log_messages), "each accepted value should be logged"
68426847
print("PASSED: slot probe finds the exact ceiling")
68436848
return False
68446849

68456850

68466851
async def test_slot_probe_expected_maximum_accepted():
6847-
"""When the inverter keeps the expected maximum, that is the ceiling - one write each way plus the restore."""
6848-
print("\n=== Test: slot probe keeps an accepted expected maximum ===")
6852+
"""When the inverter keeps its battery limit, that is the ceiling - and it replaces a lower rated estimate.
6853+
6854+
An 8kW hybrid reading 180A is rated for only 156A at 51.2V, yet takes 180A (charging from PV
6855+
above the AC rating); the rated estimate must not hold it below what was measured. One write
6856+
each way plus the restore.
6857+
"""
6858+
print("\n=== Test: slot probe keeps an accepted battery limit ===")
68496859
api, sn = _probe_api(storage=_FakeStorage())
68506860
inverter = _ProbeInverter(api, ceiling=100)
6861+
assert api.slot_current_limits(sn)["discharge"] == 75.0, "before probing, the 75A rated estimate should apply"
68516862
await api.ensure_slot_current_probed(sn)
6852-
assert (api.slot_current_probed[sn]["charge"], api.slot_current_probed[sn]["discharge"]) == (75.0, 75.0), f"expected the 75A rated cap to stand, got {api.slot_current_probed.get(sn)}"
6863+
assert (api.slot_current_probed[sn]["charge"], api.slot_current_probed[sn]["discharge"]) == (100.0, 100.0), f"expected the 100A battery limit to be kept, got {api.slot_current_probed.get(sn)}"
68536864
assert len(inverter.writes) == 4, f"expected one probe write and one restore per direction, got {inverter.writes}"
6854-
print("PASSED: slot probe keeps an accepted expected maximum")
6865+
limits = api.slot_current_limits(sn)
6866+
assert (limits["charge"], limits["discharge"]) == (100.0, 100.0) and not limits["rated_capped"], f"a measured ceiling above the rated estimate should replace it, got {limits}"
6867+
print("PASSED: slot probe keeps an accepted battery limit")
68556868
return False
68566869

68576870

@@ -6903,6 +6916,17 @@ async def test_slot_probe_reloaded_not_repeated():
69036916
limits = api.slot_current_limits(sn)
69046917
assert (limits["charge"], limits["discharge"]) == (60.0, 58.0), f"the saved ceilings should cap the slots, got {limits}"
69056918

6919+
# A day old with the ceiling unchanged: the re-check starts at the known ceiling, so it costs two writes and the restore
6920+
unchanged = {"SN0PROBE": {"charge": 60, "discharge": 60, "probed_at": (now - timedelta(hours=25)).isoformat()}}
6921+
api, sn = _probe_api(storage=_FakeStorage({("solis", "slot_current_limits"): unchanged}))
6922+
api._test_now_utc_exact = now
6923+
inverter = _ProbeInverter(api, ceiling=60)
6924+
await api.load_slot_current_probes()
6925+
await api.ensure_slot_current_probed(sn)
6926+
discharge_writes = [value for cid, value in inverter.writes if cid == SOLIS_CID_DISCHARGE_CURRENT[5]]
6927+
assert discharge_writes == ["60", "61", "50"], f"an unchanged re-check should write the ceiling, one above, then restore; got {discharge_writes}"
6928+
assert api.slot_current_probed[sn]["probed_at"] == now.isoformat(), "the re-check should refresh the probe time"
6929+
69066930
# A day old: checked again, and the fresh result replaces it
69076931
stale = {"SN0PROBE": {"charge": 60, "discharge": 58, "probed_at": (now - timedelta(hours=25)).isoformat()}}
69086932
storage = _FakeStorage({("solis", "slot_current_limits"): stale})

‎docs/apps-yaml.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -845,7 +845,7 @@ When `automatic: true` (recommended), Predbat will automatically create and conf
845845

846846
No manual entity configuration is required when using automatic mode.
847847

848-
Automatic mode plans each direction at the most a charge or discharge slot can actually be set to. That is the lowest of the inverter's maximum charge or discharge current, the current it can deliver at its rated power, and a ceiling Predbat measures itself. Some inverters refuse slot currents well below the maximum current they report, with no way to read the real limit. For example, a 3.6kW inverter reports 100A but only accepts 60A. When Predbat first sees an inverter, it measures this ceiling by writing test currents to one of slots 2-6 that is not in use, and puts back the value that slot held. It saves the result, and checks it again at most once a day. Predbat only uses slot 1 itself, so the test does not touch the schedule. The results are published as `sensor.predbat_solis_<serial>_slot_charge_power_max` and `..._slot_discharge_power_max`, and used for `inverter_limit_charge` and `inverter_limit_discharge`. `battery_rate_max` is set to the larger of the two, so a battery allowed to discharge faster than it charges is planned at both rates. An `inverter_limit_charge` or `inverter_limit_discharge` you set in `apps.yaml` takes precedence - use it to state a lower limit such as your inverter's AC rating.
848+
Automatic mode plans each direction at the most a charge or discharge slot can actually be set to. Some inverters refuse slot currents well below the maximum current they report, with no way to read the real limit. For example, a 3.6kW inverter reports 100A but only accepts 60A. So when Predbat first sees an inverter, it measures the ceiling itself. It writes test currents to one of slots 2-6 that is not in use: first the inverter's reported maximum charge or discharge current, then lower values until one is accepted. It then puts back the value that slot held. It saves the result, and checks it again at most once a day, starting from the saved value. Until an inverter has been measured, the current it can deliver at its rated power is used as an estimate. Once measured, the measured ceiling replaces that estimate, so a hybrid that can charge from PV above its AC rating is not held back. Predbat only uses slot 1 itself, so the test does not touch the schedule. The results are published as `sensor.predbat_solis_<serial>_slot_charge_power_max` and `..._slot_discharge_power_max`, and used for `inverter_limit_charge` and `inverter_limit_discharge`. `battery_rate_max` is set to the larger of the two, so a battery allowed to discharge faster than it charges is planned at both rates. An `inverter_limit_charge` or `inverter_limit_discharge` you set in `apps.yaml` takes precedence - use it to state a lower limit such as your inverter's AC rating.
849849

850850
#### Manual configuration (solis_automatic: false)
851851

0 commit comments

Comments
 (0)