midas-defect 0.1.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.
- midas_defect/__init__.py +16 -0
- midas_defect/asterism_fit.py +407 -0
- midas_defect/attribution.py +130 -0
- midas_defect/bragg_diffuse.py +207 -0
- midas_defect/burgers_population.py +170 -0
- midas_defect/cli/__init__.py +0 -0
- midas_defect/cli/asterism.py +197 -0
- midas_defect/cli/polytype.py +131 -0
- midas_defect/cli/rods.py +208 -0
- midas_defect/contrast_factor.py +484 -0
- midas_defect/contrast_factor_hex.py +337 -0
- midas_defect/data_io.py +231 -0
- midas_defect/defect_tests.py +439 -0
- midas_defect/delta_pdf.py +619 -0
- midas_defect/detector_qc.py +113 -0
- midas_defect/examples/__init__.py +0 -0
- midas_defect/examples/copland_fullres_validate.py +72 -0
- midas_defect/examples/demk_fcc_end_to_end.py +170 -0
- midas_defect/examples/tutorial_size_strain_mosaic.py +99 -0
- midas_defect/forward_sim.py +435 -0
- midas_defect/geometry.py +449 -0
- midas_defect/honesty.py +103 -0
- midas_defect/intensity_budget.py +111 -0
- midas_defect/lattice.py +212 -0
- midas_defect/peakshape.py +175 -0
- midas_defect/rod_detect.py +596 -0
- midas_defect/seed_index.py +781 -0
- midas_defect/subgrain.py +166 -0
- midas_defect/types.py +148 -0
- midas_defect/viz.py +207 -0
- midas_defect/williamson_hall.py +362 -0
- midas_defect-0.1.0.dist-info/METADATA +148 -0
- midas_defect-0.1.0.dist-info/RECORD +37 -0
- midas_defect-0.1.0.dist-info/WHEEL +5 -0
- midas_defect-0.1.0.dist-info/entry_points.txt +5 -0
- midas_defect-0.1.0.dist-info/licenses/LICENSE +31 -0
- midas_defect-0.1.0.dist-info/top_level.txt +1 -0
midas_defect/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""midas_defect — phase-agnostic diffuse-scattering defect metrology for FF-HEDM.
|
|
2
|
+
|
|
3
|
+
Quantifies the diffuse field around and between the Bragg peaks of an indexed
|
|
4
|
+
FF-HEDM dataset (asterism, fault rods, the full intensity budget, defect /
|
|
5
|
+
selection-rule tests) to produce a per-grain defect inventory. Driven by a
|
|
6
|
+
`midas_hkls.Crystal` + a `Geometry`; nothing is hard-wired to a phase.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
# `geometry.pixel_to_qlab` reuses `midas_transforms.apply_tilt_distortion`, which
|
|
12
|
+
# links a second OpenMP runtime alongside torch's. Allow the duplicate at import
|
|
13
|
+
# time (before either runtime initializes) so callers don't hit a hard crash.
|
|
14
|
+
os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE")
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
"""P2 — Per-hkl asterism fitting.
|
|
2
|
+
|
|
3
|
+
Given an average crystal orientation U, for every predicted (hkl) Bragg
|
|
4
|
+
position fit a 3-D anisotropic Gaussian (in sample-frame q-space) to the
|
|
5
|
+
voxels in a crop around the prediction. The fitted covariance Σ encodes
|
|
6
|
+
the orientation-spread (asterism) at that hkl; aggregating across hkls
|
|
7
|
+
gives a discrete ODF estimate.
|
|
8
|
+
|
|
9
|
+
Why sample-frame q-space? Once we map a voxel from `(detY, detZ, ω)` to a
|
|
10
|
+
sample-frame q-vector, a perfect crystal's reflection sits at a single
|
|
11
|
+
fixed q — independent of ω. An asterized reflection forms a 3-D cloud
|
|
12
|
+
around that q. Fitting in q-space is the most direct measurement of
|
|
13
|
+
orientation spread.
|
|
14
|
+
|
|
15
|
+
Pipeline
|
|
16
|
+
--------
|
|
17
|
+
1. Predict the q_sample position of every allowed (hkl) up to qmax via
|
|
18
|
+
`q_sample(hkl) = U @ g_cry(hkl, a, c)`.
|
|
19
|
+
2. For each predicted q0, gather voxels inside a crop box of half-extent
|
|
20
|
+
`crop_halfwidth + crop_q_scale * |q0|` (asterism scales with |q|).
|
|
21
|
+
3. Fit a 3-D anisotropic Gaussian
|
|
22
|
+
I(q) = A * exp( -1/2 * (q-q0)ᵀ Σ⁻¹ (q-q0) ) + baseline
|
|
23
|
+
with `Σ⁻¹ = L L.T` (Cholesky, 6 params) for positive-definite
|
|
24
|
+
guarantee. Adam optimizer on the weighted-least-squares loss.
|
|
25
|
+
4. Eigendecompose Σ to report principal half-widths and axis directions.
|
|
26
|
+
|
|
27
|
+
Differentiability
|
|
28
|
+
-----------------
|
|
29
|
+
* Every fitted parameter is a torch tensor with `requires_grad=True`.
|
|
30
|
+
* Cholesky parameterization keeps Σ ≻ 0 under autograd without projection.
|
|
31
|
+
* `predict_q_from_U` (from `seed_index`) gives the q0 prediction in a
|
|
32
|
+
differentiable way w.r.t. (U, a, c), so the same fit can later be
|
|
33
|
+
composed with a refinement of U or (a, c).
|
|
34
|
+
|
|
35
|
+
MIDAS reuses
|
|
36
|
+
------------
|
|
37
|
+
* `midas_stress.orientation` (when we eventually express axes in crystal frame)
|
|
38
|
+
* `midas_hkls.lattice_torch.d_spacing` (via `lattice.q_inv_of_hkl_torch`)
|
|
39
|
+
* `seed_index.predict_q_from_U` to keep the prediction path canonical.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
from dataclasses import dataclass, field
|
|
45
|
+
from typing import List, Optional, Sequence, Tuple, Union
|
|
46
|
+
|
|
47
|
+
import math
|
|
48
|
+
import numpy as np
|
|
49
|
+
import torch
|
|
50
|
+
|
|
51
|
+
from midas_transforms.device import resolve_device, resolve_dtype
|
|
52
|
+
|
|
53
|
+
from .lattice import Shell, cual2_crystal, tetragonal_shells
|
|
54
|
+
from .seed_index import predict_q_from_U
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
"AsterismFit",
|
|
59
|
+
"fit_asterism_patches",
|
|
60
|
+
"predict_hkl_positions",
|
|
61
|
+
"fit_single_patch",
|
|
62
|
+
"build_bragg_residual_intensity",
|
|
63
|
+
"strain_tensor_from_centroids",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
# Output dataclasses
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class AsterismFit:
|
|
73
|
+
"""Single-hkl 3-D Gaussian fit result."""
|
|
74
|
+
hkl: Tuple[int, int, int]
|
|
75
|
+
q_pred: np.ndarray # (3,) predicted q in sample frame
|
|
76
|
+
q_fit: np.ndarray # (3,) fitted centre
|
|
77
|
+
amplitude: float # peak height
|
|
78
|
+
baseline: float # constant offset
|
|
79
|
+
sigma_eig: np.ndarray # (3,) principal half-widths (sqrt eigenvalues of Σ)
|
|
80
|
+
sigma_axes: np.ndarray # (3, 3) principal axes (columns = eigenvectors of Σ)
|
|
81
|
+
integrated_intensity: float # Σ I over the crop
|
|
82
|
+
n_voxels: int # voxels in the crop
|
|
83
|
+
final_loss: float
|
|
84
|
+
converged: bool # loss decreased monotonically
|
|
85
|
+
|
|
86
|
+
def dominant_axis(self) -> np.ndarray:
|
|
87
|
+
"""Eigenvector of Σ with the largest eigenvalue (broadest asterism direction)."""
|
|
88
|
+
return self.sigma_axes[:, np.argmax(self.sigma_eig)]
|
|
89
|
+
|
|
90
|
+
def isotropy(self) -> float:
|
|
91
|
+
"""Ratio min(σ)/max(σ); 1.0 = perfectly spherical, near 0 = needle-like."""
|
|
92
|
+
sg = np.sort(self.sigma_eig)
|
|
93
|
+
return float(sg[0] / max(sg[-1], 1e-30))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# Forward prediction
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
def predict_hkl_positions(
|
|
101
|
+
U: np.ndarray, a: float, c: float, *,
|
|
102
|
+
q_max_inv_A: float = 10.0,
|
|
103
|
+
crystal=None,
|
|
104
|
+
) -> Tuple[np.ndarray, List[Tuple[int, int, int]]]:
|
|
105
|
+
"""Return predicted sample-frame q-vectors for every allowed (hkl).
|
|
106
|
+
|
|
107
|
+
Returns (q_pred (N, 3), [hkl_i, ...]).
|
|
108
|
+
"""
|
|
109
|
+
if crystal is None:
|
|
110
|
+
crystal = cual2_crystal(a=a, c=c)
|
|
111
|
+
shells = tetragonal_shells(crystal, q_max_inv_A=q_max_inv_A)
|
|
112
|
+
hkls: List[Tuple[int, int, int]] = []
|
|
113
|
+
g_cry = []
|
|
114
|
+
twopi = 2.0 * math.pi
|
|
115
|
+
for s in shells:
|
|
116
|
+
for hkl in s.hkls:
|
|
117
|
+
hkls.append(hkl)
|
|
118
|
+
g_cry.append([twopi * hkl[0] / a,
|
|
119
|
+
twopi * hkl[1] / a,
|
|
120
|
+
twopi * hkl[2] / c])
|
|
121
|
+
# And the centro-symmetric partner (Friedel pair) -- both light up
|
|
122
|
+
hkls.append((-hkl[0], -hkl[1], -hkl[2]))
|
|
123
|
+
g_cry.append([-twopi * hkl[0] / a,
|
|
124
|
+
-twopi * hkl[1] / a,
|
|
125
|
+
-twopi * hkl[2] / c])
|
|
126
|
+
g_cry_np = np.asarray(g_cry, dtype=np.float64) # (N, 3)
|
|
127
|
+
q_pred = (U @ g_cry_np.T).T # (N, 3)
|
|
128
|
+
return q_pred, hkls
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
# Differentiable single-patch fit
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
def _sigma_from_cholesky(L_params: torch.Tensor) -> torch.Tensor:
|
|
136
|
+
"""Build a positive-definite Σ from 6 Cholesky params.
|
|
137
|
+
|
|
138
|
+
`L_params` is (..., 6) carrying (L11, L21, L22, L31, L32, L33).
|
|
139
|
+
Returns Σ = (L L.T)^-1 -- but we actually fit Σ⁻¹ = L L.T to keep
|
|
140
|
+
the math diff-friendly (Σ⁻¹ ≻ 0 ⟺ Σ ≻ 0).
|
|
141
|
+
|
|
142
|
+
Diagonals are softplus-ed to stay positive.
|
|
143
|
+
"""
|
|
144
|
+
softplus = torch.nn.functional.softplus
|
|
145
|
+
L = torch.zeros(*L_params.shape[:-1], 3, 3,
|
|
146
|
+
dtype=L_params.dtype, device=L_params.device)
|
|
147
|
+
L[..., 0, 0] = softplus(L_params[..., 0])
|
|
148
|
+
L[..., 1, 0] = L_params[..., 1]
|
|
149
|
+
L[..., 1, 1] = softplus(L_params[..., 2])
|
|
150
|
+
L[..., 2, 0] = L_params[..., 3]
|
|
151
|
+
L[..., 2, 1] = L_params[..., 4]
|
|
152
|
+
L[..., 2, 2] = softplus(L_params[..., 5])
|
|
153
|
+
Sigma_inv = L @ L.transpose(-1, -2)
|
|
154
|
+
return Sigma_inv
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def fit_single_patch(
|
|
158
|
+
q_patch: torch.Tensor, # (M, 3) voxel q-vectors
|
|
159
|
+
I_patch: torch.Tensor, # (M,)
|
|
160
|
+
q_init: torch.Tensor, # (3,)
|
|
161
|
+
*,
|
|
162
|
+
sigma_init: float = 0.05, # initial 1-σ of Gaussian (1/Å)
|
|
163
|
+
n_steps: int = 200,
|
|
164
|
+
lr: float = 1e-2,
|
|
165
|
+
loss_kind: str = "lsq", # "lsq" | "sqrt_w" | "poisson"
|
|
166
|
+
return_history: bool = False,
|
|
167
|
+
) -> dict:
|
|
168
|
+
"""Fit a 3-D anisotropic Gaussian + baseline to a patch.
|
|
169
|
+
|
|
170
|
+
All tensors must be on the same device. Returns a dict with the fitted
|
|
171
|
+
parameters and convergence info.
|
|
172
|
+
"""
|
|
173
|
+
if q_patch.shape[0] < 6:
|
|
174
|
+
raise ValueError(f"need at least 6 voxels to fit; got {q_patch.shape[0]}")
|
|
175
|
+
|
|
176
|
+
dtype = q_patch.dtype
|
|
177
|
+
device = q_patch.device
|
|
178
|
+
|
|
179
|
+
# parameters
|
|
180
|
+
q0 = q_init.detach().clone().to(dtype=dtype, device=device).requires_grad_(True)
|
|
181
|
+
log_A = torch.log(I_patch.max().clamp_min(1.0)).detach().clone().requires_grad_(True)
|
|
182
|
+
log_baseline = torch.log(
|
|
183
|
+
(I_patch.median() + 1.0).clamp_min(1.0)
|
|
184
|
+
).detach().clone().requires_grad_(True)
|
|
185
|
+
# initial Σ⁻¹ = (1/sigma_init²) I → L11 = L22 = L33 = 1/sigma_init, off-diag=0
|
|
186
|
+
diag_init = 1.0 / float(sigma_init)
|
|
187
|
+
# softplus_inv: x = log(exp(x) - 1) for stable initialization
|
|
188
|
+
softplus_inv = math.log(math.exp(diag_init) - 1.0)
|
|
189
|
+
L_params = torch.tensor(
|
|
190
|
+
[softplus_inv, 0.0, softplus_inv, 0.0, 0.0, softplus_inv],
|
|
191
|
+
dtype=dtype, device=device,
|
|
192
|
+
).requires_grad_(True)
|
|
193
|
+
|
|
194
|
+
opt = torch.optim.Adam([q0, log_A, log_baseline, L_params], lr=lr)
|
|
195
|
+
history = []
|
|
196
|
+
for step in range(n_steps):
|
|
197
|
+
opt.zero_grad()
|
|
198
|
+
Sigma_inv = _sigma_from_cholesky(L_params)
|
|
199
|
+
delta = q_patch - q0
|
|
200
|
+
quad = (delta @ Sigma_inv * delta).sum(dim=-1)
|
|
201
|
+
pred = torch.exp(log_A) * torch.exp(-0.5 * quad) + torch.exp(log_baseline)
|
|
202
|
+
if loss_kind == "lsq":
|
|
203
|
+
loss = ((I_patch - pred) ** 2).sum()
|
|
204
|
+
elif loss_kind == "sqrt_w":
|
|
205
|
+
# Poisson-style weighting via sqrt(I) → relative residuals;
|
|
206
|
+
# increases the influence of the diffuse wings on the fit.
|
|
207
|
+
w = 1.0 / torch.sqrt(I_patch.clamp_min(1.0))
|
|
208
|
+
loss = (((I_patch - pred) * w) ** 2).sum()
|
|
209
|
+
elif loss_kind == "poisson":
|
|
210
|
+
# Negative log Poisson likelihood (drop constant log-factorial).
|
|
211
|
+
# pred is guaranteed > 0 because baseline = exp(log_baseline) > 0.
|
|
212
|
+
loss = (pred - I_patch * torch.log(pred.clamp_min(1e-30))).sum()
|
|
213
|
+
else:
|
|
214
|
+
raise ValueError(f"unknown loss_kind: {loss_kind!r}")
|
|
215
|
+
loss.backward()
|
|
216
|
+
opt.step()
|
|
217
|
+
history.append(float(loss.detach().cpu()))
|
|
218
|
+
|
|
219
|
+
Sigma_inv_final = _sigma_from_cholesky(L_params).detach()
|
|
220
|
+
Sigma_final = torch.linalg.inv(Sigma_inv_final)
|
|
221
|
+
# eigendecomposition for principal axes — move to CPU because
|
|
222
|
+
# torch.linalg.eigh is not implemented on MPS. This is post-fit
|
|
223
|
+
# diagnostic only (no gradient flow needed here).
|
|
224
|
+
Sigma_cpu = Sigma_final.detach().cpu()
|
|
225
|
+
eigvals, eigvecs = torch.linalg.eigh(Sigma_cpu)
|
|
226
|
+
sigma_eig = torch.sqrt(eigvals.clamp_min(0.0))
|
|
227
|
+
|
|
228
|
+
converged = len(history) > 2 and history[-1] <= history[0]
|
|
229
|
+
|
|
230
|
+
return dict(
|
|
231
|
+
q_fit=q0.detach().cpu().numpy(),
|
|
232
|
+
amplitude=float(torch.exp(log_A).detach().cpu()),
|
|
233
|
+
baseline=float(torch.exp(log_baseline).detach().cpu()),
|
|
234
|
+
sigma_eig=sigma_eig.cpu().numpy(),
|
|
235
|
+
sigma_axes=eigvecs.cpu().numpy(),
|
|
236
|
+
Sigma=Sigma_final.cpu().numpy(),
|
|
237
|
+
final_loss=history[-1],
|
|
238
|
+
converged=bool(converged),
|
|
239
|
+
history=history if return_history else None,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
# Pipeline: patches for every predicted hkl
|
|
245
|
+
# ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
def fit_asterism_patches(
|
|
248
|
+
qx: np.ndarray, qy: np.ndarray, qz: np.ndarray, intensity: np.ndarray,
|
|
249
|
+
*,
|
|
250
|
+
U: np.ndarray, a: float, c: float,
|
|
251
|
+
crystal=None,
|
|
252
|
+
q_max_inv_A: float = 10.0,
|
|
253
|
+
crop_halfwidth: float = 0.10, # 1/Å base box half-width
|
|
254
|
+
crop_q_scale: float = 0.03, # plus a fraction-of-|q| term
|
|
255
|
+
min_voxels: int = 20,
|
|
256
|
+
sigma_init: float = 0.05,
|
|
257
|
+
n_steps: int = 200,
|
|
258
|
+
lr: float = 1e-2,
|
|
259
|
+
loss_kind: str = "lsq",
|
|
260
|
+
device: Optional[Union[str, torch.device]] = None,
|
|
261
|
+
dtype: Optional[Union[str, torch.dtype]] = None,
|
|
262
|
+
) -> List[AsterismFit]:
|
|
263
|
+
"""Fit a 3-D Gaussian asterism at every predicted hkl position.
|
|
264
|
+
|
|
265
|
+
Returns a list of `AsterismFit` (one per hkl with enough voxels in the crop).
|
|
266
|
+
"""
|
|
267
|
+
device_ = resolve_device(device)
|
|
268
|
+
dtype_ = resolve_dtype(device_, dtype)
|
|
269
|
+
|
|
270
|
+
q_pred_all, hkls_all = predict_hkl_positions(
|
|
271
|
+
U=U, a=a, c=c, q_max_inv_A=q_max_inv_A, crystal=crystal,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
q_all = np.stack([qx, qy, qz], axis=1) # (N, 3)
|
|
275
|
+
I_all = np.asarray(intensity, dtype=np.float64)
|
|
276
|
+
|
|
277
|
+
out: List[AsterismFit] = []
|
|
278
|
+
for q0, hkl in zip(q_pred_all, hkls_all):
|
|
279
|
+
# crop box: half-width grows with |q0|
|
|
280
|
+
half = crop_halfwidth + crop_q_scale * float(np.linalg.norm(q0))
|
|
281
|
+
in_box = np.all(np.abs(q_all - q0[None, :]) < half, axis=1)
|
|
282
|
+
n_in = int(in_box.sum())
|
|
283
|
+
if n_in < min_voxels:
|
|
284
|
+
continue
|
|
285
|
+
|
|
286
|
+
q_patch_t = torch.as_tensor(q_all[in_box], dtype=dtype_, device=device_)
|
|
287
|
+
I_patch_t = torch.as_tensor(I_all[in_box], dtype=dtype_, device=device_)
|
|
288
|
+
q_init_t = torch.as_tensor(q0, dtype=dtype_, device=device_)
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
fit = fit_single_patch(
|
|
292
|
+
q_patch_t, I_patch_t, q_init_t,
|
|
293
|
+
sigma_init=sigma_init, n_steps=n_steps, lr=lr,
|
|
294
|
+
loss_kind=loss_kind,
|
|
295
|
+
)
|
|
296
|
+
except Exception:
|
|
297
|
+
continue
|
|
298
|
+
|
|
299
|
+
out.append(AsterismFit(
|
|
300
|
+
hkl=hkl,
|
|
301
|
+
q_pred=q0.astype(np.float64),
|
|
302
|
+
q_fit=fit["q_fit"].astype(np.float64),
|
|
303
|
+
amplitude=fit["amplitude"],
|
|
304
|
+
baseline=fit["baseline"],
|
|
305
|
+
sigma_eig=fit["sigma_eig"].astype(np.float64),
|
|
306
|
+
sigma_axes=fit["sigma_axes"].astype(np.float64),
|
|
307
|
+
integrated_intensity=float(I_all[in_box].sum()),
|
|
308
|
+
n_voxels=n_in,
|
|
309
|
+
final_loss=fit["final_loss"],
|
|
310
|
+
converged=fit["converged"],
|
|
311
|
+
))
|
|
312
|
+
return out
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def strain_tensor_from_centroids(
|
|
316
|
+
fits: Sequence["AsterismFit"], *,
|
|
317
|
+
weight_by_intensity: bool = True,
|
|
318
|
+
) -> dict:
|
|
319
|
+
"""Fit a 3-D strain tensor `ε` that best explains `q_fit − q_pred` across all hkls.
|
|
320
|
+
|
|
321
|
+
Model: small-strain linearization in reciprocal space,
|
|
322
|
+
|
|
323
|
+
q_obs = (I + ε) · q_pred ⇒ q_obs − q_pred = ε · q_pred
|
|
324
|
+
|
|
325
|
+
Stack one (q_pred, q_obs − q_pred) per hkl and solve the
|
|
326
|
+
weighted-least-squares for the 9 entries of ε. The symmetric part is
|
|
327
|
+
the true lattice-strain tensor; the antisymmetric part is residual
|
|
328
|
+
rotation (should be small if the seed-orientation refinement is good).
|
|
329
|
+
|
|
330
|
+
Returns dict with
|
|
331
|
+
`epsilon` (3, 3): full strain tensor
|
|
332
|
+
`epsilon_sym` (3, 3): symmetric part (true lattice strain)
|
|
333
|
+
`epsilon_antisym` (3, 3): antisymmetric (residual rotation, ideally ~0)
|
|
334
|
+
`residual_norm`: ‖q_obs − (I+ε)·q_pred‖₂ after fit
|
|
335
|
+
`n_hkls`: number of fits used
|
|
336
|
+
`principal_strains`: 3-vector of eigenvalues of `epsilon_sym`
|
|
337
|
+
`principal_axes`: 3×3 matrix whose columns are eigenvectors
|
|
338
|
+
"""
|
|
339
|
+
if len(fits) < 4:
|
|
340
|
+
raise ValueError(
|
|
341
|
+
f"need at least 4 fits to solve 9 strain entries; got {len(fits)}"
|
|
342
|
+
)
|
|
343
|
+
q_pred = np.array([f.q_pred for f in fits]) # (N, 3)
|
|
344
|
+
q_obs = np.array([f.q_fit for f in fits]) # (N, 3)
|
|
345
|
+
delta = q_obs - q_pred # (N, 3)
|
|
346
|
+
if weight_by_intensity:
|
|
347
|
+
w = np.array([f.integrated_intensity for f in fits])
|
|
348
|
+
w = w / w.sum()
|
|
349
|
+
else:
|
|
350
|
+
w = np.ones(len(fits)) / len(fits)
|
|
351
|
+
|
|
352
|
+
# Per row of ε (say ε_i*): solve for the 3 entries from the i-th column
|
|
353
|
+
# of delta. Stacked: delta_i = q_pred @ ε_i*.T ⇒ ε_i*.T = lstsq(q_pred, delta_i)
|
|
354
|
+
epsilon = np.zeros((3, 3))
|
|
355
|
+
for i in range(3):
|
|
356
|
+
A_mat = q_pred * w[:, None] # weight rows
|
|
357
|
+
b_vec = delta[:, i] * w
|
|
358
|
+
sol, *_ = np.linalg.lstsq(A_mat, b_vec, rcond=None)
|
|
359
|
+
epsilon[i, :] = sol # ε[i, j] = sol[j]
|
|
360
|
+
|
|
361
|
+
pred_delta = q_pred @ epsilon.T
|
|
362
|
+
resid_norm = float(np.linalg.norm(delta - pred_delta))
|
|
363
|
+
eps_sym = 0.5 * (epsilon + epsilon.T)
|
|
364
|
+
eps_anti = 0.5 * (epsilon - epsilon.T)
|
|
365
|
+
eigvals, eigvecs = np.linalg.eigh(eps_sym)
|
|
366
|
+
return dict(
|
|
367
|
+
epsilon=epsilon,
|
|
368
|
+
epsilon_sym=eps_sym,
|
|
369
|
+
epsilon_antisym=eps_anti,
|
|
370
|
+
residual_norm=resid_norm,
|
|
371
|
+
n_hkls=len(fits),
|
|
372
|
+
principal_strains=eigvals,
|
|
373
|
+
principal_axes=eigvecs,
|
|
374
|
+
volumetric_strain=float(np.trace(eps_sym)),
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def build_bragg_residual_intensity(
|
|
379
|
+
qx: np.ndarray, qy: np.ndarray, qz: np.ndarray, intensity: np.ndarray,
|
|
380
|
+
fits: Sequence[AsterismFit],
|
|
381
|
+
*,
|
|
382
|
+
clip_negative: bool = True,
|
|
383
|
+
) -> np.ndarray:
|
|
384
|
+
"""Subtract the fitted Bragg model from the measured intensity.
|
|
385
|
+
|
|
386
|
+
Returns a copy of `intensity` with each fit's 3-D Gaussian (without the
|
|
387
|
+
baseline) subtracted. The residual is the diffuse-rod component plus any
|
|
388
|
+
asterism wings the Gaussian model couldn't capture.
|
|
389
|
+
|
|
390
|
+
Parameters
|
|
391
|
+
----------
|
|
392
|
+
clip_negative : bool, default True
|
|
393
|
+
If True, clip residuals below zero. Set False to keep the signed
|
|
394
|
+
residual (useful for diagnostic plots).
|
|
395
|
+
"""
|
|
396
|
+
q_all = np.stack([qx, qy, qz], axis=1)
|
|
397
|
+
residual = intensity.astype(np.float64).copy()
|
|
398
|
+
for f in fits:
|
|
399
|
+
Sigma = f.sigma_axes @ np.diag(f.sigma_eig ** 2) @ f.sigma_axes.T
|
|
400
|
+
Sigma_inv = np.linalg.inv(Sigma)
|
|
401
|
+
delta = q_all - f.q_fit[None, :]
|
|
402
|
+
quad = np.einsum("ni,ij,nj->n", delta, Sigma_inv, delta)
|
|
403
|
+
bragg = f.amplitude * np.exp(-0.5 * quad)
|
|
404
|
+
residual -= bragg
|
|
405
|
+
if clip_negative:
|
|
406
|
+
residual = np.clip(residual, 0.0, None)
|
|
407
|
+
return residual
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Attribution guard: refuse per-variant claims on shared reciprocal directions.
|
|
2
|
+
|
|
3
|
+
The 2026-06 demk failure root cause: a diffuse feature (the 9R satellite) lives on
|
|
4
|
+
the parent/twin **coincident** <111> (the twin plane). FF-HEDM integrates over a
|
|
5
|
+
grain's volume and has no spatial channel, so a feature on a reciprocal direction
|
|
6
|
+
shared by two variants **cannot** be attributed to one variant. Per-variant /
|
|
7
|
+
per-grain numbers computed anyway are projection-geometry artifacts (see
|
|
8
|
+
AUDIT_2026-06-23.md). This module detects coincident directions and *raises* rather
|
|
9
|
+
than letting a caller manufacture a per-variant result FF physics cannot support.
|
|
10
|
+
|
|
11
|
+
Hard limit: separating a shared-plane feature parent-vs-twin needs pf-HEDM (spatial).
|
|
12
|
+
No software fixes it; this guard makes the package say so instead of approximating.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import itertools
|
|
18
|
+
import math
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
from numpy.typing import NDArray
|
|
22
|
+
|
|
23
|
+
__all__ = ["AttributionError", "hkl_family_dirs", "coincident_axes",
|
|
24
|
+
"assert_variant_attributable"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AttributionError(RuntimeError):
|
|
28
|
+
"""Raised when a per-variant/per-grain claim is requested for a feature on a
|
|
29
|
+
reciprocal direction shared by two or more variants (FF cannot attribute it)."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def hkl_family_dirs(hkl=(1, 1, 1)) -> NDArray[np.floating]:
|
|
33
|
+
"""Unit vectors of the full signed <hkl> family (cubic), deduplicated by sign."""
|
|
34
|
+
h, k, l = hkl
|
|
35
|
+
seen = set()
|
|
36
|
+
out = []
|
|
37
|
+
for p in set(itertools.permutations((abs(h), abs(k), abs(l)))):
|
|
38
|
+
for s in itertools.product((1, -1), repeat=3):
|
|
39
|
+
v = (p[0] * s[0], p[1] * s[1], p[2] * s[2])
|
|
40
|
+
if v == (0, 0, 0):
|
|
41
|
+
continue
|
|
42
|
+
key = min(v, tuple(-x for x in v)) # fold ±
|
|
43
|
+
if key in seen:
|
|
44
|
+
continue
|
|
45
|
+
seen.add(key)
|
|
46
|
+
out.append(v)
|
|
47
|
+
D = np.array(out, dtype=float)
|
|
48
|
+
return D / np.linalg.norm(D, axis=1, keepdims=True)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def coincident_axes(
|
|
52
|
+
reference_OMs,
|
|
53
|
+
hkl=(1, 1, 1),
|
|
54
|
+
*,
|
|
55
|
+
tol_deg: float = 10.0,
|
|
56
|
+
) -> NDArray[np.floating]:
|
|
57
|
+
"""Sample-frame directions shared (within ``tol_deg``) by >=2 reference variants.
|
|
58
|
+
|
|
59
|
+
For each pair of reference orientations, a <hkl> direction of one that lies within
|
|
60
|
+
``tol_deg`` of a <hkl> direction of the other is "coincident" — a reciprocal
|
|
61
|
+
direction both variants scatter into. A feature there is not variant-attributable.
|
|
62
|
+
|
|
63
|
+
Parameters
|
|
64
|
+
----------
|
|
65
|
+
reference_OMs : sequence of (3,3)
|
|
66
|
+
Variant reference orientations (crystal->sample), e.g. [parent, twin].
|
|
67
|
+
hkl : the family that carries the feature (default <111> for FCC 9R / SF).
|
|
68
|
+
tol_deg : coincidence tolerance (degrees). **Must be >= the deformation mosaic
|
|
69
|
+
spread**: in a deformed sample two single reference orientations coincide only
|
|
70
|
+
at the mosaic scale (demk: ~8.5 deg), so a too-tight tol falsely reports "not
|
|
71
|
+
shared" and lets an artifact through. Default 10 deg is deliberately
|
|
72
|
+
conservative — a false refusal is far safer than manufacturing a per-variant
|
|
73
|
+
number FF cannot support.
|
|
74
|
+
|
|
75
|
+
Returns
|
|
76
|
+
-------
|
|
77
|
+
(M, 3) unit directions in the sample frame (deduplicated).
|
|
78
|
+
"""
|
|
79
|
+
refs = [np.asarray(R, dtype=float) for R in reference_OMs]
|
|
80
|
+
if len(refs) < 2:
|
|
81
|
+
return np.zeros((0, 3))
|
|
82
|
+
F = hkl_family_dirs(hkl)
|
|
83
|
+
ct = math.cos(math.radians(tol_deg))
|
|
84
|
+
found = []
|
|
85
|
+
for a, b in itertools.combinations(range(len(refs)), 2):
|
|
86
|
+
Da = (refs[a] @ F.T).T
|
|
87
|
+
Db = (refs[b] @ F.T).T
|
|
88
|
+
Da /= np.linalg.norm(Da, axis=1, keepdims=True)
|
|
89
|
+
Db /= np.linalg.norm(Db, axis=1, keepdims=True)
|
|
90
|
+
dots = np.abs(Da @ Db.T) # (nF, nF)
|
|
91
|
+
ia, ib = np.where(dots >= ct)
|
|
92
|
+
for i in ia:
|
|
93
|
+
d = Da[i] * (1.0 if Da[i, 2] >= 0 else -1.0)
|
|
94
|
+
found.append(d)
|
|
95
|
+
if not found:
|
|
96
|
+
return np.zeros((0, 3))
|
|
97
|
+
F2 = np.array(found)
|
|
98
|
+
_, idx = np.unique(np.round(F2, 3), axis=0, return_index=True)
|
|
99
|
+
return F2[idx]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def assert_variant_attributable(
|
|
103
|
+
feature_axis_sample,
|
|
104
|
+
reference_OMs,
|
|
105
|
+
hkl=(1, 1, 1),
|
|
106
|
+
*,
|
|
107
|
+
tol_deg: float = 5.0,
|
|
108
|
+
what: str = "this feature",
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Raise ``AttributionError`` if ``feature_axis_sample`` is on a coincident axis.
|
|
111
|
+
|
|
112
|
+
Call this at the top of any per-variant/per-grain routine that attributes a diffuse
|
|
113
|
+
feature to a variant. If the feature direction (sample frame) coincides with a
|
|
114
|
+
parent/twin-shared <hkl>, FF-HEDM cannot attribute it and we refuse.
|
|
115
|
+
"""
|
|
116
|
+
axis = np.asarray(feature_axis_sample, dtype=float)
|
|
117
|
+
axis = axis / np.linalg.norm(axis)
|
|
118
|
+
shared = coincident_axes(reference_OMs, hkl, tol_deg=tol_deg)
|
|
119
|
+
if shared.shape[0] == 0:
|
|
120
|
+
return
|
|
121
|
+
ang = np.degrees(np.arccos(np.clip(np.abs(shared @ axis).max(), 0.0, 1.0)))
|
|
122
|
+
if ang <= tol_deg:
|
|
123
|
+
raise AttributionError(
|
|
124
|
+
f"{what} lies on a parent/twin-shared <{''.join(str(x) for x in hkl)}> "
|
|
125
|
+
f"direction ({ang:.1f} deg from a coincident axis, tol {tol_deg:.1f}). "
|
|
126
|
+
"FF-HEDM integrates over grain volume and cannot attribute a shared "
|
|
127
|
+
"reciprocal direction to one variant — per-variant/per-grain numbers here "
|
|
128
|
+
"are projection-geometry artifacts (see AUDIT_2026-06-23.md). "
|
|
129
|
+
"Spatial separation requires pf-HEDM."
|
|
130
|
+
)
|