Skip to content

Fix the sign of the cross terms in NESTOR's analytic add-back - #825

Open
CharlesCNorton wants to merge 10 commits into
proximafusion:mainfrom
CharlesCNorton:nestor-cross-term-sign
Open

Fix the sign of the cross terms in NESTOR's analytic add-back#825
CharlesCNorton wants to merge 10 commits into
proximafusion:mainfrom
CharlesCNorton:nestor-cross-term-sign

Conversation

@CharlesCNorton

Copy link
Copy Markdown
Contributor

NESTOR subtracts a tangent-plane kernel numerically (RegularizedIntegrals) and adds its Fourier coefficients back analytically (SingularIntegrals). The analytic side used the opposite sign of the metric and curvature cross terms guv, auv, so mode (m, n) received the coefficient of (m, −n). The error vanishes on axisymmetric surfaces, is first order in the helical shaping, and does not decrease with resolution; VMEC 8.52 has the same sign in analyt.f90.

On an exact exterior Neumann problem (interior dipole line, helical torus) the residual drops from 4.9e-3 to 7e-6 and now converges with resolution; on the cth_like β=0.2% equilibrium the NESTOR boundary moves from 0.43 mm rms to 0.08 mm rms from a 10-digit BIEST solve of the same problem, and a W7-X standard-configuration equilibrium at β=2.2% moves by 1.7 mm on the axis and 1.3 mm rms on the boundary. educational_VMEC with the same two-line change reproduces the corrected results to 1e-11.

New tests: the assembled analytic coefficients against Gauss–Legendre quadrature of the subtracted kernels (singular_integrals_test, fails with the old sign) and the interior-dipole exact solution (nestor_test). The golden scalars in LasymFreeBoundaryMatchesEducationalVmec come from the patched educational_VMEC, the multigrid iteration guard is 328, and wout_cth_like_free_bdy.nc is regenerated (it matches the patched educational_VMEC at the reference test's tolerances).

Fixes the NESTOR part of #628.

@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

Copy link
Copy Markdown
Collaborator

Wow, well done on spotting this issue - you mention that you did a convergence check against the analytical solution, could you share some plots for it, since you already generated the data? In particular I'm curious how the B.n decays qualitatively (is/was there clear structures visible in the error? How did this go unnoticed?), and rms decay in log-log

@CharlesCNorton

Copy link
Copy Markdown
Contributor Author

Plots from the exact test (a line of dipoles on the axis of a helically deformed torus; the exact vacuum field on the surface is zero, so the total field NESTOR reconstructs is the error). NESTOR imposes B·n itself, so the residual is the tangential field.

residual maps and spectra

convergence

The old-sign residual is 6% of |B_ext| at every resolution and lives entirely in the low modes of the deformation, as (m, +1)/(m, −1) pairs of equal weight, which is the signature of the (m, n) ↔ (m, −n) swap. With the fix it drops to 0.1–0.4% and decays close to h⁻² up to nzeta 48, then plateaus around 1e-3 in |B| (1e-6 in |B|²) with an n = 0 structure; that floor is not the mgrid interpolation (a 601² grid gives the same numbers) and is three orders below the defect, so I have not chased it. rms |B_total| / rms |B_ext| for nzeta 18, 24, 36, 48, 72 (mpol = nzeta/3, ntor = nzeta/6): old 6.2e-2 throughout; fixed 3.9e-3, 2.4e-3, 1.2e-3, 7.6e-4, 1.1e-3.

It went unnoticed because the error vanishes on axisymmetric surfaces, is smooth and shares the symmetry of the geometry, moves ordinary equilibria by fractions of a millimetre, and Fortran VMEC and VMEC++ agree with each other since both carry it; only a solver-independent reference exposes it.

@jurasic-pf

Copy link
Copy Markdown
Collaborator

Haha, so fast! :)

@jurasic-pf

jurasic-pf commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Really great work catching that. I guess the remaining m=0 structure is primarily due to the expansion error of the singularity subtraction, what do you think?
And what's your intuition on why the error starts increasing around nzeta >50? I would suspect the derivatives of the geometric terms start to blow up around there so truncation error becomes dominant.

@CharlesCNorton

Copy link
Copy Markdown
Contributor Author

@jurasic-pf Yes. The plateau is the auto grid tying ntheta to 2·mpol+6: at mpol 8, ntor 4, nzeta 24 the residual is 2.4e-3 / 7.2e-4 / 4.2e-4 for ntheta 22 / 44 / 88 (nzeta 24 → 96 only gives 4.2e-4 → 3.3e-4), and at ntheta 88 it is 4.2e-4 / 1.7e-4 / 1.5e-4 for mpol 8 / 12 / 16, about first order in ntheta as expected for a remainder that is only C⁰ at the singular point.

@CharlesCNorton

Copy link
Copy Markdown
Contributor Author

@jurasic-pf The rise is mpol, not nzeta (the ladder ties mpol to nzeta/3): at fixed ntheta the residual is 1.5e-4 for mpol 16 and 5.7e-4 for mpol 24, mpol 32 diverges for both signs and also on the axisymmetric 1/R test, while nzeta 24 → 96 at fixed mpol improves it. That points at the analytic side at high l — the T_l recurrences and the alternating cmn sums with kL = mpol+1+ntor — rather than the geometry, whose derivatives are exact here (three Fourier modes).

Comment thread src/vmecpp/cpp/vmecpp/free_boundary/singular_integrals/singular_integrals.cc Outdated
@jurasic-pf

