<!-- SPDX-License-Identifier: MIT -->
<!-- Copyright (c) 2026 Olympian LED -->

# step_kit: a channel's absolute bandwidth from a fast-edge pulser, over SCPI

`step_kit.py` measures the absolute -3 dB bandwidth of one oscilloscope channel. It uses a free-running fast-edge
pulser and nothing else. It is one Python file (3.8 or newer, with numpy). It talks SCPI over the raw socket, TCP
5555 by default. It is written for, and tested against, the SCPI dialect of the Rigol DHO800, DHO900 and MHO900.

We wrote it so that you can run our method on your own unit and tell us where it is wrong.
[METHOD.md](METHOD.md) gives the arithmetic step by step, with the self-test results.

## What it measures

The pulser's square wave runs on its own clock, so its edges fall at every phase of the scope's sample clock. The kit
takes 8 records of 1 Mpts and fits the time of every edge. It then places every sample by its time from its own
edge, on a grid of 1/16 of a sample interval. That rebuilds the channel's step response in equivalent time, so
nothing above the record's Nyquist frequency aliases. From the step:

- **-3 dB**: the step's derivative is the impulse response. Its spectrum over its own DC level is the channel's
  |H(f)|, absolute: no reference channel and no ratio.
- **rise time**, 10 % to 90 % of the step.
- **overshoot**, the step's highest point after the edge over its settled level.
- **noise floor and SNR** per frequency, from the same fold a quarter period later (the flat top).
- **jitter bound**: the spread of each edge's own time against the folded step. It is an upper bound of the jitter
  that smears the fold, and the kit gives the loss it can cause at the -3 dB frequency.

## What you need

- The scope, on your network.
- A **Leo Bodnar LBE-1322** fast-edge pulser, or any pulser whose edges are well under the channel's rise time.
  The LBE-1322's edges are 30 ps, which costs 0.035 dB at 1 GHz.
- A short SMA-to-BNC cable (we use 25 cm).
- On a DHO800 / DHO900 (1 MOhm inputs only): a 50 Ohm BNC feed-through.
- Python 3.8 or newer with numpy (`python3 -m pip install numpy`).

## Setting up the pulser (once)

In the maker's configuration utility:

1. Set the output to about **0.5 Vpp**.
2. Set a frequency near 1 MHz that is **not a round number**, for example **1,092,233 Hz**. A round number, or the
   scope's own reference, locks the edges to one phase of the sample clock; the kit then refuses the records. (Our
   own runs used 1,003,017 Hz. Its period is close to a whole number of samples at 4, 2 and 1 GSa/s, which the kit
   now warns about: see "Known limits".)
3. Leave the **sync input open**.

Then connect its SMA output through the short SMA-to-BNC cable **straight into the channel**:

- MHO900: no adapter and no feed-through. The kit sets the channel to 50 Ohm.
- DHO800 / DHO900: put the 50 Ohm feed-through on the channel's BNC, and the cable into it. Pass `--imp 1M`. Never
  connect the pulser straight to a 1 MOhm input: unterminated, the cable's far end sees only the input capacitance.

The kit checks on the first record how evenly the pulser's edges cover the sample interval. If it warns ("its edges
sweep the sample interval only 1.7 times a record"), change the pulser's frequency by a few hundred Hz and run
again. See "Known limits".

## Running it

```sh
python3 step_kit.py selftest                                   # offline: the analysis on synthetic records
python3 step_kit.py measure --host SCOPE --ch 1                 # MHO900: 50 Ohm, band OFF, 4 GSa/s, 8 x 1 Mpts
python3 step_kit.py measure --host SCOPE --ch 1 --imp 1M        # DHO800 / DHO900: through the feed-through, 1.25 GSa/s
python3 step_kit.py measure --host SCOPE:5555 --ch 1 --band 20M --out ch1-20M.json --save-records ch1-20M.npz --csv ch1-20M
python3 step_kit.py measure --host SCOPE --ch 1 --rate 2e9      # a lower rate (the time base is found by reading it back)
python3 step_kit.py analyse ch1-20M.npz [--edge rise]           # the same arithmetic on the saved records
python3 step_kit.py report ch1.json [ch2.json ...] [--csv PREFIX]
python3 step_kit.py measure --help                              # every option
```

