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