Copy link
Copy Markdown
Collaborator

The PR convincingly demonstrates that there's a bug that needs fixing, but I wonder where it actually is - NESTOR and VMEC use different handedness in the coordinates, so there's a few terms that are lacking a signgs flip. I wonder if the mixed sincos terms here

snv[o] = signOfJacobian * (rub[d] * zvb[d] - zub[d] * rvb[d]);

or here
guv[o] = 2.0 * (rub[d] * rvb[d] + zub[d] * zvb[d]) / s_.nfp;

have the sign convention backwards and we are just patching the consequence here

@CharlesCNorton

Copy link
Copy Markdown
Contributor Author

@jons-pf Yes, it is the same issue. Negating guv and auv on the way into the analytic part exchanges ap ↔ am and Ap ↔ Am, i.e. T^± and S^±, which is exactly what the call-site swap does in the m ≥ 1, n ≥ 1 branch (the m = 0 / n = 0 branch uses T^+ + T^- and is unaffected); the explicit un-swap, implemented as a check, is bit-identical to this change. Intentional or not is settled by the exact solution: with the swap the assembled coefficients of mode (m, n) are those of the mirror kernel (m, −n), and NESTOR's residual on an exact exterior solution is 4.9e-3 at every resolution; without it 7e-6 and converging. If you want the form that mirrors the Fortran call, it can be written as the explicit swap instead.

@CharlesCNorton

Copy link
Copy Markdown
Contributor Author

@jurasic-pf Checked: the stored guv equals 2 X_θ·X_ζ/nfp evaluated analytically on the helical torus to 3e-17, so greenf subtracts the physical kernel; the mismatch is on the analytic side, and the analysum2 argument swap jons found is its origin.

# Conflicts:
#	src/vmecpp/cpp/vmecpp/test_data/wout_cth_like_free_bdy.nc
#	src/vmecpp/cpp/vmecpp/vmec/vmec/vmec_test.cc
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/add_fluxes/add_fluxes_00015_000054_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/bcontrav/bcontrav_00015_000054_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/bcov/bcov_00015_000054_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/bcov_full/bcov_full_00015_000054_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/bcovar_fileout/bcovar_fileout_00015_000000_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/bss/bss_00015_000000_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/calc_fbal/calc_fbal_00015_000054_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/constraint_force/constraint_force_00015_000054_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/evolve/evolve_00015_000053_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/evolve/evolve_00015_000054_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/forces/forces_00015_000053_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/forces/forces_00015_000054_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/freeb_data/freeb_data_00015_000000_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/fsq/fsq_00015_000053_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/fsq/fsq_00015_000054_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/fsq1/fsq1_00015_000053_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/fsq1/fsq1_00015_000054_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/funct3d_geometry/funct3d_geometry_00015_000054_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/jacobian/jacobian_00015_000054_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/jxbforce_bsub_lowpass/jxbforce_bsub_lowpass_00015_000000_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/jxbout/jxbout_00015_000000_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/lambda_forces/lambda_forces_00015_000054_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/lulv_comb/lulv_comb_00015_000054_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/mercier/mercier_00015_000000_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/metric/metric_00015_000054_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/phys_gc/phys_gc_00015_000053_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/phys_gc/phys_gc_00015_000054_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/rbsq/rbsq_00015_000053_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/rbsq/rbsq_00015_000054_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/scale_m1/scale_m1_00015_000053_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/scale_m1/scale_m1_00015_000054_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/scalfor_out/scalfor_out_00015_000053_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/scalfor_out/scalfor_out_00015_000054_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/threed1_axis/threed1_axis_00015_000000_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/threed1_beta/threed1_beta_00015_000000_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/threed1_firstTable/threed1_firstTable_00015_000000_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/threed1_geomag/threed1_geomag_00015_000000_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/threed1_shafrint/threed1_shafrint_00015_000000_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/threed1_volquant/threed1_volquant_00015_000000_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/tomnsps/tomnsps_00015_000053_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/tomnsps/tomnsps_00015_000054_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/totzsp_input/totzsp_input_00015_000054_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/vac1n_analyt/vac1n_analyt_00015_000053_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/vac1n_analyt/vac1n_analyt_00015_000054_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/vac1n_bextern/vac1n_bextern_00015_000054_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/vac1n_bsqvac/vac1n_bsqvac_00015_000053_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/vac1n_bsqvac/vac1n_bsqvac_00015_000054_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/vac1n_fouri/vac1n_fouri_00015_000053_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/vac1n_fourp/vac1n_fourp_00015_000053_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/vac1n_solver/vac1n_solver_00015_000053_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/vac1n_solver/vac1n_solver_00015_000054_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/vac1n_surface/vac1n_surface_00015_000054_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/vac1n_vacuum/vac1n_vacuum_00015_000054_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy/volume/volume_00015_000054_01.cth_like_free_bdy.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy_asym/vac1n_analyt/vac1n_analyt_00015_000053_01.cth_like_free_bdy_asym.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy_asym/vac1n_analyt/vac1n_analyt_00015_000054_01.cth_like_free_bdy_asym.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/cth_like_free_bdy_asym/vac1n_fouri/vac1n_fouri_00015_000053_01.cth_like_free_bdy_asym.json
#	src/vmecpp/cpp/vmecpp_large_cpp_tests/test_data/wout_cth_like_free_bdy.nc
#	tests/test_free_boundary.py
@CharlesCNorton

Copy link
Copy Markdown
Contributor Author

fixing conflicts

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.

3 participants