fusion_core/
memory_transport.rs

1// ─────────────────────────────────────────────────────────────────────
2// SCPN Fusion Core — Memory-Kernel Transport
3// © 1998–2026 Miroslav Šotek. All rights reserved.
4// Contact: www.anulum.li | protoscience@anulum.li
5// ORCID: https://orcid.org/0009-0009-3560-0851
6// License: GNU AGPL v3 | Commercial licensing available
7// ─────────────────────────────────────────────────────────────────────
8//! Phase-space memory-kernel transport model.
9//!
10//! Replaces instantaneous heat flux with a short-memory kernel:
11//!   q(rho, t) = - ∫ K(t - t') * chi(rho, t') * dT/drho(rho, t') dt'
12//! with exponential kernel K(t) = (1/tau_d) exp(-t/tau_d).
13
14use 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    /// Memory decay time [s].
36    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    /// Memory variable Q_mem = ∫ K * (chi * dT/drho) dt'
49    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    /// Advance the transport system by one step.
262    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        // q = -Q_mem
288        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}