@@ -759,8 +759,9 @@ def test_negative_prices_from_grid(self):
759759 charged_neg = sum (1 for s in charge_slots if prices [s ] < 0 )
760760 assert charged_neg > 0
761761
762- def test_negative_prices_skipped_when_pv_fills_battery (self ):
763- """Negative-price charging skipped when PV would overfill the battery."""
762+ def test_negative_prices_kept_when_pv_fills_battery (self ):
763+ """Negative-price charging preserved when PV fills battery — the
764+ overflow is PV-caused, so negative-price income is pure profit."""
764765 config = default_config ()
765766 prices = make_prices (24 , pattern = "negative" )
766767 state = default_state (
@@ -773,7 +774,10 @@ def test_negative_prices_skipped_when_pv_fills_battery(self):
773774 )
774775 result = calculate_schedule (config , state )
775776 charge_slots = [k for k , v in result .scheduled_slots .items () if v == "charge" ]
776- assert len (charge_slots ) == 0
777+ neg_slots = [s for s in charge_slots if prices [s ] < 0 ]
778+ assert len (neg_slots ) > 0 , (
779+ "Negative-price slots should be kept when PV fills battery anyway"
780+ )
777781
778782 def test_yesterday_deficit_carryover (self ):
779783 """Yesterday's deficit increases today's charging."""
@@ -1077,8 +1081,9 @@ def test_scenario_negative_prices_bonus_charge(self):
10771081 neg_slots = [s for s in charge_slots if prices [s ] < 0 ]
10781082 assert len (neg_slots ) >= 1
10791083
1080- def test_scenario_negative_prices_skipped_when_battery_full_with_pv (self ):
1081- """Negative prices skipped when PV + high SOC leaves no room."""
1084+ def test_scenario_negative_prices_kept_when_pv_fills_battery (self ):
1085+ """Negative-price slots kept when PV alone fills battery — overflow
1086+ is PV-caused, negative-price income is pure profit."""
10821087 config = default_config (consumption_est_kwh = 20.0 )
10831088 prices = [0.25 ] * 10 + [- 0.05 , - 0.03 , - 0.01 ] + [0.25 ] * 11
10841089 state = default_state (
@@ -1092,8 +1097,9 @@ def test_scenario_negative_prices_skipped_when_battery_full_with_pv(self):
10921097 result = calculate_schedule (config , state )
10931098 charge_slots = [k for k , v in result .scheduled_slots .items () if v == "charge" ]
10941099 neg_slots = [s for s in charge_slots if prices [s ] < 0 ]
1095- # With 10 kWh battery at 70% and 35 kWh PV, no room for grid charging
1096- assert len (neg_slots ) == 0
1100+ assert len (neg_slots ) >= 1 , (
1101+ "Negative-price slots should be kept when PV fills battery anyway"
1102+ )
10971103
10981104 def test_scenario_both_mode_negative_prices_with_headroom (self ):
10991105 """Both mode charges at negative prices when battery has room."""
@@ -1115,8 +1121,9 @@ def test_scenario_both_mode_negative_prices_with_headroom(self):
11151121 neg_slots = [s for s in charge_slots if prices [s ] < 0 ]
11161122 assert len (neg_slots ) >= 1
11171123
1118- def test_scenario_both_mode_negative_prices_limited_by_pv (self ):
1119- """Both mode limits negative-price charging when PV would overflow battery."""
1124+ def test_scenario_both_mode_negative_prices_kept_when_pv_fills (self ):
1125+ """Both mode keeps negative-price charging when PV fills battery —
1126+ overflow is PV-caused, negative-price income is profit."""
11201127 config = default_config (
11211128 grid_mode = "both" , consumption_est_kwh = 10.0 ,
11221129 )
@@ -1132,8 +1139,10 @@ def test_scenario_both_mode_negative_prices_limited_by_pv(self):
11321139 )
11331140 result = calculate_schedule (config , state )
11341141 charge_slots = [k for k , v in result .scheduled_slots .items () if v == "charge" ]
1135- # Battery at 80% with 40 kWh PV on 10 kWh battery: almost no room
1136- assert len (charge_slots ) <= 2
1142+ neg_slots = [s for s in charge_slots if prices [s ] < 0 ]
1143+ assert len (neg_slots ) >= 1 , (
1144+ "Negative-price slots should be kept when PV fills battery anyway"
1145+ )
11371146
11381147
11391148# ---------------------------------------------------------------------------
@@ -3024,6 +3033,72 @@ def test_sell_slots_preserved_when_pv_recovers_soc(self):
30243033 f"SOC at slot { i } is { soc } %, below min { config .battery_discharge_min_pct } %"
30253034 )
30263035
3036+ def test_negative_charge_preserved_when_pv_fills_battery (self ):
3037+ """When PV alone would fill the battery, negative-price charge slots
3038+ should NOT be pruned — the overflow is PV-caused, and charging at
3039+ negative prices is pure profit."""
3040+ config = default_config (
3041+ grid_mode = "from_grid" ,
3042+ battery_capacity_kwh = 60.0 ,
3043+ battery_discharge_min_pct = 20.0 ,
3044+ consumption_est_kwh = 10.0 ,
3045+ safe_power_kw = 5.0 ,
3046+ inverter_max_power_kw = 10.0 ,
3047+ )
3048+ # Negative prices at hours 7-10, positive rest of day
3049+ prices = [0.05 ] * 7 + [- 0.46 , - 0.30 , - 0.20 ] + [0.05 ] * 4 + [0.10 ] * 4 + [0.15 ] * 6
3050+ state = default_state (
3051+ battery_soc_pct = 29.0 ,
3052+ slot_prices_today = prices ,
3053+ pv_hourly_kwh = make_pv_hourly (65.0 ),
3054+ pv_forecast_remaining = 64.7 ,
3055+ pv_forecast_today = 65.0 ,
3056+ pv_actual_today_kwh = 0.3 ,
3057+ current_hour = 7 ,
3058+ )
3059+ result = calculate_schedule (config , state )
3060+ charges = {k for k , v in result .scheduled_slots .items () if v == "charge" }
3061+ neg_slots = {i for i , p in enumerate (prices ) if p < 0 and i >= 7 }
3062+ neg_charged = charges & neg_slots
3063+ assert len (neg_charged ) == len (neg_slots ), (
3064+ f"All { len (neg_slots )} negative-price slots should be charged "
3065+ f"(PV fills battery anyway), but only { len (neg_charged )} are: "
3066+ f"charged={ sorted (charges )} , neg={ sorted (neg_slots )} "
3067+ )
3068+
3069+ def test_negative_charge_still_pruned_when_pv_insufficient (self ):
3070+ """When PV doesn't fill the battery, negative-price charge slots
3071+ that would cause overflow should still be pruned."""
3072+ config = default_config (
3073+ grid_mode = "from_grid" ,
3074+ battery_capacity_kwh = 60.0 ,
3075+ battery_discharge_min_pct = 20.0 ,
3076+ consumption_est_kwh = 10.0 ,
3077+ safe_power_kw = 5.0 ,
3078+ inverter_max_power_kw = 10.0 ,
3079+ )
3080+ # Battery nearly full, little PV, negative prices
3081+ prices = [0.05 ] * 7 + [- 0.10 , - 0.05 , - 0.02 ] + [0.05 ] * 14
3082+ state = default_state (
3083+ battery_soc_pct = 92.0 ,
3084+ slot_prices_today = prices ,
3085+ pv_hourly_kwh = make_pv_hourly (5.0 ),
3086+ pv_forecast_remaining = 3.0 ,
3087+ pv_forecast_today = 5.0 ,
3088+ pv_actual_today_kwh = 2.0 ,
3089+ current_hour = 7 ,
3090+ )
3091+ result = calculate_schedule (config , state )
3092+ charges = {k for k , v in result .scheduled_slots .items () if v == "charge" }
3093+ neg_slots = {i for i , p in enumerate (prices ) if p < 0 and i >= 7 }
3094+ # At 92% of 60 = 55.2 kWh, capacity = 60, only 4.8 kWh headroom
3095+ # With 5 kW * 0.9 eff = 4.5 kWh per slot, only 1 slot fits.
3096+ # PV doesn't fill battery → overflow pruning should still apply.
3097+ assert len (charges & neg_slots ) < len (neg_slots ), (
3098+ f"Not all negative slots should charge when battery is nearly full "
3099+ f"and PV insufficient — overflow pruning should apply"
3100+ )
3101+
30273102 def test_sell_still_pruned_when_discharge_causes_violation (self ):
30283103 """When a sell actually causes SOC to drop below min, it should
30293104 still be pruned (the fix only affects inherent low SOC)."""
0 commit comments