rm3100/sweep.py

217 lines
9.8 KiB
Python
Raw Normal View History

#!/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 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
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 -- but the points are minutes apart and 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 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 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 -- so fitting it here is the difference between a
per-unit reference that was measured and one that was assumed. 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 does not move "
"between cycle counts. See 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 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 args.calibration_out:
write_calibration(measured, args.calibration_out, args.note)
if __name__ == "__main__":
main()