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,1112 @@
1
+ #!/usr/bin/env python3
2
+ """structures_loads_core.py - Structures and Loads Engineer executable core.
3
+
4
+ This is the role's ENGINE: given an airplane's loads facts and the
5
+ candidate wing design it derives the FAR 25 limit envelope (discrete
6
+ gust per FAR 25.341 + maneuver per FAR 25.337), converts the design
7
+ limit condition to ultimate loads with the 1.5 factor of safety
8
+ (FAR 25.303), resolves root shear/bending with an elliptic span-load
9
+ idealization, computes margins of safety per critical component from
10
+ real allowables, runs the fatigue screening (S-N basis + Miner sum),
11
+ and BUILDS the loads + strength report content. It also gate-checks
12
+ deliverables. Standalone: no external repo needed.
13
+
14
+ Domain rules encoded here mirror the real AeroSkills leaves the role
15
+ is bound to (gust-maneuver-loads, landing-ground-loads, buckling,
16
+ plate-buckling, lug-joint, mmpsd-allowables, material-selection,
17
+ stress-life, goodman, miner-damage, notch-sensitivity, beam-vibration):
18
+ the formulas are the same ones those leaves' behavior contracts assert.
19
+ The specific values in the worked example are the leaf anchors (Kg in
20
+ the 0.77-0.80 band, 7075-T6 allowables Ftu 83.0 / Fty 73.0 / Fsu 48.0 /
21
+ Fbru 152.3 ksi, fatigue strength coefficient ~100 ksi at b = -0.10),
22
+ stated with their basis. Nothing here reproduces MMPDS or FAR text.
23
+
24
+ Units: US customary throughout the report and engine internals (lb,
25
+ in, ksi, ft/s, psf) - the register of the FAR 25 loads rules. Metric
26
+ leaf anchors are converted at the module boundary (1 ksi = 6.894757
27
+ MPa, 1 GPa = 145.0377 ksi). Invalid inputs raise ValueError.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import math
32
+ import re
33
+ from dataclasses import dataclass, field
34
+ from datetime import date
35
+
36
+ def _today() -> str:
37
+ import os
38
+ from datetime import date
39
+ return os.environ.get("ROLE_GEN_DATE", date.today().isoformat())
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Domain constants (FAR 25.341 / 25.337 / 25.303, public regulation)
43
+ # ---------------------------------------------------------------------------
44
+
45
+ RHO0 = 0.002378 # sea-level standard density, slugs/ft^3
46
+ G = 32.174 # ft/s^2
47
+ KNOT = 1.68781 # ft/s per knot (used for unit labels, not calcs)
48
+
49
+ # FAR 25.341(a) design gust velocities at sea level, fps EAS.
50
+ GUST_VELOCITY_SEA_LEVEL = {"vb-vc": 66.0, "vc": 50.0, "vd": 25.0}
51
+ GUST_VELOCITY_FLOOR = {"vb-vc": 38.0, "vc": 25.0, "vd": 12.5}
52
+ GUST_ALTITUDE_FLOOR = {"vb-vc": 15000.0, "vc": 15000.0, "vd": 50000.0}
53
+
54
+ # FAR 25.337(b): positive limit maneuvering load factor at VA.
55
+ MANEUVER_LIMIT_VA = {"normal": 2.5, "commuter": 3.8, "transport": 3.8}
56
+ NEGATIVE_MANEUVER_LIMIT = -1.0
57
+ FACTOR_OF_SAFETY = 1.5 # FAR 25.303
58
+ MAX_GUST_VELOCITY = 66.0
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Materials - 7075-T6 screening allowables. Values are the real anchors used
62
+ # by the bound AeroSkills leaves (material-selection leaf: E 71.7 GPa,
63
+ # Fty 503 MPa, UTS 572 MPa; lug-joint worked example: Fsu 331 MPa,
64
+ # Fbru 1050 MPa; strain-life leaf 7075-T6 class fatigue constants).
65
+ # Basis: typical/reference-only screening values - confirm against MMPDS
66
+ # (licensed) before release. Never reproduced as a table here.
67
+ # ---------------------------------------------------------------------------
68
+ KSI = 6.894757 # MPa per ksi (conversion, not an allowable)
69
+
70
+ MATERIALS = {
71
+ "al-7075-t6": {
72
+ "label": "Aluminum 7075-T6 (screening allowables per bound "
73
+ "AeroSkills material/lug/strain-life anchors; confirm "
74
+ "B-basis values against MMPDS before release)",
75
+ "E_ksi": 10400.0, # 71.7 GPa
76
+ "Ftu_ksi": 572.0 / KSI, # 83.0
77
+ "Fty_ksi": 503.0 / KSI, # 73.0
78
+ "Fsu_ksi": 331.0 / KSI, # 48.0
79
+ "Fbru_ksi": 1050.0 / KSI, # 152.3
80
+ "rho_lb_in3": 0.101,
81
+ "nu": 0.33,
82
+ "sig_f_prime_ksi": 690.0 / KSI, # fatigue strength coeff, ~100.1 ksi
83
+ "b_fatigue": -0.10, # fatigue strength exponent
84
+ },
85
+ }
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Core loads logic (mirrors bound leaf gust_load_logic.py)
89
+ # ---------------------------------------------------------------------------
90
+
91
+
92
+ def _require_positive(value, name):
93
+ if value is None:
94
+ raise ValueError("%s is required, got None" % (name,))
95
+ value = float(value)
96
+ if not math.isfinite(value) or value <= 0.0:
97
+ raise ValueError("%s must be positive, got %r" % (name, value))
98
+ return value
99
+
100
+
101
+ def _require_nonnegative(value, name):
102
+ value = float(value)
103
+ if not math.isfinite(value) or value < 0.0:
104
+ raise ValueError("%s must be nonnegative, got %r" % (name, value))
105
+ return value
106
+
107
+
108
+ def far25_gust_velocity(speed_region, altitude_ft=0.0):
109
+ """Design discrete gust velocity U_de (fps EAS) per FAR 25.341(a)."""
110
+ if speed_region not in GUST_VELOCITY_SEA_LEVEL:
111
+ raise ValueError("speed_region must be one of vb-vc/vc/vd")
112
+ altitude_ft = _require_nonnegative(altitude_ft, "altitude_ft")
113
+ v0 = GUST_VELOCITY_SEA_LEVEL[speed_region]
114
+ alt_floor = GUST_ALTITUDE_FLOOR[speed_region]
115
+ v_floor = GUST_VELOCITY_FLOOR[speed_region]
116
+ if altitude_ft >= alt_floor:
117
+ return v_floor
118
+ return v0 - (v0 - v_floor) * (altitude_ft / alt_floor)
119
+
120
+
121
+ def gust_mass_ratio(ws, cbar, a, rho=RHO0):
122
+ """Mass ratio mu_g = 2*(W/S)/(rho*cbar*a*g), FAR 25.341(b)(2)."""
123
+ return (2.0 * _require_positive(ws, "ws")
124
+ / (_require_positive(rho, "rho") * _require_positive(cbar, "cbar")
125
+ * _require_positive(a, "a") * G))
126
+
127
+
128
+ def gust_alleviation_factor(ws, cbar, a, rho=RHO0):
129
+ """Gust alleviation factor Kg = 0.88*mu_g/(5.3+mu_g)."""
130
+ mu = gust_mass_ratio(ws, cbar, a, rho)
131
+ return 0.88 * mu / (5.3 + mu)
132
+
133
+
134
+ def gust_load_factor(ve_fps, ws, a, u_de_fps, kg=None, cbar=None, rho=RHO0):
135
+ """n = 1 + (rho0*V_e*a*K_g*U_de)/(2*W/S) with V_e in ft/s (FAR 25.341)."""
136
+ ve = _require_positive(ve_fps, "ve")
137
+ ws = _require_positive(ws, "ws")
138
+ a = _require_positive(a, "a")
139
+ u = float(u_de_fps)
140
+ if not math.isfinite(u) or u == 0.0:
141
+ raise ValueError("u_de must be a nonzero finite gust velocity")
142
+ if abs(u) > MAX_GUST_VELOCITY:
143
+ raise ValueError("u_de magnitude %.3f fps exceeds the FAR 25.341 "
144
+ "maximum design gust velocity of %.1f fps EAS"
145
+ % (abs(u), MAX_GUST_VELOCITY))
146
+ if kg is None:
147
+ if cbar is None:
148
+ raise ValueError("kg or cbar must be given")
149
+ kg = gust_alleviation_factor(ws, cbar, a, rho)
150
+ kg = _require_positive(kg, "kg")
151
+ return 1.0 + (RHO0 * ve * a * kg * u) / (2.0 * ws)
152
+
153
+
154
+ def maneuver_limit_load_factor(weight_lb, category="transport",
155
+ negative=False):
156
+ """Positive limit maneuvering load factor at VA per FAR 25.337(b):
157
+ n = max(2.5, min(3.8, 2.1 + 24000/(W + 10000))) with W the design
158
+ maximum takeoff weight in lb (regulation text, public domain). The
159
+ bound gust leaf's category table (normal 2.5, commuter 3.8) is used
160
+ for the non-transport entries; for transport the exact regulation
161
+ formula is applied - a 100,000 lb transport lands on the 2.5 floor,
162
+ exactly as the leaf's own worked envelope (VA = VS*sqrt(2.5))."""
163
+ if negative:
164
+ return NEGATIVE_MANEUVER_LIMIT
165
+ if category == "transport":
166
+ w = _require_positive(weight_lb, "weight_lb")
167
+ n = 2.1 + 24000.0 / (w + 10000.0)
168
+ return max(2.5, min(3.8, n))
169
+ try:
170
+ return MANEUVER_LIMIT_VA[category]
171
+ except KeyError:
172
+ raise ValueError("unknown category %r; use normal/commuter/transport"
173
+ % (category,))
174
+
175
+
176
+ def _positive_limit_at(vn, v):
177
+ """Positive envelope limit load factor at speed v (ft/s)."""
178
+ vs, va, vd = vn["vs"], vn["va"], vn["vd"]
179
+ n_pos = vn["n_positive"]
180
+ if v <= va:
181
+ return min(n_pos, (v / vs) ** 2)
182
+ if v >= vd:
183
+ return 0.0
184
+ return n_pos * (vd - v) / (vd - va)
185
+
186
+
187
+ def _negative_limit_at(vn, v):
188
+ vs, vc, vd = vn["vs"], vn["vc"], vn["vd"]
189
+ n_neg = vn["n_negative"]
190
+ if v <= vc:
191
+ return max(n_neg, -((v / vs) ** 2))
192
+ if v >= vd:
193
+ return 0.0
194
+ return n_neg * (vd - v) / (vd - vc)
195
+
196
+
197
+ def vn_diagram(ws, vs, vd, a, cbar, category="transport", altitude_ft=0.0,
198
+ rho=RHO0, weight_lb=None):
199
+ """Construct the FAR 25 V-n flight envelope as a dict.
200
+
201
+ Returns corner speeds (vs < va < vb < vc < vd enforced), limit
202
+ maneuvering load factors, gust points at VB/VC/VD with the FAR
203
+ 25.341(a) design gust velocities, and the gust velocities used.
204
+ weight_lb (design MTOW) is passed through to the FAR 25.337(b)
205
+ maneuvering-factor formula for the transport category.
206
+ """
207
+ ws = _require_positive(ws, "ws")
208
+ vs = _require_positive(vs, "vs")
209
+ vd = _require_positive(vd, "vd")
210
+ a = _require_positive(a, "a")
211
+ cbar = _require_positive(cbar, "cbar")
212
+ n_pos = maneuver_limit_load_factor(weight_lb, category)
213
+ n_neg = NEGATIVE_MANEUVER_LIMIT
214
+ va = vs * math.sqrt(n_pos)
215
+ vb = max(1.8 * vs, 1.05 * va)
216
+ vc = max(2.0 * vs, 1.1 * vb)
217
+ if not (vs < va < vb < vc < vd):
218
+ raise ValueError(
219
+ "speed ordering violated: need vs < va < vb < vc < vd, got "
220
+ "vs=%.1f va=%.1f vb=%.1f vc=%.1f vd=%.1f" % (vs, va, vb, vc, vd))
221
+ kg = gust_alleviation_factor(ws, cbar, a, rho)
222
+ u_bc = far25_gust_velocity("vb-vc", altitude_ft)
223
+ u_c = far25_gust_velocity("vc", altitude_ft)
224
+ u_d = far25_gust_velocity("vd", altitude_ft)
225
+ gust_points = []
226
+ for label, v, u_pos in (("VB", vb, u_bc), ("VC", vc, u_c),
227
+ ("VD", vd, u_d)):
228
+ gust_points.append({
229
+ "speed": label, "v": v,
230
+ "u_de": u_pos,
231
+ "n_pos": gust_load_factor(v, ws, a, u_pos, kg=kg),
232
+ "n_neg": gust_load_factor(v, ws, a, -u_pos, kg=kg),
233
+ })
234
+ return {
235
+ "category": category, "ws": ws, "cbar_ft": cbar, "a": a,
236
+ "vs": vs, "va": va, "vb": vb,
237
+ "vc": vc, "vd": vd, "n_positive": n_pos, "n_negative": n_neg,
238
+ "mu_g": gust_mass_ratio(ws, cbar, a, rho), "kg": kg,
239
+ "altitude_ft": altitude_ft,
240
+ "maneuver_envelope": {
241
+ "positive": [(vs, 1.0), (va, n_pos), (vd, 0.0)],
242
+ "negative": [(vs, n_neg), (vc, n_neg), (vd, 0.0)],
243
+ },
244
+ "gust_points": gust_points,
245
+ "gust_velocities": {"vb-vc": u_bc, "vc": u_c, "vd": u_d},
246
+ }
247
+
248
+
249
+ def design_limit_load_factor(vn):
250
+ """The limit design condition = max of the envelope (maneuver corner,
251
+ positive/negative plateau values and every gust line point). Returns
252
+ (n_limit, condition_label, speed_fps)."""
253
+ best = (vn["n_positive"], "maneuver at VA", vn["va"])
254
+ for p in vn["gust_points"]:
255
+ if p["n_pos"] > best[0]:
256
+ best = (p["n_pos"], "gust at %s (%.1f fps EAS)"
257
+ % (p["speed"], p["u_de"]), p["v"])
258
+ n_neg = vn["n_negative"]
259
+ if n_neg < 0.0:
260
+ for p in vn["gust_points"]:
261
+ if p["n_neg"] < n_neg:
262
+ n_neg = p["n_neg"]
263
+ return {"n_limit_pos": best[0], "n_limit_neg": n_neg,
264
+ "condition_pos": best[1], "speed_pos": best[2]}
265
+
266
+
267
+ def envelope_margins(vn):
268
+ """Margin check of each gust point against the maneuver envelope."""
269
+ out = {}
270
+ for p in vn["gust_points"]:
271
+ v = p["v"]
272
+ env_pos = _positive_limit_at(vn, v)
273
+ env_neg = _negative_limit_at(vn, v)
274
+ g_pos, g_neg = p["n_pos"], p["n_neg"]
275
+ m_pos = (env_pos - g_pos) / env_pos if env_pos > 0 else 1.0
276
+ m_neg = (g_neg - env_neg) / abs(env_neg) if env_neg < 0 else 1.0
277
+ out[p["speed"]] = {
278
+ "v": v, "n_envelope_pos": env_pos, "n_gust_pos": g_pos,
279
+ "margin_pos": m_pos, "gust_critical_pos": m_pos < 0.0,
280
+ "n_envelope_neg": env_neg, "n_gust_neg": g_neg,
281
+ "margin_neg": m_neg, "gust_critical_neg": m_neg < 0.0,
282
+ }
283
+ return out
284
+
285
+
286
+ # ---------------------------------------------------------------------------
287
+ # Landing / ground loads (mirrors bound leaf landing_ground_loads_logic.py)
288
+ # ---------------------------------------------------------------------------
289
+
290
+ N_LEVEL_DEFAULT = 2.5 # typical level-landing limit vertical inertia factor
291
+
292
+
293
+ def level_landing_reactions(weight_lb, a_ft, b_ft, lf=N_LEVEL_DEFAULT):
294
+ """Level landing reactions at a limit vertical inertia load factor."""
295
+ weight = _require_positive(weight_lb, "weight")
296
+ a = _require_positive(a_ft, "a (nose gear to CG)")
297
+ b = _require_positive(b_ft, "b (CG to main gear)")
298
+ lf = _require_positive(lf, "load factor")
299
+ r_nose = lf * weight * b / (a + b)
300
+ r_main = lf * weight * a / (a + b)
301
+ return {"nose_lb": r_nose, "main_lb": r_main, "total_lb": r_nose + r_main}
302
+
303
+
304
+ def braked_roll_deceleration(a_ft, b_ft, friction=0.8):
305
+ """Braked-roll deceleration in g: friction*a/(a+b)."""
306
+ a = _require_positive(a_ft, "a")
307
+ b = _require_positive(b_ft, "b")
308
+ friction = _require_positive(friction, "friction")
309
+ return friction * a / (a + b)
310
+
311
+
312
+ # ---------------------------------------------------------------------------
313
+ # Strength logic
314
+ # ---------------------------------------------------------------------------
315
+
316
+
317
+ def margin_of_safety(allowable, applied):
318
+ """MS = allowable/applied - 1 (must be >= 0 at ultimate for a pass)."""
319
+ allowable = _require_positive(allowable, "allowable")
320
+ applied = _require_positive(applied, "applied")
321
+ return allowable / applied - 1.0
322
+
323
+
324
+ def effective_length_factor(end_condition):
325
+ """K for named end conditions (bound buckling leaf table)."""
326
+ table = {"pinned-pinned": 1.0, "fixed-fixed": 0.5, "fixed-pinned": 0.7,
327
+ "fixed-free": 2.0}
328
+ name = end_condition.strip().lower().replace(" ", "-")
329
+ while "--" in name:
330
+ name = name.replace("--", "-")
331
+ aliases = {"pinned": "pinned-pinned", "hinged": "pinned-pinned",
332
+ "fixed": "fixed-fixed", "clamped": "fixed-fixed",
333
+ "cantilever": "fixed-free", "clamped-free": "fixed-free",
334
+ "clamped-clamped": "fixed-fixed"}
335
+ name = aliases.get(name, name)
336
+ if name not in table:
337
+ raise ValueError("unknown end condition %r" % (end_condition,))
338
+ return table[name]
339
+
340
+
341
+ def euler_buckling_load(e_ksi, i_in4, length_in,
342
+ end_condition="pinned-pinned", k_factor=None):
343
+ """Euler critical buckling load Pcr = pi^2 E I / (K L)^2, lb.
344
+
345
+ end_condition is a name resolved by effective_length_factor(); pass
346
+ k_factor directly to skip name resolution. E in ksi, I in in^4,
347
+ length in in -> Pcr in lb (E converted to psi internally).
348
+ """
349
+ k = (_require_positive(k_factor, "K")
350
+ if k_factor is not None else effective_length_factor(end_condition))
351
+ return (math.pi ** 2 * _require_positive(e_ksi, "E") * 1000.0
352
+ * _require_positive(i_in4, "I")
353
+ / (k * _require_positive(length_in, "L")) ** 2)
354
+
355
+
356
+ def column_check(e_ksi, i_in4, area_in2, length_in, end_condition,
357
+ applied_load_lb, fcy_ksi):
358
+ """Complete Euler column check with the yield transition. Returns the
359
+ governing allowable load (yield below lambda_1, Euler above), the
360
+ slenderness verdict and the margin of safety. All loads in lb."""
361
+ k = effective_length_factor(end_condition)
362
+ le = k * length_in
363
+ r = math.sqrt(i_in4 / area_in2)
364
+ lam = le / r
365
+ pcr = euler_buckling_load(e_ksi, i_in4, length_in, k_factor=k)
366
+ lam1 = math.pi * math.sqrt(e_ksi / fcy_ksi)
367
+ euler_governs = lam > lam1
368
+ p_allow = pcr if euler_governs else fcy_ksi * 1000.0 * area_in2
369
+ return {
370
+ "end_condition": end_condition, "K": k, "effective_length_in": le,
371
+ "radius_of_gyration_in": r, "slenderness_ratio": lam,
372
+ "transition_slenderness": lam1, "euler_governs": euler_governs,
373
+ "pcr_lb": pcr, "allowable_load_lb": p_allow,
374
+ "margin_of_safety": margin_of_safety(p_allow, applied_load_lb),
375
+ }
376
+
377
+
378
+ def plate_buckling_stress(e_ksi, nu, t_in, b_in, k=4.0):
379
+ """sigma_cr = k*pi^2 E/(12(1-nu^2)) (t/b)^2, ksi. k=4 long ssss plate
380
+ under uniform edge compression (bound plate-buckling leaf)."""
381
+ return (k * math.pi ** 2 * _require_positive(e_ksi, "E")
382
+ / (12.0 * (1.0 - nu ** 2))
383
+ * (_require_positive(t_in, "t") / _require_positive(b_in, "b")) ** 2)
384
+
385
+
386
+ def shear_buckling_stress(e_ksi, nu, t_in, b_in, a_over_b):
387
+ """tau_cr = k_s pi^2 E/(12(1-nu^2)) (t/b)^2 with Timoshenko shear
388
+ coefficient k_s = 5.34 + 4/(a/b)^2 (a/b >= 1), ksi."""
389
+ ar = _require_positive(a_over_b, "a/b")
390
+ k_s = 5.34 + 4.0 / ar ** 2 if ar >= 1.0 else 5.34 * ar ** 2 + 4.0
391
+ return (k_s * math.pi ** 2 * _require_positive(e_ksi, "E")
392
+ / (12.0 * (1.0 - nu ** 2))
393
+ * (_require_positive(t_in, "t") / _require_positive(b_in, "b")) ** 2)
394
+
395
+
396
+ def lug_analysis(load_lb, hole_diameter_in, thickness_in, lug_width_in,
397
+ edge_distance_in, f_tu_ksi, f_su_ksi, f_bru_ksi):
398
+ """Round-end lug margins (bound lug-joint-analysis leaf): bearing,
399
+ net-section tension, tearout; governing mode = lowest margin."""
400
+ if load_lb < 0:
401
+ raise ValueError("lug load must be non-negative")
402
+ D, t = hole_diameter_in, thickness_in
403
+ w, e = lug_width_in, edge_distance_in
404
+ if not (D > 0 and t > 0 and w > 0 and e > D / 2 and w > D):
405
+ raise ValueError("degenerate lug geometry")
406
+ sig_b = load_lb / (D * t) / 1000.0 # ksi
407
+ sig_nt = load_lb / ((w - D) * t) / 1000.0 # ksi
408
+ l_te = math.sqrt(e ** 2 - (D / 2.0) ** 2)
409
+ sig_te = load_lb / (2.0 * t * l_te) / 1000.0 # ksi
410
+ margins = {
411
+ "bearing": f_bru_ksi / sig_b - 1.0,
412
+ "net_tension": f_tu_ksi / sig_nt - 1.0,
413
+ "tearout": f_su_ksi / sig_te - 1.0,
414
+ }
415
+ governing = min(margins, key=lambda key: margins[key])
416
+ return {
417
+ "bearing_stress_ksi": sig_b, "net_tension_stress_ksi": sig_nt,
418
+ "tearout_stress_ksi": sig_te, "e_over_d": e / D, "d_over_t": D / t,
419
+ "bearing_margin": margins["bearing"],
420
+ "net_tension_margin": margins["net_tension"],
421
+ "tearout_margin": margins["tearout"],
422
+ "governing_mode": governing, "min_margin": margins[governing],
423
+ "passes": margins[governing] >= 0.0,
424
+ }
425
+
426
+
427
+ # ---------------------------------------------------------------------------
428
+ # Fatigue logic (mirrors bound stress-life / goodman / miner leaves)
429
+ # ---------------------------------------------------------------------------
430
+
431
+
432
+ def basquin_life(sig_a_ksi, a_ksi, b):
433
+ """Cycles to failure N = (S/A)^(1/b) (Basquin S-N, bound leaf)."""
434
+ if not sig_a_ksi > 0 or not a_ksi > 0 or b == 0.0:
435
+ raise ValueError("need S > 0, A > 0, b != 0")
436
+ return (sig_a_ksi / a_ksi) ** (1.0 / b)
437
+
438
+
439
+ def goodman_effective_amplitude(sig_a_ksi, sig_m_ksi, f_tu_ksi):
440
+ """Fully-reversed equivalent amplitude: Sa/(1 - Sm/Ftu) (modified
441
+ Goodman mean correction, bound goodman leaf)."""
442
+ f = _require_positive(f_tu_ksi, "Ftu")
443
+ sm = _require_nonnegative(sig_m_ksi, "Sm")
444
+ if sm >= f:
445
+ raise ValueError("mean stress must be below Ftu")
446
+ return _require_positive(sig_a_ksi, "Sa") / (1.0 - sm / f)
447
+
448
+
449
+ def cumulative_damage(cycles):
450
+ """Palmgren-Miner damage fraction over (n, N) blocks (bound leaf)."""
451
+ if not cycles:
452
+ raise ValueError("cycle blocks must not be empty")
453
+ total = 0.0
454
+ for n, n_fail in cycles:
455
+ if n < 0:
456
+ raise ValueError("applied cycles must be >= 0")
457
+ if n_fail <= 0:
458
+ raise ValueError("cycles to failure must be > 0")
459
+ total += n / float(n_fail)
460
+ return total
461
+
462
+
463
+ def fatigue_life_report(spectrum, sig_a_ksi, sig_m_ksi, mat):
464
+ """Miner sum and equivalent-life multiples for a per-flight spectrum.
465
+
466
+ spectrum: list of (cycles_per_flight, load_factor_amplitude_dn).
467
+ The stress amplitude for each block scales with dn: the reference
468
+ amplitude sig_a_ksi corresponds to dn = 1.0.
469
+ """
470
+ m = MATERIALS[mat]
471
+ a = m["sig_f_prime_ksi"]
472
+ b = m["b_fatigue"]
473
+ f_tu = m["Ftu_ksi"]
474
+ blocks = []
475
+ total_damage_per_flight = 0.0
476
+ for n_per_flight, dn in spectrum:
477
+ s_a = sig_a_ksi * dn
478
+ s_ar = goodman_effective_amplitude(s_a, sig_m_ksi, f_tu)
479
+ n_fail = basquin_life(s_ar, a, b)
480
+ blocks.append({"dn": dn, "cycles_per_flight": n_per_flight,
481
+ "Sa_ksi": s_a, "Sa_equiv_ksi": s_ar,
482
+ "N_fail": n_fail,
483
+ "damage_per_flight": n_per_flight / n_fail})
484
+ total_damage_per_flight += n_per_flight / n_fail
485
+ return {"blocks": blocks, "damage_per_flight": total_damage_per_flight,
486
+ "A_ksi": a, "b": b}
487
+
488
+
489
+ # ---------------------------------------------------------------------------
490
+ # Dynamics (mirrors bound beam-vibration leaf: cantilever first root)
491
+ # ---------------------------------------------------------------------------
492
+
493
+ CANTILEVER_BETA1_L = 1.87510407
494
+ # Equivalent uniform-beam stiffness factor: a real wing tapers, so the
495
+ # equivalent uniform cantilever uses a fraction of the root box EI
496
+ # (standard preliminary-dynamics idealization, ~0.25-0.35 of root EI).
497
+ WING_EI_EQUIVALENT_FACTOR = 0.30
498
+
499
+
500
+ def cantilever_frequency_hz(e_ksi, i_in4, mass_per_len_lb_s2_in,
501
+ length_in, stiffness_factor=1.0):
502
+ """First cantilever bending frequency f = (b1 L)^2/(2pi) sqrt(EI/(m L^4))
503
+ with m the mass per unit length in lb*s^2/in and stiffness_factor the
504
+ fraction of the given EI used by the equivalent uniform beam."""
505
+ ei = (_require_positive(e_ksi, "E") * 1000.0
506
+ * _require_positive(i_in4, "I")
507
+ * _require_positive(stiffness_factor, "stiffness_factor"))
508
+ m = _require_positive(mass_per_len_lb_s2_in, "m")
509
+ length = _require_positive(length_in, "L")
510
+ return (CANTILEVER_BETA1_L ** 2 / (2.0 * math.pi)
511
+ * math.sqrt(ei / (m * length ** 4)))
512
+
513
+
514
+ # ---------------------------------------------------------------------------
515
+ # Example wing (the role's reference item) - one coherent, self-consistent
516
+ # regional-transport-class wing sized by the rules above.
517
+ # ---------------------------------------------------------------------------
518
+
519
+
520
+ @dataclass
521
+ class ExampleWing:
522
+ """Project facts for the worked-example wing (reference item).
523
+
524
+ A 100,000 lb regional-transport-class wing (FAR 25). FAR 25.337(b)
525
+ gives the positive limit maneuvering factor n = max(2.5,
526
+ min(3.8, 2.1 + 24000/(W+10000))) = 2.5 at this weight, so the
527
+ corner sits at VA = VS*sqrt(2.5), matching the bound gust leaf's
528
+ own worked-envelope anchor.
529
+ """
530
+ name: str = "Example regional transport wing"
531
+ aircraft: str = "Example 100,000 lb regional transport (FAR 25)"
532
+ category: str = "transport"
533
+ gross_weight_lb: float = 100_000.0
534
+ wing_loading_psf: float = 100.0 # W/S (kept when re-sizing by weight)
535
+ aspect_ratio: float = 8.0 # AR (kept when re-sizing by weight)
536
+ wing_area_ft2: float = 1_000.0
537
+ span_ft: float = 89.44 # AR 8, cbar = S/b = 11.18 ft
538
+ cbar_ft: float = 11.18
539
+ lift_curve_slope: float = 5.7 # 1/rad
540
+ stall_speed_fps: float = 230.0 # EAS
541
+ dive_speed_fps: float = 621.0 # EAS
542
+ altitude_ft: float = 0.0
543
+ # wing weight fraction for dynamics (regional transport band)
544
+ wing_weight_fraction: float = 0.15
545
+ material: str = "al-7075-t6"
546
+ # Root box section (candidate design being checked)
547
+ box_depth_in: float = 40.0 # spar cap centroid separation
548
+ cap_area_in2: float = 17.5 # total cap area per surface (both spars)
549
+ web_thickness_in: float = 0.08 # per web
550
+ web_depth_in: float = 38.0 # effective web depth
551
+ # Compression surface (upper skin/stringer between ribs)
552
+ stringer_pitch_in: float = 6.0 # skin bay width b
553
+ skin_thickness_in: float = 0.25
554
+ stringer_area_in2: float = 1.2
555
+ stringer_i_in4: float = 0.60 # weak axis, panel buckling direction
556
+ rib_pitch_in: float = 22.0 # column length for stringer
557
+ # Root attachment lug (wing-to-fuselage vertical shear, per lug pair)
558
+ lug_hole_diameter_in: float = 3.0
559
+ lug_thickness_in: float = 0.75
560
+ lug_edge_distance_in: float = 4.5
561
+ # Landing gear stations (for the ground-loads row)
562
+ gear_nose_to_cg_ft: float = 18.0
563
+ gear_cg_to_main_ft: float = 6.0
564
+ # Fatigue: gust cycle count per flight at VC-scale gust intensity
565
+ fatigue_spectrum: list = field(default_factory=lambda: [
566
+ (1.0, 0.80), # 1 light-gust cycle/flight at dn = 0.80
567
+ (0.05, 1.40), # 1 per 20 flights at dn = 1.40
568
+ ])
569
+
570
+ def resize_for_weight(self, weight_lb):
571
+ """Re-derive the wing AND its box section for a new MTOW under
572
+ geometric similarity with the 100,000 lb baseline: planform keeps
573
+ the example's W/S and AR, and every length scales with the span
574
+ ratio while areas scale with its square (so margins land in the
575
+ same band)."""
576
+ w0 = 100_000.0
577
+ s0 = w0 / self.wing_loading_psf # baseline area
578
+ b0 = math.sqrt(self.aspect_ratio * s0) # baseline span
579
+ self.gross_weight_lb = _require_positive(weight_lb, "weight_lb")
580
+ self.wing_area_ft2 = self.gross_weight_lb / self.wing_loading_psf
581
+ self.span_ft = math.sqrt(self.aspect_ratio * self.wing_area_ft2)
582
+ self.cbar_ft = self.wing_area_ft2 / self.span_ft
583
+ r = self.span_ft / b0 # length ratio
584
+ self.box_depth_in *= r
585
+ self.cap_area_in2 *= r * r
586
+ self.web_thickness_in *= r
587
+ self.web_depth_in *= r
588
+ self.stringer_area_in2 *= r * r
589
+ self.stringer_i_in4 *= r ** 4
590
+ self.rib_pitch_in *= r
591
+ self.lug_hole_diameter_in *= r
592
+ self.lug_thickness_in *= r
593
+ self.lug_edge_distance_in *= r
594
+ return self
595
+
596
+
597
+ def _mat(item):
598
+ if item.material not in MATERIALS:
599
+ raise ValueError("unknown material %r" % (item.material,))
600
+ return MATERIALS[item.material]
601
+
602
+
603
+ # ---------------------------------------------------------------------------
604
+ # Report builder
605
+ # ---------------------------------------------------------------------------
606
+
607
+ def build_report(item: ExampleWing) -> dict:
608
+ """Build the complete loads + strength report content model."""
609
+ mat = _mat(item)
610
+ vn = vn_diagram(
611
+ ws=item.wing_loading_psf, vs=item.stall_speed_fps,
612
+ vd=item.dive_speed_fps, a=item.lift_curve_slope,
613
+ cbar=item.cbar_ft, category=item.category,
614
+ altitude_ft=item.altitude_ft, weight_lb=item.gross_weight_lb)
615
+ design = design_limit_load_factor(vn)
616
+ env_margins = envelope_margins(vn)
617
+
618
+ n_lim = design["n_limit_pos"]
619
+ n_ult = FACTOR_OF_SAFETY * n_lim
620
+ # Elliptic span-load idealization: root shear per wing = n*W/2 and
621
+ # root bending M = n*W*b/(3 pi) (semi-ellipse centroid at 4/(3 pi) of
622
+ # the semi-span from the root).
623
+ v_root_ult = n_ult * item.gross_weight_lb / 2.0
624
+ m_root_ult_ftlb = (n_ult * item.gross_weight_lb * item.span_ft
625
+ / (3.0 * math.pi))
626
+ m_root_ult_inlb = m_root_ult_ftlb * 12.0
627
+ m_root_limit_inlb = m_root_ult_inlb / FACTOR_OF_SAFETY
628
+
629
+ h = item.box_depth_in
630
+ a_cap = item.cap_area_in2
631
+ p_cap = m_root_ult_inlb / h # cap axial load, lb
632
+ sig_cap = p_cap / a_cap / 1000.0 # ksi
633
+ # Web shear: two webs carry the root shear
634
+ tau_web = v_root_ult / (2.0 * item.web_thickness_in
635
+ * item.web_depth_in) / 1000.0
636
+
637
+ e_ksi = mat["E_ksi"]
638
+ nu = mat["nu"]
639
+ # Compression surface: skin local buckling between stringers (long
640
+ # simply-supported plate, k = 4.0) and stringer column over the rib
641
+ # pitch (pinned-pinned).
642
+ sig_cr_skin = plate_buckling_stress(
643
+ e_ksi, nu, item.skin_thickness_in, item.stringer_pitch_in, k=4.0)
644
+ # Applied compression stress in the skin at the same fibre as the cap.
645
+ col = column_check(
646
+ e_ksi, item.stringer_i_in4, item.stringer_area_in2,
647
+ item.rib_pitch_in, "pinned-pinned",
648
+ applied_load_lb=sig_cap * 1000.0 * item.stringer_area_in2,
649
+ fcy_ksi=mat["Fty_ksi"])
650
+
651
+ lug = lug_analysis(
652
+ v_root_ult / 2.0, item.lug_hole_diameter_in, item.lug_thickness_in,
653
+ 2.0 * item.lug_edge_distance_in, item.lug_edge_distance_in,
654
+ mat["Ftu_ksi"], mat["Fsu_ksi"], mat["Fbru_ksi"])
655
+
656
+ # Fatigue at the lower spar cap (root): 1g stress amplitude scale
657
+ m_1g_ftlb = (item.gross_weight_lb * item.span_ft / (3.0 * math.pi))
658
+ m_1g_inlb = m_1g_ftlb * 12.0
659
+ sig_1g = m_1g_inlb / h / a_cap / 1000.0 # ksi at 1g
660
+ # Reference amplitude for dn=1.0 about the 1g mean (gust cycle) and a
661
+ # GAG cycle 0->1g (mean sig_1g/2, amplitude sig_1g/2).
662
+ sig_a_ref = sig_1g * 0.5 # dn 1.0 about 1g mean
663
+ sig_m_gust = sig_1g
664
+ fatigue_gust = fatigue_life_report(item.fatigue_spectrum, sig_a_ref,
665
+ sig_m_gust, item.material)
666
+ # GAG cycle: 1 per flight, 0g -> 1g (wing root bending)
667
+ g_ar = goodman_effective_amplitude(sig_1g / 2.0, sig_1g / 2.0,
668
+ mat["Ftu_ksi"])
669
+ n_gag = basquin_life(g_ar, mat["sig_f_prime_ksi"], mat["b_fatigue"])
670
+ damage_per_flight = fatigue_gust["damage_per_flight"] + 1.0 / n_gag
671
+
672
+ # Dynamics: first wing bending frequency (uniform cantilever, root box
673
+ # stiffness, wing weight fraction over the semi-span)
674
+ i_box_in4 = 2.0 * a_cap * (h / 2.0) ** 2
675
+ wing_weight_lb = item.wing_weight_fraction * item.gross_weight_lb
676
+ semi_span_in = item.span_ft * 12.0 / 2.0
677
+ w_per_in = (wing_weight_lb / 2.0) / semi_span_in # lb/in per wing
678
+ mass_per_len = w_per_in / 386.09 # lb*s^2/in per wing
679
+ f1 = cantilever_frequency_hz(e_ksi, i_box_in4, mass_per_len,
680
+ semi_span_in,
681
+ stiffness_factor=WING_EI_EQUIVALENT_FACTOR)
682
+
683
+ # Ground loads (level landing at MLW = gross weight in example)
684
+ ground = level_landing_reactions(
685
+ item.gross_weight_lb, item.gear_nose_to_cg_ft,
686
+ item.gear_cg_to_main_ft, N_LEVEL_DEFAULT)
687
+ decel_g = braked_roll_deceleration(item.gear_nose_to_cg_ft,
688
+ item.gear_cg_to_main_ft, 0.8)
689
+
690
+ margins = [
691
+ {"component": "Lower spar cap (tension, root)",
692
+ "load": "P = M_ult/h = %.0f lb" % p_cap,
693
+ "applied_ksi": sig_cap,
694
+ "allowable": "Ftu = %.1f ksi" % mat["Ftu_ksi"],
695
+ "MS": margin_of_safety(mat["Ftu_ksi"], sig_cap),
696
+ "basis": "boom couple at box depth %.0f in; 7075-T6 Ftu per bound "
697
+ "materials/lug leaf anchors (MMPDS B-basis to confirm)" % h},
698
+ {"component": "Upper spar cap (compression, root)",
699
+ "load": "P = M_ult/h = %.0f lb" % p_cap,
700
+ "applied_ksi": sig_cap,
701
+ "allowable": "Fcy = %.1f ksi" % mat["Fty_ksi"],
702
+ "MS": margin_of_safety(mat["Fty_ksi"], sig_cap),
703
+ "basis": "compression material limit (yield); local stability of "
704
+ "the stiffened panel carried by the skin/stringer rows"},
705
+ {"component": "Spar web (shear, two webs)",
706
+ "load": "V_ult = %.0f lb" % v_root_ult,
707
+ "applied_ksi": tau_web,
708
+ "allowable": "Fsu = %.1f ksi" % mat["Fsu_ksi"],
709
+ "MS": margin_of_safety(mat["Fsu_ksi"], tau_web),
710
+ "basis": "V_ult/(2 t h_web); 7075-T6 Fsu per bound lug leaf anchor "
711
+ "(MMPDS to confirm)"},
712
+ {"component": "Upper skin panel (compression buckling, between "
713
+ "stringers)",
714
+ "load": "sigma_app = %.1f ksi" % sig_cap,
715
+ "applied_ksi": sig_cap,
716
+ "allowable": "sigma_cr = %.1f ksi (k=4, ssss long plate)"
717
+ % sig_cr_skin,
718
+ "MS": margin_of_safety(sig_cr_skin, sig_cap),
719
+ "basis": "plate buckling sigma_cr = k pi^2 E/(12(1-nu^2)) (t/b)^2 "
720
+ "with b = stringer pitch %.1f in, t = %.2f in (bound "
721
+ "plate-buckling leaf)" % (item.stringer_pitch_in,
722
+ item.skin_thickness_in)},
723
+ {"component": "Upper stringer (column over rib pitch)",
724
+ "load": "P_app = %.0f lb" % (sig_cap * 1000.0
725
+ * item.stringer_area_in2),
726
+ "applied_ksi": sig_cap,
727
+ "allowable": ("Pcr = %.0f lb (Euler)" % col["pcr_lb"]
728
+ if col["euler_governs"]
729
+ else "Fcy*A = %.0f lb (yield-governed column)"
730
+ % col["allowable_load_lb"]),
731
+ "MS": col["margin_of_safety"],
732
+ "basis": ("Euler Pcr = pi^2 E I/(K L)^2, K=1 (pinned-pinned), "
733
+ "L = rib pitch %.1f in, lambda = %.1f > lambda_1 = %.1f"
734
+ % (item.rib_pitch_in, col["slenderness_ratio"],
735
+ col["transition_slenderness"])
736
+ if col["euler_governs"] else
737
+ "short column: lambda = %.1f < lambda_1 = %.1f, yield "
738
+ "governs (bound buckling leaf)"
739
+ % (col["slenderness_ratio"], col["transition_slenderness"]))},
740
+ {"component": "Wing root lug (vertical shear, one lug of pair)",
741
+ "load": "P = V_ult/2 = %.0f lb" % (v_root_ult / 2.0),
742
+ "applied_ksi": lug["bearing_stress_ksi"],
743
+ "allowable": ("min over bearing Fbru, net-tension Ftu, tearout Fsu; "
744
+ "governing mode %s" % lug["governing_mode"]),
745
+ "MS": lug["min_margin"],
746
+ "basis": "round-end lug: bearing, net section, tearout margins per "
747
+ "bound lug-joint leaf (e/D = %.2f, D/t = %.2f)"
748
+ % (lug["e_over_d"], lug["d_over_t"])},
749
+ ]
750
+ for m in margins:
751
+ m["status"] = "PASS" if m["MS"] >= 0.0 else "RESIZE"
752
+
753
+ return {
754
+ "document_type": "Loads and Strength Report",
755
+ "status": "draft-for-review",
756
+ "item": item.name,
757
+ "aircraft": item.aircraft,
758
+ "category": item.category,
759
+ "material": mat["label"],
760
+ "vn": vn,
761
+ "envelope_margins": env_margins,
762
+ "design": design,
763
+ "n_limit": n_lim,
764
+ "n_limit_neg": design["n_limit_neg"],
765
+ "n_ult": n_ult,
766
+ "loads_condition": design["condition_pos"],
767
+ "m_root_limit_inlb": m_root_limit_inlb,
768
+ "m_root_ult_inlb": m_root_ult_inlb,
769
+ "m_root_ult_ftlb": m_root_ult_ftlb,
770
+ "v_root_ult_lb": v_root_ult,
771
+ "ground": ground,
772
+ "decel_g": decel_g,
773
+ "margins": margins,
774
+ "dynamics_f1_hz": f1,
775
+ "geometry": {"box_depth_in": h, "cap_area_in2": a_cap,
776
+ "span_ft": item.span_ft,
777
+ "semi_span_ft": item.span_ft / 2.0,
778
+ "web_thickness_in": item.web_thickness_in,
779
+ "web_depth_in": item.web_depth_in,
780
+ "stringer_pitch_in": item.stringer_pitch_in,
781
+ "skin_thickness_in": item.skin_thickness_in,
782
+ "wing_weight_fraction": item.wing_weight_fraction},
783
+ "materials": {"E_ksi": mat["E_ksi"], "Ftu_ksi": mat["Ftu_ksi"],
784
+ "Fty_ksi": mat["Fty_ksi"], "Fsu_ksi": mat["Fsu_ksi"],
785
+ "Fbru_ksi": mat["Fbru_ksi"]},
786
+ "fatigue": {"sig_1g_ksi": sig_1g, "damage_per_flight": damage_per_flight,
787
+ "n_gag": n_gag, "gust_blocks": fatigue_gust["blocks"],
788
+ "A_ksi": fatigue_gust["A_ksi"], "b": fatigue_gust["b"],
789
+ "sig_m_gust_ksi": sig_m_gust},
790
+ "generated": _today(),
791
+ }
792
+
793
+
794
+ # ---------------------------------------------------------------------------
795
+ # Renderer
796
+ # ---------------------------------------------------------------------------
797
+
798
+ def _fmt(x, nd=3):
799
+ return ("%.*f" % (nd, x))
800
+
801
+
802
+ def render_report_markdown(model: dict) -> str:
803
+ """Render the report content model as the deliverable markdown."""
804
+ vn = model["vn"]
805
+ em = model["envelope_margins"]
806
+ lines = [
807
+ "# Loads and Strength Report",
808
+ "",
809
+ f"**Item:** {model['item']}",
810
+ f"**Aircraft:** {model['aircraft']}",
811
+ f"**Category / basis:** {model['category']} category, FAR 25 "
812
+ "(Subpart C loads, Subpart D structure)",
813
+ f"**Status:** {model['status']}",
814
+ "",
815
+ "## 1. Loads",
816
+ "",
817
+ f"- Wing loading W/S = {_fmt(vn['ws'])} psf, mean geometric chord "
818
+ f"cbar = {_fmt(vn['cbar_ft'])} ft (cbar = S/b), lift-curve slope "
819
+ f"a = {_fmt(vn['a'])}/rad.",
820
+ f"- Design speeds (EAS): VS = {_fmt(vn['vs'])} ft/s, "
821
+ f"VA = {_fmt(vn['va'])} ft/s, VB = {_fmt(vn['vb'])} ft/s, "
822
+ f"VC = {_fmt(vn['vc'])} ft/s, VD = {_fmt(vn['vd'])} ft/s.",
823
+ f"- Gust alleviation (FAR 25.341(b)(2)): mass ratio "
824
+ f"mu_g = {_fmt(vn['mu_g'])}, alleviation factor "
825
+ f"K_g = {_fmt(vn['kg'])}.",
826
+ f"- Limit maneuvering load factor at VA = {_fmt(vn['n_positive'])} "
827
+ f"({vn['category']} category, FAR 25.337); negative limit "
828
+ f"{_fmt(vn['n_negative'])} at speeds up to VC.",
829
+ "- Discrete-gust limit load factors (design gust velocities U_de "
830
+ "of FAR 25.341(a) at sea level, fps EAS):",
831
+ "",
832
+ ]
833
+ for p in vn["gust_points"]:
834
+ lines.append(
835
+ f" - {p['speed']} ({_fmt(p['v'])} ft/s, U_de = "
836
+ f"{_fmt(p['u_de'])} fps): n = {_fmt(p['n_pos'])} (positive), "
837
+ f"{_fmt(p['n_neg'])} (negative).")
838
+ env_lines = []
839
+ for label in ("VB", "VC", "VD"):
840
+ m = em[label]
841
+ verdict = ("gust-critical" if m["gust_critical_pos"]
842
+ else "maneuver envelope governs")
843
+ env_lines.append("%s margin %+.3f (%s)" % (label, m["margin_pos"],
844
+ verdict))
845
+ lines += [
846
+ "",
847
+ f"- Gust line vs maneuver envelope at VB/VC/VD: "
848
+ + "; ".join(env_lines) + ".",
849
+ "",
850
+ f"- **Design limit condition: {model['loads_condition']}, "
851
+ f"n_limit = +{_fmt(model['n_limit'])}** (negative envelope "
852
+ f"limit {_fmt(model['n_limit_neg'])}).",
853
+ f"- **Ultimate loads: n_ult = 1.5 x {_fmt(model['n_limit'])} = "
854
+ f"{_fmt(model['n_ult'])} (factor of safety, FAR 25.303).**",
855
+ "",
856
+ "- Root loads per wing at the design condition (elliptic span-load "
857
+ "idealization: root shear = nW/2 and root bending M = nWb/(3 pi), "
858
+ "the semi-ellipse lift resultant standing at 4/(3 pi) of the "
859
+ "semi-span from the root):",
860
+ f" - Limit root bending moment = "
861
+ f"{model['m_root_limit_inlb'] / 1e6:.2f} M in-lb; ultimate root "
862
+ f"bending M_ult = {model['m_root_ult_inlb'] / 1e6:.2f} M in-lb "
863
+ f"({model['m_root_ult_ftlb'] / 1e6:.2f} M ft-lb).",
864
+ f" - Ultimate root shear V_ult = "
865
+ f"{model['v_root_ult_lb'] / 1000:.0f} klb per wing.",
866
+ "",
867
+ "- Ground loads - level landing at the limit vertical inertia "
868
+ f"factor {_fmt(N_LEVEL_DEFAULT)} (bound landing-ground-loads "
869
+ "leaf):",
870
+ f" - Nose gear reaction {model['ground']['nose_lb'] / 1000:.0f} "
871
+ f"klb; main gear reaction "
872
+ f"{model['ground']['main_lb'] / 1000:.0f} klb (total "
873
+ f"{model['ground']['total_lb'] / 1000:.0f} klb).",
874
+ f" - Braked-roll deceleration {_fmt(model['decel_g'], 2)} g "
875
+ "(braking friction 0.8 on the main gear).",
876
+ "- Spectra: fatigue and dynamic load spectra feed the fatigue "
877
+ "screening (Section 6); random-vibration / shock-response-spectrum "
878
+ "conditions for equipment-mounted structure are derived with the "
879
+ "bound random-vibration-analysis / shock-response-spectrum leaves "
880
+ "when that structure is in scope.",
881
+ "",
882
+ "## 2. Global model",
883
+ "",
884
+ "- Idealization: each wing is a cantilever box beam from the root "
885
+ "to the tip carrying the elliptic spanwise lift distribution "
886
+ "l(y) = l0 sqrt(1 - (2y/b)^2); root shear and bending then follow "
887
+ "in closed form (above), which is sufficient for the screening "
888
+ "margins of this report. A refined beam-frame/truss/CalculiX model "
889
+ "(bound FEM leaves) confirms station-by-station internal loads and "
890
+ "deflections in the detailed phase.",
891
+ f"- Boundary conditions: wing root fixed at the fuselage side of "
892
+ f"body; box depth h = {_fmt(model['geometry']['box_depth_in'])} "
893
+ "in between spar cap centroids at the root section (example "
894
+ "geometry).",
895
+ f"- Internal loads at the root: M_ult = "
896
+ f"{model['m_root_ult_inlb'] / 1e6:.2f} M in-lb, V_ult = "
897
+ f"{model['v_root_ult_lb'] / 1000:.0f} klb - used for every margin "
898
+ "below.",
899
+ "",
900
+ "## 3. Strength/stability margins (ultimate load)",
901
+ "",
902
+ "| Component | Load | Allowable | MS | Basis |",
903
+ "|---|---|---|---|---|",
904
+ ]
905
+ for m in model["margins"]:
906
+ lines.append("| %s | %s | %s | **%+.3f** | %s |"
907
+ % (m["component"], m["load"], m["allowable"], m["MS"],
908
+ m["basis"]))
909
+ lines += [
910
+ "",
911
+ "## 4. Dynamics",
912
+ "",
913
+ f"- First wing bending frequency f1 = "
914
+ f"{_fmt(model['dynamics_f1_hz'])} Hz (uniform cantilever "
915
+ "idealization over the semi-span with the root box stiffness EI, "
916
+ "wing weight fraction applied; beta1*L = "
917
+ f"{_fmt(CANTILEVER_BETA1_L)}, bound beam-vibration leaf).",
918
+ "- Frequency placement vs rotor/propeller 1P and control-surface "
919
+ "excitation bands is confirmed against the bound "
920
+ "modal-analysis / beam-vibration leaves in the detailed phase.",
921
+ "",
922
+ "## 5. Materials",
923
+ "",
924
+ f"- Allowables used: 7075-T6 screening values from the bound "
925
+ "AeroSkills anchors (material-selection leaf E/Fty/UTS, "
926
+ "lug-joint worked example Fsu/Fbru, strain-life 7075-T6 class "
927
+ "fatigue constants). MMPDS (licensed) is referenced, never "
928
+ "reproduced; B-basis design allowables are confirmed by the "
929
+ "materials group before release:",
930
+ f" - E = {_fmt(model['materials']['E_ksi'], 0)} ksi; "
931
+ f"Ftu = {_fmt(model['materials']['Ftu_ksi'], 1)} ksi; "
932
+ f"Fty = {_fmt(model['materials']['Fty_ksi'], 1)} ksi; "
933
+ f"Fsu = {_fmt(model['materials']['Fsu_ksi'], 1)} ksi; "
934
+ f"Fbru = {_fmt(model['materials']['Fbru_ksi'], 1)} ksi.",
935
+ "- Ramberg-Osgood / creep / fracture data: not required for the "
936
+ "7075-T6 screening at room temperature; the bound "
937
+ "ramberg-osgood / creep-rupture / fracture-toughness leaves apply "
938
+ "where those regimes are in scope.",
939
+ "",
940
+ "## 6. Fatigue",
941
+ "",
942
+ f"- Critical location: lower spar cap at the root; 1g bending "
943
+ f"stress sigma_1g = {_fmt(model['fatigue']['sig_1g_ksi'], 1)} ksi.",
944
+ f"- S-N basis: Basquin S = A N^b with A = "
945
+ f"{_fmt(model['fatigue']['A_ksi'], 1)} ksi, b = "
946
+ f"{_fmt(model['fatigue']['b'], 2)} (7075-T6 class fatigue strength "
947
+ "constants, bound stress-life / strain-life leaves, "
948
+ "reference-only). Mean stress is corrected with the modified "
949
+ "Goodman rule Sa_eq = Sa / (1 - Sm/Ftu).",
950
+ "- Spectrum (per-flight cycle blocks at the lower spar cap, "
951
+ "amplitude dn about the 1g mean):",
952
+ "",
953
+ ]
954
+ for blk in model["fatigue"]["gust_blocks"]:
955
+ lines.append(
956
+ " - %.2f cycle(s)/flight at dn = %.2f: Sa = %.1f ksi, "
957
+ "Sa_eq = %.1f ksi, N = %.2e cycles, damage per flight = %.2e"
958
+ % (blk["cycles_per_flight"], blk["dn"], blk["Sa_ksi"],
959
+ blk["Sa_equiv_ksi"], blk["N_fail"],
960
+ blk["damage_per_flight"]))
961
+ sig_1g = model["fatigue"]["sig_1g_ksi"]
962
+ mat = MATERIALS["al-7075-t6"]
963
+ g_ar = goodman_effective_amplitude(sig_1g / 2.0, sig_1g / 2.0,
964
+ mat["Ftu_ksi"])
965
+ lines += [
966
+ "",
967
+ f"- GAG cycle (0g to 1g, once per flight): Sa = {sig_1g / 2.0:.1f} "
968
+ f"ksi, Sm = {sig_1g / 2.0:.1f} ksi, Sa_eq = {g_ar:.1f} ksi, "
969
+ f"N = {model['fatigue']['n_gag']:.2e} cycles, damage per flight = "
970
+ f"{1.0 / model['fatigue']['n_gag']:.2e}.",
971
+ f"- **Palmgren-Miner cumulative damage sum per flight D = "
972
+ f"{model['fatigue']['damage_per_flight']:.2e}** (limit 1.0): "
973
+ "safe-life screening passes with equivalent life far beyond the "
974
+ "design life; production fatigue refines with the counted load "
975
+ "spectrum and detail notch factors from the bound "
976
+ "load-spectrum-counting / notch-sensitivity / goodman-diagram "
977
+ "leaves.",
978
+ "",
979
+ "## 7. Damage tolerance",
980
+ "",
981
+ "- Safe-life screening (Section 6) is the scope of this example "
982
+ "report; the damage tolerance evaluation per FAR 25.571 (crack "
983
+ "growth, residual strength, inspection intervals) is executed with "
984
+ "the bound crack-growth / residual-strength / "
985
+ "widespread-fatigue-damage leaves once the production spectrum and "
986
+ "crack-growth data are baselined.",
987
+ "- Bird strike (FAR 25.631) is a design load case for the leading "
988
+ "edge and windshield supports; the bound bird-strike leaf applies "
989
+ "when that structure is in scope.",
990
+ "",
991
+ "## 8. Composites / thermal",
992
+ "",
993
+ "- The example item is metallic (7075-T6); laminate first-ply-"
994
+ "failure and sandwich margins from the bound "
995
+ "laminate-stiffness / laminate-first-ply-failure / "
996
+ "failure-criteria / sandwich-panels leaves apply when composite "
997
+ "structure enters the design.",
998
+ "- Thermal stress and thermal buckling margins are computed with "
999
+ "the bound thermal-stress-analysis / thermal-buckling leaves for "
1000
+ "thermal-critical structure; no thermal-critical metallic margin "
1001
+ "is driven in this room-temperature screening.",
1002
+ "",
1003
+ "## 9. Margin summary",
1004
+ "",
1005
+ "| Component | MS | Status |",
1006
+ "|---|---|---|",
1007
+ ]
1008
+ for m in model["margins"]:
1009
+ lines.append("| %s | %+.3f | %s |"
1010
+ % (m["component"], m["MS"], m["status"]))
1011
+ lines += [
1012
+ "",
1013
+ "---",
1014
+ f"*Generated by Aero Agent Roles structures-loads-engineer core "
1015
+ f"({model['generated']}). DRAFT for human stress lead review. Not "
1016
+ "an approval document - no certification approval is claimed, no "
1017
+ "compliance finding is declared. FEM results are as good as the "
1018
+ "model idealizations stated above.*",
1019
+ ]
1020
+ return "\n".join(lines)
1021
+
1022
+
1023
+ # ---------------------------------------------------------------------------
1024
+ # Evidence gate checkers
1025
+ # ---------------------------------------------------------------------------
1026
+
1027
+ GATE_CHECKS = {
1028
+ "loads_stated": "limit/ultimate load factors present as numbers",
1029
+ "envelope_present": "V-n gust + maneuver envelope present",
1030
+ "margins_present": "every margin row has a load, an allowable, an MS "
1031
+ "and a basis",
1032
+ "fatigue_stated": "fatigue life basis + Miner sum present",
1033
+ "sign_off_honest": "document marked draft-for-review, not approval",
1034
+ }
1035
+
1036
+
1037
+ def check_report(model: dict) -> dict:
1038
+ """Run the evidence gates against a report model."""
1039
+ vn = model.get("vn", {})
1040
+ results = {
1041
+ "loads_stated": (isinstance(model.get("n_limit"), (int, float))
1042
+ and isinstance(model.get("n_ult"), (int, float))
1043
+ and isinstance(model.get("n_limit_neg"),
1044
+ (int, float))),
1045
+ "envelope_present": bool(vn.get("gust_points"))
1046
+ and bool(vn.get("maneuver_envelope")),
1047
+ "margins_present": bool(model.get("margins")) and all(
1048
+ all(m.get(k) is not None for k in
1049
+ ("component", "load", "allowable", "MS", "basis"))
1050
+ for m in model["margins"]),
1051
+ "fatigue_stated": (model.get("fatigue", {}).get("damage_per_flight")
1052
+ is not None),
1053
+ "sign_off_honest": model.get("status") == "draft-for-review",
1054
+ }
1055
+ results["all_pass"] = all(results.values())
1056
+ return results
1057
+
1058
+
1059
+ def check_report_markdown(md_text: str) -> dict:
1060
+ """Gate-check the rendered markdown deliverable."""
1061
+ low = md_text.lower()
1062
+ checks = {
1063
+ "has_title": "loads and strength report" in low,
1064
+ "has_loads": bool(re.search(r"n_ult\s*=\s*\d", md_text))
1065
+ and "design limit condition" in low,
1066
+ "has_margins_table": ("| component | load | allowable | ms |"
1067
+ in low),
1068
+ "has_ms_numbers": len(re.findall(r"\*\*[+-]?\d+\.\d{2,}\*\*",
1069
+ md_text)) >= 6,
1070
+ "has_fatigue": ("palmgren-miner" in low and "s-n basis" in low
1071
+ and "damage" in low),
1072
+ "has_draft_marker": "draft" in low,
1073
+ "has_not_approval": "not an approval" in low,
1074
+ }
1075
+ checks["all_pass"] = all(checks.values())
1076
+ return checks
1077
+
1078
+
1079
+ def validate_deliverable_text(md_text: str) -> dict:
1080
+ """Public entry point used by gate tooling: check a report document."""
1081
+ return check_report_markdown(md_text)
1082
+
1083
+
1084
+ # ---------------------------------------------------------------------------
1085
+ # Example item / worked output (for tests and the filled template)
1086
+ # ---------------------------------------------------------------------------
1087
+
1088
+
1089
+ def example_item() -> ExampleWing:
1090
+ return ExampleWing()
1091
+
1092
+
1093
+ def example_report_markdown() -> str:
1094
+ return render_report_markdown(build_report(example_item()))
1095
+
1096
+
1097
+ if __name__ == "__main__":
1098
+ item = example_item()
1099
+ model = build_report(item)
1100
+ md = render_report_markdown(model)
1101
+ print("DESIGN LIMIT: %s n_limit=%.2f n_ult=%.2f"
1102
+ % (model["loads_condition"], model["n_limit"], model["n_ult"]))
1103
+ print("Kg=%.3f mu_g=%.1f" % (model["vn"]["kg"], model["vn"]["mu_g"]))
1104
+ print("M_ult=%.2f M in-lb V_ult=%.0f lb"
1105
+ % (model["m_root_ult_inlb"] / 1e6, model["v_root_ult_lb"]))
1106
+ for m in model["margins"]:
1107
+ print(" MS %+7.3f %s" % (m["MS"], m["component"]))
1108
+ print("f1=%.2f Hz D/flight=%.2e" % (model["dynamics_f1_hz"],
1109
+ model["fatigue"]["damage_per_flight"]))
1110
+ print("GATES: %s" % check_report(model))
1111
+ print("MD GATES: %s" % check_report_markdown(md))
1112
+ print("RENDERED: %d chars" % len(md))