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.
Files changed (84) hide show
  1. torchsx/__init__.py +8 -0
  2. torchsx/_categorical.py +58 -0
  3. torchsx/_core.py +602 -0
  4. torchsx/cli/__init__.py +0 -0
  5. torchsx/cli/_pipeline.py +912 -0
  6. torchsx/cli/merge_nn.py +205 -0
  7. torchsx/cli/merge_stats.py +217 -0
  8. torchsx/cli/stats.py +95 -0
  9. torchsx/cli/train.py +278 -0
  10. torchsx/geometry.py +745 -0
  11. torchsx/io/__init__.py +30 -0
  12. torchsx/io/_crystfel.py +105 -0
  13. torchsx/io/_duckdb.py +258 -0
  14. torchsx/io/_metadata.py +187 -0
  15. torchsx/io/_native.py +48 -0
  16. torchsx/io/_parse/__init__.py +8 -0
  17. torchsx/io/_parse/indexing.py +186 -0
  18. torchsx/io/_parse/orchestrator.py +258 -0
  19. torchsx/io/_parse/records.py +520 -0
  20. torchsx/io/_parse/workers.py +35 -0
  21. torchsx/io/_parser.py +40 -0
  22. torchsx/io/_safetensors.py +170 -0
  23. torchsx/io/_writeback.py +463 -0
  24. torchsx/io/sources.py +76 -0
  25. torchsx/io/times.py +438 -0
  26. torchsx/levels.py +306 -0
  27. torchsx/measurement.py +217 -0
  28. torchsx/merge_stats.py +672 -0
  29. torchsx/merging/__init__.py +30 -0
  30. torchsx/merging/config.py +52 -0
  31. torchsx/merging/hkl/__init__.py +75 -0
  32. torchsx/merging/hkl/adjacency.py +71 -0
  33. torchsx/merging/hkl/filter.py +177 -0
  34. torchsx/merging/hkl/hkl.py +118 -0
  35. torchsx/merging/hkl/noise.py +451 -0
  36. torchsx/merging/hkl/shell_fit.py +102 -0
  37. torchsx/merging/hkl/stats.py +294 -0
  38. torchsx/merging/likelihood/__init__.py +10 -0
  39. torchsx/merging/likelihood/base.py +64 -0
  40. torchsx/merging/likelihood/detector_noise.py +104 -0
  41. torchsx/merging/likelihood/poisson.py +173 -0
  42. torchsx/merging/observations.py +699 -0
  43. torchsx/merging/parameters.py +77 -0
  44. torchsx/merging/priors/__init__.py +13 -0
  45. torchsx/merging/priors/reciprocal_basis.py +293 -0
  46. torchsx/merging/quadrature/__init__.py +15 -0
  47. torchsx/merging/quadrature/_fixed_log_grid_triton.py +426 -0
  48. torchsx/merging/quadrature/_fixed_log_grid_tuning.json +44 -0
  49. torchsx/merging/quadrature/_fused_posterior_triton.py +249 -0
  50. torchsx/merging/quadrature/_posterior_nodes_triton.py +378 -0
  51. torchsx/merging/quadrature/_split_log_grid_triton.py +294 -0
  52. torchsx/merging/quadrature/_split_mixture_triton.py +218 -0
  53. torchsx/merging/quadrature/base.py +98 -0
  54. torchsx/merging/quadrature/fixed_log_grid.py +305 -0
  55. torchsx/merging/quadrature/split_log_grid.py +392 -0
  56. torchsx/merging/scale/__init__.py +10 -0
  57. torchsx/merging/scale/amortized.py +452 -0
  58. torchsx/merging/scale/base.py +44 -0
  59. torchsx/merging/scatter/__init__.py +22 -0
  60. torchsx/merging/scatter/base.py +54 -0
  61. torchsx/merging/scatter/gengauss.py +314 -0
  62. torchsx/merging/scatter/mixture.py +269 -0
  63. torchsx/merging/scatter/predicted.py +119 -0
  64. torchsx/merging/scatter_shape.py +213 -0
  65. torchsx/nn/__init__.py +41 -0
  66. torchsx/nn/deepsets.py +579 -0
  67. torchsx/nn/heads.py +370 -0
  68. torchsx/nn/merge_target.py +67 -0
  69. torchsx/nn/metrics.py +397 -0
  70. torchsx/nn/objective.py +380 -0
  71. torchsx/nn/obs_features.py +424 -0
  72. torchsx/nn/pad.py +78 -0
  73. torchsx/nn/pooling.py +395 -0
  74. torchsx/nn/posterior_target.py +662 -0
  75. torchsx/nn/shape_prior.py +98 -0
  76. torchsx/nn/train_amortized.py +1640 -0
  77. torchsx/stats.py +302 -0
  78. torchsx/stream.py +826 -0
  79. torchsx-0.1.0.dist-info/METADATA +182 -0
  80. torchsx-0.1.0.dist-info/RECORD +84 -0
  81. torchsx-0.1.0.dist-info/WHEEL +5 -0
  82. torchsx-0.1.0.dist-info/entry_points.txt +5 -0
  83. torchsx-0.1.0.dist-info/licenses/LICENSE +21 -0
  84. 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"
@@ -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
File without changes