///|
/// Named feature tables used to exchange HRV measurements with models.
///|
/// One named numeric feature with provenance.
pub(all) struct NamedFeature {
name : String
value : Double
source : String
valid : Bool
} derive(FromJson, ToJson, Debug, Eq)
///|
/// A deterministic feature table.
pub(all) struct FeatureTable {
schema_version : String
features : Array[NamedFeature]
valid_count : Int
missing_count : Int
} derive(FromJson, ToJson, Debug, Eq)
///|
/// Feature scaling parameters learned from a reference set.
pub(all) struct FeatureScaler {
names : Array[String]
centers : Array[Double]
scales : Array[Double]
} derive(FromJson, ToJson, Debug, Eq)
///|
/// Build named features from a vector and a name list.
pub fn make_named_features(
names : Array[String],
values : Array[Double],
source : String,
) -> Array[NamedFeature] {
let result = []
let n = if names.length() < values.length() {
names.length()
} else {
values.length()
}
for i in 0.. FeatureTable {
let mut valid = 0
for feature in features {
if feature.valid {
valid += 1
}
}
{
schema_version,
features,
valid_count: valid,
missing_count: features.length() - valid,
}
}
///|
/// Return the feature names in table order.
pub fn feature_names(table : FeatureTable) -> Array[String] {
let result = []
for feature in table.features {
result.push(feature.name)
}
result
}
///|
/// Return values in table order, replacing missing values with a default.
pub fn feature_values(
table : FeatureTable,
missing_value : Double,
) -> Array[Double] {
let result = []
for feature in table.features {
if feature.valid {
result.push(feature.value)
} else {
result.push(missing_value)
}
}
result
}
///|
/// Look up a named feature.
pub fn feature_value(table : FeatureTable, name : String) -> Double? {
for feature in table.features {
if feature.name == name && feature.valid {
return Some(feature.value)
}
}
None
}
///|
/// Look up a named feature and return a fallback when absent.
pub fn feature_value_or(
table : FeatureTable,
name : String,
fallback : Double,
) -> Double {
match feature_value(table, name) {
Some(value) => value
None => fallback
}
}
///|
/// Add a derived feature to a table.
pub fn add_feature(
table : FeatureTable,
feature : NamedFeature,
) -> FeatureTable {
let features = []
let mut replaced = false
for current in table.features {
if current.name == feature.name {
features.push(feature)
replaced = true
} else {
features.push(current)
}
}
if !replaced {
features.push(feature)
}
FeatureTable::from_features(features, table.schema_version)
}
///|
/// Merge two feature tables while preferring values from the right table.
pub fn merge_feature_tables(
left : FeatureTable,
right : FeatureTable,
) -> FeatureTable {
let mut result = left
for feature in right.features {
result = add_feature(result, feature)
}
FeatureTable::from_features(result.features, right.schema_version)
}
///|
/// Return only valid features.
pub fn valid_features(table : FeatureTable) -> Array[NamedFeature] {
let result = []
for feature in table.features {
if feature.valid {
result.push(feature)
}
}
result
}
///|
/// Learn robust center and scale parameters for a table.
pub fn fit_feature_scaler(table : FeatureTable) -> FeatureScaler {
let names = feature_names(table)
let centers = []
let scales = []
for feature in table.features {
centers.push(if feature.valid { feature.value } else { 0.0 })
scales.push(1.0)
}
{ names, centers, scales }
}
///|
/// Learn a scaler from aligned feature tables.
pub fn fit_scaler_from_tables(tables : Array[FeatureTable]) -> FeatureScaler {
if tables.length() == 0 {
return { names: [], centers: [], scales: [] }
}
let names = feature_names(tables[0])
let centers = []
let scales = []
for i in 0.. FeatureTable {
let result = []
for feature in table.features {
let mut index = -1
for i in 0..= scaler.centers.length() {
result.push({
name: feature.name,
value: 0.0,
source: feature.source,
valid: false,
})
} else {
let scale = if index < scaler.scales.length() &&
scaler.scales[index] != 0.0 {
scaler.scales[index]
} else {
1.0
}
result.push({
name: feature.name,
value: (feature.value - scaler.centers[index]) / scale,
source: feature.source,
valid: true,
})
}
}
FeatureTable::from_features(result, table.schema_version)
}
///|
/// Concatenate feature vectors and preserve their source names.
pub fn concatenate_feature_tables(tables : Array[FeatureTable]) -> FeatureTable {
let result = []
let mut schema = "hrvkit.features.v1"
for table in tables {
schema = table.schema_version
for feature in table.features {
result.push(feature)
}
}
FeatureTable::from_features(result, schema)
}
///|
/// Select a named subset while preserving the requested order.
pub fn select_features(
table : FeatureTable,
names : Array[String],
) -> FeatureTable {
let result = []
for name in names {
for feature in table.features {
if feature.name == name {
result.push(feature)
}
}
}
FeatureTable::from_features(result, table.schema_version)
}
///|
/// Create a table from the complete default analysis vector.
pub fn default_analysis_feature_table(report : AnalysisReport) -> FeatureTable {
let names = []
for name in time_domain_feature_names() {
names.push("time." + name)
}
let time_values = time_domain_feature_vector(report.cleaned_intervals)
let features = make_named_features(names, time_values, "time_domain")
let frequency = make_named_features(
["frequency.total_power", "frequency.centroid", "frequency.entropy"],
[
report.frequency.total_power,
report.frequency.spectral_centroid_hz,
report.frequency.spectral_entropy,
],
"frequency",
)
for feature in frequency {
features.push(feature)
}
FeatureTable::from_features(features, "hrvkit.features.v1")
}
///|
/// Calculate an L2 distance between aligned feature tables.
pub fn feature_table_distance(
left : FeatureTable,
right : FeatureTable,
) -> Double {
let mut sum = 0.0
let mut count = 0
for feature in left.features {
match feature_value(right, feature.name) {
Some(other) =>
if feature.valid {
sum += (feature.value - other) * (feature.value - other)
count += 1
}
None => ()
}
}
if count == 0 {
0.0
} else {
(sum / count.to_double()).sqrt()
}
}
///|
/// Return a CSV-compatible row with the table values.
pub fn feature_table_row(table : FeatureTable) -> Array[String] {
let result = []
for feature in table.features {
if feature.valid {
result.push(feature.value.to_string())
} else {
result.push("")
}
}
result
}
///|
/// Return whether two tables have exactly the same feature schema.
pub fn feature_schema_matches(
left : FeatureTable,
right : FeatureTable,
) -> Bool {
if left.schema_version != right.schema_version ||
left.features.length() != right.features.length() {
return false
}
for i in 0.. Double {
if table.features.length() == 0 {
0.0
} else {
table.valid_count.to_double() / table.features.length().to_double()
}
}