Skip to content

feat(car): model opportunistic solar charging, and read the charger from EVCC API - #4598

Open
mbuhansen wants to merge 7 commits into
springfall2008:mainfrom
mbuhansen:upstream-pr/car-solar-evcc
Open

feat(car): model opportunistic solar charging, and read the charger from EVCC API#4598
mbuhansen wants to merge 7 commits into
springfall2008:mainfrom
mbuhansen:upstream-pr/car-solar-evcc

Conversation

@mbuhansen

@mbuhansen mbuhansen commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

evcc component
evcc (https://evcc.io/) is an open source EV charging controller that sits between the house and
the charger. What makes it worth integrating with is its reach: it drives surplus charging on
nearly any charger, including the many that cannot follow the sun by themselves, it holds
repeating departure plans per vehicle rather than per charge point, and it reads the car's state
of charge from the manufacturer's online API for most brands - or from a sensor wired up by hand
in Home Assistant where no API exists.

For Predbat that is one integration instead of one per charger brand, and it supplies the two
things Predbat cannot work out on its own: what the charger is about to do with the surplus, and
when the car has to be ready. Predbat still owns the money side - which hours to buy in, and
whether the surplus is worth more in the car than exported - and hands evcc a mode to carry out.

Opportunistic solar charging
Adopted from tieskuh's feature/car-charging-solar-diversion branch. car_charging_solar and its
power band model the PV an external charger diverts to the car itself - a Zappi in ECO+, a
Wallbox in Eco-Smart, evcc, or a Home Assistant automation - so the home battery forecast is not
over-optimistic on exactly the sunny days when the most solar goes to the car. It is modelling
only: Predbat never commands the car. It generalises the iBoost diversion logic to the car
loadpoint without the iBoost power cap, so it works at the full rate of a 3-phase charger, and
adds the kernel mirror that branch was missing - without it the C engine, enabled by default,
ignored the feature entirely.

The charger's own behaviour is modelled rather than assumed: car_charging_solar_min_power is the
surplus below which it will not start, and car_charging_solar_power_step the whole current steps
it switches in, which leave a remainder to the battery. The car fills to car_charging_solar_limit,
which is deliberately independent of the car_charging_limit departure target, so solar can top up
beyond what the grid plan guarantees.

Choosing between solar and export
Diverting PV is not free: its opportunity cost is the export income given up. Per kWh into the car
both routes divide by car_charging_loss, so the comparison reduces to the export rate now against
the cheapest import before the ready time. When exporting pays better the surplus is sold and the
car charged from the planned cheap slot instead. Which way that falls changes with the season, so
switch.predbat_car_charging_solar_export_smart recomputes it every cycle rather than asking the
user for a threshold that would need re-tuning whenever rates move. Only applied when there is a
departure plan to fall back on, so it can never leave the car short.

The decision is published as sensor.predbat_car_charging_mode - charge now, follow the sun, or do
not charge - with a reason attribute. Solar is the resting state rather than off, because off means
"do not charge from the surplus" and for a charger holding its own departure plan that takes the
plan down with it; off is published only when it is a decision, not an absence.

On the plan, a slot is green wherever the charger is free to divert, not only where energy is
expected. A green slot showing zero says "allowed, but the surplus is too small to start the
charger", which is a different thing from no sun at all and the two looked identical before.

apps/predbat/evcc.py reads an evcc instance over its REST API and wires it into the car model, so
the charger and car need not be described by hand. evcc_host is enough to start it read-only,
evcc_automatic points the car_charging_* keys at the entities it publishes, and evcc_control lets
Predbat map its own solar/grid decision onto evcc's now/pv/off.

Because the plans live on the vehicle rather than the loadpoint, a departure time and target SoC
can be read while the car is away, so the plan is ready the moment it is plugged in rather than a
cycle later. The charger's power band is derived from evcc's current and phase configuration
instead of asking the user to work it out, and the home battery priority SoC is taken from evcc's
prioritySoc so the same number cannot drift apart in two places.

Auto-configuration is additive, never authoritative: any car_charging_* key already in apps.yaml
is left alone and reported in the status sensor's overridden attribute. That matters most for
car_charging_soc, where a Home Assistant car integration reporting a live SoC while the car is
away beats evcc, which only knows the SoC it last saw while connected.

Car energy already in the load history
Where the car is inside the CT clamp (car_energy_reported_load) and its energy has not been stripped back
out (car_charging_hold off), the historical load already carries the car demand, and any ML load model
trained on it does too. The prediction added the planned car slots on top of that, counting the same kWh
twice and over-stating the house import for those hours. The condition follows from the two existing
switches rather than adding one of its own, so nothing new has to be configured, and it is logged when it
changes.

The plugged-in sensor is matched on the standard on/true states rather than only on car_charging_now_response, which is written for a charger's own status text: pointing that key at a plain binary sensor otherwise fails silently, leaving the diversion permanently off with no warning.

Verified with the C++ kernel built and active, including the kernel parity tests.

Skærmbillede 2026-08-19 185215

@springfall2008

Copy link
Copy Markdown
Owner

Looks really good, do you want me to resolve the conflict and clean up in a new branch or do you want to do so?

…rom evcc

Two related gaps in how Predbat sees an EV, both opt-in and off by default.

Opportunistic solar charging
  Adopted from tieskuh's feature/car-charging-solar-diversion branch. car_charging_solar and its
  power band model the PV an external charger diverts to the car itself - a Zappi in ECO+, a
  Wallbox in Eco-Smart, evcc, or a Home Assistant automation - so the home battery forecast is not
  over-optimistic on exactly the sunny days when the most solar goes to the car. It is modelling
  only: Predbat never commands the car. It generalises the iBoost diversion logic to the car
  loadpoint without the iBoost power cap, so it works at the full rate of a 3-phase charger, and
  adds the kernel mirror that branch was missing - without it the C engine, enabled by default,
  ignored the feature entirely.

  The charger's own behaviour is modelled rather than assumed: car_charging_solar_min_power is the
  surplus below which it will not start, and car_charging_solar_power_step the whole current steps
  it switches in, which leave a remainder to the battery. The car fills to car_charging_solar_limit,
  which is deliberately independent of the car_charging_limit departure target, so solar can top up
  beyond what the grid plan guarantees.

Choosing between solar and export
  Diverting PV is not free: its opportunity cost is the export income given up. Per kWh into the car
  both routes divide by car_charging_loss, so the comparison reduces to the export rate now against
  the cheapest import before the ready time. When exporting pays better the surplus is sold and the
  car charged from the planned cheap slot instead. Which way that falls changes with the season, so
  switch.predbat_car_charging_solar_export_smart recomputes it every cycle rather than asking the
  user for a threshold that would need re-tuning whenever rates move. Only applied when there is a
  departure plan to fall back on, so it can never leave the car short.

  The decision is published as sensor.predbat_car_charging_mode - charge now, follow the sun, or do
  not charge - with a reason attribute. Solar is the resting state rather than off, because off means
  "do not charge from the surplus" and for a charger holding its own departure plan that takes the
  plan down with it; off is published only when it is a decision, not an absence.

  On the plan, a slot is green wherever the charger is free to divert, not only where energy is
  expected. A green slot showing zero says "allowed, but the surplus is too small to start the
  charger", which is a different thing from no sun at all and the two looked identical before.

evcc component
  evcc (https://evcc.io/) is an open source EV charging controller that sits between the house and
  the charger. What makes it worth integrating with is its reach: it drives surplus charging on
  nearly any charger, including the many that cannot follow the sun by themselves, it holds
  repeating departure plans per vehicle rather than per charge point, and it reads the car's state
  of charge from the manufacturer's online API for most brands - or from a sensor wired up by hand
  in Home Assistant where no API exists.

  For Predbat that is one integration instead of one per charger brand, and it supplies the two
  things Predbat cannot work out on its own: what the charger is about to do with the surplus, and
  when the car has to be ready. Predbat still owns the money side - which hours to buy in, and
  whether the surplus is worth more in the car than exported - and hands evcc a mode to carry out.

  apps/predbat/evcc.py reads an evcc instance over its REST API and wires it into the car model, so
  the charger and car need not be described by hand. evcc_host is enough to start it read-only,
  evcc_automatic points the car_charging_* keys at the entities it publishes, and evcc_control lets
  Predbat map its own solar/grid decision onto evcc's now/pv/off.

  Because the plans live on the vehicle rather than the loadpoint, a departure time and target SoC
  can be read while the car is away, so the plan is ready the moment it is plugged in rather than a
  cycle later. The charger's power band is derived from evcc's current and phase configuration
  instead of asking the user to work it out, and the home battery priority SoC is taken from evcc's
  prioritySoc so the same number cannot drift apart in two places.

  Auto-configuration is additive, never authoritative: any car_charging_* key already in apps.yaml
  is left alone and reported in the status sensor's overridden attribute. That matters most for
  car_charging_soc, where a Home Assistant car integration reporting a live SoC while the car is
  away beats evcc, which only knows the SoC it last saw while connected.

Car energy already in the load history
  Where the car is inside the CT clamp (car_energy_reported_load) and its energy has not been
  stripped back out (car_charging_hold off), the historical load already carries the car demand,
  and any ML load model trained on it does too. The prediction added the planned car slots on top
  of that, counting the same kWh twice and over-stating the house import for those hours. The
  condition follows from the two existing switches rather than adding one of its own, so nothing
  new has to be configured, and it is logged when it changes.

The plugged-in sensor is matched on the standard on/true states rather than only on
car_charging_now_response, which is written for a charger's own status text: pointing that key at a
plain binary sensor otherwise fails silently, leaving the diversion permanently off with no warning.

Verified with the C++ kernel built and active, including the kernel parity tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mbuhansen
mbuhansen force-pushed the upstream-pr/car-solar-evcc branch from 4aaf727 to 1f47ea6 Compare August 20, 2026 18:25
@mbuhansen

Copy link
Copy Markdown
Contributor Author

Thanks! I've done it — rebased onto current main and force-pushed, so this is mergeable again.

Conflicts. Four, all in import blocks apart from the kernel version defines. That one needed a decision: main is on PK_PARITY_REVISION 7, this branch was on ABI 5 / parity 6. Resolved as ABI 5 (PkContext carries the car solar diversion fields) and parity 8, since both sides changed the hot loop.

The substantive overlap is #4207 — the new elif set_export_freeze recapture branch sits between branches this PR touches. The solar diversion subtracts from pv_now earlier in the loop, before any of the battery branches, so the ordering still holds. Confirmed by the parity tests rather than by reading.

Kernel binaries. Rebuilt for all six platforms with zig 0.16.0 and included in the commit. Worth flagging: kernel-binaries is gated on head.repo.full_name == github.repository, so it's SKIPPED on fork PRs and would not have fixed them up here — the stale ones would have been rejected at load time and silently fallen back to the Python engine.

Cleanup.

  • car_charging_in_load_history is a real behaviour change for existing users — where the car is inside the CT clamp and car_charging_hold is off, the historical load already carries the car demand and the planned slots were added on top, double counting. It was buried in the diff and mentioned in neither the commit message nor the PR body; it now has its own section in both.
  • Two comments sat inside the branch they described the opposite of (prediction.py and the .cpp mirror).
  • Merged a duplicate from const import in predbat.py.
  • car_plan_boundscar_ready_minutes (it returns one value, not a range) and parse_car_available_fromparse_car_ready_time (only ever used for the ready time).

Verification on the rebased tree: full unit suite with PREDBAT_KERNEL_REQUIRED=1 all passed, and verify_kernel_binary.py passes parity on the checked-in x86_64 binary over the 450-configuration sweep. pre-commit clean. interrogate and flake8 report nothing new — the pre-existing failures are all on main too, and docstring coverage in output.py, plan.py and fetch.py is marginally up.

Happy to split the load-history fix into its own commit if you'd rather have it separate from the feature — it would mean an intermediate commit whose kernel binaries don't match its source, which is why I left it as one.

Comment thread apps/predbat/config/apps.yaml Outdated
# Modelling only: Predbat never commands the car, it only reflects the energy the charger diverts.
# Works with any such charger - the evcc component below just fills these in for you.
# See https://springfall2008.github.io/batpred/car-charging/#opportunistic-solar-sun-following-charging
#car_charging_solar:

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.

Aren't these in config.py and hence a HA setting rather than an apps.yaml setting?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — car_charging_solar is moved.

It's now a Home Assistant switch per car in config.py, following the car_charging_rate template (car_charging_solar plus _1.._7, each gated on num_cars). It's a behaviour toggle, so it belongs next to car_charging_from_battery rather than needing a YAML edit and a restart to turn off.

The evcc side needed a bit more than a move: automatic_config() was setting the key with auto_set(), which writes an apps.yaml arg — a list — and a list would become the default of a switch. It now writes the config item per car on the same terms publish_priority_soc already uses: only when evcc's own answer changes, so a switch you turn off in Home Assistant isn't overruled on the next poll.

mbuhansen added a commit to mbuhansen/batpred that referenced this pull request Aug 22, 2026
Review feedback on springfall2008#4598: it is a behaviour toggle, so it belongs in config.py as a Home Assistant
control rather than an apps.yaml key - its nearest sibling car_charging_from_battery already is one,
and turning the model off should not need a YAML edit and a restart.

Follows the car_charging_rate template exactly: car_charging_solar for car 0 plus _1.._7, each gated on
num_cars, read with car_postfix instead of index=car_n.

The evcc side needed more than a move. automatic_config() wrote the key with auto_set(), which sets an
apps.yaml arg - a list - and a list becomes the default of a switch. publish_solar_enabled() writes the
config item per car instead, on the same terms publish_priority_soc already uses: only when evcc's own
answer changes, so a switch turned off in Home Assistant is not overruled on the next poll and evcc
wins again when a loadpoint appears or goes away. Without that the switch would be decorative.

An apps.yaml key left behind from before this change would otherwise be silently ignored, the switch
would default to off and the diversion would just stop, so a list value there now warns and records
status. Only branch testers can have one - the feature is unreleased.

The other four car_charging_solar_* keys stay in apps.yaml deliberately. max_power, min_power and
power_step describe the charger's hardware and are filled in from evcc rather than tuned, and
car_charging_solar_limit's own sibling car_charging_limit lives in apps.yaml - moving one without the
other is the inconsistency, not the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mbuhansen

Copy link
Copy Markdown
Contributor Author

Three commits since your review.

car_charging_solar is now a Home Assistant switch — replied inline. It follows the car_charging_rate template, and the evcc side writes the config item per car rather than an apps.yaml list.

off does not delete a charger's departure plan. The rationale for solar being the resting state claimed, in six places, that off "takes the charger's own departure plan down with it". It doesn't: evcc keeps the plan, still reports it to Predbat, and it stays editable — off only stops evcc acting on it itself. Verified on a live setup. That claim had justified a guard on the new reason below, which is now removed. Solar stays the resting state for the reason that does hold: if Predbat isn't publishing at all, a loadpoint left in pv still charges from the sun where one left off sits idle.

car_charging_solar_min_soc only ever reached the forecast. publish_car_solar_slot decided on the export rate and plugged-in state alone, so Predbat would tell a charger to follow the sun with the home battery below the priority the plan had already assumed would stop it. Now published as a home_battery_low reason — a gap in this PR's own feature rather than a new one.

The car's charging windows are priced against the forecast rather than the import rate. This one is new scope rather than a fix to what's already here. It came out of a real case: with car_charging_from_battery on, the plan booked the car's morning import at 156.73 øre while exporting the battery at 120.71 øre the night before — plan_car_charging only ever compared import rates, so the battery was never a source it could pick, just something that absorbed the load afterwards. What a window actually costs depends on which export it displaces, whether the battery is already at its discharge limit there, the losses and the cycle cost, which no rate comparison holds at once. So each candidate window is now scored by running the forecast with the car's load in it and taking the metric delta, reusing the what-if machinery already there for the marginal cost matrix. Only active when car_charging_from_battery is on.

Full unit suite green (244/244), pre-commit clean.

@mbuhansen

Copy link
Copy Markdown
Contributor Author

Correction to my last comment, on the motivating example rather than the code.

The case I cited had a second and larger cause that I hadn't found at the time: car_charging_hold was off on that system. With car_energy_reported_load on and hold off, car_charging_in_load_history is true, and the planned car slots then add no load to the forecast at all —

if not self.car_charging_in_load_history:
    load_yesterday += car_amount_premium

— so the plan never saw the car's 7.4kWh morning charge as demand at all. It exported the battery overnight because, as far as the forecast was concerned, nothing was going to draw it. That is a misconfiguration rather than a code fault, and turning hold on addresses most of that symptom on its own.

The argument for scoring the windows still stands on its own terms: with car_charging_from_battery on, plan_car_charging orders candidate windows by an import rate that isn't what the energy costs, and the battery is never a source it can pick. But I should be straight that placement only matters materially in narrower cases than I implied — where the battery is throughput-limited in the window, where a slot collides with a forced export, or where the battery can only cover part of the charge and placement decides which part is bought.

I don't currently have a clean measurement showing it changes the outcome once hold is set correctly. If you'd rather this PR stayed to its own scope, I'm happy to pull that commit out into a follow-up and hold it until I do.

One thing the investigation did expose: nothing warns when car_charging_from_battery is on while the car's demand is invisible to the forecast. Small, separate, and I can raise it on its own if useful.

mbuhansen and others added 4 commits August 23, 2026 11:10
…and correct the off premise

Two things live testing turned up in this PR's own feature.

car_charging_solar_min_soc only ever reached the forecast. Prediction.run_prediction will not divert to
the car below it, but publish_car_solar_slot decided on the export rate and plugged-in state alone, so
Predbat would tell a charger to follow the sun with the home battery below the priority the plan had
already assumed would stop it - charging out of a battery the house still needs, from energy the plan
had spent elsewhere. Published as a new home_battery_low reason. Unlike export_better it needs no
departure plan behind it: below the priority level the surplus belongs in the home battery, which is
what the setting means, and the diversion resumes on its own once the battery climbs back above it.
With evcc this doubles up with evcc's own prioritySoc; for any other charger, or an automation reading
the sensor, Predbat's decision is the only thing enforcing it.

The second is a correction rather than a fix. The rationale for solar being the resting state claimed,
in several places, that off "takes the charger's own departure plan down with it". It does not: evcc
keeps the plan, still reports it to Predbat, and it stays editable - off only stops evcc acting on it
itself, which Predbat does through now anyway. Verified against a live setup. Solar stays the resting
state for the reason that does hold: if Predbat is not publishing at all, a loadpoint left in pv still
charges from the sun where one left off would sit idle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review feedback: it is a behaviour toggle, so it belongs in config.py as a Home Assistant control
rather than an apps.yaml key - its nearest sibling car_charging_from_battery already is one, and
turning the model off should not need a YAML edit and a restart.

Follows the car_charging_rate template exactly: car_charging_solar for car 0 plus _1.._7, each gated on
num_cars, read with car_postfix instead of index=car_n.

The evcc side needed more than a move. automatic_config() wrote the key with auto_set(), which sets an
apps.yaml arg - a list - and a list becomes the default of a switch. publish_solar_enabled() writes the
config item per car instead, on the same terms publish_priority_soc already uses: only when evcc's own
answer changes, so a switch turned off in Home Assistant is not overruled on the next poll and evcc
wins again when a loadpoint appears or goes away. Without that the switch would be decorative.

An apps.yaml key left behind from before this change would otherwise be silently ignored, the switch
would default to off and the diversion would just stop, so a list value there now warns and records
status. Only branch testers can have one - the feature is unreleased.

The other four car_charging_solar_* keys stay in apps.yaml deliberately. max_power, min_power and
power_step describe the charger's hardware and are filled in from evcc rather than tuned, and
car_charging_solar_limit's own sibling car_charging_limit lives in apps.yaml - moving one without the
other is the inconsistency, not the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
car_charging_in_load_history was introduced by this PR and does not exist upstream. It suppressed the
planned car slot's load whenever the car is inside the CT clamp and car_charging_hold is off:

    car_charging_in_load_history = car_energy_reported_load and not car_charging_hold
    ...
    if not self.car_charging_in_load_history:
        load_yesterday += car_amount_premium

The stated reason was double counting, and it is half right - with hold off the history does carry past
car charging. But it carries it smeared across the times the car happened to charge before, not at the
slot the plan has actually booked. Suppressing the slot went from over-counting a little to not counting
at all, so the plan stopped seeing an upcoming charge as demand and would sell the battery out from
under it: on a live system, exported to 16% overnight at 120.71 øre and then bought 3.64kWh back at
~142 øre for the morning charge it never knew was coming.

Reverted to what upstream does, which is what this PR should not have changed in the first place. The
flag is gone from PkContext too, so PK_ABI_VERSION goes 5 -> 6 and PK_PARITY_REVISION 8 -> 9, and the
kernel binaries are rebuilt for all six platforms - kernel-binaries is skipped on fork PRs, so a stale
one would be rejected at load time and silently fall back to the Python engine.

Guarded by a new test that pins both engines: the kernel and the Python loop each have their own copy
of this, and a version that only exercised whichever happens to be enabled passed with the bug
reintroduced. test_car_charging_in_load_history.py loses the tests for the removed flag and is renamed
to test_car_modes.py for what is left in it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eed it

The planned grid top-up was capped only by the slot's rate and the room left to car_charging_limit, with
nothing accounting for what the solar diversion had already put into the car in the same step. The
comment claimed the top-up covers "the remainder", but that was only ever enforced on the energy limit,
never on the power within the step. So the model could deliver the full diversion and the full grid slot
at once: 3.68kW of sun plus a 3.5kW slot through a 3.68kW charger.

Seen on the plan as 2.93kWh in a 30 minute slot - 5.86kW - where the normal slots showed 1.75.

The sun diverted per car is now recorded for the step and the top-up gets only the headroom left under
car_charging_solar_max_power, which is the same physical device the grid rate describes and defaults to
it. Mirrored in the kernel, so PK_PARITY_REVISION goes 9 -> 10 and the binaries are rebuilt for all six
platforms.

test_model's car_solar_first_order encoded the old behaviour: 0.6kW of sun plus a 0.3kW top-up through a
charger set to 0.7kW. Its point is the solar-before-grid ordering, so the charger is raised to 1.0kW and
it still measures that rather than tripping over the cap.

The new test can only measure the Python engine - reading the car's SoC needs debug_enable and
kernel_supported refuses the kernel whenever that is set - so the kernel side was checked by breaking
the cap in the .cpp alone and confirming test_kernel_parity fails on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mbuhansen
mbuhansen force-pushed the upstream-pr/car-solar-evcc branch from ebb4e66 to 28807ea Compare August 23, 2026 10:02
@mbuhansen

Copy link
Copy Markdown
Contributor Author

I've narrowed this PR. It now covers what it says on the tin: reading the charger, car and departure plans from evcc, modelling the opportunistic solar diversion, and the smart solar-versus-export decision. Force-pushed.

The car charging window scoring is out. Not just deferred — it never actually ran. plan_car_charging is called from fetch, before calculate_plan rebuilds load_minutes_step, and the previous cycle's copy is freed at the end of every cycle unless debug_enable is on. So the guard that stops it scoring against an empty load profile rejected it on every cycle, and a full day of live logs shows effective identical to average on every slot. My unit tests set the array directly, which is why they never caught it.

It will come back as its own PR: with car_charging_from_battery on, the home battery serves the car, so a window's import rate is not what the charge costs there — the aim is to pick each window on whichever source is actually cheaper at that moment, the battery or the grid, rather than sorting on an import rate that does not apply. That needs a working call site and a measurement behind it before it deserves review.

Live testing then turned up two bugs in this PR's own feature, both now fixed:

car_charging_in_load_history was a regression against upstream. It suppressed the planned car slot's load whenever the car is inside the CT clamp and car_charging_hold is off. The double-counting reasoning is half right — the history does carry past car charging — but it carries it smeared across the times the car charged before, not at the slot the plan has booked. Suppressing the slot went from over-counting a little to not counting at all, so the plan stopped seeing an upcoming charge as demand: on a live system it exported to 16% overnight at 120.71 øre, then bought 3.64kWh back at ~142 øre for a morning charge it never knew was coming. Reverted to what upstream does. PK_ABI_VERSION 5 → 6.

Sun and grid together could exceed the charger's maximum. The top-up was capped only by the slot's rate and the room to car_charging_limit, with nothing accounting for the diversion in the same step — 3.68kW of sun plus a 3.5kW slot through a 3.68kW charger, seen on the plan as 2.93kWh in a 30 minute slot. PK_PARITY_REVISION 9 → 10.

Kernel binaries rebuilt for all six platforms. Full suite green (244/244), pre-commit clean. test_model's car_solar_first_order needed its charger raised from 0.7 to 1.0kW — it was encoding the old, impossible behaviour while testing the solar-before-grid ordering.

…oes, and not on an unread battery

Found in live testing: the loadpoint was switched off with home_battery_low while the home battery sat
at 20% against a 15% priority.

    12:26:54  Car 0 solar diversion allowed ...      <- publish_car_plan, from fetch_sensor_data
    12:26:55  Inverter 0: Type Kostal                <- fetch_inverter_data, after it
    12:27:17  EvccAPI: loadpoint 1 mode -> off (home_battery_low)

publish_car_plan runs from fetch_sensor_data, before fetch_inverter_data, so soc_kw is always the
previous cycle's value. Steady state that is harmless, but on the first cycle after a restart it is
still 0 while soc_max is already set, which reads as an empty battery and turns a well charged one off.
It recovers on the next cycle, but stops the car for a few minutes after every restart. The gate now
requires a battery reading to exist at all.

The second half is a decision rather than a bug. With evcc the priority level comes from evcc's own
prioritySoc, which evcc applies itself - the charger has already stopped diverting before Predbat says
anything, so publishing off here wins nothing and costs a needless loadpoint shutdown, exactly the one
above. Predbat now enforces the number only when it is what enforces it: set by hand for a plain
charger, or read by a Home Assistant automation. car_charging_solar_min_soc_external is flagged where
the value is taken, rather than inferred from the evcc component being loaded, since a second car on a
plain charger still needs Predbat to apply it.

Both are pinned by tests, and both were checked by reverting the gate and confirming they fail.

Co-Authored-By: Claude Opus 5 <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.

2 participants