Writing Macros#

Macros are Python files (or functions) that extend a session with reusable procedures. They range from simple motor shortcuts to complex multi-step Bluesky plans. The files in .usage_logs/ provide real examples from production sessions.


Startup Scripts (Shell)#

The startup_scripts/ folder contains shell scripts that launch a session for each beamline. Each one activates the conda environment and starts IPython with the appropriate import:

# startup_scripts/bluesky-4idh
#!/bin/bash
source /APSshare/miniconda/x86_64/bin/activate polar-bits
ipython -i -c "from id4_h.startup import *"

Run from a terminal:

bluesky-4idg    # 4IDG session
bluesky-4idh    # 4IDH session
bluesky-4idb    # 4IDB session
bluesky-core    # shared core only

Per-Session Startup Files#

After the main shell startup, users typically run a Python file that handles session-specific setup: loading extra devices, setting energy tracking, configuring detectors, and defining motor shortcuts. This is run inside IPython with %run:

%run startup_4idh.py

A typical session startup file looks like this:

# startup_4idh.py
from apsbits.core.instrument_init import oregistry
from id4_common.utils.experiment_utils import experiment_load_from_bluesky
from id4_common.utils.counters_class import counters
from id4_common.utils.pr_setup import pr_setup
import matplotlib.pyplot as plt

plt.ion()    # enable interactive plots

# Restore experiment from last Bluesky run
experiment_load_from_bluesky()

# Energy tracking
energy = oregistry.find("energy")
energy.tracking_setup(["undulators_ds", "pr2"])

# Undulator offset
undulators = oregistry.find("undulators")
undulators.ds.energy_offset.put(-0.063)
undulators.ds.energy_deadband.put(0.002)

# Default detector/monitor selection
counters.plotselect(9, 8)

# PR2 setup for helicity switching
pr_setup.positioner    = oregistry.find("pr2_pzt_localdc")
pr_setup.offset        = oregistry.find("pr2_pzt_offset_microns")
pr_setup.oscillate_pzt = True

# Load motor shortcuts and macros
from motor_shortcuts import *
from macros import *

Motor Shortcuts#

Create short variable names for frequently used motors by retrieving them from the device registry. Put these in a motor_shortcuts.py file:

# motor_shortcuts.py — 4IDH example
from apsbits.core.instrument_init import oregistry

_mag = oregistry.find("magnet911")

tabx  = _mag.tab.x       # sample table X
taby  = _mag.tab.y       # sample table Y
tabth = _mag.tab.srot    # sample table rotation
samy  = _mag.samp.y      # sample Y
samth = _mag.samp.th     # sample rotation
field = _mag.ps.field    # magnetic field (Tesla)
# motor_shortcuts.py — 4IDG example
from apsbits.core.instrument_init import oregistry

huber_euler = oregistry.find("huber_euler")
huber_hp    = oregistry.find("huber_hp")
energy      = oregistry.find("energy")

sx  = huber_euler.x
sy  = huber_euler.y
phi = huber_euler.phi
chi = huber_euler.chi
delta = huber_euler.delta

nanox = huber_hp.nanox
nanoy = huber_hp.nanoy

Import with a wildcard so shortcuts land in the session namespace:

from motor_shortcuts import *
# now: tabx, field, phi, etc. are directly usable

Writing Bluesky Plans#

Bluesky plans are Python generator functions. They use yield from to compose built-in plan stubs, which allows the RunEngine to track, pause, and replay them. Any sequence of moves and scans can be wrapped in a plan:

# macros.py
from bluesky.plan_stubs import abs_set, sleep
from id4_common.plans.local_scans import lup, ascan, mv, grid_scan, rel_grid_scan
from id4_common.plans.center_maximum import cen
from id4_common.utils.counters_class import counters
from apsbits.core.instrument_init import oregistry

huber_hp = oregistry.find("huber_hp")
energy   = oregistry.find("energy")


def align_sample():
    """Align sample in X and Y by scanning to the transmission peak."""
    tabx  = oregistry.find("magnet911").tab.x
    taby  = oregistry.find("magnet911").tab.y
    yield from lup(tabx, -0.5, 0.5, 30, 0.2)
    yield from cen(tabx)
    yield from lup(taby, -0.5, 0.5, 30, 0.2)
    yield from cen(taby)


def energy_map(energies, dwell=0.5):
    """Take a 2D nano-scan at each energy in the list."""
    for en in energies:
        yield from mv(energy, en)
        yield from rel_grid_scan(
            huber_hp.nanoy, -3, 3, 31,
            huber_hp.nanox, -2, 2, 21,
            dwell,
            snake_axes=True,
        )

Run a plan:

RE(align_sample())
RE(energy_map([6.205, 6.208, 6.211]))

Chain plans sequentially:

RE(align_sample())
RE(energy_map([6.205, 6.208, 6.211], dwell=0.1))

Or within a single plan:

def overnight_run(energies):
    """Full overnight sequence."""
    yield from align_sample()
    yield from energy_map(energies)

RE(overnight_run([6.205, 6.208, 6.210, 6.212]))

Accessing Devices Inside Plans#

Use oregistry.find() at the top of the macro file to get device references. Avoid accessing the registry inside a running plan — resolve names at import time instead:

# Good — resolve at import time
_magnet = oregistry.find("magnet911")
field   = _magnet.ps.field

def field_sweep():
    yield from ascan(field, -1, 1, 50, 2.0, dichro=True)

Non-Interactive Experiment Setup in Scripts#

When running from a script (not interactive), pass all arguments to experiment_setup() as keywords so it does not prompt:

from id4_common.utils.experiment_utils import experiment_setup
from apsbits.utils.config_loaders import get_config
from pathlib import Path

experiment_setup(
    esaf_id         = 281924,
    proposal_id     = "1014446",
    base_name       = "scan",
    sample          = "EuAl4",
    server          = "dserv",
    experiment_name = "Frontini_26-1",
    reset_scan_id   = 1,
)

# Override the data path if needed
iconfig = get_config()
experiment.base_experiment_path = (
    Path(iconfig["DM_ROOT_PATH"]) / "2026-1/Frontini_26-1/data"
)

Logging Notes Within a Session#

Use spec_comment to annotate the SPEC logbook with free-form text at any point during a session:

spec_comment("Sample: EuAl4 single crystal, (001) face")
spec_comment("PR2 theta = 22.302 deg, field = +3 T, T = 100 K")
spec_comment("Starting overnight field-dependent XMCD run")

Comments appear in the SPEC .dat file prefixed with #C and are visible in any SPEC data viewer.

For Python-side logging within a macro, use the standard logging module:

import logging
logger = logging.getLogger(__name__)

def my_plan():
    logger.info("Starting field sweep")
    yield from ascan(field, -1, 1, 50, 2.0)
    logger.info("Field sweep complete")

Importing Macros#

Load macros into the session namespace:

%run macros_4idh.py          # executes the file; all names land in session
from macros_4idh import *    # explicit wildcard import

To share macros across sessions, keep them in a dedicated directory and use %run from anywhere:

%run /home/beams/POLAR/macros/macros_4idh.py