rm3100/calibrate.py

724 lines
32 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Convert a raw capture to nanotesla, optionally applying a calibration.
./.venv/bin/python calibrate.py raw.csv -o field.csv
./.venv/bin/python calibrate.py raw.csv -o field.csv --calibration bench.json
./.venv/bin/python calibrate.py raw.csv -o field.csv --trim 30
Without `--calibration` this only changes the format: counts become nanotesla
using the `tesla_per_count` the capture already carries, and nothing is
corrected. That is the default deliberately -- see "What is not corrected".
**Why a calibration is per-unit.** The chip times everything from one oscillator,
and `rm3100`'s model of it, `period = 3 x (cycle_count / C + overhead)`, uses a
specified C and an overhead measured on one rig. Neither fits every part. Solving
the model against one unit's two cycle counts gave C = 88,546 with a 40.6 us
overhead at 3.006 V, against the model's 90,000 and 68.7 us -- and C moved to
92,889 when the supply moved to 3.353 V, because the oscillator does.
That matters beyond timing. Gain moves with the oscillator, so a capture taken at
a different supply reads a different field for the same magnet. A calibration
records the timing pair and a reference oscillator frequency, and the correction
is how far this capture's oscillator sits from that reference.
Once the timing pair is right, **one capture is enough** to recover the
oscillator: rearranging the model gives `C = cycle_count / (period/3 - overhead)`,
and a 1 us error in the overhead moves C by only 0.09% at cycle count 100. With
the model's 28 us error it moves by 2.5%, and by a *different* amount at each
cycle count, which is what makes the uncalibrated figures unusable for this.
**Why the exponent is 1.** The measurement interval is clocked entirely by that
oscillator: the per-axis overhead measures 3.60 counts at 3.006 V and 3.54 at
3.353 V while its *duration* moves 6%, so the same number of counts is always
collected over a window whose length goes as 1/f. Gain then follows integration
time, giving `gain ~ (cc + n) / f` -- exponent exactly 1. Measured rates agree:
the supply rate ratio is the same at both cycle counts to 0.04%, which a
fixed-time overhead would not produce. It is favoured rather than proven, at 0.3
sigma against 1.2 for a fixed-time overhead; thermal drift is what limits it.
**Why there is a gain offset.** Table 3-1's `0.3671*cc + 1.5` LSB/uT implies the
gain goes as `cc + 4.086` in count units. Solving the same-supply cycle-count
contrast on one unit gives 0.90 and -0.09 -- near zero, so the overhead counts
cost time without integrating field. Leaving it at the datasheet value accounts
for only half of what the correction can remove.
**What is not corrected, and why nothing is by default.** All of it was measured
on four captures, two per supply, with a sensor that rotated between runs by 2.8
to 23 degrees, and every capture drifts 186 to 1241 ppm through its own thermal
warm-up. So it is applied only when a calibration is named, every factor arrives
with the standard deviation it earned, and the output records what was done.
The output is a different format from a raw capture and `capture.py` will refuse
to read it, which is intended: raw captures stay the one source of truth, and a
derived file that drifts from its source should be detectable rather than
plausible. The header carries the source's SHA-256 for that reason.
"""
import argparse
import csv
import hashlib
import json
import math
import os
import sys
from collections import namedtuple
import numpy as np
import capture
import characterize as ch
import rm3100
# Bumped when either file format changes incompatibly, so a reader can refuse
# rather than misinterpret.
CALIBRATION_VERSION = 2
CALIBRATED_VERSION = 1
# Read in blocks when hashing: a capture can be tens of megabytes and there is
# no reason to hold one in memory twice.
HASH_BLOCK = 1 << 20
# Sub-windows used to measure how much the oscillator moved during a capture.
# Eight over a five-minute run is ~40 s each, long enough to fit a rate against
# and short enough to see a thermal transient.
STABILITY_WINDOWS = 8
class CalibrationError(Exception):
pass
class Estimate(namedtuple("Estimate", "value sd")):
"""A number and its standard deviation, carried together.
Every corrected field here rests on a chain of measurements, and a residual
only means something against the uncertainty of the thing that was supposed
to remove it. Returning the two together is what makes that comparison
possible at the point of use rather than in a comment.
"""
__slots__ = ()
@property
def relative(self):
return self.sd / abs(self.value) if self.value else float("inf")
def __format__(self, spec):
return f"{self.value:{spec or '.6g'}} +/- {self.sd:{spec or '.2g'}}"
Calibration = namedtuple("Calibration", [
"counts_per_second", # C in the timing model, this unit's own
"axis_overhead_s", # the model's fixed per-axis term
"reference_oscillator_hz", # C at which the gain is taken as correct
"reference_cycle_count", # cycle count at which it is taken as correct
"gain_exponent", # gain ~ (cc + offset) / f ** exponent
"gain_offset_counts", # the "+ n" -- see gain_factor()
"counts_per_second_sd",
"axis_overhead_s_sd",
"gain_exponent_sd",
"gain_offset_counts_sd",
"note",
"created",
])
def calibration(counts_per_second, axis_overhead_s,
reference_oscillator_hz=None, reference_cycle_count=100,
gain_exponent=1.0, gain_offset_counts=0.0,
counts_per_second_sd=0.0, axis_overhead_s_sd=0.0,
gain_exponent_sd=0.0, gain_offset_counts_sd=0.0,
note="", created=""):
"""Build a Calibration, defaulting the reference to the measured value.
A calibration whose reference is its own oscillator frequency corrects
nothing on the unit and supply it was taken at, and corrects other captures
relative to it. That is the useful default: the reference is a choice of
where "right" is, and the only defensible choice without an absolute field
standard is the condition the gain was characterised at.
The uncertainties all default to zero, so a calibration that makes no claim
about its own accuracy still works -- and every field it corrects then
reports a standard deviation of zero, which is a visible claim rather than a
silent one.
"""
return Calibration(
counts_per_second=float(counts_per_second),
axis_overhead_s=float(axis_overhead_s),
reference_oscillator_hz=float(reference_oscillator_hz
if reference_oscillator_hz is not None
else counts_per_second),
reference_cycle_count=int(reference_cycle_count),
gain_exponent=float(gain_exponent),
gain_offset_counts=float(gain_offset_counts),
counts_per_second_sd=float(counts_per_second_sd),
axis_overhead_s_sd=float(axis_overhead_s_sd),
gain_exponent_sd=float(gain_exponent_sd),
gain_offset_counts_sd=float(gain_offset_counts_sd),
note=str(note), created=str(created),
)
def _finite(value, field):
"""A field that must parse as a real number. Booleans are not numbers here.
JSON's `true` reaches Python as a bool, which floats to 1.0 without
complaint -- a silent way for a malformed calibration to become a plausible
one.
"""
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
raise CalibrationError(f"{field}: {value!r} is not a number")
try:
number = float(value)
except ValueError:
raise CalibrationError(f"{field}: {value!r} is not a number")
if not math.isfinite(number):
raise CalibrationError(f"{field}: {value!r} is not finite")
return number
def _positive(value, field):
number = _finite(value, field)
if number <= 0:
raise CalibrationError(f"{field}: {number} must be positive")
return number
def _non_negative(value, field):
number = _finite(value, field)
if number < 0:
raise CalibrationError(f"{field}: {number} must not be negative")
return number
def load_calibration(path):
"""Read a calibration file, refusing anything it cannot fully understand."""
try:
with open(path) as handle:
raw = json.load(handle)
except json.JSONDecodeError as exc:
raise CalibrationError(f"{path}: not valid JSON ({exc})")
if not isinstance(raw, dict):
raise CalibrationError(f"{path}: expected an object, got "
f"{type(raw).__name__}")
version = raw.get("rm3100_calibration")
if version != CALIBRATION_VERSION:
raise CalibrationError(
f"{path}: rm3100_calibration is {version!r}, this reads "
f"{CALIBRATION_VERSION}. Version 1 carried a per-cycle-count gain "
"table; version 2 replaces it with gain_offset_counts, which is "
"not derivable from it -- re-measure rather than convert.")
missing = {"counts_per_second", "axis_overhead_s"} - raw.keys()
if missing:
raise CalibrationError(f"{path}: missing {', '.join(sorted(missing))}")
cycle_count = raw.get("reference_cycle_count", 100)
if isinstance(cycle_count, bool) or not isinstance(cycle_count, (int, str)):
raise CalibrationError(
f"{path}: reference_cycle_count {cycle_count!r} is not an integer")
try:
cycle_count = int(cycle_count)
except ValueError:
raise CalibrationError(
f"{path}: reference_cycle_count {cycle_count!r} is not an integer")
if cycle_count < 1:
raise CalibrationError(
f"{path}: reference_cycle_count {cycle_count} must be at least 1")
return Calibration(
counts_per_second=_positive(raw["counts_per_second"],
"counts_per_second"),
# The overhead is a duration and may legitimately be zero, but not
# negative: a measurement cannot take less time than its own counts.
axis_overhead_s=_non_negative(raw["axis_overhead_s"],
"axis_overhead_s"),
reference_oscillator_hz=_positive(
raw.get("reference_oscillator_hz", raw["counts_per_second"]),
"reference_oscillator_hz"),
reference_cycle_count=cycle_count,
gain_exponent=_finite(raw.get("gain_exponent", 1.0), "gain_exponent"),
gain_offset_counts=_finite(raw.get("gain_offset_counts", 0.0),
"gain_offset_counts"),
counts_per_second_sd=_non_negative(
raw.get("counts_per_second_sd", 0.0), "counts_per_second_sd"),
axis_overhead_s_sd=_non_negative(
raw.get("axis_overhead_s_sd", 0.0), "axis_overhead_s_sd"),
gain_exponent_sd=_non_negative(
raw.get("gain_exponent_sd", 0.0), "gain_exponent_sd"),
gain_offset_counts_sd=_non_negative(
raw.get("gain_offset_counts_sd", 0.0), "gain_offset_counts_sd"),
note=str(raw.get("note", "")),
created=str(raw.get("created", "")),
)
def save_calibration(path, cal):
"""Write a calibration file."""
if cal.axis_overhead_s < 0:
raise CalibrationError(
f"axis_overhead_s {cal.axis_overhead_s} is negative")
body = {"rm3100_calibration": CALIBRATION_VERSION,
"note": cal.note, "created": cal.created}
body.update({field: getattr(cal, field) for field in Calibration._fields
if field not in ("note", "created")})
with open(path, "w") as handle:
json.dump(body, handle, indent=2, sort_keys=False)
handle.write("\n")
return path
def window_rates(cap, seconds):
"""Sample rate fitted independently on each consecutive window of a capture.
Returns (centres, rates) -- seconds from the start of the capture, and Hz --
with one point per window that held enough samples to fit.
`Capture.dt_true` is a single straight line through a run whose rate is
moving. This is the same fit repeated on pieces short enough that the
movement within any one of them is small, which is what turns the drift from
an error on the whole-capture number into something measurable in its own
right.
"""
seconds = float(seconds)
start = float(cap.elapsed[0])
if seconds <= 0 or cap.duration < 2 * seconds:
return np.array([]), np.array([])
centres, rates = [], []
for w in range(int(cap.duration // seconds)):
low = start + w * seconds
try:
rate = cap.restrict(low, low + seconds).true_rate_hz
except capture.CaptureError:
continue
rates.append(rate)
centres.append((w + 0.5) * seconds)
return np.array(centres), np.array(rates)
def rate_stability(cap, windows=STABILITY_WINDOWS):
"""Fractional spread of the sample rate across sub-windows of a capture.
The chip's RC oscillator warms up, and every capture here drifts downward
through its own run -- 186 ppm on the quietest, 1241 ppm on the worst. A
single fitted `dt_true` is the average over that transient, so this is the
honest error bar on it, and the input to every uncertainty downstream.
It is not the same thing as `Capture.drift_limited`, which asks whether one
straight line describes the capture at all. This asks how far the rate moved
while it did. Nor is it `rate_allan`, which asks how much of that movement
is real: a spread this wide is only meaningful against the noise of the
measurement that produced it.
"""
if len(cap.sample_index) < 64 * windows:
return 0.0
_, rates = window_rates(cap, cap.duration / windows)
if len(rates) < 2:
return 0.0
return float((rates.max() - rates.min()) / rates.mean())
def rate_allan(cap, taus):
"""Two-sample (Allan) deviation of the measured sample rate, fractional.
For each averaging time tau the rate is fitted on consecutive tau-second
windows and the deviation taken between neighbours:
sigma_y(tau) = sqrt( mean( (y[k+1] - y[k])^2 ) / 2 )
Differences between neighbours rather than a spread about the mean, for the
same reason `characterize.white_sd` differences rather than taking a
standard deviation: on a series that drifts, the spread measures the drift.
Here both effects are wanted and the shape of the curve separates them.
Falling with tau is measurement noise averaging down; rising with tau is
drift the measurement has begun to resolve, and a rise proportional to tau
specifically is a deterministic frequency ramp rather than a random walk.
The minimum is therefore the best the rate can be known, and the tau where
it falls is how long it is worth measuring for.
Returns (taus, sigma_y) over the taus that yielded three or more windows.
Two windows give a single difference, which is not an estimate of anything.
"""
kept, devs = [], []
for tau in taus:
_, rates = window_rates(cap, tau)
if len(rates) < 3:
continue
y = rates / rates.mean()
kept.append(float(tau))
devs.append(float(np.sqrt(np.mean(np.diff(y) ** 2) / 2)))
return np.array(kept), np.array(devs)
TimingFit = namedtuple("TimingFit", "counts_per_second axis_overhead_s "
"counts_per_second_sd axis_overhead_s_sd "
"residual_ppm points")
def fit_timing(cycle_counts, periods, period_sd=None):
"""Solve `period = AXES * (cycle_count / C + overhead)` for both terms.
`periods` are whole sample periods in seconds, not per-axis. `period_sd` is
the fractional uncertainty on each -- `rate_stability()` is where it comes
from.
Both terms are needed. Holding C at the specified 90,000 and solving for the
overhead alone gave 58.9 us at cycle count 100 and 113.6 us at 400 on the
same unit -- no single value fits, because the misfit is in both.
**Two points determine both terms exactly and leave no residual**, so a bad
point cannot be detected: `residual_ppm` is 0 and means nothing. That case
requires `period_sd`, so the uncertainty is at least stated rather than
fabricated from a perfect fit. Three or more points carry their own.
"""
counts = np.asarray(cycle_counts, dtype=float)
per_axis = np.asarray(periods, dtype=float) / rm3100.AXES
if counts.shape != per_axis.shape:
raise CalibrationError(
f"{counts.size} cycle counts against {per_axis.size} periods")
distinct = len(np.unique(counts))
if distinct < 2:
raise CalibrationError(
"need at least two distinct cycle counts to fit both the count "
"rate and the per-axis overhead")
if distinct == 2 and len(counts) == 2:
if period_sd is None:
raise CalibrationError(
"a two-point fit has no residual and cannot detect a bad "
"point, so it cannot estimate its own uncertainty. Pass "
"period_sd (from rate_stability) or sweep a third cycle count.")
slope, intercept = np.polyfit(counts, per_axis, 1)
if slope <= 0:
raise CalibrationError(
"fitted count rate is not positive; these captures do not "
"follow the timing model")
count_rate = 1.0 / float(slope)
# C leans on the *difference* of the two per-axis times, so a fractional
# error on each is amplified by how close together they are.
span = abs(per_axis[1] - per_axis[0])
rate_sd = count_rate * period_sd * float(np.hypot(*per_axis)) / span
overhead_sd = (per_axis.min() * period_sd
+ counts.min() / count_rate * rate_sd / count_rate)
return TimingFit(count_rate, float(intercept), rate_sd, overhead_sd,
0.0, len(counts))
(slope, intercept), cov = np.polyfit(counts, per_axis, 1, cov=True)
if slope <= 0:
raise CalibrationError(
"fitted count rate is not positive; these captures do not follow "
"the timing model")
count_rate = 1.0 / float(slope)
slope_sd, overhead_sd = math.sqrt(cov[0, 0]), math.sqrt(cov[1, 1])
residual = per_axis - (slope * counts + intercept)
return TimingFit(
count_rate, float(intercept),
# d(1/slope) = dslope / slope^2
float(slope_sd) / slope ** 2, float(overhead_sd),
float(np.abs(residual / per_axis).max() * 1e6), len(counts))
def oscillator_hz(cap, cal, period_sd=None):
"""This capture's oscillator frequency, with its uncertainty.
Inverts `period = AXES * (cycle_count / C + overhead)`. The overhead comes
from the calibration because it cannot be recovered from a single cycle
count -- one equation, two unknowns -- and it is the term the nominal model
gets most wrong.
`period_sd` is the fractional uncertainty on the measured period; it
defaults to measuring it from the capture with `rate_stability()`.
"""
per_axis = cap.dt_true / rm3100.AXES - cal.axis_overhead_s
if per_axis <= 0:
raise CalibrationError(
f"{cap.path}: a {cap.dt_true * 1e3:.3f} ms period leaves no time "
f"for {cap.cycle_count} counts once the calibration's "
f"{cal.axis_overhead_s * 1e6:.1f} us overhead is removed. The "
"calibration does not describe this capture.")
if period_sd is None:
period_sd = rate_stability(cap)
value = cap.cycle_count / per_axis
# Two independent contributions: how well the period was measured, and how
# well the overhead subtracted from it is known. The second dominates at low
# cycle counts, where the overhead is a larger share of the per-axis time.
from_period = cap.dt_true * period_sd / rm3100.AXES
relative = math.hypot(from_period, cal.axis_overhead_s_sd) / per_axis
return Estimate(value, value * relative)
def gain_factor(cap, cal, period_sd=None):
"""Multiply measured nanotesla by this to put them on the reference gain.
Three terms, each correcting something the capture's own conversion assumed:
oscillator how far this capture's clock sits from the reference, raised
to the fitted exponent. The measurement interval is clocked
entirely by that oscillator -- the per-axis overhead measures
3.57 counts at both supplies, constant, while its *duration*
moves 6% -- so the same number of counts is always collected
over a window whose length goes as 1/f. That predicts an
exponent of exactly 1.
gain shape Table 3-1's `0.3671*cc + 1.5` LSB/uT implies the gain goes as
`cc + 4.086` in count units. Measured on one unit it is 0 to
0.9, so the overhead counts cost time without integrating
field. `gain_offset_counts` is that "+ n".
reference the shape term is only known up to an overall scale, since
`gain = A(cc + n)` leaves A free. `reference_cycle_count`
pins it: the correction is exactly 1 there.
"""
oscillator = oscillator_hz(cap, cal, period_sd)
ratio = oscillator.value / cal.reference_oscillator_hz
reference_cc, offset = cal.reference_cycle_count, cal.gain_offset_counts
if cap.cycle_count + offset <= 0 or reference_cc + offset <= 0:
raise CalibrationError(
f"gain_offset_counts {offset} puts the gain at or below zero for "
f"cycle count {min(cap.cycle_count, reference_cc)}")
shape = ((rm3100.gain_lsb_per_tesla(cap.cycle_count)
/ rm3100.gain_lsb_per_tesla(reference_cc))
* ((reference_cc + offset) / (cap.cycle_count + offset)))
value = ratio ** cal.gain_exponent * shape
# In logs, so the terms add in quadrature as independent contributions.
from_oscillator = cal.gain_exponent * oscillator.relative
from_exponent = cal.gain_exponent_sd * abs(math.log(ratio)) if ratio > 0 else 0.0
from_offset = cal.gain_offset_counts_sd * abs(
1.0 / (reference_cc + offset) - 1.0 / (cap.cycle_count + offset))
return Estimate(value, value * math.sqrt(from_oscillator ** 2
+ from_exponent ** 2
+ from_offset ** 2))
def source_digest(path):
"""SHA-256 of a file, so a derived copy can be tied back to its source."""
digest = hashlib.sha256()
with open(path, "rb") as handle:
for block in iter(lambda: handle.read(HASH_BLOCK), b""):
digest.update(block)
return digest.hexdigest()
def write_calibrated(cap, path, source, cal=None, factor=None,
calibration_path=None):
"""Write nanotesla and a header recording where they came from.
`factor` is an Estimate; its standard deviation goes into the header beside
it, so a reader can tell a real residual from calibration slop without
having the calibration to hand.
"""
if factor is None:
factor = Estimate(1.0, 0.0)
elif not isinstance(factor, Estimate):
factor = Estimate(float(factor), 0.0)
header = [
("rm3100_calibrated", CALIBRATED_VERSION),
("source", os.path.basename(source)),
("source_sha256", source_digest(source)),
("samples", len(cap.sample_index)),
("cycle_count", cap.cycle_count),
("tesla_per_count", repr(cap.tesla_per_count)),
("measured_rate_hz", repr(cap.true_rate_hz)),
("calibration", os.path.basename(calibration_path)
if calibration_path else "none"),
("gain_factor", repr(factor.value)),
("gain_factor_sd", repr(factor.sd)),
]
if cal is not None:
oscillator = oscillator_hz(cap, cal)
header += [
("oscillator_hz", repr(oscillator.value)),
("oscillator_hz_sd", repr(oscillator.sd)),
("reference_oscillator_hz", repr(cal.reference_oscillator_hz)),
("reference_cycle_count", cal.reference_cycle_count),
("gain_exponent", repr(cal.gain_exponent)),
("gain_offset_counts", repr(cal.gain_offset_counts)),
]
x, y, z = (cap.x * factor.value, cap.y * factor.value,
cap.z * factor.value)
total = np.sqrt(x**2 + y**2 + z**2)
# The two flags are independent, so a row can carry both. Placeholder rows
# keep their interpolated values here, exactly as capture.py produced them,
# and the flag is what says the values were reconstructed.
flags = [" ".join(
([capture.WARN_MISSED] if missed else [])
+ ([capture.WARN_AMBIGUOUS] if ambiguous else []))
for missed, ambiguous in zip(cap.missed, cap.ambiguous)]
with open(path, "w", newline="") as handle:
for key, value in header:
handle.write(f"# {key}: {value}\n")
writer = csv.writer(handle)
writer.writerow(["sample_index", "elapsed_s",
"x_nT", "y_nT", "z_nT", "total_nT", "warning"])
writer.writerows(
(index, f"{t:.6f}", f"{a:.3f}", f"{b:.3f}", f"{c:.3f}",
f"{n:.3f}", flag)
for index, t, a, b, c, n, flag
in zip(cap.sample_index, cap.elapsed, x, y, z, total, flags))
return path
class Calibrated:
"""A capture already converted to nanotesla, read back from disk.
Deliberately not a `capture.Capture`: the raw counts and the host clock are
gone, so the time base cannot be re-fitted and `restrict()` would be a lie.
What survives is what a plot or a spectrum needs, plus the provenance of how
it got that way.
"""
def __init__(self, path, meta, sample_index, elapsed, x, y, z, total):
self.path = path
self.meta = meta
self.sample_index = sample_index
self.elapsed = elapsed
self.x, self.y, self.z, self.total = x, y, z, total
self.cycle_count = int(meta["cycle_count"])
self.tesla_per_count = float(meta["tesla_per_count"])
self.lsb_nt = self.tesla_per_count * rm3100.NT_PER_TESLA
self.true_rate_hz = float(meta["measured_rate_hz"])
self.dt_true = 1.0 / self.true_rate_hz
self.gain_factor = float(meta["gain_factor"])
self.gain_factor_sd = float(meta.get("gain_factor_sd", 0.0))
self.calibration_name = meta.get("calibration", "none")
self.source = meta.get("source", "")
self.source_sha256 = meta.get("source_sha256", "")
@property
def duration(self):
return float(self.elapsed[-1] - self.elapsed[0])
def axes(self):
return [("x", self.x), ("y", self.y), ("z", self.z),
("total", self.total)]
def describe(self):
if self.gain_factor == 1.0:
return "converted to nT, uncorrected"
return (f"gain x{self.gain_factor:.6f} +/- {self.gain_factor_sd:.6f} "
f"from {self.calibration_name}")
def load_calibrated(path):
"""Read a file written by write_calibrated()."""
meta, rows = {}, []
with open(path, newline="") as handle:
while True:
position = handle.tell()
line = handle.readline()
if not line:
break
if not line.startswith("#"):
handle.seek(position)
break
key, _, value = line[1:].partition(":")
if value:
meta[key.strip()] = value.strip()
rows = list(csv.DictReader(handle))
version = meta.get("rm3100_calibrated")
if version is None:
raise CalibrationError(
f"{path}: no rm3100_calibrated header. A raw capture goes through "
"capture.load() instead.")
if int(version) != CALIBRATED_VERSION:
raise CalibrationError(
f"{path}: rm3100_calibrated is {version}, this reads "
f"{CALIBRATED_VERSION}")
missing = {"cycle_count", "tesla_per_count", "measured_rate_hz",
"gain_factor"} - meta.keys()
if missing:
raise CalibrationError(f"{path}: header missing "
f"{', '.join(sorted(missing))}")
if not rows:
raise CalibrationError(f"{path}: no samples")
column = lambda name: np.array([float(r[name]) for r in rows])
return Calibrated(
path, meta,
np.array([int(r["sample_index"]) for r in rows], dtype=np.int64),
column("elapsed_s"), column("x_nT"), column("y_nT"), column("z_nT"),
column("total_nT"))
def load_any(path):
"""Load a raw capture or a calibrated one, whichever this is.
Which format a file is, is a property of the file, not of the caller -- so
tools take a path and get back something with axes() and a time base either
way, rather than each having to know.
"""
with open(path) as handle:
head = handle.read(4096)
if "rm3100_calibrated" in head:
return load_calibrated(path)
return capture.load(path)
def main():
ap = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("csv", help="raw capture written by logger.py")
ap.add_argument("-o", "--output", required=True, metavar="CSV",
help="where to write the nanotesla capture")
ap.add_argument("--calibration", default=None, metavar="JSON",
help="apply this calibration; without it nothing is "
"corrected and only the units change")
ap.add_argument("--trim", type=float, default=0.0, metavar="SECONDS",
help="drop this many seconds from BOTH ends before "
"converting (default: %(default)s)")
args = ap.parse_args()
# Check the destination before reading a capture that may be tens of
# megabytes, so a mistyped directory fails in a second rather than a minute.
parent = os.path.dirname(os.path.abspath(args.output)) or "."
if not os.path.isdir(parent):
sys.exit(f"{args.output}: {parent} is not a directory")
if not os.access(parent, os.W_OK):
sys.exit(f"{args.output}: {parent} is not writable")
try:
cal = load_calibration(args.calibration) if args.calibration else None
cap = capture.load(args.csv)
cap, note = ch.trimmed(cap, args.trim)
factor = gain_factor(cap, cal) if cal else Estimate(1.0, 0.0)
except (OSError, ValueError, CalibrationError,
capture.CaptureError) as exc:
sys.exit(str(exc))
if note:
print(f" {note}")
if cal is None:
print("no calibration given: converting units only, correcting nothing")
else:
measured = oscillator_hz(cap, cal)
stability = rate_stability(cap)
print(f"calibration {args.calibration}"
+ (f" -- {cal.note}" if cal.note else ""))
print(f" rate {cap.true_rate_hz:.4f} Hz, drifting "
f"{stability * 1e6:.0f} ppm across the run")
print(f" oscillator {measured.value:,.0f} +/- {measured.sd:,.0f} Hz "
f"against a {cal.reference_oscillator_hz:,.0f} Hz reference "
f"({measured.value / cal.reference_oscillator_hz - 1:+.2%})")
if cap.cycle_count != cal.reference_cycle_count:
print(f" gain shape cycle count {cap.cycle_count} against a "
f"{cal.reference_cycle_count} reference, offset "
f"{cal.gain_offset_counts:+.2f} counts")
print(f" gain factor {factor.value:.6f} +/- {factor.sd:.6f} "
f"({factor.relative:.2%}) |B| {cap.total.mean():,.0f} -> "
f"{cap.total.mean() * factor.value:,.0f} nT")
try:
out = write_calibrated(cap, args.output, args.csv, cal, factor,
args.calibration)
except (OSError, CalibrationError) as exc:
sys.exit(str(exc))
print(f"-> {out}")
return 0
if __name__ == "__main__":
sys.exit(main())