probixi 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.
probixi/__init__.py ADDED
@@ -0,0 +1 @@
1
+ from .probixi import Probixi as Probixi
probixi/cli.py ADDED
@@ -0,0 +1,206 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal, Optional, cast
4
+
5
+ import click
6
+ import torch
7
+
8
+ from probixi.io import DataOffloader, PeakOffloader
9
+ from probixi.probixi import Probixi
10
+
11
+
12
+ @click.command()
13
+ @click.option(
14
+ "-i",
15
+ "--input",
16
+ "list_file",
17
+ required=True,
18
+ type=click.Path(exists=True, dir_okay=False),
19
+ help="CrystFEL list file (.lst) of HDF5 inputs.",
20
+ )
21
+ @click.option(
22
+ "-g",
23
+ "--geometry",
24
+ "geometry_file",
25
+ required=True,
26
+ type=click.Path(exists=True, dir_okay=False),
27
+ help="CrystFEL geometry file (.geom).",
28
+ )
29
+ @click.option(
30
+ "-p",
31
+ "--cell",
32
+ "cell_file",
33
+ default=None,
34
+ type=click.Path(exists=True, dir_okay=False),
35
+ help="CrystFEL unit-cell file (.cell). Required unless --peaks-only.",
36
+ )
37
+ @click.option(
38
+ "-o",
39
+ "--output",
40
+ "output",
41
+ required=True,
42
+ type=click.Path(dir_okay=False, writable=True),
43
+ help="Output CrystFEL-style .stream file.",
44
+ )
45
+ @click.option(
46
+ "--peaks-only",
47
+ is_flag=True,
48
+ help="Only run peak finding and export a peaks-only stream "
49
+ "Does not require a unit cell.",
50
+ )
51
+ @click.option(
52
+ "--gif",
53
+ "gif",
54
+ default=None,
55
+ type=click.Path(dir_okay=False, writable=True),
56
+ help="Also write a noise-model diagnostic GIF over the seed frames.",
57
+ )
58
+ @click.option("--start", type=int, default=None, help="First frame index (inclusive).")
59
+ @click.option("--stop", type=int, default=None, help="Stop frame index (exclusive).")
60
+ @click.option(
61
+ "--batch-size",
62
+ type=int,
63
+ default=8,
64
+ show_default=True,
65
+ help="Frames per batched refinement pass.",
66
+ )
67
+ @click.option("--device", default=None, help="Torch device (default: auto).")
68
+ @click.option(
69
+ "--noise-mode",
70
+ type=click.Choice(["online", "per_frame"]),
71
+ default="online",
72
+ show_default=True,
73
+ help="Noise-model update mode.",
74
+ )
75
+ @click.option("--warmup-frames", type=int, default=16, show_default=True)
76
+ @click.option(
77
+ "--seed-frames",
78
+ type=int,
79
+ default=32,
80
+ show_default=True,
81
+ help="Frames used to calibrate the noise model and detection threshold.",
82
+ )
83
+ @click.option(
84
+ "--target-noise-peaks",
85
+ type=float,
86
+ default=5.0,
87
+ show_default=True,
88
+ help="Calibrate the detection threshold so a signal-free frame "
89
+ "yields at most this many noise blobs.",
90
+ )
91
+ @click.option(
92
+ "--panel",
93
+ default="0",
94
+ show_default=True,
95
+ help="Fallback panel name for peaks outside all geometry panels.",
96
+ )
97
+ @click.option("-q", "--quiet", is_flag=True, help="Suppress per-frame progress.")
98
+ def main(
99
+ list_file: str,
100
+ geometry_file: str,
101
+ cell_file: Optional[str],
102
+ output: str,
103
+ peaks_only: bool,
104
+ gif: Optional[str],
105
+ start: Optional[int],
106
+ stop: Optional[int],
107
+ batch_size: int,
108
+ device: Optional[str],
109
+ noise_mode: str,
110
+ warmup_frames: int,
111
+ seed_frames: int,
112
+ target_noise_peaks: float,
113
+ panel: str,
114
+ quiet: bool,
115
+ ) -> None:
116
+ """Run the probixi pipeline and write indexed frames to a CrystFEL stream.
117
+
118
+ With --peaks-only, peak finding runs but indexing does not, and a
119
+ peaks-only stream is written instead.
120
+ """
121
+ if not peaks_only and cell_file is None:
122
+ raise click.UsageError(
123
+ "a unit cell (-p/--cell) is required unless --peaks-only"
124
+ )
125
+ dev = torch.device(device) if device else None
126
+ probixi = Probixi(
127
+ list_file=list_file,
128
+ geometry_file=geometry_file,
129
+ cell_file=cell_file,
130
+ noise_mode=cast("Literal['online', 'per_frame']", noise_mode),
131
+ warmup_frames=warmup_frames,
132
+ device=dev,
133
+ )
134
+
135
+ meta = probixi.metadata
136
+ if not quiet:
137
+ click.echo(f"Loaded {meta.n_frames} frames from {meta.n_files} file(s).")
138
+
139
+ if gif:
140
+ probixi.noise_diagnostics(
141
+ gif, stop=seed_frames, batch_size=max(2, seed_frames // 8)
142
+ )
143
+ if not quiet:
144
+ click.echo(f"Wrote noise diagnostic GIF to {gif}")
145
+
146
+ cal = probixi.calibrate(n_seed=seed_frames, target_noise_peaks=target_noise_peaks)
147
+ if not quiet:
148
+ tc = probixi.threshold_calibration
149
+ msg = (
150
+ f"Calibrated on {seed_frames} frames: kappa={cal.kappa:.2f} "
151
+ f"prior_peak={cal.prior_peak:.4f} var_scale={cal.var_scale:.3f}"
152
+ )
153
+ if tc is not None:
154
+ msg += f" mf_threshold={tc.threshold:.2f}"
155
+ click.echo(msg)
156
+
157
+ frames = probixi.frames(start=start, stop=stop)
158
+
159
+ if peaks_only:
160
+ peaks = probixi.peak_stream(
161
+ frames, start_index=start or 0, estimate_scale=False
162
+ )
163
+ with PeakOffloader(
164
+ output,
165
+ geometry=probixi.geometry,
166
+ geometry_file=geometry_file,
167
+ files=meta.files,
168
+ panel=panel,
169
+ ) as off:
170
+ n = 0
171
+ for result in peaks:
172
+ if len(result) == 0:
173
+ continue
174
+ off.write(result)
175
+ n += 1
176
+ if not quiet:
177
+ click.echo(f" frame {result.frame_index}: {len(result)} peaks")
178
+ click.echo(f"Wrote peaks for {n} frame(s) to {output}")
179
+ return
180
+
181
+ stream = probixi.index_stream(frames, batch_size=batch_size, start_index=start or 0)
182
+
183
+ with DataOffloader(
184
+ output,
185
+ geometry=probixi.geometry,
186
+ cell=probixi.target_cell,
187
+ geometry_file=geometry_file,
188
+ files=meta.files,
189
+ panel=panel,
190
+ ) as off:
191
+ n = 0
192
+ for result in stream:
193
+ off.write(result)
194
+ n += 1
195
+ if not quiet:
196
+ click.echo(
197
+ f" frame {result.frame_index}: "
198
+ f"{result.n_indexed}/{result.n_peaks} peaks indexed "
199
+ f"(rmsd {result.rmsd:.4f})"
200
+ )
201
+
202
+ click.echo(f"Wrote {n} indexed frame(s) to {output}")
203
+
204
+
205
+ if __name__ == "__main__":
206
+ main()
@@ -0,0 +1,17 @@
1
+ from .indexer import (
2
+ CellMatchConfig,
3
+ Indexer,
4
+ IndexStream,
5
+ IntegrateConfig,
6
+ RefineConfig,
7
+ SeedConfig,
8
+ )
9
+
10
+ __all__ = [
11
+ "Indexer",
12
+ "IndexStream",
13
+ "CellMatchConfig",
14
+ "IntegrateConfig",
15
+ "RefineConfig",
16
+ "SeedConfig",
17
+ ]
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ import torch
6
+ from torch import Tensor
7
+
8
+
9
+ def _geometry_constants(
10
+ geometry: dict,
11
+ device: Optional[torch.device] = None,
12
+ dtype: torch.dtype = torch.float64,
13
+ ) -> dict:
14
+ bc = geometry["beam_center"]
15
+ return {
16
+ "bc_row": torch.tensor(float(bc[0]), dtype=dtype, device=device),
17
+ "bc_col": torch.tensor(float(bc[1]), dtype=dtype, device=device),
18
+ "clen_A": torch.tensor(
19
+ float(geometry["clen"]) * 1e10, dtype=dtype, device=device
20
+ ),
21
+ "pix_A": torch.tensor(
22
+ float(geometry["pixel_size"]) * 1e10, dtype=dtype, device=device
23
+ ),
24
+ "wavelength_A": torch.tensor(
25
+ float(geometry["wavelength"]), dtype=dtype, device=device
26
+ ),
27
+ }
28
+
29
+
30
+ def detector_to_q(
31
+ positions: Tensor,
32
+ geometry: dict,
33
+ frame_rotation: Optional[Tensor] = None,
34
+ dtype: torch.dtype = torch.float64,
35
+ ) -> Tensor:
36
+ """Map detector pixel positions to reciprocal-space q-vectors.
37
+
38
+ Elastic (Ewald) construction with the beam along +z: a pixel at lab position
39
+ ``(x, y, clen)`` defines a scattered unit direction ``s_hat`` and
40
+ ``q = (s_hat - s0_hat) / lambda`` with ``s0_hat = (0, 0, 1)``.
41
+
42
+ Parameters
43
+ ----------
44
+ positions : Tensor
45
+ (N, 2) detector ``(row, col)`` pixel positions.
46
+ geometry : dict
47
+ Geometry dict (``beam_center``, ``clen``, ``pixel_size``, ``wavelength``).
48
+ frame_rotation : Tensor, optional
49
+ (3, 3) rotation taking q from lab into the crystal frame.
50
+ dtype : torch.dtype, optional
51
+ Working dtype (default ``torch.float64``).
52
+
53
+ Returns
54
+ -------
55
+ Tensor
56
+ (N, 3) reciprocal-space q-vectors (A^-1).
57
+ """
58
+ if positions.ndim != 2 or positions.shape[-1] != 2:
59
+ raise ValueError("positions must be (N, 2)")
60
+ device = positions.device
61
+ g = _geometry_constants(geometry, device=device, dtype=dtype)
62
+ pos = positions.to(device=device, dtype=dtype)
63
+ rows, cols = pos[:, 0], pos[:, 1]
64
+ # pixel offset from beam center translation to lab-frame coordinates \AA
65
+ x_lab = (cols - g["bc_col"]) * g["pix_A"]
66
+ y_lab = (rows - g["bc_row"]) * g["pix_A"]
67
+ z_lab = g["clen_A"].expand_as(x_lab)
68
+ r = torch.sqrt(x_lab * x_lab + y_lab * y_lab + z_lab * z_lab)
69
+ inv_lambda = 1.0 / g["wavelength_A"]
70
+ qx = (x_lab / r) * inv_lambda
71
+ qy = (y_lab / r) * inv_lambda
72
+ qz = (z_lab / r - 1.0) * inv_lambda
73
+ q_lab = torch.stack([qx, qy, qz], dim=-1)
74
+ if frame_rotation is None:
75
+ return q_lab
76
+ R = frame_rotation.to(device=device, dtype=dtype)
77
+ if R.shape != (3, 3):
78
+ raise ValueError("frame_rotation must be (3, 3)")
79
+ return q_lab @ R
80
+
81
+
82
+ def q_to_detector(
83
+ q: Tensor,
84
+ geometry: dict,
85
+ frame_rotation: Optional[Tensor] = None,
86
+ dtype: torch.dtype = torch.float64,
87
+ ) -> Tensor:
88
+ # S = q*lambda + s0
89
+ # Intersect the ray with z = clen.
90
+ if q.ndim != 2 or q.shape[-1] != 3:
91
+ raise ValueError("q must be (N, 3)")
92
+ device = q.device
93
+ g = _geometry_constants(geometry, device=device, dtype=dtype)
94
+ qv = q.to(device=device, dtype=dtype)
95
+ if frame_rotation is not None:
96
+ R = frame_rotation.to(device=device, dtype=dtype)
97
+ if R.shape != (3, 3):
98
+ raise ValueError("frame_rotation must be (3, 3)")
99
+ qv = qv @ R.transpose(-1, -2)
100
+ inv_lambda = 1.0 / g["wavelength_A"]
101
+ Sx = qv[:, 0] / inv_lambda
102
+ Sy = qv[:, 1] / inv_lambda
103
+ Sz = qv[:, 2] / inv_lambda + 1.0
104
+ scale = g["clen_A"] / Sz.clamp_min(1e-30)
105
+ col = (Sx * scale) / g["pix_A"] + g["bc_col"]
106
+ row = (Sy * scale) / g["pix_A"] + g["bc_row"]
107
+ return torch.stack([row, col], dim=-1)