2026-08-19 23:00:47 -04:00
# RM3100 logging over a CH347 USB-I2C adapter
2026-08-23 20:50:56 -04:00
Logs a PNI RM3100 geomagnetic sensor connected to the I²C pins of a Waveshare
USB to UART/I2C/SPI/JTAG adapter (CH347, USB `1a86:55db` ), with a time base good
enough for spectral work: the sample index is a chip-clock grid coordinate, not
a count of host reads.
2026-08-19 23:00:47 -04:00
2026-08-23 20:50:56 -04:00
Reference throughout: *RM3100 & RM2100 Sensor Suite User Manual* , Doc 1017252
R07 (`PNI Sensor - RM3100-Sensor-Suite-User-Manual-R07-1.pdf` , in this
directory). Section numbers below refer to it.
2026-08-19 23:00:47 -04:00
2026-09-02 13:20:10 -04:00
This file documents how the code works *now* . How that was arrived at, every
measurement plus a table of conclusions later measurements overturned, is in
[Noise_Floor_Testing/NOTES.md ](Noise_Floor_Testing/NOTES.md ), with the supply
and rate-scaling analysis in
[Noise_Floor_Testing/NOISE_FLOOR.md ](Noise_Floor_Testing/NOISE_FLOOR.md ).
2026-08-19 23:00:47 -04:00
2026-08-23 20:50:56 -04:00
## Quickstart
```bash
./setup.sh # udev rule + venv, sudo for the rule only
./.venv/bin/python diagnose-comms.py # walk the signal chain, stop at the first fault
./.venv/bin/python logger.py --duration 60 # capture to rm3100_< timestamp > .csv
./.venv/bin/python characterize.py rm3100_*.csv # noise floor, spectrum, stability
2026-09-02 13:20:10 -04:00
./.venv/bin/python -m pytest # the suite needs no hardware
2026-08-23 20:50:56 -04:00
```
2026-08-19 23:00:47 -04:00
2026-08-23 20:50:56 -04:00
## Hardware
Waveshare adapter in **Mode 1** (UART1 + I2C + SPI), voltage selector at **3V3** .
The RM3100 is on a breakout board. All connections below are confirmed working.
2026-08-19 23:00:47 -04:00
| Adapter | RM3100 | Notes |
|---|---|---|
| I2C VCC (3V3) | DVDD | digital supply |
2026-09-02 13:20:10 -04:00
| I2C VCC (3V3) | VDD (= AVDD) | analog supply, **required for measurements** |
2026-08-19 23:00:47 -04:00
| 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
2026-09-02 13:20:10 -04:00
negligible, ~260 µA per axis at 24 Hz (Table 3-1).
Four pins matter; two of them cause failures that are easy to misdiagnose.
- **I2CEN (pin 22) must be tied HIGH.** `HIGH = I2C, LOW = SPI` (§4.3.1), and
SDA/SCL are shared with SPI MOSI/SCLK. Left floating or low the chip stays in
SPI mode and **never ACKs its I2C address** , so the bus scan finds nothing at
all on a perfectly healthy adapter. At DVDD = 3.3 V it needs
`VIH = 0.7 × DVDD` = 2.31 V, so tie it directly to 3V3.
- **AVDD/AVSS (pins 4, 5) must be powered to take measurements.** With only DVDD
the part is **half alive** in a way that reads as working: the address
responds, REVID reads `0x22` , registers write and read back, while the analog
section is dead, DRDY never asserts and the measurement registers stay zero.
BIST reporting `XOK=YOK=ZOK=0` is the unambiguous tell. DVDD must come up
**before or with** AVDD, never after, and the two must stay within **0.1 V**
while running (§4.3.1, Table 3-5), which one shared 3V3 rail gives for free.
- **SA0 (pin 3) / SA1 (pin 28) set the address**, top five bits fixed at
`0b01000` , giving 0x20– 0x23 (§4.5). On this breakout both straps are high, so
the sensor answers at **0x23** . `logger.py` scans all four, so no configuration
is needed. The pins are shared with SPI SSN and MISO, so a breakout may label
them `SSN/SA0` and `MISO/SA1` .
- **DRDY (pin 23) is not connected.** The driver polls the STATUS register,
which the manual explicitly offers as an alternative (§5.4.1).
2026-08-23 20:50:56 -04:00
### Power
2026-08-19 23:00:47 -04:00
| Parameter | Limit | Source |
|---|---|---|
2026-08-23 20:50:56 -04:00
| 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` |
2026-08-19 23:00:47 -04:00
| Supply range | 2.0– 3.6 V (typ 3.0) | Table 3-5 |
| Absolute maximum | 3.7 V | Table 3-4 |
2026-08-23 20:50:56 -04:00
Reference decoupling (Figures 4-1, 4-2) is a **10 µF bulk capacitor in parallel
with 0.1 µF ceramic**, rail to ground, feeding AVDD, both DVDD pins and I2CEN.
2026-09-02 13:20:10 -04:00
That is the entire filtering specification: no ferrite, no split rails, no LDO
2026-08-23 20:50:56 -04:00
requirement. §4.2.3 adds a placement rule: *"Keep capacitors, especially tantalum
2026-09-02 13:20:10 -04:00
capacitors, far away from the sensor coils"*, which is a magnetic concern.
2026-08-19 23:00:47 -04:00
2026-08-23 22:11:50 -04:00
**Every number in Table 3-1 is quoted at 3.0 V** (footnote 1: *"performance will
2026-09-02 13:20:10 -04:00
differ"*), so the gain fit behind `tesla_per_count` is strictly a 3.0 V figure.
Measured on interleaved captures at 3.006 V and 3.353 V, +0.347 V moves the
sample rate by ** +4.94%** and |B| by ** − 7.1%**, with no resolvable change in
broadband noise. So **supply quality does not set the noise floor, but supply
*voltage* sets the scale**, at roughly − 20 %/V. Both effects are one thing seen
twice, and the calibrated period every capture already records is a proxy for the
rail; see [Calibration ](#calibration ).
2026-08-19 23:00:47 -04:00
## Setup
```bash
./setup.sh
```
2026-08-23 20:50:56 -04:00
Idempotent. It checks the adapter is present, installs a udev rule, creates
`.venv` with pyusb, numpy, matplotlib and pytest, then verifies the device node
is actually writable and prints whichever fix applies if it is not. `sudo` is
2026-09-02 13:20:10 -04:00
needed for the udev rule and nothing else. Python here is PEP 668
externally-managed, hence the venv.
2026-08-19 23:00:47 -04:00
2026-08-23 20:50:56 -04:00
The rule is needed because the CH347's USB node defaults to `root:root 0664` . It
grants access two ways, because no single mechanism covers every distro:
2026-08-19 23:00:47 -04:00
```
2026-08-23 20:50:56 -04:00
# /etc/udev/rules.d/60-ch347.rules
SUBSYSTEM=="usb", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="55db", TAG+="uaccess"
SUBSYSTEM=="usb", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="55db", GROUP="plugdev", MODE="0660"
2026-08-23 18:59:51 -04:00
```
2026-09-02 13:20:10 -04:00
The `60-` prefix, the two separate lines and the choice of group are all
load-bearing; getting any of them wrong installs cleanly and grants nothing.
[NOTES.md ](Noise_Floor_Testing/NOTES.md ) explains why, and covers the
atomic-Fedora traps `setup.sh` prints fixes for. `setup.sh` checks the node
itself at the end and says *which* mechanism granted access: `uaccess ACL` or
`group membership` .
2026-08-19 23:00:47 -04:00
## 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 |
|---|---|---|
2026-09-02 13:20:10 -04:00
| `--rate` | none | target Hz; derives the cycle count and TMRC. Mutually exclusive with `--cycle-count` |
| `--cycle-count` | 100 | sets both the rate and the LSB; see [Configuration ](#configuration ) |
2026-08-23 20:50:56 -04:00
| `--tmrc` | `0x92` | rate register. The default is the fastest, letting the cycle count set the rate; give one only to sample *slower* |
2026-08-19 23:00:47 -04:00
| `--duration` | 0 | seconds, 0 = until Ctrl-C |
| `--output` | timestamped | CSV path |
2026-08-23 20:50:56 -04:00
| `--address` | autodetect | skip the bus scan |
| `--bus-speed` | 750 | I²C kHz. 100 kHz would spend 42% of each period on the bus at the default cycle count; 750 spends 6% |
| `--calibrate` | 1.0 | seconds of loss-free samples used to measure the true period before recording. The run aborts if no clean stretch can be found |
2026-09-02 13:20:10 -04:00
| `--note` | none | free text recorded in the capture header, e.g. the supply under test |
2026-08-23 20:50:56 -04:00
| `--high-priority` | off | raise the sampling thread to nice − 10. Needs `CAP_SYS_NICE` , so run under `sudo` ; output files are handed back to the invoking user |
2026-09-02 13:20:10 -04:00
| `--scan-only` | none | scan and exit |
2026-08-19 23:00:47 -04:00
2026-09-02 13:20:10 -04:00
**Exit status is non-zero if the capture is compromised in any way**: a lost
2026-08-23 20:50:56 -04:00
measurement, an ambiguous gap, a truncated run, or an undrained writer. Each
prints an explanation on stderr, and the file is always valid as far as it goes.
2026-09-02 13:20:10 -04:00
A capture is analysable whether the run ended on `--duration` , on **Ctrl-C** , or
early.
2026-08-23 20:50:56 -04:00
2026-09-02 13:20:10 -04:00
Before recording, `logger.py` prints how the configuration was derived, so it
can be checked, then measures the true sample period against the host clock.
Both appear in the capture header.
2026-08-23 20:50:56 -04:00
### Analysis tools
```bash
./.venv/bin/python plot.py capture.csv # -> capture.png: X, Y, Z, |B|
./.venv/bin/python characterize.py capture.csv # -> capture_noise.png + summary
2026-08-23 22:11:50 -04:00
./.venv/bin/python characterize.py capture.csv --trim 30 # drop settling/handling
2026-08-23 20:50:56 -04:00
./.venv/bin/python compare.py --group note *.csv # A/B two conditions
2026-08-23 22:11:50 -04:00
./.venv/bin/python compare.py --trim 30 --supply LDO=3.006 --supply 3V3=3.353 \
LDO/cc100=a.csv 3V3/cc100=b.csv LDO/cc400=c.csv 3V3/cc400=d.csv -o cmp.png
2026-08-23 20:50:56 -04:00
./.venv/bin/python sweep.py --rates 10,50,150 # measure what each rate delivers
```
2026-08-23 18:16:43 -04:00
2026-09-02 13:20:10 -04:00
Each tool's `--help` carries its full reasoning; in one line each:
- **`plot.py` **: four stacked panels with independent y-scales, since the three
2026-08-23 20:50:56 -04:00
axes sit at very different DC offsets.
2026-09-02 13:20:10 -04:00
- **`characterize.py` **: spectral density against Table 3-1, Allan deviation,
residual distribution and host read latency on **fixed axes** , plus a dither
check and any line at a simple fraction of the sample rate. Writes three more
figures beside it: `_spectrogram.png` (whether a feature lasted or was a
burst), `_drift.png` (the chip's oscillator against the host clock, with
σ _y(τ) supplying the error bars) and `_timebase.png` (which clock each line is
coherent on, which settles from one capture what `compare.py` needs two rates
to decide).
- **`compare.py` **: everything cross-capture: fractional noise in ppm of |B|,
one common band for every capture, the timing model per condition, whether a
line is locked to the sampling or to a frequency, whether decimating a fast
capture matches a natively slow one, and |B| under each candidate correction.
Label captures `CONDITION/variant=path` ; `--supply CONDITION=VOLTS` attaches a
rail.
- **`sweep.py` **: runs `logger.py` once per target rate and tabulates what each
configuration achieved. `--calibration-out` fits the timing model across the
sweep and writes it as a calibration.
- **`calibrate.py` **: converts a raw capture to a nanotesla CSV, applying a
calibration when one is named and nothing when one is not.
`--trim SECONDS` drops that much from *both* ends of a capture, since a run
usually opens while the sensor is settling and closes with a hand on the rig. Nothing is
dropped unless asked, and a capture shorter than 4× the trim is analysed whole
with a note.
2026-08-23 22:11:50 -04:00
2026-08-24 18:09:19 -04:00
### Calibration
**Nothing is corrected by default.** `calibrate.py` without `--calibration`
changes units and nothing else, and the analysis tools read raw captures as they
2026-09-02 13:20:10 -04:00
always did. The corrections are real but not settled enough to apply silently.
2026-08-24 18:09:19 -04:00
```bash
./.venv/bin/python sweep.py --rates 32,64,128,256 \
--calibration-out bench.json --note "unit A at 3.006 V"
./.venv/bin/python calibrate.py raw.csv -o field.csv --calibration bench.json
./.venv/bin/python compare.py --calibration bench.json a.csv b.csv
```
A calibration is **per unit and per supply** , because the chip times everything
from one oscillator and that oscillator is neither the specified frequency nor
2026-09-02 13:20:10 -04:00
fixed. It is worth having even if you never correct a field, because it makes the
timing model right: with a measured pair the predicted rate matches the measured
one at both cycle counts, where the nominal model is 1.9% out between them. Two
corrections then become available, neither on by default:
2026-08-24 18:09:19 -04:00
2026-09-02 13:20:10 -04:00
| | what it is |
|---|---|
| oscillator gain | `(C / reference) ^ gain_exponent` , where the exponent is **1** , which an oscillator-clocked measurement interval predicts |
| cycle-count gain | `gain ∝ (cc + gain_offset_counts)` ; Table 3-1 implies n = 4.086, measurement puts it near zero |
2026-08-24 18:09:19 -04:00
2026-09-02 13:20:10 -04:00
Every correction arrives with a propagated standard deviation, from the timing
fit and from `rate_stability()` , which measures how far the oscillator drifted
during a run and reads **186 to 1241 ppm** here. Let the rig settle first.
2026-08-24 18:09:19 -04:00
2026-09-02 13:20:10 -04:00
The output is a different format and `capture.py` refuses to read it, which
keeps raw captures the one source of truth. Its header carries the source's
SHA-256, so a derived file can be tied back to what produced it.
2026-08-24 18:09:19 -04:00
> `Capture.rate_error` is measured against the **nominal** model, whose error is
2026-09-02 13:20:10 -04:00
> cycle-count dependent, which makes it a configuration diagnostic.
> `calibrate.py` corrects gain against a measured per-unit reference.
2026-08-23 20:50:56 -04:00
## Capture format
2026-08-23 18:16:43 -04:00
2026-09-02 13:20:10 -04:00
A capture stores **only irreducible facts** , the chip's sample count, the host
clock and the raw counts, behind a header of configuration. Chip time, elapsed
2026-08-23 20:50:56 -04:00
time, tesla and magnitude are all reconstructed on load by `capture.py` , so there
is exactly one source of truth for each.
2026-08-23 18:16:43 -04:00
```
# rm3100_capture: 1
2026-08-23 20:50:56 -04:00
# nominal_rate_hz: 282.53110196547465
# tmrc_nominal_hz: 600.0
# tmrc: 0x92
# cycle_count: 100
# tesla_per_count: 2.617115938236064e-08
2026-08-23 18:16:43 -04:00
# i2c_address: 0x23
2026-08-23 20:50:56 -04:00
# bus_speed_khz: 750
2026-08-23 18:16:43 -04:00
# revid: 0x22
2026-08-23 20:50:56 -04:00
# calibrated_period_s: 0.0035391156462585034
# note: bench, 3V0 LDO
2026-08-23 18:16:43 -04:00
sample_index,system_time_unix,x_raw,y_raw,z_raw,warning
0,1755930856.722866,-2765,767,378,
5,1755930856.867436,0,0,0,MISSED
13,1755930857.041244,0,0,0,MISSED AMBIGUOUS
21,1755930857.272108,-2761,769,377,AMBIGUOUS
```
2026-08-23 20:50:56 -04:00
`tesla_per_count` is written with `repr()` so it round-trips through float64
2026-09-02 13:20:10 -04:00
exactly, and is expressed per-count, so conversion from the datasheet's LSB/µT
is a multiply. All times are unix epoch seconds.
2026-08-23 20:50:56 -04:00
2026-08-23 18:16:43 -04:00
The `warning` column carries space-separated flags, empty when the row is fine,
2026-08-23 20:50:56 -04:00
and generalises to future flags. Because it already marks a row as having no
2026-09-02 13:20:10 -04:00
data, placeholders carry **zeros** , which keeps `x/y/z` parsing as integers on
every row.
2026-08-23 18:16:43 -04:00
| flag | meaning |
|---|---|
| `MISSED` | placeholder row; the measurement was never read, so it has no data. The row exists to keep `sample_index` contiguous |
| `AMBIGUOUS` | the gap ending at this row was of uncertain length, so the index may have slipped from here on |
**The two are independent.** A gap measuring 1.35 periods rounds to one, so no
2026-09-02 13:20:10 -04:00
placeholder is written, yet it sits far enough from an integer to distrust, and
2026-08-23 18:16:43 -04:00
that case carries `AMBIGUOUS` on the *real* sample ending the gap, which keeps
2026-09-02 13:20:10 -04:00
its data. A placeholder inside an uncertain gap carries both. The flag is a
confidence measure: 1.35 probably is one period and 1.65 probably two, but
deciding which needs neighbouring timestamps and assumptions, so the row carries
the flag and the reader decides.
2026-08-23 18:16:43 -04:00
2026-08-23 20:50:56 -04:00
`capture.py` interpolates `MISSED` rows on load so the uniform grid the spectra
depend on survives, and reports how many were substituted so it is never silent.
2026-08-23 18:16:43 -04:00
2026-08-23 20:50:56 -04:00
## Configuration
2026-08-19 23:00:47 -04:00
2026-08-23 20:50:56 -04:00
**Cycle count is the rate knob, not TMRC.** Two ceilings compete and the slower
one wins (§5.2.1): the cycle count sets how *long* a measurement takes, TMRC sets
how *often* one is started.
| | sets | granularity |
|---|---|---|
| cycle count | `3 × (cc/90,000 + 68.7 µs)` per measurement | continuous |
| TMRC | how often a measurement starts | factor-of-two steps |
2026-08-19 23:00:47 -04:00
2026-09-02 13:20:10 -04:00
> The 90,000 counts/s is specified and holds up. **The 68.7 µs overhead does
> not**: solving the model on this unit gives 40.6 µs at 3.0 V and 38.1 µs at
> 3.35 V, and it moves with the supply because the same oscillator times it.
2026-08-23 22:11:50 -04:00
2026-08-23 20:50:56 -04:00
Leaving TMRC faster than the cycle-count ceiling makes the sensor free-run at
2026-09-02 13:20:10 -04:00
~100% duty. Setting it slower makes the sensor idle, which costs sensitivity for
nothing: noise after filtering scales as `1/√duty` , and a measured 23% duty cost
1.43× the noise ASD against the same rate reached by cycle count alone. So the
default in every branch is the fastest TMRC, and `--rate` derives the cycle count.
2026-08-19 23:00:47 -04:00
2026-08-23 20:50:56 -04:00
**Cycle count also sets resolution**, which is the real trade:
2026-08-19 23:00:47 -04:00
2026-08-23 20:50:56 -04:00
| cc | rate | Nyquist | nT/LSB | dither | duty | |
|---|---|---|---|---|---|---|
| 50 | 534 Hz | 267 Hz | 50.37 | 0.58 | 89.0% | spectrum, thin dither |
| **100** | **283 Hz** | **141 Hz** | **26.17** | **0.79** | **94.2%** | **default** |
| 200 | 145 Hz | 73 Hz | 13.35 | 1.10 | 97.0% | resolution, 60 Hz only |
2026-09-02 13:20:10 -04:00
| 400 | 74 Hz | 37 Hz | 6.74 | 1.54 | 98.5% | mains aliases, fs/4 artifact |
2026-08-19 23:00:47 -04:00
2026-09-02 13:20:10 -04:00
*dither* is the sensor's own noise in LSB; below roughly 0.2 LSB the quantiser
2026-08-23 20:50:56 -04:00
stops being dithered and averaging no longer recovers sub-LSB resolution.
2026-09-02 13:20:10 -04:00
`characterize.py` prints the measured figure for any capture, and cycle count
200 is the fallback where its dither check shows the LSB column flattening out. The notebook has the reasoning behind the default and behind preferring
to decimate a fast capture over sampling slowly.
2026-08-19 23:00:47 -04:00
2026-09-02 13:20:10 -04:00
**Bus speed is independent of the rate**, appearing in neither mechanism. What
2026-08-23 20:50:56 -04:00
it sets is latency: how long a read takes, hence how tightly DRDY can be
timestamped and how much margin there is against a stall. Host cost is
`bus time + ~0.6 ms` of fixed USB round trip, so faster is simply better.
| bus | traffic/sample | share of a cc=100 period |
|---|---|---|
| 100 kHz | 1.500 ms | **42%** |
| 400 kHz | 0.375 ms | 11% |
| **750 kHz** | **0.200 ms** | **6%** |
Below ~0.46 Hz the 16-bit cycle-count register runs out and TMRC must set the
cadence; below cycle count 30 the manual warns of quantisation (§5.1). Both
bounds are enforced by `rm3100.plan()` .
### Configuration warnings
`rm3100.plan()` resolves a configuration, `logger.py` prints its derivation, then
2026-09-02 13:20:10 -04:00
checks it against six known traps. Each is a *silent* failure: the capture
completes, the numbers look plausible, and the defect shows up afterwards.
2026-08-23 20:50:56 -04:00
So each is reported on stderr and **nothing is fixed automatically** : changing a
setting that was asked for would hide the problem behind a configuration change.
| Warning | Trigger | Why it matters |
|---|---|---|
2026-09-02 13:20:10 -04:00
| cycle count below the recommended 50 | `cc < RECOMMENDED_MIN_CYCLE_COUNT` | duty falls, and post-filter noise goes as `1/√duty` |
2026-08-23 20:50:56 -04:00
| TMRC governs and the sensor idles | TMRC-governed, idle > 20% | idle time buys nothing; measured 1.43× the ASD at 23% duty |
| rate differs from the one requested | \|error\| > 2% | the run silently uses the ceiling, and every derived figure moves with it |
| Nyquist below 60 Hz | rate < 120 Hz | mains folds onto signal and no later filter undoes it |
| cycle count past 400 | `cc > MAX_SPEC_CYCLE_COUNT` | Table 3-1 ends there, so the printed gain and noise are extrapolated |
| bus over half the period | traffic / period > 50% | names the speed that would fit |
The aliasing one is the easiest to walk into: `--rate 32` yields a clean-looking
2.9 nT/LSB capture with 60 Hz mains sitting at 4.0 Hz, indistinguishable from
signal. Sampling fast and decimating afterwards gives the same noise floor with
the line still visible.
## Diagnostics
2026-09-02 13:20:10 -04:00
`diagnose-comms.py` walks the chain, USB → bus → identity → registers → BIST →
live read, and stops at the first failure, so a fault points at a specific wire.
2026-08-23 20:50:56 -04:00
Otherwise, symptoms map onto causes:
| Symptom | Cause |
|---|---|
2026-09-02 13:20:10 -04:00
| `Cannot claim CH347 interface 2: Access denied` | udev rule missing or not yet applied to this node; replug the adapter, then run `./setup.sh` . On Bazzite also try `sudo udevadm control --reload-rules && sudo udevadm trigger` . Do **not** reach for `sudo` : it works as a normal user, and masking a permissions problem with root only defers it |
2026-08-23 20:50:56 -04:00
| `No CH347 adapter found` | not plugged in, or not in Mode 1 |
2026-09-02 13:20:10 -04:00
| `Could not find N s of loss-free samples` | the host cannot sustain this rate; lower it (`--rate` , or a higher `--tmrc` ) or raise `--bus-speed` |
2026-08-23 20:50:56 -04:00
| `N measurement(s) were lost` | host stalls during the run. Same fixes; the capture is still usable, with explicit gaps |
2026-09-02 13:20:10 -04:00
| `N gap(s) could not be counted confidently` | the index may have slipped; re-record before doing spectral work |
| `no capture header found` | a capture predating the header format; re-record it |
2026-08-23 20:50:56 -04:00
| `sample_index is not contiguous` | the file was truncated mid-row, damaged, or hand-edited |
| `HSHAKE did not take` | the I²C write is unreliable; try a lower `--bus-speed` |
| Adapter opens, `--scan-only` finds nothing | **I2CEN not tied high** (most likely), or SDA/SCL swapped, or no bus pull-ups |
2026-09-02 13:20:10 -04:00
| Found at 0x23, REVID `0x22` , registers fine, but DRDY never sets and results are all zero | **AVDD/VDD not powered** ; confirm with BIST |
| Cycle-count read-back mismatch | bus integrity; try a lower speed |
2026-08-23 20:50:56 -04:00
**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.
2026-09-02 13:20:10 -04:00
All zeros means the coils are not oscillating, which points at AVDD or the REXT
timing resistor.
2026-08-23 20:50:56 -04:00
A healthy total field magnitude is roughly **25– 65 µT** (Earth's field). Near
zero, railed, or wildly out of range means the decode or the gain is wrong rather
than merely "data arrived".
2026-08-19 23:00:47 -04:00
## Files
| File | Role |
|---|---|
2026-08-23 20:50:56 -04:00
| `setup.sh` | udev rule, venv, dependencies, node permission check |
2026-09-02 13:20:10 -04:00
| `ch347.py` | CH347 I²C transport; USB only, knows nothing about the sensor |
| `rm3100.py` | RM3100 driver and configuration model; knows nothing about USB |
2026-08-23 20:50:56 -04:00
| `logger.py` | CLI: scan → identify → configure → calibrate → log, recording lost measurements as explicit placeholders |
2026-09-02 13:20:10 -04:00
| `capture.py` | the only capture reader; parses the header, rebuilds tesla and the time base |
2026-08-19 23:00:47 -04:00
| `plot.py` | four-panel plot of a capture: X, Y, Z and the norm |
2026-09-02 13:20:10 -04:00
| `characterize.py` | noise floor and stability: spectral density, Allan deviation, residuals, read latency, oscillator drift |
2026-08-23 20:50:56 -04:00
| `sweep.py` | sweep target rates, reporting measured rate, LSB, noise, duty and bus use per point |
2026-09-02 13:20:10 -04:00
| `compare.py` | A/B captures: fractional noise, plus the checks that separate a gain change from a moved sensor |
2026-08-24 18:09:19 -04:00
| `calibrate.py` | convert a raw capture to nanotesla, applying a per-unit calibration if one is given; also holds the calibration model |
2026-08-23 18:16:43 -04:00
| `diagnose-comms.py` | walks USB → bus → identity → registers → BIST → live read, stopping at the first failure |
2026-08-23 20:50:56 -04:00
| `tests/` | pytest suite; needs no hardware |
2026-09-02 13:20:10 -04:00
| `Noise_Floor_Testing/` | the lab notebook, captures, figures and the supply analysis. Only `NOTES.md` is tracked |
2026-08-19 23:00:47 -04:00
2026-08-23 20:50:56 -04:00
The adapter/sensor split is deliberate: `ch347.py` is a general I²C master usable
2026-08-19 23:00:47 -04:00
with any device, and `rm3100.py` needs only a bus object exposing
2026-09-02 13:20:10 -04:00
`write(addr, data)` and `read(addr, count)` , optionally `write_read()` , which it
2026-08-23 20:50:56 -04:00
prefers when available.
2026-08-19 23:00:47 -04:00
2026-09-02 13:20:10 -04:00
The CH347 wire format, the RM3100 register quirks and the reasoning behind the
time base are in
[NOTES.md ](Noise_Floor_Testing/NOTES.md#design-rationale-moved-out-of-the-readme );
`tests/test_ch347.py` pins every framing form byte for byte.
2026-08-19 23:00:47 -04:00
2026-08-23 20:50:56 -04:00
## Tests
2026-08-23 18:16:43 -04:00
```bash
2026-08-23 20:50:56 -04:00
./.venv/bin/python -m pytest # whole suite, no hardware needed
./.venv/bin/python -m pytest -k logger # one module
2026-08-23 18:16:43 -04:00
```
2026-08-23 20:50:56 -04:00
The suite fakes the hardware at three seams: `FakeUsbDevice` (libusb, so
`ch347.py` 's framing is itself under test), `FakeBus` (an I²C master with a
register map), and `FakeSensor` (a scripted DRDY timeline against a fake clock,
so a host stall of an exact size can be injected). See `tests/conftest.py` .
2026-08-23 18:59:51 -04:00
2026-08-23 20:50:56 -04:00
The miss-counting logic gets the most attention, since it is the part whose
failures are invisible downstream: a stall of 1.1 periods must lose nothing, 2.1
must record one placeholder, and 1.35 must flag the sample as `AMBIGUOUS` without
inserting anything.
2026-08-19 23:00:47 -04:00
2026-09-02 13:20:10 -04:00
---
2026-08-23 20:50:56 -04:00
Captures written before the current header format are unreadable and must be
2026-09-02 13:20:10 -04:00
re-recorded; `capture.py` says so explicitly. Figures quoted in the notebook
came from those files and stand as historical measurements only.
2026-08-23 18:16:43 -04:00
A proper Python API is planned; these modules are the initial-communication
milestone, deliberately kept simple.