# 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 access two ways, because no single mechanism covers every distro: ``` SUBSYSTEM=="usb", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="55db", TAG+="uaccess", GROUP="plugdev", MODE="0660" ``` - **`TAG+="uaccess"`** makes systemd-logind put an ACL on the node for whoever is logged in at the local seat. No group, no logout, and it is the only part that works unaided on atomic Fedora. `setup.sh` triggers with `--action=add`, not the default `change`, because the `uaccess` builtin only runs on add — a change event installs the rule but grants nothing until the next replug. - **`GROUP=`/`MODE=`** is the fallback for ssh sessions and seatless systems, where there is no local seat for `uaccess` to grant to. The group is chosen from those that exist — `plugdev` is a Debian convention and absent on Fedora, and naming a group that does not exist makes udev log an error and leave the node owned by root. If neither `plugdev` nor `dialout` exists the clause is omitted entirely. #### Atomic Fedora (Bazzite, Silverblue, Kinoite) `usermod -aG dialout $USER` fails there with *"group 'dialout' does not exist"* even though `getent group dialout` finds it. Fedora ships `nss_altfiles`, so `getent` reads both `/etc/group` and `/usr/lib/group`, while `usermod` writes `/etc/group` alone — and on an rpm-ostree system the group frequently lives only in the latter. Copy the line across first: ```bash grep -E '^dialout:' /usr/lib/group | sudo tee -a /etc/group sudo usermod -aG dialout $USER # then log out and back in ``` None of that is needed for a local desktop session: `uaccess` covers it, which is why the rule carries both. `setup.sh` tests the node itself at the end and prints whichever fix applies rather than guessing up front. 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 | |---|---|---| | `--rate` | — | target Hz; derives the cycle count and TMRC. Mutually exclusive with `--cycle-count` | | `--cycle-count` | 100 | sets the rate and the LSB; see Recommended configuration | | `--tmrc` | fastest | rate register. Default lets the cycle count set the rate; give one only to sample *slower* | | `--duration` | 0 | seconds, 0 = until Ctrl-C | | `--output` | timestamped | CSV path | | `--address` | autodetect | skip the scan | | `--bus-speed` | 750 | I²C kHz; 100 would spend 42% of the period on the bus at cc=100 | | `--scan-only` | — | scan and exit | Exit status is non-zero if an interval was missed. The partial capture is kept and is valid up to that point. ### Capture format A capture stores **only irreducible facts** — the chip's sample count, the host clock, and the raw counts — behind a header of configuration. Everything else (chip time, elapsed time, tesla, magnitude) is reconstructed on load by `capture.py`, so there is exactly one source of truth for each quantity. ``` # rm3100_capture: 1 # nominal_rate_hz: 37.0 # tmrc: 0x96 # cycle_count: 200 # tesla_per_count: 1.3347570742124934e-08 # calibrated_period_s: 0.028891621... # i2c_address: 0x23 # bus_speed_khz: 400 # revid: 0x22 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 ``` The `warning` column carries space-separated flags, empty when the row is fine, and the column generalises to future flags. Because it already marks a row as having no data, placeholders carry **zeros rather than blanks** — so `x/y/z` parse as integers on every row without special-casing. | flag | meaning | |---|---| | `MISSED` | placeholder row; the measurement was never read, so it has no data. The row exists to keep `sample_index` contiguous | | `AMBIGUOUS` | the gap ending at this row was of uncertain length, so the index may have slipped from here on | **The two are independent.** A gap measuring 1.35 periods rounds to one, so no placeholder is written — yet it sits far enough from an integer to distrust, and that case carries `AMBIGUOUS` on the *real* sample ending the gap, which keeps its data. A placeholder inside an uncertain gap carries both. The flag is a confidence measure, not a claim that 1.35 and 1.65 are equally likely — 1.35 probably is one period and 1.65 probably two. Deciding which needs neighbouring timestamps and assumptions and can still be wrong, which is exactly why these rows are flagged rather than silently resolved. `tesla_per_count` is written with `repr()` so it round-trips through float64 exactly, and is expressed per-count rather than the datasheet's LSB/µT so conversion is a multiply: `tesla = count * tesla_per_count`. Rows are ~33 bytes, against ~90 for the old redundant schema. All times are unix epoch seconds; no ISO-8601 anywhere in the data. ```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 ``` ### One-step capture and analysis ```bash ./noise_floor_test.sh --duration 60 # capture, then plot + characterize ./noise_floor_test.sh # until Ctrl-C, then analyse ``` Activates the venv and forwards every flag to `logger.py`, then runs `plot.py` and `characterize.py` on the result. It defaults `--bus-speed 400` (measured best) and picks the output name so the analysis can find it; anything you pass explicitly wins. Analysis runs whether the capture ended on `--duration`, on **Ctrl-C**, or on an abort — a partial capture is still valid up to the abort. Exit status is non-zero if any stage failed, so it is usable from a scheduler. ### Timing model The chip samples on its **own internal schedule**, unaffected by bus traffic (§5.8.2 "this can run in the background"; §5.7.2 "This will not affect the measurement process"). Two consequences drive the whole design: - **Read jitter is not sample jitter.** The measurement grid is uniform even when host reads are late, so `sample_index` is a grid coordinate, not a count of reads. - **A late read returns the *newer* measurement**, not a delayed one. Missing an interval therefore silently skips a grid point and corrupts the time axis. So `logger.py` **records a lost measurement rather than dropping it**: placeholder rows carrying `MISSED` in the count fields keep `sample_index` contiguous, so the gap is explicit instead of silently compressing the timeline. The run continues and reports the total at the end, exiting non-zero. Double-counting is structurally impossible: `HSHAKE` DRC1=1 makes DRDY clear when the results are read, and `configure()` verifies the readback. **The period is calibrated before recording starts.** Counting how many grid points passed unseen inside a bracket needs the real period, and the TMRC table value is 6–9% out on this unit — enough to insert the wrong number of placeholders and slip the index against real time, which is exactly what placeholders exist to prevent. `calibrate_period()` takes a least-squares slope over one second of loss-free samples: | | period | error | |---|---|---| | nominal (TMRC table) | 27.03 ms | −6.4% | | **calibrated (1 s)** | **28.8849 ms** | **0.068%** | | whole-run fit (25 s) | 28.8653 ms | — | A 100× improvement, from ~35 points at ~0.5 ms jitter. It is then refined from clean intervals during the run so it follows thermal drift. **If no clean second can be found, the run aborts before recording anything** — without a clean stretch the period cannot be measured, so misses cannot be counted and the index would not track chip time. That is also the honest signal that the requested rate is not sustainable. **Counting lost measurements uses two different signals**, because the two questions have different best answers: - *Did we lose any?* — the **bracket**, rigorously: a bracket shorter than one period cannot contain two completions, whatever the timing precision. - *How many?* — the **interval since the previous accepted sample**, rounded to whole periods. Every accepted read sits on a grid point, so that interval is nearly an exact multiple; the bracket alone cannot say, since it only reaches back to the last poll that saw DRDY clear and so discards where the grid is. With the period calibrated to 0.07% and each completion located to under 1% of a period, the rounding is unambiguous for small gaps. Measured fit residual: | capture | lost | residual | |---|---|---| | 37 Hz clean | 0 | **0.019 periods** | | 150 Hz clean | 0 | 0.082 periods | | 150 Hz under CPU load | 0 | 0.444 periods | | 300 Hz | 10 | 1.241 periods | The load case is the one that shows the design paying off: the bracket reached **119%** of threshold, so a stall genuinely occurred, but the interval showed only one period had elapsed — nothing was lost. The older `int(bracket/period)` estimator would have inserted a spurious placeholder and slipped the index. `logger.py` reports an **ambiguous** count when a rounding lands near a half-period. That is the signal the count itself is a guess, and it is distinct from merely having missed something — at 300 Hz it fired 19 times. A capture with ambiguous gaps should be re-recorded before spectral work. Detection uses the **DRDY bracket** — the span between the last poll showing DRDY clear and the poll showing it set — not the read-to-read interval. The bracket is exact rather than heuristic: if DRDY reads clear at t꜀ then every earlier measurement has already been read, measurements complete one period apart, so a bracket narrower than a period can contain at most one completion, and DRDY going high proves it contained at least one. The interval cannot make that claim, and measurement settled it: on this rig the read-to-read interval reaches **37 ms against a 28.8 ms period** — a 29 % overshoot from host stalls alone — while the bracket stays under 10 ms. An interval threshold aborts on perfectly healthy captures; the first attempt here did exactly that at sample 88. **Clock drift is compensated in post-processing, not baked into the file.** The chip's RC oscillator is *regular* but only accurate to ±7% (§5.2.1) — this unit runs ~6% slow, 34.7 Hz against a 37 Hz nominal. `capture.py` regresses the host clock on `sample_index` to recover the true period: ``` elapsed_nominal = N * dt_nominal # uniform, but ~6% wrongly scaled elapsed = N * dt_true # uniform and correctly scaled ``` Short-term regularity comes from the chip, long-term rate calibration from the host. `characterize.py` uses `dt_true` for its frequency axis, since a 6% error would otherwise displace every spectral feature by 6%. ### 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 | | `noise_floor_test.sh` | capture then analyse in one step — wraps `logger.py`, then runs `plot.py` and `characterize.py` | | `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, aborting on a missed interval | | `capture.py` | the only capture reader — parses the header, rebuilds tesla and both time bases | | `plot.py` | four-panel plot of a capture: X, Y, Z and the norm | | `characterize.py` | noise floor: spectral density, Allan deviation, residuals, read latency | | `sweep.py` | sweep target rates, reporting measured rate, LSB, noise and bus use per point | | `compare.py` | A/B captures — fractional noise, plus the checks that separate a gain change from a moved sensor | | `diagnose-comms.py` | walks USB → bus → identity → registers → BIST → live read, stopping at the first failure | The adapter/sensor split is deliberate: `ch347.py` is a general I2C master usable with any device, and `rm3100.py` needs only a bus object exposing `write(addr, data)` and `read(addr, count)`. ## Implementation notes Things that cost time to work out, recorded so they don't have to be again. ### CH347 access The adapter presents three USB interfaces. Interfaces 0 and 1 are CDC-ACM and the kernel binds them as `/dev/ttyACM0` (the UART). **Interface 2** is vendor-class and carries I2C/SPI/JTAG with no kernel driver bound, so libusb can claim it directly with nothing to detach. `ch347.py` deliberately never calls `set_configuration()` — the device is already configured, and re-setting it would disturb the CDC-ACM interfaces driving the UART. There is no usable off-the-shelf option: no CH347 kernel driver exists (the in-tree `spi-ch341` is for `1a86:5512`, a different chip); the PyPI `ch347` package wraps a Windows DLL; and `ch347api` supports only HID mode (`55dc`), not this vendor-bulk mode 1. The framing in `ch347.py` follows the `aystarik/ch347-i2c-spi-gpio` Linux driver, whose id-table entry `USB_DEVICE_INTERFACE_NUMBER(0x1a86, 0x55db, 0x02)` matches this device exactly. Wire format — bulk OUT `0x06`, bulk IN `0x86`, max 63 bytes per transfer: | Purpose | Bytes out | Bytes back | |---|---|---| | Set speed | `AA 6 00` | 0 | | Write n | `AA 74 (80\|n+1) (addr<<1) 75 00` | n+1 | | Read n | `AA 74 81 ((addr<<1)\|1) [C0\|(n-1)] C0 75 00` | n+1 | | Probe | `AA 74 81 (addr<<1) 75 00` | 1 | **Every returned byte must be `1`** — that is the per-byte ACK. On a read, byte 0 is the address ACK and the rest is payload. ### RM3100 quirks - **Register reads use the plain address, not `|0x80`.** §5 describes the SPI convention of adding 0x80, but the I2C diagram in §5.8.4 writes `0x24` literally. Only 7 bits are decoded, so both work; the plain form is used here. - **A read is STOP-then-START, not a repeated START** (§4.5.2, §5.8.4), which is exactly what two separate CH347 transactions produce. No special handling. - **CMM = 0x79** for all three axes. Table 5-1 describes bit 3 as reserved-zero, but the manual's own examples (§5.7.2, §5.8.3) set it. The examples win. - **HSHAKE is set to 0x0A** (`DRC0=0`, `DRC1=1`) during init. The 0x1B default has `DRC0=1`, meaning *any* register write clears DRDY — including the pointer write that reading STATUS itself requires, so polling could never observe DRDY set. With `DRC0=0`, DRDY clears only on a results read. - **Gain** is `0.3671 × cycle_count + 1.5` LSB/µT, a linear fit to Table 3-1 (50→20, 100→38, 200→75; reproduces all three within a count). - Measurements are three **24-bit big-endian two's-complement** values, read as 9 bytes from 0x24 using the sensor's register auto-increment. ## Diagnostics Symptoms map cleanly onto causes, so work down this list. | Symptom | Cause | |---|---| | `Cannot claim CH347 interface 2: Access denied` | udev rule missing or not yet applied to this node — run `./setup.sh`, which diagnoses which of `uaccess` or group membership fell through. Do **not** reach for `sudo`: it works as your normal user, and masking a permissions problem with root only defers it. `--high-priority` is the one case that legitimately needs root, and it hands the capture file back to `$SUDO_UID` afterwards | | `No CH347 adapter found` | not plugged in, or not in Mode 1 | | `ABORTED: missed N interval(s)` | the host could not keep up — raise `--tmrc` (slower) or `--bus-speed`. Expected at cycle count 50 near the sensor's maximum | | `no capture header found` | a capture predating the header format — re-record it | | `sample_index is not contiguous` | the file was truncated mid-row, damaged, or hand-edited | | `HSHAKE did not take` | the I²C write is unreliable; try a lower `--bus-speed` | | Adapter opens, `--scan-only` finds nothing | **I2CEN not tied high** (most likely), or SDA/SCL swapped, or no bus pull-ups | | Found at 0x23, REVID `0x22`, registers fine, but DRDY never sets and results are all zero | **AVDD/VDD not powered** — confirm with BIST; 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. ### Bench results (desk, cc=200, TMRC 0x96, 400 kHz) Timing held up well throughout: fitted periods land 6.0–6.6% below nominal, and two consecutive 60 s runs agreed to **0.017%**, so a single-slope drift correction is sound over at least a minute. #### Supply: 3.3 V adapter vs 3.0 V LDO — indistinguishable Two 60 s captures, `..._DESK_3v3.csv` and `..._DESK_3vLDO.csv`: | | 3V3 adapter | 3.0 V LDO | |---|---|---| | \|B\| | 40,345 nT | 42,731 nT (**+5.9%**) | | sd \|B\| absolute | 35.4 nT | 38.5 nT (+8.8%) | | sd \|B\| **fractional** | **878 ppm** | **900 ppm** (+2.5%) | **Compare noise fractionally, not absolutely.** The measured scale moved 5.9% between these runs, and any scale change carries the noise with it — so the absolute sd difference is mostly an artifact. Normalised, the two supplies differ by 2.5%, which is indistinguishable on single 60 s captures. That is the expected outcome: Table 3-5 allows **50 mVPP** of supply ripple, and the LP5907's ~6.5 µV RMS sits roughly **1000× inside** it — as did the adapter rail, most likely. Supply quality was never the binding constraint; the magnetic environment is. **The 5.9% scale change is not attributable to supply from this data.** The sensor also moved between captures — the direction rotated **6.16°**, and the per-axis ratios (X 1.136, Y 1.051, Z 0.870) spread by **26.6%**, whereas a pure gain change would scale all three identically. Translation through a field gradient changes |B| just as a gain change does, so the two are confounded here. There is still good reason to expect 3.0 V to read *more accurately*, but it comes from the datasheet rather than these captures: Table 3-1 footnote 1 establishes every specification — including the gain fit behind `tesla_per_count` — at **3.0 V**, noting performance "will differ" elsewhere. At 3.3 V the gain is an extrapolation off the calibration point. To settle it, clamp the sensor so it cannot move and switch only the supply. Two diagnostics then make the answer unambiguous: a pure gain change gives **equal per-axis ratios** (spread ≈ 0) and **≈ 0° of rotation**. `compare.py` runs both checks and refuses to attribute a magnitude difference to gain unless they pass: ```bash ./.venv/bin/python logger.py --duration 300 --note "3V3" --output a1.csv ./.venv/bin/python logger.py --duration 300 --note "3V0 LDO" --output b1.csv ./.venv/bin/python compare.py --group note *.csv ``` Use `--note` to record the configuration in the header, so the comparison does not depend on filenames surviving. ### Designing an A/B supply test - **Clamp the sensor and never touch it.** Swapping supplies must not disturb it. This is the single thing that invalidated the first attempt. - **Interleave A/B/A/B**, never all of A then all of B. Drift over a session is large enough to swamp the effect, and only interleaving lets you difference adjacent pairs. - **Repeat at least 3–4 times per configuration.** With one run each, a 2.5% difference is indistinguishable from run-to-run variation. - **Pick cycle count to match a TMRC step** — see below. - **Let it thermally settle** before each run. Coil resistance moves 0.4 %/°C (Table 3-3), so an unsettled sensor drifts through the measurement. #### 60 Hz mains aliases into the band — the dominant feature The largest spectral feature by far is a line near 9 Hz reaching ~100–125 nT/√Hz, against a ~4–5 nT/√Hz broadband floor: **25× the noise floor**. It is 60 Hz folding in, proven by making it move: | capture | fs | peak | predicted 60 Hz alias | |---|---|---|---| | 3V3 | 34.7565 Hz | 9.5063 Hz | 9.5129 Hz | | 3.0 V LDO | 34.5557 Hz | 9.0725 Hz | 9.1114 Hz | | long run | 34.5783 Hz | 9.1764 Hz | 9.1566 Hz | A real 9 Hz magnetic signal would sit still. This one moves with the sample rate: `fs` changed by 0.20 Hz and the peak moved 0.43 Hz — the ×2 sensitivity of a second-order fold, exactly as predicted. The RM3100 has **no anti-alias filter** — its cycle-count integration is a boxcar with poor stopband — so out-of-band interference folds in freely. Until this is dealt with (notch in post, a sample rate that folds mains somewhere harmless, or removing the coupling) it will dominate every sd figure and mask any supply-level improvement. #### Overnight run: 13.2 h, 1.63 M samples `rm3100_20260823_014804.csv`. **Acquisition integrity was perfect** — sample index contiguous across all 1,635,055 rows, system clock monotonic with no NTP steps, no malformed rows, no int24 saturation, and no missed intervals (worst DRDY bracket 11.0 ms of 27.03 ms, 41% of margin). Two problems in the data, both outside the logger: **1. The first ~1.5 h and the last ~1 h are contaminated by handling.** |B| reaches **198,268 nT**, five times Earth's field, in bursts around t+0.44 h. Hourly |B| sd tells the story plainly: | hours | \|B\| sd | usable | |---|---|---| | 0–1 | 3,750 nT | no — handling | | 2–10 | **34–40 nT** | yes | | 11–12 | 41–68 nT | marginal | | 13 | 247 nT | no — activity resumed | The quiet window is extracted as `overnight_quiet.csv` (hours 2.0–10.5, 1,055,585 samples). A 5-minute slice from its middle gives |B| = 42,132 nT with sd 32.5 nT (771 ppm). **2. A single rate does not describe a 13-hour capture.** Fitting one slope across the whole run leaves a residual sd of **5.3 s** and a **22.8 s span**. Hour-by-hour fits show the oscillator speeding up monotonically: | hour | period | vs run mean | |---|---|---| | 2 | 29.0187 ms | +1254 ppm | | 6 | 28.9794 ms | −102 ppm | | 12 | 28.9187 ms | −2195 ppm | That is ~2,500 ppm of rate variation across the quiet hours — thermal, as expected from an RC oscillator. The consequence is that **long captures cannot be given a single frequency axis**; spectra must be computed on windows short enough that the rate is constant. `capture.py` now warns when the residual sd exceeds one sample period, and reports read jitter separately from fit residual — over 5 minutes the residual is 3.7 ms, over 8.5 hours it is 2,356 ms, while read jitter stays 0.21 ms in both. Conflating the two would have read as a 20,000× worse host. #### Long run: drift-limited beyond a second `rm3100_20260823_014804.csv`, 26,604 samples over 769 s: sd rises to 214–284 nT per axis while the broadband floor barely moves (5.1–5.7 nT/√Hz). The extra spread is **low-frequency drift, not broadband noise** — the Allan deviation bottoms at ~13 nT near τ = 0.12 s and climbs steadily after. Over 13 minutes, thermal and environmental drift dominate everything else. ### Achievable rate Two fixes roughly doubled the achievable rate. Measured with `--cycle-count 50 --bus-speed 400`: | TMRC | Nominal | Result here | Worst DRDY bracket | |---|---|---|---| | 0x92 | 600 Hz | abort | 1.86 ms of 1.67 ms (112%) | | 0x93 | 300 Hz | abort | 3.45 ms of 3.33 ms (104%) | | 0x94 | 150 Hz | borderline | 5.65 / 5.95 / 6.53 / 7.19 ms of 6.67 ms | | 0x95 | 75 Hz | clean, 69.2 Hz | 2.63–5.16 ms of 13.33 ms (20–39%) | | 0x96 | 37 Hz | clean, 34.7 Hz | 3.30–8.56 ms of 27.03 ms (12–32%) | > **These ceilings are host-specific and pessimistic.** They were measured in a > resource-limited VM with virtualized USB, shared cores and background load > (2.76 at the time of the one 150 Hz abort). Bracket tails there include > hypervisor scheduling and USB passthrough latency that bare metal does not > pay. The *shape* of the result should carry over — the limit is host round-trip > latency, not I²C bandwidth or the sensor — but the numbers should be > re-measured on the machine that will actually run the capture. The > "% of margin used" line that `logger.py` prints after every run is the number > to watch. **The I²C bus is never the constraint.** Per sample it carries a DRDY poll plus a results read — 150 bit-times — against a host cost 2–3× larger. `logger.py` prints both at the start of every run: | | 400 kHz | 100 kHz | |---|---|---| | bus time per sample | 0.375 ms | 1.50 ms | | as % of a 28.86 ms period (37 Hz) | **1.3%** | 5.2% | | as % of a 7.21 ms period (150 Hz) | **5.2%** | 20.8% | | measured host cost | 0.89–0.98 ms | — | That puts the bus-limited ceiling near 1100 Hz, roughly double the sensor's own 562 Hz three-axis maximum at cycle count 50 — so absent host stalls, zero misses is always achievable. Every miss observed has been a host stall. The ceiling was therefore two host-side costs: **1. Two USB round trips per register read.** Reading a register as a pointer write plus a separate read — the form the manual draws (§4.5.2, §5.8.4) — costs two round trips. The part in fact accepts a **repeated START**, so `write_read()` in [ch347.py](ch347.py) does it in one. Measured at 400 kHz: | | separate | combined | |---|---|---| | `data_ready()` | 0.652 ms | **0.350 ms** | | `read_raw()` | 0.838 ms | **0.548 ms** | **2. The GIL, not the OS scheduler.** Even after that, `logger.py` stalled ~10 ms at 150 Hz while the *same sampling loop with no writer thread* peaked at 3.1 ms (p99.9 = 2.9 ms, zero threshold crossings in 2079 samples). The sampler releases the GIL inside each USB transfer and must re-acquire it, waiting up to Python's default **5 ms** switch interval while the writer holds it. Setting `sys.setswitchinterval(0.0005)` cut the worst bracket from 9.96 ms to 5.65 ms and made 150 Hz pass. That is worth remembering generally: with a latency-sensitive loop and a helper thread, the default GIL switch interval *is* the jitter floor. ### Rate, resolution and the bus Four settings interact. Three of them are worth understanding together, and the fourth is independent of the rest. **Two mechanisms set the rate, and the slower one wins** (§5.2.1): | | sets | granularity | |---|---|---| | cycle count | how *long* a measurement takes: `3 x (cc/90,000 + 68.7 us)` | continuous | | TMRC | how *often* one is started | factor-of-two steps | If TMRC asks for something the cycle count cannot deliver, the cycle count wins and TMRC has no effect at all — measured 73.85 Hz at TMRC 0x92 against 73.86 Hz at 0x94 for the same cycle count. If TMRC asks for something slower, TMRC wins and **the sensor idles**, which is the case to avoid. **Idling costs sensitivity.** Duty is integration time over period, and noise after filtering scales as `1/sqrt(duty)`. Reaching ~130 Hz two ways: | | rate | cc | duty | ASD | |---|---|---|---|---| | TMRC-governed (cc=50, 0x94) | 138.5 Hz | 50 | **23%** | 3.42 nT/√Hz | | cycle-count-governed (cc=228) | 128.5 Hz | 228 | **98%** | 2.39 nT/√Hz | Same chip, same field, same per-sample physics — the difference is idle time. So: **hold TMRC fast and let the cycle count set the rate.** That is what the defaults do, and `--rate` derives both for you. **Cycle count also sets resolution**, which is the real trade. It buys rate and LSB in opposite directions, and the dither margin follows the LSB: | cc | rate | nT/LSB | dither | Nyquist | |---|---|---|---|---| | 50 | 534 Hz | 50.37 | 0.58 | 267 Hz | | 228 | 128 Hz | 11.74 | 1.17 | 64 Hz | | 931 | 32 Hz | 2.91 | 2.34 | 16 Hz | Below ~0.46 Hz the 16-bit cycle-count register runs out and TMRC has to set the cadence; below cycle count 30 the manual warns of quantisation (§5.1). Both bounds are enforced by `rm3100.plan()`. **Bus speed is independent of the rate.** It does not appear in either rate mechanism. What it sets is *latency* — how long a read takes, hence how tightly a DRDY event 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 and 750 kHz is the default everywhere. ### Recommended configuration **Cycle count 100 at 750 kHz**, which is what the defaults do. That runs the sensor at its own ceiling of ~282 Hz, with TMRC held fast so the cycle count governs and the duty cycle stays at 94%. ```bash ./.venv/bin/python logger.py --duration 60 # cc=100, 750 kHz, ~282 Hz ``` It sits deliberately between the two things that pull in opposite directions: - **Against cycle count 200** it costs **1.5%** in post-filter noise — 94% duty against 97%, and nothing else — for **1.9× the spectrum**, 141 Hz of Nyquist against 73 Hz. Worth taking, because **aliased interference cannot be filtered out afterwards at any cycle count**, so seeing it beats 1.5% of noise. At 282 Hz both mains and its second harmonic sit in band and can be notched; at 145 Hz only the fundamental does. - **Against cycle count 50** it gives up half the spectrum (141 Hz of Nyquist against 267 Hz) and buys **36% more dither margin**, 0.79 LSB against 0.58, plus a 1.9× coarser rate demand on the host. cc=50 is the right choice when something above 141 Hz needs identifying; it is not the right default, because its dither margin is the one assumption in the whole configuration that has not yet been checked against a real quiet-site capture. 750 kHz is not optional at this cycle count. The period is 3.539 ms, and the irreducible I2C traffic per sample is: | bus | traffic | share of period | |---|---|---| | 100 kHz | 1.500 ms | **42%** | | 400 kHz | 0.375 ms | 11% | | 750 kHz | 0.200 ms | **6%** | 750 kHz is also the fastest measured, not merely the least bus traffic. Host cost per sample, three runs each at cycle count 228: | bus | bus time | host cost | implied USB overhead | |---|---|---|---| | 100 kHz | 1.500 ms | 2.03–2.19 ms | ~0.63 ms | | 400 kHz | 0.375 ms | 0.88–0.95 ms | ~0.55 ms | | **750 kHz** | 0.200 ms | **0.78–0.80 ms** | ~0.59 ms | Host cost is `bus time + ~0.6 ms` of fixed USB round-trip latency, so a faster bus helps but cannot go below that floor. An earlier note here claimed 400 kHz benchmarked faster than 750; that rested on a single short run whose 4% difference was inside the run-to-run spread, and does not survive repetition. 400 kHz remains a sane fallback if 750 proves marginal on long wiring. **Fall back to cycle count 200 if dither is still thin.** The assumption to check is that cc=100 stays dithered: its intrinsic noise is 0.79 LSB against a simulated failure point below ~0.2 LSB, so there is roughly 4× of margin at the spec noise figure. That margin shrinks if the real sensor is quieter than spec, which is the entire point of the quiet-site experiment. `characterize.py` prints `sd/LSB` per axis, which settles it from the first capture — below ~0.35 and averaging stops recovering sub-LSB resolution, at which point 200 (1.10 LSB) buys the margin back at the cost of bandwidth. | cycle count | rate | Nyquist | nT/LSB | dither | duty | | |---|---|---|---|---|---|---| | 50 | 534 Hz | 267 Hz | 50.37 | 0.58 | 89.0% | spectrum, thin dither | | **100** | **282 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 | `logger.py` warns on stderr for anything below cycle count 50 (`rm3100.RECOMMENDED_MIN_CYCLE_COUNT`), along with five other configuration traps — see [Configuration warnings](#configuration-warnings). ### Configuration warnings `rm3100.plan()` resolves a configuration and `logger.py` prints its derivation before every run, then checks it against six known traps. Each one is a *silent* failure: the capture completes, the numbers look plausible, and the defect is only visible afterwards. So each is reported on stderr and **nothing is fixed automatically** — changing a setting that was asked for would hide the problem behind a configuration change. | Warning | Trigger | Why it matters | |---|---|---| | cycle count below the recommended 50 | `cc < RECOMMENDED_MIN_CYCLE_COUNT` | dither thins toward the ~0.2 LSB where averaging stops recovering sub-LSB resolution | | TMRC governs and the sensor idles | TMRC-governed and 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 figure above it is derived from the rate | | 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, not specified | | 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.91 nT/LSB capture with 60 Hz mains sitting at 4.02 Hz, indistinguishable from signal. Sampling fast and decimating afterwards gives the same noise floor with the line still visible. ### Rate model, and how TMRC is chosen Two things set the rate, and **the slower one wins** (§5.2.1). TMRC requests a rate; the cycle count imposes a ceiling. Measured at TMRC 0x94 (150 Hz nominal, 138.8 Hz actual), varying only cc: | cc | measured | governed by | duty | |---|---|---|---| | 50 | 138.53 Hz | TMRC | 25% | | 200 | 138.84 Hz | boundary | 99% | | 400 | 73.83 Hz | cycle count | 100% | | 800 | 37.09 Hz | cycle count | 100% | **When the cycle count governs, TMRC is irrelevant** — cc=400 gives 73.85 Hz at TMRC 0x92 and 73.86 Hz at 0x94. So the simplest operating model is to set TMRC fast (`0x92`) and use **cycle count as the single rate knob**: it is a continuous integer where TMRC is coarse factor-of-two steps, and a cc-governed rate is always ~100% duty, which sidesteps the quantisation trap entirely. TMRC is then only worth choosing when you want a rate *slower* than the cycle count allows — to save power by letting the chip idle, or to hit a specific cadence. The rate follows from a fitted model rather than Table 3-1's three data points (`rm3100.sample_period` / `cycle_count_for_rate`): ``` per-axis time = cycle_count / 90,000 + 68.7 us ``` The divisor is the **specified** value, not a fitted one: Table 3-1 gives a 180 kHz circuit oscillation and §4.1 measures each count in *both* bias directions, so one count costs two oscillations. Rates measured here across cycle counts 229–29,769 agree with that to within **0.8%**, which confirms the spec figure rather than improving on it — the residual is this unit's oscillator inside ordinary component tolerance, and another part would sit elsewhere. The overhead has no specified value and must be measured. It is why the naive `rate × cc` "constant" is not constant, drifting from 84,429 at cc=100 to 89,191 at cc=1200. **Treat predicted rates as ±2%.** The manual quotes no oscillator tolerance, so anything needing the real number measures it — `logger.py` calibrates the period against the host clock before recording, and that is what every capture reports. `sweep.py` measures this end to end. Every target from 1 to 128 Hz lands within **0.1%**: ``` target measured err cc LSB/uT nT/count noise bus use 1H 1.00H +0.0% 29769 10929.7 0.091 1.21n 0.0% 8H 8.01H +0.1% 3718 1366.4 0.732 3.41n 0.3% 32H 32.00H -0.0% 927 341.8 2.926 6.83n 1.2% 128H 127.99H -0.0% 229 85.6 11.687 13.75n 4.8% 256H 249.23H -2.6% 113 43.0 23.265 19.57n 9.3% 2 lost ``` 256 Hz is where this host gives out; everything below it is exact. Note the noise column past cycle count ~400 is extrapolation — the manual calls that its useful upper limit and gives no data beyond. ### Choosing cycle count Run each cycle count at *its own* fastest clean rate, not a fixed one. What then matters is **duty** — the fraction of wall time the sensor is actually integrating — because after decimating to a common bandwidth, noise scales as 1/√duty. Two ceilings compete: the sensor's (`~84,333/cc/3` Hz) and the host's (~145 Hz here). | cc | sensor max | TMRC | actual | duty | noise | LSB | Nyquist | |---|---|---|---|---|---|---|---| | 50 | 562 Hz | 0x94 | 138.8 Hz | **25%** | **2.01×** | 50.37 nT | 69.4 Hz | | 100 | 281 Hz | 0x94 | 138.8 Hz | 49% | 1.42× | 26.17 nT | 69.4 Hz | | **200** | 141 Hz | **0x94** | **138.8 Hz** | **99%** | 1.01× | 13.35 nT | 69.4 Hz | | 250 | 112 Hz | 0x95 | 69.4 Hz | 62% | 1.27× | 10.72 nT | 34.7 Hz | | **400** | 70 Hz | **0x95** | **69.4 Hz** | **99%** | 1.01× | 6.74 nT | 34.7 Hz | | 600 | 47 Hz | 0x96 | 34.2 Hz | 73% | 1.17× | 4.51 nT | 17.1 Hz | | **800** | 35 Hz | **0x96** | **34.2 Hz** | **97%** | 1.01× | 3.39 nT | 17.1 Hz | **Because TMRC is quantised, cycle count should be chosen so the sensor ceiling sits just *above* a TMRC step, not just below it.** Land just below and the rate halves while integration time does not, wasting ~40% of the duty — cc=250 and cc=600 are exactly that trap. The natural operating points are `cc ≈ 203 / 405 / 821`, i.e. **200 / 400 / 800** paired with TMRC `0x94 / 0x95 / 0x96`. Both extremes verified on hardware: ``` cc200 @150Hz 138.77 Hz LSB 13.35 nT ASD 2.98 nT/rtHz (66% bracket margin) cc800 @37Hz 34.72 Hz LSB 3.39 nT ASD 3.26 nT/rtHz (10% bracket margin) ``` Near-identical spectral density, as the equal-duty argument predicts — so the choice between them is purely **bandwidth versus resolution**, not noise. Use cc=200/0x94 when you need 60 Hz inside the band; cc=800/0x96 when resolution matters more than bandwidth. **Low cycle counts are dominated, not fast.** cc=50 reaches the same 138.8 Hz as cc=200 — both are host-limited, not sensor-limited — but idles 75% of the time and quantises 3.8× more coarsely. Its extra sensor speed is capability the host cannot collect. ### 60 Hz: resolved rather than aliased At 150 Hz, Nyquist is 69.3 Hz and mains lands **in band** — the strongest line sits at **59.923 Hz**, observed directly. At 75 Hz it folds to 9.304 Hz, matching the 9.233 Hz prediction, with only 4 nT/√Hz left at 60 Hz because that frequency is no longer sampled. This settles the mains hypothesis by observation rather than inference, and gives a way to *measure* interference before deciding how to reject it. ### 60 s capture at maximum rate (office rig, historical) `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** - [x] Exactly-once sampling with abort-on-miss, and a chip-grid time base - [x] Verified on hardware: exact grid, −6.11% drift recovered, 0.017% run-to-run - [x] 3.0 V LDO built and compared — noise indistinguishable once normalised - [ ] Re-test the LDO with the sensor **clamped**, to separate gain from movement - [ ] Reliability testing over extended runs - [ ] **Deal with 60 Hz mains aliasing into the band** — the dominant term - [ ] Standoff distance, then a repeat characterisation > **Captures written before the header format are unreadable** and must be > re-recorded — `capture.py` says so explicitly rather than guessing. The > figures quoted under *Observed performance* came from those older files and > stand as historical measurements only. A proper Python API is planned; these modules are the initial-communication milestone, deliberately kept simple.