Skip to content

Use cubic interpolation for vacuum field tables - #819

Merged
jurasic-pf merged 8 commits into
proximafusion:mainfrom
philippbogdan:feat/cubic-vacuum-field-interpolation
Sep 10, 2026
Merged

Use cubic interpolation for vacuum field tables#819
jurasic-pf merged 8 commits into
proximafusion:mainfrom
philippbogdan:feat/cubic-vacuum-field-interpolation

Conversation

@philippbogdan

@philippbogdan philippbogdan commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

External-field tables use tensor cubic interpolation in R and Z. The four-node stencil shifts inward at grid edges; tables with only two or three nodes use the degree supported by their available nodes. Toroidal-plane selection and the out-of-domain error are unchanged.

The ordinary solver uses this implementation unconditionally. The linear implementation and interpolation setting have been removed, including their input/output and serialisation changes. The experiment write-up, benchmark script and results JSON are removed from the final tree. Their evidence remains in the previous PR revision.

Regression tests check tensor polynomials of degrees one, two and three, including grid edges, small tables, multiple toroidal planes and current-weighted coil groups. CTH and Solov'ev equilibrium tests check Fourier-geometry improvement under field-table refinement. The numerical method changes the free-boundary reference values, so the affected Fortran checkpoints and output fixtures are regenerated with cubic interpolation. Existing comparison tolerances are retained.

Validation of revision e47ab40b (including current upstream de5166b1) on Apple Silicon/macOS:

  • C++: 41/43 targets pass, including all free-boundary and output comparisons. The same two Fourier-basis tests fail at round-off tolerances on unmodified upstream with this LLVM configuration. The glibc-only allocation target is excluded on macOS.
  • Python: 206 pass, 2 skip, 36 slow tests deselected. The same six exception-type tests fail in the full suite on unmodified upstream because of import order; those six pass in isolation. The 49 focused input/output and free-boundary tests pass together.
  • All pre-commit hooks pass on the changed files, including clang-format and Pyright.
  • CI is being rerun on this revision. The previous revision's green CI is not validation of this one.
Measurements retained from the earlier PR revision

At matched plasma resolution, profiles, currents and force tolerance, cubic 51x51 gives 16 to 100 times smaller physical-point B differences than linear 101x101 against a 401x401 refinement reference from identical source coils. Independent Biot-Savart/quadrature checks verify the source fields. This measures field-table error; plasma truncation and smooth-coil representation error remain separate.

Six adjacent process-isolated timing pairs per case/grid against upstream 98aaaea give 120/120 converged solves, including asymmetric CTH and finite-pressure W7-X. The largest original-grid median slowdown is 6.1%; no solver speedup is claimed. A separate 12-configuration, three-profile QUASR matrix has the same five of 36 converged problems with both methods at the supplied budgets. An additional W7-X check at measured 4.24% total beta converges in all four method/grid combinations; cubic 61 has about 59 times smaller physical-point B difference than linear 61 against the original 121-table cubic reference.

These are historical measurements from the earlier revision. The final cubic weights on tables with at least four nodes retain its closed-form expressions.

Reference regeneration and exact Fortran source patch

The independent reference generator is educational_VMEC at ce663b2 with the patch below. It also includes the asymmetric debug-output slices from educational_VMEC#30. It changes becoil to tensor Lagrange interpolation and restores the VMEC 8.52 lforbal calculation for the existing Solov'ev force-balance test. This is a deliberately modified reference solver, not unmodified VMEC 8.52.

To validate the restoration, a build with the original bilinear becoil reproduces the existing lforbal=true golden's R/Z/lambda/B Fourier coefficients to a worst normalised difference of 4.8e-11. The cubic build then supplies the replacement golden.

Build with CMake/Ninja, gfortran, NetCDF C/Fortran and FFTW. The local compiler flags were -ffree-line-length-none -fallow-argument-mismatch plus the NetCDF-Fortran include directory. Run xvmec input.cth_like_free_bdy and xvmec input.solovev_free_bdy using the inputs from src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data and the unchanged mgrid files from src/vmecpp/cpp/vmecpp/test_data. Those inputs contain the checkpoint dump flags. Replace matching JSON checkpoints when the maximum normalised numerical change exceeds 1e-12; retain unchanged checkpoints. The final multigrid filenames follow the new Fortran iteration numbers: CTH 496, Solov'ev 417 and 808.

