///|
/// Ambulatory segmentation and day-level summaries for wearable streams.
///|
/// Coarse activity state inferred from heart rate and movement.
pub(all) enum ActivityState {
Rest
LightActivity
ModerateActivity
VigorousActivity
UnknownActivity
} derive(FromJson, ToJson, Debug, Eq)
///|
/// One synchronized wearable observation.
pub(all) struct AmbulatorySample {
timestamp_seconds : Double
rr_ms : Double
heart_rate_bpm : Double
movement : Double
temperature_c : Double
quality_ratio : Double
} derive(FromJson, ToJson, Debug, Eq)
///|
/// A contiguous activity block.
pub(all) struct ActivityBlock {
start_index : Int
end_index : Int
state : ActivityState
duration_seconds : Double
sample_count : Int
mean_hr : Double
mean_rr : Double
rmssd : Double
quality_ratio : Double
} derive(FromJson, ToJson, Debug, Eq)
///|
/// Day-level ambulatory summary.
pub(all) struct AmbulatorySummary {
sample_count : Int
duration_seconds : Double
rest_seconds : Double
light_seconds : Double
moderate_seconds : Double
vigorous_seconds : Double
mean_hr : Double
mean_rr : Double
valid_ratio : Double
block_count : Int
dominant_state : ActivityState
hr_load : Double
temperature_mean : Double
} derive(FromJson, ToJson, Debug, Eq)
///|
/// Default heart-rate and movement thresholds.
pub(all) struct ActivityThresholds {
resting_hr_upper : Double
light_hr_upper : Double
moderate_hr_upper : Double
resting_movement_upper : Double
unknown_quality_floor : Double
} derive(FromJson, ToJson, Debug, Eq)
///|
/// Conservative thresholds for mixed wearable data.
pub fn ActivityThresholds::default() -> ActivityThresholds {
{
resting_hr_upper: 75.0,
light_hr_upper: 100.0,
moderate_hr_upper: 140.0,
resting_movement_upper: 0.12,
unknown_quality_floor: 0.50,
}
}
///|
/// Classify a synchronized sample.
pub fn classify_activity(
sample : AmbulatorySample,
thresholds : ActivityThresholds,
) -> ActivityState {
if sample.quality_ratio < thresholds.unknown_quality_floor ||
sample.heart_rate_bpm <= 0.0 {
return UnknownActivity
}
if sample.heart_rate_bpm <= thresholds.resting_hr_upper &&
sample.movement <= thresholds.resting_movement_upper {
Rest
} else if sample.heart_rate_bpm <= thresholds.light_hr_upper {
LightActivity
} else if sample.heart_rate_bpm <= thresholds.moderate_hr_upper {
ModerateActivity
} else {
VigorousActivity
}
}
///|
/// Return a numeric load weight for an activity state.
pub fn activity_state_weight(state : ActivityState) -> Double {
match state {
Rest => 0.0
LightActivity => 1.0
ModerateActivity => 2.0
VigorousActivity => 3.0
UnknownActivity => 0.0
}
}
///|
/// Return the duration between adjacent samples with a safe fallback.
pub fn sample_delta_seconds(
left : AmbulatorySample,
right : AmbulatorySample,
) -> Double {
let delta = right.timestamp_seconds - left.timestamp_seconds
if delta <= 0.0 || delta > 300.0 {
0.0
} else {
delta
}
}
///|
/// Find the contiguous block containing an index.
pub fn block_for_index(
blocks : Array[ActivityBlock],
index : Int,
) -> ActivityBlock? {
for block in blocks {
if index >= block.start_index && index < block.end_index {
return Some(block)
}
}
None
}
///|
fn build_activity_block(
samples : Array[AmbulatorySample],
states : Array[ActivityState],
start : Int,
end : Int,
) -> ActivityBlock {
let rr = []
let hr = []
let quality = []
let mut duration = 0.0
for i in start.. Array[ActivityBlock] {
if samples.length() == 0 {
return []
}
let states = []
for sample in samples {
states.push(classify_activity(sample, thresholds))
}
let blocks = []
let mut start = 0
let minimum = if minimum_block_samples < 1 {
1
} else {
minimum_block_samples
}
let mut i = 1
while i <= samples.length() {
let state_changed = i == samples.length() || states[i] != states[start]
if state_changed {
if i - start >= minimum || blocks.length() == 0 {
blocks.push(build_activity_block(samples, states, start, i))
} else {
let previous = blocks[blocks.length() - 1]
blocks[blocks.length() - 1] = build_activity_block(
samples,
states,
previous.start_index,
i,
)
}
start = i
}
i += 1
}
blocks
}
///|
/// Aggregate a synchronized sample day.
pub fn summarize_ambulatory(
samples : Array[AmbulatorySample],
thresholds : ActivityThresholds,
) -> AmbulatorySummary {
let blocks = segment_activity(samples, thresholds, 3)
let mut rest = 0.0
let mut light = 0.0
let mut moderate = 0.0
let mut vigorous = 0.0
let mut hr_load = 0.0
let mut total_temperature = 0.0
let rr = []
let hr = []
let quality = []
for sample in samples {
rr.push(sample.rr_ms)
hr.push(sample.heart_rate_bpm)
quality.push(sample.quality_ratio)
total_temperature += sample.temperature_c
}
for block in blocks {
match block.state {
Rest => rest += block.duration_seconds
LightActivity => light += block.duration_seconds
ModerateActivity => moderate += block.duration_seconds
VigorousActivity => vigorous += block.duration_seconds
UnknownActivity => ()
}
hr_load += activity_state_weight(block.state) *
block.duration_seconds /
60.0
}
let dominant = dominant_activity_state(blocks)
{
sample_count: samples.length(),
duration_seconds: rest + light + moderate + vigorous,
rest_seconds: rest,
light_seconds: light,
moderate_seconds: moderate,
vigorous_seconds: vigorous,
mean_hr: mean_value(hr),
mean_rr: mean_value(rr),
valid_ratio: mean_value(quality),
block_count: blocks.length(),
dominant_state: dominant,
hr_load,
temperature_mean: if samples.length() == 0 {
0.0
} else {
total_temperature / samples.length().to_double()
},
}
}
///|
/// Choose the state with the largest duration.
pub fn dominant_activity_state(blocks : Array[ActivityBlock]) -> ActivityState {
let mut best = UnknownActivity
let mut best_duration = -1.0
for block in blocks {
if block.duration_seconds > best_duration {
best_duration = block.duration_seconds
best = block.state
}
}
best
}
///|
/// Count samples belonging to a state.
pub fn count_activity_state(
samples : Array[AmbulatorySample],
state : ActivityState,
thresholds : ActivityThresholds,
) -> Int {
let mut count = 0
for sample in samples {
if classify_activity(sample, thresholds) == state {
count += 1
}
}
count
}
///|
/// Calculate a weighted activity load from blocks.
pub fn ambulatory_activity_load(blocks : Array[ActivityBlock]) -> Double {
let mut total = 0.0
for block in blocks {
total += activity_state_weight(block.state) * block.duration_seconds / 60.0
}
total
}
///|
/// Estimate heart-rate recovery after a vigorous block.
pub fn heart_rate_recovery_after_activity(
blocks : Array[ActivityBlock],
recovery_minutes : Double,
) -> Double {
if blocks.length() < 2 {
return 0.0
}
let mut peak = 0.0
let mut following = 0.0
for i in 0.. peak {
peak = blocks[i].mean_hr
if i + 1 < blocks.length() {
following = blocks[i + 1].mean_hr
}
}
}
if peak == 0.0 || following == 0.0 || recovery_minutes <= 0.0 {
0.0
} else {
(peak - following) / recovery_minutes
}
}
///|
/// Return a state-duration feature vector.
pub fn ambulatory_feature_vector(summary : AmbulatorySummary) -> Array[Double] {
[
summary.sample_count.to_double(),
summary.duration_seconds,
summary.rest_seconds,
summary.light_seconds,
summary.moderate_seconds,
summary.vigorous_seconds,
summary.mean_hr,
summary.mean_rr,
summary.valid_ratio,
summary.block_count.to_double(),
activity_state_weight(summary.dominant_state),
summary.hr_load,
summary.temperature_mean,
]
}
///|
/// Return whether an ambulatory summary has enough usable signal.
pub fn ambulatory_summary_is_usable(summary : AmbulatorySummary) -> Bool {
summary.sample_count > 0 &&
summary.duration_seconds > 0.0 &&
summary.valid_ratio >= 0.5
}