#!/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     # group by header note

Built around one hard lesson: a naive comparison of absolute noise is wrong when
the measured scale differs between runs. A gain change carries the noise with it,
so a run that reads 6% larger also reads ~6% noisier while being physically
identical. Everything here is therefore reported **fractionally**, in ppm of the
field magnitude.

The second trap is attributing a scale change to the variable under test when
the sensor simply moved. Two diagnostics separate them:

  per-axis ratio spread   a pure gain change scales X, Y and Z identically,
                          so the spread is ~0. Anything larger means the sensor
                          moved.
  rotation angle          the angle between mean field directions. ~0 deg means
                          the sensor held still.

Both must be small before a magnitude difference can be blamed on gain. Note
that |B| is preserved under rotation but *not* under translation through a field
gradient, so a moved sensor can change magnitude on its own.
"""

import argparse
import itertools
import sys
from collections import defaultdict

import numpy as np

import capture
import characterize as ch

# 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


def summarise(cap):
    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}
    for key, _, _ in ch.SERIES:
        v = d[key]
        freqs, asd = ch.welch_asd(v, fs, nperseg=min(4096, len(v) // 4 * 2))
        band = freqs > min(3.0, fs / 8)
        out[key] = {
            "sd": v.std(),
            "ppm": v.std() / field * 1e6,
            "asd": float(np.median(asd[band])) if band.any() else float("nan"),
            "peak": float(asd[band].max()) if band.any() else float("nan"),
            "peak_hz": float(freqs[band][np.argmax(asd[band])]) if band.any() else float("nan"),
        }
    taus, devs = ch.allan_deviation(d["total"], fs)
    i = int(np.argmin(devs))
    out["allan"] = (float(devs[i]), float(taus[i]))
    return out


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


def main():
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("csv", nargs="+")
    ap.add_argument("--group", choices=["note", "file"], default="file",
                    help="group captures by header note, averaging repeats "
                         "(default: %(default)s)")
    args = ap.parse_args()

    recs = []
    for path in args.csv:
        try:
            cap = capture.load(path)
        except (OSError, capture.CaptureError) as exc:
            print(f"skipping {path}: {exc}", file=sys.stderr)
            continue
        recs.append((label_for(cap, path, args.group), path, summarise(cap)))
    if len(recs) < 1:
        sys.exit("nothing to compare")

    print(f"{'capture':<26} {'|B| nT':>10} {'sd nT':>8} {'sd ppm':>9} "
          f"{'ASD':>7} {'peak':>8} {'@Hz':>7} {'Allan':>7}")
    for label, path, s in recs:
        t = s["total"]
        print(f"{label[:26]:<26} {s['field']:10,.0f} {t['sd']:8.1f} {t['ppm']:9.1f} "
              f"{t['asd']:7.2f} {t['peak']:8.1f} {t['peak_hz']:7.3f} "
              f"{s['allan'][0]:7.2f}")
    print("            ASD/peak in nT/rtHz; Allan = best sigma by averaging")

    # Averaged per group, which is the number to compare when runs are repeated.
    if args.group == "note":
        groups = defaultdict(list)
        for label, _, s in recs:
            groups[label].append(s)
        if len(groups) > 1:
            print("\n=== group means (fractional -- the comparable figure) ===")
            for label, ss in groups.items():
                ppm = [s["total"]["ppm"] for s in ss]
                print(f"{label[:26]:<26} n={len(ss)}  "
                      f"|B| {np.mean([s['field'] for s in ss]):9,.0f} nT   "
                      f"sd {np.mean(ppm):7.1f} ppm"
                      + (f" +/- {np.std(ppm):.1f}" if len(ss) > 1 else ""))

    if len(recs) < 2:
        return 0

    print("\n=== pairwise: did the sensor hold still? ===")
    print("A magnitude difference only means gain if BOTH checks pass.")
    for (la, _, a), (lb, _, b) in itertools.combinations(recs, 2):
        ratio = b["mean"] / a["mean"]
        spread = float(np.ptp(ratio) / np.abs(np.mean(ratio)))
        ua, ub = a["mean"] / a["field"], b["mean"] / b["field"]
        angle = float(np.degrees(np.arccos(np.clip(np.dot(ua, ub), -1, 1))))
        moved = spread > RATIO_SPREAD_OK or angle > ROTATION_OK_DEG
        print(f"\n{la[:24]} -> {lb[:24]}")
        print(f"   |B| ratio        {b['field']/a['field']:.5f}  "
              f"({(b['field']/a['field'] - 1) * 100:+.2f}%)")
        print(f"   fractional noise {a['total']['ppm']:.1f} -> {b['total']['ppm']:.1f} ppm  "
              f"({(b['total']['ppm']/a['total']['ppm'] - 1) * 100:+.1f}%)")
        print(f"   per-axis ratios  X {ratio[0]:.5f}  Y {ratio[1]:.5f}  Z {ratio[2]:.5f}"
              f"   spread {spread * 100:.2f}%")
        print(f"   rotation         {angle:.3f} deg")
        if moved:
            print("   -> SENSOR MOVED. The magnitude difference cannot be "
                  "attributed to gain;\n      re-run with the sensor clamped.")
        else:
            print(f"   -> held still. The {(b['field']/a['field'] - 1) * 100:+.2f}% "
                  "magnitude difference is a real gain change.")
    return 0


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