Skip to content

fix(solis): use a stable voltage for amp<->watt conversions - #5095

Merged
springfall2008 merged 3 commits into
mainfrom
fix/solis-nominal-voltage-5090
Sep 15, 2026
Merged

springfall2008 merged 3 commits into
mainfrom
fix/solis-nominal-voltage-5090

Conversation

@springfall2008

@springfall2008 springfall2008 commented Sep 14, 2026 •

Copy link
Copy Markdown
Owner

This is an automated draft PR generated from issue #5090 — a maintainer should review it before merging.

Fixes #5090

Summary

The inverter holds its charge/discharge limits as currents (CID 7224/7226 and the per-slot currents), but get_nominal_voltage() re-derived every watt value from the live measured batteryVoltage. A fixed 70A limit therefore republished as anything from 3352W to 3726W as the pack moved with state of charge, dragging battery_rate_max (auto-bound to the max_charge_power entity), the write tolerance computed from it, and the read-back of every rate setpoint along with it.

Implements the strategy from the maintainer's field-data comment (#5090 (comment)). get_nominal_voltage() now returns, in priority order:

  1. solis_nominal_voltage from apps.yaml — which previously fed only get_capacity_voltage() and did nothing for the rate paths, so there was no workaround for this at all;
  2. the live voltage when it exceeds 60V — an HV pack, where behaviour is unchanged from the [Solis Cloud] Hard-coded nominal_voltage of 48V breaks power/capacity calculations for HV batteries #4493 fix (no sampled LV system passed 57.5V);
  3. otherwise an LV pack classified by the BMS-requested charge voltage: 51.2V nominal at or above 55V, 48V below it;
  4. otherwise the existing 48V fallback.

