pymmcore-plus 0.9.4__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 +133 -30
  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.4.dist-info → pymmcore_plus-0.13.0.dist-info}/METADATA +22 -11
  63. pymmcore_plus-0.13.0.dist-info/RECORD +71 -0
  64. {pymmcore_plus-0.9.4.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.4.dist-info/RECORD +0 -55
  67. {pymmcore_plus-0.9.4.dist-info → pymmcore_plus-0.13.0.dist-info}/entry_points.txt +0 -0
  68. {pymmcore_plus-0.9.4.dist-info → pymmcore_plus-0.13.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,15 +1,17 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from abc import abstractmethod
4
- from typing import TYPE_CHECKING, Any, Mapping, Protocol, runtime_checkable
4
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
5
5
 
6
6
  if TYPE_CHECKING:
7
- from typing import Iterable, Iterator
7
+ from collections.abc import Iterable, Iterator
8
8
 
9
9
  from numpy.typing import NDArray
10
10
  from useq import MDAEvent, MDASequence
11
11
 
12
- PImagePayload = tuple[NDArray, MDAEvent, dict]
12
+ from pymmcore_plus.metadata.schema import FrameMetaV1, SummaryMetaV1
13
+
14
+ PImagePayload = tuple[NDArray, MDAEvent, FrameMetaV1]
13
15
 
14
16
 
15
17
  # NOTE: This whole thing could potentially go in useq-schema
@@ -21,7 +23,7 @@ class PMDAEngine(Protocol):
21
23
  """Protocol that all MDA engines must implement."""
22
24
 
23
25
  @abstractmethod
24
- def setup_sequence(self, sequence: MDASequence) -> None | Mapping[str, Any]:
26
+ def setup_sequence(self, sequence: MDASequence) -> SummaryMetaV1 | None:
25
27
  """Setup state of system (hardware, etc.) before an MDA is run.
26
28
 
27
29
  This method is called once at the beginning of a sequence.
@@ -2,17 +2,10 @@ from __future__ import annotations
2
2
 
3
3
  import time
4
4
  import warnings
5
- from contextlib import nullcontext
5
+ from collections.abc import Iterable, Iterator, Sequence
6
+ from contextlib import AbstractContextManager, nullcontext
6
7
  from pathlib import Path
7
- from typing import (
8
- TYPE_CHECKING,
9
- Any,
10
- ContextManager,
11
- Iterable,
12
- Iterator,
13
- Sequence,
14
- Tuple,
15
- )
8
+ from typing import TYPE_CHECKING, Any
16
9
  from unittest.mock import MagicMock
17
10
 
18
11
  from useq import MDASequence
@@ -39,7 +32,7 @@ MSG = (
39
32
 
40
33
 
41
34
  class GeneratorMDASequence(MDASequence):
42
- axis_order: Tuple[str, ...] = () # noqa: UP006
35
+ axis_order: tuple[str, ...] = ()
43
36
 
44
37
  @property
45
38
  def sizes(self) -> dict[str, int]: # pragma: no cover
@@ -77,7 +70,10 @@ class MDARunner:
77
70
 
78
71
  self._canceled = False
79
72
  self._sequence: MDASequence | None = None
80
- self._reset_timer()
73
+ # timer for the full sequence, reset only once at the beginning of the sequence
74
+ self._sequence_t0: float = 0.0
75
+ # event clock, reset whenever `event.reset_event_timer` is True
76
+ self._t0: float = 0.0
81
77
 
82
78
  def set_engine(self, engine: PMDAEngine) -> PMDAEngine | None:
83
79
  """Set the [`PMDAEngine`][pymmcore_plus.mda.PMDAEngine] to use for the MDA run.""" # noqa: E501
@@ -89,7 +85,7 @@ class MDARunner:
89
85
  if self.is_running(): # pragma: no cover
90
86
  raise RuntimeError(
91
87
  "Cannot register a new engine when the current engine is running "
92
- "an acquistion. Please cancel the current engine's acquistion "
88
+ "an acquisition. Please cancel the current engine's acquisition "
93
89
  "before registering"
94
90
  )
95
91
 
@@ -115,7 +111,7 @@ class MDARunner:
115
111
  return self._signals
116
112
 
117
113
  def is_running(self) -> bool:
118
- """Return True if an acquistion is currently underway.
114
+ """Return True if an acquisition is currently underway.
119
115
 
120
116
  This will return True at any point between the emission of the
121
117
  [`sequenceStarted`][pymmcore_plus.mda.PMDASignaler.sequenceStarted] and
@@ -125,19 +121,19 @@ class MDARunner:
125
121
  Returns
126
122
  -------
127
123
  bool
128
- Whether an acquistion is underway.
124
+ Whether an acquisition is underway.
129
125
  """
130
126
  return self._running
131
127
 
132
128
  def is_paused(self) -> bool:
133
- """Return True if the acquistion is currently paused.
129
+ """Return True if the acquisition is currently paused.
134
130
 
135
131
  Use `toggle_pause` to change the paused state.
136
132
 
137
133
  Returns
138
134
  -------
139
135
  bool
140
- Whether the current acquistion is paused.
136
+ Whether the current acquisition is paused.
141
137
  """
142
138
  return self._paused
143
139
 
@@ -145,7 +141,7 @@ class MDARunner:
145
141
  """Cancel the currently running acquisition.
146
142
 
147
143
  This is a no-op if no acquisition is currently running.
148
- If an acquisition is running then this will cancel the acquistion and
144
+ If an acquisition is running then this will cancel the acquisition and
149
145
  a sequenceCanceled signal, followed by a sequenceFinished signal will
150
146
  be emitted.
151
147
  """
@@ -157,7 +153,7 @@ class MDARunner:
157
153
 
158
154
  To get whether the acquisition is currently paused use the
159
155
  [`is_paused`][pymmcore_plus.mda.MDARunner.is_paused] method. This method is a
160
- no-op if no acquistion is currently underway.
156
+ no-op if no acquisition is currently underway.
161
157
  """
162
158
  if self.is_running():
163
159
  self._paused = not self._paused
@@ -169,7 +165,7 @@ class MDARunner:
169
165
  *,
170
166
  output: SingleOutput | Sequence[SingleOutput] | None = None,
171
167
  ) -> None:
172
- """Run the multi-dimensional acquistion defined by `sequence`.
168
+ """Run the multi-dimensional acquisition defined by `sequence`.
173
169
 
174
170
  Most users should not use this directly as it will block further
175
171
  execution. Instead, use the
@@ -210,9 +206,21 @@ class MDARunner:
210
206
  if error is not None:
211
207
  raise error
212
208
 
209
+ def seconds_elapsed(self) -> float:
210
+ """Return the number of seconds since the start of the acquisition."""
211
+ return time.perf_counter() - self._sequence_t0
212
+
213
+ def event_seconds_elapsed(self) -> float:
214
+ """Return the number of seconds on the "event clock".
215
+
216
+ This is the time since either the start of the acquisition or the last
217
+ event with `reset_event_timer` set to `True`.
218
+ """
219
+ return time.perf_counter() - self._t0
220
+
213
221
  def _outputs_connected(
214
222
  self, output: SingleOutput | Sequence[SingleOutput] | None
215
- ) -> ContextManager:
223
+ ) -> AbstractContextManager:
216
224
  """Context in which output handlers are connected to the frameReady signal."""
