Skip to content

fix(car): clamp manual car SoC and advance it by measured energy, not the plan - #5671

Closed
mgazza wants to merge 4 commits into
mainfrom
fix/car-manual-soc-clamp-measured
Closed

mgazza wants to merge 4 commits into
mainfrom
fix/car-manual-soc-clamp-measured

Conversation

@mgazza

@mgazza mgazza commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

The manual car SoC tracker (car_charging_manual_soc_kwh) had two problems:

  1. No clamp on read. fetch_sensor_data_cars() used the tracker as given, so a home with 100 kWh entered for a 10 kWh car planned around a car that was "10x full".
  2. It drifts without energy. update_car_manual_soc() writes back car_charging_soc_next, which is the plan's predicted SoC after its first step. The only clamp is to car_charging_limit. The tracker therefore advances by whatever the plan intended (around 0.6 kWh per cycle on a 7 kW slot) even while the charger delivers 0 W. Within a few hours the car reads as full and the plan stops charging it.

Fix

  • Clamp on read. The new car_manual_soc_kwh(car_n) keeps the tracker within [0, car_charging_battery_size] for that car. For an IOG car the battery size is replaced partway through the fetch by Octopus's vehicle_battery_size_in_kwh, so manual-SoC cars are clamped again after that update. Without the second clamp, a 60 kWh tracker on a 75 kWh IOG car would be cut to the configured size.
  • Measurement wins over the plan on write-back:
    • Session energy counter (car_charging_energy). This is a single total, so it is only used when num_cars == 1. The tracker advances by the energy delivered since the last good reading, multiplied by (1 - car_charging_loss), and is capped at the battery size.
      • Counter unreadable (e.g. unavailable): the tracker holds and the baseline is kept, so the energy delivered across the gap is counted once by the next good reading. It never falls back to the plan for that cycle, which would double count.
      • Long outage. After 60 minutes unreadable (CAR_MANUAL_SOC_COUNTER_HOLD_MINUTES), the baseline is dropped and the write-back falls back to the behaviour below until the counter returns. A missing or broken counter therefore cannot freeze the SoC for good. When the counter comes back it only sets a new baseline.
      • Counter drops (a new session, or an integration reload glitching to 0): the new value becomes the baseline and nothing is added.
        • For 15 minutes after the latest drop (CAR_MANUAL_SOC_GLITCH_MINUTES), PredBat remembers the highest value seen before the drops, even across several drops in a row. Rises that stay below it are held back, and the baseline stays at the low value.
        • A rise back to about that value is the glitch recovering: only the part above it counts. So 500 → 0 → 500, 500 → 250 → 0 → 500 and 500 → 0 → 250 → 500 all add nothing.
        • If the window passes without a recovery, the drop was a real reset (a new session). The rise from the low value is then counted in full, within the budget.
      • Implausible rise: clamped, not discarded. Each rise is limited by an energy budget. The budget starts at 0.1 kWh, refills at car_charging_rate × 1.1 between readings, holds at most 60 minutes' worth (CAR_MANUAL_SOC_MAX_GAP_MINUTES), and each counted rise spends from it. Clamping is logged at Info. Counters with coarse steps (1 kWh) or slow updates (cloud, every 10 minutes) are read every 5 minutes; they lag and then catch up, and are still counted in full. A car_charging_rate set below the real rate clamps the count rather than zeroing it.
    • car_charging_now reports not charging (checked first). The tracker holds and any energy counter is left unread, so its baseline is kept. Energy hidden by a lagging sensor is picked up by the next reading, within the budget. The sensor is read live, as a tri-state. Without a counter this case is the same: the sensor can be an on/off sensor or a power sensor read in watts, and the tracker does not advance. If the sensor reads unknown/unavailable, that is treated as no information, not as "not charging".
    • Otherwise (no usable counter, and no sensor, charging, or unknown): the behaviour is unchanged. The plan-based write-back applies, with the car_charging_limit cap (octopus_intelligent_consider_full has no effect on the prediction - the car is always modelled as filling #4967). Users without a measured source are unaffected.
  • User edits. If the tracker read this cycle differs from the value PredBat last wrote (clamped to the current battery size, so a battery that shrank does not look like an edit), the user has edited it. The energy measured since the last write-back is not added on top of the edit, and the counter keeps its new baseline.

The PredBat gateway's EV registration sets car_charging_energy (session energy), car_charging_now (charging binary sensor) and num_cars: 1, so gateway OCPP chargers move to the measured path.

Known limitations (all under-count, which is the safe direction)

  • Restarts. The counter baseline is held in memory only. After a restart, the first good reading only sets a new baseline. Energy delivered between the last write-back before the restart and the first reading after it is not counted. That window is the downtime plus up to one cycle, not just one cycle.
  • Counter resets at session start. These add nothing for the cycle in which the reset happens.
  • User edits. The energy measured in the cycle of an edit is dropped, including any delivered after the edit within that cycle. Detection compares against the last value written in this process, so it is inactive for the first write-back after a restart.

Tests

test_multi_car_iog.py:

  • car_manual_soc_measured covers:
    • the read clamp
    • the plan path with no source
    • car_charging_now off, on, and unavailable (falls back to the plan)
    • counter baseline, delta and flat counter
    • a counter dropout held for a cycle and then counted once (5.9 → 6.8, not 6.9)
    • a long outage falling back to the plan and then re-establishing the baseline
    • a 4.0 → 0 → 4.0 glitch adding nothing
    • one hour of 7.4 kW charging read in 5-minute (RUN_EVERY) cycles:
      • a 1 kWh-resolution counter counts 7.0 kWh
      • a 10-minute-update counter counts 7.4 kWh
      • a 500 → 0 → 500 glitch counts 0
      • car_charging_rate 3.7 kW against a real 7.4 kW counts 4.17 kWh (clamped, not 0)
      • glitches in steps (500 → 250 → 0 → 500 and 500 → 0 → 250 → 500) count 0
      • a real reset to a new session is counted in full (4.93 kWh)
      • with car_charging_now off, a 12 → 900 counter jump counts 0
      • with car_charging_now unavailable, the counter is still counted
    • a user edit not having energy added on top
    • the battery-size cap
  • car_manual_soc_fetch_clamp goes through the real fetch_sensor_data_cars(). It checks the clamp to the configured battery, and to a 75 kWh IOG vehicle_battery_size_in_kwh (60 → 60, 100 → 75).

Each fix was mutation-checked: reverting it fails its test. The reviewer's repro (5-minute cycles, 1 kWh and 10-minute counters) now counts 7.0 / 7.4 kWh. Mutations tried: no IOG re-clamp, no fetch clamp, no hold on an unavailable counter, no jump cap, and car_charging_now unavailable treated as off. --test multi_car_iog passes, --quick passes (TZ=UTC), and pre-commit is clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TngskXRktQAMFTCf1TdsYZ

The manual car SoC tracker was read with no clamp (100 kWh on a 10 kWh
car) and written back from the plan's predicted next SoC, so it advanced
every cycle even while the charger delivered nothing.

Clamp it to [0, battery size] on read. On write-back, when a single car
has a car_charging_energy session counter, advance by the energy delivered
since the last write-back (less car_charging_loss, a counter drop counts
as a new session); when a car_charging_now sensor says the car is not
charging, do not advance. With no measured source the plan-based
write-back is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TngskXRktQAMFTCf1TdsYZ
@github-actions github-actions Bot added the BOT_REVIEW Trigger an autotriage label Oct 20, 2026
mgazza and others added 2 commits October 21, 2026 01:01
…itches, clamp to IOG battery

- An unreadable car_charging_energy counter holds the SoC and keeps the baseline (for up to 60
  minutes), so the gap's energy is counted once - it no longer falls back to the plan for that
  cycle and double counts when the counter returns.
- A counter drop starts a new baseline and adds nothing; a rise larger than car_charging_rate
  could deliver since the last good reading is ignored and becomes the new baseline.
- car_charging_now unknown/unavailable is no information: falls back to the plan (no counter)
  instead of freezing the SoC.
- A tracker value that differs from the last write-back is a user edit: the energy measured
  before it is not added on top.
- fetch re-clamps manual-SoC cars after IOG replaces the battery size with Octopus's figure.
- Tests through fetch_sensor_data_cars() and for each rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TngskXRktQAMFTCf1TdsYZ
…ng them

The jump cap measured from the last read and discarded anything above it, so a 1 kWh-resolution
or 10-minute-update counter read every 5 minutes added nothing for an hour of charging. Rises
are now clamped to a budget that refills at car_charging_rate x 1.1 between readings (at most
60 minutes' worth) and is spent by each rise, so lagging counters are counted in full and a
low configured rate clamps rather than zeroes. A rise straight after a drop back to about the
pre-drop value is a glitch recovering (500 -> 0 -> 500) and only the excess counts. The user
edit check compares against the last write-back clamped to the current battery size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TngskXRktQAMFTCf1TdsYZ

@springfall2008 springfall2008 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Automated comment from the triage bot.

Automated review of PR #5671 at head commit 9e3f847: 3 findings posted inline (all in apps/predbat/predbat.py) and 1 in this body. The clamp-on-read, the counter baseline/reset arithmetic and the test's finally-restore of shared state all check out, and the new assertions would fail on main so the red/green story holds. The findings are about where the measured path engages and what happens when its sources stop working - in both cases a silent outcome.

docs/car-charging.md:879-884 - documented settings change behaviour with no docs update. The PR gives two documented settings a new job without touching docs/:

  • car_charging_energy (docs/car-charging.md:57-66): beyond the documented hold/load subtraction, it now advances the manual car SoC tracker whenever one car uses manual SoC - and (per the inline finding) it does so for any single readable entity, including non-car counters the docs bless for load subtraction. Nothing in the docs section says so.
  • the manual SoC tracker section (docs/car-charging.md:879-884): "Predbat will increment it during charging sessions but will not reset it automatically" stays true in outline, but the increment now follows the counter (or freezes while car_charging_now reports not charging), and per-session counters are newly tolerated - docs/car-charging.md:64-66 still insists the sensor must be daily incrementing resetting only at midnight, and users now also need to know how Predbat will fail over when the counter is unreadable (finding 3).

Per the project rule that user-facing behaviour changes get matching docs, a few lines in docs/car-charging.md (the manual-SoC section and the car_charging_energy section, with the heat-pump caveat and the unreadable-counter warning behaviour) would cover it.

Comment thread apps/predbat/predbat.py Outdated
if self.car_charging_soc_next[car_n] is not None:
measured_kwh = self.car_manual_soc_measured_energy(car_n)
charging_now_sensor = is_entity_id(self.get_arg("car_charging_now", None, indirect=False, index=car_n))
if measured_kwh is not None or (charging_now_sensor and not self.car_charging_now[car_n]):

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Automated comment from the triage bot.

The measured branch engages for any readable single-entity car_charging_energy on a one-car manual-SoC install - but car_charging_energy has a documented second use that has nothing to do with cars: "You can also use car_charging_energy to remove other house load kWh from the data Predbat uses for the forecast ... exclude multiple loads such as heat pump load" (docs/car-charging.md:73-78). A single-car manual-SoC user subtracting only a heat pump via one sensor now has the heat pump's kWh advance the manual car SoC tracker (soc_next = car_charging_soc + measured x loss, capped at the battery), so the plan models a car filling from heat-pump energy: car charging is planned less or not at all, and with car_charging_hold on the battery is held as if the car were charging. Before this PR car_charging_energy never touched the tracker, so exactly this configuration was unaffected.

I verified by reading car_manual_soc_measured_energy() (predbat.py:1162-1188): the only gates are num_cars == 1 and is_entity_id(), nothing checks what the entity measures, and the clamped tracker then feeds the plan via fetch_sensor_data_cars (fetch.py:1902). I also confirmed the docs bless the non-car use, and that car-charging.md:64-66 requires a daily incrementing counter resetting only at midnight - the PR's drop-restart logic additionally tolerates per-session counters (a genuine improvement), which widens the set of eligible sensors beyond what the docs describe.

Suggestion: gate the measured advance on an expert opt-in (e.g. a per-car switch, default off, turned on for the gateway's OCPP registration), and log one Warn: the first time it engages on a user-set counter, so a heat-pump user can see in the log why their car SoC starts moving.

Comment thread apps/predbat/predbat.py
if self.num_cars != 1:
return None
entity_id = self.get_arg("car_charging_energy", None, indirect=False)
if isinstance(entity_id, list) and len(entity_id) == 1:

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Automated comment from the triage bot.

Only a length-1 list unwraps to a sensor; a >=2-entity car_charging_energy list silently disables the measured path (is_entity_id() of a list is False, so this returns None and the plan-based branch advances the tracker by the plan again). The docs' own example is a 3-entity list (docs/car-charging.md:77-82), the list form is blessed for "multiple EV car chargers" (docs/car-charging.md:75, 97), and wallbox/myenergi/alphaess auto-config writes lists (myenergi.py:872, wallbox.py:550, alphaess.py:840). The concrete failure is a two-zappi house charging one car: both entities are car chargers, their summed delivered energy is this car's delivered energy, and here the tracker silently falls back to plan-based drift - the config where measured advance is most useful is the one it skips.

I verified resolve_arg()'s list handling (userinterface.py:164-184): combine=True sums resolved items, but it drops required_unit in its recursive call and sums current values, so a mid-charging session reset on one charger makes the combined sum dip and the drop-as-new-session rule would re-count the other charger's already-counted energy. So the fix is to resolve and delta the entities individually, not to just flip on combine=True. Worth handling, or at minimum a Warn: when the counter is a multi-entity list on a car using manual SoC.

Comment thread apps/predbat/predbat.py
soc_next = max(0.0, min(soc_next, self.car_charging_battery_size[car_n]))
self.log("Car {} charging Manual SoC advanced by measured energy {} kWh to {}".format(car_n, dp3(measured_kwh or 0.0), dp3(soc_next)))
self.expose_config("car_charging_manual_soc_kwh" + car_postfix, dp3(soc_next))
elif self.car_charging_soc_next[car_n] is not None:

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Automated comment from the triage bot.

Both quiet-degradation states this PR introduces are silent:

  1. car_charging_energy configured but not readable this cycle - the state is unavailable/unknown, the entity is deleted, or the unit is one unit_conversion() does not map - so float() raises and car_manual_soc_measured_energy() returns None (predbat.py:1178-1181): the plan-based branch runs and advances the tracker by the plan's prediction again, i.e. the drift the PR fixes comes back whenever the counter blinks, with no warning. Contrast load_car_energy()'s history-parse failure, which warns and counts a failed fetch (fetch.py:4155-4174).
  2. car_charging_now configured but its sensor is deleted/unavailable - is_entity_id() only tests the string shape, the arg still holds the entity id, so the no-advance branch keeps the tracker frozen indefinitely, with no warning.

The new test only feeds readable values, so none of the None/unavailable paths is exercised - worth one case for each, plus a Warn: (once per restart, per source) on the configured-source-failed path so users can tell a frozen tracker from a working one.

@springfall2008 springfall2008 removed the BOT_REVIEW Trigger an autotriage label Oct 21, 2026
… gates measured energy

- Consecutive drops keep the highest pre-drop value, and for 15 minutes after the latest drop
  rises below it are held back (baseline left at the low value) - 500 -> 250 -> 0 -> 500 and
  500 -> 0 -> 250 -> 500 add nothing. After the window a rise from the low value is a real new
  session and is counted in full.
- car_charging_now explicitly off holds the SoC without reading the counter, keeping its
  baseline; unknown/unavailable is still no information. It is read live as a tri-state rather
  than from the cached boolean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TngskXRktQAMFTCf1TdsYZ
springfall2008 pushed a commit that referenced this pull request Oct 21, 2026
…ap counted energy

Brings in what #5671 covered and #5678 did not:

- Clamp on read: the plan uses the manual SoC within zero and the battery size, and
  clamps again once Octopus Intelligent reports a (larger) vehicle battery size. The
  entity keeps what was typed.
- car_charging_now reading not charging holds the plan's predicted write-back for a
  manual car without an energy sensor; unavailable/unknown is no reading.
- Charger energy counted per cycle is capped at car_charging_rate x elapsed x 1.1
  (+0.1 kWh), so a counter that drops to zero and comes back - which the history
  counts as a new session - or jumps cannot fill the car.

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

Copy link
Copy Markdown
Owner

Closing as superseded by #5678, which now includes what this PR covered:

  • the manual SoC is clamped to 0…battery size on read, and again after Octopus Intelligent reports the vehicle's battery size
  • it advances by the charger energy measured rather than the plan's prediction, with each cycle's count capped at car_charging_rate × elapsed × 1.1 (+0.1 kWh), so a counter that drops to 0 and comes back, or jumps, can't fill the car
  • car_charging_now reading not charging holds the plan's write-back for a car without an energy sensor (unavailable/unknown is treated as no reading)
  • a value the user enters is kept as the new starting point, with nothing added on top

#5678 keeps its tracking state in sensor.predbat_car_manual_soc_tracked, so energy delivered across a restart is counted rather than lost. It also resets the SoC on unplug.

One deliberate difference: when the energy counter has been unreadable for an hour, #5678 holds the SoC rather than falling back to the plan, so it under-counts rather than drifts.

Thanks for this work, which shaped those parts of #5678.

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