Tutorial 6 · Write your own procedure¶
Goal: create a brand-new measurement procedure, register it, and run it from the app — learning how the pieces fit together. Time: ~15 minutes. Hardware: none.
So far you've run the procedures that ship with Laser Setup. Now you'll add your own. We'll keep it simple — a procedure that records a (simulated) signal over time — so you can focus on the moving parts. When you're ready for the full reference (instruments, shared parameters, sequences), the Developer Guide → Creating a New Procedure goes deeper; we'll point there as we go.
What you'll learn
- The three things every procedure needs: parameters,
DATA_COLUMNS, and anexecute()loop. - How a procedure becomes a clickable item in the app (registration).
- How your inputs, live plot and saved file all come from the same class.
The mental model¶
A procedure is a Python class. Laser Setup turns it into a GUI automatically:
flowchart LR
P["Your Procedure class<br/>parameters · DATA_COLUMNS · execute()"] --> R["Register it<br/>(procedures.yaml)"]
R --> G["App builds inputs + plot<br/>from the class"]
G --> Run["Queue → startup → execute → shutdown<br/>emit() streams data to plot + CSV"]
You write the class; the framework does the rest. Let's build one.
Step 1 — Create the procedure file¶
Create a new file laser_setup/procedures/SignalVsTime.py with this content:
import logging
import math
import random
import time
from pymeasure.experiment import FloatParameter
from .BaseProcedure import BaseProcedure
log = logging.getLogger(__name__)
log.addHandler(logging.NullHandler())
class SignalVsTime(BaseProcedure):
"""Record a (simulated) decaying signal over time."""
name = 'Signal vs Time'
# 1) Parameters → become inputs in the GUI (and are saved to the file)
duration = FloatParameter('Duration', units='s', default=10.0, minimum=0.1)
amplitude = FloatParameter('Amplitude', units='V', default=1.0)
noise = FloatParameter('Noise level', units='V', default=0.05, group_by='show_more')
# 2) Which inputs to show, and in what order
INPUTS = BaseProcedure.INPUTS + ['duration', 'amplitude', 'noise']
# 3) The columns your measurement produces (first two = default plot axes)
DATA_COLUMNS = ['t (s)', 'signal (V)']
# 4) The measurement itself
def execute(self):
log.info("Measuring a signal for %.1f s", self.duration)
tau = self.duration / 3 or 1.0
t0 = time.time()
t = 0.0
while t < self.duration:
if self.should_stop(): # (1) makes the Abort button work
log.warning("Measurement aborted by the user")
break
signal = self.amplitude * math.exp(-t / tau) + random.gauss(0, self.noise)
self.emit('results', {'t (s)': t, 'signal (V)': signal}) # (2) one data row
self.emit('progress', 100 * t / self.duration) # (3) progress bar
time.sleep(0.2)
t = time.time() - t0
- Always poll
self.should_stop()in the loop so Abort (and sequences) can interrupt cleanly. emit('results', …)sends one row to the live plot and the CSV. The dict keys must matchDATA_COLUMNS.emit('progress', …)drives the progress bar (0–100).
That's the whole procedure. We subclassed
BaseProcedure, which gives us instrument
handling and the common inputs (info, show_more, …) for free. We didn't write
startup() or shutdown() because this measurement needs no setup — the base
defaults are fine.
Step 2 — Register it¶
For the app to see your procedure, add one line under _types in
laser_setup/assets/templates/procedures.yaml:
_types:
# … the existing entries …
SignalVsTime: ${class:laser_setup.procedures.SignalVsTime.SignalVsTime}
${class:…} is a resolver that turns
that dotted path into the actual Python class when the config loads. The _types
map is exactly what fills the Procedures menu and the list of names the CLI
accepts.
Where registration belongs
Editing the bundled template works on a cloned repo (it's the default config).
For a setup you maintain separately, register it in your local
config/procedures.yaml instead — see
Tutorial 4 and
Developer Guide → Registering in YAML.
Step 3 — Run it¶
Confirm the app now recognizes it:
Then open it:
An experiment window opens with Duration and Amplitude inputs (tick
Show more to reveal Noise level). Press Queue and watch the decaying
signal appear on the plot, then find your data under data/<date>/. 🎉
You just created and ran your own measurement.
How the infrastructure works¶
What happened when you pressed Queue? The same pipeline every procedure uses:
| Your class member | What the framework does with it |
|---|---|
name |
The label shown in the Procedures menu and window title. |
Parameter attributes + INPUTS |
Builds the input widgets on the left (with units, limits, and group_by show/hide). |
DATA_COLUMNS |
Sets the plot axes (first two) and the CSV columns. |
execute() + emit('results', …) |
Runs in a background thread; each emitted row streams to the live plot and is written to the data file. |
emit('progress', …) / should_stop() |
Drive the progress bar and the Abort button. |
the _types entry |
Lets CONFIG.procedures find your class so laser_setup SignalVsTime and the menu can launch it. |
The run itself follows the lifecycle startup() → execute() → shutdown()
(see Architecture),
and every parameter value is saved into the file header so the measurement is
reproducible (Data & Output Files).
Make it measure real hardware¶
SignalVsTime fakes its data so it runs anywhere. To measure something real you
add an instrument — for example, read current from a Keithley:
from ..instruments import InstrumentManager, Keithley2450
from .utils import Instruments
class SignalVsTime(BaseProcedure):
instruments = InstrumentManager()
meter: Keithley2450 = instruments.queue(**Instruments.Keithley2450)
def startup(self):
self.connect_instruments() # proxy → live (or debug) instrument
self.meter.measure_current()
def execute(self):
...
signal = self.meter.current # read the instrument instead of faking it
Run it with uv run laser_setup -d SignalVsTime and the Keithley is simulated
(debug mode) so you can develop without hardware — see
Tutorial 5.
The full story — adding instruments, reusing shared parameters from
parameters.yaml, device (ChipProcedure) measurements, estimates, and
sequencer sweeps — is in the Developer Guide:
-
The complete reference, with a worked instrument example.
-
Rich, reusable inputs (units, limits, choices,
group_by). -
Procedures, sequences and scripts.
Recap¶
- A procedure = a class with parameters,
DATA_COLUMNS, and anexecute()loop that callsemit('results', …). - Register it with one
${class:…}line under_typesinprocedures.yaml. - Run it with
uv run laser_setup <Name>— the GUI, plot and saved file are all built from your class. - Swap the fake data for an instrument read to measure real hardware; the Developer Guide shows how.
That's the end of the tutorial track — you can now run, configure, sequence, and build measurements. 🔬