217
225
  if output is None:
218
226
  return nullcontext()
@@ -266,10 +274,21 @@ class MDARunner:
266
274
  def _run(self, engine: PMDAEngine, events: Iterable[MDAEvent]) -> None:
267
275
  """Main execution of events, inside the try/except block of `run`."""
268
276
  teardown_event = getattr(engine, "teardown_event", lambda e: None)
269
- event_iterator = getattr(engine, "event_iterator", iter)
277
+ if isinstance(events, Iterator):
278
+ # if an iterator is passed directly, then we use that iterator
279
+ # instead of the engine's event_iterator. Directly passing an iterator
280
+ # is an advanced use case, (for example, `iter(Queue(), None)` for event-
281
+ # driven acquisition) and we don't want the engine to interfere with it.
282
+ event_iterator = iter
283
+ else:
284
+ event_iterator = getattr(engine, "event_iterator", iter)
270
285
  _events: Iterator[MDAEvent] = event_iterator(events)
286
+ self._reset_event_timer()
287
+ self._sequence_t0 = self._t0
271
288
 
272
289
  for event in _events:
290
+ if event.reset_event_timer:
291
+ self._reset_event_timer()
273
292
  # If cancelled break out of the loop
274
293
  if self._wait_until_event(event) or not self._running:
275
294
  break
