rm3100/characterize.py

1300 lines
64 KiB
Python
Raw Normal View History

2026-08-19 23:00:47 -04:00
#!/usr/bin/env python3
"""Characterise an RM3100 capture: noise floor, spectrum, stability.
./.venv/bin/python characterize.py capture_60s.csv
Produces a four-panel figure and a text summary:
Amplitude spectral density nT/sqrt(Hz) against the 1.2 nT/sqrt(Hz) the
manual quotes (Table 3-1), and against the
white-noise level implied by the sample sd.
Allan deviation where averaging stops helping and drift takes
over -- the honest measure of a noise floor.
Residual distribution after removing a slow trend, so a non-Gaussian
tail or quantisation shows up.
Read latency host-side diagnostics only -- the measurement
grid itself is uniform regardless.
Three further figures are written beside it, each answering something the four
panels collapse away:
_spectrogram.png whether a feature lasted the run or was a burst.
_drift.png how far the chip's oscillator moved against the
host clock, and -- from the two-sample deviation
of the rate -- how much of that is real. These
parts drift 186-1241 ppm against a floor near
10 ppm, so it is a signal by a wide margin.
_timebase.png which clock each line is coherent on. A line at a
fixed frequency sharpens when the samples are
placed by the host clock; one locked to the
sampling is wrecked by it, which settles from a
single capture what compare.py needs two rates to
decide. Diagnostic only: every other figure here
uses the chip's grid, because neither time base is
right for both kinds of line.
Two noise figures are printed, and the difference between them matters. `sd` is
the plain standard deviation, which on a drifting capture measures the drift:
one run here reads 167.9 nT of sd on an axis whose actual noise is 12.1 nT.
`white sd` is sd(diff)/sqrt(2), a first difference that rejects anything slower
than the sample rate, and is what Table 3-1's 208/sqrt(cycle_count) should be
compared against.
`--trim` drops equal time from both ends. A capture usually starts while the
sensor is still settling and ends while it is being handled for the next run,
and neither belongs in a noise figure. Nothing is dropped unless asked.
The sample grid is exact, not assumed: the sensor samples on its own internal
schedule (manual sections 5.7.2, 5.8.2), logger.py writes a placeholder rather
than skip a grid point, and capture.py refuses a non-contiguous sample index.
Frequencies use the rate measured against the host clock, not the nominal table
value, which is ~6% out on this unit.
Rows lost during capture carry no data and are linearly interpolated on load, so
a capture with many of them will read as artificially smooth at high frequency.
capture.py's summary reports how many, and characterize.py prints it above.
2026-08-19 23:00:47 -04:00
"""
import argparse
import math
2026-08-19 23:00:47 -04:00
import sys
from collections import namedtuple
2026-08-19 23:00:47 -04:00
import matplotlib
matplotlib.use("Agg")
import matplotlib.colors as mcolors
2026-08-19 23:00:47 -04:00
import matplotlib.pyplot as plt
import matplotlib.ticker as mticker
2026-08-19 23:00:47 -04:00
import numpy as np
import capture
import rm3100
2026-08-19 23:00:47 -04:00
SURFACE = "#fcfcfb"
TEXT_PRIMARY = "#0b0b0b"
TEXT_SECONDARY = "#52514e"
GRID = "#e3e2df"
REFERENCE = "#8a8880"
# Categorical slots 1-3; three peer axes, validated all-pairs in light mode.
AXES = [("x", "X", "#2a78d6"), ("y", "Y", "#eb6834"), ("z", "Z", "#1baf7a")]
# Total field is derived from the three axes rather than a fourth peer, so it
# takes neutral ink instead of a competing hue -- which also keeps the
# categorical set at the three slots that validate.
TOTAL = ("total", "Total", TEXT_PRIMARY)
SERIES = AXES + [TOTAL]
2026-08-19 23:00:47 -04:00
# Table 3-1: "Noise Density @ Max. Single-Axis Sample Rate".
SPEC_ASD_NT = 1.2
# Every panel is drawn on a fixed scale so two runs can be laid side by side and
# compared by eye. Autoscaling defeats that completely: a quieter capture simply
# redraws its own axis and looks identical to a noisy one. The ranges are wide
# enough for any configuration this driver offers -- cycle counts 50 to 800, so
# rates from ~2 to ~530 Hz and LSBs from 3 to 50 nT. Where an axis is
# logarithmic the range is whole decades, so the tick labels are powers of ten.
#
# The ASD frequency axis is the exception: linear, not logarithmic. A log axis
# gives the sub-hertz drift most of the width and crams every line worth naming
# -- mains, its harmonics, fs/4 -- into the last fifth. Linear spaces them
# evenly, which is how a spectrum is read for lines. It costs the low-frequency
# decades, which the Allan panel beside it covers better anyway, and it cuts off
# above 100 Hz: at cycle count 100 that leaves out 100-150 Hz, where nothing but
# the noise floor has ever appeared.
ASD_HZ = (0.0, 100.0)
ASD_NT = (5e-1, 5e1) # floor ~1 nT/rtHz, lines to ~15
ALLAN_TAU_S = (1e-3, 1e3) # one sample period to a 1000 s average
ALLAN_NT = (5e-1, 5e1)
RESIDUAL_NT = (-150.0, 150.0) # +/-6 LSB at cycle count 100
RESIDUAL_DENSITY = (0.0, 0.045)
LATENCY_MS = (0.0, 20.0) # covers 1.9 ms at cc=50 to 13.7 ms at cc=400
LATENCY_COUNT = (0.8, 1e5)
# compare.py plots the same two quantities normalised by |B|, so they get their
# own fixed ranges in ppm. Same reasoning: two invocations should overlay. Its
# frequency axis stays logarithmic: that figure overlays captures whose rates
# differ several-fold, and log is what puts them on comparable footing.
FRACTIONAL_ASD_PPM = (1e0, 1e3)
FRACTIONAL_ALLAN_PPM = (1e0, 1e3)
FRACTIONAL_ASD_HZ = (1e-2, 1e3)
# Spectrogram segment length. Longer than SEGMENT_SECONDS because the job here
# is to resolve one line from another -- 40 s gives 0.025 Hz, enough to separate
# a mains line from a sample-locked one even when they land close together --
# and a ~300 s capture still yields ~100 columns at this overlap.
SPECTROGRAM_SECONDS = 40.0
# 31/32. Overlapping segments are not independent, so this buys no new
# information -- what it buys is columns: the hop is 1.25 s instead of 5 s, so a
# feature that lasts a few seconds is drawn as a few columns rather than falling
# between two. It costs only render time, and the axes are wide enough to show it.
SPECTROGRAM_OVERLAP = 0.96875
# Spectrograms are drawn in ppm of |B| per sqrt(Hz), not nT/sqrt(Hz), for the
# same reason compare.py's figures are: a capture whose gain is 7% lower records
# 7% fewer nanotesla of the same noise, and a colour scale shared across captures
# would show that as a quieter sensor.
#
# Sharing a colour scale also requires the *rendering* to be identical, which is
# easier to get wrong. Two things are pinned below to make it so:
#
# SPECTROGRAM_BIN_HZ one displayed frequency step, reached by averaging
# native bins. Since the window is a fixed *duration*,
# native resolution is the same for every capture, so a
# fixed step means the same number of bins averaged and
# therefore the same degrees of freedom per cell. This
# is the one that matters: dof is what sets how widely
# cells scatter, and so where the colour distribution
# sits. Before it, a 150 Hz capture had 6.6x more bins
# averaged into each pixel than a 38 Hz one, its speckle
# was suppressed, and that read as a lower noise level
# when nothing differed.
#
# A single fixed frequency axis would remove the last of it -- the ~1.8x
# difference in how many binned rows still fall in one pixel -- but only by
# drawing a 38 Hz capture into the bottom quarter of a 160 Hz axis and leaving
# three quarters blank. `--max-freq` does that for anyone who wants it; the
# default is each capture's own Nyquist, which is worth the residual.
#
# The range spans the floor (~30 ppm/rtHz here) to a line an order of magnitude
# above it.
SPECTROGRAM_PPM = (15.0, 200.0)
SPECTROGRAM_BIN_HZ = 0.1 # 4 native bins at a 40 s window: 8 dof per cell
# Turbo by default: a spectrogram is read for narrow lines against a broad floor,
# and hue steps make those jump out in a way a single hue cannot. Turbo is the
# right rainbow to reach for -- it is jet without the sharp lightness kinks that
# fake banding.
#
# It is worth being accurate about what it is not. Measured over its own 256
# steps, turbo's L* runs 12 to 91 but is *not* monotonic: it climbs to a bright
# yellow mid-scale then darkens into red, and 129 of 255 steps go down. Jet is
# 108 of 255 with a worse worst-case step, so turbo is smoother, not ordered.
# Neither survives colour vision deficiency well. Two alternatives are a flag
# away: `viridis`, which is genuinely monotonic (0 of 255 steps decrease), and
# `sequential`, the single-hue ramp below (L* 97 to 17, hue 213 +/- 1 deg).
SPECTROGRAM_COLORMAP = "turbo"
SEQUENTIAL_STEPS = ["#f4f8fd", "#cfe0f5", "#93bbe9", "#4e8ddb",
"#2a78d6", "#1b5091", "#0e2a4d"]
# Lower edge of the band every broadband figure is quoted over. Below a few Hz
# the spectrum is drift, not noise floor, and it varies far more between runs
# than the floor does -- so including it would compare environments rather than
# sensors.
BAND_LO_HZ = 3.0
# Upper edge, as a fraction of Nyquist. This has to stay clear of the corner of
# any filter a compared series has been through, or the comparison scores one
# path partway down a rolloff and reads it as a quieter sensor. The binding one
# is decimation: DECIMATE_CUTOFF_FRACTION is where that filter turns over, so
# BAND_NYQUIST_FRACTION / 2 is held below it with margin to spare.
BAND_NYQUIST_FRACTION = 0.8
# Welch segment duration. Frequency resolution is 1/this, so 20 s of segment
# resolves 0.05 Hz -- fine against a band starting at 3 Hz -- and a 5-minute
# capture still yields ~30 overlapping segments to average.
SEGMENT_SECONDS = 20.0
# A sample-locked tone is reported at this many standard errors. Under the null
# the statistic is Rayleigh(1), so 5 is a one-in-270,000 bin and stays clear of
# the ~20 fractions tested. Real artefacts here reach 30.
LINE_SIGMA = 5.0
# Drift figure. The left panel fits the rate on windows of DRIFT_WINDOW_S; the
# right sweeps that window length to show what it costs. 10 s is a compromise
# and not the optimum -- sigma_y bottoms out nearer 2 s on these parts -- but 2 s
# windows put 145 points on a 290 s capture, which draws as a band rather than a
# trend. At 10 s there are ~29, the floor is still only 13-34 ppm, and the drift
# being plotted is 186-1241 ppm, so the error bars stay a minor part of the span.
DRIFT_WINDOW_S = 10.0
# Log-spaced, from a window that holds a few hundred samples to a third of a
# 300 s capture -- past that there are fewer than three windows and `rate_allan`
# drops the point rather than estimate a deviation from one difference.
RATE_TAUS = (1.0, 1.5, 2.0, 3.0, 5.0, 7.0, 10.0, 15.0,
20.0, 30.0, 50.0, 70.0, 100.0)
DRIFT_PPM = (-800.0, 800.0) # the worst run here spans 1241 ppm peak to peak
RATE_TAU_S = (1.0, 1e2)
RATE_ALLAN_PPM = (1e0, 1e3)
# Time-base figure. The host clock is fitted against sample index by a
# polynomial of this degree: high enough to follow a warm-up transient, low
# enough that it cannot chase the per-read scheduling jitter, which carries no
# information about the chip. Degree 1 is exactly the uniform grid already in
# use, and nothing above 3 changed any measured amplitude on these captures --
# consistent with `calibrate.rate_allan`, which finds nothing above the noise
# below a ~2 s timescale.
TIMEBASE_DEGREE = 3
# Half-width of the scan around each line, and how finely it is sampled. A
# 290 s capture resolves 1/T = 0.0034 Hz, so +/-0.05 Hz is ~30 resolution widths
# -- wide enough to show the shape of a peak and whether it moved, narrow enough
# that the peak is not a single pixel.
TIMEBASE_SPAN_HZ = 0.05
TIMEBASE_POINTS = 401
TIMEBASE_PANELS = 3
# A verdict needs a peak that is actually a peak: this is how far the scan's
# maximum must stand above the scan's own median before the ratio of the two
# time bases means anything.
#
# Local contrast rather than significance against the broadband noise, because
# that is the question. A line can sit at a respectable sigma across the whole
# band and still have no resolvable peak in a 0.1 Hz window -- the 60 Hz alias
# in |B| at cycle count 400 does exactly that, ranking third at 9 sigma while
# its scan is visibly flat -- and comparing where two flat curves happen to peak
# is comparing noise. 3x is a little above what a smooth curve reaches by
# chance and well below the 4-6x a real line here shows.
TIMEBASE_MIN_CONTRAST = 3.0
def band_for(fs):
"""The (lo, hi) analysis band for a capture sampled at fs."""
return BAND_LO_HZ, BAND_NYQUIST_FRACTION * fs / 2
2026-08-19 23:00:47 -04:00
def welch_asd(v, fs, nperseg=4096):
"""Amplitude spectral density in units/sqrt(Hz) via Welch's method."""
nperseg = min(nperseg, len(v) // 4 * 2 or len(v))
step = nperseg // 2
window = np.hanning(nperseg)
# Normalisation for a one-sided PSD with this window.
scale = 1.0 / (fs * (window ** 2).sum())
segments = []
for start in range(0, len(v) - nperseg + 1, step):
seg = v[start:start + nperseg]
# Linear detrend: removes DC and any slow ramp that would smear
# energy across the low-frequency bins.
seg = seg - np.polyval(np.polyfit(np.arange(nperseg), seg, 1),
np.arange(nperseg))
spectrum = np.abs(np.fft.rfft(seg * window)) ** 2 * scale
spectrum[1:-1] *= 2.0 # fold negative frequencies
segments.append(spectrum)
psd = np.mean(segments, axis=0)
freqs = np.fft.rfftfreq(nperseg, 1.0 / fs)
return freqs[1:], np.sqrt(psd[1:]) # drop DC bin
def allan_deviation(v, fs, points=40):
"""Overlapping Allan deviation of the signal against averaging time tau."""
n = len(v)
max_m = n // 4
ms = np.unique(np.geomspace(1, max(max_m, 2), points).astype(int))
taus, devs = [], []
cumulative = np.concatenate([[0.0], np.cumsum(v)])
for m in ms:
# Bin means of length m, taken at every offset (overlapping).
means = (cumulative[m:] - cumulative[:-m]) / m
diffs = means[m:] - means[:-m]
if diffs.size < 2:
continue
taus.append(m / fs)
devs.append(np.sqrt(0.5 * np.mean(diffs ** 2)))
return np.array(taus), np.array(devs)
def white_sd(v):
"""Per-sample noise with drift removed, in the units of v.
The first difference of white noise has twice its variance, so dividing by
sqrt(2) recovers the original sd -- while any component slower than the
sample rate is differenced away. On a capture that drifts, this and the
plain sd measure entirely different things, and only this one is the noise.
It is not free: differencing has a +6 dB/octave response, so a narrowband
line near Nyquist is weighted about twice as heavily as the broadband floor.
Use band_stats for a figure that is flat across the band.
"""
if len(v) < 2:
return 0.0
return float(np.diff(v).std() / np.sqrt(2))
def segment_length(fs, seconds=SEGMENT_SECONDS):
"""Welch segment length in samples for a fixed segment *duration*.
Holding the duration fixed rather than the sample count is what makes two
captures at different rates comparable. A fixed nperseg gives them different
frequency resolutions and, worse, different numbers of segments to average:
the median of a Welch estimate is biased low by an amount that depends on
that count, so a slower capture would read as a quieter one by a few percent
for no physical reason at all.
"""
return max(64, int(round(fs * seconds)))
def band_stats(v, fs, band=None, nperseg=None):
"""Broadband figures over a stated band: (median ASD, RMS, peak, peak Hz).
The median is the floor -- robust to the handful of bins a line occupies --
while the RMS integrates everything in the band including those lines, so
the two together say how much of the band is line rather than floor.
"""
lo, hi = band if band is not None else band_for(fs)
freqs, asd = welch_asd(v, fs, nperseg=nperseg or segment_length(fs))
keep = (freqs >= lo) & (freqs <= hi)
if not keep.any():
nan = float("nan")
return nan, nan, nan, nan
f, a = freqs[keep], asd[keep]
return (float(np.median(a)), float(np.sqrt(np.trapezoid(a ** 2, f))),
float(a.max()), float(f[np.argmax(a)]))
def fir_lowpass(cutoff, ntaps):
"""Linear-phase low-pass FIR: a sinc truncated by a Blackman window.
`cutoff` is in cycles per sample (0 to 0.5). Written out rather than taken
from scipy, which is not a dependency here and would be a large one for one
filter. Blackman buys a -74 dB stopband for a transition band of about
5.5/ntaps, which is the trade that matters: the whole point of the filter is
that what it stops does not fold back.
Normalised to unit gain at DC, so decimating does not rescale the field.
"""
if not 0 < cutoff < 0.5:
raise ValueError(f"cutoff {cutoff} must be in (0, 0.5) cycles/sample")
ntaps = int(ntaps) | 1 # odd, so the delay is a whole sample
if ntaps < 3:
raise ValueError(f"ntaps {ntaps} is too short to filter anything")
n = np.arange(ntaps) - (ntaps - 1) / 2
h = 2 * cutoff * np.sinc(2 * cutoff * n) * np.blackman(ntaps)
return h / h.sum()
# Where the anti-alias filter turns over, as a fraction of the decimated rate.
# It must sit above BAND_NYQUIST_FRACTION / 2 so that nothing compared is
# scored on the rolloff, and below 0.5 so that nothing folds back. The
# transition band left over is (0.5 - this)/k, and Blackman gives about
# 5.5/ntaps, so the tap count has to grow with k: 128*k+1 leaves roughly 2x
# margin on that and is odd by construction.
DECIMATE_CUTOFF_FRACTION = 0.45
assert BAND_NYQUIST_FRACTION / 2 < DECIMATE_CUTOFF_FRACTION < 0.5
def decimate(v, k, method="fir"):
"""Downsample by an integer k, low-passing first so nothing folds in.
Two filters, because they answer different questions:
boxcar average k consecutive samples. This is what the sensor itself
does over a cycle count, so it is the right comparison for "would
a slower cycle count have given me this?" -- and it shares the
boxcar's poor stopband, which is why the sensor aliases.
fir a proper anti-alias filter. This is what you would actually do in
post, and it is the one that removes out-of-band interference
instead of folding it.
Returns the decimated series; the new rate is fs/k.
"""
k = int(k)
if k < 1:
raise ValueError(f"decimation factor {k} must be at least 1")
if k == 1:
return np.asarray(v, dtype=float)
v = np.asarray(v, dtype=float)
if method == "boxcar":
n = len(v) // k * k
if n < k:
raise ValueError(f"{len(v)} samples cannot be decimated by {k}")
return v[:n].reshape(-1, k).mean(axis=1)
if method != "fir":
raise ValueError(f"unknown decimation method {method!r}")
h = fir_lowpass(DECIMATE_CUTOFF_FRACTION / k, 128 * k + 1)
if len(v) <= 2 * len(h):
raise ValueError(
f"{len(v)} samples is too few for a {len(h)}-tap filter; "
f"decimating by {k} needs at least {2 * len(h) + 1}")
# Discard a full filter length at each end rather than half: 'same' pads
# with zeros, so the taper reaches ntaps//2 in and a half-length trim would
# leave the tail of it in the data.
return np.convolve(v, h, mode="same")[len(h):-len(h)][::k]
SampleLine = namedtuple("SampleLine", "numerator period amplitude sigma")
def sample_locked_lines(v, max_period=8, threshold=LINE_SIGMA):
"""Tones sitting at a simple rational fraction of the sample rate.
Returns SampleLine(numerator, period, amplitude, sigma) for each surviving
fraction numerator/period of fs, strongest first. `amplitude` is the peak
amplitude of the tone in the units of v.
What this finds that a spectrum cannot distinguish is *what a line is locked
to*. A line at a fixed frequency lands on a different fraction of fs when
the rate changes; one that stays at fs/4 across captures whose rates differ
is locked to the sampling, so it is an artefact of the measurement rather
than a field. Comparing two captures settles it -- see compare.py.
Evaluating the transform at exactly j/period rather than reading a spectrum
puts the whole capture behind one number, which is what makes a 1 nT tone
detectable at 30 sigma under 20 nT of broadband noise.
Only fractions in lowest terms are tested, so each frequency is reported
once. Without that, a period-4 pattern would also be reported at 6 and 8,
which are multiples of it and carry no additional information.
A cubic trend is removed first, so drift cannot leak into a low fraction.
"""
v = np.asarray(v, dtype=float)
if len(v) < 4 * max_period:
return []
index = np.arange(len(v))
v = v - np.polyval(np.polyfit(index, v, 3), index)
sd = v.std()
if sd == 0:
return []
found = []
for period in range(2, max_period + 1):
for numerator in range(1, period // 2 + 1):
if math.gcd(numerator, period) != 1:
continue
if 2 * numerator == period:
# Nyquist has no phase to fit -- it is a real alternation, so
# one degree of freedom rather than two, and a tighter null.
amplitude = abs(float((v * (-1.0) ** index).mean()))
sigma = amplitude / (sd / np.sqrt(len(v)))
else:
phase = np.exp(-2j * np.pi * numerator / period * index)
amplitude = 2 * abs(complex((v * phase).mean()))
# Real and imaginary parts each have variance 2 sd^2 / len, so
# the magnitude is Rayleigh(1) in these units under the null:
# 5 sigma is a one-in-270,000 fluctuation.
sigma = amplitude / (sd * np.sqrt(2 / len(v)))
if sigma >= threshold:
found.append(SampleLine(numerator, period, float(amplitude),
float(sigma)))
return sorted(found, key=lambda line: -line.sigma)
def spectrogram(v, fs, seconds=SPECTROGRAM_SECONDS, overlap=SPECTROGRAM_OVERLAP):
"""(times, freqs, ASD) -- how the spectrum of a series evolves.
The Welch average that `band_stats` reports is this, collapsed along time.
Keeping the time axis is what distinguishes a line that was there all along
from a burst that a single average would smear into a raised floor, and it
is the only view that shows the difference at a glance.
Segments are long and heavily overlapped: the point here is frequency
resolution fine enough to separate a mains line from a sample-locked one, and
at these capture lengths there are plenty of samples to spend on it.
"""
nperseg = max(64, int(round(fs * seconds)))
if len(v) < nperseg:
raise ValueError(f"{len(v)} samples is shorter than one "
f"{seconds:g} s window ({nperseg} samples)")
step = max(1, int(round(nperseg * (1.0 - overlap))))
window = np.hanning(nperseg)
scale = 1.0 / (fs * (window ** 2).sum())
index = np.arange(nperseg)
starts = range(0, len(v) - nperseg + 1, step)
columns, times = [], []
for start in starts:
seg = v[start:start + nperseg]
seg = seg - np.polyval(np.polyfit(index, seg, 1), index)
spectrum = np.abs(np.fft.rfft(seg * window)) ** 2 * scale
spectrum[1:-1] *= 2.0
columns.append(np.sqrt(spectrum[1:]))
times.append((start + nperseg / 2) / fs)
freqs = np.fft.rfftfreq(nperseg, 1.0 / fs)[1:]
return np.array(times), freqs, np.array(columns).T
def bin_frequency(freqs, asd, step=SPECTROGRAM_BIN_HZ):
"""Average a spectrogram onto a fixed frequency step. Returns (freqs, asd).
Averaging in *power* -- the array is already amplitude, so it is squared and
rooted around the mean -- because that is what adds degrees of freedom. The
point is not to save pixels but to give every capture the same dof per cell:
the window is a fixed duration, so native resolution is identical across
captures, and a fixed step therefore averages an identical number of bins.
"""
width = max(1, int(round(step / (freqs[1] - freqs[0]))))
if width == 1:
return freqs, asd
usable = len(freqs) // width * width
grouped = (asd[:usable] ** 2).reshape(-1, width, asd.shape[1])
return freqs[:usable].reshape(-1, width).mean(axis=1), np.sqrt(grouped.mean(axis=1))
def alias_of(frequency, fs):
"""Where `frequency` lands after sampling at fs, and whether it folded."""
folded = abs(frequency - round(frequency / fs) * fs)
return folded, frequency > fs / 2
def dither_check(v, lsb, longest=1024):
"""Does averaging still recover resolution below one quantiser step?
Returns [(n, sd, sd_in_lsb, ratio_to_ideal), ...] for block averages of n
samples. Ideal is sd(1)/sqrt(n), so a ratio near 1 means averaging is
buying everything it should.
This is the check the whole cycle-count choice rests on. Section 5.1 warns
that quantisation "generally dictates working above a cycle count of ~30",
and the failure it warns about is specific: if the sensor's own noise is
small against the LSB, samples stop straddling the boundary, the quantiser
stops being dithered, and averaging stalls at a fixed fraction of an LSB no
matter how long you average. What that looks like here is a ratio that
climbs while the LSB column stops falling.
A slow trend is removed first. Otherwise drift -- which averaging cannot
remove and is not supposed to -- is what stops the average shrinking, and
the answer would be about the site rather than the quantiser.
"""
v = np.asarray(v, dtype=float)
index = np.arange(len(v))
v = v - np.polyval(np.polyfit(index, v, 5), index)
base = white_sd(v)
if base == 0:
return []
out = []
n = 1
while n <= longest and len(v) // n >= 16:
usable = len(v) // n * n
means = v[:usable].reshape(-1, n).mean(axis=1)
sd = float(means.std())
out.append((n, sd, sd / lsb, sd / (base / np.sqrt(n))))
n *= 4
return out
def decade_ticks(ax, which="both", minimum_decades=1.0):
"""Label a log axis at powers of ten, with unlabelled minors between.
Matplotlib falls back to labelling minor ticks when a log axis spans less
than a decade, which produces a scale reading 2x10^0, 3x10^0, 4x10^0 --
dense, and not what a log axis is read as. Decades are the convention, so
the range is widened to hold at least one rather than the labelling being
changed to suit a narrow range.
"""
axes = {"x": [ax.xaxis], "y": [ax.yaxis],
"both": [ax.xaxis, ax.yaxis]}[which]
for axis in axes:
setter = ax.set_xlim if axis is ax.xaxis else ax.set_ylim
lo, hi = ax.get_xlim() if axis is ax.xaxis else ax.get_ylim()
if lo > 0 and hi > lo:
short = minimum_decades - math.log10(hi / lo)
if short > 0:
pad = 10 ** (short / 2)
setter(lo / pad, hi * pad)
axis.set_major_locator(mticker.LogLocator(base=10.0))
axis.set_minor_locator(
mticker.LogLocator(base=10.0, subs=tuple(range(2, 10))))
axis.set_minor_formatter(mticker.NullFormatter())
def reference_lines(fs):
"""Frequencies worth marking on a spectrum: [(hz, label, is_alias), ...].
Mains is drawn where it actually lands, not where it is generated. Above
Nyquist it has folded, and the folded position is the only place it can be
seen -- so that is what gets the line, labelled as an alias so it is never
mistaken for a real signal at that frequency.
"""
marks = [(fs / 4, "fs/4", False), (fs / 2, "fs/2 = Nyquist", False)]
for mains in (60.0, 120.0):
landed, folded = alias_of(mains, fs)
if landed < fs / 2 * 0.995:
marks.append((landed,
f"{mains:g} Hz" + (" alias" if folded else ""),
folded))
return sorted(marks)
def coherent_amplitude(v, t, freqs):
"""Amplitude of a tone at each of `freqs`, for samples taken at times `t`.
Evaluates 2*|mean(v * exp(-2*pi*i*f*t))| directly. That is what a spectrum
computes, but without requiring the samples to be evenly spaced, which is
the entire reason this exists rather than a call to `welch_asd`: the
question here is which *time base* a line is coherent on, and one of the two
candidates is not uniform.
A tone present at f throughout the record sums in phase and returns its own
amplitude; anything else averages towards zero as 1/sqrt(n). A sample placed
at the wrong time by a fraction d of a cycle contributes cos(2*pi*d) instead
of 1, so coherence is lost at a rate set by the tone's frequency -- which
makes this the sharpest test available of whether a time base is right, and
also the reason a low-frequency line can say nothing about one.
The mean is removed first, or a DC offset leaks into the lowest frequencies.
"""
v = np.asarray(v, dtype=float)
v = v - v.mean()
t = np.asarray(t, dtype=float)
return np.array([2.0 * abs(complex((v * np.exp(-2j * np.pi * f * t)).mean()))
for f in np.atleast_1d(freqs)])
def host_time_base(cap, degree=TIMEBASE_DEGREE):
"""Sample times taken from the host clock, smoothed against sample index.
The alternative to `cap.elapsed`, which is `sample_index * dt_true` and so
asserts the rate never moved. It did: `calibrate.rate_stability` measures
186 to 1241 ppm of movement across the captures here.
Raw host timestamps carry that drift, but also the scheduling jitter of each
individual read, which says nothing about when the chip actually sampled.
A low-order polynomial keeps the first and discards the second. Degree 1
reduces exactly to `cap.elapsed`, which makes the uniform grid the degree-1
member of this family rather than a separate idea, and nothing above degree
3 changed any measured amplitude on these captures.
Returned relative to the first sample, like `cap.elapsed - cap.elapsed[0]`.
Shifting to that origin is what makes the degree-1 case *exactly* the
uniform grid rather than merely parallel to it: the fit carries a non-zero
intercept, since a least-squares line through a curve does not pass through
its first point.
"""
index = cap.sample_index - cap.sample_index[0]
host = cap.system_time - cap.system_time[0]
if len(index) <= degree + 1:
return index * cap.dt_true
fitted = np.polyval(np.polyfit(index, host, degree), index)
return fitted - fitted[0]
def sibling_path(path, suffix):
"""Name a companion figure from the main one: `x_noise.png` -> `x_drift.png`.
The trailing `_noise` is stripped first, so the extra figures do not come
out named `_noise_drift` and `_noise_spectrogram`.
"""
stem = path.rsplit(".", 1)[0]
if stem.endswith("_noise"):
stem = stem[:-len("_noise")]
return f"{stem}_{suffix}.png"
def make_spectrogram(cap, path, seconds=SPECTROGRAM_SECONDS,
overlap=SPECTROGRAM_OVERLAP,
colormap=SPECTROGRAM_COLORMAP, max_freq=None):
"""Write a time-frequency plot of |B|, marking the lines worth watching."""
fs = cap.true_rate_hz
times, freqs, asd = spectrogram(cap.total, fs, seconds, overlap)
native = freqs[1] - freqs[0]
import calibrate
rate_drift = calibrate.rate_stability(cap)
freqs, asd = bin_frequency(freqs, asd)
# Fractional, so a gain difference between captures is not read as a noise
# difference when they share a colour scale.
field = float(np.linalg.norm([cap.x.mean(), cap.y.mean(), cap.z.mean()]))
asd = asd / field * 1e6
dof = 2 * max(1, int(round(SPECTROGRAM_BIN_HZ / native)))
top = max_freq or fs / 2
fig, ax = plt.subplots(figsize=(13.5, 7), dpi=150)
fig.patch.set_facecolor(SURFACE)
ax.set_facecolor(SURFACE)
ramp = (mcolors.LinearSegmentedColormap.from_list("sequential",
SEQUENTIAL_STEPS)
if colormap == "sequential" else colormap)
# imshow rather than pcolormesh: the grid is regular in both axes, and at
# this resolution that is thousands of times fewer objects to draw.
# 'antialiased' resamples when there are more rows than pixels. 'nearest'
# would drop a one-bin line entirely depending on where it fell, which is
# exactly the feature this plot exists to show.
image = ax.imshow(asd, origin="lower", aspect="auto", cmap=ramp,
norm=mcolors.LogNorm(*SPECTROGRAM_PPM),
extent=[times[0], times[-1], freqs[0], freqs[-1]],
interpolation="antialiased")
bar = fig.colorbar(image, ax=ax, pad=0.02, extend="both", fraction=0.04)
bar.set_label("ppm of |B| per √Hz", color=TEXT_SECONDARY, fontsize=10)
bar.ax.tick_params(colors=TEXT_SECONDARY, labelsize=9, length=0)
bar.outline.set_visible(False)
# Labels sit inside the axes on a surface-coloured chip: outside they get
# clipped by the colorbar, and a spectrogram has no margin to give away.
# Anything at the very top is the axis boundary itself -- fs/2 when the plot
# runs to Nyquist -- where a line adds nothing and its label lands on the
# title.
marks = [m for m in reference_lines(fs) if freqs[0] <= m[0] <= top * 0.97]
for slot, (hz, label, folded) in enumerate(marks):
ax.axhline(hz, color=TEXT_PRIMARY, linewidth=1.0,
linestyle=":" if folded else "--", alpha=0.85)
# Lines can land within a label's height of each other -- fs/4 and a
# mains alias were 2.4 Hz apart here. Staggering across the width keeps
# both readable without moving either line.
ax.annotate(f"{label} {hz:.2f} Hz",
xy=(0.008 + 0.17 * (slot % 3), hz),
xycoords=("axes fraction", "data"),
xytext=(0, 7), textcoords="offset points",
va="bottom", ha="left", color=TEXT_PRIMARY, fontsize=9,
bbox=dict(boxstyle="round,pad=0.25", facecolor=SURFACE,
edgecolor="none", alpha=0.85))
ax.set_title("Spectral density of |B| over time", loc="left",
color=TEXT_PRIMARY, fontsize=13, fontweight="bold", pad=8)
ax.set_xlabel("elapsed (s)", color=TEXT_SECONDARY, fontsize=10)
ax.set_ylabel("frequency (Hz)", color=TEXT_SECONDARY, fontsize=10)
ax.set_ylim(0.0, top)
if fs / 2 < top:
# Above Nyquist there is no data, and the blank says so -- that missing
# bandwidth is a real difference between configurations, not a gap.
ax.annotate(f"no data above Nyquist, {fs / 2:.1f} Hz",
xy=(0.5, (fs / 2 + top) / 2), xycoords=("axes fraction", "data"),
ha="center", va="center", color=TEXT_SECONDARY, fontsize=10)
ax.tick_params(colors=TEXT_SECONDARY, labelsize=9, length=0)
for side in ("top", "right"):
ax.spines[side].set_visible(False)
for side in ("left", "bottom"):
ax.spines[side].set_color(GRID)
# Independent windows in the whole record. The columns far outnumber these,
# because they overlap -- so cell-to-cell scatter is the two degrees of
# freedom of a single periodogram, not a changing spectrum. What is real is
# what stays put across many columns.
independent = max(1, int(cap.duration / seconds))
fig.text(0.5, 0.965,
f"{cap.path.rsplit('/', 1)[-1]}{len(cap.sample_index):,} samples "
f"at {fs:.2f} Hz, cycle count {cap.cycle_count}",
color=TEXT_PRIMARY, fontsize=10, ha="center")
fig.text(0.5, 0.935,
f"oscillator drifted {rate_drift * 1e6:.0f} ppm across the run — "
f"a line at fs/4 smears by {rate_drift * fs / 4:.4f} Hz. "
f"{seconds:g} s windows, {native:.3f} Hz native binned to "
f"{SPECTROGRAM_BIN_HZ:g} Hz ({dof} dof/cell), {overlap:.2%} overlap, "
f"{len(times)} columns from {independent} independent windows. "
f"Dashed = real, dotted = alias."
+ " Colour scale and dof per cell are fixed across captures.",
color=TEXT_SECONDARY, fontsize=9, ha="center")
fig.tight_layout(rect=[0, 0, 1, 0.925])
fig.savefig(path, facecolor=SURFACE)
plt.close(fig)
return path
def make_drift(cap, path, window=DRIFT_WINDOW_S):
"""Write the oscillator's drift against the host clock, with its error bar.
Two views of one measurement. The left panel is the chip's rate through the
run; the right is how well that rate can be known as a function of how long
it is measured for -- which is what decides whether the left panel shows a
signal or its own noise. On these parts it is emphatically a signal: 186 to
1241 ppm of drift against a floor near 10 ppm.
"""
import calibrate
centres, rates = calibrate.window_rates(cap, window)
if len(rates) < 3:
raise ValueError(f"{cap.duration:.0f} s gives {len(rates)} window(s) of "
f"{window:g} s, too few to show a trend")
taus, devs = calibrate.rate_allan(cap, RATE_TAUS)
if not len(taus):
raise ValueError(f"{cap.duration:.0f} s is too short for any averaging "
f"time in {RATE_TAUS[0]:g}-{RATE_TAUS[-1]:g} s")
# The error bar is the two-sample deviation at the window length actually
# plotted. Not the scatter of the points themselves, which is the drift this
# figure exists to show, and using it would beg the question.
floor = float(np.interp(window, taus, devs))
best = int(np.argmin(devs))
ppm = (rates / rates.mean() - 1.0) * 1e6
span = calibrate.rate_stability(cap) * 1e6
fig, axs = plt.subplots(1, 2, figsize=(13.5, 5.5), dpi=150)
fig.patch.set_facecolor(SURFACE)
for ax in axs:
ax.set_facecolor(SURFACE)
a = axs[0]
a.axhline(0.0, color=REFERENCE, linestyle="--", linewidth=1.0)
a.errorbar(centres, ppm, yerr=floor * 1e6, fmt="o", markersize=3.5,
color=AXES[0][2], ecolor=REFERENCE, elinewidth=1.0, capsize=2.5,
label=f"{window:g} s windows, ± σ_y = {floor * 1e6:.0f} ppm")
trend = np.polyfit(centres, ppm, 1)
a.plot(centres, np.polyval(trend, centres), color=TEXT_PRIMARY, linewidth=1.5,
label=f"trend {trend[0] * cap.duration:+,.0f} ppm across the run")
a.set_title("Chip oscillator against the host clock", loc="left",
color=TEXT_PRIMARY, fontsize=12, fontweight="bold", pad=8)
a.set_xlabel("elapsed (s)")
a.set_ylabel("rate error (ppm of the run mean)")
# The y axis is fixed so two runs can be compared; the x axis is the
# capture's own length, which pinning would simply clip on a longer one.
a.set_ylim(*DRIFT_PPM)
a.annotate("the sample grid stays uniform in index —\nthis is its spacing "
"in seconds moving.\nGain does not follow: it is set by the "
"ratio\nof two on-die clocks, which drift together.",
xy=(0.02, 0.05), xycoords="axes fraction",
color=TEXT_SECONDARY, fontsize=9)
a = axs[1]
a.loglog(taus, devs * 1e6, "o-", color=AXES[0][2], linewidth=1.5,
markersize=4.5, label="σ_y(τ), measured")
a.plot([taus[best]], [devs[best] * 1e6], "o", markersize=11,
markerfacecolor="none", markeredgecolor=TEXT_PRIMARY,
markeredgewidth=1.4, zorder=5)
a.annotate(f"{devs[best] * 1e6:.1f} ppm at τ = {taus[best]:g} s",
xy=(taus[best], devs[best] * 1e6), xytext=(0, -26),
textcoords="offset points", ha="center",
color=TEXT_PRIMARY, fontsize=9)
# Anchored at the longest tau and drawn back to the minimum: white noise
# cannot rise, so a branch parallel to this is a frequency ramp, and one
# steeper than it is not.
rising = taus[best:]
a.plot(rising, devs[-1] * 1e6 * rising / taus[-1], linestyle=":",
color=REFERENCE, linewidth=1.5,
label="∝ τ — a deterministic frequency ramp")
a.set_title("How well the rate can be measured", loc="left",
color=TEXT_PRIMARY, fontsize=12, fontweight="bold", pad=8)
a.set_xlabel("averaging time τ (s)"); a.set_ylabel("σ_y (ppm)")
a.set_xlim(*RATE_TAU_S); a.set_ylim(*RATE_ALLAN_PPM)
decade_ticks(a)
a.annotate("falling = measurement noise averaging down;\nrising = drift the "
"measurement has resolved",
xy=(0.02, 0.05), xycoords="axes fraction",
color=TEXT_SECONDARY, fontsize=9)
for ax in axs:
ax.grid(True, which="both", color=GRID, linewidth=0.7)
ax.set_axisbelow(True)
for side in ("top", "right"):
ax.spines[side].set_visible(False)
for side in ("left", "bottom"):
ax.spines[side].set_color(GRID)
ax.tick_params(colors=TEXT_SECONDARY, labelsize=9, length=0)
ax.xaxis.label.set_color(TEXT_SECONDARY)
ax.yaxis.label.set_color(TEXT_SECONDARY)
ax.legend(frameon=False, fontsize=9, labelcolor=TEXT_SECONDARY,
loc="upper right")
fig.text(0.5, 0.955,
f"{cap.path.rsplit('/', 1)[-1]}{len(cap.sample_index):,} samples "
f"at {cap.true_rate_hz:.2f} Hz, cycle count {cap.cycle_count}",
color=TEXT_PRIMARY, fontsize=10, ha="center")
# Two figures for the drift, deliberately: the peak-to-peak spread is what
# `rate_stability` feeds to the uncertainty propagation and what the rest of
# the tooling quotes, while the fitted trend uses every window and is the
# better estimate of the warm-up itself. They differ by how much of the
# transient a coarse window averages over, and printing only one invites the
# other to be read off the legend and mistaken for a discrepancy.
fig.text(0.5, 0.915,
f"drifted {span:.0f} ppm peak to peak, "
f"{abs(trend[0] * cap.duration):.0f} ppm by the fitted trend, "
f"against a floor of {devs[best] * 1e6:.1f} ppm at τ = "
f"{taus[best]:g} s — resolved {span / (devs[best] * 1e6):.0f}× over.",
color=TEXT_SECONDARY, fontsize=9, ha="center")
fig.tight_layout(rect=[0, 0, 1, 0.90])
fig.savefig(path, facecolor=SURFACE)
plt.close(fig)
return path
def make_time_base(cap, path):
"""Write, line by line, which clock each one is coherent on.
A line at a fixed frequency lives in wall time, so it sharpens when the
samples are placed by the host clock. A line locked to the sampling lives in
sample index, and the same substitution wrecks it. Measuring both ways
therefore separates a real signal from an artefact of measuring *within a
single capture*, where compare.py needs two at different rates to do it.
The catch is that the test only has force on a strong line: coherence is
lost in proportion to the tone's frequency, so a low-frequency or aliased
line says little, and a weak one returns noise. Both are marked as such
rather than being given a verdict they cannot support.
"""
fs = cap.true_rate_hz
chip_t = (cap.sample_index - cap.sample_index[0]) * cap.dt_true
host_t = host_time_base(cap)
# The same cubic detrend `sample_locked_lines` applies, and for the same
# reason: drift is not a line, and left in place it leaks into everything
# below a few tenths of a hertz.
order = np.arange(len(cap.total))
v = cap.total - np.polyval(np.polyfit(order, cap.total, 3), order)
# Against the quantiser rather than against zero. A capture whose |B| never
# moves by even a thousandth of an LSB has no spectrum -- every sample is
# the same integer -- but the cubic detrend still leaves ~1e-11 nT of
# floating-point residue, so an exact test would let it through and then
# find "lines" in rounding error.
if v.std() < cap.lsb_nt * 1e-3:
raise ValueError("|B| is constant to within a thousandth of an LSB; "
"no lines to test")
null = v.std() * np.sqrt(2 / len(v))
candidates = [(line.numerator / line.period * fs,
f"{line.numerator}/{line.period} of fs", False)
for line in sample_locked_lines(v)]
candidates += [(hz, label, folded)
for hz, label, folded in reference_lines(fs)
if not label.startswith("fs/")]
scored = []
for hz, label, folded in candidates:
if not 0 < hz < fs / 2:
continue
sigma = float(coherent_amplitude(v, chip_t, hz)[0]) / null
scored.append((sigma, hz, label, folded))
scored.sort(reverse=True)
scored = scored[:TIMEBASE_PANELS]
if not scored:
raise ValueError("no lines to compare time bases on")
# Floored rather than purely per-panel: a capture with one or two lines
# worth testing would otherwise be narrower than its own caption.
fig, axs = plt.subplots(1, len(scored),
figsize=(max(9.8, 4.7 * len(scored)), 5.2),
dpi=150, squeeze=False)
fig.patch.set_facecolor(SURFACE)
for ax, (sigma, hz, label, folded) in zip(axs[0], scored):
ax.set_facecolor(SURFACE)
freqs = np.linspace(hz - TIMEBASE_SPAN_HZ, hz + TIMEBASE_SPAN_HZ,
TIMEBASE_POINTS)
chip = coherent_amplitude(v, chip_t, freqs)
host = coherent_amplitude(v, host_t, freqs)
ratio = host.max() / chip.max()
contrast = chip.max() / np.median(chip)
if folded:
verdict = "aliased — folding assumes uniformity, not a clean test"
elif contrast < TIMEBASE_MIN_CONTRAST:
verdict = f"no resolved peak ({contrast:.1f}× its floor) — no verdict"
elif ratio > 1.05:
verdict = "coherent in wall time — external"
elif ratio < 0.95:
verdict = "coherent in sample index — an artefact of measuring"
else:
verdict = "no preference between the two"
ax.plot(freqs - hz, chip, color=AXES[0][2], linewidth=1.3,
label=f"chip index grid — {chip.max():.3f} nT")
ax.plot(freqs - hz, host, color=AXES[1][2], linewidth=1.3,
label=f"host clock, degree {TIMEBASE_DEGREE}{host.max():.3f} nT")
# Padded so the verdict can sit between title and axes without either
# landing on the traces, which reach the top of a tightly scaled panel.
ax.set_title(f"{label} at {hz:.3f} Hz {ratio:.2f}×", loc="left",
color=TEXT_PRIMARY, fontsize=11, fontweight="bold", pad=22)
ax.annotate(verdict, xy=(0.0, 1.01), xycoords="axes fraction",
color=TEXT_SECONDARY, fontsize=8.5, va="bottom")
ax.set_xlabel(f"offset from {hz:.3f} Hz (Hz)")
ax.set_ylabel("coherent amplitude (nT)")
ax.set_xlim(-TIMEBASE_SPAN_HZ, TIMEBASE_SPAN_HZ)
# Headroom for the legend, which would otherwise sit on the peak the
# panel exists to show.
ax.set_ylim(0.0, max(chip.max(), host.max()) * 1.45)
ax.grid(True, which="both", color=GRID, linewidth=0.7)
ax.set_axisbelow(True)
for side in ("top", "right"):
ax.spines[side].set_visible(False)
for side in ("left", "bottom"):
ax.spines[side].set_color(GRID)
ax.tick_params(colors=TEXT_SECONDARY, labelsize=9, length=0)
ax.xaxis.label.set_color(TEXT_SECONDARY)
ax.yaxis.label.set_color(TEXT_SECONDARY)
ax.legend(frameon=False, fontsize=8.5, labelcolor=TEXT_SECONDARY,
loc="upper right")
fig.suptitle("Which clock is each line coherent on?", color=TEXT_PRIMARY,
fontsize=13, fontweight="bold", y=0.99)
fig.text(0.5, 0.930,
f"{cap.path.rsplit('/', 1)[-1]} at {fs:.2f} Hz, "
f"cycle count {cap.cycle_count}",
color=TEXT_SECONDARY, fontsize=9, ha="center")
fig.text(0.5, 0.897,
"Ratio is host peak over chip peak. Diagnostic only — no other "
"figure uses the host time base.",
color=TEXT_SECONDARY, fontsize=9, ha="center")
fig.tight_layout(rect=[0, 0, 1, 0.88])
fig.savefig(path, facecolor=SURFACE)
plt.close(fig)
return path
def trimmed(cap, seconds):
"""Drop `seconds` from both ends of a capture. Returns (capture, note).
A capture typically opens while the sensor is still settling after power-up
and closes while it is being handled for whatever comes next, and neither
end is a noise measurement. Trimming is never silent -- the note says what
went -- and never automatic, because discarding data the caller did not ask
to discard is how a figure ends up quietly describing a subset.
Refuses rather than trims when there would be too little left to mean
anything, since a short deliberate capture is a legitimate thing to analyse.
"""
if not seconds:
return cap, ""
if seconds < 0:
raise capture.CaptureError(f"--trim {seconds} must not be negative")
if cap.duration < 4 * seconds:
return cap, (f"not trimming: {cap.duration:.1f} s is under 4x the "
f"{seconds:g} s requested from each end")
kept = cap.restrict(seconds, cap.duration - seconds)
return kept, (f"trimmed {seconds:g} s from each end: "
f"{len(cap.sample_index):,} -> {len(kept.sample_index):,} "
f"samples, {cap.duration:.1f} -> {kept.duration:.1f} s")
2026-08-19 23:00:47 -04:00
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("csv")
ap.add_argument("-o", "--output", default=None)
ap.add_argument("--start", type=float, default=0.0,
help="ignore samples before this elapsed time (s)")
ap.add_argument("--end", type=float, default=None,
help="ignore samples after this elapsed time (s)")
ap.add_argument("--trim", type=float, default=0.0, metavar="SECONDS",
help="drop this many seconds from BOTH ends -- settling at "
"the start, handling at the end (default: %(default)s)")
ap.add_argument("--spectrogram", type=float, default=SPECTROGRAM_SECONDS,
metavar="SECONDS",
help="spectrogram window length; frequency resolution is "
"its reciprocal, time resolution is it "
"(default: %(default)s)")
ap.add_argument("--overlap", type=float, default=SPECTROGRAM_OVERLAP,
metavar="FRACTION",
help="spectrogram segment overlap, 0 to <1; higher gives "
"more columns, not more information "
"(default: %(default)s)")
ap.add_argument("--max-freq", type=float, default=None, metavar="HZ",
help="spectrogram frequency ceiling; the default is each "
"capture's own Nyquist. Set it the same for every "
"capture to make them pixel-for-pixel comparable, at "
"the cost of blank space on the slower ones")
ap.add_argument("--colormap", default=SPECTROGRAM_COLORMAP,
help="spectrogram colormap; any matplotlib name, or "
"'sequential' for the single-hue ramp "
"(default: %(default)s)")
2026-08-19 23:00:47 -04:00
args = ap.parse_args()
# --trim and --start/--end both choose a window, and silently letting one
# win would make the reported figures depend on argument order.
if args.trim and (args.start or args.end is not None):
ap.error("--trim sets the window from both ends; it cannot be combined "
"with --start/--end, which set it explicitly")
try:
cap = capture.load(args.csv)
if args.start or args.end is not None:
cap = cap.restrict(args.start or None, args.end)
whole = cap.duration
cap, trim_note = trimmed(cap, args.trim)
trim_applied = cap.duration < whole
except (OSError, capture.CaptureError) as exc:
sys.exit(str(exc))
# Total field is derived from the three axes; its noise is dominated by
# whichever axis carries most of the field, weighted by direction cosines,
# so it tracks that axis rather than being an independent measurement.
data = dict(cap.axes())
t = cap.elapsed
# Rate measured against the host clock, not the nominal table value: a 6%
# error would put every spectral feature 6% off.
fs = cap.true_rate_hz
lsb_nt = cap.lsb_nt
# Host read latency, kept apart from the sample grid it does not affect.
latency = np.diff(cap.system_time) # local read spacing, not the fit residual
# Normalising by |B| makes noise comparable across captures whose scale
# differs -- a gain change carries the noise with it, so absolute sd alone
# will read as a noise difference when only the scale moved.
field = data["total"].mean()
band = band_for(fs)
# Table 3-1's noise figure for this cycle count -- what `white sd` is the
# measurement of, so the two belong in the same table.
spec_nt = rm3100.expected_noise_nt(cap.cycle_count)
print(cap.summary())
if trim_note:
print(f" {trim_note}")
# How far the oscillator moved during the run. Imported here rather than at
# module scope: calibrate.py imports this module, so the dependency only
# runs one way at import time.
import calibrate
drift = calibrate.rate_stability(cap)
# Reported, not flagged. The sensor oscillator is an LR relaxation circuit
# -- coil inductance, a resistor and comparator thresholds, none of them
# temperature-stable -- so a tenth of a percent across a run is ordinary,
# not a fault. What it is, is the error bar on this capture's rate and on
# anything derived from it.
print(f" oscillator drifted {drift * 1e6:.0f} ppm across the run "
f"({drift * cap.dt_true * 1e6:.2f} us on a "
f"{cap.dt_true * 1e3:.2f} ms period) -- the error bar on the rate")
print()
print(f"{'series':6s} {'sd':>9s} {'white sd':>9s} {'dither':>7s} "
f"{'sd/|B|':>10s} {'p2p':>9s} {'vs spec':>8s} {'ASD':>7s}")
2026-08-19 23:00:47 -04:00
fig, axs = plt.subplots(2, 2, figsize=(13.5, 9), dpi=150)
fig.patch.set_facecolor(SURFACE)
for ax in axs.flat:
ax.set_facecolor(SURFACE)
for key, label, color in SERIES:
2026-08-19 23:00:47 -04:00
v = data[key]
sd = v.std()
# Drift-free: what Table 3-1 quotes, and what the dither margin is
# really made of. The plain sd above it is drift on a drifting capture.
wsd = white_sd(v)
2026-08-19 23:00:47 -04:00
freqs, asd = welch_asd(v, fs)
# Thin: four traces overlaid on a linear 0-100 Hz axis, and the detail
# between the lines is what the panel is read for. The Allan panel below
# keeps a heavier stroke -- four smooth curves, not four dense ones.
axs[0, 0].semilogy(freqs, asd, color=color, linewidth=0.7,
label=label, alpha=0.85)
2026-08-19 23:00:47 -04:00
taus, devs = allan_deviation(v, fs)
axs[0, 1].loglog(taus, devs, color=color, linewidth=1.6, label=label)
# Detrend before the histogram so slow drift does not masquerade as
# a fat tail. Bin edges are locked to quarter-LSB steps: the residual
# of an integer-valued signal is a comb at one LSB by construction, and
# arbitrary bin edges beat against it into ragged spikes that look like
# structure. Aligned bins render the comb as what it is.
2026-08-19 23:00:47 -04:00
resid = v - np.polyval(np.polyfit(t, v, 3), t)
step = lsb_nt / 4
edge = np.ceil(max(abs(resid.min()), abs(resid.max())) / step) * step
axs[1, 0].hist(resid, bins=np.arange(-edge, edge + step, step),
histtype="step", linewidth=1.4,
2026-08-19 23:00:47 -04:00
color=color, label=label, density=True)
median_asd, _, _, _ = band_stats(v, fs, band)
print(f"{label:6s} {sd:9.1f} {wsd:9.2f} {wsd/lsb_nt:7.2f} "
f"{sd/field*1e6:9.1f}p {v.max()-v.min():9.1f} "
f"{(wsd/spec_nt - 1) * 100:+7.1f}% {median_asd:7.2f}")
print(f" sd, white sd, p2p in nT; dither = white sd in LSB "
f"(1 LSB = {lsb_nt:.2f} nT);")
print(f" sd/|B| in ppm of {field:,.0f} nT; vs spec against Table 3-1's "
f"{spec_nt:.1f} nT at cycle count {cap.cycle_count};")
print(f" ASD = median nT/rtHz over {band[0]:g}-{band[1]:.1f} Hz.")
# The assumption the cycle-count choice rests on, checked rather than
# asserted: with dither under one LSB, does averaging still get below it?
print("\ndither: averaging against the quantiser")
for key, label, _ in SERIES:
steps = dither_check(data[key], lsb_nt)
if not steps:
continue
longest = steps[-1]
print(f" {label:6s} {steps[0][2]:.2f} LSB per sample -> "
f"{longest[2]:.3f} LSB after {longest[0]:,} "
f"({longest[0] / fs:.1f} s), {longest[3]:.1f}x the ideal 1/sqrt(n)")
print(" Sub-LSB resolution is being recovered wherever the LSB column "
"keeps falling.\n A ratio climbing while it stalls is the "
"quantiser losing its dither (section 5.1);\n a ratio climbing "
"while it falls is just drift, which averaging cannot remove.")
# Structure locked to the sample index rather than to a frequency. Reported
# per axis because it is not isotropic -- one axis here carries 5x another.
lines = [(label, sample_locked_lines(data[key]))
for key, label, _ in SERIES]
if any(found for _, found in lines):
print("\nlines at simple fractions of the sample rate:")
for label, found in lines:
for line in found:
fraction = line.numerator / line.period
print(f" {label:6s} {line.numerator}/{line.period} of fs "
f"= {fraction * fs:8.3f} Hz {line.amplitude:6.2f} nT "
f"amplitude {line.sigma:5.1f} sigma")
print(" These may be sample-locked -- moving with the rate rather than "
"sitting at a\n fixed frequency -- which would make them "
"artefacts of measuring, not field.\n compare.py decides it, by "
"checking whether they hold the same fraction of fs\n in a "
"capture taken at a different rate.")
2026-08-19 23:00:47 -04:00
a = axs[0, 0]
# The band the quoted median comes from, so the number and the picture
# cannot drift apart. Drawn under the traces, hence the low zorder.
a.axvspan(band[0], band[1], color=GRID, alpha=0.55, zorder=0, linewidth=0)
# Named in the legend rather than annotated on the line. The spec sits low
# in the range, where on a linear axis the traces are densest, and floating
# text disappears into them wherever it is put.
a.axhline(SPEC_ASD_NT, color=REFERENCE, linestyle="--", linewidth=1.2,
label=f"Table 3-1 spec, {SPEC_ASD_NT} nT/√Hz")
2026-08-19 23:00:47 -04:00
a.set_title("Amplitude spectral density", loc="left",
color=TEXT_PRIMARY, fontsize=12, fontweight="bold", pad=8)
a.annotate(f"shaded: {band[0]:g}{band[1]:.0f} Hz, the quoted band",
xy=(0.02, 0.04), xycoords="axes fraction",
color=TEXT_SECONDARY, fontsize=9)
2026-08-19 23:00:47 -04:00
a.set_xlabel("frequency (Hz)"); a.set_ylabel("nT/√Hz")
a.set_xlim(*ASD_HZ); a.set_ylim(*ASD_NT)
decade_ticks(a, which="y")
2026-08-19 23:00:47 -04:00
a = axs[0, 1]
a.set_title("Allan deviation", loc="left",
color=TEXT_PRIMARY, fontsize=12, fontweight="bold", pad=8)
a.set_xlabel("averaging time τ (s)"); a.set_ylabel("σ (nT)")
a.set_xlim(*ALLAN_TAU_S); a.set_ylim(*ALLAN_NT)
decade_ticks(a)
2026-08-19 23:00:47 -04:00
a.annotate("slope −½ = white noise; upturn = drift",
xy=(0.02, 0.04), xycoords="axes fraction",
color=TEXT_SECONDARY, fontsize=9)
a = axs[1, 0]
a.set_title("Residual distribution (cubic trend removed)", loc="left",
color=TEXT_PRIMARY, fontsize=12, fontweight="bold", pad=8)
a.set_xlabel("nT"); a.set_ylabel("density")
a.set_xlim(*RESIDUAL_NT); a.set_ylim(*RESIDUAL_DENSITY)
2026-08-19 23:00:47 -04:00
a = axs[1, 1]
# Fixed bin edges as well as fixed limits: comparing two histograms whose
# bins are different widths compares the binning as much as the data.
a.hist(latency * 1000, bins=np.linspace(*LATENCY_MS, 121), color=REFERENCE)
2026-08-19 23:00:47 -04:00
a.set_yscale("log")
a.axvline(cap.dt_true * 1000, color=TEXT_PRIMARY, linestyle="--", linewidth=1.2)
a.annotate(f"grid {cap.dt_true * 1000:.2f} ms", xy=(cap.dt_true * 1000, 1),
2026-08-19 23:00:47 -04:00
xytext=(6, 0), textcoords="offset points",
color=TEXT_PRIMARY, fontsize=9)
a.set_title("Host read latency", loc="left",
2026-08-19 23:00:47 -04:00
color=TEXT_PRIMARY, fontsize=12, fontweight="bold", pad=8)
# The oscillator's own drift used to be annotated here, on a panel about
# host-side comms that has nothing to do with it. It has its own figure now.
a.annotate("spread is comms only;\nthe sample grid is exact",
xy=(0.98, 0.94), xycoords="axes fraction", ha="right", va="top",
color=TEXT_SECONDARY, fontsize=9)
a.set_xlabel("interval between reads (ms)"); a.set_ylabel("count")
a.set_xlim(*LATENCY_MS); a.set_ylim(*LATENCY_COUNT)
decade_ticks(a, which="y")
2026-08-19 23:00:47 -04:00
for ax in axs.flat:
ax.grid(True, which="both", color=GRID, linewidth=0.7)
ax.set_axisbelow(True)
for side in ("top", "right"):
ax.spines[side].set_visible(False)
for side in ("left", "bottom"):
ax.spines[side].set_color(GRID)
ax.tick_params(colors=TEXT_SECONDARY, labelsize=9, length=0)
ax.xaxis.label.set_color(TEXT_SECONDARY)
ax.yaxis.label.set_color(TEXT_SECONDARY)
# Explicit placement: "best" puts the ASD legend on top of the spec-line
# annotation in the lower left.
for ax, loc in ((axs[0, 0], "upper right"), (axs[0, 1], "upper right"),
(axs[1, 0], "upper right")):
ax.legend(frameon=False, fontsize=9, labelcolor=TEXT_SECONDARY, loc=loc)
2026-08-19 23:00:47 -04:00
fig.suptitle("RM3100 noise characterisation", color=TEXT_PRIMARY,
fontsize=15, fontweight="bold", y=0.985)
fig.text(0.5, 0.945,
f"{len(t):,} samples, {cap.duration:.1f} s at {fs:.2f} Hz measured "
f"(nominal {cap.nominal_rate_hz:g} Hz, {cap.rate_error * 100:+.1f}%), "
f"cycle count {cap.cycle_count}, 1 LSB = {lsb_nt:.2f} nT. "
f"Drift {drift * 1e6:.0f} ppm — see the drift figure."
+ (f" {args.trim:g} s trimmed from each end." if trim_applied
else ""),
2026-08-19 23:00:47 -04:00
color=TEXT_SECONDARY, fontsize=10, ha="center")
fig.tight_layout(rect=[0, 0, 1, 0.935])
out = args.output or args.csv.rsplit(".", 1)[0] + "_noise.png"
fig.savefig(out, facecolor=SURFACE)
print(f"\n-> {out}")
# Separate files rather than more panels. The spectrogram needs the width to
# resolve anything; the other two answer questions the four panels do not --
# whether a feature lasted, how far the chip's clock moved, and which clock
# each line is coherent on. Each degrades to a message rather than aborting
# the run, since a short capture can legitimately support none of them.
for suffix, build in (("spectrogram",
lambda p: make_spectrogram(cap, p, args.spectrogram,
args.overlap,
args.colormap,
args.max_freq)),
("drift", lambda p: make_drift(cap, p)),
("timebase", lambda p: make_time_base(cap, p))):
try:
print(f"-> {build(sibling_path(out, suffix))}")
except ValueError as exc:
print(f"no {suffix}: {exc}")
2026-08-19 23:00:47 -04:00
if __name__ == "__main__":
main()