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.
- package/NOTICE +18 -0
- package/README.md +49 -0
- package/bin/aero-agent-roles.js +127 -0
- package/lib/catalog.js +97 -0
- package/lib/install.js +48 -0
- package/lib/mcp.js +199 -0
- package/manifest.json +516 -0
- package/package.json +44 -0
- package/roles/aerodynamics-engineer/ROLE.md +153 -0
- package/roles/aerodynamics-engineer/SOURCES.md +12 -0
- package/roles/aerodynamics-engineer/cli.py +143 -0
- package/roles/aerodynamics-engineer/core/aerodynamics_core.py +1218 -0
- package/roles/aerodynamics-engineer/templates/aero-design-report-template.md +72 -0
- package/roles/aerodynamics-engineer/tests/test_aerodynamics_core.py +227 -0
- package/roles/aerodynamics-engineer/tests/test_role_aerodynamics.py +94 -0
- package/roles/aircraft-design-engineer/ROLE.md +138 -0
- package/roles/aircraft-design-engineer/SOURCES.md +12 -0
- package/roles/aircraft-design-engineer/cli.py +152 -0
- package/roles/aircraft-design-engineer/core/aircraft_design_core.py +1278 -0
- package/roles/aircraft-design-engineer/templates/concept-design-template.md +99 -0
- package/roles/aircraft-design-engineer/tests/test_aircraft_design_core.py +269 -0
- package/roles/aircraft-design-engineer/tests/test_role_aircraft_design_engineer.py +106 -0
- package/roles/airworthiness-compliance-engineer/ROLE.md +122 -0
- package/roles/airworthiness-compliance-engineer/SOURCES.md +16 -0
- package/roles/airworthiness-compliance-engineer/cli.py +146 -0
- package/roles/airworthiness-compliance-engineer/core/airworthiness_core.py +894 -0
- package/roles/airworthiness-compliance-engineer/templates/compliance-matrix-template.md +46 -0
- package/roles/airworthiness-compliance-engineer/tests/test_airworthiness_core.py +224 -0
- package/roles/airworthiness-compliance-engineer/tests/test_role_airworthiness.py +85 -0
- package/roles/as9100-quality-auditor/ROLE.md +128 -0
- package/roles/as9100-quality-auditor/SOURCES.md +16 -0
- package/roles/as9100-quality-auditor/cli.py +136 -0
- package/roles/as9100-quality-auditor/core/as9100_core.py +857 -0
- package/roles/as9100-quality-auditor/templates/findings-report-template.md +119 -0
- package/roles/as9100-quality-auditor/tests/test_as9100_cli_profile.py +105 -0
- package/roles/as9100-quality-auditor/tests/test_as9100_core.py +204 -0
- package/roles/as9100-quality-auditor/tests/test_role_as9100.py +90 -0
- package/roles/data-bus-avionics-engineer/ROLE.md +146 -0
- package/roles/data-bus-avionics-engineer/SOURCES.md +23 -0
- package/roles/data-bus-avionics-engineer/cli.py +311 -0
- package/roles/data-bus-avionics-engineer/core/data_bus_avionics_core.py +1121 -0
- package/roles/data-bus-avionics-engineer/templates/bus-assessment-template.md +134 -0
- package/roles/data-bus-avionics-engineer/tests/test_bundle_protocol.py +137 -0
- package/roles/data-bus-avionics-engineer/tests/test_data_bus_avionics_core.py +261 -0
- package/roles/data-bus-avionics-engineer/tests/test_role_data_bus_avionics_engineer.py +121 -0
- package/roles/do160-environmental-engineer/ROLE.md +136 -0
- package/roles/do160-environmental-engineer/SOURCES.md +30 -0
- package/roles/do160-environmental-engineer/cli.py +147 -0
- package/roles/do160-environmental-engineer/core/do160_environmental_core.py +1246 -0
- package/roles/do160-environmental-engineer/templates/environmental-qualification-plan-template.md +114 -0
- package/roles/do160-environmental-engineer/tests/test_do160_environmental_core.py +312 -0
- package/roles/do160-environmental-engineer/tests/test_role_do160_environmental_engineer.py +281 -0
- package/roles/do178c-cert-engineer/ROLE.md +120 -0
- package/roles/do178c-cert-engineer/SOURCES.md +16 -0
- package/roles/do178c-cert-engineer/cli.py +129 -0
- package/roles/do178c-cert-engineer/core/do178c_core.py +411 -0
- package/roles/do178c-cert-engineer/templates/psac-template.md +74 -0
- package/roles/do178c-cert-engineer/tests/test_do178c_cert_engineer_core.py +91 -0
- package/roles/do178c-cert-engineer/tests/test_role_do178c.py +109 -0
- package/roles/do254-hardware-engineer/ROLE.md +127 -0
- package/roles/do254-hardware-engineer/SOURCES.md +19 -0
- package/roles/do254-hardware-engineer/cli.py +308 -0
- package/roles/do254-hardware-engineer/core/do254_hardware_core.py +761 -0
- package/roles/do254-hardware-engineer/templates/phac-template.md +87 -0
- package/roles/do254-hardware-engineer/tests/test_do254_bundle_profile.py +152 -0
- package/roles/do254-hardware-engineer/tests/test_do254_hardware_core.py +200 -0
- package/roles/do254-hardware-engineer/tests/test_role_do254_hardware.py +108 -0
- package/roles/engineering-analysis-engineer/ROLE.md +154 -0
- package/roles/engineering-analysis-engineer/SOURCES.md +15 -0
- package/roles/engineering-analysis-engineer/cli.py +151 -0
- package/roles/engineering-analysis-engineer/core/engineering_analysis_engineer_core.py +866 -0
- package/roles/engineering-analysis-engineer/templates/analysis-memo-template.md +73 -0
- package/roles/engineering-analysis-engineer/tests/test_engineering_analysis_engineer_core.py +367 -0
- package/roles/engineering-analysis-engineer/tests/test_role_engineering_analysis_engineer.py +113 -0
- package/roles/flight-management-engineer/ROLE.md +160 -0
- package/roles/flight-management-engineer/SOURCES.md +30 -0
- package/roles/flight-management-engineer/cli.py +258 -0
- package/roles/flight-management-engineer/core/flight_management_core.py +1473 -0
- package/roles/flight-management-engineer/templates/route-assessment-template.md +94 -0
- package/roles/flight-management-engineer/tests/test_flight_management_core.py +437 -0
- package/roles/flight-management-engineer/tests/test_role_flight_management_engineer.py +123 -0
- package/roles/flight-mechanics-engineer/ROLE.md +162 -0
- package/roles/flight-mechanics-engineer/SOURCES.md +51 -0
- package/roles/flight-mechanics-engineer/cli.py +149 -0
- package/roles/flight-mechanics-engineer/core/flight_mechanics_core.py +1507 -0
- package/roles/flight-mechanics-engineer/templates/perf-sc-report-template.md +82 -0
- package/roles/flight-mechanics-engineer/tests/test_flight_mechanics_core.py +277 -0
- package/roles/flight-mechanics-engineer/tests/test_role_flight_mechanics_engineer.py +141 -0
- package/roles/flight-test-engineer/ROLE.md +137 -0
- package/roles/flight-test-engineer/SOURCES.md +24 -0
- package/roles/flight-test-engineer/cli.py +135 -0
- package/roles/flight-test-engineer/core/flight_test_core.py +931 -0
- package/roles/flight-test-engineer/templates/flight-test-template.md +143 -0
- package/roles/flight-test-engineer/tests/test_flight_test_engineer_core.py +227 -0
- package/roles/flight-test-engineer/tests/test_role_flight_test_engineer.py +150 -0
- package/roles/flight-test-performance-engineer/ROLE.md +165 -0
- package/roles/flight-test-performance-engineer/SOURCES.md +20 -0
- package/roles/flight-test-performance-engineer/cli.py +319 -0
- package/roles/flight-test-performance-engineer/core/flight_test_performance_core.py +1398 -0
- package/roles/flight-test-performance-engineer/templates/performance-report-template.md +105 -0
- package/roles/flight-test-performance-engineer/tests/test_flight_test_performance_engineer_core.py +308 -0
- package/roles/flight-test-performance-engineer/tests/test_role_flight_test_performance_engineer.py +165 -0
- package/roles/gnc-engineer/ROLE.md +141 -0
- package/roles/gnc-engineer/SOURCES.md +35 -0
- package/roles/gnc-engineer/cli.py +141 -0
- package/roles/gnc-engineer/core/gnc_core.py +1074 -0
- package/roles/gnc-engineer/templates/gnc-report-template.md +105 -0
- package/roles/gnc-engineer/tests/test_gnc_engineer_core.py +238 -0
- package/roles/gnc-engineer/tests/test_role_gnc_engineer.py +142 -0
- package/roles/guidance-engineer/ROLE.md +156 -0
- package/roles/guidance-engineer/SOURCES.md +43 -0
- package/roles/guidance-engineer/cli.py +280 -0
- package/roles/guidance-engineer/core/guidance_core.py +820 -0
- package/roles/guidance-engineer/templates/guidance-report-template.md +70 -0
- package/roles/guidance-engineer/tests/test_guidance_engineer_core.py +269 -0
- package/roles/guidance-engineer/tests/test_role_guidance_engineer.py +146 -0
- package/roles/high-speed-aerodynamics-engineer/ROLE.md +161 -0
- package/roles/high-speed-aerodynamics-engineer/SOURCES.md +18 -0
- package/roles/high-speed-aerodynamics-engineer/cli.py +282 -0
- package/roles/high-speed-aerodynamics-engineer/core/high_speed_aero_core.py +1566 -0
- package/roles/high-speed-aerodynamics-engineer/templates/high-speed-memo-template.md +75 -0
- package/roles/high-speed-aerodynamics-engineer/tests/test_bundle_protocol.py +103 -0
- package/roles/high-speed-aerodynamics-engineer/tests/test_high_speed_aero_core.py +280 -0
- package/roles/high-speed-aerodynamics-engineer/tests/test_role_high_speed_aerodynamics.py +125 -0
- package/roles/ndt-engineer/ROLE.md +157 -0
- package/roles/ndt-engineer/SOURCES.md +18 -0
- package/roles/ndt-engineer/cli.py +316 -0
- package/roles/ndt-engineer/core/ndt_core.py +1590 -0
- package/roles/ndt-engineer/templates/ndt-plan-template.md +109 -0
- package/roles/ndt-engineer/tests/test_ndt_cli_bundle.py +183 -0
- package/roles/ndt-engineer/tests/test_ndt_core.py +437 -0
- package/roles/ndt-engineer/tests/test_role_ndt_engineer.py +148 -0
- package/roles/numerical-analysis-engineer/ROLE.md +158 -0
- package/roles/numerical-analysis-engineer/SOURCES.md +19 -0
- package/roles/numerical-analysis-engineer/cli.py +414 -0
- package/roles/numerical-analysis-engineer/core/numerical_analysis_core.py +1220 -0
- package/roles/numerical-analysis-engineer/templates/verification-memo-template.md +120 -0
- package/roles/numerical-analysis-engineer/tests/test_bundle_protocol.py +157 -0
- package/roles/numerical-analysis-engineer/tests/test_numerical_analysis_core.py +337 -0
- package/roles/numerical-analysis-engineer/tests/test_role_numerical_analysis_engineer.py +117 -0
- package/roles/propulsion-engineer/ROLE.md +163 -0
- package/roles/propulsion-engineer/SOURCES.md +15 -0
- package/roles/propulsion-engineer/cli.py +168 -0
- package/roles/propulsion-engineer/core/propulsion_core.py +1291 -0
- package/roles/propulsion-engineer/templates/propulsion-report-template.md +92 -0
- package/roles/propulsion-engineer/tests/test_propulsion_engineer_core.py +294 -0
- package/roles/propulsion-engineer/tests/test_role_propulsion_engineer.py +106 -0
- package/roles/space-systems-engineer/ROLE.md +161 -0
- package/roles/space-systems-engineer/SOURCES.md +49 -0
- package/roles/space-systems-engineer/cli.py +173 -0
- package/roles/space-systems-engineer/core/space_systems_core.py +980 -0
- package/roles/space-systems-engineer/templates/space-systems-template.md +88 -0
- package/roles/space-systems-engineer/tests/test_role_space_systems_engineer.py +117 -0
- package/roles/space-systems-engineer/tests/test_space_systems_engineer_core.py +247 -0
- package/roles/state-estimation-engineer/ROLE.md +154 -0
- package/roles/state-estimation-engineer/SOURCES.md +27 -0
- package/roles/state-estimation-engineer/cli.py +326 -0
- package/roles/state-estimation-engineer/core/state_estimation_core.py +958 -0
- package/roles/state-estimation-engineer/templates/nav-state-estimator-design-template.md +76 -0
- package/roles/state-estimation-engineer/tests/test_bundle_protocol.py +110 -0
- package/roles/state-estimation-engineer/tests/test_role_state_estimation_engineer.py +127 -0
- package/roles/state-estimation-engineer/tests/test_state_estimation_engineer_core.py +231 -0
- package/roles/structures-loads-engineer/ROLE.md +171 -0
- package/roles/structures-loads-engineer/SOURCES.md +44 -0
- package/roles/structures-loads-engineer/cli.py +219 -0
- package/roles/structures-loads-engineer/core/structures_loads_core.py +1112 -0
- package/roles/structures-loads-engineer/templates/loads-strength-report-template.md +96 -0
- package/roles/structures-loads-engineer/tests/test_bundle_protocol.py +101 -0
- package/roles/structures-loads-engineer/tests/test_role_structures_loads_engineer.py +147 -0
- 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))
|