@@ -278,17 +297,23 @@ class MDARunner:
278
297
  logger.info("%s", event)
279
298
  engine.setup_event(event)
280
299
 
281
- output = engine.exec_event(event) or () # in case output is None
282
-
283
- for payload in output:
284
- img, event, meta = payload
285
- if "PerfCounter" in meta:
286
- meta["ElapsedTime-ms"] = (meta["PerfCounter"] - self._t0) * 1000
287
- meta["Event"] = event
288
- with exceptions_logged():
289
- self._signals.frameReady.emit(img, event, meta)
290
-
291
- teardown_event(event)
300
+ try:
301
+ runner_time_ms = self.seconds_elapsed() * 1000
302
+ # this is a bit of a hack to pass the time into the engine
303
+ # it is used for intra-event time calculations inside the engine.
304
+ # we pop it off after the event is executed.
305
+ event.metadata["runner_t0"] = self._sequence_t0
306
+ output = engine.exec_event(event) or () # in case output is None
307
+ for payload in output:
308
+ img, event, meta = payload
309
+ event.metadata.pop("runner_t0", None)
310
+ # if the engine calculated its own time, don't overwrite it
311
+ if "runner_time_ms" not in meta:
312
+ meta["runner_time_ms"] = runner_time_ms
313
+ with exceptions_logged():
314
+ self._signals.frameReady.emit(img, event, meta)
315
+ finally:
316
+ teardown_event(event)
292
317
 
293
318
  def _prepare_to_run(self, sequence: MDASequence) -> PMDAEngine:
294
319
  """Set up for the MDA run.
@@ -307,18 +332,13 @@ class MDARunner:
307
332
  self._sequence = sequence
308
333
 
309
334
  meta = self._engine.setup_sequence(sequence)
310
- logger.info("MDA Started: %s", sequence)
311
-
312
335
  self._signals.sequenceStarted.emit(sequence, meta or {})
313
- self._reset_timer()
336
+ logger.info("MDA Started: %s", sequence)
314
337
  return self._engine
315
338
 
316
- def _reset_timer(self) -> None:
339
+ def _reset_event_timer(self) -> None:
317
340
  self._t0 = time.perf_counter() # reference time, in seconds
318
341
 
319
- def _time_elapsed(self) -> float:
320
- return time.perf_counter() - self._t0
321
-
322
342
  def _check_canceled(self) -> bool:
323
343
  """Return True if the cancel method has been called and emit relevant signals.
324
344
 
@@ -369,7 +389,7 @@ class MDARunner:
369
389
  go_at = event.min_start_time + self._paused_time
370
390
  # We need to enter a loop here checking paused and canceled.
371
391
  # otherwise you'll potentially wait a long time to cancel
372
- remaining_wait_time = go_at - self._time_elapsed()
392
+ remaining_wait_time = go_at - self.event_seconds_elapsed()
373
393
  while remaining_wait_time > 0:
374
394
  self._signals.awaitingEvent.emit(event, remaining_wait_time)
375
395
  while self._paused and not self._canceled:
@@ -380,7 +400,7 @@ class MDARunner:
380
400
  if self._canceled:
381
401
  break
382
402
  time.sleep(min(remaining_wait_time, 0.5))
383
- remaining_wait_time = go_at - self._time_elapsed()
403
+ remaining_wait_time = go_at - self.event_seconds_elapsed()
384
404
 
385
405
  # check canceled again in case it was canceled
386
406
  # during the waiting loop
@@ -402,12 +422,3 @@ class MDARunner:
402
422
 
403
423
  logger.info("MDA Finished: %s", sequence)
404
424
  self._signals.sequenceFinished.emit(sequence)
405
-
406
-
407
- def _assert_handler(handler: Any) -> None:
408
- if (
409
- not hasattr(handler, "start")
410
- or not hasattr(handler, "finish")
411
- or not hasattr(handler, "put")
412
- ):
413
- raise TypeError("Handler must have start, finish, and put methods.")
@@ -11,7 +11,9 @@ from pymmcore_plus._util import listeners_connected
11
11
  from .events import _get_auto_MDA_callback_class
12
12
 
13
13
  if TYPE_CHECKING:
14
- from typing import Any, ContextManager, Iterator
14
+ from collections.abc import Iterator
15
+ from contextlib import AbstractContextManager
16
+ from typing import Any
15
17
 
16
18
  from pymmcore_plus.core.events._protocol import PSignalInstance
