fusion_core/
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//! 1.5D radial transport solver with Bohm/Gyro-Bohm turbulence model.
7//!
8//! Port of `integrated_transport_solver.py` lines 12-162.
9//! Solves heat and particle diffusion on radial grid ρ ∈ [0, 1].
10
11use crate::pedestal::{PedestalConfig, PedestalModel};
12use fusion_math::tridiag::thomas_solve;
13use fusion_types::error::{FusionError, FusionResult};
14use fusion_types::state::RadialProfiles;
15use ndarray::Array1;
16
17/// Number of radial grid points. Python line 21.
18const TRANSPORT_NR: usize = 50;
19
20/// Default base thermal diffusivity [m²/s] when neoclassical is not configured.
21const CHI_BASE_DEFAULT: f64 = 0.5;
22
23/// Floor for neoclassical chi [m²/s] to prevent unphysical values.
24const CHI_NC_FLOOR: f64 = 0.01;
25
26/// Boltzmann constant in J/keV for neoclassical calculations.
27const NC_BOLTZMANN_J_PER_KEV: f64 = 1.602_176_634e-16;
28/// Elementary charge [C].
29const NC_ELEM_CHARGE: f64 = 1.602_176_634e-19;
30/// Proton mass [kg].
31const NC_PROTON_MASS: f64 = 1.672_621_923_69e-27;
32
33/// Turbulent transport multiplier. Python line 80.
34const CHI_TURB: f64 = 5.0;
35
36/// Impurity diffusion coefficient [m²/s]. Python line 54.
37const D_IMPURITY: f64 = 1.0;
38
39/// Critical temperature gradient for turbulence onset [keV/m]. Python line 74.
40const CRIT_GRADIENT: f64 = 2.0;
41
42/// Minimum transport multiplier inside the pedestal transport barrier.
43const HMODE_CHI_MIN_FACTOR: f64 = 0.08;
44
45/// H-mode power threshold [MW]. Python line 83.
46const HMODE_POWER_THRESHOLD: f64 = 30.0;
47
48/// Edge boundary temperature [keV]. Python line 125.
49const EDGE_TEMPERATURE: f64 = 0.1;
50/// Stability cap for explicit-euler temperature updates [keV].
51const MAX_TEMPERATURE: f64 = 100.0;
52
53/// Radiation cooling coefficient. Python line 105.
54const COOLING_FACTOR: f64 = 5.0;
55
56/// Gaussian heating profile width. Python line 100.
57const HEATING_WIDTH: f64 = 0.1;
58
59/// Cap for low-order toroidal-mode coupling multiplier.
60const TOROIDAL_COUPLING_MAX_FACTOR: f64 = 3.0;
61
62/// Parameters for the Chang-Hinton (1982) neoclassical transport model.
63#[derive(Debug, Clone, PartialEq)]
64pub struct NeoclassicalParams {
65    /// Tokamak major radius R₀ [m].
66    pub r_major: f64,
67    /// Minor radius a [m].
68    pub a_minor: f64,
69    /// Toroidal magnetic field B₀ [T].
70    pub b_toroidal: f64,
71    /// Ion mass number (e.g. 2 for deuterium).
72    pub a_ion: f64,
73    /// Effective charge Z_eff.
74    pub z_eff: f64,
75    /// Safety factor profile q(ρ) at each radial grid point.
76    pub q_profile: Array1<f64>,
77}
78
79impl Default for NeoclassicalParams {
80    fn default() -> Self {
81        Self {
82            r_major: 6.2,
83            a_minor: 2.0,
84            b_toroidal: 5.3,
85            a_ion: 2.0,
86            z_eff: 1.5,
87            q_profile: Array1::linspace(1.0, 3.0, TRANSPORT_NR),
88        }
89    }
90}
91
92/// Chang-Hinton (1982) neoclassical ion thermal diffusivity [m²/s].
93///
94/// χ_i^NC = 0.66 (1 + 1.54 α) q² ε^{-3/2} ρ_i² ν_ii / (1 + 0.74 ν*^{2/3})
95///
96/// where ε = r/R is the local inverse aspect ratio, ρ_i is the ion gyroradius,
97/// and ν* = ν_ii qR / (ε^{3/2} v_ti) is the collisionality parameter.
98pub fn chang_hinton_chi(
99    rho: f64,
100    t_i_kev: f64,
101    n_e_19: f64,
102    q: f64,
103    params: &NeoclassicalParams,
104) -> f64 {
105    if rho <= 0.0 || rho > 1.0 || t_i_kev <= 0.0 || n_e_19 <= 0.0 || q <= 0.0 {
106        return CHI_NC_FLOOR;
107    }
108    let epsilon = (rho * params.a_minor) / params.r_major;
109    if epsilon <= 1e-6 {
110        return CHI_NC_FLOOR;
111    }
112
113    let t_i_j = t_i_kev * NC_BOLTZMANN_J_PER_KEV;
114    let m_i = params.a_ion * NC_PROTON_MASS;
115    let n_e = n_e_19 * 1e19;
116
117    // Ion thermal velocity: v_ti = sqrt(2 T_i / m_i)
118    let v_ti = (2.0 * t_i_j / m_i).sqrt();
119    if !v_ti.is_finite() || v_ti <= 0.0 {
120        return CHI_NC_FLOOR;
121    }
122
123    // Ion gyroradius: ρ_i = m_i v_ti / (Z_i e B)
124    let rho_i = m_i * v_ti / (NC_ELEM_CHARGE * params.b_toroidal);
125
126    // NRL Plasma Formulary: ln Λ = 17.7 + ln(T_keV/10) − 0.5 ln(n_e/1e20)
127    let ln_lambda =
128        (17.7 + (t_i_kev.max(0.01) / 10.0).ln() - 0.5 * (n_e / 1e20).ln()).clamp(10.0, 25.0);
129    let eps0 = 8.854_187_812_8e-12;
130    let e4 = NC_ELEM_CHARGE.powi(4);
131    let nu_ii = n_e * params.z_eff * params.z_eff * e4 * ln_lambda
132        / (12.0 * std::f64::consts::PI.powf(1.5) * eps0 * eps0 * m_i.sqrt() * t_i_j.powf(1.5));
133
134    // Collisionality: ν* = ν_ii q R₀ / (ε^{3/2} v_ti)
135    let eps32 = epsilon.powf(1.5);
136    let nu_star = nu_ii * q * params.r_major / (eps32 * v_ti);
137
138    // α = (R/r) correction for Shafranov shift effects
139    let alpha = epsilon; // simplified
140
141    // Chang-Hinton formula
142    let chi = 0.66 * (1.0 + 1.54 * alpha) * q * q * rho_i * rho_i * nu_ii
143        / (eps32 * (1.0 + 0.74 * nu_star.powf(2.0 / 3.0)));
144
145    if chi.is_finite() && chi > 0.0 {
146        chi.max(CHI_NC_FLOOR)
147    } else {
148        CHI_NC_FLOOR
149    }
150}
151
152/// Vectorized Chang-Hinton ion thermal diffusivity over radial grid.
153pub fn chang_hinton_chi_profile(
154    rho: &Array1<f64>,
155    t_i_kev: &Array1<f64>,
156    n_e_19: &Array1<f64>,
157    q: &Array1<f64>,
158    params: &NeoclassicalParams,
159) -> Array1<f64> {
160    if rho.len() != t_i_kev.len() || rho.len() != n_e_19.len() || rho.len() != q.len() {
161        return Array1::from_elem(rho.len(), CHI_NC_FLOOR);
162    }
163    Array1::from_iter(
164        (0..rho.len()).map(|i| chang_hinton_chi(rho[i], t_i_kev[i], n_e_19[i], q[i], params)),
165    )
166}
167
168/// Vectorized gyro-Bohm diffusivity over radial grid.
169pub fn gyro_bohm_chi_profile(
170    rho: &Array1<f64>,
171    t_i_kev: &Array1<f64>,
172    b_field: f64,
173    mass_amu: f64,
174    chi_gb_coeff: f64,
175) -> Array1<f64> {
176    let n = rho.len();
177    if n == 0 || t_i_kev.len() != n {
178        return Array1::zeros(n);
179    }
180    if !b_field.is_finite() || b_field.abs() < 1e-12 || !mass_amu.is_finite() || mass_amu <= 0.0 {
181        return Array1::from_elem(n, CHI_NC_FLOOR);
182    }
183    let b2 = b_field * b_field;
184    let mass_term = mass_amu.sqrt();
185    let coeff = chi_gb_coeff.abs().max(1e-9);
186    Array1::from_iter((0..n).map(|i| {
187        let t = t_i_kev[i].max(0.01);
188        let r = rho[i].clamp(0.0, 1.0);
189        let chi = coeff * (t.powf(1.5) / b2) * (1.0 + r) / mass_term;
190        if chi.is_finite() && chi > 0.0 {
191            chi.max(CHI_NC_FLOOR)
192        } else {
193            CHI_NC_FLOOR
194        }
195    }))
196}
197
198/// Sauter bootstrap current density over radial grid.
199pub fn sauter_bootstrap_current_profile(
200    rho: &Array1<f64>,
201    t_e_kev: &Array1<f64>,
202    t_i_kev: &Array1<f64>,
203    n_e_19: &Array1<f64>,
204    q: &Array1<f64>,
205    epsilon: &Array1<f64>,
206    b_field: f64,
207) -> Array1<f64> {
208    let n = rho.len();
209    if n < 3
210        || t_e_kev.len() != n
211        || t_i_kev.len() != n
212        || n_e_19.len() != n
213        || q.len() != n
214        || epsilon.len() != n
215        || !b_field.is_finite()
216        || b_field <= 0.0
217    {
218        return Array1::zeros(n);
219    }
220
221    let e_charge = NC_ELEM_CHARGE;
222    let m_e = 9.109_383_70e-31;
223    let eps0: f64 = 8.854_187_812e-12;
224    let z_eff = 1.5;
225    let mut j_bs = Array1::zeros(n);
226
227    for i in 1..(n - 1) {
228        let eps = epsilon[i];
229        let te = t_e_kev[i];
230        let ti = t_i_kev[i];
231        let ne19 = n_e_19[i];
232        let qi = q[i];
233        if eps < 1e-6 || te <= 0.0 || ti <= 0.0 || ne19 <= 0.0 || qi <= 0.0 {
234            continue;
235        }
236
237        let eps_sq = (eps * eps).clamp(0.0, 0.999_999);
238        let trapped_denom = (1.0 - eps_sq).sqrt() * (1.0 + 1.46 * eps.sqrt());
239        if trapped_denom <= 1e-12 {
240            continue;
241        }
242        let mut f_t = 1.0 - (1.0 - eps).powi(2) / trapped_denom;
243        f_t = f_t.clamp(0.0, 1.0);
244
245        let t_e_j = te * 1e3 * e_charge;
246        if !t_e_j.is_finite() || t_e_j <= 0.0 {
247            continue;
248        }
249        let v_te = (2.0 * t_e_j / m_e).sqrt();
250        if !v_te.is_finite() || v_te <= 0.0 {
251            continue;
252        }
253
254        let n_e = ne19 * 1e19;
255        // NRL Plasma Formulary: ln Λ = 17.7 + ln(T_keV/10) − 0.5 ln(n_e/1e20)
256        let ln_lambda =
257            (17.7 + (te.max(0.01) / 10.0).ln() - 0.5 * (n_e / 1e20).ln()).clamp(10.0, 25.0);
258        let nu_ei = n_e * z_eff * e_charge.powi(4) * ln_lambda
259            / (12.0 * std::f64::consts::PI.powf(1.5) * eps0.powi(2) * m_e.sqrt() * t_e_j.powf(1.5));
260        if !nu_ei.is_finite() || nu_ei <= 0.0 {
261            continue;
262        }
263        let nu_star_e = nu_ei * qi / (eps.powf(1.5).max(1e-9) * v_te);
264        let alpha_31 = 1.0 / (1.0 + 0.36 / z_eff);
265        let l31 = f_t * alpha_31
266            / (1.0 + alpha_31 * nu_star_e.sqrt() + 0.25 * nu_star_e * (1.0 - f_t).powi(2));
267        let mut l32 = f_t * (0.05 + 0.62 * z_eff) / (z_eff * (1.0 + 0.44 * z_eff));
268        l32 /= 1.0 + 0.22 * nu_star_e.sqrt() + 0.19 * nu_star_e * (1.0 - f_t);
269        let l34 = l31 * ti / te.max(0.01);
270
271        let dr = (rho[i + 1] - rho[i - 1]).abs().max(1e-12);
272        let dn_dr = (n_e_19[i + 1] - n_e_19[i - 1]) * 1e19 / dr;
273        let dte_dr = (t_e_kev[i + 1] - t_e_kev[i - 1]) * 1e3 * e_charge / dr;
274        let dti_dr = (t_i_kev[i + 1] - t_i_kev[i - 1]) * 1e3 * e_charge / dr;
275
276        let b_pol = (b_field * eps / qi.max(0.1)).max(1e-10);
277        let p_e = n_e * t_e_j;
278        let denom_ti = (ti * 1e3 * e_charge).max(1e-30);
279        let j_val = -(p_e / b_pol)
280            * (l31 * dn_dr / n_e.max(1e10)
281                + l32 * dte_dr / t_e_j.max(1e-30)
282                + l34 * dti_dr / denom_ti);
283        if j_val.is_finite() {
284            j_bs[i] = j_val;
285        }
286    }
287    j_bs
288}
289
290/// Build Crank-Nicolson tridiagonal coefficients for 1D diffusion.
291///
292/// Returns `(a, b, c, d)` where `a/b/c` are sub/main/super diagonals for
293/// `fusion_math::tridiag::thomas_solve` and `d` is a zero-initialized RHS
294/// template that callers may fill with source terms.
295pub fn build_cn_tridiag(
296    chi: &Array1<f64>,
297    rho: &Array1<f64>,
298    dt: f64,
299) -> (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>) {
300    let n = rho.len();
301    let mut a = vec![0.0; n];
302    let mut b = vec![1.0; n];
303    let mut c = vec![0.0; n];
304    let mut d = vec![0.0; n];
305    if n < 3 || chi.len() != n || !dt.is_finite() || dt <= 0.0 {
306        return (a, b, c, d);
307    }
308
309    let dr = (rho[1] - rho[0]).abs().max(1e-12);
310    for i in 1..(n - 1) {
311        let r = rho[i].abs().max(1e-6);
312        let chi_i = chi[i].max(0.0);
313        let chi_ip = 0.5 * (chi_i + chi[i + 1].max(0.0));
314        let chi_im = 0.5 * (chi_i + chi[i - 1].max(0.0));
315        let r_ip = r + 0.5 * dr;
316        let r_im = (r - 0.5 * dr).max(1e-6);
317        let coeff_ip = chi_ip * r_ip / (r * dr * dr);
318        let coeff_im = chi_im * r_im / (r * dr * dr);
319
320        b[i] = 1.0 + 0.5 * dt * (coeff_ip + coeff_im);
321        c[i] = -0.5 * dt * coeff_ip;
322        a[i] = -0.5 * dt * coeff_im;
323    }
324    d[0] = EDGE_TEMPERATURE;
325    d[n - 1] = EDGE_TEMPERATURE;
326    (a, b, c, d)
327}
328
329/// Single transport timestep helper with explicit dt control.
330pub fn transport_step(solver: &mut TransportSolver, p_aux_mw: f64, dt: f64) -> FusionResult<()> {
331    if !p_aux_mw.is_finite() || p_aux_mw < 0.0 {
332        return Err(FusionError::ConfigError(format!(
333            "transport step requires finite p_aux_mw >= 0, got {p_aux_mw}"
334        )));
335    }
336    if !dt.is_finite() || dt <= 0.0 {
337        return Err(FusionError::ConfigError(format!(
338            "transport step requires finite dt > 0, got {dt}"
339        )));
340    }
341
342    let dt_prev = solver.dt;
343    solver.dt = dt;
344    let step_result = (|| -> FusionResult<()> {
345        solver.update_transport_model(p_aux_mw)?;
346        let te_before = solver.profiles.te.clone();
347        solver.evolve_profiles(p_aux_mw)?;
348
349        // CN smoothing pass using current diffusivity for added robustness.
350        let (a, b, c, mut d) = build_cn_tridiag(&solver.chi, &solver.profiles.rho, dt);
351        let n = solver.profiles.te.len();
352        if n >= 3 {
353            for (i, d_i) in d.iter_mut().enumerate().skip(1).take(n - 2) {
354                *d_i = solver.profiles.te[i];
355            }
356            let solved = thomas_solve(&a, &b, &c, &d);
357            for i in 1..(n - 1) {
358                let val = solved[i];
359                solver.profiles.te[i] = if val.is_finite() {
360                    val.clamp(EDGE_TEMPERATURE, MAX_TEMPERATURE)
361                } else {
362                    te_before[i]
363                };
364                solver.profiles.ti[i] = solver.profiles.te[i];
365            }
366            solver.profiles.te[n - 1] = EDGE_TEMPERATURE;
367            solver.profiles.ti[n - 1] = EDGE_TEMPERATURE;
368        }
369
370        if solver.profiles.te.iter().any(|v| !v.is_finite())
371            || solver.profiles.ti.iter().any(|v| !v.is_finite())
372            || solver.profiles.ne.iter().any(|v| !v.is_finite())
373        {
374            return Err(FusionError::ConfigError(
375                "transport step produced non-finite profile outputs".to_string(),
376            ));
377        }
378        Ok(())
379    })();
380    solver.dt = dt_prev;
381    step_result
382}
383
384/// 1.5D radial transport solver.
385pub struct TransportSolver {
386    pub profiles: RadialProfiles,
387    pub chi: Array1<f64>,
388    pub dt: f64,
389    pub pedestal: PedestalModel,
390    pub toroidal_mode_amplitudes: Vec<f64>,
391    pub toroidal_coupling_gain: f64,
392    /// Optional neoclassical transport model (replaces constant CHI_BASE).
393    pub neoclassical: Option<NeoclassicalParams>,
394}
395
396impl TransportSolver {
397    /// Create a new transport solver with default ITER-like profiles.
398    pub fn new() -> Self {
399        let rho = Array1::linspace(0.0, 1.0, TRANSPORT_NR);
400
401        // Initial parabolic profiles
402        let te = Array1::from_shape_fn(TRANSPORT_NR, |i| {
403            let r: f64 = rho[i];
404            10.0 * (1.0 - r * r).max(0.0) + EDGE_TEMPERATURE
405        });
406        let ti = te.clone();
407        let ne = Array1::from_shape_fn(TRANSPORT_NR, |i| {
408            let r: f64 = rho[i];
409            10.0 * (1.0 - r * r).max(0.0) + 0.5
410        });
411        let n_impurity = Array1::zeros(TRANSPORT_NR);
412        let chi = Array1::from_elem(TRANSPORT_NR, CHI_BASE_DEFAULT);
413        let pedestal = PedestalModel::new(PedestalConfig {
414            beta_p_ped: 0.35,
415            rho_s: 2.0e-3,
416            r_major: 6.2,
417            alpha_crit: 2.5,
418            tau_elm: 1.0e-3,
419        })
420        .expect("default pedestal config must be valid");
421
422        TransportSolver {
423            profiles: RadialProfiles {
424                rho,
425                te,
426                ti,
427                ne,
428                n_impurity,
429            },
430            chi,
431            dt: 0.01,
432            pedestal,
433            toroidal_mode_amplitudes: vec![0.0; 3],
434            toroidal_coupling_gain: 0.0,
435            neoclassical: None,
436        }
437    }
438
439    /// Configure the neoclassical transport model (Chang-Hinton 1982).
440    ///
441    /// When set, replaces the constant CHI_BASE with q-profile-dependent neoclassical χ_i.
442    pub fn set_neoclassical(&mut self, params: NeoclassicalParams) -> FusionResult<()> {
443        if !params.r_major.is_finite() || params.r_major <= 0.0 {
444            return Err(FusionError::PhysicsViolation(
445                "neoclassical r_major must be finite and > 0".into(),
446            ));
447        }
448        if !params.a_minor.is_finite() || params.a_minor <= 0.0 {
449            return Err(FusionError::PhysicsViolation(
450                "neoclassical a_minor must be finite and > 0".into(),
451            ));
452        }
453        if !params.b_toroidal.is_finite() || params.b_toroidal <= 0.0 {
454            return Err(FusionError::PhysicsViolation(
455                "neoclassical b_toroidal must be finite and > 0".into(),
456            ));
457        }
458        if !params.a_ion.is_finite() || params.a_ion <= 0.0 {
459            return Err(FusionError::PhysicsViolation(
460                "neoclassical a_ion must be finite and > 0".into(),
461            ));
462        }
463        if !params.z_eff.is_finite() || params.z_eff < 1.0 {
464            return Err(FusionError::PhysicsViolation(
465                "neoclassical z_eff must be finite and >= 1".into(),
466            ));
467        }
468        if params.q_profile.len() != self.profiles.rho.len() {
469            return Err(FusionError::ConfigError(format!(
470                "neoclassical q_profile length {} must match radial grid {}",
471                params.q_profile.len(),
472                self.profiles.rho.len()
473            )));
474        }
475        if params.q_profile.iter().any(|v| !v.is_finite() || *v <= 0.0) {
476            return Err(FusionError::PhysicsViolation(
477                "neoclassical q_profile values must be finite and > 0".into(),
478            ));
479        }
480        self.neoclassical = Some(params);
481        Ok(())
482    }
483
484    /// Configure low-order toroidal mode spectrum `n=1..N` for reduced transport closure.
485    ///
486    /// The solver remains 1.5D; this adds a radial transport multiplier using spectral
487    /// energy from low-order `n != 0` modes to mimic toroidal asymmetry effects.
488    pub fn set_toroidal_mode_spectrum(
489        &mut self,
490        amplitudes: &[f64],
491        gain: f64,
492    ) -> FusionResult<()> {
493        if !gain.is_finite() || gain < 0.0 {
494            return Err(FusionError::PhysicsViolation(
495                "toroidal coupling gain must be finite and >= 0".to_string(),
496            ));
497        }
498        if amplitudes.iter().any(|a| !a.is_finite() || *a < 0.0) {
499            return Err(FusionError::PhysicsViolation(
500                "toroidal mode amplitudes must be finite and >= 0".to_string(),
501            ));
502        }
503        self.toroidal_mode_amplitudes.clear();
504        self.toroidal_mode_amplitudes
505            .extend(amplitudes.iter().copied());
506        self.toroidal_coupling_gain = gain;
507        Ok(())
508    }
509
510    fn toroidal_mode_coupling_factor(&self, rho: f64) -> f64 {
511        if self.toroidal_coupling_gain <= 0.0 || self.toroidal_mode_amplitudes.is_empty() {
512            return 1.0;
513        }
514
515        // Weight higher-n modes by n to reflect stronger short-scale asymmetry drive.
516        let spectral_rms = self
517            .toroidal_mode_amplitudes
518            .iter()
519            .enumerate()
520            .map(|(idx, amp)| {
521                let n = (idx + 1) as f64;
522                (n * amp).powi(2)
523            })
524            .sum::<f64>()
525            .sqrt();
526        if spectral_rms <= 1e-12 {
527            return 1.0;
528        }
529
530        // Edge-weighted envelope keeps core near baseline while allowing edge asymmetry.
531        let envelope = rho.clamp(0.0, 1.0).powi(2);
532        (1.0 + self.toroidal_coupling_gain * envelope * spectral_rms)
533            .clamp(1.0, TOROIDAL_COUPLING_MAX_FACTOR)
534    }
535
536    /// Update thermal diffusivity using Bohm/Gyro-Bohm + EPED-like pedestal.
537    ///
538    /// χ = χ_base + χ_turb · max(0, |∇T| - threshold)
539    /// Pedestal barrier starts at ρ = 1 - Δ_ped and suppresses edge transport in H-mode.
540    pub fn update_transport_model(&mut self, p_aux_mw: f64) -> FusionResult<()> {
541        if !p_aux_mw.is_finite() || p_aux_mw < 0.0 {
542            return Err(FusionError::ConfigError(format!(
543                "transport update requires finite p_aux_mw >= 0, got {p_aux_mw}"
544            )));
545        }
546        let n = self.profiles.rho.len();
547        if n < 2 {
548            return Err(FusionError::ConfigError(
549                "transport update requires at least 2 radial points".to_string(),
550            ));
551        }
552        let dr = if n > 1 { 1.0 / (n as f64 - 1.0) } else { 1.0 };
553        if !dr.is_finite() || dr <= 0.0 {
554            return Err(FusionError::ConfigError(format!(
555                "transport update produced invalid dr={dr}"
556            )));
557        }
558        let ped_width = self.pedestal.pedestal_width();
559        if !ped_width.is_finite() || ped_width <= 0.0 {
560            return Err(FusionError::ConfigError(format!(
561                "transport pedestal width must be finite and > 0, got {ped_width}"
562            )));
563        }
564        let barrier_rho = (1.0 - ped_width).clamp(0.75, 0.995);
565
566        for i in 0..n {
567            if !self.profiles.te[i].is_finite() || !self.profiles.rho[i].is_finite() {
568                return Err(FusionError::ConfigError(format!(
569                    "transport profile contains non-finite values at index {}",
570                    i
571                )));
572            }
573            // Compute local temperature gradient
574            let grad_t = if i == 0 {
575                (self.profiles.te[1] - self.profiles.te[0]) / dr
576            } else if i == n - 1 {
577                (self.profiles.te[n - 1] - self.profiles.te[n - 2]) / dr
578            } else {
579                (self.profiles.te[i + 1] - self.profiles.te[i - 1]) / (2.0 * dr)
580            };
581            if !grad_t.is_finite() {
582                return Err(FusionError::ConfigError(format!(
583                    "transport gradient became non-finite at index {}",
584                    i
585                )));
586            }
587
588            // Bohm/Gyro-Bohm transport
589            let excess = (-grad_t - CRIT_GRADIENT).max(0.0);
590            let chi_base = if let Some(ref nc) = self.neoclassical {
591                let rho_val = self.profiles.rho[i];
592                let t_i = self.profiles.ti[i].max(0.01);
593                let n_e_19 = self.profiles.ne[i].max(0.01);
594                let q = nc.q_profile[i];
595                chang_hinton_chi(rho_val, t_i, n_e_19, q, nc)
596            } else {
597                CHI_BASE_DEFAULT
598            };
599            self.chi[i] = chi_base + CHI_TURB * excess;
600
601            // Reduced toroidal coupling closure from low-order n!=0 mode spectrum.
602            self.chi[i] *= self.toroidal_mode_coupling_factor(self.profiles.rho[i]);
603
604            // EPED-like H-mode pedestal suppression
605            if p_aux_mw > HMODE_POWER_THRESHOLD && self.profiles.rho[i] >= barrier_rho {
606                let edge_weight =
607                    ((self.profiles.rho[i] - barrier_rho) / ped_width.max(1e-5)).clamp(0.0, 1.0);
608                let factor = (1.0 - 0.92 * edge_weight).clamp(HMODE_CHI_MIN_FACTOR, 1.0);
609                self.chi[i] *= factor;
610            }
611            if !self.chi[i].is_finite() {
612                return Err(FusionError::ConfigError(format!(
613                    "transport chi became non-finite at index {}",
614                    i
615                )));
616            }
617        }
618        Ok(())
619    }
620
621    fn compute_pedestal_pressure_gradient(&self) -> FusionResult<f64> {
622        let n = self.profiles.rho.len();
623        if n < 3 {
624            return Err(FusionError::ConfigError(
625                "transport pressure-gradient calculation requires at least 3 radial points"
626                    .to_string(),
627            ));
628        }
629
630        let dr = 1.0 / (n as f64 - 1.0);
631        let ped_width = self.pedestal.pedestal_width();
632        let barrier_rho = (1.0 - ped_width).clamp(0.75, 0.995);
633        let pressure =
634            &self.profiles.ne * (&self.profiles.te + &self.profiles.ti).mapv(|v| v.max(0.0));
635
636        let mut max_grad: f64 = 0.0;
637        for i in 1..n - 1 {
638            if self.profiles.rho[i] < barrier_rho {
639                continue;
640            }
641            let grad = (pressure[i + 1] - pressure[i - 1]) / (2.0 * dr);
642            max_grad = max_grad.max(grad.abs());
643        }
644        if !max_grad.is_finite() {
645            return Err(FusionError::ConfigError(
646                "transport pressure-gradient calculation produced non-finite result".to_string(),
647            ));
648        }
649        Ok(max_grad)
650    }
651
652    /// Evolve temperature profiles by one time step (explicit Euler).
653    ///
654    /// ∂T/∂t = (1/r)∂(r χ ∂T/∂r)/∂r + S_heat - S_rad
655    pub fn evolve_profiles(&mut self, p_aux_mw: f64) -> FusionResult<()> {
656        if !p_aux_mw.is_finite() || p_aux_mw < 0.0 {
657            return Err(FusionError::ConfigError(format!(
658                "transport evolve requires finite p_aux_mw >= 0, got {p_aux_mw}"
659            )));
660        }
661        let n = self.profiles.rho.len();
662        if n < 2 {
663            return Err(FusionError::ConfigError(
664                "transport evolve requires at least 2 radial points".to_string(),
665            ));
666        }
667        let dr = if n > 1 { 1.0 / (n as f64 - 1.0) } else { 1.0 };
668        let dt = self.dt;
669        if !dt.is_finite() || dt <= 0.0 {
670            return Err(FusionError::ConfigError(format!(
671                "transport evolve requires finite dt > 0, got {dt}"
672            )));
673        }
674
675        let te_old = self.profiles.te.clone();
676
677        for i in 1..n - 1 {
678            let rho_i = self.profiles.rho[i];
679            if !rho_i.is_finite() || rho_i <= 0.0 {
680                return Err(FusionError::ConfigError(format!(
681                    "transport evolve requires finite rho > 0 at interior index {}, got {}",
682                    i, rho_i
683                )));
684            }
685
686            // Diffusion: (1/r)∂(r χ ∂T/∂r)/∂r via central differences
687            let flux_plus =
688                0.5 * (self.chi[i] + self.chi[i + 1]) * (te_old[i + 1] - te_old[i]) / dr;
689            let flux_minus =
690                0.5 * (self.chi[i - 1] + self.chi[i]) * (te_old[i] - te_old[i - 1]) / dr;
691            let rho_plus = rho_i + 0.5 * dr;
692            let rho_minus = rho_i - 0.5 * dr;
693            if rho_plus <= 0.0 || rho_minus <= 0.0 {
694                return Err(FusionError::ConfigError(format!(
695                    "transport evolve requires positive rho +/- dr/2 at index {}",
696                    i
697                )));
698            }
699            let div_flux = (rho_plus * flux_plus - rho_minus * flux_minus) / (rho_i * dr);
700            if !div_flux.is_finite() {
701                return Err(FusionError::ConfigError(format!(
702                    "transport evolve produced non-finite div_flux at index {}",
703                    i
704                )));
705            }
706
707            // Heating source (Gaussian centered at axis)
708            let s_heat = p_aux_mw * (-self.profiles.rho[i].powi(2) / HEATING_WIDTH).exp();
709            if !s_heat.is_finite() {
710                return Err(FusionError::ConfigError(format!(
711                    "transport evolve produced non-finite heating source at index {}",
712                    i
713                )));
714            }
715
716            // Radiation sink
717            let s_rad = COOLING_FACTOR
718                * self.profiles.ne[i]
719                * self.profiles.n_impurity[i]
720                * te_old[i].abs().sqrt();
721            if !s_rad.is_finite() {
722                return Err(FusionError::ConfigError(format!(
723                    "transport evolve produced non-finite radiation sink at index {}",
724                    i
725                )));
726            }
727
728            // Euler step
729            let te_new = te_old[i] + dt * (div_flux + s_heat - s_rad);
730            if !te_new.is_finite() {
731                return Err(FusionError::ConfigError(format!(
732                    "transport evolve produced non-finite temperature at index {}",
733                    i
734                )));
735            }
736            self.profiles.te[i] = te_new.clamp(EDGE_TEMPERATURE, MAX_TEMPERATURE);
737        }
738
739        // Ti tracks Te (simplified)
740        for i in 1..n - 1 {
741            self.profiles.ti[i] = self.profiles.te[i];
742        }
743
744        // Boundary conditions
745        self.profiles.te[n - 1] = EDGE_TEMPERATURE;
746        self.profiles.ti[n - 1] = EDGE_TEMPERATURE;
747        if self.profiles.te.iter().any(|v| !v.is_finite())
748            || self.profiles.ti.iter().any(|v| !v.is_finite())
749        {
750            return Err(FusionError::ConfigError(
751                "transport evolve produced non-finite profile outputs".to_string(),
752            ));
753        }
754        Ok(())
755    }
756
757    /// Inject impurities (erosion model). Python lines 39-66.
758    pub fn inject_impurities(&mut self, erosion_rate: f64) -> FusionResult<()> {
759        if !erosion_rate.is_finite() || erosion_rate < 0.0 {
760            return Err(FusionError::ConfigError(format!(
761                "transport impurity injection requires finite erosion_rate >= 0, got {erosion_rate}"
762            )));
763        }
764        if !self.dt.is_finite() || self.dt <= 0.0 {
765            return Err(FusionError::ConfigError(format!(
766                "transport impurity injection requires finite dt > 0, got {}",
767                self.dt
768            )));
769        }
770        let n = self.profiles.rho.len();
771        let dr = if n > 1 { 1.0 / (n as f64 - 1.0) } else { 1.0 };
772
773        // Add impurity source at edge
774        if n > 1 {
775            self.profiles.n_impurity[n - 1] += erosion_rate * 1e-18 * self.dt;
776        }
777
778        // Diffuse inward (explicit Euler on impurity diffusion equation)
779        let n_imp_old = self.profiles.n_impurity.clone();
780        for i in 1..n - 1 {
781            let laplacian = (n_imp_old[i + 1] - 2.0 * n_imp_old[i] + n_imp_old[i - 1]) / (dr * dr);
782            self.profiles.n_impurity[i] =
783                (n_imp_old[i] + D_IMPURITY * self.dt * laplacian).max(0.0);
784        }
785        if self.profiles.n_impurity.iter().any(|v| !v.is_finite()) {
786            return Err(FusionError::ConfigError(
787                "transport impurity injection produced non-finite values".to_string(),
788            ));
789        }
790        Ok(())
791    }
792
793    /// Full transport step: update model, evolve, pedestal crash, inject.
794    pub fn step(&mut self, p_aux_mw: f64, erosion_rate: f64) -> FusionResult<()> {
795        if !p_aux_mw.is_finite() || p_aux_mw < 0.0 {
796            return Err(FusionError::ConfigError(format!(
797                "transport step requires finite p_aux_mw >= 0, got {p_aux_mw}"
798            )));
799        }
800        if !erosion_rate.is_finite() || erosion_rate < 0.0 {
801            return Err(FusionError::ConfigError(format!(
802                "transport step requires finite erosion_rate >= 0, got {erosion_rate}"
803            )));
804        }
805        self.pedestal.advance(self.dt);
806        self.update_transport_model(p_aux_mw)?;
807        self.evolve_profiles(p_aux_mw)?;
808
809        if p_aux_mw > HMODE_POWER_THRESHOLD {
810            let grad_p = self.compute_pedestal_pressure_gradient()?;
811            self.pedestal.record_gradient(grad_p);
812            if self.pedestal.is_elm_triggered(grad_p) {
813                self.pedestal.apply_elm_crash(&mut self.profiles);
814            }
815        }
816
817        // Keep strict edge boundary after any pedestal crash adjustment.
818        let n = self.profiles.te.len();
819        if n > 0 {
820            self.profiles.te[n - 1] = EDGE_TEMPERATURE;
821            self.profiles.ti[n - 1] = EDGE_TEMPERATURE;
822        }
823
824        self.inject_impurities(erosion_rate)?;
825        if self.profiles.te.iter().any(|v| !v.is_finite())
826            || self.profiles.ti.iter().any(|v| !v.is_finite())
827            || self.profiles.n_impurity.iter().any(|v| !v.is_finite())
828            || self.chi.iter().any(|v| !v.is_finite())
829        {
830            return Err(FusionError::ConfigError(
831                "transport step produced non-finite runtime state".to_string(),
832            ));
833        }
834        Ok(())
835    }
836}
837
838impl Default for TransportSolver {
839    fn default() -> Self {
840        Self::new()
841    }
842}
843
844#[cfg(test)]
845mod tests {
846    use super::*;
847
848    #[test]
849    fn test_transport_creation() {
850        let ts = TransportSolver::new();
851        assert_eq!(ts.profiles.rho.len(), 50);
852        assert!(ts.profiles.te[0] > ts.profiles.te[49]);
853    }
854
855    #[test]
856    fn test_transport_step_no_panic() {
857        let mut ts = TransportSolver::new();
858        ts.step(50.0, 1e14).expect("valid transport step");
859        // Should not panic and profiles should remain finite
860        assert!(ts.profiles.te.iter().all(|v| v.is_finite()));
861        assert!(ts.profiles.ti.iter().all(|v| v.is_finite()));
862    }
863
864    #[test]
865    fn test_hmode_barrier() {
866        let mut ts = TransportSolver::new();
867
868        // Without H-mode
869        ts.update_transport_model(10.0)
870            .expect("valid transport-model update");
871        let chi_edge_no_hmode = ts.chi[48]; // near ρ=0.96
872
873        // With H-mode
874        ts.update_transport_model(50.0)
875            .expect("valid transport-model update");
876        let chi_edge_hmode = ts.chi[48];
877
878        assert!(
879            chi_edge_hmode < chi_edge_no_hmode,
880            "H-mode should reduce edge chi: {} vs {}",
881            chi_edge_hmode,
882            chi_edge_no_hmode
883        );
884    }
885
886    #[test]
887    fn test_toroidal_mode_coupling_disabled_by_default() {
888        let mut baseline = TransportSolver::new();
889        baseline
890            .update_transport_model(20.0)
891            .expect("valid transport-model update");
892        let baseline_chi = baseline.chi.clone();
893
894        let mut coupled = TransportSolver::new();
895        coupled
896            .set_toroidal_mode_spectrum(&[0.2, 0.1, 0.05], 0.0)
897            .expect("valid spectrum");
898        coupled
899            .update_transport_model(20.0)
900            .expect("valid transport-model update");
901
902        let max_err = baseline_chi
903            .iter()
904            .zip(coupled.chi.iter())
905            .map(|(a, b)| (a - b).abs())
906            .fold(0.0, f64::max);
907        assert!(
908            max_err < 1e-12,
909            "Expected baseline parity when toroidal coupling gain is zero, max_err={max_err}"
910        );
911    }
912
913    #[test]
914    fn test_toroidal_mode_coupling_boosts_edge_more_than_core() {
915        let mut baseline = TransportSolver::new();
916        baseline
917            .update_transport_model(20.0)
918            .expect("valid transport-model update");
919        let base_edge = baseline.chi[48];
920        let base_core = baseline.chi[4];
921
922        let mut coupled = TransportSolver::new();
923        coupled
924            .set_toroidal_mode_spectrum(&[0.2, 0.1, 0.04], 0.7)
925            .expect("valid spectrum");
926        coupled
927            .update_transport_model(20.0)
928            .expect("valid transport-model update");
929        let edge_gain = coupled.chi[48] / base_edge.max(1e-12);
930        let core_gain = coupled.chi[4] / base_core.max(1e-12);
931
932        assert!(
933            edge_gain > 1.0,
934            "Expected edge transport gain > 1 from toroidal coupling, got {edge_gain}"
935        );
936        assert!(
937            edge_gain > core_gain,
938            "Expected stronger edge gain than core gain: edge={edge_gain}, core={core_gain}"
939        );
940    }
941
942    #[test]
943    fn test_toroidal_mode_coupling_factor_is_clamped() {
944        let mut ts = TransportSolver::new();
945        ts.set_toroidal_mode_spectrum(&[10.0, 10.0, 10.0], 10.0)
946            .expect("valid spectrum");
947        let factor = ts.toroidal_mode_coupling_factor(1.0);
948        assert!(
949            (factor - TOROIDAL_COUPLING_MAX_FACTOR).abs() < 1e-12,
950            "Expected clamped factor={}, got {}",
951            TOROIDAL_COUPLING_MAX_FACTOR,
952            factor
953        );
954    }
955
956    #[test]
957    fn test_toroidal_mode_spectrum_rejects_invalid_inputs() {
958        let mut ts = TransportSolver::new();
959        for bad_gain in [f64::NAN, -0.1] {
960            let err = ts
961                .set_toroidal_mode_spectrum(&[0.1, 0.2], bad_gain)
962                .expect_err("invalid gain must error");
963            match err {
964                FusionError::PhysicsViolation(msg) => {
965                    assert!(msg.contains("gain"));
966                }
967                other => panic!("Unexpected error: {other:?}"),
968            }
969        }
970        for bad_amp in [f64::NAN, -0.2] {
971            let err = ts
972                .set_toroidal_mode_spectrum(&[0.1, bad_amp], 0.3)
973                .expect_err("invalid amplitude must error");
974            match err {
975                FusionError::PhysicsViolation(msg) => {
976                    assert!(msg.contains("amplitudes"));
977                }
978                other => panic!("Unexpected error: {other:?}"),
979            }
980        }
981    }
982
983    #[test]
984    fn test_transport_edge_boundary() {
985        let mut ts = TransportSolver::new();
986        for _ in 0..10 {
987            ts.step(50.0, 0.0).expect("valid transport step");
988        }
989        // Edge should stay at boundary temperature
990        let last = ts.profiles.te.len() - 1;
991        assert!(
992            (ts.profiles.te[last] - EDGE_TEMPERATURE).abs() < 1e-10,
993            "Edge temperature should be fixed at {EDGE_TEMPERATURE}"
994        );
995    }
996
997    #[test]
998    fn test_elm_crash_reduces_pedestal_temperature() {
999        let mut ts = TransportSolver::new();
1000
1001        // Build a steep pedestal pressure gradient near the edge.
1002        for i in 0..ts.profiles.rho.len() {
1003            if ts.profiles.rho[i] < 0.9 {
1004                ts.profiles.te[i] = 2.0;
1005                ts.profiles.ti[i] = 2.0;
1006                ts.profiles.ne[i] = 6.0;
1007            } else {
1008                ts.profiles.te[i] = 9.0;
1009                ts.profiles.ti[i] = 9.0;
1010                ts.profiles.ne[i] = 9.5;
1011            }
1012        }
1013
1014        let edge_idx = ts.profiles.rho.len() - 2;
1015        let te_before = ts.profiles.te[edge_idx];
1016        ts.step(60.0, 0.0).expect("valid transport step");
1017        let te_after = ts.profiles.te[edge_idx];
1018
1019        assert!(
1020            te_after < te_before,
1021            "ELM crash should reduce pedestal Te: before={te_before}, after={te_after}"
1022        );
1023        assert!(ts.pedestal.last_gradient() > 0.0);
1024    }
1025
1026    #[test]
1027    fn test_transport_rejects_invalid_runtime_inputs() {
1028        let mut ts = TransportSolver::new();
1029        let err = ts
1030            .step(f64::NAN, 0.0)
1031            .expect_err("non-finite p_aux must fail");
1032        match err {
1033            FusionError::ConfigError(msg) => assert!(msg.contains("p_aux")),
1034            other => panic!("Unexpected error variant: {other:?}"),
1035        }
1036
1037        let err = ts
1038            .step(50.0, -1.0)
1039            .expect_err("negative erosion_rate must fail");
1040        match err {
1041            FusionError::ConfigError(msg) => assert!(msg.contains("erosion_rate")),
1042            other => panic!("Unexpected error variant: {other:?}"),
1043        }
1044
1045        ts.dt = 0.0;
1046        let err = ts
1047            .evolve_profiles(20.0)
1048            .expect_err("non-positive dt must fail");
1049        match err {
1050            FusionError::ConfigError(msg) => assert!(msg.contains("dt")),
1051            other => panic!("Unexpected error variant: {other:?}"),
1052        }
1053    }
1054
1055    // ═══════════════════════════════════════════════════════════════════
1056    // Neoclassical transport tests
1057    // ═══════════════════════════════════════════════════════════════════
1058
1059    fn iter_neoclassical_params(n: usize) -> NeoclassicalParams {
1060        let rho = Array1::linspace(0.0, 1.0, n);
1061        let q_profile = Array1::from_shape_fn(n, |i| {
1062            let r = rho[i];
1063            1.0 + 2.0 * r * r // monotonic q-profile q(0)=1, q(1)=3
1064        });
1065        NeoclassicalParams {
1066            r_major: 6.2,
1067            a_minor: 2.0,
1068            b_toroidal: 5.3,
1069            a_ion: 2.0,
1070            z_eff: 1.5,
1071            q_profile,
1072        }
1073    }
1074
1075    #[test]
1076    fn test_neoclassical_chi_positive() {
1077        let nc = iter_neoclassical_params(50);
1078        for i in 1..50 {
1079            let rho = i as f64 / 49.0;
1080            let chi = chang_hinton_chi(rho, 10.0, 10.0, 1.0 + 2.0 * rho * rho, &nc);
1081            assert!(chi > 0.0, "chi must be > 0 at rho={rho}, got {chi}");
1082            assert!(chi.is_finite(), "chi must be finite at rho={rho}");
1083        }
1084    }
1085
1086    #[test]
1087    fn test_neoclassical_chi_increases_with_q() {
1088        let nc = iter_neoclassical_params(50);
1089        let chi_low_q = chang_hinton_chi(0.5, 10.0, 10.0, 1.0, &nc);
1090        let chi_high_q = chang_hinton_chi(0.5, 10.0, 10.0, 3.0, &nc);
1091        assert!(
1092            chi_high_q > chi_low_q,
1093            "Higher q should give larger chi: q=1→{chi_low_q}, q=3→{chi_high_q}"
1094        );
1095    }
1096
1097    #[test]
1098    fn test_neoclassical_chi_floor_for_invalid() {
1099        let nc = iter_neoclassical_params(50);
1100        // rho=0 should return floor
1101        let chi = chang_hinton_chi(0.0, 10.0, 10.0, 1.0, &nc);
1102        assert!((chi - CHI_NC_FLOOR).abs() < 1e-12);
1103        // negative temperature
1104        let chi = chang_hinton_chi(0.5, -1.0, 10.0, 1.5, &nc);
1105        assert!((chi - CHI_NC_FLOOR).abs() < 1e-12);
1106    }
1107
1108    #[test]
1109    fn test_neoclassical_step_no_panic() {
1110        let mut ts = TransportSolver::new();
1111        let nc = iter_neoclassical_params(50);
1112        ts.set_neoclassical(nc).expect("valid neoclassical config");
1113        ts.step(50.0, 1e14)
1114            .expect("valid transport step with neoclassical");
1115        assert!(ts.profiles.te.iter().all(|v| v.is_finite()));
1116    }
1117
1118    #[test]
1119    fn test_neoclassical_differs_from_constant() {
1120        let mut ts_const = TransportSolver::new();
1121        ts_const.update_transport_model(20.0).unwrap();
1122        let chi_const = ts_const.chi.clone();
1123
1124        let mut ts_nc = TransportSolver::new();
1125        let nc = iter_neoclassical_params(50);
1126        ts_nc.set_neoclassical(nc).unwrap();
1127        ts_nc.update_transport_model(20.0).unwrap();
1128
1129        let max_diff = chi_const
1130            .iter()
1131            .zip(ts_nc.chi.iter())
1132            .map(|(a, b)| (a - b).abs())
1133            .fold(0.0f64, f64::max);
1134        assert!(
1135            max_diff > 0.0,
1136            "Neoclassical chi should differ from constant base"
1137        );
1138    }
1139
1140    #[test]
1141    fn test_neoclassical_rejects_invalid_params() {
1142        let mut ts = TransportSolver::new();
1143        let mut nc = iter_neoclassical_params(50);
1144        nc.r_major = -1.0;
1145        assert!(ts.set_neoclassical(nc).is_err());
1146
1147        let mut nc2 = iter_neoclassical_params(50);
1148        nc2.q_profile = Array1::zeros(10); // wrong length
1149        assert!(ts.set_neoclassical(nc2).is_err());
1150    }
1151
1152    #[test]
1153    fn test_chang_hinton_profile_vectorized_matches_scalar() {
1154        let n = 50;
1155        let rho = Array1::linspace(0.02, 1.0, n);
1156        let t_i = Array1::from_shape_fn(n, |i| {
1157            let r = rho[i];
1158            12.0_f64 * (1.0_f64 - r * r).max(0.05_f64)
1159        });
1160        let n_e = Array1::from_shape_fn(n, |i| 8.0 * (1.0 - 0.6 * rho[i] * rho[i]) + 0.5);
1161        let q = Array1::from_shape_fn(n, |i| 1.0 + 2.5 * rho[i] * rho[i]);
1162        let params = NeoclassicalParams {
1163            q_profile: q.clone(),
1164            ..NeoclassicalParams::default()
1165        };
1166
1167        let vectorized = chang_hinton_chi_profile(&rho, &t_i, &n_e, &q, &params);
1168        for i in 0..n {
1169            let scalar = chang_hinton_chi(rho[i], t_i[i], n_e[i], q[i], &params);
1170            assert!(
1171                (vectorized[i] - scalar).abs() < 1e-12,
1172                "vectorized and scalar mismatch at i={i}: vec={}, scalar={scalar}",
1173                vectorized[i]
1174            );
1175        }
1176    }
1177
1178    #[test]
1179    fn test_gyro_bohm_profile_positive_and_scaling() {
1180        let rho = Array1::linspace(0.02, 1.0, 32);
1181        let t_base = Array1::from_elem(32, 4.0);
1182        let t_hot = Array1::from_elem(32, 8.0);
1183
1184        let chi_base = gyro_bohm_chi_profile(&rho, &t_base, 5.0, 2.0, 0.1);
1185        let chi_hot = gyro_bohm_chi_profile(&rho, &t_hot, 5.0, 2.0, 0.1);
1186        let chi_high_b = gyro_bohm_chi_profile(&rho, &t_hot, 10.0, 2.0, 0.1);
1187
1188        assert!(chi_base.iter().all(|v| v.is_finite() && *v > 0.0));
1189        let ratio_t = chi_hot[10] / chi_base[10].max(1e-12);
1190        let ratio_b = chi_hot[10] / chi_high_b[10].max(1e-12);
1191
1192        assert!(
1193            ratio_t > 2.6,
1194            "Expected T^(3/2) scaling (>2.6 for 4->8 keV), got {ratio_t}"
1195        );
1196        assert!(
1197            ratio_b > 3.5,
1198            "Expected ~B^-2 scaling (>3.5 for 5T->10T), got {ratio_b}"
1199        );
1200    }
1201
1202    #[test]
1203    fn test_sauter_bootstrap_profile_is_mostly_positive() {
1204        let n = 60;
1205        let rho = Array1::linspace(0.01, 1.0, n);
1206        let t_i = Array1::from_shape_fn(n, |i| {
1207            let r = rho[i];
1208            12.0_f64 * (1.0_f64 - r * r).max(0.05_f64) + 0.5_f64
1209        });
1210        let t_e = Array1::from_shape_fn(n, |i| {
1211            let r = rho[i];
1212            10.0_f64 * (1.0_f64 - r * r).max(0.05_f64) + 0.3_f64
1213        });
1214        let n_e = Array1::from_shape_fn(n, |i| {
1215            let r = rho[i];
1216            9.0_f64 * (1.0_f64 - 0.7_f64 * r * r) + 1.0_f64
1217        });
1218        let q = Array1::from_shape_fn(n, |i| {
1219            let r = rho[i];
1220            1.0_f64 + 2.5_f64 * r * r
1221        });
1222        let epsilon = rho.mapv(|r| r * 0.32);
1223
1224        let j_bs = sauter_bootstrap_current_profile(&rho, &t_e, &t_i, &n_e, &q, &epsilon, 5.3);
1225        let positive_fraction =
1226            j_bs.iter().filter(|v| **v > 0.0).count() as f64 / j_bs.len() as f64;
1227        assert!(
1228            positive_fraction > 0.75,
1229            "Expected mostly positive bootstrap current, fraction={positive_fraction:.3}"
1230        );
1231    }
1232
1233    #[test]
1234    fn test_cn_tridiag_diagonal_dominance() {
1235        let n = 50;
1236        let rho = Array1::linspace(0.0, 1.0, n);
1237        let chi = Array1::from_elem(n, 0.5);
1238        let (a, b, c, _) = build_cn_tridiag(&chi, &rho, 0.01);
1239
1240        for i in 1..(n - 1) {
1241            let dominance = b[i].abs() - (a[i].abs() + c[i].abs());
1242            assert!(
1243                dominance > 0.0,
1244                "Expected strict diagonal dominance at i={i}: b={}, a={}, c={}",
1245                b[i],
1246                a[i],
1247                c[i]
1248            );
1249        }
1250    }
1251
1252    fn thermal_energy_proxy(ts: &TransportSolver) -> f64 {
1253        ts.profiles
1254            .rho
1255            .iter()
1256            .zip(ts.profiles.ne.iter())
1257            .zip(ts.profiles.ti.iter())
1258            .map(|((r, n_e), t_i)| r.max(0.0) * n_e.max(0.0) * t_i.max(0.0))
1259            .sum::<f64>()
1260    }
1261
1262    #[test]
1263    fn test_transport_step_conserves_energy_proxy() {
1264        let mut ts = TransportSolver::new();
1265        let p_aux = 20.0;
1266        let dt = 0.01;
1267        let before = thermal_energy_proxy(&ts);
1268        transport_step(&mut ts, p_aux, dt).expect("valid transport step");
1269        let after = thermal_energy_proxy(&ts);
1270
1271        let delta = (after - before).abs();
1272        // Transport update is numerically diffusive; allow generous source-scaled bound.
1273        let bound = p_aux * dt * 5e4;
1274        assert!(
1275            delta <= bound,
1276            "Energy proxy change too large: delta={delta}, bound={bound}"
1277        );
1278    }
1279}