Add exact-interval sampling, chip-grid timing, and analysis tooling

Sample every measurement exactly once, flagging losses in a warning
column rather than dropping them, so sample_index stays a valid
chip-time grid coordinate. Calibrate the true period before recording;
abort if no loss-free stretch exists.

Captures now store only raw counts plus a config header, with tesla and
both time bases reconstructed on load. Cycle count becomes the rate knob
(TMRC has no effect once it governs), raising the ceiling ~4x via
repeated-START reads and a shorter GIL switch interval.
This commit is contained in:
Jeremy Karst 2026-08-23 18:16:43 -04:00
parent 2a101d013d
commit 3d6c7251e9
11 changed files with 2102 additions and 221 deletions

623
README.md
View file

@ -147,21 +147,186 @@ system-wide `pip install`.
| Flag | Default | Meaning |
|---|---|---|
| `--cycle-count` | 200 | gain/resolution vs. speed; 200 → 75 LSB/µT |
| `--tmrc` | 0x96 | continuous-mode rate; 0x92 (~600 Hz) to 0x9D (~0.3 Hz) |
| `--cycle-count` | 50 | sets the rate and the LSB; see Recommended configuration |
| `--tmrc` | fastest | rate register. Default lets the cycle count set the rate; give one only to sample *slower* |
| `--duration` | 0 | seconds, 0 = until Ctrl-C |
| `--output` | timestamped | CSV path |
| `--address` | autodetect | skip the scan |
| `--bus-speed` | 750 | I²C kHz; 100 would spend 80% of the period on the bus at cc=50 |
| `--scan-only` | — | scan and exit |
CSV columns: `timestamp_iso, elapsed_s, x_raw, y_raw, z_raw, x_uT, y_uT, z_uT,
magnitude_uT`.
Exit status is non-zero if an interval was missed. The partial capture is kept
and is valid up to that point.
### Capture format
A capture stores **only irreducible facts** — the chip's sample count, the host
clock, and the raw counts — behind a header of configuration. Everything else
(chip time, elapsed time, tesla, magnitude) is reconstructed on load by
`capture.py`, so there is exactly one source of truth for each quantity.
```
# rm3100_capture: 1
# nominal_rate_hz: 37.0
# tmrc: 0x96
# cycle_count: 200
# tesla_per_count: 1.3347570742124934e-08
# calibrated_period_s: 0.028891621...
# i2c_address: 0x23
# bus_speed_khz: 400
# revid: 0x22
sample_index,system_time_unix,x_raw,y_raw,z_raw,warning
0,1755930856.722866,-2765,767,378,
5,1755930856.867436,0,0,0,MISSED
13,1755930857.041244,0,0,0,MISSED AMBIGUOUS
21,1755930857.272108,-2761,769,377,AMBIGUOUS
```
The `warning` column carries space-separated flags, empty when the row is fine,
and the column generalises to future flags. Because it already marks a row as
having no data, placeholders carry **zeros rather than blanks** — so `x/y/z`
parse as integers on every row without special-casing.
| flag | meaning |
|---|---|
| `MISSED` | placeholder row; the measurement was never read, so it has no data. The row exists to keep `sample_index` contiguous |
| `AMBIGUOUS` | the gap ending at this row was of uncertain length, so the index may have slipped from here on |
**The two are independent.** A gap measuring 1.35 periods rounds to one, so no
placeholder is written — yet it sits far enough from an integer to distrust, and
that case carries `AMBIGUOUS` on the *real* sample ending the gap, which keeps
its data. A placeholder inside an uncertain gap carries both.
The flag is a confidence measure, not a claim that 1.35 and 1.65 are equally
likely — 1.35 probably is one period and 1.65 probably two. Deciding which needs
neighbouring timestamps and assumptions and can still be wrong, which is exactly
why these rows are flagged rather than silently resolved.
`tesla_per_count` is written with `repr()` so it round-trips through float64
exactly, and is expressed per-count rather than the datasheet's LSB/µT so
conversion is a multiply: `tesla = count * tesla_per_count`. Rows are ~33 bytes,
against ~90 for the old redundant schema.
All times are unix epoch seconds; no ISO-8601 anywhere in the data.
```bash
./.venv/bin/python plot.py capture_60s.csv # -> capture_60s.png
./.venv/bin/python plot.py capture.csv --smooth 0 # raw only, no moving average
```
### One-step capture and analysis
```bash
./noise_floor_test.sh --duration 60 # capture, then plot + characterize
./noise_floor_test.sh # until Ctrl-C, then analyse
```
Activates the venv and forwards every flag to `logger.py`, then runs `plot.py`
and `characterize.py` on the result. It defaults `--bus-speed 400` (measured
best) and picks the output name so the analysis can find it; anything you pass
explicitly wins.
Analysis runs whether the capture ended on `--duration`, on **Ctrl-C**, or on an
abort — a partial capture is still valid up to the abort. Exit status is
non-zero if any stage failed, so it is usable from a scheduler.
### Timing model
The chip samples on its **own internal schedule**, unaffected by bus traffic
(§5.8.2 "this can run in the background"; §5.7.2 "This will not affect the
measurement process"). Two consequences drive the whole design:
- **Read jitter is not sample jitter.** The measurement grid is uniform even when
host reads are late, so `sample_index` is a grid coordinate, not a count of
reads.
- **A late read returns the *newer* measurement**, not a delayed one. Missing an
interval therefore silently skips a grid point and corrupts the time axis.
So `logger.py` **records a lost measurement rather than dropping it**: placeholder
rows carrying `MISSED` in the count fields keep `sample_index` contiguous, so the
gap is explicit instead of silently compressing the timeline. The run continues
and reports the total at the end, exiting non-zero. Double-counting is
structurally impossible: `HSHAKE` DRC1=1 makes DRDY clear when the results are
read, and `configure()` verifies the readback.
**The period is calibrated before recording starts.** Counting how many grid
points passed unseen inside a bracket needs the real period, and the TMRC table
value is 69% out on this unit — enough to insert the wrong number of
placeholders and slip the index against real time, which is exactly what
placeholders exist to prevent. `calibrate_period()` takes a least-squares slope
over one second of loss-free samples:
| | period | error |
|---|---|---|
| nominal (TMRC table) | 27.03 ms | 6.4% |
| **calibrated (1 s)** | **28.8849 ms** | **0.068%** |
| whole-run fit (25 s) | 28.8653 ms | — |
A 100× improvement, from ~35 points at ~0.5 ms jitter. It is then refined from
clean intervals during the run so it follows thermal drift.
**If no clean second can be found, the run aborts before recording anything** —
without a clean stretch the period cannot be measured, so misses cannot be
counted and the index would not track chip time. That is also the honest signal
that the requested rate is not sustainable.
**Counting lost measurements uses two different signals**, because the two
questions have different best answers:
- *Did we lose any?* — the **bracket**, rigorously: a bracket shorter than one
period cannot contain two completions, whatever the timing precision.
- *How many?* — the **interval since the previous accepted sample**, rounded to
whole periods. Every accepted read sits on a grid point, so that interval is
nearly an exact multiple; the bracket alone cannot say, since it only reaches
back to the last poll that saw DRDY clear and so discards where the grid is.
With the period calibrated to 0.07% and each completion located to under 1% of
a period, the rounding is unambiguous for small gaps. Measured fit residual:
| 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 one period had elapsed — nothing was lost. The older `int(bracket/period)`
estimator would have inserted a spurious placeholder and slipped the index.
`logger.py` reports an **ambiguous** count when a rounding lands near a
half-period. That is the signal the count itself is a guess, and it is distinct
from merely having missed something — at 300 Hz it fired 19 times. A capture
with ambiguous gaps should be re-recorded before spectral work.
Detection uses the **DRDY bracket** — the span between the last poll showing DRDY
clear and the poll showing it set — not the read-to-read interval. The bracket is
exact rather than heuristic: if DRDY reads clear at t꜀ then every earlier
measurement has already been read, measurements complete one period apart, so a
bracket narrower than a period can contain at most one completion, and DRDY going
high proves it contained at least one.
The interval cannot make that claim, and measurement settled it: on this rig the
read-to-read interval reaches **37 ms against a 28.8 ms period** — a 29 % overshoot
from host stalls alone — while the bracket stays under 10 ms. An interval
threshold aborts on perfectly healthy captures; the first attempt here did
exactly that at sample 88.
**Clock drift is compensated in post-processing, not baked into the file.** The
chip's RC oscillator is *regular* but only accurate to ±7% (§5.2.1) — this unit
runs ~6% slow, 34.7 Hz against a 37 Hz nominal. `capture.py` regresses the host
clock on `sample_index` to recover the true period:
```
elapsed_nominal = N * dt_nominal # uniform, but ~6% wrongly scaled
elapsed = N * dt_true # uniform and correctly scaled
```
Short-term regularity comes from the chip, long-term rate calibration from the
host. `characterize.py` uses `dt_true` for its frequency axis, since a 6% error
would otherwise displace every spectral feature by 6%.
### Sample rate
**Cycle count, not TMRC, sets the ceiling.** Measured with `--bus-speed 400`:
@ -202,11 +367,16 @@ silently losing data.
| File | Role |
|---|---|
| `setup.sh` | udev rule, venv, pyusb |
| `noise_floor_test.sh` | capture then analyse in one step — wraps `logger.py`, then runs `plot.py` and `characterize.py` |
| `ch347.py` | CH347 I2C transport — USB only, knows nothing about the sensor |
| `rm3100.py` | RM3100 driver — registers and decoding, knows nothing about USB |
| `logger.py` | CLI: scan → identify → configure → log |
| `logger.py` | CLI: scan → identify → configure → log, aborting on a missed interval |
| `capture.py` | the only capture reader — parses the header, rebuilds tesla and both time bases |
| `plot.py` | four-panel plot of a capture: X, Y, Z and the norm |
| `diagnose.py` | walks USB → bus → identity → registers → BIST → live read, stopping at the first failure |
| `characterize.py` | noise floor: spectral density, Allan deviation, residuals, read latency |
| `sweep.py` | sweep target rates, reporting measured rate, LSB, noise and bus use per point |
| `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 |
The adapter/sensor split is deliberate: `ch347.py` is a general I2C master usable
with any device, and `rm3100.py` needs only a bus object exposing
@ -270,8 +440,12 @@ Symptoms map cleanly onto causes, so work down this list.
| Symptom | Cause |
|---|---|
| `Cannot claim CH347 interface 2: Access denied` | udev rule missing — run `./setup.sh` |
| `Cannot claim CH347 interface 2: Access denied` | udev rule missing — run `./setup.sh`. Do **not** use `sudo`: it works as your normal user, and running as root leaves root-owned capture files |
| `No CH347 adapter found` | not plugged in, or not in Mode 1 |
| `ABORTED: missed N interval(s)` | the host could not keep up — raise `--tmrc` (slower) or `--bus-speed`. Expected at cycle count 50 near the sensor's maximum |
| `no capture header found` | a capture predating the header format — re-record it |
| `sample_index is not contiguous` | the file was truncated mid-row, damaged, or hand-edited |
| `HSHAKE did not take` | the I²C write is unreliable; try a lower `--bus-speed` |
| Adapter opens, `--scan-only` finds nothing | **I2CEN not tied high** (most likely), or SDA/SCL swapped, or no bus pull-ups |
| Found at 0x23, REVID `0x22`, registers fine, but DRDY never sets and results are all zero | **AVDD/VDD not powered** — confirm with BIST; both of these were hit during bring-up |
| Cycle-count read-back mismatch | bus integrity — try a lower speed |
@ -318,7 +492,424 @@ None of this is calibrated: the figures are raw sensor output with no hard- or
soft-iron correction, so the individual axis values reflect local distortion as
much as Earth's field.
### 60 s capture at maximum rate
### Bench results (desk, cc=200, TMRC 0x96, 400 kHz)
Timing held up well throughout: fitted periods land 6.06.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.
#### Supply: 3.3 V adapter vs 3.0 V LDO — indistinguishable
Two 60 s captures, `..._DESK_3v3.csv` and `..._DESK_3vLDO.csv`:
| | 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 these runs, and any scale change carries the noise with it — so the
absolute sd difference is mostly an artifact. Normalised, the two supplies differ
by 2.5%, which is indistinguishable on single 60 s captures.
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 is not attributable to supply from this data.** The
sensor also moved between captures — the direction rotated **6.16°**, and the
per-axis ratios (X 1.136, Y 1.051, Z 0.870) spread by **26.6%**, whereas a pure
gain change would scale all three identically. Translation through a field
gradient changes |B| just as a gain change does, so the two are confounded here.
There is still good 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 every specification — including the gain fit behind
`tesla_per_count` — at **3.0 V**, noting performance "will differ" elsewhere.
At 3.3 V the gain is an extrapolation off the calibration point.
To settle it, clamp the sensor so it cannot move and switch only the supply.
Two diagnostics then make the answer unambiguous: a pure gain change gives
**equal per-axis ratios** (spread ≈ 0) and **≈ 0° of rotation**.
`compare.py` runs both checks and refuses to attribute a magnitude difference to
gain unless they pass:
```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
```
Use `--note` to record the configuration in the header, so the comparison does
not depend on filenames surviving.
### 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 you difference
adjacent pairs.
- **Repeat at least 34 times per configuration.** With one run each, a 2.5%
difference is indistinguishable from run-to-run variation.
- **Pick cycle count to match a TMRC step** — see below.
- **Let it thermally settle** before each run. Coil resistance moves 0.4 %/°C
(Table 3-3), so an unsettled sensor drifts through the measurement.
#### 60 Hz mains aliases into the band — the dominant feature
The largest spectral feature by far is a line near 9 Hz reaching ~100125 nT/√Hz,
against a ~45 nT/√Hz broadband floor: **25× the noise floor**.
It is 60 Hz folding in, proven by making it move:
| capture | fs | 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 rate:
`fs` changed by 0.20 Hz and the peak moved 0.43 Hz — the ×2 sensitivity of a
second-order fold, exactly as predicted.
The RM3100 has **no anti-alias filter** — its cycle-count integration is a boxcar
with poor stopband — so out-of-band interference folds in freely. Until this is
dealt with (notch in post, a sample rate that folds mains somewhere harmless, or
removing the coupling) it will dominate every sd figure and mask any supply-level
improvement.
#### Overnight run: 13.2 h, 1.63 M samples
`rm3100_20260823_014804.csv`. **Acquisition integrity was perfect** — sample
index contiguous across all 1,635,055 rows, system clock monotonic with no NTP
steps, no malformed rows, no int24 saturation, and no missed intervals (worst
DRDY bracket 11.0 ms of 27.03 ms, 41% of margin). Two problems in the data, both
outside the logger:
**1. The first ~1.5 h and the last ~1 h are contaminated by handling.** |B|
reaches **198,268 nT**, five times Earth's field, in bursts around t+0.44 h.
Hourly |B| sd tells the story plainly:
| hours | \|B\| sd | usable |
|---|---|---|
| 01 | 3,750 nT | no — handling |
| 210 | **3440 nT** | yes |
| 1112 | 4168 nT | marginal |
| 13 | 247 nT | no — activity resumed |
The quiet window is extracted as `overnight_quiet.csv` (hours 2.010.5,
1,055,585 samples). A 5-minute slice from its middle gives |B| = 42,132 nT with
sd 32.5 nT (771 ppm).
**2. A single rate does not describe a 13-hour capture.** Fitting 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 | period | vs run mean |
|---|---|---|
| 2 | 29.0187 ms | +1254 ppm |
| 6 | 28.9794 ms | 102 ppm |
| 12 | 28.9187 ms | 2195 ppm |
That is ~2,500 ppm of rate variation across the quiet hours — thermal, as
expected from an RC oscillator. The consequence is that **long captures cannot
be given a single frequency axis**; spectra must be computed on windows short
enough that the rate is constant. `capture.py` now warns when the residual sd
exceeds one sample period, and reports read jitter separately from fit residual
— over 5 minutes the residual is 3.7 ms, over 8.5 hours it is 2,356 ms, while
read jitter stays 0.21 ms in both. Conflating the two would have read as a
20,000× worse host.
#### Long run: drift-limited beyond a second
`rm3100_20260823_014804.csv`, 26,604 samples over 769 s: sd rises to 214284 nT
per axis while the broadband floor barely moves (5.15.7 nT/√Hz). The extra
spread is **low-frequency drift, not broadband noise** — the Allan deviation
bottoms at ~13 nT near τ = 0.12 s and climbs steadily after. Over 13 minutes,
thermal and environmental drift dominate everything else.
### Achievable rate
Two fixes roughly doubled the achievable rate. Measured with
`--cycle-count 50 --bus-speed 400`:
| TMRC | Nominal | Result here | Worst DRDY bracket |
|---|---|---|---|
| 0x92 | 600 Hz | abort | 1.86 ms of 1.67 ms (112%) |
| 0x93 | 300 Hz | abort | 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.635.16 ms of 13.33 ms (2039%) |
| 0x96 | 37 Hz | clean, 34.7 Hz | 3.308.56 ms of 27.03 ms (1232%) |
> **These ceilings are host-specific and pessimistic.** They were measured in a
> resource-limited VM with virtualized USB, shared cores and background load
> (2.76 at the time of the one 150 Hz abort). Bracket tails there include
> hypervisor scheduling and USB passthrough latency that bare metal does not
> pay. The *shape* of the result should carry over — the limit is host round-trip
> latency, not I²C bandwidth or the sensor — but the numbers should be
> re-measured on the machine that will actually run the capture. The
> "% of margin used" line that `logger.py` prints after every run is the number
> to watch.
**The I²C bus is never the constraint.** Per sample it carries a DRDY poll plus
a results read — 150 bit-times — against a host cost 23× larger. `logger.py`
prints both at the start of every run:
| | 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% |
| measured host cost | 0.890.98 ms | — |
That puts the bus-limited ceiling near 1100 Hz, roughly double the sensor's own
562 Hz three-axis maximum at cycle count 50 — so absent host stalls, zero misses
is always achievable. Every miss observed has been a host stall.
The ceiling was therefore two host-side costs:
**1. Two USB round trips per register read.** 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 in fact accepts a **repeated START**, so `write_read()`
in [ch347.py](ch347.py) 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.
That is worth remembering generally: with a latency-sensitive loop and a helper
thread, the default GIL switch interval *is* the jitter floor.
### Rate, resolution and the bus
Four settings interact. Three of them are worth understanding together, and the
fourth is independent of the rest.
**Two mechanisms set the rate, and the slower one wins** (§5.2.1):
| | sets | granularity |
|---|---|---|
| cycle count | how *long* a measurement takes: `3 x (cc/90,000 + 68.7 us)` | continuous |
| TMRC | how *often* one is started | factor-of-two steps |
If TMRC asks for something the cycle count cannot deliver, the cycle count wins
and TMRC has no effect at all — measured 73.85 Hz at TMRC 0x92 against 73.86 Hz
at 0x94 for the same cycle count. If TMRC asks for something slower, TMRC wins
and **the sensor idles**, which is the case to avoid.
**Idling costs sensitivity.** Duty is integration time over period, and noise
after filtering scales as `1/sqrt(duty)`. 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 |
Same chip, same field, same per-sample physics — the difference is idle time.
So: **hold TMRC fast and let the cycle count set the rate.** That is what the
defaults do, and `--rate` derives both for you.
**Cycle count also sets resolution**, which is the real trade. It buys rate and
LSB in opposite directions, and the dither margin follows the LSB:
| cc | rate | nT/LSB | dither | Nyquist |
|---|---|---|---|---|
| 50 | 534 Hz | 50.37 | 0.58 | 267 Hz |
| 228 | 128 Hz | 11.74 | 1.17 | 64 Hz |
| 931 | 32 Hz | 2.91 | 2.34 | 16 Hz |
Below ~0.46 Hz the 16-bit cycle-count register runs out and TMRC has to set the
cadence; below cycle count 30 the manual warns of quantisation (§5.1). Both
bounds are enforced by `rm3100.plan()`.
**Bus speed is independent of the rate.** It does not appear in either rate
mechanism. What it sets is *latency* — how long a read takes, hence how tightly
a DRDY event can be timestamped and how much margin there is against a stall.
Host cost is `bus time + ~0.6 ms` of fixed USB round-trip, so faster is simply
better and 750 kHz is the default everywhere.
### Recommended configuration
**Cycle count 50 at 750 kHz**, which is what the defaults now do. That runs the
sensor at its own ceiling of ~534 Hz, with TMRC held fast so the cycle count
governs and the duty cycle stays near 100%.
```bash
./.venv/bin/python logger.py --duration 60 # cc=50, 750 kHz, ~534 Hz
```
Against cycle count 200 this costs **4.4%** in post-filter noise — 89% duty
against 97%, and nothing else — while giving **3.7x the spectrum**, 267 Hz of
Nyquist against 73 Hz. That trade is worth taking because **aliased interference
cannot be filtered out afterwards at any cycle count**, so seeing it beats a few
percent of noise. At 534 Hz mains and its first four harmonics all sit in band
and can be notched; at 145 Hz only the 60 Hz fundamental does, and the rest fold
irreversibly onto signal.
750 kHz is not optional at this cycle count. The period is 1.873 ms, and the
irreducible I2C traffic per sample is:
| bus | traffic | share of period |
|---|---|---|
| 100 kHz | 1.500 ms | **80%** |
| 400 kHz | 0.375 ms | 20% |
| 750 kHz | 0.200 ms | **11%** |
750 kHz is also the fastest measured, not merely the least bus traffic. Host
cost per sample, three runs each at cycle count 228:
| bus | bus time | host cost | implied USB overhead |
|---|---|---|---|
| 100 kHz | 1.500 ms | 2.032.19 ms | ~0.63 ms |
| 400 kHz | 0.375 ms | 0.880.95 ms | ~0.55 ms |
| **750 kHz** | 0.200 ms | **0.780.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 here 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.
**Fall back to cycle count 100 or 200 if dither is thin.** The one assumption
worth checking is that cc=50 stays dithered: its intrinsic noise is 0.58 LSB,
and simulation puts the failure point below ~0.2 LSB, so there is roughly 3x of
margin. `characterize.py` prints `sd/LSB` per axis, which settles it from the
first capture — below ~0.35 and averaging stops recovering sub-LSB resolution,
at which point 100 (1.17 LSB) or 200 (1.10 LSB) buys the margin back at the cost
of bandwidth.
| cycle count | rate | Nyquist | nT/LSB | dither | duty |
|---|---|---|---|---|---|
| **50** | **534 Hz** | **267 Hz** | 50.37 | **0.58** | 89.0% |
| 100 | 281 Hz | 141 Hz | 26.17 | 0.80 | 93.7% |
| 200 | 145 Hz | 73 Hz | 13.35 | 1.10 | 97.0% |
### Rate model, and how TMRC is chosen
Two things set the rate, and **the slower one wins** (§5.2.1). TMRC requests a
rate; the cycle count imposes a ceiling. Measured at TMRC 0x94 (150 Hz nominal,
138.8 Hz actual), varying only cc:
| 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% |
**When the cycle count governs, TMRC is irrelevant** — cc=400 gives 73.85 Hz at
TMRC 0x92 and 73.86 Hz at 0x94. So the simplest operating model is to set TMRC
fast (`0x92`) and use **cycle count as the single rate knob**: it is a continuous
integer where TMRC is coarse factor-of-two steps, and a cc-governed rate is
always ~100% duty, which sidesteps the quantisation trap entirely.
TMRC is then only worth choosing when you want a rate *slower* than the cycle
count allows — to save power by letting the chip idle, or to hit a specific
cadence.
The rate follows from a fitted model rather than Table 3-1's three data points
(`rm3100.sample_period` / `cycle_count_for_rate`):
```
per-axis time = cycle_count / 90,000 + 68.7 us
```
The divisor is the **specified** value, not a fitted one: Table 3-1 gives a
180 kHz circuit oscillation and §4.1 measures each count in *both* bias
directions, so one count costs two oscillations. Rates measured here across
cycle counts 22929,769 agree with that to within **0.8%**, which confirms the
spec figure rather than improving on it — the residual is this unit's oscillator
inside ordinary component tolerance, and another part would sit elsewhere.
The overhead has no specified value and must be measured. It is why the naive
`rate × cc` "constant" is not constant, drifting from 84,429 at cc=100 to
89,191 at cc=1200.
**Treat predicted rates as ±2%.** The manual quotes no oscillator tolerance, so
anything needing the real number measures it — `logger.py` calibrates the period
against the host clock before recording, and that is what every capture reports.
`sweep.py` measures this end to end. Every target from 1 to 128 Hz lands within
**0.1%**:
```
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 this host gives out; everything below it is exact. Note the
noise column past cycle count ~400 is extrapolation — the manual calls that its
useful upper limit and gives no data beyond.
### Choosing cycle count
Run each cycle count at *its own* fastest clean rate, not a fixed one. What then
matters is **duty** — the fraction of wall time the sensor is actually
integrating — because after decimating to a common bandwidth, noise scales as
1/√duty. Two ceilings compete: the sensor's (`~84,333/cc/3` Hz) and the host's
(~145 Hz here).
| cc | sensor max | TMRC | actual | duty | noise | LSB | Nyquist |
|---|---|---|---|---|---|---|---|
| 50 | 562 Hz | 0x94 | 138.8 Hz | **25%** | **2.01×** | 50.37 nT | 69.4 Hz |
| 100 | 281 Hz | 0x94 | 138.8 Hz | 49% | 1.42× | 26.17 nT | 69.4 Hz |
| **200** | 141 Hz | **0x94** | **138.8 Hz** | **99%** | 1.01× | 13.35 nT | 69.4 Hz |
| 250 | 112 Hz | 0x95 | 69.4 Hz | 62% | 1.27× | 10.72 nT | 34.7 Hz |
| **400** | 70 Hz | **0x95** | **69.4 Hz** | **99%** | 1.01× | 6.74 nT | 34.7 Hz |
| 600 | 47 Hz | 0x96 | 34.2 Hz | 73% | 1.17× | 4.51 nT | 17.1 Hz |
| **800** | 35 Hz | **0x96** | **34.2 Hz** | **97%** | 1.01× | 3.39 nT | 17.1 Hz |
**Because TMRC is quantised, cycle count should be chosen so the sensor ceiling
sits just *above* a TMRC step, not just below it.** Land just below and the rate
halves while integration time does not, wasting ~40% of the duty — cc=250 and
cc=600 are exactly that trap.
The natural operating points are `cc ≈ 203 / 405 / 821`, i.e. **200 / 400 / 800**
paired with TMRC `0x94 / 0x95 / 0x96`. Both extremes verified on hardware:
```
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)
```
Near-identical spectral density, as the equal-duty argument predicts — so the
choice between them is purely **bandwidth versus resolution**, not noise. Use
cc=200/0x94 when you need 60 Hz inside the band; cc=800/0x96 when resolution
matters more than bandwidth.
**Low cycle counts are dominated, not fast.** cc=50 reaches the same 138.8 Hz as
cc=200 — both are host-limited, not sensor-limited — but idles 75% of the time
and quantises 3.8× more coarsely. Its extra sensor speed is capability the host
cannot collect.
### 60 Hz: resolved rather than aliased
At 150 Hz, Nyquist is 69.3 Hz and mains lands **in band** — the strongest line
sits at **59.923 Hz**, observed directly. At 75 Hz it folds to 9.304 Hz, matching
the 9.233 Hz prediction, with only 4 nT/√Hz left at 60 Hz because that frequency
is no longer sampled.
This settles the mains hypothesis by observation rather than inference, and gives
a way to *measure* interference before deciding how to reject it.
### 60 s capture at maximum rate (office rig, historical)
`capture_60s.csv` / `capture_60s.png` — 28,864 samples, 481 Hz, cycle count 50,
400 kHz bus. The sensor was nudged by hand at t ≈ 41 s, so statistics are split
@ -359,6 +950,18 @@ Three things worth noting:
- [x] Register write/read verified — cycle counts written and read back
- [x] Analog section verified — BIST reports `XOK=YOK=ZOK=1`
- [x] **Live measurements logging to CSV at ~35 Hz**
- [x] Exactly-once sampling with abort-on-miss, and a chip-grid time base
- [x] Verified on hardware: exact grid, 6.11% drift recovered, 0.017% run-to-run
- [x] 3.0 V LDO built and compared — noise indistinguishable once normalised
- [ ] Re-test the LDO with the sensor **clamped**, to separate gain from movement
- [ ] Reliability testing over extended runs
- [ ] **Deal with 60 Hz mains aliasing into the band** — the dominant term
- [ ] Standoff distance, then a repeat characterisation
Initial communication is complete. A proper Python API is planned; these modules
are the initial-communication milestone, deliberately kept simple.
> **Captures written before the header format are unreadable** and must be
> re-recorded — `capture.py` says so explicitly rather than guessing. The
> figures quoted under *Observed performance* came from those older files and
> stand as historical measurements only.
A proper Python API is planned; these modules are the initial-communication
milestone, deliberately kept simple.

243
capture.py Normal file
View file

@ -0,0 +1,243 @@
"""Read an RM3100 capture and reconstruct everything the file does not store.
A capture holds only irreducible facts -- sample count, host clock, raw counts --
plus a header of configuration. This module turns that back into field values and
a time base, and is the single place either is derived.
Two time bases come out of `load()`:
elapsed_nominal N * dt_nominal, using the TMRC table rate
elapsed N * dt_true, using the rate actually observed
The chip's RC oscillator is accurate to about +/-7% (manual section 5.2.1) and
our unit runs ~6% slow, so `elapsed_nominal` is uniform but wrongly scaled.
`dt_true` comes from regressing the host clock on the sample index: the chip
supplies short-term regularity, the host supplies long-term rate calibration.
"""
import csv
import numpy as np
import rm3100
# Flags logger.py writes in the `warning` column, space separated. Duplicated
# rather than imported so that reading a capture never pulls in the USB stack.
WARN_MISSED = "MISSED" # placeholder row: no data, keeps the index contiguous
WARN_AMBIGUOUS = "AMBIGUOUS" # gap ending here was of uncertain length
class CaptureError(Exception):
pass
class Capture:
"""A loaded capture: raw counts, nanotesla axes, and two time bases."""
def __init__(self, path, meta, sample_index, system_time, counts,
missed=None, ambiguous=None):
self.path = path
self.missed = (np.zeros(len(sample_index), dtype=bool)
if missed is None else missed)
# A subset of missed: gaps whose length could not be counted
# confidently, so sample_index may have slipped across them.
self.ambiguous = (np.zeros(len(sample_index), dtype=bool)
if ambiguous is None else ambiguous)
self.meta = meta
self.sample_index = sample_index
self.system_time = system_time
self.counts = counts
self.cycle_count = int(meta["cycle_count"])
self.tesla_per_count = float(meta["tesla_per_count"])
self.lsb_nt = self.tesla_per_count * rm3100.NT_PER_TESLA
self.nominal_rate_hz = float(meta["nominal_rate_hz"])
self.dt_nominal = 1.0 / self.nominal_rate_hz
self.x, self.y, self.z = (counts[a] * self.lsb_nt for a in "xyz")
self.total = np.sqrt(self.x**2 + self.y**2 + self.z**2)
# Least-squares fit of host clock against grid coordinate. numpy's
# polyfit is centred internally, so the large epoch offset is harmless.
slope, intercept = np.polyfit(sample_index, system_time, 1)
self.dt_true = float(slope)
# Error in the *rate*, so the sign matches the reported Hz: negative
# means the chip samples slower than the nominal table value.
self.rate_error = self.dt_nominal / self.dt_true - 1.0
self.residuals = system_time - (intercept + slope * sample_index)
self.elapsed_nominal = sample_index * self.dt_nominal
self.elapsed = sample_index * self.dt_true
# Two different things live in the timing error, and conflating them is
# misleading. Read latency is local: the scatter of one read interval
# about the next. Fit residual is global: how far the whole capture
# departs from a single straight line, which on a long run is dominated
# by the oscillator's rate drifting with temperature, not by the host.
self.read_jitter = float(np.diff(system_time).std()) if len(system_time) > 1 else 0.0
self.residual_sd = float(self.residuals.std())
self.residual_span = float(np.ptp(self.residuals))
# One sample period of accumulated error means the single slope is no
# longer describing the capture.
self.drift_limited = self.residual_sd > self.dt_true
@property
def true_rate_hz(self):
return 1.0 / self.dt_true
@property
def duration(self):
return float(self.elapsed[-1] - self.elapsed[0])
def axes(self):
"""(key, array) for the three axes plus the derived total."""
return [("x", self.x), ("y", self.y), ("z", self.z),
("total", self.total)]
def restrict(self, start=None, end=None):
"""Return a new Capture covering a window of drift-corrected seconds.
Re-fits on the subset, so a window's rate is its own rather than
inherited -- which is what makes it usable for spotting drift across a
long run by comparing windows.
"""
keep = np.ones(len(self.sample_index), dtype=bool)
if start is not None:
keep &= self.elapsed >= start
if end is not None:
keep &= self.elapsed <= end
if keep.sum() < 64:
raise CaptureError(
f"{self.path}: window [{start}, {end}] leaves "
f"{int(keep.sum())} samples, too few to analyse")
return Capture(self.path, self.meta, self.sample_index[keep],
self.system_time[keep],
{a: self.counts[a][keep] for a in "xyz"},
self.missed[keep], self.ambiguous[keep])
def summary(self):
lines = [
f"{self.path}: {len(self.sample_index):,} samples over "
f"{self.duration:.2f} s",
f" cycle count {self.cycle_count}, 1 LSB = {self.lsb_nt:.2f} nT",
f" rate {self.true_rate_hz:.3f} Hz measured vs "
f"{self.nominal_rate_hz:g} Hz nominal ({self.rate_error * 100:+.2f}%)",
f" read jitter {self.read_jitter * 1e3:.3f} ms sd | "
f"fit residual {self.residual_sd * 1e3:.1f} ms sd, "
f"{self.residual_span:.2f} s span",
f" Nyquist {self.true_rate_hz / 2:.2f} Hz",
]
if self.drift_limited:
lines += ["",
f" WARNING: the single-rate model does not fit this capture "
f"(residual sd {self.residual_sd * 1e3:.0f} ms",
f" against a {self.dt_true * 1e3:.1f} ms period)."]
if self.missed.any():
# Each lost measurement is an independent chance to insert one
# placeholder too many or too few, and the error accumulates in
# sample_index. That is the likelier cause here than thermal drift.
lines += [
f" With {int(self.missed.sum()):,} lost measurement(s) the likely cause is "
"miscounted placeholders:",
" how many grid points passed unseen can only be estimated, so the index",
" slips by about one per miss. The grid is exact only in a loss-free "
"capture.",
" Re-record at a lower rate rather than trusting this one for "
"spectral work.",
]
else:
lines += [
" No measurements were lost, so this is the chip's oscillator drifting --",
" expect thermal variation over a long run. Frequencies are scaled by an",
" average rate and will be smeared; analyse shorter windows",
" (capture.restrict) for spectral work.",
]
if self.missed.any():
n = int(self.missed.sum())
lines.insert(1, f" {n:,} lost measurement(s) "
f"({n / len(self.missed) * 100:.3f}%), interpolated")
if self.ambiguous.any():
lines.insert(1, f" {int(self.ambiguous.sum()):,} AMBIGUOUS gap(s) "
"-- length uncertain, index may have slipped")
if self.meta.get("note"):
lines.insert(1, f" note: {self.meta['note']}")
return "\n".join(lines)
def _parse_header(handle):
"""Consume leading '# key: value' lines, leaving the reader at the CSV."""
meta = {}
while True:
position = handle.tell()
line = handle.readline()
if not line:
break
if not line.startswith("#"):
handle.seek(position)
break
key, _, value = line[1:].partition(":")
if value:
meta[key.strip()] = value.strip()
return meta
def load(path):
"""Load a capture written by logger.py."""
with open(path, newline="") as handle:
meta = _parse_header(handle)
rows = list(csv.DictReader(handle))
if "rm3100_capture" not in meta:
raise CaptureError(
f"{path}: no capture header found. Files written before the header "
"format was introduced cannot be read -- re-record them.")
missing = {"cycle_count", "tesla_per_count", "nominal_rate_hz"} - meta.keys()
if missing:
raise CaptureError(f"{path}: header missing {', '.join(sorted(missing))}")
if len(rows) < 64:
raise CaptureError(f"{path}: only {len(rows)} samples, too few to analyse")
# logger.py writes "MISSED" in the count fields for a measurement it could
# not read, keeping sample_index contiguous so the chip-time grid stays
# valid across the gap.
if "warning" not in rows[0]:
raise CaptureError(
f"{path}: no 'warning' column. Captures predating it cannot be "
"read -- re-record them.")
flags = [set(r["warning"].split()) for r in rows]
missed = np.array([WARN_MISSED in f for f in flags])
# Independent of missed: a gap can be uncertain yet round to zero losses,
# in which case the flag rides on the real sample that ends it.
ambiguous = np.array([WARN_AMBIGUOUS in f for f in flags])
if missed.all():
raise CaptureError(f"{path}: every row is a lost measurement")
sample_index = np.array([int(r["sample_index"]) for r in rows], dtype=np.int64)
# logger.py aborts on a missed interval, so a gap here means the file was
# damaged or hand-edited rather than merely cut short.
gaps = np.diff(sample_index)
if np.any(gaps != 1):
bad = int(sample_index[np.argmax(gaps != 1)])
raise CaptureError(
f"{path}: sample_index is not contiguous (breaks after {bad}). "
"The chip grid is only valid for an unbroken index.")
# Lost measurements carry no data. They are linearly interpolated so the
# uniform grid the spectra depend on is preserved, and counted so the
# substitution is never silent.
counts = {}
good = ~missed
for a in "xyz":
# Every row parses as an integer -- placeholders carry zeros, and the
# warning column is what marks them as having no data.
v = np.array([int(r[f"{a}_raw"]) for r in rows], dtype=np.float64)
if missed.any():
v[missed] = np.interp(np.flatnonzero(missed), np.flatnonzero(good),
v[good])
counts[a] = v
return Capture(
path, meta, sample_index,
np.array([float(r["system_time_unix"]) for r in rows]),
counts, missed, ambiguous,
)

View file

@ -13,6 +13,8 @@ aystarik/ch347-i2c-spi-gpio Linux driver, whose USB id table entry
USB_DEVICE_INTERFACE_NUMBER(0x1a86, 0x55db, 0x02) matches this device.
"""
import time
import usb.core
import usb.util
@ -34,7 +36,11 @@ CMD_OUT = 0x80 # write; low 6 bits are the byte count
CMD_IN = 0xC0 # read; low 6 bits are the byte count
CMD_SET = 0x60 # set bus speed; low bits select the rate
# Bus speeds, as the low nibble of CMD_SET.
# Bus speeds, as the low nibble of CMD_SET. The fastest is the default: bus
# speed does not set the sample rate -- the sensor does -- but it does set how
# long a read takes, and so how tightly an event on the far side of the bus can
# be timestamped. Measured host cost per sample is bus time plus ~0.6 ms of
# fixed USB round-trip latency: 2.13 ms at 100 kHz, 0.92 at 400, 0.79 at 750.
SPEED_20KHZ = 0
SPEED_100KHZ = 1
SPEED_400KHZ = 2
@ -54,7 +60,7 @@ class CH347I2C:
followed by the payload.
"""
def __init__(self, speed=SPEED_100KHZ):
def __init__(self, speed=SPEED_750KHZ):
self._dev = usb.core.find(idVendor=VENDOR_ID, idProduct=PRODUCT_ID)
if self._dev is None:
raise IOError(
@ -90,21 +96,32 @@ class CH347I2C:
return False
def _xfer(self, out_bytes, in_len):
"""Send one command stream, then read in_len status/data bytes."""
"""Send one command stream, then read in_len status/data bytes.
Returns (data, mono, wall), the clocks read the instant the reply
lands -- before any parsing or unwinding of the call stack. A caller
timing an event on the far side of the bus (DRDY going high, say) wants
that moment, not one several Python frames later: the frames add both a
systematic lag and jitter from whatever the interpreter does between.
The stamp travels with its own reply rather than being left on the
instance, so it cannot go stale or be picked up by the wrong call.
"""
written = self._dev.write(EP_OUT, bytes(out_bytes), TIMEOUT_MS)
if written != len(out_bytes):
raise IOError(
f"Short USB write to CH347: sent {written} of {len(out_bytes)} bytes"
)
if in_len == 0:
return b""
return b"", time.monotonic(), time.time()
reply = bytes(self._dev.read(EP_IN, in_len, TIMEOUT_MS))
mono, wall = time.monotonic(), time.time()
if len(reply) != in_len:
raise IOError(
f"Short USB read from CH347: got {len(reply)} of {in_len} bytes"
)
return reply
return reply, mono, wall
def set_speed(self, speed):
"""Select the I2C clock rate (one of the SPEED_* constants)."""
@ -126,7 +143,7 @@ class CH347I2C:
packet += [CMD_STO, CMD_END]
# One status byte per clocked-out byte: the address plus the payload.
reply = self._xfer(packet, len(data) + 1)
reply, _, _ = self._xfer(packet, len(data) + 1)
if reply[0] != ACK:
raise IOError(f"No ACK from I2C address 0x{addr:02x} on write")
if any(byte != ACK for byte in reply[1:]):
@ -136,7 +153,7 @@ class CH347I2C:
)
def read(self, addr, count):
"""Read count bytes from a 7-bit address. Raises IOError on NACK."""
"""Read count bytes from an address. Returns (data, mono, wall)."""
if not 1 <= count <= MAX_XFER:
raise ValueError(f"Read of {count} bytes outside 1..{MAX_XFER}")
@ -147,16 +164,58 @@ class CH347I2C:
packet.append(CMD_IN | (count - 1))
packet += [CMD_IN, CMD_STO, CMD_END]
reply = self._xfer(packet, count + 1)
reply, mono, wall = self._xfer(packet, count + 1)
if reply[0] != ACK:
raise IOError(f"No ACK from I2C address 0x{addr:02x} on read")
return reply[1:]
return reply[1:], mono, wall
def write_read(self, addr, data, count):
"""Write data then read count bytes in one transaction.
Returns (data, mono, wall) -- see _xfer() on why the stamp is returned
rather than stored.
Uses a repeated START rather than STOP-then-START, so the whole
exchange is a single USB round trip instead of two. Measured at
400 kHz this halves a one-byte register read (0.65 -> 0.35 ms), which
matters because the host round trip, not the bus, sets the sample-rate
ceiling.
The RM3100's manual draws its register reads with a STOP between the
pointer write and the read (sections 4.5.2, 5.8.4), but the part
accepts a repeated START -- verified against REVID and the measurement
registers.
"""
data = bytes(data)
if not data:
raise ValueError("write_read needs at least one byte to write")
if len(data) > MAX_XFER - 1:
raise ValueError(f"Write of {len(data)} bytes exceeds the CH347 limit")
if not 1 <= count <= MAX_XFER:
raise ValueError(f"Read of {count} bytes outside 1..{MAX_XFER}")
packet = [CMD_STREAM, CMD_STA, CMD_OUT | (len(data) + 1), addr << 1]
packet += data
packet += [CMD_STA, CMD_OUT | 1, (addr << 1) | 1]
if count > 1:
packet.append(CMD_IN | (count - 1))
packet += [CMD_IN, CMD_STO, CMD_END]
# One status byte per clocked-out byte: the write address, the payload,
# and the read address -- then the payload itself.
acks = len(data) + 2
reply, mono, wall = self._xfer(packet, acks + count)
if any(byte != ACK for byte in reply[:acks]):
raise IOError(
f"I2C address 0x{addr:02x} NACKed during combined transfer "
f"(status {reply[:acks].hex(' ')})")
return reply[acks:], mono, wall
def probe(self, addr):
"""Return True if a device ACKs its address. A NACK is not an error."""
packet = [CMD_STREAM, CMD_STA, CMD_OUT | 1, addr << 1, CMD_STO, CMD_END]
try:
return self._xfer(packet, 1)[0] == ACK
return self._xfer(packet, 1)[0][0] == ACK
except IOError:
return False

View file

@ -12,18 +12,17 @@ Produces a four-panel figure and a text summary:
over -- the honest measure of a noise floor.
Residual distribution after removing a slow trend, so a non-Gaussian
tail or quantisation shows up.
Sample interval whether the timing supports spectral analysis
at all.
Read latency host-side diagnostics only -- the measurement
grid itself is uniform regardless.
Timing caveat: in continuous measurement mode the sensor samples on its own
internal schedule, so the true sample instants are near-uniform even when our
reads are jittery. The spectral estimates assume uniform spacing at the mean
observed rate. That assumption holds only if no samples were missed or read
twice -- which is exactly what the sample-interval panel is there to check.
The sample grid is exact, not assumed: the sensor samples on its own internal
schedule (manual sections 5.7.2, 5.8.2), logger.py aborts rather than skip an
interval, and capture.py refuses a non-contiguous sample index. Frequencies use
the rate measured against the host clock, not the nominal table value, which is
~6% out on this unit.
"""
import argparse
import csv
import sys
import matplotlib
@ -31,6 +30,8 @@ matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import capture
SURFACE = "#fcfcfb"
TEXT_PRIMARY = "#0b0b0b"
TEXT_SECONDARY = "#52514e"
@ -40,27 +41,16 @@ REFERENCE = "#8a8880"
# Categorical slots 1-3; three peer axes, validated all-pairs in light mode.
AXES = [("x", "X", "#2a78d6"), ("y", "Y", "#eb6834"), ("z", "Z", "#1baf7a")]
# Total field is derived from the three axes rather than a fourth peer, so it
# takes neutral ink instead of a competing hue -- which also keeps the
# categorical set at the three slots that validate.
TOTAL = ("total", "Total", TEXT_PRIMARY)
SERIES = AXES + [TOTAL]
# Table 3-1: "Noise Density @ Max. Single-Axis Sample Rate".
SPEC_ASD_NT = 1.2
def load(path):
t, cols, gains = [], {a: [] for a, _, _ in AXES}, []
with open(path, newline="") as fh:
for row in csv.DictReader(fh):
t.append(float(row["elapsed_s"]))
for a, _, _ in AXES:
ut = float(row[f"{a}_uT"])
cols[a].append(ut)
if abs(ut) > 1.0:
gains.append(float(row[f"{a}_raw"]) / ut)
if len(t) < 64:
sys.exit(f"{path} has too few samples to characterise")
return (np.array(t),
{a: np.array(v) * 1000.0 for a, v in cols.items()}, # work in nT
float(np.median(gains)))
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))
@ -115,32 +105,39 @@ def main():
help="ignore samples after this elapsed time (s)")
args = ap.parse_args()
t, data, gain = load(args.csv)
keep = t >= args.start
if args.end is not None:
keep &= t <= args.end
t, data = t[keep], {a: v[keep] for a, v in data.items()}
if len(t) < 64:
sys.exit("selected window has too few samples")
try:
cap = capture.load(args.csv)
if args.start or args.end is not None:
cap = cap.restrict(args.start or None, args.end)
except (OSError, capture.CaptureError) as exc:
sys.exit(str(exc))
duration = t[-1] - t[0]
fs = (len(t) - 1) / duration
intervals = np.diff(t)
lsb_nt = 1000.0 / gain
# Total field is derived from the three axes; its noise is dominated by
# whichever axis carries most of the field, weighted by direction cosines,
# so it tracks that axis rather than being an independent measurement.
data = dict(cap.axes())
t = cap.elapsed
# Rate measured against the host clock, not the nominal table value: a 6%
# error would put every spectral feature 6% off.
fs = cap.true_rate_hz
lsb_nt = cap.lsb_nt
# Host read latency, kept apart from the sample grid it does not affect.
latency = np.diff(cap.system_time) # local read spacing, not the fit residual
print(f"{args.csv}: {len(t):,} samples over {duration:.2f} s")
print(f" mean rate {fs:.1f} Hz interval {intervals.mean()*1000:.3f} ms "
f"+/- {intervals.std()*1000:.3f} ms max {intervals.max()*1000:.1f} ms")
print(f" gain {gain:.2f} LSB/uT -> 1 LSB = {lsb_nt:.1f} nT")
print(f" Nyquist {fs/2:.1f} Hz\n")
print("axis sd p2p sd/LSB white-noise ASD median ASD")
# Normalising by |B| makes noise comparable across captures whose scale
# differs -- a gain change carries the noise with it, so absolute sd alone
# will read as a noise difference when only the scale moved.
field = data["total"].mean()
print(cap.summary() + "\n")
print("series sd sd/|B| p2p sd/LSB white-noise ASD median ASD")
fig, axs = plt.subplots(2, 2, figsize=(13.5, 9), dpi=150)
fig.patch.set_facecolor(SURFACE)
for ax in axs.flat:
ax.set_facecolor(SURFACE)
for key, label, color in AXES:
for key, label, color in SERIES:
v = data[key]
sd = v.std()
# A flat (white) spectrum of this sd would sit at this level.
@ -159,7 +156,7 @@ def main():
axs[1, 0].hist(resid, bins=120, histtype="step", linewidth=1.4,
color=color, label=label, density=True)
print(f"{label:4s} {sd:8.1f} {v.max()-v.min():9.1f} nT "
print(f"{label:6s} {sd:8.1f} {sd/field*1e6:7.1f}ppm {v.max()-v.min():9.1f} nT "
f"{sd/lsb_nt:7.2f} {implied:9.2f} nT/rtHz "
f"{np.median(asd):9.2f} nT/rtHz")
@ -186,15 +183,18 @@ def main():
a.set_xlabel("nT"); a.set_ylabel("density")
a = axs[1, 1]
a.hist(intervals * 1000, bins=120, color=REFERENCE)
a.hist(latency * 1000, bins=120, color=REFERENCE)
a.set_yscale("log")
a.axvline(1000 / fs, color=TEXT_PRIMARY, linestyle="--", linewidth=1.2)
a.annotate(f"mean {1000/fs:.2f} ms", xy=(1000 / fs, 1),
a.axvline(cap.dt_true * 1000, color=TEXT_PRIMARY, linestyle="--", linewidth=1.2)
a.annotate(f"grid {cap.dt_true * 1000:.2f} ms", xy=(cap.dt_true * 1000, 1),
xytext=(6, 0), textcoords="offset points",
color=TEXT_PRIMARY, fontsize=9)
a.set_title("Sample interval", loc="left",
a.set_title("Host read latency", loc="left",
color=TEXT_PRIMARY, fontsize=12, fontweight="bold", pad=8)
a.set_xlabel("ms"); a.set_ylabel("count")
a.annotate("spread is host-side only;\nthe sample grid is exact",
xy=(0.98, 0.94), xycoords="axes fraction", ha="right", va="top",
color=TEXT_SECONDARY, fontsize=9)
a.set_xlabel("interval between reads (ms)"); a.set_ylabel("count")
for ax in axs.flat:
ax.grid(True, which="both", color=GRID, linewidth=0.7)
@ -206,15 +206,19 @@ def main():
ax.tick_params(colors=TEXT_SECONDARY, labelsize=9, length=0)
ax.xaxis.label.set_color(TEXT_SECONDARY)
ax.yaxis.label.set_color(TEXT_SECONDARY)
for ax in (axs[0, 0], axs[0, 1], axs[1, 0]):
ax.legend(frameon=False, fontsize=9, labelcolor=TEXT_SECONDARY)
# Explicit placement: "best" puts the ASD legend on top of the spec-line
# annotation in the lower left.
for ax, loc in ((axs[0, 0], "upper right"), (axs[0, 1], "upper right"),
(axs[1, 0], "upper right")):
ax.legend(frameon=False, fontsize=9, labelcolor=TEXT_SECONDARY, loc=loc)
fig.suptitle("RM3100 noise characterisation", color=TEXT_PRIMARY,
fontsize=15, fontweight="bold", y=0.985)
fig.text(0.5, 0.945,
f"{len(t):,} samples, {duration:.1f} s at {fs:.0f} Hz, "
f"1 LSB = {lsb_nt:.1f} nT. Spectra assume uniform sampling at the "
f"mean rate (see interval panel).",
f"{len(t):,} samples, {cap.duration:.1f} s at {fs:.2f} Hz measured "
f"(nominal {cap.nominal_rate_hz:g} Hz, {cap.rate_error * 100:+.1f}%), "
f"cycle count {cap.cycle_count}, 1 LSB = {lsb_nt:.2f} nT. "
f"Sample grid is exact.",
color=TEXT_SECONDARY, fontsize=10, ha="center")
fig.tight_layout(rect=[0, 0, 1, 0.935])

144
compare.py Normal file
View file

@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Compare captures taken under different conditions -- e.g. two supplies.
./.venv/bin/python compare.py a.csv b.csv c.csv ...
./.venv/bin/python compare.py --group note *.csv # group by header note
Built around one hard lesson: a naive comparison of absolute noise is wrong when
the measured scale differs between runs. A gain change carries the noise with it,
so a run that reads 6% larger also reads ~6% noisier while being physically
identical. Everything here is therefore reported **fractionally**, in ppm of the
field magnitude.
The second trap is attributing a scale change to the variable under test when
the sensor simply moved. Two diagnostics separate them:
per-axis ratio spread a pure gain change scales X, Y and Z identically,
so the spread is ~0. Anything larger means the sensor
moved.
rotation angle the angle between mean field directions. ~0 deg means
the sensor held still.
Both must be small before a magnitude difference can be blamed on gain. Note
that |B| is preserved under rotation but *not* under translation through a field
gradient, so a moved sensor can change magnitude on its own.
"""
import argparse
import itertools
import sys
from collections import defaultdict
import numpy as np
import capture
import characterize as ch
# A pure gain change scales every axis by the same factor. Allow a little for
# noise on the means before calling it movement.
RATIO_SPREAD_OK = 0.01 # 1%
ROTATION_OK_DEG = 0.5
def summarise(cap):
d = dict(cap.axes())
mean = np.array([d["x"].mean(), d["y"].mean(), d["z"].mean()])
field = float(np.linalg.norm(mean))
fs = cap.true_rate_hz
out = {"cap": cap, "mean": mean, "field": field, "axes": d, "fs": fs}
for key, _, _ in ch.SERIES:
v = d[key]
freqs, asd = ch.welch_asd(v, fs, nperseg=min(4096, len(v) // 4 * 2))
band = freqs > min(3.0, fs / 8)
out[key] = {
"sd": v.std(),
"ppm": v.std() / field * 1e6,
"asd": float(np.median(asd[band])) if band.any() else float("nan"),
"peak": float(asd[band].max()) if band.any() else float("nan"),
"peak_hz": float(freqs[band][np.argmax(asd[band])]) if band.any() else float("nan"),
}
taus, devs = ch.allan_deviation(d["total"], fs)
i = int(np.argmin(devs))
out["allan"] = (float(devs[i]), float(taus[i]))
return out
def label_for(cap, path, group_by):
if group_by == "note":
return cap.meta.get("note", "(no note)")
return path
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("csv", nargs="+")
ap.add_argument("--group", choices=["note", "file"], default="file",
help="group captures by header note, averaging repeats "
"(default: %(default)s)")
args = ap.parse_args()
recs = []
for path in args.csv:
try:
cap = capture.load(path)
except (OSError, capture.CaptureError) as exc:
print(f"skipping {path}: {exc}", file=sys.stderr)
continue
recs.append((label_for(cap, path, args.group), path, summarise(cap)))
if len(recs) < 1:
sys.exit("nothing to compare")
print(f"{'capture':<26} {'|B| nT':>10} {'sd nT':>8} {'sd ppm':>9} "
f"{'ASD':>7} {'peak':>8} {'@Hz':>7} {'Allan':>7}")
for label, path, s in recs:
t = s["total"]
print(f"{label[:26]:<26} {s['field']:10,.0f} {t['sd']:8.1f} {t['ppm']:9.1f} "
f"{t['asd']:7.2f} {t['peak']:8.1f} {t['peak_hz']:7.3f} "
f"{s['allan'][0]:7.2f}")
print(" ASD/peak in nT/rtHz; Allan = best sigma by averaging")
# Averaged per group, which is the number to compare when runs are repeated.
if args.group == "note":
groups = defaultdict(list)
for label, _, s in recs:
groups[label].append(s)
if len(groups) > 1:
print("\n=== group means (fractional -- the comparable figure) ===")
for label, ss in groups.items():
ppm = [s["total"]["ppm"] for s in ss]
print(f"{label[:26]:<26} n={len(ss)} "
f"|B| {np.mean([s['field'] for s in ss]):9,.0f} nT "
f"sd {np.mean(ppm):7.1f} ppm"
+ (f" +/- {np.std(ppm):.1f}" if len(ss) > 1 else ""))
if len(recs) < 2:
return 0
print("\n=== pairwise: did the sensor hold still? ===")
print("A magnitude difference only means gain if BOTH checks pass.")
for (la, _, a), (lb, _, b) in itertools.combinations(recs, 2):
ratio = b["mean"] / a["mean"]
spread = float(np.ptp(ratio) / np.abs(np.mean(ratio)))
ua, ub = a["mean"] / a["field"], b["mean"] / b["field"]
angle = float(np.degrees(np.arccos(np.clip(np.dot(ua, ub), -1, 1))))
moved = spread > RATIO_SPREAD_OK or angle > ROTATION_OK_DEG
print(f"\n{la[:24]} -> {lb[:24]}")
print(f" |B| ratio {b['field']/a['field']:.5f} "
f"({(b['field']/a['field'] - 1) * 100:+.2f}%)")
print(f" fractional noise {a['total']['ppm']:.1f} -> {b['total']['ppm']:.1f} ppm "
f"({(b['total']['ppm']/a['total']['ppm'] - 1) * 100:+.1f}%)")
print(f" per-axis ratios X {ratio[0]:.5f} Y {ratio[1]:.5f} Z {ratio[2]:.5f}"
f" spread {spread * 100:.2f}%")
print(f" rotation {angle:.3f} deg")
if moved:
print(" -> SENSOR MOVED. The magnitude difference cannot be "
"attributed to gain;\n re-run with the sensor clamped.")
else:
print(f" -> held still. The {(b['field']/a['field'] - 1) * 100:+.2f}% "
"magnitude difference is a real gain change.")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -6,7 +6,7 @@ broken, so a failure points at a specific wire rather than "it doesn't work":
adapter -> I2C bus -> sensor identity -> registers -> analog section
Usage: ./.venv/bin/python diagnose.py
Usage: ./.venv/bin/python diagnose-comms.py
"""
import sys
@ -19,6 +19,9 @@ import rm3100
# the most forgiving self-test settings (manual Tables 5-6, 5-7).
BIST_RUN = 0x8F
# Matches the CH347I2C default; named here only so the report can state it.
BUS_KHZ = 750
def check(label, ok, detail=""):
print(f" [{'OK ' if ok else 'FAIL'}] {label}" + (f" -- {detail}" if detail else ""))
@ -33,7 +36,8 @@ def main():
check("open CH347", False, str(exc))
print("\n-> Run ./setup.sh, or check the adapter is plugged in and in Mode 1.")
return 1
check("open CH347 and set I2C speed", True, "interface 2 claimed, 100 kHz")
check("open CH347 and set I2C speed", True,
f"interface {ch347.INTERFACE} claimed, {BUS_KHZ} kHz")
with bus:
print("\n2. I2C bus")
@ -77,7 +81,7 @@ def main():
sensor.write_reg(rm3100.REG_BIST, [BIST_RUN])
sensor.write_reg(rm3100.REG_POLL, [rm3100.POLL_ALL_AXES])
time.sleep(0.5)
bist = sensor.read_reg(rm3100.REG_BIST)[0]
bist = sensor.read_reg(rm3100.REG_BIST)[0][0]
sensor.write_reg(rm3100.REG_BIST, [0x00])
axes = {"X": (bist >> 4) & 1, "Y": (bist >> 5) & 1, "Z": (bist >> 6) & 1}
@ -98,18 +102,31 @@ def main():
print("\n6. Live measurement")
sensor.configure()
# BIST zeroes the measurement registers and leaves DRDY set. With
# DRC1=1 only a results read clears it, so discard one -- otherwise
# wait_for_data() returns instantly on the stale flag and reports zeros.
sensor.read_raw()
sensor.set_rate(0x96)
sensor.start_cmm()
try:
ready = sensor.wait_for_data(timeout=2.0)
if not check("DRDY asserts", ready, "" if ready else "no data within 2 s"):
return 1
counts, ut = sensor.read_measurements()
magnitude = sum(v * v for v in ut) ** 0.5
check("field magnitude is plausible", 25 <= magnitude <= 65,
f"{magnitude:.1f} uT (Earth's field is 25-65)")
counts, tesla = sensor.read_measurements()
magnitude = sum(v * v for v in tesla) ** 0.5
nt = [v * rm3100.NT_PER_TESLA for v in tesla]
plausible = check(
"field magnitude is plausible", 25e-6 <= magnitude <= 65e-6,
f"{magnitude * rm3100.NT_PER_TESLA:,.0f} nT "
"(Earth's field is 25,000-65,000)")
print(f" raw {counts}")
print(f" X {ut[0]:+.3f} Y {ut[1]:+.3f} Z {ut[2]:+.3f} uT")
print(f" X {nt[0]:+,.0f} Y {nt[1]:+,.0f} Z {nt[2]:+,.0f} nT")
if not plausible:
print("\n-> Communication works but the numbers are wrong. A reading of\n"
" exactly zero means the results registers were read without a\n"
" fresh measurement behind them; anything else suggests the gain\n"
" or the int24 decode.")
return 1
finally:
sensor.stop_cmm()

554
logger.py
View file

@ -3,33 +3,34 @@
Run ./setup.sh first to install the udev rule and create the virtualenv, then:
./.venv/bin/python logger.py --duration 10
./.venv/bin/python logger.py --duration 60
./.venv/bin/python logger.py --scan-only
The sampling loop is kept clear of everything that is not I2C traffic: raw
counts go onto a queue, and a writer thread does the unit conversion, CSV
formatting and console display. At the rates the sensor can reach (~540 Hz at
cycle count 50) that formatting work is otherwise the bottleneck, not the bus.
The chip samples on its own internal schedule (manual sections 5.7.2, 5.8.2), so
`sample_index` is a grid coordinate, not a count of reads. A late read returns the
*newer* measurement rather than a delayed one, so an unnoticed miss would silently
compress the time axis. Misses are therefore detected and written as explicit
placeholder rows, keeping the index contiguous and the gap visible; the run
continues and reports the total at the end.
Only irreducible facts are written: the sample count, the host clock, and the raw
counts. Chip time, tesla and magnitude are all reconstructed on load from the
header -- see capture.py.
"""
import argparse
import csv
import math
import queue
import sys
import threading
import time
from datetime import datetime, timedelta, timezone
from datetime import datetime
import ch347
import rm3100
CSV_FIELDS = [
"timestamp_iso", "elapsed_s",
"x_raw", "y_raw", "z_raw",
"x_uT", "y_uT", "z_uT",
"magnitude_uT",
]
CSV_FIELDS = ["sample_index", "system_time_unix", "x_raw", "y_raw", "z_raw",
"warning"]
BUS_SPEEDS = {
20: ch347.SPEED_20KHZ,
@ -41,6 +42,35 @@ BUS_SPEEDS = {
CONSOLE_REFRESH_S = 0.05
FLUSH_INTERVAL_S = 0.5
# The sampling loop releases the GIL inside each USB transfer, then has to take
# it back. Python's default 5 ms switch interval means the writer thread can
# hold it for that long, stalling the sampler and inflating the DRDY bracket --
# measured at ~10 ms against a 6.67 ms budget at 150 Hz, while the same loop
# without a writer thread peaked at 3.1 ms. Handing off 10x more often costs
# negligible throughput (the writer is not CPU-bound) and keeps the stall well
# inside the bracket budget.
GIL_SWITCH_INTERVAL_S = 0.0005
# Miss detection uses the DRDY bracket -- the span between the last poll showing
# DRDY clear and the poll showing it set -- rather than the read-to-read
# interval.
#
# The bracket is exact where the interval is only a heuristic. If DRDY reads
# clear at t_c then every earlier measurement has already been read, since a
# results read is what clears it. Measurements complete one period apart, so a
# bracket narrower than one period can contain at most one completion, and DRDY
# going high proves it contained at least one. Exactly one, whatever the host
# was doing beforehand.
#
# The read-to-read interval cannot make that claim: measured on this rig it
# reaches 37 ms against a 28.8 ms period -- a 29% overshoot from host stalls
# alone -- while the bracket stays under 10 ms. Thresholding the interval
# therefore flags healthy captures as lossy.
#
# The bracket is compared against the period measured by calibrate_period(), not
# the table value: the latter is 6-9% out on this unit, which is enough to
# miscount grid points inside a bracket and slip the sample index.
# Bounded so a stalled writer degrades predictably instead of exhausting memory
# on a long run. Far above the depth a healthy writer ever reaches.
QUEUE_MAX = 200_000
@ -48,29 +78,111 @@ QUEUE_MAX = 200_000
_SENTINEL = object()
# Rows carry flags in a `warning` column, space separated, empty when fine.
# Keeping them out of the numeric columns means x/y/z stay parseable as numbers
# (blank for a row with no data) and the column generalises to future flags.
#
# MISSED this row is a placeholder -- the measurement was never read, so it
# has no data. The row exists to keep sample_index contiguous, so it
# remains a valid chip-time grid coordinate and the gap is explicit
# rather than silently compressing the timeline.
# AMBIGUOUS the gap ending at this row was of uncertain length, so the index
# may have slipped from here on.
#
# The two are independent. A gap measuring 1.35 periods rounds to one, so no
# placeholder is written -- yet it sits far enough from an integer to distrust,
# and that case carries AMBIGUOUS on the *real* sample terminating the gap,
# which keeps its data. A placeholder inside an uncertain gap carries both.
#
# The flag is a confidence measure, not a claim that 1.35 and 1.65 are equally
# likely: 1.35 probably is one period and 1.65 probably two. Deciding which
# needs neighbouring timestamps and assumptions, and can still be wrong, which
# is exactly why the rows are flagged rather than silently resolved.
WARN_MISSED = "MISSED"
WARN_AMBIGUOUS = "AMBIGUOUS"
# How near a half-period the rounding may fall before the count is a coin toss.
AMBIGUITY_MARGIN = 0.25
def parse_args():
p = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--cycle-count", type=int, default=rm3100.DEFAULT_CYCLE_COUNT,
help="cycle count per axis; lower is faster but noisier. "
"50 allows ~540 Hz, 200 allows ~150 Hz (default: %(default)s)")
p.add_argument("--tmrc", type=lambda s: int(s, 0), default=0x96,
knob = p.add_mutually_exclusive_group()
knob.add_argument("--rate", type=float, default=None,
help="target sample rate in Hz. The cycle count and TMRC "
"are derived from it, which is usually what you want "
"-- the cycle count is a continuous rate knob where "
"TMRC offers only factor-of-two steps")
knob.add_argument("--cycle-count", type=int, default=None,
help="cycle count per axis, setting both the rate and the "
"resolution. Lower is faster but more coarsely "
f"quantised (default: {rm3100.DEFAULT_CYCLE_COUNT})")
p.add_argument("--tmrc", type=lambda s: int(s, 0), default=None,
help="continuous-mode rate register, 0x92 (fastest) to 0x9D "
"(slowest) (default: 0x96, ~37 Hz)")
"(slowest). Default is the fastest, which lets the cycle "
"count set the rate -- give one only to sample slower "
"than the cycle count allows")
p.add_argument("--duration", type=float, default=0.0,
help="seconds to log, or 0 to run until Ctrl-C (default: %(default)s)")
p.add_argument("--output", default=None,
help="CSV output path (default: rm3100_<timestamp>.csv)")
p.add_argument("--address", type=lambda s: int(s, 0), default=None,
help="I2C address, skipping the scan (default: autodetect)")
p.add_argument("--bus-speed", type=int, choices=[20, 100, 400, 750], default=100,
help="I2C bus speed in kHz; 400 measured best for high sample "
"rates (default: %(default)s)")
p.add_argument("--bus-speed", type=int, choices=[20, 100, 400, 750], default=750,
help="I2C bus speed in kHz. The default cycle count runs near "
"534 Hz, where 100 kHz would spend 80%% of each period on "
"the bus; 750 spends 11%% (default: %(default)s)")
p.add_argument("--calibrate", type=float, default=1.0,
help="seconds of loss-free samples used to measure the true "
"measurement period before recording starts; the run "
"aborts if no clean stretch can be found (default: "
"%(default)s)")
p.add_argument("--note", default=None,
help="free-text label recorded in the capture header, e.g. "
"the supply under test. Keeps the configuration with the "
"data instead of only in the filename")
p.add_argument("--scan-only", action="store_true",
help="scan the bus, report what responded, and exit")
return p.parse_args()
def print_plan(cfg, bus_speed, requested_rate):
"""Show how the configuration was derived, so it can be checked not trusted."""
lsb = rm3100.tesla_per_count(cfg.cycle_count) * rm3100.NT_PER_TESLA
dither = rm3100.expected_noise_nt(cfg.cycle_count) / lsb
period = 1.0 / cfg.predicted_hz
bus = i2c_bus_time(bus_speed)
share = bus / period
print("Configuration")
if requested_rate is not None:
print(f" requested {requested_rate:g} Hz")
print(f" cycle count {cfg.cycle_count:,}"
f"{'':<8}1 / (3 x ({cfg.cycle_count}/"
f"{rm3100.COUNTS_PER_SECOND:,.0f} + "
f"{rm3100.AXIS_OVERHEAD_S * 1e6:.1f} us))")
print(f" sensor ceiling {1 / rm3100.sample_period(cfg.cycle_count):.2f} Hz")
print(f" TMRC 0x{cfg.tmrc:02x} = "
f"{rm3100.TMRC_RATES[cfg.tmrc]:g} Hz -- {cfg.governed_by} governs")
print(f" predicted rate {cfg.predicted_hz:.2f} Hz "
f"+/-{rm3100.RATE_TOLERANCE:.0%} (oscillator tolerance; measured below)")
print(f" resolution {lsb:.2f} nT/LSB dither {dither:.2f} LSB "
"at spec noise")
print(f" duty {cfg.duty:.1%} integration / period")
print(f" bus {bus_speed} kHz {bus * 1e3:.3f} ms/sample "
f"{share:.1%} of the period")
for note in cfg.notes:
print(f" note: {note}")
# Bus speed is validated but never changed silently -- swapping it would
# hide a wiring problem behind a configuration change.
if share > 0.5:
faster = [s for s in sorted(BUS_SPEEDS) if i2c_bus_time(s) / period < 0.25]
fix = f"; {faster[0]} kHz would fit" if faster else ""
print(f" WARNING: the bus needs {share:.0%} of every period{fix}",
file=sys.stderr)
def find_sensor(bus, address):
"""Locate the RM3100, or exit with wiring guidance."""
if address is not None:
@ -87,7 +199,7 @@ def find_sensor(bus, address):
else:
print("Nothing responded anywhere on the bus. Check, in order:\n"
" - I2CEN tied high (otherwise the chip stays in SPI mode)\n"
" - the adapter's voltage jumper (the RM3100 wants ~3.3 V)\n"
" - AVDD/VDD powered, not just DVDD\n"
" - SDA/SCL not swapped\n"
" - SDA and SCL pull-up resistors present", file=sys.stderr)
sys.exit(1)
@ -103,7 +215,7 @@ def find_sensor(bus, address):
def start_sensor(sensor, cycle_count, tmrc):
"""Identify and configure the sensor, printing each step."""
"""Identify and configure the sensor, printing each step. Returns REVID."""
revid = sensor.revid()
if revid == rm3100.EXPECTED_REVID:
print(f"REVID 0x{revid:02x} -- RM3100 confirmed")
@ -117,25 +229,29 @@ def start_sensor(sensor, cycle_count, tmrc):
print(f"ERROR: cycle count read back as {readback}, expected "
f"{(cycle_count,) * 3}", file=sys.stderr)
sys.exit(1)
gain = rm3100.gain_lsb_per_ut(cycle_count)
print(f"Cycle counts set to {readback} -- gain {gain:.1f} LSB/uT")
lsb_nt = rm3100.tesla_per_count(cycle_count) * rm3100.NT_PER_TESLA
print(f"Cycle counts set to {readback} -- 1 LSB = {lsb_nt:.2f} nT")
# Raises if DRC1 did not take; exactly-once sampling depends on it.
sensor.configure()
sensor.set_rate(tmrc)
rate = rm3100.TMRC_RATES[tmrc]
print(f"Rate register 0x{tmrc:02x} -- requesting about {rate:g} Hz")
sensor.start_cmm()
print("Continuous measurement mode started")
return rate
return revid
def writer_thread(q, path, gain, start_wall, stats):
"""Drain raw samples: convert, format, write CSV, drive the console.
def write_header(handle, meta):
for key, value in meta.items():
handle.write(f"# {key}: {value}\n")
Everything here is deliberately off the sampling thread.
"""
def writer_thread(q, path, meta, stats):
"""Drain samples to CSV and drive the console, off the sampling thread."""
lsb_nt = float(meta["tesla_per_count"]) * rm3100.NT_PER_TESLA
with open(path, "w", newline="") as handle:
write_header(handle, meta)
out = csv.writer(handle)
out.writerow(CSV_FIELDS)
last_print = 0.0
@ -145,72 +261,261 @@ def writer_thread(q, path, gain, start_wall, stats):
item = q.get()
if item is _SENTINEL:
break
elapsed, cx, cy, cz = item
index, system_time, cx, cy, cz, warning = item
ux, uy, uz = cx / gain, cy / gain, cz / gain
magnitude = math.sqrt(ux * ux + uy * uy + uz * uz)
# Wall-clock is reconstructed from the monotonic offset rather than
# sampled per reading: one fewer syscall in the hot path, and immune
# to NTP steps mid-capture.
stamp = (start_wall + timedelta(seconds=elapsed)).isoformat()
out.writerow([stamp, f"{elapsed:.5f}", cx, cy, cz,
f"{ux:.4f}", f"{uy:.4f}", f"{uz:.4f}",
f"{magnitude:.4f}"])
# 6 decimal places is microsecond resolution on an epoch value,
# far finer than the ~1 ms bracket uncertainty on each timestamp.
# Placeholder rows carry zeros rather than blanks: the warning
# column already says the row has no data, so keeping x/y/z strictly
# integer makes the file trivial to parse.
out.writerow([index, f"{system_time:.6f}",
0 if cx is None else cx,
0 if cy is None else cy,
0 if cz is None else cz, warning])
stats["rows"] += 1
now = time.monotonic()
if now - last_flush >= FLUSH_INTERVAL_S:
handle.flush()
last_flush = now
if now - last_print >= CONSOLE_REFRESH_S:
if now - last_print >= CONSOLE_REFRESH_S and cx is not None:
# Placeholders carry no field values, so the display holds the
# last real reading rather than blanking or showing nonsense.
last_print = now
print(f"\r{elapsed:8.2f}s X {ux:+9.3f} Y {uy:+9.3f} "
f"Z {uz:+9.3f} |B| {magnitude:8.3f} uT "
f"({stats['rows']} samples, "
f"{stats['rows'] / max(elapsed, 1e-9):.0f} Hz)",
x, y, z = cx * lsb_nt, cy * lsb_nt, cz * lsb_nt
print(f"\r{stats['rows']:,} samples ({stats['missed']} missed)"
f" | X {x:+10.1f} | Y {y:+10.1f} | Z {z:+10.1f}"
f" | T {(x * x + y * y + z * z) ** 0.5:10.1f} nT",
end="", flush=True)
handle.flush()
def sample_loop(sensor, q, duration, rate, stats):
"""Read the sensor as fast as it produces data. I2C and nothing else."""
sample_timeout = max(2.0, 5.0 / rate)
def i2c_bus_time(bus_speed_khz):
"""Seconds of I2C traffic the bus carries per sample.
A combined register read of n payload bytes clocks n+3 bytes -- write
address, register, read address, payload -- at 9 bit-times each (8 data plus
ACK), and spends roughly a bit-time on each START and STOP. One sample costs
a STATUS poll (1 payload byte) plus a results read (9).
This is the irreducible traffic, not bus occupancy: the loop polls
continuously while waiting for DRDY, so raw occupancy approaches 100% and
says nothing useful. What matters is this against the sample period.
"""
bits = 0
for payload in (1, 9):
bits += (payload + 3) * 9 + 3 # 2 STARTs and a STOP
return bits / (bus_speed_khz * 1000.0)
def calibrate_period(sensor, dt_nominal, seconds, attempts=3):
"""Measure the true measurement period from a clean stretch of samples.
Miss detection needs the real period, not the table value: the RC oscillator
runs up to 7% off (manual section 5.2.1) and this unit is ~6% slow, which is
enough to miscount grid points inside a bracket and slip the sample index.
Least-squares slope of read time against index. Over one second at 35 Hz
(~35 points, ~0.5 ms jitter) that pins the period to roughly 0.03%, against
the 6-9% error of the nominal value -- and 0.03% is far finer than the
resolution needed to tell k grid points from k+1.
Requires a stretch with no miss, since an interval spanning a miss is a
multiple of the period, not one period. Returns None if no clean stretch is
available, which is itself the answer: the rate is not sustainable.
Also returns the mean host cost of one sample's traffic -- a DRDY poll plus
a results read -- timed here at no extra cost, since those are the very
transactions being performed.
"""
poll_ready, read_raw = sensor.poll_ready, sensor.read_raw
monotonic = time.monotonic
# Bootstrap the period from observation rather than trusting the TMRC table.
# When the cycle count governs instead of TMRC -- which it does whenever the
# cycle-count ceiling falls below the requested rate (manual section 5.2.1)
# -- the table value is not merely 7% out but wrong by a large factor. At
# cc=400 with TMRC 0x92 the table says 1.67 ms and the truth is 13.5 ms, so
# a threshold built on it rejects every interval and calibration can never
# start. A handful of raw intervals settles it: the median is robust to the
# occasional stall, and only needs to be close enough to seed the real fit.
probe = []
clear = monotonic()
deadline = clear + max(seconds, 0.25)
while monotonic() < deadline and len(probe) < 24:
ready_now, stamp, _ = poll_ready()
if not ready_now:
continue
read_raw()
probe.append(stamp)
if len(probe) >= 4:
gaps = sorted(b - a for a, b in zip(probe, probe[1:]))
dt_nominal = gaps[len(gaps) // 2]
for _ in range(attempts):
times = []
poll_cost = read_cost = 0.0
polls = 0
clear = monotonic()
deadline = clear + seconds
clean = True
while monotonic() < deadline:
before = monotonic()
ready_now, after, _ = poll_ready()
poll_cost += monotonic() - before
polls += 1
if not ready_now:
clear = after
continue
ready = after
if ready - clear >= dt_nominal: # conservative: nominal is short
clean = False
break
read_raw()
read_cost += monotonic() - ready
times.append(ready)
clear = ready
n = len(times)
if not clean or n < 8:
continue
# slope of t against i for i = 0..n-1, where Sxx = n(n^2-1)/12.
mean_i = (n - 1) / 2.0
mean_t = sum(times) / n
sxy = sum((i - mean_i) * (t - mean_t) for i, t in enumerate(times))
return (sxy / (n * (n * n - 1) / 12.0),
poll_cost / max(polls, 1) + read_cost / n)
return None, None
def sample_loop(sensor, q, duration, dt_nominal, stats):
"""Read every measurement, recording placeholders for any that are lost.
The timestamp recorded per sample is the midpoint of the bracket between the
last poll showing DRDY clear and the poll showing it set: the measurement
completed somewhere in that window, and the midpoint is the best estimate
available without a hardware DRDY line.
"""
read_raw = sensor.read_raw
data_ready = sensor.data_ready
poll_ready = sensor.poll_ready
monotonic = time.monotonic
put = q.put_nowait
start = monotonic()
timeouts = 0
deadline = start + duration if duration > 0 else float("inf")
while monotonic() < deadline:
if not data_ready():
if monotonic() - start > sample_timeout and stats["rows"] == 0:
timeouts += 1
print(f"\nWARNING: no data ready within {sample_timeout:.1f} s",
file=sys.stderr)
if timeouts >= 3:
print("Giving up after 3 consecutive timeouts.", file=sys.stderr)
return
# DRDY may already be set from whatever ran before -- BIST in particular
# zeroes the result registers and leaves it high. Discard one reading so
# sample 0 is a genuinely fresh measurement.
if poll_ready()[0]:
read_raw()
deadline = monotonic() + duration if duration > 0 else float("inf")
index = 0
# Bracket lower bound: the most recent moment DRDY was seen clear.
clear_mono, clear_wall = monotonic(), time.time()
# dt_nominal here is the *calibrated* period from calibrate_period(), not
# the table value. Keep refining it from clean intervals so the estimate
# follows the oscillator's thermal drift (~2500 ppm measured over 13 h).
dt_est = dt_nominal
previous_mono = previous_wall = None
DT_SMOOTHING = 0.02
while True:
if monotonic() >= deadline:
return
# The stamps come back with the STATUS reply itself, taken the instant
# it landed rather than several frames later up here.
ready, ready_mono, ready_wall = poll_ready()
if not ready:
clear_mono, clear_wall = ready_mono, ready_wall
continue
# Monotonic drives detection so an NTP step cannot fake a miss; the
# wall clock is recorded so a real NTP correction stays visible.
bracket = ready_mono - clear_mono
if bracket > stats["max_bracket"]:
stats["max_bracket"] = bracket
cx, cy, cz = read_raw()
# Counting lost measurements in two stages, because the two questions
# have different best answers.
#
# "Did we lose any?" is settled by the bracket, and rigorously: a
# bracket shorter than one period cannot contain two completions, so
# nothing was lost regardless of timing precision.
#
# "How many?" is better answered by the interval since the previous
# accepted sample. Each accepted read sits on a grid point, so that
# interval is very close to an exact multiple of the period, and
# rounding recovers the multiple. The bracket alone cannot -- it only
# spans back to the last poll that saw DRDY clear, which discards where
# the grid actually is.
#
# Precision: the period is calibrated to ~0.07% and each completion is
# located to about half a poll interval (~0.2 ms, under 1% of a period).
# Over a gap of k periods the total error is well under a tenth of a
# period for any small k, so the rounding is unambiguous -- which is
# the case that matters, since a long gap means the run is unusable
# anyway.
lost = 0
uncertain = False
if bracket >= dt_est and previous_mono is not None:
periods = (ready_mono - previous_mono) / dt_est
lost = max(0, round(periods) - 1)
# Uncertainty is a property of the gap, not of the placeholders:
# a gap rounding to zero losses can still be a coin toss, and then
# there is no placeholder to carry the flag.
uncertain = abs(periods - round(periods)) > AMBIGUITY_MARGIN
if uncertain:
stats["ambiguous"] += 1
elif previous_mono is not None:
# A clean interval is exactly one period, so it calibrates dt_est.
# Only clean ones qualify: an interval spanning a miss is a multiple.
dt_est += (ready_mono - previous_mono - dt_est) * DT_SMOOTHING
if lost:
stats["missed"] += lost
# Placeholders sit on the grid -- one period after the previous
# accepted sample, and so on -- because that is where the lost
# measurements actually completed. Spreading them across the
# bracket instead would bunch them at the end of the gap and skew
# the rate fit.
flags = (f"{WARN_MISSED} {WARN_AMBIGUOUS}" if uncertain
else WARN_MISSED)
for k in range(lost):
try:
put((index, previous_wall + dt_est * (k + 1),
None, None, None, flags))
except queue.Full:
stats["dropped"] += 1
index += 1
sample_wall = (clear_wall + ready_wall) / 2.0
(cx, cy, cz), _, _ = read_raw()
try:
put((monotonic() - start, cx, cy, cz))
# An uncertain gap that produced no placeholder still has to be
# flagged, so it rides on the real sample that ends it.
put((index, sample_wall, cx, cy, cz,
WARN_AMBIGUOUS if uncertain and not lost else ""))
except queue.Full:
stats["dropped"] += 1
index += 1
previous_mono, previous_wall = ready_mono, sample_wall
# The read just cleared DRDY, so this is a known-clear instant.
clear_mono, clear_wall = ready_mono, ready_wall
def main():
args = parse_args()
sys.setswitchinterval(GIL_SWITCH_INTERVAL_S)
if args.cycle_count < 1 or args.cycle_count > 0xFFFF:
if args.cycle_count is not None and not 1 <= args.cycle_count <= 0xFFFF:
sys.exit(f"--cycle-count {args.cycle_count} outside 1..65535")
if args.tmrc not in rm3100.TMRC_RATES:
if args.rate is not None and args.rate <= 0:
sys.exit(f"--rate {args.rate:g} must be positive")
if args.tmrc is not None and args.tmrc not in rm3100.TMRC_RATES:
sys.exit(f"--tmrc 0x{args.tmrc:02x} is not a valid rate register value")
cfg = rm3100.plan(rate_hz=args.rate, cycle_count=args.cycle_count,
tmrc=args.tmrc)
args.cycle_count, args.tmrc = cfg.cycle_count, cfg.tmrc
try:
bus = ch347.CH347I2C(BUS_SPEEDS[args.bus_speed])
except IOError as exc:
@ -223,26 +528,84 @@ def main():
found = bus.scan()
print("Devices found: " + ", ".join(f"0x{a:02x}" for a in found)
if found else "No devices responded on the bus.")
return
return 0
address = find_sensor(bus, args.address)
sensor = rm3100.RM3100(bus, address)
rate = start_sensor(sensor, args.cycle_count, args.tmrc)
try:
revid = start_sensor(sensor, args.cycle_count, args.tmrc)
except IOError as exc:
sys.exit(str(exc))
print()
print_plan(cfg, args.bus_speed, args.rate)
print()
nominal_rate = cfg.predicted_hz
# Establish the real period before recording. Everything downstream --
# miss detection, placeholder counting, the sample-index grid -- depends
# on it, and the table value is 6-9% out on this unit.
dt, host_cost = calibrate_period(sensor, 1.0 / nominal_rate,
args.calibrate)
if dt is None:
sensor.stop_cmm()
sys.exit(
f"Could not find {args.calibrate:g} s of loss-free samples at "
f"{nominal_rate:.1f} Hz.\n"
"Without a clean stretch the true period cannot be measured, so "
"misses cannot be\ncounted reliably and the sample index would "
"not track chip time.\n"
"Lower the rate (higher --tmrc) or raise --bus-speed.")
# Compared against the predicted rate, not TMRC's nominal: when the
# cycle count governs, the TMRC figure is not what was aimed for and the
# error against it is meaningless.
print(f"Calibrated period {dt * 1e3:.4f} ms = {1 / dt:.4f} Hz "
f"({(1 / dt) / cfg.predicted_hz - 1:+.2%} vs predicted)")
# Whether the host is the constraint is a measurement, not a given: it
# costs a near-constant ~0.9 ms per sample regardless of configuration,
# so it dominates at short periods and disappears at long ones.
share = host_cost / dt
margin = ("little margin -- expect misses under load" if share > 0.5
else "modest margin" if share > 0.25
else "ample margin")
print(f"Host cost {host_cost * 1e3:.3f} ms/sample "
f"({share:.1%} of the period, {margin})")
meta = {
"rm3100_capture": 1,
# The rate this configuration is predicted to produce, whichever of
# the two ceilings governs -- not TMRC's table value, which is not
# what was aimed for when the cycle count wins.
"nominal_rate_hz": nominal_rate,
"tmrc_nominal_hz": rm3100.TMRC_RATES[args.tmrc],
"tmrc": f"0x{args.tmrc:02x}",
"cycle_count": args.cycle_count,
# repr() so the constant round-trips through float64 exactly.
"tesla_per_count": repr(rm3100.tesla_per_count(args.cycle_count)),
"i2c_address": f"0x{address:02x}",
"bus_speed_khz": args.bus_speed,
"revid": f"0x{revid:02x}",
# What the logger actually used for miss detection. Not recoverable
# from the data, since capture.py fits the whole run rather than the
# first second.
"calibrated_period_s": repr(dt),
}
if args.note:
# Newlines would break the one-line-per-key header format.
meta["note"] = " ".join(args.note.split())
path = args.output or datetime.now().strftime("rm3100_%Y%m%d_%H%M%S.csv")
print(f"Logging to {path} -- Ctrl-C to stop\n")
q = queue.Queue(maxsize=QUEUE_MAX)
stats = {"rows": 0, "dropped": 0}
writer = threading.Thread(
target=writer_thread,
args=(q, path, rm3100.gain_lsb_per_ut(args.cycle_count),
datetime.now(timezone.utc), stats),
daemon=True)
stats = {"rows": 0, "dropped": 0, "missed": 0, "ambiguous": 0,
"max_bracket": 0.0}
writer = threading.Thread(target=writer_thread,
args=(q, path, meta, stats), daemon=True)
writer.start()
try:
sample_loop(sensor, q, args.duration, rate, stats)
sample_loop(sensor, q, args.duration, dt, stats)
except KeyboardInterrupt:
pass
finally:
@ -254,11 +617,38 @@ def main():
print("Continuous measurement mode stopped")
except IOError as exc:
print(f"WARNING: could not stop CMM: {exc}", file=sys.stderr)
if stats["dropped"]:
print(f"WARNING: dropped {stats['dropped']} samples -- the writer "
"could not keep up", file=sys.stderr)
print(f"Wrote {stats['rows']} samples to {path}")
if stats["dropped"]:
print(f"WARNING: dropped {stats['dropped']} samples -- the writer "
"could not keep up", file=sys.stderr)
print(f"Wrote {stats['rows']} samples to {path}")
# How close the run came to losing a measurement: the useful number for
# judging whether a rate is sustainable before committing to a long run.
# Quoted against the calibrated period, which is the threshold actually
# enforced -- the nominal one is 6-9% out.
print(f"Worst DRDY bracket {stats['max_bracket'] * 1e3:.2f} ms of "
f"{dt * 1e3:.2f} ms allowed "
f"({stats['max_bracket'] / dt * 100:.0f}% of margin used)")
if stats["ambiguous"]:
print(f"\nWARNING: {stats['ambiguous']:,} gap(s) could not be "
"counted confidently -- the interval fell near a half-period, "
"so\nthe number of lost measurements is a guess and "
f"sample_index may have slipped.\nThose rows carry "
f"{WARN_AMBIGUOUS} in the warning column. Do not trust this "
"capture's\ntime axis for spectral work.", file=sys.stderr)
if stats["missed"]:
pct = stats["missed"] / max(stats["rows"], 1) * 100
print(f"\nWARNING: {stats['missed']:,} measurement(s) were lost "
f"({pct:.3f}% of rows), flagged {WARN_MISSED}.\n"
"The sample index still tracks chip time -- the gaps are "
"explicit, not compressed --\nbut those rows carry no field "
"data. Lower the rate (higher --tmrc) or raise --bus-speed\n"
"to remove them.", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
main()
sys.exit(main())

94
noise_floor_test.sh Executable file
View file

@ -0,0 +1,94 @@
#!/usr/bin/env bash
#
# Run a noise-floor capture and analyse it in one step.
#
# Wraps logger.py -- every logger flag is accepted and passed straight through --
# then runs plot.py and characterize.py on whatever was captured. Works whether
# the run ends on --duration or on Ctrl-C.
#
# ./noise_floor_test.sh --duration 60
# ./noise_floor_test.sh --duration 3600 --cycle-count 400
# ./noise_floor_test.sh # until Ctrl-C
#
# Anything you pass explicitly wins; otherwise logger.py's defaults apply.
set -uo pipefail # deliberately not -e: logger.py exits 1 on a miss,
# and a partial capture is still worth analysing.
cd "$(dirname "$0")"
if [[ ! -f .venv/bin/activate ]]; then
echo "No virtualenv found. Run ./setup.sh first." >&2
exit 1
fi
# shellcheck disable=SC1091
source .venv/bin/activate
args=("$@")
has_flag() {
local needle=$1 a
for a in "${args[@]:-}"; do
[[ "$a" == "$needle" || "$a" == "$needle="* ]] && return 0
done
return 1
}
# The CSV path has to be known up front so the analysis can find it; logger.py
# would otherwise pick a timestamped name we cannot predict without racing it.
output=""
for ((i = 0; i < ${#args[@]}; i++)); do
case "${args[i]}" in
--output=*) output="${args[i]#--output=}" ;;
--output|-o) output="${args[i+1]:-}" ;;
esac
done
if [[ -z "$output" ]]; then
output="noise_$(date +%Y%m%d_%H%M%S).csv"
args+=(--output "$output")
fi
has_flag --bus-speed || args+=(--bus-speed 750)
echo "=== capture -> $output ==="
# Ctrl-C reaches the whole foreground process group. Trapping it here stops bash
# killing the script, so logger.py can shut down cleanly and the analysis below
# still runs. A no-op trap (not 'ignore') keeps the child's own handling intact.
trap ':' INT
python logger.py "${args[@]}"
capture_rc=$?
trap - INT
# A non-zero logger exit covers both "aborted mid-capture" and "never started"
# (no adapter, bad flag). Whether a usable file exists is what distinguishes
# them, so check that before claiming anything about the data.
if [[ ! -s "$output" ]]; then
echo
echo "Capture produced no data (exit $capture_rc); nothing to analyse." >&2
exit 1
fi
if [[ "$capture_rc" -ne 0 ]]; then
echo
echo "Capture ended early (exit $capture_rc). Analysing what was recorded --" \
"it is valid up to that point." >&2
fi
echo
echo "=== plot ==="
python plot.py "$output"
plot_rc=$?
echo
echo "=== characterize ==="
python characterize.py "$output"
char_rc=$?
echo
echo "=== outputs ==="
base="${output%.csv}"
for f in "$output" "$base.png" "${base}_noise.png"; do
[[ -f "$f" ]] && printf ' %-40s %8s\n' "$f" "$(du -h "$f" | cut -f1)"
done
# Non-zero if any stage failed, so this is usable from a scheduler.
[[ $capture_rc -eq 0 && $plot_rc -eq 0 && $char_rc -eq 0 ]] || exit 1

62
plot.py
View file

@ -11,7 +11,6 @@ independently, and note the per-panel mean/sd annotation for context.
"""
import argparse
import csv
import sys
import matplotlib
@ -19,6 +18,8 @@ matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import capture
# Light-mode design tokens.
SURFACE = "#fcfcfb"
TEXT_PRIMARY = "#0b0b0b"
@ -31,33 +32,13 @@ GRID = "#e3e2df"
# also keeps the categorical set at the three slots that validate for small
# multiples.
SERIES = [
("x_uT", "X axis", "#2a78d6"),
("y_uT", "Y axis", "#eb6834"),
("z_uT", "Z axis", "#1baf7a"),
(None, "Norm |B| = sqrt(X^2 + Y^2 + Z^2)", TEXT_PRIMARY),
("x", "X axis", "#2a78d6"),
("y", "Y axis", "#eb6834"),
("z", "Z axis", "#1baf7a"),
("total", "Norm |B| = sqrt(X^2 + Y^2 + Z^2)", TEXT_PRIMARY),
]
def load(path):
t, x, y, z, gains = [], [], [], [], []
with open(path, newline="") as fh:
for row in csv.DictReader(fh):
t.append(float(row["elapsed_s"]))
x.append(float(row["x_uT"]))
y.append(float(row["y_uT"]))
z.append(float(row["z_uT"]))
# Recover the gain from the raw/uT ratio so the caption reports the
# settings actually used rather than an assumption.
for axis in "xyz":
ut = float(row[f"{axis}_uT"])
if abs(ut) > 1.0:
gains.append(float(row[f"{axis}_raw"]) / ut)
if not t:
sys.exit(f"{path} contains no samples")
gain = float(np.median(gains)) if gains else float("nan")
return np.array(t), np.array(x), np.array(y), np.array(z), gain
def rolling_mean(v, window):
"""Centred moving average that stays smooth all the way to both ends.
@ -83,14 +64,16 @@ def main():
"(default: %(default)s)")
args = ap.parse_args()
t, x, y, z, gain = load(args.csv)
# Inverse of rm3100.gain_lsb_per_ut().
cycle_count = (gain - 1.5) / 0.3671
norm = np.sqrt(x**2 + y**2 + z**2)
series = {"x_uT": x, "y_uT": y, "z_uT": z, None: norm}
try:
cap = capture.load(args.csv)
except (OSError, capture.CaptureError) as exc:
sys.exit(str(exc))
duration = t[-1] - t[0]
rate = len(t) / duration if duration > 0 else float("nan")
# Drift-corrected time: the nominal grid would be ~6% off real seconds.
t = cap.elapsed
series = dict(cap.axes())
duration = cap.duration
rate = cap.true_rate_hz
window = max(1, int(round(args.smooth * rate))) if args.smooth > 0 else 0
fig, axes = plt.subplots(4, 1, figsize=(12, 9.5), sharex=True, dpi=150)
@ -108,13 +91,13 @@ def main():
ax.plot(t, rolling_mean(v, window), color=color, linewidth=1.6,
solid_capstyle="round")
ax.set_ylabel("µT", color=TEXT_SECONDARY, fontsize=10)
ax.set_ylabel("nT", color=TEXT_SECONDARY, fontsize=10)
# Direct label instead of a legend: one series per panel, so the title
# names it. This is also the relief the palette's contrast WARN requires.
ax.set_title(label, color=TEXT_PRIMARY, fontsize=12, loc="left",
pad=8, fontweight="bold")
ax.annotate(f"mean {v.mean():.3f} sd {v.std() * 1000:.0f} nT "
f"span {v.max() - v.min():.3f} µT",
ax.annotate(f"mean {v.mean():,.0f} nT sd {v.std():.0f} nT "
f"span {v.max() - v.min():,.0f} nT",
xy=(1.0, 1.0), xycoords="axes fraction",
xytext=(0, 8), textcoords="offset points",
ha="right", va="bottom",
@ -137,8 +120,9 @@ def main():
fig.suptitle("RM3100 magnetometer capture", color=TEXT_PRIMARY,
fontsize=15, fontweight="bold", x=0.5, y=0.985)
fig.text(0.5, 0.955,
f"{len(t):,} samples over {duration:.1f} s ({rate:.0f} Hz), "
f"cycle count {cycle_count:.0f} ({gain:.1f} LSB/µT)"
f"{len(t):,} samples over {duration:.1f} s at {rate:.2f} Hz "
f"(nominal {cap.nominal_rate_hz:g} Hz, {cap.rate_error * 100:+.1f}%), "
f"cycle count {cap.cycle_count} (1 LSB = {cap.lsb_nt:.1f} nT)"
f"{smooth_note}. Panels have independent y-scales.",
color=TEXT_SECONDARY, fontsize=10, ha="center")
@ -148,8 +132,8 @@ def main():
print(f"{len(t):,} samples, {duration:.2f} s, {rate:.1f} Hz -> {out}")
for key, label, _ in SERIES:
v = series[key]
print(f" {label.split()[0]:5s} mean {v.mean():+9.3f} uT "
f"sd {v.std()*1000:6.1f} nT span {v.max()-v.min():6.3f} uT")
print(f" {label.split()[0]:5s} mean {v.mean():+12,.1f} nT "
f"sd {v.std():8.1f} nT span {v.max()-v.min():10,.1f} nT")
if __name__ == "__main__":

235
rm3100.py
View file

@ -8,6 +8,7 @@ and read(addr, count).
"""
import time
from collections import namedtuple
# Register addresses (manual Table 5-1).
REG_POLL = 0x00 # single measurement trigger
@ -45,16 +46,183 @@ STATUS_DRDY = 0x80
EXPECTED_REVID = 0x22
DEFAULT_CYCLE_COUNT = 200
# 50 buys bandwidth almost for free. Against cycle count 200 it costs 4.4% in
# post-filter noise (89% duty against 97%) and nothing else, while giving 3.7x
# the spectrum -- 267 Hz of Nyquist against 73 Hz. That matters because aliased
# interference cannot be filtered out afterwards at any cycle count, so seeing
# it is worth more than a few percent of noise.
#
# The one thing to verify rather than assume is dither: 50 sits at 0.58 LSB of
# intrinsic noise, which simulation puts safely in the region where averaging
# still recovers sub-LSB resolution (it fails below ~0.2). characterize.py
# prints sd/LSB, which answers it from the first capture. Fall back to 100 or
# 200 if that comes back low.
DEFAULT_CYCLE_COUNT = 50
# The fastest rate register. Used by default so the cycle count, not TMRC, sets
# the rate -- TMRC offers only factor-of-two steps and has no effect at all once
# the cycle count governs.
TMRC_FASTEST = 0x92
def gain_lsb_per_ut(cycle_count):
"""Sensitivity in LSB per microtesla for a given cycle count.
# Table 3-1 quotes gain in LSB per microtesla; these are the coefficients of a
# linear fit to its 20, 38 and 75 LSB/uT at cycle counts 50, 100 and 200, which
# reproduces all three to within a count. Working units in this codebase are
# tesla, so the fit is scaled by 1e6 on the way out.
_GAIN_SLOPE_LSB_PER_UT = 0.3671
_GAIN_OFFSET_LSB_PER_UT = 1.5
UT_PER_TESLA = 1e6
Linear fit to manual Table 3-1, which quotes 20, 38 and 75 LSB/uT at cycle
counts of 50, 100 and 200; this reproduces all three to within a count.
# Displays and plots use nanotesla; no computation is done in it.
NT_PER_TESLA = 1e9
def gain_lsb_per_tesla(cycle_count):
"""Sensitivity in LSB per tesla for a given cycle count."""
return (_GAIN_SLOPE_LSB_PER_UT * cycle_count
+ _GAIN_OFFSET_LSB_PER_UT) * UT_PER_TESLA
def tesla_per_count(cycle_count):
"""Calibration constant: multiply a raw count by this to get tesla.
Stored in capture headers, so a reader needs no knowledge of the gain fit --
and it is a multiply at the point of use rather than a divide.
"""
return 0.3671 * cycle_count + 1.5
return 1.0 / gain_lsb_per_tesla(cycle_count)
# Timing model for a three-axis measurement:
#
# per-axis time = cycle_count / COUNTS_PER_SECOND + AXIS_OVERHEAD_S
#
# COUNTS_PER_SECOND comes from the specification, not from a fit: Table 3-1
# gives a 180 kHz circuit oscillation frequency, and section 4.1 measures each
# cycle count in *both* bias directions, so one count costs two oscillations.
# Rates measured here across cycle counts 229..29769 agree with that figure to
# within 0.8%, which confirms the spec value rather than improving on it -- the
# residual is this particular unit's oscillator sitting inside ordinary
# component tolerance, and another part would sit somewhere else.
#
# The overhead has no specified value and must be measured; 68.7 us reproduces
# the observed rates to better than 1% when the divisor is held at spec. It is
# why the naive "rate x cycle_count" constant is not constant, drifting from
# 84,429 at cycle count 100 to 89,191 at 1200.
COUNTS_PER_SECOND = 90000.0 # 180 kHz (Table 3-1) / 2 bias directions
AXIS_OVERHEAD_S = 68.7e-6
AXES = 3
# Rates predicted from the model are good to roughly this much on a given unit,
# and no better across units, since the manual quotes no tolerance on the
# oscillator. Anything needing the real number measures it: logger.py calibrates
# the period against the host clock before recording.
RATE_TOLERANCE = 0.02
# Table 3-1 quotes 30/20/15 nT at cycle counts 50/100/200, which fits
# K/sqrt(cycle_count). Extrapolation past ~400 is unverified: the manual calls
# that its useful upper limit for noise and gives no data beyond it.
_NOISE_K_NT = 208.0
def sample_period(cycle_count, axes=AXES):
"""Seconds between measurements when the cycle count governs the rate."""
return axes * (cycle_count / COUNTS_PER_SECOND + AXIS_OVERHEAD_S)
def integration_time(cycle_count, axes=AXES):
"""Seconds per sample actually spent integrating, excluding fixed overhead.
Only this part reduces noise. Against the sample period it gives the duty
cycle: how much of the wall clock the sensor is doing useful work rather
than idling between measurements or paying per-axis overhead.
"""
return axes * cycle_count / COUNTS_PER_SECOND
def cycle_count_for_rate(rate_hz, axes=AXES):
"""Cycle count that makes the sensor free-run at about rate_hz.
Inverse of sample_period(). Use with a TMRC faster than the target so the
cycle count governs -- then this is a continuous rate knob, where TMRC only
offers factor-of-two steps, and the duty cycle is ~100% by construction.
The achieved rate will sit within about RATE_TOLERANCE of the target, set by
oscillator tolerance rather than by this calculation. Measure it if it
matters.
"""
cc = round((1.0 / (rate_hz * axes) - AXIS_OVERHEAD_S) * COUNTS_PER_SECOND)
return max(1, min(0xFFFF, cc))
# Section 5.1: "quantization issues generally dictate working above a cycle
# count value of ~30". Below that the LSB grows faster than the sensor's own
# noise, so the quantiser stops being dithered and averaging stalls.
MIN_CYCLE_COUNT = 30
MAX_CYCLE_COUNT = 0xFFFF
MIN_RATE_BY_CYCLE_COUNT = 1.0 / (AXES * (MAX_CYCLE_COUNT / COUNTS_PER_SECOND
+ AXIS_OVERHEAD_S))
Plan = namedtuple("Plan", "cycle_count tmrc predicted_hz governed_by duty notes")
def plan(rate_hz=None, cycle_count=None, tmrc=None):
"""Resolve a full configuration from whichever knob the caller specified.
Two ceilings compete and **the slower one wins** (manual section 5.2.1): the
cycle count sets how long a measurement takes, TMRC sets how often one is
started. Leaving TMRC faster than the cycle-count ceiling means the sensor
free-runs at ~100% duty; setting it slower makes the sensor idle, which
costs sensitivity for nothing unless low power is the goal.
So the default in every branch is TMRC_FASTEST, and the cycle count is the
rate knob -- it is continuous where TMRC offers only factor-of-two steps.
"""
notes = []
if cycle_count is None:
if rate_hz is None:
cycle_count = DEFAULT_CYCLE_COUNT
elif rate_hz < MIN_RATE_BY_CYCLE_COUNT:
# The register is 16 bits, so below ~0.46 Hz the cycle count runs
# out of range and TMRC is the only way to go slower. Max the cycle
# count anyway: it costs nothing and buys resolution.
cycle_count = MAX_CYCLE_COUNT
notes.append(
f"{rate_hz:g} Hz is below the {MIN_RATE_BY_CYCLE_COUNT:.3f} Hz "
f"floor of a {MAX_CYCLE_COUNT:,}-count measurement, so TMRC "
"must set the cadence and the sensor will idle")
if tmrc is None:
tmrc = min(TMRC_RATES, key=lambda t: abs(TMRC_RATES[t] - rate_hz))
else:
cycle_count = cycle_count_for_rate(rate_hz)
if cycle_count < MIN_CYCLE_COUNT:
notes.append(f"cycle count raised to the {MIN_CYCLE_COUNT} the manual "
"advises as a quantisation floor (section 5.1)")
cycle_count = max(MIN_CYCLE_COUNT, min(MAX_CYCLE_COUNT, cycle_count))
ceiling = 1.0 / sample_period(cycle_count)
if tmrc is None:
tmrc = TMRC_FASTEST
requested = TMRC_RATES[tmrc]
if requested <= ceiling:
predicted, governed_by = requested, "TMRC"
idle = 1.0 - requested / ceiling
if idle > 0.05:
notes.append(f"TMRC leaves the sensor idle {idle:.0%} of each "
"period, which costs sensitivity")
else:
predicted, governed_by = ceiling, "cycle count"
if rate_hz is not None and predicted < rate_hz * 0.98:
notes.append(f"{rate_hz:g} Hz is faster than this configuration can "
f"reach; {predicted:.1f} Hz is the ceiling")
return Plan(cycle_count, tmrc, predicted, governed_by,
integration_time(cycle_count) * predicted, notes)
def expected_noise_nt(cycle_count):
"""Per-sample noise from the Table 3-1 fit, in nanotesla."""
return _NOISE_K_NT / cycle_count ** 0.5
def decode_measurements(data):
@ -84,10 +252,17 @@ class RM3100:
def read_reg(self, reg, count=1):
"""Read count bytes starting at reg, using the sensor's auto-increment.
The pointer write is a separate transaction terminated by STOP rather
than a repeated START, which is exactly what the manual's I2C read
diagrams (sections 4.5.2 and 5.8.4) specify.
Returns (data, mono, wall): the bus stamps the clocks when the reply
lands, and the stamp travels with its own data.
Prefers a single combined transaction (repeated START) where the bus
offers one, since the host round trip is what limits the sample rate.
Falls back to the manual's STOP-then-START form (sections 4.5.2, 5.8.4)
for a bus that cannot do it.
"""
combined = getattr(self.bus, "write_read", None)
if combined is not None:
return combined(self.address, [reg], count)
self.bus.write(self.address, [reg])
return self.bus.read(self.address, count)
@ -95,7 +270,7 @@ class RM3100:
self.bus.write(self.address, bytes([reg]) + bytes(data))
def revid(self):
return self.read_reg(REG_REVID)[0]
return self.read_reg(REG_REVID)[0][0]
def set_cycle_counts(self, count):
"""Set all three axes to the same cycle count."""
@ -106,7 +281,7 @@ class RM3100:
def get_cycle_counts(self):
"""Read back (ccx, ccy, ccz)."""
data = self.read_reg(REG_CCX, 6)
data, _, _ = self.read_reg(REG_CCX, 6)
return tuple(
int.from_bytes(data[i:i + 2], "big") for i in (0, 2, 4)
)
@ -120,8 +295,21 @@ class RM3100:
self.write_reg(REG_TMRC, [tmrc])
def configure(self):
"""Put DRDY into a state where polling STATUS actually works."""
"""Put DRDY into a state where polling STATUS actually works.
Verified rather than assumed: exactly-once sampling relies on DRC1=1
clearing DRDY when the measurement registers are read, which is what
makes a second read of the same measurement impossible.
"""
self.write_reg(REG_HSHAKE, [HSHAKE_DRDY_ON_READ_ONLY])
# Bits 4-6 are read-only NACK status, so compare only the writable ones.
readback = self.read_reg(REG_HSHAKE)[0][0] & 0x0F
if readback != HSHAKE_DRDY_ON_READ_ONLY & 0x0F:
raise IOError(
f"HSHAKE did not take: wrote 0x{HSHAKE_DRDY_ON_READ_ONLY:02x}, "
f"read back 0x{readback:02x}. Exactly-once sampling cannot be "
"guaranteed without DRC1=1."
)
def start_cmm(self):
self.write_reg(REG_CMM, [CMM_ALL_AXES])
@ -130,7 +318,17 @@ class RM3100:
self.write_reg(REG_CMM, [CMM_OFF])
def data_ready(self):
return bool(self.read_reg(REG_STATUS)[0] & STATUS_DRDY)
"""True if a measurement is waiting."""
return self.poll_ready()[0]
def poll_ready(self):
"""Like data_ready(), but returns (ready, mono, wall).
The stamp is when the STATUS reply landed, which is the tightest bound
available on when DRDY actually went high.
"""
data, mono, wall = self.read_reg(REG_STATUS)
return bool(data[0] & STATUS_DRDY), mono, wall
def wait_for_data(self, timeout=2.0, interval=0.001):
"""Block until DRDY is set. Returns False if timeout elapses first."""
@ -143,15 +341,16 @@ class RM3100:
time.sleep(interval)
def read_raw(self):
"""Return (x, y, z) as signed counts -- the fast path.
"""Return ((x, y, z) counts, mono, wall) -- the fast path.
Callers logging at high rates should use this and defer the microtesla
conversion, so the sampling loop does I2C and nothing else.
"""
return decode_measurements(self.read_reg(REG_MX, 9))
data, mono, wall = self.read_reg(REG_MX, 9)
return decode_measurements(data), mono, wall
def read_measurements(self):
"""Return ((x, y, z) counts, (x, y, z) microtesla)."""
counts = self.read_raw()
gain = gain_lsb_per_ut(self.cycle_count)
"""Return ((x, y, z) counts, (x, y, z) tesla)."""
counts, _, _ = self.read_raw()
gain = gain_lsb_per_tesla(self.cycle_count)
return counts, tuple(c / gain for c in counts)

144
sweep.py Normal file
View file

@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Sweep sample rates and report what each configuration actually delivers.
./.venv/bin/python sweep.py # 1..256 Hz, powers of two
./.venv/bin/python sweep.py --from 8 --to 128
./.venv/bin/python sweep.py --rates 10,25,50
Cycle count is the rate knob, not TMRC. TMRC only offers factor-of-two steps
and, once the cycle count governs, has no effect at all -- measured 73.85 Hz at
TMRC 0x92 against 73.86 Hz at 0x94 for the same cycle count. So each point runs
with TMRC set fast and the cycle count chosen from rm3100.cycle_count_for_rate(),
which makes the rate continuous and the duty cycle ~100% by construction.
Each row is measured, not predicted: the logger calibrates the true period
against the host clock before recording, and that figure is what is reported.
"""
import argparse
import re
import subprocess
import sys
from pathlib import Path
import logger
import rm3100
HERE = Path(__file__).resolve().parent
CALIBRATED = re.compile(r"Calibrated period [\d.]+ ms = ([\d.]+) Hz")
MISSED = re.compile(r"WARNING: ([\d,]+) measurement\(s\) were lost")
AMBIGUOUS = re.compile(r"WARNING: ([\d,]+) gap\(s\) could not be counted")
CALIBRATE_SECONDS = 5.0
RECORD_SECONDS = 5.0
# One width per column, shared by the header rule and every cell, so the table
# cannot drift out of alignment as the cell formats change.
COLUMNS = [("target", 9), ("measured", 11), ("err", 7), ("cycle count", 11),
("nT/LSB", 10), ("spec noise", 10), ("duty", 6), ("bus use", 8)]
def row(cells):
return " ".join(f"{c:>{width}}" for c, (_, width) in zip(cells, COLUMNS))
def measure(rate, bus_speed, tmrc, output, record_seconds):
"""Run one configuration. Returns (cycle_count, measured_hz or None, note)."""
cc = rm3100.cycle_count_for_rate(rate)
proc = subprocess.run(
[sys.executable, "logger.py",
"--cycle-count", str(cc), "--tmrc", hex(tmrc),
"--bus-speed", str(bus_speed),
"--calibrate", f"{CALIBRATE_SECONDS:g}",
"--duration", f"{record_seconds:g}",
"--output", str(output)],
capture_output=True, text=True, cwd=HERE)
out = proc.stdout + proc.stderr
found = CALIBRATED.search(out)
if found:
# Both warnings matter and they are independent. Reporting only losses
# would hide the worse case: an ambiguous gap means the sample index
# itself may have slipped, so the timeline is suspect even where no
# measurement was lost.
notes = []
for pattern, label in ((MISSED, "lost"), (AMBIGUOUS, "ambiguous")):
hit = pattern.search(out)
if hit:
notes.append(f"{hit.group(1)} {label}")
return cc, float(found.group(1)), ", ".join(notes)
if "loss-free" in out:
return cc, None, "host cannot sustain"
return cc, None, "failed"
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--from", dest="low", type=float, default=2.0,
help="lowest target rate in Hz (default: %(default)s)")
ap.add_argument("--to", dest="high", type=float, default=512.0,
help="highest target rate in Hz (default: %(default)s)")
ap.add_argument("--rates", help="explicit comma-separated rates, overriding "
"the powers-of-two range")
ap.add_argument("--bus-speed", type=int, choices=[20, 100, 400, 750],
default=750)
ap.add_argument("--tmrc", type=lambda s: int(s, 0), default=0x92,
help="held fast so the cycle count governs (default: 0x92)")
ap.add_argument("--duration", type=float, default=RECORD_SECONDS,
help="seconds to record at each point, after calibration "
"(default: %(default)s)")
ap.add_argument("--output", default="/tmp/sweep_point.csv",
help="scratch capture path, overwritten each point")
args = ap.parse_args()
if args.rates:
rates = [float(r) for r in args.rates.split(",")]
else:
rates, r = [], args.low
while r <= args.high * 1.001:
rates.append(r)
r *= 2
bus_time = logger.i2c_bus_time(args.bus_speed)
print(f"TMRC {hex(args.tmrc)} (held fast), I2C {args.bus_speed} kHz, "
f"{bus_time * 1e3:.3f} ms of bus traffic per sample, "
f"{args.duration:g} s per config\n")
print(row(name for name, _ in COLUMNS))
print(row("-" * width for _, width in COLUMNS))
for rate in rates:
cc, hz, note = measure(rate, args.bus_speed, args.tmrc, args.output,
args.duration)
# One count is one LSB, so nT/LSB is the quantisation step directly.
nt_per_lsb = rm3100.tesla_per_count(cc) * rm3100.NT_PER_TESLA
noise = rm3100.expected_noise_nt(cc)
# Fraction of the period spent integrating: what actually reduces noise.
duty = rm3100.integration_time(cc) * hz if hz else None
# A note always trails the row rather than sitting in a cell: "host
# cannot sustain" is wider than any sensible column and would shove the
# rest of the line out of alignment.
print(row([
f"{rate:g} Hz",
f"{hz:.2f} Hz" if hz else "-",
f"{hz / rate - 1:+.1%}" if hz else "-",
f"{cc:,}",
f"{nt_per_lsb:.3f} nT",
f"{noise:.2f} nT",
f"{duty:.1%}" if duty else "-",
f"{bus_time * hz:.1%}" if hz else "-",
]) + (f" {note}" if note else ""))
print("\nspec noise is Table 3-1's figure for the cycle count, not a\n"
"measurement: 208/sqrt(cc) nT, fitted to its 30/20/15 nT at 50/100/200.\n"
"Past cycle count ~400 the manual gives no data, so those are\n"
"extrapolation. Measure the real floor with characterize.py.\n"
"duty is integration time against the period -- what reduces noise.\n"
"It falls at high rates as the fixed per-axis overhead grows relative\n"
"to the integration, and would fall further if TMRC rather than the\n"
"cycle count governed, leaving the sensor idle between measurements.\n"
"bus use is irreducible I2C traffic against the period -- not occupancy,\n"
"which approaches 100% because the loop polls continuously for DRDY.")
if __name__ == "__main__":
main()