1use fusion_types::error::{FusionError, FusionResult};
17use ndarray::Array2;
18
19#[derive(Debug, Clone)]
21pub struct BoutGridConfig {
22 pub nx: usize,
24 pub ny: usize,
26 pub nz: usize,
28 pub psi_inner: f64,
30 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#[derive(Debug, Clone)]
80pub struct BoutGrid {
81 pub nx: usize,
83 pub ny: usize,
85 pub r_grid: Array2<f64>,
87 pub z_grid: Array2<f64>,
89 pub psi_n: Array2<f64>,
91 pub b_mag: Array2<f64>,
93 pub g_xx: Array2<f64>,
95 pub g_yy: Array2<f64>,
97 pub g_zz: Array2<f64>,
99 pub g_xy: Array2<f64>,
101 pub jacobian: Array2<f64>,
103 pub q_profile: Vec<f64>,
105 pub b_toroidal: f64,
107}
108
109pub 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 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 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 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 let r_guess = r_ax + rho_est * sin_t;
212 let z_guess = z_ax + rho_est * 1.5 * cos_t;
213
214 let mut r_pt = r_guess;
216 let mut z_pt = z_guess;
217
218 for _newton in 0..20 {
219 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 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 let step = residual / grad_sq;
252 r_pt -= step * dpsi_dr;
253 z_pt -= step * dpsi_dz;
254
255 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 let b_t = b_toroidal * r_ax / r_pt.max(0.1);
266 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 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[[ix, iy]] = b_p * b_p;
290 g_zz[[ix, iy]] = 1.0 / (r_pt * r_pt);
292 g_xy[[ix, iy]] = 0.0;
294 jacobian_grid[[ix, iy]] = r_pt / b_p.max(1e-20);
296 }
297
298 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
329pub 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#[derive(Debug, Clone)]
369pub struct BoutStabilityResult {
370 pub n_toroidal: i32,
372 pub growth_rate: f64,
374 pub real_frequency: f64,
376 pub mode_amplitude: Vec<f64>,
378}
379
380pub 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 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 assert!(generate_bout_grid(&psi, &r, &z, 0.0, 0.0, 5.0, &config).is_err());
566
567 assert!(generate_bout_grid(&psi, &r, &z, 1.0, 0.0, 0.0, &config).is_err());
569 }
570}