Skip to content

Commit 34dd554

Browse files
Merge pull request #4552 from springfall2008/fix/charge-curve-average-power
fix(inverter): average battery power across each SoC step in find_charge_curve
2 parents 7b2ea4f + c192f96 commit 34dd554

2 files changed

Lines changed: 125 additions & 4 deletions

File tree

‎apps/predbat/inverter.py‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1194,9 +1194,9 @@ def find_charge_curve(self, discharge):
11941194
# Find a period where charging was at full rate and the SoC just drops below the data point
11951195
for target_minute in range(minute, min_len):
11961196
this_soc = soc_percent.get(target_minute, 0)
1197-
if not discharge and (predbat_status.get(target_minute, "") != "Charging" or charge_rate.get(minute, 0) < max_power_scaled or battery_power.get(minute, 0) >= 0):
1197+
if not discharge and (predbat_status.get(target_minute, "") != "Charging" or charge_rate.get(target_minute, 0) < max_power_scaled or battery_power.get(target_minute, 0) >= 0):
11981198
break
1199-
if discharge and (not ((predbat_status.get(target_minute, "") in ["Exporting", "Discharging"])) or charge_rate.get(minute, 0) < max_power_scaled or battery_power.get(minute, 0) <= 0):
1199+
if discharge and (not ((predbat_status.get(target_minute, "") in ["Exporting", "Discharging"])) or charge_rate.get(target_minute, 0) < max_power_scaled or battery_power.get(target_minute, 0) <= 0):
12001200
break
12011201

12021202
if (discharge and (this_soc > data_point)) or (not discharge and (this_soc < data_point)):
@@ -1242,8 +1242,10 @@ def find_charge_curve(self, discharge):
12421242

12431243
break
12441244
else:
1245-
# Store data
1246-
total_power += abs(battery_power.get(minute, 0))
1245+
# Store data for this minute of the period, so total_power/total_count
1246+
# below is the mean power across the whole SoC step rather than the
1247+
# reading at the single minute the step was triggered on
1248+
total_power += abs(battery_power.get(target_minute, 0))
12471249
total_count += 1
12481250
if final_curve:
12491251
# Average the data points

‎apps/predbat/tests/test_find_charge_curve.py‎

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,121 @@ def test_find_charge_curve_inverted_battery_power(my_predbat):
374374
return failed
375375

376376

