Some updates to the noise floor testing scripts.

This commit is contained in:
Jeremy Karst 2026-08-23 22:11:50 -04:00
parent 45a6307c6c
commit c2bb3efa93
4 changed files with 455 additions and 47 deletions

View file

@ -27,6 +27,9 @@ are no longer right.
| "Pair cycle count with a TMRC step: 200/0x94, 400/0x95, 800/0x96" | the cycle-count rate model | Hold TMRC fast and use cycle count as the only rate knob. The pairing advice exists only because the rate was being set by TMRC | | "Pair cycle count with a TMRC step: 200/0x94, 400/0x95, 800/0x96" | the cycle-count rate model | Hold TMRC fast and use cycle count as the only rate knob. The pairing advice exists only because the rate was being set by TMRC |
| "Abort the run on a missed measurement" | placeholder rows | The run continues and writes a `MISSED` row, keeping `sample_index` contiguous | | "Abort the run on a missed measurement" | placeholder rows | The run continues and writes a `MISSED` row, keeping `sample_index` contiguous |
| `~84,333 / cc / 3` for the sensor ceiling | fitting overhead separately | `1 / (3 × (cc/90,000 + 68.7 µs))`. The naive constant is not constant — it drifts from 84,429 at cc=100 to 89,191 at cc=1200 | | `~84,333 / cc / 3` for the sensor ceiling | fitting overhead separately | `1 / (3 × (cc/90,000 + 68.7 µs))`. The naive constant is not constant — it drifts from 84,429 at cc=100 to 89,191 at cc=1200 |
| "3.3 V vs 3.0 V — indistinguishable" | interleaved A/B at two cycle counts, 2026-08-23 | Indistinguishable in *noise*, yes. But the **scale differs by 7.1%** and the **sample rate by 4.94%**. The earlier entry could not see it because the sensor had moved and the two effects were confounded |
| "60 Hz is 25× the noise floor and dominates every sd figure" | the same session, at 285299 Hz | True at ~35 Hz where it folds to 9 Hz. At the default rate mains is resolved in band at only **1.71.8×** the floor. At cc=400 the dominant line is not mains at all but an **fs/4 artefact** |
| "68.7 µs per-axis overhead" | solving the model per supply | Right in order, wrong in value for this unit: **40.6 µs at 3.006 V, 38.1 µs at 3.353 V**. The 90,000 counts/s divisor holds up; the overhead does not, and it moves with the supply |
--- ---
@ -62,6 +65,10 @@ as Earth's field.
## Supply A/B: 3.3 V adapter vs 3.0 V LDO — indistinguishable ## Supply A/B: 3.3 V adapter vs 3.0 V LDO — indistinguishable
> **Superseded in the noise conclusion only, and wrong on scale.** The
> interleaved re-run below finds the scale differs by 7.1% and the sample rate by
> 4.94%. This entry could not separate either from the sensor having moved.
Two 60 s captures on the bench, cycle count 200, TMRC 0x96, 400 kHz. Two 60 s captures on the bench, cycle count 200, TMRC 0x96, 400 kHz.
| | 3V3 adapter | 3.0 V LDO | | | 3V3 adapter | 3.0 V LDO |
@ -122,6 +129,12 @@ magnitude difference to gain unless both movement checks pass.
At ~35 Hz the largest spectral feature by far was a line near 9 Hz reaching At ~35 Hz the largest spectral feature by far was a line near 9 Hz reaching
100125 nT/√Hz against a 45 nT/√Hz broadband floor — **25× the noise floor**. 100125 nT/√Hz against a 45 nT/√Hz broadband floor — **25× the noise floor**.
> **The 25× is specific to that rate and that site.** Measured at 285299 Hz on
> 2026-08-23, with mains resolved in band rather than folded, the 60 Hz line
> reaches only **1.71.8×** the floor, and at cycle count 400 the dominant line
> is an fs/4 artefact rather than mains. The mechanism below is unchanged; the
> "dominant term" framing is not general.
It is 60 Hz aliasing, proven by making it move: It is 60 Hz aliasing, proven by making it move:
| capture | fs | observed peak | predicted 60 Hz alias | | capture | fs | observed peak | predicted 60 Hz alias |
@ -435,14 +448,90 @@ the CH347's 3V3 rail ripple actually measures against the 50 mV limit.
--- ---
## Interleaved supply A/B, 2026-08-23 — the scale factor, measured
Four captures, LDO / 3V3 / LDO / 3V3 at cycle counts 100, 100, 400, 400, ~6
minutes each, rails measured at the board: **3.006 V** and **3.353 V**. Full
write-up, figures and raw output in
[Noise_Floor_Testing/NOISE_FLOOR.md](Noise_Floor_Testing/NOISE_FLOOR.md); this is
the summary and the things that changed the tooling.
**The oscillator moves with the supply.** +4.94% from 3.006 V to 3.353 V,
measured independently at two cycle counts and agreeing to **0.04%** — V^+0.44.
This is the cleanest number in the session because, unlike |B|, it cannot be
contaminated by the sensor moving.
**|B| moves the other way, by 7.1%** (3,245 nT), consistent across both cycle
counts to 0.74%. Exactly ratiometric with the rail would be V^1; measured is
V^0.65, and correcting by the rail overshoots by +3.5 to +4.3% against a
run-to-run reproducibility of 2.33.1%. Correcting by the **measured sample
rate** instead leaves 2.6 to 1.9%, inside that floor — and needs nothing
external, since the logger already calibrates the period every run. Applying both
corrections is clearly wrong (+8.7%): the rate change *is* the supply change seen
through the oscillator, so stacking them counts it twice.
**The sensor still moved**, 2.823° of rotation between pairs. |B| survives a
rotation, so this does not invalidate the above, but translation through a
gradient would, and only a clamped re-run can bound that.
**Cycle count 400 carries an artefact that cycle count 100 does not.** Tones at
exactly fs/4 and fs/2 of each capture's *own* rate — the two cc=400 runs differ
by 4.9% in rate and each peaks at its own fs/4, so it is locked to the sampling
rather than to any frequency. On one axis it reaches 2.43 nT at 25.7σ. Mechanism
unresolved; it is not a host-side period, since 4 samples is 52.1 ms at one rate
and 54.7 ms at the other.
It also **grows with the supply**: comparing the rotation-invariant vector
magnitude of the tone, 3V3 is **1.217 ± 0.086×** the LDO, agreeing between the
fs/4 and fs/2 harmonics. That excludes a fixed digital pattern (which would be
1.000, +2.5σ) and external pickup (0.935, +3.3σ), and sits between V and V²,
which two rail voltages cannot separate.
**Filtering and decimating a fast capture matches sampling slowly**, which the
README had been asserting without evidence. Decimating a cc=100 capture by 4
changes its own broadband floor by **+0.3 to +0.5%** — a comparison with no
second run in it, so no gain or position difference to confound it. It is also
strictly better: the decimated path carries none of the fs/4 artefact, and a
proper anti-alias filter removes 60 Hz before it can fold, which sampling slowly
cannot.
**The dither question is settled at cycle count 100.** 0.650.81 LSB per sample,
and averaging 1,024 samples reaches 0.0350.050 LSB — within 1.52.1× of ideal.
A comb in the residual histogram is not evidence against this: the residual of an
integer-valued signal is a comb at one LSB by construction, and its sharpness
tracks only how far that axis drifted.
**What this changed in the tooling.** Four estimator bugs surfaced, all of which
would have biased a supply comparison:
- `compare.py` scored each capture over its own band, so a 299 Hz run was judged
over 3135 Hz and a 73 Hz one over 333. One common band now, chosen to sit
inside the slowest path compared *including decimated ones*.
- Welch segments were a fixed sample count, so captures at different rates got
different numbers of segments; the median of a Welch estimate is biased low by
an amount that depends on that count. A fixed 20 s segment duration now.
- Broadband noise was reported only in absolute nT/√Hz, which credits a low gain
as a low noise floor. Reported fractionally too, and the figures are fractional.
- `characterize.py` autoscaled every panel, so a quiet capture and a noisy one
drew identical-looking plots. Fixed axes now.
---
## Open questions ## Open questions
- Re-run the LDO comparison with the sensor **clamped**, to separate gain from - Re-run the LDO comparison with the sensor **clamped**, and in an order that is
movement. This is the one experiment the A/B entry above failed to perform. not degenerate. The 2026-08-23 session used L,3,L,3 against cycle counts
100,100,400,400, which makes `t = supply + 2·cc` exactly — supply, cycle count
and a linear drift cannot all three be fitted. L,3,3,L breaks it.
- Identify the **fs/4 artefact** at cycle count 400. A cc=200 run at both
supplies would say whether it scales with cycle count or appears at a threshold.
- Fix `rm3100.AXIS_OVERHEAD_S`: 68.7 µs against 3841 µs measured on this unit,
which puts the cc100:cc400 rate ratio 1.9% out.
- Compare |B| against IGRF/WMM for the capture site (39.661262° N,
82.604173° W, 255 m). Not a calibration standard — indoor hard and soft iron
are unknown and nothing here is corrected — but it is the only absolute
reference available, and it would say which rail reads closer to right.
- Deal with 60 Hz coupling at source — standoff distance, then a repeat - Deal with 60 Hz coupling at source — standoff distance, then a repeat
characterisation — rather than only sampling fast enough to see it. characterisation — rather than only sampling fast enough to see it.
- Check the dither assumption at cycle count 100 against a genuinely quiet site.
`characterize.py` prints `sd/LSB` per axis; the concern is that a sensor
quieter than spec has less dither than the 0.79 LSB the spec figure implies.
- Reliability over extended runs on bare metal, where the VM ceilings above do - Reliability over extended runs on bare metal, where the VM ceilings above do
not apply. not apply.

