2026-08-19 23:00:47 -04:00
|
|
|
#!/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
|
|
|
|
|
|
2026-09-02 13:20:10 -04:00
|
|
|
Small multiples, since the three axes sit at very different DC offsets and a
|
|
|
|
|
shared scale would flatten the variation that matters. Each panel has its own
|
|
|
|
|
y-scale, with a mean/sd annotation for context.
|
2026-08-19 23:00:47 -04:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
|
import sys
|
|
|
|
|
|
|
|
|
|
import matplotlib
|
|
|
|
|
matplotlib.use("Agg")
|
|
|
|
|
import matplotlib.pyplot as plt
|
|
|
|
|
import numpy as np
|
|
|
|
|
|
2026-08-24 18:09:19 -04:00
|
|
|
import calibrate
|
2026-08-23 18:16:43 -04:00
|
|
|
import capture
|
2026-09-02 13:20:10 -04:00
|
|
|
import characterize
|
|
|
|
|
from characterize import GRID, SURFACE, TEXT_PRIMARY, TEXT_SECONDARY
|
2026-08-23 18:16:43 -04:00
|
|
|
|
2026-09-02 13:20:10 -04:00
|
|
|
# The three peer axes take characterize's categorical slots, so a capture keeps
|
|
|
|
|
# the same colours in every figure. The norm is derived from them, so it takes
|
|
|
|
|
# neutral ink.
|
|
|
|
|
SERIES = [(key, f"{name} axis", color)
|
|
|
|
|
for key, name, color in characterize.AXES]
|
|
|
|
|
SERIES.append(("total", "Norm |B| = sqrt(X^2 + Y^2 + Z^2)", TEXT_PRIMARY))
|
2026-08-19 23:00:47 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-09-02 13:20:10 -04:00
|
|
|
instead, so the ends carry no artifact.
|
2026-08-19 23:00:47 -04:00
|
|
|
"""
|
|
|
|
|
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()
|
|
|
|
|
|
2026-08-23 18:16:43 -04:00
|
|
|
try:
|
2026-09-02 13:20:10 -04:00
|
|
|
# Either format: a raw capture, or one already converted to nT.
|
2026-08-24 18:09:19 -04:00
|
|
|
cap = calibrate.load_any(args.csv)
|
|
|
|
|
except (OSError, capture.CaptureError,
|
|
|
|
|
calibrate.CalibrationError) as exc:
|
2026-08-23 18:16:43 -04:00
|
|
|
sys.exit(str(exc))
|
2026-08-19 23:00:47 -04:00
|
|
|
|
2026-09-02 13:20:10 -04:00
|
|
|
t = cap.elapsed # drift-corrected; the nominal grid is ~6% off
|
2026-08-23 18:16:43 -04:00
|
|
|
series = dict(cap.axes())
|
|
|
|
|
duration = cap.duration
|
|
|
|
|
rate = cap.true_rate_hz
|
2026-08-19 23:00:47 -04:00
|
|
|
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)
|
|
|
|
|
|
2026-09-02 13:20:10 -04:00
|
|
|
# Thin and translucent, there being far more samples than pixels.
|
2026-08-19 23:00:47 -04:00
|
|
|
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")
|
|
|
|
|
|
2026-08-23 18:16:43 -04:00
|
|
|
ax.set_ylabel("nT", color=TEXT_SECONDARY, fontsize=10)
|
2026-09-02 13:20:10 -04:00
|
|
|
# Direct label: one series per panel.
|
2026-08-19 23:00:47 -04:00
|
|
|
ax.set_title(label, color=TEXT_PRIMARY, fontsize=12, loc="left",
|
|
|
|
|
pad=8, fontweight="bold")
|
2026-08-23 18:16:43 -04:00
|
|
|
ax.annotate(f"mean {v.mean():,.0f} nT sd {v.std():.0f} nT "
|
|
|
|
|
f"span {v.max() - v.min():,.0f} nT",
|
2026-08-19 23:00:47 -04:00
|
|
|
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)
|
2026-09-02 13:20:10 -04:00
|
|
|
# A calibrated file carries a gain factor, which is what is worth stating
|
|
|
|
|
# about it; a raw one carries a nominal rate to compare against.
|
2026-08-24 18:09:19 -04:00
|
|
|
provenance = (cap.describe() if hasattr(cap, "describe") else
|
|
|
|
|
f"nominal {cap.nominal_rate_hz:g} Hz, "
|
|
|
|
|
f"{cap.rate_error * 100:+.1f}%")
|
2026-08-19 23:00:47 -04:00
|
|
|
fig.text(0.5, 0.955,
|
2026-08-23 18:16:43 -04:00
|
|
|
f"{len(t):,} samples over {duration:.1f} s at {rate:.2f} Hz "
|
2026-08-24 18:09:19 -04:00
|
|
|
f"({provenance}), cycle count {cap.cycle_count} "
|
|
|
|
|
f"(1 LSB = {cap.lsb_nt:.1f} nT)"
|
2026-08-19 23:00:47 -04:00
|
|
|
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]
|
2026-08-23 18:16:43 -04:00
|
|
|
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")
|
2026-08-19 23:00:47 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|