Initial commit
This commit is contained in:
commit
2a101d013d
11 changed files with 1537 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
__pycache__
|
||||||
|
.venv
|
||||||
|
|
||||||
|
*.png
|
||||||
|
*.csv
|
||||||
BIN
PNI Sensor - RM3100-Sensor-Suite-User-Manual-R07-1.pdf
Normal file
BIN
PNI Sensor - RM3100-Sensor-Suite-User-Manual-R07-1.pdf
Normal file
Binary file not shown.
364
README.md
Normal file
364
README.md
Normal 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.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<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 **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.
|
||||||
165
ch347.py
Normal file
165
ch347.py
Normal 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
227
characterize.py
Normal 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
121
diagnose.py
Normal 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
264
logger.py
Executable 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
BIN
lp5907.pdf
Normal file
Binary file not shown.
156
plot.py
Normal file
156
plot.py
Normal 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
157
rm3100.py
Normal 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
78
setup.sh
Executable 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"
|
||||||
Loading…
Add table
Reference in a new issue