1use fusion_types::error::{FusionError, FusionResult};
14use fusion_types::state::RadialProfiles;
15use ndarray::Array1;
16
17const TRANSPORT_NR: usize = 50;
18const CHI_BASE: f64 = 0.5;
19const CHI_TURB: f64 = 5.0;
20const CRIT_GRADIENT: f64 = 2.0;
21const HMODE_BARRIER_RHO: f64 = 0.9;
22const HMODE_CHI_REDUCTION: f64 = 0.1;
23const HMODE_POWER_THRESHOLD: f64 = 30.0;
24const EDGE_TEMPERATURE: f64 = 0.1;
25const HEATING_WIDTH: f64 = 0.1;
26const COOLING_FACTOR: f64 = 1.0;
27const MAX_MEMORY_DRIVE: f64 = 1.0e4;
28const MAX_DIV_FLUX: f64 = 1.0e5;
29const MAX_HEATING: f64 = 1.0e4;
30const MAX_TEMPERATURE: f64 = 100.0;
31
32#[derive(Debug, Clone, Copy)]
33pub struct MemoryKernelConfig {
34 pub tau_d: f64,
36}
37
38impl Default for MemoryKernelConfig {
39 fn default() -> Self {
40 Self { tau_d: 1e-3 }
41 }
42}
43
44pub struct MemoryTransportSolver {
45 pub profiles: RadialProfiles,
46 pub chi: Array1<f64>,
47 pub q_memory: Array1<f64>,
49 pub config: MemoryKernelConfig,
50}
51
52impl MemoryTransportSolver {
53 pub fn new(config: MemoryKernelConfig) -> FusionResult<Self> {
54 if !config.tau_d.is_finite() || config.tau_d < 0.0 {
55 return Err(FusionError::ConfigError(format!(
56 "memory transport tau_d must be finite and >= 0, got {}",
57 config.tau_d
58 )));
59 }
60
61 let rho = Array1::linspace(0.0, 1.0, TRANSPORT_NR);
62 let te = Array1::from_shape_fn(TRANSPORT_NR, |i| {
63 let r: f64 = rho[i];
64 6.0 * (1.0_f64 - r * r).max(0.0) + EDGE_TEMPERATURE
65 });
66 let ti = te.clone();
67 let ne = Array1::from_shape_fn(TRANSPORT_NR, |i| {
68 let r: f64 = rho[i];
69 10.0 * (1.0_f64 - r * r).max(0.0) + 0.5
70 });
71 let n_impurity = Array1::zeros(TRANSPORT_NR);
72 let chi = Array1::from_elem(TRANSPORT_NR, CHI_BASE);
73 let q_memory = Array1::zeros(TRANSPORT_NR);
74
75 let solver = Self {
76 profiles: RadialProfiles {
77 rho,
78 te,
79 ti,
80 ne,
81 n_impurity,
82 },
83 chi,
84 q_memory,
85 config,
86 };
87 if solver.profiles.te.iter().any(|v| !v.is_finite())
88 || solver.profiles.ti.iter().any(|v| !v.is_finite())
89 || solver.profiles.ne.iter().any(|v| !v.is_finite())
90 || solver.chi.iter().any(|v| !v.is_finite())
91 || solver.q_memory.iter().any(|v| !v.is_finite())
92 {
93 return Err(FusionError::ConfigError(
94 "memory transport initialization produced non-finite values".to_string(),
95 ));
96 }
97 Ok(solver)
98 }
99
100 fn gradient(values: &Array1<f64>, dr: f64) -> FusionResult<Array1<f64>> {
101 if values.len() < 2 {
102 return Err(FusionError::ConfigError(
103 "memory transport gradient requires at least 2 samples".to_string(),
104 ));
105 }
106 if !dr.is_finite() || dr <= 0.0 {
107 return Err(FusionError::ConfigError(format!(
108 "memory transport gradient requires finite dr > 0, got {dr}"
109 )));
110 }
111 if values.iter().any(|v| !v.is_finite()) {
112 return Err(FusionError::ConfigError(
113 "memory transport gradient input contains non-finite values".to_string(),
114 ));
115 }
116 let n = values.len();
117 let mut grad = Array1::zeros(n);
118 for i in 0..n {
119 grad[i] = if i == 0 {
120 (values[1] - values[0]) / dr
121 } else if i == n - 1 {
122 (values[n - 1] - values[n - 2]) / dr
123 } else {
124 (values[i + 1] - values[i - 1]) / (2.0 * dr)
125 };
126 }
127 if grad.iter().any(|v| !v.is_finite()) {
128 return Err(FusionError::ConfigError(
129 "memory transport gradient output contains non-finite values".to_string(),
130 ));
131 }
132 Ok(grad)
133 }
134
135 fn divergence_flux_cylindrical(
136 flux: &Array1<f64>,
137 rho: &Array1<f64>,
138 dr: f64,
139 ) -> FusionResult<Array1<f64>> {
140 if flux.len() != rho.len() || flux.len() < 2 {
141 return Err(FusionError::ConfigError(format!(
142 "memory transport divergence requires matching flux/rho lengths >= 2, got {} and {}",
143 flux.len(),
144 rho.len()
145 )));
146 }
147 if !dr.is_finite() || dr <= 0.0 {
148 return Err(FusionError::ConfigError(format!(
149 "memory transport divergence requires finite dr > 0, got {dr}"
150 )));
151 }
152 if flux.iter().any(|v| !v.is_finite()) || rho.iter().any(|v| !v.is_finite()) {
153 return Err(FusionError::ConfigError(
154 "memory transport divergence input contains non-finite values".to_string(),
155 ));
156 }
157 let n = flux.len();
158 let mut div = Array1::zeros(n);
159 for i in 1..n - 1 {
160 let rho_i = rho[i];
161 if rho_i <= 0.0 {
162 return Err(FusionError::ConfigError(format!(
163 "memory transport rho must be > 0 for interior points, got {} at index {}",
164 rho_i, i
165 )));
166 }
167 let rho_plus = rho_i + 0.5 * dr;
168 let rho_minus = rho_i - 0.5 * dr;
169 if rho_plus <= 0.0 || rho_minus <= 0.0 {
170 return Err(FusionError::ConfigError(format!(
171 "memory transport cylindrical stencil requires positive rho +/- dr/2 at index {}",
172 i
173 )));
174 }
175 let f_plus = 0.5 * (flux[i] + flux[i + 1]);
176 let f_minus = 0.5 * (flux[i - 1] + flux[i]);
177 div[i] = (rho_plus * f_plus - rho_minus * f_minus) / (rho_i * dr);
178 }
179 if div.iter().any(|v| !v.is_finite()) {
180 return Err(FusionError::ConfigError(
181 "memory transport divergence output contains non-finite values".to_string(),
182 ));
183 }
184 Ok(div)
185 }
186
187 fn update_transport_model(&mut self, p_aux_mw: f64) -> FusionResult<()> {
188 if !p_aux_mw.is_finite() || p_aux_mw < 0.0 {
189 return Err(FusionError::ConfigError(format!(
190 "memory transport auxiliary heating power must be finite and >= 0, got {p_aux_mw}"
191 )));
192 }
193 let n = self.profiles.rho.len();
194 let dr = if n > 1 { 1.0 / (n as f64 - 1.0) } else { 1.0 };
195 let grad_t = Self::gradient(&self.profiles.te, dr)?;
196
197 for i in 0..n {
198 let excess = (-grad_t[i] - CRIT_GRADIENT).max(0.0);
199 self.chi[i] = CHI_BASE + CHI_TURB * excess;
200
201 if p_aux_mw > HMODE_POWER_THRESHOLD && self.profiles.rho[i] > HMODE_BARRIER_RHO {
202 self.chi[i] *= HMODE_CHI_REDUCTION;
203 }
204 }
205 if self.chi.iter().any(|v| !v.is_finite()) {
206 return Err(FusionError::ConfigError(
207 "memory transport chi update produced non-finite values".to_string(),
208 ));
209 }
210 Ok(())
211 }
212
213 fn update_memory_from_drive(&mut self, drive: &Array1<f64>, dt: f64) -> FusionResult<()> {
214 if drive.len() != self.q_memory.len() {
215 return Err(FusionError::ConfigError(format!(
216 "memory transport drive length mismatch: expected {}, got {}",
217 self.q_memory.len(),
218 drive.len()
219 )));
220 }
221 if drive.iter().any(|v| !v.is_finite()) {
222 return Err(FusionError::ConfigError(
223 "memory transport drive contains non-finite values".to_string(),
224 ));
225 }
226 if !dt.is_finite() || dt <= 0.0 {
227 return Err(FusionError::ConfigError(format!(
228 "memory transport dt must be finite and > 0, got {dt}"
229 )));
230 }
231 if !self.config.tau_d.is_finite() || self.config.tau_d < 0.0 {
232 return Err(FusionError::ConfigError(format!(
233 "memory transport tau_d must remain finite and >= 0, got {}",
234 self.config.tau_d
235 )));
236 }
237 let drive = drive.mapv(|v| v.clamp(-MAX_MEMORY_DRIVE, MAX_MEMORY_DRIVE));
238 if self.config.tau_d <= 1e-9 {
239 self.q_memory.assign(&drive);
240 return Ok(());
241 }
242
243 let decay = (-dt / self.config.tau_d).exp();
244 let scale = dt / self.config.tau_d;
245 if !decay.is_finite() || !scale.is_finite() {
246 return Err(FusionError::ConfigError(
247 "memory transport decay coefficients became non-finite".to_string(),
248 ));
249 }
250 self.q_memory = (self.q_memory.mapv(|q| q * decay) + drive * scale)
251 .mapv(|v| v.clamp(-MAX_MEMORY_DRIVE, MAX_MEMORY_DRIVE));
252 if self.q_memory.iter().any(|v| !v.is_finite()) {
253 return Err(FusionError::ConfigError(
254 "memory transport memory state became non-finite".to_string(),
255 ));
256 }
257 Ok(())
258 }
259
260 pub fn step(&mut self, p_aux_mw: f64, dt: f64) -> FusionResult<()> {
262 if !p_aux_mw.is_finite() || p_aux_mw < 0.0 {
263 return Err(FusionError::ConfigError(format!(
264 "memory transport step requires finite p_aux_mw >= 0, got {p_aux_mw}"
265 )));
266 }
267 if !dt.is_finite() || dt <= 0.0 {
268 return Err(FusionError::ConfigError(format!(
269 "memory transport step requires finite dt > 0, got {dt}"
270 )));
271 }
272 let n = self.profiles.rho.len();
273 let dr = if n > 1 { 1.0 / (n as f64 - 1.0) } else { 1.0 };
274
275 self.update_transport_model(p_aux_mw)?;
276
277 let grad_t = Self::gradient(&self.profiles.te, dr)?;
278 let drive = &self.chi * &grad_t;
279 if drive.iter().any(|v| !v.is_finite()) {
280 return Err(FusionError::ConfigError(
281 "memory transport drive update became non-finite".to_string(),
282 ));
283 }
284 self.update_memory_from_drive(&drive, dt)?;
285
286 let flux = self.q_memory.mapv(|v| -v);
288 let div_flux = Self::divergence_flux_cylindrical(&flux, &self.profiles.rho, dr)?
289 .mapv(|v| v.clamp(-MAX_DIV_FLUX, MAX_DIV_FLUX));
290
291 let te_old = self.profiles.te.clone();
292 for i in 1..n - 1 {
293 let s_heat = (p_aux_mw * (-self.profiles.rho[i].powi(2) / HEATING_WIDTH).exp())
294 .clamp(0.0, MAX_HEATING);
295 let s_cool = COOLING_FACTOR * te_old[i].abs().sqrt() * self.profiles.n_impurity[i];
296 if !s_heat.is_finite() || !s_cool.is_finite() {
297 return Err(FusionError::ConfigError(format!(
298 "memory transport source terms became non-finite at index {}",
299 i
300 )));
301 }
302 let te_new = te_old[i] + dt * (div_flux[i] + s_heat - s_cool);
303 if !te_new.is_finite() {
304 return Err(FusionError::ConfigError(format!(
305 "memory transport temperature update became non-finite at index {}",
306 i
307 )));
308 }
309 self.profiles.te[i] = te_new.clamp(EDGE_TEMPERATURE, MAX_TEMPERATURE);
310 self.profiles.ti[i] = self.profiles.te[i];
311 }
312
313 self.profiles.te[n - 1] = EDGE_TEMPERATURE;
314 self.profiles.ti[n - 1] = EDGE_TEMPERATURE;
315 if self.profiles.te.iter().any(|v| !v.is_finite())
316 || self.profiles.ti.iter().any(|v| !v.is_finite())
317 || self.q_memory.iter().any(|v| !v.is_finite())
318 || self.chi.iter().any(|v| !v.is_finite())
319 {
320 return Err(FusionError::ConfigError(
321 "memory transport state contains non-finite values after step".to_string(),
322 ));
323 }
324 Ok(())
325 }
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331 use ndarray::Zip;
332
333 #[test]
334 fn test_zero_tau_d_matches_local() {
335 let mut solver =
336 MemoryTransportSolver::new(MemoryKernelConfig { tau_d: 0.0 }).expect("valid config");
337 let n = solver.profiles.rho.len();
338 let dr = 1.0 / (n as f64 - 1.0);
339 let grad_t =
340 MemoryTransportSolver::gradient(&solver.profiles.te, dr).expect("valid gradient input");
341 let drive = &solver.chi * &grad_t;
342
343 solver
344 .update_memory_from_drive(&drive, 1e-3)
345 .expect("valid memory update");
346
347 let max_err = solver
348 .q_memory
349 .iter()
350 .zip(drive.iter())
351 .map(|(a, b)| (a - b).abs())
352 .fold(0.0, f64::max);
353 assert!(
354 max_err < 1e-12,
355 "Expected local limit match, max_err={max_err}"
356 );
357 }
358
359 #[test]
360 fn test_memory_kernel_step_response() {
361 let tau_d = 2e-3;
362 let dt = 2e-4;
363 let expected_gain = dt / tau_d;
364 let mut solver =
365 MemoryTransportSolver::new(MemoryKernelConfig { tau_d }).expect("valid config");
366
367 let drive = Array1::from_elem(solver.q_memory.len(), 1.0);
368 solver
369 .update_memory_from_drive(&drive, dt)
370 .expect("valid memory update");
371 let first = solver.q_memory[10];
372
373 for _ in 0..20 {
374 solver
375 .update_memory_from_drive(&drive, dt)
376 .expect("valid memory update");
377 }
378 let later = solver.q_memory[10];
379
380 assert!(
381 first > 0.0 && first <= expected_gain + 1e-6,
382 "First response should be small and positive: first={first}, expected_gain={expected_gain}"
383 );
384 assert!(
385 later > first && later < 1.0 + 1e-6,
386 "Memory response should relax monotonically toward 1: first={first}, later={later}"
387 );
388 }
389
390 #[test]
391 fn test_memory_finite_after_1000_steps() {
392 let mut solver =
393 MemoryTransportSolver::new(MemoryKernelConfig { tau_d: 1e-3 }).expect("valid config");
394 for _ in 0..1000 {
395 solver.step(35.0, 1e-4).expect("valid transport step");
396 }
397
398 assert!(solver.profiles.te.iter().all(|v| v.is_finite()));
399 assert!(solver.profiles.ti.iter().all(|v| v.is_finite()));
400 assert!(solver.q_memory.iter().all(|v| v.is_finite()));
401 assert!(solver.chi.iter().all(|v| v.is_finite()));
402 assert!(solver.profiles.te.iter().all(|v| *v >= EDGE_TEMPERATURE));
403
404 let mut changed = false;
405 Zip::from(&solver.q_memory).for_each(|q| {
406 if q.abs() > 1e-12 {
407 changed = true;
408 }
409 });
410 assert!(changed, "Expected non-trivial memory state after long run");
411 }
412
413 #[test]
414 fn test_memory_transport_rejects_invalid_runtime_inputs() {
415 match MemoryTransportSolver::new(MemoryKernelConfig { tau_d: f64::NAN }) {
416 Err(FusionError::ConfigError(_)) => {}
417 Err(other) => panic!("unexpected error variant: {other:?}"),
418 Ok(_) => panic!("non-finite tau_d must fail"),
419 }
420
421 let mut solver =
422 MemoryTransportSolver::new(MemoryKernelConfig { tau_d: 1e-3 }).expect("valid config");
423 let err = solver
424 .step(35.0, 0.0)
425 .expect_err("non-positive dt must fail");
426 assert!(matches!(err, FusionError::ConfigError(_)));
427
428 let bad_drive = Array1::from_elem(solver.q_memory.len(), f64::INFINITY);
429 let err = solver
430 .update_memory_from_drive(&bad_drive, 1e-4)
431 .expect_err("non-finite drive must fail");
432 assert!(matches!(err, FusionError::ConfigError(_)));
433 }
434}