#!/usr/bin/env python3
"""Compare captures taken under different conditions -- e.g. two supplies.

    ./.venv/bin/python compare.py a.csv b.csv c.csv ...
    ./.venv/bin/python compare.py --group note *.csv        # label from header
    ./.venv/bin/python compare.py LDO/cc100=a.csv 3V3/cc100=b.csv ...

A label may be given as `LABEL=path`, which is the only way to identify a run
whose capture was recorded without `--note`. Everything before the first `/` in
a label is the **condition** -- the thing under test -- and captures sharing one
are treated as the same configuration. `--supply LDO=3.0` then attaches a rail
voltage to every capture in that condition.

Three lessons are built in, each learned by getting it wrong first.

**Compare |B|, not the axes.** Swapping a supply means touching the rig, and the
sensor does not go back exactly where it was. |B| survives a rotation; the
individual axes do not, so a per-axis difference measures the handling. The axes
appear here only as movement diagnostics.

**Compare fractionally, or over a stated band.** A gain change carries the noise
with it, so a run reading 6% larger also reads ~6% noisier while being physically
identical. And a broadband figure is only comparable over a band both captures
cover -- otherwise a fast capture is scored over five times the bandwidth of a
slow one. One band is chosen for the whole invocation and printed.

**A magnitude difference is not automatically gain.** |B| is preserved under
rotation but *not* under translation through a field gradient, so a sensor that
moved can change magnitude on its own. Two diagnostics bound it:

  rotation angle      the angle between mean field directions.
  gain residual       what is left after the best single scale factor, as a
                      fraction of |B|. Zero means one number relates the two
                      captures, which is what a gain change looks like.

Neither can prove the sensor held still, so neither vetoes the comparison; they
size the doubt. The reproducibility floor from repeats of one condition is the
honest error bar, and this prints it when there are repeats to use.
"""

import argparse
import itertools
import math
import os
import sys
from collections import defaultdict

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np

import capture
import characterize as ch
import rm3100

# A pure gain change scales every axis by the same factor. Allow a little for
# noise on the means before calling it movement.
RATIO_SPREAD_OK = 0.01      # 1%
ROTATION_OK_DEG = 0.5

# An axis whose mean field is near zero has a ratio dominated by noise -- a few
# nT of drift on a 10 nT mean is a ratio of 1.3 with nothing moving. Such an axis
# is excluded from the spread test rather than allowed to dominate it. The
# rotation check needs no such guard: it works on the direction of the whole
# vector, where a small component simply contributes little.
RATIO_AXIS_MIN_FRACTION = 0.05      # of |B|

# Two captures whose rates differ by less than this are treated as the same
# rate, so a line holding a fixed fraction of fs across them proves nothing.
RATE_DISTINCT = 0.01        # 1%

# Condition and variant are separated by this in a label: `LDO/cc100`.
CONDITION_SEP = "/"

# Hue carries the condition -- the thing under test -- and never the cycle
# count, so a chart never repaints when a run is added. Validated all-pairs in
# light mode against the #fcfcfb surface: worst CVD dE 24.7, normal-vision 33.6.
CONDITION_COLORS = ["#2a78d6", "#eb6834", "#1baf7a"]
# Cycle count is the secondary encoding, carried by line style and marker, so
# identity never rests on colour alone.
VARIANT_STYLES = ["-", "--", ":", "-."]
VARIANT_MARKERS = ["o", "s", "^", "D"]


# --------------------------------------------------------------------------
# Arguments
# --------------------------------------------------------------------------

def split_label(argument):
    """Split `LABEL=path` into (label, path); (None, path) when unlabelled.

    A path may itself contain '=', so a bare existing path always wins over the
    labelled reading. When neither reading names a file that exists, the
    labelled one is returned so the error message quotes the path the user
    most likely meant.
    """
    label, separator, path = argument.partition("=")
    if not separator:
        return None, argument
    if not label:
        raise ValueError(f"{argument!r} has an empty label before '='")
    if not os.path.exists(path) and os.path.exists(argument):
        return None, argument
    return label, path


def parse_supply(argument):
    """Parse `CONDITION=VOLTS` for --supply."""
    condition, separator, volts = argument.partition("=")
    if not separator or not condition:
        raise argparse.ArgumentTypeError(
            f"--supply {argument!r} must be CONDITION=VOLTS, e.g. LDO=3.0")
    try:
        value = float(volts)
    except ValueError:
        raise argparse.ArgumentTypeError(
            f"--supply {argument!r}: {volts!r} is not a number")
    if not 0 < value < 100:
        raise argparse.ArgumentTypeError(
            f"--supply {argument!r}: {value} V is not a plausible rail")
    return condition, value


def parse_band(text):
    """Parse `LO,HI` for --band."""
    parts = text.split(",")
    if len(parts) != 2:
        raise argparse.ArgumentTypeError(f"--band {text!r} must be LO,HI in Hz")
    try:
        lo, hi = (float(p) for p in parts)
    except ValueError:
        raise argparse.ArgumentTypeError(f"--band {text!r} must be two numbers")
    if not 0 < lo < hi:
        raise argparse.ArgumentTypeError(
            f"--band {text!r} needs 0 < LO < HI")
    return lo, hi


def label_for(cap, path, group_by, explicit):
    if explicit:
        return explicit
    if group_by == "note":
        return cap.meta.get("note", "(no note)")
    return os.path.basename(path)


