pymmcore-plus 0.9.3__py3-none-any.whl → 0.13.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 (68) hide show
  1. pymmcore_plus/__init__.py +7 -4
  2. pymmcore_plus/_benchmark.py +203 -0
  3. pymmcore_plus/_build.py +6 -1
  4. pymmcore_plus/_cli.py +131 -31
  5. pymmcore_plus/_logger.py +19 -10
  6. pymmcore_plus/_pymmcore.py +12 -0
  7. pymmcore_plus/_util.py +139 -32
  8. pymmcore_plus/core/__init__.py +5 -0
  9. pymmcore_plus/core/_config.py +6 -4
  10. pymmcore_plus/core/_config_group.py +4 -3
  11. pymmcore_plus/core/_constants.py +135 -10
  12. pymmcore_plus/core/_device.py +4 -4
  13. pymmcore_plus/core/_metadata.py +3 -3
  14. pymmcore_plus/core/_mmcore_plus.py +254 -170
  15. pymmcore_plus/core/_property.py +6 -6
  16. pymmcore_plus/core/_sequencing.py +370 -233
  17. pymmcore_plus/core/events/__init__.py +6 -6
  18. pymmcore_plus/core/events/_device_signal_view.py +8 -6
  19. pymmcore_plus/core/events/_norm_slot.py +2 -4
  20. pymmcore_plus/core/events/_prop_event_mixin.py +7 -4
  21. pymmcore_plus/core/events/_protocol.py +5 -2
  22. pymmcore_plus/core/events/_psygnal.py +2 -2
  23. pymmcore_plus/experimental/__init__.py +0 -0
  24. pymmcore_plus/experimental/unicore/__init__.py +14 -0
  25. pymmcore_plus/experimental/unicore/_device_manager.py +173 -0
  26. pymmcore_plus/experimental/unicore/_proxy.py +127 -0
  27. pymmcore_plus/experimental/unicore/_unicore.py +703 -0
  28. pymmcore_plus/experimental/unicore/devices/__init__.py +0 -0
  29. pymmcore_plus/experimental/unicore/devices/_device.py +269 -0
  30. pymmcore_plus/experimental/unicore/devices/_properties.py +400 -0
  31. pymmcore_plus/experimental/unicore/devices/_stage.py +221 -0
  32. pymmcore_plus/install.py +16 -11
  33. pymmcore_plus/mda/__init__.py +1 -1
  34. pymmcore_plus/mda/_engine.py +320 -148
  35. pymmcore_plus/mda/_protocol.py +6 -4
  36. pymmcore_plus/mda/_runner.py +62 -51
  37. pymmcore_plus/mda/_thread_relay.py +5 -3
  38. pymmcore_plus/mda/events/__init__.py +2 -2
  39. pymmcore_plus/mda/events/_protocol.py +10 -2
  40. pymmcore_plus/mda/events/_psygnal.py +2 -2
  41. pymmcore_plus/mda/handlers/_5d_writer_base.py +106 -15
  42. pymmcore_plus/mda/handlers/__init__.py +7 -1
  43. pymmcore_plus/mda/handlers/_img_sequence_writer.py +11 -6
  44. pymmcore_plus/mda/handlers/_ome_tiff_writer.py +8 -4
  45. pymmcore_plus/mda/handlers/_ome_zarr_writer.py +82 -9
  46. pymmcore_plus/mda/handlers/_tensorstore_handler.py +374 -0
  47. pymmcore_plus/mda/handlers/_util.py +1 -1
  48. pymmcore_plus/metadata/__init__.py +36 -0
  49. pymmcore_plus/metadata/functions.py +353 -0
  50. pymmcore_plus/metadata/schema.py +472 -0
  51. pymmcore_plus/metadata/serialize.py +120 -0
  52. pymmcore_plus/mocks.py +51 -0
  53. pymmcore_plus/model/_config_file.py +5 -6
  54. pymmcore_plus/model/_config_group.py +29 -2
  55. pymmcore_plus/model/_core_device.py +12 -1
  56. pymmcore_plus/model/_core_link.py +2 -1
  57. pymmcore_plus/model/_device.py +39 -8
  58. pymmcore_plus/model/_microscope.py +39 -3
  59. pymmcore_plus/model/_pixel_size_config.py +27 -4
  60. pymmcore_plus/model/_property.py +13 -3
  61. pymmcore_plus/seq_tester.py +1 -1
  62. {pymmcore_plus-0.9.3.dist-info → pymmcore_plus-0.13.0.dist-info}/METADATA +22 -12
  63. pymmcore_plus-0.13.0.dist-info/RECORD +71 -0
  64. {pymmcore_plus-0.9.3.dist-info → pymmcore_plus-0.13.0.dist-info}/WHEEL +1 -1
  65. pymmcore_plus/core/_state.py +0 -244
  66. pymmcore_plus-0.9.3.dist-info/RECORD +0 -55
  67. {pymmcore_plus-0.9.3.dist-info → pymmcore_plus-0.13.0.dist-info}/entry_points.txt +0 -0
  68. {pymmcore_plus-0.9.3.dist-info → pymmcore_plus-0.13.0.dist-info}/licenses/LICENSE +0 -0
