2026-08-19 23:00:47 -04:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""Log RM3100 magnetometer data over a CH347 USB-I2C adapter.
|
|
|
|
|
|
|
|
|
|
Run ./setup.sh first to install the udev rule and create the virtualenv, then:
|
|
|
|
|
|
2026-08-23 18:16:43 -04:00
|
|
|
./.venv/bin/python logger.py --duration 60
|
2026-08-19 23:00:47 -04:00
|
|
|
./.venv/bin/python logger.py --scan-only
|
|
|
|
|
|
2026-08-23 18:16:43 -04:00
|
|
|
The chip samples on its own internal schedule (manual sections 5.7.2, 5.8.2), so
|
|
|
|
|
`sample_index` is a grid coordinate, not a count of reads. A late read returns the
|
|
|
|
|
*newer* measurement rather than a delayed one, so an unnoticed miss would silently
|
|
|
|
|
compress the time axis. Misses are therefore detected and written as explicit
|
|
|
|
|
placeholder rows, keeping the index contiguous and the gap visible; the run
|
|
|
|
|
continues and reports the total at the end.
|
|
|
|
|
|
|
|
|
|
Only irreducible facts are written: the sample count, the host clock, and the raw
|
|
|
|
|
counts. Chip time, tesla and magnitude are all reconstructed on load from the
|
|
|
|
|
header -- see capture.py.
|
2026-08-19 23:00:47 -04:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
|
import csv
|
2026-08-23 18:39:43 -04:00
|
|
|
import os
|
2026-08-19 23:00:47 -04:00
|
|
|
import queue
|
|
|
|
|
import sys
|
2026-08-23 18:59:51 -04:00
|
|
|
import textwrap
|
2026-08-19 23:00:47 -04:00
|
|
|
import threading
|
|
|
|
|
import time
|
2026-08-23 18:16:43 -04:00
|
|
|
from datetime import datetime
|
2026-08-19 23:00:47 -04:00
|
|
|
|
|
|
|
|
import ch347
|
|
|
|
|
import rm3100
|
|
|
|
|
|
2026-08-23 18:16:43 -04:00
|
|
|
CSV_FIELDS = ["sample_index", "system_time_unix", "x_raw", "y_raw", "z_raw",
|
|
|
|
|
"warning"]
|
2026-08-19 23:00:47 -04:00
|
|
|
|
2026-08-23 18:59:51 -04:00
|
|
|
# Mains fundamental. Anything sampled below twice this folds the line onto
|
|
|
|
|
# signal irreversibly, which is the one error post-processing cannot undo.
|
|
|
|
|
MAINS_HZ = 60.0
|
|
|
|
|
|
2026-08-19 23:00:47 -04:00
|
|
|
BUS_SPEEDS = {
|
|
|
|
|
20: ch347.SPEED_20KHZ,
|
|
|
|
|
100: ch347.SPEED_100KHZ,
|
|
|
|
|
400: ch347.SPEED_400KHZ,
|
|
|
|
|
750: ch347.SPEED_750KHZ,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
CONSOLE_REFRESH_S = 0.05
|
|
|
|
|
FLUSH_INTERVAL_S = 0.5
|
|
|
|
|
|
2026-08-23 18:16:43 -04:00
|
|
|
# The sampling loop releases the GIL inside each USB transfer, then has to take
|
|
|
|
|
# it back. Python's default 5 ms switch interval means the writer thread can
|
|
|
|
|
# hold it for that long, stalling the sampler and inflating the DRDY bracket --
|
|
|
|
|
# measured at ~10 ms against a 6.67 ms budget at 150 Hz, while the same loop
|
|
|
|
|
# without a writer thread peaked at 3.1 ms. Handing off 10x more often costs
|
|
|
|
|
# negligible throughput (the writer is not CPU-bound) and keeps the stall well
|
|
|
|
|
# inside the bracket budget.
|
|
|
|
|
GIL_SWITCH_INTERVAL_S = 0.0005
|
|
|
|
|
|
2026-08-23 18:39:43 -04:00
|
|
|
# Thread priority. The sampling loop runs on the main thread and is the only
|
|
|
|
|
# latency-sensitive part; the writer merely formats a few fields per sample.
|
|
|
|
|
#
|
|
|
|
|
# Lowering the writer needs no privilege and is always done: only the *relative*
|
|
|
|
|
# priority matters, and on Linux nice is per-thread, so it does not touch the
|
|
|
|
|
# sampler. Raising the sampler needs CAP_SYS_NICE and is opt-in via
|
|
|
|
|
# --high-priority, which fails loudly rather than degrading silently -- asking
|
|
|
|
|
# for it and not getting it is worth knowing about.
|
|
|
|
|
SAMPLER_NICE = -10
|
|
|
|
|
WRITER_NICE = 10
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def restore_ownership(path):
|
|
|
|
|
"""Hand a file created under sudo back to the invoking user.
|
|
|
|
|
|
|
|
|
|
--high-priority needs root, and anything root writes stays root-owned --
|
|
|
|
|
which earlier in this project produced captures the normal user could not
|
|
|
|
|
rewrite. Undo that here so privilege is needed for scheduling and nothing
|
|
|
|
|
else leaks from it.
|
|
|
|
|
"""
|
|
|
|
|
if os.geteuid() != 0:
|
|
|
|
|
return
|
|
|
|
|
uid, gid = os.environ.get("SUDO_UID"), os.environ.get("SUDO_GID")
|
|
|
|
|
if uid is None:
|
|
|
|
|
return
|
|
|
|
|
try:
|
|
|
|
|
os.chown(path, int(uid), int(gid) if gid else -1)
|
|
|
|
|
except OSError as exc:
|
|
|
|
|
print(f"WARNING: could not hand {path} back to uid {uid}: {exc}",
|
|
|
|
|
file=sys.stderr)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def set_thread_nice(value):
|
|
|
|
|
"""Renice the calling thread. Returns the nice actually in effect, or None.
|
|
|
|
|
|
|
|
|
|
Linux threads are tasks, so PRIO_PROCESS with a native thread id applies to
|
|
|
|
|
just this thread. Elsewhere this may be a no-op or affect the process, which
|
|
|
|
|
is why failure is tolerated rather than fatal -- priority is an optimisation,
|
|
|
|
|
not a correctness requirement.
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
tid = threading.get_native_id()
|
|
|
|
|
os.setpriority(os.PRIO_PROCESS, tid, value)
|
|
|
|
|
return os.getpriority(os.PRIO_PROCESS, tid)
|
|
|
|
|
except (AttributeError, OSError):
|
|
|
|
|
return None
|
|
|
|
|
|
2026-08-23 18:16:43 -04:00
|
|
|
# Miss detection uses the DRDY bracket -- the span between the last poll showing
|
|
|
|
|
# DRDY clear and the poll showing it set -- rather than the read-to-read
|
|
|
|
|
# interval.
|
|
|
|
|
#
|
|
|
|
|
# The bracket is exact where the interval is only a heuristic. If DRDY reads
|
|
|
|
|
# clear at t_c then every earlier measurement has already been read, since a
|
|
|
|
|
# results read is what clears it. Measurements complete one period apart, so a
|
|
|
|
|
# bracket narrower than one period can contain at most one completion, and DRDY
|
|
|
|
|
# going high proves it contained at least one. Exactly one, whatever the host
|
|
|
|
|
# was doing beforehand.
|
|
|
|
|
#
|
|
|
|
|
# The read-to-read interval cannot make that claim: measured on this rig it
|
|
|
|
|
# reaches 37 ms against a 28.8 ms period -- a 29% overshoot from host stalls
|
|
|
|
|
# alone -- while the bracket stays under 10 ms. Thresholding the interval
|
|
|
|
|
# therefore flags healthy captures as lossy.
|
|
|
|
|
#
|
|
|
|
|
# The bracket is compared against the period measured by calibrate_period(), not
|
|
|
|
|
# the table value: the latter is 6-9% out on this unit, which is enough to
|
|
|
|
|
# miscount grid points inside a bracket and slip the sample index.
|
|
|
|
|
|
2026-08-19 23:00:47 -04:00
|
|
|
# Bounded so a stalled writer degrades predictably instead of exhausting memory
|
|
|
|
|
# on a long run. Far above the depth a healthy writer ever reaches.
|
|
|
|
|
QUEUE_MAX = 200_000
|
|
|
|
|
|
|
|
|
|
_SENTINEL = object()
|
|
|
|
|
|
|
|
|
|
|
2026-08-23 18:16:43 -04:00
|
|
|
# Rows carry flags in a `warning` column, space separated, empty when fine.
|
|
|
|
|
# Keeping them out of the numeric columns means x/y/z stay parseable as numbers
|
|
|
|
|
# (blank for a row with no data) and the column generalises to future flags.
|
|
|
|
|
#
|
|
|
|
|
# MISSED this row is a placeholder -- the measurement was never read, so it
|
|
|
|
|
# has no data. The row exists to keep sample_index contiguous, so it
|
|
|
|
|
# remains a valid chip-time grid coordinate and the gap is explicit
|
|
|
|
|
# rather than silently compressing the timeline.
|
|
|
|
|
# AMBIGUOUS the gap ending at this row was of uncertain length, so the index
|
|
|
|
|
# may have slipped from here on.
|
|
|
|
|
#
|
|
|
|
|
# The two are independent. A gap measuring 1.35 periods rounds to one, so no
|
|
|
|
|
# placeholder is written -- yet it sits far enough from an integer to distrust,
|
|
|
|
|
# and that case carries AMBIGUOUS on the *real* sample terminating the gap,
|
|
|
|
|
# which keeps its data. A placeholder inside an uncertain gap carries both.
|
|
|
|
|
#
|
|
|
|
|
# The flag is a confidence measure, not a claim that 1.35 and 1.65 are equally
|
|
|
|
|
# likely: 1.35 probably is one period and 1.65 probably two. Deciding which
|
|
|
|
|
# needs neighbouring timestamps and assumptions, and can still be wrong, which
|
|
|
|
|
# is exactly why the rows are flagged rather than silently resolved.
|
|
|
|
|
WARN_MISSED = "MISSED"
|
|
|
|
|
WARN_AMBIGUOUS = "AMBIGUOUS"
|
|
|
|
|
|
|
|
|
|
# How near a half-period the rounding may fall before the count is a coin toss.
|
|
|
|
|
AMBIGUITY_MARGIN = 0.25
|
|
|
|
|
|
|
|
|
|
|
2026-08-19 23:00:47 -04:00
|
|
|
def parse_args():
|
|
|
|
|
p = argparse.ArgumentParser(description=__doc__,
|
|
|
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
2026-08-23 18:16:43 -04:00
|
|
|
knob = p.add_mutually_exclusive_group()
|
|
|
|
|
knob.add_argument("--rate", type=float, default=None,
|
|
|
|
|
help="target sample rate in Hz. The cycle count and TMRC "
|
|
|
|
|
"are derived from it, which is usually what you want "
|
|
|
|
|
"-- the cycle count is a continuous rate knob where "
|
|
|
|
|
"TMRC offers only factor-of-two steps")
|
|
|
|
|
knob.add_argument("--cycle-count", type=int, default=None,
|
|
|
|
|
help="cycle count per axis, setting both the rate and the "
|
|
|
|
|
"resolution. Lower is faster but more coarsely "
|
|
|
|
|
f"quantised (default: {rm3100.DEFAULT_CYCLE_COUNT})")
|
|
|
|
|
p.add_argument("--tmrc", type=lambda s: int(s, 0), default=None,
|
2026-08-19 23:00:47 -04:00
|
|
|
help="continuous-mode rate register, 0x92 (fastest) to 0x9D "
|
2026-08-23 18:16:43 -04:00
|
|
|
"(slowest). Default is the fastest, which lets the cycle "
|
|
|
|
|
"count set the rate -- give one only to sample slower "
|
|
|
|
|
"than the cycle count allows")
|
2026-08-19 23:00:47 -04:00
|
|
|
p.add_argument("--duration", type=float, default=0.0,
|
|
|
|
|
help="seconds to log, or 0 to run until Ctrl-C (default: %(default)s)")
|
|
|
|
|
p.add_argument("--output", default=None,
|
|
|
|
|
help="CSV output path (default: rm3100_<timestamp>.csv)")
|
|
|
|
|
p.add_argument("--address", type=lambda s: int(s, 0), default=None,
|
|
|
|
|
help="I2C address, skipping the scan (default: autodetect)")
|
2026-08-23 18:16:43 -04:00
|
|
|
p.add_argument("--bus-speed", type=int, choices=[20, 100, 400, 750], default=750,
|
|
|
|
|
help="I2C bus speed in kHz. The default cycle count runs near "
|
2026-08-23 18:59:51 -04:00
|
|
|
"282 Hz, where 100 kHz would spend 42%% of each period on "
|
|
|
|
|
"the bus; 750 spends 6%% (default: %(default)s)")
|
2026-08-23 18:16:43 -04:00
|
|
|
p.add_argument("--calibrate", type=float, default=1.0,
|
|
|
|
|
help="seconds of loss-free samples used to measure the true "
|
|
|
|
|
"measurement period before recording starts; the run "
|
|
|
|
|
"aborts if no clean stretch can be found (default: "
|
|
|
|
|
"%(default)s)")
|
|
|
|
|
p.add_argument("--note", default=None,
|
|
|
|
|
help="free-text label recorded in the capture header, e.g. "
|
|
|
|
|
"the supply under test. Keeps the configuration with the "
|
|
|
|
|
"data instead of only in the filename")
|
2026-08-23 18:39:43 -04:00
|
|
|
p.add_argument("--high-priority", action="store_true",
|
|
|
|
|
help=f"raise the sampling thread to nice {SAMPLER_NICE}. "
|
|
|
|
|
"Needs CAP_SYS_NICE, so run under sudo; the run aborts "
|
|
|
|
|
"if the priority cannot be set. Output files are handed "
|
|
|
|
|
"back to the invoking user afterwards")
|
2026-08-19 23:00:47 -04:00
|
|
|
p.add_argument("--scan-only", action="store_true",
|
|
|
|
|
help="scan the bus, report what responded, and exit")
|
|
|
|
|
return p.parse_args()
|
|
|
|
|
|
|
|
|
|
|
2026-08-23 18:39:43 -04:00
|
|
|
def print_plan(cfg, bus_speed, requested_rate, sampler_nice=None):
|
2026-08-23 18:16:43 -04:00
|
|
|
"""Show how the configuration was derived, so it can be checked not trusted."""
|
|
|
|
|
lsb = rm3100.tesla_per_count(cfg.cycle_count) * rm3100.NT_PER_TESLA
|
|
|
|
|
dither = rm3100.expected_noise_nt(cfg.cycle_count) / lsb
|
|
|
|
|
period = 1.0 / cfg.predicted_hz
|
|
|
|
|
bus = i2c_bus_time(bus_speed)
|
|
|
|
|
share = bus / period
|
|
|
|
|
|
|
|
|
|
print("Configuration")
|
|
|
|
|
if requested_rate is not None:
|
|
|
|
|
print(f" requested {requested_rate:g} Hz")
|
|
|
|
|
print(f" cycle count {cfg.cycle_count:,}"
|
|
|
|
|
f"{'':<8}1 / (3 x ({cfg.cycle_count}/"
|
|
|
|
|
f"{rm3100.COUNTS_PER_SECOND:,.0f} + "
|
|
|
|
|
f"{rm3100.AXIS_OVERHEAD_S * 1e6:.1f} us))")
|
|
|
|
|
print(f" sensor ceiling {1 / rm3100.sample_period(cfg.cycle_count):.2f} Hz")
|
|
|
|
|
print(f" TMRC 0x{cfg.tmrc:02x} = "
|
|
|
|
|
f"{rm3100.TMRC_RATES[cfg.tmrc]:g} Hz -- {cfg.governed_by} governs")
|
|
|
|
|
print(f" predicted rate {cfg.predicted_hz:.2f} Hz "
|
|
|
|
|
f"+/-{rm3100.RATE_TOLERANCE:.0%} (oscillator tolerance; measured below)")
|
|
|
|
|
print(f" resolution {lsb:.2f} nT/LSB dither {dither:.2f} LSB "
|
|
|
|
|
"at spec noise")
|
|
|
|
|
print(f" duty {cfg.duty:.1%} integration / period")
|
|
|
|
|
print(f" bus {bus_speed} kHz {bus * 1e3:.3f} ms/sample "
|
|
|
|
|
f"{share:.1%} of the period")
|
2026-08-23 18:39:43 -04:00
|
|
|
if sampler_nice is not None:
|
|
|
|
|
detail = (f"nice {sampler_nice}, writer at {WRITER_NICE}"
|
|
|
|
|
if sampler_nice < 0 else
|
|
|
|
|
f"nice {sampler_nice}, writer at {WRITER_NICE} "
|
|
|
|
|
"(--high-priority raises the sampler, needs sudo)")
|
|
|
|
|
print(f" priority {detail}")
|
2026-08-23 18:16:43 -04:00
|
|
|
for note in cfg.notes:
|
|
|
|
|
print(f" note: {note}")
|
2026-08-23 18:59:51 -04:00
|
|
|
|
|
|
|
|
# Nothing below is ever fixed silently: changing a setting the user asked
|
|
|
|
|
# for would hide the problem behind a configuration change. Say what is
|
|
|
|
|
# wrong and what would fix it, then run what was requested.
|
|
|
|
|
warnings = []
|
|
|
|
|
|
|
|
|
|
# Below the recommended cycle count the LSB grows faster than the sensor's
|
|
|
|
|
# own noise, so the quantiser stops being dithered and averaging stalls.
|
|
|
|
|
# The manual's hard floor is 30 (section 5.1), but the margin is already
|
|
|
|
|
# slim at 50, so anything under that is worth saying out loud.
|
|
|
|
|
if cfg.cycle_count < rm3100.RECOMMENDED_MIN_CYCLE_COUNT:
|
|
|
|
|
warnings.append(
|
|
|
|
|
f"cycle count {cfg.cycle_count} is below the recommended "
|
|
|
|
|
f"{rm3100.RECOMMENDED_MIN_CYCLE_COUNT}: dither is {dither:.2f} LSB "
|
|
|
|
|
f"at spec noise ({lsb:.1f} nT/LSB) against the ~0.2 where averaging "
|
|
|
|
|
"stops recovering sub-LSB resolution. characterize.py prints sd/LSB "
|
|
|
|
|
"per axis, which settles it from the first capture.")
|
|
|
|
|
|
|
|
|
|
# The measured worst configuration mistake -- TMRC slower than the
|
|
|
|
|
# cycle-count ceiling leaves the sensor idle, and idle time buys nothing.
|
|
|
|
|
# Idle is the shortfall against the ceiling, which is what TMRC costs; it is
|
|
|
|
|
# not 1 - duty, since the fixed per-axis overhead is active time too.
|
|
|
|
|
idle = 1.0 - cfg.predicted_hz * rm3100.sample_period(cfg.cycle_count)
|
|
|
|
|
if cfg.governed_by == "TMRC" and idle > 0.2:
|
|
|
|
|
warnings.append(
|
|
|
|
|
f"TMRC 0x{cfg.tmrc:02x} governs and leaves the sensor idle "
|
|
|
|
|
f"{idle:.0%} of each period, holding duty to {cfg.duty:.0%}. "
|
|
|
|
|
"Measured cost at 23% duty was 1.43x the noise ASD. Raising the "
|
|
|
|
|
"cycle count instead reaches the same rate at ~100% duty.")
|
|
|
|
|
|
|
|
|
|
# A rate that came back different from the one asked for is easy to miss in
|
|
|
|
|
# a note, and every figure above is derived from the rate.
|
|
|
|
|
if (requested_rate is not None and abs(cfg.predicted_hz / requested_rate - 1)
|
|
|
|
|
> rm3100.RATE_TOLERANCE):
|
|
|
|
|
warnings.append(
|
|
|
|
|
f"{requested_rate:g} Hz was requested but this configuration runs "
|
|
|
|
|
f"at {cfg.predicted_hz:.2f} Hz.")
|
|
|
|
|
|
|
|
|
|
# Mains and its harmonics fold onto signal below 2x mains, and no amount of
|
|
|
|
|
# post-filtering undoes an alias.
|
|
|
|
|
if cfg.predicted_hz < 2 * MAINS_HZ:
|
|
|
|
|
alias = abs(MAINS_HZ - round(MAINS_HZ / cfg.predicted_hz) * cfg.predicted_hz)
|
|
|
|
|
warnings.append(
|
|
|
|
|
f"Nyquist is {cfg.predicted_hz / 2:.1f} Hz, below {MAINS_HZ:g} Hz "
|
|
|
|
|
f"mains: interference folds to {alias:.2f} Hz and cannot be removed "
|
|
|
|
|
"afterwards. Sampling faster and decimating gives the same noise "
|
|
|
|
|
"floor with the mains line still visible.")
|
|
|
|
|
|
|
|
|
|
# Table 3-1 stops at 400, so the noise figure printed above is extrapolation
|
|
|
|
|
# past that point rather than a specification.
|
|
|
|
|
if cfg.cycle_count > rm3100.MAX_SPEC_CYCLE_COUNT:
|
|
|
|
|
warnings.append(
|
|
|
|
|
f"cycle count {cfg.cycle_count:,} is past the {rm3100.MAX_SPEC_CYCLE_COUNT} "
|
|
|
|
|
"where Table 3-1 ends, so the spec noise and gain above are "
|
|
|
|
|
"extrapolated, not specified.")
|
|
|
|
|
|
2026-08-23 18:16:43 -04:00
|
|
|
if share > 0.5:
|
|
|
|
|
faster = [s for s in sorted(BUS_SPEEDS) if i2c_bus_time(s) / period < 0.25]
|
|
|
|
|
fix = f"; {faster[0]} kHz would fit" if faster else ""
|
2026-08-23 18:59:51 -04:00
|
|
|
warnings.append(f"the bus needs {share:.0%} of every period{fix}.")
|
|
|
|
|
|
|
|
|
|
for text in warnings:
|
|
|
|
|
print(textwrap.fill(text, width=79, initial_indent=" WARNING: ",
|
|
|
|
|
subsequent_indent=" "), file=sys.stderr)
|
2026-08-23 18:16:43 -04:00
|
|
|
|
|
|
|
|
|
2026-08-19 23:00:47 -04:00
|
|
|
def find_sensor(bus, address):
|
|
|
|
|
"""Locate the RM3100, or exit with wiring guidance."""
|
|
|
|
|
if address is not None:
|
|
|
|
|
print(f"Using I2C address 0x{address:02x} (scan skipped)")
|
|
|
|
|
return address
|
|
|
|
|
|
|
|
|
|
candidates = [a for a in rm3100.RM3100.ADDRESSES if bus.probe(a)]
|
|
|
|
|
if not candidates:
|
|
|
|
|
print("No RM3100 responded at 0x20-0x23.", file=sys.stderr)
|
|
|
|
|
others = bus.scan()
|
|
|
|
|
if others:
|
|
|
|
|
print("Other devices on the bus: "
|
|
|
|
|
+ ", ".join(f"0x{a:02x}" for a in others), file=sys.stderr)
|
|
|
|
|
else:
|
|
|
|
|
print("Nothing responded anywhere on the bus. Check, in order:\n"
|
|
|
|
|
" - I2CEN tied high (otherwise the chip stays in SPI mode)\n"
|
2026-08-23 18:16:43 -04:00
|
|
|
" - AVDD/VDD powered, not just DVDD\n"
|
2026-08-19 23:00:47 -04:00
|
|
|
" - SDA/SCL not swapped\n"
|
|
|
|
|
" - SDA and SCL pull-up resistors present", file=sys.stderr)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
if len(candidates) > 1:
|
|
|
|
|
print("Multiple devices responded at "
|
|
|
|
|
+ ", ".join(f"0x{a:02x}" for a in candidates)
|
|
|
|
|
+ "; use --address to pick one.", file=sys.stderr)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
print(f"Found a device at 0x{candidates[0]:02x}")
|
|
|
|
|
return candidates[0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def start_sensor(sensor, cycle_count, tmrc):
|
2026-08-23 18:16:43 -04:00
|
|
|
"""Identify and configure the sensor, printing each step. Returns REVID."""
|
2026-08-19 23:00:47 -04:00
|
|
|
revid = sensor.revid()
|
|
|
|
|
if revid == rm3100.EXPECTED_REVID:
|
|
|
|
|
print(f"REVID 0x{revid:02x} -- RM3100 confirmed")
|
|
|
|
|
else:
|
|
|
|
|
print(f"WARNING: REVID 0x{revid:02x}, expected "
|
|
|
|
|
f"0x{rm3100.EXPECTED_REVID:02x}", file=sys.stderr)
|
|
|
|
|
|
|
|
|
|
sensor.set_cycle_counts(cycle_count)
|
|
|
|
|
readback = sensor.get_cycle_counts()
|
|
|
|
|
if readback != (cycle_count,) * 3:
|
|
|
|
|
print(f"ERROR: cycle count read back as {readback}, expected "
|
|
|
|
|
f"{(cycle_count,) * 3}", file=sys.stderr)
|
|
|
|
|
sys.exit(1)
|
2026-08-23 18:16:43 -04:00
|
|
|
lsb_nt = rm3100.tesla_per_count(cycle_count) * rm3100.NT_PER_TESLA
|
|
|
|
|
print(f"Cycle counts set to {readback} -- 1 LSB = {lsb_nt:.2f} nT")
|
2026-08-19 23:00:47 -04:00
|
|
|
|
2026-08-23 18:16:43 -04:00
|
|
|
# Raises if DRC1 did not take; exactly-once sampling depends on it.
|
2026-08-19 23:00:47 -04:00
|
|
|
sensor.configure()
|
|
|
|
|
sensor.set_rate(tmrc)
|
2026-08-23 18:16:43 -04:00
|
|
|
|
2026-08-19 23:00:47 -04:00
|
|
|
|
|
|
|
|
sensor.start_cmm()
|
|
|
|
|
print("Continuous measurement mode started")
|
2026-08-23 18:16:43 -04:00
|
|
|
return revid
|
2026-08-19 23:00:47 -04:00
|
|
|
|
|
|
|
|
|
2026-08-23 18:16:43 -04:00
|
|
|
def write_header(handle, meta):
|
|
|
|
|
for key, value in meta.items():
|
|
|
|
|
handle.write(f"# {key}: {value}\n")
|
2026-08-19 23:00:47 -04:00
|
|
|
|
2026-08-23 18:16:43 -04:00
|
|
|
|
|
|
|
|
def writer_thread(q, path, meta, stats):
|
|
|
|
|
"""Drain samples to CSV and drive the console, off the sampling thread."""
|
2026-08-23 18:39:43 -04:00
|
|
|
# Stand aside for the sampler: this thread is not latency-sensitive.
|
|
|
|
|
set_thread_nice(WRITER_NICE)
|
2026-08-23 18:16:43 -04:00
|
|
|
lsb_nt = float(meta["tesla_per_count"]) * rm3100.NT_PER_TESLA
|
2026-08-19 23:00:47 -04:00
|
|
|
with open(path, "w", newline="") as handle:
|
2026-08-23 18:39:43 -04:00
|
|
|
restore_ownership(path)
|
2026-08-23 18:16:43 -04:00
|
|
|
write_header(handle, meta)
|
2026-08-19 23:00:47 -04:00
|
|
|
out = csv.writer(handle)
|
|
|
|
|
out.writerow(CSV_FIELDS)
|
|
|
|
|
last_print = 0.0
|
|
|
|
|
last_flush = time.monotonic()
|
|
|
|
|
|
|
|
|
|
while True:
|
|
|
|
|
item = q.get()
|
|
|
|
|
if item is _SENTINEL:
|
|
|
|
|
break
|
2026-08-23 18:16:43 -04:00
|
|
|
index, system_time, cx, cy, cz, warning = item
|
|
|
|
|
|
|
|
|
|
# 6 decimal places is microsecond resolution on an epoch value,
|
|
|
|
|
# far finer than the ~1 ms bracket uncertainty on each timestamp.
|
|
|
|
|
# Placeholder rows carry zeros rather than blanks: the warning
|
|
|
|
|
# column already says the row has no data, so keeping x/y/z strictly
|
|
|
|
|
# integer makes the file trivial to parse.
|
|
|
|
|
out.writerow([index, f"{system_time:.6f}",
|
|
|
|
|
0 if cx is None else cx,
|
|
|
|
|
0 if cy is None else cy,
|
|
|
|
|
0 if cz is None else cz, warning])
|
2026-08-19 23:00:47 -04:00
|
|
|
stats["rows"] += 1
|
|
|
|
|
|
|
|
|
|
now = time.monotonic()
|
|
|
|
|
if now - last_flush >= FLUSH_INTERVAL_S:
|
|
|
|
|
handle.flush()
|
|
|
|
|
last_flush = now
|
2026-08-23 18:16:43 -04:00
|
|
|
if now - last_print >= CONSOLE_REFRESH_S and cx is not None:
|
|
|
|
|
# Placeholders carry no field values, so the display holds the
|
|
|
|
|
# last real reading rather than blanking or showing nonsense.
|
2026-08-19 23:00:47 -04:00
|
|
|
last_print = now
|
2026-08-23 18:16:43 -04:00
|
|
|
x, y, z = cx * lsb_nt, cy * lsb_nt, cz * lsb_nt
|
|
|
|
|
print(f"\r{stats['rows']:,} samples ({stats['missed']} missed)"
|
|
|
|
|
f" | X {x:+10.1f} | Y {y:+10.1f} | Z {z:+10.1f}"
|
|
|
|
|
f" | T {(x * x + y * y + z * z) ** 0.5:10.1f} nT",
|
2026-08-19 23:00:47 -04:00
|
|
|
end="", flush=True)
|
|
|
|
|
handle.flush()
|
|
|
|
|
|
|
|
|
|
|
2026-08-23 18:16:43 -04:00
|
|
|
def i2c_bus_time(bus_speed_khz):
|
|
|
|
|
"""Seconds of I2C traffic the bus carries per sample.
|
|
|
|
|
|
|
|
|
|
A combined register read of n payload bytes clocks n+3 bytes -- write
|
|
|
|
|
address, register, read address, payload -- at 9 bit-times each (8 data plus
|
|
|
|
|
ACK), and spends roughly a bit-time on each START and STOP. One sample costs
|
|
|
|
|
a STATUS poll (1 payload byte) plus a results read (9).
|
|
|
|
|
|
|
|
|
|
This is the irreducible traffic, not bus occupancy: the loop polls
|
|
|
|
|
continuously while waiting for DRDY, so raw occupancy approaches 100% and
|
|
|
|
|
says nothing useful. What matters is this against the sample period.
|
|
|
|
|
"""
|
|
|
|
|
bits = 0
|
|
|
|
|
for payload in (1, 9):
|
|
|
|
|
bits += (payload + 3) * 9 + 3 # 2 STARTs and a STOP
|
|
|
|
|
return bits / (bus_speed_khz * 1000.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def calibrate_period(sensor, dt_nominal, seconds, attempts=3):
|
|
|
|
|
"""Measure the true measurement period from a clean stretch of samples.
|
|
|
|
|
|
|
|
|
|
Miss detection needs the real period, not the table value: the RC oscillator
|
|
|
|
|
runs up to 7% off (manual section 5.2.1) and this unit is ~6% slow, which is
|
|
|
|
|
enough to miscount grid points inside a bracket and slip the sample index.
|
|
|
|
|
|
|
|
|
|
Least-squares slope of read time against index. Over one second at 35 Hz
|
|
|
|
|
(~35 points, ~0.5 ms jitter) that pins the period to roughly 0.03%, against
|
|
|
|
|
the 6-9% error of the nominal value -- and 0.03% is far finer than the
|
|
|
|
|
resolution needed to tell k grid points from k+1.
|
|
|
|
|
|
|
|
|
|
Requires a stretch with no miss, since an interval spanning a miss is a
|
|
|
|
|
multiple of the period, not one period. Returns None if no clean stretch is
|
|
|
|
|
available, which is itself the answer: the rate is not sustainable.
|
|
|
|
|
|
|
|
|
|
Also returns the mean host cost of one sample's traffic -- a DRDY poll plus
|
|
|
|
|
a results read -- timed here at no extra cost, since those are the very
|
|
|
|
|
transactions being performed.
|
|
|
|
|
"""
|
|
|
|
|
poll_ready, read_raw = sensor.poll_ready, sensor.read_raw
|
|
|
|
|
monotonic = time.monotonic
|
|
|
|
|
|
|
|
|
|
# Bootstrap the period from observation rather than trusting the TMRC table.
|
|
|
|
|
# When the cycle count governs instead of TMRC -- which it does whenever the
|
|
|
|
|
# cycle-count ceiling falls below the requested rate (manual section 5.2.1)
|
|
|
|
|
# -- the table value is not merely 7% out but wrong by a large factor. At
|
|
|
|
|
# cc=400 with TMRC 0x92 the table says 1.67 ms and the truth is 13.5 ms, so
|
|
|
|
|
# a threshold built on it rejects every interval and calibration can never
|
|
|
|
|
# start. A handful of raw intervals settles it: the median is robust to the
|
|
|
|
|
# occasional stall, and only needs to be close enough to seed the real fit.
|
|
|
|
|
probe = []
|
|
|
|
|
clear = monotonic()
|
|
|
|
|
deadline = clear + max(seconds, 0.25)
|
|
|
|
|
while monotonic() < deadline and len(probe) < 24:
|
|
|
|
|
ready_now, stamp, _ = poll_ready()
|
|
|
|
|
if not ready_now:
|
|
|
|
|
continue
|
|
|
|
|
read_raw()
|
|
|
|
|
probe.append(stamp)
|
|
|
|
|
if len(probe) >= 4:
|
|
|
|
|
gaps = sorted(b - a for a, b in zip(probe, probe[1:]))
|
|
|
|
|
dt_nominal = gaps[len(gaps) // 2]
|
|
|
|
|
|
|
|
|
|
for _ in range(attempts):
|
|
|
|
|
times = []
|
|
|
|
|
poll_cost = read_cost = 0.0
|
|
|
|
|
polls = 0
|
|
|
|
|
clear = monotonic()
|
|
|
|
|
deadline = clear + seconds
|
|
|
|
|
clean = True
|
|
|
|
|
while monotonic() < deadline:
|
|
|
|
|
before = monotonic()
|
|
|
|
|
ready_now, after, _ = poll_ready()
|
|
|
|
|
poll_cost += monotonic() - before
|
|
|
|
|
polls += 1
|
|
|
|
|
if not ready_now:
|
|
|
|
|
clear = after
|
|
|
|
|
continue
|
|
|
|
|
ready = after
|
|
|
|
|
if ready - clear >= dt_nominal: # conservative: nominal is short
|
|
|
|
|
clean = False
|
|
|
|
|
break
|
|
|
|
|
read_raw()
|
|
|
|
|
read_cost += monotonic() - ready
|
|
|
|
|
times.append(ready)
|
|
|
|
|
clear = ready
|
|
|
|
|
n = len(times)
|
|
|
|
|
if not clean or n < 8:
|
|
|
|
|
continue
|
|
|
|
|
# slope of t against i for i = 0..n-1, where Sxx = n(n^2-1)/12.
|
|
|
|
|
mean_i = (n - 1) / 2.0
|
|
|
|
|
mean_t = sum(times) / n
|
|
|
|
|
sxy = sum((i - mean_i) * (t - mean_t) for i, t in enumerate(times))
|
|
|
|
|
return (sxy / (n * (n * n - 1) / 12.0),
|
|
|
|
|
poll_cost / max(polls, 1) + read_cost / n)
|
|
|
|
|
return None, None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def sample_loop(sensor, q, duration, dt_nominal, stats):
|
|
|
|
|
"""Read every measurement, recording placeholders for any that are lost.
|
|
|
|
|
|
|
|
|
|
The timestamp recorded per sample is the midpoint of the bracket between the
|
|
|
|
|
last poll showing DRDY clear and the poll showing it set: the measurement
|
|
|
|
|
completed somewhere in that window, and the midpoint is the best estimate
|
|
|
|
|
available without a hardware DRDY line.
|
|
|
|
|
"""
|
2026-08-19 23:00:47 -04:00
|
|
|
read_raw = sensor.read_raw
|
2026-08-23 18:16:43 -04:00
|
|
|
poll_ready = sensor.poll_ready
|
2026-08-19 23:00:47 -04:00
|
|
|
monotonic = time.monotonic
|
|
|
|
|
put = q.put_nowait
|
|
|
|
|
|
|
|
|
|
|
2026-08-23 18:16:43 -04:00
|
|
|
# DRDY may already be set from whatever ran before -- BIST in particular
|
|
|
|
|
# zeroes the result registers and leaves it high. Discard one reading so
|
|
|
|
|
# sample 0 is a genuinely fresh measurement.
|
|
|
|
|
if poll_ready()[0]:
|
|
|
|
|
read_raw()
|
|
|
|
|
|
|
|
|
|
deadline = monotonic() + duration if duration > 0 else float("inf")
|
|
|
|
|
index = 0
|
|
|
|
|
# Bracket lower bound: the most recent moment DRDY was seen clear.
|
|
|
|
|
clear_mono, clear_wall = monotonic(), time.time()
|
|
|
|
|
|
|
|
|
|
# dt_nominal here is the *calibrated* period from calibrate_period(), not
|
|
|
|
|
# the table value. Keep refining it from clean intervals so the estimate
|
|
|
|
|
# follows the oscillator's thermal drift (~2500 ppm measured over 13 h).
|
|
|
|
|
dt_est = dt_nominal
|
|
|
|
|
previous_mono = previous_wall = None
|
|
|
|
|
DT_SMOOTHING = 0.02
|
|
|
|
|
|
|
|
|
|
while True:
|
|
|
|
|
if monotonic() >= deadline:
|
|
|
|
|
return
|
|
|
|
|
# The stamps come back with the STATUS reply itself, taken the instant
|
|
|
|
|
# it landed rather than several frames later up here.
|
|
|
|
|
ready, ready_mono, ready_wall = poll_ready()
|
|
|
|
|
if not ready:
|
|
|
|
|
clear_mono, clear_wall = ready_mono, ready_wall
|
|
|
|
|
continue
|
|
|
|
|
# Monotonic drives detection so an NTP step cannot fake a miss; the
|
|
|
|
|
# wall clock is recorded so a real NTP correction stays visible.
|
|
|
|
|
bracket = ready_mono - clear_mono
|
|
|
|
|
if bracket > stats["max_bracket"]:
|
|
|
|
|
stats["max_bracket"] = bracket
|
|
|
|
|
|
|
|
|
|
# Counting lost measurements in two stages, because the two questions
|
|
|
|
|
# have different best answers.
|
|
|
|
|
#
|
|
|
|
|
# "Did we lose any?" is settled by the bracket, and rigorously: a
|
|
|
|
|
# bracket shorter than one period cannot contain two completions, so
|
|
|
|
|
# nothing was lost regardless of timing precision.
|
|
|
|
|
#
|
|
|
|
|
# "How many?" is better answered by the interval since the previous
|
|
|
|
|
# accepted sample. Each accepted read sits on a grid point, so that
|
|
|
|
|
# interval is very close to an exact multiple of the period, and
|
|
|
|
|
# rounding recovers the multiple. The bracket alone cannot -- it only
|
|
|
|
|
# spans back to the last poll that saw DRDY clear, which discards where
|
|
|
|
|
# the grid actually is.
|
|
|
|
|
#
|
|
|
|
|
# Precision: the period is calibrated to ~0.07% and each completion is
|
|
|
|
|
# located to about half a poll interval (~0.2 ms, under 1% of a period).
|
|
|
|
|
# Over a gap of k periods the total error is well under a tenth of a
|
|
|
|
|
# period for any small k, so the rounding is unambiguous -- which is
|
|
|
|
|
# the case that matters, since a long gap means the run is unusable
|
|
|
|
|
# anyway.
|
|
|
|
|
lost = 0
|
|
|
|
|
uncertain = False
|
|
|
|
|
if bracket >= dt_est and previous_mono is not None:
|
|
|
|
|
periods = (ready_mono - previous_mono) / dt_est
|
|
|
|
|
lost = max(0, round(periods) - 1)
|
|
|
|
|
# Uncertainty is a property of the gap, not of the placeholders:
|
|
|
|
|
# a gap rounding to zero losses can still be a coin toss, and then
|
|
|
|
|
# there is no placeholder to carry the flag.
|
|
|
|
|
uncertain = abs(periods - round(periods)) > AMBIGUITY_MARGIN
|
|
|
|
|
if uncertain:
|
|
|
|
|
stats["ambiguous"] += 1
|
|
|
|
|
elif previous_mono is not None:
|
|
|
|
|
# A clean interval is exactly one period, so it calibrates dt_est.
|
|
|
|
|
# Only clean ones qualify: an interval spanning a miss is a multiple.
|
|
|
|
|
dt_est += (ready_mono - previous_mono - dt_est) * DT_SMOOTHING
|
|
|
|
|
if lost:
|
|
|
|
|
stats["missed"] += lost
|
|
|
|
|
# Placeholders sit on the grid -- one period after the previous
|
|
|
|
|
# accepted sample, and so on -- because that is where the lost
|
|
|
|
|
# measurements actually completed. Spreading them across the
|
|
|
|
|
# bracket instead would bunch them at the end of the gap and skew
|
|
|
|
|
# the rate fit.
|
|
|
|
|
flags = (f"{WARN_MISSED} {WARN_AMBIGUOUS}" if uncertain
|
|
|
|
|
else WARN_MISSED)
|
|
|
|
|
for k in range(lost):
|
|
|
|
|
try:
|
|
|
|
|
put((index, previous_wall + dt_est * (k + 1),
|
|
|
|
|
None, None, None, flags))
|
|
|
|
|
except queue.Full:
|
|
|
|
|
stats["dropped"] += 1
|
|
|
|
|
index += 1
|
|
|
|
|
|
|
|
|
|
sample_wall = (clear_wall + ready_wall) / 2.0
|
|
|
|
|
(cx, cy, cz), _, _ = read_raw()
|
2026-08-19 23:00:47 -04:00
|
|
|
try:
|
2026-08-23 18:16:43 -04:00
|
|
|
# An uncertain gap that produced no placeholder still has to be
|
|
|
|
|
# flagged, so it rides on the real sample that ends it.
|
|
|
|
|
put((index, sample_wall, cx, cy, cz,
|
|
|
|
|
WARN_AMBIGUOUS if uncertain and not lost else ""))
|
2026-08-19 23:00:47 -04:00
|
|
|
except queue.Full:
|
|
|
|
|
stats["dropped"] += 1
|
2026-08-23 18:16:43 -04:00
|
|
|
index += 1
|
|
|
|
|
previous_mono, previous_wall = ready_mono, sample_wall
|
|
|
|
|
# The read just cleared DRDY, so this is a known-clear instant.
|
|
|
|
|
clear_mono, clear_wall = ready_mono, ready_wall
|
2026-08-19 23:00:47 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def main():
|
|
|
|
|
args = parse_args()
|
2026-08-23 18:16:43 -04:00
|
|
|
sys.setswitchinterval(GIL_SWITCH_INTERVAL_S)
|
2026-08-23 18:39:43 -04:00
|
|
|
sampler_nice = None
|
|
|
|
|
if args.high_priority:
|
|
|
|
|
sampler_nice = set_thread_nice(SAMPLER_NICE)
|
|
|
|
|
if sampler_nice is None:
|
|
|
|
|
sys.exit(
|
|
|
|
|
f"--high-priority needs CAP_SYS_NICE to set nice "
|
|
|
|
|
f"{SAMPLER_NICE}, and this process does not have it.\n"
|
|
|
|
|
"Re-run under sudo, using the venv interpreter by absolute "
|
|
|
|
|
"path:\n"
|
|
|
|
|
f" sudo {sys.executable} {' '.join(sys.argv)}\n"
|
|
|
|
|
"Or drop the flag -- the writer thread already steps aside, "
|
|
|
|
|
f"which is the half that needs no privilege.")
|
|
|
|
|
else:
|
|
|
|
|
try:
|
|
|
|
|
sampler_nice = os.getpriority(os.PRIO_PROCESS,
|
|
|
|
|
threading.get_native_id())
|
|
|
|
|
except (AttributeError, OSError):
|
|
|
|
|
pass
|
2026-08-19 23:00:47 -04:00
|
|
|
|
2026-08-23 18:16:43 -04:00
|
|
|
if args.cycle_count is not None and not 1 <= args.cycle_count <= 0xFFFF:
|
2026-08-19 23:00:47 -04:00
|
|
|
sys.exit(f"--cycle-count {args.cycle_count} outside 1..65535")
|
2026-08-23 18:16:43 -04:00
|
|
|
if args.rate is not None and args.rate <= 0:
|
|
|
|
|
sys.exit(f"--rate {args.rate:g} must be positive")
|
|
|
|
|
if args.tmrc is not None and args.tmrc not in rm3100.TMRC_RATES:
|
2026-08-19 23:00:47 -04:00
|
|
|
sys.exit(f"--tmrc 0x{args.tmrc:02x} is not a valid rate register value")
|
|
|
|
|
|
2026-08-23 18:16:43 -04:00
|
|
|
cfg = rm3100.plan(rate_hz=args.rate, cycle_count=args.cycle_count,
|
|
|
|
|
tmrc=args.tmrc)
|
|
|
|
|
args.cycle_count, args.tmrc = cfg.cycle_count, cfg.tmrc
|
|
|
|
|
|
2026-08-19 23:00:47 -04:00
|
|
|
try:
|
|
|
|
|
bus = ch347.CH347I2C(BUS_SPEEDS[args.bus_speed])
|
|
|
|
|
except IOError as exc:
|
|
|
|
|
sys.exit(str(exc))
|
|
|
|
|
|
|
|
|
|
with bus:
|
|
|
|
|
print(f"CH347 adapter opened, I2C at {args.bus_speed} kHz")
|
|
|
|
|
|
|
|
|
|
if args.scan_only:
|
|
|
|
|
found = bus.scan()
|
|
|
|
|
print("Devices found: " + ", ".join(f"0x{a:02x}" for a in found)
|
|
|
|
|
if found else "No devices responded on the bus.")
|
2026-08-23 18:16:43 -04:00
|
|
|
return 0
|
2026-08-19 23:00:47 -04:00
|
|
|
|
|
|
|
|
address = find_sensor(bus, args.address)
|
|
|
|
|
sensor = rm3100.RM3100(bus, address)
|
2026-08-23 18:16:43 -04:00
|
|
|
try:
|
|
|
|
|
revid = start_sensor(sensor, args.cycle_count, args.tmrc)
|
|
|
|
|
except IOError as exc:
|
|
|
|
|
sys.exit(str(exc))
|
|
|
|
|
|
|
|
|
|
print()
|
2026-08-23 18:39:43 -04:00
|
|
|
print_plan(cfg, args.bus_speed, args.rate, sampler_nice)
|
2026-08-23 18:16:43 -04:00
|
|
|
print()
|
|
|
|
|
nominal_rate = cfg.predicted_hz
|
|
|
|
|
|
|
|
|
|
# Establish the real period before recording. Everything downstream --
|
|
|
|
|
# miss detection, placeholder counting, the sample-index grid -- depends
|
|
|
|
|
# on it, and the table value is 6-9% out on this unit.
|
|
|
|
|
dt, host_cost = calibrate_period(sensor, 1.0 / nominal_rate,
|
|
|
|
|
args.calibrate)
|
|
|
|
|
if dt is None:
|
|
|
|
|
sensor.stop_cmm()
|
|
|
|
|
sys.exit(
|
|
|
|
|
f"Could not find {args.calibrate:g} s of loss-free samples at "
|
|
|
|
|
f"{nominal_rate:.1f} Hz.\n"
|
|
|
|
|
"Without a clean stretch the true period cannot be measured, so "
|
|
|
|
|
"misses cannot be\ncounted reliably and the sample index would "
|
|
|
|
|
"not track chip time.\n"
|
|
|
|
|
"Lower the rate (higher --tmrc) or raise --bus-speed.")
|
|
|
|
|
# Compared against the predicted rate, not TMRC's nominal: when the
|
|
|
|
|
# cycle count governs, the TMRC figure is not what was aimed for and the
|
|
|
|
|
# error against it is meaningless.
|
|
|
|
|
print(f"Calibrated period {dt * 1e3:.4f} ms = {1 / dt:.4f} Hz "
|
|
|
|
|
f"({(1 / dt) / cfg.predicted_hz - 1:+.2%} vs predicted)")
|
|
|
|
|
# Whether the host is the constraint is a measurement, not a given: it
|
|
|
|
|
# costs a near-constant ~0.9 ms per sample regardless of configuration,
|
|
|
|
|
# so it dominates at short periods and disappears at long ones.
|
|
|
|
|
share = host_cost / dt
|
|
|
|
|
margin = ("little margin -- expect misses under load" if share > 0.5
|
|
|
|
|
else "modest margin" if share > 0.25
|
|
|
|
|
else "ample margin")
|
|
|
|
|
print(f"Host cost {host_cost * 1e3:.3f} ms/sample "
|
|
|
|
|
f"({share:.1%} of the period, {margin})")
|
|
|
|
|
|
|
|
|
|
meta = {
|
|
|
|
|
"rm3100_capture": 1,
|
|
|
|
|
# The rate this configuration is predicted to produce, whichever of
|
|
|
|
|
# the two ceilings governs -- not TMRC's table value, which is not
|
|
|
|
|
# what was aimed for when the cycle count wins.
|
|
|
|
|
"nominal_rate_hz": nominal_rate,
|
|
|
|
|
"tmrc_nominal_hz": rm3100.TMRC_RATES[args.tmrc],
|
|
|
|
|
"tmrc": f"0x{args.tmrc:02x}",
|
|
|
|
|
"cycle_count": args.cycle_count,
|
|
|
|
|
# repr() so the constant round-trips through float64 exactly.
|
|
|
|
|
"tesla_per_count": repr(rm3100.tesla_per_count(args.cycle_count)),
|
|
|
|
|
"i2c_address": f"0x{address:02x}",
|
|
|
|
|
"bus_speed_khz": args.bus_speed,
|
|
|
|
|
"revid": f"0x{revid:02x}",
|
|
|
|
|
# What the logger actually used for miss detection. Not recoverable
|
|
|
|
|
# from the data, since capture.py fits the whole run rather than the
|
|
|
|
|
# first second.
|
|
|
|
|
"calibrated_period_s": repr(dt),
|
|
|
|
|
}
|
|
|
|
|
if args.note:
|
|
|
|
|
# Newlines would break the one-line-per-key header format.
|
|
|
|
|
meta["note"] = " ".join(args.note.split())
|
2026-08-19 23:00:47 -04:00
|
|
|
|
|
|
|
|
path = args.output or datetime.now().strftime("rm3100_%Y%m%d_%H%M%S.csv")
|
|
|
|
|
print(f"Logging to {path} -- Ctrl-C to stop\n")
|
|
|
|
|
|
|
|
|
|
q = queue.Queue(maxsize=QUEUE_MAX)
|
2026-08-23 18:16:43 -04:00
|
|
|
stats = {"rows": 0, "dropped": 0, "missed": 0, "ambiguous": 0,
|
|
|
|
|
"max_bracket": 0.0}
|
|
|
|
|
writer = threading.Thread(target=writer_thread,
|
|
|
|
|
args=(q, path, meta, stats), daemon=True)
|
2026-08-19 23:00:47 -04:00
|
|
|
writer.start()
|
|
|
|
|
|
|
|
|
|
try:
|
2026-08-23 18:16:43 -04:00
|
|
|
sample_loop(sensor, q, args.duration, dt, stats)
|
2026-08-19 23:00:47 -04:00
|
|
|
except KeyboardInterrupt:
|
|
|
|
|
pass
|
|
|
|
|
finally:
|
|
|
|
|
q.put(_SENTINEL)
|
|
|
|
|
writer.join(timeout=30)
|
|
|
|
|
print()
|
|
|
|
|
try:
|
|
|
|
|
sensor.stop_cmm()
|
|
|
|
|
print("Continuous measurement mode stopped")
|
|
|
|
|
except IOError as exc:
|
|
|
|
|
print(f"WARNING: could not stop CMM: {exc}", file=sys.stderr)
|
2026-08-23 18:16:43 -04:00
|
|
|
|
|
|
|
|
if stats["dropped"]:
|
|
|
|
|
print(f"WARNING: dropped {stats['dropped']} samples -- the writer "
|
|
|
|
|
"could not keep up", file=sys.stderr)
|
|
|
|
|
print(f"Wrote {stats['rows']} samples to {path}")
|
|
|
|
|
# How close the run came to losing a measurement: the useful number for
|
|
|
|
|
# judging whether a rate is sustainable before committing to a long run.
|
|
|
|
|
# Quoted against the calibrated period, which is the threshold actually
|
|
|
|
|
# enforced -- the nominal one is 6-9% out.
|
|
|
|
|
print(f"Worst DRDY bracket {stats['max_bracket'] * 1e3:.2f} ms of "
|
|
|
|
|
f"{dt * 1e3:.2f} ms allowed "
|
|
|
|
|
f"({stats['max_bracket'] / dt * 100:.0f}% of margin used)")
|
|
|
|
|
|
|
|
|
|
if stats["ambiguous"]:
|
|
|
|
|
print(f"\nWARNING: {stats['ambiguous']:,} gap(s) could not be "
|
|
|
|
|
"counted confidently -- the interval fell near a half-period, "
|
|
|
|
|
"so\nthe number of lost measurements is a guess and "
|
|
|
|
|
f"sample_index may have slipped.\nThose rows carry "
|
|
|
|
|
f"{WARN_AMBIGUOUS} in the warning column. Do not trust this "
|
|
|
|
|
"capture's\ntime axis for spectral work.", file=sys.stderr)
|
|
|
|
|
|
|
|
|
|
if stats["missed"]:
|
|
|
|
|
pct = stats["missed"] / max(stats["rows"], 1) * 100
|
|
|
|
|
print(f"\nWARNING: {stats['missed']:,} measurement(s) were lost "
|
|
|
|
|
f"({pct:.3f}% of rows), flagged {WARN_MISSED}.\n"
|
|
|
|
|
"The sample index still tracks chip time -- the gaps are "
|
|
|
|
|
"explicit, not compressed --\nbut those rows carry no field "
|
|
|
|
|
"data. Lower the rate (higher --tmrc) or raise --bus-speed\n"
|
|
|
|
|
"to remove them.", file=sys.stderr)
|
|
|
|
|
return 1
|
|
|
|
|
return 0
|
2026-08-19 23:00:47 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
2026-08-23 18:16:43 -04:00
|
|
|
sys.exit(main())
|