torchsx 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.
- torchsx/__init__.py +8 -0
- torchsx/_categorical.py +58 -0
- torchsx/_core.py +602 -0
- torchsx/cli/__init__.py +0 -0
- torchsx/cli/_pipeline.py +912 -0
- torchsx/cli/merge_nn.py +205 -0
- torchsx/cli/merge_stats.py +217 -0
- torchsx/cli/stats.py +95 -0
- torchsx/cli/train.py +278 -0
- torchsx/geometry.py +745 -0
- torchsx/io/__init__.py +30 -0
- torchsx/io/_crystfel.py +105 -0
- torchsx/io/_duckdb.py +258 -0
- torchsx/io/_metadata.py +187 -0
- torchsx/io/_native.py +48 -0
- torchsx/io/_parse/__init__.py +8 -0
- torchsx/io/_parse/indexing.py +186 -0
- torchsx/io/_parse/orchestrator.py +258 -0
- torchsx/io/_parse/records.py +520 -0
- torchsx/io/_parse/workers.py +35 -0
- torchsx/io/_parser.py +40 -0
- torchsx/io/_safetensors.py +170 -0
- torchsx/io/_writeback.py +463 -0
- torchsx/io/sources.py +76 -0
- torchsx/io/times.py +438 -0
- torchsx/levels.py +306 -0
- torchsx/measurement.py +217 -0
- torchsx/merge_stats.py +672 -0
- torchsx/merging/__init__.py +30 -0
- torchsx/merging/config.py +52 -0
- torchsx/merging/hkl/__init__.py +75 -0
- torchsx/merging/hkl/adjacency.py +71 -0
- torchsx/merging/hkl/filter.py +177 -0
- torchsx/merging/hkl/hkl.py +118 -0
- torchsx/merging/hkl/noise.py +451 -0
- torchsx/merging/hkl/shell_fit.py +102 -0
- torchsx/merging/hkl/stats.py +294 -0
- torchsx/merging/likelihood/__init__.py +10 -0
- torchsx/merging/likelihood/base.py +64 -0
- torchsx/merging/likelihood/detector_noise.py +104 -0
- torchsx/merging/likelihood/poisson.py +173 -0
- torchsx/merging/observations.py +699 -0
- torchsx/merging/parameters.py +77 -0
- torchsx/merging/priors/__init__.py +13 -0
- torchsx/merging/priors/reciprocal_basis.py +293 -0
- torchsx/merging/quadrature/__init__.py +15 -0
- torchsx/merging/quadrature/_fixed_log_grid_triton.py +426 -0
- torchsx/merging/quadrature/_fixed_log_grid_tuning.json +44 -0
- torchsx/merging/quadrature/_fused_posterior_triton.py +249 -0
- torchsx/merging/quadrature/_posterior_nodes_triton.py +378 -0
- torchsx/merging/quadrature/_split_log_grid_triton.py +294 -0
- torchsx/merging/quadrature/_split_mixture_triton.py +218 -0
- torchsx/merging/quadrature/base.py +98 -0
- torchsx/merging/quadrature/fixed_log_grid.py +305 -0
- torchsx/merging/quadrature/split_log_grid.py +392 -0
- torchsx/merging/scale/__init__.py +10 -0
- torchsx/merging/scale/amortized.py +452 -0
- torchsx/merging/scale/base.py +44 -0
- torchsx/merging/scatter/__init__.py +22 -0
- torchsx/merging/scatter/base.py +54 -0
- torchsx/merging/scatter/gengauss.py +314 -0
- torchsx/merging/scatter/mixture.py +269 -0
- torchsx/merging/scatter/predicted.py +119 -0
- torchsx/merging/scatter_shape.py +213 -0
- torchsx/nn/__init__.py +41 -0
- torchsx/nn/deepsets.py +579 -0
- torchsx/nn/heads.py +370 -0
- torchsx/nn/merge_target.py +67 -0
- torchsx/nn/metrics.py +397 -0
- torchsx/nn/objective.py +380 -0
- torchsx/nn/obs_features.py +424 -0
- torchsx/nn/pad.py +78 -0
- torchsx/nn/pooling.py +395 -0
- torchsx/nn/posterior_target.py +662 -0
- torchsx/nn/shape_prior.py +98 -0
- torchsx/nn/train_amortized.py +1640 -0
- torchsx/stats.py +302 -0
- torchsx/stream.py +826 -0
- torchsx-0.1.0.dist-info/METADATA +182 -0
- torchsx-0.1.0.dist-info/RECORD +84 -0
- torchsx-0.1.0.dist-info/WHEEL +5 -0
- torchsx-0.1.0.dist-info/entry_points.txt +5 -0
- torchsx-0.1.0.dist-info/licenses/LICENSE +21 -0
- torchsx-0.1.0.dist-info/top_level.txt +1 -0
torchsx/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from torchsx.stream import SFXStream
|
|
2
|
+
from torchsx.geometry import Geometry
|
|
3
|
+
from torchsx.io import StreamMetadata
|
|
4
|
+
from torchsx.io.sources import SourceInput
|
|
5
|
+
from torchsx.measurement import MeasurementSpec
|
|
6
|
+
from torchsx.merging.hkl import compute_hkl_index, merge_intensities, HKLIndex
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
torchsx/_categorical.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Shared string-table (categorical) encode/decode helpers.
|
|
2
|
+
|
|
3
|
+
Both :class:`torchsx.io._metadata.StreamMetadata` and :class:`torchsx.levels.Level`
|
|
4
|
+
store lists of strings as an order-preserving unique table plus an ``int32`` index
|
|
5
|
+
tensor. That ~3-line encode/decode core lived in two places; it lives here now.
|
|
6
|
+
|
|
7
|
+
These are intentionally tiny, pure functions — the two callers keep their own
|
|
8
|
+
storage containers (a JSON-serialisable schema dict vs. a typed ``ColumnSpec``),
|
|
9
|
+
which legitimately differ. Only the leaf encode/decode/remap is shared.
|
|
10
|
+
|
|
11
|
+
The on-disk shape is load-bearing: the table is the order-preserving unique list,
|
|
12
|
+
the index tensor is ``int32``. Do not change either without a format migration.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import torch
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def encode_categorical(values: list[str]) -> tuple[list[str], torch.Tensor]:
|
|
21
|
+
"""Encode strings as an order-preserving unique table + int32 index tensor.
|
|
22
|
+
|
|
23
|
+
Returns ``(table, idx)`` where ``table`` is the deduplicated list (first
|
|
24
|
+
occurrence wins) and ``idx`` is an ``int32`` tensor of positions into it.
|
|
25
|
+
"""
|
|
26
|
+
table = list(dict.fromkeys(values)) # unique, order-preserving
|
|
27
|
+
lookup = {s: i for i, s in enumerate(table)}
|
|
28
|
+
idx = torch.tensor([lookup[s] for s in values], dtype=torch.int32)
|
|
29
|
+
return table, idx
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def decode_categorical(table: list[str], idx: torch.Tensor) -> list[str]:
|
|
33
|
+
"""Inverse of :func:`encode_categorical`: index ``table`` by ``idx``."""
|
|
34
|
+
return [table[i] for i in idx.tolist()]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def remap_to_unified_table(
|
|
38
|
+
tables: list[list[str]], idx_tensors: list[torch.Tensor]
|
|
39
|
+
) -> tuple[list[str], list[torch.Tensor]]:
|
|
40
|
+
"""Merge several ``(table, idx)`` shards onto one unified string table.
|
|
41
|
+
|
|
42
|
+
Used when concatenating categorical columns whose shards may carry different
|
|
43
|
+
local tables. Returns ``(unified_table, remapped_idx_tensors)``; each output
|
|
44
|
+
tensor preserves its input's device and is ``int32``.
|
|
45
|
+
"""
|
|
46
|
+
unified: list[str] = []
|
|
47
|
+
lookup: dict[str, int] = {}
|
|
48
|
+
remapped: list[torch.Tensor] = []
|
|
49
|
+
for table, old in zip(tables, idx_tensors):
|
|
50
|
+
for s in table:
|
|
51
|
+
if s not in lookup:
|
|
52
|
+
lookup[s] = len(unified)
|
|
53
|
+
unified.append(s)
|
|
54
|
+
remap = torch.tensor(
|
|
55
|
+
[lookup[s] for s in table], dtype=torch.int32, device=old.device
|
|
56
|
+
)
|
|
57
|
+
remapped.append(remap[old.long()].to(torch.int32))
|
|
58
|
+
return unified, remapped
|
torchsx/_core.py
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
"""Columnar stream cores: BaseStream → SFXStreamCore → ShoeboxStreamCore.
|
|
2
|
+
|
|
3
|
+
The three-level SX data model (observation / crystal / image) is stored as a set
|
|
4
|
+
of :class:`~torchsx.levels.Level` columnar tables joined by integer key columns
|
|
5
|
+
(``crystal_id``: obs→crystal, ``image_id``: crystal→image). ``BaseStream`` holds
|
|
6
|
+
the generic columnar/join machinery; ``SFXStreamCore`` adds the SX field layout,
|
|
7
|
+
the legacy keyword constructor, and validation; ``ShoeboxStreamCore`` is just an
|
|
8
|
+
``SFXStreamCore`` that additionally carries per-observation pixel patches.
|
|
9
|
+
|
|
10
|
+
``_SFXStreamCore`` is kept as an alias of ``SFXStreamCore`` so existing imports and
|
|
11
|
+
the keyword constructor ``_SFXStreamCore(hkl=…, I=…, …)`` keep working unchanged;
|
|
12
|
+
attribute access (``core.hkl``, ``core.cell``, ``core.beam_energy``) is served by a
|
|
13
|
+
``__getattr__`` shim over the levels.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
|
|
21
|
+
import torch
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def abspath_str(p: str) -> str:
|
|
25
|
+
"""Resolve a path string to an absolute, ``~``-expanded path.
|
|
26
|
+
|
|
27
|
+
Streams store filesystem paths (``source_path``, per-image ``filename``) so
|
|
28
|
+
that downstream consumers — finetuning / refinement reloads frames by
|
|
29
|
+
``metadata['image']['filename']``, ``write_stream`` copies a header from
|
|
30
|
+
``source_path`` — resolve correctly regardless of the current working
|
|
31
|
+
directory. Normalising to absolute at every ingress makes that robust.
|
|
32
|
+
|
|
33
|
+
Empty strings (e.g. an un-indexed image's filename placeholder) and
|
|
34
|
+
non-strings pass through unchanged; the call is idempotent on paths that are
|
|
35
|
+
already absolute.
|
|
36
|
+
"""
|
|
37
|
+
if not isinstance(p, str) or not p:
|
|
38
|
+
return p
|
|
39
|
+
return os.path.abspath(os.path.expanduser(p))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def absolutize_image_filenames(metadata) -> None:
|
|
43
|
+
"""In place: rewrite ``metadata['image']['filename']`` entries to absolute paths.
|
|
44
|
+
|
|
45
|
+
Used at the load boundary so a stream written with relative paths comes back
|
|
46
|
+
with absolute ones. Safe on freshly-built metadata; no-op when the image
|
|
47
|
+
filename list is absent.
|
|
48
|
+
"""
|
|
49
|
+
img = metadata.get("image") if metadata else None
|
|
50
|
+
if not img:
|
|
51
|
+
return
|
|
52
|
+
fnames = img.get("filename")
|
|
53
|
+
if fnames:
|
|
54
|
+
img["filename"] = [abspath_str(f) for f in fnames]
|
|
55
|
+
|
|
56
|
+
from torchsx.geometry import Geometry
|
|
57
|
+
from torchsx.io._metadata import StreamMetadata
|
|
58
|
+
from torchsx.levels import ColumnSpec, Level
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# Field → level routing for the SX layout (drives the legacy keyword constructor).
|
|
62
|
+
_OBS_FIELDS = ["hkl", "I", "sigma", "background", "peak", "fs", "ss", "panel_id", "crystal_id"]
|
|
63
|
+
_CRYSTAL_FIELDS = [
|
|
64
|
+
"astar", "bstar", "cstar", "cell", "profile_rad", "resolution",
|
|
65
|
+
"n_refl", "image_id", "crystal_in_image",
|
|
66
|
+
]
|
|
67
|
+
_IMAGE_FIELDS = ["beam_energy", "det_shift_x", "det_shift_y", "n_peaks", "camera_length"]
|
|
68
|
+
|
|
69
|
+
_FIELD_TO_LEVEL: dict[str, str] = {
|
|
70
|
+
**{f: "obs" for f in _OBS_FIELDS},
|
|
71
|
+
**{f: "crystal" for f in _CRYSTAL_FIELDS},
|
|
72
|
+
**{f: "image" for f in _IMAGE_FIELDS},
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
# Columns whose canonical dtype must be set explicitly (everything else float32/given).
|
|
76
|
+
_CANONICAL_INT_DTYPE = {
|
|
77
|
+
"hkl": torch.int16,
|
|
78
|
+
"panel_id": torch.int16,
|
|
79
|
+
"crystal_id": torch.int32,
|
|
80
|
+
"n_refl": torch.int32,
|
|
81
|
+
"image_id": torch.int32,
|
|
82
|
+
"crystal_in_image": torch.int32,
|
|
83
|
+
"n_peaks": torch.int32,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class JoinSpec:
|
|
89
|
+
"""A directed join edge: ``child_level.key_column → parent_level``."""
|
|
90
|
+
|
|
91
|
+
child_level: str
|
|
92
|
+
key_column: str
|
|
93
|
+
parent_level: str
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _default_sx_joins() -> list[JoinSpec]:
|
|
97
|
+
return [
|
|
98
|
+
JoinSpec("obs", "crystal_id", "crystal"),
|
|
99
|
+
JoinSpec("crystal", "image_id", "image"),
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _sx_joins_with_peaks() -> list[JoinSpec]:
|
|
104
|
+
"""Join set for a stream that carries a raw peak-search ``peak`` level.
|
|
105
|
+
|
|
106
|
+
The ``peak`` level holds one row per spot found by the peak finder (indexed or
|
|
107
|
+
not) and joins straight to ``image`` (peaks are an image-level product, not a
|
|
108
|
+
crystal one). Used by any reduction front-end that carries the peak search
|
|
109
|
+
through (probixi's ``peaks`` table, say); the two-join default is kept for
|
|
110
|
+
peak-less streams (parsed/legacy).
|
|
111
|
+
"""
|
|
112
|
+
return _default_sx_joins() + [JoinSpec("peak", "peak_image_id", "image")]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class BaseStream:
|
|
116
|
+
"""Generic columnar container: named :class:`Level`s + a join registry.
|
|
117
|
+
|
|
118
|
+
Provenance-agnostic. Filtering/transforms return new objects of the most
|
|
119
|
+
specific subclass (:meth:`refine_type`); attribute access falls through to
|
|
120
|
+
the level columns via :meth:`__getattr__`.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
LEVEL_ORDER = ("obs", "crystal", "image")
|
|
124
|
+
|
|
125
|
+
def __init__(
|
|
126
|
+
self,
|
|
127
|
+
levels: dict[str, Level],
|
|
128
|
+
joins: list[JoinSpec] | None = None,
|
|
129
|
+
geometry: Geometry | None = None,
|
|
130
|
+
metadata: StreamMetadata | None = None,
|
|
131
|
+
spacegroup: str = "P 1",
|
|
132
|
+
source_path: str = "",
|
|
133
|
+
friedel_merged: bool = True,
|
|
134
|
+
):
|
|
135
|
+
self.levels = levels
|
|
136
|
+
self.joins = joins if joins is not None else _default_sx_joins()
|
|
137
|
+
self.geometry = geometry
|
|
138
|
+
self.metadata = metadata if metadata is not None else StreamMetadata()
|
|
139
|
+
self.spacegroup = spacegroup
|
|
140
|
+
# Always store provenance paths absolute (idempotent; "" passes through).
|
|
141
|
+
self.source_path = abspath_str(source_path)
|
|
142
|
+
# Whether ``hkl`` has already been Friedel-merged (canonicalized to the
|
|
143
|
+
# P-1 ASU, mates collapsed). Drives anomalous-aware MTZ writing.
|
|
144
|
+
# Default True.
|
|
145
|
+
self.friedel_merged = bool(friedel_merged)
|
|
146
|
+
# Optional detector good-pixel mask ``(P, ss, fs)`` bool (True=good) built by
|
|
147
|
+
# the masking pass. Carried here so it can be embedded in the .sfx; not threaded
|
|
148
|
+
# through transforms (set on a freshly built/loaded core). See torchsx.io._safetensors.
|
|
149
|
+
self.detector_mask: torch.Tensor | None = None
|
|
150
|
+
|
|
151
|
+
# ---- attribute-compat shim ----------------------------------------
|
|
152
|
+
|
|
153
|
+
def __getattr__(self, name: str) -> torch.Tensor:
|
|
154
|
+
# Only invoked when normal lookup fails; guard against recursion pre-init.
|
|
155
|
+
levels = self.__dict__.get("levels")
|
|
156
|
+
if levels:
|
|
157
|
+
for lvl in levels.values():
|
|
158
|
+
if name in lvl:
|
|
159
|
+
return lvl[name]
|
|
160
|
+
raise AttributeError(name)
|
|
161
|
+
|
|
162
|
+
# ---- sizes ---------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def n_observations(self) -> int:
|
|
166
|
+
return self.levels["obs"].n
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def n_crystals(self) -> int:
|
|
170
|
+
return self.levels["crystal"].n
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def n_images(self) -> int:
|
|
174
|
+
return self.levels["image"].n
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def device(self) -> torch.device:
|
|
178
|
+
return self.levels["obs"].device
|
|
179
|
+
|
|
180
|
+
# ---- construction helpers -----------------------------------------
|
|
181
|
+
|
|
182
|
+
@classmethod
|
|
183
|
+
def _make(
|
|
184
|
+
cls,
|
|
185
|
+
levels: dict[str, Level],
|
|
186
|
+
joins: list[JoinSpec],
|
|
187
|
+
geometry: Geometry | None,
|
|
188
|
+
metadata: StreamMetadata,
|
|
189
|
+
spacegroup: str,
|
|
190
|
+
source_path: str,
|
|
191
|
+
friedel_merged: bool = True,
|
|
192
|
+
) -> "BaseStream":
|
|
193
|
+
"""Build a new stream from levels, bypassing the legacy constructor,
|
|
194
|
+
and refine to the most specific subclass given which columns exist."""
|
|
195
|
+
target = _classify(levels)
|
|
196
|
+
obj = target.__new__(target)
|
|
197
|
+
BaseStream.__init__(
|
|
198
|
+
obj, levels, joins, geometry, metadata, spacegroup, source_path,
|
|
199
|
+
friedel_merged=friedel_merged,
|
|
200
|
+
)
|
|
201
|
+
obj._validate()
|
|
202
|
+
return obj
|
|
203
|
+
|
|
204
|
+
def refine_type(self) -> "BaseStream":
|
|
205
|
+
"""Return self re-typed to the most specific subclass for its columns."""
|
|
206
|
+
target = _classify(self.levels)
|
|
207
|
+
if type(self) is target:
|
|
208
|
+
return self
|
|
209
|
+
return self._make(
|
|
210
|
+
self.levels, self.joins, self.geometry, self.metadata,
|
|
211
|
+
self.spacegroup, self.source_path,
|
|
212
|
+
friedel_merged=self.friedel_merged,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def _validate(self) -> None:
|
|
216
|
+
"""Generic validation: join-key columns index into their parent level."""
|
|
217
|
+
for join in self.joins:
|
|
218
|
+
child, parent = self.levels.get(join.child_level), self.levels.get(join.parent_level)
|
|
219
|
+
if child is None or parent is None or join.key_column not in child:
|
|
220
|
+
continue
|
|
221
|
+
if child.n > 0:
|
|
222
|
+
key = child[join.key_column]
|
|
223
|
+
assert key.min() >= 0, f"{join.key_column} has negative values"
|
|
224
|
+
assert key.max() < parent.n, (
|
|
225
|
+
f"{join.key_column} max {int(key.max())} >= "
|
|
226
|
+
f"n_{join.parent_level} {parent.n}"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# ---- immutable transforms -----------------------------------------
|
|
230
|
+
|
|
231
|
+
def to(self, device) -> "BaseStream":
|
|
232
|
+
new_levels = {name: lvl.to(device) for name, lvl in self.levels.items()}
|
|
233
|
+
meta = self.metadata.to(device) if self.metadata else StreamMetadata()
|
|
234
|
+
return self._make(
|
|
235
|
+
new_levels, self.joins, self.geometry, meta, self.spacegroup, self.source_path,
|
|
236
|
+
friedel_merged=self.friedel_merged,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def select_cascade(self, masks: dict[str, torch.Tensor]) -> "BaseStream":
|
|
240
|
+
"""Apply per-level boolean masks and rebuild contiguous join keys.
|
|
241
|
+
|
|
242
|
+
``masks`` need only supply a bool mask for the levels the caller cares
|
|
243
|
+
about (typically obs/crystal/image). Any child level present in the
|
|
244
|
+
stream but *absent* from ``masks`` (e.g. the raw ``peak`` level) has its
|
|
245
|
+
mask **derived** from its parent's: a child row survives iff its parent
|
|
246
|
+
does. This keeps every level join-consistent without each filter needing
|
|
247
|
+
to know about every level. Each column is then subset in one loop and
|
|
248
|
+
each :class:`JoinSpec`'s key column is remapped onto the surviving parent
|
|
249
|
+
rows.
|
|
250
|
+
"""
|
|
251
|
+
# Derive masks for child levels that the caller didn't supply, from the
|
|
252
|
+
# parent mask through the join (fixpoint: handles chains parent→child→…).
|
|
253
|
+
masks = dict(masks)
|
|
254
|
+
changed = True
|
|
255
|
+
while changed:
|
|
256
|
+
changed = False
|
|
257
|
+
for join in self.joins:
|
|
258
|
+
child = self.levels.get(join.child_level)
|
|
259
|
+
if child is None or join.key_column not in child:
|
|
260
|
+
continue
|
|
261
|
+
if join.child_level in masks:
|
|
262
|
+
continue
|
|
263
|
+
parent_mask = masks.get(join.parent_level)
|
|
264
|
+
if parent_mask is None:
|
|
265
|
+
continue
|
|
266
|
+
key = child[join.key_column].long()
|
|
267
|
+
masks[join.child_level] = parent_mask[key]
|
|
268
|
+
changed = True
|
|
269
|
+
|
|
270
|
+
new_levels: dict[str, Level] = {}
|
|
271
|
+
for name, lvl in self.levels.items():
|
|
272
|
+
mask = masks.get(name)
|
|
273
|
+
new_levels[name] = lvl.select(mask) if mask is not None else lvl
|
|
274
|
+
|
|
275
|
+
for join in self.joins:
|
|
276
|
+
child_level = self.levels.get(join.child_level)
|
|
277
|
+
if child_level is None or join.key_column not in child_level:
|
|
278
|
+
continue
|
|
279
|
+
child_mask = masks.get(join.child_level)
|
|
280
|
+
parent_mask = masks.get(join.parent_level)
|
|
281
|
+
if parent_mask is None:
|
|
282
|
+
continue
|
|
283
|
+
old_key = self.levels[join.child_level][join.key_column]
|
|
284
|
+
kept_parent = parent_mask.nonzero(as_tuple=True)[0]
|
|
285
|
+
remap = torch.full(
|
|
286
|
+
(self.levels[join.parent_level].n,), -1,
|
|
287
|
+
dtype=old_key.dtype, device=old_key.device,
|
|
288
|
+
)
|
|
289
|
+
remap[kept_parent] = torch.arange(
|
|
290
|
+
kept_parent.shape[0], dtype=old_key.dtype, device=old_key.device
|
|
291
|
+
)
|
|
292
|
+
sub_key = old_key[child_mask] if child_mask is not None else old_key
|
|
293
|
+
new_key = remap[sub_key.long()]
|
|
294
|
+
new_levels[join.child_level].__setitem__(
|
|
295
|
+
join.key_column, new_key,
|
|
296
|
+
self.levels[join.child_level].schema[join.key_column],
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
meta = _subset_metadata(self.metadata, masks)
|
|
300
|
+
return self._make(
|
|
301
|
+
new_levels, self.joins, self.geometry, meta, self.spacegroup, self.source_path,
|
|
302
|
+
friedel_merged=self.friedel_merged,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
def with_columns(self, level: str, **columns: torch.Tensor) -> "BaseStream":
|
|
306
|
+
"""Return a new stream with extra/overwritten columns on ``level``.
|
|
307
|
+
|
|
308
|
+
Unlike :meth:`replace` (which only routes known SX fields), this adds
|
|
309
|
+
arbitrary new columns (e.g. ``shoebox``, ``peak_snr``, indexing
|
|
310
|
+
confidence) — the columnar extensibility hook.
|
|
311
|
+
"""
|
|
312
|
+
if level not in self.levels:
|
|
313
|
+
raise KeyError(f"unknown level {level!r}")
|
|
314
|
+
new_levels = dict(self.levels)
|
|
315
|
+
base = self.levels[level]
|
|
316
|
+
fresh = Level()
|
|
317
|
+
for cname, tensor in base.columns.items():
|
|
318
|
+
fresh.__setitem__(cname, columns.get(cname, tensor), base.schema.get(cname))
|
|
319
|
+
for cname, tensor in columns.items():
|
|
320
|
+
if cname not in fresh:
|
|
321
|
+
fresh.__setitem__(cname, tensor)
|
|
322
|
+
new_levels[level] = fresh
|
|
323
|
+
return self._make(
|
|
324
|
+
new_levels, self.joins, self.geometry, self.metadata,
|
|
325
|
+
self.spacegroup, self.source_path,
|
|
326
|
+
friedel_merged=self.friedel_merged,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
def broadcast(self, column: str, from_level: str, to_level: str = "obs") -> torch.Tensor:
|
|
330
|
+
"""Gather a column from ``from_level`` down to ``to_level`` through joins.
|
|
331
|
+
|
|
332
|
+
e.g. ``broadcast("beam_energy", "image", "obs")`` →
|
|
333
|
+
``beam_energy[image_id][crystal_id]``.
|
|
334
|
+
"""
|
|
335
|
+
col = self.levels[from_level][column]
|
|
336
|
+
path = []
|
|
337
|
+
cur = to_level
|
|
338
|
+
while cur != from_level:
|
|
339
|
+
join = next(j for j in self.joins if j.child_level == cur)
|
|
340
|
+
path.append(self.levels[cur][join.key_column])
|
|
341
|
+
cur = join.parent_level
|
|
342
|
+
for key in reversed(path):
|
|
343
|
+
col = col[key.long()]
|
|
344
|
+
return col
|
|
345
|
+
|
|
346
|
+
def replace(self, **overrides) -> "BaseStream":
|
|
347
|
+
"""Return a new stream with the given column/attribute overrides.
|
|
348
|
+
|
|
349
|
+
Accepts SX field names (rerouted to their level), level names, and the
|
|
350
|
+
scalar attributes ``geometry``/``metadata``/``spacegroup``/``source_path``.
|
|
351
|
+
Mirrors ``dataclasses.replace`` for the facade.
|
|
352
|
+
"""
|
|
353
|
+
attrs: dict[str, object] = {
|
|
354
|
+
"geometry": self.geometry, "metadata": self.metadata,
|
|
355
|
+
"spacegroup": self.spacegroup, "source_path": self.source_path,
|
|
356
|
+
"friedel_merged": self.friedel_merged,
|
|
357
|
+
}
|
|
358
|
+
level_over: dict[str, Level] = {}
|
|
359
|
+
col_over: dict[str, dict[str, torch.Tensor]] = {}
|
|
360
|
+
for key, value in overrides.items():
|
|
361
|
+
if key in attrs:
|
|
362
|
+
attrs[key] = value
|
|
363
|
+
elif key in self.levels:
|
|
364
|
+
level_over[key] = value
|
|
365
|
+
else:
|
|
366
|
+
level_name = _FIELD_TO_LEVEL.get(key)
|
|
367
|
+
if level_name is None:
|
|
368
|
+
raise KeyError(f"replace: unknown field/level {key!r}")
|
|
369
|
+
col_over.setdefault(level_name, {})[key] = value
|
|
370
|
+
|
|
371
|
+
new_levels: dict[str, Level] = {}
|
|
372
|
+
for name, lvl in self.levels.items():
|
|
373
|
+
if name in level_over:
|
|
374
|
+
new_levels[name] = level_over[name]
|
|
375
|
+
continue
|
|
376
|
+
overs = col_over.get(name)
|
|
377
|
+
if not overs:
|
|
378
|
+
new_levels[name] = lvl # unchanged — reuse immutable level
|
|
379
|
+
continue
|
|
380
|
+
# Rebuild fresh so an override may change the level's row count
|
|
381
|
+
# (e.g. expand-mode update_observations replaces all obs columns).
|
|
382
|
+
fresh = Level()
|
|
383
|
+
for cname in list(lvl.columns.keys()) + [c for c in overs if c not in lvl.columns]:
|
|
384
|
+
val = overs.get(cname, lvl.columns.get(cname))
|
|
385
|
+
fresh.__setitem__(cname, val, lvl.schema.get(cname))
|
|
386
|
+
new_levels[name] = fresh
|
|
387
|
+
|
|
388
|
+
obj = self._make(
|
|
389
|
+
new_levels, self.joins, attrs["geometry"], attrs["metadata"],
|
|
390
|
+
attrs["spacegroup"], attrs["source_path"],
|
|
391
|
+
friedel_merged=attrs["friedel_merged"],
|
|
392
|
+
)
|
|
393
|
+
obj.detector_mask = self.detector_mask # detector-level constant; survives replace
|
|
394
|
+
return obj
|
|
395
|
+
|
|
396
|
+
@classmethod
|
|
397
|
+
def concat(cls, cores: list["BaseStream"]) -> "BaseStream":
|
|
398
|
+
"""Row-wise concatenate cores, offsetting join keys onto the merged levels.
|
|
399
|
+
|
|
400
|
+
Works on arbitrary columns (a ``shoebox`` obs column rides along) and any
|
|
401
|
+
level set; generalises the hand-rolled per-field concat in the facade.
|
|
402
|
+
"""
|
|
403
|
+
cores = list(cores)
|
|
404
|
+
if not cores:
|
|
405
|
+
raise ValueError("concat requires at least one core")
|
|
406
|
+
if len(cores) == 1:
|
|
407
|
+
return cores[0]
|
|
408
|
+
|
|
409
|
+
joins = cores[0].joins
|
|
410
|
+
level_names = list(cores[0].levels.keys())
|
|
411
|
+
running: dict[str, int] = {ln: 0 for ln in level_names}
|
|
412
|
+
per_core: list[dict[str, Level]] = []
|
|
413
|
+
|
|
414
|
+
for core in cores:
|
|
415
|
+
adjusted = {
|
|
416
|
+
ln: Level(dict(lvl.columns), dict(lvl.schema), n=lvl.n)
|
|
417
|
+
for ln, lvl in core.levels.items()
|
|
418
|
+
}
|
|
419
|
+
for join in joins:
|
|
420
|
+
base = running.get(join.parent_level, 0)
|
|
421
|
+
child = adjusted.get(join.child_level)
|
|
422
|
+
if base and child is not None and join.key_column in child:
|
|
423
|
+
spec = child.schema.get(join.key_column)
|
|
424
|
+
child.__setitem__(join.key_column, child[join.key_column] + base, spec)
|
|
425
|
+
per_core.append(adjusted)
|
|
426
|
+
for ln, lvl in core.levels.items():
|
|
427
|
+
running[ln] = running.get(ln, 0) + lvl.n
|
|
428
|
+
|
|
429
|
+
out_levels = {
|
|
430
|
+
ln: Level.concat([pc[ln] for pc in per_core]) for ln in level_names
|
|
431
|
+
}
|
|
432
|
+
merged_meta = _merge_metadata([c.metadata for c in cores])
|
|
433
|
+
fm0 = bool(cores[0].friedel_merged)
|
|
434
|
+
for i, c in enumerate(cores[1:], start=1):
|
|
435
|
+
if bool(c.friedel_merged) != fm0:
|
|
436
|
+
raise ValueError(
|
|
437
|
+
f"concat: cores disagree on friedel_merged "
|
|
438
|
+
f"(core[0]={fm0}, core[{i}]={bool(c.friedel_merged)}). "
|
|
439
|
+
"All inputs must share the same Friedel-merge state."
|
|
440
|
+
)
|
|
441
|
+
return cls._make(
|
|
442
|
+
out_levels, joins, cores[0].geometry, merged_meta,
|
|
443
|
+
cores[0].spacegroup, cores[0].source_path,
|
|
444
|
+
friedel_merged=fm0,
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
def __repr__(self) -> str:
|
|
448
|
+
return (
|
|
449
|
+
f"{type(self).__name__}(n_observations={self.n_observations}, "
|
|
450
|
+
f"n_crystals={self.n_crystals}, n_images={self.n_images})"
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
class SFXStreamCore(BaseStream):
|
|
455
|
+
"""Serial-crystallography stream core with the canonical three-level layout.
|
|
456
|
+
|
|
457
|
+
Provides the legacy keyword constructor and the original ``__post_init__``
|
|
458
|
+
validation (row-count + join-key-range checks, ``camera_length`` synthesis).
|
|
459
|
+
"""
|
|
460
|
+
|
|
461
|
+
def __init__(
|
|
462
|
+
self,
|
|
463
|
+
hkl: torch.Tensor,
|
|
464
|
+
I: torch.Tensor,
|
|
465
|
+
sigma: torch.Tensor,
|
|
466
|
+
background: torch.Tensor,
|
|
467
|
+
peak: torch.Tensor,
|
|
468
|
+
fs: torch.Tensor,
|
|
469
|
+
ss: torch.Tensor,
|
|
470
|
+
panel_id: torch.Tensor,
|
|
471
|
+
crystal_id: torch.Tensor,
|
|
472
|
+
astar: torch.Tensor,
|
|
473
|
+
bstar: torch.Tensor,
|
|
474
|
+
cstar: torch.Tensor,
|
|
475
|
+
cell: torch.Tensor,
|
|
476
|
+
profile_rad: torch.Tensor,
|
|
477
|
+
resolution: torch.Tensor,
|
|
478
|
+
n_refl: torch.Tensor,
|
|
479
|
+
image_id: torch.Tensor,
|
|
480
|
+
crystal_in_image: torch.Tensor,
|
|
481
|
+
beam_energy: torch.Tensor,
|
|
482
|
+
det_shift_x: torch.Tensor,
|
|
483
|
+
det_shift_y: torch.Tensor,
|
|
484
|
+
n_peaks: torch.Tensor,
|
|
485
|
+
camera_length: torch.Tensor | None = None,
|
|
486
|
+
geometry: Geometry | None = None,
|
|
487
|
+
metadata: StreamMetadata | None = None,
|
|
488
|
+
spacegroup: str = "P 1",
|
|
489
|
+
source_path: str = "",
|
|
490
|
+
friedel_merged: bool = True,
|
|
491
|
+
):
|
|
492
|
+
n_obs, n_crystals, n_images = hkl.shape[0], cell.shape[0], beam_energy.shape[0]
|
|
493
|
+
if camera_length is None:
|
|
494
|
+
camera_length = torch.zeros(n_images, dtype=torch.float32)
|
|
495
|
+
|
|
496
|
+
local = locals()
|
|
497
|
+
# Row-count validation up front so the documented AssertionError fires
|
|
498
|
+
# (rather than Level's ValueError) on a mismatched field.
|
|
499
|
+
_expected = {"obs": n_obs, "crystal": n_crystals, "image": n_images}
|
|
500
|
+
for fname, level_name in _FIELD_TO_LEVEL.items():
|
|
501
|
+
t = local[fname]
|
|
502
|
+
exp = _expected[level_name]
|
|
503
|
+
assert t.shape[0] == exp, f"{fname} has {t.shape[0]} rows, expected {exp}"
|
|
504
|
+
|
|
505
|
+
levels = {"obs": Level(), "crystal": Level(), "image": Level()}
|
|
506
|
+
for fname, level_name in _FIELD_TO_LEVEL.items():
|
|
507
|
+
spec = None
|
|
508
|
+
if fname in _CANONICAL_INT_DTYPE:
|
|
509
|
+
spec = ColumnSpec(dtype=_CANONICAL_INT_DTYPE[fname])
|
|
510
|
+
levels[level_name].__setitem__(fname, local[fname], spec)
|
|
511
|
+
|
|
512
|
+
super().__init__(
|
|
513
|
+
levels, _default_sx_joins(), geometry, metadata, spacegroup, source_path,
|
|
514
|
+
friedel_merged=friedel_merged,
|
|
515
|
+
)
|
|
516
|
+
self._validate()
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
class ShoeboxStreamCore(SFXStreamCore):
|
|
520
|
+
"""An ``SFXStreamCore`` that additionally carries per-observation patches.
|
|
521
|
+
|
|
522
|
+
Distinguished purely by data richness (an obs ``shoebox`` or ``shoebox_id``
|
|
523
|
+
column), never by provenance.
|
|
524
|
+
"""
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _classify(levels: dict[str, Level]) -> type:
|
|
528
|
+
"""Pick the most specific core class supported by the present columns."""
|
|
529
|
+
obs = levels.get("obs")
|
|
530
|
+
if obs is not None and ("shoebox" in obs or "shoebox_id" in obs):
|
|
531
|
+
return ShoeboxStreamCore
|
|
532
|
+
if obs is not None and {"hkl", "I", "sigma"}.issubset(obs.keys()):
|
|
533
|
+
return SFXStreamCore
|
|
534
|
+
return BaseStream
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _subset_metadata(
|
|
538
|
+
metadata: StreamMetadata, masks: dict[str, torch.Tensor]
|
|
539
|
+
) -> StreamMetadata:
|
|
540
|
+
"""Subset per-level StreamMetadata entries by the level masks.
|
|
541
|
+
|
|
542
|
+
Transitional: kept until per-level metadata is folded into columns. Mirrors
|
|
543
|
+
the old ``filter._subset_metadata`` length-heuristic.
|
|
544
|
+
"""
|
|
545
|
+
if not metadata:
|
|
546
|
+
return StreamMetadata()
|
|
547
|
+
new = StreamMetadata()
|
|
548
|
+
for top_key, sub in metadata.items():
|
|
549
|
+
if not isinstance(sub, dict) or top_key not in masks:
|
|
550
|
+
new[top_key] = sub
|
|
551
|
+
continue
|
|
552
|
+
mask = masks[top_key]
|
|
553
|
+
n_old = int(mask.shape[0])
|
|
554
|
+
new[top_key] = {}
|
|
555
|
+
for field_name, value in sub.items():
|
|
556
|
+
if isinstance(value, torch.Tensor) and value.shape[0] == n_old:
|
|
557
|
+
new[top_key][field_name] = value[mask]
|
|
558
|
+
elif isinstance(value, list) and len(value) == n_old:
|
|
559
|
+
keep = mask.nonzero(as_tuple=True)[0].tolist()
|
|
560
|
+
new[top_key][field_name] = [value[i] for i in keep]
|
|
561
|
+
else:
|
|
562
|
+
new[top_key][field_name] = value
|
|
563
|
+
return new
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _merge_metadata(metas: list[StreamMetadata]) -> StreamMetadata:
|
|
567
|
+
"""Concatenate per-level StreamMetadata lists/tensors across cores."""
|
|
568
|
+
from torchsx.measurement import MEASUREMENT_KEY, MeasurementSpec, combine_measurements
|
|
569
|
+
|
|
570
|
+
merged = StreamMetadata()
|
|
571
|
+
for meta in metas:
|
|
572
|
+
if not meta:
|
|
573
|
+
continue
|
|
574
|
+
for top_key, sub in meta.items():
|
|
575
|
+
if top_key == MEASUREMENT_KEY:
|
|
576
|
+
continue
|
|
577
|
+
if not isinstance(sub, dict):
|
|
578
|
+
merged.setdefault(top_key, sub)
|
|
579
|
+
continue
|
|
580
|
+
dst = merged.setdefault(top_key, {})
|
|
581
|
+
for field_name, values in sub.items():
|
|
582
|
+
if isinstance(values, list):
|
|
583
|
+
bucket = dst.setdefault(field_name, [])
|
|
584
|
+
bucket.extend(values)
|
|
585
|
+
elif isinstance(values, torch.Tensor):
|
|
586
|
+
bucket = dst.setdefault(field_name, [])
|
|
587
|
+
bucket.append(values)
|
|
588
|
+
else:
|
|
589
|
+
dst.setdefault(field_name, values)
|
|
590
|
+
for top_key, sub in merged.items():
|
|
591
|
+
if isinstance(sub, dict):
|
|
592
|
+
for field_name, values in sub.items():
|
|
593
|
+
if isinstance(values, list) and values and isinstance(values[0], torch.Tensor):
|
|
594
|
+
sub[field_name] = torch.cat(values)
|
|
595
|
+
spec = combine_measurements(MeasurementSpec.from_metadata(m) for m in metas)
|
|
596
|
+
if spec is not None:
|
|
597
|
+
merged[MEASUREMENT_KEY] = {"record": spec.to_dict()}
|
|
598
|
+
return merged
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
# Backwards-compatible alias: existing imports and the keyword constructor.
|
|
602
|
+
_SFXStreamCore = SFXStreamCore
|
torchsx/cli/__init__.py
ADDED
|
File without changes
|