145 lines
5.9 KiB
Python
145 lines
5.9 KiB
Python
|
|
#!/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())
|