Skip to content

Commit 1d253f6

Browse files
romain-intelclaude
andcommitted
fix(car): let away time release the battery hold so the car charges while it is here
The battery-priority hold assumes the car can catch up later, which stops being true the moment the afternoon is marked away. With the level at 100% the car never saw a solar window at all: every slot was either withheld for the battery in the morning or suppressed as away in the afternoon, so the plan fell through to buying at the day rate - reported from a live system as "plenty of solar but it doesn't use it". The hold now gives up exactly what the car can still take from the slots it will be present for. That figure is what it needs, capped by what those slots can actually deliver, so the battery keeps everything beyond it - it has the rest of the day, the car does not. Zero when no away time is set, so the everyday behaviour is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 155ba64 commit 1d253f6

3 files changed

Lines changed: 111 additions & 5 deletions

File tree

‎apps/predbat/fetch.py‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1330,7 +1330,6 @@ def fetch_sensor_data(self, save=True):
13301330
if self.import_today:
13311331
self.cost_today_sofar, self.carbon_today_sofar = self.today_cost(self.import_today, self.export_today, self.car_charging_energy, self.load_minutes, save=save)
13321332

1333-
13341333
if self.load_minutes and not self.load_forecast_only and not self.load_forecast_history:
13351334
# Apply modal filter to historical data. Skipped for days_previous_auto: the weighted-bucket
13361335
# forecast deliberately ignores missing-data buckets, so the gap-filling/padding the modal filter

‎apps/predbat/plan.py‎

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5475,6 +5475,44 @@ def car_solar_surplus_kwh(self, start, end, load_step, rate_kw=None):
54755475
total += surplus * overlap / step
54765476
return total
54775477

5478+
def car_solar_reserved_for_car(self, load_step, car_n=0):
5479+
"""
5480+
Surplus the car needs from the slots it will actually be present for, in kWh
5481+
5482+
The battery-priority hold assumes the car can catch up later, which is only true while the car
5483+
is going to be there. Mark the afternoon away and the morning becomes the car's last chance,
5484+
so banking it for a battery that has the whole day to fill is the wrong way round - and at a
5485+
100% priority level the car would never see a solar window at all.
5486+
5487+
Returns what the car still needs, capped by what the present slots can actually deliver, so
5488+
the hold gives up exactly that much and no more. Zero when no away time is set, which leaves
5489+
the everyday behaviour untouched.
5490+
5491+
Args:
5492+
- load_step: house load forecast, as used for the surplus calculation
5493+
- car_n: which car
5494+
5495+
Returns:
5496+
- float: kWh to keep back for the car, 0.0 when there is nothing to reserve
5497+
"""
5498+
if not self.manual_car_away_times or car_n >= self.num_cars:
5499+
return 0.0
5500+
needed = self.car_charging_limit[car_n] - self.car_charging_soc[car_n]
5501+
if needed <= 0:
5502+
return 0.0
5503+
available = 0.0
5504+
rate_limit = self.car_charging_rate[car_n] * self.plan_interval_minutes / 60.0
5505+
start_minute = int(self.minutes_now / self.plan_interval_minutes) * self.plan_interval_minutes
5506+
for minute in range(start_minute, self.minutes_now + self.forecast_minutes, self.plan_interval_minutes):
5507+
slot_start = max(minute, self.minutes_now)
5508+
slot_end = minute + self.plan_interval_minutes
5509+
if slot_end <= slot_start or self.car_slot_is_away(slot_start, slot_end):
5510+
continue
5511+
available += min(self.car_solar_surplus_kwh(slot_start, slot_end, load_step), rate_limit)
5512+
if available >= needed:
5513+
return needed
5514+
return min(needed, available)
5515+
54785516
def plan_car_charging_solar_windows(self, load_step=None):
54795517
"""
54805518
Find the slots where forecast solar is worth diverting to the car
@@ -5515,6 +5553,11 @@ def plan_car_charging_solar_windows(self, load_step=None):
55155553
# the pack as the held surplus fills it. Walking it forward means "full enough" is judged at the
55165554
# time of each slot rather than from the SoC right now, so a pack that gets there mid-morning
55175555
# releases the car mid-morning rather than holding all day.
5556+
# Surplus the car can only get from the slots it will be present for. The hold below gives up
5557+
# exactly this much, so away time moves the car's charge earlier instead of removing it: the
5558+
# battery still has the rest of the day, the car does not.
5559+
reserved_for_car = self.car_solar_reserved_for_car(load_step)
5560+
given_to_car = 0.0
55185561
battery_priority_kwh = self.soc_max * min(max(self.car_charging_solar_battery_soc, 0), 100) / 100.0
55195562
battery_estimate = self.soc_kw
55205563
start_minute = int(self.minutes_now / self.plan_interval_minutes) * self.plan_interval_minutes
@@ -5534,7 +5577,7 @@ def plan_car_charging_solar_windows(self, load_step=None):
55345577
# The battery gets the surplus until it is predicted to reach the configured level. What it
55355578
# cannot physically take in the slot is not held back - that would strand surplus that the
55365579
# car could have used and the grid will otherwise buy at the midday rate.
5537-
if battery_estimate < battery_priority_kwh:
5580+
if battery_estimate < battery_priority_kwh and given_to_car >= reserved_for_car:
55385581
to_battery = min(
55395582
surplus_kwh,
55405583
battery_priority_kwh - battery_estimate,
@@ -5553,6 +5596,8 @@ def plan_car_charging_solar_windows(self, load_step=None):
55535596
continue
55545597
# Price the slot at the export rate: solar sent to the car is not bought, it is export
55555598
# given up, so that is its real cost and what the plan should show
5599+
# Count what this window hands over, so the hold resumes once the car's reserved share is met
5600+
given_to_car += min(surplus_kwh, self.car_charging_rate[0] * (slot_end - slot_start) / 60.0) if self.num_cars else 0.0
55565601
windows.append({"start": minute, "end": slot_end, "average": export_rate, "solar": True, "power_kw": dp2(power_kw)})
55575602

55585603
if slot_count:

‎apps/predbat/tests/test_car_solar.py‎

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@
2020
makes that testable: it drives the load forecast directly instead of through the fixture's history.
2121
"""
2222

