midas-nf-preprocess 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.
Files changed (41) hide show
  1. midas_nf_preprocess/__init__.py +45 -0
  2. midas_nf_preprocess/cli.py +62 -0
  3. midas_nf_preprocess/device.py +69 -0
  4. midas_nf_preprocess/diffr_spots/__init__.py +83 -0
  5. midas_nf_preprocess/diffr_spots/binary_io.py +118 -0
  6. midas_nf_preprocess/diffr_spots/cli.py +70 -0
  7. midas_nf_preprocess/diffr_spots/geometry.py +193 -0
  8. midas_nf_preprocess/diffr_spots/hkls.py +118 -0
  9. midas_nf_preprocess/diffr_spots/orientations.py +176 -0
  10. midas_nf_preprocess/diffr_spots/params.py +115 -0
  11. midas_nf_preprocess/diffr_spots/pipeline.py +301 -0
  12. midas_nf_preprocess/hex_grid/__init__.py +30 -0
  13. midas_nf_preprocess/hex_grid/cli.py +58 -0
  14. midas_nf_preprocess/hex_grid/grid.py +237 -0
  15. midas_nf_preprocess/hex_grid/io.py +64 -0
  16. midas_nf_preprocess/hex_grid/params.py +62 -0
  17. midas_nf_preprocess/process_images/__init__.py +40 -0
  18. midas_nf_preprocess/process_images/cli.py +79 -0
  19. midas_nf_preprocess/process_images/io.py +92 -0
  20. midas_nf_preprocess/process_images/log_filter.py +130 -0
  21. midas_nf_preprocess/process_images/median.py +85 -0
  22. midas_nf_preprocess/process_images/params.py +133 -0
  23. midas_nf_preprocess/process_images/peaks.py +355 -0
  24. midas_nf_preprocess/process_images/pipeline.py +272 -0
  25. midas_nf_preprocess/process_images/spots_io.py +276 -0
  26. midas_nf_preprocess/seed_orientations/__init__.py +86 -0
  27. midas_nf_preprocess/seed_orientations/cli.py +150 -0
  28. midas_nf_preprocess/seed_orientations/crystal.py +136 -0
  29. midas_nf_preprocess/seed_orientations/dispatch.py +99 -0
  30. midas_nf_preprocess/seed_orientations/from_cache.py +141 -0
  31. midas_nf_preprocess/seed_orientations/from_grains.py +100 -0
  32. midas_nf_preprocess/seed_orientations/from_scratch.py +207 -0
  33. midas_nf_preprocess/seed_orientations/io.py +77 -0
  34. midas_nf_preprocess/tomo_filter/__init__.py +36 -0
  35. midas_nf_preprocess/tomo_filter/cli.py +66 -0
  36. midas_nf_preprocess/tomo_filter/filter.py +180 -0
  37. midas_nf_preprocess-0.1.0.dist-info/METADATA +96 -0
  38. midas_nf_preprocess-0.1.0.dist-info/RECORD +41 -0
  39. midas_nf_preprocess-0.1.0.dist-info/WHEEL +5 -0
  40. midas_nf_preprocess-0.1.0.dist-info/entry_points.txt +2 -0
  41. midas_nf_preprocess-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,45 @@