@@ -5,21 +5,28 @@ import json
5
5
  import os.path
6
6
  import shutil
7
7
  import tempfile
8
- from typing import TYPE_CHECKING, Any, Literal, MutableMapping, Protocol
8
+ from contextlib import suppress
9
+ from typing import TYPE_CHECKING, Any, Literal, Protocol
10
+
11
+ import numpy as np
12
+
13
+ from pymmcore_plus.metadata.serialize import to_builtins
9
14
 
10
15
  from ._5d_writer_base import _5DWriterBase
11
16
 
12
17
  if TYPE_CHECKING:
18
+ from collections.abc import MutableMapping, Sequence
19
+ from contextlib import AbstractAsyncContextManager
13
20
  from os import PathLike
14
- from typing import ContextManager, Sequence, TypedDict
21
+ from typing import TypedDict
15
22
 
16
- import numpy as np
23
+ import xarray as xr
17
24
  import zarr
18
25
  from fsspec import FSMap
19
26
  from numcodecs.abc import Codec
20
27
 
21
28
  class ZarrSynchronizer(Protocol):
22
- def __getitem__(self, key: str) -> ContextManager: ...
29
+ def __getitem__(self, key: str) -> AbstractAsyncContextManager: ...
23
30
 
24
31
  class ArrayCreationKwargs(TypedDict, total=False):
25
32
  compressor: str | Codec
@@ -67,7 +74,7 @@ class OMEZarrWriter(_5DWriterBase["zarr.Array"]):
67
74
  │ └── y
68
75
  │ └── x # chunks will be each XY plane
69
76
  ├── ...
70
- ├── pn
77
+ ├── p<n>
71
78
  │ ├── .zarray
72
79
  │ ├── .zattrs
73
80
  │ └── t...
@@ -127,8 +134,8 @@ class OMEZarrWriter(_5DWriterBase["zarr.Array"]):
127
134
 
128
135
  # if we don't check this here, we'll get an error when creating the first array
129
136
  if (
130
- not overwrite and any(self._group.arrays()) or self._group.attrs
131
- ): # pragma: no cover
137
+ not overwrite and any(self._group.arrays())
138
+ ) or self._group.attrs: # pragma: no cover
132
139
  path = self._group.store.path if hasattr(self._group.store, "path") else ""
