feat(config): add validated profile core

This commit is contained in:
Onur Özkan committed 2026-08-09 22:06:10 +03:00
1 parent 0a75860841
commit 6c5e061359
36 files changed
+3343

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
[package]
name = "kaptan-flight-config"
edition.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
version.workspace = true
[lints]
workspace = true
[dependencies]
kaptan-config = { path = "../kaptan-config" }
kaptan-log = { path = "../kaptan-log" }
minicbor = { version = "0.25", features = ["derive"] }
+289
View File
@@ -0,0 +1,289 @@
//! Bounded CBOR import/export for one explicit current record.
use kaptan_config::{
ConfigModel, ConfigValue, ConfigViolation, FieldWriteError, ValidationRule, ValueKind,
};
use kaptan_log::KaptanError;
use minicbor::{Decoder, Encoder, encode::Write};
use crate::{CandidateProfile, FORMAT_VERSION, SCHEMA_ID, VehicleCapabilities};
/// Maximum complete candidate snapshot accepted by the current vehicle schema.
pub const MAXIMUM_CANDIDATE_BYTES: usize = 4096;
const FORMAT_KEY: u16 = 0;
const SCHEMA_KEY: u16 = u16::MAX;
/// CBOR import failure.
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
pub enum CborError {
/// The supplied payload exceeded the bounded configuration storage budget.
TooLarge,
/// CBOR was malformed or did not match a supported versioned wire record.
Malformed,
/// A second CBOR item followed the candidate snapshot.
TrailingData,
/// The decoded candidate violated complete schema/capability validation.
InvalidProfile(ConfigViolation),
}
/// Decodes exactly one bounded current record and validates it against immutable
/// board capabilities before returning a candidate to its caller.
pub fn decode_candidate(
bytes: &[u8],
capabilities: &VehicleCapabilities,
) -> Result<CandidateProfile, CborError> {
if bytes.len() > MAXIMUM_CANDIDATE_BYTES {
return Err(CborError::TooLarge);
}
let candidate = decode_current(bytes)?;
candidate
.validate_against(capabilities)
.map_err(CborError::InvalidProfile)?;
Ok(candidate)
}
/// Encodes one complete current snapshot to a caller-owned CBOR writer.
pub fn encode_candidate<W>(
candidate: CandidateProfile,
writer: W,
) -> Result<(), minicbor::encode::Error<W::Error>>
where
W: Write,
{
let mut encoder = Encoder::new(writer);
encoder.map(CandidateProfile::FIELD_COUNT as u64 + 2)?;
encoder.u16(FORMAT_KEY)?.u16(FORMAT_VERSION)?;
encoder.u16(SCHEMA_KEY)?.bytes(&SCHEMA_ID)?;
for descriptor in CandidateProfile::descriptors() {
encoder.u16(descriptor.id.get())?;
encode_value(
&mut encoder,
candidate
.field_value(descriptor.id)
.expect("descriptor field"),
)?;
}
Ok(())
}
fn decode_current(bytes: &[u8]) -> Result<CandidateProfile, CborError> {
let mut decoder = Decoder::new(bytes);
let entry_count = CandidateProfile::FIELD_COUNT as u64 + 2;
if decoder.map().map_err(|_| CborError::Malformed)? != Some(entry_count) {
return Err(CborError::Malformed);
}
let mut format_version = None;
let mut schema_id = None;
let mut values = [None; CandidateProfile::FIELD_COUNT];
for _ in 0..entry_count {
let key = decoder.u16().map_err(|_| CborError::Malformed)?;
match key {
FORMAT_KEY => {
if format_version
.replace(decoder.u16().map_err(|_| CborError::Malformed)?)
.is_some()
{
return Err(CborError::Malformed);
}
}
SCHEMA_KEY => {
let bytes = decoder.bytes().map_err(|_| CborError::Malformed)?;
let value: [u8; 16] = bytes.try_into().map_err(|_| CborError::Malformed)?;
if schema_id.replace(value).is_some() {
return Err(CborError::Malformed);
}
}
_ => {
let index = CandidateProfile::descriptors()
.iter()
.position(|descriptor| descriptor.id.get() == key)
.ok_or(CborError::Malformed)?;
if values[index].is_some() {
return Err(CborError::Malformed);
}
values[index] = Some(decode_value(
&mut decoder,
CandidateProfile::descriptors()[index].kind,
)?);
}
}
}
if decoder.position() != bytes.len() {
return Err(CborError::TrailingData);
}
if format_version != Some(FORMAT_VERSION) || schema_id != Some(SCHEMA_ID) {
return Err(CborError::InvalidProfile(ConfigViolation::global(
ValidationRule::UnsupportedFormat,
)));
}
if values.iter().any(Option::is_none) {
return Err(CborError::Malformed);
}
let mut candidate = CandidateProfile::default();
for (index, value) in values.into_iter().enumerate() {
let descriptor = CandidateProfile::descriptors()
.get(index)
.ok_or(CborError::Malformed)?;
candidate
.set_field(descriptor.id, value.ok_or(CborError::Malformed)?)
.map_err(field_write_error)?;
}
Ok(candidate)
}
fn encode_value<W>(
encoder: &mut Encoder<W>,
value: ConfigValue,
) -> Result<(), minicbor::encode::Error<W::Error>>
where
W: Write,
{
match value {
ConfigValue::Bool(value) => encoder.bool(value)?,
ConfigValue::I32(value) => encoder.i32(value)?,
ConfigValue::U32(value) => encoder.u32(value)?,
ConfigValue::U64(value) => encoder.u64(value)?,
ConfigValue::F32(value) => encoder.f32(value)?,
ConfigValue::F64(value) => encoder.f64(value)?,
};
Ok(())
}
fn decode_value(decoder: &mut Decoder<'_>, kind: ValueKind) -> Result<ConfigValue, CborError> {
match kind {
ValueKind::Bool => decoder.bool().map(ConfigValue::Bool),
ValueKind::I32 => decoder.i32().map(ConfigValue::I32),
ValueKind::U32 => decoder.u32().map(ConfigValue::U32),
ValueKind::U64 => decoder.u64().map(ConfigValue::U64),
ValueKind::F32 => decoder.f32().map(ConfigValue::F32),
ValueKind::F64 => decoder.f64().map(ConfigValue::F64),
}
.map_err(|_| CborError::Malformed)
}
fn field_write_error(error: FieldWriteError) -> CborError {
match error {
FieldWriteError::Violation(issue) => CborError::InvalidProfile(issue),
FieldWriteError::UnknownField(_) | FieldWriteError::WrongType { .. } => {
CborError::Malformed
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip_and_trailing_rejection_are_exact() {
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
let capabilities = VehicleCapabilities::quad_x();
encode_candidate(CandidateProfile::default(), &mut writer).unwrap();
let length = writer.position();
let bytes = writer.into_inner();
assert!(decode_candidate(&bytes[..length], &capabilities).is_ok());
let mut trailing = bytes;
trailing[length] = 0xf6;
assert!(matches!(
decode_candidate(&trailing[..=length], &capabilities),
Err(CborError::TrailingData)
));
}
#[test]
fn rejects_wrong_format_and_schema_before_candidate_conversion() {
let capabilities = VehicleCapabilities::quad_x();
let (bytes, length) = encoded_test_record(FORMAT_VERSION + 1, SCHEMA_ID, |key| key);
assert!(matches!(
decode_candidate(&bytes[..length], &capabilities),
Err(CborError::InvalidProfile(issue))
if issue.rule() == ValidationRule::UnsupportedFormat
));
let (bytes, length) = encoded_test_record(FORMAT_VERSION, [0; 16], |key| key);
assert!(matches!(
decode_candidate(&bytes[..length], &capabilities),
Err(CborError::InvalidProfile(issue))
if issue.rule() == kaptan_config::ValidationRule::UnsupportedFormat
));
}
#[test]
fn exact_map_rejects_unknown_duplicate_and_missing_fields() {
let capabilities = VehicleCapabilities::quad_x();
let (bytes, length) = encoded_test_record(FORMAT_VERSION, SCHEMA_ID, |key| key);
assert!(decode_candidate(&bytes[..length], &capabilities).is_ok());
let (unknown, length) =
encoded_test_record(
FORMAT_VERSION,
SCHEMA_ID,
|key| {
if key == 1 { 25 } else { key }
},
);
assert!(matches!(
decode_candidate(&unknown[..length], &capabilities),
Err(CborError::Malformed)
));
let (duplicate, length) = encoded_test_record(FORMAT_VERSION, SCHEMA_ID, |key| {
if key == 1 { FORMAT_KEY } else { key }
});
assert!(matches!(
decode_candidate(&duplicate[..length], &capabilities),
Err(CborError::Malformed)
));
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
let mut encoder = Encoder::new(&mut writer);
encoder
.map(CandidateProfile::FIELD_COUNT as u64 + 1)
.unwrap();
encoder
.u16(FORMAT_KEY)
.unwrap()
.u16(FORMAT_VERSION)
.unwrap();
encoder.u16(SCHEMA_KEY).unwrap().bytes(&SCHEMA_ID).unwrap();
let candidate = CandidateProfile::default();
for descriptor in CandidateProfile::descriptors().iter().skip(1) {
encoder.u16(descriptor.id.get()).unwrap();
encode_value(&mut encoder, candidate.field_value(descriptor.id).unwrap()).unwrap();
}
let length = writer.position();
let missing = writer.into_inner();
assert!(matches!(
decode_candidate(&missing[..length], &capabilities),
Err(CborError::Malformed)
));
}
fn encoded_test_record<F>(
format_version: u16,
schema_id: [u8; 16],
mut remap_key: F,
) -> ([u8; MAXIMUM_CANDIDATE_BYTES], usize)
where
F: FnMut(u16) -> u16,
{
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
let mut encoder = Encoder::new(&mut writer);
encoder
.map(CandidateProfile::FIELD_COUNT as u64 + 2)
.unwrap();
encoder
.u16(FORMAT_KEY)
.unwrap()
.u16(format_version)
.unwrap();
encoder.u16(SCHEMA_KEY).unwrap().bytes(&schema_id).unwrap();
let candidate = CandidateProfile::default();
for descriptor in CandidateProfile::descriptors() {
encoder.u16(remap_key(descriptor.id.get())).unwrap();
encode_value(&mut encoder, candidate.field_value(descriptor.id).unwrap()).unwrap();
}
let length = writer.position();
(writer.into_inner(), length)
}
}
+18
View File
@@ -0,0 +1,18 @@
//! Vehicle configuration schema and CBOR snapshot codec.
#![no_std]
pub mod cbor;
pub mod profile;
pub use cbor::{CborError, MAXIMUM_CANDIDATE_BYTES, decode_candidate, encode_candidate};
pub use profile::{CandidateProfile, FORMAT_VERSION, VehicleCapabilities, VehicleProfile};
/// Exact identifier of the one unreleased Quad-X profile schema.
///
/// This is an identifier, not a cryptographic fingerprint. A GCS includes it
/// with an import so a profile prepared for another schema cannot be applied.
pub const SCHEMA_ID: [u8; 16] = *b"KAPTANPROFILE-01";
#[cfg(test)]
extern crate std;
@@ -0,0 +1,237 @@
//! Immutable configuration projections consumed by runtime owners.
/// Immutable compiled contract for the physical vehicle/board image.
///
/// A runtime candidate can tune only values inside this contract. It cannot
/// create pins, buses, sensor drivers, or actuator channels.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct VehicleCapabilities {
/// Number of board-declared logical actuator channels available to the
/// selected vehicle model.
actuator_count: u8,
/// Reviewed physical allocation for this board/airframe image.
///
/// This is capability data rather than a persisted setting: profile edits
/// must never alter motor order, thrust signs, or control authority.
mixer: [[f32; 4]; 4],
}
impl VehicleCapabilities {
/// Returns the reviewed four-actuator Quad-X allocation supported by P1.
///
/// New allocation layouts require a dedicated board-bound airframe review
/// and constructor, rather than accepting an arbitrary coefficient array
/// through a configuration or capability initializer.
pub const fn quad_x() -> Self {
Self {
actuator_count: 4,
mixer: [
[-1.0, 1.0, 1.0, 1.0],
[-1.0, -1.0, -1.0, 1.0],
[1.0, -1.0, 1.0, 1.0],
[1.0, 1.0, -1.0, 1.0],
],
}
}
pub(crate) const fn actuator_count(self) -> u8 {
self.actuator_count
}
pub(crate) const fn mixer(self) -> [[f32; 4]; 4] {
self.mixer
}
}
/// Active prearm timing policy.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SafetySettings {
pub(crate) prearm_dwell_microseconds: u64,
}
impl SafetySettings {
pub const fn prearm_dwell_microseconds(self) -> u64 {
self.prearm_dwell_microseconds
}
}
/// Active stream timing policy.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TimingSettings {
pub(crate) maximum_imu_interval_microseconds: u64,
pub(crate) imu_freshness_microseconds: u64,
pub(crate) local_state_freshness_microseconds: u64,
pub(crate) command_link_freshness_microseconds: u64,
}
impl TimingSettings {
pub const fn maximum_imu_interval_microseconds(self) -> u64 {
self.maximum_imu_interval_microseconds
}
pub const fn imu_freshness_microseconds(self) -> u64 {
self.imu_freshness_microseconds
}
pub const fn local_state_freshness_microseconds(self) -> u64 {
self.local_state_freshness_microseconds
}
pub const fn command_link_freshness_microseconds(self) -> u64 {
self.command_link_freshness_microseconds
}
}
/// Active local-aiding uncertainty policy.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AidingSettings {
pub(crate) position_variance_m2: f32,
pub(crate) velocity_variance_m2_per_second_squared: f32,
}
impl AidingSettings {
pub const fn position_variance_m2(self) -> f32 {
self.position_variance_m2
}
pub const fn velocity_variance_m2_per_second_squared(self) -> f32 {
self.velocity_variance_m2_per_second_squared
}
}
/// Active outer-loop controller policy.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ControlSettings {
pub(crate) position_gain_per_second_squared: f32,
pub(crate) velocity_gain_per_second: f32,
pub(crate) maximum_horizontal_acceleration_mps2: f32,
pub(crate) maximum_upward_acceleration_mps2: f32,
pub(crate) maximum_downward_acceleration_mps2: f32,
}
impl ControlSettings {
pub const fn position_gain_per_second_squared(self) -> f32 {
self.position_gain_per_second_squared
}
pub const fn velocity_gain_per_second(self) -> f32 {
self.velocity_gain_per_second
}
pub const fn maximum_horizontal_acceleration_mps2(self) -> f32 {
self.maximum_horizontal_acceleration_mps2
}
pub const fn maximum_upward_acceleration_mps2(self) -> f32 {
self.maximum_upward_acceleration_mps2
}
pub const fn maximum_downward_acceleration_mps2(self) -> f32 {
self.maximum_downward_acceleration_mps2
}
}
/// Active physical thrust envelope.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ThrustSettings {
pub(crate) mass_kg: f32,
pub(crate) maximum_collective_thrust_newtons: f32,
pub(crate) maximum_tilt_tangent: f32,
}
impl ThrustSettings {
pub const fn mass_kg(self) -> f32 {
self.mass_kg
}
pub const fn maximum_collective_thrust_newtons(self) -> f32 {
self.maximum_collective_thrust_newtons
}
pub const fn maximum_tilt_tangent(self) -> f32 {
self.maximum_tilt_tangent
}
}
/// Active scalar PID policy.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PidSettings {
pub(crate) proportional: f32,
pub(crate) integral: f32,
pub(crate) derivative: f32,
pub(crate) output_limit: f32,
pub(crate) integral_limit: f32,
}
impl PidSettings {
pub const fn proportional(self) -> f32 {
self.proportional
}
pub const fn integral(self) -> f32 {
self.integral
}
pub const fn derivative(self) -> f32 {
self.derivative
}
pub const fn output_limit(self) -> f32 {
self.output_limit
}
pub const fn integral_limit(self) -> f32 {
self.integral_limit
}
}
/// Active actuator command bounds.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ActuatorLimits {
pub(crate) minimum: f32,
pub(crate) maximum: f32,
pub(crate) slew_per_second: f32,
}
impl ActuatorLimits {
pub const fn minimum(self) -> f32 {
self.minimum
}
pub const fn maximum(self) -> f32 {
self.maximum
}
pub const fn slew_per_second(self) -> f32 {
self.slew_per_second
}
}
/// Immutable complete configuration selected at boot.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct VehicleProfile {
pub(crate) safety: SafetySettings,
pub(crate) timing: TimingSettings,
pub(crate) aiding: AidingSettings,
pub(crate) control: ControlSettings,
pub(crate) thrust: ThrustSettings,
pub(crate) rate_pid: PidSettings,
pub(crate) actuator_limits: ActuatorLimits,
pub(crate) observability: super::ObservabilitySettings,
pub(crate) mixer: [[f32; 4]; 4],
}
impl VehicleProfile {
pub const fn safety(self) -> SafetySettings {
self.safety
}
pub const fn timing(self) -> TimingSettings {
self.timing
}
pub const fn aiding(self) -> AidingSettings {
self.aiding
}
pub const fn control(self) -> ControlSettings {
self.control
}
pub const fn thrust(self) -> ThrustSettings {
self.thrust
}
pub const fn rate_pid(self) -> PidSettings {
self.rate_pid
}
pub const fn actuator_limits(self) -> ActuatorLimits {
self.actuator_limits
}
/// Immutable boot-active best-effort diagnostic policy.
pub const fn observability(self) -> super::ObservabilitySettings {
self.observability
}
pub const fn mixer(self) -> [[f32; 4]; 4] {
self.mixer
}
}
@@ -0,0 +1,339 @@
//! Private vehicle candidate and whole-profile validation boundary.
use kaptan_config::{
ConfigModel, ConfigValue, ConfigViolation, FieldDescriptor, FieldId, FieldWriteError,
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)]
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) observability: ObservabilityCandidate,
}
impl Default for CandidateProfile {
fn default() -> Self {
registry::default_candidate()
}
}
impl CandidateProfile {
/// Number of operator-editable fields in the derived descriptor registry.
pub const FIELD_COUNT: usize = registry::FIELD_COUNT;
/// Returns the complete transport-neutral descriptor registry.
pub const fn descriptors() -> &'static [FieldDescriptor; Self::FIELD_COUNT] {
registry::descriptors()
}
/// Validates this candidate against immutable board capabilities.
pub fn validate_against(
&self,
capabilities: &VehicleCapabilities,
) -> Result<VehicleProfile, ConfigViolation> {
self.validate_complete(capabilities)
}
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> {
if capabilities.actuator_count() != 4 {
return Err(ConfigViolation::global(
ValidationRule::UnsupportedCapability,
));
}
self.safety.validate()?;
self.timing.validate()?;
self.aiding.validate()?;
self.control.validate()?;
self.thrust.validate()?;
self.rate_pid.validate()?;
self.actuator_limits.validate()?;
self.observability.validate()?;
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(),
})
}
}
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(registry::FIELD_COUNT).expect("field registry exceeds the protocol limit")
}
fn field_value(&self, id: FieldId) -> Option<ConfigValue> {
registry::field_value(self, id)
}
fn active_field_value(active: &Self::Active, id: FieldId) -> Option<ConfigValue> {
registry::field_value(&Self::from_active(active), id)
}
fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError> {
registry::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, FieldWriteError, ValidationRule};
use super::{CandidateProfile, VehicleCapabilities};
use crate::profile::ids;
const CAPABILITIES: VehicleCapabilities = VehicleCapabilities::quad_x();
#[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(ids::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(ids::OBSERVABILITY_MAXIMUM_LOG_LEVEL, ConfigValue::U32(5))
.unwrap();
assert_eq!(
candidate
.validate(&CAPABILITIES)
.unwrap()
.observability()
.maximum_log_level(),
5
);
assert!(matches!(
candidate.set_field(
ids::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(ids::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
);
}
}
}
@@ -0,0 +1,235 @@
use kaptan_config::{ConfigValidate, ConfigViolation, ValidateFields, ValidationRule};
use super::ids;
#[derive(Clone, Copy, ConfigValidate)]
pub(super) struct AidingCandidate {
#[config(
id = 6,
key = "aiding.position_variance_m2",
unit = "m2",
default = 0.04,
finite,
range(min = 0.000_001, max = 10_000.0)
)]
pub(super) position_variance_m2: f32,
#[config(
id = 7,
key = "aiding.velocity_variance_m2ps2",
unit = "m2/s2",
default = 0.04,
finite,
range(min = 0.000_001, max = 10_000.0)
)]
pub(super) velocity_variance_m2_per_second_squared: f32,
}
impl AidingCandidate {
pub(super) fn validate(self) -> Result<(), ConfigViolation> {
self.validate_fields()
}
}
#[derive(Clone, Copy, ConfigValidate)]
pub(super) struct ControlCandidate {
#[config(
id = 8,
key = "control.position_gain_per_s2",
unit = "1/s2",
default = 0.8,
finite,
range(min = 0.0, max = 100.0)
)]
pub(super) position_gain_per_second_squared: f32,
#[config(
id = 9,
key = "control.velocity_gain_per_s",
unit = "1/s",
default = 1.2,
finite,
range(min = 0.0, max = 100.0)
)]
pub(super) velocity_gain_per_second: f32,
#[config(
id = 10,
key = "control.maximum_horizontal_acceleration_mps2",
unit = "m/s2",
default = 3.0,
finite,
range(min = 0.001, max = 100.0)
)]
pub(super) maximum_horizontal_acceleration_mps2: f32,
#[config(
id = 11,
key = "control.maximum_upward_acceleration_mps2",
unit = "m/s2",
default = 2.0,
finite,
range(min = 0.001, max = 100.0)
)]
pub(super) maximum_upward_acceleration_mps2: f32,
#[config(
id = 12,
key = "control.maximum_downward_acceleration_mps2",
unit = "m/s2",
default = 2.0,
finite,
range(min = 0.001, max = 100.0)
)]
pub(super) maximum_downward_acceleration_mps2: f32,
}
impl ControlCandidate {
pub(super) fn validate(self) -> Result<(), ConfigViolation> {
self.validate_fields()
}
}
#[derive(Clone, Copy, ConfigValidate)]
pub(super) struct ThrustCandidate {
#[config(
id = 13,
key = "thrust.mass_kg",
unit = "kg",
default = 1.2,
finite,
range(min = 0.01, max = 100.0)
)]
pub(super) mass_kg: f32,
#[config(
id = 14,
key = "thrust.maximum_collective_thrust_n",
unit = "N",
default = 18.0,
finite,
range(min = 0.1, max = 10_000.0)
)]
pub(super) maximum_collective_thrust_newtons: f32,
#[config(
id = 15,
key = "thrust.maximum_tilt_tangent",
unit = "",
default = 0.700_207_53,
finite,
range(min = 0.001, max = 10.0)
)]
pub(super) maximum_tilt_tangent: f32,
}
impl ThrustCandidate {
pub(super) fn validate(self) -> Result<(), ConfigViolation> {
self.validate_fields()?;
if self.maximum_collective_thrust_newtons <= self.mass_kg * 9.806_65 {
return Err(ConfigViolation::related(
ids::MAXIMUM_COLLECTIVE_THRUST_NEWTONS,
ids::MASS_KG,
ValidationRule::Inconsistent,
));
}
Ok(())
}
}
#[derive(Clone, Copy, ConfigValidate)]
pub(super) struct PidCandidate {
#[config(
id = 16,
key = "control.rate_pid.proportional",
unit = "",
default = 0.035,
finite,
range(min = 0.0, max = 10.0)
)]
pub(super) proportional: f32,
#[config(
id = 17,
key = "control.rate_pid.integral",
unit = "",
default = 0.006,
finite,
range(min = 0.0, max = 10.0)
)]
pub(super) integral: f32,
#[config(
id = 18,
key = "control.rate_pid.derivative",
unit = "",
default = 0.0,
finite,
range(min = 0.0, max = 10.0)
)]
pub(super) derivative: f32,
#[config(
id = 19,
key = "control.rate_pid.output_limit",
unit = "",
default = 0.10,
finite,
range(min = 0.001, max = 1.0)
)]
pub(super) output_limit: f32,
#[config(
id = 20,
key = "control.rate_pid.integral_limit",
unit = "",
default = 0.025,
finite,
range(min = 0.0, max = 1.0)
)]
pub(super) integral_limit: f32,
}
impl PidCandidate {
pub(super) fn validate(self) -> Result<(), ConfigViolation> {
self.validate_fields()?;
if self.integral_limit > self.output_limit {
return Err(ConfigViolation::related(
ids::RATE_PID_INTEGRAL_LIMIT,
ids::RATE_PID_OUTPUT_LIMIT,
ValidationRule::Inconsistent,
));
}
Ok(())
}
}
#[derive(Clone, Copy, ConfigValidate)]
pub(super) struct ActuatorLimitsCandidate {
#[config(
id = 21,
key = "actuator.minimum",
unit = "",
default = 0.0,
finite,
range(min = 0.0, max = 1.0)
)]
pub(super) minimum: f32,
#[config(
id = 22,
key = "actuator.maximum",
unit = "",
default = 1.0,
finite,
range(min = 0.0, max = 1.0)
)]
pub(super) maximum: f32,
#[config(
id = 23,
key = "actuator.slew_per_second",
unit = "1/s",
default = 8.0,
finite,
range(min = 0.001, max = 100.0)
)]
pub(super) slew_per_second: f32,
}
impl ActuatorLimitsCandidate {
pub(super) fn validate(self) -> Result<(), ConfigViolation> {
self.validate_fields()?;
if self.minimum >= self.maximum {
return Err(ConfigViolation::related(
ids::ACTUATOR_MINIMUM,
ids::ACTUATOR_MAXIMUM,
ValidationRule::Inconsistent,
));
}
Ok(())
}
}
@@ -0,0 +1,41 @@
//! Stable field identities for the current vehicle configuration schema.
use super::{
control::{
ActuatorLimitsCandidate, AidingCandidate, ControlCandidate, PidCandidate, ThrustCandidate,
},
observability::ObservabilityCandidate,
safety::SafetyCandidate,
timing::TimingCandidate,
};
use kaptan_config::FieldId;
// These symbolic names are projections of derive-generated metadata. No field
// identifier is handwritten outside the field declaration itself.
pub(super) const PREARM_DWELL_MICROSECONDS: FieldId = SafetyCandidate::DESCRIPTORS[0].id;
pub(super) const MAXIMUM_IMU_INTERVAL_MICROSECONDS: FieldId = TimingCandidate::DESCRIPTORS[0].id;
pub(super) const IMU_FRESHNESS_MICROSECONDS: FieldId = TimingCandidate::DESCRIPTORS[1].id;
pub(super) const LOCAL_STATE_FRESHNESS_MICROSECONDS: FieldId = TimingCandidate::DESCRIPTORS[2].id;
pub(super) const COMMAND_LINK_FRESHNESS_MICROSECONDS: FieldId = TimingCandidate::DESCRIPTORS[3].id;
pub(super) const POSITION_VARIANCE_M2: FieldId = AidingCandidate::DESCRIPTORS[0].id;
pub(super) const VELOCITY_VARIANCE_M2_PER_SECOND_SQUARED: FieldId =
AidingCandidate::DESCRIPTORS[1].id;
pub(super) const POSITION_GAIN_PER_SECOND_SQUARED: FieldId = ControlCandidate::DESCRIPTORS[0].id;
pub(super) const VELOCITY_GAIN_PER_SECOND: FieldId = ControlCandidate::DESCRIPTORS[1].id;
pub(super) const MAXIMUM_HORIZONTAL_ACCELERATION_MPS2: FieldId =
ControlCandidate::DESCRIPTORS[2].id;
pub(super) const MAXIMUM_UPWARD_ACCELERATION_MPS2: FieldId = ControlCandidate::DESCRIPTORS[3].id;
pub(super) const MAXIMUM_DOWNWARD_ACCELERATION_MPS2: FieldId = ControlCandidate::DESCRIPTORS[4].id;
pub(super) const MASS_KG: FieldId = ThrustCandidate::DESCRIPTORS[0].id;
pub(super) const MAXIMUM_COLLECTIVE_THRUST_NEWTONS: FieldId = ThrustCandidate::DESCRIPTORS[1].id;
pub(super) const MAXIMUM_TILT_TANGENT: FieldId = ThrustCandidate::DESCRIPTORS[2].id;
pub(super) const RATE_PID_PROPORTIONAL: FieldId = PidCandidate::DESCRIPTORS[0].id;
pub(super) const RATE_PID_INTEGRAL: FieldId = PidCandidate::DESCRIPTORS[1].id;
pub(super) const RATE_PID_DERIVATIVE: FieldId = PidCandidate::DESCRIPTORS[2].id;
pub(super) const RATE_PID_OUTPUT_LIMIT: FieldId = PidCandidate::DESCRIPTORS[3].id;
pub(super) const RATE_PID_INTEGRAL_LIMIT: FieldId = PidCandidate::DESCRIPTORS[4].id;
pub(super) const ACTUATOR_MINIMUM: FieldId = ActuatorLimitsCandidate::DESCRIPTORS[0].id;
pub(super) const ACTUATOR_MAXIMUM: FieldId = ActuatorLimitsCandidate::DESCRIPTORS[1].id;
pub(super) const ACTUATOR_SLEW_PER_SECOND: FieldId = ActuatorLimitsCandidate::DESCRIPTORS[2].id;
pub(super) const OBSERVABILITY_MAXIMUM_LOG_LEVEL: FieldId =
ObservabilityCandidate::DESCRIPTORS[0].id;
@@ -0,0 +1,17 @@
//! Vehicle-specific candidate and immutable active configuration models.
mod active;
mod candidate;
mod control;
mod ids;
mod observability;
mod registry;
mod safety;
mod timing;
pub use active::{
ActuatorLimits, AidingSettings, ControlSettings, PidSettings, SafetySettings, ThrustSettings,
TimingSettings, VehicleCapabilities, VehicleProfile,
};
pub use candidate::{CandidateProfile, FORMAT_VERSION};
pub use observability::ObservabilitySettings;
@@ -0,0 +1,41 @@
//! Persisted, restart-only runtime-observability policy.
use kaptan_config::{ConfigValidate, ConfigViolation, ValidateFields};
/// Candidate-only observability policy. It deliberately controls only optional
/// detail; error-level records are never suppressible configuration fields.
#[derive(Clone, Copy, ConfigValidate)]
pub(super) struct ObservabilityCandidate {
#[config(
id = 24,
key = "observability.maximum_log_level",
unit = "mav_severity",
default = 6,
range(min = 3, max = 7)
)]
pub(super) maximum_log_level: u32,
}
impl ObservabilityCandidate {
pub(super) fn validate(self) -> Result<(), ConfigViolation> {
self.validate_fields()
}
}
/// Immutable boot-active best-effort observability policy.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ObservabilitySettings {
maximum_log_level: u32,
}
impl ObservabilitySettings {
pub(crate) const fn new(maximum_log_level: u32) -> Self {
Self { maximum_log_level }
}
/// Maximum best-effort MAVLink severity: Error (3) through Debug (7).
/// This never filters error-level records.
pub const fn maximum_log_level(self) -> u32 {
self.maximum_log_level
}
}
@@ -0,0 +1,408 @@
//! Current field registry and local candidate mutation mapping.
use kaptan_config::{
ConfigValue, ConfigViolation, FieldDescriptor, FieldId, FieldWriteError, ValidationRule,
ValueKind,
};
use super::{
candidate::CandidateProfile,
control::{
ActuatorLimitsCandidate, AidingCandidate, ControlCandidate, PidCandidate, ThrustCandidate,
},
ids,
observability::ObservabilityCandidate,
safety::SafetyCandidate,
timing::TimingCandidate,
};
/// Number of operator-editable fields in the current SITL Quad-X profile.
///
/// Allocation topology is immutable board capability data, not an operator
/// parameter. It requires a separate physical-airframe safety workflow.
pub(super) const FIELD_COUNT: usize = SafetyCandidate::DESCRIPTORS.len()
+ TimingCandidate::DESCRIPTORS.len()
+ AidingCandidate::DESCRIPTORS.len()
+ ControlCandidate::DESCRIPTORS.len()
+ ThrustCandidate::DESCRIPTORS.len()
+ PidCandidate::DESCRIPTORS.len()
+ ActuatorLimitsCandidate::DESCRIPTORS.len()
+ ObservabilityCandidate::DESCRIPTORS.len();
/// The registry is an ordered view of metadata generated from field attributes.
/// It owns no IDs, bounds, defaults, or display text.
const FIELDS: [FieldDescriptor; FIELD_COUNT] = [
SafetyCandidate::DESCRIPTORS[0],
TimingCandidate::DESCRIPTORS[0],
TimingCandidate::DESCRIPTORS[1],
TimingCandidate::DESCRIPTORS[2],
TimingCandidate::DESCRIPTORS[3],
AidingCandidate::DESCRIPTORS[0],
AidingCandidate::DESCRIPTORS[1],
ControlCandidate::DESCRIPTORS[0],
ControlCandidate::DESCRIPTORS[1],
ControlCandidate::DESCRIPTORS[2],
ControlCandidate::DESCRIPTORS[3],
ControlCandidate::DESCRIPTORS[4],
ThrustCandidate::DESCRIPTORS[0],
ThrustCandidate::DESCRIPTORS[1],
ThrustCandidate::DESCRIPTORS[2],
PidCandidate::DESCRIPTORS[0],
PidCandidate::DESCRIPTORS[1],
PidCandidate::DESCRIPTORS[2],
PidCandidate::DESCRIPTORS[3],
PidCandidate::DESCRIPTORS[4],
ActuatorLimitsCandidate::DESCRIPTORS[0],
ActuatorLimitsCandidate::DESCRIPTORS[1],
ActuatorLimitsCandidate::DESCRIPTORS[2],
ObservabilityCandidate::DESCRIPTORS[0],
];
const _: () = assert_unique_ids(&FIELDS);
const fn assert_unique_ids(fields: &[FieldDescriptor]) {
let mut index = 0;
while index < fields.len() {
let mut other = index + 1;
while other < fields.len() {
if fields[index].id.get() == fields[other].id.get() {
panic!("duplicate field id in the aggregated vehicle registry");
}
other += 1;
}
index += 1;
}
}
pub(super) const fn descriptors() -> &'static [FieldDescriptor; FIELD_COUNT] {
&FIELDS
}
/// Constructs the vehicle candidate entirely from derive-generated defaults.
pub(super) fn default_candidate() -> CandidateProfile {
CandidateProfile {
safety: SafetyCandidate {
prearm_dwell_microseconds: default_u64(ids::PREARM_DWELL_MICROSECONDS),
},
timing: TimingCandidate {
maximum_imu_interval_microseconds: default_u64(ids::MAXIMUM_IMU_INTERVAL_MICROSECONDS),
imu_freshness_microseconds: default_u64(ids::IMU_FRESHNESS_MICROSECONDS),
local_state_freshness_microseconds: default_u64(
ids::LOCAL_STATE_FRESHNESS_MICROSECONDS,
),
command_link_freshness_microseconds: default_u64(
ids::COMMAND_LINK_FRESHNESS_MICROSECONDS,
),
},
aiding: AidingCandidate {
position_variance_m2: default_f32(ids::POSITION_VARIANCE_M2),
velocity_variance_m2_per_second_squared: default_f32(
ids::VELOCITY_VARIANCE_M2_PER_SECOND_SQUARED,
),
},
control: ControlCandidate {
position_gain_per_second_squared: default_f32(ids::POSITION_GAIN_PER_SECOND_SQUARED),
velocity_gain_per_second: default_f32(ids::VELOCITY_GAIN_PER_SECOND),
maximum_horizontal_acceleration_mps2: default_f32(
ids::MAXIMUM_HORIZONTAL_ACCELERATION_MPS2,
),
maximum_upward_acceleration_mps2: default_f32(ids::MAXIMUM_UPWARD_ACCELERATION_MPS2),
maximum_downward_acceleration_mps2: default_f32(
ids::MAXIMUM_DOWNWARD_ACCELERATION_MPS2,
),
},
thrust: ThrustCandidate {
mass_kg: default_f32(ids::MASS_KG),
maximum_collective_thrust_newtons: default_f32(ids::MAXIMUM_COLLECTIVE_THRUST_NEWTONS),
maximum_tilt_tangent: default_f32(ids::MAXIMUM_TILT_TANGENT),
},
rate_pid: PidCandidate {
proportional: default_f32(ids::RATE_PID_PROPORTIONAL),
integral: default_f32(ids::RATE_PID_INTEGRAL),
derivative: default_f32(ids::RATE_PID_DERIVATIVE),
output_limit: default_f32(ids::RATE_PID_OUTPUT_LIMIT),
integral_limit: default_f32(ids::RATE_PID_INTEGRAL_LIMIT),
},
actuator_limits: ActuatorLimitsCandidate {
minimum: default_f32(ids::ACTUATOR_MINIMUM),
maximum: default_f32(ids::ACTUATOR_MAXIMUM),
slew_per_second: default_f32(ids::ACTUATOR_SLEW_PER_SECOND),
},
observability: ObservabilityCandidate {
maximum_log_level: default_u32(ids::OBSERVABILITY_MAXIMUM_LOG_LEVEL),
},
}
}
pub(super) fn descriptor(index: u16) -> Option<&'static FieldDescriptor> {
FIELDS.get(usize::from(index))
}
pub(super) fn field_value(candidate: &CandidateProfile, id: FieldId) -> Option<ConfigValue> {
Some(match id {
ids::PREARM_DWELL_MICROSECONDS => {
ConfigValue::U64(candidate.safety.prearm_dwell_microseconds)
}
ids::MAXIMUM_IMU_INTERVAL_MICROSECONDS => {
ConfigValue::U64(candidate.timing.maximum_imu_interval_microseconds)
}
ids::IMU_FRESHNESS_MICROSECONDS => {
ConfigValue::U64(candidate.timing.imu_freshness_microseconds)
}
ids::LOCAL_STATE_FRESHNESS_MICROSECONDS => {
ConfigValue::U64(candidate.timing.local_state_freshness_microseconds)
}
ids::COMMAND_LINK_FRESHNESS_MICROSECONDS => {
ConfigValue::U64(candidate.timing.command_link_freshness_microseconds)
}
ids::POSITION_VARIANCE_M2 => ConfigValue::F32(candidate.aiding.position_variance_m2),
ids::VELOCITY_VARIANCE_M2_PER_SECOND_SQUARED => {
ConfigValue::F32(candidate.aiding.velocity_variance_m2_per_second_squared)
}
ids::POSITION_GAIN_PER_SECOND_SQUARED => {
ConfigValue::F32(candidate.control.position_gain_per_second_squared)
}
ids::VELOCITY_GAIN_PER_SECOND => {
ConfigValue::F32(candidate.control.velocity_gain_per_second)
}
ids::MAXIMUM_HORIZONTAL_ACCELERATION_MPS2 => {
ConfigValue::F32(candidate.control.maximum_horizontal_acceleration_mps2)
}
ids::MAXIMUM_UPWARD_ACCELERATION_MPS2 => {
ConfigValue::F32(candidate.control.maximum_upward_acceleration_mps2)
}
ids::MAXIMUM_DOWNWARD_ACCELERATION_MPS2 => {
ConfigValue::F32(candidate.control.maximum_downward_acceleration_mps2)
}
ids::MASS_KG => ConfigValue::F32(candidate.thrust.mass_kg),
ids::MAXIMUM_COLLECTIVE_THRUST_NEWTONS => {
ConfigValue::F32(candidate.thrust.maximum_collective_thrust_newtons)
}
ids::MAXIMUM_TILT_TANGENT => ConfigValue::F32(candidate.thrust.maximum_tilt_tangent),
ids::RATE_PID_PROPORTIONAL => ConfigValue::F32(candidate.rate_pid.proportional),
ids::RATE_PID_INTEGRAL => ConfigValue::F32(candidate.rate_pid.integral),
ids::RATE_PID_DERIVATIVE => ConfigValue::F32(candidate.rate_pid.derivative),
ids::RATE_PID_OUTPUT_LIMIT => ConfigValue::F32(candidate.rate_pid.output_limit),
ids::RATE_PID_INTEGRAL_LIMIT => ConfigValue::F32(candidate.rate_pid.integral_limit),
ids::ACTUATOR_MINIMUM => ConfigValue::F32(candidate.actuator_limits.minimum),
ids::ACTUATOR_MAXIMUM => ConfigValue::F32(candidate.actuator_limits.maximum),
ids::ACTUATOR_SLEW_PER_SECOND => {
ConfigValue::F32(candidate.actuator_limits.slew_per_second)
}
ids::OBSERVABILITY_MAXIMUM_LOG_LEVEL => {
ConfigValue::U32(candidate.observability.maximum_log_level)
}
_ => return None,
})
}
pub(super) fn set_field(
candidate: &mut CandidateProfile,
id: FieldId,
value: ConfigValue,
) -> Result<(), FieldWriteError> {
let value = match descriptor_for(id) {
Some(descriptor) => match descriptor.kind {
ValueKind::U64 => ConfigValue::U64(validate_u64(id, value, descriptor)?),
ValueKind::U32 => ConfigValue::U32(validate_u32(id, value, descriptor)?),
ValueKind::F32 => ConfigValue::F32(validate_f32(id, value, descriptor)?),
kind => {
return Err(FieldWriteError::WrongType {
field: id,
expected: kind,
actual: value.kind(),
});
}
},
None => return Err(FieldWriteError::UnknownField(id)),
};
match value {
ConfigValue::U64(value) => match id {
ids::PREARM_DWELL_MICROSECONDS => candidate.safety.prearm_dwell_microseconds = value,
ids::MAXIMUM_IMU_INTERVAL_MICROSECONDS => {
candidate.timing.maximum_imu_interval_microseconds = value
}
ids::IMU_FRESHNESS_MICROSECONDS => candidate.timing.imu_freshness_microseconds = value,
ids::LOCAL_STATE_FRESHNESS_MICROSECONDS => {
candidate.timing.local_state_freshness_microseconds = value
}
ids::COMMAND_LINK_FRESHNESS_MICROSECONDS => {
candidate.timing.command_link_freshness_microseconds = value
}
_ => return Err(FieldWriteError::UnknownField(id)),
},
ConfigValue::U32(value) => match id {
ids::OBSERVABILITY_MAXIMUM_LOG_LEVEL => {
candidate.observability.maximum_log_level = value
}
_ => return Err(FieldWriteError::UnknownField(id)),
},
ConfigValue::F32(value) => match id {
ids::POSITION_VARIANCE_M2 => candidate.aiding.position_variance_m2 = value,
ids::VELOCITY_VARIANCE_M2_PER_SECOND_SQUARED => {
candidate.aiding.velocity_variance_m2_per_second_squared = value
}
ids::POSITION_GAIN_PER_SECOND_SQUARED => {
candidate.control.position_gain_per_second_squared = value
}
ids::VELOCITY_GAIN_PER_SECOND => candidate.control.velocity_gain_per_second = value,
ids::MAXIMUM_HORIZONTAL_ACCELERATION_MPS2 => {
candidate.control.maximum_horizontal_acceleration_mps2 = value
}
ids::MAXIMUM_UPWARD_ACCELERATION_MPS2 => {
candidate.control.maximum_upward_acceleration_mps2 = value
}
ids::MAXIMUM_DOWNWARD_ACCELERATION_MPS2 => {
candidate.control.maximum_downward_acceleration_mps2 = value
}
ids::MASS_KG => candidate.thrust.mass_kg = value,
ids::MAXIMUM_COLLECTIVE_THRUST_NEWTONS => {
candidate.thrust.maximum_collective_thrust_newtons = value
}
ids::MAXIMUM_TILT_TANGENT => candidate.thrust.maximum_tilt_tangent = value,
ids::RATE_PID_PROPORTIONAL => candidate.rate_pid.proportional = value,
ids::RATE_PID_INTEGRAL => candidate.rate_pid.integral = value,
ids::RATE_PID_DERIVATIVE => candidate.rate_pid.derivative = value,
ids::RATE_PID_OUTPUT_LIMIT => candidate.rate_pid.output_limit = value,
ids::RATE_PID_INTEGRAL_LIMIT => candidate.rate_pid.integral_limit = value,
ids::ACTUATOR_MINIMUM => candidate.actuator_limits.minimum = value,
ids::ACTUATOR_MAXIMUM => candidate.actuator_limits.maximum = value,
ids::ACTUATOR_SLEW_PER_SECOND => candidate.actuator_limits.slew_per_second = value,
_ => return Err(FieldWriteError::UnknownField(id)),
},
ConfigValue::Bool(_) | ConfigValue::I32(_) | ConfigValue::F64(_) => {
return Err(FieldWriteError::WrongType {
field: id,
expected: descriptor_for(id).expect("checked descriptor").kind,
actual: value.kind(),
});
}
}
Ok(())
}
fn validate_u32(
id: FieldId,
value: ConfigValue,
descriptor: &FieldDescriptor,
) -> Result<u32, FieldWriteError> {
let ConfigValue::U32(value) = value else {
return Err(FieldWriteError::WrongType {
field: id,
expected: ValueKind::U32,
actual: value.kind(),
});
};
let Some(ConfigValue::U32(minimum)) = descriptor.minimum else {
unreachable!()
};
let Some(ConfigValue::U32(maximum)) = descriptor.maximum else {
unreachable!()
};
if value < minimum {
return Err(FieldWriteError::Violation(ConfigViolation::field(
id,
ValidationRule::BelowMinimum,
)));
}
if value > maximum {
return Err(FieldWriteError::Violation(ConfigViolation::field(
id,
ValidationRule::AboveMaximum,
)));
}
Ok(value)
}
fn descriptor_for(id: FieldId) -> Option<&'static FieldDescriptor> {
FIELDS.iter().find(|descriptor| descriptor.id == id)
}
fn default_u32(id: FieldId) -> u32 {
match descriptor_for(id).expect("generated descriptor").default {
ConfigValue::U32(value) => value,
_ => unreachable!("generated descriptor type"),
}
}
fn default_u64(id: FieldId) -> u64 {
match descriptor_for(id).expect("generated descriptor").default {
ConfigValue::U64(value) => value,
_ => unreachable!("generated descriptor type"),
}
}
fn default_f32(id: FieldId) -> f32 {
match descriptor_for(id).expect("generated descriptor").default {
ConfigValue::F32(value) => value,
_ => unreachable!("generated descriptor type"),
}
}
fn validate_u64(
id: FieldId,
value: ConfigValue,
descriptor: &FieldDescriptor,
) -> Result<u64, FieldWriteError> {
let ConfigValue::U64(value) = value else {
return Err(FieldWriteError::WrongType {
field: id,
expected: ValueKind::U64,
actual: value.kind(),
});
};
let Some(ConfigValue::U64(minimum)) = descriptor.minimum else {
unreachable!()
};
let Some(ConfigValue::U64(maximum)) = descriptor.maximum else {
unreachable!()
};
if value < minimum {
return Err(FieldWriteError::Violation(ConfigViolation::field(
id,
ValidationRule::BelowMinimum,
)));
}
if value > maximum {
return Err(FieldWriteError::Violation(ConfigViolation::field(
id,
ValidationRule::AboveMaximum,
)));
}
Ok(value)
}
fn validate_f32(
id: FieldId,
value: ConfigValue,
descriptor: &FieldDescriptor,
) -> Result<f32, FieldWriteError> {
let ConfigValue::F32(value) = value else {
return Err(FieldWriteError::WrongType {
field: id,
expected: ValueKind::F32,
actual: value.kind(),
});
};
if !value.is_finite() {
return Err(FieldWriteError::non_finite(id));
}
let Some(ConfigValue::F32(minimum)) = descriptor.minimum else {
unreachable!()
};
let Some(ConfigValue::F32(maximum)) = descriptor.maximum else {
unreachable!()
};
if value < minimum {
return Err(FieldWriteError::Violation(ConfigViolation::field(
id,
ValidationRule::BelowMinimum,
)));
}
if value > maximum {
return Err(FieldWriteError::Violation(ConfigViolation::field(
id,
ValidationRule::AboveMaximum,
)));
}
Ok(value)
}
@@ -0,0 +1,20 @@
use kaptan_config::{ConfigValidate, ConfigViolation, ValidateFields};
#[derive(Clone, Copy, ConfigValidate)]
pub(super) struct SafetyCandidate {
#[config(
id = 1,
key = "safety.prearm_dwell_us",
unit = "us",
default = 100_000,
nonzero,
range(min = 1, max = 10_000_000)
)]
pub(super) prearm_dwell_microseconds: u64,
}
impl SafetyCandidate {
pub(super) fn validate(self) -> Result<(), ConfigViolation> {
self.validate_fields()
}
}
@@ -0,0 +1,57 @@
use kaptan_config::{ConfigValidate, ConfigViolation, ValidateFields, ValidationRule};
use super::ids;
#[derive(Clone, Copy, ConfigValidate)]
pub(super) struct TimingCandidate {
#[config(
id = 2,
key = "timing.imu_max_interval_us",
unit = "us",
default = 50_000,
nonzero,
range(min = 1, max = 1_000_000)
)]
pub(super) maximum_imu_interval_microseconds: u64,
#[config(
id = 3,
key = "timing.imu_freshness_us",
unit = "us",
default = 50_000,
nonzero,
range(min = 1, max = 1_000_000)
)]
pub(super) imu_freshness_microseconds: u64,
#[config(
id = 4,
key = "timing.local_state_freshness_us",
unit = "us",
default = 150_000,
nonzero,
range(min = 1, max = 10_000_000)
)]
pub(super) local_state_freshness_microseconds: u64,
#[config(
id = 5,
key = "timing.link_freshness_us",
unit = "us",
default = 500_000,
nonzero,
range(min = 1, max = 10_000_000)
)]
pub(super) command_link_freshness_microseconds: u64,
}
impl TimingCandidate {
pub(super) fn validate(self) -> Result<(), ConfigViolation> {
self.validate_fields()?;
if self.maximum_imu_interval_microseconds > self.imu_freshness_microseconds {
return Err(ConfigViolation::related(
ids::MAXIMUM_IMU_INTERVAL_MICROSECONDS,
ids::IMU_FRESHNESS_MICROSECONDS,
ValidationRule::Inconsistent,
));
}
Ok(())
}
}