The large-test CTH wout and the core Solov'ev wout are Fortran outputs. For wout_solovev_free_bdy_T.nc, use the core input.solovev_free_bdy with LFORBAL = T. The core CTH wout is a VMEC++ serialisation fixture, as documented in #781; regenerate it with the final VMEC++ implementation, one thread and the unchanged CTH input/mgrid, retaining the full VMEC++ output schema.

The three asymmetric vac1n_analyt/vac1n_fouri references introduced by #738 are regenerated from the core cth_like_free_bdy_asym.json, converted with vmecpp._util.vmecpp_json_to_indata, and the unchanged mgrid_cth_like_asym.nc. Set iter2_to_dump = 53, 54 and enable dump_vac1n_analyt, dump_vac1n_fourp and dump_vac1n_fouri. The geometric fourp fixture is unchanged. The bilinear reference build reproduces the original asymmetric bvec values within 3e-14; the rebuilt cubic reference passes both new asymmetric C++ targets at their existing tolerances.

Run each reference case in a fresh output directory: educational_VMEC stops when a checkpoint JSON already exists, even though its process exit code can be zero. Verify EXECUTION TERMINATED NORMALLY in the run log. Use a clean build directory when changing the reference interpolation method.

The following patch applies to the reference-generator commit linked above:

diff --git a/src/NESTOR/analyt.f90 b/src/NESTOR/analyt.f90
index f51afd7..d204070 100644
--- a/src/NESTOR/analyt.f90
+++ b/src/NESTOR/analyt.f90
@@ -172,16 +172,19 @@ SUBROUTINE analyt(grpmn, bvec, ivacskip, lasym, m_map, n_map, grpmn_m_map, grpmn
 
     call add_real_3d("all_tlp", mf+nf+1, nv, nu3, all_tlp)
     call add_real_3d("all_tlm", mf+nf+1, nv, nu3, all_tlm)
+    if (lasym /= 0) call add_real_2d("bvec_cos", mf1, nf1, bvec(mnpd+1:))
     call add_real_2d("bvec", mf1, nf1, bvec) ! (mf+1)x(2*nf+1)x(ndim: 1 or 2)
 
     if (ivacskip .eq. 0) then
       call add_real_3d("all_slp", mf+nf+1, nv, nu3, all_slp)
       call add_real_3d("all_slm", mf+nf+1, nv, nu3, all_slm)
+      if (lasym /= 0) call add_real_4d("grpmn_cos", mf1, nf1, nv, nu3, grpmn(nuv2*mnpd+1:))
       call add_real_4d("grpmn", mf1, nf1, nv, nu3, grpmn) ! missing dim: (ndim: 1 or 2)
     else
       call add_null("all_slp")
       call add_null("all_slm")
       call add_null("grpmn")
+      if (lasym /= 0) call add_null("grpmn_cos")
     end if
 
     call close_dbg_out()
diff --git a/src/NESTOR/becoil.f90 b/src/NESTOR/becoil.f90
index 7e69ee0..3693277 100644
--- a/src/NESTOR/becoil.f90
+++ b/src/NESTOR/becoil.f90
@@ -1,9 +1,9 @@
 !> \file
 !> \brief Compute the cylindrical components of the magnetic field due to external coils
-!>        by bi-linear interpolation of the \c mgrid file.
+!>        by tensor cubic interpolation of the \c mgrid file.
 
 !> \brief Compute the cylindrical components of the magnetic field due to external coils.
-!>        by bi-linear interpolation of the \c mgrid file.
+!>        by tensor cubic interpolation of the \c mgrid file.
 !>
 !> @param rad \f$R\f$ at which to evaluate the external magnetic field
 !> @param zee \f$Z\f$ at which to evaluate the external magnetic field
@@ -26,13 +26,13 @@ SUBROUTINE becoil(rad, zee, brvac, bpvac, bzvac)
   CHARACTER(LEN=50), PARAMETER :: warning = 'Plasma Boundary exceeded Vacuum Grid Size'
 
   INTEGER, SAVE :: icount = 0
-  INTEGER :: i, kv, ir, jz, ir1, jz1
-  REAL(rprec) :: rad0, zee0, ri, zj, pr, qz, w22, w21, w12, w11
+  INTEGER :: i, kv, ir, jz, a, b, c, nr, nz
+  REAL(rprec) :: rad0, zee0, ri, zj, pr, qz, w
+  REAL(rprec) :: wr(4), wz(4)
 
   ! DETERMINE THE CYLINDRICAL COMPONENTS OF THE EXTERNAL
   ! MAGNETIC FIELD (BR, BP, BZ) AT A FIXED PHI PLANE BY
-  ! USING 2-D INTERPOLATION BASED ON THE FOUR POINT FORMULA
-  ! IN ABRAMOWITZ AND STEGUN, EQ. 25.2.66
+  ! USING TENSOR LAGRANGE INTERPOLATION ON UP TO FOUR NODES PER AXIS
   !
   ! BRVAC, BPVAC, BZVAC: CYLINDRICAL COMPONENTS OF VACUUM B-FIELD
   !                      STORED ON R, Z, PHI GRID
@@ -73,27 +73,40 @@ SUBROUTINE becoil(rad, zee, brvac, bpvac, bzvac)
      ! Axi-symmetric special case
      kv = MIN (kv, np0b)
 
-     ! DETERMINE INTEGER INDICES (IR,JZ) FOR LOWER LEFT R, Z CORNER GRID POINT
-     ir = INT((rad0 - rminb)/delrb) + 1
-     jz = INT((zee0 - zminb)/delzb) + 1
-     ir1 = MIN(nr0b,ir + 1)
-     jz1 = MIN(nz0b,jz + 1)
-
-     ! COMPUTE RI, ZJ AND PR , QZ AT GRID POINT (IR , JZ)
-     ! ALSO, COMPUTE WEIGHTS WIJ FOR 4 CORNER GRID POINTS
-     ri = rminb + (ir - 1)*delrb
-     zj = zminb + (jz - 1)*delzb
-     pr = (rad0 - ri)/delrb
-     qz = (zee0 - zj)/delzb
-     w22 = pr*qz                   !    p *   q
-     w21 = pr - w22                !    p *(1-q)
-     w12 = qz - w22                ! (1-p)*   q
-     w11 = 1 + w22 - (pr + qz)     ! (1-p)*(1-q)
-
-     ! COMPUTE B FIELD AT R, PHI, Z BY INTERPOLATION
-     brad(i) = w11*brvac(ir,jz,kv) + w22*brvac(ir1,jz1,kv) + w21*brvac(ir1,jz,kv) + w12*brvac(ir,jz1,kv)
-     bphi(i) = w11*bpvac(ir,jz,kv) + w22*bpvac(ir1,jz1,kv) + w21*bpvac(ir1,jz,kv) + w12*bpvac(ir,jz1,kv)
-     bz(i)   = w11*bzvac(ir,jz,kv) + w22*bzvac(ir1,jz1,kv) + w21*bzvac(ir1,jz,kv) + w12*bzvac(ir,jz1,kv)
+     ! Tensor Lagrange interpolation on at most four nodes in each direction.
+     nr = MIN(4, nr0b)
+     nz = MIN(4, nz0b)
+     pr = (rad0 - rminb)/delrb
+     qz = (zee0 - zminb)/delzb
+     ir = MAX(1, MIN(INT(pr), nr0b - nr + 1))
+     jz = MAX(1, MIN(INT(qz), nz0b - nz + 1))
+     pr = pr - (ir - 1)
+     qz = qz - (jz - 1)
+     wr = 0
+     wz = 0
+     DO a = 1, nr
+        wr(a) = 1
+        DO c = 1, nr
+           IF (a /= c) wr(a) = wr(a)*(pr - (c - 1))/(a - c)
+        END DO
+     END DO
+     DO b = 1, nz
+        wz(b) = 1
+        DO c = 1, nz
+           IF (b /= c) wz(b) = wz(b)*(qz - (c - 1))/(b - c)
+        END DO
+     END DO
+     brad(i) = 0
+     bphi(i) = 0
+     bz(i) = 0
+     DO b = 1, nz
+        DO a = 1, nr
+           w = wr(a)*wz(b)
+           brad(i) = brad(i) + w*brvac(ir+a-1,jz+b-1,kv)
+           bphi(i) = bphi(i) + w*bpvac(ir+a-1,jz+b-1,kv)
+           bz(i) = bz(i) + w*bzvac(ir+a-1,jz+b-1,kv)
+        END DO
+     END DO
 
   END DO
 
diff --git a/src/NESTOR/fouri.f90 b/src/NESTOR/fouri.f90
index feacca0..47685b3 100644
--- a/src/NESTOR/fouri.f90
+++ b/src/NESTOR/fouri.f90
@@ -210,6 +210,7 @@ SUBROUTINE fouri(grpmn, gsource, amatrix, amatsq, bvec, wint, lasym)
     call add_real_4d("actemp", mf1, nf1, nf1, nu3, actemp)
     call add_real_4d("astemp", mf1, nf1, nf1, nu3, astemp)
 
+    if (lasym) call add_real_2d("bvec_cos", mf1, nf1, bvec(:,:,2))
     call add_real_2d("bvec", mf1, nf1, bvec)
     call add_real_4d("amatrix", mf1, nf1, mf1, nf1, amatrix)
 
diff --git a/src/NESTOR/fourp.f90 b/src/NESTOR/fourp.f90
index e53784a..4ff59c4 100644
--- a/src/NESTOR/fourp.f90
+++ b/src/NESTOR/fourp.f90
@@ -97,6 +97,7 @@ SUBROUTINE fourp (grpmn, grp)
   if (open_dbg_context("vac1n_fourp", num_eqsolve_retries)) then
 
     ! NOTE: This is the sum of the results from analyt and fourp!
+    if (ndim == 2) call add_real_4d("grpmn_cos", mf1, nf1, nv, nu3, grpmn(:,:,:,2))
     call add_real_4d("grpmn", mf1, nf1, nv, nu3, grpmn)
 
     call close_dbg_out()
diff --git a/src/bcovar.f90 b/src/bcovar.f90
index dbeb8aa..372c0d2 100644
--- a/src/bcovar.f90
+++ b/src/bcovar.f90
@@ -400,13 +400,27 @@ SUBROUTINE bcovar (lu, lv)
 
        CALL lamcal(phipog, guu, guv, gvv)
 
+       IF (ALLOCATED(rzu_fac)) THEN
+          IF (SIZE(rzu_fac) /= ns) DEALLOCATE(rzu_fac, rru_fac, frcc_fac, fzsc_fac)
+       END IF
+       IF (.NOT. ALLOCATED(rzu_fac)) ALLOCATE(rzu_fac(ns), rru_fac(ns), frcc_fac(ns), fzsc_fac(ns))
+
        CALL precondn(bsupv, bsq, gsqrt, r12, &
                      zs, zu12, zu, zu(1,1), z1(1,1), &
-                     arm, ard, brm, brd, crd, cos01)
+                     arm, ard, brm, brd, crd, rzu_fac, cos01)
 
        CALL precondn(bsupv, bsq, gsqrt, r12, &
                      rs, ru12, ru, ru(1,1), r1(1,1), &
-                     azm, azd, bzm, bzd, crd, sin01)
+                     azm, azd, bzm, bzd, crd, rru_fac, sin01)
+
+       IF (lforbal) THEN
+          rzu_fac(2:ns-1) = sqrts(2:ns-1)*rzu_fac(2:ns-1)
+          rru_fac(2:ns-1) = sqrts(2:ns-1)*rru_fac(2:ns-1)
+          frcc_fac(2:ns-1) = one/rzu_fac(2:ns-1)
+          fzsc_fac(2:ns-1) = -one/rru_fac(2:ns-1)
+          rzu_fac = rzu_fac/2
+          rru_fac = rru_fac/2
+       END IF
 
        ! check preconditioner output
        if (open_dbg_context("precondn", num_eqsolve_retries)) then
