Skip to content

Commit 2c61a1f

Browse files
author
Federico De Ponte
committed
Add regional overflight risk screening
1 parent 427ef48 commit 2c61a1f

10 files changed

Lines changed: 337 additions & 13 deletions

File tree

README.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
# opensky
22

3-
`skyroute` is an open-source flight search CLI that aggregates Google Flights, Duffel, and Amadeus results, then flags itineraries that transit through airports or countries listed in the bundled conflict-zone dataset.
3+
`skyroute` is an open-source flight search CLI that aggregates Google Flights, Duffel, and Amadeus results, then flags itineraries that transit through risky airports, land in risky countries, or appear to overfly whole-country conflict zones on regional segments.
44

55
## What The Engine Evaluates Today
66

77
- one-way itineraries returned by the configured providers
88
- airports and countries present in the itinerary
9+
- regional great-circle overflight screening for whole-country conflict zones
10+
- airport-based advisories for partial-region conflict zones
911
- risk levels from the bundled or refreshed conflict-zone dataset
1012
- price and duration scoring across single-route searches and multi-route scans
1113

12-
It does not currently analyze actual overflight paths, FIR boundaries, or airspace closures that do not appear in the itinerary itself.
14+
It does not ingest filed routings, live FIR closures, or airline-specific detours. Partial-region and FIR-style advisories without bundled geometry remain airport-based.
1315

1416
## Install
1517

