@@ -62,6 +62,8 @@ class ScheduleResult:
6262 tomorrow_precharge : float = 0.0
6363 status : str = "off"
6464 soc_trajectory : list [float ] = field (default_factory = list )
65+ tomorrow_scheduled_slots : dict [int , str ] = field (default_factory = dict )
66+ tomorrow_soc_trajectory : list [float ] = field (default_factory = list )
6567
6668
6769@dataclass
@@ -714,6 +716,156 @@ def select_unified_charge_slots(
714716 return today_result , tomorrow_result , tomorrow_charge_kwh
715717
716718
719+ def _compute_tomorrow_schedule (
720+ config : EMSConfig ,
721+ state : EMSState ,
722+ today_result : ScheduleResult ,
723+ today_soc_trajectory : list [float ],
724+ ) -> tuple [dict [int , str ], list [float ]]:
725+ """Compute tomorrow's charge/discharge schedule and SOC trajectory.
726+
727+ Uses projected midnight SOC from today's trajectory as the starting
728+ point. Charge slots come from the unified selection (already stored
729+ in today_result). Sell slots are computed fresh using the same
730+ profitability filter and SOC validation as today.
731+
732+ Returns (tomorrow_scheduled_slots, tomorrow_soc_trajectory).
733+ """
734+ tomorrow_prices = state .slot_prices_tomorrow
735+ if not tomorrow_prices or config .grid_mode == "off" :
736+ return {}, []
737+
738+ num_slots = len (tomorrow_prices )
739+ minutes_per_slot = (24 * 60 ) / num_slots
740+ slot_duration_hours = minutes_per_slot / 60.0
741+ energy_per_slot = config .safe_power_kw * slot_duration_hours
742+ effective_per_slot = energy_per_slot * config .efficiency
743+ round_trip_eff = config .efficiency * config .efficiency
744+
745+ # Projected midnight SOC from today's trajectory (last value)
746+ min_kwh = (config .battery_discharge_min_pct / 100.0 ) * config .battery_capacity_kwh
747+ if today_soc_trajectory :
748+ midnight_pct = today_soc_trajectory [- 1 ]
749+ midnight_kwh = max (min_kwh , (midnight_pct / 100.0 ) * config .battery_capacity_kwh )
750+ else :
751+ midnight_kwh = min_kwh
752+
753+ # Build remaining slots for tomorrow (all are future)
754+ remaining = [(i , tomorrow_prices [i ]) for i in range (num_slots )
755+ if tomorrow_prices [i ] is not None ]
756+ if not remaining :
757+ return {}, []
758+
759+ # Charge slots: from unified selection stored on today_result
760+ scheduled : dict [int , str ] = {}
761+ charge_indices : set [int ] = set ()
762+
763+ # The unified charge selection already picked tomorrow's charge slots.
764+ # Reconstruct them: they're the cheapest slots up to tomorrow_planned_slots.
765+ if today_result .tomorrow_planned_slots > 0 and config .grid_mode in ("from_grid" , "both" ):
766+ neg = [(i , p ) for i , p in remaining if p < 0 ]
767+ non_neg = sorted ([(i , p ) for i , p in remaining if p >= 0 ], key = lambda x : x [1 ])
768+ neg_energy = len (neg ) * effective_per_slot
769+ deficit = today_result .tomorrow_planned_kwh
770+ non_neg_needed = math .ceil (max (0 , deficit - neg_energy ) / effective_per_slot ) if effective_per_slot > 0 else 0
771+ charge_slots = neg + non_neg [:non_neg_needed ]
772+ for idx , _ in charge_slots :
773+ scheduled [idx ] = "charge"
774+ charge_indices .add (idx )
775+
776+ # Synthesize hourly PV for tomorrow (distribute total across daylight 6-18)
777+ pv_tomorrow_total = state .pv_forecast_tomorrow or 0.0
778+ daylight_hours = list (range (6 , 18 ))
779+ pv_per_daylight_hour = pv_tomorrow_total / len (daylight_hours ) if daylight_hours else 0.0
780+ pv_hourly_tomorrow : dict [int , float ] = {}
781+ for h in daylight_hours :
782+ pv_hourly_tomorrow [h ] = pv_per_daylight_hour
783+
784+ # Sell slots (to_grid or both mode)
785+ if config .grid_mode in ("to_grid" , "both" ):
786+ reserve_kwh = calculate_self_consumption_reserve (
787+ config .consumption_est_kwh , state .pv_hourly_kwh )
788+ reserve_target = _compute_reserve_target (config , reserve_kwh )
789+
790+ charge_energy = len (charge_indices ) * effective_per_slot
791+ max_battery_kwh = (config .battery_charge_max_pct / 100.0 ) * config .battery_capacity_kwh
792+
793+ # Arbitrage check for tomorrow
794+ arbitrage_active = False
795+ if config .arbitrage_price_delta > 0 and remaining :
796+ prices_vals = [p for _ , p in remaining ]
797+ spread = max (prices_vals ) - min (prices_vals )
798+ if spread >= config .arbitrage_price_delta :
799+ arbitrage_active = True
800+
801+ if arbitrage_active :
802+ sellable = max (0.0 , max_battery_kwh - reserve_target ) * config .efficiency * 0.85
803+ else :
804+ peak_kwh = min (max_battery_kwh , midnight_kwh + pv_tomorrow_total + charge_energy )
805+ sellable = max (0.0 , peak_kwh - reserve_target ) * config .efficiency * 0.85
806+
807+ if sellable > 0 :
808+ available = [(i , p ) for i , p in remaining
809+ if p > 0 and i not in charge_indices ]
810+ if config .grid_mode == "both" and charge_indices :
811+ max_buy = max (tomorrow_prices [i ] for i in charge_indices
812+ if tomorrow_prices [i ] is not None )
813+ min_sell = max_buy / round_trip_eff
814+ available = [(i , p ) for i , p in available if p >= min_sell ]
815+
816+ available .sort (key = lambda x : - x [1 ])
817+ sell_needed = math .ceil (sellable / energy_per_slot ) if energy_per_slot > 0 else 0
818+ sell_selected = available [:sell_needed ]
819+
820+ # SOC validation — use synthesized PV hourly for tomorrow
821+ consumption_per_slot = config .consumption_est_kwh / num_slots
822+ discharge_set = {s [0 ] for s in sell_selected }
823+ _ , validated_discharge = _validate_schedule_soc (
824+ remaining , charge_indices , discharge_set ,
825+ midnight_kwh , consumption_per_slot ,
826+ pv_hourly_tomorrow , minutes_per_slot , 1.0 ,
827+ config .battery_capacity_kwh , reserve_target ,
828+ energy_per_slot , config .efficiency ,
829+ consumption_hourly_kwh = state .consumption_hourly_kwh ,
830+ inverter_max_power_kw = config .inverter_max_power_kw ,
831+ safe_power_kw = config .safe_power_kw ,
832+ )
833+ for idx , _ in sell_selected :
834+ if idx in validated_discharge :
835+ scheduled [idx ] = "discharge"
836+
837+ # SOC trajectory for tomorrow
838+ trajectory : list [float ] = []
839+ cap = config .battery_capacity_kwh
840+ soc = midnight_kwh
841+
842+ for i in range (num_slots ):
843+ pct = max (0.0 , min (100.0 , (soc / cap ) * 100.0 )) if cap > 0 else 0.0
844+ trajectory .append (round (pct , 1 ))
845+
846+ hour = int ((i * minutes_per_slot ) / 60 )
847+ pv_kwh_rate = pv_per_daylight_hour if hour in daylight_hours else 0.0
848+ pv_per_slot = pv_kwh_rate * slot_duration_hours
849+
850+ if state .consumption_hourly_kwh and hour in state .consumption_hourly_kwh :
851+ cons = state .consumption_hourly_kwh [hour ] * slot_duration_hours
852+ else :
853+ cons = config .consumption_est_kwh / num_slots
854+
855+ delta = pv_per_slot - cons
856+ action = scheduled .get (i )
857+ if action == "charge" :
858+ grid_kw = min (config .safe_power_kw ,
859+ max (0.0 , config .inverter_max_power_kw - pv_kwh_rate ))
860+ delta += grid_kw * slot_duration_hours * config .efficiency
861+ elif action == "discharge" and soc > min_kwh :
862+ delta -= min (energy_per_slot , soc - min_kwh )
863+
864+ soc = max (min_kwh , min (cap , soc + delta ))
865+
866+ return scheduled , trajectory
867+
868+
717869def calculate_schedule (config : EMSConfig , state : EMSState ) -> ScheduleResult :
718870 """Calculate optimal charge/discharge schedule.
719871
@@ -804,6 +956,14 @@ def calculate_schedule(config: EMSConfig, state: EMSState) -> ScheduleResult:
804956 config , state ,
805957 )
806958
959+ # Compute tomorrow's schedule and trajectory (if tomorrow prices exist)
960+ if state .slot_prices_tomorrow :
961+ tmr_slots , tmr_traj = _compute_tomorrow_schedule (
962+ config , state , result , result .soc_trajectory ,
963+ )
964+ result .tomorrow_scheduled_slots = tmr_slots
965+ result .tomorrow_soc_trajectory = tmr_traj
966+
807967 return result
808968
809969
0 commit comments