17
19
  from pymmcore_plus.mda import PMDASignaler
@@ -24,7 +26,7 @@ def mda_listeners_connected(
24
26
  name_map: dict[str, str] | None = ...,
25
27
  asynchronous: Literal[False],
26
28
  wait_on_exit: bool = ...,
27
- ) -> ContextManager[None]: ...
29
+ ) -> AbstractContextManager[None]: ...
28
30
 
29
31
 
30
32
  @overload
@@ -34,7 +36,7 @@ def mda_listeners_connected(
34
36
  name_map: dict[str, str] | None = ...,
35
37
  asynchronous: Literal[True] = ...,
36
38
  wait_on_exit: bool = ...,
37
- ) -> ContextManager[MDARelayThread]: ...
39
+ ) -> AbstractContextManager[MDARelayThread]: ...
38
40
 
39
41
 
40
42
  @contextmanager
@@ -8,12 +8,12 @@ from ._protocol import PMDASignaler
8
8
  from ._psygnal import MDASignaler
9
9
 
10
10
  if TYPE_CHECKING:
11
- from ._qsignals import QMDASignaler # noqa: TCH004
11
+ from ._qsignals import QMDASignaler # noqa: TC004
12
12
 
13
13
 
14
14
  __all__ = [
15
- "PMDASignaler",
16
15
  "MDASignaler",
16
+ "PMDASignaler",
17
17
  "QMDASignaler",
18
18
  "_get_auto_MDA_callback_class",
19
19
  ]
@@ -8,7 +8,11 @@ class PMDASignaler(Protocol):
8
8
  """Declares the protocol for all signals that will be emitted from [`pymmcore_plus.mda.MDARunner`][].""" # noqa: E501
9
9
 
10
10
  sequenceStarted: PSignal
11
- """Emits `(sequence: MDASequence, metadata: dict)` when an acquisition sequence is started.""" # noqa: E501
11
+ """Emits `(sequence: MDASequence, metadata: dict)` when an acquisition sequence is started.
12
+
13
+ For the default [`MDAEngine`][pymmcore_plus.mda.MDAEngine], the metadata `dict` will
14
+ be of type [`SummaryMetaV1`][pymmcore_plus.metadata.schema.SummaryMetaV1].
15
+ """ # noqa: E501
12
16
  sequencePauseToggled: PSignal
13
17
  """Emits `(paused: bool)` when an acquisition sequence is paused or unpaused."""
14
18
  sequenceCanceled: PSignal
@@ -16,7 +20,11 @@ class PMDASignaler(Protocol):
16
20
  sequenceFinished: PSignal
17
21
  """Emits `(sequence: MDASequence)` when an acquisition sequence is finished."""
18
22
  frameReady: PSignal
19
- """Emits `(img: np.ndarray, event: MDAEvent, metadata: dict)` after an image is acquired during an acquisition sequence.""" # noqa: E501
23
+ """Emits `(img: np.ndarray, event: MDAEvent, metadata: dict)` after an image is acquired during an acquisition sequence.
24
+
25
+ For the default [`MDAEngine`][pymmcore_plus.mda.MDAEngine], the metadata `dict` will
26
+ be of type [`FrameMetaV1`][pymmcore_plus.metadata.schema.FrameMetaV1].
27
+ """ # noqa: E501
20
28
  awaitingEvent: PSignal