Two new helpers support this: get_live_battery_voltage() (the measurement, retaining the last-known fallback so #4493's behaviour is preserved exactly) and get_bms_charge_voltage() (the first non-zero of top-level batteryAcvSet or any batteryList[].batteryChargingVoltage — identical on all 14 sampled systems). The live voltage is still published as its own sensor for display.

Why not the original suggestion

The issue description's first idea — infer a nominal from a live reading and snap it to a pack class — does not work, and the field data is what establishes that: the LV classes overlap (a full 15S reaches 53.5V, a low 16S drops to 47.1V), so any rule reading a single sample lands in the wrong class. batteryType cannot arbitrate either, since it names the BMS protocol and PYLON_LV was reported by both classes. The charge voltage is the only field that separates them, and being a setting rather than a measurement it also holds still — which is what the bug needs.

Testing

coverage/run_pre_commit — all 12 hooks pass with no auto-fixes, and the bundled run_all --quick passes (All tests passed (4 slow tests skipped, total time: 120.45s)). npx cspell run separately over the changed files (the local cspell hook does not actually check spelling): clean.

Two new tests in test_solis.py, both of which fail without the fix and pass with it. Verified by restoring apps/predbat/solis.py to origin/main while keeping the test file, and driving the two tests directly:

  • test_nominal_voltage_sources_and_stability — red with Expected the 48V fallback when no charge voltage is reported, got 51.3
  • test_rate_setpoints_do_not_drift_with_battery_voltage — red with max_charge_power moved from 3647W to 3352W at 47.89V with the current unchanged, which lands inside the 3352W–3726W range the reporter measured over three days

The second is the test shape the issue asked for: it writes a slot rate in watts, moves the live voltage between the write and the next publish, and asserts the published watts and the battery_rate_max source entity are unchanged and that the write round trip closes. The first covers both charge-voltage field names, the 53.2 / 54.9 / 55.0 / 56.5 / 58.4 boundary, stability across the full 47.1–53.5V live range, the HV path plus its last-known fallback, and the no-charge-voltage fallback.

One note on the red run: the pre-fix solis.py reads nominal_voltage_last_known, which this PR renames, so the red driver sets that attribute on the mock to keep the failure behavioural rather than an AttributeError.

Notes

  • HV packs still drift. Step 2 keeps the live voltage above 60V as specified, so an HV user still sees the 10–14% swing; solis_nominal_voltage is the only fix there, which the docs now say. Worth a follow-up if there are HV systems to sample.
  • Behaviour change a reviewer should look at: when solis_nominal_voltage is configured it now wins for power conversions too, not just capacity. Three assertions in test_publish_entities encoded the opposite — that a configured 512V nominal is ignored in favour of a live 52.3V — and I updated them rather than working around them, since that preference is the bug.
  • soc_max auto-binding left disabled. The original "would wobble cycle to cycle" rationale is now gone, but without solis_nominal_voltage the capacity is still an inference, and set_arg_auto() defaults to overwrite=True, so enabling it would replace a correct hand-entered soc_max with an approximate one. That is a maintainer's judgement rather than part of this fix; the line stays commented and the comment now states the current reasoning.
  • The pre-existing test_get_nominal_voltage_and_capacity_voltage uses 533V, which is above the 60V threshold, so it now passes via the HV branch. It reads like general coverage but exercises only HV — the LV path is covered by the new test.
  • The GitNexus MCP tools were not available in this session, so the blast-radius check was done by search: get_nominal_voltage() has 17 call sites, all within solis.py, no external callers, and no signature change.
  • The wording changes in docs/apps-yaml.md, docs/components.md and templates/solis_cloud.yaml are not incidental — all three told users that the max charge/discharge power sensors use the live voltage and that solis_nominal_voltage is only needed for capacity, which is no longer true.
  • Not addressed here, from the same comment: batteryChargingCurrent / batteryDischargeLimiting hold the BMS current limits separately from the inverter's own CID 7224/7226 settings. That belongs in tools/debug-journal.md rather than in this fix.

🤖 Generated with Claude Code

The inverter holds its charge/discharge limits as currents, but every watt
value Predbat derived from them used the live measured battery voltage, so a
fixed 70A limit republished as anything from 3352W to 3726W as the pack moved
with state of charge. That dragged battery_rate_max (auto-bound to the
max_charge_power entity), the write tolerance computed from it, and the
read-back of every rate setpoint along with it - producing spurious rewrites
and "didn't complete got Y" warnings with nothing on the inverter changed.

get_nominal_voltage() now returns a voltage that holds still: the configured
solis_nominal_voltage if set (which previously fed only the capacity
calculation), otherwise the first reported batteryVoltage latched and held,
otherwise the 48V fallback. The live value is still published as its own
sensor for display.

The latched reading is deliberately not snapped to a cell count - LiFePO4's
curve is flat enough that one 16S pack reads 47.9V to 53.2V, which rounds to
15, 16 or 17 cells, so snapping would be a less predictable guess than holding
the measurement (the same reasoning as deye.py's nominal_pack_voltage()).

Fixes #5090

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@springfall2008 springfall2008 self-assigned this Sep 14, 2026
@springfall2008
springfall2008 marked this pull request as ready for review September 14, 2026 19:18
Copilot AI lite review requested due to automatic review settings September 14, 2026 19:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Clarify the diagnostic distinction between a latched voltage reading and the 48V fallback.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

This PR stabilizes Solis amp-to-watt conversions using configured, latched, or fallback voltage.

Changes:

  • Updated Solis voltage selection and conversion logic.
  • Added regression tests for stability and rate setpoints.
  • Revised Solis documentation and configuration templates.
File summaries
File Summary
templates/solis_cloud.yaml Updated sample configuration guidance.
docs/components.md Documented revised Solis conversion behavior.
docs/apps-yaml.md Updated nominal-voltage configuration documentation.
apps/predbat/tests/test_solis.py Added voltage and setpoint stability tests.
apps/predbat/solis.py Implemented stable voltage selection and conversions.
Review details
  • Files reviewed: 5/5 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread apps/predbat/solis.py Outdated
springfall2008 and others added 2 commits September 14, 2026 22:32
The local cspell pre-commit hook does not actually check spelling, so
run_pre_commit passing is not evidence CI will. Verified with npx cspell
directly: "setpoints" was unknown (added to the workspace dictionary, matching
the wording issue #5090 itself uses) and the SN0NOMV01/02 test serials tripped
on "NOMV", so they are now SN0VOLT01/02 rather than teaching the dictionary a
non-word.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the latch-the-first-reading approach with the strategy the field data
in issue #5090 supports. 30 days of history from 30 LV systems shows the live
voltage cannot identify the pack at all: 15S averages 49.3-50.4V and 16S
52.6-54.4V, but a full 15S reaches 53.5V and a low 16S drops to 47.1V, so any
rule reading a single sample lands in the wrong class. batteryType is no help
either - it names the BMS protocol, and PYLON_LV was reported by both classes.

inverterDetail does carry the BMS-requested charge voltage, top level as
batteryAcvSet and per battery as batteryList[].batteryChargingVoltage, and it
separates the classes cleanly: 53.2V on every 15S pack that reported it,
56.5-58.4V on every 16S, leaving 55V in an empty 3.3V gap. Being a setting
rather than a measurement it also holds still, which is what #5090 needs.

get_nominal_voltage() now returns, in order: solis_nominal_voltage if
configured; the live voltage when it exceeds 60V, since that is an HV pack and
no sampled LV system passed 57.5V (preserving #4493); 51.2V or 48V by the
charge voltage against the 55V threshold; else the 48V fallback, for the one
sampled inverter that reports neither field.

HV packs still track the live voltage and so still drift - there is no field
data to classify them by, and solis_nominal_voltage remains the fix there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@springfall2008
springfall2008 merged commit 3f8d383 into main Sep 15, 2026
2 checks passed
@springfall2008
springfall2008 deleted the fix/solis-nominal-voltage-5090 branch September 15, 2026 18:31
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.

[Solis] amp<->watt conversions use live battery voltage, so battery_rate_max and rate setpoints drift with SoC

2 participants