Skip to content

Examples

Two shipped protocols, walked through step by step. Both live in the protocols/ directory of the Atomize_ITC checkout and both dry-run with no hardware and no GUI:

epr-auto run protocols/overnight_t2.yaml --test
epr-auto run protocols/field_series_t1t2.yaml --test

The first is the smallest useful protocol — tune, then measure one T2 with a fixed scan budget. The second is the pattern most real campaigns follow — tune once, then repeat a measurement group across a series of fields, letting each position spend only the scans it needs. Read Writing protocols for the YAML dialect and The tune-up chain for the physics behind the tuning steps; this page is about how the pieces fit together in a working file.

Commissioning status

Live execution is enabled from the CLI, but the automation chain has not yet been validated on the spectrometer — both protocols are implemented and verified in dry-run (--test) only. Always dry-run first, and keep the first live sessions in supervised autonomy with an operator present.

A single T2 — overnight_t2.yaml

sample: test_sample
autonomy: checkpointed        # supervised | checkpointed | autonomous

steps:
  - field.edfs:
      range: [338 mT, 352 mT]
      pick: max
      checkpoint: true        # confirm the chosen field before continuing

  - tune.auto_phase           # phase on the echo at the working field

  - tune.pi_calibration:
      mode: amplitude         # fine stage; amplitude sweep at fixed length

  - exp.t2:
      tau_start: 300 ns
      tau_step: 12 ns
      points: 400
      scans: 16

field.edfs sweeps the magnet across 338–352 mT, picks the working field at the magnitude maximum of the echo-detected sweep, and parks the magnet there — storing the field for every later step. It is marked checkpoint: true: in checkpointed autonomy the runner pauses here for the operator to confirm the chosen field before the magnet moves (in a dry-run the checkpoint is logged and auto-continued). The stored field flows into the build of every acquisition after it.

tune.auto_phase runs after field.edfs so there is an echo at the working field to phase on. It acquires a short echo on the default hahn_echo_4s.phase_awg preset and zeroes the receiver phase, storing the corrected zero-order in the session. This protocol carries no tune.echo_window before it, so the phase is measured over the preset's own stored integration window — the simplest case; a fuller tune-up measures the window first (see The tune-up chain).

tune.pi_calibration runs the fine amplitude calibration on the default ampl_4s.phase_awg Amplitude preset, fitting the π and π/2 AWG amplitudes independently and storing them. Because the protocol declares no tune.power_for_length coarse stage before it, there is no rail fallback to fall back on: if the fit runs into the amplitude rails the step fails as-is (see Troubleshooting).

exp.t2 measures the Hahn-echo decay. The session state assembled by the three tuning steps flows into its build automatically: the zeroed phase from tune.auto_phase, the working field from field.edfs, and — through apply_cal, inferred from the preset's own two amplitude levels — the calibrated π and π/2 amplitudes from tune.pi_calibration. The tau_start / tau_step re-anchor the tau sweep, and the saved axis is the physical evolution time 2·tau. scans: 16 is a fixed budget here: this protocol sets no target_snr and no max_duration, so all 16 scans always run. That is the right choice when you know the scan count you want; the next example shows how to make the runner decide it.

What a live run leaves behind

Run live (not --test), this protocol writes into ~/epr_data/epr_auto_<date>_test_sample/:

  • manifest.json — the run record, rewritten after every step. It holds the protocol name, sample, autonomy, start/finish timestamps and status, and one entry per step with its resolved parameters, result, judge reports and attempt count. The exp.t2 entry, for instance, carries the fitted t2, the stretched-exponential beta, and the echo_snr / relaxation_fit judge scores.
  • protocol_overnight_t2.yaml — a verbatim copy of the protocol, so the exact YAML that produced the data always sits next to it.
  • the acquisition CSVs001_edfs.csv, 002_auto_phase.csv, 003_pi_cal_amplitude.csv, 004_t2.csv, each three columns (axis, I, Q). A dry-run writes none of this; it logs the same information to the terminal.

A field series — field_series_t1t2.yaml

sample: field_series
autonomy: checkpointed

