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

1"""Backlog: aggregate flight-statistics view. 

2 

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""" 

10 

11from collections import defaultdict 

12from datetime import date as _date 

13from typing import Any 

14 

15from models import Flight # pyright: ignore[reportMissingImports] 

16from utils import ( # pyright: ignore[reportMissingImports] 

17 _load_airport_geo, 

18 haversine_nm, 

19) 

20 

21__all__ = ["compute_flight_stats"] 

22 

23 

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 

36 

37 

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 } 

46 

47 

48def compute_flight_stats(flights: list[Flight]) -> dict[str, Any]: 

49 geo = _load_airport_geo() 

50 

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) 

59 

60 for f in flights: 

61 total_hours += _flight_hours(f) 

62 day_flight_count[f.date] += 1 

63 

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"]) 

71 

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 } 

88 

89 airport_rows = [_airport_info(icao, geo) for icao in visited_icaos] 

90 

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 

98 

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 

109 

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 } 

118 

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 }