Skip to content

fix(solis): probe the slot current ceiling the inverter enforces but does not report - #5309

Merged
springfall2008 merged 5 commits into
mainfrom
fix/solis-slot-current-probe-5187
Sep 30, 2026
Merged

springfall2008 merged 5 commits into
mainfrom
fix/solis-slot-current-probe-5187

Conversation

@springfall2008

@springfall2008 springfall2008 commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

Follow-up to #5187 / #5189 (and builds on #5308).

Problem

A 3.6 kW S5-EH1P3.6K-L (model 3102, firmware 4C0050, TOU V2, one PYLON_LV pack) reports 100 A in CID 7224/7226. #5189 capped slot currents at the 75 A rated current (3600 W / 48 V), but the inverter refuses anything above 60 A. A refused write leaves the register at its old value, so every discharge slot sat at 0 A and held the battery through each planned export. The planner meanwhile assumed 4.8 kW each way (the raw register × 48 V).

Live testing on that inverter, using the new CLI below, showed:

  • 60 A is kept; 61, 62, 65, 70, 71 and 75 A are refused
  • the same holds for charge and discharge, and on enabled (slot 1, window open) and disabled (slot 6) slots alike
  • no API reply reports 60 A:
    • atRead/atReadBatch return only msg/yuanzhi/command/needLoop, with no min/max, so the existing sysCommand.max check never fires on the OAuth route
    • inverterDetail's batteryC/DMaxiSet read 65 A and the BMS limits read 160 A

A second inverter, an 8 kW hybrid on a 51.2 V pack, reads 180 A there and ran its slots at 180 A before #5189. The rated estimate (8000 W / 51.2 V = 156 A) has held it at 156 A since then, capping PV charging. So the rated estimate is wrong in both directions, and the cap has to be measured.

Change

Slot current ceiling probe (ensure_slot_current_probed()). Predbat only drives slot 1. On first discovery, with control enabled and TOU V2 firmware, it picks a slot from 2–6 that is disabled in both directions, and for each direction:

  1. writes the battery-side cap: the CID 7224/7226 register, lowered by any slot metadata. The rated estimate plays no part.
  2. if that is refused, steps down 5 A at a time until one is kept
  3. then steps up 1 A at a time until one is refused, or until the battery-side cap is reached. That gives the exact ceiling, and nothing above the register is ever written.
  4. restores the slot's original value

Guarding against an offline or erroring inverter:

  • A refusal is concluded only when the write is acknowledged and the settled read-back still holds the previous value.
  • A read-back still holding the old value gets one more settle before it counts as a refusal, so a slow relay is not saved as a lower ceiling.
  • Any error, no reply, an offline datalogger or an unexpected read-back gives no verdict. The probe is then abandoned and nothing is learned. It is retried after 1 h, and the wait doubles on each further failure up to a day, so an inverter that never gives a verdict is not probed every hour indefinitely.
  • Refusing everything down to 10 A also learns nothing. An unreachable inverter can never settle on a minimum.

When it runs:

  • The probe runs last in each cycle, after the control writes, the publish and auto-config, so a first probe never delays slot-1 control.
  • It honours switch.<prefix>_set_read_only. While read-only is on it stays pending, and it runs on the first cycle after read-only is turned off.
  • It waits until the saved ceilings have loaded, so a transient storage error cannot trigger a fresh probe that overwrites them.

The result is saved through Storage (solis/slot_current_limits) and reloaded. It is semi-static:

  • A new inverter is probed at once.
  • A known one is re-checked only once its result is a day old. The re-check starts from the known ceiling, so an unchanged one costs two writes plus the restore.
  • A failed re-check keeps the known value.

slot_current_limits() applies the measured ceiling to every slot write, and it replaces the #5189 rated estimate. The rated estimate is only a fallback until an inverter has been probed. inverter_limit still bounds the AC side in the plan.

Planner limits. New sensor.<prefix>_solis_<sn>_slot_charge_power_max / _slot_discharge_power_max sensors (slot cap × nominal voltage) are published. They now feed inverter_limit_charge/_discharge (still overwrite=False, so an apps.yaml value wins) and battery_rate_max. These replace the raw 4800 W register figures from #5308. The slot power sliders' max follows too. max_charge_power/max_discharge_power still show the register.

