aero-agent-roles 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/NOTICE +18 -0
  2. package/README.md +49 -0
  3. package/bin/aero-agent-roles.js +127 -0
  4. package/lib/catalog.js +97 -0
  5. package/lib/install.js +48 -0
  6. package/lib/mcp.js +199 -0
  7. package/manifest.json +516 -0
  8. package/package.json +44 -0
  9. package/roles/aerodynamics-engineer/ROLE.md +153 -0
  10. package/roles/aerodynamics-engineer/SOURCES.md +12 -0
  11. package/roles/aerodynamics-engineer/cli.py +143 -0
  12. package/roles/aerodynamics-engineer/core/aerodynamics_core.py +1218 -0
  13. package/roles/aerodynamics-engineer/templates/aero-design-report-template.md +72 -0
  14. package/roles/aerodynamics-engineer/tests/test_aerodynamics_core.py +227 -0
  15. package/roles/aerodynamics-engineer/tests/test_role_aerodynamics.py +94 -0
  16. package/roles/aircraft-design-engineer/ROLE.md +138 -0
  17. package/roles/aircraft-design-engineer/SOURCES.md +12 -0
  18. package/roles/aircraft-design-engineer/cli.py +152 -0
  19. package/roles/aircraft-design-engineer/core/aircraft_design_core.py +1278 -0
  20. package/roles/aircraft-design-engineer/templates/concept-design-template.md +99 -0
  21. package/roles/aircraft-design-engineer/tests/test_aircraft_design_core.py +269 -0
  22. package/roles/aircraft-design-engineer/tests/test_role_aircraft_design_engineer.py +106 -0
  23. package/roles/airworthiness-compliance-engineer/ROLE.md +122 -0
  24. package/roles/airworthiness-compliance-engineer/SOURCES.md +16 -0
  25. package/roles/airworthiness-compliance-engineer/cli.py +146 -0
  26. package/roles/airworthiness-compliance-engineer/core/airworthiness_core.py +894 -0
  27. package/roles/airworthiness-compliance-engineer/templates/compliance-matrix-template.md +46 -0
  28. package/roles/airworthiness-compliance-engineer/tests/test_airworthiness_core.py +224 -0
  29. package/roles/airworthiness-compliance-engineer/tests/test_role_airworthiness.py +85 -0
  30. package/roles/as9100-quality-auditor/ROLE.md +128 -0
  31. package/roles/as9100-quality-auditor/SOURCES.md +16 -0
  32. package/roles/as9100-quality-auditor/cli.py +136 -0
  33. package/roles/as9100-quality-auditor/core/as9100_core.py +857 -0
  34. package/roles/as9100-quality-auditor/templates/findings-report-template.md +119 -0
  35. package/roles/as9100-quality-auditor/tests/test_as9100_cli_profile.py +105 -0
  36. package/roles/as9100-quality-auditor/tests/test_as9100_core.py +204 -0
  37. package/roles/as9100-quality-auditor/tests/test_role_as9100.py +90 -0
  38. package/roles/data-bus-avionics-engineer/ROLE.md +146 -0
  39. package/roles/data-bus-avionics-engineer/SOURCES.md +23 -0
  40. package/roles/data-bus-avionics-engineer/cli.py +311 -0
  41. package/roles/data-bus-avionics-engineer/core/data_bus_avionics_core.py +1121 -0
  42. package/roles/data-bus-avionics-engineer/templates/bus-assessment-template.md +134 -0
  43. package/roles/data-bus-avionics-engineer/tests/test_bundle_protocol.py +137 -0
  44. package/roles/data-bus-avionics-engineer/tests/test_data_bus_avionics_core.py +261 -0
  45. package/roles/data-bus-avionics-engineer/tests/test_role_data_bus_avionics_engineer.py +121 -0
  46. package/roles/do160-environmental-engineer/ROLE.md +136 -0
  47. package/roles/do160-environmental-engineer/SOURCES.md +30 -0
  48. package/roles/do160-environmental-engineer/cli.py +147 -0
  49. package/roles/do160-environmental-engineer/core/do160_environmental_core.py +1246 -0
  50. package/roles/do160-environmental-engineer/templates/environmental-qualification-plan-template.md +114 -0
  51. package/roles/do160-environmental-engineer/tests/test_do160_environmental_core.py +312 -0
  52. package/roles/do160-environmental-engineer/tests/test_role_do160_environmental_engineer.py +281 -0
  53. package/roles/do178c-cert-engineer/ROLE.md +120 -0
  54. package/roles/do178c-cert-engineer/SOURCES.md +16 -0
  55. package/roles/do178c-cert-engineer/cli.py +129 -0
  56. package/roles/do178c-cert-engineer/core/do178c_core.py +411 -0
  57. package/roles/do178c-cert-engineer/templates/psac-template.md +74 -0
  58. package/roles/do178c-cert-engineer/tests/test_do178c_cert_engineer_core.py +91 -0
  59. package/roles/do178c-cert-engineer/tests/test_role_do178c.py +109 -0
  60. package/roles/do254-hardware-engineer/ROLE.md +127 -0
  61. package/roles/do254-hardware-engineer/SOURCES.md +19 -0
  62. package/roles/do254-hardware-engineer/cli.py +308 -0
  63. package/roles/do254-hardware-engineer/core/do254_hardware_core.py +761 -0
  64. package/roles/do254-hardware-engineer/templates/phac-template.md +87 -0
  65. package/roles/do254-hardware-engineer/tests/test_do254_bundle_profile.py +152 -0
  66. package/roles/do254-hardware-engineer/tests/test_do254_hardware_core.py +200 -0
  67. package/roles/do254-hardware-engineer/tests/test_role_do254_hardware.py +108 -0
  68. package/roles/engineering-analysis-engineer/ROLE.md +154 -0
  69. package/roles/engineering-analysis-engineer/SOURCES.md +15 -0
  70. package/roles/engineering-analysis-engineer/cli.py +151 -0
  71. package/roles/engineering-analysis-engineer/core/engineering_analysis_engineer_core.py +866 -0
  72. package/roles/engineering-analysis-engineer/templates/analysis-memo-template.md +73 -0
  73. package/roles/engineering-analysis-engineer/tests/test_engineering_analysis_engineer_core.py +367 -0
  74. package/roles/engineering-analysis-engineer/tests/test_role_engineering_analysis_engineer.py +113 -0
  75. package/roles/flight-management-engineer/ROLE.md +160 -0
  76. package/roles/flight-management-engineer/SOURCES.md +30 -0
  77. package/roles/flight-management-engineer/cli.py +258 -0
  78. package/roles/flight-management-engineer/core/flight_management_core.py +1473 -0
  79. package/roles/flight-management-engineer/templates/route-assessment-template.md +94 -0
  80. package/roles/flight-management-engineer/tests/test_flight_management_core.py +437 -0
  81. package/roles/flight-management-engineer/tests/test_role_flight_management_engineer.py +123 -0
  82. package/roles/flight-mechanics-engineer/ROLE.md +162 -0
  83. package/roles/flight-mechanics-engineer/SOURCES.md +51 -0
  84. package/roles/flight-mechanics-engineer/cli.py +149 -0
  85. package/roles/flight-mechanics-engineer/core/flight_mechanics_core.py +1507 -0
  86. package/roles/flight-mechanics-engineer/templates/perf-sc-report-template.md +82 -0
  87. package/roles/flight-mechanics-engineer/tests/test_flight_mechanics_core.py +277 -0
  88. package/roles/flight-mechanics-engineer/tests/test_role_flight_mechanics_engineer.py +141 -0
  89. package/roles/flight-test-engineer/ROLE.md +137 -0
  90. package/roles/flight-test-engineer/SOURCES.md +24 -0
  91. package/roles/flight-test-engineer/cli.py +135 -0
  92. package/roles/flight-test-engineer/core/flight_test_core.py +931 -0
  93. package/roles/flight-test-engineer/templates/flight-test-template.md +143 -0
  94. package/roles/flight-test-engineer/tests/test_flight_test_engineer_core.py +227 -0
  95. package/roles/flight-test-engineer/tests/test_role_flight_test_engineer.py +150 -0
  96. package/roles/flight-test-performance-engineer/ROLE.md +165 -0
  97. package/roles/flight-test-performance-engineer/SOURCES.md +20 -0
  98. package/roles/flight-test-performance-engineer/cli.py +319 -0
  99. package/roles/flight-test-performance-engineer/core/flight_test_performance_core.py +1398 -0
  100. package/roles/flight-test-performance-engineer/templates/performance-report-template.md +105 -0
  101. package/roles/flight-test-performance-engineer/tests/test_flight_test_performance_engineer_core.py +308 -0
  102. package/roles/flight-test-performance-engineer/tests/test_role_flight_test_performance_engineer.py +165 -0
  103. package/roles/gnc-engineer/ROLE.md +141 -0
  104. package/roles/gnc-engineer/SOURCES.md +35 -0
  105. package/roles/gnc-engineer/cli.py +141 -0
  106. package/roles/gnc-engineer/core/gnc_core.py +1074 -0
  107. package/roles/gnc-engineer/templates/gnc-report-template.md +105 -0
  108. package/roles/gnc-engineer/tests/test_gnc_engineer_core.py +238 -0
  109. package/roles/gnc-engineer/tests/test_role_gnc_engineer.py +142 -0
  110. package/roles/guidance-engineer/ROLE.md +156 -0
  111. package/roles/guidance-engineer/SOURCES.md +43 -0
  112. package/roles/guidance-engineer/cli.py +280 -0
  113. package/roles/guidance-engineer/core/guidance_core.py +820 -0
  114. package/roles/guidance-engineer/templates/guidance-report-template.md +70 -0
  115. package/roles/guidance-engineer/tests/test_guidance_engineer_core.py +269 -0
  116. package/roles/guidance-engineer/tests/test_role_guidance_engineer.py +146 -0
  117. package/roles/high-speed-aerodynamics-engineer/ROLE.md +161 -0
  118. package/roles/high-speed-aerodynamics-engineer/SOURCES.md +18 -0
  119. package/roles/high-speed-aerodynamics-engineer/cli.py +282 -0
  120. package/roles/high-speed-aerodynamics-engineer/core/high_speed_aero_core.py +1566 -0
  121. package/roles/high-speed-aerodynamics-engineer/templates/high-speed-memo-template.md +75 -0
  122. package/roles/high-speed-aerodynamics-engineer/tests/test_bundle_protocol.py +103 -0
  123. package/roles/high-speed-aerodynamics-engineer/tests/test_high_speed_aero_core.py +280 -0
  124. package/roles/high-speed-aerodynamics-engineer/tests/test_role_high_speed_aerodynamics.py +125 -0
  125. package/roles/ndt-engineer/ROLE.md +157 -0
  126. package/roles/ndt-engineer/SOURCES.md +18 -0
  127. package/roles/ndt-engineer/cli.py +316 -0
  128. package/roles/ndt-engineer/core/ndt_core.py +1590 -0
  129. package/roles/ndt-engineer/templates/ndt-plan-template.md +109 -0
  130. package/roles/ndt-engineer/tests/test_ndt_cli_bundle.py +183 -0
  131. package/roles/ndt-engineer/tests/test_ndt_core.py +437 -0
  132. package/roles/ndt-engineer/tests/test_role_ndt_engineer.py +148 -0
  133. package/roles/numerical-analysis-engineer/ROLE.md +158 -0
  134. package/roles/numerical-analysis-engineer/SOURCES.md +19 -0
  135. package/roles/numerical-analysis-engineer/cli.py +414 -0
  136. package/roles/numerical-analysis-engineer/core/numerical_analysis_core.py +1220 -0
  137. package/roles/numerical-analysis-engineer/templates/verification-memo-template.md +120 -0
  138. package/roles/numerical-analysis-engineer/tests/test_bundle_protocol.py +157 -0
  139. package/roles/numerical-analysis-engineer/tests/test_numerical_analysis_core.py +337 -0
  140. package/roles/numerical-analysis-engineer/tests/test_role_numerical_analysis_engineer.py +117 -0
  141. package/roles/propulsion-engineer/ROLE.md +163 -0
  142. package/roles/propulsion-engineer/SOURCES.md +15 -0
  143. package/roles/propulsion-engineer/cli.py +168 -0
  144. package/roles/propulsion-engineer/core/propulsion_core.py +1291 -0
  145. package/roles/propulsion-engineer/templates/propulsion-report-template.md +92 -0
  146. package/roles/propulsion-engineer/tests/test_propulsion_engineer_core.py +294 -0
  147. package/roles/propulsion-engineer/tests/test_role_propulsion_engineer.py +106 -0
  148. package/roles/space-systems-engineer/ROLE.md +161 -0
  149. package/roles/space-systems-engineer/SOURCES.md +49 -0
  150. package/roles/space-systems-engineer/cli.py +173 -0
  151. package/roles/space-systems-engineer/core/space_systems_core.py +980 -0
  152. package/roles/space-systems-engineer/templates/space-systems-template.md +88 -0
  153. package/roles/space-systems-engineer/tests/test_role_space_systems_engineer.py +117 -0
  154. package/roles/space-systems-engineer/tests/test_space_systems_engineer_core.py +247 -0
  155. package/roles/state-estimation-engineer/ROLE.md +154 -0
  156. package/roles/state-estimation-engineer/SOURCES.md +27 -0
  157. package/roles/state-estimation-engineer/cli.py +326 -0
  158. package/roles/state-estimation-engineer/core/state_estimation_core.py +958 -0
  159. package/roles/state-estimation-engineer/templates/nav-state-estimator-design-template.md +76 -0
  160. package/roles/state-estimation-engineer/tests/test_bundle_protocol.py +110 -0
  161. package/roles/state-estimation-engineer/tests/test_role_state_estimation_engineer.py +127 -0
  162. package/roles/state-estimation-engineer/tests/test_state_estimation_engineer_core.py +231 -0
  163. package/roles/structures-loads-engineer/ROLE.md +171 -0
  164. package/roles/structures-loads-engineer/SOURCES.md +44 -0
  165. package/roles/structures-loads-engineer/cli.py +219 -0
  166. package/roles/structures-loads-engineer/core/structures_loads_core.py +1112 -0
  167. package/roles/structures-loads-engineer/templates/loads-strength-report-template.md +96 -0
  168. package/roles/structures-loads-engineer/tests/test_bundle_protocol.py +101 -0
  169. package/roles/structures-loads-engineer/tests/test_role_structures_loads_engineer.py +147 -0
  170. package/roles/structures-loads-engineer/tests/test_structures_loads_core.py +261 -0
