///|
/// Physiological interpretation of one RR interval.
pub(all) enum IntervalDisposition {
Normal
TooShort
TooLong
NonFinite
} derive(FromJson, ToJson, Debug, Eq)
///|
/// Detailed observation retained by the validation report.
pub(all) struct IntervalObservation {
index : Int
value : Double
disposition : IntervalDisposition
distance_from_median : Double
} derive(FromJson, ToJson, Debug, Eq)
///|
/// Input validation summary for a raw RR series.
pub(all) struct IntervalValidation {
total : Int
valid : Int
invalid : Int
too_short : Int
too_long : Int
non_finite : Int
duplicates : Int
monotonic_breaks : Int
minimum : Double
maximum : Double
median : Double
observations : Array[IntervalObservation]
} derive(FromJson, ToJson, Debug, Eq)
///|
/// Classify a value against the physiological range in a configuration.
pub fn classify_interval(
value : Double,
config : HrvConfig,
) -> IntervalDisposition {
if value.is_nan() || value.is_inf() {
NonFinite
} else if value < config.min_rr {
TooShort
} else if value > config.max_rr {
TooLong
} else {
Normal
}
}
///|
/// Build a validation report without mutating the input.
pub fn validate_intervals(
intervals : Array[Double],
config : HrvConfig,
) -> IntervalValidation {
let n = intervals.length()
let median = median_value(intervals)
let mut too_short = 0
let mut too_long = 0
let mut non_finite = 0
let mut valid = 0
let mut duplicates = 0
let mut monotonic_breaks = 0
let observations = []
let mut minimum = if n == 0 { 0.0 } else { intervals[0] }
let mut maximum = minimum
for i in 0.. valid += 1
TooShort => too_short += 1
TooLong => too_long += 1
NonFinite => non_finite += 1
}
if i > 0 {
if value == intervals[i - 1] {
duplicates += 1
}
if value <= 0.0 && intervals[i - 1] > 0.0 {
monotonic_breaks += 1
}
}
if !value.is_nan() {
if value < minimum || minimum.is_nan() {
minimum = value
}
if value > maximum || maximum.is_nan() {
maximum = value
}
}
let delta = value - median
let distance = if delta < 0.0 { -delta } else { delta }
observations.push({
index: i,
value,
disposition,
distance_from_median: distance,
})
}
{
total: n,
valid,
invalid: n - valid,
too_short,
too_long,
non_finite,
duplicates,
monotonic_breaks,
minimum: if n == 0 {
0.0
} else {
minimum
},
maximum: if n == 0 {
0.0
} else {
maximum
},
median,
observations,
}
}
///|
/// Return only values accepted by the configured physiological range.
pub fn valid_intervals(
intervals : Array[Double],
config : HrvConfig,
) -> Array[Double] {
let result = []
for value in intervals {
if classify_interval(value, config) is Normal {
result.push(value)
}
}
result
}
///|
/// Return the longest consecutive run of valid intervals.
pub fn longest_valid_run(intervals : Array[Double], config : HrvConfig) -> Int {
let mut current = 0
let mut longest = 0
for value in intervals {
if classify_interval(value, config) is Normal {
current += 1
if current > longest {
longest = current
}
} else {
current = 0
}
}
longest
}
///|
/// Count gaps between two plausible beats using a configurable multiplier.
pub fn count_gap_candidates(
intervals : Array[Double],
config : HrvConfig,
multiplier : Double,
) -> Int {
let mut count = 0
if intervals.length() <= 1 {
return 0
}
for i in 0..<(intervals.length() - 1) {
let left = intervals[i]
let right = intervals[i + 1]
if classify_interval(left, config) is Normal &&
classify_interval(right, config) is Normal {
let delta = left - right
let absolute = if delta < 0.0 { -delta } else { delta }
if absolute > multiplier * config.relative_threshold * left {
count += 1
}
}
}
count
}
///|
/// Return a quality grade suitable for dashboards.
pub fn quality_grade(validation : IntervalValidation) -> String {
if validation.total == 0 {
"empty"
} else {
let ratio = validation.valid.to_double() / validation.total.to_double()
if ratio >= 0.98 {
"excellent"
} else if ratio >= 0.90 {
"good"
} else if ratio >= 0.75 {
"usable"
} else {
"poor"
}
}
}