#!/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()