@@ -0,0 +1,931 @@
1
+ #!/usr/bin/env python3
2
+ """flight_test_core.py - Flight Test Engineer executable core.
3
+
4
+ This is the role's ENGINE: given an aircraft's project facts and the
5
+ flight-recorded (or worked-example simulated) data it plans a safe
6
+ build-up flight test campaign and produces the flight test plan plus
7
+ envelope expansion report: build-up blocks at fractions of the design
8
+ limits, V-speed margins, stall/VS1g checks, flutter point progression
9
+ with the damping requirement, load factor checks, an altitude/Mach
10
+ grid, and per-block PASS/FAIL criteria. It also gate-checks
11
+ deliverables. Standalone: no external repo needed.
12
+
13
+ Domain rules encoded here are public process knowledge as bound in the
14
+ Aero Agent Skills flight-test-operations leaves and in the public
15
+ FAR-25 / CS-25 context those leaves reference (vref/v2/vr factors, the
16
+ VA = VS*sqrt(n_max) maneuvering speed, the FAR 25.629 flutter margin
17
+ factor of 1.2 and the 0.03 minimum damping at the maximum test speed,
18
+ the FAR 25.207/25.203 stall warning and recovery criteria, equal-step
19
+ envelope expansion with the load factor checked at every point, and
20
+ ISA atmosphere physics for the altitude/Mach grid). No proprietary text
21
+ is reproduced; regulations are paraphrased context only.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import math
26
+ import re
27
+ from dataclasses import dataclass, field
28
+ from datetime import date
29
+
30
+ def _today() -> str:
31
+ import os
32
+ from datetime import date
33
+ return os.environ.get("ROLE_GEN_DATE", date.today().isoformat())
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Domain tables (public practice / FAR-25 & CS-25 context, summary-only)
37
+ # ---------------------------------------------------------------------------
38
+
39
+ # Certification V-speed factors (FAR-25.107/25.125 context as encoded in the
40
+ # v-speeds AeroSkills leaf): vref = 1.3*vs0, v2 = 1.2*vs1, vr = 1.1*vs1.
41
+ VREF_FACTOR = 1.3
42
+ V2_FACTOR = 1.2
43
+ VR_FACTOR = 1.1
44
+
45
+ # Maneuvering speed VA = VS * sqrt(n_max) (FAR-25.335 context).
46
+ # Stall warning must begin at 1.05 * VS1g, or VS1g + 3 kt, whichever is
47
+ # greater (FAR-25.207 / CS-25.207 context, as in the stall leaf).
48
+ STALL_WARNING_MARGIN = 0.05
49
+ STALL_WARNING_KNOT_FLOOR = 3.0
50
+
51
+ # Flutter: required demonstration speed = 1.2 * V_D and minimum damping
52
+ # 0.03 at the maximum test speed (FAR-25.629 context, flutter-testing leaf).
53
+ FLUTTER_MARGIN_FACTOR = 1.2
54
+ MIN_DAMPING = 0.03
55
+ MIN_FREQ_SEPARATION = 0.10
56
+
57
+ # Load factor / safety: severity x likelihood risk index; >= 15 is high
58
+ # risk and must be mitigated before the flight (flight-test-safety leaf).
59
+ HIGH_RISK_INDEX = 15
60
+
61
+ # ISA constants (public physics) for the altitude/Mach grid.
62
+ GAMMA = 1.4
63
+ R_AIR = 287.05287 # J/(kg K)
64
+ T0_K = 288.15 # ISA sea level temperature
65
+ LAPSE_K_PER_M = 0.0065 # troposphere lapse
66
+ H_TROPO_M = 11000.0
67
+ ISA_EXP = 9.80665 / (R_AIR * LAPSE_K_PER_M) - 1.0 # 4.25588 (density)
68
+
69
+ MPS_TO_KTS = 1.0 / 0.514444
70
+ FT_PER_M = 3.280839895
71
+
72
+ # Default build-up speed blocks as fractions of the design limit V_D:
73
+ # equal increments from 0.85 V_D to 1.00 V_D (dive build-up convention
74
+ # in the FAR-25.629 context - clearance is progressive, point by point).
75
+ DEFAULT_SPEED_BLOCK_FRACTIONS = (0.85, 0.90, 0.95, 1.00)
76
+
77
+ # Load factor build-up for the maneuver envelope: fractions of n_max.
78
+ DEFAULT_LOAD_FACTOR_BLOCKS = (0.5, 0.75, 1.0)
79
+
80
+ # Planned test altitudes for the altitude/Mach grid, meters pressure alt.
81
+ DEFAULT_ALTITUDES_M = (0.0, 7620.0, 10668.0) # SL / FL250 / FL350
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Project facts
86
+ # ---------------------------------------------------------------------------
87
+
88
+ @dataclass
89
+ class TestAircraft:
90
+ """Aircraft project facts the role needs to plan and reduce the test."""
91
+ name: str
92
+ description: str = ""
93
+ gross_weight_kg: float = 0.0
94
+ n_max: float = 2.5 # positive limit load factor
95
+ vs_eas_ms: float = 0.0 # clean 1g stalling speed VS1g, m/s EAS
96
+ vs1_eas_ms: float = 0.0 # takeoff-config stalling speed, m/s EAS
97
+ vs0_eas_ms: float = 0.0 # landing-config stalling speed, m/s EAS
98
+ vfe_eas_ms: float = 0.0 # flaps-extended limit speed
99
+ vno_eas_ms: float = 0.0 # normal operating limit speed
100
+ vne_eas_ms: float = 0.0 # never-exceed speed
101
+ vd_eas_ms: float = 0.0 # design dive speed
102
+ mmo: float = 0.78 # maximum operating Mach
103
+ certification_basis: str = "FAR/CS-25 (context only)"
104
+ recovery_alt_loss_limit_m: float = 30.0
105
+ recovery_pitch_up_limit_deg: float = 8.0
106
+ recovery_roll_off_limit_deg: float = 20.0
107
+
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # Core computations (real rules, real numbers)
111
+ # ---------------------------------------------------------------------------
112
+
113
+ def _require_positive(value, name):
114
+ if value <= 0:
115
+ raise ValueError("%s must be positive, got %r" % (name, value))
116
+
117
+
118
+ def _require_ordered_placards(ac: TestAircraft) -> None:
119
+ """Placard ordering 0 < vfe < va < vno < vne < vd (design context)."""
120
+ va = corner_speed(ac.vs_eas_ms, ac.n_max)
121
+ order = (ac.vfe_eas_ms, va, ac.vno_eas_ms, ac.vne_eas_ms, ac.vd_eas_ms)
122
+ if not (0 < order[0] < order[1] < order[2] < order[3] < order[4]):
123
+ raise ValueError(
124
+ "placard order must be 0 < vfe < va < vno < vne < vd, got %r"
125
+ % (order,))
126
+
127
+
128
+ def corner_speed(vs_ms, n_max):
129
+ """Maneuvering speed VA = VS * sqrt(n_max), speeds in m/s.
130
+
131
+ Raises ValueError when vs_ms <= 0 or n_max <= 1."""
132
+ if vs_ms <= 0:
133
+ raise ValueError("stall speed must be > 0, got %r" % (vs_ms,))
134
+ if n_max <= 1:
135
+ raise ValueError("limit load factor must be > 1, got %r" % (n_max,))
136
+ return vs_ms * math.sqrt(n_max)
137
+
138
+
139
+ def v_speeds(ac: TestAircraft) -> dict:
140
+ """Assemble the validated V-speed set from the stall speeds (m/s EAS).
141
+
142
+ vref = 1.3*vs0, v2 = 1.2*vs1, vr = 1.1*vs1 plus VA = vs*sqrt(n_max)."""
143
+ for name, value in (("vs", ac.vs_eas_ms), ("vs1", ac.vs1_eas_ms),
144
+ ("vs0", ac.vs0_eas_ms)):
145
+ _require_positive(value, name)
146
+ if not (ac.vs0_eas_ms <= ac.vs1_eas_ms <= ac.vs_eas_ms):
147
+ raise ValueError(
148
+ "stall speeds must satisfy vs0 <= vs1 <= vs (clean), got "
149
+ "vs0=%r vs1=%r vs=%r" % (ac.vs0_eas_ms, ac.vs1_eas_ms,
150
+ ac.vs_eas_ms))
151
+ va = corner_speed(ac.vs_eas_ms, ac.n_max)
152
+ return {
153
+ "vs": ac.vs_eas_ms,
154
+ "vs1": ac.vs1_eas_ms,
155
+ "vs0": ac.vs0_eas_ms,
156
+ "vref": VREF_FACTOR * ac.vs0_eas_ms,
157
+ "v2": V2_FACTOR * ac.vs1_eas_ms,
158
+ "vr": VR_FACTOR * ac.vs1_eas_ms,
159
+ "va": va,
160
+ }
161
+
162
+
163
+ def classify_airspeed(v_ms, vfe, va, vno, vne):
164
+ """Classify a test speed into a flight test category.
165
+
166
+ Half-open boundaries: v < vfe -> below-vfe; vfe <= v < va ->
167
+ vfe-to-va; va <= v < vno -> va-to-vno; vno <= v < vne ->
168
+ vno-to-vne; v >= vne -> at-or-above-vne. Raises ValueError when
169
+ v_ms < 0 or the bound order 0 < vfe < va < vno < vne is violated."""
170
+ if v_ms < 0:
171
+ raise ValueError("airspeed must be >= 0, got %r" % (v_ms,))
172
+ if not (0 < vfe < va < vno < vne):
173
+ raise ValueError(
174
+ "bound order must be 0 < vfe < va < vno < vne, got %r"
175
+ % ((vfe, va, vno, vne),))
176
+ if v_ms < vfe:
177
+ return "below-vfe"
178
+ if v_ms < va:
179
+ return "vfe-to-va"
180
+ if v_ms < vno:
181
+ return "va-to-vno"
182
+ if v_ms < vne:
183
+ return "vno-to-vne"
184
+ return "at-or-above-vne"
185
+
186
+
187
+ def speed_block_targets(limit_ms, fractions=DEFAULT_SPEED_BLOCK_FRACTIONS):
188
+ """Build-up block speeds = fraction * limit (m/s EAS).
189
+
190
+ Validates equal fraction increments and that the top block reaches
191
+ the limit (>= 0.99*limit). Returns a list of block dicts."""
192
+ if len(fractions) < 2:
193
+ raise ValueError("need at least two block fractions")
194
+ steps = [round(b - a, 6) for a, b in zip(fractions, fractions[1:])]
195
+ if len(set(steps)) != 1 or steps[0] <= 0:
196
+ raise ValueError("fractions must increase in equal steps, got %r"
197
+ % (fractions,))
198
+ if fractions[-1] < 0.99:
199
+ raise ValueError("top block must reach the limit, got %r"
200
+ % (fractions[-1],))
201
+ blocks = []
202
+ for i, frac in enumerate(fractions, start=1):
203
+ blocks.append({
204
+ "id": "S%d" % i,
205
+ "fraction": frac,
206
+ "target_eas_ms": limit_ms * frac,
207
+ })
208
+ return blocks
209
+
210
+
211
+ def flutter_required_speed(vd_ms, margin_factor=FLUTTER_MARGIN_FACTOR):
212
+ """Required flutter demonstration speed V_F = margin_factor * V_D."""
213
+ _require_positive(vd_ms, "vd")
214
+ _require_positive(margin_factor, "margin_factor")
215
+ return margin_factor * vd_ms
216
+
217
+
218
+ def damping_margin(damping_at_v_test, min_damping=MIN_DAMPING):
219
+ """Damping margin verdict at the maximum test speed."""
220
+ _require_positive(min_damping, "min_damping")
221
+ return {"damping": damping_at_v_test, "min_required": min_damping,
222
+ "pass": damping_at_v_test >= min_damping}
223
+
224
+
225
+ def frequency_separation(f1, f2, min_frac=MIN_FREQ_SEPARATION):
226
+ """Mode frequency separation |f1-f2| / mean(f1,f2) vs min_frac."""
227
+ _require_positive(f1, "f1")
228
+ _require_positive(f2, "f2")
229
+ _require_positive(min_frac, "min_frac")
230
+ separation = abs(f1 - f2) / ((f1 + f2) / 2.0)
231
+ return {"f1": f1, "f2": f2, "separation": separation,
232
+ "min_frac": min_frac, "pass": separation >= min_frac}
233
+
234
+
235
+ def flutter_speed_from_damping(speeds, dampings):
236
+ """Extrapolated flutter speed from the damping vs test speed trend.
237
+
238
+ Least squares line damping = m*speed + b; V_F = -b/m at zero
239
+ crossing. Speeds strictly increasing, >= 2 points, decreasing
240
+ trend (slope < 0). Raises ValueError otherwise."""
241
+ n = len(speeds)
242
+ if n == 0 or len(dampings) == 0:
243
+ raise ValueError("speeds and dampings must not be empty")
244
+ if n != len(dampings):
245
+ raise ValueError("speeds and dampings lengths differ")
246
+ if n < 2:
247
+ raise ValueError("at least two damping points are required")
248
+ if any(speeds[i] >= speeds[i + 1] for i in range(n - 1)):
249
+ raise ValueError("speeds must be strictly increasing")
250
+ sx = sum(speeds)
251
+ sy = sum(dampings)
252
+ sxx = sum(x * x for x in speeds)
253
+ sxy = sum(x * y for x, y in zip(speeds, dampings))
254
+ denom = n * sxx - sx * sx
255
+ if denom == 0:
256
+ raise ValueError("degenerate speed set")
257
+ m = (n * sxy - sx * sy) / denom
258
+ b = (sy - m * sx) / n
259
+ if m >= 0:
260
+ raise ValueError("damping trend is not decreasing; no zero crossing")
261
+ return -b / m
262
+
263
+
264
+ def flutter_margin_ratio(vf_measured, vd, required_ratio=FLUTTER_MARGIN_FACTOR):
265
+ """Flutter margin verdict ratio = V_F_measured / V_D >= 1.2."""
266
+ _require_positive(vf_measured, "vf_measured")
267
+ _require_positive(vd, "vd")
268
+ _require_positive(required_ratio, "required_ratio")
269
+ ratio = vf_measured / vd
270
+ return {"ratio": ratio, "required_ratio": required_ratio,
271
+ "pass": ratio >= required_ratio}
272
+
273
+
274
+ def stall_warning_required(vs1g_ms):
275
+ """Warning onset speed required: 1.05*VS1g or VS1g + 3 kt, greater."""
276
+ _require_positive(vs1g_ms, "vs1g")
277
+ knot_floor_ms = STALL_WARNING_KNOT_FLOOR * 0.514444
278
+ return max(vs1g_ms * (1.0 + STALL_WARNING_MARGIN), vs1g_ms + knot_floor_ms)
279
+
280
+
281
+ def stall_warning_verdict(vs1g_ms, observed_warning_ms):
282
+ """Verdict that the observed stall warning onset meets the margin."""
283
+ required = stall_warning_required(vs1g_ms)
284
+ ok = observed_warning_ms >= required
285
+ return {"required_ms": required, "observed_ms": observed_warning_ms,
286
+ "ok": ok}
287
+
288
+
289
+ def accelerated_stall_speed(vs1g_ms, load_factor):
290
+ """Stall speed at an elevated entry load factor V = vs1g*sqrt(n)."""
291
+ _require_positive(vs1g_ms, "vs1g")
292
+ if load_factor < 1:
293
+ raise ValueError("entry load factor must be >= 1")
294
+ return vs1g_ms * math.sqrt(load_factor)
295
+
296
+
297
+ def level_turn_load_factor(bank_deg):
298
+ """Load factor n = 1/cos(bank) of a coordinated level turn."""
299
+ if abs(bank_deg) >= 90.0:
300
+ raise ValueError("bank must be strictly between -90 and 90 deg")
301
+ return 1.0 / math.cos(math.radians(bank_deg))
302
+
303
+
304
+ def stall_recovery_verdict(ac: TestAircraft, altitude_loss_m, pitch_up_deg,
305
+ roll_off_deg):
306
+ """Recovery characteristics verdict against the program limits."""
307
+ if altitude_loss_m < 0 or pitch_up_deg < 0 or roll_off_deg < 0:
308
+ raise ValueError("observed recovery values must be >= 0")
309
+ alt_ok = altitude_loss_m <= ac.recovery_alt_loss_limit_m
310
+ pitch_ok = pitch_up_deg <= ac.recovery_pitch_up_limit_deg
311
+ roll_ok = roll_off_deg <= ac.recovery_roll_off_limit_deg
312
+ return {"altitude_loss_ok": alt_ok, "pitch_up_ok": pitch_ok,
313
+ "roll_off_ok": roll_ok,
314
+ "ok": alt_ok and pitch_ok and roll_ok}
315
+
316
+
317
+ def risk_index(severity, likelihood):
318
+ """Risk index = severity * likelihood (both 1..5, ints)."""
319
+ if not (1 <= severity <= 5 and 1 <= likelihood <= 5):
320
+ raise ValueError("severity and likelihood must be in 1..5")
321
+ return severity * likelihood
322
+
323
+
324
+ def load_factor_within_limit(n, n_max):
325
+ """True when the applied load factor is within the positive limit."""
326
+ if n < 0:
327
+ raise ValueError("load factor must be >= 0")
328
+ _require_positive(n_max, "n_max")
329
+ return n <= n_max
330
+
331
+
332
+ # ---------------------------------------------------------------------------
333
+ # ISA atmosphere for the altitude / Mach grid (public physics)
334
+ # ---------------------------------------------------------------------------
335
+
336
+ def isa_temperature_k(h_m):
337
+ """ISA static temperature at geopotential altitude h_m."""
338
+ if h_m < 0 or h_m > 20000.0:
339
+ raise ValueError("altitude out of ISA model range")
340
+ if h_m <= H_TROPO_M:
341
+ return T0_K - LAPSE_K_PER_M * h_m
342
+ return T0_K - LAPSE_K_PER_M * H_TROPO_M
343
+
344
+
345
+ def isa_density_ratio(h_m):
346
+ """ISA density ratio sigma at geopotential altitude h_m."""
347
+ if h_m <= H_TROPO_M:
348
+ return (1.0 - LAPSE_K_PER_M * h_m / T0_K) ** ISA_EXP
349
+ sigma_t = (1.0 - LAPSE_K_PER_M * H_TROPO_M / T0_K) ** ISA_EXP
350
+ t_tropo = isa_temperature_k(H_TROPO_M)
351
+ h_iso = R_AIR * t_tropo / 9.80665
352
+ return sigma_t * math.exp(-(h_m - H_TROPO_M) / h_iso)
353
+
354
+
355
+ def mach_grid_row(ac: TestAircraft, h_m, eas_ms):
356
+ """Reduce a test EAS at altitude to TAS and Mach (ISA).
357
+
358
+ Returns dict with sigma, temperature, speed of sound, TAS, Mach,
359
+ and the limiting factor against MMO."""
360
+ if h_m < 0:
361
+ raise ValueError("altitude must be >= 0")
362
+ _require_positive(eas_ms, "eas_ms")
363
+ sigma = isa_density_ratio(h_m)
364
+ t_k = isa_temperature_k(h_m)
365
+ a_ms = math.sqrt(GAMMA * R_AIR * t_k)
366
+ tas_ms = eas_ms / math.sqrt(sigma)
367
+ mach = tas_ms / a_ms
368
+ return {"h_m": h_m, "h_ft": h_m * FT_PER_M, "sigma": sigma,
369
+ "t_k": t_k, "a_ms": a_ms, "tas_ms": tas_ms, "mach": mach,
370
+ "mmo": ac.mmo,
371
+ "limiting": ("mach-limited" if mach >= ac.mmo else "eas-limited")}
372
+
373
+
374
+ # ---------------------------------------------------------------------------
375
+ # Deliverable builder: plan + envelope expansion report content model
376
+ # ---------------------------------------------------------------------------
377
+
378
+ def build_flight_test_deliverable(ac: TestAircraft, data: dict | None = None) -> dict:
379
+ """Build the flight test plan + envelope report content model.
380
+
381
+ data is the flight-recorded (or worked-example simulated) data dict;
382
+ when None the example simulated data is used. The model carries every
383
+ computed number and verdict; nothing is asserted without a check."""
384
+ if data is None:
385
+ data = example_flight_data()
386
+ speeds = v_speeds(ac)
387
+ _require_ordered_placards(ac)
388
+ va = speeds["va"]
389
+
390
+ # --- Build-up speed blocks (fractions of V_D) ---------------------------
391
+ speed_blocks = speed_block_targets(ac.vd_eas_ms)
392
+ for blk in speed_blocks:
393
+ blk["category"] = classify_airspeed(
394
+ blk["target_eas_ms"], ac.vfe_eas_ms, va, ac.vno_eas_ms,
395
+ ac.vne_eas_ms)
396
+ blk["abort"] = ("immediate pull-up + throttle reduction if buffet, "
397
+ "LCO, or control anomaly")
398
+ blk["criteria"] = ("PASS: damping >= 0.03 at point, no LCO, load "
399
+ "factor within %.1f g; FAIL otherwise" % ac.n_max)
400
+
401
+ # --- Flutter build-up / point progression -------------------------------
402
+ flutter_pts = data["flutter_points"]
403
+ if len(flutter_pts["speeds"]) != len(speed_blocks):
404
+ raise ValueError("flutter data must cover every speed block")
405
+ vf_req = flutter_required_speed(ac.vd_eas_ms)
406
+ damp_verdicts = []
407
+ for blk, dmp in zip(speed_blocks, flutter_pts["dampings"]):
408
+ damp_verdicts.append(damping_margin(dmp))
409
+ vf_meas = flutter_speed_from_damping(list(flutter_pts["speeds"]),
410
+ list(flutter_pts["dampings"]))
411
+ margin = flutter_margin_ratio(vf_meas, ac.vd_eas_ms)
412
+ mode_pairs = [frequency_separation(f1, f2) for f1, f2 in
413
+ data["gvt_modes"]]
414
+ flutter = {
415
+ "required_speed_ms": vf_req,
416
+ "points": [{"block": blk["id"], "speed_ms": sp, "damping": dmp,
417
+ "damping_pass": dv["pass"]}
418
+ for blk, sp, dmp, dv in zip(speed_blocks,
419
+ flutter_pts["speeds"],
420
+ flutter_pts["dampings"],
421
+ damp_verdicts)],
422
+ "vf_extrapolated_ms": vf_meas,
423
+ "margin": margin,
424
+ "mode_pairs": mode_pairs,
425
+ "min_separation": min(p["separation"] for p in mode_pairs),
426
+ "pass": margin["pass"] and all(dv["pass"] for dv in damp_verdicts)
427
+ and min(p["separation"] for p in mode_pairs)
428
+ >= MIN_FREQ_SEPARATION,
429
+ }
430
+
431
+ # --- Altitude / Mach grid ------------------------------------------------
432
+ mach_grid = [mach_grid_row(ac, h, ac.vd_eas_ms)
433
+ for h in DEFAULT_ALTITUDES_M]
434
+
435
+ # --- Stall / VS1g matrix --------------------------------------------------
436
+ stall_rows = []
437
+ stall_all_ok = True
438
+ for row in data["stall_rows"]:
439
+ req = stall_warning_required(row["vs_measured"])
440
+ wv = stall_warning_verdict(row["vs_measured"], row["warning_onset"])
441
+ rec = None
442
+ if row.get("recovery"):
443
+ rec = stall_recovery_verdict(ac, row["recovery"]["alt_loss_m"],
444
+ row["recovery"]["pitch_up_deg"],
445
+ row["recovery"]["roll_off_deg"])
446
+ rec["altitude_loss_m"] = row["recovery"]["alt_loss_m"]
447
+ rec["pitch_up_deg"] = row["recovery"]["pitch_up_deg"]
448
+ rec["roll_off_deg"] = row["recovery"]["roll_off_deg"]
449
+ row_ok = wv["ok"] and rec["ok"]
450
+ else:
451
+ row_ok = wv["ok"]
452
+ stall_all_ok = stall_all_ok and row_ok
453
+ stall_rows.append({
454
+ "config": row["config"],
455
+ "vs1g_measured": row["vs_measured"],
456
+ "warning_required": req,
457
+ "warning_onset": row["warning_onset"],
458
+ "warning_ok": wv["ok"],
459
+ "recovery": rec,
460
+ "ok": row_ok,
461
+ })
462
+
463
+ # --- Envelope / load factor blocks ----------------------------------------
464
+ n_blocks = []
465
+ for i, frac in enumerate(DEFAULT_LOAD_FACTOR_BLOCKS, start=1):
466
+ target_n = frac * ac.n_max
467
+ achieved = data["maneuver_n"][i - 1]
468
+ n_blocks.append({
469
+ "id": "L%d" % i, "fraction": frac, "target_n": target_n,
470
+ "achieved_n": achieved, "ok": load_factor_within_limit(
471
+ achieved, ac.n_max),
472
+ })
473
+
474
+ # --- Climb performance check ----------------------------------------------
475
+ climb = {
476
+ "roc_fpm": data["climb_roc_fpm"],
477
+ "gradient_pct": data["climb_gradient_pct"],
478
+ "required_gradient_pct": 2.4, # FAR-25.121 context, two-engine
479
+ "margin_pct": data["climb_gradient_pct"] - 2.4,
480
+ "pass": data["climb_gradient_pct"] >= 2.4,
481
+ }
482
+
483
+ # --- Safety register --------------------------------------------------------
484
+ safety_rows = []
485
+ for r in data["risk_rows"]:
486
+ idx = risk_index(r["severity"], r["likelihood"])
487
+ safety_rows.append({
488
+ "hazard": r["hazard"],
489
+ "severity": r["severity"],
490
+ "likelihood": r["likelihood"],
491
+ "index": idx,
492
+ "high": idx >= HIGH_RISK_INDEX,
493
+ "mitigation": r["mitigation"],
494
+ })
495
+ go_no_go = {"checks": data["go_no_go"],
496
+ "go": all(data["go_no_go"].values())}
497
+
498
+ # --- Limit changes traced to data points -------------------------------------
499
+ limit_changes = data["limit_changes"]
500
+
501
+ objectives = [
502
+ "Determine VS1g and the V-speed set (vref/v2/vr/VA) with "
503
+ "warning-margin evidence in every configuration",
504
+ "Demonstrate freedom from flutter to V_D with damping >= 0.03 at "
505
+ "the maximum test speed and an extrapolated margin >= 1.2*V_D",
506
+ "Expand the speed and load factor envelope in build-up blocks to "
507
+ "V_D / n_max with a load factor check at every point",
508
+ "Verify stall recovery characteristics within the program limits",
509
+ "Measure climb performance and check the gradient requirement",
510
+ ]
511
+
512
+ model = {
513
+ "document_type": "Flight Test Plan and Envelope Expansion Report",
514
+ "status": "draft-for-review",
515
+ "aircraft": ac.name,
516
+ "aircraft_description": ac.description,
517
+ "certification_basis": ac.certification_basis,
518
+ "generated": _today(),
519
+ "objectives": objectives,
520
+ "v_speeds": speeds,
521
+ "n_max": ac.n_max,
522
+ "placards": {"vfe": ac.vfe_eas_ms, "vno": ac.vno_eas_ms,
523
+ "vne": ac.vne_eas_ms, "vd": ac.vd_eas_ms,
524
+ "mmo": ac.mmo},
525
+ "recovery_limits": {"alt_loss_m": ac.recovery_alt_loss_limit_m,
526
+ "pitch_up_deg": ac.recovery_pitch_up_limit_deg,
527
+ "roll_off_deg": ac.recovery_roll_off_limit_deg},
528
+ "speed_blocks": speed_blocks,
529
+ "flutter": flutter,
530
+ "mach_grid": mach_grid,
531
+ "stall_rows": stall_rows,
532
+ "stall_all_ok": stall_all_ok,
533
+ "n_blocks": n_blocks,
534
+ "climb": climb,
535
+ "safety_rows": safety_rows,
536
+ "safety_no_unmitigated": not any(r["high"] for r in safety_rows),
537
+ "go_no_go": go_no_go,
538
+ "limit_changes": limit_changes,
539
+ "example_data_note": data.get("note", ""),
540
+ }
541
+ return model
542
+
543
+
544
+ # ---------------------------------------------------------------------------
545
+ # Renderer: the actual deliverable markdown document
546
+ # ---------------------------------------------------------------------------
547
+
548
+ def _fmt_ms(v):
549
+ return "%.1f" % v
550
+
551
+
552
+ def _kt(v):
553
+ return "%.0f" % (v * MPS_TO_KTS)
554
+
555
+
556
+ def _verdict(ok):
557
+ return "PASS" if ok else "FAIL"
558
+
559
+
560
+ def render_flight_test_markdown(model: dict) -> str:
561
+ """Render the content model as the deliverable markdown document."""
562
+ vs = model["v_speeds"]
563
+ pl = model["placards"]
564
+ L = []
565
+ L += [
566
+ "# Flight Test Plan and Envelope Expansion Report",
567
+ "",
568
+ f"**Aircraft:** {model['aircraft']}",
569
+ f"**Basis:** {model['certification_basis']}",
570
+ f"**Status:** {model['status']}",
571
+ "",
572
+ ]
573
+ if model.get("example_data_note"):
574
+ L += ["_%s_" % model["example_data_note"], ""]
575
+
576
+ # 1. Objectives and configuration
577
+ L += ["## 1. Objectives and configuration", "",
578
+ *[f"- {o}" for o in model["objectives"]], "",
579
+ f"- Limit load factor: n_max = {model['n_max']:.1f} g positive. "
580
+ f"Stall speeds (EAS): VS1g = {_fmt_ms(vs['vs'])} m/s "
581
+ f"({_kt(vs['vs'])} kt), VS1 = {_fmt_ms(vs['vs1'])} m/s "
582
+ f"({_kt(vs['vs1'])} kt), VS0 = {_fmt_ms(vs['vs0'])} m/s "
583
+ f"({_kt(vs['vs0'])} kt).",
584
+ f"- Placards (EAS): VFE = {_fmt_ms(pl['vfe'])} m/s, "
585
+ f"VNO = {_fmt_ms(pl['vno'])} m/s, VNE = {_fmt_ms(pl['vne'])} m/s, "
586
+ f"V_D = {_fmt_ms(pl['vd'])} m/s, MMO = {pl['mmo']:.2f}.",
587
+ f"- Derived: VA = {_fmt_ms(vs['va'])} m/s "
588
+ f"({_kt(vs['va'])} kt), vref = {_fmt_ms(vs['vref'])} m/s "
589
+ f"({_kt(vs['vref'])} kt), v2 = {_fmt_ms(vs['v2'])} m/s "
590
+ f"({_kt(vs['v2'])} kt), vr = {_fmt_ms(vs['vr'])} m/s "
591
+ f"({_kt(vs['vr'])} kt).",
592
+ f"- Crew: test pilot + flight test engineer console; "
593
+ f"airspace: restricted area; instrumentation: calibrated "
594
+ f"pitot-static, accelerometers, strain gauges on wing/elevator, "
595
+ f"flutter excitation vanes, telemetry with real-time damping.",
596
+ "",
597
+ ]
598
+
599
+ # 2. Safety
600
+ L += ["## 2. Safety", "",
601
+ "| Hazard | Sev | Lik | Risk | High (>=15) | Mitigation |",
602
+ "|---|---|---|---|---|---|"]
603
+ for r in model["safety_rows"]:
604
+ L.append("| %s | %d | %d | %d | %s | %s |" % (
605
+ r["hazard"], r["severity"], r["likelihood"], r["index"],
606
+ "yes" if r["high"] else "no", r["mitigation"]))
607
+ unmit = "true" if model["safety_no_unmitigated"] else "FALSE - blocked"
608
+ L += ["", "Every high-risk item is mitigated before the flight: %s."
609
+ % unmit,
610
+ "",
611
+ "| Go/no-go criterion | Value |",
612
+ "|---|---|"]
613
+ for name, ok in model["go_no_go"]["checks"].items():
614
+ L.append("| %s | %s |" % (name, "GO" if ok else "NO-GO"))
615
+ L += ["", "Flight release: %s." % (
616
+ "GO" if model["go_no_go"]["go"] else "NO-GO - blocked"), ""]
617
+
618
+ # 3. Build-up plan
619
+ L += ["## 3. Build-up plan", "",
620
+ "Build-up order: GVT -> flutter build-up (blocks S1..S4) -> "
621
+ "stall matrix -> load factor envelope -> climb performance. "
622
+ "Each block clears before the next is flown; every block states "
623
+ "its limit, abort criteria, and PASS/FAIL criteria.",
624
+ "",
625
+ "### 3.1 Flutter build-up (speed blocks as fractions of V_D)", ""]
626
+ L += ["| Block | V_D fraction | Target (EAS) | Category | Abort | "
627
+ "PASS/FAIL criteria |",
628
+ "|---|---|---|---|---|---|"]
629
+ for blk in model["speed_blocks"]:
630
+ L.append("| %s | %.2f | %s m/s (%s kt) | %s | %s | %s |" % (
631
+ blk["id"], blk["fraction"], _fmt_ms(blk["target_eas_ms"]),
632
+ _kt(blk["target_eas_ms"]), blk["category"], blk["abort"],
633
+ blk["criteria"]))
634
+ fl = model["flutter"]
635
+ L += ["",
636
+ f"Required flutter demonstration speed: V_F = "
637
+ f"{_fmt_ms(fl['required_speed_ms'])} m/s "
638
+ f"({FLUTTER_MARGIN_FACTOR:.1f} x V_D). Damping >= 0.03 required "
639
+ f"at the maximum test speed; structural mode frequency "
640
+ f"separation >= {MIN_FREQ_SEPARATION:.2f} at every point.",
641
+ "",
642
+ "### 3.2 Altitude / Mach grid (V_D tested per altitude, ISA)", ""]
643
+ L += ["| Altitude | Density ratio | Speed of sound | TAS at V_D | "
644
+ "Mach at V_D | MMO | Limit |",
645
+ "|---|---|---|---|---|---|---|"]
646
+ for g in model["mach_grid"]:
647
+ L.append("| %d ft | %.3f | %.1f m/s | %.1f m/s | %.3f | %.2f | %s |"
648
+ % (round(g["h_ft"]), g["sigma"], g["a_ms"], g["tas_ms"],
649
+ g["mach"], g["mmo"], g["limiting"]))
650
+ L += ["",
651
+ "### 3.3 Stall matrix (VS1g and warning onset per configuration)",
652
+ ""]
653
+ L += ["| Config | Measured VS1g (EAS) | Warning required | Warning "
654
+ "onset | Recovery limits |",
655
+ "|---|---|---|---|---|"]
656
+ for row in model["stall_rows"]:
657
+ L.append("| %s | %s m/s | %s m/s | %s m/s | alt <= %.0f m, pitch "
658
+ "<= %.0f deg, roll <= %.0f deg |" % (
659
+ row["config"], _fmt_ms(row["vs1g_measured"]),
660
+ _fmt_ms(row["warning_required"]),
661
+ _fmt_ms(row["warning_onset"]),
662
+ model["recovery_limits"]["alt_loss_m"],
663
+ model["recovery_limits"]["pitch_up_deg"],
664
+ model["recovery_limits"]["roll_off_deg"]))
665
+ L += ["",
666
+ "### 3.4 Load factor envelope (pull blocks at VA or above)", ""]
667
+ L += ["| Block | n_max fraction | Target n | PASS/FAIL |",
668
+ "|---|---|---|---|"]
669
+ for b in model["n_blocks"]:
670
+ L.append("| %s | %.2f | %.2f g | PASS if n <= %.1f g at point, "
671
+ "with no buffet/LCO |" % (b["id"], b["fraction"],
672
+ b["target_n"], model["n_max"]))
673
+ L += ["",
674
+ "### 3.5 Climb performance",
675
+ "",
676
+ "Sawtooth climb segments at constant indicated airspeed; "
677
+ f"gradient check against {model['climb']['required_gradient_pct']}"
678
+ " % (two-engine context).",
679
+ "",
680
+ ]
681
+
682
+ # 4. Results
683
+ L += ["## 4. Results", "", "### 4.1 Flutter build-up points", ""]
684
+ L += ["| Block | Speed (EAS) | Damping | Min damping | Verdict |",
685
+ "|---|---|---|---|---|"]
686
+ for p in fl["points"]:
687
+ L.append("| %s | %s m/s | %.3f | %.3f | %s |" % (
688
+ p["block"], _fmt_ms(p["speed_ms"]), p["damping"],
689
+ MIN_DAMPING, _verdict(p["damping_pass"])))
690
+ L += ["",
691
+ f"Extrapolated flutter speed from the damping trend: "
692
+ f"{_fmt_ms(fl['vf_extrapolated_ms'])} m/s; margin ratio "
693
+ f"{fl['margin']['ratio']:.2f} vs required "
694
+ f"{fl['margin']['required_ratio']:.1f} -> "
695
+ f"{_verdict(fl['margin']['pass'])}.",
696
+ "",
697
+ "Structural mode frequency separation (GVT pairs):"]
698
+ for p in fl["mode_pairs"]:
699
+ L.append(f"- mode pair {p['f1']:.1f} Hz / {p['f2']:.1f} Hz: "
700
+ f"separation {p['separation']:.2f} >= "
701
+ f"{p['min_frac']:.2f} -> {_verdict(p['pass'])}")
702
+ L += ["", "### 4.2 Stall results", ""]
703
+ L += ["| Config | VS1g (EAS) | Warning onset | Warning margin | "
704
+ "Recovery | Verdict |",
705
+ "|---|---|---|---|---|---|"]
706
+ for row in model["stall_rows"]:
707
+ if row["recovery"]:
708
+ rec = "alt %.0f m / pitch %.0f deg / roll %.0f deg" % (
709
+ row["recovery"]["altitude_loss_m"],
710
+ row["recovery"]["pitch_up_deg"],
711
+ row["recovery"]["roll_off_deg"])
712
+ else:
713
+ rec = "-"
714
+ L.append("| %s | %s m/s | %s m/s | %+.2f m/s | %s | %s |" % (
715
+ row["config"], _fmt_ms(row["vs1g_measured"]),
716
+ _fmt_ms(row["warning_onset"]),
717
+ row["warning_onset"] - row["vs1g_measured"], rec,
718
+ _verdict(row["ok"])))
719
+ L += ["", "### 4.3 Load factor envelope results", ""]
720
+ L += ["| Block | Target n | Achieved n | Limit | Verdict |",
721
+ "|---|---|---|---|---|"]
722
+ for b in model["n_blocks"]:
723
+ L.append("| %s | %.2f g | %.2f g | %.1f g | %s |" % (
724
+ b["id"], b["target_n"], b["achieved_n"], model["n_max"],
725
+ _verdict(b["ok"])))
726
+ L += ["", "### 4.4 Climb performance results", "",
727
+ f"- Rate of climb: {model['climb']['roc_fpm']:.0f} ft/min "
728
+ f"(segment reduction, corrected to reference weight).",
729
+ f"- Gradient: {model['climb']['gradient_pct']:.2f} % vs "
730
+ f"{model['climb']['required_gradient_pct']:.1f} % required -> "
731
+ f"margin {model['climb']['margin_pct']:+.2f} % "
732
+ f"({_verdict(model['climb']['pass'])}).",
733
+ "",
734
+ ]
735
+
736
+ # 5. Limit changes and findings
737
+ L += ["## 5. Findings, limit changes, and recommendations", ""]
738
+ L += ["| Envelope limit change | Traced to data point | Verdict |",
739
+ "|---|---|---|"]
740
+ for ch in model["limit_changes"]:
741
+ L.append("| %s | %s | %s |" % (ch["limit"], ch["data_point"],
742
+ _verdict(ch["ok"])))
743
+ L += ["",
744
+ "Recommendations: (1) expand to the top block only after the "
745
+ "damping and separation verdicts above; (2) repeat the "
746
+ "configuration whose stall warning onset is marginal before "
747
+ "envelope use; (3) confirm flutter clearance per point with the "
748
+ "flight test conductor.",
749
+ "",
750
+ ]
751
+
752
+ # 6. Sign-off
753
+ L += ["## 6. Sign-off", "",
754
+ "This plan/report states that the recorded data support "
755
+ "expanding to the listed limits PENDING human flight test "
756
+ "conductor review. It is a draft deliverable, not a clearance.",
757
+ "",
758
+ "---",
759
+ f"*Generated by Aero Agent Roles flight-test-engineer core "
760
+ f"({model['generated']}). DRAFT for human flight test conductor "
761
+ f"review. Not a clearance and not an approval of the envelope "
762
+ f"or of flight.*",
763
+ ]
764
+ return "\n".join(L)
765
+
766
+
767
+ # ---------------------------------------------------------------------------
768
+ # Evidence gate checkers
769
+ # ---------------------------------------------------------------------------
770
+
771
+ GATE_CHECKS = {
772
+ "objectives_stated": "test objectives are present",
773
+ "blocks_buildup": "speed blocks rise to >= 0.99 of V_D in equal steps",
774
+ "limits_stated": "VNO/VNE/V_D/n_max and flutter margin numbers present",
775
+ "criteria_stated": "every block carries PASS/FAIL criteria",
776
+ "flutter_damping_gate": "damping >= 0.03 verdict present at top speed",
777
+ "sign_off_honest": "deliverable is draft, not a clearance",
778
+ }
779
+
780
+
781
+ def check_flight_test_deliverable(model: dict) -> dict:
782
+ """Run the evidence gates against the content model."""
783
+ blocks = model.get("speed_blocks", [])
784
+ fl = model.get("flutter", {})
785
+ results = {
786
+ "objectives_stated": bool(model.get("objectives")),
787
+ "blocks_buildup": bool(blocks) and blocks[-1]["fraction"] >= 0.99,
788
+ "limits_stated": ("vd" in model.get("placards", {})
789
+ and model.get("n_max") is not None),
790
+ "criteria_stated": all(b.get("criteria") for b in blocks),
791
+ "flutter_damping_gate": bool(fl.get("points")) and all(
792
+ p["damping_pass"] for p in fl.get("points", [])),
793
+ "sign_off_honest": model.get("status") == "draft-for-review",
794
+ }
795
+ results["all_pass"] = all(results.values())
796
+ return results
797
+
798
+
799
+ def check_flight_test_markdown(md_text: str) -> dict:
800
+ """Gate-check the rendered markdown deliverable."""
801
+ low = md_text.lower()
802
+ checks = {
803
+ "has_title": ("flight test plan" in low
804
+ and "envelope expansion report" in low),
805
+ "has_limits": bool(re.search(r"vne|v_d|vno", low)),
806
+ "has_blocks": bool(re.search(r"\bs[1-4]\b|\bs%d\b", low))
807
+ or "0.85" in low,
808
+ "has_pass_fail": "pass" in low and "fail" in low,
809
+ "has_draft_marker": "draft" in low,
810
+ "has_not_clearance": "not a clearance" in low,
811
+ "has_numbers": bool(re.search(r"\d+\.\d+", low)),
812
+ "no_blank_blanks": "___" not in md_text,
813
+ }
814
+ checks["all_pass"] = all(checks.values())
815
+ return checks
816
+
817
+
818
+ def validate_deliverable_text(md_text: str) -> dict:
819
+ """Public entry point used by gate tooling: check a plan/report doc."""
820
+ return check_flight_test_markdown(md_text)
821
+
822
+
823
+ # ---------------------------------------------------------------------------
824
+ # Example aircraft and simulated flight data (worked example for tests)
825
+ # ---------------------------------------------------------------------------
826
+
827
+ def example_aircraft() -> TestAircraft:
828
+ """The worked-example reference aircraft (a synthetic light jet)."""
829
+ return TestAircraft(
830
+ name="FT-1 Worked-Example Light Jet",
831
+ description="Synthetic light jet used to demonstrate the flight "
832
+ "test engineer deliverable. Numbers are for planning "
833
+ "practice only, not a real vehicle.",
834
+ gross_weight_kg=5900.0,
835
+ n_max=2.5,
836
+ vs_eas_ms=44.0,
837
+ vs1_eas_ms=40.0,
838
+ vs0_eas_ms=36.0,
839
+ vfe_eas_ms=62.0,
840
+ vno_eas_ms=85.0,
841
+ vne_eas_ms=94.0,
842
+ vd_eas_ms=106.0,
843
+ mmo=0.78,
844
+ certification_basis="FAR/CS-25 context (worked example)",
845
+ recovery_alt_loss_limit_m=30.0,
846
+ recovery_pitch_up_limit_deg=8.0,
847
+ recovery_roll_off_limit_deg=20.0,
848
+ )
849
+
850
+
851
+ def example_flight_data() -> dict:
852
+ """Worked-example simulated flight data (clearly labeled; not real)."""
853
+ return {
854
+ "note": "Flight data below are SIMULATED for the worked example "
855
+ "and must be replaced by recorded flight data.",
856
+ "gvt_modes": [(4.2, 11.6), (9.1, 14.8), (11.6, 14.8), (4.2, 9.1)],
857
+ "flutter_points": {
858
+ "speeds": [90.1, 95.4, 100.7, 106.0],
859
+ "dampings": [0.120, 0.115, 0.108, 0.100],
860
+ },
861
+ "stall_rows": [
862
+ {"config": "clean", "vs_measured": 44.1, "warning_onset": 46.8,
863
+ "recovery": {"alt_loss_m": 18.0, "pitch_up_deg": 3.0,
864
+ "roll_off_deg": 6.0}},
865
+ {"config": "takeoff (flaps)", "vs_measured": 40.4,
866
+ "warning_onset": 42.5,
867
+ "recovery": {"alt_loss_m": 21.0, "pitch_up_deg": 4.0,
868
+ "roll_off_deg": 8.0}},
869
+ {"config": "landing (flaps/gear)", "vs_measured": 36.3,
870
+ "warning_onset": 38.2,
871
+ "recovery": {"alt_loss_m": 24.0, "pitch_up_deg": 5.0,
872
+ "roll_off_deg": 9.0}},
873
+ ],
874
+ "maneuver_n": [1.26, 1.88, 2.48],
875
+ "climb_roc_fpm": 1450.0,
876
+ "climb_gradient_pct": 2.70,
877
+ "risk_rows": [
878
+ {"hazard": "Flutter onset at high speed", "severity": 5,
879
+ "likelihood": 2,
880
+ "mitigation": "GVT + build-up blocks, damping gate 0.03, "
881
+ "excitation vanes, real-time monitoring"},
882
+ {"hazard": "Stall departure at aft CG", "severity": 4,
883
+ "likelihood": 3,
884
+ "mitigation": "entry technique, altitude floor, recovery "
885
+ "demonstration, spin chute installed"},
886
+ {"hazard": "Overspeed control upset", "severity": 4,
887
+ "likelihood": 2,
888
+ "mitigation": "abort criteria at every block, test pilot "
889
+ "briefing, VMO/VNE marking"},
890
+ {"hazard": "Engine failure during climb segment", "severity": 3,
891
+ "likelihood": 2,
892
+ "mitigation": "single-engine drills, field/landing options, "
893
+ "engine instruments monitored"},
894
+ {"hazard": "Telemetry data loss", "severity": 2,
895
+ "likelihood": 3,
896
+ "mitigation": "onboard recording backup, link checked "
897
+ "pre-flight"},
898
+ ],
899
+ "go_no_go": {"weather": True, "aircraft_ready": True,
900
+ "instrumentation_ok": True, "safety_review_ok": True,
901
+ "airspace_ok": True},
902
+ "limit_changes": [
903
+ {"limit": "Speed envelope to 0.85 V_D", "data_point": "S1",
904
+ "ok": True},
905
+ {"limit": "Speed envelope to 0.90 V_D", "data_point": "S2",
906
+ "ok": True},
907
+ {"limit": "Speed envelope to 0.95 V_D", "data_point": "S3",
908
+ "ok": True},
909
+ {"limit": "Speed envelope to 1.00 V_D", "data_point": "S4",
910
+ "ok": True},
911
+ ],
912
+ }
913
+
914
+
915
+ def example_deliverable_markdown() -> str:
916
+ return render_flight_test_markdown(
917
+ build_flight_test_deliverable(example_aircraft()))
918
+
919
+
920
+ if __name__ == "__main__":
921
+ ac = example_aircraft()
922
+ model = build_flight_test_deliverable(ac)
923
+ md = render_flight_test_markdown(model)
924
+ print("AIRCRAFT:", model["aircraft"])
925
+ print("V-SPEEDS:", {k: round(v, 2) for k, v in model["v_speeds"].items()})
926
+ print("FLUTTER REQUIRED: %.1f m/s" % model["flutter"]["required_speed_ms"])
927
+ print("FLUTTER EXTRAPOLATED: %.1f m/s" %
928
+ model["flutter"]["vf_extrapolated_ms"])
929
+ print("FLUTTER MARGIN RATIO: %.3f" % model["flutter"]["margin"]["ratio"])
930
+ print("GATES:", check_flight_test_deliverable(model))
931
+ print("RENDERED:", len(md), "chars")