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,1566 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""high_speed_aero_core.py - High-Speed Aerodynamics Engineer executable core.
|
|
3
|
+
|
|
4
|
+
This is the role's ENGINE: given a high-speed (transonic/supersonic)
|
|
5
|
+
vehicle's flight condition it computes the compressible-flow numbers
|
|
6
|
+
with REAL domain formulas and BUILDS the High-Speed Aerodynamic
|
|
7
|
+
Analysis Memo. Standalone: no external repo needed (stdlib math only,
|
|
8
|
+
offline, deterministic).
|
|
9
|
+
|
|
10
|
+
Every formula below mirrors the logic of the bound Aero Agent Skills
|
|
11
|
+
leaves (which encode public-domain compressible-flow practice;
|
|
12
|
+
summaries only, never reproduced text):
|
|
13
|
+
|
|
14
|
+
- ISA atmosphere: standard lapse-rate troposphere + isothermal
|
|
15
|
+
stratosphere (public domain), Sutherland viscosity.
|
|
16
|
+
- Isentropic flow relations
|
|
17
|
+
(aerodynamics/high-speed/isentropic-flow-relations):
|
|
18
|
+
T0/T = 1 + 0.5*(g-1)*M^2; p0/p = (T0/T)^(g/(g-1));
|
|
19
|
+
rho0/rho = (T0/T)^(1/(g-1))
|
|
20
|
+
A/A* = (1/M) * ((2/(g+1))*(1 + 0.5*(g-1)M^2))^((g+1)/(2(g-1)))
|
|
21
|
+
M from A/A* by bisection (subsonic or supersonic branch)
|
|
22
|
+
choked mass flow mdot = MFP * p0 * A* / sqrt(T0),
|
|
23
|
+
MFP = sqrt(g/R)*(2/(g+1))^((g+1)/(2(g-1))) = 0.0404184
|
|
24
|
+
- Compressibility corrections (aerodynamics/high-speed/
|
|
25
|
+
transonic-similarity):
|
|
26
|
+
P-G factor = 1/sqrt(1-M^2)
|
|
27
|
+
Karman-Tsien: Cp = Cp0 / (sqrt(1-M^2)
|
|
28
|
+
+ (M^2/(1+sqrt(1-M^2)))*Cp0/2)
|
|
29
|
+
Cp* = (2/(g M^2))*(((1+0.5(g-1)M^2)/(0.5(g+1)))^(g/(g-1)) - 1)
|
|
30
|
+
critical Mach solves Cp0/sqrt(1-M^2) = Cp*(M) by bisection
|
|
31
|
+
- Swept wing (aerodynamics/high-speed/swept-wing-aerodynamics):
|
|
32
|
+
M_eff = M cos(Lambda); M_cr,swept = M_cr0 / cos(Lambda)
|
|
33
|
+
- Normal shock (aerodynamics/high-speed/normal-shock):
|
|
34
|
+
M2 = sqrt((1+0.5(g-1)M1^2)/(g M1^2 - 0.5(g-1)))
|
|
35
|
+
p2/p1 = 1 + 2g/(g+1)(M1^2-1); rho2/rho1 = (g+1)M1^2/(2+(g-1)M1^2)
|
|
36
|
+
T2/T1 = (p2/p1)/(rho2/rho1);
|
|
37
|
+
p02/p01 = (p2/p1)^(1/(1-g)) * (rho2/rho1)^(g/(g-1))
|
|
38
|
+
- Oblique shock (aerodynamics/high-speed/oblique-shock):
|
|
39
|
+
theta-beta-M relation tan(theta) = 2 cot(beta)(M1^2 sin^2(beta)-1)
|
|
40
|
+
/(M1^2(g+cos(2 beta))+2)
|
|
41
|
+
weak/strong beta by bisection; normal ratios on M1n = M1 sin(beta);
|
|
42
|
+
M2 = M2n / sin(beta - theta); theta_max by ternary search
|
|
43
|
+
- Regular shock reflection (aerodynamics/high-speed/
|
|
44
|
+
regular-shock-reflection): incident oblique state at (M1, theta),
|
|
45
|
+
reflected shock must turn the flow back parallel to the wall;
|
|
46
|
+
verdict 'regular' when M2 > 1 and theta < theta_max(M2), else 'mach'
|
|
47
|
+
- Prandtl-Meyer (aerodynamics/high-speed/prandtl-meyer):
|
|
48
|
+
nu(M) = sqrt((g+1)/(g-1)) atan(sqrt((g-1)/(g+1)(M^2-1)))
|
|
49
|
+
- atan(sqrt(M^2-1))
|
|
50
|
+
M2 from nu(M2) = nu(M1) + delta by bisection; p2/p1 = isentropic
|
|
51
|
+
- Shock-expansion airfoil (aerodynamics/high-speed/
|
|
52
|
+
shock-expansion-airfoil): four-panel diamond airfoil, oblique-shock
|
|
53
|
+
turn on compression corners, Prandtl-Meyer turn on expansion corners,
|
|
54
|
+
surface Cp from p/p_inf, forces integrated to section cl / cd_wave /
|
|
55
|
+
cm_le (chord c = 1, q_ref = 0.5 g M^2 normalization)
|
|
56
|
+
- Supercritical section / Korn rule
|
|
57
|
+
(aerodynamics/high-speed/supercritical-airfoil):
|
|
58
|
+
M_DD = 0.95 - t/c - CL/10 (supercritical), 0.90 - ... (conventional)
|
|
59
|
+
terminating shock strength p2/p1 = 1 + 2g/(g+1)(M^2-1)
|
|
60
|
+
- Wave drag area rule (aerodynamics/high-speed/wave-drag-area-rule):
|
|
61
|
+
Sears-Haack zero-lift wave drag area D/q = (9 pi/2)(A_max/L)^2
|
|
62
|
+
wave drag rise Delta CDw = k (M - M_DD)^2 above drag divergence
|
|
63
|
+
- Boundary layer theory (aerodynamics/boundary-layer/
|
|
64
|
+
boundary-layer-theory): laminar Blasius delta = 5 x/sqrt(Re_x),
|
|
65
|
+
delta* = 1.7208 x/sqrt(Re_x), theta = 0.664 x/sqrt(Re_x),
|
|
66
|
+
Cf = 0.664/sqrt(Re_x); turbulent 1/7-power delta = 0.37 x/Re_x^(1/5),
|
|
67
|
+
delta* = delta/8, theta = 7 delta/72, Cf = 0.0592/Re_x^(1/5),
|
|
68
|
+
log-law Cf = 0.455/(log10 Re_x)^2.58; regime at Re_tr = 5e5
|
|
69
|
+
- Boundary layer transition (aerodynamics/boundary-layer/
|
|
70
|
+
boundary-layer-transition): flat-plate Michel/Thwaites closed form
|
|
71
|
+
Re_theta = sqrt(0.45) sqrt(Re_x) crossing the Michel threshold
|
|
72
|
+
Re_theta,tr = 1.174 (1 + 22400/Re_x) Re_x^0.46
|
|
73
|
+
- Rough wall skin friction (aerodynamics/boundary-layer/
|
|
74
|
+
rough-wall-skin-friction): u_tau = U sqrt(Cf/2), k+ = rho u_tau k_s/mu,
|
|
75
|
+
regime bands smooth k+ < 5, transitional 5-70, fully rough > 70
|
|
76
|
+
- Stagnation flow boundary layer (aerodynamics/boundary-layer/
|
|
77
|
+
stagnation-flow-boundary-layer): Hiemenz 2-D leading edge
|
|
78
|
+
a = 2 U/R, delta = 2.4 sqrt(nu/a), tau_w = mu U sqrt(a/nu) fpp(1.2326),
|
|
79
|
+
Cf = tau_w/(0.5 rho U^2)
|
|
80
|
+
- Aerodynamic heating (aerodynamics/high-speed/aerodynamic-heating):
|
|
81
|
+
Sutton-Graves q_s = C_SG sqrt(rho/Rn) V^3, C_SG = 1.83e-4,
|
|
82
|
+
radiation-equilibrium T_w = (q/(eps sigma))^(1/4)
|
|
83
|
+
- Flat-plate skin friction heating (aerodynamics/high-speed/
|
|
84
|
+
flat-plate-skin-friction-heating): recovery factor r = Pr^(1/2)
|
|
85
|
+
laminar / Pr^(1/3) turbulent, adiabatic wall temperature
|
|
86
|
+
T_aw = T_inf (1 + r (g-1)/2 M^2), Eckert reference temperature,
|
|
87
|
+
cold-wall flux q = h_c (T_aw - T_wall), h_c = 0.5 Cf rho* U CP
|
|
88
|
+
- Bow shock standoff (aerodynamics/high-speed/bow-shock-standoff):
|
|
89
|
+
Billig correlations sphere Delta/R = 0.143 exp(3.24/M^2),
|
|
90
|
+
cylinder Delta/R = 0.386 exp(4.67/M^2)
|
|
91
|
+
|
|
92
|
+
Deliverable: the High-Speed Aerodynamic Analysis Memo
|
|
93
|
+
(render_memo_markdown), with evidence gates (check_memo /
|
|
94
|
+
check_memo_markdown) that verify the document before it leaves the
|
|
95
|
+
role. Every margin row in the memo traces to a stage output of the
|
|
96
|
+
bound workflow.
|
|
97
|
+
"""
|
|
98
|
+
from __future__ import annotations
|
|
99
|
+
|
|
100
|
+
import math
|
|
101
|
+
import re
|
|
102
|
+
from dataclasses import dataclass, field
|
|
103
|
+
from datetime import date
|
|
104
|
+
from typing import Dict, List, Optional
|
|
105
|
+
|
|
106
|
+
def _today() -> str:
|
|
107
|
+
import os
|
|
108
|
+
from datetime import date
|
|
109
|
+
return os.environ.get("ROLE_GEN_DATE", date.today().isoformat())
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
# Physical / ISA constants (public domain)
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
ISA_T0 = 288.15 # K, sea-level temperature
|
|
115
|
+
ISA_P0 = 101325.0 # Pa, sea-level pressure
|
|
116
|
+
ISA_RHO0 = 1.225 # kg/m^3, sea-level density
|
|
117
|
+
ISA_LAPSE = 0.0065 # K/m, troposphere lapse rate
|
|
118
|
+
ISA_TROPOPAUSE = 11000.0 # m
|
|
119
|
+
GAMMA = 1.4 # specific-heat ratio of air
|
|
120
|
+
R_GAS = 287.052874 # J/(kg K), gas constant of air
|
|
121
|
+
G0 = 9.80665 # m/s^2
|
|
122
|
+
SUTHERLAND_MU0 = 1.458e-6 # Sutherland law constant (kg/(m s K^1.5))
|
|
123
|
+
SUTHERLAND_S = 110.4 # Sutherland reference temperature (K)
|
|
124
|
+
CP_AIR = 1005.0 # J/(kg K) constant-pressure specific heat
|
|
125
|
+
PR_AIR = 0.71 # Prandtl number of air
|
|
126
|
+
|
|
127
|
+
# Compressible-flow constants mirrored from the bound leaves.
|
|
128
|
+
MFP_CONST = 0.0404184199 # mass flow parameter, sqrt(g/R)*(2/(g+1))^...
|
|
129
|
+
PG_MACH_LIMIT = 0.99 # P-G/KT bisection upper bound on Mach
|
|
130
|
+
KT_MACH_LIMIT = 0.85 # documented Karman-Tsien validity ceiling
|
|
131
|
+
SECTION_MCR_MIN_CP = -2.0 # plausible suction-peak floor for M_cr solve
|
|
132
|
+
RE_TRANSITION = 5e5 # flat-plate natural transition Reynolds number
|
|
133
|
+
C_SG = 1.83e-4 # Sutton-Graves air correlation constant
|
|
134
|
+
SIGMA_SB = 5.670374419e-8 # Stefan-Boltzmann constant, W/(m2 K4)
|
|
135
|
+
EPS_DEFAULT = 0.85 # TPS surface emissivity
|
|
136
|
+
RECOVERY_TURBULENT = PR_AIR ** (1.0 / 3.0) # Pr^(1/3), flat-plate turbulent
|
|
137
|
+
RECOVERY_LAMINAR = math.sqrt(PR_AIR) # Pr^(1/2), flat-plate laminar
|
|
138
|
+
|
|
139
|
+
# Item-level screening limits (engineering design values for the memo's
|
|
140
|
+
# reference vehicle; inputs, never invented regulatory allowables).
|
|
141
|
+
SKIN_TEMP_LIMIT_K = 423.0 # aluminium-alloy skin screening limit, ~150 C
|
|
142
|
+
NOSE_TEMP_LIMIT_K = 600.0 # nose-cap screening limit (leading-edge alloy)
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
# Validation helpers
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
def _require_positive(name: str, value: float) -> None:
|
|
149
|
+
if not (isinstance(value, (int, float)) and value > 0.0):
|
|
150
|
+
raise ValueError("%s must be positive, got %r" % (name, value))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _require_nonnegative(name: str, value: float) -> None:
|
|
154
|
+
if not (isinstance(value, (int, float)) and value >= 0.0):
|
|
155
|
+
raise ValueError("%s must be >= 0, got %r" % (name, value))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
# ISA standard atmosphere (public-domain lapse-rate model)
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
def isa_temperature(altitude_m: float) -> float:
|
|
163
|
+
"""ISA temperature (K) at geometric altitude (m), tropo+stratosphere."""
|
|
164
|
+
_require_nonnegative("altitude", altitude_m)
|
|
165
|
+
if altitude_m <= ISA_TROPOPAUSE:
|
|
166
|
+
return ISA_T0 - ISA_LAPSE * altitude_m
|
|
167
|
+
return ISA_T0 - ISA_LAPSE * ISA_TROPOPAUSE # 216.65 K isothermal
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def isa_pressure(altitude_m: float) -> float:
|
|
171
|
+
"""ISA static pressure (Pa) at altitude (m)."""
|
|
172
|
+
_require_nonnegative("altitude", altitude_m)
|
|
173
|
+
t = isa_temperature(altitude_m)
|
|
174
|
+
if altitude_m <= ISA_TROPOPAUSE:
|
|
175
|
+
return ISA_P0 * (t / ISA_T0) ** (G0 / (ISA_LAPSE * R_GAS))
|
|
176
|
+
p_tropo = ISA_P0 * (216.65 / ISA_T0) ** (G0 / (ISA_LAPSE * R_GAS))
|
|
177
|
+
return p_tropo * math.exp(-G0 * (altitude_m - ISA_TROPOPAUSE)
|
|
178
|
+
/ (R_GAS * 216.65))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def isa_density(altitude_m: float) -> float:
|
|
182
|
+
"""ISA air density (kg/m^3) from the perfect-gas law."""
|
|
183
|
+
_require_nonnegative("altitude", altitude_m)
|
|
184
|
+
return isa_pressure(altitude_m) / (R_GAS * isa_temperature(altitude_m))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def speed_of_sound(temperature_k: float) -> float:
|
|
188
|
+
"""Speed of sound a = sqrt(gamma*R*T) in m/s."""
|
|
189
|
+
_require_positive("temperature", temperature_k)
|
|
190
|
+
return math.sqrt(GAMMA * R_GAS * temperature_k)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def air_viscosity(temperature_k: float) -> float:
|
|
194
|
+
"""Dynamic viscosity mu (kg/(m s)) by Sutherland's law."""
|
|
195
|
+
_require_positive("temperature", temperature_k)
|
|
196
|
+
return (SUTHERLAND_MU0 * temperature_k ** 1.5) / (temperature_k + SUTHERLAND_S)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def atmosphere(altitude_m: float) -> Dict[str, float]:
|
|
200
|
+
"""ISA atmosphere state at altitude: T, p, rho, a, mu (SI units)."""
|
|
201
|
+
_require_nonnegative("altitude", altitude_m)
|
|
202
|
+
t = isa_temperature(altitude_m)
|
|
203
|
+
p = isa_pressure(altitude_m)
|
|
204
|
+
rho = isa_density(altitude_m)
|
|
205
|
+
a = speed_of_sound(t)
|
|
206
|
+
mu = air_viscosity(t)
|
|
207
|
+
return {"temperature": t, "pressure": p, "density": rho,
|
|
208
|
+
"speed_of_sound": a, "viscosity": mu}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ---------------------------------------------------------------------------
|
|
212
|
+
# Isentropic flow relations (isentropic-flow-relations leaf)
|
|
213
|
+
# ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
def total_static_ratios(mach: float) -> Dict[str, float]:
|
|
216
|
+
"""Isentropic total-to-static ratios at a Mach number (air, g = 1.4)."""
|
|
217
|
+
_require_nonnegative("mach", mach)
|
|
218
|
+
t0_over_t = 1.0 + 0.5 * (GAMMA - 1.0) * mach * mach
|
|
219
|
+
return {
|
|
220
|
+
"t0_over_t": t0_over_t,
|
|
221
|
+
"p0_over_p": t0_over_t ** (GAMMA / (GAMMA - 1.0)),
|
|
222
|
+
"rho0_over_rho": t0_over_t ** (1.0 / (GAMMA - 1.0)),
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def area_ratio(mach: float) -> float:
|
|
227
|
+
"""Isentropic area ratio A/A* for a Mach number (Mach-area relation)."""
|
|
228
|
+
if mach <= 0.0:
|
|
229
|
+
raise ValueError("Mach number must be > 0 for the area ratio, got %r" % (mach,))
|
|
230
|
+
base = (2.0 / (GAMMA + 1.0)) * (1.0 + 0.5 * (GAMMA - 1.0) * mach * mach)
|
|
231
|
+
exponent = (GAMMA + 1.0) / (2.0 * (GAMMA - 1.0))
|
|
232
|
+
return (1.0 / mach) * base ** exponent
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def mach_from_area_ratio(aa: float, subsonic: bool = True) -> float:
|
|
236
|
+
"""Recover the Mach number for an area ratio A/A* (deterministic bisection).
|
|
237
|
+
|
|
238
|
+
Subsonic root over [0.05, 1.0), supersonic root over (1.0, 20.0].
|
|
239
|
+
Mirrors the isentropic-flow-relations leaf solver.
|
|
240
|
+
"""
|
|
241
|
+
if aa < 1.0:
|
|
242
|
+
raise ValueError("A/A* must be >= 1.0 (sonic-throat floor), got %r" % (aa,))
|
|
243
|
+
if aa == 1.0:
|
|
244
|
+
return 1.0
|
|
245
|
+
if subsonic:
|
|
246
|
+
lo, hi = 0.05, 1.0
|
|
247
|
+
else:
|
|
248
|
+
lo, hi = 1.0, 20.0
|
|
249
|
+
f_lo = area_ratio(lo) - aa
|
|
250
|
+
for _ in range(300):
|
|
251
|
+
if hi - lo < 1e-12:
|
|
252
|
+
break
|
|
253
|
+
mid = 0.5 * (lo + hi)
|
|
254
|
+
f_mid = area_ratio(mid) - aa
|
|
255
|
+
if (f_mid < 0.0) == (f_lo < 0.0):
|
|
256
|
+
lo = mid
|
|
257
|
+
else:
|
|
258
|
+
hi = mid
|
|
259
|
+
return 0.5 * (lo + hi)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def static_to_total(p_static: float, t_static: float, mach: float) -> Dict[str, float]:
|
|
263
|
+
"""Rebuild total conditions from a static state and Mach number."""
|
|
264
|
+
_require_positive("static pressure", p_static)
|
|
265
|
+
_require_positive("static temperature", t_static)
|
|
266
|
+
ratios = total_static_ratios(mach)
|
|
267
|
+
return {"p0": p_static * ratios["p0_over_p"],
|
|
268
|
+
"t0": t_static * ratios["t0_over_t"]}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def choked_mass_flow(p0: float, t0: float, area_star: float) -> float:
|
|
272
|
+
"""Choked mass flow mdot (kg/s) through a sonic throat area A* (m2)."""
|
|
273
|
+
_require_positive("total pressure", p0)
|
|
274
|
+
_require_positive("total temperature", t0)
|
|
275
|
+
_require_positive("throat area", area_star)
|
|
276
|
+
return MFP_CONST * p0 * area_star / math.sqrt(t0)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# ---------------------------------------------------------------------------
|
|
280
|
+
# Compressibility corrections (transonic-similarity leaf)
|
|
281
|
+
# ---------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
def prandtl_glauert_factor(mach: float) -> float:
|
|
284
|
+
"""Prandtl-Glauert compressibility factor 1/sqrt(1-M^2), M < 1."""
|
|
285
|
+
_require_nonnegative("mach", mach)
|
|
286
|
+
if mach >= 1.0:
|
|
287
|
+
raise ValueError("P-G factor is subsonic only (M < 1), got %r" % (mach,))
|
|
288
|
+
return 1.0 / math.sqrt(1.0 - mach * mach)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def prandtl_glauert_correction(cp0: float, mach: float) -> float:
|
|
292
|
+
"""Incompressible Cp corrected by the Prandtl-Glauert rule."""
|
|
293
|
+
return cp0 * prandtl_glauert_factor(mach)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def karman_tsien_correction(cp0: float, mach: float) -> float:
|
|
297
|
+
"""Pressure coefficient corrected by the Karman-Tsien rule.
|
|
298
|
+
|
|
299
|
+
Cp = Cp0 / (sqrt(1-M^2) + (M^2/(1+sqrt(1-M^2))) * Cp0/2).
|
|
300
|
+
"""
|
|
301
|
+
_require_nonnegative("mach", mach)
|
|
302
|
+
if mach >= KT_MACH_LIMIT:
|
|
303
|
+
raise ValueError("Karman-Tsien is documented to M ~ 0.85, got %r" % (mach,))
|
|
304
|
+
root = math.sqrt(1.0 - mach * mach)
|
|
305
|
+
return cp0 / (root + (mach * mach / (1.0 + root)) * cp0 / 2.0)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def critical_pressure_coefficient(mach: float) -> float:
|
|
309
|
+
"""Isentropic pressure coefficient at which local flow reaches M = 1.
|
|
310
|
+
|
|
311
|
+
Cp* = (2/(g M^2)) * (((1 + 0.5(g-1)M^2)/(0.5(g+1)))^(g/(g-1)) - 1).
|
|
312
|
+
"""
|
|
313
|
+
_require_nonnegative("mach", mach)
|
|
314
|
+
if mach == 0.0:
|
|
315
|
+
raise ValueError("Mach must be > 0 for Cp*, got 0")
|
|
316
|
+
term = (1.0 + 0.5 * (GAMMA - 1.0) * mach * mach) / (0.5 * (GAMMA + 1.0))
|
|
317
|
+
return (2.0 / (GAMMA * mach * mach)) * (term ** (GAMMA / (GAMMA - 1.0)) - 1.0)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def critical_mach_number(cp_min0: float) -> float:
|
|
321
|
+
"""Critical Mach number for an incompressible peak suction Cp0 < 0.
|
|
322
|
+
|
|
323
|
+
Solves Cp0 / sqrt(1 - M^2) = Cp*(M) by bisection on M in [0.02, 0.99].
|
|
324
|
+
"""
|
|
325
|
+
if cp_min0 >= 0.0:
|
|
326
|
+
raise ValueError("peak suction cp_min0 must be negative, got %r" % (cp_min0,))
|
|
327
|
+
lo, hi = 0.02, PG_MACH_LIMIT
|
|
328
|
+
for _ in range(200):
|
|
329
|
+
mid = 0.5 * (lo + hi)
|
|
330
|
+
lhs = cp_min0 / math.sqrt(1.0 - mid * mid)
|
|
331
|
+
rhs = critical_pressure_coefficient(mid)
|
|
332
|
+
if lhs - rhs > 0.0:
|
|
333
|
+
lo = mid
|
|
334
|
+
else:
|
|
335
|
+
hi = mid
|
|
336
|
+
if hi - lo < 1e-10:
|
|
337
|
+
break
|
|
338
|
+
return 0.5 * (lo + hi)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def transonic_similarity_parameter(mach: float, tau: float) -> float:
|
|
342
|
+
"""Transonic similarity parameter K = (1 - M^2) / tau^(2/3)."""
|
|
343
|
+
if not (0.0 <= mach <= 1.0):
|
|
344
|
+
raise ValueError("Mach must be in [0, 1], got %r" % (mach,))
|
|
345
|
+
if not (0.0 < tau < 1.0):
|
|
346
|
+
raise ValueError("thickness ratio tau must be in (0, 1), got %r" % (tau,))
|
|
347
|
+
return (1.0 - mach * mach) / (tau ** (2.0 / 3.0))
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# ---------------------------------------------------------------------------
|
|
351
|
+
# Swept wing (swept-wing-aerodynamics leaf)
|
|
352
|
+
# ---------------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
def cos_sweep(sweep_deg: float) -> float:
|
|
355
|
+
"""Cosine of the sweep angle (degrees)."""
|
|
356
|
+
if not (0.0 <= sweep_deg < 90.0):
|
|
357
|
+
raise ValueError("sweep must be in [0, 90) degrees, got %r" % (sweep_deg,))
|
|
358
|
+
return math.cos(math.radians(sweep_deg))
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def effective_mach(mach: float, sweep_deg: float) -> float:
|
|
362
|
+
"""Section (effective) Mach number of a yawed wing: M * cos(Lambda)."""
|
|
363
|
+
if not (0.0 <= mach < 1.0):
|
|
364
|
+
raise ValueError("effective-Mach model is subsonic (M < 1), got %r" % (mach,))
|
|
365
|
+
return mach * cos_sweep(sweep_deg)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def swept_critical_mach(mcrit0: float, sweep_deg: float) -> float:
|
|
369
|
+
"""Critical Mach of the swept wing: mcrit0 / cos(Lambda)."""
|
|
370
|
+
if not (0.0 < mcrit0 < 1.0):
|
|
371
|
+
raise ValueError("unswept critical Mach must be in (0, 1), got %r" % (mcrit0,))
|
|
372
|
+
mcrit = mcrit0 / cos_sweep(sweep_deg)
|
|
373
|
+
if mcrit >= 1.0:
|
|
374
|
+
raise ValueError("swept critical Mach reaches 1 (subsonic theory only)")
|
|
375
|
+
return mcrit
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# ---------------------------------------------------------------------------
|
|
379
|
+
# Normal shock (normal-shock leaf)
|
|
380
|
+
# ---------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
def normal_shock(m1: float) -> Dict[str, float]:
|
|
383
|
+
"""All normal-shock ratios at upstream Mach M1 (unitless, air)."""
|
|
384
|
+
if m1 <= 1.0:
|
|
385
|
+
raise ValueError("M1 must be > 1 (upstream flow supersonic), got %r" % (m1,))
|
|
386
|
+
g = GAMMA
|
|
387
|
+
m2 = math.sqrt((1.0 + 0.5 * (g - 1.0) * m1 * m1)
|
|
388
|
+
/ (g * m1 * m1 - 0.5 * (g - 1.0)))
|
|
389
|
+
p2_p1 = 1.0 + 2.0 * g / (g + 1.0) * (m1 * m1 - 1.0)
|
|
390
|
+
rho2_rho1 = (g + 1.0) * m1 * m1 / (2.0 + (g - 1.0) * m1 * m1)
|
|
391
|
+
p02_p01 = (p2_p1 ** (1.0 / (1.0 - g))
|
|
392
|
+
* rho2_rho1 ** (g / (g - 1.0)))
|
|
393
|
+
return {"m2": m2, "p2_p1": p2_p1, "t2_t1": p2_p1 / rho2_rho1,
|
|
394
|
+
"rho2_rho1": rho2_rho1, "p02_p01": p02_p01}
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
# ---------------------------------------------------------------------------
|
|
398
|
+
# Oblique shock (oblique-shock leaf)
|
|
399
|
+
# ---------------------------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
def mach_angle(m1: float) -> float:
|
|
402
|
+
"""Mach wave angle mu = asin(1/M1) in degrees."""
|
|
403
|
+
if m1 <= 1.0:
|
|
404
|
+
raise ValueError("M1 must be > 1, got %r" % (m1,))
|
|
405
|
+
return math.degrees(math.asin(1.0 / m1))
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _theta_from_beta(m1: float, beta_deg: float) -> float:
|
|
409
|
+
"""Flow deflection theta (deg) across an oblique shock at wave angle beta."""
|
|
410
|
+
b = math.radians(beta_deg)
|
|
411
|
+
m1s = m1 * m1
|
|
412
|
+
num = 2.0 * (1.0 / math.tan(b)) * (m1s * math.sin(b) ** 2 - 1.0)
|
|
413
|
+
den = m1s * (GAMMA + math.cos(2.0 * b)) + 2.0
|
|
414
|
+
return math.degrees(math.atan(num / den))
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def deflection_limit(m1: float) -> float:
|
|
418
|
+
"""Maximum attached-shock deflection theta_max (deg) at M1.
|
|
419
|
+
|
|
420
|
+
Apex of the shock polar, by ternary search over (mu, 90 deg].
|
|
421
|
+
"""
|
|
422
|
+
if m1 <= 1.0:
|
|
423
|
+
raise ValueError("M1 must be > 1, got %r" % (m1,))
|
|
424
|
+
mu = mach_angle(m1)
|
|
425
|
+
lo, hi = mu + 1e-9, 90.0
|
|
426
|
+
for _ in range(200):
|
|
427
|
+
a = (2.0 * lo + hi) / 3.0
|
|
428
|
+
b = (lo + 2.0 * hi) / 3.0
|
|
429
|
+
if _theta_from_beta(m1, a) < _theta_from_beta(m1, b):
|
|
430
|
+
lo = a
|
|
431
|
+
else:
|
|
432
|
+
hi = b
|
|
433
|
+
mid = 0.5 * (lo + hi)
|
|
434
|
+
return _theta_from_beta(m1, mid)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def oblique_shock(m1: float, theta_deg: float, strong: bool = False) -> Dict[str, float]:
|
|
438
|
+
"""Downstream state across the oblique shock (weak or strong branch).
|
|
439
|
+
|
|
440
|
+
Solves the theta-beta-M relation for the wave angle by bisection,
|
|
441
|
+
then applies the normal-shock ratios to M1n = M1 sin(beta).
|
|
442
|
+
"""
|
|
443
|
+
if m1 <= 1.0:
|
|
444
|
+
raise ValueError("M1 must be > 1, got %r" % (m1,))
|
|
445
|
+
if theta_deg < 0.0:
|
|
446
|
+
raise ValueError("theta must be >= 0 (a negative deflection is an "
|
|
447
|
+
"expansion), got %r" % (theta_deg,))
|
|
448
|
+
tmax = deflection_limit(m1)
|
|
449
|
+
if theta_deg > tmax:
|
|
450
|
+
raise ValueError("theta %g exceeds theta_max %g at M1 %g: detached"
|
|
451
|
+
% (theta_deg, tmax, m1))
|
|
452
|
+
mu = mach_angle(m1)
|
|
453
|
+
if theta_deg <= 1e-12:
|
|
454
|
+
if strong:
|
|
455
|
+
ns = normal_shock(m1) # beta -> 90 deg collapses to the normal shock
|
|
456
|
+
return {"beta_deg": 90.0, "m2": ns["m2"], "p2_p1": ns["p2_p1"],
|
|
457
|
+
"rho2_rho1": ns["rho2_rho1"], "t2_t1": ns["t2_t1"],
|
|
458
|
+
"p02_p01": ns["p02_p01"], "theta_max_deg": tmax,
|
|
459
|
+
"strong": True}
|
|
460
|
+
# weak limit: the shock collapses onto the Mach wave (isentropic)
|
|
461
|
+
return {"beta_deg": mu, "m2": m1, "p2_p1": 1.0, "rho2_rho1": 1.0,
|
|
462
|
+
"t2_t1": 1.0, "p02_p01": 1.0, "theta_max_deg": tmax,
|
|
463
|
+
"strong": False}
|
|
464
|
+
|
|
465
|
+
def _target(beta):
|
|
466
|
+
return _theta_from_beta(m1, beta) - theta_deg
|
|
467
|
+
|
|
468
|
+
# locate the peak beta (apex) to bracket weak and strong branches
|
|
469
|
+
lo, hi = mu + 1e-9, 90.0
|
|
470
|
+
for _ in range(200):
|
|
471
|
+
a = (2.0 * lo + hi) / 3.0
|
|
472
|
+
b = (lo + 2.0 * hi) / 3.0
|
|
473
|
+
if _theta_from_beta(m1, a) < _theta_from_beta(m1, b):
|
|
474
|
+
lo = a
|
|
475
|
+
else:
|
|
476
|
+
hi = b
|
|
477
|
+
bmax = 0.5 * (lo + hi)
|
|
478
|
+
if strong:
|
|
479
|
+
lo_b, hi_b = bmax, 90.0
|
|
480
|
+
else:
|
|
481
|
+
lo_b, hi_b = mu + 1e-9, bmax
|
|
482
|
+
for _ in range(300):
|
|
483
|
+
mid = 0.5 * (lo_b + hi_b)
|
|
484
|
+
if _target(mid) < 0.0:
|
|
485
|
+
lo_b = mid
|
|
486
|
+
else:
|
|
487
|
+
hi_b = mid
|
|
488
|
+
if hi_b - lo_b < 1e-10:
|
|
489
|
+
break
|
|
490
|
+
beta = 0.5 * (lo_b + hi_b)
|
|
491
|
+
m1n = m1 * math.sin(math.radians(beta))
|
|
492
|
+
g = GAMMA
|
|
493
|
+
m2n = math.sqrt((1.0 + 0.5 * (g - 1.0) * m1n * m1n)
|
|
494
|
+
/ (g * m1n * m1n - 0.5 * (g - 1.0)))
|
|
495
|
+
p2_p1 = 1.0 + 2.0 * g / (g + 1.0) * (m1n * m1n - 1.0)
|
|
496
|
+
rho2_rho1 = (g + 1.0) * m1n * m1n / (2.0 + (g - 1.0) * m1n * m1n)
|
|
497
|
+
p02_p01 = (p2_p1 ** (1.0 / (1.0 - g))
|
|
498
|
+
* rho2_rho1 ** (g / (g - 1.0)))
|
|
499
|
+
m2 = m2n / math.sin(math.radians(beta - theta_deg))
|
|
500
|
+
return {"beta_deg": beta, "m2": m2, "p2_p1": p2_p1,
|
|
501
|
+
"rho2_rho1": rho2_rho1, "t2_t1": p2_p1 / rho2_rho1,
|
|
502
|
+
"p02_p01": p02_p01, "theta_max_deg": tmax, "strong": bool(strong)}
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def shock_reflection_verdict(m1: float, theta_deg: float) -> Dict:
|
|
506
|
+
"""Two-shock regular-reflection verdict at (M1, theta) (straight wall).
|
|
507
|
+
|
|
508
|
+
Incident oblique shock at (M1, theta); the reflected shock must turn
|
|
509
|
+
the flow back parallel to the wall by the same deflection. Verdict is
|
|
510
|
+
'regular' when the incident downstream Mach M2 > 1 and theta is below
|
|
511
|
+
the reflected-shock detachment limit at M2; otherwise 'mach'.
|
|
512
|
+
"""
|
|
513
|
+
incident = oblique_shock(m1, theta_deg)
|
|
514
|
+
m2 = incident["m2"]
|
|
515
|
+
theta_max_ref = deflection_limit(m2) if m2 and m2 > 1.0 else 0.0
|
|
516
|
+
result = {
|
|
517
|
+
"verdict": None, "theta_deg": theta_deg, "M2": m2,
|
|
518
|
+
"theta_max_ref_deg": theta_max_ref,
|
|
519
|
+
"incident": incident, "reflected": None, "reason": None,
|
|
520
|
+
}
|
|
521
|
+
if m2 and m2 > 1.0 and theta_deg < theta_max_ref:
|
|
522
|
+
result["verdict"] = "regular"
|
|
523
|
+
result["reflected"] = oblique_shock(m2, theta_deg)
|
|
524
|
+
result["reason"] = ("reflected shock turns the flow back parallel "
|
|
525
|
+
"to the wall below the detachment limit")
|
|
526
|
+
else:
|
|
527
|
+
result["verdict"] = "mach"
|
|
528
|
+
result["reason"] = ("required reflected deflection reaches the "
|
|
529
|
+
"reflected-shock detachment limit at M2 "
|
|
530
|
+
"(or M2 <= 1): Mach reflection")
|
|
531
|
+
return result
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
# ---------------------------------------------------------------------------
|
|
535
|
+
# Prandtl-Meyer expansion (prandtl-meyer leaf)
|
|
536
|
+
# ---------------------------------------------------------------------------
|
|
537
|
+
|
|
538
|
+
def prandtl_meyer_function(mach: float) -> float:
|
|
539
|
+
"""Prandtl-Meyer angle nu(M) in radians (M >= 1)."""
|
|
540
|
+
if mach < 1.0:
|
|
541
|
+
raise ValueError("Prandtl-Meyer function undefined below M = 1, got %r"
|
|
542
|
+
% (mach,))
|
|
543
|
+
term = math.sqrt((GAMMA - 1.0) / (GAMMA + 1.0) * (mach * mach - 1.0))
|
|
544
|
+
return (math.sqrt((GAMMA + 1.0) / (GAMMA - 1.0)) * math.atan(term)
|
|
545
|
+
- math.atan(math.sqrt(mach * mach - 1.0)))
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def mach_after_expansion(m1: float, turning_angle_deg: float,
|
|
549
|
+
bracket=(1.0, 50.0)) -> float:
|
|
550
|
+
"""Downstream Mach number after an isentropic turn of given angle."""
|
|
551
|
+
if m1 < 1.0:
|
|
552
|
+
raise ValueError("upstream Mach must be >= 1, got %r" % (m1,))
|
|
553
|
+
if turning_angle_deg < 0.0:
|
|
554
|
+
raise ValueError("turning angle must be >= 0, got %r" % (turning_angle_deg,))
|
|
555
|
+
target = prandtl_meyer_function(m1) + math.radians(turning_angle_deg)
|
|
556
|
+
lo, hi = bracket
|
|
557
|
+
if target > prandtl_meyer_function(hi):
|
|
558
|
+
raise ValueError("turning angle too large for the Mach bracket")
|
|
559
|
+
for _ in range(300):
|
|
560
|
+
mid = 0.5 * (lo + hi)
|
|
561
|
+
if prandtl_meyer_function(mid) < target:
|
|
562
|
+
lo = mid
|
|
563
|
+
else:
|
|
564
|
+
hi = mid
|
|
565
|
+
if hi - lo < 1e-10:
|
|
566
|
+
break
|
|
567
|
+
return 0.5 * (lo + hi)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def expansion_properties(m1: float, turning_angle_deg: float) -> Dict[str, float]:
|
|
571
|
+
"""Downstream state across a Prandtl-Meyer expansion (unitless)."""
|
|
572
|
+
m2 = mach_after_expansion(m1, turning_angle_deg)
|
|
573
|
+
def _p_over_p0(m):
|
|
574
|
+
return (1.0 + 0.5 * (GAMMA - 1.0) * m * m) ** (-GAMMA / (GAMMA - 1.0))
|
|
575
|
+
return {"m2": m2, "turning_angle_deg": turning_angle_deg,
|
|
576
|
+
"pressure_ratio_p2_p1": _p_over_p0(m2) / _p_over_p0(m1)}
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
# ---------------------------------------------------------------------------
|
|
580
|
+
# Shock-expansion diamond airfoil (shock-expansion-airfoil leaf)
|
|
581
|
+
# ---------------------------------------------------------------------------
|
|
582
|
+
|
|
583
|
+
def _pm_mach_from_angle(nu_deg: float) -> float:
|
|
584
|
+
"""Invert nu(M) = nu_deg (degrees) for the Mach number (bisection)."""
|
|
585
|
+
target = math.radians(nu_deg)
|
|
586
|
+
lo, hi = 1.0 + 1e-12, 2.0
|
|
587
|
+
while prandtl_meyer_function(hi) < target:
|
|
588
|
+
hi *= 2.0
|
|
589
|
+
for _ in range(300):
|
|
590
|
+
mid = 0.5 * (lo + hi)
|
|
591
|
+
if prandtl_meyer_function(mid) < target:
|
|
592
|
+
lo = mid
|
|
593
|
+
else:
|
|
594
|
+
hi = mid
|
|
595
|
+
if hi - lo < 1e-10:
|
|
596
|
+
break
|
|
597
|
+
return 0.5 * (lo + hi)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _panel_turn(m: float, p_ratio_in: float, theta_deg: float):
|
|
601
|
+
"""State after a turn: positive theta is an oblique-shock compression,
|
|
602
|
+
negative theta is a Prandtl-Meyer expansion. Returns (m_out, p_out)."""
|
|
603
|
+
if theta_deg > 0.0:
|
|
604
|
+
st = oblique_shock(m, theta_deg)
|
|
605
|
+
return st["m2"], p_ratio_in * st["p2_p1"]
|
|
606
|
+
if theta_deg < 0.0:
|
|
607
|
+
m2 = _pm_mach_from_angle(math.degrees(prandtl_meyer_function(m))
|
|
608
|
+
- theta_deg)
|
|
609
|
+
p2_p1 = ((1.0 + 0.5 * (GAMMA - 1.0) * m * m)
|
|
610
|
+
/ (1.0 + 0.5 * (GAMMA - 1.0) * m2 * m2)) ** (GAMMA / (GAMMA - 1.0))
|
|
611
|
+
return m2, p_ratio_in * p2_p1
|
|
612
|
+
return m, p_ratio_in
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def shock_expansion_airfoil(m1: float, alpha_deg: float, eps_deg: float) -> Dict:
|
|
616
|
+
"""Shock-expansion solution for a diamond (double-wedge) airfoil.
|
|
617
|
+
|
|
618
|
+
Returns section cl, cd_wave and cm_le (leading-edge moment, positive
|
|
619
|
+
nose-up) with chord c = 1 and q_ref = 0.5 g M1^2 normalization,
|
|
620
|
+
mirroring the shock-expansion-airfoil leaf panel model.
|
|
621
|
+
"""
|
|
622
|
+
if m1 <= 1.0:
|
|
623
|
+
raise ValueError("shock-expansion requires supersonic m1 > 1")
|
|
624
|
+
if abs(alpha_deg) >= 90.0:
|
|
625
|
+
raise ValueError("|alpha| must be below 90 deg")
|
|
626
|
+
if not (0.0 <= eps_deg < 45.0):
|
|
627
|
+
raise ValueError("eps (half-angle) must lie in [0, 45) deg")
|
|
628
|
+
q_ref = 0.5 * GAMMA * m1 * m1
|
|
629
|
+
|
|
630
|
+
# Panel deflections in degrees (positive = compression).
|
|
631
|
+
theta_uf = eps_deg - alpha_deg
|
|
632
|
+
theta_lf = eps_deg + alpha_deg
|
|
633
|
+
theta_rear = -2.0 * eps_deg
|
|
634
|
+
|
|
635
|
+
m_uf, p_uf = _panel_turn(m1, 1.0, theta_uf)
|
|
636
|
+
m_lf, p_lf = _panel_turn(m1, 1.0, theta_lf)
|
|
637
|
+
m_ur, p_ur = _panel_turn(m_uf, p_uf, theta_rear)
|
|
638
|
+
m_lr, p_lr = _panel_turn(m_lf, p_lf, theta_rear)
|
|
639
|
+
surfaces = {
|
|
640
|
+
"uf": {"cp": (p_uf - 1.0) / q_ref, "p_pinf": p_uf},
|
|
641
|
+
"ur": {"cp": (p_ur - 1.0) / q_ref, "p_pinf": p_ur},
|
|
642
|
+
"lf": {"cp": (p_lf - 1.0) / q_ref, "p_pinf": p_lf},
|
|
643
|
+
"lr": {"cp": (p_lr - 1.0) / q_ref, "p_pinf": p_lr},
|
|
644
|
+
}
|
|
645
|
+
alpha = math.radians(alpha_deg)
|
|
646
|
+
eps = math.radians(eps_deg)
|
|
647
|
+
sin_e, cos_e = math.sin(eps), math.cos(eps)
|
|
648
|
+
normals = {"uf": (sin_e, -cos_e), "ur": (-sin_e, -cos_e),
|
|
649
|
+
"lf": (sin_e, cos_e), "lr": (-sin_e, cos_e)}
|
|
650
|
+
centers = {"uf": (0.25, 0.25 * math.tan(eps)),
|
|
651
|
+
"ur": (0.75, 0.25 * math.tan(eps)),
|
|
652
|
+
"lf": (0.25, -0.25 * math.tan(eps)),
|
|
653
|
+
"lr": (0.75, -0.25 * math.tan(eps))}
|
|
654
|
+
panel_len = 0.5 / cos_e
|
|
655
|
+
fx = fy = moment = 0.0
|
|
656
|
+
for key in ("uf", "ur", "lf", "lr"):
|
|
657
|
+
dp = surfaces[key]["p_pinf"] - 1.0
|
|
658
|
+
nx, ny = normals[key]
|
|
659
|
+
cx, cy = centers[key]
|
|
660
|
+
fxi = dp * panel_len * nx
|
|
661
|
+
fyi = dp * panel_len * ny
|
|
662
|
+
fx += fxi
|
|
663
|
+
fy += fyi
|
|
664
|
+
moment += cx * fyi - cy * fxi
|
|
665
|
+
cos_a, sin_a = math.cos(alpha), math.sin(alpha)
|
|
666
|
+
drag = fx * cos_a + fy * sin_a
|
|
667
|
+
lift = -fx * sin_a + fy * cos_a
|
|
668
|
+
return {"m1": m1, "alpha_deg": alpha_deg, "eps_deg": eps_deg,
|
|
669
|
+
"cl": lift / q_ref, "cd_wave": drag / q_ref,
|
|
670
|
+
"cm_le": moment / q_ref,
|
|
671
|
+
"surface_cp": {k: v["cp"] for k, v in surfaces.items()}}
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
# ---------------------------------------------------------------------------
|
|
675
|
+
# Supercritical section / Korn rule (supercritical-airfoil leaf)
|
|
676
|
+
# ---------------------------------------------------------------------------
|
|
677
|
+
|
|
678
|
+
def drag_divergence_mach(t_over_c: float, cl: float,
|
|
679
|
+
supercritical: bool = True) -> float:
|
|
680
|
+
"""Korn-rule drag-divergence Mach number of a section."""
|
|
681
|
+
if not (0.02 < t_over_c < 0.30):
|
|
682
|
+
raise ValueError("t/c must be in (0.02, 0.30), got %r" % (t_over_c,))
|
|
683
|
+
if not (0.0 <= cl < 1.5):
|
|
684
|
+
raise ValueError("cruise lift coefficient must be in [0, 1.5)")
|
|
685
|
+
base = 0.95 if supercritical else 0.90
|
|
686
|
+
return base - t_over_c - cl / 10.0
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def terminating_shock_strength(mach_ahead: float) -> float:
|
|
690
|
+
"""Static pressure ratio across the terminating shock (local M in (1,2])."""
|
|
691
|
+
if not (1.0 < mach_ahead <= 2.0):
|
|
692
|
+
raise ValueError("local Mach ahead must be in (1, 2], got %r"
|
|
693
|
+
% (mach_ahead,))
|
|
694
|
+
return 1.0 + 2.0 * GAMMA / (GAMMA + 1.0) * (mach_ahead ** 2 - 1.0)
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def wave_drag_penalty(mach: float, mdd: float) -> float:
|
|
698
|
+
"""Relative wave-drag penalty index: (M - M_DD)^3 above divergence."""
|
|
699
|
+
if not (0.5 <= mach < 1.0):
|
|
700
|
+
raise ValueError("flight Mach must be in [0.5, 1), got %r" % (mach,))
|
|
701
|
+
if not (0.5 < mdd < 1.0):
|
|
702
|
+
raise ValueError("drag-divergence Mach must be in (0.5, 1)")
|
|
703
|
+
excess = mach - mdd
|
|
704
|
+
return 0.0 if excess <= 0.0 else excess ** 3
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
# ---------------------------------------------------------------------------
|
|
708
|
+
# Wave drag area rule (wave-drag-area-rule leaf)
|
|
709
|
+
# ---------------------------------------------------------------------------
|
|
710
|
+
|
|
711
|
+
def sears_haack_wave_drag_area(length: float, r_max: float) -> float:
|
|
712
|
+
"""Zero-lift wave drag area D/q of the Sears-Haack body of revolution.
|
|
713
|
+
|
|
714
|
+
D/q = (9 pi / 2) * (A_max / L)^2, A_max = pi * r_max^2.
|
|
715
|
+
"""
|
|
716
|
+
_require_positive("body length", length)
|
|
717
|
+
_require_positive("maximum radius", r_max)
|
|
718
|
+
a_max = math.pi * r_max * r_max
|
|
719
|
+
return (9.0 * math.pi / 2.0) * (a_max / length) ** 2
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def wave_drag_rise_coef(mach: float, mdd: float, k: float = 20.0) -> float:
|
|
723
|
+
"""Parabolic wave-drag rise Delta CDw = k (M - M_DD)^2 above M_DD."""
|
|
724
|
+
if not (0.0 <= mach < 1.0):
|
|
725
|
+
raise ValueError("Mach must be in [0, 1)")
|
|
726
|
+
if not (0.0 < mdd < 1.0):
|
|
727
|
+
raise ValueError("drag-divergence Mach must be in (0, 1)")
|
|
728
|
+
if k <= 0.0:
|
|
729
|
+
raise ValueError("parabolic constant k must be positive")
|
|
730
|
+
return 0.0 if mach <= mdd else k * (mach - mdd) ** 2
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
# ---------------------------------------------------------------------------
|
|
734
|
+
# Boundary layer theory (boundary-layer-theory leaf)
|
|
735
|
+
# ---------------------------------------------------------------------------
|
|
736
|
+
|
|
737
|
+
def reynolds(rho: float, v: float, l: float, mu: float) -> float:
|
|
738
|
+
"""Reynolds number Re = rho * V * L / mu."""
|
|
739
|
+
_require_positive("rho", rho)
|
|
740
|
+
_require_positive("v", v)
|
|
741
|
+
_require_positive("l", l)
|
|
742
|
+
_require_positive("mu", mu)
|
|
743
|
+
return rho * v * l / mu
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def kinematic_viscosity(mu: float, rho: float) -> float:
|
|
747
|
+
"""Kinematic viscosity nu = mu / rho."""
|
|
748
|
+
_require_positive("mu", mu)
|
|
749
|
+
_require_positive("rho", rho)
|
|
750
|
+
return mu / rho
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def classify_regime(re_x: float, re_tr: float = RE_TRANSITION) -> str:
|
|
754
|
+
"""'laminar' below the transition Reynolds number, else 'turbulent'."""
|
|
755
|
+
_require_positive("re_x", re_x)
|
|
756
|
+
_require_positive("transition re", re_tr)
|
|
757
|
+
return "laminar" if re_x < re_tr else "turbulent"
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def flat_plate_thicknesses(x: float, re_x: float,
|
|
761
|
+
re_tr: float = RE_TRANSITION) -> Dict:
|
|
762
|
+
"""Flat-plate boundary-layer summary at station x: regime, delta,
|
|
763
|
+
delta*, theta, shape factor, local and average skin friction."""
|
|
764
|
+
_require_positive("station x", x)
|
|
765
|
+
_require_positive("re_x", re_x)
|
|
766
|
+
regime = classify_regime(re_x, re_tr)
|
|
767
|
+
if regime == "laminar":
|
|
768
|
+
delta = 5.0 * x / math.sqrt(re_x)
|
|
769
|
+
dstar = 1.7208 * x / math.sqrt(re_x)
|
|
770
|
+
theta = 0.664 * x / math.sqrt(re_x)
|
|
771
|
+
cf_local = 0.664 / math.sqrt(re_x)
|
|
772
|
+
cf_avg = 1.328 / math.sqrt(re_x)
|
|
773
|
+
else:
|
|
774
|
+
delta = 0.37 * x / re_x ** 0.2
|
|
775
|
+
dstar = delta / 8.0
|
|
776
|
+
theta = 7.0 * delta / 72.0
|
|
777
|
+
cf_local = 0.0592 / re_x ** 0.2
|
|
778
|
+
cf_avg = 0.074 / re_x ** 0.2
|
|
779
|
+
return {"regime": regime, "delta": delta, "delta_star": dstar,
|
|
780
|
+
"theta": theta, "shape_factor": dstar / theta,
|
|
781
|
+
"cf_local": cf_local, "cf_average": cf_avg}
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def cf_turbulent_log_law(re_x: float) -> float:
|
|
785
|
+
"""Turbulent skin friction from the log law: 0.455/(log10 Re)^2.58."""
|
|
786
|
+
if re_x <= 10.0:
|
|
787
|
+
raise ValueError("Reynolds number must be > 10 for the log law")
|
|
788
|
+
return 0.455 / (math.log10(re_x) ** 2.58)
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
# ---------------------------------------------------------------------------
|
|
792
|
+
# Boundary layer transition (boundary-layer-transition leaf)
|
|
793
|
+
# ---------------------------------------------------------------------------
|
|
794
|
+
|
|
795
|
+
def michel_threshold(re_x: float) -> float:
|
|
796
|
+
"""Michel transition criterion Re_theta,tr at Re_x."""
|
|
797
|
+
_require_positive("re_x", re_x)
|
|
798
|
+
return 1.174 * (1.0 + 22400.0 / re_x) * re_x ** 0.46
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def flat_plate_transition(nu: float, ue: float, x_max: float):
|
|
802
|
+
"""Natural-transition location x_tr (m) on a flat plate at edge speed ue.
|
|
803
|
+
|
|
804
|
+
Closed-form Michel check with Re_theta = sqrt(0.45) sqrt(Re_x),
|
|
805
|
+
scanned from 1e-3 m to x_max; None when no crossing exists.
|
|
806
|
+
"""
|
|
807
|
+
_require_positive("nu", nu)
|
|
808
|
+
_require_positive("ue", ue)
|
|
809
|
+
_require_positive("x_max", x_max)
|
|
810
|
+
lo, steps = 1e-3, 500
|
|
811
|
+
dx = (x_max - lo) / steps
|
|
812
|
+
for i in range(steps + 1):
|
|
813
|
+
x = lo + i * dx
|
|
814
|
+
re_x = ue * x / nu
|
|
815
|
+
re_theta = math.sqrt(0.45) * math.sqrt(re_x)
|
|
816
|
+
if re_theta >= michel_threshold(re_x):
|
|
817
|
+
return x
|
|
818
|
+
return None
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
# ---------------------------------------------------------------------------
|
|
822
|
+
# Rough wall skin friction (rough-wall-skin-friction leaf)
|
|
823
|
+
# ---------------------------------------------------------------------------
|
|
824
|
+
|
|
825
|
+
def friction_velocity(u_inf: float, cf: float) -> float:
|
|
826
|
+
"""Friction velocity u_tau = U * sqrt(Cf/2)."""
|
|
827
|
+
_require_positive("u_inf", u_inf)
|
|
828
|
+
_require_positive("cf", cf)
|
|
829
|
+
return u_inf * math.sqrt(cf / 2.0)
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def sand_roughness_reynolds(rho: float, u_tau: float, k_s: float,
|
|
833
|
+
mu: float) -> float:
|
|
834
|
+
"""Roughness Reynolds number k+ = rho * u_tau * k_s / mu."""
|
|
835
|
+
_require_positive("rho", rho)
|
|
836
|
+
_require_positive("u_tau", u_tau)
|
|
837
|
+
_require_positive("k_s", k_s)
|
|
838
|
+
_require_positive("mu", mu)
|
|
839
|
+
return rho * u_tau * k_s / mu
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def roughness_regime(k_plus: float) -> str:
|
|
843
|
+
"""Surface regime: smooth k+ < 5, transitional 5-70, fully rough > 70."""
|
|
844
|
+
_require_nonnegative("k+", k_plus)
|
|
845
|
+
if k_plus < 5.0:
|
|
846
|
+
return "smooth"
|
|
847
|
+
if k_plus <= 70.0:
|
|
848
|
+
return "transitional"
|
|
849
|
+
return "fully-rough"
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
# ---------------------------------------------------------------------------
|
|
853
|
+
# Stagnation flow boundary layer (stagnation-flow-boundary-layer leaf)
|
|
854
|
+
# ---------------------------------------------------------------------------
|
|
855
|
+
|
|
856
|
+
def stagnation_velocity_gradient(flow_type: str, u_inf: float,
|
|
857
|
+
radius: float) -> float:
|
|
858
|
+
"""Potential-flow stagnation velocity gradient a (1/s) at the
|
|
859
|
+
attachment line: 2 U/R (2-D cylinder / Hiemenz) or 1.5 U/R (sphere)."""
|
|
860
|
+
key = str(flow_type).strip().lower()
|
|
861
|
+
_require_positive("u_inf", u_inf)
|
|
862
|
+
_require_positive("radius", radius)
|
|
863
|
+
if key in ("cylinder", "2d", "two-dimensional"):
|
|
864
|
+
return 2.0 * u_inf / radius
|
|
865
|
+
if key in ("sphere", "axisymmetric", "axi"):
|
|
866
|
+
return 1.5 * u_inf / radius
|
|
867
|
+
raise ValueError("flow_type %r not recognized (cylinder/sphere)" % (flow_type,))
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def stagnation_bl_thickness(nu: float, a: float) -> float:
|
|
871
|
+
"""99-percent laminar BL thickness at a stagnation line: 2.4 sqrt(nu/a)."""
|
|
872
|
+
_require_positive("nu", nu)
|
|
873
|
+
_require_positive("velocity gradient a", a)
|
|
874
|
+
return 2.4 * math.sqrt(nu / a)
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def stagnation_wall_shear(mu: float, u_inf: float, a: float, nu: float,
|
|
878
|
+
fpp: float = 1.2326) -> float:
|
|
879
|
+
"""Stagnation-line wall shear tau_w = mu U sqrt(a/nu) fpp (Hiemenz)."""
|
|
880
|
+
_require_positive("mu", mu)
|
|
881
|
+
_require_positive("u_inf", u_inf)
|
|
882
|
+
_require_positive("a", a)
|
|
883
|
+
_require_positive("nu", nu)
|
|
884
|
+
_require_positive("fpp", fpp)
|
|
885
|
+
return mu * u_inf * math.sqrt(a / nu) * fpp
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def stagnation_cf(rho: float, u_inf: float, tau_w: float) -> float:
|
|
889
|
+
"""Local skin-friction coefficient Cf = tau_w / (0.5 rho U^2)."""
|
|
890
|
+
_require_positive("rho", rho)
|
|
891
|
+
_require_positive("u_inf", u_inf)
|
|
892
|
+
_require_positive("wall shear", tau_w)
|
|
893
|
+
return tau_w / (0.5 * rho * u_inf * u_inf)
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
# ---------------------------------------------------------------------------
|
|
897
|
+
# Aerodynamic heating (aerodynamic-heating + flat-plate heating leaves)
|
|
898
|
+
# ---------------------------------------------------------------------------
|
|
899
|
+
|
|
900
|
+
def stagnation_heat_flux(rho: float, velocity: float, nose_radius: float) -> float:
|
|
901
|
+
"""Sutton-Graves stagnation heat flux q_s = C_SG sqrt(rho/Rn) V^3 (W/m2)."""
|
|
902
|
+
_require_positive("rho", rho)
|
|
903
|
+
_require_positive("velocity", velocity)
|
|
904
|
+
_require_positive("nose radius", nose_radius)
|
|
905
|
+
return C_SG * math.sqrt(rho / nose_radius) * velocity ** 3
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def radiation_equilibrium_temp(heat_flux: float,
|
|
909
|
+
emissivity: float = EPS_DEFAULT) -> float:
|
|
910
|
+
"""Radiation-equilibrium wall temperature T = (q/(eps sigma))^(1/4), K."""
|
|
911
|
+
_require_nonnegative("heat flux", heat_flux)
|
|
912
|
+
if not (0.0 < emissivity <= 1.0):
|
|
913
|
+
raise ValueError("emissivity must be in (0, 1]")
|
|
914
|
+
return (heat_flux / (emissivity * SIGMA_SB)) ** 0.25
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def recovery_factor(regime: str) -> float:
|
|
918
|
+
"""Flat-plate recovery factor: sqrt(Pr) laminar, Pr^(1/3) turbulent."""
|
|
919
|
+
key = str(regime).strip().lower()
|
|
920
|
+
if key == "laminar":
|
|
921
|
+
return math.sqrt(PR_AIR)
|
|
922
|
+
if key == "turbulent":
|
|
923
|
+
return PR_AIR ** (1.0 / 3.0)
|
|
924
|
+
raise ValueError("regime must be 'laminar' or 'turbulent'")
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def adiabatic_wall_temperature(mach: float, t_inf: float,
|
|
928
|
+
regime: str) -> float:
|
|
929
|
+
"""Adiabatic (recovery) wall temperature T_aw (K)."""
|
|
930
|
+
_require_nonnegative("mach", mach)
|
|
931
|
+
_require_positive("static temperature", t_inf)
|
|
932
|
+
r = recovery_factor(regime)
|
|
933
|
+
return t_inf * (1.0 + r * (GAMMA - 1.0) / 2.0 * mach * mach)
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def flat_plate_cold_wall_flux(mach: float, t_inf: float, p_inf: float,
|
|
937
|
+
t_wall: float, x: float,
|
|
938
|
+
regime: str) -> Dict:
|
|
939
|
+
"""Cold-wall convective heat flux on a flat plate (W/m2).
|
|
940
|
+
|
|
941
|
+
Eckert reference temperature T* = T_inf (1 + 0.032 M^2
|
|
942
|
+
+ 0.58 (T_wall/T_inf - 1)); Cf at reference conditions (laminar
|
|
943
|
+
0.664/sqrt(Re*), turbulent 0.0592/Re*^0.2); h_c = 0.5 Cf rho* U CP;
|
|
944
|
+
q = h_c (T_aw - T_wall).
|
|
945
|
+
"""
|
|
946
|
+
_require_positive("mach", mach)
|
|
947
|
+
_require_positive("static temperature", t_inf)
|
|
948
|
+
_require_positive("static pressure", p_inf)
|
|
949
|
+
_require_positive("wall temperature", t_wall)
|
|
950
|
+
_require_positive("running length x", x)
|
|
951
|
+
key = str(regime).strip().lower()
|
|
952
|
+
if key not in ("laminar", "turbulent"):
|
|
953
|
+
raise ValueError("regime must be 'laminar' or 'turbulent'")
|
|
954
|
+
t_star = t_inf * (1.0 + 0.032 * mach * mach
|
|
955
|
+
+ 0.58 * (t_wall / t_inf - 1.0))
|
|
956
|
+
rho_star = p_inf / (R_GAS * t_star)
|
|
957
|
+
mu_star = (SUTHERLAND_MU0 * t_star ** 1.5) / (t_star + SUTHERLAND_S)
|
|
958
|
+
u_e = mach * speed_of_sound(t_inf)
|
|
959
|
+
re_star = rho_star * u_e * x / mu_star
|
|
960
|
+
if key == "laminar":
|
|
961
|
+
cf = 0.664 / math.sqrt(re_star)
|
|
962
|
+
else:
|
|
963
|
+
cf = 0.0592 / re_star ** 0.2
|
|
964
|
+
h_c = 0.5 * cf * rho_star * u_e * CP_AIR
|
|
965
|
+
t_aw = adiabatic_wall_temperature(mach, t_inf, key)
|
|
966
|
+
return {"t_aw": t_aw, "t_star": t_star, "re_star": re_star,
|
|
967
|
+
"cf": cf, "h_c": h_c, "q_cold_wall": h_c * (t_aw - t_wall)}
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
# ---------------------------------------------------------------------------
|
|
971
|
+
# Bow shock standoff (bow-shock-standoff leaf)
|
|
972
|
+
# ---------------------------------------------------------------------------
|
|
973
|
+
|
|
974
|
+
def bow_shock_standoff_ratio(mach: float, body: str = "sphere") -> float:
|
|
975
|
+
"""Billig-form standoff ratio Delta/R on the stagnation streamline."""
|
|
976
|
+
if mach <= 1.0:
|
|
977
|
+
raise ValueError("Mach must exceed 1 for a detached bow shock")
|
|
978
|
+
key = str(body).strip().lower()
|
|
979
|
+
if key == "sphere":
|
|
980
|
+
return 0.143 * math.exp(3.24 / (mach * mach))
|
|
981
|
+
if key == "cylinder":
|
|
982
|
+
return 0.386 * math.exp(4.67 / (mach * mach))
|
|
983
|
+
raise ValueError("body must be 'sphere' or 'cylinder'")
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def bow_shock_standoff_distance(mach: float, nose_radius: float,
|
|
987
|
+
body: str = "sphere") -> float:
|
|
988
|
+
"""Detached bow-shock standoff distance Delta (m) ahead of the nose."""
|
|
989
|
+
_require_positive("nose radius", nose_radius)
|
|
990
|
+
return bow_shock_standoff_ratio(mach, body) * nose_radius
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
# ---------------------------------------------------------------------------
|
|
994
|
+
# Flight-condition item: project facts the role needs
|
|
995
|
+
# ---------------------------------------------------------------------------
|
|
996
|
+
|
|
997
|
+
@dataclass
|
|
998
|
+
class HighSpeedItem:
|
|
999
|
+
"""Vehicle + flight-condition facts for the analysis memo."""
|
|
1000
|
+
name: str
|
|
1001
|
+
description: str = ""
|
|
1002
|
+
# Flight conditions
|
|
1003
|
+
cruise_mach: float = 2.0 # supersonic cruise Mach number
|
|
1004
|
+
cruise_altitude_m: float = 18288.0 # 60 000 ft
|
|
1005
|
+
climb_mach: float = 0.85 # transonic climb/check Mach
|
|
1006
|
+
climb_altitude_m: float = 12192.0 # 40 000 ft
|
|
1007
|
+
# Configuration
|
|
1008
|
+
sweep_deg: float = 66.0 # wing leading-edge sweep (deg)
|
|
1009
|
+
mcrit0: float = 0.78 # unswept-section critical Mach
|
|
1010
|
+
t_over_c: float = 0.03 # wing thickness ratio
|
|
1011
|
+
cl_cruise: float = 0.12 # cruise lift coefficient (Korn)
|
|
1012
|
+
supercritical: bool = True
|
|
1013
|
+
cp_min0: float = -0.55 # incompressible peak suction
|
|
1014
|
+
section_mach_ahead: float = 1.3 # local Mach ahead of term. shock
|
|
1015
|
+
# Geometry
|
|
1016
|
+
nose_radius_m: float = 0.05 # nose radius for standoff/heating
|
|
1017
|
+
body_length_m: float = 62.0 # equivalent-body length
|
|
1018
|
+
body_r_max_m: float = 1.6 # equivalent-body max radius
|
|
1019
|
+
intake_deflection_deg: float = 10.0 # intake ramp / wedge deflection
|
|
1020
|
+
bl_station_m: float = 6.0 # boundary-layer analysis station
|
|
1021
|
+
airfoil_mach: float = 2.0 # diamond-airfoil analysis Mach
|
|
1022
|
+
airfoil_alpha_deg: float = 2.0
|
|
1023
|
+
airfoil_eps_deg: float = 2.0
|
|
1024
|
+
# Surface/thermal facts
|
|
1025
|
+
k_s_m: float = 6.0e-6 # equivalent sand roughness (m)
|
|
1026
|
+
skin_emissivity: float = EPS_DEFAULT
|
|
1027
|
+
# Climb-point compressibility rows (item inputs)
|
|
1028
|
+
karman_tsien_mach: float = 0.70 # subsonic climb leg for KT row
|
|
1029
|
+
generated: str = ""
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
def example_item() -> "HighSpeedItem":
|
|
1033
|
+
"""Reference item: an SST-class supersonic cruiser flight condition."""
|
|
1034
|
+
return HighSpeedItem(
|
|
1035
|
+
name="HSX-1 supersonic cruiser (reference flight condition)",
|
|
1036
|
+
description="Mach 2.0 supersonic transport configuration, "
|
|
1037
|
+
"subsonic-leading-edge swept wing, mixed-compression "
|
|
1038
|
+
"intake, high-temperature aluminium airframe",
|
|
1039
|
+
cruise_mach=2.0, cruise_altitude_m=18288.0,
|
|
1040
|
+
climb_mach=0.85, climb_altitude_m=12192.0,
|
|
1041
|
+
sweep_deg=66.0, t_over_c=0.03, cl_cruise=0.12,
|
|
1042
|
+
supercritical=True, cp_min0=-0.55, section_mach_ahead=1.3,
|
|
1043
|
+
nose_radius_m=0.05, body_length_m=62.0, body_r_max_m=1.6,
|
|
1044
|
+
intake_deflection_deg=10.0, bl_station_m=6.0,
|
|
1045
|
+
airfoil_mach=2.0, airfoil_alpha_deg=2.0, airfoil_eps_deg=2.0,
|
|
1046
|
+
k_s_m=6.0e-6, skin_emissivity=EPS_DEFAULT,
|
|
1047
|
+
karman_tsien_mach=0.70,
|
|
1048
|
+
)
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
# ---------------------------------------------------------------------------
|
|
1052
|
+
# Memo builder: computes every number with the formulas above
|
|
1053
|
+
# ---------------------------------------------------------------------------
|
|
1054
|
+
|
|
1055
|
+
def _fmt(v: float, nd: int = 4) -> str:
|
|
1056
|
+
return ("%.*f" % (nd, v)).rstrip("0").rstrip(".") if nd else ("%g" % v)
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
def build_memo(item: HighSpeedItem) -> Dict:
|
|
1060
|
+
"""Build the complete high-speed analysis memo content model."""
|
|
1061
|
+
m = item.cruise_mach
|
|
1062
|
+
atm = atmosphere(item.cruise_altitude_m)
|
|
1063
|
+
a_cruise = atm["speed_of_sound"]
|
|
1064
|
+
v_cruise = m * a_cruise
|
|
1065
|
+
q_cruise = 0.5 * atm["density"] * v_cruise ** 2
|
|
1066
|
+
rho = atm["density"]
|
|
1067
|
+
mu = atm["viscosity"]
|
|
1068
|
+
nu = kinematic_viscosity(mu, rho)
|
|
1069
|
+
|
|
1070
|
+
# --- isentropic relations at the cruise Mach --------------------------
|
|
1071
|
+
ratios = total_static_ratios(m)
|
|
1072
|
+
total = static_to_total(atm["pressure"], atm["temperature"], m)
|
|
1073
|
+
aa = area_ratio(m)
|
|
1074
|
+
m_sub = mach_from_area_ratio(aa, subsonic=True) # demo of inverse
|
|
1075
|
+
|
|
1076
|
+
# --- compressibility corrections (climb leg) --------------------------
|
|
1077
|
+
kt_mach = item.karman_tsien_mach
|
|
1078
|
+
kt_cp = karman_tsien_correction(item.cp_min0, kt_mach)
|
|
1079
|
+
pg_cp = prandtl_glauert_correction(item.cp_min0, kt_mach)
|
|
1080
|
+
cp_star_climb = critical_pressure_coefficient(item.climb_mach)
|
|
1081
|
+
m_crit_section = critical_mach_number(item.cp_min0)
|
|
1082
|
+
m_eff_climb = effective_mach(item.climb_mach, item.sweep_deg)
|
|
1083
|
+
m_eff_cruise = m * cos_sweep(item.sweep_deg) # subsonic-LE check
|
|
1084
|
+
# m_eff_cruise stays below 1 for a subsonic-leading-edge planform
|
|
1085
|
+
if m_eff_cruise >= 1.0:
|
|
1086
|
+
raise ValueError("example planform must keep a subsonic leading edge "
|
|
1087
|
+
"(M cos Lambda < 1)")
|
|
1088
|
+
k_trans = transonic_similarity_parameter(item.climb_mach, item.t_over_c)
|
|
1089
|
+
m_dd = drag_divergence_mach(item.t_over_c, item.cl_cruise,
|
|
1090
|
+
item.supercritical)
|
|
1091
|
+
wave_pen_climb = wave_drag_penalty(item.climb_mach, m_dd)
|
|
1092
|
+
wave_rise = wave_drag_rise_coef(item.climb_mach, m_dd)
|
|
1093
|
+
term_shock = terminating_shock_strength(item.section_mach_ahead)
|
|
1094
|
+
|
|
1095
|
+
# --- shock system at cruise -------------------------------------------
|
|
1096
|
+
ns = normal_shock(m) # nose / intake normal shock
|
|
1097
|
+
obl = oblique_shock(m, item.intake_deflection_deg) # ramp weak shock
|
|
1098
|
+
ref = shock_reflection_verdict(obl["m2"], item.intake_deflection_deg)
|
|
1099
|
+
pm = expansion_properties(obl["m2"], item.intake_deflection_deg)
|
|
1100
|
+
standoff = bow_shock_standoff_distance(m, item.nose_radius_m, "sphere")
|
|
1101
|
+
# intake total-pressure recovery through ramp oblique + terminal normal
|
|
1102
|
+
terminal = normal_shock(obl["m2"])
|
|
1103
|
+
recovery_total = obl["p02_p01"] * terminal["p02_p01"]
|
|
1104
|
+
recovery_single = ns["p02_p01"]
|
|
1105
|
+
recovery_gain = recovery_total - recovery_single
|
|
1106
|
+
|
|
1107
|
+
# --- supersonic section + wave drag ------------------------------------
|
|
1108
|
+
sea = shock_expansion_airfoil(item.airfoil_mach, item.airfoil_alpha_deg,
|
|
1109
|
+
item.airfoil_eps_deg)
|
|
1110
|
+
dq = sears_haack_wave_drag_area(item.body_length_m, item.body_r_max_m)
|
|
1111
|
+
|
|
1112
|
+
# --- boundary layer at the cruise station ------------------------------
|
|
1113
|
+
re_bl = reynolds(rho, v_cruise, item.bl_station_m, mu)
|
|
1114
|
+
bl = flat_plate_thicknesses(item.bl_station_m, re_bl)
|
|
1115
|
+
cf_loglaw = cf_turbulent_log_law(re_bl)
|
|
1116
|
+
x_tr = flat_plate_transition(nu, v_cruise, item.bl_station_m)
|
|
1117
|
+
a_stag = stagnation_velocity_gradient("cylinder", v_cruise,
|
|
1118
|
+
item.nose_radius_m)
|
|
1119
|
+
delta_stag = stagnation_bl_thickness(nu, a_stag)
|
|
1120
|
+
tau_stag = stagnation_wall_shear(mu, v_cruise, a_stag, nu)
|
|
1121
|
+
cf_stag = stagnation_cf(rho, v_cruise, tau_stag)
|
|
1122
|
+
u_tau = friction_velocity(v_cruise, bl["cf_local"])
|
|
1123
|
+
k_plus = sand_roughness_reynolds(rho, u_tau, item.k_s_m, mu)
|
|
1124
|
+
rough = roughness_regime(k_plus)
|
|
1125
|
+
|
|
1126
|
+
# --- heating ------------------------------------------------------------
|
|
1127
|
+
q_stag = stagnation_heat_flux(rho, v_cruise, item.nose_radius_m)
|
|
1128
|
+
t_rad = radiation_equilibrium_temp(q_stag, item.skin_emissivity)
|
|
1129
|
+
t_aw_skin = adiabatic_wall_temperature(m, atm["temperature"], "turbulent")
|
|
1130
|
+
t0_stag = total["t0"] # stagnation-line recovery temperature (r=1)
|
|
1131
|
+
fp_heat = flat_plate_cold_wall_flux(m, atm["temperature"],
|
|
1132
|
+
atm["pressure"], 300.0,
|
|
1133
|
+
item.bl_station_m, "turbulent")
|
|
1134
|
+
skin_margin = SKIN_TEMP_LIMIT_K - t_aw_skin
|
|
1135
|
+
# nose margin against the stagnation-line recovery temperature T0, the
|
|
1136
|
+
# hot-wall equilibrium ceiling (the Sutton-Graves cold-wall radiation
|
|
1137
|
+
# equilibrium T_rad is a conservative screening bound, not the hot wall)
|
|
1138
|
+
nose_margin = NOSE_TEMP_LIMIT_K - t0_stag
|
|
1139
|
+
|
|
1140
|
+
margins = [
|
|
1141
|
+
{"margin": "Intake total-pressure recovery (2-shock system)",
|
|
1142
|
+
"value": recovery_total,
|
|
1143
|
+
"requirement": "> single normal-shock recovery %.4f at M %.2f"
|
|
1144
|
+
% (recovery_single, m),
|
|
1145
|
+
"source": "stage 4: ramp oblique p02/p01 %.5f x terminal normal "
|
|
1146
|
+
"p02/p01 %.5f" % (obl["p02_p01"], terminal["p02_p01"])},
|
|
1147
|
+
{"margin": "Oblique-shock attachment margin (theta_max - theta)",
|
|
1148
|
+
"value": obl["theta_max_deg"] - item.intake_deflection_deg,
|
|
1149
|
+
"requirement": "> 0 deg (attached weak shock; detached above)",
|
|
1150
|
+
"source": "stage 4: theta_max %.2f deg at M %.2f (oblique-shock "
|
|
1151
|
+
"leaf)" % (obl["theta_max_deg"], m)},
|
|
1152
|
+
{"margin": "Section drag-divergence margin at cruise "
|
|
1153
|
+
"(M_DD - M_eff,cruise)",
|
|
1154
|
+
"value": m_dd - m_eff_cruise,
|
|
1155
|
+
"requirement": "> 0 (supercritical section subcritical at the cruise "
|
|
1156
|
+
"effective Mach M_eff %.3f)" % m_eff_cruise,
|
|
1157
|
+
"source": "stage 7: Korn rule M_DD = %.3f (t/c %.3f, CL %.2f, %s) "
|
|
1158
|
+
"vs swept section M_eff = M cos(Lambda)"
|
|
1159
|
+
% (m_dd, item.t_over_c, item.cl_cruise,
|
|
1160
|
+
"supercritical" if item.supercritical else "conventional")},
|
|
1161
|
+
{"margin": "Section critical-Mach margin at climb "
|
|
1162
|
+
"(M_cr,section - M_eff,climb)",
|
|
1163
|
+
"value": m_crit_section - m_eff_climb,
|
|
1164
|
+
"requirement": "> 0 (wing section subcritical through the transonic "
|
|
1165
|
+
"climb leg, M_eff %.3f)" % m_eff_climb,
|
|
1166
|
+
"source": "stage 3: Cp* crossing M_cr = %.3f for Cp0 = %.2f; "
|
|
1167
|
+
"climb M_eff = M cos(Lambda) = %.3f"
|
|
1168
|
+
% (m_crit_section, item.cp_min0, m_eff_climb)},
|
|
1169
|
+
{"margin": "Skin kinetic-heating margin (T_limit - T_aw)",
|
|
1170
|
+
"value": skin_margin,
|
|
1171
|
+
"requirement": "> 0 (skin screening limit %.0f K vs recovery temp)"
|
|
1172
|
+
% SKIN_TEMP_LIMIT_K,
|
|
1173
|
+
"source": "stage 10: T_aw = T (1 + r (g-1)/2 M^2) = %.1f K at M %.2f, "
|
|
1174
|
+
"recovery r = %.4f" % (t_aw_skin, m, recovery_factor("turbulent"))},
|
|
1175
|
+
{"margin": "Nose stagnation-temperature margin (T_limit - T0)",
|
|
1176
|
+
"value": nose_margin,
|
|
1177
|
+
"requirement": "> 0 (nose screening limit %.0f K vs stagnation-line "
|
|
1178
|
+
"recovery temperature T0)" % NOSE_TEMP_LIMIT_K,
|
|
1179
|
+
"source": "stage 10: T0 = T (1 + (g-1)/2 M^2) = %.1f K at M %.2f "
|
|
1180
|
+
"(hot-wall ceiling; Sutton-Graves cold-wall flux %.0f "
|
|
1181
|
+
"W/m2 screened separately)" % (t0_stag, m, q_stag)},
|
|
1182
|
+
]
|
|
1183
|
+
|
|
1184
|
+
model = {
|
|
1185
|
+
"document_type": "High-Speed Aerodynamic Analysis Memo",
|
|
1186
|
+
"status": "draft-for-review",
|
|
1187
|
+
"generated": item.generated or _today(),
|
|
1188
|
+
"vehicle": item.name,
|
|
1189
|
+
"description": item.description,
|
|
1190
|
+
# ---- item facts echoed into the memo (single source of truth)
|
|
1191
|
+
"facts": {
|
|
1192
|
+
"sweep_deg": item.sweep_deg,
|
|
1193
|
+
"t_over_c": item.t_over_c, "cl_cruise": item.cl_cruise,
|
|
1194
|
+
"supercritical": item.supercritical, "cp_min0": item.cp_min0,
|
|
1195
|
+
"section_mach_ahead": item.section_mach_ahead,
|
|
1196
|
+
"nose_radius_m": item.nose_radius_m,
|
|
1197
|
+
"body_length_m": item.body_length_m,
|
|
1198
|
+
"body_r_max_m": item.body_r_max_m,
|
|
1199
|
+
"intake_deflection_deg": item.intake_deflection_deg,
|
|
1200
|
+
"bl_station_m": item.bl_station_m,
|
|
1201
|
+
"k_s_m": item.k_s_m, "emissivity": item.skin_emissivity,
|
|
1202
|
+
"karman_tsien_mach": item.karman_tsien_mach,
|
|
1203
|
+
},
|
|
1204
|
+
# ---- flight condition
|
|
1205
|
+
"cruise_mach": m, "cruise_altitude_m": item.cruise_altitude_m,
|
|
1206
|
+
"atmosphere": atm, "v_cruise": v_cruise, "q_cruise": q_cruise,
|
|
1207
|
+
"climb_mach": item.climb_mach, "climb_altitude_m": item.climb_altitude_m,
|
|
1208
|
+
# ---- isentropic relations
|
|
1209
|
+
"total_ratios": ratios, "total_conditions": total,
|
|
1210
|
+
"area_ratio": aa, "area_ratio_subsonic_root": m_sub,
|
|
1211
|
+
# ---- compressibility corrections
|
|
1212
|
+
"corrections": {"kt_mach": kt_mach, "cp_min0": item.cp_min0,
|
|
1213
|
+
"kt_cp": kt_cp, "pg_cp": pg_cp,
|
|
1214
|
+
"cp_star_climb": cp_star_climb,
|
|
1215
|
+
"m_crit_section": m_crit_section,
|
|
1216
|
+
"m_eff_climb": m_eff_climb,
|
|
1217
|
+
"m_eff_cruise": m_eff_cruise,
|
|
1218
|
+
"k_transonic": k_trans,
|
|
1219
|
+
"m_dd": m_dd, "wave_pen_climb": wave_pen_climb,
|
|
1220
|
+
"wave_rise": wave_rise,
|
|
1221
|
+
"term_shock_strength": term_shock},
|
|
1222
|
+
# ---- shock system
|
|
1223
|
+
"shocks": {"normal": ns, "oblique": obl, "reflection": ref,
|
|
1224
|
+
"expansion": pm, "standoff_m": standoff,
|
|
1225
|
+
"terminal_p02_p01": terminal["p02_p01"],
|
|
1226
|
+
"recovery_total": recovery_total,
|
|
1227
|
+
"recovery_single": recovery_single,
|
|
1228
|
+
"recovery_gain": recovery_gain},
|
|
1229
|
+
# ---- supersonic section + wave drag
|
|
1230
|
+
"airfoil": sea, "wave_drag_area_m2": dq,
|
|
1231
|
+
# ---- boundary layer
|
|
1232
|
+
"bl": {"re": re_bl, "station_m": item.bl_station_m,
|
|
1233
|
+
"flat_plate": bl, "cf_loglaw": cf_loglaw,
|
|
1234
|
+
"x_transition_m": x_tr,
|
|
1235
|
+
"stagnation": {"gradient_1_per_s": a_stag,
|
|
1236
|
+
"delta_m": delta_stag,
|
|
1237
|
+
"tau_w": tau_stag, "cf": cf_stag},
|
|
1238
|
+
"roughness": {"k_plus": k_plus, "regime": rough,
|
|
1239
|
+
"k_s_m": item.k_s_m}},
|
|
1240
|
+
# ---- heating
|
|
1241
|
+
"heating": {"q_stag": q_stag, "t_rad": t_rad,
|
|
1242
|
+
"t0_stag": t0_stag,
|
|
1243
|
+
"t_aw_skin": t_aw_skin, "flat_plate": fp_heat,
|
|
1244
|
+
"skin_limit_k": SKIN_TEMP_LIMIT_K,
|
|
1245
|
+
"nose_limit_k": NOSE_TEMP_LIMIT_K,
|
|
1246
|
+
"skin_margin": skin_margin, "nose_margin": nose_margin},
|
|
1247
|
+
# ---- margins + gates
|
|
1248
|
+
"margins": margins,
|
|
1249
|
+
"margins_all_ok": all(m["value"] > 0 for m in margins),
|
|
1250
|
+
}
|
|
1251
|
+
return model
|
|
1252
|
+
|
|
1253
|
+
|
|
1254
|
+
# ---------------------------------------------------------------------------
|
|
1255
|
+
# Markdown renderer
|
|
1256
|
+
# ---------------------------------------------------------------------------
|
|
1257
|
+
|
|
1258
|
+
def render_memo_markdown(model: Dict) -> str:
|
|
1259
|
+
"""Render the memo content model as the deliverable markdown document."""
|
|
1260
|
+
atm = model["atmosphere"]
|
|
1261
|
+
tot = model["total_conditions"]
|
|
1262
|
+
rat = model["total_ratios"]
|
|
1263
|
+
corr = model["corrections"]
|
|
1264
|
+
sh = model["shocks"]
|
|
1265
|
+
bl = model["bl"]
|
|
1266
|
+
heat = model["heating"]
|
|
1267
|
+
sea = model["airfoil"]
|
|
1268
|
+
f = model["facts"]
|
|
1269
|
+
lines = [
|
|
1270
|
+
"# High-Speed Aerodynamic Analysis Memo",
|
|
1271
|
+
"",
|
|
1272
|
+
f"**Vehicle:** {model['vehicle']}",
|
|
1273
|
+
f"**Status:** {model['status']} (generated {model['generated']})",
|
|
1274
|
+
"",
|
|
1275
|
+
"## 1. Flight condition summary",
|
|
1276
|
+
"",
|
|
1277
|
+
(f"- Vehicle: {model['vehicle']}"
|
|
1278
|
+
+ (f" - {model['description']}" if model.get("description") else "")),
|
|
1279
|
+
(f"- Flight condition: cruise M {model['cruise_mach']:.2f} at "
|
|
1280
|
+
f"{model['cruise_altitude_m']:.0f} m; transonic climb check M "
|
|
1281
|
+
f"{model['climb_mach']:.2f} at {model['climb_altitude_m']:.0f} m"),
|
|
1282
|
+
(f"- ISA state at cruise: T {atm['temperature']:.2f} K, p "
|
|
1283
|
+
f"{atm['pressure']:.0f} Pa, rho {atm['density']:.4f} kg/m3, a "
|
|
1284
|
+
f"{atm['speed_of_sound']:.2f} m/s, mu {atm['viscosity']:.2e} kg/(m s)"),
|
|
1285
|
+
(f"- Flow state: V = M a = {model['v_cruise']:.1f} m/s, q = "
|
|
1286
|
+
f"{model['q_cruise']:.0f} Pa"),
|
|
1287
|
+
(f"- Configuration: swept wing (subsonic leading edge, sweep "
|
|
1288
|
+
f"{f['sweep_deg']:.0f} deg), mixed-compression intake (ramp "
|
|
1289
|
+
f"deflection {f['intake_deflection_deg']:.1f} deg), equivalent "
|
|
1290
|
+
f"body length {f['body_length_m']:.0f} m"),
|
|
1291
|
+
"",
|
|
1292
|
+
"## 2. Freestream total conditions and Mach-area",
|
|
1293
|
+
"",
|
|
1294
|
+
(f"- Isentropic total-to-static ratios at M {model['cruise_mach']:.2f}: "
|
|
1295
|
+
f"T0/T = {rat['t0_over_t']:.4f}, p0/p = {rat['p0_over_p']:.4f}, "
|
|
1296
|
+
f"rho0/rho = {rat['rho0_over_rho']:.4f}"),
|
|
1297
|
+
(f"- Total conditions (isentropic, no shock): p0 = {tot['p0']:.0f} Pa, "
|
|
1298
|
+
f"T0 = {tot['t0']:.2f} K"),
|
|
1299
|
+
(f"- Mach-area: A/A* at M {model['cruise_mach']:.2f} = "
|
|
1300
|
+
f"{model['area_ratio']:.4f} (throat needed for isentropic capture); "
|
|
1301
|
+
f"subsonic-branch root for the same ratio M = "
|
|
1302
|
+
f"{model['area_ratio_subsonic_root']:.4f}"),
|
|
1303
|
+
"- Use: intake contraction and nozzle expansion sizing (isentropic-"
|
|
1304
|
+
"flow-relations stage).",
|
|
1305
|
+
"",
|
|
1306
|
+
"## 3. Compressibility corrections and transonic limit",
|
|
1307
|
+
"",
|
|
1308
|
+
(f"- Karman-Tsien corrected peak suction at M {corr['kt_mach']:.2f}: "
|
|
1309
|
+
f"Cp = {corr['cp_min0']:.2f} / (sqrt(1-M^2) + (M^2/(1+sqrt(1-M^2))) "
|
|
1310
|
+
f"x Cp/2) = {corr['kt_cp']:.4f} (vs Prandtl-Glauert "
|
|
1311
|
+
f"{corr['pg_cp']:.4f})"),
|
|
1312
|
+
(f"- Critical pressure coefficient at climb M {model['climb_mach']:.2f}: "
|
|
1313
|
+
f"Cp* = {corr['cp_star_climb']:.4f}"),
|
|
1314
|
+
(f"- Section critical Mach from Cp0 = {corr['cp_min0']:.2f}: "
|
|
1315
|
+
f"M_cr = {corr['m_crit_section']:.4f} (Cp* crossing solve)"),
|
|
1316
|
+
(f"- Sweep effect: M_eff = M cos(Lambda): climb M "
|
|
1317
|
+
f"{model['climb_mach']:.2f} x cos({f['sweep_deg']:.0f} deg) = "
|
|
1318
|
+
f"{corr['m_eff_climb']:.4f}; cruise M_eff = {corr['m_eff_cruise']:.4f} "
|
|
1319
|
+
f"(subsonic leading edge)"),
|
|
1320
|
+
(f"- Korn drag-divergence Mach (t/c {f['t_over_c']:.3f}, cruise CL "
|
|
1321
|
+
f"{f['cl_cruise']:.2f}): M_DD = {corr['m_dd']:.3f}; transonic wave-"
|
|
1322
|
+
f"drag penalty at climb M {model['climb_mach']:.2f} = "
|
|
1323
|
+
f"{corr['wave_pen_climb']:.4f} (0 below M_DD), parabolic rise "
|
|
1324
|
+
f"{corr['wave_rise']:.4f}"),
|
|
1325
|
+
(f"- Terminating-shock strength at local M {f['section_mach_ahead']:.2f}: "
|
|
1326
|
+
f"p2/p1 = {corr['term_shock_strength']:.4f}"),
|
|
1327
|
+
(f"- Transonic similarity parameter K = (1 - M^2)/tau^(2/3) = "
|
|
1328
|
+
f"{corr['k_transonic']:.3f}"),
|
|
1329
|
+
"",
|
|
1330
|
+
"## 4. Shock system at the cruise Mach",
|
|
1331
|
+
"",
|
|
1332
|
+
(f"- Normal shock at nose/inlet face, M1 = {model['cruise_mach']:.2f}: "
|
|
1333
|
+
f"M2 = {sh['normal']['m2']:.4f}, p2/p1 = {sh['normal']['p2_p1']:.4f}, "
|
|
1334
|
+
f"T2/T1 = {sh['normal']['t2_t1']:.4f}, rho2/rho1 = "
|
|
1335
|
+
f"{sh['normal']['rho2_rho1']:.4f}, p02/p01 = "
|
|
1336
|
+
f"{sh['normal']['p02_p01']:.4f}"),
|
|
1337
|
+
(f"- Intake ramp oblique shock, theta = {f['intake_deflection_deg']:.1f} "
|
|
1338
|
+
f"deg at M {model['cruise_mach']:.2f} (weak): beta = "
|
|
1339
|
+
f"{sh['oblique']['beta_deg']:.2f} deg, M2 = {sh['oblique']['m2']:.3f}, "
|
|
1340
|
+
f"p2/p1 = {sh['oblique']['p2_p1']:.4f}, p02/p01 = "
|
|
1341
|
+
f"{sh['oblique']['p02_p01']:.4f}; theta_max = "
|
|
1342
|
+
f"{sh['oblique']['theta_max_deg']:.2f} deg"),
|
|
1343
|
+
(f"- Terminal normal shock at M2 = {sh['oblique']['m2']:.3f}: p02/p01 "
|
|
1344
|
+
f"= {sh['terminal_p02_p01']:.4f}"),
|
|
1345
|
+
(f"- Intake recovery: 2-shock p02/p01 = {sh['recovery_total']:.4f} "
|
|
1346
|
+
f"vs single normal shock {sh['recovery_single']:.4f} (gain "
|
|
1347
|
+
f"+{sh['recovery_gain']:.4f})"),
|
|
1348
|
+
(f"- Regular shock reflection at (M {sh['oblique']['m2']:.3f}, "
|
|
1349
|
+
f"theta {f['intake_deflection_deg']:.1f} deg): verdict "
|
|
1350
|
+
f"{sh['reflection']['verdict']} ({sh['reflection']['reason']})"),
|
|
1351
|
+
(f"- Aft expansion (Prandtl-Meyer), turn "
|
|
1352
|
+
f"{f['intake_deflection_deg']:.1f} deg from M "
|
|
1353
|
+
f"{sh['oblique']['m2']:.3f}: M = {sh['expansion']['m2']:.3f}, p2/p1 = "
|
|
1354
|
+
f"{sh['expansion']['pressure_ratio_p2_p1']:.4f}"),
|
|
1355
|
+
(f"- Detached bow shock at the nose (sphere, R = "
|
|
1356
|
+
f"{f['nose_radius_m']:.3f} m): standoff Delta = "
|
|
1357
|
+
f"{sh['standoff_m']:.4f} m (Billig correlation, Delta/R = "
|
|
1358
|
+
f"{sh['standoff_m']/f['nose_radius_m']:.4f})"),
|
|
1359
|
+
"",
|
|
1360
|
+
"## 5. Supersonic section analysis and wave drag",
|
|
1361
|
+
"",
|
|
1362
|
+
(f"- Shock-expansion diamond airfoil at M {sea['m1']:.2f}, alpha "
|
|
1363
|
+
f"{sea['alpha_deg']:.1f} deg, half-angle {sea['eps_deg']:.1f} deg: "
|
|
1364
|
+
f"cl = {sea['cl']:.4f}, cd_wave = {sea['cd_wave']:.5f}, "
|
|
1365
|
+
f"cm_le = {sea['cm_le']:.4f}"),
|
|
1366
|
+
(f"- Surface Cp (uf/ur/lf/lr): {sea['surface_cp']['uf']:.4f} / "
|
|
1367
|
+
f"{sea['surface_cp']['ur']:.4f} / {sea['surface_cp']['lf']:.4f} / "
|
|
1368
|
+
f"{sea['surface_cp']['lr']:.4f}"),
|
|
1369
|
+
(f"- Zero-lift wave drag area (Sears-Haack equivalent body, L = "
|
|
1370
|
+
f"{f['body_length_m']:.0f} m, r_max = {f['body_r_max_m']:.1f} m): "
|
|
1371
|
+
f"D/q = (9 pi/2)(A_max/L)^2 = {model['wave_drag_area_m2']:.4f} m2"),
|
|
1372
|
+
"",
|
|
1373
|
+
"## 6. Boundary layer and skin friction",
|
|
1374
|
+
"",
|
|
1375
|
+
(f"- Station x = {bl['station_m']:.1f} m at cruise: Re = rho V x / mu = "
|
|
1376
|
+
f"{bl['re']:.3e} ({bl['flat_plate']['regime']} flat-plate regime)"),
|
|
1377
|
+
(f"- Flat-plate thickness: delta = {bl['flat_plate']['delta']:.5f} m, "
|
|
1378
|
+
f"delta* = {bl['flat_plate']['delta_star']:.5f} m, theta = "
|
|
1379
|
+
f"{bl['flat_plate']['theta']:.5f} m, H = "
|
|
1380
|
+
f"{bl['flat_plate']['shape_factor']:.3f}"),
|
|
1381
|
+
(f"- Skin friction: Cf_local = {bl['flat_plate']['cf_local']:.5f} "
|
|
1382
|
+
f"(1/7 power), Cf_avg = {bl['flat_plate']['cf_average']:.5f}, "
|
|
1383
|
+
f"log-law Cf = {bl['cf_loglaw']:.5f}"),
|
|
1384
|
+
(f"- Transition: Michel/Thwaites flat-plate natural transition at "
|
|
1385
|
+
f"x_tr = {_bl_x_tr(bl):.3f} m ({_bl_x_tr_frac(bl):.0f}% of the "
|
|
1386
|
+
f"analysis station)"),
|
|
1387
|
+
(f"- Attachment-line (Hiemenz 2-D) layer at the nose: a = 2U/R = "
|
|
1388
|
+
f"{bl['stagnation']['gradient_1_per_s']:.0f} 1/s, delta = "
|
|
1389
|
+
f"{bl['stagnation']['delta_m']:.6f} m, Cf = "
|
|
1390
|
+
f"{bl['stagnation']['cf']:.5f}"),
|
|
1391
|
+
(f"- Surface roughness: k_s = {bl['roughness']['k_s_m']:.1e} m -> "
|
|
1392
|
+
f"k+ = {bl['roughness']['k_plus']:.3f} "
|
|
1393
|
+
f"({bl['roughness']['regime']}; smooth retained below k+ = 5)"),
|
|
1394
|
+
"",
|
|
1395
|
+
"## 7. Aerodynamic heating screen",
|
|
1396
|
+
"",
|
|
1397
|
+
(f"- Stagnation point (Sutton-Graves): q_s = C_SG sqrt(rho/Rn) V^3 = "
|
|
1398
|
+
f"{heat['q_stag']:.0f} W/m2; radiation-equilibrium T = "
|
|
1399
|
+
f"{heat['t_rad']:.0f} K (eps {f['emissivity']:.2f})"),
|
|
1400
|
+
(f"- Flat-plate skin: recovery factor r = Pr^(1/3) = "
|
|
1401
|
+
f"{RECOVERY_TURBULENT:.4f} (turbulent); adiabatic wall temperature "
|
|
1402
|
+
f"T_aw = T (1 + r (g-1)/2 M^2) = {heat['t_aw_skin']:.1f} K"),
|
|
1403
|
+
(f"- Cold-wall skin flux at x = {bl['station_m']:.1f} m (Eckert "
|
|
1404
|
+
f"reference temp, T_wall 300 K): q = "
|
|
1405
|
+
f"{heat['flat_plate']['q_cold_wall']:.0f} W/m2, Cf = "
|
|
1406
|
+
f"{heat['flat_plate']['cf']:.5f} at Re* = "
|
|
1407
|
+
f"{heat['flat_plate']['re_star']:.3e}"),
|
|
1408
|
+
(f"- Screening margins: skin {heat['skin_margin']:.1f} K below the "
|
|
1409
|
+
f"{heat['skin_limit_k']:.0f} K limit; nose {heat['nose_margin']:.0f} K "
|
|
1410
|
+
f"below the {heat['nose_limit_k']:.0f} K limit"),
|
|
1411
|
+
"",
|
|
1412
|
+
"## 8. Margin summary",
|
|
1413
|
+
"",
|
|
1414
|
+
"| Margin | Value | Requirement | Source (stage) |",
|
|
1415
|
+
"|---|---|---|---|",
|
|
1416
|
+
]
|
|
1417
|
+
for mr in model["margins"]:
|
|
1418
|
+
lines.append(f"| {mr['margin']} | {mr['value']:.4f} | "
|
|
1419
|
+
f"{mr['requirement']} | {mr['source']} |")
|
|
1420
|
+
lines += [
|
|
1421
|
+
"",
|
|
1422
|
+
"---",
|
|
1423
|
+
"*DRAFT - for human high-speed aerodynamics lead review. Not an "
|
|
1424
|
+
"approval document.*",
|
|
1425
|
+
]
|
|
1426
|
+
return "\n".join(lines)
|
|
1427
|
+
|
|
1428
|
+
|
|
1429
|
+
# --- small renderer helpers --------------------------------------------------
|
|
1430
|
+
|
|
1431
|
+
def _bl_x_tr(bl: Dict) -> float:
|
|
1432
|
+
return bl["x_transition_m"] if bl["x_transition_m"] is not None else float("nan")
|
|
1433
|
+
|
|
1434
|
+
|
|
1435
|
+
def _bl_x_tr_frac(bl: Dict) -> float:
|
|
1436
|
+
x = bl["x_transition_m"]
|
|
1437
|
+
return 100.0 * (x if x is not None else 0.0) / bl["station_m"]
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
# ---------------------------------------------------------------------------
|
|
1441
|
+
# Evidence gate checkers
|
|
1442
|
+
# ---------------------------------------------------------------------------
|
|
1443
|
+
|
|
1444
|
+
GATE_CHECKS = {
|
|
1445
|
+
"condition_identified": "vehicle name and cruise Mach/altitude present",
|
|
1446
|
+
"numbers_real": "computed compressible-flow numbers are finite and in domain",
|
|
1447
|
+
"shock_consistent": "normal shock decelerates (M2<1); oblique shock attached",
|
|
1448
|
+
"recovery_physical": "total-pressure recoveries in (0,1); 2-shock gain positive",
|
|
1449
|
+
"corrections_physical": "Cp* negative; Karman-Tsien correction exceeds P-G",
|
|
1450
|
+
"bl_physical": "boundary-layer thickness positive; Cf in (0, 0.01]",
|
|
1451
|
+
"heating_screened": "kinetic-heating margins positive against screening limits",
|
|
1452
|
+
"margins_traced": "every margin row names its workflow stage source",
|
|
1453
|
+
"sign_off_honest": "memo is marked draft-for-review, not approval",
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
|
|
1457
|
+
def check_memo(model: Dict) -> Dict:
|
|
1458
|
+
"""Run the evidence gates against a memo content model."""
|
|
1459
|
+
def _num(key):
|
|
1460
|
+
v = model.get(key)
|
|
1461
|
+
return isinstance(v, (int, float)) and math.isfinite(v)
|
|
1462
|
+
|
|
1463
|
+
corr = model.get("corrections", {})
|
|
1464
|
+
sh = model.get("shocks", {})
|
|
1465
|
+
bl = model.get("bl", {})
|
|
1466
|
+
heat = model.get("heating", {})
|
|
1467
|
+
ns = sh.get("normal", {})
|
|
1468
|
+
obl = sh.get("oblique", {})
|
|
1469
|
+
margins = model.get("margins", [])
|
|
1470
|
+
results = {
|
|
1471
|
+
"condition_identified": (bool(model.get("vehicle"))
|
|
1472
|
+
and _num("cruise_mach")
|
|
1473
|
+
and model["cruise_mach"] > 1.0
|
|
1474
|
+
and _num("cruise_altitude_m")),
|
|
1475
|
+
"numbers_real": all([
|
|
1476
|
+
_num("area_ratio"), _num("v_cruise"), _num("q_cruise"),
|
|
1477
|
+
isinstance(model.get("total_ratios", {}).get("p0_over_p"), float),
|
|
1478
|
+
model["total_ratios"]["p0_over_p"] > 1.0,
|
|
1479
|
+
model["area_ratio"] >= 1.0,
|
|
1480
|
+
isinstance(corr.get("m_dd"), float) and 0.5 < corr["m_dd"] < 1.0,
|
|
1481
|
+
isinstance(bl.get("re"), float) and bl["re"] > 0.0,
|
|
1482
|
+
]),
|
|
1483
|
+
"shock_consistent": (
|
|
1484
|
+
ns.get("m2") is not None and 0.0 < ns["m2"] < 1.0
|
|
1485
|
+
and ns.get("p2_p1", 0.0) > 1.0
|
|
1486
|
+
and obl.get("beta_deg") is not None
|
|
1487
|
+
and obl["beta_deg"] > mach_angle(model["cruise_mach"])
|
|
1488
|
+
and obl["m2"] is not None and obl["m2"] > 1.0),
|
|
1489
|
+
"recovery_physical": (
|
|
1490
|
+
0.0 < sh.get("recovery_single", 0.0) < 1.0
|
|
1491
|
+
and 0.0 < sh.get("recovery_total", 0.0) < 1.0
|
|
1492
|
+
and sh.get("recovery_gain", 0.0) > 0.0
|
|
1493
|
+
and sh.get("reflection", {}).get("verdict") in ("regular", "mach")),
|
|
1494
|
+
"corrections_physical": (
|
|
1495
|
+
corr.get("cp_star_climb") is not None and corr["cp_star_climb"] < 0.0
|
|
1496
|
+
and corr.get("kt_cp") is not None
|
|
1497
|
+
and abs(corr["kt_cp"]) > abs(corr.get("pg_cp", 0.0))
|
|
1498
|
+
and corr.get("m_eff_cruise", 1.0) < 1.0
|
|
1499
|
+
and corr.get("m_crit_section") is not None
|
|
1500
|
+
and corr["m_crit_section"] > corr.get("m_eff_climb", 0.0)
|
|
1501
|
+
and corr.get("m_dd", 0.0) > corr.get("m_eff_cruise", 0.0)),
|
|
1502
|
+
"bl_physical": (
|
|
1503
|
+
bl.get("flat_plate", {}).get("delta", 0.0) > 0.0
|
|
1504
|
+
and bl["flat_plate"]["cf_local"] > 0.0
|
|
1505
|
+
and bl["flat_plate"]["cf_local"] < 0.01
|
|
1506
|
+
and bl["flat_plate"]["shape_factor"] > 1.0
|
|
1507
|
+
and bl.get("stagnation", {}).get("delta_m", 0.0) > 0.0
|
|
1508
|
+
and bl.get("roughness", {}).get("k_plus", -1.0) >= 0.0),
|
|
1509
|
+
"heating_screened": (
|
|
1510
|
+
heat.get("q_stag", 0.0) > 0.0
|
|
1511
|
+
and heat.get("skin_margin", 0.0) > 0.0
|
|
1512
|
+
and heat.get("nose_margin", 0.0) > 0.0
|
|
1513
|
+
and heat.get("t_aw_skin", 0.0) > 200.0),
|
|
1514
|
+
"margins_traced": (len(margins) >= 4
|
|
1515
|
+
and all("stage " in str(m.get("source", ""))
|
|
1516
|
+
for m in margins)),
|
|
1517
|
+
"sign_off_honest": model.get("status") == "draft-for-review",
|
|
1518
|
+
}
|
|
1519
|
+
results["all_pass"] = all(results.values())
|
|
1520
|
+
return results
|
|
1521
|
+
|
|
1522
|
+
|
|
1523
|
+
def check_memo_markdown(md_text: str) -> Dict:
|
|
1524
|
+
"""Gate-check the rendered markdown deliverable (zero blanks allowed)."""
|
|
1525
|
+
low = md_text.lower().replace("_", "").replace("-", " ")
|
|
1526
|
+
checks = {
|
|
1527
|
+
"has_title": "high-speed aerodynamic analysis memo" in md_text.lower(),
|
|
1528
|
+
"has_vehicle": bool(re.search(r"\*\*vehicle:\*\*", md_text, re.I)),
|
|
1529
|
+
"has_sections": all(re.search(rf"^## {n}\. ", md_text, re.M)
|
|
1530
|
+
for n in range(1, 9)),
|
|
1531
|
+
"has_key_numbers": all(key in low for key in (
|
|
1532
|
+
"total to static", "karman tsien", "normal shock",
|
|
1533
|
+
"total pressure recovery", "margins")),
|
|
1534
|
+
"has_no_blanks": not re.search(r"_{3,}|TBD|\bOPEN item\b", md_text),
|
|
1535
|
+
"has_draft_marker": "draft" in md_text.lower(),
|
|
1536
|
+
"has_not_approval": "not an approval" in md_text.lower(),
|
|
1537
|
+
}
|
|
1538
|
+
checks["all_pass"] = all(checks.values())
|
|
1539
|
+
return checks
|
|
1540
|
+
|
|
1541
|
+
|
|
1542
|
+
def validate_deliverable_text(md_text: str) -> Dict:
|
|
1543
|
+
"""Public entry point used by gate tooling: check a memo document."""
|
|
1544
|
+
return check_memo_markdown(md_text)
|
|
1545
|
+
|
|
1546
|
+
|
|
1547
|
+
# ---------------------------------------------------------------------------
|
|
1548
|
+
# Example item + worked example
|
|
1549
|
+
# ---------------------------------------------------------------------------
|
|
1550
|
+
|
|
1551
|
+
def example_memo_markdown() -> str:
|
|
1552
|
+
return render_memo_markdown(build_memo(example_item()))
|
|
1553
|
+
|
|
1554
|
+
|
|
1555
|
+
if __name__ == "__main__":
|
|
1556
|
+
model = build_memo(example_item())
|
|
1557
|
+
md = render_memo_markdown(model)
|
|
1558
|
+
print("VEHICLE: %s" % model["vehicle"])
|
|
1559
|
+
print("CRUISE M: %.2f p0/p: %.4f A/A*: %.4f recovery2: %.4f"
|
|
1560
|
+
% (model["cruise_mach"], model["total_ratios"]["p0_over_p"],
|
|
1561
|
+
model["area_ratio"], model["shocks"]["recovery_total"]))
|
|
1562
|
+
print("M_DD: %.3f M_cr,section: %.3f M_eff,cruise: %.3f"
|
|
1563
|
+
% (model["corrections"]["m_dd"], model["corrections"]["m_crit_section"],
|
|
1564
|
+
model["corrections"]["m_eff_cruise"]))
|
|
1565
|
+
print("GATES: %s" % check_memo(model))
|
|
1566
|
+
print("RENDERED: %d chars" % len(md))
|