commit 2a101d013d868e497bb99dae1ad9cd6231e37119 Author: Jeremy Karst Date: Wed Aug 19 23:00:47 2026 -0400 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3a4cfd4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__ +.venv + +*.png +*.csv \ No newline at end of file diff --git a/PNI Sensor - RM3100-Sensor-Suite-User-Manual-R07-1.pdf b/PNI Sensor - RM3100-Sensor-Suite-User-Manual-R07-1.pdf new file mode 100644 index 0000000..b3a0b18 Binary files /dev/null and b/PNI Sensor - RM3100-Sensor-Suite-User-Manual-R07-1.pdf differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..e810fac --- /dev/null +++ b/README.md @@ -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.0–3.6 V (typ 3.0) | Table 3-5 | +| Absolute maximum | 3.7 V | Table 3-4 | + +The ripple limit carries **no frequency qualifier** — it is stated flat, so +switching noise and mains hum are not distinguished. + +Reference decoupling (Figures 4-1 and 4-2) is a **10 µF bulk capacitor in +parallel with 0.1 µF ceramic**, rail to ground. One rail feeds AVDD, both DVDD +pins, and I2CEN. That is the entire filtering specification — no ferrite, no +split analog/digital rails, no LDO requirement. §4.2.3 adds a placement rule: +*"Keep capacitors, especially tantalum capacitors, far away from the sensor +coils"* — a magnetic concern (ferromagnetic packaging), not an electrical one. + +**The manual gives no transfer function from ripple to field error** — no +µT-per-mV, no PSRR, no ripple-vs-noise curve. Only the 50 mV limit. + +Slow drift should matter less than fast ripple. §4.1 describes the output as +*"the difference in the time to complete the measurement for each bias"* — a +differential measurement across forward and reverse coil bias — and §2 claims +measurements are *"stable over temperature and inherently free from offset +drift."* Anything common to both half-measurements largely cancels, so supply +variation slower than one measurement cycle is rejected far better than noise +near the ~180 kHz LR oscillation. (That last step is inference from the +described architecture, not an explicit claim in the manual.) + +**This rig runs at 3.3 V, but every number in Table 3-1 is quoted at 3.0 V.** +Footnote 1: *"Other bias resistors, external timing resistors and operating +voltages may be used, but performance will differ from the values listed."* +3.3 V is comfortably in range, but the 75 LSB/µT gain this driver uses is +strictly a 3.0 V figure — a candidate scale-factor error the manual does not +quantify. Unverified here: whether the breakout carries local decoupling, and +what the CH347's 3V3 rail ripple actually measures against the 50 mV limit. + +#### SA0 / SA1 set the I2C address + +The top 5 address bits are fixed at `0b01000`; SA0 (pin 3) and SA1 (pin 28) set +the low two, giving 0x20–0x23 (§4.5). On this breakout both are strapped high, +so the sensor answers at **0x23**. These pins are shared with SPI SSN and MISO, +so a breakout may label them `SSN/SA0` and `MISO/SA1`. + +`logger.py` scans all four addresses, so no configuration is needed. + +#### DRDY (pin 23) is not connected + +Not required. The driver polls the STATUS register instead (§5.4.1), which the +manual explicitly offers as an alternative. + +## Setup + +```bash +./setup.sh +``` + +Idempotent. It installs a udev rule, creates `.venv`, and installs pyusb. It +needs `sudo` for the udev rule only. + +The rule is needed because the CH347's USB node defaults to `root:root 0664`. +It grants 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 1100–1460 Hz, but 62–87% +of those samples are duplicates, so it buys nothing real. + +### Threading + +The sampling loop does I2C and nothing else — it pushes raw counts onto a queue, +and a writer thread does unit conversion, CSV formatting, flushing and the +console display. Wall-clock timestamps are reconstructed in the writer from one +start time plus each sample's monotonic offset, rather than calling +`datetime.now()` per sample; that is both cheaper and immune to an NTP step +mid-capture. + +This is worth roughly **28%**: the same 60 s capture yielded 22,558 samples +(376 Hz) with inline writing and 28,864 (481 Hz) with the writer split off. +The queue is bounded, and any drop is counted and reported rather than +silently losing data. + +## Files + +| File | Role | +|---|---| +| `setup.sh` | udev rule, venv, pyusb | +| `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 00` | 0 | +| Write n | `AA 74 (80\|n+1) (addr<<1) 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 **25–65 µT** (Earth's field). Near +zero, railed, or wildly out of range means the decode or gain is wrong rather +than merely "data arrived". + +## Observed performance + +First working capture, 10 s at TMRC 0x96, cycle count 200, sensor stationary on +a desk next to a PC: + +``` +349 samples over 10.02 s -> 34.8 Hz + X: mean +6.473 uT sd 177 nT + Y: mean +50.914 uT sd 106 nT + Z: mean +18.944 uT sd 396 nT + |B|: mean 54.709 uT sd 245 nT + sample interval: 28.78 ms, sd 1.52 ms +``` + +- **Rate.** 34.8 Hz against a nominal ~37 Hz is in spec: §5.2.1 quotes roughly + 7% one-standard-deviation tolerance on the update rate. +- **Magnitude.** 54.7 µT sits in the expected 25–65 µT band for Earth's field, + which is the real confirmation that gain and the int24 decode are right. +- **Noise** is well above the 15 nT that Table 3-1 quotes for cycle count 200, + and is not a driver problem. Most likely proximity to a PC and the USB + adapter; a secondary candidate is ripple on the CH347's 3V3 rail against the + 50 mV limit (see Power quality above), which has not been measured. Expect far + better readings away from mains wiring and switching supplies. +- **Drift.** All three axes drifted monotonically down over the 10 s (Z most, by + about 1.3 µT), so |B| fell from 55.5 to 54.3. Consistent with thermal settling + after AVDD is first powered — coil DC resistance moves 0.4 %/°C (Table 3-3). + Worth allowing a warm-up period before trusting absolute values. + +None of this is calibrated: the figures are raw sensor output with no hard- or +soft-iron correction, so the individual axis values reflect local distortion as +much as Earth's field. + +### 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, 0–40 s | sd, 47–60 s | step across the event | +|---|---|---|---| +| X | 84 nT | 85 nT | +280 nT | +| Y | 388 nT | 376 nT | +217 nT | +| Z | 187 nT | 198 nT | +232 nT | +| \|B\| | 369 nT | 358 nT | +313 nT | + +Three things worth noting: + +- **The t ≈ 41 s step was the sensor being physically moved**, confirmed at the + time — not an electrical artefact. Note that a norm shift does *not* by itself + imply an external source changed: |B| is preserved under **rotation** in a + uniform field, but a **translation** through a field gradient samples a + different local field and changes the magnitude. Near a PC the gradients are + steep (§4.2.2: field falls off as 1/distance³), so a few centimetres is ample + to produce the +313 nT seen here. Treat |B| changes as "not a pure rotation", + nothing more. +- **Noise is strongly axis-dependent** — Y is 4.6× X (388 vs 84 nT), and stable + across the event, so it is not a consequence of the disturbance. The coils are + nominally identical, so this asymmetry points at orientation relative to a + local noise source rather than a sensor fault. X at 84 nT is within ~3× of the + 30 nT Table 3-1 quotes for cycle count 50; Y is ~13×. +- **Sample timing is jittery**: intervals average 2.08 ms (481 Hz) with sd + 1.09 ms and a 19.6 ms worst case. Fine for logging, but the irregular spacing + makes this data unsuitable for spectral analysis without resampling. + +## Status + +- [x] udev rule and venv setup +- [x] CH347 vendor protocol — verified byte-for-byte against the kernel driver +- [x] I2C bus scan — sensor found at 0x23 +- [x] Sensor identified — REVID `0x22` +- [x] Register write/read verified — cycle counts written and read back +- [x] Analog section verified — BIST reports `XOK=YOK=ZOK=1` +- [x] **Live measurements logging to CSV at ~35 Hz** + +Initial communication is complete. A proper Python API is planned; these modules +are the initial-communication milestone, deliberately kept simple. diff --git a/ch347.py b/ch347.py new file mode 100644 index 0000000..b7d247b --- /dev/null +++ b/ch347.py @@ -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)] diff --git a/characterize.py b/characterize.py new file mode 100644 index 0000000..e6b5f32 --- /dev/null +++ b/characterize.py @@ -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() diff --git a/diagnose.py b/diagnose.py new file mode 100644 index 0000000..e95cd44 --- /dev/null +++ b/diagnose.py @@ -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()) diff --git a/logger.py b/logger.py new file mode 100755 index 0000000..6bc0b02 --- /dev/null +++ b/logger.py @@ -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_.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() diff --git a/lp5907.pdf b/lp5907.pdf new file mode 100644 index 0000000..0993bf1 Binary files /dev/null and b/lp5907.pdf differ diff --git a/plot.py b/plot.py new file mode 100644 index 0000000..b15edb1 --- /dev/null +++ b/plot.py @@ -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: .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() diff --git a/rm3100.py b/rm3100.py new file mode 100644 index 0000000..981d863 --- /dev/null +++ b/rm3100.py @@ -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) diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..889f3d5 --- /dev/null +++ b/setup.sh @@ -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"