def condition_of(label):
    """The part of a label before the first '/': the thing under test."""
    return label.split(CONDITION_SEP, 1)[0]


def variant_of(label):
    """The part after the first '/', or '' when the label carries none."""
    _, separator, variant = label.partition(CONDITION_SEP)
    return variant if separator else ""


def conditions_in_order(records):
    """Distinct conditions, in the order they were given on the command line.

    Command-line order, not alphabetical: it is the one thing the caller
    controls, so contrasts read in the direction they asked for and a colour
    never moves between runs because a condition was renamed.
    """
    seen = []
    for label, _, _ in records:
        condition = condition_of(label)
        if condition not in seen:
            seen.append(condition)
    return seen


# --------------------------------------------------------------------------
# Per-capture summary
# --------------------------------------------------------------------------

def summarise(cap, band):
    d = dict(cap.axes())
    mean = np.array([d["x"].mean(), d["y"].mean(), d["z"].mean()])
    field = float(np.linalg.norm(mean))
    fs = cap.true_rate_hz
    out = {"cap": cap, "mean": mean, "field": field, "axes": d, "fs": fs,
           "cycle_count": cap.cycle_count}
    for key, _, _ in ch.SERIES:
        v = d[key]
        median_asd, band_rms, peak, peak_hz = ch.band_stats(v, fs, band)
        out[key] = {
            "sd": v.std(),
            "white": ch.white_sd(v),
            "ppm": v.std() / field * 1e6,
            "asd": median_asd,
            # Every absolute figure above is in nanotesla, and nanotesla are
            # what a gain change moves. A condition that reads 7% smaller reads
            # 7% quieter in nT while being no quieter at all, so any comparison
            # across conditions has to divide by that condition's own |B|.
            "asd_ppm": median_asd / field * 1e6,
            "white_ppm": ch.white_sd(v) / field * 1e6,
            "band_rms": band_rms,
            "band_rms_ppm": band_rms / field * 1e6,
            "peak": peak,
            "peak_hz": peak_hz,
        }
    taus, devs = ch.allan_deviation(d["total"], fs)
    i = int(np.argmin(devs))
    out["allan"] = (float(devs[i]), float(taus[i]))
    out["allan_curve"] = (taus, devs)
    out["lines"] = ch.sample_locked_lines(d["total"])
    return out


# --------------------------------------------------------------------------
# Cross-capture analysis
# --------------------------------------------------------------------------

def axis_ratio_spread(a, b, ratio):
    """Spread of the per-axis scale ratios, or None if it cannot be measured.

    A pure gain change scales X, Y and Z identically, so the spread is the
    movement test. It is only meaningful on axes that actually carry field:
    dividing two near-zero means amplifies their noise without bound, and one
    such axis is enough to report a stationary sensor as moved. Axes below
    RATIO_AXIS_MIN_FRACTION of |B| in *either* capture are therefore dropped,
    and with fewer than two left the test abstains.
    """
    usable = [i for i in range(3)
              if abs(a["mean"][i]) >= RATIO_AXIS_MIN_FRACTION * a["field"]
              and abs(b["mean"][i]) >= RATIO_AXIS_MIN_FRACTION * b["field"]]
    if len(usable) < 2:
        return None
    kept = ratio[usable]
    return float(np.ptp(kept) / np.abs(np.mean(kept)))


def gain_and_movement(a, b):
    """(scale, residual, rotation deg) relating two mean field vectors.

    `scale` is the single factor that best maps a onto b, and `residual` is what
    that factor cannot explain, as a fraction of |b|. A pure gain change leaves
    a residual of zero at any angle; a rotation leaves a large one. Unlike a
    per-axis ratio this stays finite when an axis passes through zero, which is
    exactly when the sensor has been re-oriented and the diagnostic is needed.
    """
    ma, mb = a["mean"], b["mean"]
    scale = float(ma @ mb / (ma @ ma))
    residual = float(np.linalg.norm(mb - scale * ma) / np.linalg.norm(mb))
    ua, ub = ma / a["field"], mb / b["field"]
    angle = float(np.degrees(np.arccos(np.clip(ua @ ub, -1, 1))))
    return scale, residual, angle


def fit_rate_model(caps):
    """Solve rm3100's timing model for a set of captures: (counts/s, overhead).

    The model is `period = AXES * (cycle_count / C + h)`, so period/AXES is
    linear in cycle count with slope 1/C and intercept h. Two cycle counts
    determine both exactly; more are a least-squares fit.

    Fitting it per condition is what separates an oscillator that changed from a
    model that was never right: a supply that only scales the clock moves C and
    h together by one factor, leaving the *shape* of the model alone.
    """
    counts = np.array([float(c.cycle_count) for c in caps])
    if len(np.unique(counts)) < 2:
        raise ValueError("need at least two distinct cycle counts to fit "
                         "both the count rate and the per-axis overhead")
    periods = np.array([c.dt_true for c in caps]) / rm3100.AXES
    slope, intercept = np.polyfit(counts, periods, 1)
    if slope <= 0:
        raise ValueError("fitted count rate is not positive; the captures do "
                         "not follow the timing model")
    return 1.0 / float(slope), float(intercept)