Diagnostics:

  • A new slot_current_limits() helper returns every input to the V2 cap. The write path gets the same result as before.
  • A limits line, logged when it changes, names which input set each cap (charge 60.0A from the probed ceiling (register 100A, no slot metadata max, probed max 60.0A) …).
  • The stale per-cycle Capping slot currents … at 75.0A line is removed.
  • An allow-listed inverter summary line (model/firmware/rating/packs) is logged; the station name and address stay out.
  • "Calculated max currents" now names register × packs vs the default.
  • Each probe verdict is logged.

CLI harness (solis.py __main__), used to find all this:

  • --config apps.yaml, resolving !secret from a secrets.yaml beside the file. Its key map is kept in step with COMPONENT_LIST by a test.
  • OAuth plus the Supabase refresh settings, and --inverter-sn
  • --dump-raw, which prints every reply in full via a response_observer hook that is off in production
  • --probe-ceiling, which runs the production probe on its own. It needs --write, and run() stays read-only while it runs. The manual any-slot probe used during the investigation has been removed in favour of this.

Testing

  • 23 new tests in test_solis.py. They cover:
    • the probe's exact-ceiling search from the register
    • an accepted battery limit replacing a lower rated estimate
    • offline and write-error cases learning nothing
    • refuse-everything learning nothing
    • the Storage round trip, a daily re-check starting at the known ceiling (two writes when unchanged), a failed re-check keeping the value, and an unreadable saved entry
    • the skip conditions and slot choice
    • read-only keeping the probe pending, retry backoff, a slow relay's second settle, a load that fails or finds malformed data, unreadable registers not stopping the publish, a moving HV voltage not re-logging the limits line, and the probe running after the control write and publish
    • published limits before a probe (the rated fallback, 3600 W), after it (2880 W), and with a measured ceiling above the rated estimate
    • the limits line and change-only logging
    • the inverter-summary allow-list
    • CLI key and merge behaviour, and !secret resolution
    • the response observer
  • The probe tests fail without the probe code (checked by stashing the source) and pass with it. The seven review-fix tests each fail against the code before the fixes, for the reason they target (read-only made 86 requests; a junk register raised ValueError; the probe ran before the control write), and pass with them.
  • ./run_all --quick passes, and pre-commit passes on the changed files.
  • Live, with full --write runs:
    • 3.6 kW: probed slot 6 and learned 60/60 A. The write pass then capped at 60 A, and inverter_limit_charge/_discharge/battery_rate_max read 2880 W.
    • 8 kW: re-run after the start point moved to the register, and the result was confirmed.

Notes

  • Blast radius: GitNexus rates SolisAPI.run, write_time_windows_if_changed and fetch_inverter_details LOW, with a single caller each. The index is stale for the new helpers, so their callers were checked by search. Everything is inside solis.py; inverter.py is untouched.
  • Not covered: re-probing on a firmware change, and V1 (CID 103) inverters, which have no per-slot current registers and are not probed.
  • Cost: a first probe on an inverter like the 3.6 kW one walks down from 100 A. That is about eight refused writes per direction and roughly two minutes, most of it the 5 s settle after each refusal. After that, the daily re-check is two writes plus the restore per direction.
  • A refused slot-1 write does not trigger an early re-probe. A ceiling that drops is picked up at the next daily re-check.
  • tools/debug-journal.md's Solis row still points at the removed Capping slot currents line and at the sysCommand slot metadata. It can be refreshed once this merges.

🤖 Generated with Claude Code

CI and others added 2 commits September 29, 2026 20:35
… slot current cap

The solis.py CLI harness can now read its settings from an apps.yaml-format
file (--config, resolving !secret). It supports OAuth with the Supabase
refresh settings, limits a run to one inverter, and dumps every raw API
reply (--dump-raw). With --write it can probe which charge or discharge
currents a given slot accepts, restoring the original value afterwards.

In production, the component now logs:
- an allow-listed inverter summary (model, rating, firmware, pack count)
- every input to the V2 slot current cap: register, slot metadata and
  rated current, each logged when it changes
- whether "Calculated max currents" came from a register times the pack
  count or from the default

The slot current cap is computed in slot_current_limits(). The write path
gets the same result as before.

This came out of GH#5187: a 3.6kW S5-EH1P3.6K-L refused every 75A slot
write. Probing showed its real ceiling is 60A in both directions, on
enabled and disabled slots alike. None of the API replies reports it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…does not report

