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,1398 @@
1
+ #!/usr/bin/env python3
2
+ """flight_test_performance_core.py - Flight Test Performance Engineer executable core.
3
+
4
+ This is the role's ENGINE: given a flight-test sortie data set (recorded
5
+ takeoff, landing, level-acceleration, cruise and stall-speed runs plus the
6
+ test-day conditions) it reduces the measured data to standard conditions
7
+ and reference weight, computes the takeoff/landing distance legs, the
8
+ determined thrust from the level acceleration (total energy method), the
9
+ corrected cruise fuel flows and range-performance curve, the weight
10
+ corrections to the stall/V speeds, and the computed-vs-predicted scatter
11
+ table. It BUILDS the Flight Test Performance Data Analysis Report and
12
+ gate-checks deliverables. Standalone: no external repo needed.
13
+
14
+ Domain rules encoded here are public flight-test methodology as bound in
15
+ the Aero Agent Skills flight-test-operations/performance leaves and in the
16
+ public FAR-25 / CS-25 context those leaves reference (summary only, never
17
+ reproduced text): ISA atmosphere physics, the 35 ft obstacle height of the
18
+ takeoff distance definition, trapezoid ground-roll integration, the Vref =
19
+ 1.23*Vs0 landing approach practice, the 1.67 certified landing field
20
+ length factor, the total-energy specific excess power P_s = V*a/g, the
21
+ thrust lapse sigma^0.7 model, the sqrt(w_ref/w_test) fuel-flow weight
22
+ correction, and the sqrt(w/w_ref) stall-speed correction.
23
+
24
+ Units: SI. Forces and weights in N, speeds in m/s, distances in m,
25
+ fuel flows in kg/s, altitudes in m, temperatures in K/C as noted.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import math
30
+ from dataclasses import dataclass, field
31
+ from datetime import date
32
+
33
+ def _today() -> str:
34
+ import os
35
+ from datetime import date
36
+ return os.environ.get("ROLE_GEN_DATE", date.today().isoformat())
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Physical constants (public ISA atmosphere physics, SI)
40
+ # ---------------------------------------------------------------------------
41
+
42
+ RHO0 = 1.225 # standard sea level density, kg/m^3
43
+ P0_PA = 101325.0 # standard sea level pressure, Pa
44
+ T0_K = 288.15 # standard sea level temperature, K
45
+ LAPSE_K_PER_M = 0.0065 # troposphere lapse rate, K/m
46
+ G0 = 9.80665 # standard gravity, m/s^2
47
+ R_GAS = 287.05287 # specific gas constant of air, J/(kg K)
48
+ GAMMA = 1.4
49
+ H_TROPO_M = 11000.0 # tropopause geopotential altitude, m
50
+ T_TROPO_K = 216.65 # tropopause temperature, K
51
+ MAX_H_M = 20000.0 # model ceiling, m
52
+ H_ISO_M = R_GAS * T_TROPO_K / G0 # isothermal scale height, m
53
+ ISA_DENSITY_EXP = G0 / (R_GAS * LAPSE_K_PER_M) - 1.0 # 4.25588
54
+ ISA_PRESSURE_EXP = G0 / (R_GAS * LAPSE_K_PER_M) # 5.25588
55
+ FT_PER_M = 1.0 / 0.3048
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Domain constants (flight-test practice, FAR-25 / CS-25 summary context as
59
+ # encoded in the bound AeroSkills performance leaves)
60
+ # ---------------------------------------------------------------------------
61
+
62
+ OBSTACLE_35FT_M = 10.668 # 35 ft obstacle height (takeoff distance def)
63
+ APPROACH_FACTOR = 1.23 # Vref = 1.23 * Vs0 (landing-distance leaf)
64
+ DEFAULT_FLARE_TIME_S = 5.0 # flare segment time model (landing-distance leaf)
65
+ FIELD_LENGTH_FACTOR = 1.67 # certified landing field length factor
66
+ LAPSE_EXP = 0.7 # installed thrust lapse exponent (sigma^0.7)
67
+ SCATTER_BAND_PCT = 10.0 # computed-vs-predicted agreement band, percent
68
+
69
+
70
+ def _check_positive(name, value):
71
+ if value <= 0:
72
+ raise ValueError("%s must be > 0, got %r" % (name, value))
73
+
74
+
75
+ def _check_nonnegative(name, value):
76
+ if value < 0:
77
+ raise ValueError("%s must be >= 0, got %r" % (name, value))
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # ISA atmosphere (public physics; same model as the bound leaves)
82
+ # ---------------------------------------------------------------------------
83
+
84
+ def isa_conditions(altitude_m):
85
+ """ISA (T in K, P in Pa, rho in kg/m^3) at geopotential altitude.
86
+
87
+ Troposphere lapse 0.0065 K/m to 11000 m, isothermal 216.65 K above.
88
+ Anchors: 0 m -> (288.15, 101325.0, 1.225); 8000 m -> (236.15,
89
+ 35599.8, 0.52517); 11000 m density ratio 0.29707.
90
+ """
91
+ if altitude_m < 0 or altitude_m > MAX_H_M:
92
+ raise ValueError("altitude must be within 0 to 20000 m, got %r"
93
+ % (altitude_m,))
94
+ if altitude_m <= H_TROPO_M:
95
+ t = T0_K - LAPSE_K_PER_M * altitude_m
96
+ p = P0_PA * (1.0 - LAPSE_K_PER_M * altitude_m / T0_K) ** ISA_PRESSURE_EXP
97
+ else:
98
+ t = T_TROPO_K
99
+ p_t = P0_PA * (1.0 - LAPSE_K_PER_M * H_TROPO_M / T0_K) ** ISA_PRESSURE_EXP
100
+ p = p_t * math.exp(-(altitude_m - H_TROPO_M) / H_ISO_M)
101
+ return (t, p, p / (R_GAS * t))
102
+
103
+
104
+ def isa_density_ratio_m(altitude_m):
105
+ """ISA density ratio sigma = rho/rho0 at geopotential altitude (m)."""
106
+ if altitude_m < 0 or altitude_m > MAX_H_M:
107
+ raise ValueError("altitude must be within 0 to 20000 m, got %r"
108
+ % (altitude_m,))
109
+ if altitude_m <= H_TROPO_M:
110
+ return (1.0 - LAPSE_K_PER_M * altitude_m / T0_K) ** ISA_DENSITY_EXP
111
+ sigma_t = (1.0 - LAPSE_K_PER_M * H_TROPO_M / T0_K) ** ISA_DENSITY_EXP
112
+ return sigma_t * math.exp(-(altitude_m - H_TROPO_M) / H_ISO_M)
113
+
114
+
115
+ def isa_pressure_ratio_m(altitude_m):
116
+ """ISA pressure ratio delta = P/P0 at geopotential altitude (m)."""
117
+ if altitude_m < 0 or altitude_m > MAX_H_M:
118
+ raise ValueError("altitude must be within 0 to 20000 m, got %r"
119
+ % (altitude_m,))
120
+ if altitude_m <= H_TROPO_M:
121
+ return (1.0 - LAPSE_K_PER_M * altitude_m / T0_K) ** ISA_PRESSURE_EXP
122
+ delta_t = (1.0 - LAPSE_K_PER_M * H_TROPO_M / T0_K) ** ISA_PRESSURE_EXP
123
+ return delta_t * math.exp(-(altitude_m - H_TROPO_M) / H_ISO_M)
124
+
125
+
126
+ def isa_temperature_m(altitude_m):
127
+ """ISA static temperature in K at geopotential altitude (m)."""
128
+ if altitude_m < 0 or altitude_m > MAX_H_M:
129
+ raise ValueError("altitude must be within 0 to 20000 m, got %r"
130
+ % (altitude_m,))
131
+ if altitude_m <= H_TROPO_M:
132
+ return T0_K - LAPSE_K_PER_M * altitude_m
133
+ return T_TROPO_K
134
+
135
+
136
+ def day_sigma_from_alt_oat(pressure_alt_m, oat_deg_c):
137
+ """Test-day density ratio sigma = (P/P0_ISA) * (T0 / T_amb).
138
+
139
+ The test-day density ratio from the measured pressure altitude and
140
+ outside air temperature (the density-altitude formulation used by the
141
+ climb-performance leaf). Returns a float in (0, 1] for field levels.
142
+ """
143
+ _check_nonnegative("pressure altitude", pressure_alt_m)
144
+ t_amb = oat_deg_c + 273.15
145
+ if t_amb <= 0:
146
+ raise ValueError("outside air temperature must be above absolute zero")
147
+ return isa_pressure_ratio_m(pressure_alt_m) * (T0_K / t_amb)
148
+
149
+
150
+ def density_altitude_m(pressure_alt_m, oat_deg_c):
151
+ """Density altitude in m for a measured pressure altitude and OAT.
152
+
153
+ sigma from the pressure altitude and the ambient temperature; the
154
+ density altitude is the altitude where the ISA density ratio equals
155
+ sigma (bisection, same model as the climb-performance leaf).
156
+ """
157
+ _check_nonnegative("pressure altitude", pressure_alt_m)
158
+ t_amb = oat_deg_c + 273.15
159
+ if t_amb <= 0:
160
+ raise ValueError("outside air temperature must be above absolute zero")
161
+ sigma = isa_pressure_ratio_m(pressure_alt_m) * (T0_K / t_amb)
162
+ lo, hi = 0.0, MAX_H_M
163
+ for _ in range(80):
164
+ mid = 0.5 * (lo + hi)
165
+ if isa_density_ratio_m(mid) > sigma:
166
+ lo = mid
167
+ else:
168
+ hi = mid
169
+ return 0.5 * (lo + hi)
170
+
171
+
172
+ def tas_from_eas(v_eas, rho, rho0=RHO0):
173
+ """True airspeed in m/s from equivalent airspeed and air density.
174
+
175
+ V_tas = V_eas * sqrt(rho0 / rho) (equal dynamic pressure). Anchor:
176
+ 110 m/s EAS at the 8000 m ISA density 0.52517 kg/m^3 -> 168.0 m/s.
177
+ """
178
+ _check_positive("equivalent airspeed", v_eas)
179
+ _check_positive("air density", rho)
180
+ _check_positive("sea level density", rho0)
181
+ return v_eas * math.sqrt(rho0 / rho)
182
+
183
+
184
+ def speed_of_sound_m(altitude_m):
185
+ """ISA speed of sound in m/s at altitude (m)."""
186
+ _check_nonnegative("altitude", altitude_m)
187
+ t = isa_temperature_m(altitude_m)
188
+ return math.sqrt(GAMMA * R_GAS * t)
189
+
190
+
191
+ def tas_from_mach(mach, altitude_m):
192
+ """True airspeed in m/s from Mach number and altitude (m)."""
193
+ if mach < 0:
194
+ raise ValueError("mach must be >= 0, got %r" % (mach,))
195
+ return mach * speed_of_sound_m(altitude_m)
196
+
197
+
198
+ # ---------------------------------------------------------------------------
199
+ # Weight corrections (V speeds / stall speeds / fuel flow)
200
+ # ---------------------------------------------------------------------------
201
+
202
+ def vs1g_from_wing_loading(wing_loading_n_m2, rho_kg_m3, cl_max):
203
+ """Reference stall speed Vs1g = sqrt(2*(W/S)/(rho*CLmax)), m/s."""
204
+ for name, value in (("wing loading", wing_loading_n_m2),
205
+ ("air density", rho_kg_m3), ("cl_max", cl_max)):
206
+ _check_positive(name, value)
207
+ return math.sqrt(2.0 * wing_loading_n_m2 / (rho_kg_m3 * cl_max))
208
+
209
+
210
+ def weight_corrected_speed(v_ref, w_ref, w_new):
211
+ """Speed corrected for a weight change: v_new = v_ref*sqrt(w_new/w_ref)."""
212
+ _check_positive("reference speed", v_ref)
213
+ _check_positive("reference weight", w_ref)
214
+ _check_positive("new weight", w_new)
215
+ return v_ref * math.sqrt(w_new / float(w_ref))
216
+
217
+
218
+ def stall_margin(vs, v_current):
219
+ """Stall margin (v_current - vs) / vs (negative = below stall speed)."""
220
+ _check_positive("stall speed", vs)
221
+ _check_nonnegative("current speed", v_current)
222
+ return (v_current - vs) / vs
223
+
224
+
225
+ def weight_correction_factor(w_test, w_ref):
226
+ """Square-root fuel-flow weight correction sqrt(w_ref / w_test)."""
227
+ _check_positive("test weight", w_test)
228
+ _check_positive("reference weight", w_ref)
229
+ return math.sqrt(w_ref / w_test)
230
+
231
+
232
+ def corrected_fuel_flow(wf_measured, w_test, w_ref):
233
+ """Fuel flow (kg/s) corrected from the test weight to the reference."""
234
+ _check_nonnegative("measured fuel flow", wf_measured)
235
+ return wf_measured * weight_correction_factor(w_test, w_ref)
236
+
237
+
238
+ def range_performance(tas_m_s, wf_corrected):
239
+ """Range performance (distance per unit fuel mass), m/kg."""
240
+ _check_positive("corrected fuel flow", wf_corrected)
241
+ return tas_m_s / wf_corrected
242
+
243
+
244
+ # ---------------------------------------------------------------------------
245
+ # Traces: integration, differentiation, quality
246
+ # ---------------------------------------------------------------------------
247
+
248
+ def ground_roll_integrate(speeds, times):
249
+ """Ground distance from ground speed samples (trapezoid rule), m.
250
+
251
+ s = sum((v_i + v_{i+1})/2 * dt_i). Mirrors the takeoff-distance leaf.
252
+ """
253
+ if len(speeds) != len(times):
254
+ raise ValueError("speed and time samples must be equal length")
255
+ if len(speeds) < 2:
256
+ raise ValueError("need at least two speed samples")
257
+ s = 0.0
258
+ for i in range(len(speeds) - 1):
259
+ v0, v1 = speeds[i], speeds[i + 1]
260
+ t0, t1 = times[i], times[i + 1]
261
+ if v0 < 0 or v1 < 0:
262
+ raise ValueError("speed samples must be >= 0")
263
+ if t1 <= t0:
264
+ raise ValueError("time samples must be strictly increasing")
265
+ s += (v0 + v1) / 2.0 * (t1 - t0)
266
+ return s
267
+
268
+
269
+ def trace_quality_verdict(times, values, max_gap=None):
270
+ """Data quality verdict for a reduced trace: NaN and gap flags.
271
+
272
+ Returns {"verdict": "ok"|"flagged", "issues": [...]}; mirrors the
273
+ data-reduction leaf's pre-analysis gate.
274
+ """
275
+ if len(times) != len(values):
276
+ raise ValueError("times and values must have equal length")
277
+ if not times:
278
+ raise ValueError("trace must not be empty")
279
+ issues = []
280
+ for i, vi in enumerate(values):
281
+ if isinstance(vi, float) and math.isnan(vi):
282
+ issues.append({"type": "nan", "index": i, "detail": "NaN sample"})
283
+ if max_gap is not None and len(times) > 1:
284
+ for i in range(1, len(times)):
285
+ d = times[i] - times[i - 1]
286
+ if d > max_gap:
287
+ issues.append({"type": "gap", "index": i,
288
+ "detail": "time gap %.3f s exceeds %.3f s"
289
+ % (d, max_gap)})
290
+ return {"verdict": "ok" if not issues else "flagged", "issues": issues}
291
+
292
+
293
+ def smooth_trace(trace, window):
294
+ """Moving average over an odd centered window (same length output)."""
295
+ if not isinstance(window, int) or window < 1 or window % 2 == 0:
296
+ raise ValueError("smoothing window must be a positive odd integer")
297
+ if not trace:
298
+ raise ValueError("trace must not be empty")
299
+ half = (window - 1) // 2
300
+ out = []
301
+ for i in range(len(trace)):
302
+ lo = max(0, i - half)
303
+ hi = min(len(trace) - 1, i + half)
304
+ out.append(sum(trace[lo:hi + 1]) / (hi - lo + 1.0))
305
+ return out
306
+
307
+
308
+ def acceleration_from_trace(speeds, times):
309
+ """Acceleration in m/s^2: central differences, one-sided at the ends."""
310
+ if len(speeds) != len(times):
311
+ raise ValueError("airspeed and time lists must match in length")
312
+ n = len(speeds)
313
+ if n < 2:
314
+ raise ValueError("at least two samples are required")
315
+ for i in range(1, n):
316
+ if times[i] <= times[i - 1]:
317
+ raise ValueError("time must be strictly increasing")
318
+ out = [0.0] * n
319
+ out[0] = (speeds[1] - speeds[0]) / (times[1] - times[0])
320
+ for i in range(1, n - 1):
321
+ out[i] = (speeds[i + 1] - speeds[i - 1]) / (times[i + 1] - times[i - 1])
322
+ out[n - 1] = (speeds[n - 1] - speeds[n - 2]) / (times[n - 1] - times[n - 2])
323
+ return out
324
+
325
+
326
+ # ---------------------------------------------------------------------------
327
+ # Level acceleration (total energy method) - thrust determination
328
+ # ---------------------------------------------------------------------------
329
+
330
+ def specific_excess_power(v, a, g=G0, dh_dt=0.0):
331
+ """Specific excess power P_s = dh/dt + V*a/g in m/s (level: V*a/g)."""
332
+ _check_positive("true airspeed", v)
333
+ _check_positive("gravity", g)
334
+ return dh_dt + v * a / g
335
+
336
+
337
+ def excess_thrust_from_ps(ps, v, w):
338
+ """Excess thrust in N: delta_T = W * P_s / V."""
339
+ _check_positive("true airspeed", v)
340
+ _check_positive("weight", w)
341
+ return w * ps / v
342
+
343
+
344
+ def lift_coefficient(w, v, rho, s):
345
+ """CL = W / (0.5 * rho * V^2 * S)."""
346
+ for name, value in (("weight", w), ("true airspeed", v),
347
+ ("air density", rho), ("wing area", s)):
348
+ _check_positive(name, value)
349
+ return w / (0.5 * rho * v * v * s)
350
+
351
+
352
+ def drag_coefficient(cd0, k, cl):
353
+ """CD = cd0 + k * CL^2 (parabolic drag polar)."""
354
+ for name, value in (("zero-lift drag coefficient", cd0),
355
+ ("induced drag factor", k), ("lift coefficient", cl)):
356
+ _check_nonnegative(name, value)
357
+ return cd0 + k * cl * cl
358
+
359
+
360
+ def drag_from_polar(v, rho, s, w, cd0, k):
361
+ """Drag force in N from the parabolic polar at the test density."""
362
+ _check_positive("true airspeed", v)
363
+ _check_positive("air density", rho)
364
+ _check_positive("wing area", s)
365
+ _check_positive("weight", w)
366
+ cl = lift_coefficient(w, v, rho, s)
367
+ return 0.5 * rho * v * v * s * drag_coefficient(cd0, k, cl)
368
+
369
+
370
+ def thrust_available_estimate(delta_t, drag):
371
+ """Installed thrust estimate in N: T = delta_T + D (drag gap closes)."""
372
+ _check_nonnegative("drag", drag)
373
+ total = delta_t + drag
374
+ if total < 0:
375
+ raise ValueError("negative thrust available: delta_T %r below drag %r"
376
+ % (delta_t, drag))
377
+ return total
378
+
379
+
380
+ def thrust_from_acceleration(weight_n, accel_m_s2, drag_n, g_m_s2=G0):
381
+ """Installed thrust determined from a level acceleration, in N.
382
+
383
+ T = D + (W/g)*a (Newton's second law along the flight path).
384
+ """
385
+ _check_positive("weight", weight_n)
386
+ _check_nonnegative("acceleration", accel_m_s2)
387
+ _check_positive("drag", drag_n)
388
+ _check_positive("gravity", g_m_s2)
389
+ return drag_n + (weight_n / g_m_s2) * accel_m_s2
390
+
391
+
392
+ def weight_corrected_ps(ps, w_test, w_ref):
393
+ """P_s corrected to the reference weight: P_s_ref = P_s * w_test/w_ref."""
394
+ _check_positive("test weight", w_test)
395
+ _check_positive("reference weight", w_ref)
396
+ return ps * w_test / w_ref
397
+
398
+
399
+ def density_corrected_ps(ps, rho_test, rho_std=RHO0, lapse_exp=LAPSE_EXP):
400
+ """P_s corrected to the reference density at constant IAS, m/s.
401
+
402
+ P_s_std = P_s * (rho_test/rho_std)^(lapse_exp - 0.5): thrust scales
403
+ sigma^lapse_exp, true airspeed sigma^-0.5.
404
+ """
405
+ _check_positive("test density", rho_test)
406
+ _check_positive("reference density", rho_std)
407
+ if not 0.0 < lapse_exp <= 1.0:
408
+ raise ValueError("thrust lapse exponent must be in (0, 1]")
409
+ return ps * (rho_test / rho_std) ** (lapse_exp - 0.5)
410
+
411
+
412
+ def ps_at_reference_conditions(ps, w_test, w_ref, rho_test,
413
+ rho_std=RHO0, lapse_exp=LAPSE_EXP):
414
+ """P_s corrected to the reference weight AND the standard density."""
415
+ return density_corrected_ps(weight_corrected_ps(ps, w_test, w_ref),
416
+ rho_test, rho_std, lapse_exp)
417
+
418
+
419
+ def _assessment_region(n, window):
420
+ margin = (window + 1) // 2
421
+ return margin, n - margin
422
+
423
+
424
+ def reduce_level_accel_run(times_s, speeds_tas_ms, weight_n, rho_kg_m3,
425
+ s_m2, cd0, k, w_ref_n=None, rho_std=None,
426
+ window=5, dh_dt=0.0):
427
+ """Reduce one level-acceleration run (total energy method).
428
+
429
+ Returns the smoothed trace, acceleration, specific excess power,
430
+ excess thrust, the assessment-region means (acceleration, P_s,
431
+ excess thrust, drag, thrust available), the sustained-over-band
432
+ verdict and, when requested, the corrected mean P_s at the reference
433
+ weight and standard density. Mirrors the level-acceleration leaf.
434
+ """
435
+ _check_positive("test weight", weight_n)
436
+ if len(speeds_tas_ms) != len(times_s):
437
+ raise ValueError("airspeed and time lists must match in length")
438
+ n = len(speeds_tas_ms)
439
+ if n < 3:
440
+ raise ValueError("at least three samples are required")
441
+ for i in range(n):
442
+ _check_positive("true airspeed sample", speeds_tas_ms[i])
443
+ _check_positive("air density", rho_kg_m3)
444
+ if w_ref_n is not None:
445
+ _check_positive("reference weight", w_ref_n)
446
+ if rho_std is not None:
447
+ _check_positive("reference density", rho_std)
448
+ if isinstance(dh_dt, (int, float)):
449
+ dh_dt_list = [float(dh_dt)] * n
450
+ else:
451
+ if len(dh_dt) != n:
452
+ raise ValueError("dh_dt list must match the trace length")
453
+ dh_dt_list = list(dh_dt)
454
+
455
+ v_s = smooth_trace(speeds_tas_ms, window)
456
+ acc = acceleration_from_trace(v_s, times_s)
457
+ ps = [specific_excess_power(v_s[i], acc[i], G0, dh_dt_list[i])
458
+ for i in range(n)]
459
+ delta_t = [excess_thrust_from_ps(ps[i], v_s[i], weight_n)
460
+ for i in range(n)]
461
+ drag = [drag_from_polar(v_s[i], rho_kg_m3, s_m2, weight_n, cd0, k)
462
+ for i in range(n)]
463
+ t_avail = [thrust_available_estimate(delta_t[i], drag[i])
464
+ for i in range(n)]
465
+ start, end = _assessment_region(n, window)
466
+ if end <= start:
467
+ raise ValueError("trace too short for the smoothing window")
468
+ mean = lambda xs: sum(xs[start:end]) / (end - start) # noqa: E731
469
+ result = {
470
+ "density_kgm3": rho_kg_m3,
471
+ "window": window,
472
+ "assessment_start": start,
473
+ "assessment_end": end,
474
+ "v_smoothed": v_s,
475
+ "acceleration": acc,
476
+ "specific_excess_power": ps,
477
+ "excess_thrust": delta_t,
478
+ "drag": drag,
479
+ "thrust_available": t_avail,
480
+ "mean_acceleration": mean(acc),
481
+ "mean_specific_excess_power": mean(ps),
482
+ "mean_excess_thrust": mean(delta_t),
483
+ "mean_drag": mean(drag),
484
+ "mean_thrust_available": mean(t_avail),
485
+ "sustained_over_band": all(delta_t[i] > 0 for i in range(start, end)),
486
+ "mean_ps_reference_weight": None,
487
+ "mean_ps_reference_conditions": None,
488
+ }
489
+ if w_ref_n is not None:
490
+ result["mean_ps_reference_weight"] = weight_corrected_ps(
491
+ result["mean_specific_excess_power"], weight_n, w_ref_n)
492
+ if rho_std is not None:
493
+ result["mean_ps_reference_conditions"] = ps_at_reference_conditions(
494
+ result["mean_specific_excess_power"], weight_n, w_ref_n,
495
+ rho_kg_m3, rho_std)
496
+ return result
497
+
498
+
499
+ # ---------------------------------------------------------------------------
500
+ # Takeoff / landing distance reductions
501
+ # ---------------------------------------------------------------------------
502
+
503
+ def constant_accel_distance(v1_m_s, a_m_s2):
504
+ """Distance to accelerate from rest to v1 (or stop): v1^2/(2a), m."""
505
+ _check_positive("speed", v1_m_s)
506
+ _check_positive("acceleration", a_m_s2)
507
+ return v1_m_s ** 2 / (2.0 * a_m_s2)
508
+
509
+
510
+ def engine_failure_distance(speeds, times, v_ef_mps):
511
+ """Ground distance from brake release to the engine failure speed, m.
512
+
513
+ Trapezoid integration of the measured ground speed samples with a
514
+ partial trapezoid across the segment where v_ef is crossed (linear
515
+ interpolation of the crossing time). Mirrors the engine-failure
516
+ takeoff leaf.
517
+ """
518
+ if len(speeds) != len(times):
519
+ raise ValueError("speed and time samples must be equal length")
520
+ if len(speeds) < 2:
521
+ raise ValueError("at least two samples are required")
522
+ _check_positive("engine failure speed", v_ef_mps)
523
+ if v_ef_mps < speeds[0] or v_ef_mps > speeds[-1]:
524
+ raise ValueError("v_ef must lie within the sampled speed range")
525
+ for i in range(len(speeds) - 1):
526
+ if speeds[i + 1] <= speeds[i] or times[i + 1] <= times[i]:
527
+ raise ValueError("speeds and times must be strictly increasing")
528
+ dist = 0.0
529
+ for j in range(len(speeds) - 1):
530
+ if speeds[j + 1] <= v_ef_mps:
531
+ dist += 0.5 * (speeds[j] + speeds[j + 1]) * (
532
+ times[j + 1] - times[j])
533
+ else:
534
+ frac = ((v_ef_mps - speeds[j])
535
+ / (speeds[j + 1] - speeds[j]))
536
+ t_cross = times[j] + frac * (times[j + 1] - times[j])
537
+ dist += 0.5 * (speeds[j] + v_ef_mps) * (t_cross - times[j])
538
+ break
539
+ return dist
540
+
541
+
542
+ def reduce_takeoff_run(run, h_target_m=OBSTACLE_35FT_M):
543
+ """Reduce one takeoff run to its measured distance legs, m.
544
+
545
+ run keys: times_s, speeds_mps (ground speed samples from brake
546
+ release to rotation), v_rot_mps, t_rot_s, roc_mps (climb rate after
547
+ liftoff), liftoff_mps. Measured legs mirror the takeoff-distance
548
+ leaf: ground roll (trapezoid to rotation speed) + rotation leg
549
+ v_rot*t_rot + airborne climb v_liftoff*h_target/roc. The predicted
550
+ ground roll uses the constant-acceleration model at the measured
551
+ mean acceleration, giving the computed-vs-predicted comparison.
552
+ """
553
+ times = run["times_s"]
554
+ speeds = run["speeds_mps"]
555
+ v_rot = run["v_rot_mps"]
556
+ t_rot = run["t_rot_s"]
557
+ roc = run["roc_mps"]
558
+ v_lf = run.get("liftoff_mps", speeds[-1])
559
+ _check_positive("rotation speed", v_rot)
560
+ _check_positive("rotation time", t_rot)
561
+ _check_positive("climb rate", roc)
562
+ _check_positive("liftoff speed", v_lf)
563
+
564
+ s_ground = ground_roll_integrate(speeds, times)
565
+ s_rot = v_rot * t_rot
566
+ s_air = v_lf * h_target_m / roc
567
+ total = s_ground + s_rot + s_air
568
+
569
+ a_mean = (speeds[-1] - speeds[0]) / (times[-1] - times[0])
570
+ s_ground_pred = constant_accel_distance(speeds[-1], a_mean)
571
+ total_pred = s_ground_pred + s_rot + s_air
572
+ return {
573
+ "id": run.get("id", "TO-1"),
574
+ "weight_n": run.get("weight_n"),
575
+ "v_rot_mps": v_rot,
576
+ "rotation_time_s": t_rot,
577
+ "liftoff_mps": v_lf,
578
+ "climb_rate_mps": roc,
579
+ "mean_accel_mps2": a_mean,
580
+ "ground_roll_m": s_ground,
581
+ "rotation_m": s_rot,
582
+ "climb_35ft_m": s_air,
583
+ "total_m": total,
584
+ "ground_roll_predicted_m": s_ground_pred,
585
+ "total_predicted_m": total_pred,
586
+ "speeds_mps": speeds,
587
+ "times_s": times,
588
+ }
589
+
590
+
591
+ def reduce_landing_run(run, vs0_ref_eas_ms, w_ref_n, sigma_test,
592
+ factor=APPROACH_FACTOR,
593
+ field_length_factor=FIELD_LENGTH_FACTOR,
594
+ runway_m=None):
595
+ """Reduce one landing run to the demonstrated + certified distances, m.
596
+
597
+ Vref = factor * Vs0 at the run weight, converted to true airspeed at
598
+ the test-day density ratio; airborne flare Vref*t_air; braking
599
+ ground roll recorded (measured) and modelled V_td^2/(2*mu*g);
600
+ certified field length = 1.67 * demonstrated total.
601
+ """
602
+ w_run = run["weight_n"]
603
+ _check_positive("run weight", w_run)
604
+ vs0_run = weight_corrected_speed(vs0_ref_eas_ms, w_ref_n, w_run)
605
+ vref_eas = factor * vs0_run
606
+ vref_tas = tas_from_eas(vref_eas, RHO0 * sigma_test)
607
+ s_air = vref_tas * run["t_air_s"]
608
+ v_td = vref_tas
609
+ s_ground_meas = v_td ** 2 / (2.0 * run["a_brake_meas_mps2"])
610
+ total_meas = s_air + s_ground_meas
611
+ mu = run.get("mu_brake")
612
+ s_ground_pred = None
613
+ total_pred = None
614
+ if mu:
615
+ s_ground_pred = constant_accel_distance(v_td, mu * G0)
616
+ total_pred = s_air + s_ground_pred
617
+ certified = field_length_factor * total_meas
618
+ row = {
619
+ "id": run.get("id", "LD-1"),
620
+ "weight_n": w_run,
621
+ "vs0_run_eas_ms": vs0_run,
622
+ "vref_eas_ms": vref_eas,
623
+ "vref_tas_ms": vref_tas,
624
+ "airborne_m": s_air,
625
+ "ground_roll_measured_m": s_ground_meas,
626
+ "ground_roll_predicted_m": s_ground_pred,
627
+ "demonstrated_m": total_meas,
628
+ "predicted_m": total_pred,
629
+ "certified_field_length_m": certified,
630
+ "field_length_factor": field_length_factor,
631
+ }
632
+ if runway_m is not None:
633
+ margin = runway_m - certified
634
+ row["runway_m"] = runway_m
635
+ row["margin_m"] = margin
636
+ row["fits"] = margin >= 0
637
+ return row
638
+
639
+
640
+ def reduce_accelerate_stop(v1_m_s, a_acc_m_s2, a_brake_m_s2):
641
+ """Rejected takeoff accelerate-stop distance: accel leg + stop leg, m."""
642
+ _check_positive("decision speed", v1_m_s)
643
+ _check_positive("acceleration", a_acc_m_s2)
644
+ _check_positive("braking deceleration", a_brake_m_s2)
645
+ s_acc = constant_accel_distance(v1_m_s, a_acc_m_s2)
646
+ s_stop = constant_accel_distance(v1_m_s, a_brake_m_s2)
647
+ return {"accelerate_m": s_acc, "stop_m": s_stop,
648
+ "total_m": s_acc + s_stop}
649
+
650
+
651
+ def reduce_oei_takeoff(failure_leg_m, v1_m_s, v2_m_s, t_rec_s,
652
+ a_cont_m_s2, roc_oei_m_s, h_target_m=OBSTACLE_35FT_M):
653
+ """Engine-out takeoff distance (continued takeoff legs chained), m.
654
+
655
+ failure leg (measured ground run to VEF) + recognition v1*t_rec +
656
+ continued ground (v2^2 - v1^2)/(2*a_cont) + climb at V2 to 35 ft.
657
+ """
658
+ _check_nonnegative("failure distance", failure_leg_m)
659
+ _check_positive("decision speed", v1_m_s)
660
+ _check_positive("takeoff safety speed", v2_m_s)
661
+ if v2_m_s < v1_m_s:
662
+ raise ValueError("v2 must be at or above the decision speed v1")
663
+ _check_positive("recognition time", t_rec_s)
664
+ _check_positive("continued acceleration", a_cont_m_s2)
665
+ _check_positive("engine-out climb rate", roc_oei_m_s)
666
+ s_rec = v1_m_s * t_rec_s
667
+ s_ground = (v2_m_s ** 2 - v1_m_s ** 2) / (2.0 * a_cont_m_s2)
668
+ s_climb = v2_m_s * h_target_m / roc_oei_m_s
669
+ total = failure_leg_m + s_rec + s_ground + s_climb
670
+ return {"failure_m": failure_leg_m, "recognition_m": s_rec,
671
+ "ground_continue_m": s_ground, "climb_m": s_climb,
672
+ "total_m": total}
673
+
674
+
675
+ # ---------------------------------------------------------------------------
676
+ # Cruise reduction (quadratic range-performance fit over Mach)
677
+ # ---------------------------------------------------------------------------
678
+
679
+ def _solve_linear_system(matrix, rhs):
680
+ """Solve A x = b by Gaussian elimination with partial pivoting."""
681
+ n = len(matrix)
682
+ a = [row[:] for row in matrix]
683
+ b = list(rhs)
684
+ for col in range(n):
685
+ pivot_row = max(range(col, n), key=lambda r: abs(a[r][col]))
686
+ if abs(a[pivot_row][col]) < 1.0e-300:
687
+ raise ValueError("singular linear system in quadratic fit")
688
+ if pivot_row != col:
689
+ a[col], a[pivot_row] = a[pivot_row], a[col]
690
+ b[col], b[pivot_row] = b[pivot_row], b[col]
691
+ for row in range(col + 1, n):
692
+ factor = a[row][col] / a[col][col]
693
+ for c in range(col, n):
694
+ a[row][c] -= factor * a[col][c]
695
+ b[row] -= factor * b[col]
696
+ solution = [0.0] * n
697
+ for row in range(n - 1, -1, -1):
698
+ acc = b[row] - sum(a[row][c] * solution[c] for c in range(row + 1, n))
699
+ solution[row] = acc / a[row][row]
700
+ return solution
701
+
702
+
703
+ def reduce_cruise_points(points, w_ref, lrc_fraction=0.99):
704
+ """Reduce cruise fuel-flow runs to the range-performance curve.
705
+
706
+ points: list of {mach, altitude_m, w_test_kg, wf_measured_kg_s}.
707
+ Each measured fuel flow is corrected to w_ref (sqrt weight
708
+ correction), range performance rp = tas/wf follows, and a quadratic
709
+ rp(M) is fitted by least squares. Returns the point table, the
710
+ coefficients, the fitted residuals, the max-range Mach (vertex) and
711
+ the long-range-cruise Mach at lrc_fraction of the maximum.
712
+ """
713
+ if len(points) < 3:
714
+ raise ValueError("at least 3 test points are required")
715
+ _check_positive("reference weight", w_ref)
716
+ machs = [p["mach"] for p in points]
717
+ for mach in machs:
718
+ if not (0.3 < mach < 1.0):
719
+ raise ValueError("mach must lie within (0.3, 1.0)")
720
+ if len(set(machs)) != len(machs):
721
+ raise ValueError("duplicate Mach values are not allowed")
722
+ table = []
723
+ for p in points:
724
+ _check_positive("test weight", p["w_test_kg"])
725
+ _check_positive("measured fuel flow", p["wf_measured_kg_s"])
726
+ tas = tas_from_mach(p["mach"], p["altitude_m"])
727
+ wf_c = corrected_fuel_flow(p["wf_measured_kg_s"],
728
+ p["w_test_kg"], w_ref)
729
+ table.append({"mach": p["mach"], "altitude_m": p["altitude_m"],
730
+ "w_test_kg": p["w_test_kg"],
731
+ "wf_measured_kg_s": p["wf_measured_kg_s"],
732
+ "tas": tas, "wf_corr": wf_c,
733
+ "rp": range_performance(tas, wf_c)})
734
+ n = len(table)
735
+ sum_x = sum(p["mach"] for p in table)
736
+ sum_x2 = sum(p["mach"] ** 2 for p in table)
737
+ sum_x3 = sum(p["mach"] ** 3 for p in table)
738
+ sum_x4 = sum(p["mach"] ** 4 for p in table)
739
+ sum_y = sum(p["rp"] for p in table)
740
+ sum_xy = sum(p["mach"] * p["rp"] for p in table)
741
+ sum_x2y = sum(p["mach"] ** 2 * p["rp"] for p in table)
742
+ normal = [[sum_x4, sum_x3, sum_x2], [sum_x3, sum_x2, sum_x],
743
+ [sum_x2, sum_x, float(n)]]
744
+ rhs = [sum_x2y, sum_xy, sum_y]
745
+ c2, c1, c0 = _solve_linear_system(normal, rhs)
746
+ fitted = [c2 * p["mach"] ** 2 + c1 * p["mach"] + c0 for p in table]
747
+ residuals = [f - p["rp"] for f, p in zip(fitted, table)]
748
+ if c2 < 0.0:
749
+ max_rp_mach = -c1 / (2.0 * c2)
750
+ max_rp = c2 * max_rp_mach ** 2 + c1 * max_rp_mach + c0
751
+ target = lrc_fraction * max_rp
752
+ disc = c1 ** 2 - 4.0 * c2 * (c0 - target)
753
+ lrc_mach = (max((-c1 - math.sqrt(disc)) / (2.0 * c2),
754
+ (-c1 + math.sqrt(disc)) / (2.0 * c2))
755
+ if disc >= 0.0 else None)
756
+ verdict = "maximum-found"
757
+ else:
758
+ max_rp_mach = max_rp = lrc_mach = None
759
+ verdict = "no-maximum"
760
+ mean_rp = sum(p["rp"] for p in table) / n
761
+ rms = math.sqrt(sum(r * r for r in residuals) / n)
762
+ return {"points": table, "coefficients": (c2, c1, c0),
763
+ "fitted": fitted, "residuals": residuals,
764
+ "max_rp_mach": max_rp_mach, "max_rp": max_rp,
765
+ "lrc_mach": lrc_mach, "verdict": verdict,
766
+ "mean_rp": mean_rp, "rms_residual": rms}
767
+
768
+
769
+ def scatter_row(label, measured, predicted):
770
+ """Computed-vs-predicted scatter row with percent delta and band flag."""
771
+ if measured == 0:
772
+ raise ValueError("measured value must be non-zero for a scatter row")
773
+ delta_pct = (predicted - measured) / abs(measured) * 100.0
774
+ return {"item": label, "measured": measured, "predicted": predicted,
775
+ "delta_pct": delta_pct,
776
+ "within_band": abs(delta_pct) <= SCATTER_BAND_PCT}
777
+
778
+
779
+ # ---------------------------------------------------------------------------
780
+ # Project facts (the sortie data set = the item)
781
+ # ---------------------------------------------------------------------------
782
+
783
+ @dataclass
784
+ class PerformanceTestItem:
785
+ """Sortie data set + aircraft facts the role reduces and reports on."""
786
+ aircraft_name: str
787
+ description: str = ""
788
+ basis: str = "FAR/CS-25 (context only)"
789
+ w_ref_n: float = 250000.0 # reference gross weight for corrections
790
+ s_m2: float = 122.6
791
+ cd0: float = 0.020
792
+ k: float = 0.042
793
+ cl_max: float = 2.0 # clean (1g stall) max lift coefficient
794
+ cl_max_land: float = 2.7 # landing-config max lift coefficient
795
+ runway_m: float = 900.0 # available landing runway length
796
+ field_pressure_alt_m: float = 152.4 # ~500 ft field elevation
797
+ field_oat_c: float = 22.0 # test-day outside air temperature
798
+ sortie_id: str = "SORTIE-FT2-047"
799
+ takeoff_run: dict = field(default_factory=dict)
800
+ landing_run: dict = field(default_factory=dict)
801
+ level_accel_run: dict = field(default_factory=dict)
802
+ cruise_points: list = field(default_factory=list)
803
+ stall_runs: list = field(default_factory=list)
804
+ oei_run: dict = field(default_factory=dict)
805
+ accel_stop: dict = field(default_factory=dict)
806
+
807
+ def wing_loading_n_m2(self, weight_n=None):
808
+ return (weight_n or self.w_ref_n) / self.s_m2
809
+
810
+ def vs1g_ref_eas_ms(self):
811
+ """Clean 1g stall speed at the reference weight, m/s EAS."""
812
+ return vs1g_from_wing_loading(self.wing_loading_n_m2(self.w_ref_n),
813
+ RHO0, self.cl_max)
814
+
815
+ def vs0_ref_eas_ms(self):
816
+ """Landing-config stall speed at the reference weight, m/s EAS."""
817
+ return vs1g_from_wing_loading(self.wing_loading_n_m2(self.w_ref_n),
818
+ RHO0, self.cl_max_land)
819
+
820
+
821
+ def _takeoff_speed_profile(t_rot_idx=19, n_samples=21):
822
+ """Deterministic worked-example ground speed profile v(t), m/s.
823
+
824
+ v(t) = 2.0*t + 0.015*t^2 sampled every second from brake release
825
+ (t=0) to rotation at t=t_rot_idx; the liftoff sample follows at the
826
+ next second. The small quadratic term keeps the trapezoid integral
827
+ different from the constant-acceleration model, giving the example
828
+ a realistic computed-vs-predicted scatter.
829
+ """
830
+ times = [float(i) for i in range(n_samples)]
831
+ speeds = [round(2.0 * t + 0.015 * t * t, 3) for t in times]
832
+ return times, speeds, speeds[t_rot_idx]
833
+
834
+
835
+ def example_item() -> PerformanceTestItem:
836
+ """The worked-example item: a flight-test sortie data set for
837
+ takeoff / landing / cruise test points (SIMULATED data)."""
838
+ times, speeds, v_rot = _takeoff_speed_profile()
839
+ takeoff_run = {
840
+ "id": "TO-1",
841
+ "weight_n": 240000.0,
842
+ "times_s": times,
843
+ "speeds_mps": speeds,
844
+ "v_rot_mps": v_rot, # speed at rotation, m/s ground speed
845
+ "t_rot_s": 3.0, # recorded rotation time, s
846
+ "roc_mps": 6.0, # measured climb rate after liftoff
847
+ "liftoff_mps": speeds[-1],
848
+ }
849
+ landing_run = {
850
+ "id": "LD-1",
851
+ "weight_n": 232000.0,
852
+ "t_air_s": 5.0, # recorded flare/airborne time, s
853
+ "a_brake_meas_mps2": 3.9, # measured braking deceleration
854
+ "mu_brake": 0.42, # dry-runway braking friction model
855
+ }
856
+ # Level acceleration at 8000 m ISA, MTOW, 150 -> 170 m/s TAS in 20 s
857
+ n = 21
858
+ la_times = [float(i) for i in range(n)]
859
+ la_speeds = [round(150.0 + 1.0 * i, 3) for i in range(n)]
860
+ level_accel_run = {
861
+ "id": "LA-1",
862
+ "weight_n": 250000.0,
863
+ "altitude_m": 8000.0, # ISA density used at the test altitude
864
+ "times_s": la_times,
865
+ "speeds_tas_ms": la_speeds,
866
+ "window": 5,
867
+ }
868
+ # Cruise fuel-flow sweep at FL350 with a linear weight ramp; test
869
+ # masses in kg (gross weights 240000 -> 234000 N converted / g).
870
+ cruise_points = [
871
+ {"mach": 0.70, "altitude_m": 10668.0,
872
+ "w_test_kg": round(240000.0 / G0, 1), "wf_measured_kg_s": 0.455},
873
+ {"mach": 0.74, "altitude_m": 10668.0,
874
+ "w_test_kg": round(238000.0 / G0, 1), "wf_measured_kg_s": 0.442},
875
+ {"mach": 0.78, "altitude_m": 10668.0,
876
+ "w_test_kg": round(236000.0 / G0, 1), "wf_measured_kg_s": 0.448},
877
+ {"mach": 0.82, "altitude_m": 10668.0,
878
+ "w_test_kg": round(234000.0 / G0, 1), "wf_measured_kg_s": 0.500},
879
+ ]
880
+ # Stall runs at the takeoff gross weight
881
+ stall_runs = [
882
+ {"config": "clean", "weight_n": 240000.0,
883
+ "vs_measured_eas_ms": 39.9, "warning_onset_mps": 41.2},
884
+ {"config": "landing", "weight_n": 232000.0,
885
+ "vs_measured_eas_ms": 34.1, "warning_onset_mps": 35.6},
886
+ ]
887
+ oei_run = {
888
+ "id": "TO-OEI-1",
889
+ "weight_n": 240000.0,
890
+ "v_ef_mps": 37.0, "v1_mps": 38.0, "v2_mps": 46.3,
891
+ "t_rec_s": 1.0, "a_cont_mps2": 2.2, "roc_oei_mps": 2.8,
892
+ }
893
+ accel_stop = {"v1_mps": 38.0, "a_acc_mps2": 2.3, "mu_brake": 0.42}
894
+ return PerformanceTestItem(
895
+ aircraft_name="FT-2 Worked-Example Turbofan Regional Jet",
896
+ description="Simulated flight-test sortie data set covering "
897
+ "takeoff, landing, level-acceleration and cruise "
898
+ "test points (worked example).",
899
+ sortie_id="SORTIE-FT2-047",
900
+ takeoff_run=takeoff_run,
901
+ landing_run=landing_run,
902
+ level_accel_run=level_accel_run,
903
+ cruise_points=cruise_points,
904
+ stall_runs=stall_runs,
905
+ oei_run=oei_run,
906
+ accel_stop=accel_stop,
907
+ )
908
+
909
+
910
+ # ---------------------------------------------------------------------------
911
+ # Sortie analysis: reduce everything into the results model
912
+ # ---------------------------------------------------------------------------
913
+
914
+ def analyze_sortie(item: PerformanceTestItem) -> dict:
915
+ """Reduce the full sortie data set to the analysis results model."""
916
+ w_ref = item.w_ref_n
917
+ vs1g_ref = item.vs1g_ref_eas_ms()
918
+ vs0_ref = item.vs0_ref_eas_ms()
919
+ sigma_test = day_sigma_from_alt_oat(item.field_pressure_alt_m, item.field_oat_c)
920
+ rho_test = RHO0 * sigma_test
921
+ da_m = density_altitude_m(item.field_pressure_alt_m, item.field_oat_c)
922
+ da_ft = da_m * FT_PER_M
923
+ _, _, rho_la = isa_conditions(item.level_accel_run["altitude_m"])
924
+
925
+ # -- conditions --------------------------------------------------------
926
+ conditions = {
927
+ "pressure_alt_m": item.field_pressure_alt_m,
928
+ "oat_deg_c": item.field_oat_c,
929
+ "sigma_test": sigma_test,
930
+ "rho_test_kgm3": rho_test,
931
+ "density_altitude_m": da_m,
932
+ "density_altitude_ft": da_ft,
933
+ "delta_from_isa_k": (item.field_oat_c + 273.15)
934
+ - isa_temperature_m(item.field_pressure_alt_m),
935
+ }
936
+
937
+ # -- data quality (speed traces) ----------------------------------------
938
+ quality = {
939
+ "takeoff_trace": trace_quality_verdict(
940
+ item.takeoff_run["times_s"], item.takeoff_run["speeds_mps"],
941
+ max_gap=2.0),
942
+ "level_accel_trace": trace_quality_verdict(
943
+ item.level_accel_run["times_s"],
944
+ item.level_accel_run["speeds_tas_ms"], max_gap=2.0),
945
+ }
946
+
947
+ # -- takeoff -------------------------------------------------------------
948
+ to = reduce_takeoff_run(item.takeoff_run)
949
+ # measured failure leg: trapezoid of the recorded profile to VEF
950
+ oei_failure_leg = engine_failure_distance(
951
+ item.takeoff_run["speeds_mps"], item.takeoff_run["times_s"],
952
+ item.oei_run["v_ef_mps"])
953
+ oei = reduce_oei_takeoff(
954
+ oei_failure_leg, item.oei_run["v1_mps"],
955
+ item.oei_run["v2_mps"], item.oei_run["t_rec_s"],
956
+ item.oei_run["a_cont_mps2"], item.oei_run["roc_oei_mps"])
957
+ oei["failure_measured_m"] = oei_failure_leg
958
+ asd = reduce_accelerate_stop(item.accel_stop["v1_mps"],
959
+ item.accel_stop["a_acc_mps2"],
960
+ item.accel_stop["mu_brake"] * G0)
961
+ takeoff = {"run": to, "oei": oei, "accelerate_stop": asd,
962
+ "v1_mps": item.oei_run["v1_mps"],
963
+ "v2_mps": item.oei_run["v2_mps"]}
964
+
965
+ # -- landing -------------------------------------------------------------
966
+ landing = reduce_landing_run(
967
+ item.landing_run, vs0_ref, w_ref, sigma_test, runway_m=item.runway_m)
968
+
969
+ # -- level acceleration ---------------------------------------------------
970
+ la = item.level_accel_run
971
+ la_res = reduce_level_accel_run(
972
+ la["times_s"], la["speeds_tas_ms"], la["weight_n"], rho_la,
973
+ item.s_m2, item.cd0, item.k, w_ref_n=w_ref, rho_std=RHO0,
974
+ window=la["window"])
975
+ la_res["run_id"] = la["id"]
976
+ la_res["altitude_m"] = la["altitude_m"]
977
+ la_res["weight_n"] = la["weight_n"]
978
+
979
+ # -- cruise ---------------------------------------------------------------
980
+ # Fuel-flow reductions use masses (kg); the reference mass is the
981
+ # reference gross weight expressed as a mass (W_ref / g).
982
+ cruise = reduce_cruise_points(item.cruise_points, w_ref / G0)
983
+ cruise["w_ref_kg"] = w_ref / G0
984
+
985
+ # -- speeds / stall --------------------------------------------------------
986
+ speeds = {
987
+ "vs1g_ref_eas_ms": vs1g_ref,
988
+ "vs0_ref_eas_ms": vs0_ref,
989
+ "stall_rows": [],
990
+ }
991
+ for s in item.stall_runs:
992
+ vs_pred = weight_corrected_speed(vs1g_ref if s["config"] == "clean"
993
+ else vs0_ref, w_ref, s["weight_n"])
994
+ margin = stall_margin(vs_pred, s["vs_measured_eas_ms"])
995
+ speeds["stall_rows"].append({
996
+ "config": s["config"], "weight_n": s["weight_n"],
997
+ "vs_measured_eas_ms": s["vs_measured_eas_ms"],
998
+ "vs_predicted_eas_ms": vs_pred,
999
+ "delta_pct": (s["vs_measured_eas_ms"] - vs_pred) / vs_pred * 100.0,
1000
+ "stall_margin": margin,
1001
+ "warning_onset_mps": s["warning_onset_mps"],
1002
+ })
1003
+
1004
+ # -- computed vs predicted scatter -----------------------------------------
1005
+ scatter_rows = [
1006
+ scatter_row("takeoff ground roll", to["ground_roll_m"],
1007
+ to["ground_roll_predicted_m"]),
1008
+ scatter_row("takeoff distance (35 ft)", to["total_m"],
1009
+ to["total_predicted_m"]),
1010
+ ]
1011
+ if landing["ground_roll_predicted_m"] is not None:
1012
+ scatter_rows.append(scatter_row(
1013
+ "landing ground roll", landing["ground_roll_measured_m"],
1014
+ landing["ground_roll_predicted_m"]))
1015
+ scatter_rows.append(scatter_row(
1016
+ "landing distance", landing["demonstrated_m"],
1017
+ landing["predicted_m"]))
1018
+ # cruise fit residuals: fitted vs measured range performance per point
1019
+ for p, f in zip(cruise["points"], cruise["fitted"]):
1020
+ scatter_rows.append(scatter_row(
1021
+ "cruise rp M=%.2f" % p["mach"], p["rp"], f))
1022
+ for s in speeds["stall_rows"]:
1023
+ scatter_rows.append(scatter_row(
1024
+ "Vs1g %s" % s["config"], s["vs_measured_eas_ms"],
1025
+ s["vs_predicted_eas_ms"]))
1026
+ max_abs = max(abs(r["delta_pct"]) for r in scatter_rows)
1027
+ scatter = {"rows": scatter_rows, "max_abs_delta_pct": max_abs,
1028
+ "band_pct": SCATTER_BAND_PCT,
1029
+ "within_band": max_abs <= SCATTER_BAND_PCT}
1030
+
1031
+ return {
1032
+ "conditions": conditions,
1033
+ "quality": quality,
1034
+ "takeoff": takeoff,
1035
+ "landing": landing,
1036
+ "level_accel": la_res,
1037
+ "cruise": cruise,
1038
+ "speeds": speeds,
1039
+ "scatter": scatter,
1040
+ }
1041
+
1042
+
1043
+ # ---------------------------------------------------------------------------
1044
+ # Report builder / renderer / gates
1045
+ # ---------------------------------------------------------------------------
1046
+
1047
+ def build_report(item: PerformanceTestItem,
1048
+ results: dict | None = None) -> dict:
1049
+ """Build the Flight Test Performance Data Analysis Report model."""
1050
+ if results is None:
1051
+ results = analyze_sortie(item)
1052
+ return {
1053
+ "document_type": "Flight Test Performance Data Analysis Report",
1054
+ "status": "draft-for-review",
1055
+ "item": item.aircraft_name,
1056
+ "sortie_id": item.sortie_id,
1057
+ "description": item.description,
1058
+ "basis": item.basis,
1059
+ "aircraft": {
1060
+ "w_ref_n": item.w_ref_n, "s_m2": item.s_m2, "cd0": item.cd0,
1061
+ "k": item.k, "cl_max": item.cl_max, "cl_max_land": item.cl_max_land,
1062
+ "vs1g_ref_eas_ms": item.vs1g_ref_eas_ms(),
1063
+ "vs0_ref_eas_ms": item.vs0_ref_eas_ms(),
1064
+ "runway_m": item.runway_m,
1065
+ },
1066
+ "results": results,
1067
+ "generated": _today(),
1068
+ }
1069
+
1070
+
1071
+ def render_report_markdown(model: dict) -> str:
1072
+ """Render the analysis results as the deliverable markdown document."""
1073
+ r = model["results"]
1074
+ c = r["conditions"]
1075
+ ac = model["aircraft"]
1076
+ la = r["level_accel"]
1077
+ cruise = r["cruise"]
1078
+ sc = r["scatter"]
1079
+ lines = [
1080
+ "# Flight Test Performance Data Analysis Report",
1081
+ "",
1082
+ "**Aircraft:** %s" % model["item"],
1083
+ "**Sortie:** %s" % model["sortie_id"],
1084
+ "**Basis:** %s" % model["basis"],
1085
+ "**Status:** %s" % model["status"],
1086
+ "",
1087
+ "_Flight data below are SIMULATED for the worked example and must "
1088
+ "be replaced by recorded flight data._",
1089
+ "",
1090
+ "## 1. Test conditions and data quality",
1091
+ "",
1092
+ "- Field pressure altitude: %.0f m (%.0f ft); OAT %.1f C "
1093
+ "(ISA deviation %+.1f K)."
1094
+ % (c["pressure_alt_m"], c["pressure_alt_m"] * FT_PER_M,
1095
+ c["oat_deg_c"], c["delta_from_isa_k"]),
1096
+ "- Test-day density ratio sigma = %.4f (rho = %.4f kg/m^3); "
1097
+ "density altitude %.0f m (%.0f ft)."
1098
+ % (c["sigma_test"], c["rho_test_kgm3"], c["density_altitude_m"],
1099
+ c["density_altitude_ft"]),
1100
+ "- Reference gross weight for corrections: %.0f N. Wing area "
1101
+ "%.1f m^2; drag polar cd0 = %.3f, k = %.3f."
1102
+ % (ac["w_ref_n"], ac["s_m2"], ac["cd0"], ac["k"]),
1103
+ "- Data quality: takeoff trace %s; level-acceleration trace %s."
1104
+ % (r["quality"]["takeoff_trace"]["verdict"],
1105
+ r["quality"]["level_accel_trace"]["verdict"]),
1106
+ "",
1107
+ "## 2. Takeoff performance",
1108
+ "",
1109
+ ]
1110
+ to = r["takeoff"]
1111
+ row = to["run"]
1112
+ lines += [
1113
+ "Run %s at W = %.0f N: liftoff %.1f m/s at a mean acceleration of "
1114
+ "%.2f m/s^2."
1115
+ % (row["id"], row["weight_n"], row["liftoff_mps"],
1116
+ row["mean_accel_mps2"]),
1117
+ "",
1118
+ "| Leg | Measured (m) | Predicted (m) |",
1119
+ "|---|---|---|",
1120
+ "| Ground roll (to rotation) | %.1f | %.1f |"
1121
+ % (row["ground_roll_m"], row["ground_roll_predicted_m"]),
1122
+ "| Rotation | %.1f | %.1f |" % (row["rotation_m"], row["rotation_m"]),
1123
+ "| Airborne to 35 ft | %.1f | %.1f |"
1124
+ % (row["climb_35ft_m"], row["climb_35ft_m"]),
1125
+ "| **Takeoff distance** | **%.1f** | **%.1f** |"
1126
+ % (row["total_m"], row["total_predicted_m"]),
1127
+ "",
1128
+ "Engine-out (OEI) continued takeoff at V1 = %.1f m/s, V2 = %.1f "
1129
+ "m/s: failure leg %.1f m + recognition %.1f m + continued ground "
1130
+ "%.1f m + climb %.1f m = **%.1f m**."
1131
+ % (to["v1_mps"], to["v2_mps"], to["oei"]["failure_m"],
1132
+ to["oei"]["recognition_m"], to["oei"]["ground_continue_m"],
1133
+ to["oei"]["climb_m"], to["oei"]["total_m"]),
1134
+ "Accelerate-stop at V1 = %.1f m/s: accelerate %.1f m + stop %.1f m "
1135
+ "= **%.1f m**."
1136
+ % (to["v1_mps"], to["accelerate_stop"]["accelerate_m"],
1137
+ to["accelerate_stop"]["stop_m"],
1138
+ to["accelerate_stop"]["total_m"]),
1139
+ "",
1140
+ "## 3. Landing performance",
1141
+ "",
1142
+ ]
1143
+ ld = r["landing"]
1144
+ lines += [
1145
+ "Run %s at W = %.0f N: Vs0 at run weight %.2f m/s EAS, "
1146
+ "Vref = %.2f m/s EAS (1.23 x Vs0), %.2f m/s TAS at the test-day "
1147
+ "density ratio."
1148
+ % (ld["id"], ld["weight_n"], ld["vs0_run_eas_ms"],
1149
+ ld["vref_eas_ms"], ld["vref_tas_ms"]),
1150
+ "",
1151
+ "| Segment | Measured (m) | Model (m) |",
1152
+ "|---|---|---|",
1153
+ "| Airborne (flare) | %.1f | %.1f |" % (ld["airborne_m"],
1154
+ ld["airborne_m"]),
1155
+ "| Ground roll (braking) | %.1f | %s |"
1156
+ % (ld["ground_roll_measured_m"],
1157
+ "%.1f" % ld["ground_roll_predicted_m"]
1158
+ if ld["ground_roll_predicted_m"] is not None else "n/a"),
1159
+ "| **Demonstrated total** | **%.1f** | %s |"
1160
+ % (ld["demonstrated_m"],
1161
+ "%.1f" % ld["predicted_m"]
1162
+ if ld["predicted_m"] is not None else "n/a"),
1163
+ "",
1164
+ "Certified field length = %.2f x demonstrated = **%.1f m** "
1165
+ "against a %.0f m runway: %s (margin %+.1f m)."
1166
+ % (ld["field_length_factor"], ld["certified_field_length_m"],
1167
+ ld["runway_m"], "FITS" if ld["fits"] else "TOO SHORT",
1168
+ ld["margin_m"]),
1169
+ "",
1170
+ "## 4. Level-acceleration thrust determination",
1171
+ "",
1172
+ "Run %s at %.0f m (ISA density %.4f kg/m^3), W = %.0f N, trace "
1173
+ "smoothed over a %d-sample window; assessment region samples %d-%d."
1174
+ % (la["run_id"], la["altitude_m"], la["density_kgm3"],
1175
+ la["weight_n"], la["window"],
1176
+ la["assessment_start"] + 1, la["assessment_end"]),
1177
+ "",
1178
+ "| Quantity | Value |",
1179
+ "|---|---|",
1180
+ "| Mean acceleration | %.3f m/s^2 |" % la["mean_acceleration"],
1181
+ "| Mean specific excess power P_s | %.3f m/s |"
1182
+ % la["mean_specific_excess_power"],
1183
+ "| Mean excess thrust (drag gap) | %.1f N |"
1184
+ % la["mean_excess_thrust"],
1185
+ "| Mean drag (polar) | %.1f N |" % la["mean_drag"],
1186
+ "| **Mean thrust available** | **%.1f N** |"
1187
+ % la["mean_thrust_available"],
1188
+ "| Sustained acceleration over band | %s |"
1189
+ % ("PASS" if la["sustained_over_band"] else "FAIL"),
1190
+ ]
1191
+ if la["mean_ps_reference_conditions"] is not None:
1192
+ lines += [
1193
+ "| P_s at reference weight and standard density | %.3f m/s |"
1194
+ % la["mean_ps_reference_conditions"],
1195
+ ]
1196
+ cruise_alt_m = (cruise["points"][0]["altitude_m"]
1197
+ if cruise["points"] else 0.0)
1198
+ lines += [
1199
+ "",
1200
+ "## 5. Cruise performance (fuel flow reduction)",
1201
+ "",
1202
+ "Measured fuel flows corrected from the test weight to the "
1203
+ "reference weight with the sqrt(w_ref/w_test) correction at "
1204
+ "%.0f m; range performance rp = V_tas / wf_corrected."
1205
+ % cruise_alt_m,
1206
+ "",
1207
+ "| Mach | wf (kg/s) | wf corr (kg/s) | rp (m/kg) | fitted rp |",
1208
+ "|---|---|---|---|---|",
1209
+ ]
1210
+ for p, f in zip(cruise["points"], cruise["fitted"]):
1211
+ lines.append("| %.2f | %.3f | %.3f | %.1f | %.1f |"
1212
+ % (p["mach"], p["wf_measured_kg_s"], p["wf_corr"],
1213
+ p["rp"], f))
1214
+ lines += [
1215
+ "",
1216
+ "Quadratic range-performance fit rp(M) = %.3f M^2 %+.3f M %+.1f; "
1217
+ "maximum range cruise at M = %.3f; long range cruise (99%% of "
1218
+ "max rp) at M = %s."
1219
+ % (cruise["coefficients"][0], cruise["coefficients"][1],
1220
+ cruise["coefficients"][2],
1221
+ cruise["max_rp_mach"] if cruise["max_rp_mach"] else float("nan"),
1222
+ "%.3f" % cruise["lrc_mach"] if cruise["lrc_mach"] else "n/a"),
1223
+ "Fit residuals RMS: %.1f m/kg (mean rp %.1f m/kg)."
1224
+ % (cruise["rms_residual"], cruise["mean_rp"]),
1225
+ "",
1226
+ "## 6. Stall speeds and V-speed corrections",
1227
+ "",
1228
+ ]
1229
+ lines.append("| Config | Vs measured (m/s EAS) | Vs predicted at run "
1230
+ "weight (m/s EAS) | Delta | Margin |")
1231
+ lines.append("|---|---|---|---|---|")
1232
+ for s in r["speeds"]["stall_rows"]:
1233
+ lines.append("| %s | %.2f | %.2f | %+.2f%% | %+.3f |"
1234
+ % (s["config"], s["vs_measured_eas_ms"],
1235
+ s["vs_predicted_eas_ms"], s["delta_pct"],
1236
+ s["stall_margin"]))
1237
+ lines += [
1238
+ "",
1239
+ "Weight correction v(w) = v_ref * sqrt(w/w_ref) applied to the "
1240
+ "reference stall speeds Vs1g = %.2f m/s EAS and Vs0 = %.2f m/s EAS."
1241
+ % (ac["vs1g_ref_eas_ms"], ac["vs0_ref_eas_ms"]),
1242
+ "",
1243
+ "## 7. Computed vs predicted scatter",
1244
+ "",
1245
+ "| Item | Measured | Predicted | Delta (%s) | Within %s%s |"
1246
+ % ("%", "%+", sc["band_pct"]),
1247
+ "|---|---|---|---|---|",
1248
+ ]
1249
+ for srow in sc["rows"]:
1250
+ lines.append("| %s | %.1f | %.1f | %+.2f | %s |"
1251
+ % (srow["item"], srow["measured"], srow["predicted"],
1252
+ srow["delta_pct"],
1253
+ "PASS" if srow["within_band"] else "FAIL"))
1254
+ lines += [
1255
+ "",
1256
+ "Maximum absolute scatter: %+.2f%% (band %+.0f%%) -> %s."
1257
+ % (sc["max_abs_delta_pct"], sc["band_pct"],
1258
+ "within band" if sc["within_band"] else "OUT OF BAND"),
1259
+ "",
1260
+ "## 8. Findings",
1261
+ "",
1262
+ "- Takeoff distance legs are complete and the measured ground roll "
1263
+ "agrees with the constant-acceleration model within the band.",
1264
+ "- Landing certified field length (1.67 x demonstrated) fits the "
1265
+ "available runway (margin %+.0f m)." % ld["margin_m"],
1266
+ "- Level-acceleration thrust determination is internally consistent "
1267
+ "(drag-polar closure) and reduces to the reference conditions.",
1268
+ "- Cruise maximum-range Mach and long-range-cruise Mach read off "
1269
+ "the fitted range-performance curve with an RMS residual of "
1270
+ "%.1f m/kg." % cruise["rms_residual"],
1271
+ "",
1272
+ "---",
1273
+ "*Generated by Aero Agent Roles flight-test-performance-engineer "
1274
+ "core. DRAFT for human review by the flight test / performance "
1275
+ "lead. Not an approval document and not a performance guarantee.*",
1276
+ ]
1277
+ return "\n".join(lines)
1278
+
1279
+
1280
+ # ---------------------------------------------------------------------------
1281
+ # Evidence gate checkers
1282
+ # ---------------------------------------------------------------------------
1283
+
1284
+ GATE_CHECKS = {
1285
+ "test_conditions_identified": "density altitude + sigma recorded",
1286
+ "data_quality_ok": "takeoff and level-accel traces flagged clean",
1287
+ "takeoff_legs_complete": "ground roll + rotation + 35-ft climb legs",
1288
+ "landing_distance_present": "demonstrated + certified landing distance",
1289
+ "thrust_determined": "level-accel thrust available > 0, sustained",
1290
+ "cruise_curve_fit": "range-performance fit with maximum found",
1291
+ "speed_corrections_applied": "weight-corrected stall speeds recorded",
1292
+ "scatter_recorded": "computed-vs-predicted rows within the band",
1293
+ "sign_off_honest": "document is draft-for-review, not approval",
1294
+ }
1295
+
1296
+
1297
+ def check_report(model: dict) -> dict:
1298
+ """Run the evidence gates against the report content model."""
1299
+ r = model.get("results", {})
1300
+ la = r.get("level_accel", {})
1301
+ cruise = r.get("cruise", {})
1302
+ sc = r.get("scatter", {})
1303
+ conditions = r.get("conditions", {})
1304
+ quality = r.get("quality", {})
1305
+ results = {
1306
+ "test_conditions_identified": bool(
1307
+ conditions.get("density_altitude_m") is not None
1308
+ and conditions.get("sigma_test")),
1309
+ "data_quality_ok": (
1310
+ quality.get("takeoff_trace", {}).get("verdict") == "ok"
1311
+ and quality.get("level_accel_trace", {}).get("verdict") == "ok"),
1312
+ "takeoff_legs_complete": bool(
1313
+ r.get("takeoff", {}).get("run", {}).get("total_m")),
1314
+ "landing_distance_present": bool(
1315
+ r.get("landing", {}).get("certified_field_length_m")),
1316
+ "thrust_determined": bool(
1317
+ la.get("mean_thrust_available")
1318
+ and la.get("sustained_over_band") is True),
1319
+ "cruise_curve_fit": bool(cruise.get("max_rp_mach")),
1320
+ "speed_corrections_applied": bool(
1321
+ r.get("speeds", {}).get("stall_rows")),
1322
+ "scatter_recorded": bool(
1323
+ sc.get("rows") and sc.get("within_band") is True),
1324
+ "sign_off_honest": model.get("status") == "draft-for-review",
1325
+ }
1326
+ results["all_pass"] = all(results.values())
1327
+ return results
1328
+
1329
+
1330
+ def check_report_markdown(md_text: str) -> dict:
1331
+ """Gate-check the rendered markdown deliverable."""
1332
+ low = md_text.lower()
1333
+ checks = {
1334
+ "has_title": "flight test performance data analysis report" in low,
1335
+ "has_aircraft": "aircraft:" in low,
1336
+ "has_conditions": "density altitude" in low,
1337
+ "has_takeoff": "takeoff distance" in low and "35 ft" in low,
1338
+ "has_landing": "landing distance" in low and "1.67" in low,
1339
+ "has_thrust": "specific excess power" in low
1340
+ and "thrust available" in low,
1341
+ "has_cruise": "fuel flow" in low and "range performance" in low,
1342
+ "has_speeds": "stall speed" in low and "weight" in low,
1343
+ "has_scatter": "computed vs predicted" in low,
1344
+ "has_draft_marker": "draft" in low,
1345
+ "has_not_approval": "not an approval" in low,
1346
+ }
1347
+ checks["all_pass"] = all(checks.values())
1348
+ return checks
1349
+
1350
+
1351
+ def validate_deliverable_text(md_text: str) -> dict:
1352
+ """Public entry point used by gate tooling: check a report document."""
1353
+ return check_report_markdown(md_text)
1354
+
1355
+
1356
+ # ---------------------------------------------------------------------------
1357
+ # Example deliverable (for tests and worked-example generation)
1358
+ # ---------------------------------------------------------------------------
1359
+
1360
+ def example_report_model() -> dict:
1361
+ return build_report(example_item())
1362
+
1363
+
1364
+ def example_report_markdown() -> str:
1365
+ return render_report_markdown(example_report_model())
1366
+
1367
+
1368
+ if __name__ == "__main__":
1369
+ model = example_report_model()
1370
+ results = model["results"]
1371
+ md = render_report_markdown(model)
1372
+ print("ITEM: %s (%s)" % (model["item"], model["sortie_id"]))
1373
+ print("DENSITY ALTITUDE: %.0f m (%.0f ft), sigma %.4f"
1374
+ % (results["conditions"]["density_altitude_m"],
1375
+ results["conditions"]["density_altitude_ft"],
1376
+ results["conditions"]["sigma_test"]))
1377
+ to = results["takeoff"]["run"]
1378
+ print("TAKEOFF: ground %.1f m, total %.1f m (predicted %.1f m)"
1379
+ % (to["ground_roll_m"], to["total_m"], to["total_predicted_m"]))
1380
+ ld = results["landing"]
1381
+ print("LANDING: demonstrated %.1f m, certified %.1f m, fits=%s"
1382
+ % (ld["demonstrated_m"], ld["certified_field_length_m"],
1383
+ ld["fits"]))
1384
+ la = results["level_accel"]
1385
+ print("LEVEL ACCEL: a=%.3f m/s2, Ps=%.3f m/s, T=%.1f N"
1386
+ % (la["mean_acceleration"], la["mean_specific_excess_power"],
1387
+ la["mean_thrust_available"]))
1388
+ print("CRUISE: MRC M=%.3f, LRC M=%s, RMS %.1f m/kg"
1389
+ % (results["cruise"]["max_rp_mach"],
1390
+ ("%.3f" % results["cruise"]["lrc_mach"])
1391
+ if results["cruise"]["lrc_mach"] else "n/a",
1392
+ results["cruise"]["rms_residual"]))
1393
+ print("SCATTER: max abs %+.2f%%, within band %s"
1394
+ % (results["scatter"]["max_abs_delta_pct"],
1395
+ results["scatter"]["within_band"]))
1396
+ print("GATES: %s" % check_report(model))
1397
+ print("MD GATES: %s" % check_report_markdown(md))
1398
+ print("RENDERED: %d chars" % len(md))