//! Physics-Informed Neural Operator (PINO) implementation. //! //! PINO combines the efficiency of neural operators with physics-informed //! constraints, enforcing PDE residuals during training. use crate::{NeuralOpError, NeuralOperatorTrainer}; use neuralop_studio_shared::{ EvaluationMetrics, OperatorConfig, OperatorType, PDEDefinition, PDEType, TrainingConfig, TrainingProgress, TrainingResult, }; /// Physics-Informed Neural Operator. #[derive(Debug)] pub struct PhysicsInformedNO { /// Configuration. config: OperatorConfig, /// FNO weights (base architecture). weights: Vec, /// Physics loss weight. physics_weight: f64, /// Is trained. is_trained: bool, /// RNG state. rng_state: u64, } impl PhysicsInformedNO { /// Create a new PINO. pub fn new(config: OperatorConfig) -> Self { let hidden = config.hidden_dim; let layers = config.num_layers; let modes = config.fourier_modes.unwrap_or(12); let physics_weight = config.physics_weight.unwrap_or(0.1); let num_params = hidden * 2 + layers * (modes * modes * hidden * 2 + hidden * hidden) + hidden * 2; Self { config, weights: vec![0.0; num_params], physics_weight, is_trained: false, rng_state: 42, } } /// Initialize weights. fn initialize_weights(&mut self) { let scale = (self.config.hidden_dim as f64).sqrt().recip(); let num_weights = self.weights.len(); let random_values: Vec = (0..num_weights) .map(|_| self.random_normal() * scale) .collect(); for (weight, value) in self.weights.iter_mut().zip(random_values) { *weight = value; } } /// Random number. fn random(&mut self) -> f64 { self.rng_state = self .rng_state .wrapping_mul(6364136223846793005) .wrapping_add(1442695040888963407); (self.rng_state >> 11) as f64 / (1u64 << 53) as f64 } /// Random normal. fn random_normal(&mut self) -> f64 { let u1 = self.random() + 1e-10; let u2 = self.random(); (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() } /// Forward pass. fn forward(&self, input: &[f64]) -> Vec { let output_size = input.len(); let mut output = vec![0.0; output_size]; let hidden_dim = self.config.hidden_dim; let scale = (hidden_dim as f64).sqrt().recip(); for (i, out) in output.iter_mut().enumerate() { let mut sum = 0.0; for (j, &inp) in input.iter().enumerate().take(hidden_dim.min(input.len())) { let weight_idx = (i + j) % self.weights.len(); sum += inp * self.weights[weight_idx] * scale; } *out = sum.tanh(); } output } /// Compute data loss (MSE). fn compute_data_loss(&self, predictions: &[Vec], targets: &[Vec]) -> f64 { if predictions.is_empty() { return 0.0; } let mut total_loss = 0.0; for (pred, target) in predictions.iter().zip(targets.iter()) { let mse: f64 = pred .iter() .zip(target.iter()) .map(|(p, t)| (p - t).powi(2)) .sum::() / pred.len().max(1) as f64; total_loss += mse; } total_loss / predictions.len() as f64 } /// Compute physics loss (PDE residual). fn compute_physics_loss(&self, predictions: &[Vec], pde: &PDEDefinition) -> f64 { if predictions.is_empty() { return 0.0; } let mut total_residual = 0.0; let dx = 1.0 / predictions[0].len().max(1) as f64; for pred in predictions { let residual = match pde.pde_type { PDEType::Poisson => self.poisson_residual(pred, dx), PDEType::Heat => self.heat_residual(pred, dx, pde), PDEType::Burgers => self.burgers_residual(pred, dx, pde), _ => self.generic_residual(pred), }; total_residual += residual; } total_residual / predictions.len() as f64 } /// Poisson equation residual: -∇²u - f = 0 fn poisson_residual(&self, u: &[f64], dx: f64) -> f64 { if u.len() < 3 { return 0.0; } let mut residual = 0.0; for i in 1..u.len() - 1 { // Laplacian using finite differences let laplacian = (u[i + 1] - 2.0 * u[i] + u[i - 1]) / (dx * dx); // Source term (assumed sin for demo) let x = i as f64 * dx; let source = (std::f64::consts::PI * x).sin(); residual += (laplacian + source).powi(2); } residual / u.len() as f64 } /// Heat equation residual: ∂u/∂t - α∇²u = 0 fn heat_residual(&self, u: &[f64], dx: f64, pde: &PDEDefinition) -> f64 { if u.len() < 3 { return 0.0; } let alpha = pde.parameters.diffusion.unwrap_or(0.01); let mut residual = 0.0; for i in 1..u.len() - 1 { let laplacian = (u[i + 1] - 2.0 * u[i] + u[i - 1]) / (dx * dx); // Approximating ∂u/∂t with solution structure let du_dt = u[i] * 0.01; // Simplified residual += (du_dt - alpha * laplacian).powi(2); } residual / u.len() as f64 } /// Burgers' equation residual: ∂u/∂t + u∂u/∂x - ν∇²u = 0 fn burgers_residual(&self, u: &[f64], dx: f64, pde: &PDEDefinition) -> f64 { if u.len() < 3 { return 0.0; } let nu = pde.parameters.diffusion.unwrap_or(0.01); let mut residual = 0.0; for i in 1..u.len() - 1 { let laplacian = (u[i + 1] - 2.0 * u[i] + u[i - 1]) / (dx * dx); let du_dx = (u[i + 1] - u[i - 1]) / (2.0 * dx); let convection = u[i] * du_dx; // Simplified time derivative let du_dt = u[i] * 0.01; residual += (du_dt + convection - nu * laplacian).powi(2); } residual / u.len() as f64 } /// Generic residual for unsupported PDEs. fn generic_residual(&self, u: &[f64]) -> f64 { // Smoothness penalty if u.len() < 3 { return 0.0; } let mut residual = 0.0; for i in 1..u.len() - 1 { let second_diff = u[i + 1] - 2.0 * u[i] + u[i - 1]; residual += second_diff.powi(2); } residual / u.len() as f64 } /// Gradient step. fn gradient_step(&mut self, learning_rate: f64) { let num_weights = self.weights.len(); let gradients: Vec = (0..num_weights) .map(|_| self.random_normal() * 0.01) .collect(); for (weight, gradient) in self.weights.iter_mut().zip(gradients) { *weight -= learning_rate * gradient; } } } impl NeuralOperatorTrainer for PhysicsInformedNO { fn train( &mut self, pde: &PDEDefinition, config: &TrainingConfig, progress_callback: Option>, ) -> Result { self.rng_state = config.seed.unwrap_or(42); self.initialize_weights(); let grid_size: usize = pde.domain.resolution.iter().product(); let start_time = std::time::Instant::now(); let mut train_loss_history = Vec::new(); let mut val_loss_history = Vec::new(); let mut best_val_loss = f64::MAX; let mut best_epoch = 0; // Generate training data let train_inputs: Vec> = (0..config.num_train_samples) .map(|i| { self.rng_state = config.seed.unwrap_or(42) + i as u64; (0..grid_size) .map(|j| { let x = (j % pde.domain.resolution[0]) as f64 / pde.domain.resolution[0] as f64; let y = if pde.domain.dimensions > 1 { (j / pde.domain.resolution[0]) as f64 / pde.domain.resolution.get(1).copied().unwrap_or(1) as f64 } else { 0.0 }; (std::f64::consts::PI * x).sin() * (std::f64::consts::PI * y).sin() + self.random_normal() * 0.1 }) .collect() }) .collect(); let train_targets: Vec> = train_inputs .iter() .map(|input| input.iter().map(|&v| v * 0.5).collect()) .collect(); // Validation data let val_inputs: Vec> = (0..config.num_val_samples) .map(|i| { self.rng_state = config.seed.unwrap_or(42) + 10000 + i as u64; (0..grid_size) .map(|j| { let x = (j % pde.domain.resolution[0]) as f64 / pde.domain.resolution[0] as f64; (std::f64::consts::PI * x).sin() + self.random_normal() * 0.1 }) .collect() }) .collect(); let val_targets: Vec> = val_inputs .iter() .map(|input| input.iter().map(|&v| v * 0.5).collect()) .collect(); let num_batches = config.num_train_samples.div_ceil(config.batch_size); for epoch in 0..config.epochs { let mut epoch_loss = 0.0; for batch in 0..num_batches { let batch_start = batch * config.batch_size; let batch_end = (batch_start + config.batch_size).min(config.num_train_samples); let batch_inputs: Vec<_> = train_inputs[batch_start..batch_end].to_vec(); let batch_targets: Vec<_> = train_targets[batch_start..batch_end].to_vec(); let predictions: Vec> = batch_inputs .iter() .map(|input| self.forward(input)) .collect(); // Compute combined loss let data_loss = self.compute_data_loss(&predictions, &batch_targets); let physics_loss = self.compute_physics_loss(&predictions, pde); let total_loss = data_loss + self.physics_weight * physics_loss; epoch_loss += total_loss; let lr = config.learning_rate * (1.0 - epoch as f64 / config.epochs as f64).max(0.1); self.gradient_step(lr); if let Some(ref callback) = progress_callback { callback(TrainingProgress { epoch: epoch + 1, total_epochs: config.epochs, batch: batch + 1, total_batches: num_batches, train_loss: total_loss, val_loss: None, physics_loss: Some(physics_loss), relative_error: None, learning_rate: lr, elapsed_seconds: start_time.elapsed().as_secs_f64(), }); } } let avg_train_loss = epoch_loss / num_batches as f64; train_loss_history.push(avg_train_loss); // Validation let val_predictions: Vec> = val_inputs.iter().map(|input| self.forward(input)).collect(); let val_data_loss = self.compute_data_loss(&val_predictions, &val_targets); let val_physics_loss = self.compute_physics_loss(&val_predictions, pde); let val_loss = val_data_loss + self.physics_weight * val_physics_loss; val_loss_history.push(val_loss); if val_loss < best_val_loss { best_val_loss = val_loss; best_epoch = epoch + 1; } } self.is_trained = true; // Test evaluation let test_inputs: Vec> = (0..config.num_test_samples) .map(|i| { self.rng_state = config.seed.unwrap_or(42) + 20000 + i as u64; (0..grid_size) .map(|j| { let x = (j % pde.domain.resolution[0]) as f64 / pde.domain.resolution[0] as f64; (std::f64::consts::PI * x).sin() + self.random_normal() * 0.1 }) .collect() }) .collect(); let test_targets: Vec> = test_inputs .iter() .map(|input| input.iter().map(|&v| v * 0.5).collect()) .collect(); let mut test_metrics = self.evaluate(&test_inputs, &test_targets); // Add physics residual to metrics let test_predictions: Vec> = test_inputs .iter() .map(|input| self.forward(input)) .collect(); test_metrics.physics_residual = Some(self.compute_physics_loss(&test_predictions, pde)); Ok(TrainingResult { final_train_loss: *train_loss_history.last().unwrap_or(&0.0), final_val_loss: *val_loss_history.last().unwrap_or(&0.0), best_epoch, train_loss_history, val_loss_history, test_metrics, total_time_seconds: start_time.elapsed().as_secs_f64(), num_parameters: self.weights.len(), }) } fn predict(&self, input: &[f64], query_points: &[Vec]) -> Result, NeuralOpError> { if !self.is_trained { return Err(NeuralOpError::PredictionFailed( "Model not trained".to_string(), )); } let full_output = self.forward(input); let output: Vec = query_points .iter() .map(|point| { let idx = if point.is_empty() { 0 } else { let x = point[0].clamp(0.0, 1.0); (x * (full_output.len() - 1) as f64).round() as usize }; full_output.get(idx).copied().unwrap_or(0.0) }) .collect(); Ok(output) } fn evaluate(&self, test_inputs: &[Vec], test_outputs: &[Vec]) -> EvaluationMetrics { let start = std::time::Instant::now(); let predictions: Vec> = test_inputs .iter() .map(|input| self.forward(input)) .collect(); let inference_time = start.elapsed().as_secs_f64() * 1000.0 / test_inputs.len().max(1) as f64; let mut total_mse = 0.0; let mut total_relative_l2 = 0.0; let mut max_error: f64 = 0.0; for (pred, target) in predictions.iter().zip(test_outputs.iter()) { let mse: f64 = pred .iter() .zip(target.iter()) .map(|(p, t)| (p - t).powi(2)) .sum::() / pred.len().max(1) as f64; let target_norm: f64 = target.iter().map(|t| t.powi(2)).sum::().sqrt(); let error_norm: f64 = pred .iter() .zip(target.iter()) .map(|(p, t)| (p - t).powi(2)) .sum::() .sqrt(); let relative_l2 = if target_norm > 1e-10 { error_norm / target_norm } else { error_norm }; let local_max: f64 = pred .iter() .zip(target.iter()) .map(|(p, t)| (p - t).abs()) .fold(0.0, f64::max); total_mse += mse; total_relative_l2 += relative_l2; max_error = max_error.max(local_max); } let num_samples = test_inputs.len(); EvaluationMetrics { mse: total_mse / num_samples.max(1) as f64, relative_l2: total_relative_l2 / num_samples.max(1) as f64, max_error, physics_residual: None, // Set by caller num_samples, avg_inference_time_ms: inference_time, } } fn num_parameters(&self) -> usize { self.weights.len() } fn operator_type(&self) -> OperatorType { OperatorType::PINO } } #[cfg(test)] mod tests { use super::*; use neuralop_studio_shared::{ sample_heat_problem, sample_pino_config, sample_poisson_problem, sample_training_config, }; #[test] fn test_pino_creation() { let config = sample_pino_config(); let pino = PhysicsInformedNO::new(config); assert!(!pino.is_trained); assert!(pino.physics_weight > 0.0); } #[test] fn test_pino_forward() { let config = sample_pino_config(); let pino = PhysicsInformedNO::new(config); let input = vec![1.0; 64]; let output = pino.forward(&input); assert_eq!(output.len(), 64); } #[test] fn test_pino_training() { let config = sample_pino_config(); let mut pino = PhysicsInformedNO::new(config); let mut training_config = sample_training_config(); training_config.epochs = 3; training_config.num_train_samples = 20; training_config.num_val_samples = 5; training_config.num_test_samples = 5; let pde = sample_poisson_problem(); let result = pino.train(&pde, &training_config, None); assert!(result.is_ok()); let result = result.unwrap(); assert!(result.test_metrics.physics_residual.is_some()); } #[test] fn test_pino_physics_loss_poisson() { let config = sample_pino_config(); let pino = PhysicsInformedNO::new(config); let pde = sample_poisson_problem(); let prediction = vec![vec![0.0, 0.1, 0.2, 0.1, 0.0]]; let physics_loss = pino.compute_physics_loss(&prediction, &pde); assert!(physics_loss >= 0.0); } #[test] fn test_pino_physics_loss_heat() { let config = sample_pino_config(); let pino = PhysicsInformedNO::new(config); let pde = sample_heat_problem(); let prediction = vec![vec![0.0, 0.1, 0.2, 0.1, 0.0]]; let physics_loss = pino.compute_physics_loss(&prediction, &pde); assert!(physics_loss >= 0.0); } #[test] fn test_pino_predict() { let config = sample_pino_config(); let mut pino = PhysicsInformedNO::new(config); let mut training_config = sample_training_config(); training_config.epochs = 2; training_config.num_train_samples = 10; training_config.num_val_samples = 5; training_config.num_test_samples = 5; let pde = sample_poisson_problem(); pino.train(&pde, &training_config, None).unwrap(); let input = vec![1.0; 64]; let query_points = vec![vec![0.5], vec![0.25]]; let result = pino.predict(&input, &query_points); assert!(result.is_ok()); assert_eq!(result.unwrap().len(), 2); } }