23-
from const import PREDICT_STEP
24-
from utils import dp2
23+
from const import PREDICT_STEP, EXPORT_MODE_TARGET
24+
from utils import dp2, export_mode_of
2525
from tests.test_infra import reset_inverter, reset_rates, update_rates_import, update_rates_export
2626
from prediction import Prediction
2727

@@ -461,6 +461,64 @@ def test_solar_battery_priority_level(my_predbat):
461461
return failed
462462

463463

464+
def test_away_moves_solar_earlier(my_predbat):
465+
"""Away time makes the battery-priority hold yield, so the car charges while it is still here.
466+
467+
The hold assumes the car can catch up later, which stops being true the moment the afternoon is
468+
marked away - and at a 100% priority level the car would otherwise never see a solar window at
469+
all. Reported from a live system: marking the afternoon away made the car charge from the grid
470+
at 30p instead of moving to the morning sun.
471+
"""
472+
print(" - test_away_moves_solar_earlier")
473+
failed = False
474+
setup_car(my_predbat, car_kwh=30.0, ready_ahead=1200, rate=7.0, house_kw=1.0)
475+
reset_rates(my_predbat, 30.0, 5.0)
476+
my_predbat.car_charging_solar = True
477+
my_predbat.soc_max = 27.0
478+
my_predbat.soc_kw = 0.0
479+
my_predbat.battery_rate_max_charge = 10.0 / 60.0
480+
my_predbat.battery_rate_max_scaling = 1.0
481+
my_predbat.car_charging_solar_battery_soc = 100
482+
set_pv(my_predbat, 8.0, start_offset=120, length=480)
483+
low_rates = [{"start": my_predbat.minutes_now + 30 * n, "end": my_predbat.minutes_now + 30 * (n + 1), "average": 30.0} for n in range(40)]
484+
update_rates_import(my_predbat, low_rates)
485+
486+
my_predbat.manual_car_away_times = []
487+
baseline = [slot for slot in my_predbat.plan_car_charging(0, low_rates) if slot.get("solar")]
488+
if not baseline:
489+
print("ERROR: expected some solar charging with no away markers")
490+
return True
491+
baseline_first = baseline[0]["start"] - my_predbat.minutes_now
492+
493+
# Mark the afternoon away - the car's remaining chances are all in the morning
494+
my_predbat.manual_car_away_times = [my_predbat.minutes_now + m for m in range(300, 600, 30)]
495+
plan = my_predbat.plan_car_charging(0, low_rates)
496+
solar = [slot for slot in plan if slot.get("solar")]
497+
498+
if not solar:
499+
print("ERROR: away time removed solar charging entirely instead of moving it earlier")
500+
failed = True
501+
else:
502+
moved_to = solar[0]["start"] - my_predbat.minutes_now
503+
if moved_to >= baseline_first:
504+
print("ERROR: solar should start earlier than {} once the afternoon is away, got {}".format(baseline_first, moved_to))
505+
failed = True
506+
507+
for slot in plan:
508+
if my_predbat.car_slot_is_away(slot["start"], slot["end"]):
509+
print("ERROR: planned a slot the car is away for: {}-{}".format(slot["start"], slot["end"]))
510+
failed = True
511+
512+
# With no away time set the hold is untouched, which is the everyday case
513+
my_predbat.manual_car_away_times = []
514+
if my_predbat.car_solar_reserved_for_car(my_predbat.car_solar_load_forecast()) != 0.0:
515+
print("ERROR: nothing should be reserved for the car when no away time is set")
516+
failed = True
517+
518+
my_predbat.car_charging_solar_battery_soc = 0
519+
return failed
520+
521+
464522
def test_solar_windows_ignore_ready_time(my_predbat):
465523
"""Solar windows run to the forecast horizon, so a morning ready time does not exclude daylight."""
466524
print(" - test_solar_windows_ignore_ready_time")
@@ -531,7 +589,9 @@ def run_car_export_tradeoff(my_predbat, export_rate, from_battery, car_kwh=8.0):
531589
my_predbat.optimise_all_windows(result[0], result[8])
532590