def decimation_paths(fast, slow, k, band):
    """Noise of a fast capture decimated by k, against a natively slow one.

    The question this answers is whether sampling fast and filtering down is
    equivalent to having sampled slowly -- which is the whole basis for
    recommending a low cycle count. Both decimations are shown because they
    differ in exactly one way that matters: the boxcar is what the sensor's own
    integration does, poor stopband and all, while the FIR is what a filter
    designed for the job does, and only the second actually rejects what would
    otherwise fold in.

    Compare the ASD column, not the sd column. Each path has a different
    effective noise bandwidth, so their per-sample sd figures are not on the
    same footing, while ASD over a common band is.
    """
    v_fast = dict(fast["cap"].axes())["total"]
    v_slow = dict(slow["cap"].axes())["total"]
    fs_fast, fs_slow = fast["fs"], slow["fs"]
    rows = [("raw fast", v_fast, fs_fast, fast["field"])]
    for method in ("boxcar", "fir"):
        rows.append((f"{method} /{k}", ch.decimate(v_fast, k, method),
                     fs_fast / k, fast["field"]))
    rows.append(("native slow", v_slow, fs_slow, slow["field"]))
    out = []
    for name, v, fs, field in rows:
        median_asd, band_rms, _, _ = ch.band_stats(v, fs, band)
        out.append({"name": name, "fs": fs, "asd": median_asd,
                    # The two cycle counts do not share a gain either, so the
                    # fractional column is the comparable one here too.
                    "asd_ppm": median_asd / field * 1e6,
                    "band_rms": band_rms, "white": ch.white_sd(v),
                    "lines": ch.sample_locked_lines(v)})
    return out


def decimation_pairs(entries):
    """[(condition, fast, slow, k)] for every pair a decimation can bridge.

    `entries` are (label, summary-or-capture-like) with .cycle_count and a rate.
    A pair qualifies when one cycle count is an integer multiple of the other,
    since that is when averaging k samples of the fast run buys the same
    integration time as one sample of the slow one.
    """
    by_condition = defaultdict(list)
    for label, item in entries:
        by_condition[condition_of(label)].append((label, item))
    pairs = []
    for condition, group in by_condition.items():
        for (la, a), (lb, b) in itertools.combinations(group, 2):
            fast, slow, lf, ls = ((a, b, la, lb) if a["fs"] > b["fs"]
                                  else (b, a, lb, la))
            if not fast["cycle_count"] or slow["cycle_count"] % fast["cycle_count"]:
                continue
            k = slow["cycle_count"] // fast["cycle_count"]
            if k >= 2:
                pairs.append((condition, (lf, fast), (ls, slow), k))
    return pairs


def comparable_rates(loaded):
    """Every rate that will be scored, decimated paths included.

    The band has to fit inside the narrowest of these, not merely inside the
    slowest capture: decimating by k lands at fs/k, and the chip's per-axis
    overhead means that is a little *below* the natively-slow rate rather than
    equal to it. Two percent of bandwidth is enough to score the decimated path
    on its own filter rolloff and report it as the quieter one.

    Runs before the summaries exist, so it takes captures rather than records.
    """
    entries = [(label, {"fs": cap.true_rate_hz,
                        "cycle_count": cap.cycle_count})
               for label, cap in loaded]
    rates = [item["fs"] for _, item in entries]
    rates += [fast["fs"] / k for _, (_, fast), _, k in decimation_pairs(entries)]
    return rates


def locked_line_verdict(records):
    """Which rational-fraction lines hold their fraction across rates.

    A field line sits at a fixed frequency, so it lands on a different fraction
    of fs when the rate changes. One that keeps the same fraction is locked to
    the sampling and is an artefact of measuring rather than something measured.
    Deciding this needs two captures at genuinely different rates, which is why
    it lives here and not in characterize.py.
    """
    seen = defaultdict(list)
    for label, _, s in records:
        for line in s["lines"]:
            seen[(line.numerator, line.period)].append((label, s["fs"], line))
    verdicts = []
    for fraction, hits in sorted(seen.items(),
                                 key=lambda kv: -max(h[2].sigma for h in kv[1])):
        rates = [fs for _, fs, _ in hits]
        distinct = (len(rates) > 1
                    and (max(rates) / min(rates) - 1) > RATE_DISTINCT)
        verdicts.append((fraction, hits, distinct))
    return verdicts


# --------------------------------------------------------------------------
# Output sections
# --------------------------------------------------------------------------

def print_overview(records, band, supplies):
    print(f"{'capture':<20} {'cc':>5} {'fs Hz':>9} {'|B| nT':>10} {'sd':>7} "
          f"{'white':>7} {'ASD':>6} {'Allan':>6} | {'white':>7} {'ASD':>7}")
    for label, _, s in records:
        t = s["total"]
        print(f"{label[:20]:<20} {s['cycle_count']:5d} {s['fs']:9.3f} "
              f"{s['field']:10,.0f} {t['sd']:7.1f} {t['white']:7.2f} "
              f"{t['asd']:6.2f} {s['allan'][0]:6.2f} | "
              f"{t['white_ppm']:7.1f} {t['asd_ppm']:7.1f}")
    print(f"  Left of the bar, absolute: sd/white/Allan in nT, ASD in nT/rtHz "
          f"over {band[0]:g}-{band[1]:.1f} Hz.")
    print("  Right of the bar, the same two divided by that capture's own |B|: "
          "ppm and ppm/rtHz.")
    print("  Compare conditions on the right. A condition whose gain differs "
          "records proportionally\n  smaller nanotesla of noise while being no "
          "quieter, so the absolute columns would\n  credit a low gain as a "
          "low noise floor. Use the left against Table 3-1, which is\n  "
          "quoted in nT at one supply.")
    print("  white = sd(diff)/sqrt(2), which rejects drift; sd does not. "
          "Allan = best sigma by averaging.")
    if supplies:
        rails = "  ".join(f"{c} {v:g} V" for c, v in sorted(supplies.items()))
        print(f"  rails: {rails}")


