feat(car): model opportunistic solar charging, and read the charger from EVCC API - #4598
feat(car): model opportunistic solar charging, and read the charger from EVCC API#4598mbuhansen wants to merge 7 commits into
Conversation
|
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>
4aaf727 to
1f47ea6
Compare
|
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 The substantive overlap is #4207 — the new Kernel binaries. Rebuilt for all six platforms with zig 0.16.0 and included in the commit. Worth flagging: Cleanup.
Verification on the rebased tree: full unit suite with 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. |
| # 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: |
There was a problem hiding this comment.
Aren't these in config.py and hence a HA setting rather than an apps.yaml setting?
There was a problem hiding this comment.
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.
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>
|
Three commits since your review.
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 Full unit suite green (244/244), pre-commit clean. |
|
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: 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 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 |
…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>
ebb4e66 to
28807ea
Compare
|
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. It will come back as its own PR: with Live testing then turned up two bugs in this PR's own feature, both now fixed:
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 Kernel binaries rebuilt for all six platforms. Full suite green (244/244), pre-commit clean. |
…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>
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.