#!/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 # label from header ./.venv/bin/python compare.py LDO/cc100=a.csv 3V3/cc100=b.csv ... A label may be given as `LABEL=path`, which is the only way to identify a run whose capture was recorded without `--note`. Everything before the first `/` in a label is the **condition**, the thing under test, and captures sharing one are treated as the same configuration. `--supply LDO=3.0` then attaches a rail voltage to every capture in that condition. Three rules are built into how it reports. **Compare |B|, not the axes.** Swapping a supply means touching the rig, and the sensor comes back somewhere close to where it was. |B| survives a rotation, so a per-axis difference measures the handling. The axes appear here as movement diagnostics. **Compare fractionally, or over a stated band.** A gain change carries the noise with it, so a run reading 6% larger also reads ~6% noisier while being physically identical. And a broadband figure is comparable over a band both captures cover, so one band is chosen for the whole invocation and printed. **A magnitude difference can be movement.** |B| is preserved under rotation, and translation through a field gradient changes it, so a sensor that moved can shift magnitude on its own. Two diagnostics bound that: rotation angle the angle between mean field directions. gain residual what is left after the best single scale factor, as a fraction of |B|. Zero means one number relates the two captures, which is what a gain change looks like. Both size the doubt; the comparison runs either way. The reproducibility floor from repeats of one condition is the honest error bar, and this prints it when there are repeats to use. """ import argparse import itertools import math import os import sys from collections import defaultdict import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import numpy as np import calibrate import capture import characterize as ch import rm3100 # 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 # An axis whose mean field is near zero has a ratio dominated by noise, so the # spread test drops it. The rotation check works on the whole vector's direction, # where a small component contributes little, and needs no such guard. RATIO_AXIS_MIN_FRACTION = 0.05 # of |B| # Two captures whose rates differ by less than this are treated as the same # rate, so a line holding a fixed fraction of fs across them proves nothing. RATE_DISTINCT = 0.01 # 1% # Condition and variant are separated by this in a label: `LDO/cc100`. CONDITION_SEP = "/" # Hue carries the condition, the thing under test, and the cycle count goes to # line style below, so a chart keeps its colours when a run is added. Validated # all-pairs in light mode against the #fcfcfb surface. CONDITION_COLORS = ["#2a78d6", "#eb6834", "#1baf7a"] # Cycle count is the secondary encoding, carried by line style and marker, so # identity never rests on colour alone. VARIANT_STYLES = ["-", "--", ":", "-."] VARIANT_MARKERS = ["o", "s", "^", "D"] # -------------------------------------------------------------------------- # Arguments # -------------------------------------------------------------------------- def split_label(argument): """Split `LABEL=path` into (label, path); (None, path) when unlabelled. A path may itself contain '=', so a bare existing path wins over the labelled reading. When neither reading names a file that exists, the labelled one is returned, which puts the likelier path in the error message. """ label, separator, path = argument.partition("=") if not separator: return None, argument if not label: raise ValueError(f"{argument!r} has an empty label before '='") if not os.path.exists(path) and os.path.exists(argument): return None, argument return label, path def parse_supply(argument): """Parse `CONDITION=VOLTS` for --supply.""" condition, separator, volts = argument.partition("=") if not separator or not condition: raise argparse.ArgumentTypeError( f"--supply {argument!r} must be CONDITION=VOLTS, e.g. LDO=3.0") try: value = float(volts) except ValueError: raise argparse.ArgumentTypeError( f"--supply {argument!r}: {volts!r} is not a number") if not 0 < value < 100: raise argparse.ArgumentTypeError( f"--supply {argument!r}: {value} V is not a plausible rail") return condition, value def parse_band(text): """Parse `LO,HI` for --band.""" parts = text.split(",") if len(parts) != 2: raise argparse.ArgumentTypeError(f"--band {text!r} must be LO,HI in Hz") try: lo, hi = (float(p) for p in parts) except ValueError: raise argparse.ArgumentTypeError(f"--band {text!r} must be two numbers") if not 0 < lo < hi: raise argparse.ArgumentTypeError( f"--band {text!r} needs 0 < LO < HI") return lo, hi def label_for(cap, path, group_by, explicit): if explicit: return explicit if group_by == "note": return cap.meta.get("note", "(no note)") return os.path.basename(path) def condition_of(label): """The part of a label before the first '/': the thing under test.""" return label.split(CONDITION_SEP, 1)[0] def variant_of(label): """The part after the first '/', or '' when the label carries none.""" _, separator, variant = label.partition(CONDITION_SEP) return variant if separator else "" def conditions_in_order(records): """Distinct conditions, in the order they were given on the command line. Command-line order is the one thing the caller controls, so contrasts read in the direction they asked for and a colour stays put across runs. """ seen = [] for label, _, _ in records: condition = condition_of(label) if condition not in seen: seen.append(condition) return seen # -------------------------------------------------------------------------- # Per-capture summary # -------------------------------------------------------------------------- def summarise(cap, band): 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, "cycle_count": cap.cycle_count} for key, _, _ in ch.SERIES: v = d[key] median_asd, band_rms, peak, peak_hz = ch.band_stats(v, fs, band) # The absolute figures are nanotesla, and nanotesla are what a gain # change moves, so every cross-condition comparison uses the columns # divided by that condition's own |B|. out[key] = { "sd": v.std(), "white": ch.white_sd(v), "ppm": v.std() / field * 1e6, "asd": median_asd, "asd_ppm": median_asd / field * 1e6, "white_ppm": ch.white_sd(v) / field * 1e6, "band_rms": band_rms, "band_rms_ppm": band_rms / field * 1e6, "peak": peak, "peak_hz": peak_hz, } taus, devs = ch.allan_deviation(d["total"], fs) i = int(np.argmin(devs)) out["allan"] = (float(devs[i]), float(taus[i])) out["allan_curve"] = (taus, devs) out["lines"] = ch.sample_locked_lines(d["total"]) return out # -------------------------------------------------------------------------- # Cross-capture analysis # -------------------------------------------------------------------------- def axis_ratio_spread(a, b, ratio): """Spread of the per-axis scale ratios, or None if it cannot be measured. A pure gain change scales X, Y and Z identically, so the spread is the movement test. Dividing two near-zero means amplifies their noise without bound, so axes below RATIO_AXIS_MIN_FRACTION of |B| in *either* capture are dropped, and with fewer than two left the test abstains. """ usable = [i for i in range(3) if abs(a["mean"][i]) >= RATIO_AXIS_MIN_FRACTION * a["field"] and abs(b["mean"][i]) >= RATIO_AXIS_MIN_FRACTION * b["field"]] if len(usable) < 2: return None kept = ratio[usable] return float(np.ptp(kept) / np.abs(np.mean(kept))) def gain_and_movement(a, b): """(scale, residual, rotation deg) relating two mean field vectors. `scale` is the single factor that best maps a onto b, and `residual` is what that factor leaves over, as a fraction of |b|. A pure gain change leaves a residual of zero at any angle; a rotation leaves a large one. It stays finite when an axis passes through zero, which is when a re-oriented sensor most needs the diagnostic. """ ma, mb = a["mean"], b["mean"] scale = float(ma @ mb / (ma @ ma)) residual = float(np.linalg.norm(mb - scale * ma) / np.linalg.norm(mb)) ua, ub = ma / a["field"], mb / b["field"] angle = float(np.degrees(np.arccos(np.clip(ua @ ub, -1, 1)))) return scale, residual, angle def fit_rate_model(caps): """Solve rm3100's timing model for a set of captures: (counts/s, overhead). The model is `period = AXES * (cycle_count / C + h)`, so period/AXES is linear in cycle count with slope 1/C and intercept h. Two cycle counts determine both exactly; more are a least-squares fit. Fitting it per condition separates an oscillator that changed from a model that was wrong to begin with: a supply that only scales the clock moves C and h together by one factor, leaving the *shape* of the model alone. """ try: # The uncertainty on each period is how far the oscillator drifted # during that capture; the worst of them bounds the fit. drift = max(ch.rate_stability(c) for c in caps) return calibrate.fit_timing([c.cycle_count for c in caps], [c.dt_true for c in caps], period_sd=drift or 1e-9) except calibrate.CalibrationError as exc: # Translated because callers here already handle ValueError. The fit # itself stays in calibrate.py, shared with sweep.py. raise ValueError(str(exc)) from exc def decimation_paths(fast, slow, k, band): """Noise of a fast capture decimated by k, against a natively slow one. Answers whether sampling fast and filtering down is equivalent to having sampled slowly, which is the basis for recommending a low cycle count. Both decimations are shown: the boxcar is what the sensor's own integration does, poor stopband and all, and the FIR rejects what the boxcar folds in. Compare the ASD column, which is quoted over a common band. Each path has its own effective noise bandwidth, which the per-sample sd carries with it. """ v_fast = fast["axes"]["total"] v_slow = slow["axes"]["total"] fs_fast, fs_slow = fast["fs"], slow["fs"] rows = [("raw fast", v_fast, fs_fast, fast["field"])] for method in ("boxcar", "fir"): rows.append((f"{method} /{k}", ch.decimate(v_fast, k, method), fs_fast / k, fast["field"])) rows.append(("native slow", v_slow, fs_slow, slow["field"])) out = [] for name, v, fs, field in rows: median_asd, band_rms, _, _ = ch.band_stats(v, fs, band) # The two cycle counts have their own gains, so the fractional column # is the comparable one here too. out.append({"name": name, "fs": fs, "asd": median_asd, "asd_ppm": median_asd / field * 1e6, "band_rms": band_rms, "white": ch.white_sd(v), "lines": ch.sample_locked_lines(v)}) return out def decimation_pairs(entries): """[(condition, fast, slow, k)] for every pair a decimation can bridge. `entries` are (label, summary-or-capture-like) with .cycle_count and a rate. A pair qualifies when one cycle count is an integer multiple of the other, which is when averaging k samples of the fast run buys the same integration time as one sample of the slow one. """ by_condition = defaultdict(list) for label, item in entries: by_condition[condition_of(label)].append((label, item)) pairs = [] for condition, group in by_condition.items(): for (la, a), (lb, b) in itertools.combinations(group, 2): fast, slow, lf, ls = ((a, b, la, lb) if a["fs"] > b["fs"] else (b, a, lb, la)) if not fast["cycle_count"] or slow["cycle_count"] % fast["cycle_count"]: continue k = slow["cycle_count"] // fast["cycle_count"] if k >= 2: pairs.append((condition, (lf, fast), (ls, slow), k)) return pairs def comparable_rates(loaded): """Every rate that will be scored, decimated paths included. The band has to fit inside the narrowest of these. Decimating by k lands at fs/k, and the per-axis overhead puts that a little *below* the natively-slow rate; two percent of bandwidth is enough to score the decimated path on its own filter rolloff. Runs before the summaries exist, so it takes captures. """ entries = [(label, {"fs": cap.true_rate_hz, "cycle_count": cap.cycle_count}) for label, cap in loaded] rates = [item["fs"] for _, item in entries] rates += [fast["fs"] / k for _, (_, fast), _, k in decimation_pairs(entries)] return rates def locked_line_verdict(records): """Which rational-fraction lines hold their fraction across rates. A field line sits at a fixed frequency, so it lands on a different fraction of fs when the rate changes. One that keeps the same fraction is locked to the sampling, and so is an artifact of measuring. Deciding this needs two captures at genuinely different rates, which is why it lives here. """ seen = defaultdict(list) for label, _, s in records: for line in s["lines"]: seen[(line.numerator, line.period)].append((label, s["fs"], line)) verdicts = [] for fraction, hits in sorted(seen.items(), key=lambda kv: -max(h[2].sigma for h in kv[1])): rates = [fs for _, fs, _ in hits] distinct = (len(rates) > 1 and (max(rates) / min(rates) - 1) > RATE_DISTINCT) verdicts.append((fraction, hits, distinct)) return verdicts # -------------------------------------------------------------------------- # Output sections # -------------------------------------------------------------------------- def print_overview(records, band, supplies): print(f"{'capture':<20} {'cc':>5} {'fs Hz':>9} {'drift':>7} {'|B| nT':>10} " f"{'sd':>7} {'white':>7} {'ASD':>6} {'Allan':>6} | {'white':>7} " f"{'ASD':>7}") for label, _, s in records: t = s["total"] print(f"{label[:20]:<20} {s['cycle_count']:5d} {s['fs']:9.3f} " f"{ch.rate_stability(s['cap']) * 1e6:6.0f}p " f"{s['field']:10,.0f} {t['sd']:7.1f} {t['white']:7.2f} " f"{t['asd']:6.2f} {s['allan'][0]:6.2f} | " f"{t['white_ppm']:7.1f} {t['asd_ppm']:7.1f}") print(f" Left of the bar, absolute: sd/white/Allan in nT, ASD in nT/rtHz " f"over {band[0]:g}-{band[1]:.1f} Hz.") print(" Right of the bar, the same two divided by that capture's own |B|: " "ppm and ppm/rtHz.") print(" Compare conditions on the right, where each capture is divided by " "its own |B|, so a\n gain difference stays out of the noise figure. " "Use the left against Table 3-1,\n which is quoted in nT at one " "supply.") print(" white = sd(diff)/sqrt(2), which rejects drift. " "Allan = best sigma by averaging.") print(" drift is how far the oscillator moved across the run, in ppm: the " "error bar on that\n capture's rate, and on anything derived from it. " "A tenth of a percent is ordinary\n for an LR relaxation oscillator. " "It is a timing figure.") if supplies: rails = " ".join(f"{c} {v:g} V" for c, v in sorted(supplies.items())) print(f" rails: {rails}") def print_rate_model(records): by_condition = defaultdict(list) for label, _, s in records: by_condition[condition_of(label)].append(s["cap"]) fits = {} for condition in conditions_in_order(records): try: fits[condition] = fit_rate_model(by_condition[condition]) except ValueError: continue if not fits: return print("\n=== timing model: period = 3 x (cycle_count / C + overhead) ===") for condition, fit in fits.items(): print(f" {condition:<12} C {fit.counts_per_second:10,.0f} " f"+/- {fit.counts_per_second_sd:5,.0f} counts/s overhead " f"{fit.axis_overhead_s * 1e6:6.2f} +/- " f"{fit.axis_overhead_s_sd * 1e6:4.2f} us " f"({fit.points} points, " + ("exactly determined, so no residual)" if fit.points == 2 else f"residual {fit.residual_ppm:.0f} ppm)")) print(f" {'rm3100.py':<12} C {rm3100.COUNTS_PER_SECOND:10,.0f} counts/s " f"overhead {rm3100.AXIS_OVERHEAD_S * 1e6:6.2f} us (the model's own)") if len(fits) < 2: return print("\n Everything the chip times comes off one oscillator, so a " "condition that only") print(" changes its frequency scales C up and the overhead down by the " "same factor:") for (ca, fa), (cb, fb) in itertools.combinations(fits.items(), 2): count_ratio = fb.counts_per_second / fa.counts_per_second overhead_ratio = fb.axis_overhead_s / fa.axis_overhead_s print(f" {ca} -> {cb} C x{count_ratio:.5f} " f"({(count_ratio - 1) * 100:+.2f}%) " f"overhead x{overhead_ratio:.5f} " f"({(overhead_ratio - 1) * 100:+.2f}%)") print(f" pure clock scaling would put the overhead at " f"x{1 / count_ratio:.5f}; it is " f"{(overhead_ratio * count_ratio - 1) * 100:+.2f}% off that.") def print_locked_lines(records): verdicts = locked_line_verdict(records) if not verdicts: return print("\n=== lines at simple fractions of the sample rate (|B|) ===") for (numerator, period), hits, distinct in verdicts: print(f" {numerator}/{period} of fs:") for label, fs, line in hits: print(f" {label[:24]:<24} {line.numerator/line.period*fs:8.3f} Hz" f" {line.amplitude:6.2f} nT {line.sigma:5.1f} sigma") if distinct: print(" -> SAMPLE-LOCKED. It holds this fraction of fs across " "captures whose rates\n differ, so it moves with the " "sampling. Decimating a faster capture\n removes it.") elif len(hits) > 1: print(" -> seen in several captures, at rates too close together " "to tell a\n sample-locked line from a fixed one. " "Re-run with rates further apart.") else: print(" -> seen in one capture only. Deciding whether it is " "locked to the sampling\n or to a frequency needs a " "second capture at another rate.") def print_decimation(records, band): pairs = decimation_pairs([(label, s) for label, _, s in records]) if not pairs: return print("\n=== does filtering and decimating the fast capture match the " "slow one? ===") for condition, (lf, fast), (ls, slow), k in pairs: print(f"\n {condition}: {lf} decimated by {k} vs {ls}") try: rows = decimation_paths(fast, slow, k, band) except ValueError as exc: print(f" cannot decimate: {exc}") continue print(f" {'path':<14} {'fs Hz':>8} {'ASD':>7} {'ASD ppm':>8} " f"{'white sd':>9} sample-locked lines") for row in rows: lines = (", ".join(f"{l.numerator}/{l.period} {l.amplitude:.2f} nT" for l in row["lines"]) or "none") print(f" {row['name']:<14} {row['fs']:8.2f} {row['asd']:7.2f} " f"{row['asd_ppm']:8.1f} {row['white']:9.2f} {lines}") raw = rows[0] fir = next(r for r in rows if r["name"].startswith("fir")) native = rows[-1] # Two ratios. Decimating leaves the gain and the sensor position # untouched, so the first measures the filter alone; the second brings # in a separate run, carrying every difference between the two. print(f" -> decimating this capture changes its own floor by " f"{(fir['asd_ppm'] / raw['asd_ppm'] - 1) * 100:+.1f}%, " f"this capture against itself.") print(f" Against the separately-recorded slow capture it is " f"{fir['asd_ppm'] / native['asd_ppm']:.3f}x, which also carries " f"whatever\n differs between two runs.") print("\n Compare the ASD ppm column, which is quoted over a common band " "and divided by\n each capture's own |B|. Each path carries its own " "effective noise bandwidth in\n the per-sample sd, and its own gain " "in the absolute nT.") # The candidate explanations for a gain that moves with the supply, in the # order reported. `both` stacks the two, and its residual shows what that costs: # the rate change *is* the supply change seen through the oscillator, so the two # together count the same physics twice. CORRECTIONS = [("raw", "raw"), ("rate", "x measured fs"), ("supply", "x rail volts"), ("both", "x fs x volts")] def correction_names(cal): """Row labels, naming what the rate row is actually referenced to. Uncalibrated it is `1 + rate_error`, measured against rm3100's *nominal* model, whose misfit is cycle-count dependent, so the row carries meaning inside a matched cycle-count contrast, where that misfit cancels. With a calibration it is the measured oscillator against a stated reference, which means the same thing everywhere. """ renamed = {"rate": "x oscillator", "both": "x osc x volts"} return [(key, renamed.get(key, name) if cal else name) for key, name in CORRECTIONS] def corrected_fields(s, supplies, reference_volts, cal=None): """|B| under each candidate correction, keyed by name; None if unavailable. rate the chip's own clock is measured every run, so a gain that tracks the clock is correctable from the capture alone. With a calibration this is the measured oscillator against a stated reference; without one it falls back to `1 + rate_error`, which carries meaning inside a matched cycle-count contrast. supply a gain simply ratiometric with the rail. Needs the rail measured, and nothing from the capture. both the two together, which holds if they are independent mechanisms. """ raw = s["field"] if cal is not None: by_rate = raw * calibrate.gain_factor(s["cap"], cal).value else: by_rate = raw * (1.0 + s["cap"].rate_error) volts = supplies.get(condition_of(s["label"])) ratio = (volts / reference_volts) if volts else None return {"raw": raw, "rate": by_rate, "supply": raw * ratio if ratio else None, "both": by_rate * ratio if ratio else None} def print_contrasts(records, supplies, cal=None): """Condition contrasts at matched cycle count: the designed comparison.""" conditions = conditions_in_order(records) if len(conditions) < 2: return reference_volts = min(supplies.values()) if supplies else None by_cc = defaultdict(dict) for label, _, s in records: s["label"] = label by_cc[s["cycle_count"]][condition_of(label)] = s print("\n=== |B| by condition, at matched cycle count ===") print("The comparison the interleaving was for: same cycle count, " "so only the condition differs.") contrasts = defaultdict(list) exponents = defaultdict(list) for cc in sorted(by_cc): present = by_cc[cc] if len(present) < 2: continue for ca, cb in itertools.combinations(conditions, 2): if ca not in present or cb not in present: continue a, b = present[ca], present[cb] left = corrected_fields(a, supplies, reference_volts, cal) right = corrected_fields(b, supplies, reference_volts, cal) print(f"\n cycle count {cc}: {ca} -> {cb}") for key, name in correction_names(cal): if left[key] is None or right[key] is None: continue print(f" {name:<14} {left[key]:10,.1f} -> " f"{right[key]:10,.1f} nT " f"{right[key] - left[key]:+9,.1f} nT " f"{(right[key]/left[key] - 1) * 100:+6.2f}%") contrasts[(ca, cb)].append((cc, math.log(right["raw"] / left["raw"]))) # Fit the power gain actually goes as. -1 is exactly ratiometric, # 0 is no supply dependence. The rate gets the same treatment: same # oscillator, seen from the other end. va, vb = supplies.get(ca), supplies.get(cb) if va and vb and va != vb: dv = math.log(vb / va) exponents[(ca, cb)].append( (cc, math.log(right["raw"] / left["raw"]) / dv, math.log(b["fs"] / a["fs"]) / dv)) if cal is not None: print("\n The x oscillator rows above hold for any " "gain_offset_counts, including the\n " f"{cal.gain_offset_counts:+.2f} this calibration carries. The " "shape term depends only on the cycle\n count, so at a matched " "cycle count both conditions carry the same one and it\n " "cancels. A contrast spanning cycle counts is what measures the " "offset.") for (ca, cb), points in exponents.items(): va, vb = supplies[ca], supplies[cb] print(f"\n how {ca} -> {cb} ({va:g} V -> {vb:g} V) scales with the rail:") for cc, field_power, rate_power in points: print(f" cycle count {cc}: |B| ~ V^{field_power:+.3f} " f"rate ~ V^{rate_power:+.3f}") print(" V^-1 on |B| is exactly ratiometric, gain proportional to " "the rail; V^0 is\n no supply dependence. Read the residual " "of the `x rail volts` row above\n against the " "reproducibility figure below to see what these support.") for (ca, cb), points in contrasts.items(): if len(points) < 2: continue values = [v for _, v in points] print(f"\n {ca} -> {cb} across {len(points)} cycle counts: " f"mean {np.mean(values) * 100:+.2f}%, " f"spread {(max(values) - min(values)) * 100:.2f}%") print(" A small spread means the condition acts the same at every " "cycle count, which\n is the signature of a scale factor. An " "environmental step spreads wider.") # A condition measured twice bounds everything this experiment cannot # separate, handling and gradient and drift, and is the honest error bar. print("\n reproducibility within one condition (the error bar on all of " "the above):") any_repeat = False for condition in conditions: runs = [(s["cycle_count"], s["field"]) for l, _, s in records if condition_of(l) == condition] if len(runs) < 2: continue any_repeat = True fields = [f for _, f in runs] print(f" {condition:<12} n={len(runs)} |B| " + ", ".join(f"{f:,.0f}" for f in fields) + f" spread {(max(fields)/min(fields) - 1) * 100:.2f}%") if any_repeat: print(" These repeats differ in cycle count as well, so the spread " "is an upper bound\n on run-to-run reproducibility.") else: print(" none: no condition was captured twice, so there is no error " "bar at all.") def print_pairwise(records): if len(records) < 2: return print("\n=== pairwise: |B|, and how far the sensor moved ===") print("|B| survives a rotation, so a difference is movement where the " "sensor also\ntranslated through a gradient. These size that doubt.") for (la, _, a), (lb, _, b) in itertools.combinations(records, 2): ratio = b["mean"] / a["mean"] spread = axis_ratio_spread(a, b, ratio) scale, residual, angle = gain_and_movement(a, b) print(f"\n{la[:24]} -> {lb[:24]}") print(f" |B| {a['field']:,.0f} -> {b['field']:,.0f} nT " f"{b['field'] - a['field']:+,.1f} nT " f"({(b['field']/a['field'] - 1) * 100:+.2f}%)") print(f" fractional noise {a['total']['ppm']:.1f} -> " f"{b['total']['ppm']:.1f} ppm " f"({(b['total']['ppm']/a['total']['ppm'] - 1) * 100:+.1f}%)") print(f" best-fit scale {scale:.5f} gain residual " f"{residual * 100:5.2f}% rotation {angle:.3f} deg") if spread is not None: print(f" per-axis ratios X {ratio[0]:.5f} Y {ratio[1]:.5f} " f"Z {ratio[2]:.5f} spread {spread * 100:.2f}%") else: print(f" per-axis ratios fewer than two axes carry " f"{RATIO_AXIS_MIN_FRACTION:.0%} of |B|, so this " "test abstains") moved = angle > ROTATION_OK_DEG or residual > RATIO_SPREAD_OK if moved: print(" -> the sensor moved between these two, so some of the " "|B| difference may be\n a different place in the " "field.") else: print(" -> the sensor held still, so the |B| difference is a " "scale change.") # -------------------------------------------------------------------------- # Figure # -------------------------------------------------------------------------- def binned_asd(freqs, asd, bins=140): """Median ASD in log-spaced frequency bins, for a trace that can be read. A raw periodogram at these lengths draws tens of thousands of points into a few hundred pixels and renders as a solid band whose thickness is chi-square scatter. Binning by the median keeps the floor where the quoted numbers put it, those being medians too, and keeps a line a line. """ edges = np.geomspace(freqs[0], freqs[-1], bins + 1) index = np.digitize(freqs, edges) - 1 centres, values = [], [] for b in range(bins): inside = index == b if inside.any(): centres.append(np.sqrt(edges[b] * edges[b + 1])) values.append(np.median(asd[inside])) return np.array(centres), np.array(values) def make_figure(records, band, supplies, path, cal=None): conditions = conditions_in_order(records) variants = sorted({variant_of(l) for l, _, _ in records}) color = {c: CONDITION_COLORS[i % len(CONDITION_COLORS)] for i, c in enumerate(conditions)} dash = {v: VARIANT_STYLES[i % len(VARIANT_STYLES)] for i, v in enumerate(variants)} marker = {v: VARIANT_MARKERS[i % len(VARIANT_MARKERS)] for i, v in enumerate(variants)} fig, axs = plt.subplots(2, 2, figsize=(13.5, 9), dpi=150) fig.patch.set_facecolor(ch.SURFACE) for ax in axs.flat: ch.style(ax) a = axs[0, 0] a.axvspan(band[0], band[1], color=ch.GRID, alpha=0.55, zorder=0, linewidth=0) for label, _, s in records: freqs, asd = binned_asd(*ch.welch_asd( s["axes"]["total"], s["fs"], nperseg=ch.segment_length(s["fs"]))) # Fractional, because each condition has its own gain: in absolute # nT/rtHz a condition reading 7% smaller plots 7% lower while being # equally quiet. asd = asd / s["field"] * 1e6 a.loglog(freqs, asd, color=color[condition_of(label)], linestyle=dash[variant_of(label)], linewidth=1.4, alpha=0.9, label=label) a.set_xlim(*ch.FRACTIONAL_ASD_HZ); a.set_ylim(*ch.FRACTIONAL_ASD_PPM) ch.decade_ticks(a) ch.title(a, "Spectral density of |B|, as a fraction of |B|") a.set_xlabel("frequency (Hz)"); a.set_ylabel("ppm of |B| per √Hz") a.annotate(f"shaded: {band[0]:g}–{band[1]:.0f} Hz, the compared band." "\nnormalised by each capture's own |B|, which keeps" "\na gain difference out of the noise figure", xy=(0.02, 0.05), xycoords="axes fraction", color=ch.TEXT_SECONDARY, fontsize=9) a.legend(frameon=False, fontsize=9, labelcolor=ch.TEXT_SECONDARY, loc="upper right") a = axs[0, 1] plotted = _plot_decimation(a, records, band, color, dash) ch.title(a, "Fast capture, filtered and decimated") a.set_xlabel("frequency (Hz)"); a.set_ylabel("ppm of |B| per √Hz") if plotted: a.set_xlim(*ch.FRACTIONAL_ASD_HZ); a.set_ylim(*ch.FRACTIONAL_ASD_PPM) ch.decade_ticks(a) a.legend(frameon=False, fontsize=9, labelcolor=ch.TEXT_SECONDARY, loc="upper right") else: a.annotate("no pair of captures differs by an integer cycle-count " "factor,\nso there is nothing to decimate onto", xy=(0.5, 0.5), xycoords="axes fraction", ha="center", color=ch.TEXT_SECONDARY, fontsize=10) a = axs[1, 0] for label, _, s in records: taus, devs = s["allan_curve"] a.loglog(taus, devs / s["field"] * 1e6, color=color[condition_of(label)], linestyle=dash[variant_of(label)], linewidth=1.6, label=label) a.set_xlim(*ch.ALLAN_TAU_S); a.set_ylim(*ch.FRACTIONAL_ALLAN_PPM) ch.decade_ticks(a) ch.title(a, "Allan deviation of |B|, as a fraction of |B|") a.set_xlabel("averaging time τ (s)"); a.set_ylabel("σ (ppm of |B|)") a.annotate("slope −½ = white noise; upturn = drift", xy=(0.02, 0.04), xycoords="axes fraction", color=ch.TEXT_SECONDARY, fontsize=9) a.legend(frameon=False, fontsize=9, labelcolor=ch.TEXT_SECONDARY, loc="upper right") _plot_corrections(axs[1, 1], records, supplies, color, marker, cal) fig.suptitle("RM3100 capture comparison", color=ch.TEXT_PRIMARY, fontsize=15, fontweight="bold", y=0.985) fig.text(0.5, 0.945, f"{len(records)} captures; colour is the condition, line style " f"the cycle count. Broadband figures over " f"{band[0]:g}–{band[1]:.1f} Hz, inside every capture's Nyquist.", color=ch.TEXT_SECONDARY, fontsize=10, ha="center") fig.tight_layout(rect=[0, 0, 1, 0.935]) fig.savefig(path, facecolor=ch.SURFACE) return path def _plot_decimation(ax, records, band, color, dash): """Decimated-fast against natively-slow, per condition. True if anything.""" ax.axvspan(band[0], band[1], color=ch.GRID, alpha=0.55, zorder=0, linewidth=0) plotted = False for condition, (_, fast), (_, slow), k in decimation_pairs( [(label, s) for label, _, s in records]): try: v = ch.decimate(fast["axes"]["total"], k, "fir") except ValueError: continue hue = color[condition] freqs, asd = binned_asd(*ch.welch_asd(v, fast["fs"] / k, nperseg=ch.segment_length(fast["fs"] / k))) # Fractional again: the two cycle counts have their own gains too. asd = asd / fast["field"] * 1e6 ax.loglog(freqs, asd, color=hue, linestyle="--", linewidth=1.5, label=f"{condition} cc{fast['cycle_count']} ÷{k}") freqs, asd = binned_asd(*ch.welch_asd( slow["axes"]["total"], slow["fs"], nperseg=ch.segment_length(slow["fs"]))) asd = asd / slow["field"] * 1e6 ax.loglog(freqs, asd, color=hue, linestyle="-", linewidth=1.5, alpha=0.7, label=f"{condition} cc{slow['cycle_count']} native") plotted = True if plotted: ax.annotate("dashed: fast capture decimated. solid: natively slow.", xy=(0.02, 0.05), xycoords="axes fraction", color=ch.TEXT_SECONDARY, fontsize=9) return plotted def _plot_corrections(ax, records, supplies, color, marker, cal=None): """|B| under each correction, so the spread each one leaves is visible.""" reference_volts = min(supplies.values()) if supplies else None for label, _, s in records: s["label"] = label corrected = [(label, corrected_fields(s, supplies, reference_volts, cal)) for label, _, s in records] rows = [(key, name) for key, name in correction_names(cal) if all(c[key] is not None for _, c in corrected)] for row, (key, name) in enumerate(rows): values = [] for label, correction in corrected: values.append(correction[key]) ax.plot(correction[key], -row, marker=marker[variant_of(label)], color=color[condition_of(label)], markersize=9, markeredgecolor=ch.SURFACE, markeredgewidth=2, linestyle="none", zorder=3) ax.plot([min(values), max(values)], [-row, -row], color=ch.GRID, linewidth=6, solid_capstyle="round", zorder=1) ax.annotate(f"spread {(max(values)/min(values) - 1) * 100:.1f}%", xy=(max(values), -row), xytext=(10, 0), textcoords="offset points", va="center", color=ch.TEXT_SECONDARY, fontsize=9) ax.set_yticks([-r for r in range(len(rows))], [n for _, n in rows]) ax.set_ylim(-len(rows) + 0.5, 0.85) ax.margins(x=0.24) ch.title(ax, "Total field under each candidate correction") ax.set_xlabel("|B| (nT)") ax.annotate("a correction that is right collapses the spread", xy=(0.02, 0.06), xycoords="axes fraction", color=ch.TEXT_SECONDARY, fontsize=9) # Identity never rests on colour alone: the marker repeats the cycle count. handles = [plt.Line2D([], [], color=color[c], marker="o", markersize=9, linestyle="none", label=c) for c in conditions_in_order(records)] handles += [plt.Line2D([], [], color=ch.TEXT_SECONDARY, marker=marker[v], markersize=8, linestyle="none", label=v or "(one variant)") for v in sorted({variant_of(l) for l, _, _ in records})] ax.legend(handles=handles, frameon=False, fontsize=9, ncol=2, labelcolor=ch.TEXT_SECONDARY, loc="upper left") # -------------------------------------------------------------------------- def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("csv", nargs="+", metavar="[LABEL=]CSV") ap.add_argument("--group", choices=["note", "file"], default="file", help="where unlabelled captures take their name from " "(default: %(default)s)") ap.add_argument("--trim", type=float, default=0.0, metavar="SECONDS", help="drop this many seconds from BOTH ends of every " "capture (default: %(default)s)") ap.add_argument("--band", type=parse_band, default=None, metavar="LO,HI", help=f"broadband comparison band in Hz; the default is " f"{ch.BAND_LO_HZ:g} Hz to " f"{ch.BAND_NYQUIST_FRACTION * 100:g}%% of the Nyquist " f"of the slowest path compared, decimation included") ap.add_argument("--supply", type=parse_supply, action="append", default=[], metavar="CONDITION=VOLTS", help="rail voltage for a condition, enabling the " "ratiometric correction; repeatable") ap.add_argument("--calibration", default=None, metavar="JSON", help="reference the rate correction to a measured " "calibration in place of rm3100's nominal model") ap.add_argument("-o", "--output", default=None, metavar="PNG", help="write a comparison figure here") args = ap.parse_args() supplies = dict(args.supply) try: cal = (calibrate.load_calibration(args.calibration) if args.calibration else None) except (OSError, calibrate.CalibrationError) as exc: sys.exit(str(exc)) loaded = [] for argument in args.csv: try: explicit, path = split_label(argument) cap = capture.load(path) cap, note = ch.trimmed(cap, args.trim) except (OSError, ValueError, capture.CaptureError) as exc: print(f"skipping {argument}: {exc}", file=sys.stderr) continue loaded.append((label_for(cap, path, args.group, explicit), path, cap, note)) if not loaded: sys.exit("nothing to compare") unknown = supplies.keys() - {condition_of(l) for l, _, _, _ in loaded} if unknown: print(f"warning: --supply names no capture: {', '.join(sorted(unknown))}", file=sys.stderr) # One band for every capture, so no run is scored over more bandwidth than # another; otherwise the difference in bandwidth reads as a noise difference. slowest = min(comparable_rates([(l, cap) for l, _, cap, _ in loaded])) band = args.band or ch.band_for(slowest) if band[1] > ch.BAND_NYQUIST_FRACTION * slowest / 2: print(f"warning: --band reaches {band[1]:g} Hz, past " f"{ch.BAND_NYQUIST_FRACTION * slowest / 2:.2f} Hz where the " f"slowest path compared ({slowest:.2f} Hz, decimation " f"included) rolls off", file=sys.stderr) records = [(label, path, summarise(cap, band)) for label, path, cap, _ in loaded] if args.trim: for label, _, _, note in loaded: if note: print(f"{label[:24]:<24} {note}") print() print_overview(records, band, supplies) print_rate_model(records) print_locked_lines(records) print_decimation(records, band) print_contrasts(records, supplies, cal) print_pairwise(records) if args.output: print(f"\n-> {make_figure(records, band, supplies, args.output, cal)}") return 0 if __name__ == "__main__": sys.exit(main())