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,1590 @@
1
+ #!/usr/bin/env python3
2
+ """ndt_core.py - NDT Engineer executable core.
3
+
4
+ This is the role's ENGINE: given an aerospace part's project facts it
5
+ selects non-destructive testing (NDT) methods per candidate defect
6
+ class and material class, computes inspection parameters for the
7
+ selected methods, frames acceptance/disposition, checks NDT personnel
8
+ qualification, and BUILDS the Nondestructive Test Plan and Method
9
+ Selection Report content. It also gate-checks deliverables. Standalone:
10
+ no external repo needed.
11
+
12
+ Every formula below is mirrored from the bound AeroSkills leaf logic
13
+ (manufacturing-quality/ndt/*, same org, Apache-2.0):
14
+ ndt-method-selection decision table + sensitivity ranking
15
+ eddy-current-inspection penetration depth / frequency selection
16
+ liquid-penetrant-inspection capillary + Washburn dwell math
17
+ radiographic-inspection density / IQI / unsharpness verdict
18
+ computed-tomography voxel / resolution / porosity math
19
+ magnetic-particle-inspection magnetization + indication math
20
+ leak-testing decay rate / disposition math
21
+ acoustic-emission-inspection hit/event/Felicity-ratio math
22
+ shearography-inspection strain / load / disposition math
23
+ ndt-personnel-qualification recert / vision / level rules
24
+ Standards (AS9100D, NAS 410) are referenced, never reproduced; the
25
+ levels/intervals are documented paraphrase-safe defaults from the
26
+ bound leaves, and the governing qualified procedure always rules.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import json # noqa: F401 (kept: model round-trips in tests)
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 tables (mirrored from the bound ndt-method-selection leaf logic)
43
+ # ---------------------------------------------------------------------------
44
+
45
+ DEFECT_CLASSES = ("surface", "near-surface", "internal")
46
+ MATERIAL_CLASSES = ("ferromagnetic", "non-ferromagnetic", "non-conductive")
47
+
48
+ # Decision table: defect class -> applicable methods. Surface defects also
49
+ # depend on the material class; internal/near-surface do not (leaf contract).
50
+ DECISION_TABLE = {
51
+ "internal": ("RT", "UT"),
52
+ "near-surface": ("ET", "UT"),
53
+ "surface": {
54
+ "ferromagnetic": ("MT", "PT"),
55
+ "non-ferromagnetic": ("ET", "PT"),
56
+ "non-conductive": ("PT",),
57
+ },
58
+ }
59
+
60
+ SENSITIVITY_RANK = {"UT": 5, "RT": 4, "ET": 4, "MT": 3, "PT": 3}
61
+ COST_RANK = {"RT": 4, "UT": 3, "ET": 2, "MT": 2, "PT": 1}
62
+ TIE_ORDER = ("RT", "UT", "ET", "MT", "PT")
63
+
64
+ METHOD_NAMES = {
65
+ "RT": "radiography (RT)",
66
+ "UT": "ultrasonic testing (UT)",
67
+ "ET": "eddy current testing (ET)",
68
+ "PT": "liquid penetrant testing (PT)",
69
+ "MT": "magnetic particle testing (MT)",
70
+ "CT": "computed tomography (CT)",
71
+ }
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # Personnel qualification tables (mirrored from ndt-personnel-qualification)
75
+ # ---------------------------------------------------------------------------
76
+
77
+ RECERT_INTERVAL_MONTHS_DEFAULT = 36 # NAS-410-style recertification norm
78
+ VISION_INTERVAL_MONTHS_DEFAULT = 12 # annual near-vision norm
79
+ PERSONNEL_LEVELS = ("i", "ii", "iii")
80
+
81
+ # Minimum certification level to perform / to interpret and disposition
82
+ # indications, per method (common aerospace practice paraphrase; the
83
+ # employer's written practice governs). Level I performs under a Level II
84
+ # or III supervisor; interpretation and written acceptance typically
85
+ # require Level II minimum. NAS 410 covers ET/UT/RT/PT/MT; CT and leak
86
+ # personnel follow employer written practice, modeled at the same
87
+ # Level II minimum.
88
+ PERFORM_LEVEL = {"ET": "i", "PT": "i", "RT": "i", "UT": "i", "MT": "i",
89
+ "CT": "ii", "LT": "ii"}
90
+ INTERPRET_LEVEL = {"ET": "ii", "PT": "ii", "RT": "ii", "UT": "ii",
91
+ "MT": "ii", "CT": "ii", "LT": "ii"}
92
+
93
+ _MONTH_DAYS = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
94
+
95
+
96
+ def _is_leap_year(year):
97
+ return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
98
+
99
+
100
+ def _days_in_month(year, month):
101
+ if month == 2 and _is_leap_year(year):
102
+ return 29
103
+ return _MONTH_DAYS[month - 1]
104
+
105
+
106
+ def add_months_clamped(base_iso, months):
107
+ """ISO date + months, day clamped to the target month end."""
108
+ y, m, d = (int(p) for p in base_iso.split("-"))
109
+ total = y * 12 + (m - 1) + months
110
+ ny = total // 12
111
+ nm = total % 12 + 1
112
+ nd = min(d, _days_in_month(ny, nm))
113
+ return date(ny, nm, nd).isoformat()
114
+
115
+
116
+ def recert_due_date(cert_date_iso,
117
+ interval_months=RECERT_INTERVAL_MONTHS_DEFAULT):
118
+ return add_months_clamped(cert_date_iso, interval_months)
119
+
120
+
121
+ def vision_due_date(last_vision_iso,
122
+ interval_months=VISION_INTERVAL_MONTHS_DEFAULT):
123
+ return add_months_clamped(last_vision_iso, interval_months)
124
+
125
+
126
+ def certification_status(cert_date_iso, recert_due_iso, vision_due_iso,
127
+ today_iso):
128
+ """current | recert-due | vision-due | recert-and-vision-due."""
129
+ recert_overdue = today_iso > recert_due_iso
130
+ vision_overdue = today_iso > vision_due_iso
131
+ if recert_overdue and vision_overdue:
132
+ return "recert-and-vision-due"
133
+ if recert_overdue:
134
+ return "recert-due"
135
+ if vision_overdue:
136
+ return "vision-due"
137
+ return "current"
138
+
139
+
140
+ def supervision_valid(operator_level, supervisor_level):
141
+ op = operator_level.strip().lower()
142
+ sup = supervisor_level.strip().lower()
143
+ if op == "i":
144
+ return sup in ("ii", "iii")
145
+ return True
146
+
147
+
148
+ def level_meets(held_level, required_level):
149
+ """True when held certification level meets the required level."""
150
+ return PERSONNEL_LEVELS.index(held_level.strip().lower()) >= \
151
+ PERSONNEL_LEVELS.index(required_level.strip().lower())
152
+
153
+
154
+ def upgrade_eligible(current_level, target_level, held_hours, required_hours,
155
+ held_months, required_months, exam_passed):
156
+ if PERSONNEL_LEVELS.index(target_level) != \
157
+ PERSONNEL_LEVELS.index(current_level) + 1:
158
+ raise ValueError("upgrade target must be exactly one level above")
159
+ return (held_hours >= required_hours and held_months >= required_months
160
+ and bool(exam_passed))
161
+
162
+
163
+ def qualification_review(record, today_iso):
164
+ """Full personnel-record review as one verdict dict (mirrors the leaf's
165
+ qualification_review). record carries cert_date_iso, last_vision_iso,
166
+ operator_level, supervisor_level and optional upgrade inputs."""
167
+ recert_due = recert_due_date(record["cert_date_iso"])
168
+ vision_due = vision_due_date(record["last_vision_iso"])
169
+ status = certification_status(record["cert_date_iso"], recert_due,
170
+ vision_due, today_iso)
171
+ up = None
172
+ up_in = record.get("upgrade_inputs")
173
+ if up_in:
174
+ up = upgrade_eligible(record["operator_level"], up_in["target_level"],
175
+ up_in["held_hours"], up_in["required_hours"],
176
+ up_in["held_months"], up_in["required_months"],
177
+ up_in["exam_passed"])
178
+ return {
179
+ "certification_status": status,
180
+ "recert_due_date_iso": recert_due,
181
+ "vision_due_date_iso": vision_due,
182
+ "supervision_ok": supervision_valid(record["operator_level"],
183
+ record["supervisor_level"]),
184
+ "upgrade_eligible": up,
185
+ }
186
+
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # Method selection (mirrored from ndt-method-selection leaf logic)
190
+ # ---------------------------------------------------------------------------
191
+
192
+ def applicable_methods(defect_class, material_class):
193
+ if defect_class not in DEFECT_CLASSES:
194
+ raise ValueError("unknown defect class: %r" % (defect_class,))
195
+ if material_class not in MATERIAL_CLASSES:
196
+ raise ValueError("unknown material class: %r" % (material_class,))
197
+ if defect_class == "surface":
198
+ return sorted(DECISION_TABLE["surface"][material_class])
199
+ return sorted(DECISION_TABLE[defect_class])
200
+
201
+
202
+ def sensitivity_rank(method):
203
+ if method not in SENSITIVITY_RANK:
204
+ raise ValueError("unknown method: %r" % (method,))
205
+ return SENSITIVITY_RANK[method]
206
+
207
+
208
+ def cost_rank(method):
209
+ if method not in COST_RANK:
210
+ raise ValueError("unknown method: %r" % (method,))
211
+ return COST_RANK[method]
212
+
213
+
214
+ def select_method(defect_class, material_class):
215
+ """Pick the highest-sensitivity applicable method; ties by TIE_ORDER
216
+ (later wins). Mirrors the ndt-method-selection leaf select_method."""
217
+ applicable = applicable_methods(defect_class, material_class)
218
+ top = max(applicable,
219
+ key=lambda m: (SENSITIVITY_RANK[m], TIE_ORDER.index(m)))
220
+ alternates = [m for m in applicable if m != top]
221
+ return {
222
+ "method": top,
223
+ "alternates": alternates,
224
+ "rationale": ("%s defect in %s material: %s is the "
225
+ "highest-sensitivity applicable method"
226
+ % (defect_class, material_class, top)),
227
+ }
228
+
229
+
230
+ # ---------------------------------------------------------------------------
231
+ # Eddy current (ET) - mirrored from eddy-current-inspection leaf logic
232
+ # ---------------------------------------------------------------------------
233
+
234
+ MU0 = 4.0 * math.pi * 1e-7 # vacuum permeability, H/m
235
+ COPPER_100_IACS = 5.8e7 # S/m, 100 pct IACS reference
236
+
237
+
238
+ def conductivity_from_iacs(percent_iacs):
239
+ return (percent_iacs / 100.0) * COPPER_100_IACS
240
+
241
+
242
+ def standard_depth_of_penetration(frequency, conductivity,
243
+ relative_permeability=1.0):
244
+ mu = MU0 * relative_permeability
245
+ return 1.0 / math.sqrt(math.pi * frequency * mu * conductivity)
246
+
247
+
248
+ def frequency_for_depth(depth, conductivity, relative_permeability=1.0):
249
+ mu = MU0 * relative_permeability
250
+ return 1.0 / (math.pi * mu * conductivity * depth * depth)
251
+
252
+
253
+ def select_frequency_for_flaw(flaw_depth, conductivity,
254
+ relative_permeability=1.0,
255
+ penetration_factor=2.0):
256
+ """Frequency that puts the standard depth of penetration at
257
+ penetration_factor x flaw depth (>= 2 for subsurface flaws, < 1 for
258
+ surface-flaw sharp response)."""
259
+ return frequency_for_depth(penetration_factor * flaw_depth, conductivity,
260
+ relative_permeability)
261
+
262
+
263
+ def eddy_current_density_ratio(depth, delta):
264
+ return math.exp(-depth / delta)
265
+
266
+
267
+ def phase_lag_degrees(depth, delta):
268
+ return (depth / delta) * 180.0 / math.pi
269
+
270
+
271
+ def et_plan(flaw_depth_m, percent_iacs, penetration_factor,
272
+ label="subsurface"):
273
+ """One eddy-current parameter row: frequency, standard depth of
274
+ penetration, density ratio and phase lag at the flaw depth."""
275
+ sigma = conductivity_from_iacs(percent_iacs)
276
+ f = select_frequency_for_flaw(flaw_depth_m, sigma,
277
+ penetration_factor=penetration_factor)
278
+ delta = standard_depth_of_penetration(f, sigma)
279
+ return {
280
+ "label": label,
281
+ "flaw_depth_m": flaw_depth_m,
282
+ "percent_iacs": percent_iacs,
283
+ "conductivity_sm": sigma,
284
+ "penetration_factor": penetration_factor,
285
+ "frequency_hz": f,
286
+ "delta_m": delta,
287
+ "density_ratio_at_flaw": eddy_current_density_ratio(flaw_depth_m,
288
+ delta),
289
+ "phase_lag_deg": phase_lag_degrees(flaw_depth_m, delta),
290
+ "inputs": {"flaw_depth_m": flaw_depth_m,
291
+ "percent_iacs": percent_iacs,
292
+ "penetration_factor": penetration_factor},
293
+ }
294
+
295
+
296
+ # ---------------------------------------------------------------------------
297
+ # Liquid penetrant (PT) - mirrored from liquid-penetrant-inspection leaf
298
+ # ---------------------------------------------------------------------------
299
+
300
+ GRAVITY = 9.80665
301
+
302
+
303
+ def _wetting_cos(contact_angle_deg):
304
+ return math.cos(math.radians(contact_angle_deg))
305
+
306
+
307
+ def capillary_pressure(surface_tension, contact_angle_deg, radius):
308
+ return 2.0 * surface_tension * _wetting_cos(contact_angle_deg) / radius
309
+
310
+
311
+ def washburn_penetration_depth(surface_tension, contact_angle_deg, viscosity,
312
+ radius, time):
313
+ return math.sqrt(radius * surface_tension
314
+ * _wetting_cos(contact_angle_deg) * time
315
+ / (2.0 * viscosity))
316
+
317
+
318
+ def dwell_time_for_depth(depth, surface_tension, contact_angle_deg,
319
+ viscosity, radius):
320
+ return (2.0 * viscosity * depth * depth
321
+ / (radius * surface_tension * _wetting_cos(contact_angle_deg)))
322
+
323
+
324
+ def crack_radius_from_width(width):
325
+ return width / 2.0
326
+
327
+
328
+ def bleed_out_width(flaw_width, bleed_factor):
329
+ return flaw_width * bleed_factor
330
+
331
+
332
+ def bleed_out_ratio(indication_width, flaw_width):
333
+ return indication_width / flaw_width
334
+
335
+
336
+ def developer_coverage_mass(area, areal_density):
337
+ return area * areal_density
338
+
339
+
340
+ def contrast_ratio(background_reflectance, indication_reflectance):
341
+ return (abs(background_reflectance - indication_reflectance)
342
+ / max(background_reflectance, indication_reflectance))
343
+
344
+
345
+ def pt_plan(pt, part_area_m2):
346
+ """One liquid-penetrant parameter row for the surface-crack zone.
347
+
348
+ pt carries the penetrant properties and the crack/development facts
349
+ (typical values documented in the bound leaf; the qualified penetrant
350
+ system and procedure govern the real settings)."""
351
+ gamma = pt["surface_tension_nm"]
352
+ theta = pt["contact_angle_deg"]
353
+ eta = pt["viscosity_pas"]
354
+ crack_depth = pt["crack_depth_m"]
355
+ opening = pt["crack_opening_width_m"]
356
+ dwell_ref = pt.get("reference_dwell_s", 300.0)
357
+ areal = pt.get("developer_areal_density_kgm2", 0.15)
358
+ bleed = pt.get("bleed_factor", 4.0)
359
+ radius = crack_radius_from_width(opening)
360
+ depth_ref = washburn_penetration_depth(gamma, theta, eta, radius,
361
+ dwell_ref)
362
+ t_fill = dwell_time_for_depth(crack_depth, gamma, theta, eta, radius)
363
+ indication_w = bleed_out_width(opening, bleed)
364
+ return {
365
+ "surface_tension_nm": gamma,
366
+ "contact_angle_deg": theta,
367
+ "viscosity_pas": eta,
368
+ "crack_depth_m": crack_depth,
369
+ "crack_opening_width_m": opening,
370
+ "effective_capillary_radius_m": radius,
371
+ "capillary_pressure_pa": capillary_pressure(gamma, theta, radius),
372
+ "washburn_depth_at_reference_s_m": depth_ref,
373
+ "reference_dwell_s": dwell_ref,
374
+ "dwell_time_to_fill_s": t_fill,
375
+ "bleed_factor": bleed,
376
+ "indication_width_m": indication_w,
377
+ "bleed_out_ratio": bleed_out_ratio(indication_w, opening),
378
+ "developer_mass_kg": developer_coverage_mass(part_area_m2, areal),
379
+ "developer_areal_density_kgm2": areal,
380
+ "part_area_m2": part_area_m2,
381
+ "contrast_ratio": contrast_ratio(0.8, 0.05),
382
+ "inputs": {"crack_depth_m": crack_depth,
383
+ "crack_opening_width_m": opening,
384
+ "reference_dwell_s": dwell_ref},
385
+ }
386
+
387
+
388
+ # ---------------------------------------------------------------------------
389
+ # Radiography (RT) - mirrored from radiographic-inspection leaf logic
390
+ # ---------------------------------------------------------------------------
391
+
392
+ DENSITY_BAND = (2.0, 4.0)
393
+ UNSHARPNESS_LIMIT_MM = 0.25
394
+ SENSITIVITY_LIMIT_PERCENT = 2.0
395
+
396
+
397
+ def geometric_unsharpness(focal_spot_mm, sod_mm, odd_mm):
398
+ return focal_spot_mm * odd_mm / sod_mm
399
+
400
+
401
+ def exposure_time(base_time, distance, reference_distance):
402
+ return base_time * (distance / reference_distance) ** 2
403
+
404
+
405
+ def iqi_sensitivity_percent(visible_thickness_mm, part_thickness_mm):
406
+ return visible_thickness_mm / part_thickness_mm * 100.0
407
+
408
+
409
+ def density_verdict(film_density):
410
+ band = DENSITY_BAND
411
+ if band[0] <= film_density <= band[1]:
412
+ verdict, ok = "acceptable", True
413
+ elif film_density < band[0]:
414
+ verdict, ok = "too-low", False
415
+ else:
416
+ verdict, ok = "too-high", False
417
+ return {"density": film_density, "acceptable": ok, "verdict": verdict,
418
+ "band": list(band)}
419
+
420
+
421
+ def discontinuity_class(geometry_descriptor):
422
+ desc = (geometry_descriptor or "").strip().lower()
423
+ if any(k in desc for k in ("round", "globular", "spherical", "gas")):
424
+ return "porosity"
425
+ if any(k in desc for k in ("elongated", "linear", "sharp", "hairline",
426
+ "tight")):
427
+ return "crack"
428
+ if any(k in desc for k in ("compact", "dense", "metallic",
429
+ "high-density")):
430
+ return "inclusion"
431
+ if any(k in desc for k in ("flat", "planar", "layered", "angular",
432
+ "slag")):
433
+ return "slag"
434
+ raise ValueError("unknown geometry descriptor %r" % (geometry_descriptor,))
435
+
436
+
437
+ def rt_setup_verdict(unsharpness_mm, sensitivity_percent, film_density,
438
+ unsharpness_limit_mm=UNSHARPNESS_LIMIT_MM,
439
+ sensitivity_limit_percent=SENSITIVITY_LIMIT_PERCENT):
440
+ dv = density_verdict(film_density)
441
+ reasons = []
442
+ if unsharpness_mm > unsharpness_limit_mm:
443
+ reasons.append("geometric unsharpness %.3f mm exceeds the %.3f mm "
444
+ "limit" % (unsharpness_mm, unsharpness_limit_mm))
445
+ if sensitivity_percent > sensitivity_limit_percent:
446
+ reasons.append("IQI sensitivity %.2f percent exceeds the %.2f "
447
+ "percent limit" % (sensitivity_percent,
448
+ sensitivity_limit_percent))
449
+ if not dv["acceptable"]:
450
+ reasons.append("film density %.2f is %s (band %.1f to %.1f)"
451
+ % (film_density, dv["verdict"], DENSITY_BAND[0],
452
+ DENSITY_BAND[1]))
453
+ return {"acceptable": not reasons, "reasons": reasons}
454
+
455
+
456
+ def rt_plan(setup):
457
+ """One radiography parameter row from a setup facts dict."""
458
+ ug = geometric_unsharpness(setup["focal_spot_mm"], setup["sod_mm"],
459
+ setup["odd_mm"])
460
+ exposure = exposure_time(setup["base_exposure_min"],
461
+ setup["sod_mm"] + setup["odd_mm"],
462
+ setup["reference_distance_mm"])
463
+ sens = iqi_sensitivity_percent(setup["iq_visible_mm"],
464
+ setup["section_thickness_mm"])
465
+ verdict = rt_setup_verdict(ug, sens, setup["film_density"])
466
+ return {
467
+ "focal_spot_mm": setup["focal_spot_mm"],
468
+ "sod_mm": setup["sod_mm"],
469
+ "odd_mm": setup["odd_mm"],
470
+ "geometric_unsharpness_mm": ug,
471
+ "unsharpness_limit_mm": UNSHARPNESS_LIMIT_MM,
472
+ "exposure_min": exposure,
473
+ "iq_visible_mm": setup["iq_visible_mm"],
474
+ "section_thickness_mm": setup["section_thickness_mm"],
475
+ "iqi_sensitivity_percent": sens,
476
+ "sensitivity_limit_percent": SENSITIVITY_LIMIT_PERCENT,
477
+ "film_density": setup["film_density"],
478
+ "density_band": list(DENSITY_BAND),
479
+ "density_verdict": density_verdict(setup["film_density"])["verdict"],
480
+ "setup_acceptable": verdict["acceptable"],
481
+ "setup_reasons": verdict["reasons"],
482
+ "discontinuity_class": discontinuity_class(
483
+ setup["geometry_descriptor"]),
484
+ "inputs": dict(setup),
485
+ }
486
+
487
+
488
+ # ---------------------------------------------------------------------------
489
+ # Computed tomography (CT) - mirrored from computed-tomography leaf logic
490
+ # ---------------------------------------------------------------------------
491
+
492
+ DETECT_FACTOR = 3
493
+ PROJECTION_K = math.pi / 2.0
494
+ KV_PER_MM = {"aluminum": 7.0, "titanium": 9.0, "steel": 14.0, "nickel": 16.0}
495
+
496
+
497
+ def magnification(sod, odd):
498
+ return (sod + odd) / sod
499
+
500
+
501
+ def voxel_size(pixel_pitch, sod, odd):
502
+ return pixel_pitch / magnification(sod, odd)
503
+
504
+
505
+ def resolution_check(voxel_size_m, required_flaw_m):
506
+ smallest = voxel_size_m * DETECT_FACTOR
507
+ if smallest <= required_flaw_m * (1.0 + 1e-9):
508
+ return ("PASS: smallest detectable feature %.3e m (3 voxels) is at "
509
+ "or below the required %.3e m flaw size"
510
+ % (smallest, required_flaw_m))
511
+ return ("FAIL: smallest detectable feature %.3e m (3 voxels) exceeds "
512
+ "the required %.3e m flaw size" % (smallest, required_flaw_m))
513
+
514
+
515
+ def projection_count(columns_span):
516
+ return int(math.ceil(PROJECTION_K * columns_span))
517
+
518
+
519
+ def tube_energy_kv(material, thickness_mm):
520
+ key = material.strip().lower()
521
+ if key not in KV_PER_MM:
522
+ raise ValueError("unknown material %r" % (material,))
523
+ return KV_PER_MM[key] * thickness_mm
524
+
525
+
526
+ def scan_time(num_projections, exposure_s_per_proj):
527
+ return num_projections * exposure_s_per_proj
528
+
529
+
530
+ def ct_number(mu, mu_water):
531
+ return 1000.0 * (mu - mu_water) / mu_water
532
+
533
+
534
+ def material_class_from_ct_number(hu):
535
+ if hu <= -950.0:
536
+ return "air-or-gas"
537
+ if hu < -100.0:
538
+ return "low-density-void"
539
+ if hu < 100.0:
540
+ return "polymer-composite"
541
+ if hu < 1000.0:
542
+ return "light-alloy"
543
+ return "high-density-metal"
544
+
545
+
546
+ def porosity_fraction(void_voxels, total_voxels):
547
+ return 100.0 * void_voxels / total_voxels
548
+
549
+
550
+ def void_diameter(void_voxels, voxel_size_m):
551
+ if void_voxels == 0:
552
+ return 0.0
553
+ volume = void_voxels * voxel_size_m ** 3
554
+ return 2.0 * (3.0 * volume / (4.0 * math.pi)) ** (1.0 / 3.0)
555
+
556
+
557
+ def ct_plan(ct):
558
+ """One CT parameter row from a CT facts dict."""
559
+ mag = magnification(ct["sod_m"], ct["odd_m"])
560
+ vox = voxel_size(ct["pixel_pitch_m"], ct["sod_m"], ct["odd_m"])
561
+ nproj = projection_count(ct["columns_span"])
562
+ kv = tube_energy_kv(ct["material"], ct["thickness_mm"])
563
+ return {
564
+ "magnification": mag,
565
+ "voxel_size_m": vox,
566
+ "required_flaw_m": ct["required_flaw_m"],
567
+ "resolution": resolution_check(vox, ct["required_flaw_m"]),
568
+ "projection_count": nproj,
569
+ "tube_energy_kv": kv,
570
+ "scan_time_s": scan_time(nproj, ct["exposure_s_per_proj"]),
571
+ "porosity_percent": porosity_fraction(ct["void_voxels"],
572
+ ct["total_voxels"]),
573
+ "void_diameter_m": void_diameter(ct["void_voxels"], vox),
574
+ "ct_number_hu": ct_number(ct["mu"], ct["mu_water"]),
575
+ "material_class": material_class_from_ct_number(
576
+ ct_number(ct["mu"], ct["mu_water"])),
577
+ "inputs": dict(ct),
578
+ }
579
+
580
+
581
+ # ---------------------------------------------------------------------------
582
+ # Magnetic particle (MT) - mirrored from magnetic-particle-inspection leaf
583
+ # ---------------------------------------------------------------------------
584
+
585
+ FIELD_BAND_FLUORESCENT = (2400.0, 4800.0)
586
+ FIELD_BAND_VISIBLE = (2400.0, 3200.0)
587
+ RESIDUAL_FIELD_LIMIT_AM = 3.0
588
+ LOW_FILL_CONSTANT = 45000.0
589
+ HIGH_FILL_CONSTANT = 35000.0
590
+ LINEAR_RATIO = 3.0
591
+
592
+
593
+ def head_shot_current(diameter_in, amperes_per_inch=800.0):
594
+ return amperes_per_inch * diameter_in
595
+
596
+
597
+ def central_conductor_current(conductor_diameter_in, amperes_per_inch=800.0):
598
+ return amperes_per_inch * conductor_diameter_in
599
+
600
+
601
+ def effective_diameter_hollow(outer_diameter, inner_diameter):
602
+ return math.sqrt(outer_diameter * outer_diameter
603
+ - inner_diameter * inner_diameter)
604
+
605
+
606
+ def effective_ld_ratio(length, effective_diameter):
607
+ return length / effective_diameter
608
+
609
+
610
+ def coil_ampere_turns_low_fill(ld_ratio):
611
+ return LOW_FILL_CONSTANT / ld_ratio
612
+
613
+
614
+ def coil_ampere_turns_high_fill(ld_ratio):
615
+ return HIGH_FILL_CONSTANT / (ld_ratio + 2.0)
616
+
617
+
618
+ def coil_current_from_turns(ampere_turns, turns):
619
+ return ampere_turns / float(turns)
620
+
621
+
622
+ def solenoid_field_strength(ampere_turns, coil_length_m):
623
+ return ampere_turns / coil_length_m
624
+
625
+
626
+ def tangential_field_verdict(field_am, fluorescent=True):
627
+ low, high = FIELD_BAND_FLUORESCENT if fluorescent else FIELD_BAND_VISIBLE
628
+ if field_am < low:
629
+ return "low"
630
+ if field_am > high:
631
+ return "high"
632
+ return "adequate"
633
+
634
+
635
+ def coverage_step(shot_width, overlap_fraction):
636
+ return shot_width * (1.0 - overlap_fraction)
637
+
638
+
639
+ def particle_size_class(median_diameter_um):
640
+ for limit, label in ((10.0, "extra-fine"), (20.0, "fine"),
641
+ (35.0, "medium")):
642
+ if median_diameter_um < limit:
643
+ return label
644
+ return "coarse"
645
+
646
+
647
+ def particle_sensitivity(median_diameter_um):
648
+ for limit, label in ((20.0, "high"), (35.0, "standard")):
649
+ if median_diameter_um < limit:
650
+ return label
651
+ return "low"
652
+
653
+
654
+ def bath_concentration_check(concentration_ml_per_100ml, method="fluorescent"):
655
+ low, high = (BATH_FLUORESCENT if method == "fluorescent"
656
+ else BATH_VISIBLE)
657
+ if concentration_ml_per_100ml < low:
658
+ return "below-range"
659
+ if concentration_ml_per_100ml > high:
660
+ return "above-range"
661
+ return "within-range"
662
+
663
+
664
+ BATH_FLUORESCENT = (0.1, 0.4)
665
+ BATH_VISIBLE = (1.0, 2.0)
666
+
667
+
668
+ def indication_is_linear(length_mm, width_mm):
669
+ return length_mm / width_mm >= LINEAR_RATIO
670
+
671
+
672
+ def magnetization_for_defect(defect_orientation):
673
+ o = defect_orientation.strip().lower()
674
+ if o in ("longitudinal", "axial", "axially-oriented"):
675
+ return "circular"
676
+ if o in ("transverse", "circumferential", "circumferentially-oriented"):
677
+ return "longitudinal"
678
+ raise ValueError("unknown defect orientation %r" % (defect_orientation,))
679
+
680
+
681
+ def acceptance_verdict(relevant, indication_length_mm, max_allowed_mm):
682
+ if not relevant:
683
+ return "evaluate"
684
+ if indication_length_mm > max_allowed_mm:
685
+ return "reject"
686
+ return "accept"
687
+
688
+
689
+ def residual_field_verdict(residual_am, limit_am=RESIDUAL_FIELD_LIMIT_AM):
690
+ if residual_am > limit_am:
691
+ return "demagnetize"
692
+ return "acceptable"
693
+
694
+
695
+ def mt_plan(mt):
696
+ """One magnetic-particle parameter row (ferromagnetic parts)."""
697
+ if mt["magnetization"] == "circular":
698
+ current_a = head_shot_current(mt["diameter_in"])
699
+ field_am = None
700
+ turns_ni = None
701
+ coil_current = None
702
+ else:
703
+ deff = effective_diameter_hollow(mt["outer_diameter_in"],
704
+ mt["inner_diameter_in"])
705
+ ld = effective_ld_ratio(mt["length_in"], deff)
706
+ ni = coil_ampere_turns_low_fill(ld)
707
+ current_a = None
708
+ field_am = solenoid_field_strength(ni, mt["coil_length_m"])
709
+ turns_ni = ni
710
+ coil_current = coil_current_from_turns(ni, mt["turns"])
711
+ return {
712
+ "defect_orientation": mt["defect_orientation"],
713
+ "magnetization": magnetization_for_defect(mt["defect_orientation"]),
714
+ "head_shot_current_a": current_a,
715
+ "coil_ampere_turns": turns_ni,
716
+ "coil_current_a": coil_current,
717
+ "tangential_field_am": field_am,
718
+ "tangential_verdict": (tangential_field_verdict(field_am)
719
+ if field_am is not None else None),
720
+ "particle_size_class": particle_size_class(mt["median_particle_um"]),
721
+ "particle_sensitivity": particle_sensitivity(mt["median_particle_um"]),
722
+ "bath_check": bath_concentration_check(mt["bath_concentration"],
723
+ mt["bath_method"]),
724
+ "coverage_step_m": coverage_step(mt["shot_width_m"],
725
+ mt["overlap_fraction"]),
726
+ "indication_linear": indication_is_linear(mt["indication_length_mm"],
727
+ mt["indication_width_mm"]),
728
+ "indication_verdict": acceptance_verdict(mt["indication_relevant"],
729
+ mt["indication_length_mm"],
730
+ mt["max_allowed_mm"]),
731
+ "residual_field_verdict": residual_field_verdict(mt["residual_am"]),
732
+ "inputs": dict(mt),
733
+ }
734
+
735
+
736
+ # ---------------------------------------------------------------------------
737
+ # Leak testing (LT) - mirrored from leak-testing leaf logic
738
+ # ---------------------------------------------------------------------------
739
+
740
+ BAR_TO_ATM = 0.986923
741
+ STD_TEMP_K = 293.15
742
+ MS_THRESHOLD = 1e-6
743
+ SNIFFER_THRESHOLD = 1e-5
744
+ BUBBLE_THRESHOLD = 1e-2
745
+ REVIEW_RATIO = 1.25
746
+
747
+ VALID_METHODS = ("pressure-decay", "vacuum-decay", "bubble",
748
+ "helium-sniffer", "helium-mass-spectrometer-hood")
749
+
750
+
751
+ def pressure_decay_rate(volume_L, dP_bar, time_s, temp_K=STD_TEMP_K):
752
+ return (volume_L * 1000.0) * (dP_bar * BAR_TO_ATM) / time_s \
753
+ * (STD_TEMP_K / temp_K)
754
+
755
+
756
+ def gauge_resolution_time(volume_L, gauge_res_bar, target_sccs,
757
+ temp_K=STD_TEMP_K):
758
+ volume_cc = volume_L * 1000.0
759
+ dP_atm = gauge_res_bar * BAR_TO_ATM
760
+ return volume_cc * dP_atm / target_sccs * (STD_TEMP_K / temp_K)
761
+
762
+
763
+ def method_recommendation(required_sensitivity_sccs, access_both_sides,
764
+ need_localization, part_pressure_capable):
765
+ if required_sensitivity_sccs <= MS_THRESHOLD:
766
+ return ("helium-mass-spectrometer-hood",
767
+ "required sensitivity %.1e scc/s is at or below the 1e-6 "
768
+ "scc/s hood threshold" % required_sensitivity_sccs)
769
+ if need_localization and required_sensitivity_sccs <= SNIFFER_THRESHOLD:
770
+ return ("helium-sniffer",
771
+ "localization needed and required sensitivity %.1e scc/s "
772
+ "is at or below the 1e-5 scc/s sniffer threshold"
773
+ % required_sensitivity_sccs)
774
+ if not access_both_sides:
775
+ if part_pressure_capable:
776
+ return ("pressure-decay",
777
+ "only one side accessible and the part holds pressure, "
778
+ "so a decay test on the sealed internal volume fits")
779
+ return ("vacuum-decay",
780
+ "only one side accessible and the part cannot take internal "
781
+ "pressure, so an evacuated-chamber decay test fits")
782
+ if need_localization and required_sensitivity_sccs <= BUBBLE_THRESHOLD:
783
+ return ("bubble",
784
+ "localization needed and required sensitivity %.1e scc/s "
785
+ "is at or below the 1e-2 scc/s bubble immersion threshold"
786
+ % required_sensitivity_sccs)
787
+ return ("pressure-decay",
788
+ "no tighter constraint applies, so pressurize the sealed "
789
+ "volume and watch the pressure decay")
790
+
791
+
792
+ def disposition(measured_sccs, max_allowable_sccs, method):
793
+ margin_db = (10.0 * math.log10(max_allowable_sccs / measured_sccs)
794
+ if measured_sccs > 0 else float("inf"))
795
+ if measured_sccs <= max_allowable_sccs:
796
+ verdict = "accept"
797
+ elif measured_sccs > REVIEW_RATIO * max_allowable_sccs:
798
+ verdict = "reject"
799
+ else:
800
+ verdict = "review"
801
+ return {"verdict": verdict, "margin_db": margin_db}
802
+
803
+
804
+ def leak_plan(lt):
805
+ """One leak-test parameter row from a leak facts dict."""
806
+ rec_method, rec_reason = method_recommendation(
807
+ lt["required_sensitivity_sccs"], lt["access_both_sides"],
808
+ lt["need_localization"], lt["part_pressure_capable"])
809
+ rate = pressure_decay_rate(lt["volume_L"], lt["dP_bar"], lt["time_s"])
810
+ outcome = disposition(rate, lt["max_allowable_sccs"], "pressure-decay")
811
+ gres = gauge_resolution_time(lt["volume_L"], lt["gauge_res_bar"],
812
+ lt["max_allowable_sccs"])
813
+ return {
814
+ "method_recommendation": rec_method,
815
+ "recommendation_reason": rec_reason,
816
+ "volume_L": lt["volume_L"],
817
+ "dP_bar": lt["dP_bar"],
818
+ "time_s": lt["time_s"],
819
+ "leak_rate_sccs": rate,
820
+ "max_allowable_sccs": lt["max_allowable_sccs"],
821
+ "verdict": outcome["verdict"],
822
+ "margin_db": outcome["margin_db"],
823
+ "gauge_res_bar": lt["gauge_res_bar"],
824
+ "gauge_resolution_time_s": gres,
825
+ "gauge_adequate": lt["time_s"] >= gres,
826
+ "inputs": dict(lt),
827
+ }
828
+
829
+
830
+ # ---------------------------------------------------------------------------
831
+ # Acoustic emission (AE) - mirrored from acoustic-emission-inspection leaf
832
+ # ---------------------------------------------------------------------------
833
+
834
+ def hit_threshold_check(amplitudes_db, threshold_db):
835
+ hits = [a for a in amplitudes_db if a >= threshold_db]
836
+ return {"threshold_db": threshold_db, "hits": hits,
837
+ "hit_count": len(hits), "total_signals": len(amplitudes_db)}
838
+
839
+
840
+ def group_hits_to_events(arrival_times, hdt):
841
+ if not arrival_times:
842
+ return []
843
+ ordered = sorted(arrival_times)
844
+ events = [[ordered[0]]]
845
+ for t in ordered[1:]:
846
+ if t - events[-1][-1] > hdt:
847
+ events.append([t])
848
+ else:
849
+ events[-1].append(t)
850
+ return events
851
+
852
+
853
+ def source_location_linear(sensor_distance, arrival_times, wave_speed):
854
+ t1, t2 = arrival_times
855
+ return (sensor_distance + wave_speed * (t1 - t2)) / 2.0
856
+
857
+
858
+ def felicity_ratio(resume_load, previous_max_load):
859
+ return resume_load / previous_max_load
860
+
861
+
862
+ def kaiser_effect_check(resume_load, previous_max_load,
863
+ felicity_threshold=0.95):
864
+ ratio = felicity_ratio(resume_load, previous_max_load)
865
+ return {"felicity_ratio": ratio,
866
+ "kaiser_effect_holds": ratio >= 1.0,
867
+ "felicity_effect": ratio < 1.0,
868
+ "damage_indicated": ratio < felicity_threshold,
869
+ "felicity_threshold": felicity_threshold}
870
+
871
+
872
+ # ---------------------------------------------------------------------------
873
+ # Shearography - mirrored from shearography-inspection leaf logic
874
+ # ---------------------------------------------------------------------------
875
+
876
+ LASER_WAVELENGTH_NM = 532.0
877
+ NOISE_FLOOR_PHASE_RAD = 0.1
878
+ MIN_SNR = 3.0
879
+ SHEAR_DIVISOR = 2.0
880
+ REVIEW_BAND = 0.2
881
+ TYPICAL_LOAD_STEPS = {"vacuum": {2.0: 20.0, 6.0: 40.0, 12.0: 60.0},
882
+ "thermal": 5.0, "vibration": 30.0}
883
+
884
+
885
+ def strain_from_phase(phase_rad, shear_mm, wavelength_nm=LASER_WAVELENGTH_NM):
886
+ return (phase_rad * wavelength_nm * 1e-9
887
+ / (4.0 * math.pi * shear_mm * 1e-3))
888
+
889
+
890
+ def min_detectable_strain(noise_floor_rad, shear_mm,
891
+ wavelength_nm=LASER_WAVELENGTH_NM):
892
+ single = (noise_floor_rad * wavelength_nm * 1e-9
893
+ / (4.0 * math.pi * shear_mm * 1e-3))
894
+ return MIN_SNR * single
895
+
896
+
897
+ def shear_for_defect(defect_size_mm):
898
+ return defect_size_mm / SHEAR_DIVISOR
899
+
900
+
901
+ def select_load(part_thickness_mm, load_type):
902
+ entry = TYPICAL_LOAD_STEPS[load_type]
903
+ if not isinstance(entry, dict):
904
+ return float(entry)
905
+ thicknesses = sorted(entry)
906
+ for lo_t, hi_t in zip(thicknesses, thicknesses[1:]):
907
+ if lo_t <= part_thickness_mm <= hi_t:
908
+ slope = (entry[hi_t] - entry[lo_t]) / (hi_t - lo_t)
909
+ return entry[lo_t] + slope * (part_thickness_mm - lo_t)
910
+ if part_thickness_mm <= thicknesses[0]:
911
+ lo_t, hi_t = thicknesses[0], thicknesses[1]
912
+ slope = (entry[hi_t] - entry[lo_t]) / (hi_t - lo_t)
913
+ return entry[lo_t] + slope * (part_thickness_mm - lo_t)
914
+ lo_t, hi_t = thicknesses[-2], thicknesses[-1]
915
+ slope = (entry[hi_t] - entry[lo_t]) / (hi_t - lo_t)
916
+ return entry[hi_t] + slope * (part_thickness_mm - hi_t)
917
+
918
+
919
+ def scan_plan(part_area_m2, fov_area_m2, overlap):
920
+ passes = int(math.ceil(part_area_m2 / (fov_area_m2 * (1.0 - overlap))))
921
+ return {"passes": passes,
922
+ "overlap_area": passes * fov_area_m2 * overlap}
923
+
924
+
925
+ def anomaly_disposition(anomaly_size_mm, allow_size_mm, snr):
926
+ reasons = []
927
+ band_limit = allow_size_mm * (1.0 + REVIEW_BAND)
928
+ if anomaly_size_mm >= band_limit:
929
+ return {"verdict": "reject", "reasons": [
930
+ "anomaly %g mm is at or above %g mm, the allowable plus the "
931
+ "%g review band" % (anomaly_size_mm, band_limit, REVIEW_BAND)]}
932
+ if anomaly_size_mm > allow_size_mm:
933
+ reasons.append("anomaly %g mm exceeds the allowable %g mm but lies "
934
+ "within the %g review band"
935
+ % (anomaly_size_mm, allow_size_mm, REVIEW_BAND))
936
+ if snr < MIN_SNR:
937
+ reasons.append("snr %g is below MIN_SNR %g" % (snr, MIN_SNR))
938
+ return {"verdict": "review", "reasons": reasons}
939
+ if snr < MIN_SNR:
940
+ reasons.append("anomaly %g mm is within the allowable %g mm but "
941
+ "snr %g is below MIN_SNR %g"
942
+ % (anomaly_size_mm, allow_size_mm, snr, MIN_SNR))
943
+ return {"verdict": "review", "reasons": reasons}
944
+ reasons.append("anomaly %g mm is within the allowable %g mm and snr "
945
+ "%g meets MIN_SNR %g"
946
+ % (anomaly_size_mm, allow_size_mm, snr, MIN_SNR))
947
+ return {"verdict": "accept", "reasons": reasons}
948
+
949
+
950
+ # ---------------------------------------------------------------------------
951
+ # Item facts + builder
952
+ # ---------------------------------------------------------------------------
953
+
954
+ @dataclass
955
+ class DefectCandidate:
956
+ defect_name: str
957
+ defect_class: str # surface | near-surface | internal
958
+ zone: str = ""
959
+ geometry_descriptor: str = "" # RT discontinuity vocabulary
960
+ description: str = ""
961
+
962
+
963
+ @dataclass
964
+ class InspectionItem:
965
+ """Project facts the role needs to build the NDT plan."""
966
+ item_name: str
967
+ item_number: str = ""
968
+ description: str = ""
969
+ material: str = ""
970
+ material_class: str = "non-ferromagnetic"
971
+ percent_iacs: float = 30.0
972
+ manufacture_route: str = ""
973
+ drawing_ref: str = ""
974
+ candidate_defects: list = field(default_factory=list)
975
+ # method fact groups (SI where noted)
976
+ penetrant: dict = field(default_factory=dict)
977
+ pt_surface_area_m2: float = 0.5
978
+ eddy_surface: dict = field(default_factory=dict)
979
+ eddy_subsurface: dict = field(default_factory=dict)
980
+ radiography: dict = field(default_factory=dict)
981
+ tomography: dict = field(default_factory=dict)
982
+ leak: dict = field(default_factory=dict)
983
+ magnetic_particle: dict = field(default_factory=dict)
984
+ personnel_record: dict = field(default_factory=dict)
985
+ certification_basis: str = "AS9100D special-process control (internal)"
986
+
987
+ def _today(self):
988
+ return _today()
989
+
990
+
991
+ def build_report(item: InspectionItem) -> dict:
992
+ """Build the complete NDT plan content model from project facts."""
993
+ mat = item.material_class
994
+
995
+ population = []
996
+ for d in item.candidate_defects:
997
+ pick = select_method(d.defect_class, mat)
998
+ population.append({
999
+ "defect_name": d.defect_name,
1000
+ "zone": d.zone,
1001
+ "defect_class": d.defect_class,
1002
+ "material_class": mat,
1003
+ "geometry_descriptor": d.geometry_descriptor,
1004
+ "description": d.description,
1005
+ "applicable": applicable_methods(d.defect_class, mat),
1006
+ "top_method": pick["method"],
1007
+ "alternates": pick["alternates"],
1008
+ "rationale": pick["rationale"],
1009
+ "sensitivity": {m: SENSITIVITY_RANK[m]
1010
+ for m in applicable_methods(d.defect_class, mat)},
1011
+ })
1012
+
1013
+ cards = {}
1014
+ if item.eddy_surface:
1015
+ cards["eddy-current-surface"] = et_plan(**item.eddy_surface)
1016
+ if item.eddy_subsurface:
1017
+ cards["eddy-current-subsurface"] = et_plan(**item.eddy_subsurface)
1018
+ if item.penetrant:
1019
+ cards["liquid-penetrant"] = pt_plan(item.penetrant,
1020
+ item.pt_surface_area_m2)
1021
+ if item.radiography:
1022
+ cards["radiography"] = rt_plan(item.radiography)
1023
+ if item.tomography:
1024
+ cards["computed-tomography"] = ct_plan(item.tomography)
1025
+ if item.magnetic_particle:
1026
+ cards["magnetic-particle"] = mt_plan(item.magnetic_particle)
1027
+ if item.leak:
1028
+ cards["leak-test"] = leak_plan(item.leak)
1029
+
1030
+ # personnel: record dates are held relative to today so the plan never
1031
+ # goes stale; due dates computed by the leaf's month-add/clamp rules.
1032
+ today = date.fromisoformat(_today())
1033
+ record = dict(item.personnel_record)
1034
+ record.setdefault("cert_date_iso",
1035
+ add_months_clamped(today.isoformat(), -24))
1036
+ record.setdefault("last_vision_iso",
1037
+ add_months_clamped(today.isoformat(), -6))
1038
+ review = qualification_review(record, today.isoformat())
1039
+ methods_in_plan = _methods_in_plan(population, cards)
1040
+ personnel = {
1041
+ "record": record,
1042
+ "review": review,
1043
+ "review_date": today.isoformat(),
1044
+ "method_levels": [
1045
+ {"method": m,
1046
+ "interpretation_level": INTERPRET_LEVEL[m],
1047
+ "performance_level": PERFORM_LEVEL[m],
1048
+ "operator_meets": level_meets(record.get("operator_level", "ii"),
1049
+ INTERPRET_LEVEL[m])}
1050
+ for m in methods_in_plan],
1051
+ }
1052
+
1053
+ return {
1054
+ "document_type": "Nondestructive Test Plan and Method Selection Report",
1055
+ "status": "draft-for-review",
1056
+ "item": item.item_name,
1057
+ "item_number": item.item_number,
1058
+ "item_description": item.description,
1059
+ "material": item.material,
1060
+ "material_class": mat,
1061
+ "percent_iacs": item.percent_iacs,
1062
+ "manufacture_route": item.manufacture_route,
1063
+ "drawing_ref": item.drawing_ref,
1064
+ "certification_basis": item.certification_basis,
1065
+ "defect_population": population,
1066
+ "parameter_cards": cards,
1067
+ "personnel": personnel,
1068
+ "acceptance_framing": acceptance_framing(cards, mat),
1069
+ "generated": today.isoformat(),
1070
+ }
1071
+
1072
+
1073
+ def _method_present(method, cards):
1074
+ keys = {"ET": "eddy-current", "PT": "liquid-penetrant",
1075
+ "RT": "radiography", "CT": "computed-tomography",
1076
+ "MT": "magnetic-particle", "LT": "leak-test"}
1077
+ return any(k.startswith(keys[method]) for k in cards)
1078
+
1079
+
1080
+ def _methods_in_plan(population, cards):
1081
+ """Methods this plan actually uses: the selected/alternate methods of
1082
+ the defect population plus any method that has a parameter card.
1083
+ Canonical order MT, PT, ET, UT, RT, CT, LT."""
1084
+ methods = set()
1085
+ for row in population:
1086
+ methods.add(row["top_method"])
1087
+ methods.update(row["alternates"])
1088
+ for k in cards:
1089
+ for m, prefix in (("ET", "eddy-current"), ("PT", "liquid-penetrant"),
1090
+ ("RT", "radiography"), ("CT", "computed-tomography"),
1091
+ ("MT", "magnetic-particle"), ("LT", "leak-test")):
1092
+ if k.startswith(prefix):
1093
+ methods.add(m)
1094
+ order = ("MT", "PT", "ET", "UT", "RT", "CT", "LT")
1095
+ return [m for m in order if m in methods]
1096
+
1097
+
1098
+ def acceptance_framing(cards, material_class):
1099
+ """Disposition rules per method family - framing input only, the
1100
+ customer acceptance document / engineering authority disposes."""
1101
+ rows = []
1102
+ if "magnetic-particle" in cards:
1103
+ rows.append({
1104
+ "method": "MT",
1105
+ "rule": ("relevant indications are accepted when their length "
1106
+ "does not exceed the acceptance limit and rejected "
1107
+ "when it does; non-relevant indications (threads, "
1108
+ "geometry, magnetic writing) are recorded and "
1109
+ "evaluated, never auto-disposed; linear "
1110
+ "(length/width >= 3) indications are crack-like and "
1111
+ "evaluated against the linear limit")})
1112
+ if any(k.startswith("eddy-current") for k in cards):
1113
+ rows.append({
1114
+ "method": "ET",
1115
+ "rule": ("signal amplitude and phase vs the calibrated "
1116
+ "reference standard gate sizing; indications are "
1117
+ "evaluated against the acceptance document for the "
1118
+ "zone (crack-like vs volumetric responses)")})
1119
+ if "liquid-penetrant" in cards:
1120
+ rows.append({
1121
+ "method": "PT",
1122
+ "rule": ("rounded indications (pores) and linear indications "
1123
+ "(cracks, laps) are sized from the bleed-out image "
1124
+ "and evaluated against their class limits; excessive "
1125
+ "background is a process failure, not an indication")})
1126
+ if "radiography" in cards:
1127
+ rows.append({
1128
+ "method": "RT",
1129
+ "rule": ("the discontinuity class (porosity / crack / "
1130
+ "inclusion / slag) gates the applicable acceptance "
1131
+ "criteria; the technique verdict (unsharpness, IQI "
1132
+ "sensitivity, film density) must be acceptable "
1133
+ "before any disposition is made")})
1134
+ if "computed-tomography" in cards:
1135
+ rows.append({
1136
+ "method": "CT",
1137
+ "rule": ("segmented void fraction and equivalent void "
1138
+ "diameter are disposition inputs against the casting "
1139
+ "acceptance standard; resolution must first pass the "
1140
+ "required-flaw check")})
1141
+ if "leak-test" in cards:
1142
+ rows.append({
1143
+ "method": "LT",
1144
+ "rule": ("measured leak rate is accepted when at or below the "
1145
+ "maximum allowable, rejected when above 1.25x the "
1146
+ "allowable, reviewed in between (margin band)")})
1147
+ if material_class == "ferromagnetic":
1148
+ rows.append({
1149
+ "method": "MT",
1150
+ "rule": ("residual field must be below the demagnetization "
1151
+ "limit before the part returns to service")})
1152
+ return {"framing_note":
1153
+ "All dispositions in this plan are INPUTS for the responsible "
1154
+ "engineering authority; the customer acceptance document and "
1155
+ "the qualified procedure govern.",
1156
+ "method_rules": rows}
1157
+
1158
+
1159
+ def _fmt(x, fmt="%.4g"):
1160
+ if x is None:
1161
+ return "n/a"
1162
+ return fmt % x
1163
+
1164
+
1165
+ def _fmt_sci(x, fmt="%.3e"):
1166
+ if x is None:
1167
+ return "n/a"
1168
+ return fmt % x
1169
+
1170
+
1171
+ def render_report_markdown(model: dict) -> str:
1172
+ """Render the content model as the deliverable markdown document."""
1173
+ L = []
1174
+ ap = L.append
1175
+ ap("# Nondestructive Test Plan and Method Selection Report")
1176
+ ap("")
1177
+ ap("**Item:** %s%s" % (model["item"],
1178
+ (" (%s)" % model["item_number"])
1179
+ if model["item_number"] else ""))
1180
+ ap("**Material:** %s (%s, %g pct IACS) **Route:** %s"
1181
+ % (model["material"], model["material_class"], model["percent_iacs"],
1182
+ model["manufacture_route"]))
1183
+ if model["drawing_ref"]:
1184
+ ap("**Drawing:** %s" % model["drawing_ref"])
1185
+ ap("**Certification basis:** %s" % model["certification_basis"])
1186
+ ap("**Status:** %s" % model["status"])
1187
+ ap("")
1188
+ if model["item_description"]:
1189
+ ap("Scope: %s" % model["item_description"])
1190
+ ap("")
1191
+
1192
+ ap("## 1. Candidate defect population and method selection")
1193
+ ap("")
1194
+ ap("Method selection follows the bound ndt-method-selection decision "
1195
+ "logic: defect class (surface / near-surface / internal) x material "
1196
+ "class (%s) defines the applicable set, and sensitivity rank "
1197
+ "(UT 5, RT/ET 4, MT/PT 3) picks the method (cost is reporting "
1198
+ "only)." % model["material_class"])
1199
+ ap("")
1200
+ for row in model["defect_population"]:
1201
+ ap("### 1.%d %s" % (model["defect_population"].index(row) + 1,
1202
+ row["defect_name"]))
1203
+ ap("")
1204
+ if row["description"]:
1205
+ ap("- Zone / note: %s" % row["description"])
1206
+ ap("- Defect class: %s · material class: %s"
1207
+ % (row["defect_class"], row["material_class"]))
1208
+ ap("- Applicable methods: %s"
1209
+ % ", ".join("%s (sensitivity %d)"
1210
+ % (METHOD_NAMES[m], row["sensitivity"][m])
1211
+ for m in row["applicable"]))
1212
+ ap("- **Selected method: %s** — %s."
1213
+ % (METHOD_NAMES[row["top_method"]], row["rationale"]))
1214
+ ap("- Alternates: %s"
1215
+ % ", ".join(METHOD_NAMES[m] for m in row["alternates"])
1216
+ if row["alternates"] else "- Alternates: none")
1217
+ ap("")
1218
+
1219
+ ap("## 2. Inspection parameters per selected method")
1220
+ ap("")
1221
+ cards = model["parameter_cards"]
1222
+ ap("_Parameter rows below are computed by the role core mirroring the "
1223
+ "bound method leaf logic. The qualified procedure and the "
1224
+ "engineering specification govern the actual inspection settings._")
1225
+ ap("")
1226
+ ap("### 2.1 Eddy current (ET) — surface zone")
1227
+ if "eddy-current-surface" in cards:
1228
+ c = cards["eddy-current-surface"]
1229
+ ap("- Flaw depth: %.3g m; conductivity %g pct IACS (%.3g S/m)"
1230
+ % (c["flaw_depth_m"], c["percent_iacs"], c["conductivity_sm"]))
1231
+ ap("- Test frequency (penetration factor %.2g): **%.0f Hz**; "
1232
+ "standard depth of penetration **%.4g m**"
1233
+ % (c["penetration_factor"], c["frequency_hz"], c["delta_m"]))
1234
+ ap("- Current density at the flaw depth: %.3f of surface; "
1235
+ "phase lag %.2f deg"
1236
+ % (c["density_ratio_at_flaw"], c["phase_lag_deg"]))
1237
+ else:
1238
+ ap("- Not applicable to this part (no surface-conductive zone).")
1239
+ ap("")
1240
+ ap("### 2.2 Eddy current (ET) — near-surface zone")
1241
+ if "eddy-current-subsurface" in cards:
1242
+ c = cards["eddy-current-subsurface"]
1243
+ ap("- Subsurface flaw depth %.3g m: frequency **%.0f Hz**, "
1244
+ "standard depth of penetration %.4g m keeps the flaw at half a "
1245
+ "delta (density ratio %.3f, phase lag %.2f deg)"
1246
+ % (c["flaw_depth_m"], c["frequency_hz"], c["delta_m"],
1247
+ c["density_ratio_at_flaw"], c["phase_lag_deg"]))
1248
+ else:
1249
+ ap("- Not applicable.")
1250
+ ap("")
1251
+ ap("### 2.3 Liquid penetrant (PT) — surface-breaking crack zone")
1252
+ if "liquid-penetrant" in cards:
1253
+ c = cards["liquid-penetrant"]
1254
+ ap("- Penetrant: gamma %.3g N/m, contact angle %g deg, viscosity "
1255
+ "%.4g Pa.s; crack opening %.3g m -> effective capillary radius "
1256
+ "%.3g m" % (c["surface_tension_nm"], c["contact_angle_deg"],
1257
+ c["viscosity_pas"], c["crack_opening_width_m"],
1258
+ c["effective_capillary_radius_m"]))
1259
+ ap("- Capillary pressure across the meniscus: **%.3g Pa**"
1260
+ % c["capillary_pressure_pa"])
1261
+ ap("- Washburn model: penetration depth %.4g m at the %g s "
1262
+ "reference dwell; computed time to fill the %.3g m-deep "
1263
+ "reference crack %.4g s (dwell scales with depth squared and "
1264
+ "inversely with crack radius)" % (c["washburn_depth_at_reference_s_m"],
1265
+ c["reference_dwell_s"],
1266
+ c["crack_depth_m"],
1267
+ c["dwell_time_to_fill_s"]))
1268
+ ap("- Indication sizing: bleed-out width %.3g m for the %.3g m "
1269
+ "opening (ratio %.1f); developer %.3g kg at %.3g kg/m2 over "
1270
+ "%.2f m2; fluorescent contrast vs background %.3f"
1271
+ % (c["indication_width_m"], c["crack_opening_width_m"],
1272
+ c["bleed_out_ratio"], c["developer_mass_kg"],
1273
+ c["developer_areal_density_kgm2"], c["part_area_m2"],
1274
+ c["contrast_ratio"]))
1275
+ ap("- Dwell: procedure-qualified penetrant dwell governs; the "
1276
+ "model numbers above are sizing inputs, not settings.")
1277
+ else:
1278
+ ap("- Not applicable (no non-porous surface-breaking crack zone).")
1279
+ ap("")
1280
+ ap("### 2.4 Radiography (RT) — internal volumetric zone")
1281
+ if "radiography" in cards:
1282
+ c = cards["radiography"]
1283
+ ap("- Setup: focal spot %.1f mm, SOD %.0f mm, ODD %.0f mm -> "
1284
+ "geometric unsharpness **%.2f mm** (limit %.2f mm)"
1285
+ % (c["focal_spot_mm"], c["sod_mm"], c["odd_mm"],
1286
+ c["geometric_unsharpness_mm"], c["unsharpness_limit_mm"]))
1287
+ ap("- Exposure (inverse-square): **%.3g min** at the working "
1288
+ "distance" % c["exposure_min"])
1289
+ ap("- IQI sensitivity: %.2f pct (%.1f mm visible on %.0f mm "
1290
+ "section; limit %.1f pct)"
1291
+ % (c["iqi_sensitivity_percent"], c["iq_visible_mm"],
1292
+ c["section_thickness_mm"], c["sensitivity_limit_percent"]))
1293
+ ap("- Film density %.2f in band %.1f-%.1f (%s); technique verdict: "
1294
+ "**%s**"
1295
+ % (c["film_density"], c["density_band"][0], c["density_band"][1],
1296
+ c["density_verdict"],
1297
+ "acceptable" if c["setup_acceptable"] else
1298
+ "NOT acceptable: " + "; ".join(c["setup_reasons"])))
1299
+ ap("- Expected discontinuity class from image geometry: %s "
1300
+ "(acceptance criteria per class)"
1301
+ % c["discontinuity_class"])
1302
+ else:
1303
+ ap("- Not applicable (no internal volumetric zone).")
1304
+ ap("")
1305
+ ap("### 2.5 Computed tomography (CT) — supplemental volumetric sizing")
1306
+ if "computed-tomography" in cards:
1307
+ c = cards["computed-tomography"]
1308
+ ap("- Magnification %.2f, voxel size %.3g m; required flaw %.3g m"
1309
+ % (c["magnification"], c["voxel_size_m"], c["required_flaw_m"]))
1310
+ ap("- Resolution: %s" % c["resolution"])
1311
+ ap("- Scan plan: %d projections at %g kV (%.0f s), material %s "
1312
+ "thickness %.0f mm" % (c["projection_count"],
1313
+ c["tube_energy_kv"], c["scan_time_s"],
1314
+ c["inputs"]["material"],
1315
+ c["inputs"]["thickness_mm"]))
1316
+ ap("- Porosity ROI: %.3g pct void fraction, equivalent spherical "
1317
+ "void diameter %.3g m; CT number %+.0f HU -> %s"
1318
+ % (c["porosity_percent"], c["void_diameter_m"],
1319
+ c["ct_number_hu"], c["material_class"]))
1320
+ else:
1321
+ ap("- Not applicable.")
1322
+ ap("")
1323
+ ap("### 2.6 Magnetic particle (MT) — ferromagnetic parts")
1324
+ if "magnetic-particle" in cards:
1325
+ c = cards["magnetic-particle"]
1326
+ ap("- %s defect -> %s magnetization"
1327
+ % (c["defect_orientation"], c["magnetization"]))
1328
+ ap("- Magnetizing current: %s"
1329
+ % ("head shot %.0f A" % c["head_shot_current_a"]
1330
+ if c["head_shot_current_a"] is not None
1331
+ else "coil %.0f A (%.0f A-turns)" % (c["coil_current_a"],
1332
+ c["coil_ampere_turns"])))
1333
+ if c["tangential_field_am"] is not None:
1334
+ ap("- Tangential field %.0f A/m: %s (fluorescent band 2400-4800 "
1335
+ "A/m)" % (c["tangential_field_am"], c["tangential_verdict"]))
1336
+ ap("- Medium: %s particles (%s sensitivity); bath %s"
1337
+ % (c["particle_size_class"], c["particle_sensitivity"],
1338
+ c["bath_check"]))
1339
+ ap("- Coverage step %.2f m at %.0f pct overlap; indication "
1340
+ "length/width ratio %.1f -> %s; indication verdict: %s; "
1341
+ "residual field: %s"
1342
+ % (c["coverage_step_m"], c["inputs"]["overlap_fraction"] * 100,
1343
+ c["inputs"]["indication_length_mm"]
1344
+ / c["inputs"]["indication_width_mm"],
1345
+ "linear (crack-like)" if c["indication_linear"] else "rounded",
1346
+ c["indication_verdict"], c["residual_field_verdict"]))
1347
+ else:
1348
+ ap("- Not applicable to this non-ferromagnetic part; retained in "
1349
+ "the method library for ferromagnetic parts (forgings, steel "
1350
+ "castings).")
1351
+ ap("")
1352
+ ap("### 2.7 Leak test (LT) — sealed assembly")
1353
+ if "leak-test" in cards:
1354
+ c = cards["leak-test"]
1355
+ ap("- Method recommendation: **%s** — %s"
1356
+ % (c["method_recommendation"], c["recommendation_reason"]))
1357
+ ap("- Pressure decay: %.3g L at dP %.3g bar over %.0f s -> leak "
1358
+ "rate **%.4g scc/s**; allowable %.3g scc/s -> disposition "
1359
+ "**%s** (margin %.2f dB)"
1360
+ % (c["volume_L"], c["dP_bar"], c["time_s"], c["leak_rate_sccs"],
1361
+ c["max_allowable_sccs"], c["verdict"], c["margin_db"]))
1362
+ ap("- Gauge adequacy: %g bar resolution needs >= %.1f s to catch "
1363
+ "the allowable leak; test time %.0f s is %s"
1364
+ % (c["gauge_res_bar"], c["gauge_resolution_time_s"],
1365
+ c["time_s"], "adequate" if c["gauge_adequate"]
1366
+ else "NOT adequate"))
1367
+ else:
1368
+ ap("- Not applicable (no sealed-volume requirement).")
1369
+ ap("")
1370
+
1371
+ ap("## 3. NDT personnel qualification")
1372
+ ap("")
1373
+ p = model["personnel"]
1374
+ rec = p["record"]
1375
+ rv = p["review"]
1376
+ ap("- Operator level: Level %s (supervisor Level %s)"
1377
+ % (rec["operator_level"].upper(), rec["supervisor_level"].upper()))
1378
+ ap("- Certification status as of %s: **%s** (recert due %s; near-vision "
1379
+ "due %s)"
1380
+ % (p["review_date"], rv["certification_status"],
1381
+ rv["recert_due_date_iso"], rv["vision_due_date_iso"]))
1382
+ ap("- Supervision pairing: %s"
1383
+ % ("valid (Level I operators work under Level II/III)"
1384
+ if rv["supervision_ok"] else "INVALID"))
1385
+ if rv["upgrade_eligible"] is not None:
1386
+ ap("- Upgrade evaluation (toward Level III): %s"
1387
+ % ("eligible when hours, months and examination are met"
1388
+ if rv["upgrade_eligible"] else "not yet eligible"))
1389
+ ap("- Per-method certification check (interpretation of results "
1390
+ "requires the level shown):")
1391
+ for row in p["method_levels"]:
1392
+ ap(" - %s: Level %s to interpret, Level %s to perform — operator "
1393
+ "%s" % (row["method"], row["interpretation_level"].upper(),
1394
+ row["performance_level"].upper(),
1395
+ "meets" if row["operator_meets"] else "DOES NOT meet"))
1396
+ ap("")
1397
+
1398
+ af = model["acceptance_framing"]
1399
+ ap("## 4. Acceptance and disposition framing")
1400
+ ap("")
1401
+ ap(af["framing_note"])
1402
+ ap("")
1403
+ for rule in af["method_rules"]:
1404
+ ap("- **%s:** %s" % (rule["method"], rule["rule"]))
1405
+ ap("")
1406
+
1407
+ ap("## 5. Open items for the responsible authority")
1408
+ ap("")
1409
+ ap("- Confirm the acceptance limits and the qualified procedure "
1410
+ "revisions named in the drawing and the program plan.")
1411
+ ap("- Confirm operator certification records and intervals match the "
1412
+ "employer's written practice.")
1413
+ ap("")
1414
+ ap("---")
1415
+ ap("*Generated by Aero Agent Roles ndt-engineer core (%s). DRAFT for "
1416
+ "human NDT engineering review. Not an approval document; carries "
1417
+ "no acceptance or certification authority.*" % model["generated"])
1418
+ return "\n".join(L)
1419
+
1420
+
1421
+ # ---------------------------------------------------------------------------
1422
+ # Evidence gate checkers
1423
+ # ---------------------------------------------------------------------------
1424
+
1425
+ GATE_DETAILS = {
1426
+ "method_selection_complete": "every candidate defect has a selected "
1427
+ "method + alternates + rationale",
1428
+ "parameters_computed": "each method card carries computed parameters",
1429
+ "personnel_qualified": "operator record current, supervision valid, "
1430
+ "level requirements met",
1431
+ "acceptance_framed": "disposition rules present for every method",
1432
+ "sign_off_honest": "document marked draft-for-review, not approval",
1433
+ }
1434
+
1435
+
1436
+ def check_report(model: dict) -> dict:
1437
+ """Run the evidence gates against a plan model."""
1438
+ results = {}
1439
+ population = model.get("defect_population", [])
1440
+ cards = model.get("parameter_cards", {})
1441
+ results["method_selection_complete"] = bool(population) and all(
1442
+ row.get("top_method") and row.get("alternates") is not None
1443
+ and row.get("rationale") for row in population)
1444
+ results["parameters_computed"] = bool(cards) and all(
1445
+ isinstance(card, dict)
1446
+ and any(isinstance(v, (int, float)) for v in card.values())
1447
+ for card in cards.values())
1448
+ p = model.get("personnel", {})
1449
+ rv = p.get("review", {})
1450
+ results["personnel_qualified"] = (
1451
+ rv.get("certification_status") == "current"
1452
+ and bool(rv.get("supervision_ok"))
1453
+ and all(row.get("operator_meets") for row in
1454
+ p.get("method_levels", [])))
1455
+ af = model.get("acceptance_framing", {})
1456
+ results["acceptance_framed"] = (
1457
+ bool(af.get("method_rules")) and bool(af.get("framing_note")))
1458
+ results["sign_off_honest"] = model.get("status") == "draft-for-review"
1459
+ results["all_pass"] = all(results.values())
1460
+ return results
1461
+
1462
+
1463
+ def check_report_markdown(md_text: str) -> dict:
1464
+ """Gate-check the rendered markdown deliverable."""
1465
+ low = md_text.lower()
1466
+ checks = {
1467
+ "has_title": "nondestructive test plan and method selection report"
1468
+ in low,
1469
+ "has_item": "**item:**" in low,
1470
+ "has_selection": "selected method" in low,
1471
+ "has_numbers": "standard depth of penetration" in low
1472
+ and "leak rate" in low,
1473
+ "has_personnel": "personnel qualification" in low,
1474
+ "has_draft_marker": "draft" in low,
1475
+ "has_not_approval": "not an approval" in low,
1476
+ }
1477
+ checks["all_pass"] = all(checks.values())
1478
+ return checks
1479
+
1480
+
1481
+ def validate_deliverable_text(md_text: str) -> dict:
1482
+ """Public entry point used by gate tooling."""
1483
+ return check_report_markdown(md_text)
1484
+
1485
+
1486
+ # ---------------------------------------------------------------------------
1487
+ # Example item (for tests + worked-example generation)
1488
+ # ---------------------------------------------------------------------------
1489
+
1490
+ def example_item() -> InspectionItem:
1491
+ """Reference item: a critical cast aluminum aerospace part with three
1492
+ candidate defect classes (surface crack, near-surface porosity,
1493
+ internal shrinkage) plus the sealed-housing leak check."""
1494
+ defects = [
1495
+ DefectCandidate(
1496
+ defect_name="Fatigue crack at oil-feed boss",
1497
+ defect_class="surface",
1498
+ zone="oil-feed boss, machined face",
1499
+ description="surface-breaking crack zone on the machined "
1500
+ "boss face (non-ferromagnetic aluminum)",
1501
+ ),
1502
+ DefectCandidate(
1503
+ defect_name="Subsurface gas porosity under machined skin",
1504
+ defect_class="near-surface",
1505
+ zone="machined wall, 1 mm below surface",
1506
+ description="near-surface gas porosity band beneath the "
1507
+ "machined skin",
1508
+ ),
1509
+ DefectCandidate(
1510
+ defect_name="Internal shrinkage porosity in lug section",
1511
+ defect_class="internal",
1512
+ zone="thick lug section, 30 mm section",
1513
+ geometry_descriptor="round globular gas pockets",
1514
+ description="internal shrinkage/gas porosity population in "
1515
+ "the thick lug section",
1516
+ ),
1517
+ ]
1518
+ return InspectionItem(
1519
+ item_name="Main Gearbox Housing (investment casting)",
1520
+ item_number="PN 7450-103",
1521
+ description=("Critical cast aluminum gearbox housing; the casting "
1522
+ "is fully machined after NDT and holds oil under "
1523
+ "pressure in service."),
1524
+ material="A356 aluminum alloy casting",
1525
+ material_class="non-ferromagnetic",
1526
+ percent_iacs=30.0,
1527
+ manufacture_route="investment cast + T6",
1528
+ drawing_ref="DWG 7450-103 rev C",
1529
+ candidate_defects=defects,
1530
+ penetrant={"surface_tension_nm": 0.032, "contact_angle_deg": 5.0,
1531
+ "viscosity_pas": 0.008, "crack_depth_m": 1e-3,
1532
+ "crack_opening_width_m": 4e-6,
1533
+ "reference_dwell_s": 300.0,
1534
+ "developer_areal_density_kgm2": 0.15,
1535
+ "bleed_factor": 4.0},
1536
+ pt_surface_area_m2=0.5,
1537
+ eddy_surface={"flaw_depth_m": 1e-3, "percent_iacs": 30.0,
1538
+ "penetration_factor": 0.5,
1539
+ "label": "surface crack (sharp response)"},
1540
+ eddy_subsurface={"flaw_depth_m": 1e-3, "percent_iacs": 30.0,
1541
+ "penetration_factor": 2.0,
1542
+ "label": "near-surface porosity"},
1543
+ radiography={"focal_spot_mm": 3.0, "sod_mm": 500.0, "odd_mm": 30.0,
1544
+ "base_exposure_min": 4.0,
1545
+ "reference_distance_mm": 900.0,
1546
+ "iq_visible_mm": 0.6, "section_thickness_mm": 30.0,
1547
+ "film_density": 2.5,
1548
+ "geometry_descriptor": "round globular gas pockets"},
1549
+ tomography={"pixel_pitch_m": 200e-6, "sod_m": 0.300, "odd_m": 0.300,
1550
+ "required_flaw_m": 0.5e-3, "columns_span": 1024,
1551
+ "material": "aluminum", "thickness_mm": 50.0,
1552
+ "exposure_s_per_proj": 0.1, "void_voxels": 64000,
1553
+ "total_voxels": 8000000, "mu": 28.0, "mu_water": 20.0},
1554
+ leak={"required_sensitivity_sccs": 1e-1,
1555
+ "access_both_sides": False, "need_localization": False,
1556
+ "part_pressure_capable": True,
1557
+ "volume_L": 5.0, "dP_bar": 0.02, "time_s": 600.0,
1558
+ "max_allowable_sccs": 0.2, "gauge_res_bar": 5e-4},
1559
+ personnel_record={"operator_level": "ii",
1560
+ "supervisor_level": "iii",
1561
+ "upgrade_inputs": {"target_level": "iii",
1562
+ "held_hours": 720,
1563
+ "required_hours": 700,
1564
+ "held_months": 26,
1565
+ "required_months": 24,
1566
+ "exam_passed": True}},
1567
+ certification_basis="AS9100D special-process control; NAS 410 "
1568
+ "personnel qualification practice",
1569
+ )
1570
+
1571
+
1572
+ def example_report_markdown() -> str:
1573
+ return render_report_markdown(build_report(example_item()))
1574
+
1575
+
1576
+ if __name__ == "__main__":
1577
+ item = example_item()
1578
+ model = build_report(item)
1579
+ md = render_report_markdown(model)
1580
+ print("SELECTED METHODS:")
1581
+ for row in model["defect_population"]:
1582
+ print(" %-45s -> %s (alts %s)"
1583
+ % (row["defect_name"], row["top_method"], row["alternates"]))
1584
+ print("CARDS: %s" % ", ".join(sorted(model["parameter_cards"])))
1585
+ p = model["personnel"]
1586
+ print("PERSONNEL: %s | supervision_ok=%s"
1587
+ % (p["review"]["certification_status"],
1588
+ p["review"]["supervision_ok"]))
1589
+ print("GATES: %s" % check_report(model))
1590
+ print("RENDERED: %d chars" % len(md))