Imported from als-apg/osprey (
src/osprey/templates/claude_code/claude/skills/writing-bluesky-plans/SKILL.md). Install upstream withnpx skills add als-apg/osprey --skill writing-bluesky-plans. Copyright stays with the author.
Writing Bluesky Plans
Author a new plan as a plain-text file, get it machine-validated in a
sandbox with no hardware access, then run it through the normal
author -> validate -> run -> contribute workflow. A plan you write is inert until validate_plan records a
pass for its exact content — nothing you author here is ever imported or
run directly.
The plan-file format
A plan file is a single Python module exposing three required things (plus one
optional fourth, render — see The plan's own view below):
-
PLAN_METADATA— a plain dict with exactly three keys, all required (a plan missing one is rejected at load time, not defaulted):name(str) — the plan's name.description(str) — human-readable summary.writes(bool) — whether the plan moves a channel (vs. read-only). Authoring metadata only; it has no effect on whether writes actually happen — that is governed entirely bycontrol_system.writes_enabled.
Three keys is the whole dict. Unknown keys are refused, not ignored: the loader parses this block with extra keys forbidden, so a stray key rejects the plan with an error naming it. Nothing about the channels a plan touches belongs here — that is declared on the
PARAMSfields themselves (next section), which is the one place every consumer reads it from.When you author a session plan with
write_plan, you do not write this block at all: the bridge assembles it from the call'sname/description/writesand prepends it to your body. Write the block by hand only in a plan file installed into a facility library. -
PARAMS— apydantic.BaseModelsubclass declaring the plan's own parameters (channel names, ranges, point counts, ...). Every field that carries channel names must declare what the plan does with them — see Declare what the plan moves and what it reads. UseField(...)constraints and amodel_validatorwhere it helps (e.g. rejecting a channel named both as a moved setpoint and as a read channel). -
build_plan(devices, params)— a callable takingdevices: dict[str, Any](resolved by string name, injected by the bridge — never free names in a namespace) andparams: PARAMS, returning a bluesky generator (typically built withbluesky.plan_stubs/bluesky.plans/bluesky.preprocessors).
Study the three shipped plans for the full worked pattern — do not
invent new accelerator physics (paths are inside the installed osprey
package directory, not a source checkout):
orm(services/bluesky_bridge/plans_core/orm.py) — kicks each corrector channel either side of its own pre-scan working point, reading every BPM channel at each point, to measure an orbit-response matrix.grid_scan(services/bluesky_bridge/plans_core/grid_scan.py) — steps a set of movable channels over a rectangular grid, reading a set of channels at every grid point.orbit_bump_sweep(services/bluesky_bridge/plans_core/orbit_bump_sweep.py) — drives three or four correctors together in a combination whose kicks cancel outside the bump region, ramping a closed local orbit bump up and back down in steps and verifying the orbit against the requested tolerance band at every one — a band it first proves is wider than the machine's own measured noise. It is asked for in orbit space (displacements at named BPMs, zero at named closure BPMs) and measures the response it needs on the spot, so it carries no lattice model.
These three are the ONLY accelerator plan patterns this framework ships. Never
propose or author a BBA (beam-based alignment) or tune-scan plan — those two
measurements are explicitly out of scope, and orbit_bump_sweep does not open
that door. A closed bump is the shipped primitive those measurements would be
built on top of: running one through this plan is ordinary work, and wrapping
one in an alignment or tune measurement is still not on offer.
A plan that moves a device sweeps relative, and puts it back
Never write an absolute setpoint you did not read first, and never restore
to a literal. A running machine is not at zero: correctors hold an
orbit-correction working point, and a magnet, mover, or phase shifter sits
wherever operations left it. A plan that drives absolute values measures
about a point the machine is not at, and one that "restores" to 0.0 does
not restore anything — it drives the machine to zero, which on a stored beam
is the orbit gone.
The idiom, per device, is three lines (orm's build_plan is the worked
version):
working_point = float((yield from bps.rd(device))) # before the try
try:
for step in steps: # steps are OFFSETS
yield from bps.mv(device, working_point + step)
yield from bps.trigger_and_read(all_devices)
finally:
yield from bps.mv(device, working_point) # never a literal
Read before the try, not inside it, so a device whose read fails is
never entered and the finally can never run without a target. Your range
parameters are then excursions, not absolute setpoints — say so in their
descriptions, and do not give them a magnitude ceiling of your own: what a
device tolerates is the deployment's channel_limits.json, which the
connector's reference monitor enforces on every write.
grid_scan is the deliberate exception: a grid's whole purpose is to visit
declared absolute coordinates, so it neither reads nor restores. If your
plan maps a space, follow grid_scan; if it perturbs a working machine to
measure a response, follow orm.
The plan's name must be a valid Python identifier that does not begin with an underscore. A leading-underscore name is rejected at authoring time (400), because the queue worker would never expose such a plan — the authoring-time refusal turns a permanently unqueueable plan into one legible error.
A plan that verifies its own motion measures the noise first
orbit_bump_sweep is the exemplar for the step past orm: a plan that does
not merely move and read, but checks whether the move landed and acts on the
answer. Four rules come with that, and they are the ones an author gets wrong.
A convergence criterion is a band around a measured reference, never an
absolute. A stored beam is never still — every BPM carries its own noise —
so "the orbit is where I asked for it" only means something inside a band
measured on this machine, on this shift. orbit_bump_sweep reads
baseline_reads full rows before any write, takes the per-BPM mean as the
reference orbit every later reading is compared against and the per-BPM sample
standard deviation (ddof=1) as the noise, and then checks each step against
±tolerance around that reference. A tolerance narrower than twice the
measured noise is not a stricter plan, it is an unverifiable one — a band the
machine's own jitter crosses on every read — so the run fails on it before
any sweep write, while nothing has moved. Fail fast on a criterion you cannot
verify; never write an absolute-zero target and hope the machine sits still
for it.
The way back down is measurement, not cleanup. The amplitude profile walks
up and then back down in single-increment steps — 2 * num steps
monodirectional, 4 * num bidirectional — and each descending step short of
the terminal one is solved, trimmed and verified exactly like its counterpart
on the way up, and every step emits its own data row. That is deliberate.
Magnets do not retrace their own curve, so the descending rows are the run's
record of how far the machine actually came back at each amplitude: hysteresis
data, and the half an operator reads hardest. The same reasoning is already in
the probe — each corrector is dithered to both sides of its working point,
because a one-sided dither folds the hysteresis into the fitted slope. A plan
that ramps in verified steps and then unwinds in one jump has thrown away half
of what it measured and handed the machine a move it never checked.
The terminal step re-commands the working points verbatim, and verifies
them. The profile ends at scale 0, and that last step is neither solved nor
trimmed: it writes the working points the plan read before the try, exactly
as recorded, and then checks the orbit against the band like any other step.
Its whole job is to answer "did the machine come back?", so an out-of-band
reading there is a finding to surface (best_effort decides whether it is
recorded or raised), never something to trim away — trimming would end the run
with the correctors somewhere other than where they started, which is the one
outcome the step exists to rule out. The try/finally restore is still
underneath it, but that is the abort-and-error backstop; the terminal step is
the verified return on the clean path, and a plan that verifies its motion
needs both.
A corrector that can only move one way is unsuitable, not merely tight. A
bump solution is a combination whose kicks cancel outside the bump region,
which means some of them come out negative whatever sign the operator asked
for, and the probe drives every corrector to both sides of its working point
before the sweep starts at all. A corrector already sitting near one end of its
range, or one the deployment's limits let move in a single direction, cannot
serve either step. Choosing a different corrector is the fix — not a smaller
probe_amplitude, and not a one-sided probe.
Declare what the plan moves and what it reads
A bare list[str] field says nothing about what the plan will do to those
channels. Four annotations from
osprey.services.bluesky_bridge.plan_fields say it outright:
| Annotation | Field shape | Meaning |
|---|---|---|
MovableChannels |
list[str] |
channels the plan drives to a value |
ReadableChannels |
list[str] |
channels the plan records without driving |
MovableChannel |
str |
one driven channel, for a nested model |
ReadableChannel |
str |
one recorded channel, for a nested model |
OptionalMovableChannel |
str | None |
one driven channel the operator may leave unset |
OptionalReadableChannel |
str | None |
one recorded channel the operator may leave unset |
An optional channel takes the Optional… type, never MovableChannel | None. Spelled as a union at the field site, the role silently vanishes
(pydantic only reads an Annotated declaration when it is the outermost
annotation), and a channel with no role gets no mock, no pre-enqueue check
and no device at run time. An unset optional simply contributes no channel.
A flat list of channels, the orm shape:
from pydantic import BaseModel, Field
from osprey.services.bluesky_bridge.plan_fields import MovableChannels, ReadableChannels
class PARAMS(BaseModel):
correctors: MovableChannels = Field(..., min_length=1, title="Correctors")
readbacks: ReadableChannels = Field(..., min_length=1, title="BPMs")
num: int = Field(..., ge=3, title="Number of steps")
One channel per nested entry, the grid_scan shape — annotate the inner
field that actually holds the name, not the list around it:
class GridAxis(BaseModel):
setpoint: MovableChannel
start: float
stop: float
num_points: int = Field(..., ge=2)
class PARAMS(BaseModel):
readbacks: ReadableChannels = Field(..., min_length=1)
axes: list[GridAxis] = Field(..., min_length=1)
The role is additive. It records intent and validates nothing, so
min_length, title, description and any x-widget presentation hint stay
on the field exactly as you write them — the shipped plans depend on that.
Name the fields for your machine. The role carries the meaning, so nothing
downstream reads your spelling: correctors, bpms, setpoint, readables
are all just names. Say what the channel is at your facility.
Everything downstream reads these declarations — the load gate, the
pre-enqueue channel check, the dry run's mock devices, the devices dict your
build_plan is handed, the default figure's x axis, the pre-flight motion
summary a human approves. A channel field with no declared role is invisible
to all of them: no mock is built for it, so the dry run fails with a KeyError
naming the devices that were built. If a plan cannot find its channels, the
missing annotation is the first thing to check.
Stamp the run: scan_metadata
Anything watching a run — the live progress readout, the figure, an operator asking how far along a run is — reads the run's own opening metadata. A plan that opens its own run has to put it there:
from bluesky import preprocessors as bpp
from osprey.services.bluesky_bridge.plan_fields import scan_metadata
@bpp.run_decorator(
md=scan_metadata(
movable=params.correctors,
readable=params.readbacks,
points=params.num * len(params.correctors),
)
)
def _sweep():
...
scan_metadata is the one function that translates your capability vocabulary
into the run's standard metadata keys, so no plan ever spells those keys
itself. points is the number of points the whole run will emit — orm
multiplies its per-corrector step count by the number of correctors — because
that is the denominator every progress readout divides by. Empty channel lists
or a point count below one raise ValueError while the plan is being built,
which is where a wrong stamp is cheap to fix.
It deliberately carries no interval count and no dimensionality hint: both are
only truthful when a run's points are one continuous traversal, and a plan like
orm, whose sweeps run serially one channel at a time, is not that.
A plan that delegates to a stock bluesky plan needs no stamp at all. When
build_plan returns bp.grid_scan(...) or another stock plan, that plan opens
the run and stamps the moved and read channels, the point count and its own
dimensionality hints — more accurately than a wrapper could. grid_scan calls
scan_metadata nowhere for exactly that reason. Stamp only the run you open
yourself.
Your PARAMS fields ARE the queue item's kwargs
When the plan runs, its queue item's kwargs are the PARAMS fields
unwrapped — the field names sit at the top level of kwargs, with no
params envelope around them. The same is true of plan_args in the shared
draft and in every run record. Design PARAMS accordingly: each field is a
name an operator will see and type.
That matters for where a mistake surfaces. The bridge validates arguments
against your PARAMS schema before the item is queued, and that
pre-enqueue check is the early gate — the one that gives you a clear rejection
while nothing is moving. The worker itself only validates kwargs when the
plan actually starts, so an argument error that slips past the bridge does
not appear until the item begins running, as a failed queue item carrying a
pydantic error. Make PARAMS strict enough (typed fields, Field(...)
bounds, model_validator cross-checks) that the bridge can catch what is
wrong.
The two gates a writes: true plan must clear
A plan that declares writes: true promises two things about itself, and both
are enforced rather than trusted. Neither gate fires for a read-only plan.
1. It must declare at least one movable channel field. Otherwise the plan is quarantined at load — it never reaches the catalog, and the log carries:
PLAN_METADATA declares writes: true but no movable channel field in PARAMS —
a plan that changes machine state must declare which channels it moves
The fix is an annotation, not a metadata edit: mark the field that carries the
channels you drive as MovableChannels / MovableChannel. (A misspelled role
reads as no role, so a typo quarantines the plan rather than sliding past.)
2. It must open a run and declare that run's point count. The dry run watches what actually reached the run, and rejects the plan with one of:
Dry-run gate: plan 'X' opened a run that declares no point count; a plan that
moves channels must declare its point count — pass
md=scan_metadata(movable=..., readable=..., points=...) to your run decorator.
Dry-run gate: plan 'X' declares that it moves channels but opened no run;
<same remedy>
Opening no run at all is the same refusal on purpose: a plan that moves a
channel with nothing recording it leaves nothing to watch live and nothing to
read afterwards. The gate reads the run, not your source, so a plan delegating
to a stock bluesky plan passes it without calling scan_metadata — the stock
plan already stamped a count.
The allowlist the validator enforces
validate_plan runs your file's body through three ordered stages,
any of which can reject it outright before the next ever runs:
- Static import allowlist — only these imports are permitted:
bluesky.plan_stubs,bluesky.plans,bluesky.preprocessors(submodule-exact — bareimport blueskyorbluesky.utilsis rejected).numpy,scipy,math,statistics,time,collections,itertools,functools,pydantic,typing, andlogging(exceptlogging.configandlogging.handlers, which are denied — they resolve callables by string, an import-by-string bypass).- Exactly four OSPREY modules, each spelled in full and imported
absolutely:
osprey.services.bluesky_bridge.plan_fields(the role-typed channel annotations andscan_metadata),osprey.services.bluesky_bridge.figure(the figure vocabulary an optionalrender()returns),osprey.services.bluesky_bridge.orm_analysis(the numeric helpers behind theormplan's own view), andosprey.services.bluesky_bridge.bump_analysis(the response fit and bump solve behindorbit_bump_sweep). All four are inert — models and numeric code, no I/O and no control system. Bareimport ospreyand every otherosprey.*module are rejected. - Everything else (
epics,os,subprocess,ctypes,importlib,socket, ...) is rejected.
- CA/connector pattern scan — rejects any body matching
caput(,caget(,epics.,aioca,caproto,write_channel(,read_channel(,_osprey_connector, orPV(. Ordinary numeric/stdlib calls that merely share a method name (numpy.put(...),dict.get(...),queue.put(...)) are NOT flagged — channel I/O only ever happens through thedevicesdictbuild_planis handed, never through a raw control-system import. - Mock-device dry run — actually builds and drives your
build_plangenerator to completion against in-process mock devices, in a subprocess withEPICS_CA_*neutralized. The mocks are built from your declared channel roles: a movable field gets a mock that can be driven, a readable field one that can be read. This is an authoring-quality check ("does it actually run"), not the containment boundary — containment is stages 1 and 2 plus the load/enqueue/start gates that key off the validation record.
Foot-gun: use bps.sleep(...), never time.sleep(...). time.sleep
blocks the RunEngine's worker thread for its whole duration — no other plan
step, status update, or stop request can be serviced until it returns.
bluesky.plan_stubs.sleep(...) yields a message the RunEngine schedules
cooperatively, so the run stays responsive. time is on the import
allowlist for ordinary bookkeeping (computing a delay, timestamping) — it is
never a substitute for bps.sleep inside a plan's own control flow.
The plan's own view: render(window, params)
A plan file may expose one optional extra: a module-level
def render(window: RowWindow, params: PARAMS) -> Figure:
window is a RowWindow from osprey.services.bluesky_bridge.figure — the
rows the figure is being built from, and how much of the run they are:
| Field | What it holds |
|---|---|
window.rows |
the run's event data dicts, in emission order |
window.columns |
the column names those rows use |
window.rows_complete |
whether those rows are all the run has produced so far, or only a prefix of them |
window.total_seen |
how many rows the source has actually seen — never fewer than len(window.rows) |
params are the parameters the run was launched with, and the return value is
a Figure from
osprey.services.bluesky_bridge.figure — a list of Panels, each carrying a
title, axis labels and units, annotations (short sentences saying what the
panel does not show), and exactly one mark: LinesMark (named x/y
series), BarsMark (one value per named category), or HeatmapMark (a
labelled 2-D grid). A panel showing two things is two panels. The bridge serves
that figure from GET /runs/{id}/figure, the operator's BLUESKY panel draws
it, and get_run_figure reads it — one view, three places.
Import the figure vocabulary absolutely, never relatively: plan files are
loaded by path with no parent package, so from ..figure import ... fails at
load time and takes the plan out of the catalog with it.
from osprey.services.bluesky_bridge.figure import (
Figure,
LinesMark,
Panel,
Point,
RowWindow,
Series,
)
A plan with no render is complete and ordinary. Watchers then see the
bridge's default view — every numeric column the run recorded, plotted
against the one channel the plan declared it drives when there is exactly one,
and against row order otherwise — carrying the reason no_render. That is a
real view of real data, not a missing one, so render() is worth writing only
when the plan can say something the columns cannot say for themselves.
Six rules govern one:
- Annotate the first parameter
RowWindow. The loader readsrender's signature, and a first parameter annotated as a list of rows — or left unannotated and namedrows— is read as the retired row-list contract, which the loader cannot safely run: it warns, drops the render, and the plan itself still loads, queues and runs, so the only symptom is a figure that quietly stays the default view. Name the parameter whatever you like; the annotation is what is read. - Draw by position only over a complete window. If a panel derives meaning
from where a row sits — the
ormrender deciding which channel a row belongs to from its place in the sweep — that is sound only whenwindow.rows_completeis true. Fall back to something position-free otherwise, and say so in an annotation. render()must never raise. A figure is a view, not a result. If it raises — or returns anything other than aFigure— the run's data is untouched and the bridge quietly serves the default view with the reasonrender_failed, but the plan's own view is gone for everyone watching until the code is fixed. Write it to degrade instead: guard the parts that can fail, drop a panel rather than the figure, and return the panels that still stand.- Stay facility-neutral. Label panels from
paramsand the row keys — the channel names the run actually used — exactly asbuild_planresolves its devices by string name. Never hard-code a facility's channel names, PV strings, or a fixed channel count in the drawing code. partialandsourceare placeholders.render()sees rows, not where they came from, so set them to anything (the exemplar returnspartial=True, source="live") and let the route stamp the truth onto both.- A session-tier
render()is never run. It would run in the bridge's own process on every poll of every watching client, so it is honored only for plans from the reviewed, installed tiers — shipped, preset and facility. A session-tier file that declares one still loads, queues, runs and records data exactly as it would otherwise; only the drawing is skipped, and watchers see the default view with the reasonrender_not_supported_for_session_plans. Nothing about the execution surface changes. So writerender()when authoring for a facility library; while a plan is session-tier, the default view is what everyone sees.
plans_core/orm.py's render is the worked pattern — sweep traces first, then
the fitted matrix and its anomaly-score bars, with each stage guarded so a
failure downgrades the figure instead of losing it.
Workflow: author -> validate -> run -> contribute
-
Author —
write_plan(name, writes, body, description="").bodyis yourPARAMS+build_plansource (noPLAN_METADATAblock — the bridge assembles and prepends one from your other arguments). The channels are NOT arguments to this call: they are the role-typedPARAMSfields insidebody. Writes a session-tier file; reaches no hardware. Re-authoring the samenameoverwrites the file and drops any prior passing validation (its content hash changes). -
Validate —
validate_plan(name, sample_args=None, dry_run_timeout=30.0). Validates the file's CURRENT on-disk content (never a body you pass directly) through the three stages above.sample_argsshould supply realisticPARAMSfield values so the dry run's mock devices match what your plan expects. A pass is what makes the plan usable at all — an unvalidated session plan is never listed, loaded, or queueable.A pass also triggers an upload of the validated bytes into the queue worker's namespace, for that exact content hash. The response is
{passed, reasons, content_hash, upload}, whereuploadis{uploaded, reason, detail}. Thepassedverdict stands regardless of how the upload went: a pass withuploaded: falseis a genuine pass (a deployment with no queue server has nowhere to upload to), but the plan is not queueable until an upload lands — so keepupload.reason/detail, and relay them if a laterqueue_addis refused. -
Confirm it's live —
list_plans()to see the plan appear withprovenance: "session"alongside itsmetadata. -
Run — stage the validated plan into the shared draft with
set_draft(plan_name, plan_args_patch=...)(motion-safe, no channel touched — it only fills the plan panel and returns arevision), thenqueue_add(draft_revision)puts that pinned draft in the queue andqueue_start()begins draining it. Both consult the validation record: the plan's content hash is re-checked at enqueue and again at queue start,queue_startrequirescontrol_system.writes_enabledplus the launch token, and a human sees an approval prompt. A refusal whosedetail.codestarts withsession_plan_(session_plan_unvalidated,session_plan_not_in_namespace) means exactly one thing: re-validate the plan and try again. Useget_run(run_id)/get_run_data(run_id, ...)to watch it, andget_run_figure(run_id)for the figure — the better watch for a plan that ships arender(), and still a real view of the data for one that does not. Theoperating-bluesky-plansskill covers this run flow in full — staging the complete configuration, the two-step add/start, refusal handling, and stopping. -
Contribute to the permanent catalog — a session plan stays session-tier (least trusted, most ephemeral) until a human reviews it and contributes it into a facility catalog directory; that is a separate follow-up step, not something this skill or any MCP tool does automatically.
Anti-patterns
- Never import or reference EPICS/CA/connector internals directly
(
epics,caput/caget,_osprey_connector, raw PV names) — all channel I/O goes through thedevicesdictbuild_planreceives. - Never leave a channel-carrying
PARAMSfield unannotated — a field with no declared role is invisible to the mocks, the pre-enqueue check and the approval summary, and awrites: trueplan with no movable field is quarantined at load. - Never put channel names in
PLAN_METADATA, or any key beyondname/description/writes— the dict refuses unknown keys, and the channels belong on thePARAMSfields. - Never stamp a run you did not open — a plan wrapping a stock bluesky plan inherits that plan's metadata, and a second stamp would overwrite a more accurate one.
- Never use
time.sleep(...)inside a plan body — usebps.sleep(...). - Never propose a BBA or tune-scan plan —
orm,grid_scanandorbit_bump_sweepare the only plan patterns this framework ships, and the bump is a primitive to run, not a foundation to build those two on. - Never write a convergence criterion the machine's own noise can cross — measure a reference and a per-channel noise first, check against a band around it, and refuse an unverifiable tolerance before the first write rather than discovering it mid-sweep.
- Never unwind a verified ramp in one move — walk it back down in the same increments, verifying and recording each step, because the descent is data.
- Never hard-code a facility channel name inside
build_plan— resolve every channel by string name through the injecteddevicesdict, exactly like all three exemplars. The same holds forrender(): label its panels fromparamsand the row keys, never from a name written into the file. - Never let
render()raise — guard what can fail and drop a panel instead, or every watcher gets the default view in place of the plan's own. - Never leave
render()'s first parameter unannotated, or annotated as a list of rows — the loader reads that as the retired row-list contract and drops the view without raising, so the plan runs and records normally while its own figure silently never appears. - Never treat a passing dry run as proof the plan is safe against real hardware — it proves the plan runs, not that its channel motion is physically sound. Human approval at queue start is the real backstop.
- Never edit a validated plan file and then queue it without re-running
validate_plan— the validation record is keyed to the file's content hash, so any edit drops it, and the hash is re-checked both at enqueue and at queue start. - Never include a
from __future__ import ...line in your body — the bridge always prepends a generatedPLAN_METADATAassignment ahead of it, so it can never be the file's first statement (a hard Python requirement); modern type hints (list[str],dict[str, Any]) work without it on Python 3.9+.