rm3100/compare.py

145 lines
5.9 KiB
Python
Raw Normal View History

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