@@ -143,8 +145,9 @@ CPH = 5
143145
- [EASA Conflict Zone Information Bulletins (CZIB)](https://www.easa.europa.eu/en/domains/air-operations/czibs)
144146
- [Safe Airspace](https://safeairspace.net)
145147
- FAA NOTAMs
148+
- [Natural Earth 50m admin 0 country geometry](https://www.naturalearthdata.com/)
146149

147-
The dataset maps countries and specific airports to `SAFE`, `CAUTION`, `HIGH_RISK`, and `DO_NOT_FLY`.
150+
The dataset maps countries and specific airports to `SAFE`, `CAUTION`, `HIGH_RISK`, and `DO_NOT_FLY`. Whole-country zones also use bundled country geometry for regional overflight screening.
148151

149152
Run `skyroute zones --update` to fetch the latest dataset from GitHub.
150153

@@ -155,7 +158,8 @@ This is informational only. Always check official NOTAMs and airline advisories
155158
- Google Flights often requires a residential IP. Use `--proxy` if you are running searches from a server.
156159
- Prices can differ from airline and OTA checkout pages.
157160
- The conflict-zone database is a best-effort dataset and can be incomplete or stale.
158-
- The current engine does not analyze overflight paths or FIR-level closures.
161+
- Overflight screening uses a regional great-circle proxy, not filed flight plans or live ATC reroutes.
162+
- FIR-level closures without bundled geometry still require airport-based or manual review.
159163

160164
## Release
161165

src/skyroute/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
"""skyroute - flight search with itinerary conflict-zone flagging."""
1+
"""skyroute - flight search with itinerary and overflight conflict-zone flagging."""
22

33
__version__ = "0.1.0"

src/skyroute/data/risk_country_shapes.json

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

src/skyroute/display.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,13 @@ def flights_table(
7878
risk_text = RISK_ICONS[sf.risk.risk_level]
7979
safety_cell = f"[{risk_color}]{risk_text}[/{risk_color}]"
8080

81-
if sf.risk.flagged_airports:
82-
flagged = ", ".join(a.code for a in sf.risk.flagged_airports)
81+
flagged_labels = [airport.code for airport in sf.risk.flagged_airports]
82+
flagged_labels.extend(
83+
f"OF:{overflight.country}"
84+
for overflight in sf.risk.flagged_overflights
85+
)
86+
if flagged_labels:
87+
flagged = ", ".join(flagged_labels)
8388
safety_cell += f" [dim]({flagged})[/dim]"
8489

8590
transit_note = ""

src/skyroute/models.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,17 @@ class FlaggedAirport(BaseModel):
4040
risk_level: RiskLevel
4141

4242

43+
class FlaggedOverflight(BaseModel):
44+
country: str
45+
zone_name: str
46+
risk_level: RiskLevel
47+
segment: str
48+
49+
4350
class RiskAssessment(BaseModel):
4451
risk_level: RiskLevel = RiskLevel.SAFE
4552
flagged_airports: list[FlaggedAirport] = Field(default_factory=list)
53+
flagged_overflights: list[FlaggedOverflight] = Field(default_factory=list)
4654

4755
@property
4856
def is_safe(self) -> bool:

src/skyroute/safety.py

Lines changed: 232 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,34 @@
11
from __future__ import annotations
22

33
import json
4+
import math
45
import time
56
from pathlib import Path
67

78
import airportsdata
89

9-
from skyroute.models import ConflictZone, FlaggedAirport, RiskAssessment, RiskLevel
10+
from skyroute.models import (
11+
ConflictZone,
12+
FlaggedAirport,
13+
FlaggedOverflight,
14+
RiskAssessment,
15+
RiskLevel,
16+
)
1017

1118
_BUNDLED_PATH = Path(__file__).parent / "data" / "conflict_zones.json"
19+
_COUNTRY_SHAPES_PATH = Path(__file__).parent / "data" / "risk_country_shapes.json"
1220
_CACHE_PATH = Path.home() / ".cache" / "skyroute" / "conflict_zones.json"
1321
_CACHE_MAX_AGE = 7 * 24 * 3600 # 7 days
22+
_OVERFLIGHT_PROXY_MAX_SEGMENT_KM = 2500.0
23+
_OVERFLIGHT_SAMPLE_STEP_KM = 75.0
1424

1525
_airports_db: dict[str, dict] | None = None
1626
_zones: list[ConflictZone] | None = None
1727
_country_risk: dict[str, tuple[RiskLevel, str]] | None = None
1828
_airport_risk: dict[str, tuple[RiskLevel, str]] | None = None
29+
_country_shapes: dict[str, list[list[list[tuple[float, float]]]]] | None = None
30+
_country_shape_bounds: dict[str, tuple[float, float, float, float]] | None = None
31+
_segment_risk_cache: dict[tuple[str, str], list[FlaggedOverflight]] = {}
1932
_zones_warning_computed = False
2033
_zones_warning_value: str | None = None
2134

@@ -33,11 +46,52 @@ def airport_country(iata: str) -> str | None:
3346
return info["country"] if info else None
3447

3548

49+
def airport_coords(iata: str) -> tuple[float, float] | None:
50+
db = _get_airports_db()
51+
info = db.get(iata)
52+
if not info:
53+
return None
54+
return info["lat"], info["lon"]
55+
56+
3657
def _load_zones_from_file(path: Path) -> list[ConflictZone]:
3758
data = json.loads(path.read_text())
3859
return [ConflictZone(**z) for z in data["zones"]]
3960

4061

62+
def _load_country_shapes() -> dict[str, list[list[list[tuple[float, float]]]]]:
63+
global _country_shapes, _country_shape_bounds
64+
if _country_shapes is not None and _country_shape_bounds is not None:
65+
return _country_shapes
66+
67+
data = json.loads(_COUNTRY_SHAPES_PATH.read_text())
68+
shapes: dict[str, list[list[list[tuple[float, float]]]]] = {}
69+
bounds: dict[str, tuple[float, float, float, float]] = {}
70+
for country, polygons in data["countries"].items():
71+
typed_polygons: list[list[list[tuple[float, float]]]] = []
72+
lon_values: list[float] = []
73+
lat_values: list[float] = []
74+
for polygon in polygons:
75+
typed_polygon: list[list[tuple[float, float]]] = []
76+
for ring in polygon:
77+
typed_ring = [(lon, lat) for lon, lat in ring]
78+
typed_polygon.append(typed_ring)
79+
lon_values.extend(lon for lon, _ in typed_ring)
80+
lat_values.extend(lat for _, lat in typed_ring)
81+
typed_polygons.append(typed_polygon)
82+
shapes[country] = typed_polygons
83+
bounds[country] = (
84+
min(lon_values),
85+
min(lat_values),
86+
max(lon_values),
87+
max(lat_values),
88+
)
89+
90+
_country_shapes = shapes
91+
_country_shape_bounds = bounds
92+
return _country_shapes
93+
94+
4195
def load_zones(force_bundled: bool = False) -> list[ConflictZone]:
4296
global _zones, _country_risk, _airport_risk
4397

@@ -67,16 +121,172 @@ def load_zones(force_bundled: bool = False) -> list[ConflictZone]:
67121
if existing is None or rl > existing[0]:
68122
_airport_risk[ap] = (rl, zone.name)
69123

124+
_segment_risk_cache.clear()
70125
return _zones
71126

72127

128+
def _haversine_km(a: tuple[float, float], b: tuple[float, float]) -> float:
129+
lat1, lon1 = map(math.radians, a)
130+
lat2, lon2 = map(math.radians, b)
131+
dlat = lat2 - lat1
132+
dlon = lon2 - lon1
133+
hav = (
134+
math.sin(dlat / 2) ** 2
135+
+ math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
136+
)
137+
return 6371.0 * 2 * math.asin(min(1.0, math.sqrt(hav)))
138+
139+
140+
def _to_cartesian(point: tuple[float, float]) -> tuple[float, float, float]:
141+
lat, lon = map(math.radians, point)
142+
return (
143+
math.cos(lat) * math.cos(lon),
144+
math.cos(lat) * math.sin(lon),
145+
math.sin(lat),
146+
)
147+
148+
149+
def _from_cartesian(x: float, y: float, z: float) -> tuple[float, float]:
150+
hyp = math.hypot(x, y)
151+
return (
152+
math.degrees(math.atan2(z, hyp)),
153+
math.degrees(math.atan2(y, x)),
154+
)
155+
156+
157+
def _great_circle_points(
158+
start: tuple[float, float],
159+
end: tuple[float, float],
160+
*,
161+
step_km: float = _OVERFLIGHT_SAMPLE_STEP_KM,
162+
) -> list[tuple[float, float]]:
163+
distance_km = _haversine_km(start, end)
164+
steps = max(4, math.ceil(distance_km / step_km))
165+
p1 = _to_cartesian(start)
166+
p2 = _to_cartesian(end)
167+
dot = max(-1.0, min(1.0, sum(u * v for u, v in zip(p1, p2))))
168+
delta = math.acos(dot)
169+
if delta == 0:
170+
return []
171+
172+
sin_delta = math.sin(delta)
173+
points: list[tuple[float, float]] = []
174+
for idx in range(1, steps):
175+
fraction = idx / steps
176+
a = math.sin((1 - fraction) * delta) / sin_delta
177+
b = math.sin(fraction * delta) / sin_delta
178+
x = (a * p1[0]) + (b * p2[0])
179+
y = (a * p1[1]) + (b * p2[1])
180+
z = (a * p1[2]) + (b * p2[2])
181+
mag = math.sqrt((x * x) + (y * y) + (z * z))
182+
points.append(_from_cartesian(x / mag, y / mag, z / mag))
183+
return points
184+
185+
186+
def _point_in_ring(lon: float, lat: float, ring: list[tuple[float, float]]) -> bool:
187+
inside = False
188+
for idx in range(len(ring)):
189+
x1, y1 = ring[idx]
190+
x2, y2 = ring[(idx + 1) % len(ring)]
191+
crosses = (y1 > lat) != (y2 > lat)
192+
if not crosses:
193+
continue
194+
xinters = ((x2 - x1) * (lat - y1) / ((y2 - y1) or 1e-12)) + x1
195+
if lon < xinters:
196+
inside = not inside
197+
return inside
198+
199+
200+
def _point_in_polygon(
201+
lon: float,
202+
lat: float,
203+
polygon: list[list[tuple[float, float]]],
204+
) -> bool:
205+
if not _point_in_ring(lon, lat, polygon[0]):
206+
return False
207+
for hole in polygon[1:]:
208+
if _point_in_ring(lon, lat, hole):
209+
return False
210+
return True
211+
212+
213+
def _point_in_country(country: str, lon: float, lat: float) -> bool:
214+
shapes = _load_country_shapes()
215+
if _country_shape_bounds is None:
216+
raise RuntimeError("Country shape bounds not loaded")
217+
bounds = _country_shape_bounds.get(country)
218+
if bounds is None:
219+
return False
220+
min_lon, min_lat, max_lon, max_lat = bounds
221+
if lon < min_lon or lon > max_lon or lat < min_lat or lat > max_lat:
222+
return False
223+
return any(
224+
_point_in_polygon(lon, lat, polygon)
225+
for polygon in shapes.get(country, [])
226+
)
227+
228+
229+
def _segment_overflights(
230+
departure_airport: str,
231+
arrival_airport: str,
232+
) -> list[FlaggedOverflight]:
233+
cache_key = (departure_airport, arrival_airport)
234+
if cache_key in _segment_risk_cache:
235+
return list(_segment_risk_cache[cache_key])
236+
237+
if _country_risk is None:
238+
raise RuntimeError("Conflict zones not loaded")
239+
240+
departure_coords = airport_coords(departure_airport)
241+
arrival_coords = airport_coords(arrival_airport)
242+
if departure_coords is None or arrival_coords is None:
243+
_segment_risk_cache[cache_key] = []
244+
return []
245+
246+
distance_km = _haversine_km(departure_coords, arrival_coords)
247+
if distance_km > _OVERFLIGHT_PROXY_MAX_SEGMENT_KM:
248+
_segment_risk_cache[cache_key] = []
249+
return []
250+
251+
sampled_points = _great_circle_points(departure_coords, arrival_coords)
252+
if not sampled_points:
253+
_segment_risk_cache[cache_key] = []
254+
return []
255+
256+
departure_country = airport_country(departure_airport)
257+
arrival_country = airport_country(arrival_airport)
258+
findings: list[FlaggedOverflight] = []
259+
for country, (risk_level, zone_name) in _country_risk.items():
260+
if country in {departure_country, arrival_country}:
261+
continue
262+
if country not in _load_country_shapes():
263+
continue
264+
if any(
265+
_point_in_country(country, lon, lat)
266+
for lat, lon in sampled_points
267+
):
268+
findings.append(
269+
FlaggedOverflight(
270+
country=country,
271+
zone_name=zone_name,
272+
risk_level=risk_level,
273+
segment=f"{departure_airport} -> {arrival_airport}",
274+
)
275+
)
276+
277+
_segment_risk_cache[cache_key] = findings
278+
return list(findings)
279+
280+
73281
def check_route(airports: list[str]) -> RiskAssessment:
74282
load_zones()
75283
if _country_risk is None or _airport_risk is None:
76284
raise RuntimeError("Conflict zones not loaded")
77285

78286
worst = RiskLevel.SAFE
79287
flagged: list[FlaggedAirport] = []
288+
flagged_overflights: list[FlaggedOverflight] = []
289+
seen_overflights: set[tuple[str, str, str]] = set()
80290

81291
for code in airports:
82292
# Check specific airport first
@@ -103,7 +313,21 @@ def check_route(airports: list[str]) -> RiskAssessment:
103313
if rl > worst:
104314
worst = rl
105315

106-
return RiskAssessment(risk_level=worst, flagged_airports=flagged)
316+
for departure_airport, arrival_airport in zip(airports, airports[1:]):
317+
for finding in _segment_overflights(departure_airport, arrival_airport):
318+
key = (finding.country, finding.zone_name, finding.segment)
319+
if key in seen_overflights:
320+
continue
321+
seen_overflights.add(key)
322+
flagged_overflights.append(finding)
323+
if finding.risk_level > worst:
324+
worst = finding.risk_level
325+
326+
return RiskAssessment(
327+
risk_level=worst,
328+
flagged_airports=flagged,
329+
flagged_overflights=flagged_overflights,
330+
)
107331

108332

109333
def zones_age_warning() -> str | None:
@@ -143,5 +367,10 @@ def save_cached_zones(data: str) -> None:
143367
_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
144368
_CACHE_PATH.write_text(data)
145369
# Force reload
146-
global _zones
370+
global _zones, _country_risk, _airport_risk, _zones_warning_computed, _zones_warning_value
147371
_zones = None
372+
_country_risk = None
373+
_airport_risk = None
374+
_segment_risk_cache.clear()
375+
_zones_warning_computed = False
376+
_zones_warning_value = None

0 commit comments

Comments
 (0)