1033 lines
50 KiB
Markdown
1033 lines
50 KiB
Markdown
# RM3100 logging over a CH347 USB-I2C adapter
|
||
|
||
Logs a PNI RM3100 geomagnetic sensor connected to the I2C pins of a Waveshare
|
||
USB to UART/I2C/SPI/JTAG adapter (CH347, USB `1a86:55db`).
|
||
|
||
Reference: *RM3100 & RM2100 Sensor Suite User Manual*, Doc 1017252 R07
|
||
(`PNI Sensor - RM3100-Sensor-Suite-User-Manual-R07-1.pdf` in this directory).
|
||
Section numbers below refer to it.
|
||
|
||
## Hardware
|
||
|
||
Waveshare adapter in **Mode 1** (UART1 + I2C + SPI), voltage selector at **3V3**.
|
||
The RM3100 is on a breakout board.
|
||
|
||
> 3V3 is required, though 3.0v would be ideal for the analog side.
|
||
|
||
### Wiring
|
||
|
||
All connections below are **confirmed working** — this exact wiring produces
|
||
valid data.
|
||
|
||
| Adapter | RM3100 | Notes |
|
||
|---|---|---|
|
||
| I2C VCC (3V3) | DVDD | digital supply |
|
||
| I2C VCC (3V3) | VDD (= AVDD) | analog supply — **required for measurements** |
|
||
| I2C VCC (3V3) | I2CEN | **required to select I2C over SPI** |
|
||
| I2C GND | DVSS | digital ground |
|
||
| I2C SCL | SCK/SCL | pin 27, shared with SPI SCLK |
|
||
| I2C SDA | SI/SDA | pin 1, shared with SPI MOSI |
|
||
|
||
All three 3V3 connections share the adapter's single I2C VCC pin. Current draw is
|
||
negligible — ~260 µA per axis at 24 Hz (Table 3-1).
|
||
|
||
The two supply pins and I2CEN are the non-obvious ones; both are explained below,
|
||
because omitting either produces a failure that is easy to misdiagnose.
|
||
|
||
#### I2CEN (pin 22) must be tied HIGH
|
||
|
||
`HIGH = I2C, LOW = SPI` (§4.3.1). The RM3100's SDA/SCL pins are shared with the
|
||
SPI MOSI/SCLK pins, and I2CEN is what selects between the two interfaces. Left
|
||
floating or low, the chip stays in SPI mode and **never ACKs its I2C address** —
|
||
the bus scan finds nothing at all, even though the adapter is perfectly healthy.
|
||
|
||
At DVDD = 3.3 V it needs at least `VIH = 0.7 × DVDD` = 2.31 V, so tie it
|
||
directly to 3V3.
|
||
|
||
#### AVDD/AVSS (pins 4, 5) must be powered to take measurements
|
||
|
||
§4.3.1: *"AVDD can be turned off when not making a measurement to conserve
|
||
power, since all other operations are supported with DVDD."*
|
||
|
||
So with only DVDD connected, the part is **half alive** in a way that is easy to
|
||
misread as working: the I2C address responds, REVID reads back `0x22`, and
|
||
registers write and read back correctly — but the analog section that drives the
|
||
sensor coils is dead, so DRDY never asserts and the measurement registers stay
|
||
at zero. `BIST` reports `XOK=YOK=ZOK=0` (see Diagnostics below), which is the
|
||
unambiguous tell.
|
||
|
||
**Both rails are needed — AVDD is not a substitute for DVDD.** Two constraints
|
||
(§4.3.1, Table 3-5):
|
||
- DVDD must come up **before or at the same time as** AVDD, never after. Moving
|
||
the supply from DVDD to AVDD instead of adding it violates this.
|
||
- AVDD must stay within **0.1 V** of DVDD while on — sharing one 3V3 rail
|
||
satisfies this for free.
|
||
|
||
#### Power quality
|
||
|
||
The manual specifies very little here, but what it does specify is firm.
|
||
|
||
| Parameter | Limit | Source |
|
||
|---|---|---|
|
||
| Ripple on AVDD or DVDD | **50 mV peak-to-peak** | Table 3-5, `VDD_ripple` |
|
||
| DVDD − AVDD while running | **±0.1 V** | Table 3-5, `ΔVDD_OP` |
|
||
| Supply range | 2.0–3.6 V (typ 3.0) | Table 3-5 |
|
||
| Absolute maximum | 3.7 V | Table 3-4 |
|
||
|
||
The ripple limit carries **no frequency qualifier** — it is stated flat, so
|
||
switching noise and mains hum are not distinguished.
|
||
|
||
Reference decoupling (Figures 4-1 and 4-2) is a **10 µF bulk capacitor in
|
||
parallel with 0.1 µF ceramic**, rail to ground. One rail feeds AVDD, both DVDD
|
||
pins, and I2CEN. That is the entire filtering specification — no ferrite, no
|
||
split analog/digital rails, no LDO requirement. §4.2.3 adds a placement rule:
|
||
*"Keep capacitors, especially tantalum capacitors, far away from the sensor
|
||
coils"* — a magnetic concern (ferromagnetic packaging), not an electrical one.
|
||
|
||
**The manual gives no transfer function from ripple to field error** — no
|
||
µT-per-mV, no PSRR, no ripple-vs-noise curve. Only the 50 mV limit.
|
||
|
||
Slow drift should matter less than fast ripple. §4.1 describes the output as
|
||
*"the difference in the time to complete the measurement for each bias"* — a
|
||
differential measurement across forward and reverse coil bias — and §2 claims
|
||
measurements are *"stable over temperature and inherently free from offset
|
||
drift."* Anything common to both half-measurements largely cancels, so supply
|
||
variation slower than one measurement cycle is rejected far better than noise
|
||
near the ~180 kHz LR oscillation. (That last step is inference from the
|
||
described architecture, not an explicit claim in the manual.)
|
||
|
||
**This rig runs at 3.3 V, but every number in Table 3-1 is quoted at 3.0 V.**
|
||
Footnote 1: *"Other bias resistors, external timing resistors and operating
|
||
voltages may be used, but performance will differ from the values listed."*
|
||
3.3 V is comfortably in range, but the 75 LSB/µT gain this driver uses is
|
||
strictly a 3.0 V figure — a candidate scale-factor error the manual does not
|
||
quantify. Unverified here: whether the breakout carries local decoupling, and
|
||
what the CH347's 3V3 rail ripple actually measures against the 50 mV limit.
|
||
|
||
#### SA0 / SA1 set the I2C address
|
||
|
||
The top 5 address bits are fixed at `0b01000`; SA0 (pin 3) and SA1 (pin 28) set
|
||
the low two, giving 0x20–0x23 (§4.5). On this breakout both are strapped high,
|
||
so the sensor answers at **0x23**. These pins are shared with SPI SSN and MISO,
|
||
so a breakout may label them `SSN/SA0` and `MISO/SA1`.
|
||
|
||
`logger.py` scans all four addresses, so no configuration is needed.
|
||
|
||
#### DRDY (pin 23) is not connected
|
||
|
||
Not required. The driver polls the STATUS register instead (§5.4.1), which the
|
||
manual explicitly offers as an alternative.
|
||
|
||
## Setup
|
||
|
||
```bash
|
||
./setup.sh
|
||
```
|
||
|
||
Idempotent. It installs a udev rule, creates `.venv`, and installs pyusb. It
|
||
needs `sudo` for the udev rule only.
|
||
|
||
The rule is needed because the CH347's USB node defaults to `root:root 0664`.
|
||
It grants access two ways, because no single mechanism covers every distro:
|
||
|
||
```
|
||
SUBSYSTEM=="usb", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="55db", TAG+="uaccess", GROUP="plugdev", MODE="0660"
|
||
```
|
||
|
||
- **`TAG+="uaccess"`** makes systemd-logind put an ACL on the node for whoever
|
||
is logged in at the local seat. No group, no logout, and it is the only part
|
||
that works unaided on atomic Fedora. `setup.sh` triggers with
|
||
`--action=add`, not the default `change`, because the `uaccess` builtin only
|
||
runs on add — a change event installs the rule but grants nothing until the
|
||
next replug.
|
||
- **`GROUP=`/`MODE=`** is the fallback for ssh sessions and seatless systems,
|
||
where there is no local seat for `uaccess` to grant to. The group is chosen
|
||
from those that exist — `plugdev` is a Debian convention and absent on
|
||
Fedora, and naming a group that does not exist makes udev log an error and
|
||
leave the node owned by root. If neither `plugdev` nor `dialout` exists the
|
||
clause is omitted entirely.
|
||
|
||
#### Atomic Fedora (Bazzite, Silverblue, Kinoite)
|
||
|
||
`usermod -aG dialout $USER` fails there with *"group 'dialout' does not
|
||
exist"* even though `getent group dialout` finds it. Fedora ships
|
||
`nss_altfiles`, so `getent` reads both `/etc/group` and `/usr/lib/group`, while
|
||
`usermod` writes `/etc/group` alone — and on an rpm-ostree system the group
|
||
frequently lives only in the latter. Copy the line across first:
|
||
|
||
```bash
|
||
grep -E '^dialout:' /usr/lib/group | sudo tee -a /etc/group
|
||
sudo usermod -aG dialout $USER # then log out and back in
|
||
```
|
||
|
||
None of that is needed for a local desktop session: `uaccess` covers it, which
|
||
is why the rule carries both. `setup.sh` tests the node itself at the end and
|
||
prints whichever fix applies rather than guessing up front.
|
||
|
||
Python 3.14 here is PEP 668 externally-managed, hence the venv rather than a
|
||
system-wide `pip install`.
|
||
|
||
## Usage
|
||
|
||
```bash
|
||
./.venv/bin/python logger.py --duration 10 # log for 10 s
|
||
./.venv/bin/python logger.py # log until Ctrl-C
|
||
./.venv/bin/python logger.py --scan-only # bus scan only, for wiring checks
|
||
```
|
||
|
||
| Flag | Default | Meaning |
|
||
|---|---|---|
|
||
| `--rate` | — | target Hz; derives the cycle count and TMRC. Mutually exclusive with `--cycle-count` |
|
||
| `--cycle-count` | 100 | 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 42% of the period on the bus at cc=100 |
|
||
| `--scan-only` | — | scan and exit |
|
||
|
||
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 6–9% 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`:
|
||
|
||
| Cycle count | Measured (polled) | Table 3-1 predicts | Noise (Table 3-1) |
|
||
|---|---|---|---|
|
||
| 200 | 152 Hz | 147 Hz | 15 nT |
|
||
| 100 | 297 Hz | 283 Hz | 20 nT |
|
||
| 50 | 538 Hz | 533 Hz | 30 nT |
|
||
|
||
**The manual contradicts itself here and Table 3-1 is the correct one.** The
|
||
§5.2.1 note claims cycle count 200 allows a "~430 Hz" 3-axis update rate;
|
||
Table 3-1 gives 440 Hz *single-axis* and says to divide by 3, i.e. ~147 Hz.
|
||
Measurement backs Table 3-1 at all three cycle counts.
|
||
|
||
Bus speed matters less than expected: 400 kHz measured best (538 Hz), 100 kHz
|
||
gave 444 Hz, and 750 kHz was slightly *worse* than 400 kHz at 515 Hz. Use 400.
|
||
|
||
Skipping the DRDY poll and free-running reads reaches 1100–1460 Hz, but 62–87%
|
||
of those samples are duplicates, so it buys nothing real.
|
||
|
||
### Threading
|
||
|
||
The sampling loop does I2C and nothing else — it pushes raw counts onto a queue,
|
||
and a writer thread does unit conversion, CSV formatting, flushing and the
|
||
console display. Wall-clock timestamps are reconstructed in the writer from one
|
||
start time plus each sample's monotonic offset, rather than calling
|
||
`datetime.now()` per sample; that is both cheaper and immune to an NTP step
|
||
mid-capture.
|
||
|
||
This is worth roughly **28%**: the same 60 s capture yielded 22,558 samples
|
||
(376 Hz) with inline writing and 28,864 (481 Hz) with the writer split off.
|
||
The queue is bounded, and any drop is counted and reported rather than
|
||
silently losing data.
|
||
|
||
## Files
|
||
|
||
| 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, 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 |
|
||
| `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
|
||
`write(addr, data)` and `read(addr, count)`.
|
||
|
||
## Implementation notes
|
||
|
||
Things that cost time to work out, recorded so they don't have to be again.
|
||
|
||
### CH347 access
|
||
|
||
The adapter presents three USB interfaces. Interfaces 0 and 1 are CDC-ACM and
|
||
the kernel binds them as `/dev/ttyACM0` (the UART). **Interface 2** is
|
||
vendor-class and carries I2C/SPI/JTAG with no kernel driver bound, so libusb can
|
||
claim it directly with nothing to detach.
|
||
|
||
`ch347.py` deliberately never calls `set_configuration()` — the device is already
|
||
configured, and re-setting it would disturb the CDC-ACM interfaces driving the
|
||
UART.
|
||
|
||
There is no usable off-the-shelf option: no CH347 kernel driver exists (the
|
||
in-tree `spi-ch341` is for `1a86:5512`, a different chip); the PyPI `ch347`
|
||
package wraps a Windows DLL; and `ch347api` supports only HID mode (`55dc`), not
|
||
this vendor-bulk mode 1. The framing in `ch347.py` follows the
|
||
`aystarik/ch347-i2c-spi-gpio` Linux driver, whose id-table entry
|
||
`USB_DEVICE_INTERFACE_NUMBER(0x1a86, 0x55db, 0x02)` matches this device exactly.
|
||
|
||
Wire format — bulk OUT `0x06`, bulk IN `0x86`, max 63 bytes per transfer:
|
||
|
||
| Purpose | Bytes out | Bytes back |
|
||
|---|---|---|
|
||
| Set speed | `AA 6<speed> 00` | 0 |
|
||
| Write n | `AA 74 (80\|n+1) (addr<<1) <data> 75 00` | n+1 |
|
||
| Read n | `AA 74 81 ((addr<<1)\|1) [C0\|(n-1)] C0 75 00` | n+1 |
|
||
| Probe | `AA 74 81 (addr<<1) 75 00` | 1 |
|
||
|
||
**Every returned byte must be `1`** — that is the per-byte ACK. On a read, byte 0
|
||
is the address ACK and the rest is payload.
|
||
|
||
### RM3100 quirks
|
||
|
||
- **Register reads use the plain address, not `|0x80`.** §5 describes the SPI
|
||
convention of adding 0x80, but the I2C diagram in §5.8.4 writes `0x24`
|
||
literally. Only 7 bits are decoded, so both work; the plain form is used here.
|
||
- **A read is STOP-then-START, not a repeated START** (§4.5.2, §5.8.4), which is
|
||
exactly what two separate CH347 transactions produce. No special handling.
|
||
- **CMM = 0x79** for all three axes. Table 5-1 describes bit 3 as reserved-zero,
|
||
but the manual's own examples (§5.7.2, §5.8.3) set it. The examples win.
|
||
- **HSHAKE is set to 0x0A** (`DRC0=0`, `DRC1=1`) during init. The 0x1B default has
|
||
`DRC0=1`, meaning *any* register write clears DRDY — including the pointer
|
||
write that reading STATUS itself requires, so polling could never observe DRDY
|
||
set. With `DRC0=0`, DRDY clears only on a results read.
|
||
- **Gain** is `0.3671 × cycle_count + 1.5` LSB/µT, a linear fit to Table 3-1
|
||
(50→20, 100→38, 200→75; reproduces all three within a count).
|
||
- Measurements are three **24-bit big-endian two's-complement** values, read as
|
||
9 bytes from 0x24 using the sensor's register auto-increment.
|
||
|
||
## Diagnostics
|
||
|
||
Symptoms map cleanly onto causes, so work down this list.
|
||
|
||
| Symptom | Cause |
|
||
|---|---|
|
||
| `Cannot claim CH347 interface 2: Access denied` | udev rule missing or not yet applied to this node — run `./setup.sh`, which diagnoses which of `uaccess` or group membership fell through. Do **not** reach for `sudo`: it works as your normal user, and masking a permissions problem with root only defers it. `--high-priority` is the one case that legitimately needs root, and it hands the capture file back to `$SUDO_UID` afterwards |
|
||
| `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 |
|
||
|
||
**BIST is the definitive test for the analog side** (§5.6.1). Write `0x8F` to
|
||
BIST (STE=1, max timeout and periods), write `0x70` to POLL, wait, then read
|
||
BIST back: bits 4/5/6 are XOK/YOK/ZOK, and `1` means that axis's LR oscillator
|
||
ran. All zeros means the coils are not oscillating, which points at AVDD or the
|
||
REXT timing resistor rather than anything on the I2C side.
|
||
|
||
A healthy total field magnitude is roughly **25–65 µT** (Earth's field). Near
|
||
zero, railed, or wildly out of range means the decode or gain is wrong rather
|
||
than merely "data arrived".
|
||
|
||
## Observed performance
|
||
|
||
First working capture, 10 s at TMRC 0x96, cycle count 200, sensor stationary on
|
||
a desk next to a PC:
|
||
|
||
```
|
||
349 samples over 10.02 s -> 34.8 Hz
|
||
X: mean +6.473 uT sd 177 nT
|
||
Y: mean +50.914 uT sd 106 nT
|
||
Z: mean +18.944 uT sd 396 nT
|
||
|B|: mean 54.709 uT sd 245 nT
|
||
sample interval: 28.78 ms, sd 1.52 ms
|
||
```
|
||
|
||
- **Rate.** 34.8 Hz against a nominal ~37 Hz is in spec: §5.2.1 quotes roughly
|
||
7% one-standard-deviation tolerance on the update rate.
|
||
- **Magnitude.** 54.7 µT sits in the expected 25–65 µT band for Earth's field,
|
||
which is the real confirmation that gain and the int24 decode are right.
|
||
- **Noise** is well above the 15 nT that Table 3-1 quotes for cycle count 200,
|
||
and is not a driver problem. Most likely proximity to a PC and the USB
|
||
adapter; a secondary candidate is ripple on the CH347's 3V3 rail against the
|
||
50 mV limit (see Power quality above), which has not been measured. Expect far
|
||
better readings away from mains wiring and switching supplies.
|
||
- **Drift.** All three axes drifted monotonically down over the 10 s (Z most, by
|
||
about 1.3 µT), so |B| fell from 55.5 to 54.3. Consistent with thermal settling
|
||
after AVDD is first powered — coil DC resistance moves 0.4 %/°C (Table 3-3).
|
||
Worth allowing a warm-up period before trusting absolute values.
|
||
|
||
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.
|
||
|
||
### Bench results (desk, cc=200, TMRC 0x96, 400 kHz)
|
||
|
||
Timing held up well throughout: fitted periods land 6.0–6.6% below nominal, and
|
||
two consecutive 60 s runs agreed to **0.017%**, so a single-slope drift
|
||
correction is sound over at least a minute.
|
||
|
||
#### 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 3–4 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 ~100–125 nT/√Hz,
|
||
against a ~4–5 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 |
|
||
|---|---|---|
|
||
| 0–1 | 3,750 nT | no — handling |
|
||
| 2–10 | **34–40 nT** | yes |
|
||
| 11–12 | 41–68 nT | marginal |
|
||
| 13 | 247 nT | no — activity resumed |
|
||
|
||
The quiet window is extracted as `overnight_quiet.csv` (hours 2.0–10.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 214–284 nT
|
||
per axis while the broadband floor barely moves (5.1–5.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.63–5.16 ms of 13.33 ms (20–39%) |
|
||
| 0x96 | 37 Hz | clean, 34.7 Hz | 3.30–8.56 ms of 27.03 ms (12–32%) |
|
||
|
||
> **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 2–3× 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.89–0.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 100 at 750 kHz**, which is what the defaults do. That runs the
|
||
sensor at its own ceiling of ~282 Hz, with TMRC held fast so the cycle count
|
||
governs and the duty cycle stays at 94%.
|
||
|
||
```bash
|
||
./.venv/bin/python logger.py --duration 60 # cc=100, 750 kHz, ~282 Hz
|
||
```
|
||
|
||
It sits deliberately between the two things that pull in opposite directions:
|
||
|
||
- **Against cycle count 200** it costs **1.5%** in post-filter noise — 94% duty
|
||
against 97%, and nothing else — for **1.9× the spectrum**, 141 Hz of Nyquist
|
||
against 73 Hz. Worth taking, because **aliased interference cannot be filtered
|
||
out afterwards at any cycle count**, so seeing it beats 1.5% of noise. At
|
||
282 Hz both mains and its second harmonic sit in band and can be notched; at
|
||
145 Hz only the fundamental does.
|
||
- **Against cycle count 50** it gives up half the spectrum (141 Hz of Nyquist
|
||
against 267 Hz) and buys **36% more dither margin**, 0.79 LSB against 0.58,
|
||
plus a 1.9× coarser rate demand on the host. cc=50 is the right choice when
|
||
something above 141 Hz needs identifying; it is not the right default,
|
||
because its dither margin is the one assumption in the whole configuration
|
||
that has not yet been checked against a real quiet-site capture.
|
||
|
||
750 kHz is not optional at this cycle count. The period is 3.539 ms, and the
|
||
irreducible I2C traffic per sample is:
|
||
|
||
| bus | traffic | share of period |
|
||
|---|---|---|
|
||
| 100 kHz | 1.500 ms | **42%** |
|
||
| 400 kHz | 0.375 ms | 11% |
|
||
| 750 kHz | 0.200 ms | **6%** |
|
||
|
||
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.03–2.19 ms | ~0.63 ms |
|
||
| 400 kHz | 0.375 ms | 0.88–0.95 ms | ~0.55 ms |
|
||
| **750 kHz** | 0.200 ms | **0.78–0.80 ms** | ~0.59 ms |
|
||
|
||
Host cost is `bus time + ~0.6 ms` of fixed USB round-trip latency, so a faster
|
||
bus helps but cannot go below that floor. An earlier note 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 200 if dither is still thin.** The assumption to check
|
||
is that cc=100 stays dithered: its intrinsic noise is 0.79 LSB against a
|
||
simulated failure point below ~0.2 LSB, so there is roughly 4× of margin at the
|
||
spec noise figure. That margin shrinks if the real sensor is quieter than spec,
|
||
which is the entire point of the quiet-site experiment.
|
||
`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 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% | spectrum, thin dither |
|
||
| **100** | **282 Hz** | **141 Hz** | **26.17** | **0.79** | **94.2%** | **default** |
|
||
| 200 | 145 Hz | 73 Hz | 13.35 | 1.10 | 97.0% | resolution, 60 Hz only |
|
||
|
||
`logger.py` warns on stderr for anything below cycle count 50
|
||
(`rm3100.RECOMMENDED_MIN_CYCLE_COUNT`), along with five other configuration
|
||
traps — see [Configuration warnings](#configuration-warnings).
|
||
|
||
### Configuration warnings
|
||
|
||
`rm3100.plan()` resolves a configuration and `logger.py` prints its derivation
|
||
before every run, then checks it against six known traps. Each one is a *silent*
|
||
failure: the capture completes, the numbers look plausible, and the defect is
|
||
only visible afterwards. So each is reported on stderr and **nothing is fixed
|
||
automatically** — changing a setting that was asked for would hide the problem
|
||
behind a configuration change.
|
||
|
||
| Warning | Trigger | Why it matters |
|
||
|---|---|---|
|
||
| cycle count below the recommended 50 | `cc < RECOMMENDED_MIN_CYCLE_COUNT` | dither thins toward the ~0.2 LSB where averaging stops recovering sub-LSB resolution |
|
||
| TMRC governs and the sensor idles | TMRC-governed and idle > 20% | idle time buys nothing; measured 1.43× the ASD at 23% duty |
|
||
| rate differs from the one requested | \|error\| > 2% | the run silently uses the ceiling, and every figure above it is derived from the rate |
|
||
| Nyquist below 60 Hz | rate < 120 Hz | mains folds onto signal and no later filter undoes it |
|
||
| cycle count past 400 | `cc > MAX_SPEC_CYCLE_COUNT` | Table 3-1 ends there, so the printed gain and noise are extrapolated, not specified |
|
||
| bus over half the period | traffic / period > 50% | names the speed that would fit |
|
||
|
||
The aliasing one is the easiest to walk into: `--rate 32` yields a clean-looking
|
||
2.91 nT/LSB capture with 60 Hz mains sitting at 4.02 Hz, indistinguishable from
|
||
signal. Sampling fast and decimating afterwards gives the same noise floor with
|
||
the line still visible.
|
||
|
||
### 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 229–29,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
|
||
around that to keep the noise figures honest:
|
||
|
||
| Axis | sd, 0–40 s | sd, 47–60 s | step across the event |
|
||
|---|---|---|---|
|
||
| X | 84 nT | 85 nT | +280 nT |
|
||
| Y | 388 nT | 376 nT | +217 nT |
|
||
| Z | 187 nT | 198 nT | +232 nT |
|
||
| \|B\| | 369 nT | 358 nT | +313 nT |
|
||
|
||
Three things worth noting:
|
||
|
||
- **The t ≈ 41 s step was the sensor being physically moved**, confirmed at the
|
||
time — not an electrical artefact. Note that a norm shift does *not* by itself
|
||
imply an external source changed: |B| is preserved under **rotation** in a
|
||
uniform field, but a **translation** through a field gradient samples a
|
||
different local field and changes the magnitude. Near a PC the gradients are
|
||
steep (§4.2.2: field falls off as 1/distance³), so a few centimetres is ample
|
||
to produce the +313 nT seen here. Treat |B| changes as "not a pure rotation",
|
||
nothing more.
|
||
- **Noise is strongly axis-dependent** — Y is 4.6× X (388 vs 84 nT), and stable
|
||
across the event, so it is not a consequence of the disturbance. The coils are
|
||
nominally identical, so this asymmetry points at orientation relative to a
|
||
local noise source rather than a sensor fault. X at 84 nT is within ~3× of the
|
||
30 nT Table 3-1 quotes for cycle count 50; Y is ~13×.
|
||
- **Sample timing is jittery**: intervals average 2.08 ms (481 Hz) with sd
|
||
1.09 ms and a 19.6 ms worst case. Fine for logging, but the irregular spacing
|
||
makes this data unsuitable for spectral analysis without resampling.
|
||
|
||
## Status
|
||
|
||
- [x] udev rule and venv setup
|
||
- [x] CH347 vendor protocol — verified byte-for-byte against the kernel driver
|
||
- [x] I2C bus scan — sensor found at 0x23
|
||
- [x] Sensor identified — REVID `0x22`
|
||
- [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
|
||
|
||
> **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.
|