`SCOPE` is the scope's host name or address. The options of `measure`:

| option | default | meaning |
|---|---|---|
| `--host H[:port]` | port 5555 | the scope |
| `--ch N` | (required) | the channel the pulser is on |
| `--imp 50\|1M` | 50 where the model has it, else 1M | input impedance |
| `--band WORD` | `OFF` | the band limit word, as the scope takes it (`OFF`, `20M`, `250M`, ...) |
| `--rate R` | MHO900 4e9, DHO 1.25e9 | sample rate, Sa/s, one channel alone |
| `--depth D` | `1M` | memory depth |
| `--records N` | 8 | single acquisitions folded together |
| `--edge auto\|rise\|fall` | `auto` | the edge folded; `auto` takes the faster of the two |
| `--source-rise-ps PS` | 0 | divide out a Gaussian source edge of this 10-90 % rise |
| `--os N` | 16 | grid points a sample interval |
| `--pre-ns`, `--post-ns` | 15, 45 | the fold's window about each edge, ns |
| `--cores N` | 0 | also fold each of N interleaved ADC cores alone (skew, gain) |
| `--out`, `--save-records`, `--csv` | | the result JSON, the raw records (.npz), the CSVs |

## What `measure` does, in order

1. `*IDN?`, then `*OPC?` (the connection must answer `1`). It refuses a model it is not tested on (exit 2;
   `--untested-model` to try anyway), and `--imp 50` on a DHO, before it changes anything.
2. Reads every setting it will change: each channel's display; the test channel's impedance, probe, coupling,
   band limit, scale and offset; acquisition type, depth, time base scale and offset; trigger mode, edge source,
   edge level (on the found source and on the test channel), sweep; waveform source, mode, format, start and stop;
   and the run state.
3. Shows the test channel alone, probe 1x, DC, the band word, 0.5 V/div, normal acquisition, `--depth`, edge
   trigger on the channel in AUTO sweep, waveform RAW / WORD. It then steps the time base down the 1-2-5 ladder until
   `:ACQuire:SRATe?` reads `--rate` with the depth kept, and refuses if no time base gives both.
4. Takes one single. It must hold a free-running square: more than 20 mV, at least 10 rising edges, a period spread
   under 1 %. It refuses a record at another sample rate. It picks the edge (`--edge auto`).
5. Scales the channel so the step spans about 5 div (the 1-2-5 step at or above the step's height over 5), centres
   it with the offset, and sets the trigger to NORMal sweep at the step's middle.
6. Takes `--records` singles. It refuses a record that reaches within 0.1 div of the screen's edge (a clipped step
   is not a step), a record at another sample rate, and a record identical to the one before.
7. Folds and analyses them (METHOD.md). A falling edge is folded as the inverted record's rising edge.
8. Puts every setting back that differs, in an order the scope accepts: the test channel is shown while its scale
   and offset go back, the depth goes back while running, each trigger source gets its own level, and the channels'
   display and the run state go last. It does this twice if needed, then reads everything again and lists what still
   differs. A scope that was found stopped gets one forced single at the restored settings, so its sample-rate
   readout matches them again. That replaces the trace it was holding.

The kit never sends `*RST`, a calibration, a screenshot, a setup-block or file command. It does not touch the
built-in generator or any other channel's settings except their display. Every read has a timeout. After a timeout
it opens a new connection and sends `*IDN?` before it goes on.

### Reading the records

Each record is read as `:WAVeform:SOURce CHANnel<n>`, `:WAVeform:MODE RAW`, `:WAVeform:FORMat WORD`,
`:WAVeform:PREamble?`, then pages of 250,000 points: `:WAVeform:STARt <a>`, `:WAVeform:STOP <b>`,
`:WAVeform:DATA?` (an IEEE 488.2 definite-length block of little-endian 16-bit codes). Volts are
`(code - (yorigin + yreference)) * yincrement` and the sample interval is `xincrement`, from the preamble. Rigol's
stock MHO900 firmware answers a 1 Mpts single's RAW preamble with 250,000 points. The kit takes those at the
record's own interval, and the folds rest on about 500 edges instead of 2,000.

## Output

`--out FILE.json` (default `step-kit-<UTC stamp>.json`) holds:

