init commit

This commit is contained in:
2026-04-12 07:19:48 -03:00
commit 9dbf89da02
111 changed files with 14925 additions and 0 deletions

View File

View File

137
mcp_servers/data/models.py Normal file
View File

@@ -0,0 +1,137 @@
"""Pydantic models for all operational data."""
from datetime import datetime
from enum import Enum
from pydantic import BaseModel
class FlightStatus(str, Enum):
ON_TIME = "ON_TIME"
DELAYED = "DELAYED"
CANCELLED = "CANCELLED"
DIVERTED = "DIVERTED"
class DelayCause(str, Enum):
WEATHER = "WEATHER"
MAINTENANCE = "MAINTENANCE"
CREW = "CREW"
ATC = "ATC"
LATE_AIRCRAFT = "LATE_AIRCRAFT"
class CrewRole(str, Enum):
CAPTAIN = "CAPTAIN"
FIRST_OFFICER = "FIRST_OFFICER"
FA = "FA"
class MPStatus(str, Enum):
GLOBAL_SERVICES = "GLOBAL_SERVICES"
K1 = "1K"
PLATINUM = "PLATINUM"
GOLD = "GOLD"
SILVER = "SILVER"
GENERAL = "GENERAL"
class FlightData(BaseModel):
flight_id: str
origin: str
destination: str
scheduled_departure: datetime
actual_departure: datetime | None = None
scheduled_arrival: datetime
actual_arrival: datetime | None = None
status: FlightStatus
delay_minutes: int = 0
delay_cause: DelayCause | None = None
aircraft_tail: str
gate: str
inbound_flight: str | None = None
crew_ids: list[str] = []
passenger_count: int = 0
class CrewMember(BaseModel):
crew_id: str
name: str
role: CrewRole
duty_hours_elapsed: float
duty_hours_limit: float
rest_hours_since_last: float
next_scheduled_flight: str | None = None
base_hub: str
class Passenger(BaseModel):
pax_id: str
name: str
mileage_plus_status: MPStatus
flight_id: str
destination: str
connection_flight: str | None = None
connection_deadline: datetime | None = None
special_needs: list[str] = []
class MELItem(BaseModel):
mel_id: str
aircraft_tail: str
system: str
description: str
restriction: str | None = None
expires: datetime
class RebookingCase(BaseModel):
pax_id: str
name: str
original_flight: str
mileage_plus_status: MPStatus
destination: str
next_available: str | None = None
urgency: str # HIGH | MEDIUM | LOW
class HubInfo(BaseModel):
code: str
name: str
city: str
timezone: str
latitude: float
longitude: float
terminals: int
gates: int
runways: int
# Hub reference data
HUBS: dict[str, HubInfo] = {
"ORD": HubInfo(
code="ORD", name="O'Hare International", city="Chicago",
timezone="America/Chicago", latitude=41.9742, longitude=-87.9073,
terminals=4, gates=191, runways=8,
),
"EWR": HubInfo(
code="EWR", name="Newark Liberty International", city="Newark",
timezone="America/New_York", latitude=40.6895, longitude=-74.1745,
terminals=3, gates=120, runways=3,
),
"IAH": HubInfo(
code="IAH", name="George Bush Intercontinental", city="Houston",
timezone="America/Chicago", latitude=29.9902, longitude=-95.3368,
terminals=5, gates=130, runways=5,
),
"SFO": HubInfo(
code="SFO", name="San Francisco International", city="San Francisco",
timezone="America/Los_Angeles", latitude=37.6213, longitude=-122.3790,
terminals=4, gates=115, runways=4,
),
"DEN": HubInfo(
code="DEN", name="Denver International", city="Denver",
timezone="America/Denver", latitude=39.8561, longitude=-104.6737,
terminals=1, gates=95, runways=6,
),
}

View File

View File

