Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,14 @@ repos:
- types-PyYAML
- pydantic >=2
- numpy >=2

- repo: local
hooks:
- id: pyright
stages: [manual]
name: pyright
language: system
types_or: [python, pyi]
require_serial: true
files: "src"
entry: uv run pyright
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ dev = [
"rich>=14.0.0",
"ruff>=0.11.9",
"types-pyyaml>=6.0.12.20250402",
"pyright>=1.1.401",
]
docs = [
"mkdocs >=1.4",
Expand Down Expand Up @@ -89,6 +90,8 @@ packages = ["src/useq"]
line-length = 88
target-version = "py39"
src = ["src", "tests"]
fix = true
unsafe-fixes = true

[tool.ruff.lint]
pydocstyle = { convention = "numpy" }
Expand Down
6 changes: 5 additions & 1 deletion src/useq/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Implementation agnostic schema for multi-dimensional microscopy experiments."""

import warnings
from typing import Any
from typing import TYPE_CHECKING, Any

from useq._actions import AcquireImage, Action, CustomAction, HardwareAutofocus
from useq._channel import Channel
Expand Down Expand Up @@ -39,6 +39,10 @@
ZTopBottom,
)

if TYPE_CHECKING:
from useq._grid import GridRelative


__all__ = [
"AbsolutePosition",
"AcquireImage",
Expand Down
6 changes: 3 additions & 3 deletions src/useq/_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ class AcquireImage(Action):
This action can be used to acquire an image.
"""

type: Literal["acquire_image"] = "acquire_image"
type: Literal["acquire_image"] = "acquire_image" # pyright: ignore[reportIncompatibleVariableOverride]


class HardwareAutofocus(Action):
Expand All @@ -62,7 +62,7 @@ class HardwareAutofocus(Action):
The number of retries if autofocus fails. By default, 3.
"""

type: Literal["hardware_autofocus"] = "hardware_autofocus"
type: Literal["hardware_autofocus"] = "hardware_autofocus" # pyright: ignore[reportIncompatibleVariableOverride]
autofocus_device_name: Optional[str] = None
autofocus_motor_offset: Optional[float] = None
max_retries: int = 3
Expand All @@ -89,7 +89,7 @@ class CustomAction(Action):
Custom data associated with the action.
"""

type: Literal["custom"] = "custom"
type: Literal["custom"] = "custom" # pyright: ignore[reportIncompatibleVariableOverride]
name: str = ""
data: dict = Field(default_factory=dict)

Expand Down
11 changes: 9 additions & 2 deletions src/useq/_channel.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import Optional
from typing import Any, Optional

from pydantic import Field
from pydantic import Field, model_validator

from useq._base_model import FrozenModel

Expand Down Expand Up @@ -38,3 +38,10 @@ class Channel(FrozenModel):
z_offset: float = 0.0
acquire_every: int = Field(default=1, gt=0) # acquire every n frames
camera: Optional[str] = None

@model_validator(mode="before")
@classmethod
def _cast(cls, value: Any) -> Any:
if isinstance(value, str):
value = {"config": value}
return value
10 changes: 5 additions & 5 deletions src/useq/_grid.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,15 @@ class _GridPlan(_MultiPointPlan[PositionT]):
Engines MAY override this even if provided.
"""

overlap: tuple[float, float] = Field((0.0, 0.0), frozen=True)
mode: OrderMode = Field(OrderMode.row_wise_snake, frozen=True)
overlap: tuple[float, float] = Field(default=(0.0, 0.0), frozen=True)
mode: OrderMode = Field(default=OrderMode.row_wise_snake, frozen=True)

@field_validator("overlap", mode="before")
def _validate_overlap(cls, v: Any) -> tuple[float, float]:
with contextlib.suppress(TypeError, ValueError):
v = float(v)
if isinstance(v, float):
return (v,) * 2
return (v, v)
if isinstance(v, Sequence) and len(v) == 2:
return float(v[0]), float(v[1])
raise ValueError( # pragma: no cover
Expand Down Expand Up @@ -288,7 +288,7 @@ class GridRowsColumns(_GridPlan[RelativePosition]):
# everything but fov_width and fov_height is immutable
rows: int = Field(..., frozen=True, ge=1)
columns: int = Field(..., frozen=True, ge=1)
relative_to: RelativeTo = Field(RelativeTo.center, frozen=True)
relative_to: RelativeTo = Field(default=RelativeTo.center, frozen=True)

def _nrows(self, dy: float) -> int:
return self.rows
Expand Down Expand Up @@ -346,7 +346,7 @@ class GridWidthHeight(_GridPlan[RelativePosition]):

width: float = Field(..., frozen=True, gt=0)
height: float = Field(..., frozen=True, gt=0)
relative_to: RelativeTo = Field(RelativeTo.center, frozen=True)
relative_to: RelativeTo = Field(default=RelativeTo.center, frozen=True)

def _nrows(self, dy: float) -> int:
return math.ceil(self.height / dy)
Expand Down
2 changes: 1 addition & 1 deletion src/useq/_iter_sequence.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ def _iter_sequence(
# determine any relative position shifts or global overrides
_pos, _offsets = _position_offsets(position, event_kwargs)
# build overrides for this position
pos_overrides = MDAEventDict(sequence=sequence, **_pos)
pos_overrides = MDAEventDict(sequence=sequence, **_pos) # pyright: ignore[reportCallIssue]
pos_overrides["reset_event_timer"] = False
if position.name:
pos_overrides["pos_name"] = position.name
Expand Down
2 changes: 1 addition & 1 deletion src/useq/_plot.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ def plot_plate(
for well in plate_plan.selected_well_positions:
x, y = float(well.x), float(well.y) # type: ignore[arg-type]
# draw name next to spot
ax.text(x + offset_x, y - offset_y, well.name, fontsize=7)
ax.text(x + offset_x, y - offset_y, well.name or "", fontsize=7)

ax.axis("equal")
if show:
Expand Down
25 changes: 20 additions & 5 deletions src/useq/_position.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from collections.abc import Iterator
from typing import TYPE_CHECKING, Generic, Optional, SupportsIndex, TypeVar
from typing import TYPE_CHECKING, Any, Generic, Optional, SupportsIndex, TypeVar

from pydantic import Field
import numpy as np
from pydantic import Field, model_validator

from useq._base_model import FrozenModel, MutableModel

Expand Down Expand Up @@ -73,6 +74,20 @@ def __round__(self, ndigits: "SupportsIndex | None" = None) -> "Self":
# not sure why these Self types are not working
return type(self).model_construct(**kwargs) # type: ignore [return-value]

@model_validator(mode="before")
@classmethod
def _cast(cls, value: Any) -> Any:
if isinstance(value, (np.ndarray, tuple)):
x = y = z = None
if len(value) > 0:
x = value[0]
if len(value) > 1:
y = value[1]
if len(value) > 2:
z = value[2]
value = {"x": x, "y": y, "z": z}
return value


class AbsolutePosition(PositionBase, FrozenModel):
"""An absolute position in 3D space."""
Expand Down Expand Up @@ -120,9 +135,9 @@ class RelativePosition(PositionBase, _MultiPointPlan["RelativePosition"]):
be used to define a single field of view for a "multi-point" plan.
"""

x: float = 0
y: float = 0
z: float = 0
x: float = 0 # pyright: ignore[reportIncompatibleVariableOverride]
y: float = 0 # pyright: ignore[reportIncompatibleVariableOverride]
z: float = 0 # pyright: ignore[reportIncompatibleVariableOverride]

def __iter__(self) -> Iterator["RelativePosition"]: # type: ignore [override]
yield self
Expand Down
15 changes: 12 additions & 3 deletions src/useq/_time.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from collections.abc import Iterator, Sequence
from datetime import timedelta
from typing import Annotated, Union
from typing import Annotated, Any, Union

from pydantic import BeforeValidator, Field, PlainSerializer
from pydantic import BeforeValidator, Field, PlainSerializer, model_validator

from useq._base_model import FrozenModel

Expand Down Expand Up @@ -122,16 +122,25 @@ def deltas(self) -> Iterator[timedelta]:
accum = timedelta(0)
yield accum
for phase in self.phases:
td = None
for i, td in enumerate(phase.deltas()):
# skip the first timepoint of later phases
if i == 0 and td == timedelta(0):
continue
yield td + accum
accum += td
if td is not None:
accum += td

def num_timepoints(self) -> int:
# TODO: is this correct?
return sum(phase.loops for phase in self.phases) - 1

@model_validator(mode="before")
@classmethod
def _cast(cls, value: Any) -> Any:
if isinstance(value, Sequence) and not isinstance(value, str):
value = {"phases": value}
return value


AnyTimePlan = Union[MultiPhaseTimePlan, SinglePhaseTimePlan]
4 changes: 3 additions & 1 deletion src/useq/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from enum import Enum
from typing import TYPE_CHECKING, NamedTuple

from useq._time import MultiPhaseTimePlan

if TYPE_CHECKING:
from typing import Final, Literal, TypeVar

Expand Down Expand Up @@ -152,7 +154,7 @@ def _estimate_simple_sequence_duration(seq: useq.MDASequence) -> TimeEstimate:

t_interval_exceeded = False
if tplan := seq.time_plan:
phases = tplan.phases if hasattr(tplan, "phases") else [tplan]
phases = tplan.phases if isinstance(tplan, MultiPhaseTimePlan) else [tplan]
tot_duration = 0.0
for phase in phases:
phase_duration, exceeded = _time_phase_duration(phase, s_per_timepoint)
Expand Down
1 change: 1 addition & 0 deletions src/useq/experimental/protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@

def __get__(self, instance: Any | None, owner: Any) -> PSignalInstance:
"""Returns the signal instance for this descriptor."""
...

Check warning on line 119 in src/useq/experimental/protocols.py

View check run for this annotation

Codecov / codecov/patch

src/useq/experimental/protocols.py#L119

Added line #L119 was not covered by tests


PSignal: TypeAlias = Union[PSignalDescriptor, PSignalInstance]
Expand Down
2 changes: 1 addition & 1 deletion src/useq/pycromanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ def _event_to_pycromanager(event: MDAEvent) -> PycroManagerEvent:

for axis in event.index.keys():
if axis in _USEQ_AXIS_TO_PYCRO:
pycro["axes"][_USEQ_AXIS_TO_PYCRO[axis]] = event.index[axis]
pycro["axes"][_USEQ_AXIS_TO_PYCRO[axis]] = event.index[axis] # pyright: ignore

for useq_name, pycro_name in _USEQ_KEY_TO_PYCRO.items():
if (val := getattr(event, useq_name)) is not None:
Expand Down
127 changes: 0 additions & 127 deletions tests/test_autofocus.py

This file was deleted.

Loading