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,378 @@
|
||||
//! 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),
|
||||
}
|
||||
|
||||
/// Failure while encoding one complete candidate snapshot.
|
||||
///
|
||||
/// The candidate is validated before the first CBOR item is written, so a
|
||||
/// caller can never export a locally valid but relationally invalid edit.
|
||||
#[derive(Debug)]
|
||||
pub enum CborEncodeError<E> {
|
||||
/// The complete candidate did not validate against immutable board
|
||||
/// capabilities.
|
||||
InvalidProfile(ConfigViolation),
|
||||
/// The caller-owned CBOR writer rejected the encoded snapshot.
|
||||
Encode(minicbor::encode::Error<E>),
|
||||
}
|
||||
|
||||
/// 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(capabilities)
|
||||
.map_err(CborError::InvalidProfile)?;
|
||||
Ok(candidate)
|
||||
}
|
||||
|
||||
/// Validates and encodes one complete current snapshot to a caller-owned CBOR
|
||||
/// writer.
|
||||
///
|
||||
/// Validation happens before creating the encoder, ensuring a failed
|
||||
/// cross-field or capability check emits no partial CBOR record.
|
||||
pub fn encode_candidate<W>(
|
||||
candidate: CandidateProfile,
|
||||
capabilities: &VehicleCapabilities,
|
||||
writer: W,
|
||||
) -> Result<(), CborEncodeError<W::Error>>
|
||||
where
|
||||
W: Write,
|
||||
{
|
||||
candidate
|
||||
.validate(capabilities)
|
||||
.map_err(CborEncodeError::InvalidProfile)?;
|
||||
let mut encoder = Encoder::new(writer);
|
||||
encoder
|
||||
.map(CandidateProfile::FIELD_COUNT as u64 + 2)
|
||||
.map_err(CborEncodeError::Encode)?;
|
||||
encoder
|
||||
.u16(FORMAT_KEY)
|
||||
.and_then(|encoder| encoder.u16(FORMAT_VERSION))
|
||||
.map_err(CborEncodeError::Encode)?;
|
||||
encoder
|
||||
.u16(SCHEMA_KEY)
|
||||
.and_then(|encoder| encoder.bytes(&SCHEMA_ID))
|
||||
.map_err(CborEncodeError::Encode)?;
|
||||
for descriptor in CandidateProfile::descriptors() {
|
||||
encoder
|
||||
.u16(descriptor.id.get())
|
||||
.map_err(CborEncodeError::Encode)?;
|
||||
encode_value(
|
||||
&mut encoder,
|
||||
candidate
|
||||
.field_value(descriptor.id)
|
||||
.expect("descriptor field"),
|
||||
)
|
||||
.map_err(CborEncodeError::Encode)?;
|
||||
}
|
||||
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(), &capabilities, &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 encoding_rejects_a_cross_field_invalid_transaction_before_writing() {
|
||||
let capabilities = VehicleCapabilities::quad_x();
|
||||
let mut candidate = CandidateProfile::default();
|
||||
candidate
|
||||
.set_field(kaptan_config::FieldId::new(2), ConfigValue::U64(50_001))
|
||||
.expect("the scalar is locally valid");
|
||||
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
|
||||
|
||||
assert!(matches!(
|
||||
encode_candidate(candidate, &capabilities, &mut writer),
|
||||
Err(CborEncodeError::InvalidProfile(violation))
|
||||
if violation.rule() == ValidationRule::Inconsistent
|
||||
));
|
||||
assert_eq!(writer.position(), 0);
|
||||
}
|
||||
|
||||
#[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)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cbor_scalar_write_preserves_the_generated_field_and_rule() {
|
||||
let capabilities = VehicleCapabilities::quad_x();
|
||||
let mass = CandidateProfile::descriptors()
|
||||
.iter()
|
||||
.find(|descriptor| descriptor.display_key == "thrust.mass_kg")
|
||||
.expect("declared mass descriptor");
|
||||
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(descriptor.id.get()).unwrap();
|
||||
let value = if descriptor.id == mass.id {
|
||||
ConfigValue::F32(0.009)
|
||||
} else {
|
||||
candidate.field_value(descriptor.id).unwrap()
|
||||
};
|
||||
encode_value(&mut encoder, value).unwrap();
|
||||
}
|
||||
let length = writer.position();
|
||||
let bytes = writer.into_inner();
|
||||
|
||||
assert!(matches!(
|
||||
decode_candidate(&bytes[..length], &capabilities),
|
||||
Err(CborError::InvalidProfile(issue))
|
||||
if issue.primary() == Some(mass.id)
|
||||
&& issue.related_field().is_none()
|
||||
&& issue.rule() == ValidationRule::BelowMinimum
|
||||
));
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//! Vehicle configuration schema and CBOR snapshot codec.
|
||||
|
||||
#![no_std]
|
||||
|
||||
pub mod cbor;
|
||||
pub mod profile;
|
||||
|
||||
pub use cbor::{
|
||||
CborEncodeError, 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,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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
use kaptan_config::{ConfigValidate, ConfigViolation, 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,
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
#[derive(Clone, Copy, ConfigValidate)]
|
||||
#[config(post = Self::validate_thrust)]
|
||||
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 {
|
||||
fn validate_thrust(&self) -> Result<(), ConfigViolation> {
|
||||
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)]
|
||||
#[config(post = Self::validate_pid)]
|
||||
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 {
|
||||
fn validate_pid(&self) -> Result<(), ConfigViolation> {
|
||||
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)]
|
||||
#[config(post = Self::validate_actuator_limits)]
|
||||
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 {
|
||||
fn validate_actuator_limits(&self) -> Result<(), ConfigViolation> {
|
||||
if self.minimum >= self.maximum {
|
||||
return Err(ConfigViolation::related(
|
||||
ids::ACTUATOR_MINIMUM,
|
||||
ids::ACTUATOR_MAXIMUM,
|
||||
ValidationRule::Inconsistent,
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//! Symbolic projections used by relation checks and focused tests.
|
||||
//!
|
||||
//! These constants never define an ID. Each projects the metadata generated
|
||||
//! from its owning `#[config(id = ...)]` declaration.
|
||||
|
||||
use kaptan_config::FieldId;
|
||||
|
||||
use super::{
|
||||
control::{ActuatorLimitsCandidate, PidCandidate, ThrustCandidate},
|
||||
timing::TimingCandidate,
|
||||
};
|
||||
|
||||
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 MASS_KG: FieldId = ThrustCandidate::DESCRIPTORS[0].id;
|
||||
pub(super) const MAXIMUM_COLLECTIVE_THRUST_NEWTONS: FieldId = ThrustCandidate::DESCRIPTORS[1].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;
|
||||
@@ -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,35 @@
|
||||
//! Persisted, restart-only runtime-observability policy.
|
||||
|
||||
use kaptan_config::ConfigValidate;
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// 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,80 @@
|
||||
//! Aggregate descriptor registry for the generated vehicle sections.
|
||||
//!
|
||||
//! Every descriptor is generated from its owning scalar field. This ordered
|
||||
//! projection is intentionally exhaustive: `FIELD_COUNT` comes from the root
|
||||
//! aggregate, so adding an attributed field without extending this view fails
|
||||
//! at compile time instead of silently omitting it from CBOR or transport.
|
||||
|
||||
use kaptan_config::FieldDescriptor;
|
||||
|
||||
use super::{
|
||||
candidate::CandidateProfile,
|
||||
control::{
|
||||
ActuatorLimitsCandidate, AidingCandidate, ControlCandidate, PidCandidate, ThrustCandidate,
|
||||
},
|
||||
observability::ObservabilityCandidate,
|
||||
safety::SafetyCandidate,
|
||||
timing::TimingCandidate,
|
||||
};
|
||||
|
||||
/// Number of operator-editable fields in the current vehicle profile.
|
||||
///
|
||||
/// Allocation topology remains immutable board capability data, not an
|
||||
/// operator parameter.
|
||||
pub(super) const FIELD_COUNT: usize = CandidateProfile::FIELD_COUNT;
|
||||
|
||||
/// Ordered view of metadata generated from field attributes.
|
||||
///
|
||||
/// This has no independently-maintained defaults, bounds, values, or setter
|
||||
/// logic. The length is tied to `CandidateProfile::FIELD_COUNT` as a
|
||||
/// compile-time completeness guard.
|
||||
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
|
||||
}
|
||||
|
||||
pub(super) fn descriptor(index: u16) -> Option<&'static FieldDescriptor> {
|
||||
FIELDS.get(usize::from(index))
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
use kaptan_config::ConfigValidate;
|
||||
|
||||
#[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,
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
use kaptan_config::{ConfigValidate, ConfigViolation, ValidationRule};
|
||||
|
||||
use super::ids;
|
||||
|
||||
#[derive(Clone, Copy, ConfigValidate)]
|
||||
#[config(post = Self::validate_timing)]
|
||||
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 {
|
||||
fn validate_timing(&self) -> Result<(), ConfigViolation> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user