@@ -0,0 +1,124 @@
"""FAA NASSTATUS API client — live airport delay/status data, no API key required.
Endpoint: https://nasstatus.faa.gov/api/airport-status-information
Returns XML with ground stops, ground delay programs, arrival/departure delays, closures.
"""
import xml.etree.ElementTree as ET
import httpx
BASE_URL = "https://nasstatus.faa.gov/api/airport-status-information"
# Cache parsed data to avoid hitting the API for every airport query
_cache: dict | None = None
_cache_raw: str = ""
async def _fetch_all_status() -> dict[str, list[dict]]:
"""Fetch and parse all airport status information from FAA."""
global _cache, _cache_raw
try:
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
resp = await client.get(BASE_URL)
resp.raise_for_status()
xml_text = resp.text
# Don't re-parse if unchanged
if xml_text == _cache_raw and _cache is not None:
return _cache
_cache_raw = xml_text
_cache = _parse_status_xml(xml_text)
return _cache
except Exception as e:
return {"_error": [{"error": str(e)}]}
def _parse_status_xml(xml_text: str) -> dict[str, list[dict]]:
"""Parse FAA airport status XML into a dict of airport → delays."""
delays_by_airport: dict[str, list[dict]] = {}
root = ET.fromstring(xml_text)
for delay_type in root.findall("Delay_type"):
name = delay_type.findtext("Name", "").strip()
# Ground Stop Programs
for program in delay_type.findall(".//Program"):
arpt = program.findtext("ARPT", "").strip()
if not arpt:
continue
delays_by_airport.setdefault(arpt, []).append({
"type": "ground_stop",
"reason": program.findtext("Reason", "unknown").strip(),
"end_time": program.findtext("End_Time", "").strip() or None,
})
# Ground Delay Programs
for gdp in delay_type.findall(".//Ground_Delay"):
arpt = gdp.findtext("ARPT", "").strip()
if not arpt:
continue
delays_by_airport.setdefault(arpt, []).append({
"type": "ground_delay_program",
"reason": gdp.findtext("Reason", "unknown").strip(),
"average_delay": gdp.findtext("Avg", "").strip() or None,
"max_delay": gdp.findtext("Max", "").strip() or None,
})
# Arrival/Departure Delays
for delay in delay_type.findall(".//Delay"):
arpt = delay.findtext("ARPT", "").strip()
if not arpt:
continue
delays_by_airport.setdefault(arpt, []).append({
"type": name.lower().replace(" ", "_") if name else "delay",
"reason": delay.findtext("Reason", "unknown").strip(),
"average_delay": delay.findtext("Avg", "").strip() or None,
"max_delay": delay.findtext("Max", "").strip() or None,
"trend": delay.findtext("Trend", "").strip() or None,
})
# Closures
for closure in delay_type.findall(".//Closure"):
arpt = closure.findtext("ARPT", "").strip()
if not arpt:
continue
delays_by_airport.setdefault(arpt, []).append({
"type": "closure",
"reason": closure.findtext("Reason", "unknown").strip(),
"begin": closure.findtext("Begin", "").strip() or None,
"end": closure.findtext("End", "").strip() or None,
})
return delays_by_airport
async def get_airport_status(airport_code: str) -> dict:
"""Fetch live FAA airport status for a single airport.
Returns ground delay programs, ground stops, closures.
Falls back to 'status_unavailable' on any error.
"""
all_status = await _fetch_all_status()
if "_error" in all_status:
return {
"airport": airport_code.upper(),
"status": "status_unavailable",
"error": all_status["_error"][0]["error"],
"source": "faa_nasstatus_live",
}
airport = airport_code.upper()
delays = all_status.get(airport, [])
return {
"airport": airport,
"has_delays": len(delays) > 0,
"status": "delays_active" if delays else "normal_operations",
"delays": delays,
"source": "faa_nasstatus_live",
}

View File

@@ -0,0 +1,197 @@
"""OpenMeteo API client — live weather data, no API key required."""
import httpx
BASE_URL = "https://api.open-meteo.com/v1/forecast"
# United hub coordinates
HUB_COORDS: dict[str, tuple[float, float]] = {
"ORD": (41.9742, -87.9073), # Chicago O'Hare
"EWR": (40.6895, -74.1745), # Newark
"IAH": (29.9902, -95.3368), # Houston Intercontinental
"SFO": (37.6213, -122.3790), # San Francisco
"DEN": (39.8561, -104.6737), # Denver
"LAX": (33.9425, -118.4081), # Los Angeles
"IAD": (38.9531, -77.4565), # Washington Dulles
}
# WMO weather interpretation codes
WMO_CODES: dict[int, str] = {
0: "Clear sky",
1: "Mainly clear",
2: "Partly cloudy",
3: "Overcast",
45: "Fog",
48: "Depositing rime fog",
51: "Light drizzle",
53: "Moderate drizzle",
55: "Dense drizzle",
61: "Slight rain",
63: "Moderate rain",
65: "Heavy rain",
71: "Slight snow",
73: "Moderate snow",
75: "Heavy snow",
77: "Snow grains",
80: "Slight rain showers",
81: "Moderate rain showers",
82: "Violent rain showers",
85: "Slight snow showers",
86: "Heavy snow showers",
95: "Thunderstorm",
96: "Thunderstorm with slight hail",
99: "Thunderstorm with heavy hail",
}
SIGNIFICANT_CODES = {45, 48, 65, 75, 82, 86, 95, 96, 99}
def _interpolate_waypoints(
origin: tuple[float, float], dest: tuple[float, float], n: int = 2
) -> list[tuple[float, float]]:
"""Generate n intermediate waypoints along a great-circle approximation."""
points = []
for i in range(1, n + 1):
frac = i / (n + 1)
lat = origin[0] + frac * (dest[0] - origin[0])
lon = origin[1] + frac * (dest[1] - origin[1])
points.append((round(lat, 4), round(lon, 4)))
return points
def _interpret_weather(code: int) -> dict:
return {
"code": code,
"condition": WMO_CODES.get(code, f"Unknown ({code})"),
"is_significant": code in SIGNIFICANT_CODES,
}
async def fetch_weather_at_point(
lat: float, lon: float, client: httpx.AsyncClient
) -> dict:
"""Fetch current weather at a single point."""
params = {
"latitude": lat,
"longitude": lon,
"current": "temperature_2m,wind_speed_10m,wind_direction_10m,weather_code,visibility,precipitation",
"timezone": "UTC",
}
resp = await client.get(BASE_URL, params=params)
resp.raise_for_status()
data = resp.json()
current = data["current"]
weather = _interpret_weather(current["weather_code"])
return {
"latitude": lat,
"longitude": lon,
"temperature_c": current["temperature_2m"],
"wind_speed_kmh": current["wind_speed_10m"],
"wind_direction_deg": current["wind_direction_10m"],
"visibility_m": current.get("visibility"),
"precipitation_mm": current.get("precipitation", 0),
"weather": weather,
}
async def get_weather_along_route(origin: str, destination: str) -> dict:
"""Fetch weather at origin, destination, and en-route waypoints."""
origin_coords = HUB_COORDS.get(origin.upper())
dest_coords = HUB_COORDS.get(destination.upper())
if not origin_coords or not dest_coords:
return {
"error": f"Unknown airport code. Known: {', '.join(HUB_COORDS.keys())}",
"origin": origin,
"destination": destination,
}
waypoints = _interpolate_waypoints(origin_coords, dest_coords)
all_points = [
("origin", origin.upper(), origin_coords),
*[(f"waypoint_{i+1}", f"WP{i+1}", wp) for i, wp in enumerate(waypoints)],
("destination", destination.upper(), dest_coords),
]
results = {}
significant_events = []
async with httpx.AsyncClient(timeout=10.0) as client:
for label, name, (lat, lon) in all_points:
try:
weather = await fetch_weather_at_point(lat, lon, client)
weather["label"] = label
weather["name"] = name
results[label] = weather
if weather["weather"]["is_significant"]:
significant_events.append(
{
"location": name,
"condition": weather["weather"]["condition"],
"code": weather["weather"]["code"],
}
)
except Exception as e:
results[label] = {"label": label, "name": name, "error": str(e)}
return {
"origin": origin.upper(),
"destination": destination.upper(),
"waypoints": results,
"significant_events": significant_events,
"source": "openmeteo_live",
}
async def get_weather_forecast_hubs() -> dict:
"""Fetch 4-hour forecast for all United hubs."""
hubs = ["ORD", "EWR", "IAH", "SFO", "DEN"]
results = {}
async with httpx.AsyncClient(timeout=10.0) as client:
for hub in hubs:
lat, lon = HUB_COORDS[hub]
params = {
"latitude": lat,
"longitude": lon,
"hourly": "temperature_2m,wind_speed_10m,weather_code,visibility,precipitation_probability",
"forecast_hours": 4,
"timezone": "UTC",
}
try:
resp = await client.get(BASE_URL, params=params)
resp.raise_for_status()
data = resp.json()
hourly = data["hourly"]
hours = []
risk_flag = False
for i in range(len(hourly["time"])):
code = hourly["weather_code"][i]
weather = _interpret_weather(code)
if weather["is_significant"]:
risk_flag = True
hours.append(
{
"time": hourly["time"][i],
"temperature_c": hourly["temperature_2m"][i],
"wind_speed_kmh": hourly["wind_speed_10m"][i],
"weather": weather,
"visibility_m": hourly.get("visibility", [None])[i]
if "visibility" in hourly
else None,
"precipitation_probability": hourly[
"precipitation_probability"
][i],
}
)
results[hub] = {
"hub": hub,
"forecast": hours,
"risk_flag": risk_flag,
}
except Exception as e:
results[hub] = {"hub": hub, "error": str(e)}
return {"hubs": results, "source": "openmeteo_live"}