21
29
  """Emits `(event: MDAEvent, remaining_sec: float)` when the runner is waiting to start an event.
22
30
 
@@ -1,9 +1,9 @@
1
1
  import numpy as np
2
- from psygnal import Signal
2
+ from psygnal import Signal, SignalGroup
3
3
  from useq import MDAEvent, MDASequence
4
4
 
5
5
 
6
- class MDASignaler:
6
+ class MDASignaler(SignalGroup):
7
7
  sequenceStarted = Signal(MDASequence, dict) # at the start of an MDA sequence
8
8
  sequencePauseToggled = Signal(bool) # when MDA is paused/unpaused
9
9
  sequenceCanceled = Signal(MDASequence) # when mda is canceled
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- import json
3
+ import warnings
4
4
  from abc import abstractmethod
5
5
  from collections import defaultdict
6
6
  from typing import TYPE_CHECKING, Generic, Protocol, TypeVar
@@ -8,13 +8,18 @@ from typing import TYPE_CHECKING, Generic, Protocol, TypeVar
8
8
  from ._util import position_sizes
9
9
 
10
10
  if TYPE_CHECKING:
11
+ from collections.abc import Mapping
12
+
11
13
  import numpy as np
12
14
  import useq
13
15
 
16
+ from pymmcore_plus.metadata import FrameMetaV1, SummaryMetaV1
17
+
14
18
  class SupportsSetItem(Protocol):
15
19
  def __setitem__(self, key: tuple[int, ...], value: np.ndarray) -> None: ...
16
20
 
17
21
 
22
+ _NULL = object()
18
23
  POS_PREFIX = "p"
19
24
  T = TypeVar("T", bound="SupportsSetItem")
20
25
 
@@ -66,36 +71,73 @@ class _5DWriterBase(Generic[T]):
66
71
 
67
72
  # storage of individual frame metadata
68
73
  # maps position key to list of frame metadata
69
- self.frame_metadatas: defaultdict[str, list[dict]] = defaultdict(list)
74
+ self.frame_metadatas: defaultdict[str, list[FrameMetaV1]] = defaultdict(list)
70
75
 
71
76
  # set during sequenceStarted and cleared during sequenceFinished
72
77
  self.current_sequence: useq.MDASequence | None = None
73
78
 
74
79
  # list of {dim_name: size} map for each position in the sequence
75
- self.position_sizes: list[dict[str, int]] = []
80
+ self._position_sizes: list[dict[str, int]] = []
81
+
82
+ # actual timestamps for each frame
83
+ self._timestamps: list[float] = []
76
84
 
77
85
  # The next three methods - `sequenceStarted`, `sequenceFinished`, and `frameReady`
78
86
  # are to be connected directly to the MDA's signals, perhaps via listener_connected
79
87
 
80
- def sequenceStarted(self, seq: useq.MDASequence) -> None:
88
+ @property
89
+ def position_sizes(self) -> list[dict[str, int]]:
90
+ """Return sizes of each dimension for each position in the sequence.
91
+
92
+ Will be a list of dicts, where each dict maps dimension names to sizes, and
93
+ the index in the list corresponds to the stage position index.
94
+
95
+ This is the preferred way to access both the dimensions present in each position
96
+ as well as the sizes of those dimensions.
97
+
98
+ The dict is ordered, and the order reflects the order of dimensions in the
99
+ shape of each position.
100
+ """
101
+ return self._position_sizes
102
+
103
+ def sequenceStarted(
104
+ self, seq: useq.MDASequence, meta: SummaryMetaV1 | object = _NULL
105
+ ) -> None:
81
106
  """On sequence started, simply store the sequence."""
107
+ # this is here for backwards compatibility with experimental viewer widget.
108
+ if meta is _NULL: # pragma: no cover
109
+ warnings.warn(
110
+ "calling `sequenceStarted` without metadata as the second argument is "
111
+ "deprecated and will raise an exception in the future. Please propagate"
112
+ " metadata from the event callback.",
113
+ UserWarning,
114
+ stacklevel=2,
115
+ )
82
116
  self.frame_metadatas.clear()
83
117
  self.current_sequence = seq
84
118
  if seq:
85
- self.position_sizes = position_sizes(seq)
119
+ self._position_sizes = position_sizes(seq)
86
120
 
87
121
  def sequenceFinished(self, seq: useq.MDASequence) -> None:
88
122
  """On sequence finished, clear the current sequence."""
89
123
  self.finalize_metadata()
90
124
  self.frame_metadatas.clear()
91
- self.current_sequence = None
92
- self.position_sizes = []
93
125
 
94
- def frameReady(self, frame: np.ndarray, event: useq.MDAEvent, meta: dict) -> None:
126
+ def get_position_key(self, position_index: int) -> str:
127
+ """Get the position key for a specific position index.
128
+
129
+ This key will be used for subclasses like Zarr that need a directory structure
130
+ for each position. And may also be used to index into `self.position_arrays`.
131
+ """
132
+ return f"{POS_PREFIX}{position_index}"
133
+
134
+ def frameReady(
135
+ self, frame: np.ndarray, event: useq.MDAEvent, meta: FrameMetaV1
136
+ ) -> None:
95
137
  """Write frame to the zarr array for the appropriate position."""
96
138
  # get the position key to store the array in the group
97
139
  p_index = event.index.get("p", 0)
98
- key = f"{POS_PREFIX}{p_index}"
140
+ key = self.get_position_key(p_index)
99
141
  pos_sizes = self.position_sizes[p_index]
100
142
  if key in self.position_arrays:
101
143
  ary = self.position_arrays[key]
@@ -117,6 +159,9 @@ class _5DWriterBase(Generic[T]):
117
159
  self.position_arrays[key] = ary = self.new_array(key, frame.dtype, sizes)
118
160
 
119
161
  index = tuple(event.index[k] for k in pos_sizes)
162
+ t = event.index.get("t", 0)
163
+ if t >= len(self._timestamps) and "runner_time_ms" in meta:
164
+ self._timestamps.append(meta["runner_time_ms"])
120
165
  self.write_frame(ary, index, frame)
121
166
  self.store_frame_metadata(key, event, meta)
122
167
 
@@ -162,7 +207,9 @@ class _5DWriterBase(Generic[T]):
162
207
  # WRITE DATA TO DISK
163
208
  ary[index] = frame
164
209
 
165
- def store_frame_metadata(self, key: str, event: useq.MDAEvent, meta: dict) -> None:
210
+ def store_frame_metadata(
211
+ self, key: str, event: useq.MDAEvent, meta: FrameMetaV1
212
+ ) -> None:
166
213
  """Called during each frameReady event to store metadata for the frame.
