758 lines
34 KiB
Python
Executable file
758 lines
34 KiB
Python
Executable file
#!/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:
|
|
|
|
./.venv/bin/python logger.py --duration 60
|
|
./.venv/bin/python logger.py --scan-only
|
|
|
|
The chip samples on its own internal schedule (manual sections 5.7.2, 5.8.2), so
|
|
`sample_index` is a grid coordinate. A late read returns the *newer* measurement,
|
|
so every miss is detected and written as an explicit placeholder row, which keeps
|
|
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 reconstructed on load by capture.py.
|
|
"""
|
|
|
|
import argparse
|
|
import csv
|
|
import os
|
|
import queue
|
|
import sys
|
|
import textwrap
|
|
import threading
|
|
import time
|
|
from datetime import datetime
|
|
|
|
import ch347
|
|
import rm3100
|
|
|
|
CSV_FIELDS = ["sample_index", "system_time_unix", "x_raw", "y_raw", "z_raw",
|
|
"warning"]
|
|
|
|
# Mains fundamental. Below twice this the line folds onto signal permanently,
|
|
# which is the one error post-processing cannot undo.
|
|
MAINS_HZ = 60.0
|
|
|
|
CONSOLE_REFRESH_S = 0.05
|
|
FLUSH_INTERVAL_S = 0.5
|
|
|
|
# How long to let the writer finish its backlog once sampling has stopped. Far
|
|
# longer than a healthy writer needs, so reaching it means rows are lost and the
|
|
# run says so.
|
|
WRITER_DRAIN_TIMEOUT_S = 30.0
|
|
|
|
# The sampling loop releases the GIL inside each USB transfer and has to take it
|
|
# back, so Python's default 5 ms switch interval becomes the jitter floor: the
|
|
# writer can hold the GIL that long, stalling the sampler and inflating the DRDY
|
|
# bracket past its budget. Handing off 10x more often costs little throughput,
|
|
# the writer being I/O bound.
|
|
GIL_SWITCH_INTERVAL_S = 0.0005
|
|
|
|
# Thread priority. The sampling loop runs on the main thread and is the only
|
|
# latency-sensitive part. Lowering the writer needs no privilege and is always
|
|
# done: the *relative* priority is what matters, and on Linux nice is per-thread.
|
|
# Raising the sampler needs CAP_SYS_NICE, so it is opt-in via --high-priority,
|
|
# which fails loudly when the privilege is missing.
|
|
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, so
|
|
the ownership is handed back here. Privilege buys scheduling and nothing else.
|
|
"""
|
|
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 it may be a no-op or reach the whole process.
|
|
Failure is tolerated, priority being an optimisation.
|
|
"""
|
|
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
|
|
|
|
# Bounded, so a stalled writer degrades predictably on a long run. Far above the
|
|
# depth a healthy writer reaches, so filling it means the writer is wedged. The
|
|
# run then ends, which keeps sample_index contiguous over everything written.
|
|
QUEUE_MAX = 200_000
|
|
|
|
_SENTINEL = object()
|
|
|
|
|
|
# Rows carry flags in a `warning` column, space separated, empty when fine. See
|
|
# the capture format in the README; the two are independent, so a row can carry
|
|
# either or both.
|
|
WARN_MISSED = "MISSED" # placeholder: the measurement was never read
|
|
WARN_AMBIGUOUS = "AMBIGUOUS" # gap of uncertain length ends here
|
|
|
|
# How near a half-period the rounding may fall before the count is a coin toss.
|
|
AMBIGUITY_MARGIN = 0.25
|
|
|
|
|
|
def parse_args():
|
|
p = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
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,
|
|
help="continuous-mode rate register, 0x92 (fastest) to 0x9D "
|
|
"(slowest). Default is the fastest, which lets the cycle "
|
|
"count set the rate; give one only to sample slower "
|
|
"than the cycle count allows")
|
|
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)")
|
|
p.add_argument("--bus-speed", type=int, choices=sorted(ch347.SPEEDS),
|
|
default=ch347.DEFAULT_SPEED_KHZ,
|
|
help="I2C bus speed in kHz. The default cycle count runs near "
|
|
"282 Hz, where 100 kHz would spend 42%% of each period on "
|
|
"the bus; 750 spends 6%% (default: %(default)s)")
|
|
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")
|
|
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")
|
|
p.add_argument("--scan-only", action="store_true",
|
|
help="scan the bus, report what responded, and exit")
|
|
return p.parse_args()
|
|
|
|
|
|
def print_plan(cfg, bus_speed, requested_rate, sampler_nice=None):
|
|
"""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%} (nominal-model spread; 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")
|
|
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}")
|
|
for note in cfg.notes:
|
|
print(f" note: {note}")
|
|
|
|
# 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 = []
|
|
|
|
# What a low cycle count costs is duty; see rm3100.MIN_CYCLE_COUNT. Dither
|
|
# is the more commonly cited worry, so it is reported alongside.
|
|
if cfg.cycle_count < rm3100.RECOMMENDED_MIN_CYCLE_COUNT:
|
|
reference_duty = (rm3100.integration_time(rm3100.DEFAULT_CYCLE_COUNT)
|
|
/ rm3100.sample_period(rm3100.DEFAULT_CYCLE_COUNT))
|
|
penalty = (reference_duty / cfg.duty) ** 0.5 - 1.0
|
|
warnings.append(
|
|
f"cycle count {cfg.cycle_count} is below the recommended "
|
|
f"{rm3100.RECOMMENDED_MIN_CYCLE_COUNT}: the per-axis overhead holds "
|
|
f"duty to {cfg.duty:.0%} against {reference_duty:.0%} at cycle "
|
|
f"count {rm3100.DEFAULT_CYCLE_COUNT}, which costs {penalty:.0%} of "
|
|
f"post-filter noise floor. Dither is {dither:.2f} LSB at spec noise "
|
|
f"({lsb:.1f} nT/LSB); characterize.py prints the measured sd/LSB "
|
|
"per axis, which settles it from the first capture.")
|
|
|
|
# The 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; the 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:
|
|
# Against the duty the *same rate* would reach on the cycle count alone,
|
|
# which is not 100%: the per-axis overhead is a fixed cost per sample, so
|
|
# even a free-running sensor gives some of it back at high rates.
|
|
best_duty = 1.0 - rm3100.AXES * rm3100.AXIS_OVERHEAD_S * cfg.predicted_hz
|
|
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%} against "
|
|
f"the {best_duty:.0%} this rate reaches on the cycle count alone. "
|
|
f"Post-filter noise goes as 1/sqrt(duty), so that is "
|
|
f"{(best_duty / cfg.duty) ** 0.5:.2f}x the noise floor for nothing.")
|
|
|
|
# Every figure above is derived from the rate, so a rate that came back
|
|
# different from the one asked for is worth saying out loud.
|
|
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.")
|
|
|
|
# Below 2x mains the line folds onto signal, and no filter 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.")
|
|
|
|
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.")
|
|
|
|
if share > 0.5:
|
|
faster = [s for s in sorted(ch347.SPEEDS) if i2c_bus_time(s) / period < 0.25]
|
|
fix = f"; {faster[0]} kHz would fit" if faster else ""
|
|
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)
|
|
|
|
|
|
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"
|
|
" - AVDD/VDD powered, not just DVDD\n"
|
|
" - 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):
|
|
"""Identify and configure the sensor, printing each step. Returns REVID."""
|
|
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)
|
|
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")
|
|
|
|
# Raises if DRC1 did not take; exactly-once sampling depends on it.
|
|
sensor.configure()
|
|
sensor.set_rate(tmrc)
|
|
sensor.start_cmm()
|
|
print("Continuous measurement mode started")
|
|
return revid
|
|
|
|
|
|
def write_header(handle, meta):
|
|
for key, value in meta.items():
|
|
handle.write(f"# {key}: {value}\n")
|
|
|
|
|
|
def writer_thread(q, path, meta, stats):
|
|
"""Drain samples to CSV and drive the console, off the sampling thread."""
|
|
# Stand aside for the sampler: this thread is not latency-sensitive.
|
|
set_thread_nice(WRITER_NICE)
|
|
lsb_nt = float(meta["tesla_per_count"]) * rm3100.NT_PER_TESLA
|
|
with open(path, "w", newline="") as handle:
|
|
restore_ownership(path)
|
|
write_header(handle, meta)
|
|
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
|
|
index, system_time, cx, cy, cz, warning = item
|
|
|
|
# 6 decimals is microsecond resolution on an epoch value, far finer
|
|
# than the ~1 ms bracket uncertainty. Placeholders carry zeros rather
|
|
# than blanks: the warning column already says the row has no data,
|
|
# so x/y/z stay strictly integer and 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])
|
|
stats["rows"] += 1
|
|
|
|
now = time.monotonic()
|
|
if now - last_flush >= FLUSH_INTERVAL_S:
|
|
handle.flush()
|
|
last_flush = now
|
|
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.
|
|
last_print = now
|
|
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",
|
|
end="", flush=True)
|
|
handle.flush()
|
|
|
|
|
|
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 of 1 payload byte plus a results read of 9.
|
|
|
|
This is the irreducible traffic, and the figure worth reading against the
|
|
sample period. The loop polls continuously while waiting for DRDY, so raw
|
|
bus occupancy approaches 100%.
|
|
"""
|
|
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. The table value is a nominal model,
|
|
out by up to ~6% on this unit, which is enough to miscount grid points
|
|
inside a bracket and slip the sample index.
|
|
|
|
Least-squares slope of read time against index, which over one second pins
|
|
the period to roughly 0.03%, far finer than the resolution needed to tell k
|
|
grid points from k+1.
|
|
|
|
Requires a stretch with no miss, since an interval spanning one is a multiple
|
|
of the period. Returns None when 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 for free from the transactions being performed.
|
|
"""
|
|
poll_ready, read_raw = sensor.poll_ready, sensor.read_raw
|
|
monotonic = time.monotonic
|
|
|
|
# Bootstrap the period from observation rather than the TMRC table. When the
|
|
# cycle count governs, the table value is out by a large factor: at cc=400
|
|
# with TMRC 0x92 it says 1.67 ms against a real 13.5 ms, and a threshold
|
|
# built on it rejects every interval. The median of a handful of raw
|
|
# intervals is robust to the occasional stall and seeds 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
|
|
# Timed from here. `ready` is the instant the STATUS reply landed
|
|
# on the wire, so it carries the tail of the poll transaction.
|
|
before = monotonic()
|
|
read_raw()
|
|
read_cost += monotonic() - before
|
|
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.
|
|
|
|
Miss detection uses the **DRDY bracket**: the span between the last poll
|
|
showing DRDY clear and the poll showing it set. The bracket is exact. A
|
|
results read is what clears DRDY, so everything earlier has been read;
|
|
measurements complete one period apart, so a bracket narrower than a period
|
|
holds at most one completion, and DRDY going high proves it held at least
|
|
one. Host stalls push the read-to-read interval well past a period, and this
|
|
is unaffected by them.
|
|
|
|
The timestamp recorded per sample is the midpoint of that bracket, the best
|
|
estimate available without a hardware DRDY line.
|
|
|
|
Ends the run if the queue fills, which keeps sample_index contiguous and so
|
|
keeps every row written a valid chip-time grid coordinate.
|
|
"""
|
|
read_raw = sensor.read_raw
|
|
poll_ready = sensor.poll_ready
|
|
monotonic = time.monotonic
|
|
put = q.put_nowait
|
|
|
|
# DRDY may already be set from whatever ran before; BIST zeroes the result
|
|
# registers and leaves it high. Discard one reading so sample 0 is fresh.
|
|
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.
|
|
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.
|
|
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
|
|
|
|
# Two stages, because the two questions have different best answers.
|
|
# "Did we lose any?" is settled rigorously by the bracket, above. "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 reaches back only to the last poll that saw
|
|
# DRDY clear, which loses track of where the grid sits.
|
|
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: one rounding to zero losses
|
|
# can still be a coin toss, with 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 apart from the previous
|
|
# accepted sample, which is where the lost measurements completed
|
|
# and what keeps the rate fit straight.
|
|
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["truncated"] = True
|
|
return
|
|
index += 1
|
|
|
|
sample_wall = (clear_wall + ready_wall) / 2.0
|
|
(cx, cy, cz), _, _ = read_raw()
|
|
try:
|
|
# 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 ""))
|
|
except queue.Full:
|
|
stats["truncated"] = True
|
|
return
|
|
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
|
|
|
|
|
|
def main():
|
|
args = parse_args()
|
|
sys.setswitchinterval(GIL_SWITCH_INTERVAL_S)
|
|
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
|
|
|
|
if args.cycle_count is not None and not 1 <= args.cycle_count <= 0xFFFF:
|
|
sys.exit(f"--cycle-count {args.cycle_count} outside 1..65535")
|
|
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:
|
|
sys.exit(f"--tmrc 0x{args.tmrc:02x} is not a valid rate register value")
|
|
|
|
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
|
|
|
|
try:
|
|
bus = ch347.CH347I2C(ch347.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.")
|
|
return 0
|
|
|
|
address = find_sensor(bus, args.address)
|
|
sensor = rm3100.RM3100(bus, address)
|
|
try:
|
|
revid = start_sensor(sensor, args.cycle_count, args.tmrc)
|
|
except IOError as exc:
|
|
sys.exit(str(exc))
|
|
|
|
print()
|
|
print_plan(cfg, args.bus_speed, args.rate, sampler_nice)
|
|
print()
|
|
nominal_rate = cfg.predicted_hz
|
|
|
|
# Miss detection, placeholder counting and the sample-index grid all
|
|
# depend on the real period, so measure it first.
|
|
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.")
|
|
# Against the predicted rate, which is what the configuration aimed
|
|
# for once the cycle count governs.
|
|
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. It costs a
|
|
# near-constant ~0.9 ms per sample, 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,
|
|
# Whichever of the two ceilings governs.
|
|
"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 miss detection actually used. capture.py fits the whole run,
|
|
# so this figure lives only here.
|
|
"calibrated_period_s": repr(dt),
|
|
}
|
|
if args.note:
|
|
# Newlines would break the one-line-per-key header format.
|
|
meta["note"] = " ".join(args.note.split())
|
|
|
|
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)
|
|
stats = {"rows": 0, "missed": 0, "ambiguous": 0, "max_bracket": 0.0,
|
|
"truncated": False}
|
|
writer = threading.Thread(target=writer_thread,
|
|
args=(q, path, meta, stats), daemon=True)
|
|
writer.start()
|
|
|
|
try:
|
|
sample_loop(sensor, q, args.duration, dt, stats)
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
q.put(_SENTINEL)
|
|
writer.join(timeout=WRITER_DRAIN_TIMEOUT_S)
|
|
drained = not writer.is_alive()
|
|
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)
|
|
|
|
print(f"Wrote {stats['rows']} samples to {path}")
|
|
# How close the run came to losing a measurement: the number to watch
|
|
# before committing to a long run. Quoted against the calibrated period,
|
|
# which is the threshold enforced.
|
|
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)")
|
|
|
|
# Each of these leaves the capture usable but compromised in a way the
|
|
# file alone conceals, so each is said out loud and each makes the exit
|
|
# status non-zero.
|
|
failed = False
|
|
|
|
if stats["truncated"]:
|
|
failed = True
|
|
print("\nWARNING: recording stopped early. The writer could not "
|
|
"keep up and the\nqueue filled, so the run ended to keep "
|
|
"sample_index contiguous.\nEverything written is valid.",
|
|
file=sys.stderr)
|
|
|
|
if not drained:
|
|
failed = True
|
|
# The writer is a daemon thread, so anything still queued dies with
|
|
# the process. Say so, since a short tail is invisible in the file.
|
|
print(f"\nWARNING: the writer did not finish within "
|
|
f"{WRITER_DRAIN_TIMEOUT_S:g} s, so the last\n"
|
|
f"{q.qsize():,} queued row(s) were dropped. The file is valid "
|
|
"up to where it stops.", file=sys.stderr)
|
|
|
|
if stats["ambiguous"]:
|
|
failed = True
|
|
print(f"\nWARNING: {stats['ambiguous']:,} gap(s) could not be "
|
|
"counted confidently. The interval fell near a\nhalf-period, "
|
|
"so the number of lost measurements is a guess and "
|
|
"sample_index\nmay have slipped from there on. Those rows "
|
|
f"carry {WARN_AMBIGUOUS} in the\nwarning column. Re-record "
|
|
"before using this capture's time axis.",
|
|
file=sys.stderr)
|
|
|
|
if stats["missed"]:
|
|
failed = True
|
|
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 gaps are explicit, so the sample index still tracks chip "
|
|
"time, and those\nrows carry no field data. Lower the rate "
|
|
"(higher --tmrc) or raise --bus-speed\nto remove them.",
|
|
file=sys.stderr)
|
|
|
|
return 1 if failed else 0
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|