steps:
  # Tune once at the line maximum; field moves do NOT invalidate the phase,
  #  so the loop reuses this tune.
  - field.edfs:
      range: [338 mT, 352 mT]
      pick: max
      checkpoint: true
  - tune.echo_window
  - tune.auto_phase
  - tune.pi_calibration:
      mode: amplitude

  # One T2 + T1 per field. A dead position records + continues to the next.
  - foreach:
      var: B
      values: ['3000 G', '3318 G', '3376 G', '3450 G']
      on_fail: continue
      steps:
        - field.set:
            value: $B
        - exp.t2:
            tau_start: 300 ns
            tau_step: 12 ns
            points: 200
            scans: 48
            target_snr: 10
        - exp.t1:
            t_start: 500 ns
            t_end: 2 ms
            points: 200
            scans: 48
            target_snr: 10
            rep_rate: 100

Tune once, at the top

The four steps before the foreach are a complete tune-up performed once, at the line maximum found by the first field.edfs: find the line, measure the integration window, zero the phase, calibrate the pulses. Here tune.echo_window runs before tune.auto_phase — the canonical order, since auto-phase integrates over the window the previous step measured. The window, phase, and calibration land in the session and are reused for every field in the loop.

The key design fact is in the comment: the series does not re-phase on field moves. Moving the magnet changes neither the resonator tuning nor B₁, and its effect on T₁ is minor, so a field move drops no calibration — the phase, window, field-independent calibration, and rep-rate recommendation all survive it. Measured on real hardware, the phase drifted only ±1–3.5° across the whole line; a few degrees of demod drift costs nothing, because every relaxation curve is re-rotated onto its principal axis before fitting. Temperature moves are the opposite — they do force a re-phase — which is why a temperature series, unlike this field series, follows each move with a fresh tune.auto_phase. See The tune-up chain for the full invalidation table.

The foreach block

foreach runs its sub-steps once for each value of B, substituting the value in wherever $B appears. Here field.set: {value: $B} moves the magnet to each field in turn, then exp.t2 and exp.t1 measure the two relaxation curves at that field. The four values are given as quoted field strings ('3000 G', …) because value: needs a "<value> <unit>" string; every substituted sub-step is fully validated for every value at load time, so a typo is caught by epr-auto validate before the run.

Two foreach behaviours earn their keep here:

  • on_fail: continue — a dead field position (no echo, a failed fit) is recorded in the manifest and the series moves on to the next value rather than aborting the whole night. A per-iteration failure that is an explicit operator abort or an unexpected code error is not swallowed, but an ordinary bad-data failure is.
  • loop tagging — each acquisition's CSV carries the loop stamp, so the files are self-identifying: 005_t2_B_3318G.csv is a T2 at the B = 3318 G point. The manifest additionally records {var: B, value: '3318 G', index: 2} on every step run inside the block, tying each acquisition back to its position in the series.

The rail-triggered coarse fallback deliberately does not reach inside the foreach — the intent is to tune once before the loop, so a sub-step's amplitude-rail failure is handled by the block's on_fail rather than by re-running an earlier coarse stage.

The scans ceiling and target_snr

Both experiments set scans: 48 and target_snr: 10. This is the pairing that makes an unattended field series efficient. scans is a ceiling, not a fixed count: after each completed scan the runner measures the accumulated curve's SNR with the same echo_snr judge that gates the finished step, and stops as soon as the curve reaches SNR 10 — projecting via √N scaling whether the target is even reachable inside the ceiling. At the line maximum (3318 / 3376 G) a curve clears SNR 10 in a handful of scans; on the weak 3000 G shoulder it runs the full 48. This is exactly the adaptation an operator does by hand — a few scans at the line maximum versus dozens on a weak shoulder — now automatic.

target_snr only ever lowers the scan count; it never adds scans to chase the target. That is why it is paired with a real ceiling: with scans at its default of 1 there is nothing to shrink, and the step would warn that the setting is inert. Set scans to the largest budget you are willing to spend per point, and let target_snr cut each easy point short.

exp.t1 also fixes rep_rate: 100 (Hz) rather than inheriting the preset's value — a deliberate choice for a quantitative recovery measurement. It could instead be rep_rate: auto if a tune.rep_rate step had run in the prologue to measure and recommend a rate; see rep_rate: auto.

Note

A field series does not need max_duration, but an open-ended overnight run does: adding max_duration: 21600 s to an experiment step caps its wall-clock time, shrinking the scan count mid-run to finish inside the budget (the data acquired so far is always kept). When both target_snr and max_duration are set the smaller resulting scan count wins.

Next steps