108
README.md
View file

@ -89,12 +89,29 @@ requirement. §4.2.3 adds a placement rule: *"Keep capacitors, especially tantal
capacitors, far away from the sensor coils"* — a magnetic concern, not an capacitors, far away from the sensor coils"* — a magnetic concern, not an
electrical one. electrical one.
**This rig runs at 3.3 V, but every number in Table 3-1 is quoted at 3.0 V** **Every number in Table 3-1 is quoted at 3.0 V** (footnote 1: *"performance will
(footnote 1: *"performance will differ"*). 3.3 V is comfortably in range, but the differ"*). 3.353 V is comfortably in range, but the gain fit behind
gain fit behind `tesla_per_count` is strictly a 3.0 V figure, so the scale factor `tesla_per_count` is strictly a 3.0 V figure, so running there is an
is an extrapolation off the calibration point. A 3.0 V LDO was built and compared extrapolation off the calibration point. **This has now been measured**, on
— see [NOTES.md](NOTES.md), which also covers what the manual does and does not interleaved captures at 3.006 V and 3.353 V:
say about ripple.
| | effect of +0.347 V | as a power of V |
|---|---|---|
| **Sample rate** | **+4.94%** (repeats to 0.04%) | V^+0.44 |
| **Total field \|B\|** | **7.1%**, about 3,245 nT | V^0.65 |
| Broadband noise | none resolvable, ~4% fractionally | — |
So **supply quality does not set the noise floor, but supply *voltage* sets the
scale**, at roughly 20 %/V. A ±1% regulator at 3.0 V holds scale to ~0.2%; a
USB-derived rail free to move ±5% holds it to ~1%, on top of the ~7% fixed offset
from sitting off the calibration point.
The chip's oscillator carries the same dependence, which is useful: the
calibrated period every capture already records tracks the rail without a
voltmeter, and correcting |B| by the measured rate removes most of the offset.
Full analysis, including what is *not* settled, in
[Noise_Floor_Testing/NOISE_FLOOR.md](Noise_Floor_Testing/NOISE_FLOOR.md).
[NOTES.md](NOTES.md) covers what the manual does and does not say about ripple.
## Setup ## Setup
@ -194,23 +211,45 @@ host clock. Both appear in the capture header.
./.venv/bin/python plot.py capture.csv # -> capture.png: X, Y, Z, |B| ./.venv/bin/python plot.py capture.csv # -> capture.png: X, Y, Z, |B|
./.venv/bin/python plot.py capture.csv --smooth 0 # raw trace only ./.venv/bin/python plot.py capture.csv --smooth 0 # raw trace only
./.venv/bin/python characterize.py capture.csv # -> capture_noise.png + summary ./.venv/bin/python characterize.py capture.csv # -> capture_noise.png + summary
./.venv/bin/python characterize.py capture.csv --start 60 --end 360 ./.venv/bin/python characterize.py capture.csv --trim 30 # drop settling/handling
./.venv/bin/python compare.py --group note *.csv # A/B two conditions ./.venv/bin/python compare.py --group note *.csv # A/B two conditions
./.venv/bin/python compare.py --trim 30 --supply LDO=3.006 --supply 3V3=3.353 \
LDO/cc100=a.csv 3V3/cc100=b.csv LDO/cc400=c.csv 3V3/cc400=d.csv -o cmp.png
./.venv/bin/python sweep.py --rates 10,50,150 # measure what each rate delivers ./.venv/bin/python sweep.py --rates 10,50,150 # measure what each rate delivers
``` ```
- **`plot.py`** — four stacked panels with independent y-scales, since the three - **`plot.py`** — four stacked panels with independent y-scales, since the three
axes sit at very different DC offsets. axes sit at very different DC offsets.
- **`characterize.py`** — amplitude spectral density against the 1.2 nT/√Hz of - **`characterize.py`** — amplitude spectral density against the 1.2 nT/√Hz of
Table 3-1, Allan deviation, residual distribution, and host read latency. The Table 3-1, Allan deviation, residual distribution, and host read latency, all
`sd/LSB` column is what settles whether the quantiser is properly dithered. on **fixed axes** so two runs can be laid side by side. Reports `white sd`
- **`compare.py`** — reports noise **fractionally** (ppm of |B|), because a gain (`sd(diff)/√2`, which rejects drift) beside the plain `sd`, and a **dither
change carries the noise with it and an absolute comparison reads a pure scale check** — whether averaging still gets below one quantiser step. Flags any line
change as a noise difference. It also refuses to attribute a magnitude sitting at a simple fraction of the sample rate. Also writes a
difference to gain unless the sensor demonstrably held still. `_spectrogram.png`: |B| in time and frequency at 0.025 Hz resolution, with
fs/4 and mains marked — where mains has folded, the line is drawn at the alias
and labelled as one. Drawn in **ppm of |B| per √Hz** on a fixed colour scale,
with native bins averaged to a fixed 0.1 Hz step so every capture gets the
same degrees of freedom per cell; both are needed before one scale across
several captures means anything. `--spectrogram`, `--overlap`, `--max-freq`
and `--colormap` tune it; the default `turbo` makes narrow lines legible,
`viridis` is the lightness-monotonic option.
- **`compare.py`** — everything cross-capture. Reports noise **fractionally**
(ppm of |B|), because a gain change carries the noise with it and an absolute
comparison reads a pure scale change as a noise difference. Also: one common
band for every capture; the timing model solved per condition; whether a line
is locked to the sampling or to a frequency; whether filtering and decimating a
fast capture matches a natively slow one; and |B| under each candidate
correction. Label captures `CONDITION/variant=path` — the part before the `/`
is the thing under test, and `--supply CONDITION=VOLTS` attaches a rail to it.
- **`sweep.py`** — runs `logger.py` once per target rate and tabulates what each - **`sweep.py`** — runs `logger.py` once per target rate and tabulates what each
configuration actually achieved. configuration actually achieved.
`--trim SECONDS` drops that much from *both* ends of a capture. A run usually
opens while the sensor is still settling and closes with a hand on the rig, and
neither end is a noise measurement. Nothing is dropped unless asked, and a
capture shorter than 4× the trim is analysed whole with a note rather than gutted.
A capture is analysable whether the run ended on `--duration`, on **Ctrl-C**, or A capture is analysable whether the run ended on `--duration`, on **Ctrl-C**, or
early — it is valid up to wherever it stopped, so the analysis tools can be early — it is valid up to wherever it stopped, so the analysis tools can be
pointed at it either way. pointed at it either way.
@ -353,6 +392,12 @@ how *often* one is started.
| cycle count | `3 × (cc/90,000 + 68.7 µs)` per measurement | continuous | | cycle count | `3 × (cc/90,000 + 68.7 µs)` per measurement | continuous |
| TMRC | how often a measurement starts | factor-of-two steps | | TMRC | how often a measurement starts | factor-of-two steps |
> The 90,000 counts/s is the specified figure and holds up; **the 68.7 µs
> overhead does not.** Solving the model against two cycle counts on this unit
> gives 40.6 µs at 3.0 V and 38.1 µs at 3.35 V, so the predicted cc100:cc400 rate
> ratio is 1.9% out. The overhead also moves with the supply, because the same
> oscillator times it — see below.
Leaving TMRC faster than the cycle-count ceiling makes the sensor free-run at Leaving TMRC faster than the cycle-count ceiling makes the sensor free-run at
~100% duty and renders TMRC irrelevant. Setting it slower makes the sensor idle, ~100% duty and renders TMRC irrelevant. Setting it slower makes the sensor idle,
which costs sensitivity for nothing: noise after filtering scales as `1/√duty`, which costs sensitivity for nothing: noise after filtering scales as `1/√duty`,
@ -367,11 +412,17 @@ cycle count alone. So the default in every branch is the fastest TMRC, and
| 50 | 534 Hz | 267 Hz | 50.37 | 0.58 | 89.0% | spectrum, thin dither | | 50 | 534 Hz | 267 Hz | 50.37 | 0.58 | 89.0% | spectrum, thin dither |
| **100** | **283 Hz** | **141 Hz** | **26.17** | **0.79** | **94.2%** | **default** | | **100** | **283 Hz** | **141 Hz** | **26.17** | **0.79** | **94.2%** | **default** |
| 200 | 145 Hz | 73 Hz | 13.35 | 1.10 | 97.0% | resolution, 60 Hz only | | 200 | 145 Hz | 73 Hz | 13.35 | 1.10 | 97.0% | resolution, 60 Hz only |
| 400 | 74 Hz | 37 Hz | 6.74 | 1.54 | 98.5% | resolution, mains aliases | | 400 | 74 Hz | 37 Hz | 6.74 | 1.54 | 98.5% | mains aliases, fs/4 artefact |
*dither* is the sensor's own noise in LSB. Below roughly 0.2 LSB the quantiser *dither* is the sensor's own noise in LSB. Below roughly 0.2 LSB the quantiser
stops being dithered and averaging no longer recovers sub-LSB resolution. stops being dithered and averaging no longer recovers sub-LSB resolution.
**Measured, not assumed:** at cc=100 the dither came out at 0.650.81 LSB and
averaging 1,024 samples reached **0.0350.050 LSB** — a factor of 20 below the
step, within 1.52.1× of the ideal `1/√n`. The quantiser is not stalling.
`characterize.py` prints this for any capture; see
[Noise_Floor_Testing/NOISE_FLOOR.md](Noise_Floor_Testing/NOISE_FLOOR.md) §2.
**The default is cycle count 100 at 750 kHz**, which runs the sensor at its own **The default is cycle count 100 at 750 kHz**, which runs the sensor at its own
~283 Hz ceiling. It sits deliberately between the two things pulling in opposite ~283 Hz ceiling. It sits deliberately between the two things pulling in opposite
directions: directions:
@ -382,10 +433,20 @@ directions:
and its second harmonic are in band and can be notched. and its second harmonic are in band and can be notched.
- **Against cc=50** it gives up half the spectrum and buys 36% more dither - **Against cc=50** it gives up half the spectrum and buys 36% more dither
margin. cc=50 is right when something above 141 Hz needs identifying; it is not margin. cc=50 is right when something above 141 Hz needs identifying; it is not
the right default, because its dither margin is the one assumption here not yet the right default, because its dither margin is the thinnest here and the only
checked against a quiet-site capture. one still unmeasured.
Fall back to cycle count 200 if `characterize.py` reports `sd/LSB` below ~0.35. Fall back to cycle count 200 if `characterize.py`'s dither check shows the LSB
column flattening rather than continuing to fall.
**Prefer decimating a fast capture over sampling slowly.** Measured on a cc=100
capture decimated by 4 against a natively-recorded cc=400 one: decimation changes
a capture's own broadband floor by **+0.3 to +0.5%**, so the two are equivalent
for noise — and decimation is strictly better on everything else. The natively
slow capture folds 60 Hz irrecoverably to 1317 Hz and carries sample-locked
lines at fs/4 and fs/2 that the decimated path does not have at all. `compare.py`
prints this comparison whenever two captures differ by an integer cycle-count
factor.
**Bus speed is independent of the rate** — it appears in neither mechanism. What **Bus speed is independent of the rate** — it appears in neither mechanism. What
it sets is latency: how long a read takes, hence how tightly DRDY can be it sets is latency: how long a read takes, hence how tightly DRDY can be
@ -469,6 +530,7 @@ than merely "data arrived".
| `compare.py` | A/B captures — fractional noise, plus the checks that separate a gain change from a moved sensor | | `compare.py` | A/B captures — fractional noise, plus the checks that separate a gain change from a moved sensor |
| `diagnose-comms.py` | walks USB → bus → identity → registers → BIST → live read, stopping at the first failure | | `diagnose-comms.py` | walks USB → bus → identity → registers → BIST → live read, stopping at the first failure |
| `tests/` | pytest suite; needs no hardware | | `tests/` | pytest suite; needs no hardware |
| `Noise_Floor_Testing/` | captures, figures and [NOISE_FLOOR.md](Noise_Floor_Testing/NOISE_FLOOR.md) — the supply and rate-scaling analysis. Untracked: `.gitignore` excludes it |
The adapter/sensor split is deliberate: `ch347.py` is a general I²C master usable The adapter/sensor split is deliberate: `ch347.py` is a general I²C master usable
with any device, and `rm3100.py` needs only a bus object exposing with any device, and `rm3100.py` needs only a bus object exposing
@ -564,8 +626,18 @@ inserting anything.
- [x] Calibrated period, configuration warnings, cycle-count rate model - [x] Calibrated period, configuration warnings, cycle-count rate model
- [x] 3.0 V LDO built and compared — noise indistinguishable once normalised - [x] 3.0 V LDO built and compared — noise indistinguishable once normalised
- [x] Test suite covering everything that does not need the adapter - [x] Test suite covering everything that does not need the adapter
- [x] Interleaved supply A/B at two cycle counts — **scale factor quantified at
7.1% for +0.347 V**, rate at +4.94%
- [x] Dither margin at cc=100 checked against real data — averaging reaches
0.035 LSB, so the quantiser is not stalling
- [x] Filter-and-decimate shown equivalent to sampling slowly (+0.5% on the
broadband floor), and better on aliasing and artefacts
- [ ] Re-test the LDO with the sensor **clamped**, to separate gain from movement - [ ] Re-test the LDO with the sensor **clamped**, to separate gain from movement
- [ ] Deal with 60 Hz coupling at source — the dominant term — every pair in the A/B still shows 2.823° of rotation
- [ ] Identify the **fs/4 artefact** that appears at cc=400 and not at cc=100;
a cc=200 run at both supplies would say whether it scales with cycle count
- [ ] Fix `rm3100.AXIS_OVERHEAD_S`, which is ~1.7× too large for this unit
- [ ] Deal with 60 Hz coupling at source
- [ ] Reliability testing over extended runs on bare metal - [ ] Reliability testing over extended runs on bare metal
Captures written before the current header format are unreadable and must be Captures written before the current header format are unreadable and must be

View file

@ -44,6 +44,7 @@ from collections import namedtuple
import matplotlib import matplotlib
matplotlib.use("Agg") matplotlib.use("Agg")
import matplotlib.colors as mcolors
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
import matplotlib.ticker as mticker import matplotlib.ticker as mticker
import numpy as np import numpy as np
@ -84,6 +85,68 @@ 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_MS = (0.0, 20.0) # covers 1.9 ms at cc=50 to 13.7 ms at cc=400
LATENCY_COUNT = (0.8, 1e5) LATENCY_COUNT = (0.8, 1e5)
# compare.py plots the same two quantities normalised by |B|, so they get their
# own fixed ranges in ppm. Same reasoning: two invocations should overlay.
FRACTIONAL_ASD_PPM = (1e0, 1e3)
FRACTIONAL_ALLAN_PPM = (1e0, 1e3)
# Spectrogram segment length. Longer than SEGMENT_SECONDS because the job here
# is to resolve one line from another -- 40 s gives 0.025 Hz, enough to separate
# a mains line from a sample-locked one even when they land close together --
# and a ~300 s capture still yields ~100 columns at this overlap.
SPECTROGRAM_SECONDS = 40.0
# 31/32. Overlapping segments are not independent, so this buys no new
# information -- what it buys is columns: the hop is 1.25 s instead of 5 s, so a
# feature that lasts a few seconds is drawn as a few columns rather than falling
# between two. It costs only render time, and the axes are wide enough to show it.
SPECTROGRAM_OVERLAP = 0.96875
# Spectrograms are drawn in ppm of |B| per sqrt(Hz), not nT/sqrt(Hz), for the
# same reason compare.py's figures are: a capture whose gain is 7% lower records
# 7% fewer nanotesla of the same noise, and a colour scale shared across captures
# would show that as a quieter sensor.
#
# Sharing a colour scale also requires the *rendering* to be identical, which is
# easier to get wrong. Two things are pinned below to make it so:
#
# SPECTROGRAM_BIN_HZ one displayed frequency step, reached by averaging
# native bins. Since the window is a fixed *duration*,
# native resolution is the same for every capture, so a
# fixed step means the same number of bins averaged and
# therefore the same degrees of freedom per cell. This
# is the one that matters: dof is what sets how widely
# cells scatter, and so where the colour distribution
# sits. Before it, a 150 Hz capture had 6.6x more bins
# averaged into each pixel than a 38 Hz one, its speckle
# was suppressed, and that read as a lower noise level
# when nothing differed.
#
# A single fixed frequency axis would remove the last of it -- the ~1.8x
# difference in how many binned rows still fall in one pixel -- but only by
# drawing a 38 Hz capture into the bottom quarter of a 160 Hz axis and leaving
# three quarters blank. `--max-freq` does that for anyone who wants it; the
# default is each capture's own Nyquist, which is worth the residual.
#
# The range spans the floor (~30 ppm/rtHz here) to a line an order of magnitude
# above it.
SPECTROGRAM_PPM = (15.0, 200.0)
SPECTROGRAM_BIN_HZ = 0.1 # 4 native bins at a 40 s window: 8 dof per cell
# Turbo by default: a spectrogram is read for narrow lines against a broad floor,
# and hue steps make those jump out in a way a single hue cannot. Turbo is the
# right rainbow to reach for -- it is jet without the sharp lightness kinks that
# fake banding.
#
# It is worth being accurate about what it is not. Measured over its own 256
# steps, turbo's L* runs 12 to 91 but is *not* monotonic: it climbs to a bright
# yellow mid-scale then darkens into red, and 129 of 255 steps go down. Jet is
# 108 of 255 with a worse worst-case step, so turbo is smoother, not ordered.
# Neither survives colour vision deficiency well. Two alternatives are a flag
# away: `viridis`, which is genuinely monotonic (0 of 255 steps decrease), and
# `sequential`, the single-hue ramp below (L* 97 to 17, hue 213 +/- 1 deg).
SPECTROGRAM_COLORMAP = "turbo"
SEQUENTIAL_STEPS = ["#f4f8fd", "#cfe0f5", "#93bbe9", "#4e8ddb",
"#2a78d6", "#1b5091", "#0e2a4d"]
# Lower edge of the band every broadband figure is quoted over. Below a few Hz # 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 # 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 # than the floor does -- so including it would compare environments rather than
@ -330,6 +393,62 @@ def sample_locked_lines(v, max_period=8, threshold=LINE_SIGMA):
return sorted(found, key=lambda line: -line.sigma) return sorted(found, key=lambda line: -line.sigma)
def spectrogram(v, fs, seconds=SPECTROGRAM_SECONDS, overlap=SPECTROGRAM_OVERLAP):
"""(times, freqs, ASD) -- how the spectrum of a series evolves.
The Welch average that `band_stats` reports is this, collapsed along time.
Keeping the time axis is what distinguishes a line that was there all along
from a burst that a single average would smear into a raised floor, and it
is the only view that shows the difference at a glance.
Segments are long and heavily overlapped: the point here is frequency
resolution fine enough to separate a mains line from a sample-locked one, and
at these capture lengths there are plenty of samples to spend on it.
"""
nperseg = max(64, int(round(fs * seconds)))
if len(v) < nperseg:
raise ValueError(f"{len(v)} samples is shorter than one "
f"{seconds:g} s window ({nperseg} samples)")
step = max(1, int(round(nperseg * (1.0 - overlap))))
window = np.hanning(nperseg)
scale = 1.0 / (fs * (window ** 2).sum())
index = np.arange(nperseg)
starts = range(0, len(v) - nperseg + 1, step)
columns, times = [], []
for start in starts:
seg = v[start:start + nperseg]
seg = seg - np.polyval(np.polyfit(index, seg, 1), index)
spectrum = np.abs(np.fft.rfft(seg * window)) ** 2 * scale
spectrum[1:-1] *= 2.0
columns.append(np.sqrt(spectrum[1:]))
times.append((start + nperseg / 2) / fs)
freqs = np.fft.rfftfreq(nperseg, 1.0 / fs)[1:]
return np.array(times), freqs, np.array(columns).T
def bin_frequency(freqs, asd, step=SPECTROGRAM_BIN_HZ):
"""Average a spectrogram onto a fixed frequency step. Returns (freqs, asd).
Averaging in *power* -- the array is already amplitude, so it is squared and
rooted around the mean -- because that is what adds degrees of freedom. The
point is not to save pixels but to give every capture the same dof per cell:
the window is a fixed duration, so native resolution is identical across
captures, and a fixed step therefore averages an identical number of bins.
"""
width = max(1, int(round(step / (freqs[1] - freqs[0]))))
if width == 1:
return freqs, asd
usable = len(freqs) // width * width
grouped = (asd[:usable] ** 2).reshape(-1, width, asd.shape[1])
return freqs[:usable].reshape(-1, width).mean(axis=1), np.sqrt(grouped.mean(axis=1))
def alias_of(frequency, fs):
"""Where `frequency` lands after sampling at fs, and whether it folded."""
folded = abs(frequency - round(frequency / fs) * fs)
return folded, frequency > fs / 2
def dither_check(v, lsb, longest=1024): def dither_check(v, lsb, longest=1024):
"""Does averaging still recover resolution below one quantiser step? """Does averaging still recover resolution below one quantiser step?
@ -391,6 +510,118 @@ def decade_ticks(ax, which="both", minimum_decades=1.0):
axis.set_minor_formatter(mticker.NullFormatter()) axis.set_minor_formatter(mticker.NullFormatter())
def reference_lines(fs):
"""Frequencies worth marking on a spectrum: [(hz, label, is_alias), ...].
Mains is drawn where it actually lands, not where it is generated. Above
Nyquist it has folded, and the folded position is the only place it can be
seen -- so that is what gets the line, labelled as an alias so it is never
mistaken for a real signal at that frequency.
"""
marks = [(fs / 4, "fs/4", False), (fs / 2, "fs/2 = Nyquist", False)]
for mains in (60.0, 120.0):
landed, folded = alias_of(mains, fs)
if landed < fs / 2 * 0.995:
marks.append((landed,
f"{mains:g} Hz" + (" alias" if folded else ""),
folded))
return sorted(marks)
def 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]
freqs, asd = bin_frequency(freqs, asd)
# Fractional, so a gain difference between captures is not read as a noise
# difference when they share a colour scale.
field = float(np.linalg.norm([cap.x.mean(), cap.y.mean(), cap.z.mean()]))
asd = asd / field * 1e6
dof = 2 * max(1, int(round(SPECTROGRAM_BIN_HZ / native)))
top = max_freq or fs / 2
fig, ax = plt.subplots(figsize=(13.5, 7), dpi=150)
fig.patch.set_facecolor(SURFACE)
ax.set_facecolor(SURFACE)
ramp = (mcolors.LinearSegmentedColormap.from_list("sequential",
SEQUENTIAL_STEPS)
if colormap == "sequential" else colormap)
# imshow rather than pcolormesh: the grid is regular in both axes, and at
# this resolution that is thousands of times fewer objects to draw.
# 'antialiased' resamples when there are more rows than pixels. 'nearest'
# would drop a one-bin line entirely depending on where it fell, which is
# exactly the feature this plot exists to show.
image = ax.imshow(asd, origin="lower", aspect="auto", cmap=ramp,
norm=mcolors.LogNorm(*SPECTROGRAM_PPM),
extent=[times[0], times[-1], freqs[0], freqs[-1]],
interpolation="antialiased")
bar = fig.colorbar(image, ax=ax, pad=0.02, extend="both", fraction=0.04)
bar.set_label("ppm of |B| per √Hz", color=TEXT_SECONDARY, fontsize=10)
bar.ax.tick_params(colors=TEXT_SECONDARY, labelsize=9, length=0)
bar.outline.set_visible(False)
# Labels sit inside the axes on a surface-coloured chip: outside they get
# clipped by the colorbar, and a spectrogram has no margin to give away.
# Anything at the very top is the axis boundary itself -- fs/2 when the plot
# runs to Nyquist -- where a line adds nothing and its label lands on the
# title.
marks = [m for m in reference_lines(fs) if freqs[0] <= m[0] <= top * 0.97]
for slot, (hz, label, folded) in enumerate(marks):
ax.axhline(hz, color=TEXT_PRIMARY, linewidth=1.0,
linestyle=":" if folded else "--", alpha=0.85)
# Lines can land within a label's height of each other -- fs/4 and a
# mains alias were 2.4 Hz apart here. Staggering across the width keeps
# both readable without moving either line.
ax.annotate(f"{label} {hz:.2f} Hz",
xy=(0.008 + 0.17 * (slot % 3), hz),
xycoords=("axes fraction", "data"),
xytext=(0, 7), textcoords="offset points",
va="bottom", ha="left", color=TEXT_PRIMARY, fontsize=9,
bbox=dict(boxstyle="round,pad=0.25", facecolor=SURFACE,
edgecolor="none", alpha=0.85))
ax.set_title("Spectral density of |B| over time", loc="left",
color=TEXT_PRIMARY, fontsize=13, fontweight="bold", pad=8)
ax.set_xlabel("elapsed (s)", color=TEXT_SECONDARY, fontsize=10)
ax.set_ylabel("frequency (Hz)", color=TEXT_SECONDARY, fontsize=10)
ax.set_ylim(0.0, top)
if fs / 2 < top:
# Above Nyquist there is no data, and the blank says so -- that missing
# bandwidth is a real difference between configurations, not a gap.
ax.annotate(f"no data above Nyquist, {fs / 2:.1f} Hz",
xy=(0.5, (fs / 2 + top) / 2), xycoords=("axes fraction", "data"),
ha="center", va="center", color=TEXT_SECONDARY, fontsize=10)
ax.tick_params(colors=TEXT_SECONDARY, labelsize=9, length=0)
for side in ("top", "right"):
ax.spines[side].set_visible(False)
for side in ("left", "bottom"):
ax.spines[side].set_color(GRID)
# Independent windows in the whole record. The columns far outnumber these,
# because they overlap -- so cell-to-cell scatter is the two degrees of
# freedom of a single periodogram, not a changing spectrum. What is real is
# what stays put across many columns.
independent = max(1, int(cap.duration / seconds))
fig.text(0.5, 0.965,
f"{cap.path.rsplit('/', 1)[-1]}{len(cap.sample_index):,} samples "
f"at {fs:.2f} Hz, cycle count {cap.cycle_count}",
color=TEXT_PRIMARY, fontsize=10, ha="center")
fig.text(0.5, 0.935,
f"{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 trimmed(cap, seconds): def trimmed(cap, seconds):
"""Drop `seconds` from both ends of a capture. Returns (capture, note). """Drop `seconds` from both ends of a capture. Returns (capture, note).
@ -428,6 +659,25 @@ def main():
ap.add_argument("--trim", type=float, default=0.0, metavar="SECONDS", ap.add_argument("--trim", type=float, default=0.0, metavar="SECONDS",
help="drop this many seconds from BOTH ends -- settling at " help="drop this many seconds from BOTH ends -- settling at "
"the start, handling at the end (default: %(default)s)") "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() args = ap.parse_args()
# --trim and --start/--end both choose a window, and silently letting one # --trim and --start/--end both choose a window, and silently letting one
@ -555,7 +805,10 @@ def main():
# cannot drift apart. Drawn under the traces, hence the low zorder. # 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.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.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), # Anchored to the left margin of the fixed scale, which no capture reaches:
# the lowest bin a 20 s segment can produce is 0.05 Hz.
a.annotate(f"Table 3-1 spec {SPEC_ASD_NT} nT/√Hz",
xy=(ASD_HZ[0] * 1.2, SPEC_ASD_NT),
xytext=(0, 5), textcoords="offset points", xytext=(0, 5), textcoords="offset points",
color=REFERENCE, fontsize=9) color=REFERENCE, fontsize=9)
a.set_title("Amplitude spectral density", loc="left", a.set_title("Amplitude spectral density", loc="left",
@ -640,6 +893,20 @@ def main():
fig.savefig(out, facecolor=SURFACE) fig.savefig(out, facecolor=SURFACE)
print(f"\n-> {out}") print(f"\n-> {out}")
# The spectrogram is a second file rather than a fifth panel: it needs the
# width to resolve anything, and it answers a different question -- whether
# a feature was there throughout or only for part of the run.
spectrogram_path = out.rsplit(".", 1)[0] + "_spectrogram.png"
if spectrogram_path.endswith("_noise_spectrogram.png"):
spectrogram_path = spectrogram_path.replace("_noise_spectrogram",
"_spectrogram")
try:
print(f"-> {make_spectrogram(cap, spectrogram_path, args.spectrogram,
args.overlap, args.colormap,
args.max_freq)}")
except ValueError as exc:
print(f"no spectrogram: {exc}")
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View file

@ -708,22 +708,6 @@ def binned_asd(freqs, asd, bins=140):
return np.array(centres), np.array(values) return np.array(centres), np.array(values)
def clip_to_band(ax, series, band, headroom=3.0, floor=0.35):
"""Set y-limits from what is inside the band, not from filter stopbands.
An anti-alias filter falls away by six decades past its corner, and on a log
axis that decides the scale for everything else. Only the band being
compared should.
"""
inside = [a[(f >= band[0]) & (f <= band[1])] for f, a in series]
inside = [a for a in inside if len(a)]
if not inside:
return
lo = min(float(a.min()) for a in inside)
hi = max(float(a.max()) for a in inside)
ax.set_ylim(floor * lo, headroom * hi)
def make_figure(records, band, supplies, path): def make_figure(records, band, supplies, path):
conditions = conditions_in_order(records) conditions = conditions_in_order(records)
variants = sorted({variant_of(l) for l, _, _ in records}) variants = sorted({variant_of(l) for l, _, _ in records})
@ -742,7 +726,6 @@ def make_figure(records, band, supplies, path):
a = axs[0, 0] a = axs[0, 0]
a.axvspan(band[0], band[1], color=ch.GRID, alpha=0.55, zorder=0, a.axvspan(band[0], band[1], color=ch.GRID, alpha=0.55, zorder=0,
linewidth=0) linewidth=0)
drawn = []
for label, _, s in records: for label, _, s in records:
freqs, asd = binned_asd(*ch.welch_asd(dict(s["cap"].axes())["total"], s["fs"], freqs, asd = binned_asd(*ch.welch_asd(dict(s["cap"].axes())["total"], s["fs"],
nperseg=ch.segment_length(s["fs"]))) nperseg=ch.segment_length(s["fs"])))
@ -750,11 +733,10 @@ def make_figure(records, band, supplies, path):
# nT/rtHz a condition reading 7% smaller plots 7% lower while being no # nT/rtHz a condition reading 7% smaller plots 7% lower while being no
# quieter, and the chart would say the opposite of the truth. # quieter, and the chart would say the opposite of the truth.
asd = asd / s["field"] * 1e6 asd = asd / s["field"] * 1e6
drawn.append((freqs, asd))
a.loglog(freqs, asd, color=color[condition_of(label)], a.loglog(freqs, asd, color=color[condition_of(label)],
linestyle=dash[variant_of(label)], linewidth=1.4, alpha=0.9, linestyle=dash[variant_of(label)], linewidth=1.4, alpha=0.9,
label=label) label=label)
clip_to_band(a, drawn, band) a.set_xlim(*ch.ASD_HZ); a.set_ylim(*ch.FRACTIONAL_ASD_PPM)
ch.decade_ticks(a) ch.decade_ticks(a)
title(a, "Spectral density of |B|, as a fraction of |B|") title(a, "Spectral density of |B|, as a fraction of |B|")
a.set_xlabel("frequency (Hz)"); a.set_ylabel("ppm of |B| per √Hz") a.set_xlabel("frequency (Hz)"); a.set_ylabel("ppm of |B| per √Hz")
@ -771,6 +753,7 @@ def make_figure(records, band, supplies, path):
title(a, "Fast capture, filtered and decimated") title(a, "Fast capture, filtered and decimated")
a.set_xlabel("frequency (Hz)"); a.set_ylabel("ppm of |B| per √Hz") a.set_xlabel("frequency (Hz)"); a.set_ylabel("ppm of |B| per √Hz")
if plotted: if plotted:
a.set_xlim(*ch.ASD_HZ); a.set_ylim(*ch.FRACTIONAL_ASD_PPM)
ch.decade_ticks(a) ch.decade_ticks(a)
a.legend(frameon=False, fontsize=9, labelcolor=ch.TEXT_SECONDARY, a.legend(frameon=False, fontsize=9, labelcolor=ch.TEXT_SECONDARY,
loc="upper right") loc="upper right")
@ -786,6 +769,7 @@ def make_figure(records, band, supplies, path):
a.loglog(taus, devs / s["field"] * 1e6, a.loglog(taus, devs / s["field"] * 1e6,
color=color[condition_of(label)], color=color[condition_of(label)],
linestyle=dash[variant_of(label)], linewidth=1.6, label=label) linestyle=dash[variant_of(label)], linewidth=1.6, label=label)
a.set_xlim(*ch.ALLAN_TAU_S); a.set_ylim(*ch.FRACTIONAL_ALLAN_PPM)
ch.decade_ticks(a) ch.decade_ticks(a)
title(a, "Allan deviation of |B|, as a fraction of |B|") title(a, "Allan deviation of |B|, as a fraction of |B|")
a.set_xlabel("averaging time τ (s)"); a.set_ylabel("σ (ppm of |B|)") a.set_xlabel("averaging time τ (s)"); a.set_ylabel("σ (ppm of |B|)")
@ -814,7 +798,6 @@ def _plot_decimation(ax, records, band, color, dash):
ax.axvspan(band[0], band[1], color=ch.GRID, alpha=0.55, zorder=0, ax.axvspan(band[0], band[1], color=ch.GRID, alpha=0.55, zorder=0,
linewidth=0) linewidth=0)
plotted = False plotted = False
drawn = []
for condition, (_, fast), (_, slow), k in decimation_pairs( for condition, (_, fast), (_, slow), k in decimation_pairs(
[(label, s) for label, _, s in records]): [(label, s) for label, _, s in records]):
try: try:
@ -826,20 +809,17 @@ def _plot_decimation(ax, records, band, color, dash):
nperseg=ch.segment_length(fast["fs"] / k))) nperseg=ch.segment_length(fast["fs"] / k)))
# Fractional again: the two cycle counts do not share a gain either. # Fractional again: the two cycle counts do not share a gain either.
asd = asd / fast["field"] * 1e6 asd = asd / fast["field"] * 1e6
drawn.append((freqs, asd))
ax.loglog(freqs, asd, color=hue, linestyle="--", linewidth=1.5, ax.loglog(freqs, asd, color=hue, linestyle="--", linewidth=1.5,
label=f"{condition} cc{fast['cycle_count']} ÷{k}") label=f"{condition} cc{fast['cycle_count']} ÷{k}")
freqs, asd = binned_asd(*ch.welch_asd( freqs, asd = binned_asd(*ch.welch_asd(
dict(slow["cap"].axes())["total"], slow["fs"], dict(slow["cap"].axes())["total"], slow["fs"],
nperseg=ch.segment_length(slow["fs"]))) nperseg=ch.segment_length(slow["fs"])))
asd = asd / slow["field"] * 1e6 asd = asd / slow["field"] * 1e6
drawn.append((freqs, asd))
ax.loglog(freqs, asd, color=hue, linestyle="-", linewidth=1.5, ax.loglog(freqs, asd, color=hue, linestyle="-", linewidth=1.5,
alpha=0.7, alpha=0.7,
label=f"{condition} cc{slow['cycle_count']} native") label=f"{condition} cc{slow['cycle_count']} native")
plotted = True plotted = True
if plotted: if plotted:
clip_to_band(ax, drawn, band)
ax.annotate("dashed: fast capture decimated. solid: natively slow.", ax.annotate("dashed: fast capture decimated. solid: natively slow.",
xy=(0.02, 0.05), xycoords="axes fraction", xy=(0.02, 0.05), xycoords="axes fraction",
color=ch.TEXT_SECONDARY, fontsize=9) color=ch.TEXT_SECONDARY, fontsize=9)