533591
final = my_predbat.run_prediction(my_predbat.charge_limit_best, my_predbat.charge_window_best, my_predbat.export_window_best, my_predbat.export_limits_best, False, end_record=end_record, save="best")
534-
exported = bool(my_predbat.export_limits_best) and my_predbat.export_limits_best[0] < 100
592+
# Export limits are a packed structure since #5047, so ask the accessor rather than comparing the
593+
# raw value - a tuple is not less than 100, and reading it as one silently reports "did not export"
594+
exported = bool(my_predbat.export_limits_best) and export_mode_of(my_predbat.export_limits_best[0]) == EXPORT_MODE_TARGET
535595
return exported, final[1] + final[2]
536596

537597

@@ -600,6 +660,7 @@ def run_car_solar_tests(my_predbat):
600660
"car_charging_now",
601661
"car_charging_solar",
602662
"car_charging_solar_excess",
663+
"manual_car_away_times",
603664
"car_charging_solar_battery_soc",
604665
"battery_rate_max_charge",
605666
"battery_rate_max_scaling",
@@ -622,6 +683,7 @@ def run_car_solar_tests(my_predbat):
622683
failed |= test_solar_slot_size_follows_surplus(my_predbat)
623684
failed |= test_solar_slot_capped_by_charger(my_predbat)
624685
failed |= test_solar_battery_priority_level(my_predbat)
686+
failed |= test_away_moves_solar_earlier(my_predbat)
625687
if failed:
626688
return failed
627689
failed |= test_car_export_tradeoff(my_predbat)

0 commit comments

Comments
 (0)