type: proposal status: proposed summary: Replace the inherited magcal/quality.c with owned code: a leverage-based retention policy, robust residual statistics, an accelerometer dip-consistency check, and a heading-accuracy headline metric.
Replacing qtcalibrate's Calibration Quality Code
Done as of 2026-10-10, bar the heading-accuracy metric of phase 4.
magcal/quality.c is deleted and CompassData::choose_discard_magcal() is one
line calling the leverage rule; what the two did now lives in
magquality.h and
magretention.h, with the
accelerometer split off into
accelcalibration.h.
The measurements behind it are in
the investigation.
What follows is the proposal as written. qtcalibrate judged a magnetometer
calibration with four numbers produced by magcal/quality.c, and decided which
samples to keep with CompassData::choose_discard_magcal() in
compassdata.cpp. Both
were inherited from PJRC's MotionCal. This proposal replaces them with code we
own, keeping the equal-area coverage idea and discarding the rest.
The Freescale solver in
magcal.c is out of scope
and does not change.
Purpose
The present metrics answer the wrong question. They report how well a fitted ellipsoid reproduces the samples that were used to fit it, which is a measure the fit can satisfy while the calibration is still bad. What an operator calibrating a tag needs to know is whether the samples determine the calibration, and what heading error the result implies. Those are different questions and need different statistics.
The retention policy has the same problem in a sharper form: it decides which sample to throw away using the calibration derived from the samples it has already kept, and its own source comment identifies the resulting feedback loop as the main risk.
Problem
What the current code computes
| Metric | Definition | What it actually measures |
|---|---|---|
quality_surface_gap_error |
Weighted count of the 100 equal-area regions holding 0, 1 or 2 samples (penalty 1.0, 0.2, 0.01) | Coverage, as an unnormalised penalty with no upper bound and no units |
quality_magnitude_variance_error |
Sample standard deviation of |m_cal| over the buffer, as a percent of the mean |
Geometric residual spread — the most honest of the four |
quality_wobble_error |
Norm of the mean signed per-region offset from the ideal sphere, as a percent of radius | A residual hard-iron estimate, weakened by sign cancellation |
quality_spherical_fit_error |
magcal.FitError, the solver's algebraic residual |
Not a geometric distance, and artificially floored |
sphere_region() partitions the sphere into 1 + 15 + 34 + 34 + 15 + 1 bands
with boundaries at ±78.52° and ±42.84°. Those boundaries are correct: the cap
area formula 2*pi*h with h = 1 - sin(lat) makes every region exactly 1/100
of the sphere. The equal-area idea is sound and is kept.
Defects found
-
Southern-band reference points are at the wrong longitudes. In
quality_reset(), regions 84–98 computelongitudefrom(i - 1)where the matching offset is(i - 84). Region 84's reference point lands near 204° instead of 12°. The bug is inherited from upstream MotionCal, not introduced here. It corruptsquality_wobble_errorfor 15 of 100 regions. -
Sample retention is O(N^2) per sample. Once the buffer is full,
choose_discard_magcal()runs a nearest-pair scan overMAGBUFFSIZE = 650entries — about 211,000 distance evaluations, each constructing twoQVector3Dtemporaries — for every sample added. The solver itself only runs every 20th sample (waitcountinMagCal_Run); the discard scan has no such rate limit. -
The retention policy reads a stale coverage figure.
choose_discard_magcal()branches onquality_surface_gap_error(), whose backing histogram is only rebuilt byCompassData::qualityUpdate()on a 200 ms UI timer while samples arrive on a 100 ms timer. The gap figure is also computed against whatever calibration was current at the last rebuild, not the current one. -
The outlier branch cannot help when it is needed. It only runs once coverage is already good (
gaps < 25.0f), which is exactly when outliers matter least. Early in a capture, when a single bad sample can drag the fit, only the random nearest-pair branch runs. -
Module-global mutable state.
count,spheredist,spheredata,sphereidealand the three cache flags are file-scope statics. The module cannot hold two calibrations, cannot be unit tested in isolation, and is not reentrant.sphereidealis exported non-constthrough the header. -
quality_update()has no bound check. It writesmagnitude[count]and incrementscountwithout testing againstMAGBUFFSIZE. It is safe only because its single caller resets first and iterates exactlyMAGBUFFSIZEtimes. A second caller overflows the array. -
The displayed fit error is not comparable across solvers.
MagCal_RunfloorstrFitErrorpcat 12% for the 4-element solver and 7.5% for the 7-element solver. The number therefore steps discontinuously as the sample count crosses 100 and 150, which reads to an operator as a regression. -
The four numbers are unlabelled. They are printed into a single
QString::asprintfwith no units, no names and no pass/fail thresholds. -
quality.ccarries a copyright notice with no licence grant. Every other inherited file inmagcal/carries the Freescale BSD-3-Clause text.
Goals
- Replace
quality.candchoose_discard_magcal()with code the project owns and can license. - Make coverage a structural property of the buffer rather than an emergent property of a heuristic.
- Report at least one metric that is independent of the residual the fit minimises, so that over-fitting is visible.
- Report a headline number in degrees of heading error.
- Keep the retention policy O(1) amortised per sample.
- Keep the metrics computable incrementally so the 200 ms full rebuild can go away.
Non-Goals
- Changing the Freescale solver, its 4/7/10-element progression, or the
MagCalibration_twire representation written to tags. - Moving to a 12-parameter asymmetric fit. That is a solver change and belongs in its own proposal; see Accelerometer data for why this document stops short of it.
- Changing the capture file schema
tag-designs.qtcalibrate.calibration-capture.v1, beyond adding fields to thequalityblock. - Automatic stop-when-good. The new metrics make that possible; deciding the thresholds needs field data we do not have yet.
Survey of techniques
Coverage
The question behind coverage is whether the sample set determines the parameters. Three families of answer, in increasing strength:
Patch occupancy — what we have. Cheap, explainable, visualisable, and it maps directly onto the operator's mental model of "turn the tag until the ball fills in". Its weakness is that it is a proxy: 100 occupied patches with one sample each is reported as perfect coverage and is a poor conditioning.
Second-moment isotropy. Accumulate S = sum(m_hat * m_hat^T) over unit-
normalised calibrated samples. For uniform coverage all three eigenvalues equal
N/3; lambda_min / lambda_max falls toward zero as the samples approach a
plane. This is O(1) incremental, needs a 3x3 eigensolve only when reported, and
catches the single most dangerous real failure — coplanar data from a tag spun
on a bench without being tipped — more sharply than patch counting does.
Information-matrix conditioning. Build the design matrix D whose rows are
the 10-element measurement vector the solver already forms
([x^2, 2xy, 2xz, y^2, 2yz, z^2, x, y, z, 1]), and look at D^T D. Its
condition number states observability directly and scale-free; det(D^T D)^(1/p)
(D-optimality) gives an information-volume measure that normalises against the
value an ideal uniform sphere of the same N would produce, yielding an
interpretable 0–1 efficiency. This is the standard optimal-experiment-design
scalarisation and it is the quantity the retention policy below optimises.
The three are complementary, not competing: patch occupancy for the operator, isotropy as a cheap guard, conditioning as the policy's objective.
Residual
The current magnitude-variance metric uses a plain standard deviation, which a
handful of outliers can dominate. On the checked-in fixture the ordinary
standard deviation of |m_cal| over all 775 samples is 0.90% against a
MAD-based robust estimate of 0.82% — close, because that capture is clean.
An earlier capture taken before the tag-side axis correction showed 8.61%
against 1.78%, a factor of five driven entirely by a few extreme points.
So the honest case for the robust statistic is not that it flatters a good capture; it is that it stays meaningful on a bad one, and the operator cannot tell which kind they have while collecting. Reporting the robust spread alongside a 95th-percentile residual says more than either moment alone.
Residual structure matters as much as residual size. A fit whose residuals show a systematic pattern with direction has an unmodelled term — an asymmetric soft iron the 9-parameter model cannot express. Regressing the per-patch mean residual on patch index detects this; the current wobble metric gropes toward it but averages signed offsets, so opposite-side errors cancel.
A hold-out residual — fit on a random 80%, score the magnitude residual on the
held-out 20% — catches the over-fitting case where FitError looks excellent
because the model has absorbed the noise. The fit is fast enough to afford this
on the 200 ms timer.
Parameter uncertainty
With residual variance sigma^2, the parameter covariance is
sigma^2 * (D^T D)^-1, giving standard errors on the hard-iron vector in µT
and on B. The quantity an operator can act on is the heading error those
uncertainties imply: a hard-iron error dV perpendicular to the horizontal
field produces a heading error of roughly dV / (B * cos(dip)) radians. The
honest version propagates the full covariance by sweeping synthetic headings
through the fitted and perturbed calibrations — which is what Tom Judd's
gendat3.py does when it reports "estimated final compass accuracy expressed in
degrees".
This should be the headline number. Four unlabelled percentages are not actionable; "heading accuracy ±1.4° (95%)" is, and it is the number that decides whether a tag is fit to deploy.
Accelerometer data
The cited reference,
juddzone.com — Precision 3D Compass Calibration,
does not propose rejecting samples on acceleration magnitude. What it
proposes is using the accelerometer as an additional constraint: the dot
product m_cal · a_hat is constant for a fixed site, and imposing that
constraint lets the fit resolve all nine elements of an asymmetric
transformation matrix instead of the six a symmetric one allows. That is a
solver change, and this proposal does not make it. The page's relevance here is
its remark that precise accelerometer data is required and "not always possible,
especially on a moving platform" — which is where a motion gate would come in.
Tested against the fixture, the acceleration-magnitude gate is not supported as a magnetometer-sample rejection criterion:
Gate on abs(\|a\| - 1 g) |
Samples kept | Regions covered | Robust residual spread |
|---|---|---|---|
| none | 775 (100%) | 94 / 100 | 0.82% |
| < 100 mg | 671 (87%) | 92 / 100 | 0.80% |
| < 50 mg | 399 (51%) | 89 / 100 | 0.80% |
| < 30 mg | 246 (32%) | 78 / 100 | 0.75% |
The correlation between abs(|a| - 1 g) and abs(|m_cal| - median) across the
capture is 0.051 — essentially none. Discarding 68% of the samples buys a
0.07-point improvement in residual spread and costs sixteen regions of
coverage. On this evidence a motion gate on the magnetometer sample is clearly
not worth its cost. (One capture, n = 775, from a CompassTagAT25 turned by hand
on a bench; this is a reason to leave the gate out of the first implementation,
not a settled result.)
The accelerometer is still worth having, in three other places:
-
Dip consistency as an independent metric. The angle between
m_calanda_hatshould equal90° - dipat every sample. Its spread across the buffer is a quality measure that does not come from the residual the solver minimises, so unlikeFitErrorit cannot be improved by over-fitting. This is Judd's constraint used as a diagnostic rather than as an objective, which keeps the solver untouched. It is the most valuable thing the accelerometer gives us within this scope. -
Gating that metric, not the sample. When
abs(|a| - 1 g)is large,a_hatis not gravity, so the dip residual for that sample is meaningless. Gate the dip metric on acceleration magnitude and keep the magnetometer sample in the fit. This is the clean resolution of the rejection question: the gate belongs on the metric that depends on gravity, not on the one that does not. -
Attitude coverage. Two samples can occupy the same magnetic patch at different tag attitudes. Binning on (magnetic patch × gravity patch) is a strictly stronger coverage statement than magnetic patch alone, and it is the coverage a 12-parameter fit would need if we ever adopt one.
Acceleration is not currently plumbed into the solver path at all:
MainWindow::processCalibrationSample() passes only the magnetometer vector to
CompassData::addData(). Carrying it alongside is a prerequisite for all three.
Proposed design
magquality — a new owned module
A context struct replaces the file-scope statics, so the module is testable, reentrant, and able to hold a second calibration for hold-out scoring.
typedef struct {
/* coverage */
uint16_t patch_count[MAGQ_PATCHES];
Point_t patch_sum[MAGQ_PATCHES];
uint16_t patches_occupied;
/* incremental second moment of unit-normalised samples */
float S[3][3];
/* residual accumulators */
float resid[MAGQ_MAX_SAMPLES];
uint16_t resid_n;
/* dip consistency, gated on |a| */
float dip_resid[MAGQ_MAX_SAMPLES];
uint16_t dip_n;
} MagQuality_t;
Patch scheme. Keep equal-area patches, but generate them from a Fibonacci
lattice rather than latitude bands: N points with z_k = 1 - (2k+1)/N and
lon_k = k * golden_angle. Nearest-centre lookup is a bounded search over the
few candidates in the neighbouring z slab, so it stays O(1) with a small
precomputed index. This removes the banding artefact at the band boundaries,
removes the hand-written region arithmetic that defect 1 lives in, makes N a
parameter instead of a constant woven through the code, and is derived from
published geometry rather than adapted from inherited source.
Reported metrics.
| Name | Units | Replaces |
|---|---|---|
| Heading accuracy (95%) | degrees | — (new headline) |
| Coverage | patches occupied / N, plus lambda_min/lambda_max |
quality_surface_gap_error |
| Residual spread (robust) | % of B | quality_magnitude_variance_error |
| Residual 95th percentile | % of B | — |
| Residual hard iron | µT | quality_wobble_error |
| Dip consistency | degrees | — (independent of the fit) |
| Fit error | % | quality_spherical_fit_error, unfloored |
Each gets a name, a unit, and a green/amber/red threshold in the UI rather than a bare percentage in a shared label.
Residual hard iron replaces wobble properly: take the patch-weighted centroid of unit-normalised calibrated samples — one vote per occupied patch, not per sample, so dense patches do not dominate — and report its norm in µT. For a correct calibration it is near zero, and unlike the present metric it does not cancel opposing errors.
This does not work, and what was built instead uses the magnitude. Assigning samples to patches by direction pins each patch's mean direction to that patch's centre, so the bias being looked for is averaged away: on a planted 2 µT offset the construction above reads 0.05. An offset
dputs a sample in directionuat|B + d.u|, so the magnitude residual isd.u, and regressing residual on direction across the occupied patches recoversditself — 1.97 on that offset, 0.00 with none, and unchanged by six thousand readings dwelling in one direction, which is the robustness the patch weighting was wanted for.
Retention policy
Replace both branches of choose_discard_magcal() with one rule drawn from
optimal experiment design. For design matrix D, the leverage of sample i is
h_i = d_i^T (D^T D)^-1 d_i
and removing row i scales det(D^T D) by (1 - h_i) (matrix determinant
lemma). So the least informative sample is the one with the lowest leverage —
which is precisely what the nearest-pair heuristic is reaching for, since a
sample with a close neighbour is redundant, but computed exactly and in O(p^2)
per candidate with p = 10, against O(N^2) for the scan it replaces. Maintain
(D^T D)^-1 by rank-1 update and downdate as samples enter and leave.
The same quantity gives the outlier test for free. An influential outlier is one with both high leverage and a large studentised residual; Cook's distance
C_i = (r_i^2 / (p * s^2)) * (h_i / (1 - h_i)^2)
combines them. The policy becomes:
- if any
C_iexceeds the outlier threshold, ejectargmax C_i; - otherwise eject
argmin h_i.
One framework replaces two unrelated heuristics, it runs at every sample count rather than only once coverage is good, and it needs no stale coverage figure.
Two guards against the feedback loop the original comment warns about:
- Patch floor. Never eject the last sample in an occupied patch. Coverage becomes an invariant the optimiser cannot trade away, rather than something the heuristic is hoped to produce.
- Probation. A newly added sample is not a candidate for ejection for the
next
kadditions, so a transient bad calibration cannot immediately purge the evidence that would correct it.
Plumbing
CompassData::addData() and MainWindow::processCalibrationSample() grow an
optional accelerometer argument, stored alongside each buffer entry. Nothing
else needs it; the solver continues to see magnetometer vectors only.
Evidence
All figures above come from the checked-in replay fixture
qtcalibrate-samples-20261010-145438.json
(775 samples from a CompassTagAT25, accelerometer in milli-g, B = 45.66 µT),
scored with the calibration stored in the same file. That capture agrees with
the magnetometer axis contract in
docs/shared/sensor-axes.md; the
fixture it replaced did not, which is why the figures here differ from earlier
drafts. Before implementation the same analysis
should be repeated over several captures taken with a known reference heading,
so that the heading-accuracy metric can be checked against ground truth rather
than against itself.
Phasing
| Phase | Work | Verifiable by |
|---|---|---|
| 1 | New magquality module with the context struct, Fibonacci patches, robust residual statistics, isotropy; computed in parallel with the existing metrics and logged, with nothing switched over |
Replay the fixture; both metric sets printed side by side |
| 2 | Plumb accelerometer through addData; add the gated dip-consistency metric |
Fixture replay shows a dip spread consistent with the site |
| 3 | Add (D^T D)^-1 maintenance and the leverage/Cook's-distance retention policy behind a flag, with the patch floor and probation guards |
Replay with both policies; compare final calibration and wall-clock cost |
| 3a | Fit the accelerometer zero-g offset from the same sweep; apply it when deriving dip and orientation; show it with the other constants. Host-side only | Replay the existing captures: offset near 77 mg, dip spread about 31 percent better |
| 4 | Heading-accuracy propagation; relabel the UI with named, united, thresholded metrics | Captures at known headings |
| 5 | Delete quality.c and choose_discard_magcal(); extend the capture quality block |
docs.py check; replay fixtures regenerate |
Where phase 3 stands
Built and flag-gated, off by default, as --leverage-retention. Measured on a
four-minute capture, about 2,400 samples and 1,760 evictions, replayed both
ways. The curves coincide exactly until the buffer fills at 650, which is the
control: the same code on the same data, so everything after it is real.
Steady state, over the last quarter of each run:
| inherited | leverage | delta | |
|---|---|---|---|
| direction spread | 0.906 ± 0.014 | 0.934 ± 0.021 | +0.028 |
| dip spread | 3.613 ± 0.101 deg | 3.307 ± 0.080 deg | −0.307 |
| attitude diversity | 2.440 ± 0.031 | 2.099 ± 0.024 | −0.342 |
| coverage | 100/100 | 100/100 | — |
The dip spread result is the one that carries weight, because dip is not a quantity the solver optimises and nothing in the leverage computation knows about gravity. A better direction spread could be the policy flattering the measure it was designed around; a better dip spread cannot be.
The improvement arrives only after coverage saturates. Split at the tick where coverage reaches 100/100, the dip spread delta is +0.021 while patches are still filling and −0.357 afterwards. While a new direction is still available, which sample is discarded hardly matters; once none is, the only lever left is which samples are kept. That is the opposite of what one might assume from the end numbers alone.
Two findings against the policy, both honest:
- Attitude diversity fell, and the metric is at fault rather than the
policy. It is a per-patch mean -- distinct gravity patches per occupied
magnetometer patch -- so it rewards uneven occupancy: a patch holding thirty
samples reaches more gravity bins than one holding six. Leverage spreads
samples more evenly, so each patch holds fewer. Direction spread rising and
attitude falling are one redistribution counted twice with opposite signs.
The sound measure is the total count of distinct (magnetometer, gravity)
cells, which
MagQualityalready computes and does not yet log. - The outlier threshold is wrong for a buffer this size. Cook's distance
scales with leverage, and with 650 samples the mean leverage is p/n = 0.015,
so the textbook
D > 1needs a 25-sigma residual -- about 9 uT, a fifth of the field. Nothing fired because nothing could. A five-sigma rule isD > 0.04. The right value wants measuring on a capture with real disturbances in it, not another guess.
One observation worth recording because it is easy to misread: the isotropy sag partway through a collection, from about 0.72 down to 0.50 and back, is not the discard policy. The first eviction on this fixture happens 86 percent of the way through, long after the sag, and both policies show it identically. It is the geometry of a partial sweep.
Phases 1–3 each leave the tree working with the old behaviour intact, so the switchover is a single reviewable commit at phase 5.
Licensing
quality.c carries a copyright notice with no licence grant, which is the
reason this replacement is worth doing independently of its technical merits.
The replacement should be written from the published geometry — the spherical
cap area relation, the Fibonacci lattice construction, and the standard
leverage and Cook's-distance definitions — and not adapted line by line from
the inherited file. Choosing a different patch construction rather than
re-deriving the latitude-band one makes that separation easy to demonstrate.
magcal.c and matrix.c keep their Freescale BSD-3-Clause headers and are
untouched.
This is an engineering judgement, not legal advice; if the ambiguity matters for distribution it should be confirmed with counsel.
Calibrating the accelerometer
The dip spread has a floor of about 3.5 degrees that is flat across every rotation rate, so nothing about how the tag is turned explains it. The accelerometer does.
At rest in any orientation an accelerometer reads gravity, so a calibration sweep traces a sphere of radius one g. Fitting that sphere on the reference captures puts its centre 77 mg from the origin, dominated by −71 mg on z — the axis perpendicular to the board. The LIS2DU12 datasheet gives ±11 mg typical after factory trim, so this is seven times out of family, but that figure is measured on the bare part: soldering it to a board adds package stress, and z is the axis that stress acts on. Post-assembly offsets well beyond the trimmed figure are the expected outcome, not a defective part.
A fixed offset in the tag frame produces a tilt error that varies with orientation — maximal when the offset is perpendicular to gravity, zero when parallel — so it appears as scatter rather than bias, and has no reason to care how fast the tag is turning. That is the observed signature exactly. The implied RMS tilt error is 3.1 degrees against an observed floor of 3.5.
Running the fit over both captures, with the gating described below:
| 775-sample capture | 2,425-sample capture | |
|---|---|---|
| recovered offset | 78.2 mg | 75.9 mg |
| fitted radius | 995.0 mg | 993.7 mg |
| dip spread before | 4.18 deg | 3.67 deg |
| dip spread after | 2.88 deg | 2.54 deg |
| improvement | 31% | 31% |
The two agree to 2.3 mg, which is what makes the offset worth storing: it is a stable property of the tag, not noise.
Why a sphere, and why no buffer
Four parameters, not nine. Adding per-axis scale terms took the same captures from 30 to 34 percent, which does not pay for the machinery. The fitted radius lands within one percent of one g, so the sensitivity constant is already right and only the offset is worth removing.
No buffer and no retention policy. A sphere fit is linear least squares, so the entire state is ten running sums; each sample is folded in and discarded in constant time. The magnetometer needs a buffer because it refits ten parameters repeatedly as the solution improves and must be able to remove a sample again. Gravity has constant magnitude by definition, and four parameters determine it.
Two passes. The gate cannot be tight on the first pass, because the offset being estimated is itself several percent of one g and a tight gate would reject orientations rather than motion. So the first pass gates at 15 percent, and the second re-gates at 8 percent on the magnitude after correction, which removes the circularity of gating on the quantity being estimated. On the reference captures that moves the estimate from 71 mg to 78 mg.
What it does not fix
Dip spread lands at 2.5 degrees, not the 1 degree a pure-offset model predicts, so roughly 2.5 degrees remains unexplained — and the per-axis scale result says it is not accelerometer linearity. The inclination also stays at 63.6 degrees against the World Magnetic Model's 66 to 67, unmoved by the correction, so that gap is a separate matter.
Scope
Superseded 2026-10-10: the offset is now written to the tag as
CalibrationConstants.accelerometer and applied by SensorViz; see
decision 0027
for the storage layout.
Host-side for now. The constants are fitted during a sweep, applied when
deriving dip and orientation, shown with the other calibration constants and
recorded in the capture, but not written to the tag: storing them there is a
firmware change and CalibrationConstants would need an AccelConstants
field, which is additive but is a contract both sides compile against.
One consequence worth stating. The dip metric is recomputed from the buffer on
every quality tick, so it tracks both calibrations as they evolve and tightens
visibly during a sweep. That does not cost its independence: the accelerometer
fit minimises deviation of |a| from a sphere and the magnetometer fit
minimises deviation of |m|, and neither optimises the angle between them. Dip
spread remains a quantity no solver in the system is targeting. It does mean an
early reading is not comparable with a late one, so the value recorded in a
capture should be a final pass taken after collection stops.
The calibration environment is not controllable
Calibration happens with the tag tethered to a computer over USB, because that is how samples reach the host. The computer, its supply and the cable sit within centimetres of the magnetometer, and the room is whatever room the user is in. Users are not ours to instruct. This is a constraint to design around, not a problem with a fix.
It is measurable. On the reference captures the fitted field is 12.0 and 12.3 percent below the World Magnetic Model value for the site (45.66 and 45.53 against 51.9 uT), and the inclination is about 3 degrees shallow (63.6 against 66 to 67). Both captures agree with each other to 0.3 percent in field and 0.16 degrees in dip, so the bench is repeatable even though it is wrong in absolute terms.
What that does and does not cost:
- Heading is unaffected by a uniform error. It depends on the direction of
the calibrated vector, not its length, so a field read 12 percent low gives
the same heading.
Bis the one constant that comes out as a measurement of the room rather than of the Earth, and nothing downstream of heading uses it beyond the solver's 22-67 uT sanity range, which 45.7 passes. - Distortion fixed relative to the tag is calibrated out, correctly. That is what hard and soft iron terms are for, and a cable routed the same way every time is indistinguishable from the tag's own iron. It transfers to the field with the tag.
- Distortion that changes as the tag moves cannot be calibrated out. A laptop that stays put while the tag is rotated through the space beside it presents a different field at each orientation. That smears the sphere, and it is the irreducible part.
The consequence for this proposal is that the metrics fall into two classes, and they cannot carry the same kind of threshold:
| Class | Metrics | What a threshold means |
|---|---|---|
| Portable | coverage, direction spread | Operator technique. A number means the same on any bench, because it describes how the tag was turned. |
| Environment limited | dip spread, residual spread | The bench. Their floor is set by the room and the cable, not by the tag, so the same tag scores differently in two places. |
So a fixed pass/fail on dip spread is not portable, however convenient it would be. What is defensible: flag the portable metrics against absolute thresholds, report the environment-limited ones as numbers to compare against that user's own previous captures, and reserve hard failures for the cases that are unambiguous anywhere -- no coverage, or a design the data cannot determine.
Two cheap checks remain available to anyone who wants them, neither needing a
field trip. Repeating a capture in a different spot in the same room separates
a uniform local field, which is harmless, from one that varies across the
sweep, which is not. And comparing fitted B against the World Magnetic Model
for the site says how disturbed the bench is, as a one-off sanity check rather
than something to wire into the app -- indoors it would report a large residual
that is real and has nothing to do with the sensor.
Open questions
- What heading accuracy counts as good enough for a deployed songbird tag? Provisionally: under 2 degrees is good enough, and under 1 degree is at the limit of what most eCompasses achieve -- so 1 degree is the floor worth designing against rather than a target to chase. This is judgement, not measurement; it needs experiment before it hardens into a pass/fail threshold in the UI. Until then the metrics are reported without a verdict, which is the honest presentation of a number whose acceptable range nobody has established yet.
The dip spread is a weaker guide than it first looks. It is an upper bound
on heading error rather than an estimate, since it carries calibration
error, accelerometer error and sample timing together -- and, as the section
above sets out, the bench as well. A spread of 3 to 4 degrees on a tethered
indoor capture is mostly a statement about the room. Answering this question
needs a measurement that does not inherit the calibration environment.
2. Is MAGBUFFSIZE = 650 still the right buffer size once retention is
principled? A smaller, better-chosen set may fit as well.
3. How many patches? 100 is inherited. With a Fibonacci lattice, N becomes a
tuning parameter, and the right value depends on how many samples an
operator can realistically collect.
4. Should the hold-out residual run on the UI timer, or only when the operator
asks for a quality report?
5. Does the 12-parameter asymmetric fit justify its own proposal? The dip
consistency metric from phase 2 is the evidence that would decide it: a
persistent dip spread that the symmetric fit cannot reduce is the signature
of a transformation matrix that needs its off-diagonal terms freed.