v2 Migration Guide#
Overview#
The v2 version of useq-schema represents a fundamental architectural redesign
that generalizes the multi-dimensional axis iteration pattern to support
arbitrary dimensions while preserving the complex event building, nesting, and
skipping capabilities of the original implementation. This document explains the
new features, how to use and extend them, and the breaking changes from v1.
Key Architectural Changes#
From Fixed Axes to Extensible Axis System#
v1 Approach: Hard-coded support for specific axes (time, position,
grid, channel, z) with bespoke iteration logic in _iter_sequence.py.
v2 Approach: Generic, protocol-based system where any object implementing
AxisIterable can participate in multi-dimensional iteration.
Core Concepts#
1. AxisIterable[V] Protocol#
The foundation of v2 is the AxisIterable protocol, which defines how any axis
should behave. In short, an AxisIterable is an object that yields values (of
any type), has an associated axis_key, and can contribute to event building
and skipping logic.
from pydantic import BaseModel, Field
from typing import Generic, Iterator, Mapping, TypeVar
from abc import abstractmethod
V = TypeVar("V")
class AxisIterable(BaseModel, Generic[V]):
axis_key: str # Unique identifier for this axis
@abstractmethod
def __iter__(self) -> Iterator[V]:
"""Iterate over axis values"""
def should_skip(self, prefix: AxesIndex) -> bool:
"""Return True to skip this combination"""
return False
def contribute_to_mda_event(
self, value: V, index: Mapping[str, int]
) -> MDAEvent.Kwargs:
"""Contribute data to the event being built"""
return {}
2. SimpleValueAxis[V] - Basic Implementation#
For simple cases where you just want to iterate over a list of values:
class SimpleValueAxis(AxisIterable[V]):
values: list[V] = Field(default_factory=list)
def __iter__(self) -> Iterator[V | MultiAxisSequence]:
yield from self.values
axis = SimpleValueAxis(axis_key="z", values=[0, 1, 2, 3, 4])
for z in axis:
print(z) # Outputs 0, 1, 2, 3, 4
3. MultiAxisSequence[EventT] - The New Sequence Container#
Replaces the old MDASequence as the core container, but with generic event
support. A MultiAxisSequence holds any number of AxisIterable objects,
defines their order, and manages how the values from each axis get merged into
an event.
EventT = TypeVar("EventT")
class MultiAxisSequence(BaseModel, Generic[EventT]):
axes: tuple[AxisIterable, ...] = ()
axis_order: Optional[tuple[str, ...]] = None
value: Any = None # Used when this sequence is nested
event_builder: Optional[EventBuilder[EventT]] = None
transforms: tuple[EventTransform, ...] = ()
4. MDASequence#
There is still an MDASequence class in v2, which is a subclass of
MultiAxisSequence specialized for building MDAEvent objects.
from useq.v2 import MDAEvent
class MDASequence(MultiAxisSequence[MDAEvent]):
...
In other words, MultiAxisSequence is a generic iterator over multiple
axes that can build any type of event, while MDASequence is a specific
implementation that builds MDAEvent objects (just like v1).
New Features#
1. Arbitrary Custom Axes#
You can now define completely custom axes for any dimension:
from useq import v2
# Custom axis for laser power
class LaserPowerAxis(v2.SimpleValueAxis[float]):
axis_key: str = "laser_power"
def contribute_to_mda_event(self, value: float, index: Mapping[str, int]) -> v2.MDAEvent.Kwargs:
return {"metadata": {"laser_power": value}}
# Custom axis for temperature
class TemperatureAxis(v2.AxisIterable[float]):
axis_key: str = "temperature"
min_temp: float
max_temp: float
step: float
def __iter__(self) -> Iterator[float]:
temp = self.min_temp
while temp <= self.max_temp:
yield temp
temp += self.step
def contribute_to_mda_event(self, value: float, index: Mapping[str, int]) -> v2.MDAEvent.Kwargs:
return {"metadata": {"temperature": value}}
2. Conditional Skipping with should_skip#
should_skip method on any axis allows context-aware skipping of specific
combinations. It receives an AxesIndex, which contains information on
the exact value and index being yielded by each axis in this iteration step.
class FilteredChannelAxis(v2.SimpleValueAxis[v2.Channel]):
def should_skip(self, prefix: v2.AxesIndex) -> bool:
# Skip FITC channel for even numbered Z positions
z_idx = prefix.get("z", (None, None, None))[0]
current_channel = prefix.get("c", (None, None, None))[1]
if z_idx is not None and z_idx % 2 == 0:
return current_channel.config == "FITC"
return False
3. Hierarchical Nested Sequences#
The new system supports arbitrarily nested sequences that can override or extend
parent axes. A MultiAxisSequence itself can have a value, allowing it to
be used as a yielded value from a parent axis.
In the following example, we define a sub-sequence for a specific position that adds a temperature axis and overrides the Z plan defined in the parent sequence:
from useq import v2
# Position with custom sub-sequence (uses MDASequence, which StagePositions accepts)
sub_sequence = v2.MDASequence(
value=v2.Position(x=10, y=20), # The value represents this position
axes=(
# Add temperature dimension
v2.SimpleValueAxis(axis_key="temperature", values=[20, 25, 30]),
# Override parent Z plan
v2.ZRangeAround(range=2, step=0.5),
),
axis_order=("temperature", "z")
)
main_sequence = v2.MDASequence(
axes=(
v2.TIntervalLoops(interval=1.0, loops=5),
v2.StagePositions(values=[sub_sequence, v2.Position(x=0, y=0)]),
v2.ZRangeAround(range=4, step=1.0), # This gets overridden for the first position
)
)
4. Event Transform Pipeline#
Transforms allow you to modify events after they are built but before they are yielded. This replaces the old hardcoded event modification logic with a flexible, composable pipeline:
from useq.v2 import KeepShutterOpenTransform, EventTransform
class CustomTransform(EventTransform[MDAEvent]):
def __call__(
self,
event: MDAEvent,
*,
prev_event: MDAEvent | None,
make_next_event: Callable[[], MDAEvent | None],
) -> Iterable[MDAEvent]:
# Modify event
if event.index.get("c") == 0: # First channel
event = event.model_copy(update={"exposure": 100})
# Can return multiple events, no events, or modify the event
return [event]
seq = v2.MDASequence(
channels=["DAPI", "FITC"], # Using legacy API for brevity
transforms=(CustomTransform(), KeepShutterOpenTransform(("z",)))
)
4.1 Built-in Transforms#
v2 provides several built-in transforms that replicate v1 behavior.
Note: transforms are currently available from useq.v2._transformers:
from useq.v2 import (
AutoFocusTransform,
KeepShutterOpenTransform,
ResetEventTimerTransform,
)
# Shutter management - keeps shutter open across specified axes
KeepShutterOpenTransform(("z", "c"))
# Event timing - marks first frame of each timepoint for timer reset
ResetEventTimerTransform()
4.2 Non-Imaging Events with Transforms#
A key innovation in v2 is the ability to use transforms to insert non-imaging events that don't contribute to the sequence shape. This addresses GitHub issue #41 for use cases like laser measurements and Raman spectroscopy:
class LaserMeasurementTransform(EventTransform[MDAEvent]):
"""Insert laser measurement events after BF z-stacks."""
def __call__(
self,
event: MDAEvent,
*,
prev_event: MDAEvent | None,
make_next_event: Callable[[], MDAEvent | None],
) -> Iterable[MDAEvent]:
# Yield the original imaging event
yield event
# If this is the last event in a BF z-stack, add laser measurements
if (event.channel and event.channel.config == "BF" and
self._is_last_z_event(event, make_next_event)):
# Insert 5 laser measurement events at different points
for i, (x_offset, y_offset) in enumerate([(0, 0), (10, 0), (0, 10), (-10, 0), (0, -10)]):
laser_event = MDAEvent(
index={"t": event.index.get("t", 0), "laser": i},
x_pos=(event.x_pos or 0) + x_offset,
y_pos=(event.y_pos or 0) + y_offset,
action=CustomAction(type="laser_measurement", data={"laser_power": 75})
)
yield laser_event
def _is_last_z_event(self, event: MDAEvent, make_next_event: Callable) -> bool:
next_event = make_next_event()
return (next_event is None or
next_event.channel is None or
next_event.channel.config != "BF")
# Usage for the GitHub issue #41 use case:
# 1. Collect BF z-stack → 2. Laser measurements → 3. GFP z-stack
seq = v2.MDASequence(
channels=["BF", "GFP"],
z_plan=v2.ZRangeAround(range=2, step=0.5),
transforms=(LaserMeasurementTransform(),)
)
# This generates:
# - BF z-stack events (contribute to shape)
# - 5 laser measurement events (inserted by transform, don't affect shape)
# - GFP z-stack events (contribute to shape)
5. Pluggable Event Builders#
Customize how raw axis data gets converted into events:
class MyCustomEvent: ...
class CustomEventBuilder(v2.EventBuilder[MyCustomEvent]):
def __call__(
self, axes_index: v2.AxesIndex, context: tuple[v2.MultiAxisSequence, ...]
) -> MyCustomEvent:
# Build your custom event type
return MyCustomEvent(...)
seq = v2.MultiAxisSequence(
axes=(),
event_builder=CustomEventBuilder()
)
6. Infinite Axes Support#
Unlike v1, v2 supports infinite sequences:
class InfiniteTimeAxis(v2.AxisIterable[float]):
axis_key: str = "t"
interval: float = 1.0
def __iter__(self) -> Iterator[float]:
time = 0.0
while True:
yield time
time += self.interval
Migration from v1 to v2#
Backward Compatibility#
v2 MDASequence accepts the same constructor parameters as v1 through automatic
conversion:
# This v1 style still works
seq = v2.MDASequence(
time_plan={"interval": 1.0, "loops": 5},
z_plan={"range": 4, "step": 1},
channels=["DAPI", "FITC"],
stage_positions=[(10, 20, 5)],
)
# Internally converted to:
seq2 = v2.MDASequence(
axes=(
v2.TIntervalLoops(interval=1.0, loops=5),
v2.StagePositions(values=[v2.Position(x=10, y=20, z=5)]),
v2.ChannelsPlan(values=[v2.Channel(config="DAPI"), v2.Channel(config="FITC")]),
v2.ZRangeAround(range=4, step=1),
),
)
assert list(seq) == list(seq2)
Breaking Changes#
1. Event Building Architecture#
v1: Monolithic _iter_sequence function with hardcoded event building
logic.
v2: Separation of concerns:
- Axis iteration handled by
iterate_multi_dim_sequence - Event building handled by
EventBuilder - Event modification handled by
EventTransformpipeline
2. Shape and Sizes Properties#
from useq import MDASequence
from useq import v2
# v1
seq = MDASequence()
seq.shape # Returns tuple of sizes
seq.sizes # Returns mapping of axis -> size
# v2 - DEPRECATED
seq2 = v2.MDASequence()
seq2.shape # Deprecated - raises FutureWarning
seq2.sizes # Deprecated - raises FutureWarning
# v2 - New approach
[len(axis) for axis in seq2.axes] # Get size per axis
seq2.is_finite() # Check if sequence is finite
3. Axis Access#
# v1
seq.time_plan
seq.z_plan
seq.channels
seq.stage_positions
seq.grid_plan
# v2 - Legacy properties still work but deprecated
seq2.time_plan # Returns the time axis or None
seq2.z_plan # Returns the z axis or None
# v2 - New approach
time_axis = next((ax for ax in seq2.axes if ax.axis_key == "t"), None)
z_axis = next((ax for ax in seq2.axes if ax.axis_key == "z"), None)
# each of which have convenience methods:
time_axis = seq2.time_plan
z_axis = seq2.z_plan
4. Custom Skip Logic#
v1: Hardcoded in _should_skip function within _iter_sequence.py
v2: Implemented per-axis via should_skip method:
class CustomZAxis(v2.ZRangeAround):
def should_skip(self, prefix: AxesIndex) -> bool:
# Custom logic here
return super().should_skip(prefix)
Z. Z-Plans yield Positions, not floats#
v1: Z plans yielded floats representing Z positions.
v2: Z plans yield Position objects that (usually) include only z
coordinates:
Built-in Axes in v2#
All the original v1 plans are now AxisIterable implementations:
Time Axes#
TIntervalLoopsTIntervalDurationTDurationLoopsMultiPhaseTimePlan
Z Axes#
ZRangeAroundZTopBottomZAboveBelowZAbsolutePositionsZRelativePositions
Channel Axes#
ChannelsPlan(wraps list ofChannelobjects)
Position Axes#
StagePositions(wraps list ofPositionobjects)
Grid Axes#
GridRowsColumnsGridFromEdgesGridWidthHeightRandomPoints
Extension Examples#
Creating a Custom Scientific Axis#
class PHAxis(v2.AxisIterable[float]):
"""Axis for pH titration experiments."""
axis_key: str = "ph"
start_ph: float = 6.0
end_ph: float = 8.0
steps: int = 10
def __iter__(self) -> Iterator[float]:
step_size = (self.end_ph - self.start_ph) / (self.steps - 1)
for i in range(self.steps):
yield self.start_ph + i * step_size
def contribute_to_mda_event(self, value: float, index: Mapping[str, int]) -> MDAEvent.Kwargs:
return {
"metadata": {"ph": value},
"properties": [("pH_Controller", "target_ph", value)]
}
def should_skip(self, prefix: AxesIndex) -> bool:
# Skip pH 7.5+ for channel index > 2
channel_idx = prefix.get("c", (None, None, None))[0]
ph_value = prefix.get("ph", (None, None, None))[1]
return channel_idx is not None and channel_idx > 2 and ph_value >= 7.5
Complex Nested Workflow#
from useq import v2
# Different regions with different imaging parameters
region1 = v2.MDASequence(
value=v2.Position(x=0, y=0, name="Region1"),
axes=(
v2.ZRangeAround(range=10, step=0.2), # High-res Z
v2.ChannelsPlan(values=["DAPI", "FITC", "Cy3"]), # 3 channels
)
)
region2 = v2.MDASequence(
value=v2.Position(x=100, y=100, name="Region2"),
axes=(
v2.ZRangeAround(range=20, step=0.5), # Lower-res Z
v2.ChannelsPlan(values=["DAPI", "Cy5"]), # Only 2 channels
PHAxis(start_ph=6.5, end_ph=7.5, steps=5), # pH titration
)
)
class CustomTransform:
def __call__(
self,
event: v2.MDAEvent,
*,
prev_event: v2.MDAEvent | None,
make_next_event: Callable[[], v2.MDAEvent | None],
) -> Iterable[v2.MDAEvent]:
# possibly modify event... based on conditions
yield event
main_seq = v2.MDASequence(
axes=(
v2.TIntervalLoops(interval=60, loops=10), # Every minute for 10 minutes
v2.StagePositions(values=[region1, region2]),
),
transforms=(
CustomTransform(),
v2.KeepShutterOpenTransform(("z", "c")), # Keep shutter open for Z and C
)
)
Performance and Design Benefits#
Separation of Concerns#
- Axis logic: Isolated in individual
AxisIterableimplementations - Event building: Centralized in
EventBuilder - Event modification: Composable
EventTransformpipeline
Extensibility#
- Add new dimensions without modifying core code
- Custom skip logic per axis
- Pluggable event builders for different event types
- Composable transform pipeline
Type Safety#
- Generic types ensure type safety across the pipeline
- Protocol-based design enables duck typing
- Clear interfaces for each component
Maintainability#
- Individual axis implementations are easier to test and debug
- Transform pipeline is easier to reason about than monolithic logic
- Clear separation between axis iteration and event building
Summary#
useq-schema v2 transforms the library from a fixed-axis system to a fully extensible, protocol-based architecture that supports:
- Arbitrary custom axes with their own iteration and contribution logic
- Conditional skipping per axis with full context awareness
- Hierarchical nesting with axis override capabilities
- Composable transforms for event modification
- Pluggable event builders for different event types
- Type-safe extensibility through generic protocols
While maintaining full backward compatibility with v1 API patterns, v2 opens up useq-schema for complex, multi-dimensional experimental workflows that were impossible to express in the original architecture.