///|
/// Additional time-domain measures used by field and wearable recordings.
/// These helpers deliberately keep the original sample order and return
/// deterministic values for short recordings.
///|
/// Extended time-domain feature set.
pub(all) struct TimeDomainExtended {
mean_rr : Double
mean_hr : Double
sdnn : Double
rmssd : Double
sdsd : Double
cvnn : Double
pnn20 : Double
pnn50 : Double
median_rr : Double
iqr_rr : Double
min_rr : Double
max_rr : Double
range_rr : Double
mad_rr : Double
triangular_index : Double
} derive(FromJson, ToJson, Debug, Eq)
///|
/// Calculate the standard deviation of successive differences.
pub fn calculate_sdsd(intervals : Array[Double]) -> Double {
if intervals.length() <= 2 {
return 0.0
}
let differences = []
for i in 0..<(intervals.length() - 1) {
differences.push(intervals[i + 1] - intervals[i])
}
standard_deviation(differences)
}
///|
/// Calculate coefficient of variation of NN intervals in percent.
pub fn calculate_cvnn(intervals : Array[Double]) -> Double {
let mean = mean_value(intervals)
if mean == 0.0 {
0.0
} else {
standard_deviation(intervals) / mean * 100.0
}
}
///|
/// Calculate the median absolute successive difference.
pub fn median_successive_difference(intervals : Array[Double]) -> Double {
if intervals.length() <= 1 {
return 0.0
}
let differences = []
for i in 0..<(intervals.length() - 1) {
differences.push(absolute_difference(intervals[i + 1], intervals[i]))
}
median_value(differences)
}
///|
/// Calculate a robust pNN metric using an arbitrary threshold.
pub fn calculate_pnn_fraction(
intervals : Array[Double],
threshold_ms : Double,
) -> Double {
if intervals.length() <= 1 {
return 0.0
}
let threshold = if threshold_ms < 0.0 { 0.0 } else { threshold_ms }
let mut count = 0
for i in 0..<(intervals.length() - 1) {
if absolute_difference(intervals[i + 1], intervals[i]) > threshold {
count += 1
}
}
count.to_double() / (intervals.length() - 1).to_double()
}
///|
/// Return an extended summary for a cleaned RR sequence.
pub fn summarize_time_domain_extended(
intervals : Array[Double],
) -> TimeDomainExtended {
let distribution = summarize_distribution(intervals)
{
mean_rr: mean_value(intervals),
mean_hr: if mean_value(intervals) > 0.0 {
60000.0 / mean_value(intervals)
} else {
0.0
},
sdnn: standard_deviation(intervals),
rmssd: calculate_rmssd(intervals),
sdsd: calculate_sdsd(intervals),
cvnn: calculate_cvnn(intervals),
pnn20: calculate_pnn(intervals, 20.0),
pnn50: calculate_pnn(intervals, 50.0),
median_rr: distribution.median,
iqr_rr: distribution.q3 - distribution.q1,
min_rr: distribution.minimum,
max_rr: distribution.maximum,
range_rr: distribution.maximum - distribution.minimum,
mad_rr: distribution.median_absolute_deviation,
triangular_index: calculate_triangular_index(intervals),
}
}
///|
/// Return absolute beat-to-beat changes for a recording.
pub fn successive_differences(intervals : Array[Double]) -> Array[Double] {
if intervals.length() <= 1 {
return []
}
let result = []
for i in 0..<(intervals.length() - 1) {
result.push(intervals[i + 1] - intervals[i])
}
result
}
///|
/// Return absolute successive changes, useful for artifact dashboards.
pub fn absolute_successive_differences(
intervals : Array[Double],
) -> Array[Double] {
let result = []
for value in successive_differences(intervals) {
result.push(value.abs())
}
result
}
///|
/// Count intervals whose local change exceeds a threshold.
pub fn count_large_successive_changes(
intervals : Array[Double],
threshold_ms : Double,
) -> Int {
let mut count = 0
let threshold = if threshold_ms < 0.0 { 0.0 } else { threshold_ms }
for change in absolute_successive_differences(intervals) {
if change > threshold {
count += 1
}
}
count
}
///|
/// Calculate a short-window RMSSD for every complete window.
pub fn rolling_rmssd_with_stride(
intervals : Array[Double],
window_size : Int,
stride : Int,
) -> Array[Double] {
if window_size <= 1 || stride <= 0 || intervals.length() < window_size {
return []
}
let result = []
let mut start = 0
while start + window_size <= intervals.length() {
let window = []
for i in start..<(start + window_size) {
window.push(intervals[i])
}
result.push(calculate_rmssd(window))
start += stride
}
result
}
///|
/// Calculate a robust moving median for display or denoising.
pub fn rolling_median(
intervals : Array[Double],
window_size : Int,
) -> Array[Double] {
if window_size <= 0 {
return []
}
let result = []
for i in 0.. intervals.length() {
intervals.length()
} else {
start + window_size
}
let window = []
for j in start.. Array[Double] {
let baseline = rolling_median(intervals, window_size)
let result = []
let limit = if intervals.length() < baseline.length() {
intervals.length()
} else {
baseline.length()
}
for i in 0.. Array[Double] {
let summary = summarize_time_domain_extended(intervals)
[
summary.mean_rr,
summary.mean_hr,
summary.sdnn,
summary.rmssd,
summary.sdsd,
summary.cvnn,
summary.pnn20,
summary.pnn50,
summary.median_rr,
summary.iqr_rr,
summary.min_rr,
summary.max_rr,
summary.range_rr,
summary.mad_rr,
summary.triangular_index,
]
}
///|
/// Return a stable list of feature names matching time_domain_feature_vector.
pub fn time_domain_feature_names() -> Array[String] {
[
"mean_rr", "mean_hr", "sdnn", "rmssd", "sdsd", "cvnn", "pnn20", "pnn50", "median_rr",
"iqr_rr", "min_rr", "max_rr", "range_rr", "mad_rr", "triangular_index",
]
}
///|
/// Return a threshold-based quality score for successive changes.
pub fn successive_change_quality(
intervals : Array[Double],
threshold_ms : Double,
) -> Double {
if intervals.length() <= 1 {
return 0.0
}
let total = (intervals.length() - 1).to_double()
1.0 -
count_large_successive_changes(intervals, threshold_ms).to_double() / total
}
///|
/// Return whether an extended summary is numerically usable.
pub fn time_domain_is_usable(summary : TimeDomainExtended) -> Bool {
summary.mean_rr > 0.0 &&
summary.mean_hr > 0.0 &&
summary.sdnn >= 0.0 &&
summary.rmssd >= 0.0
}