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.
|
2026-08-23 18:16:43 -04:00
|
|
|
|
Read latency host-side diagnostics only -- the measurement
|
|
|
|
|
|
grid itself is uniform regardless.
|
|
|
|
|
|
|
2026-08-24 18:09:19 -04:00
|
|
|
|
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.
|
|
|
|
|
|
|
2026-08-23 20:50:56 -04:00
|
|
|
|
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.
|
|
|
|
|
|
|
2026-08-23 18:16:43 -04:00
|
|
|
|
The sample grid is exact, not assumed: the sensor samples on its own internal
|
2026-08-23 20:50:56 -04:00
|
|
|
|
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
|
2026-08-23 20:50:56 -04:00
|
|
|
|
import math
|
2026-08-19 23:00:47 -04:00
|
|
|
|
import sys
|
2026-08-23 20:50:56 -04:00
|
|
|
|
from collections import namedtuple
|
2026-08-19 23:00:47 -04:00
|
|
|
|
|
|
|
|
|
|
import matplotlib
|
|
|
|
|
|
matplotlib.use("Agg")
|
2026-08-23 22:11:50 -04:00
|
|
|
|
import matplotlib.colors as mcolors
|
2026-08-19 23:00:47 -04:00
|
|
|
|
import matplotlib.pyplot as plt
|
2026-08-23 20:50:56 -04:00
|
|
|
|
import matplotlib.ticker as mticker
|
2026-08-19 23:00:47 -04:00
|
|
|
|
import numpy as np
|
|
|
|
|
|
|
2026-08-23 18:16:43 -04:00
|
|
|
|
import capture
|
2026-08-23 20:50:56 -04:00
|
|
|
|
import rm3100
|
2026-08-23 18:16:43 -04:00
|
|
|
|
|
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")]
|
|
|
|
|
|
|
2026-08-23 18:16:43 -04:00
|
|
|
|
# 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
|
|
|
|
|
|
|
2026-08-23 20:50:56 -04:00
|
|
|
|
# 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
|
2026-08-24 18:09:19 -04:00
|
|
|
|
# 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
|
2026-08-23 20:50:56 -04:00
|
|
|
|
ALLAN_TAU_S = (1e-3, 1e3) # one sample period to a 1000 s average
|
2026-08-24 18:09:19 -04:00
|
|
|
|
ALLAN_NT = (5e-1, 5e1)
|
2026-08-23 20:50:56 -04:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-08-23 22:11:50 -04:00
|
|
|
|
# compare.py plots the same two quantities normalised by |B|, so they get their
|
2026-08-24 18:09:19 -04:00
|
|
|
|
# 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.
|
2026-08-23 22:11:50 -04:00
|
|
|
|
FRACTIONAL_ASD_PPM = (1e0, 1e3)
|
|
|
|
|
|
FRACTIONAL_ALLAN_PPM = (1e0, 1e3)
|
2026-08-24 18:09:19 -04:00
|
|
|
|
FRACTIONAL_ASD_HZ = (1e-2, 1e3)
|
2026-08-23 22:11:50 -04:00
|
|
|
|
|
|
|
|
|
|
# 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"]
|
|
|
|
|
|
|
2026-08-23 20:50:56 -04:00
|
|
|
|
# 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
|
|
|
|
|
|
|
2026-08-24 18:09:19 -04:00
|
|
|
|
# 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
|
|
|
|
|
|
|
2026-08-23 20:50:56 -04:00
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-23 20:50:56 -04:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-23 22:11:50 -04:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-23 20:50:56 -04:00
|
|
|
|
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())
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-23 22:11:50 -04:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 18:09:19 -04:00
|
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-23 22:11:50 -04:00
|
|
|
|
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]
|
2026-08-24 18:09:19 -04:00
|
|
|
|
import calibrate
|
|
|
|
|
|
rate_drift = calibrate.rate_stability(cap)
|
2026-08-23 22:11:50 -04:00
|
|
|
|
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,
|
2026-08-24 18:09:19 -04:00
|
|
|
|
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. "
|
2026-08-23 22:11:50 -04:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 18:09:19 -04:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-23 20:50:56 -04:00
|
|
|
|
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)")
|
2026-08-23 20:50:56 -04:00
|
|
|
|
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)")
|
2026-08-23 22:11:50 -04:00
|
|
|
|
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()
|
|
|
|
|
|
|
2026-08-23 20:50:56 -04:00
|
|
|
|
# --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")
|
|
|
|
|
|
|
2026-08-23 18:16:43 -04:00
|
|
|
|
try:
|
|
|
|
|
|
cap = capture.load(args.csv)
|
|
|
|
|
|
if args.start or args.end is not None:
|
|
|
|
|
|
cap = cap.restrict(args.start or None, args.end)
|
2026-08-23 20:50:56 -04:00
|
|
|
|
whole = cap.duration
|
|
|
|
|
|
cap, trim_note = trimmed(cap, args.trim)
|
|
|
|
|
|
trim_applied = cap.duration < whole
|
2026-08-23 18:16:43 -04:00
|
|
|
|
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()
|
2026-08-23 20:50:56 -04:00
|
|
|
|
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}")
|
2026-08-24 18:09:19 -04:00
|
|
|
|
# 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")
|
2026-08-23 20:50:56 -04:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-08-23 18:16:43 -04:00
|
|
|
|
for key, label, color in SERIES:
|
2026-08-19 23:00:47 -04:00
|
|
|
|
v = data[key]
|
|
|
|
|
|
sd = v.std()
|
2026-08-23 20:50:56 -04:00
|
|
|
|
# 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)
|
2026-08-24 18:09:19 -04:00
|
|
|
|
# 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
|
2026-08-23 20:50:56 -04:00
|
|
|
|
# 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)
|
2026-08-23 20:50:56 -04:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-08-23 20:50:56 -04:00
|
|
|
|
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]
|
2026-08-23 20:50:56 -04:00
|
|
|
|
# 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)
|
2026-08-24 18:09:19 -04:00
|
|
|
|
# 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)
|
2026-08-23 20:50:56 -04:00
|
|
|
|
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")
|
2026-08-23 20:50:56 -04:00
|
|
|
|
a.set_xlim(*ASD_HZ); a.set_ylim(*ASD_NT)
|
2026-08-24 18:09:19 -04:00
|
|
|
|
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)")
|
2026-08-23 20:50:56 -04:00
|
|
|
|
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")
|
2026-08-23 20:50:56 -04:00
|
|
|
|
a.set_xlim(*RESIDUAL_NT); a.set_ylim(*RESIDUAL_DENSITY)
|
2026-08-19 23:00:47 -04:00
|
|
|
|
|
|
|
|
|
|
a = axs[1, 1]
|
2026-08-23 20:50:56 -04:00
|
|
|
|
# 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")
|
2026-08-23 18:16:43 -04:00
|
|
|
|
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)
|
2026-08-23 18:16:43 -04:00
|
|
|
|
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)
|
2026-08-24 18:09:19 -04:00
|
|
|
|
# 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",
|
2026-08-23 18:16:43 -04:00
|
|
|
|
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")
|
2026-08-23 20:50:56 -04:00
|
|
|
|
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)
|
2026-08-23 18:16:43 -04:00
|
|
|
|
# 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,
|
2026-08-23 18:16:43 -04:00
|
|
|
|
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. "
|
2026-08-24 18:09:19 -04:00
|
|
|
|
f"Drift {drift * 1e6:.0f} ppm — see the drift figure."
|
2026-08-23 20:50:56 -04:00
|
|
|
|
+ (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}")
|
|
|
|
|
|
|
2026-08-24 18:09:19 -04:00
|
|
|
|
# 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-23 22:11:50 -04:00
|
|
|
|
|
2026-08-19 23:00:47 -04:00
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
main()
|