2026-08-23 18:16:43 -04:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""Sweep sample rates and report what each configuration actually delivers.
|
|
|
|
|
|
2026-08-23 20:50:56 -04:00
|
|
|
./.venv/bin/python sweep.py # 2..512 Hz, powers of two
|
2026-08-23 18:16:43 -04:00
|
|
|
./.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
|
|
|
|
|
|
2026-08-23 20:50:56 -04:00
|
|
|
import ch347
|
2026-08-23 18:16:43 -04:00
|
|
|
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")
|
2026-08-23 20:50:56 -04:00
|
|
|
ap.add_argument("--bus-speed", type=int, choices=sorted(ch347.SPEEDS),
|
|
|
|
|
default=ch347.DEFAULT_SPEED_KHZ)
|
2026-08-23 18:16:43 -04:00
|
|
|
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()
|