Plans

Plan authoring surfaces: the plan registry, parameter introspection, the Annotated UI vocabulary that drives automatic form generation, user plan files, and embedded plan UIs.

from lightfall.acquire.plans import PlanRegistry, PlanInfo, ParameterInfo, get_registry
from lightfall.ui.annotations import Unit, Decimals, Range, DeviceFilter
from lightfall.acquire.plan_ui import PlanUI, PlanState, plan_with_ui

How plans get registered

Route

Mechanism

User plan files

Drop a .py file in ~/lightfall/plans/; UserPlanService loads it, watches it, and registers it under the "user" category. The primary route for beamline-local plans.

Programmatic

PlanRegistry.register(name, func, category) or the @registry.register_decorator(...) decorator.

PlanPlugin

The PlanPlugin class exists (see Plugin Types), but the "plan" manifest type is not registered in the current startup sequence, so manifest entries of this type are skipped.

The default registry does not register raw Bluesky builtins (bp.scan, bp.grid_scan, …) — their *args signatures cannot generate useful forms. Instead, typed wrapper plans in lightfall.acquire.plans.lightfall_plans (e.g. scan_1d, rel_scan_1d) provide the same functionality with annotated signatures. Raw bp.* plans remain accessible through the agent’s plan-code tool and the IPython console.

User plans (~/lightfall/plans/)

Each file in ~/lightfall/plans/ defines one plan: a module-level variable named plan that is a generator function yielding Bluesky messages. The filename (without .py) becomes the plan name; files starting with _ are skipped.

# ~/lightfall/plans/my_scan.py
import bluesky.plans as bp

def plan(detectors: list, motor, start: float = -10.0,
         stop: float = 10.0, num: int = 21):
    """Scan a motor while reading detectors."""
    yield from bp.scan(detectors, motor, start, stop, num)

UserPlanService (singleton, lightfall.acquire.plans.UserPlanService):

Member

Description

get_instance()

Singleton accessor.

load_all_plans()

Load every *.py in the plans directory; returns (path, PlanInfo or Exception) tuples.

load_plan_from_file(path, commit_msg=None)

Load one file; registers (or replaces) the plan.

create_new_plan(name, description="", commit_msg=None)

Create a file from the built-in template and load it. name must be a valid Python identifier.

refresh_plans()

Unload everything and reload from disk.

get_plans_directory() / open_plans_folder()

Path helpers.

Signals

plan_loaded(PlanInfo), plan_unloaded(name), plan_error(path, message), plans_refreshed().

The service watches the directory with a QFileSystemWatcher: edits reload the plan in place (register_or_replace), deletions unregister it. Every change — including failed loads — is auto-committed to a local git repository via GitTracker, so plan history is preserved.

PlanRegistry

lightfall.acquire.plans.registry.PlanRegistry — the central catalog used by the Plans panel and the agent. Singleton via PlanRegistry.get_instance() (or the module-level get_registry()).

Member

Description

register(name, func, category="general")

Register a plan; raises ValueError on duplicate names. Returns the created PlanInfo.

register_decorator(name=None, category="general")

Decorator form; name defaults to the function name.

register_or_replace(name, func, category="general")

Replace-if-exists variant (used by hot-reload).

unregister(name)

Remove a plan; returns True if it existed.

get_plan(name)

PlanInfo or None.

list_plans(category=None)

All plans, optionally filtered by category.

get_categories()

Sorted category names.

search(query)

Case-insensitive match against name and description.

plan_names

Property: registered names. name in registry and len(registry) also work.

PlanInfo and ParameterInfo

PlanInfo.from_function(name, func, category) builds plan metadata by introspection: it captures the signature, takes the first docstring paragraph as the description, parses per-parameter descriptions from Google- or NumPy-style docstrings, and extracts >>> examples.

PlanInfo fields: name, func, signature, description, category, parameters (list of ParameterInfo), examples, display_name (generated from name via name_to_display_name() when unset), and icon (a (color, letter) tuple; defaults come from PLAN_CATEGORY_ICONS by category). Helpers: get_display_name(), get_icon(), get_required_params(), get_optional_params().

ParameterInfo fields: name, annotation, default, kind, description, plus derived required (no default) and type_name (display-friendly type).

Annotated UI vocabulary

The parameter editor reads typing.Annotated metadata from plan signatures to render appropriate inputs. The vocabulary lives in lightfall.ui.annotations (all are frozen dataclasses):

Annotation

Effect

Unit(suffix)

Display a unit suffix next to a numeric input (e.g. "eV", "s", "mm").

Decimals(places)

Decimal precision of a float spinbox.

Range(min=None, max=None)

Min/max bounds for a numeric input.

Default(value)

Default that overrides the signature default.

DeviceFilter(device_class=None, category=None, group=None, source=None, name_pattern=None)

Restrict a device-selector parameter; criteria AND together.

DeviceFilterAny(*filters)

OR-combination of DeviceFilters (e.g. motors OR positioners).

DeviceDefault(*names, pattern=None)

Pre-select devices by name or regex.

DeviceIcon(name)

qtawesome icon for the device-selector button (mdi6. prepended when no prefix is given).

from typing import Annotated
from lightfall.ui.annotations import Unit, Range, DeviceFilter

def plan(
    detectors: Annotated[list, DeviceFilter(category="detector")],
    motor: Annotated[object, DeviceFilter(category="motor")],
    start: Annotated[float, Unit("mm")] = -10.0,
    stop: Annotated[float, Unit("mm")] = 10.0,
    num: Annotated[int, Range(min=1)] = 21,
):
    ...

This is the same vocabulary the built-in typed wrapper plans use, so user plans annotated this way get identical form generation.

