@@ -960,6 +960,129 @@ def _validate_schedule_soc(
960960 return charge_slots , discharge_slots
961961
962962
963+ def _slot_grid_charge_kwh (
964+ slot_idx : int ,
965+ pv_hourly_kwh : dict [int , float ] | None ,
966+ minutes_per_slot : float ,
967+ pv_confidence : float ,
968+ energy_per_slot : float ,
969+ efficiency : float ,
970+ inverter_max_power_kw : float ,
971+ safe_power_kw : float ,
972+ ) -> float :
973+ """Grid energy (kWh, after charge efficiency) a single charge slot can
974+ actually store, accounting for PV sharing the inverter at that hour.
975+
976+ Mirrors the per-slot charge contribution computed inside
977+ `_validate_schedule_soc`, so callers can size how much a candidate slot
978+ adds without re-running the full SOC simulation.
979+ """
980+ if inverter_max_power_kw > 0 :
981+ hour = int ((slot_idx * minutes_per_slot ) / 60 )
982+ pv_kwh = (pv_hourly_kwh or {}).get (hour , 0.0 ) * pv_confidence
983+ grid_kw = min (
984+ safe_power_kw or energy_per_slot / (minutes_per_slot / 60.0 ),
985+ max (0.0 , inverter_max_power_kw - pv_kwh ),
986+ )
987+ return grid_kw * (minutes_per_slot / 60.0 ) * efficiency
988+ return energy_per_slot * efficiency
989+
990+
991+ def _fill_charge_to_deficit (
992+ remaining : list [tuple [int , float ]],
993+ validated_charge : set [int ],
994+ energy_deficit : float ,
995+ current_kwh : float ,
996+ consumption_per_slot : float ,
997+ pv_hourly_kwh : dict [int , float ] | None ,
998+ minutes_per_slot : float ,
999+ pv_confidence : float ,
1000+ battery_capacity : float ,
1001+ min_kwh : float ,
1002+ energy_per_slot : float ,
1003+ efficiency : float ,
1004+ inverter_max_power_kw : float ,
1005+ safe_power_kw : float ,
1006+ consumption_hourly_kwh : dict [int , float ] | None ,
1007+ max_price : float | None = None ,
1008+ ) -> set [int ]:
1009+ """Re-shop charge energy dropped by SOC validation into later slots.
1010+
1011+ The greedy selector (`select_unified_charge_slots`) picks the cheapest
1012+ slots cheapest-first. When the cheapest slots land back-to-back on a
1013+ small battery, charging them consecutively overflows capacity, so
1014+ `_validate_schedule_soc` drops one — and the dropped energy is otherwise
1015+ never re-allocated, leaving the overnight deficit only partly covered
1016+ (real symptom: a 20 kWh battery under a 40 kWh/day load charged only one
1017+ of two needed cheap slots, then rode the floor ~9 h earlier than the MILP
1018+ plan, which placed its second charge *later*, after consumption had freed
1019+ headroom).
1020+
1021+ This walks the remaining slots cheapest-first and adds each candidate that
1022+ (a) is priced at or below ``max_price`` (the marginal price the selector
1023+ already committed to), (b) survives a fresh validation against the
1024+ already-kept set, and (c) increases the delivered charge energy. Adding a
1025+ slot *after* prior drain is exactly what lets the second charge fit.
1026+
1027+ The ``max_price`` ceiling is the cost-discipline guard: we only re-shop the
1028+ dropped energy into a slot as cheap as the ones already chosen. We must
1029+ NOT reach for a more expensive slot to chase the deficit — if the only
1030+ feasible later slot is pricier (e.g. a 0.30 evening peak while the chosen
1031+ slots were 0.15, or while cheap 0.05 night grid follows after midnight),
1032+ bridging the overnight need by charging that expensive slot costs MORE than
1033+ just drawing the shortfall from the grid live (round-trip losses on a
1034+ higher price). This is the same no-safety-swap economics the two-day
1035+ selector already applies. Cheapest-first + the ceiling keep us cost-first;
1036+ we stop the moment the deficit is covered, so the battery is never charged
1037+ beyond what overnight survival needs. In the common case where validation
1038+ dropped nothing, the delivered energy already meets the deficit and this
1039+ returns immediately — a no-op for healthy schedules.
1040+ """
1041+
1042+ def delivered (slots : set [int ]) -> float :
1043+ return sum (
1044+ _slot_grid_charge_kwh (
1045+ s , pv_hourly_kwh , minutes_per_slot , pv_confidence ,
1046+ energy_per_slot , efficiency , inverter_max_power_kw ,
1047+ safe_power_kw ,
1048+ )
1049+ for s in slots
1050+ )
1051+
1052+ validated = set (validated_charge )
1053+ got = delivered (validated )
1054+ if got >= energy_deficit - 0.01 :
1055+ return validated
1056+
1057+ attempted = set (validated )
1058+ pool = sorted (
1059+ (
1060+ (idx , price ) for idx , price in remaining
1061+ if price is not None and idx not in attempted
1062+ and (max_price is None or price <= max_price + 0.0001 )
1063+ ),
1064+ key = lambda ip : ip [1 ],
1065+ )
1066+ for idx , _price in pool :
1067+ if got >= energy_deficit - 0.01 :
1068+ break
1069+ attempted .add (idx )
1070+ trial , _ = _validate_schedule_soc (
1071+ remaining , validated | {idx }, set (),
1072+ current_kwh , consumption_per_slot , pv_hourly_kwh ,
1073+ minutes_per_slot , pv_confidence , battery_capacity , min_kwh ,
1074+ energy_per_slot , efficiency ,
1075+ inverter_max_power_kw = inverter_max_power_kw ,
1076+ safe_power_kw = safe_power_kw ,
1077+ consumption_hourly_kwh = consumption_hourly_kwh ,
1078+ )
1079+ new_got = delivered (trial )
1080+ if new_got > got + 0.01 :
1081+ validated = trial
1082+ got = new_got
1083+ return validated
1084+
1085+
9631086def _select_discharges_for_pv_headroom (
9641087 remaining : list [tuple [int , float ]],
9651088 current_kwh : float ,
@@ -2257,7 +2380,42 @@ def _schedule_from_grid(
22572380 safe_power_kw = config .safe_power_kw ,
22582381 keep_all_negative_charges = config .charge_to_full_on_negative_price ,
22592382 )
2260- selected = [(idx , p ) for idx , p in selected if idx in validated_charge ]
2383+
2384+ # Re-shop any charge energy that validation dropped for overflow into
2385+ # later slots where prior consumption has freed headroom, so the overnight
2386+ # deficit is actually covered (see _fill_charge_to_deficit). Skipped for
2387+ # charge_to_full_on_negative_price, whose negative-slot handling and
2388+ # PV-curtailment trade-off are intentionally outside the deficit model.
2389+ if not config .charge_to_full_on_negative_price :
2390+ # Bound the re-shop by the SAME PV-aware headroom the selector uses
2391+ # (max_battery − current − net_pv_surplus). This is the cost
2392+ # discriminator: when PV surplus will fill the battery (net_pv > 0),
2393+ # headroom is small, so we never reach for an expensive evening slot
2394+ # to chase the deficit — the original cheap slot + solar already
2395+ # cover it (a small battery at midday under good PV must NOT buy
2396+ # peak-priced evening grid just to top off; that violates
2397+ # cost-first). When PV can't fill the battery (net_pv ≈ 0, heavy
2398+ # load), headroom is large and the dropped cheap slot is re-shopped.
2399+ fill_headroom = max (0.0 , max_battery_kwh - current_kwh - max (0.0 , net_pv ))
2400+ fill_target = min (energy_deficit , fill_headroom )
2401+ # Marginal price the selector already committed to: never re-shop the
2402+ # dropped energy into a pricier slot (see _fill_charge_to_deficit).
2403+ fill_max_price = max ((p for _ , p in selected if p is not None ), default = None )
2404+ validated_charge = _fill_charge_to_deficit (
2405+ remaining , validated_charge , fill_target ,
2406+ current_kwh , consumption_per_slot , state .pv_hourly_kwh ,
2407+ minutes_per_slot , pv_confidence , config .battery_capacity_kwh ,
2408+ min_kwh , energy_per_slot , config .efficiency ,
2409+ config .inverter_max_power_kw , config .safe_power_kw ,
2410+ state .consumption_hourly_kwh ,
2411+ max_price = fill_max_price ,
2412+ )
2413+
2414+ price_of_remaining = {idx : p for idx , p in remaining }
2415+ selected = [
2416+ (idx , price_of_remaining .get (idx , 0.0 )) for idx in validated_charge
2417+ ]
2418+ selected .sort (key = lambda ip : ip [0 ])
22612419
22622420 # discharge_to_make_room_for_negative_price: in from_grid mode we
22632421 # normally don't discharge. When this opt-in is enabled, schedule
0 commit comments