aero-agent-roles 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/NOTICE +18 -0
  2. package/README.md +49 -0
  3. package/bin/aero-agent-roles.js +127 -0
  4. package/lib/catalog.js +97 -0
  5. package/lib/install.js +48 -0
  6. package/lib/mcp.js +199 -0
  7. package/manifest.json +516 -0
  8. package/package.json +44 -0
  9. package/roles/aerodynamics-engineer/ROLE.md +153 -0
  10. package/roles/aerodynamics-engineer/SOURCES.md +12 -0
  11. package/roles/aerodynamics-engineer/cli.py +143 -0
  12. package/roles/aerodynamics-engineer/core/aerodynamics_core.py +1218 -0
  13. package/roles/aerodynamics-engineer/templates/aero-design-report-template.md +72 -0
  14. package/roles/aerodynamics-engineer/tests/test_aerodynamics_core.py +227 -0
  15. package/roles/aerodynamics-engineer/tests/test_role_aerodynamics.py +94 -0
  16. package/roles/aircraft-design-engineer/ROLE.md +138 -0
  17. package/roles/aircraft-design-engineer/SOURCES.md +12 -0
  18. package/roles/aircraft-design-engineer/cli.py +152 -0
  19. package/roles/aircraft-design-engineer/core/aircraft_design_core.py +1278 -0
  20. package/roles/aircraft-design-engineer/templates/concept-design-template.md +99 -0
  21. package/roles/aircraft-design-engineer/tests/test_aircraft_design_core.py +269 -0
  22. package/roles/aircraft-design-engineer/tests/test_role_aircraft_design_engineer.py +106 -0
  23. package/roles/airworthiness-compliance-engineer/ROLE.md +122 -0
  24. package/roles/airworthiness-compliance-engineer/SOURCES.md +16 -0
  25. package/roles/airworthiness-compliance-engineer/cli.py +146 -0
  26. package/roles/airworthiness-compliance-engineer/core/airworthiness_core.py +894 -0
  27. package/roles/airworthiness-compliance-engineer/templates/compliance-matrix-template.md +46 -0
  28. package/roles/airworthiness-compliance-engineer/tests/test_airworthiness_core.py +224 -0
  29. package/roles/airworthiness-compliance-engineer/tests/test_role_airworthiness.py +85 -0
  30. package/roles/as9100-quality-auditor/ROLE.md +128 -0
  31. package/roles/as9100-quality-auditor/SOURCES.md +16 -0
  32. package/roles/as9100-quality-auditor/cli.py +136 -0
  33. package/roles/as9100-quality-auditor/core/as9100_core.py +857 -0
  34. package/roles/as9100-quality-auditor/templates/findings-report-template.md +119 -0
  35. package/roles/as9100-quality-auditor/tests/test_as9100_cli_profile.py +105 -0
  36. package/roles/as9100-quality-auditor/tests/test_as9100_core.py +204 -0
  37. package/roles/as9100-quality-auditor/tests/test_role_as9100.py +90 -0
  38. package/roles/data-bus-avionics-engineer/ROLE.md +146 -0
  39. package/roles/data-bus-avionics-engineer/SOURCES.md +23 -0
  40. package/roles/data-bus-avionics-engineer/cli.py +311 -0
  41. package/roles/data-bus-avionics-engineer/core/data_bus_avionics_core.py +1121 -0
  42. package/roles/data-bus-avionics-engineer/templates/bus-assessment-template.md +134 -0
  43. package/roles/data-bus-avionics-engineer/tests/test_bundle_protocol.py +137 -0
  44. package/roles/data-bus-avionics-engineer/tests/test_data_bus_avionics_core.py +261 -0
  45. package/roles/data-bus-avionics-engineer/tests/test_role_data_bus_avionics_engineer.py +121 -0
  46. package/roles/do160-environmental-engineer/ROLE.md +136 -0
  47. package/roles/do160-environmental-engineer/SOURCES.md +30 -0
  48. package/roles/do160-environmental-engineer/cli.py +147 -0
  49. package/roles/do160-environmental-engineer/core/do160_environmental_core.py +1246 -0
  50. package/roles/do160-environmental-engineer/templates/environmental-qualification-plan-template.md +114 -0
  51. package/roles/do160-environmental-engineer/tests/test_do160_environmental_core.py +312 -0
  52. package/roles/do160-environmental-engineer/tests/test_role_do160_environmental_engineer.py +281 -0
  53. package/roles/do178c-cert-engineer/ROLE.md +120 -0
  54. package/roles/do178c-cert-engineer/SOURCES.md +16 -0
  55. package/roles/do178c-cert-engineer/cli.py +129 -0
  56. package/roles/do178c-cert-engineer/core/do178c_core.py +411 -0
  57. package/roles/do178c-cert-engineer/templates/psac-template.md +74 -0
  58. package/roles/do178c-cert-engineer/tests/test_do178c_cert_engineer_core.py +91 -0
  59. package/roles/do178c-cert-engineer/tests/test_role_do178c.py +109 -0
  60. package/roles/do254-hardware-engineer/ROLE.md +127 -0
  61. package/roles/do254-hardware-engineer/SOURCES.md +19 -0
  62. package/roles/do254-hardware-engineer/cli.py +308 -0
  63. package/roles/do254-hardware-engineer/core/do254_hardware_core.py +761 -0
  64. package/roles/do254-hardware-engineer/templates/phac-template.md +87 -0
  65. package/roles/do254-hardware-engineer/tests/test_do254_bundle_profile.py +152 -0
  66. package/roles/do254-hardware-engineer/tests/test_do254_hardware_core.py +200 -0
  67. package/roles/do254-hardware-engineer/tests/test_role_do254_hardware.py +108 -0
  68. package/roles/engineering-analysis-engineer/ROLE.md +154 -0
  69. package/roles/engineering-analysis-engineer/SOURCES.md +15 -0
  70. package/roles/engineering-analysis-engineer/cli.py +151 -0
  71. package/roles/engineering-analysis-engineer/core/engineering_analysis_engineer_core.py +866 -0
  72. package/roles/engineering-analysis-engineer/templates/analysis-memo-template.md +73 -0
  73. package/roles/engineering-analysis-engineer/tests/test_engineering_analysis_engineer_core.py +367 -0
  74. package/roles/engineering-analysis-engineer/tests/test_role_engineering_analysis_engineer.py +113 -0
  75. package/roles/flight-management-engineer/ROLE.md +160 -0
  76. package/roles/flight-management-engineer/SOURCES.md +30 -0
  77. package/roles/flight-management-engineer/cli.py +258 -0
  78. package/roles/flight-management-engineer/core/flight_management_core.py +1473 -0
  79. package/roles/flight-management-engineer/templates/route-assessment-template.md +94 -0
  80. package/roles/flight-management-engineer/tests/test_flight_management_core.py +437 -0
  81. package/roles/flight-management-engineer/tests/test_role_flight_management_engineer.py +123 -0
  82. package/roles/flight-mechanics-engineer/ROLE.md +162 -0
  83. package/roles/flight-mechanics-engineer/SOURCES.md +51 -0
  84. package/roles/flight-mechanics-engineer/cli.py +149 -0
  85. package/roles/flight-mechanics-engineer/core/flight_mechanics_core.py +1507 -0
  86. package/roles/flight-mechanics-engineer/templates/perf-sc-report-template.md +82 -0
  87. package/roles/flight-mechanics-engineer/tests/test_flight_mechanics_core.py +277 -0
  88. package/roles/flight-mechanics-engineer/tests/test_role_flight_mechanics_engineer.py +141 -0
  89. package/roles/flight-test-engineer/ROLE.md +137 -0
  90. package/roles/flight-test-engineer/SOURCES.md +24 -0
  91. package/roles/flight-test-engineer/cli.py +135 -0
  92. package/roles/flight-test-engineer/core/flight_test_core.py +931 -0
  93. package/roles/flight-test-engineer/templates/flight-test-template.md +143 -0
  94. package/roles/flight-test-engineer/tests/test_flight_test_engineer_core.py +227 -0
  95. package/roles/flight-test-engineer/tests/test_role_flight_test_engineer.py +150 -0
  96. package/roles/flight-test-performance-engineer/ROLE.md +165 -0
  97. package/roles/flight-test-performance-engineer/SOURCES.md +20 -0
  98. package/roles/flight-test-performance-engineer/cli.py +319 -0
  99. package/roles/flight-test-performance-engineer/core/flight_test_performance_core.py +1398 -0
  100. package/roles/flight-test-performance-engineer/templates/performance-report-template.md +105 -0
  101. package/roles/flight-test-performance-engineer/tests/test_flight_test_performance_engineer_core.py +308 -0
  102. package/roles/flight-test-performance-engineer/tests/test_role_flight_test_performance_engineer.py +165 -0
  103. package/roles/gnc-engineer/ROLE.md +141 -0
  104. package/roles/gnc-engineer/SOURCES.md +35 -0
  105. package/roles/gnc-engineer/cli.py +141 -0
  106. package/roles/gnc-engineer/core/gnc_core.py +1074 -0
  107. package/roles/gnc-engineer/templates/gnc-report-template.md +105 -0
  108. package/roles/gnc-engineer/tests/test_gnc_engineer_core.py +238 -0
  109. package/roles/gnc-engineer/tests/test_role_gnc_engineer.py +142 -0
  110. package/roles/guidance-engineer/ROLE.md +156 -0
  111. package/roles/guidance-engineer/SOURCES.md +43 -0
  112. package/roles/guidance-engineer/cli.py +280 -0
  113. package/roles/guidance-engineer/core/guidance_core.py +820 -0
  114. package/roles/guidance-engineer/templates/guidance-report-template.md +70 -0
  115. package/roles/guidance-engineer/tests/test_guidance_engineer_core.py +269 -0
  116. package/roles/guidance-engineer/tests/test_role_guidance_engineer.py +146 -0
  117. package/roles/high-speed-aerodynamics-engineer/ROLE.md +161 -0
  118. package/roles/high-speed-aerodynamics-engineer/SOURCES.md +18 -0
  119. package/roles/high-speed-aerodynamics-engineer/cli.py +282 -0
  120. package/roles/high-speed-aerodynamics-engineer/core/high_speed_aero_core.py +1566 -0
  121. package/roles/high-speed-aerodynamics-engineer/templates/high-speed-memo-template.md +75 -0
  122. package/roles/high-speed-aerodynamics-engineer/tests/test_bundle_protocol.py +103 -0
  123. package/roles/high-speed-aerodynamics-engineer/tests/test_high_speed_aero_core.py +280 -0
  124. package/roles/high-speed-aerodynamics-engineer/tests/test_role_high_speed_aerodynamics.py +125 -0
  125. package/roles/ndt-engineer/ROLE.md +157 -0
  126. package/roles/ndt-engineer/SOURCES.md +18 -0
  127. package/roles/ndt-engineer/cli.py +316 -0
  128. package/roles/ndt-engineer/core/ndt_core.py +1590 -0
  129. package/roles/ndt-engineer/templates/ndt-plan-template.md +109 -0
  130. package/roles/ndt-engineer/tests/test_ndt_cli_bundle.py +183 -0
  131. package/roles/ndt-engineer/tests/test_ndt_core.py +437 -0
  132. package/roles/ndt-engineer/tests/test_role_ndt_engineer.py +148 -0
  133. package/roles/numerical-analysis-engineer/ROLE.md +158 -0
  134. package/roles/numerical-analysis-engineer/SOURCES.md +19 -0
  135. package/roles/numerical-analysis-engineer/cli.py +414 -0
  136. package/roles/numerical-analysis-engineer/core/numerical_analysis_core.py +1220 -0
  137. package/roles/numerical-analysis-engineer/templates/verification-memo-template.md +120 -0
  138. package/roles/numerical-analysis-engineer/tests/test_bundle_protocol.py +157 -0
  139. package/roles/numerical-analysis-engineer/tests/test_numerical_analysis_core.py +337 -0
  140. package/roles/numerical-analysis-engineer/tests/test_role_numerical_analysis_engineer.py +117 -0
  141. package/roles/propulsion-engineer/ROLE.md +163 -0
  142. package/roles/propulsion-engineer/SOURCES.md +15 -0
  143. package/roles/propulsion-engineer/cli.py +168 -0
  144. package/roles/propulsion-engineer/core/propulsion_core.py +1291 -0
  145. package/roles/propulsion-engineer/templates/propulsion-report-template.md +92 -0
  146. package/roles/propulsion-engineer/tests/test_propulsion_engineer_core.py +294 -0
  147. package/roles/propulsion-engineer/tests/test_role_propulsion_engineer.py +106 -0
  148. package/roles/space-systems-engineer/ROLE.md +161 -0
  149. package/roles/space-systems-engineer/SOURCES.md +49 -0
  150. package/roles/space-systems-engineer/cli.py +173 -0
  151. package/roles/space-systems-engineer/core/space_systems_core.py +980 -0
  152. package/roles/space-systems-engineer/templates/space-systems-template.md +88 -0
  153. package/roles/space-systems-engineer/tests/test_role_space_systems_engineer.py +117 -0
  154. package/roles/space-systems-engineer/tests/test_space_systems_engineer_core.py +247 -0
  155. package/roles/state-estimation-engineer/ROLE.md +154 -0
  156. package/roles/state-estimation-engineer/SOURCES.md +27 -0
  157. package/roles/state-estimation-engineer/cli.py +326 -0
  158. package/roles/state-estimation-engineer/core/state_estimation_core.py +958 -0
  159. package/roles/state-estimation-engineer/templates/nav-state-estimator-design-template.md +76 -0
  160. package/roles/state-estimation-engineer/tests/test_bundle_protocol.py +110 -0
  161. package/roles/state-estimation-engineer/tests/test_role_state_estimation_engineer.py +127 -0
  162. package/roles/state-estimation-engineer/tests/test_state_estimation_engineer_core.py +231 -0
  163. package/roles/structures-loads-engineer/ROLE.md +171 -0
  164. package/roles/structures-loads-engineer/SOURCES.md +44 -0
  165. package/roles/structures-loads-engineer/cli.py +219 -0
  166. package/roles/structures-loads-engineer/core/structures_loads_core.py +1112 -0
  167. package/roles/structures-loads-engineer/templates/loads-strength-report-template.md +96 -0
  168. package/roles/structures-loads-engineer/tests/test_bundle_protocol.py +101 -0
  169. package/roles/structures-loads-engineer/tests/test_role_structures_loads_engineer.py +147 -0
  170. package/roles/structures-loads-engineer/tests/test_structures_loads_core.py +261 -0
@@ -0,0 +1,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))