🖼️ Image placeholderScreenshot: the plan configuration form generated from an annotated signature — numeric spinboxes with unit suffixes and a filtered device-selector button.

Embedded plan UIs (plan_with_ui)

Plans can ship a runtime UI widget shown as a tab in the Plans panel while the plan runs (lightfall.acquire.plan_ui):

  • PlanUIQWidget base for the widget; build the UI in __init__ using self._layout.

  • PlanStateQObject base for shared state between the running plan and its UI. Defines status_changed = Signal(str) and the stop_requested / pause_requested flags; subclasses add their own signals and attributes. The plan module keeps a module-level instance that both the plan function and the widget reference directly.

  • @plan_with_ui(UIClass) — decorator that attaches a PlanUI subclass to a plan function (stored as the _plan_ui_class attribute). The Plans panel instantiates the UI when the plan is submitted.

  • get_plan_ui_class(plan_func) — returns the attached class or None.

from lightfall.acquire.plan_ui import PlanUI, plan_with_ui

class MyPlanUI(PlanUI):
    ...

@plan_with_ui(MyPlanUI)
def plan(detectors):
    yield from bps.count(detectors)

Qt signals are thread-safe across threads; plans should reset their state object explicitly at the start of each run.

Class reference

Type annotation metadata for procedural UI generation.

Provides dataclasses that can be used with typing.Annotated to add UI hints for plan parameters. These annotations control how the parameter editor renders inputs for plan functions.

Usage:

from typing import Annotated
from lightfall.ui.annotations import Unit, Decimals, Range, DeviceFilter

def scan(
    energy: Annotated[float, Unit("eV"), Range(0, 10000)],
    num_points: Annotated[int, Range(1, 1000)],
    motor: Annotated[Device, DeviceFilter(device_class="EpicsMotor")],
): ...
class lightfall.ui.annotations.Decimals(places: int)[source]

Number of decimal places for float display.

Controls the precision of the spinbox widget for float parameters.

Parameters:
places: int

Number of decimal places (e.g., 4 for 0.0001 precision).

Example:

step_size: Annotated[float, Decimals(4)] = 0.001
class lightfall.ui.annotations.Default(value: Any)[source]

Default value for parameter.

Provides a default value that overrides the function signature default. Useful when the annotation-based default differs from the code default.

Parameters:
value: Any

The default value for this parameter.

Example:

exposure: Annotated[float, Default(1.0), Unit("s")]
class lightfall.ui.annotations.DeviceDefault(*names: str, pattern: str | None = None)[source]

Default device selection by name or pattern.

Pre-selects devices in the device selector based on explicit names or a regex pattern match.

Parameters:
*names: str

Device names to pre-select.

pattern: str | None = None

Regex pattern to match device names for pre-selection.

Example:

# Pre-select specific detector
detector: Annotated[list[Detector], DeviceDefault("PI_MTE3")]

# Pre-select all devices matching pattern
motors: Annotated[list[Motor], DeviceDefault(pattern="sample_.*")]
class lightfall.ui.annotations.DeviceFilter(device_class: str | None = None, category: str | set[str] | None = None, group: str | None = None, source: str | None = None, name_pattern: str | None = None)[source]

Filter criteria for device selection.

All specified criteria use AND logic within a single filter. Use DeviceFilterAny to combine filters with OR logic.

Parameters:
device_class: str | None = None

Match ophyd class name (e.g., “EpicsMotor”, “AreaDetector”).

category: str | set[str] | None = None

Match device category (e.g., “motor”, “detector”).

group: str | None = None

Match device group from tags (e.g., “areadetectors”, “magnets”).

source: str | None = None

Match device source/connection type (e.g., “epics”, “simulated”).

name_pattern: str | None = None

Regex pattern for device name matching.

Example:

motor: Annotated[Device, DeviceFilter(device_class="EpicsMotor")]
detector: Annotated[Device, DeviceFilter(category="detector", group="areadetectors")]
class lightfall.ui.annotations.DeviceFilterAny(*filters: DeviceFilter)[source]

Combine multiple DeviceFilter with OR logic.

Allows selecting devices that match ANY of the specified filters, enabling disjoint filter criteria (e.g., “motors OR detectors”).

Parameters:
*filters: DeviceFilter

DeviceFilter instances to combine with OR logic.

Example:

# Select either motors or positioners
axis: Annotated[Device, DeviceFilterAny(
    DeviceFilter(category="motor"),
    DeviceFilter(category="positioner"),
)]
class lightfall.ui.annotations.DeviceIcon(name: str)[source]

QtAwesome icon identifier for the device parameter button.

Specifies which icon to show on the device selector button in the plan configuration UI. If the icon string has no dot prefix, mdi6. is prepended automatically.

Parameters:
name: str

QtAwesome icon identifier (e.g., "mdi6.engine", "camera").

Example:

motor: Annotated[Device, DeviceFilter(category="motor"), DeviceIcon("engine")]
class lightfall.ui.annotations.Range(min: float | int | None = None, max: float | int | None = None)[source]

Min/max bounds for numeric input.

Sets the allowed range for numeric parameters in the spinbox widget.

Parameters:
min: float | int | None = None

Minimum allowed value (None for no minimum).

max: float | int | None = None

Maximum allowed value (None for no maximum).

Example:

num_points: Annotated[int, Range(1, 1000)] = 10
class lightfall.ui.annotations.Unit(suffix: str)[source]

Unit/suffix to display next to numeric input.

The suffix is displayed after the input field to indicate the unit of measurement.

Parameters:
suffix: str

Unit string to display (e.g., “eV”, “s”, “K”, “mm”).

Example:

energy: Annotated[float, Unit("eV")] = 100.0