"""Simulation engine for campaign quota allocation.""" import copy from src.models import Campaign, QuotaAllocation, SimulationResult from src.strategies import QuotaStrategy class CampaignSimulator: """Simulates campaign execution with quota allocation.""" def __init__(self, quota_per_hour: int): """ Initialize simulator. Args: quota_per_hour: Total emails that can be sent per hour """ self.quota_per_hour = quota_per_hour self.quota_per_minute = quota_per_hour // 60 def simulate( self, campaigns: list[Campaign], strategy: QuotaStrategy, max_minutes: int = 1000, ) -> SimulationResult: """ Run simulation with given campaigns and strategy. Args: campaigns: List of campaigns to simulate (will be deep copied) strategy: Quota allocation strategy to use max_minutes: Maximum simulation time to prevent infinite loops Returns: SimulationResult with execution details """ # Deep copy campaigns to avoid modifying originals sim_campaigns = [copy.deepcopy(c) for c in campaigns] # Reset all campaigns to initial state for campaign in sim_campaigns: campaign.reset() total_emails_used = 0 total_quota_available = 0 # Run simulation minute by minute for minute in range(max_minutes): # Check if any campaigns are active active_campaigns = [c for c in sim_campaigns if c.is_active(minute)] if not active_campaigns: # Check if all campaigns have finished or none have started yet all_finished = all( c.remaining_volume == 0 or minute < c.start_minute for c in sim_campaigns ) if all_finished and any(c.remaining_volume == 0 for c in sim_campaigns): # All campaigns that should have run are done break # Otherwise, keep going (campaigns might start later) continue # Get quota allocations from strategy allocations = strategy.allocate_quota( sim_campaigns, minute, self.quota_per_minute ) # Apply allocations to campaigns for campaign in sim_campaigns: if campaign.id in allocations: allocated = allocations[campaign.id] campaign.send_emails(minute, allocated) total_emails_used += allocated total_quota_available += self.quota_per_minute # Calculate final minute (last activity) completion_minutes = [ c.completion_minute for c in sim_campaigns if c.completion_minute is not None ] total_minutes = max(completion_minutes) + 1 if completion_minutes else 0 # Calculate metrics quota_utilization = ( (total_emails_used / total_quota_available * 100) if total_quota_available > 0 else 0.0 ) completed_campaigns = [ c for c in sim_campaigns if c.completion_minute is not None ] avg_completion_time = ( sum(c.completion_minute - c.start_minute for c in completed_campaigns) / len(completed_campaigns) if completed_campaigns else 0.0 ) max_completion_time = ( max(c.completion_minute - c.start_minute for c in completed_campaigns) if completed_campaigns else 0 ) return SimulationResult( strategy_name=strategy.get_name(), campaigns=sim_campaigns, total_minutes=total_minutes, quota_per_minute=self.quota_per_minute, quota_utilization=quota_utilization, avg_completion_time=avg_completion_time, max_completion_time=max_completion_time, ) def simulate_campaign_alone(self, campaign: Campaign) -> int: """ Simulate a single campaign running alone to get baseline time. Args: campaign: Campaign to simulate Returns: Number of minutes to complete the campaign """ # Deep copy campaign sim_campaign = copy.deepcopy(campaign) sim_campaign.reset() minute = sim_campaign.start_minute while sim_campaign.remaining_volume > 0: emails_to_send = min(self.quota_per_minute, sim_campaign.remaining_volume) sim_campaign.send_emails(minute, emails_to_send) minute += 1 return ( sim_campaign.completion_minute - sim_campaign.start_minute if sim_campaign.completion_minute is not None else 0 ) def get_baseline_times(self, campaigns: list[Campaign]) -> dict[str, int]: """ Get baseline completion times for all campaigns running alone. Args: campaigns: List of campaigns Returns: Dict mapping campaign name to completion time when running alone """ return { campaign.name: self.simulate_campaign_alone(campaign) for campaign in campaigns } def get_quota_allocations_by_minute( self, result: SimulationResult ) -> list[QuotaAllocation]: """ Extract quota allocations for each minute from simulation result. Args: result: Simulation result Returns: List of QuotaAllocation objects for each minute """ allocations: list[QuotaAllocation] = [] for minute in range(result.total_minutes): campaign_allocations: dict[str, int] = {} active_count = 0 for campaign in result.campaigns: if minute in campaign.sends_by_minute: campaign_allocations[campaign.id] = campaign.sends_by_minute[minute] active_count += 1 if campaign_allocations: allocations.append( QuotaAllocation( minute=minute, campaign_allocations=campaign_allocations, total_quota=self.quota_per_minute, active_campaigns=active_count, ) ) return allocations def compare_strategies( campaigns: list[Campaign], strategies: list[QuotaStrategy], quota_per_hour: int, ) -> list[SimulationResult]: """ Compare multiple strategies on the same set of campaigns. Args: campaigns: List of campaigns to simulate strategies: List of strategies to compare quota_per_hour: Quota per hour Returns: List of SimulationResult objects, one per strategy """ simulator = CampaignSimulator(quota_per_hour) results: list[SimulationResult] = [] for strategy in strategies: result = simulator.simulate(campaigns, strategy) results.append(result) return results