refactor: move firmware workspace under firmware
This commit is contained in:
1 parent
153b0bf262
commit
f03fc119a4
222 files changed
+56
-13
No files matched your search
@@ -0,0 +1,355 @@
|
||||
//! Private vehicle candidate and whole-profile validation boundary.
|
||||
|
||||
use kaptan_config::{
|
||||
ConfigAggregate, ConfigModel, ConfigValidate, ConfigValue, ConfigViolation, FieldDescriptor,
|
||||
FieldId, FieldWriteError, ValidateFields, ValidationRule,
|
||||
};
|
||||
|
||||
use super::{
|
||||
ActuatorLimits, AidingSettings, ControlSettings, ObservabilitySettings, PidSettings,
|
||||
SafetySettings, ThrustSettings, TimingSettings, VehicleCapabilities, VehicleProfile,
|
||||
control::{
|
||||
ActuatorLimitsCandidate, AidingCandidate, ControlCandidate, PidCandidate, ThrustCandidate,
|
||||
},
|
||||
observability::ObservabilityCandidate,
|
||||
registry,
|
||||
safety::SafetyCandidate,
|
||||
timing::TimingCandidate,
|
||||
};
|
||||
|
||||
/// Current persisted CBOR schema for the vehicle candidate.
|
||||
pub const FORMAT_VERSION: u16 = 1;
|
||||
|
||||
/// Complete editable vehicle candidate.
|
||||
///
|
||||
/// Fields are private outside this crate. Operators, CBOR import, and MAVLink
|
||||
/// transport all use the same descriptor/mutation and full-validation path.
|
||||
#[derive(Clone, Copy, ConfigValidate)]
|
||||
pub struct CandidateProfile {
|
||||
#[config(section)]
|
||||
pub(super) safety: SafetyCandidate,
|
||||
#[config(section)]
|
||||
pub(super) timing: TimingCandidate,
|
||||
#[config(section)]
|
||||
pub(super) aiding: AidingCandidate,
|
||||
#[config(section)]
|
||||
pub(super) control: ControlCandidate,
|
||||
#[config(section)]
|
||||
pub(super) thrust: ThrustCandidate,
|
||||
#[config(section)]
|
||||
pub(super) rate_pid: PidCandidate,
|
||||
#[config(section)]
|
||||
pub(super) actuator_limits: ActuatorLimitsCandidate,
|
||||
#[config(section)]
|
||||
pub(super) observability: ObservabilityCandidate,
|
||||
}
|
||||
|
||||
impl Default for CandidateProfile {
|
||||
fn default() -> Self {
|
||||
<Self as ConfigAggregate>::default_aggregate()
|
||||
.expect("compiled configuration defaults must validate")
|
||||
}
|
||||
}
|
||||
|
||||
impl CandidateProfile {
|
||||
/// Returns the complete transport-neutral descriptor registry.
|
||||
pub const fn descriptors() -> &'static [FieldDescriptor; Self::FIELD_COUNT] {
|
||||
registry::descriptors()
|
||||
}
|
||||
|
||||
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 observability = active.observability();
|
||||
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(),
|
||||
},
|
||||
observability: ObservabilityCandidate {
|
||||
maximum_log_level: observability.maximum_log_level(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_complete(
|
||||
&self,
|
||||
capabilities: &VehicleCapabilities,
|
||||
) -> Result<VehicleProfile, ConfigViolation> {
|
||||
self.validate_fields()?;
|
||||
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,
|
||||
},
|
||||
observability: ObservabilitySettings::new(self.observability.maximum_log_level),
|
||||
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] = crate::SCHEMA_ID;
|
||||
|
||||
fn descriptor(index: u16) -> Option<&'static FieldDescriptor> {
|
||||
registry::descriptor(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 ConfigAggregate>::field_value(self, id)
|
||||
}
|
||||
|
||||
fn active_field_value(active: &Self::Active, id: FieldId) -> Option<ConfigValue> {
|
||||
<Self as ConfigAggregate>::field_value(&Self::from_active(active), id)
|
||||
}
|
||||
|
||||
fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError> {
|
||||
<Self as ConfigAggregate>::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, VehicleCapabilities};
|
||||
use crate::profile::ids;
|
||||
|
||||
const CAPABILITIES: VehicleCapabilities = VehicleCapabilities::quad_x();
|
||||
|
||||
fn descriptor_id(display_key: &str) -> FieldId {
|
||||
CandidateProfile::descriptors()
|
||||
.iter()
|
||||
.find(|descriptor| descriptor.display_key == display_key)
|
||||
.expect("declared field descriptor")
|
||||
.id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_descriptor_has_a_readable_default_value() {
|
||||
let candidate = CandidateProfile::default();
|
||||
for descriptor in CandidateProfile::descriptors() {
|
||||
assert_eq!(
|
||||
candidate.field_value(descriptor.id),
|
||||
Some(descriptor.default)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregated_descriptor_ids_are_unique() {
|
||||
for (index, descriptor) in CandidateProfile::descriptors().iter().enumerate() {
|
||||
assert!(
|
||||
CandidateProfile::descriptors()[index + 1..]
|
||||
.iter()
|
||||
.all(|other| other.id != descriptor.id)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_field_invalid_edit_is_retained_only_in_the_candidate() {
|
||||
let mut candidate = CandidateProfile::default();
|
||||
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::default();
|
||||
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::default();
|
||||
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 observability_filter_is_persisted_restart_only_and_cannot_hide_errors() {
|
||||
let mut candidate = CandidateProfile::default();
|
||||
candidate
|
||||
.set_field(
|
||||
descriptor_id("observability.maximum_log_level"),
|
||||
ConfigValue::U32(3),
|
||||
)
|
||||
.unwrap();
|
||||
let active = candidate.validate(&CAPABILITIES).unwrap();
|
||||
assert_eq!(active.observability().maximum_log_level(), 3);
|
||||
assert_eq!(active.observability().maximum_log_level(), 3);
|
||||
candidate
|
||||
.set_field(
|
||||
descriptor_id("observability.maximum_log_level"),
|
||||
ConfigValue::U32(5),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
candidate
|
||||
.validate(&CAPABILITIES)
|
||||
.unwrap()
|
||||
.observability()
|
||||
.maximum_log_level(),
|
||||
5
|
||||
);
|
||||
assert!(matches!(
|
||||
candidate.set_field(
|
||||
descriptor_id("observability.maximum_log_level"),
|
||||
ConfigValue::U32(2),
|
||||
),
|
||||
Err(FieldWriteError::Violation(issue))
|
||||
if issue.rule() == ValidationRule::BelowMinimum
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_persisted_log_severity_is_retained_exactly() {
|
||||
for raw in [3, 4, 5, 6, 7] {
|
||||
let mut candidate = CandidateProfile::default();
|
||||
candidate
|
||||
.set_field(
|
||||
descriptor_id("observability.maximum_log_level"),
|
||||
ConfigValue::U32(raw),
|
||||
)
|
||||
.expect("descriptor range accepts the stable severity");
|
||||
assert_eq!(
|
||||
candidate
|
||||
.validate(&CAPABILITIES)
|
||||
.expect("valid complete profile")
|
||||
.observability()
|
||||
.maximum_log_level(),
|
||||
raw
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user