PromptWar / services /simulator.py
Mr-TD's picture
feat: Add operator dashboard, alerts, analytics, and simulator pages
aefe381
"""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 coordinates (representative sports venue in Mumbai)
center_lat = 18.9388
center_lng = 72.8255
zones = [
# Stands
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"]),
# Concourses
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"]),
# Gates
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"]),
# Food Courts
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"]),
# Restrooms
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"]),
# Parking
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 = [
# Food stalls
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),
# Merchandise
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),
# Restrooms (physical queues)
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),
# Entry/Exit gates
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-based simulation profiles ─────────────────────────────────────────
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),
},
}
# Map zone types to profile keys
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 # 1x = real-time, 2x = double speed, etc.
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
# Update zone crowd counts
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)
# Add randomness (+/- 15%)
jitter = random.uniform(-0.15, 0.15)
target_count = int(zone.capacity * max(0, min(1, target_fill + jitter)))
# Smooth transition (don't jump instantly)
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)
# Update queue lengths
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])
# Smooth transition
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))
# Update attendance count
self.event.actual_attendance = self.venue.total_current
# Periodic alerts based on conditions
if self._tick_count % 10 == 0:
self._check_and_alert()
# Fire update callback
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),
}