View File

View File

@@ -0,0 +1,123 @@
"""Scenario: EWR Crew Duty Limit — complex Part 117 crew swap needed.
Captain hitting duty limit in 2h, backup crew needed. Tests Handover IMMEDIATE action.
"""
from datetime import datetime, timezone
from mcp_servers.data.models import (
CrewMember,
CrewRole,
DelayCause,
FlightData,
FlightStatus,
MELItem,
MPStatus,
Passenger,
RebookingCase,
)
SCENARIO_ID = "crew_swap_ewr"
SCENARIO_NAME = "EWR Crew Duty Limit"
SCENARIO_DESCRIPTION = (
"Captain on UA2180 hitting Part 117 duty limit in 1h 45min. "
"Delay cascading. 2 crew swaps needed."
)
SCENARIO_HUBS = ["EWR"]
FLIGHTS: list[FlightData] = [
FlightData(
flight_id="UA2180", origin="EWR", destination="LAX",
scheduled_departure=datetime(2026, 4, 11, 20, 0, tzinfo=timezone.utc),
actual_departure=None,
scheduled_arrival=datetime(2026, 4, 11, 23, 30, tzinfo=timezone.utc),
status=FlightStatus.DELAYED,
delay_minutes=90,
delay_cause=DelayCause.CREW,
aircraft_tail="N81201",
gate="C72",
crew_ids=["CR-3001", "CR-3002", "CR-3010", "CR-3011"],
passenger_count=210,
),
FlightData(
flight_id="UA2244", origin="EWR", destination="ORD",
scheduled_departure=datetime(2026, 4, 11, 20, 30, tzinfo=timezone.utc),
actual_departure=None,
scheduled_arrival=datetime(2026, 4, 11, 22, 0, tzinfo=timezone.utc),
status=FlightStatus.DELAYED,
delay_minutes=45,
delay_cause=DelayCause.LATE_AIRCRAFT,
aircraft_tail="N81202",
gate="C87",
crew_ids=["CR-3003", "CR-3004", "CR-3012"],
passenger_count=178,
),
FlightData(
flight_id="UA2310", origin="EWR", destination="SFO",
scheduled_departure=datetime(2026, 4, 11, 21, 0, tzinfo=timezone.utc),
scheduled_arrival=datetime(2026, 4, 12, 0, 15, tzinfo=timezone.utc),
status=FlightStatus.ON_TIME,
aircraft_tail="N81203",
gate="C90",
crew_ids=["CR-3005", "CR-3006"],
passenger_count=195,
),
]
CREW: list[CrewMember] = [
# UA2180 — captain at limit
CrewMember(crew_id="CR-3001", name="Capt. Mitchell", role=CrewRole.CAPTAIN,
duty_hours_elapsed=12.25, duty_hours_limit=14.0,
rest_hours_since_last=10.0, next_scheduled_flight="UA2180", base_hub="EWR"),
CrewMember(crew_id="CR-3002", name="FO Vasquez", role=CrewRole.FIRST_OFFICER,
duty_hours_elapsed=11.0, duty_hours_limit=14.0,
rest_hours_since_last=11.0, next_scheduled_flight="UA2180", base_hub="EWR"),
# UA2244
CrewMember(crew_id="CR-3003", name="Capt. Ali", role=CrewRole.CAPTAIN,
duty_hours_elapsed=8.0, duty_hours_limit=14.0,
rest_hours_since_last=14.0, next_scheduled_flight="UA2244", base_hub="EWR"),
CrewMember(crew_id="CR-3004", name="FO Johansson", role=CrewRole.FIRST_OFFICER,
duty_hours_elapsed=8.0, duty_hours_limit=14.0,
rest_hours_since_last=13.0, next_scheduled_flight="UA2244", base_hub="EWR"),
# UA2310
CrewMember(crew_id="CR-3005", name="Capt. Reed", role=CrewRole.CAPTAIN,
duty_hours_elapsed=4.0, duty_hours_limit=14.0,
rest_hours_since_last=20.0, next_scheduled_flight="UA2310", base_hub="EWR"),
CrewMember(crew_id="CR-3006", name="FO Torres", role=CrewRole.FIRST_OFFICER,
duty_hours_elapsed=4.0, duty_hours_limit=14.0,
rest_hours_since_last=18.0, next_scheduled_flight="UA2310", base_hub="EWR"),
# FAs
CrewMember(crew_id="CR-3010", name="FA Collins", role=CrewRole.FA,
duty_hours_elapsed=11.5, duty_hours_limit=14.0,
rest_hours_since_last=10.0, next_scheduled_flight="UA2180", base_hub="EWR"),
CrewMember(crew_id="CR-3011", name="FA Yamamoto", role=CrewRole.FA,
duty_hours_elapsed=11.5, duty_hours_limit=14.0,
rest_hours_since_last=10.0, next_scheduled_flight="UA2180", base_hub="EWR"),
CrewMember(crew_id="CR-3012", name="FA Petrov", role=CrewRole.FA,
duty_hours_elapsed=7.0, duty_hours_limit=14.0,
rest_hours_since_last=15.0, next_scheduled_flight="UA2244", base_hub="EWR"),
# Backup crew
CrewMember(crew_id="CR-8812", name="Capt. Foster", role=CrewRole.CAPTAIN,
duty_hours_elapsed=0.0, duty_hours_limit=14.0,
rest_hours_since_last=28.0, next_scheduled_flight=None, base_hub="EWR"),
CrewMember(crew_id="CR-8813", name="FO Chang", role=CrewRole.FIRST_OFFICER,
duty_hours_elapsed=0.0, duty_hours_limit=14.0,
rest_hours_since_last=24.0, next_scheduled_flight=None, base_hub="EWR"),
]
CREW_NOTES: dict[str, list[str]] = {
"UA2180": [
"Capt. Mitchell duty limit approaching — 1h 45min remaining.",
"If departure slips past 22:15 ET, mandatory crew swap per Part 117 §117.19.",
"Backup Capt. Foster (CR-8812) on standby at crew lounge, cleared and rested.",
"FO Vasquez also approaching limit but has buffer until 23:00.",
],
"UA2244": [
"Delay is cascading from late inbound aircraft, not crew-related.",
"Gate conflict resolved — moved to C87.",
],
}
MAINTENANCE: dict[str, list[MELItem]] = {}
REBOOKINGS: list[RebookingCase] = []
PASSENGERS: list[Passenger] = []