diff --git a/src/data/vmec_input.f90 b/src/data/vmec_input.f90
index 4a9be29..0074a0b 100644
--- a/src/data/vmec_input.f90
+++ b/src/data/vmec_input.f90
@@ -61,6 +61,7 @@ MODULE vmec_input
   REAL(rprec), DIMENSION(0:ntord)   :: zaxis_cs
   REAL(rprec), DIMENSION(nigroup)   :: extcur
   LOGICAL                           :: lfreeb
+  LOGICAL                           :: lforbal
   LOGICAL                           :: lasym
 
   !> switch between implementations of NESTOR:
@@ -213,6 +214,7 @@ MODULE vmec_input
      extcur,        &
      phiedge,       &
      lfreeb,        &
+     lforbal,       &
      lasym,         &
      lbsubs,        &
      vac_1_2,       &
@@ -348,6 +350,7 @@ SUBROUTINE read_indata_namelist (iunit, istat)
 
   vac_1_2 = 1 ! vac1 NESTOR by default
 
+  lforbal = .false.
   lfreeb = .true.
   lasym = .false.
   lbsubs = .false.
diff --git a/src/data/vmec_main.f90 b/src/data/vmec_main.f90
index c2c3c94..07d7300 100644
--- a/src/data/vmec_main.f90
+++ b/src/data/vmec_main.f90
@@ -87,6 +87,7 @@ MODULE vmec_main
   REAL(rprec) :: ohs
   REAL(rprec) :: voli !< total plasma volume in m^3
   REAL(rprec) :: r00
