///|
/// Artifact-quality primitives for ambulatory RR streams.
///|
/// Reason assigned to a suspicious sample.
pub(all) enum ArtifactReason {
None
PhysiologicalRange
LocalOutlier
SuddenJump
RepeatedValue
MissingValue
} derive(FromJson, ToJson, Debug, Eq)
///|
/// One sample-level quality flag with a normalized severity.
pub(all) struct QualityFlag {
index : Int
value : Double
reason : ArtifactReason
severity : Double
replacement : Double
} derive(FromJson, ToJson, Debug, Eq)
///|
/// Quality profile of an RR stream.
pub(all) struct ArtifactProfile {
sample_count : Int
flagged_count : Int
range_violations : Int
local_outliers : Int
sudden_jumps : Int
repeated_values : Int
missing_values : Int
artifact_ratio : Double
median_step : Double
mad_step : Double
flags : Array[QualityFlag]
} derive(FromJson, ToJson, Debug, Eq)
///|
fn artifact_median_step(intervals : Array[Double]) -> Double {
median_value(absolute_successive_differences(intervals))
}
///|
fn artifact_mad_step(intervals : Array[Double]) -> Double {
median_absolute_deviation(absolute_successive_differences(intervals))
}
///|
fn artifact_scale(intervals : Array[Double]) -> Double {
let mad = artifact_mad_step(intervals)
if mad < 1.0 {
1.0
} else {
mad
}
}
///|
fn nearest_replacement(
intervals : Array[Double],
index : Int,
config : HrvConfig,
) -> Double {
let left = if index > 0 { Some(intervals[index - 1]) } else { None }
let right = if index + 1 < intervals.length() {
Some(intervals[index + 1])
} else {
None
}
match (left, right) {
(Some(l), Some(r)) =>
if classify_interval(l, config) is Normal &&
classify_interval(r, config) is Normal {
(l + r) / 2.0
} else {
interpolate_artifact(intervals, index, config)
}
_ => interpolate_artifact(intervals, index, config)
}
}
///|
fn artifact_reason_for(
intervals : Array[Double],
index : Int,
config : HrvConfig,
step_median : Double,
step_scale : Double,
) -> (ArtifactReason, Double) {
let value = intervals[index]
match classify_interval(value, config) {
NonFinite => (MissingValue, 1.0)
TooShort | TooLong => (PhysiologicalRange, 1.0)
Normal => {
let mut reason = None
let mut severity = 0.0
if index > 0 {
let step = absolute_difference(value, intervals[index - 1])
let robust_limit = step_median + 5.0 * step_scale
if step > robust_limit && step > config.relative_threshold * value {
reason = SuddenJump
severity = (step / (robust_limit + 1.0)).clamp(min=0.0, max=1.0)
}
if value == intervals[index - 1] {
reason = RepeatedValue
severity = 0.25
}
}
if reason is None && index > 0 && index + 1 < intervals.length() {
let local_midpoint = (intervals[index - 1] + intervals[index + 1]) / 2.0
let distance = absolute_difference(value, local_midpoint)
let limit = step_median + 4.0 * step_scale
if distance > limit {
reason = LocalOutlier
severity = (distance / (limit + 1.0)).clamp(min=0.0, max=1.0)
}
}
(reason, severity)
}
}
}
///|
/// Profile artifacts with robust step-based and physiological checks.
pub fn profile_artifacts(
intervals : Array[Double],
config : HrvConfig,
) -> ArtifactProfile {
let step_median = artifact_median_step(intervals)
let step_scale = artifact_scale(intervals)
let flags = []
let mut range_violations = 0
let mut local_outliers = 0
let mut sudden_jumps = 0
let mut repeated_values = 0
let mut missing_values = 0
for i in 0.. range_violations += 1
LocalOutlier => local_outliers += 1
SuddenJump => sudden_jumps += 1
RepeatedValue => repeated_values += 1
MissingValue => missing_values += 1
None => ()
}
let replacement = if reason is RepeatedValue {
intervals[i]
} else {
nearest_replacement(intervals, i, config)
}
flags.push({
index: i,
value: intervals[i],
reason,
severity,
replacement,
})
}
}
let flagged = flags.length()
{
sample_count: intervals.length(),
flagged_count: flagged,
range_violations,
local_outliers,
sudden_jumps,
repeated_values,
missing_values,
artifact_ratio: if intervals.length() == 0 {
0.0
} else {
flagged.to_double() / intervals.length().to_double()
},
median_step: step_median,
mad_step: step_scale,
flags,
}
}
///|
/// Repair only the flagged samples and preserve the original length.
pub fn repair_profiled_artifacts(
intervals : Array[Double],
profile : ArtifactProfile,
) -> Array[Double] {
let result = []
for value in intervals {
result.push(value)
}
for flag in profile.flags {
if flag.index >= 0 &&
flag.index < result.length() &&
flag.reason != RepeatedValue {
result[flag.index] = flag.replacement
}
}
result
}
///|
/// Calculate a quality score that penalizes severe and repeated artifacts.
pub fn artifact_quality_score(profile : ArtifactProfile) -> Double {
if profile.sample_count == 0 {
return 0.0
}
let severity_sum = sum_values(profile.flags.map(fn(flag) { flag.severity }))
(1.0 - severity_sum / profile.sample_count.to_double()).clamp(
min=0.0,
max=1.0,
)
}
///|
/// Return the proportion of the stream retained after removing flags.
pub fn retained_ratio(profile : ArtifactProfile) -> Double {
if profile.sample_count == 0 {
0.0
} else {
(profile.sample_count - profile.flagged_count).to_double() /
profile.sample_count.to_double()
}
}
///|
/// Group contiguous flags into artifact runs.
pub fn artifact_runs(profile : ArtifactProfile) -> Array[SegmentRange] {
let result = []
if profile.flags.length() == 0 {
return result
}
let mut start = profile.flags[0].index
let mut previous = start
for i in 1.. Int {
if profile.sample_count == 0 {
return 0
}
let runs = artifact_runs(profile)
if runs.length() == 0 {
return profile.sample_count
}
let mut longest = 0
let mut cursor = 0
for run in runs {
let length = run.start - cursor
if length > longest {
longest = length
}
cursor = run.end
}
let tail = profile.sample_count - cursor
if tail > longest {
longest = tail
}
longest
}
///|
/// Create a stable artifact feature vector for model input.
pub fn artifact_feature_vector(profile : ArtifactProfile) -> Array[Double] {
[
profile.sample_count.to_double(),
profile.flagged_count.to_double(),
profile.artifact_ratio,
profile.range_violations.to_double(),
profile.local_outliers.to_double(),
profile.sudden_jumps.to_double(),
profile.repeated_values.to_double(),
profile.missing_values.to_double(),
profile.median_step,
profile.mad_step,
artifact_quality_score(profile),
retained_ratio(profile),
longest_clean_run(profile).to_double(),
]
}
///|
/// Check that all replacements are within the configured physiological range.
pub fn replacements_are_plausible(
profile : ArtifactProfile,
config : HrvConfig,
) -> Bool {
for flag in profile.flags {
if classify_interval(flag.replacement, config) != Normal {
return false
}
}
true
}