#!/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, 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.
"""

import argparse
import csv
import queue
import sys
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"]

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

# 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

# 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.

# 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()


# 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


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=[20, 100, 400, 750], default=750,
                   help="I2C bus speed in kHz. The default cycle count runs near "
                        "534 Hz, where 100 kHz would spend 80%% of each period on "
                        "the bus; 750 spends 11%% (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("--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):
    """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")
    for note in cfg.notes:
        print(f"  note: {note}")
    # Bus speed is validated but never changed silently -- swapping it would
    # hide a wiring problem behind a configuration change.
    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 ""
        print(f"  WARNING: the bus needs {share:.0%} of every period{fix}",
              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."""
    lsb_nt = float(meta["tesla_per_count"]) * rm3100.NT_PER_TESLA
    with open(path, "w", newline="") as handle:
        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 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])
            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 rather than blanking or showing nonsense.
                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 (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.
    """
    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 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()
        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["dropped"] += 1
        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)

    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(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 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)
        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())

        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, "missed": 0, "ambiguous": 0,
                 "max_bracket": 0.0}
        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=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}")
        # 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


if __name__ == "__main__":
    sys.exit(main())