def print_rate_model(records):
    by_condition = defaultdict(list)
    for label, _, s in records:
        by_condition[condition_of(label)].append(s["cap"])
    fits = {}
    for condition in conditions_in_order(records):
        try:
            fits[condition] = fit_rate_model(by_condition[condition])
        except ValueError:
            continue
    if not fits:
        return
    print("\n=== timing model: period = 3 x (cycle_count / C + overhead) ===")
    for condition, (count_rate, overhead) in fits.items():
        print(f"  {condition:<12} C {count_rate:10,.0f} counts/s   "
              f"overhead {overhead * 1e6:6.2f} us")
    print(f"  {'rm3100.py':<12} C {rm3100.COUNTS_PER_SECOND:10,.0f} counts/s   "
          f"overhead {rm3100.AXIS_OVERHEAD_S * 1e6:6.2f} us   (the model's own)")
    if len(fits) < 2:
        return
    print("\n  Everything the chip times comes off one oscillator, so a "
          "condition that only")
    print("  changes its frequency scales C up and the overhead down by the "
          "same factor:")
    for (ca, fa), (cb, fb) in itertools.combinations(fits.items(), 2):
        count_ratio = fb[0] / fa[0]
        overhead_ratio = fb[1] / fa[1]
        print(f"    {ca} -> {cb}   C x{count_ratio:.5f} "
              f"({(count_ratio - 1) * 100:+.2f}%)   "
              f"overhead x{overhead_ratio:.5f} "
              f"({(overhead_ratio - 1) * 100:+.2f}%)")
        print(f"      pure clock scaling would put the overhead at "
              f"x{1 / count_ratio:.5f}; it is "
              f"{(overhead_ratio * count_ratio - 1) * 100:+.2f}% off that.")


def print_locked_lines(records):
    verdicts = locked_line_verdict(records)
    if not verdicts:
        return
    print("\n=== lines at simple fractions of the sample rate (|B|) ===")
    for (numerator, period), hits, distinct in verdicts:
        print(f"  {numerator}/{period} of fs:")
        for label, fs, line in hits:
            print(f"    {label[:24]:<24} {line.numerator/line.period*fs:8.3f} Hz"
                  f"   {line.amplitude:6.2f} nT   {line.sigma:5.1f} sigma")
        if distinct:
            print("    -> SAMPLE-LOCKED. It holds this fraction of fs across "
                  "captures whose rates\n       differ, so it moves with the "
                  "sampling and is not a field at a fixed\n       frequency. "
                  "Decimating a faster capture avoids it; filtering cannot.")
        elif len(hits) > 1:
            print("    -> seen in several captures, but at rates too close "
                  "together to tell a\n       sample-locked line from a fixed "
                  "one. Re-run with rates further apart.")
        else:
            print("    -> seen in one capture only, so nothing here says "
                  "whether it is locked to\n       the sampling or to a "
                  "frequency.")


def print_decimation(records, band):
    pairs = decimation_pairs([(label, s) for label, _, s in records])
    if not pairs:
        return
    print("\n=== does filtering and decimating the fast capture match the "
          "slow one? ===")
    for condition, (lf, fast), (ls, slow), k in pairs:
        print(f"\n  {condition}: {lf} decimated by {k} vs {ls}")
        try:
            rows = decimation_paths(fast, slow, k, band)
        except ValueError as exc:
            print(f"    cannot decimate: {exc}")
            continue
        print(f"    {'path':<14} {'fs Hz':>8} {'ASD':>7} {'ASD ppm':>8} "
              f"{'white sd':>9}  sample-locked lines")
        for row in rows:
            lines = (", ".join(f"{l.numerator}/{l.period} {l.amplitude:.2f} nT"
                               for l in row["lines"]) or "none")
            print(f"    {row['name']:<14} {row['fs']:8.2f} {row['asd']:7.2f} "
                  f"{row['asd_ppm']:8.1f} {row['white']:9.2f}  {lines}")
        raw = rows[0]
        fir = next(r for r in rows if r["name"].startswith("fir"))
        native = rows[-1]
        # Two ratios, because one is clean and the other is not. Decimating a
        # capture cannot change its gain or move the sensor, so the first is a
        # measurement of the filter alone. The second brings in a whole separate
        # run, and carries every difference between the two runs with it.
        print(f"    -> decimating this capture changes its own floor by "
              f"{(fir['asd_ppm'] / raw['asd_ppm'] - 1) * 100:+.1f}% "
              f"-- no other difference is involved.")
        print(f"       Against the separately-recorded slow capture it is "
              f"{fir['asd_ppm'] / native['asd_ppm']:.3f}x, which also carries "
              f"whatever\n       differs between two runs.")
    print("\n  Compare the ASD ppm column: each path has a different effective "
          "noise bandwidth,\n  so per-sample sd figures are not on the same "
          "footing, and the two cycle counts do\n  not share a gain, so "
          "absolute nT are not either.")