Issue #5187. A 3.6kW S5-EH1P3.6K-L reports 100A in CID 7224/7226 and has a
75A rated cap, but refuses any slot current above 60A. That applies in both
directions, on enabled and disabled slots alike. A refused write leaves the
register at its old value, so every 75A export slot sat at 0A, holding the
battery. No API reply reports the 60A.

On first discovery (V2 firmware, control enabled) Predbat now probes a slot
it does not drive (2-6, disabled in both directions):
- write the expected maximum
- if refused, step down 5A until one is kept, then up 1A until one is
  refused
- restore the slot's original value afterwards

A refusal is only concluded when the inverter acknowledges the write and
still holds its old value. An error, an offline datalogger or an
unexpected read-back gives no verdict, and nothing is learned. Refusing
everything down to 10A also learns nothing, so an unreachable inverter
cannot settle on a minimum.

The ceiling is saved to Storage and reloaded. It is re-checked at most
once a day, and a failed re-check keeps the known value. It caps every
slot write through slot_current_limits().

It also sets the planner's limits. New slot_charge_power_max and
slot_discharge_power_max sensors (cap x nominal voltage) now feed
inverter_limit_charge and inverter_limit_discharge (overwrite=False) and
battery_rate_max, replacing the raw 4800W register figures.

Also:
- the limits log line names which input set each cap
- the stale "Capping ... at 75A" line is removed
- each probe verdict is logged
- the CLI gains --probe-ceiling to run this probe live

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@springfall2008 springfall2008 self-assigned this Sep 29, 2026
CI and others added 3 commits September 29, 2026 21:00
…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>
…robe-ceiling runs the real one

--probe-charge-current, --probe-discharge-current and --probe-slot, with
probe_slot_current(), were only for finding the slot current ceiling by
hand. --probe-ceiling now runs Predbat's own ensure_slot_current_probed(),
so they are removed along with their test.

solis_slot_current_report()'s per-slot dump goes too. The CLI now prints
the one-line limits summary, which names every input and the one that set
each cap, and --dump-raw still shows the raw register replies.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review of #5309:
- Read-only: the probe now honours switch.<prefix>_set_read_only, as
  every other control write does. It stays pending (logged once) and runs
  on the first cycle after read-only is turned off.
- Timing: the probe runs last in run(), after the control writes, the
  publish and auto-config, and is checked every cycle. A first probe
  (a minute or two) no longer delays slot-1 control or startup.
- Backoff: a probe that learns nothing (an error code, a clamped
  read-back, an offline datalogger) is retried after 1h, doubling up to a
  day, instead of every hour indefinitely. A success clears it.
- Slow relay: a read-back still holding the old value gets one more
  settle before it counts as a refusal, so a late-landing write is not
  saved as a lower ceiling for a day.
- Saved ceilings: marked loaded only after a successful load, and the
  probe waits for that, so a transient storage error cannot lead to a
  fresh probe overwriting them. A malformed file or entry is ignored
  without raising.
- Publishing: slot_current_limits() treats an unreadable register or
  sysCommand max as not read. It now feeds publish_entities(), which a
  junk value would otherwise stop.
- Log spam: the change-only limits line is keyed on the limits, not the
  text, so an HV pack's moving live voltage does not re-log it every
  minute.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@springfall2008
springfall2008 merged commit 00f388b into main Sep 30, 2026
2 checks passed
@springfall2008
springfall2008 deleted the fix/solis-slot-current-probe-5187 branch September 30, 2026 06:54
springfall2008 added a commit that referenced this pull request Oct 2, 2026
…0-01

docs(debug-journal): fold in the 2026-10-01 queue slice (9 candidates); mark GH#5313 fixed by PR #5315, GH#5136 by PR #5293, GH#5222 by PR #5283, the iog-slot-io-adjusted row by PR #5304, GH#5068 reworked by PRs #5309/#5314; add the dynamic-load car cancel row and the GH#2649/#5302/#5324 findings
gcoan pushed a commit to gcoan/batpred that referenced this pull request Oct 3, 2026
…); mark GH#5313 fixed by PR springfall2008#5315, GH#5136 by PR springfall2008#5293, GH#5222 by PR springfall2008#5283, the iog-slot-io-adjusted row by PR springfall2008#5304, GH#5068 reworked by PRs springfall2008#5309/springfall2008#5314; add the dynamic-load car cancel row and the GH#2649/springfall2008#5302/springfall2008#5324 findings

Co-Authored-By: Claude Code <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant