aero-agent-roles 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/NOTICE +18 -0
  2. package/README.md +49 -0
  3. package/bin/aero-agent-roles.js +127 -0
  4. package/lib/catalog.js +97 -0
  5. package/lib/install.js +48 -0
  6. package/lib/mcp.js +199 -0
  7. package/manifest.json +516 -0
  8. package/package.json +44 -0
  9. package/roles/aerodynamics-engineer/ROLE.md +153 -0
  10. package/roles/aerodynamics-engineer/SOURCES.md +12 -0
  11. package/roles/aerodynamics-engineer/cli.py +143 -0
  12. package/roles/aerodynamics-engineer/core/aerodynamics_core.py +1218 -0
  13. package/roles/aerodynamics-engineer/templates/aero-design-report-template.md +72 -0
  14. package/roles/aerodynamics-engineer/tests/test_aerodynamics_core.py +227 -0
  15. package/roles/aerodynamics-engineer/tests/test_role_aerodynamics.py +94 -0
  16. package/roles/aircraft-design-engineer/ROLE.md +138 -0
  17. package/roles/aircraft-design-engineer/SOURCES.md +12 -0
  18. package/roles/aircraft-design-engineer/cli.py +152 -0
  19. package/roles/aircraft-design-engineer/core/aircraft_design_core.py +1278 -0
  20. package/roles/aircraft-design-engineer/templates/concept-design-template.md +99 -0
  21. package/roles/aircraft-design-engineer/tests/test_aircraft_design_core.py +269 -0
  22. package/roles/aircraft-design-engineer/tests/test_role_aircraft_design_engineer.py +106 -0
  23. package/roles/airworthiness-compliance-engineer/ROLE.md +122 -0
  24. package/roles/airworthiness-compliance-engineer/SOURCES.md +16 -0
  25. package/roles/airworthiness-compliance-engineer/cli.py +146 -0
  26. package/roles/airworthiness-compliance-engineer/core/airworthiness_core.py +894 -0
  27. package/roles/airworthiness-compliance-engineer/templates/compliance-matrix-template.md +46 -0
  28. package/roles/airworthiness-compliance-engineer/tests/test_airworthiness_core.py +224 -0
  29. package/roles/airworthiness-compliance-engineer/tests/test_role_airworthiness.py +85 -0
  30. package/roles/as9100-quality-auditor/ROLE.md +128 -0
  31. package/roles/as9100-quality-auditor/SOURCES.md +16 -0
  32. package/roles/as9100-quality-auditor/cli.py +136 -0
  33. package/roles/as9100-quality-auditor/core/as9100_core.py +857 -0
  34. package/roles/as9100-quality-auditor/templates/findings-report-template.md +119 -0
  35. package/roles/as9100-quality-auditor/tests/test_as9100_cli_profile.py +105 -0
  36. package/roles/as9100-quality-auditor/tests/test_as9100_core.py +204 -0
  37. package/roles/as9100-quality-auditor/tests/test_role_as9100.py +90 -0
  38. package/roles/data-bus-avionics-engineer/ROLE.md +146 -0
  39. package/roles/data-bus-avionics-engineer/SOURCES.md +23 -0
  40. package/roles/data-bus-avionics-engineer/cli.py +311 -0
  41. package/roles/data-bus-avionics-engineer/core/data_bus_avionics_core.py +1121 -0
  42. package/roles/data-bus-avionics-engineer/templates/bus-assessment-template.md +134 -0
  43. package/roles/data-bus-avionics-engineer/tests/test_bundle_protocol.py +137 -0
  44. package/roles/data-bus-avionics-engineer/tests/test_data_bus_avionics_core.py +261 -0
  45. package/roles/data-bus-avionics-engineer/tests/test_role_data_bus_avionics_engineer.py +121 -0
  46. package/roles/do160-environmental-engineer/ROLE.md +136 -0
  47. package/roles/do160-environmental-engineer/SOURCES.md +30 -0
  48. package/roles/do160-environmental-engineer/cli.py +147 -0
  49. package/roles/do160-environmental-engineer/core/do160_environmental_core.py +1246 -0
  50. package/roles/do160-environmental-engineer/templates/environmental-qualification-plan-template.md +114 -0
  51. package/roles/do160-environmental-engineer/tests/test_do160_environmental_core.py +312 -0
  52. package/roles/do160-environmental-engineer/tests/test_role_do160_environmental_engineer.py +281 -0
  53. package/roles/do178c-cert-engineer/ROLE.md +120 -0
  54. package/roles/do178c-cert-engineer/SOURCES.md +16 -0
  55. package/roles/do178c-cert-engineer/cli.py +129 -0
  56. package/roles/do178c-cert-engineer/core/do178c_core.py +411 -0
  57. package/roles/do178c-cert-engineer/templates/psac-template.md +74 -0
  58. package/roles/do178c-cert-engineer/tests/test_do178c_cert_engineer_core.py +91 -0
  59. package/roles/do178c-cert-engineer/tests/test_role_do178c.py +109 -0
  60. package/roles/do254-hardware-engineer/ROLE.md +127 -0
  61. package/roles/do254-hardware-engineer/SOURCES.md +19 -0
  62. package/roles/do254-hardware-engineer/cli.py +308 -0
  63. package/roles/do254-hardware-engineer/core/do254_hardware_core.py +761 -0
  64. package/roles/do254-hardware-engineer/templates/phac-template.md +87 -0
  65. package/roles/do254-hardware-engineer/tests/test_do254_bundle_profile.py +152 -0
  66. package/roles/do254-hardware-engineer/tests/test_do254_hardware_core.py +200 -0
  67. package/roles/do254-hardware-engineer/tests/test_role_do254_hardware.py +108 -0
  68. package/roles/engineering-analysis-engineer/ROLE.md +154 -0
  69. package/roles/engineering-analysis-engineer/SOURCES.md +15 -0
  70. package/roles/engineering-analysis-engineer/cli.py +151 -0
  71. package/roles/engineering-analysis-engineer/core/engineering_analysis_engineer_core.py +866 -0
  72. package/roles/engineering-analysis-engineer/templates/analysis-memo-template.md +73 -0
  73. package/roles/engineering-analysis-engineer/tests/test_engineering_analysis_engineer_core.py +367 -0
  74. package/roles/engineering-analysis-engineer/tests/test_role_engineering_analysis_engineer.py +113 -0
  75. package/roles/flight-management-engineer/ROLE.md +160 -0
  76. package/roles/flight-management-engineer/SOURCES.md +30 -0
  77. package/roles/flight-management-engineer/cli.py +258 -0
  78. package/roles/flight-management-engineer/core/flight_management_core.py +1473 -0
  79. package/roles/flight-management-engineer/templates/route-assessment-template.md +94 -0
  80. package/roles/flight-management-engineer/tests/test_flight_management_core.py +437 -0
  81. package/roles/flight-management-engineer/tests/test_role_flight_management_engineer.py +123 -0
  82. package/roles/flight-mechanics-engineer/ROLE.md +162 -0
  83. package/roles/flight-mechanics-engineer/SOURCES.md +51 -0
  84. package/roles/flight-mechanics-engineer/cli.py +149 -0
  85. package/roles/flight-mechanics-engineer/core/flight_mechanics_core.py +1507 -0
  86. package/roles/flight-mechanics-engineer/templates/perf-sc-report-template.md +82 -0
  87. package/roles/flight-mechanics-engineer/tests/test_flight_mechanics_core.py +277 -0
  88. package/roles/flight-mechanics-engineer/tests/test_role_flight_mechanics_engineer.py +141 -0
  89. package/roles/flight-test-engineer/ROLE.md +137 -0
  90. package/roles/flight-test-engineer/SOURCES.md +24 -0
  91. package/roles/flight-test-engineer/cli.py +135 -0
  92. package/roles/flight-test-engineer/core/flight_test_core.py +931 -0
  93. package/roles/flight-test-engineer/templates/flight-test-template.md +143 -0
  94. package/roles/flight-test-engineer/tests/test_flight_test_engineer_core.py +227 -0
  95. package/roles/flight-test-engineer/tests/test_role_flight_test_engineer.py +150 -0
  96. package/roles/flight-test-performance-engineer/ROLE.md +165 -0
  97. package/roles/flight-test-performance-engineer/SOURCES.md +20 -0
  98. package/roles/flight-test-performance-engineer/cli.py +319 -0
  99. package/roles/flight-test-performance-engineer/core/flight_test_performance_core.py +1398 -0
  100. package/roles/flight-test-performance-engineer/templates/performance-report-template.md +105 -0
  101. package/roles/flight-test-performance-engineer/tests/test_flight_test_performance_engineer_core.py +308 -0
  102. package/roles/flight-test-performance-engineer/tests/test_role_flight_test_performance_engineer.py +165 -0
  103. package/roles/gnc-engineer/ROLE.md +141 -0
  104. package/roles/gnc-engineer/SOURCES.md +35 -0
  105. package/roles/gnc-engineer/cli.py +141 -0
  106. package/roles/gnc-engineer/core/gnc_core.py +1074 -0
  107. package/roles/gnc-engineer/templates/gnc-report-template.md +105 -0
  108. package/roles/gnc-engineer/tests/test_gnc_engineer_core.py +238 -0
  109. package/roles/gnc-engineer/tests/test_role_gnc_engineer.py +142 -0
  110. package/roles/guidance-engineer/ROLE.md +156 -0
  111. package/roles/guidance-engineer/SOURCES.md +43 -0
  112. package/roles/guidance-engineer/cli.py +280 -0
  113. package/roles/guidance-engineer/core/guidance_core.py +820 -0
  114. package/roles/guidance-engineer/templates/guidance-report-template.md +70 -0
  115. package/roles/guidance-engineer/tests/test_guidance_engineer_core.py +269 -0
  116. package/roles/guidance-engineer/tests/test_role_guidance_engineer.py +146 -0
  117. package/roles/high-speed-aerodynamics-engineer/ROLE.md +161 -0
  118. package/roles/high-speed-aerodynamics-engineer/SOURCES.md +18 -0
  119. package/roles/high-speed-aerodynamics-engineer/cli.py +282 -0
  120. package/roles/high-speed-aerodynamics-engineer/core/high_speed_aero_core.py +1566 -0
  121. package/roles/high-speed-aerodynamics-engineer/templates/high-speed-memo-template.md +75 -0
  122. package/roles/high-speed-aerodynamics-engineer/tests/test_bundle_protocol.py +103 -0
  123. package/roles/high-speed-aerodynamics-engineer/tests/test_high_speed_aero_core.py +280 -0
  124. package/roles/high-speed-aerodynamics-engineer/tests/test_role_high_speed_aerodynamics.py +125 -0
  125. package/roles/ndt-engineer/ROLE.md +157 -0
  126. package/roles/ndt-engineer/SOURCES.md +18 -0
  127. package/roles/ndt-engineer/cli.py +316 -0
  128. package/roles/ndt-engineer/core/ndt_core.py +1590 -0
  129. package/roles/ndt-engineer/templates/ndt-plan-template.md +109 -0
  130. package/roles/ndt-engineer/tests/test_ndt_cli_bundle.py +183 -0
  131. package/roles/ndt-engineer/tests/test_ndt_core.py +437 -0
  132. package/roles/ndt-engineer/tests/test_role_ndt_engineer.py +148 -0
  133. package/roles/numerical-analysis-engineer/ROLE.md +158 -0
  134. package/roles/numerical-analysis-engineer/SOURCES.md +19 -0
  135. package/roles/numerical-analysis-engineer/cli.py +414 -0
  136. package/roles/numerical-analysis-engineer/core/numerical_analysis_core.py +1220 -0
  137. package/roles/numerical-analysis-engineer/templates/verification-memo-template.md +120 -0
  138. package/roles/numerical-analysis-engineer/tests/test_bundle_protocol.py +157 -0
  139. package/roles/numerical-analysis-engineer/tests/test_numerical_analysis_core.py +337 -0
  140. package/roles/numerical-analysis-engineer/tests/test_role_numerical_analysis_engineer.py +117 -0
  141. package/roles/propulsion-engineer/ROLE.md +163 -0
  142. package/roles/propulsion-engineer/SOURCES.md +15 -0
  143. package/roles/propulsion-engineer/cli.py +168 -0
  144. package/roles/propulsion-engineer/core/propulsion_core.py +1291 -0
  145. package/roles/propulsion-engineer/templates/propulsion-report-template.md +92 -0
  146. package/roles/propulsion-engineer/tests/test_propulsion_engineer_core.py +294 -0
  147. package/roles/propulsion-engineer/tests/test_role_propulsion_engineer.py +106 -0
  148. package/roles/space-systems-engineer/ROLE.md +161 -0
  149. package/roles/space-systems-engineer/SOURCES.md +49 -0
  150. package/roles/space-systems-engineer/cli.py +173 -0
  151. package/roles/space-systems-engineer/core/space_systems_core.py +980 -0
  152. package/roles/space-systems-engineer/templates/space-systems-template.md +88 -0
  153. package/roles/space-systems-engineer/tests/test_role_space_systems_engineer.py +117 -0
  154. package/roles/space-systems-engineer/tests/test_space_systems_engineer_core.py +247 -0
  155. package/roles/state-estimation-engineer/ROLE.md +154 -0
  156. package/roles/state-estimation-engineer/SOURCES.md +27 -0
  157. package/roles/state-estimation-engineer/cli.py +326 -0
  158. package/roles/state-estimation-engineer/core/state_estimation_core.py +958 -0
  159. package/roles/state-estimation-engineer/templates/nav-state-estimator-design-template.md +76 -0
  160. package/roles/state-estimation-engineer/tests/test_bundle_protocol.py +110 -0
  161. package/roles/state-estimation-engineer/tests/test_role_state_estimation_engineer.py +127 -0
  162. package/roles/state-estimation-engineer/tests/test_state_estimation_engineer_core.py +231 -0
  163. package/roles/structures-loads-engineer/ROLE.md +171 -0
  164. package/roles/structures-loads-engineer/SOURCES.md +44 -0
  165. package/roles/structures-loads-engineer/cli.py +219 -0
  166. package/roles/structures-loads-engineer/core/structures_loads_core.py +1112 -0
  167. package/roles/structures-loads-engineer/templates/loads-strength-report-template.md +96 -0
  168. package/roles/structures-loads-engineer/tests/test_bundle_protocol.py +101 -0
  169. package/roles/structures-loads-engineer/tests/test_role_structures_loads_engineer.py +147 -0
  170. package/roles/structures-loads-engineer/tests/test_structures_loads_core.py +261 -0
@@ -0,0 +1,1473 @@
1
+ #!/usr/bin/env python3
2
+ """flight_management_core.py - Flight Management Engineer executable core.
3
+
4
+ This is the role's ENGINE. Given a candidate route's project facts
5
+ (waypoints, leg types, RNP value, hold data, RTA demand, aircraft
6
+ performance inputs) it computes the RNAV/RNP route assessment content:
7
+
8
+ - route structure: great-circle leg tracks/distances (lateral-navigation
9
+ leaf math), total track distance (flight-planning), rhumb-line vs
10
+ great-circle distance comparison (rhumb-line-leg);
11
+ - RF (radius-to-fix) leg construction: turn centre, swept angle, arc
12
+ length, exit track, chord (radius-to-fix-leg);
13
+ - DME arc leg checks: arc length, chord, bank angle to hold the arc,
14
+ turn radius (dme-arc-leg);
15
+ - RNP containment: ANP from the lateral 1-sigma error (95% containment =
16
+ 2 x sigma), required margin, pass/fail verdict (rnp-anp-containment);
17
+ - holding pattern entry: 70/110 sector rule, outbound leg timing,
18
+ 1-in-60 wind correction, entry lap estimate (holding-pattern-entry);
19
+ - RTA time control: ETA, required ground speed/Mach, achievable window,
20
+ feasibility verdict (rta-time-control);
21
+ - VNAV vertical profile: top of descent, descent gradient, flight path
22
+ angle, altitude at a fix, constraint verdict (vertical-navigation +
23
+ flight-planning constraints), ECON cruise speed from the cost index
24
+ (performance-computation);
25
+ - radio navaid geometry: DME slant range, VOR bearing/radial
26
+ (radio-navigation-aids).
27
+
28
+ Every formula below is mirrored from the bound AeroSkills leaf logic
29
+ (avionics/flight-management/<leaf>/scripts/*_logic.py) or is a standard
30
+ public geometric/physical equation (spherical great-circle, coordinated
31
+ turn, ISA atmosphere). No proprietary standard text is reproduced; RNP /
32
+ RNP AR / PBN behaviour is summarized from public guidance (FAA AC
33
+ 90-105A, AC 90-101A) and the RTCA DO-236C family is referenced
34
+ summary-only (see SOURCES.md). Constants are the leaf constants; the
35
+ performance model is the leaves' documented simplified mid-size
36
+ transport model and must be calibrated per aircraft.
37
+
38
+ Standalone: stdlib only, deterministic, offline, no AeroSkills needed.
39
+ """
40
+ from __future__ import annotations
41
+
42
+ import math
43
+ from dataclasses import dataclass, field
44
+ from datetime import date
45
+
46
+ def _today() -> str:
47
+ import os
48
+ from datetime import date
49
+ return os.environ.get("ROLE_GEN_DATE", date.today().isoformat())
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Physical / unit constants (SI base where noted)
53
+ # ---------------------------------------------------------------------------
54
+ NM_TO_M = 1852.0 # nautical mile to metre
55
+ M_TO_NM = 1.0 / NM_TO_M
56
+ KT_TO_MS = 0.514444 # knot to m/s
57
+ G_MS2 = 9.80665 # standard gravity, m/s^2
58
+ R_EARTH_M = 6371000.0 # spherical Earth radius (WGS-84 mean), m
59
+ FT_TO_M = 0.3048 # foot to metre
60
+ NM_TO_FT = 6076.1154 # feet per nautical mile
61
+ DEG_LAT_M = math.pi * R_EARTH_M / 180.0 # metres per degree of latitude
62
+
63
+ # ISA atmosphere (rta-time-control / performance-computation leaves)
64
+ GAMMA_AIR = 1.4 # ratio of specific heats, air
65
+ R_AIR = 287.05 # specific gas constant, J/(kg K)
66
+ ISA_SL_TEMP_K = 288.15 # ISA sea-level temperature, K
67
+ ISA_LAPSE_K_PER_M = 0.0065 # ISA troposphere lapse rate, K/m
68
+ ISA_TROPOPAUSE_M = 11000.0 # tropopause altitude, m
69
+
70
+ # RTA acceptance tolerance (rta-time-control leaf)
71
+ RTA_TIME_TOL_S = 5.0
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # RNP / ANP containment (bound leaf: rnp-anp-containment)
75
+ # ---------------------------------------------------------------------------
76
+ CONTAINMENT_SIGMA = 2.0 # ANP = 95th-percentile bound = 2 x 1-sigma error
77
+ VALID_VERDICTS = ("PASS", "FAIL")
78
+
79
+
80
+ def anp_from_sigma(sigma_lateral_m):
81
+ """ANP as the 95th percentile containment bound: 2 * sigma (m)."""
82
+ if sigma_lateral_m is None or sigma_lateral_m < 0.0:
83
+ raise ValueError("sigma_lateral_m must be non-negative")
84
+ return CONTAINMENT_SIGMA * float(sigma_lateral_m)
85
+
86
+
87
+ def containment_margin_m(rnp_m, margin_fraction=0.0):
88
+ """Required margin in metres: rnp * margin_fraction."""
89
+ if rnp_m is None or rnp_m <= 0.0:
90
+ raise ValueError("rnp_m must be positive")
91
+ if margin_fraction is None or margin_fraction < 0.0:
92
+ raise ValueError("margin_fraction must be non-negative")
93
+ return float(rnp_m) * margin_fraction
94
+
95
+
96
+ def containment_pass(anp_m, rnp_m, margin_fraction=0.0):
97
+ """True when anp + required margin <= rnp (boundary inclusive)."""
98
+ if anp_m is None or anp_m < 0.0:
99
+ raise ValueError("anp_m must be non-negative")
100
+ return (float(anp_m) + containment_margin_m(rnp_m, margin_fraction)
101
+ <= float(rnp_m))
102
+
103
+
104
+ def margin_available_m(anp_m, rnp_m, margin_fraction=0.0):
105
+ """Reserve left after the required margin: rnp - margin - anp (m)."""
106
+ if anp_m is None or anp_m < 0.0:
107
+ raise ValueError("anp_m must be non-negative")
108
+ return (float(rnp_m) - containment_margin_m(rnp_m, margin_fraction)
109
+ - float(anp_m))
110
+
111
+
112
+ def rnp_nm_to_m(rnp_nm):
113
+ """Convert an RNP value in NM to metres (RNP 0.3 = 555.6 m)."""
114
+ if rnp_nm is None or rnp_nm <= 0.0:
115
+ raise ValueError("rnp_nm must be positive")
116
+ return float(rnp_nm) * NM_TO_M
117
+
118
+
119
+ def containment_analysis(sigma_lateral_m=None, anp_m=None, rnp_m=0.3 * NM_TO_M,
120
+ margin_fraction=0.0):
121
+ """Full containment check; anp taken directly or derived as 2*sigma.
122
+
123
+ Returns {anp_m, rnp_m, required_margin_m, margin_available_m,
124
+ pass, verdict}. Mirrors the bound rnp-anp-containment leaf analyze().
125
+ """
126
+ if anp_m is not None:
127
+ anp = anp_from_sigma(anp_m / CONTAINMENT_SIGMA)
128
+ else:
129
+ if sigma_lateral_m is None:
130
+ raise ValueError("supply sigma_lateral_m or anp_m")
131
+ anp = anp_from_sigma(sigma_lateral_m)
132
+ rnp = float(rnp_m)
133
+ margin = containment_margin_m(rnp, margin_fraction)
134
+ ok = containment_pass(anp, rnp, margin_fraction)
135
+ return {
136
+ "anp_m": round(anp, 3),
137
+ "anp_nm": round(anp * M_TO_NM, 4),
138
+ "rnp_m": round(rnp, 3),
139
+ "rnp_nm": round(rnp * M_TO_NM, 4),
140
+ "required_margin_m": round(margin, 3),
141
+ "margin_available_m": round(margin_available_m(anp, rnp,
142
+ margin_fraction), 3),
143
+ "pass": bool(ok),
144
+ "verdict": "PASS" if ok else "FAIL",
145
+ }
146
+
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # Holding pattern entry (bound leaf: holding-pattern-entry)
150
+ # ---------------------------------------------------------------------------
151
+ HOLD_DIRECT_SECTOR_LIMIT_DEG = 70.0
152
+ HOLD_TEARDROP_SECTOR_LIMIT_DEG = 110.0
153
+ VALID_ENTRY_TYPES = ("direct", "teardrop", "parallel")
154
+ VALID_TURN_DIRECTIONS = ("right", "left")
155
+ HOLD_LEG_ALTITUDE_THRESHOLD_FT = 14000.0
156
+ LOW_ALTITUDE_LEG_S = 60.0
157
+ HIGH_ALTITUDE_LEG_S = 90.0
158
+ HOLD_ENTRY_LAP_OFFSET_S = {
159
+ "direct": 3 * 60.0, "teardrop": 4 * 60.0, "parallel": 5 * 60.0}
160
+
161
+
162
+ def _wrap_deg(angle):
163
+ return angle % 360.0
164
+
165
+
166
+ def hold_alpha_deg(arrival_track_deg, hold_inbound_course_deg):
167
+ """Approach angle to the holding axis, [0, 180].
168
+
169
+ alpha is the smaller angle between the aircraft's track INTO the fix
170
+ (the arrival leg track) and the holding pattern axis (the inbound
171
+ course of the hold). Dead-on approach = 0 deg; approach along the
172
+ outbound-course direction = 180 deg. This is the standard
173
+ 70/110-sector-rule basis: the sector classification consumes alpha
174
+ measured on the holding side (the bound holding-pattern-entry leaf
175
+ documents the same input convention).
176
+ """
177
+ diff = abs(_wrap_deg(arrival_track_deg) - _wrap_deg(hold_inbound_course_deg))
178
+ alpha = diff if diff <= 180.0 else 360.0 - diff
179
+ return alpha
180
+
181
+
182
+ def holding_entry_type(alpha_deg, turn_direction="right"):
183
+ """Classify entry as direct / teardrop / parallel (70/110 rule).
184
+
185
+ alpha <= 70 deg: direct; 70 < alpha <= 110 deg: teardrop; alpha >
186
+ 110 deg: parallel. alpha measured on the holding side in [0, 180];
187
+ a left-hand hold mirrors the sectors and the same thresholds apply.
188
+ """
189
+ if turn_direction not in VALID_TURN_DIRECTIONS:
190
+ raise ValueError("turn_direction must be 'right' or 'left'")
191
+ if alpha_deg < 0.0 or alpha_deg > 180.0:
192
+ raise ValueError("alpha_deg must lie in [0, 180]")
193
+ if alpha_deg <= HOLD_DIRECT_SECTOR_LIMIT_DEG:
194
+ return "direct"
195
+ if alpha_deg <= HOLD_TEARDROP_SECTOR_LIMIT_DEG:
196
+ return "teardrop"
197
+ return "parallel"
198
+
199
+
200
+ def outbound_leg_seconds(altitude_ft):
201
+ """Outbound leg timing: 60 s at/below 14000 ft, 90 s above."""
202
+ if altitude_ft is None or altitude_ft < 0.0:
203
+ raise ValueError("altitude_ft must be non-negative")
204
+ if altitude_ft <= HOLD_LEG_ALTITUDE_THRESHOLD_FT:
205
+ return LOW_ALTITUDE_LEG_S
206
+ return HIGH_ALTITUDE_LEG_S
207
+
208
+
209
+ def wind_corrected_heading(outbound_heading_deg, wind_from_deg,
210
+ wind_speed_kt, tas_kt):
211
+ """1-in-60 crosswind correction of the outbound heading, [0, 360)."""
212
+ if tas_kt is None or tas_kt <= 0.0:
213
+ raise ValueError("tas_kt must be positive")
214
+ if wind_speed_kt is None or wind_speed_kt < 0.0:
215
+ raise ValueError("wind_speed_kt must be non-negative")
216
+ crosswind = (wind_speed_kt
217
+ * math.sin(math.radians(wind_from_deg - outbound_heading_deg)))
218
+ correction = 60.0 * crosswind / tas_kt
219
+ return _wrap_deg(outbound_heading_deg + correction), correction
220
+
221
+
222
+ def holding_entry_lap_seconds(entry, outbound_seconds):
223
+ """First (entry) lap time: one timed outbound leg + sector offset."""
224
+ if entry not in VALID_ENTRY_TYPES:
225
+ raise ValueError("entry must be direct/teardrop/parallel")
226
+ if outbound_seconds <= 0.0:
227
+ raise ValueError("outbound_seconds must be positive")
228
+ return outbound_seconds + HOLD_ENTRY_LAP_OFFSET_S[entry]
229
+
230
+
231
+ def hold_entry_analysis(arrival_track_deg, hold_inbound_course_deg,
232
+ turn_direction, altitude_ft, outbound_heading_deg,
233
+ wind_from_deg, wind_speed_kt, tas_kt):
234
+ """Full holding assessment dict for a fix on the route."""
235
+ alpha = hold_alpha_deg(arrival_track_deg, hold_inbound_course_deg)
236
+ entry = holding_entry_type(alpha, turn_direction)
237
+ leg_s = outbound_leg_seconds(altitude_ft)
238
+ corrected, correction_raw = wind_corrected_heading(
239
+ outbound_heading_deg, wind_from_deg, wind_speed_kt, tas_kt)
240
+ return {
241
+ "arrival_track_deg": round(float(arrival_track_deg) % 360.0, 1),
242
+ "hold_inbound_course_deg": round(float(hold_inbound_course_deg) % 360.0, 1),
243
+ "alpha_deg": round(alpha, 1),
244
+ "turn_direction": turn_direction,
245
+ "entry": entry,
246
+ "hold_altitude_ft": int(altitude_ft),
247
+ "outbound_leg_s": leg_s,
248
+ "outbound_heading_deg": round(float(outbound_heading_deg) % 360.0, 1),
249
+ "wind_from_deg": float(wind_from_deg) % 360.0,
250
+ "wind_speed_kt": float(wind_speed_kt),
251
+ "tas_kt": float(tas_kt),
252
+ "wind_corrected_heading_deg": round(corrected, 2),
253
+ "wind_correction_deg": round(correction_raw, 2),
254
+ "entry_lap_s": holding_entry_lap_seconds(entry, leg_s),
255
+ "direct_sector_deg": HOLD_DIRECT_SECTOR_LIMIT_DEG,
256
+ "teardrop_sector_deg": HOLD_TEARDROP_SECTOR_LIMIT_DEG,
257
+ }
258
+
259
+
260
+ # ---------------------------------------------------------------------------
261
+ # Great-circle lateral geometry (bound leaf: lateral-navigation)
262
+ # ---------------------------------------------------------------------------
263
+ def _check_lat(lat, name="lat"):
264
+ if lat is None or not math.isfinite(float(lat)) \
265
+ or abs(float(lat)) > 90.0:
266
+ raise ValueError("%s out of range [-90, 90]" % name)
267
+
268
+
269
+ def great_circle_distance_m(lat1, lon1, lat2, lon2):
270
+ """Spherical great-circle distance in metres (R = 6371000 m)."""
271
+ _check_lat(lat1, "lat1")
272
+ _check_lat(lat2, "lat2")
273
+ p1, p2 = math.radians(lat1), math.radians(lat2)
274
+ d_lon = math.radians(lon2 - lon1)
275
+ cos_sigma = (math.sin(p1) * math.sin(p2)
276
+ + math.cos(p1) * math.cos(p2) * math.cos(d_lon))
277
+ cos_sigma = max(-1.0, min(1.0, cos_sigma))
278
+ return R_EARTH_M * math.acos(cos_sigma)
279
+
280
+
281
+ def great_circle_track_deg(lat1, lon1, lat2, lon2):
282
+ """Initial great-circle track in true degrees [0, 360)."""
283
+ _check_lat(lat1, "lat1")
284
+ _check_lat(lat2, "lat2")
285
+ if lat1 == lat2 and lon1 == lon2:
286
+ raise ValueError("identical positions: track undefined")
287
+ d_lon = math.radians(lon2 - lon1)
288
+ y = math.sin(d_lon) * math.cos(math.radians(lat2))
289
+ x = (math.cos(math.radians(lat1)) * math.sin(math.radians(lat2))
290
+ - math.sin(math.radians(lat1)) * math.cos(math.radians(lat2))
291
+ * math.cos(d_lon))
292
+ return math.degrees(math.atan2(y, x)) % 360.0
293
+
294
+
295
+ def _offset_waypoint(lat, lon, x_east_nm, y_north_nm):
296
+ """Approximate lat/lon of a local tangent-plane offset (NM) at (lat, lon).
297
+
298
+ Local frame: x = east (NM), y = north (NM); the same short-range
299
+ tangent-plane convention used by the bound radius-to-fix leaf. The
300
+ longitude scaling uses the mid-latitude cosine. Documented short-range
301
+ approximation only.
302
+ """
303
+ dlat = y_north_nm * NM_TO_M / DEG_LAT_M
304
+ mid_lat = lat + dlat / 2.0
305
+ dlon = (x_east_nm * NM_TO_M
306
+ / (DEG_LAT_M * math.cos(math.radians(mid_lat))))
307
+ return lat + dlat, lon + dlon
308
+
309
+
310
+ # ---------------------------------------------------------------------------
311
+ # Radius-to-fix (RF) leg (bound leaf: radius-to-fix-leg)
312
+ # ---------------------------------------------------------------------------
313
+ def _compass_bearing_deg(dx, dy):
314
+ return math.degrees(math.atan2(dx, dy)) % 360.0
315
+
316
+
317
+ def rf_turn_center(ef, inbound_track_deg, radius_nm, turn):
318
+ """Turn centre C = EF + R * n (n on the turn side), in NM."""
319
+ if radius_nm is None or radius_nm <= 0.0:
320
+ raise ValueError("radius must be positive")
321
+ if turn not in VALID_TURN_DIRECTIONS and turn.upper() not in ("RIGHT", "LEFT"):
322
+ raise ValueError("turn must be RIGHT or LEFT")
323
+ t = math.radians(inbound_track_deg)
324
+ x_ef, y_ef = ef
325
+ if turn.upper() == "RIGHT":
326
+ nx, ny = math.cos(t), -math.sin(t)
327
+ else:
328
+ nx, ny = -math.cos(t), math.sin(t)
329
+ return (x_ef + radius_nm * nx, y_ef + radius_nm * ny)
330
+
331
+
332
+ def rf_leg_geometry(ef, inbound_track_deg, radius_nm, turn, sweep_deg):
333
+ """Construct an RF leg from entry fix, inbound track, radius, turn,
334
+ published swept angle, and return the full geometry dict.
335
+
336
+ The exit fix is placed on the radius circle at the published swept
337
+ angle (standard RF path construction: arc angle is part of the
338
+ published leg definition), so exit_on_arc is True by construction and
339
+ valid = sweep_deg > 0. All distances in NM in the EF local frame.
340
+ """
341
+ if sweep_deg is None or sweep_deg <= 0.0 or sweep_deg >= 360.0:
342
+ raise ValueError("sweep_deg must lie in (0, 360)")
343
+ center = rf_turn_center(ef, inbound_track_deg, radius_nm, turn)
344
+ cx, cy = center
345
+ radial_ef = _compass_bearing_deg(ef[0] - cx, ef[1] - cy)
346
+ if turn.upper() == "RIGHT":
347
+ radial_xf = (radial_ef + sweep_deg) % 360.0
348
+ else:
349
+ radial_xf = (radial_ef - sweep_deg) % 360.0
350
+ xf = (cx + radius_nm * math.sin(math.radians(radial_xf)),
351
+ cy + radius_nm * math.cos(math.radians(radial_xf)))
352
+ arc_len = radius_nm * math.radians(sweep_deg)
353
+ exit_track = (radial_xf + 90.0) % 360.0 if turn.upper() == "RIGHT" \
354
+ else (radial_xf - 90.0) % 360.0
355
+ chord = math.hypot(xf[0] - ef[0], xf[1] - ef[1])
356
+ return {
357
+ "ef": (round(ef[0], 3), round(ef[1], 3)),
358
+ "xf": (round(xf[0], 3), round(xf[1], 3)),
359
+ "center_nm": (round(cx, 3), round(cy, 3)),
360
+ "inbound_track_deg": round(float(inbound_track_deg) % 360.0, 1),
361
+ "radius_nm": float(radius_nm),
362
+ "turn": turn.upper(),
363
+ "sweep_deg": float(sweep_deg),
364
+ "exit_on_arc": True,
365
+ "arc_length_nm": round(arc_len, 3),
366
+ "exit_track_deg": round(exit_track, 1),
367
+ "chord_nm": round(chord, 3),
368
+ "valid": bool(sweep_deg > 0.0),
369
+ }
370
+
371
+
372
+ # ---------------------------------------------------------------------------
373
+ # DME arc leg (bound leaf: dme-arc-leg)
374
+ # ---------------------------------------------------------------------------
375
+ def _radial_short_diff_deg(current_deg, target_deg):
376
+ diff = (float(target_deg) - float(current_deg)) % 360.0
377
+ if diff > 180.0:
378
+ diff -= 360.0
379
+ return diff
380
+
381
+
382
+ def dme_arc_length_nm(r_nm, delta_radial_deg):
383
+ if r_nm is None or r_nm <= 0.0:
384
+ raise ValueError("DME radius must be positive")
385
+ return r_nm * math.radians(delta_radial_deg)
386
+
387
+
388
+ def dme_arc_chord_nm(r_nm, delta_radial_deg):
389
+ if r_nm is None or r_nm <= 0.0:
390
+ raise ValueError("DME radius must be positive")
391
+ return 2.0 * r_nm * math.sin(math.radians(delta_radial_deg) / 2.0)
392
+
393
+
394
+ def dme_arc_bank_angle_deg(tas_kt, r_nm):
395
+ """Bank angle (deg) that holds radius r_nm at true airspeed tas."""
396
+ if tas_kt is None or tas_kt <= 0.0:
397
+ raise ValueError("true airspeed must be positive")
398
+ if r_nm is None or r_nm <= 0.0:
399
+ raise ValueError("DME radius must be positive")
400
+ v = tas_kt * KT_TO_MS
401
+ return math.degrees(math.atan(v * v / (G_MS2 * r_nm * NM_TO_M)))
402
+
403
+
404
+ def dme_arc_turn_radius_nm(tas_kt, bank_deg):
405
+ """Turn radius V^2/(g*tan(bank)) in NM."""
406
+ if tas_kt is None or tas_kt <= 0.0:
407
+ raise ValueError("true airspeed must be positive")
408
+ if bank_deg is None or not 0.0 < bank_deg < 90.0:
409
+ raise ValueError("bank angle must lie in (0, 90) degrees")
410
+ v = tas_kt * KT_TO_MS
411
+ return (v * v / (G_MS2 * math.tan(math.radians(bank_deg)))) * M_TO_NM
412
+
413
+
414
+ def dme_arc_geometry(r_nm, radial_start_deg, radial_end_deg):
415
+ """Arc geometry between two radials (shorter signed turn flown)."""
416
+ if r_nm is None or r_nm <= 0.0:
417
+ raise ValueError("DME radius must be positive")
418
+ turn_deg = _radial_short_diff_deg(radial_start_deg, radial_end_deg)
419
+ turn_mag = abs(turn_deg)
420
+ return {
421
+ "radius_nm": float(r_nm),
422
+ "radial_start_deg": float(radial_start_deg) % 360.0,
423
+ "radial_end_deg": float(radial_end_deg) % 360.0,
424
+ "turn_angle_deg": round(turn_deg, 1),
425
+ "arc_length_nm": round(dme_arc_length_nm(r_nm, turn_mag), 3),
426
+ "chord_nm": round(dme_arc_chord_nm(r_nm, turn_mag), 3),
427
+ }
428
+
429
+
430
+ # ---------------------------------------------------------------------------
431
+ # Rhumb line geometry (bound leaf: rhumb-line-leg)
432
+ # ---------------------------------------------------------------------------
433
+ R_EARTH_RHUMB_M = 6371.0e3 # leaf constant; numerically equals R_EARTH_M
434
+
435
+
436
+ def _isometric_latitude(lat_deg):
437
+ _check_lat(lat_deg)
438
+ return math.log(math.tan(math.pi / 4.0 + math.radians(lat_deg) / 2.0))
439
+
440
+
441
+ def rhumb_distance_m(lat1, lon1, lat2, lon2):
442
+ """Constant-course (rhumb) distance in metres."""
443
+ _check_lat(lat1, "lat1")
444
+ _check_lat(lat2, "lat2")
445
+ d_lat = math.radians(lat2 - lat1)
446
+ d_lon = math.radians(lon2 - lon1)
447
+ d_psi = _isometric_latitude(lat2) - _isometric_latitude(lat1)
448
+ if abs(d_psi) < 1.0e-12:
449
+ return R_EARTH_RHUMB_M * abs(d_lon) * math.cos(math.radians(lat1))
450
+ return (R_EARTH_RHUMB_M * math.hypot(d_psi, d_lon)
451
+ * abs(d_lat) / abs(d_psi))
452
+
453
+
454
+ def rhumb_course_deg(lat1, lon1, lat2, lon2):
455
+ """Constant Mercator course in [0, 360)."""
456
+ _check_lat(lat1, "lat1")
457
+ _check_lat(lat2, "lat2")
458
+ d_lon = math.radians(lon2 - lon1)
459
+ d_psi = _isometric_latitude(lat2) - _isometric_latitude(lat1)
460
+ return math.degrees(math.atan2(d_lon, d_psi)) % 360.0
461
+
462
+
463
+ def rhumb_vs_great_circle(lat1, lon1, lat2, lon2):
464
+ """{rhumb_m, great_circle_m, delta_m, delta_pct} for one leg."""
465
+ _check_lat(lat1, "lat1")
466
+ _check_lat(lat2, "lat2")
467
+ rhumb = rhumb_distance_m(lat1, lon1, lat2, lon2)
468
+ gc = great_circle_distance_m(lat1, lon1, lat2, lon2)
469
+ delta = rhumb - gc
470
+ pct = 0.0 if gc == 0.0 else delta / gc * 100.0
471
+ return {
472
+ "rhumb_m": round(rhumb, 1),
473
+ "great_circle_m": round(gc, 1),
474
+ "delta_m": round(delta, 1),
475
+ "delta_pct": round(pct, 3),
476
+ }
477
+
478
+
479
+ # ---------------------------------------------------------------------------
480
+ # RTA time control (bound leaf: rta-time-control)
481
+ # ---------------------------------------------------------------------------
482
+ def isa_speed_of_sound_m_s(altitude_m):
483
+ """ISA speed of sound at altitude_m (troposphere lapse model)."""
484
+ if altitude_m is None or altitude_m < 0.0:
485
+ raise ValueError("altitude_m must be non-negative")
486
+ if altitude_m <= ISA_TROPOPAUSE_M:
487
+ temp_k = ISA_SL_TEMP_K - ISA_LAPSE_K_PER_M * altitude_m
488
+ else:
489
+ temp_k = ISA_SL_TEMP_K - ISA_LAPSE_K_PER_M * ISA_TROPOPAUSE_M
490
+ return math.sqrt(GAMMA_AIR * R_AIR * temp_k)
491
+
492
+
493
+ def rta_eta_s(remaining_distance_m, ground_speed_m_s):
494
+ """Estimated flight time to the waypoint (s) from t_now."""
495
+ if remaining_distance_m is None or remaining_distance_m < 0.0:
496
+ raise ValueError("remaining distance must be non-negative")
497
+ if ground_speed_m_s is None or ground_speed_m_s <= 0.0:
498
+ raise ValueError("ground speed must be positive")
499
+ return remaining_distance_m / ground_speed_m_s
500
+
501
+
502
+ def rta_time_error_s(eta_rel_s, rta_time_s, t_now_s=0.0):
503
+ """(t_now + eta) - rta; positive = late."""
504
+ return (t_now_s + eta_rel_s) - rta_time_s
505
+
506
+
507
+ def rta_required_ground_speed_m_s(remaining_distance_m, rta_time_s,
508
+ t_now_s=0.0):
509
+ """Ground speed that lands exactly at RTA time."""
510
+ if rta_time_s <= t_now_s:
511
+ raise ValueError("rta_time_s must be > t_now_s")
512
+ return remaining_distance_m / (rta_time_s - t_now_s)
513
+
514
+
515
+ def rta_tas_from_ground_speed(ground_speed_m_s, wind_along_m_s):
516
+ tas = ground_speed_m_s - wind_along_m_s
517
+ if tas <= 0.0:
518
+ raise ValueError("wind makes true airspeed non-positive")
519
+ return tas
520
+
521
+
522
+ def rta_mach_from_tas(tas_m_s, altitude_m):
523
+ if tas_m_s <= 0.0:
524
+ raise ValueError("tas must be positive")
525
+ return tas_m_s / isa_speed_of_sound_m_s(altitude_m)
526
+
527
+
528
+ def rta_achievable_window(remaining_distance_m, altitude_m, wind_along_m_s,
529
+ mach_min, mach_max):
530
+ """Earliest/latest arrival window from the cruise Mach envelope."""
531
+ a_sound = isa_speed_of_sound_m_s(altitude_m)
532
+ gs_min = mach_min * a_sound + wind_along_m_s
533
+ gs_max = mach_max * a_sound + wind_along_m_s
534
+ if gs_min <= 0.0:
535
+ raise ValueError("headwind exceeds minimum-cruise true airspeed")
536
+ if remaining_distance_m == 0.0:
537
+ eta_min = eta_max = 0.0
538
+ else:
539
+ eta_min = remaining_distance_m / gs_max
540
+ eta_max = remaining_distance_m / gs_min
541
+ return {
542
+ "gs_min_m_s": round(gs_min, 2),
543
+ "gs_max_m_s": round(gs_max, 2),
544
+ "eta_min_s": round(eta_min, 1),
545
+ "eta_max_s": round(eta_max, 1),
546
+ "window_s": round(eta_max - eta_min, 1),
547
+ }
548
+
549
+
550
+ def rta_speed_command(remaining_distance_m, ground_speed_m_s,
551
+ wind_along_m_s, rta_time_s, altitude_m,
552
+ mach_min=0.70, mach_max=0.82, t_now_s=0.0):
553
+ """RTA speed command law for an FMS time constraint at a waypoint.
554
+
555
+ Within RTA_TIME_TOL_S of the required time the current speed is
556
+ held; otherwise the required ground speed/Mach is commanded when
557
+ inside [mach_min, mach_max]; outside, the nearest bound is commanded
558
+ and the remaining time error of the best achievable arrival is
559
+ reported with verdict rta-unfeasible.
560
+ """
561
+ if ground_speed_m_s <= 0.0:
562
+ raise ValueError("ground speed must be positive")
563
+ if rta_time_s <= t_now_s:
564
+ raise ValueError("rta_time_s must be > t_now_s")
565
+ eta_rel = rta_eta_s(remaining_distance_m, ground_speed_m_s)
566
+ err = rta_time_error_s(eta_rel, rta_time_s, t_now_s)
567
+ window = rta_achievable_window(remaining_distance_m, altitude_m,
568
+ wind_along_m_s, mach_min, mach_max)
569
+ a_sound = isa_speed_of_sound_m_s(altitude_m)
570
+ if abs(err) <= RTA_TIME_TOL_S:
571
+ command_mach = rta_mach_from_tas(
572
+ rta_tas_from_ground_speed(ground_speed_m_s, wind_along_m_s),
573
+ altitude_m)
574
+ required_gs = ground_speed_m_s
575
+ required_mach = None
576
+ feasible = True
577
+ verdict = "rta-feasible"
578
+ predicted_eta = t_now_s + eta_rel
579
+ remaining_error = err
580
+ else:
581
+ required_gs = rta_required_ground_speed_m_s(remaining_distance_m,
582
+ rta_time_s, t_now_s)
583
+ required_mach = rta_mach_from_tas(
584
+ rta_tas_from_ground_speed(required_gs, wind_along_m_s),
585
+ altitude_m)
586
+ if mach_min <= required_mach <= mach_max:
587
+ command_mach = required_mach
588
+ feasible = True
589
+ verdict = "rta-feasible"
590
+ predicted_eta = rta_time_s
591
+ remaining_error = 0.0
592
+ else:
593
+ command_mach = mach_max if required_mach > mach_max else mach_min
594
+ feasible = False
595
+ verdict = "rta-unfeasible"
596
+ best_gs = command_mach * a_sound + wind_along_m_s
597
+ predicted_eta = t_now_s + remaining_distance_m / best_gs
598
+ remaining_error = predicted_eta - rta_time_s
599
+ return {
600
+ "remaining_distance_m": round(remaining_distance_m, 1),
601
+ "current_gs_m_s": round(ground_speed_m_s, 2),
602
+ "wind_along_m_s": round(wind_along_m_s, 2),
603
+ "current_eta_s": round(eta_rel, 1),
604
+ "time_error_s": round(err, 1),
605
+ "required_gs_m_s": round(required_gs, 2),
606
+ "required_mach": round(required_mach, 4) if required_mach else None,
607
+ "command_mach": round(command_mach, 4),
608
+ "feasible": bool(feasible),
609
+ "verdict": verdict,
610
+ "window": window,
611
+ "predicted_eta_s": round(predicted_eta, 1),
612
+ "remaining_error_s": round(remaining_error, 1),
613
+ "altitude_m": float(altitude_m),
614
+ "mach_envelope": [mach_min, mach_max],
615
+ }
616
+
617
+
618
+ # ---------------------------------------------------------------------------
619
+ # VNAV vertical profile (bound leaves: vertical-navigation,
620
+ # performance-computation, flight-planning)
621
+ # ---------------------------------------------------------------------------
622
+ def vnav_tod_distance_nm(cruise_alt_ft, target_alt_ft, gradient_ft_nm):
623
+ """Top of descent distance (NM): (cruise - target) / gradient."""
624
+ if gradient_ft_nm is None or gradient_ft_nm <= 0.0:
625
+ raise ValueError("gradient must be positive")
626
+ if cruise_alt_ft <= target_alt_ft:
627
+ raise ValueError("cruise altitude must exceed target altitude")
628
+ return (cruise_alt_ft - target_alt_ft) / gradient_ft_nm
629
+
630
+
631
+ def vnav_descent_gradient_ft_nm(cruise_alt_ft, target_alt_ft, distance_nm):
632
+ """Gradient implied by altitudes over a distance, ft/NM."""
633
+ if distance_nm is None or distance_nm <= 0.0:
634
+ raise ValueError("distance must be positive")
635
+ if cruise_alt_ft <= target_alt_ft:
636
+ raise ValueError("cruise altitude must exceed target altitude")
637
+ return (cruise_alt_ft - target_alt_ft) / distance_nm
638
+
639
+
640
+ def vnav_fpa_deg(gradient_ft_nm):
641
+ """Flight path angle: atan(gradient / ft-per-NM), degrees."""
642
+ if gradient_ft_nm is None or gradient_ft_nm <= 0.0:
643
+ raise ValueError("gradient must be positive")
644
+ return math.degrees(math.atan(gradient_ft_nm / NM_TO_FT))
645
+
646
+
647
+ def vnav_altitude_at_ft(alt_start_ft, gradient_ft_nm, distance_nm):
648
+ """Altitude after descending distance_nm along the gradient (ft)."""
649
+ alt = alt_start_ft - gradient_ft_nm * distance_nm
650
+ return alt
651
+
652
+
653
+ def vnav_constraint_ok(alt_at_constraint_ft, constraint_ft, at_or_above,
654
+ tol_ft=20.0):
655
+ """Constraint verdict: AT (within tol_ft) or AT OR ABOVE."""
656
+ if at_or_above:
657
+ return alt_at_constraint_ft >= constraint_ft
658
+ return abs(alt_at_constraint_ft - constraint_ft) <= tol_ft
659
+
660
+
661
+ def fpa_gradient_ft_nm(fpa_deg):
662
+ """Gradient of a flight path angle: tan(fpa) * ft-per-NM."""
663
+ return math.tan(math.radians(fpa_deg)) * NM_TO_FT
664
+
665
+
666
+ def vertical_band_ok(alt_ft, floor_ft, ceiling_ft):
667
+ """Crossing band check (bound flight-planning leaf, feet units):
668
+ True when floor <= alt <= ceiling; None = open."""
669
+ if floor_ft is not None and ceiling_ft is not None \
670
+ and floor_ft > ceiling_ft:
671
+ raise ValueError("floor above ceiling")
672
+ if floor_ft is not None and alt_ft < floor_ft:
673
+ return False
674
+ if ceiling_ft is not None and alt_ft > ceiling_ft:
675
+ return False
676
+ return True
677
+
678
+
679
+ # ---------------------------------------------------------------------------
680
+ # Performance (ECON cruise) (bound leaf: performance-computation)
681
+ # ---------------------------------------------------------------------------
682
+ # Simplified documented three-term drag model of a mid-size transport.
683
+ # Coefficients are the leaf's order-of-magnitude defaults; they MUST be
684
+ # calibrated per aircraft against the FMS performance manual.
685
+ PERF_WING_AREA_M2 = 122.0
686
+ PERF_CD0 = 0.0210
687
+ PERF_OSWALD_E = 0.82
688
+ PERF_ASPECT_RATIO = 9.4
689
+ PERF_SFC_KG_N_S = 0.0000165
690
+ PERF_COMPRESSIBILITY = 2.4e-16
691
+ PERF_M_MIN = 0.70
692
+ PERF_M_MMO = 0.82
693
+ PERF_FT_TO_M = 0.3048
694
+ PERF_MS_TO_KTS = 1.943844492
695
+ PERF_KTS_TO_MS = 0.5144444444
696
+
697
+
698
+ def perf_isa_temperature_k(altitude_ft):
699
+ h = altitude_ft * PERF_FT_TO_M
700
+ if h <= ISA_TROPOPAUSE_M:
701
+ return ISA_SL_TEMP_K - ISA_LAPSE_K_PER_M * h
702
+ return ISA_SL_TEMP_K - ISA_LAPSE_K_PER_M * ISA_TROPOPAUSE_M
703
+
704
+
705
+ def perf_speed_of_sound_kts(altitude_ft):
706
+ return math.sqrt(GAMMA_AIR * R_AIR * perf_isa_temperature_k(altitude_ft)) \
707
+ * PERF_MS_TO_KTS
708
+
709
+
710
+ def perf_tas_from_mach(mach, altitude_ft):
711
+ if mach <= 0.0:
712
+ raise ValueError("mach must be positive")
713
+ return mach * perf_speed_of_sound_kts(altitude_ft)
714
+
715
+
716
+ def perf_cost_index(time_cost_per_hour, fuel_cost_per_kg):
717
+ if time_cost_per_hour < 0.0:
718
+ raise ValueError("time cost must be non-negative")
719
+ if fuel_cost_per_kg <= 0.0:
720
+ raise ValueError("fuel cost must be positive")
721
+ return time_cost_per_hour / fuel_cost_per_kg
722
+
723
+
724
+ def perf_isa_density_kgm3(altitude_ft):
725
+ """ISA density in kg/m^3 (mirrors the performance-computation leaf)."""
726
+ h = altitude_ft * PERF_FT_TO_M
727
+ if h <= ISA_TROPOPAUSE_M:
728
+ t = ISA_SL_TEMP_K - ISA_LAPSE_K_PER_M * h
729
+ p = 101325.0 * (t / ISA_SL_TEMP_K) ** (-G_MS2 / (R_AIR * -ISA_LAPSE_K_PER_M))
730
+ else:
731
+ t_tropo = ISA_SL_TEMP_K - ISA_LAPSE_K_PER_M * ISA_TROPOPAUSE_M
732
+ p_tropo = 101325.0 * (t_tropo / ISA_SL_TEMP_K) ** (
733
+ -G_MS2 / (R_AIR * -ISA_LAPSE_K_PER_M))
734
+ p = p_tropo * math.exp(-G_MS2 * (h - ISA_TROPOPAUSE_M)
735
+ / (R_AIR * t_tropo))
736
+ t = t_tropo
737
+ return p / (R_AIR * t)
738
+
739
+
740
+ def _perf_fuel_coeffs(weight_kg, altitude_ft):
741
+ rho = perf_isa_density_kgm3(altitude_ft)
742
+ w_n = weight_kg * G_MS2
743
+ c1 = 0.5 * rho * PERF_WING_AREA_M2 * PERF_CD0
744
+ c2 = (2.0 * w_n * w_n
745
+ / (rho * PERF_WING_AREA_M2 * math.pi * PERF_OSWALD_E
746
+ * PERF_ASPECT_RATIO))
747
+ return c1, c2, PERF_COMPRESSIBILITY
748
+
749
+
750
+ def perf_fuel_per_nm(weight_kg, altitude_ft, tas_kts):
751
+ """Cruise fuel burn, kg per nautical mile."""
752
+ if weight_kg <= 0.0:
753
+ raise ValueError("weight must be positive")
754
+ if tas_kts <= 0.0:
755
+ raise ValueError("tas must be positive")
756
+ c1, c2, c3 = _perf_fuel_coeffs(weight_kg, altitude_ft)
757
+ v = tas_kts * PERF_KTS_TO_MS
758
+ return PERF_SFC_KG_N_S * NM_TO_M * (c1 * v + c2 / v ** 3 + c3 * v ** 7)
759
+
760
+
761
+ def perf_max_range_speed_kts(weight_kg, altitude_ft):
762
+ """TAS (kts) minimizing fuel per NM (Newton root of dF/dV = 0)."""
763
+ c1, c2, _c3 = _perf_fuel_coeffs(weight_kg, altitude_ft)
764
+
765
+ def g(v):
766
+ return c1 - 3.0 * c2 / v ** 4 + 7.0 * _c3 * v ** 6
767
+
768
+ def gp(v):
769
+ return 12.0 * c2 / v ** 5 + 42.0 * _c3 * v ** 5
770
+
771
+ v = (3.0 * c2 / c1) ** 0.25
772
+ for _ in range(60):
773
+ f = g(v)
774
+ df = gp(v)
775
+ if df == 0.0:
776
+ break
777
+ step = f / df
778
+ v -= step
779
+ if v <= 1.0:
780
+ v = 1.0
781
+ if abs(step) < 1e-9:
782
+ break
783
+ return v * PERF_MS_TO_KTS
784
+
785
+
786
+ def perf_econ_mach_from_cost_index(ci_kg_per_h, weight_kg, altitude_ft):
787
+ """ECON cruise Mach clamped into [M_MIN, M_MMO]."""
788
+ if ci_kg_per_h is None or ci_kg_per_h < 0.0:
789
+ raise ValueError("cost index must be non-negative")
790
+ if weight_kg <= 0.0:
791
+ raise ValueError("weight must be positive")
792
+ c1, c2, c3 = _perf_fuel_coeffs(weight_kg, altitude_ft)
793
+ a = PERF_SFC_KG_N_S * NM_TO_M
794
+ time_scale = NM_TO_M / 3600.0
795
+
796
+ def g(v):
797
+ return (a * (c1 - 3.0 * c2 / v ** 4 + 7.0 * c3 * v ** 6)
798
+ - ci_kg_per_h * time_scale / v ** 2)
799
+
800
+ def gp(v):
801
+ return (a * (12.0 * c2 / v ** 5 + 42.0 * c3 * v ** 5)
802
+ + 2.0 * ci_kg_per_h * time_scale / v ** 3)
803
+
804
+ v = perf_max_range_speed_kts(weight_kg, altitude_ft) * PERF_KTS_TO_MS
805
+ for _ in range(60):
806
+ f = g(v)
807
+ df = gp(v)
808
+ if df == 0.0:
809
+ break
810
+ step = f / df
811
+ v -= step
812
+ if v <= 1.0:
813
+ v = 1.0
814
+ if abs(step) < 1e-9:
815
+ break
816
+ mach = v / math.sqrt(GAMMA_AIR * R_AIR * perf_isa_temperature_k(altitude_ft))
817
+ return min(PERF_M_MMO, max(PERF_M_MIN, mach))
818
+
819
+
820
+ def perf_econ_summary(ci_kg_per_h, weight_kg, altitude_ft):
821
+ """ECON selection detail: Mach, TAS, fuel per NM, time per NM."""
822
+ mach = perf_econ_mach_from_cost_index(ci_kg_per_h, weight_kg, altitude_ft)
823
+ tas = perf_tas_from_mach(mach, altitude_ft)
824
+ return {
825
+ "cost_index_kg_per_h": float(ci_kg_per_h),
826
+ "weight_kg": int(weight_kg),
827
+ "altitude_ft": int(altitude_ft),
828
+ "mach": round(mach, 4),
829
+ "tas_kts": round(tas, 1),
830
+ "fuel_per_nm_kg": round(perf_fuel_per_nm(weight_kg, altitude_ft, tas), 3),
831
+ "time_per_nm_h": round(1.0 / tas, 6),
832
+ "m_min": PERF_M_MIN,
833
+ "m_mmo": PERF_M_MMO,
834
+ }
835
+
836
+
837
+ def perf_max_range_summary(weight_kg, altitude_ft):
838
+ """Max-range (minimum fuel per NM) reference, CI = 0."""
839
+ tas = perf_max_range_speed_kts(weight_kg, altitude_ft)
840
+ a_kts = perf_speed_of_sound_kts(altitude_ft)
841
+ return {
842
+ "mach": round(tas / a_kts, 4),
843
+ "tas_kts": round(tas, 1),
844
+ "fuel_per_nm_kg": round(perf_fuel_per_nm(weight_kg, altitude_ft, tas), 3),
845
+ }
846
+
847
+
848
+ # ---------------------------------------------------------------------------
849
+ # Radio navaid geometry (bound leaf: radio-navigation-aids)
850
+ # ---------------------------------------------------------------------------
851
+ def nav_bearing_deg(x_ac, y_ac):
852
+ """Bearing from station to aircraft, degrees clockwise from north."""
853
+ return math.degrees(math.atan2(x_ac, y_ac)) % 360.0
854
+
855
+
856
+ def nav_radial_deg(bearing_deg_input):
857
+ """VOR radial FROM the station (reciprocal of bearing)."""
858
+ return (float(bearing_deg_input) + 180.0) % 360.0
859
+
860
+
861
+ def nav_dme_slant_range_m(x_ac, y_ac, altitude_m):
862
+ """DME slant range: sqrt(x^2 + y^2 + h^2), metres."""
863
+ if altitude_m is None or altitude_m < 0.0:
864
+ raise ValueError("altitude must be non-negative")
865
+ return math.sqrt(x_ac * x_ac + y_ac * y_ac + altitude_m * altitude_m)
866
+
867
+
868
+ # ---------------------------------------------------------------------------
869
+ # Route model + report builder
870
+ # ---------------------------------------------------------------------------
871
+ @dataclass
872
+ class Route:
873
+ """Project facts for a flight plan + RNAV/RNP route assessment."""
874
+ route_id: str = "AERO ROUTE R1 (illustrative - not a filed plan)"
875
+ aircraft: str = "Mid-size transport (illustrative example)"
876
+ wpt_a: tuple = (41.0000, -75.0000) # W001
877
+ wpt_b: tuple = (40.0000, -74.5000) # W002 (RF entry fix)
878
+ wpt_e: tuple = (38.0000, -75.0000) # W005 (RTA / constraint fix)
879
+ leg1_ident: str = "W001"
880
+ leg2_ident: str = "W002"
881
+ hold_ident: str = "HOLDF"
882
+ rta_ident: str = "W005"
883
+ # RF leg (W002 -> W003)
884
+ rf_radius_nm: float = 12.0
885
+ rf_turn: str = "RIGHT"
886
+ rf_sweep_deg: float = 90.0
887
+ # RNP segment
888
+ rnp_nm: float = 0.3
889
+ sigma_lateral_m: float = 200.0 # 1-sigma lateral position error
890
+ rnp_margin_fraction: float = 0.10
891
+ rnp_segment: str = "W002 - HOLDF - W005 (en-route/arrival RNAV portion)"
892
+ # Hold at W004/HOLDF
893
+ hold_inbound_course_deg: float = 260.0
894
+ hold_turn: str = "right"
895
+ hold_altitude_ft: float = 20000.0
896
+ hold_outbound_heading_deg: float = 80.0
897
+ hold_wind_from_deg: float = 40.0
898
+ hold_wind_speed_kt: float = 30.0
899
+ hold_tas_kt: float = 260.0
900
+ # RTA at W005 (evaluated from the hold exit at W004)
901
+ rta_altitude_ft: float = 20000.0
902
+ rta_current_gs_m_s: float = 240.0
903
+ rta_wind_along_m_s: float = -5.0
904
+ rta_demand_delay_s: float = 25.0 # slot: arrive 25 s later than nom.
905
+ rta_mach_min: float = 0.70
906
+ rta_mach_max: float = 0.82
907
+ # VNAV profile
908
+ vnav_cruise_ft: float = 35000.0
909
+ vnav_path_fpa_deg: float = 3.0
910
+ vnav_tas_kt: float = 450.0
911
+ vnav_headwind_kt: float = 15.0
912
+ # Perf (ECON)
913
+ perf_cost_index: float = 30.0
914
+ perf_weight_kg: float = 62000.0
915
+ perf_altitude_ft: float = 35000.0
916
+ # DME arc procedure check
917
+ dme_radius_nm: float = 15.0
918
+ dme_radial_start_deg: float = 30.0
919
+ dme_radial_end_deg: float = 90.0
920
+ dme_tas_kt: float = 250.0
921
+ dme_fl_ft: float = 20000.0
922
+ extra_notes: str = ""
923
+
924
+
925
+ def _leg_dist_nm(m):
926
+ return m * M_TO_NM
927
+
928
+
929
+ def build_report(route: Route) -> dict:
930
+ """Compute the complete route assessment content model.
931
+
932
+ Leg 1: great-circle TF leg W001-W002 (track + distance).
933
+ Leg 2: RF leg W002-W003 constructed from the published radius / turn
934
+ / swept angle; W003 is the computed exit fix.
935
+ Leg 3: TF meridian leg W003-W004 (hold fix), track exactly 180.
936
+ Leg 4: TF leg W004-W005 (RTA fix).
937
+ """
938
+ lat_a, lon_a = route.wpt_a
939
+ lat_b, lon_b = route.wpt_b
940
+ # --- Leg 1 geometry
941
+ l1_track = great_circle_track_deg(lat_a, lon_a, lat_b, lon_b)
942
+ l1_dist_m = great_circle_distance_m(lat_a, lon_a, lat_b, lon_b)
943
+ l1_rhumb = rhumb_vs_great_circle(lat_a, lon_a, lat_b, lon_b)
944
+ # --- Leg 2: RF construction; inbound track = arrival track at W002
945
+ ef = (0.0, 0.0) # W002 as origin of the RF local frame
946
+ rf = rf_leg_geometry(ef, l1_track, route.rf_radius_nm, route.rf_turn,
947
+ route.rf_sweep_deg)
948
+ xf = rf["xf"]
949
+ lat_c, lon_c = _offset_waypoint(lat_b, lon_b, xf[0], xf[1])
950
+ # --- Leg 3: W003 -> W004, W004 placed 1.0 deg south of W003 (meridian)
951
+ lat_d = lat_c - 1.0
952
+ lon_d = lon_c
953
+ l3_track = great_circle_track_deg(lat_c, lon_c, lat_d, lon_d)
954
+ l3_dist_m = great_circle_distance_m(lat_c, lon_c, lat_d, lon_d)
955
+ # --- Leg 4: W004 -> W005
956
+ lat_e, lon_e = route.wpt_e
957
+ l4_track = great_circle_track_deg(lat_d, lon_d, lat_e, lon_e)
958
+ l4_dist_m = great_circle_distance_m(lat_d, lon_d, lat_e, lon_e)
959
+
960
+ waypoints = [
961
+ {"ident": route.leg1_ident, "lat": lat_a, "lon": lon_a,
962
+ "derived": False, "note": "route origin / start of RNP segment"},
963
+ {"ident": route.leg2_ident, "lat": lat_b, "lon": lon_b,
964
+ "derived": False, "note": "RF leg entry fix"},
965
+ {"ident": "W003", "lat": round(lat_c, 4), "lon": round(lon_c, 4),
966
+ "derived": True, "note": "RF leg exit fix (computed)"},
967
+ {"ident": route.hold_ident, "lat": round(lat_d, 4),
968
+ "lon": round(lon_d, 4), "derived": True,
969
+ "note": "holding fix (computed from W003)"},
970
+ {"ident": route.rta_ident, "lat": lat_e, "lon": lon_e,
971
+ "derived": False, "note": "RTA fix / VNAV crossing constraint"},
972
+ ]
973
+ legs = [
974
+ {"leg": 1, "type": "TF (great-circle)", "from": route.leg1_ident,
975
+ "to": route.leg2_ident, "track_deg": round(l1_track, 1),
976
+ "distance_nm": round(_leg_dist_nm(l1_dist_m), 2)},
977
+ {"leg": 2, "type": "RF (radius-to-fix)", "from": route.leg2_ident,
978
+ "to": "W003", "track_deg": round(rf["exit_track_deg"], 1),
979
+ "distance_nm": round(rf["arc_length_nm"], 2),
980
+ "rf": rf},
981
+ {"leg": 3, "type": "TF (great-circle)", "from": "W003",
982
+ "to": route.hold_ident, "track_deg": round(l3_track, 1),
983
+ "distance_nm": round(_leg_dist_nm(l3_dist_m), 2)},
984
+ {"leg": 4, "type": "TF (great-circle)", "from": route.hold_ident,
985
+ "to": route.rta_ident, "track_deg": round(l4_track, 1),
986
+ "distance_nm": round(_leg_dist_nm(l4_dist_m), 2)},
987
+ ]
988
+ total_nm = sum(leg["distance_nm"] for leg in legs)
989
+
990
+ # RNP containment on the RNP 0.3 segment
991
+ rnp_m = rnp_nm_to_m(route.rnp_nm)
992
+ containment = containment_analysis(
993
+ sigma_lateral_m=route.sigma_lateral_m, rnp_m=rnp_m,
994
+ margin_fraction=route.rnp_margin_fraction)
995
+
996
+ # Holding at W004/HOLDF: arrival track = leg 3 track (meridian, 180)
997
+ hold = hold_entry_analysis(
998
+ arrival_track_deg=l3_track,
999
+ hold_inbound_course_deg=route.hold_inbound_course_deg,
1000
+ turn_direction=route.hold_turn,
1001
+ altitude_ft=route.hold_altitude_ft,
1002
+ outbound_heading_deg=route.hold_outbound_heading_deg,
1003
+ wind_from_deg=route.hold_wind_from_deg,
1004
+ wind_speed_kt=route.hold_wind_speed_kt,
1005
+ tas_kt=route.hold_tas_kt)
1006
+
1007
+ # RTA at W005 from the hold exit (W004)
1008
+ alt_rta_m = route.rta_altitude_ft * FT_TO_M
1009
+ eta_nom = rta_eta_s(l4_dist_m, route.rta_current_gs_m_s)
1010
+ rta_time_s = route.rta_demand_delay_s + eta_nom # t_now = 0
1011
+ rta = rta_speed_command(
1012
+ remaining_distance_m=l4_dist_m,
1013
+ ground_speed_m_s=route.rta_current_gs_m_s,
1014
+ wind_along_m_s=route.rta_wind_along_m_s,
1015
+ rta_time_s=rta_time_s,
1016
+ altitude_m=alt_rta_m,
1017
+ mach_min=route.rta_mach_min, mach_max=route.rta_mach_max,
1018
+ t_now_s=0.0)
1019
+
1020
+ # VNAV: 3.0 deg path FL350 -> FL200 crossing W004, then W004 -> W005
1021
+ path_gradient = fpa_gradient_ft_nm(route.vnav_path_fpa_deg)
1022
+ tod_air_nm = vnav_tod_distance_nm(route.vnav_cruise_ft, 20000.0,
1023
+ path_gradient)
1024
+ ground_factor = (route.vnav_tas_kt
1025
+ / (route.vnav_tas_kt - route.vnav_headwind_kt))
1026
+ tod_ground_nm = tod_air_nm * ground_factor
1027
+ l3_nm = _leg_dist_nm(l3_dist_m)
1028
+ tod_in_leg3 = max(0.0, l3_nm - tod_ground_nm)
1029
+ # crossing at W005 continuing the same 3.0 deg path from FL200
1030
+ l4_nm = _leg_dist_nm(l4_dist_m)
1031
+ alt_at_w005_ft = vnav_altitude_at_ft(20000.0, path_gradient, l4_nm)
1032
+ w005_constraint_ft = 6000.0
1033
+ w005_ok = vnav_constraint_ok(alt_at_w005_ft, w005_constraint_ft,
1034
+ at_or_above=False, tol_ft=20.0)
1035
+ w005_at_or_above = vnav_constraint_ok(alt_at_w005_ft,
1036
+ w005_constraint_ft,
1037
+ at_or_above=True)
1038
+ required_gradient = vnav_descent_gradient_ft_nm(20000.0,
1039
+ w005_constraint_ft, l4_nm)
1040
+ required_fpa = vnav_fpa_deg(required_gradient)
1041
+ # vertical band checks (flight-planning constraint model, feet)
1042
+ band_cruise = vertical_band_ok(route.vnav_cruise_ft, 33000.0, 39000.0)
1043
+ band_w004 = vertical_band_ok(20000.0, 19500.0, 20500.0)
1044
+ vnav = {
1045
+ "cruise_ft": int(route.vnav_cruise_ft),
1046
+ "hold_crossing_ft": 20000,
1047
+ "fpa_deg": route.vnav_path_fpa_deg,
1048
+ "path_gradient_ft_nm": round(path_gradient, 1),
1049
+ "tas_kt": route.vnav_tas_kt,
1050
+ "headwind_kt": route.vnav_headwind_kt,
1051
+ "tod_air_nm": round(tod_air_nm, 1),
1052
+ "tod_ground_nm": round(tod_ground_nm, 1),
1053
+ "leg3_nm": round(l3_nm, 2),
1054
+ "tod_in_leg3_nm": round(tod_in_leg3, 1),
1055
+ "leg4_nm": round(l4_nm, 2),
1056
+ "alt_at_w005_ft_3deg": round(alt_at_w005_ft, 0),
1057
+ "w005_constraint_ft": w005_constraint_ft,
1058
+ "w005_crossing_ok": bool(w005_ok),
1059
+ "w005_at_or_above_ok": bool(w005_at_or_above),
1060
+ "required_gradient_ft_nm": round(required_gradient, 1),
1061
+ "required_fpa_deg": round(required_fpa, 2),
1062
+ "band_cruise_ok": bool(band_cruise),
1063
+ "band_w004_ok": bool(band_w004),
1064
+ }
1065
+
1066
+ # Performance (ECON cruise at FL350)
1067
+ econ = perf_econ_summary(route.perf_cost_index, route.perf_weight_kg,
1068
+ route.perf_altitude_ft)
1069
+ maxrange = perf_max_range_summary(route.perf_weight_kg,
1070
+ route.perf_altitude_ft)
1071
+
1072
+ # DME arc procedure check + radio navaid geometry
1073
+ dme = dme_arc_geometry(route.dme_radius_nm, route.dme_radial_start_deg,
1074
+ route.dme_radial_end_deg)
1075
+ dme_bank = dme_arc_bank_angle_deg(route.dme_tas_kt, route.dme_radius_nm)
1076
+ dme_turn_r = dme_arc_turn_radius_nm(route.dme_tas_kt, 25.0)
1077
+ arc_alt_m = route.dme_fl_ft * FT_TO_M
1078
+ ground_m = route.dme_radius_nm * NM_TO_M
1079
+ slant_m = nav_dme_slant_range_m(ground_m, 0.0, arc_alt_m)
1080
+ pt_start = (route.dme_radius_nm * math.sin(math.radians(30.0)),
1081
+ route.dme_radius_nm * math.cos(math.radians(30.0)))
1082
+ bearing_start = nav_bearing_deg(pt_start[0], pt_start[1])
1083
+ nav = {
1084
+ "dme": dme,
1085
+ "dme_bank_angle_deg": round(dme_bank, 2),
1086
+ "dme_turn_radius_nm_25deg": round(dme_turn_r, 2),
1087
+ "slant_range_ground_m": round(ground_m, 0),
1088
+ "slant_range_m": round(slant_m, 0),
1089
+ "start_point_bearing_deg": round(bearing_start, 1),
1090
+ "start_point_radial_deg": round(nav_radial_deg(bearing_start), 1),
1091
+ "arc_altitude_ft": int(route.dme_fl_ft),
1092
+ "dme_tas_kt": float(route.dme_tas_kt),
1093
+ }
1094
+
1095
+ # Rhumb-line leg comparison for leg 1
1096
+ rhumb_l1 = {
1097
+ "rhumb_nm": round(l1_rhumb["rhumb_m"] * M_TO_NM, 3),
1098
+ "gc_nm": round(l1_rhumb["great_circle_m"] * M_TO_NM, 3),
1099
+ "delta_m": l1_rhumb["delta_m"],
1100
+ "delta_pct": l1_rhumb["delta_pct"],
1101
+ "rhumb_course_deg": round(rhumb_course_deg(lat_a, lon_a, lat_b, lon_b), 1),
1102
+ }
1103
+
1104
+ findings = []
1105
+ if not vnav["w005_crossing_ok"]:
1106
+ findings.append({
1107
+ "id": "F1",
1108
+ "severity": "finding",
1109
+ "title": "W005 crossing constraint not met on the planned 3.0 deg path",
1110
+ "detail": ("Continuing the 3.0 deg descent path from the FL200 "
1111
+ "hold crossing reaches %s at %.0f ft, below the AT "
1112
+ "%.0f ft constraint by %.0f ft. Replan the arrival "
1113
+ "profile: the descent leg from the hold to %s "
1114
+ "requires a gradient no steeper than %.0f ft/NM "
1115
+ "(%.2f deg)."
1116
+ % (route.rta_ident, alt_at_w005_ft, w005_constraint_ft,
1117
+ 6000.0 - alt_at_w005_ft, route.rta_ident,
1118
+ required_gradient, required_fpa)),
1119
+ })
1120
+ if not containment["pass"]:
1121
+ findings.append({
1122
+ "id": "F2", "severity": "finding",
1123
+ "title": "RNP containment not met",
1124
+ "detail": "ANP exceeds the RNP containment bound on the segment.",
1125
+ })
1126
+ if not rta["feasible"]:
1127
+ findings.append({
1128
+ "id": "F3", "severity": "finding",
1129
+ "title": "RTA demand not achievable inside the Mach envelope",
1130
+ "detail": "The commanded bound Mach leaves a residual time error.",
1131
+ })
1132
+ if not findings:
1133
+ findings.append({
1134
+ "id": "F1", "severity": "info",
1135
+ "title": "No constraint violations found",
1136
+ "detail": "All checked route elements comply within the stated assumptions.",
1137
+ })
1138
+
1139
+ model = {
1140
+ "document_type": "Flight Plan and RNAV/RNP Route Assessment",
1141
+ "status": "draft-for-review",
1142
+ "route_id": route.route_id,
1143
+ "aircraft": route.aircraft,
1144
+ "cruise_fl": int(route.vnav_cruise_ft),
1145
+ "waypoints": waypoints,
1146
+ "legs": legs,
1147
+ "total_distance_nm": round(total_nm, 2),
1148
+ "leg1_rhumb_comparison": rhumb_l1,
1149
+ "rnp": {
1150
+ "rnp_nm": route.rnp_nm,
1151
+ "rnp_m": round(rnp_m, 1),
1152
+ "segment": route.rnp_segment,
1153
+ "sigma_lateral_m": route.sigma_lateral_m,
1154
+ "containment": containment,
1155
+ },
1156
+ "hold": hold,
1157
+ "hold_fix": route.hold_ident,
1158
+ "rta_fix": route.rta_ident,
1159
+ "rta": rta,
1160
+ "rta_demand": {
1161
+ "evaluated_from": route.hold_ident,
1162
+ "to": route.rta_ident,
1163
+ "slot_delay_s": route.rta_demand_delay_s,
1164
+ },
1165
+ "vnav": vnav,
1166
+ "performance": {"econ": econ, "max_range_reference": maxrange},
1167
+ "navaid": nav,
1168
+ "findings": findings,
1169
+ "n_findings": len([f for f in findings if f["severity"] == "finding"]),
1170
+ "generated": _today(),
1171
+ }
1172
+ return model
1173
+
1174
+
1175
+ # ---------------------------------------------------------------------------
1176
+ # Render the deliverable markdown
1177
+ # ---------------------------------------------------------------------------
1178
+ def _fmt_nm(value):
1179
+ return "%.2f" % value
1180
+
1181
+
1182
+ def render_report_markdown(model: dict) -> str:
1183
+ """Render the assessment model as the deliverable markdown document."""
1184
+ m = model
1185
+ rnp = m["rnp"]["containment"]
1186
+ hold = m["hold"]
1187
+ rta = m["rta"]
1188
+ vnav = m["vnav"]
1189
+ econ = m["performance"]["econ"]
1190
+ mr = m["performance"]["max_range_reference"]
1191
+ nav = m["navaid"]
1192
+ rh1 = m["leg1_rhumb_comparison"]
1193
+
1194
+ lines = [
1195
+ "# Flight Plan and RNAV/RNP Route Assessment",
1196
+ "",
1197
+ "**Route:** %s" % m["route_id"],
1198
+ "**Aircraft:** %s" % m["aircraft"],
1199
+ "**Cruise level:** FL%d (illustrative profile)" % (m["cruise_fl"] // 100),
1200
+ "**Status:** %s" % m["status"],
1201
+ "",
1202
+ "## 1. Route structure and track distance",
1203
+ "",
1204
+ "The assessed route contains %d waypoints and %d legs. Waypoints are "
1205
+ "illustrative identifiers with coordinates; the route is a synthetic "
1206
+ "example for the role deliverable, not a published navigation "
1207
+ "database procedure." % (len(m["waypoints"]), len(m["legs"])),
1208
+ "",
1209
+ "| Waypoint | Latitude | Longitude | Role |",
1210
+ "|---|---|---|---|",
1211
+ ]
1212
+ for wp in m["waypoints"]:
1213
+ role = ("derived: " if wp["derived"] else "") + wp["note"]
1214
+ lines.append("| %s | %.4f N | %.4f W | %s |"
1215
+ % (wp["ident"], abs(wp["lat"]), abs(wp["lon"]), role))
1216
+ lines += [
1217
+ "",
1218
+ "| Leg | Type | From | To | Track (deg)* | Distance (NM) |",
1219
+ "|---|---|---|---|---|---|",
1220
+ "*TF legs: initial great-circle track. RF leg: exit track after the "
1221
+ "turn (the value shown is the flight tangent at the exit fix).*",
1222
+ "",
1223
+ ]
1224
+ for leg in m["legs"]:
1225
+ lines.append("| %d | %s | %s | %s | %.1f deg | %s |"
1226
+ % (leg["leg"], leg["type"], leg["from"], leg["to"],
1227
+ leg["track_deg"], _fmt_nm(leg["distance_nm"])))
1228
+ lines += [
1229
+ "",
1230
+ "Total track distance: **%s NM**." % _fmt_nm(m["total_distance_nm"]),
1231
+ "",
1232
+ "Leg 1 track-distance method check (rhumb line vs great circle): "
1233
+ "rhumb %.3f NM, great-circle %.3f NM, difference %.1f m "
1234
+ "(%.3f %%), rhumb course %.1f deg. Great-circle tracks are used "
1235
+ "for the TF legs." % (rh1["rhumb_nm"], rh1["gc_nm"], rh1["delta_m"],
1236
+ rh1["delta_pct"], rh1["rhumb_course_deg"]),
1237
+ "",
1238
+ "## 2. Radius-to-fix (RF) leg construction (leg 2)",
1239
+ "",
1240
+ "The RF leg from W002 turns %s at radius %s NM with the published "
1241
+ "swept angle %s deg. Entry fix is the origin of the leg local "
1242
+ "tangent frame (x = east, y = north, NM)." % (
1243
+ m["legs"][1]["rf"]["turn"], m["legs"][1]["rf"]["radius_nm"],
1244
+ m["legs"][1]["rf"]["sweep_deg"]),
1245
+ "",
1246
+ "- Turn centre (local NM): %s" % (m["legs"][1]["rf"]["center_nm"],),
1247
+ "- Exit fix (local NM): %s (on radius circle: %s)" % (
1248
+ m["legs"][1]["rf"]["xf"], m["legs"][1]["rf"]["exit_on_arc"]),
1249
+ "- Swept angle: %s deg; arc length: %s NM" % (
1250
+ m["legs"][1]["rf"]["sweep_deg"], _fmt_nm(m["legs"][1]["rf"]["arc_length_nm"])),
1251
+ "- Exit track: %s deg; chord: %s NM" % (
1252
+ m["legs"][1]["rf"]["exit_track_deg"], _fmt_nm(m["legs"][1]["rf"]["chord_nm"])),
1253
+ "- RF leg valid: %s" % m["legs"][1]["rf"]["valid"],
1254
+ "",
1255
+ "## 3. RNP containment on the RNP segment",
1256
+ "",
1257
+ "Segment: %s" % m["rnp"]["segment"],
1258
+ "- Required navigation performance (RNP): %.1f NM = %.1f m" % (
1259
+ m["rnp"]["rnp_nm"], m["rnp"]["rnp_m"]),
1260
+ "- Lateral 1-sigma position error input: %.0f m" % m["rnp"]["sigma_lateral_m"],
1261
+ "- Actual navigation performance (ANP), 95%% containment "
1262
+ "(2 x sigma): %.1f m (%.4f NM)" % (rnp["anp_m"], rnp["anp_nm"]),
1263
+ "- Required margin (%.0f %% of RNP): %.1f m" % (
1264
+ m["rnp"]["containment"]["required_margin_m"]
1265
+ / m["rnp"]["rnp_m"] * 100.0,
1266
+ rnp["required_margin_m"]),
1267
+ "- Containment check (ANP + margin <= RNP): **%s** - margin "
1268
+ "available %.1f m" % (rnp["verdict"], rnp["margin_available_m"]),
1269
+ "",
1270
+ "## 4. Holding pattern entry at %s" % m["hold_fix"],
1271
+ "",
1272
+ "Approach track at the holding fix: %.1f deg (leg 3, due-south "
1273
+ "meridian). Hold inbound course %.1f deg, %s turns. Approach angle "
1274
+ "alpha to the holding axis: %.1f deg (sector rule: direct <= %.0f "
1275
+ "deg, teardrop %.0f < alpha <= %.0f deg, parallel > %.0f deg)."
1276
+ % (hold["arrival_track_deg"], hold["hold_inbound_course_deg"],
1277
+ hold["turn_direction"], hold["alpha_deg"],
1278
+ hold["direct_sector_deg"], hold["direct_sector_deg"],
1279
+ hold["teardrop_sector_deg"], hold["teardrop_sector_deg"]),
1280
+ "",
1281
+ "- Entry type: **%s**" % hold["entry"],
1282
+ "- Outbound leg timing at FL%d (%.0f ft): %.0f s"
1283
+ % (hold["hold_altitude_ft"] // 100, hold["hold_altitude_ft"],
1284
+ hold["outbound_leg_s"]),
1285
+ "- Outbound heading %s deg, wind %s deg/%.0f kt, TAS %.0f kt: "
1286
+ "1-in-60 corrected heading **%s deg**" % (
1287
+ hold["outbound_heading_deg"], hold["wind_from_deg"],
1288
+ hold["wind_speed_kt"], hold["tas_kt"],
1289
+ hold["wind_corrected_heading_deg"]),
1290
+ "- First (entry) lap estimate: %.0f s" % hold["entry_lap_s"],
1291
+ "",
1292
+ "## 5. RTA time control at %s" % m["rta_fix"],
1293
+ "",
1294
+ "RTA evaluated from the %s exit, %.1f NM before %s, slot demand "
1295
+ "%.0f s later than the nominal arrival." % (
1296
+ m["hold_fix"], vnav["leg4_nm"], m["rta_fix"],
1297
+ m["rta_demand"]["slot_delay_s"]),
1298
+ "- Current ground speed: %.1f m/s; nominal ETA: %.1f s; RTA time "
1299
+ "error: %+.1f s" % (rta["current_gs_m_s"], rta["current_eta_s"],
1300
+ rta["time_error_s"]),
1301
+ "- Required ground speed: %.1f m/s; required Mach: %s" % (
1302
+ rta["required_gs_m_s"],
1303
+ ("%.4f" % rta["required_mach"]) if rta["required_mach"] else "n/a"),
1304
+ "- Commanded Mach: **%.4f**; verdict: **%s** (feasible: %s)" % (
1305
+ rta["command_mach"], rta["verdict"], rta["feasible"]),
1306
+ "- Achievable window at %.0f ft: [%.0f s, %.0f s] (%.0f s wide)" % (
1307
+ rta["altitude_m"] / FT_TO_M, rta["window"]["eta_min_s"],
1308
+ rta["window"]["eta_max_s"], rta["window"]["window_s"]),
1309
+ "",
1310
+ "## 6. Performance and vertical (VNAV) profile",
1311
+ "",
1312
+ "ECON cruise at FL%d, cost index %.0f kg/h, weight %.0f kg:" % (
1313
+ econ["altitude_ft"] // 100, econ["cost_index_kg_per_h"],
1314
+ econ["weight_kg"]),
1315
+ "- ECON Mach: **%.4f** (TAS %.1f kt, fuel %.3f kg/NM); envelope "
1316
+ "[%.2f, %.2f] Mach; max-range reference (CI 0): Mach %.4f, "
1317
+ "fuel %.3f kg/NM" % (econ["mach"], econ["tas_kts"],
1318
+ econ["fuel_per_nm_kg"], econ["m_min"],
1319
+ econ["m_mmo"], mr["mach"], mr["fuel_per_nm_kg"]),
1320
+ "",
1321
+ "Vertical path: descend FL%d -> FL200 on a %.1f deg flight path "
1322
+ "(%.1f ft/NM) to cross %s AT FL200, then continue toward %s "
1323
+ "(constraint AT %.0f ft)." % (
1324
+ vnav["cruise_ft"] // 100, vnav["fpa_deg"],
1325
+ vnav["path_gradient_ft_nm"], m["hold_fix"], m["rta_fix"],
1326
+ vnav["w005_constraint_ft"]),
1327
+ "- Top of descent (air): %.1f NM before %s; with %.0f kt headwind "
1328
+ "at %.0f kt TAS: %.1f NM ground distance, starting %.1f NM inside "
1329
+ "leg 3 (%.2f NM) - no path conflict." % (
1330
+ vnav["tod_air_nm"], m["hold_fix"], vnav["headwind_kt"],
1331
+ vnav["tas_kt"], vnav["tod_ground_nm"], vnav["tod_in_leg3_nm"],
1332
+ vnav["leg3_nm"]),
1333
+ "- Crossing altitude at %s continuing the %.1f deg path: %.0f ft "
1334
+ "vs constraint AT %.0f ft: **%s**" % (
1335
+ m["rta_fix"], vnav["fpa_deg"], vnav["alt_at_w005_ft_3deg"],
1336
+ vnav["w005_constraint_ft"],
1337
+ "PASS" if vnav["w005_crossing_ok"] else "FAIL"),
1338
+ "- Gradient required to comply: %.0f ft/NM (%.2f deg)" % (
1339
+ vnav["required_gradient_ft_nm"], vnav["required_fpa_deg"]),
1340
+ "- Vertical band checks: cruise FL%d within band: %s; %s crossing "
1341
+ "FL200 within band: %s" % (
1342
+ vnav["cruise_ft"] // 100,
1343
+ "PASS" if vnav["band_cruise_ok"] else "FAIL",
1344
+ m["hold_fix"], "PASS" if vnav["band_w004_ok"] else "FAIL"),
1345
+ "",
1346
+ "## 7. DME arc and radio navaid geometry checks",
1347
+ "",
1348
+ "Published arc transition check: radius %.0f NM from the arc "
1349
+ "navaid between radials %.0f and %.0f:" % (
1350
+ nav["dme"]["radius_nm"], nav["dme"]["radial_start_deg"],
1351
+ nav["dme"]["radial_end_deg"]),
1352
+ "- Arc length: %s NM; chord: %s NM; turn angle: %.1f deg" % (
1353
+ _fmt_nm(nav["dme"]["arc_length_nm"]),
1354
+ _fmt_nm(nav["dme"]["chord_nm"]), nav["dme"]["turn_angle_deg"]),
1355
+ "- Bank angle to hold the arc at %.0f kt TAS: %.2f deg; turn "
1356
+ "radius at 25 deg bank: %.2f NM" % (
1357
+ nav["dme_tas_kt"], nav["dme_bank_angle_deg"],
1358
+ nav["dme_turn_radius_nm_25deg"]),
1359
+ "- DME slant range at %.0f NM ground range, FL%d: %.0f m "
1360
+ "(planar ground range %.0f m); arc start point bearing %.1f deg, "
1361
+ "reciprocal radial %.1f deg" % (
1362
+ nav["dme"]["radius_nm"], nav["arc_altitude_ft"] // 100,
1363
+ nav["slant_range_m"], nav["slant_range_ground_m"],
1364
+ nav["start_point_bearing_deg"], nav["start_point_radial_deg"]),
1365
+ "",
1366
+ "## 8. Findings and recommendations",
1367
+ "",
1368
+ ]
1369
+ for f in m["findings"]:
1370
+ lines.append("- **[%s]** %s - %s" % (f["severity"].upper(),
1371
+ f["title"], f["detail"]))
1372
+ lines += [
1373
+ "",
1374
+ "Recommendation: resolve every [FINDING] before the route is "
1375
+ "offered for operational dispatch review. Numerical values in this "
1376
+ "assessment are computed by the role core from the stated route "
1377
+ "facts and the bound flight-management logic leaves.",
1378
+ "",
1379
+ "---",
1380
+ "*Generated by Aero Agent Roles flight-management-engineer core "
1381
+ "(%s). DRAFT for human flight operations / dispatch review. "
1382
+ "This document is not a clearance, not a filed flight plan, and "
1383
+ "not an approval; the operator and the flight crew retain all "
1384
+ "operational decision authority.*" % m["generated"],
1385
+ ]
1386
+ return "\n".join(lines)
1387
+
1388
+
1389
+ # ---------------------------------------------------------------------------
1390
+ # Evidence gates
1391
+ # ---------------------------------------------------------------------------
1392
+ GATE_CHECKS = {
1393
+ "route_stated": "route has >= 2 waypoints and >= 1 leg with computed geometry",
1394
+ "rnp_present": "an RNP value > 0 is stated for the assessed segment",
1395
+ "containment_checked": "ANP-vs-RNP containment verdict present (PASS/FAIL)",
1396
+ "entries_correct": "holding entry classified as direct/teardrop/parallel",
1397
+ "sign_off_honest": "document is marked draft-for-review, not an approval",
1398
+ }
1399
+
1400
+
1401
+ def check_report(model: dict) -> dict:
1402
+ """Run the evidence gates against the model. Returns per-gate + all_pass."""
1403
+ wps = model.get("waypoints") or []
1404
+ legs = model.get("legs") or []
1405
+ containment = model.get("rnp", {}).get("containment") or {}
1406
+ hold = model.get("hold") or {}
1407
+ entry = hold.get("entry", "")
1408
+ results = {
1409
+ "route_stated": (len(wps) >= 2 and len(legs) >= 1
1410
+ and all(isinstance(leg.get("distance_nm"), (int, float))
1411
+ and leg["distance_nm"] > 0 for leg in legs)),
1412
+ "rnp_present": (isinstance(model.get("rnp", {}).get("rnp_nm"),
1413
+ (int, float))
1414
+ and model["rnp"]["rnp_nm"] > 0),
1415
+ "containment_checked": (isinstance(containment.get("pass"), bool)
1416
+ and containment.get("verdict") in VALID_VERDICTS),
1417
+ "entries_correct": entry in VALID_ENTRY_TYPES,
1418
+ "sign_off_honest": model.get("status") == "draft-for-review",
1419
+ }
1420
+ results["all_pass"] = all(results.values())
1421
+ return results
1422
+
1423
+
1424
+ def check_report_markdown(md_text: str) -> dict:
1425
+ """Gate-check the rendered markdown deliverable (render markers)."""
1426
+ low = md_text.lower()
1427
+ checks = {
1428
+ "has_title": "flight plan and rnav/rnp route assessment" in low,
1429
+ "has_route": "total track distance" in low,
1430
+ "has_rnp": "rnp" in low and "anp" in low,
1431
+ "has_containment_verdict": "containment" in low and "pass" in low,
1432
+ "has_entry": any(e in low for e in VALID_ENTRY_TYPES),
1433
+ "has_draft_marker": "draft" in low,
1434
+ "has_not_approval": "not an approval" in low,
1435
+ }
1436
+ checks["all_pass"] = all(checks.values())
1437
+ return checks
1438
+
1439
+
1440
+ def validate_deliverable_text(md_text: str) -> dict:
1441
+ """Public entry point used by gate tooling: check an assessment doc."""
1442
+ return check_report_markdown(md_text)
1443
+
1444
+
1445
+ # ---------------------------------------------------------------------------
1446
+ # Example route (for tests and worked-example generation)
1447
+ # ---------------------------------------------------------------------------
1448
+ def example_item() -> Route:
1449
+ return Route()
1450
+
1451
+
1452
+ def example_report_markdown() -> str:
1453
+ return render_report_markdown(build_report(example_item()))
1454
+
1455
+
1456
+ if __name__ == "__main__":
1457
+ model = build_report(example_item())
1458
+ md = render_report_markdown(model)
1459
+ print("ROUTE: %s" % model["route_id"])
1460
+ print("TOTAL DISTANCE: %.2f NM" % model["total_distance_nm"])
1461
+ print("CONTAINMENT: %s (margin available %.1f m)"
1462
+ % (model["rnp"]["containment"]["verdict"],
1463
+ model["rnp"]["containment"]["margin_available_m"]))
1464
+ print("HOLD ENTRY: %s (alpha %.1f deg)"
1465
+ % (model["hold"]["entry"], model["hold"]["alpha_deg"]))
1466
+ print("RTA: %s, command Mach %.4f"
1467
+ % (model["rta"]["verdict"], model["rta"]["command_mach"]))
1468
+ print("W005 CROSSING: %s at %.0f ft"
1469
+ % ("PASS" if model["vnav"]["w005_crossing_ok"] else "FAIL",
1470
+ model["vnav"]["alt_at_w005_ft_3deg"]))
1471
+ print("FINDINGS: %d" % model["n_findings"])
1472
+ print("GATES: %s" % check_report(model))
1473
+ print("RENDERED: %d chars" % len(md))