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

# How step_kit measures a channel's absolute response

This page gives the arithmetic of `step_kit.py`, step by step, with the defaults `measure` runs. The function
names are the ones in `step_kit.py`. The last sections give the self-test results and what we know limits the
method. `step_kit.py selftest` reruns every case below.

## 1. The records

`measure` takes `--records` (8) single acquisitions of the pulser's square, with the channel alone at the full rate
(MHO900 4 GSa/s, DHO 1.25 GSa/s). Each record is read as 16-bit codes and turned into volts with its own preamble:

    v = (code - (yorigin + yreference)) * yincrement,   dt = xincrement

A falling edge (`--edge fall`, or `auto` when it is the faster) is analysed as the rising edge of the inverted
record, `-v`. Every figure below is then that edge's.

`--edge auto` (`fast_edge`): at the 50 % level of the record's two levels, the median rise per sample over the
upward crossings and over the downward ones; the larger is the fast edge.

## 2. The square's two levels (`square_levels`)

`p1` and `p99` are the record's 1st and 99th percentiles, and `mid = (p1 + p99) / 2`. `lo` is the median of the
samples below `mid`, `hi` the median of those at or above it.

## 3. The 50 % crossings (`rising_edges`)

Each sample is marked low below `lo + 0.3 (hi - lo)` and high above `lo + 0.7 (hi - lo)`. Every low-to-high turn
of the marks is one edge, so noise near the middle cannot make two. Within it, the last sample pair that crosses
`(lo + hi) / 2` upward gives the crossing time by linear interpolation:

    t = k + (mid - v[k]) / (v[k+1] - v[k])          (in samples)

## 4. The edge-time fit (`fit_edges`)

At least 8 edges are needed. Each edge gets its cycle number `n = round((t - t[0]) / P0)`, with `P0` the median
spacing; an edge that shares its cycle with the one before (a false crossing) is dropped. The times are fitted by
least squares as

    t = t0 + n P + q (n - n_mean)^2

The `q` term takes a slow drift between the pulser's clock and the scope's. Edges with a residual over 5 sigma are
dropped and the fit is done once more. The fitted times `T`, not the measured crossings, place the samples: each
crossing carries an error that depends on where the edge fell between two samples, and the fit averages it out over
the record.

## 5. The window, and what is refused

The fold covers `pre` = 15 ns before each edge and `post` = 45 ns after it (`--pre-ns`, `--post-ns`). The kit
refuses a record whose window is more than 0.45 of the pulser's period.

## 6. The fold (`fold_step`)

For every edge and every sample `j` within the window, `tau = j - T` (samples) is the sample's time from the edge.
Its bin on the grid of `1 / os` sample (`os` = 16) is `floor((tau + pre) os)`. Per bin, over all edges of all
records, the kit keeps the sum of the values, the sum of the `tau`s and the count.

If any bin is empty, the kit refuses: the pulser's period is locked to the sample clock (all edges at a few phases),
or the records hold too few edges. Change the pulser's frequency by a few percent.

## 7. The step on a regular grid (`fold_wave`)

Each bin's mean value is placed at its mean time, not at its centre: a bin's samples need not be spread across it.
The step `y` is these points linearly interpolated onto the bin centres, `-pre + (i + 0.5) / os` samples. The grid
step is `dtau = dt / os`: 15.6 ps at 4 GSa/s, 50 ps at 1.25 GSa/s.

## 8. Normalised step, rise time, overshoot

- `base` is the mean of `y` over the first half of the pre-edge bins; `top` the mean over the last 1/8 of the
  window. The normalised step is `ys = (y - base) / (top - base)`.
- **Rise time**: the first grid points at which `ys` reaches 0.1 and 0.9, each by linear interpolation; their
  difference times `dtau`.
- **Overshoot**: `(max(ys) after the edge - 1) x 100 %`.
- `step_v = top - base` against `square_v = hi - lo` (mean over the records) gives `settled_db`: how far the step's
  settled level within the window sits from the square's levels.

## 9. The response, |H(f)|

- The impulse response is the first difference `h = diff(y)`.
- The window `w` is 1, with its last quarter a half-cosine taper to 0.
- `H = FFT(h w)`, zero-padded to the next power of two at or above 4 `len(h)`.
- A first difference on the grid is a box-car of one grid step, so `|H|` is divided by `|sinc(f dtau)|`.
- The level is relative to DC: `|H(0)| = |sum(h w)|`. So `db = 20 log10(|H| / |sinc(f dtau)| / |H(0)|)`.
- With `--source-rise-ps tr`, a Gaussian edge of that 10-90 % rise is divided out:
  `db -= -10 log10(e) (2 pi f tr / 2.563)^2`.
- The kit reports 80 points (`--points`) spaced evenly in log f, from `2 / (pre + post)` (33 MHz) to `0.4 / dtau`
  (25.6 GHz at 4 GSa/s), each at the first FFT bin at or above it.

## 10. The noise floor and SNR

When the window is under 0.2 of the period, the same fold is also made at `T + P / 4`: inside the flat top, away
from both edges. Its difference, window, FFT and sinc division are the step's. Its power is smoothed over 9 FFT
bins, and `snr = 20 log10(|H| / floor)` at each point.

## 11. The -3 dB rule (`summarise`)

1. Keep the points with an SNR of at least 10 dB.
2. Levels are relative to 0 dB: the step's own DC level.
3. The corner is the first point at or below -3 dB that is not a one-bin dip. A dip is a point more than 1 dB below
   both its neighbours: a spur, not the corner.
