fusion_core/
bout_interface.rs

1// ─────────────────────────────────────────────────────────────────────
2// SCPN Fusion Core — BOUT++ 3D MHD Coupling Interface
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//! BOUT++ coupling interface for 3D MHD stability analysis.
9//!
10//! Generates field-aligned coordinate grids from 2D GS equilibria,
11//! computes metric tensors (g^ij, Jacobian, |B|), and exports in
12//! a text format compatible with BOUT++ input requirements.
13//!
14//! Also provides import routines for BOUT++ stability results
15//! (growth rates, mode structure).
16
17use fusion_types::error::{FusionError, FusionResult};
18use ndarray::Array2;
19
20/// Configuration for BOUT++ grid generation.
21#[derive(Debug, Clone)]
22pub struct BoutGridConfig {
23    /// Number of radial flux surfaces (x-direction in BOUT++).
24    pub nx: usize,
25    /// Number of poloidal points per surface (y-direction in BOUT++).
26    pub ny: usize,
27    /// Number of toroidal points (z-direction in BOUT++).
28    pub nz: usize,
29    /// Inner normalised flux boundary (0 = axis).
30    pub psi_inner: f64,
31    /// Outer normalised flux boundary (1 = separatrix).
32    pub psi_outer: f64,
33}
34
35impl Default for BoutGridConfig {
36    fn default() -> Self {
37        Self {
38            nx: 36,
39            ny: 64,
40            nz: 32,
41            psi_inner: 0.1,
42            psi_outer: 0.95,
43        }
44    }
45}
46
47impl BoutGridConfig {
48    pub fn validate(&self) -> FusionResult<()> {
49        if self.nx < 4 {
50            return Err(FusionError::PhysicsViolation(
51                "BOUT++ grid requires nx >= 4".into(),
52            ));
53        }
54        if self.ny < 8 {
55            return Err(FusionError::PhysicsViolation(
56                "BOUT++ grid requires ny >= 8".into(),
57            ));
58        }
59        if self.nz < 4 {
60            return Err(FusionError::PhysicsViolation(
61                "BOUT++ grid requires nz >= 4".into(),
62            ));
63        }
64        if !self.psi_inner.is_finite()
65            || !self.psi_outer.is_finite()
66            || self.psi_inner < 0.0
67            || self.psi_outer > 1.0
68            || self.psi_inner >= self.psi_outer
69        {
70            return Err(FusionError::PhysicsViolation(format!(
71                "BOUT++ grid psi bounds invalid: inner={}, outer={}",
72                self.psi_inner, self.psi_outer
73            )));
74        }
75        Ok(())
76    }
77}
78
79/// BOUT++ field-aligned grid with metric tensors.
80#[derive(Debug, Clone)]
81pub struct BoutGrid {
82    /// Number of radial points.
83    pub nx: usize,
84    /// Number of poloidal points.
85    pub ny: usize,
86    /// R coordinates on the grid [nx × ny].
87    pub r_grid: Array2<f64>,
88    /// Z coordinates on the grid [nx × ny].
89    pub z_grid: Array2<f64>,
90    /// Normalised poloidal flux ψ_N on the grid [nx × ny].
91    pub psi_n: Array2<f64>,
92    /// Magnetic field magnitude |B| [T] on the grid [nx × ny].
93    pub b_mag: Array2<f64>,
94    /// Contravariant metric g^{xx} (radial-radial) [nx × ny].
95    pub g_xx: Array2<f64>,
96    /// Contravariant metric g^{yy} (poloidal-poloidal) [nx × ny].
97    pub g_yy: Array2<f64>,
98    /// Contravariant metric g^{zz} (toroidal-toroidal) [nx × ny].
99    pub g_zz: Array2<f64>,
100    /// Contravariant metric g^{xy} (radial-poloidal) [nx × ny].
101    pub g_xy: Array2<f64>,
102    /// Jacobian J [nx × ny].
103    pub jacobian: Array2<f64>,
104    /// Safety factor q(ψ) [nx].
105    pub q_profile: Vec<f64>,
106    /// Toroidal field B_toroidal [T].
107    pub b_toroidal: f64,
108}
109
110/// Generate a BOUT++ field-aligned grid from a 2D equilibrium.
111///
112/// Takes the poloidal flux ψ(R,Z) on a rectangular (R,Z) grid and
113/// traces flux surfaces to build field-aligned coordinates.
114///
115/// # Arguments
116/// * `psi` — Poloidal flux on (nz_eq, nr_eq) rectangular grid
117/// * `r_axis` — R coordinates of the equilibrium grid [nr_eq]
118/// * `z_axis` — Z coordinates of the equilibrium grid [nz_eq]
119/// * `psi_axis` — Flux at the magnetic axis
120/// * `psi_boundary` — Flux at the separatrix/boundary
121/// * `b_toroidal` — Toroidal magnetic field at geometric center [T]
122/// * `config` — BOUT++ grid configuration
123pub fn generate_bout_grid(
124    psi: &Array2<f64>,
125    r_axis: &[f64],
126    z_axis: &[f64],
127    psi_axis: f64,
128    psi_boundary: f64,
129    b_toroidal: f64,
130    config: &BoutGridConfig,
131) -> FusionResult<BoutGrid> {
132    config.validate()?;
133
134    let nz_eq = psi.nrows();
135    let nr_eq = psi.ncols();
136    if nz_eq < 4 || nr_eq < 4 {
137        return Err(FusionError::PhysicsViolation(format!(
138            "Equilibrium grid too small: {}×{}",
139            nz_eq, nr_eq
140        )));
141    }
142    if r_axis.len() != nr_eq || z_axis.len() != nz_eq {
143        return Err(FusionError::PhysicsViolation(
144            "r_axis/z_axis length must match psi dimensions".into(),
145        ));
146    }
147    if !psi_axis.is_finite() || !psi_boundary.is_finite() {
148        return Err(FusionError::PhysicsViolation(
149            "psi_axis/psi_boundary must be finite".into(),
150        ));
151    }
152    let psi_range = (psi_boundary - psi_axis).abs();
153    if psi_range < 1e-12 {
154        return Err(FusionError::PhysicsViolation(
155            "psi_axis and psi_boundary too close".into(),
156        ));
157    }
158    if !b_toroidal.is_finite() || b_toroidal.abs() < 1e-6 {
159        return Err(FusionError::PhysicsViolation(
160            "b_toroidal must be finite and non-negligible".into(),
161        ));
162    }
163
164    let nx = config.nx;
165    let ny = config.ny;
166    let dr = (r_axis[nr_eq - 1] - r_axis[0]) / (nr_eq - 1) as f64;
167    let dz_eq = (z_axis[nz_eq - 1] - z_axis[0]) / (nz_eq - 1) as f64;
168
169    // Generate normalised flux surfaces
170    let psi_n_surfaces: Vec<f64> = (0..nx)
171        .map(|i| {
172            config.psi_inner + (config.psi_outer - config.psi_inner) * i as f64 / (nx - 1) as f64
173        })
174        .collect();
175
176    let mut r_grid = Array2::zeros((nx, ny));
177    let mut z_grid = Array2::zeros((nx, ny));
178    let mut psi_n_grid = Array2::zeros((nx, ny));
179    let mut b_mag = Array2::zeros((nx, ny));
180    let mut g_xx = Array2::zeros((nx, ny));
181    let mut g_yy = Array2::zeros((nx, ny));
182    let mut g_zz = Array2::zeros((nx, ny));
183    let mut g_xy = Array2::zeros((nx, ny));
184    let mut jacobian_grid = Array2::zeros((nx, ny));
185    let mut q_profile = vec![0.0; nx];
186
187    // Find magnetic axis position (maximum of psi)
188    let mut r_ax = r_axis[nr_eq / 2];
189    let mut z_ax = z_axis[nz_eq / 2];
190    let mut max_psi = f64::NEG_INFINITY;
191    for iz in 0..nz_eq {
192        for ir in 0..nr_eq {
193            if psi[[iz, ir]] > max_psi {
194                max_psi = psi[[iz, ir]];
195                r_ax = r_axis[ir];
196                z_ax = z_axis[iz];
197            }
198        }
199    }
200
201    // For each flux surface, trace poloidal contour
202    let pi2 = 2.0 * std::f64::consts::PI;
203    for ix in 0..nx {
204        let psi_target = psi_axis + psi_n_surfaces[ix] * (psi_boundary - psi_axis);
205        let rho_est = psi_n_surfaces[ix].sqrt() * 0.5 * (r_axis[nr_eq - 1] - r_axis[0]);
206
207        for iy in 0..ny {
208            let theta = pi2 * iy as f64 / ny as f64;
209            let (cos_t, sin_t) = theta.sin_cos();
210
211            // Initial guess: approximate elliptical contour
212            let r_guess = r_ax + rho_est * sin_t;
213            let z_guess = z_ax + rho_est * 1.5 * cos_t;
214
215            // Newton iteration to find (R,Z) on the ψ contour
216            let mut r_pt = r_guess;
217            let mut z_pt = z_guess;
218
219            for _newton in 0..20 {
220                // Bilinear interpolation of ψ at (r_pt, z_pt)
221                let ir_f = (r_pt - r_axis[0]) / dr;
222                let iz_f = (z_pt - z_axis[0]) / dz_eq;
223                let ir0 = (ir_f as usize).min(nr_eq - 2);
224                let iz0 = (iz_f as usize).min(nz_eq - 2);
225                let fr = (ir_f - ir0 as f64).clamp(0.0, 1.0);
226                let fz = (iz_f - iz0 as f64).clamp(0.0, 1.0);
227
228                let psi_interp = psi[[iz0, ir0]] * (1.0 - fr) * (1.0 - fz)
229                    + psi[[iz0, ir0 + 1]] * fr * (1.0 - fz)
230                    + psi[[iz0 + 1, ir0]] * (1.0 - fr) * fz
231                    + psi[[iz0 + 1, ir0 + 1]] * fr * fz;
232
233                let residual = psi_interp - psi_target;
234                if residual.abs() < 1e-10 * psi_range {
235                    break;
236                }
237
238                // Gradient of ψ (finite difference)
239                let dpsi_dr = (psi[[iz0, (ir0 + 1).min(nr_eq - 1)]]
240                    - psi[[iz0, ir0.saturating_sub(1)]])
241                    / (2.0 * dr);
242                let dpsi_dz = (psi[[(iz0 + 1).min(nz_eq - 1), ir0]]
243                    - psi[[iz0.saturating_sub(1), ir0]])
244                    / (2.0 * dz_eq);
245
246                let grad_sq = dpsi_dr * dpsi_dr + dpsi_dz * dpsi_dz;
247                if grad_sq < 1e-30 {
248                    break;
249                }
250
251                // Move along ∇ψ direction
252                let step = residual / grad_sq;
253                r_pt -= step * dpsi_dr;
254                z_pt -= step * dpsi_dz;
255
256                // Clamp to domain
257                r_pt = r_pt.clamp(r_axis[0], r_axis[nr_eq - 1]);
258                z_pt = z_pt.clamp(z_axis[0], z_axis[nz_eq - 1]);
259            }
260
261            r_grid[[ix, iy]] = r_pt;
262            z_grid[[ix, iy]] = z_pt;
263            psi_n_grid[[ix, iy]] = psi_n_surfaces[ix];
264
265            // |B| ≈ B_toroidal * R_0 / R (leading order)
266            let b_t = b_toroidal * r_ax / r_pt.max(0.1);
267            // Poloidal field from ∇ψ
268            let ir_f = ((r_pt - r_axis[0]) / dr).clamp(0.0, (nr_eq - 2) as f64);
269            let iz_f = ((z_pt - z_axis[0]) / dz_eq).clamp(0.0, (nz_eq - 2) as f64);
270            let ir0 = ir_f as usize;
271            let iz0 = iz_f as usize;
272            let dpsi_dr = (psi[[iz0.min(nz_eq - 1), (ir0 + 1).min(nr_eq - 1)]]
273                - psi[[iz0.min(nz_eq - 1), ir0.saturating_sub(1)]])
274                / (2.0 * dr);
275            let dpsi_dz = (psi[[(iz0 + 1).min(nz_eq - 1), ir0.min(nr_eq - 1)]]
276                - psi[[iz0.saturating_sub(1), ir0.min(nr_eq - 1)]])
277                / (2.0 * dz_eq);
278
279            let b_r = -dpsi_dz / r_pt.max(0.1);
280            let b_z = dpsi_dr / r_pt.max(0.1);
281            let b_p = (b_r * b_r + b_z * b_z).sqrt();
282            let b_total = (b_t * b_t + b_p * b_p).sqrt();
283            b_mag[[ix, iy]] = b_total;
284
285            // Metric tensors (flux coordinates)
286            // g^{xx} = |∇ψ|² / (R²B_p²) (radial)
287            let grad_psi_sq = dpsi_dr * dpsi_dr + dpsi_dz * dpsi_dz;
288            g_xx[[ix, iy]] = grad_psi_sq / (r_pt * r_pt * b_p * b_p + 1e-30);
289            // g^{yy} = B_p² (poloidal arc length)
290            g_yy[[ix, iy]] = b_p * b_p;
291            // g^{zz} = 1/R² (toroidal)
292            g_zz[[ix, iy]] = 1.0 / (r_pt * r_pt);
293            // g^{xy} ≈ 0 for orthogonal flux coordinates
294            g_xy[[ix, iy]] = 0.0;
295            // Jacobian J = R / B_p
296            jacobian_grid[[ix, iy]] = r_pt / b_p.max(1e-20);
297        }
298
299        // Safety factor: q ≈ (r B_tor) / (R B_pol) averaged over poloidal angle
300        let mut q_sum = 0.0;
301        for iy in 0..ny {
302            let r_pt = r_grid[[ix, iy]];
303            let b_pol = (b_mag[[ix, iy]] * b_mag[[ix, iy]]
304                - (b_toroidal * r_ax / r_pt.max(0.1)).powi(2))
305            .max(0.0)
306            .sqrt()
307            .max(1e-10);
308            q_sum += b_toroidal * r_ax / (r_pt.max(0.1) * b_pol);
309        }
310        q_profile[ix] = q_sum / ny as f64;
311    }
312
313    Ok(BoutGrid {
314        nx,
315        ny,
316        r_grid,
317        z_grid,
318        psi_n: psi_n_grid,
319        b_mag,
320        g_xx,
321        g_yy,
322        g_zz,
323        g_xy,
324        jacobian: jacobian_grid,
325        q_profile,
326        b_toroidal,
327    })
328}
329
330/// Export BOUT++ grid to text format.
331///
332/// Generates a human-readable text file with all metric data
333/// that can be converted to NetCDF for BOUT++ input.
334pub fn export_bout_grid_text(grid: &BoutGrid) -> FusionResult<String> {
335    let mut out = String::new();
336    out.push_str("# BOUT++ grid file generated by SCPN-Fusion-Core\n");
337    out.push_str(&format!("nx={}\n", grid.nx));
338    out.push_str(&format!("ny={}\n", grid.ny));
339    out.push_str(&format!("b_toroidal={:.16e}\n", grid.b_toroidal));
340
341    out.push_str("\n# q profile\n");
342    for (i, q) in grid.q_profile.iter().enumerate() {
343        out.push_str(&format!("q[{}]={:.16e}\n", i, q));
344    }
345
346    out.push_str("\n# Grid data: ix iy R Z psi_n |B| g_xx g_yy g_zz g_xy J\n");
347    for ix in 0..grid.nx {
348        for iy in 0..grid.ny {
349            out.push_str(&format!(
350                "{} {} {:.10e} {:.10e} {:.10e} {:.10e} {:.10e} {:.10e} {:.10e} {:.10e} {:.10e}\n",
351                ix,
352                iy,
353                grid.r_grid[[ix, iy]],
354                grid.z_grid[[ix, iy]],
355                grid.psi_n[[ix, iy]],
356                grid.b_mag[[ix, iy]],
357                grid.g_xx[[ix, iy]],
358                grid.g_yy[[ix, iy]],
359                grid.g_zz[[ix, iy]],
360                grid.g_xy[[ix, iy]],
361                grid.jacobian[[ix, iy]],
362            ));
363        }
364    }
365    Ok(out)
366}
367
368/// Parsed BOUT++ stability result (growth rate + mode structure).
369#[derive(Debug, Clone)]
370pub struct BoutStabilityResult {
371    /// Toroidal mode number n.
372    pub n_toroidal: i32,
373    /// Growth rate γ [1/s]. Positive = unstable.
374    pub growth_rate: f64,
375    /// Real frequency ω [rad/s].
376    pub real_frequency: f64,
377    /// Radial mode structure amplitude [nx].
378    pub mode_amplitude: Vec<f64>,
379}
380
381/// Parse BOUT++ stability output (text format).
382///
383/// Expected format:
384/// ```text
385/// n=<toroidal_mode_number>
386/// gamma=<growth_rate>
387/// omega=<real_frequency>
388/// amplitude=<val0>,<val1>,...
389/// ```
390pub fn parse_bout_stability(text: &str) -> FusionResult<BoutStabilityResult> {
391    let mut n_tor: Option<i32> = None;
392    let mut gamma: Option<f64> = None;
393    let mut omega: Option<f64> = None;
394    let mut amplitude: Option<Vec<f64>> = None;
395
396    for line in text.lines() {
397        let line = line.trim();
398        if line.is_empty() || line.starts_with('#') {
399            continue;
400        }
401        if let Some(rest) = line.strip_prefix("n=") {
402            n_tor = Some(
403                rest.trim()
404                    .parse::<i32>()
405                    .map_err(|e| FusionError::PhysicsViolation(format!("BOUT++ parse n: {e}")))?,
406            );
407        } else if let Some(rest) = line.strip_prefix("gamma=") {
408            gamma =
409                Some(rest.trim().parse::<f64>().map_err(|e| {
410                    FusionError::PhysicsViolation(format!("BOUT++ parse gamma: {e}"))
411                })?);
412        } else if let Some(rest) = line.strip_prefix("omega=") {
413            omega =
414                Some(rest.trim().parse::<f64>().map_err(|e| {
415                    FusionError::PhysicsViolation(format!("BOUT++ parse omega: {e}"))
416                })?);
417        } else if let Some(rest) = line.strip_prefix("amplitude=") {
418            let vals: Result<Vec<f64>, _> =
419                rest.split(',').map(|s| s.trim().parse::<f64>()).collect();
420            amplitude = Some(vals.map_err(|e| {
421                FusionError::PhysicsViolation(format!("BOUT++ parse amplitude: {e}"))
422            })?);
423        }
424    }
425
426    Ok(BoutStabilityResult {
427        n_toroidal: n_tor
428            .ok_or_else(|| FusionError::PhysicsViolation("Missing BOUT++ field: n".into()))?,
429        growth_rate: gamma
430            .ok_or_else(|| FusionError::PhysicsViolation("Missing BOUT++ field: gamma".into()))?,
431        real_frequency: omega
432            .ok_or_else(|| FusionError::PhysicsViolation("Missing BOUT++ field: omega".into()))?,
433        mode_amplitude: amplitude.ok_or_else(|| {
434            FusionError::PhysicsViolation("Missing BOUT++ field: amplitude".into())
435        })?,
436    })
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    fn mock_equilibrium() -> (Array2<f64>, Vec<f64>, Vec<f64>, f64, f64) {
444        // Create a simple Solov'ev-like ψ on a 33×33 grid
445        let nr = 33;
446        let nz = 33;
447        let r_min = 4.0;
448        let r_max = 8.4;
449        let z_min = -4.0;
450        let z_max = 4.0;
451        let r_axis: Vec<f64> = (0..nr)
452            .map(|i| r_min + (r_max - r_min) * i as f64 / (nr - 1) as f64)
453            .collect();
454        let z_axis: Vec<f64> = (0..nz)
455            .map(|i| z_min + (z_max - z_min) * i as f64 / (nz - 1) as f64)
456            .collect();
457
458        let r0 = 6.2;
459        let a = 2.0;
460        let kappa = 1.7;
461
462        let mut psi = Array2::zeros((nz, nr));
463        let mut psi_max = f64::NEG_INFINITY;
464        for iz in 0..nz {
465            for ir in 0..nr {
466                let r = r_axis[ir];
467                let z = z_axis[iz];
468                let x = (r - r0) / a;
469                let y = z / (kappa * a);
470                let rho_sq = x * x + y * y;
471                psi[[iz, ir]] = (1.0 - rho_sq).max(0.0);
472                if psi[[iz, ir]] > psi_max {
473                    psi_max = psi[[iz, ir]];
474                }
475            }
476        }
477
478        (psi, r_axis, z_axis, psi_max, 0.0)
479    }
480
481    #[test]
482    fn test_bout_grid_generation() {
483        let (psi, r_axis, z_axis, psi_ax, psi_bnd) = mock_equilibrium();
484        let config = BoutGridConfig {
485            nx: 8,
486            ny: 16,
487            nz: 8,
488            psi_inner: 0.15,
489            psi_outer: 0.85,
490        };
491
492        let grid = generate_bout_grid(&psi, &r_axis, &z_axis, psi_ax, psi_bnd, 5.3, &config)
493            .expect("BOUT++ grid generation should succeed");
494
495        assert_eq!(grid.nx, 8);
496        assert_eq!(grid.ny, 16);
497        assert_eq!(grid.q_profile.len(), 8);
498        assert!(grid.b_mag.iter().all(|v| v.is_finite() && *v > 0.0));
499        assert!(grid.jacobian.iter().all(|v| v.is_finite()));
500    }
501
502    #[test]
503    fn test_bout_grid_export_text() {
504        let (psi, r_axis, z_axis, psi_ax, psi_bnd) = mock_equilibrium();
505        let config = BoutGridConfig {
506            nx: 4,
507            ny: 8,
508            nz: 4,
509            psi_inner: 0.2,
510            psi_outer: 0.8,
511        };
512        let grid =
513            generate_bout_grid(&psi, &r_axis, &z_axis, psi_ax, psi_bnd, 5.3, &config).unwrap();
514        let text = export_bout_grid_text(&grid).expect("export should succeed");
515        assert!(text.contains("nx=4"));
516        assert!(text.contains("ny=8"));
517        assert!(text.contains("q[0]="));
518    }
519
520    #[test]
521    fn test_bout_stability_parse() {
522        let text = "\
523# BOUT++ stability output
524n=1
525gamma=1.5e4
526omega=-2.3e3
527amplitude=0.1,0.3,0.8,0.5,0.2
528";
529        let result = parse_bout_stability(text).expect("parse should succeed");
530        assert_eq!(result.n_toroidal, 1);
531        assert!((result.growth_rate - 1.5e4).abs() < 1.0);
532        assert!((result.real_frequency - (-2.3e3)).abs() < 1.0);
533        assert_eq!(result.mode_amplitude.len(), 5);
534    }
535
536    #[test]
537    fn test_bout_stability_parse_missing_field() {
538        let text = "n=1\ngamma=1.5e4\n";
539        assert!(parse_bout_stability(text).is_err());
540    }
541
542    #[test]
543    fn test_bout_grid_rejects_invalid_config() {
544        let cfg = BoutGridConfig {
545            nx: 2,
546            ..Default::default()
547        };
548        assert!(cfg.validate().is_err());
549
550        let cfg2 = BoutGridConfig {
551            psi_inner: 0.9,
552            psi_outer: 0.1,
553            ..Default::default()
554        };
555        assert!(cfg2.validate().is_err());
556    }
557
558    #[test]
559    fn test_bout_grid_rejects_bad_equilibrium() {
560        let psi = Array2::zeros((4, 4));
561        let r = vec![4.0, 5.0, 6.0, 7.0];
562        let z = vec![-2.0, -1.0, 1.0, 2.0];
563        let config = BoutGridConfig::default();
564
565        // psi_axis ≈ psi_boundary → error
566        assert!(generate_bout_grid(&psi, &r, &z, 0.0, 0.0, 5.0, &config).is_err());
567
568        // Bad b_toroidal
569        assert!(generate_bout_grid(&psi, &r, &z, 1.0, 0.0, 0.0, &config).is_err());
570    }
571}