Add exact-interval sampling, chip-grid timing, and analysis tooling
Sample every measurement exactly once, flagging losses in a warning column rather than dropping them, so sample_index stays a valid chip-time grid coordinate. Calibrate the true period before recording; abort if no loss-free stretch exists. Captures now store only raw counts plus a config header, with tesla and both time bases reconstructed on load. Cycle count becomes the rate knob (TMRC has no effect once it governs), raising the ceiling ~4x via repeated-START reads and a shorter GIL switch interval.
This commit is contained in:
parent
2a101d013d
commit
3d6c7251e9
11 changed files with 2102 additions and 221 deletions
623
README.md
623
README.md
|
|
@ -147,21 +147,186 @@ system-wide `pip install`.
|
||||||
|
|
||||||
| Flag | Default | Meaning |
|
| Flag | Default | Meaning |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `--cycle-count` | 200 | gain/resolution vs. speed; 200 → 75 LSB/µT |
|
| `--cycle-count` | 50 | sets the rate and the LSB; see Recommended configuration |
|
||||||
| `--tmrc` | 0x96 | continuous-mode rate; 0x92 (~600 Hz) to 0x9D (~0.3 Hz) |
|
| `--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 |
|
| `--duration` | 0 | seconds, 0 = until Ctrl-C |
|
||||||
| `--output` | timestamped | CSV path |
|
| `--output` | timestamped | CSV path |
|
||||||
| `--address` | autodetect | skip the scan |
|
| `--address` | autodetect | skip the scan |
|
||||||
|
| `--bus-speed` | 750 | I²C kHz; 100 would spend 80% of the period on the bus at cc=50 |
|
||||||
| `--scan-only` | — | scan and exit |
|
| `--scan-only` | — | scan and exit |
|
||||||
|
|
||||||
CSV columns: `timestamp_iso, elapsed_s, x_raw, y_raw, z_raw, x_uT, y_uT, z_uT,
|
Exit status is non-zero if an interval was missed. The partial capture is kept
|
||||||
magnitude_uT`.
|
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
|
```bash
|
||||||
./.venv/bin/python plot.py capture_60s.csv # -> capture_60s.png
|
./.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
|
./.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
|
### Sample rate
|
||||||
|
|
||||||
**Cycle count, not TMRC, sets the ceiling.** Measured with `--bus-speed 400`:
|
**Cycle count, not TMRC, sets the ceiling.** Measured with `--bus-speed 400`:
|
||||||
|
|
@ -202,11 +367,16 @@ silently losing data.
|
||||||
| File | Role |
|
| File | Role |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `setup.sh` | udev rule, venv, pyusb |
|
| `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 |
|
| `ch347.py` | CH347 I2C transport — USB only, knows nothing about the sensor |
|
||||||
| `rm3100.py` | RM3100 driver — registers and decoding, knows nothing about USB |
|
| `rm3100.py` | RM3100 driver — registers and decoding, knows nothing about USB |
|
||||||
| `logger.py` | CLI: scan → identify → configure → log |
|
| `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 |
|
| `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 |
|
| `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
|
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
|
with any device, and `rm3100.py` needs only a bus object exposing
|
||||||
|
|
@ -270,8 +440,12 @@ Symptoms map cleanly onto causes, so work down this list.
|
||||||
|
|
||||||
| Symptom | Cause |
|
| Symptom | Cause |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `Cannot claim CH347 interface 2: Access denied` | udev rule missing — run `./setup.sh` |
|
| `Cannot claim CH347 interface 2: Access denied` | udev rule missing — run `./setup.sh`. Do **not** use `sudo`: it works as your normal user, and running as root leaves root-owned capture files |
|
||||||
| `No CH347 adapter found` | not plugged in, or not in Mode 1 |
|
| `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 |
|
| 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 |
|
| 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 |
|
| Cycle-count read-back mismatch | bus integrity — try a lower speed |
|
||||||
|
|
@ -318,7 +492,424 @@ 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
|
soft-iron correction, so the individual axis values reflect local distortion as
|
||||||
much as Earth's field.
|
much as Earth's field.
|
||||||
|
|
||||||
### 60 s capture at maximum rate
|
### 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 mV<sub>PP</sub>** 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 50 at 750 kHz**, which is what the defaults now do. That runs the
|
||||||
|
sensor at its own ceiling of ~534 Hz, with TMRC held fast so the cycle count
|
||||||
|
governs and the duty cycle stays near 100%.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./.venv/bin/python logger.py --duration 60 # cc=50, 750 kHz, ~534 Hz
|
||||||
|
```
|
||||||
|
|
||||||
|
Against cycle count 200 this costs **4.4%** in post-filter noise — 89% duty
|
||||||
|
against 97%, and nothing else — while giving **3.7x the spectrum**, 267 Hz of
|
||||||
|
Nyquist against 73 Hz. That trade is worth taking because **aliased interference
|
||||||
|
cannot be filtered out afterwards at any cycle count**, so seeing it beats a few
|
||||||
|
percent of noise. At 534 Hz mains and its first four harmonics all sit in band
|
||||||
|
and can be notched; at 145 Hz only the 60 Hz fundamental does, and the rest fold
|
||||||
|
irreversibly onto signal.
|
||||||
|
|
||||||
|
750 kHz is not optional at this cycle count. The period is 1.873 ms, and the
|
||||||
|
irreducible I2C traffic per sample is:
|
||||||
|
|
||||||
|
| bus | traffic | share of period |
|
||||||
|
|---|---|---|
|
||||||
|
| 100 kHz | 1.500 ms | **80%** |
|
||||||
|
| 400 kHz | 0.375 ms | 20% |
|
||||||
|
| 750 kHz | 0.200 ms | **11%** |
|
||||||
|
|
||||||
|
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 100 or 200 if dither is thin.** The one assumption
|
||||||
|
worth checking is that cc=50 stays dithered: its intrinsic noise is 0.58 LSB,
|
||||||
|
and simulation puts the failure point below ~0.2 LSB, so there is roughly 3x of
|
||||||
|
margin. `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 100 (1.17 LSB) or 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% |
|
||||||
|
| 100 | 281 Hz | 141 Hz | 26.17 | 0.80 | 93.7% |
|
||||||
|
| 200 | 145 Hz | 73 Hz | 13.35 | 1.10 | 97.0% |
|
||||||
|
|
||||||
|
### 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,
|
`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
|
400 kHz bus. The sensor was nudged by hand at t ≈ 41 s, so statistics are split
|
||||||
|
|
@ -359,6 +950,18 @@ Three things worth noting:
|
||||||
- [x] Register write/read verified — cycle counts written and read back
|
- [x] Register write/read verified — cycle counts written and read back
|
||||||
- [x] Analog section verified — BIST reports `XOK=YOK=ZOK=1`
|
- [x] Analog section verified — BIST reports `XOK=YOK=ZOK=1`
|
||||||
- [x] **Live measurements logging to CSV at ~35 Hz**
|
- [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
|
||||||
|
|
||||||
Initial communication is complete. A proper Python API is planned; these modules
|
> **Captures written before the header format are unreadable** and must be
|
||||||
are the initial-communication milestone, deliberately kept simple.
|
> 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.
|
||||||
|
|
|
||||||
243
capture.py
Normal file
243
capture.py
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
"""Read an RM3100 capture and reconstruct everything the file does not store.
|
||||||
|
|
||||||
|
A capture holds only irreducible facts -- sample count, host clock, raw counts --
|
||||||
|
plus a header of configuration. This module turns that back into field values and
|
||||||
|
a time base, and is the single place either is derived.
|
||||||
|
|
||||||
|
Two time bases come out of `load()`:
|
||||||
|
|
||||||
|
elapsed_nominal N * dt_nominal, using the TMRC table rate
|
||||||
|
elapsed N * dt_true, using the rate actually observed
|
||||||
|
|
||||||
|
The chip's RC oscillator is accurate to about +/-7% (manual section 5.2.1) and
|
||||||
|
our unit runs ~6% slow, so `elapsed_nominal` is uniform but wrongly scaled.
|
||||||
|
`dt_true` comes from regressing the host clock on the sample index: the chip
|
||||||
|
supplies short-term regularity, the host supplies long-term rate calibration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
import rm3100
|
||||||
|
|
||||||
|
# Flags logger.py writes in the `warning` column, space separated. Duplicated
|
||||||
|
# rather than imported so that reading a capture never pulls in the USB stack.
|
||||||
|
WARN_MISSED = "MISSED" # placeholder row: no data, keeps the index contiguous
|
||||||
|
WARN_AMBIGUOUS = "AMBIGUOUS" # gap ending here was of uncertain length
|
||||||
|
|
||||||
|
|
||||||
|
class CaptureError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Capture:
|
||||||
|
"""A loaded capture: raw counts, nanotesla axes, and two time bases."""
|
||||||
|
|
||||||
|
def __init__(self, path, meta, sample_index, system_time, counts,
|
||||||
|
missed=None, ambiguous=None):
|
||||||
|
self.path = path
|
||||||
|
self.missed = (np.zeros(len(sample_index), dtype=bool)
|
||||||
|
if missed is None else missed)
|
||||||
|
# A subset of missed: gaps whose length could not be counted
|
||||||
|
# confidently, so sample_index may have slipped across them.
|
||||||
|
self.ambiguous = (np.zeros(len(sample_index), dtype=bool)
|
||||||
|
if ambiguous is None else ambiguous)
|
||||||
|
self.meta = meta
|
||||||
|
self.sample_index = sample_index
|
||||||
|
self.system_time = system_time
|
||||||
|
self.counts = counts
|
||||||
|
|
||||||
|
self.cycle_count = int(meta["cycle_count"])
|
||||||
|
self.tesla_per_count = float(meta["tesla_per_count"])
|
||||||
|
self.lsb_nt = self.tesla_per_count * rm3100.NT_PER_TESLA
|
||||||
|
self.nominal_rate_hz = float(meta["nominal_rate_hz"])
|
||||||
|
self.dt_nominal = 1.0 / self.nominal_rate_hz
|
||||||
|
|
||||||
|
self.x, self.y, self.z = (counts[a] * self.lsb_nt for a in "xyz")
|
||||||
|
self.total = np.sqrt(self.x**2 + self.y**2 + self.z**2)
|
||||||
|
|
||||||
|
# Least-squares fit of host clock against grid coordinate. numpy's
|
||||||
|
# polyfit is centred internally, so the large epoch offset is harmless.
|
||||||
|
slope, intercept = np.polyfit(sample_index, system_time, 1)
|
||||||
|
self.dt_true = float(slope)
|
||||||
|
# Error in the *rate*, so the sign matches the reported Hz: negative
|
||||||
|
# means the chip samples slower than the nominal table value.
|
||||||
|
self.rate_error = self.dt_nominal / self.dt_true - 1.0
|
||||||
|
self.residuals = system_time - (intercept + slope * sample_index)
|
||||||
|
|
||||||
|
self.elapsed_nominal = sample_index * self.dt_nominal
|
||||||
|
self.elapsed = sample_index * self.dt_true
|
||||||
|
|
||||||
|
# Two different things live in the timing error, and conflating them is
|
||||||
|
# misleading. Read latency is local: the scatter of one read interval
|
||||||
|
# about the next. Fit residual is global: how far the whole capture
|
||||||
|
# departs from a single straight line, which on a long run is dominated
|
||||||
|
# by the oscillator's rate drifting with temperature, not by the host.
|
||||||
|
self.read_jitter = float(np.diff(system_time).std()) if len(system_time) > 1 else 0.0
|
||||||
|
self.residual_sd = float(self.residuals.std())
|
||||||
|
self.residual_span = float(np.ptp(self.residuals))
|
||||||
|
# One sample period of accumulated error means the single slope is no
|
||||||
|
# longer describing the capture.
|
||||||
|
self.drift_limited = self.residual_sd > self.dt_true
|
||||||
|
|
||||||
|
@property
|
||||||
|
def true_rate_hz(self):
|
||||||
|
return 1.0 / self.dt_true
|
||||||
|
|
||||||
|
@property
|
||||||
|
def duration(self):
|
||||||
|
return float(self.elapsed[-1] - self.elapsed[0])
|
||||||
|
|
||||||
|
def axes(self):
|
||||||
|
"""(key, array) for the three axes plus the derived total."""
|
||||||
|
return [("x", self.x), ("y", self.y), ("z", self.z),
|
||||||
|
("total", self.total)]
|
||||||
|
|
||||||
|
def restrict(self, start=None, end=None):
|
||||||
|
"""Return a new Capture covering a window of drift-corrected seconds.
|
||||||
|
|
||||||
|
Re-fits on the subset, so a window's rate is its own rather than
|
||||||
|
inherited -- which is what makes it usable for spotting drift across a
|
||||||
|
long run by comparing windows.
|
||||||
|
"""
|
||||||
|
keep = np.ones(len(self.sample_index), dtype=bool)
|
||||||
|
if start is not None:
|
||||||
|
keep &= self.elapsed >= start
|
||||||
|
if end is not None:
|
||||||
|
keep &= self.elapsed <= end
|
||||||
|
if keep.sum() < 64:
|
||||||
|
raise CaptureError(
|
||||||
|
f"{self.path}: window [{start}, {end}] leaves "
|
||||||
|
f"{int(keep.sum())} samples, too few to analyse")
|
||||||
|
return Capture(self.path, self.meta, self.sample_index[keep],
|
||||||
|
self.system_time[keep],
|
||||||
|
{a: self.counts[a][keep] for a in "xyz"},
|
||||||
|
self.missed[keep], self.ambiguous[keep])
|
||||||
|
|
||||||
|
def summary(self):
|
||||||
|
lines = [
|
||||||
|
f"{self.path}: {len(self.sample_index):,} samples over "
|
||||||
|
f"{self.duration:.2f} s",
|
||||||
|
f" cycle count {self.cycle_count}, 1 LSB = {self.lsb_nt:.2f} nT",
|
||||||
|
f" rate {self.true_rate_hz:.3f} Hz measured vs "
|
||||||
|
f"{self.nominal_rate_hz:g} Hz nominal ({self.rate_error * 100:+.2f}%)",
|
||||||
|
f" read jitter {self.read_jitter * 1e3:.3f} ms sd | "
|
||||||
|
f"fit residual {self.residual_sd * 1e3:.1f} ms sd, "
|
||||||
|
f"{self.residual_span:.2f} s span",
|
||||||
|
f" Nyquist {self.true_rate_hz / 2:.2f} Hz",
|
||||||
|
]
|
||||||
|
if self.drift_limited:
|
||||||
|
lines += ["",
|
||||||
|
f" WARNING: the single-rate model does not fit this capture "
|
||||||
|
f"(residual sd {self.residual_sd * 1e3:.0f} ms",
|
||||||
|
f" against a {self.dt_true * 1e3:.1f} ms period)."]
|
||||||
|
if self.missed.any():
|
||||||
|
# Each lost measurement is an independent chance to insert one
|
||||||
|
# placeholder too many or too few, and the error accumulates in
|
||||||
|
# sample_index. That is the likelier cause here than thermal drift.
|
||||||
|
lines += [
|
||||||
|
f" With {int(self.missed.sum()):,} lost measurement(s) the likely cause is "
|
||||||
|
"miscounted placeholders:",
|
||||||
|
" how many grid points passed unseen can only be estimated, so the index",
|
||||||
|
" slips by about one per miss. The grid is exact only in a loss-free "
|
||||||
|
"capture.",
|
||||||
|
" Re-record at a lower rate rather than trusting this one for "
|
||||||
|
"spectral work.",
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
lines += [
|
||||||
|
" No measurements were lost, so this is the chip's oscillator drifting --",
|
||||||
|
" expect thermal variation over a long run. Frequencies are scaled by an",
|
||||||
|
" average rate and will be smeared; analyse shorter windows",
|
||||||
|
" (capture.restrict) for spectral work.",
|
||||||
|
]
|
||||||
|
if self.missed.any():
|
||||||
|
n = int(self.missed.sum())
|
||||||
|
lines.insert(1, f" {n:,} lost measurement(s) "
|
||||||
|
f"({n / len(self.missed) * 100:.3f}%), interpolated")
|
||||||
|
if self.ambiguous.any():
|
||||||
|
lines.insert(1, f" {int(self.ambiguous.sum()):,} AMBIGUOUS gap(s) "
|
||||||
|
"-- length uncertain, index may have slipped")
|
||||||
|
if self.meta.get("note"):
|
||||||
|
lines.insert(1, f" note: {self.meta['note']}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_header(handle):
|
||||||
|
"""Consume leading '# key: value' lines, leaving the reader at the CSV."""
|
||||||
|
meta = {}
|
||||||
|
while True:
|
||||||
|
position = handle.tell()
|
||||||
|
line = handle.readline()
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
if not line.startswith("#"):
|
||||||
|
handle.seek(position)
|
||||||
|
break
|
||||||
|
key, _, value = line[1:].partition(":")
|
||||||
|
if value:
|
||||||
|
meta[key.strip()] = value.strip()
|
||||||
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
def load(path):
|
||||||
|
"""Load a capture written by logger.py."""
|
||||||
|
with open(path, newline="") as handle:
|
||||||
|
meta = _parse_header(handle)
|
||||||
|
rows = list(csv.DictReader(handle))
|
||||||
|
|
||||||
|
if "rm3100_capture" not in meta:
|
||||||
|
raise CaptureError(
|
||||||
|
f"{path}: no capture header found. Files written before the header "
|
||||||
|
"format was introduced cannot be read -- re-record them.")
|
||||||
|
missing = {"cycle_count", "tesla_per_count", "nominal_rate_hz"} - meta.keys()
|
||||||
|
if missing:
|
||||||
|
raise CaptureError(f"{path}: header missing {', '.join(sorted(missing))}")
|
||||||
|
if len(rows) < 64:
|
||||||
|
raise CaptureError(f"{path}: only {len(rows)} samples, too few to analyse")
|
||||||
|
|
||||||
|
# logger.py writes "MISSED" in the count fields for a measurement it could
|
||||||
|
# not read, keeping sample_index contiguous so the chip-time grid stays
|
||||||
|
# valid across the gap.
|
||||||
|
if "warning" not in rows[0]:
|
||||||
|
raise CaptureError(
|
||||||
|
f"{path}: no 'warning' column. Captures predating it cannot be "
|
||||||
|
"read -- re-record them.")
|
||||||
|
flags = [set(r["warning"].split()) for r in rows]
|
||||||
|
missed = np.array([WARN_MISSED in f for f in flags])
|
||||||
|
# Independent of missed: a gap can be uncertain yet round to zero losses,
|
||||||
|
# in which case the flag rides on the real sample that ends it.
|
||||||
|
ambiguous = np.array([WARN_AMBIGUOUS in f for f in flags])
|
||||||
|
if missed.all():
|
||||||
|
raise CaptureError(f"{path}: every row is a lost measurement")
|
||||||
|
|
||||||
|
sample_index = np.array([int(r["sample_index"]) for r in rows], dtype=np.int64)
|
||||||
|
# logger.py aborts on a missed interval, so a gap here means the file was
|
||||||
|
# damaged or hand-edited rather than merely cut short.
|
||||||
|
gaps = np.diff(sample_index)
|
||||||
|
if np.any(gaps != 1):
|
||||||
|
bad = int(sample_index[np.argmax(gaps != 1)])
|
||||||
|
raise CaptureError(
|
||||||
|
f"{path}: sample_index is not contiguous (breaks after {bad}). "
|
||||||
|
"The chip grid is only valid for an unbroken index.")
|
||||||
|
|
||||||
|
# Lost measurements carry no data. They are linearly interpolated so the
|
||||||
|
# uniform grid the spectra depend on is preserved, and counted so the
|
||||||
|
# substitution is never silent.
|
||||||
|
counts = {}
|
||||||
|
good = ~missed
|
||||||
|
for a in "xyz":
|
||||||
|
# Every row parses as an integer -- placeholders carry zeros, and the
|
||||||
|
# warning column is what marks them as having no data.
|
||||||
|
v = np.array([int(r[f"{a}_raw"]) for r in rows], dtype=np.float64)
|
||||||
|
if missed.any():
|
||||||
|
v[missed] = np.interp(np.flatnonzero(missed), np.flatnonzero(good),
|
||||||
|
v[good])
|
||||||
|
counts[a] = v
|
||||||
|
|
||||||
|
return Capture(
|
||||||
|
path, meta, sample_index,
|
||||||
|
np.array([float(r["system_time_unix"]) for r in rows]),
|
||||||
|
counts, missed, ambiguous,
|
||||||
|
)
|
||||||
79
ch347.py
79
ch347.py
|
|
@ -13,6 +13,8 @@ aystarik/ch347-i2c-spi-gpio Linux driver, whose USB id table entry
|
||||||
USB_DEVICE_INTERFACE_NUMBER(0x1a86, 0x55db, 0x02) matches this device.
|
USB_DEVICE_INTERFACE_NUMBER(0x1a86, 0x55db, 0x02) matches this device.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
import usb.core
|
import usb.core
|
||||||
import usb.util
|
import usb.util
|
||||||
|
|
||||||
|
|
@ -34,7 +36,11 @@ CMD_OUT = 0x80 # write; low 6 bits are the byte count
|
||||||
CMD_IN = 0xC0 # read; 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
|
CMD_SET = 0x60 # set bus speed; low bits select the rate
|
||||||
|
|
||||||
# Bus speeds, as the low nibble of CMD_SET.
|
# Bus speeds, as the low nibble of CMD_SET. The fastest is the default: bus
|
||||||
|
# speed does not set the sample rate -- the sensor does -- but it does set how
|
||||||
|
# long a read takes, and so how tightly an event on the far side of the bus can
|
||||||
|
# be timestamped. Measured host cost per sample is bus time plus ~0.6 ms of
|
||||||
|
# fixed USB round-trip latency: 2.13 ms at 100 kHz, 0.92 at 400, 0.79 at 750.
|
||||||
SPEED_20KHZ = 0
|
SPEED_20KHZ = 0
|
||||||
SPEED_100KHZ = 1
|
SPEED_100KHZ = 1
|
||||||
SPEED_400KHZ = 2
|
SPEED_400KHZ = 2
|
||||||
|
|
@ -54,7 +60,7 @@ class CH347I2C:
|
||||||
followed by the payload.
|
followed by the payload.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, speed=SPEED_100KHZ):
|
def __init__(self, speed=SPEED_750KHZ):
|
||||||
self._dev = usb.core.find(idVendor=VENDOR_ID, idProduct=PRODUCT_ID)
|
self._dev = usb.core.find(idVendor=VENDOR_ID, idProduct=PRODUCT_ID)
|
||||||
if self._dev is None:
|
if self._dev is None:
|
||||||
raise IOError(
|
raise IOError(
|
||||||
|
|
@ -90,21 +96,32 @@ class CH347I2C:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _xfer(self, out_bytes, in_len):
|
def _xfer(self, out_bytes, in_len):
|
||||||
"""Send one command stream, then read in_len status/data bytes."""
|
"""Send one command stream, then read in_len status/data bytes.
|
||||||
|
|
||||||
|
Returns (data, mono, wall), the clocks read the instant the reply
|
||||||
|
lands -- before any parsing or unwinding of the call stack. A caller
|
||||||
|
timing an event on the far side of the bus (DRDY going high, say) wants
|
||||||
|
that moment, not one several Python frames later: the frames add both a
|
||||||
|
systematic lag and jitter from whatever the interpreter does between.
|
||||||
|
|
||||||
|
The stamp travels with its own reply rather than being left on the
|
||||||
|
instance, so it cannot go stale or be picked up by the wrong call.
|
||||||
|
"""
|
||||||
written = self._dev.write(EP_OUT, bytes(out_bytes), TIMEOUT_MS)
|
written = self._dev.write(EP_OUT, bytes(out_bytes), TIMEOUT_MS)
|
||||||
if written != len(out_bytes):
|
if written != len(out_bytes):
|
||||||
raise IOError(
|
raise IOError(
|
||||||
f"Short USB write to CH347: sent {written} of {len(out_bytes)} bytes"
|
f"Short USB write to CH347: sent {written} of {len(out_bytes)} bytes"
|
||||||
)
|
)
|
||||||
if in_len == 0:
|
if in_len == 0:
|
||||||
return b""
|
return b"", time.monotonic(), time.time()
|
||||||
|
|
||||||
reply = bytes(self._dev.read(EP_IN, in_len, TIMEOUT_MS))
|
reply = bytes(self._dev.read(EP_IN, in_len, TIMEOUT_MS))
|
||||||
|
mono, wall = time.monotonic(), time.time()
|
||||||
if len(reply) != in_len:
|
if len(reply) != in_len:
|
||||||
raise IOError(
|
raise IOError(
|
||||||
f"Short USB read from CH347: got {len(reply)} of {in_len} bytes"
|
f"Short USB read from CH347: got {len(reply)} of {in_len} bytes"
|
||||||
)
|
)
|
||||||
return reply
|
return reply, mono, wall
|
||||||
|
|
||||||
def set_speed(self, speed):
|
def set_speed(self, speed):
|
||||||
"""Select the I2C clock rate (one of the SPEED_* constants)."""
|
"""Select the I2C clock rate (one of the SPEED_* constants)."""
|
||||||
|
|
@ -126,7 +143,7 @@ class CH347I2C:
|
||||||
packet += [CMD_STO, CMD_END]
|
packet += [CMD_STO, CMD_END]
|
||||||
|
|
||||||
# One status byte per clocked-out byte: the address plus the payload.
|
# One status byte per clocked-out byte: the address plus the payload.
|
||||||
reply = self._xfer(packet, len(data) + 1)
|
reply, _, _ = self._xfer(packet, len(data) + 1)
|
||||||
if reply[0] != ACK:
|
if reply[0] != ACK:
|
||||||
raise IOError(f"No ACK from I2C address 0x{addr:02x} on write")
|
raise IOError(f"No ACK from I2C address 0x{addr:02x} on write")
|
||||||
if any(byte != ACK for byte in reply[1:]):
|
if any(byte != ACK for byte in reply[1:]):
|
||||||
|
|
@ -136,7 +153,7 @@ class CH347I2C:
|
||||||
)
|
)
|
||||||
|
|
||||||
def read(self, addr, count):
|
def read(self, addr, count):
|
||||||
"""Read count bytes from a 7-bit address. Raises IOError on NACK."""
|
"""Read count bytes from an address. Returns (data, mono, wall)."""
|
||||||
if not 1 <= count <= MAX_XFER:
|
if not 1 <= count <= MAX_XFER:
|
||||||
raise ValueError(f"Read of {count} bytes outside 1..{MAX_XFER}")
|
raise ValueError(f"Read of {count} bytes outside 1..{MAX_XFER}")
|
||||||
|
|
||||||
|
|
@ -147,16 +164,58 @@ class CH347I2C:
|
||||||
packet.append(CMD_IN | (count - 1))
|
packet.append(CMD_IN | (count - 1))
|
||||||
packet += [CMD_IN, CMD_STO, CMD_END]
|
packet += [CMD_IN, CMD_STO, CMD_END]
|
||||||
|
|
||||||
reply = self._xfer(packet, count + 1)
|
reply, mono, wall = self._xfer(packet, count + 1)
|
||||||
if reply[0] != ACK:
|
if reply[0] != ACK:
|
||||||
raise IOError(f"No ACK from I2C address 0x{addr:02x} on read")
|
raise IOError(f"No ACK from I2C address 0x{addr:02x} on read")
|
||||||
return reply[1:]
|
return reply[1:], mono, wall
|
||||||
|
|
||||||
|
def write_read(self, addr, data, count):
|
||||||
|
"""Write data then read count bytes in one transaction.
|
||||||
|
|
||||||
|
Returns (data, mono, wall) -- see _xfer() on why the stamp is returned
|
||||||
|
rather than stored.
|
||||||
|
|
||||||
|
Uses a repeated START rather than STOP-then-START, so the whole
|
||||||
|
exchange is a single USB round trip instead of two. Measured at
|
||||||
|
400 kHz this halves a one-byte register read (0.65 -> 0.35 ms), which
|
||||||
|
matters because the host round trip, not the bus, sets the sample-rate
|
||||||
|
ceiling.
|
||||||
|
|
||||||
|
The RM3100's manual draws its register reads with a STOP between the
|
||||||
|
pointer write and the read (sections 4.5.2, 5.8.4), but the part
|
||||||
|
accepts a repeated START -- verified against REVID and the measurement
|
||||||
|
registers.
|
||||||
|
"""
|
||||||
|
data = bytes(data)
|
||||||
|
if not data:
|
||||||
|
raise ValueError("write_read needs at least one byte to write")
|
||||||
|
if len(data) > MAX_XFER - 1:
|
||||||
|
raise ValueError(f"Write of {len(data)} bytes exceeds the CH347 limit")
|
||||||
|
if not 1 <= count <= MAX_XFER:
|
||||||
|
raise ValueError(f"Read of {count} bytes outside 1..{MAX_XFER}")
|
||||||
|
|
||||||
|
packet = [CMD_STREAM, CMD_STA, CMD_OUT | (len(data) + 1), addr << 1]
|
||||||
|
packet += data
|
||||||
|
packet += [CMD_STA, CMD_OUT | 1, (addr << 1) | 1]
|
||||||
|
if count > 1:
|
||||||
|
packet.append(CMD_IN | (count - 1))
|
||||||
|
packet += [CMD_IN, CMD_STO, CMD_END]
|
||||||
|
|
||||||
|
# One status byte per clocked-out byte: the write address, the payload,
|
||||||
|
# and the read address -- then the payload itself.
|
||||||
|
acks = len(data) + 2
|
||||||
|
reply, mono, wall = self._xfer(packet, acks + count)
|
||||||
|
if any(byte != ACK for byte in reply[:acks]):
|
||||||
|
raise IOError(
|
||||||
|
f"I2C address 0x{addr:02x} NACKed during combined transfer "
|
||||||
|
f"(status {reply[:acks].hex(' ')})")
|
||||||
|
return reply[acks:], mono, wall
|
||||||
|
|
||||||
def probe(self, addr):
|
def probe(self, addr):
|
||||||
"""Return True if a device ACKs its address. A NACK is not an error."""
|
"""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]
|
packet = [CMD_STREAM, CMD_STA, CMD_OUT | 1, addr << 1, CMD_STO, CMD_END]
|
||||||
try:
|
try:
|
||||||
return self._xfer(packet, 1)[0] == ACK
|
return self._xfer(packet, 1)[0][0] == ACK
|
||||||
except IOError:
|
except IOError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
|
||||||
112
characterize.py
112
characterize.py
|
|
@ -12,18 +12,17 @@ Produces a four-panel figure and a text summary:
|
||||||
over -- the honest measure of a noise floor.
|
over -- the honest measure of a noise floor.
|
||||||
Residual distribution after removing a slow trend, so a non-Gaussian
|
Residual distribution after removing a slow trend, so a non-Gaussian
|
||||||
tail or quantisation shows up.
|
tail or quantisation shows up.
|
||||||
Sample interval whether the timing supports spectral analysis
|
Read latency host-side diagnostics only -- the measurement
|
||||||
at all.
|
grid itself is uniform regardless.
|
||||||
|
|
||||||
Timing caveat: in continuous measurement mode the sensor samples on its own
|
The sample grid is exact, not assumed: the sensor samples on its own internal
|
||||||
internal schedule, so the true sample instants are near-uniform even when our
|
schedule (manual sections 5.7.2, 5.8.2), logger.py aborts rather than skip an
|
||||||
reads are jittery. The spectral estimates assume uniform spacing at the mean
|
interval, and capture.py refuses a non-contiguous sample index. Frequencies use
|
||||||
observed rate. That assumption holds only if no samples were missed or read
|
the rate measured against the host clock, not the nominal table value, which is
|
||||||
twice -- which is exactly what the sample-interval panel is there to check.
|
~6% out on this unit.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import csv
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import matplotlib
|
import matplotlib
|
||||||
|
|
@ -31,6 +30,8 @@ matplotlib.use("Agg")
|
||||||
import matplotlib.pyplot as plt
|
import matplotlib.pyplot as plt
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
import capture
|
||||||
|
|
||||||
SURFACE = "#fcfcfb"
|
SURFACE = "#fcfcfb"
|
||||||
TEXT_PRIMARY = "#0b0b0b"
|
TEXT_PRIMARY = "#0b0b0b"
|
||||||
TEXT_SECONDARY = "#52514e"
|
TEXT_SECONDARY = "#52514e"
|
||||||
|
|
@ -40,27 +41,16 @@ REFERENCE = "#8a8880"
|
||||||
# Categorical slots 1-3; three peer axes, validated all-pairs in light mode.
|
# Categorical slots 1-3; three peer axes, validated all-pairs in light mode.
|
||||||
AXES = [("x", "X", "#2a78d6"), ("y", "Y", "#eb6834"), ("z", "Z", "#1baf7a")]
|
AXES = [("x", "X", "#2a78d6"), ("y", "Y", "#eb6834"), ("z", "Z", "#1baf7a")]
|
||||||
|
|
||||||
|
# Total field is derived from the three axes rather than 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.
|
||||||
|
TOTAL = ("total", "Total", TEXT_PRIMARY)
|
||||||
|
SERIES = AXES + [TOTAL]
|
||||||
|
|
||||||
# Table 3-1: "Noise Density @ Max. Single-Axis Sample Rate".
|
# Table 3-1: "Noise Density @ Max. Single-Axis Sample Rate".
|
||||||
SPEC_ASD_NT = 1.2
|
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):
|
def welch_asd(v, fs, nperseg=4096):
|
||||||
"""Amplitude spectral density in units/sqrt(Hz) via Welch's method."""
|
"""Amplitude spectral density in units/sqrt(Hz) via Welch's method."""
|
||||||
nperseg = min(nperseg, len(v) // 4 * 2 or len(v))
|
nperseg = min(nperseg, len(v) // 4 * 2 or len(v))
|
||||||
|
|
@ -115,32 +105,39 @@ def main():
|
||||||
help="ignore samples after this elapsed time (s)")
|
help="ignore samples after this elapsed time (s)")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
t, data, gain = load(args.csv)
|
try:
|
||||||
keep = t >= args.start
|
cap = capture.load(args.csv)
|
||||||
if args.end is not None:
|
if args.start or args.end is not None:
|
||||||
keep &= t <= args.end
|
cap = cap.restrict(args.start or None, args.end)
|
||||||
t, data = t[keep], {a: v[keep] for a, v in data.items()}
|
except (OSError, capture.CaptureError) as exc:
|
||||||
if len(t) < 64:
|
sys.exit(str(exc))
|
||||||
sys.exit("selected window has too few samples")
|
|
||||||
|
|
||||||
duration = t[-1] - t[0]
|
# Total field is derived from the three axes; its noise is dominated by
|
||||||
fs = (len(t) - 1) / duration
|
# whichever axis carries most of the field, weighted by direction cosines,
|
||||||
intervals = np.diff(t)
|
# so it tracks that axis rather than being an independent measurement.
|
||||||
lsb_nt = 1000.0 / gain
|
data = dict(cap.axes())
|
||||||
|
t = cap.elapsed
|
||||||
|
# Rate measured against the host clock, not the nominal table value: a 6%
|
||||||
|
# error would put every spectral feature 6% off.
|
||||||
|
fs = cap.true_rate_hz
|
||||||
|
lsb_nt = cap.lsb_nt
|
||||||
|
# Host read latency, kept apart from the sample grid it does not affect.
|
||||||
|
latency = np.diff(cap.system_time) # local read spacing, not the fit residual
|
||||||
|
|
||||||
print(f"{args.csv}: {len(t):,} samples over {duration:.2f} s")
|
# Normalising by |B| makes noise comparable across captures whose scale
|
||||||
print(f" mean rate {fs:.1f} Hz interval {intervals.mean()*1000:.3f} ms "
|
# differs -- a gain change carries the noise with it, so absolute sd alone
|
||||||
f"+/- {intervals.std()*1000:.3f} ms max {intervals.max()*1000:.1f} ms")
|
# will read as a noise difference when only the scale moved.
|
||||||
print(f" gain {gain:.2f} LSB/uT -> 1 LSB = {lsb_nt:.1f} nT")
|
field = data["total"].mean()
|
||||||
print(f" Nyquist {fs/2:.1f} Hz\n")
|
|
||||||
print("axis sd p2p sd/LSB white-noise ASD median ASD")
|
print(cap.summary() + "\n")
|
||||||
|
print("series sd sd/|B| p2p sd/LSB white-noise ASD median ASD")
|
||||||
|
|
||||||
fig, axs = plt.subplots(2, 2, figsize=(13.5, 9), dpi=150)
|
fig, axs = plt.subplots(2, 2, figsize=(13.5, 9), dpi=150)
|
||||||
fig.patch.set_facecolor(SURFACE)
|
fig.patch.set_facecolor(SURFACE)
|
||||||
for ax in axs.flat:
|
for ax in axs.flat:
|
||||||
ax.set_facecolor(SURFACE)
|
ax.set_facecolor(SURFACE)
|
||||||
|
|
||||||
for key, label, color in AXES:
|
for key, label, color in SERIES:
|
||||||
v = data[key]
|
v = data[key]
|
||||||
sd = v.std()
|
sd = v.std()
|
||||||
# A flat (white) spectrum of this sd would sit at this level.
|
# A flat (white) spectrum of this sd would sit at this level.
|
||||||
|
|
@ -159,7 +156,7 @@ def main():
|
||||||
axs[1, 0].hist(resid, bins=120, histtype="step", linewidth=1.4,
|
axs[1, 0].hist(resid, bins=120, histtype="step", linewidth=1.4,
|
||||||
color=color, label=label, density=True)
|
color=color, label=label, density=True)
|
||||||
|
|
||||||
print(f"{label:4s} {sd:8.1f} {v.max()-v.min():9.1f} nT "
|
print(f"{label:6s} {sd:8.1f} {sd/field*1e6:7.1f}ppm {v.max()-v.min():9.1f} nT "
|
||||||
f"{sd/lsb_nt:7.2f} {implied:9.2f} nT/rtHz "
|
f"{sd/lsb_nt:7.2f} {implied:9.2f} nT/rtHz "
|
||||||
f"{np.median(asd):9.2f} nT/rtHz")
|
f"{np.median(asd):9.2f} nT/rtHz")
|
||||||
|
|
||||||
|
|
@ -186,15 +183,18 @@ def main():
|
||||||
a.set_xlabel("nT"); a.set_ylabel("density")
|
a.set_xlabel("nT"); a.set_ylabel("density")
|
||||||
|
|
||||||
a = axs[1, 1]
|
a = axs[1, 1]
|
||||||
a.hist(intervals * 1000, bins=120, color=REFERENCE)
|
a.hist(latency * 1000, bins=120, color=REFERENCE)
|
||||||
a.set_yscale("log")
|
a.set_yscale("log")
|
||||||
a.axvline(1000 / fs, color=TEXT_PRIMARY, linestyle="--", linewidth=1.2)
|
a.axvline(cap.dt_true * 1000, color=TEXT_PRIMARY, linestyle="--", linewidth=1.2)
|
||||||
a.annotate(f"mean {1000/fs:.2f} ms", xy=(1000 / fs, 1),
|
a.annotate(f"grid {cap.dt_true * 1000:.2f} ms", xy=(cap.dt_true * 1000, 1),
|
||||||
xytext=(6, 0), textcoords="offset points",
|
xytext=(6, 0), textcoords="offset points",
|
||||||
color=TEXT_PRIMARY, fontsize=9)
|
color=TEXT_PRIMARY, fontsize=9)
|
||||||
a.set_title("Sample interval", loc="left",
|
a.set_title("Host read latency", loc="left",
|
||||||
color=TEXT_PRIMARY, fontsize=12, fontweight="bold", pad=8)
|
color=TEXT_PRIMARY, fontsize=12, fontweight="bold", pad=8)
|
||||||
a.set_xlabel("ms"); a.set_ylabel("count")
|
a.annotate("spread is host-side only;\nthe sample grid is exact",
|
||||||
|
xy=(0.98, 0.94), xycoords="axes fraction", ha="right", va="top",
|
||||||
|
color=TEXT_SECONDARY, fontsize=9)
|
||||||
|
a.set_xlabel("interval between reads (ms)"); a.set_ylabel("count")
|
||||||
|
|
||||||
for ax in axs.flat:
|
for ax in axs.flat:
|
||||||
ax.grid(True, which="both", color=GRID, linewidth=0.7)
|
ax.grid(True, which="both", color=GRID, linewidth=0.7)
|
||||||
|
|
@ -206,15 +206,19 @@ def main():
|
||||||
ax.tick_params(colors=TEXT_SECONDARY, labelsize=9, length=0)
|
ax.tick_params(colors=TEXT_SECONDARY, labelsize=9, length=0)
|
||||||
ax.xaxis.label.set_color(TEXT_SECONDARY)
|
ax.xaxis.label.set_color(TEXT_SECONDARY)
|
||||||
ax.yaxis.label.set_color(TEXT_SECONDARY)
|
ax.yaxis.label.set_color(TEXT_SECONDARY)
|
||||||
for ax in (axs[0, 0], axs[0, 1], axs[1, 0]):
|
# Explicit placement: "best" puts the ASD legend on top of the spec-line
|
||||||
ax.legend(frameon=False, fontsize=9, labelcolor=TEXT_SECONDARY)
|
# annotation in the lower left.
|
||||||
|
for ax, loc in ((axs[0, 0], "upper right"), (axs[0, 1], "upper right"),
|
||||||
|
(axs[1, 0], "upper right")):
|
||||||
|
ax.legend(frameon=False, fontsize=9, labelcolor=TEXT_SECONDARY, loc=loc)
|
||||||
|
|
||||||
fig.suptitle("RM3100 noise characterisation", color=TEXT_PRIMARY,
|
fig.suptitle("RM3100 noise characterisation", color=TEXT_PRIMARY,
|
||||||
fontsize=15, fontweight="bold", y=0.985)
|
fontsize=15, fontweight="bold", y=0.985)
|
||||||
fig.text(0.5, 0.945,
|
fig.text(0.5, 0.945,
|
||||||
f"{len(t):,} samples, {duration:.1f} s at {fs:.0f} Hz, "
|
f"{len(t):,} samples, {cap.duration:.1f} s at {fs:.2f} Hz measured "
|
||||||
f"1 LSB = {lsb_nt:.1f} nT. Spectra assume uniform sampling at the "
|
f"(nominal {cap.nominal_rate_hz:g} Hz, {cap.rate_error * 100:+.1f}%), "
|
||||||
f"mean rate (see interval panel).",
|
f"cycle count {cap.cycle_count}, 1 LSB = {lsb_nt:.2f} nT. "
|
||||||
|
f"Sample grid is exact.",
|
||||||
color=TEXT_SECONDARY, fontsize=10, ha="center")
|
color=TEXT_SECONDARY, fontsize=10, ha="center")
|
||||||
|
|
||||||
fig.tight_layout(rect=[0, 0, 1, 0.935])
|
fig.tight_layout(rect=[0, 0, 1, 0.935])
|
||||||
|
|
|
||||||
144
compare.py
Normal file
144
compare.py
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Compare captures taken under different conditions -- e.g. two supplies.
|
||||||
|
|
||||||
|
./.venv/bin/python compare.py a.csv b.csv c.csv ...
|
||||||
|
./.venv/bin/python compare.py --group note *.csv # group by header note
|
||||||
|
|
||||||
|
Built around one hard lesson: a naive comparison of absolute noise is wrong when
|
||||||
|
the measured scale differs between runs. A gain change carries the noise with it,
|
||||||
|
so a run that reads 6% larger also reads ~6% noisier while being physically
|
||||||
|
identical. Everything here is therefore reported **fractionally**, in ppm of the
|
||||||
|
field magnitude.
|
||||||
|
|
||||||
|
The second trap is attributing a scale change to the variable under test when
|
||||||
|
the sensor simply moved. Two diagnostics separate them:
|
||||||
|
|
||||||
|
per-axis ratio spread a pure gain change scales X, Y and Z identically,
|
||||||
|
so the spread is ~0. Anything larger means the sensor
|
||||||
|
moved.
|
||||||
|
rotation angle the angle between mean field directions. ~0 deg means
|
||||||
|
the sensor held still.
|
||||||
|
|
||||||
|
Both must be small before a magnitude difference can be blamed on gain. Note
|
||||||
|
that |B| is preserved under rotation but *not* under translation through a field
|
||||||
|
gradient, so a moved sensor can change magnitude on its own.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import itertools
|
||||||
|
import sys
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
import capture
|
||||||
|
import characterize as ch
|
||||||
|
|
||||||
|
# A pure gain change scales every axis by the same factor. Allow a little for
|
||||||
|
# noise on the means before calling it movement.
|
||||||
|
RATIO_SPREAD_OK = 0.01 # 1%
|
||||||
|
ROTATION_OK_DEG = 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def summarise(cap):
|
||||||
|
d = dict(cap.axes())
|
||||||
|
mean = np.array([d["x"].mean(), d["y"].mean(), d["z"].mean()])
|
||||||
|
field = float(np.linalg.norm(mean))
|
||||||
|
fs = cap.true_rate_hz
|
||||||
|
out = {"cap": cap, "mean": mean, "field": field, "axes": d, "fs": fs}
|
||||||
|
for key, _, _ in ch.SERIES:
|
||||||
|
v = d[key]
|
||||||
|
freqs, asd = ch.welch_asd(v, fs, nperseg=min(4096, len(v) // 4 * 2))
|
||||||
|
band = freqs > min(3.0, fs / 8)
|
||||||
|
out[key] = {
|
||||||
|
"sd": v.std(),
|
||||||
|
"ppm": v.std() / field * 1e6,
|
||||||
|
"asd": float(np.median(asd[band])) if band.any() else float("nan"),
|
||||||
|
"peak": float(asd[band].max()) if band.any() else float("nan"),
|
||||||
|
"peak_hz": float(freqs[band][np.argmax(asd[band])]) if band.any() else float("nan"),
|
||||||
|
}
|
||||||
|
taus, devs = ch.allan_deviation(d["total"], fs)
|
||||||
|
i = int(np.argmin(devs))
|
||||||
|
out["allan"] = (float(devs[i]), float(taus[i]))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def label_for(cap, path, group_by):
|
||||||
|
if group_by == "note":
|
||||||
|
return cap.meta.get("note", "(no note)")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
ap.add_argument("csv", nargs="+")
|
||||||
|
ap.add_argument("--group", choices=["note", "file"], default="file",
|
||||||
|
help="group captures by header note, averaging repeats "
|
||||||
|
"(default: %(default)s)")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
recs = []
|
||||||
|
for path in args.csv:
|
||||||
|
try:
|
||||||
|
cap = capture.load(path)
|
||||||
|
except (OSError, capture.CaptureError) as exc:
|
||||||
|
print(f"skipping {path}: {exc}", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
recs.append((label_for(cap, path, args.group), path, summarise(cap)))
|
||||||
|
if len(recs) < 1:
|
||||||
|
sys.exit("nothing to compare")
|
||||||
|
|
||||||
|
print(f"{'capture':<26} {'|B| nT':>10} {'sd nT':>8} {'sd ppm':>9} "
|
||||||
|
f"{'ASD':>7} {'peak':>8} {'@Hz':>7} {'Allan':>7}")
|
||||||
|
for label, path, s in recs:
|
||||||
|
t = s["total"]
|
||||||
|
print(f"{label[:26]:<26} {s['field']:10,.0f} {t['sd']:8.1f} {t['ppm']:9.1f} "
|
||||||
|
f"{t['asd']:7.2f} {t['peak']:8.1f} {t['peak_hz']:7.3f} "
|
||||||
|
f"{s['allan'][0]:7.2f}")
|
||||||
|
print(" ASD/peak in nT/rtHz; Allan = best sigma by averaging")
|
||||||
|
|
||||||
|
# Averaged per group, which is the number to compare when runs are repeated.
|
||||||
|
if args.group == "note":
|
||||||
|
groups = defaultdict(list)
|
||||||
|
for label, _, s in recs:
|
||||||
|
groups[label].append(s)
|
||||||
|
if len(groups) > 1:
|
||||||
|
print("\n=== group means (fractional -- the comparable figure) ===")
|
||||||
|
for label, ss in groups.items():
|
||||||
|
ppm = [s["total"]["ppm"] for s in ss]
|
||||||
|
print(f"{label[:26]:<26} n={len(ss)} "
|
||||||
|
f"|B| {np.mean([s['field'] for s in ss]):9,.0f} nT "
|
||||||
|
f"sd {np.mean(ppm):7.1f} ppm"
|
||||||
|
+ (f" +/- {np.std(ppm):.1f}" if len(ss) > 1 else ""))
|
||||||
|
|
||||||
|
if len(recs) < 2:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print("\n=== pairwise: did the sensor hold still? ===")
|
||||||
|
print("A magnitude difference only means gain if BOTH checks pass.")
|
||||||
|
for (la, _, a), (lb, _, b) in itertools.combinations(recs, 2):
|
||||||
|
ratio = b["mean"] / a["mean"]
|
||||||
|
spread = float(np.ptp(ratio) / np.abs(np.mean(ratio)))
|
||||||
|
ua, ub = a["mean"] / a["field"], b["mean"] / b["field"]
|
||||||
|
angle = float(np.degrees(np.arccos(np.clip(np.dot(ua, ub), -1, 1))))
|
||||||
|
moved = spread > RATIO_SPREAD_OK or angle > ROTATION_OK_DEG
|
||||||
|
print(f"\n{la[:24]} -> {lb[:24]}")
|
||||||
|
print(f" |B| ratio {b['field']/a['field']:.5f} "
|
||||||
|
f"({(b['field']/a['field'] - 1) * 100:+.2f}%)")
|
||||||
|
print(f" fractional noise {a['total']['ppm']:.1f} -> {b['total']['ppm']:.1f} ppm "
|
||||||
|
f"({(b['total']['ppm']/a['total']['ppm'] - 1) * 100:+.1f}%)")
|
||||||
|
print(f" per-axis ratios X {ratio[0]:.5f} Y {ratio[1]:.5f} Z {ratio[2]:.5f}"
|
||||||
|
f" spread {spread * 100:.2f}%")
|
||||||
|
print(f" rotation {angle:.3f} deg")
|
||||||
|
if moved:
|
||||||
|
print(" -> SENSOR MOVED. The magnitude difference cannot be "
|
||||||
|
"attributed to gain;\n re-run with the sensor clamped.")
|
||||||
|
else:
|
||||||
|
print(f" -> held still. The {(b['field']/a['field'] - 1) * 100:+.2f}% "
|
||||||
|
"magnitude difference is a real gain change.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
|
|
@ -6,7 +6,7 @@ broken, so a failure points at a specific wire rather than "it doesn't work":
|
||||||
|
|
||||||
adapter -> I2C bus -> sensor identity -> registers -> analog section
|
adapter -> I2C bus -> sensor identity -> registers -> analog section
|
||||||
|
|
||||||
Usage: ./.venv/bin/python diagnose.py
|
Usage: ./.venv/bin/python diagnose-comms.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
@ -19,6 +19,9 @@ import rm3100
|
||||||
# the most forgiving self-test settings (manual Tables 5-6, 5-7).
|
# the most forgiving self-test settings (manual Tables 5-6, 5-7).
|
||||||
BIST_RUN = 0x8F
|
BIST_RUN = 0x8F
|
||||||
|
|
||||||
|
# Matches the CH347I2C default; named here only so the report can state it.
|
||||||
|
BUS_KHZ = 750
|
||||||
|
|
||||||
|
|
||||||
def check(label, ok, detail=""):
|
def check(label, ok, detail=""):
|
||||||
print(f" [{'OK ' if ok else 'FAIL'}] {label}" + (f" -- {detail}" if detail else ""))
|
print(f" [{'OK ' if ok else 'FAIL'}] {label}" + (f" -- {detail}" if detail else ""))
|
||||||
|
|
@ -33,7 +36,8 @@ def main():
|
||||||
check("open CH347", False, str(exc))
|
check("open CH347", False, str(exc))
|
||||||
print("\n-> Run ./setup.sh, or check the adapter is plugged in and in Mode 1.")
|
print("\n-> Run ./setup.sh, or check the adapter is plugged in and in Mode 1.")
|
||||||
return 1
|
return 1
|
||||||
check("open CH347 and set I2C speed", True, "interface 2 claimed, 100 kHz")
|
check("open CH347 and set I2C speed", True,
|
||||||
|
f"interface {ch347.INTERFACE} claimed, {BUS_KHZ} kHz")
|
||||||
|
|
||||||
with bus:
|
with bus:
|
||||||
print("\n2. I2C bus")
|
print("\n2. I2C bus")
|
||||||
|
|
@ -77,7 +81,7 @@ def main():
|
||||||
sensor.write_reg(rm3100.REG_BIST, [BIST_RUN])
|
sensor.write_reg(rm3100.REG_BIST, [BIST_RUN])
|
||||||
sensor.write_reg(rm3100.REG_POLL, [rm3100.POLL_ALL_AXES])
|
sensor.write_reg(rm3100.REG_POLL, [rm3100.POLL_ALL_AXES])
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
bist = sensor.read_reg(rm3100.REG_BIST)[0]
|
bist = sensor.read_reg(rm3100.REG_BIST)[0][0]
|
||||||
sensor.write_reg(rm3100.REG_BIST, [0x00])
|
sensor.write_reg(rm3100.REG_BIST, [0x00])
|
||||||
|
|
||||||
axes = {"X": (bist >> 4) & 1, "Y": (bist >> 5) & 1, "Z": (bist >> 6) & 1}
|
axes = {"X": (bist >> 4) & 1, "Y": (bist >> 5) & 1, "Z": (bist >> 6) & 1}
|
||||||
|
|
@ -98,18 +102,31 @@ def main():
|
||||||
|
|
||||||
print("\n6. Live measurement")
|
print("\n6. Live measurement")
|
||||||
sensor.configure()
|
sensor.configure()
|
||||||
|
# BIST zeroes the measurement registers and leaves DRDY set. With
|
||||||
|
# DRC1=1 only a results read clears it, so discard one -- otherwise
|
||||||
|
# wait_for_data() returns instantly on the stale flag and reports zeros.
|
||||||
|
sensor.read_raw()
|
||||||
sensor.set_rate(0x96)
|
sensor.set_rate(0x96)
|
||||||
sensor.start_cmm()
|
sensor.start_cmm()
|
||||||
try:
|
try:
|
||||||
ready = sensor.wait_for_data(timeout=2.0)
|
ready = sensor.wait_for_data(timeout=2.0)
|
||||||
if not check("DRDY asserts", ready, "" if ready else "no data within 2 s"):
|
if not check("DRDY asserts", ready, "" if ready else "no data within 2 s"):
|
||||||
return 1
|
return 1
|
||||||
counts, ut = sensor.read_measurements()
|
counts, tesla = sensor.read_measurements()
|
||||||
magnitude = sum(v * v for v in ut) ** 0.5
|
magnitude = sum(v * v for v in tesla) ** 0.5
|
||||||
check("field magnitude is plausible", 25 <= magnitude <= 65,
|
nt = [v * rm3100.NT_PER_TESLA for v in tesla]
|
||||||
f"{magnitude:.1f} uT (Earth's field is 25-65)")
|
plausible = check(
|
||||||
|
"field magnitude is plausible", 25e-6 <= magnitude <= 65e-6,
|
||||||
|
f"{magnitude * rm3100.NT_PER_TESLA:,.0f} nT "
|
||||||
|
"(Earth's field is 25,000-65,000)")
|
||||||
print(f" raw {counts}")
|
print(f" raw {counts}")
|
||||||
print(f" X {ut[0]:+.3f} Y {ut[1]:+.3f} Z {ut[2]:+.3f} uT")
|
print(f" X {nt[0]:+,.0f} Y {nt[1]:+,.0f} Z {nt[2]:+,.0f} nT")
|
||||||
|
if not plausible:
|
||||||
|
print("\n-> Communication works but the numbers are wrong. A reading of\n"
|
||||||
|
" exactly zero means the results registers were read without a\n"
|
||||||
|
" fresh measurement behind them; anything else suggests the gain\n"
|
||||||
|
" or the int24 decode.")
|
||||||
|
return 1
|
||||||
finally:
|
finally:
|
||||||
sensor.stop_cmm()
|
sensor.stop_cmm()
|
||||||
|
|
||||||
554
logger.py
554
logger.py
|
|
@ -3,33 +3,34 @@
|
||||||
|
|
||||||
Run ./setup.sh first to install the udev rule and create the virtualenv, then:
|
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 --duration 60
|
||||||
./.venv/bin/python logger.py --scan-only
|
./.venv/bin/python logger.py --scan-only
|
||||||
|
|
||||||
The sampling loop is kept clear of everything that is not I2C traffic: raw
|
The chip samples on its own internal schedule (manual sections 5.7.2, 5.8.2), so
|
||||||
counts go onto a queue, and a writer thread does the unit conversion, CSV
|
`sample_index` is a grid coordinate, not a count of reads. A late read returns the
|
||||||
formatting and console display. At the rates the sensor can reach (~540 Hz at
|
*newer* measurement rather than a delayed one, so an unnoticed miss would silently
|
||||||
cycle count 50) that formatting work is otherwise the bottleneck, not the bus.
|
compress the time axis. Misses are therefore detected and written as explicit
|
||||||
|
placeholder rows, keeping the index contiguous and the gap visible; the run
|
||||||
|
continues and reports the total at the end.
|
||||||
|
|
||||||
|
Only irreducible facts are written: the sample count, the host clock, and the raw
|
||||||
|
counts. Chip time, tesla and magnitude are all reconstructed on load from the
|
||||||
|
header -- see capture.py.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import csv
|
import csv
|
||||||
import math
|
|
||||||
import queue
|
import queue
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime
|
||||||
|
|
||||||
import ch347
|
import ch347
|
||||||
import rm3100
|
import rm3100
|
||||||
|
|
||||||
CSV_FIELDS = [
|
CSV_FIELDS = ["sample_index", "system_time_unix", "x_raw", "y_raw", "z_raw",
|
||||||
"timestamp_iso", "elapsed_s",
|
"warning"]
|
||||||
"x_raw", "y_raw", "z_raw",
|
|
||||||
"x_uT", "y_uT", "z_uT",
|
|
||||||
"magnitude_uT",
|
|
||||||
]
|
|
||||||
|
|
||||||
BUS_SPEEDS = {
|
BUS_SPEEDS = {
|
||||||
20: ch347.SPEED_20KHZ,
|
20: ch347.SPEED_20KHZ,
|
||||||
|
|
@ -41,6 +42,35 @@ BUS_SPEEDS = {
|
||||||
CONSOLE_REFRESH_S = 0.05
|
CONSOLE_REFRESH_S = 0.05
|
||||||
FLUSH_INTERVAL_S = 0.5
|
FLUSH_INTERVAL_S = 0.5
|
||||||
|
|
||||||
|
# The sampling loop releases the GIL inside each USB transfer, then has to take
|
||||||
|
# it back. Python's default 5 ms switch interval means the writer thread can
|
||||||
|
# hold it for that long, stalling the sampler and inflating the DRDY bracket --
|
||||||
|
# measured at ~10 ms against a 6.67 ms budget at 150 Hz, while the same loop
|
||||||
|
# without a writer thread peaked at 3.1 ms. Handing off 10x more often costs
|
||||||
|
# negligible throughput (the writer is not CPU-bound) and keeps the stall well
|
||||||
|
# inside the bracket budget.
|
||||||
|
GIL_SWITCH_INTERVAL_S = 0.0005
|
||||||
|
|
||||||
|
# Miss detection uses the DRDY bracket -- the span between the last poll showing
|
||||||
|
# DRDY clear and the poll showing it set -- rather than the read-to-read
|
||||||
|
# interval.
|
||||||
|
#
|
||||||
|
# The bracket is exact where the interval is only a heuristic. If DRDY reads
|
||||||
|
# clear at t_c then every earlier measurement has already been read, since a
|
||||||
|
# results read is what clears it. Measurements complete one period apart, so a
|
||||||
|
# bracket narrower than one period can contain at most one completion, and DRDY
|
||||||
|
# going high proves it contained at least one. Exactly one, whatever the host
|
||||||
|
# was doing beforehand.
|
||||||
|
#
|
||||||
|
# The read-to-read interval cannot make that claim: measured on this rig it
|
||||||
|
# reaches 37 ms against a 28.8 ms period -- a 29% overshoot from host stalls
|
||||||
|
# alone -- while the bracket stays under 10 ms. Thresholding the interval
|
||||||
|
# therefore flags healthy captures as lossy.
|
||||||
|
#
|
||||||
|
# The bracket is compared against the period measured by calibrate_period(), not
|
||||||
|
# the table value: the latter is 6-9% out on this unit, which is enough to
|
||||||
|
# miscount grid points inside a bracket and slip the sample index.
|
||||||
|
|
||||||
# Bounded so a stalled writer degrades predictably instead of exhausting memory
|
# Bounded so a stalled writer degrades predictably instead of exhausting memory
|
||||||
# on a long run. Far above the depth a healthy writer ever reaches.
|
# on a long run. Far above the depth a healthy writer ever reaches.
|
||||||
QUEUE_MAX = 200_000
|
QUEUE_MAX = 200_000
|
||||||
|
|
@ -48,29 +78,111 @@ QUEUE_MAX = 200_000
|
||||||
_SENTINEL = object()
|
_SENTINEL = object()
|
||||||
|
|
||||||
|
|
||||||
|
# Rows carry flags in a `warning` column, space separated, empty when fine.
|
||||||
|
# Keeping them out of the numeric columns means x/y/z stay parseable as numbers
|
||||||
|
# (blank for a row with no data) and the column generalises to future flags.
|
||||||
|
#
|
||||||
|
# MISSED this row is a placeholder -- the measurement was never read, so it
|
||||||
|
# has no data. The row exists to keep sample_index contiguous, so it
|
||||||
|
# remains a valid chip-time grid coordinate and the gap is explicit
|
||||||
|
# rather than silently compressing the timeline.
|
||||||
|
# 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 terminating 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 the rows are flagged rather than silently resolved.
|
||||||
|
WARN_MISSED = "MISSED"
|
||||||
|
WARN_AMBIGUOUS = "AMBIGUOUS"
|
||||||
|
|
||||||
|
# How near a half-period the rounding may fall before the count is a coin toss.
|
||||||
|
AMBIGUITY_MARGIN = 0.25
|
||||||
|
|
||||||
|
|
||||||
def parse_args():
|
def parse_args():
|
||||||
p = argparse.ArgumentParser(description=__doc__,
|
p = argparse.ArgumentParser(description=__doc__,
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
p.add_argument("--cycle-count", type=int, default=rm3100.DEFAULT_CYCLE_COUNT,
|
knob = p.add_mutually_exclusive_group()
|
||||||
help="cycle count per axis; lower is faster but noisier. "
|
knob.add_argument("--rate", type=float, default=None,
|
||||||
"50 allows ~540 Hz, 200 allows ~150 Hz (default: %(default)s)")
|
help="target sample rate in Hz. The cycle count and TMRC "
|
||||||
p.add_argument("--tmrc", type=lambda s: int(s, 0), default=0x96,
|
"are derived from it, which is usually what you want "
|
||||||
|
"-- the cycle count is a continuous rate knob where "
|
||||||
|
"TMRC offers only factor-of-two steps")
|
||||||
|
knob.add_argument("--cycle-count", type=int, default=None,
|
||||||
|
help="cycle count per axis, setting both the rate and the "
|
||||||
|
"resolution. Lower is faster but more coarsely "
|
||||||
|
f"quantised (default: {rm3100.DEFAULT_CYCLE_COUNT})")
|
||||||
|
p.add_argument("--tmrc", type=lambda s: int(s, 0), default=None,
|
||||||
help="continuous-mode rate register, 0x92 (fastest) to 0x9D "
|
help="continuous-mode rate register, 0x92 (fastest) to 0x9D "
|
||||||
"(slowest) (default: 0x96, ~37 Hz)")
|
"(slowest). Default is the fastest, which lets the cycle "
|
||||||
|
"count set the rate -- give one only to sample slower "
|
||||||
|
"than the cycle count allows")
|
||||||
p.add_argument("--duration", type=float, default=0.0,
|
p.add_argument("--duration", type=float, default=0.0,
|
||||||
help="seconds to log, or 0 to run until Ctrl-C (default: %(default)s)")
|
help="seconds to log, or 0 to run until Ctrl-C (default: %(default)s)")
|
||||||
p.add_argument("--output", default=None,
|
p.add_argument("--output", default=None,
|
||||||
help="CSV output path (default: rm3100_<timestamp>.csv)")
|
help="CSV output path (default: rm3100_<timestamp>.csv)")
|
||||||
p.add_argument("--address", type=lambda s: int(s, 0), default=None,
|
p.add_argument("--address", type=lambda s: int(s, 0), default=None,
|
||||||
help="I2C address, skipping the scan (default: autodetect)")
|
help="I2C address, skipping the scan (default: autodetect)")
|
||||||
p.add_argument("--bus-speed", type=int, choices=[20, 100, 400, 750], default=100,
|
p.add_argument("--bus-speed", type=int, choices=[20, 100, 400, 750], default=750,
|
||||||
help="I2C bus speed in kHz; 400 measured best for high sample "
|
help="I2C bus speed in kHz. The default cycle count runs near "
|
||||||
"rates (default: %(default)s)")
|
"534 Hz, where 100 kHz would spend 80%% of each period on "
|
||||||
|
"the bus; 750 spends 11%% (default: %(default)s)")
|
||||||
|
p.add_argument("--calibrate", type=float, default=1.0,
|
||||||
|
help="seconds of loss-free samples used to measure the true "
|
||||||
|
"measurement period before recording starts; the run "
|
||||||
|
"aborts if no clean stretch can be found (default: "
|
||||||
|
"%(default)s)")
|
||||||
|
p.add_argument("--note", default=None,
|
||||||
|
help="free-text label recorded in the capture header, e.g. "
|
||||||
|
"the supply under test. Keeps the configuration with the "
|
||||||
|
"data instead of only in the filename")
|
||||||
p.add_argument("--scan-only", action="store_true",
|
p.add_argument("--scan-only", action="store_true",
|
||||||
help="scan the bus, report what responded, and exit")
|
help="scan the bus, report what responded, and exit")
|
||||||
return p.parse_args()
|
return p.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def print_plan(cfg, bus_speed, requested_rate):
|
||||||
|
"""Show how the configuration was derived, so it can be checked not trusted."""
|
||||||
|
lsb = rm3100.tesla_per_count(cfg.cycle_count) * rm3100.NT_PER_TESLA
|
||||||
|
dither = rm3100.expected_noise_nt(cfg.cycle_count) / lsb
|
||||||
|
period = 1.0 / cfg.predicted_hz
|
||||||
|
bus = i2c_bus_time(bus_speed)
|
||||||
|
share = bus / period
|
||||||
|
|
||||||
|
print("Configuration")
|
||||||
|
if requested_rate is not None:
|
||||||
|
print(f" requested {requested_rate:g} Hz")
|
||||||
|
print(f" cycle count {cfg.cycle_count:,}"
|
||||||
|
f"{'':<8}1 / (3 x ({cfg.cycle_count}/"
|
||||||
|
f"{rm3100.COUNTS_PER_SECOND:,.0f} + "
|
||||||
|
f"{rm3100.AXIS_OVERHEAD_S * 1e6:.1f} us))")
|
||||||
|
print(f" sensor ceiling {1 / rm3100.sample_period(cfg.cycle_count):.2f} Hz")
|
||||||
|
print(f" TMRC 0x{cfg.tmrc:02x} = "
|
||||||
|
f"{rm3100.TMRC_RATES[cfg.tmrc]:g} Hz -- {cfg.governed_by} governs")
|
||||||
|
print(f" predicted rate {cfg.predicted_hz:.2f} Hz "
|
||||||
|
f"+/-{rm3100.RATE_TOLERANCE:.0%} (oscillator tolerance; measured below)")
|
||||||
|
print(f" resolution {lsb:.2f} nT/LSB dither {dither:.2f} LSB "
|
||||||
|
"at spec noise")
|
||||||
|
print(f" duty {cfg.duty:.1%} integration / period")
|
||||||
|
print(f" bus {bus_speed} kHz {bus * 1e3:.3f} ms/sample "
|
||||||
|
f"{share:.1%} of the period")
|
||||||
|
for note in cfg.notes:
|
||||||
|
print(f" note: {note}")
|
||||||
|
# Bus speed is validated but never changed silently -- swapping it would
|
||||||
|
# hide a wiring problem behind a configuration change.
|
||||||
|
if share > 0.5:
|
||||||
|
faster = [s for s in sorted(BUS_SPEEDS) if i2c_bus_time(s) / period < 0.25]
|
||||||
|
fix = f"; {faster[0]} kHz would fit" if faster else ""
|
||||||
|
print(f" WARNING: the bus needs {share:.0%} of every period{fix}",
|
||||||
|
file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
def find_sensor(bus, address):
|
def find_sensor(bus, address):
|
||||||
"""Locate the RM3100, or exit with wiring guidance."""
|
"""Locate the RM3100, or exit with wiring guidance."""
|
||||||
if address is not None:
|
if address is not None:
|
||||||
|
|
@ -87,7 +199,7 @@ def find_sensor(bus, address):
|
||||||
else:
|
else:
|
||||||
print("Nothing responded anywhere on the bus. Check, in order:\n"
|
print("Nothing responded anywhere on the bus. Check, in order:\n"
|
||||||
" - I2CEN tied high (otherwise the chip stays in SPI mode)\n"
|
" - I2CEN tied high (otherwise the chip stays in SPI mode)\n"
|
||||||
" - the adapter's voltage jumper (the RM3100 wants ~3.3 V)\n"
|
" - AVDD/VDD powered, not just DVDD\n"
|
||||||
" - SDA/SCL not swapped\n"
|
" - SDA/SCL not swapped\n"
|
||||||
" - SDA and SCL pull-up resistors present", file=sys.stderr)
|
" - SDA and SCL pull-up resistors present", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
@ -103,7 +215,7 @@ def find_sensor(bus, address):
|
||||||
|
|
||||||
|
|
||||||
def start_sensor(sensor, cycle_count, tmrc):
|
def start_sensor(sensor, cycle_count, tmrc):
|
||||||
"""Identify and configure the sensor, printing each step."""
|
"""Identify and configure the sensor, printing each step. Returns REVID."""
|
||||||
revid = sensor.revid()
|
revid = sensor.revid()
|
||||||
if revid == rm3100.EXPECTED_REVID:
|
if revid == rm3100.EXPECTED_REVID:
|
||||||
print(f"REVID 0x{revid:02x} -- RM3100 confirmed")
|
print(f"REVID 0x{revid:02x} -- RM3100 confirmed")
|
||||||
|
|
@ -117,25 +229,29 @@ def start_sensor(sensor, cycle_count, tmrc):
|
||||||
print(f"ERROR: cycle count read back as {readback}, expected "
|
print(f"ERROR: cycle count read back as {readback}, expected "
|
||||||
f"{(cycle_count,) * 3}", file=sys.stderr)
|
f"{(cycle_count,) * 3}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
gain = rm3100.gain_lsb_per_ut(cycle_count)
|
lsb_nt = rm3100.tesla_per_count(cycle_count) * rm3100.NT_PER_TESLA
|
||||||
print(f"Cycle counts set to {readback} -- gain {gain:.1f} LSB/uT")
|
print(f"Cycle counts set to {readback} -- 1 LSB = {lsb_nt:.2f} nT")
|
||||||
|
|
||||||
|
# Raises if DRC1 did not take; exactly-once sampling depends on it.
|
||||||
sensor.configure()
|
sensor.configure()
|
||||||
sensor.set_rate(tmrc)
|
sensor.set_rate(tmrc)
|
||||||
rate = rm3100.TMRC_RATES[tmrc]
|
|
||||||
print(f"Rate register 0x{tmrc:02x} -- requesting about {rate:g} Hz")
|
|
||||||
|
|
||||||
sensor.start_cmm()
|
sensor.start_cmm()
|
||||||
print("Continuous measurement mode started")
|
print("Continuous measurement mode started")
|
||||||
return rate
|
return revid
|
||||||
|
|
||||||
|
|
||||||
def writer_thread(q, path, gain, start_wall, stats):
|
def write_header(handle, meta):
|
||||||
"""Drain raw samples: convert, format, write CSV, drive the console.
|
for key, value in meta.items():
|
||||||
|
handle.write(f"# {key}: {value}\n")
|
||||||
|
|
||||||
Everything here is deliberately off the sampling thread.
|
|
||||||
"""
|
def writer_thread(q, path, meta, stats):
|
||||||
|
"""Drain samples to CSV and drive the console, off the sampling thread."""
|
||||||
|
lsb_nt = float(meta["tesla_per_count"]) * rm3100.NT_PER_TESLA
|
||||||
with open(path, "w", newline="") as handle:
|
with open(path, "w", newline="") as handle:
|
||||||
|
write_header(handle, meta)
|
||||||
out = csv.writer(handle)
|
out = csv.writer(handle)
|
||||||
out.writerow(CSV_FIELDS)
|
out.writerow(CSV_FIELDS)
|
||||||
last_print = 0.0
|
last_print = 0.0
|
||||||
|
|
@ -145,72 +261,261 @@ def writer_thread(q, path, gain, start_wall, stats):
|
||||||
item = q.get()
|
item = q.get()
|
||||||
if item is _SENTINEL:
|
if item is _SENTINEL:
|
||||||
break
|
break
|
||||||
elapsed, cx, cy, cz = item
|
index, system_time, cx, cy, cz, warning = item
|
||||||
|
|
||||||
ux, uy, uz = cx / gain, cy / gain, cz / gain
|
# 6 decimal places is microsecond resolution on an epoch value,
|
||||||
magnitude = math.sqrt(ux * ux + uy * uy + uz * uz)
|
# far finer than the ~1 ms bracket uncertainty on each timestamp.
|
||||||
# Wall-clock is reconstructed from the monotonic offset rather than
|
# Placeholder rows carry zeros rather than blanks: the warning
|
||||||
# sampled per reading: one fewer syscall in the hot path, and immune
|
# column already says the row has no data, so keeping x/y/z strictly
|
||||||
# to NTP steps mid-capture.
|
# integer makes the file trivial to parse.
|
||||||
stamp = (start_wall + timedelta(seconds=elapsed)).isoformat()
|
out.writerow([index, f"{system_time:.6f}",
|
||||||
|
0 if cx is None else cx,
|
||||||
out.writerow([stamp, f"{elapsed:.5f}", cx, cy, cz,
|
0 if cy is None else cy,
|
||||||
f"{ux:.4f}", f"{uy:.4f}", f"{uz:.4f}",
|
0 if cz is None else cz, warning])
|
||||||
f"{magnitude:.4f}"])
|
|
||||||
stats["rows"] += 1
|
stats["rows"] += 1
|
||||||
|
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if now - last_flush >= FLUSH_INTERVAL_S:
|
if now - last_flush >= FLUSH_INTERVAL_S:
|
||||||
handle.flush()
|
handle.flush()
|
||||||
last_flush = now
|
last_flush = now
|
||||||
if now - last_print >= CONSOLE_REFRESH_S:
|
if now - last_print >= CONSOLE_REFRESH_S and cx is not None:
|
||||||
|
# Placeholders carry no field values, so the display holds the
|
||||||
|
# last real reading rather than blanking or showing nonsense.
|
||||||
last_print = now
|
last_print = now
|
||||||
print(f"\r{elapsed:8.2f}s X {ux:+9.3f} Y {uy:+9.3f} "
|
x, y, z = cx * lsb_nt, cy * lsb_nt, cz * lsb_nt
|
||||||
f"Z {uz:+9.3f} |B| {magnitude:8.3f} uT "
|
print(f"\r{stats['rows']:,} samples ({stats['missed']} missed)"
|
||||||
f"({stats['rows']} samples, "
|
f" | X {x:+10.1f} | Y {y:+10.1f} | Z {z:+10.1f}"
|
||||||
f"{stats['rows'] / max(elapsed, 1e-9):.0f} Hz)",
|
f" | T {(x * x + y * y + z * z) ** 0.5:10.1f} nT",
|
||||||
end="", flush=True)
|
end="", flush=True)
|
||||||
handle.flush()
|
handle.flush()
|
||||||
|
|
||||||
|
|
||||||
def sample_loop(sensor, q, duration, rate, stats):
|
def i2c_bus_time(bus_speed_khz):
|
||||||
"""Read the sensor as fast as it produces data. I2C and nothing else."""
|
"""Seconds of I2C traffic the bus carries per sample.
|
||||||
sample_timeout = max(2.0, 5.0 / rate)
|
|
||||||
|
A combined register read of n payload bytes clocks n+3 bytes -- write
|
||||||
|
address, register, read address, payload -- at 9 bit-times each (8 data plus
|
||||||
|
ACK), and spends roughly a bit-time on each START and STOP. One sample costs
|
||||||
|
a STATUS poll (1 payload byte) plus a results read (9).
|
||||||
|
|
||||||
|
This is the irreducible traffic, not bus occupancy: the loop polls
|
||||||
|
continuously while waiting for DRDY, so raw occupancy approaches 100% and
|
||||||
|
says nothing useful. What matters is this against the sample period.
|
||||||
|
"""
|
||||||
|
bits = 0
|
||||||
|
for payload in (1, 9):
|
||||||
|
bits += (payload + 3) * 9 + 3 # 2 STARTs and a STOP
|
||||||
|
return bits / (bus_speed_khz * 1000.0)
|
||||||
|
|
||||||
|
|
||||||
|
def calibrate_period(sensor, dt_nominal, seconds, attempts=3):
|
||||||
|
"""Measure the true measurement period from a clean stretch of samples.
|
||||||
|
|
||||||
|
Miss detection needs the real period, not the table value: the RC oscillator
|
||||||
|
runs up to 7% off (manual section 5.2.1) and this unit is ~6% slow, which is
|
||||||
|
enough to miscount grid points inside a bracket and slip the sample index.
|
||||||
|
|
||||||
|
Least-squares slope of read time against index. Over one second at 35 Hz
|
||||||
|
(~35 points, ~0.5 ms jitter) that pins the period to roughly 0.03%, against
|
||||||
|
the 6-9% error of the nominal value -- and 0.03% is far finer than the
|
||||||
|
resolution needed to tell k grid points from k+1.
|
||||||
|
|
||||||
|
Requires a stretch with no miss, since an interval spanning a miss is a
|
||||||
|
multiple of the period, not one period. Returns None if no clean stretch is
|
||||||
|
available, which is itself the answer: the rate is not sustainable.
|
||||||
|
|
||||||
|
Also returns the mean host cost of one sample's traffic -- a DRDY poll plus
|
||||||
|
a results read -- timed here at no extra cost, since those are the very
|
||||||
|
transactions being performed.
|
||||||
|
"""
|
||||||
|
poll_ready, read_raw = sensor.poll_ready, sensor.read_raw
|
||||||
|
monotonic = time.monotonic
|
||||||
|
|
||||||
|
# Bootstrap the period from observation rather than trusting the TMRC table.
|
||||||
|
# When the cycle count governs instead of TMRC -- which it does whenever the
|
||||||
|
# cycle-count ceiling falls below the requested rate (manual section 5.2.1)
|
||||||
|
# -- the table value is not merely 7% out but wrong by a large factor. At
|
||||||
|
# cc=400 with TMRC 0x92 the table says 1.67 ms and the truth is 13.5 ms, so
|
||||||
|
# a threshold built on it rejects every interval and calibration can never
|
||||||
|
# start. A handful of raw intervals settles it: the median is robust to the
|
||||||
|
# occasional stall, and only needs to be close enough to seed the real fit.
|
||||||
|
probe = []
|
||||||
|
clear = monotonic()
|
||||||
|
deadline = clear + max(seconds, 0.25)
|
||||||
|
while monotonic() < deadline and len(probe) < 24:
|
||||||
|
ready_now, stamp, _ = poll_ready()
|
||||||
|
if not ready_now:
|
||||||
|
continue
|
||||||
|
read_raw()
|
||||||
|
probe.append(stamp)
|
||||||
|
if len(probe) >= 4:
|
||||||
|
gaps = sorted(b - a for a, b in zip(probe, probe[1:]))
|
||||||
|
dt_nominal = gaps[len(gaps) // 2]
|
||||||
|
|
||||||
|
for _ in range(attempts):
|
||||||
|
times = []
|
||||||
|
poll_cost = read_cost = 0.0
|
||||||
|
polls = 0
|
||||||
|
clear = monotonic()
|
||||||
|
deadline = clear + seconds
|
||||||
|
clean = True
|
||||||
|
while monotonic() < deadline:
|
||||||
|
before = monotonic()
|
||||||
|
ready_now, after, _ = poll_ready()
|
||||||
|
poll_cost += monotonic() - before
|
||||||
|
polls += 1
|
||||||
|
if not ready_now:
|
||||||
|
clear = after
|
||||||
|
continue
|
||||||
|
ready = after
|
||||||
|
if ready - clear >= dt_nominal: # conservative: nominal is short
|
||||||
|
clean = False
|
||||||
|
break
|
||||||
|
read_raw()
|
||||||
|
read_cost += monotonic() - ready
|
||||||
|
times.append(ready)
|
||||||
|
clear = ready
|
||||||
|
n = len(times)
|
||||||
|
if not clean or n < 8:
|
||||||
|
continue
|
||||||
|
# slope of t against i for i = 0..n-1, where Sxx = n(n^2-1)/12.
|
||||||
|
mean_i = (n - 1) / 2.0
|
||||||
|
mean_t = sum(times) / n
|
||||||
|
sxy = sum((i - mean_i) * (t - mean_t) for i, t in enumerate(times))
|
||||||
|
return (sxy / (n * (n * n - 1) / 12.0),
|
||||||
|
poll_cost / max(polls, 1) + read_cost / n)
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def sample_loop(sensor, q, duration, dt_nominal, stats):
|
||||||
|
"""Read every measurement, recording placeholders for any that are lost.
|
||||||
|
|
||||||
|
The timestamp recorded per sample is the midpoint of the bracket between the
|
||||||
|
last poll showing DRDY clear and the poll showing it set: the measurement
|
||||||
|
completed somewhere in that window, and the midpoint is the best estimate
|
||||||
|
available without a hardware DRDY line.
|
||||||
|
"""
|
||||||
read_raw = sensor.read_raw
|
read_raw = sensor.read_raw
|
||||||
data_ready = sensor.data_ready
|
poll_ready = sensor.poll_ready
|
||||||
monotonic = time.monotonic
|
monotonic = time.monotonic
|
||||||
put = q.put_nowait
|
put = q.put_nowait
|
||||||
|
|
||||||
start = monotonic()
|
|
||||||
timeouts = 0
|
|
||||||
deadline = start + duration if duration > 0 else float("inf")
|
|
||||||
|
|
||||||
while monotonic() < deadline:
|
# DRDY may already be set from whatever ran before -- BIST in particular
|
||||||
if not data_ready():
|
# zeroes the result registers and leaves it high. Discard one reading so
|
||||||
if monotonic() - start > sample_timeout and stats["rows"] == 0:
|
# sample 0 is a genuinely fresh measurement.
|
||||||
timeouts += 1
|
if poll_ready()[0]:
|
||||||
print(f"\nWARNING: no data ready within {sample_timeout:.1f} s",
|
read_raw()
|
||||||
file=sys.stderr)
|
|
||||||
if timeouts >= 3:
|
deadline = monotonic() + duration if duration > 0 else float("inf")
|
||||||
print("Giving up after 3 consecutive timeouts.", file=sys.stderr)
|
index = 0
|
||||||
return
|
# Bracket lower bound: the most recent moment DRDY was seen clear.
|
||||||
|
clear_mono, clear_wall = monotonic(), time.time()
|
||||||
|
|
||||||
|
# dt_nominal here is the *calibrated* period from calibrate_period(), not
|
||||||
|
# the table value. Keep refining it from clean intervals so the estimate
|
||||||
|
# follows the oscillator's thermal drift (~2500 ppm measured over 13 h).
|
||||||
|
dt_est = dt_nominal
|
||||||
|
previous_mono = previous_wall = None
|
||||||
|
DT_SMOOTHING = 0.02
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if monotonic() >= deadline:
|
||||||
|
return
|
||||||
|
# The stamps come back with the STATUS reply itself, taken the instant
|
||||||
|
# it landed rather than several frames later up here.
|
||||||
|
ready, ready_mono, ready_wall = poll_ready()
|
||||||
|
if not ready:
|
||||||
|
clear_mono, clear_wall = ready_mono, ready_wall
|
||||||
continue
|
continue
|
||||||
|
# Monotonic drives detection so an NTP step cannot fake a miss; the
|
||||||
|
# wall clock is recorded so a real NTP correction stays visible.
|
||||||
|
bracket = ready_mono - clear_mono
|
||||||
|
if bracket > stats["max_bracket"]:
|
||||||
|
stats["max_bracket"] = bracket
|
||||||
|
|
||||||
cx, cy, cz = read_raw()
|
# Counting lost measurements in two stages, because the two questions
|
||||||
|
# have different best answers.
|
||||||
|
#
|
||||||
|
# "Did we lose any?" is settled by the bracket, and rigorously: a
|
||||||
|
# bracket shorter than one period cannot contain two completions, so
|
||||||
|
# nothing was lost regardless of timing precision.
|
||||||
|
#
|
||||||
|
# "How many?" is better answered by the interval since the previous
|
||||||
|
# accepted sample. Each accepted read sits on a grid point, so that
|
||||||
|
# interval is very close to an exact multiple of the period, and
|
||||||
|
# rounding recovers the multiple. The bracket alone cannot -- it only
|
||||||
|
# spans back to the last poll that saw DRDY clear, which discards where
|
||||||
|
# the grid actually is.
|
||||||
|
#
|
||||||
|
# Precision: the period is calibrated to ~0.07% and each completion is
|
||||||
|
# located to about half a poll interval (~0.2 ms, under 1% of a period).
|
||||||
|
# Over a gap of k periods the total error is well under a tenth of a
|
||||||
|
# period for any small k, so the rounding is unambiguous -- which is
|
||||||
|
# the case that matters, since a long gap means the run is unusable
|
||||||
|
# anyway.
|
||||||
|
lost = 0
|
||||||
|
uncertain = False
|
||||||
|
if bracket >= dt_est and previous_mono is not None:
|
||||||
|
periods = (ready_mono - previous_mono) / dt_est
|
||||||
|
lost = max(0, round(periods) - 1)
|
||||||
|
# Uncertainty is a property of the gap, not of the placeholders:
|
||||||
|
# a gap rounding to zero losses can still be a coin toss, and then
|
||||||
|
# there is no placeholder to carry the flag.
|
||||||
|
uncertain = abs(periods - round(periods)) > AMBIGUITY_MARGIN
|
||||||
|
if uncertain:
|
||||||
|
stats["ambiguous"] += 1
|
||||||
|
elif previous_mono is not None:
|
||||||
|
# A clean interval is exactly one period, so it calibrates dt_est.
|
||||||
|
# Only clean ones qualify: an interval spanning a miss is a multiple.
|
||||||
|
dt_est += (ready_mono - previous_mono - dt_est) * DT_SMOOTHING
|
||||||
|
if lost:
|
||||||
|
stats["missed"] += lost
|
||||||
|
# Placeholders sit on the grid -- one period after the previous
|
||||||
|
# accepted sample, and so on -- because that is where the lost
|
||||||
|
# measurements actually completed. Spreading them across the
|
||||||
|
# bracket instead would bunch them at the end of the gap and skew
|
||||||
|
# the rate fit.
|
||||||
|
flags = (f"{WARN_MISSED} {WARN_AMBIGUOUS}" if uncertain
|
||||||
|
else WARN_MISSED)
|
||||||
|
for k in range(lost):
|
||||||
|
try:
|
||||||
|
put((index, previous_wall + dt_est * (k + 1),
|
||||||
|
None, None, None, flags))
|
||||||
|
except queue.Full:
|
||||||
|
stats["dropped"] += 1
|
||||||
|
index += 1
|
||||||
|
|
||||||
|
sample_wall = (clear_wall + ready_wall) / 2.0
|
||||||
|
(cx, cy, cz), _, _ = read_raw()
|
||||||
try:
|
try:
|
||||||
put((monotonic() - start, cx, cy, cz))
|
# An uncertain gap that produced no placeholder still has to be
|
||||||
|
# flagged, so it rides on the real sample that ends it.
|
||||||
|
put((index, sample_wall, cx, cy, cz,
|
||||||
|
WARN_AMBIGUOUS if uncertain and not lost else ""))
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
stats["dropped"] += 1
|
stats["dropped"] += 1
|
||||||
|
index += 1
|
||||||
|
previous_mono, previous_wall = ready_mono, sample_wall
|
||||||
|
# The read just cleared DRDY, so this is a known-clear instant.
|
||||||
|
clear_mono, clear_wall = ready_mono, ready_wall
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
|
sys.setswitchinterval(GIL_SWITCH_INTERVAL_S)
|
||||||
|
|
||||||
if args.cycle_count < 1 or args.cycle_count > 0xFFFF:
|
if args.cycle_count is not None and not 1 <= args.cycle_count <= 0xFFFF:
|
||||||
sys.exit(f"--cycle-count {args.cycle_count} outside 1..65535")
|
sys.exit(f"--cycle-count {args.cycle_count} outside 1..65535")
|
||||||
if args.tmrc not in rm3100.TMRC_RATES:
|
if args.rate is not None and args.rate <= 0:
|
||||||
|
sys.exit(f"--rate {args.rate:g} must be positive")
|
||||||
|
if args.tmrc is not None and args.tmrc not in rm3100.TMRC_RATES:
|
||||||
sys.exit(f"--tmrc 0x{args.tmrc:02x} is not a valid rate register value")
|
sys.exit(f"--tmrc 0x{args.tmrc:02x} is not a valid rate register value")
|
||||||
|
|
||||||
|
cfg = rm3100.plan(rate_hz=args.rate, cycle_count=args.cycle_count,
|
||||||
|
tmrc=args.tmrc)
|
||||||
|
args.cycle_count, args.tmrc = cfg.cycle_count, cfg.tmrc
|
||||||
|
|
||||||
try:
|
try:
|
||||||
bus = ch347.CH347I2C(BUS_SPEEDS[args.bus_speed])
|
bus = ch347.CH347I2C(BUS_SPEEDS[args.bus_speed])
|
||||||
except IOError as exc:
|
except IOError as exc:
|
||||||
|
|
@ -223,26 +528,84 @@ def main():
|
||||||
found = bus.scan()
|
found = bus.scan()
|
||||||
print("Devices found: " + ", ".join(f"0x{a:02x}" for a in found)
|
print("Devices found: " + ", ".join(f"0x{a:02x}" for a in found)
|
||||||
if found else "No devices responded on the bus.")
|
if found else "No devices responded on the bus.")
|
||||||
return
|
return 0
|
||||||
|
|
||||||
address = find_sensor(bus, args.address)
|
address = find_sensor(bus, args.address)
|
||||||
sensor = rm3100.RM3100(bus, address)
|
sensor = rm3100.RM3100(bus, address)
|
||||||
rate = start_sensor(sensor, args.cycle_count, args.tmrc)
|
try:
|
||||||
|
revid = start_sensor(sensor, args.cycle_count, args.tmrc)
|
||||||
|
except IOError as exc:
|
||||||
|
sys.exit(str(exc))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print_plan(cfg, args.bus_speed, args.rate)
|
||||||
|
print()
|
||||||
|
nominal_rate = cfg.predicted_hz
|
||||||
|
|
||||||
|
# Establish the real period before recording. Everything downstream --
|
||||||
|
# miss detection, placeholder counting, the sample-index grid -- depends
|
||||||
|
# on it, and the table value is 6-9% out on this unit.
|
||||||
|
dt, host_cost = calibrate_period(sensor, 1.0 / nominal_rate,
|
||||||
|
args.calibrate)
|
||||||
|
if dt is None:
|
||||||
|
sensor.stop_cmm()
|
||||||
|
sys.exit(
|
||||||
|
f"Could not find {args.calibrate:g} s of loss-free samples at "
|
||||||
|
f"{nominal_rate:.1f} Hz.\n"
|
||||||
|
"Without a clean stretch the true period cannot be measured, so "
|
||||||
|
"misses cannot be\ncounted reliably and the sample index would "
|
||||||
|
"not track chip time.\n"
|
||||||
|
"Lower the rate (higher --tmrc) or raise --bus-speed.")
|
||||||
|
# Compared against the predicted rate, not TMRC's nominal: when the
|
||||||
|
# cycle count governs, the TMRC figure is not what was aimed for and the
|
||||||
|
# error against it is meaningless.
|
||||||
|
print(f"Calibrated period {dt * 1e3:.4f} ms = {1 / dt:.4f} Hz "
|
||||||
|
f"({(1 / dt) / cfg.predicted_hz - 1:+.2%} vs predicted)")
|
||||||
|
# Whether the host is the constraint is a measurement, not a given: it
|
||||||
|
# costs a near-constant ~0.9 ms per sample regardless of configuration,
|
||||||
|
# so it dominates at short periods and disappears at long ones.
|
||||||
|
share = host_cost / dt
|
||||||
|
margin = ("little margin -- expect misses under load" if share > 0.5
|
||||||
|
else "modest margin" if share > 0.25
|
||||||
|
else "ample margin")
|
||||||
|
print(f"Host cost {host_cost * 1e3:.3f} ms/sample "
|
||||||
|
f"({share:.1%} of the period, {margin})")
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
"rm3100_capture": 1,
|
||||||
|
# The rate this configuration is predicted to produce, whichever of
|
||||||
|
# the two ceilings governs -- not TMRC's table value, which is not
|
||||||
|
# what was aimed for when the cycle count wins.
|
||||||
|
"nominal_rate_hz": nominal_rate,
|
||||||
|
"tmrc_nominal_hz": rm3100.TMRC_RATES[args.tmrc],
|
||||||
|
"tmrc": f"0x{args.tmrc:02x}",
|
||||||
|
"cycle_count": args.cycle_count,
|
||||||
|
# repr() so the constant round-trips through float64 exactly.
|
||||||
|
"tesla_per_count": repr(rm3100.tesla_per_count(args.cycle_count)),
|
||||||
|
"i2c_address": f"0x{address:02x}",
|
||||||
|
"bus_speed_khz": args.bus_speed,
|
||||||
|
"revid": f"0x{revid:02x}",
|
||||||
|
# What the logger actually used for miss detection. Not recoverable
|
||||||
|
# from the data, since capture.py fits the whole run rather than the
|
||||||
|
# first second.
|
||||||
|
"calibrated_period_s": repr(dt),
|
||||||
|
}
|
||||||
|
if args.note:
|
||||||
|
# Newlines would break the one-line-per-key header format.
|
||||||
|
meta["note"] = " ".join(args.note.split())
|
||||||
|
|
||||||
path = args.output or datetime.now().strftime("rm3100_%Y%m%d_%H%M%S.csv")
|
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")
|
print(f"Logging to {path} -- Ctrl-C to stop\n")
|
||||||
|
|
||||||
q = queue.Queue(maxsize=QUEUE_MAX)
|
q = queue.Queue(maxsize=QUEUE_MAX)
|
||||||
stats = {"rows": 0, "dropped": 0}
|
stats = {"rows": 0, "dropped": 0, "missed": 0, "ambiguous": 0,
|
||||||
writer = threading.Thread(
|
"max_bracket": 0.0}
|
||||||
target=writer_thread,
|
writer = threading.Thread(target=writer_thread,
|
||||||
args=(q, path, rm3100.gain_lsb_per_ut(args.cycle_count),
|
args=(q, path, meta, stats), daemon=True)
|
||||||
datetime.now(timezone.utc), stats),
|
|
||||||
daemon=True)
|
|
||||||
writer.start()
|
writer.start()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sample_loop(sensor, q, args.duration, rate, stats)
|
sample_loop(sensor, q, args.duration, dt, stats)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
|
|
@ -254,11 +617,38 @@ def main():
|
||||||
print("Continuous measurement mode stopped")
|
print("Continuous measurement mode stopped")
|
||||||
except IOError as exc:
|
except IOError as exc:
|
||||||
print(f"WARNING: could not stop CMM: {exc}", file=sys.stderr)
|
print(f"WARNING: could not stop CMM: {exc}", file=sys.stderr)
|
||||||
if stats["dropped"]:
|
|
||||||
print(f"WARNING: dropped {stats['dropped']} samples -- the writer "
|
if stats["dropped"]:
|
||||||
"could not keep up", file=sys.stderr)
|
print(f"WARNING: dropped {stats['dropped']} samples -- the writer "
|
||||||
print(f"Wrote {stats['rows']} samples to {path}")
|
"could not keep up", file=sys.stderr)
|
||||||
|
print(f"Wrote {stats['rows']} samples to {path}")
|
||||||
|
# How close the run came to losing a measurement: the useful number for
|
||||||
|
# judging whether a rate is sustainable before committing to a long run.
|
||||||
|
# Quoted against the calibrated period, which is the threshold actually
|
||||||
|
# enforced -- the nominal one is 6-9% out.
|
||||||
|
print(f"Worst DRDY bracket {stats['max_bracket'] * 1e3:.2f} ms of "
|
||||||
|
f"{dt * 1e3:.2f} ms allowed "
|
||||||
|
f"({stats['max_bracket'] / dt * 100:.0f}% of margin used)")
|
||||||
|
|
||||||
|
if stats["ambiguous"]:
|
||||||
|
print(f"\nWARNING: {stats['ambiguous']:,} gap(s) could not be "
|
||||||
|
"counted confidently -- the interval fell near a half-period, "
|
||||||
|
"so\nthe number of lost measurements is a guess and "
|
||||||
|
f"sample_index may have slipped.\nThose rows carry "
|
||||||
|
f"{WARN_AMBIGUOUS} in the warning column. Do not trust this "
|
||||||
|
"capture's\ntime axis for spectral work.", file=sys.stderr)
|
||||||
|
|
||||||
|
if stats["missed"]:
|
||||||
|
pct = stats["missed"] / max(stats["rows"], 1) * 100
|
||||||
|
print(f"\nWARNING: {stats['missed']:,} measurement(s) were lost "
|
||||||
|
f"({pct:.3f}% of rows), flagged {WARN_MISSED}.\n"
|
||||||
|
"The sample index still tracks chip time -- the gaps are "
|
||||||
|
"explicit, not compressed --\nbut those rows carry no field "
|
||||||
|
"data. Lower the rate (higher --tmrc) or raise --bus-speed\n"
|
||||||
|
"to remove them.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
sys.exit(main())
|
||||||
|
|
|
||||||
94
noise_floor_test.sh
Executable file
94
noise_floor_test.sh
Executable file
|
|
@ -0,0 +1,94 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Run a noise-floor capture and analyse it in one step.
|
||||||
|
#
|
||||||
|
# Wraps logger.py -- every logger flag is accepted and passed straight through --
|
||||||
|
# then runs plot.py and characterize.py on whatever was captured. Works whether
|
||||||
|
# the run ends on --duration or on Ctrl-C.
|
||||||
|
#
|
||||||
|
# ./noise_floor_test.sh --duration 60
|
||||||
|
# ./noise_floor_test.sh --duration 3600 --cycle-count 400
|
||||||
|
# ./noise_floor_test.sh # until Ctrl-C
|
||||||
|
#
|
||||||
|
# Anything you pass explicitly wins; otherwise logger.py's defaults apply.
|
||||||
|
|
||||||
|
set -uo pipefail # deliberately not -e: logger.py exits 1 on a miss,
|
||||||
|
# and a partial capture is still worth analysing.
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
if [[ ! -f .venv/bin/activate ]]; then
|
||||||
|
echo "No virtualenv found. Run ./setup.sh first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source .venv/bin/activate
|
||||||
|
|
||||||
|
args=("$@")
|
||||||
|
|
||||||
|
has_flag() {
|
||||||
|
local needle=$1 a
|
||||||
|
for a in "${args[@]:-}"; do
|
||||||
|
[[ "$a" == "$needle" || "$a" == "$needle="* ]] && return 0
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# The CSV path has to be known up front so the analysis can find it; logger.py
|
||||||
|
# would otherwise pick a timestamped name we cannot predict without racing it.
|
||||||
|
output=""
|
||||||
|
for ((i = 0; i < ${#args[@]}; i++)); do
|
||||||
|
case "${args[i]}" in
|
||||||
|
--output=*) output="${args[i]#--output=}" ;;
|
||||||
|
--output|-o) output="${args[i+1]:-}" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
if [[ -z "$output" ]]; then
|
||||||
|
output="noise_$(date +%Y%m%d_%H%M%S).csv"
|
||||||
|
args+=(--output "$output")
|
||||||
|
fi
|
||||||
|
|
||||||
|
has_flag --bus-speed || args+=(--bus-speed 750)
|
||||||
|
|
||||||
|
echo "=== capture -> $output ==="
|
||||||
|
# Ctrl-C reaches the whole foreground process group. Trapping it here stops bash
|
||||||
|
# killing the script, so logger.py can shut down cleanly and the analysis below
|
||||||
|
# still runs. A no-op trap (not 'ignore') keeps the child's own handling intact.
|
||||||
|
trap ':' INT
|
||||||
|
python logger.py "${args[@]}"
|
||||||
|
capture_rc=$?
|
||||||
|
trap - INT
|
||||||
|
|
||||||
|
# A non-zero logger exit covers both "aborted mid-capture" and "never started"
|
||||||
|
# (no adapter, bad flag). Whether a usable file exists is what distinguishes
|
||||||
|
# them, so check that before claiming anything about the data.
|
||||||
|
if [[ ! -s "$output" ]]; then
|
||||||
|
echo
|
||||||
|
echo "Capture produced no data (exit $capture_rc); nothing to analyse." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ "$capture_rc" -ne 0 ]]; then
|
||||||
|
echo
|
||||||
|
echo "Capture ended early (exit $capture_rc). Analysing what was recorded --" \
|
||||||
|
"it is valid up to that point." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== plot ==="
|
||||||
|
python plot.py "$output"
|
||||||
|
plot_rc=$?
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== characterize ==="
|
||||||
|
python characterize.py "$output"
|
||||||
|
char_rc=$?
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== outputs ==="
|
||||||
|
base="${output%.csv}"
|
||||||
|
for f in "$output" "$base.png" "${base}_noise.png"; do
|
||||||
|
[[ -f "$f" ]] && printf ' %-40s %8s\n' "$f" "$(du -h "$f" | cut -f1)"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Non-zero if any stage failed, so this is usable from a scheduler.
|
||||||
|
[[ $capture_rc -eq 0 && $plot_rc -eq 0 && $char_rc -eq 0 ]] || exit 1
|
||||||
62
plot.py
62
plot.py
|
|
@ -11,7 +11,6 @@ independently, and note the per-panel mean/sd annotation for context.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import csv
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import matplotlib
|
import matplotlib
|
||||||
|
|
@ -19,6 +18,8 @@ matplotlib.use("Agg")
|
||||||
import matplotlib.pyplot as plt
|
import matplotlib.pyplot as plt
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
import capture
|
||||||
|
|
||||||
# Light-mode design tokens.
|
# Light-mode design tokens.
|
||||||
SURFACE = "#fcfcfb"
|
SURFACE = "#fcfcfb"
|
||||||
TEXT_PRIMARY = "#0b0b0b"
|
TEXT_PRIMARY = "#0b0b0b"
|
||||||
|
|
@ -31,33 +32,13 @@ GRID = "#e3e2df"
|
||||||
# also keeps the categorical set at the three slots that validate for small
|
# also keeps the categorical set at the three slots that validate for small
|
||||||
# multiples.
|
# multiples.
|
||||||
SERIES = [
|
SERIES = [
|
||||||
("x_uT", "X axis", "#2a78d6"),
|
("x", "X axis", "#2a78d6"),
|
||||||
("y_uT", "Y axis", "#eb6834"),
|
("y", "Y axis", "#eb6834"),
|
||||||
("z_uT", "Z axis", "#1baf7a"),
|
("z", "Z axis", "#1baf7a"),
|
||||||
(None, "Norm |B| = sqrt(X^2 + Y^2 + Z^2)", TEXT_PRIMARY),
|
("total", "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):
|
def rolling_mean(v, window):
|
||||||
"""Centred moving average that stays smooth all the way to both ends.
|
"""Centred moving average that stays smooth all the way to both ends.
|
||||||
|
|
||||||
|
|
@ -83,14 +64,16 @@ def main():
|
||||||
"(default: %(default)s)")
|
"(default: %(default)s)")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
t, x, y, z, gain = load(args.csv)
|
try:
|
||||||
# Inverse of rm3100.gain_lsb_per_ut().
|
cap = capture.load(args.csv)
|
||||||
cycle_count = (gain - 1.5) / 0.3671
|
except (OSError, capture.CaptureError) as exc:
|
||||||
norm = np.sqrt(x**2 + y**2 + z**2)
|
sys.exit(str(exc))
|
||||||
series = {"x_uT": x, "y_uT": y, "z_uT": z, None: norm}
|
|
||||||
|
|
||||||
duration = t[-1] - t[0]
|
# Drift-corrected time: the nominal grid would be ~6% off real seconds.
|
||||||
rate = len(t) / duration if duration > 0 else float("nan")
|
t = cap.elapsed
|
||||||
|
series = dict(cap.axes())
|
||||||
|
duration = cap.duration
|
||||||
|
rate = cap.true_rate_hz
|
||||||
window = max(1, int(round(args.smooth * rate))) if args.smooth > 0 else 0
|
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, axes = plt.subplots(4, 1, figsize=(12, 9.5), sharex=True, dpi=150)
|
||||||
|
|
@ -108,13 +91,13 @@ def main():
|
||||||
ax.plot(t, rolling_mean(v, window), color=color, linewidth=1.6,
|
ax.plot(t, rolling_mean(v, window), color=color, linewidth=1.6,
|
||||||
solid_capstyle="round")
|
solid_capstyle="round")
|
||||||
|
|
||||||
ax.set_ylabel("µT", color=TEXT_SECONDARY, fontsize=10)
|
ax.set_ylabel("nT", color=TEXT_SECONDARY, fontsize=10)
|
||||||
# Direct label instead of a legend: one series per panel, so the title
|
# 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.
|
# names it. This is also the relief the palette's contrast WARN requires.
|
||||||
ax.set_title(label, color=TEXT_PRIMARY, fontsize=12, loc="left",
|
ax.set_title(label, color=TEXT_PRIMARY, fontsize=12, loc="left",
|
||||||
pad=8, fontweight="bold")
|
pad=8, fontweight="bold")
|
||||||
ax.annotate(f"mean {v.mean():.3f} sd {v.std() * 1000:.0f} nT "
|
ax.annotate(f"mean {v.mean():,.0f} nT sd {v.std():.0f} nT "
|
||||||
f"span {v.max() - v.min():.3f} µT",
|
f"span {v.max() - v.min():,.0f} nT",
|
||||||
xy=(1.0, 1.0), xycoords="axes fraction",
|
xy=(1.0, 1.0), xycoords="axes fraction",
|
||||||
xytext=(0, 8), textcoords="offset points",
|
xytext=(0, 8), textcoords="offset points",
|
||||||
ha="right", va="bottom",
|
ha="right", va="bottom",
|
||||||
|
|
@ -137,8 +120,9 @@ def main():
|
||||||
fig.suptitle("RM3100 magnetometer capture", color=TEXT_PRIMARY,
|
fig.suptitle("RM3100 magnetometer capture", color=TEXT_PRIMARY,
|
||||||
fontsize=15, fontweight="bold", x=0.5, y=0.985)
|
fontsize=15, fontweight="bold", x=0.5, y=0.985)
|
||||||
fig.text(0.5, 0.955,
|
fig.text(0.5, 0.955,
|
||||||
f"{len(t):,} samples over {duration:.1f} s ({rate:.0f} Hz), "
|
f"{len(t):,} samples over {duration:.1f} s at {rate:.2f} Hz "
|
||||||
f"cycle count {cycle_count:.0f} ({gain:.1f} LSB/µT)"
|
f"(nominal {cap.nominal_rate_hz:g} Hz, {cap.rate_error * 100:+.1f}%), "
|
||||||
|
f"cycle count {cap.cycle_count} (1 LSB = {cap.lsb_nt:.1f} nT)"
|
||||||
f"{smooth_note}. Panels have independent y-scales.",
|
f"{smooth_note}. Panels have independent y-scales.",
|
||||||
color=TEXT_SECONDARY, fontsize=10, ha="center")
|
color=TEXT_SECONDARY, fontsize=10, ha="center")
|
||||||
|
|
||||||
|
|
@ -148,8 +132,8 @@ def main():
|
||||||
print(f"{len(t):,} samples, {duration:.2f} s, {rate:.1f} Hz -> {out}")
|
print(f"{len(t):,} samples, {duration:.2f} s, {rate:.1f} Hz -> {out}")
|
||||||
for key, label, _ in SERIES:
|
for key, label, _ in SERIES:
|
||||||
v = series[key]
|
v = series[key]
|
||||||
print(f" {label.split()[0]:5s} mean {v.mean():+9.3f} uT "
|
print(f" {label.split()[0]:5s} mean {v.mean():+12,.1f} nT "
|
||||||
f"sd {v.std()*1000:6.1f} nT span {v.max()-v.min():6.3f} uT")
|
f"sd {v.std():8.1f} nT span {v.max()-v.min():10,.1f} nT")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
235
rm3100.py
235
rm3100.py
|
|
@ -8,6 +8,7 @@ and read(addr, count).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
from collections import namedtuple
|
||||||
|
|
||||||
# Register addresses (manual Table 5-1).
|
# Register addresses (manual Table 5-1).
|
||||||
REG_POLL = 0x00 # single measurement trigger
|
REG_POLL = 0x00 # single measurement trigger
|
||||||
|
|
@ -45,16 +46,183 @@ STATUS_DRDY = 0x80
|
||||||
|
|
||||||
EXPECTED_REVID = 0x22
|
EXPECTED_REVID = 0x22
|
||||||
|
|
||||||
DEFAULT_CYCLE_COUNT = 200
|
# 50 buys bandwidth almost for free. Against cycle count 200 it costs 4.4% in
|
||||||
|
# post-filter noise (89% duty against 97%) and nothing else, while giving 3.7x
|
||||||
|
# the spectrum -- 267 Hz of Nyquist against 73 Hz. That matters because aliased
|
||||||
|
# interference cannot be filtered out afterwards at any cycle count, so seeing
|
||||||
|
# it is worth more than a few percent of noise.
|
||||||
|
#
|
||||||
|
# The one thing to verify rather than assume is dither: 50 sits at 0.58 LSB of
|
||||||
|
# intrinsic noise, which simulation puts safely in the region where averaging
|
||||||
|
# still recovers sub-LSB resolution (it fails below ~0.2). characterize.py
|
||||||
|
# prints sd/LSB, which answers it from the first capture. Fall back to 100 or
|
||||||
|
# 200 if that comes back low.
|
||||||
|
DEFAULT_CYCLE_COUNT = 50
|
||||||
|
|
||||||
|
# The fastest rate register. Used by default so the cycle count, not TMRC, sets
|
||||||
|
# the rate -- TMRC offers only factor-of-two steps and has no effect at all once
|
||||||
|
# the cycle count governs.
|
||||||
|
TMRC_FASTEST = 0x92
|
||||||
|
|
||||||
|
|
||||||
def gain_lsb_per_ut(cycle_count):
|
# Table 3-1 quotes gain in LSB per microtesla; these are the coefficients of a
|
||||||
"""Sensitivity in LSB per microtesla for a given cycle count.
|
# linear fit to its 20, 38 and 75 LSB/uT at cycle counts 50, 100 and 200, which
|
||||||
|
# reproduces all three to within a count. Working units in this codebase are
|
||||||
|
# tesla, so the fit is scaled by 1e6 on the way out.
|
||||||
|
_GAIN_SLOPE_LSB_PER_UT = 0.3671
|
||||||
|
_GAIN_OFFSET_LSB_PER_UT = 1.5
|
||||||
|
UT_PER_TESLA = 1e6
|
||||||
|
|
||||||
Linear fit to manual Table 3-1, which quotes 20, 38 and 75 LSB/uT at cycle
|
# Displays and plots use nanotesla; no computation is done in it.
|
||||||
counts of 50, 100 and 200; this reproduces all three to within a count.
|
NT_PER_TESLA = 1e9
|
||||||
|
|
||||||
|
|
||||||
|
def gain_lsb_per_tesla(cycle_count):
|
||||||
|
"""Sensitivity in LSB per tesla for a given cycle count."""
|
||||||
|
return (_GAIN_SLOPE_LSB_PER_UT * cycle_count
|
||||||
|
+ _GAIN_OFFSET_LSB_PER_UT) * UT_PER_TESLA
|
||||||
|
|
||||||
|
|
||||||
|
def tesla_per_count(cycle_count):
|
||||||
|
"""Calibration constant: multiply a raw count by this to get tesla.
|
||||||
|
|
||||||
|
Stored in capture headers, so a reader needs no knowledge of the gain fit --
|
||||||
|
and it is a multiply at the point of use rather than a divide.
|
||||||
"""
|
"""
|
||||||
return 0.3671 * cycle_count + 1.5
|
return 1.0 / gain_lsb_per_tesla(cycle_count)
|
||||||
|
|
||||||
|
|
||||||
|
# Timing model for a three-axis measurement:
|
||||||
|
#
|
||||||
|
# per-axis time = cycle_count / COUNTS_PER_SECOND + AXIS_OVERHEAD_S
|
||||||
|
#
|
||||||
|
# COUNTS_PER_SECOND comes from the specification, not from a fit: Table 3-1
|
||||||
|
# gives a 180 kHz circuit oscillation frequency, and section 4.1 measures each
|
||||||
|
# cycle count in *both* bias directions, so one count costs two oscillations.
|
||||||
|
# Rates measured here across cycle counts 229..29769 agree with that figure to
|
||||||
|
# within 0.8%, which confirms the spec value rather than improving on it -- the
|
||||||
|
# residual is this particular unit's oscillator sitting inside ordinary
|
||||||
|
# component tolerance, and another part would sit somewhere else.
|
||||||
|
#
|
||||||
|
# The overhead has no specified value and must be measured; 68.7 us reproduces
|
||||||
|
# the observed rates to better than 1% when the divisor is held at spec. It is
|
||||||
|
# why the naive "rate x cycle_count" constant is not constant, drifting from
|
||||||
|
# 84,429 at cycle count 100 to 89,191 at 1200.
|
||||||
|
COUNTS_PER_SECOND = 90000.0 # 180 kHz (Table 3-1) / 2 bias directions
|
||||||
|
AXIS_OVERHEAD_S = 68.7e-6
|
||||||
|
AXES = 3
|
||||||
|
|
||||||
|
# Rates predicted from the model are good to roughly this much on a given unit,
|
||||||
|
# and no better across units, since the manual quotes no tolerance on the
|
||||||
|
# oscillator. Anything needing the real number measures it: logger.py calibrates
|
||||||
|
# the period against the host clock before recording.
|
||||||
|
RATE_TOLERANCE = 0.02
|
||||||
|
|
||||||
|
# Table 3-1 quotes 30/20/15 nT at cycle counts 50/100/200, which fits
|
||||||
|
# K/sqrt(cycle_count). Extrapolation past ~400 is unverified: the manual calls
|
||||||
|
# that its useful upper limit for noise and gives no data beyond it.
|
||||||
|
_NOISE_K_NT = 208.0
|
||||||
|
|
||||||
|
|
||||||
|
def sample_period(cycle_count, axes=AXES):
|
||||||
|
"""Seconds between measurements when the cycle count governs the rate."""
|
||||||
|
return axes * (cycle_count / COUNTS_PER_SECOND + AXIS_OVERHEAD_S)
|
||||||
|
|
||||||
|
|
||||||
|
def integration_time(cycle_count, axes=AXES):
|
||||||
|
"""Seconds per sample actually spent integrating, excluding fixed overhead.
|
||||||
|
|
||||||
|
Only this part reduces noise. Against the sample period it gives the duty
|
||||||
|
cycle: how much of the wall clock the sensor is doing useful work rather
|
||||||
|
than idling between measurements or paying per-axis overhead.
|
||||||
|
"""
|
||||||
|
return axes * cycle_count / COUNTS_PER_SECOND
|
||||||
|
|
||||||
|
|
||||||
|
def cycle_count_for_rate(rate_hz, axes=AXES):
|
||||||
|
"""Cycle count that makes the sensor free-run at about rate_hz.
|
||||||
|
|
||||||
|
Inverse of sample_period(). Use with a TMRC faster than the target so the
|
||||||
|
cycle count governs -- then this is a continuous rate knob, where TMRC only
|
||||||
|
offers factor-of-two steps, and the duty cycle is ~100% by construction.
|
||||||
|
|
||||||
|
The achieved rate will sit within about RATE_TOLERANCE of the target, set by
|
||||||
|
oscillator tolerance rather than by this calculation. Measure it if it
|
||||||
|
matters.
|
||||||
|
"""
|
||||||
|
cc = round((1.0 / (rate_hz * axes) - AXIS_OVERHEAD_S) * COUNTS_PER_SECOND)
|
||||||
|
return max(1, min(0xFFFF, cc))
|
||||||
|
|
||||||
|
|
||||||
|
# Section 5.1: "quantization issues generally dictate working above a cycle
|
||||||
|
# count value of ~30". Below that the LSB grows faster than the sensor's own
|
||||||
|
# noise, so the quantiser stops being dithered and averaging stalls.
|
||||||
|
MIN_CYCLE_COUNT = 30
|
||||||
|
MAX_CYCLE_COUNT = 0xFFFF
|
||||||
|
MIN_RATE_BY_CYCLE_COUNT = 1.0 / (AXES * (MAX_CYCLE_COUNT / COUNTS_PER_SECOND
|
||||||
|
+ AXIS_OVERHEAD_S))
|
||||||
|
|
||||||
|
Plan = namedtuple("Plan", "cycle_count tmrc predicted_hz governed_by duty notes")
|
||||||
|
|
||||||
|
|
||||||
|
def plan(rate_hz=None, cycle_count=None, tmrc=None):
|
||||||
|
"""Resolve a full configuration from whichever knob the caller specified.
|
||||||
|
|
||||||
|
Two ceilings compete and **the slower one wins** (manual section 5.2.1): the
|
||||||
|
cycle count sets how long a measurement takes, TMRC sets how often one is
|
||||||
|
started. Leaving TMRC faster than the cycle-count ceiling means the sensor
|
||||||
|
free-runs at ~100% duty; setting it slower makes the sensor idle, which
|
||||||
|
costs sensitivity for nothing unless low power is the goal.
|
||||||
|
|
||||||
|
So the default in every branch is TMRC_FASTEST, and the cycle count is the
|
||||||
|
rate knob -- it is continuous where TMRC offers only factor-of-two steps.
|
||||||
|
"""
|
||||||
|
notes = []
|
||||||
|
if cycle_count is None:
|
||||||
|
if rate_hz is None:
|
||||||
|
cycle_count = DEFAULT_CYCLE_COUNT
|
||||||
|
elif rate_hz < MIN_RATE_BY_CYCLE_COUNT:
|
||||||
|
# The register is 16 bits, so below ~0.46 Hz the cycle count runs
|
||||||
|
# out of range and TMRC is the only way to go slower. Max the cycle
|
||||||
|
# count anyway: it costs nothing and buys resolution.
|
||||||
|
cycle_count = MAX_CYCLE_COUNT
|
||||||
|
notes.append(
|
||||||
|
f"{rate_hz:g} Hz is below the {MIN_RATE_BY_CYCLE_COUNT:.3f} Hz "
|
||||||
|
f"floor of a {MAX_CYCLE_COUNT:,}-count measurement, so TMRC "
|
||||||
|
"must set the cadence and the sensor will idle")
|
||||||
|
if tmrc is None:
|
||||||
|
tmrc = min(TMRC_RATES, key=lambda t: abs(TMRC_RATES[t] - rate_hz))
|
||||||
|
else:
|
||||||
|
cycle_count = cycle_count_for_rate(rate_hz)
|
||||||
|
|
||||||
|
if cycle_count < MIN_CYCLE_COUNT:
|
||||||
|
notes.append(f"cycle count raised to the {MIN_CYCLE_COUNT} the manual "
|
||||||
|
"advises as a quantisation floor (section 5.1)")
|
||||||
|
cycle_count = max(MIN_CYCLE_COUNT, min(MAX_CYCLE_COUNT, cycle_count))
|
||||||
|
ceiling = 1.0 / sample_period(cycle_count)
|
||||||
|
if tmrc is None:
|
||||||
|
tmrc = TMRC_FASTEST
|
||||||
|
requested = TMRC_RATES[tmrc]
|
||||||
|
|
||||||
|
if requested <= ceiling:
|
||||||
|
predicted, governed_by = requested, "TMRC"
|
||||||
|
idle = 1.0 - requested / ceiling
|
||||||
|
if idle > 0.05:
|
||||||
|
notes.append(f"TMRC leaves the sensor idle {idle:.0%} of each "
|
||||||
|
"period, which costs sensitivity")
|
||||||
|
else:
|
||||||
|
predicted, governed_by = ceiling, "cycle count"
|
||||||
|
|
||||||
|
if rate_hz is not None and predicted < rate_hz * 0.98:
|
||||||
|
notes.append(f"{rate_hz:g} Hz is faster than this configuration can "
|
||||||
|
f"reach; {predicted:.1f} Hz is the ceiling")
|
||||||
|
return Plan(cycle_count, tmrc, predicted, governed_by,
|
||||||
|
integration_time(cycle_count) * predicted, notes)
|
||||||
|
|
||||||
|
|
||||||
|
def expected_noise_nt(cycle_count):
|
||||||
|
"""Per-sample noise from the Table 3-1 fit, in nanotesla."""
|
||||||
|
return _NOISE_K_NT / cycle_count ** 0.5
|
||||||
|
|
||||||
|
|
||||||
def decode_measurements(data):
|
def decode_measurements(data):
|
||||||
|
|
@ -84,10 +252,17 @@ class RM3100:
|
||||||
def read_reg(self, reg, count=1):
|
def read_reg(self, reg, count=1):
|
||||||
"""Read count bytes starting at reg, using the sensor's auto-increment.
|
"""Read count bytes starting at reg, using the sensor's auto-increment.
|
||||||
|
|
||||||
The pointer write is a separate transaction terminated by STOP rather
|
Returns (data, mono, wall): the bus stamps the clocks when the reply
|
||||||
than a repeated START, which is exactly what the manual's I2C read
|
lands, and the stamp travels with its own data.
|
||||||
diagrams (sections 4.5.2 and 5.8.4) specify.
|
|
||||||
|
Prefers a single combined transaction (repeated START) where the bus
|
||||||
|
offers one, since the host round trip is what limits the sample rate.
|
||||||
|
Falls back to the manual's STOP-then-START form (sections 4.5.2, 5.8.4)
|
||||||
|
for a bus that cannot do it.
|
||||||
"""
|
"""
|
||||||
|
combined = getattr(self.bus, "write_read", None)
|
||||||
|
if combined is not None:
|
||||||
|
return combined(self.address, [reg], count)
|
||||||
self.bus.write(self.address, [reg])
|
self.bus.write(self.address, [reg])
|
||||||
return self.bus.read(self.address, count)
|
return self.bus.read(self.address, count)
|
||||||
|
|
||||||
|
|
@ -95,7 +270,7 @@ class RM3100:
|
||||||
self.bus.write(self.address, bytes([reg]) + bytes(data))
|
self.bus.write(self.address, bytes([reg]) + bytes(data))
|
||||||
|
|
||||||
def revid(self):
|
def revid(self):
|
||||||
return self.read_reg(REG_REVID)[0]
|
return self.read_reg(REG_REVID)[0][0]
|
||||||
|
|
||||||
def set_cycle_counts(self, count):
|
def set_cycle_counts(self, count):
|
||||||
"""Set all three axes to the same cycle count."""
|
"""Set all three axes to the same cycle count."""
|
||||||
|
|
@ -106,7 +281,7 @@ class RM3100:
|
||||||
|
|
||||||
def get_cycle_counts(self):
|
def get_cycle_counts(self):
|
||||||
"""Read back (ccx, ccy, ccz)."""
|
"""Read back (ccx, ccy, ccz)."""
|
||||||
data = self.read_reg(REG_CCX, 6)
|
data, _, _ = self.read_reg(REG_CCX, 6)
|
||||||
return tuple(
|
return tuple(
|
||||||
int.from_bytes(data[i:i + 2], "big") for i in (0, 2, 4)
|
int.from_bytes(data[i:i + 2], "big") for i in (0, 2, 4)
|
||||||
)
|
)
|
||||||
|
|
@ -120,8 +295,21 @@ class RM3100:
|
||||||
self.write_reg(REG_TMRC, [tmrc])
|
self.write_reg(REG_TMRC, [tmrc])
|
||||||
|
|
||||||
def configure(self):
|
def configure(self):
|
||||||
"""Put DRDY into a state where polling STATUS actually works."""
|
"""Put DRDY into a state where polling STATUS actually works.
|
||||||
|
|
||||||
|
Verified rather than assumed: exactly-once sampling relies on DRC1=1
|
||||||
|
clearing DRDY when the measurement registers are read, which is what
|
||||||
|
makes a second read of the same measurement impossible.
|
||||||
|
"""
|
||||||
self.write_reg(REG_HSHAKE, [HSHAKE_DRDY_ON_READ_ONLY])
|
self.write_reg(REG_HSHAKE, [HSHAKE_DRDY_ON_READ_ONLY])
|
||||||
|
# Bits 4-6 are read-only NACK status, so compare only the writable ones.
|
||||||
|
readback = self.read_reg(REG_HSHAKE)[0][0] & 0x0F
|
||||||
|
if readback != HSHAKE_DRDY_ON_READ_ONLY & 0x0F:
|
||||||
|
raise IOError(
|
||||||
|
f"HSHAKE did not take: wrote 0x{HSHAKE_DRDY_ON_READ_ONLY:02x}, "
|
||||||
|
f"read back 0x{readback:02x}. Exactly-once sampling cannot be "
|
||||||
|
"guaranteed without DRC1=1."
|
||||||
|
)
|
||||||
|
|
||||||
def start_cmm(self):
|
def start_cmm(self):
|
||||||
self.write_reg(REG_CMM, [CMM_ALL_AXES])
|
self.write_reg(REG_CMM, [CMM_ALL_AXES])
|
||||||
|
|
@ -130,7 +318,17 @@ class RM3100:
|
||||||
self.write_reg(REG_CMM, [CMM_OFF])
|
self.write_reg(REG_CMM, [CMM_OFF])
|
||||||
|
|
||||||
def data_ready(self):
|
def data_ready(self):
|
||||||
return bool(self.read_reg(REG_STATUS)[0] & STATUS_DRDY)
|
"""True if a measurement is waiting."""
|
||||||
|
return self.poll_ready()[0]
|
||||||
|
|
||||||
|
def poll_ready(self):
|
||||||
|
"""Like data_ready(), but returns (ready, mono, wall).
|
||||||
|
|
||||||
|
The stamp is when the STATUS reply landed, which is the tightest bound
|
||||||
|
available on when DRDY actually went high.
|
||||||
|
"""
|
||||||
|
data, mono, wall = self.read_reg(REG_STATUS)
|
||||||
|
return bool(data[0] & STATUS_DRDY), mono, wall
|
||||||
|
|
||||||
def wait_for_data(self, timeout=2.0, interval=0.001):
|
def wait_for_data(self, timeout=2.0, interval=0.001):
|
||||||
"""Block until DRDY is set. Returns False if timeout elapses first."""
|
"""Block until DRDY is set. Returns False if timeout elapses first."""
|
||||||
|
|
@ -143,15 +341,16 @@ class RM3100:
|
||||||
time.sleep(interval)
|
time.sleep(interval)
|
||||||
|
|
||||||
def read_raw(self):
|
def read_raw(self):
|
||||||
"""Return (x, y, z) as signed counts -- the fast path.
|
"""Return ((x, y, z) counts, mono, wall) -- the fast path.
|
||||||
|
|
||||||
Callers logging at high rates should use this and defer the microtesla
|
Callers logging at high rates should use this and defer the microtesla
|
||||||
conversion, so the sampling loop does I2C and nothing else.
|
conversion, so the sampling loop does I2C and nothing else.
|
||||||
"""
|
"""
|
||||||
return decode_measurements(self.read_reg(REG_MX, 9))
|
data, mono, wall = self.read_reg(REG_MX, 9)
|
||||||
|
return decode_measurements(data), mono, wall
|
||||||
|
|
||||||
def read_measurements(self):
|
def read_measurements(self):
|
||||||
"""Return ((x, y, z) counts, (x, y, z) microtesla)."""
|
"""Return ((x, y, z) counts, (x, y, z) tesla)."""
|
||||||
counts = self.read_raw()
|
counts, _, _ = self.read_raw()
|
||||||
gain = gain_lsb_per_ut(self.cycle_count)
|
gain = gain_lsb_per_tesla(self.cycle_count)
|
||||||
return counts, tuple(c / gain for c in counts)
|
return counts, tuple(c / gain for c in counts)
|
||||||
|
|
|
||||||
144
sweep.py
Normal file
144
sweep.py
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Sweep sample rates and report what each configuration actually delivers.
|
||||||
|
|
||||||
|
./.venv/bin/python sweep.py # 1..256 Hz, powers of two
|
||||||
|
./.venv/bin/python sweep.py --from 8 --to 128
|
||||||
|
./.venv/bin/python sweep.py --rates 10,25,50
|
||||||
|
|
||||||
|
Cycle count is the rate knob, not TMRC. TMRC only offers factor-of-two steps
|
||||||
|
and, once the cycle count governs, has no effect at all -- measured 73.85 Hz at
|
||||||
|
TMRC 0x92 against 73.86 Hz at 0x94 for the same cycle count. So each point runs
|
||||||
|
with TMRC set fast and the cycle count chosen from rm3100.cycle_count_for_rate(),
|
||||||
|
which makes the rate continuous and the duty cycle ~100% by construction.
|
||||||
|
|
||||||
|
Each row is measured, not predicted: the logger calibrates the true period
|
||||||
|
against the host clock before recording, and that figure is what is reported.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import logger
|
||||||
|
import rm3100
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
CALIBRATED = re.compile(r"Calibrated period [\d.]+ ms = ([\d.]+) Hz")
|
||||||
|
MISSED = re.compile(r"WARNING: ([\d,]+) measurement\(s\) were lost")
|
||||||
|
AMBIGUOUS = re.compile(r"WARNING: ([\d,]+) gap\(s\) could not be counted")
|
||||||
|
|
||||||
|
CALIBRATE_SECONDS = 5.0
|
||||||
|
RECORD_SECONDS = 5.0
|
||||||
|
|
||||||
|
# One width per column, shared by the header rule and every cell, so the table
|
||||||
|
# cannot drift out of alignment as the cell formats change.
|
||||||
|
COLUMNS = [("target", 9), ("measured", 11), ("err", 7), ("cycle count", 11),
|
||||||
|
("nT/LSB", 10), ("spec noise", 10), ("duty", 6), ("bus use", 8)]
|
||||||
|
|
||||||
|
|
||||||
|
def row(cells):
|
||||||
|
return " ".join(f"{c:>{width}}" for c, (_, width) in zip(cells, COLUMNS))
|
||||||
|
|
||||||
|
|
||||||
|
def measure(rate, bus_speed, tmrc, output, record_seconds):
|
||||||
|
"""Run one configuration. Returns (cycle_count, measured_hz or None, note)."""
|
||||||
|
cc = rm3100.cycle_count_for_rate(rate)
|
||||||
|
proc = subprocess.run(
|
||||||
|
[sys.executable, "logger.py",
|
||||||
|
"--cycle-count", str(cc), "--tmrc", hex(tmrc),
|
||||||
|
"--bus-speed", str(bus_speed),
|
||||||
|
"--calibrate", f"{CALIBRATE_SECONDS:g}",
|
||||||
|
"--duration", f"{record_seconds:g}",
|
||||||
|
"--output", str(output)],
|
||||||
|
capture_output=True, text=True, cwd=HERE)
|
||||||
|
out = proc.stdout + proc.stderr
|
||||||
|
found = CALIBRATED.search(out)
|
||||||
|
if found:
|
||||||
|
# Both warnings matter and they are independent. Reporting only losses
|
||||||
|
# would hide the worse case: an ambiguous gap means the sample index
|
||||||
|
# itself may have slipped, so the timeline is suspect even where no
|
||||||
|
# measurement was lost.
|
||||||
|
notes = []
|
||||||
|
for pattern, label in ((MISSED, "lost"), (AMBIGUOUS, "ambiguous")):
|
||||||
|
hit = pattern.search(out)
|
||||||
|
if hit:
|
||||||
|
notes.append(f"{hit.group(1)} {label}")
|
||||||
|
return cc, float(found.group(1)), ", ".join(notes)
|
||||||
|
if "loss-free" in out:
|
||||||
|
return cc, None, "host cannot sustain"
|
||||||
|
return cc, None, "failed"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
ap.add_argument("--from", dest="low", type=float, default=2.0,
|
||||||
|
help="lowest target rate in Hz (default: %(default)s)")
|
||||||
|
ap.add_argument("--to", dest="high", type=float, default=512.0,
|
||||||
|
help="highest target rate in Hz (default: %(default)s)")
|
||||||
|
ap.add_argument("--rates", help="explicit comma-separated rates, overriding "
|
||||||
|
"the powers-of-two range")
|
||||||
|
ap.add_argument("--bus-speed", type=int, choices=[20, 100, 400, 750],
|
||||||
|
default=750)
|
||||||
|
ap.add_argument("--tmrc", type=lambda s: int(s, 0), default=0x92,
|
||||||
|
help="held fast so the cycle count governs (default: 0x92)")
|
||||||
|
ap.add_argument("--duration", type=float, default=RECORD_SECONDS,
|
||||||
|
help="seconds to record at each point, after calibration "
|
||||||
|
"(default: %(default)s)")
|
||||||
|
ap.add_argument("--output", default="/tmp/sweep_point.csv",
|
||||||
|
help="scratch capture path, overwritten each point")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if args.rates:
|
||||||
|
rates = [float(r) for r in args.rates.split(",")]
|
||||||
|
else:
|
||||||
|
rates, r = [], args.low
|
||||||
|
while r <= args.high * 1.001:
|
||||||
|
rates.append(r)
|
||||||
|
r *= 2
|
||||||
|
|
||||||
|
bus_time = logger.i2c_bus_time(args.bus_speed)
|
||||||
|
print(f"TMRC {hex(args.tmrc)} (held fast), I2C {args.bus_speed} kHz, "
|
||||||
|
f"{bus_time * 1e3:.3f} ms of bus traffic per sample, "
|
||||||
|
f"{args.duration:g} s per config\n")
|
||||||
|
print(row(name for name, _ in COLUMNS))
|
||||||
|
print(row("-" * width for _, width in COLUMNS))
|
||||||
|
|
||||||
|
for rate in rates:
|
||||||
|
cc, hz, note = measure(rate, args.bus_speed, args.tmrc, args.output,
|
||||||
|
args.duration)
|
||||||
|
# One count is one LSB, so nT/LSB is the quantisation step directly.
|
||||||
|
nt_per_lsb = rm3100.tesla_per_count(cc) * rm3100.NT_PER_TESLA
|
||||||
|
noise = rm3100.expected_noise_nt(cc)
|
||||||
|
# Fraction of the period spent integrating: what actually reduces noise.
|
||||||
|
duty = rm3100.integration_time(cc) * hz if hz else None
|
||||||
|
# A note always trails the row rather than sitting in a cell: "host
|
||||||
|
# cannot sustain" is wider than any sensible column and would shove the
|
||||||
|
# rest of the line out of alignment.
|
||||||
|
print(row([
|
||||||
|
f"{rate:g} Hz",
|
||||||
|
f"{hz:.2f} Hz" if hz else "-",
|
||||||
|
f"{hz / rate - 1:+.1%}" if hz else "-",
|
||||||
|
f"{cc:,}",
|
||||||
|
f"{nt_per_lsb:.3f} nT",
|
||||||
|
f"{noise:.2f} nT",
|
||||||
|
f"{duty:.1%}" if duty else "-",
|
||||||
|
f"{bus_time * hz:.1%}" if hz else "-",
|
||||||
|
]) + (f" {note}" if note else ""))
|
||||||
|
|
||||||
|
print("\nspec noise is Table 3-1's figure for the cycle count, not a\n"
|
||||||
|
"measurement: 208/sqrt(cc) nT, fitted to its 30/20/15 nT at 50/100/200.\n"
|
||||||
|
"Past cycle count ~400 the manual gives no data, so those are\n"
|
||||||
|
"extrapolation. Measure the real floor with characterize.py.\n"
|
||||||
|
"duty is integration time against the period -- what reduces noise.\n"
|
||||||
|
"It falls at high rates as the fixed per-axis overhead grows relative\n"
|
||||||
|
"to the integration, and would fall further if TMRC rather than the\n"
|
||||||
|
"cycle count governed, leaving the sensor idle between measurements.\n"
|
||||||
|
"bus use is irreducible I2C traffic against the period -- not occupancy,\n"
|
||||||
|
"which approaches 100% because the loop polls continuously for DRDY.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
Add table
Reference in a new issue