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,1278 @@
1
+ #!/usr/bin/env python3
2
+ """aircraft_design_core.py - Aircraft Conceptual Design Engineer executable core.
3
+
4
+ This is the role's ENGINE: given a top-level aircraft requirement (payload,
5
+ range, cruise Mach, takeoff field length) it runs the real conceptual sizing
6
+ loop - constraint analysis (T/W vs W/S matching chart), the sizing-mission
7
+ fuel-fraction chain (Breguet segments), the class-I empty-weight fraction
8
+ estimate, and the MTOW convergence iteration - and BUILDS the concept design
9
+ package. It also gate-checks the deliverable. Standalone: stdlib only, no
10
+ AeroSkills checkout needed.
11
+
12
+ Every formula below mirrors the real Aero Skills vehicle-design logic
13
+ (constraint-analysis, ws-tw-trade, sizing-mission-profile, tow-estimation,
14
+ weight-estimation, engine-sizing leaves; public methodology, summary-not-copy):
15
+
16
+ - Stall (max wing loading): W/S = 0.5*rho*CLmax*VS^2
17
+ - Takeoff distance: T/W = 1.21*(W/S)/(rho*g*CLmax*s_TO)
18
+ - Climb gradient: T/W = 1/LD + gamma (OEI gradient converted
19
+ to the installed basis by n/(n-1))
20
+ - Cruise: T/W = 0.5*rho*V^2*CD0/(W/S) + k*(W/S)/(0.5*rho*V^2)
21
+ - Cruise fuel (Breguet range): W_f = W0*(1 - exp(-R/(V*TSFC*L/D)))
22
+ - Hold fuel (Breguet endurance):W_f = W0*(1 - exp(-E*TSFC/(L/D)))
23
+ - Reserve rule hold45_5pct: 45 min hold at 1500 ft + 5% of trip fuel
24
+ - Fuel-fraction sizing: W0 = payload/(1 - W_e/W0 - W_f/W0), iterated
25
+ until successive estimates change < 0.5%
26
+ - Class-I empty-weight fit: transport band 0.42-0.55 of MTOW; the
27
+ representative fraction is the band midpoint
28
+
29
+ Constraint units are SI (W/S in N/m^2, speeds m/s, rho kg/m^3, g 9.80665);
30
+ mission-fuel units are the US customary transport-sizing convention
31
+ (weight lb, range nm, speed kt, time hr, TSFC lb/lbf/hr). Invalid inputs
32
+ raise ValueError throughout.
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import math
37
+ from dataclasses import dataclass
38
+ from datetime import date
39
+
40
+ def _today() -> str:
41
+ import os
42
+ from datetime import date
43
+ return os.environ.get("ROLE_GEN_DATE", date.today().isoformat())
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Physical constants and unit conversions (public standard values)
47
+ # ---------------------------------------------------------------------------
48
+ G = 9.80665 # standard gravity, m/s^2
49
+ RHO_SL = 1.225 # sea-level ISA air density, kg/m^3
50
+ T0_ISA = 288.15 # sea-level ISA temperature, K
51
+ LAPSE = 0.0065 # ISA troposphere temperature lapse rate, K/m
52
+ DENSITY_EXPONENT = 4.255879 # g0/(R*L) - 1 for the ISA density ratio
53
+ TROPOPAUSE = 11000.0 # troposphere top, m
54
+ GAMMA_AIR = 1.4 # ratio of specific heats
55
+ R_AIR = 287.05 # specific gas constant, J/(kg*K)
56
+
57
+ LB_TO_KG = 0.45359237
58
+ KG_TO_LB = 1.0 / LB_TO_KG
59
+ N_PER_LBF = 4.4482216
60
+ LBF_TO_N = N_PER_LBF
61
+ KT_TO_MPS = 0.514444
62
+ MPS_TO_KT = 1.0 / KT_TO_MPS
63
+ NM2_TO_PSF = 0.0208854342 # N/m^2 -> lb/ft^2 (1 N = 0.224808943 lbf, 1 m = 3.28084 ft)
64
+
65
+ # Engine sizing leaf: SFC conversion lb/lbf/hr -> kg/(N*s).
66
+ LB_PER_LBF_HR_TO_KG_PER_N_S = LB_TO_KG / (N_PER_LBF * 3600.0)
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Domain tables (from the Aero Skills weight-estimation leaf)
70
+ # ---------------------------------------------------------------------------
71
+ # Class-I empty-weight fraction bands per category (typical historical
72
+ # values; validate against program data). The role's sizing fit uses the
73
+ # band midpoint as the representative fraction for the category.
74
+ EMPTY_WEIGHT_FRACTION_BANDS = {
75
+ "transport": (0.42, 0.55),
76
+ "general-aviation": (0.55, 0.68),
77
+ "turboprop": (0.50, 0.62),
78
+ }
79
+
80
+ # Growth allowance by design phase (Aero Skills mass-budget leaf): common
81
+ # weight-engineering practice - ~10% conceptual, ~6% preliminary, ~3% detailed.
82
+ GROWTH_ALLOWANCE_BY_PHASE = {
83
+ "conceptual": 0.10,
84
+ "preliminary": 0.06,
85
+ "detailed": 0.03,
86
+ }
87
+
88
+ # Reserve rules supported by the sizing-mission leaf.
89
+ RESERVE_RULES = ("hold45_5pct", "far121")
90
+
91
+ # Default segment types in mission order (sizing-mission-profile leaf).
92
+ SEGMENT_TYPES = ("taxi", "takeoff", "climb", "cruise", "descent",
93
+ "loiter", "reserve")
94
+
95
+ # Matching-chart constraint names.
96
+ CONSTRAINT_NAMES = ("takeoff", "climb", "cruise", "maneuvering")
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # ISA atmosphere helpers (public ISA standard, troposphere; constants above)
101
+ # ---------------------------------------------------------------------------
102
+
103
+ def isa_density_ratio(altitude_m):
104
+ """ISA air density ratio sigma = rho/rho0 at altitude (0..11000 m).
105
+
106
+ sigma = (1 - L*h/T0)**(g0/(R*L) - 1) in the troposphere, the same model
107
+ the engine-sizing leaf uses. Raises ValueError outside the troposphere.
108
+ """
109
+ if altitude_m < 0 or altitude_m > TROPOPAUSE:
110
+ raise ValueError("altitude must be within 0..%g m, got %r"
111
+ % (TROPOPAUSE, altitude_m))
112
+ temp_ratio = 1.0 - LAPSE * altitude_m / T0_ISA
113
+ return temp_ratio ** DENSITY_EXPONENT
114
+
115
+
116
+ def isa_temperature(altitude_m):
117
+ """ISA temperature (K) in the troposphere: T = T0 - L*h."""
118
+ if altitude_m < 0 or altitude_m > TROPOPAUSE:
119
+ raise ValueError("altitude must be within 0..%g m, got %r"
120
+ % (TROPOPAUSE, altitude_m))
121
+ return T0_ISA - LAPSE * altitude_m
122
+
123
+
124
+ def isa_speed_of_sound(altitude_m):
125
+ """ISA speed of sound (m/s) at altitude: a = sqrt(gamma*R*T)."""
126
+ return math.sqrt(GAMMA_AIR * R_AIR * isa_temperature(altitude_m))
127
+
128
+
129
+ def isa_density(altitude_m):
130
+ """ISA air density (kg/m^3) at altitude in the troposphere."""
131
+ return RHO_SL * isa_density_ratio(altitude_m)
132
+
133
+
134
+ def mach_to_kt(mach, altitude_m):
135
+ """True airspeed in knots from cruise Mach and ISA altitude."""
136
+ return isa_speed_of_sound(altitude_m) * mach * MPS_TO_KT
137
+
138
+
139
+ def _require_positive(value, name):
140
+ if value is None or value <= 0:
141
+ raise ValueError("%s must be positive, got %r" % (name, value))
142
+ return value
143
+
144
+
145
+ # ---------------------------------------------------------------------------
146
+ # Constraint analysis (mirrors constraint-analysis / ws-tw-trade leaves, SI)
147
+ # ---------------------------------------------------------------------------
148
+
149
+ def stall_max_ws(VS, CLmax, rho=RHO_SL):
150
+ """Maximum wing loading allowed by the stall speed (N/m^2).
151
+
152
+ W/S = 0.5*rho*CLmax*VS^2 from lift equal to weight at the stall speed.
153
+ """
154
+ if VS <= 0:
155
+ raise ValueError("stall speed VS must be positive, got %r" % (VS,))
156
+ if CLmax <= 0:
157
+ raise ValueError("CLmax must be positive, got %r" % (CLmax,))
158
+ if rho <= 0:
159
+ raise ValueError("air density rho must be positive, got %r" % (rho,))
160
+ return 0.5 * rho * CLmax * VS * VS
161
+
162
+
163
+ def takeoff_constraint_tw(W_S, rho, CLmax, s_TO):
164
+ """Required thrust to weight from the takeoff distance (unitless).
165
+
166
+ T/W = 1.21*(W/S)/(rho*g*CLmax*s_TO); W/S in N/m^2, s_TO in m.
167
+ """
168
+ if W_S <= 0:
169
+ raise ValueError("wing loading W/S must be positive, got %r" % (W_S,))
170
+ if rho <= 0:
171
+ raise ValueError("air density rho must be positive, got %r" % (rho,))
172
+ if CLmax <= 0:
173
+ raise ValueError("CLmax must be positive, got %r" % (CLmax,))
174
+ if s_TO <= 0:
175
+ raise ValueError("takeoff distance s_TO must be positive, got %r" % (s_TO,))
176
+ return 1.21 * W_S / (rho * G * CLmax * s_TO)
177
+
178
+
179
+ def climb_constraint_tw(LD, gamma_rad):
180
+ """Required thrust to weight from the climb gradient (unitless).
181
+
182
+ T/W = 1/LD + gamma_rad: the level-flight drag term plus the small-angle
183
+ climb term (excess thrust fraction equals the climb gradient).
184
+ """
185
+ if LD <= 0:
186
+ raise ValueError("lift-to-drag ratio LD must be positive, got %r" % (LD,))
187
+ if gamma_rad < 0:
188
+ raise ValueError("climb gradient must be non-negative, got %r" % (gamma_rad,))
189
+ return 1.0 / LD + gamma_rad
190
+
191
+
192
+ def cruise_constraint_tw(W_S, V, rho, CD0, k):
193
+ """Required thrust to weight at cruise speed (unitless).
194
+
195
+ T/W = q*CD0/(W/S) + k*(W/S)/q with q = 0.5*rho*V^2 and k = 1/(pi*e*AR).
196
+ """
197
+ if W_S <= 0:
198
+ raise ValueError("wing loading W/S must be positive, got %r" % (W_S,))
199
+ if V <= 0:
200
+ raise ValueError("cruise speed V must be positive, got %r" % (V,))
201
+ if rho <= 0:
202
+ raise ValueError("air density rho must be positive, got %r" % (rho,))
203
+ if CD0 <= 0:
204
+ raise ValueError("CD0 must be positive, got %r" % (CD0,))
205
+ if k <= 0:
206
+ raise ValueError("induced drag factor k must be positive, got %r" % (k,))
207
+ q = 0.5 * rho * V * V
208
+ return q * CD0 / W_S + k * W_S / q
209
+
210
+
211
+ def maneuvering_constraint_tw(W_S, V, rho, CD0, k, n):
212
+ """Required thrust to weight in a level turn at load factor n (unitless)."""
213
+ if n <= 0:
214
+ raise ValueError("load factor n must be positive, got %r" % (n,))
215
+ q = 0.5 * rho * V * V
216
+ if W_S <= 0:
217
+ raise ValueError("wing loading W/S must be positive, got %r" % (W_S,))
218
+ if V <= 0:
219
+ raise ValueError("turn speed V must be positive, got %r" % (V,))
220
+ if rho <= 0:
221
+ raise ValueError("air density rho must be positive, got %r" % (rho,))
222
+ if CD0 <= 0:
223
+ raise ValueError("CD0 must be positive, got %r" % (CD0,))
224
+ if k <= 0:
225
+ raise ValueError("induced drag factor k must be positive, got %r" % (k,))
226
+ q = 0.5 * rho * V * V
227
+ return q * CD0 / W_S + k * n * n * W_S / q
228
+
229
+
230
+ def constraint_tw(constraint, **params):
231
+ """Required thrust to weight for one named matching-chart constraint.
232
+
233
+ constraint is one of CONSTRAINT_NAMES:
234
+ - 'takeoff': params W_S (N/m^2), rho (default 1.225), CLmax, s_TO (m)
235
+ - 'climb': params LD, gamma (rad). With 'engines' = n >= 2 the OEI
236
+ requirement is converted to the installed (all-engine)
237
+ basis by the factor n/(n-1) - with one engine inoperative
238
+ only (n-1)/n of installed thrust is available.
239
+ - 'cruise': params W_S, V (m/s), rho, CD0, k
240
+ - 'maneuvering': params W_S, V (m/s), rho, CD0, k, n
241
+
242
+ Raises ValueError for an unknown constraint or invalid parameters.
243
+ """
244
+ key = str(constraint).strip().lower()
245
+ if key == "takeoff":
246
+ return takeoff_constraint_tw(
247
+ _require_positive(params.get("W_S"), "W_S"),
248
+ _require_positive(params.get("rho", RHO_SL), "rho"),
249
+ _require_positive(params.get("CLmax"), "CLmax"),
250
+ _require_positive(params.get("s_TO"), "s_TO"))
251
+ if key == "climb":
252
+ raw = climb_constraint_tw(_require_positive(params.get("LD"), "LD"),
253
+ _require_positive(params.get("gamma"), "gamma"))
254
+ engines = params.get("engines")
255
+ if engines is not None:
256
+ if engines < 2:
257
+ raise ValueError("engines must be >= 2 for the OEI basis "
258
+ "conversion, got %r" % (engines,))
259
+ raw *= engines / (engines - 1.0)
260
+ return raw
261
+ if key == "cruise":
262
+ return cruise_constraint_tw(
263
+ _require_positive(params.get("W_S"), "W_S"),
264
+ _require_positive(params.get("V"), "V"),
265
+ _require_positive(params.get("rho"), "rho"),
266
+ _require_positive(params.get("CD0"), "CD0"),
267
+ _require_positive(params.get("k"), "k"))
268
+ if key == "maneuvering":
269
+ return maneuvering_constraint_tw(
270
+ _require_positive(params.get("W_S"), "W_S"),
271
+ _require_positive(params.get("V"), "V"),
272
+ _require_positive(params.get("rho"), "rho"),
273
+ _require_positive(params.get("CD0"), "CD0"),
274
+ _require_positive(params.get("k"), "k"),
275
+ _require_positive(params.get("n"), "n"))
276
+ raise ValueError("unknown constraint %r, expected one of %s"
277
+ % (constraint, ", ".join(CONSTRAINT_NAMES)))
278
+
279
+
280
+ def induced_drag_factor(oswald_e, aspect_ratio):
281
+ """Induced drag factor k = 1/(pi*e*AR)."""
282
+ if oswald_e <= 0:
283
+ raise ValueError("Oswald efficiency must be positive, got %r" % (oswald_e,))
284
+ if aspect_ratio <= 0:
285
+ raise ValueError("aspect ratio must be positive, got %r" % (aspect_ratio,))
286
+ return 1.0 / (math.pi * oswald_e * aspect_ratio)
287
+
288
+
289
+ def constraint_values_at(ws_nm2, item):
290
+ """Dict {name: required T/W} for every active constraint at wing loading
291
+ ws_nm2 (N/m^2), evaluated from the item's design inputs on the installed
292
+ (all-engines) basis with cruise at the item's cruise Mach/altitude.
293
+ """
294
+ rho_cr = isa_density(item.cruise_alt_m)
295
+ v_cr = isa_speed_of_sound(item.cruise_alt_m) * item.cruise_mach
296
+ k = induced_drag_factor(item.oswald_e, item.aspect_ratio)
297
+ values = {
298
+ "takeoff": takeoff_constraint_tw(ws_nm2, item.rho_sl, item.clmax_takeoff,
299
+ item.tofl_m),
300
+ "climb": constraint_tw("climb", LD=item.climb_ld,
301
+ gamma=item.climb_gradient,
302
+ engines=item.n_engines),
303
+ "cruise": cruise_constraint_tw(ws_nm2, v_cr, rho_cr, item.cruise_cd0, k),
304
+ }
305
+ if item.maneuver_load_factor is not None:
306
+ values["maneuvering"] = maneuvering_constraint_tw(
307
+ ws_nm2, v_cr, rho_cr, item.cruise_cd0, k, item.maneuver_load_factor)
308
+ return values
309
+
310
+
311
+ def min_tw_and_binding(ws_nm2, item):
312
+ """Minimum feasible T/W at W/S: max over the active constraint curves,
313
+ with the name of the binding constraint (ties keep first in order)."""
314
+ values = constraint_values_at(ws_nm2, item)
315
+ best = None
316
+ binding = None
317
+ for name, value in values.items():
318
+ if best is None or value > best:
319
+ best = value
320
+ binding = name
321
+ return {"ws_nm2": ws_nm2, "min_tw": best, "binding_constraint": binding,
322
+ "constraints": values}
323
+
324
+
325
+ def _feasible_boundary(item, n_points=9):
326
+ """Sample the feasible-region lower bound (W/S, T/W) pairs: at each wing
327
+ loading the lower bound is the maximum required T/W over the active
328
+ constraint curves. Samples the range up to the stall-limited W/S."""
329
+ ws_stall = stall_max_ws(item.stall_vs_mps, item.clmax_landing, item.rho_sl)
330
+ lo = 0.25 * ws_stall
331
+ pairs = []
332
+ for i in range(n_points):
333
+ ws = lo + (ws_stall - lo) * i / (n_points - 1.0)
334
+ tw = max(constraint_values_at(ws, item).values())
335
+ pairs.append((ws, tw))
336
+ return pairs
337
+
338
+
339
+ def select_design_point(item):
340
+ """Choose the (W/S, T/W) design point on the matching chart.
341
+
342
+ If item.design_ws_nm2 is set, that wing loading is the engineer's choice
343
+ from requirement framing and the chart evaluates the required T/W there
344
+ (the feasible lower bound). Otherwise the chart picks the W/S that
345
+ minimizes the required T/W; when the requirement is flat over a range of
346
+ wing loadings, the largest feasible W/S (smallest wing) is chosen. The
347
+ stall constraint caps the wing loading in both cases.
348
+ """
349
+ ws_stall = stall_max_ws(item.stall_vs_mps, item.clmax_landing, item.rho_sl)
350
+ if item.design_ws_nm2 is not None:
351
+ ws = _require_positive(item.design_ws_nm2, "design wing loading W/S")
352
+ if ws > ws_stall * (1.0 + 1e-9):
353
+ raise ValueError("design wing loading %g N/m^2 exceeds the stall "
354
+ "limit %g N/m^2" % (ws, ws_stall))
355
+ info = min_tw_and_binding(ws, item)
356
+ else:
357
+ # minimize the lower bound over the feasible wing-loading range
358
+ lo = 0.25 * ws_stall
359
+ best_ws, best_tw = lo, None
360
+ for i in range(401):
361
+ cand = lo + (ws_stall - lo) * i / 400.0
362
+ tw = max(constraint_values_at(cand, item).values())
363
+ if best_tw is None or tw < best_tw - 1e-12:
364
+ best_ws, best_tw = cand, tw
365
+ # ties: smallest wing (largest feasible W/S) at the minimum T/W
366
+ ws = best_ws if best_tw is not None else lo
367
+ if best_tw is not None:
368
+ for i in range(400, -1, -1):
369
+ cand = lo + (ws_stall - lo) * i / 400.0
370
+ if abs(max(constraint_values_at(cand, item).values())
371
+ - best_tw) < 1e-12:
372
+ ws = cand
373
+ break
374
+ info = min_tw_and_binding(ws, item)
375
+ return {"ws_stall_max": ws_stall, "ws_nm2": info["ws_nm2"],
376
+ "tw_required": info["min_tw"], "binding_constraint": info["binding_constraint"],
377
+ "constraints": info["constraints"]}
378
+
379
+
380
+ # ---------------------------------------------------------------------------
381
+ # Sizing-mission fuel fractions (mirrors sizing-mission-profile leaf)
382
+ # ---------------------------------------------------------------------------
383
+
384
+ def breguet_cruise_fuel_fraction(R_nm, V_kt, TSFC, LD):
385
+ """Cruise fuel fraction of segment start weight (Breguet range equation).
386
+
387
+ W_f/W = 1 - exp(-R/(V*TSFC*L/D)) with R in nm, V in kt, TSFC in
388
+ lb/lbf/hr, L/D unitless - the leaf's exact model.
389
+ """
390
+ _require_positive(R_nm, "range R_nm")
391
+ _require_positive(V_kt, "cruise speed V_kt")
392
+ _require_positive(TSFC, "thrust specific fuel consumption TSFC")
393
+ _require_positive(LD, "lift-to-drag ratio LD")
394
+ return 1.0 - math.exp(-R_nm / (V_kt * TSFC * LD))
395
+
396
+
397
+ def breguet_loiter_fuel_fraction(E_hr, TSFC, LD):
398
+ """Loiter/hold fuel fraction of segment start weight (Breguet endurance).
399
+
400
+ W_f/W = 1 - exp(-E*TSFC/(L/D)) with E in hours, TSFC in lb/lbf/hr.
401
+ """
402
+ _require_positive(E_hr, "loiter endurance E_hr")
403
+ _require_positive(TSFC, "thrust specific fuel consumption TSFC")
404
+ _require_positive(LD, "lift-to-drag ratio LD")
405
+ return 1.0 - math.exp(-E_hr * TSFC / LD)
406
+
407
+
408
+ def segment_fuel_fraction(seg_type, params, W_start=None):
409
+ """Fuel burned in one mission segment as a fraction of its start weight.
410
+
411
+ seg_type is one of SEGMENT_TYPES; params carries the segment's fuel model
412
+ (see the sizing-mission-profile leaf):
413
+ - taxi/takeoff/descent: {'fuel_flow': lb/hr, 'time': hr} -> absolute fuel,
414
+ so the fraction needs W_start (lb) and is W0-dependent.
415
+ - climb: {'fraction': of start weight} or {'fuel_flow','time'}.
416
+ - cruise: {'R_nm','V_kt','TSFC','LD'} (Breguet range).
417
+ - loiter/reserve: {'E_hr','TSFC','LD'} (Breguet endurance).
418
+ Returns the fraction of the segment start weight burned (unitless).
419
+ """
420
+ if seg_type not in SEGMENT_TYPES:
421
+ raise ValueError("unknown segment type %r, expected one of %s"
422
+ % (seg_type, ", ".join(SEGMENT_TYPES)))
423
+ if seg_type in ("taxi", "takeoff", "descent"):
424
+ flow = _require_positive(params.get("fuel_flow"), "fuel_flow")
425
+ time = _require_positive(params.get("time"), "time")
426
+ return flow * time / _require_positive(W_start, "segment start weight")
427
+ if seg_type == "climb":
428
+ if "fraction" in params:
429
+ frac = params["fraction"]
430
+ if frac <= 0 or frac >= 1:
431
+ raise ValueError("climb fuel fraction must be in (0, 1), "
432
+ "got %r" % (frac,))
433
+ return frac
434
+ flow = _require_positive(params.get("fuel_flow"), "fuel_flow")
435
+ time = _require_positive(params.get("time"), "time")
436
+ return flow * time / _require_positive(W_start, "segment start weight")
437
+ if seg_type == "cruise":
438
+ return breguet_cruise_fuel_fraction(
439
+ _require_positive(params.get("R_nm"), "R_nm"),
440
+ _require_positive(params.get("V_kt"), "V_kt"),
441
+ _require_positive(params.get("TSFC"), "TSFC"),
442
+ _require_positive(params.get("LD"), "LD"))
443
+ # loiter and reserve both use the endurance equation
444
+ return breguet_loiter_fuel_fraction(
445
+ _require_positive(params.get("E_hr"), "E_hr"),
446
+ _require_positive(params.get("TSFC"), "TSFC"),
447
+ _require_positive(params.get("LD"), "LD"))
448
+
449
+
450
+ def mission_segments(item):
451
+ """Ordered design-mission segment list for the item (leaf schema).
452
+
453
+ Segment fuel models and reserve inputs follow the transport sizing
454
+ practice encoded in the sizing-mission-profile leaf: taxi/takeoff/climb/
455
+ descent burn fuel flow x time, cruise burns by Breguet range at the
456
+ item's cruise speed, and the reserve is the hold45_5pct rule (45 minute
457
+ hold at 1500 ft at the hold TSFC/L/D plus 5% of the trip fuel).
458
+ """
459
+ v_kt = mach_to_kt(item.cruise_mach, item.cruise_alt_m)
460
+ return [
461
+ {"type": "taxi", "params": {"fuel_flow": item.taxi_flow_lb_hr,
462
+ "time": item.taxi_time_hr}},
463
+ {"type": "takeoff", "params": {"fuel_flow": item.takeoff_flow_lb_hr,
464
+ "time": item.takeoff_time_hr}},
465
+ {"type": "climb", "params": {"fuel_flow": item.climb_flow_lb_hr,
466
+ "time": item.climb_time_hr}},
467
+ {"type": "cruise", "params": {"R_nm": item.range_nm, "V_kt": v_kt,
468
+ "TSFC": item.tsfc_cruise,
469
+ "LD": item.ld_cruise}},
470
+ {"type": "descent", "params": {"fuel_flow": item.descent_flow_lb_hr,
471
+ "time": item.descent_time_hr}},
472
+ ]
473
+
474
+
475
+ def chain_mission(item, W_start):
476
+ """Run the sizing mission for the item at takeoff weight W_start (lb).
477
+
478
+ Each segment burns from the weight remaining after all earlier segments
479
+ (the weight chains through the mission, matching the leaf's
480
+ block_fuel_and_time). Returns per-segment fuel (lb), time (hr), start
481
+ weight, fraction of W_start, plus block fuel/time, landing weight,
482
+ reserve fuel under the item's reserve rule, and required fuel.
483
+ """
484
+ _require_positive(W_start, "takeoff weight W_start")
485
+ segments = mission_segments(item)
486
+ weight = W_start
487
+ rows = []
488
+ times = []
489
+ for seg in segments:
490
+ stype = seg["type"]
491
+ params = seg["params"]
492
+ frac = segment_fuel_fraction(stype, params, weight)
493
+ fuel = frac * weight
494
+ rows.append({"type": stype, "start_weight_lb": weight,
495
+ "fuel_lb": fuel, "fraction_of_start": frac,
496
+ "fraction_of_mtow": fuel / W_start})
497
+ times.append(_segment_time(stype, params, fuel, weight))
498
+ weight -= fuel
499
+ if weight <= 0:
500
+ raise ValueError("mission burns more fuel than the takeoff weight")
501
+ block_fuel = sum(r["fuel_lb"] for r in rows)
502
+ block_time = sum(times)
503
+ reserve = reserve_fuel(item, weight, block_fuel)
504
+ required = block_fuel + reserve
505
+ return {
506
+ "segments": rows,
507
+ "block_fuel_lb": block_fuel,
508
+ "block_time_hr": block_time,
509
+ "landing_weight_lb": weight,
510
+ "reserve_fuel_lb": reserve,
511
+ "required_fuel_lb": required,
512
+ "fuel_fraction": required / W_start,
513
+ }
514
+
515
+
516
+ def _segment_time(seg_type, params, fuel, W_start):
517
+ """Segment time in hours: explicit time param, or R/V for cruise, E for
518
+ loiter/reserve (leaf's _segment_time behaviour)."""
519
+ if params.get("time") is not None:
520
+ t = params["time"]
521
+ if t <= 0:
522
+ raise ValueError("segment time must be positive, got %r" % (t,))
523
+ return t
524
+ if seg_type == "cruise":
525
+ return (_require_positive(params.get("R_nm"), "R_nm")
526
+ / _require_positive(params.get("V_kt"), "V_kt"))
527
+ if seg_type in ("loiter", "reserve"):
528
+ return _require_positive(params.get("E_hr"), "E_hr")
529
+ raise ValueError("segment %r has no time param" % (seg_type,))
530
+
531
+
532
+ def reserve_fuel(item, landing_weight_lb, trip_fuel_lb):
533
+ """Reserve fuel (lb) required by the item's reserve rule, burned from the
534
+ landing weight - mirror of the leaf's reserve_fuel for hold45_5pct
535
+ (45 min hold at 1500 ft plus 5% contingency on trip fuel)."""
536
+ rule = item.reserve_rule
537
+ if rule not in RESERVE_RULES:
538
+ raise ValueError("unknown reserve rule %r, expected one of %s"
539
+ % (rule, ", ".join(RESERVE_RULES)))
540
+ if rule == "hold45_5pct":
541
+ hold = breguet_loiter_fuel_fraction(item.reserve_hold_hr,
542
+ item.tsfc_hold, item.ld_hold)
543
+ return hold * landing_weight_lb + item.reserve_contingency * trip_fuel_lb
544
+ # far121: alternate fuel plus a 30 minute hold above the alternate
545
+ _require_positive(item.far121_alternate_fuel_lb, "far121_alternate_fuel_lb")
546
+ hold = breguet_loiter_fuel_fraction(item.reserve_hold_hr, item.tsfc_hold,
547
+ item.ld_hold)
548
+ return item.far121_alternate_fuel_lb + hold * landing_weight_lb
549
+
550
+
551
+ # ---------------------------------------------------------------------------
552
+ # Empty-weight fraction (class-I fit from the weight-estimation leaf) and
553
+ # takeoff-weight sizing (mirrors the tow-estimation leaf)
554
+ # ---------------------------------------------------------------------------
555
+
556
+ def empty_weight_fraction_band(category):
557
+ """Class-I empty-weight fraction band for an aircraft category.
558
+
559
+ Historical typical bands (weight-estimation leaf): transport 0.42-0.55,
560
+ general-aviation 0.55-0.68, turboprop 0.50-0.62. Raises ValueError for
561
+ an unknown category.
562
+ """
563
+ key = str(category).strip().lower()
564
+ if key not in EMPTY_WEIGHT_FRACTION_BANDS:
565
+ raise ValueError("unknown category %r; use transport, general-aviation "
566
+ "or turboprop" % (category,))
567
+ return EMPTY_WEIGHT_FRACTION_BANDS[key]
568
+
569
+
570
+ def empty_weight_fraction(category="transport"):
571
+ """Representative class-I empty-weight fraction W_e/W_0 for the category.
572
+
573
+ The class-I fit is the historical empty-weight fraction band of the
574
+ weight-estimation leaf; the representative value used by the sizing loop
575
+ is the band midpoint (e.g. transport 0.42-0.55 -> 0.485).
576
+ """
577
+ lo, hi = empty_weight_fraction_band(category)
578
+ return (lo + hi) / 2.0
579
+
580
+
581
+ def empty_weight_lb(mtow_lb, category="transport"):
582
+ """Class-I empty weight (lb) from the representative fraction."""
583
+ _require_positive(mtow_lb, "MTOW")
584
+ return empty_weight_fraction(category) * mtow_lb
585
+
586
+
587
+ def check_empty_weight_fraction(empty_lb, mtow_lb, category):
588
+ """(in_band, band, fraction): is empty_lb/mtow_lb inside the category
589
+ band? Mirrors the weight-estimation leaf check."""
590
+ if mtow_lb <= 0:
591
+ raise ValueError("MTOW must be positive: %r" % (mtow_lb,))
592
+ if empty_lb < 0:
593
+ raise ValueError("empty weight must be non-negative: %r" % (empty_lb,))
594
+ band = empty_weight_fraction_band(category)
595
+ fraction = empty_lb / mtow_lb
596
+ return band[0] <= fraction <= band[1], band, fraction
597
+
598
+
599
+ def tow_estimate(payload_lb, empty_fraction, fuel_fraction):
600
+ """Takeoff gross weight (lb) from payload and class fractions:
601
+ W0 = payload / (1 - empty_fraction - fuel_fraction) (tow-estimation leaf).
602
+ """
603
+ if payload_lb <= 0:
604
+ raise ValueError("payload must be > 0, got %r" % (payload_lb,))
605
+ if empty_fraction < 0 or fuel_fraction < 0:
606
+ raise ValueError("fractions must be >= 0")
607
+ remaining = 1.0 - empty_fraction - fuel_fraction
608
+ if remaining <= 0:
609
+ raise ValueError("empty + fuel fraction must be < 1")
610
+ return payload_lb / remaining
611
+
612
+
613
+ def tow_converged(series, tol):
614
+ """True when the last two TOW estimates differ by less than tol."""
615
+ if len(series) < 2:
616
+ raise ValueError("series must have at least two estimates")
617
+ if any(v <= 0 for v in series):
618
+ raise ValueError("estimates must be > 0")
619
+ return abs(series[-1] - series[-2]) < tol
620
+
621
+
622
+ def weight_breakdown_ok(empty_lb, fuel_lb, payload_lb, total_lb, tol=0.01):
623
+ """True when empty + fuel + payload balances the total within tol."""
624
+ if empty_lb < 0 or fuel_lb < 0 or payload_lb < 0:
625
+ raise ValueError("breakdown terms must be >= 0")
626
+ if total_lb <= 0:
627
+ raise ValueError("total must be > 0")
628
+ return abs((empty_lb + fuel_lb + payload_lb) - total_lb) <= tol * total_lb
629
+
630
+
631
+ def size_mtow(item):
632
+ """Iterate the takeoff gross weight to convergence (within 0.5%).
633
+
634
+ The fuel-fraction sizing loop closes the equation
635
+ W0 = payload / (1 - W_e/W0 - W_f/W0)
636
+ where W_e/W0 is the class-I empty-weight fraction fit for the category
637
+ and W_f/W0 is the required-fuel fraction (block fuel + reserves) of the
638
+ design mission re-evaluated at the current W0 guess. Because the
639
+ fixed-fuel segments (taxi, takeoff, climb flow, descent) do not scale
640
+ with W0, the fuel fraction is W0-dependent and the loop genuinely
641
+ iterates. Converges when successive estimates change by less than
642
+ item.tolerance (default 0.005 = 0.5%) of the previous estimate.
643
+
644
+ Returns a dict with mtow_lb, the full iteration history, the converged
645
+ fuel/empty fractions, and the payload/empty/fuel weights.
646
+ """
647
+ tol = item.tolerance
648
+ if tol <= 0:
649
+ raise ValueError("convergence tolerance must be positive, got %r" % (tol,))
650
+ payload = item.payload_lb()
651
+ _require_positive(payload, "payload weight")
652
+ frac_empty = empty_weight_fraction(item.category)
653
+ guess = _require_positive(item.initial_guess_lb, "initial MTOW guess")
654
+ w0 = guess
655
+ history = []
656
+ converged = False
657
+ for i in range(item.max_iter):
658
+ mission = chain_mission(item, w0)
659
+ fuel_frac = mission["fuel_fraction"]
660
+ w_new = tow_estimate(payload, frac_empty, fuel_frac)
661
+ rel_change = abs(w_new - w0) / w0
662
+ history.append({
663
+ "iteration": i + 1,
664
+ "guess_lb": w0,
665
+ "mtow_new_lb": w_new,
666
+ "fuel_fraction": fuel_frac,
667
+ "empty_fraction": frac_empty,
668
+ "required_fuel_lb": mission["required_fuel_lb"],
669
+ "rel_change": rel_change,
670
+ })
671
+ if rel_change < tol:
672
+ converged = True
673
+ break
674
+ w0 = w_new
675
+ if not converged:
676
+ raise ValueError("MTOW iteration failed to converge within %d "
677
+ "iterations (last rel. change %g)"
678
+ % (item.max_iter, history[-1]["rel_change"]))
679
+ mtow = history[-1]["mtow_new_lb"]
680
+ final_mission = chain_mission(item, mtow)
681
+ return {
682
+ "mtow_lb": mtow,
683
+ "converged": converged,
684
+ "tolerance": tol,
685
+ "iterations": history,
686
+ "n_iterations": len(history),
687
+ "payload_lb": payload,
688
+ "empty_fraction": frac_empty,
689
+ "empty_weight_lb": frac_empty * mtow,
690
+ "required_fuel_lb": final_mission["required_fuel_lb"],
691
+ "fuel_fraction": final_mission["fuel_fraction"],
692
+ }
693
+
694
+
695
+ # ---------------------------------------------------------------------------
696
+ # Engine size check (mirrors the engine-sizing leaf)
697
+ # ---------------------------------------------------------------------------
698
+
699
+ def engine_size_check(item, mtow_lb, tw_design, cruise_start_weight_lb):
700
+ """Class-I propulsion size and top-of-climb margin check (SI inside).
701
+
702
+ Total sea-level static thrust T_SL = (T/W)*W from the design point;
703
+ thrust splits across item.n_engines; installed engine weight comes from
704
+ the engine thrust-to-weight ratio (5.0 typical, 4-6 band); the top of
705
+ climb margin is T(h) = T_SL*sigma**0.7 over the cruise thrust required
706
+ W/(L/D) at the top of climb weight (engine-sizing leaf lapse model).
707
+ """
708
+ w_n = mtow_lb * LB_TO_KG * G
709
+ t_sl_n = tw_design * w_n
710
+ per_engine_n = t_sl_n / item.n_engines
711
+ engine_w_n = per_engine_n / item.engine_tw_ratio
712
+ toc_w_n = cruise_start_weight_lb * LB_TO_KG * G
713
+ avail_n = t_sl_n * (isa_density_ratio(item.cruise_alt_m) ** 0.7)
714
+ required_n = toc_w_n / item.ld_cruise
715
+ margin = avail_n / required_n
716
+ return {
717
+ "n_engines": item.n_engines,
718
+ "tw_design": tw_design,
719
+ "total_thrust_lbf": t_sl_n / N_PER_LBF,
720
+ "thrust_per_engine_lbf": per_engine_n / N_PER_LBF,
721
+ "engine_weight_lb_each": engine_w_n / (LB_TO_KG * G),
722
+ "engine_tw_ratio": item.engine_tw_ratio,
723
+ "toc_margin": margin,
724
+ "toc_ok": margin >= 1.0,
725
+ }
726
+
727
+
728
+ # ---------------------------------------------------------------------------
729
+ # The requirement item
730
+ # ---------------------------------------------------------------------------
731
+
732
+ @dataclass
733
+ class AircraftRequirement:
734
+ """Project facts the role needs to size the concept.
735
+
736
+ Defaults match the worked reference item: a 100-passenger regional
737
+ jet transport, 1500 nm design range at M0.78 / FL350, 6000 ft takeoff
738
+ field - class-I conceptual design inputs in the transport sizing
739
+ convention (weights lb, range nm, speeds kt / Mach).
740
+ """
741
+ name: str = "RegionalJet-1500 (100 pax / 1500 nm / M0.78)"
742
+ category: str = "transport" # empty-weight-fraction band class
743
+
744
+ # Requirement payload
745
+ pax: int = 100
746
+ payload_per_pax_lb: float = 200.0 # passenger + baggage, lb (class-I)
747
+ cargo_lb: float = 0.0
748
+
749
+ # Sizing mission
750
+ range_nm: float = 1500.0
751
+ cruise_mach: float = 0.78
752
+ cruise_alt_m: float = 10668.0 # FL350 (0.3796 kg/m^3 ISA)
753
+ tsfc_cruise: float = 0.6 # lb fuel/lbf thrust/hr
754
+ ld_cruise: float = 18.0
755
+ tsfc_hold: float = 0.5 # hold TSFC (worse than cruise)
756
+ ld_hold: float = 15.0
757
+ reserve_rule: str = "hold45_5pct"
758
+ reserve_hold_hr: float = 0.75 # 45 minute hold
759
+ reserve_contingency: float = 0.05 # 5% of trip fuel
760
+ far121_alternate_fuel_lb: float = 0.0 # only used by the far121 rule
761
+
762
+ # Fixed-fuel mission segments (lb/hr x hr), transport sizing practice
763
+ taxi_flow_lb_hr: float = 1200.0
764
+ taxi_time_hr: float = 0.25
765
+ takeoff_flow_lb_hr: float = 18000.0
766
+ takeoff_time_hr: float = 0.05
767
+ climb_flow_lb_hr: float = 24000.0
768
+ climb_time_hr: float = 0.25
769
+ descent_flow_lb_hr: float = 2500.0
770
+ descent_time_hr: float = 0.30
771
+
772
+ # Constraint-analysis inputs (SI)
773
+ rho_sl: float = RHO_SL
774
+ stall_vs_mps: float = 60.0 # stall speed, landing config
775
+ clmax_landing: float = 2.5 # max CL, landing (stall limit)
776
+ clmax_takeoff: float = 2.0 # max CL, takeoff config
777
+ tofl_m: float = 1829.0 # takeoff field length (6000 ft)
778
+ climb_ld: float = 12.0 # climb configuration L/D
779
+ climb_gradient: float = 0.024 # FAR 25.121(b)(1), 2 engines
780
+ cruise_cd0: float = 0.018 # clean-config cruise CD0 (class-I)
781
+ oswald_e: float = 0.8 # Oswald span efficiency
782
+ aspect_ratio: float = 9.5
783
+ maneuver_load_factor: float = None # None disables the turn curve
784
+ n_engines: int = 2
785
+
786
+ # Design point and iteration controls
787
+ design_ws_nm2: float = 5100.0 # engineer's W/S choice (None=auto)
788
+ initial_guess_lb: float = 100000.0
789
+ tolerance: float = 0.005 # 0.5% convergence band
790
+ max_iter: int = 50
791
+ engine_tw_ratio: float = 5.0 # engine thrust-to-weight (4-6 band)
792
+
793
+ # Weight-growth context (mass-budget leaf practice)
794
+ design_phase: str = "conceptual"
795
+ growth_allowance_fraction: float = None # defaults per design phase
796
+
797
+ def payload_lb(self):
798
+ """Design payload weight (lb): passengers + baggage + cargo."""
799
+ pax_lb = _require_positive(self.pax, "passenger count") * \
800
+ _require_positive(self.payload_per_pax_lb, "payload per passenger")
801
+ cargo = self.cargo_lb
802
+ if cargo < 0:
803
+ raise ValueError("cargo weight must be non-negative")
804
+ return pax_lb + cargo
805
+
806
+ def growth_allowance(self):
807
+ """Growth allowance fraction for the design phase (mass-budget leaf:
808
+ conceptual 0.10, preliminary 0.06, detailed 0.03)."""
809
+ phase = self.design_phase.strip().lower()
810
+ if phase not in GROWTH_ALLOWANCE_BY_PHASE:
811
+ raise ValueError("unknown design phase %r; use conceptual, "
812
+ "preliminary, or detailed" % (self.design_phase,))
813
+ if self.growth_allowance_fraction is not None:
814
+ frac = self.growth_allowance_fraction
815
+ if frac < 0:
816
+ raise ValueError("growth allowance must be non-negative")
817
+ return frac
818
+ return GROWTH_ALLOWANCE_BY_PHASE[phase]
819
+
820
+
821
+ # ---------------------------------------------------------------------------
822
+ # Concept package builder
823
+ # ---------------------------------------------------------------------------
824
+
825
+ def build_concept(item):
826
+ """Build the complete concept design package content model.
827
+
828
+ Runs the full class-I chain for the item: matching-chart design point,
829
+ sizing-mission fuel fractions at the converged MTOW, the MTOW iteration,
830
+ the engine size/top-of-climb check, and the mass statement that balances
831
+ to the converged MTOW.
832
+ """
833
+ if not isinstance(item, AircraftRequirement):
834
+ item = item_from_dict(item)
835
+ payload = item.payload_lb()
836
+
837
+ # 1. Constraint analysis / design point on the matching chart
838
+ dp = select_design_point(item)
839
+ ws_stall = dp["ws_stall_max"]
840
+ ws = dp["ws_nm2"]
841
+ tw = dp["tw_required"]
842
+ binding = dp["binding_constraint"]
843
+ curves = dp["constraints"]
844
+ # feasibility: on the feasible lower bound at W/S (tw >= each curve) and
845
+ # within the stall-limited wing loading
846
+ feasible = (tw >= max(curves.values()) - 1e-12) and ws <= ws_stall * (1 + 1e-9)
847
+ w_n = 0.0 # filled after sizing; recomputed below from mtow
848
+
849
+ # 2. MTOW sizing iteration
850
+ sizing = size_mtow(item)
851
+ mtow_lb = sizing["mtow_lb"]
852
+ oew_lb = sizing["empty_weight_lb"]
853
+ fuel_lb = sizing["required_fuel_lb"]
854
+ empty_frac = sizing["empty_fraction"]
855
+ in_band, band, _ = check_empty_weight_fraction(oew_lb, mtow_lb, item.category)
856
+
857
+ # 3. Mission details at the converged MTOW (for the report)
858
+ mission = chain_mission(item, mtow_lb)
859
+ v_kt = mach_to_kt(item.cruise_mach, item.cruise_alt_m)
860
+
861
+ # 4. Geometry from the design point
862
+ w_newton = mtow_lb * LB_TO_KG * G
863
+ wing_area_m2 = w_newton / ws
864
+ span_m = math.sqrt(item.aspect_ratio * wing_area_m2)
865
+ k = induced_drag_factor(item.oswald_e, item.aspect_ratio)
866
+ ld_max = 1.0 / (2.0 * math.sqrt(k * item.cruise_cd0))
867
+
868
+ # 5. Engine size check (top of climb at the start-of-cruise weight)
869
+ cruise_start = next(r["start_weight_lb"] for r in mission["segments"]
870
+ if r["type"] == "cruise")
871
+ engine = engine_size_check(item, mtow_lb, tw, cruise_start)
872
+
873
+ # 6. Mass statement rows (must sum to the converged MTOW)
874
+ rows = [
875
+ {"group": "Operating empty weight (class-I fit %.3f x MTOW)"
876
+ % empty_frac, "weight_lb": oew_lb, "pct_mtow": empty_frac * 100.0},
877
+ {"group": "Design payload (%d pax x %.0f lb + cargo)" % (item.pax,
878
+ item.payload_per_pax_lb), "weight_lb": payload,
879
+ "pct_mtow": payload / mtow_lb * 100.0},
880
+ {"group": "Fuel (mission + reserves, hold45_5pct)", "weight_lb": fuel_lb,
881
+ "pct_mtow": fuel_lb / mtow_lb * 100.0},
882
+ ]
883
+ balances = weight_breakdown_ok(oew_lb, fuel_lb, payload, mtow_lb)
884
+
885
+ return {
886
+ "document_type": "Concept Design Package",
887
+ "status": "draft-for-review",
888
+ "generated": _today(),
889
+ "item": item.name,
890
+ "category": item.category,
891
+ "requirement": {
892
+ "pax": item.pax,
893
+ "payload_per_pax_lb": item.payload_per_pax_lb,
894
+ "payload_lb": payload,
895
+ "range_nm": item.range_nm,
896
+ "cruise_mach": item.cruise_mach,
897
+ "cruise_alt_m": item.cruise_alt_m,
898
+ "cruise_v_kt": v_kt,
899
+ "tofl_m": item.tofl_m,
900
+ "tofl_ft": item.tofl_m / 0.3048,
901
+ "n_engines": item.n_engines,
902
+ },
903
+ "design_point": {
904
+ "ws_nm2": ws,
905
+ "ws_psf": ws * NM2_TO_PSF,
906
+ "tw_required": tw,
907
+ "binding_constraint": binding,
908
+ "feasible": feasible,
909
+ "climb_gradient": item.climb_gradient,
910
+ "climb_ld": item.climb_ld,
911
+ "n_engines": item.n_engines,
912
+ "ws_stall_max_nm2": ws_stall,
913
+ "ws_stall_max_psf": ws_stall * NM2_TO_PSF,
914
+ "curves": {name: {"value": val, "tw_at_design": val}
915
+ for name, val in curves.items()},
916
+ "boundary": _feasible_boundary(item),
917
+ "aerodynamics": {"cd0": item.cruise_cd0, "k": k,
918
+ "oswald_e": item.oswald_e,
919
+ "aspect_ratio": item.aspect_ratio,
920
+ "ld_max": ld_max},
921
+ },
922
+ "wing": {"area_m2": wing_area_m2, "span_m": span_m,
923
+ "aspect_ratio": item.aspect_ratio,
924
+ "area_ft2": wing_area_m2 / 0.09290304},
925
+ "mission": {
926
+ "segments": mission["segments"],
927
+ "block_fuel_lb": mission["block_fuel_lb"],
928
+ "block_time_hr": mission["block_time_hr"],
929
+ "landing_weight_lb": mission["landing_weight_lb"],
930
+ "reserve_fuel_lb": mission["reserve_fuel_lb"],
931
+ "reserve_rule": item.reserve_rule,
932
+ "required_fuel_lb": mission["required_fuel_lb"],
933
+ "fuel_fraction_of_mtow": mission["fuel_fraction"],
934
+ "cruise_v_kt": v_kt,
935
+ },
936
+ "sizing": {
937
+ "mtow_lb": mtow_lb,
938
+ "mtow_kg": mtow_lb * LB_TO_KG,
939
+ "converged": sizing["converged"],
940
+ "tolerance": sizing["tolerance"],
941
+ "iterations": sizing["iterations"],
942
+ "n_iterations": sizing["n_iterations"],
943
+ "empty_fraction": empty_frac,
944
+ "empty_in_band": in_band,
945
+ "empty_band": band,
946
+ "oew_lb": oew_lb,
947
+ "fuel_lb": fuel_lb,
948
+ "payload_lb": payload,
949
+ },
950
+ "engine": engine,
951
+ "mass_statement": {
952
+ "rows": rows,
953
+ "total_lb": mtow_lb,
954
+ "balances": balances,
955
+ "growth_allowance": item.growth_allowance(),
956
+ "design_phase": item.design_phase,
957
+ },
958
+ "gates": check_concept({
959
+ "status": "draft-for-review",
960
+ "requirement": {"payload_lb": payload, "range_nm": item.range_nm},
961
+ "design_point": {"feasible": feasible, "binding_constraint": binding,
962
+ "ws_nm2": ws, "tw_required": tw},
963
+ "sizing": {"converged": sizing["converged"],
964
+ "iterations": sizing["iterations"],
965
+ "mtow_lb": mtow_lb, "empty_in_band": in_band},
966
+ "mass_statement": {"total_lb": mtow_lb, "balances": balances,
967
+ "rows": rows},
968
+ }),
969
+ }
970
+
971
+
972
+ def item_from_dict(data):
973
+ """Build an AircraftRequirement from a plain dict (unknown keys ignored)."""
974
+ known = {f.name for f in AircraftRequirement.__dataclass_fields__.values()}
975
+ return AircraftRequirement(**{k: v for k, v in data.items() if k in known})
976
+
977
+
978
+ # ---------------------------------------------------------------------------
979
+ # Render: the concept design package document
980
+ # ---------------------------------------------------------------------------
981
+
982
+ def render_concept_markdown(model):
983
+ """Render the concept content model as the deliverable markdown package."""
984
+ req = model["requirement"]
985
+ dp = model["design_point"]
986
+ wing = model["wing"]
987
+ mis = model["mission"]
988
+ sizing = model["sizing"]
989
+ eng = model["engine"]
990
+ ms = model["mass_statement"]
991
+
992
+ def seg_row(s):
993
+ frac = s["fraction_of_mtow"] * 100.0
994
+ if s["type"] == "cruise":
995
+ desc = (f"Cruise ({req['range_nm']:,.0f} nm @ "
996
+ f"{mis['cruise_v_kt']:,.0f} kt)")
997
+ else:
998
+ desc = s["type"].capitalize()
999
+ return (f"| {desc} | {s['fuel_lb']:,.0f} lb | "
1000
+ f"{frac:.1f}% of MTOW |")
1001
+
1002
+ lines = [
1003
+ "# Concept Design Package - %s" % model["item"],
1004
+ "",
1005
+ "**Document type:** %s " % model["document_type"],
1006
+ "**Status:** %s " % model["status"],
1007
+ "**Aircraft class:** %s (class-I conceptual sizing)" % model["category"],
1008
+ "**Generated:** %s" % model["generated"],
1009
+ "",
1010
+ "## 1. Requirements and design space",
1011
+ "",
1012
+ "| Requirement | Value |",
1013
+ "|---|---|",
1014
+ f"| Passengers | {req['pax']} pax |",
1015
+ f"| Payload | {req['payload_lb']:,.0f} lb ({req['pax']} pax x "
1016
+ f"{req['payload_per_pax_lb']:,.0f} lb + baggage, class-I) |",
1017
+ f"| Design range | {req['range_nm']:,.0f} nm |",
1018
+ f"| Cruise | M{req['cruise_mach']:.2f} at {req['cruise_alt_m']:,.0f} m "
1019
+ f"(FL{req['cruise_alt_m'] / 30.48:,.0f}), {req['cruise_v_kt']:,.0f} kt TAS |",
1020
+ f"| Takeoff field length | {req['tofl_m']:,.0f} m "
1021
+ f"({req['tofl_ft']:,.0f} ft) |",
1022
+ f"| Engines | {req['n_engines']} turbofans |",
1023
+ "",
1024
+ "Constraint diagram (matching chart, T/W vs W/S; SI inputs, "
1025
+ "sea-level ISA for takeoff, cruise at FL350):",
1026
+ "",
1027
+ "| Constraint | Rule | Value at design W/S |",
1028
+ "|---|---|---|",
1029
+ f"| Stall (max W/S) | W/S = 0.5*rho*CLmax*VS^2 | "
1030
+ f"{dp['ws_stall_max_nm2']:,.0f} N/m^2 ({dp['ws_stall_max_psf']:,.1f} psf) |",
1031
+ ]
1032
+ aero = dp["aerodynamics"]
1033
+ for name in ("takeoff", "climb", "cruise", "maneuvering"):
1034
+ if name in dp["curves"]:
1035
+ rule = {
1036
+ "takeoff": "T/W = 1.21*(W/S)/(rho*g*CLmax*s_TO)",
1037
+ "climb": "T/W = 1/LD + gamma (OEI -> installed, x n/(n-1))",
1038
+ "cruise": "T/W = q*CD0/(W/S) + k*(W/S)/q",
1039
+ "maneuvering": "T/W = q*CD0/(W/S) + k*n^2*(W/S)/q",
1040
+ }[name]
1041
+ lines.append(
1042
+ f"| {name.capitalize()} | {rule} | "
1043
+ f"{dp['curves'][name]['tw_at_design']:.4f} |")
1044
+ lines += [
1045
+ "",
1046
+ f"**Selected design point:** W/S = {dp['ws_nm2']:,.0f} N/m^2 "
1047
+ f"({dp['ws_psf']:,.1f} psf), T/W = {dp['tw_required']:.4f} "
1048
+ f"(binding constraint: {dp['binding_constraint']}).",
1049
+ f"**Feasible:** {dp['feasible']} - the point lies on the lower bound "
1050
+ "of the feasible region (stall limit not exceeded).",
1051
+ f"Feasible-region boundary samples (W/S N/m^2 -> minimum T/W): "
1052
+ + ", ".join(f"{ws:,.0f} -> {tw:.4f}" for ws, tw in dp["boundary"]) + ".",
1053
+ "",
1054
+ "## 2. Sizing mission",
1055
+ "",
1056
+ "Mission profile (segments in flight order; each burns from the "
1057
+ "weight remaining after earlier segments, Breguet range/endurance "
1058
+ "models):",
1059
+ "",
1060
+ "| Segment | Fuel burned | Fraction of MTOW |",
1061
+ "|---|---|---|",
1062
+ ]
1063
+ lines += [seg_row(s) for s in mis["segments"]]
1064
+ lines += [
1065
+ "",
1066
+ f"| **Block fuel** | **{mis['block_fuel_lb']:,.0f} lb** | "
1067
+ f"**{mis['block_fuel_lb'] / sizing['mtow_lb'] * 100:.1f}%** |",
1068
+ f"| Block time | {mis['block_time_hr']:.2f} hr | |",
1069
+ f"| Landing weight | {mis['landing_weight_lb']:,.0f} lb | |",
1070
+ f"| Reserve ({mis['reserve_rule']}: "
1071
+ f"{model['sizing']['iterations'][0] and '45 min hold at 1500 ft + 5% trip'}) "
1072
+ f"| {mis['reserve_fuel_lb']:,.0f} lb | |",
1073
+ f"| **Required fuel (mission + reserves)** | "
1074
+ f"**{mis['required_fuel_lb']:,.0f} lb** | "
1075
+ f"**{mis['fuel_fraction_of_mtow'] * 100:.1f}% of MTOW** |",
1076
+ "",
1077
+ "## 3. Configuration (from the design point)",
1078
+ "",
1079
+ f"- Wing loading W/S: {dp['ws_nm2']:,.0f} N/m^2 "
1080
+ f"({dp['ws_psf']:,.1f} psf) - design choice, {dp['ws_nm2'] / dp['ws_stall_max_nm2'] * 100:.1f}% "
1081
+ "of the stall-limited maximum.",
1082
+ f"- Wing area S = W/(W/S) = {wing['area_m2']:,.1f} m^2 "
1083
+ f"({wing['area_ft2']:,.0f} ft^2); aspect ratio {wing['aspect_ratio']:.1f}; "
1084
+ f"span b = {wing['span_m']:,.1f} m.",
1085
+ f"- Drag model: CD0 = {aero['cd0']:.3f}, e = {aero['oswald_e']:.2f}, "
1086
+ f"k = 1/(pi*e*AR) = {aero['k']:.4f}; L/D_max = {aero['ld_max']:.1f} "
1087
+ "(consistent with the mission cruise L/D of 18).",
1088
+ f"- Engines: {eng['n_engines']} x ~{eng['thrust_per_engine_lbf']:,.0f} lbf "
1089
+ "class sea-level static thrust (see Section 5).",
1090
+ "- 3-view reference geometry from the OpenVSP stage (initial layout) "
1091
+ "feeds the class-II mass build-up; not required for the class-I "
1092
+ "sizing closure.",
1093
+ "",
1094
+ "## 4. Mass statement (class-I, balances to converged MTOW)",
1095
+ "",
1096
+ "| Group | Weight (lb) | % MTOW |",
1097
+ "|---|---|---|",
1098
+ ]
1099
+ for r in ms["rows"]:
1100
+ lines.append(f"| {r['group']} | {r['weight_lb']:,.0f} | {r['pct_mtow']:.1f}% |")
1101
+ lines += [
1102
+ f"| **Maximum takeoff weight (converged)** | **{sizing['mtow_lb']:,.0f}** | **100.0%** |",
1103
+ "",
1104
+ f"- Balance check (empty + payload + fuel vs MTOW): "
1105
+ f"{'PASS within 1%' if ms['balances'] else 'FAIL'}.",
1106
+ f"- Empty-weight fraction {sizing['empty_fraction']:.3f} inside the "
1107
+ f"{model['category']} class-I band {sizing['empty_band'][0]:.2f}-"
1108
+ f"{sizing['empty_band'][1]:.2f}: "
1109
+ f"{'PASS' if sizing['empty_in_band'] else 'FAIL'}.",
1110
+ f"- Weight-growth context: {ms['design_phase']} phase growth allowance "
1111
+ f"{ms['growth_allowance'] * 100:.0f}% applies to the detailed mass "
1112
+ "budget roll-up (mass-budget stage).",
1113
+ "- CG envelope and critical loading conditions require the 3-view "
1114
+ "layout and the mass-properties stage (component arms); the class-I "
1115
+ "closure above bounds the balance (fuel + payload = "
1116
+ f"{sizing['fuel_lb'] + sizing['payload_lb']:,.0f} lb available below "
1117
+ f"MTOW - OEW = {sizing['mtow_lb'] - sizing['oew_lb']:,.0f} lb).",
1118
+ "",
1119
+ "## 5. MTOW convergence and engine size check",
1120
+ "",
1121
+ "Fuel-fraction sizing iteration "
1122
+ "(W0 = payload / (1 - W_e/W0 - W_f/W0), class-I empty fraction "
1123
+ f"{sizing['empty_fraction']:.3f}):",
1124
+ "",
1125
+ "| Iteration | Guess (lb) | Fuel fraction | New MTOW (lb) | Rel. change |",
1126
+ "|---|---|---|---|---|",
1127
+ ]
1128
+ for it in sizing["iterations"]:
1129
+ lines.append(
1130
+ f"| {it['iteration']} | {it['guess_lb']:,.0f} | "
1131
+ f"{it['fuel_fraction']:.4f} | {it['mtow_new_lb']:,.0f} | "
1132
+ f"{it['rel_change'] * 100:.2f}% |")
1133
+ lines += [
1134
+ "",
1135
+ f"**Converged MTOW = {sizing['mtow_lb']:,.0f} lb "
1136
+ f"({sizing['mtow_kg']:,.0f} kg)** after {sizing['n_iterations']} "
1137
+ f"iterations (tolerance {sizing['tolerance'] * 100:.1f}%): "
1138
+ f"{'CONVERGED' if sizing['converged'] else 'NOT CONVERGED'}.",
1139
+ "",
1140
+ f"- Installed sea-level thrust: {eng['total_thrust_lbf']:,.0f} lbf "
1141
+ f"total = {eng['thrust_per_engine_lbf']:,.0f} lbf per engine "
1142
+ f"(T/W = {eng['tw_design']:.3f}).",
1143
+ f"- Engine weight estimate: ~{eng['engine_weight_lb_each']:,.0f} lb "
1144
+ f"per engine at engine T/W = {eng['engine_tw_ratio']:.1f} "
1145
+ "(4-6 typical band).",
1146
+ f"- Top-of-climb margin at {req['cruise_alt_m']:,.0f} m "
1147
+ f"(thrust lapse sigma^0.7 vs W/(L/D)): {eng['toc_margin']:.2f} "
1148
+ f"- {'PASS (>= 1.0)' if eng['toc_ok'] else 'FAIL (< 1.0)'}.",
1149
+ "- Engine-out second-segment gradient is carried on the installed "
1150
+ "basis: T/W = (n/(n-1)) x (1/LD + gamma) with LD = "
1151
+ f"{dp['climb_ld']:.0f} and gamma = {dp['climb_gradient']:.3f} "
1152
+ f"(FAR 25.121(b)(1) two-engine value, {dp['n_engines']} engines); the "
1153
+ "engine-sizing stage re-checks OEI climb, hot day, and takeoff "
1154
+ "derate against the catalogue engine.",
1155
+ "",
1156
+ "## 6. Feasibility notes and next stages",
1157
+ "",
1158
+ "- Class-I results are FEASIBILITY, not detailed design: the "
1159
+ "converged MTOW, the W/S-T/W design point, and the mass statement "
1160
+ "close the sizing loop within the stated class-I model accuracy.",
1161
+ "- Structure/propulsion/systems mass split requires the class-II "
1162
+ "component build-up (weight-estimation, engine-sizing, fuselage/"
1163
+ "wing/tail-sizing leaves); the conceptual growth allowance "
1164
+ f"({ms['growth_allowance'] * 100:.0f}%) is the margin policy for that "
1165
+ "roll-up.",
1166
+ "- Payload-range, cost (parametric DOC/LCC), and CG envelope are "
1167
+ "produced by the dedicated stages (payload-range-diagram, "
1168
+ "parametric-cost, cg-envelope) once the class-II breakdown exists.",
1169
+ "",
1170
+ "---",
1171
+ "",
1172
+ "*DRAFT - conceptual feasibility for human design review. Not a "
1173
+ "production release and not an approval document. Conceptual results "
1174
+ "are class-I estimates; every configuration choice must trace to the "
1175
+ "trade data before detailed design.*",
1176
+ ]
1177
+ return "\n".join(lines)
1178
+
1179
+
1180
+ # ---------------------------------------------------------------------------
1181
+ # Evidence gate checkers
1182
+ # ---------------------------------------------------------------------------
1183
+
1184
+ GATE_CHECKS = {
1185
+ "requirements_present": "payload and range requirements are real numbers",
1186
+ "mtow_converged": "MTOW converged to within the 0.5% band (>= 2 iterations)",
1187
+ "empty_fraction_in_band": "empty weight fraction inside the category band",
1188
+ "design_point_feasible": "design point lies on the feasible boundary",
1189
+ "mass_statement_balances": "empty + payload + fuel balance MTOW within 1%",
1190
+ "sign_off_honest": "document is marked draft-for-review, not approval",
1191
+ }
1192
+
1193
+
1194
+ def check_concept(model):
1195
+ """Run the evidence gates against a concept model. Pass/fail per gate."""
1196
+ req = model.get("requirement", {})
1197
+ dp = model.get("design_point", {})
1198
+ sizing = model.get("sizing", {})
1199
+ ms = model.get("mass_statement", {})
1200
+ iters = sizing.get("iterations", [])
1201
+ last_rel = (iters[-1]["rel_change"] if len(iters) >= 2 else None)
1202
+ results = {
1203
+ "requirements_present": (
1204
+ isinstance(req.get("payload_lb"), (int, float)) and req["payload_lb"] > 0
1205
+ and isinstance(req.get("range_nm"), (int, float)) and req["range_nm"] > 0),
1206
+ "mtow_converged": (bool(sizing.get("converged"))
1207
+ and len(iters) >= 2
1208
+ and last_rel is not None
1209
+ and last_rel < sizing.get("tolerance", 0.005)),
1210
+ "empty_fraction_in_band": bool(sizing.get("empty_in_band")),
1211
+ "design_point_feasible": bool(dp.get("feasible"))
1212
+ and isinstance(dp.get("ws_nm2"), (int, float))
1213
+ and isinstance(dp.get("tw_required"), (int, float)),
1214
+ "mass_statement_balances": bool(ms.get("balances"))
1215
+ and ms.get("total_lb", 0) > 0,
1216
+ "sign_off_honest": model.get("status") == "draft-for-review",
1217
+ }
1218
+ results["all_pass"] = all(results.values())
1219
+ return results
1220
+
1221
+
1222
+ def check_concept_markdown(md_text):
1223
+ """Gate-check the rendered concept design package markdown deliverable."""
1224
+ low = md_text.lower()
1225
+ import re as _re
1226
+ checks = {
1227
+ "has_title": "concept design package" in low,
1228
+ "has_mtow": _re.search(r"Maximum takeoff weight \(converged\)\*\* \| "
1229
+ r"\*\*[\d,]+", md_text) is not None,
1230
+ "has_ws": _re.search(r"W/S = [\d,]+ N/m\^2", md_text) is not None,
1231
+ "has_tw": _re.search(r"T/W = 0\.\d+", md_text) is not None,
1232
+ "has_convergence_history": "| Iteration |" in md_text
1233
+ and "CONVERGED" in md_text.upper(),
1234
+ "has_mass_statement": "| Operating empty weight" in md_text
1235
+ and "| **Maximum takeoff weight" in md_text,
1236
+ "has_draft_marker": "draft" in low,
1237
+ "has_not_approval": "not an approval" in low,
1238
+ "no_blank_fields": "___" not in md_text and "__" not in _strip_em(md_text),
1239
+ }
1240
+ checks["all_pass"] = all(checks.values())
1241
+ return checks
1242
+
1243
+
1244
+ def _strip_em(text):
1245
+ """Remove '**' emphasis markers so '____' style emphasis is not a blank."""
1246
+ return text.replace("**", "")
1247
+
1248
+
1249
+ # ---------------------------------------------------------------------------
1250
+ # Example requirement (reference item for tests and worked-example output)
1251
+ # ---------------------------------------------------------------------------
1252
+
1253
+ def example_item():
1254
+ """Reference item: 100-pax regional jet transport, 1500 nm @ M0.78."""
1255
+ return AircraftRequirement()
1256
+
1257
+
1258
+ def example_concept_markdown():
1259
+ """Concept design package markdown for the reference item."""
1260
+ return render_concept_markdown(build_concept(example_item()))
1261
+
1262
+
1263
+ if __name__ == "__main__":
1264
+ item = example_item()
1265
+ model = build_concept(item)
1266
+ md = render_concept_markdown(model)
1267
+ s = model["sizing"]
1268
+ dp = model["design_point"]
1269
+ print(f"ITEM: {model['item']}")
1270
+ print(f"MTOW: {s['mtow_lb']:,.0f} lb (converged in {s['n_iterations']} "
1271
+ f"iterations, tol {s['tolerance']:.3f})")
1272
+ print(f"DESIGN POINT: W/S {dp['ws_nm2']:,.0f} N/m^2 ({dp['ws_psf']:,.1f} psf), "
1273
+ f"T/W {dp['tw_required']:.4f} ({dp['binding_constraint']} binding)")
1274
+ print(f"MASS: OEW {s['oew_lb']:,.0f} lb + payload {s['payload_lb']:,.0f} lb "
1275
+ f"+ fuel {s['fuel_lb']:,.0f} lb = MTOW {s['mtow_lb']:,.0f} lb")
1276
+ print(f"GATES: {check_concept(model)}")
1277
+ print(f"MARKDOWN GATES: {check_concept_markdown(md)}")
1278
+ print(f"RENDERED: {len(md)} chars")