+  REAL(rprec), ALLOCATABLE :: rzu_fac(:), rru_fac(:), frcc_fac(:), fzsc_fac(:)
   REAL(rprec) :: r0scale
   REAL(rprec) :: z00
   REAL(rprec) :: fsqsum0
diff --git a/src/precondn.f90 b/src/precondn.f90
index 16c2fab..c052299 100644
--- a/src/precondn.f90
+++ b/src/precondn.f90
@@ -23,7 +23,7 @@
 !> @param trigmult
 SUBROUTINE precondn(lu1, bsq, gsqrt, r12, &
                     xs, xu12, xue, xuo, xodd, &
-                    axm, axd, bxm, bxd, cx, trigmult)
+                    axm, axd, bxm, bxd, cx, eqfactor, trigmult)
   USE vmec_main
   USE vmec_params, ONLY: signgs
   USE realspace
@@ -34,6 +34,8 @@ SUBROUTINE precondn(lu1, bsq, gsqrt, r12, &
   REAL(rprec), DIMENSION(ns+1),   INTENT(out) :: cx
   REAL(rprec), DIMENSION(nznt),   INTENT(in)  :: trigmult
 
+  REAL(rprec), DIMENSION(ns), INTENT(out) :: eqfactor
+
   INTEGER :: js, l, lk
   REAL(rprec), DIMENSION(:,:), ALLOCATABLE :: ax, bx
   REAL(rprec) :: temp(ns+1)
@@ -107,6 +109,9 @@ SUBROUTINE precondn(lu1, bsq, gsqrt, r12, &
     temp(js) = signgs*(temp(js) + temp(js+1))
   END DO
 
+  eqfactor = 0.0_dp
+  IF (lforbal) eqfactor(2:ns-1) = axd(2:ns-1,2)*hs*hs/temp(2:ns-1)
+
   axm(ns+1,:) = 0.0_dp
   axd(ns+1,:) = 0.0_dp
   bxm(ns+1,:) = 0.0_dp
diff --git a/src/tomnsp.f90 b/src/tomnsp.f90
index 102e5b2..d263ff8 100644
--- a/src/tomnsp.f90
+++ b/src/tomnsp.f90
@@ -20,7 +20,7 @@ SUBROUTINE tomnsps(frzl_array,       &
                    blmn, clmn,       &
                    arcon, azcon       )
   USE vmec_main
-  USE vmec_params, ONLY: jlam, jmin2, ntmax, rcc, rss, zsc, zcs
+  USE vmec_params, ONLY: jlam, jmin2, ntmax, rcc, rss, zsc, zcs, nscale
 
   use dbgout
 
@@ -145,6 +145,17 @@ SUBROUTINE tomnsps(frzl_array,       &
      END DO
   END DO
 
+  ! VMEC 8.52 non-variational m=1,n=0 force balance (restored from v8.52).
+  IF (lforbal) THEN
+     mj = 1 + joff
+     ni = ioff
+     DO jl = 2, ns-1
+        work1(jl,1) = frcc_fac(jl)*frcc(jl,ni,mj) + fzsc_fac(jl)*fzsc(jl,ni,mj)
+        frcc(jl,ni,mj) = rzu_fac(jl)*(nscale(0)*r0scale*equif(jl) + work1(jl,1))
+        fzsc(jl,ni,mj) = rru_fac(jl)*(nscale(0)*r0scale*equif(jl) - work1(jl,1))
+     END DO
+  END IF
+
   DEALLOCATE (work1, tempr, tempz)
 
   if (open_dbg_context("tomnsps", num_eqsolve_retries)) then

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

@jurasic-pf jurasic-pf left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea! I think cubic interpolation makes a lot of sense, especially if the performance impact is barely measurable and the accuracy impact clearly is!

Given all the results you showed, I don't think there's a good reason to keep supporting linear interpolation anymore, I'm happy to just switch to the cubic default for everything. The benchmarks are convincing, and thanks for the writeup, this made the review very straightforward.
The information is tracked in the PR commits for posterity, but I don't think all of it should be merged, just the feature. I left comments in the according places.

Comment thread docs/vacuum_field_interpolation.md Outdated
Comment thread benchmarks/mgrid_interpolation_results.json Outdated
Comment thread benchmarks/mgrid_interpolation.py Outdated
Comment thread src/vmecpp/cpp/vmecpp/common/vmec_indata/vmec_indata.cc
@jurasic-pf
jurasic-pf enabled auto-merge (squash) September 8, 2026 12:13
@jurasic-pf
jurasic-pf merged commit 9bf9270 into proximafusion:main Sep 10, 2026
43 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants