645 lines
30 KiB
Python
645 lines
30 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 the 1.2 nT/sqrt(Hz) the
|
||
manual quotes (Table 3-1), and against the
|
||
white-noise level implied by the sample sd.
|
||
Allan deviation where averaging stops helping and drift takes
|
||
over -- the honest measure of a noise floor.
|
||
Residual distribution after removing a slow trend, so a non-Gaussian
|
||
tail or quantisation shows up.
|
||
Read latency host-side diagnostics only -- the measurement
|
||
grid itself is uniform regardless.
|
||
|
||
Two noise figures are printed, and the difference between them matters. `sd` is
|
||
the plain standard deviation, which on a drifting capture measures the drift:
|
||
one run here reads 167.9 nT of sd on an axis whose actual noise is 12.1 nT.
|
||
`white sd` is sd(diff)/sqrt(2), a first difference that rejects anything slower
|
||
than the sample rate, and is what Table 3-1's 208/sqrt(cycle_count) should be
|
||
compared against.
|
||
|
||
`--trim` drops equal time from both ends. A capture usually starts while the
|
||
sensor is still settling and ends while it is being handled for the next run,
|
||
and neither belongs in a noise figure. Nothing is dropped unless asked.
|
||
|
||
The sample grid is exact, not assumed: the sensor samples on its own internal
|
||
schedule (manual sections 5.7.2, 5.8.2), logger.py writes a placeholder rather
|
||
than skip a grid point, and capture.py refuses a non-contiguous sample index.
|
||
Frequencies use the rate measured against the host clock, not the nominal table
|
||
value, which is ~6% out on this unit.
|
||
|
||
Rows lost during capture carry no data and are linearly interpolated on load, so
|
||
a capture with many of them will read as artificially smooth at high frequency.
|
||
capture.py's summary reports how many, and characterize.py prints it above.
|
||
"""
|
||
|
||
import argparse
|
||
import math
|
||
import sys
|
||
from collections import namedtuple
|
||
|
||
import matplotlib
|
||
matplotlib.use("Agg")
|
||
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 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]
|
||
|
||
# Table 3-1: "Noise Density @ Max. Single-Axis Sample Rate".
|
||
SPEC_ASD_NT = 1.2
|
||
|
||
# Every panel is drawn on a fixed scale so two runs can be laid side by side and
|
||
# compared by eye. Autoscaling defeats that completely: a quieter capture simply
|
||
# redraws its own axis and looks identical to a noisy one. The ranges are wide
|
||
# enough for any configuration this driver offers -- cycle counts 50 to 800, so
|
||
# rates from ~2 to ~530 Hz and LSBs from 3 to 50 nT -- and are deliberately whole
|
||
# decades so the tick labels are powers of ten.
|
||
ASD_HZ = (1e-2, 1e3) # 0.05 Hz is the lowest bin a 20 s segment gives
|
||
ASD_NT = (1e-1, 1e2) # floor ~1 nT/rtHz, lines to ~15
|
||
ALLAN_TAU_S = (1e-3, 1e3) # one sample period to a 1000 s average
|
||
ALLAN_NT = (1e-1, 1e2)
|
||
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)
|
||
|
||
# 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
|
||
|
||
|
||
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 welch_asd(v, fs, nperseg=4096):
|
||
"""Amplitude spectral density in units/sqrt(Hz) via Welch's method."""
|
||
nperseg = min(nperseg, len(v) // 4 * 2 or len(v))
|
||
step = nperseg // 2
|
||
window = np.hanning(nperseg)
|
||
# Normalisation for a one-sided PSD with this window.
|
||
scale = 1.0 / (fs * (window ** 2).sum())
|
||
|
||
segments = []
|
||
for start in range(0, len(v) - nperseg + 1, step):
|
||
seg = v[start:start + nperseg]
|
||
# Linear detrend: removes DC and any slow ramp that would smear
|
||
# energy across the low-frequency bins.
|
||
seg = seg - np.polyval(np.polyfit(np.arange(nperseg), seg, 1),
|
||
np.arange(nperseg))
|
||
spectrum = np.abs(np.fft.rfft(seg * window)) ** 2 * scale
|
||
spectrum[1:-1] *= 2.0 # fold negative frequencies
|
||
segments.append(spectrum)
|
||
|
||
psd = np.mean(segments, axis=0)
|
||
freqs = np.fft.rfftfreq(nperseg, 1.0 / fs)
|
||
return freqs[1:], np.sqrt(psd[1:]) # drop DC bin
|
||
|
||
|
||
def allan_deviation(v, fs, points=40):
|
||
"""Overlapping Allan deviation of the signal against averaging time tau."""
|
||
n = len(v)
|
||
max_m = n // 4
|
||
ms = np.unique(np.geomspace(1, max(max_m, 2), points).astype(int))
|
||
taus, devs = [], []
|
||
cumulative = np.concatenate([[0.0], np.cumsum(v)])
|
||
|
||
for m in ms:
|
||
# Bin means of length m, taken at every offset (overlapping).
|
||
means = (cumulative[m:] - cumulative[:-m]) / m
|
||
diffs = means[m:] - means[:-m]
|
||
if diffs.size < 2:
|
||
continue
|
||
taus.append(m / fs)
|
||
devs.append(np.sqrt(0.5 * np.mean(diffs ** 2)))
|
||
return np.array(taus), np.array(devs)
|
||
|
||
|
||
def white_sd(v):
|
||
"""Per-sample noise with drift removed, in the units of v.
|
||
|
||
The first difference of white noise has twice its variance, so dividing by
|
||
sqrt(2) recovers the original sd -- while any component slower than the
|
||
sample rate is differenced away. On a capture that drifts, this and the
|
||
plain sd measure entirely different things, and only this one is the noise.
|
||
|
||
It is not free: differencing has a +6 dB/octave response, so a narrowband
|
||
line near Nyquist is weighted about twice as heavily as the broadband floor.
|
||
Use band_stats for a figure that is flat across the band.
|
||
"""
|
||
if len(v) < 2:
|
||
return 0.0
|
||
return float(np.diff(v).std() / np.sqrt(2))
|
||
|
||
|
||
def segment_length(fs, seconds=SEGMENT_SECONDS):
|
||
"""Welch segment length in samples for a fixed segment *duration*.
|
||
|
||
Holding the duration fixed rather than the sample count is what makes two
|
||
captures at different rates comparable. A fixed nperseg gives them different
|
||
frequency resolutions and, worse, different numbers of segments to average:
|
||
the median of a Welch estimate is biased low by an amount that depends on
|
||
that count, so a slower capture would read as a quieter one by a few percent
|
||
for no physical reason at all.
|
||
"""
|
||
return max(64, int(round(fs * seconds)))
|
||
|
||
|
||
def band_stats(v, fs, band=None, nperseg=None):
|
||
"""Broadband figures over a stated band: (median ASD, RMS, peak, peak Hz).
|
||
|
||
The median is the floor -- robust to the handful of bins a line occupies --
|
||
while the RMS integrates everything in the band including those lines, so
|
||
the two together say how much of the band is line rather than floor.
|
||
"""
|
||
lo, hi = band if band is not None else band_for(fs)
|
||
freqs, asd = welch_asd(v, fs, nperseg=nperseg or segment_length(fs))
|
||
keep = (freqs >= lo) & (freqs <= hi)
|
||
if not keep.any():
|
||
nan = float("nan")
|
||
return nan, nan, nan, nan
|
||
f, a = freqs[keep], asd[keep]
|
||
return (float(np.median(a)), float(np.sqrt(np.trapezoid(a ** 2, f))),
|
||
float(a.max()), float(f[np.argmax(a)]))
|
||
|
||
|
||
def fir_lowpass(cutoff, ntaps):
|
||
"""Linear-phase low-pass FIR: a sinc truncated by a Blackman window.
|
||
|
||
`cutoff` is in cycles per sample (0 to 0.5). Written out rather than taken
|
||
from scipy, which is not a dependency here and would be a large one for one
|
||
filter. Blackman buys a -74 dB stopband for a transition band of about
|
||
5.5/ntaps, which is the trade that matters: the whole point of the filter is
|
||
that what it stops does not fold back.
|
||
|
||
Normalised to unit gain at DC, so decimating does not rescale the field.
|
||
"""
|
||
if not 0 < cutoff < 0.5:
|
||
raise ValueError(f"cutoff {cutoff} must be in (0, 0.5) cycles/sample")
|
||
ntaps = int(ntaps) | 1 # odd, so the delay is a whole sample
|
||
if ntaps < 3:
|
||
raise ValueError(f"ntaps {ntaps} is too short to filter anything")
|
||
n = np.arange(ntaps) - (ntaps - 1) / 2
|
||
h = 2 * cutoff * np.sinc(2 * cutoff * n) * np.blackman(ntaps)
|
||
return h / h.sum()
|
||
|
||
|
||
# Where the anti-alias filter turns over, as a fraction of the decimated rate.
|
||
# It must sit above BAND_NYQUIST_FRACTION / 2 so that nothing compared is
|
||
# scored on the rolloff, and below 0.5 so that nothing folds back. The
|
||
# transition band left over is (0.5 - this)/k, and Blackman gives about
|
||
# 5.5/ntaps, so the tap count has to grow with k: 128*k+1 leaves roughly 2x
|
||
# margin on that and is odd by construction.
|
||
DECIMATE_CUTOFF_FRACTION = 0.45
|
||
assert BAND_NYQUIST_FRACTION / 2 < DECIMATE_CUTOFF_FRACTION < 0.5
|
||
|
||
|
||
def decimate(v, k, method="fir"):
|
||
"""Downsample by an integer k, low-passing first so nothing folds in.
|
||
|
||
Two filters, because they answer different questions:
|
||
|
||
boxcar average k consecutive samples. This is what the sensor itself
|
||
does over a cycle count, so it is the right comparison for "would
|
||
a slower cycle count have given me this?" -- and it shares the
|
||
boxcar's poor stopband, which is why the sensor aliases.
|
||
fir a proper anti-alias filter. This is what you would actually do in
|
||
post, and it is the one that removes out-of-band interference
|
||
instead of folding it.
|
||
|
||
Returns the decimated series; the new rate is fs/k.
|
||
"""
|
||
k = int(k)
|
||
if k < 1:
|
||
raise ValueError(f"decimation factor {k} must be at least 1")
|
||
if k == 1:
|
||
return np.asarray(v, dtype=float)
|
||
v = np.asarray(v, dtype=float)
|
||
if method == "boxcar":
|
||
n = len(v) // k * k
|
||
if n < k:
|
||
raise ValueError(f"{len(v)} samples cannot be decimated by {k}")
|
||
return v[:n].reshape(-1, k).mean(axis=1)
|
||
if method != "fir":
|
||
raise ValueError(f"unknown decimation method {method!r}")
|
||
h = fir_lowpass(DECIMATE_CUTOFF_FRACTION / k, 128 * k + 1)
|
||
if len(v) <= 2 * len(h):
|
||
raise ValueError(
|
||
f"{len(v)} samples is too few for a {len(h)}-tap filter; "
|
||
f"decimating by {k} needs at least {2 * len(h) + 1}")
|
||
# Discard a full filter length at each end rather than half: 'same' pads
|
||
# with zeros, so the taper reaches ntaps//2 in and a half-length trim would
|
||
# leave the tail of it in the data.
|
||
return np.convolve(v, h, mode="same")[len(h):-len(h)][::k]
|
||
|
||
|
||
SampleLine = namedtuple("SampleLine", "numerator period amplitude sigma")
|
||
|
||
|
||
def sample_locked_lines(v, max_period=8, threshold=LINE_SIGMA):
|
||
"""Tones sitting at a simple rational fraction of the sample rate.
|
||
|
||
Returns SampleLine(numerator, period, amplitude, sigma) for each surviving
|
||
fraction numerator/period of fs, strongest first. `amplitude` is the peak
|
||
amplitude of the tone in the units of v.
|
||
|
||
What this finds that a spectrum cannot distinguish is *what a line is locked
|
||
to*. A line at a fixed frequency lands on a different fraction of fs when
|
||
the rate changes; one that stays at fs/4 across captures whose rates differ
|
||
is locked to the sampling, so it is an artefact of the measurement rather
|
||
than a field. Comparing two captures settles it -- see compare.py.
|
||
|
||
Evaluating the transform at exactly j/period rather than reading a spectrum
|
||
puts the whole capture behind one number, which is what makes a 1 nT tone
|
||
detectable at 30 sigma under 20 nT of broadband noise.
|
||
|
||
Only fractions in lowest terms are tested, so each frequency is reported
|
||
once. Without that, a period-4 pattern would also be reported at 6 and 8,
|
||
which are multiples of it and carry no additional information.
|
||
|
||
A cubic trend is removed first, so drift cannot leak into a low fraction.
|
||
"""
|
||
v = np.asarray(v, dtype=float)
|
||
if len(v) < 4 * max_period:
|
||
return []
|
||
index = np.arange(len(v))
|
||
v = v - np.polyval(np.polyfit(index, v, 3), index)
|
||
sd = v.std()
|
||
if sd == 0:
|
||
return []
|
||
found = []
|
||
for period in range(2, max_period + 1):
|
||
for numerator in range(1, period // 2 + 1):
|
||
if math.gcd(numerator, period) != 1:
|
||
continue
|
||
if 2 * numerator == period:
|
||
# Nyquist has no phase to fit -- it is a real alternation, so
|
||
# one degree of freedom rather than two, and a tighter null.
|
||
amplitude = abs(float((v * (-1.0) ** index).mean()))
|
||
sigma = amplitude / (sd / np.sqrt(len(v)))
|
||
else:
|
||
phase = np.exp(-2j * np.pi * numerator / period * index)
|
||
amplitude = 2 * abs(complex((v * phase).mean()))
|
||
# Real and imaginary parts each have variance 2 sd^2 / len, so
|
||
# the magnitude is Rayleigh(1) in these units under the null:
|
||
# 5 sigma is a one-in-270,000 fluctuation.
|
||
sigma = amplitude / (sd * np.sqrt(2 / len(v)))
|
||
if sigma >= threshold:
|
||
found.append(SampleLine(numerator, period, float(amplitude),
|
||
float(sigma)))
|
||
return sorted(found, key=lambda line: -line.sigma)
|
||
|
||
|
||
def dither_check(v, lsb, longest=1024):
|
||
"""Does averaging still recover resolution below one quantiser step?
|
||
|
||
Returns [(n, sd, sd_in_lsb, ratio_to_ideal), ...] for block averages of n
|
||
samples. Ideal is sd(1)/sqrt(n), so a ratio near 1 means averaging is
|
||
buying everything it should.
|
||
|
||
This is the check the whole cycle-count choice rests on. Section 5.1 warns
|
||
that quantisation "generally dictates working above a cycle count of ~30",
|
||
and the failure it warns about is specific: if the sensor's own noise is
|
||
small against the LSB, samples stop straddling the boundary, the quantiser
|
||
stops being dithered, and averaging stalls at a fixed fraction of an LSB no
|
||
matter how long you average. What that looks like here is a ratio that
|
||
climbs while the LSB column stops falling.
|
||
|
||
A slow trend is removed first. Otherwise drift -- which averaging cannot
|
||
remove and is not supposed to -- is what stops the average shrinking, and
|
||
the answer would be about the site rather than the quantiser.
|
||
"""
|
||
v = np.asarray(v, dtype=float)
|
||
index = np.arange(len(v))
|
||
v = v - np.polyval(np.polyfit(index, v, 5), index)
|
||
base = white_sd(v)
|
||
if base == 0:
|
||
return []
|
||
out = []
|
||
n = 1
|
||
while n <= longest and len(v) // n >= 16:
|
||
usable = len(v) // n * n
|
||
means = v[:usable].reshape(-1, n).mean(axis=1)
|
||
sd = float(means.std())
|
||
out.append((n, sd, sd / lsb, sd / (base / np.sqrt(n))))
|
||
n *= 4
|
||
return out
|
||
|
||
|
||
def decade_ticks(ax, which="both", minimum_decades=1.0):
|
||
"""Label a log axis at powers of ten, with unlabelled minors between.
|
||
|
||
Matplotlib falls back to labelling minor ticks when a log axis spans less
|
||
than a decade, which produces a scale reading 2x10^0, 3x10^0, 4x10^0 --
|
||
dense, and not what a log axis is read as. Decades are the convention, so
|
||
the range is widened to hold at least one rather than the labelling being
|
||
changed to suit a narrow range.
|
||
"""
|
||
axes = {"x": [ax.xaxis], "y": [ax.yaxis],
|
||
"both": [ax.xaxis, ax.yaxis]}[which]
|
||
for axis in axes:
|
||
setter = ax.set_xlim if axis is ax.xaxis else ax.set_ylim
|
||
lo, hi = ax.get_xlim() if axis is ax.xaxis else ax.get_ylim()
|
||
if lo > 0 and hi > lo:
|
||
short = minimum_decades - math.log10(hi / lo)
|
||
if short > 0:
|
||
pad = 10 ** (short / 2)
|
||
setter(lo / pad, hi * pad)
|
||
axis.set_major_locator(mticker.LogLocator(base=10.0))
|
||
axis.set_minor_locator(
|
||
mticker.LogLocator(base=10.0, subs=tuple(range(2, 10))))
|
||
axis.set_minor_formatter(mticker.NullFormatter())
|
||
|
||
|
||
def 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")
|
||
|
||
|
||
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)")
|
||
args = ap.parse_args()
|
||
|
||
# --trim and --start/--end both choose a window, and silently letting one
|
||
# win would make the reported figures depend on argument order.
|
||
if args.trim and (args.start or args.end is not None):
|
||
ap.error("--trim sets the window from both ends; it cannot be combined "
|
||
"with --start/--end, which set it explicitly")
|
||
|
||
try:
|
||
cap = capture.load(args.csv)
|
||
if args.start or args.end is not None:
|
||
cap = cap.restrict(args.start or None, args.end)
|
||
whole = cap.duration
|
||
cap, trim_note = trimmed(cap, args.trim)
|
||
trim_applied = cap.duration < whole
|
||
except (OSError, capture.CaptureError) as exc:
|
||
sys.exit(str(exc))
|
||
|
||
# Total field is derived from the three axes; its noise is dominated by
|
||
# whichever axis carries most of the field, weighted by direction cosines,
|
||
# so it tracks that axis rather than being an independent measurement.
|
||
data = dict(cap.axes())
|
||
t = cap.elapsed
|
||
# Rate measured against the host clock, not the nominal table value: a 6%
|
||
# error would put every spectral feature 6% off.
|
||
fs = cap.true_rate_hz
|
||
lsb_nt = cap.lsb_nt
|
||
# Host read latency, kept apart from the sample grid it does not affect.
|
||
latency = np.diff(cap.system_time) # local read spacing, not the fit residual
|
||
|
||
# Normalising by |B| makes noise comparable across captures whose scale
|
||
# differs -- a gain change carries the noise with it, so absolute sd alone
|
||
# will read as a noise difference when only the scale moved.
|
||
field = data["total"].mean()
|
||
band = band_for(fs)
|
||
# Table 3-1's noise figure for this cycle count -- what `white sd` is the
|
||
# measurement of, so the two belong in the same table.
|
||
spec_nt = rm3100.expected_noise_nt(cap.cycle_count)
|
||
|
||
print(cap.summary())
|
||
if trim_note:
|
||
print(f" {trim_note}")
|
||
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:
|
||
ax.set_facecolor(SURFACE)
|
||
|
||
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
|
||
# really made of. The plain sd above it is drift on a drifting capture.
|
||
wsd = white_sd(v)
|
||
|
||
freqs, asd = welch_asd(v, fs)
|
||
axs[0, 0].loglog(freqs, asd, color=color, linewidth=1.2,
|
||
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 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.
|
||
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 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.")
|
||
|
||
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)
|
||
a.axhline(SPEC_ASD_NT, color=REFERENCE, linestyle="--", linewidth=1.2)
|
||
a.annotate(f"Table 3-1 spec {SPEC_ASD_NT} nT/√Hz", xy=(freqs[1], SPEC_ASD_NT),
|
||
xytext=(0, 5), textcoords="offset points",
|
||
color=REFERENCE, fontsize=9)
|
||
a.set_title("Amplitude spectral density", loc="left",
|
||
color=TEXT_PRIMARY, fontsize=12, fontweight="bold", pad=8)
|
||
a.annotate(f"shaded: {band[0]:g}–{band[1]:.0f} Hz, the quoted band",
|
||
xy=(0.02, 0.04), xycoords="axes fraction",
|
||
color=TEXT_SECONDARY, fontsize=9)
|
||
a.set_xlabel("frequency (Hz)"); a.set_ylabel("nT/√Hz")
|
||
a.set_xlim(*ASD_HZ); a.set_ylim(*ASD_NT)
|
||
decade_ticks(a)
|
||
|
||
a = axs[0, 1]
|
||
a.set_title("Allan deviation", loc="left",
|
||
color=TEXT_PRIMARY, fontsize=12, fontweight="bold", pad=8)
|
||
a.set_xlabel("averaging time τ (s)"); a.set_ylabel("σ (nT)")
|
||
a.set_xlim(*ALLAN_TAU_S); a.set_ylim(*ALLAN_NT)
|
||
decade_ticks(a)
|
||
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.annotate(f"bins are {lsb_nt / 4:.2f} nT, a quarter LSB.\n"
|
||
f"Integer counts make this a comb at {lsb_nt:.1f} nT however\n"
|
||
f"well dithered; how sharp it looks only tracks how far\n"
|
||
f"the axis drifted. Whether averaging still beats the\n"
|
||
f"quantiser is the dither figure printed above.",
|
||
xy=(0.02, 0.97), xycoords="axes fraction", va="top",
|
||
color=TEXT_SECONDARY, fontsize=9)
|
||
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)
|
||
a.set_title("Host read latency", loc="left",
|
||
color=TEXT_PRIMARY, fontsize=12, fontweight="bold", pad=8)
|
||
a.annotate("spread is host-side 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")
|
||
|
||
for ax in axs.flat:
|
||
ax.grid(True, which="both", color=GRID, linewidth=0.7)
|
||
ax.set_axisbelow(True)
|
||
for side in ("top", "right"):
|
||
ax.spines[side].set_visible(False)
|
||
for side in ("left", "bottom"):
|
||
ax.spines[side].set_color(GRID)
|
||
ax.tick_params(colors=TEXT_SECONDARY, labelsize=9, length=0)
|
||
ax.xaxis.label.set_color(TEXT_SECONDARY)
|
||
ax.yaxis.label.set_color(TEXT_SECONDARY)
|
||
# Explicit placement: "best" puts the ASD legend on top of the spec-line
|
||
# annotation in the lower left.
|
||
for ax, loc in ((axs[0, 0], "upper right"), (axs[0, 1], "upper right"),
|
||
(axs[1, 0], "upper right")):
|
||
ax.legend(frameon=False, fontsize=9, labelcolor=TEXT_SECONDARY, loc=loc)
|
||
|
||
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"Sample grid is exact."
|
||
+ (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}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|