1use fusion_types::error::{FusionError, FusionResult};
18use ndarray::Array2;
19
20#[derive(Debug, Clone)]
22pub struct BoutGridConfig {
23 pub nx: usize,
25 pub ny: usize,
27 pub nz: usize,
29 pub psi_inner: f64,
31 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#[derive(Debug, Clone)]
81pub struct BoutGrid {
82 pub nx: usize,
84 pub ny: usize,
86 pub r_grid: Array2<f64>,
88 pub z_grid: Array2<f64>,
90 pub psi_n: Array2<f64>,
92 pub b_mag: Array2<f64>,
94 pub g_xx: Array2<f64>,
96 pub g_yy: Array2<f64>,
98 pub g_zz: Array2<f64>,
100 pub g_xy: Array2<f64>,
102 pub jacobian: Array2<f64>,
104 pub q_profile: Vec<f64>,
106 pub b_toroidal: f64,
108}
109
110pub 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 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 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 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 let r_guess = r_ax + rho_est * sin_t;
213 let z_guess = z_ax + rho_est * 1.5 * cos_t;
214
215 let mut r_pt = r_guess;
217 let mut z_pt = z_guess;
218
219 for _newton in 0..20 {
220 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 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 let step = residual / grad_sq;
253 r_pt -= step * dpsi_dr;
254 z_pt -= step * dpsi_dz;
255
256 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 let b_t = b_toroidal * r_ax / r_pt.max(0.1);
267 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 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[[ix, iy]] = b_p * b_p;
291 g_zz[[ix, iy]] = 1.0 / (r_pt * r_pt);
293 g_xy[[ix, iy]] = 0.0;
295 jacobian_grid[[ix, iy]] = r_pt / b_p.max(1e-20);
297 }
298
299 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
330pub 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#[derive(Debug, Clone)]
370pub struct BoutStabilityResult {
371 pub n_toroidal: i32,
373 pub growth_rate: f64,
375 pub real_frequency: f64,
377 pub mode_amplitude: Vec<f64>,
379}
380
381pub 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 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 assert!(generate_bout_grid(&psi, &r, &z, 0.0, 0.0, 5.0, &config).is_err());
567
568 assert!(generate_bout_grid(&psi, &r, &z, 1.0, 0.0, 0.0, &config).is_err());
570 }
571}