View File

@@ -0,0 +1,117 @@
"""Scenario: SFO Maintenance Delay — MEL issue cascading to 2 flights.
Aircraft N82301 has APU MEL, delay cascading. Tests FCE maintenance-caused
delay explanation and Handover MEL flag section.
"""
from datetime import datetime, timezone
from mcp_servers.data.models import (
CrewMember,
CrewRole,
DelayCause,
FlightData,
FlightStatus,
MELItem,
MPStatus,
Passenger,
RebookingCase,
)
SCENARIO_ID = "maintenance_delay_sfo"
SCENARIO_NAME = "SFO MEL Issue"
SCENARIO_DESCRIPTION = (
"Aircraft N82301 APU inoperative (MEL). Delay cascading to 2 flights. "
"Route restriction if divert needed."
)
SCENARIO_HUBS = ["SFO"]
FLIGHTS: list[FlightData] = [
FlightData(
flight_id="UA712", origin="SFO", destination="EWR",
scheduled_departure=datetime(2026, 4, 11, 19, 0, tzinfo=timezone.utc),
actual_departure=None,
scheduled_arrival=datetime(2026, 4, 12, 3, 30, tzinfo=timezone.utc),
status=FlightStatus.DELAYED,
delay_minutes=65,
delay_cause=DelayCause.MAINTENANCE,
aircraft_tail="N82301",
gate="G4",
crew_ids=["CR-4001", "CR-4002", "CR-4010"],
passenger_count=198,
),
FlightData(
flight_id="UA724", origin="SFO", destination="DEN",
scheduled_departure=datetime(2026, 4, 11, 20, 30, tzinfo=timezone.utc),
scheduled_arrival=datetime(2026, 4, 11, 23, 45, tzinfo=timezone.utc),
status=FlightStatus.DELAYED,
delay_minutes=30,
delay_cause=DelayCause.LATE_AIRCRAFT,
aircraft_tail="N82302",
gate="G8",
crew_ids=["CR-4003", "CR-4004"],
passenger_count=165,
),
FlightData(
flight_id="UA760", origin="SFO", destination="LAX",
scheduled_departure=datetime(2026, 4, 11, 18, 30, tzinfo=timezone.utc),
scheduled_arrival=datetime(2026, 4, 11, 19, 45, tzinfo=timezone.utc),
status=FlightStatus.ON_TIME,
aircraft_tail="N82303",
gate="G12",
crew_ids=["CR-4005", "CR-4006"],
passenger_count=140,
),
]
CREW: list[CrewMember] = [
CrewMember(crew_id="CR-4001", name="Capt. Novak", role=CrewRole.CAPTAIN,
duty_hours_elapsed=6.0, duty_hours_limit=14.0,
rest_hours_since_last=16.0, next_scheduled_flight="UA712", base_hub="SFO"),
CrewMember(crew_id="CR-4002", name="FO Agrawal", role=CrewRole.FIRST_OFFICER,
duty_hours_elapsed=6.0, duty_hours_limit=14.0,
rest_hours_since_last=15.0, next_scheduled_flight="UA712", base_hub="SFO"),
CrewMember(crew_id="CR-4003", name="Capt. Svensson", role=CrewRole.CAPTAIN,
duty_hours_elapsed=5.0, duty_hours_limit=14.0,
rest_hours_since_last=18.0, next_scheduled_flight="UA724", base_hub="SFO"),
CrewMember(crew_id="CR-4004", name="FO Rivera", role=CrewRole.FIRST_OFFICER,
duty_hours_elapsed=5.0, duty_hours_limit=14.0,
rest_hours_since_last=17.0, next_scheduled_flight="UA724", base_hub="SFO"),
CrewMember(crew_id="CR-4005", name="Capt. Wallace", role=CrewRole.CAPTAIN,
duty_hours_elapsed=3.0, duty_hours_limit=14.0,
rest_hours_since_last=22.0, next_scheduled_flight="UA760", base_hub="SFO"),
CrewMember(crew_id="CR-4006", name="FO Zhao", role=CrewRole.FIRST_OFFICER,
duty_hours_elapsed=3.0, duty_hours_limit=14.0,
rest_hours_since_last=20.0, next_scheduled_flight="UA760", base_hub="SFO"),
CrewMember(crew_id="CR-4010", name="FA Douglas", role=CrewRole.FA,
duty_hours_elapsed=5.5, duty_hours_limit=14.0,
rest_hours_since_last=16.0, next_scheduled_flight="UA712", base_hub="SFO"),
]
CREW_NOTES: dict[str, list[str]] = {
"UA712": [
"MEL item #47-3: APU inoperative on N82301.",
"Maintenance team inspecting — estimated release in 45 min.",
"APU MEL restricts routing to airports with ground power only.",
"Current SFO→EWR route unaffected, but flag if divert to BDL or HPN needed.",
],
"UA724": [
"Delay cascading from UA712 — shared gate G8 not available until UA712 pushes.",
],
}
MAINTENANCE: dict[str, list[MELItem]] = {
"N82301": [
MELItem(
mel_id="MEL-SFO-473",
aircraft_tail="N82301",
system="APU",
description="Auxiliary Power Unit inoperative. Aircraft requires external ground power for engine start and gate operations.",
restriction="Routing restricted to airports with ground power availability. Cannot divert to airports without ground power units (e.g., BDL, HPN).",
expires=datetime(2026, 4, 18, 0, 0, tzinfo=timezone.utc),
),
],
}
REBOOKINGS: list[RebookingCase] = []
PASSENGERS: list[Passenger] = []

