Initial commit

This commit is contained in:
Jeremy Karst 2026-08-19 23:00:47 -04:00
commit 2a101d013d
11 changed files with 1537 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
__pycache__
.venv
*.png
*.csv

Binary file not shown.

364
README.md Normal file
View file

@ -0,0 +1,364 @@
# 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.03.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 0x200x23 (§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 the `plugdev` group access:
```
SUBSYSTEM=="usb", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="55db", GROUP="plugdev", MODE="0660"
```
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 |
|---|---|---|
| `--cycle-count` | 200 | gain/resolution vs. speed; 200 → 75 LSB/µT |
| `--tmrc` | 0x96 | continuous-mode rate; 0x92 (~600 Hz) to 0x9D (~0.3 Hz) |
| `--duration` | 0 | seconds, 0 = until Ctrl-C |
| `--output` | timestamped | CSV path |
| `--address` | autodetect | skip the scan |
| `--scan-only` | — | scan and exit |
CSV columns: `timestamp_iso, elapsed_s, x_raw, y_raw, z_raw, x_uT, y_uT, z_uT,
magnitude_uT`.
```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
```
### 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 11001460 Hz, but 6287%
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 |
| `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 |
| `plot.py` | four-panel plot of a capture: X, Y, Z and the norm |
| `diagnose.py` | walks USB → bus → identity → registers → BIST → live read, stopping at the first failure |
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 — run `./setup.sh` |
| `No CH347 adapter found` | not plugged in, or not in Mode 1 |
| 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 **2565 µ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 2565 µ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.
### 60 s capture at maximum rate
`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, 040 s | sd, 4760 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**
Initial communication is complete. A proper Python API is planned; these modules
are the initial-communication milestone, deliberately kept simple.

165
ch347.py Normal file
View file

@ -0,0 +1,165 @@
"""I2C master over a CH347 USB adapter (Waveshare USB to UART/I2C/SPI/JTAG).
Knows nothing about any particular I2C device -- it only moves bytes to and
from a 7-bit address.
The CH347 in Mode 1 (UART1+I2C+SPI) presents three interfaces: two CDC-ACM
interfaces that the kernel binds for the UART, and interface 2, a vendor-class
interface carrying I2C/SPI/JTAG that no kernel driver claims. We talk to
interface 2 directly over libusb.
Framing follows the CH347 stream protocol as implemented by the
aystarik/ch347-i2c-spi-gpio Linux driver, whose USB id table entry
USB_DEVICE_INTERFACE_NUMBER(0x1a86, 0x55db, 0x02) matches this device.
"""
import usb.core
import usb.util
VENDOR_ID = 0x1A86
PRODUCT_ID = 0x55DB
INTERFACE = 2
EP_OUT = 0x06
EP_IN = 0x86
TIMEOUT_MS = 1000
# Stream protocol opcodes.
CMD_STREAM = 0xAA # start of an I2C command stream
CMD_END = 0x00 # end of stream
CMD_STA = 0x74 # emit START
CMD_STO = 0x75 # emit STOP
CMD_OUT = 0x80 # write; low 6 bits are the byte count
CMD_IN = 0xC0 # read; low 6 bits are the byte count
CMD_SET = 0x60 # set bus speed; low bits select the rate
# Bus speeds, as the low nibble of CMD_SET.
SPEED_20KHZ = 0
SPEED_100KHZ = 1
SPEED_400KHZ = 2
SPEED_750KHZ = 3
# The count field is 6 bits, and a write also spends one byte on the address.
MAX_XFER = 0x3F
ACK = 1
class CH347I2C:
"""I2C master on a CH347 adapter.
Every byte the adapter clocks out produces one status byte in the reply,
where 1 means the slave ACKed. A read reply is one address-ACK byte
followed by the payload.
"""
def __init__(self, speed=SPEED_100KHZ):
self._dev = usb.core.find(idVendor=VENDOR_ID, idProduct=PRODUCT_ID)
if self._dev is None:
raise IOError(
f"No CH347 adapter found ({VENDOR_ID:04x}:{PRODUCT_ID:04x}). "
"Is it plugged in and in Mode 1?"
)
# Deliberately no set_configuration(): the device is already configured,
# and re-setting it would reset the CDC-ACM interfaces the kernel is
# using for /dev/ttyACM*.
try:
usb.util.claim_interface(self._dev, INTERFACE)
except usb.core.USBError as exc:
raise IOError(
f"Cannot claim CH347 interface {INTERFACE}: {exc}. "
"If this is a permission error, run ./setup.sh."
) from exc
self._claimed = True
self.set_speed(speed)
def close(self):
if getattr(self, "_claimed", False):
usb.util.release_interface(self._dev, INTERFACE)
usb.util.dispose_resources(self._dev)
self._claimed = False
def __enter__(self):
return self
def __exit__(self, *exc_info):
self.close()
return False
def _xfer(self, out_bytes, in_len):
"""Send one command stream, then read in_len status/data bytes."""
written = self._dev.write(EP_OUT, bytes(out_bytes), TIMEOUT_MS)
if written != len(out_bytes):
raise IOError(
f"Short USB write to CH347: sent {written} of {len(out_bytes)} bytes"
)
if in_len == 0:
return b""
reply = bytes(self._dev.read(EP_IN, in_len, TIMEOUT_MS))
if len(reply) != in_len:
raise IOError(
f"Short USB read from CH347: got {len(reply)} of {in_len} bytes"
)
return reply
def set_speed(self, speed):
"""Select the I2C clock rate (one of the SPEED_* constants)."""
if speed not in (SPEED_20KHZ, SPEED_100KHZ, SPEED_400KHZ, SPEED_750KHZ):
raise ValueError(f"Invalid I2C speed {speed}, expected 0-3")
self._xfer([CMD_STREAM, CMD_SET | speed, CMD_END], 0)
def write(self, addr, data):
"""Write data (bytes) to a 7-bit address. Raises IOError on NACK."""
data = bytes(data)
# One byte of the transfer budget goes to the address.
if len(data) > MAX_XFER - 1:
raise ValueError(
f"Write of {len(data)} bytes exceeds the CH347 limit of {MAX_XFER - 1}"
)
packet = [CMD_STREAM, CMD_STA, CMD_OUT | (len(data) + 1), addr << 1]
packet += data
packet += [CMD_STO, CMD_END]
# One status byte per clocked-out byte: the address plus the payload.
reply = self._xfer(packet, len(data) + 1)
if reply[0] != ACK:
raise IOError(f"No ACK from I2C address 0x{addr:02x} on write")
if any(byte != ACK for byte in reply[1:]):
raise IOError(
f"I2C address 0x{addr:02x} NACKed a data byte "
f"(status {reply.hex(' ')})"
)
def read(self, addr, count):
"""Read count bytes from a 7-bit address. Raises IOError on NACK."""
if not 1 <= count <= MAX_XFER:
raise ValueError(f"Read of {count} bytes outside 1..{MAX_XFER}")
packet = [CMD_STREAM, CMD_STA, CMD_OUT | 1, (addr << 1) | 1]
# All but the last byte are ACKed by us; the final bare CMD_IN NACKs to
# tell the slave to stop.
if count > 1:
packet.append(CMD_IN | (count - 1))
packet += [CMD_IN, CMD_STO, CMD_END]
reply = self._xfer(packet, count + 1)
if reply[0] != ACK:
raise IOError(f"No ACK from I2C address 0x{addr:02x} on read")
return reply[1:]
def probe(self, addr):
"""Return True if a device ACKs its address. A NACK is not an error."""
packet = [CMD_STREAM, CMD_STA, CMD_OUT | 1, addr << 1, CMD_STO, CMD_END]
try:
return self._xfer(packet, 1)[0] == ACK
except IOError:
return False
def scan(self, first=0x08, last=0x77):
"""Return the addresses in [first, last] that respond."""
return [addr for addr in range(first, last + 1) if self.probe(addr)]

