#!/usr/bin/env python3
"""Plot an RM3100 capture: X, Y, Z and the norm of the three.

    ./.venv/bin/python plot.py capture_60s.csv
    ./.venv/bin/python plot.py capture_60s.csv -o out.png

Small multiples rather than one shared axis: the three axes sit at very
different DC offsets, so a single scale would flatten the variation that
matters. Each panel therefore has its own y-scale -- read the panels
independently, and note the per-panel mean/sd annotation for context.
"""

import argparse
import sys

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

import capture

# Light-mode design tokens.
SURFACE = "#fcfcfb"
TEXT_PRIMARY = "#0b0b0b"
TEXT_SECONDARY = "#52514e"
GRID = "#e3e2df"

# Categorical slots 1-3 for the three peer axes. Validated all-pairs in light
# mode (worst CVD dE 9.2, normal-vision 24.0). The norm is a derived quantity,
# not a fourth peer, so it takes neutral ink instead of a competing hue -- which
# also keeps the categorical set at the three slots that validate for small
# multiples.
SERIES = [
    ("x", "X axis", "#2a78d6"),
    ("y", "Y axis", "#eb6834"),
    ("z", "Z axis", "#1baf7a"),
    ("total", "Norm  |B| = sqrt(X^2 + Y^2 + Z^2)", TEXT_PRIMARY),
]


def rolling_mean(v, window):
    """Centred moving average that stays smooth all the way to both ends.

    A plain convolution tapers toward zero at the edges. Dividing by the number
    of samples that actually contributed gives a true partial-window mean
    instead, so the ends carry no artefact.
    """
    if window < 2:
        return v
    kernel = np.ones(window)
    total = np.convolve(v, kernel, mode="same")
    count = np.convolve(np.ones_like(v), kernel, mode="same")
    return total / count


def main():
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("csv", help="capture written by logger.py")
    ap.add_argument("-o", "--output", default=None, help="PNG path (default: <csv>.png)")
    ap.add_argument("--smooth", type=float, default=1.0,
                    help="moving-average window in seconds, 0 to disable "
                         "(default: %(default)s)")
    args = ap.parse_args()

    try:
        cap = capture.load(args.csv)
    except (OSError, capture.CaptureError) as exc:
        sys.exit(str(exc))

    # Drift-corrected time: the nominal grid would be ~6% off real seconds.
    t = cap.elapsed
    series = dict(cap.axes())
    duration = cap.duration
    rate = cap.true_rate_hz
    window = max(1, int(round(args.smooth * rate))) if args.smooth > 0 else 0

    fig, axes = plt.subplots(4, 1, figsize=(12, 9.5), sharex=True, dpi=150)
    fig.patch.set_facecolor(SURFACE)

    for ax, (key, label, color) in zip(axes, SERIES):
        v = series[key]
        ax.set_facecolor(SURFACE)

        # Raw trace kept thin and translucent: at ~376 Hz there are far more
        # samples than pixels, so a full-weight line would read as a solid band.
        ax.plot(t, v, color=color, linewidth=0.4, alpha=0.30,
                solid_capstyle="round", rasterized=True)
        if window > 1:
            ax.plot(t, rolling_mean(v, window), color=color, linewidth=1.6,
                    solid_capstyle="round")

        ax.set_ylabel("nT", color=TEXT_SECONDARY, fontsize=10)
        # Direct label instead of a legend: one series per panel, so the title
        # names it. This is also the relief the palette's contrast WARN requires.
        ax.set_title(label, color=TEXT_PRIMARY, fontsize=12, loc="left",
                     pad=8, fontweight="bold")
        ax.annotate(f"mean {v.mean():,.0f} nT   sd {v.std():.0f} nT   "
                    f"span {v.max() - v.min():,.0f} nT",
                    xy=(1.0, 1.0), xycoords="axes fraction",
                    xytext=(0, 8), textcoords="offset points",
                    ha="right", va="bottom",
                    color=TEXT_SECONDARY, fontsize=9)

        ax.grid(True, axis="y", color=GRID, linewidth=0.8)
        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(GRID)
            ax.spines[side].set_linewidth(0.8)
        ax.tick_params(colors=TEXT_SECONDARY, labelsize=9, length=0)

    axes[-1].set_xlabel("elapsed (s)", color=TEXT_SECONDARY, fontsize=10)
    axes[-1].set_xlim(t[0], t[-1])

    smooth_note = (f"; {args.smooth:g} s moving average over translucent raw trace"
                   if window > 1 else "")
    fig.suptitle("RM3100 magnetometer capture", color=TEXT_PRIMARY,
                 fontsize=15, fontweight="bold", x=0.5, y=0.985)
    fig.text(0.5, 0.955,
             f"{len(t):,} samples over {duration:.1f} s at {rate:.2f} Hz "
             f"(nominal {cap.nominal_rate_hz:g} Hz, {cap.rate_error * 100:+.1f}%), "
             f"cycle count {cap.cycle_count} (1 LSB = {cap.lsb_nt:.1f} nT)"
             f"{smooth_note}. Panels have independent y-scales.",
             color=TEXT_SECONDARY, fontsize=10, ha="center")

    fig.tight_layout(rect=[0, 0, 1, 0.945])
    out = args.output or args.csv.rsplit(".", 1)[0] + ".png"
    fig.savefig(out, facecolor=SURFACE)
    print(f"{len(t):,} samples, {duration:.2f} s, {rate:.1f} Hz -> {out}")
    for key, label, _ in SERIES:
        v = series[key]
        print(f"  {label.split()[0]:5s} mean {v.mean():+12,.1f} nT   "
              f"sd {v.std():8.1f} nT   span {v.max()-v.min():10,.1f} nT")


if __name__ == "__main__":
    main()