View File

@@ -0,0 +1,85 @@
"""Scenario manager — loads and switches between operational scenarios."""
from importlib import import_module
from typing import Any
SCENARIO_MODULES = {
"normal_ops": "mcp_servers.data.scenarios.normal_ops",
"weather_disruption_ord": "mcp_servers.data.scenarios.weather_disruption_ord",
"maintenance_delay_sfo": "mcp_servers.data.scenarios.maintenance_delay_sfo",
"crew_swap_ewr": "mcp_servers.data.scenarios.crew_swap_ewr",
}
class ScenarioManager:
"""Manages the active scenario. Singleton — all MCP servers share one instance."""
def __init__(self) -> None:
self._active_id: str = "weather_disruption_ord"
self._cache: dict[str, Any] = {}
@property
def active_id(self) -> str:
return self._active_id
def set_active(self, scenario_id: str) -> dict:
if scenario_id not in SCENARIO_MODULES:
raise ValueError(
f"Unknown scenario: {scenario_id}. "
f"Available: {', '.join(SCENARIO_MODULES.keys())}"
)
self._active_id = scenario_id
return self.get_metadata()
def get_metadata(self, scenario_id: str | None = None) -> dict:
mod = self._load(scenario_id or self._active_id)
return {
"scenario_id": mod.SCENARIO_ID,
"name": mod.SCENARIO_NAME,
"description": mod.SCENARIO_DESCRIPTION,
"hubs": mod.SCENARIO_HUBS,
"flight_count": len(mod.FLIGHTS),
"disrupted_flights": sum(
1 for f in mod.FLIGHTS if f.status != "ON_TIME"
),
}
def list_scenarios(self) -> list[dict]:
return [self.get_metadata(sid) for sid in SCENARIO_MODULES]
def _load(self, scenario_id: str) -> Any:
if scenario_id not in self._cache:
self._cache[scenario_id] = import_module(SCENARIO_MODULES[scenario_id])
return self._cache[scenario_id]
@property
def _module(self) -> Any:
return self._load(self._active_id)
@property
def flights(self):
return self._module.FLIGHTS
@property
def crew(self):
return self._module.CREW
@property
def crew_notes(self) -> dict[str, list[str]]:
return self._module.CREW_NOTES
@property
def maintenance(self):
return self._module.MAINTENANCE
@property
def rebookings(self):
return self._module.REBOOKINGS
@property
def passengers(self):
return self._module.PASSENGERS
# Singleton
scenario_manager = ScenarioManager()

View File

