Coverage for app/reports/flight_stats.py: 100%
63 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-17 15:29 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-17 15:29 +0000
1"""Backlog: aggregate flight-statistics view.
3Pure calculation functions, kept independent of Flask routing (mirrors the
4style of reports/utilization.py) so they can be unit-tested deterministically.
5Distance/geography figures are computed from app/data/airports.csv (OurAirports)
6coordinates — a leg between two ICAO codes not in that dataset (or missing)
7simply doesn't contribute distance/geo figures, but is still counted towards
8flight_count and total_hours.
9"""
11from collections import defaultdict
12from datetime import date as _date
13from typing import Any
15from models import Flight # pyright: ignore[reportMissingImports]
16from utils import ( # pyright: ignore[reportMissingImports]
17 _load_airport_geo,
18 haversine_nm,
19)
21__all__ = ["compute_flight_stats"]
24def _flight_hours(f: Flight) -> float:
25 """Prefer the directly-logged flight_time over the counter delta —
26 mirrors reports/utilization.py's _period_stats() and the flight list
27 template's duration display."""
28 if f.flight_time is not None:
29 return float(f.flight_time)
30 if (
31 f.flight_time_counter_end is not None
32 and f.flight_time_counter_start is not None
33 ):
34 return float(f.flight_time_counter_end) - float(f.flight_time_counter_start)
35 return 0.0
38def _airport_info(icao: str, geo: dict[str, Any]) -> dict[str, Any]:
39 row = geo[icao]
40 return {
41 "icao": icao,
42 "name": row["name"],
43 "country": row["country"],
44 "region": row["region"],
45 }
48def compute_flight_stats(flights: list[Flight]) -> dict[str, Any]:
49 geo = _load_airport_geo()
51 total_hours = 0.0
52 total_distance_nm = 0.0
53 visited_icaos: set[str] = set()
54 regions_visited: set[str] = set()
55 countries_visited: set[str] = set()
56 longest_leg: dict[str, Any] | None = None
57 day_distance: dict[_date, float] = defaultdict(float)
58 day_flight_count: dict[_date, int] = defaultdict(int)
60 for f in flights:
61 total_hours += _flight_hours(f)
62 day_flight_count[f.date] += 1
64 for icao in (f.departure_icao, f.arrival_icao):
65 if icao and icao in geo:
66 visited_icaos.add(icao)
67 if geo[icao]["region"]:
68 regions_visited.add(geo[icao]["region"])
69 if geo[icao]["country"]:
70 countries_visited.add(geo[icao]["country"])
72 dep, arr = f.departure_icao, f.arrival_icao
73 if dep and arr and dep in geo and arr in geo:
74 distance = haversine_nm(
75 geo[dep]["lat"], geo[dep]["lon"], geo[arr]["lat"], geo[arr]["lon"]
76 )
77 total_distance_nm += distance
78 day_distance[f.date] += distance
79 if longest_leg is None or distance > longest_leg["distance_nm"]:
80 longest_leg = {
81 "flight_id": f.id,
82 "aircraft_id": f.aircraft_id,
83 "date": f.date,
84 "dep": dep,
85 "arr": arr,
86 "distance_nm": round(distance, 1),
87 }
89 airport_rows = [_airport_info(icao, geo) for icao in visited_icaos]
91 def _extreme(key: str, reverse: bool) -> dict[str, Any] | None:
92 if not visited_icaos:
93 return None
94 icao = (max if reverse else min)(visited_icaos, key=lambda i: geo[i][key])
95 info = _airport_info(icao, geo)
96 info[key] = geo[icao][key]
97 return info
99 def _extreme_elevation(reverse: bool) -> dict[str, Any] | None:
100 candidates = [i for i in visited_icaos if geo[i]["elevation_ft"] is not None]
101 if not candidates:
102 return None
103 icao = (max if reverse else min)(
104 candidates, key=lambda i: geo[i]["elevation_ft"]
105 )
106 info = _airport_info(icao, geo)
107 info["elevation_ft"] = geo[icao]["elevation_ft"]
108 return info
110 longest_day: dict[str, Any] | None = None
111 if day_distance:
112 best_date = max(day_distance, key=lambda d: day_distance[d])
113 longest_day = {
114 "date": best_date,
115 "distance_nm": round(day_distance[best_date], 1),
116 "flight_count": day_flight_count[best_date],
117 }
119 return {
120 "flight_count": len(flights),
121 "total_hours": round(total_hours, 1),
122 "total_distance_nm": round(total_distance_nm, 1),
123 "airports_visited": sorted(airport_rows, key=lambda r: r["icao"]),
124 "airport_count": len(visited_icaos),
125 "country_count": len(countries_visited),
126 "region_count": len(regions_visited),
127 "farthest_north": _extreme("lat", reverse=True),
128 "farthest_south": _extreme("lat", reverse=False),
129 "farthest_east": _extreme("lon", reverse=True),
130 "farthest_west": _extreme("lon", reverse=False),
131 "highest_field": _extreme_elevation(reverse=True),
132 "lowest_field": _extreme_elevation(reverse=False),
133 "longest_leg": longest_leg,
134 "longest_day": longest_day,
135 }