1use 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
17const TRANSPORT_NR: usize = 50;
19
20const CHI_BASE_DEFAULT: f64 = 0.5;
22
23const CHI_NC_FLOOR: f64 = 0.01;
25
26const NC_BOLTZMANN_J_PER_KEV: f64 = 1.602_176_634e-16;
28const NC_ELEM_CHARGE: f64 = 1.602_176_634e-19;
30const NC_PROTON_MASS: f64 = 1.672_621_923_69e-27;
32
33const CHI_TURB: f64 = 5.0;
35
36const D_IMPURITY: f64 = 1.0;
38
39const CRIT_GRADIENT: f64 = 2.0;
41
42const HMODE_CHI_MIN_FACTOR: f64 = 0.08;
44
45const HMODE_POWER_THRESHOLD: f64 = 30.0;
47
48const EDGE_TEMPERATURE: f64 = 0.1;
50const MAX_TEMPERATURE: f64 = 100.0;
52
53const COOLING_FACTOR: f64 = 5.0;
55
56const HEATING_WIDTH: f64 = 0.1;
58
59const TOROIDAL_COUPLING_MAX_FACTOR: f64 = 3.0;
61
62#[derive(Debug, Clone, PartialEq)]
64pub struct NeoclassicalParams {
65 pub r_major: f64,
67 pub a_minor: f64,
69 pub b_toroidal: f64,
71 pub a_ion: f64,
73 pub z_eff: f64,
75 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
92pub 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 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 let rho_i = m_i * v_ti / (NC_ELEM_CHARGE * params.b_toroidal);
125
126 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 let eps32 = epsilon.powf(1.5);
136 let nu_star = nu_ii * q * params.r_major / (eps32 * v_ti);
137
138 let alpha = epsilon; 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
152pub 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
168pub 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
198pub 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 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
290pub 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
329pub 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 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
384pub 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 pub neoclassical: Option<NeoclassicalParams>,
394}
395
396impl TransportSolver {
397 pub fn new() -> Self {
399 let rho = Array1::linspace(0.0, 1.0, TRANSPORT_NR);
400
401 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 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 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 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 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 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 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 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 self.chi[i] *= self.toroidal_mode_coupling_factor(self.profiles.rho[i]);
603
604 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 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 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 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 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 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 for i in 1..n - 1 {
741 self.profiles.ti[i] = self.profiles.te[i];
742 }
743
744 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 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 if n > 1 {
775 self.profiles.n_impurity[n - 1] += erosion_rate * 1e-18 * self.dt;
776 }
777
778 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 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 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 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 ts.update_transport_model(10.0)
870 .expect("valid transport-model update");
871 let chi_edge_no_hmode = ts.chi[48]; 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 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 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 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 });
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 let chi = chang_hinton_chi(0.0, 10.0, 10.0, 1.0, &nc);
1102 assert!((chi - CHI_NC_FLOOR).abs() < 1e-12);
1103 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); 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, ¶ms);
1168 for i in 0..n {
1169 let scalar = chang_hinton_chi(rho[i], t_i[i], n_e[i], q[i], ¶ms);
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 let bound = p_aux * dt * 5e4;
1274 assert!(
1275 delta <= bound,
1276 "Energy proxy change too large: delta={delta}, bound={bound}"
1277 );
1278 }
1279}