@@ -0,0 +1,72 @@
"""Scenario: Normal Operations — all flights on time, clear weather, full crew.
Baseline scenario. Agents should produce minimal/calm output.
"""
from datetime import datetime, timezone
from mcp_servers.data.models import (
CrewMember,
CrewRole,
FlightData,
FlightStatus,
MPStatus,
Passenger,
RebookingCase,
MELItem,
)
SCENARIO_ID = "normal_ops"
SCENARIO_NAME = "Normal Operations"
SCENARIO_DESCRIPTION = "All flights on time at ORD. Clear weather. No disruptions."
SCENARIO_HUBS = ["ORD"]
FLIGHTS: list[FlightData] = [
FlightData(
flight_id="UA1440", origin="ORD", destination="SFO",
scheduled_departure=datetime(2026, 4, 11, 18, 0, tzinfo=timezone.utc),
scheduled_arrival=datetime(2026, 4, 11, 20, 30, tzinfo=timezone.utc),
status=FlightStatus.ON_TIME, aircraft_tail="N79001", gate="H10",
crew_ids=["CR-2001", "CR-2002"], passenger_count=175,
),
FlightData(
flight_id="UA1552", origin="ORD", destination="EWR",
scheduled_departure=datetime(2026, 4, 11, 18, 30, tzinfo=timezone.utc),
scheduled_arrival=datetime(2026, 4, 11, 21, 45, tzinfo=timezone.utc),
status=FlightStatus.ON_TIME, aircraft_tail="N79002", gate="B12",
crew_ids=["CR-2003", "CR-2004"], passenger_count=190,
),
FlightData(
flight_id="UA1678", origin="ORD", destination="DEN",
scheduled_departure=datetime(2026, 4, 11, 19, 0, tzinfo=timezone.utc),
scheduled_arrival=datetime(2026, 4, 11, 20, 30, tzinfo=timezone.utc),
status=FlightStatus.ON_TIME, aircraft_tail="N79003", gate="C8",
crew_ids=["CR-2005", "CR-2006"], passenger_count=160,
),
]
CREW: list[CrewMember] = [
CrewMember(crew_id="CR-2001", name="Capt. Hayes", role=CrewRole.CAPTAIN,
duty_hours_elapsed=4.0, duty_hours_limit=14.0,
rest_hours_since_last=18.0, next_scheduled_flight="UA1440", base_hub="ORD"),
CrewMember(crew_id="CR-2002", name="FO Park", role=CrewRole.FIRST_OFFICER,
duty_hours_elapsed=4.0, duty_hours_limit=14.0,
rest_hours_since_last=16.0, next_scheduled_flight="UA1440", base_hub="ORD"),
CrewMember(crew_id="CR-2003", name="Capt. Lewis", role=CrewRole.CAPTAIN,
duty_hours_elapsed=5.0, duty_hours_limit=14.0,
rest_hours_since_last=15.0, next_scheduled_flight="UA1552", base_hub="ORD"),
CrewMember(crew_id="CR-2004", name="FO Sharma", role=CrewRole.FIRST_OFFICER,
duty_hours_elapsed=5.0, duty_hours_limit=14.0,
rest_hours_since_last=14.0, next_scheduled_flight="UA1552", base_hub="ORD"),
CrewMember(crew_id="CR-2005", name="Capt. Brown", role=CrewRole.CAPTAIN,
duty_hours_elapsed=3.0, duty_hours_limit=14.0,
rest_hours_since_last=20.0, next_scheduled_flight="UA1678", base_hub="ORD"),
CrewMember(crew_id="CR-2006", name="FO Nguyen", role=CrewRole.FIRST_OFFICER,
duty_hours_elapsed=3.0, duty_hours_limit=14.0,
rest_hours_since_last=19.0, next_scheduled_flight="UA1678", base_hub="ORD"),
]
CREW_NOTES: dict[str, list[str]] = {}
MAINTENANCE: dict[str, list[MELItem]] = {}
REBOOKINGS: list[RebookingCase] = []
PASSENGERS: list[Passenger] = []

View File

