Files
rustytorch/demos/rtx-cardiosim-demo/src/neural_operator.rs
T
osobhandClaude Opus 4.6 02d382d5f6 style: apply rustfmt across all crates and demos
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]>
2026-04-12 07:01:58 -07:00

384 lines
11 KiB
Rust

//! Physics-Informed Neural Operator (PINO) for cardiac electrophysiology.
//!
//! Implements a neural operator that learns the solution operator for the
//! monodomain equation with physics-based constraints.
use cardiosim_shared::{
HeartMesh, NeuralOperatorConfig, SimulationConfig, StimulationProtocol, VoltageField,
};
use crate::CardioSimError;
/// Physics-Informed Neural Operator for cardiac simulation.
#[derive(Debug)]
pub struct CardiacPINO {
config: NeuralOperatorConfig,
fno_layers: Vec<FourierLayer>,
lifting: MLPLayer,
projection: MLPLayer,
}
impl CardiacPINO {
/// Create a new cardiac PINO.
#[must_use]
pub fn new(config: NeuralOperatorConfig) -> Self {
let lifting = MLPLayer::new(config.input_channels, config.hidden_dim);
let fno_layers: Vec<FourierLayer> = (0..config.num_layers)
.map(|_| FourierLayer::new(config.hidden_dim, 16))
.collect();
let projection = MLPLayer::new(config.hidden_dim, config.output_channels);
Self {
config,
fno_layers,
lifting,
projection,
}
}
/// Solve using neural operator.
pub fn solve(
&self,
mesh: &HeartMesh,
protocol: &StimulationProtocol,
config: &SimulationConfig,
) -> Result<Vec<VoltageField>, CardioSimError> {
let n_vertices = mesh.vertices.len();
let n_outputs = (config.total_time / config.output_interval) as usize;
// Initialize input (voltage, recovery, coordinates)
let mut voltage = vec![-85.0_f32; n_vertices];
let recovery = vec![0.0_f32; n_vertices];
// Encode mesh coordinates
let coords: Vec<[f32; 3]> = mesh
.vertices
.iter()
.map(|v| [v.x / 100.0, v.y / 100.0, v.z / 100.0]) // Normalize
.collect();
let mut output = Vec::new();
// Simulate in chunks using neural operator
let _time_steps_per_inference = self.config.time_steps;
let mut t = 0.0;
for _ in 0..n_outputs {
// Prepare input tensor
let input = self.prepare_input(&voltage, &recovery, &coords, mesh, protocol, t);
// Forward through neural operator
let prediction = self.forward(&input);
// Extract voltage and recovery
let new_voltage: Vec<f32> = prediction.iter().take(n_vertices).copied().collect();
let _new_recovery: Vec<f32> = prediction
.iter()
.skip(n_vertices)
.take(n_vertices)
.copied()
.collect();
voltage = new_voltage;
output.push(VoltageField {
time: t,
voltages: voltage.clone(),
});
t += config.output_interval;
}
Ok(output)
}
fn prepare_input(
&self,
voltage: &[f32],
recovery: &[f32],
coords: &[[f32; 3]],
mesh: &HeartMesh,
protocol: &StimulationProtocol,
t: f32,
) -> Vec<f32> {
let n = voltage.len();
let mut input = Vec::with_capacity(n * self.config.input_channels);
for i in 0..n {
// Voltage (normalized)
input.push((voltage[i] + 85.0) / 120.0);
// Recovery
input.push(recovery[i]);
// Spatial coordinates
input.push(coords[i][0]);
input.push(coords[i][1]);
input.push(coords[i][2]);
// Fiber direction
input.push(mesh.fibers[i].x);
input.push(mesh.fibers[i].y);
input.push(mesh.fibers[i].z);
}
// Add stimulus encoding
for site in &protocol.sites {
for &stim_time in &site.times {
if t >= stim_time && t < stim_time + site.duration {
// Mark stimulated vertices
for (i, vertex) in mesh.vertices.iter().enumerate() {
let dist = vertex.distance_to(&site.center);
if dist < site.radius {
input[i * self.config.input_channels] += 0.5;
}
}
}
}
}
input
}
fn forward(&self, input: &[f32]) -> Vec<f32> {
// Lifting layer
let mut hidden = self.lifting.forward(input);
// Fourier layers
for layer in &self.fno_layers {
hidden = layer.forward(&hidden);
}
// Projection layer
self.projection.forward(&hidden)
}
}
/// Fourier layer for FNO.
#[derive(Debug)]
struct FourierLayer {
hidden_dim: usize,
modes: usize,
weights_real: Vec<Vec<f32>>,
weights_imag: Vec<Vec<f32>>,
linear_weights: Vec<Vec<f32>>,
}
impl FourierLayer {
fn new(hidden_dim: usize, modes: usize) -> Self {
use rand::SeedableRng;
use rand_distr::{Distribution, Normal};
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
let std = (2.0 / hidden_dim as f32).sqrt();
let normal = Normal::new(0.0_f32, std).unwrap();
let weights_real: Vec<Vec<f32>> = (0..modes)
.map(|_| (0..hidden_dim).map(|_| normal.sample(&mut rng)).collect())
.collect();
let weights_imag: Vec<Vec<f32>> = (0..modes)
.map(|_| (0..hidden_dim).map(|_| normal.sample(&mut rng)).collect())
.collect();
let linear_weights: Vec<Vec<f32>> = (0..hidden_dim)
.map(|_| (0..hidden_dim).map(|_| normal.sample(&mut rng)).collect())
.collect();
Self {
hidden_dim,
modes,
weights_real,
weights_imag,
linear_weights,
}
}
fn forward(&self, input: &[f32]) -> Vec<f32> {
let n = input.len() / self.hidden_dim;
if n == 0 {
return input.to_vec();
}
// Simplified spectral convolution
let mut output = vec![0.0_f32; input.len()];
// Linear path (skip connection)
for i in 0..n {
for j in 0..self.hidden_dim {
let input_val = input.get(i * self.hidden_dim + j).copied().unwrap_or(0.0);
for k in 0..self.hidden_dim {
let weight = self
.linear_weights
.get(j)
.and_then(|w| w.get(k))
.copied()
.unwrap_or(0.0);
output[i * self.hidden_dim + k] += input_val * weight;
}
}
}
// Spectral path (simplified DFT-like operation)
for mode in 0..self.modes.min(n) {
let freq = 2.0 * std::f32::consts::PI * mode as f32 / n as f32;
for i in 0..n {
let phase = freq * i as f32;
let cos_p = phase.cos();
let sin_p = phase.sin();
for j in 0..self.hidden_dim {
let input_val = input.get(i * self.hidden_dim + j).copied().unwrap_or(0.0);
let weight_r = self
.weights_real
.get(mode)
.and_then(|w| w.get(j))
.copied()
.unwrap_or(0.0);
let weight_i = self
.weights_imag
.get(mode)
.and_then(|w| w.get(j))
.copied()
.unwrap_or(0.0);
output[i * self.hidden_dim + j] +=
input_val * (weight_r * cos_p - weight_i * sin_p);
}
}
}
// GELU activation
for v in &mut output {
*v = gelu(*v);
}
output
}
}
/// MLP layer.
#[derive(Debug)]
struct MLPLayer {
in_dim: usize,
out_dim: usize,
weights: Vec<Vec<f32>>,
bias: Vec<f32>,
}
impl MLPLayer {
fn new(in_dim: usize, out_dim: usize) -> Self {
use rand::SeedableRng;
use rand_distr::{Distribution, Normal};
let mut rng = rand::rngs::StdRng::seed_from_u64(123);
let std = (2.0 / in_dim as f32).sqrt();
let normal = Normal::new(0.0_f32, std).unwrap();
let weights: Vec<Vec<f32>> = (0..in_dim)
.map(|_| (0..out_dim).map(|_| normal.sample(&mut rng)).collect())
.collect();
let bias: Vec<f32> = (0..out_dim).map(|_| 0.0).collect();
Self {
in_dim,
out_dim,
weights,
bias,
}
}
fn forward(&self, input: &[f32]) -> Vec<f32> {
let batch_size = input.len() / self.in_dim;
if batch_size == 0 {
return vec![0.0; self.out_dim];
}
let mut output = vec![0.0_f32; batch_size * self.out_dim];
for b in 0..batch_size {
for j in 0..self.out_dim {
let mut sum = self.bias[j];
for i in 0..self.in_dim {
let input_idx = b * self.in_dim + i;
let input_val = input.get(input_idx).copied().unwrap_or(0.0);
let weight = self
.weights
.get(i)
.and_then(|w| w.get(j))
.copied()
.unwrap_or(0.0);
sum += input_val * weight;
}
output[b * self.out_dim + j] = sum;
}
}
output
}
}
/// GELU activation function.
fn gelu(x: f32) -> f32 {
0.5 * x * (1.0 + ((2.0_f32 / std::f32::consts::PI).sqrt() * (x + 0.044715 * x.powi(3))).tanh())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pino_creation() {
let config = NeuralOperatorConfig::default();
let pino = CardiacPINO::new(config);
assert_eq!(pino.fno_layers.len(), 4);
}
#[test]
fn test_pino_solve() {
let config = NeuralOperatorConfig::default();
let pino = CardiacPINO::new(config);
let mesh = cardiosim_shared::get_sample_heart_mesh();
let protocol = cardiosim_shared::get_sample_protocol();
let sim_config = SimulationConfig {
total_time: 10.0,
output_interval: 5.0,
..Default::default()
};
let result = pino.solve(&mesh, &protocol, &sim_config);
assert!(result.is_ok());
let fields = result.unwrap();
assert!(!fields.is_empty());
}
#[test]
fn test_fourier_layer() {
let layer = FourierLayer::new(32, 8);
let input = vec![0.1_f32; 32 * 10];
let output = layer.forward(&input);
assert_eq!(output.len(), input.len());
}
#[test]
fn test_mlp_layer() {
let layer = MLPLayer::new(16, 32);
let input = vec![0.1_f32; 16 * 5];
let output = layer.forward(&input);
assert_eq!(output.len(), 32 * 5);
}
#[test]
fn test_gelu() {
assert!((gelu(0.0) - 0.0).abs() < 0.01);
assert!(gelu(2.0) > 1.9);
assert!(gelu(-2.0) < 0.1);
}
}