pymmcore-plus 0.15.4__py3-none-any.whl → 0.17.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 (35) hide show
  1. pymmcore_plus/__init__.py +20 -1
  2. pymmcore_plus/_accumulator.py +23 -5
  3. pymmcore_plus/_cli.py +44 -26
  4. pymmcore_plus/_discovery.py +344 -0
  5. pymmcore_plus/_ipy_completion.py +1 -1
  6. pymmcore_plus/_logger.py +3 -3
  7. pymmcore_plus/_util.py +9 -245
  8. pymmcore_plus/core/_device.py +57 -13
  9. pymmcore_plus/core/_mmcore_plus.py +20 -23
  10. pymmcore_plus/core/_property.py +35 -29
  11. pymmcore_plus/core/_sequencing.py +2 -0
  12. pymmcore_plus/core/events/_device_signal_view.py +8 -1
  13. pymmcore_plus/experimental/simulate/__init__.py +88 -0
  14. pymmcore_plus/experimental/simulate/_objects.py +670 -0
  15. pymmcore_plus/experimental/simulate/_render.py +510 -0
  16. pymmcore_plus/experimental/simulate/_sample.py +156 -0
  17. pymmcore_plus/experimental/unicore/__init__.py +2 -0
  18. pymmcore_plus/experimental/unicore/_device_manager.py +46 -13
  19. pymmcore_plus/experimental/unicore/core/_config.py +706 -0
  20. pymmcore_plus/experimental/unicore/core/_unicore.py +834 -18
  21. pymmcore_plus/experimental/unicore/devices/_device_base.py +13 -0
  22. pymmcore_plus/experimental/unicore/devices/_hub.py +50 -0
  23. pymmcore_plus/experimental/unicore/devices/_stage.py +46 -1
  24. pymmcore_plus/experimental/unicore/devices/_state.py +6 -0
  25. pymmcore_plus/install.py +149 -18
  26. pymmcore_plus/mda/_engine.py +268 -73
  27. pymmcore_plus/mda/handlers/_5d_writer_base.py +16 -5
  28. pymmcore_plus/mda/handlers/_tensorstore_handler.py +7 -1
  29. pymmcore_plus/metadata/_ome.py +553 -0
  30. pymmcore_plus/metadata/functions.py +2 -1
  31. {pymmcore_plus-0.15.4.dist-info → pymmcore_plus-0.17.0.dist-info}/METADATA +7 -4
  32. {pymmcore_plus-0.15.4.dist-info → pymmcore_plus-0.17.0.dist-info}/RECORD +35 -27
  33. {pymmcore_plus-0.15.4.dist-info → pymmcore_plus-0.17.0.dist-info}/WHEEL +1 -1
  34. {pymmcore_plus-0.15.4.dist-info → pymmcore_plus-0.17.0.dist-info}/entry_points.txt +0 -0
  35. {pymmcore_plus-0.15.4.dist-info → pymmcore_plus-0.17.0.dist-info}/licenses/LICENSE +0 -0
@@ -79,6 +79,9 @@ class _5DWriterBase(Generic[T]):
79
79
  # list of {dim_name: size} map for each position in the sequence
80
80
  self._position_sizes: list[dict[str, int]] = []
81
81
 
82
+ # map of position index to position key
83
+ self._position_key_map: dict[int, str] = {}
84
+
82
85
  # actual timestamps for each frame
83
86
  self._timestamps: list[float] = []
84
87
 
@@ -123,13 +126,21 @@ class _5DWriterBase(Generic[T]):
123
126
  self.finalize_metadata()
124
127
  self.frame_metadatas.clear()
125
128
 
126
- def get_position_key(self, position_index: int) -> str:
127
- """Get the position key for a specific position index.
129
+ def get_position_key(self, event: useq.MDAEvent) -> str:
130
+ """Get the position key for a specific MDA event.
128
131
 
129
132
  This key will be used for subclasses like Zarr that need a directory structure
130
133
  for each position. And may also be used to index into `self.position_arrays`.