@@ -0,0 +1,281 @@
"""Scenario: ORD Thunderstorms — active disruption at Chicago O'Hare.
Thunderstorm line causing GDP, 4 flights delayed, 1 cancelled.
Tests FCE multi-flight notifications and Handover weather risk section.
"""
from datetime import datetime, timezone
from mcp_servers.data.models import (
CrewMember,
CrewRole,
DelayCause,
FlightData,
FlightStatus,
MELItem,
MPStatus,
Passenger,
RebookingCase,
)
SCENARIO_ID = "weather_disruption_ord"
SCENARIO_NAME = "ORD Thunderstorms"
SCENARIO_DESCRIPTION = (
"Active thunderstorm line at ORD. GDP in effect. "
"4 delays, 1 cancellation. 47 passengers need rebooking."
)
SCENARIO_HUBS = ["ORD"]
# --- Flights ---
FLIGHTS: list[FlightData] = [
# Delayed flights
FlightData(
flight_id="UA432",
origin="ORD", destination="SFO",
scheduled_departure=datetime(2026, 4, 11, 17, 45, tzinfo=timezone.utc),
actual_departure=None,
scheduled_arrival=datetime(2026, 4, 11, 20, 15, tzinfo=timezone.utc),
actual_arrival=None,
status=FlightStatus.DELAYED,
delay_minutes=55,
delay_cause=DelayCause.WEATHER,
aircraft_tail="N78501",
gate="H14",
inbound_flight="UA219",
crew_ids=["CR-1001", "CR-1002", "CR-1010", "CR-1011"],
passenger_count=186,
),
FlightData(
flight_id="UA881",
origin="ORD", destination="LAX",
scheduled_departure=datetime(2026, 4, 11, 18, 30, tzinfo=timezone.utc),
actual_departure=None,
scheduled_arrival=datetime(2026, 4, 11, 21, 0, tzinfo=timezone.utc),
actual_arrival=None,
status=FlightStatus.DELAYED,
delay_minutes=40,
delay_cause=DelayCause.WEATHER,
aircraft_tail="N78502",
gate="H22",
crew_ids=["CR-1003", "CR-1004", "CR-1012", "CR-1013"],
passenger_count=204,
),
FlightData(
flight_id="UA233",
origin="ORD", destination="DEN",
scheduled_departure=datetime(2026, 4, 11, 18, 0, tzinfo=timezone.utc),
actual_departure=None,
scheduled_arrival=datetime(2026, 4, 11, 19, 45, tzinfo=timezone.utc),
actual_arrival=None,
status=FlightStatus.DELAYED,
delay_minutes=70,
delay_cause=DelayCause.WEATHER,
aircraft_tail="N78503",
gate="C12",
crew_ids=["CR-1005", "CR-1006", "CR-1014"],
passenger_count=152,
),
FlightData(
flight_id="UA094",
origin="ORD", destination="EWR",
scheduled_departure=datetime(2026, 4, 11, 17, 15, tzinfo=timezone.utc),
actual_departure=None,
scheduled_arrival=datetime(2026, 4, 11, 20, 30, tzinfo=timezone.utc),
actual_arrival=None,
status=FlightStatus.DELAYED,
delay_minutes=35,
delay_cause=DelayCause.WEATHER,
aircraft_tail="N78504",
gate="B8",
crew_ids=["CR-1007", "CR-1008", "CR-1015"],
passenger_count=178,
),
# Cancelled flight
FlightData(
flight_id="UA517",
origin="ORD", destination="IAH",
scheduled_departure=datetime(2026, 4, 11, 19, 0, tzinfo=timezone.utc),
actual_departure=None,
scheduled_arrival=datetime(2026, 4, 11, 21, 30, tzinfo=timezone.utc),
actual_arrival=None,
status=FlightStatus.CANCELLED,
delay_minutes=0,
delay_cause=DelayCause.WEATHER,
aircraft_tail="N78505",
gate="C18",
crew_ids=["CR-1009", "CR-1016"],
passenger_count=47,
),
# On-time flights (normal ops happening alongside disruption)
FlightData(
flight_id="UA1220",
origin="ORD", destination="IAD",
scheduled_departure=datetime(2026, 4, 11, 20, 30, tzinfo=timezone.utc),
actual_departure=None,
scheduled_arrival=datetime(2026, 4, 11, 23, 15, tzinfo=timezone.utc),
actual_arrival=None,
status=FlightStatus.ON_TIME,
delay_minutes=0,
delay_cause=None,
aircraft_tail="N78506",
gate="B15",
crew_ids=["CR-1017", "CR-1018"],
passenger_count=145,
),
FlightData(
flight_id="UA788",
origin="ORD", destination="SFO",
scheduled_departure=datetime(2026, 4, 11, 21, 0, tzinfo=timezone.utc),
actual_departure=None,
scheduled_arrival=datetime(2026, 4, 11, 23, 30, tzinfo=timezone.utc),
actual_arrival=None,
status=FlightStatus.ON_TIME,
delay_minutes=0,
delay_cause=None,
aircraft_tail="N78507",
gate="H18",
crew_ids=["CR-1019", "CR-1020"],
passenger_count=192,
),
]
# --- Crew ---
CREW: list[CrewMember] = [
# UA432 crew
CrewMember(crew_id="CR-1001", name="Capt. Rodriguez", role=CrewRole.CAPTAIN,
duty_hours_elapsed=10.5, duty_hours_limit=14.0,
rest_hours_since_last=12.0, next_scheduled_flight="UA432", base_hub="ORD"),
CrewMember(crew_id="CR-1002", name="FO Chen", role=CrewRole.FIRST_OFFICER,
duty_hours_elapsed=10.5, duty_hours_limit=14.0,
rest_hours_since_last=11.0, next_scheduled_flight="UA432", base_hub="ORD"),
# UA881 crew — captain approaching duty limit
CrewMember(crew_id="CR-1003", name="Capt. Williams", role=CrewRole.CAPTAIN,
duty_hours_elapsed=12.25, duty_hours_limit=14.0,
rest_hours_since_last=10.0, next_scheduled_flight="UA881", base_hub="ORD"),
CrewMember(crew_id="CR-1004", name="FO Patel", role=CrewRole.FIRST_OFFICER,
duty_hours_elapsed=9.0, duty_hours_limit=14.0,
rest_hours_since_last=14.0, next_scheduled_flight="UA881", base_hub="ORD"),
# UA233 crew
CrewMember(crew_id="CR-1005", name="Capt. Johnson", role=CrewRole.CAPTAIN,
duty_hours_elapsed=8.0, duty_hours_limit=14.0,
rest_hours_since_last=16.0, next_scheduled_flight="UA233", base_hub="ORD"),
CrewMember(crew_id="CR-1006", name="FO Kim", role=CrewRole.FIRST_OFFICER,
duty_hours_elapsed=7.5, duty_hours_limit=14.0,
rest_hours_since_last=15.0, next_scheduled_flight="UA233", base_hub="ORD"),
# UA094 crew
CrewMember(crew_id="CR-1007", name="Capt. Davis", role=CrewRole.CAPTAIN,
duty_hours_elapsed=6.0, duty_hours_limit=14.0,
rest_hours_since_last=18.0, next_scheduled_flight="UA094", base_hub="ORD"),
CrewMember(crew_id="CR-1008", name="FO Martinez", role=CrewRole.FIRST_OFFICER,
duty_hours_elapsed=6.0, duty_hours_limit=14.0,
rest_hours_since_last=17.0, next_scheduled_flight="UA094", base_hub="ORD"),
# UA517 crew (cancelled flight — available for reassignment)
CrewMember(crew_id="CR-1009", name="Capt. Thompson", role=CrewRole.CAPTAIN,
duty_hours_elapsed=4.0, duty_hours_limit=14.0,
rest_hours_since_last=20.0, next_scheduled_flight=None, base_hub="ORD"),
# Flight attendants
CrewMember(crew_id="CR-1010", name="FA Brooks", role=CrewRole.FA,
duty_hours_elapsed=10.0, duty_hours_limit=14.0,
rest_hours_since_last=12.0, next_scheduled_flight="UA432", base_hub="ORD"),
CrewMember(crew_id="CR-1011", name="FA Lee", role=CrewRole.FA,
duty_hours_elapsed=10.0, duty_hours_limit=14.0,
rest_hours_since_last=13.0, next_scheduled_flight="UA432", base_hub="ORD"),
CrewMember(crew_id="CR-1012", name="FA Garcia", role=CrewRole.FA,
duty_hours_elapsed=11.5, duty_hours_limit=14.0,
rest_hours_since_last=10.0, next_scheduled_flight="UA881", base_hub="ORD"),
# Backup crew
CrewMember(crew_id="CR-4421", name="Capt. Okafor", role=CrewRole.CAPTAIN,
duty_hours_elapsed=0.0, duty_hours_limit=14.0,
rest_hours_since_last=24.0, next_scheduled_flight=None, base_hub="ORD"),
]
# --- Crew Notes ---
CREW_NOTES: dict[str, list[str]] = {
"UA432": [
"Ground stop at ORD — thunderstorm line moving NE across field.",
"Inbound aircraft N78501 on ground, boarding paused at gate H14.",
"Fresh crew on board, catering complete. Ready to board on release.",
"Gate hold — no gate change expected per ops.",
],
"UA881": [
"Capt. Williams approaching duty limit — monitor closely.",
"If departure slips past 22:15 CT, mandatory crew swap required.",
"Backup crew CR-4421 (Capt. Okafor) on standby, cleared and rested.",
],
"UA233": [
"Extended delay due to weather at ORD and congestion at DEN.",
"8 connecting passengers with tight connections at DEN — rebooking may be needed.",
],
"UA094": [
"Moderate delay. EWR reporting clear conditions, delay is ORD-side only.",
],
"UA517": [
"CANCELLED — thunderstorm forecast through departure window.",
"47 passengers need rebooking. 8 Global Services pax — priority handling.",
"Rebooking options loaded in system — supervisor approval pending.",
],
}
# --- Maintenance ---
MAINTENANCE: dict[str, list[MELItem]] = {
"N78501": [
MELItem(
mel_id="MEL-ORD-001",
aircraft_tail="N78501",
system="WEATHER_RADAR",
description="Weather radar intermittent — functions normally after reset cycle.",
restriction=None,
expires=datetime(2026, 4, 15, 0, 0, tzinfo=timezone.utc),
),
],
}
# --- Passengers needing rebooking (from cancelled UA517) ---
REBOOKINGS: list[RebookingCase] = [
RebookingCase(pax_id="PAX-00341", name="J. Morrison",
original_flight="UA517", mileage_plus_status=MPStatus.GLOBAL_SERVICES,
destination="IAH", next_available="UA519", urgency="HIGH"),
RebookingCase(pax_id="PAX-00342", name="S. Nakamura",
original_flight="UA517", mileage_plus_status=MPStatus.GLOBAL_SERVICES,
destination="IAH", next_available="UA519", urgency="HIGH"),
RebookingCase(pax_id="PAX-00355", name="R. Okonkwo",
original_flight="UA517", mileage_plus_status=MPStatus.K1,
destination="IAH", next_available="UA519", urgency="HIGH"),
RebookingCase(pax_id="PAX-00360", name="M. Fernandez",
original_flight="UA517", mileage_plus_status=MPStatus.PLATINUM,
destination="IAH", next_available=None, urgency="MEDIUM"),
RebookingCase(pax_id="PAX-00371", name="K. Singh",
original_flight="UA517", mileage_plus_status=MPStatus.GOLD,
destination="IAH", next_available=None, urgency="MEDIUM"),
RebookingCase(pax_id="PAX-00380", name="L. Anderson",
original_flight="UA517", mileage_plus_status=MPStatus.GENERAL,
destination="IAH", next_available=None, urgency="LOW"),
]
# --- Passengers on disrupted flights (sample) ---
PASSENGERS: list[Passenger] = [
# UA432 passengers
Passenger(pax_id="PAX-10001", name="A. Kowalski",
mileage_plus_status=MPStatus.GLOBAL_SERVICES, flight_id="UA432",
destination="SFO", connection_flight="UA1892",
connection_deadline=datetime(2026, 4, 11, 21, 30, tzinfo=timezone.utc)),
Passenger(pax_id="PAX-10002", name="B. Tanaka",
mileage_plus_status=MPStatus.K1, flight_id="UA432",
destination="SFO"),
Passenger(pax_id="PAX-10003", name="C. Dubois",
mileage_plus_status=MPStatus.GENERAL, flight_id="UA432",
destination="SFO", special_needs=["WCHR"]),
# UA517 passengers (cancelled)
Passenger(pax_id="PAX-00341", name="J. Morrison",
mileage_plus_status=MPStatus.GLOBAL_SERVICES, flight_id="UA517",
destination="IAH"),
Passenger(pax_id="PAX-00342", name="S. Nakamura",
mileage_plus_status=MPStatus.GLOBAL_SERVICES, flight_id="UA517",
destination="IAH"),
]