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