413 lines
16 KiB
Rust
413 lines
16 KiB
Rust
//! Private vehicle candidate and whole-profile validation boundary.
|
|
|
|
use kaptan_config::{
|
|
Config, ConfigModel, ConfigValue, ConfigViolation, FieldDescriptor, FieldId, FieldWriteError,
|
|
ValidationRule,
|
|
};
|
|
use kaptan_log::Severity;
|
|
|
|
use super::{
|
|
ActuatorLimits, AidingSettings, ControlSettings, MavlinkLoggingSettings, PidSettings,
|
|
SafetySettings, ThrustSettings, TimingSettings, VehicleCapabilities, VehicleProfile,
|
|
control::{
|
|
ActuatorLimitsCandidate, AidingCandidate, ControlCandidate, PidCandidate, ThrustCandidate,
|
|
},
|
|
mavlink_logging::MavlinkLoggingCandidate,
|
|
safety::SafetyCandidate,
|
|
timing::TimingCandidate,
|
|
};
|
|
|
|
/// Current persisted CBOR schema for the vehicle candidate.
|
|
pub const FORMAT_VERSION: u16 = 2;
|
|
|
|
/// Complete editable vehicle candidate.
|
|
///
|
|
/// Fields are private outside this crate. Local snapshot recovery and MAVLink
|
|
/// scalar writes both use the same descriptor/mutation and full-validation
|
|
/// path.
|
|
#[derive(Clone, Copy, Config)]
|
|
pub struct CandidateProfile {
|
|
pub(super) safety: SafetyCandidate,
|
|
pub(super) timing: TimingCandidate,
|
|
pub(super) aiding: AidingCandidate,
|
|
pub(super) control: ControlCandidate,
|
|
pub(super) thrust: ThrustCandidate,
|
|
pub(super) rate_pid: PidCandidate,
|
|
pub(super) actuator_limits: ActuatorLimitsCandidate,
|
|
pub(super) mavlink_logging: MavlinkLoggingCandidate,
|
|
}
|
|
|
|
impl CandidateProfile {
|
|
/// Explicit factory candidate used by the vehicle-profile examples.
|
|
///
|
|
/// Board firmware supplies its own factory [`Config`] through the board
|
|
/// composition root; configuration types never synthesize values through
|
|
/// [`Default`].
|
|
pub const fn factory() -> Self {
|
|
Self {
|
|
safety: SafetyCandidate {
|
|
prearm_dwell_microseconds: 100_000,
|
|
},
|
|
timing: TimingCandidate {
|
|
maximum_imu_interval_microseconds: 50_000,
|
|
imu_freshness_microseconds: 50_000,
|
|
local_state_freshness_microseconds: 150_000,
|
|
command_link_freshness_microseconds: 500_000,
|
|
},
|
|
aiding: AidingCandidate {
|
|
position_variance_m2: 0.04,
|
|
velocity_variance_m2_per_second_squared: 0.04,
|
|
},
|
|
control: ControlCandidate {
|
|
position_gain_per_second_squared: 0.8,
|
|
velocity_gain_per_second: 1.2,
|
|
maximum_horizontal_acceleration_mps2: 3.0,
|
|
maximum_upward_acceleration_mps2: 2.0,
|
|
maximum_downward_acceleration_mps2: 2.0,
|
|
},
|
|
thrust: ThrustCandidate {
|
|
mass_kg: 1.2,
|
|
maximum_collective_thrust_newtons: 18.0,
|
|
maximum_tilt_tangent: 0.700_207_53,
|
|
},
|
|
rate_pid: PidCandidate {
|
|
proportional: 0.035,
|
|
integral: 0.006,
|
|
derivative: 0.0,
|
|
output_limit: 0.10,
|
|
integral_limit: 0.025,
|
|
},
|
|
actuator_limits: ActuatorLimitsCandidate {
|
|
minimum: 0.0,
|
|
maximum: 1.0,
|
|
slew_per_second: 8.0,
|
|
},
|
|
mavlink_logging: MavlinkLoggingCandidate {
|
|
enabled: true,
|
|
maximum_severity: Severity::Info,
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Returns the complete transport-neutral descriptor registry.
|
|
pub fn descriptors() -> impl ExactSizeIterator<Item = &'static FieldDescriptor> + Clone {
|
|
(0..Self::FIELD_COUNT).map(|index| {
|
|
<Self as Config>::descriptor(index)
|
|
.expect("derived aggregate descriptor count must be exact")
|
|
})
|
|
}
|
|
|
|
fn from_active(active: &VehicleProfile) -> Self {
|
|
let safety = active.safety();
|
|
let timing = active.timing();
|
|
let aiding = active.aiding();
|
|
let control = active.control();
|
|
let thrust = active.thrust();
|
|
let rate_pid = active.rate_pid();
|
|
let actuator_limits = active.actuator_limits();
|
|
let mavlink_logging = active.mavlink_logging();
|
|
Self {
|
|
safety: SafetyCandidate {
|
|
prearm_dwell_microseconds: safety.prearm_dwell_microseconds(),
|
|
},
|
|
timing: TimingCandidate {
|
|
maximum_imu_interval_microseconds: timing.maximum_imu_interval_microseconds(),
|
|
imu_freshness_microseconds: timing.imu_freshness_microseconds(),
|
|
local_state_freshness_microseconds: timing.local_state_freshness_microseconds(),
|
|
command_link_freshness_microseconds: timing.command_link_freshness_microseconds(),
|
|
},
|
|
aiding: AidingCandidate {
|
|
position_variance_m2: aiding.position_variance_m2(),
|
|
velocity_variance_m2_per_second_squared: aiding
|
|
.velocity_variance_m2_per_second_squared(),
|
|
},
|
|
control: ControlCandidate {
|
|
position_gain_per_second_squared: control.position_gain_per_second_squared(),
|
|
velocity_gain_per_second: control.velocity_gain_per_second(),
|
|
maximum_horizontal_acceleration_mps2: control
|
|
.maximum_horizontal_acceleration_mps2(),
|
|
maximum_upward_acceleration_mps2: control.maximum_upward_acceleration_mps2(),
|
|
maximum_downward_acceleration_mps2: control.maximum_downward_acceleration_mps2(),
|
|
},
|
|
thrust: ThrustCandidate {
|
|
mass_kg: thrust.mass_kg(),
|
|
maximum_collective_thrust_newtons: thrust.maximum_collective_thrust_newtons(),
|
|
maximum_tilt_tangent: thrust.maximum_tilt_tangent(),
|
|
},
|
|
rate_pid: PidCandidate {
|
|
proportional: rate_pid.proportional(),
|
|
integral: rate_pid.integral(),
|
|
derivative: rate_pid.derivative(),
|
|
output_limit: rate_pid.output_limit(),
|
|
integral_limit: rate_pid.integral_limit(),
|
|
},
|
|
actuator_limits: ActuatorLimitsCandidate {
|
|
minimum: actuator_limits.minimum(),
|
|
maximum: actuator_limits.maximum(),
|
|
slew_per_second: actuator_limits.slew_per_second(),
|
|
},
|
|
mavlink_logging: MavlinkLoggingCandidate {
|
|
enabled: mavlink_logging.enabled(),
|
|
maximum_severity: mavlink_logging.maximum_severity(),
|
|
},
|
|
}
|
|
}
|
|
|
|
fn validate_complete(
|
|
&self,
|
|
capabilities: &VehicleCapabilities,
|
|
) -> Result<VehicleProfile, ConfigViolation> {
|
|
<Self as Config>::validate(self)?;
|
|
self.validate_capabilities(capabilities)?;
|
|
Ok(VehicleProfile {
|
|
safety: SafetySettings {
|
|
prearm_dwell_microseconds: self.safety.prearm_dwell_microseconds,
|
|
},
|
|
timing: TimingSettings {
|
|
maximum_imu_interval_microseconds: self.timing.maximum_imu_interval_microseconds,
|
|
imu_freshness_microseconds: self.timing.imu_freshness_microseconds,
|
|
local_state_freshness_microseconds: self.timing.local_state_freshness_microseconds,
|
|
command_link_freshness_microseconds: self
|
|
.timing
|
|
.command_link_freshness_microseconds,
|
|
},
|
|
aiding: AidingSettings {
|
|
position_variance_m2: self.aiding.position_variance_m2,
|
|
velocity_variance_m2_per_second_squared: self
|
|
.aiding
|
|
.velocity_variance_m2_per_second_squared,
|
|
},
|
|
control: ControlSettings {
|
|
position_gain_per_second_squared: self.control.position_gain_per_second_squared,
|
|
velocity_gain_per_second: self.control.velocity_gain_per_second,
|
|
maximum_horizontal_acceleration_mps2: self
|
|
.control
|
|
.maximum_horizontal_acceleration_mps2,
|
|
maximum_upward_acceleration_mps2: self.control.maximum_upward_acceleration_mps2,
|
|
maximum_downward_acceleration_mps2: self.control.maximum_downward_acceleration_mps2,
|
|
},
|
|
thrust: ThrustSettings {
|
|
mass_kg: self.thrust.mass_kg,
|
|
maximum_collective_thrust_newtons: self.thrust.maximum_collective_thrust_newtons,
|
|
maximum_tilt_tangent: self.thrust.maximum_tilt_tangent,
|
|
},
|
|
rate_pid: PidSettings {
|
|
proportional: self.rate_pid.proportional,
|
|
integral: self.rate_pid.integral,
|
|
derivative: self.rate_pid.derivative,
|
|
output_limit: self.rate_pid.output_limit,
|
|
integral_limit: self.rate_pid.integral_limit,
|
|
},
|
|
actuator_limits: ActuatorLimits {
|
|
minimum: self.actuator_limits.minimum,
|
|
maximum: self.actuator_limits.maximum,
|
|
slew_per_second: self.actuator_limits.slew_per_second,
|
|
},
|
|
mavlink_logging: MavlinkLoggingSettings::new(
|
|
self.mavlink_logging.enabled,
|
|
self.mavlink_logging.maximum_severity,
|
|
),
|
|
mixer: capabilities.mixer(),
|
|
})
|
|
}
|
|
|
|
fn validate_capabilities(
|
|
&self,
|
|
capabilities: &VehicleCapabilities,
|
|
) -> Result<(), ConfigViolation> {
|
|
if capabilities.actuator_count() != 4 {
|
|
return Err(ConfigViolation::global(
|
|
ValidationRule::UnsupportedCapability,
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl ConfigModel for CandidateProfile {
|
|
type Active = VehicleProfile;
|
|
type Context = VehicleCapabilities;
|
|
const SCHEMA_ID: [u8; 16] = <Self as Config>::SCHEMA_ID;
|
|
|
|
fn descriptor(index: u16) -> Option<&'static FieldDescriptor> {
|
|
<Self as Config>::descriptor(usize::from(index))
|
|
}
|
|
|
|
fn field_count() -> u16 {
|
|
u16::try_from(Self::FIELD_COUNT).expect("field registry exceeds the protocol limit")
|
|
}
|
|
|
|
fn field_value(&self, id: FieldId) -> Option<ConfigValue> {
|
|
<Self as Config>::field_value(self, id)
|
|
}
|
|
|
|
fn active_field_value(active: &Self::Active, id: FieldId) -> Option<ConfigValue> {
|
|
<Self as Config>::field_value(&Self::from_active(active), id)
|
|
}
|
|
|
|
fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError> {
|
|
<Self as Config>::set_field(self, id, value)
|
|
}
|
|
|
|
fn validate(&self, context: &Self::Context) -> Result<Self::Active, ConfigViolation> {
|
|
self.validate_complete(context)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use kaptan_config::{ConfigModel, ConfigValue, FieldId, FieldWriteError, ValidationRule};
|
|
|
|
use super::{CandidateProfile, Severity, VehicleCapabilities};
|
|
use crate::profile::ids;
|
|
|
|
const CAPABILITIES: VehicleCapabilities = VehicleCapabilities::quad_x();
|
|
|
|
#[test]
|
|
fn generated_schema_fingerprint_is_stable() {
|
|
assert_eq!(
|
|
crate::SCHEMA_ID,
|
|
[
|
|
0xc4, 0xf6, 0x36, 0xc0, 0x29, 0xd2, 0x3e, 0x29, 0xa3, 0x85, 0x5b, 0x36, 0xb7, 0xb3,
|
|
0x59, 0x9c,
|
|
]
|
|
);
|
|
}
|
|
|
|
fn descriptor_id(display_key: &str) -> FieldId {
|
|
CandidateProfile::descriptors()
|
|
.find(|descriptor| descriptor.display_key == display_key)
|
|
.expect("declared field descriptor")
|
|
.id
|
|
}
|
|
|
|
#[test]
|
|
fn every_descriptor_has_a_readable_factory_value() {
|
|
let candidate = CandidateProfile::factory();
|
|
for descriptor in CandidateProfile::descriptors() {
|
|
assert!(candidate.field_value(descriptor.id).is_some());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn aggregated_descriptor_ids_are_unique() {
|
|
for (index, descriptor) in CandidateProfile::descriptors().enumerate() {
|
|
assert!(
|
|
CandidateProfile::descriptors()
|
|
.skip(index + 1)
|
|
.all(|other| other.id != descriptor.id)
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn cross_field_invalid_edit_is_retained_only_in_the_candidate() {
|
|
let mut candidate = CandidateProfile::factory();
|
|
candidate
|
|
.set_field(
|
|
ids::MAXIMUM_IMU_INTERVAL_MICROSECONDS,
|
|
ConfigValue::U64(51_000),
|
|
)
|
|
.unwrap();
|
|
assert!(matches!(
|
|
candidate.validate(&CAPABILITIES),
|
|
Err(issue) if issue.rule() == ValidationRule::Inconsistent
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn local_write_error_retains_exact_field_and_rule() {
|
|
let mut candidate = CandidateProfile::factory();
|
|
assert_eq!(
|
|
candidate.set_field(ids::MASS_KG, ConfigValue::F32(f32::NAN)),
|
|
Err(FieldWriteError::non_finite(ids::MASS_KG))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn descriptor_bounds_do_not_conflict_with_derived_field_validation() {
|
|
for descriptor in CandidateProfile::descriptors() {
|
|
for bound in [descriptor.minimum, descriptor.maximum]
|
|
.into_iter()
|
|
.flatten()
|
|
{
|
|
let mut candidate = CandidateProfile::factory();
|
|
candidate
|
|
.set_field(descriptor.id, bound)
|
|
.expect("descriptor bound must pass the descriptor-backed setter");
|
|
if let Err(issue) = candidate.validate(&CAPABILITIES) {
|
|
assert!(
|
|
issue.primary() != Some(descriptor.id)
|
|
|| issue.rule() == ValidationRule::Inconsistent,
|
|
"descriptor bound for {} conflicts with generated field validation: {issue:?}",
|
|
descriptor.display_key,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn mavlink_logging_policy_is_persisted_and_restart_only() {
|
|
let mut candidate = CandidateProfile::factory();
|
|
let severity_descriptor = CandidateProfile::descriptors()
|
|
.find(|descriptor| descriptor.display_key == "mavlink.logging.maximum_severity")
|
|
.unwrap();
|
|
assert_eq!(
|
|
severity_descriptor.enum_options,
|
|
Severity::CONFIG_ENUM_OPTIONS
|
|
);
|
|
candidate
|
|
.set_field(
|
|
descriptor_id("mavlink.logging.enabled"),
|
|
ConfigValue::Bool(false),
|
|
)
|
|
.unwrap();
|
|
let active = candidate.validate(&CAPABILITIES).unwrap();
|
|
assert!(!active.mavlink_logging().enabled());
|
|
candidate
|
|
.set_field(
|
|
descriptor_id("mavlink.logging.maximum_severity"),
|
|
ConfigValue::U32(5),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
candidate
|
|
.validate(&CAPABILITIES)
|
|
.unwrap()
|
|
.mavlink_logging()
|
|
.maximum_severity(),
|
|
Severity::Notice
|
|
);
|
|
assert!(matches!(
|
|
candidate.set_field(
|
|
descriptor_id("mavlink.logging.maximum_severity"),
|
|
ConfigValue::U32(8),
|
|
),
|
|
Err(FieldWriteError::Violation(issue))
|
|
if issue.rule() == ValidationRule::InvalidEnumValue
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn every_persisted_log_severity_is_retained_exactly() {
|
|
for raw in 0..=7 {
|
|
let mut candidate = CandidateProfile::factory();
|
|
candidate
|
|
.set_field(
|
|
descriptor_id("mavlink.logging.maximum_severity"),
|
|
ConfigValue::U32(raw),
|
|
)
|
|
.expect("descriptor range accepts the stable severity");
|
|
assert_eq!(
|
|
candidate
|
|
.validate(&CAPABILITIES)
|
|
.expect("valid complete profile")
|
|
.mavlink_logging()
|
|
.maximum_severity(),
|
|
Severity::try_from_wire(u8::try_from(raw).unwrap()).unwrap()
|
|
);
|
|
}
|
|
}
|
|
}
|