- `unit`: the `*IDN?` model and firmware fields. The serial number is never recorded, in the trace either.
- `settings.asked`, `settings.read_back`: what the kit asked for, and every setting as the scope answered after
  setup, with `:ACQuire:SRATe?`.
- `snapshot`: every setting as found. `restore`: what was written back, and anything that still differs.
- `preamble`: the last record's preamble, raw and parsed. `rate`, `points_per_record`, `edge`.
- `results`: `f3db` (Hz), `rise_ps`, `overshoot_pct`, `peaking_db`, `jitter_ps`, `jitter_bound_db_at_f3`,
  `edge_30ps_db_at_f3` (what a 30 ps source edge costs there, not divided out), `edges`, `records`, `period_ns`,
  `step_v`, `square_v`, `settled_db`, `grid_ps`, `min_bin_count`, `phase_turns`, and the curves: `freqs`, `db`,
  `snr` (80 log-spaced points), `step` (the normalised step, every `step_stride` grid point, from `step_t0_ps`).
- `trace`: every SCPI line with its UTC time, seconds from the start, the answer and its latency.
- `warnings`, `refused`, `error` when they apply.

`--save-records FILE.npz` holds the raw codes with each record's preamble scale, so `analyse` gives the same numbers
bit for bit. `--csv PREFIX` writes `PREFIX-step.csv` (`time_ps,level`, 0 = base, 1 = top) and
`PREFIX-response.csv` (`freq_hz,db,snr_db`).

Exit codes: 0 measured and restored; 1 refused or failed, restored; 2 usage error, an untested model, or 50 Ohm
on a DHO; 3 the restore is incomplete (the JSON says what); 4 the scope stopped answering, nothing restored;
130 Ctrl-C, restored.

## Known limits

- **One channel of one unit.** Another channel, or another unit of the same model, can sit tens of MHz away.
  Measure each channel you care about.
- **A 1 MOhm input needs a 50 Ohm feed-through.** The result is then the feed-through and the input together, which
  is how a 1 MOhm input's bandwidth is rated.
- **The pulser's own edge is not divided out** unless you pass `--source-rise-ps` (30 for the LBE-1322). At 1 GHz a
  30 ps edge is 0.035 dB, so the -3 dB moves by well under 1 %. The JSON gives the figure at your -3 dB.
- **The falling edge is the headline.** A negative-going pulser such as the LBE-1322 steps from 0 V down to its
  level: its falling edge is its step proper. `--edge auto` picks it.
- **The two edges differ on these front ends.** On our MHO934s the rising edge read 9-25 MHz wider than the falling
  edge, with 7-8 points more overshoot, although the pulser's two edges are both 30 ps. So the front end responds
  differently to a positive and a negative step. Report which edge you folded.
- **The pulser's frequency matters.** If its period is close to a whole number of samples, or to a simple fraction,
  its edges sweep the sample interval only a few times a record. The edge fit then cannot average out the error of
  each 50 % crossing, and the -3 dB reads low. In our synthetic tests: -1.7 to -2.1 % at 1.28 GHz at 4 GSa/s with
  250,000-point records and 1.7 sweeps a record; within 1 % with 7 sweeps. The kit gives the count (`phase_turns`)
  and warns below 5. Our own runs at 1,003,017 Hz had 1.7 sweeps on the stock firmware's 250,000-point records.
- **A deeper record helps.** With 250,000 points the folds rest on about 500 edges, and the spread between runs is
  larger than with 1 Mpts.
- **A -3 dB past the record's Nyquist frequency is a fact about the front end**, measured in equivalent time. A
  single real-time record at that rate holds nothing above Nyquist.
- The band limit word is sent as given and read back. The kit does not know which words a model offers.

## Files

- `step_kit.py`: the kit.
- `test_step_kit.py`: the tests, against a fake SCPI scope on 127.0.0.1. Run them with `python3 -I test_step_kit.py`.
- `METHOD.md`: the arithmetic step by step, and the self-test results.
- `LICENSE`: MIT.

MIT licence (the SPDX line at the top of each file). Copyright (c) 2026 Olympian LED. No third-party code is
included; the erf approximation is Abramowitz and Stegun 7.1.26.
