2026-08-23 20:50:56 -04:00
# Measurement notes
The lab notebook for this rig: what was measured, when, on what, and what it
2026-09-02 13:20:10 -04:00
changed. [README.md ](../README.md ) documents how the code works *now* ; this file is
the record of how that was arrived at, and it is append-mostly, an entry is
2026-08-23 20:50:56 -04:00
corrected rather than deleted when a later measurement supersedes it.
Every capture cited here predates the current header format and has been
deleted (`*.csv` is gitignored). The numbers stand as measurements; the files
cannot be re-loaded. Re-record before relying on any of it.
**Rigs.** Two hosts appear below. *Bench* is bare metal. *VM* is a
resource-limited virtual machine with virtualised USB passthrough, shared cores
and background load; every host-side ceiling measured there is pessimistic and
labelled as such.
---
## Superseded conclusions
2026-09-02 13:20:10 -04:00
Read this first: these appear as firm recommendations in earlier entries and
2026-08-23 20:50:56 -04:00
are no longer right.
| Earlier conclusion | Superseded by | Now |
|---|---|---|
| "400 kHz measured best; use 400" | three-run repetition at each speed | 750 kHz is fastest and is the default. The 4% edge for 400 kHz sat inside the run-to-run spread of a single short run |
| "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 |
2026-09-02 13:20:10 -04:00
| `~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 285– 299 Hz | True at ~35 Hz where it folds to 9 Hz. At the default rate mains is resolved in band at only **1.7– 1.8× ** the floor. At cc=400 the dominant line is not mains at all but an **fs/4 artifact** |
| "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** . And the divisor does not hold either: solving both freely gives **88,546 counts/s** at 3.006 V and **92,889** at 3.353 V. Holding it at 90,000 and solving for the overhead alone gives 58.9 µs at cc=100 and 113.6 µs at cc=400, fitting neither. Both terms are per-unit and supply-dependent; `calibrate.py` carries them |
2026-08-24 18:09:19 -04:00
| "`× (1 + rate_error)` corrects gain, cutting the spread to 3.1%" | referencing the correction to a measured oscillator | **The 3.1% was two errors cancelling.** `rate_error` is measured against the *nominal* model, whose error is cycle-count dependent (+0.84% at cc=100, − 0.99% at cc=400, same supply), and that partly cancelled the cycle-count gain difference. Referenced properly the figure is **4.9%** . The matched-cycle-count contrasts are unaffected either way |
2026-09-02 13:20:10 -04:00
| "gain ∝ V^− 0.65, i.e. rate^− 1.47; mechanism unresolved" | freeing the gain offset | **The 1.47 was absorbing a gain-offset error.** Table 3-1's `0.3671·cc + 1.5` implies gain ∝ (cc + 4.086) counts; measured on this unit it is 0.90 (LDO) and − 0.09 (3V3). With the offset free, an exponent of exactly **1** is fully consistent, which is what an oscillator-clocked collection interval predicts. Correcting both leaves **2.48%** , inside the 2.3– 3.1% movement floor |
| "the per-axis overhead is constant in counts (0.7σ ) and changes as a time (2.8σ )" | propagating the thermal drift properly | Those σ came from a half-drift error bar chosen without justification. Propagated, the overhead is 3.60 ± 0.30 counts against 3.54 ± 0.15, and neither reading is excluded. The clean test is the supply rate ratio across cycle counts: oscillator-clocked sits **0.3σ ** from it, fixed-time **1.2σ ** . Favoured, not proven: drift is the limit |
| "the within-run drift could be used to correct gain in post-processing" | the coupling test, then σ _y(τ) | **Closed. Do not reopen.** Gain does not follow the within-run rate: the measured slopes are ** +0.08 to +0.35** where gain ∝ 1/f demands ** − 1**. The mechanism is that gain is set by the *ratio* of the MagI2C internal clock to f_LR, both on the same die, so a thermal shift is common-mode and divides out. That is what PNI's "stable over temperature" claim in §2 actually protects, and it is why 1241 ppm of rate drift produces no measurable gain drift. The drift is real and well resolved; it simply does not reach the gain |
| "the rate measurement is limited by host read jitter, so a longer window is always better" | σ _y(τ) on the four captures | Wrong in both halves. White jitter predicts σ /rate = √12·σ _t/(√n·T), or sub-ppm at 30 s; measured, σ _y bottoms out at **8– 14 ppm around τ = 2– 10 s** and then *rises* . The floor is correlated host-side noise, 10– 30× the naive figure, and past ~10 s a longer window makes the estimate worse, not better: it starts measuring the drift instead of the rate |
2026-08-23 20:50:56 -04:00
---
## First working capture
10 s, cycle count 200, TMRC 0x96, sensor stationary on a desk beside a PC.
```
349 samples over 10.02 s -> 34.8 Hz
X: mean +6.473 uT sd 177 nT
Y: mean +50.914 uT sd 106 nT
Z: mean +18.944 uT sd 396 nT
|B|: mean 54.709 uT sd 245 nT
sample interval: 28.78 ms, sd 1.52 ms
```
- **Rate.** 34.8 Hz against a nominal ~37 Hz is in spec: §5.2.1 quotes roughly
7% one-sigma tolerance on the update rate.
- **Magnitude.** 54.7 µT sits inside the 25– 65 µT band for Earth's field, which
is the real confirmation that the gain and the int24 decode are right.
- **Noise** is well above the 15 nT Table 3-1 quotes for cycle count 200, and is
2026-09-02 13:20:10 -04:00
not a driver problem, most likely proximity to a PC and the USB adapter.
2026-08-23 20:50:56 -04:00
- **Drift.** All three axes fell monotonically over the 10 s (Z most, ~1.3 µT),
so |B| went from 55.5 to 54.3 µT. Consistent with thermal settling after AVDD
is first powered; coil DC resistance moves 0.4 %/°C (Table 3-3). Allow a
warm-up before trusting absolute values.
Nothing here is calibrated: these are raw sensor outputs with no hard- or
soft-iron correction, so individual axis values reflect local distortion as much
as Earth's field.
---
2026-09-02 13:20:10 -04:00
## Supply A/B: 3.3 V adapter vs 3.0 V LDO, indistinguishable
2026-08-23 20:50:56 -04:00
2026-08-23 22:11:50 -04:00
> **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.
2026-08-23 20:50:56 -04:00
Two 60 s captures on the bench, cycle count 200, TMRC 0x96, 400 kHz.
| | 3V3 adapter | 3.0 V LDO |
|---|---|---|
| \|B\| | 40,345 nT | 42,731 nT (**+5.9%**) |
| sd \|B\| absolute | 35.4 nT | 38.5 nT (+8.8%) |
| sd \|B\| **fractional** | **878 ppm** | **900 ppm** (+2.5%) |
**Compare noise fractionally, not absolutely.** The measured scale moved 5.9%
between the runs, and a scale change carries the noise with it, so most of the
2026-09-02 13:20:10 -04:00
absolute sd difference is an artifact. Normalised, the two supplies differ by
2.5%, indistinguishable on single 60 s captures.
2026-08-23 20:50:56 -04:00
That is the expected outcome. Table 3-5 allows 50 mV< sub > PP< / sub > of supply
ripple and the LP5907's ~6.5 µV RMS sits roughly 1000× inside it, as did the
adapter rail most likely. Supply quality was never the binding constraint; the
magnetic environment is.
**The 5.9% scale change cannot be attributed to the supply from this data.** The
sensor also moved: the field direction rotated 6.16°, and the per-axis ratios
(X 1.136, Y 1.051, Z 0.870) spread by 26.6%, where a pure gain change would
scale all three identically. |B| is preserved under rotation but not under
translation through a field gradient, so movement and gain are confounded here.
There is still reason to expect 3.0 V to read *more accurately* , but it comes
from the datasheet rather than these captures: Table 3-1 footnote 1 establishes
2026-09-02 13:20:10 -04:00
every specification, including the gain fit behind `tesla_per_count` , at
2026-08-23 20:50:56 -04:00
3.0 V, noting that performance "will differ" elsewhere. At 3.3 V the gain is an
extrapolation off the calibration point.
This experiment is what `compare.py` was written for; it refuses to attribute a
magnitude difference to gain unless both movement checks pass.
### Designing an A/B supply test
- **Clamp the sensor and never touch it.** Swapping supplies must not disturb
it. This is the single thing that invalidated the first attempt.
- **Interleave A/B/A/B**, never all of A then all of B. Drift over a session is
large enough to swamp the effect, and only interleaving lets adjacent pairs be
differenced.
- **Repeat 3– 4 times per configuration.** With one run each, a 2.5% difference
is indistinguishable from run-to-run variation.
- **Let it thermally settle** before each run. Coil resistance moves 0.4 %/°C
(Table 3-3), so an unsettled sensor drifts through the measurement.
- Record the configuration with `--note` , so the comparison does not depend on
filenames surviving:
```bash
./.venv/bin/python logger.py --duration 300 --note "3V3" --output a1.csv
./.venv/bin/python logger.py --duration 300 --note "3V0 LDO" --output b1.csv
./.venv/bin/python compare.py --group note *.csv
```
---
## 60 Hz mains folds into the band
At ~35 Hz the largest spectral feature by far was a line near 9 Hz reaching
2026-09-02 13:20:10 -04:00
100– 125 nT/√Hz against a 4– 5 nT/√Hz broadband floor, **25× the noise floor** .
2026-08-23 20:50:56 -04:00
2026-08-23 22:11:50 -04:00
> **The 25× is specific to that rate and that site.** Measured at 285– 299 Hz on
> 2026-08-23, with mains resolved in band rather than folded, the 60 Hz line
> reaches only **1.7– 1.8× ** the floor, and at cycle count 400 the dominant line
2026-09-02 13:20:10 -04:00
> is an fs/4 artifact rather than mains. The mechanism below is unchanged; the
2026-08-23 22:11:50 -04:00
> "dominant term" framing is not general.
2026-08-23 20:50:56 -04:00
It is 60 Hz aliasing, proven by making it move:
| capture | fs | observed peak | predicted 60 Hz alias |
|---|---|---|---|
| 3V3 | 34.7565 Hz | 9.5063 Hz | 9.5129 Hz |
| 3.0 V LDO | 34.5557 Hz | 9.0725 Hz | 9.1114 Hz |
| long run | 34.5783 Hz | 9.1764 Hz | 9.1566 Hz |
A real 9 Hz magnetic signal would sit still. This one moves with the sample
2026-09-02 13:20:10 -04:00
rate: `fs` changed by 0.20 Hz and the peak moved 0.43 Hz, the × 2 sensitivity of
2026-08-23 20:50:56 -04:00
a second-order fold, exactly as predicted.
2026-09-02 13:20:10 -04:00
The RM3100 has **no anti-alias filter** : cycle-count integration is a boxcar
with a poor stopband, so out-of-band interference folds in freely.
2026-08-23 20:50:56 -04:00
**Sampling faster resolves it instead.** At 150 Hz (Nyquist 69.3 Hz) mains lands
in band and the strongest line sits at **59.923 Hz** , observed directly. At
75 Hz it folds to 9.304 Hz against a 9.233 Hz prediction, with only 4 nT/√Hz
left at 60 Hz because that frequency is no longer sampled. This is why the
default configuration runs at ~282 Hz: at that rate mains *and* its second
harmonic are inside the band and can be notched afterwards, which no amount of
filtering can do to an alias.
---
## Overnight run: 13.2 h, 1.63 M samples
2026-09-02 13:20:10 -04:00
Cycle count 200, TMRC 0x96. **Acquisition integrity was perfect** : sample index
2026-08-23 20:50:56 -04:00
contiguous across all 1,635,055 rows, system clock monotonic with no NTP steps,
no malformed rows, no int24 saturation, no missed measurements (worst DRDY
bracket 11.0 ms of 27.03 ms, 41% of margin). Both problems in the data were
outside the logger.
**1. The first ~1.5 h and the last ~1 h are contaminated by handling.** |B|
2026-09-02 13:20:10 -04:00
reaches 198,268 nT, five times Earth's field, in bursts around t+0.44 h.
2026-08-23 20:50:56 -04:00
Hourly |B| sd tells the story:
| hours | \|B\| sd | usable |
|---|---|---|
2026-09-02 13:20:10 -04:00
| 0– 1 | 3,750 nT | no, handling |
2026-08-23 20:50:56 -04:00
| 2– 10 | **34– 40 nT** | yes |
| 11– 12 | 41– 68 nT | marginal |
2026-09-02 13:20:10 -04:00
| 13 | 247 nT | no, activity resumed |
2026-08-23 20:50:56 -04:00
A 5-minute slice from the middle of the quiet window gave |B| = 42,132 nT with
sd 32.5 nT (771 ppm).
**2. A single rate does not describe a 13-hour capture.** One slope across the
whole run leaves a residual sd of 5.3 s and a 22.8 s span. Hour-by-hour fits
show the oscillator speeding up monotonically:
| hour | fitted period | vs run mean |
|---|---|---|
| 2 | 29.0187 ms | +1254 ppm |
| 6 | 28.9794 ms | − 102 ppm |
| 12 | 28.9187 ms | − 2195 ppm |
2026-09-02 13:20:10 -04:00
That is ~2,500 ppm of rate variation across the quiet hours, thermal, as
2026-08-23 20:50:56 -04:00
expected of an RC oscillator. So **long captures cannot be given a single
frequency axis**; spectra must be computed on windows short enough that the rate
is constant, via `Capture.restrict()` .
This is what `drift_limited` and the separate reporting of read jitter and fit
residual exist for. Over 5 minutes the residual is 3.7 ms; over 8.5 hours it is
2026-09-02 13:20:10 -04:00
2,356 ms, while read jitter stays 0.21 ms in both. Conflating the two would
2026-08-23 20:50:56 -04:00
have read as a 20,000× worse host.
**Drift-limited beyond a second.** A 769 s window of the same run: per-axis sd
rises to 214– 284 nT while the broadband floor barely moves (5.1– 5.7 nT/√Hz). The
2026-09-02 13:20:10 -04:00
extra spread is low-frequency drift, not broadband noise: the Allan deviation
2026-08-23 20:50:56 -04:00
bottoms at ~13 nT near τ = 0.12 s and climbs steadily after.
---
## Host-side rate ceilings
Two fixes roughly doubled the achievable rate.
### 1. Two USB round trips per register read → one
2026-09-02 13:20:10 -04:00
Reading a register as a pointer write plus a separate read, the form the manual
draws (§4.5.2, §5.8.4), costs two round trips. The part accepts a **repeated
2026-08-23 20:50:56 -04:00
START**, so `ch347.write_read()` does it in one. Measured at 400 kHz:
| | separate | combined |
|---|---|---|
| `data_ready()` | 0.652 ms | **0.350 ms** |
| `read_raw()` | 0.838 ms | **0.548 ms** |
### 2. The GIL, not the OS scheduler
Even after that, `logger.py` stalled ~10 ms at 150 Hz while the *same sampling
loop with no writer thread* peaked at 3.1 ms (p99.9 = 2.9 ms, zero threshold
crossings in 2079 samples). The sampler releases the GIL inside each USB
transfer and must re-acquire it, waiting up to Python's default **5 ms** switch
interval while the writer holds it. Setting `sys.setswitchinterval(0.0005)` cut
the worst bracket from 9.96 ms to 5.65 ms and made 150 Hz pass.
Worth remembering generally: with a latency-sensitive loop and a helper thread,
the default GIL switch interval *is* the jitter floor.
### 3. Splitting the writer off the sampling thread
The same 60 s capture yielded 22,558 samples (376 Hz) with inline writing and
2026-09-02 13:20:10 -04:00
28,864 (481 Hz) with the writer split off, about **28%** .
2026-08-23 20:50:56 -04:00
### Measured ceilings (VM, cycle count 50, 400 kHz)
| TMRC | Nominal | Result | Worst DRDY bracket |
|---|---|---|---|
| 0x92 | 600 Hz | fail | 1.86 ms of 1.67 ms (112%) |
| 0x93 | 300 Hz | fail | 3.45 ms of 3.33 ms (104%) |
| 0x94 | 150 Hz | borderline | 5.65 / 5.95 / 6.53 / 7.19 ms of 6.67 ms |
| 0x95 | 75 Hz | clean, 69.2 Hz | 2.63– 5.16 ms of 13.33 ms (20– 39%) |
| 0x96 | 37 Hz | clean, 34.7 Hz | 3.30– 8.56 ms of 27.03 ms (12– 32%) |
2026-09-02 13:20:10 -04:00
> **These ceilings are host-specific and pessimistic**: VM, virtualised USB,
2026-08-23 20:50:56 -04:00
> shared cores, load average 2.76 at the time of the one 150 Hz failure. The
> *shape* carries over (the limit is host round-trip latency, not I²C bandwidth
> or the sensor), but the numbers must be re-measured on the machine that will
> run the capture. The "% of margin used" line `logger.py` prints after every
> run is the number to watch.
### The bus is never the constraint
2026-09-02 13:20:10 -04:00
Per sample the bus carries a DRDY poll plus a results read, 150 bit-times,
2026-08-23 20:50:56 -04:00
against a host cost 2– 3× larger.
| | 400 kHz | 100 kHz |
|---|---|---|
| bus time per sample | 0.375 ms | 1.50 ms |
| as % of a 28.86 ms period (37 Hz) | **1.3%** | 5.2% |
| as % of a 7.21 ms period (150 Hz) | **5.2%** | 20.8% |
2026-09-02 13:20:10 -04:00
| measured host cost | 0.89– 0.98 ms | n/a |
2026-08-23 20:50:56 -04:00
That puts the bus-limited ceiling near 1100 Hz, roughly double the sensor's own
2026-09-02 13:20:10 -04:00
562 Hz three-axis maximum at cycle count 50, so absent host stalls, zero misses
2026-08-23 20:50:56 -04:00
is always achievable. Every miss observed has been a host stall.
### Bus speed, three runs each at cycle count 228
| bus | bus time | host cost | implied USB overhead |
|---|---|---|---|
| 100 kHz | 1.500 ms | 2.03– 2.19 ms | ~0.63 ms |
| 400 kHz | 0.375 ms | 0.88– 0.95 ms | ~0.55 ms |
| **750 kHz** | 0.200 ms | **0.78– 0.80 ms** | ~0.59 ms |
Host cost is `bus time + ~0.6 ms` of fixed USB round-trip latency, so a faster
bus helps but cannot go below that floor. An earlier note claimed 400 kHz
benchmarked faster than 750; that rested on a single short run whose 4%
difference was inside the run-to-run spread and does not survive repetition.
400 kHz remains a sane fallback if 750 proves marginal on long wiring.
---
## Timing: how the miss detector was arrived at
**The read-to-read interval is not a usable threshold.** On the VM it reaches
2026-09-02 13:20:10 -04:00
37 ms against a 28.8 ms period, a 29% overshoot from host stalls alone, while
2026-08-23 20:50:56 -04:00
the DRDY bracket stays under 10 ms. The first implementation thresholded the
interval and aborted a perfectly healthy capture at sample 88.
**Period calibration is worth 100× .** Counting how many grid points passed
unseen inside a bracket needs the real period, and the TMRC table value is 6– 9%
out on this unit:
| | period | error |
|---|---|---|
| nominal (TMRC table) | 27.03 ms | − 6.4% |
| **calibrated (1 s)** | **28.8849 ms** | **0.068%** |
2026-09-02 13:20:10 -04:00
| whole-run fit (25 s) | 28.8653 ms | n/a |
2026-08-23 20:50:56 -04:00
**Counting needs the interval, detection needs the bracket.** Fit residual after
adopting the two-signal scheme:
| capture | lost | residual |
|---|---|---|
| 37 Hz clean | 0 | **0.019 periods** |
| 150 Hz clean | 0 | 0.082 periods |
| 150 Hz under CPU load | 0 | 0.444 periods |
| 300 Hz | 10 | 1.241 periods |
The load case is the one that shows the design paying off: the bracket reached
119% of threshold, so a stall genuinely occurred, but the interval showed only
2026-09-02 13:20:10 -04:00
one period had elapsed, nothing was lost. The older `int(bracket/period)`
2026-08-23 20:50:56 -04:00
estimator would have inserted a spurious placeholder and slipped the index from
there on. At 300 Hz the ambiguous counter fired 19 times.
**Run-to-run stability.** Fitted periods land 6.0– 6.6% below nominal, and two
consecutive 60 s runs agreed to **0.017%** , so a single-slope drift correction
is sound over at least a minute.
---
## Rate sweep
`sweep.py` end to end. Every target from 1 to 128 Hz landed within 0.1%
(columns as of the run; the current tool also prints duty):
```
target measured err cc LSB/uT nT/count noise bus use
1H 1.00H +0.0% 29769 10929.7 0.091 1.21n 0.0%
8H 8.01H +0.1% 3718 1366.4 0.732 3.41n 0.3%
32H 32.00H -0.0% 927 341.8 2.926 6.83n 1.2%
128H 127.99H -0.0% 229 85.6 11.687 13.75n 4.8%
256H 249.23H -2.6% 113 43.0 23.265 19.57n 9.3% 2 lost
```
256 Hz is where the VM gives out; everything below it is exact. Noise past cycle
2026-09-02 13:20:10 -04:00
count ~400 is extrapolation, Table 3-1 ends there.
2026-08-23 20:50:56 -04:00
**Cycle count sets the ceiling, not TMRC.** Measured at 400 kHz:
| Cycle count | Measured | Table 3-1 predicts |
|---|---|---|
| 200 | 152 Hz | 147 Hz |
| 100 | 297 Hz | 283 Hz |
| 50 | 538 Hz | 533 Hz |
**The manual contradicts itself here and Table 3-1 is the correct one.** The
§5.2.1 note claims cycle count 200 allows a "~430 Hz" three-axis update rate;
Table 3-1 gives 440 Hz *single-axis* and says to divide by 3, i.e. ~147 Hz.
Measurement backs Table 3-1 at all three cycle counts.
2026-09-02 13:20:10 -04:00
**When the cycle count governs, TMRC is irrelevant**: cc=400 gave 73.85 Hz at
2026-08-23 20:50:56 -04:00
TMRC 0x92 and 73.86 Hz at 0x94. Varying only cc at TMRC 0x94:
| cc | measured | governed by | duty |
|---|---|---|---|
| 50 | 138.53 Hz | TMRC | 25% |
| 200 | 138.84 Hz | boundary | 99% |
| 400 | 73.83 Hz | cycle count | 100% |
| 800 | 37.09 Hz | cycle count | 100% |
**Idling costs sensitivity.** Reaching ~130 Hz two ways:
| | rate | cc | duty | ASD |
|---|---|---|---|---|
| TMRC-governed (cc=50, 0x94) | 138.5 Hz | 50 | **23%** | 3.42 nT/√Hz |
| cycle-count-governed (cc=228) | 128.5 Hz | 228 | **98%** | 2.39 nT/√Hz |
2026-09-02 13:20:10 -04:00
Same chip, same field, same per-sample physics, the difference is idle time,
2026-08-23 20:50:56 -04:00
and it is why the current model holds TMRC fast and uses cycle count alone.
**Equal duty means equal noise.** Two configurations at ~100% duty and very
different rates:
```
cc200 @150Hz 138.77 Hz LSB 13.35 nT ASD 2.98 nT/rtHz (66% bracket margin)
cc800 @37Hz 34.72 Hz LSB 3.39 nT ASD 3.26 nT/rtHz (10% bracket margin)
```
2026-09-02 13:20:10 -04:00
Near-identical spectral density, as the equal-duty argument predicts: so the
2026-08-23 20:50:56 -04:00
choice between them is bandwidth versus resolution, not noise.
**Low cycle counts are dominated, not fast.** cc=50 reached the same 138.8 Hz as
2026-09-02 13:20:10 -04:00
cc=200 on the VM: both host-limited, not sensor-limited, while idling 75% of
2026-08-23 20:50:56 -04:00
the time and quantising 3.8× more coarsely. Its extra sensor speed is capability
the host cannot collect.
---
## 60 s capture at maximum rate (office rig, historical)
28,864 samples, 481 Hz, cycle count 50, 400 kHz. The sensor was nudged by hand
at t ≈ 41 s, so statistics are split around that:
| Axis | sd, 0– 40 s | sd, 47– 60 s | step across the event |
|---|---|---|---|
| X | 84 nT | 85 nT | +280 nT |
| Y | 388 nT | 376 nT | +217 nT |
| Z | 187 nT | 198 nT | +232 nT |
| \|B\| | 369 nT | 358 nT | +313 nT |
- **The step was the sensor being physically moved**, confirmed at the time, not
2026-09-02 13:20:10 -04:00
an electrical artifact. A norm shift does not by itself imply an external
2026-08-23 20:50:56 -04:00
source changed: |B| is preserved under rotation in a uniform field, but a
translation through a gradient samples a different local field. Near a PC the
gradients are steep (§4.2.2: field falls off as 1/distance³), so a few
centimetres is ample for the +313 nT seen here. Treat a |B| change as "not a
pure rotation", nothing more.
2026-09-02 13:20:10 -04:00
- **Noise is strongly axis-dependent**, Y is 4.6× X (388 vs 84 nT) and stable
2026-08-23 20:50:56 -04:00
across the event, so it is not a consequence of the disturbance. The coils are
nominally identical, so this points at orientation relative to a local noise
source rather than a sensor fault. X at 84 nT is within ~3× of the 30 nT
Table 3-1 quotes for cycle count 50; Y is ~13× .
- **Timing was jittery**: intervals averaged 2.08 ms (481 Hz) with sd 1.09 ms
and a 19.6 ms worst case. This capture predates the chip-grid time base, so it
is unsuitable for spectral work without resampling. A capture taken today
carries an exact grid regardless of read jitter.
---
## Supply ripple: what the manual does and does not say
Table 3-5 gives a flat 50 mV< sub > PP< / sub > ripple limit on AVDD and DVDD with
**no frequency qualifier**, and no transfer function from ripple to field error
2026-09-02 13:20:10 -04:00
, no µT-per-mV, no PSRR, no ripple-vs-noise curve.
2026-08-23 20:50:56 -04:00
Slow drift should matter less than fast ripple, though this is inference from
the described architecture rather than an explicit claim. §4.1 describes the
output as "the difference in the time to complete the measurement for each
2026-09-02 13:20:10 -04:00
bias": a differential measurement across forward and reverse coil bias: and §2
2026-08-23 20:50:56 -04:00
claims measurements are "stable over temperature and inherently free from offset
drift". Anything common to both half-measurements largely cancels, so supply
variation slower than one measurement cycle should be rejected far better than
noise near the ~180 kHz LR oscillation.
Unverified on this rig: whether the breakout carries local decoupling, and what
the CH347's 3V3 rail ripple actually measures against the 50 mV limit.
---
2026-09-02 13:20:10 -04:00
## Interleaved supply A/B, 2026-08-23, the scale factor, measured
2026-08-23 22:11:50 -04:00
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
2026-09-02 13:20:10 -04:00
write-up, figures and raw output in [NOISE_FLOOR.md ](NOISE_FLOOR.md ); this is
2026-08-23 22:11:50 -04:00
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,
2026-09-02 13:20:10 -04:00
measured independently at two cycle counts and agreeing to **0.04%** , V^+0.44.
2026-08-23 22:11:50 -04:00
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.3– 3.1%. Correcting by the **measured sample
2026-09-02 13:20:10 -04:00
rate** instead leaves − 2.6 to − 1.9%, inside that floor, and needs nothing
2026-08-23 22:11:50 -04:00
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.8– 23° 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.
2026-09-02 13:20:10 -04:00
**Cycle count 400 carries an artifact 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
2026-08-23 22:11:50 -04:00
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.
2026-09-02 13:20:10 -04:00
**Every capture drifts through its own thermal warm-up**, 186 to 1241 ppm,
2026-08-24 18:09:19 -04:00
monotonically downward, measured by re-fitting the rate on sub-windows
(`calibrate.rate_stability` ). That is the error bar under every timing figure
here, and the reason the collection-interval mechanism is favoured rather than
established. Let the rig settle before a run that matters.
**That drift is the sample rate, not the field**, and the distinction is worth
keeping because the natural explanations for "drift" on a magnetometer are
geomagnetic. `dt_true` is fitted from the host clock against sample index, so
nothing magnetic can reach it, and the pattern confirms it: every run starts high
and ends low, then the *next* run starts high again. A diurnal trend would run
continuously across the session; this resets at each `logger.py` invocation.
The **field** drift within a run is separate and probably is diurnal: |B| moves
− 1.4 to − 1.8 nT/min in three of the four runs, in a consistent direction, which
2026-09-02 13:20:10 -04:00
is the right order for Sq variation. It cannot be the 2.48% residual: that is
2026-08-24 18:09:19 -04:00
1,166 nT against ~40 nT of diurnal across the whole 26-minute session.
**The sensor moved during a run, not only between them.** LDO/cc400 rotates
**0.49°** across its own run, ten times the quietest, and that is the same run
2026-09-02 13:20:10 -04:00
carrying 485 nT of Z drift at nearly constant |B|, which is rotation, not field.
2026-08-24 18:09:19 -04:00
The rig was not restrained and had to be handled to move the 3 V jumper between
runs, so this is expected rather than surprising; it is why the residual is
attributed to movement.
2026-08-23 22:11:50 -04:00
**Filtering and decimating a fast capture matches sampling slowly**, which the
README had been asserting without evidence. Decimating a cc=100 capture by 4
2026-09-02 13:20:10 -04:00
changes its own broadband floor by ** +0.3 to +0.5%**: a comparison with no
2026-08-23 22:11:50 -04:00
second run in it, so no gain or position difference to confound it. It is also
2026-09-02 13:20:10 -04:00
strictly better: the decimated path carries none of the fs/4 artifact, and a
2026-08-23 22:11:50 -04:00
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.65– 0.81 LSB per sample,
2026-09-02 13:20:10 -04:00
and averaging 1,024 samples reaches 0.035– 0.050 LSB, within 1.5– 2.1× of ideal.
2026-08-23 22:11:50 -04:00
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 3– 135 Hz and a 73 Hz one over 3– 33. 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.
---
2026-09-02 13:20:10 -04:00
## Host setup: why the udev rule looks the way it does
Moved here from the README, which now states the rule and not its history.
The CH347's USB node defaults to `root:root 0664` , so it needs a rule. The one
`setup.sh` installs grants access two ways, because no single mechanism covers
every distro:
```
# /etc/udev/rules.d/60-ch347.rules
SUBSYSTEM=="usb", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="55db", TAG+="uaccess"
SUBSYSTEM=="usb", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="55db", GROUP="plugdev", MODE="0660"
```
`uaccess` has systemd-logind put an ACL on the node for whoever holds the local
seat, no group, no logout, and is the only mechanism that can work on atomic
Fedora. `GROUP` /`MODE` is the fallback for ssh sessions and seatless systems.
**Two details are load-bearing**, and getting either wrong produces a rule that
installs cleanly and grants nothing:
- **The prefix must sort below 73.** udev merges every rules directory into one
lexicographic sequence, and the only thing that acts on the tag is
`TAG=="uaccess", ENV{MAJOR}!="", RUN{builtin}+="uaccess"` in systemd's
`73-seat-late.rules` . At `99-` the tag is added after that line has already
run: set, never read, no ACL. systemd keeps its own uaccess rules in
`70-uaccess.rules` for this reason.
- **`TAG+="uaccess"` and `GROUP=` must be on separate lines.** udev discards a
whole rule line whose `GROUP=` it cannot resolve, and since systemd 258 that
includes any group that exists but is not a *system* group. Sharing a line
means an unusable group silently takes the uaccess tag down with it.
Together those explain a failure that looks distro-specific but is not: on
Debian and Ubuntu the `plugdev` group grants access on its own, masking a
uaccess tag that never fired. Fedora removed `plugdev` years ago, so on
**Bazzite** neither half worked and the adapter stayed inaccessible.
Two more atomic-Fedora traps, which `setup.sh` prints fixes for:
- `usermod -aG dialout $USER` fails with *"group 'dialout' does not exist"* even
though `getent` finds it, because `nss_altfiles` lets `getent` read
`/usr/lib/group` while `usermod` writes `/etc/group` alone. Copy the line
across first, then re-run `usermod` and log back in.
- Bazzite is known not to reload `/etc/udev/rules.d` when it switches to the
final rootfs ([ublue-os/bazzite#2516 ](https://github.com/ublue-os/bazzite/issues/2516 )),
so a correct rule can sit inert until
`sudo udevadm control --reload-rules && sudo udevadm trigger` is run once.
`setup.sh` checks the node itself at the end rather than guessing, and says
*which* mechanism granted access, `uaccess ACL` or `group membership` , since
the two fail in different ways and only one is available on Bazzite.
---
## Design rationale, moved out of the README
The README states what the code does; this is why it does it that way.
### How the time base and miss detection work
**The chip samples on its own internal schedule**, unaffected by bus traffic
(§5.8.2, §5.7.2). Two consequences drive the design: read jitter is not sample
jitter, so `sample_index` is a grid coordinate; and a late read returns the
*newer* measurement, so an unnoticed miss would silently compress the time axis.
A lost measurement is therefore **recorded, not dropped** : a placeholder row
keeps `sample_index` contiguous and makes the gap explicit. Double-counting is
structurally impossible: `HSHAKE` DRC1=1 makes DRDY clear only on a results read,
and `configure()` verifies the readback rather than assuming it. If the writer
queue ever fills the run **stops** rather than dropping a row, since a hole in
`sample_index` is worse than a short capture.
**The period is calibrated before recording starts**, because counting how many
grid points passed unseen needs the real period and the TMRC table value is 6– 9%
out on this unit. `calibrate_period()` fits one second of loss-free samples to
~0.07%, then keeps refining from clean intervals to follow thermal drift. If no
clean stretch can be found the run aborts before recording: that is the honest
signal that the requested rate is not sustainable.
**Counting lost measurements uses two signals**, because the two questions have
different best answers. *Did we lose any?* , the **DRDY bracket** , which is exact
where the read-to-read interval is only a heuristic (measured here the interval
overshoots a 28.8 ms period by 29% on host stalls alone, while the bracket stays
under 10 ms). *How many?* : the **interval since the previous accepted sample** ,
rounded to whole periods, since every accepted read sits on a grid point.
**Clock drift is compensated in post-processing, not baked into the file.**
`capture.py` regresses the host clock on `sample_index` to recover the true
period: short-term regularity from the chip, long-term rate calibration from the
host. Over hours no single slope fits; `capture.py` reports that as
`drift_limited` and says to analyse shorter windows via `Capture.restrict()` .
**Threading.** The sampling loop does I²C and nothing else, pushing raw counts
onto a bounded queue; a writer thread does unit conversion, CSV formatting,
flushing and the console display. The sampler releases the GIL inside each USB
transfer and must re-acquire it, so Python's default 5 ms switch interval becomes
the jitter floor, `sys.setswitchinterval(0.0005)` cut the worst bracket from
9.96 ms to 5.65 ms. The writer is reniced out of the way, which needs no
privilege; `--high-priority` additionally raises the sampler, which does.
### Choosing a cycle count
*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. At
cc=100 this is **measured, not assumed** : dither came out at 0.65– 0.81 LSB and
averaging 1,024 samples reached 0.035– 0.050 LSB, within 1.5– 2.1× of the ideal
`1/√n` . `characterize.py` prints this for any capture; fall back to cycle count
200 if its dither check shows the LSB column flattening rather than falling.
**The default is cycle count 100 at 750 kHz**, which runs the sensor at its own
~283 Hz ceiling. It costs 1.5% in post-filter noise against cc=200 and buys 1.9×
the spectrum, which is worth taking because **aliased interference cannot be
filtered out afterwards at any cycle count**, at 283 Hz both mains and its
second harmonic are in band and can be notched. cc=50 is right when something
above 141 Hz needs identifying, but its dither margin is the thinnest here.
**Prefer decimating a fast capture over sampling slowly.** 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, since a natively
slow capture folds 60 Hz irrecoverably and carries sample-locked lines the
decimated path does not have at all. `compare.py` prints this comparison whenever
two captures differ by an integer cycle-count factor.
### CH347 access
The adapter presents three USB interfaces. Interfaces 0 and 1 are CDC-ACM and the
kernel binds them as `/dev/ttyACM0` (the UART). **Interface 2** is vendor-class,
carries I2C/SPI/JTAG with no kernel driver bound, and libusb can claim it
directly. `ch347.py` deliberately never calls `set_configuration()` : the device
is already configured, and re-setting it would disturb the CDC-ACM interfaces.
There is no usable off-the-shelf option: no CH347 kernel driver exists (the
in-tree `spi-ch341` is for `1a86:5512` , a different chip), the PyPI `ch347`
package wraps a Windows DLL, and `ch347api` supports only HID mode (`55dc` ), not
this vendor-bulk Mode 1. The framing follows the `aystarik/ch347-i2c-spi-gpio`
Linux driver, whose id-table entry
`USB_DEVICE_INTERFACE_NUMBER(0x1a86, 0x55db, 0x02)` matches this device exactly.
Wire format, bulk OUT `0x06` , bulk IN `0x86` , max 63 bytes per transfer:
| Purpose | Bytes out | Bytes back |
|---|---|---|
| Set speed | `AA 6<speed> 00` | 0 |
| Write n | `AA 74 (80\|n+1) (addr<<1) <data> 75 00` | n+1 |
| Read n | `AA 74 81 ((addr<<1)\|1) [C0\|(n-1)] C0 75 00` | n+1 |
| Write n then read m | `AA 74 (80\|n+1) (addr<<1) <data> 74 81 ((addr<<1)\|1) [C0\|(m-1)] C0 75 00` | n+2+m |
| Probe | `AA 74 81 (addr<<1) 75 00` | 1 |
**Every returned byte must be `1` **: that is the per-byte ACK. On a read, the
leading bytes are address ACKs and the rest is payload. `tests/test_ch347.py`
pins all five forms byte for byte.
### RM3100 quirks
Each of these is a place where the part or the manual is not what you would
guess; `rm3100.py` carries the detail at the constant that encodes it.
- **Register reads use the plain address, not `|0x80` .** §5 describes the SPI
convention of adding 0x80, but §5.8.4's I2C diagram writes `0x24` literally.
- **A register read uses a repeated START**, not the STOP-then-START the manual
draws (§4.5.2, §5.8.4). The part accepts it and it halves the USB round trips,
which is what sets the sample-rate ceiling. `rm3100.read_reg()` falls back to
the manual's form on a bus that cannot do it.
- **CMM = 0x79** for all three axes. Table 5-1 calls bit 3 reserved-zero, but the
manual's own examples (§5.7.2, §5.8.3) set it. The examples win.
- **HSHAKE is set to 0x0A** (`DRC0=0` , `DRC1=1` ) during init. The 0x1B default has
`DRC0=1` , meaning *any* register write clears DRDY, including the pointer write
that reading STATUS itself requires, so polling could never observe DRDY set.
- **Gain** is `0.3671 × cycle_count + 1.5` LSB/µT, a linear fit to Table 3-1.
- **The rate model divisor is specified, not fitted:** a 180 kHz circuit
oscillation (Table 3-1) over two bias directions (§4.1) gives 90,000 counts/s.
- Measurements are three **24-bit big-endian two's-complement** values, read as
9 bytes from 0x24 using the sensor's register auto-increment.
---
2026-08-23 20:50:56 -04:00
## Open questions
2026-08-23 22:11:50 -04:00
- Re-run the LDO comparison with the sensor **clamped** , and in an order that is
not degenerate. The 2026-08-23 session used L,3,L,3 against cycle counts
2026-09-02 13:20:10 -04:00
100,100,400,400, which makes `t = supply + 2·cc` exactly: supply, cycle count
2026-08-23 22:11:50 -04:00
and a linear drift cannot all three be fitted. L,3,3,L breaks it.
2026-09-02 13:20:10 -04:00
- Identify the **fs/4 artifact** at cycle count 400. A cc=200 run at both
2026-08-23 22:11:50 -04:00
supplies would say whether it scales with cycle count or appears at a threshold.
- Fix `rm3100.AXIS_OVERHEAD_S` : 68.7 µs against 38– 41 µ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,
2026-09-02 13:20:10 -04:00
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
2026-08-23 22:11:50 -04:00
reference available, and it would say which rail reads closer to right.
2026-09-02 13:20:10 -04:00
- Deal with 60 Hz coupling at source: standoff distance, then a repeat
characterisation, rather than only sampling fast enough to see it.
2026-08-23 20:50:56 -04:00
- Reliability over extended runs on bare metal, where the VM ceilings above do
not apply.