How to migrate from 0.15¶
Release 0.16 removes the two things 0.15 deprecated, changes what a view of plan progress receives, moves some axes to another box of the positioner, removes two helpers the positioner no longer uses, changes what a session does when a service does not start, stops a held key from running its command again and again, moves the test fixtures for Qt into a plugin of their own, and reports a stopped plan on a signal of its own. This page gives each change with what to write instead. The changelog lists everything else the release changed.
A view's placement can't come from a property¶
The session reads a view's placement before it builds the view, so the
placement has to be a plain class attribute or part of the declaration. A view
class that answers placement from a property, and whose declaration gives
none, is refused when the session reads its declarations: the view is left out
and the log says it has no placement. Set placement as a class attribute when
the place is always the same:
# 0.15
class MyView(QWidget):
@property
def placement(self) -> Placement:
return Dock("left")
# now
class MyView(QWidget):
placement: Placement = Dock("left")
When the place depends on the session, give it in the declaration instead, as How to place a view shows.
BUILD_STEPS is gone¶
redsun.session.BUILD_STEPS, the tuple of the steps a session's build goes
through, is removed. Use the enumeration
BuildStep: iterate it for the steps in order,
and take len(BuildStep) for their count.
Positioner boxes follow the device holding the axes¶
The positioner groups axes by the device that holds them directly. A device
whose axes are its own children, such as a stage with the attributes x and
y, keeps its box and its saved positions. A device whose axes sit one level
deeper sees them move. This stage holds its axes in motors:
class MyStage(StandardReadable):
def __init__(self, name: str = "") -> None:
self.motors = DeviceMap({"x": MyAxis(), "y": MyAxis()})
super().__init__(name=name)
In 0.15 its axes were rows of the box stage. Now they are rows of the box
stage-motors, titled stage / motors, so positions saved under stage no
longer match a box: the view leaves them out and logs a warning naming
stage. Move the stage to each position again and save it. To keep the box
stage instead, hold the axes as attributes of the stage, as in
self.x = MyAxis(). An include list naming stage changes the same way, to
stage-motors.
find_axes and walk_axes are gone¶
redsun.utils.devices.find_axes and walk_axes gathered every axis below one
device. To find the axes below a device, walk it with walk_devices from
ophyd-async and keep what is an Axis and not a
signal:
# 0.15
axes = find_axes(stage)
# now
axes = {
path: device
for path, device in walk_devices(stage).items()
if isinstance(device, Axis) and not isinstance(device, Signal)
}
Progress views receive the scopes¶
RunEngine.sig_progress carries the open progress scopes themselves, as
PlanProgress objects, so ProgressState is
gone. A scope gives its name, its parent scope, and in update the last
WatcherUpdate of ophyd-async it reported, or None before the first. The
update holds what the plan or the device reported and nothing more, so a view
that showed fraction computes it from current, initial and target when
the update has none. The scopes are the engine's own, and the engine replaces
their update after the emission, so a view reads it once for each emission.
A view that passes the scopes on to the plan widget only changes its
annotation to tuple[PlanProgress, ...]. A view that reads the values reads
them from the scope's update:
# 0.15
@slot
def on_progress(self, scopes: tuple[ProgressState, ...]) -> None:
for scope in scopes:
print(scope.name, scope.parent, scope.current)
# now
@slot
def on_progress(self, scopes: tuple[PlanProgress, ...]) -> None:
for scope in scopes:
parent = scope.parent.name if scope.parent is not None else None
current = scope.update.current if scope.update is not None else None
print(scope.name, parent, current)
A watcher passed to PlanProgress.watch is now typed as the Watcher of
ophyd-async, whose parameters can be passed by position or by name. A type
checker therefore refuses a function that takes only **kwargs, or whose
parameters are keyword-only; declare the parameters as plain named ones. A
fraction reported alone reaches the watcher alone, with current, initial
and target left None, and a device's status that reports no unit gives
unit as None instead of "unit".
A strict session stops when a service does not start¶
A strict session now stops when
any service fails to start, even one that no device names. In 0.15 such a
session ran on, and stopped only if a device on the service could not be
built. If the session declares a service it no longer uses, remove the
service from the session file. If a service may be missing while you work,
leave strict off for those runs:
A prefix that defaults to None is optional¶
A device declared with service="x" whose prefix argument defaults to None
is now built when the service does not start. The session leaves prefix out,
so the device gets None, and the build summary lists it under
Built without. In 0.15 the session skipped the device. To keep it skipped,
give prefix no default:
# 0.15: skipped when the service does not start
class MyCamera(StandardReadable):
def __init__(self, prefix: str | None = None, name: str = "") -> None: ...
# now, to keep it skipped
class MyCamera(StandardReadable):
def __init__(self, prefix: str, name: str = "") -> None: ...
A held key runs its command once¶
A keyboard shortcut now runs its method once per press. In 0.15 a held key
ran it again at the keyboard's repeat rate, so holding the key that starts a
plan started it several times. To keep a key repeating, such as one that
steps an axis, pass repeat=True to @shortcut:
# 0.15: repeats while held
class MyView(QWidget):
@shortcut("Right", title="Step forward", scope="view")
def step_forward(self) -> None: ...
# now
class MyView(QWidget):
@shortcut("Right", title="Step forward", scope="view", repeat=True)
def step_forward(self) -> None: ...
A stopped plan has a signal of its own¶
AcquisitionPresenter now reports a
plan the user stopped on sig_plan_stopped, and keeps sig_plan_done for a
plan that runs to its end. The built-in view shows the stop through its new
slot, set_stopped. A session that pairs the two components gets the new link
by itself. A session file that writes out the links under wiring adds the
new one, or the view stays locked after a stop:
# 0.15
wiring:
acquisition.sig_plan_done: acquisition_view.set_done
# now
wiring:
acquisition.sig_plan_done: acquisition_view.set_done
acquisition.sig_plan_stopped: acquisition_view.set_stopped
A component of your own that connects to sig_plan_done to learn that a run
is over connects to sig_plan_stopped too.
The Qt test fixtures are a plugin of their own¶
keep_application_as_found,
the fixture that fails a test leaving the application changed, moved from
redsun.testing to redsun.testing.qt, and so did the setting that gives
Qt's offscreen platform the system fonts on Windows. That module is a second pytest
plugin, installed with the testing-qt extra, so a suite that tests no Qt
components never imports Qt. A suite that tests Qt components changes its
install line and its addopts:
# 0.15
uv add --dev "redsun[testing]" pytest-asyncio
# now
uv add --dev "redsun[testing-qt]" pytest-asyncio
The extra installs no Qt binding, so the suite keeps the one it installs
today. redsun.testing.qt also defines a qapp fixture, and a suite that
takes pytest-qt's or defines its own can keep it.
How to test a plugin says what happens
when pytest-qt is loaded too.