1202 lines
54 KiB
Python
1202 lines
54 KiB
Python
#!/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 Table 3-1's noise figure
|
||
expressed at this capture's own rate.
|
||
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. The measurement grid is
|
||
uniform whatever this shows.
|
||
|
||
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 how much of that is real.
|
||
_timebase.png which clock each line is coherent on, settled
|
||
from a single capture where compare.py needs two
|
||
rates. Diagnostic only: every other figure uses
|
||
the chip's grid.
|
||
|
||
Two noise figures are printed. `sd` is the plain standard deviation, which on a
|
||
drifting capture measures the drift. `white sd` is sd(diff)/sqrt(2), which
|
||
rejects drift, and is what Table 3-1's 208/sqrt(cycle_count) compares against.
|
||
|
||
`--trim` drops equal time from both ends: settling at the start, handling at
|
||
the end. Nothing is dropped unless asked.
|
||
|
||
Frequencies use the rate measured against the host clock, never the nominal
|
||
table value, which is out by a cycle-count and supply dependent amount.
|
||
|
||
Rows lost during capture carry no data and are linearly interpolated on load, so
|
||
a capture with many of them reads as artificially smooth at high frequency;
|
||
capture.py's summary reports how many.
|
||
"""
|
||
|
||
import argparse
|
||
import math
|
||
import sys
|
||
from collections import namedtuple
|
||
|
||
import matplotlib
|
||
matplotlib.use("Agg")
|
||
import matplotlib.colors as mcolors
|
||
import matplotlib.pyplot as plt
|
||
import matplotlib.ticker as mticker
|
||
import numpy as np
|
||
|
||
import capture
|
||
import rm3100
|
||
|
||
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, so it takes neutral ink.
|
||
TOTAL = ("total", "Total", TEXT_PRIMARY)
|
||
SERIES = AXES + [TOTAL]
|
||
|
||
# Every panel is drawn on a fixed scale, so two runs can be laid side by side
|
||
# and compared by eye. The ranges cover cycle counts 50 to 800, and a logarithmic
|
||
# axis spans whole decades so the ticks are powers of ten.
|
||
#
|
||
# The ASD frequency axis is linear, which spaces mains, its harmonics and fs/4
|
||
# evenly. The Allan panel beside it covers the low-frequency decades.
|
||
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. Its frequency axis is logarithmic, which is what puts
|
||
# captures whose rates differ several-fold on comparable footing.
|
||
FRACTIONAL_ASD_PPM = (1e0, 1e3)
|
||
FRACTIONAL_ALLAN_PPM = (1e0, 1e3)
|
||
FRACTIONAL_ASD_HZ = (1e-2, 1e3)
|
||
|
||
# Spectrogram segment length. The job here is to resolve one line from another,
|
||
# and 40 s gives 0.025 Hz, enough to separate a mains line from a sample-locked
|
||
# one.
|
||
SPECTROGRAM_SECONDS = 40.0
|
||
# Overlap buys columns, so a feature lasting a few seconds spans several of them.
|
||
SPECTROGRAM_OVERLAP = 0.96875
|
||
# Two things let captures share one colour scale. They are drawn in ppm of |B|
|
||
# per sqrt(Hz), which divides out the gain, and SPECTROGRAM_BIN_HZ averages
|
||
# native bins onto one displayed step. The window is a fixed duration, so that
|
||
# step gives every capture the same dof per cell. `--max-freq` pins the frequency
|
||
# axis too, at the cost of blank space on the slower captures.
|
||
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 its hue steps make those legible. `viridis` is the
|
||
# lightness-monotonic alternative, `sequential` the single-hue ramp below.
|
||
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, which varies far more between runs than the floor does.
|
||
BAND_LO_HZ = 3.0
|
||
# Upper edge, as a fraction of Nyquist. Half of it is held below
|
||
# DECIMATE_CUTOFF_FRACTION, the corner of the anti-alias filter, so that every
|
||
# compared path is scored on its flat band. The assert below checks it.
|
||
BAND_NYQUIST_FRACTION = 0.8
|
||
|
||
# Welch segment duration. Frequency resolution is 1/this, so 20 s resolves
|
||
# 0.05 Hz 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 across the ~20
|
||
# fractions tested. Real artifacts 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 puts ~29 points on
|
||
# a 300 s capture, with a sigma_y floor small against the drift being plotted.
|
||
DRIFT_WINDOW_S = 10.0
|
||
# Log-spaced, from a window holding a few hundred samples to a third of a 300 s
|
||
# capture, which is as far as `rate_allan` finds enough windows.
|
||
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)
|
||
RATE_TAU_S = (1.0, 1e2)
|
||
RATE_ALLAN_PPM = (1e0, 1e3)
|
||
|
||
# Sub-windows used by rate_stability. Eight over a five-minute run is ~40 s
|
||
# each, long enough to fit a rate against and short enough to see a thermal
|
||
# transient.
|
||
STABILITY_WINDOWS = 8
|
||
|
||
# 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 to leave the per-read scheduling jitter behind. Degree 1 is the uniform
|
||
# grid already in use, and nothing above 3 changed a measured amplitude here.
|
||
TIMEBASE_DEGREE = 3
|
||
# Half-width of the scan around each line, and how finely it is sampled: ~30
|
||
# resolution widths on a 300 s capture, so a peak has a visible shape.
|
||
TIMEBASE_SPAN_HZ = 0.05
|
||
TIMEBASE_POINTS = 401
|
||
TIMEBASE_PANELS = 3
|
||
# Local contrast the scan's maximum must reach against the scan's own median
|
||
# before the ratio of the two time bases carries a verdict.
|
||
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
|
||
|
||
|
||
def spec_asd_nt(cycle_count, fs):
|
||
"""Table 3-1's noise as a spectral density at the rate actually sampled.
|
||
|
||
Table 3-1 gives two figures. This uses the per-sample noise column, which
|
||
`white_sd` is directly comparable against: white noise of sd
|
||
`expected_noise_nt` sampled at fs has a one-sided amplitude spectral density
|
||
of sd * sqrt(2 / fs). Its other column, a 1.2 nT/sqrt(Hz) noise density, is
|
||
quoted at three times this rate.
|
||
"""
|
||
return rm3100.expected_noise_nt(cycle_count) * math.sqrt(2.0 / fs)
|
||
|
||
|
||
def _power_segments(v, fs, nperseg, step):
|
||
"""Yield (start, one-sided PSD) for each overlapping segment of v.
|
||
|
||
Shared by welch_asd and spectrogram, so both get the same Hann window, the
|
||
same one-sided scaling and the same linear detrend, which takes out DC and
|
||
any slow ramp before it smears across the low-frequency bins.
|
||
"""
|
||
window = np.hanning(nperseg)
|
||
scale = 1.0 / (fs * (window ** 2).sum())
|
||
index = np.arange(nperseg)
|
||
for start in range(0, len(v) - nperseg + 1, step):
|
||
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 # fold negative frequencies
|
||
yield start, spectrum
|
||
|
||
|
||
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))
|
||
psd = np.mean([s for _, s in _power_segments(v, fs, nperseg, nperseg // 2)],
|
||
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 window_rates(cap, seconds):
|
||
"""Sample rate fitted independently on each consecutive window of a capture.
|
||
|
||
Returns (centres, rates), in seconds from the start of the capture and in
|
||
Hz, with one point per window that held enough samples to fit.
|
||
|
||
`Capture.dt_true` fits one straight line through the whole run. This repeats
|
||
that fit on pieces short enough that the rate barely moves within any one of
|
||
them, which makes the drift itself measurable.
|
||
"""
|
||
seconds = float(seconds)
|
||
start = float(cap.elapsed[0])
|
||
if seconds <= 0 or cap.duration < 2 * seconds:
|
||
return np.array([]), np.array([])
|
||
centres, rates = [], []
|
||
for w in range(int(cap.duration // seconds)):
|
||
low = start + w * seconds
|
||
try:
|
||
rate = cap.restrict(low, low + seconds).true_rate_hz
|
||
except capture.CaptureError:
|
||
continue
|
||
rates.append(rate)
|
||
centres.append((w + 0.5) * seconds)
|
||
return np.array(centres), np.array(rates)
|
||
|
||
|
||
def rate_stability(cap, windows=STABILITY_WINDOWS):
|
||
"""Fractional spread of the sample rate across sub-windows of a capture.
|
||
|
||
The chip's RC oscillator warms up and every capture drifts downward through
|
||
its own run, so a single fitted `dt_true` is the average over that transient.
|
||
This measures how far the rate moved across it: the error bar on `dt_true`,
|
||
and the input to every uncertainty downstream.
|
||
"""
|
||
if len(cap.sample_index) < 64 * windows:
|
||
return 0.0
|
||
_, rates = window_rates(cap, cap.duration / windows)
|
||
if len(rates) < 2:
|
||
return 0.0
|
||
return float((rates.max() - rates.min()) / rates.mean())
|
||
|
||
|
||
def rate_allan(cap, taus):
|
||
"""Two-sample (Allan) deviation of the measured sample rate, fractional.
|
||
|
||
For each averaging time tau the rate is fitted on consecutive tau-second
|
||
windows and the deviation taken between neighbours:
|
||
|
||
sigma_y(tau) = sqrt( mean( (y[k+1] - y[k])^2 ) / 2 )
|
||
|
||
Falling with tau is measurement noise averaging down; rising with tau is
|
||
drift the measurement has begun to resolve, and a rise proportional to tau
|
||
marks a deterministic frequency ramp. The minimum is the best the rate can
|
||
be known, at the averaging time worth using.
|
||
|
||
Returns (taus, sigma_y) over the taus that yielded three or more windows.
|
||
"""
|
||
kept, devs = [], []
|
||
for tau in taus:
|
||
_, rates = window_rates(cap, tau)
|
||
if len(rates) < 3:
|
||
continue
|
||
y = rates / rates.mean()
|
||
kept.append(float(tau))
|
||
devs.append(float(np.sqrt(np.mean(np.diff(y) ** 2) / 2)))
|
||
return np.array(kept), 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.
|
||
|
||
Differencing has a +6 dB/octave response, so a narrowband line near Nyquist
|
||
is weighted about twice the broadband floor. `band_stats` gives 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*.
|
||
|
||
A fixed duration gives two captures at different rates the same number of
|
||
segments to average, which is what makes their Welch medians comparable: that
|
||
median is biased low by an amount set by the segment count.
|
||
"""
|
||
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. The
|
||
RMS integrates the whole band, lines included, so the two together say how
|
||
much of the band is line.
|
||
"""
|
||
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). Blackman buys a -74 dB peak
|
||
sidelobe for a transition band of about 5.5/ntaps, which is the trade that
|
||
matters: what the filter stops has to stay stopped. In the decimate-by-4
|
||
case the realised response measures -92 dB just past the corner and -135 dB
|
||
at 60 Hz, so mains stays out when a fast capture is decimated.
|
||
|
||
Normalised to unit gain at DC, which keeps the field at its own scale.
|
||
"""
|
||
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.
|
||
# The assert below pins it between BAND_NYQUIST_FRACTION / 2, which keeps the
|
||
# compared band on the flat part, and 0.5, which keeps the stopband out of the
|
||
# fold. That leaves a transition band of (0.5 - this)/k; Blackman needs about
|
||
# 5.5/ntaps, so 128*k+1 taps carries roughly 2x margin 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, answering two questions:
|
||
|
||
boxcar average k consecutive samples, which is what the sensor does over
|
||
a cycle count, poor stopband and all. Answers "would a slower
|
||
cycle count have given me this?"
|
||
fir a proper anti-alias filter, which removes out-of-band
|
||
interference. Answers "what does filtering in post give me?"
|
||
|
||
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}")
|
||
# 'same' pads with zeros, so the taper reaches ntaps//2 into the result. A
|
||
# full filter length off each end clears it.
|
||
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.
|
||
|
||
This finds *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 moves with the sampling, so it is an
|
||
artifact of the measurement. compare.py settles it across two captures.
|
||
|
||
The transform is evaluated at exactly j/period, which puts the whole capture
|
||
behind one number and 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. A cubic trend is removed first, which keeps drift out of the low
|
||
fractions.
|
||
"""
|
||
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 is a real alternation with no phase to fit: one
|
||
# degree of freedom, and so 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 separates a line that ran throughout from a burst.
|
||
"""
|
||
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))))
|
||
columns, times = [], []
|
||
for start, spectrum in _power_segments(v, fs, nperseg, step):
|
||
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).
|
||
|
||
Averages in *power*, squaring and rooting around the mean, which is what
|
||
adds degrees of freedom. A fixed step gives every capture the same dof per
|
||
cell.
|
||
"""
|
||
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. `sd` is the plain standard deviation of the detrended series; the
|
||
ratio is against the ideal `white_sd`/sqrt(n), so a ratio near 1 means
|
||
averaging is buying everything it should.
|
||
|
||
This is the check the cycle-count choice rests on. Section 5.1 warns that
|
||
quantisation "generally dictates working above a cycle count of ~30": 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. That shows here as a ratio climbing while the LSB
|
||
column stops falling.
|
||
|
||
A slow trend is removed first, which leaves the quantiser as the only thing
|
||
that can stop the average shrinking.
|
||
"""
|
||
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 style(ax, grid=True):
|
||
"""Apply the shared light-mode look to one axes. Used by compare.py too."""
|
||
ax.set_facecolor(SURFACE)
|
||
if grid:
|
||
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)
|
||
|
||
|
||
def title(ax, text, size=12, pad=8):
|
||
ax.set_title(text, loc="left", color=TEXT_PRIMARY, fontsize=size,
|
||
fontweight="bold", pad=pad)
|
||
|
||
|
||
def caption(cap):
|
||
"""The one-line 'file, N samples at F Hz, cycle count C' figure header."""
|
||
return (f"{cap.path.rsplit('/', 1)[-1]}: {len(cap.sample_index):,} samples "
|
||
f"at {cap.true_rate_hz:.2f} Hz, cycle count {cap.cycle_count}")
|
||
|
||
|
||
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, producing a dense scale reading 2x10^0, 3x10^0. Widening the
|
||
range to a whole decade restores the convention.
|
||
"""
|
||
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 lands. Above Nyquist it has folded, so the line
|
||
goes at the folded position and carries an "alias" label.
|
||
"""
|
||
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, which is what a spectrum
|
||
computes but accepts unevenly spaced samples. One of the two time bases
|
||
compared here is non-uniform, so it needs that.
|
||
|
||
A sample placed at the wrong time by a fraction d of a cycle contributes
|
||
cos(2*pi*d), so coherence is lost at a rate set by the tone's frequency.
|
||
That makes this the sharpest available test of a time base, on a line high
|
||
enough in frequency to lose coherence within the record.
|
||
|
||
The mean is removed first, keeping a DC offset out of 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.
|
||
|
||
`cap.elapsed` is `sample_index * dt_true`, a rate held constant across the
|
||
run. The rate in fact moves by the hundreds of ppm `rate_stability`
|
||
measures, and raw host timestamps carry that movement.
|
||
|
||
They also carry the scheduling jitter of each read. A low-order polynomial
|
||
keeps the drift and leaves the jitter behind; degree 1 reduces to
|
||
`cap.elapsed`, making the uniform grid the degree-1 member of this family.
|
||
|
||
Returned relative to the first sample, which is what makes that degree-1
|
||
case *exactly* the uniform grid: a least-squares line through a curve
|
||
passes above or below 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]
|
||
rate_drift = 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)
|
||
style(ax, grid=False)
|
||
ramp = (mcolors.LinearSegmentedColormap.from_list("sequential",
|
||
SEQUENTIAL_STEPS)
|
||
if colormap == "sequential" else colormap)
|
||
# imshow suits a grid regular in both axes and draws thousands of times
|
||
# fewer objects. 'antialiased' resamples when there are more rows than
|
||
# pixels, which keeps a one-bin line visible wherever it falls.
|
||
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. Anything at the very top is the axis boundary
|
||
# itself, 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, so labels are
|
||
# staggered across the width, which leaves both lines in place.
|
||
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))
|
||
|
||
title(ax, "Spectral density of |B| over time", size=13)
|
||
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:
|
||
# The blank above Nyquist is real missing bandwidth, and marking it
|
||
# keeps that difference between configurations visible.
|
||
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)
|
||
|
||
# Columns far outnumber the independent windows, because they overlap, so
|
||
# cell-to-cell scatter is the two degrees of freedom of a single
|
||
# periodogram. What is real stays put across many columns.
|
||
independent = max(1, int(cap.duration / seconds))
|
||
fig.text(0.5, 0.965, caption(cap),
|
||
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 sets the error bar on the left.
|
||
"""
|
||
centres, rates = 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 = 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 plotted,
|
||
# which is independent of the point scatter this figure exists to show.
|
||
floor = float(np.interp(window, taus, devs))
|
||
best = int(np.argmin(devs))
|
||
ppm = (rates / rates.mean() - 1.0) * 1e6
|
||
span = rate_stability(cap) * 1e6
|
||
|
||
fig, axs = plt.subplots(1, 2, figsize=(13.5, 5.5), dpi=150)
|
||
fig.patch.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")
|
||
title(a, "Chip oscillator against the host clock")
|
||
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 holds: regressing |B| on rate\ngives "
|
||
"+0.08 to +0.35, where 1/f needs −1.",
|
||
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")
|
||
title(a, "How well the rate can be measured")
|
||
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:
|
||
style(ax)
|
||
ax.legend(frameon=False, fontsize=9, labelcolor=TEXT_SECONDARY,
|
||
loc="upper right")
|
||
|
||
fig.text(0.5, 0.955, caption(cap),
|
||
color=TEXT_PRIMARY, fontsize=10, ha="center")
|
||
# Both drift figures are quoted deliberately: the peak-to-peak spread is
|
||
# what `rate_stability` feeds to the uncertainty propagation, the fitted
|
||
# trend uses every window and is the better estimate of the warm-up. Showing
|
||
# one invites the other to be read off the legend as 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 artifact of measuring *within a
|
||
single capture*, where compare.py needs two at different rates to do it.
|
||
|
||
The test has force on a strong line, coherence being lost in proportion to
|
||
the tone's frequency. A low-frequency or aliased line says little and a weak
|
||
one returns noise, and both are labelled as such.
|
||
"""
|
||
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, which keeps drift
|
||
# out of 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: on a capture whose |B| never moves the detrend
|
||
# still leaves ~1e-11 nT of floating-point residue, and an exact test would
|
||
# find "lines" in it.
|
||
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 at 9.8 in, which keeps a one- or two-panel figure wider 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):
|
||
style(ax)
|
||
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 already assumes a uniform grid"
|
||
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 artifact 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.
|
||
title(ax, f"{label} at {hz:.3f} Hz {ratio:.2f}×", size=11, 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.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: every other "
|
||
"figure uses the chip's grid.",
|
||
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).
|
||
|
||
Never silent: the note says what went. Never automatic either, so a figure
|
||
always describes the capture the caller named. Below 4x the requested trim
|
||
the capture is returned whole with a note, which keeps a short deliberate
|
||
capture analysable.
|
||
"""
|
||
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")
|
||
|
||
|
||
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)")
|
||
args = ap.parse_args()
|
||
|
||
# --trim and --start/--end both choose a window, so asking for both is an
|
||
# error and the reported figures stay independent of 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, so its noise tracks whichever
|
||
# axis carries most of the field.
|
||
data = dict(cap.axes())
|
||
t = cap.elapsed
|
||
fs = cap.true_rate_hz # measured, never the nominal table value
|
||
lsb_nt = cap.lsb_nt
|
||
latency = np.diff(cap.system_time) # read-to-read spacing
|
||
|
||
# Normalising by |B| makes noise comparable across captures whose scale
|
||
# differs: a gain change carries the noise with it, so absolute sd tracks
|
||
# the scale as well as the noise.
|
||
field = data["total"].mean()
|
||
band = band_for(fs)
|
||
spec_nt = rm3100.expected_noise_nt(cap.cycle_count)
|
||
|
||
print(cap.summary())
|
||
if trim_note:
|
||
print(f" {trim_note}")
|
||
# Reported without a threshold. The sensor oscillator is an LR relaxation
|
||
# circuit, none of it temperature-stable, so a tenth of a percent across a
|
||
# run is ordinary. It is the error bar on this capture's rate.
|
||
drift = rate_stability(cap)
|
||
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}")
|
||
|
||
fig, axs = plt.subplots(2, 2, figsize=(13.5, 9), dpi=150)
|
||
fig.patch.set_facecolor(SURFACE)
|
||
for ax in axs.flat:
|
||
style(ax)
|
||
|
||
for key, label, color in SERIES:
|
||
v = data[key]
|
||
sd = v.std()
|
||
# Drift-free: what Table 3-1 quotes, and what the dither margin is made
|
||
# of. The plain sd beside it is drift on a drifting capture.
|
||
wsd = white_sd(v)
|
||
|
||
freqs, asd = welch_asd(v, fs)
|
||
# Thin, because the detail between the lines is what the panel is read
|
||
# for. The Allan panel keeps a heavier stroke for its smooth curves.
|
||
axs[0, 0].semilogy(freqs, asd, color=color, linewidth=0.7,
|
||
label=label, alpha=0.85)
|
||
|
||
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, and arbitrary edges
|
||
# beat against it into ragged spikes that look like structure.
|
||
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,
|
||
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 check the cycle-count choice rests on: 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 (plain sd) -> "
|
||
f"{longest[2]:.3f} LSB after {longest[0]:,} "
|
||
f"({longest[0] / fs:.1f} s), {longest[3]:.1f}x the ideal 1/sqrt(n)")
|
||
print(" A falling LSB column means sub-LSB resolution is being "
|
||
"recovered. A ratio climbing\n while it stalls is the quantiser "
|
||
"losing its dither (section 5.1); a ratio climbing\n while it "
|
||
"falls is drift, which stays put under averaging.")
|
||
print(" The per-sample figure is plain sd, holding whatever the detrend "
|
||
"left. The ratio is\n referenced to white sd, the dither column of "
|
||
"the table above.")
|
||
|
||
# Reported per axis because this structure is not isotropic.
|
||
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(" A sample-locked line moves with the rate, which makes it an "
|
||
"artifact of\n measuring. compare.py decides it, by checking "
|
||
"whether these hold the same\n fraction of fs in a capture "
|
||
"taken at a different rate.")
|
||
|
||
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, since the spec line sits low in the range where the
|
||
# traces are densest and floating text would disappear into them.
|
||
spec_asd = spec_asd_nt(cap.cycle_count, fs)
|
||
a.axhline(spec_asd, color=REFERENCE, linestyle="--", linewidth=1.2,
|
||
label=f"Table 3-1 noise at {fs:.0f} Hz, {spec_asd:.2f} nT/√Hz")
|
||
title(a, "Amplitude spectral density")
|
||
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)
|
||
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")
|
||
|
||
a = axs[0, 1]
|
||
title(a, "Allan deviation")
|
||
a.set_xlabel("averaging time τ (s)"); a.set_ylabel("σ (nT)")
|
||
a.set_xlim(*ALLAN_TAU_S); a.set_ylim(*ALLAN_NT)
|
||
decade_ticks(a)
|
||
a.annotate("slope −½ = white noise; upturn = drift",
|
||
xy=(0.02, 0.04), xycoords="axes fraction",
|
||
color=TEXT_SECONDARY, fontsize=9)
|
||
|
||
a = axs[1, 0]
|
||
title(a, "Residual distribution (cubic trend removed)")
|
||
a.set_xlabel("nT"); a.set_ylabel("density")
|
||
a.set_xlim(*RESIDUAL_NT); a.set_ylim(*RESIDUAL_DENSITY)
|
||
|
||
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)
|
||
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),
|
||
xytext=(6, 0), textcoords="offset points",
|
||
color=TEXT_PRIMARY, fontsize=9)
|
||
title(a, "Host read latency")
|
||
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")
|
||
|
||
# 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)
|
||
|
||
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 ""),
|
||
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, since the spectrogram needs the width. Each degrades to a
|
||
# message, which keeps a short capture analysable when it can 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}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|