# The candidate explanations for a gain that moves with the supply, in the
# order they are reported. `both` is included because it is the obvious thing
# to try and because it is instructive when it fails: the rate change *is* the
# supply change seen through the oscillator, so applying the two on top of each
# other counts the same physics twice.
CORRECTIONS = [("raw", "raw"),
               ("rate", "x measured fs"),
               ("supply", "x rail volts"),
               ("both", "x fs x volts")]


def corrected_fields(s, supplies, reference_volts):
    """|B| under each candidate correction, keyed by name; None if unavailable.

    rate    the chip's own clock is measured every run, so a gain that tracks
            the clock is correctable from the capture alone, with nothing
            external to know.
    supply  a gain that is simply ratiometric with the rail needs the rail
            measured, but nothing from the capture.
    both    the two together, which is only right if they are independent
            mechanisms rather than one seen twice.
    """
    raw = s["field"]
    by_rate = raw * (1.0 + s["cap"].rate_error)
    volts = supplies.get(condition_of(s["label"]))
    ratio = (volts / reference_volts) if volts else None
    return {"raw": raw,
            "rate": by_rate,
            "supply": raw * ratio if ratio else None,
            "both": by_rate * ratio if ratio else None}


def print_contrasts(records, supplies):
    """Condition contrasts at matched cycle count -- the designed comparison."""
    conditions = conditions_in_order(records)
    if len(conditions) < 2:
        return
    reference_volts = min(supplies.values()) if supplies else None
    by_cc = defaultdict(dict)
    for label, _, s in records:
        s["label"] = label
        by_cc[s["cycle_count"]][condition_of(label)] = s

    print("\n=== |B| by condition, at matched cycle count ===")
    print("The comparison the interleaving was for: same cycle count, "
          "so only the condition differs.")
    contrasts = defaultdict(list)
    exponents = defaultdict(list)
    for cc in sorted(by_cc):
        present = by_cc[cc]
        if len(present) < 2:
            continue
        for ca, cb in itertools.combinations(conditions, 2):
            if ca not in present or cb not in present:
                continue
            a, b = present[ca], present[cb]
            left = corrected_fields(a, supplies, reference_volts)
            right = corrected_fields(b, supplies, reference_volts)
            print(f"\n  cycle count {cc}: {ca} -> {cb}")
            for key, name in CORRECTIONS:
                if left[key] is None or right[key] is None:
                    continue
                print(f"    {name:<14} {left[key]:10,.1f} -> "
                      f"{right[key]:10,.1f} nT   "
                      f"{right[key] - left[key]:+9,.1f} nT   "
                      f"{(right[key]/left[key] - 1) * 100:+6.2f}%")
            contrasts[(ca, cb)].append((cc, math.log(right["raw"] / left["raw"])))
            # Rather than only testing whether gain goes as 1/V, fit the power
            # it actually goes as. -1 is exactly ratiometric, 0 is no supply
            # dependence at all, and the rate gets the same treatment because
            # both are the same oscillator seen from different ends.
            va, vb = supplies.get(ca), supplies.get(cb)
            if va and vb and va != vb:
                dv = math.log(vb / va)
                exponents[(ca, cb)].append(
                    (cc, math.log(right["raw"] / left["raw"]) / dv,
                     math.log(b["fs"] / a["fs"]) / dv))

    for (ca, cb), points in exponents.items():
        va, vb = supplies[ca], supplies[cb]
        print(f"\n  how {ca} -> {cb} ({va:g} V -> {vb:g} V) scales with the rail:")
        for cc, field_power, rate_power in points:
            print(f"    cycle count {cc}: |B| ~ V^{field_power:+.3f}   "
                  f"rate ~ V^{rate_power:+.3f}")
        print("    V^-1 on |B| would be exactly ratiometric -- gain simply "
              "proportional to the\n    rail. V^0 would be no supply "
              "dependence. Read the residual of the `x rail\n    volts` row "
              "above against the reproducibility figure below before "
              "believing\n    either endpoint.")

    for (ca, cb), points in contrasts.items():
        if len(points) < 2:
            continue
        values = [v for _, v in points]
        print(f"\n  {ca} -> {cb} across {len(points)} cycle counts: "
              f"mean {np.mean(values) * 100:+.2f}%, "
              f"spread {(max(values) - min(values)) * 100:.2f}%")
        print("    A small spread means the condition acts the same at every "
              "cycle count, which\n    is what a scale factor does and what an "
              "environmental step does not.")

    # A condition measured twice bounds everything this experiment cannot
    # separate -- handling, gradient, drift -- and is the only honest error bar.
    print("\n  reproducibility within one condition (the error bar on all of "
          "the above):")
    any_repeat = False
    for condition in conditions:
        runs = [(s["cycle_count"], s["field"]) for l, _, s in records
                if condition_of(l) == condition]
        if len(runs) < 2:
            continue
        any_repeat = True
        fields = [f for _, f in runs]
        print(f"    {condition:<12} n={len(runs)}  |B| "
              + ", ".join(f"{f:,.0f}" for f in fields)
              + f"   spread {(max(fields)/min(fields) - 1) * 100:.2f}%")
    if any_repeat:
        print("    These repeats differ in cycle count as well, so the spread "
              "is an upper bound\n    on run-to-run reproducibility, not a "
              "clean measurement of it.")
    else:
        print("    none -- no condition was captured twice, so there is no "
              "error bar at all.")


