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,94 @@
|
|
|
1
|
+
# Flight Plan and RNAV/RNP Route Assessment
|
|
2
|
+
|
|
3
|
+
**Route:** AERO ROUTE R1 (illustrative - not a filed plan)
|
|
4
|
+
**Aircraft:** Mid-size transport (illustrative example)
|
|
5
|
+
**Cruise level:** FL350 (illustrative profile)
|
|
6
|
+
**Status:** draft-for-review
|
|
7
|
+
|
|
8
|
+
## 1. Route structure and track distance
|
|
9
|
+
|
|
10
|
+
The assessed route contains 5 waypoints and 4 legs. Waypoints are illustrative identifiers with coordinates; the route is a synthetic example for the role deliverable, not a published navigation database procedure.
|
|
11
|
+
|
|
12
|
+
| Waypoint | Latitude | Longitude | Role |
|
|
13
|
+
|---|---|---|---|
|
|
14
|
+
| W001 | 41.0000 N | 75.0000 W | route origin / start of RNP segment |
|
|
15
|
+
| W002 | 40.0000 N | 74.5000 W | RF leg entry fix |
|
|
16
|
+
| W003 | 39.7418 N | 74.6499 W | derived: RF leg exit fix (computed) |
|
|
17
|
+
| HOLDF | 38.7418 N | 74.6499 W | derived: holding fix (computed from W003) |
|
|
18
|
+
| W005 | 38.0000 N | 75.0000 W | RTA fix / VNAV crossing constraint |
|
|
19
|
+
|
|
20
|
+
| Leg | Type | From | To | Track (deg)* | Distance (NM) |
|
|
21
|
+
|---|---|---|---|---|---|
|
|
22
|
+
*TF legs: initial great-circle track. RF leg: exit track after the turn (the value shown is the flight tangent at the exit fix).*
|
|
23
|
+
|
|
24
|
+
| 1 | TF (great-circle) | W001 | W002 | 159.0 deg | 64.23 |
|
|
25
|
+
| 2 | RF (radius-to-fix) | W002 | W003 | 249.0 deg | 18.85 |
|
|
26
|
+
| 3 | TF (great-circle) | W003 | HOLDF | 180.0 deg | 60.04 |
|
|
27
|
+
| 4 | TF (great-circle) | HOLDF | W005 | 200.4 deg | 47.49 |
|
|
28
|
+
|
|
29
|
+
Total track distance: **190.61 NM**.
|
|
30
|
+
|
|
31
|
+
Leg 1 track-distance method check (rhumb line vs great circle): rhumb 64.233 NM, great-circle 64.233 NM, difference 0.2 m (0.000 %), rhumb course 159.2 deg. Great-circle tracks are used for the TF legs.
|
|
32
|
+
|
|
33
|
+
## 2. Radius-to-fix (RF) leg construction (leg 2)
|
|
34
|
+
|
|
35
|
+
The RF leg from W002 turns RIGHT at radius 12.0 NM with the published swept angle 90.0 deg. Entry fix is the origin of the leg local tangent frame (x = east, y = north, NM).
|
|
36
|
+
|
|
37
|
+
- Turn centre (local NM): (-11.204, -4.296)
|
|
38
|
+
- Exit fix (local NM): (-6.908, -15.501) (on radius circle: True)
|
|
39
|
+
- Swept angle: 90.0 deg; arc length: 18.85 NM
|
|
40
|
+
- Exit track: 249.0 deg; chord: 16.97 NM
|
|
41
|
+
- RF leg valid: True
|
|
42
|
+
|
|
43
|
+
## 3. RNP containment on the RNP segment
|
|
44
|
+
|
|
45
|
+
Segment: W002 - HOLDF - W005 (en-route/arrival RNAV portion)
|
|
46
|
+
- Required navigation performance (RNP): 0.3 NM = 555.6 m
|
|
47
|
+
- Lateral 1-sigma position error input: 200 m
|
|
48
|
+
- Actual navigation performance (ANP), 95% containment (2 x sigma): 400.0 m (0.2160 NM)
|
|
49
|
+
- Required margin (10 % of RNP): 55.6 m
|
|
50
|
+
- Containment check (ANP + margin <= RNP): **PASS** - margin available 100.0 m
|
|
51
|
+
|
|
52
|
+
## 4. Holding pattern entry at HOLDF
|
|
53
|
+
|
|
54
|
+
Approach track at the holding fix: 180.0 deg (leg 3, due-south meridian). Hold inbound course 260.0 deg, right turns. Approach angle alpha to the holding axis: 80.0 deg (sector rule: direct <= 70 deg, teardrop 70 < alpha <= 110 deg, parallel > 110 deg).
|
|
55
|
+
|
|
56
|
+
- Entry type: **teardrop**
|
|
57
|
+
- Outbound leg timing at FL200 (20000 ft): 90 s
|
|
58
|
+
- Outbound heading 80.0 deg, wind 40.0 deg/30 kt, TAS 260 kt: 1-in-60 corrected heading **75.55 deg**
|
|
59
|
+
- First (entry) lap estimate: 330 s
|
|
60
|
+
|
|
61
|
+
## 5. RTA time control at W005
|
|
62
|
+
|
|
63
|
+
RTA evaluated from the HOLDF exit, 47.5 NM before W005, slot demand 25 s later than the nominal arrival.
|
|
64
|
+
- Current ground speed: 240.0 m/s; nominal ETA: 366.5 s; RTA time error: -25.0 s
|
|
65
|
+
- Required ground speed: 224.7 m/s; required Mach: 0.7267
|
|
66
|
+
- Commanded Mach: **0.7267**; verdict: **rta-feasible** (feasible: True)
|
|
67
|
+
- Achievable window at 20000 ft: [346 s, 407 s] (61 s wide)
|
|
68
|
+
|
|
69
|
+
## 6. Performance and vertical (VNAV) profile
|
|
70
|
+
|
|
71
|
+
ECON cruise at FL350, cost index 30 kg/h, weight 62000 kg:
|
|
72
|
+
- ECON Mach: **0.7723** (TAS 445.2 kt, fuel 5.323 kg/NM); envelope [0.70, 0.82] Mach; max-range reference (CI 0): Mach 0.7706, fuel 5.323 kg/NM
|
|
73
|
+
|
|
74
|
+
Vertical path: descend FL350 -> FL200 on a 3.0 deg flight path (318.4 ft/NM) to cross HOLDF AT FL200, then continue toward W005 (constraint AT 6000 ft).
|
|
75
|
+
- Top of descent (air): 47.1 NM before HOLDF; with 15 kt headwind at 450 kt TAS: 48.7 NM ground distance, starting 11.3 NM inside leg 3 (60.04 NM) - no path conflict.
|
|
76
|
+
- Crossing altitude at W005 continuing the 3.0 deg path: 4877 ft vs constraint AT 6000 ft: **FAIL**
|
|
77
|
+
- Gradient required to comply: 295 ft/NM (2.78 deg)
|
|
78
|
+
- Vertical band checks: cruise FL350 within band: PASS; HOLDF crossing FL200 within band: PASS
|
|
79
|
+
|
|
80
|
+
## 7. DME arc and radio navaid geometry checks
|
|
81
|
+
|
|
82
|
+
Published arc transition check: radius 15 NM from the arc navaid between radials 30 and 90:
|
|
83
|
+
- Arc length: 15.71 NM; chord: 15.00 NM; turn angle: 60.0 deg
|
|
84
|
+
- Bank angle to hold the arc at 250 kt TAS: 3.47 deg; turn radius at 25 deg bank: 1.95 NM
|
|
85
|
+
- DME slant range at 15 NM ground range, FL200: 28441 m (planar ground range 27780 m); arc start point bearing 30.0 deg, reciprocal radial 210.0 deg
|
|
86
|
+
|
|
87
|
+
## 8. Findings and recommendations
|
|
88
|
+
|
|
89
|
+
- **[FINDING]** W005 crossing constraint not met on the planned 3.0 deg path - Continuing the 3.0 deg descent path from the FL200 hold crossing reaches W005 at 4877 ft, below the AT 6000 ft constraint by 1123 ft. Replan the arrival profile: the descent leg from the hold to W005 requires a gradient no steeper than 295 ft/NM (2.78 deg).
|
|
90
|
+
|
|
91
|
+
Recommendation: resolve every [FINDING] before the route is offered for operational dispatch review. Numerical values in this assessment are computed by the role core from the stated route facts and the bound flight-management logic leaves.
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
*Generated by Aero Agent Roles flight-management-engineer core (2026-09-05). DRAFT for human flight operations / dispatch review. This document is not a clearance, not a filed flight plan, and not an approval; the operator and the flight crew retain all operational decision authority.*
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Test flight_management_core: the executable engine of the
|
|
3
|
+
Flight Management Engineer role.
|
|
4
|
+
|
|
5
|
+
Proves the role can DO its job standalone (no AeroSkills needed) and
|
|
6
|
+
that the domain numbers are real: RNP containment (ANP = 2 x sigma,
|
|
7
|
+
95% containment), the 70/110 holding-entry sector rule, outbound leg
|
|
8
|
+
timing and the 1-in-60 wind correction (leaf worked anchor 94.7140),
|
|
9
|
+
RF leg construction (radius circle, swept angle, exit track, chord),
|
|
10
|
+
DME arc geometry and bank angle, great-circle/rhumb anchors (1 deg on
|
|
11
|
+
the equator = R*pi/180), the RTA speed-command law, VNAV top-of-descent
|
|
12
|
+
and gradients, the ECON cruise clamp, radio navaid slant range, plus
|
|
13
|
+
the builder, the evidence gates and standalone mode.
|
|
14
|
+
"""
|
|
15
|
+
import json
|
|
16
|
+
import math
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
import unittest
|
|
20
|
+
|
|
21
|
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
22
|
+
"..", "core"))
|
|
23
|
+
from flight_management_core import ( # noqa: E402
|
|
24
|
+
NM_TO_M, G_MS2, R_EARTH_M,
|
|
25
|
+
anp_from_sigma, build_report, check_report, check_report_markdown,
|
|
26
|
+
containment_analysis, containment_margin_m, containment_pass,
|
|
27
|
+
dme_arc_bank_angle_deg, dme_arc_chord_nm, dme_arc_geometry,
|
|
28
|
+
dme_arc_length_nm, dme_arc_turn_radius_nm, example_item,
|
|
29
|
+
example_report_markdown, fpa_gradient_ft_nm, great_circle_distance_m,
|
|
30
|
+
great_circle_track_deg, hold_alpha_deg, hold_entry_analysis,
|
|
31
|
+
holding_entry_lap_seconds, holding_entry_type, isa_speed_of_sound_m_s,
|
|
32
|
+
margin_available_m, nav_bearing_deg, nav_dme_slant_range_m,
|
|
33
|
+
nav_radial_deg, outbound_leg_seconds, perf_cost_index,
|
|
34
|
+
perf_econ_mach_from_cost_index, perf_econ_summary,
|
|
35
|
+
perf_max_range_speed_kts, perf_tas_from_mach, render_report_markdown,
|
|
36
|
+
rf_leg_geometry, rhumb_course_deg, rhumb_distance_m,
|
|
37
|
+
rhumb_vs_great_circle, rnp_nm_to_m, rta_achievable_window,
|
|
38
|
+
rta_eta_s, rta_required_ground_speed_m_s, rta_speed_command,
|
|
39
|
+
rta_time_error_s, vertical_band_ok, vnav_altitude_at_ft,
|
|
40
|
+
vnav_constraint_ok, vnav_descent_gradient_ft_nm, vnav_fpa_deg,
|
|
41
|
+
vnav_tod_distance_nm, wind_corrected_heading,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TestRnpContainment(unittest.TestCase):
|
|
46
|
+
"""ANP = 2 x 1-sigma (95% containment), margin and verdict rules."""
|
|
47
|
+
|
|
48
|
+
def test_anp_two_sigma(self):
|
|
49
|
+
self.assertAlmostEqual(anp_from_sigma(92.6), 185.2, places=3)
|
|
50
|
+
self.assertAlmostEqual(anp_from_sigma(200.0), 400.0, places=3)
|
|
51
|
+
|
|
52
|
+
def test_rnp_nm_to_m(self):
|
|
53
|
+
self.assertAlmostEqual(rnp_nm_to_m(0.3), 0.3 * 1852.0, places=6)
|
|
54
|
+
self.assertAlmostEqual(rnp_nm_to_m(0.3), 555.6, places=1)
|
|
55
|
+
|
|
56
|
+
def test_margin_and_available(self):
|
|
57
|
+
self.assertAlmostEqual(containment_margin_m(555.6, 0.10), 55.56,
|
|
58
|
+
places=6)
|
|
59
|
+
self.assertAlmostEqual(margin_available_m(185.2, 555.6, 0.10),
|
|
60
|
+
555.6 - 55.56 - 185.2, places=6)
|
|
61
|
+
|
|
62
|
+
def test_containment_pass_inclusive(self):
|
|
63
|
+
# boundary inclusive: anp == rnp passes with zero margin
|
|
64
|
+
self.assertTrue(containment_pass(555.6, 555.6, 0.0))
|
|
65
|
+
self.assertTrue(containment_pass(185.2, 555.6, 0.10))
|
|
66
|
+
self.assertFalse(containment_pass(556.0, 555.6, 0.0))
|
|
67
|
+
|
|
68
|
+
def test_analysis_verdicts(self):
|
|
69
|
+
ok = containment_analysis(sigma_lateral_m=200.0, rnp_m=555.6,
|
|
70
|
+
margin_fraction=0.10)
|
|
71
|
+
self.assertTrue(ok["pass"])
|
|
72
|
+
self.assertEqual(ok["verdict"], "PASS")
|
|
73
|
+
bad = containment_analysis(sigma_lateral_m=400.0, rnp_m=555.6,
|
|
74
|
+
margin_fraction=0.10)
|
|
75
|
+
self.assertFalse(bad["pass"])
|
|
76
|
+
self.assertEqual(bad["verdict"], "FAIL")
|
|
77
|
+
|
|
78
|
+
def test_analysis_anp_direct(self):
|
|
79
|
+
r = containment_analysis(anp_m=400.0, rnp_m=555.6, margin_fraction=0.0)
|
|
80
|
+
self.assertAlmostEqual(r["anp_m"], 400.0, places=3)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class TestHoldingEntry(unittest.TestCase):
|
|
84
|
+
"""70/110 sector rule, outbound timing, 1-in-60 wind correction."""
|
|
85
|
+
|
|
86
|
+
def test_alpha_arithmetic(self):
|
|
87
|
+
self.assertAlmostEqual(hold_alpha_deg(180.0, 260.0), 80.0, places=6)
|
|
88
|
+
self.assertAlmostEqual(hold_alpha_deg(90.0, 90.0), 0.0, places=6)
|
|
89
|
+
self.assertAlmostEqual(hold_alpha_deg(90.0, 270.0), 180.0, places=6)
|
|
90
|
+
self.assertAlmostEqual(hold_alpha_deg(350.0, 10.0), 20.0, places=6)
|
|
91
|
+
self.assertAlmostEqual(hold_alpha_deg(30.0, 90.0), 60.0, places=6)
|
|
92
|
+
|
|
93
|
+
def test_entry_sector_boundaries(self):
|
|
94
|
+
self.assertEqual(holding_entry_type(0.0, "right"), "direct")
|
|
95
|
+
self.assertEqual(holding_entry_type(70.0, "right"), "direct")
|
|
96
|
+
self.assertEqual(holding_entry_type(70.001, "right"), "teardrop")
|
|
97
|
+
self.assertEqual(holding_entry_type(90.0, "right"), "teardrop")
|
|
98
|
+
self.assertEqual(holding_entry_type(110.0, "right"), "teardrop")
|
|
99
|
+
self.assertEqual(holding_entry_type(110.001, "right"), "parallel")
|
|
100
|
+
self.assertEqual(holding_entry_type(180.0, "right"), "parallel")
|
|
101
|
+
|
|
102
|
+
def test_left_hand_mirrors(self):
|
|
103
|
+
for alpha in (50.0, 90.0, 130.0, 70.0, 110.0):
|
|
104
|
+
self.assertEqual(holding_entry_type(alpha, "left"),
|
|
105
|
+
holding_entry_type(alpha, "right"))
|
|
106
|
+
|
|
107
|
+
def test_entry_valueerrors(self):
|
|
108
|
+
with self.assertRaises(ValueError):
|
|
109
|
+
holding_entry_type(-1.0, "right")
|
|
110
|
+
with self.assertRaises(ValueError):
|
|
111
|
+
holding_entry_type(181.0, "right")
|
|
112
|
+
with self.assertRaises(ValueError):
|
|
113
|
+
holding_entry_type(50.0, "bank")
|
|
114
|
+
|
|
115
|
+
def test_outbound_leg_timing(self):
|
|
116
|
+
self.assertEqual(outbound_leg_seconds(12000.0), 60.0)
|
|
117
|
+
self.assertEqual(outbound_leg_seconds(14000.0), 60.0)
|
|
118
|
+
self.assertEqual(outbound_leg_seconds(14001.0), 90.0)
|
|
119
|
+
self.assertEqual(outbound_leg_seconds(20000.0), 90.0)
|
|
120
|
+
|
|
121
|
+
def test_wind_correction_leaf_anchor(self):
|
|
122
|
+
# Worked anchor from the holding-pattern-entry leaf: 4.71 deg at
|
|
123
|
+
# outbound 090, wind from 135/20 kt, TAS 180 kt.
|
|
124
|
+
corr, raw = wind_corrected_heading(90.0, 135.0, 20.0, 180.0)
|
|
125
|
+
self.assertAlmostEqual(corr, 94.7140, places=3)
|
|
126
|
+
self.assertAlmostEqual(raw, 4.7140, places=3)
|
|
127
|
+
corr2, raw2 = wind_corrected_heading(90.0, 315.0, 20.0, 180.0)
|
|
128
|
+
self.assertAlmostEqual(corr + corr2, 180.0, places=6)
|
|
129
|
+
self.assertAlmostEqual(raw2, -4.7140, places=3)
|
|
130
|
+
|
|
131
|
+
def test_entry_lap_times(self):
|
|
132
|
+
self.assertEqual(holding_entry_lap_seconds("direct", 60.0), 240.0)
|
|
133
|
+
self.assertEqual(holding_entry_lap_seconds("teardrop", 90.0), 330.0)
|
|
134
|
+
self.assertEqual(holding_entry_lap_seconds("parallel", 60.0), 360.0)
|
|
135
|
+
|
|
136
|
+
def test_full_hold_analysis(self):
|
|
137
|
+
h = hold_entry_analysis(arrival_track_deg=180.0,
|
|
138
|
+
hold_inbound_course_deg=260.0,
|
|
139
|
+
turn_direction="right", altitude_ft=20000.0,
|
|
140
|
+
outbound_heading_deg=80.0,
|
|
141
|
+
wind_from_deg=40.0, wind_speed_kt=30.0,
|
|
142
|
+
tas_kt=260.0)
|
|
143
|
+
self.assertEqual(h["entry"], "teardrop")
|
|
144
|
+
self.assertAlmostEqual(h["alpha_deg"], 80.0, places=1)
|
|
145
|
+
self.assertEqual(h["outbound_leg_s"], 90.0)
|
|
146
|
+
self.assertEqual(h["entry_lap_s"], 330.0)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class TestLateralGeometry(unittest.TestCase):
|
|
150
|
+
"""Great-circle and rhumb anchors (R = 6371000 m)."""
|
|
151
|
+
|
|
152
|
+
def test_great_circle_equator_degree(self):
|
|
153
|
+
# 1 deg of longitude at the equator = R * pi/180
|
|
154
|
+
expected = R_EARTH_M * math.pi / 180.0
|
|
155
|
+
self.assertAlmostEqual(great_circle_distance_m(0.0, 0.0, 0.0, 1.0),
|
|
156
|
+
expected, places=1)
|
|
157
|
+
|
|
158
|
+
def test_great_circle_meridian_degree(self):
|
|
159
|
+
self.assertAlmostEqual(great_circle_distance_m(0.0, 0.0, 1.0, 0.0),
|
|
160
|
+
R_EARTH_M * math.pi / 180.0, places=1)
|
|
161
|
+
|
|
162
|
+
def test_great_circle_tracks(self):
|
|
163
|
+
self.assertAlmostEqual(great_circle_track_deg(0.0, 0.0, 0.0, 10.0),
|
|
164
|
+
90.0, places=6)
|
|
165
|
+
self.assertAlmostEqual(great_circle_track_deg(0.0, 0.0, 10.0, 0.0),
|
|
166
|
+
0.0, places=6)
|
|
167
|
+
self.assertAlmostEqual(great_circle_track_deg(0.0, 0.0, -10.0, 0.0),
|
|
168
|
+
180.0, places=6)
|
|
169
|
+
|
|
170
|
+
def test_rhumb_meridian_equals_gc(self):
|
|
171
|
+
r = rhumb_vs_great_circle(40.0, -75.0, 41.0, -75.0)
|
|
172
|
+
self.assertAlmostEqual(r["delta_m"], 0.0, places=6)
|
|
173
|
+
self.assertEqual(rhumb_course_deg(0.0, 0.0, 10.0, 0.0), 0.0)
|
|
174
|
+
self.assertEqual(rhumb_course_deg(0.0, 0.0, 0.0, 10.0), 90.0)
|
|
175
|
+
|
|
176
|
+
def test_rhumb_parallel_at_60(self):
|
|
177
|
+
# along-parallel at lat 60 over 1 deg: R*rad(1)*cos(60)
|
|
178
|
+
expected = 6371000.0 * math.radians(1.0) * math.cos(math.radians(60.0))
|
|
179
|
+
self.assertAlmostEqual(rhumb_distance_m(60.0, 0.0, 60.0, 1.0),
|
|
180
|
+
expected, places=1)
|
|
181
|
+
|
|
182
|
+
def test_rhumb_longer_than_gc(self):
|
|
183
|
+
r = rhumb_vs_great_circle(30.0, -70.0, 32.0, -65.0)
|
|
184
|
+
self.assertGreaterEqual(r["delta_m"], 0.0)
|
|
185
|
+
self.assertGreater(r["great_circle_m"], 0.0)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class TestRfLeg(unittest.TestCase):
|
|
189
|
+
"""RF construction anchors: EF(0,0), inbound 090, R=15 NM, RIGHT."""
|
|
190
|
+
|
|
191
|
+
def test_rf_geometry_anchor(self):
|
|
192
|
+
g = rf_leg_geometry((0.0, 0.0), 90.0, 15.0, "RIGHT", 90.0)
|
|
193
|
+
self.assertAlmostEqual(g["center_nm"][0], 0.0, places=3)
|
|
194
|
+
self.assertAlmostEqual(g["center_nm"][1], -15.0, places=3)
|
|
195
|
+
self.assertAlmostEqual(g["xf"][0], 15.0, places=3)
|
|
196
|
+
self.assertAlmostEqual(g["xf"][1], -15.0, places=3)
|
|
197
|
+
self.assertTrue(g["exit_on_arc"])
|
|
198
|
+
self.assertAlmostEqual(g["sweep_deg"], 90.0, places=6)
|
|
199
|
+
self.assertAlmostEqual(g["arc_length_nm"], 15.0 * math.pi / 2.0,
|
|
200
|
+
places=3)
|
|
201
|
+
self.assertAlmostEqual(g["chord_nm"], 15.0 * math.sqrt(2.0), places=3)
|
|
202
|
+
self.assertAlmostEqual(g["exit_track_deg"], 180.0, places=6)
|
|
203
|
+
self.assertTrue(g["valid"])
|
|
204
|
+
|
|
205
|
+
def test_rf_left_mirror(self):
|
|
206
|
+
g = rf_leg_geometry((0.0, 0.0), 90.0, 15.0, "LEFT", 90.0)
|
|
207
|
+
self.assertAlmostEqual(g["center_nm"][1], 15.0, places=3)
|
|
208
|
+
self.assertAlmostEqual(g["exit_track_deg"], 0.0, places=6)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class TestDmeArc(unittest.TestCase):
|
|
212
|
+
"""DME arc length/chord/bank: r=15 NM over 60 deg of radial."""
|
|
213
|
+
|
|
214
|
+
def test_arc_length_chord(self):
|
|
215
|
+
self.assertAlmostEqual(dme_arc_length_nm(15.0, 60.0),
|
|
216
|
+
15.0 * math.pi / 3.0, places=3)
|
|
217
|
+
self.assertAlmostEqual(dme_arc_chord_nm(15.0, 60.0), 15.0, places=3)
|
|
218
|
+
|
|
219
|
+
def test_arc_bank_angle(self):
|
|
220
|
+
# bank = atan(V^2/(g*r)), V = 250 kt, r = 15 NM in SI
|
|
221
|
+
v = 250.0 * 0.514444
|
|
222
|
+
r_m = 15.0 * NM_TO_M
|
|
223
|
+
expected = math.degrees(math.atan(v * v / (G_MS2 * r_m)))
|
|
224
|
+
self.assertAlmostEqual(dme_arc_bank_angle_deg(250.0, 15.0),
|
|
225
|
+
expected, places=6)
|
|
226
|
+
self.assertAlmostEqual(dme_arc_bank_angle_deg(250.0, 15.0),
|
|
227
|
+
3.475, places=2)
|
|
228
|
+
|
|
229
|
+
def test_turn_radius(self):
|
|
230
|
+
v = 250.0 * 0.514444
|
|
231
|
+
r_m = v * v / (G_MS2 * math.tan(math.radians(25.0)))
|
|
232
|
+
self.assertAlmostEqual(dme_arc_turn_radius_nm(250.0, 25.0),
|
|
233
|
+
r_m / NM_TO_M, places=6)
|
|
234
|
+
|
|
235
|
+
def test_arc_geometry(self):
|
|
236
|
+
g = dme_arc_geometry(15.0, 30.0, 90.0)
|
|
237
|
+
self.assertAlmostEqual(g["turn_angle_deg"], 60.0, places=6)
|
|
238
|
+
self.assertAlmostEqual(g["arc_length_nm"], 15.0 * math.pi / 3.0,
|
|
239
|
+
places=3)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class TestRta(unittest.TestCase):
|
|
243
|
+
"""RTA law anchors (SI)."""
|
|
244
|
+
|
|
245
|
+
def test_eta(self):
|
|
246
|
+
self.assertEqual(rta_eta_s(200000.0, 250.0), 800.0)
|
|
247
|
+
with self.assertRaises(ValueError):
|
|
248
|
+
rta_eta_s(200000.0, 0.0)
|
|
249
|
+
|
|
250
|
+
def test_required_ground_speed(self):
|
|
251
|
+
self.assertEqual(rta_required_ground_speed_m_s(100000.0, 500.0, 0.0),
|
|
252
|
+
200.0)
|
|
253
|
+
|
|
254
|
+
def test_time_error(self):
|
|
255
|
+
self.assertEqual(rta_time_error_s(800.0, 800.0, 0.0), 0.0)
|
|
256
|
+
self.assertEqual(rta_time_error_s(800.0, 750.0, 0.0), 50.0)
|
|
257
|
+
|
|
258
|
+
def test_isa_speed_of_sound_sea_level(self):
|
|
259
|
+
expected = math.sqrt(1.4 * 287.05 * 288.15)
|
|
260
|
+
self.assertAlmostEqual(isa_speed_of_sound_m_s(0.0), expected, places=2)
|
|
261
|
+
self.assertAlmostEqual(isa_speed_of_sound_m_s(0.0), 340.29, places=1)
|
|
262
|
+
|
|
263
|
+
def test_feasible_slowdown(self):
|
|
264
|
+
# Route-shaped case: FL200 (a ~316 m/s), demand 25 s late.
|
|
265
|
+
alt = 20000.0 * 0.3048
|
|
266
|
+
a = isa_speed_of_sound_m_s(alt)
|
|
267
|
+
eta = rta_eta_s(87952.0, 240.0)
|
|
268
|
+
rta = rta_speed_command(remaining_distance_m=87952.0,
|
|
269
|
+
ground_speed_m_s=240.0,
|
|
270
|
+
wind_along_m_s=-5.0,
|
|
271
|
+
rta_time_s=eta + 25.0, altitude_m=alt)
|
|
272
|
+
self.assertTrue(rta["feasible"])
|
|
273
|
+
self.assertEqual(rta["verdict"], "rta-feasible")
|
|
274
|
+
expected_req = 87952.0 / (eta + 25.0)
|
|
275
|
+
self.assertAlmostEqual(rta["required_gs_m_s"], expected_req, places=2)
|
|
276
|
+
expected_mach = (expected_req + 5.0) / a
|
|
277
|
+
self.assertAlmostEqual(rta["command_mach"], expected_mach, places=4)
|
|
278
|
+
self.assertAlmostEqual(rta["remaining_error_s"], 0.0, places=6)
|
|
279
|
+
|
|
280
|
+
def test_unfeasible_high_demand(self):
|
|
281
|
+
alt = 20000.0 * 0.3048
|
|
282
|
+
eta = rta_eta_s(87952.0, 240.0)
|
|
283
|
+
rta = rta_speed_command(remaining_distance_m=87952.0,
|
|
284
|
+
ground_speed_m_s=240.0,
|
|
285
|
+
wind_along_m_s=-5.0,
|
|
286
|
+
rta_time_s=max(1.0, eta - 100.0),
|
|
287
|
+
altitude_m=alt)
|
|
288
|
+
self.assertFalse(rta["feasible"])
|
|
289
|
+
self.assertEqual(rta["verdict"], "rta-unfeasible")
|
|
290
|
+
self.assertEqual(rta["command_mach"], 0.82)
|
|
291
|
+
|
|
292
|
+
def test_window(self):
|
|
293
|
+
alt = 20000.0 * 0.3048
|
|
294
|
+
w = rta_achievable_window(87952.0, alt, -5.0, 0.70, 0.82)
|
|
295
|
+
self.assertGreater(w["eta_min_s"], 0.0)
|
|
296
|
+
self.assertGreater(w["eta_max_s"], w["eta_min_s"])
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class TestVnavAndPerf(unittest.TestCase):
|
|
300
|
+
"""Top of descent, gradient/FPA math, ECON clamps, band checks."""
|
|
301
|
+
|
|
302
|
+
def test_tod(self):
|
|
303
|
+
self.assertAlmostEqual(vnav_tod_distance_nm(35000.0, 20000.0, 318.4),
|
|
304
|
+
15000.0 / 318.4, places=4)
|
|
305
|
+
|
|
306
|
+
def test_fpa_roundtrip(self):
|
|
307
|
+
grad = fpa_gradient_ft_nm(3.0)
|
|
308
|
+
self.assertAlmostEqual(vnav_fpa_deg(grad), 3.0, places=6)
|
|
309
|
+
self.assertAlmostEqual(grad,
|
|
310
|
+
math.tan(math.radians(3.0)) * 6076.1154,
|
|
311
|
+
places=3)
|
|
312
|
+
|
|
313
|
+
def test_gradient_and_altitude(self):
|
|
314
|
+
g = vnav_descent_gradient_ft_nm(20000.0, 6000.0, 47.49)
|
|
315
|
+
self.assertAlmostEqual(g, 14000.0 / 47.49, places=3)
|
|
316
|
+
alt = vnav_altitude_at_ft(20000.0, g, 47.49)
|
|
317
|
+
self.assertAlmostEqual(alt, 6000.0, places=0)
|
|
318
|
+
|
|
319
|
+
def test_constraint_verdicts(self):
|
|
320
|
+
self.assertTrue(vnav_constraint_ok(6005.0, 6000.0, False, tol_ft=20.0))
|
|
321
|
+
self.assertFalse(vnav_constraint_ok(5194.0, 6000.0, False,
|
|
322
|
+
tol_ft=20.0))
|
|
323
|
+
self.assertTrue(vnav_constraint_ok(5194.0, 5000.0, True))
|
|
324
|
+
|
|
325
|
+
def test_vertical_bands(self):
|
|
326
|
+
self.assertTrue(vertical_band_ok(35000.0, 33000.0, 39000.0))
|
|
327
|
+
self.assertTrue(vertical_band_ok(20000.0, 19500.0, 20500.0))
|
|
328
|
+
self.assertFalse(vertical_band_ok(12000.0, 20000.0, 20000.0))
|
|
329
|
+
|
|
330
|
+
def test_cost_index(self):
|
|
331
|
+
self.assertEqual(perf_cost_index(60.0, 1.0), 60.0)
|
|
332
|
+
self.assertEqual(perf_cost_index(0.0, 2.0), 0.0)
|
|
333
|
+
|
|
334
|
+
def test_econ_clamped_and_positive(self):
|
|
335
|
+
for ci in (0.0, 30.0, 100.0):
|
|
336
|
+
m = perf_econ_mach_from_cost_index(ci, 62000.0, 35000.0)
|
|
337
|
+
self.assertGreaterEqual(m, 0.70)
|
|
338
|
+
self.assertLessEqual(m, 0.82)
|
|
339
|
+
self.assertGreater(perf_max_range_speed_kts(62000.0, 35000.0), 0.0)
|
|
340
|
+
|
|
341
|
+
def test_mach_tas_roundtrip(self):
|
|
342
|
+
tas = perf_tas_from_mach(0.78, 35000.0)
|
|
343
|
+
mach_back = tas / (math.sqrt(1.4 * 287.05
|
|
344
|
+
* (288.15 - 0.0065 * 35000.0 * 0.3048))
|
|
345
|
+
* 1.943844492)
|
|
346
|
+
self.assertAlmostEqual(mach_back, 0.78, places=6)
|
|
347
|
+
|
|
348
|
+
def test_econ_summary_shape(self):
|
|
349
|
+
s = perf_econ_summary(30.0, 62000.0, 35000.0)
|
|
350
|
+
self.assertIn("mach", s)
|
|
351
|
+
self.assertGreater(s["fuel_per_nm_kg"], 0.0)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
class TestNavaidGeometry(unittest.TestCase):
|
|
355
|
+
"""DME slant range and VOR bearing/radial conversions."""
|
|
356
|
+
|
|
357
|
+
def test_slant_range(self):
|
|
358
|
+
# 15 NM ground range at FL200: sqrt(27780^2 + 6096^2)
|
|
359
|
+
expected = math.hypot(15.0 * NM_TO_M, 20000.0 * 0.3048)
|
|
360
|
+
self.assertAlmostEqual(nav_dme_slant_range_m(15.0 * NM_TO_M, 0.0,
|
|
361
|
+
20000.0 * 0.3048),
|
|
362
|
+
expected, places=1)
|
|
363
|
+
|
|
364
|
+
def test_bearing_radial(self):
|
|
365
|
+
self.assertEqual(nav_bearing_deg(0.0, 10.0), 0.0)
|
|
366
|
+
self.assertEqual(nav_bearing_deg(10.0, 0.0), 90.0)
|
|
367
|
+
self.assertEqual(nav_radial_deg(30.0), 210.0)
|
|
368
|
+
self.assertEqual(nav_radial_deg(210.0), 30.0)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class TestBuilderAndGates(unittest.TestCase):
|
|
372
|
+
|
|
373
|
+
def test_example_route_facts(self):
|
|
374
|
+
model = build_report(example_item())
|
|
375
|
+
self.assertEqual(len(model["legs"]), 4)
|
|
376
|
+
self.assertEqual(len(model["waypoints"]), 5)
|
|
377
|
+
self.assertEqual(model["hold"]["entry"], "teardrop")
|
|
378
|
+
self.assertEqual(model["rnp"]["rnp_nm"], 0.3)
|
|
379
|
+
self.assertTrue(model["rnp"]["containment"]["pass"])
|
|
380
|
+
self.assertTrue(model["rta"]["feasible"])
|
|
381
|
+
# example deliberately surfaces the W005 crossing finding
|
|
382
|
+
self.assertFalse(model["vnav"]["w005_crossing_ok"])
|
|
383
|
+
self.assertGreaterEqual(model["n_findings"], 1)
|
|
384
|
+
self.assertEqual(model["status"], "draft-for-review")
|
|
385
|
+
self.assertEqual(model["total_distance_nm"], sum(
|
|
386
|
+
leg["distance_nm"] for leg in model["legs"]))
|
|
387
|
+
|
|
388
|
+
def test_core_gates_all_pass(self):
|
|
389
|
+
model = build_report(example_item())
|
|
390
|
+
gates = check_report(model)
|
|
391
|
+
self.assertTrue(gates["all_pass"], gates)
|
|
392
|
+
for gate in ("route_stated", "rnp_present", "containment_checked",
|
|
393
|
+
"entries_correct", "sign_off_honest"):
|
|
394
|
+
self.assertTrue(gates[gate], gate)
|
|
395
|
+
|
|
396
|
+
def test_containment_fail_still_gate_checked(self):
|
|
397
|
+
item = example_item()
|
|
398
|
+
item.sigma_lateral_m = 400.0 # ANP 800 m > RNP 555.6 m + margin
|
|
399
|
+
model = build_report(item)
|
|
400
|
+
self.assertFalse(model["rnp"]["containment"]["pass"])
|
|
401
|
+
self.assertEqual(model["rnp"]["containment"]["verdict"], "FAIL")
|
|
402
|
+
gates = check_report(model)
|
|
403
|
+
self.assertTrue(gates["all_pass"], gates) # structural gates
|
|
404
|
+
|
|
405
|
+
def test_markdown_gates_all_pass(self):
|
|
406
|
+
md = example_report_markdown()
|
|
407
|
+
gates = check_report_markdown(md)
|
|
408
|
+
self.assertTrue(gates["all_pass"], gates)
|
|
409
|
+
self.assertGreater(len(md), 2000)
|
|
410
|
+
|
|
411
|
+
def test_markdown_sections_present(self):
|
|
412
|
+
md = example_report_markdown()
|
|
413
|
+
for n in range(1, 9):
|
|
414
|
+
self.assertIn("## %d. " % n, md, "section %d missing" % n)
|
|
415
|
+
low = md.lower()
|
|
416
|
+
self.assertIn("not an approval", low)
|
|
417
|
+
self.assertIn("draft", low)
|
|
418
|
+
self.assertIn("teardrop", low)
|
|
419
|
+
|
|
420
|
+
def test_model_plain_json(self):
|
|
421
|
+
# protocol: model is plain JSON (no NaN / non-serializable objects)
|
|
422
|
+
model = build_report(example_item())
|
|
423
|
+
text = json.dumps(model)
|
|
424
|
+
self.assertGreater(len(text), 1500)
|
|
425
|
+
self.assertNotIn("NaN", text)
|
|
426
|
+
|
|
427
|
+
def test_standalone_no_skills_repo(self):
|
|
428
|
+
# Core must work with no AeroSkills present.
|
|
429
|
+
os.environ["AEROSKILLS_DEV"] = "/nonexistent"
|
|
430
|
+
model = build_report(example_item())
|
|
431
|
+
self.assertTrue(model["rnp"]["containment"]["verdict"] in ("PASS", "FAIL"))
|
|
432
|
+
md = render_report_markdown(model)
|
|
433
|
+
self.assertGreater(len(md), 2000)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
if __name__ == "__main__":
|
|
437
|
+
unittest.main()
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Role test: Flight Management Engineer.
|
|
3
|
+
|
|
4
|
+
Verifies (offline, no network):
|
|
5
|
+
1. Every skill bound in ROLE.md frontmatter RESOLVES to a real leaf in
|
|
6
|
+
the Aero Agent Skills repo (avionics/flight-management cluster).
|
|
7
|
+
2. The workflow stage order is deterministic and complete.
|
|
8
|
+
3. The filled deliverable template is complete: numbered sections,
|
|
9
|
+
DRAFT marker, not-an-approval boundary, no blank placeholders.
|
|
10
|
+
4. Boundaries, author, SOURCES.md and the executable core/cli anatomy.
|
|
11
|
+
"""
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import sys
|
|
15
|
+
import unittest
|
|
16
|
+
|
|
17
|
+
ROLES_REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(
|
|
18
|
+
os.path.abspath(__file__))))) # repo root (4 up from tests/)
|
|
19
|
+
AEROSKILLS = os.environ.get("AEROSKILLS_DEV", os.path.expanduser("~/AeroSkills"))
|
|
20
|
+
HAS_SKILLS = os.path.isdir(os.path.join(AEROSKILLS, "skills"))
|
|
21
|
+
ROLE_DIR = os.path.join(ROLES_REPO, "roles", "flight-management-engineer")
|
|
22
|
+
TEMPLATE = os.path.join(ROLE_DIR, "templates", "route-assessment-template.md")
|
|
23
|
+
|
|
24
|
+
EXPECTED_BOUND = [
|
|
25
|
+
"avionics/flight-management/flight-planning",
|
|
26
|
+
"avionics/flight-management/lateral-navigation",
|
|
27
|
+
"avionics/flight-management/vertical-navigation",
|
|
28
|
+
"avionics/flight-management/holding-pattern-entry",
|
|
29
|
+
"avionics/flight-management/dme-arc-leg",
|
|
30
|
+
"avionics/flight-management/radius-to-fix-leg",
|
|
31
|
+
"avionics/flight-management/rhumb-line-leg",
|
|
32
|
+
"avionics/flight-management/radio-navigation-aids",
|
|
33
|
+
"avionics/flight-management/rnp-anp-containment",
|
|
34
|
+
"avionics/flight-management/performance-computation",
|
|
35
|
+
"avionics/flight-management/rta-time-control",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
EXPECTED_STAGES = [
|
|
39
|
+
"Route structure & constraints",
|
|
40
|
+
"Lateral track geometry",
|
|
41
|
+
"RF leg construction",
|
|
42
|
+
"Rhumb line legs",
|
|
43
|
+
"RNP containment",
|
|
44
|
+
"Holding entry",
|
|
45
|
+
"DME arc legs",
|
|
46
|
+
"Navaid geometry",
|
|
47
|
+
"RTA control",
|
|
48
|
+
"VNAV & performance",
|
|
49
|
+
"Findings & sign-off",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def read_role_md():
|
|
54
|
+
p = os.path.join(ROLE_DIR, "ROLE.md")
|
|
55
|
+
if not os.path.exists(p):
|
|
56
|
+
return None
|
|
57
|
+
return open(p).read()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TestFlightManagementRole(unittest.TestCase):
|
|
61
|
+
|
|
62
|
+
def test_bound_skills_resolve_in_aeroskills(self):
|
|
63
|
+
if not HAS_SKILLS:
|
|
64
|
+
self.skipTest("Aero Agent Skills checkout not present "
|
|
65
|
+
"(cross-repo dev check)")
|
|
66
|
+
for leaf in EXPECTED_BOUND:
|
|
67
|
+
sk = os.path.join(AEROSKILLS, "skills", leaf, "SKILL.md")
|
|
68
|
+
self.assertTrue(os.path.exists(sk),
|
|
69
|
+
"bound skill not found in aero-agent-skills: %s"
|
|
70
|
+
% leaf)
|
|
71
|
+
|
|
72
|
+
def test_bound_skills_listed_in_frontmatter(self):
|
|
73
|
+
role = read_role_md()
|
|
74
|
+
self.assertIsNotNone(role)
|
|
75
|
+
for leaf in EXPECTED_BOUND:
|
|
76
|
+
self.assertIn(leaf, role, "missing in ROLE.md skills_bound: %s"
|
|
77
|
+
% leaf)
|
|
78
|
+
|
|
79
|
+
def test_workflow_stages_deterministic(self):
|
|
80
|
+
role = read_role_md()
|
|
81
|
+
idx = [role.find(stage) for stage in EXPECTED_STAGES]
|
|
82
|
+
self.assertTrue(all(i >= 0 for i in idx), "a stage is missing")
|
|
83
|
+
self.assertEqual(idx, sorted(idx),
|
|
84
|
+
"stage order not deterministic")
|
|
85
|
+
|
|
86
|
+
def test_deliverable_template_complete(self):
|
|
87
|
+
if not os.path.exists(TEMPLATE):
|
|
88
|
+
self.fail("route-assessment-template.md missing")
|
|
89
|
+
text = open(TEMPLATE).read()
|
|
90
|
+
# all 8 numbered sections present
|
|
91
|
+
for n in range(1, 9):
|
|
92
|
+
self.assertTrue(re.search(r"^## %d\. " % n, text, re.M),
|
|
93
|
+
"section %d missing" % n)
|
|
94
|
+
self.assertIn("DRAFT", text)
|
|
95
|
+
low = text.lower()
|
|
96
|
+
self.assertIn("not an approval", low)
|
|
97
|
+
self.assertIn("document", low)
|
|
98
|
+
# zero blank placeholders
|
|
99
|
+
self.assertNotRegex(text, r"_{3,}|\[TBD\]|TODO|Lorem")
|
|
100
|
+
|
|
101
|
+
def test_forbidden_lines_present(self):
|
|
102
|
+
role = read_role_md()
|
|
103
|
+
for phrase in ["approve the flight plan", "issue a clearance",
|
|
104
|
+
"reproduce proprietary", "sign_off_required: true"]:
|
|
105
|
+
self.assertIn(phrase, role, "missing boundary: %s" % phrase)
|
|
106
|
+
|
|
107
|
+
def test_author_ashfordeou(self):
|
|
108
|
+
role = read_role_md()
|
|
109
|
+
self.assertIn("ashfordeOU", role, "author not set")
|
|
110
|
+
|
|
111
|
+
def test_sources_register_exists(self):
|
|
112
|
+
p = os.path.join(ROLE_DIR, "SOURCES.md")
|
|
113
|
+
self.assertTrue(os.path.exists(p), "SOURCES.md missing for role")
|
|
114
|
+
|
|
115
|
+
def test_core_engine_exists(self):
|
|
116
|
+
"""100% standard: every role ships an executable core + cli."""
|
|
117
|
+
for f in ["core", "cli.py"]:
|
|
118
|
+
self.assertTrue(os.path.exists(os.path.join(ROLE_DIR, f)),
|
|
119
|
+
"%s missing - role is not executable" % f)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
if __name__ == "__main__":
|
|
123
|
+
unittest.main()
|