delta-theory 6.9.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.
@@ -0,0 +1,762 @@
1
+ #!/usr/bin/env python3
2
+ """Unified Yield + δ-fatigue (v6.9b)
3
+
4
+ - Yield model: v5.0 FINAL
5
+ σ_y = σ_base(δ) + Δσ_ss(c) + Δσ_ρ(ε) + Δσ_ppt(r,f)
6
+
7
+ - Fatigue model: v6.8 δ-fatigue damage
8
+ dD/dN = 0 (r<=r_th) else A_eff * (r-r_th)^n
9
+ with r = (amplitude)/(yield in the same loading mode)
10
+ and failure when Λ(D)=D/(1-D) reaches 1 (i.e., D>=0.5) by default.
11
+
12
+ - Added from v4.1 (was missing in original v6.9 integration)
13
+ τ/σ = (α_s/α_t) * C_class * T_twin * A_texture
14
+ σ_c/σ_t = R_comp (twinning/asymmetry)
15
+
16
+ This lets v6.9 operate not only with tensile amplitude σ_a, but also
17
+ with shear amplitude τ_a or compression amplitude σ_a^c.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ from dataclasses import dataclass, replace
24
+ from typing import Dict, Literal, Optional, Tuple
25
+
26
+ import numpy as np
27
+
28
+ # ==============================================================================
29
+ # Physical constants
30
+ # ==============================================================================
31
+ PI = np.pi
32
+ ELECTRON_CHARGE_J = 1.602176634e-19 # 1 eV in Joule
33
+ M_TAYLOR = 3.0
34
+ ALPHA_TAYLOR = 0.3
35
+
36
+ # ==============================================================================
37
+ # v5.0 δ-yield constants
38
+ # ==============================================================================
39
+ ALPHA_DELTA = {'BCC': 0.289, 'FCC': 0.250, 'HCP': 0.350}
40
+ N_EXPONENT = {'interstitial': 0.90, 'substitutional': 0.95}
41
+
42
+ # Work hardening presets
43
+ K_RHO = {
44
+ 'Fe': 1.24e15, 'Cu': 1.07e15, 'Al': 5.98e14, 'Ni': 1.27e15,
45
+ 'BCC': 1.2e15, 'FCC': 1.0e15, 'HCP': 0.8e15,
46
+ }
47
+
48
+ # ==============================================================================
49
+ # v4.1 τ/σ factors
50
+ # ==============================================================================
51
+ CU_TAU_SIGMA_TORSION = 0.565 # Cu torsion calibration point
52
+ DEFAULT_BCC_W110 = 0.0 # {112} dominant
53
+
54
+ # Twinning factor (tension) and compression/tension asymmetry
55
+ T_TWIN = {
56
+ 'Cu': 1.0, 'Al': 1.0, 'Ni': 1.0, 'Au': 1.0, 'Ag': 1.0,
57
+ 'Fe': 1.0, 'W': 1.0,
58
+ 'Ti': 1.0,
59
+ 'Mg': 0.6,
60
+ 'Zn': 0.9,
61
+ }
62
+
63
+ R_COMP = { # σ_c/σ_t
64
+ 'Cu': 1.0, 'Al': 1.0, 'Ni': 1.0, 'Au': 1.0, 'Ag': 1.0,
65
+ 'Fe': 1.0, 'W': 1.0,
66
+ 'Ti': 1.0,
67
+ 'Mg': 0.6,
68
+ 'Zn': 1.2,
69
+ }
70
+
71
+ T_REF = {
72
+ 'BCC': np.array([1, 0, 0], dtype=float),
73
+ 'FCC': np.array([1, 1, 0], dtype=float),
74
+ 'HCP': np.array([1, 0, 0], dtype=float),
75
+ }
76
+
77
+ # ==============================================================================
78
+ # Material database
79
+ # ==============================================================================
80
+ @dataclass(frozen=True)
81
+ class Material:
82
+ name: str
83
+ structure: Literal['BCC', 'FCC', 'HCP']
84
+
85
+ # v5.0 base-yield inputs
86
+ a: float # lattice parameter [m]
87
+ T_m: float # melting point [K]
88
+ dL: float # Lindemann parameter
89
+ Eb: float # bond energy [eV]
90
+ f_d: float # d-electron directionality factor
91
+ G: float # shear modulus [Pa]
92
+
93
+ # v4.1 τ/σ inputs
94
+ c_a: float = 1.633
95
+ A_texture: float = 1.0
96
+ T_twin: float = 1.0
97
+ R_comp: float = 1.0
98
+
99
+
100
+ MATERIALS: Dict[str, Material] = {
101
+ 'Fe': Material('Fe', 'BCC', 2.92e-10, 1811, 0.18, 4.28, 1.5, 82e9, 1.633, 1.0, T_TWIN['Fe'], R_COMP['Fe']),
102
+ 'W': Material('W', 'BCC', 3.16e-10, 3695, 0.16, 8.90, 4.7, 161e9, 1.633, 1.0, T_TWIN['W'], R_COMP['W']),
103
+ 'Cu': Material('Cu', 'FCC', 3.61e-10, 1357, 0.10, 3.49, 2.0, 48e9, 1.633, 1.0, T_TWIN['Cu'], R_COMP['Cu']),
104
+ 'Al': Material('Al', 'FCC', 4.05e-10, 933, 0.10, 3.39, 1.6, 26e9, 1.633, 1.0, T_TWIN['Al'], R_COMP['Al']),
105
+ 'Ni': Material('Ni', 'FCC', 3.52e-10, 1728, 0.11, 4.44, 2.6, 76e9, 1.633, 1.0, T_TWIN['Ni'], R_COMP['Ni']),
106
+ 'Au': Material('Au', 'FCC', 4.08e-10, 1337, 0.10, 3.81, 1.1, 27e9, 1.633, 1.0, T_TWIN['Au'], R_COMP['Au']),
107
+ 'Ag': Material('Ag', 'FCC', 4.09e-10, 1235, 0.10, 2.95, 2.0, 30e9, 1.633, 1.0, T_TWIN['Ag'], R_COMP['Ag']),
108
+ 'Ti': Material('Ti', 'HCP', 2.95e-10, 1941, 0.10, 4.85, 5.7, 44e9, 1.587, 1.0, T_TWIN['Ti'], R_COMP['Ti']),
109
+ 'Mg': Material('Mg', 'HCP', 3.21e-10, 923, 0.117,1.51, 8.2, 17e9, 1.624, 1.0, T_TWIN['Mg'], R_COMP['Mg']),
110
+ 'Zn': Material('Zn', 'HCP', 2.66e-10, 693, 0.12, 1.35, 2.0, 43e9, 1.856, 1.0, T_TWIN['Zn'], R_COMP['Zn']),
111
+ }
112
+
113
+ # ==============================================================================
114
+ # Helpers
115
+ # ==============================================================================
116
+
117
+ def calc_burgers(a: float, structure: str) -> float:
118
+ if structure == 'BCC':
119
+ return a * np.sqrt(3) / 2
120
+ if structure == 'FCC':
121
+ return a / np.sqrt(2)
122
+ return a
123
+
124
+ # ==============================================================================
125
+ # v5.0 yield components
126
+ # ==============================================================================
127
+
128
+ def sigma_base_delta(mat: Material, T_K: float = 300.0) -> float:
129
+ """Base yield stress from δ-theory [MPa]."""
130
+ alpha = ALPHA_DELTA[mat.structure]
131
+ b = calc_burgers(mat.a, mat.structure)
132
+ V_act = b**3
133
+ HP = max(0.0, 1.0 - T_K / mat.T_m)
134
+ E_eff = mat.Eb * ELECTRON_CHARGE_J * alpha * mat.f_d
135
+ sigma = (E_eff / V_act) * mat.dL * HP / (2 * PI * M_TAYLOR)
136
+ return sigma / 1e6
137
+
138
+
139
+ def delta_sigma_ss(c_wt_percent: float, k: float,
140
+ solute_type: Optional[Literal['interstitial', 'substitutional']]) -> float:
141
+ if solute_type is None or c_wt_percent <= 0:
142
+ return 0.0
143
+ n = N_EXPONENT[solute_type]
144
+ return k * (c_wt_percent ** n)
145
+
146
+
147
+ def calibrate_k_ss(c_wt_percent: float, sigma_exp_MPa: float,
148
+ sigma_base_MPa: float,
149
+ solute_type: Literal['interstitial', 'substitutional']) -> float:
150
+ n = N_EXPONENT[solute_type]
151
+ return (sigma_exp_MPa - sigma_base_MPa) / (c_wt_percent ** n)
152
+
153
+
154
+ def delta_sigma_taylor(eps: float, mat: Material, rho_0: float = 1e12) -> float:
155
+ """Work hardening via Taylor relation [MPa]."""
156
+ if eps <= 0 and rho_0 <= 0:
157
+ return 0.0
158
+ K = K_RHO.get(mat.name, K_RHO.get(mat.structure, 1e15))
159
+ rho = rho_0 + K * max(eps, 0.0)
160
+ b = calc_burgers(mat.a, mat.structure)
161
+ return M_TAYLOR * ALPHA_TAYLOR * mat.G * b * np.sqrt(rho) / 1e6
162
+
163
+
164
+ def delta_sigma_cutting(r_nm: float, f: float, gamma: float, G: float, b: float) -> float:
165
+ r = r_nm * 1e-9
166
+ if r <= 0 or f <= 0 or gamma <= 0:
167
+ return 0.0
168
+ T_line = 0.5 * G * b**2
169
+ return M_TAYLOR * (gamma / (2 * b)) * np.sqrt(3 * PI * f * r / T_line) / 1e6
170
+
171
+
172
+ def delta_sigma_orowan(r_nm: float, f: float, G: float, b: float) -> float:
173
+ r = r_nm * 1e-9
174
+ if r <= 0 or f <= 0:
175
+ return 0.0
176
+ factor = max(0.1, (2 * PI / (3 * f))**0.5 - 2)
177
+ lambda_eff = 2 * r * factor
178
+ log_term = np.log(max(2 * r / b, 2))
179
+ return 0.4 * M_TAYLOR * G * b / lambda_eff * log_term / 1e6
180
+
181
+
182
+ def delta_sigma_ppt(r_nm: float, f: float, gamma: float, mat: Material, A: float = 1.0) -> Tuple[float, str]:
183
+ if r_nm <= 0 or f <= 0:
184
+ return 0.0, 'None'
185
+ b = calc_burgers(mat.a, mat.structure)
186
+ d_cut = A * delta_sigma_cutting(r_nm, f, gamma, mat.G, b)
187
+ d_oro = A * delta_sigma_orowan(r_nm, f, mat.G, b)
188
+ return (d_cut, 'Cutting') if d_cut <= d_oro else (d_oro, 'Orowan')
189
+
190
+
191
+ def calc_sigma_y(
192
+ mat: Material,
193
+ T_K: float = 300.0,
194
+ c_wt_percent: float = 0.0,
195
+ k_ss: float = 0.0,
196
+ solute_type: Optional[Literal['interstitial', 'substitutional']] = None,
197
+ eps: float = 0.0,
198
+ rho_0: float = 0.0,
199
+ r_ppt_nm: float = 0.0,
200
+ f_ppt: float = 0.0,
201
+ gamma_apb: float = 0.0,
202
+ A_ppt: float = 1.0,
203
+ ) -> Dict[str, float | str]:
204
+ base = sigma_base_delta(mat, T_K)
205
+ ss = delta_sigma_ss(c_wt_percent, k_ss, solute_type)
206
+ wh = delta_sigma_taylor(eps, mat, rho_0) if (eps > 0 or rho_0 > 0) else 0.0
207
+ ppt, mech = delta_sigma_ppt(r_ppt_nm, f_ppt, gamma_apb, mat, A_ppt)
208
+ return {
209
+ 'sigma_y': base + ss + wh + ppt,
210
+ 'sigma_base': base,
211
+ 'delta_ss': ss,
212
+ 'delta_wh': wh,
213
+ 'delta_ppt': ppt,
214
+ 'ppt_mechanism': mech,
215
+ }
216
+
217
+ # ==============================================================================
218
+ # v4.1: α_s/α_t and τ/σ
219
+ # ==============================================================================
220
+
221
+ def get_bond_vectors(structure: str, c_a_ratio: float = 1.633):
222
+ bonds = []
223
+ if structure == 'BCC':
224
+ for i in (-1, 1):
225
+ for j in (-1, 1):
226
+ for k in (-1, 1):
227
+ v = np.array([i, j, k], dtype=float)
228
+ bonds.append(v / np.linalg.norm(v))
229
+ elif structure == 'FCC':
230
+ for i in (-1, 1):
231
+ for j in (-1, 1):
232
+ bonds.append(np.array([i, j, 0], dtype=float) / np.sqrt(2))
233
+ bonds.append(np.array([i, 0, j], dtype=float) / np.sqrt(2))
234
+ bonds.append(np.array([0, i, j], dtype=float) / np.sqrt(2))
235
+ elif structure == 'HCP':
236
+ # basal-plane
237
+ for i in range(6):
238
+ angle = i * PI / 3
239
+ bonds.append(np.array([np.cos(angle), np.sin(angle), 0.0]))
240
+ # out-of-plane
241
+ r_xy = 1.0 / np.sqrt(3)
242
+ z = c_a_ratio / 2
243
+ length = np.sqrt(r_xy**2 + z**2)
244
+ r_norm, z_norm = r_xy / length, z / length
245
+ for i in range(3):
246
+ angle = i * 2 * PI / 3 + PI / 6
247
+ bonds.append(np.array([r_norm * np.cos(angle), r_norm * np.sin(angle), z_norm]))
248
+ bonds.append(np.array([r_norm * np.cos(angle + PI), r_norm * np.sin(angle + PI), -z_norm]))
249
+ return bonds
250
+
251
+
252
+ def calc_alpha_tensile(bonds, tensile_dir: np.ndarray) -> float:
253
+ d = tensile_dir / np.linalg.norm(tensile_dir)
254
+ Z = len(bonds)
255
+ return sum(max(float(np.dot(b, d)), 0.0) for b in bonds) / Z
256
+
257
+
258
+ def calc_alpha_shear(bonds, n: np.ndarray, s: np.ndarray) -> float:
259
+ n = n / np.linalg.norm(n)
260
+ s = s / np.linalg.norm(s)
261
+ Z = len(bonds)
262
+ return sum(abs(float(np.dot(b, n))) * abs(float(np.dot(b, s))) for b in bonds) / Z
263
+
264
+
265
+ def get_slip_system(structure: str, variant: Optional[str] = None) -> Tuple[np.ndarray, np.ndarray]:
266
+ if structure == 'BCC':
267
+ v = variant or '112'
268
+ if v == '110':
269
+ return (np.array([1, 1, 0], dtype=float) / np.sqrt(2),
270
+ np.array([1, -1, 1], dtype=float) / np.sqrt(3))
271
+ return (np.array([1, 1, 2], dtype=float) / np.sqrt(6),
272
+ np.array([1, 1, -1], dtype=float) / np.sqrt(3))
273
+ if structure == 'FCC':
274
+ return (np.array([1, 1, 1], dtype=float) / np.sqrt(3),
275
+ np.array([1, -1, 0], dtype=float) / np.sqrt(2))
276
+ # HCP: basal (simplest)
277
+ return (np.array([0, 0, 1], dtype=float), np.array([1, 0, 0], dtype=float))
278
+
279
+
280
+ def calc_alpha_ratio(structure: str, c_a: float = 1.633, bcc_w110: float = DEFAULT_BCC_W110) -> Dict[str, float]:
281
+ bonds = get_bond_vectors(structure, c_a)
282
+ alpha_t = calc_alpha_tensile(bonds, T_REF[structure])
283
+
284
+ if structure == 'BCC':
285
+ n110, s110 = get_slip_system('BCC', '110')
286
+ n112, s112 = get_slip_system('BCC', '112')
287
+ a110 = calc_alpha_shear(bonds, n110, s110)
288
+ a112 = calc_alpha_shear(bonds, n112, s112)
289
+ alpha_s = bcc_w110 * a110 + (1.0 - bcc_w110) * a112
290
+ return {
291
+ 'alpha_t': alpha_t,
292
+ 'alpha_s': alpha_s,
293
+ 'ratio': alpha_s / alpha_t,
294
+ 'ratio_110': a110 / alpha_t,
295
+ 'ratio_112': a112 / alpha_t,
296
+ }
297
+
298
+ n, s = get_slip_system(structure)
299
+ alpha_s = calc_alpha_shear(bonds, n, s)
300
+ return {'alpha_t': alpha_t, 'alpha_s': alpha_s, 'ratio': alpha_s / alpha_t}
301
+
302
+
303
+ def calibrate_C_class() -> float:
304
+ fcc = calc_alpha_ratio('FCC')
305
+ return CU_TAU_SIGMA_TORSION / fcc['ratio']
306
+
307
+
308
+ C_CLASS_DEFAULT = calibrate_C_class()
309
+
310
+
311
+ def tau_over_sigma(mat: Material,
312
+ C_class: float = C_CLASS_DEFAULT,
313
+ bcc_w110: float = DEFAULT_BCC_W110,
314
+ apply_C_class_hcp: bool = False) -> float:
315
+ al = calc_alpha_ratio(mat.structure, mat.c_a, bcc_w110)
316
+ c_cls = C_class if (mat.structure != 'HCP' or apply_C_class_hcp) else 1.0
317
+ return al['ratio'] * c_cls * mat.T_twin * mat.A_texture
318
+
319
+
320
+ def sigma_c_over_sigma_t(mat: Material) -> float:
321
+ return mat.R_comp
322
+
323
+
324
+ def yield_by_mode(
325
+ mat: Material,
326
+ sigma_y_tension_MPa: float,
327
+ mode: Literal['tensile', 'compression', 'shear'] = 'tensile',
328
+ C_class: float = C_CLASS_DEFAULT,
329
+ bcc_w110: float = DEFAULT_BCC_W110,
330
+ apply_C_class_hcp: bool = False,
331
+ ) -> Tuple[float, Dict[str, float]]:
332
+ """Return yield level [MPa] in the requested loading mode + diagnostics."""
333
+ tau_sig = tau_over_sigma(mat, C_class=C_class, bcc_w110=bcc_w110, apply_C_class_hcp=apply_C_class_hcp)
334
+ R = sigma_c_over_sigma_t(mat)
335
+ sigma_y_comp = sigma_y_tension_MPa * R
336
+ tau_y = sigma_y_tension_MPa * tau_sig
337
+
338
+ if mode == 'tensile':
339
+ y = sigma_y_tension_MPa
340
+ elif mode == 'compression':
341
+ y = sigma_y_comp
342
+ else:
343
+ y = tau_y
344
+
345
+ return y, {
346
+ 'tau_over_sigma': tau_sig,
347
+ 'sigma_c_over_sigma_t': R,
348
+ 'sigma_y_tension': sigma_y_tension_MPa,
349
+ 'sigma_y_compression': sigma_y_comp,
350
+ 'tau_y': tau_y,
351
+ }
352
+
353
+ # ==============================================================================
354
+ # v6.8 δ-fatigue damage model
355
+ # ==============================================================================
356
+
357
+ FATIGUE_CLASS_PRESET = {
358
+ 'BCC': {'r_th': 0.65, 'n': 10.0},
359
+ 'FCC': {'r_th': 0.02, 'n': 7.0},
360
+ 'HCP': {'r_th': 0.20, 'n': 9.0},
361
+ }
362
+
363
+ # A_int from δ parameters (normalized to Fe)
364
+ A_INT_DB = {
365
+ 'Fe': 1.00,
366
+ 'Cu': 1.41,
367
+ 'Al': 0.71,
368
+ 'Ni': 1.37,
369
+ 'W': 0.85,
370
+ 'Ti': 1.10,
371
+ 'Mg': 0.60,
372
+ 'Zn': 0.75,
373
+ 'Au': 1.00,
374
+ 'Ag': 1.00,
375
+ }
376
+
377
+
378
+ def lambda_from_damage(D: float) -> float:
379
+ """Λ(D)=D/(1-D)."""
380
+ if D <= 0:
381
+ return 0.0
382
+ if D >= 1:
383
+ return float('inf')
384
+ return D / (1.0 - D)
385
+
386
+
387
+ def solve_N_fail(
388
+ r: float,
389
+ r_th: float,
390
+ n: float,
391
+ A_eff: float,
392
+ D0: float = 0.0,
393
+ D_fail: float = 0.5,
394
+ ) -> float:
395
+ """Closed-form cycles to reach D_fail for constant r."""
396
+ if r <= r_th:
397
+ return float('inf')
398
+ if A_eff <= 0:
399
+ return float('inf')
400
+ rate = A_eff * (r - r_th) ** n
401
+ if rate <= 0:
402
+ return float('inf')
403
+ return (D_fail - D0) / rate
404
+
405
+
406
+ def fatigue_life_const_amp(
407
+ mat: Material,
408
+ sigma_a_MPa: float,
409
+ sigma_y_tension_MPa: float,
410
+ A_ext: float,
411
+ mode: Literal['tensile', 'compression', 'shear'] = 'tensile',
412
+ D0: float = 0.0,
413
+ D_fail: float = 0.5,
414
+ C_class: float = C_CLASS_DEFAULT,
415
+ bcc_w110: float = DEFAULT_BCC_W110,
416
+ apply_C_class_hcp: bool = False,
417
+ ) -> Dict[str, float | str]:
418
+ """Fatigue life under constant amplitude for the chosen loading mode."""
419
+
420
+ preset = FATIGUE_CLASS_PRESET.get(mat.structure, FATIGUE_CLASS_PRESET['FCC'])
421
+ r_th = preset['r_th']
422
+ n = preset['n']
423
+
424
+ A_int = A_INT_DB.get(mat.name, 1.0)
425
+ A_eff = A_int * A_ext
426
+
427
+ y_mode, diag = yield_by_mode(
428
+ mat,
429
+ sigma_y_tension_MPa=sigma_y_tension_MPa,
430
+ mode=mode,
431
+ C_class=C_class,
432
+ bcc_w110=bcc_w110,
433
+ apply_C_class_hcp=apply_C_class_hcp,
434
+ )
435
+
436
+ # In shear mode, sigma_a_MPa is interpreted as τ_a [MPa]
437
+ r = sigma_a_MPa / y_mode if y_mode > 0 else float('inf')
438
+
439
+ N_fail = solve_N_fail(r, r_th, n, A_eff, D0=D0, D_fail=D_fail)
440
+
441
+ return {
442
+ 'mode': mode,
443
+ 'amp_input_MPa': sigma_a_MPa,
444
+ 'yield_mode_MPa': y_mode,
445
+ 'r': r,
446
+ 'r_th': r_th,
447
+ 'n': n,
448
+ 'A_int': A_int,
449
+ 'A_ext': A_ext,
450
+ 'A_eff': A_eff,
451
+ 'D0': D0,
452
+ 'D_fail': D_fail,
453
+ 'Lambda_fail': lambda_from_damage(D_fail),
454
+ **diag,
455
+ 'N_fail': N_fail,
456
+ 'log10_N_fail': (np.log10(N_fail) if np.isfinite(N_fail) and N_fail > 0 else float('inf')),
457
+ }
458
+
459
+
460
+ def generate_sn_curve(
461
+ mat: Material,
462
+ sigma_y_tension_MPa: float,
463
+ A_ext: float,
464
+ sigmas_MPa: np.ndarray,
465
+ mode: Literal['tensile', 'compression', 'shear'] = 'tensile',
466
+ D_fail: float = 0.5,
467
+ C_class: float = C_CLASS_DEFAULT,
468
+ bcc_w110: float = DEFAULT_BCC_W110,
469
+ apply_C_class_hcp: bool = False,
470
+ ) -> np.ndarray:
471
+ Ns = []
472
+ for s in sigmas_MPa:
473
+ out = fatigue_life_const_amp(
474
+ mat,
475
+ sigma_a_MPa=float(s),
476
+ sigma_y_tension_MPa=sigma_y_tension_MPa,
477
+ A_ext=A_ext,
478
+ mode=mode,
479
+ D_fail=D_fail,
480
+ C_class=C_class,
481
+ bcc_w110=bcc_w110,
482
+ apply_C_class_hcp=apply_C_class_hcp,
483
+ )
484
+ Ns.append(out['N_fail'])
485
+ return np.array(Ns, dtype=float)
486
+
487
+ # ==============================================================================
488
+ # CLI
489
+ # ==============================================================================
490
+
491
+ def cmd_point(args: argparse.Namespace) -> None:
492
+ mat0 = MATERIALS[args.metal]
493
+ mat = replace(
494
+ mat0,
495
+ A_texture=float(args.A_texture),
496
+ T_twin=(float(args.T_twin) if args.T_twin is not None else mat0.T_twin),
497
+ R_comp=(float(args.R_comp) if args.R_comp is not None else mat0.R_comp),
498
+ c_a=float(args.c_a) if args.c_a is not None else mat0.c_a,
499
+ )
500
+
501
+ y = calc_sigma_y(
502
+ mat,
503
+ T_K=args.T_K,
504
+ c_wt_percent=args.c_wt,
505
+ k_ss=args.k_ss,
506
+ solute_type=args.solute_type,
507
+ eps=args.eps,
508
+ rho_0=args.rho_0,
509
+ r_ppt_nm=args.r_ppt_nm,
510
+ f_ppt=args.f_ppt,
511
+ gamma_apb=args.gamma_apb,
512
+ A_ppt=args.A_ppt,
513
+ )
514
+
515
+ # Fatigue (optional)
516
+ if args.sigma_a is not None:
517
+ out = fatigue_life_const_amp(
518
+ mat,
519
+ sigma_a_MPa=float(args.sigma_a),
520
+ sigma_y_tension_MPa=float(y['sigma_y']),
521
+ A_ext=float(args.A_ext),
522
+ mode=args.mode,
523
+ D_fail=args.D_fail,
524
+ C_class=args.C_class,
525
+ bcc_w110=args.bcc_w110,
526
+ apply_C_class_hcp=args.apply_C_class_hcp,
527
+ )
528
+ else:
529
+ out = None
530
+
531
+ print("=" * 88)
532
+ print(f"v6.9b point | metal={mat.name} ({mat.structure}) | mode={args.mode}")
533
+ print("=" * 88)
534
+
535
+ # Yield summary
536
+ y_mode, diag = yield_by_mode(
537
+ mat,
538
+ sigma_y_tension_MPa=float(y['sigma_y']),
539
+ mode=args.mode,
540
+ C_class=args.C_class,
541
+ bcc_w110=args.bcc_w110,
542
+ apply_C_class_hcp=args.apply_C_class_hcp,
543
+ )
544
+
545
+ print("[Yield v5.0]")
546
+ print(f" σ_base = {y['sigma_base']:.2f} MPa")
547
+ print(f" Δσ_ss = {y['delta_ss']:.2f} MPa")
548
+ print(f" Δσ_wh = {y['delta_wh']:.2f} MPa (rho_0={args.rho_0:.2e})")
549
+ print(f" Δσ_ppt = {y['delta_ppt']:.2f} MPa ({y['ppt_mechanism']})")
550
+ print(f" σ_y(t) = {y['sigma_y']:.2f} MPa")
551
+ print("[Class factors v4.1]")
552
+ print(f" C_class = {args.C_class:.4f} (apply to HCP: {args.apply_C_class_hcp})")
553
+ print(f" bcc_w110 = {args.bcc_w110:.3f}")
554
+ print(f" A_texture= {mat.A_texture:.3f}")
555
+ print(f" T_twin = {mat.T_twin:.3f}")
556
+ print(f" R_comp = {mat.R_comp:.3f} (σ_c/σ_t)")
557
+ print(f" τ/σ_pred = {diag['tau_over_sigma']:.4f}")
558
+ print(f" τ_y = {diag['tau_y']:.2f} MPa")
559
+ print(f" σ_y(c) = {diag['sigma_y_compression']:.2f} MPa")
560
+ print(f" Yield(mode) = {y_mode:.2f} MPa")
561
+
562
+ if out is None:
563
+ return
564
+
565
+ print("\n[Fatigue v6.8]")
566
+ unit = "MPa" if args.mode != 'shear' else "MPa (τ_a)"
567
+ print(f" amplitude input = {out['amp_input_MPa']:.2f} {unit}")
568
+ print(f" r = amp / yield(mode) = {out['r']:.5f}")
569
+ print(f" r_th={out['r_th']:.3f}, n={out['n']:.1f}")
570
+ print(f" A_int={out['A_int']:.3f}, A_ext={out['A_ext']:.3e} => A_eff={out['A_eff']:.3e}")
571
+ print(f" D_fail={out['D_fail']:.3f} (Λ_fail={out['Lambda_fail']:.3f})")
572
+ if np.isfinite(out['N_fail']):
573
+ print(f" N_fail = {out['N_fail']:.3e} cycles (log10={out['log10_N_fail']:.3f})")
574
+ else:
575
+ print(" N_fail = inf (fatigue limit region)")
576
+
577
+
578
+ def cmd_calibrate(args: argparse.Namespace) -> None:
579
+ """Calibrate A_ext from one (σ_a, N_fail) point."""
580
+ mat0 = MATERIALS[args.metal]
581
+ mat = replace(
582
+ mat0,
583
+ A_texture=float(args.A_texture),
584
+ T_twin=(float(args.T_twin) if args.T_twin is not None else mat0.T_twin),
585
+ R_comp=(float(args.R_comp) if args.R_comp is not None else mat0.R_comp),
586
+ c_a=float(args.c_a) if args.c_a is not None else mat0.c_a,
587
+ )
588
+
589
+ y = calc_sigma_y(
590
+ mat,
591
+ T_K=args.T_K,
592
+ c_wt_percent=args.c_wt,
593
+ k_ss=args.k_ss,
594
+ solute_type=args.solute_type,
595
+ eps=args.eps,
596
+ rho_0=args.rho_0,
597
+ r_ppt_nm=args.r_ppt_nm,
598
+ f_ppt=args.f_ppt,
599
+ gamma_apb=args.gamma_apb,
600
+ A_ppt=args.A_ppt,
601
+ )
602
+
603
+ preset = FATIGUE_CLASS_PRESET.get(mat.structure, FATIGUE_CLASS_PRESET['FCC'])
604
+ r_th, n = preset['r_th'], preset['n']
605
+
606
+ y_mode, _ = yield_by_mode(
607
+ mat,
608
+ sigma_y_tension_MPa=float(y['sigma_y']),
609
+ mode=args.mode,
610
+ C_class=args.C_class,
611
+ bcc_w110=args.bcc_w110,
612
+ apply_C_class_hcp=args.apply_C_class_hcp,
613
+ )
614
+
615
+ r = args.sigma_a / y_mode
616
+
617
+ if r <= r_th:
618
+ raise SystemExit("Calibration point is below r_th (fatigue limit); choose a point with finite life.")
619
+
620
+ A_int = A_INT_DB.get(mat.name, 1.0)
621
+ D_fail = args.D_fail
622
+ D0 = 0.0
623
+
624
+ rate_needed = (D_fail - D0) / args.N_fail
625
+ A_eff = rate_needed / ((r - r_th) ** n)
626
+ A_ext = A_eff / A_int
627
+
628
+ print("=" * 88)
629
+ print("v6.9b calibrate A_ext")
630
+ print("=" * 88)
631
+ print(f"metal={mat.name} ({mat.structure}), mode={args.mode}")
632
+ print(f"σ_y(tension) = {y['sigma_y']:.3f} MPa")
633
+ print(f"yield(mode) = {y_mode:.3f} MPa")
634
+ print(f"amp = {args.sigma_a:.3f} MPa {'(τ_a)' if args.mode=='shear' else ''}")
635
+ print(f"r={r:.6f}, r_th={r_th:.3f}, n={n:.2f}")
636
+ print(f"D_fail={D_fail:.3f}, N_fail={args.N_fail:.3e}")
637
+ print(f"A_int={A_int:.3f} => A_ext={A_ext:.3e} (A_eff={A_eff:.3e})")
638
+
639
+
640
+ def cmd_sn(args: argparse.Namespace) -> None:
641
+ mat0 = MATERIALS[args.metal]
642
+ mat = replace(
643
+ mat0,
644
+ A_texture=float(args.A_texture),
645
+ T_twin=(float(args.T_twin) if args.T_twin is not None else mat0.T_twin),
646
+ R_comp=(float(args.R_comp) if args.R_comp is not None else mat0.R_comp),
647
+ c_a=float(args.c_a) if args.c_a is not None else mat0.c_a,
648
+ )
649
+
650
+ y = calc_sigma_y(
651
+ mat,
652
+ T_K=args.T_K,
653
+ c_wt_percent=args.c_wt,
654
+ k_ss=args.k_ss,
655
+ solute_type=args.solute_type,
656
+ eps=args.eps,
657
+ rho_0=args.rho_0,
658
+ r_ppt_nm=args.r_ppt_nm,
659
+ f_ppt=args.f_ppt,
660
+ gamma_apb=args.gamma_apb,
661
+ A_ppt=args.A_ppt,
662
+ )
663
+
664
+ sigmas = np.linspace(args.sigma_min, args.sigma_max, args.num)
665
+ Ns = generate_sn_curve(
666
+ mat,
667
+ sigma_y_tension_MPa=float(y['sigma_y']),
668
+ A_ext=args.A_ext,
669
+ sigmas_MPa=sigmas,
670
+ mode=args.mode,
671
+ D_fail=args.D_fail,
672
+ C_class=args.C_class,
673
+ bcc_w110=args.bcc_w110,
674
+ apply_C_class_hcp=args.apply_C_class_hcp,
675
+ )
676
+
677
+ print("=" * 88)
678
+ print(f"v6.9b S-N | metal={mat.name} ({mat.structure}) | mode={args.mode}")
679
+ print("=" * 88)
680
+ print(f"σ_y(tension)={y['sigma_y']:.3f} MPa | A_ext={args.A_ext:.3e} | D_fail={args.D_fail:.3f}")
681
+
682
+ header_amp = 'sigma_a_MPa' if args.mode != 'shear' else 'tau_a_MPa'
683
+ print(f"{header_amp:>12} {'N_fail':>14} {'log10N':>10} {'r':>10} {'note':>10}")
684
+ for s, N in zip(sigmas, Ns):
685
+ y_mode, _ = yield_by_mode(
686
+ mat,
687
+ sigma_y_tension_MPa=float(y['sigma_y']),
688
+ mode=args.mode,
689
+ C_class=args.C_class,
690
+ bcc_w110=args.bcc_w110,
691
+ apply_C_class_hcp=args.apply_C_class_hcp,
692
+ )
693
+ r = s / y_mode
694
+ if np.isfinite(N):
695
+ print(f"{s:12.2f} {N:14.3e} {np.log10(N):10.3f} {r:10.4f} {'':>10}")
696
+ else:
697
+ print(f"{s:12.2f} {'inf':>14} {'inf':>10} {r:10.4f} {'limit':>10}")
698
+
699
+
700
+ def build_parser() -> argparse.ArgumentParser:
701
+ p = argparse.ArgumentParser(description='v6.9b: v5.0 yield × v6.8 fatigue + τ/σ, twins, texture')
702
+ sub = p.add_subparsers(dest='cmd', required=True)
703
+
704
+ def add_common(sp: argparse.ArgumentParser):
705
+ sp.add_argument('--metal', required=True, choices=sorted(MATERIALS.keys()))
706
+ sp.add_argument('--T_K', type=float, default=300.0)
707
+ sp.add_argument('--c_wt', type=float, default=0.0, help='solute wt%% (e.g., 0.10 for 0.10 wt%%)')
708
+ sp.add_argument('--k_ss', type=float, default=0.0, help='solid-solution k [MPa/(wt%%)^n]')
709
+ sp.add_argument('--solute_type', choices=['interstitial', 'substitutional'], default=None)
710
+ sp.add_argument('--eps', type=float, default=0.0, help='monotonic strain for work hardening')
711
+ sp.add_argument('--rho_0', type=float, default=0.0, help='initial dislocation density [m^-2]')
712
+ sp.add_argument('--r_ppt_nm', type=float, default=0.0)
713
+ sp.add_argument('--f_ppt', type=float, default=0.0)
714
+ sp.add_argument('--gamma_apb', type=float, default=0.0)
715
+ sp.add_argument('--A_ppt', type=float, default=1.0)
716
+
717
+ # v4.1 class factors
718
+ sp.add_argument('--A_texture', type=float, default=1.0)
719
+ sp.add_argument('--T_twin', type=float, default=None, help='override T_twin')
720
+ sp.add_argument('--R_comp', type=float, default=None, help='override σ_c/σ_t')
721
+ sp.add_argument('--c_a', type=float, default=None, help='override HCP c/a')
722
+ sp.add_argument('--C_class', type=float, default=C_CLASS_DEFAULT)
723
+ sp.add_argument('--bcc_w110', type=float, default=DEFAULT_BCC_W110)
724
+ sp.add_argument('--apply_C_class_hcp', action='store_true')
725
+
726
+ def add_fatigue(sp: argparse.ArgumentParser):
727
+ sp.add_argument('--mode', choices=['tensile', 'compression', 'shear'], default='tensile')
728
+ sp.add_argument('--A_ext', type=float, default=2.46e-4, help='external factor (1-point calibration)')
729
+ sp.add_argument('--D_fail', type=float, default=0.5)
730
+
731
+ sp_point = sub.add_parser('point', help='compute yield (+ optional fatigue life)')
732
+ add_common(sp_point)
733
+ add_fatigue(sp_point)
734
+ sp_point.add_argument('--sigma_a', type=float, default=None, help='amplitude [MPa]. If mode=shear, this is τ_a.')
735
+ sp_point.set_defaults(func=cmd_point)
736
+
737
+ sp_cal = sub.add_parser('calibrate', help='calibrate A_ext from one fatigue point')
738
+ add_common(sp_cal)
739
+ add_fatigue(sp_cal)
740
+ sp_cal.add_argument('--sigma_a', type=float, required=True, help='amplitude [MPa]. If mode=shear, τ_a.')
741
+ sp_cal.add_argument('--N_fail', type=float, required=True)
742
+ sp_cal.set_defaults(func=cmd_calibrate)
743
+
744
+ sp_sn = sub.add_parser('sn', help='generate S-N table')
745
+ add_common(sp_sn)
746
+ add_fatigue(sp_sn)
747
+ sp_sn.add_argument('--sigma_min', type=float, required=True)
748
+ sp_sn.add_argument('--sigma_max', type=float, required=True)
749
+ sp_sn.add_argument('--num', type=int, default=25)
750
+ sp_sn.set_defaults(func=cmd_sn)
751
+
752
+ return p
753
+
754
+
755
+ def main() -> None:
756
+ parser = build_parser()
757
+ args = parser.parse_args()
758
+ args.func(args)
759
+
760
+
761
+ if __name__ == '__main__':
762
+ main()