damocles 0.2.0__py3-none-any.whl
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.
- damocles/__init__.py +33 -0
- damocles/ac3314.py +342 -0
- damocles/allowables.py +53 -0
- damocles/cli.py +47 -0
- damocles/data/ac3314_anomalies.json +1062 -0
- damocles/data/ac3314_pod.json +59 -0
- damocles/data/materials.json +63 -0
- damocles/fracture.py +318 -0
- damocles/inspection.py +131 -0
- damocles/materials.py +97 -0
- damocles/nasgro.py +137 -0
- damocles/newman_raju.py +163 -0
- damocles/plots.py +84 -0
- damocles/random_vars.py +137 -0
- damocles/reliability.py +117 -0
- damocles/sampling.py +57 -0
- damocles/sensitivity.py +51 -0
- damocles/spectrum.py +159 -0
- damocles/study.py +231 -0
- damocles-0.2.0.dist-info/METADATA +314 -0
- damocles-0.2.0.dist-info/RECORD +25 -0
- damocles-0.2.0.dist-info/WHEEL +5 -0
- damocles-0.2.0.dist-info/entry_points.txt +2 -0
- damocles-0.2.0.dist-info/licenses/LICENSE +21 -0
- damocles-0.2.0.dist-info/top_level.txt +1 -0
damocles/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""damocles: probabilistic damage tolerance analysis.
|
|
2
|
+
|
|
3
|
+
Monte Carlo fatigue crack growth with variance reduction, inspection
|
|
4
|
+
planning against POD curves, sensitivity analysis and material basis
|
|
5
|
+
values. Units throughout: stress in MPa, stress intensity in MPa*sqrt(m),
|
|
6
|
+
crack sizes in metres, life in cycles.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .ac3314 import (
|
|
10
|
+
ExceedanceCurve, TabulatedPOD, hoop_stress as ac3314_hoop_stress,
|
|
11
|
+
run_test_case as ac3314_test_case,
|
|
12
|
+
)
|
|
13
|
+
from .allowables import a_basis, b_basis, basis_value, tolerance_factor
|
|
14
|
+
from .fracture import (
|
|
15
|
+
CenterCrack, CornerCrack, CustomGeometry, ParisLaw, SurfaceCrack,
|
|
16
|
+
ThroughCrack, WalkerLaw, critical_size, grow, grow_spectrum,
|
|
17
|
+
)
|
|
18
|
+
from .spectrum import CycleClass, Spectrum, rainflow
|
|
19
|
+
from .inspection import InspectionPlan, PODCurve, apply_plan, sweep_intervals
|
|
20
|
+
from .materials import available as available_materials
|
|
21
|
+
from .materials import get as get_material
|
|
22
|
+
from .materials import growth_law as material_growth_law
|
|
23
|
+
from .nasgro import NasgroLaw, newman_opening_function
|
|
24
|
+
from .newman_raju import NewmanRajuCornerCrack, NewmanRajuSurfaceCrack
|
|
25
|
+
from .random_vars import (
|
|
26
|
+
Deterministic, Gumbel, Lognormal, Normal, Uniform, Weibull, from_spec,
|
|
27
|
+
)
|
|
28
|
+
from .reliability import estimate_pof
|
|
29
|
+
from .sampling import map_to_physical, sample_unit
|
|
30
|
+
from .sensitivity import rank_drivers, sobol_indices
|
|
31
|
+
from .study import DamageToleranceStudy, build_study
|
|
32
|
+
|
|
33
|
+
__version__ = "0.2.0"
|
damocles/ac3314.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""FAA AC 33.14-1 hard alpha rotor assessment, including the published
|
|
2
|
+
calibration test case.
|
|
3
|
+
|
|
4
|
+
The Advisory Circular requires every probabilistic rotor integrity code
|
|
5
|
+
to be calibrated against a standardized test case (Appendix 1): a
|
|
6
|
+
titanium ring disk spun to 6,800 rpm with a 50 MPa blade load on the
|
|
7
|
+
rim, certified life 20,000 cycles, anomalies drawn from the post-1995
|
|
8
|
+
triple-melt hard alpha distribution with #3 FBH billet and forging
|
|
9
|
+
inspections, and an optional in-service ultrasonic inspection at 10,000
|
|
10
|
+
cycles. Results between 1.27e-9 and 1.93e-9 events per cycle without
|
|
11
|
+
inspection, and 8.36e-10 to 1.53e-9 with, are acceptable (AC 33.14-1
|
|
12
|
+
Section 3, calibration paragraph).
|
|
13
|
+
|
|
14
|
+
This module implements that assessment. Because the test case treats
|
|
15
|
+
everything but the anomaly size as deterministic, and the geometry
|
|
16
|
+
factors are constants, the per-zone physics inverts in closed form and
|
|
17
|
+
the fleet risk reduces to a one-dimensional integral over the anomaly
|
|
18
|
+
exceedance curve. A Monte Carlo path over the same zones is provided to
|
|
19
|
+
cross-check the sampling engine against the quadrature.
|
|
20
|
+
|
|
21
|
+
Stress field: the test case ring is a plain rotating annulus with an
|
|
22
|
+
outward rim traction, so the hoop stress is the classical Lame plus
|
|
23
|
+
rotating-disk solution. It reproduces the AC's quoted bore stress of
|
|
24
|
+
572.4 MPa to within a few MPa.
|
|
25
|
+
|
|
26
|
+
Data files: ac3314_anomalies.json (Table A3-1, verbatim) and
|
|
27
|
+
ac3314_pod.json (Appendix 5 POD curves, digitized from the official
|
|
28
|
+
PDF's vector art).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import json
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from importlib import resources
|
|
36
|
+
|
|
37
|
+
import numpy as np
|
|
38
|
+
|
|
39
|
+
MIL_SQ_TO_M_SQ = (25.4e-6) ** 2 # one square mil in square metres
|
|
40
|
+
|
|
41
|
+
# AC 33.14-1 Appendix 1 test case definition
|
|
42
|
+
RPM = 6800.0
|
|
43
|
+
RIM_TRACTION = 50.0 # MPa, outward, simulates blade load
|
|
44
|
+
R_OUTER = 0.425 # m
|
|
45
|
+
R_BORE = 0.300 # m
|
|
46
|
+
AXIAL_WIDTH = 0.100 # m
|
|
47
|
+
DENSITY = 4450.0 # kg/m^3
|
|
48
|
+
POISSON = 0.361
|
|
49
|
+
PARIS_C = 9.25e-13 # m/cycle, MPa sqrt(m), MCIC-HB-01R Ti-6-4
|
|
50
|
+
PARIS_M = 3.87
|
|
51
|
+
K_IC = 64.5 # MPa sqrt(m)
|
|
52
|
+
SERVICE_CYCLES = 20000.0
|
|
53
|
+
INSPECTION_CYCLES = 10000.0
|
|
54
|
+
SKIN_DEPTH = 0.5e-3 # m, the AC's 0.020 in onion skin
|
|
55
|
+
LB_PER_KG = 2.2046226
|
|
56
|
+
|
|
57
|
+
# geometry factors per the AC's modelling rules: embedded circular
|
|
58
|
+
# crack for subsurface zones, semicircular surface crack for surface
|
|
59
|
+
# zones; anomaly area converts to crack size by the prescribed shapes
|
|
60
|
+
Y_EMBEDDED = 2.0 / np.pi
|
|
61
|
+
Y_SURFACE = 1.12 * 2.0 / np.pi
|
|
62
|
+
AREA_FACTOR = {"embedded": np.pi, "surface": np.pi / 2.0}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _load(name):
|
|
66
|
+
path = resources.files("damocles").joinpath(f"data/{name}")
|
|
67
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
68
|
+
data = json.load(fh)
|
|
69
|
+
data.pop("_comment", None)
|
|
70
|
+
return data
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ExceedanceCurve:
|
|
74
|
+
"""Anomalies per million pounds exceeding a given cross-sectional
|
|
75
|
+
area, log-log interpolated and linearly extrapolated on the log-log
|
|
76
|
+
scale outside the tabulated range, as the AC instructs."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, area_sq_mils, exceedance):
|
|
79
|
+
self.log_a = np.log10(np.asarray(area_sq_mils, dtype=float))
|
|
80
|
+
self.log_e = np.log10(np.asarray(exceedance, dtype=float))
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def hard_alpha(cls, billet_fbh=3, forging_fbh=3):
|
|
84
|
+
"""The 2017 Change 1 tabulated distributions (Table A3-1)."""
|
|
85
|
+
data = _load("ac3314_anomalies.json")
|
|
86
|
+
key = f"{billet_fbh}-{forging_fbh}"
|
|
87
|
+
cols = data["exceedance_per_million_lb"]
|
|
88
|
+
if key not in cols:
|
|
89
|
+
raise KeyError(f"no curve for billet/forging {key!r}, "
|
|
90
|
+
f"available: {sorted(cols)}")
|
|
91
|
+
return cls(data["area_sq_mils"], cols[key])
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def test_case_2001(cls):
|
|
95
|
+
"""The 2001 Appendix 3 FIGURE A3-7 curve (#3/#3 FBH) that the
|
|
96
|
+
calibration test case and its acceptance band were built on. It
|
|
97
|
+
differs substantially from the Change 1 tabulation in the
|
|
98
|
+
1000-5000 sq mil region that dominates the test case risk."""
|
|
99
|
+
data = _load("ac3314_anomalies.json")
|
|
100
|
+
rec = data["testcase_2001_a3_7"]
|
|
101
|
+
return cls(rec["area_sq_mils"], rec["exceedance_per_million_lb"])
|
|
102
|
+
|
|
103
|
+
def exceedance(self, area_sq_mils):
|
|
104
|
+
la = np.log10(np.maximum(np.asarray(area_sq_mils, dtype=float), 1e-12))
|
|
105
|
+
# np.interp clamps; extend with the end slopes for extrapolation
|
|
106
|
+
le = np.interp(la, self.log_a, self.log_e)
|
|
107
|
+
lo, hi = self.log_a[0], self.log_a[-1]
|
|
108
|
+
s_lo = (self.log_e[1] - self.log_e[0]) / (self.log_a[1] - self.log_a[0])
|
|
109
|
+
s_hi = (self.log_e[-1] - self.log_e[-2]) / (self.log_a[-1] - self.log_a[-2])
|
|
110
|
+
le = np.where(la < lo, self.log_e[0] + s_lo * (la - lo), le)
|
|
111
|
+
le = np.where(la > hi, self.log_e[-1] + s_hi * (la - hi), le)
|
|
112
|
+
return 10.0 ** le
|
|
113
|
+
|
|
114
|
+
def _extended(self):
|
|
115
|
+
# widen the table by the AC's log-log linear extrapolation so the
|
|
116
|
+
# inverse covers tails beyond the published range
|
|
117
|
+
s_lo = (self.log_e[1] - self.log_e[0]) / (self.log_a[1] - self.log_a[0])
|
|
118
|
+
s_hi = (self.log_e[-1] - self.log_e[-2]) / (self.log_a[-1] - self.log_a[-2])
|
|
119
|
+
la = np.concatenate([[self.log_a[0] - 4.0], self.log_a,
|
|
120
|
+
[self.log_a[-1] + 4.0]])
|
|
121
|
+
le = np.concatenate([[self.log_e[0] - 4.0 * s_lo], self.log_e,
|
|
122
|
+
[self.log_e[-1] + 4.0 * s_hi]])
|
|
123
|
+
return la, le
|
|
124
|
+
|
|
125
|
+
def sample_conditional(self, u, min_area=None):
|
|
126
|
+
"""Inverse CDF of anomaly area given an anomaly is present
|
|
127
|
+
(area >= min_area), for the Monte Carlo path."""
|
|
128
|
+
la, le = self._extended()
|
|
129
|
+
a_min = la[1] if min_area is None else np.log10(min_area)
|
|
130
|
+
e_min = self.exceedance(10.0 ** a_min)
|
|
131
|
+
target = np.log10((1.0 - np.asarray(u)) * e_min)
|
|
132
|
+
# invert the piecewise-linear log E(log a) relation
|
|
133
|
+
out = 10.0 ** np.interp(-target, -le, la)
|
|
134
|
+
return np.maximum(out, 10.0 ** a_min)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class TabulatedPOD:
|
|
138
|
+
"""POD interpolated linearly in log size between digitized points,
|
|
139
|
+
zero below the first point and one above the last."""
|
|
140
|
+
|
|
141
|
+
def __init__(self, size, pod, axis):
|
|
142
|
+
self.log_s = np.log10(np.asarray(size, dtype=float))
|
|
143
|
+
self.p = np.asarray(pod, dtype=float)
|
|
144
|
+
self.axis = axis
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def from_ac(cls, name="ut-3fbh-cal"):
|
|
148
|
+
data = _load("ac3314_pod.json")
|
|
149
|
+
if name not in data:
|
|
150
|
+
raise KeyError(f"no POD curve {name!r}, available: {sorted(data)}")
|
|
151
|
+
rec = data[name]
|
|
152
|
+
return cls(rec["size"], rec["pod"], rec["axis"])
|
|
153
|
+
|
|
154
|
+
def pod(self, size):
|
|
155
|
+
ls = np.log10(np.maximum(np.asarray(size, dtype=float), 1e-12))
|
|
156
|
+
p = np.interp(ls, self.log_s, self.p)
|
|
157
|
+
p = np.where(ls < self.log_s[0], 0.0, p)
|
|
158
|
+
p = np.where(ls > self.log_s[-1], 1.0, p)
|
|
159
|
+
return p
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def hoop_stress(r, rpm=RPM, traction=RIM_TRACTION, r_outer=R_OUTER,
|
|
163
|
+
r_bore=R_BORE, density=DENSITY, nu=POISSON):
|
|
164
|
+
"""Hoop stress [MPa] in the rotating ring with outward rim traction:
|
|
165
|
+
rotating annulus solution plus the Lame field of the rim load."""
|
|
166
|
+
r = np.asarray(r, dtype=float)
|
|
167
|
+
omega = rpm * 2.0 * np.pi / 60.0
|
|
168
|
+
rho_w2 = density * omega**2 / 1e6 # to MPa
|
|
169
|
+
k = (3.0 + nu) / 8.0
|
|
170
|
+
spin = k * rho_w2 * (r_outer**2 + r_bore**2
|
|
171
|
+
+ (r_outer * r_bore / r) ** 2
|
|
172
|
+
- (1.0 + 3.0 * nu) / (3.0 + nu) * r**2)
|
|
173
|
+
lame = traction * r_outer**2 / (r_outer**2 - r_bore**2) \
|
|
174
|
+
* (1.0 + (r_bore / r) ** 2)
|
|
175
|
+
return spin + lame
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@dataclass
|
|
179
|
+
class Zone:
|
|
180
|
+
name: str
|
|
181
|
+
kind: str # "embedded" or "surface"
|
|
182
|
+
volume: float # m^3
|
|
183
|
+
stress: float # MPa, life-limiting (max) stress in the zone
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def mass_mlb(self):
|
|
187
|
+
return self.volume * DENSITY * LB_PER_KG / 1e6
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def build_zones(n_rings=18):
|
|
191
|
+
"""Zone the ring per the AC's scheme: 0.5 mm surface 'onion skins'
|
|
192
|
+
on the bore, rim and both flat faces (surface cracks), and the
|
|
193
|
+
remaining interior as radial rings (embedded cracks). The zone
|
|
194
|
+
stress is the maximum within the zone, i.e. at its innermost
|
|
195
|
+
radius, which is the AC's life-limiting-location rule."""
|
|
196
|
+
zones = []
|
|
197
|
+
|
|
198
|
+
# bore and rim cylindrical skins: radial slivers across the full
|
|
199
|
+
# axial width; the interior rings start beyond them so the zone set
|
|
200
|
+
# partitions the disk volume exactly
|
|
201
|
+
zones.append(Zone("bore skin", "surface",
|
|
202
|
+
np.pi * ((R_BORE + SKIN_DEPTH) ** 2 - R_BORE**2)
|
|
203
|
+
* AXIAL_WIDTH,
|
|
204
|
+
hoop_stress(R_BORE)))
|
|
205
|
+
zones.append(Zone("rim skin", "surface",
|
|
206
|
+
np.pi * (R_OUTER**2 - (R_OUTER - SKIN_DEPTH) ** 2)
|
|
207
|
+
* AXIAL_WIDTH,
|
|
208
|
+
hoop_stress(R_OUTER)))
|
|
209
|
+
|
|
210
|
+
edges = np.linspace(R_BORE + SKIN_DEPTH, R_OUTER - SKIN_DEPTH,
|
|
211
|
+
n_rings + 1)
|
|
212
|
+
interior_width = AXIAL_WIDTH - 2.0 * SKIN_DEPTH
|
|
213
|
+
for i in range(n_rings):
|
|
214
|
+
r_in, r_out = edges[i], edges[i + 1]
|
|
215
|
+
ring_area = np.pi * (r_out**2 - r_in**2)
|
|
216
|
+
sigma = float(hoop_stress(r_in)) # hoop decreases with radius
|
|
217
|
+
# flat face skins of this ring, both faces
|
|
218
|
+
zones.append(Zone(f"face skin {i}", "surface",
|
|
219
|
+
2.0 * ring_area * SKIN_DEPTH, sigma))
|
|
220
|
+
zones.append(Zone(f"interior {i}", "embedded",
|
|
221
|
+
ring_area * interior_width, sigma))
|
|
222
|
+
return zones
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _closed_form(zone):
|
|
226
|
+
"""Constant-Y Paris quantities for a zone: returns (g, a_crit) with
|
|
227
|
+
g = (m/2 - 1) * C * (Y * dsigma * sqrt(pi))^m so that
|
|
228
|
+
a^(1-m/2) consumes g per cycle."""
|
|
229
|
+
y = Y_EMBEDDED if zone.kind == "embedded" else Y_SURFACE
|
|
230
|
+
a_crit = (K_IC / (y * zone.stress * np.sqrt(np.pi))) ** 2
|
|
231
|
+
g = (PARIS_M / 2.0 - 1.0) * PARIS_C \
|
|
232
|
+
* (y * zone.stress * np.sqrt(np.pi)) ** PARIS_M
|
|
233
|
+
return g, a_crit
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _a_initial_for_life(zone, cycles):
|
|
237
|
+
"""Initial crack size that fails in exactly `cycles`: closed-form
|
|
238
|
+
inversion of the Paris integral with constant Y. With e = 1 - m/2,
|
|
239
|
+
a0^e = a_crit^e + cycles * g."""
|
|
240
|
+
g, a_crit = _closed_form(zone)
|
|
241
|
+
e = 1.0 - PARIS_M / 2.0 # negative
|
|
242
|
+
return (a_crit**e + cycles * g) ** (1.0 / e)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _crack_at(zone, a0, cycles):
|
|
246
|
+
"""Crack size after `cycles` from a0, closed form; capped at
|
|
247
|
+
critical (failed)."""
|
|
248
|
+
g, a_crit = _closed_form(zone)
|
|
249
|
+
e = 1.0 - PARIS_M / 2.0
|
|
250
|
+
val = np.asarray(a0, dtype=float) ** e - cycles * g
|
|
251
|
+
failed = val <= a_crit**e
|
|
252
|
+
return np.where(failed, a_crit, np.power(np.maximum(val, a_crit**e), 1.0 / e))
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _area_sq_mils(zone, a):
|
|
256
|
+
return AREA_FACTOR[zone.kind] * np.asarray(a) ** 2 / MIL_SQ_TO_M_SQ
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _a_from_area_sq_mils(zone, area):
|
|
260
|
+
return np.sqrt(np.asarray(area) * MIL_SQ_TO_M_SQ / AREA_FACTOR[zone.kind])
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@dataclass
|
|
264
|
+
class TestCaseResult:
|
|
265
|
+
pof_service: float
|
|
266
|
+
events_per_cycle: float
|
|
267
|
+
inspected: bool
|
|
268
|
+
method: str
|
|
269
|
+
zone_risk: dict = field(repr=False, default_factory=dict)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def run_test_case(inspection=True, n_rings=18, method="quadrature",
|
|
273
|
+
n_samples=200_000, seed=42, pod_basis="original"):
|
|
274
|
+
"""Run the AC 33.14-1 Appendix 1 calibration test case.
|
|
275
|
+
|
|
276
|
+
method "quadrature" integrates the anomaly exceedance curve exactly
|
|
277
|
+
(the test case physics is deterministic given anomaly size);
|
|
278
|
+
"montecarlo" samples anomalies per zone instead and must agree with
|
|
279
|
+
the quadrature within its sampling error.
|
|
280
|
+
|
|
281
|
+
pod_basis: "original" evaluates the inspection POD at the
|
|
282
|
+
as-manufactured anomaly area (the UT reflector is the inclusion
|
|
283
|
+
plus diffusion zone, which is what Appendix 5 curves are defined
|
|
284
|
+
on); "current" uses the grown crack area at 10,000 cycles, which
|
|
285
|
+
credits the inspection more. Both land inside the AC acceptance
|
|
286
|
+
band; "original" sits nearer the industry round-robin mean.
|
|
287
|
+
"""
|
|
288
|
+
curve = ExceedanceCurve.test_case_2001()
|
|
289
|
+
pod = TabulatedPOD.from_ac("ut-3fbh-cal")
|
|
290
|
+
zones = build_zones(n_rings)
|
|
291
|
+
|
|
292
|
+
total = 0.0
|
|
293
|
+
breakdown = {}
|
|
294
|
+
rng = np.random.default_rng(seed)
|
|
295
|
+
for zone in zones:
|
|
296
|
+
a_star = _a_initial_for_life(zone, SERVICE_CYCLES)
|
|
297
|
+
area_star = float(_area_sq_mils(zone, a_star))
|
|
298
|
+
|
|
299
|
+
if not inspection:
|
|
300
|
+
risk = zone.mass_mlb * float(curve.exceedance(area_star))
|
|
301
|
+
elif method == "quadrature":
|
|
302
|
+
# integrate (1 - POD) over the exceedance tail above area*
|
|
303
|
+
la = np.linspace(np.log10(area_star),
|
|
304
|
+
np.log10(area_star) + 3.5, 600)
|
|
305
|
+
areas = 10.0 ** la
|
|
306
|
+
e = curve.exceedance(areas)
|
|
307
|
+
a0 = _a_from_area_sq_mils(zone, areas)
|
|
308
|
+
mids_a0 = 0.5 * (a0[1:] + a0[:-1])
|
|
309
|
+
if pod_basis == "current":
|
|
310
|
+
a_insp = _crack_at(zone, mids_a0, INSPECTION_CYCLES)
|
|
311
|
+
insp_area = _area_sq_mils(zone, a_insp)
|
|
312
|
+
else:
|
|
313
|
+
insp_area = 0.5 * (areas[1:] + areas[:-1])
|
|
314
|
+
miss = 1.0 - pod.pod(insp_area)
|
|
315
|
+
risk = zone.mass_mlb * float(np.sum((e[:-1] - e[1:]) * miss))
|
|
316
|
+
else:
|
|
317
|
+
u = rng.random(n_samples)
|
|
318
|
+
areas = curve.sample_conditional(u, min_area=area_star)
|
|
319
|
+
a0 = _a_from_area_sq_mils(zone, areas)
|
|
320
|
+
if pod_basis == "current":
|
|
321
|
+
insp_area = _area_sq_mils(
|
|
322
|
+
zone, _crack_at(zone, a0, INSPECTION_CYCLES))
|
|
323
|
+
else:
|
|
324
|
+
insp_area = areas
|
|
325
|
+
miss = 1.0 - pod.pod(insp_area)
|
|
326
|
+
risk = zone.mass_mlb * float(curve.exceedance(area_star)) \
|
|
327
|
+
* float(np.mean(miss))
|
|
328
|
+
total += risk
|
|
329
|
+
breakdown[zone.name] = risk
|
|
330
|
+
|
|
331
|
+
return TestCaseResult(
|
|
332
|
+
pof_service=total,
|
|
333
|
+
events_per_cycle=total / SERVICE_CYCLES,
|
|
334
|
+
inspected=inspection,
|
|
335
|
+
method=method if inspection else "quadrature",
|
|
336
|
+
zone_risk=breakdown,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# the AC's published acceptance bands, events per flight cycle
|
|
341
|
+
ACCEPTANCE_NO_INSPECTION = (1.27e-9, 1.93e-9)
|
|
342
|
+
ACCEPTANCE_WITH_INSPECTION = (8.36e-10, 1.53e-9)
|
damocles/allowables.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Statistical basis values for material properties.
|
|
2
|
+
|
|
3
|
+
A-basis: 99 % of the population exceeds the value with 95 % confidence.
|
|
4
|
+
B-basis: 90 % exceedance with 95 % confidence. These are the design
|
|
5
|
+
allowables of MMPDS and CMH-17. The one-sided tolerance factor for
|
|
6
|
+
normal data comes from the noncentral t distribution, which is exact.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
from scipy import stats
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def tolerance_factor(n, proportion=0.90, confidence=0.95):
|
|
16
|
+
"""Exact one-sided tolerance limit factor k for normal samples:
|
|
17
|
+
P(at least `proportion` of the population > xbar - k*s) = confidence."""
|
|
18
|
+
if n < 2:
|
|
19
|
+
raise ValueError("need at least two specimens")
|
|
20
|
+
z_p = stats.norm.ppf(proportion)
|
|
21
|
+
return float(stats.nct.ppf(confidence, df=n - 1, nc=z_p * np.sqrt(n))
|
|
22
|
+
/ np.sqrt(n))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def basis_value(data, proportion=0.90, confidence=0.95, dist="normal"):
|
|
26
|
+
"""Lower tolerance bound on the data.
|
|
27
|
+
|
|
28
|
+
dist="normal" : xbar - k * s
|
|
29
|
+
dist="lognormal" : the same on log data, transformed back. Use this
|
|
30
|
+
for anything physically positive with right skew
|
|
31
|
+
(toughness, life, flaw size).
|
|
32
|
+
"""
|
|
33
|
+
x = np.asarray(data, dtype=float)
|
|
34
|
+
if x.ndim != 1:
|
|
35
|
+
raise ValueError("data must be one-dimensional")
|
|
36
|
+
n = x.shape[0]
|
|
37
|
+
k = tolerance_factor(n, proportion, confidence)
|
|
38
|
+
if dist == "normal":
|
|
39
|
+
return float(np.mean(x) - k * np.std(x, ddof=1))
|
|
40
|
+
if dist == "lognormal":
|
|
41
|
+
if np.any(x <= 0):
|
|
42
|
+
raise ValueError("lognormal basis needs positive data")
|
|
43
|
+
lx = np.log(x)
|
|
44
|
+
return float(np.exp(np.mean(lx) - k * np.std(lx, ddof=1)))
|
|
45
|
+
raise ValueError(f"unknown dist {dist!r}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def b_basis(data, dist="normal"):
|
|
49
|
+
return basis_value(data, proportion=0.90, confidence=0.95, dist=dist)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def a_basis(data, dist="normal"):
|
|
53
|
+
return basis_value(data, proportion=0.99, confidence=0.95, dist=dist)
|
damocles/cli.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Command line entry point: run a study from a YAML file.
|
|
2
|
+
|
|
3
|
+
damocles config.yaml
|
|
4
|
+
damocles config.yaml --sensitivity --plot out/
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
|
|
14
|
+
from .study import build_study
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main(argv=None):
|
|
18
|
+
parser = argparse.ArgumentParser(
|
|
19
|
+
prog="damocles",
|
|
20
|
+
description="Probabilistic damage tolerance from a YAML study file.")
|
|
21
|
+
parser.add_argument("config", help="study definition, see examples/")
|
|
22
|
+
parser.add_argument("--sensitivity", action="store_true",
|
|
23
|
+
help="also compute Sobol indices on log-life")
|
|
24
|
+
parser.add_argument("--plot", metavar="DIR",
|
|
25
|
+
help="write report figures to this directory")
|
|
26
|
+
args = parser.parse_args(argv)
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
with open(args.config, "r", encoding="utf-8") as fh:
|
|
30
|
+
spec = yaml.safe_load(fh)
|
|
31
|
+
study = build_study(spec)
|
|
32
|
+
except (OSError, yaml.YAMLError, KeyError, ValueError, TypeError) as exc:
|
|
33
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
34
|
+
return 2
|
|
35
|
+
|
|
36
|
+
result = study.run(sensitivity=args.sensitivity)
|
|
37
|
+
print(result.summary())
|
|
38
|
+
|
|
39
|
+
if args.plot:
|
|
40
|
+
from .plots import save_all
|
|
41
|
+
for path in save_all(result, args.plot):
|
|
42
|
+
print(f" wrote {path}")
|
|
43
|
+
return 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
if __name__ == "__main__":
|
|
47
|
+
sys.exit(main())
|