#!/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()