# 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. This file documents how the code works *now*. How that was arrived at, every measurement plus a table of conclusions later measurements overturned, is in [Noise_Floor_Testing/NOTES.md](Noise_Floor_Testing/NOTES.md), with the supply and rate-scaling analysis in [Noise_Floor_Testing/NOISE_FLOOR.md](Noise_Floor_Testing/NOISE_FLOOR.md). ## 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_.csv ./.venv/bin/python characterize.py rm3100_*.csv # noise floor, spectrum, stability ./.venv/bin/python -m pytest # the suite needs no hardware ``` ## 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 matter; two of them cause failures that are easy to misdiagnose. - **I2CEN (pin 22) must be tied HIGH.** `HIGH = I2C, LOW = SPI` (§4.3.1), and SDA/SCL are shared with SPI MOSI/SCLK. Left floating or low the chip stays in SPI mode and **never ACKs its I2C address**, so the bus scan finds nothing at all on a perfectly healthy adapter. 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.** With only DVDD the part is **half alive** in a way that reads as working: the address responds, REVID reads `0x22`, registers write and read back, while the analog section is dead, DRDY never asserts and the measurement registers stay zero. BIST reporting `XOK=YOK=ZOK=0` is the unambiguous tell. 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 one shared 3V3 rail gives for free. - **SA0 (pin 3) / SA1 (pin 28) set the address**, top five bits fixed at `0b01000`, giving 0x20–0x23 (§4.5). On this breakout both straps are high, so the sensor answers at **0x23**. `logger.py` scans all four, so no configuration is needed. The pins are shared with SPI SSN and MISO, so a breakout may label them `SSN/SA0` and `MISO/SA1`. - **DRDY (pin 23) is not connected.** The driver polls the STATUS register, 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"*, which is a magnetic concern. **Every number in Table 3-1 is quoted at 3.0 V** (footnote 1: *"performance will differ"*), so the gain fit behind `tesla_per_count` is strictly a 3.0 V figure. Measured on interleaved captures at 3.006 V and 3.353 V, +0.347 V moves the sample rate by **+4.94%** and |B| by **−7.1%**, with no resolvable change in broadband noise. So **supply quality does not set the noise floor, but supply *voltage* sets the scale**, at roughly −20 %/V. Both effects are one thing seen twice, and the calibrated period every capture already records is a proxy for the rail; see [Calibration](#calibration). ## 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. Python here is PEP 668 externally-managed, hence the venv. 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" ``` The `60-` prefix, the two separate lines and the choice of group are all load-bearing; getting any of them wrong installs cleanly and grants nothing. [NOTES.md](Noise_Floor_Testing/NOTES.md) explains why, and covers the atomic-Fedora traps `setup.sh` prints fixes for. `setup.sh` checks the node itself at the end and says *which* mechanism granted access: `uaccess ACL` or `group membership`. ## 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` | none | 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` | none | 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` | none | 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. A capture is analysable whether the run ended on `--duration`, on **Ctrl-C**, or early. Before recording, `logger.py` prints how the configuration was derived, so it can be checked, 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 characterize.py capture.csv # -> capture_noise.png + summary ./.venv/bin/python characterize.py capture.csv --trim 30 # drop settling/handling ./.venv/bin/python compare.py --group note *.csv # A/B two conditions ./.venv/bin/python compare.py --trim 30 --supply LDO=3.006 --supply 3V3=3.353 \ LDO/cc100=a.csv 3V3/cc100=b.csv LDO/cc400=c.csv 3V3/cc400=d.csv -o cmp.png ./.venv/bin/python sweep.py --rates 10,50,150 # measure what each rate delivers ``` Each tool's `--help` carries its full reasoning; in one line each: - **`plot.py`**: four stacked panels with independent y-scales, since the three axes sit at very different DC offsets. - **`characterize.py`**: spectral density against Table 3-1, Allan deviation, residual distribution and host read latency on **fixed axes**, plus a dither check and any line at a simple fraction of the sample rate. Writes three more figures beside it: `_spectrogram.png` (whether a feature lasted or was a burst), `_drift.png` (the chip's oscillator against the host clock, with σ_y(τ) supplying the error bars) and `_timebase.png` (which clock each line is coherent on, which settles from one capture what `compare.py` needs two rates to decide). - **`compare.py`**: everything cross-capture: fractional noise in ppm of |B|, one common band for every capture, the timing model per condition, whether a line is locked to the sampling or to a frequency, whether decimating a fast capture matches a natively slow one, and |B| under each candidate correction. Label captures `CONDITION/variant=path`; `--supply CONDITION=VOLTS` attaches a rail. - **`sweep.py`**: runs `logger.py` once per target rate and tabulates what each configuration achieved. `--calibration-out` fits the timing model across the sweep and writes it as a calibration. - **`calibrate.py`**: converts a raw capture to a nanotesla CSV, applying a calibration when one is named and nothing when one is not. `--trim SECONDS` drops that much from *both* ends of a capture, since a run usually opens while the sensor is settling and closes with a hand on the rig. Nothing is dropped unless asked, and a capture shorter than 4× the trim is analysed whole with a note. ### Calibration **Nothing is corrected by default.** `calibrate.py` without `--calibration` changes units and nothing else, and the analysis tools read raw captures as they always did. The corrections are real but not settled enough to apply silently. ```bash ./.venv/bin/python sweep.py --rates 32,64,128,256 \ --calibration-out bench.json --note "unit A at 3.006 V" ./.venv/bin/python calibrate.py raw.csv -o field.csv --calibration bench.json ./.venv/bin/python compare.py --calibration bench.json a.csv b.csv ``` A calibration is **per unit and per supply**, because the chip times everything from one oscillator and that oscillator is neither the specified frequency nor fixed. It is worth having even if you never correct a field, because it makes the timing model right: with a measured pair the predicted rate matches the measured one at both cycle counts, where the nominal model is 1.9% out between them. Two corrections then become available, neither on by default: | | what it is | |---|---| | oscillator gain | `(C / reference) ^ gain_exponent`, where the exponent is **1**, which an oscillator-clocked measurement interval predicts | | cycle-count gain | `gain ∝ (cc + gain_offset_counts)`; Table 3-1 implies n = 4.086, measurement puts it near zero | Every correction arrives with a propagated standard deviation, from the timing fit and from `rate_stability()`, which measures how far the oscillator drifted during a run and reads **186 to 1241 ppm** here. Let the rig settle first. The output is a different format and `capture.py` refuses to read it, which keeps raw captures the one source of truth. Its header carries the source's SHA-256, so a derived file can be tied back to what produced it. > `Capture.rate_error` is measured against the **nominal** model, whose error is > cycle-count dependent, which makes it a configuration diagnostic. > `calibrate.py` corrects gain against a measured per-unit reference. ## 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, so conversion from the datasheet's LSB/µT is a multiply. All times are unix epoch seconds. 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**, which keeps `x/y/z` parsing as integers on every row. | 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: 1.35 probably is one period and 1.65 probably two, but deciding which needs neighbouring timestamps and assumptions, so the row carries the flag and the reader decides. `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. ## 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 | > The 90,000 counts/s is specified and holds up. **The 68.7 µs overhead does > not**: solving the model on this unit gives 40.6 µs at 3.0 V and 38.1 µs at > 3.35 V, and it moves with the supply because the same oscillator times it. Leaving TMRC faster than the cycle-count ceiling makes the sensor free-run at ~100% duty. 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. **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% | mains aliases, fs/4 artifact | *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. `characterize.py` prints the measured figure for any capture, and cycle count 200 is the fallback where its dither check shows the LSB column flattening out. The notebook has the reasoning behind the default and behind preferring to decimate a fast capture over sampling slowly. **Bus speed is independent of the rate**, appearing 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 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` | duty falls, and post-filter noise goes as `1/√duty` | | 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`. 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, which points at AVDD or the REXT timing resistor. 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 the time base | | `plot.py` | four-panel plot of a capture: X, Y, Z and the norm | | `characterize.py` | noise floor and stability: spectral density, Allan deviation, residuals, read latency, oscillator drift | | `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 | | `calibrate.py` | convert a raw capture to nanotesla, applying a per-unit calibration if one is given; also holds the calibration model | | `diagnose-comms.py` | walks USB → bus → identity → registers → BIST → live read, stopping at the first failure | | `tests/` | pytest suite; needs no hardware | | `Noise_Floor_Testing/` | the lab notebook, captures, figures and the supply analysis. Only `NOTES.md` is tracked | 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. The CH347 wire format, the RM3100 register quirks and the reasoning behind the time base are in [NOTES.md](Noise_Floor_Testing/NOTES.md#design-rationale-moved-out-of-the-readme); `tests/test_ch347.py` pins every framing form byte for byte. ## 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. --- Captures written before the current header format are unreadable and must be re-recorded; `capture.py` says so explicitly. Figures quoted in the notebook 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.