Files
kaptan/firmware/libs/kaptan-navigation/src/flight.rs
T
Onur Özkan f9a61a086f feat(firmware): validate configuration and migrate sensor-driven flight APIs
Validate configuration through its owning schemas and keep board
declarations separate from shared F722 resource ownership. Bound USB
transitions, isolate transport failures and retain restart-only activation.

Migrate navigation and flight consumers with the sensor and schema
contracts. Require explicit installation data and synchronous output
authority; board motor outputs remain inhibited.

Validation: assembled-prefix host tests, strict Clippy, platform/USB tests
and both ARM release builds. Final-source coverage and scoped Miri/Kani
evidence are retained separately from hardware qualification.
2026-11-07 18:12:11 +03:00

869 lines
31 KiB
Rust

//! Flight observation, uncertainty and bounded execution contracts.
use crate::*;
use kaptan_math::{Frd, Ned, UnitQuaternion, Vector3};
use kaptan_time::Timestamp;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct OriginId(pub u32);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SensorReferenceId(pub u32);
/// Mean calibrated force/rate over the declared preceding acquisition window.
/// Interior propagation reconstructs that window with its constant average.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FlightImuObservation {
pub context: ObservationContext,
pub acceleration: AccelerationFrd,
pub angular_rate: AngularRateFrd,
/// Positive duration in the endpoint clock, bounded by the configured IMU
/// interval. Both window endpoints must share the same timing model.
pub acquisition_duration_us: u32,
}
impl FlightImuObservation {
pub(crate) fn acquisition_start(self) -> Option<u64> {
if self.acquisition_duration_us == 0 {
return None;
}
self.context
.measurement_time
.as_microseconds()
.checked_sub(u64::from(self.acquisition_duration_us))
}
}
/// Timing of the latest valid admitted IMU window, possibly not yet propagated.
/// This historical snapshot does not establish freshness, estimator health or
/// output authority. It may remain available after a numerical failure.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ImuCoverage {
pub end: Timestamp,
pub acquisition_duration_us: u32,
pub time_uncertainty_microseconds: u32,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MagneticObservation {
pub context: ObservationContext,
pub microtesla_frd: Vector3<Frd>,
pub variance_microtesla_squared: [ScalarVariance; 3],
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PressureObservation {
pub context: ObservationContext,
pub pascals: f32,
pub variance_pascals_squared: ScalarVariance,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum SensorRecord {
Imu(FlightImuObservation),
LocalPositionVelocity(LocalPositionVelocityObservation),
Magnetic(MagneticObservation),
Pressure(PressureObservation),
Range(RangeObservation),
}
impl SensorRecord {
pub const fn context(self) -> ObservationContext {
match self {
Self::Imu(v) => v.context,
Self::LocalPositionVelocity(v) => v.context,
Self::Magnetic(v) => v.context,
Self::Pressure(v) => v.context,
Self::Range(v) => v.context,
}
}
pub const fn kind(self) -> u8 {
match self {
Self::Imu(_) => 1,
Self::LocalPositionVelocity(_) => 2,
Self::Magnetic(_) => 3,
Self::Pressure(_) => 4,
Self::Range(_) => 5,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(u16)]
pub enum ObservationRejection {
WrongEpoch = 1,
InvalidTime = 2,
TooOld = 3,
Replay = 4,
SourceMismatch = 5,
FrameMismatch = 6,
CalibrationMismatch = 7,
InvalidValue = 8,
InvalidQuality = 9,
QueueFull = 10,
RateExceeded = 11,
BehindFusionHorizon = 12,
ReferenceMismatch = 13,
Terminated = 14,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ObservationAdmission {
Queued,
Rejected(ObservationRejection),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum NavigationFailure {
ImuGap,
QueueOverrun,
WorkBudget,
Numerical,
RequiredCapabilityLost,
}
impl From<kaptan_math::MathError> for NavigationFailure {
fn from(_: kaptan_math::MathError) -> Self {
Self::Numerical
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SourceStatus {
Unknown,
Healthy,
Stale,
Isolated,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SensorStatus {
pub kind: u8,
pub source: SourceId,
pub integrity: SourceStatus,
pub last_received_measurement: Option<Timestamp>,
pub last_fused_measurement: Option<Timestamp>,
pub queued: u16,
pub rejected: u32,
pub fused: u32,
pub dropped: u32,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LocalOrigin {
pub id: OriginId,
pub established_at: Timestamp,
pub reference: SensorReferenceId,
pub launch_in_reference: PositionNed,
pub launch_variance_m2: [f32; 3],
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct CapabilityEvidence {
pub available: bool,
pub last_measurement_at: Option<Timestamp>,
pub valid_until: Option<Timestamp>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct FlightCapabilities {
pub tilt: CapabilityEvidence,
pub heading: CapabilityEvidence,
pub horizontal_position: CapabilityEvidence,
pub horizontal_velocity: CapabilityEvidence,
pub height: CapabilityEvidence,
pub vertical_velocity: CapabilityEvidence,
pub ground_clearance: CapabilityEvidence,
}
impl FlightCapabilities {
pub fn flight_ready(self) -> bool {
self.tilt.available
&& self.heading.available
&& self.horizontal_position.available
&& self.horizontal_velocity.available
&& self.height.available
&& self.vertical_velocity.available
&& self.ground_clearance.available
}
pub fn valid_until(self) -> Option<Timestamp> {
if !self.flight_ready() {
return None;
}
[
self.tilt,
self.heading,
self.horizontal_position,
self.horizontal_velocity,
self.height,
self.vertical_velocity,
self.ground_clearance,
]
.into_iter()
.map(|v| v.valid_until)
.try_fold(Timestamp::from_microseconds(u64::MAX), |a, b| {
Some(a.min(b?))
})
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GroundClearance {
pub measurement_at: Timestamp,
pub valid_until: Timestamp,
pub source: SourceId,
pub body_origin_height_metres: f32,
pub contact_clearance_metres: f32,
pub variance_m2: f32,
pub measurement_time_uncertainty_microseconds: u32,
pub upper_error_metres: f32,
}
impl GroundClearance {
/// Bound current clearance using current foot orientation and velocity
/// confidence. `upper_error_metres` bounds measured COM height, not a foot
/// projection frozen at the range timestamp.
pub fn upper_at(
self,
now: Timestamp,
estimate: &FlightEstimate,
contact: ContactGeometry,
) -> Option<f32> {
let speed_bound =
estimate.velocity_error_bound().norm() + estimate.velocity.vector().norm();
let angle_bound = estimate.attitude_error_bound().norm();
if now >= self.valid_until
|| estimate.at != now
|| !speed_bound.is_finite()
|| !angle_bound.is_finite()
|| !contact.validate()
{
return None;
}
let earliest = self
.measurement_at
.as_microseconds()
.checked_sub(u64::from(self.measurement_time_uncertainty_microseconds))?;
let age = now.as_microseconds().checked_sub(earliest)? as f32 * 1e-6;
// Configuration checks the vehicle's free-flight acceleration bound.
let feet = contact.depth(estimate.attitude);
let upper = self.body_origin_height_metres
+ self.upper_error_metres
+ speed_bound * age
+ 0.5 * MAXIMUM_FREE_ACCELERATION_MPS2 * age * age
- feet
+ contact.rotation_radius_bound() * angle_bound;
upper.is_finite().then_some(upper)
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FlightEstimate {
pub at: Timestamp,
pub fused_through: Timestamp,
/// Latest complete IMU acquisition-window end, not its start or receipt.
pub last_imu_at: Timestamp,
pub origin: LocalOrigin,
pub attitude: UnitQuaternion<Frd, Ned>,
pub position: PositionNed,
pub velocity: VelocityNed,
/// Bias-corrected average of the latest complete declared window. This is not
/// an instantaneous angular-rate measurement at `at`.
pub angular_rate: AngularRateFrd,
pub position_variance_m2: [f32; 3],
pub velocity_variance_m2ps2: [f32; 3],
pub attitude_variance_rad2: [f32; 3],
pub timing_error_bound: TimingErrorBounds,
pub capabilities: FlightCapabilities,
pub ground_clearance: Option<GroundClearance>,
}
/// Deterministic absolute error radii from timing, separate from Gaussian P.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TimingErrorBounds {
pub position_metres: [f32; 3],
pub velocity_mps: [f32; 3],
pub attitude_radians: [f32; 3],
}
impl TimingErrorBounds {
pub const ZERO: Self = Self {
position_metres: [0.0; 3],
velocity_mps: [0.0; 3],
attitude_radians: [0.0; 3],
};
}
impl FlightEstimate {
pub fn velocity_error_bound(&self) -> Vector3<Ned> {
let e: [f32; 3] = core::array::from_fn(|i| {
3.0 * kaptan_math::sqrt(self.velocity_variance_m2ps2[i])
+ self.timing_error_bound.velocity_mps[i]
});
Vector3::new(e[0], e[1], e[2])
}
pub fn attitude_error_bound(&self) -> Vector3<Ned> {
let e: [f32; 3] = core::array::from_fn(|i| {
3.0 * kaptan_math::sqrt(self.attitude_variance_rad2[i])
+ self.timing_error_bound.attitude_radians[i]
});
Vector3::new(e[0], e[1], e[2])
}
}
/// Four equal contact spheres at the corners of a body-FRD rectangle.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ContactGeometry {
pub half_span_x_metres: f32,
pub half_span_y_metres: f32,
pub centre_z_metres: f32,
pub radius_metres: f32,
}
impl ContactGeometry {
pub(crate) fn validate(self) -> bool {
self.half_span_x_metres.is_finite()
&& self.half_span_x_metres > 0.0
&& self.half_span_y_metres.is_finite()
&& self.half_span_y_metres > 0.0
&& self.centre_z_metres.is_finite()
&& self.radius_metres.is_finite()
&& self.radius_metres >= 0.0
&& (self.rotation_radius_bound() + self.radius_metres).is_finite()
}
fn rotation_radius_bound(self) -> f32 {
l1_bound(Vector3::new(
self.half_span_x_metres,
self.half_span_y_metres,
self.centre_z_metres,
))
}
pub(crate) fn depth(self, q: UnitQuaternion<Frd, Ned>) -> f32 {
let mut depth = f32::NEG_INFINITY;
for x in [-self.half_span_x_metres, self.half_span_x_metres] {
for y in [-self.half_span_y_metres, self.half_span_y_metres] {
let foot = q.body_to_ned(Vector3::new(x, y, self.centre_z_metres)).z()
+ self.radius_metres;
if !foot.is_finite() {
return f32::NAN;
}
depth = depth.max(foot);
}
}
depth
}
}
/// Selected source for each supported observation kind.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct NavigationSources {
pub imu: SourceId,
pub local_position_velocity: SourceId,
pub magnetic: SourceId,
pub pressure: SourceId,
pub range: SourceId,
}
impl NavigationSources {
pub(crate) const fn ordered(self) -> [SourceId; 5] {
[
self.imu,
self.local_position_velocity,
self.magnetic,
self.pressure,
self.range,
]
}
}
/// Range-sensor extrinsics and deterministic quantization error.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RangeInstallation {
pub frame: FrameId,
pub offset_frd_metres: Vector3<Frd>,
/// Finite nonzero beam direction, normalized once by navigation construction.
pub direction_frd: Vector3<Frd>,
pub quantization_error_metres: f32,
}
impl RangeInstallation {
fn validate(self) -> bool {
self.offset_frd_metres.is_finite()
&& l1_bound(self.offset_frd_metres).is_finite()
&& self.direction_frd.normalized().is_some()
&& self.quantization_error_metres.is_finite()
&& self.quantization_error_metres >= 0.0
}
pub(crate) fn lever_bound(self) -> f32 {
l1_bound(self.offset_frd_metres)
}
}
// The L1 norm bounds Euclidean radius. Round both additions outward.
fn l1_bound(v: Vector3<Frd>) -> f32 {
((v.x().abs() + v.y().abs()).next_up() + v.z().abs()).next_up()
}
/// Local isothermal pressure reference. The conversion accepts 0.95..1.05 of it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BarometricReference {
pub pressure_pascals: f32,
pub temperature_kelvin: f32,
}
impl BarometricReference {
pub(crate) fn height_scale(self) -> f32 {
287.05 * self.temperature_kelvin / GRAVITY_MPS2
}
fn validate(self) -> bool {
let lower = 0.95 * self.pressure_pascals;
let upper = 1.05 * self.pressure_pascals;
let scale = self.height_scale();
self.pressure_pascals.is_finite()
&& self.pressure_pascals > 0.0
&& self.temperature_kelvin.is_finite()
&& self.temperature_kelvin > 0.0
&& (self.pressure_pascals + upper).is_finite()
&& scale.is_finite()
&& (scale / lower).is_finite()
&& scale / upper > 0.0
}
}
/// Required compiled installation. Calibration identifies one coherent set of
/// mounting, calibration and uncertainty data, changed only by reinitialization.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct NavigationInstallation {
pub sources: NavigationSources,
pub range: RangeInstallation,
pub contact: ContactGeometry,
pub atmosphere: BarometricReference,
pub magnetic_field_ned_microtesla: Vector3<Ned>,
pub calibration: CalibrationRevision,
pub reference: SensorReferenceId,
}
impl NavigationInstallation {
fn validate(self) -> bool {
self.range.validate()
&& self.contact.validate()
&& self.atmosphere.validate()
&& self.magnetic_field_ned_microtesla.is_finite()
&& Vector3::<Ned>::new(
self.magnetic_field_ned_microtesla.x(),
self.magnetic_field_ned_microtesla.y(),
0.0,
)
.norm()
> 10.0
&& self.magnetic_field_ned_microtesla.norm() < 100.0
&& self.reference.0 != 0
}
}
/// Continuous process-noise densities in SI units.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct NoiseDensities {
pub gyro: f32, // rad²/s
pub acceleration: f32, // m²/s³
pub gyro_bias: f32, // rad²/s³
pub acceleration_bias: f32, // m²/s⁵
pub barometric_datum: f32, // m²/s
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct NavigationConfig {
pub noise: NoiseDensities,
/// Acquisition-window, numerical-step and current-extrapolation ceiling.
pub maximum_imu_interval_microseconds: u64,
pub imu_freshness_microseconds: u64,
pub aiding_freshness_microseconds: u64,
pub maximum_time_uncertainty_microseconds: u32,
pub lag_microseconds: u64,
pub installation: NavigationInstallation,
}
impl NavigationConfig {
#[cfg(test)]
pub const fn engineering_fixture() -> Self {
Self {
noise: NoiseDensities {
gyro: 2.5e-7,
acceleration: 2.5e-5,
gyro_bias: 1e-10,
acceleration_bias: 1e-8,
barometric_datum: 1e-5,
},
maximum_imu_interval_microseconds: 20_000,
imu_freshness_microseconds: 20_000,
aiding_freshness_microseconds: 250_000,
maximum_time_uncertainty_microseconds: 2_000,
lag_microseconds: 100_000,
installation: NavigationInstallation {
sources: NavigationSources {
imu: SourceId::new(1),
local_position_velocity: SourceId::new(2),
magnetic: SourceId::new(3),
pressure: SourceId::new(4),
range: SourceId::new(5),
},
range: RangeInstallation {
frame: FrameId::new(3),
offset_frd_metres: Vector3::new(0.0, 0.0, 0.04),
direction_frd: Vector3::new(0.0, 0.0, 1.0),
quantization_error_metres: 0.0005,
},
contact: ContactGeometry {
half_span_x_metres: 0.12,
half_span_y_metres: 0.12,
centre_z_metres: 0.065,
radius_metres: 0.015,
},
atmosphere: BarometricReference {
pressure_pascals: 101_325.0,
temperature_kelvin: 288.15,
},
magnetic_field_ned_microtesla: Vector3::new(20.0, 0.0, 45.0),
calibration: CalibrationRevision::new(1),
reference: SensorReferenceId(1),
},
}
}
pub fn validate(self) -> bool {
let n = self.noise;
[
n.gyro,
n.acceleration,
n.gyro_bias,
n.acceleration_bias,
n.barometric_datum,
]
.iter()
.all(|v| v.is_finite() && *v > 0.0 && *v <= 1.0)
&& (10_000..=20_000).contains(&self.maximum_imu_interval_microseconds)
&& self.imu_freshness_microseconds >= self.maximum_imu_interval_microseconds
&& self.imu_freshness_microseconds <= 20_000
&& self.lag_microseconds == 100_000
&& (200_000..=500_000).contains(&self.aiding_freshness_microseconds)
&& self.maximum_time_uncertainty_microseconds <= 2_000
&& self.installation.validate()
}
}
pub(crate) fn earliest(c: ObservationContext) -> Option<u64> {
c.measurement_time
.as_microseconds()
.checked_sub(u64::from(c.time_uncertainty_microseconds))
}
pub(crate) fn expiry(c: ObservationContext, age: u64) -> Option<Timestamp> {
Some(Timestamp::from_microseconds(earliest(c)?.checked_add(age)?))
}
#[cfg(test)]
mod tests {
use super::*;
fn contact() -> ContactGeometry {
NavigationConfig::engineering_fixture().installation.contact
}
fn time(v: u64) -> Timestamp {
Timestamp::from_microseconds(v)
}
fn estimate(at: Timestamp) -> FlightEstimate {
let capability = CapabilityEvidence {
available: true,
last_measurement_at: Some(at),
valid_until: Some(time(u64::MAX)),
};
FlightEstimate {
at,
fused_through: at,
last_imu_at: at,
origin: LocalOrigin {
id: OriginId(1),
established_at: at,
reference: SensorReferenceId(1),
launch_in_reference: PositionNed::metres(0.0, 0.0, 0.0),
launch_variance_m2: [0.0; 3],
},
attitude: UnitQuaternion::identity(),
position: PositionNed::metres(0.0, 0.0, 0.0),
velocity: VelocityNed::metres_per_second(0.1, 0.0, 0.0),
angular_rate: AngularRateFrd::radians_per_second(0.0, 0.0, 0.0),
position_variance_m2: [0.0; 3],
velocity_variance_m2ps2: [0.0; 3],
attitude_variance_rad2: [0.0; 3],
timing_error_bound: TimingErrorBounds::ZERO,
capabilities: FlightCapabilities {
tilt: capability,
heading: capability,
horizontal_position: capability,
horizontal_velocity: capability,
height: capability,
vertical_velocity: capability,
ground_clearance: capability,
},
ground_clearance: None,
}
}
#[test]
fn capability_expiry_requires_every_requested_capability() {
let evidence = CapabilityEvidence {
available: true,
last_measurement_at: Some(time(10)),
valid_until: Some(time(100)),
};
let healthy = FlightCapabilities {
tilt: evidence,
heading: evidence,
horizontal_position: evidence,
horizontal_velocity: evidence,
height: evidence,
vertical_velocity: evidence,
ground_clearance: evidence,
};
assert_eq!(healthy.valid_until(), Some(time(100)));
for index in 0..7 {
let mut c = healthy;
[
&mut c.tilt,
&mut c.heading,
&mut c.horizontal_position,
&mut c.horizontal_velocity,
&mut c.height,
&mut c.vertical_velocity,
&mut c.ground_clearance,
]
.into_iter()
.nth(index)
.unwrap()
.available = false;
assert!(!c.flight_ready());
assert_eq!(c.valid_until(), None);
}
let mut c = healthy;
c.ground_clearance.valid_until = None;
assert_eq!(c.valid_until(), None);
c = healthy;
c.heading.valid_until = Some(time(50));
assert_eq!(c.valid_until(), Some(time(50)));
}
#[test]
fn clearance_bound_uses_earliest_capture_and_rejects_invalid_arithmetic() {
let c = GroundClearance {
measurement_at: time(10_000),
valid_until: time(60_000),
source: SourceId::new(5),
body_origin_height_metres: 0.08,
contact_clearance_metres: 0.0,
variance_m2: 4e-6,
measurement_time_uncertainty_microseconds: 1_000,
upper_error_metres: 0.01,
};
let e = estimate(time(20_000));
assert!((c.upper_at(time(20_000), &e, contact()).unwrap() - 0.012_612_5).abs() < 1e-7);
assert_eq!(
c.upper_at(time(60_000), &estimate(time(60_000)), contact()),
None
);
for speed in [f32::NAN, f32::INFINITY] {
let mut bad = e;
bad.velocity = VelocityNed::metres_per_second(speed, 0.0, 0.0);
assert_eq!(c.upper_at(time(20_000), &bad, contact()), None);
}
assert_eq!(
c.upper_at(time(8_000), &estimate(time(8_000)), contact()),
None
);
assert_eq!(c.upper_at(time(20_001), &e, contact()), None);
let mut bad_angle = e;
bad_angle.attitude_variance_rad2[0] = f32::NAN;
assert_eq!(c.upper_at(time(20_000), &bad_angle, contact()), None);
let mut invalid_contact = contact();
invalid_contact.radius_metres = -0.01;
assert_eq!(c.upper_at(time(20_000), &e, invalid_contact), None);
assert_eq!(
GroundClearance {
measurement_time_uncertainty_microseconds: 20_000,
..c
}
.upper_at(time(20_000), &e, contact()),
None
);
assert_eq!(
GroundClearance {
valid_until: time(3_000_000),
..c
}
.upper_at(
time(2_000_000),
&FlightEstimate {
at: time(2_000_000),
velocity: VelocityNed::metres_per_second(f32::MAX, 0.0, 0.0),
..e
},
contact(),
),
None
);
}
#[test]
fn current_foot_geometry_and_velocity_confidence_bound_landing_clearance() {
// Independent rigid geometry: fixed COM 0.132 m; foot-sphere centres
// (+/-0.12,+/-0.12,0.065), radius 0.015. Roll changes 0.2 -> 0.
let old_depth = 0.12_f64 * 0.2_f64.sin() + 0.065 * 0.2_f64.cos() + 0.015;
let measured_contact = 0.132 - old_depth;
let old_bound = measured_contact + 0.0075 + 0.2 * 0.020 + 0.5 * 25.0 * 0.020_f64.powi(2);
assert!(old_bound < 0.05);
let actual_now = 0.132 - (0.065 + 0.015);
let c = GroundClearance {
measurement_at: time(10_000),
valid_until: time(70_000),
source: SourceId::new(5),
body_origin_height_metres: 0.132,
contact_clearance_metres: measured_contact as f32,
variance_m2: 4e-6,
measurement_time_uncertainty_microseconds: 0,
upper_error_metres: 0.0075,
};
let mut e = estimate(time(30_000));
e.velocity = VelocityNed::metres_per_second(0.0, 0.0, 0.0);
let upper = c.upper_at(e.at, &e, contact()).unwrap();
assert!(f64::from(upper) >= actual_now);
assert!(upper > 0.05); // neither cap entry nor contact dwell may use the old bound
e.velocity_variance_m2ps2 = [0.04; 3];
let uncertain = c.upper_at(e.at, &e, contact()).unwrap();
assert!(uncertain > upper + 0.020); // 3*sqrt(trace(Pv))*20 ms, not fixed +0.2 m/s
}
#[test]
fn contact_geometry() {
for geometry in [
contact(),
ContactGeometry {
half_span_x_metres: 0.3,
half_span_y_metres: 0.2,
centre_z_metres: 0.15,
radius_metres: 0.025,
},
] {
let x = f64::from(geometry.half_span_x_metres);
let y = f64::from(geometry.half_span_y_metres);
let z = f64::from(geometry.centre_z_metres);
assert!(f64::from(geometry.rotation_radius_bound()) >= (x * x + y * y + z * z).sqrt());
for (roll, pitch) in [(-0.5_f64, 0.2_f64), (0.0, 0.0), (0.4, -0.3)] {
let (sr, cr) = ((roll / 2.0).sin(), (roll / 2.0).cos());
let (sp, cp) = ((pitch / 2.0).sin(), (pitch / 2.0).cos());
let q = UnitQuaternion::from_components(
(cp * cr) as f32,
(cp * sr) as f32,
(sp * cr) as f32,
(-sp * sr) as f32,
)
.unwrap();
let expected = x * pitch.sin().abs()
+ y * (pitch.cos() * roll.sin()).abs()
+ z * pitch.cos() * roll.cos()
+ f64::from(geometry.radius_metres);
assert!((f64::from(geometry.depth(q)) - expected).abs() < 1e-7);
let e = FlightEstimate {
attitude: q,
..estimate(time(10_000))
};
let c = GroundClearance {
measurement_at: e.at,
valid_until: time(70_000),
source: SourceId::new(90),
body_origin_height_metres: 1.0,
contact_clearance_metres: 0.0,
variance_m2: 1e-6,
measurement_time_uncertainty_microseconds: 0,
upper_error_metres: 0.001,
};
assert!(f64::from(c.upper_at(e.at, &e, geometry).unwrap()) >= 1.0 - expected);
}
}
}
#[test]
fn installation_validation() {
let fixture = NavigationConfig::engineering_fixture();
for field in 0..9 {
for bad in [f32::NAN, f32::INFINITY, -1.0] {
let mut config = fixture;
let i = &mut config.installation;
match field {
0 => i.contact.half_span_x_metres = bad,
1 => i.contact.half_span_y_metres = bad,
2 => i.contact.radius_metres = bad,
3 => i.range.quantization_error_metres = bad,
4 => i.atmosphere.pressure_pascals = bad,
5 => i.atmosphere.temperature_kelvin = bad,
6 if !bad.is_finite() => i.contact.centre_z_metres = bad,
7 if !bad.is_finite() => {
i.range.offset_frd_metres = Vector3::new(bad, 0.0, 0.0)
}
8 if !bad.is_finite() => i.range.direction_frd = Vector3::new(0.0, 0.0, bad),
_ => continue,
}
assert!(Navigation::new(config).is_err());
}
}
for field in 0..6 {
let mut config = fixture;
let i = &mut config.installation;
match field {
0 => i.range.direction_frd = Vector3::zero(),
1 => i.contact.half_span_x_metres = 0.0,
2 => i.contact.half_span_y_metres = 0.0,
3 => i.atmosphere.pressure_pascals = 0.0,
4 => i.atmosphere.temperature_kelvin = 0.0,
_ => i.atmosphere.pressure_pascals = f32::MAX,
}
assert!(Navigation::new(config).is_err());
}
let mut config = fixture;
config.installation.contact.half_span_x_metres = f32::MAX;
config.installation.contact.half_span_y_metres = f32::MAX;
assert!(Navigation::new(config).is_err());
config = fixture;
config.installation.range.offset_frd_metres = Vector3::new(f32::MAX, f32::MAX, 0.0);
assert!(Navigation::new(config).is_err());
for (pressure_pascals, temperature_kelvin) in [
(101_325.0, f32::MAX),
(f32::MIN_POSITIVE, 288.15),
(1e20, f32::from_bits(1)),
] {
config = fixture;
config.installation.atmosphere = BarometricReference {
pressure_pascals,
temperature_kelvin,
};
assert!(Navigation::new(config).is_err());
}
}
#[test]
fn navigation_configuration_rejects_each_invalid_domain() {
let factory = NavigationConfig::engineering_fixture();
assert!(factory.validate());
for index in 0..5 {
for bad in [f32::NAN, 0.0, -1.0, 1.01] {
let mut c = factory;
*[
&mut c.noise.gyro,
&mut c.noise.acceleration,
&mut c.noise.gyro_bias,
&mut c.noise.acceleration_bias,
&mut c.noise.barometric_datum,
]
.into_iter()
.nth(index)
.unwrap() = bad;
assert!(!c.validate());
assert!(crate::Navigation::new(c).is_err());
}
}
let mut c = factory;
c.maximum_imu_interval_microseconds = 9_999;
assert!(!c.validate());
c = factory;
c.imu_freshness_microseconds = 19_999;
assert!(!c.validate());
c = factory;
c.imu_freshness_microseconds = 20_001;
assert!(!c.validate());
c = factory;
c.lag_microseconds = 0;
assert!(!c.validate());
c = factory;
c.aiding_freshness_microseconds = 100_000;
assert!(!c.validate());
c = factory;
c.maximum_time_uncertainty_microseconds = 2_001;
assert!(!c.validate());
c = factory;
c.installation.magnetic_field_ned_microtesla = Vector3::new(f32::NAN, 0.0, 45.0);
assert!(!c.validate());
c = factory;
c.installation.magnetic_field_ned_microtesla = Vector3::new(10.0, 0.0, 45.0);
assert!(!c.validate());
c = factory;
c.installation.magnetic_field_ned_microtesla = Vector3::new(20.0, f32::MAX, 45.0);
assert!(!c.validate());
c = factory;
c.installation.reference = SensorReferenceId(0);
assert!(!c.validate());
c = factory;
c.installation.magnetic_field_ned_microtesla = Vector3::new(0.0, 20.0, 45.0);
assert!(c.validate());
}
}