1
+ """midas-nf-preprocess: Differentiable PyTorch port of NF-HEDM preprocessing.
2
+
3
+ Bundles the preprocessing stages that sit between raw experimental inputs
4
+ and the orientation-fitting step (``FitOrientationOMP``):
5
+
6
+ - ``hex_grid`` : voxel grid generation (port of MakeHexGrid)
7
+ - ``tomo_filter`` : grid masking from a tomography image
8
+ (port of filterGridfromTomo)
9
+ - ``seed_orientations``: NF seed-orientation generation (cache, from-scratch,
10
+ or from an FF Grains.csv)
11
+ - ``diffr_spots`` : per-orientation diffraction-spot prediction
12
+ (differentiable port of MakeDiffrSpots; wraps
13
+ ``midas_diffract.HEDMForwardModel.calc_bragg_geometry``)
14
+ - ``process_images`` : raw TIFF -> SpotsInfo.bin
15
+ (differentiable port of ProcessImagesCombined)
16
+
17
+ The shared ``device`` module provides device/dtype resolution
18
+ (env vars ``MIDAS_NF_PREPROCESS_DEVICE`` / ``MIDAS_NF_PREPROCESS_DTYPE``)
19
+ matching the convention used by ``midas-transforms``.
20
+ """
21
+
22
+ __version__ = "0.1.0"
23
+
24
+ from . import (
25
+ device,
26
+ diffr_spots,
27
+ hex_grid,
28
+ process_images,
29
+ seed_orientations,
30
+ tomo_filter,
31
+ )
32
+ from .device import resolve_device, resolve_dtype, apply_cpu_threads
33
+
34
+ __all__ = [
35
+ "__version__",
36
+ "device",
37
+ "diffr_spots",
38
+ "hex_grid",
39
+ "process_images",
40
+ "seed_orientations",
41
+ "tomo_filter",
42
+ "resolve_device",
43
+ "resolve_dtype",
44
+ "apply_cpu_threads",
45
+ ]
@@ -0,0 +1,62 @@
1
+ """Umbrella CLI: ``midas-nf-preprocess <subcommand> ...``.
2
+
3
+ Subcommands:
4
+
5
+ - ``hex-grid`` : generate the voxel grid (port of MakeHexGrid)
6
+ - ``tomo-filter`` : mask the grid by a tomography image / bbox
7
+ - ``diffr-spots`` : forward-simulate diffraction spots (port of MakeDiffrSpots)
8
+ - ``process-images`` : raw TIFF -> SpotsInfo.bin (port of ProcessImagesCombined)
9
+
10
+ Each subcommand mirrors its standalone ``python -m`` invocation:
11
+
12
+ python -m midas_nf_preprocess.hex_grid <args> ==
13
+ midas-nf-preprocess hex-grid <args>
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import sys
20
+
21
+ from . import __version__
22
+ from .diffr_spots import cli as diffr_spots_cli
23
+ from .hex_grid import cli as hex_grid_cli
24
+ from .process_images import cli as process_images_cli
25
+ from .seed_orientations import cli as seed_orientations_cli
26
+ from .tomo_filter import cli as tomo_filter_cli
27
+
28
+
29
+ _SUBCOMMANDS = {
30
+ "hex-grid": hex_grid_cli,
31
+ "tomo-filter": tomo_filter_cli,
32
+ "diffr-spots": diffr_spots_cli,
33
+ "process-images": process_images_cli,
34
+ "seed-orientations": seed_orientations_cli,
35
+ }
36
+
37
+
38
+ def _build_parser() -> argparse.ArgumentParser:
39
+ parser = argparse.ArgumentParser(
40
+ prog="midas-nf-preprocess",
41
+ description="Differentiable PyTorch port of NF-HEDM preprocessing.",
42
+ )
43
+ parser.add_argument(
44
+ "--version", action="version", version=f"midas-nf-preprocess {__version__}"
45
+ )
46
+ subparsers = parser.add_subparsers(
47
+ dest="subcommand", required=True, metavar="<subcommand>"
48
+ )
49
+ for name, mod in _SUBCOMMANDS.items():
50
+ sp = subparsers.add_parser(name, help=mod.__doc__.splitlines()[0] if mod.__doc__ else None)
51
+ mod.add_arguments(sp)
52
+ sp.set_defaults(_run=mod.run)
53
+ return parser
54
+
55
+
56
+ def main(argv: list[str] | None = None) -> int:
57
+ args = _build_parser().parse_args(argv)
58
+ return args._run(args)
59
+
60
+
61
+ if __name__ == "__main__":
62
+ sys.exit(main())
@@ -0,0 +1,69 @@
1
+ """Device / dtype resolution.
2
+
3
+ Same contract as ``midas-transforms``:
4
+ - device default: cuda > mps > cpu (auto-detect)
5
+ - dtype default: float64 on cpu (matches the C binaries),
6
+ float32 on cuda/mps (matches the GPU codepath idioms).
7
+ - Both float32 and float64 are supported on every device.
8
+ - Env vars MIDAS_NF_PREPROCESS_DEVICE / MIDAS_NF_PREPROCESS_DTYPE
9
+ override defaults.
10
+ - Library ``device=``/``dtype=`` kwargs override env vars.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ from typing import Optional, Union
17
+
18
+ import torch
19
+
20
+ _DTYPE_ALIAS = {
21
+ "float32": torch.float32,
22
+ "fp32": torch.float32,
23
+ "float64": torch.float64,
24
+ "fp64": torch.float64,
25
+ "double": torch.float64,
26
+ "single": torch.float32,
27
+ }
28
+
29
+ ENV_DEVICE = "MIDAS_NF_PREPROCESS_DEVICE"
30
+ ENV_DTYPE = "MIDAS_NF_PREPROCESS_DTYPE"
31
+
32
+
33
+ def resolve_device(user: Optional[Union[str, torch.device]]) -> torch.device:
34
+ """Pick a torch.device. Precedence: arg > env var > auto-detect."""
35
+ if isinstance(user, torch.device):
36
+ return user
37
+ if user is None:
38
+ user = os.environ.get(ENV_DEVICE)
39
+ if user is None:
40
+ if torch.cuda.is_available():
41
+ return torch.device("cuda")
42
+ if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
43
+ return torch.device("mps")
44
+ return torch.device("cpu")
45
+ return torch.device(user)
46
+
47
+
48
+ def resolve_dtype(
49
+ device: torch.device, user: Optional[Union[str, torch.dtype]]
50
+ ) -> torch.dtype:
51
+ """Pick a torch.dtype. Precedence: arg > env var > per-device default."""
52
+ if isinstance(user, torch.dtype):
53
+ return user
54
+ if user is None:
55
+ user = os.environ.get(ENV_DTYPE)
56
+ if user is not None:
57
+ try:
58
+ return _DTYPE_ALIAS[user.lower()]
59
+ except KeyError as e:
60
+ raise ValueError(
61
+ f"Unsupported dtype '{user}'. Use one of {sorted(_DTYPE_ALIAS)}."
62
+ ) from e
63
+ return torch.float64 if device.type == "cpu" else torch.float32
64
+
65
+
66
+ def apply_cpu_threads(num_procs: int, device: torch.device) -> None:
67
+ """Honor a legacy ``numProcs`` argv on CPU only (no-op on GPU)."""
68
+ if device.type == "cpu" and num_procs > 0:
69
+ torch.set_num_threads(num_procs)
@@ -0,0 +1,83 @@
1
+ """diffr_spots: per-orientation diffraction-spot prediction (port of MakeDiffrSpots).
2
+
3
+ Differentiable PyTorch port of ``NF_HEDM/src/MakeDiffrSpots.c``. For each seed
4
+ orientation and each HKL, solve the Bragg condition:
5
+
6
+ - ``omega(s)`` (1 or 2 solutions): rotation angles where the reciprocal-space
7
+ G-vector intersects the Ewald sphere
8
+ - ``eta``: azimuthal angle on the detector
9
+ - ``(yl, zl)``: lab-frame detector position at the primary distance
10
+
11
+ then filter by ``OmegaRange``, ``BoxSize``, and ``ExcludePoleAngle``. Output is
12
+ either a structured tensor bundle or the same three binary files the C
13
+ executable produces:
14
+
15
+ - ``DiffractionSpots.bin`` : ``[TotalSpots, 3]`` float64 ``(yl, zl, omega)``
16
+ - ``Key.bin`` : ``[N_orient, 2]`` int32 ``(count_i, offset_i)``
17
+ - ``OrientMat.bin`` : ``[N_orient, 3, 3]`` float64 row-major
18
+
19
+ Differentiability:
20
+
21
+ - ``omega``, ``eta``, ``yl``, ``zl`` are differentiable in the input
22
+ quaternions / Euler angles / orientation matrices and in the lattice
23
+ parameters that produced ``hkls``.
24
+ - The hard ``OmegaRange`` / ``BoxSize`` / ``ExcludePoleAngle`` filters return
25
+ a boolean ``valid`` mask (no gradient), matching the convention used by
26
+ ``midas_diffract.SpotDescriptors``.
27
+
28
+ The geometry primitives (``calc_omega``, ``calc_eta``, ``calc_spot_position``)
29
+ match the C implementations in ``MakeDiffrSpots.c`` L99-L204 line-by-line and
30
+ extend them to batched, vectorized PyTorch operations.
31
+ """
32
+
33
+ from .params import DiffrSpotsParams
34
+ from .orientations import (
35
+ quat_to_orient_matrix,
36
+ orient_matrix_to_quat,
37
+ euler_to_orient_matrix,
38
+ )
39
+ from .geometry import (
40
+ bragg_omega_eta,
41
+ calc_eta_deg,
42
+ calc_spot_position,
43
+ rotate_around_z,
44
+ )
45
+ from .pipeline import (
46
+ DiffrSpotsPipeline,
47
+ DiffrSpotsResult,
48
+ predict_spots,
49
+ )
50
+ from .binary_io import (
51
+ write_diffr_spots_bin,
52
+ write_key_bin,
53
+ write_orient_mat_bin,
54
+ read_diffr_spots_bin,
55
+ read_key_bin,
56
+ read_orient_mat_bin,
57
+ write_all,
58
+ )
59
+ from .hkls import HKLEntry, read_hkls_csv, read_seed_orientations
60
+
61
+ __all__ = [
62
+ "DiffrSpotsParams",
63
+ "quat_to_orient_matrix",
64
+ "orient_matrix_to_quat",
65
+ "euler_to_orient_matrix",
66
+ "bragg_omega_eta",
67
+ "calc_eta_deg",
68
+ "calc_spot_position",
69
+ "rotate_around_z",
70
+ "DiffrSpotsPipeline",
71
+ "DiffrSpotsResult",
72
+ "predict_spots",
73
+ "write_diffr_spots_bin",
74
+ "write_key_bin",
75
+ "write_orient_mat_bin",
76
+ "read_diffr_spots_bin",
77
+ "read_key_bin",
78
+ "read_orient_mat_bin",
79
+ "write_all",
80
+ "HKLEntry",
81
+ "read_hkls_csv",
82
+ "read_seed_orientations",
83
+ ]
@@ -0,0 +1,118 @@
1
+ """Binary I/O for the three MakeDiffrSpots output files.
2
+
3
+ File formats (from MakeDiffrSpots.c L595-L626):
4
+
5
+ - ``Key.bin`` : ``int32 [N_orient * 2]`` little-endian, with
6
+ entries ``(count_i, offset_i)`` for each orientation.
7
+ - ``DiffractionSpots.bin`` : ``float64 [TotalSpots * 3]`` little-endian, with
8
+ row layout ``(yl, zl, omega)``.
9
+ - ``OrientMat.bin`` : ``float64 [N_orient * 9]`` little-endian, with
10
+ row-major flattened 3x3 matrices.
11
+
12
+ Total spots = sum of per-orientation counts; ``offset_i`` is the cumulative
13
+ prefix sum (excluding ``count_i`` itself) so that orientation i's spots span
14
+ ``DiffractionSpots[offset_i : offset_i + count_i]``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from pathlib import Path
20
+ from typing import Union
21
+
22
+ import numpy as np
23
+ import torch
24
+
25
+
26
+ # -----------------------------------------------------------------------------
27
+ # Writers
28
+ # -----------------------------------------------------------------------------
29
+
30
+
31
+ def write_key_bin(
32
+ counts: torch.Tensor, path: Union[str, Path]
33
+ ) -> None:
34
+ """Write ``Key.bin`` from a ``(N,)`` int tensor of per-orientation spot counts."""
35
+ if counts.ndim != 1:
36
+ raise ValueError(f"Expected 1D counts tensor, got shape {tuple(counts.shape)}")
37
+ counts_np = counts.detach().cpu().numpy().astype(np.int32)
38
+ offsets = np.zeros_like(counts_np)
39
+ if counts_np.size > 0:
40
+ offsets[1:] = np.cumsum(counts_np[:-1])
41
+ key = np.stack([counts_np, offsets], axis=1) # (N, 2)
42
+ path = Path(path)
43
+ path.parent.mkdir(parents=True, exist_ok=True)
44
+ key.tofile(path)
45
+
46
+
47
+ def write_diffr_spots_bin(
48
+ spots: torch.Tensor, path: Union[str, Path]
49
+ ) -> None:
50
+ """Write ``DiffractionSpots.bin`` from a ``(TotalSpots, 3)`` float tensor."""
51
+ if spots.ndim != 2 or spots.shape[1] != 3:
52
+ raise ValueError(
53
+ f"Expected (TotalSpots, 3) tensor, got shape {tuple(spots.shape)}"
54
+ )
55
+ arr = spots.detach().cpu().numpy().astype(np.float64)
56
+ path = Path(path)
57
+ path.parent.mkdir(parents=True, exist_ok=True)
58
+ arr.tofile(path)
59
+
60
+
61
+ def write_orient_mat_bin(
62
+ orient_mats: torch.Tensor, path: Union[str, Path]
63
+ ) -> None:
64
+ """Write ``OrientMat.bin`` from a ``(N, 3, 3)`` float tensor."""
65
+ if orient_mats.ndim != 3 or orient_mats.shape[1:] != (3, 3):
66
+ raise ValueError(
67
+ f"Expected (N, 3, 3) tensor, got shape {tuple(orient_mats.shape)}"
68
+ )
69
+ arr = orient_mats.detach().cpu().numpy().astype(np.float64)
70
+ path = Path(path)
71
+ path.parent.mkdir(parents=True, exist_ok=True)
72
+ arr.tofile(path)
73
+
74
+
75
+ def write_all(
76
+ output_dir: Union[str, Path],
77
+ counts: torch.Tensor,
78
+ spots: torch.Tensor,
79
+ orient_mats: torch.Tensor,
80
+ ) -> dict[str, Path]:
81
+ """Convenience wrapper that writes all three files into ``output_dir``."""
82
+ output_dir = Path(output_dir)
83
+ paths = {
84
+ "Key.bin": output_dir / "Key.bin",
85
+ "DiffractionSpots.bin": output_dir / "DiffractionSpots.bin",
86
+ "OrientMat.bin": output_dir / "OrientMat.bin",
87
+ }
88
+ write_key_bin(counts, paths["Key.bin"])
89
+ write_diffr_spots_bin(spots, paths["DiffractionSpots.bin"])
90
+ write_orient_mat_bin(orient_mats, paths["OrientMat.bin"])
91
+ return paths
92
+
93
+
94
+ # -----------------------------------------------------------------------------
95
+ # Readers (for parity tests / round-trip)
96
+ # -----------------------------------------------------------------------------
97
+
98
+
99
+ def read_key_bin(path: Union[str, Path], n_orient: int) -> torch.Tensor:
100
+ """Read ``Key.bin`` and return a ``(N, 2)`` int64 tensor ``(count_i, offset_i)``."""
101
+ arr = np.fromfile(path, dtype=np.int32, count=n_orient * 2).reshape(n_orient, 2)
102
+ return torch.from_numpy(arr.astype(np.int64))
103
+
104
+
105
+ def read_diffr_spots_bin(
106
+ path: Union[str, Path], n_spots: int
107
+ ) -> torch.Tensor:
108
+ """Read ``DiffractionSpots.bin`` and return a ``(N_spots, 3)`` float64 tensor."""
109
+ arr = np.fromfile(path, dtype=np.float64, count=n_spots * 3).reshape(n_spots, 3)
110
+ return torch.from_numpy(arr)
111
+
112
+
113
+ def read_orient_mat_bin(
114
+ path: Union[str, Path], n_orient: int
115
+ ) -> torch.Tensor:
116
+ """Read ``OrientMat.bin`` and return a ``(N, 3, 3)`` float64 tensor."""
117
+ arr = np.fromfile(path, dtype=np.float64, count=n_orient * 9).reshape(n_orient, 3, 3)
118
+ return torch.from_numpy(arr)
@@ -0,0 +1,70 @@
1
+ """``midas-nf-preprocess diffr-spots`` subcommand."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from .params import DiffrSpotsParams
10
+ from .pipeline import DiffrSpotsPipeline
11
+
12
+
13
+ def add_arguments(parser: argparse.ArgumentParser) -> None:
14
+ parser.add_argument(
15
+ "parameter_file", help="MIDAS parameter file (Lsd, SeedOrientations, etc.)"
16
+ )
17
+ parser.add_argument(
18
+ "--device", default=None, help="cpu | cuda | mps (default: auto-detect)."
19
+ )
20
+ parser.add_argument(
21
+ "--dtype", default=None, help="fp32 | fp64 (default: fp64 on cpu)."
22
+ )
23
+ parser.add_argument(
24
+ "--hkls-csv",
25
+ default=None,
26
+ help="Override hkls.csv path (default: <DataDirectory>/hkls.csv).",
27
+ )
28
+ parser.add_argument(
29
+ "--seeds",
30
+ default=None,
31
+ help="Override SeedOrientations path (default: from parameter file).",
32
+ )
33
+ parser.add_argument(
34
+ "--output-dir",
35
+ default=None,
36
+ help="Override output directory for Key.bin / DiffractionSpots.bin / OrientMat.bin.",
37
+ )
38
+
39
+
40
+ def run(args: argparse.Namespace) -> int:
41
+ params = DiffrSpotsParams.from_paramfile(args.parameter_file)
42
+ pipe = DiffrSpotsPipeline(
43
+ params,
44
+ device=args.device,
45
+ dtype=args.dtype,
46
+ hkls_csv=args.hkls_csv,
47
+ seed_orientations_csv=args.seeds,
48
+ )
49
+ result, paths = pipe.run(output_dir=args.output_dir)
50
+ total = int(result.counts.sum().item())
51
+ print(
52
+ f"Wrote {len(paths)} files to {Path(paths['Key.bin']).parent} "
53
+ f"({pipe.n_orientations} orientations, {total} total spots)"
54
+ )
55
+ for name, p in paths.items():
56
+ print(f" {name}: {p}")
57
+ return 0
58
+
59
+
60
+ def main(argv: list[str] | None = None) -> int:
61
+ parser = argparse.ArgumentParser(
62
+ prog="midas-nf-preprocess diffr-spots",
63
+ description="Differentiable diffraction-spot prediction (port of MakeDiffrSpots).",
64
+ )
65
+ add_arguments(parser)
66
+ return run(parser.parse_args(argv))
67
+
68
+
69
+ if __name__ == "__main__":
70
+ sys.exit(main())
@@ -0,0 +1,193 @@
1
+ """Bragg geometry for the diffr_spots pipeline.
2
+
3
+ We **delegate** the omega/eta calculation to
4
+ ``midas_diffract.HEDMForwardModel.calc_bragg_geometry`` (forward.py:680-857)
5
+ to avoid maintaining two copies of the same quadratic-Bragg solver. That
6
+ function ports ``CalcDiffractionSpots.c::CalcOmega`` (NF) and
7
+ ``ForwardSimulationCompressed.c`` (FF), and adds a wedge correction.
8
+
9
+ Only the helpers that are MakeDiffrSpots-specific live here:
10
+
11
+ - ``calc_spot_position`` : ``(eta, ring_radius) -> (yl, zl)`` lab-frame
12
+ projection at a given detector distance (matches MakeDiffrSpots.c L115-L120).
13
+ - ``rotate_around_z`` : convenience helper used by tests.
14
+
15
+ Returned omega/eta from calc_bragg_geometry are in radians; the rest of this
16
+ package converts to degrees at the boundary so the binary output matches the
17
+ C convention.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import math
23
+ from typing import Optional
24
+
25
+ import torch
26
+
27
+
28
+ _DEG2RAD = math.pi / 180.0
29
+ _RAD2DEG = 180.0 / math.pi
30
+
31
+
32
+ def rotate_around_z(v: torch.Tensor, alpha_deg: torch.Tensor) -> torch.Tensor:
33
+ """Apply a rotation about the +Z axis (active, right-handed).
34
+
35
+ Direct port of ``RotateAroundZ`` (MakeDiffrSpots.c L99-L107). Used in tests
36
+ to validate the geometry; the production path goes through midas_diffract.
37
+ """
38
+ if v.shape[-1] != 3:
39
+ raise ValueError(f"Expected last dim = 3, got shape {tuple(v.shape)}")
40
+ a = alpha_deg * _DEG2RAD
41
+ cosa = torch.cos(a)
42
+ sina = torch.sin(a)
43
+ x = v[..., 0]
44
+ y = v[..., 1]
45
+ z = v[..., 2]
46
+ return torch.stack(
47
+ [cosa * x - sina * y, sina * x + cosa * y, z], dim=-1
48
+ )
49
+
50
+
51
+ def calc_eta_deg(y: torch.Tensor, z: torch.Tensor) -> torch.Tensor:
52
+ """Convert lab-frame (y, z) into eta in degrees.
53
+
54
+ Port of ``MakeDiffrSpots.c::CalcEtaAngle`` (L109-L113), used as a
55
+ cross-check against the radians version returned by midas_diffract.
56
+ """
57
+ r = torch.sqrt(y * y + z * z)
58
+ eps = torch.finfo(r.dtype).eps
59
+ cos_eta = torch.where(r > 0, z / torch.clamp(r, min=eps), torch.zeros_like(z))
60
+ cos_eta = torch.clamp(cos_eta, -1.0, 1.0)
61
+ alpha = torch.acos(cos_eta) * _RAD2DEG
62
+ return torch.where(y > 0, -alpha, alpha)
63
+
64
+
65
+ def calc_spot_position(
66
+ ring_radius: torch.Tensor, eta_deg: torch.Tensor
67
+ ) -> tuple[torch.Tensor, torch.Tensor]:
68
+ """Lab-frame ``(yl, zl)`` from ring radius and eta (degrees).
69
+
70
+ Direct port of ``MakeDiffrSpots.c::CalcSpotPosition`` (L115-L120):
71
+
72
+ yl = -RingRadius * sin(eta)
73
+ zl = RingRadius * cos(eta)
74
+
75
+ This is the bit MakeDiffrSpots needs but ``midas_diffract`` does not
76
+ expose -- the latter goes straight from eta to detector pixel coordinates,
77
+ skipping the lab-frame intermediate.
78
+ """
79
+ eta_rad = eta_deg * _DEG2RAD
80
+ yl = -ring_radius * torch.sin(eta_rad)
81
+ zl = ring_radius * torch.cos(eta_rad)
82
+ return yl, zl
83
+
84
+
85
+ # -----------------------------------------------------------------------------
86
+ # Lazy adapter to midas_diffract.HEDMForwardModel for the Bragg geometry call.
87
+ # -----------------------------------------------------------------------------
88
+
89
+
90
+ def _make_minimal_geometry(
91
+ distance_um: float, *, n_pixels: int = 2048, wedge_deg: float = 0.0
92
+ ):
93
+ """Build an HEDMGeometry whose pixel/beam fields are placeholders.
94
+
95
+ ``calc_bragg_geometry`` only reads ``self.wedge`` and ``self.epsilon``
96
+ (the Bragg quadratic itself doesn't touch pixels or beam centers), so any
97
+ consistent values for the unused fields work.
98
+ """
99
+ from midas_diffract.forward import HEDMGeometry
100
+
101
+ return HEDMGeometry(
102
+ Lsd=float(distance_um),
103
+ y_BC=0.0,
104
+ z_BC=0.0,
105
+ px=1.0,
106
+ omega_start=0.0,
107
+ omega_step=0.1,
108
+ n_frames=1,
109
+ n_pixels_y=n_pixels,
110
+ n_pixels_z=n_pixels,
111
+ min_eta=0.0,
112
+ wavelength=0.0,
113
+ wedge=float(wedge_deg),
114
+ flip_y=False,
115
+ )
116
+
117
+
118
+ def bragg_omega_eta(
119
+ orient_mats: torch.Tensor,
120
+ hkls: torch.Tensor,
121
+ thetas_deg: torch.Tensor,
122
+ *,
123
+ distance_um: float,
124
+ wedge_deg: float = 0.0,
125
+ device: Optional[torch.device] = None,
126
+ ):
127
+ """Compute (omega_deg, eta_deg, valid) by delegating to midas_diffract.
128
+
129
+ Wraps ``HEDMForwardModel.calc_bragg_geometry``: the work happens there.
130
+ Output is converted from radians (midas_diffract) to degrees (the
131
+ convention used by MakeDiffrSpots and the rest of this package).
132
+
133
+ Parameters
134
+ ----------
135
+ orient_mats : Tensor of shape ``(N, 3, 3)``.
136
+ hkls : Tensor of shape ``(M, 3)``.
137
+ thetas_deg : Tensor of shape ``(M,)`` -- Bragg angles in degrees.
138
+
139
+ Returns
140
+ -------
141
+ omega_deg : Tensor of shape ``(N, 2, M)`` -- two solutions per HKL.
142
+ eta_deg : Tensor of shape ``(N, 2, M)``.
143
+ valid : Bool Tensor of shape ``(N, 2, M)``.
144
+ """
145
+ from midas_diffract.forward import HEDMForwardModel
146
+
147
+ if orient_mats.ndim != 3 or orient_mats.shape[-2:] != (3, 3):
148
+ raise ValueError(
149
+ f"Expected (N, 3, 3), got shape {tuple(orient_mats.shape)}"
150
+ )
151
+ if hkls.shape[-1] != 3:
152
+ raise ValueError(f"Expected hkls last dim = 3, got {tuple(hkls.shape)}")
153
+
154
+ device = device or orient_mats.device
155
+
156
+ # Construct a one-shot model. The constructor stores hkls/thetas as
157
+ # buffers, but calc_bragg_geometry will use the explicit args we pass.
158
+ geom = _make_minimal_geometry(distance_um, wedge_deg=wedge_deg)
159
+ model = HEDMForwardModel(
160
+ hkls=hkls.to(device),
161
+ thetas=thetas_deg.to(device) * _DEG2RAD, # midas_diffract expects radians
162
+ geometry=geom,
163
+ device=device,
164
+ )
165
+
166
+ omega, eta, _two_theta, valid = model.calc_bragg_geometry(
167
+ orientation_matrices=orient_mats,
168
+ hkls_cart=hkls.to(device),
169
+ thetas=thetas_deg.to(device) * _DEG2RAD,
170
+ )
171
+ # midas_diffract layout: (..., 2N, M) -- the leading 2N is a *concatenation*
172
+ # of the two omega solutions across the N axis. For our (N, 1, 3, 3) input
173
+ # we get omega shape (1, 2*N, M); with N=N orientations this becomes
174
+ # (2*N, M) which we reshape into (N, 2, M).
175
+ # In our use we always pass orient_mats of shape (N, 3, 3) which makes
176
+ # midas_diffract interpret it as N "voxels" in a 1D batch. The concat
177
+ # along the K=2N axis stacks "omega_p (N entries)" then "omega_n (N
178
+ # entries)", so reshape with stride pattern needs care.
179
+ # Output of calc_bragg_geometry for our input shape:
180
+ # omega: (2*N, M) [no leading batch dim]
181
+ # The first N rows are omega_p (one per orientation); the next N rows
182
+ # are omega_n (same orientations, the other quadratic root). Reshape to
183
+ # (2, N, M) then permute to (N, 2, M).
184
+ n = orient_mats.shape[0]
185
+ omega_2nm = omega.reshape(2, n, -1).permute(1, 0, 2).contiguous()
186
+ eta_2nm = eta.reshape(2, n, -1).permute(1, 0, 2).contiguous()
187
+ valid_2nm = valid.reshape(2, n, -1).permute(1, 0, 2).bool().contiguous()
188
+
189
+ return (
190
+ omega_2nm * _RAD2DEG,
191
+ eta_2nm * _RAD2DEG,
192
+ valid_2nm,
193
+ )