Skip to content

Recover battery size after a transient unavailable soc_max read#4024

Merged
springfall2008 merged 1 commit into
springfall2008:mainfrom
tieskuh:fix/soc_max-transient-unavailable-pin
Jun 8, 2026
Merged

Recover battery size after a transient unavailable soc_max read#4024
springfall2008 merged 1 commit into
springfall2008:mainfrom
tieskuh:fix/soc_max-transient-unavailable-pin

Conversation

@tieskuh

@tieskuh tieskuh commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

A momentary unavailable reading of a configured soc_max sensor caused Predbat to fall back to the 8 kWh default and cache it, pinning the battery size to 8 kWh for the rest of the day (until restart) even after the sensor recovered. With the battery then appearing "full", grid charging was disabled.

Two fixes in Inverter.battery_size_tracking():

  • Recompute soc_max from the recovered soc_max_nominal when the live read was invalid, so the last known-good capacity takes effect instead of the fallback.
  • Make the 8 kWh fallback non-sticky by no longer writing soc_max / soc_max_nominal into base.args, so the next cycle re-reads the real source (or restores the nominal) and recovers automatically.

Genuine first-run behaviour is preserved: with no config and no history the fallback still applies 8 kWh per cycle and self-heals once a value is available.

Adds regression tests covering the transient-outage recovery and the non-sticky fallback.

A momentary `unavailable` reading of a configured `soc_max` sensor caused
Predbat to fall back to the 8 kWh default and cache it, pinning the battery
size to 8 kWh for the rest of the day (until restart) even after the sensor
recovered. With the battery then appearing "full", grid charging was disabled.

Two fixes in Inverter.battery_size_tracking():
- Recompute soc_max from the recovered soc_max_nominal when the live read was
  invalid, so the last known-good capacity takes effect instead of the fallback.
- Make the 8 kWh fallback non-sticky by no longer writing soc_max /
  soc_max_nominal into base.args, so the next cycle re-reads the real source
  (or restores the nominal) and recovers automatically.

Genuine first-run behaviour is preserved: with no config and no history the
fallback still applies 8 kWh per cycle and self-heals once a value is available.

Adds regression tests covering the transient-outage recovery and the
non-sticky fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@springfall2008

Copy link
Copy Markdown
Owner

@copilot Review these changes, I wonder how they will interact with a system where soc_max is not set at all and the battery size has to be calculated? We don't want to lose the setting and have it re-computed on every single run.

@springfall2008

Copy link
Copy Markdown
Owner

PR #4024 Review: soc_max not configured / auto-calculation case

The concern: will find_battery_size re-run every cycle?

Short answer: No — and the PR doesn't break this. Here's why:

The re-computation guard at inverter.py:589 is:

if today_key not in existing_history:
    found_size = self.find_battery_size(...)

where existing_history comes from get_state_wrapper(soc_max_sensor_name, attribute="history") — the HA sensor state, completely separate from base.args. The PR only changes what gets written to base.args. This guard is untouched.

On the first cycle where find_battery_size returns None, update_soc_max_calculated_sensor(None, 0) stores today_key: None in the sensor history. Every subsequent cycle that same day, today_key is in existing_historyfind_battery_size is NOT called. This holds whether or not 8.0 was cached in base.args.


The new early-exit block

if (not self.soc_max or self.soc_max <= 0) and self.nominal_capacity and self.nominal_capacity > 0:
    self.soc_max = dp3(self.nominal_capacity * self.battery_scaling)

This correctly does not interfere with the auto-calculation path. In the "no soc_max configured, no measurement yet" case, nominal_capacity is 0 (nothing persisted in soc_max_nominal either), so the condition is False and the block is skipped entirely.


battery_scaling_auto is reset each cycle

predbat.py:620 resets self.battery_scaling_auto = False at the start of every cycle. So there's no sticky state issue between cycles — it's re-evaluated from scratch each time.


One genuine difference in behaviour

With the PR, in the "no soc_max configured, no measurement yet" case:

  | Old code | PR -- | -- | -- Cycle 1 | fallback: log + set soc_max=8.0 + persist to args | fallback: log + set soc_max=8.0 + no persist Cycle 2 | get_arg("soc_max") → 8.0 already, no fallback triggered | get_arg("soc_max") → 0.0, fallback runs again

So with the PR, the "Warn: Unable to determine battery size... using 8 kWh default for this cycle" log fires every 5 minutes until data is available, rather than just once. This is intentional (the log message was changed to say "for this cycle") and is what enables self-healing. It's cheap — just two assignments and a log call; find_battery_size is still guarded and not re-run.


Verdict

The PR is correct for the soc_max-not-configured / auto-calculation case. No re-computation risk. The only observable difference is the warn log becomes a per-cycle message rather than a one-shot, which is an intentional and honest reflection of the new non-sticky behaviour.

PR #4024 Review: soc_max not configured / auto-calculation case The concern: will find_battery_size re-run every cycle? Short answer: No — and the PR doesn't break this. Here's why:

The re-computation guard at inverter.py:589 is:

if today_key not in existing_history:
found_size = self.find_battery_size(...)
where existing_history comes from get_state_wrapper(soc_max_sensor_name, attribute="history") — the HA sensor state, completely separate from base.args. The PR only changes what gets written to base.args. This guard is untouched.

On the first cycle where find_battery_size returns None, update_soc_max_calculated_sensor(None, 0) stores today_key: None in the sensor history. Every subsequent cycle that same day, today_key is in existing_history → find_battery_size is NOT called. This holds whether or not 8.0 was cached in base.args.

The new early-exit block

if (not self.soc_max or self.soc_max <= 0) and self.nominal_capacity and self.nominal_capacity > 0:
self.soc_max = dp3(self.nominal_capacity * self.battery_scaling)
This correctly does not interfere with the auto-calculation path. In the "no soc_max configured, no measurement yet" case, nominal_capacity is 0 (nothing persisted in soc_max_nominal either), so the condition is False and the block is skipped entirely.

battery_scaling_auto is reset each cycle
predbat.py:620 resets self.battery_scaling_auto = False at the start of every cycle. So there's no sticky state issue between cycles — it's re-evaluated from scratch each time.

One genuine difference in behaviour
With the PR, in the "no soc_max configured, no measurement yet" case:

Old code PR
Cycle 1 fallback: log + set soc_max=8.0 + persist to args fallback: log + set soc_max=8.0 + no persist
Cycle 2 get_arg("soc_max") → 8.0 already, no fallback triggered get_arg("soc_max") → 0.0, fallback runs again
So with the PR, the "Warn: Unable to determine battery size... using 8 kWh default for this cycle" log fires every 5 minutes until data is available, rather than just once. This is intentional (the log message was changed to say "for this cycle") and is what enables self-healing. It's cheap — just two assignments and a log call; find_battery_size is still guarded and not re-run.

Verdict
The PR is correct for the soc_max-not-configured / auto-calculation case. No re-computation risk. The only observable difference is the warn log becomes a per-cycle message rather than a one-shot, which is an intentional and honest reflection of the new non-sticky behaviour.

@springfall2008 springfall2008 merged commit 0780408 into springfall2008:main Jun 8, 2026
1 check passed
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.

2 participants