oxirs_physics/simulation/
scirs2_thermal.rs1use 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
16pub struct SciRS2ThermalSimulation {
18 #[cfg_attr(not(feature = "simulation"), allow(dead_code))]
20 conductivity: f64,
21
22 #[cfg_attr(not(feature = "simulation"), allow(dead_code))]
24 specific_heat: f64,
25
26 #[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, specific_heat: 4186.0, density: 1000.0, }
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 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 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 let initial_temp = params
104 .initial_conditions
105 .get("temperature")
106 .map(|q| q.value)
107 .unwrap_or(293.15); let n_points = 10;
111 let initial_state: Array1<f64> = Array1::from_elem(n_points, initial_temp);
112
113 let alpha = self.conductivity / (self.density * self.specific_heat);
115 let dx = 0.1; 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 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 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 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 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#[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 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 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 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(¶ms).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}