167
214
 
168
215
  Subclasses may override this method to customize how metadata is stored for each
@@ -181,11 +228,6 @@ class _5DWriterBase(Generic[T]):
181
228
  # needn't be re-implemented in subclasses
182
229
  # default implementation is to store the metadata in self._frame_metas
183
230
  # use finalize_metadata to write to disk at the end of the sequence.
184
- if meta:
185
- # fix serialization MDAEvent
186
- # XXX: There is already an Event object in meta, this overwrites it.
187
- event_json = event.json(exclude={"sequence"}, exclude_defaults=True)
188
- meta["Event"] = json.loads(event_json)
189
231
  self.frame_metadatas[key].append(meta or {})
190
232
 
191
233
  def finalize_metadata(self) -> None:
@@ -194,3 +236,52 @@ class _5DWriterBase(Generic[T]):
194
236
  Subclasses may override this method to flush any accumulated frame metadata to
195
237
  disk at the end of the sequence.
196
238
  """
239
+
240
+ # This syntax is intentionally the same as xarray's isel method. It's possible
241
+ # we will use xarray in the future, and this will make it easier to switch.
242
+ def isel(
243
+ self,
244
+ indexers: Mapping[str, int | slice] | None = None,
245
+ **indexers_kwargs: int | slice,
246
+ ) -> np.ndarray:
247
+ """Select data from the array.
248
+
249
+ This is a convenience method to select data from the array for a given position
250
+ key. It will call the appropriate `__getitem__` method on the array with the
251
+ given indexers.
252
+
253
+ Parameters
254
+ ----------
255
+ indexers : Mapping[str, int | slice] | None
256
+ Mapping of dimension names to indices or slices. If None, will return the
257
+ full array.
258
+ **indexers_kwargs : int | slice
259
+ Keyword arguments of dimension names to indices or slices. These will be
260
+ merged with `indexers`, and allows for a nicer syntax.
261
+
262
+ Returns
263
+ -------
264
+ np.ndarray
265
+ The selected data from the array.
266
+
267
+ Examples
268
+ --------
269
+ >>> data = writer.isel(t=0, z=1, c=0, x=slice(128, 256))
270
+ """
271
+ indexers = dict(indexers or {})
272
+ indexers.update(indexers_kwargs)
273
+
274
+ p_index = indexers.get("p", 0)
275
+ if isinstance(p_index, slice):
276
+ raise NotImplementedError("Cannot slice over position index") # TODO
277
+
278
+ try:
279
+ sizes = [*list(self.position_sizes[p_index]), "y", "x"]
280
+ except IndexError as e:
281
+ raise IndexError(
282
+ f"Position index {p_index} out of range for {len(self.position_sizes)}"
283
+ ) from e
284
+ data = self.position_arrays[self.get_position_key(p_index)]
285
+ full = slice(None, None)
286
+ index = tuple(indexers.get(k, full) for k in sizes)
287
+ return data[index] # type: ignore
@@ -1,5 +1,11 @@
1
1
  from ._img_sequence_writer import ImageSequenceWriter
2
2
  from ._ome_tiff_writer import OMETiffWriter
3
3
  from ._ome_zarr_writer import OMEZarrWriter
4
+ from ._tensorstore_handler import TensorStoreHandler
4
5
 
5
- __all__ = ["ImageSequenceWriter", "OMEZarrWriter", "OMETiffWriter"]
6
+ __all__ = [
7
+ "ImageSequenceWriter",
8
+ "OMETiffWriter",
9
+ "OMEZarrWriter",
10
+ "TensorStoreHandler",
11
+ ]
@@ -7,10 +7,12 @@ provided.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- import json
10
+ from collections.abc import Mapping, Sequence
11
11
  from itertools import count
12
12
  from pathlib import Path
13
- from typing import TYPE_CHECKING, Any, Callable, ClassVar, Mapping, Sequence, cast
13
+ from typing import TYPE_CHECKING, Any, Callable, ClassVar, cast
14
+
15
+ from pymmcore_plus.metadata.serialize import json_dumps
14
16
 
15
17
  from ._util import get_full_sequence_axes
16
18
 
@@ -176,11 +178,13 @@ class ImageSequenceWriter:
176
178
  include_frame_count=self._include_frame_count,
177
179
  )
178
180
  # make directory and write metadata
179
- self._seq_meta_file.write_text(seq.json(exclude_unset=True, indent=4))
181
+ self._seq_meta_file.write_text(
182
+ seq.model_dump_json(exclude_unset=True, indent=2)
183
+ )
180
184
 
181
185
  def sequenceFinished(self, seq: useq.MDASequence) -> None:
182
186
  # write final frame metadata to disk
183
- self._frame_meta_file.write_text(json.dumps(self._frame_metadata, indent=2))
187
+ self._frame_meta_file.write_bytes(json_dumps(self._frame_metadata, indent=2))
184
188
 
185
189
  def frameReady(self, frame: np.ndarray, event: useq.MDAEvent, meta: dict) -> None:
186
190
  """Write a frame to disk."""
@@ -199,11 +203,12 @@ class ImageSequenceWriter:
199
203
  self._imwrite(str(self._directory / filename), frame, **self._imwrite_kwargs)
200
204
 
201
205
  # store metadata
202
- meta["Event"] = json.loads(event.json(exclude={"sequence"}, exclude_unset=True))
203
206
  self._frame_metadata[filename] = meta
204
207
  # write metadata to disk every 10 frames
205
208
  if frame_idx % 10 == 0:
206
- self._frame_meta_file.write_text(json.dumps(self._frame_metadata, indent=2))
209
+ self._frame_meta_file.write_bytes(
210
+ json_dumps(self._frame_metadata, indent=2)
211
+ )
207
212
 
208
213
  @staticmethod
209
214
  def fname_template(
@@ -41,13 +41,15 @@ from typing import TYPE_CHECKING, Any
41
41
 
42
42
  import numpy as np
43
43
 
44
- from ._5d_writer_base import _5DWriterBase
44
+ from ._5d_writer_base import _NULL, _5DWriterBase
45
45
 
46
46
  if TYPE_CHECKING:
47
47
  from pathlib import Path
48
48
 
49
49
  import useq
50
50
 
51
+ from pymmcore_plus.metadata import SummaryMetaV1
52
+
51
53
  IMAGEJ_AXIS_ORDER = "tzcyxs"
52
54
 
53
55
 
@@ -81,13 +83,15 @@ class OMETiffWriter(_5DWriterBase[np.memmap]):
81
83
 
82
84
  super().__init__()
83
85
 
84
- def sequenceStarted(self, seq: useq.MDASequence) -> None:
85
- super().sequenceStarted(seq)
86
+ def sequenceStarted(
87
+ self, seq: useq.MDASequence, meta: SummaryMetaV1 | object = _NULL
88
+ ) -> None:
89
+ super().sequenceStarted(seq, meta)
86
90
  # Non-OME (ImageJ) hyperstack axes MUST be in TZCYXS order
87
91
  # so we reorder the ordered position_sizes dicts. This will ensure
88
92
  # that the array indices created from event.index are in the correct order.
89
93
  if not self._is_ome:
90
- self.position_sizes = [
94
+ self._position_sizes = [
91
95
  {k: x[k] for k in IMAGEJ_AXIS_ORDER if k.lower() in x}
92
96
  for x in self.position_sizes
93
97
  ]