///|
/// Respiration-related features estimated from RR variability.
///|
/// Respiratory modulation summary.
pub(all) struct RespirationSummary {
rate_bpm : Double
peak_frequency_hz : Double
modulation_depth_ms : Double
coherence : Double
phase_consistency : Double
confidence : Double
cycles : Int
} derive(FromJson, ToJson, Debug, Eq)
///|
/// Respiratory analysis configuration.
pub(all) struct RespirationConfig {
sample_rate_hz : Double
minimum_rate_bpm : Double
maximum_rate_bpm : Double
minimum_cycles : Int
band_width_hz : Double
} derive(FromJson, ToJson, Debug, Eq)
///|
/// Default adult resting respiration search range.
pub fn RespirationConfig::default() -> RespirationConfig {
{
sample_rate_hz: 4.0,
minimum_rate_bpm: 6.0,
maximum_rate_bpm: 30.0,
minimum_cycles: 3,
band_width_hz: 0.04,
}
}
///|
/// Calculate a smoothed RR modulation signal.
pub fn respiratory_modulation(
intervals : Array[Double],
radius : Int,
) -> Array[Double] {
let baseline = rolling_median(
intervals,
if radius < 1 {
3
} else {
radius * 2 + 1
},
)
let result = []
let limit = if baseline.length() < intervals.length() {
baseline.length()
} else {
intervals.length()
}
for i in 0.. Double {
let modulation = respiratory_modulation(intervals, 2)
if modulation.length() == 0 {
0.0
} else {
quantile_value(modulation, 0.75) - quantile_value(modulation, 0.25)
}
}
///|
/// Estimate an RR-respiration phase series from a candidate frequency.
pub fn respiratory_phase_series(
intervals : Array[Double],
frequency_hz : Double,
sample_rate_hz : Double,
) -> Array[Double] {
if frequency_hz <= 0.0 || sample_rate_hz <= 0.0 {
return []
}
let phase_step = 2.0 * 3.141592653589793 * frequency_hz / sample_rate_hz
let result = []
let mut phase = 0.0
let mean = mean_value(intervals)
let scale = standard_deviation(intervals)
for value in intervals {
let normalized = if scale == 0.0 { 0.0 } else { (value - mean) / scale }
result.push((@math.sin(phase) * normalized).clamp(min=-1.0, max=1.0))
phase += phase_step
}
result
}
///|
/// Calculate a normalized correlation between RR modulation and a sinusoid.
pub fn respiratory_coherence(
intervals : Array[Double],
frequency_hz : Double,
sample_rate_hz : Double,
) -> Double {
let phase = respiratory_phase_series(intervals, frequency_hz, sample_rate_hz)
let modulation = respiratory_modulation(intervals, 2)
let n = if phase.length() < modulation.length() {
phase.length()
} else {
modulation.length()
}
if n == 0 {
return 0.0
}
let left = []
let right = []
for i in 0.. Double {
if frequency_hz <= 0.0 || sample_rate_hz <= 0.0 || intervals.length() == 0 {
return 0.0
}
let modulation = respiratory_modulation(intervals, 2)
let mean = mean_value(modulation)
let deviation = standard_deviation(modulation)
if deviation == 0.0 {
return 0.0
}
let step = 2.0 * 3.141592653589793 * frequency_hz / sample_rate_hz
let mut phase = 0.0
let mut total = 0.0
for value in modulation {
let z = (value - mean) / deviation
total += (z * @math.sin(phase)).abs().clamp(min=0.0, max=1.0)
phase += step
}
total / intervals.length().to_double()
}
///|
/// Estimate the number of respiratory cycles in a candidate frequency.
pub fn estimate_respiratory_cycles(
intervals : Array[Double],
frequency_hz : Double,
sample_rate_hz : Double,
) -> Int {
if frequency_hz <= 0.0 || sample_rate_hz <= 0.0 {
return 0
}
(intervals.length().to_double() / sample_rate_hz * frequency_hz).to_int()
}
///|
/// Estimate respiration from the dominant respiratory spectral peak.
pub fn estimate_respiration(
intervals : Array[Double],
config : RespirationConfig,
) -> RespirationSummary {
let peak = respiratory_peak_frequency(intervals, config.sample_rate_hz)
let rate = respiratory_rate_bpm(peak)
let in_range = rate >= config.minimum_rate_bpm &&
rate <= config.maximum_rate_bpm
let cycles = estimate_respiratory_cycles(
intervals,
peak,
config.sample_rate_hz,
)
let coherence = respiratory_coherence(intervals, peak, config.sample_rate_hz)
let consistency = respiratory_phase_consistency(
intervals,
peak,
config.sample_rate_hz,
)
let confidence = if !in_range || cycles < config.minimum_cycles {
0.0
} else {
(0.5 * coherence + 0.5 * consistency).clamp(min=0.0, max=1.0)
}
{
rate_bpm: if in_range {
rate
} else {
0.0
},
peak_frequency_hz: if in_range {
peak
} else {
0.0
},
modulation_depth_ms: respiratory_modulation_depth(intervals),
coherence,
phase_consistency: consistency,
confidence,
cycles,
}
}
///|
/// Return a respiratory rate from a direct breathing trace.
pub fn estimate_trace_rate(
trace : Array[Double],
sample_rate_hz : Double,
) -> Double {
if trace.length() <= 2 || sample_rate_hz <= 0.0 {
return 0.0
}
let spectrum = calculate_periodogram(trace, sample_rate_hz)
let selected = []
for bin in spectrum {
let rate = bin.frequency_hz * 60.0
if rate >= 4.0 && rate <= 40.0 {
selected.push(bin)
}
}
if selected.length() == 0 {
0.0
} else {
respiratory_rate_bpm(dominant_spectrum_bin(selected).frequency_hz)
}
}
///|
/// Calculate a respiration-aware LF/HF modulation ratio.
pub fn respiratory_band_ratio(
intervals : Array[Double],
sample_rate_hz : Double,
) -> Double {
let spectrum = calculate_periodogram(intervals, sample_rate_hz)
let lf = spectral_band_power(spectrum, {
name: "lf",
lower_hz: 0.04,
upper_hz: 0.15,
})
let respiration = spectral_band_power(spectrum, {
name: "resp",
lower_hz: 0.10,
upper_hz: 0.50,
})
if lf == 0.0 {
0.0
} else {
respiration / lf
}
}
///|
/// Return a compact respiration feature vector.
pub fn respiration_feature_vector(
summary : RespirationSummary,
) -> Array[Double] {
[
summary.rate_bpm,
summary.peak_frequency_hz,
summary.modulation_depth_ms,
summary.coherence,
summary.phase_consistency,
summary.confidence,
summary.cycles.to_double(),
]
}
///|
/// Return whether respiration can be used as a report annotation.
pub fn respiration_is_usable(summary : RespirationSummary) -> Bool {
summary.rate_bpm > 0.0 && summary.cycles >= 3 && summary.confidence >= 0.25
}