Consistent formatting pass: line wrapping, import sorting, trailing whitespace removal, let-chain indentation, merged derive attributes, and unsafe block reformatting. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
357 lines
12 KiB
Rust
357 lines
12 KiB
Rust
//! ECG computation from cardiac voltage fields.
|
|
//!
|
|
//! Computes virtual ECG signals using the solid angle method.
|
|
|
|
use cardiosim_shared::{ECGLeads, ECGResult, Point3D, SimulationResult};
|
|
|
|
use crate::CardioSimError;
|
|
|
|
/// ECG computer.
|
|
#[derive(Debug)]
|
|
pub struct ECGComputer {
|
|
leads: ECGLeads,
|
|
electrode_positions: Vec<Point3D>,
|
|
}
|
|
|
|
impl ECGComputer {
|
|
/// Create a new ECG computer.
|
|
#[must_use]
|
|
pub fn new(leads: &ECGLeads) -> Self {
|
|
let mut electrode_positions = leads.custom_electrodes.clone();
|
|
|
|
if leads.standard_12_lead {
|
|
// Standard 12-lead electrode positions (simplified)
|
|
// These are relative positions on a normalized torso
|
|
|
|
// Limb leads
|
|
electrode_positions.push(Point3D::new(-100.0, 0.0, 0.0)); // LA
|
|
electrode_positions.push(Point3D::new(100.0, 0.0, 0.0)); // RA
|
|
electrode_positions.push(Point3D::new(0.0, 0.0, -100.0)); // LL
|
|
electrode_positions.push(Point3D::new(0.0, 0.0, -100.0)); // RL (reference)
|
|
|
|
// Precordial leads (V1-V6)
|
|
electrode_positions.push(Point3D::new(20.0, 50.0, 30.0)); // V1
|
|
electrode_positions.push(Point3D::new(-20.0, 50.0, 30.0)); // V2
|
|
electrode_positions.push(Point3D::new(-50.0, 50.0, 20.0)); // V3
|
|
electrode_positions.push(Point3D::new(-70.0, 50.0, 0.0)); // V4
|
|
electrode_positions.push(Point3D::new(-80.0, 40.0, -20.0)); // V5
|
|
electrode_positions.push(Point3D::new(-90.0, 30.0, -40.0)); // V6
|
|
}
|
|
|
|
Self {
|
|
leads: leads.clone(),
|
|
electrode_positions,
|
|
}
|
|
}
|
|
|
|
/// Compute ECG from simulation result.
|
|
pub fn compute(&self, sim: &SimulationResult) -> Result<ECGResult, CardioSimError> {
|
|
if sim.voltage_fields.is_empty() {
|
|
return Err(CardioSimError::SimulationError(
|
|
"No voltage fields to compute ECG".to_string(),
|
|
));
|
|
}
|
|
|
|
let times: Vec<f32> = sim.voltage_fields.iter().map(|f| f.time).collect();
|
|
let n_times = times.len();
|
|
|
|
// Compute potentials at each electrode
|
|
let mut potentials: Vec<Vec<f32>> =
|
|
vec![vec![0.0; n_times]; self.electrode_positions.len()];
|
|
|
|
for (t_idx, field) in sim.voltage_fields.iter().enumerate() {
|
|
for (e_idx, electrode) in self.electrode_positions.iter().enumerate() {
|
|
potentials[e_idx][t_idx] = self.compute_potential(electrode, &field.voltages, sim);
|
|
}
|
|
}
|
|
|
|
// Compute lead signals
|
|
let (lead_i, lead_ii, lead_iii) = if self.electrode_positions.len() >= 4 {
|
|
// LA=0, RA=1, LL=2
|
|
let lead_i: Vec<f32> = (0..n_times)
|
|
.map(|t| potentials[0][t] - potentials[1][t])
|
|
.collect();
|
|
let lead_ii: Vec<f32> = (0..n_times)
|
|
.map(|t| potentials[2][t] - potentials[1][t])
|
|
.collect();
|
|
let lead_iii: Vec<f32> = (0..n_times)
|
|
.map(|t| potentials[2][t] - potentials[0][t])
|
|
.collect();
|
|
(lead_i, lead_ii, lead_iii)
|
|
} else {
|
|
(vec![0.0; n_times], vec![0.0; n_times], vec![0.0; n_times])
|
|
};
|
|
|
|
// Precordial leads (V1-V6)
|
|
let precordial: Vec<Vec<f32>> = if self.electrode_positions.len() >= 10 {
|
|
// Wilson's central terminal
|
|
let wct: Vec<f32> = (0..n_times)
|
|
.map(|t| (potentials[0][t] + potentials[1][t] + potentials[2][t]) / 3.0)
|
|
.collect();
|
|
|
|
(4..10)
|
|
.map(|e| (0..n_times).map(|t| potentials[e][t] - wct[t]).collect())
|
|
.collect()
|
|
} else {
|
|
vec![vec![0.0; n_times]; 6]
|
|
};
|
|
|
|
// Calculate heart rate from R-R intervals
|
|
let heart_rate = self.calculate_heart_rate(&lead_ii);
|
|
|
|
// Calculate QT interval
|
|
let qt_interval = self.calculate_qt_interval(&lead_ii, ×);
|
|
|
|
// Calculate QRS duration
|
|
let qrs_duration = self.calculate_qrs_duration(&lead_ii, ×);
|
|
|
|
Ok(ECGResult {
|
|
times,
|
|
lead_i,
|
|
lead_ii,
|
|
lead_iii,
|
|
precordial,
|
|
heart_rate,
|
|
qt_interval,
|
|
qrs_duration,
|
|
})
|
|
}
|
|
|
|
fn compute_potential(
|
|
&self,
|
|
electrode: &Point3D,
|
|
voltages: &[f32],
|
|
sim: &SimulationResult,
|
|
) -> f32 {
|
|
// Use solid angle method (simplified)
|
|
// Φ = (1/4πσ) ∫ Vm ∇·(r/|r|³) dV
|
|
|
|
let mut potential = 0.0;
|
|
let n_vertices = voltages.len();
|
|
|
|
// Need mesh info - use activation map vertex count
|
|
let n_mesh_vertices = sim.activation_map.activation_times.len();
|
|
if n_mesh_vertices == 0 || n_vertices != n_mesh_vertices {
|
|
return 0.0;
|
|
}
|
|
|
|
// Simplified: treat each vertex as a dipole source
|
|
for (i, &voltage) in voltages.iter().enumerate() {
|
|
// Estimate vertex position from activation time gradient
|
|
// (in real implementation, would use mesh vertices)
|
|
let t = sim.activation_map.activation_times[i];
|
|
if !t.is_finite() {
|
|
continue;
|
|
}
|
|
|
|
// Create pseudo-position based on activation time
|
|
let r = t / 100.0; // Convert to distance-like quantity
|
|
let theta = (i as f32) * 0.1;
|
|
let phi = (i as f32) * 0.2;
|
|
|
|
let vertex = Point3D::new(
|
|
r * theta.sin() * phi.cos() * 30.0,
|
|
r * theta.sin() * phi.sin() * 25.0,
|
|
r * theta.cos() * 50.0,
|
|
);
|
|
|
|
let dx = electrode.x - vertex.x;
|
|
let dy = electrode.y - vertex.y;
|
|
let dz = electrode.z - vertex.z;
|
|
let dist_sq = dx * dx + dy * dy + dz * dz + 1e-6;
|
|
let dist = dist_sq.sqrt();
|
|
|
|
// Dipole contribution (simplified)
|
|
let contribution = voltage / (dist * dist);
|
|
potential += contribution;
|
|
}
|
|
|
|
// Scale
|
|
potential * 0.001
|
|
}
|
|
|
|
fn calculate_heart_rate(&self, lead_ii: &[f32]) -> f32 {
|
|
if lead_ii.len() < 10 {
|
|
return 60.0; // Default
|
|
}
|
|
|
|
// Find R-peaks (simplified: look for local maxima above threshold)
|
|
let threshold = lead_ii.iter().fold(f32::MIN, |a, &b| a.max(b)) * 0.5;
|
|
let mut r_peak_indices = Vec::new();
|
|
|
|
for i in 1..lead_ii.len() - 1 {
|
|
if lead_ii[i] > threshold && lead_ii[i] > lead_ii[i - 1] && lead_ii[i] > lead_ii[i + 1]
|
|
{
|
|
// Check if far enough from last peak
|
|
if r_peak_indices.is_empty() || i - r_peak_indices.last().unwrap() > 50 {
|
|
r_peak_indices.push(i);
|
|
}
|
|
}
|
|
}
|
|
|
|
if r_peak_indices.len() < 2 {
|
|
return 60.0;
|
|
}
|
|
|
|
// Calculate average R-R interval
|
|
let mut rr_sum = 0.0;
|
|
for i in 1..r_peak_indices.len() {
|
|
rr_sum += (r_peak_indices[i] - r_peak_indices[i - 1]) as f32;
|
|
}
|
|
let avg_rr = rr_sum / (r_peak_indices.len() - 1) as f32;
|
|
|
|
// Convert to heart rate (assuming 1ms per sample)
|
|
60000.0 / avg_rr
|
|
}
|
|
|
|
fn calculate_qt_interval(&self, lead_ii: &[f32], times: &[f32]) -> f32 {
|
|
if lead_ii.len() < 100 || times.len() < 100 {
|
|
return 400.0; // Default ~400ms
|
|
}
|
|
|
|
// Find Q onset and T end (simplified)
|
|
let threshold = lead_ii.iter().fold(f32::MIN, |a, &b| a.max(b)) * 0.1;
|
|
|
|
// Find first significant deflection (Q)
|
|
let q_onset = lead_ii.iter().position(|&v| v.abs() > threshold);
|
|
|
|
// Find return to baseline after T wave
|
|
let t_end = lead_ii.iter().rposition(|&v| v.abs() > threshold);
|
|
|
|
match (q_onset, t_end) {
|
|
(Some(q), Some(t)) if t > q && q < times.len() && t < times.len() => {
|
|
times[t] - times[q]
|
|
}
|
|
_ => 400.0,
|
|
}
|
|
}
|
|
|
|
fn calculate_qrs_duration(&self, lead_ii: &[f32], times: &[f32]) -> f32 {
|
|
if lead_ii.len() < 50 || times.len() < 50 {
|
|
return 100.0; // Default ~100ms
|
|
}
|
|
|
|
// Find QRS complex (high amplitude, fast changes)
|
|
let threshold = lead_ii.iter().fold(f32::MIN, |a, &b| a.max(b)) * 0.3;
|
|
|
|
let qrs_start = lead_ii.iter().position(|&v| v.abs() > threshold);
|
|
let qrs_end = lead_ii
|
|
.iter()
|
|
.enumerate()
|
|
.skip(qrs_start.unwrap_or(0) + 10)
|
|
.find(|(_, v)| v.abs() < threshold)
|
|
.map(|(i, _)| i);
|
|
|
|
match (qrs_start, qrs_end) {
|
|
(Some(start), Some(end)) if end > start && start < times.len() && end < times.len() => {
|
|
times[end] - times[start]
|
|
}
|
|
_ => 100.0,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use cardiosim_shared::{APDMap, ActivationMap, VoltageField};
|
|
|
|
fn create_test_simulation() -> SimulationResult {
|
|
let n = 100;
|
|
let n_times = 50;
|
|
|
|
let voltage_fields: Vec<VoltageField> = (0..n_times)
|
|
.map(|t| {
|
|
let time = t as f32 * 2.0;
|
|
let voltages: Vec<f32> = (0..n)
|
|
.map(|i| {
|
|
let phase = (time - i as f32) / 50.0;
|
|
if phase > 0.0 && phase < 0.5 {
|
|
20.0 * (std::f32::consts::PI * 2.0 * phase).sin()
|
|
} else {
|
|
-85.0
|
|
}
|
|
})
|
|
.collect();
|
|
VoltageField { time, voltages }
|
|
})
|
|
.collect();
|
|
|
|
let activation_times: Vec<f32> = (0..n).map(|i| i as f32).collect();
|
|
|
|
SimulationResult {
|
|
times: voltage_fields.iter().map(|f| f.time).collect(),
|
|
voltage_fields,
|
|
activation_map: ActivationMap {
|
|
activation_times,
|
|
conduction_velocity: vec![0.8; n],
|
|
},
|
|
apd_map: APDMap {
|
|
apd50: vec![150.0; n],
|
|
apd90: vec![250.0; n],
|
|
dispersion: 20.0,
|
|
},
|
|
arrhythmias: vec![],
|
|
stats: cardiosim_shared::SimulationStats {
|
|
compute_time: 1.0,
|
|
time_steps: n_times,
|
|
speedup_factor: 1.0,
|
|
inference_time: 1.0,
|
|
},
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_ecg_computer_creation() {
|
|
let leads = ECGLeads::default();
|
|
let computer = ECGComputer::new(&leads);
|
|
assert!(!computer.electrode_positions.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_compute_ecg() {
|
|
let leads = ECGLeads::default();
|
|
let computer = ECGComputer::new(&leads);
|
|
let sim = create_test_simulation();
|
|
|
|
let result = computer.compute(&sim);
|
|
assert!(result.is_ok());
|
|
|
|
let ecg = result.unwrap();
|
|
assert!(!ecg.times.is_empty());
|
|
assert_eq!(ecg.lead_i.len(), ecg.times.len());
|
|
assert_eq!(ecg.lead_ii.len(), ecg.times.len());
|
|
assert_eq!(ecg.lead_iii.len(), ecg.times.len());
|
|
assert_eq!(ecg.precordial.len(), 6);
|
|
}
|
|
|
|
#[test]
|
|
fn test_heart_rate_calculation() {
|
|
let leads = ECGLeads::default();
|
|
let computer = ECGComputer::new(&leads);
|
|
|
|
// Create synthetic ECG with known R-R interval
|
|
let lead_ii: Vec<f32> = (0..1000)
|
|
.map(|i| {
|
|
// R-peak every 100 samples
|
|
if i % 100 == 0 { 1.0 } else { 0.0 }
|
|
})
|
|
.collect();
|
|
|
|
let hr = computer.calculate_heart_rate(&lead_ii);
|
|
// Should be around 600 bpm (R every 100ms)
|
|
assert!(hr > 500.0 && hr < 700.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_custom_electrodes() {
|
|
let leads = ECGLeads {
|
|
standard_12_lead: false,
|
|
custom_electrodes: vec![Point3D::new(0.0, 50.0, 0.0), Point3D::new(0.0, -50.0, 0.0)],
|
|
};
|
|
|
|
let computer = ECGComputer::new(&leads);
|
|
assert_eq!(computer.electrode_positions.len(), 2);
|
|
}
|
|
}
|