///|
/// Runtime health classification for an on-device filter.
pub(all) enum FilterStatus {
WarmingUp
Healthy
Degraded
Faulted
} derive(Debug, Eq)
///|
/// A point-in-time operational view. It is intentionally independent of a
/// concrete filter type so it can be exported by a telemetry adapter.
pub struct OperationalSnapshot {
timestamp : Int
result : UpdateResult
status : FilterStatus
score : Double
nis : Double
state : Array[Double]
covariance : Matrix
} derive(Debug)
///|
pub fn OperationalSnapshot::timestamp(self : OperationalSnapshot) -> Int {
self.timestamp
}
///|
pub fn OperationalSnapshot::result(self : OperationalSnapshot) -> UpdateResult {
self.result
}
///|
pub fn OperationalSnapshot::status(self : OperationalSnapshot) -> FilterStatus {
self.status
}
///|
pub fn OperationalSnapshot::score(self : OperationalSnapshot) -> Double {
self.score
}
///|
pub fn OperationalSnapshot::nis(self : OperationalSnapshot) -> Double {
self.nis
}
///|
pub fn OperationalSnapshot::state(self : OperationalSnapshot) -> Array[Double] {
self.state.copy()
}
///|
pub fn OperationalSnapshot::covariance(self : OperationalSnapshot) -> Matrix {
self.covariance.copy()
}
///|
/// Rolling health monitor suitable for long-running sensor services.
pub struct OperationalMonitor {
mut samples : Int
mut accepted : Int
mut failures : Int
mut covariance_failures : Int
mut consecutive_failures : Int
mut worst_nis : Double
mut score : Double
warmup_samples : Int
failure_limit : Int
nis_limit : Double
}
///|
pub fn OperationalMonitor::new(
warmup_samples : Int,
failure_limit : Int,
nis_limit : Double,
) -> OperationalMonitor {
{
samples: 0,
accepted: 0,
failures: 0,
covariance_failures: 0,
consecutive_failures: 0,
worst_nis: 0.0,
score: 1.0,
warmup_samples: if warmup_samples < 0 {
0
} else {
warmup_samples
},
failure_limit: if failure_limit < 1 {
1
} else {
failure_limit
},
nis_limit: if nis_limit <= 0.0 {
1.0
} else {
nis_limit
},
}
}
///|
fn operational_result_failed(result : UpdateResult) -> Bool {
match result {
Accepted => false
RejectedByGate
| InvalidMeasurement
| SingularInnovation
| MissingMeasurement => true
}
}
///|
pub fn OperationalMonitor::observe(
self : OperationalMonitor,
timestamp : Int,
filter : KalmanND,
result : UpdateResult,
) -> OperationalSnapshot {
self.samples = self.samples + 1
let state = filter.state()
let covariance = filter.covariance()
let covariance_ok = covariance_is_psd(covariance, 0.001) &&
covariance.is_finite()
let nis = filter.normalized_innovation_squared()
let nis_bad = nis.is_nan() || nis.is_inf() || nis > self.nis_limit
if result is Accepted {
self.accepted = self.accepted + 1
self.consecutive_failures = 0
self.score = self.score + 0.05 * (1.0 - self.score)
} else {
self.failures = self.failures + 1
self.consecutive_failures = self.consecutive_failures + 1
self.score = self.score * 0.85
}
if !covariance_ok {
self.covariance_failures = self.covariance_failures + 1
self.score = self.score * 0.5
}
if nis > self.worst_nis && !nis.is_nan() {
self.worst_nis = nis
}
let status = if !vector_is_finite(state) || !covariance_ok {
Faulted
} else if self.samples <= self.warmup_samples {
WarmingUp
} else if self.consecutive_failures >= self.failure_limit ||
nis_bad ||
self.score < 0.25 {
Faulted
} else if operational_result_failed(result) || self.score < 0.7 {
Degraded
} else {
Healthy
}
{ timestamp, result, status, score: self.score, nis, state, covariance }
}
///|
pub fn OperationalMonitor::samples(self : OperationalMonitor) -> Int {
self.samples
}
///|
pub fn OperationalMonitor::accepted(self : OperationalMonitor) -> Int {
self.accepted
}
///|
pub fn OperationalMonitor::failures(self : OperationalMonitor) -> Int {
self.failures
}
///|
pub fn OperationalMonitor::covariance_failures(
self : OperationalMonitor,
) -> Int {
self.covariance_failures
}
///|
pub fn OperationalMonitor::consecutive_failures(
self : OperationalMonitor,
) -> Int {
self.consecutive_failures
}
///|
pub fn OperationalMonitor::worst_nis(self : OperationalMonitor) -> Double {
self.worst_nis
}
///|
pub fn OperationalMonitor::score(self : OperationalMonitor) -> Double {
self.score
}
///|
pub fn OperationalMonitor::reset(self : OperationalMonitor) -> Unit {
self.samples = 0
self.accepted = 0
self.failures = 0
self.covariance_failures = 0
self.consecutive_failures = 0
self.worst_nis = 0.0
self.score = 1.0
}
///|
/// A bounded packet queue. The oldest packet is dropped when capacity is
/// reached, which is safer than allowing a delayed sensor to exhaust memory.
pub struct ObservationBuffer {
packets : Array[ObservationPacket]
capacity : Int
}
///|
pub fn ObservationBuffer::new(capacity : Int) -> ObservationBuffer {
{ packets: [], capacity: if capacity < 0 { 0 } else { capacity } }
}
///|
pub fn ObservationBuffer::push(
self : ObservationBuffer,
packet : ObservationPacket,
) -> Bool {
if self.capacity == 0 || !packet.is_valid() {
return false
}
if self.packets.length() >= self.capacity {
for i in 1.. Int {
self.packets.length()
}
///|
pub fn ObservationBuffer::capacity(self : ObservationBuffer) -> Int {
self.capacity
}
///|
pub fn ObservationBuffer::packets(
self : ObservationBuffer,
) -> Array[ObservationPacket] {
self.packets.copy()
}
///|
pub fn ObservationBuffer::oldest(
self : ObservationBuffer,
) -> ObservationPacket? {
if self.packets.length() == 0 {
None
} else {
Some(self.packets[0])
}
}
///|
pub fn ObservationBuffer::newest(
self : ObservationBuffer,
) -> ObservationPacket? {
if self.packets.length() == 0 {
None
} else {
Some(self.packets[self.packets.length() - 1])
}
}
///|
pub fn ObservationBuffer::clear(self : ObservationBuffer) -> Unit {
self.packets.clear()
}
///|
pub fn observation_time_span(buffer : ObservationBuffer) -> Int {
guard buffer.oldest() is Some(first) else { return 0 }
guard buffer.newest() is Some(last) else { return 0 }
last.timestamp() - first.timestamp()
}
///|
/// Check a state against component-wise bounds without mutating it.
pub fn state_within_bounds(
state : Array[Double],
lower : Array[Double],
upper : Array[Double],
) -> Bool {
if state.length() != lower.length() || state.length() != upper.length() {
return false
}
for i in 0.. upper[i] {
return false
}
}
true
}
///|
pub fn clamp_state_to_bounds(
state : Array[Double],
lower : Array[Double],
upper : Array[Double],
) -> Array[Double] {
if state.length() != lower.length() || state.length() != upper.length() {
return []
}
let result = state.copy()
for i in 0.. upper[i] {
result[i] = upper[i]
}
}
result
}