577 lines
30 KiB
Markdown
577 lines
30 KiB
Markdown
# RM3100 logging over a CH347 USB-I2C adapter
|
||
|
||
Logs a PNI RM3100 geomagnetic sensor connected to the I²C pins of a Waveshare
|
||
USB to UART/I2C/SPI/JTAG adapter (CH347, USB `1a86:55db`), with a time base good
|
||
enough for spectral work: the sample index is a chip-clock grid coordinate, not
|
||
a count of host reads.
|
||
|
||
Reference throughout: *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.
|
||
|
||
[NOTES.md](NOTES.md) holds the measurement notebook — what was measured on this
|
||
rig, and why the design ended up as it is.
|
||
|
||
## Quickstart
|
||
|
||
```bash
|
||
./setup.sh # udev rule + venv, sudo for the rule only
|
||
./.venv/bin/python diagnose-comms.py # walk the signal chain, stop at the first fault
|
||
./.venv/bin/python logger.py --duration 60 # capture to rm3100_<timestamp>.csv
|
||
./.venv/bin/python characterize.py rm3100_*.csv # noise floor, spectrum, stability
|
||
```
|
||
|
||
The test suite needs no hardware:
|
||
|
||
```bash
|
||
./.venv/bin/python -m pytest
|
||
```
|
||
|
||
## Hardware
|
||
|
||
Waveshare adapter in **Mode 1** (UART1 + I2C + SPI), voltage selector at **3V3**.
|
||
The RM3100 is on a breakout board. All connections below are confirmed working.
|
||
|
||
| 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).
|
||
|
||
Four pins are worth knowing about; two of them cause failures that are easy to
|
||
misdiagnose.
|
||
|
||
**I2CEN (pin 22) must be tied HIGH.** `HIGH = I2C, LOW = SPI` (§4.3.1). SDA/SCL
|
||
are shared with SPI MOSI/SCLK and I2CEN selects between them. 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 `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."* With only DVDD connected the part is
|
||
**half alive** in a way that reads as working: the address responds, REVID reads
|
||
`0x22`, registers write and read back — but the analog section driving the coils
|
||
is dead, so DRDY never asserts and the measurement registers stay at zero. BIST
|
||
reporting `XOK=YOK=ZOK=0` is the unambiguous tell. Both rails are needed, and
|
||
AVDD is not a substitute for DVDD: DVDD must come up **before or with** AVDD,
|
||
never after, and the two must stay within **0.1 V** while running (§4.3.1,
|
||
Table 3-5) — which sharing one 3V3 rail satisfies for free.
|
||
|
||
**SA0 (pin 3) / SA1 (pin 28) set the address.** The top five bits are fixed at
|
||
`0b01000`, giving 0x20–0x23 (§4.5). On this breakout both straps are high, so the
|
||
sensor answers at **0x23**. The 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, which the manual explicitly offers as an alternative (§5.4.1).
|
||
|
||
### Power
|
||
|
||
| 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 |
|
||
|
||
Reference decoupling (Figures 4-1, 4-2) is a **10 µF bulk capacitor in parallel
|
||
with 0.1 µF ceramic**, rail to ground, feeding AVDD, both DVDD pins and I2CEN.
|
||
That is the entire filtering specification — no ferrite, no split 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, not an
|
||
electrical one.
|
||
|
||
**This rig runs at 3.3 V, but every number in Table 3-1 is quoted at 3.0 V**
|
||
(footnote 1: *"performance will differ"*). 3.3 V is comfortably in range, but the
|
||
gain fit behind `tesla_per_count` is strictly a 3.0 V figure, so the scale factor
|
||
is an extrapolation off the calibration point. A 3.0 V LDO was built and compared
|
||
— see [NOTES.md](NOTES.md), which also covers what the manual does and does not
|
||
say about ripple.
|
||
|
||
## Setup
|
||
|
||
```bash
|
||
./setup.sh
|
||
```
|
||
|
||
Idempotent. It checks the adapter is present, installs a udev rule, creates
|
||
`.venv` with pyusb, numpy, matplotlib and pytest, then verifies the device node
|
||
is actually writable and prints whichever fix applies if it is not. `sudo` is
|
||
needed for the udev rule and nothing else.
|
||
|
||
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:
|
||
|
||
```
|
||
# /etc/udev/rules.d/60-ch347.rules
|
||
SUBSYSTEM=="usb", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="55db", TAG+="uaccess"
|
||
SUBSYSTEM=="usb", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="55db", GROUP="plugdev", MODE="0660"
|
||
```
|
||
|
||
`uaccess` has systemd-logind put an ACL on the node for whoever holds the local
|
||
seat — no group, no logout — and is the only mechanism that can work on atomic
|
||
Fedora. `GROUP`/`MODE` is the fallback for ssh sessions and seatless systems,
|
||
using whichever of `plugdev` or `dialout` exists as a system group.
|
||
|
||
**Two details in that file are load-bearing**, and getting either wrong produces
|
||
a rule that installs cleanly and grants nothing:
|
||
|
||
- **The prefix must sort below 73.** udev merges every rules directory into one
|
||
lexicographic sequence, and the only thing that acts on the tag is
|
||
`TAG=="uaccess", ENV{MAJOR}!="", RUN{builtin}+="uaccess"` in systemd's
|
||
`73-seat-late.rules`. At `99-` the tag is added after that line has already
|
||
run: set, never read, no ACL. systemd keeps its own uaccess rules in
|
||
`70-uaccess.rules` for this reason.
|
||
- **`TAG+="uaccess"` and `GROUP=` must be on separate lines.** udev discards a
|
||
whole rule line whose `GROUP=` it cannot resolve, and since systemd 258 that
|
||
includes any group that exists but is not a *system* group. Sharing a line
|
||
means an unusable group silently takes the uaccess tag down with it.
|
||
|
||
Together those explain a failure that looks distro-specific but is not: on
|
||
Debian and Ubuntu the `plugdev` group grants access on its own, masking a
|
||
uaccess tag that never fired. Fedora removed `plugdev` years ago, so on
|
||
**Bazzite** neither half worked and the adapter stayed inaccessible.
|
||
|
||
Two more atomic-Fedora traps `setup.sh` prints fixes for:
|
||
|
||
- `usermod -aG dialout $USER` fails with *"group 'dialout' does not exist"* even
|
||
though `getent` finds it, because `nss_altfiles` lets `getent` read
|
||
`/usr/lib/group` while `usermod` writes `/etc/group` alone. Copy the line
|
||
across first, then re-run `usermod` and log back in.
|
||
- Bazzite is known not to reload `/etc/udev/rules.d` when it switches to the
|
||
final rootfs ([ublue-os/bazzite#2516](https://github.com/ublue-os/bazzite/issues/2516)),
|
||
so a correct rule can sit inert until
|
||
`sudo udevadm control --reload-rules && sudo udevadm trigger` is run once.
|
||
|
||
`setup.sh` checks the node itself at the end rather than guessing, and says
|
||
*which* mechanism granted access — `uaccess ACL` or `group membership` — since
|
||
the two fail in different ways and only one of them is available on Bazzite.
|
||
|
||
Python 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 both the rate and the LSB — see [Configuration](#configuration) |
|
||
| `--tmrc` | `0x92` | rate register. The default is the fastest, letting 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 bus scan |
|
||
| `--bus-speed` | 750 | I²C kHz. 100 kHz would spend 42% of each period on the bus at the default cycle count; 750 spends 6% |
|
||
| `--calibrate` | 1.0 | seconds of loss-free samples used to measure the true period before recording. The run aborts if no clean stretch can be found |
|
||
| `--note` | — | free text recorded in the capture header, e.g. the supply under test |
|
||
| `--high-priority` | off | raise the sampling thread to nice −10. Needs `CAP_SYS_NICE`, so run under `sudo`; output files are handed back to the invoking user |
|
||
| `--scan-only` | — | scan and exit |
|
||
|
||
**Exit status is non-zero if the capture is compromised in any way** — a lost
|
||
measurement, an ambiguous gap, a truncated run, or an undrained writer. Each
|
||
prints an explanation on stderr, and the file is always valid as far as it goes.
|
||
|
||
Before recording, `logger.py` prints how the configuration was derived, so it can
|
||
be checked rather than trusted, then measures the true sample period against the
|
||
host clock. Both appear in the capture header.
|
||
|
||
### Analysis tools
|
||
|
||
```bash
|
||
./.venv/bin/python plot.py capture.csv # -> capture.png: X, Y, Z, |B|
|
||
./.venv/bin/python plot.py capture.csv --smooth 0 # raw trace only
|
||
./.venv/bin/python characterize.py capture.csv # -> capture_noise.png + summary
|
||
./.venv/bin/python characterize.py capture.csv --start 60 --end 360
|
||
./.venv/bin/python compare.py --group note *.csv # A/B two conditions
|
||
./.venv/bin/python sweep.py --rates 10,50,150 # measure what each rate delivers
|
||
```
|
||
|
||
- **`plot.py`** — four stacked panels with independent y-scales, since the three
|
||
axes sit at very different DC offsets.
|
||
- **`characterize.py`** — amplitude spectral density against the 1.2 nT/√Hz of
|
||
Table 3-1, Allan deviation, residual distribution, and host read latency. The
|
||
`sd/LSB` column is what settles whether the quantiser is properly dithered.
|
||
- **`compare.py`** — reports noise **fractionally** (ppm of |B|), because a gain
|
||
change carries the noise with it and an absolute comparison reads a pure scale
|
||
change as a noise difference. It also refuses to attribute a magnitude
|
||
difference to gain unless the sensor demonstrably held still.
|
||
- **`sweep.py`** — runs `logger.py` once per target rate and tabulates what each
|
||
configuration actually achieved.
|
||
|
||
A capture is analysable whether the run ended on `--duration`, on **Ctrl-C**, or
|
||
early — it is valid up to wherever it stopped, so the analysis tools can be
|
||
pointed at it either way.
|
||
|
||
## 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. Chip time, elapsed
|
||
time, tesla and magnitude are all reconstructed on load by `capture.py`, so there
|
||
is exactly one source of truth for each.
|
||
|
||
```
|
||
# rm3100_capture: 1
|
||
# nominal_rate_hz: 282.53110196547465
|
||
# tmrc_nominal_hz: 600.0
|
||
# tmrc: 0x92
|
||
# cycle_count: 100
|
||
# tesla_per_count: 2.617115938236064e-08
|
||
# i2c_address: 0x23
|
||
# bus_speed_khz: 750
|
||
# revid: 0x22
|
||
# calibrated_period_s: 0.0035391156462585034
|
||
# note: bench, 3V0 LDO
|
||
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
|
||
```
|
||
|
||
`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`. All times are unix
|
||
epoch seconds; there is no ISO-8601 anywhere in the data.
|
||
|
||
The `warning` column carries space-separated flags, empty when the row is fine,
|
||
and 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.
|
||
|
||
`capture.py` interpolates `MISSED` rows on load so the uniform grid the spectra
|
||
depend on survives, and reports how many were substituted so it is never silent.
|
||
|
||
## How it works
|
||
|
||
**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 stays uniform even
|
||
when host reads are late, so `sample_index` is a grid coordinate.
|
||
- **A late read returns the *newer* measurement**, not a delayed one. An
|
||
unnoticed miss therefore skips a grid point and silently compresses the time
|
||
axis.
|
||
|
||
So a lost measurement is **recorded, not dropped**: a placeholder row keeps
|
||
`sample_index` contiguous and makes the gap explicit. The run continues and
|
||
reports the total at the end. Double-counting is structurally impossible —
|
||
`HSHAKE` DRC1=1 makes DRDY clear only on a results read, and `configure()`
|
||
verifies the readback rather than assuming it.
|
||
|
||
**The period is calibrated before recording starts.** Counting how many grid
|
||
points passed unseen 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. `calibrate_period()` takes a least-squares slope over one second of
|
||
loss-free samples, which pins it to ~0.07%, then keeps refining it from clean
|
||
intervals so it follows the oscillator's thermal drift. If no clean stretch can
|
||
be found the run aborts before recording anything: that is 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 **DRDY bracket**, the span between the last poll
|
||
showing DRDY clear and the poll showing it set. This 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.
|
||
- *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 cannot say, since it only reaches back to
|
||
the last poll that saw DRDY clear and so discards where the grid is.
|
||
|
||
The read-to-read interval alone cannot make the first claim: measured here 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 flags healthy
|
||
captures as lossy.
|
||
|
||
**Clock drift is compensated in post-processing, not baked into the file.** The
|
||
RC oscillator is *regular* but only accurate to ±7% (§5.2.1). `capture.py`
|
||
regresses the host clock on `sample_index` to recover the true period:
|
||
|
||
```
|
||
elapsed_nominal = N * dt_nominal # uniform, but 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 displace every spectral feature by 6%. Over hours the oscillator drifts
|
||
enough that no single slope fits; `capture.py` reports that as `drift_limited`
|
||
and says to analyse shorter windows.
|
||
|
||
**Threading.** The sampling loop does I²C and nothing else, pushing raw counts
|
||
onto a bounded queue; a writer thread does unit conversion, CSV formatting,
|
||
flushing and the console display. The sampler releases the GIL inside each USB
|
||
transfer and must re-acquire it, so Python's default 5 ms switch interval becomes
|
||
the jitter floor — `sys.setswitchinterval(0.0005)` cut the worst bracket from
|
||
9.96 ms to 5.65 ms. The writer is also reniced out of the way, which needs no
|
||
privilege; `--high-priority` additionally raises the sampler, which does.
|
||
|
||
If the queue ever fills, the run **stops** rather than dropping a row: a missing
|
||
row would leave a hole in `sample_index`, and a truncated capture is worth more
|
||
than a longer one no tool can load.
|
||
|
||
## Configuration
|
||
|
||
**Cycle count is the rate knob, not TMRC.** Two ceilings compete and the slower
|
||
one wins (§5.2.1): the cycle count sets how *long* a measurement takes, TMRC sets
|
||
how *often* one is started.
|
||
|
||
| | sets | granularity |
|
||
|---|---|---|
|
||
| cycle count | `3 × (cc/90,000 + 68.7 µs)` per measurement | continuous |
|
||
| TMRC | how often a measurement starts | factor-of-two steps |
|
||
|
||
Leaving TMRC faster than the cycle-count ceiling makes the sensor free-run at
|
||
~100% duty and renders TMRC irrelevant. Setting it slower makes the sensor idle,
|
||
which costs sensitivity for nothing: noise after filtering scales as `1/√duty`,
|
||
and a measured 23% duty cost 1.43× the noise ASD against the same rate reached by
|
||
cycle count alone. So the default in every branch is the fastest TMRC, and
|
||
`--rate` derives the cycle count from there.
|
||
|
||
**Cycle count also sets resolution**, which is the real trade:
|
||
|
||
| cc | rate | Nyquist | nT/LSB | dither | duty | |
|
||
|---|---|---|---|---|---|---|
|
||
| 50 | 534 Hz | 267 Hz | 50.37 | 0.58 | 89.0% | spectrum, thin dither |
|
||
| **100** | **283 Hz** | **141 Hz** | **26.17** | **0.79** | **94.2%** | **default** |
|
||
| 200 | 145 Hz | 73 Hz | 13.35 | 1.10 | 97.0% | resolution, 60 Hz only |
|
||
| 400 | 74 Hz | 37 Hz | 6.74 | 1.54 | 98.5% | resolution, mains aliases |
|
||
|
||
*dither* is the sensor's own noise in LSB. Below roughly 0.2 LSB the quantiser
|
||
stops being dithered and averaging no longer recovers sub-LSB resolution.
|
||
|
||
**The default is cycle count 100 at 750 kHz**, which runs the sensor at its own
|
||
~283 Hz ceiling. It sits deliberately between the two things pulling in opposite
|
||
directions:
|
||
|
||
- **Against cc=200** it costs 1.5% in post-filter noise 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** — at 283 Hz both mains
|
||
and its second harmonic are in band and can be notched.
|
||
- **Against cc=50** it gives up half the spectrum and buys 36% more dither
|
||
margin. cc=50 is right when something above 141 Hz needs identifying; it is not
|
||
the right default, because its dither margin is the one assumption here not yet
|
||
checked against a quiet-site capture.
|
||
|
||
Fall back to cycle count 200 if `characterize.py` reports `sd/LSB` below ~0.35.
|
||
|
||
**Bus speed is independent of the rate** — it appears in neither mechanism. What
|
||
it sets is latency: how long a read takes, hence how tightly DRDY can be
|
||
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.
|
||
|
||
| bus | traffic/sample | share of a cc=100 period |
|
||
|---|---|---|
|
||
| 100 kHz | 1.500 ms | **42%** |
|
||
| 400 kHz | 0.375 ms | 11% |
|
||
| **750 kHz** | **0.200 ms** | **6%** |
|
||
|
||
Below ~0.46 Hz the 16-bit cycle-count register runs out and TMRC must set the
|
||
cadence; below cycle count 30 the manual warns of quantisation (§5.1). Both
|
||
bounds are enforced by `rm3100.plan()`.
|
||
|
||
### Configuration warnings
|
||
|
||
`rm3100.plan()` resolves a configuration, `logger.py` prints its derivation, then
|
||
checks it against six known traps. Each is a *silent* failure — the capture
|
||
completes, the numbers look plausible, and the defect only shows up 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, 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 derived figure moves with it |
|
||
| 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 |
|
||
| 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.9 nT/LSB capture with 60 Hz mains sitting at 4.0 Hz, indistinguishable from
|
||
signal. Sampling fast and decimating afterwards gives the same noise floor with
|
||
the line still visible.
|
||
|
||
## Diagnostics
|
||
|
||
`diagnose-comms.py` walks the chain — USB → bus → identity → registers → BIST →
|
||
live read — and stops at the first failure, so a fault points at a specific wire.
|
||
Otherwise, symptoms map onto causes:
|
||
|
||
| Symptom | Cause |
|
||
|---|---|
|
||
| `Cannot claim CH347 interface 2: Access denied` | udev rule missing or not yet applied to this node — replug the adapter, then run `./setup.sh`, which reports whether `uaccess` or group membership granted access and diagnoses whichever fell through. On Bazzite also try `sudo udevadm control --reload-rules && sudo udevadm trigger`. Do **not** reach for `sudo`: it works as a normal user, and masking a permissions problem with root only defers it |
|
||
| `No CH347 adapter found` | not plugged in, or not in Mode 1 |
|
||
| `Could not find N s of loss-free samples` | the host cannot sustain this rate — lower it (`--rate`, or a higher `--tmrc`) or raise `--bus-speed` |
|
||
| `N measurement(s) were lost` | host stalls during the run. Same fixes; the capture is still usable, with explicit gaps |
|
||
| `N gap(s) could not be counted confidently` | the index may have slipped — re-record before doing spectral work |
|
||
| `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 |
|
||
| 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, pointing at AVDD or the REXT
|
||
timing resistor rather than anything on the I²C 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 the gain is wrong rather
|
||
than merely "data arrived".
|
||
|
||
## Files
|
||
|
||
| File | Role |
|
||
|---|---|
|
||
| `setup.sh` | udev rule, venv, dependencies, node permission check |
|
||
| `ch347.py` | CH347 I²C transport — USB only, knows nothing about the sensor |
|
||
| `rm3100.py` | RM3100 driver and configuration model — knows nothing about USB |
|
||
| `logger.py` | CLI: scan → identify → configure → calibrate → log, recording lost measurements as explicit placeholders |
|
||
| `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, duty 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 |
|
||
| `tests/` | pytest suite; needs no hardware |
|
||
|
||
The adapter/sensor split is deliberate: `ch347.py` is a general I²C master usable
|
||
with any device, and `rm3100.py` needs only a bus object exposing
|
||
`write(addr, data)` and `read(addr, count)` — optionally `write_read()`, which it
|
||
prefers when available.
|
||
|
||
## 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,
|
||
carries I2C/SPI/JTAG with no kernel driver bound, and 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 follows the `aystarik/ch347-i2c-spi-gpio`
|
||
Linux driver, whose id-table entry
|
||
`USB_DEVICE_INTERFACE_NUMBER(0x1a86, 0x55db, 0x02)` matches this device exactly.
|
||
|
||
Wire format — bulk OUT `0x06`, bulk IN `0x86`, max 63 bytes per transfer:
|
||
|
||
| Purpose | Bytes out | Bytes back |
|
||
|---|---|---|
|
||
| Set speed | `AA 6<speed> 00` | 0 |
|
||
| Write n | `AA 74 (80\|n+1) (addr<<1) <data> 75 00` | n+1 |
|
||
| Read n | `AA 74 81 ((addr<<1)\|1) [C0\|(n-1)] C0 75 00` | n+1 |
|
||
| Write n then read m | `AA 74 (80\|n+1) (addr<<1) <data> 74 81 ((addr<<1)\|1) [C0\|(m-1)] C0 75 00` | n+2+m |
|
||
| Probe | `AA 74 81 (addr<<1) 75 00` | 1 |
|
||
|
||
**Every returned byte must be `1`** — that is the per-byte ACK. On a read, the
|
||
leading bytes are address ACKs and the rest is payload. `tests/test_ch347.py`
|
||
pins all five forms byte for byte.
|
||
|
||
### RM3100 quirks
|
||
|
||
- **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 register read uses a repeated START**, not the STOP-then-START the manual
|
||
draws (§4.5.2, §5.8.4). The part accepts it — verified against REVID and the
|
||
measurement registers — and it halves the USB round trips, which is what sets
|
||
the sample-rate ceiling. `rm3100.read_reg()` falls back to the manual's form on
|
||
a bus that cannot do it.
|
||
- **CMM = 0x79** for all three axes. Table 5-1 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, which is what makes
|
||
exactly-once sampling possible.
|
||
- **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).
|
||
- **The rate model divisor is specified, not fitted.** Table 3-1 gives a 180 kHz
|
||
circuit oscillation and §4.1 measures each cycle count in *both* bias
|
||
directions, so one count costs two oscillations — hence 90,000 counts/s. The
|
||
68.7 µs per-axis overhead has no specified value and was measured; it is why
|
||
the naive `rate × cc` "constant" drifts from 84,429 at cc=100 to 89,191 at 1200.
|
||
- Measurements are three **24-bit big-endian two's-complement** values, read as
|
||
9 bytes from 0x24 using the sensor's register auto-increment.
|
||
|
||
## Tests
|
||
|
||
```bash
|
||
./.venv/bin/python -m pytest # whole suite, no hardware needed
|
||
./.venv/bin/python -m pytest -k logger # one module
|
||
```
|
||
|
||
The suite fakes the hardware at three seams: `FakeUsbDevice` (libusb, so
|
||
`ch347.py`'s framing is itself under test), `FakeBus` (an I²C master with a
|
||
register map), and `FakeSensor` (a scripted DRDY timeline against a fake clock,
|
||
so a host stall of an exact size can be injected). See `tests/conftest.py`.
|
||
|
||
The miss-counting logic gets the most attention, since it is the part whose
|
||
failures are invisible downstream: a stall of 1.1 periods must lose nothing, 2.1
|
||
must record one placeholder, and 1.35 must flag the sample as `AMBIGUOUS` without
|
||
inserting anything.
|
||
|
||
## Status
|
||
|
||
- [x] udev rule, venv and dependency setup
|
||
- [x] CH347 vendor protocol — verified byte-for-byte against the kernel driver
|
||
- [x] I²C bus scan, sensor identified at 0x23, REVID `0x22`
|
||
- [x] Analog section verified — BIST reports `XOK=YOK=ZOK=1`
|
||
- [x] Exactly-once sampling, chip-grid time base, explicit gaps for lost samples
|
||
- [x] Verified on hardware: exact grid, −6.11% drift recovered, 0.017% run-to-run
|
||
- [x] Calibrated period, configuration warnings, cycle-count rate model
|
||
- [x] 3.0 V LDO built and compared — noise indistinguishable once normalised
|
||
- [x] Test suite covering everything that does not need the adapter
|
||
- [ ] Re-test the LDO with the sensor **clamped**, to separate gain from movement
|
||
- [ ] Deal with 60 Hz coupling at source — the dominant term
|
||
- [ ] Reliability testing over extended runs on bare metal
|
||
|
||
Captures written before the current header format are unreadable and must be
|
||
re-recorded — `capture.py` says so explicitly rather than guessing. Figures
|
||
quoted in [NOTES.md](NOTES.md) came from those files and stand as historical
|
||
measurements only.
|
||
|
||
A proper Python API is planned; these modules are the initial-communication
|
||
milestone, deliberately kept simple.
|