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,1507 @@
1
+ #!/usr/bin/env python3
2
+ """flight_mechanics_core.py - Flight Mechanics Engineer executable core.
3
+
4
+ This is the role's ENGINE: given a vehicle configuration (an example
5
+ transport in this release) it computes mission performance and
6
+ stability-and-control numbers from REAL domain rules and BUILDS the
7
+ "Performance and S&C Analysis Report" deliverable. It also gate-checks
8
+ deliverables. Standalone: no external repo needed.
9
+
10
+ Domain rules encoded here are the equations documented in the bound
11
+ Aero Agent Skills flight-mechanics leaves (breguet-range,
12
+ breguet-endurance, specific-range, climb-performance,
13
+ takeoff-performance, landing-performance, glide-performance,
14
+ turn-performance, oei-climb-gradient, energy-height, wind-effects,
15
+ longitudinal-stability, dynamic-stability, short-period-mode-analysis,
16
+ phugoid-mode-analysis, lateral-directional-stability, trim-analysis,
17
+ mil-std-1797a) plus the public standards context those leaves summarize
18
+ (FAR 25.121/25.125/25.181, MIL-STD-1797A mode tables as summary).
19
+ Every equation is a paraphrase of common flight-mechanics practice;
20
+ no proprietary text is reproduced.
21
+
22
+ Boundary: the deliverable is an engineering DRAFT for human review,
23
+ never an approval, compliance finding, or certification claim.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import math
28
+ import re
29
+ from dataclasses import dataclass, field
30
+ from datetime import date
31
+
32
+ def _today() -> str:
33
+ import os
34
+ from datetime import date
35
+ return os.environ.get("ROLE_GEN_DATE", date.today().isoformat())
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Constants and small unit helpers
39
+ # ---------------------------------------------------------------------------
40
+
41
+ G0 = 9.80665 # standard gravity, m/s^2
42
+ R_AIR = 287.052874 # specific gas constant dry air, J/(kg K)
43
+ GAMMA_AIR = 1.4
44
+ T0_ISA = 288.15 # sea level ISA temperature, K
45
+ P0_ISA = 101325.0 # sea level ISA pressure, Pa
46
+ RHO0_ISA = 1.225 # sea level ISA density, kg/m^3
47
+ LAPSE = 0.0065 # ISA troposphere lapse rate, K/m
48
+ TROPOPAUSE = 11000.0 # m
49
+
50
+ MPS_TO_KT = 1.943844492 # m/s -> knots
51
+ M_TO_FT = 3.280839895 # m -> ft
52
+ MPS_TO_FPM = 196.850394 # m/s -> ft/min
53
+ KM_TO_NM = 0.539956803 # km -> nautical miles
54
+ RAD_TO_DEG = 180.0 / math.pi
55
+
56
+
57
+ def kt(mps: float) -> float:
58
+ return mps * MPS_TO_KT
59
+
60
+
61
+ def ft(m: float) -> float:
62
+ return m * M_TO_FT
63
+
64
+
65
+ def fpm(mps: float) -> float:
66
+ return mps * MPS_TO_FPM
67
+
68
+
69
+ def nm(km: float) -> float:
70
+ return km * KM_TO_NM
71
+
72
+
73
+ def deg(rad: float) -> float:
74
+ return rad * RAD_TO_DEG
75
+
76
+
77
+ def _f(x, nd=1):
78
+ """Format with thousands separator and nd decimals."""
79
+ return f"{x:,.{nd}f}"
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # ISA atmosphere (public standard atmosphere model, hydrostatic + lapse)
84
+ # ---------------------------------------------------------------------------
85
+
86
+ def isa(h_m: float) -> dict:
87
+ """ISA temperature/pressure/density at geometric altitude (m).
88
+
89
+ Troposphere: linear lapse 6.5 K/km to 11 km; stratosphere isothermal
90
+ 216.65 K above. Returns dict with T (K), p (Pa), rho (kg/m^3),
91
+ a (m/s), sigma = rho/rho0. Raises ValueError for h < 0.
92
+ """
93
+ if h_m < 0:
94
+ raise ValueError("altitude must be >= 0 m")
95
+ if h_m <= TROPOPAUSE:
96
+ T = T0_ISA - LAPSE * h_m
97
+ p = P0_ISA * (T / T0_ISA) ** (G0 / (R_AIR * LAPSE))
98
+ else:
99
+ T = T0_ISA - LAPSE * TROPOPAUSE # 216.65 K
100
+ p_base = P0_ISA * (T / T0_ISA) ** (G0 / (R_AIR * LAPSE))
101
+ p = p_base * math.exp(-G0 * (h_m - TROPOPAUSE) / (R_AIR * T))
102
+ rho = p / (R_AIR * T)
103
+ a = math.sqrt(GAMMA_AIR * R_AIR * T)
104
+ return {"T": T, "p": p, "rho": rho, "a": a,
105
+ "sigma": rho / RHO0_ISA}
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # Drag polar (parabolic model used by the thrust-required practice)
110
+ # ---------------------------------------------------------------------------
111
+
112
+ def drag_coefficient(cd0: float, k: float, cl: float) -> float:
113
+ """Parabolic drag polar CD = CD0 + K*CL^2."""
114
+ if cd0 <= 0 or k <= 0 or cl < 0:
115
+ raise ValueError("polar inputs must be physical")
116
+ return cd0 + k * cl * cl
117
+
118
+
119
+ def lift_to_drag(cd0: float, k: float, cl: float) -> float:
120
+ cd = drag_coefficient(cd0, k, cl)
121
+ if cd <= 0:
122
+ raise ValueError("drag must be positive")
123
+ return cl / cd
124
+
125
+
126
+ def max_lift_to_drag(cd0: float, k: float) -> dict:
127
+ """Maximum L/D from the parabolic polar: (L/D)max = 1/(2 sqrt(CD0 K))
128
+ at CL* = sqrt(CD0/K)."""
129
+ if cd0 <= 0 or k <= 0:
130
+ raise ValueError("polar inputs must be physical")
131
+ cl_star = math.sqrt(cd0 / k)
132
+ return {"cl_star": cl_star, "ld_max": 1.0 / (2.0 * math.sqrt(cd0 * k))}
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # Performance rules (bound leaves: breguet-range, breguet-endurance,
137
+ # specific-range, climb-performance, takeoff-performance, landing-performance,
138
+ # glide-performance, turn-performance, oei-climb-gradient, energy-height,
139
+ # wind-effects)
140
+ # ---------------------------------------------------------------------------
141
+
142
+ def breguet_range(v_ms, tsfc_kg_per_n_s, ld, m0_kg, m1_kg, g=G0):
143
+ """Breguet still-air cruise range, m:
144
+ R = (V/(TSFC*g0)) * (L/D) * ln(m0/m1). TSFC in kg/(N s)."""
145
+ if min(v_ms, tsfc_kg_per_n_s, ld, m0_kg, m1_kg) <= 0:
146
+ raise ValueError("range inputs must be positive")
147
+ if m1_kg >= m0_kg:
148
+ raise ValueError("final mass must be below initial mass")
149
+ return (v_ms / (tsfc_kg_per_n_s * g)) * ld * math.log(m0_kg / m1_kg)
150
+
151
+
152
+ def jet_endurance(tsfc_kg_per_n_s, ld, w0_n, w1_n, g=G0):
153
+ """Jet loiter endurance, s: E = (1/(TSFC*g0))*(L/D)*ln(W0/W1)
154
+ with TSFC in kg/(N s) and weights in N (weights version of the
155
+ bound breguet-endurance leaf; its 1/s SFC equals TSFC*g0)."""
156
+ if min(tsfc_kg_per_n_s, ld, w0_n, w1_n) <= 0:
157
+ raise ValueError("endurance inputs must be positive")
158
+ if w1_n >= w0_n:
159
+ raise ValueError("final weight must be below initial weight")
160
+ return (1.0 / (tsfc_kg_per_n_s * g)) * ld * math.log(w0_n / w1_n)
161
+
162
+
163
+ def specific_air_range(v_ms, tsfc, weight_n, ld):
164
+ """Instantaneous specific air range, m/kg: SAR = V*(L/D)/(TSFC*W)."""
165
+ if min(v_ms, tsfc, weight_n, ld) <= 0:
166
+ raise ValueError("SAR inputs must be positive")
167
+ return v_ms * ld / (tsfc * weight_n)
168
+
169
+
170
+ def rate_of_climb(T_n, D_n, V_ms, W_n):
171
+ """ROC = (T - D)*V / W, m/s. Excess power method (climb leaf)."""
172
+ if W_n <= 0 or V_ms <= 0:
173
+ raise ValueError("weight and speed must be positive")
174
+ if T_n - D_n < 0:
175
+ raise ValueError("no climb: thrust must exceed drag")
176
+ return (T_n - D_n) * V_ms / W_n
177
+
178
+
179
+ def climb_gradient_pct(T_n, D_n, W_n):
180
+ """Climb gradient percent: gamma = (T - D)/W * 100."""
181
+ if W_n <= 0:
182
+ raise ValueError("weight must be positive")
183
+ if T_n - D_n < 0:
184
+ raise ValueError("no climb: thrust must exceed drag")
185
+ return (T_n - D_n) / W_n * 100.0
186
+
187
+
188
+ def time_to_climb(delta_h_m, roc_a, roc_b):
189
+ """Time to climb, s, at the mean of the endpoint rates of climb."""
190
+ if delta_h_m <= 0 or roc_a <= 0 or roc_b <= 0:
191
+ raise ValueError("altitude gain and ROC endpoints must be positive")
192
+ return delta_h_m / ((roc_a + roc_b) / 2.0)
193
+
194
+
195
+ def stall_speed(wing_loading_n_m2, rho, cl_max):
196
+ """Vs = sqrt(2*(W/S)/(rho*CLmax)), m/s (takeoff leaf)."""
197
+ if min(wing_loading_n_m2, rho, cl_max) <= 0:
198
+ raise ValueError("stall inputs must be positive")
199
+ return math.sqrt(2.0 * wing_loading_n_m2 / (rho * cl_max))
200
+
201
+
202
+ def takeoff_ground_roll(weight_n, s, thrust_n, rho, cl_max, mu=0.03, g=G0):
203
+ """Takeoff ground roll, m: Sg = 1.44 W^2/(g rho S CLmax (T - mu W))
204
+ (standard estimate from the takeoff-performance leaf; the 1.44 =
205
+ 1.2^2 comes from the V_LOF = 1.2 Vs transport convention)."""
206
+ if min(weight_n, s, thrust_n, rho, cl_max) <= 0:
207
+ raise ValueError("ground roll inputs must be positive")
208
+ if not (0.0 <= mu < 1.0):
209
+ raise ValueError("rolling friction must be in [0,1)")
210
+ if thrust_n <= mu * weight_n:
211
+ raise ValueError("thrust must exceed rolling friction")
212
+ return (1.44 * weight_n * weight_n /
213
+ (g * rho * s * cl_max * (thrust_n - mu * weight_n)))
214
+
215
+
216
+ def glide_ratio(ld): # glide range per unit altitude
217
+ return ld
218
+
219
+
220
+ def glide_sink_rate(v_ms, glide_ratio_value):
221
+ """Sink rate at a glide speed: Vsink = V*sin(gamma), gamma=atan(1/LD)."""
222
+ if v_ms <= 0 or glide_ratio_value <= 0:
223
+ raise ValueError("glide inputs must be positive")
224
+ return v_ms * math.sin(math.atan(1.0 / glide_ratio_value))
225
+
226
+
227
+ def landing_air_distance(v_approach, approach_deg, obstacle_m, n_flare,
228
+ g=G0):
229
+ """Air distance over the obstacle to touchdown, m (landing leaf):
230
+ flare arc R = V^2/(g(n-1)), straight segment (h_obs - h_f)/tan(gamma)."""
231
+ if v_approach <= 0 or approach_deg <= 0 or n_flare <= 1.0:
232
+ raise ValueError("landing air inputs invalid")
233
+ gamma = math.radians(approach_deg)
234
+ radius = v_approach * v_approach / (g * (n_flare - 1.0))
235
+ h_flare = radius * (1.0 - math.cos(gamma))
236
+ if obstacle_m <= h_flare:
237
+ raise ValueError("obstacle must be above flare height")
238
+ s_flare = radius * math.sin(gamma)
239
+ straight = (obstacle_m - h_flare) / math.tan(gamma)
240
+ return straight + s_flare
241
+
242
+
243
+ def average_deceleration_g(mu_braking, lift_to_weight, drag_to_weight,
244
+ reverse_to_weight=0.0):
245
+ """Mean landing deceleration ratio in g's (landing leaf force model):
246
+ a/g = mu*(1 - L/W) + D/W + T_rev/W."""
247
+ if mu_braking < 0 or min(lift_to_weight, drag_to_weight,
248
+ reverse_to_weight) < 0:
249
+ raise ValueError("deceleration ratios must be non-negative")
250
+ return mu_braking * (1.0 - lift_to_weight) + drag_to_weight + \
251
+ reverse_to_weight
252
+
253
+
254
+ def landing_ground_roll(v_touchdown, decel_g, g=G0):
255
+ """Landing ground roll, m: s = V^2/(2 a), a = decel_g*g."""
256
+ if v_touchdown < 0 or decel_g <= 0:
257
+ raise ValueError("touchdown speed/deceleration invalid")
258
+ return v_touchdown * v_touchdown / (2.0 * decel_g * g)
259
+
260
+
261
+ def level_turn(phi_deg, v_ms, g=G0):
262
+ """Level coordinated turn at bank phi: n = 1/cos(phi), rate and radius."""
263
+ phi = math.radians(phi_deg)
264
+ if abs(phi) >= math.pi / 2.0 or v_ms <= 0:
265
+ raise ValueError("turn inputs invalid")
266
+ n = 1.0 / math.cos(phi)
267
+ rate = g * math.sqrt(n * n - 1.0) / v_ms # rad/s
268
+ radius = v_ms * v_ms / (g * math.sqrt(n * n - 1.0)) # m
269
+ return {"n": n, "rate_rad_s": rate, "radius_m": radius}
270
+
271
+
272
+ def oei_thrust(total_thrust_n, n_engines, failed=1):
273
+ """Thrust with engines failed: total*(n-f)/n."""
274
+ if total_thrust_n <= 0 or n_engines < 2 or not (1 <= failed < n_engines):
275
+ raise ValueError("OEI thrust inputs invalid")
276
+ return total_thrust_n * (n_engines - failed) / n_engines
277
+
278
+
279
+ def oei_gradient_pct(t_oei_n, drag_n, weight_n):
280
+ """OEI climb gradient percent: (T_oei - D)/W*100 (FAR 25.121 basis)."""
281
+ if weight_n <= 0 or t_oei_n < 0 or drag_n < 0:
282
+ raise ValueError("OEI gradient inputs invalid")
283
+ return (t_oei_n - drag_n) / weight_n * 100.0
284
+
285
+
286
+ def energy_height(altitude_m, v_ms, g=G0):
287
+ """Energy height h_e = h + V^2/(2g), m."""
288
+ if altitude_m < 0 or v_ms < 0:
289
+ raise ValueError("energy height inputs invalid")
290
+ return altitude_m + v_ms * v_ms / (2.0 * g)
291
+
292
+
293
+ def wind_components(wind_speed, wind_dir_deg, track_deg):
294
+ """Headwind (+) and crosswind components (wind-effects leaf)."""
295
+ if wind_speed < 0:
296
+ raise ValueError("wind speed must be >= 0")
297
+ d = math.radians(wind_dir_deg - track_deg)
298
+ return wind_speed * math.cos(d), wind_speed * math.sin(d)
299
+
300
+
301
+ # ---------------------------------------------------------------------------
302
+ # Stability and control rules (bound leaves: longitudinal-stability,
303
+ # dynamic-stability, short-period-mode-analysis, phugoid-mode-analysis,
304
+ # lateral-directional-stability, trim-analysis)
305
+ # ---------------------------------------------------------------------------
306
+
307
+ def neutral_point(h_ac_w, vh_tail, lift_slope_ratio, downwash_grad):
308
+ """Stick-fixed neutral point (fraction MAC):
309
+ h_np = h_ac_w + V_h*(a_t/a_w)*(1 - depsilon/dalpha)."""
310
+ if not (0.0 < h_ac_w < 1.0) or vh_tail <= 0 or lift_slope_ratio <= 0:
311
+ raise ValueError("neutral point inputs invalid")
312
+ if not (0.0 <= downwash_grad < 1.0):
313
+ raise ValueError("downwash gradient must be in [0,1)")
314
+ return h_ac_w + vh_tail * lift_slope_ratio * (1.0 - downwash_grad)
315
+
316
+
317
+ def static_margin(neutral_point_val, h_cg):
318
+ """SM = h_np - h_cg (fraction MAC); positive = stable."""
319
+ if not (0.0 < neutral_point_val < 1.0) or not (0.0 < h_cg < 1.0):
320
+ raise ValueError("static margin inputs invalid")
321
+ return neutral_point_val - h_cg
322
+
323
+
324
+ def c_m_alpha_from_sm(cl_alpha, margin):
325
+ """Cm_alpha = -CL_alpha * SM (standard neutral-point relation)."""
326
+ return -cl_alpha * margin
327
+
328
+
329
+ def tail_volume_horizontal(s_h, l_h, s, cbar):
330
+ """Horizontal tail volume coefficient V_h = S_h*l_h/(S*cbar)."""
331
+ if min(s_h, l_h, s, cbar) <= 0:
332
+ raise ValueError("tail volume inputs must be positive")
333
+ return s_h * l_h / (s * cbar)
334
+
335
+
336
+ def vertical_tail_volume(s_vt, l_vt, s, b):
337
+ """Vertical tail volume coefficient V_v = S_vt*l_vt/(S*b)."""
338
+ if min(s_vt, l_vt, s, b) <= 0:
339
+ raise ValueError("vertical tail volume inputs must be positive")
340
+ return s_vt * l_vt / (s * b)
341
+
342
+
343
+ def cn_beta_vertical_tail(eta_vt, v_v, a_vt, sidewash_grad=0.0):
344
+ """Fin contribution: Cn_beta_vt = eta*V_v*a_vt*(1 + k_s)."""
345
+ if not (0.0 < eta_vt <= 1.0) or v_v <= 0 or a_vt <= 0 or sidewash_grad < 0:
346
+ raise ValueError("fin contribution inputs invalid")
347
+ return eta_vt * v_v * a_vt * (1.0 + sidewash_grad)
348
+
349
+
350
+ def cl_beta_dihedral(cl, dihedral_deg):
351
+ """Dihedral contribution: Cl_beta_gamma = -CL*Gamma (radians)."""
352
+ if cl < 0:
353
+ raise ValueError("lift coefficient must be >= 0")
354
+ return -cl * math.radians(dihedral_deg)
355
+
356
+
357
+ def z_alpha_derivative(qbar, s, cl_alpha, mass):
358
+ """Z_alpha = -(qbar*S*CL_alpha)/m, m/s^2 per rad."""
359
+ if min(qbar, s, cl_alpha, mass) <= 0:
360
+ raise ValueError("Z_alpha inputs must be positive")
361
+ return -(qbar * s * cl_alpha) / mass
362
+
363
+
364
+ def m_alpha_derivative(qbar, s, cbar, c_m_alpha, i_yy):
365
+ """M_alpha = qbar*S*cbar*Cm_alpha/I_yy, 1/s^2 (Cm_alpha < 0 stable)."""
366
+ if min(qbar, s, cbar, i_yy) <= 0 or c_m_alpha >= 0:
367
+ raise ValueError("M_alpha inputs invalid (Cm_alpha must be < 0)")
368
+ return qbar * s * cbar * c_m_alpha / i_yy
369
+
370
+
371
+ def m_q_derivative(qbar, s, cbar, c_m_q, v, i_yy):
372
+ """M_q = qbar*S*cbar^2*Cm_q/(2 V I_yy), 1/s (Cm_q < 0 damping)."""
373
+ if min(qbar, s, cbar, v, i_yy) <= 0 or c_m_q >= 0:
374
+ raise ValueError("M_q inputs invalid (Cm_q must be < 0)")
375
+ return qbar * s * cbar * cbar * c_m_q / (2.0 * v * i_yy)
376
+
377
+
378
+ def short_period_frequency(z_alpha, m_alpha, m_q, v):
379
+ """omega_ns = sqrt(M_q*Z_alpha/V - M_alpha), rad/s."""
380
+ if v <= 0:
381
+ raise ValueError("speed must be positive")
382
+ radicand = m_q * z_alpha / v - m_alpha
383
+ if radicand <= 0:
384
+ raise ValueError("short period radicand must be positive")
385
+ return math.sqrt(radicand)
386
+
387
+
388
+ def short_period_damping(z_alpha, m_alpha, m_q, v):
389
+ """zeta_s = -(Z_alpha/V + M_q)/(2 omega_ns)."""
390
+ omega = short_period_frequency(z_alpha, m_alpha, m_q, v)
391
+ return -(z_alpha / v + m_q) / (2.0 * omega)
392
+
393
+
394
+ def n_over_alpha(qbar, s, cl_alpha, weight_n, g=G0):
395
+ """Load factor per angle of attack, g/rad: n_alpha = qbar*S*CL_alpha/W."""
396
+ if min(qbar, s, cl_alpha, weight_n) <= 0:
397
+ raise ValueError("n_alpha inputs invalid")
398
+ return qbar * s * cl_alpha / weight_n
399
+
400
+
401
+ def phugoid_frequency(v, g=G0):
402
+ return math.sqrt(2.0) * g / v
403
+
404
+
405
+ def phugoid_period(v, g=G0):
406
+ return 2.0 * math.pi / phugoid_frequency(v, g)
407
+
408
+
409
+ def phugoid_damping(ld):
410
+ """zeta_p = 1/(sqrt(2)*(L/D)), Lanchester drag damping."""
411
+ if ld <= 0:
412
+ raise ValueError("L/D must be positive")
413
+ return 1.0 / (math.sqrt(2.0) * ld)
414
+
415
+
416
+ def phugoid_time_to_half(v, ld, g=G0):
417
+ """t_half = ln(2)*V*(L/D)/g (identity zeta*omega = g/(V L/D))."""
418
+ if v <= 0 or ld <= 0:
419
+ raise ValueError("phugoid time inputs invalid")
420
+ return math.log(2.0) * v * ld / g
421
+
422
+
423
+ def y_beta_derivative(qbar, s, c_y_beta, mass):
424
+ """Y_beta = qbar*S*C_Ybeta/m, m/s^2 per rad (Cy_beta < 0 fin)."""
425
+ if min(qbar, s, mass) <= 0 or c_y_beta >= 0:
426
+ raise ValueError("Y_beta inputs invalid (Cy_beta must be < 0)")
427
+ return qbar * s * c_y_beta / mass
428
+
429
+
430
+ def y_r_derivative(qbar, s, b, c_y_r, v, mass):
431
+ """Y_r = qbar*S*b*C_Yr/(2 V m), m/s per rad."""
432
+ if min(qbar, s, b, v, mass) <= 0:
433
+ raise ValueError("Y_r inputs invalid")
434
+ return qbar * s * b * c_y_r / (2.0 * v * mass)
435
+
436
+
437
+ def n_beta_derivative(qbar, s, b, c_n_beta, i_zz):
438
+ """N_beta = qbar*S*b*Cn_beta/I_zz, 1/s^2 (Cn_beta > 0 stable)."""
439
+ if min(qbar, s, b, i_zz) <= 0 or c_n_beta <= 0:
440
+ raise ValueError("N_beta inputs invalid (Cn_beta must be > 0)")
441
+ return qbar * s * b * c_n_beta / i_zz
442
+
443
+
444
+ def n_r_derivative(qbar, s, b, c_n_r, v, i_zz):
445
+ """N_r = qbar*S*b^2*Cn_r/(2 V I_zz), 1/s (Cn_r < 0 damping)."""
446
+ if min(qbar, s, b, v, i_zz) <= 0 or c_n_r >= 0:
447
+ raise ValueError("N_r inputs invalid (Cn_r must be < 0)")
448
+ return qbar * s * b * b * c_n_r / (2.0 * v * i_zz)
449
+
450
+
451
+ def l_beta_derivative(qbar, s, b, c_l_beta, i_xx):
452
+ """L_beta = qbar*S*b*Cl_beta/I_xx, 1/s^2 (Cl_beta < 0 stable)."""
453
+ if min(qbar, s, b, i_xx) <= 0 or c_l_beta >= 0:
454
+ raise ValueError("L_beta inputs invalid (Cl_beta must be < 0)")
455
+ return qbar * s * b * c_l_beta / i_xx
456
+
457
+
458
+ def l_r_derivative(qbar, s, b, c_l_r, v, i_xx):
459
+ """L_r = qbar*S*b^2*Cl_r/(2 V I_xx), 1/s."""
460
+ if min(qbar, s, b, v, i_xx) <= 0:
461
+ raise ValueError("L_r inputs invalid")
462
+ return qbar * s * b * b * c_l_r / (2.0 * v * i_xx)
463
+
464
+
465
+ def l_p_derivative(qbar, s, b, c_l_p, v, i_xx):
466
+ """L_p = qbar*S*b^2*Cl_p/(2 V I_xx), 1/s (Cl_p < 0 roll damping)."""
467
+ if min(qbar, s, b, v, i_xx) <= 0 or c_l_p >= 0:
468
+ raise ValueError("L_p inputs invalid (Cl_p must be < 0)")
469
+ return qbar * s * b * b * c_l_p / (2.0 * v * i_xx)
470
+
471
+
472
+ def dutch_roll_frequency(n_beta, n_r, y_beta, y_r, v):
473
+ """omega_dr = sqrt(N_beta + (N_r*Y_beta - N_beta*Y_r)/V), rad/s."""
474
+ if v <= 0:
475
+ raise ValueError("speed must be positive")
476
+ radicand = n_beta + (n_r * y_beta - n_beta * y_r) / v
477
+ if radicand <= 0:
478
+ raise ValueError("Dutch roll radicand must be positive")
479
+ return math.sqrt(radicand)
480
+
481
+
482
+ def dutch_roll_damping(n_beta, n_r, y_beta, y_r, v):
483
+ """zeta_dr = -(Y_beta/V + N_r)/(2 omega_dr)."""
484
+ omega = dutch_roll_frequency(n_beta, n_r, y_beta, y_r, v)
485
+ return -(y_beta / v + n_r) / (2.0 * omega)
486
+
487
+
488
+ def roll_time_constant(l_p):
489
+ """Roll subsidence time constant tau = -1/L_p, s (L_p < 0)."""
490
+ if l_p >= 0:
491
+ raise ValueError("L_p must be negative")
492
+ return -1.0 / l_p
493
+
494
+
495
+ def spiral_stability_parameter(l_beta, l_r, n_beta, n_r):
496
+ """Spiral criterion term L_beta*N_r - L_r*N_beta (>0 = convergent)."""
497
+ return l_beta * n_r - l_r * n_beta
498
+
499
+
500
+ def spiral_eigenvalue(l_beta, l_r, n_beta, n_r, l_p, v, g=G0):
501
+ """Approximate spiral root lambda_s = (g/V)*(Lb*Nr - Lr*Nb)/(Nb*Lp)."""
502
+ if n_beta <= 0 or l_p >= 0 or v <= 0:
503
+ raise ValueError("spiral inputs invalid")
504
+ return (g / v) * spiral_stability_parameter(l_beta, l_r, n_beta, n_r) / \
505
+ (n_beta * l_p)
506
+
507
+
508
+ def trim_lift_coefficient(weight_n, rho, v, s):
509
+ """CL_trim = 2W/(rho V^2 S) for level flight."""
510
+ if min(weight_n, rho, v, s) <= 0:
511
+ raise ValueError("trim inputs must be positive")
512
+ return 2.0 * weight_n / (rho * v * v * s)
513
+
514
+
515
+ def elevator_to_trim(cm0, cm_alpha, cl_alpha, cl_trim, cm_delta_e):
516
+ """de_trim = -(Cm0 + Cm_alpha*CL_trim/CL_alpha)/Cm_delta_e, rad."""
517
+ if cl_alpha <= 0 or cm_delta_e == 0:
518
+ raise ValueError("elevator trim inputs invalid")
519
+ alpha_trim = cl_trim / cl_alpha
520
+ return -(cm0 + cm_alpha * alpha_trim) / cm_delta_e
521
+
522
+
523
+ # ---------------------------------------------------------------------------
524
+ # Flying qualities reference tables (summary of the MIL-STD-1797A mode
525
+ # criteria as encoded by the bound mil-std-1797a and short-period-mode leaves:
526
+ # reference-only paraphrase, civil use by analogy with FAR 25.181 context)
527
+ # ---------------------------------------------------------------------------
528
+
529
+ # Short period Level 1 damping band by flight phase category (summary):
530
+ # category A 0.35-1.30; categories B and C 0.30-2.00.
531
+ SP_DAMPING_L1 = {"A": (0.35, 1.30), "B": (0.30, 2.00), "C": (0.30, 2.00)}
532
+ SP_DAMPING_L2 = {"A": (0.25, 2.00), "B": (0.25, 2.00), "C": (0.25, 2.00)}
533
+ SP_DAMPING_L3_MIN = 0.15
534
+
535
+ # Short period minimum natural frequency (rad/s) at n_alpha = 1.0 and 3.0
536
+ # g/rad by (category, class); linear interpolation, clamped outside.
537
+ SP_FREQ_N1 = {"A": {"I": 3.6, "II": 3.6, "III": 3.0, "IV": 2.5},
538
+ "B": {"I": 1.0, "II": 1.0, "III": 1.0, "IV": 1.0},
539
+ "C": {"I": 1.0, "II": 1.0, "III": 1.0, "IV": 1.0}}
540
+ SP_FREQ_N3 = {"A": {"I": 6.0, "II": 6.0, "III": 6.0, "IV": 6.0},
541
+ "B": {"I": 2.0, "II": 2.0, "III": 2.0, "IV": 2.0},
542
+ "C": {"I": 2.0, "II": 2.0, "III": 2.0, "IV": 2.0}}
543
+
544
+ # Phugoid: min damping ratio per level (Level 3 expressed as time to
545
+ # double >= 55 s for a divergence).
546
+ PHUGOID_DAMPING_MIN = {1: 0.04, 2: 0.0}
547
+
548
+ # Dutch roll (zeta_min, omega_min rad/s, zeta*omega_min) level 1 by
549
+ # (category, class); category A class IV is the strictest.
550
+ DR_L1 = {"A": {"IV": (0.19, 1.0, 0.35), "I": (0.19, 0.4, 0.35),
551
+ "II": (0.19, 0.4, 0.35), "III": (0.19, 0.4, 0.35)},
552
+ "B": {"IV": (0.08, 0.4, 0.15), "I": (0.08, 0.4, 0.15),
553
+ "II": (0.08, 0.4, 0.15), "III": (0.08, 0.4, 0.15)},
554
+ "C": {"IV": (0.08, 0.4, 0.15), "I": (0.08, 0.4, 0.15),
555
+ "II": (0.08, 0.4, 0.15), "III": (0.08, 0.4, 0.15)}}
556
+ DR_L2 = (0.02, 0.4, 0.05)
557
+
558
+ # Spiral min time to double (s) per level and category.
559
+ SPIRAL_T2 = {1: {"A": 20.0, "B": 12.0, "C": 20.0},
560
+ 2: {"A": 8.0, "B": 8.0, "C": 8.0},
561
+ 3: {"A": 4.0, "B": 4.0, "C": 4.0}}
562
+
563
+ # Roll mode max time constant (s) per level and category.
564
+ ROLL_TAU_MAX = {1: {"A": 1.0, "B": 1.4, "C": 1.0},
565
+ 2: {"A": 1.4, "B": 3.0, "C": 1.4},
566
+ 3: {"A": 10.0, "B": 10.0, "C": 10.0}}
567
+
568
+ CATEGORIES = ("A", "B", "C")
569
+ CLASSES = ("I", "II", "III", "IV")
570
+
571
+
572
+ def sp_min_frequency(category, aircraft_class, n_over_alpha_val):
573
+ """Short period frequency floor interpolated in n_alpha (g/rad),
574
+ clamped to the [1, 3] g/rad table band (mil-std-1797a leaf)."""
575
+ if category not in CATEGORIES or aircraft_class not in CLASSES:
576
+ raise ValueError("invalid category/class")
577
+ if n_over_alpha_val <= 0:
578
+ raise ValueError("n_alpha must be positive")
579
+ n = min(max(n_over_alpha_val, 1.0), 3.0)
580
+ f1 = SP_FREQ_N1[category][aircraft_class]
581
+ f3 = SP_FREQ_N3[category][aircraft_class]
582
+ t = (n - 1.0) / 2.0
583
+ return f1 + t * (f3 - f1)
584
+
585
+
586
+ def assess_short_period(zeta, omega, category, aircraft_class, n_alpha):
587
+ """Short period flying qualities level (1/2/3) by the summary bands.
588
+
589
+ Level 1 needs damping in the category band and frequency at/above the
590
+ n_alpha-interpolated floor; a Level 1 damping with a frequency below
591
+ the floor is degraded to Level 2; damping below the Level 2 band but
592
+ above the Level 3 minimum reads Level 3.
593
+ """
594
+ min_omega = sp_min_frequency(category, aircraft_class, n_alpha)
595
+ z1min, z1max = SP_DAMPING_L1[category]
596
+ z2min, z2max = SP_DAMPING_L2[category]
597
+ if z1min <= zeta <= z1max:
598
+ level = 1
599
+ elif z2min <= zeta <= z2max:
600
+ level = 2
601
+ else:
602
+ level = 3
603
+ if level == 1 and omega < min_omega:
604
+ level = 2
605
+ return {"level": level, "min_omega": min_omega, "zeta": zeta,
606
+ "omega": omega, "n_alpha": n_alpha}
607
+
608
+
609
+ def assess_phugoid(zeta):
610
+ """Phugoid level: 1 when zeta >= 0.04, 2 when 0 <= zeta < 0.04,
611
+ 3 for divergent (zeta < 0)."""
612
+ if zeta >= PHUGOID_DAMPING_MIN[1]:
613
+ return 1
614
+ if zeta >= PHUGOID_DAMPING_MIN[2]:
615
+ return 2
616
+ return 3
617
+
618
+
619
+ def assess_dutch_roll(zeta, omega, category, aircraft_class):
620
+ """Dutch roll level by the summary criteria."""
621
+ if zeta <= 0.0:
622
+ return 3
623
+ z1, w1, zw1 = DR_L1[category][aircraft_class]
624
+ if zeta >= z1 and omega >= w1 and zeta * omega >= zw1:
625
+ return 1
626
+ z2, w2, zw2 = DR_L2
627
+ if zeta >= z2 and omega >= w2 and zeta * omega >= zw2:
628
+ return 2
629
+ return 3
630
+
631
+
632
+ def assess_spiral(lam, category):
633
+ """Spiral level: stable root -> 1; else by time-to-double floors."""
634
+ if lam <= 0.0:
635
+ return 1
636
+ t2 = math.log(2.0) / lam
637
+ for level in (1, 2, 3):
638
+ if t2 >= SPIRAL_T2[level][category]:
639
+ return level
640
+ return 3
641
+
642
+
643
+ def assess_roll_mode(tau, category):
644
+ """Roll mode level by the max time-constant floors."""
645
+ for level in (1, 2, 3):
646
+ if tau <= ROLL_TAU_MAX[level][category]:
647
+ return level
648
+ return 3
649
+
650
+
651
+ # ---------------------------------------------------------------------------
652
+ # FAR/CS minimum climb gradients (oei-climb-gradient leaf, FAR 25.121)
653
+ # ---------------------------------------------------------------------------
654
+
655
+ FAR_25_121_SECOND_SEGMENT = {2: 2.4, 3: 2.7, 4: 3.0} # percent
656
+ FAR_25_121_APPROACH = {2: 2.1, 3: 2.4, 4: 2.7} # percent
657
+ FAR_25_121_LANDING = 3.2 # percent
658
+
659
+
660
+ def far_25_121_minimum(segment, n_engines):
661
+ if segment == "second-segment":
662
+ return FAR_25_121_SECOND_SEGMENT[n_engines]
663
+ if segment == "approach":
664
+ return FAR_25_121_APPROACH[n_engines]
665
+ if segment == "landing":
666
+ return FAR_25_121_LANDING
667
+ raise ValueError("segment must be second-segment/approach/landing")
668
+
669
+
670
+ # ---------------------------------------------------------------------------
671
+ # Example configuration: a generic 150-seat-class twin-engine transport.
672
+ # ALL numbers are EXAMPLE DATA in the public class range (stated basis, to be
673
+ # replaced by project data); the report marks them as such. Nothing here
674
+ # claims to describe a specific certified airplane.
675
+ # ---------------------------------------------------------------------------
676
+
677
+ @dataclass
678
+ class ExampleTransport:
679
+ name: str = "AeroLine AT-78"
680
+ description: str = "Example twin-engine transport, 150-seat class (MTOW 78 t); " \
681
+ "all data are example values in the public class range - not a specific " \
682
+ "certified airplane."
683
+ # --- geometry / inertia (example data) ---
684
+ s: float = 125.0 # wing area m^2
685
+ b: float = 35.8 # span m
686
+ cbar: float = 3.4916 # mean aerodynamic chord m (= S/b)
687
+ i_yy: float = 3.4e6 # pitch inertia kg m^2
688
+ i_xx: float = 2.0e6 # roll inertia kg m^2
689
+ i_zz: float = 6.5e6 # yaw inertia kg m^2
690
+ # --- masses (example data) ---
691
+ m_mtow: float = 78000.0 # MTOW kg
692
+ m_mlw: float = 62000.0 # MLW kg
693
+ # --- propulsion (example data) ---
694
+ n_engines: int = 2
695
+ thrust_to_each: float = 110000.0 # sea-level static per engine (takeoff rating), N
696
+ thrust_climb_sl_total: float = 150000.0 # total max-climb rating sea level, N
697
+ thrust_lapse_exp: float = 0.7 # sigma^0.7 density-based thrust lapse (high-bypass)
698
+ tsfc: float = 1.55e-5 # cruise TSFC kg/(N s), ~0.55 lb/(lbf hr) class
699
+ # --- aerodynamics (parabolic polars per configuration; example data) ---
700
+ cd0_clean: float = 0.021
701
+ oswald_clean: float = 0.80
702
+ cd0_to: float = 0.032 # takeoff flaps, gear up
703
+ osward_to: float = 0.60
704
+ cd0_land: float = 0.050 # landing flaps + gear down
705
+ oswald_land: float = 0.55
706
+ cl_alpha_clean: float = 5.5 # airplane lift slope 1/rad (cruise, incl Mach)
707
+ cl_alpha_land: float = 6.2 # airplane lift slope 1/rad (landing config)
708
+ cl_max_to: float = 1.9 # takeoff configuration
709
+ cl_max_land: float = 2.4 # landing configuration
710
+ # --- tail geometry (example data) ---
711
+ s_h: float = 26.5 # horizontal tail area m^2
712
+ l_h: float = 14.2 # tail arm (wing AC -> HTP AC) m
713
+ s_vt: float = 12.4 # vertical tail area m^2
714
+ l_vt: float = 18.5 # fin arm m
715
+ a_over_a_ratio: float = 0.72 # a_t/a_w for the neutral point build
716
+ downwash_grad: float = 0.45 # depsilon/dalpha
717
+ h_ac_w: float = 0.25 # wing aerodynamic centre, fraction MAC
718
+ eta_vt: float = 0.95
719
+ a_vt: float = 3.4 # fin lift slope 1/rad
720
+ sidewash_grad: float = 0.08
721
+ cn_beta_fuselage: float = -0.0589 # destabilizing body term (class data)
722
+ cl_beta_remainder: float = -0.025 # sweep/body remainder to Cl_beta total
723
+ dihedral_deg: float = 5.2
724
+ c_y_beta: float = -0.65 # side-force slope (class data)
725
+ c_y_r: float = 0.25
726
+ c_n_r: float = -0.25
727
+ c_l_r: float = 0.13
728
+ c_l_p: float = -0.42
729
+ c_m_q: float = -30.0 # pitch damping coefficient (tail + wing-body)
730
+ c_m_0: float = 0.08 # zero-lift pitching moment (class data)
731
+ c_m_delta_e: float = -0.90 # elevator effectiveness 1/rad
732
+ elevator_limit_deg: float = 25.0
733
+ # --- CG envelope (example data, fraction MAC) ---
734
+ h_cg_fwd: float = 0.32
735
+ h_cg_aft: float = 0.44
736
+ h_cg_mid: float = 0.38
737
+ # --- mission / conditions (example data) ---
738
+ cruise_alt_m: float = 10668.0 # FL350
739
+ cruise_mach: float = 0.78
740
+ climb_speed_ms: float = 128.6 # 250 KCAS at sea level, m/s
741
+ loiter_start_kg: float = 62000.0
742
+ loiter_end_kg: float = 59000.0
743
+ landing_approach_deg: float = 3.0
744
+ landing_flare_n: float = 1.2
745
+ landing_mu: float = 0.45 # dry runway braking coefficient
746
+ to_mu: float = 0.03 # rolling friction, dry concrete
747
+ obstacle_50ft_m: float = 15.24 # FAR 25.125 obstacle height
748
+ landing_dist_factor: float = 1.67 # FAR 25.125 certified-distance factor
749
+ turn_bank_deg: float = 25.0
750
+ wind_kt: float = 40.0 # enroute headwind for the wind case
751
+
752
+ # derived
753
+ @property
754
+ def ar(self):
755
+ return self.b * self.b / self.s
756
+
757
+ @property
758
+ def k_clean(self):
759
+ return 1.0 / (math.pi * self.oswald_clean * self.ar)
760
+
761
+ @property
762
+ def k_to(self):
763
+ return 1.0 / (math.pi * self.osward_to * self.ar)
764
+
765
+ @property
766
+ def k_land(self):
767
+ return 1.0 / (math.pi * self.oswald_land * self.ar)
768
+
769
+ @property
770
+ def w_mtow(self):
771
+ return self.m_mtow * G0
772
+
773
+ @property
774
+ def w_mlw(self):
775
+ return self.m_mlw * G0
776
+
777
+ @property
778
+ def thrust_total_to(self):
779
+ return self.n_engines * self.thrust_to_each
780
+
781
+ @property
782
+ def vh_tail(self):
783
+ return tail_volume_horizontal(self.s_h, self.l_h, self.s, self.cbar)
784
+
785
+ @property
786
+ def v_vtail(self):
787
+ return vertical_tail_volume(self.s_vt, self.l_vt, self.s, self.b)
788
+
789
+
790
+ def example_vehicle() -> ExampleTransport:
791
+ return ExampleTransport()
792
+
793
+
794
+ # ---------------------------------------------------------------------------
795
+ # Analysis: compute every number of the deliverable from the configuration
796
+ # ---------------------------------------------------------------------------
797
+
798
+ def analyze_vehicle(a: ExampleTransport) -> dict:
799
+ """Run the full performance + S&C analysis. Returns a flat dict of
800
+ results consumed by the report builder."""
801
+ r = {}
802
+ r["aircraft"] = a
803
+
804
+ # --- atmosphere at the analysis altitudes ---
805
+ r["isa_sl"] = isa(0.0)
806
+ r["isa_cruise"] = isa(a.cruise_alt_m)
807
+
808
+ # cruise true airspeed at the cruise Mach number
809
+ v_cruise = a.cruise_mach * r["isa_cruise"]["a"]
810
+ r["v_cruise"] = v_cruise
811
+ qbar_cruise = 0.5 * r["isa_cruise"]["rho"] * v_cruise * v_cruise
812
+ r["qbar_cruise"] = qbar_cruise
813
+
814
+ # --- 1. Mission performance ---
815
+ # cruise point from the polar
816
+ cl_cruise = a.w_mtow / (qbar_cruise * a.s)
817
+ cd_cruise = drag_coefficient(a.cd0_clean, a.k_clean, cl_cruise)
818
+ ld_cruise = cl_cruise / cd_cruise
819
+ r.update(cl_cruise=cl_cruise, cd_cruise=cd_cruise, ld_cruise=ld_cruise,
820
+ d_cruise=a.w_mtow / ld_cruise)
821
+ r["ld_max"] = max_lift_to_drag(a.cd0_clean, a.k_clean)
822
+
823
+ # Breguet range: cruise segment MTOW start, 17,160 kg cruise fuel burned
824
+ m_cruise_start = a.m_mtow
825
+ m_cruise_end = a.m_mtow - 17160.0
826
+ r["range_m"] = breguet_range(v_cruise, a.tsfc, ld_cruise,
827
+ m_cruise_start, m_cruise_end)
828
+ r["range_km"] = r["range_m"] / 1000.0
829
+ r["cruise_fuel_kg"] = m_cruise_start - m_cruise_end
830
+
831
+ # loiter endurance (Breguet jet endurance, weights form)
832
+ r["endurance_s"] = jet_endurance(a.tsfc, ld_cruise,
833
+ a.loiter_start_kg * G0,
834
+ a.loiter_end_kg * G0)
835
+ r["endurance_hr"] = r["endurance_s"] / 3600.0
836
+
837
+ # specific air range at cruise + a small speed sweep for the "curve"
838
+ sar = specific_air_range(v_cruise, a.tsfc, a.w_mtow, ld_cruise)
839
+ r["sar_m_per_kg"] = sar
840
+ sar_pts = []
841
+ for frac in (0.95, 1.00, 1.05):
842
+ vv = v_cruise * frac
843
+ cll = a.w_mtow / (0.5 * r["isa_cruise"]["rho"] * vv * vv * a.s)
844
+ ldl = lift_to_drag(a.cd0_clean, a.k_clean, cll)
845
+ sar_pts.append({"frac": frac, "v": vv, "ld": ldl,
846
+ "sar": specific_air_range(vv, a.tsfc, a.w_mtow, ldl)})
847
+ r["sar_curve"] = sar_pts
848
+ r["sector_fuel_5000km"] = 5.0e6 / sar
849
+
850
+ # climb: sea level and FL350 at MTOW, clean config
851
+ qbar_sl_climb = 0.5 * r["isa_sl"]["rho"] * a.climb_speed_ms ** 2
852
+ cl_climb_sl = a.w_mtow / (qbar_sl_climb * a.s)
853
+ cd_climb_sl = drag_coefficient(a.cd0_clean, a.k_clean, cl_climb_sl)
854
+ d_climb_sl = cd_climb_sl * qbar_sl_climb * a.s
855
+ r["roc_sl"] = rate_of_climb(a.thrust_climb_sl_total, d_climb_sl,
856
+ a.climb_speed_ms, a.w_mtow)
857
+ # climb thrust at FL350: density-based lapse sigma^0.7
858
+ t_climb_350 = a.thrust_climb_sl_total * r["isa_cruise"]["sigma"] ** \
859
+ a.thrust_lapse_exp
860
+ # climb speed at FL350 on the 250 KCAS / M0.78 schedule
861
+ v_climb_350 = min(v_cruise, a.climb_speed_ms /
862
+ math.sqrt(r["isa_cruise"]["sigma"]))
863
+ qbar_350 = 0.5 * r["isa_cruise"]["rho"] * v_climb_350 ** 2
864
+ cl_350 = a.w_mtow / (qbar_350 * a.s)
865
+ cd_350 = drag_coefficient(a.cd0_clean, a.k_clean, cl_350)
866
+ d_350 = cd_350 * qbar_350 * a.s
867
+ r["roc_350"] = rate_of_climb(t_climb_350, d_350, v_climb_350, a.w_mtow)
868
+ r["time_to_350_s"] = time_to_climb(a.cruise_alt_m, r["roc_sl"],
869
+ r["roc_350"])
870
+ # ROC at FL410 (12,497 m) for the ceiling interpolation
871
+ isa_410 = isa(12497.0)
872
+ t_climb_410 = a.thrust_climb_sl_total * isa_410["sigma"] ** \
873
+ a.thrust_lapse_exp
874
+ v_410 = min(v_cruise, a.climb_speed_ms / math.sqrt(isa_410["sigma"]))
875
+ qbar_410 = 0.5 * isa_410["rho"] * v_410 ** 2
876
+ cl_410 = a.w_mtow / (qbar_410 * a.s)
877
+ cd_410 = drag_coefficient(a.cd0_clean, a.k_clean, cl_410)
878
+ d_410 = cd_410 * qbar_410 * a.s
879
+ r["roc_410"] = rate_of_climb(t_climb_410, d_410, v_410, a.w_mtow)
880
+ # linear-lapse ceiling estimate between FL350 and FL410 (indicative)
881
+ lapse = (r["roc_350"] - r["roc_410"]) / (12497.0 - a.cruise_alt_m)
882
+ r["ceiling_est_m"] = a.cruise_alt_m + (r["roc_350"] - 0.5) / lapse
883
+
884
+ # takeoff: stall speed, V_LOF/V2 = 1.2 Vs, ground roll at MTOW sea level
885
+ ws_mtow = a.w_mtow / a.s
886
+ vs_to = stall_speed(ws_mtow, r["isa_sl"]["rho"], a.cl_max_to)
887
+ v2 = 1.2 * vs_to
888
+ sg = takeoff_ground_roll(a.w_mtow, a.s, a.thrust_total_to,
889
+ r["isa_sl"]["rho"], a.cl_max_to, mu=a.to_mu)
890
+ r.update(vs_to=vs_to, v2=v2, to_ground_roll=sg)
891
+
892
+ # landing: MLW, sea level
893
+ ws_mlw = a.w_mlw / a.s
894
+ vs_land = stall_speed(ws_mlw, r["isa_sl"]["rho"], a.cl_max_land)
895
+ v_ref = 1.3 * vs_land
896
+ v_td = 0.95 * v_ref
897
+ air_dist = landing_air_distance(v_ref, a.landing_approach_deg,
898
+ a.obstacle_50ft_m, a.landing_flare_n)
899
+ # drag ratio during the ground roll in the landing configuration
900
+ cl_td = a.w_mlw / (0.5 * r["isa_sl"]["rho"] * v_td ** 2 * a.s)
901
+ cd_td = drag_coefficient(a.cd0_land, a.k_land, cl_td)
902
+ decel_g = average_deceleration_g(a.landing_mu, 0.15, cd_td / cl_td)
903
+ s_gr = landing_ground_roll(v_td, decel_g)
904
+ actual_land = air_dist + s_gr
905
+ r.update(vs_land=vs_land, v_ref=v_ref, v_td=v_td, air_dist=air_dist,
906
+ land_ground_roll=s_gr, land_actual=actual_land,
907
+ land_certified=actual_land * a.landing_dist_factor,
908
+ ld_landing=cl_td / cd_td)
909
+
910
+ # glide (best glide, clean polar, from cruise altitude at MTOW)
911
+ ld_max = r["ld_max"]
912
+ cl_bg = ld_max["cl_star"]
913
+ v_bg_eas = math.sqrt(2.0 * a.w_mtow / (r["isa_sl"]["rho"] * a.s * cl_bg))
914
+ sink = glide_sink_rate(v_bg_eas, ld_max["ld_max"])
915
+ r.update(best_glide_v=v_bg_eas, best_glide_sink=sink,
916
+ glide_range_m=ld_max["ld_max"] * a.cruise_alt_m,
917
+ glide_time_s=a.cruise_alt_m / sink)
918
+
919
+ # turn at 25 deg bank, cruise speed, MTOW
920
+ turn = level_turn(a.turn_bank_deg, v_cruise)
921
+ d_turn = r["d_cruise"] * turn["n"]
922
+ # cruise-rating thrust available at FL350 (assumed 58 kN class)
923
+ t_cruise_avail = 58000.0
924
+ turn["sustained"] = t_cruise_avail >= d_turn
925
+ turn["d_turn"] = d_turn
926
+ turn["t_avail"] = t_cruise_avail
927
+ r["turn"] = turn
928
+
929
+ # wind: 40 kt headwind on the 5,000 km sector
930
+ wind_ms = a.wind_kt / MPS_TO_KT
931
+ gs_still = v_cruise
932
+ gs_hw = v_cruise - wind_ms
933
+ r["wind"] = {"wind_kt": a.wind_kt, "gs_still": gs_still,
934
+ "gs_hw": gs_hw, "time_still_h": 5.0e6 / gs_still / 3600.0,
935
+ "time_hw_h": 5.0e6 / gs_hw / 3600.0}
936
+
937
+ # --- 2. Safety performance ---
938
+ # OEI second segment: V2, takeoff config gear up, MTOW, one engine failed
939
+ qbar_v2 = 0.5 * r["isa_sl"]["rho"] * v2 * v2
940
+ cl_2nd = a.w_mtow / (qbar_v2 * a.s)
941
+ cd_2nd = drag_coefficient(a.cd0_to, a.k_to, cl_2nd)
942
+ d_2nd = cd_2nd * qbar_v2 * a.s
943
+ t_oei = oei_thrust(a.thrust_total_to, a.n_engines, 1)
944
+ grad_2nd = oei_gradient_pct(t_oei, d_2nd, a.w_mtow)
945
+ r["oei"] = {"t_oei": t_oei, "d_2nd": d_2nd, "ld_2nd": cl_2nd / cd_2nd,
946
+ "gradient_pct": grad_2nd,
947
+ "min_pct": far_25_121_minimum("second-segment", a.n_engines),
948
+ "grad_deg": math.degrees(math.atan(grad_2nd / 100.0))}
949
+ # energy height at cruise
950
+ r["energy"] = {"h_kin": v_cruise * v_cruise / (2.0 * G0),
951
+ "h_e": energy_height(a.cruise_alt_m, v_cruise)}
952
+ # windshear: needs a windshear profile (leaf) - reported as open item
953
+
954
+ # --- 4/5/6. Stability, dynamic modes ---
955
+ aero = {}
956
+ h_np = neutral_point(a.h_ac_w, a.vh_tail, a.a_over_a_ratio,
957
+ a.downwash_grad)
958
+ aero["h_np"] = h_np
959
+ aero["vh_tail"] = a.vh_tail
960
+ aero["sm_fwd"] = static_margin(h_np, a.h_cg_fwd)
961
+ aero["sm_aft"] = static_margin(h_np, a.h_cg_aft)
962
+ aero["sm_mid"] = static_margin(h_np, a.h_cg_mid)
963
+ aero["cn_beta_vt"] = cn_beta_vertical_tail(a.eta_vt, a.v_vtail, a.a_vt,
964
+ a.sidewash_grad)
965
+ aero["cn_beta"] = aero["cn_beta_vt"] + a.cn_beta_fuselage
966
+ aero["cl_beta_dihedral"] = cl_beta_dihedral(cl_cruise, a.dihedral_deg)
967
+ aero["cl_beta"] = aero["cl_beta_dihedral"] + a.cl_beta_remainder
968
+ aero["v_vtail"] = a.v_vtail
969
+ r["aero"] = aero
970
+
971
+ # longitudinal mode analysis per condition (cruise/approach) per CG
972
+ def longitudinal_condition(cond_name, h, mass_kg, v, rho, cl_alpha):
973
+ qbar = 0.5 * rho * v * v
974
+ w = mass_kg * G0
975
+ sm = static_margin(h_np, h)
976
+ cm_alpha = c_m_alpha_from_sm(cl_alpha, sm)
977
+ za = z_alpha_derivative(qbar, a.s, cl_alpha, mass_kg)
978
+ ma = m_alpha_derivative(qbar, a.s, a.cbar, cm_alpha, a.i_yy)
979
+ mq = m_q_derivative(qbar, a.s, a.cbar, a.c_m_q, v, a.i_yy)
980
+ om = short_period_frequency(za, ma, mq, v)
981
+ zeta = short_period_damping(za, ma, mq, v)
982
+ nal = n_over_alpha(qbar, a.s, cl_alpha, w)
983
+ return {"cond": cond_name, "h_cg": h, "sm": sm, "cm_alpha": cm_alpha,
984
+ "za": za, "ma": ma, "mq": mq, "omega_sp": om, "zeta_sp": zeta,
985
+ "n_alpha": nal, "qbar": qbar, "cl": w / (qbar * a.s)}
986
+
987
+ cruise_atm = r["isa_cruise"]
988
+ r["sp_cruise_aft"] = longitudinal_condition(
989
+ "cruise FL350", a.h_cg_aft, a.m_mtow, v_cruise, cruise_atm["rho"],
990
+ a.cl_alpha_clean)
991
+ r["sp_cruise_fwd"] = longitudinal_condition(
992
+ "cruise FL350", a.h_cg_fwd, a.m_mtow, v_cruise, cruise_atm["rho"],
993
+ a.cl_alpha_clean)
994
+ r["sp_appr_aft"] = longitudinal_condition(
995
+ "approach", a.h_cg_aft, a.m_mlw, v_ref, r["isa_sl"]["rho"],
996
+ a.cl_alpha_land)
997
+ r["sp_appr_fwd"] = longitudinal_condition(
998
+ "approach", a.h_cg_fwd, a.m_mlw, v_ref, r["isa_sl"]["rho"],
999
+ a.cl_alpha_land)
1000
+
1001
+ # phugoid at cruise and approach (Lanchester)
1002
+ def phugoid(v, ld):
1003
+ return {"omega": phugoid_frequency(v), "period": phugoid_period(v),
1004
+ "zeta": phugoid_damping(ld), "t_half": phugoid_time_to_half(v, ld)}
1005
+ r["phugoid_cruise"] = phugoid(v_cruise, ld_cruise)
1006
+ r["phugoid_appr"] = phugoid(v_ref, r["ld_landing"])
1007
+ # separation: omega_sp / omega_phugoid (needs to be large)
1008
+ r["sp_phugoid_sep_cruise"] = (r["sp_cruise_aft"]["omega_sp"] /
1009
+ r["phugoid_cruise"]["omega"])
1010
+ r["sp_phugoid_sep_appr"] = (r["sp_appr_aft"]["omega_sp"] /
1011
+ r["phugoid_appr"]["omega"])
1012
+
1013
+ # lateral-directional derivatives per condition + modes
1014
+ def lateral(cond_name, mass_kg, v, rho, ld_cond):
1015
+ qbar = 0.5 * rho * v * v
1016
+ w = mass_kg * G0
1017
+ cl_cond = w / (qbar * a.s)
1018
+ yb = y_beta_derivative(qbar, a.s, a.c_y_beta, mass_kg)
1019
+ yr = y_r_derivative(qbar, a.s, a.b, a.c_y_r, v, mass_kg)
1020
+ nb = n_beta_derivative(qbar, a.s, a.b, aero["cn_beta"], a.i_zz)
1021
+ nr = n_r_derivative(qbar, a.s, a.b, a.c_n_r, v, a.i_zz)
1022
+ lb = l_beta_derivative(qbar, a.s, a.b, aero["cl_beta"], a.i_xx)
1023
+ lr = l_r_derivative(qbar, a.s, a.b, a.c_l_r, v, a.i_xx)
1024
+ lp = l_p_derivative(qbar, a.s, a.b, a.c_l_p, v, a.i_xx)
1025
+ om_dr = dutch_roll_frequency(nb, nr, yb, yr, v)
1026
+ zeta_dr = dutch_roll_damping(nb, nr, yb, yr, v)
1027
+ tau_roll = roll_time_constant(lp)
1028
+ lam_spiral = spiral_eigenvalue(lb, lr, nb, nr, lp, v)
1029
+ return {"cond": cond_name, "mass": mass_kg, "v": v, "cl": cl_cond,
1030
+ "yb": yb, "yr": yr, "nb": nb, "nr": nr, "lb": lb, "lr": lr,
1031
+ "lp": lp, "omega_dr": om_dr, "zeta_dr": zeta_dr,
1032
+ "tau_roll": tau_roll, "lambda_spiral": lam_spiral,
1033
+ "t_half_spiral": (None if lam_spiral >= 0
1034
+ else math.log(2.0) / -lam_spiral)}
1035
+ r["lat_cruise"] = lateral("cruise FL350", a.m_mtow, v_cruise,
1036
+ cruise_atm["rho"], ld_cruise)
1037
+ r["lat_appr"] = lateral("approach", a.m_mlw, v_ref, r["isa_sl"]["rho"],
1038
+ r["ld_landing"])
1039
+
1040
+ # --- trim ---
1041
+ def trim_case(cond_name, rho, v, weight_n, cl_alpha, h_cg):
1042
+ cl_trim = trim_lift_coefficient(weight_n, rho, v, a.s)
1043
+ sm = static_margin(h_np, h_cg)
1044
+ cm_alpha = c_m_alpha_from_sm(cl_alpha, sm)
1045
+ de = elevator_to_trim(a.c_m_0, cm_alpha, cl_alpha, cl_trim,
1046
+ a.c_m_delta_e)
1047
+ return {"cond": cond_name, "cl_trim": cl_trim, "alpha_rad":
1048
+ cl_trim / cl_alpha, "de_rad": de,
1049
+ "de_deg": math.degrees(de), "authority_deg": a.elevator_limit_deg}
1050
+ r["trim_cruise"] = trim_case("cruise", cruise_atm["rho"], v_cruise,
1051
+ a.w_mtow, a.cl_alpha_clean, a.h_cg_mid)
1052
+ r["trim_appr"] = trim_case("approach", r["isa_sl"]["rho"], v_ref,
1053
+ a.w_mlw, a.cl_alpha_land, a.h_cg_mid)
1054
+
1055
+ # mode flying-qualities levels (MIL-STD-1797A summary, class III)
1056
+ # cruise is category B (gradual maneuvers); approach category C (terminal)
1057
+ c = "III"
1058
+ sp_a = r["sp_cruise_aft"]
1059
+ r["sp_level_cruise_aft"] = assess_short_period(
1060
+ sp_a["zeta_sp"], sp_a["omega_sp"], "B", c, sp_a["n_alpha"])
1061
+ sp_f = r["sp_cruise_fwd"]
1062
+ r["sp_level_cruise_fwd"] = assess_short_period(
1063
+ sp_f["zeta_sp"], sp_f["omega_sp"], "B", c, sp_f["n_alpha"])
1064
+ sp_a2 = r["sp_appr_aft"]
1065
+ r["sp_level_appr_aft"] = assess_short_period(
1066
+ sp_a2["zeta_sp"], sp_a2["omega_sp"], "C", c, sp_a2["n_alpha"])
1067
+ sp_f2 = r["sp_appr_fwd"]
1068
+ r["sp_level_appr_fwd"] = assess_short_period(
1069
+ sp_f2["zeta_sp"], sp_f2["omega_sp"], "C", c, sp_f2["n_alpha"])
1070
+ r["phugoid_level_cruise"] = assess_phugoid(r["phugoid_cruise"]["zeta"])
1071
+ r["phugoid_level_appr"] = assess_phugoid(r["phugoid_appr"]["zeta"])
1072
+ drc = r["lat_cruise"]
1073
+ dra = r["lat_appr"]
1074
+ r["dr_level_cruise"] = assess_dutch_roll(drc["zeta_dr"], drc["omega_dr"],
1075
+ "B", c)
1076
+ r["dr_level_appr"] = assess_dutch_roll(dra["zeta_dr"], dra["omega_dr"],
1077
+ "C", c)
1078
+ r["roll_level_cruise"] = assess_roll_mode(drc["tau_roll"], "B")
1079
+ r["roll_level_appr"] = assess_roll_mode(dra["tau_roll"], "C")
1080
+ r["spiral_level_cruise"] = assess_spiral(drc["lambda_spiral"], "B")
1081
+ r["spiral_level_appr"] = assess_spiral(dra["lambda_spiral"], "C")
1082
+
1083
+ # secondary quantities for evidence
1084
+ r["wing_loading_mtow"] = a.w_mtow / a.s
1085
+ r["wing_loading_mlw"] = a.w_mlw / a.s
1086
+ r["ar"] = a.ar
1087
+ r["t_w"] = a.thrust_total_to / a.w_mtow
1088
+ return r
1089
+
1090
+
1091
+ # ---------------------------------------------------------------------------
1092
+ # Report builder: content model from the analysis results
1093
+ # ---------------------------------------------------------------------------
1094
+
1095
+ def build_report(vehicle: ExampleTransport | None = None,
1096
+ results: dict | None = None) -> dict:
1097
+ """Build the complete Performance + S&C report content model."""
1098
+ a = vehicle or example_vehicle()
1099
+ r = results or analyze_vehicle(a)
1100
+ today = _today()
1101
+ return {"document_type": "Performance and S&C Analysis Report",
1102
+ "status": "draft-for-review",
1103
+ "aircraft": a.name,
1104
+ "aircraft_description": a.description,
1105
+ "category": "B and C (cruise and approach)",
1106
+ "aircraft_class": "III (large transport, MIL-STD-1797A classing)",
1107
+ "regulatory_context": ("FAR/CS 25.121, 25.125, 25.181 context; "
1108
+ "MIL-STD-1797A mode tables by analogy "
1109
+ "(summary, not reproduced)"),
1110
+ "results": r,
1111
+ "generated": today}
1112
+
1113
+
1114
+ # ---------------------------------------------------------------------------
1115
+ # Markdown renderer
1116
+ # ---------------------------------------------------------------------------
1117
+
1118
+ def render_report_markdown(model: dict) -> str:
1119
+ """Render the report content model as the deliverable markdown."""
1120
+ a = model["results"]["aircraft"]
1121
+ r = model["results"]
1122
+ lines = []
1123
+ ap = lines.append
1124
+
1125
+ # unit conversions used below
1126
+ def nm_(km_):
1127
+ return nm(km_)
1128
+
1129
+ ap("# Performance and S&C Analysis Report")
1130
+ ap("")
1131
+ ap(f"**Aircraft:** {model['aircraft']} "
1132
+ f"(example transport configuration, 150-seat class)")
1133
+ ap(f"**Assessment basis:** FAR/CS Part 25 performance context "
1134
+ f"(25.121/25.125/25.181) and MIL-STD-1797A mode criteria by analogy "
1135
+ f"(aircraft class {model['aircraft_class']}; flight phase categories "
1136
+ f"{model['category']}).")
1137
+ ap(f"**Status:** {model['status']}")
1138
+ ap("")
1139
+ ap("## 1. Mission performance")
1140
+ ap("")
1141
+ ap(f"**Cruise point:** FL350 (ISA {r['isa_cruise']['T'] - 273.15:.0f} degC), "
1142
+ f"M0.78, V = {_f(r['v_cruise'], 1)} m/s "
1143
+ f"({_f(kt(r['v_cruise']), 0)} kt TAS), MTOW. "
1144
+ f"CL = {r['cl_cruise']:.3f}, CD = {r['cd_cruise']:.4f}, "
1145
+ f"L/D = {r['ld_cruise']:.1f}; (L/D)max = {r['ld_max']['ld_max']:.1f} "
1146
+ f"at CL* = {r['ld_max']['cl_star']:.2f}. Input basis: MTOW "
1147
+ f"{_f(a.m_mtow, 0)} kg, cruise altitude FL350, M0.78, clean "
1148
+ f"configuration, example class data.")
1149
+ ap("")
1150
+ ap(f"- **Still-air range (Breguet):** R = (V/(TSFC*g))*(L/D)*ln(W0/W1) "
1151
+ f"= **{_f(r['range_km'], 0)} km "
1152
+ f"({_f(nm_(r['range_km']), 0)} nm)** burning "
1153
+ f"{_f(r['cruise_fuel_kg'], 0)} kg over the cruise segment "
1154
+ f"({a.m_mtow:,.0f} -> {a.m_mtow - r['cruise_fuel_kg']:,.0f} kg). "
1155
+ f"Breguet assumptions stated: constant L/D = {r['ld_cruise']:.1f}, "
1156
+ f"constant TSFC = {a.tsfc:.2e} kg/(N s), still air, cruise "
1157
+ f"configuration.")
1158
+ ap(f"- **Loiter endurance (Breguet):** E = (1/(TSFC*g))*(L/D)*ln(W0/W1) "
1159
+ f"= **{_f(r['endurance_hr'], 2)} h** on {_f(a.loiter_start_kg - a.loiter_end_kg, 0)} kg "
1160
+ f"hold fuel at L/D = {r['ld_cruise']:.1f}.")
1161
+ ap(f"- **Specific air range:** SAR = V*(L/D)/(TSFC*W) = "
1162
+ f"**{_f(r['sar_m_per_kg'], 1)} m/kg** "
1163
+ f"({_f(r['sar_m_per_kg'] / 1000.0, 3)} km/kg); 5,000 km sector fuel "
1164
+ f"~ {_f(r['sector_fuel_5000km'], 0)} kg. SAR curve (same weight, "
1165
+ f"M = 0.74/0.78/0.82): " +
1166
+ ", ".join(f"{p['frac'] * 0.78:.2f} M -> "
1167
+ f"{_f(p['sar'] / 1000.0, 3)} km/kg (L/D {p['ld']:.1f})"
1168
+ for p in r["sar_curve"]) + ".")
1169
+ ap(f"- **Climb (MTOW, clean, all engines):** ROC(SL) = "
1170
+ f"(T-D)V/W = **{_f(r['roc_sl'], 1)} m/s "
1171
+ f"({_f(fpm(r['roc_sl']), 0)} ft/min)** at 250 KCAS; "
1172
+ f"ROC(FL350) = **{_f(r['roc_350'], 2)} m/s "
1173
+ f"({_f(fpm(r['roc_350']), 0)} ft/min)** (climb thrust scaled by "
1174
+ f"sigma^{a.thrust_lapse_exp}); time to climb SL->FL350 at mean ROC = "
1175
+ f"**{_f(r['time_to_350_s'] / 60.0, 1)} min**. ROC at FL410 = "
1176
+ f"{_f(r['roc_410'], 2)} m/s ({_f(fpm(r['roc_410']), 0)} ft/min); "
1177
+ f"linear-lapse ceiling estimate ~{_f(ft(r['ceiling_est_m']) / 1000.0, 1)}k ft "
1178
+ f"(indicative; nonlinear refinement is an open item).")
1179
+ ap(f"- **Takeoff (SL ISA, MTOW):** Vs = {_f(kt(r['vs_to']), 0)} kt; "
1180
+ f"V_LOF = V2 = 1.2*Vs = {_f(kt(r['v2']), 0)} kt; estimated ground roll "
1181
+ f"Sg = 1.44*W^2/(g*rho*S*CLmax*(T-mu*W)) = **{_f(r['to_ground_roll'], 0)} m** "
1182
+ f"(mu = {a.to_mu}, CLmax = {a.cl_max_to}). The FAR 25.113 takeoff "
1183
+ f"distance demonstration (airborne segment to 35 ft, 1.15 factor) is "
1184
+ f"a flight-test determination - open item for the takeoff leaf build.")
1185
+ ap(f"- **Landing (SL ISA, MLW):** Vs = {_f(kt(r['vs_land']), 0)} kt, "
1186
+ f"Vref = 1.3*Vs = {_f(kt(r['v_ref']), 0)} kt, touchdown = "
1187
+ f"{_f(kt(r['v_td']), 0)} kt; air distance over 50 ft "
1188
+ f"{_f(r['air_dist'], 0)} m + ground roll {_f(r['land_ground_roll'], 0)} m "
1189
+ f"= {_f(r['land_actual'], 0)} m actual; certified field length "
1190
+ f"(FAR 25.125, x1.67) = **{_f(r['land_certified'], 0)} m**.")
1191
+ ap(f"- **Glide (engine-out reference):** best glide L/D = "
1192
+ f"{r['ld_max']['ld_max']:.1f} at {_f(kt(r['best_glide_v']), 0)} kt EAS; "
1193
+ f"from FL350: glide range {_f(r['glide_range_m'] / 1000.0, 0)} km, "
1194
+ f"sink {_f(r['best_glide_sink'], 1)} m/s, "
1195
+ f"{_f(r['glide_time_s'] / 60.0, 0)} min to sea level.")
1196
+ ap(f"- **Turn (25 deg bank, cruise, MTOW):** n = 1/cos(phi) = "
1197
+ f"{r['turn']['n']:.3f}; turn rate {_f(deg(r['turn']['rate_rad_s']), 2)} deg/s; "
1198
+ f"radius {_f(r['turn']['radius_m'] / 1000.0, 1)} km; sustained "
1199
+ f"({'yes' if r['turn']['sustained'] else 'no'}: D_turn = "
1200
+ f"{_f(r['turn']['d_turn'], 0)} N vs cruise available "
1201
+ f"{_f(r['turn']['t_avail'], 0)} N).")
1202
+ ap(f"- **Wind effects (enroute):** 40 kt headwind lowers groundspeed "
1203
+ f"{_f(r['wind']['gs_still'], 0)} -> {_f(r['wind']['gs_hw'], 0)} m/s "
1204
+ f"({_f(r['wind']['time_still_h'], 2)} h -> "
1205
+ f"{_f(r['wind']['time_hw_h'], 2)} h on 5,000 km).")
1206
+ ap("")
1207
+ ap("## 2. Safety performance")
1208
+ ap("")
1209
+ ap(f"- **OEI second segment (FAR 25.121(b), one engine inoperative at "
1210
+ f"V2, gear up):** T_oei = {_f(r['oei']['t_oei'], 0)} N, L/D = "
1211
+ f"{r['oei']['ld_2nd']:.1f}, gradient = (T_oei - D)/W = "
1212
+ f"**{r['oei']['gradient_pct']:.2f}%** vs the {a.n_engines}-engine "
1213
+ f"minimum of **{r['oei']['min_pct']:.1f}%** -> meets.")
1214
+ ap(f"- **Energy height / time to climb:** cruise energy height h_e = h + "
1215
+ f"V^2/2g = **{_f(r['energy']['h_e'], 0)} m** "
1216
+ f"({_f(ft(r['energy']['h_e']) / 1000.0, 0)}k ft), kinetic term "
1217
+ f"{_f(r['energy']['h_kin'], 0)} m of zoom-climb reserve; specific "
1218
+ f"excess power equals ROC (steady unaccelerated) by identity.")
1219
+ ap(f"- **Wind / windshear effects:** enroute headwind case above "
1220
+ f"(wind-effects leaf). Windshear assessment needs a shear profile "
1221
+ f"(windshear-analysis leaf) - **open item**.")
1222
+ ap("")
1223
+ ap("## 3. Rotorcraft")
1224
+ ap("")
1225
+ ap("Not applicable to this example (fixed-wing transport). For a "
1226
+ "rotary-wing vehicle the rotorcraft performance leaves (hover "
1227
+ "OGE/IGE, forward flight, autorotation, tail-rotor sizing, vertical "
1228
+ "climb) replace this section.")
1229
+ ap("")
1230
+ ap("## 4. Stability")
1231
+ ap("")
1232
+ np_ = r["aero"]["h_np"]
1233
+ ap(f"- **Longitudinal static stability:** stick-fixed neutral point "
1234
+ f"h_np = h_ac_w + V_h*(a_t/a_w)*(1-d_eps/d_alpha) = 0.25 + "
1235
+ f"{r['aero']['vh_tail']:.3f}*0.72*0.55 = **{np_:.3f} MAC** "
1236
+ f"(V_h = {r['aero']['vh_tail']:.3f}). Static margin SM = h_np - h_cg: "
1237
+ f"**fwd CG {r['aero']['sm_fwd'] * 100:.1f}%**, "
1238
+ f"**aft CG {r['aero']['sm_aft'] * 100:.1f}%** (minimum margin band "
1239
+ f"0.05 MAC; positive SM = stable).")
1240
+ ap(f"- **Directional stability (build):** V_v = {r['aero']['v_vtail']:.3f}; "
1241
+ f"Cn_beta(vt) = eta*V_v*a_vt*(1+k_s) = "
1242
+ f"{r['aero']['cn_beta_vt']:.3f}; total Cn_beta = "
1243
+ f"**{r['aero']['cn_beta']:.3f}/rad** (fuselage term "
1244
+ f"{a.cn_beta_fuselage:+.3f}) - directionally stable (Cn_beta > 0).")
1245
+ ap(f"- **Lateral stability (build):** dihedral term Cl_beta(gamma) = "
1246
+ f"-CL*Gamma = {r['aero']['cl_beta_dihedral']:.3f} at cruise CL; total "
1247
+ f"Cl_beta = **{r['aero']['cl_beta']:.3f}/rad** (remainder "
1248
+ f"{a.cl_beta_remainder:+.3f}, class data) - laterally stable "
1249
+ f"(Cl_beta < 0).")
1250
+ ap(f"- **Trim (linear model):** cruise CL_trim = "
1251
+ f"{r['trim_cruise']['cl_trim']:.3f}, alpha_trim = "
1252
+ f"{_f(deg(r['trim_cruise']['alpha_rad']), 1)} deg, elevator to trim "
1253
+ f"{r['trim_cruise']['de_deg']:+.1f} deg (limit +/-"
1254
+ f"{a.elevator_limit_deg:.0f} deg); approach CL_trim = "
1255
+ f"{r['trim_appr']['cl_trim']:.3f}, elevator to trim "
1256
+ f"{r['trim_appr']['de_deg']:+.1f} deg. Linear-model values; the "
1257
+ f"stabilizer/elevator split and flap Cm increments are trim-analysis "
1258
+ f"+ AVL stage items (open item).")
1259
+ ap("")
1260
+ ap("## 5. Dynamic modes")
1261
+ ap("")
1262
+ ap("| Mode | Condition | Frequency | Damping | Requirement | Result |")
1263
+ ap("|---|---|---|---|---|---|")
1264
+ sp_ca = r["sp_cruise_aft"]
1265
+ nca = r["sp_level_cruise_aft"]
1266
+ ap(f"| short period | cruise FL350 aft CG | {sp_ca['omega_sp']:.2f} rad/s "
1267
+ f"({_f(sp_ca['omega_sp'] / (2 * math.pi), 2)} Hz) | "
1268
+ f"zeta = {sp_ca['zeta_sp']:.2f} | FAR 25.181 heavily damped "
1269
+ f"(zeta >= 0.3 band); MIL-STD-1797A cat B L1 zeta 0.30-2.00, "
1270
+ f"freq floor {nca['min_omega']:.1f} rad/s (n_alpha "
1271
+ f"{sp_ca['n_alpha']:.1f} g/rad, table-clamped) | damping L1, "
1272
+ f"freq-limited -> "
1273
+ f"Level {nca['level']} by analogy (FAR heavy damping met) |")
1274
+ sp_cf = r["sp_cruise_fwd"]
1275
+ ap(f"| short period | cruise FL350 fwd CG | {sp_cf['omega_sp']:.2f} rad/s "
1276
+ f"| zeta = {sp_cf['zeta_sp']:.2f} | as above | damping below L2 floor "
1277
+ f"0.25 -> Level {r['sp_level_cruise_fwd']['level']} by analogy; "
1278
+ f"fwd-CG cruise damping open item |")
1279
+ sp_aa = r["sp_appr_aft"]
1280
+ naa = r["sp_level_appr_aft"]
1281
+ ap(f"| short period | approach aft CG | {sp_aa['omega_sp']:.2f} rad/s | "
1282
+ f"zeta = {sp_aa['zeta_sp']:.2f} | FAR 25.181; cat C L1 zeta 0.30-2.00, "
1283
+ f"freq floor {naa['min_omega']:.1f} rad/s | damping L1 -> Level "
1284
+ f"{naa['level']} by analogy (frequency-limited) |")
1285
+ sp_af = r["sp_appr_fwd"]
1286
+ ap(f"| short period | approach fwd CG | {sp_af['omega_sp']:.2f} rad/s | "
1287
+ f"zeta = {sp_af['zeta_sp']:.2f} | as above | Level "
1288
+ f"{r['sp_level_appr_fwd']['level']} by analogy |")
1289
+ pc = r["phugoid_cruise"]
1290
+ ap(f"| phugoid | cruise FL350 | {pc['omega']:.4f} rad/s (T = "
1291
+ f"{pc['period']:.0f} s) | zeta = {pc['zeta']:.3f} | FAR 25.181 not "
1292
+ f"growing (zeta > 0); 1797A L1 zeta >= 0.04 | Level "
1293
+ f"{r['phugoid_level_cruise']} (t_half {pc['t_half']:.0f} s, "
1294
+ f"{pc['t_half'] / pc['period']:.1f} cycles) |")
1295
+ pa = r["phugoid_appr"]
1296
+ ap(f"| phugoid | approach | {pa['omega']:.4f} rad/s (T = "
1297
+ f"{pa['period']:.0f} s) | zeta = {pa['zeta']:.3f} | as above | Level "
1298
+ f"{r['phugoid_level_appr']} |")
1299
+ drc = r["lat_cruise"]
1300
+ ap(f"| dutch roll | cruise FL350 | {drc['omega_dr']:.2f} rad/s (T = "
1301
+ f"{2 * math.pi / drc['omega_dr']:.1f} s) | zeta = {drc['zeta_dr']:.3f} "
1302
+ f"(zeta*omega = {drc['zeta_dr'] * drc['omega_dr']:.3f}) | FAR 25.181 "
1303
+ f"positively damped; 1797A cat B L1 zeta >= 0.08 & zeta*omega >= 0.15 "
1304
+ f"| stable (FAR ok); Level {r['dr_level_cruise']} by analogy - yaw "
1305
+ f"damper need (GNC boundary) |")
1306
+ dra = r["lat_appr"]
1307
+ ap(f"| dutch roll | approach | {dra['omega_dr']:.2f} rad/s (T = "
1308
+ f"{2 * math.pi / dra['omega_dr']:.1f} s) | zeta = {dra['zeta_dr']:.3f} "
1309
+ f"(product {dra['zeta_dr'] * dra['omega_dr']:.3f}) | 1797A cat C L1 as "
1310
+ f"above | Level {r['dr_level_appr']} by analogy |")
1311
+ rc = r["lat_cruise"]
1312
+ ap(f"| roll subsidence | cruise FL350 | tau = {rc['tau_roll']:.2f} s | - "
1313
+ f"| 1797A cat B L1 tau <= 1.4 s | Level {r['roll_level_cruise']} |")
1314
+ ra = r["lat_appr"]
1315
+ ap(f"| roll subsidence | approach | tau = {ra['tau_roll']:.2f} s | - | "
1316
+ f"1797A cat C L1 tau <= 1.0 s | Level {r['roll_level_appr']} |")
1317
+ sc = r["lat_cruise"]
1318
+ ap(f"| spiral | cruise FL350 | lambda = {sc['lambda_spiral']:.5f} 1/s | "
1319
+ f"- | 1797A L1 T2 >= 12 s (cat B); stable root passes | "
1320
+ f"convergent, Level {r['spiral_level_cruise']} |")
1321
+ sa = r["lat_appr"]
1322
+ ap(f"| spiral | approach | lambda = {sa['lambda_spiral']:.5f} 1/s | - | "
1323
+ f"1797A L1 T2 >= 20 s (cat C) | convergent, Level "
1324
+ f"{r['spiral_level_appr']} |")
1325
+ ap("")
1326
+ ap("Short-period/phugoid frequency separation (omega_sp/omega_ph): "
1327
+ f"{r['sp_phugoid_sep_cruise']:.0f}x cruise, "
1328
+ f"{r['sp_phugoid_sep_appr']:.1f}x approach (>= 5x ideal for the "
1329
+ "two-timescale split; well separated at cruise, tighter at approach "
1330
+ "- a full 4-DOF longitudinal root solve is an open item).")
1331
+ ap("")
1332
+ ap("## 6. Control effectiveness")
1333
+ ap("")
1334
+ ap(f"- Elevator authority for trim: cruise {r['trim_cruise']['de_deg']:+.1f} deg, "
1335
+ f"approach {r['trim_appr']['de_deg']:+.1f} deg of +/-"
1336
+ f"{a.elevator_limit_deg:.0f} deg limits (linear model; margins "
1337
+ f"acceptable, verify in the AVL/6DOF stage - open item).")
1338
+ ap("- Aileron reversal: requires wing torsional stiffness and aileron "
1339
+ "geometry (aileron-reversal leaf) - **open item**.")
1340
+ ap("- Deep stall: example has a low-mounted tailplane; deep-stall "
1341
+ "analysis (deep-stall-analysis leaf) remains a verification item for "
1342
+ "the stall-characteristics campaign - open item.")
1343
+ ap("- Spin: no spin demonstration is required for the FAR Part 25 "
1344
+ "transport category; the spin-recovery leaf applies to Part 23-class "
1345
+ "vehicles.")
1346
+ ap("")
1347
+ ap("## 7. Handling qualities")
1348
+ ap("")
1349
+ ap(f"- Mode-based assessment (MIL-STD-1797A summary by analogy, class "
1350
+ f"III): longitudinal modes damped at the aft-CG design point "
1351
+ f"(zeta_sp {sp_aa['zeta_sp']:.2f} approach, {sp_ca['zeta_sp']:.2f} "
1352
+ f"cruise); Dutch roll positively damped but below the Level 1 "
1353
+ f"product criterion -> production fitment of a yaw damper is "
1354
+ f"expected (SAS design is the GNC role boundary).")
1355
+ ap(f"- FAR 25.181 context: short period heavily damped "
1356
+ f"(zeta >= 0.3 band met at the aft CG), phugoid not growing in "
1357
+ f"amplitude (zeta > 0) - satisfied at the analyzed conditions; "
1358
+ f"formal compliance is a flight-test demonstration, not an analytic "
1359
+ f"finding (boundary).")
1360
+ ap("- Cooper-Harper ratings are pilot-in-the-loop observations: they "
1361
+ "are NOT assigned analytically. The cooper-harper-rating leaf "
1362
+ "structures the evaluation campaign (simulator/flight) - open item. "
1363
+ "PIO/pitch-bandwidth criteria likewise require the closed-loop "
1364
+ "response (6DOF + pilot model) - open item.")
1365
+ ap("")
1366
+ ap("## 8. Simulation")
1367
+ ap("")
1368
+ ap("- Trajectory evidence in this report is the analytic energy/climb "
1369
+ "set of sections 1-2 (specific excess power = rate of climb "
1370
+ "identity, energy height, time-to-climb at mean ROC).")
1371
+ ap("- Point-mass trajectory and 6-DOF simulation runs (point-mass-"
1372
+ "trajectory / six-dof-simulation leaves, JSBSim class tools) plus the "
1373
+ "AVL derivative build (stability-derivatives-avl leaf) are the "
1374
+ "deeper stages that replace the class-range derivative data - open "
1375
+ "items with the input basis recorded above.")
1376
+ ap("")
1377
+ ap("## 9. Conclusions and open items")
1378
+ ap("")
1379
+ ap(f"- Performance vs requirement: computed still-air range "
1380
+ f"{_f(r['range_km'], 0)} km, cruise ROC {_f(r['roc_sl'], 1)} m/s at "
1381
+ f"SL, takeoff ground roll {_f(r['to_ground_roll'], 0)} m, certified "
1382
+ f"landing field {_f(r['land_certified'], 0)} m - all in the class "
1383
+ f"range for a 150-seat twin-engine transport at the stated "
1384
+ f"assumptions (Breguet assumptions for range, example thrust "
1385
+ f"ratings, dry runway).")
1386
+ ap(f"- S&C vs requirement: static margin {r['aero']['sm_aft'] * 100:.1f}% "
1387
+ f"(aft CG) positive; short-period damping meets the FAR 25.181 "
1388
+ f"heavy-damping band at the aft-CG design point "
1389
+ f"(zeta {sp_aa['zeta_sp']:.2f} approach / {sp_ca['zeta_sp']:.2f} "
1390
+ f"cruise); all modes stable.")
1391
+ ap("- Open items: (1) replace example class-range inputs with project "
1392
+ "data; (2) AVL derivative build and wind-tunnel/flight-data "
1393
+ "correlation; (3) takeoff distance/accelerate-stop demonstration "
1394
+ "inputs (takeoff leaf); (4) windshear profile analysis; (5) "
1395
+ "6DOF/JSBSim trajectory and closed-loop handling checks; (6) "
1396
+ "stabilizer scheduling and elevator authority verification; (7) "
1397
+ "fwd-CG cruise pitch damping margin; (8) yaw damper authority "
1398
+ "(GNC role).")
1399
+ ap("")
1400
+ ap("---")
1401
+ ap(f"*Generated by Aero Agent Roles flight-mechanics-engineer core "
1402
+ f"({model['generated']}). Every number above carries its stated input "
1403
+ f"basis (mass, CG, altitude, configuration, assumptions). DRAFT for "
1404
+ f"human flight-mechanics review - engineering assessment, not an "
1405
+ f"approval or certification finding.*")
1406
+ return "\n".join(lines)
1407
+
1408
+
1409
+ # ---------------------------------------------------------------------------
1410
+ # Evidence gate checkers
1411
+ # ---------------------------------------------------------------------------
1412
+
1413
+ GATE_CHECKS = {
1414
+ "range_real": "Breguet still-air range is a positive class-range number",
1415
+ "roc_real": "sea-level rate of climb is a positive m/s number",
1416
+ "takeoff_real": "takeoff ground roll is in the plausible field range",
1417
+ "static_margin_present": "static margins are present and positive",
1418
+ "short_period_present": "short period frequency and damping present",
1419
+ "modes_stable": "phugoid/dutch roll/spiral stability verdicts present",
1420
+ "basis_stated": "input basis (mass, CG, altitude) recorded",
1421
+ "sign_off_honest": "document marked draft-for-review, not approval",
1422
+ }
1423
+
1424
+
1425
+ def check_report(results: dict) -> dict:
1426
+ """Run the evidence gates against the analysis results dict."""
1427
+ oei_ok = results["oei"]["gradient_pct"] >= results["oei"]["min_pct"]
1428
+ gates = {
1429
+ "range_real": 3000.0 < results["range_km"] < 15000.0,
1430
+ "roc_real": 5.0 < results["roc_sl"] < 60.0,
1431
+ "takeoff_real": 300.0 < results["to_ground_roll"] < 5000.0,
1432
+ "static_margin_present": (0.0 < results["aero"]["sm_fwd"] < 0.5 and
1433
+ 0.0 < results["aero"]["sm_aft"] < 0.5),
1434
+ "short_period_present": (0.5 < results["sp_cruise_aft"]["omega_sp"] < 5.0 and
1435
+ 0.0 < results["sp_cruise_aft"]["zeta_sp"] < 2.0 and
1436
+ 0.5 < results["sp_appr_aft"]["omega_sp"] < 5.0),
1437
+ "modes_stable": (results["phugoid_cruise"]["zeta"] > 0.0 and
1438
+ results["lat_cruise"]["zeta_dr"] > 0.0 and
1439
+ results["lat_cruise"]["lambda_spiral"] < 0.0),
1440
+ "oei_meets_25_121b": oei_ok,
1441
+ "basis_stated": results["sp_cruise_aft"]["cond"] != "" and
1442
+ results["energy"]["h_e"] > 0.0,
1443
+ "sign_off_honest": True, # enforced at render/model level
1444
+ }
1445
+ gates["all_pass"] = all(gates.values())
1446
+ return gates
1447
+
1448
+
1449
+ def check_report_markdown(md_text: str) -> dict:
1450
+ """Gate-check the rendered markdown deliverable."""
1451
+ low = md_text.lower()
1452
+ has_num = lambda pat: bool(re.search(pat, md_text, re.IGNORECASE))
1453
+ checks = {
1454
+ "has_title": "performance and s&c analysis report" in low,
1455
+ "has_aircraft": "aeroline at-78" in low,
1456
+ "has_range": has_num(r"range[^\n]*\d[\d,]*\s*km"),
1457
+ "has_roc": has_num(r"roc[^\n]*\d[\d,.]+\s*m/s"),
1458
+ "has_takeoff": has_num(r"ground roll[^\n]*\d[\d,]*\s*m"),
1459
+ "has_static_margin": has_num(r"margin[^\n]*\d+\.\d+%"),
1460
+ "has_short_period": ("short period" in low and "zeta" in low),
1461
+ "has_modes_table": all(m in low for m in
1462
+ ("dutch roll", "phugoid", "spiral",
1463
+ "roll subsidence")),
1464
+ "has_category_class": ("category" in low and "class iii" in low),
1465
+ "has_draft_marker": "draft" in low,
1466
+ "has_not_approval": "not an approval" in low,
1467
+ }
1468
+ checks["all_pass"] = all(checks.values())
1469
+ return checks
1470
+
1471
+
1472
+ def validate_deliverable_text(md_text: str) -> dict:
1473
+ """Public entry point used by gate tooling: check a report document."""
1474
+ return check_report_markdown(md_text)
1475
+
1476
+
1477
+ # ---------------------------------------------------------------------------
1478
+ # Example item + full example deliverable
1479
+ # ---------------------------------------------------------------------------
1480
+
1481
+ def example_report_markdown() -> str:
1482
+ """The complete worked-example deliverable (used by the template and
1483
+ tests): performance + S&C report for the AeroLine AT-78 example
1484
+ transport, built by the core from real domain rules."""
1485
+ return render_report_markdown(build_report())
1486
+
1487
+
1488
+ if __name__ == "__main__":
1489
+ results = analyze_vehicle(example_vehicle())
1490
+ model = build_report(example_vehicle(), results)
1491
+ md = render_report_markdown(model)
1492
+ gates = check_report(results)
1493
+ print(f"AIRCRAFT: {model['aircraft']}")
1494
+ print(f"RANGE: {results['range_km']:,.0f} km")
1495
+ print(f"ROC SL: {results['roc_sl']:.1f} m/s")
1496
+ print(f"TAKEOFF GROUND ROLL: {results['to_ground_roll']:,.0f} m")
1497
+ print(f"STATIC MARGIN fwd/aft: {results['aero']['sm_fwd']*100:.1f}% / "
1498
+ f"{results['aero']['sm_aft']*100:.1f}%")
1499
+ print(f"SHORT PERIOD (cruise aft): omega={results['sp_cruise_aft']['omega_sp']:.3f} "
1500
+ f"rad/s zeta={results['sp_cruise_aft']['zeta_sp']:.3f}")
1501
+ print(f"SHORT PERIOD (approach aft): omega={results['sp_appr_aft']['omega_sp']:.3f} "
1502
+ f"rad/s zeta={results['sp_appr_aft']['zeta_sp']:.3f}")
1503
+ print(f"DUTCH ROLL (cruise): omega={results['lat_cruise']['omega_dr']:.3f} "
1504
+ f"zeta={results['lat_cruise']['zeta_dr']:.3f}")
1505
+ print(f"GATES: {check_report(results)}")
1506
+ print(f"MD GATES: {check_report_markdown(md)}")
1507
+ print(f"RENDERED: {len(md)} chars")