2026-08-24 18:09:19 -04:00
|
|
|
"""Calibration: the timing fit, the gain factor, and the file it is carried in.
|
|
|
|
|
|
|
|
|
|
Everything is checked against a planted answer. A calibration is a claim about a
|
|
|
|
|
particular part, so the tests that matter are the ones showing a value goes in
|
|
|
|
|
and the same value comes back out -- and that a file which cannot be fully
|
|
|
|
|
understood is refused rather than half-read into a plausible-looking correction.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
import math
|
|
|
|
|
|
|
|
|
|
import numpy as np
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
import calibrate
|
|
|
|
|
import capture
|
|
|
|
|
import rm3100
|
|
|
|
|
from synthetic import drifting_times, field_counts, write_capture
|
|
|
|
|
|
|
|
|
|
FIELD = (-40000.0, 16000.0, 12000.0)
|
|
|
|
|
|
|
|
|
|
|
2026-09-02 13:20:10 -04:00
|
|
|
REFERENCE_HZ = 88546.0
|
|
|
|
|
REFERENCE_OVERHEAD_S = 40.61e-6
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 18:09:19 -04:00
|
|
|
def planted_period(cycle_count, counts_per_second, overhead):
|
2026-09-02 13:20:10 -04:00
|
|
|
"""Period for a capture whose per-axis overhead is a fixed *duration*.
|
|
|
|
|
|
|
|
|
|
Within one condition this is indistinguishable from the clocked form below,
|
|
|
|
|
which is why `fit_timing` -- which only ever sees one condition at a time --
|
|
|
|
|
is tested with it.
|
|
|
|
|
"""
|
2026-08-24 18:09:19 -04:00
|
|
|
return rm3100.AXES * (cycle_count / counts_per_second + overhead)
|
|
|
|
|
|
|
|
|
|
|
2026-09-02 13:20:10 -04:00
|
|
|
def planted_period_clocked(cycle_count, counts_per_second,
|
|
|
|
|
overhead=REFERENCE_OVERHEAD_S,
|
|
|
|
|
reference_hz=REFERENCE_HZ):
|
|
|
|
|
"""Period for a capture whose overhead is a fixed *count* of cycles.
|
|
|
|
|
|
|
|
|
|
The two forms only diverge once a capture runs at a frequency other than the
|
|
|
|
|
one the overhead was measured at, and that is exactly the case a calibration
|
|
|
|
|
is applied to. This is the model `calibrate.oscillator_hz` inverts: the
|
|
|
|
|
overhead's duration goes as 1/f, so a capture at 92,889 counts/s carries
|
|
|
|
|
38.71 us of what was 40.61 us at 88,546.
|
|
|
|
|
"""
|
|
|
|
|
return rm3100.AXES * (cycle_count + overhead * reference_hz) / counts_per_second
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def make(path, cycle_count=100, counts_per_second=REFERENCE_HZ,
|
|
|
|
|
overhead=REFERENCE_OVERHEAD_S, clocked=False,
|
2026-08-24 18:09:19 -04:00
|
|
|
rows=2000, mean=FIELD, noise_nt=5.0, seed=0):
|
2026-09-02 13:20:10 -04:00
|
|
|
"""A synthetic capture with a planted timing model.
|
|
|
|
|
|
|
|
|
|
`clocked` chooses which hypothesis about the overhead the capture obeys; it
|
|
|
|
|
makes no difference unless `counts_per_second` differs from REFERENCE_HZ.
|
|
|
|
|
"""
|
|
|
|
|
period = (planted_period_clocked if clocked else planted_period)
|
2026-08-24 18:09:19 -04:00
|
|
|
counts = field_counts(rows, mean, cycle_count, noise_nt, seed)
|
|
|
|
|
return capture.load(write_capture(
|
|
|
|
|
path, rows=rows, cycle_count=cycle_count, counts=counts,
|
2026-09-02 13:20:10 -04:00
|
|
|
dt=period(cycle_count, counts_per_second, overhead)))
|
2026-08-24 18:09:19 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def written(tmp_path, **fields):
|
|
|
|
|
body = {"rm3100_calibration": calibrate.CALIBRATION_VERSION,
|
|
|
|
|
"counts_per_second": 88546.0, "axis_overhead_s": 40.61e-6}
|
|
|
|
|
body.update(fields)
|
|
|
|
|
path = tmp_path / "cal.json"
|
|
|
|
|
path.write_text(json.dumps(body))
|
|
|
|
|
return str(path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
# fit_timing
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def test_fit_timing_recovers_a_planted_pair_exactly():
|
|
|
|
|
counts_per_second, overhead = 88546.0, 40.61e-6
|
|
|
|
|
ccs = [100, 400]
|
|
|
|
|
periods = [planted_period(cc, counts_per_second, overhead) for cc in ccs]
|
|
|
|
|
fit = calibrate.fit_timing(ccs, periods, period_sd=1e-4)
|
|
|
|
|
assert fit.counts_per_second == pytest.approx(counts_per_second, rel=1e-9)
|
|
|
|
|
assert fit.axis_overhead_s == pytest.approx(overhead, rel=1e-9)
|
|
|
|
|
# Two points fit both terms exactly, so there is nothing left over -- and
|
|
|
|
|
# that is precisely why the residual cannot be used as a check.
|
|
|
|
|
assert fit.residual_ppm == 0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_fit_timing_least_squares_over_more_points():
|
|
|
|
|
counts_per_second, overhead = 92889.0, 38.11e-6
|
|
|
|
|
ccs = [50, 100, 200, 400, 800]
|
|
|
|
|
periods = [planted_period(cc, counts_per_second, overhead) for cc in ccs]
|
|
|
|
|
fit = calibrate.fit_timing(ccs, periods)
|
|
|
|
|
assert fit.counts_per_second == pytest.approx(counts_per_second, rel=1e-6)
|
|
|
|
|
assert fit.axis_overhead_s == pytest.approx(overhead, rel=1e-6)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_fit_timing_needs_two_distinct_cycle_counts():
|
|
|
|
|
"""Two unknowns need two points; one would be a guess dressed as a fit."""
|
|
|
|
|
with pytest.raises(calibrate.CalibrationError,
|
|
|
|
|
match="two distinct cycle counts"):
|
|
|
|
|
calibrate.fit_timing([100, 100, 100], [3.5e-3] * 3, period_sd=1e-4)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_fit_timing_rejects_data_that_does_not_follow_the_model():
|
|
|
|
|
"""A higher cycle count that samples faster is not this chip."""
|
|
|
|
|
with pytest.raises(calibrate.CalibrationError, match="not positive"):
|
|
|
|
|
calibrate.fit_timing([100, 400], [13.0e-3, 3.5e-3], period_sd=1e-4)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_fit_timing_rejects_mismatched_lengths():
|
|
|
|
|
with pytest.raises(calibrate.CalibrationError, match="against"):
|
|
|
|
|
calibrate.fit_timing([100, 400], [3.5e-3], period_sd=1e-4)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_holding_the_divisor_at_spec_cannot_absorb_the_misfit():
|
|
|
|
|
"""The reason both terms are calibrated, not just the overhead.
|
|
|
|
|
|
|
|
|
|
Solving for the overhead alone with the spec divisor gives a different
|
|
|
|
|
answer at every cycle count, which is what makes the nominal model's error
|
|
|
|
|
cycle-count dependent.
|
|
|
|
|
"""
|
|
|
|
|
counts_per_second, overhead = 88546.0, 40.61e-6
|
|
|
|
|
solved = [planted_period(cc, counts_per_second, overhead) / rm3100.AXES
|
|
|
|
|
- cc / rm3100.COUNTS_PER_SECOND for cc in (100, 400)]
|
|
|
|
|
assert solved[0] == pytest.approx(58.9e-6, abs=1e-6)
|
|
|
|
|
assert solved[1] == pytest.approx(113.6e-6, abs=1e-6)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
# oscillator_hz
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("cycle_count", [50, 100, 200, 400, 800])
|
|
|
|
|
def test_oscillator_is_recovered_from_a_single_capture(tmp_path, cycle_count):
|
|
|
|
|
"""One capture is enough once the overhead is known -- the whole point."""
|
|
|
|
|
counts_per_second, overhead = 88546.0, 40.61e-6
|
|
|
|
|
cap = make(tmp_path / "c.csv", cycle_count=cycle_count,
|
|
|
|
|
counts_per_second=counts_per_second, overhead=overhead)
|
|
|
|
|
cal = calibrate.calibration(counts_per_second, overhead)
|
|
|
|
|
assert calibrate.oscillator_hz(cap, cal).value == pytest.approx(
|
|
|
|
|
counts_per_second, rel=1e-5)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_overhead_error_costs_less_than_a_tenth_of_a_percent(tmp_path):
|
|
|
|
|
"""The sensitivity the single-capture path depends on.
|
|
|
|
|
|
|
|
|
|
A 1 us error in the overhead must stay under 0.1% of the oscillator at the
|
|
|
|
|
worst cycle count in use, or a calibration from one supply could not be
|
|
|
|
|
applied to a capture from another.
|
|
|
|
|
"""
|
|
|
|
|
counts_per_second, overhead = 88546.0, 40.61e-6
|
|
|
|
|
cap = make(tmp_path / "c.csv", cycle_count=100,
|
|
|
|
|
counts_per_second=counts_per_second, overhead=overhead)
|
|
|
|
|
exact = calibrate.oscillator_hz(
|
|
|
|
|
cap, calibrate.calibration(counts_per_second, overhead)).value
|
|
|
|
|
off_by_1us = calibrate.oscillator_hz(
|
|
|
|
|
cap, calibrate.calibration(counts_per_second, overhead + 1e-6)).value
|
|
|
|
|
assert abs(off_by_1us / exact - 1) < 0.001
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_nominal_overhead_costs_far_more_and_varies_with_cycle_count(tmp_path):
|
|
|
|
|
"""Why the nominal model cannot be used as a gain reference."""
|
|
|
|
|
counts_per_second, overhead = 88546.0, 40.61e-6
|
|
|
|
|
errors = []
|
|
|
|
|
for cycle_count in (100, 400):
|
|
|
|
|
cap = make(tmp_path / f"{cycle_count}.csv", cycle_count=cycle_count,
|
|
|
|
|
counts_per_second=counts_per_second, overhead=overhead)
|
|
|
|
|
nominal = calibrate.calibration(counts_per_second,
|
|
|
|
|
rm3100.AXIS_OVERHEAD_S)
|
|
|
|
|
errors.append(calibrate.oscillator_hz(cap, nominal).value
|
|
|
|
|
/ counts_per_second)
|
|
|
|
|
# Both wrong, and wrong by different amounts -- so it does not cancel.
|
|
|
|
|
assert abs(errors[0] - 1) > 0.02
|
|
|
|
|
assert abs(errors[1] - 1) < 0.01
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_oscillator_refuses_a_calibration_that_cannot_describe_the_capture(tmp_path):
|
2026-09-02 13:20:10 -04:00
|
|
|
"""An overhead that rivals the cycle count is not merely wrong."""
|
2026-08-24 18:09:19 -04:00
|
|
|
cap = make(tmp_path / "c.csv", cycle_count=100)
|
|
|
|
|
absurd = calibrate.calibration(88546.0, axis_overhead_s=1.0)
|
|
|
|
|
with pytest.raises(calibrate.CalibrationError, match="does not describe"):
|
|
|
|
|
calibrate.oscillator_hz(cap, absurd)
|
|
|
|
|
|
|
|
|
|
|
2026-09-02 13:20:10 -04:00
|
|
|
@pytest.mark.parametrize("cycle_count", [50, 100, 200, 400])
|
|
|
|
|
def test_the_overhead_is_carried_as_counts_not_microseconds(tmp_path,
|
|
|
|
|
cycle_count):
|
|
|
|
|
"""A capture at another frequency is inverted with its own overhead.
|
|
|
|
|
|
|
|
|
|
The overhead is a fixed count of oscillator cycles, so a condition running
|
|
|
|
|
4.9% faster spends 4.9% less time on it. Subtracting the reference
|
|
|
|
|
condition's *duration* instead would leave a residual, and one that grows as
|
|
|
|
|
the overhead's share of the per-axis time grows -- which is why the low
|
|
|
|
|
cycle counts are parametrised here.
|
|
|
|
|
"""
|
|
|
|
|
cap = make(tmp_path / f"{cycle_count}.csv", cycle_count=cycle_count,
|
|
|
|
|
counts_per_second=92889.0, clocked=True)
|
|
|
|
|
cal = calibrate.calibration(REFERENCE_HZ, REFERENCE_OVERHEAD_S)
|
|
|
|
|
assert calibrate.oscillator_hz(cap, cal).value == pytest.approx(
|
|
|
|
|
92889.0, rel=1e-6)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_second_condition_is_recovered_as_its_own_timing_fit_would(tmp_path):
|
|
|
|
|
"""The single-capture inversion agrees with the two-point fit it replaces.
|
|
|
|
|
|
|
|
|
|
`fit_timing` needs two cycle counts and recovers both terms for a condition
|
|
|
|
|
outright; `oscillator_hz` claims to get the frequency from one capture and a
|
|
|
|
|
calibration taken elsewhere. Those two routes have to land in the same
|
|
|
|
|
place, or a calibration cannot be carried across supplies at all -- which is
|
|
|
|
|
the entire premise of the file.
|
|
|
|
|
"""
|
|
|
|
|
periods = {cc: planted_period_clocked(cc, 92889.0) for cc in (100, 400)}
|
|
|
|
|
fit = calibrate.fit_timing(list(periods), list(periods.values()),
|
|
|
|
|
period_sd=1e-6)
|
|
|
|
|
assert fit.counts_per_second == pytest.approx(92889.0, rel=1e-9)
|
|
|
|
|
# The overhead scaled by exactly the frequency ratio, which is the claim.
|
|
|
|
|
assert fit.axis_overhead_s == pytest.approx(
|
|
|
|
|
REFERENCE_OVERHEAD_S * REFERENCE_HZ / 92889.0, rel=1e-9)
|
|
|
|
|
|
|
|
|
|
cal = calibrate.calibration(REFERENCE_HZ, REFERENCE_OVERHEAD_S)
|
|
|
|
|
for cycle_count in periods:
|
|
|
|
|
cap = make(tmp_path / f"{cycle_count}.csv", cycle_count=cycle_count,
|
|
|
|
|
counts_per_second=92889.0, clocked=True)
|
|
|
|
|
assert calibrate.oscillator_hz(cap, cal).value == pytest.approx(
|
|
|
|
|
fit.counts_per_second, rel=1e-6)
|
|
|
|
|
|
|
|
|
|
|
2026-08-24 18:09:19 -04:00
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
# gain_factor
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def test_a_capture_at_its_own_reference_is_not_corrected(tmp_path):
|
|
|
|
|
counts_per_second, overhead = 88546.0, 40.61e-6
|
|
|
|
|
cap = make(tmp_path / "c.csv", counts_per_second=counts_per_second,
|
|
|
|
|
overhead=overhead)
|
|
|
|
|
cal = calibrate.calibration(counts_per_second, overhead)
|
|
|
|
|
assert calibrate.gain_factor(cap, cal).value == pytest.approx(1.0, rel=1e-5)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_gain_factor_is_the_oscillator_ratio_at_exponent_one(tmp_path):
|
2026-09-02 13:20:10 -04:00
|
|
|
overhead = REFERENCE_OVERHEAD_S
|
|
|
|
|
cap = make(tmp_path / "c.csv", counts_per_second=92889.0, overhead=overhead,
|
|
|
|
|
clocked=True)
|
|
|
|
|
cal = calibrate.calibration(REFERENCE_HZ, overhead,
|
|
|
|
|
reference_oscillator_hz=REFERENCE_HZ)
|
2026-08-24 18:09:19 -04:00
|
|
|
assert calibrate.gain_factor(cap, cal).value == pytest.approx(
|
2026-09-02 13:20:10 -04:00
|
|
|
92889.0 / REFERENCE_HZ, rel=1e-4)
|
2026-08-24 18:09:19 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_zero_exponent_disables_the_oscillator_term(tmp_path):
|
2026-09-02 13:20:10 -04:00
|
|
|
overhead = REFERENCE_OVERHEAD_S
|
|
|
|
|
cap = make(tmp_path / "c.csv", counts_per_second=92889.0, overhead=overhead,
|
|
|
|
|
clocked=True)
|
|
|
|
|
cal = calibrate.calibration(REFERENCE_HZ, overhead, gain_exponent=0.0)
|
2026-08-24 18:09:19 -04:00
|
|
|
assert calibrate.gain_factor(cap, cal).value == 1.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_correction_is_exactly_one_at_the_reference_cycle_count(tmp_path):
|
|
|
|
|
"""The offset is only known up to a scale, so the reference pins it."""
|
|
|
|
|
counts_per_second, overhead = 88546.0, 40.61e-6
|
|
|
|
|
for reference in (50, 100, 400):
|
|
|
|
|
cap = make(tmp_path / f"{reference}.csv", cycle_count=reference,
|
|
|
|
|
counts_per_second=counts_per_second, overhead=overhead)
|
|
|
|
|
for offset in (-0.5, 0.0, 0.9, 4.086):
|
|
|
|
|
cal = calibrate.calibration(
|
|
|
|
|
counts_per_second, overhead, reference_cycle_count=reference,
|
|
|
|
|
gain_offset_counts=offset)
|
|
|
|
|
assert calibrate.gain_factor(cap, cal).value == pytest.approx(
|
|
|
|
|
1.0, rel=1e-5)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_offset_corrects_the_datasheet_gain_shape(tmp_path):
|
|
|
|
|
"""The datasheet implies gain ~ (cc + 4.086); this unit measures ~0.
|
|
|
|
|
|
|
|
|
|
Two captures whose counts embed gain = A(cc + n) must read the same field
|
|
|
|
|
once corrected with that n, and must not with the datasheet's.
|
|
|
|
|
"""
|
|
|
|
|
counts_per_second, overhead, n = 88546.0, 40.61e-6, 0.0
|
|
|
|
|
field = 47000.0
|
|
|
|
|
raw, good, bad = {}, {}, {}
|
|
|
|
|
for cycle_count in (100, 400):
|
|
|
|
|
# The counts this chip would produce if its gain really went as (cc+n).
|
|
|
|
|
per_axis = field / math.sqrt(3) * 0.3671 * (cycle_count + n)
|
|
|
|
|
counts = np.tile([per_axis, per_axis, per_axis], (2000, 1))
|
|
|
|
|
cap = capture.load(write_capture(
|
|
|
|
|
tmp_path / f"{cycle_count}.csv", rows=2000,
|
|
|
|
|
cycle_count=cycle_count, counts=counts,
|
|
|
|
|
dt=planted_period(cycle_count, counts_per_second, overhead)))
|
|
|
|
|
raw[cycle_count] = cap.total.mean()
|
|
|
|
|
for name, offset, into in (("good", n, good), ("bad", 4.086, bad)):
|
|
|
|
|
cal = calibrate.calibration(counts_per_second, overhead,
|
|
|
|
|
reference_cycle_count=100,
|
|
|
|
|
gain_offset_counts=offset)
|
|
|
|
|
into[cycle_count] = cap.total.mean() * calibrate.gain_factor(cap, cal).value
|
|
|
|
|
|
|
|
|
|
# Uncorrected the two disagree, because gain_model has the wrong offset.
|
|
|
|
|
assert abs(raw[400] / raw[100] - 1) > 0.02
|
|
|
|
|
assert good[400] == pytest.approx(good[100], rel=1e-4)
|
|
|
|
|
assert abs(bad[400] / bad[100] - 1) > 0.02
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_gain_offset_that_zeroes_the_gain_is_refused(tmp_path):
|
|
|
|
|
cap = make(tmp_path / "c.csv", cycle_count=100)
|
|
|
|
|
cal = calibrate.calibration(88546.0, 40.61e-6, reference_cycle_count=100,
|
|
|
|
|
gain_offset_counts=-100.0)
|
|
|
|
|
with pytest.raises(calibrate.CalibrationError, match="at or below zero"):
|
|
|
|
|
calibrate.gain_factor(cap, cal)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
# Propagated uncertainty
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def test_zero_input_uncertainty_gives_zero_output_uncertainty(tmp_path):
|
|
|
|
|
"""A calibration claiming perfect knowledge says so, rather than guessing."""
|
|
|
|
|
cap = make(tmp_path / "c.csv", cycle_count=400)
|
|
|
|
|
cal = calibrate.calibration(88546.0, 40.61e-6)
|
|
|
|
|
assert calibrate.gain_factor(cap, cal, period_sd=0.0).sd == 0.0
|
|
|
|
|
assert calibrate.oscillator_hz(cap, cal, period_sd=0.0).sd == 0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_offset_uncertainty_vanishes_at_the_reference_cycle_count(tmp_path):
|
|
|
|
|
"""At the reference the shape term is 1 by construction, so it contributes
|
|
|
|
|
nothing however badly the offset is known."""
|
|
|
|
|
cap = make(tmp_path / "c.csv", cycle_count=100)
|
|
|
|
|
cal = calibrate.calibration(88546.0, 40.61e-6, reference_cycle_count=100,
|
|
|
|
|
gain_offset_counts_sd=10.0)
|
|
|
|
|
assert calibrate.gain_factor(cap, cal, period_sd=0.0).sd == pytest.approx(0.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_offset_uncertainty_scales_linearly_away_from_the_reference(tmp_path):
|
|
|
|
|
cap = make(tmp_path / "c.csv", cycle_count=400)
|
|
|
|
|
one = calibrate.calibration(88546.0, 40.61e-6, reference_cycle_count=100,
|
|
|
|
|
gain_offset_counts_sd=0.5)
|
|
|
|
|
two = calibrate.calibration(88546.0, 40.61e-6, reference_cycle_count=100,
|
|
|
|
|
gain_offset_counts_sd=1.0)
|
|
|
|
|
a = calibrate.gain_factor(cap, one, period_sd=0.0)
|
|
|
|
|
b = calibrate.gain_factor(cap, two, period_sd=0.0)
|
|
|
|
|
assert b.sd == pytest.approx(2 * a.sd, rel=1e-9)
|
|
|
|
|
assert a.relative > 0.001 # and it is not negligible
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_oscillator_term_scales_with_the_exponent(tmp_path):
|
|
|
|
|
cap = make(tmp_path / "c.csv", cycle_count=100, counts_per_second=92889.0)
|
|
|
|
|
one = calibrate.calibration(88546.0, 40.61e-6, axis_overhead_s_sd=1e-6,
|
|
|
|
|
gain_exponent=1.0)
|
|
|
|
|
two = calibrate.calibration(88546.0, 40.61e-6, axis_overhead_s_sd=1e-6,
|
|
|
|
|
gain_exponent=2.0)
|
|
|
|
|
a = calibrate.gain_factor(cap, one, period_sd=0.0)
|
|
|
|
|
b = calibrate.gain_factor(cap, two, period_sd=0.0)
|
|
|
|
|
assert b.relative == pytest.approx(2 * a.relative, rel=1e-9)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_two_point_fit_refuses_to_invent_a_confidence():
|
|
|
|
|
"""It has no residual, so it cannot estimate its own uncertainty."""
|
|
|
|
|
ccs = [100, 400]
|
|
|
|
|
periods = [planted_period(cc, 88546.0, 40.61e-6) for cc in ccs]
|
|
|
|
|
with pytest.raises(calibrate.CalibrationError, match="two-point fit"):
|
|
|
|
|
calibrate.fit_timing(ccs, periods)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_three_points_carry_a_residual_that_can_catch_a_bad_one():
|
|
|
|
|
clean = [planted_period(cc, 88546.0, 40.61e-6) for cc in (100, 200, 400)]
|
|
|
|
|
good = calibrate.fit_timing([100, 200, 400], clean)
|
|
|
|
|
assert good.residual_ppm < 1.0
|
|
|
|
|
nudged = list(clean)
|
|
|
|
|
nudged[1] *= 1.002
|
|
|
|
|
bad = calibrate.fit_timing([100, 200, 400], nudged)
|
|
|
|
|
assert bad.residual_ppm > 100.0
|
|
|
|
|
assert bad.counts_per_second_sd > good.counts_per_second_sd
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
# The calibration file
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def test_a_calibration_round_trips(tmp_path):
|
|
|
|
|
cal = calibrate.calibration(
|
|
|
|
|
88546.0, 40.61e-6, reference_oscillator_hz=88000.0,
|
|
|
|
|
reference_cycle_count=200, gain_exponent=1.4, gain_offset_counts=0.9,
|
|
|
|
|
counts_per_second_sd=70.0, axis_overhead_s_sd=1.1e-6,
|
|
|
|
|
gain_exponent_sd=0.2, gain_offset_counts_sd=0.5,
|
|
|
|
|
note="bench", created="2026-08-24")
|
|
|
|
|
path = tmp_path / "cal.json"
|
|
|
|
|
calibrate.save_calibration(path, cal)
|
|
|
|
|
assert calibrate.load_calibration(path) == cal
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_reference_defaults_to_the_measured_oscillator():
|
|
|
|
|
"""A calibration corrects nothing at the condition it was taken at."""
|
|
|
|
|
cal = calibrate.calibration(88546.0, 40.61e-6)
|
|
|
|
|
assert cal.reference_oscillator_hz == 88546.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_missing_version_is_refused(tmp_path):
|
|
|
|
|
path = tmp_path / "cal.json"
|
|
|
|
|
path.write_text(json.dumps({"counts_per_second": 1.0,
|
|
|
|
|
"axis_overhead_s": 0.0}))
|
|
|
|
|
with pytest.raises(calibrate.CalibrationError, match="rm3100_calibration"):
|
|
|
|
|
calibrate.load_calibration(path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_future_version_is_refused_rather_than_guessed(tmp_path):
|
|
|
|
|
with pytest.raises(calibrate.CalibrationError, match="rm3100_calibration"):
|
|
|
|
|
calibrate.load_calibration(
|
|
|
|
|
written(tmp_path, rm3100_calibration=calibrate.CALIBRATION_VERSION + 1))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_truncated_json_is_refused(tmp_path):
|
|
|
|
|
path = tmp_path / "cal.json"
|
|
|
|
|
path.write_text('{"rm3100_calibration": 1, "counts_per_second":')
|
|
|
|
|
with pytest.raises(calibrate.CalibrationError, match="not valid JSON"):
|
|
|
|
|
calibrate.load_calibration(path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_json_list_is_refused(tmp_path):
|
|
|
|
|
path = tmp_path / "cal.json"
|
|
|
|
|
path.write_text("[1, 2, 3]")
|
|
|
|
|
with pytest.raises(calibrate.CalibrationError, match="expected an object"):
|
|
|
|
|
calibrate.load_calibration(path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("field", ["counts_per_second", "axis_overhead_s"])
|
|
|
|
|
def test_a_missing_required_field_names_itself(tmp_path, field):
|
|
|
|
|
body = {"rm3100_calibration": calibrate.CALIBRATION_VERSION,
|
|
|
|
|
"counts_per_second": 88546.0, "axis_overhead_s": 40.61e-6}
|
|
|
|
|
del body[field]
|
|
|
|
|
path = tmp_path / "cal.json"
|
|
|
|
|
path.write_text(json.dumps(body))
|
|
|
|
|
with pytest.raises(calibrate.CalibrationError, match=field):
|
|
|
|
|
calibrate.load_calibration(path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("bad", [0, -1, "abc", None, True, [1], float("nan")])
|
|
|
|
|
def test_a_bad_count_rate_is_refused(tmp_path, bad):
|
|
|
|
|
path = tmp_path / "cal.json"
|
|
|
|
|
# json.dump cannot write nan as valid JSON, so write it literally.
|
|
|
|
|
body = ('{"rm3100_calibration": 2, "axis_overhead_s": 4e-05, '
|
|
|
|
|
f'"counts_per_second": {json.dumps(bad) if bad == bad else "NaN"}}}')
|
|
|
|
|
path.write_text(body)
|
|
|
|
|
with pytest.raises(calibrate.CalibrationError, match="counts_per_second"):
|
|
|
|
|
calibrate.load_calibration(path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("bad", ["abc", None, True])
|
|
|
|
|
def test_a_bad_exponent_is_refused(tmp_path, bad):
|
|
|
|
|
with pytest.raises(calibrate.CalibrationError, match="gain_exponent"):
|
|
|
|
|
calibrate.load_calibration(written(tmp_path, gain_exponent=bad))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_non_integer_reference_cycle_count_is_refused(tmp_path):
|
|
|
|
|
with pytest.raises(calibrate.CalibrationError,
|
|
|
|
|
match="reference_cycle_count"):
|
|
|
|
|
calibrate.load_calibration(
|
|
|
|
|
written(tmp_path, reference_cycle_count="fast"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_negative_uncertainty_is_refused(tmp_path):
|
|
|
|
|
with pytest.raises(calibrate.CalibrationError,
|
|
|
|
|
match="gain_offset_counts_sd"):
|
|
|
|
|
calibrate.load_calibration(
|
|
|
|
|
written(tmp_path, gain_offset_counts_sd=-1.0))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_version_1_file_is_refused_with_an_explanation(tmp_path):
|
|
|
|
|
"""Version 1's per-cycle-count table has no version 2 equivalent."""
|
|
|
|
|
path = tmp_path / "old.json"
|
|
|
|
|
path.write_text(json.dumps({"rm3100_calibration": 1,
|
|
|
|
|
"counts_per_second": 88546.0,
|
|
|
|
|
"axis_overhead_s": 40.61e-6,
|
|
|
|
|
"gain_scale_by_cycle_count": {"400": 0.977}}))
|
|
|
|
|
with pytest.raises(calibrate.CalibrationError, match="re-measure"):
|
|
|
|
|
calibrate.load_calibration(path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_true_is_not_accepted_as_a_number(tmp_path):
|
|
|
|
|
"""JSON's true floats to 1.0 in Python, which would pass silently."""
|
|
|
|
|
with pytest.raises(calibrate.CalibrationError, match="counts_per_second"):
|
|
|
|
|
calibrate.load_calibration(written(tmp_path, counts_per_second=True))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
# Conversion and provenance
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def read_calibrated(path):
|
|
|
|
|
"""Parse a calibrated CSV back into (header dict, rows)."""
|
|
|
|
|
meta, rows = {}, []
|
|
|
|
|
with open(path) as handle:
|
|
|
|
|
for line in handle:
|
|
|
|
|
if line.startswith("#"):
|
|
|
|
|
key, _, value = line[1:].partition(":")
|
|
|
|
|
meta[key.strip()] = value.strip()
|
|
|
|
|
else:
|
|
|
|
|
rows.append(line.rstrip("\n").split(","))
|
|
|
|
|
return meta, rows[0], rows[1:]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_uncorrected_output_matches_capture_exactly(tmp_path):
|
|
|
|
|
source = tmp_path / "raw.csv"
|
|
|
|
|
cap = make(source)
|
|
|
|
|
out = tmp_path / "out.csv"
|
|
|
|
|
calibrate.write_calibrated(cap, out, source)
|
|
|
|
|
meta, header, rows = read_calibrated(out)
|
|
|
|
|
assert meta["gain_factor"] == "1.0"
|
|
|
|
|
assert meta["calibration"] == "none"
|
|
|
|
|
assert header == ["sample_index", "elapsed_s",
|
|
|
|
|
"x_nT", "y_nT", "z_nT", "total_nT", "warning"]
|
|
|
|
|
assert len(rows) == len(cap.sample_index)
|
|
|
|
|
assert float(rows[0][2]) == pytest.approx(cap.x[0], abs=5e-4)
|
|
|
|
|
assert float(rows[0][5]) == pytest.approx(cap.total[0], abs=5e-4)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_total_column_is_the_norm_of_the_three(tmp_path):
|
|
|
|
|
source = tmp_path / "raw.csv"
|
|
|
|
|
cap = make(source)
|
|
|
|
|
out = tmp_path / "out.csv"
|
|
|
|
|
calibrate.write_calibrated(cap, out, source, factor=1.049)
|
|
|
|
|
_, _, rows = read_calibrated(out)
|
|
|
|
|
for row in rows[:50]:
|
|
|
|
|
x, y, z, total = (float(v) for v in row[2:6])
|
|
|
|
|
assert total == pytest.approx(math.sqrt(x*x + y*y + z*z), abs=1e-2)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_factor_scales_the_field(tmp_path):
|
|
|
|
|
source = tmp_path / "raw.csv"
|
|
|
|
|
cap = make(source)
|
|
|
|
|
out = tmp_path / "out.csv"
|
|
|
|
|
calibrate.write_calibrated(cap, out, source, factor=2.0)
|
|
|
|
|
_, _, rows = read_calibrated(out)
|
|
|
|
|
assert float(rows[0][2]) == pytest.approx(cap.x[0] * 2.0, abs=1e-3)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_source_digest_matches_and_changes_with_the_source(tmp_path):
|
|
|
|
|
"""The answer to a derived file drifting from its source unnoticed."""
|
|
|
|
|
source = tmp_path / "raw.csv"
|
|
|
|
|
cap = make(source)
|
|
|
|
|
out = tmp_path / "out.csv"
|
|
|
|
|
calibrate.write_calibrated(cap, out, source)
|
|
|
|
|
meta, _, _ = read_calibrated(out)
|
|
|
|
|
assert meta["source_sha256"] == calibrate.source_digest(source)
|
|
|
|
|
|
|
|
|
|
source.write_text(source.read_text() + "\n")
|
|
|
|
|
assert meta["source_sha256"] != calibrate.source_digest(source)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_flags_survive_the_conversion(tmp_path):
|
|
|
|
|
source = tmp_path / "raw.csv"
|
|
|
|
|
write_capture(source, rows=200,
|
|
|
|
|
flags={40: capture.WARN_MISSED,
|
|
|
|
|
41: f"{capture.WARN_MISSED} {capture.WARN_AMBIGUOUS}",
|
|
|
|
|
42: capture.WARN_AMBIGUOUS})
|
|
|
|
|
cap = capture.load(source)
|
|
|
|
|
out = tmp_path / "out.csv"
|
|
|
|
|
calibrate.write_calibrated(cap, out, source)
|
|
|
|
|
_, _, rows = read_calibrated(out)
|
|
|
|
|
assert rows[40][6] == capture.WARN_MISSED
|
|
|
|
|
assert rows[41][6] == f"{capture.WARN_MISSED} {capture.WARN_AMBIGUOUS}"
|
|
|
|
|
assert rows[42][6] == capture.WARN_AMBIGUOUS
|
|
|
|
|
assert rows[39][6] == ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_calibrated_file_is_not_a_capture(tmp_path):
|
|
|
|
|
"""Different format, and capture.py must say so rather than misread it."""
|
|
|
|
|
source = tmp_path / "raw.csv"
|
|
|
|
|
cap = make(source)
|
|
|
|
|
out = tmp_path / "out.csv"
|
|
|
|
|
calibrate.write_calibrated(cap, out, source)
|
|
|
|
|
with pytest.raises(capture.CaptureError, match="no capture header"):
|
|
|
|
|
capture.load(out)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_header_records_the_calibration_that_was_applied(tmp_path):
|
|
|
|
|
source = tmp_path / "raw.csv"
|
2026-09-02 13:20:10 -04:00
|
|
|
cap = make(source, counts_per_second=92889.0, clocked=True)
|
|
|
|
|
cal = calibrate.calibration(REFERENCE_HZ, REFERENCE_OVERHEAD_S)
|
2026-08-24 18:09:19 -04:00
|
|
|
cal_path = tmp_path / "bench.json"
|
|
|
|
|
calibrate.save_calibration(cal_path, cal)
|
|
|
|
|
out = tmp_path / "out.csv"
|
|
|
|
|
calibrate.write_calibrated(cap, out, source, cal,
|
|
|
|
|
calibrate.gain_factor(cap, cal), cal_path)
|
|
|
|
|
meta, _, _ = read_calibrated(out)
|
|
|
|
|
assert meta["calibration"] == "bench.json"
|
|
|
|
|
assert float(meta["gain_factor"]) == pytest.approx(92889.0 / 88546.0,
|
|
|
|
|
rel=1e-4)
|
|
|
|
|
assert float(meta["oscillator_hz"]) == pytest.approx(92889.0, rel=1e-4)
|
|
|
|
|
assert float(meta["reference_oscillator_hz"]) == 88546.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
# End to end
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def run(monkeypatch, capsys, argv):
|
|
|
|
|
monkeypatch.setattr("sys.argv", ["calibrate.py"] + argv)
|
|
|
|
|
assert calibrate.main() == 0
|
|
|
|
|
return capsys.readouterr()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_cli_converts_without_a_calibration(tmp_path, monkeypatch, capsys):
|
|
|
|
|
source = tmp_path / "raw.csv"
|
|
|
|
|
make(source)
|
|
|
|
|
out = tmp_path / "out.csv"
|
|
|
|
|
result = run(monkeypatch, capsys, [str(source), "-o", str(out)])
|
|
|
|
|
assert "correcting nothing" in result.out
|
|
|
|
|
assert out.exists()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_cli_applies_a_calibration(tmp_path, monkeypatch, capsys):
|
|
|
|
|
source = tmp_path / "raw.csv"
|
|
|
|
|
make(source, counts_per_second=92889.0, overhead=40.61e-6)
|
|
|
|
|
cal_path = tmp_path / "bench.json"
|
|
|
|
|
calibrate.save_calibration(cal_path,
|
|
|
|
|
calibrate.calibration(88546.0, 40.61e-6))
|
|
|
|
|
out = tmp_path / "out.csv"
|
|
|
|
|
result = run(monkeypatch, capsys,
|
|
|
|
|
[str(source), "-o", str(out), "--calibration", str(cal_path)])
|
|
|
|
|
assert "gain factor 1.04" in result.out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_cli_fails_before_reading_when_the_output_is_unwritable(tmp_path):
|
|
|
|
|
source = tmp_path / "raw.csv"
|
|
|
|
|
make(source)
|
|
|
|
|
import sys as _sys
|
|
|
|
|
argv = [str(source), "-o", str(tmp_path / "nope" / "out.csv")]
|
|
|
|
|
_sys.argv = ["calibrate.py"] + argv
|
|
|
|
|
with pytest.raises(SystemExit, match="not a directory"):
|
|
|
|
|
calibrate.main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_cli_reports_a_bad_calibration_rather_than_writing_output(tmp_path):
|
|
|
|
|
source = tmp_path / "raw.csv"
|
|
|
|
|
make(source)
|
|
|
|
|
bad = tmp_path / "bad.json"
|
|
|
|
|
bad.write_text("{}")
|
|
|
|
|
out = tmp_path / "out.csv"
|
|
|
|
|
import sys as _sys
|
|
|
|
|
_sys.argv = ["calibrate.py", str(source), "-o", str(out),
|
|
|
|
|
"--calibration", str(bad)]
|
|
|
|
|
with pytest.raises(SystemExit, match="rm3100_calibration"):
|
|
|
|
|
calibrate.main()
|
|
|
|
|
assert not out.exists()
|