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.
@@ -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
+ )