| """Realistic event simulation engine for demo/testing.""" |
|
|
| import random |
| import math |
| import logging |
| import threading |
| import time |
| from datetime import datetime |
| from typing import Dict, Optional, Callable |
| from models.venue import Venue, Zone |
| from models.queue import QueueStation |
| from models.event import Event, EventPhase |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| def create_demo_venue() -> Venue: |
| """Create a realistic cricket stadium layout (based on a typical Indian cricket ground).""" |
| |
| center_lat = 18.9388 |
| center_lng = 72.8255 |
|
|
| zones = [ |
| |
| Zone(id="north_stand", name="North Stand", zone_type="stand", capacity=8000, |
| lat=center_lat + 0.0015, lng=center_lng, amenities=["seating", "shade"]), |
| Zone(id="south_stand", name="South Stand", zone_type="stand", capacity=8000, |
| lat=center_lat - 0.0015, lng=center_lng, amenities=["seating", "shade"]), |
| Zone(id="east_pavilion", name="East Pavilion", zone_type="stand", capacity=6000, |
| lat=center_lat, lng=center_lng + 0.0015, amenities=["seating", "premium"]), |
| Zone(id="west_pavilion", name="West Pavilion", zone_type="stand", capacity=6000, |
| lat=center_lat, lng=center_lng - 0.0015, amenities=["seating", "premium"]), |
| Zone(id="main_pavilion", name="Main Pavilion (VIP)", zone_type="stand", capacity=3000, |
| lat=center_lat + 0.001, lng=center_lng + 0.001, amenities=["seating", "vip", "lounge"]), |
|
|
| |
| Zone(id="north_concourse", name="North Concourse", zone_type="concourse", capacity=4000, |
| lat=center_lat + 0.002, lng=center_lng, amenities=["walking"]), |
| Zone(id="south_concourse", name="South Concourse", zone_type="concourse", capacity=4000, |
| lat=center_lat - 0.002, lng=center_lng, amenities=["walking"]), |
| Zone(id="east_concourse", name="East Concourse", zone_type="concourse", capacity=3000, |
| lat=center_lat, lng=center_lng + 0.002, amenities=["walking"]), |
| Zone(id="west_concourse", name="West Concourse", zone_type="concourse", capacity=3000, |
| lat=center_lat, lng=center_lng - 0.002, amenities=["walking"]), |
|
|
| |
| Zone(id="gate_1", name="Gate 1 (Main)", zone_type="gate", capacity=2000, |
| lat=center_lat + 0.0025, lng=center_lng - 0.001, amenities=["entry", "security"]), |
| Zone(id="gate_3", name="Gate 3 (North)", zone_type="gate", capacity=1500, |
| lat=center_lat + 0.0025, lng=center_lng + 0.001, amenities=["entry", "security"]), |
| Zone(id="gate_5", name="Gate 5 (West)", zone_type="gate", capacity=1500, |
| lat=center_lat, lng=center_lng - 0.0025, amenities=["entry", "security"]), |
| Zone(id="gate_7", name="Gate 7 (South)", zone_type="gate", capacity=1500, |
| lat=center_lat - 0.0025, lng=center_lng, amenities=["entry", "security"]), |
|
|
| |
| Zone(id="food_north", name="North Food Court", zone_type="food_court", capacity=1500, |
| lat=center_lat + 0.0018, lng=center_lng + 0.0005, amenities=["food", "beverages"]), |
| Zone(id="food_south", name="South Food Court", zone_type="food_court", capacity=1500, |
| lat=center_lat - 0.0018, lng=center_lng - 0.0005, amenities=["food", "beverages"]), |
| Zone(id="food_west", name="West Food Stalls", zone_type="food_court", capacity=800, |
| lat=center_lat - 0.0005, lng=center_lng - 0.0018, amenities=["food"]), |
|
|
| |
| Zone(id="restroom_n1", name="Restrooms N1", zone_type="restroom", capacity=200, |
| lat=center_lat + 0.0017, lng=center_lng - 0.0008, amenities=["restroom", "accessible"]), |
| Zone(id="restroom_s1", name="Restrooms S1", zone_type="restroom", capacity=200, |
| lat=center_lat - 0.0017, lng=center_lng + 0.0008, amenities=["restroom", "accessible"]), |
| Zone(id="restroom_e1", name="Restrooms E1", zone_type="restroom", capacity=150, |
| lat=center_lat + 0.0005, lng=center_lng + 0.0017, amenities=["restroom"]), |
| Zone(id="restroom_w1", name="Restrooms W1", zone_type="restroom", capacity=150, |
| lat=center_lat - 0.0005, lng=center_lng - 0.0017, amenities=["restroom"]), |
|
|
| |
| Zone(id="parking_a", name="Parking Lot A", zone_type="parking", capacity=3000, |
| lat=center_lat + 0.003, lng=center_lng, amenities=["parking"]), |
| Zone(id="parking_b", name="Parking Lot B", zone_type="parking", capacity=2000, |
| lat=center_lat, lng=center_lng - 0.003, amenities=["parking"]), |
| ] |
|
|
| venue = Venue( |
| id="wankhede", |
| name="Wankhede Stadium", |
| city="Mumbai", |
| total_capacity=50000, |
| zones=zones, |
| center_lat=center_lat, |
| center_lng=center_lng, |
| map_zoom=16, |
| ) |
|
|
| return venue |
|
|
|
|
| def create_demo_queue_stations() -> list: |
| """Create realistic queue stations for the demo venue.""" |
| stations = [ |
| |
| QueueStation(id="f_n1", name="Chaat Counter", category="food", zone_id="food_north", |
| avg_service_time_sec=60, lat=18.9406, lng=72.8260), |
| QueueStation(id="f_n2", name="Biryani House", category="food", zone_id="food_north", |
| avg_service_time_sec=90, lat=18.9406, lng=72.8258), |
| QueueStation(id="f_n3", name="Pizza & Burgers", category="food", zone_id="food_north", |
| avg_service_time_sec=75, lat=18.9406, lng=72.8262), |
| QueueStation(id="f_s1", name="South Indian Delights", category="food", zone_id="food_south", |
| avg_service_time_sec=70, lat=18.9370, lng=72.8250), |
| QueueStation(id="f_s2", name="Beverages & Ice Cream", category="food", zone_id="food_south", |
| avg_service_time_sec=45, lat=18.9370, lng=72.8252), |
| QueueStation(id="f_w1", name="Quick Bites", category="food", zone_id="food_west", |
| avg_service_time_sec=40, lat=18.9385, lng=72.8237), |
|
|
| |
| QueueStation(id="m_1", name="Team Jersey Shop", category="merch", zone_id="north_concourse", |
| avg_service_time_sec=120, lat=18.9408, lng=72.8255), |
| QueueStation(id="m_2", name="Souvenir Stand", category="merch", zone_id="east_concourse", |
| avg_service_time_sec=90, lat=18.9388, lng=72.8275), |
|
|
| |
| QueueStation(id="r_n1", name="Restrooms N1", category="restroom", zone_id="restroom_n1", |
| avg_service_time_sec=120, lat=18.9405, lng=72.8247), |
| QueueStation(id="r_s1", name="Restrooms S1", category="restroom", zone_id="restroom_s1", |
| avg_service_time_sec=120, lat=18.9371, lng=72.8263), |
| QueueStation(id="r_e1", name="Restrooms E1", category="restroom", zone_id="restroom_e1", |
| avg_service_time_sec=110, lat=18.9393, lng=72.8272), |
| QueueStation(id="r_w1", name="Restrooms W1", category="restroom", zone_id="restroom_w1", |
| avg_service_time_sec=110, lat=18.9383, lng=72.8238), |
|
|
| |
| QueueStation(id="g_1", name="Gate 1 Entry", category="entry", zone_id="gate_1", |
| avg_service_time_sec=15, lat=18.9413, lng=72.8245), |
| QueueStation(id="g_3", name="Gate 3 Entry", category="entry", zone_id="gate_3", |
| avg_service_time_sec=15, lat=18.9413, lng=72.8265), |
| QueueStation(id="g_5", name="Gate 5 Entry", category="entry", zone_id="gate_5", |
| avg_service_time_sec=15, lat=18.9388, lng=72.8230), |
| QueueStation(id="g_7", name="Gate 7 Entry", category="entry", zone_id="gate_7", |
| avg_service_time_sec=15, lat=18.9363, lng=72.8255), |
| ] |
|
|
| return stations |
|
|
|
|
| def create_demo_event() -> Event: |
| """Create a demo cricket match event.""" |
| return Event( |
| id="ipl_2026_01", |
| name="IPL 2026 — Mumbai Indians vs Chennai Super Kings", |
| sport="Cricket", |
| venue_id="wankhede", |
| date=datetime.now(), |
| home_team="Mumbai Indians", |
| away_team="Chennai Super Kings", |
| current_phase="first_half", |
| expected_attendance=33000, |
| actual_attendance=0, |
| weather="clear", |
| temperature_c=31.0, |
| ) |
|
|
|
|
| |
|
|
| PHASE_PROFILES = { |
| "pre_event": { |
| "stand_fill": 0.0, "concourse_fill": 0.05, "gate_fill": 0.1, |
| "food_fill": 0.05, "restroom_fill": 0.05, "parking_fill": 0.3, |
| "queue_food": (0, 3), "queue_restroom": (0, 2), "queue_entry": (0, 5), |
| }, |
| "gates_open": { |
| "stand_fill": 0.1, "concourse_fill": 0.3, "gate_fill": 0.7, |
| "food_fill": 0.15, "restroom_fill": 0.1, "parking_fill": 0.6, |
| "queue_food": (2, 8), "queue_restroom": (1, 4), "queue_entry": (10, 40), |
| }, |
| "filling": { |
| "stand_fill": 0.4, "concourse_fill": 0.5, "gate_fill": 0.8, |
| "food_fill": 0.3, "restroom_fill": 0.2, "parking_fill": 0.8, |
| "queue_food": (5, 15), "queue_restroom": (3, 8), "queue_entry": (20, 60), |
| }, |
| "first_half": { |
| "stand_fill": 0.8, "concourse_fill": 0.15, "gate_fill": 0.1, |
| "food_fill": 0.1, "restroom_fill": 0.1, "parking_fill": 0.9, |
| "queue_food": (2, 8), "queue_restroom": (1, 5), "queue_entry": (0, 5), |
| }, |
| "halftime": { |
| "stand_fill": 0.5, "concourse_fill": 0.7, "gate_fill": 0.15, |
| "food_fill": 0.9, "restroom_fill": 0.8, "parking_fill": 0.9, |
| "queue_food": (15, 40), "queue_restroom": (10, 25), "queue_entry": (0, 3), |
| }, |
| "second_half": { |
| "stand_fill": 0.75, "concourse_fill": 0.1, "gate_fill": 0.1, |
| "food_fill": 0.1, "restroom_fill": 0.1, "parking_fill": 0.9, |
| "queue_food": (2, 8), "queue_restroom": (1, 5), "queue_entry": (0, 2), |
| }, |
| "event_end": { |
| "stand_fill": 0.3, "concourse_fill": 0.6, "gate_fill": 0.8, |
| "food_fill": 0.05, "restroom_fill": 0.3, "parking_fill": 0.9, |
| "queue_food": (0, 3), "queue_restroom": (3, 10), "queue_entry": (0, 0), |
| }, |
| "exiting": { |
| "stand_fill": 0.1, "concourse_fill": 0.4, "gate_fill": 0.9, |
| "food_fill": 0.02, "restroom_fill": 0.2, "parking_fill": 0.7, |
| "queue_food": (0, 2), "queue_restroom": (1, 5), "queue_entry": (0, 0), |
| }, |
| "post_event": { |
| "stand_fill": 0.0, "concourse_fill": 0.02, "gate_fill": 0.05, |
| "food_fill": 0.0, "restroom_fill": 0.02, "parking_fill": 0.2, |
| "queue_food": (0, 0), "queue_restroom": (0, 1), "queue_entry": (0, 0), |
| }, |
| } |
|
|
| |
| ZONE_TYPE_MAP = { |
| "stand": "stand_fill", |
| "concourse": "concourse_fill", |
| "gate": "gate_fill", |
| "food_court": "food_fill", |
| "restroom": "restroom_fill", |
| "parking": "parking_fill", |
| } |
|
|
|
|
| class Simulator: |
| """Simulates realistic crowd and queue dynamics.""" |
|
|
| def __init__(self, venue: Venue, queue_service, crowd_service, notification_service, event: Event): |
| self.venue = venue |
| self.queue_service = queue_service |
| self.crowd_service = crowd_service |
| self.notification_service = notification_service |
| self.event = event |
| self._running = False |
| self._thread: Optional[threading.Thread] = None |
| self._speed = 1.0 |
| self._tick_count = 0 |
| self._update_callback: Optional[Callable] = None |
|
|
| @property |
| def is_running(self) -> bool: |
| return self._running |
|
|
| def set_update_callback(self, callback: Callable): |
| """Set a callback to fire after each simulation tick.""" |
| self._update_callback = callback |
|
|
| def set_phase(self, phase: str): |
| """Manually set the event phase.""" |
| if phase in PHASE_PROFILES: |
| self.event.current_phase = phase |
| logger.info(f"🎬 Simulation phase changed to: {phase}") |
|
|
| def set_speed(self, speed: float): |
| """Set simulation speed (1.0 = real-time).""" |
| self._speed = max(0.1, min(10.0, speed)) |
|
|
| def start(self): |
| """Start the simulation loop.""" |
| if self._running: |
| return |
|
|
| self._running = True |
| self._thread = threading.Thread(target=self._run_loop, daemon=True) |
| self._thread.start() |
| logger.info("▶️ Simulation started") |
|
|
| def stop(self): |
| """Stop the simulation loop.""" |
| self._running = False |
| if self._thread: |
| self._thread.join(timeout=5) |
| logger.info("⏹️ Simulation stopped") |
|
|
| def tick(self): |
| """Execute one simulation tick — update all zones and queues.""" |
| phase = self.event.current_phase |
| profile = PHASE_PROFILES.get(phase, PHASE_PROFILES["pre_event"]) |
|
|
| self._tick_count += 1 |
|
|
| |
| for zone in self.venue.zones: |
| fill_key = ZONE_TYPE_MAP.get(zone.zone_type, "concourse_fill") |
| target_fill = profile.get(fill_key, 0.5) |
|
|
| |
| jitter = random.uniform(-0.15, 0.15) |
| target_count = int(zone.capacity * max(0, min(1, target_fill + jitter))) |
|
|
| |
| diff = target_count - zone.current_count |
| step = max(1, abs(diff) // 5) |
| if diff > 0: |
| new_count = zone.current_count + min(step, diff) |
| elif diff < 0: |
| new_count = zone.current_count - min(step, abs(diff)) |
| else: |
| new_count = zone.current_count |
|
|
| self.crowd_service.update_zone_count(self.venue, zone.id, new_count) |
|
|
| |
| for station_id, station in self.queue_service._stations.items(): |
| cat = station.category |
| queue_range = profile.get(f"queue_{cat}", (0, 5)) |
| target = random.randint(queue_range[0], queue_range[1]) |
|
|
| |
| diff = target - station.current_length |
| step = max(1, abs(diff) // 3) |
| if diff > 0: |
| new_len = station.current_length + min(step, diff) |
| elif diff < 0: |
| new_len = station.current_length - min(step, abs(diff)) |
| else: |
| new_len = station.current_length |
|
|
| self.queue_service.update_queue_length(station_id, max(0, new_len)) |
|
|
| |
| self.event.actual_attendance = self.venue.total_current |
|
|
| |
| if self._tick_count % 10 == 0: |
| self._check_and_alert() |
|
|
| |
| if self._update_callback: |
| try: |
| self._update_callback() |
| except Exception as e: |
| logger.error(f"Simulator callback error: {e}") |
|
|
| def _check_and_alert(self): |
| """Generate contextual alerts based on current conditions.""" |
| for zone in self.venue.zones: |
| if zone.occupancy_rate > 0.9: |
| self.notification_service.create_alert( |
| title=f"High Density: {zone.name}", |
| message=f"{zone.name} is at {round(zone.occupancy_rate * 100)}% capacity. Consider redirecting foot traffic.", |
| severity="warning", |
| zone_id=zone.id, |
| target_role="operator", |
| ) |
|
|
| def _run_loop(self): |
| """Main simulation loop running in a background thread.""" |
| while self._running: |
| try: |
| self.tick() |
| time.sleep(2.0 / self._speed) |
| except Exception as e: |
| logger.error(f"Simulation error: {e}") |
| time.sleep(1) |
|
|
| def get_status(self) -> Dict: |
| """Get current simulation status.""" |
| return { |
| "running": self._running, |
| "speed": self._speed, |
| "tick_count": self._tick_count, |
| "phase": self.event.current_phase, |
| "phase_label": self.event.phase.label, |
| "phase_description": self.event.phase.description, |
| "attendance": self.event.actual_attendance, |
| "venue_occupancy": round(self.venue.overall_occupancy * 100, 1), |
| } |
|
|