You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 108f591
Browse filesBrowse the repository at this point in the historyBrowse files
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>
parts.append(f"{direction}{cap}A from the {source} ({', '.join(inputs)})")
1255
1265
iflimits["rated"] isnotNone:
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"iflimits.get("charge_probed") isnotNoneandlimits.get("discharge_probed") isnotNoneelse""
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}"
1257
1268
else:
1258
1269
rated_text="rated current unknown"
1259
1270
returnf"Solis API: Slot current limits for {inverter_sn}: {', '.join(parts)}; {rated_text}"
returnawaitself.probe_slot_write(inverter_sn, cid, amps, f"probe {direction} slot {slot} current {amps}A")
1386
1399
1387
1400
try:
1388
-
amps=int(expected)
1401
+
upper=int(upper)
1402
+
amps=min(int(start), upper)
1389
1403
verdict=awaitattempt(amps)
1390
1404
ifverdictisNone:
1391
1405
returnNone
1392
1406
ifverdict:
1393
-
returnfloat(amps)
1394
-
refused=amps
1395
-
whileTrue:
1407
+
refused=upper+1# Nothing above the battery-side cap is tried
1408
+
else:
1409
+
refused=amps
1410
+
whilenotverdict:
1396
1411
amps-=SOLIS_SLOT_PROBE_STEP_DOWN_AMPS
1397
1412
ifamps<SOLIS_SLOT_PROBE_MIN_AMPS:
1398
1413
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")
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
1437
1452
slot=self.find_probe_slot(inverter_sn)
1438
1453
ifslotisNone:
1439
1454
ifinverter_snnotinself.slot_probe_no_slot_logged:
1440
1455
self.slot_probe_no_slot_logged.add(inverter_sn)
1441
1456
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")
1442
1457
return
1443
1458
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 {}
self.log(f"Solis API: {'Re-checking'ifknownelse'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)")
assertdischarge_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
+
assertdischarge_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}"
6830
6835
assert {cidforcid, _ininverter.writes} == {SOLIS_CID_CHARGE_CURRENT[5], discharge_cid}, f"only disabled slot 6 may be written, got {inverter.writes}"
6831
6836
assertinverter.registers[discharge_cid] =="50"andinverter.registers[SOLIS_CID_CHARGE_CURRENT[5]] =="50", "slot 6 must be restored in both directions"
6832
6837
assertstorage.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}"
assert (limits["charge"], limits["discharge"]) == (60.0, 60.0), f"slot writes should be capped at 60A, got {limits}"
6837
6842
text=api.describe_slot_current_limits(sn, limits)
6838
6843
assert"charge 60.0A from the probed ceiling (register 100A, no slot metadata max, probed max 60.0A)"intext, f"the limits line should say the probed ceiling set the cap: {text}"
6839
-
assert"from the rated current"notintext, f"the rated 75A must not read as the cap once the probe has lowered it: {text}"
6844
+
assert"from the rated current"notintextand"not used once probed"intext, f"the rated 75A must not read as the cap once probed: {text}"
6840
6845
assertany("probe discharge slot 6 current 61A on SN0PROBE: refused, still 60"inmforminapi.log_messages), "each refusal should be logged with the value kept"
6841
6846
assertany("probe discharge slot 6 current 60A on SN0PROBE: kept"inmforminapi.log_messages), "each accepted value should be logged"
6842
6847
print("PASSED: slot probe finds the exact ceiling")
"""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 ===")
6849
6859
api, sn=_probe_api(storage=_FakeStorage())
6850
6860
inverter=_ProbeInverter(api, ceiling=100)
6861
+
assertapi.slot_current_limits(sn)["discharge"] ==75.0, "before probing, the 75A rated estimate should apply"
6851
6862
awaitapi.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)}"
6853
6864
assertlen(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) andnotlimits["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")
Copy file name to clipboardExpand all lines: docs/apps-yaml.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -845,7 +845,7 @@ When `automatic: true` (recommended), Predbat will automatically create and conf
845
845
846
846
No manual entity configuration is required when using automatic mode.
847
847
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.
0 commit comments