377+
def create_tapering_power_history_data(my_predbat, num_days=7):
378+
"""
379+
Create history where the power drawn within a 1% SoC step varies by SoC.
380+
381+
The existing fixtures hold battery power constant across a whole charging session, so the
382+
mean over a step equals the reading at any single minute of it and a curve built from one
383+
sample looks identical to one built from the average.
384+
385+
Here the low SoC steps pull a steady 2600W, while steps at 93% and above pull 2600W only
386+
at each end of the step and 650W through the middle - a mean of 1040W. Both ends carry the
387+
high reading deliberately, so a curve sampling a single minute of the step reads 2600W
388+
whichever end it triggers on and comes out flat. Only a curve that averages the step sees
389+
the taper.
390+
"""
391+
ha = my_predbat.ha_interface
392+
base_time = my_predbat.midnight_utc - timedelta(days=num_days)
393+
394+
history_dict = {"sensor.soc_percent": [], "number.charge_rate": [], "number.discharge_rate": [], "sensor.battery_power": [], "predbat.status": []}
395+
ha.dummy_items["sensor.soc_percent"] = 100
396+
ha.dummy_items["number.charge_rate"] = 2600
397+
ha.dummy_items["number.discharge_rate"] = 2600
398+
ha.dummy_items["sensor.battery_power"] = -2600
399+
ha.dummy_items["predbat.status"] = "Idle"
400+
401+
total_minutes = num_days * 24 * 60
402+
step_minutes = 10 # Minutes spent at each 1% of SoC
403+
404+
for minutes in range(0, total_minutes):
405+
timestamp = base_time + timedelta(minutes=minutes)
406+
timestamp_str = timestamp.strftime("%Y-%m-%dT%H:%M:%S%z")
407+
hour = timestamp.hour
408+
minute_of_hour = timestamp.minute
409+
410+
if 2 <= hour < 6:
411+
session_minute = (hour - 2) * 60 + minute_of_hour
412+
soc = min(98, 80 + session_minute // step_minutes)
413+
charge_rate = 2600
414+
discharge_rate = 0
415+
position = session_minute % step_minutes
416+
if soc >= 93 and position not in (0, step_minutes - 1):
417+
battery_power = -650
418+
else:
419+
battery_power = -2600
420+
status = "Charging"
421+
else:
422+
soc = 80 if hour < 2 else 98
423+
charge_rate = 0
424+
discharge_rate = 0
425+
battery_power = 0
426+
status = "Idle"
427+
428+
history_dict["sensor.soc_percent"].append({"state": str(soc), "last_updated": timestamp_str, "attributes": {"unit_of_measurement": "%"}})
429+
history_dict["number.charge_rate"].append({"state": int(charge_rate), "last_updated": timestamp_str, "attributes": {"unit_of_measurement": "W"}})
430+
history_dict["number.discharge_rate"].append({"state": int(discharge_rate), "last_updated": timestamp_str, "attributes": {"unit_of_measurement": "W"}})
431+
history_dict["sensor.battery_power"].append({"state": round(battery_power, 1), "last_updated": timestamp_str, "attributes": {"unit_of_measurement": "W"}})
432+
history_dict["predbat.status"].append({"state": status, "last_updated": timestamp_str, "attributes": {}})
433+
434+
def mock_get_history(entity_id, now=None, days=30):
435+
if entity_id in history_dict:
436+
return [history_dict[entity_id]]
437+
return None
438+
439+
my_predbat.ha_interface.get_history = mock_get_history
440+
441+
442+
def test_find_charge_curve_averages_power_across_period(my_predbat):
443+
"""
444+
The curve must reflect the average power over each SoC step, not one sample of it.
445+
446+
Steps below 93% draw a steady 2600W; steps at 93% and above average 1040W but read 2600W
447+
at either end. Averaging the step produces a curve that tapers to roughly 0.4 above 93%.
448+
Sampling one minute of the step reads 2600W everywhere and produces a flat curve.
449+
"""
450+
print("*** Running test: find_charge_curve_averages_power_across_period ***")
451+
failed = False
452+
453+
inv = Inverter(my_predbat, 0)
454+
inv.battery_rate_max_charge = 2600 / 60000
455+
inv.battery_rate_max_discharge = 2600 / 60000
456+
inv.soc_max = 10.0
457+
inv.battery_scaling = 1.0
458+
459+
setup_predbat(my_predbat)
460+
create_tapering_power_history_data(my_predbat, num_days=7)
461+
462+
charge_curve = inv.find_charge_curve(discharge=False)
463+
if not charge_curve:
464+
print("ERROR: No charge curve found from tapering power data")
465+
remove_test_history_data(my_predbat)
466+
return True
467+
print("Charge curve found: {}".format(charge_curve))
468+
469+
low = [value for soc, value in charge_curve.items() if 86 <= soc <= 91]
470+
high = [value for soc, value in charge_curve.items() if 94 <= soc <= 98]
471+
472+
if not low or not high:
473+
print("ERROR: Curve missing the SoC range needed to check the taper: {}".format(sorted(charge_curve)))
474+
return True
475+
476+
if min(low) < 0.9:
477+
print("ERROR: Steady 2600W steps below 93% should sit at full rate, got {}".format(low))
478+
failed = True
479+
480+
if max(high) > 0.6:
481+
print("ERROR: Curve is flat - built from a single sample of each step rather than its average")
482+
print(" Steps at 93%+ average 1040W of 2600W so should be near 0.4, got {}".format(high))
483+
failed = True
484+
485+
if not failed:
486+
print("SUCCESS: Curve tapers above 93%, reflecting the averaged power across each SoC step")
487+
488+
remove_test_history_data(my_predbat)
489+
return failed
490+
491+
377492
def run_find_charge_curve_tests(my_predbat):
378493
"""
379494
Run all find_charge_curve tests
@@ -415,5 +530,9 @@ def run_find_charge_curve_tests(my_predbat):
415530
if failed:
416531
return failed
417532

533+
failed |= test_find_charge_curve_averages_power_across_period(my_predbat)
534+
if failed:
535+
return failed
536+
418537
print("**** find_charge_curve tests completed ****")
419538
return failed

0 commit comments

Comments
 (0)