safety: add fail-closed permit supervisor
This commit is contained in:
1 parent
757457bb83
commit
1c2979deeb
2 files changed
+619
No files matched your search
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "kaptan-safety"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
kaptan-time = { path = "../kaptan-time" }
|
||||
@@ -0,0 +1,606 @@
|
||||
//! Bounded safety supervisor for actuator authorization.
|
||||
//!
|
||||
//! 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.
|
||||
|
||||
#![no_std]
|
||||
|
||||
use kaptan_time::Timestamp;
|
||||
|
||||
/// Supervisor arming state.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum SafetyState {
|
||||
/// Physical outputs inhibited.
|
||||
Disarmed,
|
||||
/// Checks are continuously passing during the configured dwell period.
|
||||
PreArm,
|
||||
/// Outputs may be enabled by a platform transport that validates a permit.
|
||||
Armed,
|
||||
/// Outputs are inhibited pending an explicit disarm and new arm request.
|
||||
Failsafe,
|
||||
}
|
||||
|
||||
/// Typed reason for refusing or ending actuator authorization.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum SafetyFailure {
|
||||
/// Estimator is absent, stale, or non-finite.
|
||||
EstimatorUnhealthy,
|
||||
/// Required inertial sensor stream is stale.
|
||||
ImuStale,
|
||||
/// Required command/link stream is stale.
|
||||
LinkStale,
|
||||
/// Actuator transport reports a fault.
|
||||
ActuatorFault,
|
||||
/// No current explicit arm transaction exists.
|
||||
ArmNotRequested,
|
||||
/// The platform supplied time that moved backwards.
|
||||
ClockRegression,
|
||||
/// A non-wrapping actuator-permit epoch could not be allocated.
|
||||
PermitEpochExhausted,
|
||||
}
|
||||
|
||||
/// Invalid safety configuration.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum SafetyConfigError {
|
||||
/// A zero dwell would permit two evaluations at one timestamp to arm.
|
||||
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.
|
||||
#[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)
|
||||
} else {
|
||||
Ok(Self {
|
||||
prearm_dwell_microseconds,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct SafetyInputs {
|
||||
/// Estimator finite/initialized health is acceptable for the requested mode.
|
||||
pub estimator_healthy: bool,
|
||||
/// IMU receipt is within the static platform 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,
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
pub struct SafetyDecision {
|
||||
state: SafetyState,
|
||||
failure: Option<SafetyFailure>,
|
||||
permit: Option<ActuatorPermit>,
|
||||
}
|
||||
|
||||
impl SafetyDecision {
|
||||
/// Resulting supervisor state.
|
||||
pub const fn state(&self) -> SafetyState {
|
||||
self.state
|
||||
}
|
||||
|
||||
/// Reason whenever physical output is inhibited.
|
||||
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.
|
||||
pub struct SafetySupervisor {
|
||||
config: SafetyConfig,
|
||||
state: SafetyState,
|
||||
last_failure: Option<SafetyFailure>,
|
||||
prearm_since: Option<Timestamp>,
|
||||
last_evaluation: Option<Timestamp>,
|
||||
permit_epoch: u64,
|
||||
}
|
||||
|
||||
impl SafetySupervisor {
|
||||
/// Starts disarmed and inhibited irrespective of command availability.
|
||||
pub const fn new(config: SafetyConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
state: SafetyState::Disarmed,
|
||||
last_failure: Some(SafetyFailure::ArmNotRequested),
|
||||
prearm_since: None,
|
||||
last_evaluation: None,
|
||||
permit_epoch: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Consumes one explicit arm transaction.
|
||||
///
|
||||
/// Only a healthy request while disarmed enters prearm. A failed request is
|
||||
/// never retained for a later healthy evaluation.
|
||||
pub fn request_arm(&mut self, now: Timestamp, inputs: SafetyInputs) -> SafetyDecision {
|
||||
if !self.observe_time(now) {
|
||||
return self.decision();
|
||||
}
|
||||
if self.state != SafetyState::Disarmed {
|
||||
return self.decision();
|
||||
}
|
||||
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.
|
||||
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);
|
||||
}
|
||||
|
||||
/// 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;
|
||||
self.last_evaluation = None;
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
if !self.observe_time(now) {
|
||||
return self.decision();
|
||||
}
|
||||
let failure = preflight_failure(inputs);
|
||||
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.last_failure = Some(reason);
|
||||
} else if self.prearm_since.is_some_and(|since| {
|
||||
elapsed_at_least(now, since, self.config.prearm_dwell_microseconds)
|
||||
}) {
|
||||
self.enter_armed();
|
||||
}
|
||||
}
|
||||
SafetyState::Armed => {
|
||||
if let Some(reason) = failure {
|
||||
self.leave_armed(SafetyState::Failsafe, 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);
|
||||
}
|
||||
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;
|
||||
self.prearm_since = None;
|
||||
self.last_failure = None;
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user