133
140
  raise ValueError(
134
141
  f"There is already data in {path!r}. Use 'overwrite=True' to overwrite."
@@ -186,14 +193,74 @@ class OMEZarrWriter(_5DWriterBase["zarr.Array"]):
186
193
  def finalize_metadata(self) -> None:
187
194
  """Called by superclass in sequenceFinished. Flush metadata to disk."""
188
195
  # flush frame metadata to disk
196
+ self._populate_xarray_coords()
189
197
  while self.frame_metadatas:
190
198
  key, metas = self.frame_metadatas.popitem()
191
199
  if key in self.position_arrays:
192
- self.position_arrays[key].attrs["frame_meta"] = metas
200
+ self.position_arrays[key].attrs["frame_meta"] = to_builtins(metas)
193
201
 
194
202
  if self._minify_metadata:
195
203
  self._minify_zattrs_metadata()
196
204
 
205
+ def _populate_xarray_coords(self) -> None:
206
+ # FIXME:
207
+ # This provides support for xarray coordinates... but it's not obvious
208
+ # how we should deal with positions that have different shapes, etc...
209
+ # Also: this whole thing should be generalized to support any kind of
210
+ # dimension, and should be better about populating the coords as the experiment
211
+ # progresses. And it's rather ugly...
212
+ if not (seq := self.current_sequence):
213
+ return
214
+
215
+ sizes = {**seq.sizes}
216
+ px: float = 1.0
217
+ if self.frame_metadatas:
218
+ key, metas = next(iter(self.frame_metadatas.items()))
219
+ if key in self.position_arrays:
220
+ shape = self.position_arrays[key].shape
221
+ px = metas[-1].get("pixel_size_um", 1)
222
+ with suppress(IndexError):
223
+ sizes.update(y=shape[-2], x=shape[-1])
224
+
225
+ for dim, size in sizes.items():
226
+ if size == 0:
227
+ continue
228
+
229
+ # TODO: this could be much cleaner
230
+ attrs: dict = {"_ARRAY_DIMENSIONS": [dim]}
231
+ if dim == "t":
232
+ if self._timestamps:
233
+ coords: Any = list(self._timestamps)
234
+ elif seq.time_plan:
235
+ coords = np.arange(seq.time_plan.num_timepoints(), dtype="float")
236
+ else:
237
+ continue
238
+ attrs["units"] = "ms"
239
+ elif dim == "p":
240
+ # coords = [(p.x, p.y, p.z) for p in seq.stage_positions]
241
+ coords = np.arange(size)
242
+ elif dim == "c":
243
+ coords = [c.config for c in seq.channels]
244
+ elif dim == "z":
245
+ coords = list(seq.z_plan) if seq.z_plan else [0]
246
+ attrs["units"] = "um"
247
+ elif dim in "yx":
248
+ coords = np.arange(size, dtype="float") * px
249
+ attrs["units"] = "um"
250
+ elif dim == "g":
251
+ coords = np.arange(size)
252
+ # TODO
253
+ else:
254
+ continue
255
+
256
+ # fill_value=None is important to avoid nan where coords == 0
257
+ if dim in self._group:
258
+ ds = self._group[dim]
259
+ ds[:] = coords
260
+ else:
261
+ ds = self._group.create_dataset(dim, data=coords, fill_value=None)
262
+ ds.attrs.update(attrs)
263
+
197
264
  def new_array(self, key: str, dtype: np.dtype, sizes: dict[str, int]) -> zarr.Array:
198
265
  """Create a new array in the group, under `key`."""
199
266
  dims, shape = zip(*sizes.items())
@@ -211,7 +278,8 @@ class OMEZarrWriter(_5DWriterBase["zarr.Array"]):
211
278
  self._group.attrs["multiscales"] = scales
212
279
  ary.attrs["_ARRAY_DIMENSIONS"] = dims
213
280
  if seq := self.current_sequence:
214
- ary.attrs["useq_MDASequence"] = json.loads(seq.json(exclude_unset=True))
281
+ ary.attrs["useq_MDASequence"] = to_builtins(seq)
282
+
215
283
  return ary
216
284
 
217
285
  # # the superclass implementation is all we need
@@ -250,6 +318,11 @@ class OMEZarrWriter(_5DWriterBase["zarr.Array"]):
250
318
  # dump minified data back to disk
251
319
  store[key] = json.dumps(data, separators=(",", ":")).encode("ascii")
252
320
 
321
+ def as_xarray(self) -> xr.Dataset:
322
+ import xarray as xr
323
+
324
+ return xr.open_zarr(self.group.store, consolidated=False)
325
+
253
326
 
254
327
  # https://ngff.openmicroscopy.org/0.4/index.html#axes-md
255
328
  AXTYPE = {
@@ -0,0 +1,374 @@
1
+ from __future__ import annotations
2
+
3
+ import atexit
4
+ import os
5
+ import shutil
6
+ import tempfile
7
+ import warnings
8
+ from itertools import product
9
+ from os import PathLike
10
+ from typing import TYPE_CHECKING, Any, cast
11
+
12
+ import numpy as np
13
+
14
+ from pymmcore_plus.metadata.serialize import json_dumps, json_loads
15
+
16
+ from ._util import position_sizes
17
+
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Mapping, Sequence
20
+ from typing import Literal, TypeAlias
21
+
22
+ import tensorstore as ts
23
+ import useq
24
+ from typing_extensions import Self # py311
25
+
26
+ from pymmcore_plus.metadata import FrameMetaV1, SummaryMetaV1
27
+
28
+ TsDriver: TypeAlias = Literal["zarr", "zarr3", "n5", "neuroglancer_precomputed"]
29
+ EventKey: TypeAlias = frozenset[tuple[str, int]]
30
+
31
+ # special dimension label used when _nd_storage is False
32
+ FRAME_DIM = "frame"
33
+
34
+
35
+ class TensorStoreHandler:
36
+ """Tensorstore handler for writing MDA sequences.
37
+
38
+ This is a performant and shape-agnostic handler for writing MDA sequences to
39
+ chunked storages like zarr, n5, backed by tensorstore:
40
+ <https://google.github.io/tensorstore/>
41
+
42
+ By default, the handler will store frames in a zarr array, with a shape of
43
+ (nframes, *frame_shape) and a chunk size of (1, *frame_shape), i.e. each frame
44
+ is stored in a separate chunk. To customize shape or chunking, override the
45
+ `get_full_shape`, `get_chunk_layout`, and `get_index_domain` methods (these
46
+ may change in the future as we learn to use tensorstore better).
47
+
48
+ Parameters
49
+ ----------
50
+ driver : TsDriver, optional
51
+ The driver to use for the tensorstore, by default "zarr". Must be one of
52
+ "zarr", "zarr3", "n5", or "neuroglancer_precomputed".
53
+ kvstore : str | dict | None, optional
54
+ The key-value store to use for the tensorstore, by default "memory://".
55
+ A dict might look like {'driver': 'file', 'path': '/path/to/dataset.zarr'}
56
+ see <https://google.github.io/tensorstore/kvstore/index.html#json-KvStore>
57
+ for all options. If path is provided, the kvstore will be set to file://path
58
+ path : str | Path | None, optional
59
+ Convenience for specifying a local filepath. If provided, overrides the
60
+ kvstore option, to be `file://file_path`.
61
+ delete_existing : bool, optional
62
+ Whether to delete the existing dataset if it exists, by default False.
63
+ spec : Mapping, optional
64
+ A spec to use when opening the tensorstore, by default None. Values provided
65
+ in this object will override the default values provided by the handler.
66
+ This is a complex object that can completely define the tensorstore, see
67
+ <https://google.github.io/tensorstore/spec.html> for more information.
68
+
69
+ Examples
70
+ --------
71
+ ```python
72
+ from pymmcore_plus import CMMCorePlus
73
+ from pymmcore_plus.mda.handlers import TensorStoreHandler
74
+ from useq import MDASequence
75
+
76
+ core = CMMCorePlus.instance()
77
+ core.loadSystemConfiguration()
78
+
79
+ sequence = MDASequence(
80
+ channels=["DAPI", {"config": "FITC", "exposure": 1}],
81
+ stage_positions=[{"x": 1, "y": 1, "name": "some position"}, {"x": 0, "y": 0}],
82
+ time_plan={"interval": 2, "loops": 3},
83
+ z_plan={"range": 4, "step": 0.5},
84
+ axis_order="tpcz",
85
+ )
86
+
87
+ writer = TensorStoreHandler(path="example_ts.zarr", delete_existing=True)
88
+ core.mda.run(sequence, output=writer)
89
+ ```
90
+
91
+ """
92
+
93
+ def __init__(
94
+ self,
95
+ *,
96
+ driver: TsDriver = "zarr",
97
+ kvstore: str | dict | None = "memory://",
98
+ path: str | PathLike | None = None,
99
+ delete_existing: bool = False,
100
+ spec: Mapping | None = None,
101
+ ) -> None:
102
+ try:
103
+ import tensorstore
104
+ except ImportError as e:
105
+ raise ImportError("Tensorstore is required to use this handler.") from e
106
+
107
+ self._ts = tensorstore
108
+
109
+ self.ts_driver = driver
110
+ self.kvstore = f"file://{path}" if path is not None else kvstore
111
+ self.delete_existing = delete_existing
112
+ self.spec = spec
113
+
114
+ # storage of individual frame metadata
115
+ # maps position key to list of frame metadata
116
+ self.frame_metadatas: list[tuple[useq.MDAEvent, FrameMetaV1]] = []
117
+
118
+ self._size_increment = 300
119
+
120
+ self._store: ts.TensorStore | None = None
121
+ self._futures: list[ts.Future | ts.WriteFutures] = []
122
+ self._frame_indices: dict[EventKey, int | ts.DimExpression] = {}
123
+
124
+ # "_nd_storage" means we're greedily attempting to store the data in a
125
+ # multi-dimensional format based on the axes of the sequence.
126
+ # for non-deterministic experiments, this often won't work...
127
+ # _nd_storage False means we simply store data as a 3D array of shape
128
+ # (nframes, y, x). `_nd_storage` is set when a new_store is created.
129
+ self._nd_storage: bool = True
130
+ self._frame_index: int = 0
131
+
132
+ # the highest index seen for each axis
133
+ self._axis_max: dict[str, int] = {}
134
+
135
+ @property
136
+ def store(self) -> ts.TensorStore | None:
137
+ """The current tensorstore."""
138
+ return self._store
139
+
140
+ @classmethod
141
+ def in_tmpdir(
142
+ cls,
143
+ suffix: str | None = "",
144
+ prefix: str | None = "pymmcore_zarr_",
145
+ dir: str | PathLike[str] | None = None,
146
+ cleanup_atexit: bool = True,
147
+ **kwargs: Any,
148
+ ) -> Self:
149
+ """Create TensorStoreHandler that writes to a temporary directory.
150
+
151
+ Parameters
152
+ ----------
153
+ suffix : str, optional
154
+ If suffix is specified, the file name will end with that suffix, otherwise
155
+ there will be no suffix.
156
+ prefix : str, optional
157
+ If prefix is specified, the file name will begin with that prefix, otherwise
158
+ a default prefix is used.
159
+ dir : str or PathLike, optional
160
+ If dir is specified, the file will be created in that directory, otherwise
161
+ a default directory is used (tempfile.gettempdir())
162
+ cleanup_atexit : bool, optional
163
+ Whether to automatically cleanup the temporary directory when the python
164
+ process exits. Default is True.
165
+ **kwargs
166
+ Remaining kwargs are passed to `TensorStoreHandler.__init__`
167
+ """
168
+ # same as zarr.storage.TempStore, but with option not to cleanup
169
+ path = tempfile.mkdtemp(suffix=suffix, prefix=prefix, dir=dir)
170
+ if cleanup_atexit:
171
+
172
+ @atexit.register
173
+ def _atexit_rmtree(_path: str = path) -> None: # pragma: no cover
174
+ if os.path.isdir(_path):
175
+ shutil.rmtree(_path, ignore_errors=True)
176
+
177
+ return cls(path=path, **kwargs)
178
+
179
+ def sequenceStarted(self, seq: useq.MDASequence, meta: SummaryMetaV1) -> None:
180
+ """On sequence started, simply store the sequence."""
181
+ self._frame_index = 0
182
+ self._store = None
183
+ self._futures.clear()
184
+ self.frame_metadatas.clear()
185
+ self.current_sequence = seq
186
+
187
+ def sequenceFinished(self, seq: useq.MDASequence) -> None:
188
+ """On sequence finished, clear the current sequence."""
189
+ if self._store is None:
190
+ return # pragma: no cover
191
+
192
+ while self._futures:
193
+ self._futures.pop().result()
194
+ if not self._nd_storage:
195
+ self._store = self._store.resize(
196
+ exclusive_max=(self._frame_index, *self._store.shape[-2:])
197
+ ).result()
198
+ if self.frame_metadatas:
199
+ self.finalize_metadata()
200
+
201
+ def frameReady(
202
+ self, frame: np.ndarray, event: useq.MDAEvent, meta: FrameMetaV1
203
+ ) -> None:
204
+ """Write frame to the zarr array for the appropriate position."""
205
+ if self._store is None:
206
+ self._store = self.new_store(frame, event.sequence, meta).result()
207
+
208
+ ts_index: ts.DimExpression | int
209
+ if self._nd_storage:
210
+ ts_index = self._event_index_to_store_index(event.index)
211
+ else:
212
+ if self._frame_index >= self._store.shape[0]:
213
+ self._store = self._expand_store(self._store).result()
214
+ ts_index = self._frame_index
215
+ # store reverse lookup of event.index -> frame_index
216
+ self._frame_indices[frozenset(event.index.items())] = ts_index
217
+
218
+ # write the new frame asynchronously
219
+ self._futures.append(self._store[ts_index].write(frame))
220
+
221
+ # store, but do not process yet, the frame metadata
222
+ self.frame_metadatas.append((event, meta))
223
+ # update the frame counter
224
+ self._frame_index += 1
225
+ # remember the highest index seen for each axis
226
+ for k, v in event.index.items():
227
+ self._axis_max[k] = max(self._axis_max.get(k, 0), v)
228
+
229
+ def isel(
230
+ self,
231
+ indexers: Mapping[str, int | slice] | None = None,
232
+ **indexers_kwargs: int | slice,
233
+ ) -> np.ndarray:
234
+ """Select data from the array."""
235
+ # FIXME: will fail on slices
236
+ indexers = {**(indexers or {}), **indexers_kwargs}
237
+ ts_index = self._event_index_to_store_index(indexers)
238
+ if self._store is None: # pragma: no cover
239
+ warnings.warn("No data written.", stacklevel=2)
240
+ return np.empty([])
241
+ return self._store[ts_index].read().result().squeeze() # type: ignore [no-any-return]
242
+
243
+ def new_store(
244
+ self, frame: np.ndarray, seq: useq.MDASequence | None, meta: FrameMetaV1
245
+ ) -> ts.Future[ts.TensorStore]:
246
+ shape, chunks, labels = self.get_shape_chunks_labels(frame.shape, seq)
247
+ self._nd_storage = FRAME_DIM not in labels
248
+ return self._ts.open(
249
+ self.get_spec(),
250
+ create=True,
251
+ delete_existing=self.delete_existing,
252
+ dtype=self._ts.dtype(frame.dtype),
253
+ shape=shape,
254
+ chunk_layout=self._ts.ChunkLayout(chunk_shape=chunks),
255
+ domain=self._ts.IndexDomain(labels=labels),
256
+ )
257
+
258
+ def get_shape_chunks_labels(
259
+ self, frame_shape: tuple[int, ...], seq: useq.MDASequence | None
260
+ ) -> tuple[tuple[int, ...], tuple[int, ...], tuple[str, ...]]:
261
+ labels: tuple[str, ...]
262
+ if seq is not None and seq.sizes:
263
+ # expand the sizes to include the largest size we encounter for each axis
264
+ # in the case of positions with subsequences, we'll still end up with a
265
+ # jagged array, but it won't take extra space, and we won't get index errors
266
+ max_sizes = dict(seq.sizes)
267
+ for psize in position_sizes(seq):
268
+ for k, v in psize.items():
269
+ max_sizes[k] = max(max_sizes.get(k, 0), v)
270
+
271
+ # remove axes with length 0
272
+ labels, sizes = zip(*(x for x in max_sizes.items() if x[1]))
273
+ full_shape: tuple[int, ...] = (*sizes, *frame_shape)
274
+ else:
275
+ labels = (FRAME_DIM,)
276
+ full_shape = (self._size_increment, *frame_shape)
277
+
278
+ chunks = [1] * len(full_shape)
279
+ chunks[-len(frame_shape) :] = frame_shape
280
+ labels = (*labels, "y", "x")
281
+ return full_shape, tuple(chunks), labels
282
+
283
+ def get_spec(self) -> dict:
284
+ """Construct the tensorstore spec."""
285
+ spec = {"driver": self.ts_driver, "kvstore": self.kvstore}
286
+ if self.spec:
287
+ _merge_nested_dicts(spec, self.spec)
288
+
289
+ # HACK
290
+ if self.ts_driver == "zarr":
291
+ meta = cast(dict, spec.setdefault("metadata", {}))
292
+ if "dimension_separator" not in meta:
293
+ meta["dimension_separator"] = "/"
294
+ return spec
295
+
296
+ def finalize_metadata(self) -> None:
297
+ """Finalize and flush metadata to storage."""
298
+ if not (store := self._store) or not store.kvstore:
299
+ return # pragma: no cover
300
+
301
+ metadata = {"frame_metadatas": [m[1] for m in self.frame_metadatas]}
302
+ if not self._nd_storage:
303
+ metadata["frame_indices"] = [
304
+ (tuple(dict(k).items()), v) # type: ignore
305
+ for k, v in self._frame_indices.items()
306
+ ]
307
+
308
+ if self.ts_driver.startswith("zarr"):
309
+ store.kvstore.write(".zattrs", json_dumps(metadata).decode("utf-8"))
310
+ elif self.ts_driver == "n5": # pragma: no cover
311
+ attrs = json_loads(store.kvstore.read("attributes.json").result().value)
312
+ attrs.update(metadata)
313
+ store.kvstore.write("attributes.json", json_dumps(attrs).decode("utf-8"))
314
+
315
+ def _expand_store(self, store: ts.TensorStore) -> ts.Future[ts.TensorStore]:
316
+ """Grow the store by `self._size_increment` frames.
317
+
318
+ This is used when _nd_storage mode is False and we've run out of space.
319
+ """
320
+ new_shape = [self._frame_index + self._size_increment, *store.shape[-2:]]
321
+ return store.resize(exclusive_max=new_shape, expand_only=True)
322
+
323
+ def _event_index_to_store_index(
324
+ self, index: Mapping[str, int | slice]
325
+ ) -> ts.DimExpression:
326
+ """Convert event index to store index.
327
+
328
+ The return value is safe to use as an index to self._store[...]
329
+ """
330
+ if self._nd_storage:
331
+ keys, values = zip(*index.items())
332
+ return self._ts.d[keys][values]
333
+
334
+ if any(isinstance(v, slice) for v in index.values()):
335
+ idx: list | int | ts.DimExpression = self._get_frame_indices(index)
336
+ else:
337
+ try:
338
+ idx = self._frame_indices[frozenset(index.items())] # type: ignore
339
+ except KeyError as e:
340
+ raise KeyError(f"Index {index} not found in frame_indices.") from e
341
+ return self._ts.d[FRAME_DIM][idx]
342
+
343
+ def _get_frame_indices(self, indexers: Mapping[str, int | slice]) -> list[int]:
344
+ """Convert indexers (with slices) to a list of frame indices."""
345
+ # converting slice objects to actual indices
346
+ axis_indices: dict[str, Sequence[int]] = {}
347
+ for k, v in indexers.items():
348
+ if isinstance(v, slice):
349
+ axis_indices[k] = tuple(range(*v.indices(self._axis_max.get(k, 0) + 1)))
350
+ else:
351
+ axis_indices[k] = (v,)
352
+
353
+ indices: list[int] = []
354
+ for p in product(*axis_indices.values()):
355
+ key = frozenset(dict(zip(axis_indices.keys(), p)).items())
356
+ try:
357
+ indices.append(self._frame_indices[key])
358
+ except KeyError: # pragma: no cover
359
+ warnings.warn(
360
+ f"Index {dict(key)} not found in frame_indices.", stacklevel=2
361
+ )
362
+ return indices
363
+
364
+
365
+ def _merge_nested_dicts(dict1: dict, dict2: Mapping) -> None:
366
+ """Merge two nested dictionaries.
367
+
368
+ Values in dict2 will override values in dict1.
369
+ """
370
+ for key, value in dict2.items():
371
+ if key in dict1 and isinstance(dict1[key], dict) and isinstance(value, dict):
372
+ _merge_nested_dicts(dict1[key], value)
373
+ else:
374
+ dict1[key] = value
@@ -29,7 +29,7 @@ def position_sizes(seq: useq.MDASequence) -> list[dict[str, int]]:
29
29
  `{dim: size}` pairs for each dimension in the sequence. Dimensions with no size
30
30
  will be omitted, though singletons will be included.
31
31
  """
32
- main_sizes = seq.sizes.copy()
32
+ main_sizes = dict(seq.sizes)
33
33
  main_sizes.pop("p", None) # remove position
34
34
 
35
35
  if not seq.stage_positions:
@@ -0,0 +1,36 @@
1
+ from .functions import frame_metadata, summary_metadata
2
+ from .schema import (
3
+ ConfigGroup,
4
+ ConfigPreset,
5
+ DeviceInfo,
6
+ FrameMetaV1,
7
+ ImageInfo,
8
+ PixelSizeConfigPreset,
9
+ Position,
10
+ PropertyInfo,
11
+ PropertyValue,
12
+ StagePosition,
13
+ SummaryMetaV1,
14
+ SystemInfo,
15
+ )
16
+ from .serialize import json_dumps, to_builtins
17
+
18
+ __all__ = [
19
+ "ConfigGroup",
20
+ "ConfigPreset",
21
+ "ConfigPreset",
22
+ "DeviceInfo",
23
+ "FrameMetaV1",
24
+ "ImageInfo",
25
+ "PixelSizeConfigPreset",
26
+ "Position",
27
+ "PropertyInfo",
28
+ "PropertyValue",
29
+ "StagePosition",
30
+ "SummaryMetaV1",
31
+ "SystemInfo",
32
+ "frame_metadata",
33
+ "json_dumps",
34
+ "summary_metadata",
35
+ "to_builtins",
36
+ ]