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 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())