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,1218 @@
1
+ #!/usr/bin/env python3
2
+ """aerodynamics_core.py - Aerodynamics Engineer executable core.
3
+
4
+ This is the role's ENGINE: given an aircraft configuration's project
5
+ facts it computes the aerodynamic design numbers with REAL domain
6
+ formulas and BUILDS the aerodynamic design report. Standalone: no
7
+ external repo needed (stdlib math only, offline).
8
+
9
+ Every formula below mirrors the logic of the bound Aero Agent Skills
10
+ leaves (which encode public-domain aero practice; summaries only, never
11
+ reproduced text):
12
+
13
+ - Parasite drag buildup (aerodynamics/drag-polars/parasite-drag):
14
+ Re = rho*V*L/mu
15
+ Cf_laminar = 1.328/sqrt(Re) (Blasius)
16
+ Cf_turbulent = 0.455/(log10(Re))^2.58 (Schlichting)
17
+ Cf_mixed = Cf_t(Re) - (Re_tr/Re)*(Cf_t(Re_tr) - Cf_l(Re_tr))
18
+ FF wing/tail = 1 + 2(t/c) + 100(t/c)^4
19
+ FF fuselage = 1 + 60/(l/d)^3 + 0.0025*(l/d)
20
+ FF nacelle = 1 + 0.35/(l/d)
21
+ CD_i = Cf * FF * Q * S_wet_i / S_ref; CD_parasite = sum(CD_i)
22
+ S_wet_wing = 2 * S_exposed * (1 + 0.2 t/c)
23
+ Cf_e = CD0 * S_ref / S_wet_total
24
+ - Drag polar (aerodynamics/drag-polars/drag-polar):
25
+ k = 1/(pi*e*AR); CD = CD0 + k*CL^2; cl_opt = sqrt(CD0/k)
26
+ L/D_max = 1/(2*sqrt(CD0*k))
27
+ - Lift curve slope (aerodynamics/drag-polars/lift-curve-slope):
28
+ a0 = 2*pi (thin airfoil); a = a0/(1 + a0/(pi*e*AR)) (lifting line)
29
+ a_swept = a*cos(sweep); a_mach = a/sqrt(1-M^2) (P-G, M < 0.7)
30
+ - Sweep effects (aerodynamics/high-speed/swept-wing-aerodynamics):
31
+ M_eff = M*cos(Lambda)
32
+ - High-lift (aerodynamics/high-lift/high-lift-systems):
33
+ Delta_clmax = ref * K_delta * K_chord * K_span,
34
+ K_delta = sin(delta)/sin(delta_max); wing CLmax = 0.9*clmax*cos(sweep)
35
+ V_stall = sqrt(2W/(rho*S*CLmax))
36
+ - Transonic (aerodynamics/high-speed/supercritical-airfoil):
37
+ M_DD = 0.95 - t/c - C_L/10 (supercritical), 0.90 - t/c - C_L/10 (conv.)
38
+ wave-drag penalty ~ (M - M_DD)^3 above divergence, 0 below
39
+ - Flutter margin (aerodynamics/aeroelasticity/flutter-speed-prediction):
40
+ margin = V_F/V_D, clearance practice threshold 1.15
41
+ - Static divergence (aerodynamics/aeroelasticity/divergence-speed):
42
+ q_div = k_theta/(S*c*C_Lalpha*e); V_div = sqrt(2 q_div / rho)
43
+ - CFD validation metrics (aerodynamics/cfd/cfd-validation):
44
+ relative_error, Richardson extrapolation with Roache GCI (F=1.25),
45
+ PASS/FAIL verdict vs tolerance band
46
+ - Discrete-gust screening (aerodynamics/aeroelasticity/
47
+ aeroelastic-gust-response): quasi-steady section gust lift
48
+ Delta_CL = a_wing*(U_de/V), Delta n = (0.5*rho*V*a*U_de)/(W/S)
49
+ - ISA atmosphere: standard lapse-rate model (public domain), Sutherland
50
+ viscosity.
51
+
52
+ Deliverable: the aerodynamic design report (render_*_markdown), with
53
+ evidence gates (check_report / check_report_markdown) that verify the
54
+ document before it leaves the role. Every margin row in the report
55
+ traces to a stage output of the bound workflow.
56
+ """
57
+ from __future__ import annotations
58
+
59
+ import math
60
+ import re
61
+ from dataclasses import dataclass, field
62
+ from datetime import date
63
+ from typing import Dict, List, Optional
64
+
65
+ def _today() -> str:
66
+ import os
67
+ from datetime import date
68
+ return os.environ.get("ROLE_GEN_DATE", date.today().isoformat())
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Physical / ISA constants (public domain)
72
+ # ---------------------------------------------------------------------------
73
+ ISA_T0 = 288.15 # K, sea-level temperature
74
+ ISA_P0 = 101325.0 # Pa, sea-level pressure
75
+ ISA_RHO0 = 1.225 # kg/m^3, sea-level density
76
+ ISA_LAPSE = 0.0065 # K/m, troposphere lapse rate
77
+ ISA_TROPOPAUSE = 11000.0 # m
78
+ GAMMA = 1.4 # specific-heat ratio of air
79
+ R_GAS = 287.052874 # J/(kg K), gas constant of air
80
+ G0 = 9.80665 # m/s^2
81
+ SUTHERLAND_MU0 = 1.458e-6 # Sutherland law constant (kg/(m s K^1.5))
82
+ SUTHERLAND_S = 110.4 # Sutherland reference temperature (K)
83
+
84
+ # AeroSkills leaf constants mirrored here (see module docstring).
85
+ SECTION_SLOPE = 2.0 * math.pi # thin-airfoil section slope, per rad
86
+ MAX_MACH_PG = 0.7 # Prandtl-Glauert subsonic validity limit
87
+ FLUTTER_MARGIN_REQUIRED = 1.15 # clearance practice, flutter leaf
88
+ MIN_DIVERGENCE_MARGIN = 1.15 # design practice, divergence leaf
89
+ WING_CLMAX_FACTOR = 0.9 # 3D/sweep reduction, high-lift leaf
90
+ GCI_SAFETY_FACTOR = 1.25 # Roache GCI safety factor, cfd-validation
91
+ DEFAULT_VALIDATION_TOL = 0.05 # 5% default band, cfd-validation leaf
92
+
93
+ # High-lift reference flap data (high-lift-systems leaf).
94
+ FLAP_TYPES = {
95
+ "plain": {"delta_clmax_ref": 0.9, "deflection_max_deg": 60.0,
96
+ "chord_frac_ref": 0.20, "cd0_ref": 0.05, "cp_frac": 0.50},
97
+ "split": {"delta_clmax_ref": 0.9, "deflection_max_deg": 60.0,
98
+ "chord_frac_ref": 0.20, "cd0_ref": 0.06, "cp_frac": 0.50},
99
+ "slotted": {"delta_clmax_ref": 1.3, "deflection_max_deg": 40.0,
100
+ "chord_frac_ref": 0.25, "cd0_ref": 0.08, "cp_frac": 0.52},
101
+ "fowler": {"delta_clmax_ref": 1.7, "deflection_max_deg": 40.0,
102
+ "chord_frac_ref": 0.30, "cd0_ref": 0.09, "cp_frac": 0.58},
103
+ }
104
+ LEADING_EDGE_DEVICES = {"slat": 0.5, "krueger": 0.4}
105
+
106
+ # CFD validation case catalog (cfd-validation leaf, summary data).
107
+ VALIDATION_CASES = {
108
+ "onera-m6": {
109
+ "name": "ONERA M6 wing",
110
+ "regime": "transonic", "application": "wing",
111
+ "conditions": {"mach": 0.84, "reynolds": 11.72e6, "alpha_deg": 3.06},
112
+ "reference": {"cd": 0.0163, "cl": 0.266},
113
+ "data_source": "AGARD-AR-138 (Schmitt and Charpin 1979), summary",
114
+ },
115
+ "dlr-f6": {
116
+ "name": "DLR-F6 wing-body transport",
117
+ "regime": "transonic", "application": "wing-body",
118
+ "conditions": {"mach": 0.75, "reynolds": 3.0e6, "cl_target": 0.5},
119
+ "reference": {"cd": 0.0299, "cl": 0.5},
120
+ "data_source": "AIAA Drag Prediction Workshop series, summary",
121
+ },
122
+ }
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # Validation helpers
126
+ # ---------------------------------------------------------------------------
127
+
128
+ def _require_positive(name: str, value: float) -> None:
129
+ if not (isinstance(value, (int, float)) and value > 0.0):
130
+ raise ValueError("%s must be positive, got %r" % (name, value))
131
+
132
+
133
+ def _require_nonnegative(name: str, value: float) -> None:
134
+ if not (isinstance(value, (int, float)) and value >= 0.0):
135
+ raise ValueError("%s must be >= 0, got %r" % (name, value))
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # ISA standard atmosphere (public-domain lapse-rate model)
140
+ # ---------------------------------------------------------------------------
141
+
142
+ def isa_temperature(altitude_m: float) -> float:
143
+ """ISA temperature (K) at geometric altitude (m), troposphere+stratosphere."""
144
+ _require_nonnegative("altitude", altitude_m)
145
+ if altitude_m <= ISA_TROPOPAUSE:
146
+ return ISA_T0 - ISA_LAPSE * altitude_m
147
+ return ISA_T0 - ISA_LAPSE * ISA_TROPOPAUSE # 216.65 K isothermal
148
+
149
+
150
+ def isa_pressure(altitude_m: float) -> float:
151
+ """ISA static pressure (Pa) at altitude (m)."""
152
+ _require_nonnegative("altitude", altitude_m)
153
+ t = isa_temperature(altitude_m)
154
+ if altitude_m <= ISA_TROPOPAUSE:
155
+ return ISA_P0 * (t / ISA_T0) ** (G0 / (ISA_LAPSE * R_GAS))
156
+ p_tropo = ISA_P0 * (216.65 / ISA_T0) ** (G0 / (ISA_LAPSE * R_GAS))
157
+ return p_tropo * math.exp(-G0 * (altitude_m - ISA_TROPOPAUSE) / (R_GAS * 216.65))
158
+
159
+
160
+ def isa_density(altitude_m: float) -> float:
161
+ """ISA air density (kg/m^3) from the perfect-gas law."""
162
+ _require_nonnegative("altitude", altitude_m)
163
+ return isa_pressure(altitude_m) / (R_GAS * isa_temperature(altitude_m))
164
+
165
+
166
+ def speed_of_sound(temperature_k: float) -> float:
167
+ """Speed of sound a = sqrt(gamma*R*T) in m/s."""
168
+ _require_positive("temperature", temperature_k)
169
+ return math.sqrt(GAMMA * R_GAS * temperature_k)
170
+
171
+
172
+ def air_viscosity(temperature_k: float) -> float:
173
+ """Dynamic viscosity mu (kg/(m s)) by Sutherland's law."""
174
+ _require_positive("temperature", temperature_k)
175
+ return (SUTHERLAND_MU0 * temperature_k ** 1.5) / (temperature_k + SUTHERLAND_S)
176
+
177
+
178
+ def atmosphere(altitude_m: float) -> Dict[str, float]:
179
+ """ISA atmosphere state at altitude: T, p, rho, a, mu (SI units)."""
180
+ _require_nonnegative("altitude", altitude_m)
181
+ t = isa_temperature(altitude_m)
182
+ p = isa_pressure(altitude_m)
183
+ rho = isa_density(altitude_m)
184
+ a = speed_of_sound(t)
185
+ mu = air_viscosity(t)
186
+ return {"temperature": t, "pressure": p, "density": rho,
187
+ "speed_of_sound": a, "viscosity": mu}
188
+
189
+
190
+ # ---------------------------------------------------------------------------
191
+ # Reynolds number and skin friction (parasite-drag leaf)
192
+ # ---------------------------------------------------------------------------
193
+
194
+ def reynolds(rho: float, v: float, l: float, mu: float) -> float:
195
+ """Reynolds number Re = rho * V * L / mu for a component of length L."""
196
+ _require_positive("rho", rho)
197
+ _require_positive("v", v)
198
+ _require_positive("l", l)
199
+ _require_positive("mu", mu)
200
+ return rho * v * l / mu
201
+
202
+
203
+ def cf_laminar(re: float) -> float:
204
+ """Laminar flat-plate skin friction Cf = 1.328/sqrt(Re) (Blasius)."""
205
+ _require_positive("Reynolds number", re)
206
+ return 1.328 / math.sqrt(re)
207
+
208
+
209
+ def cf_turbulent(re: float) -> float:
210
+ """Turbulent flat-plate skin friction Cf = 0.455/(log10 Re)^2.58."""
211
+ if re <= 10.0:
212
+ raise ValueError("Reynolds number must be > 10 for turbulent Cf, got %r" % (re,))
213
+ return 0.455 / (math.log10(re) ** 2.58)
214
+
215
+
216
+ def cf_mixed(re: float, re_transition: float) -> float:
217
+ """Mixed laminar-turbulent skin friction with transition at Re_tr.
218
+
219
+ Cf = Cf_turb(Re) - (Re_tr/Re) * (Cf_turb(Re_tr) - Cf_lam(Re_tr)).
220
+ """
221
+ if re <= 10.0:
222
+ raise ValueError("Reynolds number must be > 10, got %r" % (re,))
223
+ if re_transition <= 10.0 or re_transition >= re:
224
+ raise ValueError(
225
+ "transition Reynolds number must satisfy 10 < Re_tr < Re, "
226
+ "got Re_tr=%r, Re=%r" % (re_transition, re)
227
+ )
228
+ return (cf_turbulent(re)
229
+ - (re_transition / re) * (cf_turbulent(re_transition) - cf_laminar(re_transition)))
230
+
231
+
232
+ def form_factor(kind: str, t_over_c: Optional[float] = None,
233
+ l_over_d: Optional[float] = None) -> float:
234
+ """Component form factor FF (parasite-drag leaf formulas)."""
235
+ kind = str(kind).lower()
236
+ if kind in ("wing", "tail"):
237
+ if t_over_c is None or t_over_c <= 0.0 or t_over_c >= 0.5:
238
+ raise ValueError("t_over_c must be in (0, 0.5) for wing/tail, got %r" % (t_over_c,))
239
+ return 1.0 + 2.0 * t_over_c + 100.0 * t_over_c ** 4
240
+ if kind in ("fuselage", "nacelle"):
241
+ if l_over_d is None or l_over_d <= 1.0:
242
+ raise ValueError("l_over_d must be > 1.0 for fuselage/nacelle, got %r" % (l_over_d,))
243
+ if kind == "fuselage":
244
+ return 1.0 + 60.0 / (l_over_d ** 3) + 0.0025 * l_over_d
245
+ return 1.0 + 0.35 / l_over_d
246
+ raise ValueError("unknown component kind %r (use wing, tail, fuselage, nacelle)" % (kind,))
247
+
248
+
249
+ def component_drag(cf: float, ff: float, q: float, s_wet: float, s_ref: float) -> float:
250
+ """Component parasite drag CD_i = Cf * FF * Q * S_wet / S_ref."""
251
+ _require_positive("skin-friction coefficient", cf)
252
+ if ff < 1.0:
253
+ raise ValueError("form factor must be >= 1, got %r" % (ff,))
254
+ if q < 1.0:
255
+ raise ValueError("interference factor must be >= 1, got %r" % (q,))
256
+ _require_positive("wetted area", s_wet)
257
+ _require_positive("reference area", s_ref)
258
+ return cf * ff * q * s_wet / s_ref
259
+
260
+
261
+ def wing_wetted_area(s_exposed: float, t_over_c: float) -> float:
262
+ """Wing wetted area S_wet = 2 * S_exposed * (1 + 0.2*t/c)."""
263
+ _require_positive("exposed area", s_exposed)
264
+ if t_over_c <= 0.0 or t_over_c >= 0.5:
265
+ raise ValueError("t_over_c must be in (0, 0.5), got %r" % (t_over_c,))
266
+ return 2.0 * s_exposed * (1.0 + 0.2 * t_over_c)
267
+
268
+
269
+ def equivalent_skin_friction(cd0: float, s_ref: float, s_wet_total: float) -> float:
270
+ """Equivalent skin friction Cf_e = CD0 * S_ref / S_wet_total."""
271
+ _require_nonnegative("total parasite drag", cd0)
272
+ _require_positive("reference area", s_ref)
273
+ _require_positive("total wetted area", s_wet_total)
274
+ return cd0 * s_ref / s_wet_total
275
+
276
+
277
+ def parasite_drag(components: List[dict], s_ref: float, rho: float, v: float,
278
+ mu: float, re_transition: float = 5e5) -> Dict:
279
+ """Assemble the parasite-drag buildup from component geometry.
280
+
281
+ Each component dict: {name, kind ('wing'|'tail'|'fuselage'|'nacelle'),
282
+ length (m, for Re), s_wet (m^2), t_over_c or l_over_d, q (interference),
283
+ count (optional, default 1)}. All sub-formulas are the parasite-drag
284
+ leaf formulas; total = sum of CD_i terms.
285
+ """
286
+ _require_positive("reference area", s_ref)
287
+ _require_positive("rho", rho)
288
+ _require_positive("v", v)
289
+ _require_positive("mu", mu)
290
+ rows, s_wet_total = [], 0.0
291
+ for comp in components:
292
+ count = comp.get("count", 1)
293
+ kind = comp["kind"]
294
+ length = comp["length"]
295
+ s_wet = comp["s_wet"]
296
+ q = comp.get("q", 1.0)
297
+ re_i = reynolds(rho, v, length, mu)
298
+ cf_i = cf_mixed(re_i, re_transition)
299
+ if kind in ("wing", "tail"):
300
+ ff_i = form_factor(kind, t_over_c=comp["t_over_c"])
301
+ else:
302
+ ff_i = form_factor(kind, l_over_d=comp["l_over_d"])
303
+ cd_i = component_drag(cf_i, ff_i, q, s_wet, s_ref) * count
304
+ s_wet_total += s_wet * count
305
+ rows.append({"name": comp["name"], "kind": kind, "count": count,
306
+ "reynolds": re_i, "cf": cf_i, "ff": ff_i, "q": q,
307
+ "s_wet": s_wet * count, "cd": cd_i})
308
+ cd0 = float(sum(r["cd"] for r in rows))
309
+ cf_e = equivalent_skin_friction(cd0, s_ref, s_wet_total)
310
+ return {"components": rows, "cd0": cd0, "s_wet_total": s_wet_total,
311
+ "cf_e": cf_e, "count_components": len(rows)}
312
+
313
+
314
+ # ---------------------------------------------------------------------------
315
+ # Drag polar (drag-polar leaf)
316
+ # ---------------------------------------------------------------------------
317
+
318
+ def induced_drag_factor(e: float, ar: float) -> float:
319
+ """Induced drag factor k = 1 / (pi * e * AR)."""
320
+ if e <= 0.0 or e > 1.0:
321
+ raise ValueError("Oswald span-efficiency e must be in (0, 1], got %r" % (e,))
322
+ _require_positive("aspect ratio", ar)
323
+ return 1.0 / (math.pi * e * ar)
324
+
325
+
326
+ def drag_polar(cd0: float, e: float, ar: float, cl: Optional[float] = None) -> Dict:
327
+ """Evaluate the parabolic drag polar at lift coefficient cl.
328
+
329
+ Returns k, CD at cl (or at cl_opt when cl is None), cl_opt and L/D_max.
330
+ """
331
+ _require_positive("zero-lift drag coefficient", cd0)
332
+ k = induced_drag_factor(e, ar)
333
+ cl_opt = math.sqrt(cd0 / k)
334
+ ld_max = 1.0 / (2.0 * math.sqrt(cd0 * k))
335
+ if cl is None:
336
+ cl = cl_opt
337
+ if cl < 0.0:
338
+ raise ValueError("lift coefficient must be >= 0, got %r" % (cl,))
339
+ cd = cd0 + k * cl * cl
340
+ return {"k": k, "cd": cd, "cl_opt": cl_opt, "ld_max": ld_max,
341
+ "l_over_d": cl / cd if cd > 0.0 else 0.0}
342
+
343
+
344
+ # ---------------------------------------------------------------------------
345
+ # Lift curve slope (lift-curve-slope leaf)
346
+ # ---------------------------------------------------------------------------
347
+
348
+ def lift_curve_slope(ar: float, e: float = 1.0, sweep_deg: float = 0.0,
349
+ mach: float = 0.0, a0: Optional[float] = None) -> Dict:
350
+ """Wing lift-curve slope (per radian) with stepwise breakdown.
351
+
352
+ Corrections in leaf order: section (2*pi default) -> finite wing
353
+ (lifting line) -> sweep (simple sweep theory) -> Mach (Prandtl-Glauert,
354
+ valid M < 0.7).
355
+ """
356
+ if a0 is None:
357
+ a0 = SECTION_SLOPE
358
+ _require_positive("section slope a0", a0)
359
+ _require_positive("aspect ratio", ar)
360
+ if e <= 0.0 or e > 1.0:
361
+ raise ValueError("span efficiency e must be in (0, 1], got %r" % (e,))
362
+ if not (0.0 <= sweep_deg < 90.0):
363
+ raise ValueError("sweep must be in [0, 90) degrees, got %r" % (sweep_deg,))
364
+ if not (0.0 <= mach < MAX_MACH_PG):
365
+ raise ValueError(
366
+ "mach must be in [0, 0.7) for the Prandtl-Glauert correction, got %r" % (mach,))
367
+ a_section = float(a0)
368
+ a_finite = a_section / (1.0 + a_section / (math.pi * e * ar))
369
+ a_swept = a_finite * math.cos(math.radians(sweep_deg))
370
+ a_final = a_swept / math.sqrt(1.0 - mach * mach)
371
+ return {"a_section": a_section, "a_finite": a_finite, "a_swept": a_swept,
372
+ "a_mach": a_final, "per_degree": a_final / math.degrees(1.0)}
373
+
374
+
375
+ def lift_coefficient(a_per_rad: float, alpha_deg: float, alpha_zero_deg: float = 0.0) -> float:
376
+ """C_L = a * (alpha - alpha_zero), angles in degrees, slope per radian."""
377
+ _require_positive("lift curve slope", a_per_rad)
378
+ return a_per_rad * math.radians(alpha_deg - alpha_zero_deg)
379
+
380
+
381
+ # ---------------------------------------------------------------------------
382
+ # Sweep effects (swept-wing-aerodynamics leaf)
383
+ # ---------------------------------------------------------------------------
384
+
385
+ def cos_sweep(sweep_deg: float) -> float:
386
+ """Cosine of the sweep angle in degrees (simple sweep theory)."""
387
+ if not (0.0 <= sweep_deg < 90.0):
388
+ raise ValueError("sweep angle must be in [0, 90) degrees, got %r" % (sweep_deg,))
389
+ return math.cos(math.radians(sweep_deg))
390
+
391
+
392
+ def effective_mach(mach: float, sweep_deg: float) -> float:
393
+ """Section Mach number seen by a yawed wing: M * cos(Lambda)."""
394
+ if not (0.0 <= mach < 1.0):
395
+ raise ValueError("Mach number must be in [0, 1), got %r" % (mach,))
396
+ return mach * cos_sweep(sweep_deg)
397
+
398
+
399
+ # ---------------------------------------------------------------------------
400
+ # High-lift (high-lift-systems leaf)
401
+ # ---------------------------------------------------------------------------
402
+
403
+ def deflection_factor(deflection_deg: float, deflection_max_deg: float) -> float:
404
+ """sin(delta)/sin(delta_max), clamped at delta_max."""
405
+ if deflection_deg >= deflection_max_deg:
406
+ return 1.0
407
+ return math.sin(math.radians(deflection_deg)) / math.sin(math.radians(deflection_max_deg))
408
+
409
+
410
+ def flap_clmax_increment(flap_type: str, deflection_deg: float,
411
+ chord_frac: Optional[float] = None, span_frac: float = 1.0) -> float:
412
+ """Section clmax increment for a trailing-edge flap (high-lift leaf)."""
413
+ if flap_type not in FLAP_TYPES:
414
+ raise ValueError("unknown flap type %r; expected %s"
415
+ % (flap_type, ", ".join(sorted(FLAP_TYPES))))
416
+ data = FLAP_TYPES[flap_type]
417
+ if chord_frac is None:
418
+ chord_frac = data["chord_frac_ref"]
419
+ if not (0.0 < chord_frac <= 1.0):
420
+ raise ValueError("chord_frac must be in (0, 1], got %r" % (chord_frac,))
421
+ if not (0.0 < span_frac <= 1.0):
422
+ raise ValueError("span_frac must be in (0, 1], got %r" % (span_frac,))
423
+ k_delta = deflection_factor(deflection_deg, data["deflection_max_deg"])
424
+ k_chord = chord_frac / data["chord_frac_ref"]
425
+ return data["delta_clmax_ref"] * k_delta * k_chord * span_frac
426
+
427
+
428
+ def slat_clmax_increment(device: str = "slat", span_frac: float = 1.0) -> float:
429
+ """Section clmax increment from a leading-edge device."""
430
+ if device not in LEADING_EDGE_DEVICES:
431
+ raise ValueError("unknown leading-edge device %r; expected %s"
432
+ % (device, ", ".join(sorted(LEADING_EDGE_DEVICES))))
433
+ if not (0.0 < span_frac <= 1.0):
434
+ raise ValueError("span_frac must be in (0, 1], got %r" % (span_frac,))
435
+ return LEADING_EDGE_DEVICES[device] * span_frac
436
+
437
+
438
+ def wing_clmax(clmax_section: float, sweep_deg: float = 0.0) -> float:
439
+ """Wing-level CLmax = 0.9 * clmax_section * cos(Lambda)."""
440
+ _require_positive("section clmax", clmax_section)
441
+ if not (0.0 <= sweep_deg < 90.0):
442
+ raise ValueError("sweep must be in [0, 90) deg, got %r" % (sweep_deg,))
443
+ return WING_CLMAX_FACTOR * clmax_section * math.cos(math.radians(sweep_deg))
444
+
445
+
446
+ def stall_speed(weight: float, wing_area: float, rho: float, clmax_wing: float) -> float:
447
+ """Stall speed V = sqrt(2 W / (rho S CLmax)) in m/s."""
448
+ _require_positive("weight", weight)
449
+ _require_positive("wing_area", wing_area)
450
+ _require_positive("rho", rho)
451
+ _require_positive("clmax_wing", clmax_wing)
452
+ return math.sqrt(2.0 * weight / (rho * wing_area * clmax_wing))
453
+
454
+
455
+ # ---------------------------------------------------------------------------
456
+ # Transonic (supercritical-airfoil + wave-drag leaves)
457
+ # ---------------------------------------------------------------------------
458
+
459
+ def drag_divergence_mach(t_over_c: float, cl: float, supercritical: bool = True) -> float:
460
+ """Drag-divergence Mach from the Korn rule of thumb.
461
+
462
+ M_DD = 0.95 - t/c - C_L/10 (supercritical), 0.90 - ... (conventional).
463
+ """
464
+ if not (0.02 < t_over_c < 0.30):
465
+ raise ValueError("thickness ratio t/c must be in (0.02, 0.30), got %r" % (t_over_c,))
466
+ if not (0.0 <= cl < 1.5):
467
+ raise ValueError("cruise lift coefficient must be in [0, 1.5), got %r" % (cl,))
468
+ base = 0.95 if supercritical else 0.90
469
+ mdd = base - t_over_c - cl / 10.0
470
+ if not (0.5 < mdd < 0.95):
471
+ raise ValueError("Korn rule gives drag-divergence Mach %r outside (0.5, 0.95)"
472
+ % (mdd,))
473
+ return mdd
474
+
475
+
476
+ def wave_drag_penalty(mach: float, mdd: float) -> float:
477
+ """Relative wave-drag penalty index: (M - M_DD)^3 above divergence, 0 below."""
478
+ if not (0.5 <= mach < 1.0):
479
+ raise ValueError("flight Mach must be in [0.5, 1), got %r" % (mach,))
480
+ if not (0.5 < mdd < 1.0):
481
+ raise ValueError("drag-divergence Mach must be in (0.5, 1), got %r" % (mdd,))
482
+ excess = mach - mdd
483
+ if excess <= 0.0:
484
+ return 0.0
485
+ return excess ** 3
486
+
487
+
488
+ def transonic_similarity_parameter(mach: float, t_over_c: float) -> float:
489
+ """Transonic similarity parameter K = (1 - M^2) / tau^(2/3)."""
490
+ if not (0.0 <= mach <= 1.0):
491
+ raise ValueError("Mach must be in [0, 1], got %r" % (mach,))
492
+ if not (0.0 < t_over_c < 1.0):
493
+ raise ValueError("thickness ratio must be in (0, 1), got %r" % (t_over_c,))
494
+ return (1.0 - mach * mach) / (t_over_c ** (2.0 / 3.0))
495
+
496
+
497
+ # ---------------------------------------------------------------------------
498
+ # Aeroelastic screening (flutter-speed + divergence-speed + gust leaves)
499
+ # ---------------------------------------------------------------------------
500
+
501
+ def flutter_margin(v_f: float, v_design: float,
502
+ required: float = FLUTTER_MARGIN_REQUIRED):
503
+ """Flutter margin against the design dive speed.
504
+
505
+ margin = V_F / V_D. Acceptable when margin >= required (clearance
506
+ practice threshold 1.15). Returns (margin, acceptable).
507
+ """
508
+ _require_positive("flutter speed V_F", v_f)
509
+ _require_positive("design dive speed V_D", v_design)
510
+ if required < 1.0:
511
+ raise ValueError("required margin must be at least 1.0, got %r" % (required,))
512
+ margin = v_f / v_design
513
+ return (margin, margin >= required)
514
+
515
+
516
+ def divergence_dynamic_pressure(k_theta: float, area: float, chord: float,
517
+ cl_alpha: float, offset_ratio: float) -> float:
518
+ """Divergence dynamic pressure q_div = k_theta/(S*c*C_Lalpha*e).
519
+
520
+ offset_ratio must be positive (aerodynamic center ahead of the shear
521
+ center); e <= 0 means no divergence mechanism.
522
+ """
523
+ _require_positive("torsional stiffness k_theta", k_theta)
524
+ _require_positive("reference area S", area)
525
+ _require_positive("chord c", chord)
526
+ _require_positive("lift curve slope C_Lalpha", cl_alpha)
527
+ if offset_ratio <= 0.0:
528
+ raise ValueError(
529
+ "offset ratio must be positive (aerodynamic center ahead of the "
530
+ "shear center); e <= 0 means no divergence mechanism, got %r"
531
+ % (offset_ratio,))
532
+ return k_theta / (area * chord * cl_alpha * offset_ratio)
533
+
534
+
535
+ def divergence_speed(q_div: float, rho: float = ISA_RHO0) -> float:
536
+ """Divergence speed V_div = sqrt(2 q_div / rho) in m/s."""
537
+ _require_positive("divergence dynamic pressure", q_div)
538
+ _require_positive("air density", rho)
539
+ return math.sqrt(2.0 * q_div / rho)
540
+
541
+
542
+ def divergence_margin(v_div: float, v_design: float,
543
+ min_margin: float = MIN_DIVERGENCE_MARGIN):
544
+ """Divergence margin m = V_div / V_design; (margin, acceptable)."""
545
+ _require_positive("divergence speed", v_div)
546
+ _require_positive("design dive speed", v_design)
547
+ if min_margin < 1.0:
548
+ raise ValueError("min_margin must be at least 1.0, got %r" % (min_margin,))
549
+ margin = v_div / v_design
550
+ return (margin, margin >= min_margin)
551
+
552
+
553
+ def gust_load_factor(density: float, velocity: float, u_de: float, a_wing: float,
554
+ weight: float, wing_area: float) -> float:
555
+ """Total load factor under a discrete gust (quasi-steady screening).
556
+
557
+ Delta_CL = a_wing * U_de / V (section quasi-steady gust lift model of
558
+ the aeroelastic-gust-response leaf expressed at wing level with the
559
+ computed wing slope a_wing), so
560
+ Delta n = (0.5 * rho * V * a * U_de) / (W / S),
561
+ n = 1 + Delta n.
562
+ Conservative screening: no gust-alleviation factor applied.
563
+ """
564
+ _require_positive("density", density)
565
+ _require_positive("velocity", velocity)
566
+ _require_positive("gust velocity U_de", u_de)
567
+ _require_positive("wing lift curve slope", a_wing)
568
+ _require_positive("weight", weight)
569
+ _require_positive("wing area", wing_area)
570
+ w_s = weight / wing_area
571
+ delta_n = 0.5 * density * velocity * a_wing * u_de / w_s
572
+ return 1.0 + delta_n
573
+
574
+
575
+ # ---------------------------------------------------------------------------
576
+ # CFD validation metrics (cfd-validation leaf)
577
+ # ---------------------------------------------------------------------------
578
+
579
+ def relative_error(computed: float, reference: float) -> float:
580
+ """Relative error |computed - reference| / |reference|."""
581
+ if reference == 0.0:
582
+ raise ValueError("relative error undefined for zero reference")
583
+ return abs(computed - reference) / abs(reference)
584
+
585
+
586
+ def validation_verdict(computed: float, reference: float,
587
+ tolerance: float = DEFAULT_VALIDATION_TOL) -> Dict:
588
+ """PASS/FAIL verdict of a computed quantity against a reference."""
589
+ _require_positive("tolerance", tolerance)
590
+ error = relative_error(computed, reference)
591
+ passed = error <= tolerance
592
+ return {"passed": passed, "error": error, "tolerance": tolerance,
593
+ "verdict": "PASS" if passed else "FAIL",
594
+ "margin": tolerance - error}
595
+
596
+
597
+ def richardson_extrapolation(values: List[float], refinement_ratio: float = 2.0) -> Dict:
598
+ """3-mesh Richardson extrapolation (cfd-validation leaf).
599
+
600
+ values: [finest, medium, coarsest]; returns apparent order p, the
601
+ extrapolated value, and Roache's GCI (safety factor 1.25).
602
+ """
603
+ if not isinstance(values, (list, tuple)) or len(values) != 3:
604
+ raise ValueError("needs exactly 3 values (finest, medium, coarsest)")
605
+ if refinement_ratio <= 1.0:
606
+ raise ValueError("refinement_ratio must be > 1, got %r" % (refinement_ratio,))
607
+ f1, f2, f3 = (float(v) for v in values)
608
+ e12, e23 = f2 - f1, f3 - f2
609
+ if e12 == 0.0 or e23 == 0.0 or e12 * e23 <= 0.0:
610
+ raise ValueError("non-monotone or zero-change mesh sequence; "
611
+ "Richardson extrapolation not valid")
612
+ p = math.log(e23 / e12) / math.log(refinement_ratio)
613
+ denom = refinement_ratio ** p - 1.0
614
+ extrapolated = f1 - e12 / denom
615
+ gci = GCI_SAFETY_FACTOR * abs(e12) / denom
616
+ return {"apparent_order": p, "extrapolated": extrapolated, "gci": gci,
617
+ "finest_value": f1, "refinement_ratio": float(refinement_ratio),
618
+ "monotone": True}
619
+
620
+
621
+ # ---------------------------------------------------------------------------
622
+ # Aircraft item: project facts the role needs
623
+ # ---------------------------------------------------------------------------
624
+
625
+ @dataclass
626
+ class AircraftItem:
627
+ """Configuration + flight facts for the aerodynamic design report."""
628
+ name: str
629
+ description: str = ""
630
+ # Planform / configuration
631
+ s_ref: float = 122.6 # reference wing area, m^2
632
+ aspect_ratio: float = 8.0
633
+ sweep_deg: float = 25.0 # quarter-chord sweep, degrees
634
+ taper_ratio: float = 0.30
635
+ t_over_c_wing: float = 0.105 # representative thickness ratio
636
+ oswald_e: float = 0.80 # span efficiency
637
+ airfoil: str = "representative supercritical section"
638
+ supercritical: bool = True
639
+ cl_max_section: float = 1.55 # section clmax (stage-1 analysis)
640
+ section_alpha_zero_deg: float = -2.0
641
+ # Weights, N
642
+ w_cruise: float = 622000.0
643
+ w_mtow: float = 735000.0
644
+ w_land: float = 633000.0
645
+ # Flight conditions
646
+ cruise_mach: float = 0.78
647
+ cruise_altitude_m: float = 10668.0 # 35 000 ft
648
+ dive_mach: float = 0.89
649
+ re_transition: float = 5e5
650
+ # Component geometry for the parasite drag buildup. s_wet entries are
651
+ # configuration inputs; each CD_i is computed by the leaf formulas.
652
+ components: List[dict] = field(default_factory=list)
653
+ # High-lift
654
+ flap_type: str = "slotted"
655
+ flap_chord_frac: float = 0.25
656
+ flap_span_frac: float = 0.70
657
+ flap_defl_to_deg: float = 15.0
658
+ flap_defl_land_deg: float = 35.0
659
+ slat_device: str = "slat"
660
+ slat_span_frac: float = 0.85
661
+ # Aeroelastic screening inputs
662
+ flutter_speed_mps: float = 364.2 # V-g typical-section flutter speed
663
+ gust_velocity_mps: float = 12.5 # design gust velocity U_de (screening)
664
+ # CFD evidence inputs (analysis run outputs; metrics computed here)
665
+ cfd_mesh_cells: tuple = (19.2e6, 4.8e6, 1.2e6) # fine, medium, coarse
666
+ cfd_cd_meshes: tuple = (0.02840, 0.02910, 0.03060) # total drag at cruise CL
667
+ cfd_validation_run_cd: float = 0.0169 # ONERA M6 run (same method/tools)
668
+ generated: str = ""
669
+
670
+
671
+ def default_components(item: Optional[AircraftItem] = None) -> List[dict]:
672
+ """Component geometry for the reference transport (wing wet area from
673
+ the leaf formula; exposed area is the config input)."""
674
+ return [
675
+ {"name": "wing", "kind": "wing", "length": 3.915, # MAC, m
676
+ "s_wet": wing_wetted_area(107.0, 0.105), "t_over_c": 0.105, "q": 1.0},
677
+ {"name": "fuselage", "kind": "fuselage", "length": 37.6,
678
+ "s_wet": 401.0, "l_over_d": 37.6 / 3.95, "q": 1.0},
679
+ {"name": "nacelles", "kind": "nacelle", "length": 2.9, "count": 2,
680
+ "s_wet": 14.5, "l_over_d": 2.9 / 2.0, "q": 1.2},
681
+ {"name": "horizontal tail", "kind": "tail", "length": 2.54,
682
+ "s_wet": wing_wetted_area(31.0 * 0.80, 0.10), "t_over_c": 0.10, "q": 1.0},
683
+ {"name": "vertical tail", "kind": "tail", "length": 3.0,
684
+ "s_wet": wing_wetted_area(21.0 * 0.92, 0.10), "t_over_c": 0.10, "q": 1.0},
685
+ ]
686
+
687
+
688
+ # ---------------------------------------------------------------------------
689
+ # Report builder: computes every number with the formulas above
690
+ # ---------------------------------------------------------------------------
691
+
692
+ def _fmt(v: float, nd: int = 4) -> str:
693
+ return ("%.*f" % (nd, v)).rstrip("0").rstrip(".") if nd else ("%g" % v)
694
+
695
+
696
+ def build_report(item: AircraftItem) -> Dict:
697
+ """Build the complete aerodynamic design report content model.
698
+
699
+ Every numeric field is produced by the formula functions above from
700
+ the item facts (no external data, no invented rules).
701
+ """
702
+ s = item.s_ref
703
+ ar = item.aspect_ratio
704
+ e = item.oswald_e
705
+ span = math.sqrt(ar * s)
706
+ mac = s / span
707
+ sweep = item.sweep_deg
708
+
709
+ # --- atmosphere + flight conditions -------------------------------------
710
+ atm = atmosphere(item.cruise_altitude_m)
711
+ rho = atm["density"]
712
+ a_cruise = atm["speed_of_sound"]
713
+ v_cruise = item.cruise_mach * a_cruise
714
+ q_cruise = 0.5 * rho * v_cruise * v_cruise
715
+ cl_cruise = item.w_cruise / (q_cruise * s)
716
+ w_s_cruise = item.w_cruise / s
717
+
718
+ v_dive = item.dive_mach * a_cruise
719
+ q_dive = 0.5 * rho * v_dive * v_dive
720
+
721
+ # --- parasite drag buildup at the cruise condition -----------------------
722
+ comps = list(item.components) if item.components else default_components()
723
+ buildup = parasite_drag(comps, s, rho, v_cruise, atm["viscosity"],
724
+ item.re_transition)
725
+ cd0 = buildup["cd0"]
726
+
727
+ # --- drag polar ----------------------------------------------------------
728
+ polar = drag_polar(cd0, e, ar, cl_cruise)
729
+ k = polar["k"]
730
+ cd_cruise = polar["cd"]
731
+ ld_cruise = cl_cruise / cd_cruise
732
+ cl_opt = polar["cl_opt"]
733
+ ld_max = polar["ld_max"]
734
+ # linear-model cruise incidence estimate (lifting-line/simple-sweep slope
735
+ # at M = 0; the Prandtl-Glauert correction is not valid at cruise M 0.78)
736
+ alpha_cruise_deg = (item.section_alpha_zero_deg
737
+ + math.degrees(cl_cruise / _lift_slope_for_alpha(item)))
738
+
739
+ # --- lift curve slope (low-speed reference M = 0.30, P-G valid) ----------
740
+ slope = lift_curve_slope(ar, e=e, sweep_deg=sweep, mach=0.30, a0=None)
741
+ a_wing = slope["a_mach"] # wing slope per radian at M 0.30
742
+ a_wing_per_deg = slope["per_degree"]
743
+ a_section = slope["a_section"]
744
+ a_finite = slope["a_finite"]
745
+ a_swept = slope["a_swept"]
746
+ cl_per_deg = a_wing_per_deg
747
+
748
+ # --- airfoil / section analysis numbers -----------------------------------
749
+ # operating Reynolds range (MAC-based): sea-level low-speed end and cruise
750
+ v_low_sl = _stall_clean(item)
751
+ re_range = sorted([
752
+ reynolds(ISA_RHO0, v_low_sl, mac, air_viscosity(ISA_T0)),
753
+ reynolds(rho, v_cruise, mac, atm["viscosity"]),
754
+ ])
755
+
756
+ # --- high lift -------------------------------------------------------------
757
+ dcl_flap_to = flap_clmax_increment(item.flap_type, item.flap_defl_to_deg,
758
+ item.flap_chord_frac, item.flap_span_frac)
759
+ dcl_flap_land = flap_clmax_increment(item.flap_type, item.flap_defl_land_deg,
760
+ item.flap_chord_frac, item.flap_span_frac)
761
+ dcl_slat = slat_clmax_increment(item.slat_device, item.slat_span_frac)
762
+ clmax_sec_clean = item.cl_max_section
763
+ clmax_sec_to = clmax_sec_clean + dcl_flap_to + dcl_slat
764
+ clmax_sec_land = clmax_sec_clean + dcl_flap_land + dcl_slat
765
+ clmax_wing_clean = wing_clmax(clmax_sec_clean, sweep)
766
+ clmax_wing_to = wing_clmax(clmax_sec_to, sweep)
767
+ clmax_wing_land = wing_clmax(clmax_sec_land, sweep)
768
+ vs_clean = stall_speed(item.w_mtow, s, ISA_RHO0, clmax_wing_clean)
769
+ vs_to = stall_speed(item.w_mtow, s, ISA_RHO0, clmax_wing_to)
770
+ vs_land = stall_speed(item.w_land, s, ISA_RHO0, clmax_wing_land)
771
+ v_ref_land = 1.23 * vs_land # CS/FAR 25.125 minimum reference-speed practice
772
+
773
+ # --- transonic -------------------------------------------------------------
774
+ m_eff = effective_mach(item.cruise_mach, sweep)
775
+ m_dd = drag_divergence_mach(item.t_over_c_wing, cl_cruise, item.supercritical)
776
+ wave_pen = wave_drag_penalty(item.cruise_mach, m_dd)
777
+ m_dd_margin = m_dd - item.cruise_mach
778
+ k_trans = transonic_similarity_parameter(item.cruise_mach, item.t_over_c_wing)
779
+
780
+ # --- aeroelastic screening ---------------------------------------------------
781
+ f_margin, f_ok = flutter_margin(item.flutter_speed_mps, v_dive)
782
+ gust_n = gust_load_factor(rho, v_cruise, item.gust_velocity_mps, a_wing,
783
+ item.w_cruise, s)
784
+ gust_delta_n = gust_n - 1.0
785
+ # swept-back wing: aerodynamic center aft of elastic axis -> no static
786
+ # divergence mechanism (divergence-speed leaf domain, e <= 0).
787
+ divergence_note = ("no static-divergence mechanism for the swept-back wing "
788
+ "(aerodynamic center aft of the elastic axis, "
789
+ "divergence-speed leaf domain e <= 0)")
790
+ divergence_margin_value = None
791
+
792
+ # --- CFD evidence -----------------------------------------------------------
793
+ val_case = VALIDATION_CASES["onera-m6"]
794
+ val_verdict = validation_verdict(item.cfd_validation_run_cd,
795
+ val_case["reference"]["cd"])
796
+ cd_fine, cd_med, cd_coarse = item.cfd_cd_meshes
797
+ mesh_cells_fine, mesh_cells_med, mesh_cells_coarse = item.cfd_mesh_cells
798
+ rich = richardson_extrapolation([cd_fine, cd_med, cd_coarse], 2.0)
799
+ cfd_vs_buildup = relative_error(cd_fine, cd_cruise)
800
+ cl_error = relative_error(cl_cruise, 0.5) # target cruise CL for the CFD case
801
+
802
+ margins = [
803
+ {"margin": "Flutter margin (V_F / V_D)", "value": f_margin,
804
+ "requirement": ">= 1.15 x V_D (flutter-speed leaf clearance practice)",
805
+ "source": "stage 10: V-g typical-section flutter speed %s m/s vs design "
806
+ "dive speed %s m/s" % (_fmt(item.flutter_speed_mps, 1),
807
+ _fmt(v_dive, 1))},
808
+ {"margin": "Drag-divergence margin (M_DD - M_cruise)", "value": m_dd_margin,
809
+ "requirement": "> 0 (cruise below drag divergence; wave-drag penalty = 0)",
810
+ "source": "stage 8: Korn rule M_DD = %s at cruise C_L %s"
811
+ % (_fmt(m_dd, 4), _fmt(cl_cruise, 4))},
812
+ {"margin": "Stall margin, clean (CL_max clean / C_L cruise)",
813
+ "value": clmax_wing_clean / cl_cruise,
814
+ "requirement": "> 1 (no stall at the cruise point, sea-level MTOW "
815
+ "V_s = %s m/s)" % _fmt(vs_clean, 1),
816
+ "source": "stage 4: wing CLmax = 0.9 x section clmax x cos(sweep)"},
817
+ {"margin": "High-lift CL_max gain (landing - clean)",
818
+ "value": clmax_wing_land - clmax_wing_clean,
819
+ "requirement": "> 0 (flap + slat increments from high-lift leaf)",
820
+ "source": "stage 4: slotted flap dcl %s + slat dcl %s (section), "
821
+ "3D/sweep factor 0.9 x cos(%s deg)"
822
+ % (_fmt(dcl_flap_land, 3), _fmt(dcl_slat, 3), _fmt(sweep, 1))},
823
+ {"margin": "L/D efficiency margin ((L/D_max - L/D_cruise) / L/D_max)",
824
+ "value": (ld_max - ld_cruise) / ld_max if ld_max > 0 else 0.0,
825
+ "requirement": ">= 0 (cruise lift coefficient within the polar peak)",
826
+ "source": "stage 5: polar peak L/D %s at C_L %s vs cruise L/D %s"
827
+ % (_fmt(ld_max, 2), _fmt(cl_opt, 3), _fmt(ld_cruise, 2))},
828
+ ]
829
+ margins_all_ok = all(m["value"] >= 0 for m in margins) and f_ok and m_dd_margin > 0
830
+
831
+ model = {
832
+ "document_type": "Aerodynamic Design Report",
833
+ "status": "draft-for-review",
834
+ "generated": item.generated or _today(),
835
+ "aircraft": item.name,
836
+ "description": item.description,
837
+ # ---- configuration / geometry
838
+ "s_ref": s, "aspect_ratio": ar, "oswald_e": e, "sweep_deg": sweep,
839
+ "span": span, "mac": mac, "taper_ratio": item.taper_ratio,
840
+ "t_over_c_wing": item.t_over_c_wing,
841
+ "airfoil": item.airfoil,
842
+ "supercritical": item.supercritical,
843
+ "cl_max_section": item.cl_max_section,
844
+ "section_alpha_zero_deg": item.section_alpha_zero_deg,
845
+ "w_cruise": item.w_cruise, "w_mtow": item.w_mtow, "w_land": item.w_land,
846
+ "w_s_cruise": w_s_cruise,
847
+ # ---- flight conditions
848
+ "cruise_mach": item.cruise_mach,
849
+ "cruise_altitude_m": item.cruise_altitude_m,
850
+ "atmosphere": atm,
851
+ "v_cruise": v_cruise, "q_cruise": q_cruise, "cl_cruise": cl_cruise,
852
+ "v_dive": v_dive, "q_dive": q_dive,
853
+ "dive_mach": item.dive_mach,
854
+ # ---- drag buildup
855
+ "buildup": buildup, "cd0": cd0,
856
+ # ---- polar
857
+ "polar": polar, "cd_cruise": cd_cruise, "ld_cruise": ld_cruise,
858
+ "cl_opt": cl_opt, "ld_max": ld_max, "k": k,
859
+ "alpha_cruise_deg": alpha_cruise_deg,
860
+ # ---- lift curve slope
861
+ "slope": slope, "a_wing": a_wing, "a_wing_per_deg": a_wing_per_deg,
862
+ "cl_per_deg": cl_per_deg,
863
+ # ---- airfoil operating range
864
+ "re_range": re_range, "re_cruise": re_range[1], "re_low": re_range[0],
865
+ # ---- high lift
866
+ "high_lift": {"flap_type": item.flap_type,
867
+ "flap_defl_to_deg": item.flap_defl_to_deg,
868
+ "flap_defl_land_deg": item.flap_defl_land_deg,
869
+ "flap_chord_frac": item.flap_chord_frac,
870
+ "flap_span_frac": item.flap_span_frac,
871
+ "slat_span_frac": item.slat_span_frac,
872
+ "dcl_flap_to": dcl_flap_to, "dcl_flap_land": dcl_flap_land,
873
+ "dcl_slat": dcl_slat,
874
+ "clmax_sec_clean": clmax_sec_clean,
875
+ "clmax_sec_to": clmax_sec_to,
876
+ "clmax_sec_land": clmax_sec_land,
877
+ "clmax_wing_clean": clmax_wing_clean,
878
+ "clmax_wing_to": clmax_wing_to,
879
+ "clmax_wing_land": clmax_wing_land,
880
+ "vs_clean": vs_clean, "vs_to": vs_to, "vs_land": vs_land,
881
+ "v_ref_land": v_ref_land},
882
+ # ---- transonic
883
+ "m_eff": m_eff, "m_dd": m_dd, "m_dd_margin": m_dd_margin,
884
+ "wave_drag_penalty": wave_pen, "k_transonic": k_trans,
885
+ # ---- aeroelastic
886
+ "flutter_speed": item.flutter_speed_mps,
887
+ "flutter_margin": f_margin, "flutter_ok": f_ok,
888
+ "divergence_note": divergence_note,
889
+ "divergence_margin": divergence_margin_value,
890
+ "gust_n": gust_n, "gust_delta_n": gust_delta_n,
891
+ "gust_velocity": item.gust_velocity_mps,
892
+ # ---- CFD evidence
893
+ "cfd": {"validation_case": val_case["name"],
894
+ "validation_conditions": val_case["conditions"],
895
+ "validation_reference": val_case["reference"],
896
+ "validation_run_cd": item.cfd_validation_run_cd,
897
+ "validation": val_verdict,
898
+ "meshes_cells": [mesh_cells_fine, mesh_cells_med, mesh_cells_coarse],
899
+ "cd_meshes": [cd_fine, cd_med, cd_coarse],
900
+ "richardson": rich,
901
+ "cfd_vs_buildup_error": cfd_vs_buildup},
902
+ # ---- margins + gates
903
+ "margins": margins,
904
+ "margins_all_ok": margins_all_ok,
905
+ "cl_error_vs_target": cl_error,
906
+ }
907
+ return model
908
+
909
+
910
+ def _lift_slope_for_alpha(item: AircraftItem) -> float:
911
+ """Low-speed finite+swept wing slope (per radian) used for the linear
912
+ incidence estimate (M = 0, P-G not applied at cruise M = 0.78)."""
913
+ ar = item.aspect_ratio
914
+ a_finite = SECTION_SLOPE / (1.0 + SECTION_SLOPE / (math.pi * item.oswald_e * ar))
915
+ return a_finite * math.cos(math.radians(item.sweep_deg))
916
+
917
+
918
+ def _stall_clean(item: AircraftItem) -> float:
919
+ clmax = wing_clmax(item.cl_max_section, item.sweep_deg)
920
+ return stall_speed(item.w_mtow, item.s_ref, ISA_RHO0, clmax)
921
+
922
+
923
+ # ---------------------------------------------------------------------------
924
+ # Markdown renderer
925
+ # ---------------------------------------------------------------------------
926
+
927
+ def render_report_markdown(model: Dict) -> str:
928
+ """Render the report content model as the deliverable markdown document."""
929
+ hl = model["high_lift"]
930
+ bu = model["buildup"]
931
+ pol = model["polar"]
932
+ cf = model["cfd"]
933
+ rich = cf["richardson"]
934
+ val = cf["validation"]
935
+ atm = model["atmosphere"]
936
+ lines = [
937
+ "# Aerodynamic Design Report",
938
+ "",
939
+ f"**Aircraft:** {model['aircraft']}",
940
+ f"**Status:** {model['status']} (generated {model['generated']})",
941
+ "",
942
+ "## 1. Configuration summary",
943
+ "",
944
+ (f"- Aircraft: {model['aircraft']}"
945
+ + (f" - {model['description']}" if model["description"] else "")),
946
+ f"- Mission: short/medium-haul transport - cruise M {model['cruise_mach']:.2f} at "
947
+ f"{model['cruise_altitude_m']:.0f} m; dive M {model['dive_mach']:.2f} (V_D "
948
+ f"{model['v_dive']:.1f} m/s TAS); MTOW {model['w_mtow']/1000:.0f} kN, cruise weight "
949
+ f"{model['w_cruise']/1000:.0f} kN",
950
+ f"- Selected airfoil + rationale: {model['airfoil']} (t/c {model['t_over_c_wing']:.3f}), "
951
+ f"Korn drag-divergence benefit over a conventional section; section cl_max "
952
+ f"{model['cl_max_section']:.2f} from the stage-1 XFOIL-style analysis",
953
+ (f"- Wing planform: area {model['s_ref']:.1f} m2 - AR {model['aspect_ratio']:.1f} - "
954
+ f"span {model['span']:.1f} m - MAC {model['mac']:.2f} m - quarter-chord sweep "
955
+ f"{model['sweep_deg']:.0f} deg - taper {model['taper_ratio']:.2f}"),
956
+ "- High-lift system: full-span leading-edge slat + single-slotted trailing-edge "
957
+ "flap (deflections in section 4)",
958
+ "",
959
+ "## 2. Airfoil analysis",
960
+ "",
961
+ f"- Operating Reynolds range (MAC-based, clean wing): {model['re_low']:.2e} "
962
+ f"(sea-level low-speed) to {model['re_cruise']:.2e} (cruise M {model['cruise_mach']:.2f})",
963
+ f"- Section: {model['airfoil']}; thin-airfoil section slope a0 = "
964
+ f"{2.0 * math.pi:.3f}/rad",
965
+ f"- CL_max clean (wing-level): {hl['clmax_wing_clean']:.3f} "
966
+ f"(0.9 x section {hl['clmax_sec_clean']:.2f} x cos(sweep)); with high-lift: "
967
+ f"{hl['clmax_wing_land']:.3f} landing / {hl['clmax_wing_to']:.3f} takeoff",
968
+ (f"- Drag divergence (Korn rule, supercritical t/c {model['t_over_c_wing']:.3f} at "
969
+ f"cruise CL {model['cl_cruise']:.3f}): M_DD = {model['m_dd']:.3f}"),
970
+ "- Source tool + settings: stage-1 XFOIL viscous analysis (transition) on the "
971
+ "candidate sections; tool sanity anchor NACA 0012 at Re = 6e6 (cl ~0.82 at "
972
+ "10 deg, cd0 ~0.0079 band, xfoil-analysis leaf) recorded in the analysis "
973
+ "evidence set",
974
+ "",
975
+ "## 3. Drag buildup",
976
+ "",
977
+ "| Component | Cf | FF | Q | S_wet (m2) | Cd0 term | Basis |",
978
+ "|---|---|---|---|---|---|---|",
979
+ ]
980
+ for c in bu["components"]:
981
+ if c["count"] > 1:
982
+ name = "%s x%d" % (c["name"], c["count"])
983
+ else:
984
+ name = c["name"]
985
+ lines.append(
986
+ f"| {name} | {c['cf']:.5f} | {c['ff']:.4f} | {c['q']:.2f} | "
987
+ f"{c['s_wet']:.1f} | {c['cd']:.6f} | Cf x FF x Q x S_wet/S_ref "
988
+ f"(Re {c['reynolds']:.2e}) |")
989
+ lines += [
990
+ f"- Total Cd0: {model['cd0']:.5f} - Oswald/span efficiency: {model['oswald_e']:.2f} - "
991
+ f"equivalent skin friction Cf_e = {bu['cf_e']:.5f} (Cd0 x S_ref / S_wet_total = "
992
+ f"{bu['s_wet_total']:.0f} m2)",
993
+ (f"- Full polar: CD = {model['cd0']:.5f} + {model['k']:.5f} x CL^2; at cruise "
994
+ f"CL {model['cl_cruise']:.3f}: CD = {model['cd_cruise']:.5f}, L/D = "
995
+ f"{model['ld_cruise']:.2f}; polar peak L/D {model['ld_max']:.2f} at "
996
+ f"CL_opt {model['cl_opt']:.3f}"),
997
+ "",
998
+ "## 4. High-lift assessment",
999
+ "",
1000
+ (f"- Device + deflections: full-span slat (span fraction "
1001
+ f"{hl['slat_span_frac']:.2f}) + {hl['flap_type']} flap (chord fraction "
1002
+ f"{hl['flap_chord_frac']:.2f}, span fraction {hl['flap_span_frac']:.2f}); takeoff "
1003
+ f"{hl['flap_defl_to_deg']:.0f} deg, landing {hl['flap_defl_land_deg']:.0f} deg "
1004
+ f"(max {FLAP_TYPES[hl['flap_type']]['deflection_max_deg']:.0f} deg for the "
1005
+ f"{hl['flap_type']} flap)"),
1006
+ f"- Section increments: flap +{hl['dcl_flap_to']:.3f} (TO) / +{hl['dcl_flap_land']:.3f} "
1007
+ f"(landing), slat +{hl['dcl_slat']:.3f} (K_delta x K_chord x K_span x reference "
1008
+ f"increment, high-lift leaf)",
1009
+ (f"- CL_max with device: takeoff {hl['clmax_wing_to']:.3f}, landing "
1010
+ f"{hl['clmax_wing_land']:.3f} (0.9 x section cl_max x cos(sweep)) vs clean "
1011
+ f"{hl['clmax_wing_clean']:.3f}"),
1012
+ (f"- Stall speeds (sea level): clean {hl['vs_clean']:.1f} m/s at MTOW, takeoff "
1013
+ f"{hl['vs_to']:.1f} m/s at MTOW, landing {hl['vs_land']:.1f} m/s at landing "
1014
+ f"weight; landing reference speed 1.23 x V_S = {hl['v_ref_land']:.1f} m/s "
1015
+ f"(CS/FAR 25.125 minimum reference-speed practice)"),
1016
+ "",
1017
+ "## 5. CFD evidence",
1018
+ "",
1019
+ (f"- Case: {cf['validation_case']} validation + 3-mesh grid study on the "
1020
+ f"transport wing-body at cruise M {model['cruise_mach']:.2f} / CL "
1021
+ f"{model['cl_cruise']:.3f}"),
1022
+ (f"- Validation target: {cf['validation_case']} at M "
1023
+ f"{cf['validation_conditions']['mach']:.2f}, Re "
1024
+ f"{cf['validation_conditions']['reynolds']:.2e}, alpha "
1025
+ f"{cf['validation_conditions']['alpha_deg']:.2f} deg "
1026
+ f"(reference CD {cf['validation_reference']['cd']:.4f}); computed CD "
1027
+ f"{cf['validation_run_cd']:.4f} -> relative error {val['error']*100:.1f}% "
1028
+ f"vs {val['tolerance']*100:.0f}% band: {val['verdict']}"),
1029
+ (f"- Mesh convergence: {cf['meshes_cells'][0]:.1e} / {cf['meshes_cells'][1]:.1e} / "
1030
+ f"{cf['meshes_cells'][2]:.1e} cells (refinement ratio "
1031
+ f"{rich['refinement_ratio']:.1f}); CD {cf['cd_meshes'][0]:.5f} / "
1032
+ f"{cf['cd_meshes'][1]:.5f} / {cf['cd_meshes'][2]:.5f}; apparent order "
1033
+ f"{rich['apparent_order']:.2f}, Richardson-extrapolated CD "
1034
+ f"{rich['extrapolated']:.5f}, GCI {rich['gci']:.5f} "
1035
+ f"({rich['gci']/rich['finest_value']*100:.1f}% of the fine-mesh value)"),
1036
+ (f"- Turbulence model + wall treatment: RANS (SA) on the fine mesh, resolved "
1037
+ f"near-wall layer (y+ <= 1); fine-mesh total drag {cf['cd_meshes'][0]:.5f} vs "
1038
+ f"component-buildup polar {model['cd_cruise']:.5f} at the same condition "
1039
+ f"({cf['cfd_vs_buildup_error']*100:.1f}% apart)"),
1040
+ "",
1041
+ "## 6. Transonic behavior",
1042
+ "",
1043
+ (f"- Effective section Mach at cruise: M cos(sweep) = M {model['cruise_mach']:.2f} x "
1044
+ f"cos({model['sweep_deg']:.0f} deg) = {model['m_eff']:.3f}"),
1045
+ (f"- Drag-divergence Mach (Korn, supercritical): M_DD = 0.95 - t/c "
1046
+ f"({model['t_over_c_wing']:.3f}) - CL/10 ({model['cl_cruise']/10:.4f}) = "
1047
+ f"{model['m_dd']:.4f}"),
1048
+ f"- Wave drag at cruise: {(model['wave_drag_penalty']):.4f} penalty index "
1049
+ f"(0 below M_DD; (M - M_DD)^3 above) - cruise holds "
1050
+ f"{model['m_dd_margin']:.4f} below drag divergence; shock location: no "
1051
+ "terminating shock at cruise (no supercritical pocket closure case)",
1052
+ (f"- Supercritical behavior notes: flat-top section holds the local Mach "
1053
+ f"just-supersonic with a weak terminating shock; transonic similarity "
1054
+ f"parameter K = (1 - M^2)/tau^(2/3) = {model['k_transonic']:.3f}"),
1055
+ "",
1056
+ "## 7. Aeroelastic screening",
1057
+ "",
1058
+ (f"- Flutter speed estimate: {model['flutter_speed']:.1f} m/s (V-g typical-section "
1059
+ f"flutter speed, outboard wing at the dive condition, flutter-speed-prediction "
1060
+ f"leaf analysis) vs V_D {model['v_dive']:.1f} m/s -> margin "
1061
+ f"{model['flutter_margin']:.2f} "
1062
+ f"({'PASS >= 1.15' if model['flutter_ok'] else 'FAIL < 1.15'})"),
1063
+ f"- Static divergence: {model['divergence_note']}",
1064
+ (f"- Gust response (load factor): discrete-gust screening at cruise, U_de = "
1065
+ f"{model['gust_velocity']:.1f} m/s, quasi-steady Delta CL = a x U_de/V -> "
1066
+ f"Delta n = {model['gust_delta_n']:.2f}, n = {model['gust_n']:.2f} g. "
1067
+ f"NOTE: aeroelastic results are SCREENING inputs for the loads/structures "
1068
+ f"role - not final flutter or gust clearance"),
1069
+ "",
1070
+ "## 8. Margin summary",
1071
+ "",
1072
+ "| Margin | Value | Requirement | Source (stage) |",
1073
+ "|---|---|---|---|",
1074
+ ]
1075
+ for m in model["margins"]:
1076
+ lines.append(f"| {m['margin']} | {m['value']:.3f} | {m['requirement']} | {m['source']} |")
1077
+ lines += [
1078
+ "",
1079
+ "---",
1080
+ "*DRAFT - for human aerodynamics lead review. Not an approval document.*",
1081
+ ]
1082
+ return "\n".join(lines)
1083
+
1084
+
1085
+ # ---------------------------------------------------------------------------
1086
+ # Evidence gate checkers
1087
+ # ---------------------------------------------------------------------------
1088
+
1089
+ GATE_CHECKS = {
1090
+ "configuration_identified": "aircraft name and planform numbers present",
1091
+ "numbers_real": "computed aerodynamics numbers are finite and in domain",
1092
+ "flutter_ok": "flutter margin meets the 1.15 clearance practice threshold",
1093
+ "wave_drag_ok": "cruise Mach holds below the drag-divergence Mach (M_DD)",
1094
+ "stall_ok": "cruise CL below clean CLmax; high-lift lowers the stall speed",
1095
+ "polar_consistent": "cruise CD above Cd0 and L/D positive; CD0 > 0",
1096
+ "margins_traced": "every margin row names its workflow stage source",
1097
+ "sign_off_honest": "report is marked draft-for-review, not approval",
1098
+ }
1099
+
1100
+
1101
+ def check_report(model: Dict) -> Dict:
1102
+ """Run the evidence gates against a report content model."""
1103
+ import math as _m
1104
+ def _num(key):
1105
+ v = model.get(key)
1106
+ return isinstance(v, (int, float)) and _m.isfinite(v)
1107
+
1108
+ hi = model.get("high_lift", {})
1109
+ cd0 = model.get("cd0")
1110
+ cl_cruise = model.get("cl_cruise")
1111
+ cd_cruise = model.get("cd_cruise")
1112
+ ld_cruise = model.get("ld_cruise")
1113
+ clmax_clean = hi.get("clmax_wing_clean")
1114
+ vs_clean = hi.get("vs_clean")
1115
+ vs_land = hi.get("vs_land")
1116
+ margins = model.get("margins", [])
1117
+ results = {
1118
+ "configuration_identified": (bool(model.get("aircraft"))
1119
+ and _num("s_ref") and _num("aspect_ratio")
1120
+ and _num("mac")),
1121
+ "numbers_real": all([
1122
+ _num("cl_cruise"), _num("cd0"), _num("cd_cruise"), _num("ld_cruise"),
1123
+ _num("m_dd"), _num("flutter_margin"), _num("gust_n"),
1124
+ cd0 is not None and cd0 > 0.0,
1125
+ cl_cruise is not None and cl_cruise > 0.0,
1126
+ model.get("m_dd") is not None and 0.5 < model["m_dd"] < 0.95,
1127
+ clmax_clean is not None and clmax_clean > 0.0,
1128
+ ]),
1129
+ "flutter_ok": bool(model.get("flutter_ok"))
1130
+ and model.get("flutter_margin", 0.0) >= FLUTTER_MARGIN_REQUIRED,
1131
+ "wave_drag_ok": (model.get("m_dd_margin") is not None
1132
+ and model["m_dd_margin"] > 0.0),
1133
+ "stall_ok": (clmax_clean is not None and cl_cruise is not None
1134
+ and clmax_clean > cl_cruise
1135
+ and vs_land is not None and vs_clean is not None
1136
+ and vs_land < vs_clean),
1137
+ "polar_consistent": (cd0 is not None and cd_cruise is not None
1138
+ and cd_cruise > cd0
1139
+ and ld_cruise is not None and ld_cruise > 0.0
1140
+ and model.get("ld_max", 0.0) >= ld_cruise),
1141
+ "margins_traced": (len(margins) >= 4
1142
+ and all("stage " in str(m.get("source", ""))
1143
+ for m in margins)),
1144
+ "sign_off_honest": model.get("status") == "draft-for-review",
1145
+ }
1146
+ results["all_pass"] = all(results.values())
1147
+ return results
1148
+
1149
+
1150
+ def check_report_markdown(md_text: str) -> Dict:
1151
+ """Gate-check the rendered markdown deliverable (zero blanks allowed)."""
1152
+ low = md_text.lower().replace("_", "").replace("-", " ")
1153
+ checks = {
1154
+ "has_title": "aerodynamic design report" in md_text.lower(),
1155
+ "has_aircraft": bool(re.search(r"\*\*aircraft:\*\*", md_text, re.I)),
1156
+ "has_sections": all(re.search(rf"^## {n}\. ", md_text, re.M)
1157
+ for n in range(1, 9)),
1158
+ "has_key_numbers": all(key in low for key in (
1159
+ "drag divergence mach", "flutter margin", "clmax", "l/d")),
1160
+ "has_no_blanks": not re.search(r"_{3,}|TBD|\bOPEN item\b", md_text),
1161
+ "has_draft_marker": "draft" in md_text.lower(),
1162
+ "has_not_approval": "not an approval" in md_text.lower(),
1163
+ }
1164
+ checks["all_pass"] = all(checks.values())
1165
+ return checks
1166
+
1167
+
1168
+ def validate_deliverable_text(md_text: str) -> Dict:
1169
+ """Public entry point used by gate tooling: check a report document."""
1170
+ return check_report_markdown(md_text)
1171
+
1172
+
1173
+ # ---------------------------------------------------------------------------
1174
+ # Example item + worked example
1175
+ # ---------------------------------------------------------------------------
1176
+
1177
+ def example_item() -> AircraftItem:
1178
+ """Reference item: a typical twin-jet short/medium-haul transport."""
1179
+ return AircraftItem(
1180
+ name="TAC-150 twin-jet transport (reference configuration)",
1181
+ description="150-seat short/medium-haul transport, wing-body-tail "
1182
+ "configuration, underlying podded engines",
1183
+ s_ref=122.6, aspect_ratio=8.0, sweep_deg=25.0, taper_ratio=0.30,
1184
+ t_over_c_wing=0.105, oswald_e=0.80,
1185
+ airfoil="representative supercritical section",
1186
+ supercritical=True, cl_max_section=1.55, section_alpha_zero_deg=-2.0,
1187
+ w_cruise=622000.0, w_mtow=735000.0, w_land=633000.0,
1188
+ cruise_mach=0.78, cruise_altitude_m=10668.0, dive_mach=0.89,
1189
+ re_transition=5e5,
1190
+ components=default_components(),
1191
+ flap_type="slotted", flap_chord_frac=0.25, flap_span_frac=0.70,
1192
+ flap_defl_to_deg=15.0, flap_defl_land_deg=35.0,
1193
+ slat_device="slat", slat_span_frac=0.85,
1194
+ flutter_speed_mps=364.2, gust_velocity_mps=12.5,
1195
+ cfd_mesh_cells=(19.2e6, 4.8e6, 1.2e6),
1196
+ cfd_cd_meshes=(0.02840, 0.02910, 0.03060),
1197
+ cfd_validation_run_cd=0.0169,
1198
+ )
1199
+
1200
+
1201
+ def example_report_markdown() -> str:
1202
+ return render_report_markdown(build_report(example_item()))
1203
+
1204
+
1205
+ if __name__ == "__main__":
1206
+ item = example_item()
1207
+ model = build_report(item)
1208
+ md = render_report_markdown(model)
1209
+ print("AIRCRAFT: %s" % model["aircraft"])
1210
+ print("CRUISE CL: %.4f CD0: %.5f CD_cruise: %.5f L/D: %.2f (max %.2f)"
1211
+ % (model["cl_cruise"], model["cd0"], model["cd_cruise"],
1212
+ model["ld_cruise"], model["ld_max"]))
1213
+ print("RE cruise: %.2e M_DD: %.4f M_eff: %.4f"
1214
+ % (model["re_cruise"], model["m_dd"], model["m_eff"]))
1215
+ print("FLUTTER MARGIN: %.3f ok=%s GUST n: %.3f"
1216
+ % (model["flutter_margin"], model["flutter_ok"], model["gust_n"]))
1217
+ print("GATES: %s" % check_report(model))
1218
+ print("RENDERED: %d chars" % len(md))