@@ -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+
377492def 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