def print_pairwise(records):
    if len(records) < 2:
        return
    print("\n=== pairwise: |B|, and how far the sensor moved ===")
    print("|B| survives a rotation, so a difference is only movement if the "
          "sensor also\ntranslated through a gradient. These size that doubt; "
          "they do not veto the result.")
    for (la, _, a), (lb, _, b) in itertools.combinations(records, 2):
        ratio = b["mean"] / a["mean"]
        spread = axis_ratio_spread(a, b, ratio)
        scale, residual, angle = gain_and_movement(a, b)
        print(f"\n{la[:24]} -> {lb[:24]}")
        print(f"   |B|              {a['field']:,.0f} -> {b['field']:,.0f} nT   "
              f"{b['field'] - a['field']:+,.1f} nT   "
              f"({(b['field']/a['field'] - 1) * 100:+.2f}%)")
        print(f"   fractional noise {a['total']['ppm']:.1f} -> "
              f"{b['total']['ppm']:.1f} ppm  "
              f"({(b['total']['ppm']/a['total']['ppm'] - 1) * 100:+.1f}%)")
        print(f"   best-fit scale   {scale:.5f}   gain residual "
              f"{residual * 100:5.2f}%   rotation {angle:.3f} deg")
        if spread is not None:
            print(f"   per-axis ratios  X {ratio[0]:.5f}  Y {ratio[1]:.5f}  "
                  f"Z {ratio[2]:.5f}   spread {spread * 100:.2f}%")
        else:
            print(f"   per-axis ratios  fewer than two axes carry "
                  f"{RATIO_AXIS_MIN_FRACTION:.0%} of |B|, so this "
                  "test abstains")
        moved = angle > ROTATION_OK_DEG or residual > RATIO_SPREAD_OK
        if moved:
            print("   -> the sensor moved between these two, so some of the "
                  "|B| difference may be\n      a different place in the field "
                  "rather than a different scale factor.")
        else:
            print("   -> the sensor held still, so the |B| difference is a "
                  "scale change.")


# --------------------------------------------------------------------------
# Figure
# --------------------------------------------------------------------------

def style(ax):
    ax.set_facecolor(ch.SURFACE)
    ax.grid(True, which="both", color=ch.GRID, linewidth=0.7)
    ax.set_axisbelow(True)
    for side in ("top", "right"):
        ax.spines[side].set_visible(False)
    for side in ("left", "bottom"):
        ax.spines[side].set_color(ch.GRID)
    ax.tick_params(colors=ch.TEXT_SECONDARY, labelsize=9, length=0)
    ax.xaxis.label.set_color(ch.TEXT_SECONDARY)
    ax.yaxis.label.set_color(ch.TEXT_SECONDARY)


def title(ax, text):
    ax.set_title(text, loc="left", color=ch.TEXT_PRIMARY, fontsize=12,
                 fontweight="bold", pad=8)


def binned_asd(freqs, asd, bins=140):
    """Median ASD in log-spaced frequency bins, for a trace that can be read.

    A raw periodogram at these lengths draws tens of thousands of points into a
    few hundred pixels, which renders as a solid band whose apparent thickness
    is chi-square scatter rather than anything about the sensor. Binning by the
    median keeps the floor exactly where the quoted numbers put it -- they are
    medians too -- while a line stays a line. Narrow lines survive because a
    log bin up here is many raw bins wide only at high frequency, where these
    captures have no lines.
    """
    edges = np.geomspace(freqs[0], freqs[-1], bins + 1)
    index = np.digitize(freqs, edges) - 1
    centres, values = [], []
    for b in range(bins):
        inside = index == b
        if inside.any():
            centres.append(np.sqrt(edges[b] * edges[b + 1]))
            values.append(np.median(asd[inside]))
    return np.array(centres), np.array(values)


def clip_to_band(ax, series, band, headroom=3.0, floor=0.35):
    """Set y-limits from what is inside the band, not from filter stopbands.

    An anti-alias filter falls away by six decades past its corner, and on a log
    axis that decides the scale for everything else. Only the band being
    compared should.
    """
    inside = [a[(f >= band[0]) & (f <= band[1])] for f, a in series]
    inside = [a for a in inside if len(a)]
    if not inside:
        return
    lo = min(float(a.min()) for a in inside)
    hi = max(float(a.max()) for a in inside)
    ax.set_ylim(floor * lo, headroom * hi)


