#!/usr/bin/env python3 """Sweep sample rates and report what each configuration actually delivers. ./.venv/bin/python sweep.py # 2..512 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 offers only factor-of-two steps and has no effect at all once the cycle count governs. So each point runs with TMRC set fast and the cycle count 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 import time from pathlib import Path import calibrate import ch347 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") # Each sweep point is a short capture, so the oscillator barely drifts within # one, and the points are minutes apart while the chip warms through the sweep. # This is the fractional period uncertainty that implies, and it is what the # timing fit's error bars are built on. PERIOD_SD = 500e-6 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 are independent: an ambiguous gap means the # sample index itself may have slipped, even where nothing 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 write_calibration(measured, path, note): """Fit the timing model across the sweep and save it as a calibration. The sweep already measures what a calibration is made of: a rate at each of several cycle counts. The reference oscillator is set to the fitted value, which makes this sweep the definition of "uncorrected" and every later capture correct relative to it. """ if len({cc for cc, _ in measured}) < 2: print(f"\nnot writing {path}: the sweep produced " f"{len({cc for cc, _ in measured})} distinct cycle count(s), and " "fitting both\nthe count rate and the overhead needs at least " "two.", file=sys.stderr) return None try: fit = calibrate.fit_timing([cc for cc, _ in measured], [period for _, period in measured], period_sd=PERIOD_SD) except calibrate.CalibrationError as exc: print(f"\nnot writing {path}: {exc}", file=sys.stderr) return None cal = calibrate.calibration( counts_per_second=fit.counts_per_second, axis_overhead_s=fit.axis_overhead_s, reference_oscillator_hz=fit.counts_per_second, reference_cycle_count=min(cc for cc, _ in measured), gain_exponent=1.0, gain_offset_counts=0.0, counts_per_second_sd=fit.counts_per_second_sd, axis_overhead_s_sd=fit.axis_overhead_s_sd, note=note or f"sweep of {len(measured)} points, " f"cycle counts {min(cc for cc, _ in measured)}" f"-{max(cc for cc, _ in measured)}", created=time.strftime("%Y-%m-%dT%H:%M:%S")) calibrate.save_calibration(path, cal) print(f"\ncalibration -> {path}") print(f" counts/s {fit.counts_per_second:,.0f} +/- " f"{fit.counts_per_second_sd:,.0f} overhead " f"{fit.axis_overhead_s * 1e6:.2f} +/- " f"{fit.axis_overhead_s_sd * 1e6:.2f} us " f"(the model's own: {rm3100.COUNTS_PER_SECOND:,.0f} and " f"{rm3100.AXIS_OVERHEAD_S * 1e6:.1f} us)") print(f" fit residual {fit.residual_ppm:.0f} ppm over {fit.points} points") print(" gain_exponent is 1.0, which the oscillator-clocked measurement " "interval predicts,\n and gain_offset_counts is 0.0. Neither is " "measured by a rate sweep. Both need\n a field that holds still " "between cycle counts. See Noise_Floor_Testing/NOTES.md.") return path 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=sorted(ch347.SPEEDS), default=ch347.DEFAULT_SPEED_KHZ) 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("--calibration-out", default=None, metavar="JSON", help="fit the timing model across the sweep and write it " "as a calibration for calibrate.py") ap.add_argument("--note", default=None, help="note recorded in the calibration, e.g. the unit and " "supply it was measured at") 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)) measured = [] for rate in rates: cc, hz, note = measure(rate, args.bus_speed, args.tmrc, args.output, args.duration) if hz: measured.append((cc, 1.0 / hz)) # 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 trails the row: "host cannot sustain" is wider than any # sensible column. 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, which is what reduces\n" "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. Raw occupancy\n" "approaches 100%, the loop polling continuously for DRDY.") if args.calibration_out: write_calibration(measured, args.calibration_out, args.note) if __name__ == "__main__": main()