fusion_core/
bout_interface.rs

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