fusion_core/
memory_transport.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later | Commercial license available
2// © Concepts 1996–2026 Miroslav Šotek. All rights reserved.
3// © Code 2020–2026 Miroslav Šotek. All rights reserved.
4// ORCID: 0009-0009-3560-0851
5// Contact: www.anulum.li | protoscience@anulum.li
6// SCPN Fusion Core — Memory-Kernel Transport
7//! Phase-space memory-kernel transport model.
8//!
9//! Replaces instantaneous heat flux with a short-memory kernel:
10//!   q(rho, t) = - ∫ K(t - t') * chi(rho, t') * dT/drho(rho, t') dt'
11//! with exponential kernel K(t) = (1/tau_d) exp(-t/tau_d).
12
13use 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    /// Memory decay time [s].
35    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    /// Memory variable Q_mem = ∫ K * (chi * dT/drho) dt'
48    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    /// Advance the transport system by one step.
261    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        // q = -Q_mem
287        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}