rm3100/sweep.py

212 lines
9.4 KiB
Python

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