rm3100/logger.py

265 lines
9.5 KiB
Python
Raw Normal View History

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:
./.venv/bin/python logger.py --duration 10
./.venv/bin/python logger.py --scan-only
The sampling loop is kept clear of everything that is not I2C traffic: raw
counts go onto a queue, and a writer thread does the unit conversion, CSV
formatting and console display. At the rates the sensor can reach (~540 Hz at
cycle count 50) that formatting work is otherwise the bottleneck, not the bus.
"""
import argparse
import csv
import math
import queue
import sys
import threading
import time
from datetime import datetime, timedelta, timezone
import ch347
import rm3100
CSV_FIELDS = [
"timestamp_iso", "elapsed_s",
"x_raw", "y_raw", "z_raw",
"x_uT", "y_uT", "z_uT",
"magnitude_uT",
]
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
# 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()
def parse_args():
p = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--cycle-count", type=int, default=rm3100.DEFAULT_CYCLE_COUNT,
help="cycle count per axis; lower is faster but noisier. "
"50 allows ~540 Hz, 200 allows ~150 Hz (default: %(default)s)")
p.add_argument("--tmrc", type=lambda s: int(s, 0), default=0x96,
help="continuous-mode rate register, 0x92 (fastest) to 0x9D "
"(slowest) (default: 0x96, ~37 Hz)")
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=[20, 100, 400, 750], default=100,
help="I2C bus speed in kHz; 400 measured best for high sample "
"rates (default: %(default)s)")
p.add_argument("--scan-only", action="store_true",
help="scan the bus, report what responded, and exit")
return p.parse_args()
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"
" - the adapter's voltage jumper (the RM3100 wants ~3.3 V)\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."""
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)
gain = rm3100.gain_lsb_per_ut(cycle_count)
print(f"Cycle counts set to {readback} -- gain {gain:.1f} LSB/uT")
sensor.configure()
sensor.set_rate(tmrc)
rate = rm3100.TMRC_RATES[tmrc]
print(f"Rate register 0x{tmrc:02x} -- requesting about {rate:g} Hz")
sensor.start_cmm()
print("Continuous measurement mode started")
return rate
def writer_thread(q, path, gain, start_wall, stats):
"""Drain raw samples: convert, format, write CSV, drive the console.
Everything here is deliberately off the sampling thread.
"""
with open(path, "w", newline="") as handle:
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
elapsed, cx, cy, cz = item
ux, uy, uz = cx / gain, cy / gain, cz / gain
magnitude = math.sqrt(ux * ux + uy * uy + uz * uz)
# Wall-clock is reconstructed from the monotonic offset rather than
# sampled per reading: one fewer syscall in the hot path, and immune
# to NTP steps mid-capture.
stamp = (start_wall + timedelta(seconds=elapsed)).isoformat()
out.writerow([stamp, f"{elapsed:.5f}", cx, cy, cz,
f"{ux:.4f}", f"{uy:.4f}", f"{uz:.4f}",
f"{magnitude:.4f}"])
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:
last_print = now
print(f"\r{elapsed:8.2f}s X {ux:+9.3f} Y {uy:+9.3f} "
f"Z {uz:+9.3f} |B| {magnitude:8.3f} uT "
f"({stats['rows']} samples, "
f"{stats['rows'] / max(elapsed, 1e-9):.0f} Hz)",
end="", flush=True)
handle.flush()
def sample_loop(sensor, q, duration, rate, stats):
"""Read the sensor as fast as it produces data. I2C and nothing else."""
sample_timeout = max(2.0, 5.0 / rate)
read_raw = sensor.read_raw
data_ready = sensor.data_ready
monotonic = time.monotonic
put = q.put_nowait
start = monotonic()
timeouts = 0
deadline = start + duration if duration > 0 else float("inf")
while monotonic() < deadline:
if not data_ready():
if monotonic() - start > sample_timeout and stats["rows"] == 0:
timeouts += 1
print(f"\nWARNING: no data ready within {sample_timeout:.1f} s",
file=sys.stderr)
if timeouts >= 3:
print("Giving up after 3 consecutive timeouts.", file=sys.stderr)
return
continue
cx, cy, cz = read_raw()
try:
put((monotonic() - start, cx, cy, cz))
except queue.Full:
stats["dropped"] += 1
def main():
args = parse_args()
if args.cycle_count < 1 or args.cycle_count > 0xFFFF:
sys.exit(f"--cycle-count {args.cycle_count} outside 1..65535")
if args.tmrc not in rm3100.TMRC_RATES:
sys.exit(f"--tmrc 0x{args.tmrc:02x} is not a valid rate register value")
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.")
return
address = find_sensor(bus, args.address)
sensor = rm3100.RM3100(bus, address)
rate = start_sensor(sensor, args.cycle_count, args.tmrc)
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, "dropped": 0}
writer = threading.Thread(
target=writer_thread,
args=(q, path, rm3100.gain_lsb_per_ut(args.cycle_count),
datetime.now(timezone.utc), stats),
daemon=True)
writer.start()
try:
sample_loop(sensor, q, args.duration, rate, stats)
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)
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}")
if __name__ == "__main__":
main()