131
134
  """
132
- return f"{POS_PREFIX}{position_index}"
135
+ pos_index = event.index.get("p", 0)
136
+ if pos_index in self._position_key_map:
137
+ return self._position_key_map[pos_index]
138
+
139
+ pos_key = event.pos_name
140
+ if pos_key is None:
141
+ pos_key = f"{POS_PREFIX}{pos_index}"
142
+ self._position_key_map[pos_index] = pos_key
143
+ return pos_key
133
144
 
134
145
  def frameReady(
135
146
  self, frame: np.ndarray, event: useq.MDAEvent, meta: FrameMetaV1
@@ -137,7 +148,7 @@ class _5DWriterBase(Generic[T]):
137
148
  """Write frame to the zarr array for the appropriate position."""
138
149
  # get the position key to store the array in the group
139
150
  p_index = event.index.get("p", 0)
140
- key = self.get_position_key(p_index)
151
+ key = self.get_position_key(event)
141
152
  pos_sizes = self.position_sizes[p_index]
142
153
  if key in self.position_arrays:
143
154
  ary = self.position_arrays[key]
@@ -281,7 +292,7 @@ class _5DWriterBase(Generic[T]):
281
292
  raise IndexError(
282
293
  f"Position index {p_index} out of range for {len(self.position_sizes)}"
283
294
  ) from e
284
- data = self.position_arrays[self.get_position_key(p_index)]
295
+ data = self.position_arrays[self._position_key_map[p_index]]
285
296
  full = slice(None, None)
286
297
  index = tuple(indexers.get(k, full) for k in sizes)
287
298
  return data[index] # type: ignore
@@ -107,7 +107,13 @@ class TensorStoreHandler:
107
107
  self._ts = tensorstore
108
108
 
109
109
  self.ts_driver = driver
110
- self.kvstore = f"file://{path}" if path is not None else kvstore
110
+ if path is not None:
111
+ self.kvstore: dict | str = {"driver": "file", "path": str(path)}
112
+ elif kvstore is not None:
113
+ self.kvstore = kvstore
114
+ else:
115
+ raise ValueError("Either path or kvstore must be provided.")
116
+
111
117
  self.delete_existing = delete_existing
112
118
  self.spec = spec
113
119
 
@@ -0,0 +1,553 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from contextlib import suppress
5
+ from datetime import datetime
6
+ from typing import TYPE_CHECKING, NamedTuple
7
+
8
+ import useq
9
+ from ome_types.model import (
10
+ OME,
11
+ Channel,
12
+ Image,
13
+ ImageRef,
14
+ Instrument,
15
+ Pixels,
16
+ Pixels_DimensionOrder,
17
+ PixelType,
18
+ Plane,
19
+ Plate,
20
+ TiffData,
21
+ UnitsLength,
22
+ UnitsTime,
23
+ Well,
24
+ WellSample,
25
+ )
26
+
27
+ from pymmcore_plus.mda._runner import GeneratorMDASequence
28
+
29
+ if TYPE_CHECKING:
30
+ from pymmcore_plus.metadata.schema import ImageInfo
31
+
32
+ from .schema import FrameMetaV1, SummaryMetaV1
33
+
34
+
35
+ __all__ = ["create_ome_metadata"]
36
+
37
+
38
+ def create_ome_metadata(
39
+ summary_metadata: SummaryMetaV1, frame_metadata_list: list[FrameMetaV1]
40
+ ) -> OME:
41
+ """Create OME metadata from metadata saved as json by the core engine.
42
+
43
+ Parameters
44
+ ----------
45
+ summary_metadata : SummaryMetaV1
46
+ Summary metadata containing acquisition information.
47
+ frame_metadata_list : list[FrameMetaV1]
48
+ List of frame metadata for each acquired image.
49
+
50
+ Returns
51
+ -------
52
+ OME
53
+ The OME metadata as an `ome_types.OME` object.
54
+ """
55
+ _uuid = f"urn:uuid:{uuid.uuid4()}"
56
+ ome = OME(uuid=_uuid)
57
+
58
+ ome.instruments = instruments = _build_instrument_list(summary_metadata)
59
+
60
+ image_infos = summary_metadata.get("image_infos", ())
61
+ if not frame_metadata_list or not image_infos:
62
+ return ome
63
+
64
+ sequence = _extract_mda_sequence(summary_metadata, frame_metadata_list[0])
65
+ position_groups = _group_frames_by_position(frame_metadata_list)
66
+ images = _build_ome_images(
67
+ dimension_info=_extract_dimension_info(image_infos[0]),
68
+ sequence=sequence,
69
+ position_groups=position_groups,
70
+ acquisition_date=_extract_acquisition_date(summary_metadata),
71
+ )
72
+
73
+ plates = []
74
+ if (plate_plan := _extract_plate_plan(sequence)) is not None:
75
+ position_to_image_mapping = _create_position_to_image_mapping(position_groups)
76
+ plates = [_build_ome_plate(plate_plan, position_to_image_mapping)]
77
+
78
+ return OME(
79
+ uuid=_uuid,
80
+ images=images,
81
+ instruments=instruments,
82
+ plates=plates,
83
+ )
84
+
85
+
86
+ # =============================================================================
87
+ # Data Structures
88
+ # =============================================================================
89
+
90
+
91
+ class _DimensionInfo(NamedTuple):
92
+ pixel_size_um: float
93
+ dtype: str | None
94
+ height: int
95
+ width: int
96
+
97
+
98
+ class _PositionKey(NamedTuple):
99
+ name: str | None
100
+ p_index: int
101
+ g_index: int | None = None
102
+
103
+ def __str__(self) -> str:
104
+ if self.g_index is not None:
105
+ # if it has a name, include it in the position string before grid
106
+ # (e.g. name_p0000_g0000)
107
+ if self.name:
108
+ return f"{self.name}_p{self.p_index:04d}_g{self.g_index:04d}"
109
+ # otherwise just use p and g indices (e.g. p0000_g0000)
110
+ return f"p{self.p_index:04d}_g{self.g_index:04d}"
111
+ else:
112
+ # if it has a name, include it in the position string (e.g. name_p0000)
113
+ if self.name:
114
+ return f"{self.name}_p{self.p_index:04d}"
115
+ # otherwise just use p index (e.g. p0000)
116
+ return f"p{self.p_index:04d}"
117
+
118
+ @property
119
+ def image_id(self) -> str:
120
+ if self.g_index is not None:
121
+ return f"{self.p_index}:{self.g_index}"
122
+ return f"{self.p_index}"
123
+
124
+
125
+ # =============================================================================
126
+ # Metadata Extraction Functions
127
+ # =============================================================================
128
+
129
+
130
+ def _extract_dimension_info(
131
+ image_info: ImageInfo,
132
+ ) -> _DimensionInfo:
133
+ """Extract pixel size (µm), data type, width, and height from image_infos."""
134
+ return _DimensionInfo(
135
+ pixel_size_um=image_info.get("pixel_size_um", 1.0),
136
+ dtype=image_info.get("dtype", None),
137
+ width=image_info.get("width", 0),
138
+ height=image_info.get("height", 0),
139
+ )
140
+
141
+
142
+ def _extract_acquisition_date(summary_metadata: SummaryMetaV1) -> datetime | None:
143
+ """Extract acquisition date from summary metadata."""
144
+ if (acquisition_time := summary_metadata.get("datetime")) is not None:
145
+ with suppress(ValueError, AttributeError):
146
+ return datetime.fromisoformat(acquisition_time.replace("Z", "+00:00"))
147
+ return None
148
+
149
+
150
+ def _extract_mda_sequence(
151
+ summary_metadata: SummaryMetaV1, single_frame_metadata: FrameMetaV1
152
+ ) -> useq.MDASequence | None:
153
+ """Extract the MDA sequence from summary metadata or frame metadata."""
154
+ if (sequence_data := summary_metadata.get("mda_sequence")) is not None:
155
+ return useq.MDASequence.model_validate(sequence_data)
156
+ if (mda_event := _extract_mda_event(single_frame_metadata)) is not None:
157
+ return mda_event.sequence
158
+ return None
159
+
160
+
161
+ def _extract_mda_event(frame_metadata: FrameMetaV1) -> useq.MDAEvent | None:
162
+ """Extract the useq.MDAEvent from frame metadata."""
163
+ if (mda_event_data := frame_metadata.get("mda_event")) is not None:
164
+ return useq.MDAEvent.model_validate(mda_event_data)
165
+ return None # pragma: no cover
166
+
167
+
168
+ def _extract_plate_plan(
169
+ sequence: useq.MDASequence | None,
170
+ ) -> useq.WellPlatePlan | None:
171
+ """Extract the plate plan from the MDA sequence if it exists."""
172
+ if sequence is None: # pragma: no cover
173
+ return None
174
+ stage_positions = sequence.stage_positions
175
+ if isinstance(stage_positions, useq.WellPlatePlan):
176
+ return stage_positions
177
+ return None
178
+
179
+
180
+ # =============================================================================
181
+ # Frame Grouping and Processing
182
+ # =============================================================================
183
+
184
+
185
+ def _group_frames_by_position(
186
+ frame_metadata_list: list[FrameMetaV1],
187
+ ) -> dict[_PositionKey, list[FrameMetaV1]]:
188
+ """Reorganize frame metadata by stage position index in a dictionary.
189
+
190
+ Handles the 'g' axis (grid) by converting it to separate positions,
191
+ since OME doesn't support the 'g' axis. Each grid position becomes
192
+ a separate OME Image with names like "Pos0000_Grid0000".
193
+
194
+ Returns
195
+ -------
196
+ dict[str, list[FrameMetaV1]]
197
+ mapping of position identifier (e.g. 'Pos0000_Grid0000')
198
+ to list of `FrameMetaV1`.
199
+ """
200
+ frames_by_position: dict[_PositionKey, list[FrameMetaV1]] = {}
201
+ for frame_metadata in frame_metadata_list:
202
+ if (mda_event := _extract_mda_event(frame_metadata)) is None:
203
+ continue # pragma: no cover
204
+
205
+ p_index = mda_event.index.get(useq.Axis.POSITION, 0) or 0
206
+ g_index = mda_event.index.get(useq.Axis.GRID, None)
207
+ key = _PositionKey(mda_event.pos_name, p_index, g_index)
208
+ pos_list = frames_by_position.setdefault(key, [])
209
+ pos_list.append(frame_metadata)
210
+ return frames_by_position
211
+
212
+
213
+ def _create_position_to_image_mapping(
214
+ position_groups: dict[_PositionKey, list[FrameMetaV1]],
215
+ ) -> dict[int, str]:
216
+ """Create a mapping from position index to image ID."""
217
+ position_to_image_mapping: dict[int, str] = {}
218
+
219
+ for position_key, position_frames in position_groups.items():
220
+ if position_frames:
221
+ mda_event = _extract_mda_event(position_frames[0])
222
+ if mda_event is not None:
223
+ position_index = mda_event.index.get("p", 0)
224
+ position_to_image_mapping[position_index] = position_key.image_id
225
+ return position_to_image_mapping
226
+
227
+
228
+ # =============================================================================
229
+ # Dimension Order and Pixel Information
230
+ # =============================================================================
231
+
232
+
233
+ def _determine_dimension_order(
234
+ sequence: useq.MDASequence | None,
235
+ ) -> Pixels_DimensionOrder | None:
236
+ """Determine the dimension order for pixels."""
237
+ if sequence is None or isinstance(sequence, GeneratorMDASequence):
238
+ return Pixels_DimensionOrder.XYTCZ
239
+ return _extract_dimension_order_from_sequence(sequence)
240
+
241
+
242
+ def _extract_dimension_order_from_sequence(
243
+ sequence: useq.MDASequence,
244
+ ) -> Pixels_DimensionOrder:
245
+ """Extract axis order from a useq.MDASequence.
246
+
247
+ useq axis_order represents iteration order (outermost to innermost loop),
248
+ while OME DimensionOrder represents rasterization order (slowest to fastest
249
+ varying dimension). Since planes are stored in the order they're generated,
250
+ we need to reverse the useq axis order to get the OME dimension order.
251
+
252
+ For example, if useq axis_order="tpzc":
253
+ - Iteration: for t in times: for p in positions: for z in z_steps: for c in channels
254
+ - Plane storage: t0-z0-c0, t0-z0-c1, t0-z1-c0, t0-z1-c1, t1-z0-c0, ...
255
+ - This means C varies fastest, then Z, then T → OME order "XYCZT"
256
+
257
+ Returns
258
+ -------
259
+ A Pixels_DimensionOrder representing the dimension order compatible with OME
260
+ standards (e.g., "XYCZT").
261
+ """
262
+ # Filter out 'p' and 'g' axes since they don't exist within a single OME Image
263
+ filtered_axes = [axis for axis in sequence.axis_order if axis not in {"p", "g"}]
264
+
265
+ # Reverse the order since useq is iteration order, OME is rasterization order
266
+ reversed_axes = filtered_axes[::-1]
267
+ dimension_order = "XY" + "".join(reversed_axes).upper()
268
+
269
+ # Ensure we have exactly 5 dimensions by adding missing ones
270
+ if len(dimension_order) != 5:
271
+ missing_axes = [axis for axis in "XYCZT" if axis not in dimension_order]
272
+ dimension_order += "".join(missing_axes)
273
+
274
+ return Pixels_DimensionOrder(dimension_order)
275
+
276
+
277
+ def _extract_pixel_dimensions_and_channels(
278
+ sequence: useq.MDASequence | None,
279
+ position_frames: list[FrameMetaV1],
280
+ image_id: str,
281
+ ) -> tuple[tuple[int, int, int], list[Channel]]:
282
+ """Extract pixel dimensions and channels from sequence or frames."""
283
+ if sequence is None or isinstance(sequence, GeneratorMDASequence):
284
+ return _extract_pixel_info_from_frames(position_frames, image_id)
285
+ return _extract_pixel_info_from_sequence(sequence, image_id)
286
+
287
+
288
+ def _extract_pixel_info_from_frames(
289
+ position_metadata: list[FrameMetaV1],
290
+ image_id: str,
291
+ ) -> tuple[tuple[int, int, int], list[Channel]]:
292
+ """Extract pixel dimensions and channel information from frame metadata.
293
+
294
+ Returns
295
+ -------
296
+ A tuple containing the maximum (t, z, c) dimensions, and a list of channels.
297
+ """
298
+ max_t, max_z, max_c = 0, 0, 0
299
+ channels: dict[int, Channel] = {}
300
+
301
+ for frame_metadata in position_metadata:
302
+ mda_event = _extract_mda_event(frame_metadata)
303
+ if mda_event is None: # pragma: no cover
304
+ continue
305
+
306
+ t_index = mda_event.index.get("t", 0)
307
+ z_index = mda_event.index.get("z", 0)
308
+ c_index = mda_event.index.get("c", 0)
309
+
310
+ max_t = max(max_t, t_index)
311
+ max_z = max(max_z, z_index)
312
+ max_c = max(max_c, c_index)
313
+
314
+ if c_index not in channels and mda_event.channel is not None:
315
+ channels[c_index] = Channel(
316
+ id=f"Channel:{image_id}:{c_index}",
317
+ name=mda_event.channel.config,
318
+ samples_per_pixel=1,
319
+ )
320
+
321
+ sorted_channels = [channels[i] for i in sorted(channels.keys())]
322
+ return (max_t + 1, max_z + 1, max_c + 1), sorted_channels
323
+
324
+
325
+ def _extract_pixel_info_from_sequence(
326
+ sequence: useq.MDASequence,
327
+ image_id: str,
328
+ ) -> tuple[tuple[int, int, int], list[Channel]]:
329
+ """Extract pixel dimensions and channel information from MDA sequence."""
330
+ max_t = sequence.sizes.get("t", 1)
331
+ max_z = sequence.sizes.get("z", 1)
332
+ channels = [
333
+ Channel(
334
+ id=f"Channel:{image_id}:{index}",
335
+ name=channel.config,
336
+ samples_per_pixel=1,
337
+ )
338
+ for index, channel in enumerate(sequence.channels)
339
+ ]
340
+ return (max_t, max_z, len(channels)), channels
341
+
342
+
343
+ # =============================================================================
344
+ # OME Object Builders
345
+ # =============================================================================
346
+
347
+
348
+ def _build_ome_images(
349
+ dimension_info: _DimensionInfo,
350
+ sequence: useq.MDASequence | None,
351
+ position_groups: dict[_PositionKey, list[FrameMetaV1]],
352
+ acquisition_date: datetime | None,
353
+ ) -> list[Image]:
354
+ """Build OME Images from grouped frame metadata by position."""
355
+ images = []
356
+ for position_key, position_frames in position_groups.items():
357
+ image_id = position_key.image_id
358
+ position_name = str(position_key)
359
+
360
+ dimension_order = _determine_dimension_order(sequence)
361
+ if not dimension_order: # pragma: no cover
362
+ continue
363
+
364
+ size_info, channels = _extract_pixel_dimensions_and_channels(
365
+ sequence, position_frames, image_id
366
+ )
367
+ max_t, max_z, max_c = size_info
368
+
369
+ pixels = _build_pixels_object(
370
+ image_id,
371
+ dimension_order,
372
+ dimension_info,
373
+ max_t,
374
+ max_z,
375
+ max_c,
376
+ channels,
377
+ position_frames,
378
+ )
379
+
380
+ image = Image(
381
+ acquisition_date=acquisition_date,
382
+ id=f"Image:{image_id}",
383
+ name=position_name,
384
+ pixels=pixels,
385
+ )
386
+ images.append(image)
387
+ return images
388
+
389
+
390
+ def _build_pixels_object(
391
+ image_id: str,
392
+ dimension_order: Pixels_DimensionOrder,
393
+ dimension_info: _DimensionInfo,
394
+ max_t: int,
395
+ max_z: int,
396
+ max_c: int,
397
+ channels: list[Channel],
398
+ position_frames: list[FrameMetaV1],
399
+ ) -> Pixels:
400
+ """Build a Pixels object with the given parameters."""
401
+ return Pixels(
402
+ id=f"Pixels:{image_id}",
403
+ dimension_order=dimension_order,
404
+ size_x=dimension_info.width,
405
+ size_y=dimension_info.height,
406
+ size_z=max(max_z, 1),
407
+ size_c=max(max_c, 1),
408
+ size_t=max(max_t, 1),
409
+ type=PixelType(dimension_info.dtype),
410
+ physical_size_x=dimension_info.pixel_size_um,
411
+ physical_size_x_unit=UnitsLength.MICROMETER,
412
+ physical_size_y=dimension_info.pixel_size_um,
413
+ physical_size_y_unit=UnitsLength.MICROMETER,
414
+ channels=channels,
415
+ tiff_data_blocks=_build_tiff_data_list(position_frames),
416
+ planes=_build_plane_list(position_frames),
417
+ )
418
+
419
+
420
+ def _build_tiff_data_list(position_frames: list[FrameMetaV1]) -> list[TiffData]:
421
+ """Build TiffData objects for frame metadata at a specific position."""
422
+ tiff_data_blocks = []
423
+ for frame_metadata in position_frames:
424
+ mda_event = _extract_mda_event(frame_metadata)
425
+ if mda_event is None: # pragma: no cover
426
+ continue
427
+
428
+ event_index = mda_event.index
429
+ z_index = event_index.get("z", 0)
430
+ c_index = event_index.get("c", 0)
431
+ t_index = event_index.get("t", 0)
432
+
433
+ # Create a TiffData block for this plane
434
+ tiff_data = TiffData(
435
+ first_z=z_index,
436
+ first_c=c_index,
437
+ first_t=t_index,
438
+ plane_count=1,
439
+ )
440
+ tiff_data_blocks.append(tiff_data)
441
+
442
+ return tiff_data_blocks
443
+
444
+
445
+ def _build_plane_list(position_frames: list[FrameMetaV1]) -> list[Plane]:
446
+ """Build Plane objects for frame metadata at a specific position."""
447
+ planes = []
448
+ for frame_metadata in position_frames:
449
+ mda_event = _extract_mda_event(frame_metadata)
450
+ if mda_event is None: # pragma: no cover
451
+ continue
452
+
453
+ event_index = mda_event.index
454
+ z_index = event_index.get("z", 0)
455
+ c_index = event_index.get("c", 0)
456
+ t_index = event_index.get("t", 0)
457
+
458
+ runner_time_ms = frame_metadata.get("runner_time_ms", 0.0)
459
+ delta_t = runner_time_ms / 1000.0 if runner_time_ms > 0 else None
460
+ exposure_ms = frame_metadata.get("exposure_ms", 0.0)
461
+
462
+ plane = Plane(
463
+ the_z=z_index,
464
+ the_c=c_index,
465
+ the_t=t_index,
466
+ position_x=mda_event.x_pos,
467
+ position_x_unit=UnitsLength.MICROMETER,
468
+ position_y=mda_event.y_pos,
469
+ position_y_unit=UnitsLength.MICROMETER,
470
+ position_z=mda_event.z_pos,
471
+ position_z_unit=UnitsLength.MICROMETER,
472
+ delta_t=delta_t,
473
+ delta_t_unit=UnitsTime.SECOND,
474
+ exposure_time=exposure_ms,
475
+ exposure_time_unit=UnitsTime.MILLISECOND,
476
+ )
477
+
478
+ planes.append(plane)
479
+ return planes
480
+
481
+
482
+ def _build_ome_plate(
483
+ plate_plan: useq.WellPlatePlan, position_to_image_mapping: dict[int, str]
484
+ ) -> Plate:
485
+ """Create a Plate object from a useq.WellPlatePlan."""
486
+ wells: list[Well] = []
487
+
488
+ # create a mapping from well name to acquisition indices
489
+ well_acquisition_map: dict[str, list[int]] = {}
490
+ for acquisition_index, position in enumerate(plate_plan.image_positions):
491
+ if (position_name := position.name) is not None:
492
+ # Extract base well name by removing FOV suffix ("A1_0000" -> "A1")
493
+ # This handles cases where well_points_plan creates multiple FOVs per well
494
+ base_well_name = position_name.split("_")[0]
495
+
496
+ if base_well_name not in well_acquisition_map:
497
+ well_acquisition_map[base_well_name] = []
498
+
499
+ well_acquisition_map[base_well_name].append(acquisition_index)
500
+
501
+ for well_index, ((row, col), name, pos) in enumerate(
502
+ zip(
503
+ plate_plan.selected_well_indices,
504
+ plate_plan.selected_well_names,
505
+ plate_plan.selected_well_positions,
506
+ )
507
+ ):
508
+ # get all acquisition indices for this well
509
+ acquisition_indices = well_acquisition_map.get(name, [])
510
+
511
+ # create WellSample objects for each acquisition in this well
512
+ well_samples = []
513
+ for acq_index in acquisition_indices:
514
+ # Use the actual image ID from the mapping
515
+ image_id = position_to_image_mapping.get(acq_index, str(acq_index))
516
+ well_samples.append(
517
+ WellSample(
518
+ id=f"WellSample:{acq_index}",
519
+ position_x=pos.x,
520
+ position_y=pos.y,
521
+ position_x_unit=UnitsLength.MICROMETER,
522
+ position_y_unit=UnitsLength.MICROMETER,
523
+ index=acq_index,
524
+ image_ref=ImageRef(id=f"Image:{image_id}"),
525
+ )
526
+ )
527
+
528
+ wells.append(
529
+ Well(
530
+ id=f"Well:{well_index}",
531
+ row=row,
532
+ column=col,
533
+ well_samples=well_samples,
534
+ )
535
+ )
536
+
537
+ return Plate(
538
+ id="Plate:0",
539
+ name=plate_plan.plate.name,
540
+ rows=plate_plan.plate.rows,
541
+ columns=plate_plan.plate.columns,
542
+ wells=wells,
543
+ well_origin_x=plate_plan.a1_center_xy[0],
544
+ well_origin_x_unit=UnitsLength.MICROMETER,
545
+ well_origin_y=plate_plan.a1_center_xy[1],
546
+ well_origin_y_unit=UnitsLength.MICROMETER,
547
+ )
548
+
549
+
550
+ def _build_instrument_list(summary_metadata: SummaryMetaV1) -> list[Instrument]:
551
+ """Build instrument list from summary metadata."""
552
+ # TODO
553
+ return []
@@ -209,7 +209,8 @@ def image_infos(core: CMMCorePlus) -> tuple[ImageInfo, ...]:
209
209
  infos.append(image_info(core))
210
210
  finally:
211
211
  # set the camera back to the originally selected device
212
- core.setCameraDevice(selected)
212
+ with suppress(RuntimeError):
213
+ core.setCameraDevice(selected)
213
214
  return tuple(infos)
214
215
 
215
216
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pymmcore-plus
3
- Version: 0.15.4
3
+ Version: 0.17.0
4
4
  Summary: pymmcore superset providing improved APIs, event handling, and a pure python acquisition engine
5
5
  Project-URL: Source, https://github.com/pymmcore-plus/pymmcore-plus
6
6
  Project-URL: Tracker, https://github.com/pymmcore-plus/pymmcore-plus/issues
@@ -28,18 +28,19 @@ Requires-Python: >=3.9
28
28
  Requires-Dist: numpy>=1.25.2
29
29
  Requires-Dist: numpy>=1.26.0; python_version >= '3.12'
30
30
  Requires-Dist: numpy>=2.1.0; python_version >= '3.13'
31
+ Requires-Dist: ome-types>=0.6.0
31
32
  Requires-Dist: platformdirs>=3.0.0
32
33
  Requires-Dist: psygnal>=0.10
33
- Requires-Dist: pymmcore>=11.9.0.73.0
34
+ Requires-Dist: pymmcore>=11.10.0.74.0
34
35
  Requires-Dist: rich>=10.2.0
35
36
  Requires-Dist: tensorstore!=0.1.72,>=0.1.67
36
37
  Requires-Dist: tensorstore!=0.1.72,>=0.1.71; python_version >= '3.13'
37
- Requires-Dist: typer>=0.4.2
38
+ Requires-Dist: typer>=0.13.0
38
39
  Requires-Dist: typing-extensions>=4
39
40
  Requires-Dist: useq-schema>=0.7.2
40
41
  Provides-Extra: cli
41
42
  Requires-Dist: rich>=10.2.0; extra == 'cli'
42
- Requires-Dist: typer>=0.4.2; extra == 'cli'
43
+ Requires-Dist: typer>=0.13.0; extra == 'cli'
43
44
  Provides-Extra: io
44
45
  Requires-Dist: tifffile>=2021.6.14; extra == 'io'
45
46
  Requires-Dist: zarr<3,>=2.15; extra == 'io'
@@ -51,6 +52,8 @@ Provides-Extra: pyside2
51
52
  Requires-Dist: pyside2>=5.15.2.1; extra == 'pyside2'
52
53
  Provides-Extra: pyside6
53
54
  Requires-Dist: pyside6==6.7.3; extra == 'pyside6'
55
+ Provides-Extra: simulate
56
+ Requires-Dist: pillow>=11.0; extra == 'simulate'
54
57
  Description-Content-Type: text/markdown
55
58
 
56
59
  # pymmcore-plus