624 lines
27 KiB
Python
624 lines
27 KiB
Python
#!/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 deliberate: the corrections are real but rest on a small
|
|
number of captures taken with a sensor that moved between runs, so they are
|
|
applied only when asked for, and every factor carries the standard deviation it
|
|
earned. See Noise_Floor_Testing/NOTES.md for the measurements behind them.
|
|
|
|
**A calibration is per unit and per supply.** 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 a nominal overhead that fit no particular
|
|
part. A calibration records a measured pair plus a reference oscillator
|
|
frequency; the correction is how far this capture's oscillator sits from that
|
|
reference. Gain moves with the oscillator, so a capture taken at another supply
|
|
reads a different field for the same magnet.
|
|
|
|
Once the timing pair is right, **one capture is enough** to recover the
|
|
oscillator: `C = (cycle_count + n) / (period/3)`, where n is the overhead in
|
|
counts. `gain_exponent` is 1 because the measurement interval is clocked
|
|
entirely by that oscillator. The overhead is a fixed *count* of cycles, so the
|
|
same number of counts is collected over a window whose length goes as 1/f, and
|
|
gain follows integration time. `oscillator_hz` rests on the same premise, which
|
|
keeps the two consistent.
|
|
|
|
The output is a different format from a raw capture and `capture.py` refuses to
|
|
read it, which keeps raw captures the one source of truth. The header carries
|
|
the source's SHA-256, so a derived file can be tied back to what produced it.
|
|
"""
|
|
|
|
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
|
|
# a file it would misread.
|
|
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
|
|
|
|
|
|
class CalibrationError(Exception):
|
|
pass
|
|
|
|
|
|
class Estimate(namedtuple("Estimate", "value sd")):
|
|
"""A number and its standard deviation, carried together.
|
|
|
|
Every corrected field rests on a chain of measurements, and a residual only
|
|
means something against the uncertainty of the thing that was supposed to
|
|
remove it. Carrying the two together puts that comparison at the point of
|
|
use.
|
|
"""
|
|
|
|
__slots__ = ()
|
|
|
|
@property
|
|
def relative(self):
|
|
return self.sd / abs(self.value) if self.value else float("inf")
|
|
|
|
|
|
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: without an absolute field
|
|
standard, the only defensible choice of where "right" is, is the condition
|
|
the gain was characterised at.
|
|
|
|
The uncertainties default to zero, so a calibration making no claim about
|
|
its own accuracy still works. Every field it corrects then reports a
|
|
standard deviation of zero, which states that claim on the face of the file.
|
|
"""
|
|
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, so booleans are rejected here.
|
|
"""
|
|
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 it.")
|
|
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
|
|
|
|
|
|
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, which `rate_stability()` supplies.
|
|
|
|
Both terms are solved for, since the misfit sits in both: holding C at the
|
|
specified 90,000 gives 58.9 us of overhead at cycle count 100 and 113.6 us
|
|
at 400 on the same unit.
|
|
|
|
**Two points determine both terms exactly and leave no residual**, so
|
|
`residual_ppm` is 0 and carries no information there. That case requires
|
|
`period_sd`, which states the uncertainty outright. 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 overhead_counts(cal):
|
|
"""The calibration's per-axis overhead expressed in oscillator cycles.
|
|
|
|
The overhead is stored as a duration, which is what `fit_timing` solves for,
|
|
and that duration belongs to the C it was measured with: the overhead is a
|
|
fixed *count* of cycles whose duration goes as 1/f. See `oscillator_hz`.
|
|
"""
|
|
return cal.axis_overhead_s * cal.counts_per_second
|
|
|
|
|
|
def oscillator_hz(cap, cal, period_sd=None):
|
|
"""This capture's oscillator frequency, with its uncertainty.
|
|
|
|
Inverts the timing model. The overhead comes from the calibration, a single
|
|
cycle count leaving one equation in two unknowns, and it is the term the
|
|
nominal model gets most wrong.
|
|
|
|
**The overhead is carried as a count of cycles**, the same premise the gain
|
|
exponent rests on, so the model keeps it inside the count and closes in one
|
|
step:
|
|
|
|
period / AXES = (cycle_count + overhead_counts) / C
|
|
|
|
Written with the calibration's *duration* instead, the two forms disagree by
|
|
0.23% on a figure whose propagated standard deviation is 0.30%.
|
|
|
|
`period_sd` is the fractional uncertainty on the measured period; it
|
|
defaults to measuring it from the capture with `rate_stability()`.
|
|
"""
|
|
overhead = overhead_counts(cal)
|
|
# The overhead is a handful of counts against a cycle count of 30 or more.
|
|
# One that rivals the cycle count would set the recovered frequency, so the
|
|
# calibration is refused.
|
|
if overhead >= cap.cycle_count:
|
|
raise CalibrationError(
|
|
f"{cap.path}: the calibration's {cal.axis_overhead_s * 1e6:.1f} us "
|
|
f"overhead is {overhead:,.1f} counts against a "
|
|
f"{cap.cycle_count}-count measurement, so the overhead, not the "
|
|
"measurement, would set the answer. The calibration does not "
|
|
"describe this capture.")
|
|
counts = cap.cycle_count + overhead
|
|
per_axis = cap.dt_true / rm3100.AXES
|
|
if period_sd is None:
|
|
period_sd = ch.rate_stability(cap)
|
|
value = counts / per_axis
|
|
# Two independent contributions: how well the period was measured, and how
|
|
# well the overhead added to the count is known. The second dominates at low
|
|
# cycle counts, where the overhead is a larger share of the total count.
|
|
# Both go into fractional terms first, since they enter from opposite ends.
|
|
from_period = period_sd
|
|
from_overhead = cal.axis_overhead_s_sd * cal.counts_per_second / counts
|
|
return Estimate(value, value * math.hypot(from_period, from_overhead))
|
|
|
|
|
|
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, which 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, where measurement puts it near
|
|
zero, the overhead counts costing time while integrating no
|
|
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.
|
|
|
|
**A matched-cycle-count contrast cannot measure the offset.** The shape term
|
|
depends only on the cycle count, so two conditions compared at the *same*
|
|
cycle count carry the same shape and it cancels from their ratio exactly.
|
|
Only a contrast spanning cycle counts says anything about `n`.
|
|
"""
|
|
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.
|
|
|
|
A separate type from `capture.Capture`, since the raw counts and the host
|
|
clock are gone and the time base is fixed at what was written. What survives
|
|
is what a plot or a spectrum needs, plus the provenance of how it got there.
|
|
"""
|
|
|
|
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()."""
|
|
with open(path, newline="") as handle:
|
|
meta = capture.parse_header(handle)
|
|
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")
|
|
|
|
def column(name):
|
|
return 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, so tools take a path and
|
|
get back something with axes() and a time base either way.
|
|
"""
|
|
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.
|
|
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 = ch.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())
|