Files
kaptan/firmware/libs/kaptan-flight-config/src/profile/candidate.rs
T

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()
);
}
}
}