feat(firmware): validate configuration and migrate sensor-driven flight APIs
Validate configuration through its owning schemas and keep board declarations separate from shared F722 resource ownership. Bound USB transitions, isolate transport failures and retain restart-only activation. Migrate navigation and flight consumers with the sensor and schema contracts. Require explicit installation data and synchronous output authority; board motor outputs remain inhibited. Validation: assembled-prefix host tests, strict Clippy, platform/USB tests and both ARM release builds. Final-source coverage and scoped Miri/Kani evidence are retained separately from hardware qualification.
This commit is contained in:
1 parent
fb1b8af2d1
commit
f9a61a086f
211 files changed
+45657
-12740
No files matched your search
@@ -0,0 +1,155 @@
|
||||
//! Bounded public-operation histories at the synchronous commit boundary.
|
||||
|
||||
use super::*;
|
||||
|
||||
fn healthy() -> SafetyInputs {
|
||||
SafetyInputs {
|
||||
estimator_healthy: true,
|
||||
imu_fresh: true,
|
||||
phase_authorized: true,
|
||||
output_healthy: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn any_inputs() -> SafetyInputs {
|
||||
SafetyInputs {
|
||||
estimator_healthy: kani::any(),
|
||||
imu_fresh: kani::any(),
|
||||
phase_authorized: kani::any(),
|
||||
output_healthy: kani::any(),
|
||||
}
|
||||
}
|
||||
|
||||
fn supervisor() -> (SafetySupervisor, u64) {
|
||||
let dwell: u64 = kani::any();
|
||||
kani::assume(dwell > 0);
|
||||
(
|
||||
SafetySupervisor::new(SafetyConfig::new(dwell).unwrap()),
|
||||
dwell,
|
||||
)
|
||||
}
|
||||
|
||||
#[kani::proof]
|
||||
fn commit_requires_current_health_and_completed_dwell() {
|
||||
let (mut safety, dwell) = supervisor();
|
||||
let start: u64 = kani::any();
|
||||
let now: u64 = kani::any();
|
||||
safety.request_arm(Timestamp::from_microseconds(start), healthy());
|
||||
|
||||
let inputs = any_inputs();
|
||||
let admitted = inputs == healthy()
|
||||
&& now
|
||||
.checked_sub(start)
|
||||
.is_some_and(|elapsed| elapsed >= dwell);
|
||||
let mut calls = 0_u8;
|
||||
let value: u32 = kani::any();
|
||||
let outcome = safety.evaluate_and_commit(Timestamp::from_microseconds(now), inputs, || {
|
||||
calls += 1;
|
||||
Ok::<_, ()>(value)
|
||||
});
|
||||
assert_eq!(calls, u8::from(admitted));
|
||||
if admitted {
|
||||
assert_eq!(outcome, CommitOutcome::Committed(value));
|
||||
assert_eq!(safety.state(), SafetyState::Armed);
|
||||
} else {
|
||||
assert!(matches!(outcome, CommitOutcome::Inhibited(_)));
|
||||
}
|
||||
kani::cover!(admitted);
|
||||
kani::cover!(safety.state() == SafetyState::PreArm);
|
||||
kani::cover!(now < start);
|
||||
kani::cover!(!inputs.imu_fresh && now >= start);
|
||||
}
|
||||
|
||||
#[kani::proof]
|
||||
fn failed_commit_preserves_error_and_latches_inhibition() {
|
||||
let (mut safety, dwell) = supervisor();
|
||||
let start: u64 = kani::any();
|
||||
let Some(armed_at) = start.checked_add(dwell) else {
|
||||
return;
|
||||
};
|
||||
safety.request_arm(Timestamp::from_microseconds(start), healthy());
|
||||
let error: u32 = kani::any();
|
||||
let mut calls = 0_u8;
|
||||
let failed =
|
||||
safety.evaluate_and_commit(Timestamp::from_microseconds(armed_at), healthy(), || {
|
||||
calls += 1;
|
||||
Err::<(), _>(error)
|
||||
});
|
||||
assert_eq!(calls, 1);
|
||||
assert_eq!(failed, CommitOutcome::Failed(error));
|
||||
assert_eq!(safety.state(), SafetyState::Failsafe);
|
||||
assert_eq!(
|
||||
safety.decision().failure(),
|
||||
Some(SafetyFailure::CommitFailed)
|
||||
);
|
||||
|
||||
// No disarm or epoch reset occurs between failure and this next evaluation.
|
||||
let next = Timestamp::from_microseconds(kani::any());
|
||||
let inputs = any_inputs();
|
||||
let outcome = safety.evaluate_and_commit(next, inputs, || {
|
||||
calls += 1;
|
||||
Ok::<_, ()>(())
|
||||
});
|
||||
assert!(matches!(outcome, CommitOutcome::Inhibited(_)));
|
||||
assert_eq!(calls, 1);
|
||||
assert_eq!(safety.state(), SafetyState::Failsafe);
|
||||
kani::cover!(inputs == healthy() && next.as_microseconds() >= armed_at);
|
||||
}
|
||||
|
||||
#[kani::proof]
|
||||
fn clock_regression_requires_successful_inhibited_reset() {
|
||||
let (mut safety, dwell) = supervisor();
|
||||
let first: u64 = kani::any();
|
||||
let earlier: u64 = kani::any();
|
||||
kani::assume(earlier < first);
|
||||
safety.request_arm(Timestamp::from_microseconds(first), healthy());
|
||||
let mut commits = 0_u8;
|
||||
let regressed =
|
||||
safety.evaluate_and_commit(Timestamp::from_microseconds(earlier), healthy(), || {
|
||||
commits += 1;
|
||||
Ok::<_, ()>(())
|
||||
});
|
||||
assert!(matches!(regressed, CommitOutcome::Inhibited(_)));
|
||||
assert_eq!(
|
||||
safety.decision().failure(),
|
||||
Some(SafetyFailure::ClockRegression)
|
||||
);
|
||||
safety.request_disarm();
|
||||
assert_eq!(
|
||||
safety
|
||||
.request_arm(Timestamp::from_microseconds(first), healthy())
|
||||
.state(),
|
||||
SafetyState::Failsafe
|
||||
);
|
||||
|
||||
safety.request_disarm();
|
||||
let inhibit_succeeds: bool = kani::any();
|
||||
let error: u32 = kani::any();
|
||||
let mut inhibitions = 0_u8;
|
||||
let reset = safety.reset_time_epoch(|| {
|
||||
inhibitions += 1;
|
||||
if inhibit_succeeds { Ok(()) } else { Err(error) }
|
||||
});
|
||||
assert_eq!(inhibitions, 1);
|
||||
if inhibit_succeeds {
|
||||
assert_eq!(reset, Ok(()));
|
||||
} else {
|
||||
assert_eq!(reset, Err(ResetError::Inhibit(error)));
|
||||
assert_eq!(safety.state(), SafetyState::Failsafe);
|
||||
}
|
||||
|
||||
safety.request_disarm();
|
||||
safety.request_arm(Timestamp::from_microseconds(0), healthy());
|
||||
let outcome =
|
||||
safety.evaluate_and_commit(Timestamp::from_microseconds(dwell), healthy(), || {
|
||||
commits += 1;
|
||||
Ok::<_, ()>(())
|
||||
});
|
||||
assert_eq!(commits, u8::from(inhibit_succeeds));
|
||||
assert_eq!(
|
||||
matches!(outcome, CommitOutcome::Committed(())),
|
||||
inhibit_succeeds
|
||||
);
|
||||
kani::cover!(inhibit_succeeds);
|
||||
kani::cover!(!inhibit_succeeds);
|
||||
}
|
||||
@@ -1,79 +1,80 @@
|
||||
//! Bounded safety supervisor for actuator authorization.
|
||||
//! Bounded safety supervision with a synchronous actuator commit boundary.
|
||||
//!
|
||||
//! The platform turns estimator, command, and transport health into the
|
||||
//! explicit inputs below. This crate does not authenticate a command or drive
|
||||
//! hardware; a reviewed platform must evaluate it before every output commit
|
||||
//! and make the output port validate the returned opaque permit.
|
||||
//! Decisions are diagnostics. Only `evaluate_and_commit` invokes an enabled
|
||||
//! output operation, while holding the issuing supervisor's exclusive borrow.
|
||||
//! The callback must commit synchronously, not queue future authority.
|
||||
//!
|
||||
//! Callbacks must not panic. They must report operational failure with `Err`.
|
||||
//! Panics do not run the returned-error transition or guarantee inhibition.
|
||||
//! If a host catches an unwind, it must discard the affected supervisor and
|
||||
//! callback-owned state, never resume them or use an epoch reset as recovery.
|
||||
//! The platform owns abort/halt panic containment. Neither abort nor halt alone
|
||||
//! establishes that physical outputs are zero.
|
||||
//!
|
||||
//! There is no transferable permission or cross-supervisor validation API.
|
||||
//! ```compile_fail
|
||||
//! use kaptan_safety::ActuatorPermit;
|
||||
//! ```
|
||||
//! ```compile_fail
|
||||
//! use kaptan_safety::{SafetyConfig, SafetySupervisor};
|
||||
//! let a = SafetySupervisor::new(SafetyConfig::new(1).unwrap());
|
||||
//! let b = SafetySupervisor::new(SafetyConfig::new(1).unwrap());
|
||||
//! b.permit_is_current(a.decision().permit().unwrap());
|
||||
//! ```
|
||||
|
||||
#![no_std]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use kaptan_err_reportable::KaptanError;
|
||||
use kaptan_time::Timestamp;
|
||||
|
||||
/// Supervisor arming state.
|
||||
#[cfg(kani)]
|
||||
mod kani_proofs;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum SafetyState {
|
||||
/// Physical outputs inhibited.
|
||||
/// No current arm transaction.
|
||||
Disarmed,
|
||||
/// Checks are continuously passing during the configured dwell period.
|
||||
/// Continuously healthy evidence must satisfy the configured dwell.
|
||||
PreArm,
|
||||
/// Outputs may be enabled by a platform transport that validates a permit.
|
||||
/// The next synchronous commit still requires evaluation of current inputs.
|
||||
Armed,
|
||||
/// Outputs are inhibited pending an explicit disarm and new arm request.
|
||||
/// Inhibited until explicit disarm. Clock faults also require an epoch reset.
|
||||
Failsafe,
|
||||
}
|
||||
|
||||
/// Typed reason for refusing or ending actuator authorization.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
|
||||
pub enum SafetyFailure {
|
||||
/// Estimator is absent, stale, or non-finite.
|
||||
#[kaptan_error(id = 9001)]
|
||||
EstimatorUnhealthy,
|
||||
/// Required inertial sensor stream is stale.
|
||||
#[kaptan_error(id = 9002)]
|
||||
ImuStale,
|
||||
/// Required command/link stream is stale.
|
||||
#[kaptan_error(id = 9003)]
|
||||
LinkStale,
|
||||
/// Actuator transport reports a fault.
|
||||
PhaseUnauthorized,
|
||||
#[kaptan_error(id = 9004)]
|
||||
ActuatorFault,
|
||||
/// No current explicit arm transaction exists.
|
||||
#[kaptan_error(id = 9005)]
|
||||
ArmNotRequested,
|
||||
/// The platform supplied time that moved backwards.
|
||||
#[kaptan_error(id = 9006)]
|
||||
ClockRegression,
|
||||
/// A non-wrapping actuator-permit epoch could not be allocated.
|
||||
#[kaptan_error(id = 9007)]
|
||||
PermitEpochExhausted,
|
||||
/// The synchronous commit failed. Its error is returned to the caller.
|
||||
#[kaptan_error(id = 9008)]
|
||||
CommitFailed,
|
||||
}
|
||||
|
||||
/// Invalid safety configuration.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
|
||||
pub enum SafetyConfigError {
|
||||
/// A zero dwell would permit two evaluations at one timestamp to arm.
|
||||
#[kaptan_error(id = 9101)]
|
||||
ZeroPrearmDwell,
|
||||
}
|
||||
|
||||
/// Explicit platform policy for the safety state machine.
|
||||
///
|
||||
/// This is intentionally not `Default`: a vehicle or simulator must choose
|
||||
/// and review its own continuously-healthy prearm dwell.
|
||||
///
|
||||
/// TODO(configuration): this value is currently selected by the build-time
|
||||
/// vehicle profile. A ground-station configuration service must validate it
|
||||
/// as part of one persisted safety/output profile and only activate the new
|
||||
/// revision while disarmed; a live duration change must not restart or extend
|
||||
/// an arming transaction.
|
||||
/// Validated build-time policy. Activate profile changes only while disarmed.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct SafetyConfig {
|
||||
prearm_dwell_microseconds: u64,
|
||||
}
|
||||
|
||||
impl SafetyConfig {
|
||||
/// Builds a policy with a strictly positive prearm dwell.
|
||||
pub const fn new(prearm_dwell_microseconds: u64) -> Result<Self, SafetyConfigError> {
|
||||
if prearm_dwell_microseconds == 0 {
|
||||
Err(SafetyConfigError::ZeroPrearmDwell)
|
||||
@@ -83,83 +84,65 @@ impl SafetyConfig {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the required continuously-healthy dwell duration.
|
||||
pub const fn prearm_dwell_microseconds(self) -> u64 {
|
||||
self.prearm_dwell_microseconds
|
||||
}
|
||||
}
|
||||
|
||||
/// Platform-normalized bounded health input. Every `true` is a current
|
||||
/// measurement, not a promise about sensor calibration or physical safety.
|
||||
/// Current platform-normalized evidence, not authentication or physical safety.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct SafetyInputs {
|
||||
/// Estimator finite/initialized health is acceptable for the requested mode.
|
||||
/// The estimator is initialized, finite, and fresh for the requested mode.
|
||||
pub estimator_healthy: bool,
|
||||
/// IMU receipt is within the static platform freshness limit.
|
||||
/// The required inertial stream is within the configured freshness limit.
|
||||
pub imu_fresh: bool,
|
||||
/// Control command/link receipt is within its freshness limit.
|
||||
///
|
||||
/// TODO(flight-config): an external-control flight mode must map lease
|
||||
/// expiry into a reviewed bounded hold or landing mode while estimator and
|
||||
/// actuator health remain valid; it must not pass that expired lease here
|
||||
/// as an immediate physical output inhibit. Fatal sensor/control/transport
|
||||
/// faults remain inhibit conditions.
|
||||
pub link_fresh: bool,
|
||||
/// Output transport is initialized and fault-free.
|
||||
pub actuator_healthy: bool,
|
||||
/// Runtime has applied mode deadlines and admitted this phase's authority.
|
||||
pub phase_authorized: bool,
|
||||
/// The synchronous output transport is initialized and fault-free.
|
||||
pub output_healthy: bool,
|
||||
}
|
||||
|
||||
/// Authorization token that external code cannot construct or clone.
|
||||
///
|
||||
/// A retained token cannot authorize output after a supervisor transition.
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub struct ActuatorPermit {
|
||||
epoch: u64,
|
||||
}
|
||||
|
||||
/// Observable supervisor transition decision.
|
||||
///
|
||||
/// The token is deliberately opaque and non-`Copy`. A transport must call
|
||||
/// [`SafetySupervisor::permit_is_current`] immediately before a nonzero
|
||||
/// output commit; the decision itself is not an output command.
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
/// Diagnostic snapshot. It cannot authorize an output operation.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct SafetyDecision {
|
||||
state: SafetyState,
|
||||
failure: Option<SafetyFailure>,
|
||||
permit: Option<ActuatorPermit>,
|
||||
}
|
||||
|
||||
impl SafetyDecision {
|
||||
/// Resulting supervisor state.
|
||||
pub const fn state(&self) -> SafetyState {
|
||||
pub const fn state(self) -> SafetyState {
|
||||
self.state
|
||||
}
|
||||
|
||||
/// Reason whenever physical output is inhibited.
|
||||
pub const fn failure(&self) -> Option<SafetyFailure> {
|
||||
pub const fn failure(self) -> Option<SafetyFailure> {
|
||||
self.failure
|
||||
}
|
||||
|
||||
/// Returns the authorization token only while the decision is armed.
|
||||
pub const fn permit(&self) -> Option<&ActuatorPermit> {
|
||||
self.permit.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
/// Small safety supervisor with no queues, heap, peripheral access, or hidden
|
||||
/// clock. A platform supplies one monotonic timestamp to every transition.
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub enum CommitOutcome<T, E> {
|
||||
/// The callback was not invoked.
|
||||
Inhibited(SafetyDecision),
|
||||
Committed(T),
|
||||
/// The callback ran once and failed. The supervisor is now in failsafe.
|
||||
Failed(E),
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub enum ResetError<E> {
|
||||
NotDisarmed,
|
||||
Inhibit(E),
|
||||
}
|
||||
|
||||
/// No queues, heap, peripherals, hidden clock, or transferable authority.
|
||||
/// Callbacks must not panic. A supervisor crossed by unwinding must be discarded.
|
||||
/// Platform panic containment is required independently of this state machine.
|
||||
pub struct SafetySupervisor {
|
||||
config: SafetyConfig,
|
||||
state: SafetyState,
|
||||
last_failure: Option<SafetyFailure>,
|
||||
prearm_since: Option<Timestamp>,
|
||||
last_evaluation: Option<Timestamp>,
|
||||
permit_epoch: u64,
|
||||
clock_fault: bool,
|
||||
}
|
||||
|
||||
impl SafetySupervisor {
|
||||
/// Starts disarmed and inhibited irrespective of command availability.
|
||||
pub const fn new(config: SafetyConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
@@ -167,449 +150,178 @@ impl SafetySupervisor {
|
||||
last_failure: Some(SafetyFailure::ArmNotRequested),
|
||||
prearm_since: None,
|
||||
last_evaluation: None,
|
||||
permit_epoch: 0,
|
||||
clock_fault: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Consumes one explicit arm transaction.
|
||||
///
|
||||
/// Only a healthy request while disarmed enters prearm. A failed request is
|
||||
/// never retained for a later healthy evaluation.
|
||||
/// A failed request is never retained for subsequent health recovery.
|
||||
pub fn request_arm(&mut self, now: Timestamp, inputs: SafetyInputs) -> SafetyDecision {
|
||||
if !self.observe_time(now) {
|
||||
if !self.observe_time(now) || self.state != SafetyState::Disarmed {
|
||||
return self.decision();
|
||||
}
|
||||
if self.state != SafetyState::Disarmed {
|
||||
return self.decision();
|
||||
match preflight_failure(inputs) {
|
||||
Some(SafetyFailure::ActuatorFault) => self.report_actuator_fault(),
|
||||
Some(failure) => self.last_failure = Some(failure),
|
||||
None => {
|
||||
self.state = SafetyState::PreArm;
|
||||
self.prearm_since = Some(now);
|
||||
self.last_failure = None;
|
||||
}
|
||||
}
|
||||
if let Some(failure) = preflight_failure(inputs) {
|
||||
self.last_failure = Some(failure);
|
||||
return self.decision();
|
||||
}
|
||||
self.state = SafetyState::PreArm;
|
||||
self.prearm_since = Some(now);
|
||||
self.last_failure = None;
|
||||
self.decision()
|
||||
}
|
||||
|
||||
/// Explicitly inhibits output and consumes any outstanding arm state.
|
||||
/// Consumes arming state. A clock fault still requires an explicit epoch reset.
|
||||
pub fn request_disarm(&mut self) {
|
||||
if self.state == SafetyState::Armed && !self.invalidate_active_permit() {
|
||||
return;
|
||||
}
|
||||
self.state = SafetyState::Disarmed;
|
||||
self.prearm_since = None;
|
||||
self.last_failure = Some(SafetyFailure::ArmNotRequested);
|
||||
self.last_failure = Some(if self.clock_fault {
|
||||
SafetyFailure::ClockRegression
|
||||
} else {
|
||||
SafetyFailure::ArmNotRequested
|
||||
});
|
||||
}
|
||||
|
||||
/// Begins a new platform clock epoch after output has been inhibited.
|
||||
///
|
||||
/// This is for a simulator restart or an explicitly reviewed platform
|
||||
/// clock reset. It preserves the monotonically increasing permit epoch so
|
||||
/// a token from before the reset can never validate after rearming.
|
||||
pub fn reset_time_epoch(&mut self) {
|
||||
self.request_disarm();
|
||||
self.prearm_since = None;
|
||||
/// Resets time only while disarmed and after synchronous inhibition succeeds.
|
||||
/// A failed output leaves all clock history intact and latches actuator fault.
|
||||
/// `inhibit` must return `Err` on failure, not panic. After unwinding, discard
|
||||
/// the supervisor and callback-owned state instead of retrying this method.
|
||||
pub fn reset_time_epoch<E>(
|
||||
&mut self,
|
||||
inhibit: impl FnOnce() -> Result<(), E>,
|
||||
) -> Result<(), ResetError<E>> {
|
||||
if self.state != SafetyState::Disarmed {
|
||||
return Err(ResetError::NotDisarmed);
|
||||
}
|
||||
if let Err(error) = inhibit() {
|
||||
self.report_actuator_fault();
|
||||
return Err(ResetError::Inhibit(error));
|
||||
}
|
||||
self.last_evaluation = None;
|
||||
self.clock_fault = false;
|
||||
self.request_disarm();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Evaluates preflight/failsafe inputs supplied by a platform that owns
|
||||
/// their timestamp and freshness policy. Platforms must call this before
|
||||
/// every physical output commit.
|
||||
pub fn evaluate(&mut self, now: Timestamp, inputs: SafetyInputs) -> SafetyDecision {
|
||||
/// Evaluates current evidence and invokes `commit` at most once, only armed.
|
||||
/// No permission escapes this exclusive borrow. Returned callback errors latch
|
||||
/// failsafe before returning their original error for required cleanup.
|
||||
/// `commit` must not panic. Unwinding skips this error transition and requires
|
||||
/// discarding the supervisor and callback-owned state, not resuming execution.
|
||||
///
|
||||
/// A callback cannot re-enter or replace its issuing supervisor.
|
||||
/// ```compile_fail
|
||||
/// use kaptan_safety::{SafetyConfig, SafetyInputs, SafetySupervisor};
|
||||
/// use kaptan_time::Timestamp;
|
||||
/// let mut safety = SafetySupervisor::new(SafetyConfig::new(1).unwrap());
|
||||
/// let inputs = SafetyInputs {
|
||||
/// estimator_healthy: true, imu_fresh: true,
|
||||
/// phase_authorized: true, output_healthy: true,
|
||||
/// };
|
||||
/// safety.evaluate_and_commit(Timestamp::from_microseconds(1), inputs, || {
|
||||
/// safety.request_disarm();
|
||||
/// Ok::<_, ()>(())
|
||||
/// });
|
||||
/// ```
|
||||
pub fn evaluate_and_commit<T, E>(
|
||||
&mut self,
|
||||
now: Timestamp,
|
||||
inputs: SafetyInputs,
|
||||
commit: impl FnOnce() -> Result<T, E>,
|
||||
) -> CommitOutcome<T, E> {
|
||||
self.evaluate(now, inputs);
|
||||
if self.state != SafetyState::Armed {
|
||||
return CommitOutcome::Inhibited(self.decision());
|
||||
}
|
||||
match commit() {
|
||||
Ok(value) => CommitOutcome::Committed(value),
|
||||
Err(error) => {
|
||||
self.fail(SafetyFailure::CommitFailed);
|
||||
CommitOutcome::Failed(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Output failures, including failed inhibition, latch until explicit disarm.
|
||||
pub fn report_actuator_fault(&mut self) {
|
||||
self.fail(SafetyFailure::ActuatorFault);
|
||||
}
|
||||
|
||||
pub const fn state(&self) -> SafetyState {
|
||||
self.state
|
||||
}
|
||||
|
||||
pub const fn decision(&self) -> SafetyDecision {
|
||||
SafetyDecision {
|
||||
state: self.state,
|
||||
failure: self.last_failure,
|
||||
}
|
||||
}
|
||||
|
||||
fn evaluate(&mut self, now: Timestamp, inputs: SafetyInputs) {
|
||||
if !self.observe_time(now) {
|
||||
return self.decision();
|
||||
return;
|
||||
}
|
||||
let failure = preflight_failure(inputs);
|
||||
if failure == Some(SafetyFailure::ActuatorFault) {
|
||||
self.report_actuator_fault();
|
||||
return;
|
||||
}
|
||||
match self.state {
|
||||
SafetyState::Disarmed => {
|
||||
self.last_failure = failure.or(Some(SafetyFailure::ArmNotRequested));
|
||||
}
|
||||
SafetyState::PreArm => {
|
||||
if let Some(reason) = failure {
|
||||
self.state = SafetyState::Disarmed;
|
||||
self.prearm_since = None;
|
||||
self.request_disarm();
|
||||
self.last_failure = Some(reason);
|
||||
} else if self.prearm_since.is_some_and(|since| {
|
||||
elapsed_at_least(now, since, self.config.prearm_dwell_microseconds)
|
||||
now.as_microseconds() - since.as_microseconds()
|
||||
>= self.config.prearm_dwell_microseconds
|
||||
}) {
|
||||
self.enter_armed();
|
||||
self.state = SafetyState::Armed;
|
||||
self.prearm_since = None;
|
||||
self.last_failure = None;
|
||||
}
|
||||
}
|
||||
SafetyState::Armed => {
|
||||
if let Some(reason) = failure {
|
||||
self.leave_armed(SafetyState::Failsafe, reason);
|
||||
self.fail(reason);
|
||||
}
|
||||
}
|
||||
SafetyState::Failsafe => {}
|
||||
}
|
||||
self.decision()
|
||||
}
|
||||
|
||||
/// Checks an opaque token at the output boundary.
|
||||
pub fn permit_is_current(&self, permit: &ActuatorPermit) -> bool {
|
||||
self.state == SafetyState::Armed && self.permit_epoch == permit.epoch
|
||||
}
|
||||
|
||||
/// Returns the current state without granting a bypass permission.
|
||||
pub const fn state(&self) -> SafetyState {
|
||||
self.state
|
||||
}
|
||||
|
||||
fn observe_time(&mut self, now: Timestamp) -> bool {
|
||||
if self.last_evaluation.is_some_and(|previous| now < previous) {
|
||||
self.prearm_since = None;
|
||||
if self.state == SafetyState::Armed {
|
||||
self.leave_armed(SafetyState::Failsafe, SafetyFailure::ClockRegression);
|
||||
} else if self.state == SafetyState::PreArm {
|
||||
self.state = SafetyState::Failsafe;
|
||||
self.last_failure = Some(SafetyFailure::ClockRegression);
|
||||
} else {
|
||||
self.last_failure = Some(SafetyFailure::ClockRegression);
|
||||
}
|
||||
self.clock_fault = true;
|
||||
}
|
||||
if self.clock_fault {
|
||||
self.fail(SafetyFailure::ClockRegression);
|
||||
return false;
|
||||
}
|
||||
self.last_evaluation = Some(now);
|
||||
true
|
||||
}
|
||||
|
||||
fn enter_armed(&mut self) {
|
||||
let Some(next_epoch) = self.permit_epoch.checked_add(1) else {
|
||||
self.state = SafetyState::Failsafe;
|
||||
self.prearm_since = None;
|
||||
self.last_failure = Some(SafetyFailure::PermitEpochExhausted);
|
||||
return;
|
||||
};
|
||||
self.permit_epoch = next_epoch;
|
||||
self.state = SafetyState::Armed;
|
||||
fn fail(&mut self, failure: SafetyFailure) {
|
||||
self.state = SafetyState::Failsafe;
|
||||
self.prearm_since = None;
|
||||
self.last_failure = None;
|
||||
self.last_failure = Some(failure);
|
||||
}
|
||||
|
||||
fn leave_armed(&mut self, next_state: SafetyState, failure: SafetyFailure) {
|
||||
if self.invalidate_active_permit() {
|
||||
self.state = next_state;
|
||||
self.prearm_since = None;
|
||||
self.last_failure = Some(failure);
|
||||
}
|
||||
}
|
||||
|
||||
fn invalidate_active_permit(&mut self) -> bool {
|
||||
let Some(next_epoch) = self.permit_epoch.checked_add(1) else {
|
||||
self.state = SafetyState::Failsafe;
|
||||
self.prearm_since = None;
|
||||
self.last_failure = Some(SafetyFailure::PermitEpochExhausted);
|
||||
return false;
|
||||
};
|
||||
self.permit_epoch = next_epoch;
|
||||
true
|
||||
}
|
||||
|
||||
fn decision(&self) -> SafetyDecision {
|
||||
SafetyDecision {
|
||||
state: self.state,
|
||||
failure: self.last_failure,
|
||||
permit: if self.state == SafetyState::Armed {
|
||||
Some(ActuatorPermit {
|
||||
epoch: self.permit_epoch,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn elapsed_at_least(now: Timestamp, since: Timestamp, duration_microseconds: u64) -> bool {
|
||||
now.as_microseconds() - since.as_microseconds() >= duration_microseconds
|
||||
}
|
||||
|
||||
fn preflight_failure(inputs: SafetyInputs) -> Option<SafetyFailure> {
|
||||
if !inputs.estimator_healthy {
|
||||
if !inputs.output_healthy {
|
||||
Some(SafetyFailure::ActuatorFault)
|
||||
} else if !inputs.estimator_healthy {
|
||||
Some(SafetyFailure::EstimatorUnhealthy)
|
||||
} else if !inputs.imu_fresh {
|
||||
Some(SafetyFailure::ImuStale)
|
||||
} else if !inputs.link_fresh {
|
||||
Some(SafetyFailure::LinkStale)
|
||||
} else if !inputs.actuator_healthy {
|
||||
Some(SafetyFailure::ActuatorFault)
|
||||
} else if !inputs.phase_authorized {
|
||||
Some(SafetyFailure::PhaseUnauthorized)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const DWELL_MICROSECONDS: u64 = 100;
|
||||
|
||||
fn supervisor() -> SafetySupervisor {
|
||||
SafetySupervisor::new(SafetyConfig::new(DWELL_MICROSECONDS).unwrap())
|
||||
}
|
||||
|
||||
fn time(microseconds: u64) -> Timestamp {
|
||||
Timestamp::from_microseconds(microseconds)
|
||||
}
|
||||
|
||||
fn healthy() -> SafetyInputs {
|
||||
SafetyInputs {
|
||||
estimator_healthy: true,
|
||||
imu_fresh: true,
|
||||
link_fresh: true,
|
||||
actuator_healthy: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn arm(supervisor: &mut SafetySupervisor) -> SafetyDecision {
|
||||
assert_eq!(
|
||||
supervisor.request_arm(time(10), healthy()).state(),
|
||||
SafetyState::PreArm
|
||||
);
|
||||
supervisor.evaluate(time(10 + DWELL_MICROSECONDS), healthy())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_requires_nonzero_dwell() {
|
||||
assert_eq!(
|
||||
SafetyConfig::new(0),
|
||||
Err(SafetyConfigError::ZeroPrearmDwell)
|
||||
);
|
||||
assert_eq!(
|
||||
SafetyConfig::new(DWELL_MICROSECONDS)
|
||||
.unwrap()
|
||||
.prearm_dwell_microseconds(),
|
||||
DWELL_MICROSECONDS
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_arm_is_not_latched_for_later_health() {
|
||||
let mut safety = supervisor();
|
||||
let mut unhealthy = healthy();
|
||||
unhealthy.imu_fresh = false;
|
||||
assert_eq!(
|
||||
safety.request_arm(time(1), unhealthy).failure(),
|
||||
Some(SafetyFailure::ImuStale)
|
||||
);
|
||||
let decision = safety.evaluate(time(1 + DWELL_MICROSECONDS), healthy());
|
||||
assert_eq!(decision.state(), SafetyState::Disarmed);
|
||||
assert!(decision.permit().is_none());
|
||||
assert_eq!(decision.failure(), Some(SafetyFailure::ArmNotRequested));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dwell_requires_elapsed_monotonic_time() {
|
||||
let mut safety = supervisor();
|
||||
assert_eq!(
|
||||
safety.request_arm(time(10), healthy()).state(),
|
||||
SafetyState::PreArm
|
||||
);
|
||||
assert_eq!(
|
||||
safety.evaluate(time(10), healthy()).state(),
|
||||
SafetyState::PreArm
|
||||
);
|
||||
assert_eq!(
|
||||
safety
|
||||
.evaluate(time(10 + DWELL_MICROSECONDS - 1), healthy())
|
||||
.state(),
|
||||
SafetyState::PreArm
|
||||
);
|
||||
let decision = safety.evaluate(time(10 + DWELL_MICROSECONDS), healthy());
|
||||
assert_eq!(decision.state(), SafetyState::Armed);
|
||||
assert!(decision.permit().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prearm_fault_consumes_transaction() {
|
||||
let mut safety = supervisor();
|
||||
safety.request_arm(time(10), healthy());
|
||||
let mut stale = healthy();
|
||||
stale.link_fresh = false;
|
||||
assert_eq!(
|
||||
safety.evaluate(time(20), stale).state(),
|
||||
SafetyState::Disarmed
|
||||
);
|
||||
assert_eq!(
|
||||
safety
|
||||
.evaluate(time(10 + DWELL_MICROSECONDS), healthy())
|
||||
.state(),
|
||||
SafetyState::Disarmed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn armed_fault_latches_failsafe() {
|
||||
let mut safety = supervisor();
|
||||
let armed = arm(&mut safety);
|
||||
assert!(safety.permit_is_current(armed.permit().unwrap()));
|
||||
let mut stale = healthy();
|
||||
stale.link_fresh = false;
|
||||
let decision = safety.evaluate(time(111), stale);
|
||||
assert_eq!(decision.state(), SafetyState::Failsafe);
|
||||
assert!(decision.permit().is_none());
|
||||
assert_eq!(decision.failure(), Some(SafetyFailure::LinkStale));
|
||||
assert_eq!(
|
||||
safety.evaluate(time(112), healthy()).state(),
|
||||
SafetyState::Failsafe
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_regression_inhibits_and_latches_failsafe() {
|
||||
let mut safety = supervisor();
|
||||
let armed = arm(&mut safety);
|
||||
let permit = armed.permit().unwrap();
|
||||
assert!(safety.permit_is_current(permit));
|
||||
let decision = safety.evaluate(time(109), healthy());
|
||||
assert_eq!(decision.state(), SafetyState::Failsafe);
|
||||
assert!(decision.permit().is_none());
|
||||
assert_eq!(decision.failure(), Some(SafetyFailure::ClockRegression));
|
||||
assert!(!safety.permit_is_current(permit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failsafe_requires_explicit_disarm_before_a_fresh_arm_request() {
|
||||
let mut safety = supervisor();
|
||||
let _armed = arm(&mut safety);
|
||||
let mut stale = healthy();
|
||||
stale.link_fresh = false;
|
||||
safety.evaluate(time(111), stale);
|
||||
assert_eq!(
|
||||
safety.request_arm(time(112), healthy()).state(),
|
||||
SafetyState::Failsafe
|
||||
);
|
||||
safety.request_disarm();
|
||||
assert_eq!(
|
||||
safety.request_arm(time(113), healthy()).state(),
|
||||
SafetyState::PreArm
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_permit_cannot_authorize_after_rearm() {
|
||||
let mut safety = supervisor();
|
||||
let first = arm(&mut safety);
|
||||
let first_permit = first.permit().unwrap();
|
||||
assert!(safety.permit_is_current(first_permit));
|
||||
safety.request_disarm();
|
||||
assert!(!safety.permit_is_current(first_permit));
|
||||
assert_eq!(
|
||||
safety.request_arm(time(120), healthy()).state(),
|
||||
SafetyState::PreArm
|
||||
);
|
||||
let second = safety.evaluate(time(120 + DWELL_MICROSECONDS), healthy());
|
||||
assert!(safety.permit_is_current(second.permit().unwrap()));
|
||||
assert!(!safety.permit_is_current(first_permit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_epoch_reset_accepts_a_new_clock_without_revalidating_permits() {
|
||||
let mut safety = supervisor();
|
||||
let armed = arm(&mut safety);
|
||||
let permit = armed.permit().unwrap();
|
||||
safety.reset_time_epoch();
|
||||
assert_eq!(safety.state(), SafetyState::Disarmed);
|
||||
assert!(!safety.permit_is_current(permit));
|
||||
assert_eq!(
|
||||
safety.request_arm(time(1), healthy()).state(),
|
||||
SafetyState::PreArm
|
||||
);
|
||||
let rearmed = safety.evaluate(time(1 + DWELL_MICROSECONDS), healthy());
|
||||
assert!(safety.permit_is_current(rearmed.permit().unwrap()));
|
||||
assert!(!safety.permit_is_current(permit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permit_epoch_exhaustion_latches_inhibition() {
|
||||
let mut safety = supervisor();
|
||||
safety.permit_epoch = u64::MAX;
|
||||
safety.request_arm(time(10), healthy());
|
||||
let decision = safety.evaluate(time(10 + DWELL_MICROSECONDS), healthy());
|
||||
assert_eq!(decision.state(), SafetyState::Failsafe);
|
||||
assert_eq!(
|
||||
decision.failure(),
|
||||
Some(SafetyFailure::PermitEpochExhausted)
|
||||
);
|
||||
assert!(decision.permit().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preflight_priority_is_stable() {
|
||||
assert_eq!(
|
||||
preflight_failure(SafetyInputs {
|
||||
estimator_healthy: false,
|
||||
imu_fresh: false,
|
||||
link_fresh: false,
|
||||
actuator_healthy: false,
|
||||
}),
|
||||
Some(SafetyFailure::EstimatorUnhealthy)
|
||||
);
|
||||
assert_eq!(
|
||||
preflight_failure(SafetyInputs {
|
||||
estimator_healthy: true,
|
||||
imu_fresh: false,
|
||||
link_fresh: false,
|
||||
actuator_healthy: false,
|
||||
}),
|
||||
Some(SafetyFailure::ImuStale)
|
||||
);
|
||||
assert_eq!(
|
||||
preflight_failure(SafetyInputs {
|
||||
estimator_healthy: true,
|
||||
imu_fresh: true,
|
||||
link_fresh: true,
|
||||
actuator_healthy: false,
|
||||
}),
|
||||
Some(SafetyFailure::ActuatorFault)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clock_regressions_and_permit_exhaustion_fail_closed() {
|
||||
let mut disarmed = supervisor();
|
||||
disarmed.evaluate(time(10), healthy());
|
||||
let decision = disarmed.request_arm(time(9), healthy());
|
||||
assert_eq!(decision.state(), SafetyState::Disarmed);
|
||||
assert_eq!(decision.failure(), Some(SafetyFailure::ClockRegression));
|
||||
|
||||
let mut prearm = supervisor();
|
||||
prearm.request_arm(time(10), healthy());
|
||||
let decision = prearm.evaluate(time(9), healthy());
|
||||
assert_eq!(decision.state(), SafetyState::Failsafe);
|
||||
assert_eq!(decision.failure(), Some(SafetyFailure::ClockRegression));
|
||||
|
||||
let mut armed = supervisor();
|
||||
let armed_decision = arm(&mut armed);
|
||||
let permit = armed_decision.permit().unwrap();
|
||||
assert!(armed.permit_is_current(permit));
|
||||
assert_eq!(
|
||||
armed.evaluate(time(111), healthy()).state(),
|
||||
SafetyState::Armed
|
||||
);
|
||||
|
||||
// This test-only corrupt state models a counter exhausted after a
|
||||
// previously issued permit. Explicit disarm must still revoke output
|
||||
// and latch the overflow rather than wrapping a token epoch.
|
||||
armed.permit_epoch = u64::MAX;
|
||||
armed.request_disarm();
|
||||
assert_eq!(armed.state(), SafetyState::Failsafe);
|
||||
assert_eq!(
|
||||
armed.last_failure,
|
||||
Some(SafetyFailure::PermitEpochExhausted)
|
||||
);
|
||||
|
||||
// A failsafe transition uses the same non-wrapping revocation path.
|
||||
let mut overflow_on_fault = supervisor();
|
||||
overflow_on_fault.state = SafetyState::Armed;
|
||||
overflow_on_fault.permit_epoch = u64::MAX;
|
||||
let mut stale = healthy();
|
||||
stale.link_fresh = false;
|
||||
let decision = overflow_on_fault.evaluate(time(1), stale);
|
||||
assert_eq!(decision.state(), SafetyState::Failsafe);
|
||||
assert_eq!(
|
||||
decision.failure(),
|
||||
Some(SafetyFailure::PermitEpochExhausted)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
use kaptan_safety::*;
|
||||
use kaptan_time::Timestamp;
|
||||
|
||||
fn time(value: u64) -> Timestamp {
|
||||
Timestamp::from_microseconds(value)
|
||||
}
|
||||
fn healthy() -> SafetyInputs {
|
||||
SafetyInputs {
|
||||
estimator_healthy: true,
|
||||
imu_fresh: true,
|
||||
phase_authorized: true,
|
||||
output_healthy: true,
|
||||
}
|
||||
}
|
||||
fn supervisor() -> SafetySupervisor {
|
||||
SafetySupervisor::new(SafetyConfig::new(100).unwrap())
|
||||
}
|
||||
fn evaluate(s: &mut SafetySupervisor, now: u64, inputs: SafetyInputs) -> CommitOutcome<(), ()> {
|
||||
s.evaluate_and_commit(time(now), inputs, || Ok(()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dwell_and_owner_local_commit() {
|
||||
assert_eq!(
|
||||
SafetyConfig::new(0),
|
||||
Err(SafetyConfigError::ZeroPrearmDwell)
|
||||
);
|
||||
let mut a = supervisor();
|
||||
let mut b = supervisor();
|
||||
assert_eq!(
|
||||
a.request_arm(time(10), healthy()).state(),
|
||||
SafetyState::PreArm
|
||||
);
|
||||
for now in [10, 109] {
|
||||
assert!(matches!(
|
||||
a.evaluate_and_commit(time(now), healthy(), || -> Result<(), ()> {
|
||||
panic!("prearm cannot commit")
|
||||
}),
|
||||
CommitOutcome::Inhibited(_)
|
||||
));
|
||||
}
|
||||
let mut calls = 0;
|
||||
assert_eq!(
|
||||
a.evaluate_and_commit(time(110), healthy(), || {
|
||||
calls += 1;
|
||||
Ok::<_, ()>(42)
|
||||
}),
|
||||
CommitOutcome::Committed(42)
|
||||
);
|
||||
assert_eq!(calls, 1);
|
||||
// Healthy armed evaluations keep committing without restarting the dwell.
|
||||
assert_eq!(
|
||||
evaluate(&mut a, 111, healthy()),
|
||||
CommitOutcome::Committed(())
|
||||
);
|
||||
assert!(matches!(
|
||||
b.evaluate_and_commit(time(110), healthy(), || -> Result<(), ()> {
|
||||
panic!("another owner's armed state cannot commit here")
|
||||
}),
|
||||
CommitOutcome::Inhibited(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_arm_and_prearm_are_consumed() {
|
||||
let mut s = supervisor();
|
||||
let mut stale = healthy();
|
||||
stale.imu_fresh = false;
|
||||
assert_eq!(
|
||||
s.request_arm(time(1), stale).failure(),
|
||||
Some(SafetyFailure::ImuStale)
|
||||
);
|
||||
evaluate(&mut s, 200, healthy());
|
||||
assert_eq!(s.state(), SafetyState::Disarmed);
|
||||
s.request_arm(time(201), healthy());
|
||||
evaluate(&mut s, 202, stale);
|
||||
evaluate(&mut s, 400, healthy());
|
||||
assert_eq!(s.state(), SafetyState::Disarmed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_arm_health_priority_and_recovery_require_a_new_transaction() {
|
||||
for (inputs, expected) in [
|
||||
(
|
||||
SafetyInputs {
|
||||
estimator_healthy: false,
|
||||
imu_fresh: false,
|
||||
phase_authorized: false,
|
||||
output_healthy: false,
|
||||
},
|
||||
SafetyFailure::ActuatorFault,
|
||||
),
|
||||
(
|
||||
SafetyInputs {
|
||||
estimator_healthy: false,
|
||||
imu_fresh: false,
|
||||
phase_authorized: false,
|
||||
..healthy()
|
||||
},
|
||||
SafetyFailure::EstimatorUnhealthy,
|
||||
),
|
||||
(
|
||||
SafetyInputs {
|
||||
imu_fresh: false,
|
||||
phase_authorized: false,
|
||||
..healthy()
|
||||
},
|
||||
SafetyFailure::ImuStale,
|
||||
),
|
||||
(
|
||||
SafetyInputs {
|
||||
phase_authorized: false,
|
||||
..healthy()
|
||||
},
|
||||
SafetyFailure::PhaseUnauthorized,
|
||||
),
|
||||
] {
|
||||
let mut s = supervisor();
|
||||
let expected_state = if expected == SafetyFailure::ActuatorFault {
|
||||
SafetyState::Failsafe
|
||||
} else {
|
||||
SafetyState::Disarmed
|
||||
};
|
||||
let decision = s.request_arm(time(0), inputs);
|
||||
assert_eq!(decision.failure(), Some(expected));
|
||||
assert_eq!(decision.state(), expected_state);
|
||||
assert!(matches!(
|
||||
evaluate(&mut s, 100, healthy()),
|
||||
CommitOutcome::Inhibited(_)
|
||||
));
|
||||
assert_eq!(s.state(), expected_state);
|
||||
if expected == SafetyFailure::ActuatorFault {
|
||||
assert_eq!(
|
||||
s.request_arm(time(101), healthy()).state(),
|
||||
SafetyState::Failsafe
|
||||
);
|
||||
s.request_disarm();
|
||||
}
|
||||
assert_eq!(
|
||||
s.request_arm(time(102), healthy()).state(),
|
||||
SafetyState::PreArm
|
||||
);
|
||||
assert_eq!(
|
||||
evaluate(&mut s, 202, healthy()),
|
||||
CommitOutcome::Committed(())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regressing_arm_request_consumes_prearm_and_requires_explicit_reset() {
|
||||
let mut s = supervisor();
|
||||
assert_eq!(
|
||||
s.request_arm(time(100), healthy()).state(),
|
||||
SafetyState::PreArm
|
||||
);
|
||||
let decision = s.request_arm(time(99), healthy());
|
||||
assert_eq!(decision.state(), SafetyState::Failsafe);
|
||||
assert_eq!(decision.failure(), Some(SafetyFailure::ClockRegression));
|
||||
assert!(matches!(
|
||||
evaluate(&mut s, 200, healthy()),
|
||||
CommitOutcome::Inhibited(_)
|
||||
));
|
||||
s.request_disarm();
|
||||
s.reset_time_epoch(|| Ok::<_, ()>(())).unwrap();
|
||||
assert_eq!(
|
||||
s.request_arm(time(0), healthy()).state(),
|
||||
SafetyState::PreArm
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_health_revokes_before_callback_and_recovery_cannot_rearm() {
|
||||
let mut s = supervisor();
|
||||
s.request_arm(time(0), healthy());
|
||||
assert_eq!(
|
||||
evaluate(&mut s, 100, healthy()),
|
||||
CommitOutcome::Committed(())
|
||||
);
|
||||
let diagnostic = s.decision();
|
||||
let mut stale = healthy();
|
||||
stale.phase_authorized = false;
|
||||
assert!(matches!(
|
||||
s.evaluate_and_commit(time(101), stale, || -> Result<(), ()> {
|
||||
panic!("stale evidence must inhibit before callback")
|
||||
}),
|
||||
CommitOutcome::Inhibited(_)
|
||||
));
|
||||
assert_eq!(diagnostic.state(), SafetyState::Armed); // retained diagnostic only
|
||||
assert_eq!(
|
||||
s.decision().failure(),
|
||||
Some(SafetyFailure::PhaseUnauthorized)
|
||||
);
|
||||
assert!(matches!(
|
||||
evaluate(&mut s, 102, healthy()),
|
||||
CommitOutcome::Inhibited(_)
|
||||
));
|
||||
assert_eq!(
|
||||
s.request_arm(time(103), healthy()).state(),
|
||||
SafetyState::Failsafe
|
||||
);
|
||||
s.request_disarm();
|
||||
assert_eq!(
|
||||
s.request_arm(time(104), healthy()).state(),
|
||||
SafetyState::PreArm
|
||||
);
|
||||
assert_eq!(
|
||||
evaluate(&mut s, 204, healthy()),
|
||||
CommitOutcome::Committed(())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callback_error_and_actuator_health_require_disarm() {
|
||||
let mut s = supervisor();
|
||||
s.request_arm(time(0), healthy());
|
||||
assert_eq!(
|
||||
s.evaluate_and_commit(time(100), healthy(), || Err::<(), _>("commit")),
|
||||
CommitOutcome::Failed("commit")
|
||||
);
|
||||
assert_eq!(s.state(), SafetyState::Failsafe);
|
||||
evaluate(&mut s, 101, healthy());
|
||||
assert_eq!(
|
||||
s.request_arm(time(102), healthy()).state(),
|
||||
SafetyState::Failsafe
|
||||
);
|
||||
s.request_disarm();
|
||||
s.request_arm(time(103), healthy());
|
||||
let mut broken = healthy();
|
||||
broken.output_healthy = false;
|
||||
evaluate(&mut s, 104, broken);
|
||||
assert_eq!(s.decision().failure(), Some(SafetyFailure::ActuatorFault));
|
||||
evaluate(&mut s, 300, healthy());
|
||||
assert_eq!(s.state(), SafetyState::Failsafe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clock_fault_survives_disarm_and_failed_reset() {
|
||||
let mut s = supervisor();
|
||||
evaluate(&mut s, 100, healthy());
|
||||
evaluate(&mut s, 99, healthy());
|
||||
assert_eq!(s.decision().failure(), Some(SafetyFailure::ClockRegression));
|
||||
assert_eq!(
|
||||
s.reset_time_epoch(|| Ok::<_, ()>(())),
|
||||
Err(ResetError::NotDisarmed)
|
||||
);
|
||||
s.request_disarm();
|
||||
assert_eq!(
|
||||
s.request_arm(time(101), healthy()).state(),
|
||||
SafetyState::Failsafe
|
||||
);
|
||||
s.request_disarm();
|
||||
assert_eq!(
|
||||
s.reset_time_epoch(|| Err("inhibit")),
|
||||
Err(ResetError::Inhibit("inhibit"))
|
||||
);
|
||||
s.request_disarm();
|
||||
assert_eq!(
|
||||
s.request_arm(time(102), healthy()).state(),
|
||||
SafetyState::Failsafe
|
||||
);
|
||||
s.request_disarm();
|
||||
s.reset_time_epoch(|| Ok::<_, ()>(())).unwrap();
|
||||
assert_eq!(
|
||||
s.request_arm(time(0), healthy()).state(),
|
||||
SafetyState::PreArm
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user