def make_figure(records, band, supplies, path):
    conditions = conditions_in_order(records)
    variants = sorted({variant_of(l) for l, _, _ in records})
    color = {c: CONDITION_COLORS[i % len(CONDITION_COLORS)]
             for i, c in enumerate(conditions)}
    dash = {v: VARIANT_STYLES[i % len(VARIANT_STYLES)]
            for i, v in enumerate(variants)}
    marker = {v: VARIANT_MARKERS[i % len(VARIANT_MARKERS)]
              for i, v in enumerate(variants)}

    fig, axs = plt.subplots(2, 2, figsize=(13.5, 9), dpi=150)
    fig.patch.set_facecolor(ch.SURFACE)
    for ax in axs.flat:
        style(ax)

    a = axs[0, 0]
    a.axvspan(band[0], band[1], color=ch.GRID, alpha=0.55, zorder=0,
              linewidth=0)
    drawn = []
    for label, _, s in records:
        freqs, asd = binned_asd(*ch.welch_asd(dict(s["cap"].axes())["total"], s["fs"],
                                nperseg=ch.segment_length(s["fs"])))
        # Fractional, because the conditions do not share a gain. In absolute
        # nT/rtHz a condition reading 7% smaller plots 7% lower while being no
        # quieter, and the chart would say the opposite of the truth.
        asd = asd / s["field"] * 1e6
        drawn.append((freqs, asd))
        a.loglog(freqs, asd, color=color[condition_of(label)],
                 linestyle=dash[variant_of(label)], linewidth=1.4, alpha=0.9,
                 label=label)
    clip_to_band(a, drawn, band)
    ch.decade_ticks(a)
    title(a, "Spectral density of |B|, as a fraction of |B|")
    a.set_xlabel("frequency (Hz)"); a.set_ylabel("ppm of |B| per √Hz")
    a.annotate(f"shaded: {band[0]:g}–{band[1]:.0f} Hz, the compared band."
               "\nnormalised by each capture's own |B|, so a gain"
               "\ndifference is not read as a noise difference",
               xy=(0.02, 0.05), xycoords="axes fraction",
               color=ch.TEXT_SECONDARY, fontsize=9)
    a.legend(frameon=False, fontsize=9, labelcolor=ch.TEXT_SECONDARY,
             loc="upper right")

    a = axs[0, 1]
    plotted = _plot_decimation(a, records, band, color, dash)
    title(a, "Fast capture, filtered and decimated")
    a.set_xlabel("frequency (Hz)"); a.set_ylabel("ppm of |B| per √Hz")
    if plotted:
        ch.decade_ticks(a)
        a.legend(frameon=False, fontsize=9, labelcolor=ch.TEXT_SECONDARY,
                 loc="upper right")
    else:
        a.annotate("no pair of captures differs by an integer cycle-count "
                   "factor,\nso there is nothing to decimate onto",
                   xy=(0.5, 0.5), xycoords="axes fraction", ha="center",
                   color=ch.TEXT_SECONDARY, fontsize=10)

    a = axs[1, 0]
    for label, _, s in records:
        taus, devs = s["allan_curve"]
        a.loglog(taus, devs / s["field"] * 1e6,
                 color=color[condition_of(label)],
                 linestyle=dash[variant_of(label)], linewidth=1.6, label=label)
    ch.decade_ticks(a)
    title(a, "Allan deviation of |B|, as a fraction of |B|")
    a.set_xlabel("averaging time τ (s)"); a.set_ylabel("σ (ppm of |B|)")
    a.annotate("slope −½ = white noise; upturn = drift",
               xy=(0.02, 0.04), xycoords="axes fraction",
               color=ch.TEXT_SECONDARY, fontsize=9)
    a.legend(frameon=False, fontsize=9, labelcolor=ch.TEXT_SECONDARY,
             loc="upper right")

    _plot_corrections(axs[1, 1], records, supplies, color, marker)

    fig.suptitle("RM3100 capture comparison", color=ch.TEXT_PRIMARY,
                 fontsize=15, fontweight="bold", y=0.985)
    fig.text(0.5, 0.945,
             f"{len(records)} captures; colour is the condition, line style "
             f"the cycle count. Broadband figures over "
             f"{band[0]:g}–{band[1]:.1f} Hz, inside every capture's Nyquist.",
             color=ch.TEXT_SECONDARY, fontsize=10, ha="center")
    fig.tight_layout(rect=[0, 0, 1, 0.935])
    fig.savefig(path, facecolor=ch.SURFACE)
    return path


def _plot_decimation(ax, records, band, color, dash):
    """Decimated-fast against natively-slow, per condition. True if anything."""
    ax.axvspan(band[0], band[1], color=ch.GRID, alpha=0.55, zorder=0,
               linewidth=0)
    plotted = False
    drawn = []
    for condition, (_, fast), (_, slow), k in decimation_pairs(
            [(label, s) for label, _, s in records]):
        try:
            v = ch.decimate(dict(fast["cap"].axes())["total"], k, "fir")
        except ValueError:
            continue
        hue = color[condition]
        freqs, asd = binned_asd(*ch.welch_asd(v, fast["fs"] / k,
                                             nperseg=ch.segment_length(fast["fs"] / k)))
        # Fractional again: the two cycle counts do not share a gain either.
        asd = asd / fast["field"] * 1e6
        drawn.append((freqs, asd))
        ax.loglog(freqs, asd, color=hue, linestyle="--", linewidth=1.5,
                  label=f"{condition} cc{fast['cycle_count']} ÷{k}")
        freqs, asd = binned_asd(*ch.welch_asd(
            dict(slow["cap"].axes())["total"], slow["fs"],
            nperseg=ch.segment_length(slow["fs"])))
        asd = asd / slow["field"] * 1e6
        drawn.append((freqs, asd))
        ax.loglog(freqs, asd, color=hue, linestyle="-", linewidth=1.5,
                  alpha=0.7,
                  label=f"{condition} cc{slow['cycle_count']} native")
        plotted = True
    if plotted:
        clip_to_band(ax, drawn, band)
        ax.annotate("dashed: fast capture decimated. solid: natively slow.",
                    xy=(0.02, 0.05), xycoords="axes fraction",
                    color=ch.TEXT_SECONDARY, fontsize=9)
    return plotted


