2025, Dec 21 09:00

Stop Mislabeling DFT Spectra: Convert Bin Index k to Hertz with f = k/T and Keep Peaks at 3 Hz

Learn why a 3 Hz tone moves in DFT plots when bins are labeled as Hertz. Map the bin index with f = k/T, fix spectrum scaling, and read peaks accurately.

When a pure 3 Hz sine is sampled at 15 samples per second, a 1-second snapshot shows a spectral peak where you would expect it. Stretch that snapshot to 2 seconds, though, and a naïve plot suddenly highlights a peak at 6. Nothing in the signal changed; only the interpretation did. The mismatch comes from labeling the Discrete Fourier Transform (DFT) axis as if bin indices were Hertz. They are not. The bin index k counts how many full periods fit into the captured duration T. To convert to frequency in the time domain, you must map k to f using f = k / T.

Broken scaling in a minimal example

The example below keeps the sampling setup and DFT logic intact, but treats the DFT bin index as if it were already in Hertz. That is exactly how a 3 Hz tone, observed for 2 seconds, appears to “move” to 6 on the horizontal axis.

from sage.all import *
import numpy as np

u = var('u')

fs = 15               # samples per second
dt = 1 / fs           # time step
span_s = 2            # total captured duration in seconds
n_total = fs * span_s # number of samples

sig_fn = sin(3 * 2 * pi * u)

t_grid = []
x_vals = []

# sample the signal over the chosen duration
for tt in np.arange(0, span_s, dt):
    x_vals.append(N(sig_fn(u=tt)))
    t_grid.append(tt)

def build_dft_expr():
    acc = ''
    q = var('q')
    for n in range(0, n_total, 1):
        acc = acc + '+' + str(x_vals[n] * e^(-(i * 2 * pi * q * n) / n_total))
    return acc[1:]

dft_expr = build_dft_expr()

def eval_bin(expr, idx):
    q = var('q')
    f_callable = fast_callable(SR(expr), vars=[q])
    return f_callable(idx)

k_idx = []
mag_vals = []

# evaluate DFT magnitude across bins
for kk in np.arange(0, n_total, 1):
    val = eval_bin(dft_expr, kk)
    k_idx.append(kk)
    mag_vals.append(N(abs(val)))

# WRONG frequency axis: treating bin index as Hertz
freq_axis_wrong = k_idx

What really happens in the DFT bins

The DFT basis function at bin k is a complex sinusoid that completes k cycles over the N samples. With a total captured duration T, those N samples span T seconds. Substitute t = n · Ts with Ts = T / N into a sine of frequency f and compare to the DFT kernel e^(−j 2π k n / N). This yields the relationship f = k / T. The lowest nonzero frequency the DFT can represent over that window is therefore 1 / T, not 1 Hz unless T = 1 s. When T = 2 s, the first nonzero frequency bin is at 0.5 Hz; a 3 Hz tone sits at k = f · T = 3 · 2 = 6. If you label the horizontal axis by raw k, a longer window simply shows a larger k because more complete cycles fit in the longer record.

Fix: map DFT bin index to Hertz

The correction is to scale the horizontal axis by the total duration. The bin index k is in “periods per record,” and converting to Hertz requires dividing by T. That is all that’s needed; the computation itself is fine.

from sage.all import *
import numpy as np

u = var('u')

fs = 15               # samples per second
dt = 1 / fs           # time step
span_s = 2            # total captured duration in seconds
n_total = fs * span_s # number of samples

sig_fn = sin(3 * 2 * pi * u)

t_grid = []
x_vals = []

# sample the signal over the chosen duration
for tt in np.arange(0, span_s, dt):
    x_vals.append(N(sig_fn(u=tt)))
    t_grid.append(tt)

def build_dft_expr():
    acc = ''
    q = var('q')
    for n in range(0, n_total, 1):
        acc = acc + '+' + str(x_vals[n] * e^(-(i * 2 * pi * q * n) / n_total))
    return acc[1:]

dft_expr = build_dft_expr()

def eval_bin(expr, idx):
    q = var('q')
    f_callable = fast_callable(SR(expr), vars=[q])
    return f_callable(idx)

k_idx = []
mag_vals = []

# evaluate DFT magnitude across bins
for kk in np.arange(0, n_total, 1):
    val = eval_bin(dft_expr, kk)
    k_idx.append(kk)
    mag_vals.append(N(abs(val)))

# CORRECT frequency axis in Hertz
df_hz = 1.0 / span_s
freq_axis_hz = [kk / span_s for kk in k_idx]

With this mapping, the 3 Hz tone appears at 3 Hz regardless of whether T is 1 s, 2 s, or 3 s. The apparent “doubling” or “tripling” at k = 6 or k = 9 is expected in bin index space because the longer window contains 6 or 9 full cycles, respectively. In Hertz, the peak is stationary at 3 Hz.

Why it matters

DFT outputs are indexed by cycle count over the observation window, not by absolute time. Unless you explicitly carry timing information into your frequency axis, any plot will reflect “periods per record” rather than “periods per second.” That is why feeding only the samples into a transform is insufficient to recover frequency units; the mapping to Hertz depends on the duration T and it must be applied when you label or interpret the spectrum. Longer records still help: the spacing between resolvable spectral lines is 1 / T, so increasing T gives you finer frequency spacing without moving the true tone in Hertz—once the axis is scaled properly.

Wrap-up

If you see a spectral peak “moving” from 3 to 6 to 9 when you lengthen the record, the computation is fine and the plot is mislabeled. DFT bin k is the count of periods across the captured duration T. Convert to physical frequency with f = k / T and the peak will sit at 3 Hz, as it should. Keep the duration alongside the samples, and always scale the horizontal axis by 1 / T when you want Hertz.