oxirs_physics/simulation/
scirs2_thermal.rs

1//! SciRS2-based Thermal Simulation
2//!
3//! Real thermal simulation using scirs2-integrate ODE solvers
4
5use super::parameter_extraction::SimulationParameters;
6use super::result_injection::{
7    ConvergenceInfo, SimulationProvenance, SimulationResult, StateVector,
8};
9use super::simulation_runner::PhysicsSimulation;
10use crate::error::{PhysicsError, PhysicsResult};
11use async_trait::async_trait;
12use chrono::Utc;
13use std::collections::HashMap;
14use uuid::Uuid;
15
16/// Thermal Simulation using SciRS2
17pub struct SciRS2ThermalSimulation {
18    /// Thermal conductivity (W/m·K)
19    #[cfg_attr(not(feature = "simulation"), allow(dead_code))]
20    conductivity: f64,
21
22    /// Specific heat capacity (J/kg·K)
23    #[cfg_attr(not(feature = "simulation"), allow(dead_code))]
24    specific_heat: f64,
25
26    /// Density (kg/m³)
27    #[cfg_attr(not(feature = "simulation"), allow(dead_code))]
28    density: f64,
29}
30
31impl Default for SciRS2ThermalSimulation {
32    fn default() -> Self {
33        Self {
34            conductivity: 1.0,     // Default: water-like
35            specific_heat: 4186.0, // Water: 4186 J/kg·K
36            density: 1000.0,       // Water: 1000 kg/m³
37        }
38    }
39}
40
41impl SciRS2ThermalSimulation {
42    pub fn new(conductivity: f64, specific_heat: f64, density: f64) -> Self {
43        Self {
44            conductivity,
45            specific_heat,
46            density,
47        }
48    }
49}
50
51#[async_trait]
52impl PhysicsSimulation for SciRS2ThermalSimulation {
53    fn simulation_type(&self) -> &str {
54        "thermal"
55    }
56
57    async fn run(&self, params: &SimulationParameters) -> PhysicsResult<SimulationResult> {
58        #[cfg(feature = "simulation")]
59        {
60            self.run_with_scirs2(params).await
61        }
62
63        #[cfg(not(feature = "simulation"))]
64        {
65            // Fallback to mock simulation
66            self.run_mock(params).await
67        }
68    }
69
70    fn validate_results(&self, result: &SimulationResult) -> PhysicsResult<()> {
71        if !result.convergence_info.converged {
72            return Err(PhysicsError::Simulation(
73                "Thermal simulation did not converge".to_string(),
74            ));
75        }
76
77        // Check temperature bounds (physical constraints)
78        for state in &result.state_trajectory {
79            if let Some(&temp) = state.state.get("temperature") {
80                if !(0.0..=1000.0).contains(&temp) {
81                    return Err(PhysicsError::ConstraintViolation(format!(
82                        "Temperature {} K out of physical bounds [0, 1000]",
83                        temp
84                    )));
85                }
86            }
87        }
88
89        Ok(())
90    }
91}
92
93impl SciRS2ThermalSimulation {
94    #[cfg(feature = "simulation")]
95    async fn run_with_scirs2(
96        &self,
97        params: &SimulationParameters,
98    ) -> PhysicsResult<SimulationResult> {
99        use scirs2_core::ndarray_ext::{Array1, ArrayView1};
100        use scirs2_integrate::ode::{solve_ivp, ODEMethod, ODEOptions};
101
102        // Extract initial temperature from parameters
103        let initial_temp = params
104            .initial_conditions
105            .get("temperature")
106            .map(|q| q.value)
107            .unwrap_or(293.15); // Default: 20°C = 293.15 K
108
109        // Setup initial state (1D temperature distribution)
110        let n_points = 10;
111        let initial_state: Array1<f64> = Array1::from_elem(n_points, initial_temp);
112
113        // Thermal diffusivity
114        let alpha = self.conductivity / (self.density * self.specific_heat);
115        let dx = 0.1; // Grid spacing
116
117        // Define the thermal ODE: dT/dt = α ∇²T
118        let thermal_ode = move |_t: f64, y: ArrayView1<f64>| -> Array1<f64> {
119            let mut derivatives = Array1::zeros(n_points);
120            for i in 0..n_points {
121                let left = if i > 0 { y[i - 1] } else { y[i] };
122                let right = if i < n_points - 1 { y[i + 1] } else { y[i] };
123                let center = y[i];
124                derivatives[i] = alpha * (left - 2.0 * center + right) / (dx * dx);
125            }
126            derivatives
127        };
128
129        // Solve with RK45
130        let options = ODEOptions {
131            method: ODEMethod::RK45,
132            rtol: 1e-6,
133            atol: 1e-8,
134            max_step: Some((params.time_span.1 - params.time_span.0) / (params.time_steps as f64)),
135            ..Default::default()
136        };
137
138        let start_time = std::time::Instant::now();
139        let solution = solve_ivp(
140            thermal_ode,
141            [params.time_span.0, params.time_span.1],
142            initial_state,
143            Some(options),
144        )
145        .map_err(|e| PhysicsError::Simulation(format!("SciRS2 ODE solver failed: {:?}", e)))?;
146        let elapsed_ms = start_time.elapsed().as_millis() as u64;
147
148        // Resample the solution to match requested time_steps
149        // The ODE solver uses adaptive steps, so we interpolate to get the requested number of points
150        let mut trajectory = Vec::new();
151        let dt = (params.time_span.1 - params.time_span.0) / (params.time_steps as f64);
152
153        for i in 0..params.time_steps {
154            let target_time = params.time_span.0 + dt * (i as f64);
155
156            // Find the temperature at this time by linear interpolation
157            let temp = interpolate_at_time(&solution.t, &solution.y, target_time, n_points / 2);
158
159            let mut state = HashMap::new();
160            state.insert("temperature".to_string(), temp);
161            trajectory.push(StateVector {
162                time: target_time,
163                state,
164            });
165        }
166
167        Ok(SimulationResult {
168            entity_iri: params.entity_iri.clone(),
169            simulation_run_id: Uuid::new_v4().to_string(),
170            timestamp: Utc::now(),
171            state_trajectory: trajectory,
172            derived_quantities: HashMap::new(),
173            convergence_info: ConvergenceInfo {
174                converged: solution.success,
175                iterations: solution.t.len(),
176                final_residual: 1e-6,
177            },
178            provenance: SimulationProvenance {
179                software: "oxirs-physics (SciRS2)".to_string(),
180                version: crate::VERSION.to_string(),
181                parameters_hash: self.compute_params_hash(params),
182                executed_at: Utc::now(),
183                execution_time_ms: elapsed_ms,
184            },
185        })
186    }
187
188    #[cfg(not(feature = "simulation"))]
189    async fn run_mock(&self, params: &SimulationParameters) -> PhysicsResult<SimulationResult> {
190        // Mock simulation (fallback when scirs2-integrate not available)
191        let mut trajectory = Vec::new();
192
193        let initial_temp = params
194            .initial_conditions
195            .get("temperature")
196            .map(|q| q.value)
197            .unwrap_or(293.15);
198
199        for i in 0..params.time_steps {
200            let time = params.time_span.0
201                + (params.time_span.1 - params.time_span.0) * (i as f64 / params.time_steps as f64);
202
203            let mut state = HashMap::new();
204            state.insert("temperature".to_string(), initial_temp + time * 0.01);
205
206            trajectory.push(StateVector { time, state });
207        }
208
209        Ok(SimulationResult {
210            entity_iri: params.entity_iri.clone(),
211            simulation_run_id: Uuid::new_v4().to_string(),
212            timestamp: Utc::now(),
213            state_trajectory: trajectory,
214            derived_quantities: HashMap::new(),
215            convergence_info: ConvergenceInfo {
216                converged: true,
217                iterations: params.time_steps,
218                final_residual: 1e-6,
219            },
220            provenance: SimulationProvenance {
221                software: "oxirs-physics (mock)".to_string(),
222                version: crate::VERSION.to_string(),
223                parameters_hash: self.compute_params_hash(params),
224                executed_at: Utc::now(),
225                execution_time_ms: 10,
226            },
227        })
228    }
229
230    fn compute_params_hash(&self, params: &SimulationParameters) -> String {
231        use std::collections::hash_map::DefaultHasher;
232        use std::hash::{Hash, Hasher};
233
234        let mut hasher = DefaultHasher::new();
235        params.entity_iri.hash(&mut hasher);
236        params.simulation_type.hash(&mut hasher);
237        hasher.finish().to_string()
238    }
239}
240
241/// Interpolate value at a given time from ODE solver output
242/// Uses linear interpolation between the nearest time points
243#[cfg(feature = "simulation")]
244fn interpolate_at_time(
245    times: &[f64],
246    values: &[scirs2_core::ndarray_ext::Array1<f64>],
247    target_time: f64,
248    index: usize,
249) -> f64 {
250    // Find the two closest time points
251    let mut i = 0;
252    while i < times.len() - 1 && times[i + 1] < target_time {
253        i += 1;
254    }
255
256    if i >= times.len() - 1 {
257        // Beyond the last point, return last value
258        return values[times.len() - 1][index];
259    }
260
261    let t0 = times[i];
262    let t1 = times[i + 1];
263
264    if (t1 - t0).abs() < 1e-12 {
265        return values[i][index];
266    }
267
268    // Linear interpolation
269    let alpha = (target_time - t0) / (t1 - t0);
270    let v0 = values[i][index];
271    let v1 = values[i + 1][index];
272
273    v0 + alpha * (v1 - v0)
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    #[tokio::test]
281    async fn test_scirs2_thermal_simulation() {
282        let sim = SciRS2ThermalSimulation::default();
283
284        let mut initial_conditions = HashMap::new();
285        initial_conditions.insert(
286            "temperature".to_string(),
287            super::super::parameter_extraction::PhysicalQuantity {
288                value: 300.0,
289                unit: "K".to_string(),
290                uncertainty: None,
291            },
292        );
293
294        let params = SimulationParameters {
295            entity_iri: "urn:example:battery:thermal".to_string(),
296            simulation_type: "thermal".to_string(),
297            initial_conditions,
298            boundary_conditions: Vec::new(),
299            time_span: (0.0, 100.0),
300            time_steps: 20,
301            material_properties: HashMap::new(),
302            constraints: Vec::new(),
303        };
304
305        let result = sim.run(&params).await.unwrap();
306
307        assert_eq!(result.state_trajectory.len(), 20);
308        assert!(result.convergence_info.converged);
309        assert!(sim.validate_results(&result).is_ok());
310    }
311}