Skip to main content

calibsession/
lib.rs

1//! The live tag calibration, and the compass angles every CompassTag display
2//! shows, through the C++ core.
3//!
4//! [`Session`] is a safe wrapper over the C interface in
5//! `host/libraries/tagcore-c/calibsession_c.h`, which wraps sensoranalysis's
6//! Qt-free `calibcore::CalibrationSession` -- the same code qtcalibrate's
7//! `CompassData` now runs. So a Rust program calibrates exactly as qtcalibrate
8//! does: Freescale's solver, eviction by leverage and outliers, the
9//! accelerometer's own fit, the quality figures and the filtered live compass.
10//! [`display_angles`] and [`model_rotation`] are the conversions qtcalibrate's
11//! displays apply to an orientation.
12//!
13//! ```no_run
14//! use calibsession::Session;
15//! # let samples: Vec<([f32; 3], Option<[f32; 3]>)> = vec![];
16//! let mut session = Session::new()?;
17//! for (i, (mag, accel)) in samples.iter().enumerate() {
18//!     if session.add_sample(*mag, *accel) {
19//!         // the solver accepted a new fit
20//!     }
21//!     if i % 2 == 1 {
22//!         session.update_quality(); // the caller's tick
23//!     }
24//! }
25//! let quality = session.quality();
26//! # Ok::<(), calibsession::SessionError>(())
27//! ```
28//!
29//! Units are the calibration stream's: magnetometer in µT, accelerometer in
30//! mg. Quaternions are `[w, x, y, z]`.
31//!
32//! # Linking
33//!
34//! As for `taglogwriter`: `tagcore-c-sys` links `libtagcore_c`, found through
35//! `TAGCORE_C_LIB_DIR`, statically on macOS and shared on Linux and Windows.
36//! It is the one crate that declares `links = "tagcore_c"`, so a program may
37//! use this crate beside the other wrappers over the library.
38//!
39//! # Safety model
40//!
41//! All `unsafe` is in this file. A [`Session`] owns exactly one C handle,
42//! never null, freed exactly once in `Drop`. Every pointer passed in points
43//! at Rust memory that lives until the call returns, sized as the header
44//! requires; the library keeps no pointer after a call. Output structs are
45//! `#[repr(C)]` mirrors of the header's, zero-initialized before the call
46//! fills them. A session may move between threads but not be shared:
47//! `Session` is `Send` and not `Sync`, and every call that advances it takes
48//! `&mut self`.
49//!
50//! # Maintaining
51//!
52//! The declarations, imported here as `ffi`, are `tagcore_c_sys::tcs`
53//! (`host/libraries/tagcore-c-sys/src/tcs.rs`), written by hand from
54//! `calibsession_c.h`. When the
55//! header changes, change the declaration and the safe method over it; if a
56//! signature, meaning or struct layout changed, the header raises
57//! `TCS_ABI_VERSION`, and `ffi::TCS_ABI_VERSION` must follow.
58//! [`Session::new`] refuses a library built from another version.
59
60use std::fmt;
61use std::ptr::NonNull;
62
63/// The raw C interface, in `tagcore-c-sys`, which links the library.
64use tagcore_c_sys::tcs as ffi;
65
66
67/// Buffer slots in a session: [`Session::points`] never returns more.
68pub const SLOTS: usize = 650;
69
70/// Why a session could not be made.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub enum SessionError {
73    /// The library was built from another version of `calibsession_c.h`.
74    AbiMismatch {
75        /// The version this crate expects.
76        expected: u32,
77        /// The version the library reports.
78        found: u32,
79    },
80    /// The library could not allocate a session.
81    OutOfMemory,
82}
83
84impl fmt::Display for SessionError {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        match self {
87            SessionError::AbiMismatch { expected, found } => write!(
88                f,
89                "libtagcore_c's calibration interface is version {found}, this program needs {expected}; \
90                 rebuild them together"
91            ),
92            SessionError::OutOfMemory => write!(f, "out of memory creating a calibration session"),
93        }
94    }
95}
96
97impl std::error::Error for SessionError {}
98
99/// The magnetometer calibration constants.
100#[derive(Debug, Clone, Copy, PartialEq)]
101pub struct Constants {
102    /// Whether a calibration exists (fitted or loaded). The values are the
103    /// identity and 50 µT when it does not.
104    pub valid: bool,
105    /// Field magnitude B, µT.
106    pub field: f32,
107    /// Hard-iron offset V, µT.
108    pub offset: [f32; 3],
109    /// Inverse soft-iron matrix A, row-major.
110    pub mapping: [[f32; 3]; 3],
111}
112
113/// MagQuality's figures; see `magquality_core.h` for what each means.
114#[derive(Debug, Clone, Copy, PartialEq, Default)]
115pub struct Quality {
116    /// Occupied magnetometer patches.
117    pub patches_seen: i32,
118    /// Patches available.
119    pub mag_patches: i32,
120    /// patches_seen / mag_patches.
121    pub coverage: f32,
122    /// Samples in the emptiest occupied patch.
123    pub patch_min: i32,
124    /// Samples in the fullest patch.
125    pub patch_max: i32,
126    /// Direction evenness, 0 to 1.
127    pub isotropy: f32,
128    /// Gravity patches per occupied magnetometer patch.
129    pub attitude_diversity: f32,
130    /// Distinct (magnetometer, gravity) cells.
131    pub attitude_cells: i32,
132    /// Magnetometer patches with a gravity observation.
133    pub attitude_patches: i32,
134    /// Median calibrated magnitude, µT.
135    pub field: f32,
136    /// RMS residual, percent of the field.
137    pub fit_error: f32,
138    /// Robust residual spread, percent of the field.
139    pub residual_spread: f32,
140    /// 95th percentile residual, percent of the field.
141    pub residual_p95: f32,
142    /// Hard iron the fit left, µT.
143    pub residual_hard_iron: f32,
144    /// Median inclination, degrees.
145    pub dip_mean_deg: f32,
146    /// Robust inclination spread, degrees.
147    pub dip_spread_deg: f32,
148    /// 95th percentile inclination deviation, degrees.
149    pub dip_p95_deg: f32,
150    /// Samples that passed the accelerometer gate.
151    pub dip_samples: i32,
152    /// Samples offered with an accelerometer.
153    pub accel_samples: i32,
154    /// Whether the dip figures exist.
155    pub have_accel_metrics: bool,
156    /// Samples accepted.
157    pub samples: i32,
158}
159
160/// The accelerometer offset in force: the live fit, or one loaded from a tag.
161#[derive(Debug, Clone, Copy, PartialEq, Default)]
162pub struct AccelFit {
163    /// Whether offset, radius and residual are a fit or a loaded offset.
164    pub valid: bool,
165    /// Zero-g offset, mg.
166    pub offset: [f32; 3],
167    /// Fitted sphere radius, mg; 0 when loaded.
168    pub radius: f32,
169    /// RMS distance from the sphere, mg.
170    pub residual: f32,
171    /// Readings the fit was built from.
172    pub samples: i32,
173    /// Occupied direction patches.
174    pub patches_seen: i32,
175    /// Patches in the lattice.
176    pub patches: i32,
177    /// patches_seen / patches.
178    pub coverage: f32,
179    /// Direction evenness, 0 to 1.
180    pub isotropy: f32,
181    /// Readings offered since the last clear.
182    pub offered: i32,
183    /// Of those, rejected as motion.
184    pub gated: i32,
185}
186
187/// Evictions from the full magnetometer buffer since the last clear.
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
189pub struct Evictions {
190    /// All evictions, including the random fallback's.
191    pub total: i32,
192    /// Decided as the least informative sample.
193    pub leverage: i32,
194    /// Decided as an outlier.
195    pub outlier: i32,
196}
197
198/// One solved compass orientation.
199#[derive(Debug, Clone, Copy, PartialEq, Default)]
200pub struct Orientation {
201    /// Magnetic-frame orientation, `[w, x, y, z]`.
202    pub q: [f32; 4],
203    /// Dip angle, degrees.
204    pub dip: f32,
205    /// Field magnitude after calibration, µT.
206    pub field: f32,
207    /// Acceleration magnitude, mg.
208    pub mg: f32,
209}
210
211/// Display angles of an orientation, before declination and battery direction.
212#[derive(Debug, Clone, Copy, PartialEq, Default)]
213pub struct Angles {
214    /// Magnetic heading, degrees clockwise from north, [0, 360).
215    pub yaw: f64,
216    /// About the tag x axis, degrees.
217    pub pitch: f64,
218    /// About the tag y axis, degrees.
219    pub roll: f64,
220}
221
222/// One live calibration: the sample buffer, the solver, retention, the
223/// accelerometer fit, the quality figures and the filtered compass.
224pub struct Session {
225    /// The C handle; never null, freed in `Drop`.
226    ptr: NonNull<ffi::TcsSession>,
227}
228
229// SAFETY: the handle owns its memory and has no thread affinity; the C
230// interface only forbids concurrent use, which `!Sync` and `&mut self`
231// already prevent.
232unsafe impl Send for Session {}
233
234impl Session {
235    /// Creates an empty session.
236    ///
237    /// # Errors
238    ///
239    /// [`SessionError::AbiMismatch`] when the linked library was built from
240    /// another version of the header; [`SessionError::OutOfMemory`] when it
241    /// cannot allocate.
242    pub fn new() -> Result<Session, SessionError> {
243        // SAFETY: takes nothing, returns a constant.
244        let found = unsafe { ffi::tcs_abi_version() };
245        if found != ffi::TCS_ABI_VERSION {
246            return Err(SessionError::AbiMismatch {
247                expected: ffi::TCS_ABI_VERSION,
248                found,
249            });
250        }
251        // SAFETY: takes nothing; returns an owned handle or null.
252        let ptr = NonNull::new(unsafe { ffi::tcs_new() }).ok_or(SessionError::OutOfMemory)?;
253        Ok(Session { ptr })
254    }
255
256    /// Discards every sample, the fit, the accelerometer fit and any loaded
257    /// offset; the field returns to 50 µT. The live compass filter is kept.
258    pub fn clear(&mut self) {
259        // SAFETY: a live handle, used from one thread.
260        unsafe { ffi::tcs_clear(self.ptr.as_ptr()) }
261    }
262
263    /// Feeds one calibration sample: raw magnetometer, µT, and the
264    /// accelerometer reading, mg, when the sample has one.
265    ///
266    /// Returns true when the solver accepted a new fit on this sample.
267    pub fn add_sample(&mut self, mag: [f32; 3], accel: Option<[f32; 3]>) -> bool {
268        let a = accel.unwrap_or_default();
269        // SAFETY: a live handle; both arrays hold three floats and outlive
270        // the call.
271        unsafe {
272            ffi::tcs_add_sample(
273                self.ptr.as_ptr(),
274                mag.as_ptr(),
275                accel.is_some().into(),
276                a.as_ptr(),
277            ) != 0
278        }
279    }
280
281    /// Whether a magnetometer calibration exists (fitted or loaded).
282    pub fn has_calibration(&self) -> bool {
283        // SAFETY: a live handle.
284        unsafe { ffi::tcs_has_calibration(self.ptr.as_ptr()) != 0 }
285    }
286
287    /// Applies the current magnetometer calibration, `A (mag - V)`; the
288    /// identity before a fit.
289    pub fn calibrate(&self, mag: [f32; 3]) -> [f32; 3] {
290        let mut out = [0.0f32; 3];
291        // SAFETY: a live handle; three floats in, room for three out.
292        unsafe { ffi::tcs_calibrate(self.ptr.as_ptr(), mag.as_ptr(), out.as_mut_ptr()) };
293        out
294    }
295
296    /// Every buffered reading, calibrated, in slot order; at most [`SLOTS`].
297    pub fn points(&self) -> Vec<[f32; 3]> {
298        let mut xyz = vec![0.0f32; 3 * SLOTS];
299        // SAFETY: a live handle; `xyz` has room for SLOTS points, and the
300        // library writes at most `max` of them.
301        let n = unsafe { ffi::tcs_points(self.ptr.as_ptr(), xyz.as_mut_ptr(), SLOTS) }.min(SLOTS);
302        xyz.as_chunks::<3>().0.iter().take(n).copied().collect()
303    }
304
305    /// The current magnetometer constants.
306    pub fn constants(&self) -> Constants {
307        let mut field = 0.0f32;
308        let mut offset = [0.0f32; 3];
309        let mut a = [0.0f32; 9];
310        // SAFETY: a live handle; one float, three and nine of room.
311        let valid = unsafe {
312            ffi::tcs_constants(
313                self.ptr.as_ptr(),
314                &mut field,
315                offset.as_mut_ptr(),
316                a.as_mut_ptr(),
317            )
318        } != 0;
319        Constants {
320            valid,
321            field,
322            offset,
323            mapping: [[a[0], a[1], a[2]], [a[3], a[4], a[5]], [a[6], a[7], a[8]]],
324        }
325    }
326
327    /// Installs magnetometer constants read back from a tag. The field is
328    /// recomputed from the buffered readings, or kept when there are none.
329    pub fn set_constants(&mut self, offset: [f32; 3], mapping: [[f32; 3]; 3]) {
330        let a: Vec<f32> = mapping.iter().flatten().copied().collect();
331        // SAFETY: a live handle; three and nine floats that outlive the call.
332        unsafe { ffi::tcs_set_constants(self.ptr.as_ptr(), offset.as_ptr(), a.as_ptr()) }
333    }
334
335    /// The field magnitude B, µT.
336    pub fn field(&self) -> f32 {
337        // SAFETY: a live handle.
338        unsafe { ffi::tcs_field(self.ptr.as_ptr()) }
339    }
340
341    /// Installs an accelerometer offset read back from a tag, mg. It stands
342    /// until a live fit is valid or the session is cleared.
343    pub fn set_accel_offset(&mut self, offset: [f32; 3]) {
344        // SAFETY: a live handle; three floats.
345        unsafe { ffi::tcs_set_accel_offset(self.ptr.as_ptr(), offset.as_ptr()) }
346    }
347
348    /// Recomputes the quality figures and the accelerometer fit. A valid fit
349    /// also tightens the accelerometer's intake gate, so how often this runs
350    /// affects later samples: qtcalibrate's replay runs it every second
351    /// sample.
352    pub fn update_quality(&mut self) {
353        // SAFETY: a live handle.
354        unsafe { ffi::tcs_update_quality(self.ptr.as_ptr()) }
355    }
356
357    /// The quality figures as of the last [`Session::update_quality`].
358    pub fn quality(&self) -> Quality {
359        let mut q = ffi::TcsQuality::default();
360        // SAFETY: a live handle; `q` is a repr(C) mirror of tcs_quality.
361        unsafe { ffi::tcs_get_quality(self.ptr.as_ptr(), &mut q) };
362        Quality {
363            patches_seen: q.patches_seen,
364            mag_patches: q.mag_patches,
365            coverage: q.coverage,
366            patch_min: q.patch_min,
367            patch_max: q.patch_max,
368            isotropy: q.isotropy,
369            attitude_diversity: q.attitude_diversity,
370            attitude_cells: q.attitude_cells,
371            attitude_patches: q.attitude_patches,
372            field: q.field,
373            fit_error: q.fit_error,
374            residual_spread: q.residual_spread,
375            residual_p95: q.residual_p95,
376            residual_hard_iron: q.residual_hard_iron,
377            dip_mean_deg: q.dip_mean_deg,
378            dip_spread_deg: q.dip_spread_deg,
379            dip_p95_deg: q.dip_p95_deg,
380            dip_samples: q.dip_samples,
381            accel_samples: q.accel_samples,
382            have_accel_metrics: q.have_accel_metrics != 0,
383            samples: q.samples,
384        }
385    }
386
387    /// The accelerometer offset in force.
388    pub fn accel_fit(&self) -> AccelFit {
389        let mut a = ffi::TcsAccelFit::default();
390        // SAFETY: a live handle; `a` is a repr(C) mirror of tcs_accel_fit.
391        unsafe { ffi::tcs_get_accel_fit(self.ptr.as_ptr(), &mut a) };
392        AccelFit {
393            valid: a.valid != 0,
394            offset: a.offset,
395            radius: a.radius,
396            residual: a.residual,
397            samples: a.samples,
398            patches_seen: a.patches_seen,
399            patches: a.patches,
400            coverage: a.coverage,
401            isotropy: a.isotropy,
402            offered: a.offered,
403            gated: a.gated,
404        }
405    }
406
407    /// Evictions since the last clear.
408    pub fn evictions(&self) -> Evictions {
409        let mut out = [0; 3];
410        // SAFETY: a live handle; room for three ints.
411        unsafe { ffi::tcs_evictions(self.ptr.as_ptr(), out.as_mut_ptr()) };
412        Evictions {
413            total: out[0],
414            leverage: out[1],
415            outlier: out[2],
416        }
417    }
418
419    /// Solves the live compass from one raw sample, through the session's
420    /// low-pass filter, which this advances.
421    ///
422    /// Returns `None` when the filtered pair admits no orientation.
423    pub fn orientation(&mut self, mag: [f32; 3], accel: [f32; 3]) -> Option<Orientation> {
424        let mut o = ffi::TcsOrientation::default();
425        // SAFETY: a live handle; three floats each in; `o` is a repr(C)
426        // mirror of tcs_orientation.
427        let ok = unsafe {
428            ffi::tcs_orientation_of(self.ptr.as_ptr(), mag.as_ptr(), accel.as_ptr(), &mut o)
429        } != 0;
430        ok.then_some(Orientation {
431            q: o.q,
432            dip: o.dip,
433            field: o.field,
434            mg: o.mg,
435        })
436    }
437}
438
439impl Drop for Session {
440    fn drop(&mut self) {
441        // SAFETY: the handle is live and owned by this value alone; it is
442        // freed exactly once, here.
443        unsafe { ffi::tcs_free(self.ptr.as_ptr()) }
444    }
445}
446
447/// Display angles of an orientation quaternion `[w, x, y, z]`: the heading,
448/// pitch and roll every CompassTag display shows, before declination and
449/// battery direction.
450pub fn display_angles(q: [f32; 4]) -> Angles {
451    let mut a = ffi::TcsAngles::default();
452    // SAFETY: four floats in; `a` is a repr(C) mirror of tcs_angles.
453    unsafe { ffi::tcs_display_angles(q.as_ptr(), &mut a) };
454    Angles {
455        yaw: a.yaw,
456        pitch: a.pitch,
457        roll: a.roll,
458    }
459}
460
461/// The rotation qtcalibrate applies to its tag model, `Rz(yaw) * Ry(roll) *
462/// Rx(pitch)`, degrees, yaw counter-clockwise; `[w, x, y, z]`.
463pub fn model_rotation(pitch: f32, roll: f32, yaw: f32) -> [f32; 4] {
464    let mut q = [0.0f32; 4];
465    // SAFETY: room for four floats.
466    unsafe { ffi::tcs_model_rotation(pitch, roll, yaw, q.as_mut_ptr()) };
467    q
468}