4. The -3 dB frequency is interpolated linearly in dB against log f between that point and the one before it.
5. Peaking is the highest level up to the -3 dB frequency.

## 12. The jitter bound

Each edge's own time is fitted against the folded step: one Gauss-Newton step of a shift-only fit over its samples
from 1.5 ns before to 3 ns after it. The spread of these shifts (`jitter_ps`) holds the pulser's and the sample
clock's jitter, and the noise's share: it is an upper bound of what smears the fold. A Gaussian smear of that width
costs, at the -3 dB frequency,

    jitter_bound_db_at_f3 = -10 log10(e) (2 pi f3 jitter)^2

On our units this was under 0.01 dB.

## 13. The phase sweeps (`phase_turns`)

This is a diagnostic beside the analysis; it changes none of its numbers. With `m` edges of period `P` samples in
a record, and for each `q` from 1 to 8,

    turns_q = m |q P - round(q P)|

is how many times the edges sweep the sample interval in one record (a period near `N + p/q` samples puts the
edges in `q` clusters that move together). `phase_turns` is the smallest over `q` and over the records. Below 5,
`measure` and `report` warn. See "The pulser's frequency" below.

## 14. Interleaved cores (`--cores N`)

Optional. Each of `N` cores (sample index modulo `N`) is folded alone on a grid of `1 / (os / 2)` sample. Each
core's 50 % crossing against the mean of all cores gives its sample-time skew (`sample_late_ps`: a core that
samples late sees the step early). Its height against the mean height gives `height_db`.

## Self-test results

`python3 step_kit.py selftest` makes synthetic pulser records through known responses and runs the analysis on
them. The records carry white noise (1.5 mV rms), 12-bit quantisation over 8 div, 2 ps rms edge jitter and a 2 ppm
drift between the clocks. Each -3 dB case must read within 1 %. These are the results of the kit as committed:

| case | true -3 dB | read | error | rise | edges |
|---|---|---|---|---|---|
| single pole, 1.25 GSa/s, 4 x 400,000 points | 400 MHz | 397.6 MHz | -0.60 % | 876.0 ps | 1750 |
| Gaussian, 1.25 GSa/s (Nyquist 625 MHz) | 900 MHz | 893.7 MHz | -0.70 % | 385.0 ps | 1750 |
| single pole, 1.25 GSa/s, one core of four 10 ps late | 300 MHz | 298.7 MHz | -0.45 % | 1167.3 ps | 1750 |
| Gaussian, 4 GSa/s, 8 x 250,000 points, pulser 1,092,233 Hz | 1300 MHz | 1290.2 MHz | -0.75 % | 262.8 ps | 547 |
| falling edge (Gaussian 900 MHz; rise a 150 MHz pole), inverted | 900 MHz | 893.9 MHz | -0.67 % | 385.3 ps | 1748 |

- The 10 ps late core was found 9.4 ps late.
- `--edge auto` picked the falling edge of the pulser whose fall is the fast edge.
- A pulser whose period is exactly 1000 samples was refused: 1125 of 1200 grid bins never sampled.
- The same 1.3 GHz case at 1,003,017 Hz gave 2.05 phase turns a record and read 1281.5 MHz (-1.42 %). The kit warned.

Every case reads a little low, by 0.45-0.75 %.

## The pulser's frequency

Our runs used 1,003,017 Hz. On our units its period was 3,987.97 samples at 4 GSa/s (1,993.99 at 2 GSa/s, 996.99 at
1 GSa/s): at 4 GSa/s, 0.027 of a sample from a whole number. In a 250,000-point record, which is what the stock firmware gives of a
1 Mpts single, its edges swept the sample interval only 1.7 times. With 1 Mpts records the same frequency gives 6.8
sweeps.

With few sweeps the edge fit (step 4) cannot average out the error of each 50 % crossing, because that error then
changes slowly from edge to edge, and part of it goes into the fitted times. Synthetic records at that same period
(Gaussian responses, three runs each with their own noise and start phases):

| rate | points a record | true -3 dB | read | phase turns |
|---|---|---|---|---|
| 4 GSa/s | 250,000 | 1070 MHz | -0.84 to -0.35 % | 1.7 |
| 4 GSa/s | 250,000 | 1280 MHz | -2.11 to -1.67 % | 1.7 |
| 4 GSa/s | 1,000,000 | 1220 MHz | -0.63 to +0.01 % | 6.8 |
| 4 GSa/s | 1,000,000 | 1370 MHz | -0.41 to -0.27 % | 6.8 |
| 2 GSa/s | 250,000 | 1250 MHz | -4.19 to -2.88 % | 1.5 |
| 2 GSa/s | 1,000,000 | 820 MHz | -0.76 to -0.55 % | 6.8 |
| 1 GSa/s | 250,000 | 400 MHz | -1.20 to -0.76 % | 1.6 |
| 1 GSa/s | 1,000,000 | 400 MHz | -0.60 to -0.54 % | 6.8 |

So the error grows with the -3 dB frequency against the sample rate, and shrinks with more sweeps. The kit counts
the sweeps (`phase_turns`, step 13) and warns below 5. The exact period depends on both clocks to a few ppm, so no
frequency can be chosen for every unit in advance: if the kit warns, change the pulser's frequency by a few hundred
Hz and run again.

## What is not divided out

- The pulser's own edge, unless `--source-rise-ps` is given. A 30 ps Gaussian edge is 0.035 dB down at 1 GHz.
- The cable, and on a 1 MOhm input the feed-through: they are part of what is measured.
- The jitter: the kit gives its bound and does not correct for it.