def _plot_corrections(ax, records, supplies, color, marker):
    """|B| under each correction, so the spread each one leaves is visible."""
    reference_volts = min(supplies.values()) if supplies else None
    for label, _, s in records:
        s["label"] = label
    corrected = [(label, corrected_fields(s, supplies, reference_volts))
                 for label, _, s in records]
    rows = [(key, name) for key, name in CORRECTIONS
            if all(c[key] is not None for _, c in corrected)]

    for row, (key, name) in enumerate(rows):
        values = []
        for label, correction in corrected:
            values.append(correction[key])
            ax.plot(correction[key], -row, marker=marker[variant_of(label)],
                    color=color[condition_of(label)], markersize=9,
                    markeredgecolor=ch.SURFACE, markeredgewidth=2,
                    linestyle="none", zorder=3)
        ax.plot([min(values), max(values)], [-row, -row],
                color=ch.GRID, linewidth=6, solid_capstyle="round", zorder=1)
        ax.annotate(f"spread {(max(values)/min(values) - 1) * 100:.1f}%",
                    xy=(max(values), -row), xytext=(10, 0),
                    textcoords="offset points", va="center",
                    color=ch.TEXT_SECONDARY, fontsize=9)
    ax.set_yticks([-r for r in range(len(rows))], [n for _, n in rows])
    ax.set_ylim(-len(rows) + 0.5, 0.85)
    ax.margins(x=0.24)
    title(ax, "Total field under each candidate correction")
    ax.set_xlabel("|B| (nT)")
    ax.annotate("a correction that is right collapses the spread",
                xy=(0.02, 0.06), xycoords="axes fraction",
                color=ch.TEXT_SECONDARY, fontsize=9)
    # Identity never rests on colour alone: the marker repeats the cycle count.
    handles = [plt.Line2D([], [], color=color[c], marker="o", markersize=9,
                          linestyle="none", label=c)
               for c in conditions_in_order(records)]
    handles += [plt.Line2D([], [], color=ch.TEXT_SECONDARY,
                           marker=marker[v], markersize=8, linestyle="none",
                           label=v or "(one variant)")
                for v in sorted({variant_of(l) for l, _, _ in records})]
    ax.legend(handles=handles, frameon=False, fontsize=9, ncol=2,
              labelcolor=ch.TEXT_SECONDARY, loc="upper left")


# --------------------------------------------------------------------------

def main():
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("csv", nargs="+", metavar="[LABEL=]CSV")
    ap.add_argument("--group", choices=["note", "file"], default="file",
                    help="where unlabelled captures take their name from "
                         "(default: %(default)s)")
    ap.add_argument("--trim", type=float, default=0.0, metavar="SECONDS",
                    help="drop this many seconds from BOTH ends of every "
                         "capture (default: %(default)s)")
    ap.add_argument("--band", type=parse_band, default=None, metavar="LO,HI",
                    help=f"broadband comparison band in Hz; the default is "
                         f"{ch.BAND_LO_HZ:g} Hz to "
                         f"{ch.BAND_NYQUIST_FRACTION * 100:g}%% of the Nyquist "
                         f"of the slowest path compared, decimation included")
    ap.add_argument("--supply", type=parse_supply, action="append",
                    default=[], metavar="CONDITION=VOLTS",
                    help="rail voltage for a condition, enabling the "
                         "ratiometric correction; repeatable")
    ap.add_argument("-o", "--output", default=None, metavar="PNG",
                    help="write a comparison figure here")
    args = ap.parse_args()
    supplies = dict(args.supply)

    loaded = []
    for argument in args.csv:
        try:
            explicit, path = split_label(argument)
            cap = capture.load(path)
            cap, note = ch.trimmed(cap, args.trim)
        except (OSError, ValueError, capture.CaptureError) as exc:
            print(f"skipping {argument}: {exc}", file=sys.stderr)
            continue
        loaded.append((label_for(cap, path, args.group, explicit), path, cap,
                       note))
    if not loaded:
        sys.exit("nothing to compare")

    unknown = supplies.keys() - {condition_of(l) for l, _, _, _ in loaded}
    if unknown:
        print(f"warning: --supply names no capture: {', '.join(sorted(unknown))}",
              file=sys.stderr)

    # One band for every capture, so no run is scored over more bandwidth than
    # another. Without this a 299 Hz capture is judged over 3-135 Hz and a
    # 73 Hz one over 3-33, and the difference reads as a noise difference.
    slowest = min(comparable_rates([(l, cap) for l, _, cap, _ in loaded]))
    band = args.band or ch.band_for(slowest)
    if band[1] > ch.BAND_NYQUIST_FRACTION * slowest / 2:
        print(f"warning: --band reaches {band[1]:g} Hz, past "
              f"{ch.BAND_NYQUIST_FRACTION * slowest / 2:.2f} Hz where the "
              f"slowest path compared -- {slowest:.2f} Hz, decimation "
              f"included -- rolls off", file=sys.stderr)

    records = [(label, path, summarise(cap, band)) for label, path, cap, _ in loaded]

    if args.trim:
        for label, _, _, note in loaded:
            if note:
                print(f"{label[:24]:<24} {note}")
        print()
    print_overview(records, band, supplies)
    print_rate_model(records)
    print_locked_lines(records)
    print_decimation(records, band)
    print_contrasts(records, supplies)
    print_pairwise(records)

    if args.output:
        print(f"\n-> {make_figure(records, band, supplies, args.output)}")
    return 0


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