How to add a new plan#

This page covers writing and registering a custom Bluesky plan or plan stub for the id3c instrument.

For the difference between a plan and a plan stub, see Plans and stubs.

Where things live#

  • src/id3c/plans/ – plan modules. One file per topic; group related plans in one file.

  • src/id3c/startup.py – imports plans at session end so they become available at the IPython prompt.

Skeleton#

# src/id3c/plans/my_plans.py
"""Plans for ___ at 3-ID-C."""

import logging

from bluesky import plan_stubs as bps
from bluesky import plans as bp
from bluesky.utils import plan

logger = logging.getLogger(__name__)


@plan
def park_and_count(detectors, num=1, md=None):
    """Park the sample stage, then count.

    Parameters
    ----------
    detectors : list
        Detectors to read in the count.
    num : int
        Number of counts.
    md : dict, optional
        Extra metadata to attach to the run.
    """
    md = md or {}
    md = {"purpose": "park-then-count", **md}
    yield from bps.mv(sample_stage.xprime, 0, sample_stage.base_y, 0)
    yield from bp.count(detectors, num=num, md=md)

Then register it in startup.py so the prompt sees it:

# src/id3c/startup.py  (at the bottom, with the other plan imports)
from .plans.my_plans import park_and_count   # noqa: E402, F401

Restart the IPython session; park_and_count is now available:

RE(park_and_count([scaler], num=5))

Repository conventions#

These are the established conventions from AGENTS.md; follow them in new plans.

Decorate with @plan#

Always:

from bluesky.utils import plan

@plan
def my_plan(...):
    ...

@plan wraps the generator so that discarding it without iteration (the common new-user mistake of my_plan() instead of RE(my_plan())) emits a RuntimeWarning pointing at the user’s call site. See The @plan decorator.

Examples in docstrings use RE(...)#

Any code example in a plan’s docstring should use RE(my_plan(...)). This is the pattern users see in the rest of the docs and the help() output should match. Direct yield from my_plan(...) examples are appropriate only when showing composition of one plan inside another.

Plan stubs vs. plans#

If your function publishes documents (uses bp.* plans, or explicit bps.open_run / bps.close_run), it is a plan. If it does not (only bps.mv, bps.sleep, etc.), it is a plan stub.

The decorator is the same; the difference is what your function should be composed into. Plans are typically run by the user at the top level (RE(my_plan())). Plan stubs are typically called from inside another plan (yield from my_stub(...)), though both work either way.

Metadata#

Accept a md kwarg in plans that publish documents, merge it with your defaults, and pass it through:

@plan
def my_plan(detectors, md=None):
    md = md or {}
    md = {"plan_name": "my_plan", **md}
    yield from bp.count(detectors, md=md)

This lets users attach their own metadata without your plan silently discarding it.

Composing plan stubs#

Inside a plan you are writing, use yield from to call another plan or stub:

@plan
def align_then_scan(detectors):
    yield from align_sample()              # another plan you wrote
    yield from bps.sleep(0.5)
    yield from bp.scan(detectors, sample_stage.xprime, 0, 10, 11)

You do not use RE(...) inside another plan. RE(...) is the top-level invocation only.

If you forget yield from, the inner generator is created and discarded – the call does nothing. This is the same bug @plan is designed to catch; decorating both the outer and inner plan turns the silent no-op into a visible warning.

Testing without EPICS#

This repo is developed on a host that cannot reach the beamline EPICS PVs. For an offline check that a plan is at least shaped right:

from id3c.plans.my_plans import park_and_count

# 1. Decorator marker present?
park_and_count._is_plan_     # True if @plan applied

# 2. Calling it returns a Plan, not None?
park_and_count([], num=1)    # <Plan object ...>

# 3. Smoke-iterate to surface obvious errors (will raise on the first
#    real device access, which is expected off-network):
gen = park_and_count([], num=1)
next(gen)   # the first yielded message

For full validation, run the plan on a workstation that can reach the IOCs.

See also#