///|
/// Signal-quality indices for deciding whether an HRV result is publishable.
///|
/// Quality components on a common zero-to-one scale.
pub(all) struct SignalQualityComponents {
range_score : Double
continuity_score : Double
stationarity_score : Double
coverage_score : Double
plausibility_score : Double
} derive(FromJson, ToJson, Debug, Eq)
///|
/// Weighted quality score and decision.
pub(all) struct SignalQualitySummary {
components : SignalQualityComponents
score : Double
grade : String
usable : Bool
reasons : Array[String]
} derive(FromJson, ToJson, Debug, Eq)
///|
/// Quality-score weights.
pub(all) struct SignalQualityWeights {
range : Double
continuity : Double
stationarity : Double
coverage : Double
plausibility : Double
} derive(FromJson, ToJson, Debug, Eq)
///|
/// Balanced default weights for daily RR data.
pub fn SignalQualityWeights::default() -> SignalQualityWeights {
{
range: 0.25,
continuity: 0.25,
stationarity: 0.20,
coverage: 0.20,
plausibility: 0.10,
}
}
///|
/// Score physiological-range compliance.
pub fn range_quality_score(validation : IntervalValidation) -> Double {
if validation.total == 0 {
0.0
} else {
validation.valid.to_double() / validation.total.to_double()
}
}
///|
/// Score continuity using large successive changes.
pub fn continuity_quality_score(
intervals : Array[Double],
threshold_ms : Double,
) -> Double {
successive_change_quality(intervals, threshold_ms).clamp(min=0.0, max=1.0)
}
///|
/// Score local stationarity by comparing first and second half means.
pub fn stationarity_quality_score(intervals : Array[Double]) -> Double {
if intervals.length() < 4 {
return 0.0
}
let split = intervals.length() / 2
let left = []
let right = []
for i in 0.. Double {
if sample_count <= 0 || target <= 0 {
return 0.0
}
if sample_count < minimum {
sample_count.to_double() / minimum.to_double() * 0.5
} else if sample_count >= target {
1.0
} else {
sample_count.to_double() / target.to_double()
}
}
///|
/// Score plausibility of the median and spread.
pub fn plausibility_quality_score(
intervals : Array[Double],
config : HrvConfig,
) -> Double {
if intervals.length() == 0 {
return 0.0
}
let median = median_value(intervals)
let spread = median_absolute_deviation(intervals)
if classify_interval(median, config) != Normal {
return 0.0
}
if spread <= 80.0 {
1.0
} else {
(1.0 - (spread - 80.0) / 400.0).clamp(min=0.0, max=1.0)
}
}
///|
/// Combine components using explicit weights.
pub fn weighted_signal_quality(
components : SignalQualityComponents,
weights : SignalQualityWeights,
) -> Double {
let weight_sum = weights.range +
weights.continuity +
weights.stationarity +
weights.coverage +
weights.plausibility
if weight_sum == 0.0 {
0.0
} else {
(
components.range_score * weights.range +
components.continuity_score * weights.continuity +
components.stationarity_score * weights.stationarity +
components.coverage_score * weights.coverage +
components.plausibility_score * weights.plausibility
) /
weight_sum
}
}
///|
/// Convert a score to a stable quality grade.
pub fn signal_quality_grade(score : Double) -> String {
if score >= 0.90 {
"excellent"
} else if score >= 0.75 {
"good"
} else if score >= 0.55 {
"usable"
} else {
"poor"
}
}
///|
/// Evaluate an RR stream with explainable components.
pub fn evaluate_signal_quality(
intervals : Array[Double],
config : HrvConfig,
weights : SignalQualityWeights,
minimum_beats : Int,
target_beats : Int,
) -> SignalQualitySummary {
let validation = validate_intervals(intervals, config)
let components : SignalQualityComponents = {
range_score: range_quality_score(validation),
continuity_score: continuity_quality_score(intervals, 50.0),
stationarity_score: stationarity_quality_score(intervals),
coverage_score: coverage_quality_score(
intervals.length(),
minimum_beats,
target_beats,
),
plausibility_score: plausibility_quality_score(intervals, config),
}
let score = weighted_signal_quality(components, weights)
let reasons = []
if components.range_score < 0.85 {
reasons.push("physiological_range")
}
if components.continuity_score < 0.80 {
reasons.push("successive_jump")
}
if components.stationarity_score < 0.50 {
reasons.push("non_stationary")
}
if components.coverage_score < 0.50 {
reasons.push("short_recording")
}
if components.plausibility_score < 0.50 {
reasons.push("implausible_spread")
}
{
components,
score,
grade: signal_quality_grade(score),
usable: score >= 0.55 && components.range_score >= 0.70,
reasons,
}
}
///|
/// Return a fixed-order quality feature vector.
pub fn signal_quality_feature_vector(
summary : SignalQualitySummary,
) -> Array[Double] {
[
summary.components.range_score,
summary.components.continuity_score,
summary.components.stationarity_score,
summary.components.coverage_score,
summary.components.plausibility_score,
summary.score,
if summary.usable {
1.0
} else {
0.0
},
]
}
///|
/// Compare two quality summaries.
pub fn quality_score_delta(
left : SignalQualitySummary,
right : SignalQualitySummary,
) -> Double {
left.score - right.score
}
///|
/// Return a human-readable explanation of a quality summary.
pub fn signal_quality_explanation(summary : SignalQualitySummary) -> String {
if summary.usable {
"signal is usable for HRV scoring (" + summary.grade + ")"
} else if summary.reasons.length() == 0 {
"signal did not reach the configured quality threshold"
} else {
"signal quality requires review: " + join_quality_reasons(summary.reasons)
}
}
///|
fn join_quality_reasons(reasons : Array[String]) -> String {
let mut result = ""
for i in 0.. 0 {
result += ", "
}
result += reasons[i]
}
result
}
///|
/// Return whether quality changes are material.
pub fn quality_change_is_material(
left : SignalQualitySummary,
right : SignalQualitySummary,
threshold : Double,
) -> Bool {
let bound = if threshold < 0.0 { 0.0 } else { threshold }
absolute_difference(left.score, right.score) >= bound
}
///|
/// Create a score from a cleaning result.
pub fn quality_from_cleaning(
result : CleaningResult,
config : HrvConfig,
) -> SignalQualitySummary {
evaluate_signal_quality(
result.intervals,
config,
SignalQualityWeights::default(),
30,
300,
)
}
///|
/// Return the fraction of clean contiguous runs longer than a threshold.
pub fn long_run_coverage(
profile : ArtifactProfile,
minimum_run : Int,
) -> Double {
if profile.sample_count == 0 {
return 0.0
}
let threshold = if minimum_run < 1 { 1 } else { minimum_run }
let mut covered = 0
let mut cursor = 0
for run in artifact_runs(profile) {
let gap = run.start - cursor
if gap >= threshold {
covered += gap
}
cursor = run.end
}
let tail = profile.sample_count - cursor
if tail >= threshold {
covered += tail
}
covered.to_double() / profile.sample_count.to_double()
}
///|
/// Return a quality summary with a custom run-coverage reason.
pub fn evaluate_quality_with_runs(
intervals : Array[Double],
config : HrvConfig,
minimum_run : Int,
) -> SignalQualitySummary {
let profile = profile_artifacts(intervals, config)
let weights : SignalQualityWeights = {
range: 0.25,
continuity: 0.35,
stationarity: 0.20,
coverage: 0.0,
plausibility: 0.20,
}
let base = evaluate_signal_quality(intervals, config, weights, 30, 300)
let run_score = long_run_coverage(profile, minimum_run)
let adjusted_components = {
range_score: base.components.range_score,
continuity_score: base.components.continuity_score * 0.7 + run_score * 0.3,
stationarity_score: base.components.stationarity_score,
coverage_score: run_score,
plausibility_score: base.components.plausibility_score,
}
let score = weighted_signal_quality(adjusted_components, weights)
{
components: adjusted_components,
score,
grade: signal_quality_grade(score),
usable: score >= 0.55 && run_score >= 0.50,
reasons: base.reasons,
}
}