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
|
2026-08-24 18:09:19 -04:00
|
|
|
import time
|
2026-08-23 18:16:43 -04:00
|
|
|
from pathlib import Path
|
|
|
|
|
|
2026-08-24 18:09:19 -04:00
|
|
|
import calibrate
|
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")
|
|
|
|
|
|
2026-08-24 18:09:19 -04:00
|
|
|
# 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
|
|
|
|
|
|
2026-08-23 18:16:43 -04:00
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 18:09:19 -04:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-08-23 18:16:43 -04:00
|
|
|
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)")
|
2026-08-24 18:09:19 -04:00
|
|
|
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")
|
2026-08-23 18:16:43 -04:00
|
|
|
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))
|
|
|
|
|
|
2026-08-24 18:09:19 -04:00
|
|
|
measured = []
|
2026-08-23 18:16:43 -04:00
|
|
|
for rate in rates:
|
|
|
|
|
cc, hz, note = measure(rate, args.bus_speed, args.tmrc, args.output,
|
|
|
|
|
args.duration)
|
2026-08-24 18:09:19 -04:00
|
|
|
if hz:
|
|
|
|
|
measured.append((cc, 1.0 / hz))
|
2026-08-23 18:16:43 -04:00
|
|
|
# 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.")
|
|
|
|
|
|
2026-08-24 18:09:19 -04:00
|
|
|
if args.calibration_out:
|
|
|
|
|
write_calibration(measured, args.calibration_out, args.note)
|
|
|
|
|
|
2026-08-23 18:16:43 -04:00
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|