From 25c79424e541636d2f24f1eb86b1ee862848f340 Mon Sep 17 00:00:00 2001 From: Jake Halpern Date: Wed, 7 Jan 2026 13:32:51 -0500 Subject: [PATCH 01/31] VACUUM - WIP - isolating plasma only into vacuum calculations in prep for starting 3D --- src/DCON/Free.jl | 48 ++++---- src/Vacuum/Vacuum.jl | 50 ++++++++ src/Vacuum/VacuumInternals.jl | 127 +++++++++++++++++++ src/Vacuum/VacuumStructs.jl | 221 ++++++++++++++++------------------ 4 files changed, 302 insertions(+), 144 deletions(-) diff --git a/src/DCON/Free.jl b/src/DCON/Free.jl index 6a2004a4..ffa9890e 100644 --- a/src/DCON/Free.jl +++ b/src/DCON/Free.jl @@ -8,6 +8,7 @@ and returns a `VacuumData` struct containing the data needed for perturbed equil and data dumping. ### TODOs + Check if normalize is ever false, currently always true, and if not, remove related logic """ function free_run!(odet::OdeState, ctrl::DconControl, equil::Equilibrium.PlasmaEquilibrium, ffit::FourFitVars, intr::DconInternal, wall_settings::Vacuum.WallShapeSettings) @@ -38,7 +39,7 @@ function free_run!(odet::OdeState, ctrl::DconControl, equil::Equilibrium.PlasmaE wv_block = zeros(ComplexF64, intr.mpert, intr.mpert) for ipert_n in 1:intr.npert n = ipert_n - 1 + intr.nlow - + # Set VACUUM run parameters and boundary shape vac_inputs = set_vacuum_inputs(intr.psilim, n, equil, intr, ctrl) fill!(vac.grri, 0.0) @@ -50,17 +51,17 @@ function free_run!(odet::OdeState, ctrl::DconControl, equil::Equilibrium.PlasmaE if intr.debug_settings.output_benchmark_data @info "Outputting top level vacuum debug data for n = $n" benchmark_inputs = VacuumBenchmarkInputs( - wv_block, intr.mpert, equil.config.control.mtheta, ctrl.mthvac, - complex_flag, vac_inputs.kernelsign, wall_flag, - farwall_flag, vac.grri, vac.xzpts, ahg_file, intr.dir_path, - vac_inputs, wall_settings, - n, ipert_n, intr.psilim + wv_block, intr.mpert, equil.config.control.mtheta, ctrl.mthvac, + complex_flag, vac_inputs.kernelsign, wall_flag, + farwall_flag, vac.grri, vac.xzpts, ahg_file, intr.dir_path, + vac_inputs, wall_settings, + n, ipert_n, intr.psilim ) @save "vacuum_response_inputs.jld2" benchmark_inputs end # Compute vacuum energy matrix - wv_block, vac.grri, vac.xzpts = Vacuum.compute_vacuum_response(vac_inputs, wall_settings) + wv_block, vac.grri, vac.xzpts = Vacuum.compute_vacuum_response_plasma(vac_inputs, wall_settings) # Scale vacuum matrix by singfac = (m - n*qlim) singfac = collect(intr.mlow:intr.mhigh) .- (n * intr.qlim) @@ -70,7 +71,7 @@ function free_run!(odet::OdeState, ctrl::DconControl, equil::Equilibrium.PlasmaE end # Store block in full wv matrix - @views vac.wv[(ipert_n-1)*intr.mpert+1 : ipert_n*intr.mpert, (ipert_n-1)*intr.mpert+1 : ipert_n*intr.mpert] .= wv_block + @views vac.wv[((ipert_n-1)*intr.mpert+1):(ipert_n*intr.mpert), ((ipert_n-1)*intr.mpert+1):(ipert_n*intr.mpert)] .= wv_block Vacuum.unset_dcon_params() end @@ -122,7 +123,7 @@ function free_run!(odet::OdeState, ctrl::DconControl, equil::Equilibrium.PlasmaE end # Normalize eigenvectors based on scaled wt - coeffs = odet.u[:,:,1,end] \ (vac.wt .* (2π * equil.psio * 1e-3)) + coeffs = odet.u[:, :, 1, end] \ (vac.wt .* (2π * equil.psio * 1e-3)) for istep in 1:odet.step odet.u_store[:, :, 1, istep] .= odet.u_store[:, :, 1, istep] * coeffs odet.u_store[:, :, 2, istep] .= odet.u_store[:, :, 2, istep] * coeffs @@ -153,7 +154,6 @@ Performs the same function as `free_write_msc` in the Fortran code, except we wi - `n`: Toroidal mode number (Int) - `equil`: Plasma equilibrium data (Equilibrium.PlasmaEquilibrium) - `intr`: Internal DCON parameters (DconInternal) - """ function set_vacuum_inputs(psifac::Float64, n::Int, equil::Equilibrium.PlasmaEquilibrium, intr::DconInternal, ctrl::DconControl) @@ -168,7 +168,7 @@ function set_vacuum_inputs(psifac::Float64, n::Int, equil::Equilibrium.PlasmaEqu # Compute output qa = Spl.spline_eval!(equil.sq, psifac)[4] - for itheta in 1:equil.config.control.mtheta+1 + for itheta in 1:(equil.config.control.mtheta+1) f = Spl.bicube_eval!(equil.rzphi, psifac, theta_norm[itheta]) rfac[itheta] = sqrt(f[1]) angle[itheta] = 2π * (theta_norm[itheta] + f[2]) @@ -190,17 +190,17 @@ function set_vacuum_inputs(psifac::Float64, n::Int, equil::Equilibrium.PlasmaEqu # For input to the Julia vacuum code return Vacuum.VacuumInput(; - r = reverse(r), - z = reverse(z), - delta = reverse(delta), - mhigh = intr.mhigh, - mlow = intr.mlow, - mpert = intr.mpert, - n = n, - qa = qa, - mtheta_eq = equil.config.control.mtheta, - mtheta = ctrl.mthvac, - force_wv_symmetry = ctrl.force_wv_symmetry + r=reverse(r), + z=reverse(z), + delta=reverse(delta), + mhigh=intr.mhigh, + mlow=intr.mlow, + mpert=intr.mpert, + n=n, + qa=qa, + mtheta_eq=equil.config.control.mtheta, + mtheta=ctrl.mthvac, + force_wv_symmetry=ctrl.force_wv_symmetry ) end @@ -221,7 +221,7 @@ function free_compute_wv_spline(ctrl::DconControl, equil::Equilibrium.PlasmaEqui psi_array = zeros(Float64, npsi + 1) wv_array = zeros(ComplexF64, npsi + 1, intr.numpert_total, intr.numpert_total) - for i in 1:npsi+1 + for i in 1:(npsi+1) # Space points evenly in q qi = qedge + (intr.qlim - qedge) * (i / npsi) @@ -272,7 +272,7 @@ function free_compute_wv_spline(ctrl::DconControl, equil::Equilibrium.PlasmaEqui end # Store block in full wv matrix - @views wv_array[i, (ipert_n-1)*intr.mpert+1 : ipert_n*intr.mpert, (ipert_n-1)*intr.mpert+1 : ipert_n*intr.mpert] .= wv_block + @views wv_array[i, ((ipert_n-1)*intr.mpert+1):(ipert_n*intr.mpert), ((ipert_n-1)*intr.mpert+1):(ipert_n*intr.mpert)] .= wv_block # Free VACUUM memory Vacuum.unset_dcon_params() diff --git a/src/Vacuum/Vacuum.jl b/src/Vacuum/Vacuum.jl index 7b77f9bf..fe40f0e1 100644 --- a/src/Vacuum/Vacuum.jl +++ b/src/Vacuum/Vacuum.jl @@ -322,6 +322,56 @@ function compute_vacuum_response(inputs::VacuumInput, wall_settings::WallShapeSe return wv, grri, xzpts end +function compute_vacuum_response_plasma(inputs::VacuumInput, wall_settings::WallShapeSettings) + + # Initialization and allocations + (; mtheta, mpert, n, kernelsign, force_wv_symmetry) = inputs + plasma_surf = initialize_plasma_surface(inputs) + grri = zeros(mtheta, 2 * mpert) + grad_greenfunction_mat = zeros(mtheta, mtheta) + greenfunction_temp = zeros(mtheta, mtheta) + + # Plasma–Plasma block + kernel_plasma!(grad_greenfunction_mat, greenfunction_temp, plasma_surf.x, plasma_surf.z, plasma_surf.x, plasma_surf.z, n) + + # Fourier transform plasma-plasma block + fourier_transform!(grri, greenfunction_temp, plasma_surf.cslth, 0, 0) + fourier_transform!(grri, greenfunction_temp, plasma_surf.snlth, 0, mpert) + + # Invert the vacuum response system of equations, eqs. 92-94ish of Chance 1997 + grri .= grad_greenfunction_mat \ grri + + # Perform inverse Fourier transforms to get response matrix components (eq. 115-118 of Chance 2007) + arr = zeros(mpert, mpert) + aii = zeros(mpert, mpert) + ari = zeros(mpert, mpert) + air = zeros(mpert, mpert) + fourier_inverse_transform!(arr, grri, plasma_surf.cslth, 0, 0) + fourier_inverse_transform!(aii, grri, plasma_surf.snlth, 0, mpert) + fourier_inverse_transform!(ari, grri, plasma_surf.snlth, 0, 0) + fourier_inverse_transform!(air, grri, plasma_surf.cslth, 0, mpert) + + # Final form of vacuum response matrix (eq. 114 of Chance 2007) + vacmat = arr .+ aii + vacmti = air .- ari + # Force symmetry of response matrix if desired + force_wv_symmetry && begin + for l1 in 1:mpert + for l2 in l1:mpert + vacmat[l1, l2] = 0.5 * (vacmat[l1, l2] + vacmat[l2, l1]) + vacmti[l1, l2] = 0.5 * (vacmti[l1, l2] - vacmti[l2, l1]) + end + end + end + wv = complex.(vacmat, vacmti) + + # Create xzpts array + xzpts = zeros(Float64, inputs.mtheta, 4) + @views xzpts[:, 1] .= plasma_surf.x + @views xzpts[:, 2] .= plasma_surf.z + return wv, grri, xzpts +end + """ compute_vacuum_field(inputs::VacuumInput, plasma_surf::PlasmaGeometry, wall::WallGeometry, Bn::Vector{<:Number}, R_grid::AbstractVector, Z_grid::AbstractVector) diff --git a/src/Vacuum/VacuumInternals.jl b/src/Vacuum/VacuumInternals.jl index c1295e1e..a7b36fa2 100644 --- a/src/Vacuum/VacuumInternals.jl +++ b/src/Vacuum/VacuumInternals.jl @@ -277,6 +277,133 @@ function kernel!( greenfunction_mat ./= 2π end +function kernel_plasma!( + grad_greenfunction_mat::Matrix{Float64}, + greenfunction_mat::Matrix{Float64}, + x_obspoints::Vector{Float64}, + z_obspoints::Vector{Float64}, + x_sourcepoints::Vector{Float64}, + z_sourcepoints::Vector{Float64}, + n::Int +) + + mtheta = length(x_obspoints) + dtheta = 2π / mtheta + theta_grid = range(; start=0, length=mtheta, step=dtheta) + + # Zero out greenfunction_mat at start of each kernel call (matches Fortran behavior) + fill!(greenfunction_mat, 0.0) + + # S₁ᵢ in Chance 1997, eq.(78) + log_correction_0=16.0*dtheta*(log(2*dtheta)-68.0/15.0)/15.0 + log_correction_1=128.0*dtheta*(log(2*dtheta)-8.0/15.0)/45.0 + log_correction_2=4.0*dtheta*(7.0*log(2*dtheta)-11.0/15.0)/45.0 + + # Used for Z'_θ and X'_θ in eq.(51) + spline_x = cubic_spline_interpolation(theta_grid, x_sourcepoints; extrapolation_bc=Interpolations.Periodic()) + spline_z = cubic_spline_interpolation(theta_grid, z_sourcepoints; extrapolation_bc=Interpolations.Periodic()) + dx_dtheta = [Interpolations.gradient(spline_x, t)[1] for t in theta_grid] + dz_dtheta = [Interpolations.gradient(spline_z, t)[1] for t in theta_grid] + + # Loop through observer points + for j in 1:mtheta + # Initialize variables + x_obs=x_obspoints[j] + z_obs=z_obspoints[j] + theta_obs=theta_grid[j] + grad_green_0 = 0.0 # simpson integral for coupling_0 (𝒥 ∇'𝒢⁰∇'ℒ) + # Workspace = view of appropriate row of grad_greenfunction_mat for this observer point + grad_green_work = @view(grad_greenfunction_mat[j, 1:mtheta]) + + # Perform Simpson integration for nonsingular source points (excludes j-1, j, j+1) + for i in 1:(mtheta-3) + # Get source point index (ic) and ensure it is in range [1, mtheta] + ic = i + j + 1 + if ic > mtheta + ic -= mtheta + end + x_source=x_sourcepoints[ic] + z_source=z_sourcepoints[ic] + + # G_n is 2pi𝒢ⁿ; coupling_n is 𝒥 ∇'𝒢ⁿ∇'ℒ; coupling_0 is 𝒥 ∇'𝒢ⁿ∇'ℒ for n=0 + G_n, coupling_n, coupling_0 = green(x_obs, z_obs, x_source, z_source, dx_dtheta[ic], dz_dtheta[ic], n) + + # Compute composite Simpson's 1/3 rule weight (https://en.wikipedia.org/wiki/Simpson%27s_rule#Composite_Simpson's_1/3_rule) + # Note we set to 4 for even/2 for odd since we index from 1 while the formula assumes indexing from 0 + endpoint = (i == 1)||(i == mtheta - 3) + wsimpson = (endpoint ? 1 : (iseven(i) ? 4 : 2)) * dtheta / 3 + + # Sum contributions to Green's function matrices using Simpson weight + grad_green_work[ic] += -1 * coupling_n * wsimpson + greenfunction_mat[j, ic] += G_n * wsimpson + grad_green_0 += coupling_0 * wsimpson + end + + # Perform Gaussian quadrature for singular points (source = obs point) + # Get indices of the singularity region ([j-2, j-1, j, j+1, j+2]) + js = mod.(j .+ ((mtheta-3):(mtheta+1)), mtheta) .+ 1 + # Integrate region of length 2 * dtheta on left (ilr = 1)/right (ilr = 2) of singularity + for ilr in [1, 2] + gauss_xleft = theta_obs + 2 * (ilr-2) * dtheta + gauss_xright = gauss_xleft + 2 * dtheta + gauss_xavg = (gauss_xright + gauss_xleft)/2 + theta_gauss = gauss_xavg .+ GAUSSIANPOINTS .* dtheta # tgaus is 8 point gauss points, since GAUSSIANPOINTS is for only [-1,1] + for ig in 1:8 # 8-point Gaussian quadrature + # Compute green function for this Gaussian point + theta_gauss0 = mod(theta_gauss[ig], 2π) + x_gauss = spline_x(theta_gauss0) + dx_dtheta_gauss = Interpolations.gradient(spline_x, theta_gauss0)[1] + z_gauss = spline_z(theta_gauss0) + dz_dtheta_gauss = Interpolations.gradient(spline_z, theta_gauss0)[1] + G_n, coupling_n, coupling_0 = green(x_obs, z_obs, x_gauss, z_gauss, dx_dtheta_gauss, dz_dtheta_gauss, n) + + # Add logarithm to G_n to analytically isolate the singularity (first type), Chance eq.(75) + G_n_nonsingular = G_n + log((theta_obs-theta_gauss[ig])^2)/x_obs + + # Redefine hardcoded Gaussian weights on the interval [-1, 1] to physical interval with length 2 * dtheta + wgauss = GAUSSIANWEIGHTS[ig] * dtheta + # Calculate p = θ/Δ = (θⱼ - θ')/Δ, 0 at observation point, ±1,±2 at other 5-point stencil nodes + pgauss=(theta_gauss[ig]-theta_obs)/dtheta + # Compute 5-point Lagrange basis polynomials at the Gauss point and multiply by quadrature weight + A0 = (pgauss^2-1)*(pgauss^2-4)/4.0 * wgauss + A1_plus = -(pgauss+1)*pgauss*(pgauss^2-4)/6.0 * wgauss + A1_minus = -(pgauss-1)*pgauss*(pgauss^2-4)/6.0 * wgauss + A2_plus = (pgauss^2-1)*pgauss*(pgauss+2)/24.0 * wgauss + A2_minus = (pgauss^2-1)*pgauss*(pgauss-2)/24.0 * wgauss + + # First type of singularity: 𝒢ⁿ, occurs plasma as source only (see RHS of Chance eqs. 26/27) + greenfunction_mat[j, js[1]] += G_n_nonsingular * A2_minus + greenfunction_mat[j, js[2]] += G_n_nonsingular * A1_minus + greenfunction_mat[j, js[3]] += G_n_nonsingular * A0 + greenfunction_mat[j, js[4]] += G_n_nonsingular * A1_plus + greenfunction_mat[j, js[5]] += G_n_nonsingular * A2_plus + + # Second type of singularity: 𝒦ⁿ + # Eq. 86: 𝒦ⁿαᵢ - δⱼᵢK⁰ (js[3] = j if iend=2) + grad_green_work[js[1]] += -1 * coupling_n * A2_minus + grad_green_work[js[2]] += -1 * coupling_n * A1_minus + grad_green_work[js[3]] += -1 * coupling_n * A0 + grad_green_work[js[4]] += -1 * coupling_n * A1_plus + grad_green_work[js[5]] += -1 * coupling_n * A2_plus + # Subtract off the diverging singular n=0 component + grad_green_work[j] -= -1 * coupling_0 * wgauss + end + end + + # Subtract regular integral component of δⱼᵢK⁰ in eq. 83 and add residue value in eq. 89/90 + grad_green_work[j] += grad_green_0 + 2.0 + + # Subtract off analytic singular integral from Chance eq.(75) if plasma-plasma block + greenfunction_mat[j, js[1]] -= log_correction_2 / x_obs + greenfunction_mat[j, js[2]] -= log_correction_1 / x_obs + greenfunction_mat[j, js[3]] -= log_correction_0 / x_obs + greenfunction_mat[j, js[4]] -= log_correction_1 / x_obs + greenfunction_mat[j, js[5]] -= log_correction_2 / x_obs + end + # Since we computed 2π𝒢, divide by 2π to get 𝒢 + greenfunction_mat ./= 2π +end + """ fourier_inverse_transform!(gll, gil, cs, m00, l00) diff --git a/src/Vacuum/VacuumStructs.jl b/src/Vacuum/VacuumStructs.jl index e26e4632..26e57ec1 100644 --- a/src/Vacuum/VacuumStructs.jl +++ b/src/Vacuum/VacuumStructs.jl @@ -5,18 +5,18 @@ Struct holding plasma boundary and mode data as provided from DCON namelist and # Fields -- `r::Vector{Float64}`: Plasma boundary R-coordinate as a function of poloidal angle -- `z::Vector{Float64}`: Plasma boundary Z-coordinate as a function of poloidal angle -- `delta::Vector{Float64}`: Toroidal angle offset: -dφ/qa; 0 for coordinate systems using machine angle (e.g., PEST basis) -- `mlow::Int`: Lower poloidal mode number for spectral representation -- `mhigh::Int`: Upper poloidal mode number for spectral representation -- `mpert::Int`: Number of perturbation modes (mhigh - mlow + 1) -- `n::Int`: The toroidal mode number -- `qa::Float64`: Safety factor at the plasma boundary -- `mtheta_eq::Int`: Number of poloidal angles in the input equilibrium boundary arrays -- `mtheta::Int`: Number of poloidal grid points for vacuum calculations -- `kernelsign::Float64`: Sign for kernel; +1 or -1, only ≠ 1 for mutual inductance calculations -- `force_wv_symmetry::Bool`: Boolean flag to enforce symmetry in the vacuum response matrix (set in dcon.toml) + - `r::Vector{Float64}`: Plasma boundary R-coordinate as a function of poloidal angle + - `z::Vector{Float64}`: Plasma boundary Z-coordinate as a function of poloidal angle + - `delta::Vector{Float64}`: Toroidal angle offset divided by qa (i.e., -ν/qa where ϕ = 2πζ + ν(ψ, θ)) at plasma surface + - `mlow::Int`: Lower poloidal mode number for spectral representation + - `mhigh::Int`: Upper poloidal mode number for spectral representation + - `mpert::Int`: Number of perturbation modes (mhigh - mlow + 1) + - `n::Int`: The toroidal mode number + - `qa::Float64`: Safety factor at the plasma boundary + - `mtheta_eq::Int`: Number of poloidal angles in the input equilibrium boundary arrays + - `mtheta::Int`: Number of poloidal grid points for vacuum calculations + - `kernelsign::Float64`: Sign for kernel; +1 or -1, only ≠ 1 for mutual inductance calculations + - `force_wv_symmetry::Bool`: Boolean flag to enforce symmetry in the vacuum response matrix (set in dcon.toml) """ @kwdef mutable struct VacuumInput r::Vector{Float64} = Float64[] @@ -36,23 +36,19 @@ end """ PlasmaGeometry -Struct holding plasma geometry data on the mtheta grid for vacuum calculations. +Struct holding plasma geometry data on the mtheta grid for vacuum calculations. Arrays are of length `mtheta`, where `mtheta` is the number of poloidal grid points and θ ∈ [0, 1). # Fields -- `x::Vector{Float64}`: Plasma surface R-coordinate -- `z::Vector{Float64}`: Plasma surface Z-coordinate -- `delta::Vector{Float64}`: Toroidal angle offset divided by qa (i.e., -ν/qa where ϕ = 2πζ + ν(ψ, θ)) at plasma surface -- `dx_dtheta::Vector{Float64}`: Derivative dR/dθ at plasma surface -- `dz_dtheta::Vector{Float64}`: Derivative dZ/dθ at plasma surface -- `cnqd::Vector{Float64}`: cos(n * qa * delta) at plasma surface -- `snqd::Vector{Float64}`: sin(n * qa * delta) at plasma surface -- `sinlt::Matrix{Float64}`: sin(l * θ) basis functions for poloidal modes at plasma surface -- `coslt::Matrix{Float64}`: cos(l * θ) basis functions for poloidal modes at plasma surface -- `snlth::Matrix{Float64}`: sin(l * θ + n * qa * delta) basis functions for poloidal modes at plasma surface -- `cslth::Matrix{Float64}`: cos(l * θ + n * qa * delta) basis functions for poloidal modes at plasma surface + - `x::Vector{Float64}`: Plasma surface R-coordinate + - `z::Vector{Float64}`: Plasma surface Z-coordinate + - `delta::Vector{Float64}`: Toroidal angle offset divided by qa (i.e., -ν/qa where ϕ = 2πζ + ν(ψ, θ)) at plasma surface + - `dx_dtheta::Vector{Float64}`: Derivative dR/dθ at plasma surface + - `dz_dtheta::Vector{Float64}`: Derivative dZ/dθ at plasma surface + - `snlth::Matrix{Float64}`: sin(lθ - nν) basis functions for poloidal modes at plasma surface + - `cslth::Matrix{Float64}`: cos(lθ - nν) basis functions for poloidal modes at plasma surface """ struct PlasmaGeometry x::Vector{Float64} @@ -60,10 +56,6 @@ struct PlasmaGeometry delta::Vector{Float64} dx_dtheta::Vector{Float64} dz_dtheta::Vector{Float64} - cnqd::Vector{Float64} - snqd::Vector{Float64} - sinlt::Matrix{Float64} - coslt::Matrix{Float64} snlth::Matrix{Float64} cslth::Matrix{Float64} end @@ -71,18 +63,18 @@ end """ WallGeometry -Struct holding wall geometry data for vacuum calculations. +Struct holding wall geometry data for vacuum calculations. Arrays are of length `mtheta`, where `mtheta` is the number of poloidal grid points and θ ∈ [0, 1). # Fields -- `nowall::Bool`: Boolean flag indicating if there is no wall -- `is_closed_toroidal::Bool`: Boolean flag indicating if the wall is a closed toroidal surface -- `x::Vector{Float64}`: Wall R-coordinates -- `z::Vector{Float64}`: Wall Z-coordinates -- `dx_dtheta::Vector{Float64}`: Derivative dR/dθ at wall -- `dz_dtheta::Vector{Float64}`: Derivative dZ/dθ at wall + - `nowall::Bool`: Boolean flag indicating if there is no wall + - `is_closed_toroidal::Bool`: Boolean flag indicating if the wall is a closed toroidal surface + - `x::Vector{Float64}`: Wall R-coordinates + - `z::Vector{Float64}`: Wall Z-coordinates + - `dx_dtheta::Vector{Float64}`: Derivative dR/dθ at wall + - `dz_dtheta::Vector{Float64}`: Derivative dZ/dθ at wall """ @kwdef struct WallGeometry nowall::Bool = true @@ -100,27 +92,29 @@ Struct containing input settings for vacuum wall geometry. # Fields -- `shape::String`: String selecting wall shape. Options are: - - `"nowall"`: No wall - - `"conformal"`: Wall conformal to plasma surface at distance `a` - - `"elliptical"`: Elliptical wall - - `"dee"`: Dee-shaped wall - - `"mod_dee"`: Modified Dee-shaped wall - - `"filepath"`: Custom wall shape from the file you specify -- `a::Float64`: Distance of wall from plasma in units of major radius (conformal), or shape parameter (others) -- `aw::Float64`: Half-thickness parameter for Dee-shaped walls -- `bw::Float64`: Elongation parameter for wall shapes -- `cw::Float64`: Offset of the center of the wall from the major radius -- `dw::Float64`: Triangularity parameter for wall shapes -- `tw::Float64`: Sharpness of the corners of the wall (try 0.05 as initial value) -- `equal_arc_wall::Bool`: Flag to enforce equal arc length distribution of nodes on the wall - (recommended unless wall is very close to plasma) + - `shape::String`: String selecting wall shape. Options are: + + + `"nowall"`: No wall + + `"conformal"`: Wall conformal to plasma surface at distance `a` + + `"elliptical"`: Elliptical wall + + `"dee"`: Dee-shaped wall + + `"mod_dee"`: Modified Dee-shaped wall + + `"filepath"`: Custom wall shape from the file you specify + + - `a::Float64`: Distance of wall from plasma in units of major radius (conformal), or shape parameter (others) + - `aw::Float64`: Half-thickness parameter for Dee-shaped walls + - `bw::Float64`: Elongation parameter for wall shapes + - `cw::Float64`: Offset of the center of the wall from the major radius + - `dw::Float64`: Triangularity parameter for wall shapes + - `tw::Float64`: Sharpness of the corners of the wall (try 0.05 as initial value) + - `equal_arc_wall::Bool`: Flag to enforce equal arc length distribution of nodes on the wall + (recommended unless wall is very close to plasma) """ -@kwdef struct WallShapeSettings +@kwdef struct WallShapeSettings # Core shape selection shape::String = "nowall" - + # Standard geometric parameters for Dee/Mod-Dee a::Float64 = 0.3 aw::Float64 = 0.05 @@ -128,7 +122,7 @@ Struct containing input settings for vacuum wall geometry. cw::Float64 = 0.0 dw::Float64 = 0.5 tw::Float64 = 0.05 - + # Algorithmic options equal_arc_wall::Bool = true end @@ -136,26 +130,26 @@ end """ initialize_plasma_surface(inputs::VacuumInput) -> PlasmaGeometry -Initialize the plasma surface geometry based on the provided vacuum inputs. +Initialize the plasma surface geometry based on the provided vacuum inputs. -This function performs functionality from `readahg`, `arrays`, and `funint` in the +This function performs functionality from `readahg`, `arrays`, and `funint` in the original Fortran VACUUM code. It returns a `PlasmaGeometry` struct containing the necessary plasma surface data for vacuum calculations. # Process -1. Interpolate the input plasma boundary arrays onto the mtheta grid -2. Compute derivatives of the plasma boundary with respect to poloidal angle θ - using periodic cubic spline differentiation -3. Compute trigonometric basis functions needed for Fourier calculations + 1. Interpolate the input plasma boundary arrays onto the mtheta grid + 2. Compute derivatives of the plasma boundary with respect to poloidal angle θ + using periodic cubic spline differentiation + 3. Compute trigonometric basis functions needed for Fourier calculations # Arguments -- `inputs::VacuumInput`: Struct containing plasma boundary data and calculation parameters + - `inputs::VacuumInput`: Struct containing plasma boundary data and calculation parameters # Returns -- `PlasmaGeometry`: Struct containing plasma surface coordinates, derivatives, and basis functions + - `PlasmaGeometry`: Struct containing plasma surface coordinates, derivatives, and basis functions """ function initialize_plasma_surface(inputs::VacuumInput) @@ -166,22 +160,16 @@ function initialize_plasma_surface(inputs::VacuumInput) delta = interp_to_new_grid(inputs.delta, mtheta) # Plasma boundary theta derivative (this is semi-working) # All of these arrays are of length mth with θ = [0, 1) - theta_grid = range(start=0, length=mtheta, step=2π/mtheta) + theta_grid = range(; start=0, length=mtheta, step=2π/mtheta) dx_plasma_dtheta = periodic_cubic_deriv(theta_grid, x_plasma) dz_plasma_dtheta = periodic_cubic_deriv(theta_grid, z_plasma) # Trigonometric basis arrays # Pre-allocate output arrays - cos_nqdelta = zeros(mtheta) - sin_nqdelta = zeros(mtheta) - sin_mstheta = zeros(mtheta, inputs.mpert) - cos_mstheta = zeros(mtheta, inputs.mpert) sin_mstheta_arg = zeros(mtheta, inputs.mpert) cos_mstheta_arg = zeros(mtheta, inputs.mpert) # Calculate n*q*delta phase term - nqdelta = inputs.n .* inputs.qa .* delta - cos_nqdelta .= cos.(nqdelta) - sin_nqdelta .= sin.(nqdelta) + nqdelta = inputs.n .* inputs.qa .* delta # = -n * ν # Fuse loop for trigonometric basis functions to improve cache efficiency # and avoid intermediate array allocations. @@ -192,9 +180,6 @@ function initialize_plasma_surface(inputs::VacuumInput) for i in 1:mtheta m_theta = theta_grid[i] * mode_val nqdelta_val = nqdelta[i] - - cos_mstheta[i, l] = cos(m_theta) - sin_mstheta[i, l] = sin(m_theta) cos_mstheta_arg[i, l] = cos(m_theta + nqdelta_val) sin_mstheta_arg[i, l] = sin(m_theta + nqdelta_val) end @@ -206,10 +191,6 @@ function initialize_plasma_surface(inputs::VacuumInput) delta, dx_plasma_dtheta, dz_plasma_dtheta, - cos_nqdelta, - sin_nqdelta, - sin_mstheta, - cos_mstheta, sin_mstheta_arg, cos_mstheta_arg ) @@ -221,36 +202,36 @@ end """ initialize_wall(inputs::VacuumInput, plasma_surf::PlasmaGeometry, wall_settings::WallShapeSettings) -> WallGeometry -Initialize the wall geometry based on the provided vacuum inputs and wall shape settings. +Initialize the wall geometry based on the provided vacuum inputs and wall shape settings. -This performs functionality similar to portions of the `arrays` function in the original -Fortran VACUUM code. It returns a `WallGeometry` struct containing the necessary wall +This performs functionality similar to portions of the `arrays` function in the original +Fortran VACUUM code. It returns a `WallGeometry` struct containing the necessary wall surface data for vacuum calculations. # Arguments -- `inputs::VacuumInput`: Struct containing vacuum calculation parameters -- `plasma_surf::PlasmaGeometry`: Struct with plasma surface geometry (used for reference) -- `wall_settings::WallShapeSettings`: Struct specifying wall shape and parameters + - `inputs::VacuumInput`: Struct containing vacuum calculation parameters + - `plasma_surf::PlasmaGeometry`: Struct with plasma surface geometry (used for reference) + - `wall_settings::WallShapeSettings`: Struct specifying wall shape and parameters # Returns -- `WallGeometry`: Struct containing wall surface coordinates and derivatives + - `WallGeometry`: Struct containing wall surface coordinates and derivatives # Notes -- Supports multiple wall shapes: nowall, conformal, elliptical, dee, mod_dee, from_file -- Optionally redistributes wall points to equal arc length spacing if `equal_arc_wall=true` + - Supports multiple wall shapes: nowall, conformal, elliptical, dee, mod_dee, from_file + - Optionally redistributes wall points to equal arc length spacing if `equal_arc_wall=true` """ function initialize_wall(inputs::VacuumInput, plasma_surf::PlasmaGeometry, wall_settings::WallShapeSettings) - + # Basic wall flags nowall = wall_settings.shape == "nowall" is_closed_toroidal = true - # All of these arrays are of length mtheta with θ = [0, 1) + # All of these arrays are of length mtheta with θ = [0, 1) mtheta = inputs.mtheta - + # Get wall shape from form_wall # Plasma surface coordinates x_plasma = plasma_surf.x @@ -258,7 +239,7 @@ function initialize_wall(inputs::VacuumInput, plasma_surf::PlasmaGeometry, wall_ # Output wall coordinate arrays x_wall = zeros(Float64, mtheta) - z_wall = zeros(Float64, mtheta) + z_wall = zeros(Float64, mtheta) # Common geometric parameters xmin = minimum(x_plasma) @@ -268,7 +249,7 @@ function initialize_wall(inputs::VacuumInput, plasma_surf::PlasmaGeometry, wall_ r_minor = 0.5 * (xmax - xmin) r_major = 0.5 * (xmax + xmin) - + # Destructuring settings for readability (; aw, bw, cw, dw, tw, a) = wall_settings wcentr = 0.0 # Initialize @@ -277,15 +258,15 @@ function initialize_wall(inputs::VacuumInput, plasma_surf::PlasmaGeometry, wall_ @info "Using no wall" elseif wall_settings.shape == "conformal" dx = a * r_minor - @info "Calculating conformal wall shape $((@sprintf "%.2e" dx)) m from plasma surface." + @info "Calculating conformal wall shape $((@sprintf "%.2e" dx)) m from plasma surface." wcentr = r_major - centerstack_min = min(0.1, 0.1 * minimum(x_plasma)) # Avoid wall crossing R=0 axis + centerstack_min = min(0.1, 0.1 * minimum(x_plasma)) # Avoid wall crossing R=0 axis for i in 1:mtheta j = mod1(i - 1, mtheta) k = mod1(i + 1, mtheta) # Normal vector calculation alph = atan(x_plasma[k] - x_plasma[j], z_plasma[j] - z_plasma[k]) - x_wall[i] = max(centerstack_min , x_plasma[i] + a * r_minor * cos(alph)) + x_wall[i] = max(centerstack_min, x_plasma[i] + a * r_minor * cos(alph)) z_wall[i] = z_plasma[i] + a * r_minor * sin(alph) end @@ -295,9 +276,9 @@ function initialize_wall(inputs::VacuumInput, plasma_surf::PlasmaGeometry, wall_ zrad = 0.5 * (zmax - zmin) zh = sqrt(abs(zrad^2 - r_minor^2)) - zmuw = log((a/zh) + sqrt((a/zh)^2 + 1)) - bw_eff = (zh * cosh(zmuw)) / a - + zmuw = log((a/zh) + sqrt((a/zh)^2 + 1)) + bw_eff = (zh * cosh(zmuw)) / a + for i in 1:mtheta the = (i - 1) * (2π / mtheta) x_wall[i] = r_major + a * cos(the) @@ -358,7 +339,7 @@ function initialize_wall(inputs::VacuumInput, plasma_surf::PlasmaGeometry, wall_ dz_dtheta = only.(Interpolations.gradient.(Ref(fz_of_theta), theta_grid)) else # used regular theta grid spacing to build wall - theta_grid = range(0, stop=2π, length=mtheta + 1)[1:end-1] # length mtheta without endpoint + theta_grid = range(0; stop=2π, length=mtheta + 1)[1:(end-1)] # length mtheta without endpoint dx_dtheta = periodic_cubic_deriv(theta_grid, x_wall) dz_dtheta = periodic_cubic_deriv(theta_grid, z_wall) end @@ -377,67 +358,67 @@ end """ distribute_to_equal_arc_grid(xin, zin, mw1) -Perform arc length re-parameterization of a 2D curve. +Perform arc length re-parameterization of a 2D curve. Takes an input curve defined by `(xin, zin)` coordinates and re-samples it such that the new points `(xout, zout)` are equally spaced in arc length along the curve. # Arguments -- `xin::Vector{Float64}`: Array of x-coordinates of the input curve -- `zin::Vector{Float64}`: Array of z-coordinates of the input curve -- `mw1::Int`: Number of points in the input and output curves + - `xin::Vector{Float64}`: Array of x-coordinates of the input curve + - `zin::Vector{Float64}`: Array of z-coordinates of the input curve + - `mw1::Int`: Number of points in the input and output curves # Returns -- `xout::Vector{Float64}`: Array of x-coordinates of the arc-length re-parameterized curve -- `zout::Vector{Float64}`: Array of z-coordinates of the arc-length re-parameterized curve -- `ell::Vector{Float64}`: Array of cumulative arc lengths for the input curve -- `thgr::Vector{Float64}`: Array of re-parameterized 'theta' values corresponding to equal arc lengths -- `thlag::Vector{Float64}`: Array of normalized 'theta' values for the input curve (0 to 1) + - `xout::Vector{Float64}`: Array of x-coordinates of the arc-length re-parameterized curve + - `zout::Vector{Float64}`: Array of z-coordinates of the arc-length re-parameterized curve + - `ell::Vector{Float64}`: Array of cumulative arc lengths for the input curve + - `thgr::Vector{Float64}`: Array of re-parameterized 'theta' values corresponding to equal arc lengths + - `thlag::Vector{Float64}`: Array of normalized 'theta' values for the input curve (0 to 1) # Notes -- Uses Lagrange interpolation for calculating arc length and resampling -- Ensures uniform spacing in arc length for improved numerical stability + - Uses Lagrange interpolation for calculating arc length and resampling + - Ensures uniform spacing in arc length for improved numerical stability """ function distribute_to_equal_arc_grid(xin::Vector{Float64}, zin::Vector{Float64}, mtheta::Int) # Temporary arrays for interpolation and arc-length calculation theta_in = zeros(Float64, mtheta) # Normalized input parameter [0, 1) - theta_out = zeros(Float64, mtheta) # New parameter distribution for equal spacing - xout = zeros(Float64, mtheta) # Uniformly spaced R-coordinates - zout = zeros(Float64, mtheta) # Uniformly spaced Z-coordinates + theta_out = zeros(Float64, mtheta) # New parameter distribution for equal spacing + xout = zeros(Float64, mtheta) # Uniformly spaced R-coordinates + zout = zeros(Float64, mtheta) # Uniformly spaced Z-coordinates # Define initial normalized parameter theta_in dt = 1.0 / mtheta - theta_in .= range(start=0, length=mtheta, step=dt) # θ ∈ [0, 1) + theta_in .= range(; start=0, length=mtheta, step=dt) # θ ∈ [0, 1) # we need a closed loop for arc length calculation mtheta1 = mtheta + 1 xin1 = vcat(xin, xin[1]) zin1 = vcat(zin, zin[1]) theta_in1 = vcat(theta_in, [1.0]) - ell = zeros(Float64, mtheta1) # Cumulative arc length of closed loop + ell = zeros(Float64, mtheta1) # Cumulative arc length of closed loop # Calculate cumulative arc length using numerical integration # We use a mid-point derivative approximation to find the path length for iw in 2:mtheta1 # Evaluate derivative at the midpoint of the interval - theta = (theta_in1[iw] + theta_in1[iw - 1]) / 2.0 - + theta = (theta_in1[iw] + theta_in1[iw-1]) / 2.0 + # Calculate dx/dt and dz/dt using Lagrange interpolation (order 3) _, d_xin = lagrange1d(theta_in1, xin1, mtheta1, 3, theta, 1) _, d_zin = lagrange1d(theta_in1, zin1, mtheta1, 3, theta, 1) # Instantaneous speed (ds/dt) ds_dt = sqrt(d_xin^2 + d_zin^2) - + # Accumulate length: ds = (ds/dt) * dt - ell[iw] = ell[iw - 1] + ds_dt * dt + ell[iw] = ell[iw-1] + ds_dt * dt end # Re-parameterize based on equal arc-length segments - ell_targets = collect(range(0, step=ell[end]/mtheta, length=mtheta)) # [0, Length) for open loop result + ell_targets = collect(range(0; step=ell[end]/mtheta, length=mtheta)) # [0, Length) for open loop result for i in 2:mtheta # Find the value of 'theta_in' that corresponds to the target arc length 's' f_th, _ = lagrange1d(ell, theta_in1, mtheta1, 3, ell_targets[i], 0) @@ -452,6 +433,6 @@ function distribute_to_equal_arc_grid(xin::Vector{Float64}, zin::Vector{Float64} xout[i] = f_x zout[i] = f_z end - + return xout, zout, ell, theta_out, theta_in -end \ No newline at end of file +end From d12d49a43edbc292435f509f8b35fa34f3dc347a Mon Sep 17 00:00:00 2001 From: Jake Halpern Date: Tue, 20 Jan 2026 11:56:04 -0500 Subject: [PATCH 02/31] VACUUM - WIP - current state of 3D code --- deps/build_helpers.jl | 40 +++++++- src/BIEST.jl | 64 +++++++++++++ src/DCON/DconStructs.jl | 65 ++++++------- src/DCON/Free.jl | 50 +++++----- src/JPEC.jl | 8 +- src/Vacuum/Vacuum.jl | 138 ++++++++++++++++++++++------ src/Vacuum/VacuumInternals.jl | 166 ++++++++++++++-------------------- src/Vacuum/VacuumStructs.jl | 64 +++++++++---- 8 files changed, 393 insertions(+), 202 deletions(-) create mode 100644 src/BIEST.jl diff --git a/deps/build_helpers.jl b/deps/build_helpers.jl index 180e8c23..19487ddb 100644 --- a/deps/build_helpers.jl +++ b/deps/build_helpers.jl @@ -6,7 +6,7 @@ const parent_dir = joinpath(@__DIR__, "..", "src") -export build_spline_fortran, build_vacuum_fortran +export build_spline_fortran, build_vacuum_fortran, build_biest function build_fortran() ENV["FC"] = get(ENV, "FC", "gfortran") @@ -34,7 +34,8 @@ function build_fortran() results = [ # build_jpec_fortran() add here build_spline_fortran(), - build_vacuum_fortran() + build_vacuum_fortran(), + build_biest() ] if all(results) @@ -70,6 +71,40 @@ function build_vacuum_fortran() end +function build_biest() + dir = joinpath(parent_dir, "BIEST") + try + # Build BIEST library + run(pipeline(`make -C $dir`)) + @info "BIEST compiled successfully" + + # Copy the compiled library to the lib directory + lib_dir = joinpath(parent_dir, "BIEST", "lib") + if !isdir(lib_dir) + mkdir(lib_dir) + end + + # Look for the compiled shared library and copy it + lib_file = joinpath(dir, "libbiest.so") + if Sys.isapple() + lib_file = joinpath(dir, "libbiest.dylib") + end + + if isfile(lib_file) + cp(lib_file, joinpath(lib_dir, basename(lib_file)); force=true) + @info "BIEST library copied to $lib_dir" + end + + return true + catch e + @warn "BIEST build may have issues (this is not critical): $e" + @warn "BIEST functionality from Julia will not be available unless the library is compiled separately" + @warn "To manually compile BIEST, run: cd src/BIEST && make" + return false + end + +end + # Example for including more fortran: # # function build_jpec_fortran() @@ -83,4 +118,3 @@ end # return false # end # end - diff --git a/src/BIEST.jl b/src/BIEST.jl new file mode 100644 index 00000000..4425c66e --- /dev/null +++ b/src/BIEST.jl @@ -0,0 +1,64 @@ +""" + BIEST + +Module for interfacing with BIEST (Boundary Integral Equation Solver for Toroidal surfaces). +Provides access to boundary integral operator functionality for 3D vacuum calculations. +""" +module BIEST + +# Determine the library file based on OS +const lib_base_path = joinpath(@__DIR__, "BIEST", "lib") +const libbiest = if Sys.isapple() + joinpath(lib_base_path, "libbiest.dylib") +else + joinpath(lib_base_path, "libbiest.so") +end + +""" + compute_green_matrices(R::Vector{Float64}, Z::Vector{Float64}, Nt::Integer) -> (G::Matrix{Float64}, K::Matrix{Float64}) + +Compute Green's function matrices for Laplace single-layer and double-layer kernels on an axisymmetric toroidal surface. + +Takes a 2D poloidal contour defined by `R` (major radius) and `Z` (height) coordinates, and extends it +toroidally using `Nt` toroidal grid points to create a 3D axisymmetric surface with `Np*Nt` total points +(where `Np = length(R)`). + +Computes two matrices `G` and `K` of size `(Np*Nt) × (Np*Nt)` where: + + - `G[i,j]`: Single-layer kernel value between 3D points `x_i` and `x_j` on the surface + - `K[i,j]`: Double-layer kernel value between 3D points `x_i` and `x_j` on the surface + +Uses the MatrixExtractor::test() method from extract_matrix.hpp to build the explicit matrix +representation of the boundary integral operators. + +# Arguments + + - `G::Matrix{Float64}`: Single-layer kernel matrix, size `(Np*Nt, Np*Nt)` + - `K::Matrix{Float64}`: Double-layer kernel matrix, size `(Np*Nt, Np*Nt)` + - `R::Vector{Float64}`: Poloidal major radius coordinates (length Np) + - `Z::Vector{Float64}`: Poloidal height coordinates (length Np) + - `Nt::Integer`: Number of toroidal grid points + +# Note + +Requires the BIEST library to be compiled. Build it with: + +```bash +cd src/BIEST && make +``` + +For large grids (e.g., `Nt*Np > 200`), this computation can be expensive as it requires +evaluating the boundary integral operator `Nt*Np` times to build each matrix column. +""" +function compute_green_matrices!(G::Matrix{Float64}, K::Matrix{Float64}, R::Vector{Float64}, Z::Vector{Float64}, ν::Vector{Float64}, Nt::Integer) + isfile(libbiest) || error("BIEST library not found at $libbiest. Build it with: cd src/BIEST && make") + + # Call C++ wrapper which uses MatrixExtractor::test() + ccall((:biest_compute_green_matrices, libbiest), Cvoid, + (Ptr{Cdouble}, Ptr{Cdouble}, Ptr{Cdouble}, Cint, Cint, Ptr{Cdouble}, Ptr{Cdouble}), + R, Z, ν, Cint(length(R)), Cint(Nt), G, K) +end + +export compute_green_matrices! + +end # module BIEST diff --git a/src/DCON/DconStructs.jl b/src/DCON/DconStructs.jl index e3411c39..7ec58dbf 100644 --- a/src/DCON/DconStructs.jl +++ b/src/DCON/DconStructs.jl @@ -180,6 +180,7 @@ A mutable struct containing control parameters for stability analysis, set by th mer_flag::Bool = false fft_flag::Bool = false mthvac::Int = 480 + nzvac::Int = 64 sing_start::Int = 0 nn_low::Int = 0 nn_high::Int = 0 @@ -257,17 +258,17 @@ Populated in `Free.jl`. ## Fields -- `mthvac::Int` - Number of vacuum poloidal grid points (corresponds to `mtheta` in VacuumInput) -- `mpert::Int` - Number of poloidal modes -- `numpert_total::Int` - Total number of modes (mpert × npert) -- `wt::Array{ComplexF64, 2}` - Toroidal vacuum response matrix (numpert_total × numpert_total) -- `wt0::Array{ComplexF64, 2}` - Reference toroidal vacuum matrix (numpert_total × numpert_total) -- `wv::Array{ComplexF64, 2}` - Vacuum energy matrix (numpert_total × numpert_total) -- `ep::Vector{ComplexF64}` - Plasma eigenvalues -- `ev::Vector{ComplexF64}` - Vacuum eigenvalues -- `et::Vector{ComplexF64}` - Total eigenvalues of plasma + vacuum -- `grri::Array{Float64, 2}` - Green's function radial integrals (2×mthvac × 2×mpert) -- `xzpts::Array{Float64, 2}` - Coordinate points [R_plasma, Z_plasma, R_wall, Z_wall] (mthvac × 4) + - `mthvac::Int` - Number of vacuum poloidal grid points (corresponds to `mtheta` in VacuumInput) + - `mpert::Int` - Number of poloidal modes + - `numpert_total::Int` - Total number of modes (mpert × npert) + - `wt::Array{ComplexF64, 2}` - Toroidal vacuum response matrix (numpert_total × numpert_total) + - `wt0::Array{ComplexF64, 2}` - Reference toroidal vacuum matrix (numpert_total × numpert_total) + - `wv::Array{ComplexF64, 2}` - Vacuum energy matrix (numpert_total × numpert_total) + - `ep::Vector{ComplexF64}` - Plasma eigenvalues + - `ev::Vector{ComplexF64}` - Vacuum eigenvalues + - `et::Vector{ComplexF64}` - Total eigenvalues of plasma + vacuum + - `grri::Array{Float64, 2}` - Green's function radial integrals (2×mthvac × 2×mpert) + - `xzpts::Array{Float64, 2}` - Coordinate points [R_plasma, Z_plasma, R_wall, Z_wall] (mthvac × 4) """ @kwdef mutable struct VacuumData mthvac::Int @@ -418,23 +419,23 @@ A struct to hold all inputs required for vacuum benchmarking between Fortran and ## Fields -- `wv_block::Matrix{ComplexF64}` - Vacuum response matrix block -- `mpert::Int` - Number of poloidal modes -- `mtheta_eq::Int` - Number of poloidal grid points in input equilibrium (corresponds to `mtheta_eq` in VacuumInput) -- `mthvac::Int` - Number of poloidal grid points in vacuum calculations (corresponds to `mtheta` in VacuumInput) -- `complex_flag::Bool` - Flag indicating if complex arithmetic is used -- `kernelsign::Float64` - Sign of the kernel for vacuum calculation -- `wall_flag::Bool` - Flag indicating presence of wall -- `farwall_flag::Bool` - Flag indicating presence of far wall -- `grri::Matrix{Float64}` - Green's function response matrix -- `xzpts::Matrix{Float64}` - Coordinate points on plasma boundary [R, Z] -- `ahg_file::String` - Filename for AHG data -- `dir_path::String` - Directory path for input/output files -- `vac_inputs::Vacuum.VacuumInput` - VacuumInput struct for Julia vacuum code -- `wall_settings::Vacuum.WallShapeSettings` - Wall shape settings -- `n::Int` - Toroidal mode number -- `ipert_n::Int` - Index of perturbed toroidal mode -- `psifac::Float64` - Normalized flux coordinate + - `wv_block::Matrix{ComplexF64}` - Vacuum response matrix block + - `mpert::Int` - Number of poloidal modes + - `mtheta_eq::Int` - Number of poloidal grid points in input equilibrium (corresponds to `mtheta_eq` in VacuumInput) + - `mthvac::Int` - Number of poloidal grid points in vacuum calculations (corresponds to `mtheta` in VacuumInput) + - `complex_flag::Bool` - Flag indicating if complex arithmetic is used + - `kernelsign::Float64` - Sign of the kernel for vacuum calculation + - `wall_flag::Bool` - Flag indicating presence of wall + - `farwall_flag::Bool` - Flag indicating presence of far wall + - `grri::Matrix{Float64}` - Green's function response matrix + - `xzpts::Matrix{Float64}` - Coordinate points on plasma boundary [R, Z] + - `ahg_file::String` - Filename for AHG data + - `dir_path::String` - Directory path for input/output files + - `vac_inputs::Vacuum.VacuumInput` - VacuumInput struct for Julia vacuum code + - `wall_settings::Vacuum.WallShapeSettings` - Wall shape settings + - `n::Int` - Toroidal mode number + - `ipert_n::Int` - Index of perturbed toroidal mode + - `psifac::Float64` - Normalized flux coordinate """ @kwdef struct VacuumBenchmarkInputs # Vacuum computation parameters @@ -450,15 +451,15 @@ A struct to hold all inputs required for vacuum benchmarking between Fortran and xzpts::Matrix{Float64} ahg_file::String dir_path::String - + # VacuumInput struct for Julia code vac_inputs::Vacuum.VacuumInput - + # Wall settings wall_settings::Vacuum.WallShapeSettings - + # Additional context n::Int ipert_n::Int psifac::Float64 -end \ No newline at end of file +end diff --git a/src/DCON/Free.jl b/src/DCON/Free.jl index 438f1a6c..89e7a2de 100644 --- a/src/DCON/Free.jl +++ b/src/DCON/Free.jl @@ -48,7 +48,7 @@ function free_run!(odet::OdeState, ctrl::DconControl, equil::Equilibrium.PlasmaE farwall_flag = wall_settings.shape == "nowall" ? true : false # Output data for unit testing and benchmarking - if intr.debug_settings.output_benchmark_data + if true #intr.debug_settings.output_benchmark_data @info "Outputting top level vacuum debug data for n = $n" benchmark_inputs = VacuumBenchmarkInputs( wv_block, intr.mpert, equil.config.control.mtheta, ctrl.mthvac, @@ -57,11 +57,11 @@ function free_run!(odet::OdeState, ctrl::DconControl, equil::Equilibrium.PlasmaE vac_inputs, wall_settings, n, ipert_n, intr.psilim ) - @save "vacuum_response_inputs.jld2" benchmark_inputs + @save intr.dir_path*"/benchmark_inputs.jld2" benchmark_inputs end # Compute vacuum energy matrix - wv_block, vac.grri, vac.xzpts = Vacuum.compute_vacuum_response_plasma(vac_inputs, wall_settings) + wv_block, vac.grri, vac.xzpts = Vacuum.compute_vacuum_response_3D(vac_inputs) # Scale vacuum matrix by singfac = (m - n*qlim) singfac = collect(intr.mlow:intr.mhigh) .- (n * intr.qlim) @@ -148,48 +148,52 @@ Performs the same function as `free_write_msc` in the Fortran code, except we wi ### Arguments - - `psifac`: Flux surface value at the plasma boundary (Float64) + - `ψ`: Flux surface value at the plasma boundary (Float64) - `n`: Toroidal mode number (Int) - `equil`: Plasma equilibrium data (Equilibrium.PlasmaEquilibrium) - `intr`: Internal DCON parameters (DconInternal) - `ctrl`: DCON control parameters (DconControl) """ -function set_vacuum_inputs(psifac::Float64, n::Int, equil::Equilibrium.PlasmaEquilibrium, intr::DconInternal, ctrl::DconControl) +function set_vacuum_inputs(ψ::Float64, n::Int, equil::Equilibrium.PlasmaEquilibrium, intr::DconInternal, ctrl::DconControl) # Allocations - theta_norm = Vector(equil.rzphi.ys) mtheta = equil.config.control.mtheta - angle = zeros(Float64, mtheta + 1) - r = zeros(Float64, mtheta + 1) - z = zeros(Float64, mtheta + 1) + θ_SFL = zeros(Float64, mtheta + 1) + R = zeros(Float64, mtheta + 1) + Z = zeros(Float64, mtheta + 1) ν = zeros(Float64, mtheta + 1) - rfac = zeros(Float64, mtheta + 1) - - # Compute output - for itheta in 1:(mtheta+1) - f = Spl.bicube_eval!(equil.rzphi, psifac, theta_norm[itheta]) - rfac[itheta] = sqrt(f[1]) - angle[itheta] = 2π * (theta_norm[itheta] + f[2]) - ν[itheta] = f[3] + r_minor = zeros(Float64, mtheta + 1) + + # Compute geometric quantities on plasma boundary + for (i, θ) in enumerate(equil.rzphi.ys) + f = Spl.bicube_eval!(equil.rzphi, ψ, θ) + r_minor[i] = sqrt(f[1]) + θ_SFL[i] = 2π * (θ + f[2]) # f[2] = λ(ψ, θ) / 2π + ν[i] = f[3] end - r .= equil.ro .+ rfac .* cos.(angle) - z .= equil.zo .+ rfac .* sin.(angle) + # Compute R and Z on straight-fieldline θ grid + R .= equil.ro .+ r_minor .* cos.(θ_SFL) + Z .= equil.zo .+ r_minor .* sin.(θ_SFL) - # Invert values for n < 0 + # Invert values for n < 0 # TODO: move this to VACUUM? + # This is here because the only thing that changes in 2D for n<0 is (mθ - nν) + # since Gⁿ = G⁻ⁿ and Kⁿ = K⁻ⁿ? Confirm later and generalize to 3D if n < 0 ν .= -ν n = -n end - # For input to the Julia vacuum code return Vacuum.VacuumInput(; - r=reverse(r), - z=reverse(z), + r=reverse(R), + z=reverse(Z), ν=reverse(ν), mlow=intr.mlow, mpert=intr.mpert, + nlow=intr.nlow, + npert=intr.npert, n=n, mtheta=ctrl.mthvac, + nzeta=ctrl.nzvac, force_wv_symmetry=ctrl.force_wv_symmetry ) end diff --git a/src/JPEC.jl b/src/JPEC.jl index db61868d..7eba3edd 100644 --- a/src/JPEC.jl +++ b/src/JPEC.jl @@ -9,6 +9,10 @@ include("Equilibrium/Equilibrium.jl") import .Equilibrium as Equilibrium export Equilibrium +include("BIEST.jl") +import .BIEST as BIEST +export BIEST + include("Vacuum/Vacuum.jl") import .Vacuum as Vacuum export Vacuum @@ -18,6 +22,6 @@ import .DCON as DCON export DCON include(joinpath(@__DIR__, "..", "deps", "build_helpers.jl")) -export build_fortran, build_spline_fortran, build_vacuum_fortran +export build_fortran, build_spline_fortran, build_vacuum_fortran, build_biest -end # module JPEC \ No newline at end of file +end # module JPEC diff --git a/src/Vacuum/Vacuum.jl b/src/Vacuum/Vacuum.jl index b89fce97..266806ed 100644 --- a/src/Vacuum/Vacuum.jl +++ b/src/Vacuum/Vacuum.jl @@ -1,11 +1,13 @@ module Vacuum using TOML, Interpolations, SpecialFunctions, LinearAlgebra, Printf +using StaticArrays +using ..BIEST include("VacuumStructs.jl") include("VacuumInternals.jl") -export mscvac, set_dcon_params, VacuumInput, compute_vacuum_response +export mscvac, set_dcon_params, VacuumInput, compute_vacuum_response, compute_vacuum_response_3D export compute_vacuum_field export kernel! export WallShapeSettings @@ -239,8 +241,8 @@ function compute_vacuum_response(inputs::VacuumInput, wall_settings::WallShapeSe kernel!(grad_greenfunction_mat, greenfunction_temp, plasma_surf.x, plasma_surf.z, plasma_surf.x, plasma_surf.z, j1, j2, n) # Fourier transform plasma-plasma block - fourier_transform!(grri, greenfunction_temp, plasma_surf.cos_ln_basis, 0, 0) - fourier_transform!(grri, greenfunction_temp, plasma_surf.sin_ln_basis, 0, mpert) + fourier_transform!(grri, greenfunction_temp, plasma_surf.cos_mn_basis, 0, 0) + fourier_transform!(grri, greenfunction_temp, plasma_surf.sin_mn_basis, 0, mpert) !wall.nowall && begin # Plasma–Wall block @@ -256,8 +258,8 @@ function compute_vacuum_response(inputs::VacuumInput, wall_settings::WallShapeSe kernel!(grad_greenfunction_mat, greenfunction_temp, wall.x, wall.z, plasma_surf.x, plasma_surf.z, j1, j2, n) # Fourier transform wall blocks into grri - fourier_transform!(grri, greenfunction_temp, plasma_surf.cos_ln_basis, mtheta, 0) - fourier_transform!(grri, greenfunction_temp, plasma_surf.sin_ln_basis, mtheta, mpert) + fourier_transform!(grri, greenfunction_temp, plasma_surf.cos_mn_basis, mtheta, 0) + fourier_transform!(grri, greenfunction_temp, plasma_surf.sin_mn_basis, mtheta, mpert) end # Add cn0 to make grdgre nonsingular for n=0 modes @@ -294,10 +296,10 @@ function compute_vacuum_response(inputs::VacuumInput, wall_settings::WallShapeSe aii = zeros(mpert, mpert) ari = zeros(mpert, mpert) air = zeros(mpert, mpert) - fourier_inverse_transform!(arr, grri, plasma_surf.cos_ln_basis, 0, 0) - fourier_inverse_transform!(aii, grri, plasma_surf.sin_ln_basis, 0, mpert) - fourier_inverse_transform!(ari, grri, plasma_surf.sin_ln_basis, 0, 0) - fourier_inverse_transform!(air, grri, plasma_surf.cos_ln_basis, 0, mpert) + fourier_inverse_transform!(arr, grri, plasma_surf.cos_mn_basis, 0, 0) + fourier_inverse_transform!(aii, grri, plasma_surf.sin_mn_basis, 0, mpert) + fourier_inverse_transform!(ari, grri, plasma_surf.sin_mn_basis, 0, 0) + fourier_inverse_transform!(air, grri, plasma_surf.cos_mn_basis, 0, mpert) # Final form of vacuum response matrix (eq. 114 of Chance 2007) vacmat = arr .+ aii @@ -322,10 +324,10 @@ function compute_vacuum_response(inputs::VacuumInput, wall_settings::WallShapeSe return wv, grri, xzpts end -function compute_vacuum_response_plasma(inputs::VacuumInput, wall_settings::WallShapeSettings) +function compute_vacuum_response_3D(inputs::VacuumInput) # Initialization and allocations - (; mtheta, mpert, n, kernelsign, force_wv_symmetry) = inputs + (; mtheta, mpert, n, force_wv_symmetry, nzeta, npert) = inputs plasma_surf = initialize_plasma_surface(inputs) grri = zeros(mtheta, 2 * mpert) grad_greenfunction_mat = zeros(mtheta, mtheta) @@ -334,42 +336,124 @@ function compute_vacuum_response_plasma(inputs::VacuumInput, wall_settings::Wall # Plasma–Plasma block kernel_plasma!(grad_greenfunction_mat, greenfunction_temp, plasma_surf.x, plasma_surf.z, plasma_surf.x, plasma_surf.z, n) + # Call BIEST to compute Green's function matrices for plasma boundary + # Use Nt toroidal points to properly discretize the 3D surface (must be >= 6 for BIEST) + num_gridpoints = nzeta * mtheta + num_modes = npert * mpert + green_3D = zeros(num_gridpoints, num_gridpoints) + gradgreen_3D = zeros(num_gridpoints, num_gridpoints) + grri_3D = zeros(num_gridpoints, 2 * num_modes) + + # G = single-layer kernel, K = double-layer kernel + println("Calling BIEST with Nt=$nzeta, Np=$mtheta (total 3D points: $(num_gridpoints))...") + compute_green_matrices!(green_3D, gradgreen_3D, plasma_surf.x, plasma_surf.z, plasma_surf.ν, nzeta) + + # Sum Green's function matrices over toroidal direction to recover 2D poloidal slice + # green_2D = zeros(ComplexF64, mtheta, mtheta) + # gradgreen_2D = zeros(ComplexF64, mtheta, mtheta) + # for i in 1:mtheta + # for j in 1:mtheta + # green_2D[i, j] = sum(green_3D[(k-1)*mtheta + i, (l-1)*mtheta + j] * exp(im * 2π * (k-l) / nzeta) for k in 1:nzeta, l in 1:nzeta) .* (1 / nzeta) + # gradgreen_2D[i, j] = sum(gradgreen_3D[(k-1)*mtheta + i, (l-1)*mtheta + j] * exp(im * 2π * (k-l) / nzeta) for k in 1:nzeta, l in 1:nzeta) .* (1 / nzeta) + # end + # end + # identity = Matrix{ComplexF64}(I, mtheta, mtheta) + # gradgreen_2D .+= identity .* 0.5 # Add identity*0.5 to double-layer kernel for jump condition + # println("Computes 2D single layer kernel matrix:") + # display(greenfunction_temp) + # println("Computes 2D double layer kernel matrix:") + # display(grad_greenfunction_mat) + # println("Sum over zeta entries of green_3D (single-layer):") + # display(green_2D) + # println("Sum over zeta entries of gradgreen_3D (double-layer):") + # display(gradgreen_2D) + + identity = Matrix{Float64}(I, num_gridpoints, num_gridpoints) + gradgreen_3D .+= identity .* 0.5 # Add identity*0.5 to double-layer kernel for jump condition + # Fourier transform plasma-plasma block - fourier_transform!(grri, greenfunction_temp, plasma_surf.cslth, 0, 0) - fourier_transform!(grri, greenfunction_temp, plasma_surf.snlth, 0, mpert) + fourier_transform!(grri, greenfunction_temp, plasma_surf.cos_mn_basis, 0, 0) + fourier_transform!(grri, greenfunction_temp, plasma_surf.sin_mn_basis, 0, mpert) + grri .*= 2π / mtheta # Multiply by periodic trapezoidal quadrature weights + + # Fourier transform plasma-plasma block + fourier_transform!(grri_3D, green_3D, plasma_surf.cos_mn_basis3D, 0, 0) + fourier_transform!(grri_3D, green_3D, plasma_surf.sin_mn_basis3D, 0, num_modes) + grri_3D .*= (2π / mtheta) * (2π / nzeta) # Multiply by periodic trapezoidal quadrature weights in 3D # Invert the vacuum response system of equations, eqs. 92-94ish of Chance 1997 grri .= grad_greenfunction_mat \ grri + grri_3D .= gradgreen_3D \ grri_3D # Perform inverse Fourier transforms to get response matrix components (eq. 115-118 of Chance 2007) arr = zeros(mpert, mpert) aii = zeros(mpert, mpert) ari = zeros(mpert, mpert) air = zeros(mpert, mpert) - fourier_inverse_transform!(arr, grri, plasma_surf.cslth, 0, 0) - fourier_inverse_transform!(aii, grri, plasma_surf.snlth, 0, mpert) - fourier_inverse_transform!(ari, grri, plasma_surf.snlth, 0, 0) - fourier_inverse_transform!(air, grri, plasma_surf.cslth, 0, mpert) + fourier_inverse_transform!(arr, grri, plasma_surf.cos_mn_basis, 0, 0) + fourier_inverse_transform!(aii, grri, plasma_surf.sin_mn_basis, 0, mpert) + fourier_inverse_transform!(ari, grri, plasma_surf.sin_mn_basis, 0, 0) + fourier_inverse_transform!(air, grri, plasma_surf.cos_mn_basis, 0, mpert) + + # Perform inverse Fourier transforms to get response matrix components (eq. 115-118 of Chance 2007) + arr3D = zeros(num_modes, num_modes) + aii3D = zeros(num_modes, num_modes) + ari3D = zeros(num_modes, num_modes) + air3D = zeros(num_modes, num_modes) + fourier_inverse_transform!(arr3D, grri_3D, plasma_surf.cos_mn_basis3D, 0, 0) + fourier_inverse_transform!(aii3D, grri_3D, plasma_surf.sin_mn_basis3D, 0, num_modes) + fourier_inverse_transform!(ari3D, grri_3D, plasma_surf.sin_mn_basis3D, 0, 0) + fourier_inverse_transform!(air3D, grri_3D, plasma_surf.cos_mn_basis3D, 0, num_modes) # Final form of vacuum response matrix (eq. 114 of Chance 2007) vacmat = arr .+ aii vacmti = air .- ari # Force symmetry of response matrix if desired - force_wv_symmetry && begin - for l1 in 1:mpert - for l2 in l1:mpert - vacmat[l1, l2] = 0.5 * (vacmat[l1, l2] + vacmat[l2, l1]) - vacmti[l1, l2] = 0.5 * (vacmti[l1, l2] - vacmti[l2, l1]) - end - end - end - wv = complex.(vacmat, vacmti) + # force_wv_symmetry && begin + # for l1 in 1:mpert + # for l2 in l1:mpert + # vacmat[l1, l2] = 0.5 * (vacmat[l1, l2] + vacmat[l2, l1]) + # vacmti[l1, l2] = 0.5 * (vacmti[l1, l2] - vacmti[l2, l1]) + # end + # end + # end + wv = 2π .* complex.(vacmat, vacmti) + println("2D Vacuum response matrix wv:") + display(wv) + + # Final form of vacuum response matrix (eq. 114 of Chance 2007) + vacmat3D = arr3D .+ aii3D + vacmti3D = air3D .- ari3D + # Force symmetry of response matrix if desired + # force_wv_symmetry && begin + # for l1 in 1:mpert*npert + # for l2 in l1:mpert*npert + # vacmat3D[l1, l2] = 0.5 * (vacmat3D[l1, l2] + vacmat3D[l2, l1]) + # vacmti3D[l1, l2] = 0.5 * (vacmti3D[l1, l2] - vacmti3D[l2, l1]) + # end + # end + # end + wv3D = complex.(vacmat3D, vacmti3D) + + println("3D Vacuum response matrix wv3D:") + display(wv3D) + + println("Difference between 2D and 3D vacuum response matrices:") + display(wv .- wv3D) + display(norm(wv .- wv3D)) + + println("Maximum eigenvalues:") + display(maximum(real.(eigvals(wv)))) + display(maximum(real.(eigvals(wv3D)))) + + println("Difference in maximum eigenvalue:") + display(maximum(real.(eigvals(wv))) - maximum(real.(eigvals(wv3D)))) # Create xzpts array xzpts = zeros(Float64, inputs.mtheta, 4) @views xzpts[:, 1] .= plasma_surf.x @views xzpts[:, 2] .= plasma_surf.z - return wv, grri, xzpts + return wv3D, grri, xzpts end """ diff --git a/src/Vacuum/VacuumInternals.jl b/src/Vacuum/VacuumInternals.jl index d364c020..56564ca9 100644 --- a/src/Vacuum/VacuumInternals.jl +++ b/src/Vacuum/VacuumInternals.jl @@ -89,6 +89,7 @@ function kernel!( ) # These used to be function arguments, but can just set inside here based on j1/j2 + # TODO: pass in the entire PlasmaGeom or WallGeom structs, check struct types to get this info, then access as usual plasma_plasma_block = j1 == 1 && j2 == 1 # previously iops plasma_is_source = j2 == 1 # previously iopw isgn = plasma_is_source ? -1 : 1 @@ -108,8 +109,10 @@ function kernel!( log_correction_0=16.0*dtheta*(log(2*dtheta)-68.0/15.0)/15.0 log_correction_1=128.0*dtheta*(log(2*dtheta)-8.0/15.0)/45.0 log_correction_2=4.0*dtheta*(7.0*log(2*dtheta)-11.0/15.0)/45.0 + log_correction = [log_correction_2, log_correction_1, log_correction_0, log_correction_1, log_correction_2] # Used for Z'_θ and X'_θ in eq.(51) + # TODO: just pass in the entire struct and get dx_dtheta, dz_dtheta on the grid points from there? spline_x = cubic_spline_interpolation(theta_grid, x_sourcepoints; extrapolation_bc=Interpolations.Periodic()) spline_z = cubic_spline_interpolation(theta_grid, z_sourcepoints; extrapolation_bc=Interpolations.Periodic()) dx_dtheta = [Interpolations.gradient(spline_x, t)[1] for t in theta_grid] @@ -118,44 +121,41 @@ function kernel!( # Loop through observer points for j in 1:mtheta # Initialize variables - x_obs=x_obspoints[j] - z_obs=z_obspoints[j] - theta_obs=theta_grid[j] + x_obs, z_obs, theta_obs = x_obspoints[j], z_obspoints[j], theta_grid[j] grad_green_0 = 0.0 # simpson integral for coupling_0 (𝒥 ∇'𝒢⁰∇'ℒ) - # Workspace = view of appropriate row of grad_greenfunction_mat for this observer point - grad_green_work = @view(grad_greenfunction_mat[(j1-1)*mtheta+j, (j2-1)*mtheta .+ (1:mtheta)]) # Obtain nonsingular region (endpoints at j+2 and j-2, so exclude j-1, j, and j+1) - nonsing_src_indices = mod1.((j+2):(j+mtheta-2), mtheta) # mod1 ensures isrc is in [1, mtheta] + nonsing_idx = mod1.((j+2):(j+mtheta-2), mtheta) # mod1 ensures isrc is in [1, mtheta] # Compute composite Simpson's 1/3 rule weights (https://en.wikipedia.org/wiki/Simpson%27s_rule#Composite_Simpson's_1/3_rule) # Note we set to 4 for even/2 for odd since we index from 1 while the formula assumes indexing from 0 - nsrc = length(nonsing_src_indices) + nsrc = length(nonsing_idx) simpson_weights = dtheta / 3 .* [(k == 1 || k == nsrc) ? 1 : (iseven(k) ? 4 : 2) for k in 1:nsrc] # Perform Simpson integration for nonsingular source points - for (isrc, wsimpson) in zip(nonsing_src_indices, simpson_weights) - x_source=x_sourcepoints[isrc] - z_source=z_sourcepoints[isrc] - + for (isrc, wsimpson) in zip(nonsing_idx, simpson_weights) # G_n is 2pi𝒢ⁿ; coupling_n is 𝒥 ∇'𝒢ⁿ∇'ℒ; coupling_0 is 𝒥 ∇'𝒢ⁿ∇'ℒ for n=0 + x_source, z_source = x_sourcepoints[isrc], z_sourcepoints[isrc] G_n, coupling_n, coupling_0 = green(x_obs, z_obs, x_source, z_source, dx_dtheta[isrc], dz_dtheta[isrc], n) # Sum contributions to Green's function matrices using Simpson weight - grad_green_work[isrc] += isgn * coupling_n * wsimpson + grad_greenfunction_mat[j, isrc] += isgn * coupling_n * wsimpson greenfunction_mat[j, isrc] += G_n * wsimpson + # TODO: can we just subtract this off grad_greenfunction_mat here? grad_green_0 += coupling_0 * wsimpson end # Perform Gaussian quadrature for singular points (source = obs point) # Get indices of the singularity region, [j-2, j-1, j, j+1, j+2] - js = mod.(j .+ ((mtheta-3):(mtheta+1)), mtheta) .+ 1 + sing_idx = mod1.(j .+ ((mtheta-2):(mtheta+2)), mtheta) # Integrate region of length 2 * dtheta on left/right of singularity for region in ["left", "right"] gauss_xleft = theta_obs - (region == "left" ? 2 * dtheta : 0) gauss_xright = gauss_xleft + 2 * dtheta - gauss_xavg = (gauss_xright + gauss_xleft)/2 - theta_gauss = gauss_xavg .+ GAUSSIANPOINTS .* dtheta # tgaus is 8 point gauss points, since GAUSSIANPOINTS is for only [-1,1] + gauss_xavg = (gauss_xright + gauss_xleft) / 2 + # TODO: can just make gauss_xavg = theta_obs ± dtheta depending on region? + theta_gauss = gauss_xavg .+ GAUSSIANPOINTS .* dtheta + wgauss = GAUSSIANWEIGHTS .* dtheta for ig in 1:8 # 8-point Gaussian quadrature # Compute green function for this Gaussian point theta_gauss0 = mod(theta_gauss[ig], 2π) @@ -168,35 +168,23 @@ function kernel!( # Add logarithm to G_n to analytically isolate the singularity (first type), Chance eq.(75) G_n_nonsingular = plasma_plasma_block ? G_n + log((theta_obs-theta_gauss[ig])^2)/x_obs : G_n - # Redefine hardcoded Gaussian weights on the interval [-1, 1] to physical interval with length 2 * dtheta - wgauss = GAUSSIANWEIGHTS[ig] * dtheta - # Calculate p = θ/Δ = (θⱼ - θ')/Δ - pgauss=(theta_gauss[ig]-theta_obs)/dtheta # Compute 5-point Lagrange basis polynomials at the Gauss point and multiply by quadrature weight - A0 = (pgauss^2-1)*(pgauss^2-4)/4.0 * wgauss - A1_plus = -(pgauss+1)*pgauss*(pgauss^2-4)/6.0 * wgauss - A1_minus = -(pgauss-1)*pgauss*(pgauss^2-4)/6.0 * wgauss - A2_plus = (pgauss^2-1)*pgauss*(pgauss+2)/24.0 * wgauss - A2_minus = (pgauss^2-1)*pgauss*(pgauss-2)/24.0 * wgauss + p = (theta_gauss[ig] - theta_obs) / dtheta # p = θ/Δ = (θⱼ - θ')/Δ + stencil_points = SVector(-2, -1, 0, 1, 2) + lagrange_stencil = ntuple(5) do i + xi = stencil_points[i] + prod(j -> j == i ? 1.0 : (p - stencil_points[j])/(xi - stencil_points[j]), 1:5) + end |> SVector # First type of singularity: 𝒢ⁿ, occurs plasma as source only (see RHS of Chance eqs. 26/27) if plasma_is_source - greenfunction_mat[j, js[1]] += G_n_nonsingular * A2_minus - greenfunction_mat[j, js[2]] += G_n_nonsingular * A1_minus - greenfunction_mat[j, js[3]] += G_n_nonsingular * A0 - greenfunction_mat[j, js[4]] += G_n_nonsingular * A1_plus - greenfunction_mat[j, js[5]] += G_n_nonsingular * A2_plus + @. @views greenfunction_mat[j, sing_idx] += wgauss[ig] * G_n_nonsingular * lagrange_stencil end - # Second type of singularity: 𝒦ⁿ - # Eq. 86: 𝒦ⁿαᵢ - δⱼᵢK⁰ - grad_green_work[js[1]] += isgn * coupling_n * A2_minus - grad_green_work[js[2]] += isgn * coupling_n * A1_minus - grad_green_work[js[3]] += isgn * coupling_n * A0 - grad_green_work[js[4]] += isgn * coupling_n * A1_plus - grad_green_work[js[5]] += isgn * coupling_n * A2_plus + # Second type of singularity: 𝒦ⁿ (Eq. 86: 𝒦ⁿαᵢ - δⱼᵢK⁰) + @. @views grad_greenfunction_mat[j, sing_idx] += wgauss[ig] * isgn * coupling_n * lagrange_stencil # Subtract off the diverging singular n=0 component - grad_green_work[j] -= isgn * coupling_0 * wgauss + grad_greenfunction_mat[j, j] -= isgn * coupling_0 * wgauss[ig] end end @@ -210,15 +198,11 @@ function kernel!( residue = (j1 == j2) ? 2.0 : 0.0 # Chance eq. 90 end # Subtract regular integral component of δⱼᵢK⁰ in eq. 83 and add residue value in eq. 89/90 - grad_green_work[j] = grad_green_work[j] - isgn * grad_green_0 + residue + grad_greenfunction_mat[j, j] += residue - isgn * grad_green_0 # Subtract off analytic singular integral from Chance eq.(75) if plasma-plasma block if plasma_plasma_block - greenfunction_mat[j, js[1]] -= log_correction_2 / x_obs - greenfunction_mat[j, js[2]] -= log_correction_1 / x_obs - greenfunction_mat[j, js[3]] -= log_correction_0 / x_obs - greenfunction_mat[j, js[4]] -= log_correction_1 / x_obs - greenfunction_mat[j, js[5]] -= log_correction_2 / x_obs + @. @views greenfunction_mat[j, sing_idx] -= log_correction / x_obs end end # Since we computed 2π𝒢, divide by 2π to get 𝒢 @@ -256,34 +240,27 @@ function kernel_plasma!( # Loop through observer points for j in 1:mtheta # Initialize variables - x_obs=x_obspoints[j] - z_obs=z_obspoints[j] - theta_obs=theta_grid[j] + x_obs, z_obs, theta_obs = x_obspoints[j], z_obspoints[j], theta_grid[j] grad_green_0 = 0.0 # simpson integral for coupling_0 (𝒥 ∇'𝒢⁰∇'ℒ) - # Workspace = view of appropriate row of grad_greenfunction_mat for this observer point - grad_green_work = @view(grad_greenfunction_mat[j, 1:mtheta]) - - # Perform Simpson integration for nonsingular source points (excludes j-1, j, j+1) - for i in 1:(mtheta-3) - # Get source point index (ic) and ensure it is in range [1, mtheta] - ic = i + j + 1 - if ic > mtheta - ic -= mtheta - end - x_source=x_sourcepoints[ic] - z_source=z_sourcepoints[ic] - # G_n is 2pi𝒢ⁿ; coupling_n is 𝒥 ∇'𝒢ⁿ∇'ℒ; coupling_0 is 𝒥 ∇'𝒢ⁿ∇'ℒ for n=0 - G_n, coupling_n, coupling_0 = green(x_obs, z_obs, x_source, z_source, dx_dtheta[ic], dz_dtheta[ic], n) + # Obtain nonsingular region (endpoints at j+2 and j-2, so exclude j-1, j, and j+1) + nonsing_idx = mod1.((j+2):(j+mtheta-2), mtheta) # mod1 ensures isrc is in [1, mtheta] - # Compute composite Simpson's 1/3 rule weight (https://en.wikipedia.org/wiki/Simpson%27s_rule#Composite_Simpson's_1/3_rule) - # Note we set to 4 for even/2 for odd since we index from 1 while the formula assumes indexing from 0 - endpoint = (i == 1)||(i == mtheta - 3) - wsimpson = (endpoint ? 1 : (iseven(i) ? 4 : 2)) * dtheta / 3 + # Compute composite Simpson's 1/3 rule weights (https://en.wikipedia.org/wiki/Simpson%27s_rule#Composite_Simpson's_1/3_rule) + # Note we set to 4 for even/2 for odd since we index from 1 while the formula assumes indexing from 0 + nsrc = length(nonsing_idx) + simpson_weights = dtheta / 3 .* [(k == 1 || k == nsrc) ? 1 : (iseven(k) ? 4 : 2) for k in 1:nsrc] + + # Perform Simpson integration for nonsingular source points + for (isrc, wsimpson) in zip(nonsing_idx, simpson_weights) + # G_n is 2pi𝒢ⁿ; coupling_n is 𝒥 ∇'𝒢ⁿ∇'ℒ; coupling_0 is 𝒥 ∇'𝒢ⁿ∇'ℒ for n=0 + x_source, z_source = x_sourcepoints[isrc], z_sourcepoints[isrc] + G_n, coupling_n, coupling_0 = green(x_obs, z_obs, x_source, z_source, dx_dtheta[isrc], dz_dtheta[isrc], n) # Sum contributions to Green's function matrices using Simpson weight - grad_green_work[ic] += -1 * coupling_n * wsimpson - greenfunction_mat[j, ic] += G_n * wsimpson + grad_greenfunction_mat[j, isrc] += -1 * coupling_n * wsimpson + greenfunction_mat[j, isrc] += G_n * wsimpson + # TODO: can we just subtract this off grad_greenfunction_mat here? grad_green_0 += coupling_0 * wsimpson end @@ -310,36 +287,34 @@ function kernel_plasma!( # Redefine hardcoded Gaussian weights on the interval [-1, 1] to physical interval with length 2 * dtheta wgauss = GAUSSIANWEIGHTS[ig] * dtheta - # Calculate p = θ/Δ = (θⱼ - θ')/Δ, 0 at observation point, ±1,±2 at other 5-point stencil nodes - pgauss=(theta_gauss[ig]-theta_obs)/dtheta - # Compute 5-point Lagrange basis polynomials at the Gauss point and multiply by quadrature weight - A0 = (pgauss^2-1)*(pgauss^2-4)/4.0 * wgauss - A1_plus = -(pgauss+1)*pgauss*(pgauss^2-4)/6.0 * wgauss - A1_minus = -(pgauss-1)*pgauss*(pgauss^2-4)/6.0 * wgauss - A2_plus = (pgauss^2-1)*pgauss*(pgauss+2)/24.0 * wgauss - A2_minus = (pgauss^2-1)*pgauss*(pgauss-2)/24.0 * wgauss + + # Compute 5-point Lagrange basis polynomials using the formula: + # L_i(p) = ∏_{m≠i} (p - x_m)/(x_i - x_m) where stencil points are x = [-2, -1, 0, 1, 2] + p = (theta_gauss[ig]-theta_obs)/dtheta # p = θ/Δ = (θⱼ - θ')/Δ + # A_stencil = SVector( + # p*(p - 1)*(p - 2)*(p + 1)/24, # L_{-2}(p) + # -p*(p - 1)*(p - 2)*(p + 2)/6, # L_{-1}(p) + # (p - 2)*(p - 1)*(p + 1)*(p + 2)/4, # L_0(p) + # -p*(p - 2)*(p + 1)*(p + 2)/6, # L_1(p) + # p*(p - 1)*(p + 1)*(p + 2)/24 # L_2(p) + # ) + stencil_points = SVector(-2, -1, 0, 1, 2) + A_stencil = ntuple(5) do i + xi = stencil_points[i] + prod(j -> j == i ? 1.0 : (p - stencil_points[j])/(xi - stencil_points[j]), 1:5) + end |> SVector # First type of singularity: 𝒢ⁿ, occurs plasma as source only (see RHS of Chance eqs. 26/27) - greenfunction_mat[j, js[1]] += G_n_nonsingular * A2_minus - greenfunction_mat[j, js[2]] += G_n_nonsingular * A1_minus - greenfunction_mat[j, js[3]] += G_n_nonsingular * A0 - greenfunction_mat[j, js[4]] += G_n_nonsingular * A1_plus - greenfunction_mat[j, js[5]] += G_n_nonsingular * A2_plus - - # Second type of singularity: 𝒦ⁿ - # Eq. 86: 𝒦ⁿαᵢ - δⱼᵢK⁰ (js[3] = j if iend=2) - grad_green_work[js[1]] += -1 * coupling_n * A2_minus - grad_green_work[js[2]] += -1 * coupling_n * A1_minus - grad_green_work[js[3]] += -1 * coupling_n * A0 - grad_green_work[js[4]] += -1 * coupling_n * A1_plus - grad_green_work[js[5]] += -1 * coupling_n * A2_plus + @views greenfunction_mat[j, js] .+= wgauss * G_n_nonsingular .* A_stencil + # Second type of singularity: 𝒦ⁿ (Eq. 86: 𝒦ⁿαᵢ - δⱼᵢK⁰) + @views grad_greenfunction_mat[j, js] .+= wgauss * (-coupling_n) .* A_stencil # Subtract off the diverging singular n=0 component - grad_green_work[j] -= -1 * coupling_0 * wgauss + grad_greenfunction_mat[j, j] -= -1 * coupling_0 * wgauss end end # Subtract regular integral component of δⱼᵢK⁰ in eq. 83 and add residue value in eq. 89/90 - grad_green_work[j] += grad_green_0 + 2.0 + grad_greenfunction_mat[j, j] += grad_green_0 + 2.0 # Subtract off analytic singular integral from Chance eq.(75) if plasma-plasma block greenfunction_mat[j, js[1]] -= log_correction_2 / x_obs @@ -377,13 +352,12 @@ Perform the inverse Fourier transform of `gil` onto `gll` using Fourier coeffici function fourier_inverse_transform!(gll::Matrix{Float64}, gil::Matrix{Float64}, cs::Matrix{Float64}, m00::Int, l00::Int) # Zero out gll block - mtheta, mpert = size(cs) - fill!(view(gll, 1:mpert, 1:mpert), 0.0) + num_gridpoints, num_pert = size(cs) + fill!(view(gll, 1:num_pert, 1:num_pert), 0.0) # Inverse Fourier transform via matrix multiply: gll = cs^T * gil * (2π * dth) # This computes: gll[l2, l1] = (2π * dth) * Σ_i cs[i, l2] * gil[i, l1] - dth = 2π / mtheta - mul!(gll, cs', view(gil, (m00+1):(m00+mtheta), (l00+1):(l00+mpert)), 2π * dth, 0.0) + mul!(gll, cs', view(gil, (m00+1):(m00+num_gridpoints), (l00+1):(l00+num_pert))) end """ @@ -406,11 +380,11 @@ end function fourier_transform!(gil::Matrix{Float64}, gij::Matrix{Float64}, cs::Matrix{Float64}, m00::Int, l00::Int) # Zero out relevant gil block - mtheta, mpert = size(cs) - fill!(view(gil, (m00+1):(m00+mtheta), (l00+1):(l00+mpert)), 0.0) + num_gridpoints, num_pert = size(cs) + fill!(view(gil, (m00+1):(m00+num_gridpoints), (l00+1):(l00+num_pert)), 0.0) # Fourier transform via matrix multiply: gil[i, l] = Σ_j gij[i, j] * cs[j, l] - mul!(view(gil, (m00+1):(m00+mtheta), (l00+1):(l00+mpert)), gij, cs) + mul!(view(gil, (m00+1):(m00+num_gridpoints), (l00+1):(l00+num_pert)), gij, cs) end # Returns the array of derivatives at all x points, I think this acts like difspl diff --git a/src/Vacuum/VacuumStructs.jl b/src/Vacuum/VacuumStructs.jl index 6045fbc7..c264d27f 100644 --- a/src/Vacuum/VacuumStructs.jl +++ b/src/Vacuum/VacuumStructs.jl @@ -21,8 +21,11 @@ Struct holding plasma boundary and mode data as provided from DCON namelist and ν::Vector{Float64} = Float64[] mlow::Int = 0 mpert::Int = 0 + nlow::Int = 0 + npert::Int = 0 n::Int = 0 mtheta::Int = 1 + nzeta::Int = 1 kernelsign::Float64 = 1.0 force_wv_symmetry::Bool = true end @@ -41,16 +44,19 @@ of size (mtheta, mpert), where `mpert` is the number of poloidal modes. - `z::Vector{Float64}`: Plasma surface Z-coordinate on VACUUM theta grid - `dx_dtheta::Vector{Float64}`: Derivative dR/dθ at plasma surface - `dz_dtheta::Vector{Float64}`: Derivative dZ/dθ at plasma surface - - `sin_ln_basis::Matrix{Float64}`: sin(lθ - nν) basis functions for poloidal modes at plasma surface - - `cos_ln_basis::Matrix{Float64}`: cos(lθ - nν) basis functions for poloidal modes at plasma surface + - `sin_mn_basis::Matrix{Float64}`: sin(mθ - nν) basis functions for poloidal modes at plasma surface + - `cos_mn_basis::Matrix{Float64}`: cos(mθ - nν) basis functions for poloidal modes at plasma surface """ struct PlasmaGeometry x::Vector{Float64} z::Vector{Float64} + ν::Vector{Float64} dx_dtheta::Vector{Float64} dz_dtheta::Vector{Float64} - sin_ln_basis::Matrix{Float64} - cos_ln_basis::Matrix{Float64} + sin_mn_basis::Matrix{Float64} + cos_mn_basis::Matrix{Float64} + sin_mn_basis3D::Matrix{Float64} + cos_mn_basis3D::Matrix{Float64} end """ @@ -145,35 +151,55 @@ the necessary plasma surface data for vacuum calculations. """ function initialize_plasma_surface(inputs::VacuumInput) - (; mtheta, mpert, mlow, ν, r, z, n) = inputs + (; mtheta, mpert, mlow, nzeta, npert, nlow, ν, r, z, n) = inputs # Interpolate arrays from input onto mtheta grid - x_plasma = interp_to_new_grid(r, mtheta) - z_plasma = interp_to_new_grid(z, mtheta) + R = interp_to_new_grid(r, mtheta) + Z = interp_to_new_grid(z, mtheta) ν = interp_to_new_grid(ν, mtheta) # Plasma boundary theta derivative: length mth with θ = [0, 1) θ_grid = range(; start=0, length=mtheta, step=2π/mtheta) - dx_dtheta = periodic_cubic_deriv(θ_grid, x_plasma) - dz_dtheta = periodic_cubic_deriv(θ_grid, z_plasma) + dx_dtheta = periodic_cubic_deriv(θ_grid, R) + dz_dtheta = periodic_cubic_deriv(θ_grid, Z) - # Precompute Fourier transform terms, sin(lθ - nν) and cos(lθ - nν) - sin_ln_basis = zeros(Float64, mtheta, mpert) - cos_ln_basis = zeros(Float64, mtheta, mpert) + # Precompute Fourier transform terms, sin(mθ - nν) and cos(mθ - nν) + sin_mn_basis = zeros(mtheta, mpert) + cos_mn_basis = zeros(mtheta, mpert) for j in 1:mpert for i in 1:mtheta - l = mlow + j - 1 - cos_ln_basis[i, j] = cos(l * θ_grid[i] - n * ν[i]) - sin_ln_basis[i, j] = sin(l * θ_grid[i] - n * ν[i]) + m = mlow + j - 1 + cos_mn_basis[i, j] = cos(m * θ_grid[i] - n * ν[i]) + sin_mn_basis[i, j] = sin(m * θ_grid[i] - n * ν[i]) + end + end + + # Precompute Fourier transform terms, sin(lθ - nν(θ) - nϕ) and cos(lθ - nν(θ) - nϕ) + sin_mn_basis3D = zeros(mtheta*nzeta, mpert*npert) + cos_mn_basis3D = zeros(mtheta*nzeta, mpert*npert) + ϕ_grid = range(; start=0, length=nzeta, step=2π/nzeta) + for idx_n in 1:npert + n = nlow + idx_n - 1 + for idx_m in 1:mpert + m = mlow + idx_m - 1 + for j in 1:nzeta + for i in 1:mtheta + cos_mn_basis3D[i+(j-1)*mtheta, idx_m+(idx_n-1)*mpert] = cos(m * θ_grid[i] - n * (ν[i] + ϕ_grid[j])) + sin_mn_basis3D[i+(j-1)*mtheta, idx_m+(idx_n-1)*mpert] = sin(m * θ_grid[i] - n * (ν[i] + ϕ_grid[j])) + end + end end end return PlasmaGeometry( - x_plasma, - z_plasma, + R, + Z, + ν, dx_dtheta, dz_dtheta, - sin_ln_basis, - cos_ln_basis + sin_mn_basis, + cos_mn_basis, + sin_mn_basis3D, + cos_mn_basis3D ) end From 9a44f015971d3f734a3aa7dcd551d5889e41c875 Mon Sep 17 00:00:00 2001 From: Jake Halpern Date: Wed, 21 Jan 2026 09:35:15 -0500 Subject: [PATCH 03/31] VACUUM - BUGFIX - block indices for kernel with walls --- .github/copilot-instructions.md | 149 ++++++++++++++++++++++++++++++++ .gitignore | 1 + src/Vacuum/VacuumInternals.jl | 129 ++------------------------- 3 files changed, 156 insertions(+), 123 deletions(-) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..4a88fd63 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,149 @@ +# JPEC Copilot Instructions + +JPEC (Julia Perturbed Equilibrium Code) is a hybrid Julia/Fortran MHD equilibrium and stability analysis suite for fusion plasmas. This is an active port of GPEC with legacy Fortran called via `ccall`. + +## Architecture Overview + +Four main modules in `src/`: + +1. **Splines** - Numerical interpolation (1D/2D cubic, Fourier). Hybrid Julia/Fortran implementations. +2. **Equilibrium** - MHD equilibrium solvers. Entry: `setup_equilibrium(path)` or `setup_equilibrium(config)`. Supports efit, chease, chease2, lar (Large Aspect Ratio), sol (Solovev). +3. **Vacuum** - Vacuum field calculations. **Actively being converted from Fortran to Julia**. Main functions: `mscvac()` (Fortran) and `compute_vacuum_response()` (Julia). +4. **DCON** - Stability analysis. Solves ODE for MHD stability, handles singular surfaces. Entry: functions in `src/DCON/Main.jl`. + +**BIEST** (`src/BIEST/`) - C++ boundary integral operator for 3D vacuum geometries, called from Julia via wrapper. + +## Data Flow + +1. **Equilibrium**: `setup_equilibrium()` → parse TOML config → read equilibrium file → run solver (direct/inverse) → compute q-profile, beta → diagnose GS solution → output gsec.h5, gse.h5, gsei.h5 +2. **Vacuum**: Initialize plasma/wall surfaces → compute vacuum response matrix → return wv, grri, xzpts arrays +3. **Stability**: Use equilibrium + vacuum response → integrate stability ODEs → compute energies → determine stability + +### Key Data Structures + +- `PlasmaEquilibrium` - Main equilibrium container with bicubic splines (rzphi), 1D profiles (sq), and parameters +- `EquilibriumConfig` - Configuration from TOML files (sections: `[EQUIL_CONTROL]`, `[EQUIL_OUTPUT]`) +- `VacuumInput` - Vacuum calculation parameters +- `DconControl` - DCON configuration from TOML (sections: `[DCON_CONTROL]`, `[WALL]`) + +## Build & Test Commands + +```bash +# Build Fortran libraries (required before first use) +julia --project=. -e 'using Pkg; Pkg.build()' + +# Run all tests +julia --project=. test/runtests.jl + +# Run specific test (add others as arguments) +julia --project=. test/runtests.jl test/runtests_spline.jl + +# Build documentation locally +julia --project=. build_docs_local.jl +``` + +**Available test files**: `runtests_build.jl`, `runtests_spline.jl`, `runtests_vacuum_fortran.jl`, `runtests_vacuum_julia.jl`, `runtests_solovev.jl`, `runtests_ode.jl`, `runtests_sing.jl`, `runtests_fullruns.jl` + +## Fortran Integration + +Build system in `deps/build.jl` and `deps/build_helpers.jl`: +- Compiles Fortran → shared libraries (`.dylib` on macOS, `.so` on Linux) +- OS-specific flags: macOS uses Accelerate, Linux uses OpenBLAS +- **No Windows support** - use WSL +- Current Fortran modules: Splines (`libspline`), Vacuum (`libvac`), BIEST (`libbiest`) +- Add new builds by creating `build_*_fortran()` in `deps/build_helpers.jl` + +### Calling Fortran from Julia + +Use `ccall` with mangled names: +```julia +ccall((:function_name_, libname), ReturnType, (ArgTypes...), args...) +# Module functions: (:__module_MOD_function, libname) +``` + +See examples in `src/Vacuum/Vacuum.jl` (lines 59-93) and `src/Splines/CubicSpline.jl`. + +## BIEST C++ Integration + +Located in `src/BIEST/` with C++ sources and Julia wrapper: +- Build: `cd src/BIEST && make` +- C++ wrapper: `src/BIEST/wrapper.cpp` exposes functions via `extern "C"` +- Julia interface: `src/BIEST.jl` calls via `ccall((:function, libbiest), ...)` +- To add functions: update `wrapper.cpp`, add to `BIEST.jl`, export, recompile + +## Project Conventions + +### Commit Message Format + +``` +CODE - TAG - Detailed message +``` + +- **CODE**: Module (EQUIL, DCON, VAC, VACUUM, BIEST, etc.) +- **TAG**: WIP, MINOR, IMPROVEMENT, BUG FIX, NEW FEATURE, etc. + +Examples: +- `VAC - WIP - Converting vaccal wall code to Julia` +- `EQUIL - BUG FIX - Fixed separatrix finding for high kappa` + +### Code Style + +- **NO step numbering in comments** - Avoid "Step 1:", "Step 2:" annotations (they get out of sync) +- Many places use **0-based indexing** (Fortran convention) then convert to 1-based Julia indexing +- Document index conventions in comments when converting between Fortran/Julia + +### Git Workflow (GitFlow) + +- Two permanent branches: `main` and `develop` +- `main` updated only at releases +- Feature branches off `develop`, merge with `--no-ff` +- Current active work: `3D_vacuum` branch (PR #131) + +## Configuration Files + +TOML-based configuration for equilibrium and stability runs: + +**equil.toml** - `[EQUIL_CONTROL]` (solver params, grid) + `[EQUIL_OUTPUT]` (diagnostics) +**dcon.toml** - `[DCON_CONTROL]` (stability params, mode numbers) + `[WALL]` (wall geometry) + +Examples in `examples/DIIID-like_ideal_example/` and `examples/Solovev_ideal_example/` + +## Development Tips + +- Use **Revise.jl** for faster recompilation (install in global env, not Project.toml): + ```julia + using Revise + using JPEC + ``` +- Target Julia version: **1.11** +- When modifying equilibrium code, update diagnostic outputs (gsec.h5, gse.h5, gsei.h5) +- Tests include Fortran and Julia implementations to ensure parity during conversion +- Pre-commit hooks configured for notebook cleaning and Julia formatting (see `docs/src/set_up.md`) + +## Benchmarking + +**Default benchmark case**: `examples/DIIID-like_ideal_example` +**Reference**: `origin/develop` branch (fetch latest before running) + +**Required metrics**: +1. **Least stable eigenmode energy** - First value of `et` array (verifies consistency) +2. **Number of steps** - ODE solver integration steps +3. **Runtime** - Wall-clock execution time + +Compare current branch vs `origin/develop` for performance regression testing. + +## Important Files + +- `src/JPEC.jl` - Main module, includes all submodules +- `deps/build_helpers.jl` - Fortran build configuration +- `src/Equilibrium/EquilibriumTypes.jl` - Core equilibrium data structures +- `src/DCON/DconStructs.jl` - Stability analysis data structures +- `src/Vacuum/VacuumInternals.jl` - Julia vacuum implementation (active development) +- `examples/*/equil.toml`, `examples/*/dcon.toml` - Configuration examples + +## Current Development Focus + +Active conversion of Vacuum module from Fortran to Julia on `3D_vacuum` branch. When working on vacuum code: +- Maintain parity between `mscvac()` (Fortran) and `compute_vacuum_response()` (Julia) +- Test both implementations with `runtests_vacuum_fortran.jl` and `runtests_vacuum_julia.jl` +- Document indexing conversions between 0-based (Fortran) and 1-based (Julia) diff --git a/.gitignore b/.gitignore index d1d98eec..0189694e 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ docs/build/ anaconda_projects/ modovmc pestotv +Vacuum3D_temp/ diff --git a/src/Vacuum/VacuumInternals.jl b/src/Vacuum/VacuumInternals.jl index 56564ca9..ffdb9fde 100644 --- a/src/Vacuum/VacuumInternals.jl +++ b/src/Vacuum/VacuumInternals.jl @@ -100,6 +100,7 @@ function kernel!( # Zero out greenfunction_mat at start of each kernel call (matches Fortran behavior) fill!(greenfunction_mat, 0.0) + grad_greenfunction_block = @view(grad_greenfunction_mat[(j1-1)*mtheta .+ (1:mtheta), (j2-1)*mtheta .+ (1:mtheta)]) if mtheta != length(z_obspoints) || mtheta != length(x_sourcepoints) || mtheta != length(z_sourcepoints) error("Length of input arrays (xobs, zobs, xsource, zsce) are different. All length should be the same") @@ -139,9 +140,9 @@ function kernel!( G_n, coupling_n, coupling_0 = green(x_obs, z_obs, x_source, z_source, dx_dtheta[isrc], dz_dtheta[isrc], n) # Sum contributions to Green's function matrices using Simpson weight - grad_greenfunction_mat[j, isrc] += isgn * coupling_n * wsimpson + grad_greenfunction_block[j, isrc] += isgn * coupling_n * wsimpson greenfunction_mat[j, isrc] += G_n * wsimpson - # TODO: can we just subtract this off grad_greenfunction_mat here? + # TODO: can we just subtract this off grad_greenfunction_block here? grad_green_0 += coupling_0 * wsimpson end @@ -182,9 +183,9 @@ function kernel!( end # Second type of singularity: 𝒦ⁿ (Eq. 86: 𝒦ⁿαᵢ - δⱼᵢK⁰) - @. @views grad_greenfunction_mat[j, sing_idx] += wgauss[ig] * isgn * coupling_n * lagrange_stencil + @. @views grad_greenfunction_block[j, sing_idx] += wgauss[ig] * isgn * coupling_n * lagrange_stencil # Subtract off the diverging singular n=0 component - grad_greenfunction_mat[j, j] -= isgn * coupling_0 * wgauss[ig] + grad_greenfunction_block[j, j] -= isgn * coupling_0 * wgauss[ig] end end @@ -198,7 +199,7 @@ function kernel!( residue = (j1 == j2) ? 2.0 : 0.0 # Chance eq. 90 end # Subtract regular integral component of δⱼᵢK⁰ in eq. 83 and add residue value in eq. 89/90 - grad_greenfunction_mat[j, j] += residue - isgn * grad_green_0 + grad_greenfunction_block[j, j] += residue - isgn * grad_green_0 # Subtract off analytic singular integral from Chance eq.(75) if plasma-plasma block if plasma_plasma_block @@ -209,124 +210,6 @@ function kernel!( greenfunction_mat ./= 2π end -function kernel_plasma!( - grad_greenfunction_mat::Matrix{Float64}, - greenfunction_mat::Matrix{Float64}, - x_obspoints::Vector{Float64}, - z_obspoints::Vector{Float64}, - x_sourcepoints::Vector{Float64}, - z_sourcepoints::Vector{Float64}, - n::Int -) - - mtheta = length(x_obspoints) - dtheta = 2π / mtheta - theta_grid = range(; start=0, length=mtheta, step=dtheta) - - # Zero out greenfunction_mat at start of each kernel call (matches Fortran behavior) - fill!(greenfunction_mat, 0.0) - - # S₁ᵢ in Chance 1997, eq.(78) - log_correction_0=16.0*dtheta*(log(2*dtheta)-68.0/15.0)/15.0 - log_correction_1=128.0*dtheta*(log(2*dtheta)-8.0/15.0)/45.0 - log_correction_2=4.0*dtheta*(7.0*log(2*dtheta)-11.0/15.0)/45.0 - - # Used for Z'_θ and X'_θ in eq.(51) - spline_x = cubic_spline_interpolation(theta_grid, x_sourcepoints; extrapolation_bc=Interpolations.Periodic()) - spline_z = cubic_spline_interpolation(theta_grid, z_sourcepoints; extrapolation_bc=Interpolations.Periodic()) - dx_dtheta = [Interpolations.gradient(spline_x, t)[1] for t in theta_grid] - dz_dtheta = [Interpolations.gradient(spline_z, t)[1] for t in theta_grid] - - # Loop through observer points - for j in 1:mtheta - # Initialize variables - x_obs, z_obs, theta_obs = x_obspoints[j], z_obspoints[j], theta_grid[j] - grad_green_0 = 0.0 # simpson integral for coupling_0 (𝒥 ∇'𝒢⁰∇'ℒ) - - # Obtain nonsingular region (endpoints at j+2 and j-2, so exclude j-1, j, and j+1) - nonsing_idx = mod1.((j+2):(j+mtheta-2), mtheta) # mod1 ensures isrc is in [1, mtheta] - - # Compute composite Simpson's 1/3 rule weights (https://en.wikipedia.org/wiki/Simpson%27s_rule#Composite_Simpson's_1/3_rule) - # Note we set to 4 for even/2 for odd since we index from 1 while the formula assumes indexing from 0 - nsrc = length(nonsing_idx) - simpson_weights = dtheta / 3 .* [(k == 1 || k == nsrc) ? 1 : (iseven(k) ? 4 : 2) for k in 1:nsrc] - - # Perform Simpson integration for nonsingular source points - for (isrc, wsimpson) in zip(nonsing_idx, simpson_weights) - # G_n is 2pi𝒢ⁿ; coupling_n is 𝒥 ∇'𝒢ⁿ∇'ℒ; coupling_0 is 𝒥 ∇'𝒢ⁿ∇'ℒ for n=0 - x_source, z_source = x_sourcepoints[isrc], z_sourcepoints[isrc] - G_n, coupling_n, coupling_0 = green(x_obs, z_obs, x_source, z_source, dx_dtheta[isrc], dz_dtheta[isrc], n) - - # Sum contributions to Green's function matrices using Simpson weight - grad_greenfunction_mat[j, isrc] += -1 * coupling_n * wsimpson - greenfunction_mat[j, isrc] += G_n * wsimpson - # TODO: can we just subtract this off grad_greenfunction_mat here? - grad_green_0 += coupling_0 * wsimpson - end - - # Perform Gaussian quadrature for singular points (source = obs point) - # Get indices of the singularity region ([j-2, j-1, j, j+1, j+2]) - js = mod.(j .+ ((mtheta-3):(mtheta+1)), mtheta) .+ 1 - # Integrate region of length 2 * dtheta on left (ilr = 1)/right (ilr = 2) of singularity - for ilr in [1, 2] - gauss_xleft = theta_obs + 2 * (ilr-2) * dtheta - gauss_xright = gauss_xleft + 2 * dtheta - gauss_xavg = (gauss_xright + gauss_xleft)/2 - theta_gauss = gauss_xavg .+ GAUSSIANPOINTS .* dtheta # tgaus is 8 point gauss points, since GAUSSIANPOINTS is for only [-1,1] - for ig in 1:8 # 8-point Gaussian quadrature - # Compute green function for this Gaussian point - theta_gauss0 = mod(theta_gauss[ig], 2π) - x_gauss = spline_x(theta_gauss0) - dx_dtheta_gauss = Interpolations.gradient(spline_x, theta_gauss0)[1] - z_gauss = spline_z(theta_gauss0) - dz_dtheta_gauss = Interpolations.gradient(spline_z, theta_gauss0)[1] - G_n, coupling_n, coupling_0 = green(x_obs, z_obs, x_gauss, z_gauss, dx_dtheta_gauss, dz_dtheta_gauss, n) - - # Add logarithm to G_n to analytically isolate the singularity (first type), Chance eq.(75) - G_n_nonsingular = G_n + log((theta_obs-theta_gauss[ig])^2)/x_obs - - # Redefine hardcoded Gaussian weights on the interval [-1, 1] to physical interval with length 2 * dtheta - wgauss = GAUSSIANWEIGHTS[ig] * dtheta - - # Compute 5-point Lagrange basis polynomials using the formula: - # L_i(p) = ∏_{m≠i} (p - x_m)/(x_i - x_m) where stencil points are x = [-2, -1, 0, 1, 2] - p = (theta_gauss[ig]-theta_obs)/dtheta # p = θ/Δ = (θⱼ - θ')/Δ - # A_stencil = SVector( - # p*(p - 1)*(p - 2)*(p + 1)/24, # L_{-2}(p) - # -p*(p - 1)*(p - 2)*(p + 2)/6, # L_{-1}(p) - # (p - 2)*(p - 1)*(p + 1)*(p + 2)/4, # L_0(p) - # -p*(p - 2)*(p + 1)*(p + 2)/6, # L_1(p) - # p*(p - 1)*(p + 1)*(p + 2)/24 # L_2(p) - # ) - stencil_points = SVector(-2, -1, 0, 1, 2) - A_stencil = ntuple(5) do i - xi = stencil_points[i] - prod(j -> j == i ? 1.0 : (p - stencil_points[j])/(xi - stencil_points[j]), 1:5) - end |> SVector - - # First type of singularity: 𝒢ⁿ, occurs plasma as source only (see RHS of Chance eqs. 26/27) - @views greenfunction_mat[j, js] .+= wgauss * G_n_nonsingular .* A_stencil - # Second type of singularity: 𝒦ⁿ (Eq. 86: 𝒦ⁿαᵢ - δⱼᵢK⁰) - @views grad_greenfunction_mat[j, js] .+= wgauss * (-coupling_n) .* A_stencil - # Subtract off the diverging singular n=0 component - grad_greenfunction_mat[j, j] -= -1 * coupling_0 * wgauss - end - end - - # Subtract regular integral component of δⱼᵢK⁰ in eq. 83 and add residue value in eq. 89/90 - grad_greenfunction_mat[j, j] += grad_green_0 + 2.0 - - # Subtract off analytic singular integral from Chance eq.(75) if plasma-plasma block - greenfunction_mat[j, js[1]] -= log_correction_2 / x_obs - greenfunction_mat[j, js[2]] -= log_correction_1 / x_obs - greenfunction_mat[j, js[3]] -= log_correction_0 / x_obs - greenfunction_mat[j, js[4]] -= log_correction_1 / x_obs - greenfunction_mat[j, js[5]] -= log_correction_2 / x_obs - end - # Since we computed 2π𝒢, divide by 2π to get 𝒢 - greenfunction_mat ./= 2π -end - """ fourier_inverse_transform!(gll, gil, cs, m00, l00) From f4c23c309203cae9527a7f72710f58d0446ca462 Mon Sep 17 00:00:00 2001 From: Jake Halpern Date: Wed, 21 Jan 2026 09:49:43 -0500 Subject: [PATCH 04/31] VACUUM - BUGFIX - lost a factor of dth in the fourier transforms --- src/Vacuum/VacuumInternals.jl | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Vacuum/VacuumInternals.jl b/src/Vacuum/VacuumInternals.jl index ffdb9fde..090e34b0 100644 --- a/src/Vacuum/VacuumInternals.jl +++ b/src/Vacuum/VacuumInternals.jl @@ -240,7 +240,8 @@ function fourier_inverse_transform!(gll::Matrix{Float64}, gil::Matrix{Float64}, # Inverse Fourier transform via matrix multiply: gll = cs^T * gil * (2π * dth) # This computes: gll[l2, l1] = (2π * dth) * Σ_i cs[i, l2] * gil[i, l1] - mul!(gll, cs', view(gil, (m00+1):(m00+num_gridpoints), (l00+1):(l00+num_pert))) + dth = 2π / num_gridpoints + mul!(gll, cs', view(gil, (m00+1):(m00+num_gridpoints), (l00+1):(l00+num_pert)), 2π * dth, 0.0) end """ From a7759733d2b26659be55443d7f95066743811102 Mon Sep 17 00:00:00 2001 From: Jake Halpern Date: Wed, 21 Jan 2026 11:14:05 -0500 Subject: [PATCH 05/31] VACUUM - IMPROVEMENT - kernel updates --- src/Vacuum/Vacuum.jl | 76 ++++++++++-------------- src/Vacuum/VacuumInternals.jl | 109 +++++++++++++++++----------------- 2 files changed, 86 insertions(+), 99 deletions(-) diff --git a/src/Vacuum/Vacuum.jl b/src/Vacuum/Vacuum.jl index 266806ed..b4eb039d 100644 --- a/src/Vacuum/Vacuum.jl +++ b/src/Vacuum/Vacuum.jl @@ -232,34 +232,36 @@ function compute_vacuum_response(inputs::VacuumInput, wall_settings::WallShapeSe (; mtheta, mpert, n, kernelsign, force_wv_symmetry) = inputs plasma_surf = initialize_plasma_surface(inputs) wall = initialize_wall(inputs, plasma_surf, wall_settings) - grri = zeros(2 * mtheta, 2 * mpert) - grad_greenfunction_mat = zeros(2 * mtheta, 2 * mtheta) - greenfunction_temp = zeros(mtheta, mtheta) + grad_green = zeros(2 * mtheta, 2 * mtheta) + green_temp = zeros(mtheta, mtheta) + + # 𝒢ₗ(θⱼ) from Chance eq. 106-108. first mtheta rows are plasma as observer, second are wall + # First mpert columns are real (cosine), second mpert are imaginary (sine) + green_fourier = zeros(2 * mtheta, 2 * mpert) + PLASMA_ROW_OFFSET = 0 + WALL_ROW_OFFSET = mtheta + COS_COL_OFFSET = 0 + SIN_COL_OFFSET = mpert # Plasma–Plasma block - j1, j2 = 1, 1 - kernel!(grad_greenfunction_mat, greenfunction_temp, plasma_surf.x, plasma_surf.z, plasma_surf.x, plasma_surf.z, j1, j2, n) + kernel!(grad_green, green_temp, plasma_surf, plasma_surf, n) # Fourier transform plasma-plasma block - fourier_transform!(grri, greenfunction_temp, plasma_surf.cos_mn_basis, 0, 0) - fourier_transform!(grri, greenfunction_temp, plasma_surf.sin_mn_basis, 0, mpert) + fourier_transform!(green_fourier, green_temp, plasma_surf.cos_mn_basis, PLASMA_ROW_OFFSET, COS_COL_OFFSET) + fourier_transform!(green_fourier, green_temp, plasma_surf.sin_mn_basis, PLASMA_ROW_OFFSET, SIN_COL_OFFSET) !wall.nowall && begin # Plasma–Wall block - j1, j2 = 1, 2 - kernel!(grad_greenfunction_mat, greenfunction_temp, plasma_surf.x, plasma_surf.z, wall.x, wall.z, j1, j2, n) + kernel!(grad_green, green_temp, plasma_surf, wall, n) # Wall–Wall block - j1, j2 = 2, 2 - kernel!(grad_greenfunction_mat, greenfunction_temp, wall.x, wall.z, wall.x, wall.z, j1, j2, n) - + kernel!(grad_green, green_temp, wall, wall, n) # Wall–Plasma block - j1, j2 = 2, 1 - kernel!(grad_greenfunction_mat, greenfunction_temp, wall.x, wall.z, plasma_surf.x, plasma_surf.z, j1, j2, n) + kernel!(grad_green, green_temp, wall, plasma_surf, n) - # Fourier transform wall blocks into grri - fourier_transform!(grri, greenfunction_temp, plasma_surf.cos_mn_basis, mtheta, 0) - fourier_transform!(grri, greenfunction_temp, plasma_surf.sin_mn_basis, mtheta, mpert) + # Fourier transform wall blocks into green_fourier + fourier_transform!(green_fourier, green_temp, plasma_surf.cos_mn_basis, WALL_ROW_OFFSET, COS_COL_OFFSET) + fourier_transform!(green_fourier, green_temp, plasma_surf.sin_mn_basis, WALL_ROW_OFFSET, SIN_COL_OFFSET) end # Add cn0 to make grdgre nonsingular for n=0 modes @@ -268,60 +270,48 @@ function compute_vacuum_response(inputs::VacuumInput, wall_settings::WallShapeSe @warn "Adding $cn0 to diagonal of grdgre to regularize n=0 mode; this may affect accuracy of results." mth12 = wall.nowall ? mtheta : 2 * mtheta for i in 1:mth12, j in 1:mth12 - grad_greenfunction_mat[i, j] += cn0 + grad_green[i, j] += cn0 end end # Only needed for mutual inductance with the wall calculations (kernelsign < 0) && begin - grad_greenfunction_mat .*= kernelsign + grad_green .*= kernelsign # Account for factor of 2 in diagonal terms in eq. 90 of Chance for i in 1:(2*mtheta) - grad_greenfunction_mat[i, i] += 2.0 + grad_green[i, i] += 2.0 end end # Invert the vacuum response system of equations, eqs. 92-94ish of Chance 1997 (gelimb in Fortran) # If plasma only, lower blocks will be empty if wall.nowall - @views grri[1:mtheta, :] .= grad_greenfunction_mat[1:mtheta, 1:mtheta] \ grri[1:mtheta, :] + @views green_fourier[1:mtheta, :] .= grad_green[1:mtheta, 1:mtheta] \ green_fourier[1:mtheta, :] else - grri .= grad_greenfunction_mat \ grri + green_fourier .= grad_green \ green_fourier end # There's some logic that computes xpass/zpass and chiwc/chiws here, might eventually be needed? # Perform inverse Fourier transforms to get response matrix components (eq. 115-118 of Chance 2007) - arr = zeros(mpert, mpert) - aii = zeros(mpert, mpert) - ari = zeros(mpert, mpert) - air = zeros(mpert, mpert) - fourier_inverse_transform!(arr, grri, plasma_surf.cos_mn_basis, 0, 0) - fourier_inverse_transform!(aii, grri, plasma_surf.sin_mn_basis, 0, mpert) - fourier_inverse_transform!(ari, grri, plasma_surf.sin_mn_basis, 0, 0) - fourier_inverse_transform!(air, grri, plasma_surf.cos_mn_basis, 0, mpert) + arr, aii, ari, air = ntuple(_ -> zeros(mpert, mpert), 4) + fourier_inverse_transform!(arr, green_fourier, plasma_surf.cos_mn_basis, PLASMA_ROW_OFFSET, COS_COL_OFFSET) + fourier_inverse_transform!(aii, green_fourier, plasma_surf.sin_mn_basis, PLASMA_ROW_OFFSET, SIN_COL_OFFSET) + fourier_inverse_transform!(ari, green_fourier, plasma_surf.sin_mn_basis, PLASMA_ROW_OFFSET, COS_COL_OFFSET) + fourier_inverse_transform!(air, green_fourier, plasma_surf.cos_mn_basis, PLASMA_ROW_OFFSET, SIN_COL_OFFSET) # Final form of vacuum response matrix (eq. 114 of Chance 2007) - vacmat = arr .+ aii - vacmti = air .- ari + wv = complex.(arr .+ aii, air .- ari) # Force symmetry of response matrix if desired - force_wv_symmetry && begin - for l1 in 1:mpert - for l2 in l1:mpert - vacmat[l1, l2] = 0.5 * (vacmat[l1, l2] + vacmat[l2, l1]) - vacmti[l1, l2] = 0.5 * (vacmti[l1, l2] - vacmti[l2, l1]) - end - end - end - wv = complex.(vacmat, vacmti) + force_wv_symmetry && hermitianpart!(wv) # Create xzpts array - xzpts = zeros(Float64, inputs.mtheta, 4) + xzpts = zeros(inputs.mtheta, 4) @views xzpts[:, 1] .= plasma_surf.x @views xzpts[:, 2] .= plasma_surf.z @views xzpts[:, 3] .= wall.x @views xzpts[:, 4] .= wall.z - return wv, grri, xzpts + return wv, green_fourier, xzpts end function compute_vacuum_response_3D(inputs::VacuumInput) diff --git a/src/Vacuum/VacuumInternals.jl b/src/Vacuum/VacuumInternals.jl index 090e34b0..89ebe8fb 100644 --- a/src/Vacuum/VacuumInternals.jl +++ b/src/Vacuum/VacuumInternals.jl @@ -45,7 +45,7 @@ const GAUSSIANPOINTS32 = [ ] """ - kernel!(grad_greenfunction_mat, greenfunction_mat, x_obspoints, z_obspoints, x_sourcepoints, z_sourcepoints, j1, j2, isgn, iops, inputs; xwall=nothing, zwall=nothing) + kernel!(grad_greenfunction_mat, greenfunction_mat, observer, source, n) Compute kernels of integral equation for Laplace's equation in a torus. @@ -56,11 +56,8 @@ The residue calculation needs to be updated for open walls.** - `grad_greenfunction_mat`: Gradient Green's function matrix (output) - `greenfunction_mat`: Green's function matrix (output) - - `x_obspoints`: Observer x coordinates (R coordinates) - - `z_obspoints`: Observer z coordinates (Z coordinates) - - `x_sourcepoints`: Source x coordinates (R coordinates) - - `z_sourcepoints`: Source z coordinates (Z coordinates) - - `j1/j2`: Block index for observer/source (1=plasma, 2=wall) + - `observer`: Observer geometry struct (PlasmaGeometry or WallGeometry) + - `source`: Source geometry struct (PlasmaGeometry or WallGeometry) - `n`: Toroidal mode number # Returns @@ -79,30 +76,28 @@ but grad_greenfunction_mat is not since it fills a different block of the function kernel!( grad_greenfunction_mat::Matrix{Float64}, greenfunction_mat::Matrix{Float64}, - x_obspoints::Vector{Float64}, - z_obspoints::Vector{Float64}, - x_sourcepoints::Vector{Float64}, - z_sourcepoints::Vector{Float64}, - j1::Int, - j2::Int, + observer::Union{PlasmaGeometry,WallGeometry}, + source::Union{PlasmaGeometry,WallGeometry}, n::Int ) - # These used to be function arguments, but can just set inside here based on j1/j2 - # TODO: pass in the entire PlasmaGeom or WallGeom structs, check struct types to get this info, then access as usual - plasma_plasma_block = j1 == 1 && j2 == 1 # previously iops - plasma_is_source = j2 == 1 # previously iopw - isgn = plasma_is_source ? -1 : 1 - - mtheta = length(x_obspoints) + mtheta = length(observer.x) dtheta = 2π / mtheta theta_grid = range(; start=0, length=mtheta, step=dtheta) + # Take a view of the corresponding block of the grad_greenfunction_mat + col_index = (source isa PlasmaGeometry ? 1 : 2) + row_index = (observer isa PlasmaGeometry ? 1 : 2) + grad_greenfunction_block = view( + grad_greenfunction_mat, + ((row_index-1)*mtheta+1):(row_index*mtheta), + ((col_index-1)*mtheta+1):(col_index*mtheta) + ) + # Zero out greenfunction_mat at start of each kernel call (matches Fortran behavior) fill!(greenfunction_mat, 0.0) - grad_greenfunction_block = @view(grad_greenfunction_mat[(j1-1)*mtheta .+ (1:mtheta), (j2-1)*mtheta .+ (1:mtheta)]) - if mtheta != length(z_obspoints) || mtheta != length(x_sourcepoints) || mtheta != length(z_sourcepoints) + if mtheta != length(observer.z) || mtheta != length(source.x) || mtheta != length(source.z) error("Length of input arrays (xobs, zobs, xsource, zsce) are different. All length should be the same") end @@ -113,17 +108,15 @@ function kernel!( log_correction = [log_correction_2, log_correction_1, log_correction_0, log_correction_1, log_correction_2] # Used for Z'_θ and X'_θ in eq.(51) - # TODO: just pass in the entire struct and get dx_dtheta, dz_dtheta on the grid points from there? - spline_x = cubic_spline_interpolation(theta_grid, x_sourcepoints; extrapolation_bc=Interpolations.Periodic()) - spline_z = cubic_spline_interpolation(theta_grid, z_sourcepoints; extrapolation_bc=Interpolations.Periodic()) + spline_x = cubic_spline_interpolation(theta_grid, source.x; extrapolation_bc=Interpolations.Periodic()) + spline_z = cubic_spline_interpolation(theta_grid, source.z; extrapolation_bc=Interpolations.Periodic()) dx_dtheta = [Interpolations.gradient(spline_x, t)[1] for t in theta_grid] dz_dtheta = [Interpolations.gradient(spline_z, t)[1] for t in theta_grid] # Loop through observer points for j in 1:mtheta # Initialize variables - x_obs, z_obs, theta_obs = x_obspoints[j], z_obspoints[j], theta_grid[j] - grad_green_0 = 0.0 # simpson integral for coupling_0 (𝒥 ∇'𝒢⁰∇'ℒ) + x_obs, z_obs, theta_obs = observer.x[j], observer.z[j], theta_grid[j] # Obtain nonsingular region (endpoints at j+2 and j-2, so exclude j-1, j, and j+1) nonsing_idx = mod1.((j+2):(j+mtheta-2), mtheta) # mod1 ensures isrc is in [1, mtheta] @@ -136,14 +129,13 @@ function kernel!( # Perform Simpson integration for nonsingular source points for (isrc, wsimpson) in zip(nonsing_idx, simpson_weights) # G_n is 2pi𝒢ⁿ; coupling_n is 𝒥 ∇'𝒢ⁿ∇'ℒ; coupling_0 is 𝒥 ∇'𝒢ⁿ∇'ℒ for n=0 - x_source, z_source = x_sourcepoints[isrc], z_sourcepoints[isrc] - G_n, coupling_n, coupling_0 = green(x_obs, z_obs, x_source, z_source, dx_dtheta[isrc], dz_dtheta[isrc], n) + G_n, coupling_n, coupling_0 = green(x_obs, z_obs, source.x[isrc], source.z[isrc], dx_dtheta[isrc], dz_dtheta[isrc], n) # Sum contributions to Green's function matrices using Simpson weight - grad_greenfunction_block[j, isrc] += isgn * coupling_n * wsimpson greenfunction_mat[j, isrc] += G_n * wsimpson - # TODO: can we just subtract this off grad_greenfunction_block here? - grad_green_0 += coupling_0 * wsimpson + grad_greenfunction_block[j, isrc] += coupling_n * wsimpson + # Subtract regular integral component of δⱼᵢK⁰ in eq. 83 + grad_greenfunction_block[j, j] -= coupling_0 * wsimpson end # Perform Gaussian quadrature for singular points (source = obs point) @@ -151,11 +143,8 @@ function kernel!( sing_idx = mod1.(j .+ ((mtheta-2):(mtheta+2)), mtheta) # Integrate region of length 2 * dtheta on left/right of singularity for region in ["left", "right"] - gauss_xleft = theta_obs - (region == "left" ? 2 * dtheta : 0) - gauss_xright = gauss_xleft + 2 * dtheta - gauss_xavg = (gauss_xright + gauss_xleft) / 2 - # TODO: can just make gauss_xavg = theta_obs ± dtheta depending on region? - theta_gauss = gauss_xavg .+ GAUSSIANPOINTS .* dtheta + gauss_mid = theta_obs + (region == "left" ? -dtheta : dtheta) + theta_gauss = gauss_mid .+ GAUSSIANPOINTS .* dtheta wgauss = GAUSSIANWEIGHTS .* dtheta for ig in 1:8 # 8-point Gaussian quadrature # Compute green function for this Gaussian point @@ -167,9 +156,10 @@ function kernel!( G_n, coupling_n, coupling_0 = green(x_obs, z_obs, x_gauss, z_gauss, dx_dtheta_gauss, dz_dtheta_gauss, n) # Add logarithm to G_n to analytically isolate the singularity (first type), Chance eq.(75) - G_n_nonsingular = plasma_plasma_block ? G_n + log((theta_obs-theta_gauss[ig])^2)/x_obs : G_n + if observer isa PlasmaGeometry && source isa PlasmaGeometry # previously iops + G_n += log((theta_obs - theta_gauss[ig])^2) / x_obs + end - # Compute 5-point Lagrange basis polynomials at the Gauss point and multiply by quadrature weight p = (theta_gauss[ig] - theta_obs) / dtheta # p = θ/Δ = (θⱼ - θ')/Δ stencil_points = SVector(-2, -1, 0, 1, 2) lagrange_stencil = ntuple(5) do i @@ -178,36 +168,43 @@ function kernel!( end |> SVector # First type of singularity: 𝒢ⁿ, occurs plasma as source only (see RHS of Chance eqs. 26/27) - if plasma_is_source - @. @views greenfunction_mat[j, sing_idx] += wgauss[ig] * G_n_nonsingular * lagrange_stencil + if source isa PlasmaGeometry # previously iopw + @. @views greenfunction_mat[j, sing_idx] += wgauss[ig] * G_n * lagrange_stencil end # Second type of singularity: 𝒦ⁿ (Eq. 86: 𝒦ⁿαᵢ - δⱼᵢK⁰) - @. @views grad_greenfunction_block[j, sing_idx] += wgauss[ig] * isgn * coupling_n * lagrange_stencil + @. @views grad_greenfunction_block[j, sing_idx] += wgauss[ig] * coupling_n * lagrange_stencil # Subtract off the diverging singular n=0 component - grad_greenfunction_block[j, j] -= isgn * coupling_0 * wgauss[ig] + grad_greenfunction_block[j, j] -= coupling_0 * wgauss[ig] end end - # Set residue based on logic similar to Table I of Chance 1997 + existing δⱼᵢ in eq. 69 - # Would need to pass in wall geometry to generalize this to open walls - is_closed_toroidal = true - if is_closed_toroidal - residue = (j1 == 2.0) ? 0.0 : (j2 == 1 ? 2.0 : -2.0) # Chance eq. 89 - else - # TODO: this line can be gotten rid of if we are never doing open walls - residue = (j1 == j2) ? 2.0 : 0.0 # Chance eq. 90 - end - # Subtract regular integral component of δⱼᵢK⁰ in eq. 83 and add residue value in eq. 89/90 - grad_greenfunction_block[j, j] += residue - isgn * grad_green_0 - # Subtract off analytic singular integral from Chance eq.(75) if plasma-plasma block - if plasma_plasma_block + if observer isa PlasmaGeometry && source isa PlasmaGeometry @. @views greenfunction_mat[j, sing_idx] -= log_correction / x_obs end end + + # Account for normal direction pointing out of vacuum integration region in 𝒦ⁿ ⋅ dS, previously isgn + # Negative for plasma since dS = ∇ψ J dθdζ and ∇ψ points outward but outward normal is inward + @views grad_greenfunction_block .*= (source isa PlasmaGeometry ? -1 : 1) + # Since we computed 2π𝒢, divide by 2π to get 𝒢 greenfunction_mat ./= 2π + + # Determine residue based on logic similar to Table I of Chance 1997 + existing δⱼᵢ in eq. 69 + # Would need to pass in wall geometry to generalize this to open walls + is_closed_toroidal = true + if is_closed_toroidal # Chance eq. 89 + residue = (observer isa WallGeometry) ? 0.0 : (source isa PlasmaGeometry ? 2.0 : -2.0) + else # Chance eq. 90 + # TODO: this line can be gotten rid of if we are never doing open walls + residue = (typeof(observer) == typeof(source)) ? 2.0 : 0.0 + end + # Add residue value from eq. 89/90 to block diagonal + @inbounds for i in 1:mtheta + grad_greenfunction_block[i, i] += residue + end end """ @@ -274,7 +271,7 @@ end # Returns the array of derivatives at all x points, I think this acts like difspl # in the Fortran but need to check/consolidate spline routines later function periodic_cubic_deriv(theta, vals) - itp = scale(interpolate(vals, BSpline(Cubic(Periodic(OnGrid())))), theta) + itp = cubic_spline_interpolation(theta, vals; extrapolation_bc=Interpolations.Periodic()) #scale(interpolate(vals, BSpline(Cubic(Periodic(OnGrid())))), theta) return first.(Interpolations.gradient.(Ref(itp), theta)) end From 7eec939c506e732e9337a284c99a0035f94ad850 Mon Sep 17 00:00:00 2001 From: Jake Halpern Date: Wed, 21 Jan 2026 11:30:18 -0500 Subject: [PATCH 06/31] minor --- src/DCON/Free.jl | 1 - 1 file changed, 1 deletion(-) diff --git a/src/DCON/Free.jl b/src/DCON/Free.jl index 9780c8c8..d0d916b3 100644 --- a/src/DCON/Free.jl +++ b/src/DCON/Free.jl @@ -37,7 +37,6 @@ function free_run!(odet::OdeState, ctrl::DconControl, equil::Equilibrium.PlasmaE # Output data for unit testing and benchmarking if true #intr.debug_settings.output_benchmark_data - @info "Outputting top level vacuum debug data for n = $n" farwall_flag = intr.wall_settings.shape == "nowall" ? true : false benchmark_inputs = VacuumBenchmarkInputs( wv_block, intr.mpert, equil.config.control.mtheta, ctrl.mthvac, From 7ca65452f5a22bab4c9a40cefec48ca215333967 Mon Sep 17 00:00:00 2001 From: Jake Halpern Date: Wed, 21 Jan 2026 11:41:21 -0500 Subject: [PATCH 07/31] VACUUM - WIP - vectorizing trig basis --- src/Vacuum/VacuumInternals.jl | 7 +++---- src/Vacuum/VacuumStructs.jl | 11 ++--------- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/Vacuum/VacuumInternals.jl b/src/Vacuum/VacuumInternals.jl index 89ebe8fb..75dd122b 100644 --- a/src/Vacuum/VacuumInternals.jl +++ b/src/Vacuum/VacuumInternals.jl @@ -107,11 +107,10 @@ function kernel!( log_correction_2=4.0*dtheta*(7.0*log(2*dtheta)-11.0/15.0)/45.0 log_correction = [log_correction_2, log_correction_1, log_correction_0, log_correction_1, log_correction_2] - # Used for Z'_θ and X'_θ in eq.(51) + # TODO: this isn't the same as the periodic_cubic_deriv interpolation? + # We need to interpolate off-grid during Gaussian quadrature spline_x = cubic_spline_interpolation(theta_grid, source.x; extrapolation_bc=Interpolations.Periodic()) spline_z = cubic_spline_interpolation(theta_grid, source.z; extrapolation_bc=Interpolations.Periodic()) - dx_dtheta = [Interpolations.gradient(spline_x, t)[1] for t in theta_grid] - dz_dtheta = [Interpolations.gradient(spline_z, t)[1] for t in theta_grid] # Loop through observer points for j in 1:mtheta @@ -129,7 +128,7 @@ function kernel!( # Perform Simpson integration for nonsingular source points for (isrc, wsimpson) in zip(nonsing_idx, simpson_weights) # G_n is 2pi𝒢ⁿ; coupling_n is 𝒥 ∇'𝒢ⁿ∇'ℒ; coupling_0 is 𝒥 ∇'𝒢ⁿ∇'ℒ for n=0 - G_n, coupling_n, coupling_0 = green(x_obs, z_obs, source.x[isrc], source.z[isrc], dx_dtheta[isrc], dz_dtheta[isrc], n) + G_n, coupling_n, coupling_0 = green(x_obs, z_obs, source.x[isrc], source.z[isrc], source.dx_dtheta[isrc], source.dz_dtheta[isrc], n) # Sum contributions to Green's function matrices using Simpson weight greenfunction_mat[j, isrc] += G_n * wsimpson diff --git a/src/Vacuum/VacuumStructs.jl b/src/Vacuum/VacuumStructs.jl index c264d27f..8c447d55 100644 --- a/src/Vacuum/VacuumStructs.jl +++ b/src/Vacuum/VacuumStructs.jl @@ -163,15 +163,8 @@ function initialize_plasma_surface(inputs::VacuumInput) dz_dtheta = periodic_cubic_deriv(θ_grid, Z) # Precompute Fourier transform terms, sin(mθ - nν) and cos(mθ - nν) - sin_mn_basis = zeros(mtheta, mpert) - cos_mn_basis = zeros(mtheta, mpert) - for j in 1:mpert - for i in 1:mtheta - m = mlow + j - 1 - cos_mn_basis[i, j] = cos(m * θ_grid[i] - n * ν[i]) - sin_mn_basis[i, j] = sin(m * θ_grid[i] - n * ν[i]) - end - end + sin_mn_basis = sin.((mlow .+ (0:(mpert-1))') .* θ_grid .- n .* ν) + cos_mn_basis = cos.((mlow .+ (0:(mpert-1))') .* θ_grid .- n .* ν) # Precompute Fourier transform terms, sin(lθ - nν(θ) - nϕ) and cos(lθ - nν(θ) - nϕ) sin_mn_basis3D = zeros(mtheta*nzeta, mpert*npert) From 7d90cdbb93a6a820a9157420753b851a4751cd21 Mon Sep 17 00:00:00 2001 From: Jake Halpern Date: Wed, 21 Jan 2026 14:41:00 -0500 Subject: [PATCH 08/31] VACUUM - WIP - final cleanups to 2D response, updating example tests, preparing to start BIEST conversion --- examples/DIIID-like_ideal_example/dcon.toml | 5 +- examples/DIIID-like_ideal_example/equil.toml | 2 +- examples/Solovev_ideal_example/dcon.toml | 9 +- examples/Solovev_ideal_example/sol.toml | 8 +- src/DCON/Free.jl | 23 ++- src/Vacuum/Vacuum.jl | 141 +++++++------------ src/Vacuum/VacuumInternals.jl | 23 +-- 7 files changed, 92 insertions(+), 119 deletions(-) diff --git a/examples/DIIID-like_ideal_example/dcon.toml b/examples/DIIID-like_ideal_example/dcon.toml index cb5e2142..c6ee2a8b 100644 --- a/examples/DIIID-like_ideal_example/dcon.toml +++ b/examples/DIIID-like_ideal_example/dcon.toml @@ -17,7 +17,8 @@ nn_high = 1 # Largest toroidal mode number to include delta_mlow = 8 # Expands lower bound of Fourier harmonics delta_mhigh = 8 # Expands upper bound of Fourier harmonics delta_mband = 0 # Integration keeps only this wide a band... -mthvac = 512 # Number of points used in splines over poloidal angle at plasma-vacuum interface. +mthvac = 64 # Number of points used in splines over poloidal angle at plasma-vacuum interface. +nzvac = 32 thmax0 = 1 # Linear multiplier on the automatic choice of theta integration bounds kin_flag = false # Kinetic EL equation (default: false) @@ -46,4 +47,4 @@ cw = 0 dw = 0.5 tw = 0.05 equal_arc_wall = 0 -a = 0.2415 \ No newline at end of file +a = 0.2415 diff --git a/examples/DIIID-like_ideal_example/equil.toml b/examples/DIIID-like_ideal_example/equil.toml index e85c02a9..8c941bb8 100644 --- a/examples/DIIID-like_ideal_example/equil.toml +++ b/examples/DIIID-like_ideal_example/equil.toml @@ -2,7 +2,7 @@ eq_type = "efit" # Type of the input 2D equilibrium file eq_filename = "TKMKR_D3Dlike_default_Hmode.geqdsk" # path to equilibrium file -jac_type = "hamada" # Coordinate system (hamada, pest, boozer, equal_arc) +jac_type = "pest" # Coordinate system (hamada, pest, boozer, equal_arc) power_bp = 0 # del.B ~ B_p^power_bp * B^power_b / R^power_r power_b = 0 # del.B ~ B_p^power_bp * B^power_b / R^power_r power_r = 0 # del.B ~ B_p^power_bp * B^power_b / R^power_r diff --git a/examples/Solovev_ideal_example/dcon.toml b/examples/Solovev_ideal_example/dcon.toml index dde9cd83..d5c37793 100644 --- a/examples/Solovev_ideal_example/dcon.toml +++ b/examples/Solovev_ideal_example/dcon.toml @@ -16,7 +16,8 @@ nn_high = 1 # Largest toroidal mode number to include delta_mlow = 8 # Expands lower bound of Fourier harmonics delta_mhigh = 8 # Expands upper bound of Fourier harmonics delta_mband = 0 # Integration keeps only this wide a band... -mthvac = 960 # Number of points used in splines over poloidal angle at plasma-vacuum interface. +mthvac = 24 # Number of points used in splines over poloidal angle at plasma-vacuum interface. +nzvac = 48 thmax0 = 1 # Linear multiplier on the automatic choice of theta integration bounds kin_flag = false # Kinetic EL equation (default: false) @@ -36,14 +37,14 @@ tol_r = 1e-7 # Relative tolerance of dynamic integration steps crossover = 1e-2 # Fractional distance from rational q at which tol switches singfac_min = 1e-4 # Fractional distance from rational q at which ideal jump enforced ucrit = 1e3 # Maximum fraction of solutions allowed before re-normalized -force_wv_symmetry = true # Forces vacuum energy matrix symmetry +force_wv_symmetry = true # Forces vacuum energy matrix symmetry [WALL] -shape = "conformal" # String selecting wall shape ["nowall", "conformal", "elliptical", "dee", "mod_dee", "from_file"] +shape = "nowall" # String selecting wall shape ["nowall", "conformal", "elliptical", "dee", "mod_dee", "from_file"] a = 0.2415 # The distance of the wall from the plasma in units of major radius (conformal), or minor radius parameter (others). aw = 0.05 # Half-thickness of the wall. bw = 1.5 # Elongation. cw = 0 # Offset of the center of the wall from the major radius. dw = 0.5 # Triangularity tw = 0.05 # Sharpness of the corners of the wall. Try 0.05 as a good initial value. -equal_arc_wall = true # Flag to enforce equal arcs distribution of the nodes on the wall. Best results unless the wall is very close to the plasma. +equal_arc_wall = false # Flag to enforce equal arcs distribution of the nodes on the wall. Best results unless the wall is very close to the plasma. diff --git a/examples/Solovev_ideal_example/sol.toml b/examples/Solovev_ideal_example/sol.toml index cb8418b1..303a6ec0 100644 --- a/examples/Solovev_ideal_example/sol.toml +++ b/examples/Solovev_ideal_example/sol.toml @@ -2,10 +2,10 @@ mr = 128 # number of radial grid zones mz = 128 # number of axial grid zones ma = 128 # number of flux grid zones -e = 1.6 # elongation -a = 0.33 # minor radius -r0 = 1.0 # major radius +e = 1.0 # elongation +a = 1.0 # minor radius +r0 = 5.0 # major radius q0 = 1.9 # safety factor at the o-point p0fac=1 # scale on-axis pressure (P-> P+P0*p0fac. beta changes. Phi,q constant) b0fac=1 # scale toroidal field at constant beta (s*Phi,s*f,s^2*P. bt changes. Shape,beta constant) -f0fac=1 # scale toroidal field at constant pressure (s*f. beta,q changes. Phi,p,bp constant) \ No newline at end of file +f0fac=1 # scale toroidal field at constant pressure (s*f. beta,q changes. Phi,p,bp constant) diff --git a/src/DCON/Free.jl b/src/DCON/Free.jl index d0d916b3..48b559a0 100644 --- a/src/DCON/Free.jl +++ b/src/DCON/Free.jl @@ -33,7 +33,28 @@ function free_run!(odet::OdeState, ctrl::DconControl, equil::Equilibrium.PlasmaE fill!(vac.xzpts, 0.0) # Compute block of vacuum energy matrix for one toroidal mode number - wv_block, vac.grri, vac.xzpts = Vacuum.compute_vacuum_response(vac_inputs, intr.wall_settings) + wv, vac.grri, vac.xzpts = Vacuum.compute_vacuum_response(vac_inputs, intr.wall_settings) + + wv3D, grri3D, xzpts3D = Vacuum.compute_vacuum_response_3D(vac_inputs, intr.wall_settings) + + println("2D Vacuum response matrix wv:") + display(wv) + + println("3D Vacuum response matrix wv3D:") + display(wv3D) + + println("Difference between 2D and 3D vacuum response matrices:") + display(wv .- wv3D) + display(norm(wv .- wv3D)) + + println("Maximum eigenvalues:") + display(maximum(real.(eigvals(wv)))) + display(maximum(real.(eigvals(wv3D)))) + + println("Difference in maximum eigenvalue:") + display(maximum(real.(eigvals(wv))) - maximum(real.(eigvals(wv3D)))) + + error("Vacuum response matrix computation complete.") # Output data for unit testing and benchmarking if true #intr.debug_settings.output_benchmark_data diff --git a/src/Vacuum/Vacuum.jl b/src/Vacuum/Vacuum.jl index b4eb039d..370359a2 100644 --- a/src/Vacuum/Vacuum.jl +++ b/src/Vacuum/Vacuum.jl @@ -295,10 +295,10 @@ function compute_vacuum_response(inputs::VacuumInput, wall_settings::WallShapeSe # Perform inverse Fourier transforms to get response matrix components (eq. 115-118 of Chance 2007) arr, aii, ari, air = ntuple(_ -> zeros(mpert, mpert), 4) - fourier_inverse_transform!(arr, green_fourier, plasma_surf.cos_mn_basis, PLASMA_ROW_OFFSET, COS_COL_OFFSET) - fourier_inverse_transform!(aii, green_fourier, plasma_surf.sin_mn_basis, PLASMA_ROW_OFFSET, SIN_COL_OFFSET) - fourier_inverse_transform!(ari, green_fourier, plasma_surf.sin_mn_basis, PLASMA_ROW_OFFSET, COS_COL_OFFSET) - fourier_inverse_transform!(air, green_fourier, plasma_surf.cos_mn_basis, PLASMA_ROW_OFFSET, SIN_COL_OFFSET) + fourier_inverse_transform!(arr, green_fourier, plasma_surf.cos_mn_basis, PLASMA_ROW_OFFSET, COS_COL_OFFSET, 4π^2 / mtheta) + fourier_inverse_transform!(aii, green_fourier, plasma_surf.sin_mn_basis, PLASMA_ROW_OFFSET, SIN_COL_OFFSET, 4π^2 / mtheta) + fourier_inverse_transform!(ari, green_fourier, plasma_surf.sin_mn_basis, PLASMA_ROW_OFFSET, COS_COL_OFFSET, 4π^2 / mtheta) + fourier_inverse_transform!(air, green_fourier, plasma_surf.cos_mn_basis, PLASMA_ROW_OFFSET, SIN_COL_OFFSET, 4π^2 / mtheta) # Final form of vacuum response matrix (eq. 114 of Chance 2007) wv = complex.(arr .+ aii, air .- ari) @@ -314,29 +314,32 @@ function compute_vacuum_response(inputs::VacuumInput, wall_settings::WallShapeSe return wv, green_fourier, xzpts end -function compute_vacuum_response_3D(inputs::VacuumInput) +function compute_vacuum_response_3D(inputs::VacuumInput, wall_settings::WallShapeSettings) # Initialization and allocations - (; mtheta, mpert, n, force_wv_symmetry, nzeta, npert) = inputs + (; mtheta, mpert, n, kernelsign, force_wv_symmetry, nzeta, npert) = inputs + num_gridpoints = nzeta * mtheta + num_modes = npert * mpert plasma_surf = initialize_plasma_surface(inputs) - grri = zeros(mtheta, 2 * mpert) - grad_greenfunction_mat = zeros(mtheta, mtheta) - greenfunction_temp = zeros(mtheta, mtheta) + wall = initialize_wall(inputs, plasma_surf, wall_settings) + grad_green = zeros(num_gridpoints, num_gridpoints) # for walls, this is 2*mtheta x 2*mtheta + green_temp = zeros(num_gridpoints, num_gridpoints) - # Plasma–Plasma block - kernel_plasma!(grad_greenfunction_mat, greenfunction_temp, plasma_surf.x, plasma_surf.z, plasma_surf.x, plasma_surf.z, n) + # 𝒢ₗ(θⱼ) from Chance eq. 106-108. first num_gridpoints rows are plasma as observer, second are wall + # First num_modes columns are real (cosine), second num_modes are imaginary (sine) + green_fourier = zeros(num_gridpoints, 2 * num_modes) + PLASMA_ROW_OFFSET = 0 + WALL_ROW_OFFSET = num_gridpoints + COS_COL_OFFSET = 0 + SIN_COL_OFFSET = num_modes - # Call BIEST to compute Green's function matrices for plasma boundary - # Use Nt toroidal points to properly discretize the 3D surface (must be >= 6 for BIEST) - num_gridpoints = nzeta * mtheta - num_modes = npert * mpert - green_3D = zeros(num_gridpoints, num_gridpoints) - gradgreen_3D = zeros(num_gridpoints, num_gridpoints) - grri_3D = zeros(num_gridpoints, 2 * num_modes) + !wall.nowall && error("No walls yet!") # DEBUG + # Plasma–Plasma block # G = single-layer kernel, K = double-layer kernel + # Use Nt toroidal points to properly discretize the 3D surface (must be >= 6 for BIEST) println("Calling BIEST with Nt=$nzeta, Np=$mtheta (total 3D points: $(num_gridpoints))...") - compute_green_matrices!(green_3D, gradgreen_3D, plasma_surf.x, plasma_surf.z, plasma_surf.ν, nzeta) + compute_green_matrices!(green_temp, grad_green, plasma_surf.x, plasma_surf.z, plasma_surf.ν, nzeta) # Sum Green's function matrices over toroidal direction to recover 2D poloidal slice # green_2D = zeros(ComplexF64, mtheta, mtheta) @@ -358,92 +361,48 @@ function compute_vacuum_response_3D(inputs::VacuumInput) # println("Sum over zeta entries of gradgreen_3D (double-layer):") # display(gradgreen_2D) - identity = Matrix{Float64}(I, num_gridpoints, num_gridpoints) - gradgreen_3D .+= identity .* 0.5 # Add identity*0.5 to double-layer kernel for jump condition + grad_green += I * 0.5 # Add 0.5I to double-layer kernel for jump condition # Fourier transform plasma-plasma block - fourier_transform!(grri, greenfunction_temp, plasma_surf.cos_mn_basis, 0, 0) - fourier_transform!(grri, greenfunction_temp, plasma_surf.sin_mn_basis, 0, mpert) - grri .*= 2π / mtheta # Multiply by periodic trapezoidal quadrature weights + fourier_transform!(green_fourier, green_temp, plasma_surf.cos_mn_basis3D, PLASMA_ROW_OFFSET, COS_COL_OFFSET) + fourier_transform!(green_fourier, green_temp, plasma_surf.sin_mn_basis3D, PLASMA_ROW_OFFSET, SIN_COL_OFFSET) - # Fourier transform plasma-plasma block - fourier_transform!(grri_3D, green_3D, plasma_surf.cos_mn_basis3D, 0, 0) - fourier_transform!(grri_3D, green_3D, plasma_surf.sin_mn_basis3D, 0, num_modes) - grri_3D .*= (2π / mtheta) * (2π / nzeta) # Multiply by periodic trapezoidal quadrature weights in 3D + !wall.nowall && error("No walls yet!") - # Invert the vacuum response system of equations, eqs. 92-94ish of Chance 1997 - grri .= grad_greenfunction_mat \ grri - grri_3D .= gradgreen_3D \ grri_3D + # Add cn0 to make grdgre nonsingular for n=0 modes + (abs(n) <= 1e-5 && !wall.nowall && wall.is_closed_toroidal) && error("No walls yet!") - # Perform inverse Fourier transforms to get response matrix components (eq. 115-118 of Chance 2007) - arr = zeros(mpert, mpert) - aii = zeros(mpert, mpert) - ari = zeros(mpert, mpert) - air = zeros(mpert, mpert) - fourier_inverse_transform!(arr, grri, plasma_surf.cos_mn_basis, 0, 0) - fourier_inverse_transform!(aii, grri, plasma_surf.sin_mn_basis, 0, mpert) - fourier_inverse_transform!(ari, grri, plasma_surf.sin_mn_basis, 0, 0) - fourier_inverse_transform!(air, grri, plasma_surf.cos_mn_basis, 0, mpert) + # Only needed for mutual inductance with the wall calculations + (kernelsign < 0) && error("No walls yet!") - # Perform inverse Fourier transforms to get response matrix components (eq. 115-118 of Chance 2007) - arr3D = zeros(num_modes, num_modes) - aii3D = zeros(num_modes, num_modes) - ari3D = zeros(num_modes, num_modes) - air3D = zeros(num_modes, num_modes) - fourier_inverse_transform!(arr3D, grri_3D, plasma_surf.cos_mn_basis3D, 0, 0) - fourier_inverse_transform!(aii3D, grri_3D, plasma_surf.sin_mn_basis3D, 0, num_modes) - fourier_inverse_transform!(ari3D, grri_3D, plasma_surf.sin_mn_basis3D, 0, 0) - fourier_inverse_transform!(air3D, grri_3D, plasma_surf.cos_mn_basis3D, 0, num_modes) + # Invert the vacuum response system of equations, eqs. 92-94ish of Chance 1997 (gelimb in Fortran) + # If plasma only, lower blocks will be empty + if wall.nowall + @views green_fourier[1:mtheta, :] .= grad_green[1:mtheta, 1:mtheta] \ green_fourier[1:mtheta, :] + else + error("No walls yet!") + green_fourier .= grad_green \ green_fourier + end - # Final form of vacuum response matrix (eq. 114 of Chance 2007) - vacmat = arr .+ aii - vacmti = air .- ari - # Force symmetry of response matrix if desired - # force_wv_symmetry && begin - # for l1 in 1:mpert - # for l2 in l1:mpert - # vacmat[l1, l2] = 0.5 * (vacmat[l1, l2] + vacmat[l2, l1]) - # vacmti[l1, l2] = 0.5 * (vacmti[l1, l2] - vacmti[l2, l1]) - # end - # end - # end - wv = 2π .* complex.(vacmat, vacmti) - println("2D Vacuum response matrix wv:") - display(wv) + # Perform inverse Fourier transforms to get response matrix components (eq. 115-118 of Chance 2007) + arr, aii, ari, air = ntuple(_ -> zeros(num_modes, num_modes), 4) + fourier_inverse_transform!(arr, green_fourier, plasma_surf.cos_mn_basis3D, PLASMA_ROW_OFFSET, COS_COL_OFFSET, 4π^2 / num_gridpoints) + fourier_inverse_transform!(aii, green_fourier, plasma_surf.sin_mn_basis3D, PLASMA_ROW_OFFSET, SIN_COL_OFFSET, 4π^2 / num_gridpoints) + fourier_inverse_transform!(ari, green_fourier, plasma_surf.sin_mn_basis3D, PLASMA_ROW_OFFSET, COS_COL_OFFSET, 4π^2 / num_gridpoints) + fourier_inverse_transform!(air, green_fourier, plasma_surf.cos_mn_basis3D, PLASMA_ROW_OFFSET, SIN_COL_OFFSET, 4π^2 / num_gridpoints) # Final form of vacuum response matrix (eq. 114 of Chance 2007) - vacmat3D = arr3D .+ aii3D - vacmti3D = air3D .- ari3D + wv = complex.(arr .+ aii, air .- ari) # Force symmetry of response matrix if desired - # force_wv_symmetry && begin - # for l1 in 1:mpert*npert - # for l2 in l1:mpert*npert - # vacmat3D[l1, l2] = 0.5 * (vacmat3D[l1, l2] + vacmat3D[l2, l1]) - # vacmti3D[l1, l2] = 0.5 * (vacmti3D[l1, l2] - vacmti3D[l2, l1]) - # end - # end - # end - wv3D = complex.(vacmat3D, vacmti3D) - - println("3D Vacuum response matrix wv3D:") - display(wv3D) - - println("Difference between 2D and 3D vacuum response matrices:") - display(wv .- wv3D) - display(norm(wv .- wv3D)) - - println("Maximum eigenvalues:") - display(maximum(real.(eigvals(wv)))) - display(maximum(real.(eigvals(wv3D)))) - - println("Difference in maximum eigenvalue:") - display(maximum(real.(eigvals(wv))) - maximum(real.(eigvals(wv3D)))) + force_wv_symmetry && hermitianpart!(wv) # Create xzpts array - xzpts = zeros(Float64, inputs.mtheta, 4) + xzpts = zeros(inputs.mtheta, 4) @views xzpts[:, 1] .= plasma_surf.x @views xzpts[:, 2] .= plasma_surf.z - return wv3D, grri, xzpts + @views xzpts[:, 3] .= wall.x + @views xzpts[:, 4] .= wall.z + return wv, green_fourier, xzpts end """ diff --git a/src/Vacuum/VacuumInternals.jl b/src/Vacuum/VacuumInternals.jl index 75dd122b..acea003c 100644 --- a/src/Vacuum/VacuumInternals.jl +++ b/src/Vacuum/VacuumInternals.jl @@ -218,30 +218,26 @@ Perform the inverse Fourier transform of `gil` onto `gll` using Fourier coeffici - `cs`: Fourier coefficient matrix (mtheta × mpert) - `m00`: Integer offset in the gil matrix (row offset) - `l00`: Integer offset in the gil matrix (column offset) + - `weight`: Quadrature weight factor # Notes - - Computes: `gll[l2, l1] = (2π * dth) * Σ_i cs[i, l2] * gil[i, l1]` + - Computes: `gll[l2, l1] = weight * Σ_i cs[i, l2] * gil[i, l1]` - Performs the same function as fouranv in the Fortran code. # Returns - gll(l2,l1) : output matrix updated in-place (mpert × mpert) """ -function fourier_inverse_transform!(gll::Matrix{Float64}, gil::Matrix{Float64}, cs::Matrix{Float64}, m00::Int, l00::Int) - - # Zero out gll block - num_gridpoints, num_pert = size(cs) - fill!(view(gll, 1:num_pert, 1:num_pert), 0.0) +function fourier_inverse_transform!(gll::Matrix{Float64}, gil::Matrix{Float64}, cs::Matrix{Float64}, m00::Int, l00::Int, weight::Float64) # Inverse Fourier transform via matrix multiply: gll = cs^T * gil * (2π * dth) - # This computes: gll[l2, l1] = (2π * dth) * Σ_i cs[i, l2] * gil[i, l1] - dth = 2π / num_gridpoints - mul!(gll, cs', view(gil, (m00+1):(m00+num_gridpoints), (l00+1):(l00+num_pert)), 2π * dth, 0.0) + num_gridpoints, num_pert = size(cs) + mul!(gll, cs', view(gil, (m00+1):(m00+num_gridpoints), (l00+1):(l00+num_pert)), weight, 0.0) end """ - fourier_transform!(gil, gij, cs, m00, l00, mth, mpert) + fourier_transform!(gil, gij, cs, m00, l00) Purpose: This routine performs a truncated Fourier transform of gij onto gil @@ -251,19 +247,14 @@ end gij(i,j) : input matrix of size (mth × mth), the "physical-space" data cs(j,l) : Fourier coefficient matrix (mth × mpert) m00, l00 : integer offsets in the gil matrix - mth : number of θ-grid points (dimension of gij along i, j) - mpert : number of Fourier modes Output: gil(i', l') : output matrix updated in-place (mth × mpert), where i' = m00 + i and l' = l00 + l """ function fourier_transform!(gil::Matrix{Float64}, gij::Matrix{Float64}, cs::Matrix{Float64}, m00::Int, l00::Int) - # Zero out relevant gil block - num_gridpoints, num_pert = size(cs) - fill!(view(gil, (m00+1):(m00+num_gridpoints), (l00+1):(l00+num_pert)), 0.0) - # Fourier transform via matrix multiply: gil[i, l] = Σ_j gij[i, j] * cs[j, l] + num_gridpoints, num_pert = size(cs) mul!(view(gil, (m00+1):(m00+num_gridpoints), (l00+1):(l00+num_pert)), gij, cs) end From 72f3276930b8bc1ebea9670d94111c090a5cee57 Mon Sep 17 00:00:00 2001 From: Jake Halpern Date: Thu, 22 Jan 2026 11:17:13 -0500 Subject: [PATCH 09/31] VACUUM - WIP - creation of PlasmaGeometry3D struct, messing with interpolants trying to fix the periodic BCs --- src/BIEST.jl | 9 ++++ src/DCON/Free.jl | 4 +- src/Vacuum/Vacuum.jl | 22 ++++++++- src/Vacuum/VacuumInternals.jl | 23 +++++---- src/Vacuum/VacuumStructs.jl | 90 +++++++++++++++++++++++++++++++++-- 5 files changed, 130 insertions(+), 18 deletions(-) diff --git a/src/BIEST.jl b/src/BIEST.jl index 4425c66e..873f0742 100644 --- a/src/BIEST.jl +++ b/src/BIEST.jl @@ -59,6 +59,15 @@ function compute_green_matrices!(G::Matrix{Float64}, K::Matrix{Float64}, R::Vect R, Z, ν, Cint(length(R)), Cint(Nt), G, K) end +function compute_green_matrices!(G::Matrix{Float64}, K::Matrix{Float64}, surf) + isfile(libbiest) || error("BIEST library not found at $libbiest. Build it with: cd src/BIEST && make") + + # Call C++ wrapper which builds the surface from supplied coordinates + ccall((:biest_compute_green_matrices_from3D, libbiest), Cvoid, + (Ptr{Cdouble}, Ptr{Cdouble}, Ptr{Cdouble}, Cint, Cint, Ptr{Cdouble}, Ptr{Cdouble}), + surf.x, surf.y, surf.z, Cint(surf.ntheta), Cint(surf.nzeta), G, K) +end + export compute_green_matrices! end # module BIEST diff --git a/src/DCON/Free.jl b/src/DCON/Free.jl index 48b559a0..2c9b2333 100644 --- a/src/DCON/Free.jl +++ b/src/DCON/Free.jl @@ -32,11 +32,11 @@ function free_run!(odet::OdeState, ctrl::DconControl, equil::Equilibrium.PlasmaE fill!(vac.grri, 0.0) fill!(vac.xzpts, 0.0) + wv3D, grri3D, xzpts3D = Vacuum.compute_vacuum_response_3D(vac_inputs, intr.wall_settings) + # Compute block of vacuum energy matrix for one toroidal mode number wv, vac.grri, vac.xzpts = Vacuum.compute_vacuum_response(vac_inputs, intr.wall_settings) - wv3D, grri3D, xzpts3D = Vacuum.compute_vacuum_response_3D(vac_inputs, intr.wall_settings) - println("2D Vacuum response matrix wv:") display(wv) diff --git a/src/Vacuum/Vacuum.jl b/src/Vacuum/Vacuum.jl index 370359a2..a79fbea2 100644 --- a/src/Vacuum/Vacuum.jl +++ b/src/Vacuum/Vacuum.jl @@ -320,11 +320,30 @@ function compute_vacuum_response_3D(inputs::VacuumInput, wall_settings::WallShap (; mtheta, mpert, n, kernelsign, force_wv_symmetry, nzeta, npert) = inputs num_gridpoints = nzeta * mtheta num_modes = npert * mpert + plasma_surf = initialize_plasma_surface(inputs) wall = initialize_wall(inputs, plasma_surf, wall_settings) grad_green = zeros(num_gridpoints, num_gridpoints) # for walls, this is 2*mtheta x 2*mtheta green_temp = zeros(num_gridpoints, num_gridpoints) + plasma_surf3D = PlasmaGeometry3D(plasma_surf, nzeta) + + if false # dA debugging + a = 0.1 + R0 = 10 + ϕ_grid = range(; start=0, length=nzeta, step=2π/nzeta) + θ_grid = range(; start=0, length=mtheta, step=2π/mtheta) + analytic_dA = [a * (R0 + a * cos(θ)) * (4π^2 / mtheta / nzeta) for θ in θ_grid, ϕ in ϕ_grid] + println("Computed 3D plasma surface differential area elements dA: $(sum(plasma_surf3D.dA))") + println("Sum of analytic dA: $(sum(analytic_dA))") # DEBUG + println("Analytic = $(4π^2 * a * R0)") # DEBUG + println("Difference in analytic and computed dA:") # DEBUG + display(analytic_dA - reshape(plasma_surf3D.dA, size(analytic_dA))) # DEBUG + println("Max difference in dA: $(maximum(abs.(analytic_dA - reshape(plasma_surf3D.dA, size(analytic_dA)))))") # DEBUG + println("Min difference in dA: $(minimum(abs.(analytic_dA - reshape(plasma_surf3D.dA, size(analytic_dA)))))") # DEBUG + error("Debugging dA") # DEBUG + end + # 𝒢ₗ(θⱼ) from Chance eq. 106-108. first num_gridpoints rows are plasma as observer, second are wall # First num_modes columns are real (cosine), second num_modes are imaginary (sine) green_fourier = zeros(num_gridpoints, 2 * num_modes) @@ -339,7 +358,8 @@ function compute_vacuum_response_3D(inputs::VacuumInput, wall_settings::WallShap # G = single-layer kernel, K = double-layer kernel # Use Nt toroidal points to properly discretize the 3D surface (must be >= 6 for BIEST) println("Calling BIEST with Nt=$nzeta, Np=$mtheta (total 3D points: $(num_gridpoints))...") - compute_green_matrices!(green_temp, grad_green, plasma_surf.x, plasma_surf.z, plasma_surf.ν, nzeta) + # compute_green_matrices!(green_temp, grad_green, plasma_surf.x, plasma_surf.z, plasma_surf.ν, nzeta) + compute_green_matrices!(green_temp, grad_green, plasma_surf3D) # Sum Green's function matrices over toroidal direction to recover 2D poloidal slice # green_2D = zeros(ComplexF64, mtheta, mtheta) diff --git a/src/Vacuum/VacuumInternals.jl b/src/Vacuum/VacuumInternals.jl index acea003c..36842114 100644 --- a/src/Vacuum/VacuumInternals.jl +++ b/src/Vacuum/VacuumInternals.jl @@ -109,6 +109,12 @@ function kernel!( # TODO: this isn't the same as the periodic_cubic_deriv interpolation? # We need to interpolate off-grid during Gaussian quadrature + # THIS IS A BUG: extrapolations BC assumes both endpoints are included in the data, so this wraps at mtheta-1 to 0 instead of mtheta to 0 + # The correct form is: + # theta_grid_periodic = range(; start=0, length=mtheta+1, step=dtheta) + # R_periodic = vcat(source.x, source.x[1]) + # spline_x = cubic_spline_interpolation(theta_grid_periodic, R_periodic, bc = Periodic(OnGrid()); extrapolation_bc=Interpolations.Periodic()) + # We can drop the extrapolation condition since we do mod(theta_gauss[ig], 2π) in the Gaussian quadrature loop below, or leave it here and drop that. They are redundant spline_x = cubic_spline_interpolation(theta_grid, source.x; extrapolation_bc=Interpolations.Periodic()) spline_z = cubic_spline_interpolation(theta_grid, source.z; extrapolation_bc=Interpolations.Periodic()) @@ -261,7 +267,7 @@ end # Returns the array of derivatives at all x points, I think this acts like difspl # in the Fortran but need to check/consolidate spline routines later function periodic_cubic_deriv(theta, vals) - itp = cubic_spline_interpolation(theta, vals; extrapolation_bc=Interpolations.Periodic()) #scale(interpolate(vals, BSpline(Cubic(Periodic(OnGrid())))), theta) + itp = cubic_spline_interpolation(theta, vals; bc=Periodic(OnGrid())) return first.(Interpolations.gradient.(Ref(itp), theta)) end @@ -368,8 +374,6 @@ function with optional offset parameters. - `vecin::Vector{Float64}`: Input array to be resampled - `mtheta::Int`: Desired length of the output array - - `dx0::Float64`: Global offset added to all x-coordinates (default 0, applied as `x += dx0 / mtheta_in`) - - `dx1::Float64`: Fine offset added to each index (default 0, applied as `ai = (i-1) + dx1`) # Returns @@ -378,28 +382,23 @@ function with optional offset parameters. # Notes - If `mtheta == length(vecin)`, returns the input vector unchanged - - Uses periodic cubic spline interpolation for resampling - - Input grid is normalized to [0, 1] for interpolation """ -function interp_to_new_grid(vecin::Vector{Float64}, mtheta::Int; dx0=0.0, dx1=0.0) +function interp_to_new_grid(vecin::Vector{Float64}, mtheta::Int) # Initialize mtheta_in = length(vecin) - # If mtheta == mtheta_in, just return the input vector if mtheta == mtheta_in return vecin end - # Input grids are from [0, 1] inclusive, since no interpolants will fall outside of this, we don't need periodic extrapolation θin = range(0.0, 1.0; length=mtheta_in) - itp = cubic_spline_interpolation(θin, vecin) + itp = cubic_spline_interpolation(θin, vecin; bc=Periodic(OnGrid())) - # Interpolate to new grid with optional offsets + # Interpolate to new grid vecout = zeros(mtheta) for i in 1:mtheta - x = (i - 1 + dx1) / mtheta + dx0 / mtheta_in - x = x % 1.0 # This is for periodicity in the case of dx1/dx0 ≠ 0 + x = (i - 1) / mtheta vecout[i] = itp(x) end return vecout diff --git a/src/Vacuum/VacuumStructs.jl b/src/Vacuum/VacuumStructs.jl index 8c447d55..691ef672 100644 --- a/src/Vacuum/VacuumStructs.jl +++ b/src/Vacuum/VacuumStructs.jl @@ -157,10 +157,13 @@ function initialize_plasma_surface(inputs::VacuumInput) Z = interp_to_new_grid(z, mtheta) ν = interp_to_new_grid(ν, mtheta) - # Plasma boundary theta derivative: length mth with θ = [0, 1) + # Plasma boundary theta derivative: for splines, need to add periodic point θ_grid = range(; start=0, length=mtheta, step=2π/mtheta) - dx_dtheta = periodic_cubic_deriv(θ_grid, R) - dz_dtheta = periodic_cubic_deriv(θ_grid, Z) + θ_grid_periodic = range(; start=0, length=mtheta+1, step=2π/mtheta) + R_periodic = vcat(R, R[1]) + Z_periodic = vcat(Z, Z[1]) + dx_dtheta = periodic_cubic_deriv(θ_grid_periodic, R_periodic) + dz_dtheta = periodic_cubic_deriv(θ_grid_periodic, Z_periodic) # Precompute Fourier transform terms, sin(mθ - nν) and cos(mθ - nν) sin_mn_basis = sin.((mlow .+ (0:(mpert-1))') .* θ_grid .- n .* ν) @@ -196,6 +199,87 @@ function initialize_plasma_surface(inputs::VacuumInput) ) end +struct PlasmaGeometry3D + ntheta::Int + nzeta::Int + x::Vector{Float64} + y::Vector{Float64} + z::Vector{Float64} + n::Matrix{Float64} + dA::Vector{Float64} + # sin_mn_basis3D::Matrix{Float64} + # cos_mn_basis3D::Matrix{Float64} +end + +function PlasmaGeometry3D(plasma_2d::PlasmaGeometry, nzeta::Int)::PlasmaGeometry3D + + # Extract 2D poloidal data + ntheta = length(plasma_2d.x) + ntotal = ntheta * nzeta + R = plasma_2d.x + Z = plasma_2d.z + ν = plasma_2d.ν + + # Allocate output arrays + x = zeros(ntotal) + y = zeros(ntotal) + z = zeros(ntotal) + n = zeros(ntotal, 3) + dA = zeros(ntotal) + + dθ = 2π / ntheta + dϕ = 2π / nzeta + θ_grid = range(; start=0, length=ntheta, step=dθ) + ϕ_grid = range(; start=0, length=nzeta, step=dϕ) + + # Build surface point-by-point + for (i, θ) in enumerate(θ_grid), (j, ϕ) in enumerate(ϕ_grid) + # Linear index in flattened array + idx = i + (j - 1) * ntheta + x[idx] = R[i] * cos(ϕ + ν[i]) + y[idx] = R[i] * sin(ϕ + ν[i]) + z[idx] = Z[i] + end + + # Compute differential area elements dA via cross product of tangent vectors + # Create temporary arrays including endpoints for spline interpolation + X_temp = reshape(x, ntheta, nzeta) + Y_temp = reshape(y, ntheta, nzeta) + Z_temp = reshape(z, ntheta, nzeta) + + # Create splines with periodic data including endpoints + itpX = cubic_spline_interpolation((θ_grid, ϕ_grid), X_temp; bc=Periodic(OnGrid())) + itpY = cubic_spline_interpolation((θ_grid, ϕ_grid), Y_temp; bc=Periodic(OnGrid())) + itpZ = cubic_spline_interpolation((θ_grid, ϕ_grid), Z_temp; bc=Periodic(OnGrid())) + ∂r_dθ = SVector(3) + ∂r_dϕ = SVector(3) + # Evaluate derivatives at original (non-endpoint) grid points + for (i, θ) in enumerate(θ_grid), (j, ϕ) in enumerate(ϕ_grid) + idx = i + (j - 1) * ntheta + ∂X_dθ = Interpolations.gradient(itpX, θ, ϕ)[1] + ∂X_dϕ = Interpolations.gradient(itpX, θ, ϕ)[2] + ∂Y_dθ = Interpolations.gradient(itpY, θ, ϕ)[1] + ∂Y_dϕ = Interpolations.gradient(itpY, θ, ϕ)[2] + ∂Z_dθ = Interpolations.gradient(itpZ, θ, ϕ)[1] + ∂Z_dϕ = Interpolations.gradient(itpZ, θ, ϕ)[2] + ∂r_dθ = SVector(∂X_dθ, ∂Y_dθ, ∂Z_dθ) + ∂r_dϕ = SVector(∂X_dϕ, ∂Y_dϕ, ∂Z_dϕ) + dA[idx] = norm(cross(∂r_dθ, ∂r_dϕ)) + n[idx, :] = cross(∂r_dθ, ∂r_dϕ) / dA[idx] + end + dA .*= dθ * dϕ + + return PlasmaGeometry3D( + ntheta, + nzeta, + x, + y, + z, + n, + dA + ) +end + """ initialize_wall(inputs::VacuumInput, plasma_surf::PlasmaGeometry, wall_settings::WallShapeSettings) -> WallGeometry From 247fd7cd9a4420df41218fc8bc790ab060478431 Mon Sep 17 00:00:00 2001 From: Jake Halpern Date: Thu, 22 Jan 2026 12:33:14 -0500 Subject: [PATCH 10/31] VACUUM - WIP - first attempt at creating a Julia 3D kernel function by adding single/double layer kernels and filling nonsingular components of the matrix which seems to match BIEST --- src/Vacuum/Vacuum.jl | 7 ++ src/Vacuum/Vacuum3D.jl | 164 ++++++++++++++++++++++++++++++++++++ src/Vacuum/VacuumStructs.jl | 10 +++ 3 files changed, 181 insertions(+) create mode 100644 src/Vacuum/Vacuum3D.jl diff --git a/src/Vacuum/Vacuum.jl b/src/Vacuum/Vacuum.jl index a79fbea2..aa6bd473 100644 --- a/src/Vacuum/Vacuum.jl +++ b/src/Vacuum/Vacuum.jl @@ -6,6 +6,7 @@ using ..BIEST include("VacuumStructs.jl") include("VacuumInternals.jl") +include("Vacuum3D.jl") export mscvac, set_dcon_params, VacuumInput, compute_vacuum_response, compute_vacuum_response_3D export compute_vacuum_field @@ -357,9 +358,15 @@ function compute_vacuum_response_3D(inputs::VacuumInput, wall_settings::WallShap # Plasma–Plasma block # G = single-layer kernel, K = double-layer kernel # Use Nt toroidal points to properly discretize the 3D surface (must be >= 6 for BIEST) + compute_3D_kernel_matrix!(grad_green, green_temp, plasma_surf3D, plasma_surf3D) + display(green_temp) + display(grad_green) + println("Calling BIEST with Nt=$nzeta, Np=$mtheta (total 3D points: $(num_gridpoints))...") # compute_green_matrices!(green_temp, grad_green, plasma_surf.x, plasma_surf.z, plasma_surf.ν, nzeta) compute_green_matrices!(green_temp, grad_green, plasma_surf3D) + display(green_temp) + display(grad_green) # Sum Green's function matrices over toroidal direction to recover 2D poloidal slice # green_2D = zeros(ComplexF64, mtheta, mtheta) diff --git a/src/Vacuum/Vacuum3D.jl b/src/Vacuum/Vacuum3D.jl new file mode 100644 index 00000000..6452a52a --- /dev/null +++ b/src/Vacuum/Vacuum3D.jl @@ -0,0 +1,164 @@ +""" + laplace_single_layer(x_obs::Vector{Float64}, x_src::Vector{Float64}) -> Float64 + +Evaluate the Laplace single-layer (FxU) kernel between two 3D points. + +The single-layer kernel φ is the fundamental solution to Laplace's equation: + +``` +φ(x_obs, x_src) = 1 / (4π |x_obs - x_src|) +``` + +# Arguments + + - `x_obs::Vector{Float64}`: Observation point (3D Cartesian coordinates) + - `x_src::Vector{Float64}`: Source point (3D Cartesian coordinates) + +# Returns + + - `Float64`: Kernel value φ(x_obs, x_src) +""" +function laplace_single_layer(x_obs::Vector{Float64}, x_src::Vector{Float64})::Float64 + + # Compute separation vector + dx = x_obs[1] - x_src[1] + dy = x_obs[2] - x_src[2] + dz = x_obs[3] - x_src[3] + + # Distance + r = sqrt(dx^2 + dy^2 + dz^2) + + # Single-layer kernel + return 1.0 / (4π * r) +end + +""" + laplace_double_layer(x_obs::Vector{Float64}, x_src::Vector{Float64}, n_src::Vector{Float64}) -> Float64 + +Evaluate the Laplace double-layer (DxU) kernel between a point and a surface element. + +The double-layer kernel K is the normal derivative of the fundamental solution: + +``` +K(x_obs, x_src, n_src) = ∇_{x_src} φ · n̂_src + = -1/(4π) * (x_obs - x_src) · n̂_src / |x_obs - x_src|³ +``` + +# Arguments + + - `x_obs::Vector{Float64}`: Observation point (3D Cartesian coordinates) + - `x_src::Vector{Float64}`: Source point on surface (3D Cartesian coordinates) + - `n_src::Vector{Float64}`: Outward UNIT normal at source point (must be normalized!) + +# Returns + + - `Float64`: Kernel value K(x_obs, x_src, n_src) +""" +function laplace_double_layer(x_obs::Vector{Float64}, x_src::Vector{Float64}, n_src::Vector{Float64})::Float64 + + # Compute separation vector + dx = x_obs[1] - x_src[1] + dy = x_obs[2] - x_src[2] + dz = x_obs[3] - x_src[3] + + # Distance + r = sqrt(dx^2 + dy^2 + dz^2) + + # Dot product: (x_obs - x_src) · n_src + r_dot_n = dx * n_src[1] + dy * n_src[2] + dz * n_src[3] + + # Double-layer kernel: -1/(4π) * (r·n) / r³ + return -r_dot_n / (4π * r^3) +end + +function compute_3D_kernel_matrix!( + grad_greenfunction::Matrix{Float64}, + greenfunction::Matrix{Float64}, + observer::PlasmaGeometry3D, + source::PlasmaGeometry3D; + PATCH_DIM=3 +) + + # Zero out greenfunction at start of each kernel call (matches Fortran behavior) + fill!(grad_greenfunction, 0.0) + fill!(greenfunction, 0.0) + @inline periodic_dist(i, j, n) = min(abs(i - j), n - abs(i - j)) + + + # Loop through observer points + for i_obs in 1:observer.ntheta, j_obs in 1:observer.nzeta + idx_obs = i_obs + (j_obs - 1) * observer.ntheta + # Initialize variables + r_obs = observer.r[idx_obs, :] + + # Get indices excluding the singular region (±PATCH_DIM around observer point) + # Only include points where AT LEAST ONE theta and zeta are outside the patch + nonsing_idx = Vector{Int}(undef, 0) + sizehint!(nonsing_idx, observer.ntheta * observer.nzeta) + for j in 1:observer.nzeta, i in 1:observer.ntheta + if periodic_dist(i, i_obs, observer.ntheta) > PATCH_DIM || + periodic_dist(j, j_obs, observer.nzeta) > PATCH_DIM + push!(nonsing_idx, i + (j - 1) * observer.ntheta) + end + end + + # Perform 2D periodic trapezoidal integration for nonsingular source points + # Note that all trapezoidal weights are 1 since we perform the full periodic integration + # However, we are actually integrating the kernel times the POU function, which is 1 at nonsingular points + for idx_src in nonsing_idx + greenfunction[idx_obs, idx_src] += laplace_single_layer(r_obs, source.r[idx_src, :]) * source.dA[idx_src] + grad_greenfunction[idx_obs, idx_src] += laplace_double_layer(r_obs, source.r[idx_src, :], source.n[idx_src, :]) * source.dA[idx_src] + end + + # TODO: singular region treatment!! + # Perform Gaussian quadrature for singular points (source = obs point) + # Get indices of the singularity region, [j-2, j-1, j, j+1, j+2] + # sing_idx = mod1.(j .+ ((mtheta-2):(mtheta+2)), mtheta) + # # Integrate region of length 2 * dtheta on left/right of singularity + # for region in ["left", "right"] + # gauss_mid = theta_obs + (region == "left" ? -dtheta : dtheta) + # theta_gauss = gauss_mid .+ GAUSSIANPOINTS .* dtheta + # wgauss = GAUSSIANWEIGHTS .* dtheta + # for ig in 1:8 # 8-point Gaussian quadrature + # # Compute green function for this Gaussian point + # theta_gauss0 = mod(theta_gauss[ig], 2π) + # x_gauss = spline_x(theta_gauss0) + # dx_dtheta_gauss = Interpolations.gradient(spline_x, theta_gauss0)[1] + # z_gauss = spline_z(theta_gauss0) + # dz_dtheta_gauss = Interpolations.gradient(spline_z, theta_gauss0)[1] + # G_n, coupling_n, coupling_0 = green(x_obs, z_obs, x_gauss, z_gauss, dx_dtheta_gauss, dz_dtheta_gauss, n) + + # # Add logarithm to G_n to analytically isolate the singularity (first type), Chance eq.(75) + # if observer isa PlasmaGeometry && source isa PlasmaGeometry # previously iops + # G_n += log((theta_obs - theta_gauss[ig])^2) / x_obs + # end + + # p = (theta_gauss[ig] - theta_obs) / dtheta # p = θ/Δ = (θⱼ - θ')/Δ + # stencil_points = SVector(-2, -1, 0, 1, 2) + # lagrange_stencil = ntuple(5) do i + # xi = stencil_points[i] + # prod(j -> j == i ? 1.0 : (p - stencil_points[j])/(xi - stencil_points[j]), 1:5) + # end |> SVector + + # # First type of singularity: 𝒢ⁿ, occurs plasma as source only (see RHS of Chance eqs. 26/27) + # if source isa PlasmaGeometry # previously iopw + # @. @views greenfunction_mat[j, sing_idx] += wgauss[ig] * G_n * lagrange_stencil + # end + + # # Second type of singularity: 𝒦ⁿ (Eq. 86: 𝒦ⁿαᵢ - δⱼᵢK⁰) + # @. @views grad_greenfunction_block[j, sing_idx] += wgauss[ig] * coupling_n * lagrange_stencil + # # Subtract off the diverging singular n=0 component + # grad_greenfunction_block[j, j] -= coupling_0 * wgauss[ig] + # end + # end + + # # Subtract off analytic singular integral from Chance eq.(75) if plasma-plasma block + # if observer isa PlasmaGeometry && source isa PlasmaGeometry + # @. @views greenfunction_mat[j, sing_idx] -= log_correction / x_obs + # end + end + + # Account for normal direction pointing out of vacuum integration region in 𝒦ⁿ ⋅ dS, previously isgn + # Negative for plasma since dS = ∇ψ J dθdζ and ∇ψ points outward but outward normal is inward + @views grad_greenfunction .*= (source isa PlasmaGeometry3D ? -1 : 1) +end diff --git a/src/Vacuum/VacuumStructs.jl b/src/Vacuum/VacuumStructs.jl index 691ef672..c0a67347 100644 --- a/src/Vacuum/VacuumStructs.jl +++ b/src/Vacuum/VacuumStructs.jl @@ -205,6 +205,7 @@ struct PlasmaGeometry3D x::Vector{Float64} y::Vector{Float64} z::Vector{Float64} + r::Matrix{Float64} n::Matrix{Float64} dA::Vector{Float64} # sin_mn_basis3D::Matrix{Float64} @@ -241,6 +242,14 @@ function PlasmaGeometry3D(plasma_2d::PlasmaGeometry, nzeta::Int)::PlasmaGeometry z[idx] = Z[i] end + # r: (ntotal, 3) array of (x, y, z) coordinates for each surface point + r = zeros(ntotal, 3) + for idx in 1:ntotal + r[idx, 1] = x[idx] + r[idx, 2] = y[idx] + r[idx, 3] = z[idx] + end + # Compute differential area elements dA via cross product of tangent vectors # Create temporary arrays including endpoints for spline interpolation X_temp = reshape(x, ntheta, nzeta) @@ -275,6 +284,7 @@ function PlasmaGeometry3D(plasma_2d::PlasmaGeometry, nzeta::Int)::PlasmaGeometry x, y, z, + r, n, dA ) From 9a7cf87f48ffe2039c1a4eab5c71e413aa1ef64b Mon Sep 17 00:00:00 2001 From: Jake Halpern Date: Fri, 23 Jan 2026 15:00:09 -0500 Subject: [PATCH 11/31] VACUUM - WIP - 3D code running through, spitting out garbage --- src/Vacuum/Vacuum.jl | 1 + src/Vacuum/Vacuum3D.jl | 505 ++++++++++++++++++++++++++++++++++------- 2 files changed, 426 insertions(+), 80 deletions(-) diff --git a/src/Vacuum/Vacuum.jl b/src/Vacuum/Vacuum.jl index aa6bd473..d4511e74 100644 --- a/src/Vacuum/Vacuum.jl +++ b/src/Vacuum/Vacuum.jl @@ -2,6 +2,7 @@ module Vacuum using TOML, Interpolations, SpecialFunctions, LinearAlgebra, Printf using StaticArrays +using FastGaussQuadrature using ..BIEST include("VacuumStructs.jl") diff --git a/src/Vacuum/Vacuum3D.jl b/src/Vacuum/Vacuum3D.jl index 6452a52a..3a4b0d23 100644 --- a/src/Vacuum/Vacuum3D.jl +++ b/src/Vacuum/Vacuum3D.jl @@ -1,3 +1,150 @@ +""" +Precomputed data for singular correction quadrature following BIEST approach. +Initialized once on first use. + +Conversion references: + + - Patch/polar layout and POU weights mirror setup in biest/singular_correction.hpp +""" +struct SingularQuadratureData + qx::Vector{Float64} # Radial quadrature points + qw::Vector{Float64} # Radial quadrature weights + Gpou::Vector{Float64} # Partition of unity on Cartesian grid + Ppou::Vector{Float64} # Partition of unity on polar grid + M_G2P::Vector{Float64} # Interpolation matrix: grid → polar + I_G2P::Vector{Int} # Sparse indices for interpolation + PATCH_DIM::Int # Patch dimension + INTERP_ORDER::Int # Interpolation order + Npolar::Int # Number of polar points +end + +# Global cache for quadrature data (initialized on first use) +const SINGULAR_QUAD_CACHE = Ref{Union{Nothing,SingularQuadratureData}}(nothing) + +""" + init_singular_quadrature(PATCH_RAD::Int, RAD_DIM::Int, INTERP_ORDER::Int=6) + +Initialize quadrature points, weights, partition-of-unity functions, and +interpolation matrices for singular correction. Follows BIEST's approach. + +Conversion references: + + - Quadrature/Patch setup adapted from biest/singular_correction.hpp + +# Arguments + + - `PATCH_RAD::Int`: Number of points adjacent to source point to treat as singular + - `RAD_DIM::Int`: Radial quadrature order + - `INTERP_ORDER::Int`: Lagrange interpolation order (default 6) + +# Returns + + - `SingularQuadratureData`: Precomputed quadrature data +""" +function init_singular_quadrature(PATCH_RAD::Int, RAD_DIM::Int, INTERP_ORDER::Int=6) + + # Total size of square patch extracted around singular point (odd number: 2*PATCH_DIM0+1) + PATCH_DIM = 2 * PATCH_RAD + 1 + @assert INTERP_ORDER <= PATCH_DIM "Must have INTERP_ORDER <= PATCH_DIM" + # Number of angular quadrature nodes in polar coordinates (uniformly distributed around circle) + ANG_DIM = 2 * RAD_DIM + # Total number of points in the square Patch + Ngrid = PATCH_DIM * PATCH_DIM + # Total number of polar quadrature nodes + Npolar = RAD_DIM * ANG_DIM + + # Setup radial quadrature (Gauss-Legendre on [0,1]) + qx, qw = FastGaussQuadrature.gausslegendre(RAD_DIM) + + # Partition of unity function, exp(-36 * r^p) where p depends on PATCH_DIM + pou_power = PATCH_DIM > 45 ? 10 : (PATCH_DIM > 20 ? 8 : 6) + pou_func(r) = r ≥ 1.0 ? 0.0 : exp(-36.0 * r^pou_power) + + # Evaluate POU on Cartesian grid + Gpou = zeros(Ngrid) + for i in 1:PATCH_DIM, j in 1:PATCH_DIM + r = sqrt((i - 1 - PATCH_RAD)^2 + (j - 1 - PATCH_RAD)^2) / PATCH_RAD + Gpou[j+(i-1)*PATCH_DIM] = -pou_func(r) + end + + # Evaluate POU on polar grid including transformation Jacobian - Ppou = χ(ρ) M²/4 r dr dt, eq. 38 in Malhotra 2019 + Ppou = zeros(Npolar) + dt = 2π / ANG_DIM + for j in 1:ANG_DIM, i in 1:RAD_DIM + dr = qw[i] * PATCH_RAD + rdt = qx[i] * PATCH_RAD * dt; + Ppou[j+(i-1)*ANG_DIM] = pou_func(qx[i]) * dr * rdt + end + + # Build Lagrange interpolation matrix from Cartesian grid to polar points + M_G2P = zeros(Npolar * INTERP_ORDER^2) + I_G2P = zeros(Int, Npolar) + + # Spacing between Lagrange interpolation nodes in [0,1] for INTERP_ORDER-point stencil + h = 1.0 / (INTERP_ORDER - 1) + # Compute 2D tensor-product Lagrange basis function at (x0, x1) in local stencil coordinates + # for basis node (i0, i1) on uniform grid with spacing h + @inline function lagrange_interp(x0::Float64, x1::Float64, i0::Int, i1::Int) + Lx = Ly = 1.0 + ξ0 = x0 / h + ξ1 = x1 / h + for j0 in 0:(INTERP_ORDER-1) + j0 != i0 && (Lx *= (ξ0 - j0) / (i0 - j0)) + end + for j1 in 0:(INTERP_ORDER-1) + j1 != i1 && (Ly *= (ξ1 - j1) / (i1 - j1)) + end + return Lx * Ly + end + + for ia in 1:ANG_DIM + theta_polar = 2π * (ia - 1) / ANG_DIM + for ir in 1:RAD_DIM + # Map polar node to unit square: x0, x1 ∈ [0,1] × [0,1] + x0 = 0.5 + 0.5 * qx[ir] * cos(theta_polar) + x1 = 0.5 + 0.5 * qx[ir] * sin(theta_polar) + println("x0 = $x0, x1 = $x1") + + # Lower-left corner indices of INTERP_ORDER × INTERP_ORDER stencil centered on (x0,x1) + # Clamped to stay within patch boundaries + i0_center = trunc(Int, x0 * (PATCH_DIM - 1) - (INTERP_ORDER - 1) / 2) + y0 = clamp(i0_center, 0, PATCH_DIM - INTERP_ORDER) + i1_center = trunc(Int, x1 * (PATCH_DIM - 1) - (INTERP_ORDER - 1) / 2) + y1 = clamp(i1_center, 0, PATCH_DIM - INTERP_ORDER) + + # Local coordinates within INTERP_ORDER×INTERP_ORDER stencil, normalized to [0,1] + z0 = (x0 * (PATCH_DIM - 1) - y0) * h + z1 = (x1 * (PATCH_DIM - 1) - y1) * h + + idx = ia + ANG_DIM * (ir - 1) + I_G2P[idx] = y0 * PATCH_DIM + y1 + for j0 in 0:(INTERP_ORDER-1) + for j1 in 0:(INTERP_ORDER-1) + M_G2P[1+j1+INTERP_ORDER*(j0+INTERP_ORDER*(idx-1))] = lagrange_interp(z0, z1, j0, j1) + end + end + end + end + + return SingularQuadratureData(qx, qw, Gpou, Ppou, M_G2P, I_G2P, PATCH_DIM, INTERP_ORDER, Npolar) +end + +""" + get_singular_quadrature(PATCH_RAD::Int, RAD_DIM::Int) + +Get cached singular quadrature data, initializing if necessary. + +Conversion references: + + - Follows caching pattern used around FieldPeriodBIOp setup in biest/boundary_integ_op.hpp +""" +function get_singular_quadrature(PATCH_RAD::Int, RAD_DIM::Int) + if isnothing(SINGULAR_QUAD_CACHE[]) + SINGULAR_QUAD_CACHE[] = init_singular_quadrature(PATCH_RAD, RAD_DIM) + end + return SINGULAR_QUAD_CACHE[] +end + """ laplace_single_layer(x_obs::Vector{Float64}, x_src::Vector{Float64}) -> Float64 @@ -17,18 +164,21 @@ The single-layer kernel φ is the fundamental solution to Laplace's equation: # Returns - `Float64`: Kernel value φ(x_obs, x_src) + +Conversion references: + + - Fundamental solution form matches FxU kernel definition in biest/kernel.hpp """ function laplace_single_layer(x_obs::Vector{Float64}, x_src::Vector{Float64})::Float64 - # Compute separation vector - dx = x_obs[1] - x_src[1] - dy = x_obs[2] - x_src[2] - dz = x_obs[3] - x_src[3] + # Single-layer kernel: 1/(4π r) + r = norm(x_obs - x_src) - # Distance - r = sqrt(dx^2 + dy^2 + dz^2) + # if r < 1e-10 + # @warn("laplace_single_layer: Observation and source points are too close (r = $r). Returning 0.0 to avoid singularity. Increase PATCH_RAD to improve accuracy.") + # return 0.0 + # end - # Single-layer kernel return 1.0 / (4π * r) end @@ -53,112 +203,307 @@ K(x_obs, x_src, n_src) = ∇_{x_src} φ · n̂_src # Returns - `Float64`: Kernel value K(x_obs, x_src, n_src) + +Conversion references: + + - Normal-derivative kernel mirrors DxU in biest/kernel.hpp """ function laplace_double_layer(x_obs::Vector{Float64}, x_src::Vector{Float64}, n_src::Vector{Float64})::Float64 - # Compute separation vector - dx = x_obs[1] - x_src[1] - dy = x_obs[2] - x_src[2] - dz = x_obs[3] - x_src[3] + # Double-layer kernel: -1/(4π) * (r·n) / r³ + r = norm(x_obs - x_src) - # Distance - r = sqrt(dx^2 + dy^2 + dz^2) + # if r < 1e-10 + # @warn("laplace_double_layer: Observation and source points are too close (r = $r). Returning 0.0 to avoid singularity. Increase PATCH_RAD to improve accuracy.") + # return 0.0 + # end - # Dot product: (x_obs - x_src) · n_src - r_dot_n = dx * n_src[1] + dy * n_src[2] + dz * n_src[3] + return -dot(x_obs - x_src, n_src) / (4π * r^3) +end - # Double-layer kernel: -1/(4π) * (r·n) / r³ - return -r_dot_n / (4π * r^3) +""" + extract_patch(data, Nt, Np, t0, p0, PATCH_DIM) + +Extract a PATCH_DIM × PATCH_DIM patch of data centered at (t0, p0) with periodic wrapping. + +# Arguments + + - `data`: Source data array (can be coordinates, normals, or area elements) + - `Nt, Np`: Grid dimensions (toroidal, poloidal) + - `t0, p0`: Center indices (1-based) + - `PATCH_DIM`: Patch size (must be odd) + +# Returns + + - `patch`: Extracted patch array + +Conversion references: + + - Mirrors patch extraction used inside SingularCorrection::SetPatch in biest/singular_correction.hpp +""" +function extract_patch(data::Matrix{Float64}, Nt::Int, Np::Int, t0::Int, p0::Int, PATCH_DIM::Int) + + @assert size(data, 1) == Nt * Np + @assert Nt ≥ PATCH_DIM + @assert Np ≥ PATCH_DIM + + PATCH_RAD = (PATCH_DIM - 1) ÷ 2 + t_start = t0 - PATCH_RAD + p_start = p0 - PATCH_RAD + dof = size(data, 2) # Number of components (3 for coords, 1 for scalars) + patch = zeros(PATCH_DIM * PATCH_DIM, dof) + + for i in 1:PATCH_DIM, j in 1:PATCH_DIM + # Enforce periodicity + t = mod1(t_start + i - 1, Nt) + p = mod1(p_start + j - 1, Np) + + # Copy data to the patch + idx_in = p + Np * (t - 1) + idx_out = j + PATCH_DIM * (i - 1) + @views patch[idx_out, :] .= data[idx_in, :] + end + + return patch # (Ngrid, dof) +end + +""" + interpolate_to_polar(patch, M_G2P, Npolar) + +Interpolate Cartesian patch data to polar quadrature points using precomputed matrix. + +# Arguments + + - `patch`: Patch data (PATCH_DIM² × dof) + - `quad_data`: Precomputed quadrature data + +# Returns + + - `polar_data`: Interpolated data at polar points (Npolar × dof) + +Conversion references: + + - Grid→polar interpolation follows M_G2P application in biest/singular_correction.hpp +""" +function interpolate_to_polar(patch::Matrix{Float64}, quad_data::SingularQuadratureData) + + (; M_G2P, I_G2P, INTERP_ORDER, PATCH_DIM, Npolar) = quad_data + dof = size(patch, 2) + polar_data = zeros(Npolar, dof) + for j in 1:Npolar + # Base offset for interpolation matrix block + M_offset = (j - 1) * INTERP_ORDER * INTERP_ORDER + for k in 1:dof + sum = 0.0 + for i0 in 0:(INTERP_ORDER-1), i1 in 0:(INTERP_ORDER-1) + # Index into interpolation weights + M_idx = M_offset + i0 * INTERP_ORDER + i1 + 1 + # Index into the patch for this stencil point + patch_idx = I_G2P[j] + i0 * PATCH_DIM + i1 + 1 + sum += M_G2P[M_idx] * patch[patch_idx, k] + end + polar_data[j, k] = sum + end + end + + return polar_data +end + +""" + compute_singular_correction!( + greenfunction, grad_greenfunction, + obs_idx, source_geom, quad_data, + PATCH_DIM, RAD_DIM + ) + +Compute singular correction for observer point using polar quadrature. +Follows BIEST's approach: extract patch, interpolate to polar coordinates, +evaluate kernels with POU weighting, distribute back to grid. + +# Arguments + + - `greenfunction`: Single-layer matrix to update + - `grad_greenfunction`: Double-layer matrix to update + - `obs_idx`: Linear index of observer point + - `source_geom`: Source geometry (PlasmaGeometry3D) + - `quad_data`: Precomputed quadrature data + - `RAD_DIM`: Radial quadrature order + +Conversion references: + + - Patch → polar correction mirrors SingularCorrection operator in biest/singular_correction.hpp + - Subtract/add near-field correction follows EvalSurfInteg flow in biest/surface_op.txx +""" +function compute_singular_correction!( + greenfunction::Matrix{Float64}, + grad_greenfunction::Matrix{Float64}, + obs_idx::Int, + obs_geom::PlasmaGeometry3D, + source_geom::PlasmaGeometry3D, + quad_data::SingularQuadratureData, + t0::Int, p0::Int, + RAD_DIM::Int +) + (; nzeta, ntheta, r, n, dA) = source_geom + (; PATCH_DIM, INTERP_ORDER, Npolar, qw, Ppou, Gpou, M_G2P, I_G2P) = quad_data + PATCH_RAD = (PATCH_DIM - 1) ÷ 2 + ANG_DIM = 2 * RAD_DIM + # Observer position + r_obs = obs_geom.r[obs_idx, :] + + # Extract patches of source geometry (size = Ngrid x dof) + r_patch = extract_patch(r, nzeta, ntheta, t0, p0, PATCH_DIM) + n_patch = extract_patch(n, nzeta, ntheta, t0, p0, PATCH_DIM) + dA_patch = extract_patch(reshape(dA, :, 1), nzeta, ntheta, t0, p0, PATCH_DIM) + + # Interpolate to polar quadrature points (size = Npolar x dof) + r_polar = interpolate_to_polar(r_patch, quad_data) + n_polar = interpolate_to_polar(n_patch, quad_data) + dA_polar = interpolate_to_polar(dA_patch, quad_data)[:, 1] + + # Compute area elements at polar points (cross product of tangent vectors) + # For now use interpolated dA; could recompute from derivatives if needed + + # Evaluate kernels at polar points with POU weighting + M_polar_single = zeros(Npolar) + M_polar_double = zeros(Npolar) + dtheta = 2π / ANG_DIM + for ia in 1:ANG_DIM, ir in 1:RAD_DIM + ipolar = ir + RAD_DIM * (ia - 1) + r_src, n_src = r_polar[ipolar, :], n_polar[ipolar, :] + + # Evaluate kernels + K_single = laplace_single_layer(r_obs, r_src) + K_double = laplace_double_layer(r_obs, r_src, n_src) + + # Apply quadrature weights: radial weight × angular weight × area element × POU + wt = qw[ir] * dtheta * dA_polar[ipolar] * Ppou[ipolar] + M_polar_single[ipolar] = K_single * wt + M_polar_double[ipolar] = K_double * wt + end + + # Distribute corrections back to Cartesian grid using interpolation matrix + # Correction at grid point = sum over polar points of (kernel_value * interp_weight) + M_grid_single = zeros(PATCH_DIM * PATCH_DIM) + M_grid_double = zeros(PATCH_DIM * PATCH_DIM) + M_G2P_view = reshape(M_G2P, INTERP_ORDER, INTERP_ORDER, Npolar) + for j in 1:Npolar + for i0 in 0:(INTERP_ORDER-1), i1 in 0:(INTERP_ORDER-1) + patch_idx = I_G2P[j] + i0 * PATCH_DIM + i1 + 1 + M_grid_single[patch_idx] += M_G2P_view[i0+1, i1+1, j] * M_polar_single[j] + M_grid_double[patch_idx] += M_G2P_view[i0+1, i1+1, j] * M_polar_double[j] + end + end + + # Subtract far-field contribution (Gpou = -χ on grid) and add near-field polar quadrature result + for j in 1:PATCH_DIM, i in 1:PATCH_DIM + igrid = i + PATCH_DIM * (j - 1) + + # Map back to global indices + it = mod1(t0 - PATCH_RAD - 1 + i, nzeta) + ip = mod1(p0 - PATCH_RAD - 1 + j, ntheta) + idx_src = it + nzeta * (ip - 1) + + # Far-field contribution to subtract (computed with rectangle rule) + r_src, n_src, dA_src = r[idx_src, :], n[idx_src, :], dA[idx_src] + far_single = laplace_single_layer(r_obs, r_src) * dA_src * Gpou[igrid] + far_double = laplace_double_layer(r_obs, r_src, n_src) * dA_src * Gpou[igrid] + + # Apply correction: subtract far, add near + greenfunction[obs_idx, idx_src] += M_grid_single[igrid] + far_single + grad_greenfunction[obs_idx, idx_src] += M_grid_double[igrid] + far_double + end end +""" + compute_3D_kernel_matrix!( + grad_greenfunction, greenfunction, + observer, source; + PATCH_DIM=7, RAD_DIM=12 + ) + +Compute boundary integral kernel matrices for 3D geometries with singular correction. + +Uses BIEST-style approach: + + - Far regions: Rectangle rule with uniform weights (1/N) + - Singular regions: Polar quadrature with partition-of-unity blending + +# Arguments + + - `grad_greenfunction`: Double-layer kernel matrix (Nobs × Nsrc) + - `greenfunction`: Single-layer kernel matrix (Nobs × Nsrc) + - `observer`: Observer geometry (PlasmaGeometry3D) + - `source`: Source geometry (PlasmaGeometry3D) + - `PATCH_RAD`: Number of points adjacent to source point to treat as singular (default 3) + - `RAD_DIM`: Radial quadrature order (default 12) + +Conversion references: + + - Far/near split and Eval flow adapted from FieldPeriodBIOp and BoundaryIntegralOp in biest/boundary_integ_op.hpp + - Rectangle-rule weights mirror SurfNormalAreaElem/EvalSurfInteg in biest/surface_op.txx +""" function compute_3D_kernel_matrix!( grad_greenfunction::Matrix{Float64}, greenfunction::Matrix{Float64}, observer::PlasmaGeometry3D, source::PlasmaGeometry3D; - PATCH_DIM=3 + PATCH_RAD::Int=3, + RAD_DIM::Int=12 ) - # Zero out greenfunction at start of each kernel call (matches Fortran behavior) + # Zero out matrices fill!(grad_greenfunction, 0.0) fill!(greenfunction, 0.0) - @inline periodic_dist(i, j, n) = min(abs(i - j), n - abs(i - j)) + # Initialize quadrature data (cached) + quad_data = get_singular_quadrature(PATCH_RAD, RAD_DIM) + + # Helper for periodic distance + @inline periodic_dist(i, j, n) = min(abs(i - j), n - abs(i - j)) # Loop through observer points - for i_obs in 1:observer.ntheta, j_obs in 1:observer.nzeta + for j_obs in 1:observer.nzeta, i_obs in 1:observer.ntheta idx_obs = i_obs + (j_obs - 1) * observer.ntheta - # Initialize variables r_obs = observer.r[idx_obs, :] - # Get indices excluding the singular region (±PATCH_DIM around observer point) - # Only include points where AT LEAST ONE theta and zeta are outside the patch + # Get indices excluding the singular region (square of radius ±PATCH_RAD around observer point) + # Include points where either θ and ζ distance is greater than patch + # (i.e., at least one coordinate distance > PATCH_RAD) nonsing_idx = Vector{Int}(undef, 0) sizehint!(nonsing_idx, observer.ntheta * observer.nzeta) - for j in 1:observer.nzeta, i in 1:observer.ntheta - if periodic_dist(i, i_obs, observer.ntheta) > PATCH_DIM || - periodic_dist(j, j_obs, observer.nzeta) > PATCH_DIM - push!(nonsing_idx, i + (j - 1) * observer.ntheta) + for j in 1:source.nzeta, i in 1:source.ntheta + if periodic_dist(i, i_obs, source.ntheta) > PATCH_RAD || + periodic_dist(j, j_obs, source.nzeta) > PATCH_RAD + push!(nonsing_idx, i + (j - 1) * source.ntheta) end end - # Perform 2D periodic trapezoidal integration for nonsingular source points - # Note that all trapezoidal weights are 1 since we perform the full periodic integration - # However, we are actually integrating the kernel times the POU function, which is 1 at nonsingular points + # ============================================ + # FAR FIELD: Rectangle rule for nonsingular source points + # ============================================ for idx_src in nonsing_idx - greenfunction[idx_obs, idx_src] += laplace_single_layer(r_obs, source.r[idx_src, :]) * source.dA[idx_src] - grad_greenfunction[idx_obs, idx_src] += laplace_double_layer(r_obs, source.r[idx_src, :], source.n[idx_src, :]) * source.dA[idx_src] + # Evaluate kernels at grid points + K_single = laplace_single_layer(r_obs, source.r[idx_src, :]) + K_double = laplace_double_layer(r_obs, source.r[idx_src, :], source.n[idx_src, :]) + + # Apply area element (periodic trapezoidal rule: w = dA = J∇ψdθdζ) + greenfunction[idx_obs, idx_src] = K_single * source.dA[idx_src] + grad_greenfunction[idx_obs, idx_src] = K_double * source.dA[idx_src] end - # TODO: singular region treatment!! - # Perform Gaussian quadrature for singular points (source = obs point) - # Get indices of the singularity region, [j-2, j-1, j, j+1, j+2] - # sing_idx = mod1.(j .+ ((mtheta-2):(mtheta+2)), mtheta) - # # Integrate region of length 2 * dtheta on left/right of singularity - # for region in ["left", "right"] - # gauss_mid = theta_obs + (region == "left" ? -dtheta : dtheta) - # theta_gauss = gauss_mid .+ GAUSSIANPOINTS .* dtheta - # wgauss = GAUSSIANWEIGHTS .* dtheta - # for ig in 1:8 # 8-point Gaussian quadrature - # # Compute green function for this Gaussian point - # theta_gauss0 = mod(theta_gauss[ig], 2π) - # x_gauss = spline_x(theta_gauss0) - # dx_dtheta_gauss = Interpolations.gradient(spline_x, theta_gauss0)[1] - # z_gauss = spline_z(theta_gauss0) - # dz_dtheta_gauss = Interpolations.gradient(spline_z, theta_gauss0)[1] - # G_n, coupling_n, coupling_0 = green(x_obs, z_obs, x_gauss, z_gauss, dx_dtheta_gauss, dz_dtheta_gauss, n) - - # # Add logarithm to G_n to analytically isolate the singularity (first type), Chance eq.(75) - # if observer isa PlasmaGeometry && source isa PlasmaGeometry # previously iops - # G_n += log((theta_obs - theta_gauss[ig])^2) / x_obs - # end - - # p = (theta_gauss[ig] - theta_obs) / dtheta # p = θ/Δ = (θⱼ - θ')/Δ - # stencil_points = SVector(-2, -1, 0, 1, 2) - # lagrange_stencil = ntuple(5) do i - # xi = stencil_points[i] - # prod(j -> j == i ? 1.0 : (p - stencil_points[j])/(xi - stencil_points[j]), 1:5) - # end |> SVector - - # # First type of singularity: 𝒢ⁿ, occurs plasma as source only (see RHS of Chance eqs. 26/27) - # if source isa PlasmaGeometry # previously iopw - # @. @views greenfunction_mat[j, sing_idx] += wgauss[ig] * G_n * lagrange_stencil - # end - - # # Second type of singularity: 𝒦ⁿ (Eq. 86: 𝒦ⁿαᵢ - δⱼᵢK⁰) - # @. @views grad_greenfunction_block[j, sing_idx] += wgauss[ig] * coupling_n * lagrange_stencil - # # Subtract off the diverging singular n=0 component - # grad_greenfunction_block[j, j] -= coupling_0 * wgauss[ig] - # end - # end - - # # Subtract off analytic singular integral from Chance eq.(75) if plasma-plasma block - # if observer isa PlasmaGeometry && source isa PlasmaGeometry - # @. @views greenfunction_mat[j, sing_idx] -= log_correction / x_obs - # end + # ============================================ + # NEAR FIELD: Polar quadrature with singular correction + # ============================================ + compute_singular_correction!( + greenfunction, grad_greenfunction, + idx_obs, observer, source, + quad_data, i_obs, j_obs, + RAD_DIM + ) end - # Account for normal direction pointing out of vacuum integration region in 𝒦ⁿ ⋅ dS, previously isgn + # Account for normal direction pointing out of vacuum integration region in 𝒦ⁿ ⋅ dS # Negative for plasma since dS = ∇ψ J dθdζ and ∇ψ points outward but outward normal is inward @views grad_greenfunction .*= (source isa PlasmaGeometry3D ? -1 : 1) end From a423d7ad8fa21210d58cd81c2b66c610ffe7f808 Mon Sep 17 00:00:00 2001 From: Jake Halpern Date: Mon, 26 Jan 2026 15:32:50 -0500 Subject: [PATCH 12/31] VACUUM - WIP - debugging of 3D portions, refactoring indexing into multi-dimensional arrays instead of flattended like c++ --- src/Vacuum/Vacuum3D.jl | 288 +++++++++++++++++------------------------ 1 file changed, 119 insertions(+), 169 deletions(-) diff --git a/src/Vacuum/Vacuum3D.jl b/src/Vacuum/Vacuum3D.jl index 3a4b0d23..e0505daa 100644 --- a/src/Vacuum/Vacuum3D.jl +++ b/src/Vacuum/Vacuum3D.jl @@ -2,20 +2,32 @@ Precomputed data for singular correction quadrature following BIEST approach. Initialized once on first use. -Conversion references: - - - Patch/polar layout and POU weights mirror setup in biest/singular_correction.hpp +## Fields + + - `qx::Vector{Float64}`: Radial quadrature points in [0,1] + - `qw::Vector{Float64}`: Radial quadrature weights + - `Gpou::Matrix{Float64}`: Partition of unity on Cartesian grid (PATCH_DIM × PATCH_DIM) + - `Ppou::Matrix{Float64}`: Partition of unity on polar grid (RAD_DIM × ANG_DIM) + - `M_G2P::Array{Float64, 4}`: 2D tensor-product Lagrange basis function values for interpolating from the Cartesian patch grid to polar quadrature points (RAD_DIM × ANG_DIM × INTERP_ORDER × INTERP_ORDER) + - `I_G2P::Array{Int, 3}`: Indices of lower-left corner of INTERP_ORDER × INTERP_ORDER stencil in PATCH_DIM × PATCH_DIM grid + - `PATCH_DIM::Int`: Patch dimension (odd integer) + - `PATCH_RAD::Int`: Patch radius (number of points adjacent to source point treated as singular) + - `INTERP_ORDER::Int`: Interpolation order + - `ANG_DIM::Int`: Number of angular quadrature points + - `RAD_DIM::Int`: Number of radial quadrature points """ struct SingularQuadratureData - qx::Vector{Float64} # Radial quadrature points - qw::Vector{Float64} # Radial quadrature weights - Gpou::Vector{Float64} # Partition of unity on Cartesian grid - Ppou::Vector{Float64} # Partition of unity on polar grid - M_G2P::Vector{Float64} # Interpolation matrix: grid → polar - I_G2P::Vector{Int} # Sparse indices for interpolation - PATCH_DIM::Int # Patch dimension - INTERP_ORDER::Int # Interpolation order - Npolar::Int # Number of polar points + qx::Vector{Float64} + qw::Vector{Float64} + Gpou::Matrix{Float64} + Ppou::Matrix{Float64} + M_G2P::Array{Float64,4} + I_G2P::Array{Int,3} + PATCH_DIM::Int + PATCH_RAD::Int + INTERP_ORDER::Int + ANG_DIM::Int + RAD_DIM::Int end # Global cache for quadrature data (initialized on first use) @@ -44,42 +56,38 @@ Conversion references: function init_singular_quadrature(PATCH_RAD::Int, RAD_DIM::Int, INTERP_ORDER::Int=6) # Total size of square patch extracted around singular point (odd number: 2*PATCH_DIM0+1) + INTERP_ORDER = 2 PATCH_DIM = 2 * PATCH_RAD + 1 @assert INTERP_ORDER <= PATCH_DIM "Must have INTERP_ORDER <= PATCH_DIM" # Number of angular quadrature nodes in polar coordinates (uniformly distributed around circle) ANG_DIM = 2 * RAD_DIM - # Total number of points in the square Patch - Ngrid = PATCH_DIM * PATCH_DIM - # Total number of polar quadrature nodes - Npolar = RAD_DIM * ANG_DIM - # Setup radial quadrature (Gauss-Legendre on [0,1]) - qx, qw = FastGaussQuadrature.gausslegendre(RAD_DIM) + # Setup radial quadrature (Gauss-Legendre transformed to [0,1]) + # FastGaussQuadrature.gausslegendre returns points on [-1,1], must transform to [0,1] + qx_raw, qw_raw = FastGaussQuadrature.gausslegendre(RAD_DIM) + qx = (qx_raw .+ 1) ./ 2 # Map [-1, 1] to [0, 1] + qw = qw_raw ./ 2 # Adjust weights for interval change # Partition of unity function, exp(-36 * r^p) where p depends on PATCH_DIM pou_power = PATCH_DIM > 45 ? 10 : (PATCH_DIM > 20 ? 8 : 6) - pou_func(r) = r ≥ 1.0 ? 0.0 : exp(-36.0 * r^pou_power) + pou(r) = r ≥ 1.0 ? 0.0 : exp(-36.0 * r^pou_power) - # Evaluate POU on Cartesian grid - Gpou = zeros(Ngrid) + # Partition of Unity on Cartesian grid + Gpou = zeros(PATCH_DIM, PATCH_DIM) for i in 1:PATCH_DIM, j in 1:PATCH_DIM r = sqrt((i - 1 - PATCH_RAD)^2 + (j - 1 - PATCH_RAD)^2) / PATCH_RAD - Gpou[j+(i-1)*PATCH_DIM] = -pou_func(r) + Gpou[i, j] = -pou(r) end - # Evaluate POU on polar grid including transformation Jacobian - Ppou = χ(ρ) M²/4 r dr dt, eq. 38 in Malhotra 2019 - Ppou = zeros(Npolar) + # Partition of Unity on polar grid including transformation Jacobian - Ppou = χ(ρ) M²/4 r dr dt, eq. 38 in Malhotra 2019 + Ppou = zeros(RAD_DIM, ANG_DIM) dt = 2π / ANG_DIM for j in 1:ANG_DIM, i in 1:RAD_DIM dr = qw[i] * PATCH_RAD rdt = qx[i] * PATCH_RAD * dt; - Ppou[j+(i-1)*ANG_DIM] = pou_func(qx[i]) * dr * rdt + Ppou[i, j] = pou(qx[i]) * dr * rdt end - # Build Lagrange interpolation matrix from Cartesian grid to polar points - M_G2P = zeros(Npolar * INTERP_ORDER^2) - I_G2P = zeros(Int, Npolar) - # Spacing between Lagrange interpolation nodes in [0,1] for INTERP_ORDER-point stencil h = 1.0 / (INTERP_ORDER - 1) # Compute 2D tensor-product Lagrange basis function at (x0, x1) in local stencil coordinates @@ -97,36 +105,34 @@ function init_singular_quadrature(PATCH_RAD::Int, RAD_DIM::Int, INTERP_ORDER::In return Lx * Ly end - for ia in 1:ANG_DIM - theta_polar = 2π * (ia - 1) / ANG_DIM - for ir in 1:RAD_DIM - # Map polar node to unit square: x0, x1 ∈ [0,1] × [0,1] - x0 = 0.5 + 0.5 * qx[ir] * cos(theta_polar) - x1 = 0.5 + 0.5 * qx[ir] * sin(theta_polar) - println("x0 = $x0, x1 = $x1") - - # Lower-left corner indices of INTERP_ORDER × INTERP_ORDER stencil centered on (x0,x1) - # Clamped to stay within patch boundaries - i0_center = trunc(Int, x0 * (PATCH_DIM - 1) - (INTERP_ORDER - 1) / 2) - y0 = clamp(i0_center, 0, PATCH_DIM - INTERP_ORDER) - i1_center = trunc(Int, x1 * (PATCH_DIM - 1) - (INTERP_ORDER - 1) / 2) - y1 = clamp(i1_center, 0, PATCH_DIM - INTERP_ORDER) - - # Local coordinates within INTERP_ORDER×INTERP_ORDER stencil, normalized to [0,1] - z0 = (x0 * (PATCH_DIM - 1) - y0) * h - z1 = (x1 * (PATCH_DIM - 1) - y1) * h - - idx = ia + ANG_DIM * (ir - 1) - I_G2P[idx] = y0 * PATCH_DIM + y1 - for j0 in 0:(INTERP_ORDER-1) - for j1 in 0:(INTERP_ORDER-1) - M_G2P[1+j1+INTERP_ORDER*(j0+INTERP_ORDER*(idx-1))] = lagrange_interp(z0, z1, j0, j1) - end - end + # Build Lagrange interpolation matrix from Cartesian grid to polar (G2P) points + M_G2P = zeros(RAD_DIM, ANG_DIM, INTERP_ORDER, INTERP_ORDER) + I_G2P = zeros(Int, RAD_DIM, ANG_DIM, 2) # y0,y1 indices of lower-left corner of stencil in PATCH_DIM × PATCH_DIM grid + Δθ = 2π / ANG_DIM + for ir in 1:RAD_DIM, ia in 1:ANG_DIM + # Map polar node to unit square: x0, x1 ∈ [0,1] × [0,1] + x0 = 0.5 + 0.5 * qx[ir] * cos(Δθ * (ia - 1)) + x1 = 0.5 + 0.5 * qx[ir] * sin(Δθ * (ia - 1)) + + # Lower-left corner indices of INTERP_ORDER × INTERP_ORDER stencil centered on (x0,x1) + # C++: (Integer)(x0 * (PATCH_DIM - 1) - (INTERP_ORDER - 1) / 2) -- truncate AFTER subtraction + y0 = clamp(trunc(Int, x0 * (PATCH_DIM - 1) - (INTERP_ORDER - 1) ÷ 2), 0, PATCH_DIM - INTERP_ORDER) + y1 = clamp(trunc(Int, x1 * (PATCH_DIM - 1) - (INTERP_ORDER - 1) ÷ 2), 0, PATCH_DIM - INTERP_ORDER) + + # Local coordinates within INTERP_ORDER×INTERP_ORDER stencil, normalized to [0,1] + z0 = (x0 * (PATCH_DIM - 1) - y0) * h + z1 = (x1 * (PATCH_DIM - 1) - y1) * h + + # Indices of lower-left corner of INTERP_ORDER × INTERP_ORDER stencil in PATCH_DIM × PATCH_DIM grid + I_G2P[ir, ia, :] .= [y0, y1] + + # 2D tensor-product Lagrange basis function values on the polar grid + for j0 in 1:INTERP_ORDER, j1 in 1:INTERP_ORDER + M_G2P[ir, ia, j0, j1] = lagrange_interp(z0, z1, j0 - 1, j1 - 1) end end - return SingularQuadratureData(qx, qw, Gpou, Ppou, M_G2P, I_G2P, PATCH_DIM, INTERP_ORDER, Npolar) + return SingularQuadratureData(qx, qw, Gpou, Ppou, M_G2P, I_G2P, PATCH_DIM, PATCH_RAD, INTERP_ORDER, ANG_DIM, RAD_DIM) end """ @@ -164,21 +170,12 @@ The single-layer kernel φ is the fundamental solution to Laplace's equation: # Returns - `Float64`: Kernel value φ(x_obs, x_src) - -Conversion references: - - - Fundamental solution form matches FxU kernel definition in biest/kernel.hpp """ function laplace_single_layer(x_obs::Vector{Float64}, x_src::Vector{Float64})::Float64 # Single-layer kernel: 1/(4π r) r = norm(x_obs - x_src) - - # if r < 1e-10 - # @warn("laplace_single_layer: Observation and source points are too close (r = $r). Returning 0.0 to avoid singularity. Increase PATCH_RAD to improve accuracy.") - # return 0.0 - # end - + r < 1e-30 && return 0.0 return 1.0 / (4π * r) end @@ -203,21 +200,12 @@ K(x_obs, x_src, n_src) = ∇_{x_src} φ · n̂_src # Returns - `Float64`: Kernel value K(x_obs, x_src, n_src) - -Conversion references: - - - Normal-derivative kernel mirrors DxU in biest/kernel.hpp """ function laplace_double_layer(x_obs::Vector{Float64}, x_src::Vector{Float64}, n_src::Vector{Float64})::Float64 # Double-layer kernel: -1/(4π) * (r·n) / r³ r = norm(x_obs - x_src) - - # if r < 1e-10 - # @warn("laplace_double_layer: Observation and source points are too close (r = $r). Returning 0.0 to avoid singularity. Increase PATCH_RAD to improve accuracy.") - # return 0.0 - # end - + r < 1e-30 && return 0.0 return -dot(x_obs - x_src, n_src) / (4π * r^3) end @@ -243,69 +231,49 @@ Conversion references: """ function extract_patch(data::Matrix{Float64}, Nt::Int, Np::Int, t0::Int, p0::Int, PATCH_DIM::Int) - @assert size(data, 1) == Nt * Np - @assert Nt ≥ PATCH_DIM - @assert Np ≥ PATCH_DIM - PATCH_RAD = (PATCH_DIM - 1) ÷ 2 - t_start = t0 - PATCH_RAD - p_start = p0 - PATCH_RAD dof = size(data, 2) # Number of components (3 for coords, 1 for scalars) - patch = zeros(PATCH_DIM * PATCH_DIM, dof) - + patch = zeros(PATCH_DIM, PATCH_DIM, dof) for i in 1:PATCH_DIM, j in 1:PATCH_DIM # Enforce periodicity - t = mod1(t_start + i - 1, Nt) - p = mod1(p_start + j - 1, Np) - + t = mod1(t0 - PATCH_RAD + i - 1, Nt) + p = mod1(p0 - PATCH_RAD + j - 1, Np) # Copy data to the patch idx_in = p + Np * (t - 1) - idx_out = j + PATCH_DIM * (i - 1) - @views patch[idx_out, :] .= data[idx_in, :] + @views patch[i, j, :] .= data[idx_in, :] end - - return patch # (Ngrid, dof) + return patch # (PATCH_DIM, PATCH_DIM, dof) end """ - interpolate_to_polar(patch, M_G2P, Npolar) + interpolate_to_polar(patch, quad_data) Interpolate Cartesian patch data to polar quadrature points using precomputed matrix. # Arguments - - `patch`: Patch data (PATCH_DIM² × dof) + - `patch`: Patch data (PATCH_DIM × PATCH_DIM × dof) - `quad_data`: Precomputed quadrature data # Returns - - `polar_data`: Interpolated data at polar points (Npolar × dof) + - `polar_data`: Interpolated data at polar points (RAD_DIM × ANG_DIM × dof) Conversion references: - Grid→polar interpolation follows M_G2P application in biest/singular_correction.hpp """ -function interpolate_to_polar(patch::Matrix{Float64}, quad_data::SingularQuadratureData) - - (; M_G2P, I_G2P, INTERP_ORDER, PATCH_DIM, Npolar) = quad_data - dof = size(patch, 2) - polar_data = zeros(Npolar, dof) - for j in 1:Npolar - # Base offset for interpolation matrix block - M_offset = (j - 1) * INTERP_ORDER * INTERP_ORDER - for k in 1:dof - sum = 0.0 - for i0 in 0:(INTERP_ORDER-1), i1 in 0:(INTERP_ORDER-1) - # Index into interpolation weights - M_idx = M_offset + i0 * INTERP_ORDER + i1 + 1 - # Index into the patch for this stencil point - patch_idx = I_G2P[j] + i0 * PATCH_DIM + i1 + 1 - sum += M_G2P[M_idx] * patch[patch_idx, k] - end - polar_data[j, k] = sum +function interpolate_to_polar(patch::Array{Float64,3}, quad_data::SingularQuadratureData) + + (; M_G2P, I_G2P, INTERP_ORDER, RAD_DIM, ANG_DIM) = quad_data + dof = size(patch, 3) + polar_data = zeros(RAD_DIM, ANG_DIM, dof) + for ir in 1:RAD_DIM, ia in 1:ANG_DIM + ycorner = I_G2P[ir, ia, :] + for i0 in 1:INTERP_ORDER, i1 in 1:INTERP_ORDER + polar_data[ir, ia, :] .+= M_G2P[ir, ia, i0, i1] .* patch[ycorner[1]+i0, ycorner[2]+i1, :] end end - return polar_data end @@ -341,77 +309,68 @@ function compute_singular_correction!( obs_geom::PlasmaGeometry3D, source_geom::PlasmaGeometry3D, quad_data::SingularQuadratureData, - t0::Int, p0::Int, - RAD_DIM::Int + t0::Int, p0::Int ) (; nzeta, ntheta, r, n, dA) = source_geom - (; PATCH_DIM, INTERP_ORDER, Npolar, qw, Ppou, Gpou, M_G2P, I_G2P) = quad_data - PATCH_RAD = (PATCH_DIM - 1) ÷ 2 - ANG_DIM = 2 * RAD_DIM + (; PATCH_DIM, PATCH_RAD, INTERP_ORDER, ANG_DIM, RAD_DIM, qw, Ppou, Gpou, M_G2P, I_G2P) = quad_data # Observer position r_obs = obs_geom.r[obs_idx, :] - # Extract patches of source geometry (size = Ngrid x dof) + # Extract patches of source geometry (size = PATCH_DIM x PATCH_DIM x dof) r_patch = extract_patch(r, nzeta, ntheta, t0, p0, PATCH_DIM) n_patch = extract_patch(n, nzeta, ntheta, t0, p0, PATCH_DIM) dA_patch = extract_patch(reshape(dA, :, 1), nzeta, ntheta, t0, p0, PATCH_DIM) - # Interpolate to polar quadrature points (size = Npolar x dof) + # Interpolate to polar quadrature points (size = RAD_DIM x ANG_DIM x dof) r_polar = interpolate_to_polar(r_patch, quad_data) n_polar = interpolate_to_polar(n_patch, quad_data) - dA_polar = interpolate_to_polar(dA_patch, quad_data)[:, 1] + dA_polar = interpolate_to_polar(dA_patch, quad_data)[:, :, 1] - # Compute area elements at polar points (cross product of tangent vectors) - # For now use interpolated dA; could recompute from derivatives if needed + # TODO: Compute area elements at polar points (cross product of tangent vectors) # Evaluate kernels at polar points with POU weighting - M_polar_single = zeros(Npolar) - M_polar_double = zeros(Npolar) - dtheta = 2π / ANG_DIM + M_polar_single = zeros(RAD_DIM, ANG_DIM) + M_polar_double = zeros(RAD_DIM, ANG_DIM) + Δθ = 2π / ANG_DIM for ia in 1:ANG_DIM, ir in 1:RAD_DIM - ipolar = ir + RAD_DIM * (ia - 1) - r_src, n_src = r_polar[ipolar, :], n_polar[ipolar, :] - # Evaluate kernels + r_src, n_src = r_polar[ir, ia, :], n_polar[ir, ia, :] K_single = laplace_single_layer(r_obs, r_src) K_double = laplace_double_layer(r_obs, r_src, n_src) # Apply quadrature weights: radial weight × angular weight × area element × POU - wt = qw[ir] * dtheta * dA_polar[ipolar] * Ppou[ipolar] - M_polar_single[ipolar] = K_single * wt - M_polar_double[ipolar] = K_double * wt + wt = qw[ir] * Δθ * dA_polar[ir, ia] * Ppou[ir, ia] + M_polar_single[ir, ia] = K_single * wt + M_polar_double[ir, ia] = K_double * wt end # Distribute corrections back to Cartesian grid using interpolation matrix # Correction at grid point = sum over polar points of (kernel_value * interp_weight) - M_grid_single = zeros(PATCH_DIM * PATCH_DIM) - M_grid_double = zeros(PATCH_DIM * PATCH_DIM) - M_G2P_view = reshape(M_G2P, INTERP_ORDER, INTERP_ORDER, Npolar) - for j in 1:Npolar - for i0 in 0:(INTERP_ORDER-1), i1 in 0:(INTERP_ORDER-1) - patch_idx = I_G2P[j] + i0 * PATCH_DIM + i1 + 1 - M_grid_single[patch_idx] += M_G2P_view[i0+1, i1+1, j] * M_polar_single[j] - M_grid_double[patch_idx] += M_G2P_view[i0+1, i1+1, j] * M_polar_double[j] + M_grid_single = zeros(PATCH_DIM, PATCH_DIM) + M_grid_double = zeros(PATCH_DIM, PATCH_DIM) + for ir in 1:RAD_DIM, ia in 1:ANG_DIM + ycorner = I_G2P[ir, ia, :] + for i0 in 1:INTERP_ORDER, i1 in 1:INTERP_ORDER + M_grid_single[ycorner[1]+i0, ycorner[2]+i1] += M_G2P[ir, ia, i0, i1] * M_polar_single[ir, ia] + M_grid_double[ycorner[1]+i0, ycorner[2]+i1] += M_G2P[ir, ia, i0, i1] * M_polar_double[ir, ia] end end - # Subtract far-field contribution (Gpou = -χ on grid) and add near-field polar quadrature result + # Add far-field POU contribution (Gpou = -χ on grid) and near-field polar quadrature result for j in 1:PATCH_DIM, i in 1:PATCH_DIM - igrid = i + PATCH_DIM * (j - 1) - # Map back to global indices - it = mod1(t0 - PATCH_RAD - 1 + i, nzeta) - ip = mod1(p0 - PATCH_RAD - 1 + j, ntheta) - idx_src = it + nzeta * (ip - 1) + it = mod1(t0 - PATCH_RAD + i - 1, nzeta) + ip = mod1(p0 - PATCH_RAD + j - 1, ntheta) + idx_src = ip + ntheta * (it - 1) - # Far-field contribution to subtract (computed with rectangle rule) + # Far-field contribution (computed with rectangle rule) r_src, n_src, dA_src = r[idx_src, :], n[idx_src, :], dA[idx_src] - far_single = laplace_single_layer(r_obs, r_src) * dA_src * Gpou[igrid] - far_double = laplace_double_layer(r_obs, r_src, n_src) * dA_src * Gpou[igrid] + far_single = laplace_single_layer(r_obs, r_src) * dA_src * Gpou[i, j] + far_double = laplace_double_layer(r_obs, r_src, n_src) * dA_src * Gpou[i, j] - # Apply correction: subtract far, add near - greenfunction[obs_idx, idx_src] += M_grid_single[igrid] + far_single - grad_greenfunction[obs_idx, idx_src] += M_grid_double[igrid] + far_double + # Apply far + near contributions + greenfunction[obs_idx, idx_src] += M_grid_single[i, j] + far_single + grad_greenfunction[obs_idx, idx_src] += M_grid_double[i, j] + far_double end end @@ -449,7 +408,7 @@ function compute_3D_kernel_matrix!( observer::PlasmaGeometry3D, source::PlasmaGeometry3D; PATCH_RAD::Int=3, - RAD_DIM::Int=12 + RAD_DIM::Int=15 ) # Zero out matrices @@ -458,6 +417,8 @@ function compute_3D_kernel_matrix!( # Initialize quadrature data (cached) quad_data = get_singular_quadrature(PATCH_RAD, RAD_DIM) + @assert observer.ntheta ≥ quad_data.PATCH_DIM + @assert observer.nzeta ≥ quad_data.PATCH_DIM # Helper for periodic distance @inline periodic_dist(i, j, n) = min(abs(i - j), n - abs(i - j)) @@ -467,23 +428,13 @@ function compute_3D_kernel_matrix!( idx_obs = i_obs + (j_obs - 1) * observer.ntheta r_obs = observer.r[idx_obs, :] - # Get indices excluding the singular region (square of radius ±PATCH_RAD around observer point) - # Include points where either θ and ζ distance is greater than patch - # (i.e., at least one coordinate distance > PATCH_RAD) - nonsing_idx = Vector{Int}(undef, 0) - sizehint!(nonsing_idx, observer.ntheta * observer.nzeta) - for j in 1:source.nzeta, i in 1:source.ntheta - if periodic_dist(i, i_obs, source.ntheta) > PATCH_RAD || - periodic_dist(j, j_obs, source.nzeta) > PATCH_RAD - push!(nonsing_idx, i + (j - 1) * source.ntheta) - end - end - # ============================================ - # FAR FIELD: Rectangle rule for nonsingular source points + # FAR FIELD: Trapezoidal rule for nonsingular source points + # Note: kernels return zero for r_src = r_obs # ============================================ - for idx_src in nonsing_idx + for j_src in 1:source.nzeta, i_src in 1:source.ntheta # Evaluate kernels at grid points + idx_src = i_src + (j_src - 1) * source.ntheta K_single = laplace_single_layer(r_obs, source.r[idx_src, :]) K_double = laplace_double_layer(r_obs, source.r[idx_src, :], source.n[idx_src, :]) @@ -498,8 +449,7 @@ function compute_3D_kernel_matrix!( compute_singular_correction!( greenfunction, grad_greenfunction, idx_obs, observer, source, - quad_data, i_obs, j_obs, - RAD_DIM + quad_data, j_obs, i_obs ) end From 37b429415ea2475364eafbddf4c645665a74a51f Mon Sep 17 00:00:00 2001 From: Jake Halpern Date: Tue, 27 Jan 2026 08:22:06 -0500 Subject: [PATCH 13/31] VACUUM - IMPROVEMENT - julia conversion now matching c++ outputs, added gradients to geometry array for interpolation of dA and n --- src/Vacuum/Vacuum.jl | 28 ++++-- src/Vacuum/Vacuum3D.jl | 187 +++++++++++++++++------------------- src/Vacuum/VacuumStructs.jl | 38 +++++--- 3 files changed, 137 insertions(+), 116 deletions(-) diff --git a/src/Vacuum/Vacuum.jl b/src/Vacuum/Vacuum.jl index d4511e74..67d36d29 100644 --- a/src/Vacuum/Vacuum.jl +++ b/src/Vacuum/Vacuum.jl @@ -357,17 +357,29 @@ function compute_vacuum_response_3D(inputs::VacuumInput, wall_settings::WallShap !wall.nowall && error("No walls yet!") # DEBUG # Plasma–Plasma block - # G = single-layer kernel, K = double-layer kernel - # Use Nt toroidal points to properly discretize the 3D surface (must be >= 6 for BIEST) - compute_3D_kernel_matrix!(grad_green, green_temp, plasma_surf3D, plasma_surf3D) - display(green_temp) - display(grad_green) - println("Calling BIEST with Nt=$nzeta, Np=$mtheta (total 3D points: $(num_gridpoints))...") # compute_green_matrices!(green_temp, grad_green, plasma_surf.x, plasma_surf.z, plasma_surf.ν, nzeta) compute_green_matrices!(green_temp, grad_green, plasma_surf3D) - display(green_temp) - display(grad_green) + # display(green_temp) + # display(grad_green) + green_BIEST = copy(green_temp) + grad_green_BIEST = copy(grad_green) + + # G = single-layer kernel, K = double-layer kernel + # Use Nt toroidal points to properly discretize the 3D surface (must be >= 6 for BIEST) + compute_3D_kernel_matrix!(grad_green, green_temp, plasma_surf3D, plasma_surf3D) + # display(green_temp) + # display(grad_green) + + # Compare BIEST and regular kernel matrices + println("\n=== Comparing BIEST vs Regular Kernel Matrices ===") + println("\nDifference single layer (regular - BIEST):") + display(green_temp - green_BIEST) + println("Max difference in green_temp: $(maximum(abs.(green_temp - green_BIEST)))") + + println("\nDifference double layer (regular - BIEST):") + display(grad_green - grad_green_BIEST) + println("Max difference in grad_green: $(maximum(abs.(grad_green - grad_green_BIEST)))") # Sum Green's function matrices over toroidal direction to recover 2D poloidal slice # green_2D = zeros(ComplexF64, mtheta, mtheta) diff --git a/src/Vacuum/Vacuum3D.jl b/src/Vacuum/Vacuum3D.jl index e0505daa..468b1d3e 100644 --- a/src/Vacuum/Vacuum3D.jl +++ b/src/Vacuum/Vacuum3D.jl @@ -56,7 +56,6 @@ Conversion references: function init_singular_quadrature(PATCH_RAD::Int, RAD_DIM::Int, INTERP_ORDER::Int=6) # Total size of square patch extracted around singular point (odd number: 2*PATCH_DIM0+1) - INTERP_ORDER = 2 PATCH_DIM = 2 * PATCH_RAD + 1 @assert INTERP_ORDER <= PATCH_DIM "Must have INTERP_ORDER <= PATCH_DIM" # Number of angular quadrature nodes in polar coordinates (uniformly distributed around circle) @@ -229,18 +228,17 @@ Conversion references: - Mirrors patch extraction used inside SingularCorrection::SetPatch in biest/singular_correction.hpp """ -function extract_patch(data::Matrix{Float64}, Nt::Int, Np::Int, t0::Int, p0::Int, PATCH_DIM::Int) +function extract_patch(data::Matrix{Float64}, idx_pol_center::Int, idx_tor_center::Int, npol::Int, ntor::Int, PATCH_DIM::Int) PATCH_RAD = (PATCH_DIM - 1) ÷ 2 dof = size(data, 2) # Number of components (3 for coords, 1 for scalars) patch = zeros(PATCH_DIM, PATCH_DIM, dof) for i in 1:PATCH_DIM, j in 1:PATCH_DIM # Enforce periodicity - t = mod1(t0 - PATCH_RAD + i - 1, Nt) - p = mod1(p0 - PATCH_RAD + j - 1, Np) + idx_pol = mod1(idx_pol_center - PATCH_RAD + i - 1, npol) + idx_tor = mod1(idx_tor_center - PATCH_RAD + j - 1, ntor) # Copy data to the patch - idx_in = p + Np * (t - 1) - @views patch[i, j, :] .= data[idx_in, :] + @views patch[i, j, :] .= data[idx_pol+npol*(idx_tor-1), :] end return patch # (PATCH_DIM, PATCH_DIM, dof) end @@ -278,100 +276,46 @@ function interpolate_to_polar(patch::Array{Float64,3}, quad_data::SingularQuadra end """ - compute_singular_correction!( - greenfunction, grad_greenfunction, - obs_idx, source_geom, quad_data, - PATCH_DIM, RAD_DIM - ) + compute_dA_and_normal_polar(dr_dθ_polar, dr_dζ_polar) + +Compute area elements at polar quadrature points from interpolated tangent vectors. -Compute singular correction for observer point using polar quadrature. -Follows BIEST's approach: extract patch, interpolate to polar coordinates, -evaluate kernels with POU weighting, distribute back to grid. +The area element is |∂r/∂θ × ∂r/∂ζ| dθ dζ, computed from the cross product of +the interpolated tangent vectors. # Arguments - - `greenfunction`: Single-layer matrix to update - - `grad_greenfunction`: Double-layer matrix to update - - `obs_idx`: Linear index of observer point - - `source_geom`: Source geometry (PlasmaGeometry3D) - - `quad_data`: Precomputed quadrature data - - `RAD_DIM`: Radial quadrature order + - `dr_dθ_polar`: Interpolated ∂r/∂θ at polar points (RAD_DIM × ANG_DIM × 3) + - `dr_dζ_polar`: Interpolated ∂r/∂ζ at polar points (RAD_DIM × ANG_DIM × 3) -Conversion references: +# Returns - - Patch → polar correction mirrors SingularCorrection operator in biest/singular_correction.hpp - - Subtract/add near-field correction follows EvalSurfInteg flow in biest/surface_op.txx + - `dA_polar`: Area element at each polar point (RAD_DIM × ANG_DIM) + - `n_polar`: Unit normal vector at each polar point (RAD_DIM × ANG_DIM × 3) """ -function compute_singular_correction!( - greenfunction::Matrix{Float64}, - grad_greenfunction::Matrix{Float64}, - obs_idx::Int, - obs_geom::PlasmaGeometry3D, - source_geom::PlasmaGeometry3D, - quad_data::SingularQuadratureData, - t0::Int, p0::Int -) - (; nzeta, ntheta, r, n, dA) = source_geom - (; PATCH_DIM, PATCH_RAD, INTERP_ORDER, ANG_DIM, RAD_DIM, qw, Ppou, Gpou, M_G2P, I_G2P) = quad_data - # Observer position - r_obs = obs_geom.r[obs_idx, :] +function compute_dA_and_normal_polar(dr_dθ::Array{Float64,3}, dr_dζ::Array{Float64,3}) - # Extract patches of source geometry (size = PATCH_DIM x PATCH_DIM x dof) - r_patch = extract_patch(r, nzeta, ntheta, t0, p0, PATCH_DIM) - n_patch = extract_patch(n, nzeta, ntheta, t0, p0, PATCH_DIM) - dA_patch = extract_patch(reshape(dA, :, 1), nzeta, ntheta, t0, p0, PATCH_DIM) + RAD_DIM, ANG_DIM, _ = size(dr_dθ) + dA_polar = zeros(RAD_DIM, ANG_DIM) + n_polar = zeros(RAD_DIM, ANG_DIM, 3) + for ia in 1:ANG_DIM, ir in 1:RAD_DIM + # Extract tangent vectors at this polar point + t_θ = @view dr_dθ[ir, ia, :] + t_ζ = @view dr_dζ[ir, ia, :] - # Interpolate to polar quadrature points (size = RAD_DIM x ANG_DIM x dof) - r_polar = interpolate_to_polar(r_patch, quad_data) - n_polar = interpolate_to_polar(n_patch, quad_data) - dA_polar = interpolate_to_polar(dA_patch, quad_data)[:, :, 1] + # Cross product: ∂r/∂θ × ∂r/∂ζ + cross_prod = cross(t_θ, t_ζ) - # TODO: Compute area elements at polar points (cross product of tangent vectors) + # Magnitude gives area element + dA_polar[ir, ia] = norm(cross_prod) - # Evaluate kernels at polar points with POU weighting - M_polar_single = zeros(RAD_DIM, ANG_DIM) - M_polar_double = zeros(RAD_DIM, ANG_DIM) - Δθ = 2π / ANG_DIM - for ia in 1:ANG_DIM, ir in 1:RAD_DIM - # Evaluate kernels - r_src, n_src = r_polar[ir, ia, :], n_polar[ir, ia, :] - K_single = laplace_single_layer(r_obs, r_src) - K_double = laplace_double_layer(r_obs, r_src, n_src) - - # Apply quadrature weights: radial weight × angular weight × area element × POU - wt = qw[ir] * Δθ * dA_polar[ir, ia] * Ppou[ir, ia] - M_polar_single[ir, ia] = K_single * wt - M_polar_double[ir, ia] = K_double * wt - end - - # Distribute corrections back to Cartesian grid using interpolation matrix - # Correction at grid point = sum over polar points of (kernel_value * interp_weight) - M_grid_single = zeros(PATCH_DIM, PATCH_DIM) - M_grid_double = zeros(PATCH_DIM, PATCH_DIM) - for ir in 1:RAD_DIM, ia in 1:ANG_DIM - ycorner = I_G2P[ir, ia, :] - for i0 in 1:INTERP_ORDER, i1 in 1:INTERP_ORDER - M_grid_single[ycorner[1]+i0, ycorner[2]+i1] += M_G2P[ir, ia, i0, i1] * M_polar_single[ir, ia] - M_grid_double[ycorner[1]+i0, ycorner[2]+i1] += M_G2P[ir, ia, i0, i1] * M_polar_double[ir, ia] + # Unit normal (handle zero jacobian gracefully) + if dA_polar[ir, ia] > 1e-30 + n_polar[ir, ia, :] .= cross_prod ./ dA_polar[ir, ia] end end - # Add far-field POU contribution (Gpou = -χ on grid) and near-field polar quadrature result - for j in 1:PATCH_DIM, i in 1:PATCH_DIM - # Map back to global indices - it = mod1(t0 - PATCH_RAD + i - 1, nzeta) - ip = mod1(p0 - PATCH_RAD + j - 1, ntheta) - idx_src = ip + ntheta * (it - 1) - - # Far-field contribution (computed with rectangle rule) - r_src, n_src, dA_src = r[idx_src, :], n[idx_src, :], dA[idx_src] - far_single = laplace_single_layer(r_obs, r_src) * dA_src * Gpou[i, j] - far_double = laplace_double_layer(r_obs, r_src, n_src) * dA_src * Gpou[i, j] - - # Apply far + near contributions - greenfunction[obs_idx, idx_src] += M_grid_single[i, j] + far_single - grad_greenfunction[obs_idx, idx_src] += M_grid_double[i, j] + far_double - end + return dA_polar, n_polar end """ @@ -417,11 +361,9 @@ function compute_3D_kernel_matrix!( # Initialize quadrature data (cached) quad_data = get_singular_quadrature(PATCH_RAD, RAD_DIM) - @assert observer.ntheta ≥ quad_data.PATCH_DIM - @assert observer.nzeta ≥ quad_data.PATCH_DIM - - # Helper for periodic distance - @inline periodic_dist(i, j, n) = min(abs(i - j), n - abs(i - j)) + (; PATCH_DIM, PATCH_RAD, INTERP_ORDER, ANG_DIM, RAD_DIM, qw, Ppou, Gpou, M_G2P, I_G2P) = quad_data + @assert observer.ntheta ≥ PATCH_DIM + @assert observer.nzeta ≥ PATCH_DIM # Loop through observer points for j_obs in 1:observer.nzeta, i_obs in 1:observer.ntheta @@ -446,14 +388,65 @@ function compute_3D_kernel_matrix!( # ============================================ # NEAR FIELD: Polar quadrature with singular correction # ============================================ - compute_singular_correction!( - greenfunction, grad_greenfunction, - idx_obs, observer, source, - quad_data, j_obs, i_obs - ) + # Extract patches of source data around the singular point (size = PATCH_DIM x PATCH_DIM x dof) + r_patch = extract_patch(source.r, i_obs, j_obs, source.ntheta, source.nzeta, PATCH_DIM) + dr_dθ_patch = extract_patch(source.dr_dθ, i_obs, j_obs, source.ntheta, source.nzeta, PATCH_DIM) + dr_dζ_patch = extract_patch(source.dr_dζ, i_obs, j_obs, source.ntheta, source.nzeta, PATCH_DIM) + + # Interpolate coordinates and tangent vectors to polar quadrature points + r_polar = interpolate_to_polar(r_patch, quad_data) + dr_dθ_polar = interpolate_to_polar(dr_dθ_patch, quad_data) + dr_dζ_polar = interpolate_to_polar(dr_dζ_patch, quad_data) + + # Compute area elements and unit normals at polar points from interpolated tangent vectors + dA_polar, n_polar = compute_dA_and_normal_polar(dr_dθ_polar, dr_dζ_polar) + + # Evaluate kernels at polar points with POU weighting + M_polar_single = zeros(RAD_DIM, ANG_DIM) + M_polar_double = zeros(RAD_DIM, ANG_DIM) + for ia in 1:ANG_DIM, ir in 1:RAD_DIM + # Evaluate kernels using recomputed normal + r_src, n_src = r_polar[ir, ia, :], n_polar[ir, ia, :] + K_single = laplace_single_layer(r_obs, r_src) + K_double = laplace_double_layer(r_obs, r_src, n_src) + + # Apply quadrature weights: area element × POU, where POU contains rdrdθ already + wt = dA_polar[ir, ia] * Ppou[ir, ia] + M_polar_single[ir, ia] = K_single * wt + M_polar_double[ir, ia] = K_double * wt + end + + # Distribute corrections back to Cartesian grid using interpolation matrix + # Correction at grid point = sum over polar points of (kernel_value * interp_weight) + M_grid_single = zeros(PATCH_DIM, PATCH_DIM) + M_grid_double = zeros(PATCH_DIM, PATCH_DIM) + for ir in 1:RAD_DIM, ia in 1:ANG_DIM + ycorner = I_G2P[ir, ia, :] + for i0 in 1:INTERP_ORDER, i1 in 1:INTERP_ORDER + M_grid_single[ycorner[1]+i0, ycorner[2]+i1] += M_G2P[ir, ia, i0, i1] * M_polar_single[ir, ia] + M_grid_double[ycorner[1]+i0, ycorner[2]+i1] += M_G2P[ir, ia, i0, i1] * M_polar_double[ir, ia] + end + end + + # Add far-field POU contribution (Gpou = -χ on grid) and near-field polar quadrature result + for j in 1:PATCH_DIM, i in 1:PATCH_DIM + # Map back to global indices + idx_pol = mod1(i_obs - PATCH_RAD + i - 1, source.ntheta) + idx_tor = mod1(j_obs - PATCH_RAD + j - 1, source.nzeta) + idx_src = idx_pol + source.ntheta * (idx_tor - 1) + + # Remainder of far-field contribution on the singular grid: -χGᵢⱼdA + r_src, n_src, dA_src = source.r[idx_src, :], source.n[idx_src, :], source.dA[idx_src] + far_single = laplace_single_layer(r_obs, r_src) * dA_src * Gpou[i, j] + far_double = laplace_double_layer(r_obs, r_src, n_src) * dA_src * Gpou[i, j] + + # Apply far + near contributions + greenfunction[idx_obs, idx_src] += M_grid_single[i, j] + far_single + grad_greenfunction[idx_obs, idx_src] += M_grid_double[i, j] + far_double + end end # Account for normal direction pointing out of vacuum integration region in 𝒦ⁿ ⋅ dS # Negative for plasma since dS = ∇ψ J dθdζ and ∇ψ points outward but outward normal is inward - @views grad_greenfunction .*= (source isa PlasmaGeometry3D ? -1 : 1) + # @views grad_greenfunction .*= (source isa PlasmaGeometry3D ? -1 : 1) end diff --git a/src/Vacuum/VacuumStructs.jl b/src/Vacuum/VacuumStructs.jl index c0a67347..2f493148 100644 --- a/src/Vacuum/VacuumStructs.jl +++ b/src/Vacuum/VacuumStructs.jl @@ -208,8 +208,8 @@ struct PlasmaGeometry3D r::Matrix{Float64} n::Matrix{Float64} dA::Vector{Float64} - # sin_mn_basis3D::Matrix{Float64} - # cos_mn_basis3D::Matrix{Float64} + dr_dθ::Matrix{Float64} + dr_dζ::Matrix{Float64} end function PlasmaGeometry3D(plasma_2d::PlasmaGeometry, nzeta::Int)::PlasmaGeometry3D @@ -227,6 +227,8 @@ function PlasmaGeometry3D(plasma_2d::PlasmaGeometry, nzeta::Int)::PlasmaGeometry z = zeros(ntotal) n = zeros(ntotal, 3) dA = zeros(ntotal) + dr_dθ = zeros(ntotal, 3) # Tangent vector ∂r/∂θ + dr_dζ = zeros(ntotal, 3) # Tangent vector ∂r/∂ζ dθ = 2π / ntheta dϕ = 2π / nzeta @@ -250,33 +252,45 @@ function PlasmaGeometry3D(plasma_2d::PlasmaGeometry, nzeta::Int)::PlasmaGeometry r[idx, 3] = z[idx] end - # Compute differential area elements dA via cross product of tangent vectors - # Create temporary arrays including endpoints for spline interpolation + # Compute tangent vectors and differential area elements via spline interpolation + # Create temporary arrays for spline interpolation X_temp = reshape(x, ntheta, nzeta) Y_temp = reshape(y, ntheta, nzeta) Z_temp = reshape(z, ntheta, nzeta) - # Create splines with periodic data including endpoints + # Create splines with periodic boundary conditions itpX = cubic_spline_interpolation((θ_grid, ϕ_grid), X_temp; bc=Periodic(OnGrid())) itpY = cubic_spline_interpolation((θ_grid, ϕ_grid), Y_temp; bc=Periodic(OnGrid())) itpZ = cubic_spline_interpolation((θ_grid, ϕ_grid), Z_temp; bc=Periodic(OnGrid())) - ∂r_dθ = SVector(3) - ∂r_dϕ = SVector(3) - # Evaluate derivatives at original (non-endpoint) grid points + + # Evaluate derivatives at grid points and store tangent vectors for (i, θ) in enumerate(θ_grid), (j, ϕ) in enumerate(ϕ_grid) idx = i + (j - 1) * ntheta + + # Compute gradient components ∂X_dθ = Interpolations.gradient(itpX, θ, ϕ)[1] ∂X_dϕ = Interpolations.gradient(itpX, θ, ϕ)[2] ∂Y_dθ = Interpolations.gradient(itpY, θ, ϕ)[1] ∂Y_dϕ = Interpolations.gradient(itpY, θ, ϕ)[2] ∂Z_dθ = Interpolations.gradient(itpZ, θ, ϕ)[1] ∂Z_dϕ = Interpolations.gradient(itpZ, θ, ϕ)[2] + + # Store tangent vectors + dr_dθ[idx, :] = [∂X_dθ, ∂Y_dθ, ∂Z_dθ] + dr_dζ[idx, :] = [∂X_dϕ, ∂Y_dϕ, ∂Z_dϕ] + + # Compute cross product for normal and area element ∂r_dθ = SVector(∂X_dθ, ∂Y_dθ, ∂Z_dθ) ∂r_dϕ = SVector(∂X_dϕ, ∂Y_dϕ, ∂Z_dϕ) - dA[idx] = norm(cross(∂r_dθ, ∂r_dϕ)) - n[idx, :] = cross(∂r_dθ, ∂r_dϕ) / dA[idx] + cross_prod = cross(∂r_dθ, ∂r_dϕ) + dA[idx] = norm(cross_prod) + n[idx, :] = cross_prod / dA[idx] end + + # Multipy by scalings dA .*= dθ * dϕ + dr_dθ .*= dθ + dr_dζ .*= dϕ return PlasmaGeometry3D( ntheta, @@ -286,7 +300,9 @@ function PlasmaGeometry3D(plasma_2d::PlasmaGeometry, nzeta::Int)::PlasmaGeometry z, r, n, - dA + dA, + dr_dθ, + dr_dζ ) end From c17a3b3cd4258d9ba4d2e5c03170fcf5f8fa089d Mon Sep 17 00:00:00 2001 From: Jake Halpern Date: Tue, 27 Jan 2026 10:00:31 -0500 Subject: [PATCH 14/31] VACUUM - IMPROVEMENT - cleanups to PlasmaGeometry3D struct --- src/BIEST.jl | 2 +- src/Vacuum/Vacuum3D.jl | 4 +- src/Vacuum/VacuumStructs.jl | 140 ++++++++++++++++++------------------ 3 files changed, 73 insertions(+), 73 deletions(-) diff --git a/src/BIEST.jl b/src/BIEST.jl index 873f0742..265fab1f 100644 --- a/src/BIEST.jl +++ b/src/BIEST.jl @@ -65,7 +65,7 @@ function compute_green_matrices!(G::Matrix{Float64}, K::Matrix{Float64}, surf) # Call C++ wrapper which builds the surface from supplied coordinates ccall((:biest_compute_green_matrices_from3D, libbiest), Cvoid, (Ptr{Cdouble}, Ptr{Cdouble}, Ptr{Cdouble}, Cint, Cint, Ptr{Cdouble}, Ptr{Cdouble}), - surf.x, surf.y, surf.z, Cint(surf.ntheta), Cint(surf.nzeta), G, K) + surf.r[:, 1], surf.r[:, 2], surf.r[:, 3], Cint(surf.ntheta), Cint(surf.nzeta), G, K) end export compute_green_matrices! diff --git a/src/Vacuum/Vacuum3D.jl b/src/Vacuum/Vacuum3D.jl index 468b1d3e..bf5ed7b2 100644 --- a/src/Vacuum/Vacuum3D.jl +++ b/src/Vacuum/Vacuum3D.jl @@ -378,7 +378,7 @@ function compute_3D_kernel_matrix!( # Evaluate kernels at grid points idx_src = i_src + (j_src - 1) * source.ntheta K_single = laplace_single_layer(r_obs, source.r[idx_src, :]) - K_double = laplace_double_layer(r_obs, source.r[idx_src, :], source.n[idx_src, :]) + K_double = laplace_double_layer(r_obs, source.r[idx_src, :], source.normal[idx_src, :]) # Apply area element (periodic trapezoidal rule: w = dA = J∇ψdθdζ) greenfunction[idx_obs, idx_src] = K_single * source.dA[idx_src] @@ -436,7 +436,7 @@ function compute_3D_kernel_matrix!( idx_src = idx_pol + source.ntheta * (idx_tor - 1) # Remainder of far-field contribution on the singular grid: -χGᵢⱼdA - r_src, n_src, dA_src = source.r[idx_src, :], source.n[idx_src, :], source.dA[idx_src] + r_src, n_src, dA_src = source.r[idx_src, :], source.normal[idx_src, :], source.dA[idx_src] far_single = laplace_single_layer(r_obs, r_src) * dA_src * Gpou[i, j] far_double = laplace_double_layer(r_obs, r_src, n_src) * dA_src * Gpou[i, j] diff --git a/src/Vacuum/VacuumStructs.jl b/src/Vacuum/VacuumStructs.jl index 2f493148..8697b60a 100644 --- a/src/Vacuum/VacuumStructs.jl +++ b/src/Vacuum/VacuumStructs.jl @@ -199,110 +199,110 @@ function initialize_plasma_surface(inputs::VacuumInput) ) end -struct PlasmaGeometry3D +""" + PlasmaGeometry3D + +3D toroidal surface geometry for vacuum boundary integral calculations. + +Built by toroidally extruding a 2D poloidal contour (`PlasmaGeometry`) and computing +Cartesian coordinates, tangent vectors, normals, and differential area elements. Note +that the gradient/area elements are scaled by dθ and dζ. + +# Fields + + - `ntheta::Int`: Number of poloidal grid points + - `nzeta::Int`: Number of toroidal grid points + - `num_gridpoints::Int`: Total number of surface grid points (ntheta * nzeta) + - `r::Matrix{Float64}`: Surface points in Cartesian (X,Y,Z), shape (num_gridpoints, 3) + - `dr_dθ::Matrix{Float64}`: Poloidal tangent vector ∂r/∂θ × dθ, shape (num_gridpoints, 3) + - `dr_dζ::Matrix{Float64}`: Toroidal tangent vector ∂r/∂ζ × dζ, shape (num_gridpoints, 3) + - `n::Matrix{Float64}`: Outward unit normal vectors, shape (num_gridpoints, 3) + - `dA::Vector{Float64}`: Differential area elements |∂r/∂θ × ∂r/∂ζ| dθ dζ, length num_gridpoints +""" +@kwdef struct PlasmaGeometry3D ntheta::Int nzeta::Int - x::Vector{Float64} - y::Vector{Float64} - z::Vector{Float64} + num_gridpoints::Int r::Matrix{Float64} - n::Matrix{Float64} - dA::Vector{Float64} dr_dθ::Matrix{Float64} dr_dζ::Matrix{Float64} + normal::Matrix{Float64} + dA::Vector{Float64} end +""" + PlasmaGeometry3D(plasma_2d::PlasmaGeometry, nzeta::Int) -> PlasmaGeometry3D + +Construct a 3D axisymmetric toroidal surface from a 2D poloidal contour. + +# Algorithm + + 1. Map 2D (R, Z, ν) to 3D Cartesian: X = R cos(ϕ+ν), Y = R sin(ϕ+ν), Z = Z + 2. Fit periodic bicubic splines to (X, Y, Z) on (θ, ϕ) grid + 3. Compute tangent vectors via spline gradients + 4. Compute normals and area elements via cross product: n × dA = ∂r/∂θ × ∂r/∂ζ + +# Arguments + + - `plasma_2d`: 2D poloidal plasma geometry + - `nzeta`: Number of toroidal grid points + +# Returns + + - `PlasmaGeometry3D`: Complete 3D surface description +""" function PlasmaGeometry3D(plasma_2d::PlasmaGeometry, nzeta::Int)::PlasmaGeometry3D # Extract 2D poloidal data ntheta = length(plasma_2d.x) - ntotal = ntheta * nzeta - R = plasma_2d.x - Z = plasma_2d.z - ν = plasma_2d.ν - - # Allocate output arrays - x = zeros(ntotal) - y = zeros(ntotal) - z = zeros(ntotal) - n = zeros(ntotal, 3) - dA = zeros(ntotal) - dr_dθ = zeros(ntotal, 3) # Tangent vector ∂r/∂θ - dr_dζ = zeros(ntotal, 3) # Tangent vector ∂r/∂ζ - + num_gridpoints = ntheta * nzeta + (; R, Z, ν) = plasma_2d dθ = 2π / ntheta dϕ = 2π / nzeta θ_grid = range(; start=0, length=ntheta, step=dθ) ϕ_grid = range(; start=0, length=nzeta, step=dϕ) - # Build surface point-by-point + # Allocate output arrays + r = zeros(num_gridpoints, 3) + normal = zeros(num_gridpoints, 3) + dA = zeros(num_gridpoints) + dr_dθ = zeros(num_gridpoints, 3) + dr_dζ = zeros(num_gridpoints, 3) + + # Build 3D surface point-by-point from 2D contour for (i, θ) in enumerate(θ_grid), (j, ϕ) in enumerate(ϕ_grid) - # Linear index in flattened array idx = i + (j - 1) * ntheta - x[idx] = R[i] * cos(ϕ + ν[i]) - y[idx] = R[i] * sin(ϕ + ν[i]) - z[idx] = Z[i] + r[idx, :] .= [R[i] * cos(ϕ + ν[i]), R[i] * sin(ϕ + ν[i]), Z[i]] end - # r: (ntotal, 3) array of (x, y, z) coordinates for each surface point - r = zeros(ntotal, 3) - for idx in 1:ntotal - r[idx, 1] = x[idx] - r[idx, 2] = y[idx] - r[idx, 3] = z[idx] - end + # Create splines for each Cartesian component (X, Y, Z) with periodic boundary conditions + r_grid = reshape(r, ntheta, nzeta, 3) + itps = [cubic_spline_interpolation((θ_grid, ϕ_grid), r_grid[:, :, k]; bc=Periodic(OnGrid())) for k in 1:3] - # Compute tangent vectors and differential area elements via spline interpolation - # Create temporary arrays for spline interpolation - X_temp = reshape(x, ntheta, nzeta) - Y_temp = reshape(y, ntheta, nzeta) - Z_temp = reshape(z, ntheta, nzeta) - - # Create splines with periodic boundary conditions - itpX = cubic_spline_interpolation((θ_grid, ϕ_grid), X_temp; bc=Periodic(OnGrid())) - itpY = cubic_spline_interpolation((θ_grid, ϕ_grid), Y_temp; bc=Periodic(OnGrid())) - itpZ = cubic_spline_interpolation((θ_grid, ϕ_grid), Z_temp; bc=Periodic(OnGrid())) - - # Evaluate derivatives at grid points and store tangent vectors + # Compute tangent vectors, unit normals, and differential area elements via spline interpolation for (i, θ) in enumerate(θ_grid), (j, ϕ) in enumerate(ϕ_grid) idx = i + (j - 1) * ntheta - - # Compute gradient components - ∂X_dθ = Interpolations.gradient(itpX, θ, ϕ)[1] - ∂X_dϕ = Interpolations.gradient(itpX, θ, ϕ)[2] - ∂Y_dθ = Interpolations.gradient(itpY, θ, ϕ)[1] - ∂Y_dϕ = Interpolations.gradient(itpY, θ, ϕ)[2] - ∂Z_dθ = Interpolations.gradient(itpZ, θ, ϕ)[1] - ∂Z_dϕ = Interpolations.gradient(itpZ, θ, ϕ)[2] - - # Store tangent vectors - dr_dθ[idx, :] = [∂X_dθ, ∂Y_dθ, ∂Z_dθ] - dr_dζ[idx, :] = [∂X_dϕ, ∂Y_dϕ, ∂Z_dϕ] - - # Compute cross product for normal and area element - ∂r_dθ = SVector(∂X_dθ, ∂Y_dθ, ∂Z_dθ) - ∂r_dϕ = SVector(∂X_dϕ, ∂Y_dϕ, ∂Z_dϕ) - cross_prod = cross(∂r_dθ, ∂r_dϕ) + dr_dθ[idx, :] .= [Interpolations.gradient(itps[k], θ, ϕ)[1] for k in 1:3] + dr_dζ[idx, :] .= [Interpolations.gradient(itps[k], θ, ϕ)[2] for k in 1:3] + cross_prod = cross(dr_dθ[idx, :], dr_dζ[idx, :]) dA[idx] = norm(cross_prod) - n[idx, :] = cross_prod / dA[idx] + normal[idx, :] .= cross_prod / dA[idx] end - # Multipy by scalings + # Multiply by scalings dA .*= dθ * dϕ dr_dθ .*= dθ dr_dζ .*= dϕ - return PlasmaGeometry3D( + return PlasmaGeometry3D(; ntheta, nzeta, - x, - y, - z, + num_gridpoints, r, - n, - dA, dr_dθ, - dr_dζ + dr_dζ, + normal, + dA ) end From d3f5019183d1d62d444cd4ad5f1e2f7d32425cc1 Mon Sep 17 00:00:00 2001 From: Jake Halpern Date: Wed, 28 Jan 2026 15:39:22 -0500 Subject: [PATCH 15/31] VACUUM - IMPROVEMENT - 3D response matrix agreeing with 2D! --- Project.toml | 2 + src/DCON/Free.jl | 7 +-- src/Vacuum/Vacuum.jl | 88 +++++++++++++++++++++++-------------- src/Vacuum/Vacuum3D.jl | 48 ++++++++++---------- src/Vacuum/VacuumStructs.jl | 75 +++++++++++++++---------------- 5 files changed, 122 insertions(+), 98 deletions(-) diff --git a/Project.toml b/Project.toml index af559a6c..f84c7e1e 100644 --- a/Project.toml +++ b/Project.toml @@ -7,6 +7,7 @@ version = "0.1.0" [deps] DiffEqCallbacks = "459566f4-90b8-5000-8ac3-15dfb0a30def" Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" +FastGaussQuadrature = "442a2c76-b920-505d-bb47-c5924d526838" FFTW = "7a1cc6ca-52ef-59f5-83cd-3a7055c09341" HDF5 = "f67ccb44-e63f-5c2f-98bd-6dc0ccc4ba2f" Interpolations = "a98d9a8b-a2ab-59e6-89dd-64a1c18fca59" @@ -24,6 +25,7 @@ Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [compat] DiffEqCallbacks = "4.9.0" Documenter = "1.14.1" +FastGaussQuadrature = "1" FFTW = "1.9.0" HDF5 = "0.17.2" Interpolations = "0.16.1" diff --git a/src/DCON/Free.jl b/src/DCON/Free.jl index 2c9b2333..42f0e999 100644 --- a/src/DCON/Free.jl +++ b/src/DCON/Free.jl @@ -45,11 +45,12 @@ function free_run!(odet::OdeState, ctrl::DconControl, equil::Equilibrium.PlasmaE println("Difference between 2D and 3D vacuum response matrices:") display(wv .- wv3D) + + println("Norm of difference between 2D and 3D vacuum response matrices:") display(norm(wv .- wv3D)) - println("Maximum eigenvalues:") - display(maximum(real.(eigvals(wv)))) - display(maximum(real.(eigvals(wv3D)))) + println("Maximum relative difference between 2D and 3D vacuum response matrices:") + display(maximum(abs.(wv .- wv3D)) / maximum(abs.(wv))) println("Difference in maximum eigenvalue:") display(maximum(real.(eigvals(wv))) - maximum(real.(eigvals(wv3D)))) diff --git a/src/Vacuum/Vacuum.jl b/src/Vacuum/Vacuum.jl index 67d36d29..13b389e8 100644 --- a/src/Vacuum/Vacuum.jl +++ b/src/Vacuum/Vacuum.jl @@ -247,6 +247,18 @@ function compute_vacuum_response(inputs::VacuumInput, wall_settings::WallShapeSe # Plasma–Plasma block kernel!(grad_green, green_temp, plasma_surf, plasma_surf, n) + # println("2D green_temp (single-layer) matrix:") + # display(green_temp ./2) + # println("2D grad_green (double-layer) matrix:") + # gg_temp = grad_green[1:mtheta, 1:mtheta] ./ 2 - 0.5 * I + # display(gg_temp) + B = ones(mtheta) + green_test = green_temp * B + println("2D Green's integral with a unit source:") + display(real.(green_test)) + green_test = (grad_green[1:mtheta, 1:mtheta]) * B # account for 2D green's function being 1/r (not 1/4πr) + println("2D Grad Green's integral with a unit source:") + display(real.(green_test)) # Fourier transform plasma-plasma block fourier_transform!(green_fourier, green_temp, plasma_surf.cos_mn_basis, PLASMA_ROW_OFFSET, COS_COL_OFFSET) @@ -328,7 +340,7 @@ function compute_vacuum_response_3D(inputs::VacuumInput, wall_settings::WallShap grad_green = zeros(num_gridpoints, num_gridpoints) # for walls, this is 2*mtheta x 2*mtheta green_temp = zeros(num_gridpoints, num_gridpoints) - plasma_surf3D = PlasmaGeometry3D(plasma_surf, nzeta) + plasma_surf3D = PlasmaGeometry3D(plasma_surf, inputs) if false # dA debugging a = 0.1 @@ -357,55 +369,63 @@ function compute_vacuum_response_3D(inputs::VacuumInput, wall_settings::WallShap !wall.nowall && error("No walls yet!") # DEBUG # Plasma–Plasma block - println("Calling BIEST with Nt=$nzeta, Np=$mtheta (total 3D points: $(num_gridpoints))...") + # println("Calling BIEST with Nt=$nzeta, Np=$mtheta (total 3D points: $(num_gridpoints))...") # compute_green_matrices!(green_temp, grad_green, plasma_surf.x, plasma_surf.z, plasma_surf.ν, nzeta) - compute_green_matrices!(green_temp, grad_green, plasma_surf3D) - # display(green_temp) - # display(grad_green) - green_BIEST = copy(green_temp) - grad_green_BIEST = copy(grad_green) + # compute_green_matrices!(green_temp, grad_green, plasma_surf3D) + # # display(green_temp) + # # display(grad_green) + # green_BIEST = copy(green_temp) + # grad_green_BIEST = copy(grad_green) # G = single-layer kernel, K = double-layer kernel - # Use Nt toroidal points to properly discretize the 3D surface (must be >= 6 for BIEST) - compute_3D_kernel_matrix!(grad_green, green_temp, plasma_surf3D, plasma_surf3D) + compute_3D_kernel_matrix!(grad_green, green_temp, plasma_surf3D, plasma_surf3D; INTERP_ORDER=6) # display(green_temp) # display(grad_green) # Compare BIEST and regular kernel matrices - println("\n=== Comparing BIEST vs Regular Kernel Matrices ===") - println("\nDifference single layer (regular - BIEST):") - display(green_temp - green_BIEST) - println("Max difference in green_temp: $(maximum(abs.(green_temp - green_BIEST)))") + # println("\n=== Comparing BIEST vs Regular Kernel Matrices ===") + # println("\nRelative difference single layer (regular - BIEST):") + # display((green_temp .- green_BIEST) ./ green_BIEST) + # println("Max relative difference in green_temp: $(maximum(abs.((green_temp - green_BIEST) ./ green_BIEST)))") - println("\nDifference double layer (regular - BIEST):") - display(grad_green - grad_green_BIEST) - println("Max difference in grad_green: $(maximum(abs.(grad_green - grad_green_BIEST)))") + # println("\nRelative difference double layer (regular - BIEST):") + # display((grad_green .- grad_green_BIEST) ./ grad_green_BIEST) + # println("Max relative difference in grad_green: $(maximum(abs.((grad_green - grad_green_BIEST) ./ grad_green_BIEST)))") # Sum Green's function matrices over toroidal direction to recover 2D poloidal slice # green_2D = zeros(ComplexF64, mtheta, mtheta) # gradgreen_2D = zeros(ComplexF64, mtheta, mtheta) - # for i in 1:mtheta + # dζ = 2π / nzeta + # # Perform Fourier integral over zeta to get 2D kernels G_2D = 1/2π ∫ G_3D e^{i n (ζ - ζ')} dζ' + # for ipol_obs in 1:mtheta + # itor_obs = 1 + # idx = (itor_obs - 1) * mtheta + ipol_obs # for j in 1:mtheta - # green_2D[i, j] = sum(green_3D[(k-1)*mtheta + i, (l-1)*mtheta + j] * exp(im * 2π * (k-l) / nzeta) for k in 1:nzeta, l in 1:nzeta) .* (1 / nzeta) - # gradgreen_2D[i, j] = sum(gradgreen_3D[(k-1)*mtheta + i, (l-1)*mtheta + j] * exp(im * 2π * (k-l) / nzeta) for k in 1:nzeta, l in 1:nzeta) .* (1 / nzeta) + # green_2D[idx, j] = 1/2π * sum(green_temp[idx, (l-1)*mtheta + j] * exp(im * n * (itor_obs - l) * dζ) for l in 1:nzeta) + # gradgreen_2D[idx, j] = 1/2π * sum(grad_green[idx, (l-1)*mtheta + j] * exp(im * n * (itor_obs - l) * dζ) for l in 1:nzeta) # end # end - # identity = Matrix{ComplexF64}(I, mtheta, mtheta) - # gradgreen_2D .+= identity .* 0.5 # Add identity*0.5 to double-layer kernel for jump condition - # println("Computes 2D single layer kernel matrix:") - # display(greenfunction_temp) - # println("Computes 2D double layer kernel matrix:") - # display(grad_greenfunction_mat) + # Test Green's function by applying to unit source: green_test[i] = ∫ G B dθ' ≈ Σⱼ Gᵢⱼ Bⱼ Δθ' + # B = ones(mtheta) + # green_test = ((4π .* green_2D)) * B # account for 2D green's function being 1/r (not 1/4πr) + # println("3D Green's integral with a unit source:") + # display(real.(green_test)) + # green_test = ((4π .* gradgreen_2D) + I) * B # account for 2D green's function being 1/r (not 1/4πr) + # println("3D Grad Green's integral with a unit source:") + # display(real.(green_test)) # println("Sum over zeta entries of green_3D (single-layer):") - # display(green_2D) + # display(real.(green_2D)) # println("Sum over zeta entries of gradgreen_3D (double-layer):") - # display(gradgreen_2D) + # display(real.(gradgreen_2D[1:mtheta, 1:mtheta])) + # display(real.(gradgreen_2D[mtheta+1:2*mtheta, 1:mtheta])) # confirmed that this is the same as the first mtheta rows + # identity = Matrix{ComplexF64}(I, mtheta, mtheta) + # gradgreen_2D[1:mtheta, 1:mtheta] .+= identity .* 0.5 # Add identity*0.5 to double-layer kernel for jump condition grad_green += I * 0.5 # Add 0.5I to double-layer kernel for jump condition # Fourier transform plasma-plasma block - fourier_transform!(green_fourier, green_temp, plasma_surf.cos_mn_basis3D, PLASMA_ROW_OFFSET, COS_COL_OFFSET) - fourier_transform!(green_fourier, green_temp, plasma_surf.sin_mn_basis3D, PLASMA_ROW_OFFSET, SIN_COL_OFFSET) + fourier_transform!(green_fourier, green_temp, plasma_surf3D.cos_mn_basis3D, PLASMA_ROW_OFFSET, COS_COL_OFFSET) + fourier_transform!(green_fourier, green_temp, plasma_surf3D.sin_mn_basis3D, PLASMA_ROW_OFFSET, SIN_COL_OFFSET) !wall.nowall && error("No walls yet!") @@ -418,7 +438,7 @@ function compute_vacuum_response_3D(inputs::VacuumInput, wall_settings::WallShap # Invert the vacuum response system of equations, eqs. 92-94ish of Chance 1997 (gelimb in Fortran) # If plasma only, lower blocks will be empty if wall.nowall - @views green_fourier[1:mtheta, :] .= grad_green[1:mtheta, 1:mtheta] \ green_fourier[1:mtheta, :] + @views green_fourier[1:num_gridpoints, :] .= grad_green[1:num_gridpoints, 1:num_gridpoints] \ green_fourier[1:num_gridpoints, :] else error("No walls yet!") green_fourier .= grad_green \ green_fourier @@ -426,10 +446,10 @@ function compute_vacuum_response_3D(inputs::VacuumInput, wall_settings::WallShap # Perform inverse Fourier transforms to get response matrix components (eq. 115-118 of Chance 2007) arr, aii, ari, air = ntuple(_ -> zeros(num_modes, num_modes), 4) - fourier_inverse_transform!(arr, green_fourier, plasma_surf.cos_mn_basis3D, PLASMA_ROW_OFFSET, COS_COL_OFFSET, 4π^2 / num_gridpoints) - fourier_inverse_transform!(aii, green_fourier, plasma_surf.sin_mn_basis3D, PLASMA_ROW_OFFSET, SIN_COL_OFFSET, 4π^2 / num_gridpoints) - fourier_inverse_transform!(ari, green_fourier, plasma_surf.sin_mn_basis3D, PLASMA_ROW_OFFSET, COS_COL_OFFSET, 4π^2 / num_gridpoints) - fourier_inverse_transform!(air, green_fourier, plasma_surf.cos_mn_basis3D, PLASMA_ROW_OFFSET, SIN_COL_OFFSET, 4π^2 / num_gridpoints) + fourier_inverse_transform!(arr, green_fourier, plasma_surf3D.cos_mn_basis3D, PLASMA_ROW_OFFSET, COS_COL_OFFSET, 4π^2 / num_gridpoints) + fourier_inverse_transform!(aii, green_fourier, plasma_surf3D.sin_mn_basis3D, PLASMA_ROW_OFFSET, SIN_COL_OFFSET, 4π^2 / num_gridpoints) + fourier_inverse_transform!(ari, green_fourier, plasma_surf3D.sin_mn_basis3D, PLASMA_ROW_OFFSET, COS_COL_OFFSET, 4π^2 / num_gridpoints) + fourier_inverse_transform!(air, green_fourier, plasma_surf3D.cos_mn_basis3D, PLASMA_ROW_OFFSET, SIN_COL_OFFSET, 4π^2 / num_gridpoints) # Final form of vacuum response matrix (eq. 114 of Chance 2007) wv = complex.(arr .+ aii, air .- ari) diff --git a/src/Vacuum/Vacuum3D.jl b/src/Vacuum/Vacuum3D.jl index bf5ed7b2..56bfacce 100644 --- a/src/Vacuum/Vacuum3D.jl +++ b/src/Vacuum/Vacuum3D.jl @@ -34,7 +34,7 @@ end const SINGULAR_QUAD_CACHE = Ref{Union{Nothing,SingularQuadratureData}}(nothing) """ - init_singular_quadrature(PATCH_RAD::Int, RAD_DIM::Int, INTERP_ORDER::Int=6) + init_singular_quadrature(PATCH_RAD::Int, RAD_DIM::Int, INTERP_ORDER::Int) Initialize quadrature points, weights, partition-of-unity functions, and interpolation matrices for singular correction. Follows BIEST's approach. @@ -47,13 +47,13 @@ Conversion references: - `PATCH_RAD::Int`: Number of points adjacent to source point to treat as singular - `RAD_DIM::Int`: Radial quadrature order - - `INTERP_ORDER::Int`: Lagrange interpolation order (default 6) + - `INTERP_ORDER::Int`: Lagrange interpolation order # Returns - `SingularQuadratureData`: Precomputed quadrature data """ -function init_singular_quadrature(PATCH_RAD::Int, RAD_DIM::Int, INTERP_ORDER::Int=6) +function init_singular_quadrature(PATCH_RAD::Int, RAD_DIM::Int, INTERP_ORDER::Int) # Total size of square patch extracted around singular point (odd number: 2*PATCH_DIM0+1) PATCH_DIM = 2 * PATCH_RAD + 1 @@ -62,10 +62,9 @@ function init_singular_quadrature(PATCH_RAD::Int, RAD_DIM::Int, INTERP_ORDER::In ANG_DIM = 2 * RAD_DIM # Setup radial quadrature (Gauss-Legendre transformed to [0,1]) - # FastGaussQuadrature.gausslegendre returns points on [-1,1], must transform to [0,1] - qx_raw, qw_raw = FastGaussQuadrature.gausslegendre(RAD_DIM) + qx_raw, qw_raw = FastGaussQuadrature.gausslegendre(RAD_DIM) # points on [-1,1] qx = (qx_raw .+ 1) ./ 2 # Map [-1, 1] to [0, 1] - qw = qw_raw ./ 2 # Adjust weights for interval change + qw = qw_raw ./ 2 # Adjust weights for interval change # Partition of unity function, exp(-36 * r^p) where p depends on PATCH_DIM pou_power = PATCH_DIM > 45 ? 10 : (PATCH_DIM > 20 ? 8 : 6) @@ -135,7 +134,7 @@ function init_singular_quadrature(PATCH_RAD::Int, RAD_DIM::Int, INTERP_ORDER::In end """ - get_singular_quadrature(PATCH_RAD::Int, RAD_DIM::Int) + get_singular_quadrature(PATCH_RAD::Int, RAD_DIM::Int, INTERP_ORDER::Int) Get cached singular quadrature data, initializing if necessary. @@ -143,9 +142,9 @@ Conversion references: - Follows caching pattern used around FieldPeriodBIOp setup in biest/boundary_integ_op.hpp """ -function get_singular_quadrature(PATCH_RAD::Int, RAD_DIM::Int) +function get_singular_quadrature(PATCH_RAD::Int, RAD_DIM::Int, INTERP_ORDER::Int) if isnothing(SINGULAR_QUAD_CACHE[]) - SINGULAR_QUAD_CACHE[] = init_singular_quadrature(PATCH_RAD, RAD_DIM) + SINGULAR_QUAD_CACHE[] = init_singular_quadrature(PATCH_RAD, RAD_DIM, INTERP_ORDER) end return SINGULAR_QUAD_CACHE[] end @@ -352,7 +351,8 @@ function compute_3D_kernel_matrix!( observer::PlasmaGeometry3D, source::PlasmaGeometry3D; PATCH_RAD::Int=3, - RAD_DIM::Int=15 + RAD_DIM::Int=15, + INTERP_ORDER::Int=6 ) # Zero out matrices @@ -360,28 +360,28 @@ function compute_3D_kernel_matrix!( fill!(greenfunction, 0.0) # Initialize quadrature data (cached) - quad_data = get_singular_quadrature(PATCH_RAD, RAD_DIM) + quad_data = get_singular_quadrature(PATCH_RAD, RAD_DIM, INTERP_ORDER) (; PATCH_DIM, PATCH_RAD, INTERP_ORDER, ANG_DIM, RAD_DIM, qw, Ppou, Gpou, M_G2P, I_G2P) = quad_data - @assert observer.ntheta ≥ PATCH_DIM + @assert observer.mtheta ≥ PATCH_DIM @assert observer.nzeta ≥ PATCH_DIM # Loop through observer points - for j_obs in 1:observer.nzeta, i_obs in 1:observer.ntheta - idx_obs = i_obs + (j_obs - 1) * observer.ntheta + for j_obs in 1:observer.nzeta, i_obs in 1:observer.mtheta + idx_obs = i_obs + (j_obs - 1) * observer.mtheta r_obs = observer.r[idx_obs, :] # ============================================ # FAR FIELD: Trapezoidal rule for nonsingular source points # Note: kernels return zero for r_src = r_obs # ============================================ - for j_src in 1:source.nzeta, i_src in 1:source.ntheta + for j_src in 1:source.nzeta, i_src in 1:source.mtheta # Evaluate kernels at grid points - idx_src = i_src + (j_src - 1) * source.ntheta + idx_src = i_src + (j_src - 1) * source.mtheta K_single = laplace_single_layer(r_obs, source.r[idx_src, :]) K_double = laplace_double_layer(r_obs, source.r[idx_src, :], source.normal[idx_src, :]) # Apply area element (periodic trapezoidal rule: w = dA = J∇ψdθdζ) - greenfunction[idx_obs, idx_src] = K_single * source.dA[idx_src] + greenfunction[idx_obs, idx_src] = K_single * (4π^2 / (observer.mtheta * observer.nzeta)) # * source.dA[idx_src] grad_greenfunction[idx_obs, idx_src] = K_double * source.dA[idx_src] end @@ -389,9 +389,9 @@ function compute_3D_kernel_matrix!( # NEAR FIELD: Polar quadrature with singular correction # ============================================ # Extract patches of source data around the singular point (size = PATCH_DIM x PATCH_DIM x dof) - r_patch = extract_patch(source.r, i_obs, j_obs, source.ntheta, source.nzeta, PATCH_DIM) - dr_dθ_patch = extract_patch(source.dr_dθ, i_obs, j_obs, source.ntheta, source.nzeta, PATCH_DIM) - dr_dζ_patch = extract_patch(source.dr_dζ, i_obs, j_obs, source.ntheta, source.nzeta, PATCH_DIM) + r_patch = extract_patch(source.r, i_obs, j_obs, source.mtheta, source.nzeta, PATCH_DIM) + dr_dθ_patch = extract_patch(source.dr_dθ, i_obs, j_obs, source.mtheta, source.nzeta, PATCH_DIM) + dr_dζ_patch = extract_patch(source.dr_dζ, i_obs, j_obs, source.mtheta, source.nzeta, PATCH_DIM) # Interpolate coordinates and tangent vectors to polar quadrature points r_polar = interpolate_to_polar(r_patch, quad_data) @@ -412,7 +412,7 @@ function compute_3D_kernel_matrix!( # Apply quadrature weights: area element × POU, where POU contains rdrdθ already wt = dA_polar[ir, ia] * Ppou[ir, ia] - M_polar_single[ir, ia] = K_single * wt + M_polar_single[ir, ia] = K_single * Ppou[ir, ia] * (4π^2 / (observer.mtheta * observer.nzeta)) M_polar_double[ir, ia] = K_double * wt end @@ -431,13 +431,13 @@ function compute_3D_kernel_matrix!( # Add far-field POU contribution (Gpou = -χ on grid) and near-field polar quadrature result for j in 1:PATCH_DIM, i in 1:PATCH_DIM # Map back to global indices - idx_pol = mod1(i_obs - PATCH_RAD + i - 1, source.ntheta) + idx_pol = mod1(i_obs - PATCH_RAD + i - 1, source.mtheta) idx_tor = mod1(j_obs - PATCH_RAD + j - 1, source.nzeta) - idx_src = idx_pol + source.ntheta * (idx_tor - 1) + idx_src = idx_pol + source.mtheta * (idx_tor - 1) # Remainder of far-field contribution on the singular grid: -χGᵢⱼdA r_src, n_src, dA_src = source.r[idx_src, :], source.normal[idx_src, :], source.dA[idx_src] - far_single = laplace_single_layer(r_obs, r_src) * dA_src * Gpou[i, j] + far_single = laplace_single_layer(r_obs, r_src) * Gpou[i, j] * (4π^2 / (observer.mtheta * observer.nzeta)) #* dA_src far_double = laplace_double_layer(r_obs, r_src, n_src) * dA_src * Gpou[i, j] # Apply far + near contributions diff --git a/src/Vacuum/VacuumStructs.jl b/src/Vacuum/VacuumStructs.jl index 8697b60a..d890aed2 100644 --- a/src/Vacuum/VacuumStructs.jl +++ b/src/Vacuum/VacuumStructs.jl @@ -55,8 +55,6 @@ struct PlasmaGeometry dz_dtheta::Vector{Float64} sin_mn_basis::Matrix{Float64} cos_mn_basis::Matrix{Float64} - sin_mn_basis3D::Matrix{Float64} - cos_mn_basis3D::Matrix{Float64} end """ @@ -169,23 +167,6 @@ function initialize_plasma_surface(inputs::VacuumInput) sin_mn_basis = sin.((mlow .+ (0:(mpert-1))') .* θ_grid .- n .* ν) cos_mn_basis = cos.((mlow .+ (0:(mpert-1))') .* θ_grid .- n .* ν) - # Precompute Fourier transform terms, sin(lθ - nν(θ) - nϕ) and cos(lθ - nν(θ) - nϕ) - sin_mn_basis3D = zeros(mtheta*nzeta, mpert*npert) - cos_mn_basis3D = zeros(mtheta*nzeta, mpert*npert) - ϕ_grid = range(; start=0, length=nzeta, step=2π/nzeta) - for idx_n in 1:npert - n = nlow + idx_n - 1 - for idx_m in 1:mpert - m = mlow + idx_m - 1 - for j in 1:nzeta - for i in 1:mtheta - cos_mn_basis3D[i+(j-1)*mtheta, idx_m+(idx_n-1)*mpert] = cos(m * θ_grid[i] - n * (ν[i] + ϕ_grid[j])) - sin_mn_basis3D[i+(j-1)*mtheta, idx_m+(idx_n-1)*mpert] = sin(m * θ_grid[i] - n * (ν[i] + ϕ_grid[j])) - end - end - end - end - return PlasmaGeometry( R, Z, @@ -193,9 +174,7 @@ function initialize_plasma_surface(inputs::VacuumInput) dx_dtheta, dz_dtheta, sin_mn_basis, - cos_mn_basis, - sin_mn_basis3D, - cos_mn_basis3D + cos_mn_basis ) end @@ -210,17 +189,19 @@ that the gradient/area elements are scaled by dθ and dζ. # Fields - - `ntheta::Int`: Number of poloidal grid points + - `mtheta::Int`: Number of poloidal grid points - `nzeta::Int`: Number of toroidal grid points - - `num_gridpoints::Int`: Total number of surface grid points (ntheta * nzeta) + - `num_gridpoints::Int`: Total number of surface grid points (mtheta * nzeta) - `r::Matrix{Float64}`: Surface points in Cartesian (X,Y,Z), shape (num_gridpoints, 3) - `dr_dθ::Matrix{Float64}`: Poloidal tangent vector ∂r/∂θ × dθ, shape (num_gridpoints, 3) - `dr_dζ::Matrix{Float64}`: Toroidal tangent vector ∂r/∂ζ × dζ, shape (num_gridpoints, 3) - `n::Matrix{Float64}`: Outward unit normal vectors, shape (num_gridpoints, 3) - `dA::Vector{Float64}`: Differential area elements |∂r/∂θ × ∂r/∂ζ| dθ dζ, length num_gridpoints + - `sin_mn_basis3D::Matrix{Float64}`: sin(mθ - nν - nϕ) basis functions at plasma surface + - `cos_mn_basis3D::Matrix{Float64}`: cos(mθ - nν - nϕ) basis functions at plasma surface """ @kwdef struct PlasmaGeometry3D - ntheta::Int + mtheta::Int nzeta::Int num_gridpoints::Int r::Matrix{Float64} @@ -228,6 +209,8 @@ that the gradient/area elements are scaled by dθ and dζ. dr_dζ::Matrix{Float64} normal::Matrix{Float64} dA::Vector{Float64} + sin_mn_basis3D::Matrix{Float64} + cos_mn_basis3D::Matrix{Float64} end """ @@ -251,15 +234,15 @@ Construct a 3D axisymmetric toroidal surface from a 2D poloidal contour. - `PlasmaGeometry3D`: Complete 3D surface description """ -function PlasmaGeometry3D(plasma_2d::PlasmaGeometry, nzeta::Int)::PlasmaGeometry3D +function PlasmaGeometry3D(plasma_2d::PlasmaGeometry, inputs::VacuumInput)::PlasmaGeometry3D # Extract 2D poloidal data - ntheta = length(plasma_2d.x) - num_gridpoints = ntheta * nzeta - (; R, Z, ν) = plasma_2d - dθ = 2π / ntheta + (; mtheta, npert, nlow, mlow, nzeta, mpert) = inputs + num_gridpoints = mtheta * nzeta + (; x, z, ν) = plasma_2d + dθ = 2π / mtheta dϕ = 2π / nzeta - θ_grid = range(; start=0, length=ntheta, step=dθ) + θ_grid = range(; start=0, length=mtheta, step=dθ) ϕ_grid = range(; start=0, length=nzeta, step=dϕ) # Allocate output arrays @@ -271,17 +254,17 @@ function PlasmaGeometry3D(plasma_2d::PlasmaGeometry, nzeta::Int)::PlasmaGeometry # Build 3D surface point-by-point from 2D contour for (i, θ) in enumerate(θ_grid), (j, ϕ) in enumerate(ϕ_grid) - idx = i + (j - 1) * ntheta - r[idx, :] .= [R[i] * cos(ϕ + ν[i]), R[i] * sin(ϕ + ν[i]), Z[i]] + idx = i + (j - 1) * mtheta + r[idx, :] .= [x[i] * cos(ϕ + ν[i]), x[i] * sin(ϕ + ν[i]), z[i]] end # Create splines for each Cartesian component (X, Y, Z) with periodic boundary conditions - r_grid = reshape(r, ntheta, nzeta, 3) + r_grid = reshape(r, mtheta, nzeta, 3) itps = [cubic_spline_interpolation((θ_grid, ϕ_grid), r_grid[:, :, k]; bc=Periodic(OnGrid())) for k in 1:3] # Compute tangent vectors, unit normals, and differential area elements via spline interpolation for (i, θ) in enumerate(θ_grid), (j, ϕ) in enumerate(ϕ_grid) - idx = i + (j - 1) * ntheta + idx = i + (j - 1) * mtheta dr_dθ[idx, :] .= [Interpolations.gradient(itps[k], θ, ϕ)[1] for k in 1:3] dr_dζ[idx, :] .= [Interpolations.gradient(itps[k], θ, ϕ)[2] for k in 1:3] cross_prod = cross(dr_dθ[idx, :], dr_dζ[idx, :]) @@ -294,15 +277,33 @@ function PlasmaGeometry3D(plasma_2d::PlasmaGeometry, nzeta::Int)::PlasmaGeometry dr_dθ .*= dθ dr_dζ .*= dϕ + # Precompute Fourier transform terms, sin(lθ - nν(θ) - nϕ) and cos(lθ - nν(θ) - nϕ) + sin_mn_basis3D = zeros(num_gridpoints, mpert*npert) + cos_mn_basis3D = zeros(num_gridpoints, mpert*npert) + for idx_n in 1:npert + n = nlow + idx_n - 1 + for idx_m in 1:mpert + m = mlow + idx_m - 1 + for j in 1:nzeta + for i in 1:mtheta + cos_mn_basis3D[i+(j-1)*mtheta, idx_m+(idx_n-1)*mpert] = cos(m * θ_grid[i] - n * (ν[i] + ϕ_grid[j])) + sin_mn_basis3D[i+(j-1)*mtheta, idx_m+(idx_n-1)*mpert] = sin(m * θ_grid[i] - n * (ν[i] + ϕ_grid[j])) + end + end + end + end + return PlasmaGeometry3D(; - ntheta, + mtheta, nzeta, num_gridpoints, r, dr_dθ, dr_dζ, normal, - dA + dA, + sin_mn_basis3D, + cos_mn_basis3D ) end From d53f1fc02fda0e7d9473636b57ee8781015c1109 Mon Sep 17 00:00:00 2001 From: Jake Halpern Date: Thu, 29 Jan 2026 08:47:46 -0500 Subject: [PATCH 16/31] VACUUM - WIP - removing nu from initialization --- src/DCON/Free.jl | 10 ++-------- src/Vacuum/Vacuum.jl | 12 ------------ src/Vacuum/VacuumStructs.jl | 2 +- 3 files changed, 3 insertions(+), 21 deletions(-) diff --git a/src/DCON/Free.jl b/src/DCON/Free.jl index 42f0e999..596f4e7e 100644 --- a/src/DCON/Free.jl +++ b/src/DCON/Free.jl @@ -43,17 +43,11 @@ function free_run!(odet::OdeState, ctrl::DconControl, equil::Equilibrium.PlasmaE println("3D Vacuum response matrix wv3D:") display(wv3D) - println("Difference between 2D and 3D vacuum response matrices:") - display(wv .- wv3D) - - println("Norm of difference between 2D and 3D vacuum response matrices:") - display(norm(wv .- wv3D)) - println("Maximum relative difference between 2D and 3D vacuum response matrices:") display(maximum(abs.(wv .- wv3D)) / maximum(abs.(wv))) - println("Difference in maximum eigenvalue:") - display(maximum(real.(eigvals(wv))) - maximum(real.(eigvals(wv3D)))) + println("Relative difference in maximum eigenvalue:") + display((maximum(real.(eigvals(wv))) - maximum(real.(eigvals(wv3D)))) / maximum(real.(eigvals(wv)))) error("Vacuum response matrix computation complete.") diff --git a/src/Vacuum/Vacuum.jl b/src/Vacuum/Vacuum.jl index 13b389e8..ab507ccb 100644 --- a/src/Vacuum/Vacuum.jl +++ b/src/Vacuum/Vacuum.jl @@ -247,18 +247,6 @@ function compute_vacuum_response(inputs::VacuumInput, wall_settings::WallShapeSe # Plasma–Plasma block kernel!(grad_green, green_temp, plasma_surf, plasma_surf, n) - # println("2D green_temp (single-layer) matrix:") - # display(green_temp ./2) - # println("2D grad_green (double-layer) matrix:") - # gg_temp = grad_green[1:mtheta, 1:mtheta] ./ 2 - 0.5 * I - # display(gg_temp) - B = ones(mtheta) - green_test = green_temp * B - println("2D Green's integral with a unit source:") - display(real.(green_test)) - green_test = (grad_green[1:mtheta, 1:mtheta]) * B # account for 2D green's function being 1/r (not 1/4πr) - println("2D Grad Green's integral with a unit source:") - display(real.(green_test)) # Fourier transform plasma-plasma block fourier_transform!(green_fourier, green_temp, plasma_surf.cos_mn_basis, PLASMA_ROW_OFFSET, COS_COL_OFFSET) diff --git a/src/Vacuum/VacuumStructs.jl b/src/Vacuum/VacuumStructs.jl index d890aed2..7627de29 100644 --- a/src/Vacuum/VacuumStructs.jl +++ b/src/Vacuum/VacuumStructs.jl @@ -255,7 +255,7 @@ function PlasmaGeometry3D(plasma_2d::PlasmaGeometry, inputs::VacuumInput)::Plasm # Build 3D surface point-by-point from 2D contour for (i, θ) in enumerate(θ_grid), (j, ϕ) in enumerate(ϕ_grid) idx = i + (j - 1) * mtheta - r[idx, :] .= [x[i] * cos(ϕ + ν[i]), x[i] * sin(ϕ + ν[i]), z[i]] + r[idx, :] .= [x[i] * cos(ϕ), x[i] * sin(ϕ), z[i]] end # Create splines for each Cartesian component (X, Y, Z) with periodic boundary conditions From 463e9c94156dd9754a9f4d769696da7b7bce9580 Mon Sep 17 00:00:00 2001 From: Jake Halpern Date: Thu, 29 Jan 2026 16:04:50 -0500 Subject: [PATCH 17/31] VACUUM - WIP - starting my massive cleanup journey, cleaning up multi-n capabilties in Free.jl --- src/DCON/DconStructs.jl | 81 ++------- src/DCON/Free.jl | 86 ++++----- src/Vacuum/Vacuum.jl | 169 ++++++----------- src/Vacuum/Vacuum3D.jl | 144 ++++++--------- src/Vacuum/VacuumInternals.jl | 83 ++++++++- src/Vacuum/VacuumStructs.jl | 329 ++++++++++++++++++++-------------- 6 files changed, 443 insertions(+), 449 deletions(-) diff --git a/src/DCON/DconStructs.jl b/src/DCON/DconStructs.jl index 3644c93c..d85e80af 100644 --- a/src/DCON/DconStructs.jl +++ b/src/DCON/DconStructs.jl @@ -257,7 +257,8 @@ Populated in `Free.jl`. ## Fields - `mthvac::Int` - Number of vacuum poloidal grid points (corresponds to `mtheta` in VacuumInput) - - `mpert::Int` - Number of poloidal modes + + - `nzvac::Int` - Number of vacuum toroidal grid points (corresponds to `nzeta` in VacuumInput) - `numpert_total::Int` - Total number of modes (mpert × npert) - `wt::Array{ComplexF64, 2}` - Toroidal vacuum response matrix (numpert_total × numpert_total) - `wt0::Array{ComplexF64, 2}` - Reference toroidal vacuum matrix (numpert_total × numpert_total) @@ -265,12 +266,16 @@ Populated in `Free.jl`. - `ep::Vector{ComplexF64}` - Plasma eigenvalues - `ev::Vector{ComplexF64}` - Vacuum eigenvalues - `et::Vector{ComplexF64}` - Total eigenvalues of plasma + vacuum - - `grri::Array{Float64, 2}` - Green's function radial integrals (2×mthvac × 2×mpert) - - `xzpts::Array{Float64, 2}` - Coordinate points [R_plasma, Z_plasma, R_wall, Z_wall] (mthvac × 4) + - `green_fourier::Array{Float64, 2}` - Fourier transformed Green's function (2 * mthvac * nzvac x 2 * numpert_total) + + + First mthvac*nzvac rows correspond to plasma, second to wall + + First numpert_total columns correspond to real cosine part, second to imaginary sine part + - `plasma_coords::Array{Float64, 2}` - Cartesian coordinate points of plasma surface (mthvac * nzvac x 3) + - `wall_coords::Array{Float64, 2}` - Cartesian coordinate points of wall surface (mthvac * nzvac x 3) """ @kwdef mutable struct VacuumData mthvac::Int - mpert::Int + nzvac::Int numpert_total::Int wt::Array{ComplexF64,2} = Array{ComplexF64}(undef, numpert_total, numpert_total) @@ -279,14 +284,12 @@ Populated in `Free.jl`. ep::Vector{ComplexF64} = Vector{ComplexF64}(undef, numpert_total) ev::Vector{ComplexF64} = Vector{ComplexF64}(undef, numpert_total) et::Vector{ComplexF64} = Vector{ComplexF64}(undef, numpert_total) - - # VACUUM can't handle 3D yet, so these are temporary mpert arrays - # TODO: Matt separated grri into a few arrays for IPEC, will need to do that later - grri::Array{Float64,2} = Array{Float64}(undef, 2 * mthvac, 2 * mpert) - xzpts::Array{Float64,2} = Array{Float64}(undef, mthvac, 4) + green_fourier::Array{Float64,2} = Array{Float64}(undef, 2 * mthvac * nzvac, 2 * numpert_total) + plasma_coords::Array{Float64,2} = Array{Float64}(undef, mthvac * nzvac, 3) + wall_coords::Array{Float64,2} = Array{Float64}(undef, mthvac * nzvac, 3) end -VacuumData(mthvac::Int, mpert::Int, numpert_total::Int) = VacuumData(; mthvac, mpert, numpert_total) +VacuumData(mthvac::Int, nzvac::Int, numpert_total::Int) = VacuumData(; mthvac, nzvac, numpert_total) """ OdeState @@ -403,61 +406,3 @@ end # Initialize function for OdeState with relevant parameters for array initialization OdeState(numpert_total::Int, numsteps_init::Int, numunorms_init::Int, msing::Int) = OdeState(; numpert_total, numsteps_init, numunorms_init, msing) - - -# Below here are debug output structs used for benchmarking and unit testing - - - - -""" - VacuumBenchmarkInputs - -A struct to hold all inputs required for vacuum benchmarking between Fortran and Julia implementations. - -## Fields - - - `wv_block::Matrix{ComplexF64}` - Vacuum response matrix block - - `mpert::Int` - Number of poloidal modes - - `mtheta_eq::Int` - Number of poloidal grid points in input equilibrium (corresponds to `mtheta_eq` in VacuumInput) - - `mthvac::Int` - Number of poloidal grid points in vacuum calculations (corresponds to `mtheta` in VacuumInput) - - `complex_flag::Bool` - Flag indicating if complex arithmetic is used - - `kernelsign::Float64` - Sign of the kernel for vacuum calculation - - `wall_flag::Bool` - Flag indicating presence of wall - - `farwall_flag::Bool` - Flag indicating presence of far wall - - `grri::Matrix{Float64}` - Green's function response matrix - - `xzpts::Matrix{Float64}` - Coordinate points on plasma boundary [R, Z] - - `ahg_file::String` - Filename for AHG data - - `dir_path::String` - Directory path for input/output files - - `vac_inputs::Vacuum.VacuumInput` - VacuumInput struct for Julia vacuum code - - `wall_settings::Vacuum.WallShapeSettings` - Wall shape settings - - `n::Int` - Toroidal mode number - - `ipert_n::Int` - Index of perturbed toroidal mode - - `psifac::Float64` - Normalized flux coordinate -""" -@kwdef struct VacuumBenchmarkInputs - # Vacuum computation parameters - wv_block::Matrix{ComplexF64} - mpert::Int - mtheta_eq::Int - mthvac::Int - complex_flag::Bool - kernelsign::Float64 - wall_flag::Bool - farwall_flag::Bool - grri::Matrix{Float64} - xzpts::Matrix{Float64} - ahg_file::String - dir_path::String - - # VacuumInput struct for Julia code - vac_inputs::Vacuum.VacuumInput - - # Wall settings - wall_settings::Vacuum.WallShapeSettings - - # Additional context - n::Int - ipert_n::Int - psifac::Float64 -end diff --git a/src/DCON/Free.jl b/src/DCON/Free.jl index 596f4e7e..83c3b671 100644 --- a/src/DCON/Free.jl +++ b/src/DCON/Free.jl @@ -10,7 +10,7 @@ and data dumping. function free_run!(odet::OdeState, ctrl::DconControl, equil::Equilibrium.PlasmaEquilibrium, ffit::FourFitVars, intr::DconInternal) # Initializations and allocations - vac = VacuumData(ctrl.mthvac, intr.mpert, intr.numpert_total) + vac = VacuumData(ctrl.mthvac, ctrl.nzvac, intr.numpert_total) etemp = zeros(ComplexF64, intr.numpert_total) wp = zeros(ComplexF64, intr.numpert_total, intr.numpert_total) wpt = zeros(ComplexF64, intr.numpert_total, intr.numpert_total) @@ -29,40 +29,10 @@ function free_run!(odet::OdeState, ctrl::DconControl, equil::Equilibrium.PlasmaE # Set VACUUM run parameters and boundary shape n = ipert_n - 1 + intr.nlow vac_inputs = compute_vacuum_inputs(intr.psilim, n, ctrl, equil, intr) - fill!(vac.grri, 0.0) - fill!(vac.xzpts, 0.0) - - wv3D, grri3D, xzpts3D = Vacuum.compute_vacuum_response_3D(vac_inputs, intr.wall_settings) + fill!(vac.green_fourier, 0.0) # Compute block of vacuum energy matrix for one toroidal mode number - wv, vac.grri, vac.xzpts = Vacuum.compute_vacuum_response(vac_inputs, intr.wall_settings) - - println("2D Vacuum response matrix wv:") - display(wv) - - println("3D Vacuum response matrix wv3D:") - display(wv3D) - - println("Maximum relative difference between 2D and 3D vacuum response matrices:") - display(maximum(abs.(wv .- wv3D)) / maximum(abs.(wv))) - - println("Relative difference in maximum eigenvalue:") - display((maximum(real.(eigvals(wv))) - maximum(real.(eigvals(wv3D)))) / maximum(real.(eigvals(wv)))) - - error("Vacuum response matrix computation complete.") - - # Output data for unit testing and benchmarking - if true #intr.debug_settings.output_benchmark_data - farwall_flag = intr.wall_settings.shape == "nowall" ? true : false - benchmark_inputs = VacuumBenchmarkInputs( - wv_block, intr.mpert, equil.config.control.mtheta, ctrl.mthvac, - true, vac_inputs.kernelsign, false, - farwall_flag, vac.grri, vac.xzpts, "ahg2msc_dcon.out", intr.dir_path, - vac_inputs, intr.wall_settings, - n, ipert_n, intr.psilim - ) - @save intr.dir_path*"/benchmark_inputs.jld2" benchmark_inputs - end + wv_block, vac.green_fourier, vac.plasma_coords, vac.wall_coords = Vacuum.compute_vacuum_response(vac_inputs, intr.wall_settings) # Equation 126 in Chance 1997 - scale by (m - n*q)(m' - n*q) singfac = collect(intr.mlow:intr.mhigh) .- (n * intr.qlim) @@ -75,6 +45,39 @@ function free_run!(odet::OdeState, ctrl::DconControl, equil::Equilibrium.PlasmaE @views vac.wv[((ipert_n-1)*intr.mpert+1):(ipert_n*intr.mpert), ((ipert_n-1)*intr.mpert+1):(ipert_n*intr.mpert)] .= wv_block end + if ctrl.nzvac > 1 + if ctrl.verbose + println("Computing 3D vacuum response matrix in addition to 2D matrix with nzvac = $(ctrl.nzvac)") + end + + # Compute 3D vacuum response matrix + vac_inputs = compute_vacuum_inputs(intr.psilim, 1, ctrl, equil, intr) + vac_inputs_3D = Vacuum.VacuumInput3D(vac_inputs, ctrl.nzvac, intr.nlow, intr.npert) + wv3D, _, _, _ = Vacuum.compute_vacuum_response_3D(vac_inputs_3D, intr.wall_settings) + + # Scale by (m - n*q)(m' - n'*q) + singfac = vec((intr.mlow:intr.mhigh) .- intr.qlim .* (intr.nlow:intr.nhigh)') + @inbounds for ipert in 1:intr.numpert_total + @views wv3D[ipert, :] .*= singfac[ipert] + @views wv3D[:, ipert] .*= singfac[ipert] + end + + # DEBUG + println("2D Vacuum response matrix wv:") + display(vac.wv) + println("3D Vacuum response matrix wv3D:") + display(wv3D) + println("Maximum relative difference between 2D and 3D vacuum response matrices:") + display(maximum(abs.(vac.wv .- wv3D)) / maximum(abs.(vac.wv))) + println("Maximum difference between 2D and 3D vacuum response matrices:") + display(maximum(abs.(vac.wv .- wv3D))) + println("Relative difference in maximum eigenvalue:") + display((maximum(real.(eigvals(vac.wv))) - maximum(real.(eigvals(wv3D)))) / maximum(real.(eigvals(vac.wv)))) + println("Difference in maximum eigenvalue:") + display((maximum(real.(eigvals(vac.wv))) - maximum(real.(eigvals(wv3D))))) + error("Vacuum response matrix computation complete.") + end + # Compute complex energy eigenvalues and vectors vac.wt .= wp .+ vac.wv vac.wt0 .= vac.wt @@ -111,7 +114,6 @@ function free_run!(odet::OdeState, ctrl::DconControl, equil::Equilibrium.PlasmaE end # Compute plasma and vacuum contributions. - # wpt = wt' * wp * wt ; wvt = wt' * wv * wt wpt .= adjoint(vac.wt) * (wp * vac.wt) wvt .= adjoint(vac.wt) * (vac.wv * vac.wt) for ipert in 1:intr.numpert_total @@ -158,12 +160,12 @@ the r, z, and ν values at the plasma boundary, as well as mode numbers and numb function compute_vacuum_inputs(ψ::Float64, n::Int, ctrl::DconControl, equil::Equilibrium.PlasmaEquilibrium, intr::DconInternal) # Allocations - mtheta = equil.config.control.mtheta - θ_SFL = zeros(Float64, mtheta + 1) - R = zeros(Float64, mtheta + 1) - Z = zeros(Float64, mtheta + 1) - ν = zeros(Float64, mtheta + 1) - r_minor = zeros(Float64, mtheta + 1) + mtheta = equil.config.control.mtheta + 1 + θ_SFL = zeros(Float64, mtheta) + R = zeros(Float64, mtheta) + Z = zeros(Float64, mtheta) + ν = zeros(Float64, mtheta) + r_minor = zeros(Float64, mtheta) # Compute geometric quantities on plasma boundary for (i, θ) in enumerate(equil.rzphi.ys) @@ -172,6 +174,7 @@ function compute_vacuum_inputs(ψ::Float64, n::Int, ctrl::DconControl, equil::Eq θ_SFL[i] = 2π * (θ + f[2]) # f[2] = λ(ψ, θ) / 2π ν[i] = f[3] end + # Compute R and Z on straight-fieldline θ grid R .= equil.ro .+ r_minor .* cos.(θ_SFL) Z .= equil.zo .+ r_minor .* sin.(θ_SFL) @@ -190,11 +193,8 @@ function compute_vacuum_inputs(ψ::Float64, n::Int, ctrl::DconControl, equil::Eq ν=reverse(ν), mlow=intr.mlow, mpert=intr.mpert, - nlow=intr.nlow, - npert=intr.npert, n=n, mtheta=ctrl.mthvac, - nzeta=ctrl.nzvac, force_wv_symmetry=ctrl.force_wv_symmetry ) end diff --git a/src/Vacuum/Vacuum.jl b/src/Vacuum/Vacuum.jl index ab507ccb..4928549d 100644 --- a/src/Vacuum/Vacuum.jl +++ b/src/Vacuum/Vacuum.jl @@ -208,7 +208,7 @@ end Compute the vacuum response matrix using provided vacuum inputs. This is the pure Julia implementation that replaces the Fortran `mscvac` function. -It returns the relevant arrays: `wv`, `grri`, and `xzpts`. +It returns the relevant arrays: `wv`, `green_fourier`, `plasma_coords`, and `wall_coords`. # Arguments @@ -219,21 +219,16 @@ It returns the relevant arrays: `wv`, `grri`, and `xzpts`. # Returns - `wv`: Complex vacuum response matrix (mpert × mpert) relating plasma perturbations to vacuum response - - `grri`: Green's function response matrix (2*mtheta × 2*mpert) in Fourier space - - `xzpts`: Coordinate array (mtheta × 4) containing [R_plasma, Z_plasma, R_wall, Z_wall] - -# Notes - - - This function initializes the plasma surface and wall geometry internally - - The vacuum response includes plasma-plasma and plasma-wall coupling effects - - For n=0 modes with closed walls, a regularization factor is added to prevent singularities + - `green_fourier`: Green's function response matrix (2 * mtheta × 2 * mpert) in Fourier space + - `plasma_coords`: Cartesian coordinate array (mtheta × 3) containing [R_plasma, 0, Z_plasma] + - `wall_coords`: Cartesian coordinate array (mtheta × 3) containing [R_wall, 0, Z_wall] """ function compute_vacuum_response(inputs::VacuumInput, wall_settings::WallShapeSettings) # Initialization and allocations (; mtheta, mpert, n, kernelsign, force_wv_symmetry) = inputs - plasma_surf = initialize_plasma_surface(inputs) - wall = initialize_wall(inputs, plasma_surf, wall_settings) + plasma_surf = PlasmaGeometry(inputs) + wall = WallGeometry(inputs, plasma_surf, wall_settings) grad_green = zeros(2 * mtheta, 2 * mtheta) green_temp = zeros(mtheta, mtheta) @@ -285,8 +280,8 @@ function compute_vacuum_response(inputs::VacuumInput, wall_settings::WallShapeSe end end - # Invert the vacuum response system of equations, eqs. 92-94ish of Chance 1997 (gelimb in Fortran) - # If plasma only, lower blocks will be empty + # Invert the vacuum response system of equations, eqs. 112 of Chance 1997 (gelimb in Fortran) + # If plasma only, lower blocks are zero if wall.nowall @views green_fourier[1:mtheta, :] .= grad_green[1:mtheta, 1:mtheta] \ green_fourier[1:mtheta, :] else @@ -296,56 +291,62 @@ function compute_vacuum_response(inputs::VacuumInput, wall_settings::WallShapeSe # There's some logic that computes xpass/zpass and chiwc/chiws here, might eventually be needed? # Perform inverse Fourier transforms to get response matrix components (eq. 115-118 of Chance 2007) + dθ = 2π / mtheta arr, aii, ari, air = ntuple(_ -> zeros(mpert, mpert), 4) - fourier_inverse_transform!(arr, green_fourier, plasma_surf.cos_mn_basis, PLASMA_ROW_OFFSET, COS_COL_OFFSET, 4π^2 / mtheta) - fourier_inverse_transform!(aii, green_fourier, plasma_surf.sin_mn_basis, PLASMA_ROW_OFFSET, SIN_COL_OFFSET, 4π^2 / mtheta) - fourier_inverse_transform!(ari, green_fourier, plasma_surf.sin_mn_basis, PLASMA_ROW_OFFSET, COS_COL_OFFSET, 4π^2 / mtheta) - fourier_inverse_transform!(air, green_fourier, plasma_surf.cos_mn_basis, PLASMA_ROW_OFFSET, SIN_COL_OFFSET, 4π^2 / mtheta) + fourier_inverse_transform!(arr, green_fourier, plasma_surf.cos_mn_basis, PLASMA_ROW_OFFSET, COS_COL_OFFSET, 2π * dθ) + fourier_inverse_transform!(aii, green_fourier, plasma_surf.sin_mn_basis, PLASMA_ROW_OFFSET, SIN_COL_OFFSET, 2π * dθ) + fourier_inverse_transform!(ari, green_fourier, plasma_surf.sin_mn_basis, PLASMA_ROW_OFFSET, COS_COL_OFFSET, 2π * dθ) + fourier_inverse_transform!(air, green_fourier, plasma_surf.cos_mn_basis, PLASMA_ROW_OFFSET, SIN_COL_OFFSET, 2π * dθ) # Final form of vacuum response matrix (eq. 114 of Chance 2007) wv = complex.(arr .+ aii, air .- ari) # Force symmetry of response matrix if desired force_wv_symmetry && hermitianpart!(wv) - # Create xzpts array - xzpts = zeros(inputs.mtheta, 4) - @views xzpts[:, 1] .= plasma_surf.x - @views xzpts[:, 2] .= plasma_surf.z - @views xzpts[:, 3] .= wall.x - @views xzpts[:, 4] .= wall.z - return wv, green_fourier, xzpts + # Create plasma_coords and wall_coords arrays + plasma_coords = zeros(inputs.mtheta, 3) + wall_coords = zeros(inputs.mtheta, 3) + @views plasma_coords[:, 1] .= plasma_surf.x + @views plasma_coords[:, 2] .= 0.0 + @views plasma_coords[:, 3] .= plasma_surf.z + @views wall_coords[:, 1] .= wall.x + @views wall_coords[:, 2] .= 0.0 + @views wall_coords[:, 3] .= wall.z + return wv, green_fourier, plasma_coords, wall_coords end -function compute_vacuum_response_3D(inputs::VacuumInput, wall_settings::WallShapeSettings) +""" + compute_vacuum_response_3D(inputs::VacuumInput3D, wall_settings::WallShapeSettings) + +Compute the vacuum response matrix via the 3D approach using provided vacuum inputs. +It returns the relevant arrays: `wv`, `green_fourier`, `plasma_coords`, and `wall_coords`. + +# Arguments + + - `inputs::VacuumInput3D`: Struct containing vacuum calculation parameters including mode numbers, + grid resolution, toroidal mode numbers, and plasma boundary information. + - `wall_settings::WallShapeSettings`: Struct specifying the wall geometry configuration. + +# Returns + + - `wv`: Complex vacuum response matrix (mpert * npert × mpert * npert) relating plasma perturbations to vacuum response + - `green_fourier`: Green's function response matrix (2 * mtheta * nzeta × 2 * mpert * npert) in Fourier space + - `plasma_coords`: Cartesian coordinate array (mtheta * nzeta × 3) of the plasma surface + - `wall_coords`: Cartesian coordinate array (mtheta * nzeta × 3) of the wall +""" +function compute_vacuum_response_3D(inputs::VacuumInput3D, wall_settings::WallShapeSettings) # Initialization and allocations - (; mtheta, mpert, n, kernelsign, force_wv_symmetry, nzeta, npert) = inputs + (; mtheta, mpert, n, force_wv_symmetry, kernelsign, nzeta, npert) = inputs num_gridpoints = nzeta * mtheta num_modes = npert * mpert - plasma_surf = initialize_plasma_surface(inputs) - wall = initialize_wall(inputs, plasma_surf, wall_settings) + # TODO: Currently only supports axisymmetric surfaces + plasma_surf = PlasmaGeometry3D(inputs) + wall = WallGeometry3D(inputs, plasma_surf, wall_settings) grad_green = zeros(num_gridpoints, num_gridpoints) # for walls, this is 2*mtheta x 2*mtheta green_temp = zeros(num_gridpoints, num_gridpoints) - plasma_surf3D = PlasmaGeometry3D(plasma_surf, inputs) - - if false # dA debugging - a = 0.1 - R0 = 10 - ϕ_grid = range(; start=0, length=nzeta, step=2π/nzeta) - θ_grid = range(; start=0, length=mtheta, step=2π/mtheta) - analytic_dA = [a * (R0 + a * cos(θ)) * (4π^2 / mtheta / nzeta) for θ in θ_grid, ϕ in ϕ_grid] - println("Computed 3D plasma surface differential area elements dA: $(sum(plasma_surf3D.dA))") - println("Sum of analytic dA: $(sum(analytic_dA))") # DEBUG - println("Analytic = $(4π^2 * a * R0)") # DEBUG - println("Difference in analytic and computed dA:") # DEBUG - display(analytic_dA - reshape(plasma_surf3D.dA, size(analytic_dA))) # DEBUG - println("Max difference in dA: $(maximum(abs.(analytic_dA - reshape(plasma_surf3D.dA, size(analytic_dA)))))") # DEBUG - println("Min difference in dA: $(minimum(abs.(analytic_dA - reshape(plasma_surf3D.dA, size(analytic_dA)))))") # DEBUG - error("Debugging dA") # DEBUG - end - # 𝒢ₗ(θⱼ) from Chance eq. 106-108. first num_gridpoints rows are plasma as observer, second are wall # First num_modes columns are real (cosine), second num_modes are imaginary (sine) green_fourier = zeros(num_gridpoints, 2 * num_modes) @@ -357,63 +358,12 @@ function compute_vacuum_response_3D(inputs::VacuumInput, wall_settings::WallShap !wall.nowall && error("No walls yet!") # DEBUG # Plasma–Plasma block - # println("Calling BIEST with Nt=$nzeta, Np=$mtheta (total 3D points: $(num_gridpoints))...") - # compute_green_matrices!(green_temp, grad_green, plasma_surf.x, plasma_surf.z, plasma_surf.ν, nzeta) - # compute_green_matrices!(green_temp, grad_green, plasma_surf3D) - # # display(green_temp) - # # display(grad_green) - # green_BIEST = copy(green_temp) - # grad_green_BIEST = copy(grad_green) - - # G = single-layer kernel, K = double-layer kernel - compute_3D_kernel_matrix!(grad_green, green_temp, plasma_surf3D, plasma_surf3D; INTERP_ORDER=6) - # display(green_temp) - # display(grad_green) - - # Compare BIEST and regular kernel matrices - # println("\n=== Comparing BIEST vs Regular Kernel Matrices ===") - # println("\nRelative difference single layer (regular - BIEST):") - # display((green_temp .- green_BIEST) ./ green_BIEST) - # println("Max relative difference in green_temp: $(maximum(abs.((green_temp - green_BIEST) ./ green_BIEST)))") - - # println("\nRelative difference double layer (regular - BIEST):") - # display((grad_green .- grad_green_BIEST) ./ grad_green_BIEST) - # println("Max relative difference in grad_green: $(maximum(abs.((grad_green - grad_green_BIEST) ./ grad_green_BIEST)))") - - # Sum Green's function matrices over toroidal direction to recover 2D poloidal slice - # green_2D = zeros(ComplexF64, mtheta, mtheta) - # gradgreen_2D = zeros(ComplexF64, mtheta, mtheta) - # dζ = 2π / nzeta - # # Perform Fourier integral over zeta to get 2D kernels G_2D = 1/2π ∫ G_3D e^{i n (ζ - ζ')} dζ' - # for ipol_obs in 1:mtheta - # itor_obs = 1 - # idx = (itor_obs - 1) * mtheta + ipol_obs - # for j in 1:mtheta - # green_2D[idx, j] = 1/2π * sum(green_temp[idx, (l-1)*mtheta + j] * exp(im * n * (itor_obs - l) * dζ) for l in 1:nzeta) - # gradgreen_2D[idx, j] = 1/2π * sum(grad_green[idx, (l-1)*mtheta + j] * exp(im * n * (itor_obs - l) * dζ) for l in 1:nzeta) - # end - # end - # Test Green's function by applying to unit source: green_test[i] = ∫ G B dθ' ≈ Σⱼ Gᵢⱼ Bⱼ Δθ' - # B = ones(mtheta) - # green_test = ((4π .* green_2D)) * B # account for 2D green's function being 1/r (not 1/4πr) - # println("3D Green's integral with a unit source:") - # display(real.(green_test)) - # green_test = ((4π .* gradgreen_2D) + I) * B # account for 2D green's function being 1/r (not 1/4πr) - # println("3D Grad Green's integral with a unit source:") - # display(real.(green_test)) - # println("Sum over zeta entries of green_3D (single-layer):") - # display(real.(green_2D)) - # println("Sum over zeta entries of gradgreen_3D (double-layer):") - # display(real.(gradgreen_2D[1:mtheta, 1:mtheta])) - # display(real.(gradgreen_2D[mtheta+1:2*mtheta, 1:mtheta])) # confirmed that this is the same as the first mtheta rows - # identity = Matrix{ComplexF64}(I, mtheta, mtheta) - # gradgreen_2D[1:mtheta, 1:mtheta] .+= identity .* 0.5 # Add identity*0.5 to double-layer kernel for jump condition - - grad_green += I * 0.5 # Add 0.5I to double-layer kernel for jump condition + compute_3D_kernel_matrix!(grad_green, green_temp, plasma_surf, plasma_surf; INTERP_ORDER=6) + grad_green += I * 0.5 # Fourier transform plasma-plasma block - fourier_transform!(green_fourier, green_temp, plasma_surf3D.cos_mn_basis3D, PLASMA_ROW_OFFSET, COS_COL_OFFSET) - fourier_transform!(green_fourier, green_temp, plasma_surf3D.sin_mn_basis3D, PLASMA_ROW_OFFSET, SIN_COL_OFFSET) + fourier_transform!(green_fourier, green_temp, plasma_surf.cos_mn_basis3D, PLASMA_ROW_OFFSET, COS_COL_OFFSET) + fourier_transform!(green_fourier, green_temp, plasma_surf.sin_mn_basis3D, PLASMA_ROW_OFFSET, SIN_COL_OFFSET) !wall.nowall && error("No walls yet!") @@ -433,24 +383,19 @@ function compute_vacuum_response_3D(inputs::VacuumInput, wall_settings::WallShap end # Perform inverse Fourier transforms to get response matrix components (eq. 115-118 of Chance 2007) + dθdζ = (2π / mtheta) * (2π / nzeta) arr, aii, ari, air = ntuple(_ -> zeros(num_modes, num_modes), 4) - fourier_inverse_transform!(arr, green_fourier, plasma_surf3D.cos_mn_basis3D, PLASMA_ROW_OFFSET, COS_COL_OFFSET, 4π^2 / num_gridpoints) - fourier_inverse_transform!(aii, green_fourier, plasma_surf3D.sin_mn_basis3D, PLASMA_ROW_OFFSET, SIN_COL_OFFSET, 4π^2 / num_gridpoints) - fourier_inverse_transform!(ari, green_fourier, plasma_surf3D.sin_mn_basis3D, PLASMA_ROW_OFFSET, COS_COL_OFFSET, 4π^2 / num_gridpoints) - fourier_inverse_transform!(air, green_fourier, plasma_surf3D.cos_mn_basis3D, PLASMA_ROW_OFFSET, SIN_COL_OFFSET, 4π^2 / num_gridpoints) + fourier_inverse_transform!(arr, green_fourier, plasma_surf.cos_mn_basis3D, PLASMA_ROW_OFFSET, COS_COL_OFFSET, dθdζ) + fourier_inverse_transform!(aii, green_fourier, plasma_surf.sin_mn_basis3D, PLASMA_ROW_OFFSET, SIN_COL_OFFSET, dθdζ) + fourier_inverse_transform!(ari, green_fourier, plasma_surf.sin_mn_basis3D, PLASMA_ROW_OFFSET, COS_COL_OFFSET, dθdζ) + fourier_inverse_transform!(air, green_fourier, plasma_surf.cos_mn_basis3D, PLASMA_ROW_OFFSET, SIN_COL_OFFSET, dθdζ) # Final form of vacuum response matrix (eq. 114 of Chance 2007) wv = complex.(arr .+ aii, air .- ari) # Force symmetry of response matrix if desired force_wv_symmetry && hermitianpart!(wv) - # Create xzpts array - xzpts = zeros(inputs.mtheta, 4) - @views xzpts[:, 1] .= plasma_surf.x - @views xzpts[:, 2] .= plasma_surf.z - @views xzpts[:, 3] .= wall.x - @views xzpts[:, 4] .= wall.z - return wv, green_fourier, xzpts + return wv, green_fourier, plasma_surf.r, wall.r end """ diff --git a/src/Vacuum/Vacuum3D.jl b/src/Vacuum/Vacuum3D.jl index 56bfacce..72635192 100644 --- a/src/Vacuum/Vacuum3D.jl +++ b/src/Vacuum/Vacuum3D.jl @@ -79,11 +79,11 @@ function init_singular_quadrature(PATCH_RAD::Int, RAD_DIM::Int, INTERP_ORDER::In # Partition of Unity on polar grid including transformation Jacobian - Ppou = χ(ρ) M²/4 r dr dt, eq. 38 in Malhotra 2019 Ppou = zeros(RAD_DIM, ANG_DIM) - dt = 2π / ANG_DIM + dθ = 2π / ANG_DIM for j in 1:ANG_DIM, i in 1:RAD_DIM dr = qw[i] * PATCH_RAD - rdt = qx[i] * PATCH_RAD * dt; - Ppou[i, j] = pou(qx[i]) * dr * rdt + rdθ = qx[i] * PATCH_RAD * dθ + Ppou[i, j] = pou(qx[i]) * dr * rdθ end # Spacing between Lagrange interpolation nodes in [0,1] for INTERP_ORDER-point stencil @@ -106,14 +106,12 @@ function init_singular_quadrature(PATCH_RAD::Int, RAD_DIM::Int, INTERP_ORDER::In # Build Lagrange interpolation matrix from Cartesian grid to polar (G2P) points M_G2P = zeros(RAD_DIM, ANG_DIM, INTERP_ORDER, INTERP_ORDER) I_G2P = zeros(Int, RAD_DIM, ANG_DIM, 2) # y0,y1 indices of lower-left corner of stencil in PATCH_DIM × PATCH_DIM grid - Δθ = 2π / ANG_DIM for ir in 1:RAD_DIM, ia in 1:ANG_DIM # Map polar node to unit square: x0, x1 ∈ [0,1] × [0,1] - x0 = 0.5 + 0.5 * qx[ir] * cos(Δθ * (ia - 1)) - x1 = 0.5 + 0.5 * qx[ir] * sin(Δθ * (ia - 1)) + x0 = 0.5 + 0.5 * qx[ir] * cos(dθ * (ia - 1)) + x1 = 0.5 + 0.5 * qx[ir] * sin(dθ * (ia - 1)) # Lower-left corner indices of INTERP_ORDER × INTERP_ORDER stencil centered on (x0,x1) - # C++: (Integer)(x0 * (PATCH_DIM - 1) - (INTERP_ORDER - 1) / 2) -- truncate AFTER subtraction y0 = clamp(trunc(Int, x0 * (PATCH_DIM - 1) - (INTERP_ORDER - 1) ÷ 2), 0, PATCH_DIM - INTERP_ORDER) y1 = clamp(trunc(Int, x1 * (PATCH_DIM - 1) - (INTERP_ORDER - 1) ÷ 2), 0, PATCH_DIM - INTERP_ORDER) @@ -152,7 +150,8 @@ end """ laplace_single_layer(x_obs::Vector{Float64}, x_src::Vector{Float64}) -> Float64 -Evaluate the Laplace single-layer (FxU) kernel between two 3D points. +Evaluate the Laplace single-layer (FxU) kernel between two 3D points. Returns +0.0 if the observation point coincides with the source point to avoid singularity. The single-layer kernel φ is the fundamental solution to Laplace's equation: @@ -170,17 +169,16 @@ The single-layer kernel φ is the fundamental solution to Laplace's equation: - `Float64`: Kernel value φ(x_obs, x_src) """ function laplace_single_layer(x_obs::Vector{Float64}, x_src::Vector{Float64})::Float64 - # Single-layer kernel: 1/(4π r) r = norm(x_obs - x_src) - r < 1e-30 && return 0.0 - return 1.0 / (4π * r) + return r < 1e-30 ? 0.0 : 1.0 / (4π * r) end """ laplace_double_layer(x_obs::Vector{Float64}, x_src::Vector{Float64}, n_src::Vector{Float64}) -> Float64 -Evaluate the Laplace double-layer (DxU) kernel between a point and a surface element. +Evaluate the Laplace double-layer (DxU) kernel between a point and a surface element. Returns +0.0 if the observation point coincides with the source point to avoid singularity. The double-layer kernel K is the normal derivative of the fundamental solution: @@ -200,11 +198,9 @@ K(x_obs, x_src, n_src) = ∇_{x_src} φ · n̂_src - `Float64`: Kernel value K(x_obs, x_src, n_src) """ function laplace_double_layer(x_obs::Vector{Float64}, x_src::Vector{Float64}, n_src::Vector{Float64})::Float64 - # Double-layer kernel: -1/(4π) * (r·n) / r³ r = norm(x_obs - x_src) - r < 1e-30 && return 0.0 - return -dot(x_obs - x_src, n_src) / (4π * r^3) + return r < 1e-30 ? 0.0 : -dot(x_obs - x_src, n_src) / (4π * r^3) end """ @@ -221,11 +217,7 @@ Extract a PATCH_DIM × PATCH_DIM patch of data centered at (t0, p0) with periodi # Returns - - `patch`: Extracted patch array - -Conversion references: - - - Mirrors patch extraction used inside SingularCorrection::SetPatch in biest/singular_correction.hpp + - `patch`: Extracted patch of data around the singular point (PATCH_DIM × PATCH_DIM × dof) """ function extract_patch(data::Matrix{Float64}, idx_pol_center::Int, idx_tor_center::Int, npol::Int, ntor::Int, PATCH_DIM::Int) @@ -236,7 +228,7 @@ function extract_patch(data::Matrix{Float64}, idx_pol_center::Int, idx_tor_cente # Enforce periodicity idx_pol = mod1(idx_pol_center - PATCH_RAD + i - 1, npol) idx_tor = mod1(idx_tor_center - PATCH_RAD + j - 1, ntor) - # Copy data to the patch + # Copy data to the patch (for each dof) @views patch[i, j, :] .= data[idx_pol+npol*(idx_tor-1), :] end return patch # (PATCH_DIM, PATCH_DIM, dof) @@ -255,10 +247,6 @@ Interpolate Cartesian patch data to polar quadrature points using precomputed ma # Returns - `polar_data`: Interpolated data at polar points (RAD_DIM × ANG_DIM × dof) - -Conversion references: - - - Grid→polar interpolation follows M_G2P application in biest/singular_correction.hpp """ function interpolate_to_polar(patch::Array{Float64,3}, quad_data::SingularQuadratureData) @@ -275,12 +263,9 @@ function interpolate_to_polar(patch::Array{Float64,3}, quad_data::SingularQuadra end """ - compute_dA_and_normal_polar(dr_dθ_polar, dr_dζ_polar) + compute_polar_normal(dr_dθ_polar, dr_dζ_polar) -Compute area elements at polar quadrature points from interpolated tangent vectors. - -The area element is |∂r/∂θ × ∂r/∂ζ| dθ dζ, computed from the cross product of -the interpolated tangent vectors. +Compute normal vector (= ∂r/∂θ × ∂r/∂ζ) at polar quadrature points from interpolated tangent vectors. # Arguments @@ -289,61 +274,42 @@ the interpolated tangent vectors. # Returns - - `dA_polar`: Area element at each polar point (RAD_DIM × ANG_DIM) - `n_polar`: Unit normal vector at each polar point (RAD_DIM × ANG_DIM × 3) """ -function compute_dA_and_normal_polar(dr_dθ::Array{Float64,3}, dr_dζ::Array{Float64,3}) - - RAD_DIM, ANG_DIM, _ = size(dr_dθ) - dA_polar = zeros(RAD_DIM, ANG_DIM) - n_polar = zeros(RAD_DIM, ANG_DIM, 3) - for ia in 1:ANG_DIM, ir in 1:RAD_DIM - # Extract tangent vectors at this polar point - t_θ = @view dr_dθ[ir, ia, :] - t_ζ = @view dr_dζ[ir, ia, :] +function compute_polar_normal(dr_dθ::Array{Float64,3}, dr_dζ::Array{Float64,3}) - # Cross product: ∂r/∂θ × ∂r/∂ζ - cross_prod = cross(t_θ, t_ζ) - - # Magnitude gives area element - dA_polar[ir, ia] = norm(cross_prod) - - # Unit normal (handle zero jacobian gracefully) - if dA_polar[ir, ia] > 1e-30 - n_polar[ir, ia, :] .= cross_prod ./ dA_polar[ir, ia] - end + n_polar = similar(dr_dθ) + @views @inbounds for ia in axes(dr_dθ, 2), ir in axes(dr_dθ, 1) + n_polar[ir, ia, :] .= cross(dr_dθ[ir, ia, :], dr_dζ[ir, ia, :]) end - - return dA_polar, n_polar + return n_polar end """ - compute_3D_kernel_matrix!( - grad_greenfunction, greenfunction, - observer, source; - PATCH_DIM=7, RAD_DIM=12 - ) - -Compute boundary integral kernel matrices for 3D geometries with singular correction. + compute_3D_kernel_matrix!(grad_greenfunction, greenfunction, observer, source; PATCH_RAD=3, RAD_DIM=12, INTERP_ORDER=6) -Uses BIEST-style approach: +Compute boundary integral kernel matrices for 3D geometries with the singular correction +algorithm from Malhotra et al. 2019. - Far regions: Rectangle rule with uniform weights (1/N) - Singular regions: Polar quadrature with partition-of-unity blending +grad_greenfunction is the double-layer kernel matrix, where each entry is +∇_{x_src} φ(x_obs, x_src) · n_src, and greenfunction is the single-layer kernel matrix, +where each entry is φ(x_obs, x_src). + # Arguments - - `grad_greenfunction`: Double-layer kernel matrix (Nobs × Nsrc) - - `greenfunction`: Single-layer kernel matrix (Nobs × Nsrc) + - `grad_greenfunction`: Double-layer kernel matrix (Nobs × Nsrc) filled in place + + - `greenfunction`: Single-layer kernel matrix (Nobs × Nsrc) filled in place - `observer`: Observer geometry (PlasmaGeometry3D) - `source`: Source geometry (PlasmaGeometry3D) - `PATCH_RAD`: Number of points adjacent to source point to treat as singular (default 3) - - `RAD_DIM`: Radial quadrature order (default 12) - -Conversion references: - - Far/near split and Eval flow adapted from FieldPeriodBIOp and BoundaryIntegralOp in biest/boundary_integ_op.hpp - - Rectangle-rule weights mirror SurfNormalAreaElem/EvalSurfInteg in biest/surface_op.txx + + Total patch size in # of gridpoints = (2 * PATCH_RAD + 1) x (2 * PATCH_RAD + 1) + - `RAD_DIM`: Polar radial quadrature order (default 12). Angular order = 2 * RAD_DIM + - `INTERP_ORDER`: Lagrange interpolation order (default 6) """ function compute_3D_kernel_matrix!( grad_greenfunction::Matrix{Float64}, @@ -361,33 +327,34 @@ function compute_3D_kernel_matrix!( # Initialize quadrature data (cached) quad_data = get_singular_quadrature(PATCH_RAD, RAD_DIM, INTERP_ORDER) - (; PATCH_DIM, PATCH_RAD, INTERP_ORDER, ANG_DIM, RAD_DIM, qw, Ppou, Gpou, M_G2P, I_G2P) = quad_data + (; PATCH_DIM, PATCH_RAD, INTERP_ORDER, ANG_DIM, RAD_DIM, Ppou, Gpou, M_G2P, I_G2P) = quad_data @assert observer.mtheta ≥ PATCH_DIM @assert observer.nzeta ≥ PATCH_DIM + dθdζ = (2π / observer.mtheta) * (2π / observer.nzeta) # Loop through observer points for j_obs in 1:observer.nzeta, i_obs in 1:observer.mtheta idx_obs = i_obs + (j_obs - 1) * observer.mtheta r_obs = observer.r[idx_obs, :] - # ============================================ + # ============================================================ # FAR FIELD: Trapezoidal rule for nonsingular source points # Note: kernels return zero for r_src = r_obs - # ============================================ + # ============================================================ for j_src in 1:source.nzeta, i_src in 1:source.mtheta # Evaluate kernels at grid points idx_src = i_src + (j_src - 1) * source.mtheta K_single = laplace_single_layer(r_obs, source.r[idx_src, :]) K_double = laplace_double_layer(r_obs, source.r[idx_src, :], source.normal[idx_src, :]) - # Apply area element (periodic trapezoidal rule: w = dA = J∇ψdθdζ) - greenfunction[idx_obs, idx_src] = K_single * (4π^2 / (observer.mtheta * observer.nzeta)) # * source.dA[idx_src] - grad_greenfunction[idx_obs, idx_src] = K_double * source.dA[idx_src] + # Apply weights (periodic trapezoidal rule = constant weights) + greenfunction[idx_obs, idx_src] = K_single * dθdζ + grad_greenfunction[idx_obs, idx_src] = K_double * dθdζ end - # ============================================ + # ============================================================ # NEAR FIELD: Polar quadrature with singular correction - # ============================================ + # ============================================================ # Extract patches of source data around the singular point (size = PATCH_DIM x PATCH_DIM x dof) r_patch = extract_patch(source.r, i_obs, j_obs, source.mtheta, source.nzeta, PATCH_DIM) dr_dθ_patch = extract_patch(source.dr_dθ, i_obs, j_obs, source.mtheta, source.nzeta, PATCH_DIM) @@ -398,8 +365,8 @@ function compute_3D_kernel_matrix!( dr_dθ_polar = interpolate_to_polar(dr_dθ_patch, quad_data) dr_dζ_polar = interpolate_to_polar(dr_dζ_patch, quad_data) - # Compute area elements and unit normals at polar points from interpolated tangent vectors - dA_polar, n_polar = compute_dA_and_normal_polar(dr_dθ_polar, dr_dζ_polar) + # Compute normal vectors at polar points from interpolated tangent vectors + n_polar = compute_polar_normal(dr_dθ_polar, dr_dζ_polar) # Evaluate kernels at polar points with POU weighting M_polar_single = zeros(RAD_DIM, ANG_DIM) @@ -411,12 +378,11 @@ function compute_3D_kernel_matrix!( K_double = laplace_double_layer(r_obs, r_src, n_src) # Apply quadrature weights: area element × POU, where POU contains rdrdθ already - wt = dA_polar[ir, ia] * Ppou[ir, ia] - M_polar_single[ir, ia] = K_single * Ppou[ir, ia] * (4π^2 / (observer.mtheta * observer.nzeta)) - M_polar_double[ir, ia] = K_double * wt + M_polar_single[ir, ia] = K_single * Ppou[ir, ia] * dθdζ + M_polar_double[ir, ia] = K_double * Ppou[ir, ia] * dθdζ end - # Distribute corrections back to Cartesian grid using interpolation matrix + # Distribute polar singular corrections back to Cartesian grid using interpolation matrix # Correction at grid point = sum over polar points of (kernel_value * interp_weight) M_grid_single = zeros(PATCH_DIM, PATCH_DIM) M_grid_double = zeros(PATCH_DIM, PATCH_DIM) @@ -428,24 +394,28 @@ function compute_3D_kernel_matrix!( end end - # Add far-field POU contribution (Gpou = -χ on grid) and near-field polar quadrature result + # Compute remaining far-field POU contribution and near-field polar quadrature result + # We include this region in the far-field trapezoidal rule, so use Gpou = -χ here to get 1-χ for j in 1:PATCH_DIM, i in 1:PATCH_DIM # Map back to global indices idx_pol = mod1(i_obs - PATCH_RAD + i - 1, source.mtheta) idx_tor = mod1(j_obs - PATCH_RAD + j - 1, source.nzeta) idx_src = idx_pol + source.mtheta * (idx_tor - 1) - # Remainder of far-field contribution on the singular grid: -χGᵢⱼdA - r_src, n_src, dA_src = source.r[idx_src, :], source.normal[idx_src, :], source.dA[idx_src] - far_single = laplace_single_layer(r_obs, r_src) * Gpou[i, j] * (4π^2 / (observer.mtheta * observer.nzeta)) #* dA_src - far_double = laplace_double_layer(r_obs, r_src, n_src) * dA_src * Gpou[i, j] + # Remainder of far-field contribution on the singular grid: Gpou = -χ + r_src, n_src = source.r[idx_src, :], source.normal[idx_src, :] + far_single = laplace_single_layer(r_obs, r_src) * Gpou[i, j] * dθdζ + far_double = laplace_double_layer(r_obs, r_src, n_src) * Gpou[i, j] * dθdζ - # Apply far + near contributions + # Apply near + far contributions greenfunction[idx_obs, idx_src] += M_grid_single[i, j] + far_single grad_greenfunction[idx_obs, idx_src] += M_grid_double[i, j] + far_double end end + # TODO: Don't delete this yet - signs might change depending on convention. I think it might be -1 for wall, + # since we calculate n = dr_dθ × dr_dζ which points inward for a toroidal surface. Should add in normal + # orient for this later for generalization. # Account for normal direction pointing out of vacuum integration region in 𝒦ⁿ ⋅ dS # Negative for plasma since dS = ∇ψ J dθdζ and ∇ψ points outward but outward normal is inward # @views grad_greenfunction .*= (source isa PlasmaGeometry3D ? -1 : 1) diff --git a/src/Vacuum/VacuumInternals.jl b/src/Vacuum/VacuumInternals.jl index 36842114..7f1849bf 100644 --- a/src/Vacuum/VacuumInternals.jl +++ b/src/Vacuum/VacuumInternals.jl @@ -404,6 +404,87 @@ function interp_to_new_grid(vecin::Vector{Float64}, mtheta::Int) return vecout end +""" + distribute_to_equal_arc_grid(xin, zin, mw1) + +Perform arc length re-parameterization of a 2D curve. + +Takes an input curve defined by `(xin, zin)` coordinates and re-samples it such that +the new points `(xout, zout)` are equally spaced in arc length along the curve. + +# Arguments + + - `xin::Vector{Float64}`: Array of x-coordinates of the input curve + - `zin::Vector{Float64}`: Array of z-coordinates of the input curve + - `mw1::Int`: Number of points in the input and output curves + +# Returns + + - `xout::Vector{Float64}`: Array of x-coordinates of the arc-length re-parameterized curve + - `zout::Vector{Float64}`: Array of z-coordinates of the arc-length re-parameterized curve + - `ell::Vector{Float64}`: Array of cumulative arc lengths for the input curve + - `thgr::Vector{Float64}`: Array of re-parameterized 'theta' values corresponding to equal arc lengths + - `thlag::Vector{Float64}`: Array of normalized 'theta' values for the input curve (0 to 1) + +# Notes + + - Uses Lagrange interpolation for calculating arc length and resampling + - Ensures uniform spacing in arc length for improved numerical stability +""" +function distribute_to_equal_arc_grid(xin::Vector{Float64}, zin::Vector{Float64}, mtheta::Int) + + # Temporary arrays for interpolation and arc-length calculation + theta_in = zeros(Float64, mtheta) # Normalized input parameter [0, 1) + theta_out = zeros(Float64, mtheta) # New parameter distribution for equal spacing + xout = zeros(Float64, mtheta) # Uniformly spaced R-coordinates + zout = zeros(Float64, mtheta) # Uniformly spaced Z-coordinates + + # Define initial normalized parameter theta_in + dt = 1.0 / mtheta + theta_in .= range(; start=0, length=mtheta, step=dt) # θ ∈ [0, 1) + # we need a closed loop for arc length calculation + mtheta1 = mtheta + 1 + xin1 = vcat(xin, xin[1]) + zin1 = vcat(zin, zin[1]) + theta_in1 = vcat(theta_in, [1.0]) + ell = zeros(Float64, mtheta1) # Cumulative arc length of closed loop + + # Calculate cumulative arc length using numerical integration + # We use a mid-point derivative approximation to find the path length + for iw in 2:mtheta1 + # Evaluate derivative at the midpoint of the interval + theta = (theta_in1[iw] + theta_in1[iw-1]) / 2.0 + + # Calculate dx/dt and dz/dt using Lagrange interpolation (order 3) + _, d_xin = lagrange1d(theta_in1, xin1, mtheta1, 3, theta, 1) + _, d_zin = lagrange1d(theta_in1, zin1, mtheta1, 3, theta, 1) + + # Instantaneous speed (ds/dt) + ds_dt = sqrt(d_xin^2 + d_zin^2) + + # Accumulate length: ds = (ds/dt) * dt + ell[iw] = ell[iw-1] + ds_dt * dt + end + + # Re-parameterize based on equal arc-length segments + ell_targets = collect(range(0; step=ell[end]/mtheta, length=mtheta)) # [0, Length) for open loop result + for i in 2:mtheta + # Find the value of 'theta_in' that corresponds to the target arc length 's' + f_th, _ = lagrange1d(ell, theta_in1, mtheta1, 3, ell_targets[i], 0) + theta_out[i] = f_th + end + + # Interpolate the original (x,z) data at the new parameter points to get (xout, zout) + # Chance interpolates in theta_out to get xin, zin but this introduces small errors in arc lengths + for i in 1:mtheta + f_x, _ = lagrange1d(ell, xin1, mtheta1, 3, ell_targets[i], 0) + f_z, _ = lagrange1d(ell, zin1, mtheta1, 3, ell_targets[i], 0) + xout[i] = f_x + zout[i] = f_z + end + + return xout, zout, ell, theta_out, theta_in +end ############################################################# # Legendre function of the first kind eq.(47)~(50) , replacing aleg. (verified) @@ -437,7 +518,6 @@ function elliptic_integral_k(m1) return ellipk end - """ This function is different from elliptic integral E(k). Be careful. @@ -465,7 +545,6 @@ function elliptic_integral_e(m1) end - # Chance 1997 eq.(49) (original) function P0_minus_half(s) m1 = 2 / (s + 1) diff --git a/src/Vacuum/VacuumStructs.jl b/src/Vacuum/VacuumStructs.jl index 7627de29..02c12e92 100644 --- a/src/Vacuum/VacuumStructs.jl +++ b/src/Vacuum/VacuumStructs.jl @@ -21,64 +21,67 @@ Struct holding plasma boundary and mode data as provided from DCON namelist and ν::Vector{Float64} = Float64[] mlow::Int = 0 mpert::Int = 0 - nlow::Int = 0 - npert::Int = 0 n::Int = 0 mtheta::Int = 1 - nzeta::Int = 1 kernelsign::Float64 = 1.0 force_wv_symmetry::Bool = true end """ - PlasmaGeometry + VacuumInput3D -Struct holding plasma geometry data on the mtheta grid for vacuum calculations. Arrays are -of length `mtheta`, where `mtheta` is the number of poloidal grid points and θ ∈ [0, 1). -It also precomputes trigonometric basis functions needed for Fourier calculations into matrices -of size (mtheta, mpert), where `mpert` is the number of poloidal modes. +Struct holding 2D plasma boundary for a 3D VACUUM run `and mode data as provided from DCON namelist and computed quantities. +2D contour becomes a 3D axisymmetric surface by toroidal extrusion. # Fields - - `x::Vector{Float64}`: Plasma surface R-coordinate on VACUUM theta grid - - `z::Vector{Float64}`: Plasma surface Z-coordinate on VACUUM theta grid - - `dx_dtheta::Vector{Float64}`: Derivative dR/dθ at plasma surface - - `dz_dtheta::Vector{Float64}`: Derivative dZ/dθ at plasma surface - - `sin_mn_basis::Matrix{Float64}`: sin(mθ - nν) basis functions for poloidal modes at plasma surface - - `cos_mn_basis::Matrix{Float64}`: cos(mθ - nν) basis functions for poloidal modes at plasma surface + - `x::Vector{Float64}`: Plasma boundary X-coordinate on DCON theta grid + - `z::Vector{Float64}`: Plasma boundary Z-coordinate on DCON theta grid + - `ν::Vector{Float64}`: Free parameter in specifying toroidal angle, ϕ = 2πζ + ν(ψ, θ), on DCON theta grid + - `mlow::Int`: Lower poloidal mode number + - `mpert::Int`: Number of poloidal modes + - `nlow::Int`: Lower toroidal mode number + - `npert::Int`: Number of toroidal modes + - `mtheta::Int`: Number of poloidal collocation points + - `nzeta::Int`: Number of toroidal collocation points + - `kernelsign::Float64`: Sign for kernel; +1 or -1, only ≠ 1 for mutual inductance calculations + - `force_wv_symmetry::Bool`: Boolean flag to enforce symmetry in the vacuum response matrix (set in dcon.toml) """ -struct PlasmaGeometry - x::Vector{Float64} - z::Vector{Float64} - ν::Vector{Float64} - dx_dtheta::Vector{Float64} - dz_dtheta::Vector{Float64} - sin_mn_basis::Matrix{Float64} - cos_mn_basis::Matrix{Float64} +@kwdef struct VacuumInput3D + x::Vector{Float64} = Float64[] + z::Vector{Float64} = Float64[] + ν::Vector{Float64} = Float64[] + mlow::Int = 0 + mpert::Int = 0 + nlow::Int = 0 + npert::Int = 0 + n::Int = 0 + mtheta::Int = 1 + nzeta::Int = 1 + kernelsign::Float64 = 1.0 + force_wv_symmetry::Bool = true end """ - WallGeometry - -Struct holding wall geometry data for vacuum calculations. Arrays are of length -`mtheta`, where `mtheta` is the number of poloidal grid points and θ ∈ [0, 1). + VacuumInput3D(inputs_2D::VacuumInput, nzeta::Int, nlow::Int, npert::Int) -# Fields - - - `nowall::Bool`: Boolean flag indicating if there is no wall - - `is_closed_toroidal::Bool`: Boolean flag indicating if the wall is a closed toroidal surface - - `x::Vector{Float64}`: Wall R-coordinates - - `z::Vector{Float64}`: Wall Z-coordinates - - `dx_dtheta::Vector{Float64}`: Derivative dR/dθ at wall - - `dz_dtheta::Vector{Float64}`: Derivative dZ/dθ at wall +Convenience constructor for a 3D VacuumInput3D struct from a 2D VacuumInput with the additional +required parameters for the toroidal grid/modes. """ -struct WallGeometry - nowall::Bool - is_closed_toroidal::Bool - x::Vector{Float64} - z::Vector{Float64} - dx_dtheta::Vector{Float64} - dz_dtheta::Vector{Float64} +function VacuumInput3D(inputs_2D::VacuumInput, nzeta::Int, nlow::Int, npert::Int) + return VacuumInput3D(; + x=inputs_2D.r, + z=inputs_2D.z, + ν=inputs_2D.ν, + mlow=inputs_2D.mlow, + mpert=inputs_2D.mpert, + nlow=nlow, + npert=npert, + mtheta=inputs_2D.mtheta, + nzeta=nzeta, + kernelsign=inputs_2D.kernelsign, + force_wv_symmetry=inputs_2D.force_wv_symmetry + ) end """ @@ -124,9 +127,36 @@ Struct containing input settings for vacuum wall geometry. end """ - initialize_plasma_surface(inputs::VacuumInput) -> PlasmaGeometry + PlasmaGeometry -Initialize the plasma surface geometry based on the provided vacuum inputs. +Struct holding plasma geometry data on the mtheta grid for vacuum calculations. Arrays are +of length `mtheta`, where `mtheta` is the number of poloidal grid points and θ ∈ [0, 1). +It also precomputes trigonometric basis functions needed for Fourier calculations into matrices +of size (mtheta, mpert), where `mpert` is the number of poloidal modes. + +# Fields + + - `x::Vector{Float64}`: Plasma surface R-coordinate on VACUUM theta grid + - `z::Vector{Float64}`: Plasma surface Z-coordinate on VACUUM theta grid + - `dx_dtheta::Vector{Float64}`: Derivative dR/dθ at plasma surface + - `dz_dtheta::Vector{Float64}`: Derivative dZ/dθ at plasma surface + - `sin_mn_basis::Matrix{Float64}`: sin(mθ - nν) basis functions for poloidal modes at plasma surface + - `cos_mn_basis::Matrix{Float64}`: cos(mθ - nν) basis functions for poloidal modes at plasma surface +""" +struct PlasmaGeometry + x::Vector{Float64} + z::Vector{Float64} + ν::Vector{Float64} + dx_dtheta::Vector{Float64} + dz_dtheta::Vector{Float64} + sin_mn_basis::Matrix{Float64} + cos_mn_basis::Matrix{Float64} +end + +""" + PlasmaGeometry(inputs::VacuumInput) + +Contructor to initialize the plasma surface geometry based on the provided vacuum inputs. This function performs functionality from `readahg`, `arrays`, and `funint` in the original Fortran VACUUM code. It returns a `PlasmaGeometry` struct containing @@ -147,9 +177,9 @@ the necessary plasma surface data for vacuum calculations. - `PlasmaGeometry`: Struct containing plasma surface coordinates, derivatives, and basis functions """ -function initialize_plasma_surface(inputs::VacuumInput) +function PlasmaGeometry(inputs::VacuumInput) - (; mtheta, mpert, mlow, nzeta, npert, nlow, ν, r, z, n) = inputs + (; mtheta, mpert, mlow, ν, r, z, n) = inputs # Interpolate arrays from input onto mtheta grid R = interp_to_new_grid(r, mtheta) Z = interp_to_new_grid(z, mtheta) @@ -195,35 +225,33 @@ that the gradient/area elements are scaled by dθ and dζ. - `r::Matrix{Float64}`: Surface points in Cartesian (X,Y,Z), shape (num_gridpoints, 3) - `dr_dθ::Matrix{Float64}`: Poloidal tangent vector ∂r/∂θ × dθ, shape (num_gridpoints, 3) - `dr_dζ::Matrix{Float64}`: Toroidal tangent vector ∂r/∂ζ × dζ, shape (num_gridpoints, 3) - - `n::Matrix{Float64}`: Outward unit normal vectors, shape (num_gridpoints, 3) - - `dA::Vector{Float64}`: Differential area elements |∂r/∂θ × ∂r/∂ζ| dθ dζ, length num_gridpoints + - `n::Matrix{Float64}`: Outward normal vectors, shape (num_gridpoints, 3) - `sin_mn_basis3D::Matrix{Float64}`: sin(mθ - nν - nϕ) basis functions at plasma surface - `cos_mn_basis3D::Matrix{Float64}`: cos(mθ - nν - nϕ) basis functions at plasma surface """ @kwdef struct PlasmaGeometry3D mtheta::Int nzeta::Int - num_gridpoints::Int r::Matrix{Float64} dr_dθ::Matrix{Float64} dr_dζ::Matrix{Float64} normal::Matrix{Float64} - dA::Vector{Float64} sin_mn_basis3D::Matrix{Float64} cos_mn_basis3D::Matrix{Float64} end """ - PlasmaGeometry3D(plasma_2d::PlasmaGeometry, nzeta::Int) -> PlasmaGeometry3D + PlasmaGeometry3D(plasma_2d::PlasmaGeometry, nzeta::Int) Construct a 3D axisymmetric toroidal surface from a 2D poloidal contour. # Algorithm + 0. Interpolate 2D arrays onto mtheta grid 1. Map 2D (R, Z, ν) to 3D Cartesian: X = R cos(ϕ+ν), Y = R sin(ϕ+ν), Z = Z 2. Fit periodic bicubic splines to (X, Y, Z) on (θ, ϕ) grid 3. Compute tangent vectors via spline gradients - 4. Compute normals and area elements via cross product: n × dA = ∂r/∂θ × ∂r/∂ζ + 4. Compute normals via cross product: n = ∂r/∂θ × ∂r/∂ζ # Arguments @@ -234,12 +262,11 @@ Construct a 3D axisymmetric toroidal surface from a 2D poloidal contour. - `PlasmaGeometry3D`: Complete 3D surface description """ -function PlasmaGeometry3D(plasma_2d::PlasmaGeometry, inputs::VacuumInput)::PlasmaGeometry3D +function PlasmaGeometry3D(inputs::VacuumInput3D) # Extract 2D poloidal data - (; mtheta, npert, nlow, mlow, nzeta, mpert) = inputs + (; mtheta, nzeta, npert, nlow, mlow, mpert) = inputs num_gridpoints = mtheta * nzeta - (; x, z, ν) = plasma_2d dθ = 2π / mtheta dϕ = 2π / nzeta θ_grid = range(; start=0, length=mtheta, step=dθ) @@ -252,6 +279,11 @@ function PlasmaGeometry3D(plasma_2d::PlasmaGeometry, inputs::VacuumInput)::Plasm dr_dθ = zeros(num_gridpoints, 3) dr_dζ = zeros(num_gridpoints, 3) + # Interpolate arrays from input onto mtheta grid (same as 2D) + x = interp_to_new_grid(inputs.x, mtheta) + z = interp_to_new_grid(inputs.z, mtheta) + ν = interp_to_new_grid(inputs.ν, mtheta) + # Build 3D surface point-by-point from 2D contour for (i, θ) in enumerate(θ_grid), (j, ϕ) in enumerate(ϕ_grid) idx = i + (j - 1) * mtheta @@ -267,16 +299,9 @@ function PlasmaGeometry3D(plasma_2d::PlasmaGeometry, inputs::VacuumInput)::Plasm idx = i + (j - 1) * mtheta dr_dθ[idx, :] .= [Interpolations.gradient(itps[k], θ, ϕ)[1] for k in 1:3] dr_dζ[idx, :] .= [Interpolations.gradient(itps[k], θ, ϕ)[2] for k in 1:3] - cross_prod = cross(dr_dθ[idx, :], dr_dζ[idx, :]) - dA[idx] = norm(cross_prod) - normal[idx, :] .= cross_prod / dA[idx] + normal[idx, :] = cross(dr_dθ[idx, :], dr_dζ[idx, :]) end - # Multiply by scalings - dA .*= dθ * dϕ - dr_dθ .*= dθ - dr_dζ .*= dϕ - # Precompute Fourier transform terms, sin(lθ - nν(θ) - nϕ) and cos(lθ - nν(θ) - nϕ) sin_mn_basis3D = zeros(num_gridpoints, mpert*npert) cos_mn_basis3D = zeros(num_gridpoints, mpert*npert) @@ -296,21 +321,43 @@ function PlasmaGeometry3D(plasma_2d::PlasmaGeometry, inputs::VacuumInput)::Plasm return PlasmaGeometry3D(; mtheta, nzeta, - num_gridpoints, r, dr_dθ, dr_dζ, normal, - dA, sin_mn_basis3D, cos_mn_basis3D ) end """ - initialize_wall(inputs::VacuumInput, plasma_surf::PlasmaGeometry, wall_settings::WallShapeSettings) -> WallGeometry + WallGeometry + +Struct holding wall geometry data for vacuum calculations. Arrays are of length +`mtheta`, where `mtheta` is the number of poloidal grid points and θ ∈ [0, 1). -Initialize the wall geometry based on the provided vacuum inputs and wall shape settings. +# Fields + + - `nowall::Bool`: Boolean flag indicating if there is no wall + - `is_closed_toroidal::Bool`: Boolean flag indicating if the wall is a closed toroidal surface + - `x::Vector{Float64}`: Wall R-coordinates + - `z::Vector{Float64}`: Wall Z-coordinates + - `dx_dtheta::Vector{Float64}`: Derivative dR/dθ at wall + - `dz_dtheta::Vector{Float64}`: Derivative dZ/dθ at wall +""" +@kwdef struct WallGeometry + nowall::Bool + is_closed_toroidal::Bool + x::Vector{Float64} + z::Vector{Float64} + dx_dtheta::Vector{Float64} + dz_dtheta::Vector{Float64} +end + +""" + WallGeometry(inputs::VacuumInput, plasma_surf::PlasmaGeometry, wall_settings::WallShapeSettings) + +Contructor to initialize the wall geometry based on the provided vacuum inputs and wall shape settings. This performs functionality similar to portions of the `arrays` function in the original Fortran VACUUM code. It returns a `WallGeometry` struct containing the necessary wall @@ -331,7 +378,7 @@ surface data for vacuum calculations. - Supports multiple wall shapes: nowall, conformal, elliptical, dee, mod_dee, from_file - Optionally redistributes wall points to equal arc length spacing if `equal_arc_wall=true` """ -function initialize_wall(inputs::VacuumInput, plasma_surf::PlasmaGeometry, wall_settings::WallShapeSettings) +function WallGeometry(inputs::VacuumInput, plasma_surf::PlasmaGeometry, wall_settings::WallShapeSettings) # Basic wall flags nowall = wall_settings.shape == "nowall" @@ -461,94 +508,102 @@ function initialize_wall(inputs::VacuumInput, plasma_surf::PlasmaGeometry, wall_ error("Wall R-coordinates contain non-physical values (R <= 0). Check wall geometry.") end - return WallGeometry( - nowall, - is_closed_toroidal, - x_wall, - z_wall, - dx_dtheta, - dz_dtheta + return WallGeometry(; + nowall=nowall, + is_closed_toroidal=is_closed_toroidal, + x=x_wall, + z=z_wall, + dx_dtheta=dx_dtheta, + dz_dtheta=dz_dtheta ) end """ - distribute_to_equal_arc_grid(xin, zin, mw1) + WallGeometry3D -Perform arc length re-parameterization of a 2D curve. +Struct holding wall geometry data for vacuum calculations. Arrays are of length +`mtheta`, where `mtheta` is the number of poloidal grid points and θ ∈ [0, 1). -Takes an input curve defined by `(xin, zin)` coordinates and re-samples it such that -the new points `(xout, zout)` are equally spaced in arc length along the curve. +# Fields + + - `nowall::Bool`: Boolean flag indicating if there is no wall + - `is_closed_toroidal::Bool`: Boolean flag indicating if the wall is a closed toroidal surface + - `mtheta::Int`: Number of poloidal grid points + - `nzeta::Int`: Number of toroidal grid points + - `r::Matrix{Float64}`: (x, y, z) wall coordinates at each grid point + - `dr_dθ::Matrix{Float64}`: Derivative dR/dθ at wall + - `dr_dζ::Matrix{Float64}`: Derivative dR/dζ at wall + - `normal::Matrix{Float64}`: Outward unit normal vectors at wall + - `dA::Vector{Float64}`: Differential area elements at wall +""" +@kwdef struct WallGeometry3D + nowall::Bool + is_closed_toroidal::Bool + mtheta::Int + nzeta::Int + r::Matrix{Float64} + dr_dθ::Matrix{Float64} + dr_dζ::Matrix{Float64} + normal::Matrix{Float64} + dA::Vector{Float64} +end + +""" + WallGeometry3D(inputs::VacuumInput3D, plasma_surf::PlasmaGeometry3D, wall_settings::WallShapeSettings) + +Contructor to initialize the 3D wall geometry based on the provided vacuum inputs and wall shape settings. + +This performs functionality similar to portions of the `arrays` function in the original +Fortran VACUUM code. It returns a `WallGeometry` struct containing the necessary wall +surface data for vacuum calculations. # Arguments - - `xin::Vector{Float64}`: Array of x-coordinates of the input curve - - `zin::Vector{Float64}`: Array of z-coordinates of the input curve - - `mw1::Int`: Number of points in the input and output curves + - `inputs::VacuumInput3D`: Struct containing vacuum calculation parameters + - `plasma_surf::PlasmaGeometry3D`: Struct with plasma surface geometry (used for reference) + - `wall_settings::WallShapeSettings`: Struct specifying wall shape and parameters # Returns - - `xout::Vector{Float64}`: Array of x-coordinates of the arc-length re-parameterized curve - - `zout::Vector{Float64}`: Array of z-coordinates of the arc-length re-parameterized curve - - `ell::Vector{Float64}`: Array of cumulative arc lengths for the input curve - - `thgr::Vector{Float64}`: Array of re-parameterized 'theta' values corresponding to equal arc lengths - - `thlag::Vector{Float64}`: Array of normalized 'theta' values for the input curve (0 to 1) + - `WallGeometry`: Struct containing wall surface coordinates and derivatives # Notes - - Uses Lagrange interpolation for calculating arc length and resampling - - Ensures uniform spacing in arc length for improved numerical stability + - Supports multiple wall shapes: nowall, conformal, elliptical, dee, mod_dee, from_file + - Optionally redistributes wall points to equal arc length spacing if `equal_arc_wall=true` """ -function distribute_to_equal_arc_grid(xin::Vector{Float64}, zin::Vector{Float64}, mtheta::Int) - - # Temporary arrays for interpolation and arc-length calculation - theta_in = zeros(Float64, mtheta) # Normalized input parameter [0, 1) - theta_out = zeros(Float64, mtheta) # New parameter distribution for equal spacing - xout = zeros(Float64, mtheta) # Uniformly spaced R-coordinates - zout = zeros(Float64, mtheta) # Uniformly spaced Z-coordinates - - # Define initial normalized parameter theta_in - dt = 1.0 / mtheta - theta_in .= range(; start=0, length=mtheta, step=dt) # θ ∈ [0, 1) - # we need a closed loop for arc length calculation - mtheta1 = mtheta + 1 - xin1 = vcat(xin, xin[1]) - zin1 = vcat(zin, zin[1]) - theta_in1 = vcat(theta_in, [1.0]) - ell = zeros(Float64, mtheta1) # Cumulative arc length of closed loop - - # Calculate cumulative arc length using numerical integration - # We use a mid-point derivative approximation to find the path length - for iw in 2:mtheta1 - # Evaluate derivative at the midpoint of the interval - theta = (theta_in1[iw] + theta_in1[iw-1]) / 2.0 - - # Calculate dx/dt and dz/dt using Lagrange interpolation (order 3) - _, d_xin = lagrange1d(theta_in1, xin1, mtheta1, 3, theta, 1) - _, d_zin = lagrange1d(theta_in1, zin1, mtheta1, 3, theta, 1) - - # Instantaneous speed (ds/dt) - ds_dt = sqrt(d_xin^2 + d_zin^2) - - # Accumulate length: ds = (ds/dt) * dt - ell[iw] = ell[iw-1] + ds_dt * dt - end +function WallGeometry3D(inputs::VacuumInput3D, plasma_surf::PlasmaGeometry3D, wall_settings::WallShapeSettings) - # Re-parameterize based on equal arc-length segments - ell_targets = collect(range(0; step=ell[end]/mtheta, length=mtheta)) # [0, Length) for open loop result - for i in 2:mtheta - # Find the value of 'theta_in' that corresponds to the target arc length 's' - f_th, _ = lagrange1d(ell, theta_in1, mtheta1, 3, ell_targets[i], 0) - theta_out[i] = f_th - end + # Basic wall flags + nowall = wall_settings.shape == "nowall" + is_closed_toroidal = true + + # All of these arrays are of length mtheta with θ = [0, 1) + (; mtheta, nzeta) = inputs + num_gridpoints = mtheta * nzeta - # Interpolate the original (x,z) data at the new parameter points to get (xout, zout) - # Chance interpolates in theta_out to get xin, zin but this introduces small errors in arc lengths - for i in 1:mtheta - f_x, _ = lagrange1d(ell, xin1, mtheta1, 3, ell_targets[i], 0) - f_z, _ = lagrange1d(ell, zin1, mtheta1, 3, ell_targets[i], 0) - xout[i] = f_x - zout[i] = f_z + # Output wall coordinate arrays + r = zeros(num_gridpoints, 3) + normal = zeros(num_gridpoints, 3) + dA = zeros(num_gridpoints) + dr_dθ = zeros(num_gridpoints, 3) + dr_dζ = zeros(num_gridpoints, 3) + + if wall_settings.shape == "nowall" + @info "Using no wall" + else + error("3D wall shapes other than 'nowall' are not yet implemented.") end - return xout, zout, ell, theta_out, theta_in + return WallGeometry3D( + nowall, + is_closed_toroidal, + mtheta, + nzeta, + r, + dr_dθ, + dr_dζ, + normal, + dA + ) end From 42ec87761959ddb10385fa1647584967fa280de3 Mon Sep 17 00:00:00 2001 From: Jake Halpern Date: Thu, 29 Jan 2026 18:38:00 -0500 Subject: [PATCH 18/31] VACUUM - IMPROVEMENT - sparse array approach for interpolation --- src/Vacuum/Vacuum.jl | 1 + src/Vacuum/Vacuum3D.jl | 97 +++++++++++++++++++++++------------------- 2 files changed, 55 insertions(+), 43 deletions(-) diff --git a/src/Vacuum/Vacuum.jl b/src/Vacuum/Vacuum.jl index 4928549d..f9a8220f 100644 --- a/src/Vacuum/Vacuum.jl +++ b/src/Vacuum/Vacuum.jl @@ -3,6 +3,7 @@ module Vacuum using TOML, Interpolations, SpecialFunctions, LinearAlgebra, Printf using StaticArrays using FastGaussQuadrature +using SparseArrays using ..BIEST include("VacuumStructs.jl") diff --git a/src/Vacuum/Vacuum3D.jl b/src/Vacuum/Vacuum3D.jl index 72635192..88b77008 100644 --- a/src/Vacuum/Vacuum3D.jl +++ b/src/Vacuum/Vacuum3D.jl @@ -8,11 +8,11 @@ Initialized once on first use. - `qw::Vector{Float64}`: Radial quadrature weights - `Gpou::Matrix{Float64}`: Partition of unity on Cartesian grid (PATCH_DIM × PATCH_DIM) - `Ppou::Matrix{Float64}`: Partition of unity on polar grid (RAD_DIM × ANG_DIM) - - `M_G2P::Array{Float64, 4}`: 2D tensor-product Lagrange basis function values for interpolating from the Cartesian patch grid to polar quadrature points (RAD_DIM × ANG_DIM × INTERP_ORDER × INTERP_ORDER) - - `I_G2P::Array{Int, 3}`: Indices of lower-left corner of INTERP_ORDER × INTERP_ORDER stencil in PATCH_DIM × PATCH_DIM grid + - `P2G::SparseMatrixCSC{Float64,Int}`: Sparse interpolation matrix (Ngrid × Npolar) mapping polar quadrature points to Cartesian grid + - Forward (patch→polar): `polar = P2G' * patch` + - Backward (polar→grid): `grid = P2G * polar`. - `PATCH_DIM::Int`: Patch dimension (odd integer) - `PATCH_RAD::Int`: Patch radius (number of points adjacent to source point treated as singular) - - `INTERP_ORDER::Int`: Interpolation order - `ANG_DIM::Int`: Number of angular quadrature points - `RAD_DIM::Int`: Number of radial quadrature points """ @@ -21,11 +21,9 @@ struct SingularQuadratureData qw::Vector{Float64} Gpou::Matrix{Float64} Ppou::Matrix{Float64} - M_G2P::Array{Float64,4} - I_G2P::Array{Int,3} + P2G::SparseMatrixCSC{Float64,Int} PATCH_DIM::Int PATCH_RAD::Int - INTERP_ORDER::Int ANG_DIM::Int RAD_DIM::Int end @@ -61,7 +59,7 @@ function init_singular_quadrature(PATCH_RAD::Int, RAD_DIM::Int, INTERP_ORDER::In # Number of angular quadrature nodes in polar coordinates (uniformly distributed around circle) ANG_DIM = 2 * RAD_DIM - # Setup radial quadrature (Gauss-Legendre transformed to [0,1]) + # Setup radial quadrature qx_raw, qw_raw = FastGaussQuadrature.gausslegendre(RAD_DIM) # points on [-1,1] qx = (qx_raw .+ 1) ./ 2 # Map [-1, 1] to [0, 1] qw = qw_raw ./ 2 # Adjust weights for interval change @@ -88,8 +86,9 @@ function init_singular_quadrature(PATCH_RAD::Int, RAD_DIM::Int, INTERP_ORDER::In # Spacing between Lagrange interpolation nodes in [0,1] for INTERP_ORDER-point stencil h = 1.0 / (INTERP_ORDER - 1) - # Compute 2D tensor-product Lagrange basis function at (x0, x1) in local stencil coordinates - # for basis node (i0, i1) on uniform grid with spacing h + + # Compute 2D tensor-product Lagrange basis function at (x0, x1) in local + # stencil coordinates for basis node (i0, i1) on uniform grid with spacing h @inline function lagrange_interp(x0::Float64, x1::Float64, i0::Int, i1::Int) Lx = Ly = 1.0 ξ0 = x0 / h @@ -103,9 +102,23 @@ function init_singular_quadrature(PATCH_RAD::Int, RAD_DIM::Int, INTERP_ORDER::In return Lx * Ly end - # Build Lagrange interpolation matrix from Cartesian grid to polar (G2P) points - M_G2P = zeros(RAD_DIM, ANG_DIM, INTERP_ORDER, INTERP_ORDER) - I_G2P = zeros(Int, RAD_DIM, ANG_DIM, 2) # y0,y1 indices of lower-left corner of stencil in PATCH_DIM × PATCH_DIM grid + # Build sparse interpolation operator P2G ∈ ℝ^{Ngrid × Npolar} + # grid_values = P2G * polar_values + # polar_values = P2G' * grid_values + # Each column of P2G contains the INTERP_ORDER² Lagrange weights + # mapping one polar sample to its surrounding Cartesian grid stencil. + Ngrid = PATCH_DIM * PATCH_DIM + Npolar = RAD_DIM * ANG_DIM + + # Preallocate COO storage: + # I_coo[k], J_coo[k] = (row, column) index of kth nonzero + # V_coo[k] = interpolation weight + nnz_per_polar = INTERP_ORDER^2 + I_coo = Vector{Int}(undef, Npolar * nnz_per_polar) + J_coo = Vector{Int}(undef, Npolar * nnz_per_polar) + V_coo = Vector{Float64}(undef, Npolar * nnz_per_polar) + + idx = 1 for ir in 1:RAD_DIM, ia in 1:ANG_DIM # Map polar node to unit square: x0, x1 ∈ [0,1] × [0,1] x0 = 0.5 + 0.5 * qx[ir] * cos(dθ * (ia - 1)) @@ -119,16 +132,24 @@ function init_singular_quadrature(PATCH_RAD::Int, RAD_DIM::Int, INTERP_ORDER::In z0 = (x0 * (PATCH_DIM - 1) - y0) * h z1 = (x1 * (PATCH_DIM - 1) - y1) * h - # Indices of lower-left corner of INTERP_ORDER × INTERP_ORDER stencil in PATCH_DIM × PATCH_DIM grid - I_G2P[ir, ia, :] .= [y0, y1] + # Polar point index (column in P2G) + j_polar = ir + RAD_DIM * (ia - 1) - # 2D tensor-product Lagrange basis function values on the polar grid - for j0 in 1:INTERP_ORDER, j1 in 1:INTERP_ORDER - M_G2P[ir, ia, j0, j1] = lagrange_interp(z0, z1, j0 - 1, j1 - 1) + # Populate stencil contributions for this polar node + for i0 in 1:INTERP_ORDER, i1 in 1:INTERP_ORDER + # Grid point index (row in P2G), using column-major layout + i_grid = (y0 + i0) + PATCH_DIM * (y1 + i1 - 1) + I_coo[idx] = i_grid + J_coo[idx] = j_polar + V_coo[idx] = lagrange_interp(z0, z1, i0 - 1, i1 - 1) + idx += 1 end end - return SingularQuadratureData(qx, qw, Gpou, Ppou, M_G2P, I_G2P, PATCH_DIM, PATCH_RAD, INTERP_ORDER, ANG_DIM, RAD_DIM) + # Assemble sparse interpolation matrix + P2G = sparse(I_coo, J_coo, V_coo, Ngrid, Npolar) + + return SingularQuadratureData(qx, qw, Gpou, Ppou, P2G, PATCH_DIM, PATCH_RAD, ANG_DIM, RAD_DIM) end """ @@ -237,7 +258,7 @@ end """ interpolate_to_polar(patch, quad_data) -Interpolate Cartesian patch data to polar quadrature points using precomputed matrix. +Interpolate Cartesian patch data to polar quadrature points using sparse matrix multiply. # Arguments @@ -250,16 +271,13 @@ Interpolate Cartesian patch data to polar quadrature points using precomputed ma """ function interpolate_to_polar(patch::Array{Float64,3}, quad_data::SingularQuadratureData) - (; M_G2P, I_G2P, INTERP_ORDER, RAD_DIM, ANG_DIM) = quad_data + (; P2G, RAD_DIM, ANG_DIM) = quad_data dof = size(patch, 3) - polar_data = zeros(RAD_DIM, ANG_DIM, dof) - for ir in 1:RAD_DIM, ia in 1:ANG_DIM - ycorner = I_G2P[ir, ia, :] - for i0 in 1:INTERP_ORDER, i1 in 1:INTERP_ORDER - polar_data[ir, ia, :] .+= M_G2P[ir, ia, i0, i1] .* patch[ycorner[1]+i0, ycorner[2]+i1, :] - end - end - return polar_data + + # Flatten patch to (Ngrid × dof), apply P2G' to get (Npolar × dof) + patch_flat = reshape(patch, :, dof) + polar_flat = P2G' * patch_flat + return reshape(polar_flat, RAD_DIM, ANG_DIM, dof) end """ @@ -279,7 +297,7 @@ Compute normal vector (= ∂r/∂θ × ∂r/∂ζ) at polar quadrature points fr function compute_polar_normal(dr_dθ::Array{Float64,3}, dr_dζ::Array{Float64,3}) n_polar = similar(dr_dθ) - @views @inbounds for ia in axes(dr_dθ, 2), ir in axes(dr_dθ, 1) + @views for ia in axes(dr_dθ, 2), ir in axes(dr_dθ, 1) n_polar[ir, ia, :] .= cross(dr_dθ[ir, ia, :], dr_dζ[ir, ia, :]) end return n_polar @@ -314,8 +332,8 @@ where each entry is φ(x_obs, x_src). function compute_3D_kernel_matrix!( grad_greenfunction::Matrix{Float64}, greenfunction::Matrix{Float64}, - observer::PlasmaGeometry3D, - source::PlasmaGeometry3D; + observer::Union{PlasmaGeometry3D,WallGeometry3D}, + source::Union{PlasmaGeometry3D,WallGeometry3D}; PATCH_RAD::Int=3, RAD_DIM::Int=15, INTERP_ORDER::Int=6 @@ -327,7 +345,7 @@ function compute_3D_kernel_matrix!( # Initialize quadrature data (cached) quad_data = get_singular_quadrature(PATCH_RAD, RAD_DIM, INTERP_ORDER) - (; PATCH_DIM, PATCH_RAD, INTERP_ORDER, ANG_DIM, RAD_DIM, Ppou, Gpou, M_G2P, I_G2P) = quad_data + (; PATCH_DIM, PATCH_RAD, ANG_DIM, RAD_DIM, Ppou, Gpou, P2G) = quad_data @assert observer.mtheta ≥ PATCH_DIM @assert observer.nzeta ≥ PATCH_DIM dθdζ = (2π / observer.mtheta) * (2π / observer.nzeta) @@ -382,17 +400,10 @@ function compute_3D_kernel_matrix!( M_polar_double[ir, ia] = K_double * Ppou[ir, ia] * dθdζ end - # Distribute polar singular corrections back to Cartesian grid using interpolation matrix - # Correction at grid point = sum over polar points of (kernel_value * interp_weight) - M_grid_single = zeros(PATCH_DIM, PATCH_DIM) - M_grid_double = zeros(PATCH_DIM, PATCH_DIM) - for ir in 1:RAD_DIM, ia in 1:ANG_DIM - ycorner = I_G2P[ir, ia, :] - for i0 in 1:INTERP_ORDER, i1 in 1:INTERP_ORDER - M_grid_single[ycorner[1]+i0, ycorner[2]+i1] += M_G2P[ir, ia, i0, i1] * M_polar_single[ir, ia] - M_grid_double[ycorner[1]+i0, ycorner[2]+i1] += M_G2P[ir, ia, i0, i1] * M_polar_double[ir, ia] - end - end + # Distribute polar singular corrections back to Cartesian grid using sparse matrix + # grid = P2G * polar (maps Npolar → Ngrid) + M_grid_single = reshape(P2G * vec(M_polar_single), PATCH_DIM, PATCH_DIM) + M_grid_double = reshape(P2G * vec(M_polar_double), PATCH_DIM, PATCH_DIM) # Compute remaining far-field POU contribution and near-field polar quadrature result # We include this region in the far-field trapezoidal rule, so use Gpou = -χ here to get 1-χ From 11a5b934091cfca8f15d083864b44733432f6cfb Mon Sep 17 00:00:00 2001 From: Jake Halpern Date: Fri, 30 Jan 2026 08:45:03 -0500 Subject: [PATCH 19/31] VACUUM - WIP - adding aspect ratio check and some scans --- .../vacuum_3D_convergence_benchmark.jl | 407 ++++++++++++++++++ .../vacuum_3D_speed_benchmark.jl | 317 ++++++++++++++ src/Vacuum/Vacuum.jl | 10 +- src/Vacuum/VacuumStructs.jl | 15 +- 4 files changed, 740 insertions(+), 9 deletions(-) create mode 100644 benchmarks/Solovev_ideal_example/vacuum_3D_convergence_benchmark.jl create mode 100644 benchmarks/Solovev_ideal_example/vacuum_3D_speed_benchmark.jl diff --git a/benchmarks/Solovev_ideal_example/vacuum_3D_convergence_benchmark.jl b/benchmarks/Solovev_ideal_example/vacuum_3D_convergence_benchmark.jl new file mode 100644 index 00000000..705a71ce --- /dev/null +++ b/benchmarks/Solovev_ideal_example/vacuum_3D_convergence_benchmark.jl @@ -0,0 +1,407 @@ +""" +Convergence benchmark for compute_vacuum_response_3D + +This script tests how the wv matrix converges with grid resolution for the Solovev +equilibrium. It initializes the equilibrium from sol.toml and constructs vacuum +inputs following the logic in Free.jl. + +Scans performed: +1. 3D convergence: mtheta varying with nzeta/mtheta = 1.5 fixed +2. 2D convergence: mtheta varying (standard 2D vacuum response) +3. 2D vs 3D comparison at matched mtheta values + +Outputs: +- Convergence plots +- Runtime scaling data +- Relative error metrics vs. high-resolution reference +""" + +using Pkg +Pkg.activate("$(@__DIR__)/../..") + +using JPEC +using JPEC.Vacuum +using JPEC.Equilibrium +using JPEC: Spl +using LinearAlgebra +using Printf +using Plots +using TOML + +default(; markersize=4, linewidth=2) + +# ============================================================================ +# Helper functions +# ============================================================================ + +""" +Compute vacuum inputs from equilibrium, following Free.jl logic. +""" +function compute_vacuum_inputs_from_equil(equil::PlasmaEquilibrium, mthvac::Int, mlow::Int, mpert::Int, n::Int) + # Get boundary flux value (psilim = 1.0 for normalized coordinates) + psilim = equil.config.control.psihigh + + # Allocate arrays + mtheta_eq = equil.config.control.mtheta + 1 + R = zeros(Float64, mtheta_eq) + Z = zeros(Float64, mtheta_eq) + ν = zeros(Float64, mtheta_eq) + r_minor = zeros(Float64, mtheta_eq) + θ_SFL = zeros(Float64, mtheta_eq) + + # Compute geometric quantities on plasma boundary + for (i, θ) in enumerate(equil.rzphi.ys) + f = Spl.bicube_eval!(equil.rzphi, psilim, θ) + r_minor[i] = sqrt(f[1]) + θ_SFL[i] = 2π * (θ + f[2]) # f[2] = λ(ψ, θ) / 2π + ν[i] = f[3] + end + + # Compute R and Z on straight-fieldline θ grid + R .= equil.ro .+ r_minor .* cos.(θ_SFL) + Z .= equil.zo .+ r_minor .* sin.(θ_SFL) + + return Vacuum.VacuumInput(; + r=reverse(R), + z=reverse(Z), + ν=reverse(ν), + mlow=mlow, + mpert=mpert, + n=n, + mtheta=mthvac, + force_wv_symmetry=true + ) +end + +""" +Compute accuracy metrics between two wv matrices. +Returns: (relative_frobenius_norm, max_absolute_error, max_eigenvalue_error) +""" +function compute_accuracy_metrics(wv_test::Matrix, wv_ref::Matrix) + diff = wv_test - wv_ref + frobenius_norm = norm(diff) + relative_frobenius = frobenius_norm / norm(wv_ref) + max_abs_error = maximum(abs.(diff)) + + # Eigenvalue comparison + ev_test = maximum(real.(eigvals(wv_test))) + ev_ref = maximum(real.(eigvals(wv_ref))) + ev_rel_error = abs(ev_test - ev_ref) / abs(ev_ref) + + return relative_frobenius, max_abs_error, ev_rel_error +end + +# ============================================================================ +# Setup equilibrium and parameters +# ============================================================================ + +println("="^70) +println("3D VACUUM RESPONSE CONVERGENCE BENCHMARK") +println("="^70) + +# Load equilibrium from Solovev example +example_dir = joinpath(@__DIR__, "../../examples/Solovev_ideal_example") +equil = Equilibrium.setup_equilibrium(joinpath(example_dir, "equil.toml")) + +# Load DCON settings to get mode numbers +dcon_inputs = TOML.parsefile(joinpath(example_dir, "dcon.toml")) +wall_inputs = dcon_inputs["WALL"] +wall_settings = Vacuum.WallShapeSettings(; (Symbol(k) => v for (k, v) in wall_inputs)...) + +# Determine mode numbers (following Main.jl logic) +nlow = dcon_inputs["DCON_CONTROL"]["nn_low"] +nhigh = dcon_inputs["DCON_CONTROL"]["nn_high"] +npert = nhigh - nlow + 1 + +# Poloidal mode range (simplified version of Main.jl logic) +mlow = trunc(Int, min(nlow * equil.params.qmin, 0)) - 4 +mhigh = trunc(Int, nhigh * equil.params.qmax) +mpert = mhigh - mlow + 1 + +println("\nEquilibrium loaded from: $example_dir") +println(" Major radius R0 = $(equil.ro)") +println(" q on axis = $(equil.params.qmin)") +println(" q at edge = $(equil.params.qmax)") +println("\nMode numbers:") +println(" n range: $nlow to $nhigh (npert = $npert)") +println(" m range: $mlow to $mhigh (mpert = $mpert)") +println(" Total modes: $(mpert * npert)") + +# ============================================================================ +# Convergence scan parameters +# ============================================================================ + +# Fixed aspect ratio for 3D: nzeta/mtheta = 1.5 +aspect_ratio_grid = 1.5 + +# Mtheta values chosen to give total grid points from ~300 to ~30000 +# Total points = mtheta * nzeta = mtheta * (1.5 * mtheta) = 1.5 * mtheta^2 +# mtheta=14 -> 294 points, mtheta=140 -> 29400 points +mtheta_values = [14, 20, 28, 40, 56, 80] +nzeta_values = [round(Int, aspect_ratio_grid * m) for m in mtheta_values] +total_points = [m * n for (m, n) in zip(mtheta_values, nzeta_values)] + +# Reference resolution (high resolution) +mtheta_ref = 112 +nzeta_ref = round(Int, aspect_ratio_grid * mtheta_ref) + +println("\n" * "="^70) +println("SCAN PARAMETERS") +println("="^70) +println(" Grid aspect ratio (nzeta/mtheta): $aspect_ratio_grid") +println(" Mtheta values: $mtheta_values") +println(" Nzeta values: $nzeta_values") +println(" Total points: $total_points") +println(" Reference: mtheta=$mtheta_ref, nzeta=$nzeta_ref ($(mtheta_ref * nzeta_ref) points)") + +# ============================================================================ +# Compute reference solutions (3D and 2D) +# ============================================================================ + +println("\n" * "="^70) +println("COMPUTING REFERENCE SOLUTIONS") +println("="^70) + +# 3D reference +println("\n3D Reference (mtheta=$mtheta_ref, nzeta=$nzeta_ref)...") +vac_inputs_2D_ref = compute_vacuum_inputs_from_equil(equil, mtheta_ref, mlow, mpert, nlow) +vac_inputs_3D_ref = Vacuum.VacuumInput3D(vac_inputs_2D_ref, nzeta_ref, nlow, npert) + +t_ref_3D = @elapsed begin + wv_ref_3D, _, _, _ = Vacuum.compute_vacuum_response_3D(vac_inputs_3D_ref, wall_settings) +end +println(" Time: $(@sprintf "%.2f" t_ref_3D) s") +println(" wv matrix size: $(size(wv_ref_3D))") +println(" Max eigenvalue: $(@sprintf "%.6e" maximum(real.(eigvals(wv_ref_3D))))") + +# 2D reference (use same mtheta as 3D reference) +println("\n2D Reference (mtheta=$mtheta_ref)...") +t_ref_2D = @elapsed begin + wv_ref_2D, _, _ = Vacuum.compute_vacuum_response(vac_inputs_2D_ref, wall_settings) +end +println(" Time: $(@sprintf "%.2f" t_ref_2D) s") +println(" wv matrix size: $(size(wv_ref_2D))") +println(" Max eigenvalue: $(@sprintf "%.6e" maximum(real.(eigvals(wv_ref_2D))))") + +# ============================================================================ +# 3D Convergence scan (fixed aspect ratio) +# ============================================================================ + +println("\n" * "="^70) +println("3D CONVERGENCE SCAN (nzeta/mtheta = $aspect_ratio_grid)") +println("="^70) + +rel_errors_3D = Float64[] +ev_errors_3D = Float64[] +times_3D = Float64[] + +for (i, mth) in enumerate(mtheta_values) + nz = nzeta_values[i] + print(" mtheta=$mth, nzeta=$nz ($(mth*nz) pts) ... ") + + local vac_inputs_2D = compute_vacuum_inputs_from_equil(equil, mth, mlow, mpert, nlow) + local vac_inputs_3D = Vacuum.VacuumInput3D(vac_inputs_2D, nz, nlow, npert) + + t = @elapsed begin + wv, _, _, _ = Vacuum.compute_vacuum_response_3D(vac_inputs_3D, wall_settings) + end + + rel_err, _, ev_err = compute_accuracy_metrics(wv, wv_ref_3D) + + push!(rel_errors_3D, rel_err) + push!(ev_errors_3D, ev_err) + push!(times_3D, t) + + println(@sprintf "rel_err=%.2e, ev_err=%.2e, time=%.2fs" rel_err ev_err t) +end + +# ============================================================================ +# 2D Convergence scan (same mtheta values) +# ============================================================================ + +println("\n" * "="^70) +println("2D CONVERGENCE SCAN") +println("="^70) + +rel_errors_2D = Float64[] +ev_errors_2D = Float64[] +times_2D = Float64[] + +for mth in mtheta_values + print(" mtheta=$mth ... ") + + local vac_inputs_2D = compute_vacuum_inputs_from_equil(equil, mth, mlow, mpert, nlow) + + t = @elapsed begin + wv, _, _ = Vacuum.compute_vacuum_response(vac_inputs_2D, wall_settings) + end + + rel_err, _, ev_err = compute_accuracy_metrics(wv, wv_ref_2D) + + push!(rel_errors_2D, rel_err) + push!(ev_errors_2D, ev_err) + push!(times_2D, t) + + println(@sprintf "rel_err=%.2e, ev_err=%.2e, time=%.2fs" rel_err ev_err t) +end + +# ============================================================================ +# 2D vs 3D comparison (compute difference at each mtheta) +# ============================================================================ + +println("\n" * "="^70) +println("2D vs 3D COMPARISON (at matched mtheta)") +println("="^70) + +rel_errors_2D_vs_3D = Float64[] +ev_errors_2D_vs_3D = Float64[] + +for (i, mth) in enumerate(mtheta_values) + nz = nzeta_values[i] + print(" mtheta=$mth ... ") + + local vac_inputs_2D = compute_vacuum_inputs_from_equil(equil, mth, mlow, mpert, nlow) + local vac_inputs_3D = Vacuum.VacuumInput3D(vac_inputs_2D, nz, nlow, npert) + + # Compute both 2D and 3D + wv_2D, _, _ = Vacuum.compute_vacuum_response(vac_inputs_2D, wall_settings) + wv_3D, _, _, _ = Vacuum.compute_vacuum_response_3D(vac_inputs_3D, wall_settings) + + # Compare 2D block against corresponding 3D block + # For n=nlow, the 2D wv corresponds to the first mpert×mpert block of the 3D wv + wv_3D_block = wv_3D[1:mpert, 1:mpert] + + rel_err = norm(wv_2D - wv_3D_block) / norm(wv_2D) + ev_2D = maximum(real.(eigvals(wv_2D))) + ev_3D = maximum(real.(eigvals(wv_3D_block))) + ev_err = abs(ev_2D - ev_3D) / abs(ev_2D) + + push!(rel_errors_2D_vs_3D, rel_err) + push!(ev_errors_2D_vs_3D, ev_err) + + println(@sprintf "rel_err=%.2e, ev_err=%.2e" rel_err ev_err) +end + +# ============================================================================ +# Create plots +# ============================================================================ + +println("\n" * "="^70) +println("CREATING PLOTS") +println("="^70) + +# Plot 1: 3D convergence vs total grid points +p1 = plot(total_points, rel_errors_3D; + label="Relative Frobenius Error", + marker=:circle, + xlabel="Total grid points (mtheta × nzeta)", + ylabel="Relative Error", + title="3D Vacuum Response Convergence", + legend=:topright, + yscale=:log10, + xscale=:log10, + minorgrid=true, + framestyle=:box) +plot!(p1, total_points, ev_errors_3D; + label="Max Eigenvalue Error", + marker=:square) + +# Plot 2: 2D convergence vs mtheta +p2 = plot(mtheta_values, rel_errors_2D; + label="Relative Frobenius Error", + marker=:circle, + xlabel="mtheta (poloidal points)", + ylabel="Relative Error", + title="2D Vacuum Response Convergence", + legend=:topright, + yscale=:log10, + xscale=:log10, + minorgrid=true, + framestyle=:box) +plot!(p2, mtheta_values, ev_errors_2D; + label="Max Eigenvalue Error", + marker=:square) + +# Plot 3: 2D vs 3D comparison vs mtheta +p3 = plot(mtheta_values, rel_errors_2D_vs_3D; + label="2D vs 3D Rel. Error", + marker=:circle, + xlabel="mtheta (poloidal points)", + ylabel="Relative Difference (2D vs 3D)", + title="2D vs 3D Comparison (nzeta/mtheta = $aspect_ratio_grid)", + legend=:topright, + yscale=:log10, + xscale=:log10, + minorgrid=true, + framestyle=:box) +plot!(p3, mtheta_values, ev_errors_2D_vs_3D; + label="Eigenvalue Rel. Diff.", + marker=:square) + +# Plot 4: Runtime comparison +p4 = plot(total_points, times_3D; + label="3D (vs total points)", + marker=:circle, + xlabel="Grid points", + ylabel="Runtime (s)", + title="Runtime Scaling", + legend=:topleft, + xscale=:log10, + yscale=:log10, + minorgrid=true, + framestyle=:box) +plot!(p4, mtheta_values, times_2D; + label="2D (vs mtheta)", + marker=:square) + +# Add O(N^2) reference lines +N_ref_3D = total_points +t_ref_scale_3D = times_3D[end] * (N_ref_3D ./ N_ref_3D[end]) .^ 2 +plot!(p4, N_ref_3D, t_ref_scale_3D; label="O(N²)", linestyle=:dash, color=:gray) + +# Combined plot +p_combined = plot(p1, p2, p3, p4; layout=(2, 2), size=(1000, 800)) + +# Save plots +savefig(p1, joinpath(@__DIR__, "vacuum_3D_convergence_vs_gridpoints.pdf")) +savefig(p2, joinpath(@__DIR__, "vacuum_2D_convergence_vs_mtheta.pdf")) +savefig(p3, joinpath(@__DIR__, "vacuum_2D_vs_3D_comparison.pdf")) +savefig(p_combined, joinpath(@__DIR__, "vacuum_convergence_combined.pdf")) + +println("Plots saved to $(joinpath(@__DIR__, "vacuum_*.pdf"))") + +# Display plots +display(p_combined) + +# ============================================================================ +# Summary tables +# ============================================================================ + +println("\n" * "="^70) +println("SUMMARY") +println("="^70) + +println("\n3D Convergence (nzeta/mtheta = $aspect_ratio_grid):") +println(" mtheta | nzeta | Total Pts | Rel Error | EV Error | Time (s)") +println(" " * "-"^65) +for i in eachindex(mtheta_values) + @printf " %6d | %5d | %9d | %.2e | %.2e | %7.2f\n" mtheta_values[i] nzeta_values[i] total_points[i] rel_errors_3D[i] ev_errors_3D[i] times_3D[i] +end + +println("\n2D Convergence:") +println(" mtheta | Rel Error | EV Error | Time (s)") +println(" " * "-"^50) +for i in eachindex(mtheta_values) + @printf " %6d | %.2e | %.2e | %7.2f\n" mtheta_values[i] rel_errors_2D[i] ev_errors_2D[i] times_2D[i] +end + +println("\n2D vs 3D Comparison:") +println(" mtheta | Rel Diff | EV Diff") +println(" " * "-"^40) +for i in eachindex(mtheta_values) + @printf " %6d | %.2e | %.2e\n" mtheta_values[i] rel_errors_2D_vs_3D[i] ev_errors_2D_vs_3D[i] +end + +println("\n" * "="^70) +println("BENCHMARK COMPLETE") +println("="^70) diff --git a/benchmarks/Solovev_ideal_example/vacuum_3D_speed_benchmark.jl b/benchmarks/Solovev_ideal_example/vacuum_3D_speed_benchmark.jl new file mode 100644 index 00000000..df9dc403 --- /dev/null +++ b/benchmarks/Solovev_ideal_example/vacuum_3D_speed_benchmark.jl @@ -0,0 +1,317 @@ +""" +Speed and allocations benchmark for compute_vacuum_response_3D + +This script measures runtime and memory allocations as a function of grid size +for the 3D vacuum response calculation. Uses fixed aspect ratio nzeta/mtheta. + +Outputs: +- Runtime scaling vs grid size +- Allocation scaling vs grid size +- Breakdown of time spent in key functions +""" + +using Pkg +Pkg.activate("$(@__DIR__)/../..") + +using JPEC +using JPEC.Vacuum +using JPEC.Equilibrium +using JPEC: Spl +using LinearAlgebra +using Printf +using Plots +using TOML + +default(; markersize=4, linewidth=2) + +# ============================================================================ +# Helper functions +# ============================================================================ + +""" +Compute vacuum inputs from equilibrium, following Free.jl logic. +""" +function compute_vacuum_inputs_from_equil(equil::PlasmaEquilibrium, mthvac::Int, mlow::Int, mpert::Int, n::Int) + psilim = equil.config.control.psihigh + mtheta_eq = equil.config.control.mtheta + 1 + R = zeros(Float64, mtheta_eq) + Z = zeros(Float64, mtheta_eq) + ν = zeros(Float64, mtheta_eq) + r_minor = zeros(Float64, mtheta_eq) + θ_SFL = zeros(Float64, mtheta_eq) + + for (i, θ) in enumerate(equil.rzphi.ys) + f = Spl.bicube_eval!(equil.rzphi, psilim, θ) + r_minor[i] = sqrt(f[1]) + θ_SFL[i] = 2π * (θ + f[2]) + ν[i] = f[3] + end + + R .= equil.ro .+ r_minor .* cos.(θ_SFL) + Z .= equil.zo .+ r_minor .* sin.(θ_SFL) + + return Vacuum.VacuumInput(; + r=reverse(R), + z=reverse(Z), + ν=reverse(ν), + mlow=mlow, + mpert=mpert, + n=n, + mtheta=mthvac, + force_wv_symmetry=true + ) +end + +""" +Format bytes as human-readable string. +""" +function format_bytes(bytes::Integer) + if bytes < 1024 + return @sprintf "%d B" bytes + elseif bytes < 1024^2 + return @sprintf "%.2f KB" bytes / 1024 + elseif bytes < 1024^3 + return @sprintf "%.2f MB" bytes / 1024^2 + else + return @sprintf "%.2f GB" bytes / 1024^3 + end +end + +# ============================================================================ +# Setup equilibrium and parameters +# ============================================================================ + +println("="^70) +println("3D VACUUM RESPONSE SPEED/ALLOCATIONS BENCHMARK") +println("="^70) + +# Load equilibrium from Solovev example +example_dir = joinpath(@__DIR__, "../../examples/Solovev_ideal_example") +equil = Equilibrium.setup_equilibrium(joinpath(example_dir, "equil.toml")) + +# Load DCON settings to get mode numbers +dcon_inputs = TOML.parsefile(joinpath(example_dir, "dcon.toml")) +wall_inputs = dcon_inputs["WALL"] +wall_settings = Vacuum.WallShapeSettings(; (Symbol(k) => v for (k, v) in wall_inputs)...) + +# Determine mode numbers (following Main.jl logic) +nlow = dcon_inputs["DCON_CONTROL"]["nn_low"] +nhigh = dcon_inputs["DCON_CONTROL"]["nn_high"] +npert = nhigh - nlow + 1 + +# Poloidal mode range +mlow = trunc(Int, min(nlow * equil.params.qmin, 0)) - 4 +mhigh = trunc(Int, nhigh * equil.params.qmax) +mpert = mhigh - mlow + 1 + +println("\nEquilibrium loaded from: $example_dir") +println(" Major radius R0 = $(equil.ro)") +println(" q on axis = $(equil.params.qmin)") +println(" q at edge = $(equil.params.qmax)") +println("\nMode numbers:") +println(" n range: $nlow to $nhigh (npert = $npert)") +println(" m range: $mlow to $mhigh (mpert = $mpert)") +println(" Total modes: $(mpert * npert)") + +# ============================================================================ +# Benchmark parameters +# ============================================================================ + +# Fixed aspect ratio for 3D: nzeta/mtheta = 1.5 +aspect_ratio_grid = 1.5 + +# Grid sizes to test +mtheta_values = [16, 32, 48, 64, 96] # Small test set +nzeta_values = [round(Int, aspect_ratio_grid * m) for m in mtheta_values] +total_points = [m * n for (m, n) in zip(mtheta_values, nzeta_values)] + +println("\n" * "="^70) +println("BENCHMARK PARAMETERS") +println("="^70) +println(" Grid aspect ratio (nzeta/mtheta): $aspect_ratio_grid") +println(" Mtheta values: $mtheta_values") +println(" Nzeta values: $nzeta_values") +println(" Total points: $total_points") + +# ============================================================================ +# Warmup run (compile everything) +# ============================================================================ + +println("\n" * "="^70) +println("WARMUP RUN (compiling)") +println("="^70) + +mth_warmup = mtheta_values[1] +nz_warmup = nzeta_values[1] +print(" Running warmup with mtheta=$mth_warmup, nzeta=$nz_warmup ... ") + +vac_inputs_2D_warmup = compute_vacuum_inputs_from_equil(equil, mth_warmup, mlow, mpert, nlow) +vac_inputs_3D_warmup = Vacuum.VacuumInput3D(vac_inputs_2D_warmup, nz_warmup, nlow, npert) +Vacuum.compute_vacuum_response_3D(vac_inputs_3D_warmup, wall_settings) +println("done") + +# ============================================================================ +# Speed and allocation benchmark +# ============================================================================ + +println("\n" * "="^70) +println("3D SPEED/ALLOCATION BENCHMARK (nzeta/mtheta = $aspect_ratio_grid)") +println("="^70) + +times_3D = Float64[] +allocs_3D = Int[] +alloc_bytes_3D = Int[] + +for (i, mth) in enumerate(mtheta_values) + nz = nzeta_values[i] + print(" mtheta=$mth, nzeta=$nz ($(mth*nz) pts) ... ") + + local vac_inputs_2D = compute_vacuum_inputs_from_equil(equil, mth, mlow, mpert, nlow) + local vac_inputs_3D = Vacuum.VacuumInput3D(vac_inputs_2D, nz, nlow, npert) + + # Run with allocation tracking + stats = @timed Vacuum.compute_vacuum_response_3D(vac_inputs_3D, wall_settings) + + push!(times_3D, stats.time) + push!(allocs_3D, Base.gc_alloc_count(stats.gcstats)) + push!(alloc_bytes_3D, stats.bytes) + + println(@sprintf "time=%.3fs, allocs=%d, bytes=%s" stats.time Base.gc_alloc_count(stats.gcstats) format_bytes(stats.bytes)) +end + +# ============================================================================ +# Create plots +# ============================================================================ + +println("\n" * "="^70) +println("CREATING PLOTS") +println("="^70) + +# Plot 1: Runtime scaling +p1 = plot(total_points, times_3D; + label="Measured", + marker=:circle, + xlabel="Total grid points (mtheta × nzeta)", + ylabel="Runtime (s)", + title="3D Vacuum Runtime Scaling", + legend=:topleft, + xscale=:log10, + yscale=:log10, + minorgrid=true, + framestyle=:box) + +# Add O(N²) reference line +if length(times_3D) >= 2 + N_ref = total_points + t_ref_scale = times_3D[end] * (N_ref ./ N_ref[end]) .^ 2 + plot!(p1, N_ref, t_ref_scale; label="O(N²)", linestyle=:dash, color=:gray) +end + +# Plot 2: Allocation count scaling +p2 = plot(total_points, allocs_3D; + label="Allocation count", + marker=:circle, + xlabel="Total grid points (mtheta × nzeta)", + ylabel="Number of allocations", + title="Allocation Count Scaling", + legend=:topleft, + xscale=:log10, + yscale=:log10, + minorgrid=true, + framestyle=:box) + +# Add O(N) and O(N²) reference lines for allocations +if length(allocs_3D) >= 2 + N_ref = total_points + alloc_ref_N = allocs_3D[end] * (N_ref ./ N_ref[end]) + alloc_ref_N2 = allocs_3D[end] * (N_ref ./ N_ref[end]) .^ 2 + plot!(p2, N_ref, alloc_ref_N; label="O(N)", linestyle=:dash, color=:blue) + plot!(p2, N_ref, alloc_ref_N2; label="O(N²)", linestyle=:dash, color=:red) +end + +# Plot 3: Memory allocation scaling +p3 = plot(total_points, alloc_bytes_3D ./ 1e6; + label="Memory allocated", + marker=:circle, + xlabel="Total grid points (mtheta × nzeta)", + ylabel="Memory allocated (MB)", + title="Memory Allocation Scaling", + legend=:topleft, + xscale=:log10, + yscale=:log10, + minorgrid=true, + framestyle=:box) + +# Add O(N²) reference line for memory +if length(alloc_bytes_3D) >= 2 + N_ref = total_points + mem_ref_N2 = alloc_bytes_3D[end] * (N_ref ./ N_ref[end]) .^ 2 ./ 1e6 + plot!(p3, N_ref, mem_ref_N2; label="O(N²)", linestyle=:dash, color=:gray) +end + +# Plot 4: Allocations per grid point +allocs_per_point = allocs_3D ./ total_points +p4 = plot(total_points, allocs_per_point; + label="Allocs per grid point", + marker=:circle, + xlabel="Total grid points (mtheta × nzeta)", + ylabel="Allocations / grid point", + title="Allocations per Grid Point", + legend=:topright, + xscale=:log10, + minorgrid=true, + framestyle=:box) + +# Combined plot +p_combined = plot(p1, p2, p3, p4; layout=(2, 2), size=(1000, 800)) + +# Save plots +savefig(p1, joinpath(@__DIR__, "vacuum_3D_runtime_scaling.pdf")) +savefig(p2, joinpath(@__DIR__, "vacuum_3D_allocation_count_scaling.pdf")) +savefig(p3, joinpath(@__DIR__, "vacuum_3D_memory_scaling.pdf")) +savefig(p_combined, joinpath(@__DIR__, "vacuum_3D_speed_benchmark_combined.pdf")) + +println("Plots saved to $(joinpath(@__DIR__, "vacuum_3D_*.pdf"))") + +# Display plots +display(p_combined) + +# ============================================================================ +# Summary table +# ============================================================================ + +println("\n" * "="^70) +println("SUMMARY") +println("="^70) + +println("\n3D Speed/Allocation Benchmark (nzeta/mtheta = $aspect_ratio_grid):") +println(" mtheta | nzeta | Total Pts | Time (s) | Allocs | Memory") +println(" " * "-"^70) +for i in eachindex(mtheta_values) + @printf " %6d | %5d | %9d | %8.3f | %10d | %10s\n" mtheta_values[i] nzeta_values[i] total_points[i] times_3D[i] allocs_3D[i] format_bytes(alloc_bytes_3D[i]) +end + +# Compute scaling exponents if we have enough data points +if length(mtheta_values) >= 2 + println("\nScaling Analysis:") + + # Runtime scaling: t ∝ N^α + log_N = log.(total_points) + log_t = log.(times_3D) + α_time = (log_t[end] - log_t[1]) / (log_N[end] - log_N[1]) + println(@sprintf " Runtime scaling exponent: α = %.2f (expect ~2 for O(N²))" α_time) + + # Allocation scaling: allocs ∝ N^β + log_allocs = log.(allocs_3D) + β_allocs = (log_allocs[end] - log_allocs[1]) / (log_N[end] - log_N[1]) + println(@sprintf " Allocation count scaling exponent: β = %.2f" β_allocs) + + # Memory scaling: bytes ∝ N^γ + log_bytes = log.(alloc_bytes_3D) + γ_mem = (log_bytes[end] - log_bytes[1]) / (log_N[end] - log_N[1]) + println(@sprintf " Memory scaling exponent: γ = %.2f (expect ~2 for O(N²) matrices)" γ_mem) +end + +println("\n" * "="^70) +println("BENCHMARK COMPLETE") +println("="^70) diff --git a/src/Vacuum/Vacuum.jl b/src/Vacuum/Vacuum.jl index f9a8220f..03e29165 100644 --- a/src/Vacuum/Vacuum.jl +++ b/src/Vacuum/Vacuum.jl @@ -12,7 +12,6 @@ include("Vacuum3D.jl") export mscvac, set_dcon_params, VacuumInput, compute_vacuum_response, compute_vacuum_response_3D export compute_vacuum_field -export kernel! export WallShapeSettings # ====================================================================== @@ -228,10 +227,10 @@ function compute_vacuum_response(inputs::VacuumInput, wall_settings::WallShapeSe # Initialization and allocations (; mtheta, mpert, n, kernelsign, force_wv_symmetry) = inputs - plasma_surf = PlasmaGeometry(inputs) - wall = WallGeometry(inputs, plasma_surf, wall_settings) grad_green = zeros(2 * mtheta, 2 * mtheta) green_temp = zeros(mtheta, mtheta) + plasma_surf = PlasmaGeometry(inputs) + wall = WallGeometry(inputs, plasma_surf, wall_settings) # 𝒢ₗ(θⱼ) from Chance eq. 106-108. first mtheta rows are plasma as observer, second are wall # First mpert columns are real (cosine), second mpert are imaginary (sine) @@ -341,12 +340,11 @@ function compute_vacuum_response_3D(inputs::VacuumInput3D, wall_settings::WallSh (; mtheta, mpert, n, force_wv_symmetry, kernelsign, nzeta, npert) = inputs num_gridpoints = nzeta * mtheta num_modes = npert * mpert - + grad_green = zeros(num_gridpoints, num_gridpoints) # for walls, this is 2*mtheta x 2*mtheta + green_temp = zeros(num_gridpoints, num_gridpoints) # TODO: Currently only supports axisymmetric surfaces plasma_surf = PlasmaGeometry3D(inputs) wall = WallGeometry3D(inputs, plasma_surf, wall_settings) - grad_green = zeros(num_gridpoints, num_gridpoints) # for walls, this is 2*mtheta x 2*mtheta - green_temp = zeros(num_gridpoints, num_gridpoints) # 𝒢ₗ(θⱼ) from Chance eq. 106-108. first num_gridpoints rows are plasma as observer, second are wall # First num_modes columns are real (cosine), second num_modes are imaginary (sine) diff --git a/src/Vacuum/VacuumStructs.jl b/src/Vacuum/VacuumStructs.jl index 02c12e92..d6ba30fc 100644 --- a/src/Vacuum/VacuumStructs.jl +++ b/src/Vacuum/VacuumStructs.jl @@ -268,14 +268,13 @@ function PlasmaGeometry3D(inputs::VacuumInput3D) (; mtheta, nzeta, npert, nlow, mlow, mpert) = inputs num_gridpoints = mtheta * nzeta dθ = 2π / mtheta - dϕ = 2π / nzeta + dζ = 2π / nzeta θ_grid = range(; start=0, length=mtheta, step=dθ) - ϕ_grid = range(; start=0, length=nzeta, step=dϕ) + ϕ_grid = range(; start=0, length=nzeta, step=dζ) # Allocate output arrays r = zeros(num_gridpoints, 3) normal = zeros(num_gridpoints, 3) - dA = zeros(num_gridpoints) dr_dθ = zeros(num_gridpoints, 3) dr_dζ = zeros(num_gridpoints, 3) @@ -302,6 +301,16 @@ function PlasmaGeometry3D(inputs::VacuumInput3D) normal[idx, :] = cross(dr_dθ[idx, :], dr_dζ[idx, :]) end + # Warn if grid spacing is highly anisotropic + spacing_θ = sqrt(sum(abs2, dr_dθ) / size(dr_dθ, 1)) * dθ + spacing_ζ = sqrt(sum(abs2, dr_dζ) / size(dr_dζ, 1)) * dζ + aspect_ratio = max(spacing_θ, spacing_ζ) / min(spacing_θ, spacing_ζ) + @info "Average grid spacing: dθ=$(round(spacing_θ, digits=4)), dζ=$(round(spacing_ζ, digits=4)), aspect ratio=$(round(aspect_ratio, digits=2))" + if aspect_ratio > 2.0 + @warn "Grid spacing aspect ratio is $(round(aspect_ratio, digits=2)). " * + "Singular correction assumes roughly isotropic patches; accuracy may degrade for highly anisotropic grids." + end + # Precompute Fourier transform terms, sin(lθ - nν(θ) - nϕ) and cos(lθ - nν(θ) - nϕ) sin_mn_basis3D = zeros(num_gridpoints, mpert*npert) cos_mn_basis3D = zeros(num_gridpoints, mpert*npert) From 4f78be1bd96d46767ffd1cd12a02ac2d2c9e480f Mon Sep 17 00:00:00 2001 From: Jake Halpern Date: Fri, 30 Jan 2026 09:41:41 -0500 Subject: [PATCH 20/31] VACUUM - IMPROVEMENT - optimized kernels for scalar arithmetic, 0.5x allocations and 50% faster runtime --- src/Vacuum/Vacuum3D.jl | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/src/Vacuum/Vacuum3D.jl b/src/Vacuum/Vacuum3D.jl index 88b77008..4bfb0058 100644 --- a/src/Vacuum/Vacuum3D.jl +++ b/src/Vacuum/Vacuum3D.jl @@ -1,3 +1,5 @@ +const INV_4PI = 1.0 / (4π) + """ Precomputed data for singular correction quadrature following BIEST approach. Initialized once on first use. @@ -189,17 +191,24 @@ The single-layer kernel φ is the fundamental solution to Laplace's equation: - `Float64`: Kernel value φ(x_obs, x_src) """ -function laplace_single_layer(x_obs::Vector{Float64}, x_src::Vector{Float64})::Float64 +function laplace_single_layer(x_obs::Vector{Float64}, x_src::Vector{Float64}) # Single-layer kernel: 1/(4π r) - r = norm(x_obs - x_src) - return r < 1e-30 ? 0.0 : 1.0 / (4π * r) + @inbounds begin + dx = x_obs[1] - x_src[1] + dy = x_obs[2] - x_src[2] + dz = x_obs[3] - x_src[3] + end + r2 = dx*dx + dy*dy + dz*dz + r2 < 1e-30 && return 0.0 + return INV_4PI * inv(sqrt(r2)) end """ laplace_double_layer(x_obs::Vector{Float64}, x_src::Vector{Float64}, n_src::Vector{Float64}) -> Float64 Evaluate the Laplace double-layer (DxU) kernel between a point and a surface element. Returns -0.0 if the observation point coincides with the source point to avoid singularity. +0.0 if the observation point coincides with the source point to avoid singularity. Allocation-free +scalar arithmetic is used for maximum performance. The double-layer kernel K is the normal derivative of the fundamental solution: @@ -218,10 +227,21 @@ K(x_obs, x_src, n_src) = ∇_{x_src} φ · n̂_src - `Float64`: Kernel value K(x_obs, x_src, n_src) """ -function laplace_double_layer(x_obs::Vector{Float64}, x_src::Vector{Float64}, n_src::Vector{Float64})::Float64 +function laplace_double_layer(x_obs::Vector{Float64}, x_src::Vector{Float64}, n_src::Vector{Float64}) # Double-layer kernel: -1/(4π) * (r·n) / r³ - r = norm(x_obs - x_src) - return r < 1e-30 ? 0.0 : -dot(x_obs - x_src, n_src) / (4π * r^3) + @inbounds begin + dx = x_obs[1] - x_src[1] + dy = x_obs[2] - x_src[2] + dz = x_obs[3] - x_src[3] + nx = n_src[1] + ny = n_src[2] + nz = n_src[3] + end + r2 = dx*dx + dy*dy + dz*dz + r2 < 1e-30 && return 0.0 + rinv = inv(sqrt(r2)) + r3inv = rinv * rinv * rinv + return -(dx*nx + dy*ny + dz*nz) * (r3inv * INV_4PI) end """ From df753673a89485fa0a2e7c446596dd1284a21341 Mon Sep 17 00:00:00 2001 From: Jake Halpern Date: Fri, 30 Jan 2026 10:14:31 -0500 Subject: [PATCH 21/31] VACUUM - improvement - adding in-place functions and preallocation matrices in the kernels. 5% reduction in memory use and runtime. --- src/Vacuum/Vacuum3D.jl | 93 ++++++++++++++++++++++-------------------- 1 file changed, 48 insertions(+), 45 deletions(-) diff --git a/src/Vacuum/Vacuum3D.jl b/src/Vacuum/Vacuum3D.jl index 4bfb0058..a76f3acd 100644 --- a/src/Vacuum/Vacuum3D.jl +++ b/src/Vacuum/Vacuum3D.jl @@ -245,26 +245,22 @@ function laplace_double_layer(x_obs::Vector{Float64}, x_src::Vector{Float64}, n_ end """ - extract_patch(data, Nt, Np, t0, p0, PATCH_DIM) + extract_patch!(patch, data, Nt, Np, t0, p0, PATCH_DIM) Extract a PATCH_DIM × PATCH_DIM patch of data centered at (t0, p0) with periodic wrapping. # Arguments + - `patch`: Preallocated output array for data around the singular point (PATCH_DIM × PATCH_DIM × dof) - `data`: Source data array (can be coordinates, normals, or area elements) - `Nt, Np`: Grid dimensions (toroidal, poloidal) - `t0, p0`: Center indices (1-based) - `PATCH_DIM`: Patch size (must be odd) - -# Returns - - - `patch`: Extracted patch of data around the singular point (PATCH_DIM × PATCH_DIM × dof) """ -function extract_patch(data::Matrix{Float64}, idx_pol_center::Int, idx_tor_center::Int, npol::Int, ntor::Int, PATCH_DIM::Int) +function extract_patch!(patch::Array{Float64,3}, data::Matrix{Float64}, idx_pol_center::Int, idx_tor_center::Int, npol::Int, ntor::Int, PATCH_DIM::Int) + fill!(patch, 0.0) PATCH_RAD = (PATCH_DIM - 1) ÷ 2 - dof = size(data, 2) # Number of components (3 for coords, 1 for scalars) - patch = zeros(PATCH_DIM, PATCH_DIM, dof) for i in 1:PATCH_DIM, j in 1:PATCH_DIM # Enforce periodicity idx_pol = mod1(idx_pol_center - PATCH_RAD + i - 1, npol) @@ -272,55 +268,47 @@ function extract_patch(data::Matrix{Float64}, idx_pol_center::Int, idx_tor_cente # Copy data to the patch (for each dof) @views patch[i, j, :] .= data[idx_pol+npol*(idx_tor-1), :] end - return patch # (PATCH_DIM, PATCH_DIM, dof) end """ - interpolate_to_polar(patch, quad_data) + interpolate_to_polar!(polar_data, patch, quad_data) Interpolate Cartesian patch data to polar quadrature points using sparse matrix multiply. # Arguments + - `polar_data`: Preallocated output array for polar data (RAD_DIM × ANG_DIM × dof) - `patch`: Patch data (PATCH_DIM × PATCH_DIM × dof) - - `quad_data`: Precomputed quadrature data - -# Returns - - - `polar_data`: Interpolated data at polar points (RAD_DIM × ANG_DIM × dof) + - `P2G`: Sparse interpolation matrix """ -function interpolate_to_polar(patch::Array{Float64,3}, quad_data::SingularQuadratureData) - - (; P2G, RAD_DIM, ANG_DIM) = quad_data - dof = size(patch, 3) +function interpolate_to_polar!(polar_data::Array{Float64,3}, patch::Array{Float64,3}, P2G::SparseMatrixCSC{Float64,Int}) # Flatten patch to (Ngrid × dof), apply P2G' to get (Npolar × dof) - patch_flat = reshape(patch, :, dof) - polar_flat = P2G' * patch_flat - return reshape(polar_flat, RAD_DIM, ANG_DIM, dof) + fill!(polar_data, 0.0) + patch_flat = reshape(patch, :, size(patch, 3)) + mul!(reshape(polar_data, :, size(patch, 3)), P2G', patch_flat) end """ - compute_polar_normal(dr_dθ_polar, dr_dζ_polar) + compute_polar_normal!(n_polar, dr_dθ_polar, dr_dζ_polar) Compute normal vector (= ∂r/∂θ × ∂r/∂ζ) at polar quadrature points from interpolated tangent vectors. # Arguments + - `n_polar`: Preallocation unit normal vector at each polar point (RAD_DIM × ANG_DIM × 3) - `dr_dθ_polar`: Interpolated ∂r/∂θ at polar points (RAD_DIM × ANG_DIM × 3) - `dr_dζ_polar`: Interpolated ∂r/∂ζ at polar points (RAD_DIM × ANG_DIM × 3) - -# Returns - - - `n_polar`: Unit normal vector at each polar point (RAD_DIM × ANG_DIM × 3) """ -function compute_polar_normal(dr_dθ::Array{Float64,3}, dr_dζ::Array{Float64,3}) - - n_polar = similar(dr_dθ) - @views for ia in axes(dr_dθ, 2), ir in axes(dr_dθ, 1) - n_polar[ir, ia, :] .= cross(dr_dθ[ir, ia, :], dr_dζ[ir, ia, :]) +function compute_polar_normal!(n_polar::Array{Float64,3}, dr_dθ::Array{Float64,3}, dr_dζ::Array{Float64,3}) + + fill!(n_polar, 0.0) + # Inline cross product to avoid slice allocation + @inbounds for ia in axes(dr_dθ, 2), ir in axes(dr_dθ, 1) + n_polar[ir, ia, 1] = dr_dθ[ir, ia, 2] * dr_dζ[ir, ia, 3] - dr_dθ[ir, ia, 3] * dr_dζ[ir, ia, 2] + n_polar[ir, ia, 2] = dr_dθ[ir, ia, 3] * dr_dζ[ir, ia, 1] - dr_dθ[ir, ia, 1] * dr_dζ[ir, ia, 3] + n_polar[ir, ia, 3] = dr_dθ[ir, ia, 1] * dr_dζ[ir, ia, 2] - dr_dθ[ir, ia, 2] * dr_dζ[ir, ia, 1] end - return n_polar end """ @@ -363,13 +351,26 @@ function compute_3D_kernel_matrix!( fill!(grad_greenfunction, 0.0) fill!(greenfunction, 0.0) - # Initialize quadrature data (cached) + # Initialize quadrature data quad_data = get_singular_quadrature(PATCH_RAD, RAD_DIM, INTERP_ORDER) (; PATCH_DIM, PATCH_RAD, ANG_DIM, RAD_DIM, Ppou, Gpou, P2G) = quad_data @assert observer.mtheta ≥ PATCH_DIM @assert observer.nzeta ≥ PATCH_DIM dθdζ = (2π / observer.mtheta) * (2π / observer.nzeta) + # Allocate temporary arrays + r_patch = zeros(PATCH_DIM, PATCH_DIM, 3) + dr_dθ_patch = zeros(PATCH_DIM, PATCH_DIM, 3) + dr_dζ_patch = zeros(PATCH_DIM, PATCH_DIM, 3) + r_polar = zeros(RAD_DIM, ANG_DIM, 3) + dr_dθ_polar = zeros(RAD_DIM, ANG_DIM, 3) + dr_dζ_polar = zeros(RAD_DIM, ANG_DIM, 3) + n_polar = zeros(RAD_DIM, ANG_DIM, 3) + M_polar_single = zeros(RAD_DIM, ANG_DIM) + M_polar_double = zeros(RAD_DIM, ANG_DIM) + M_grid_single_flat = zeros(PATCH_DIM^2) + M_grid_double_flat = zeros(PATCH_DIM^2) + # Loop through observer points for j_obs in 1:observer.nzeta, i_obs in 1:observer.mtheta idx_obs = i_obs + (j_obs - 1) * observer.mtheta @@ -394,21 +395,21 @@ function compute_3D_kernel_matrix!( # NEAR FIELD: Polar quadrature with singular correction # ============================================================ # Extract patches of source data around the singular point (size = PATCH_DIM x PATCH_DIM x dof) - r_patch = extract_patch(source.r, i_obs, j_obs, source.mtheta, source.nzeta, PATCH_DIM) - dr_dθ_patch = extract_patch(source.dr_dθ, i_obs, j_obs, source.mtheta, source.nzeta, PATCH_DIM) - dr_dζ_patch = extract_patch(source.dr_dζ, i_obs, j_obs, source.mtheta, source.nzeta, PATCH_DIM) + extract_patch!(r_patch, source.r, i_obs, j_obs, source.mtheta, source.nzeta, PATCH_DIM) + extract_patch!(dr_dθ_patch, source.dr_dθ, i_obs, j_obs, source.mtheta, source.nzeta, PATCH_DIM) + extract_patch!(dr_dζ_patch, source.dr_dζ, i_obs, j_obs, source.mtheta, source.nzeta, PATCH_DIM) # Interpolate coordinates and tangent vectors to polar quadrature points - r_polar = interpolate_to_polar(r_patch, quad_data) - dr_dθ_polar = interpolate_to_polar(dr_dθ_patch, quad_data) - dr_dζ_polar = interpolate_to_polar(dr_dζ_patch, quad_data) + interpolate_to_polar!(r_polar, r_patch, P2G) + interpolate_to_polar!(dr_dθ_polar, dr_dθ_patch, P2G) + interpolate_to_polar!(dr_dζ_polar, dr_dζ_patch, P2G) # Compute normal vectors at polar points from interpolated tangent vectors - n_polar = compute_polar_normal(dr_dθ_polar, dr_dζ_polar) + compute_polar_normal!(n_polar, dr_dθ_polar, dr_dζ_polar) # Evaluate kernels at polar points with POU weighting - M_polar_single = zeros(RAD_DIM, ANG_DIM) - M_polar_double = zeros(RAD_DIM, ANG_DIM) + fill!(M_polar_single, 0.0); + fill!(M_polar_double, 0.0) for ia in 1:ANG_DIM, ir in 1:RAD_DIM # Evaluate kernels using recomputed normal r_src, n_src = r_polar[ir, ia, :], n_polar[ir, ia, :] @@ -422,8 +423,10 @@ function compute_3D_kernel_matrix!( # Distribute polar singular corrections back to Cartesian grid using sparse matrix # grid = P2G * polar (maps Npolar → Ngrid) - M_grid_single = reshape(P2G * vec(M_polar_single), PATCH_DIM, PATCH_DIM) - M_grid_double = reshape(P2G * vec(M_polar_double), PATCH_DIM, PATCH_DIM) + mul!(M_grid_single_flat, P2G, vec(M_polar_single)) + mul!(M_grid_double_flat, P2G, vec(M_polar_double)) + M_grid_single = reshape(M_grid_single_flat, PATCH_DIM, PATCH_DIM) + M_grid_double = reshape(M_grid_double_flat, PATCH_DIM, PATCH_DIM) # Compute remaining far-field POU contribution and near-field polar quadrature result # We include this region in the far-field trapezoidal rule, so use Gpou = -χ here to get 1-χ From c6f79afed3301694ca0f09441b02215a29680571 Mon Sep 17 00:00:00 2001 From: Jake Halpern Date: Fri, 30 Jan 2026 10:56:43 -0500 Subject: [PATCH 22/31] VACUUM - IMPROVEMENT - views for obs/src array slices + inbounds. 30% speedup, 8x reduction in memory. Adding multi-threading --- src/Vacuum/Vacuum3D.jl | 136 ++++++++++++++++++++++++++++------------- 1 file changed, 94 insertions(+), 42 deletions(-) diff --git a/src/Vacuum/Vacuum3D.jl b/src/Vacuum/Vacuum3D.jl index a76f3acd..77e3f9de 100644 --- a/src/Vacuum/Vacuum3D.jl +++ b/src/Vacuum/Vacuum3D.jl @@ -171,7 +171,7 @@ function get_singular_quadrature(PATCH_RAD::Int, RAD_DIM::Int, INTERP_ORDER::Int end """ - laplace_single_layer(x_obs::Vector{Float64}, x_src::Vector{Float64}) -> Float64 + laplace_single_layer(x_obs, x_src) -> Float64 Evaluate the Laplace single-layer (FxU) kernel between two 3D points. Returns 0.0 if the observation point coincides with the source point to avoid singularity. @@ -184,14 +184,14 @@ The single-layer kernel φ is the fundamental solution to Laplace's equation: # Arguments - - `x_obs::Vector{Float64}`: Observation point (3D Cartesian coordinates) - - `x_src::Vector{Float64}`: Source point (3D Cartesian coordinates) + - `x_obs`: Observation point (3D Cartesian coordinates, any AbstractVector) + - `x_src`: Source point (3D Cartesian coordinates, any AbstractVector) # Returns - `Float64`: Kernel value φ(x_obs, x_src) """ -function laplace_single_layer(x_obs::Vector{Float64}, x_src::Vector{Float64}) +function laplace_single_layer(x_obs::AbstractVector{<:Real}, x_src::AbstractVector{<:Real}) # Single-layer kernel: 1/(4π r) @inbounds begin dx = x_obs[1] - x_src[1] @@ -204,7 +204,7 @@ function laplace_single_layer(x_obs::Vector{Float64}, x_src::Vector{Float64}) end """ - laplace_double_layer(x_obs::Vector{Float64}, x_src::Vector{Float64}, n_src::Vector{Float64}) -> Float64 + laplace_double_layer(x_obs, x_src, n_src) -> Float64 Evaluate the Laplace double-layer (DxU) kernel between a point and a surface element. Returns 0.0 if the observation point coincides with the source point to avoid singularity. Allocation-free @@ -219,15 +219,15 @@ K(x_obs, x_src, n_src) = ∇_{x_src} φ · n̂_src # Arguments - - `x_obs::Vector{Float64}`: Observation point (3D Cartesian coordinates) - - `x_src::Vector{Float64}`: Source point on surface (3D Cartesian coordinates) - - `n_src::Vector{Float64}`: Outward UNIT normal at source point (must be normalized!) + - `x_obs`: Observation point (3D Cartesian coordinates, any AbstractVector) + - `x_src`: Source point on surface (3D Cartesian coordinates, any AbstractVector) + - `n_src`: Outward UNIT normal at source point (must be normalized!, any AbstractVector) # Returns - `Float64`: Kernel value K(x_obs, x_src, n_src) """ -function laplace_double_layer(x_obs::Vector{Float64}, x_src::Vector{Float64}, n_src::Vector{Float64}) +function laplace_double_layer(x_obs::AbstractVector{<:Real}, x_src::AbstractVector{<:Real}, n_src::AbstractVector{<:Real}) # Double-layer kernel: -1/(4π) * (r·n) / r³ @inbounds begin dx = x_obs[1] - x_src[1] @@ -259,14 +259,16 @@ Extract a PATCH_DIM × PATCH_DIM patch of data centered at (t0, p0) with periodi """ function extract_patch!(patch::Array{Float64,3}, data::Matrix{Float64}, idx_pol_center::Int, idx_tor_center::Int, npol::Int, ntor::Int, PATCH_DIM::Int) - fill!(patch, 0.0) PATCH_RAD = (PATCH_DIM - 1) ÷ 2 - for i in 1:PATCH_DIM, j in 1:PATCH_DIM + @inbounds for j in 1:PATCH_DIM, i in 1:PATCH_DIM # Enforce periodicity idx_pol = mod1(idx_pol_center - PATCH_RAD + i - 1, npol) idx_tor = mod1(idx_tor_center - PATCH_RAD + j - 1, ntor) - # Copy data to the patch (for each dof) - @views patch[i, j, :] .= data[idx_pol+npol*(idx_tor-1), :] + # Copy data to the patch using direct indexing (avoids view allocation) + src_idx = idx_pol + npol * (idx_tor - 1) + patch[i, j, 1] = data[src_idx, 1] + patch[i, j, 2] = data[src_idx, 2] + patch[i, j, 3] = data[src_idx, 3] end end @@ -312,10 +314,50 @@ function compute_polar_normal!(n_polar::Array{Float64,3}, dr_dθ::Array{Float64, end """ - compute_3D_kernel_matrix!(grad_greenfunction, greenfunction, observer, source; PATCH_RAD=3, RAD_DIM=12, INTERP_ORDER=6) +Thread-local workspace for `compute_3D_kernel_matrix!` to enable parallel execution. +Each thread gets its own workspace to avoid data races on temporary arrays. +""" +struct KernelWorkspace + r_patch::Array{Float64,3} + dr_dθ_patch::Array{Float64,3} + dr_dζ_patch::Array{Float64,3} + r_polar::Array{Float64,3} + dr_dθ_polar::Array{Float64,3} + dr_dζ_polar::Array{Float64,3} + n_polar::Array{Float64,3} + M_polar_single::Matrix{Float64} + M_polar_double::Matrix{Float64} + M_grid_single_flat::Vector{Float64} + M_grid_double_flat::Vector{Float64} +end + +""" + KernelWorkspace(PATCH_DIM, RAD_DIM, ANG_DIM) + +Create a new workspace with pre-allocated arrays for kernel matrix computation. +""" +function KernelWorkspace(PATCH_DIM::Int, RAD_DIM::Int, ANG_DIM::Int) + return KernelWorkspace( + zeros(PATCH_DIM, PATCH_DIM, 3), # r_patch + zeros(PATCH_DIM, PATCH_DIM, 3), # dr_dθ_patch + zeros(PATCH_DIM, PATCH_DIM, 3), # dr_dζ_patch + zeros(RAD_DIM, ANG_DIM, 3), # r_polar + zeros(RAD_DIM, ANG_DIM, 3), # dr_dθ_polar + zeros(RAD_DIM, ANG_DIM, 3), # dr_dζ_polar + zeros(RAD_DIM, ANG_DIM, 3), # n_polar + zeros(RAD_DIM, ANG_DIM), # M_polar_single + zeros(RAD_DIM, ANG_DIM), # M_polar_double + zeros(PATCH_DIM^2), # M_grid_single_flat + zeros(PATCH_DIM^2) # M_grid_double_flat + ) +end + +""" + compute_3D_kernel_matrix!(grad_greenfunction, greenfunction, observer, source; PATCH_RAD=3, RAD_DIM=15, INTERP_ORDER=6) Compute boundary integral kernel matrices for 3D geometries with the singular correction -algorithm from Malhotra et al. 2019. +algorithm from Malhotra et al. 2019. Uses multi-threading for parallel computation over +observer points. - Far regions: Rectangle rule with uniform weights (1/N) - Singular regions: Polar quadrature with partition-of-unity blending @@ -334,8 +376,13 @@ where each entry is φ(x_obs, x_src). - `PATCH_RAD`: Number of points adjacent to source point to treat as singular (default 3) + Total patch size in # of gridpoints = (2 * PATCH_RAD + 1) x (2 * PATCH_RAD + 1) - - `RAD_DIM`: Polar radial quadrature order (default 12). Angular order = 2 * RAD_DIM + - `RAD_DIM`: Polar radial quadrature order (default 15). Angular order = 2 * RAD_DIM - `INTERP_ORDER`: Lagrange interpolation order (default 6) + +# Threading + +This function automatically uses all available threads (`Threads.nthreads()`). +Start Julia with `julia -t auto` or set `JULIA_NUM_THREADS` to enable multi-threading. """ function compute_3D_kernel_matrix!( grad_greenfunction::Matrix{Float64}, @@ -358,33 +405,36 @@ function compute_3D_kernel_matrix!( @assert observer.nzeta ≥ PATCH_DIM dθdζ = (2π / observer.mtheta) * (2π / observer.nzeta) - # Allocate temporary arrays - r_patch = zeros(PATCH_DIM, PATCH_DIM, 3) - dr_dθ_patch = zeros(PATCH_DIM, PATCH_DIM, 3) - dr_dζ_patch = zeros(PATCH_DIM, PATCH_DIM, 3) - r_polar = zeros(RAD_DIM, ANG_DIM, 3) - dr_dθ_polar = zeros(RAD_DIM, ANG_DIM, 3) - dr_dζ_polar = zeros(RAD_DIM, ANG_DIM, 3) - n_polar = zeros(RAD_DIM, ANG_DIM, 3) - M_polar_single = zeros(RAD_DIM, ANG_DIM) - M_polar_double = zeros(RAD_DIM, ANG_DIM) - M_grid_single_flat = zeros(PATCH_DIM^2) - M_grid_double_flat = zeros(PATCH_DIM^2) - - # Loop through observer points - for j_obs in 1:observer.nzeta, i_obs in 1:observer.mtheta - idx_obs = i_obs + (j_obs - 1) * observer.mtheta - r_obs = observer.r[idx_obs, :] + # Allocate thread-local workspaces (one per thread) + nthreads = Threads.nthreads() + workspaces = [KernelWorkspace(PATCH_DIM, RAD_DIM, ANG_DIM) for _ in 1:nthreads] + + # Total number of observer points for linear indexing + n_obs = observer.mtheta * observer.nzeta + + # Parallel loop through observer points + Threads.@threads for idx_obs in 1:n_obs + # Get thread-local workspace + ws = workspaces[Threads.threadid()] + (; r_patch, dr_dθ_patch, dr_dζ_patch, r_polar, dr_dθ_polar, dr_dζ_polar, + n_polar, M_polar_single, M_polar_double, M_grid_single_flat, M_grid_double_flat) = ws + + # Convert linear index to 2D indices + i_obs = mod1(idx_obs, observer.mtheta) + j_obs = (idx_obs - 1) ÷ observer.mtheta + 1 + r_obs = @view observer.r[idx_obs, :] # ============================================================ # FAR FIELD: Trapezoidal rule for nonsingular source points # Note: kernels return zero for r_src = r_obs # ============================================================ - for j_src in 1:source.nzeta, i_src in 1:source.mtheta + @inbounds for j_src in 1:source.nzeta, i_src in 1:source.mtheta # Evaluate kernels at grid points idx_src = i_src + (j_src - 1) * source.mtheta - K_single = laplace_single_layer(r_obs, source.r[idx_src, :]) - K_double = laplace_double_layer(r_obs, source.r[idx_src, :], source.normal[idx_src, :]) + r_src = @view source.r[idx_src, :] + n_src = @view source.normal[idx_src, :] + K_single = laplace_single_layer(r_obs, r_src) + K_double = laplace_double_layer(r_obs, r_src, n_src) # Apply weights (periodic trapezoidal rule = constant weights) greenfunction[idx_obs, idx_src] = K_single * dθdζ @@ -410,9 +460,10 @@ function compute_3D_kernel_matrix!( # Evaluate kernels at polar points with POU weighting fill!(M_polar_single, 0.0); fill!(M_polar_double, 0.0) - for ia in 1:ANG_DIM, ir in 1:RAD_DIM - # Evaluate kernels using recomputed normal - r_src, n_src = r_polar[ir, ia, :], n_polar[ir, ia, :] + @inbounds for ia in 1:ANG_DIM, ir in 1:RAD_DIM + # Evaluate kernels using recomputed normal (use @view to avoid allocation) + r_src = @view r_polar[ir, ia, :] + n_src = @view n_polar[ir, ia, :] K_single = laplace_single_layer(r_obs, r_src) K_double = laplace_double_layer(r_obs, r_src, n_src) @@ -430,14 +481,15 @@ function compute_3D_kernel_matrix!( # Compute remaining far-field POU contribution and near-field polar quadrature result # We include this region in the far-field trapezoidal rule, so use Gpou = -χ here to get 1-χ - for j in 1:PATCH_DIM, i in 1:PATCH_DIM + @inbounds for j in 1:PATCH_DIM, i in 1:PATCH_DIM # Map back to global indices idx_pol = mod1(i_obs - PATCH_RAD + i - 1, source.mtheta) idx_tor = mod1(j_obs - PATCH_RAD + j - 1, source.nzeta) idx_src = idx_pol + source.mtheta * (idx_tor - 1) - # Remainder of far-field contribution on the singular grid: Gpou = -χ - r_src, n_src = source.r[idx_src, :], source.normal[idx_src, :] + # Remainder of far-field contribution on the singular grid: Gpou = -χ (use @view to avoid allocation) + r_src = @view source.r[idx_src, :] + n_src = @view source.normal[idx_src, :] far_single = laplace_single_layer(r_obs, r_src) * Gpou[i, j] * dθdζ far_double = laplace_double_layer(r_obs, r_src, n_src) * Gpou[i, j] * dθdζ From 05a20e18dbc2b46e31e1f5f33557203ecc201936 Mon Sep 17 00:00:00 2001 From: Jake Halpern Date: Fri, 30 Jan 2026 11:05:16 -0500 Subject: [PATCH 23/31] VACUUM - IMPROVEMENT - faster mod1 operation, removing allocations from list comprehension. Order of a few percent decrease in memory and runtime --- src/Vacuum/Vacuum3D.jl | 20 +++++++++++++++----- src/Vacuum/VacuumStructs.jl | 8 ++++++-- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/Vacuum/Vacuum3D.jl b/src/Vacuum/Vacuum3D.jl index 77e3f9de..caecbd95 100644 --- a/src/Vacuum/Vacuum3D.jl +++ b/src/Vacuum/Vacuum3D.jl @@ -1,5 +1,14 @@ const INV_4PI = 1.0 / (4π) +""" + periodic_wrap(x, n) -> Int + +Fast periodic wrapping for indices near the valid range [1, n]. +Equivalent to `mod1(x, n)` but avoids division for small offsets. +Only valid when `x` is within one period of the valid range (i.e., `1-n < x < 2n`). +""" +@inline periodic_wrap(x::Int, n::Int) = x < 1 ? x + n : (x > n ? x - n : x) + """ Precomputed data for singular correction quadrature following BIEST approach. Initialized once on first use. @@ -262,8 +271,8 @@ function extract_patch!(patch::Array{Float64,3}, data::Matrix{Float64}, idx_pol_ PATCH_RAD = (PATCH_DIM - 1) ÷ 2 @inbounds for j in 1:PATCH_DIM, i in 1:PATCH_DIM # Enforce periodicity - idx_pol = mod1(idx_pol_center - PATCH_RAD + i - 1, npol) - idx_tor = mod1(idx_tor_center - PATCH_RAD + j - 1, ntor) + idx_pol = periodic_wrap(idx_pol_center - PATCH_RAD + i - 1, npol) + idx_tor = periodic_wrap(idx_tor_center - PATCH_RAD + j - 1, ntor) # Copy data to the patch using direct indexing (avoids view allocation) src_idx = idx_pol + npol * (idx_tor - 1) patch[i, j, 1] = data[src_idx, 1] @@ -407,6 +416,7 @@ function compute_3D_kernel_matrix!( # Allocate thread-local workspaces (one per thread) nthreads = Threads.nthreads() + @info "Computing 3D kernel matrix with $nthreads threads..." workspaces = [KernelWorkspace(PATCH_DIM, RAD_DIM, ANG_DIM) for _ in 1:nthreads] # Total number of observer points for linear indexing @@ -458,7 +468,7 @@ function compute_3D_kernel_matrix!( compute_polar_normal!(n_polar, dr_dθ_polar, dr_dζ_polar) # Evaluate kernels at polar points with POU weighting - fill!(M_polar_single, 0.0); + fill!(M_polar_single, 0.0) fill!(M_polar_double, 0.0) @inbounds for ia in 1:ANG_DIM, ir in 1:RAD_DIM # Evaluate kernels using recomputed normal (use @view to avoid allocation) @@ -483,8 +493,8 @@ function compute_3D_kernel_matrix!( # We include this region in the far-field trapezoidal rule, so use Gpou = -χ here to get 1-χ @inbounds for j in 1:PATCH_DIM, i in 1:PATCH_DIM # Map back to global indices - idx_pol = mod1(i_obs - PATCH_RAD + i - 1, source.mtheta) - idx_tor = mod1(j_obs - PATCH_RAD + j - 1, source.nzeta) + idx_pol = periodic_wrap(i_obs - PATCH_RAD + i - 1, source.mtheta) + idx_tor = periodic_wrap(j_obs - PATCH_RAD + j - 1, source.nzeta) idx_src = idx_pol + source.mtheta * (idx_tor - 1) # Remainder of far-field contribution on the singular grid: Gpou = -χ (use @view to avoid allocation) diff --git a/src/Vacuum/VacuumStructs.jl b/src/Vacuum/VacuumStructs.jl index d6ba30fc..38b6fc21 100644 --- a/src/Vacuum/VacuumStructs.jl +++ b/src/Vacuum/VacuumStructs.jl @@ -296,8 +296,12 @@ function PlasmaGeometry3D(inputs::VacuumInput3D) # Compute tangent vectors, unit normals, and differential area elements via spline interpolation for (i, θ) in enumerate(θ_grid), (j, ϕ) in enumerate(ϕ_grid) idx = i + (j - 1) * mtheta - dr_dθ[idx, :] .= [Interpolations.gradient(itps[k], θ, ϕ)[1] for k in 1:3] - dr_dζ[idx, :] .= [Interpolations.gradient(itps[k], θ, ϕ)[2] for k in 1:3] + # Compute gradients directly to avoid list comprehension allocation + for k in 1:3 + g = Interpolations.gradient(itps[k], θ, ϕ) + dr_dθ[idx, k] = g[1] + dr_dζ[idx, k] = g[2] + end normal[idx, :] = cross(dr_dθ[idx, :], dr_dζ[idx, :]) end From 38fceb87de05df95eafcef79f586ed3a3ae39904 Mon Sep 17 00:00:00 2001 From: Jake Halpern Date: Tue, 3 Feb 2026 17:26:46 -0500 Subject: [PATCH 24/31] VACUUM - WIP - adding singular parameters as inputs, docstring cleanups, starting to add walls --- src/DCON/Free.jl | 2 +- src/Vacuum/Vacuum.jl | 4 +- src/Vacuum/Vacuum3D.jl | 187 +++++++++++++++++++++++------------------ 3 files changed, 107 insertions(+), 86 deletions(-) diff --git a/src/DCON/Free.jl b/src/DCON/Free.jl index 83c3b671..e81b5690 100644 --- a/src/DCON/Free.jl +++ b/src/DCON/Free.jl @@ -51,7 +51,7 @@ function free_run!(odet::OdeState, ctrl::DconControl, equil::Equilibrium.PlasmaE end # Compute 3D vacuum response matrix - vac_inputs = compute_vacuum_inputs(intr.psilim, 1, ctrl, equil, intr) + vac_inputs = compute_vacuum_inputs(intr.psilim, 1, ctrl, equil, intr) # n doesn't matter here vac_inputs_3D = Vacuum.VacuumInput3D(vac_inputs, ctrl.nzvac, intr.nlow, intr.npert) wv3D, _, _, _ = Vacuum.compute_vacuum_response_3D(vac_inputs_3D, intr.wall_settings) diff --git a/src/Vacuum/Vacuum.jl b/src/Vacuum/Vacuum.jl index 03e29165..9a0ffc08 100644 --- a/src/Vacuum/Vacuum.jl +++ b/src/Vacuum/Vacuum.jl @@ -334,7 +334,7 @@ It returns the relevant arrays: `wv`, `green_fourier`, `plasma_coords`, and `wal - `plasma_coords`: Cartesian coordinate array (mtheta * nzeta × 3) of the plasma surface - `wall_coords`: Cartesian coordinate array (mtheta * nzeta × 3) of the wall """ -function compute_vacuum_response_3D(inputs::VacuumInput3D, wall_settings::WallShapeSettings) +function compute_vacuum_response_3D(inputs::VacuumInput3D, wall_settings::WallShapeSettings; PATCH_RAD::Int=11, RAD_DIM::Int=20, INTERP_ORDER::Int=5) # Initialization and allocations (; mtheta, mpert, n, force_wv_symmetry, kernelsign, nzeta, npert) = inputs @@ -357,7 +357,7 @@ function compute_vacuum_response_3D(inputs::VacuumInput3D, wall_settings::WallSh !wall.nowall && error("No walls yet!") # DEBUG # Plasma–Plasma block - compute_3D_kernel_matrix!(grad_green, green_temp, plasma_surf, plasma_surf; INTERP_ORDER=6) + compute_3D_kernel_matrix!(grad_green, green_temp, plasma_surf, plasma_surf, PATCH_RAD, RAD_DIM, INTERP_ORDER) grad_green += I * 0.5 # Fourier transform plasma-plasma block diff --git a/src/Vacuum/Vacuum3D.jl b/src/Vacuum/Vacuum3D.jl index caecbd95..55538cc8 100644 --- a/src/Vacuum/Vacuum3D.jl +++ b/src/Vacuum/Vacuum3D.jl @@ -3,13 +3,15 @@ const INV_4PI = 1.0 / (4π) """ periodic_wrap(x, n) -> Int -Fast periodic wrapping for indices near the valid range [1, n]. +Inline function for fast periodic wrapping for indices near the valid range [1, n]. Equivalent to `mod1(x, n)` but avoids division for small offsets. Only valid when `x` is within one period of the valid range (i.e., `1-n < x < 2n`). """ @inline periodic_wrap(x::Int, n::Int) = x < 1 ? x + n : (x > n ? x - n : x) """ + SingularQuadratureData + Precomputed data for singular correction quadrature following BIEST approach. Initialized once on first use. @@ -26,6 +28,7 @@ Initialized once on first use. - `PATCH_RAD::Int`: Patch radius (number of points adjacent to source point treated as singular) - `ANG_DIM::Int`: Number of angular quadrature points - `RAD_DIM::Int`: Number of radial quadrature points + - `INTERP_ORDER::Int`: Lagrange interpolation order """ struct SingularQuadratureData qx::Vector{Float64} @@ -37,20 +40,14 @@ struct SingularQuadratureData PATCH_RAD::Int ANG_DIM::Int RAD_DIM::Int + INTERP_ORDER::Int end -# Global cache for quadrature data (initialized on first use) -const SINGULAR_QUAD_CACHE = Ref{Union{Nothing,SingularQuadratureData}}(nothing) - """ - init_singular_quadrature(PATCH_RAD::Int, RAD_DIM::Int, INTERP_ORDER::Int) + SingularQuadratureData(PATCH_RAD::Int, RAD_DIM::Int, INTERP_ORDER::Int) -Initialize quadrature points, weights, partition-of-unity functions, and -interpolation matrices for singular correction. Follows BIEST's approach. - -Conversion references: - - - Quadrature/Patch setup adapted from biest/singular_correction.hpp +Constructor which initializes quadrature points, weights, partition-of-unity functions, and +interpolation matrices for singular correction based on input parameters. Follows BIEST's approach. # Arguments @@ -62,7 +59,7 @@ Conversion references: - `SingularQuadratureData`: Precomputed quadrature data """ -function init_singular_quadrature(PATCH_RAD::Int, RAD_DIM::Int, INTERP_ORDER::Int) +function SingularQuadratureData(PATCH_RAD::Int, RAD_DIM::Int, INTERP_ORDER::Int) # Total size of square patch extracted around singular point (odd number: 2*PATCH_DIM0+1) PATCH_DIM = 2 * PATCH_RAD + 1 @@ -160,22 +157,33 @@ function init_singular_quadrature(PATCH_RAD::Int, RAD_DIM::Int, INTERP_ORDER::In # Assemble sparse interpolation matrix P2G = sparse(I_coo, J_coo, V_coo, Ngrid, Npolar) - return SingularQuadratureData(qx, qw, Gpou, Ppou, P2G, PATCH_DIM, PATCH_RAD, ANG_DIM, RAD_DIM) + return SingularQuadratureData(qx, qw, Gpou, Ppou, P2G, PATCH_DIM, PATCH_RAD, ANG_DIM, RAD_DIM, INTERP_ORDER) end +# Global cache for quadrature data (initialized on first use) +const SINGULAR_QUAD_CACHE = Ref{Union{Nothing,SingularQuadratureData}}(nothing) + """ get_singular_quadrature(PATCH_RAD::Int, RAD_DIM::Int, INTERP_ORDER::Int) -Get cached singular quadrature data, initializing if necessary. - -Conversion references: - - - Follows caching pattern used around FieldPeriodBIOp setup in biest/boundary_integ_op.hpp +Get cached singular quadrature data, initializing if necessary. Returns cached data +if parameters match the cached initialization; reinitializes if parameters differ. +This allows the user to change quadrature parameters between calls, but prevents +redundant reinitialization when parameters are unchanged. """ function get_singular_quadrature(PATCH_RAD::Int, RAD_DIM::Int, INTERP_ORDER::Int) - if isnothing(SINGULAR_QUAD_CACHE[]) - SINGULAR_QUAD_CACHE[] = init_singular_quadrature(PATCH_RAD, RAD_DIM, INTERP_ORDER) + + # Check if cache exists and parameters match + cached = SINGULAR_QUAD_CACHE[] + if !isnothing(cached) && + cached.PATCH_RAD == PATCH_RAD && + cached.RAD_DIM == RAD_DIM && + cached.INTERP_ORDER == INTERP_ORDER + return cached end + + # Reinitialize if parameters changed or cache is empty + SINGULAR_QUAD_CACHE[] = SingularQuadratureData(PATCH_RAD, RAD_DIM, INTERP_ORDER) return SINGULAR_QUAD_CACHE[] end @@ -211,7 +219,6 @@ function laplace_single_layer(x_obs::AbstractVector{<:Real}, x_src::AbstractVect r2 < 1e-30 && return 0.0 return INV_4PI * inv(sqrt(r2)) end - """ laplace_double_layer(x_obs, x_src, n_src) -> Float64 @@ -250,7 +257,7 @@ function laplace_double_layer(x_obs::AbstractVector{<:Real}, x_src::AbstractVect r2 < 1e-30 && return 0.0 rinv = inv(sqrt(r2)) r3inv = rinv * rinv * rinv - return -(dx*nx + dy*ny + dz*nz) * (r3inv * INV_4PI) + return -(dx*nx + dy*ny + dz*nz) * (INV_4PI * r3inv) end """ @@ -323,6 +330,8 @@ function compute_polar_normal!(n_polar::Array{Float64,3}, dr_dθ::Array{Float64, end """ + KernelWorkspace + Thread-local workspace for `compute_3D_kernel_matrix!` to enable parallel execution. Each thread gets its own workspace to avoid data races on temporary arrays. """ @@ -382,11 +391,13 @@ where each entry is φ(x_obs, x_src). - `greenfunction`: Single-layer kernel matrix (Nobs × Nsrc) filled in place - `observer`: Observer geometry (PlasmaGeometry3D) - `source`: Source geometry (PlasmaGeometry3D) - - `PATCH_RAD`: Number of points adjacent to source point to treat as singular (default 3) + - `PATCH_RAD`: Number of points adjacent to source point to treat as singular + Total patch size in # of gridpoints = (2 * PATCH_RAD + 1) x (2 * PATCH_RAD + 1) - - `RAD_DIM`: Polar radial quadrature order (default 15). Angular order = 2 * RAD_DIM - - `INTERP_ORDER`: Lagrange interpolation order (default 6) + - `RAD_DIM`: Polar radial quadrature order. Angular order = 2 * RAD_DIM + - `INTERP_ORDER`: Lagrange interpolation order + + + Must be ≤ (2 * PATCH_RAD + 1) # Threading @@ -398,15 +409,23 @@ function compute_3D_kernel_matrix!( greenfunction::Matrix{Float64}, observer::Union{PlasmaGeometry3D,WallGeometry3D}, source::Union{PlasmaGeometry3D,WallGeometry3D}; - PATCH_RAD::Int=3, - RAD_DIM::Int=15, - INTERP_ORDER::Int=6 + PATCH_RAD::Int, + RAD_DIM::Int, + INTERP_ORDER::Int ) # Zero out matrices - fill!(grad_greenfunction, 0.0) fill!(greenfunction, 0.0) + # Get block of grad_green matrix + col_index = (source isa PlasmaGeometry ? 1 : 2) + row_index = (observer isa PlasmaGeometry ? 1 : 2) + grad_greenfunction_block = view( + grad_greenfunction, + ((row_index-1)*mtheta+1):(row_index*mtheta), + ((col_index-1)*mtheta+1):(col_index*mtheta) + ) + # Initialize quadrature data quad_data = get_singular_quadrature(PATCH_RAD, RAD_DIM, INTERP_ORDER) (; PATCH_DIM, PATCH_RAD, ANG_DIM, RAD_DIM, Ppou, Gpou, P2G) = quad_data @@ -416,7 +435,6 @@ function compute_3D_kernel_matrix!( # Allocate thread-local workspaces (one per thread) nthreads = Threads.nthreads() - @info "Computing 3D kernel matrix with $nthreads threads..." workspaces = [KernelWorkspace(PATCH_DIM, RAD_DIM, ANG_DIM) for _ in 1:nthreads] # Total number of observer points for linear indexing @@ -448,64 +466,67 @@ function compute_3D_kernel_matrix!( # Apply weights (periodic trapezoidal rule = constant weights) greenfunction[idx_obs, idx_src] = K_single * dθdζ - grad_greenfunction[idx_obs, idx_src] = K_double * dθdζ + grad_greenfunction_block[idx_obs, idx_src] = K_double * dθdζ end # ============================================================ # NEAR FIELD: Polar quadrature with singular correction + # Only needed if observer points lies on source surface # ============================================================ - # Extract patches of source data around the singular point (size = PATCH_DIM x PATCH_DIM x dof) - extract_patch!(r_patch, source.r, i_obs, j_obs, source.mtheta, source.nzeta, PATCH_DIM) - extract_patch!(dr_dθ_patch, source.dr_dθ, i_obs, j_obs, source.mtheta, source.nzeta, PATCH_DIM) - extract_patch!(dr_dζ_patch, source.dr_dζ, i_obs, j_obs, source.mtheta, source.nzeta, PATCH_DIM) - - # Interpolate coordinates and tangent vectors to polar quadrature points - interpolate_to_polar!(r_polar, r_patch, P2G) - interpolate_to_polar!(dr_dθ_polar, dr_dθ_patch, P2G) - interpolate_to_polar!(dr_dζ_polar, dr_dζ_patch, P2G) - - # Compute normal vectors at polar points from interpolated tangent vectors - compute_polar_normal!(n_polar, dr_dθ_polar, dr_dζ_polar) - - # Evaluate kernels at polar points with POU weighting - fill!(M_polar_single, 0.0) - fill!(M_polar_double, 0.0) - @inbounds for ia in 1:ANG_DIM, ir in 1:RAD_DIM - # Evaluate kernels using recomputed normal (use @view to avoid allocation) - r_src = @view r_polar[ir, ia, :] - n_src = @view n_polar[ir, ia, :] - K_single = laplace_single_layer(r_obs, r_src) - K_double = laplace_double_layer(r_obs, r_src, n_src) - - # Apply quadrature weights: area element × POU, where POU contains rdrdθ already - M_polar_single[ir, ia] = K_single * Ppou[ir, ia] * dθdζ - M_polar_double[ir, ia] = K_double * Ppou[ir, ia] * dθdζ - end - - # Distribute polar singular corrections back to Cartesian grid using sparse matrix - # grid = P2G * polar (maps Npolar → Ngrid) - mul!(M_grid_single_flat, P2G, vec(M_polar_single)) - mul!(M_grid_double_flat, P2G, vec(M_polar_double)) - M_grid_single = reshape(M_grid_single_flat, PATCH_DIM, PATCH_DIM) - M_grid_double = reshape(M_grid_double_flat, PATCH_DIM, PATCH_DIM) - - # Compute remaining far-field POU contribution and near-field polar quadrature result - # We include this region in the far-field trapezoidal rule, so use Gpou = -χ here to get 1-χ - @inbounds for j in 1:PATCH_DIM, i in 1:PATCH_DIM - # Map back to global indices - idx_pol = periodic_wrap(i_obs - PATCH_RAD + i - 1, source.mtheta) - idx_tor = periodic_wrap(j_obs - PATCH_RAD + j - 1, source.nzeta) - idx_src = idx_pol + source.mtheta * (idx_tor - 1) - - # Remainder of far-field contribution on the singular grid: Gpou = -χ (use @view to avoid allocation) - r_src = @view source.r[idx_src, :] - n_src = @view source.normal[idx_src, :] - far_single = laplace_single_layer(r_obs, r_src) * Gpou[i, j] * dθdζ - far_double = laplace_double_layer(r_obs, r_src, n_src) * Gpou[i, j] * dθdζ - - # Apply near + far contributions - greenfunction[idx_obs, idx_src] += M_grid_single[i, j] + far_single - grad_greenfunction[idx_obs, idx_src] += M_grid_double[i, j] + far_double + if typeof(observer) == typeof(source) + # Extract patches of source data around the singular point (size = PATCH_DIM x PATCH_DIM x dof) + extract_patch!(r_patch, source.r, i_obs, j_obs, source.mtheta, source.nzeta, PATCH_DIM) + extract_patch!(dr_dθ_patch, source.dr_dθ, i_obs, j_obs, source.mtheta, source.nzeta, PATCH_DIM) + extract_patch!(dr_dζ_patch, source.dr_dζ, i_obs, j_obs, source.mtheta, source.nzeta, PATCH_DIM) + + # Interpolate coordinates and tangent vectors to polar quadrature points + interpolate_to_polar!(r_polar, r_patch, P2G) + interpolate_to_polar!(dr_dθ_polar, dr_dθ_patch, P2G) + interpolate_to_polar!(dr_dζ_polar, dr_dζ_patch, P2G) + + # Compute normal vectors at polar points from interpolated tangent vectors + compute_polar_normal!(n_polar, dr_dθ_polar, dr_dζ_polar) + + # Evaluate kernels at polar points with POU weighting + fill!(M_polar_single, 0.0) + fill!(M_polar_double, 0.0) + @inbounds for ia in 1:ANG_DIM, ir in 1:RAD_DIM + # Evaluate kernels using recomputed normal (use @view to avoid allocation) + r_src = @view r_polar[ir, ia, :] + n_src = @view n_polar[ir, ia, :] + K_single = laplace_single_layer(r_obs, r_src) + K_double = laplace_double_layer(r_obs, r_src, n_src) + + # Apply quadrature weights: area element × POU, where POU contains rdrdθ already + M_polar_single[ir, ia] = K_single * Ppou[ir, ia] * dθdζ + M_polar_double[ir, ia] = K_double * Ppou[ir, ia] * dθdζ + end + + # Distribute polar singular corrections back to Cartesian grid using sparse matrix + # grid = P2G * polar (maps Npolar → Ngrid) + mul!(M_grid_single_flat, P2G, vec(M_polar_single)) + mul!(M_grid_double_flat, P2G, vec(M_polar_double)) + M_grid_single = reshape(M_grid_single_flat, PATCH_DIM, PATCH_DIM) + M_grid_double = reshape(M_grid_double_flat, PATCH_DIM, PATCH_DIM) + + # Compute remaining far-field POU contribution and near-field polar quadrature result + # We include this region in the far-field trapezoidal rule, so use Gpou = -χ here to get 1-χ + @inbounds for j in 1:PATCH_DIM, i in 1:PATCH_DIM + # Map back to global indices + idx_pol = periodic_wrap(i_obs - PATCH_RAD + i - 1, source.mtheta) + idx_tor = periodic_wrap(j_obs - PATCH_RAD + j - 1, source.nzeta) + idx_src = idx_pol + source.mtheta * (idx_tor - 1) + + # Remainder of far-field contribution on the singular grid: Gpou = -χ (use @view to avoid allocation) + r_src = @view source.r[idx_src, :] + n_src = @view source.normal[idx_src, :] + far_single = laplace_single_layer(r_obs, r_src) * Gpou[i, j] * dθdζ + far_double = laplace_double_layer(r_obs, r_src, n_src) * Gpou[i, j] * dθdζ + + # Apply near + far contributions + greenfunction[idx_obs, idx_src] += M_grid_single[i, j] + far_single + grad_greenfunction_block[idx_obs, idx_src] += M_grid_double[i, j] + far_double + end end end From 5647dbfa1fe32e015f8b56d6fd45f27a25000bf8 Mon Sep 17 00:00:00 2001 From: Jake Halpern Date: Wed, 4 Feb 2026 15:41:40 -0500 Subject: [PATCH 25/31] VACUUM - WIP - resizing logic for kernel function, first attempt at 3D walls but getting errors ~10%, adding populate_greenfunction variable, precomputing Gaussian quad in 2D --- src/Vacuum/Vacuum.jl | 92 ++++++++------- src/Vacuum/Vacuum3D.jl | 156 +++++++++++++------------- src/Vacuum/VacuumInternals.jl | 153 +++++++++++++++---------- src/Vacuum/VacuumStructs.jl | 205 +++++++++++++++++++++++----------- 4 files changed, 359 insertions(+), 247 deletions(-) diff --git a/src/Vacuum/Vacuum.jl b/src/Vacuum/Vacuum.jl index 9a0ffc08..2f77659f 100644 --- a/src/Vacuum/Vacuum.jl +++ b/src/Vacuum/Vacuum.jl @@ -227,14 +227,18 @@ function compute_vacuum_response(inputs::VacuumInput, wall_settings::WallShapeSe # Initialization and allocations (; mtheta, mpert, n, kernelsign, force_wv_symmetry) = inputs - grad_green = zeros(2 * mtheta, 2 * mtheta) - green_temp = zeros(mtheta, mtheta) plasma_surf = PlasmaGeometry(inputs) wall = WallGeometry(inputs, plasma_surf, wall_settings) + # Allocate matrices upfront with correct size based on wall presence + grad_green_size = wall.nowall ? mtheta : 2 * mtheta + grad_green = zeros(grad_green_size, grad_green_size) + green_temp = zeros(mtheta, mtheta) + # 𝒢ₗ(θⱼ) from Chance eq. 106-108. first mtheta rows are plasma as observer, second are wall # First mpert columns are real (cosine), second mpert are imaginary (sine) - green_fourier = zeros(2 * mtheta, 2 * mpert) + green_fourier_rows = wall.nowall ? mtheta : 2 * mtheta + green_fourier = zeros(green_fourier_rows, 2 * mpert) PLASMA_ROW_OFFSET = 0 WALL_ROW_OFFSET = mtheta COS_COL_OFFSET = 0 @@ -243,27 +247,26 @@ function compute_vacuum_response(inputs::VacuumInput, wall_settings::WallShapeSe # Plasma–Plasma block kernel!(grad_green, green_temp, plasma_surf, plasma_surf, n) - # Fourier transform plasma-plasma block + # Fourier transform obs=plasma, src=plasma block into green_fourier fourier_transform!(green_fourier, green_temp, plasma_surf.cos_mn_basis, PLASMA_ROW_OFFSET, COS_COL_OFFSET) fourier_transform!(green_fourier, green_temp, plasma_surf.sin_mn_basis, PLASMA_ROW_OFFSET, SIN_COL_OFFSET) - !wall.nowall && begin + if !wall.nowall # Plasma–Wall block kernel!(grad_green, green_temp, plasma_surf, wall, n) - # Wall–Wall block kernel!(grad_green, green_temp, wall, wall, n) # Wall–Plasma block kernel!(grad_green, green_temp, wall, plasma_surf, n) - # Fourier transform wall blocks into green_fourier + # Fourier transform obs=wall, src=plasma block into green_fourier fourier_transform!(green_fourier, green_temp, plasma_surf.cos_mn_basis, WALL_ROW_OFFSET, COS_COL_OFFSET) fourier_transform!(green_fourier, green_temp, plasma_surf.sin_mn_basis, WALL_ROW_OFFSET, SIN_COL_OFFSET) end # Add cn0 to make grdgre nonsingular for n=0 modes cn0 = 1.0 # expose to user if anyone ever actually tries to use this - (abs(n) <= 1e-5 && !wall.nowall && wall.is_closed_toroidal) && begin + (n == 0 && !wall.nowall && wall.is_closed_toroidal) && begin @warn "Adding $cn0 to diagonal of grdgre to regularize n=0 mode; this may affect accuracy of results." mth12 = wall.nowall ? mtheta : 2 * mtheta for i in 1:mth12, j in 1:mth12 @@ -272,21 +275,14 @@ function compute_vacuum_response(inputs::VacuumInput, wall_settings::WallShapeSe end # Only needed for mutual inductance with the wall calculations - (kernelsign < 0) && begin + if kernelsign < 0 grad_green .*= kernelsign # Account for factor of 2 in diagonal terms in eq. 90 of Chance - for i in 1:(2*mtheta) - grad_green[i, i] += 2.0 - end + grad_green .+= 2I end # Invert the vacuum response system of equations, eqs. 112 of Chance 1997 (gelimb in Fortran) - # If plasma only, lower blocks are zero - if wall.nowall - @views green_fourier[1:mtheta, :] .= grad_green[1:mtheta, 1:mtheta] \ green_fourier[1:mtheta, :] - else - green_fourier .= grad_green \ green_fourier - end + green_fourier .= grad_green \ green_fourier # There's some logic that computes xpass/zpass and chiwc/chiws here, might eventually be needed? @@ -300,6 +296,7 @@ function compute_vacuum_response(inputs::VacuumInput, wall_settings::WallShapeSe # Final form of vacuum response matrix (eq. 114 of Chance 2007) wv = complex.(arr .+ aii, air .- ari) + # Force symmetry of response matrix if desired force_wv_symmetry && hermitianpart!(wv) @@ -337,52 +334,62 @@ It returns the relevant arrays: `wv`, `green_fourier`, `plasma_coords`, and `wal function compute_vacuum_response_3D(inputs::VacuumInput3D, wall_settings::WallShapeSettings; PATCH_RAD::Int=11, RAD_DIM::Int=20, INTERP_ORDER::Int=5) # Initialization and allocations - (; mtheta, mpert, n, force_wv_symmetry, kernelsign, nzeta, npert) = inputs - num_gridpoints = nzeta * mtheta + (; mtheta, mpert, force_wv_symmetry, kernelsign, nzeta, npert) = inputs + num_points = nzeta * mtheta num_modes = npert * mpert - grad_green = zeros(num_gridpoints, num_gridpoints) # for walls, this is 2*mtheta x 2*mtheta - green_temp = zeros(num_gridpoints, num_gridpoints) + # TODO: Currently only supports axisymmetric surfaces plasma_surf = PlasmaGeometry3D(inputs) wall = WallGeometry3D(inputs, plasma_surf, wall_settings) - # 𝒢ₗ(θⱼ) from Chance eq. 106-108. first num_gridpoints rows are plasma as observer, second are wall + # Allocate based on wall presence + grad_green_size = wall.nowall ? num_points : 2 * num_points + grad_green = zeros(grad_green_size, grad_green_size) + green_temp = zeros(num_points, num_points) + + # 𝒢ₗ(θⱼ) from Chance eq. 106-108. first num_points rows are plasma as observer, second are wall # First num_modes columns are real (cosine), second num_modes are imaginary (sine) - green_fourier = zeros(num_gridpoints, 2 * num_modes) + green_fourier_rows = wall.nowall ? num_points : 2 * num_points + green_fourier = zeros(green_fourier_rows, 2 * num_modes) PLASMA_ROW_OFFSET = 0 - WALL_ROW_OFFSET = num_gridpoints + WALL_ROW_OFFSET = num_points COS_COL_OFFSET = 0 SIN_COL_OFFSET = num_modes - !wall.nowall && error("No walls yet!") # DEBUG - # Plasma–Plasma block compute_3D_kernel_matrix!(grad_green, green_temp, plasma_surf, plasma_surf, PATCH_RAD, RAD_DIM, INTERP_ORDER) - grad_green += I * 0.5 - # Fourier transform plasma-plasma block + # Fourier transform obs=plasma, src=plasma block into green_fourier fourier_transform!(green_fourier, green_temp, plasma_surf.cos_mn_basis3D, PLASMA_ROW_OFFSET, COS_COL_OFFSET) fourier_transform!(green_fourier, green_temp, plasma_surf.sin_mn_basis3D, PLASMA_ROW_OFFSET, SIN_COL_OFFSET) - !wall.nowall && error("No walls yet!") + if !wall.nowall + # Plasma–Wall block + compute_3D_kernel_matrix!(grad_green, green_temp, plasma_surf, wall, PATCH_RAD, RAD_DIM, INTERP_ORDER) + # Wall–Wall block + compute_3D_kernel_matrix!(grad_green, green_temp, wall, wall, PATCH_RAD, RAD_DIM, INTERP_ORDER) + # Wall–Plasma block + compute_3D_kernel_matrix!(grad_green, green_temp, wall, plasma_surf, PATCH_RAD, RAD_DIM, INTERP_ORDER) + + # Fourier transform obs=wall, src=plasma block into green_fourier + fourier_transform!(green_fourier, green_temp, plasma_surf.cos_mn_basis3D, WALL_ROW_OFFSET, COS_COL_OFFSET) + fourier_transform!(green_fourier, green_temp, plasma_surf.sin_mn_basis3D, WALL_ROW_OFFSET, SIN_COL_OFFSET) + end - # Add cn0 to make grdgre nonsingular for n=0 modes - (abs(n) <= 1e-5 && !wall.nowall && wall.is_closed_toroidal) && error("No walls yet!") + grad_green += 0.5I # Only needed for mutual inductance with the wall calculations - (kernelsign < 0) && error("No walls yet!") - - # Invert the vacuum response system of equations, eqs. 92-94ish of Chance 1997 (gelimb in Fortran) - # If plasma only, lower blocks will be empty - if wall.nowall - @views green_fourier[1:num_gridpoints, :] .= grad_green[1:num_gridpoints, 1:num_gridpoints] \ green_fourier[1:num_gridpoints, :] - else - error("No walls yet!") - green_fourier .= grad_green \ green_fourier + if kernelsign < 0 + grad_green .*= kernelsign + # Account for factor of 2 in diagonal terms in eq. 90 of Chance + grad_green .+= 2I end + # Invert the vacuum response system of equations, eqs. 112 of Chance 1997 (gelimb in Fortran) + green_fourier .= grad_green \ green_fourier + # Perform inverse Fourier transforms to get response matrix components (eq. 115-118 of Chance 2007) - dθdζ = (2π / mtheta) * (2π / nzeta) + dθdζ = 4π^2 / (num_points) arr, aii, ari, air = ntuple(_ -> zeros(num_modes, num_modes), 4) fourier_inverse_transform!(arr, green_fourier, plasma_surf.cos_mn_basis3D, PLASMA_ROW_OFFSET, COS_COL_OFFSET, dθdζ) fourier_inverse_transform!(aii, green_fourier, plasma_surf.sin_mn_basis3D, PLASMA_ROW_OFFSET, SIN_COL_OFFSET, dθdζ) @@ -391,6 +398,7 @@ function compute_vacuum_response_3D(inputs::VacuumInput3D, wall_settings::WallSh # Final form of vacuum response matrix (eq. 114 of Chance 2007) wv = complex.(arr .+ aii, air .- ari) + # Force symmetry of response matrix if desired force_wv_symmetry && hermitianpart!(wv) diff --git a/src/Vacuum/Vacuum3D.jl b/src/Vacuum/Vacuum3D.jl index 55538cc8..d8e512d4 100644 --- a/src/Vacuum/Vacuum3D.jl +++ b/src/Vacuum/Vacuum3D.jl @@ -209,7 +209,6 @@ The single-layer kernel φ is the fundamental solution to Laplace's equation: - `Float64`: Kernel value φ(x_obs, x_src) """ function laplace_single_layer(x_obs::AbstractVector{<:Real}, x_src::AbstractVector{<:Real}) - # Single-layer kernel: 1/(4π r) @inbounds begin dx = x_obs[1] - x_src[1] dy = x_obs[2] - x_src[2] @@ -244,7 +243,6 @@ K(x_obs, x_src, n_src) = ∇_{x_src} φ · n̂_src - `Float64`: Kernel value K(x_obs, x_src, n_src) """ function laplace_double_layer(x_obs::AbstractVector{<:Real}, x_src::AbstractVector{<:Real}, n_src::AbstractVector{<:Real}) - # Double-layer kernel: -1/(4π) * (r·n) / r³ @inbounds begin dx = x_obs[1] - x_src[1] dy = x_obs[2] - x_src[2] @@ -281,10 +279,10 @@ function extract_patch!(patch::Array{Float64,3}, data::Matrix{Float64}, idx_pol_ idx_pol = periodic_wrap(idx_pol_center - PATCH_RAD + i - 1, npol) idx_tor = periodic_wrap(idx_tor_center - PATCH_RAD + j - 1, ntor) # Copy data to the patch using direct indexing (avoids view allocation) - src_idx = idx_pol + npol * (idx_tor - 1) - patch[i, j, 1] = data[src_idx, 1] - patch[i, j, 2] = data[src_idx, 2] - patch[i, j, 3] = data[src_idx, 3] + idx_src = idx_pol + npol * (idx_tor - 1) + patch[i, j, 1] = data[idx_src, 1] + patch[i, j, 2] = data[idx_src, 2] + patch[i, j, 3] = data[idx_src, 3] end end @@ -292,6 +290,8 @@ end interpolate_to_polar!(polar_data, patch, quad_data) Interpolate Cartesian patch data to polar quadrature points using sparse matrix multiply. +Overwrites `polar_data` using mul! function arguments, mul!(C, A, B, α, β) -> C where +C = α * A * B + β * C. # Arguments @@ -300,11 +300,9 @@ Interpolate Cartesian patch data to polar quadrature points using sparse matrix - `P2G`: Sparse interpolation matrix """ function interpolate_to_polar!(polar_data::Array{Float64,3}, patch::Array{Float64,3}, P2G::SparseMatrixCSC{Float64,Int}) - # Flatten patch to (Ngrid × dof), apply P2G' to get (Npolar × dof) - fill!(polar_data, 0.0) patch_flat = reshape(patch, :, size(patch, 3)) - mul!(reshape(polar_data, :, size(patch, 3)), P2G', patch_flat) + mul!(reshape(polar_data, :, size(patch, 3)), P2G', patch_flat, 1.0, 0.0) end """ @@ -319,8 +317,6 @@ Compute normal vector (= ∂r/∂θ × ∂r/∂ζ) at polar quadrature points fr - `dr_dζ_polar`: Interpolated ∂r/∂ζ at polar points (RAD_DIM × ANG_DIM × 3) """ function compute_polar_normal!(n_polar::Array{Float64,3}, dr_dθ::Array{Float64,3}, dr_dζ::Array{Float64,3}) - - fill!(n_polar, 0.0) # Inline cross product to avoid slice allocation @inbounds for ia in axes(dr_dθ, 2), ir in axes(dr_dθ, 1) n_polar[ir, ia, 1] = dr_dθ[ir, ia, 2] * dr_dζ[ir, ia, 3] - dr_dθ[ir, ia, 3] * dr_dζ[ir, ia, 2] @@ -408,40 +404,40 @@ function compute_3D_kernel_matrix!( grad_greenfunction::Matrix{Float64}, greenfunction::Matrix{Float64}, observer::Union{PlasmaGeometry3D,WallGeometry3D}, - source::Union{PlasmaGeometry3D,WallGeometry3D}; + source::Union{PlasmaGeometry3D,WallGeometry3D}, PATCH_RAD::Int, RAD_DIM::Int, INTERP_ORDER::Int ) + num_points = observer.mtheta * observer.nzeta + dθdζ = 4π^2 / (num_points) - # Zero out matrices - fill!(greenfunction, 0.0) - - # Get block of grad_green matrix - col_index = (source isa PlasmaGeometry ? 1 : 2) - row_index = (observer isa PlasmaGeometry ? 1 : 2) + # Get block of grad green function matrix + col_index = (source isa PlasmaGeometry3D ? 1 : 2) + row_index = (observer isa PlasmaGeometry3D ? 1 : 2) grad_greenfunction_block = view( grad_greenfunction, - ((row_index-1)*mtheta+1):(row_index*mtheta), - ((col_index-1)*mtheta+1):(col_index*mtheta) + ((row_index-1)*num_points+1):(row_index*num_points), + ((col_index-1)*num_points+1):(col_index*num_points) ) + # Zero out green function matrix + fill!(greenfunction, 0.0) + # 𝒢ⁿ only needed for plasma as source term (RHS of eqs. 26/27 in Chance 1997) + populate_greenfunction = source isa PlasmaGeometry3D + # Initialize quadrature data quad_data = get_singular_quadrature(PATCH_RAD, RAD_DIM, INTERP_ORDER) (; PATCH_DIM, PATCH_RAD, ANG_DIM, RAD_DIM, Ppou, Gpou, P2G) = quad_data @assert observer.mtheta ≥ PATCH_DIM @assert observer.nzeta ≥ PATCH_DIM - dθdζ = (2π / observer.mtheta) * (2π / observer.nzeta) # Allocate thread-local workspaces (one per thread) nthreads = Threads.nthreads() workspaces = [KernelWorkspace(PATCH_DIM, RAD_DIM, ANG_DIM) for _ in 1:nthreads] - # Total number of observer points for linear indexing - n_obs = observer.mtheta * observer.nzeta - # Parallel loop through observer points - Threads.@threads for idx_obs in 1:n_obs + Threads.@threads for idx_obs in 1:num_points # Get thread-local workspace ws = workspaces[Threads.threadid()] (; r_patch, dr_dθ_patch, dr_dζ_patch, r_polar, dr_dθ_polar, dr_dζ_polar, @@ -456,77 +452,75 @@ function compute_3D_kernel_matrix!( # FAR FIELD: Trapezoidal rule for nonsingular source points # Note: kernels return zero for r_src = r_obs # ============================================================ - @inbounds for j_src in 1:source.nzeta, i_src in 1:source.mtheta + @inbounds for idx_src in 1:num_points # Evaluate kernels at grid points - idx_src = i_src + (j_src - 1) * source.mtheta r_src = @view source.r[idx_src, :] n_src = @view source.normal[idx_src, :] K_single = laplace_single_layer(r_obs, r_src) K_double = laplace_double_layer(r_obs, r_src, n_src) # Apply weights (periodic trapezoidal rule = constant weights) - greenfunction[idx_obs, idx_src] = K_single * dθdζ + if populate_greenfunction + greenfunction[idx_obs, idx_src] = K_single * dθdζ + end grad_greenfunction_block[idx_obs, idx_src] = K_double * dθdζ end # ============================================================ # NEAR FIELD: Polar quadrature with singular correction - # Only needed if observer points lies on source surface # ============================================================ - if typeof(observer) == typeof(source) - # Extract patches of source data around the singular point (size = PATCH_DIM x PATCH_DIM x dof) - extract_patch!(r_patch, source.r, i_obs, j_obs, source.mtheta, source.nzeta, PATCH_DIM) - extract_patch!(dr_dθ_patch, source.dr_dθ, i_obs, j_obs, source.mtheta, source.nzeta, PATCH_DIM) - extract_patch!(dr_dζ_patch, source.dr_dζ, i_obs, j_obs, source.mtheta, source.nzeta, PATCH_DIM) - - # Interpolate coordinates and tangent vectors to polar quadrature points - interpolate_to_polar!(r_polar, r_patch, P2G) - interpolate_to_polar!(dr_dθ_polar, dr_dθ_patch, P2G) - interpolate_to_polar!(dr_dζ_polar, dr_dζ_patch, P2G) - - # Compute normal vectors at polar points from interpolated tangent vectors - compute_polar_normal!(n_polar, dr_dθ_polar, dr_dζ_polar) - - # Evaluate kernels at polar points with POU weighting - fill!(M_polar_single, 0.0) - fill!(M_polar_double, 0.0) - @inbounds for ia in 1:ANG_DIM, ir in 1:RAD_DIM - # Evaluate kernels using recomputed normal (use @view to avoid allocation) - r_src = @view r_polar[ir, ia, :] - n_src = @view n_polar[ir, ia, :] - K_single = laplace_single_layer(r_obs, r_src) - K_double = laplace_double_layer(r_obs, r_src, n_src) - - # Apply quadrature weights: area element × POU, where POU contains rdrdθ already - M_polar_single[ir, ia] = K_single * Ppou[ir, ia] * dθdζ - M_polar_double[ir, ia] = K_double * Ppou[ir, ia] * dθdζ - end + # Extract patches of source data around the singular point (size = PATCH_DIM x PATCH_DIM x dof) + extract_patch!(r_patch, source.r, i_obs, j_obs, source.mtheta, source.nzeta, PATCH_DIM) + extract_patch!(dr_dθ_patch, source.dr_dθ, i_obs, j_obs, source.mtheta, source.nzeta, PATCH_DIM) + extract_patch!(dr_dζ_patch, source.dr_dζ, i_obs, j_obs, source.mtheta, source.nzeta, PATCH_DIM) + + # Interpolate coordinates and tangent vectors to polar quadrature points + interpolate_to_polar!(r_polar, r_patch, P2G) + interpolate_to_polar!(dr_dθ_polar, dr_dθ_patch, P2G) + interpolate_to_polar!(dr_dζ_polar, dr_dζ_patch, P2G) + + # Compute normal vectors at polar points from interpolated tangent vectors + compute_polar_normal!(n_polar, dr_dθ_polar, dr_dζ_polar) + + # Evaluate kernels at polar points with POU weighting + @inbounds for ia in 1:ANG_DIM, ir in 1:RAD_DIM + # Evaluate kernels using recomputed normal (use @view to avoid allocation) + r_src = @view r_polar[ir, ia, :] + n_src = @view n_polar[ir, ia, :] + K_single = laplace_single_layer(r_obs, r_src) + K_double = laplace_double_layer(r_obs, r_src, n_src) + + # Apply quadrature weights: area element × POU, where POU contains rdrdθ already + M_polar_single[ir, ia] = K_single * Ppou[ir, ia] * dθdζ + M_polar_double[ir, ia] = K_double * Ppou[ir, ia] * dθdζ + end + + # Distribute polar singular corrections back to Cartesian grid using sparse matrix + # grid = P2G * polar (maps Npolar → Ngrid) + mul!(M_grid_single_flat, P2G, vec(M_polar_single)) + mul!(M_grid_double_flat, P2G, vec(M_polar_double)) + M_grid_single = reshape(M_grid_single_flat, PATCH_DIM, PATCH_DIM) + M_grid_double = reshape(M_grid_double_flat, PATCH_DIM, PATCH_DIM) + + # Compute remaining far-field POU contribution and near-field polar quadrature result + # We include this region in the far-field trapezoidal rule, so use Gpou = -χ here to get 1-χ + @inbounds for j in 1:PATCH_DIM, i in 1:PATCH_DIM + # Map back to global indices + idx_pol = periodic_wrap(i_obs - PATCH_RAD + i - 1, source.mtheta) + idx_tor = periodic_wrap(j_obs - PATCH_RAD + j - 1, source.nzeta) + idx_src = idx_pol + source.mtheta * (idx_tor - 1) + + # Remainder of far-field contribution on the singular grid: Gpou = -χ + r_src = @view source.r[idx_src, :] + n_src = @view source.normal[idx_src, :] + far_single = laplace_single_layer(r_obs, r_src) * Gpou[i, j] * dθdζ + far_double = laplace_double_layer(r_obs, r_src, n_src) * Gpou[i, j] * dθdζ - # Distribute polar singular corrections back to Cartesian grid using sparse matrix - # grid = P2G * polar (maps Npolar → Ngrid) - mul!(M_grid_single_flat, P2G, vec(M_polar_single)) - mul!(M_grid_double_flat, P2G, vec(M_polar_double)) - M_grid_single = reshape(M_grid_single_flat, PATCH_DIM, PATCH_DIM) - M_grid_double = reshape(M_grid_double_flat, PATCH_DIM, PATCH_DIM) - - # Compute remaining far-field POU contribution and near-field polar quadrature result - # We include this region in the far-field trapezoidal rule, so use Gpou = -χ here to get 1-χ - @inbounds for j in 1:PATCH_DIM, i in 1:PATCH_DIM - # Map back to global indices - idx_pol = periodic_wrap(i_obs - PATCH_RAD + i - 1, source.mtheta) - idx_tor = periodic_wrap(j_obs - PATCH_RAD + j - 1, source.nzeta) - idx_src = idx_pol + source.mtheta * (idx_tor - 1) - - # Remainder of far-field contribution on the singular grid: Gpou = -χ (use @view to avoid allocation) - r_src = @view source.r[idx_src, :] - n_src = @view source.normal[idx_src, :] - far_single = laplace_single_layer(r_obs, r_src) * Gpou[i, j] * dθdζ - far_double = laplace_double_layer(r_obs, r_src, n_src) * Gpou[i, j] * dθdζ - - # Apply near + far contributions + # Apply near + far contributions + if populate_greenfunction greenfunction[idx_obs, idx_src] += M_grid_single[i, j] + far_single - grad_greenfunction_block[idx_obs, idx_src] += M_grid_double[i, j] + far_double end + grad_greenfunction_block[idx_obs, idx_src] += M_grid_double[i, j] + far_double end end @@ -535,5 +529,5 @@ function compute_3D_kernel_matrix!( # orient for this later for generalization. # Account for normal direction pointing out of vacuum integration region in 𝒦ⁿ ⋅ dS # Negative for plasma since dS = ∇ψ J dθdζ and ∇ψ points outward but outward normal is inward - # @views grad_greenfunction .*= (source isa PlasmaGeometry3D ? -1 : 1) + @views grad_greenfunction_block .*= (source isa PlasmaGeometry3D ? 1 : -1) end diff --git a/src/Vacuum/VacuumInternals.jl b/src/Vacuum/VacuumInternals.jl index 7f1849bf..be2f3b9b 100644 --- a/src/Vacuum/VacuumInternals.jl +++ b/src/Vacuum/VacuumInternals.jl @@ -1,10 +1,3 @@ -# Gaussian quadrature weights and points for 8-point integration (used for kernel! function) -const GAUSSIANWEIGHTS = [0.101228536290376, 0.222381034453374, 0.313706645877887, 0.362683783378362, - 0.362683783378362, 0.313706645877887, 0.222381034453374, 0.101228536290376] - -const GAUSSIANPOINTS = [-0.960289856497536, -0.796666477413627, -0.525532409916329, -0.183434642495650, - 0.183434642495650, 0.525532409916329, 0.796666477413627, 0.960289856497536] - # 32-point Gaussian quadrature abscissae (used for Pn_minus_half_2007 function when nρ̂>0.1) const GAUSSIANWEIGHTS32 = [ 0.007018610009470096600, 0.016274394730905670605, @@ -44,6 +37,54 @@ const GAUSSIANPOINTS32 = [ 0.985611511545268335400, 0.997263861849481563545 ] +# Cache for Lagrange stencils keyed by Gaussian order +const LAGRANGE_STENCIL_CACHE = Dict{Int,Tuple{Vector{SVector{5,Float64}},Vector{SVector{5,Float64}}}}() + +""" + precompute_lagrange_stencils(gaussian_points) + +Precompute 5-point Lagrange interpolation stencils for Gaussian quadrature points. + +Returns a tuple `(left, right)` where each entry is a Vector of SVector{5,Float64} +containing the stencil weights for points on the left/right panel. +""" +function precompute_lagrange_stencils(gaussian_points::AbstractVector{<:Real}) + stencil_points = SVector(-2, -1, 0, 1, 2) + npts = length(gaussian_points) + left = Vector{SVector{5,Float64}}(undef, npts) + right = Vector{SVector{5,Float64}}(undef, npts) + + for ig in 1:npts + p_left = -1.0 + gaussian_points[ig] + p_right = 1.0 + gaussian_points[ig] + + left[ig] = ntuple(5) do i + xi = stencil_points[i] + prod(j -> j == i ? 1.0 : (p_left - stencil_points[j]) / (xi - stencil_points[j]), 1:5) + end |> SVector + + right[ig] = ntuple(5) do i + xi = stencil_points[i] + prod(j -> j == i ? 1.0 : (p_right - stencil_points[j]) / (xi - stencil_points[j]), 1:5) + end |> SVector + end + + return left, right +end + +""" + get_lagrange_stencils(gaussian_points) + +Return cached 5-point Lagrange stencils keyed by Gaussian order. Initializes +and caches the stencils on first use for a given order. +""" +function get_lagrange_stencils(gaussian_points::AbstractVector{<:Real}) + order = length(gaussian_points) + return get!(LAGRANGE_STENCIL_CACHE, order) do + precompute_lagrange_stencils(gaussian_points) + end +end + """ kernel!(grad_greenfunction_mat, greenfunction_mat, observer, source, n) @@ -78,7 +119,8 @@ function kernel!( greenfunction_mat::Matrix{Float64}, observer::Union{PlasmaGeometry,WallGeometry}, source::Union{PlasmaGeometry,WallGeometry}, - n::Int + n::Int; + GAUSS_ORDER::Int=8 ) mtheta = length(observer.x) @@ -94,12 +136,10 @@ function kernel!( ((col_index-1)*mtheta+1):(col_index*mtheta) ) - # Zero out greenfunction_mat at start of each kernel call (matches Fortran behavior) + # Zero out greenfunction_mat at start of each kernel call fill!(greenfunction_mat, 0.0) - - if mtheta != length(observer.z) || mtheta != length(source.x) || mtheta != length(source.z) - error("Length of input arrays (xobs, zobs, xsource, zsce) are different. All length should be the same") - end + # 𝒢ⁿ only needed for plasma as source term (RHS of eqs. 26/27 in Chance 1997) + populate_greenfunction = source isa PlasmaGeometry # S₁ᵢ in Chance 1997, eq.(78) log_correction_0=16.0*dtheta*(log(2*dtheta)-68.0/15.0)/15.0 @@ -107,6 +147,18 @@ function kernel!( log_correction_2=4.0*dtheta*(7.0*log(2*dtheta)-11.0/15.0)/45.0 log_correction = [log_correction_2, log_correction_1, log_correction_0, log_correction_1, log_correction_2] + # Precompute composite Simpson's 1/3 rule weights, excluding singular points + # Note we set to 4 for even/2 for odd since we index from 1 while the formula assumes indexing from 0 + nsrc = mtheta - 3 + simpson_weights = dtheta / 3 .* [(k == 1 || k == nsrc) ? 1 : (iseven(k) ? 4 : 2) for k in 1:nsrc] + + # Precompute quantities for Gaussian quadrature + GAUSSIANPOINTS, GAUSSIANWEIGHTS = FastGaussQuadrature.gausslegendre(GAUSS_ORDER) # on [-1, 1] + wgauss = GAUSSIANWEIGHTS .* dtheta # scale to [-Δθ, Δθ] + xgauss_left = -dtheta .+ GAUSSIANPOINTS .* dtheta # [-2Δθ, 0] + xgauss_right = dtheta .+ GAUSSIANPOINTS .* dtheta # [0, 2Δθ] + lagrange_left, lagrange_right = get_lagrange_stencils(GAUSSIANPOINTS) + # TODO: this isn't the same as the periodic_cubic_deriv interpolation? # We need to interpolate off-grid during Gaussian quadrature # THIS IS A BUG: extrapolations BC assumes both endpoints are included in the data, so this wraps at mtheta-1 to 0 instead of mtheta to 0 @@ -120,24 +172,20 @@ function kernel!( # Loop through observer points for j in 1:mtheta - # Initialize variables + # Get observer coordinates x_obs, z_obs, theta_obs = observer.x[j], observer.z[j], theta_grid[j] # Obtain nonsingular region (endpoints at j+2 and j-2, so exclude j-1, j, and j+1) nonsing_idx = mod1.((j+2):(j+mtheta-2), mtheta) # mod1 ensures isrc is in [1, mtheta] - # Compute composite Simpson's 1/3 rule weights (https://en.wikipedia.org/wiki/Simpson%27s_rule#Composite_Simpson's_1/3_rule) - # Note we set to 4 for even/2 for odd since we index from 1 while the formula assumes indexing from 0 - nsrc = length(nonsing_idx) - simpson_weights = dtheta / 3 .* [(k == 1 || k == nsrc) ? 1 : (iseven(k) ? 4 : 2) for k in 1:nsrc] - # Perform Simpson integration for nonsingular source points for (isrc, wsimpson) in zip(nonsing_idx, simpson_weights) - # G_n is 2pi𝒢ⁿ; coupling_n is 𝒥 ∇'𝒢ⁿ∇'ℒ; coupling_0 is 𝒥 ∇'𝒢ⁿ∇'ℒ for n=0 G_n, coupling_n, coupling_0 = green(x_obs, z_obs, source.x[isrc], source.z[isrc], source.dx_dtheta[isrc], source.dz_dtheta[isrc], n) # Sum contributions to Green's function matrices using Simpson weight - greenfunction_mat[j, isrc] += G_n * wsimpson + if populate_greenfunction + greenfunction_mat[j, isrc] += G_n * wsimpson + end grad_greenfunction_block[j, isrc] += coupling_n * wsimpson # Subtract regular integral component of δⱼᵢK⁰ in eq. 83 grad_greenfunction_block[j, j] -= coupling_0 * wsimpson @@ -148,10 +196,10 @@ function kernel!( sing_idx = mod1.(j .+ ((mtheta-2):(mtheta+2)), mtheta) # Integrate region of length 2 * dtheta on left/right of singularity for region in ["left", "right"] - gauss_mid = theta_obs + (region == "left" ? -dtheta : dtheta) - theta_gauss = gauss_mid .+ GAUSSIANPOINTS .* dtheta - wgauss = GAUSSIANWEIGHTS .* dtheta - for ig in 1:8 # 8-point Gaussian quadrature + # Get precomputed quadrature data + lagrange_stencil = (region == "left") ? lagrange_left : lagrange_right + theta_gauss = theta_obs .+ (region == "left" ? xgauss_left : xgauss_right) + for ig in 1:GAUSS_ORDER # Compute green function for this Gaussian point theta_gauss0 = mod(theta_gauss[ig], 2π) x_gauss = spline_x(theta_gauss0) @@ -160,44 +208,35 @@ function kernel!( dz_dtheta_gauss = Interpolations.gradient(spline_z, theta_gauss0)[1] G_n, coupling_n, coupling_0 = green(x_obs, z_obs, x_gauss, z_gauss, dx_dtheta_gauss, dz_dtheta_gauss, n) - # Add logarithm to G_n to analytically isolate the singularity (first type), Chance eq.(75) - if observer isa PlasmaGeometry && source isa PlasmaGeometry # previously iops - G_n += log((theta_obs - theta_gauss[ig])^2) / x_obs - end - - p = (theta_gauss[ig] - theta_obs) / dtheta # p = θ/Δ = (θⱼ - θ')/Δ - stencil_points = SVector(-2, -1, 0, 1, 2) - lagrange_stencil = ntuple(5) do i - xi = stencil_points[i] - prod(j -> j == i ? 1.0 : (p - stencil_points[j])/(xi - stencil_points[j]), 1:5) - end |> SVector - - # First type of singularity: 𝒢ⁿ, occurs plasma as source only (see RHS of Chance eqs. 26/27) - if source isa PlasmaGeometry # previously iopw - @. @views greenfunction_mat[j, sing_idx] += wgauss[ig] * G_n * lagrange_stencil + # First type of singularity: 𝒢ⁿ (Eq. 75: 2π𝒢ⁿ + log(θ-θ')²/X') + if populate_greenfunction + if observer isa PlasmaGeometry + # Remove singular behavior by adding on leading-order term, Chance eq.(75) + G_n += log((theta_obs - theta_gauss[ig])^2) / x_obs + end + @. @views greenfunction_mat[j, sing_idx] += wgauss[ig] * G_n * lagrange_stencil[ig] end # Second type of singularity: 𝒦ⁿ (Eq. 86: 𝒦ⁿαᵢ - δⱼᵢK⁰) - @. @views grad_greenfunction_block[j, sing_idx] += wgauss[ig] * coupling_n * lagrange_stencil - # Subtract off the diverging singular n=0 component + @. @views grad_greenfunction_block[j, sing_idx] += wgauss[ig] * coupling_n * lagrange_stencil[ig] grad_greenfunction_block[j, j] -= coupling_0 * wgauss[ig] end end - # Subtract off analytic singular integral from Chance eq.(75) if plasma-plasma block - if observer isa PlasmaGeometry && source isa PlasmaGeometry + # Add analytic singular integral (first type) from Chance eq. 78 + if populate_greenfunction && observer isa PlasmaGeometry @. @views greenfunction_mat[j, sing_idx] -= log_correction / x_obs end end # Account for normal direction pointing out of vacuum integration region in 𝒦ⁿ ⋅ dS, previously isgn # Negative for plasma since dS = ∇ψ J dθdζ and ∇ψ points outward but outward normal is inward - @views grad_greenfunction_block .*= (source isa PlasmaGeometry ? -1 : 1) + grad_greenfunction_block .*= (source isa PlasmaGeometry ? -1 : 1) # Since we computed 2π𝒢, divide by 2π to get 𝒢 greenfunction_mat ./= 2π - # Determine residue based on logic similar to Table I of Chance 1997 + existing δⱼᵢ in eq. 69 + # Add analytic singular integral (second type) from Table I of Chance 1997 + existing δⱼᵢ in eq. 69 # Would need to pass in wall geometry to generalize this to open walls is_closed_toroidal = true if is_closed_toroidal # Chance eq. 89 @@ -219,9 +258,9 @@ Perform the inverse Fourier transform of `gil` onto `gll` using Fourier coeffici # Arguments - - `gll`: Output matrix (mpert × mpert) updated in-place - - `gil`: Input matrix (mtheta × mpert) containing Fourier-space data - - `cs`: Fourier coefficient matrix (mtheta × mpert) + - `gll`: Output matrix (num_pert × num_pert) updated in-place + - `gil`: Input matrix (num_points × num_pert) containing Fourier-space data + - `cs`: Fourier coefficient matrix (num_points × num_pert) - `m00`: Integer offset in the gil matrix (row offset) - `l00`: Integer offset in the gil matrix (column offset) - `weight`: Quadrature weight factor @@ -236,10 +275,9 @@ Perform the inverse Fourier transform of `gil` onto `gll` using Fourier coeffici - gll(l2,l1) : output matrix updated in-place (mpert × mpert) """ function fourier_inverse_transform!(gll::Matrix{Float64}, gil::Matrix{Float64}, cs::Matrix{Float64}, m00::Int, l00::Int, weight::Float64) - # Inverse Fourier transform via matrix multiply: gll = cs^T * gil * (2π * dth) - num_gridpoints, num_pert = size(cs) - mul!(gll, cs', view(gil, (m00+1):(m00+num_gridpoints), (l00+1):(l00+num_pert)), weight, 0.0) + num_points, num_pert = size(cs) + mul!(gll, cs', view(gil, (m00+1):(m00+num_points), (l00+1):(l00+num_pert)), weight, 0.0) end """ @@ -250,18 +288,17 @@ end using Fourier coefficients stored in cs. Inputs: - gij(i,j) : input matrix of size (mth × mth), the "physical-space" data - cs(j,l) : Fourier coefficient matrix (mth × mpert) + gij(i,j) : input matrix of size (num_points × num_points), the "physical-space" data + cs(j,l) : Fourier coefficient matrix (num_points × num_pert) m00, l00 : integer offsets in the gil matrix Output: - gil(i', l') : output matrix updated in-place (mth × mpert), where i' = m00 + i and l' = l00 + l + gil(i', l') : output matrix updated in-place (num_points × num_pert), where i' = m00 + i and l' = l00 + l """ function fourier_transform!(gil::Matrix{Float64}, gij::Matrix{Float64}, cs::Matrix{Float64}, m00::Int, l00::Int) - # Fourier transform via matrix multiply: gil[i, l] = Σ_j gij[i, j] * cs[j, l] - num_gridpoints, num_pert = size(cs) - mul!(view(gil, (m00+1):(m00+num_gridpoints), (l00+1):(l00+num_pert)), gij, cs) + num_points, num_pert = size(cs) + mul!(view(gil, (m00+1):(m00+num_points), (l00+1):(l00+num_pert)), gij, cs) end # Returns the array of derivatives at all x points, I think this acts like difspl diff --git a/src/Vacuum/VacuumStructs.jl b/src/Vacuum/VacuumStructs.jl index 38b6fc21..a301d48c 100644 --- a/src/Vacuum/VacuumStructs.jl +++ b/src/Vacuum/VacuumStructs.jl @@ -266,17 +266,17 @@ function PlasmaGeometry3D(inputs::VacuumInput3D) # Extract 2D poloidal data (; mtheta, nzeta, npert, nlow, mlow, mpert) = inputs - num_gridpoints = mtheta * nzeta + num_points = mtheta * nzeta dθ = 2π / mtheta dζ = 2π / nzeta θ_grid = range(; start=0, length=mtheta, step=dθ) ϕ_grid = range(; start=0, length=nzeta, step=dζ) # Allocate output arrays - r = zeros(num_gridpoints, 3) - normal = zeros(num_gridpoints, 3) - dr_dθ = zeros(num_gridpoints, 3) - dr_dζ = zeros(num_gridpoints, 3) + r = zeros(num_points, 3) + normal = zeros(num_points, 3) + dr_dθ = zeros(num_points, 3) + dr_dζ = zeros(num_points, 3) # Interpolate arrays from input onto mtheta grid (same as 2D) x = interp_to_new_grid(inputs.x, mtheta) @@ -309,15 +309,12 @@ function PlasmaGeometry3D(inputs::VacuumInput3D) spacing_θ = sqrt(sum(abs2, dr_dθ) / size(dr_dθ, 1)) * dθ spacing_ζ = sqrt(sum(abs2, dr_dζ) / size(dr_dζ, 1)) * dζ aspect_ratio = max(spacing_θ, spacing_ζ) / min(spacing_θ, spacing_ζ) - @info "Average grid spacing: dθ=$(round(spacing_θ, digits=4)), dζ=$(round(spacing_ζ, digits=4)), aspect ratio=$(round(aspect_ratio, digits=2))" - if aspect_ratio > 2.0 - @warn "Grid spacing aspect ratio is $(round(aspect_ratio, digits=2)). " * - "Singular correction assumes roughly isotropic patches; accuracy may degrade for highly anisotropic grids." - end + @info "Average grid spacing [m]: dθ=$(round(spacing_θ, digits=4)), dζ=$(round(spacing_ζ, digits=4)), aspect ratio=$(round(aspect_ratio, digits=2))" + aspect_ratio > 10.0 && @warn "Grid aspect ratio is highly anisotropic, which may degrade quadrature accuracy" # Precompute Fourier transform terms, sin(lθ - nν(θ) - nϕ) and cos(lθ - nν(θ) - nϕ) - sin_mn_basis3D = zeros(num_gridpoints, mpert*npert) - cos_mn_basis3D = zeros(num_gridpoints, mpert*npert) + sin_mn_basis3D = zeros(num_points, mpert*npert) + cos_mn_basis3D = zeros(num_points, mpert*npert) for idx_n in 1:npert n = nlow + idx_n - 1 for idx_m in 1:mpert @@ -397,37 +394,41 @@ function WallGeometry(inputs::VacuumInput, plasma_surf::PlasmaGeometry, wall_set nowall = wall_settings.shape == "nowall" is_closed_toroidal = true - # All of these arrays are of length mtheta with θ = [0, 1) + # Output wall coordinate arrays mtheta = inputs.mtheta + x_wall = zeros(mtheta) + z_wall = zeros(mtheta) + dx_dtheta = zeros(mtheta) + dz_dtheta = zeros(mtheta) + + if nowall + @info "Using no wall" + return WallGeometry(; + nowall=nowall, + is_closed_toroidal=is_closed_toroidal, + x=x_wall, + z=z_wall, + dx_dtheta=dx_dtheta, + dz_dtheta=dz_dtheta + ) + end - # Get wall shape from form_wall - # Plasma surface coordinates + # Compute plasma surface quantities x_plasma = plasma_surf.x z_plasma = plasma_surf.z - - # Output wall coordinate arrays - x_wall = zeros(Float64, mtheta) - z_wall = zeros(Float64, mtheta) - - # Common geometric parameters xmin = minimum(x_plasma) xmax = maximum(x_plasma) zmin = minimum(z_plasma) zmax = maximum(z_plasma) - r_minor = 0.5 * (xmax - xmin) r_major = 0.5 * (xmax + xmin) # Destructuring settings for readability (; aw, bw, cw, dw, tw, a) = wall_settings - wcentr = 0.0 # Initialize - if wall_settings.shape == "nowall" - @info "Using no wall" - elseif wall_settings.shape == "conformal" + if wall_settings.shape == "conformal" dx = a * r_minor @info "Calculating conformal wall shape $((@sprintf "%.2e" dx)) m from plasma surface." - wcentr = r_major centerstack_min = min(0.1, 0.1 * minimum(x_plasma)) # Avoid wall crossing R=0 axis for i in 1:mtheta j = mod1(i - 1, mtheta) @@ -437,26 +438,20 @@ function WallGeometry(inputs::VacuumInput, plasma_surf::PlasmaGeometry, wall_set x_wall[i] = max(centerstack_min, x_plasma[i] + a * r_minor * cos(alph)) z_wall[i] = z_plasma[i] + a * r_minor * sin(alph) end - if any(x_wall .<= centerstack_min + eps(Float64)) @warn "Conformal wall with a=$a would cross R=0 axis; forcing minimum wall R to $(@sprintf "%.2e" centerstack_min) m to avoid unphysical geometry." end - elseif wall_settings.shape == "elliptical" @info "Calculating elliptical wall shape with a = $((@sprintf "%.2e" a)) m." - wcentr = r_major - zrad = 0.5 * (zmax - zmin) zh = sqrt(abs(zrad^2 - r_minor^2)) zmuw = log((a/zh) + sqrt((a/zh)^2 + 1)) bw_eff = (zh * cosh(zmuw)) / a - for i in 1:mtheta the = (i - 1) * (2π / mtheta) x_wall[i] = r_major + a * cos(the) z_wall[i] = -bw_eff * a * sin(the) end - elseif wall_settings.shape == "dee" wcentr = r_major + cw * r_minor @info "Calculating dee-shaped wall with R = $((@sprintf "%.2e" wcentr)) + $((@sprintf "%.2e" r_minor)) * (1.0 + $((@sprintf "%.2e" a)) - $((@sprintf "%.2e" cw))) * cos(θ + $((@sprintf "%.2e" dw)) * sin(θ)), Z = -$((@sprintf "%.2e" bw)) * $((@sprintf "%.2e" r_minor)) * (1.0 + $((@sprintf "%.2e" a)) - $((@sprintf "%.2e" cw))) * sin(θ + $((@sprintf "%.2e" tw)) * sin(2θ)) - $((@sprintf "%.2e" aw)) * $((@sprintf "%.2e" r_minor)) * sin(2θ)." @@ -465,29 +460,22 @@ function WallGeometry(inputs::VacuumInput, plasma_surf::PlasmaGeometry, wall_set x_wall[i] = wcentr + r_minor * (1.0 + a - cw) * cos(the + dw * sin(the)) z_wall[i] = -bw * r_minor * (1.0 + a - cw) * sin(the + tw * sin(2.0*the)) - aw * r_minor * sin(2.0*the) end - elseif wall_settings.shape == "mod_dee" @info "Calculating modified dee-shaped wall with R = $((@sprintf "%.2e" cw)) + $((@sprintf "%.2e" a)) * cos(θ + $((@sprintf "%.2e" dw)) * sin(θ)), Z = -$((@sprintf "%.2e" bw)) * $((@sprintf "%.2e" a)) * sin(θ + $((@sprintf "%.2e" tw)) * sin(2θ)) - $((@sprintf "%.2e" aw)) * sin(2θ)." - wcentr = cw for i in 1:mtheta the = (i - 1) * (2π / mtheta) x_wall[i] = cw + a * cos(the + dw * sin(the)) z_wall[i] = -bw * a * sin(the + tw * sin(2.0*the)) - aw * sin(2.0*the) end - else filepath = wall_settings.shape !isfile(filepath) && @error "ERROR: Wall geometry file $filepath does not exist. Please set the wall shape parameter to a valid file path or a built-in shape (nowall, conformal, elliptical, dee, mod_dee)." - - wcentr = 0.0 open(wall_settings.shape, "r") do io npots0 = parse(Int, readline(io)) # Number of points in file - wcentr = parse(Float64, readline(io)) + readline(io) # Skip wcentr line readline(io) # Skip header/comment line - (npots0 < mtheta) && @error "ERROR: $filename contains fewer points ($npots0) than mtheta ($mtheta)." - for i in 1:mtheta line = split(readline(io)) # Assumes file format: [index R_coord Z_coord] @@ -500,9 +488,7 @@ function WallGeometry(inputs::VacuumInput, plasma_surf::PlasmaGeometry, wall_set # Optional: Re-parameterization if wall_settings.equal_arc_wall && (wall_settings.shape != "nowall") @info "Re-distributing wall points to equal arc length spacing" - if !is_closed_toroidal - @error "Wall is not closed toroidally; equal arc length distribution assumes periodicity as cannot be safely used." - end + !is_closed_toroidal && error("Wall is not closed toroidally; equal arc length distribution assumes periodicity as cannot be safely used.") x_wall, z_wall, _, theta_grid, _ = distribute_to_equal_arc_grid(x_wall, z_wall, mtheta) theta_grid .= theta_grid .* (2π) # Scale to [0, 2π) - irregular spacing fx_of_theta = interpolate((theta_grid,), x_wall, Gridded(Linear())) @@ -516,10 +502,8 @@ function WallGeometry(inputs::VacuumInput, plasma_surf::PlasmaGeometry, wall_set dz_dtheta = periodic_cubic_deriv(theta_grid, z_wall) end - if any(x_wall .<= 0.0) && !nowall - # to add support for x<0 walls, be sure to carefully replicate Chance's fortran code x<0 handling in the kernel function to account for the additional singularities associated with this - error("Wall R-coordinates contain non-physical values (R <= 0). Check wall geometry.") - end + # to add support for x<0 walls, be sure to carefully replicate Chance's fortran code x<0 handling in the kernel function to account for the additional singularities associated with this + any(x_wall .<= 0.0) && error("Wall R-coordinates contain non-physical values (R <= 0). Check wall geometry.") return WallGeometry(; nowall=nowall, @@ -546,8 +530,7 @@ Struct holding wall geometry data for vacuum calculations. Arrays are of length - `r::Matrix{Float64}`: (x, y, z) wall coordinates at each grid point - `dr_dθ::Matrix{Float64}`: Derivative dR/dθ at wall - `dr_dζ::Matrix{Float64}`: Derivative dR/dζ at wall - - `normal::Matrix{Float64}`: Outward unit normal vectors at wall - - `dA::Vector{Float64}`: Differential area elements at wall + - `normal::Matrix{Float64}`: Outward normal vectors at wall """ @kwdef struct WallGeometry3D nowall::Bool @@ -558,17 +541,13 @@ Struct holding wall geometry data for vacuum calculations. Arrays are of length dr_dθ::Matrix{Float64} dr_dζ::Matrix{Float64} normal::Matrix{Float64} - dA::Vector{Float64} end """ WallGeometry3D(inputs::VacuumInput3D, plasma_surf::PlasmaGeometry3D, wall_settings::WallShapeSettings) Contructor to initialize the 3D wall geometry based on the provided vacuum inputs and wall shape settings. - -This performs functionality similar to portions of the `arrays` function in the original -Fortran VACUUM code. It returns a `WallGeometry` struct containing the necessary wall -surface data for vacuum calculations. +Currently only works for axisymmetric walls generated by toroidal extrusion of 2D poloidal contours. # Arguments @@ -591,21 +570,116 @@ function WallGeometry3D(inputs::VacuumInput3D, plasma_surf::PlasmaGeometry3D, wa nowall = wall_settings.shape == "nowall" is_closed_toroidal = true - # All of these arrays are of length mtheta with θ = [0, 1) (; mtheta, nzeta) = inputs - num_gridpoints = mtheta * nzeta + dθ = 2π / mtheta + dζ = 2π / nzeta + θ_grid = range(; start=0, length=mtheta, step=dθ) + ϕ_grid = range(; start=0, length=nzeta, step=dζ) + num_points = mtheta * nzeta # Output wall coordinate arrays - r = zeros(num_gridpoints, 3) - normal = zeros(num_gridpoints, 3) - dA = zeros(num_gridpoints) - dr_dθ = zeros(num_gridpoints, 3) - dr_dζ = zeros(num_gridpoints, 3) + r = zeros(num_points, 3) + normal = zeros(num_points, 3) + dr_dθ = zeros(num_points, 3) + dr_dζ = zeros(num_points, 3) - if wall_settings.shape == "nowall" + if nowall @info "Using no wall" + return WallGeometry3D( + nowall, + is_closed_toroidal, + mtheta, + nzeta, + r, + dr_dθ, + dr_dζ, + normal + ) + end + + # Plasma surface coordinates (2D) + x_plasma = plasma_surf.r[1:plasma_surf.mtheta, 1] + z_plasma = plasma_surf.r[1:plasma_surf.mtheta, 3] + xmin = minimum(x_plasma) + xmax = maximum(x_plasma) + zmin = minimum(z_plasma) + zmax = maximum(z_plasma) + r_minor = 0.5 * (xmax - xmin) + r_major = 0.5 * (xmax + xmin) + + # Destructuring settings for readability + (; aw, bw, cw, dw, tw, a) = wall_settings + + if wall_settings.shape == "conformal" + dx = a * r_minor + @info "Calculating conformal wall shape $((@sprintf "%.2e" dx)) m from plasma surface." + centerstack_min = min(0.1, 0.1 * minimum(x_plasma)) + for (j, ϕ) in enumerate(ϕ_grid), i in 1:mtheta + idx = i + (j - 1) * mtheta + k_prev = mod1(i - 1, mtheta) + k_next = mod1(i + 1, mtheta) + # Compute normal direction in poloidal plane + alph = atan(x_plasma[k_next] - x_plasma[k_prev], z_plasma[k_prev] - z_plasma[k_next]) + # Wall radius in cylindrical coordinates + R_wall = max(centerstack_min, x_plasma[i] + a * r_minor * cos(alph)) + Z_wall = z_plasma[i] + a * r_minor * sin(alph) + # Map to Cartesian (X, Y, Z) + r[idx, :] .= [R_wall * cos(ϕ), R_wall * sin(ϕ), Z_wall] + end + + if any(sqrt.(r[:, 1] .^ 2 .+ r[:, 2] .^ 2) .<= centerstack_min + eps(Float64)) + @warn "Conformal wall with a=$a would cross R=0 axis; forcing minimum wall R to $(@sprintf "%.2e" centerstack_min) m to avoid unphysical geometry." + end + elseif wall_settings.shape == "elliptical" + @info "Calculating elliptical wall shape with a = $((@sprintf "%.2e" a)) m." + zrad = 0.5 * (zmax - zmin) + zh = sqrt(abs(zrad^2 - r_minor^2)) + zmuw = log((a/zh) + sqrt((a/zh)^2 + 1)) + bw_eff = (zh * cosh(zmuw)) / a + for (j, ϕ) in enumerate(ϕ_grid), (i, θ) in enumerate(θ_grid) + idx = i + (j - 1) * mtheta + r[idx, :] .= [(r_major + a * cos(θ)) * cos(ϕ), (r_major + a * cos(θ)) * sin(ϕ), -bw_eff * a * sin(θ)] + end + elseif wall_settings.shape == "dee" + error("Dee-shaped walls not yet implemented for 3D walls.") + elseif wall_settings.shape == "mod_dee" + error("Modified Dee-shaped walls not yet implemented for 3D walls.") else - error("3D wall shapes other than 'nowall' are not yet implemented.") + filepath = wall_settings.shape + !isfile(filepath) && error("ERROR: Wall geometry file $filepath does not exist. + Please set the wall shape parameter to a valid file path or a built-in shape (nowall, conformal, elliptical, dee, mod_dee).") + + open(filepath, "r") do io + npots0 = parse(Int, readline(io)) + (npots0 != num_points) && error("ERROR: $filepath contains different points ($npots0) than mtheta * nzeta ($num_points).") + # TODO: add an interpolation here for if they're different + for i in 1:num_points + line = split(readline(io)) + r[i, 1] = parse(Float64, line[1]) + r[i, 2] = parse(Float64, line[2]) + r[i, 3] = parse(Float64, line[3]) + end + end + end + + # Optional: Re-parameterization + if wall_settings.equal_arc_wall && (wall_settings.shape != "nowall") + error("Re-distributing wall points to equal arc length spacing not implemented for 3D walls yet.") + end + + # Create splines for each Cartesian component (X, Y, Z) with periodic boundary conditions + r_grid = reshape(r, mtheta, nzeta, 3) + itps = [cubic_spline_interpolation((θ_grid, ϕ_grid), r_grid[:, :, k]; bc=Periodic(OnGrid())) for k in 1:3] + + # Compute tangent vectors, normals, and differential area elements + for (i, θ) in enumerate(θ_grid), (j, ϕ) in enumerate(ϕ_grid) + idx = i + (j - 1) * mtheta + for k in 1:3 + g = Interpolations.gradient(itps[k], θ, ϕ) + dr_dθ[idx, k] = g[1] + dr_dζ[idx, k] = g[2] + end + normal[idx, :] = cross(dr_dθ[idx, :], dr_dζ[idx, :]) end return WallGeometry3D( @@ -616,7 +690,6 @@ function WallGeometry3D(inputs::VacuumInput3D, plasma_surf::PlasmaGeometry3D, wa r, dr_dθ, dr_dζ, - normal, - dA + normal ) end From 6ccac0da79eb119d3c4e7e8c2ff3af3c4856e8bb Mon Sep 17 00:00:00 2001 From: Jake Halpern Date: Thu, 5 Feb 2026 08:45:50 -0500 Subject: [PATCH 26/31] VACUUM - WIP - adding PATCH_RAD in both dimensions based on average aspect ratio of grid, not fully implemented yet --- src/DCON/Free.jl | 4 ++ src/DCON/Main.jl | 10 +++-- src/Vacuum/Vacuum.jl | 18 ++++---- src/Vacuum/Vacuum3D.jl | 5 ++- src/Vacuum/VacuumStructs.jl | 83 ++++++++++++++++++------------------- 5 files changed, 64 insertions(+), 56 deletions(-) diff --git a/src/DCON/Free.jl b/src/DCON/Free.jl index e81b5690..afe2d389 100644 --- a/src/DCON/Free.jl +++ b/src/DCON/Free.jl @@ -53,6 +53,10 @@ function free_run!(odet::OdeState, ctrl::DconControl, equil::Equilibrium.PlasmaE # Compute 3D vacuum response matrix vac_inputs = compute_vacuum_inputs(intr.psilim, 1, ctrl, equil, intr) # n doesn't matter here vac_inputs_3D = Vacuum.VacuumInput3D(vac_inputs, ctrl.nzvac, intr.nlow, intr.npert) + t_3d = @elapsed wv3D, _, _, _ = Vacuum.compute_vacuum_response_3D(vac_inputs_3D, intr.wall_settings) + if ctrl.verbose + println("3D vacuum response computation time: $(round(t_3d, digits=4))s") + end wv3D, _, _, _ = Vacuum.compute_vacuum_response_3D(vac_inputs_3D, intr.wall_settings) # Scale by (m - n*q)(m' - n'*q) diff --git a/src/DCON/Main.jl b/src/DCON/Main.jl index ee4abf28..9b119bfe 100644 --- a/src/DCON/Main.jl +++ b/src/DCON/Main.jl @@ -310,10 +310,12 @@ function write_outputs_to_HDF5(ctrl::DconControl, equil::Equilibrium.PlasmaEquil out_h5["vacuum/ep"] = vac.ep out_h5["vacuum/ev"] = vac.ev out_h5["vacuum/et"] = vac.et - out_h5["vacuum/x_plasma"] = vac.xzpts[:, 1] - out_h5["vacuum/z_plasma"] = vac.xzpts[:, 2] - out_h5["vacuum/x_wall"] = vac.xzpts[:, 3] - out_h5["vacuum/z_wall"] = vac.xzpts[:, 4] + out_h5["vacuum/x_plasma"] = vac.plasma_coords[:, 1] + out_h5["vacuum/y_plasma"] = vac.plasma_coords[:, 2] + out_h5["vacuum/z_plasma"] = vac.plasma_coords[:, 3] + out_h5["vacuum/x_wall"] = vac.wall_coords[:, 1] + out_h5["vacuum/y_wall"] = vac.wall_coords[:, 2] + out_h5["vacuum/z_wall"] = vac.wall_coords[:, 3] end end end diff --git a/src/Vacuum/Vacuum.jl b/src/Vacuum/Vacuum.jl index 2f77659f..ba9e17e3 100644 --- a/src/Vacuum/Vacuum.jl +++ b/src/Vacuum/Vacuum.jl @@ -239,9 +239,9 @@ function compute_vacuum_response(inputs::VacuumInput, wall_settings::WallShapeSe # First mpert columns are real (cosine), second mpert are imaginary (sine) green_fourier_rows = wall.nowall ? mtheta : 2 * mtheta green_fourier = zeros(green_fourier_rows, 2 * mpert) - PLASMA_ROW_OFFSET = 0 + PLASMA_ROW_OFFSET = 0; WALL_ROW_OFFSET = mtheta - COS_COL_OFFSET = 0 + COS_COL_OFFSET = 0; SIN_COL_OFFSET = mpert # Plasma–Plasma block @@ -341,6 +341,8 @@ function compute_vacuum_response_3D(inputs::VacuumInput3D, wall_settings::WallSh # TODO: Currently only supports axisymmetric surfaces plasma_surf = PlasmaGeometry3D(inputs) wall = WallGeometry3D(inputs, plasma_surf, wall_settings) + patch_rad_pol = PATCH_RAD; + patch_rad_tor = PATCH_RAD #round(Int, PATCH_RAD * plasma_surf.aspect_ratio) # Allocate based on wall presence grad_green_size = wall.nowall ? num_points : 2 * num_points @@ -351,13 +353,13 @@ function compute_vacuum_response_3D(inputs::VacuumInput3D, wall_settings::WallSh # First num_modes columns are real (cosine), second num_modes are imaginary (sine) green_fourier_rows = wall.nowall ? num_points : 2 * num_points green_fourier = zeros(green_fourier_rows, 2 * num_modes) - PLASMA_ROW_OFFSET = 0 + PLASMA_ROW_OFFSET = 0; WALL_ROW_OFFSET = num_points - COS_COL_OFFSET = 0 + COS_COL_OFFSET = 0; SIN_COL_OFFSET = num_modes # Plasma–Plasma block - compute_3D_kernel_matrix!(grad_green, green_temp, plasma_surf, plasma_surf, PATCH_RAD, RAD_DIM, INTERP_ORDER) + compute_3D_kernel_matrix!(grad_green, green_temp, plasma_surf, plasma_surf, patch_rad_pol, patch_rad_tor, RAD_DIM, INTERP_ORDER) # Fourier transform obs=plasma, src=plasma block into green_fourier fourier_transform!(green_fourier, green_temp, plasma_surf.cos_mn_basis3D, PLASMA_ROW_OFFSET, COS_COL_OFFSET) @@ -365,11 +367,11 @@ function compute_vacuum_response_3D(inputs::VacuumInput3D, wall_settings::WallSh if !wall.nowall # Plasma–Wall block - compute_3D_kernel_matrix!(grad_green, green_temp, plasma_surf, wall, PATCH_RAD, RAD_DIM, INTERP_ORDER) + compute_3D_kernel_matrix!(grad_green, green_temp, plasma_surf, wall, patch_rad_pol, patch_rad_tor, RAD_DIM, INTERP_ORDER) # Wall–Wall block - compute_3D_kernel_matrix!(grad_green, green_temp, wall, wall, PATCH_RAD, RAD_DIM, INTERP_ORDER) + compute_3D_kernel_matrix!(grad_green, green_temp, wall, wall, patch_rad_pol, patch_rad_tor, RAD_DIM, INTERP_ORDER) # Wall–Plasma block - compute_3D_kernel_matrix!(grad_green, green_temp, wall, plasma_surf, PATCH_RAD, RAD_DIM, INTERP_ORDER) + compute_3D_kernel_matrix!(grad_green, green_temp, wall, plasma_surf, patch_rad_pol, patch_rad_tor, RAD_DIM, INTERP_ORDER) # Fourier transform obs=wall, src=plasma block into green_fourier fourier_transform!(green_fourier, green_temp, plasma_surf.cos_mn_basis3D, WALL_ROW_OFFSET, COS_COL_OFFSET) diff --git a/src/Vacuum/Vacuum3D.jl b/src/Vacuum/Vacuum3D.jl index d8e512d4..35c8cf90 100644 --- a/src/Vacuum/Vacuum3D.jl +++ b/src/Vacuum/Vacuum3D.jl @@ -405,7 +405,8 @@ function compute_3D_kernel_matrix!( greenfunction::Matrix{Float64}, observer::Union{PlasmaGeometry3D,WallGeometry3D}, source::Union{PlasmaGeometry3D,WallGeometry3D}, - PATCH_RAD::Int, + PATCH_RAD_POL::Int, + PATCH_RAD_TOR::Int, RAD_DIM::Int, INTERP_ORDER::Int ) @@ -427,7 +428,7 @@ function compute_3D_kernel_matrix!( populate_greenfunction = source isa PlasmaGeometry3D # Initialize quadrature data - quad_data = get_singular_quadrature(PATCH_RAD, RAD_DIM, INTERP_ORDER) + quad_data = get_singular_quadrature(PATCH_RAD_POL, RAD_DIM, INTERP_ORDER) (; PATCH_DIM, PATCH_RAD, ANG_DIM, RAD_DIM, Ppou, Gpou, P2G) = quad_data @assert observer.mtheta ≥ PATCH_DIM @assert observer.nzeta ≥ PATCH_DIM diff --git a/src/Vacuum/VacuumStructs.jl b/src/Vacuum/VacuumStructs.jl index a301d48c..5467c2bc 100644 --- a/src/Vacuum/VacuumStructs.jl +++ b/src/Vacuum/VacuumStructs.jl @@ -143,14 +143,14 @@ of size (mtheta, mpert), where `mpert` is the number of poloidal modes. - `sin_mn_basis::Matrix{Float64}`: sin(mθ - nν) basis functions for poloidal modes at plasma surface - `cos_mn_basis::Matrix{Float64}`: cos(mθ - nν) basis functions for poloidal modes at plasma surface """ -struct PlasmaGeometry - x::Vector{Float64} - z::Vector{Float64} - ν::Vector{Float64} - dx_dtheta::Vector{Float64} - dz_dtheta::Vector{Float64} - sin_mn_basis::Matrix{Float64} - cos_mn_basis::Matrix{Float64} +@kwdef struct PlasmaGeometry + x::Vector{Float64} = Float64[] + z::Vector{Float64} = Float64[] + ν::Vector{Float64} = Float64[] + dx_dtheta::Vector{Float64} = Float64[] + dz_dtheta::Vector{Float64} = Float64[] + sin_mn_basis::Matrix{Float64} = zeros(1, 1) + cos_mn_basis::Matrix{Float64} = zeros(1, 1) end """ @@ -225,19 +225,21 @@ that the gradient/area elements are scaled by dθ and dζ. - `r::Matrix{Float64}`: Surface points in Cartesian (X,Y,Z), shape (num_gridpoints, 3) - `dr_dθ::Matrix{Float64}`: Poloidal tangent vector ∂r/∂θ × dθ, shape (num_gridpoints, 3) - `dr_dζ::Matrix{Float64}`: Toroidal tangent vector ∂r/∂ζ × dζ, shape (num_gridpoints, 3) - - `n::Matrix{Float64}`: Outward normal vectors, shape (num_gridpoints, 3) + - `normal::Matrix{Float64}`: Outward normal vectors, shape (num_gridpoints, 3) - `sin_mn_basis3D::Matrix{Float64}`: sin(mθ - nν - nϕ) basis functions at plasma surface - `cos_mn_basis3D::Matrix{Float64}`: cos(mθ - nν - nϕ) basis functions at plasma surface + - `aspect_ratio::Float64`: Ratio of max to min grid spacing for anisotropy analysis """ @kwdef struct PlasmaGeometry3D - mtheta::Int - nzeta::Int - r::Matrix{Float64} - dr_dθ::Matrix{Float64} - dr_dζ::Matrix{Float64} - normal::Matrix{Float64} - sin_mn_basis3D::Matrix{Float64} - cos_mn_basis3D::Matrix{Float64} + mtheta::Int = 1 + nzeta::Int = 1 + r::Matrix{Float64} = zeros(1, 3) + dr_dθ::Matrix{Float64} = zeros(1, 3) + dr_dζ::Matrix{Float64} = zeros(1, 3) + normal::Matrix{Float64} = zeros(1, 3) + sin_mn_basis3D::Matrix{Float64} = zeros(1, 1) + cos_mn_basis3D::Matrix{Float64} = zeros(1, 1) + aspect_ratio::Float64 = 1.0 end """ @@ -308,23 +310,19 @@ function PlasmaGeometry3D(inputs::VacuumInput3D) # Warn if grid spacing is highly anisotropic spacing_θ = sqrt(sum(abs2, dr_dθ) / size(dr_dθ, 1)) * dθ spacing_ζ = sqrt(sum(abs2, dr_dζ) / size(dr_dζ, 1)) * dζ - aspect_ratio = max(spacing_θ, spacing_ζ) / min(spacing_θ, spacing_ζ) + aspect_ratio = spacing_ζ / spacing_θ @info "Average grid spacing [m]: dθ=$(round(spacing_θ, digits=4)), dζ=$(round(spacing_ζ, digits=4)), aspect ratio=$(round(aspect_ratio, digits=2))" aspect_ratio > 10.0 && @warn "Grid aspect ratio is highly anisotropic, which may degrade quadrature accuracy" # Precompute Fourier transform terms, sin(lθ - nν(θ) - nϕ) and cos(lθ - nν(θ) - nϕ) sin_mn_basis3D = zeros(num_points, mpert*npert) cos_mn_basis3D = zeros(num_points, mpert*npert) - for idx_n in 1:npert + for idx_n in 1:npert, idx_m in 1:mpert n = nlow + idx_n - 1 - for idx_m in 1:mpert - m = mlow + idx_m - 1 - for j in 1:nzeta - for i in 1:mtheta - cos_mn_basis3D[i+(j-1)*mtheta, idx_m+(idx_n-1)*mpert] = cos(m * θ_grid[i] - n * (ν[i] + ϕ_grid[j])) - sin_mn_basis3D[i+(j-1)*mtheta, idx_m+(idx_n-1)*mpert] = sin(m * θ_grid[i] - n * (ν[i] + ϕ_grid[j])) - end - end + m = mlow + idx_m - 1 + for (j, ϕ) in enumerate(ϕ_grid), (i, θ) in enumerate(θ_grid) + cos_mn_basis3D[i+(j-1)*mtheta, idx_m+(idx_n-1)*mpert] = cos(m * θ - n * (ν[i] + ϕ)) + sin_mn_basis3D[i+(j-1)*mtheta, idx_m+(idx_n-1)*mpert] = sin(m * θ - n * (ν[i] + ϕ)) end end @@ -336,7 +334,8 @@ function PlasmaGeometry3D(inputs::VacuumInput3D) dr_dζ, normal, sin_mn_basis3D, - cos_mn_basis3D + cos_mn_basis3D, + aspect_ratio ) end @@ -356,12 +355,12 @@ Struct holding wall geometry data for vacuum calculations. Arrays are of length - `dz_dtheta::Vector{Float64}`: Derivative dZ/dθ at wall """ @kwdef struct WallGeometry - nowall::Bool - is_closed_toroidal::Bool - x::Vector{Float64} - z::Vector{Float64} - dx_dtheta::Vector{Float64} - dz_dtheta::Vector{Float64} + nowall::Bool = true + is_closed_toroidal::Bool = true + x::Vector{Float64} = Float64[] + z::Vector{Float64} = Float64[] + dx_dtheta::Vector{Float64} = Float64[] + dz_dtheta::Vector{Float64} = Float64[] end """ @@ -533,14 +532,14 @@ Struct holding wall geometry data for vacuum calculations. Arrays are of length - `normal::Matrix{Float64}`: Outward normal vectors at wall """ @kwdef struct WallGeometry3D - nowall::Bool - is_closed_toroidal::Bool - mtheta::Int - nzeta::Int - r::Matrix{Float64} - dr_dθ::Matrix{Float64} - dr_dζ::Matrix{Float64} - normal::Matrix{Float64} + nowall::Bool = true + is_closed_toroidal::Bool = true + mtheta::Int = 1 + nzeta::Int = 1 + r::Matrix{Float64} = zeros(1, 3) + dr_dθ::Matrix{Float64} = zeros(1, 3) + dr_dζ::Matrix{Float64} = zeros(1, 3) + normal::Matrix{Float64} = zeros(1, 3) end """ From 2a29d8bc9a3f9b50f245bbb7853998e5b8cda908 Mon Sep 17 00:00:00 2001 From: Jake Halpern Date: Thu, 5 Feb 2026 14:17:28 -0500 Subject: [PATCH 27/31] VACUUM - WIP - updating project.toml --- Project.toml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Project.toml b/Project.toml index f84c7e1e..043a74c0 100644 --- a/Project.toml +++ b/Project.toml @@ -7,8 +7,8 @@ version = "0.1.0" [deps] DiffEqCallbacks = "459566f4-90b8-5000-8ac3-15dfb0a30def" Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" -FastGaussQuadrature = "442a2c76-b920-505d-bb47-c5924d526838" FFTW = "7a1cc6ca-52ef-59f5-83cd-3a7055c09341" +FastGaussQuadrature = "442a2c76-b920-505d-bb47-c5924d526838" HDF5 = "f67ccb44-e63f-5c2f-98bd-6dc0ccc4ba2f" Interpolations = "a98d9a8b-a2ab-59e6-89dd-64a1c18fca59" JLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819" @@ -17,6 +17,7 @@ OrdinaryDiffEq = "1dea7af3-3e70-54e6-95c3-0bf5283fa5ed" Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f" Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80" Printf = "de0858da-6303-5e67-8744-51eddeeeb8d7" +SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" SpecialFunctions = "276daf66-3868-5448-9aa4-cd146d93841b" StaticArrays = "90137ffa-7385-5640-81b9-e52037218182" TOML = "fa267f1f-6049-4f14-aa54-33bafae1ed76" @@ -25,8 +26,8 @@ Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [compat] DiffEqCallbacks = "4.9.0" Documenter = "1.14.1" -FastGaussQuadrature = "1" FFTW = "1.9.0" +FastGaussQuadrature = "1" HDF5 = "0.17.2" Interpolations = "0.16.1" JLD2 = "0.6.3" @@ -35,6 +36,7 @@ OrdinaryDiffEq = "6.102.0" Pkg = "1.11.0" Plots = "1.40.15" Printf = "1.11.0" +SparseArrays = "1.11.0" SpecialFunctions = "2.5.1" StaticArrays = "1.9.15" TOML = "1.0.3" From b5977ff4e10f0b891b68a14ccae4f0036a0ec52a Mon Sep 17 00:00:00 2001 From: Jake Halpern Date: Thu, 5 Feb 2026 16:38:49 -0500 Subject: [PATCH 28/31] VACUUM - WIP - implementing normal_orient, fixing 2D bugs in the wall geometry --- src/DCON/Free.jl | 10 +++-- src/Vacuum/Vacuum.jl | 8 ++-- src/Vacuum/Vacuum3D.jl | 19 +++++----- src/Vacuum/VacuumInternals.jl | 53 +++++++++++++------------- src/Vacuum/VacuumStructs.jl | 70 +++++++++++++++++++++-------------- 5 files changed, 91 insertions(+), 69 deletions(-) diff --git a/src/DCON/Free.jl b/src/DCON/Free.jl index afe2d389..23f22a95 100644 --- a/src/DCON/Free.jl +++ b/src/DCON/Free.jl @@ -57,7 +57,6 @@ function free_run!(odet::OdeState, ctrl::DconControl, equil::Equilibrium.PlasmaE if ctrl.verbose println("3D vacuum response computation time: $(round(t_3d, digits=4))s") end - wv3D, _, _, _ = Vacuum.compute_vacuum_response_3D(vac_inputs_3D, intr.wall_settings) # Scale by (m - n*q)(m' - n'*q) singfac = vec((intr.mlow:intr.mhigh) .- intr.qlim .* (intr.nlow:intr.nhigh)') @@ -82,6 +81,9 @@ function free_run!(odet::OdeState, ctrl::DconControl, equil::Equilibrium.PlasmaE error("Vacuum response matrix computation complete.") end + println("2D Vacuum response matrix wv:") + display(vac.wv) + # Compute complex energy eigenvalues and vectors vac.wt .= wp .+ vac.wv vac.wt0 .= vac.wt @@ -192,9 +194,9 @@ function compute_vacuum_inputs(ψ::Float64, n::Int, ctrl::DconControl, equil::Eq end return Vacuum.VacuumInput(; - r=reverse(R), - z=reverse(Z), - ν=reverse(ν), + r=R, + z=Z, + ν=ν, mlow=intr.mlow, mpert=intr.mpert, n=n, diff --git a/src/Vacuum/Vacuum.jl b/src/Vacuum/Vacuum.jl index ba9e17e3..b8d5772d 100644 --- a/src/Vacuum/Vacuum.jl +++ b/src/Vacuum/Vacuum.jl @@ -239,9 +239,9 @@ function compute_vacuum_response(inputs::VacuumInput, wall_settings::WallShapeSe # First mpert columns are real (cosine), second mpert are imaginary (sine) green_fourier_rows = wall.nowall ? mtheta : 2 * mtheta green_fourier = zeros(green_fourier_rows, 2 * mpert) - PLASMA_ROW_OFFSET = 0; + PLASMA_ROW_OFFSET = 0 WALL_ROW_OFFSET = mtheta - COS_COL_OFFSET = 0; + COS_COL_OFFSET = 0 SIN_COL_OFFSET = mpert # Plasma–Plasma block @@ -353,9 +353,9 @@ function compute_vacuum_response_3D(inputs::VacuumInput3D, wall_settings::WallSh # First num_modes columns are real (cosine), second num_modes are imaginary (sine) green_fourier_rows = wall.nowall ? num_points : 2 * num_points green_fourier = zeros(green_fourier_rows, 2 * num_modes) - PLASMA_ROW_OFFSET = 0; + PLASMA_ROW_OFFSET = 0 WALL_ROW_OFFSET = num_points - COS_COL_OFFSET = 0; + COS_COL_OFFSET = 0 SIN_COL_OFFSET = num_modes # Plasma–Plasma block diff --git a/src/Vacuum/Vacuum3D.jl b/src/Vacuum/Vacuum3D.jl index 35c8cf90..f6cadb1a 100644 --- a/src/Vacuum/Vacuum3D.jl +++ b/src/Vacuum/Vacuum3D.jl @@ -228,8 +228,7 @@ scalar arithmetic is used for maximum performance. The double-layer kernel K is the normal derivative of the fundamental solution: ``` -K(x_obs, x_src, n_src) = ∇_{x_src} φ · n̂_src - = -1/(4π) * (x_obs - x_src) · n̂_src / |x_obs - x_src|³ +K(x_obs, x_src, n_src) = ∇_{x_src} φ · n_src = 1/(4π) * (x_obs - x_src) · n_src / |x_obs - x_src|³ ``` # Arguments @@ -255,7 +254,7 @@ function laplace_double_layer(x_obs::AbstractVector{<:Real}, x_src::AbstractVect r2 < 1e-30 && return 0.0 rinv = inv(sqrt(r2)) r3inv = rinv * rinv * rinv - return -(dx*nx + dy*ny + dz*nz) * (INV_4PI * r3inv) + return (dx*nx + dy*ny + dz*nz) * (INV_4PI * r3inv) end """ @@ -309,20 +308,24 @@ end compute_polar_normal!(n_polar, dr_dθ_polar, dr_dζ_polar) Compute normal vector (= ∂r/∂θ × ∂r/∂ζ) at polar quadrature points from interpolated tangent vectors. +We already scaled the normals by normal_orient in the geometry construction, so we need to reapply +that here since we are recomputing the normals from the derivatives. # Arguments - `n_polar`: Preallocation unit normal vector at each polar point (RAD_DIM × ANG_DIM × 3) - `dr_dθ_polar`: Interpolated ∂r/∂θ at polar points (RAD_DIM × ANG_DIM × 3) - `dr_dζ_polar`: Interpolated ∂r/∂ζ at polar points (RAD_DIM × ANG_DIM × 3) + - `normal_orient`: Multiplier applied to normals to make them orient out of vacuum region (+1 or -1) """ -function compute_polar_normal!(n_polar::Array{Float64,3}, dr_dθ::Array{Float64,3}, dr_dζ::Array{Float64,3}) +function compute_polar_normal!(n_polar::Array{Float64,3}, dr_dθ::Array{Float64,3}, dr_dζ::Array{Float64,3}, normal_orient::Int) # Inline cross product to avoid slice allocation @inbounds for ia in axes(dr_dθ, 2), ir in axes(dr_dθ, 1) n_polar[ir, ia, 1] = dr_dθ[ir, ia, 2] * dr_dζ[ir, ia, 3] - dr_dθ[ir, ia, 3] * dr_dζ[ir, ia, 2] n_polar[ir, ia, 2] = dr_dθ[ir, ia, 3] * dr_dζ[ir, ia, 1] - dr_dθ[ir, ia, 1] * dr_dζ[ir, ia, 3] n_polar[ir, ia, 3] = dr_dθ[ir, ia, 1] * dr_dζ[ir, ia, 2] - dr_dθ[ir, ia, 2] * dr_dζ[ir, ia, 1] end + n_polar .*= normal_orient end """ @@ -481,7 +484,7 @@ function compute_3D_kernel_matrix!( interpolate_to_polar!(dr_dζ_polar, dr_dζ_patch, P2G) # Compute normal vectors at polar points from interpolated tangent vectors - compute_polar_normal!(n_polar, dr_dθ_polar, dr_dζ_polar) + compute_polar_normal!(n_polar, dr_dθ_polar, dr_dζ_polar, source.normal_orient) # Evaluate kernels at polar points with POU weighting @inbounds for ia in 1:ANG_DIM, ir in 1:RAD_DIM @@ -525,10 +528,8 @@ function compute_3D_kernel_matrix!( end end - # TODO: Don't delete this yet - signs might change depending on convention. I think it might be -1 for wall, - # since we calculate n = dr_dθ × dr_dζ which points inward for a toroidal surface. Should add in normal - # orient for this later for generalization. # Account for normal direction pointing out of vacuum integration region in 𝒦ⁿ ⋅ dS # Negative for plasma since dS = ∇ψ J dθdζ and ∇ψ points outward but outward normal is inward - @views grad_greenfunction_block .*= (source isa PlasmaGeometry3D ? 1 : -1) + # grad_greenfunction_block .*= (source isa PlasmaGeometry3D ? -1 : 1) + # grad_greenfunction_block .*= source.normal_orient end diff --git a/src/Vacuum/VacuumInternals.jl b/src/Vacuum/VacuumInternals.jl index be2f3b9b..34ed0548 100644 --- a/src/Vacuum/VacuumInternals.jl +++ b/src/Vacuum/VacuumInternals.jl @@ -86,7 +86,7 @@ function get_lagrange_stencils(gaussian_points::AbstractVector{<:Real}) end """ - kernel!(grad_greenfunction_mat, greenfunction_mat, observer, source, n) + kernel!(grad_greenfunction, greenfunction, observer, source, n) Compute kernels of integral equation for Laplace's equation in a torus. @@ -95,17 +95,17 @@ The residue calculation needs to be updated for open walls.** # Arguments - - `grad_greenfunction_mat`: Gradient Green's function matrix (output) - - `greenfunction_mat`: Green's function matrix (output) + - `grad_greenfunction`: Gradient Green's function matrix (output) + - `greenfunction`: Green's function matrix (output) - `observer`: Observer geometry struct (PlasmaGeometry or WallGeometry) - `source`: Source geometry struct (PlasmaGeometry or WallGeometry) - `n`: Toroidal mode number # Returns -Modifies `grad_greenfunction_mat` and `greenfunction_mat` in place. -Note that greenfunction_mat is zeroed each time this function is called, -but grad_greenfunction_mat is not since it fills a different block of the +Modifies `grad_greenfunction` and `greenfunction` in place. +Note that greenfunction is zeroed each time this function is called, +but grad_greenfunction is not since it fills a different block of the (2 * mtheta, 2 * mtheta) depending on the source/observer. # Notes @@ -115,8 +115,8 @@ but grad_greenfunction_mat is not since it fills a different block of the - Implements analytical singularity removal following Chance 1997 """ function kernel!( - grad_greenfunction_mat::Matrix{Float64}, - greenfunction_mat::Matrix{Float64}, + grad_greenfunction::Matrix{Float64}, + greenfunction::Matrix{Float64}, observer::Union{PlasmaGeometry,WallGeometry}, source::Union{PlasmaGeometry,WallGeometry}, n::Int; @@ -127,17 +127,17 @@ function kernel!( dtheta = 2π / mtheta theta_grid = range(; start=0, length=mtheta, step=dtheta) - # Take a view of the corresponding block of the grad_greenfunction_mat + # Take a view of the corresponding block of the grad_greenfunction col_index = (source isa PlasmaGeometry ? 1 : 2) row_index = (observer isa PlasmaGeometry ? 1 : 2) grad_greenfunction_block = view( - grad_greenfunction_mat, + grad_greenfunction, ((row_index-1)*mtheta+1):(row_index*mtheta), ((col_index-1)*mtheta+1):(col_index*mtheta) ) - # Zero out greenfunction_mat at start of each kernel call - fill!(greenfunction_mat, 0.0) + # Zero out greenfunction at start of each kernel call + fill!(greenfunction, 0.0) # 𝒢ⁿ only needed for plasma as source term (RHS of eqs. 26/27 in Chance 1997) populate_greenfunction = source isa PlasmaGeometry @@ -180,15 +180,15 @@ function kernel!( # Perform Simpson integration for nonsingular source points for (isrc, wsimpson) in zip(nonsing_idx, simpson_weights) - G_n, coupling_n, coupling_0 = green(x_obs, z_obs, source.x[isrc], source.z[isrc], source.dx_dtheta[isrc], source.dz_dtheta[isrc], n) + G_n, gradG_n, gradG_0 = green(x_obs, z_obs, source.x[isrc], source.z[isrc], source.dx_dtheta[isrc], source.dz_dtheta[isrc], n) # Sum contributions to Green's function matrices using Simpson weight if populate_greenfunction - greenfunction_mat[j, isrc] += G_n * wsimpson + greenfunction[j, isrc] += G_n * wsimpson end - grad_greenfunction_block[j, isrc] += coupling_n * wsimpson + grad_greenfunction_block[j, isrc] += gradG_n * wsimpson # Subtract regular integral component of δⱼᵢK⁰ in eq. 83 - grad_greenfunction_block[j, j] -= coupling_0 * wsimpson + grad_greenfunction_block[j, j] -= gradG_0 * wsimpson end # Perform Gaussian quadrature for singular points (source = obs point) @@ -206,7 +206,7 @@ function kernel!( dx_dtheta_gauss = Interpolations.gradient(spline_x, theta_gauss0)[1] z_gauss = spline_z(theta_gauss0) dz_dtheta_gauss = Interpolations.gradient(spline_z, theta_gauss0)[1] - G_n, coupling_n, coupling_0 = green(x_obs, z_obs, x_gauss, z_gauss, dx_dtheta_gauss, dz_dtheta_gauss, n) + G_n, gradG_n, gradG_0 = green(x_obs, z_obs, x_gauss, z_gauss, dx_dtheta_gauss, dz_dtheta_gauss, n) # First type of singularity: 𝒢ⁿ (Eq. 75: 2π𝒢ⁿ + log(θ-θ')²/X') if populate_greenfunction @@ -214,27 +214,30 @@ function kernel!( # Remove singular behavior by adding on leading-order term, Chance eq.(75) G_n += log((theta_obs - theta_gauss[ig])^2) / x_obs end - @. @views greenfunction_mat[j, sing_idx] += wgauss[ig] * G_n * lagrange_stencil[ig] + @. @views greenfunction[j, sing_idx] += wgauss[ig] * G_n * lagrange_stencil[ig] end # Second type of singularity: 𝒦ⁿ (Eq. 86: 𝒦ⁿαᵢ - δⱼᵢK⁰) - @. @views grad_greenfunction_block[j, sing_idx] += wgauss[ig] * coupling_n * lagrange_stencil[ig] - grad_greenfunction_block[j, j] -= coupling_0 * wgauss[ig] + @. @views grad_greenfunction_block[j, sing_idx] += wgauss[ig] * gradG_n * lagrange_stencil[ig] + grad_greenfunction_block[j, j] -= gradG_0 * wgauss[ig] end end # Add analytic singular integral (first type) from Chance eq. 78 if populate_greenfunction && observer isa PlasmaGeometry - @. @views greenfunction_mat[j, sing_idx] -= log_correction / x_obs + @. @views greenfunction[j, sing_idx] -= log_correction / x_obs end end - # Account for normal direction pointing out of vacuum integration region in 𝒦ⁿ ⋅ dS, previously isgn - # Negative for plasma since dS = ∇ψ J dθdζ and ∇ψ points outward but outward normal is inward - grad_greenfunction_block .*= (source isa PlasmaGeometry ? -1 : 1) + # Normals need to point outward from vacuum region. In CCW θ convention, normal points + # out of vacuum for plasma but inward for wall, so we multiply by -1 for wall sources + if source isa WallGeometry + grad_greenfunction_block .*= -1 + end + # Since we computed 2π𝒢, divide by 2π to get 𝒢 - greenfunction_mat ./= 2π + greenfunction ./= 2π # Add analytic singular integral (second type) from Table I of Chance 1997 + existing δⱼᵢ in eq. 69 # Would need to pass in wall geometry to generalize this to open walls diff --git a/src/Vacuum/VacuumStructs.jl b/src/Vacuum/VacuumStructs.jl index 5467c2bc..57570d7e 100644 --- a/src/Vacuum/VacuumStructs.jl +++ b/src/Vacuum/VacuumStructs.jl @@ -225,10 +225,11 @@ that the gradient/area elements are scaled by dθ and dζ. - `r::Matrix{Float64}`: Surface points in Cartesian (X,Y,Z), shape (num_gridpoints, 3) - `dr_dθ::Matrix{Float64}`: Poloidal tangent vector ∂r/∂θ × dθ, shape (num_gridpoints, 3) - `dr_dζ::Matrix{Float64}`: Toroidal tangent vector ∂r/∂ζ × dζ, shape (num_gridpoints, 3) - - `normal::Matrix{Float64}`: Outward normal vectors, shape (num_gridpoints, 3) + - `normal::Matrix{Float64}`: Oriented normal vectors, shape (num_gridpoints, 3) - `sin_mn_basis3D::Matrix{Float64}`: sin(mθ - nν - nϕ) basis functions at plasma surface - `cos_mn_basis3D::Matrix{Float64}`: cos(mθ - nν - nϕ) basis functions at plasma surface - `aspect_ratio::Float64`: Ratio of max to min grid spacing for anisotropy analysis + - `normal_orient::Int`: Forces normals to face out from vacuum region (+1 or -1) """ @kwdef struct PlasmaGeometry3D mtheta::Int = 1 @@ -240,6 +241,7 @@ that the gradient/area elements are scaled by dθ and dζ. sin_mn_basis3D::Matrix{Float64} = zeros(1, 1) cos_mn_basis3D::Matrix{Float64} = zeros(1, 1) aspect_ratio::Float64 = 1.0 + normal_orient::Int = 1 end """ @@ -307,6 +309,11 @@ function PlasmaGeometry3D(inputs::VacuumInput3D) normal[idx, :] = cross(dr_dθ[idx, :], dr_dζ[idx, :]) end + # Determine normal orientation (inward for plasma) and enforce it + idx = argmax(view(r, :, 1)) # outboard midplane + normal_orient = normal[idx, 1] < 0 ? 1 : -1 + normal .*= normal_orient + # Warn if grid spacing is highly anisotropic spacing_θ = sqrt(sum(abs2, dr_dθ) / size(dr_dθ, 1)) * dθ spacing_ζ = sqrt(sum(abs2, dr_dζ) / size(dr_dζ, 1)) * dζ @@ -335,7 +342,8 @@ function PlasmaGeometry3D(inputs::VacuumInput3D) normal, sin_mn_basis3D, cos_mn_basis3D, - aspect_ratio + aspect_ratio, + normal_orient ) end @@ -399,6 +407,7 @@ function WallGeometry(inputs::VacuumInput, plasma_surf::PlasmaGeometry, wall_set z_wall = zeros(mtheta) dx_dtheta = zeros(mtheta) dz_dtheta = zeros(mtheta) + θ_grid = range(; start=0, length=mtheta, step=2π/mtheta) if nowall @info "Using no wall" @@ -430,10 +439,11 @@ function WallGeometry(inputs::VacuumInput, plasma_surf::PlasmaGeometry, wall_set @info "Calculating conformal wall shape $((@sprintf "%.2e" dx)) m from plasma surface." centerstack_min = min(0.1, 0.1 * minimum(x_plasma)) # Avoid wall crossing R=0 axis for i in 1:mtheta - j = mod1(i - 1, mtheta) - k = mod1(i + 1, mtheta) - # Normal vector calculation - alph = atan(x_plasma[k] - x_plasma[j], z_plasma[j] - z_plasma[k]) + prev = mod1(i - 1, mtheta) + next = mod1(i + 1, mtheta) + # Approximate local tangent t = (dx, dz) using centered finite differences, t ≈ (dx, dz) + # Then, extend in normal direction, n = (-dz, dx) + alph = -atan(x_plasma[next] - x_plasma[prev], z_plasma[next] - z_plasma[prev]) x_wall[i] = max(centerstack_min, x_plasma[i] + a * r_minor * cos(alph)) z_wall[i] = z_plasma[i] + a * r_minor * sin(alph) end @@ -441,30 +451,28 @@ function WallGeometry(inputs::VacuumInput, plasma_surf::PlasmaGeometry, wall_set @warn "Conformal wall with a=$a would cross R=0 axis; forcing minimum wall R to $(@sprintf "%.2e" centerstack_min) m to avoid unphysical geometry." end elseif wall_settings.shape == "elliptical" + # TODO: need to verify I fixed these walls shapes for CCW correctly @info "Calculating elliptical wall shape with a = $((@sprintf "%.2e" a)) m." zrad = 0.5 * (zmax - zmin) zh = sqrt(abs(zrad^2 - r_minor^2)) zmuw = log((a/zh) + sqrt((a/zh)^2 + 1)) bw_eff = (zh * cosh(zmuw)) / a - for i in 1:mtheta - the = (i - 1) * (2π / mtheta) - x_wall[i] = r_major + a * cos(the) - z_wall[i] = -bw_eff * a * sin(the) + for (i, θ) in enumerate(θ_grid) + x_wall[i] = r_major + a * cos(θ) + z_wall[i] = bw_eff * a * sin(θ) end elseif wall_settings.shape == "dee" wcentr = r_major + cw * r_minor @info "Calculating dee-shaped wall with R = $((@sprintf "%.2e" wcentr)) + $((@sprintf "%.2e" r_minor)) * (1.0 + $((@sprintf "%.2e" a)) - $((@sprintf "%.2e" cw))) * cos(θ + $((@sprintf "%.2e" dw)) * sin(θ)), Z = -$((@sprintf "%.2e" bw)) * $((@sprintf "%.2e" r_minor)) * (1.0 + $((@sprintf "%.2e" a)) - $((@sprintf "%.2e" cw))) * sin(θ + $((@sprintf "%.2e" tw)) * sin(2θ)) - $((@sprintf "%.2e" aw)) * $((@sprintf "%.2e" r_minor)) * sin(2θ)." - for i in 1:mtheta - the = (i - 1) * (2π / mtheta) - x_wall[i] = wcentr + r_minor * (1.0 + a - cw) * cos(the + dw * sin(the)) - z_wall[i] = -bw * r_minor * (1.0 + a - cw) * sin(the + tw * sin(2.0*the)) - aw * r_minor * sin(2.0*the) + for (i, θ) in enumerate(θ_grid) + x_wall[i] = wcentr + r_minor * (1.0 + a - cw) * cos(θ + dw * sin(θ)) + z_wall[i] = bw * r_minor * (1 + a - cw) * sin(θ + tw * sin(2 * θ)) - aw * r_minor * sin(2 * θ) end elseif wall_settings.shape == "mod_dee" @info "Calculating modified dee-shaped wall with R = $((@sprintf "%.2e" cw)) + $((@sprintf "%.2e" a)) * cos(θ + $((@sprintf "%.2e" dw)) * sin(θ)), Z = -$((@sprintf "%.2e" bw)) * $((@sprintf "%.2e" a)) * sin(θ + $((@sprintf "%.2e" tw)) * sin(2θ)) - $((@sprintf "%.2e" aw)) * sin(2θ)." - for i in 1:mtheta - the = (i - 1) * (2π / mtheta) - x_wall[i] = cw + a * cos(the + dw * sin(the)) - z_wall[i] = -bw * a * sin(the + tw * sin(2.0*the)) - aw * sin(2.0*the) + for (i, θ) in enumerate(θ_grid) + x_wall[i] = cw + a * cos(θ + dw * sin(θ)) + z_wall[i] = bw * a * sin(θ + tw * sin(2 * θ)) - aw * sin(2 * θ) end else filepath = wall_settings.shape @@ -478,8 +486,8 @@ function WallGeometry(inputs::VacuumInput, plasma_surf::PlasmaGeometry, wall_set for i in 1:mtheta line = split(readline(io)) # Assumes file format: [index R_coord Z_coord] - x_wall[i] = parse(Float64, line[2]) - z_wall[i] = parse(Float64, line[3]) + x_wall[i] = parse(line[2]) + z_wall[i] = parse(line[3]) end end end @@ -496,9 +504,8 @@ function WallGeometry(inputs::VacuumInput, plasma_surf::PlasmaGeometry, wall_set dz_dtheta = only.(Interpolations.gradient.(Ref(fz_of_theta), theta_grid)) else # used regular theta grid spacing to build wall - theta_grid = range(0; stop=2π, length=mtheta + 1)[1:(end-1)] # length mtheta without endpoint - dx_dtheta = periodic_cubic_deriv(theta_grid, x_wall) - dz_dtheta = periodic_cubic_deriv(theta_grid, z_wall) + dx_dtheta = periodic_cubic_deriv(θ_grid, x_wall) + dz_dtheta = periodic_cubic_deriv(θ_grid, z_wall) end # to add support for x<0 walls, be sure to carefully replicate Chance's fortran code x<0 handling in the kernel function to account for the additional singularities associated with this @@ -540,6 +547,7 @@ Struct holding wall geometry data for vacuum calculations. Arrays are of length dr_dθ::Matrix{Float64} = zeros(1, 3) dr_dζ::Matrix{Float64} = zeros(1, 3) normal::Matrix{Float64} = zeros(1, 3) + normal_orient::Int = 1 end """ @@ -581,6 +589,7 @@ function WallGeometry3D(inputs::VacuumInput3D, plasma_surf::PlasmaGeometry3D, wa normal = zeros(num_points, 3) dr_dθ = zeros(num_points, 3) dr_dζ = zeros(num_points, 3) + normal_orient = 1 if nowall @info "Using no wall" @@ -592,7 +601,8 @@ function WallGeometry3D(inputs::VacuumInput3D, plasma_surf::PlasmaGeometry3D, wa r, dr_dθ, dr_dζ, - normal + normal, + normal_orient ) end @@ -620,8 +630,8 @@ function WallGeometry3D(inputs::VacuumInput3D, plasma_surf::PlasmaGeometry3D, wa # Compute normal direction in poloidal plane alph = atan(x_plasma[k_next] - x_plasma[k_prev], z_plasma[k_prev] - z_plasma[k_next]) # Wall radius in cylindrical coordinates - R_wall = max(centerstack_min, x_plasma[i] + a * r_minor * cos(alph)) - Z_wall = z_plasma[i] + a * r_minor * sin(alph) + R_wall = max(centerstack_min, x_plasma[i] + dx * cos(alph)) + Z_wall = z_plasma[i] + dx * sin(alph) # Map to Cartesian (X, Y, Z) r[idx, :] .= [R_wall * cos(ϕ), R_wall * sin(ϕ), Z_wall] end @@ -681,6 +691,11 @@ function WallGeometry3D(inputs::VacuumInput3D, plasma_surf::PlasmaGeometry3D, wa normal[idx, :] = cross(dr_dθ[idx, :], dr_dζ[idx, :]) end + # Determine normal orientation (outward for wall) and enforce it + idx = argmax(view(r, :, 1)) # outboard midplane + normal_orient = normal[idx, 1] > 0 ? 1 : -1 + @views normal .*= normal_orient + return WallGeometry3D( nowall, is_closed_toroidal, @@ -689,6 +704,7 @@ function WallGeometry3D(inputs::VacuumInput3D, plasma_surf::PlasmaGeometry3D, wa r, dr_dθ, dr_dζ, - normal + normal, + normal_orient ) end From 1b305b3dd7f08e37859d8bc71f74c660fb48fa78 Mon Sep 17 00:00:00 2001 From: Jake Halpern Date: Thu, 5 Feb 2026 16:48:40 -0500 Subject: [PATCH 29/31] VACUUM - WIP - fixing normal_orient in 3D --- src/Vacuum/Vacuum3D.jl | 5 ----- src/Vacuum/VacuumStructs.jl | 16 ++++++++-------- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/src/Vacuum/Vacuum3D.jl b/src/Vacuum/Vacuum3D.jl index f6cadb1a..d7948c28 100644 --- a/src/Vacuum/Vacuum3D.jl +++ b/src/Vacuum/Vacuum3D.jl @@ -527,9 +527,4 @@ function compute_3D_kernel_matrix!( grad_greenfunction_block[idx_obs, idx_src] += M_grid_double[i, j] + far_double end end - - # Account for normal direction pointing out of vacuum integration region in 𝒦ⁿ ⋅ dS - # Negative for plasma since dS = ∇ψ J dθdζ and ∇ψ points outward but outward normal is inward - # grad_greenfunction_block .*= (source isa PlasmaGeometry3D ? -1 : 1) - # grad_greenfunction_block .*= source.normal_orient end diff --git a/src/Vacuum/VacuumStructs.jl b/src/Vacuum/VacuumStructs.jl index 57570d7e..8d3dfbdd 100644 --- a/src/Vacuum/VacuumStructs.jl +++ b/src/Vacuum/VacuumStructs.jl @@ -625,13 +625,13 @@ function WallGeometry3D(inputs::VacuumInput3D, plasma_surf::PlasmaGeometry3D, wa centerstack_min = min(0.1, 0.1 * minimum(x_plasma)) for (j, ϕ) in enumerate(ϕ_grid), i in 1:mtheta idx = i + (j - 1) * mtheta - k_prev = mod1(i - 1, mtheta) - k_next = mod1(i + 1, mtheta) - # Compute normal direction in poloidal plane - alph = atan(x_plasma[k_next] - x_plasma[k_prev], z_plasma[k_prev] - z_plasma[k_next]) - # Wall radius in cylindrical coordinates - R_wall = max(centerstack_min, x_plasma[i] + dx * cos(alph)) - Z_wall = z_plasma[i] + dx * sin(alph) + prev = mod1(i - 1, mtheta) + next = mod1(i + 1, mtheta) + # Approximate local tangent t = (dx, dz) using centered finite differences, t ≈ (dx, dz) + # Then, extend in normal direction, n = (-dz, dx) + alph = -atan(x_plasma[next] - x_plasma[prev], z_plasma[next] - z_plasma[prev]) + R_wall = max(centerstack_min, x_plasma[i] + a * r_minor * cos(alph)) + Z_wall = z_plasma[i] + a * r_minor * sin(alph) # Map to Cartesian (X, Y, Z) r[idx, :] .= [R_wall * cos(ϕ), R_wall * sin(ϕ), Z_wall] end @@ -647,7 +647,7 @@ function WallGeometry3D(inputs::VacuumInput3D, plasma_surf::PlasmaGeometry3D, wa bw_eff = (zh * cosh(zmuw)) / a for (j, ϕ) in enumerate(ϕ_grid), (i, θ) in enumerate(θ_grid) idx = i + (j - 1) * mtheta - r[idx, :] .= [(r_major + a * cos(θ)) * cos(ϕ), (r_major + a * cos(θ)) * sin(ϕ), -bw_eff * a * sin(θ)] + r[idx, :] .= [(r_major + a * cos(θ)) * cos(ϕ), (r_major + a * cos(θ)) * sin(ϕ), bw_eff * a * sin(θ)] end elseif wall_settings.shape == "dee" error("Dee-shaped walls not yet implemented for 3D walls.") From 65675c4902682ee607bb52ba710b7a19be1673ca Mon Sep 17 00:00:00 2001 From: Jake Halpern Date: Fri, 6 Feb 2026 08:16:12 -0500 Subject: [PATCH 30/31] VACUUM - WIP - reducing PR diff. Removing changes to DIIID example and BIEST wrapper --- deps/build_helpers.jl | 39 +---------- examples/DIIID-like_ideal_example/dcon.toml | 3 +- examples/DIIID-like_ideal_example/equil.toml | 2 +- src/BIEST.jl | 73 -------------------- src/JPEC.jl | 6 +- 5 files changed, 5 insertions(+), 118 deletions(-) delete mode 100644 src/BIEST.jl diff --git a/deps/build_helpers.jl b/deps/build_helpers.jl index 19487ddb..6adb06d9 100644 --- a/deps/build_helpers.jl +++ b/deps/build_helpers.jl @@ -6,7 +6,7 @@ const parent_dir = joinpath(@__DIR__, "..", "src") -export build_spline_fortran, build_vacuum_fortran, build_biest +export build_spline_fortran, build_vacuum_fortran function build_fortran() ENV["FC"] = get(ENV, "FC", "gfortran") @@ -34,8 +34,7 @@ function build_fortran() results = [ # build_jpec_fortran() add here build_spline_fortran(), - build_vacuum_fortran(), - build_biest() + build_vacuum_fortran() ] if all(results) @@ -71,40 +70,6 @@ function build_vacuum_fortran() end -function build_biest() - dir = joinpath(parent_dir, "BIEST") - try - # Build BIEST library - run(pipeline(`make -C $dir`)) - @info "BIEST compiled successfully" - - # Copy the compiled library to the lib directory - lib_dir = joinpath(parent_dir, "BIEST", "lib") - if !isdir(lib_dir) - mkdir(lib_dir) - end - - # Look for the compiled shared library and copy it - lib_file = joinpath(dir, "libbiest.so") - if Sys.isapple() - lib_file = joinpath(dir, "libbiest.dylib") - end - - if isfile(lib_file) - cp(lib_file, joinpath(lib_dir, basename(lib_file)); force=true) - @info "BIEST library copied to $lib_dir" - end - - return true - catch e - @warn "BIEST build may have issues (this is not critical): $e" - @warn "BIEST functionality from Julia will not be available unless the library is compiled separately" - @warn "To manually compile BIEST, run: cd src/BIEST && make" - return false - end - -end - # Example for including more fortran: # # function build_jpec_fortran() diff --git a/examples/DIIID-like_ideal_example/dcon.toml b/examples/DIIID-like_ideal_example/dcon.toml index c6ee2a8b..be52a3d6 100644 --- a/examples/DIIID-like_ideal_example/dcon.toml +++ b/examples/DIIID-like_ideal_example/dcon.toml @@ -17,8 +17,7 @@ nn_high = 1 # Largest toroidal mode number to include delta_mlow = 8 # Expands lower bound of Fourier harmonics delta_mhigh = 8 # Expands upper bound of Fourier harmonics delta_mband = 0 # Integration keeps only this wide a band... -mthvac = 64 # Number of points used in splines over poloidal angle at plasma-vacuum interface. -nzvac = 32 +mthvac = 512 # Number of points used in splines over poloidal angle at plasma-vacuum interface. thmax0 = 1 # Linear multiplier on the automatic choice of theta integration bounds kin_flag = false # Kinetic EL equation (default: false) diff --git a/examples/DIIID-like_ideal_example/equil.toml b/examples/DIIID-like_ideal_example/equil.toml index 8c941bb8..e85c02a9 100644 --- a/examples/DIIID-like_ideal_example/equil.toml +++ b/examples/DIIID-like_ideal_example/equil.toml @@ -2,7 +2,7 @@ eq_type = "efit" # Type of the input 2D equilibrium file eq_filename = "TKMKR_D3Dlike_default_Hmode.geqdsk" # path to equilibrium file -jac_type = "pest" # Coordinate system (hamada, pest, boozer, equal_arc) +jac_type = "hamada" # Coordinate system (hamada, pest, boozer, equal_arc) power_bp = 0 # del.B ~ B_p^power_bp * B^power_b / R^power_r power_b = 0 # del.B ~ B_p^power_bp * B^power_b / R^power_r power_r = 0 # del.B ~ B_p^power_bp * B^power_b / R^power_r diff --git a/src/BIEST.jl b/src/BIEST.jl deleted file mode 100644 index 265fab1f..00000000 --- a/src/BIEST.jl +++ /dev/null @@ -1,73 +0,0 @@ -""" - BIEST - -Module for interfacing with BIEST (Boundary Integral Equation Solver for Toroidal surfaces). -Provides access to boundary integral operator functionality for 3D vacuum calculations. -""" -module BIEST - -# Determine the library file based on OS -const lib_base_path = joinpath(@__DIR__, "BIEST", "lib") -const libbiest = if Sys.isapple() - joinpath(lib_base_path, "libbiest.dylib") -else - joinpath(lib_base_path, "libbiest.so") -end - -""" - compute_green_matrices(R::Vector{Float64}, Z::Vector{Float64}, Nt::Integer) -> (G::Matrix{Float64}, K::Matrix{Float64}) - -Compute Green's function matrices for Laplace single-layer and double-layer kernels on an axisymmetric toroidal surface. - -Takes a 2D poloidal contour defined by `R` (major radius) and `Z` (height) coordinates, and extends it -toroidally using `Nt` toroidal grid points to create a 3D axisymmetric surface with `Np*Nt` total points -(where `Np = length(R)`). - -Computes two matrices `G` and `K` of size `(Np*Nt) × (Np*Nt)` where: - - - `G[i,j]`: Single-layer kernel value between 3D points `x_i` and `x_j` on the surface - - `K[i,j]`: Double-layer kernel value between 3D points `x_i` and `x_j` on the surface - -Uses the MatrixExtractor::test() method from extract_matrix.hpp to build the explicit matrix -representation of the boundary integral operators. - -# Arguments - - - `G::Matrix{Float64}`: Single-layer kernel matrix, size `(Np*Nt, Np*Nt)` - - `K::Matrix{Float64}`: Double-layer kernel matrix, size `(Np*Nt, Np*Nt)` - - `R::Vector{Float64}`: Poloidal major radius coordinates (length Np) - - `Z::Vector{Float64}`: Poloidal height coordinates (length Np) - - `Nt::Integer`: Number of toroidal grid points - -# Note - -Requires the BIEST library to be compiled. Build it with: - -```bash -cd src/BIEST && make -``` - -For large grids (e.g., `Nt*Np > 200`), this computation can be expensive as it requires -evaluating the boundary integral operator `Nt*Np` times to build each matrix column. -""" -function compute_green_matrices!(G::Matrix{Float64}, K::Matrix{Float64}, R::Vector{Float64}, Z::Vector{Float64}, ν::Vector{Float64}, Nt::Integer) - isfile(libbiest) || error("BIEST library not found at $libbiest. Build it with: cd src/BIEST && make") - - # Call C++ wrapper which uses MatrixExtractor::test() - ccall((:biest_compute_green_matrices, libbiest), Cvoid, - (Ptr{Cdouble}, Ptr{Cdouble}, Ptr{Cdouble}, Cint, Cint, Ptr{Cdouble}, Ptr{Cdouble}), - R, Z, ν, Cint(length(R)), Cint(Nt), G, K) -end - -function compute_green_matrices!(G::Matrix{Float64}, K::Matrix{Float64}, surf) - isfile(libbiest) || error("BIEST library not found at $libbiest. Build it with: cd src/BIEST && make") - - # Call C++ wrapper which builds the surface from supplied coordinates - ccall((:biest_compute_green_matrices_from3D, libbiest), Cvoid, - (Ptr{Cdouble}, Ptr{Cdouble}, Ptr{Cdouble}, Cint, Cint, Ptr{Cdouble}, Ptr{Cdouble}), - surf.r[:, 1], surf.r[:, 2], surf.r[:, 3], Cint(surf.ntheta), Cint(surf.nzeta), G, K) -end - -export compute_green_matrices! - -end # module BIEST diff --git a/src/JPEC.jl b/src/JPEC.jl index 7eba3edd..53e212c8 100644 --- a/src/JPEC.jl +++ b/src/JPEC.jl @@ -9,10 +9,6 @@ include("Equilibrium/Equilibrium.jl") import .Equilibrium as Equilibrium export Equilibrium -include("BIEST.jl") -import .BIEST as BIEST -export BIEST - include("Vacuum/Vacuum.jl") import .Vacuum as Vacuum export Vacuum @@ -22,6 +18,6 @@ import .DCON as DCON export DCON include(joinpath(@__DIR__, "..", "deps", "build_helpers.jl")) -export build_fortran, build_spline_fortran, build_vacuum_fortran, build_biest +export build_fortran, build_spline_fortran, build_vacuum_fortran end # module JPEC From 721cbbf08753609995bba3c09b0b07701809aca4 Mon Sep 17 00:00:00 2001 From: Jake Halpern Date: Fri, 6 Feb 2026 11:31:46 -0500 Subject: [PATCH 31/31] VACUUM - WIP - adding new 3D example and fixing fourier transforms --- examples/Solovev_ideal_example/dcon.toml | 7 ++- examples/Solovev_ideal_example/sol.toml | 6 +-- examples/Solovev_ideal_example_3D/dcon.toml | 51 ++++++++++++++++++++ examples/Solovev_ideal_example_3D/equil.toml | 30 ++++++++++++ examples/Solovev_ideal_example_3D/sol.toml | 11 +++++ src/Vacuum/MathUtils.jl | 39 ++++++--------- 6 files changed, 112 insertions(+), 32 deletions(-) create mode 100644 examples/Solovev_ideal_example_3D/dcon.toml create mode 100644 examples/Solovev_ideal_example_3D/equil.toml create mode 100644 examples/Solovev_ideal_example_3D/sol.toml diff --git a/examples/Solovev_ideal_example/dcon.toml b/examples/Solovev_ideal_example/dcon.toml index f16632fb..9215f583 100644 --- a/examples/Solovev_ideal_example/dcon.toml +++ b/examples/Solovev_ideal_example/dcon.toml @@ -16,8 +16,7 @@ nn_high = 1 # Largest toroidal mode number to include delta_mlow = 8 # Expands lower bound of Fourier harmonics delta_mhigh = 8 # Expands upper bound of Fourier harmonics delta_mband = 0 # Integration keeps only this wide a band... -mthvac = 24 # Number of points used in splines over poloidal angle at plasma-vacuum interface. -nzvac = 48 +mthvac = 960 # Number of points used in splines over poloidal angle at plasma-vacuum interface. thmax0 = 1 # Linear multiplier on the automatic choice of theta integration bounds kin_flag = false # Kinetic EL equation (default: false) @@ -41,11 +40,11 @@ force_wv_symmetry = true # Forces vacuum energy matrix symmetry save_interval = 10 # Save every Nth ODE step (1=all, 10=every 10th). Always saves near rational surfaces. [WALL] -shape = "nowall" # String selecting wall shape ["nowall", "conformal", "elliptical", "dee", "mod_dee", "from_file"] +shape = "conformal" # String selecting wall shape ["nowall", "conformal", "elliptical", "dee", "mod_dee", "from_file"] a = 0.2415 # The distance of the wall from the plasma in units of major radius (conformal), or minor radius parameter (others). aw = 0.05 # Half-thickness of the wall. bw = 1.5 # Elongation. cw = 0 # Offset of the center of the wall from the major radius. dw = 0.5 # Triangularity tw = 0.05 # Sharpness of the corners of the wall. Try 0.05 as a good initial value. -equal_arc_wall = false # Flag to enforce equal arcs distribution of the nodes on the wall. Best results unless the wall is very close to the plasma. +equal_arc_wall = true # Flag to enforce equal arcs distribution of the nodes on the wall. Best results unless the wall is very close to the plasma. diff --git a/examples/Solovev_ideal_example/sol.toml b/examples/Solovev_ideal_example/sol.toml index 303a6ec0..e54fdeac 100644 --- a/examples/Solovev_ideal_example/sol.toml +++ b/examples/Solovev_ideal_example/sol.toml @@ -2,9 +2,9 @@ mr = 128 # number of radial grid zones mz = 128 # number of axial grid zones ma = 128 # number of flux grid zones -e = 1.0 # elongation -a = 1.0 # minor radius -r0 = 5.0 # major radius +e = 1.6 # elongation +a = 0.33 # minor radius +r0 = 1.0 # major radius q0 = 1.9 # safety factor at the o-point p0fac=1 # scale on-axis pressure (P-> P+P0*p0fac. beta changes. Phi,q constant) b0fac=1 # scale toroidal field at constant beta (s*Phi,s*f,s^2*P. bt changes. Shape,beta constant) diff --git a/examples/Solovev_ideal_example_3D/dcon.toml b/examples/Solovev_ideal_example_3D/dcon.toml new file mode 100644 index 00000000..46dbef2c --- /dev/null +++ b/examples/Solovev_ideal_example_3D/dcon.toml @@ -0,0 +1,51 @@ +[DCON_CONTROL] +bal_flag = false # Ideal MHD ballooning criterion for short wavelengths +mat_flag = true # Construct coefficient matrices for diagnostic purposes +ode_flag = true # Integrate ODE's for determining stability of internal long-wavelength mode (must be true for GPEC) +vac_flag = true # Compute plasma, vacuum, and total energies for free-boundary modes +mer_flag = true # Evaluate the Mercier criterian + +set_psilim_via_dmlim = false # Safety factor (q) limit determined as q_ir+dmlim... +dmlim = 0.2 # See sas_flag +qlow = 1.02 # Integration initiated at q determined by min(q0, qlow)... +qhigh = 1e3 # Integration terminated at q limit determined by min(qa, qhigh)... +sing_start = 0 # Start integration at the sing_start'th rational from the axis (psilow) + +nn_low = 1 # Smallest toroidal mode number to include +nn_high = 1 # Largest toroidal mode number to include +delta_mlow = 8 # Expands lower bound of Fourier harmonics +delta_mhigh = 8 # Expands upper bound of Fourier harmonics +delta_mband = 0 # Integration keeps only this wide a band... +mthvac = 128 # Number of points used in splines over poloidal angle at plasma-vacuum interface. +nzvac = 64 +thmax0 = 1 # Linear multiplier on the automatic choice of theta integration bounds + +kin_flag = false # Kinetic EL equation (default: false) +con_flag = false # Continue integration through layers (default: false) +kinfac1 = 1.0 # Scale factor for energy contribution (default: 1.0) +kinfac2 = 1.0 # Scale factor for torque contribution (default: 1.0) +kingridtype = 0 # Regular grid method (default: 0) +passing_flag = true # Includes passing particle effects (default: false) +ktanh_flag = true # Ignore kinetic effects in the core smoothly (default: false) +ktc = 0.1 # Parameter for ktanh_flag (default: 0.1) +ktw = 50.0 # Parameter for ktanh_flag (default: 50.0) +ion_flag = true # Include ion dW_k when kin_flag is true +electron_flag = false # Include electron dW_k when kin_flag is true + +tol_nr = 1e-6 # Relative tolerance of dynamic integration steps away from rationals +tol_r = 1e-7 # Relative tolerance of dynamic integration steps near rationals +crossover = 1e-2 # Fractional distance from rational q at which tol switches +singfac_min = 1e-4 # Fractional distance from rational q at which ideal jump enforced +ucrit = 1e3 # Maximum fraction of solutions allowed before re-normalized +force_wv_symmetry = true # Forces vacuum energy matrix symmetry +save_interval = 10 # Save every Nth ODE step (1=all, 10=every 10th). Always saves near rational surfaces. + +[WALL] +shape = "nowall" # String selecting wall shape ["nowall", "conformal", "elliptical", "dee", "mod_dee", "from_file"] +a = 0.2415 # The distance of the wall from the plasma in units of major radius (conformal), or minor radius parameter (others). +aw = 0.05 # Half-thickness of the wall. +bw = 1.5 # Elongation. +cw = 0 # Offset of the center of the wall from the major radius. +dw = 0.5 # Triangularity +tw = 0.05 # Sharpness of the corners of the wall. Try 0.05 as a good initial value. +equal_arc_wall = false # Flag to enforce equal arcs distribution of the nodes on the wall. Best results unless the wall is very close to the plasma. diff --git a/examples/Solovev_ideal_example_3D/equil.toml b/examples/Solovev_ideal_example_3D/equil.toml new file mode 100644 index 00000000..7abe0a9d --- /dev/null +++ b/examples/Solovev_ideal_example_3D/equil.toml @@ -0,0 +1,30 @@ +[EQUIL_CONTROL] +eq_type = "sol" # Type of the input 2D equilibrium file +eq_filename = "sol.toml" # path to equilibrium file + +jac_type = "pest" # Coordinate system (hamada, pest, boozer, equal_arc) +power_bp = 0 # del.B ~ B_p^power_bp * B^power_b / R^power_r +power_b = 0 # del.B ~ B_p^power_bp * B^power_b / R^power_r +power_r = 0 # del.B ~ B_p^power_bp * B^power_b / R^power_r + +grid_type = "ldp" # Radial grid packing +psilow = 1e-4 # Min psi (normalized) +psihigh = 0.99999 # Max psi (normalized) +mpsi = 128 # Radial grid intervals +mtheta = 256 # Poloidal grid intervals + +newq0 = 0 # Override q(0) +etol = 1e-7 # Reconstruction tolerance +use_classic_splines = false # Use classical spline (vs. tri-diagonal) + +input_only = false # Quit after input read + +[EQUIL_OUTPUT] +gse_flag = false # Output G-S equation accuracy diagnostics +out_eq_1d = false # ASCII output of 1D eq data +bin_eq_1d = false # Binary output of 1D eq data +out_eq_2d = false # ASCII output of 2D eq data +bin_eq_2d = true # Binary output of 2D eq data (used by GPEC) +out_2d = false # ASCII output of processed 2D data +bin_2d = false # Binary output of processed 2D data +dump_flag = false # Binary dump of equilibrium data diff --git a/examples/Solovev_ideal_example_3D/sol.toml b/examples/Solovev_ideal_example_3D/sol.toml new file mode 100644 index 00000000..303a6ec0 --- /dev/null +++ b/examples/Solovev_ideal_example_3D/sol.toml @@ -0,0 +1,11 @@ +[SOL_INPUT] +mr = 128 # number of radial grid zones +mz = 128 # number of axial grid zones +ma = 128 # number of flux grid zones +e = 1.0 # elongation +a = 1.0 # minor radius +r0 = 5.0 # major radius +q0 = 1.9 # safety factor at the o-point +p0fac=1 # scale on-axis pressure (P-> P+P0*p0fac. beta changes. Phi,q constant) +b0fac=1 # scale toroidal field at constant beta (s*Phi,s*f,s^2*P. bt changes. Shape,beta constant) +f0fac=1 # scale toroidal field at constant pressure (s*f. beta,q changes. Phi,p,bp constant) diff --git a/src/Vacuum/MathUtils.jl b/src/Vacuum/MathUtils.jl index d8ec0b88..ccfc31a6 100644 --- a/src/Vacuum/MathUtils.jl +++ b/src/Vacuum/MathUtils.jl @@ -5,58 +5,47 @@ Perform the inverse Fourier transform of `gil` onto `gll` using Fourier coeffici # Arguments - - `gll`: Output matrix (mpert × mpert) updated in-place - - `gil`: Input matrix (mtheta × mpert) containing Fourier-space data - - `cs`: Fourier coefficient matrix (mtheta × mpert) + - `gll`: Output matrix (num_pert × num_pert) updated in-place + - `gil`: Input matrix (num_points × num_pert) containing Fourier-space data + - `cs`: Fourier coefficient matrix (num_points × num_pert) - `m00`: Integer offset in the gil matrix (row offset) - `l00`: Integer offset in the gil matrix (column offset) + - `weight`: Quadrature weight factor # Notes - - Computes: `gll[l2, l1] = (2π * dth) * Σ_i cs[i, l2] * gil[i, l1]` + - Computes: `gll[l2, l1] = weight * Σ_i cs[i, l2] * gil[i, l1]` - Performs the same function as fouranv in the Fortran code. # Returns - gll(l2,l1) : output matrix updated in-place (mpert × mpert) """ -function fourier_inverse_transform!(gll::Matrix{Float64}, gil::Matrix{Float64}, cs::Matrix{Float64}, m00::Int, l00::Int) - - # Zero out gll block - mtheta, mpert = size(cs) - fill!(view(gll, 1:mpert, 1:mpert), 0.0) - +function fourier_inverse_transform!(gll::Matrix{Float64}, gil::Matrix{Float64}, cs::Matrix{Float64}, m00::Int, l00::Int, weight::Float64) # Inverse Fourier transform via matrix multiply: gll = cs^T * gil * (2π * dth) - # This computes: gll[l2, l1] = (2π * dth) * Σ_i cs[i, l2] * gil[i, l1] - dth = 2π / mtheta - mul!(gll, cs', view(gil, (m00+1):(m00+mtheta), (l00+1):(l00+mpert)), 2π * dth, 0.0) + num_points, num_pert = size(cs) + mul!(gll, cs', view(gil, (m00+1):(m00+num_points), (l00+1):(l00+num_pert)), weight, 0.0) end """ - fourier_transform!(gil, gij, cs, m00, l00, mth, mpert) + fourier_transform!(gil, gij, cs, m00, l00) Purpose: This routine performs a truncated Fourier transform of gij onto gil using Fourier coefficients stored in cs. Inputs: - gij(i,j) : input matrix of size (mth × mth), the "physical-space" data - cs(j,l) : Fourier coefficient matrix (mth × mpert) + gij(i,j) : input matrix of size (num_points × num_points), the "physical-space" data + cs(j,l) : Fourier coefficient matrix (num_points × num_pert) m00, l00 : integer offsets in the gil matrix - mth : number of θ-grid points (dimension of gij along i, j) - mpert : number of Fourier modes Output: - gil(i', l') : output matrix updated in-place (mth × mpert), where i' = m00 + i and l' = l00 + l + gil(i', l') : output matrix updated in-place (num_points × num_pert), where i' = m00 + i and l' = l00 + l """ function fourier_transform!(gil::Matrix{Float64}, gij::Matrix{Float64}, cs::Matrix{Float64}, m00::Int, l00::Int) - - # Zero out relevant gil block - mtheta, mpert = size(cs) - fill!(view(gil, (m00+1):(m00+mtheta), (l00+1):(l00+mpert)), 0.0) - # Fourier transform via matrix multiply: gil[i, l] = Σ_j gij[i, j] * cs[j, l] - mul!(view(gil, (m00+1):(m00+mtheta), (l00+1):(l00+mpert)), gij, cs) + num_points, num_pert = size(cs) + mul!(view(gil, (m00+1):(m00+num_points), (l00+1):(l00+num_pert)), gij, cs) end # Returns the array of derivatives at all x points, I think this acts like difspl