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.
- package/NOTICE +18 -0
- package/README.md +49 -0
- package/bin/aero-agent-roles.js +127 -0
- package/lib/catalog.js +97 -0
- package/lib/install.js +48 -0
- package/lib/mcp.js +199 -0
- package/manifest.json +516 -0
- package/package.json +44 -0
- package/roles/aerodynamics-engineer/ROLE.md +153 -0
- package/roles/aerodynamics-engineer/SOURCES.md +12 -0
- package/roles/aerodynamics-engineer/cli.py +143 -0
- package/roles/aerodynamics-engineer/core/aerodynamics_core.py +1218 -0
- package/roles/aerodynamics-engineer/templates/aero-design-report-template.md +72 -0
- package/roles/aerodynamics-engineer/tests/test_aerodynamics_core.py +227 -0
- package/roles/aerodynamics-engineer/tests/test_role_aerodynamics.py +94 -0
- package/roles/aircraft-design-engineer/ROLE.md +138 -0
- package/roles/aircraft-design-engineer/SOURCES.md +12 -0
- package/roles/aircraft-design-engineer/cli.py +152 -0
- package/roles/aircraft-design-engineer/core/aircraft_design_core.py +1278 -0
- package/roles/aircraft-design-engineer/templates/concept-design-template.md +99 -0
- package/roles/aircraft-design-engineer/tests/test_aircraft_design_core.py +269 -0
- package/roles/aircraft-design-engineer/tests/test_role_aircraft_design_engineer.py +106 -0
- package/roles/airworthiness-compliance-engineer/ROLE.md +122 -0
- package/roles/airworthiness-compliance-engineer/SOURCES.md +16 -0
- package/roles/airworthiness-compliance-engineer/cli.py +146 -0
- package/roles/airworthiness-compliance-engineer/core/airworthiness_core.py +894 -0
- package/roles/airworthiness-compliance-engineer/templates/compliance-matrix-template.md +46 -0
- package/roles/airworthiness-compliance-engineer/tests/test_airworthiness_core.py +224 -0
- package/roles/airworthiness-compliance-engineer/tests/test_role_airworthiness.py +85 -0
- package/roles/as9100-quality-auditor/ROLE.md +128 -0
- package/roles/as9100-quality-auditor/SOURCES.md +16 -0
- package/roles/as9100-quality-auditor/cli.py +136 -0
- package/roles/as9100-quality-auditor/core/as9100_core.py +857 -0
- package/roles/as9100-quality-auditor/templates/findings-report-template.md +119 -0
- package/roles/as9100-quality-auditor/tests/test_as9100_cli_profile.py +105 -0
- package/roles/as9100-quality-auditor/tests/test_as9100_core.py +204 -0
- package/roles/as9100-quality-auditor/tests/test_role_as9100.py +90 -0
- package/roles/data-bus-avionics-engineer/ROLE.md +146 -0
- package/roles/data-bus-avionics-engineer/SOURCES.md +23 -0
- package/roles/data-bus-avionics-engineer/cli.py +311 -0
- package/roles/data-bus-avionics-engineer/core/data_bus_avionics_core.py +1121 -0
- package/roles/data-bus-avionics-engineer/templates/bus-assessment-template.md +134 -0
- package/roles/data-bus-avionics-engineer/tests/test_bundle_protocol.py +137 -0
- package/roles/data-bus-avionics-engineer/tests/test_data_bus_avionics_core.py +261 -0
- package/roles/data-bus-avionics-engineer/tests/test_role_data_bus_avionics_engineer.py +121 -0
- package/roles/do160-environmental-engineer/ROLE.md +136 -0
- package/roles/do160-environmental-engineer/SOURCES.md +30 -0
- package/roles/do160-environmental-engineer/cli.py +147 -0
- package/roles/do160-environmental-engineer/core/do160_environmental_core.py +1246 -0
- package/roles/do160-environmental-engineer/templates/environmental-qualification-plan-template.md +114 -0
- package/roles/do160-environmental-engineer/tests/test_do160_environmental_core.py +312 -0
- package/roles/do160-environmental-engineer/tests/test_role_do160_environmental_engineer.py +281 -0
- package/roles/do178c-cert-engineer/ROLE.md +120 -0
- package/roles/do178c-cert-engineer/SOURCES.md +16 -0
- package/roles/do178c-cert-engineer/cli.py +129 -0
- package/roles/do178c-cert-engineer/core/do178c_core.py +411 -0
- package/roles/do178c-cert-engineer/templates/psac-template.md +74 -0
- package/roles/do178c-cert-engineer/tests/test_do178c_cert_engineer_core.py +91 -0
- package/roles/do178c-cert-engineer/tests/test_role_do178c.py +109 -0
- package/roles/do254-hardware-engineer/ROLE.md +127 -0
- package/roles/do254-hardware-engineer/SOURCES.md +19 -0
- package/roles/do254-hardware-engineer/cli.py +308 -0
- package/roles/do254-hardware-engineer/core/do254_hardware_core.py +761 -0
- package/roles/do254-hardware-engineer/templates/phac-template.md +87 -0
- package/roles/do254-hardware-engineer/tests/test_do254_bundle_profile.py +152 -0
- package/roles/do254-hardware-engineer/tests/test_do254_hardware_core.py +200 -0
- package/roles/do254-hardware-engineer/tests/test_role_do254_hardware.py +108 -0
- package/roles/engineering-analysis-engineer/ROLE.md +154 -0
- package/roles/engineering-analysis-engineer/SOURCES.md +15 -0
- package/roles/engineering-analysis-engineer/cli.py +151 -0
- package/roles/engineering-analysis-engineer/core/engineering_analysis_engineer_core.py +866 -0
- package/roles/engineering-analysis-engineer/templates/analysis-memo-template.md +73 -0
- package/roles/engineering-analysis-engineer/tests/test_engineering_analysis_engineer_core.py +367 -0
- package/roles/engineering-analysis-engineer/tests/test_role_engineering_analysis_engineer.py +113 -0
- package/roles/flight-management-engineer/ROLE.md +160 -0
- package/roles/flight-management-engineer/SOURCES.md +30 -0
- package/roles/flight-management-engineer/cli.py +258 -0
- package/roles/flight-management-engineer/core/flight_management_core.py +1473 -0
- package/roles/flight-management-engineer/templates/route-assessment-template.md +94 -0
- package/roles/flight-management-engineer/tests/test_flight_management_core.py +437 -0
- package/roles/flight-management-engineer/tests/test_role_flight_management_engineer.py +123 -0
- package/roles/flight-mechanics-engineer/ROLE.md +162 -0
- package/roles/flight-mechanics-engineer/SOURCES.md +51 -0
- package/roles/flight-mechanics-engineer/cli.py +149 -0
- package/roles/flight-mechanics-engineer/core/flight_mechanics_core.py +1507 -0
- package/roles/flight-mechanics-engineer/templates/perf-sc-report-template.md +82 -0
- package/roles/flight-mechanics-engineer/tests/test_flight_mechanics_core.py +277 -0
- package/roles/flight-mechanics-engineer/tests/test_role_flight_mechanics_engineer.py +141 -0
- package/roles/flight-test-engineer/ROLE.md +137 -0
- package/roles/flight-test-engineer/SOURCES.md +24 -0
- package/roles/flight-test-engineer/cli.py +135 -0
- package/roles/flight-test-engineer/core/flight_test_core.py +931 -0
- package/roles/flight-test-engineer/templates/flight-test-template.md +143 -0
- package/roles/flight-test-engineer/tests/test_flight_test_engineer_core.py +227 -0
- package/roles/flight-test-engineer/tests/test_role_flight_test_engineer.py +150 -0
- package/roles/flight-test-performance-engineer/ROLE.md +165 -0
- package/roles/flight-test-performance-engineer/SOURCES.md +20 -0
- package/roles/flight-test-performance-engineer/cli.py +319 -0
- package/roles/flight-test-performance-engineer/core/flight_test_performance_core.py +1398 -0
- package/roles/flight-test-performance-engineer/templates/performance-report-template.md +105 -0
- package/roles/flight-test-performance-engineer/tests/test_flight_test_performance_engineer_core.py +308 -0
- package/roles/flight-test-performance-engineer/tests/test_role_flight_test_performance_engineer.py +165 -0
- package/roles/gnc-engineer/ROLE.md +141 -0
- package/roles/gnc-engineer/SOURCES.md +35 -0
- package/roles/gnc-engineer/cli.py +141 -0
- package/roles/gnc-engineer/core/gnc_core.py +1074 -0
- package/roles/gnc-engineer/templates/gnc-report-template.md +105 -0
- package/roles/gnc-engineer/tests/test_gnc_engineer_core.py +238 -0
- package/roles/gnc-engineer/tests/test_role_gnc_engineer.py +142 -0
- package/roles/guidance-engineer/ROLE.md +156 -0
- package/roles/guidance-engineer/SOURCES.md +43 -0
- package/roles/guidance-engineer/cli.py +280 -0
- package/roles/guidance-engineer/core/guidance_core.py +820 -0
- package/roles/guidance-engineer/templates/guidance-report-template.md +70 -0
- package/roles/guidance-engineer/tests/test_guidance_engineer_core.py +269 -0
- package/roles/guidance-engineer/tests/test_role_guidance_engineer.py +146 -0
- package/roles/high-speed-aerodynamics-engineer/ROLE.md +161 -0
- package/roles/high-speed-aerodynamics-engineer/SOURCES.md +18 -0
- package/roles/high-speed-aerodynamics-engineer/cli.py +282 -0
- package/roles/high-speed-aerodynamics-engineer/core/high_speed_aero_core.py +1566 -0
- package/roles/high-speed-aerodynamics-engineer/templates/high-speed-memo-template.md +75 -0
- package/roles/high-speed-aerodynamics-engineer/tests/test_bundle_protocol.py +103 -0
- package/roles/high-speed-aerodynamics-engineer/tests/test_high_speed_aero_core.py +280 -0
- package/roles/high-speed-aerodynamics-engineer/tests/test_role_high_speed_aerodynamics.py +125 -0
- package/roles/ndt-engineer/ROLE.md +157 -0
- package/roles/ndt-engineer/SOURCES.md +18 -0
- package/roles/ndt-engineer/cli.py +316 -0
- package/roles/ndt-engineer/core/ndt_core.py +1590 -0
- package/roles/ndt-engineer/templates/ndt-plan-template.md +109 -0
- package/roles/ndt-engineer/tests/test_ndt_cli_bundle.py +183 -0
- package/roles/ndt-engineer/tests/test_ndt_core.py +437 -0
- package/roles/ndt-engineer/tests/test_role_ndt_engineer.py +148 -0
- package/roles/numerical-analysis-engineer/ROLE.md +158 -0
- package/roles/numerical-analysis-engineer/SOURCES.md +19 -0
- package/roles/numerical-analysis-engineer/cli.py +414 -0
- package/roles/numerical-analysis-engineer/core/numerical_analysis_core.py +1220 -0
- package/roles/numerical-analysis-engineer/templates/verification-memo-template.md +120 -0
- package/roles/numerical-analysis-engineer/tests/test_bundle_protocol.py +157 -0
- package/roles/numerical-analysis-engineer/tests/test_numerical_analysis_core.py +337 -0
- package/roles/numerical-analysis-engineer/tests/test_role_numerical_analysis_engineer.py +117 -0
- package/roles/propulsion-engineer/ROLE.md +163 -0
- package/roles/propulsion-engineer/SOURCES.md +15 -0
- package/roles/propulsion-engineer/cli.py +168 -0
- package/roles/propulsion-engineer/core/propulsion_core.py +1291 -0
- package/roles/propulsion-engineer/templates/propulsion-report-template.md +92 -0
- package/roles/propulsion-engineer/tests/test_propulsion_engineer_core.py +294 -0
- package/roles/propulsion-engineer/tests/test_role_propulsion_engineer.py +106 -0
- package/roles/space-systems-engineer/ROLE.md +161 -0
- package/roles/space-systems-engineer/SOURCES.md +49 -0
- package/roles/space-systems-engineer/cli.py +173 -0
- package/roles/space-systems-engineer/core/space_systems_core.py +980 -0
- package/roles/space-systems-engineer/templates/space-systems-template.md +88 -0
- package/roles/space-systems-engineer/tests/test_role_space_systems_engineer.py +117 -0
- package/roles/space-systems-engineer/tests/test_space_systems_engineer_core.py +247 -0
- package/roles/state-estimation-engineer/ROLE.md +154 -0
- package/roles/state-estimation-engineer/SOURCES.md +27 -0
- package/roles/state-estimation-engineer/cli.py +326 -0
- package/roles/state-estimation-engineer/core/state_estimation_core.py +958 -0
- package/roles/state-estimation-engineer/templates/nav-state-estimator-design-template.md +76 -0
- package/roles/state-estimation-engineer/tests/test_bundle_protocol.py +110 -0
- package/roles/state-estimation-engineer/tests/test_role_state_estimation_engineer.py +127 -0
- package/roles/state-estimation-engineer/tests/test_state_estimation_engineer_core.py +231 -0
- package/roles/structures-loads-engineer/ROLE.md +171 -0
- package/roles/structures-loads-engineer/SOURCES.md +44 -0
- package/roles/structures-loads-engineer/cli.py +219 -0
- package/roles/structures-loads-engineer/core/structures_loads_core.py +1112 -0
- package/roles/structures-loads-engineer/templates/loads-strength-report-template.md +96 -0
- package/roles/structures-loads-engineer/tests/test_bundle_protocol.py +101 -0
- package/roles/structures-loads-engineer/tests/test_role_structures_loads_engineer.py +147 -0
- 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")
|