227
characterize.py Normal file
View file

@ -0,0 +1,227 @@
#!/usr/bin/env python3
"""Characterise an RM3100 capture: noise floor, spectrum, stability.
./.venv/bin/python characterize.py capture_60s.csv
Produces a four-panel figure and a text summary:
Amplitude spectral density nT/sqrt(Hz) against the 1.2 nT/sqrt(Hz) the
manual quotes (Table 3-1), and against the
white-noise level implied by the sample sd.
Allan deviation where averaging stops helping and drift takes
over -- the honest measure of a noise floor.
Residual distribution after removing a slow trend, so a non-Gaussian
tail or quantisation shows up.
Sample interval whether the timing supports spectral analysis
at all.
Timing caveat: in continuous measurement mode the sensor samples on its own
internal schedule, so the true sample instants are near-uniform even when our
reads are jittery. The spectral estimates assume uniform spacing at the mean
observed rate. That assumption holds only if no samples were missed or read
twice -- which is exactly what the sample-interval panel is there to check.
"""
import argparse
import csv
import sys
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
SURFACE = "#fcfcfb"
TEXT_PRIMARY = "#0b0b0b"
TEXT_SECONDARY = "#52514e"
GRID = "#e3e2df"
REFERENCE = "#8a8880"
# Categorical slots 1-3; three peer axes, validated all-pairs in light mode.
AXES = [("x", "X", "#2a78d6"), ("y", "Y", "#eb6834"), ("z", "Z", "#1baf7a")]
# Table 3-1: "Noise Density @ Max. Single-Axis Sample Rate".
SPEC_ASD_NT = 1.2
def load(path):
t, cols, gains = [], {a: [] for a, _, _ in AXES}, []
with open(path, newline="") as fh:
for row in csv.DictReader(fh):
t.append(float(row["elapsed_s"]))
for a, _, _ in AXES:
ut = float(row[f"{a}_uT"])
cols[a].append(ut)
if abs(ut) > 1.0:
gains.append(float(row[f"{a}_raw"]) / ut)
if len(t) < 64:
sys.exit(f"{path} has too few samples to characterise")
return (np.array(t),
{a: np.array(v) * 1000.0 for a, v in cols.items()}, # work in nT
float(np.median(gains)))
def welch_asd(v, fs, nperseg=4096):
"""Amplitude spectral density in units/sqrt(Hz) via Welch's method."""
nperseg = min(nperseg, len(v) // 4 * 2 or len(v))
step = nperseg // 2
window = np.hanning(nperseg)
# Normalisation for a one-sided PSD with this window.
scale = 1.0 / (fs * (window ** 2).sum())
segments = []
for start in range(0, len(v) - nperseg + 1, step):
seg = v[start:start + nperseg]
# Linear detrend: removes DC and any slow ramp that would smear
# energy across the low-frequency bins.
seg = seg - np.polyval(np.polyfit(np.arange(nperseg), seg, 1),
np.arange(nperseg))
spectrum = np.abs(np.fft.rfft(seg * window)) ** 2 * scale
spectrum[1:-1] *= 2.0 # fold negative frequencies
segments.append(spectrum)
psd = np.mean(segments, axis=0)
freqs = np.fft.rfftfreq(nperseg, 1.0 / fs)
return freqs[1:], np.sqrt(psd[1:]) # drop DC bin
def allan_deviation(v, fs, points=40):
"""Overlapping Allan deviation of the signal against averaging time tau."""
n = len(v)
max_m = n // 4
ms = np.unique(np.geomspace(1, max(max_m, 2), points).astype(int))
taus, devs = [], []
cumulative = np.concatenate([[0.0], np.cumsum(v)])
for m in ms:
# Bin means of length m, taken at every offset (overlapping).
means = (cumulative[m:] - cumulative[:-m]) / m
diffs = means[m:] - means[:-m]
if diffs.size < 2:
continue
taus.append(m / fs)
devs.append(np.sqrt(0.5 * np.mean(diffs ** 2)))
return np.array(taus), np.array(devs)
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("csv")
ap.add_argument("-o", "--output", default=None)
ap.add_argument("--start", type=float, default=0.0,
help="ignore samples before this elapsed time (s)")
ap.add_argument("--end", type=float, default=None,
help="ignore samples after this elapsed time (s)")
args = ap.parse_args()
t, data, gain = load(args.csv)
keep = t >= args.start
if args.end is not None:
keep &= t <= args.end
t, data = t[keep], {a: v[keep] for a, v in data.items()}
if len(t) < 64:
sys.exit("selected window has too few samples")
duration = t[-1] - t[0]
fs = (len(t) - 1) / duration
intervals = np.diff(t)
lsb_nt = 1000.0 / gain
print(f"{args.csv}: {len(t):,} samples over {duration:.2f} s")
print(f" mean rate {fs:.1f} Hz interval {intervals.mean()*1000:.3f} ms "
f"+/- {intervals.std()*1000:.3f} ms max {intervals.max()*1000:.1f} ms")
print(f" gain {gain:.2f} LSB/uT -> 1 LSB = {lsb_nt:.1f} nT")
print(f" Nyquist {fs/2:.1f} Hz\n")
print("axis sd p2p sd/LSB white-noise ASD median ASD")
fig, axs = plt.subplots(2, 2, figsize=(13.5, 9), dpi=150)
fig.patch.set_facecolor(SURFACE)
for ax in axs.flat:
ax.set_facecolor(SURFACE)
for key, label, color in AXES:
v = data[key]
sd = v.std()
# A flat (white) spectrum of this sd would sit at this level.
implied = sd / np.sqrt(fs / 2)
freqs, asd = welch_asd(v, fs)
axs[0, 0].loglog(freqs, asd, color=color, linewidth=1.2,
label=label, alpha=0.85)
taus, devs = allan_deviation(v, fs)
axs[0, 1].loglog(taus, devs, color=color, linewidth=1.6, label=label)
# Detrend before the histogram so slow drift does not masquerade as
# a fat tail.
resid = v - np.polyval(np.polyfit(t, v, 3), t)
axs[1, 0].hist(resid, bins=120, histtype="step", linewidth=1.4,
color=color, label=label, density=True)
print(f"{label:4s} {sd:8.1f} {v.max()-v.min():9.1f} nT "
f"{sd/lsb_nt:7.2f} {implied:9.2f} nT/rtHz "
f"{np.median(asd):9.2f} nT/rtHz")
a = axs[0, 0]
a.axhline(SPEC_ASD_NT, color=REFERENCE, linestyle="--", linewidth=1.2)
a.annotate(f"Table 3-1 spec {SPEC_ASD_NT} nT/√Hz", xy=(freqs[1], SPEC_ASD_NT),
xytext=(0, 5), textcoords="offset points",
color=REFERENCE, fontsize=9)
a.set_title("Amplitude spectral density", loc="left",
color=TEXT_PRIMARY, fontsize=12, fontweight="bold", pad=8)
a.set_xlabel("frequency (Hz)"); a.set_ylabel("nT/√Hz")
a = axs[0, 1]
a.set_title("Allan deviation", loc="left",
color=TEXT_PRIMARY, fontsize=12, fontweight="bold", pad=8)
a.set_xlabel("averaging time τ (s)"); a.set_ylabel("σ (nT)")
a.annotate("slope −½ = white noise; upturn = drift",
xy=(0.02, 0.04), xycoords="axes fraction",
color=TEXT_SECONDARY, fontsize=9)
a = axs[1, 0]
a.set_title("Residual distribution (cubic trend removed)", loc="left",
color=TEXT_PRIMARY, fontsize=12, fontweight="bold", pad=8)
a.set_xlabel("nT"); a.set_ylabel("density")
a = axs[1, 1]
a.hist(intervals * 1000, bins=120, color=REFERENCE)
a.set_yscale("log")
a.axvline(1000 / fs, color=TEXT_PRIMARY, linestyle="--", linewidth=1.2)
a.annotate(f"mean {1000/fs:.2f} ms", xy=(1000 / fs, 1),
xytext=(6, 0), textcoords="offset points",
color=TEXT_PRIMARY, fontsize=9)
a.set_title("Sample interval", loc="left",
color=TEXT_PRIMARY, fontsize=12, fontweight="bold", pad=8)
a.set_xlabel("ms"); a.set_ylabel("count")
for ax in axs.flat:
ax.grid(True, which="both", color=GRID, linewidth=0.7)
ax.set_axisbelow(True)
for side in ("top", "right"):
ax.spines[side].set_visible(False)
for side in ("left", "bottom"):
ax.spines[side].set_color(GRID)
ax.tick_params(colors=TEXT_SECONDARY, labelsize=9, length=0)
ax.xaxis.label.set_color(TEXT_SECONDARY)
ax.yaxis.label.set_color(TEXT_SECONDARY)
for ax in (axs[0, 0], axs[0, 1], axs[1, 0]):
ax.legend(frameon=False, fontsize=9, labelcolor=TEXT_SECONDARY)
fig.suptitle("RM3100 noise characterisation", color=TEXT_PRIMARY,
fontsize=15, fontweight="bold", y=0.985)
fig.text(0.5, 0.945,
f"{len(t):,} samples, {duration:.1f} s at {fs:.0f} Hz, "
f"1 LSB = {lsb_nt:.1f} nT. Spectra assume uniform sampling at the "
f"mean rate (see interval panel).",
color=TEXT_SECONDARY, fontsize=10, ha="center")
fig.tight_layout(rect=[0, 0, 1, 0.935])
out = args.output or args.csv.rsplit(".", 1)[0] + "_noise.png"
fig.savefig(out, facecolor=SURFACE)
print(f"\n-> {out}")
if __name__ == "__main__":
main()

121
diagnose.py Normal file
View file

@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Hardware diagnostics for the RM3100 / CH347 link.
Walks the signal chain from USB inwards and stops at the first thing that is
broken, so a failure points at a specific wire rather than "it doesn't work":
adapter -> I2C bus -> sensor identity -> registers -> analog section
Usage: ./.venv/bin/python diagnose.py
"""
import sys
import time
import ch347
import rm3100
# BIST with STE=1, BW=11 (4 sleep oscillation cycles), BP=11 (4 LR periods):
# the most forgiving self-test settings (manual Tables 5-6, 5-7).
BIST_RUN = 0x8F
def check(label, ok, detail=""):
print(f" [{'OK ' if ok else 'FAIL'}] {label}" + (f" -- {detail}" if detail else ""))
return ok
def main():
print("1. USB adapter")
try:
bus = ch347.CH347I2C()
except IOError as exc:
check("open CH347", False, str(exc))
print("\n-> Run ./setup.sh, or check the adapter is plugged in and in Mode 1.")
return 1
check("open CH347 and set I2C speed", True, "interface 2 claimed, 100 kHz")
with bus:
print("\n2. I2C bus")
found = bus.scan()
if not check("devices respond", bool(found),
", ".join(f"0x{a:02x}" for a in found) if found else "bus is silent"):
print("\n-> Nothing ACKs. In order of likelihood:")
print(" - I2CEN not tied HIGH, so the chip is in SPI mode (most common)")
print(" - SDA/SCL swapped")
print(" - missing pull-up resistors on SDA and SCL")
print(" - module not powered")
return 1
candidates = [a for a in found if a in rm3100.RM3100.ADDRESSES]
if not check("an address in the RM3100 range 0x20-0x23", bool(candidates),
", ".join(f"0x{a:02x}" for a in candidates) if candidates
else "responders are not RM3100 addresses"):
return 1
sensor = rm3100.RM3100(bus, candidates[0])
print(f"\n3. Sensor identity (at 0x{sensor.address:02x})")
revid = sensor.revid()
check("REVID", revid == rm3100.EXPECTED_REVID,
f"0x{revid:02x}" + ("" if revid == rm3100.EXPECTED_REVID
else f", expected 0x{rm3100.EXPECTED_REVID:02x}"))
print("\n4. Register read/write")
sensor.stop_cmm()
time.sleep(0.05)
original = sensor.get_cycle_counts()
sensor.set_cycle_counts(100)
readback = sensor.get_cycle_counts()
ok = readback == (100, 100, 100)
check("cycle counts write then read back", ok, f"wrote 100, read {readback}")
sensor.set_cycle_counts(original[0] or rm3100.DEFAULT_CYCLE_COUNT)
if not ok:
print("\n-> Registers are unreliable. Try a lower bus speed or shorter wires.")
return 1
print("\n5. Analog section (BIST -- drives the sensor coils)")
sensor.write_reg(rm3100.REG_BIST, [BIST_RUN])
sensor.write_reg(rm3100.REG_POLL, [rm3100.POLL_ALL_AXES])
time.sleep(0.5)
bist = sensor.read_reg(rm3100.REG_BIST)[0]
sensor.write_reg(rm3100.REG_BIST, [0x00])
axes = {"X": (bist >> 4) & 1, "Y": (bist >> 5) & 1, "Z": (bist >> 6) & 1}
all_ok = all(axes.values())
check("LR oscillators", all_ok,
f"BIST=0x{bist:02x} " + " ".join(f"{k}OK={v}" for k, v in axes.items()))
if not all_ok:
print("\n-> The coils are not oscillating, though I2C is fine. Check:")
if not any(axes.values()):
print(" - AVDD/AVSS not connected (most common when ALL axes fail).")
print(" Registers run on DVDD alone, so I2C works while the analog")
print(" section is dead. Tie AVDD to the same 3V3 rail as DVDD")
print(" (they must stay within 0.1 V), and AVSS to ground.")
print(" - REXT timing resistor (33k) missing")
else:
print(f" - coil wiring for {', '.join(k for k, v in axes.items() if not v)}")
return 1
print("\n6. Live measurement")
sensor.configure()
sensor.set_rate(0x96)
sensor.start_cmm()
try:
ready = sensor.wait_for_data(timeout=2.0)
if not check("DRDY asserts", ready, "" if ready else "no data within 2 s"):
return 1
counts, ut = sensor.read_measurements()
magnitude = sum(v * v for v in ut) ** 0.5
check("field magnitude is plausible", 25 <= magnitude <= 65,
f"{magnitude:.1f} uT (Earth's field is 25-65)")
print(f" raw {counts}")
print(f" X {ut[0]:+.3f} Y {ut[1]:+.3f} Z {ut[2]:+.3f} uT")
finally:
sensor.stop_cmm()
print("\nAll checks passed -- the link is fully working.")
return 0
if __name__ == "__main__":
sys.exit(main())

264
logger.py Executable file
View file

@ -0,0 +1,264 @@
#!/usr/bin/env python3
"""Log RM3100 magnetometer data over a CH347 USB-I2C adapter.
Run ./setup.sh first to install the udev rule and create the virtualenv, then:
./.venv/bin/python logger.py --duration 10
./.venv/bin/python logger.py --scan-only
The sampling loop is kept clear of everything that is not I2C traffic: raw
counts go onto a queue, and a writer thread does the unit conversion, CSV
formatting and console display. At the rates the sensor can reach (~540 Hz at
cycle count 50) that formatting work is otherwise the bottleneck, not the bus.
"""
import argparse
import csv
import math
import queue
import sys
import threading
import time
from datetime import datetime, timedelta, timezone
import ch347
import rm3100
CSV_FIELDS = [
"timestamp_iso", "elapsed_s",
"x_raw", "y_raw", "z_raw",
"x_uT", "y_uT", "z_uT",
"magnitude_uT",
]
BUS_SPEEDS = {
20: ch347.SPEED_20KHZ,
100: ch347.SPEED_100KHZ,
400: ch347.SPEED_400KHZ,
750: ch347.SPEED_750KHZ,
}
CONSOLE_REFRESH_S = 0.05
FLUSH_INTERVAL_S = 0.5
# Bounded so a stalled writer degrades predictably instead of exhausting memory
# on a long run. Far above the depth a healthy writer ever reaches.
QUEUE_MAX = 200_000
_SENTINEL = object()
def parse_args():
p = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--cycle-count", type=int, default=rm3100.DEFAULT_CYCLE_COUNT,
help="cycle count per axis; lower is faster but noisier. "
"50 allows ~540 Hz, 200 allows ~150 Hz (default: %(default)s)")
p.add_argument("--tmrc", type=lambda s: int(s, 0), default=0x96,
help="continuous-mode rate register, 0x92 (fastest) to 0x9D "
"(slowest) (default: 0x96, ~37 Hz)")
p.add_argument("--duration", type=float, default=0.0,
help="seconds to log, or 0 to run until Ctrl-C (default: %(default)s)")
p.add_argument("--output", default=None,
help="CSV output path (default: rm3100_<timestamp>.csv)")
p.add_argument("--address", type=lambda s: int(s, 0), default=None,
help="I2C address, skipping the scan (default: autodetect)")
p.add_argument("--bus-speed", type=int, choices=[20, 100, 400, 750], default=100,
help="I2C bus speed in kHz; 400 measured best for high sample "
"rates (default: %(default)s)")
p.add_argument("--scan-only", action="store_true",
help="scan the bus, report what responded, and exit")
return p.parse_args()
def find_sensor(bus, address):
"""Locate the RM3100, or exit with wiring guidance."""
if address is not None:
print(f"Using I2C address 0x{address:02x} (scan skipped)")
return address
candidates = [a for a in rm3100.RM3100.ADDRESSES if bus.probe(a)]
if not candidates:
print("No RM3100 responded at 0x20-0x23.", file=sys.stderr)
others = bus.scan()
if others:
print("Other devices on the bus: "
+ ", ".join(f"0x{a:02x}" for a in others), file=sys.stderr)
else:
print("Nothing responded anywhere on the bus. Check, in order:\n"
" - I2CEN tied high (otherwise the chip stays in SPI mode)\n"
" - the adapter's voltage jumper (the RM3100 wants ~3.3 V)\n"
" - SDA/SCL not swapped\n"
" - SDA and SCL pull-up resistors present", file=sys.stderr)
sys.exit(1)
if len(candidates) > 1:
print("Multiple devices responded at "
+ ", ".join(f"0x{a:02x}" for a in candidates)
+ "; use --address to pick one.", file=sys.stderr)
sys.exit(1)
print(f"Found a device at 0x{candidates[0]:02x}")
return candidates[0]
def start_sensor(sensor, cycle_count, tmrc):
"""Identify and configure the sensor, printing each step."""
revid = sensor.revid()
if revid == rm3100.EXPECTED_REVID:
print(f"REVID 0x{revid:02x} -- RM3100 confirmed")
else:
print(f"WARNING: REVID 0x{revid:02x}, expected "
f"0x{rm3100.EXPECTED_REVID:02x}", file=sys.stderr)
sensor.set_cycle_counts(cycle_count)
readback = sensor.get_cycle_counts()
if readback != (cycle_count,) * 3:
print(f"ERROR: cycle count read back as {readback}, expected "
f"{(cycle_count,) * 3}", file=sys.stderr)
sys.exit(1)
gain = rm3100.gain_lsb_per_ut(cycle_count)
print(f"Cycle counts set to {readback} -- gain {gain:.1f} LSB/uT")
sensor.configure()
sensor.set_rate(tmrc)
rate = rm3100.TMRC_RATES[tmrc]
print(f"Rate register 0x{tmrc:02x} -- requesting about {rate:g} Hz")
sensor.start_cmm()
print("Continuous measurement mode started")
return rate
def writer_thread(q, path, gain, start_wall, stats):
"""Drain raw samples: convert, format, write CSV, drive the console.
Everything here is deliberately off the sampling thread.
"""
with open(path, "w", newline="") as handle:
out = csv.writer(handle)
out.writerow(CSV_FIELDS)
last_print = 0.0
last_flush = time.monotonic()
while True:
item = q.get()
if item is _SENTINEL:
break
elapsed, cx, cy, cz = item
ux, uy, uz = cx / gain, cy / gain, cz / gain
magnitude = math.sqrt(ux * ux + uy * uy + uz * uz)
# Wall-clock is reconstructed from the monotonic offset rather than
# sampled per reading: one fewer syscall in the hot path, and immune
# to NTP steps mid-capture.
stamp = (start_wall + timedelta(seconds=elapsed)).isoformat()
out.writerow([stamp, f"{elapsed:.5f}", cx, cy, cz,
f"{ux:.4f}", f"{uy:.4f}", f"{uz:.4f}",
f"{magnitude:.4f}"])
stats["rows"] += 1
now = time.monotonic()
if now - last_flush >= FLUSH_INTERVAL_S:
handle.flush()
last_flush = now
if now - last_print >= CONSOLE_REFRESH_S:
last_print = now
print(f"\r{elapsed:8.2f}s X {ux:+9.3f} Y {uy:+9.3f} "
f"Z {uz:+9.3f} |B| {magnitude:8.3f} uT "
f"({stats['rows']} samples, "
f"{stats['rows'] / max(elapsed, 1e-9):.0f} Hz)",
end="", flush=True)
handle.flush()
def sample_loop(sensor, q, duration, rate, stats):
"""Read the sensor as fast as it produces data. I2C and nothing else."""
sample_timeout = max(2.0, 5.0 / rate)
read_raw = sensor.read_raw
data_ready = sensor.data_ready
monotonic = time.monotonic
put = q.put_nowait
start = monotonic()
timeouts = 0
deadline = start + duration if duration > 0 else float("inf")
while monotonic() < deadline:
if not data_ready():
if monotonic() - start > sample_timeout and stats["rows"] == 0:
timeouts += 1
print(f"\nWARNING: no data ready within {sample_timeout:.1f} s",
file=sys.stderr)
if timeouts >= 3:
print("Giving up after 3 consecutive timeouts.", file=sys.stderr)
return
continue
cx, cy, cz = read_raw()
try:
put((monotonic() - start, cx, cy, cz))
except queue.Full:
stats["dropped"] += 1
def main():
args = parse_args()
if args.cycle_count < 1 or args.cycle_count > 0xFFFF:
sys.exit(f"--cycle-count {args.cycle_count} outside 1..65535")
if args.tmrc not in rm3100.TMRC_RATES:
sys.exit(f"--tmrc 0x{args.tmrc:02x} is not a valid rate register value")
try:
bus = ch347.CH347I2C(BUS_SPEEDS[args.bus_speed])
except IOError as exc:
sys.exit(str(exc))
with bus:
print(f"CH347 adapter opened, I2C at {args.bus_speed} kHz")
if args.scan_only:
found = bus.scan()
print("Devices found: " + ", ".join(f"0x{a:02x}" for a in found)
if found else "No devices responded on the bus.")
return
address = find_sensor(bus, args.address)
sensor = rm3100.RM3100(bus, address)
rate = start_sensor(sensor, args.cycle_count, args.tmrc)
path = args.output or datetime.now().strftime("rm3100_%Y%m%d_%H%M%S.csv")
print(f"Logging to {path} -- Ctrl-C to stop\n")
q = queue.Queue(maxsize=QUEUE_MAX)
stats = {"rows": 0, "dropped": 0}
writer = threading.Thread(
target=writer_thread,
args=(q, path, rm3100.gain_lsb_per_ut(args.cycle_count),
datetime.now(timezone.utc), stats),
daemon=True)
writer.start()
try:
sample_loop(sensor, q, args.duration, rate, stats)
except KeyboardInterrupt:
pass
finally:
q.put(_SENTINEL)
writer.join(timeout=30)
print()
try:
sensor.stop_cmm()
print("Continuous measurement mode stopped")
except IOError as exc:
print(f"WARNING: could not stop CMM: {exc}", file=sys.stderr)
if stats["dropped"]:
print(f"WARNING: dropped {stats['dropped']} samples -- the writer "
"could not keep up", file=sys.stderr)
print(f"Wrote {stats['rows']} samples to {path}")
if __name__ == "__main__":
main()

BIN
lp5907.pdf Normal file

Binary file not shown.

156
plot.py Normal file
View file

@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""Plot an RM3100 capture: X, Y, Z and the norm of the three.
./.venv/bin/python plot.py capture_60s.csv
./.venv/bin/python plot.py capture_60s.csv -o out.png
Small multiples rather than one shared axis: the three axes sit at very
different DC offsets, so a single scale would flatten the variation that
matters. Each panel therefore has its own y-scale -- read the panels
independently, and note the per-panel mean/sd annotation for context.
"""
import argparse
import csv
import sys
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
# Light-mode design tokens.
SURFACE = "#fcfcfb"
TEXT_PRIMARY = "#0b0b0b"
TEXT_SECONDARY = "#52514e"
GRID = "#e3e2df"
# Categorical slots 1-3 for the three peer axes. Validated all-pairs in light
# mode (worst CVD dE 9.2, normal-vision 24.0). The norm is a derived quantity,
# not a fourth peer, so it takes neutral ink instead of a competing hue -- which
# also keeps the categorical set at the three slots that validate for small
# multiples.
SERIES = [
("x_uT", "X axis", "#2a78d6"),
("y_uT", "Y axis", "#eb6834"),
("z_uT", "Z axis", "#1baf7a"),
(None, "Norm |B| = sqrt(X^2 + Y^2 + Z^2)", TEXT_PRIMARY),
]
def load(path):
t, x, y, z, gains = [], [], [], [], []
with open(path, newline="") as fh:
for row in csv.DictReader(fh):
t.append(float(row["elapsed_s"]))
x.append(float(row["x_uT"]))
y.append(float(row["y_uT"]))
z.append(float(row["z_uT"]))
# Recover the gain from the raw/uT ratio so the caption reports the
# settings actually used rather than an assumption.
for axis in "xyz":
ut = float(row[f"{axis}_uT"])
if abs(ut) > 1.0:
gains.append(float(row[f"{axis}_raw"]) / ut)
if not t:
sys.exit(f"{path} contains no samples")
gain = float(np.median(gains)) if gains else float("nan")
return np.array(t), np.array(x), np.array(y), np.array(z), gain
def rolling_mean(v, window):
"""Centred moving average that stays smooth all the way to both ends.
A plain convolution tapers toward zero at the edges. Dividing by the number
of samples that actually contributed gives a true partial-window mean
instead, so the ends carry no artefact.
"""
if window < 2:
return v
kernel = np.ones(window)
total = np.convolve(v, kernel, mode="same")
count = np.convolve(np.ones_like(v), kernel, mode="same")
return total / count
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("csv", help="capture written by logger.py")
ap.add_argument("-o", "--output", default=None, help="PNG path (default: <csv>.png)")
ap.add_argument("--smooth", type=float, default=1.0,
help="moving-average window in seconds, 0 to disable "
"(default: %(default)s)")
args = ap.parse_args()
t, x, y, z, gain = load(args.csv)
# Inverse of rm3100.gain_lsb_per_ut().
cycle_count = (gain - 1.5) / 0.3671
norm = np.sqrt(x**2 + y**2 + z**2)
series = {"x_uT": x, "y_uT": y, "z_uT": z, None: norm}
duration = t[-1] - t[0]
rate = len(t) / duration if duration > 0 else float("nan")
window = max(1, int(round(args.smooth * rate))) if args.smooth > 0 else 0
fig, axes = plt.subplots(4, 1, figsize=(12, 9.5), sharex=True, dpi=150)
fig.patch.set_facecolor(SURFACE)
for ax, (key, label, color) in zip(axes, SERIES):
v = series[key]
ax.set_facecolor(SURFACE)
# Raw trace kept thin and translucent: at ~376 Hz there are far more
# samples than pixels, so a full-weight line would read as a solid band.
ax.plot(t, v, color=color, linewidth=0.4, alpha=0.30,
solid_capstyle="round", rasterized=True)
if window > 1:
ax.plot(t, rolling_mean(v, window), color=color, linewidth=1.6,
solid_capstyle="round")
ax.set_ylabel("µT", color=TEXT_SECONDARY, fontsize=10)
# Direct label instead of a legend: one series per panel, so the title
# names it. This is also the relief the palette's contrast WARN requires.
ax.set_title(label, color=TEXT_PRIMARY, fontsize=12, loc="left",
pad=8, fontweight="bold")
ax.annotate(f"mean {v.mean():.3f} sd {v.std() * 1000:.0f} nT "
f"span {v.max() - v.min():.3f} µT",
xy=(1.0, 1.0), xycoords="axes fraction",
xytext=(0, 8), textcoords="offset points",
ha="right", va="bottom",
color=TEXT_SECONDARY, fontsize=9)
ax.grid(True, axis="y", color=GRID, linewidth=0.8)
ax.set_axisbelow(True)
for side in ("top", "right"):
ax.spines[side].set_visible(False)
for side in ("left", "bottom"):
ax.spines[side].set_color(GRID)
ax.spines[side].set_linewidth(0.8)
ax.tick_params(colors=TEXT_SECONDARY, labelsize=9, length=0)
axes[-1].set_xlabel("elapsed (s)", color=TEXT_SECONDARY, fontsize=10)
axes[-1].set_xlim(t[0], t[-1])
smooth_note = (f"; {args.smooth:g} s moving average over translucent raw trace"
if window > 1 else "")
fig.suptitle("RM3100 magnetometer capture", color=TEXT_PRIMARY,
fontsize=15, fontweight="bold", x=0.5, y=0.985)
fig.text(0.5, 0.955,
f"{len(t):,} samples over {duration:.1f} s ({rate:.0f} Hz), "
f"cycle count {cycle_count:.0f} ({gain:.1f} LSB/µT)"
f"{smooth_note}. Panels have independent y-scales.",
color=TEXT_SECONDARY, fontsize=10, ha="center")
fig.tight_layout(rect=[0, 0, 1, 0.945])
out = args.output or args.csv.rsplit(".", 1)[0] + ".png"
fig.savefig(out, facecolor=SURFACE)
print(f"{len(t):,} samples, {duration:.2f} s, {rate:.1f} Hz -> {out}")
for key, label, _ in SERIES:
v = series[key]
print(f" {label.split()[0]:5s} mean {v.mean():+9.3f} uT "
f"sd {v.std()*1000:6.1f} nT span {v.max()-v.min():6.3f} uT")
if __name__ == "__main__":
main()

157
rm3100.py Normal file
View file

@ -0,0 +1,157 @@
"""PNI RM3100 geomagnetic sensor driver.
Register numbers and sequences follow the RM3100 & RM2100 Sensor Suite User
Manual (Doc 1017252 R07), section 5.
Knows nothing about USB: the bus object need only provide write(addr, data)
and read(addr, count).
"""
import time
# Register addresses (manual Table 5-1).
REG_POLL = 0x00 # single measurement trigger
REG_CMM = 0x01 # continuous measurement mode
REG_CCX = 0x04 # cycle counts, 6 bytes: CCX, CCY, CCZ as uint16 big-endian
REG_TMRC = 0x0B # continuous mode update rate
REG_MX = 0x24 # measurements, 9 bytes: X, Y, Z as int24 big-endian
REG_BIST = 0x33
REG_STATUS = 0x34 # bit 7 = DRDY
REG_HSHAKE = 0x35
REG_REVID = 0x36
# The manual's own examples (sections 5.7.2 and 5.8.3) use 0x79 for "measure all
# three axes, raise DRDY once the whole sequence is done", even though Table 5-1
# describes bit 3 as reserved-zero. Follow the examples.
CMM_ALL_AXES = 0x79
CMM_OFF = 0x00
POLL_ALL_AXES = 0x70
# HSHAKE with DRC1=1, DRC0=0: DRDY is cleared by reading the measurement
# registers, but *not* by an arbitrary register write. The 0x1B default has
# DRC0=1, which would mean the pointer write needed to read STATUS clears the
# very flag we are about to sample, so polling could never observe it set.
HSHAKE_DRDY_ON_READ_ONLY = 0x0A
# TMRC values (manual Table 5-4), mapped to their approximate rates in Hz.
TMRC_RATES = {
0x92: 600.0, 0x93: 300.0, 0x94: 150.0, 0x95: 75.0,
0x96: 37.0, 0x97: 18.0, 0x98: 9.0, 0x99: 4.5,
0x9A: 2.3, 0x9B: 1.2, 0x9C: 0.6, 0x9D: 0.3,
}
STATUS_DRDY = 0x80
EXPECTED_REVID = 0x22
DEFAULT_CYCLE_COUNT = 200
def gain_lsb_per_ut(cycle_count):
"""Sensitivity in LSB per microtesla for a given cycle count.
Linear fit to manual Table 3-1, which quotes 20, 38 and 75 LSB/uT at cycle
counts of 50, 100 and 200; this reproduces all three to within a count.
"""
return 0.3671 * cycle_count + 1.5
def decode_measurements(data):
"""Decode 9 bytes from REG_MX into (x, y, z) signed counts.
Each axis is 24-bit two's complement, most significant byte first.
"""
if len(data) != 9:
raise ValueError(f"Expected 9 measurement bytes, got {len(data)}")
return tuple(
int.from_bytes(data[i:i + 3], "big", signed=True) for i in (0, 3, 6)
)
class RM3100:
"""An RM3100 on an I2C bus."""
# The top 5 bits of the address are fixed at 0b01000; SA1/SA0 are strapped
# on the module, so any of these four is possible (manual section 4.5).
ADDRESSES = range(0x20, 0x24)
def __init__(self, bus, address):
self.bus = bus
self.address = address
self.cycle_count = DEFAULT_CYCLE_COUNT
def read_reg(self, reg, count=1):
"""Read count bytes starting at reg, using the sensor's auto-increment.
The pointer write is a separate transaction terminated by STOP rather
than a repeated START, which is exactly what the manual's I2C read
diagrams (sections 4.5.2 and 5.8.4) specify.
"""
self.bus.write(self.address, [reg])
return self.bus.read(self.address, count)
def write_reg(self, reg, data):
self.bus.write(self.address, bytes([reg]) + bytes(data))
def revid(self):
return self.read_reg(REG_REVID)[0]
def set_cycle_counts(self, count):
"""Set all three axes to the same cycle count."""
if not 0 <= count <= 0xFFFF:
raise ValueError(f"Cycle count {count} outside 0..65535")
self.write_reg(REG_CCX, count.to_bytes(2, "big") * 3)
self.cycle_count = count
def get_cycle_counts(self):
"""Read back (ccx, ccy, ccz)."""
data = self.read_reg(REG_CCX, 6)
return tuple(
int.from_bytes(data[i:i + 2], "big") for i in (0, 2, 4)
)
def set_rate(self, tmrc):
if tmrc not in TMRC_RATES:
raise ValueError(
f"TMRC 0x{tmrc:02x} not one of "
f"{', '.join(f'0x{v:02x}' for v in TMRC_RATES)}"
)
self.write_reg(REG_TMRC, [tmrc])
def configure(self):
"""Put DRDY into a state where polling STATUS actually works."""
self.write_reg(REG_HSHAKE, [HSHAKE_DRDY_ON_READ_ONLY])
def start_cmm(self):
self.write_reg(REG_CMM, [CMM_ALL_AXES])
def stop_cmm(self):
self.write_reg(REG_CMM, [CMM_OFF])
def data_ready(self):
return bool(self.read_reg(REG_STATUS)[0] & STATUS_DRDY)
def wait_for_data(self, timeout=2.0, interval=0.001):
"""Block until DRDY is set. Returns False if timeout elapses first."""
deadline = time.monotonic() + timeout
while True:
if self.data_ready():
return True
if time.monotonic() >= deadline:
return False
time.sleep(interval)
def read_raw(self):
"""Return (x, y, z) as signed counts -- the fast path.
Callers logging at high rates should use this and defer the microtesla
conversion, so the sampling loop does I2C and nothing else.
"""
return decode_measurements(self.read_reg(REG_MX, 9))
def read_measurements(self):
"""Return ((x, y, z) counts, (x, y, z) microtesla)."""
counts = self.read_raw()
gain = gain_lsb_per_ut(self.cycle_count)
return counts, tuple(c / gain for c in counts)

78
setup.sh Executable file
View file

@ -0,0 +1,78 @@
#!/usr/bin/env bash
#
# One-time system setup for the CH347 USB-I2C adapter and the RM3100 logger.
# Idempotent: safe to re-run.
#
# Needs sudo for exactly one thing: installing the udev rule that grants the
# plugdev group access to the adapter's USB node.
set -euo pipefail
VID=1a86
PID=55db
RULE_FILE=/etc/udev/rules.d/99-ch347.rules
RULE='SUBSYSTEM=="usb", ATTRS{idVendor}=="'"$VID"'", ATTRS{idProduct}=="'"$PID"'", GROUP="plugdev", MODE="0660"'
cd "$(dirname "$0")"
echo "== 1. Checking for the CH347 adapter =="
if ! lsusb -d "$VID:$PID" >/dev/null 2>&1; then
echo "ERROR: no device $VID:$PID found on the USB bus." >&2
echo " Plug in the Waveshare adapter and make sure it is in Mode 1" >&2
echo " (UART1+I2C+SPI): DTR1 pulled high, RTS1 pulled low." >&2
exit 1
fi
lsusb -d "$VID:$PID"
echo
echo "== 2. Installing udev rule =="
if [[ -f "$RULE_FILE" ]] && [[ "$(cat "$RULE_FILE")" == "$RULE" ]]; then
echo "$RULE_FILE already up to date, skipping."
else
echo "Writing $RULE_FILE (needs sudo)..."
printf '%s\n' "$RULE" | sudo tee "$RULE_FILE" >/dev/null
sudo udevadm control --reload-rules
sudo udevadm trigger --subsystem-match=usb
# udevadm trigger returns before the rule has necessarily been applied.
sudo udevadm settle
echo "Installed."
fi
if ! id -nG | tr ' ' '\n' | grep -qx plugdev; then
echo "WARNING: $(id -un) is not in the 'plugdev' group, so the rule above" >&2
echo " will not grant access. Fix with:" >&2
echo " sudo usermod -aG plugdev $(id -un)" >&2
echo " then log out and back in." >&2
fi
echo
echo "== 3. Creating virtualenv and installing pyusb =="
if [[ ! -d .venv ]]; then
python3 -m venv .venv
echo "Created .venv"
else
echo ".venv already exists"
fi
./.venv/bin/pip install --quiet --upgrade pip
./.venv/bin/pip install --quiet pyusb
echo "pyusb $(./.venv/bin/python -c 'import usb; print(usb.__version__)') installed"
echo
echo "== 4. Verifying device node permissions =="
# Resolve the bus/device path for this specific adapter.
NODE=$(lsusb -d "$VID:$PID" | head -1 |
sed -E 's|Bus ([0-9]+) Device ([0-9]+).*|/dev/bus/usb/\1/\2|')
ls -l "$NODE"
if [[ -w "$NODE" ]]; then
echo "OK: $NODE is writable by $(id -un)."
else
echo "WARNING: $NODE is not writable by $(id -un)." >&2
echo " If the group above is already 'plugdev', unplug and replug" >&2
echo " the adapter so the new rule is applied to a fresh node." >&2
fi
echo
echo "Setup complete. Run the logger with:"
echo " ./.venv/bin/python logger.py --duration 10"
echo "or, to check wiring only:"
echo " ./.venv/bin/python logger.py --scan-only"