from __future__ import annotations
import math
from typing import TYPE_CHECKING, Annotated, Any
from zoneinfo import ZoneInfo
from pydantic import BaseModel, field_validator
from pydantic.functional_validators import AfterValidator, BeforeValidator
from .named import Named
if TYPE_CHECKING:
from passengersim.core import Airport
[docs]
class LimitConnectTime(BaseModel, extra="forbid", validate_assignment=True):
domestic_domestic: int
"""Minimum connect time for domestic to domestic connections in minutes."""
domestic_international: int
"""Minimum connect time for domestic to international connections in minutes."""
international_domestic: int
"""Minimum connect time for international to domestic connections in minutes."""
international_international: int
"""Minimum connect time for international to international connects in minutes."""
def _inflate_simple_mct(mct: int):
if isinstance(mct, int):
return LimitConnectTime(
domestic_domestic=mct,
domestic_international=mct,
international_domestic=mct,
international_international=mct,
)
return mct
def _reformat_mct(mct: Any) -> Any:
if isinstance(mct, dict):
# reformat shorthand keys
if "DD" in mct:
mct["domestic_domestic"] = mct.pop("DD")
if "DI" in mct:
mct["domestic_international"] = mct.pop("DI")
if "ID" in mct:
mct["international_domestic"] = mct.pop("ID")
if "II" in mct:
mct["international_international"] = mct.pop("II")
if "dd" in mct:
mct["domestic_domestic"] = mct.pop("dd")
if "di" in mct:
mct["domestic_international"] = mct.pop("di")
if "id" in mct:
mct["international_domestic"] = mct.pop("id")
if "ii" in mct:
mct["international_international"] = mct.pop("ii")
elif isinstance(mct, list):
# handle list of 4 integers
mct = dict(
zip(
[
"domestic_domestic",
"domestic_international",
"international_domestic",
"international_international",
],
mct,
)
)
return mct
[docs]
class Place(Named, extra="forbid", validate_assignment=True):
name: str
"""Identifying code for this place.
For airports, typically the three letter IATA code."""
label: str
"""A descriptive label for this place."""
country: str | None = None
"""Country code.
Recommended to use ISO 3166-1 alpha-2 codes, ie. US / GB / AU / MX / etc."""
state: str | None = None
"""State code"""
lat: float | None = None
"""Latitude in degrees."""
lon: float | None = None
"""Longitude in degrees."""
time_zone: str | None = None
"""
The time zone for this location.
"""
tz_offset: int | None = None
"""Hours offset from GMT"""
mct: Annotated[
LimitConnectTime | int | None,
AfterValidator(_inflate_simple_mct),
BeforeValidator(_reformat_mct),
] = None
"""
Default Minimum Connect Time (MCT) in minutes for this location (Airport).
This can be given as a single integer, which will be applied to all
connections, or differentiated by connection type (domestic-domestic,
domestic-international, etc.). Connection types can be given using their
full name (with underscore) or using shorthand codes (DD, DI, ID, II), or
as a list of 4 integers in the order DD, DI, ID, II.
Future version of PassengerSim will also allow specific exceptions by
airline / route / etc.
"""
max_connect_time: Annotated[
LimitConnectTime | int | None,
AfterValidator(_inflate_simple_mct),
BeforeValidator(_reformat_mct),
] = None
"""
Maximum connection time (MCT) in minutes for this place.
This can be given as a single integer, which will be applied to all
connections, or differentiated by connection type (domestic-domestic,
domestic-international, etc.). Connection types can be given using their
full name (with underscore) or using shorthand codes (DD, DI, ID, II), or
as a list of 4 integers in the order DD, DI, ID, II.
If the maximum connection time for any category is missing or set to -1,
then the simulation default maximum connection time will be used for that
category.
Future version of PassengerSim will also allow specific exceptions by
airline / route / etc.
"""
tags: dict[str, str] = {}
"""Optional dictionary of tags associated with this place.
Tags can be used to store arbitrary key-value pairs of information related to
this place. This may be useful for categorization, filtering, or adding metadata.
Tags can also be used in RM actions to apply different strategies based on
place characteristics. For example, a tag could indicate whether a place is
within the Schengen area, allowing RM actions to adjust their behavior accordingly.
"""
@field_validator("time_zone")
def _valid_time_zone(cls, v: str):
"""Check for valid time zones."""
if isinstance(v, str):
ZoneInfo(v)
return v
@property
def time_zone_info(self) -> ZoneInfo | None:
if self.time_zone is None:
return None # No time zone set
return ZoneInfo(self.time_zone)
@property
def latitude(self) -> float:
"""Alias for `lat`."""
return self.lat
@property
def longitude(self) -> float:
"""Alias for `lon`."""
return self.lon
def great_circle(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Using Haversine formula, to get distance between points in miles."""
lon1 = math.radians(lon1)
lat1 = math.radians(lat1)
lon2 = math.radians(lon2)
lat2 = math.radians(lat2)
lon_diff = lon2 - lon1
lat_diff = lat2 - lat1
a = math.sin((lat_diff) / 2.0) ** 2.0 + (math.cos(lat1) * math.cos(lat2) * (math.sin((lon_diff) / 2.0) ** 2.0))
angle2 = 2.0 * math.asin(min(1.0, math.sqrt(a)))
# Convert back to degrees.
angle2 = math.degrees(angle2)
# Each degree on a great circle of Earth is 69.0468 miles. ( 60 nautical miles )
distance2 = 69.0468 * angle2
return distance2
def get_mileage(airports: dict[str, Place | Airport], orig: str, dest: str) -> float:
"""Get the distance between two places in statue miles.
Parameters
----------
airports : dict[str, Place or Airport]
A dictionary mapping airport codes to Place objects containing their location information.
orig : str
The code of the origin airport.
dest : str
The code of the destination airport.
Returns
-------
float
The great circle distance between the origin and destination airports in miles.
Returns 0 if either airport is not found in the provided dictionary.
"""
if orig not in airports or dest not in airports:
return 0
a1 = airports[orig]
a2 = airports[dest]
dist = great_circle(a1.latitude, a1.longitude, a2.latitude, a2.longitude)
return dist
def calculate_initial_bearing(lat1, lon1, lat2, lon2):
# Convert decimal degrees to radians
lat1, lon1, lat2, lon2 = map(math.radians, [lat1, lon1, lat2, lon2])
d_lon = lon2 - lon1
y = math.sin(d_lon) * math.cos(lat2)
x = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(d_lon)
initial_bearing = math.atan2(y, x)
# Convert radians to degrees and normalize to 0-360°
initial_bearing = math.degrees(initial_bearing)
return (initial_bearing + 360) % 360
def calculate_final_bearing(lat1, lon1, lat2, lon2):
initial_bearing = calculate_initial_bearing(lat2, lon2, lat1, lon1)
# reverse the bearing to get the final bearing
final_bearing = (initial_bearing + 180) % 360
return final_bearing
def calculate_mean_bearing(lat1, lon1, lat2, lon2):
initial_bearing = calculate_initial_bearing(lat1, lon1, lat2, lon2)
final_bearing = calculate_final_bearing(lat1, lon1, lat2, lon2)
mean_bearing = (final_bearing + initial_bearing) / 2
return mean_bearing
def calculate_predominant_bearing(lat1, lon1, lat2, lon2):
mean_bearing = calculate_mean_bearing(lat1, lon1, lat2, lon2)
if mean_bearing < 45:
return "N"
elif mean_bearing < 135:
return "E"
elif mean_bearing < 225:
return "S"
elif mean_bearing < 315:
return "W"
else:
return "N"