Skip to content

Commit cade338

Browse files
logan-ncclaude
andcommitted
PE - DOCS - Delineate the boundary-response torque from the KineticForces NTV torque
GPEC now reports torque through two distinct constructions and will gain a third (the gpout_dw psi-resolved profile, issue #423): the PE scalar is the boundary quadratic form of the anti-Hermitian plasma inductance, equal to the volume-integrated Euler-Lagrange kinetic torque only for converged self-consistent solutions, and a distinct construction from the KineticForces energy-space NTV evaluation. State this in the struct docstring, the HDF5 long_name, and at the computation site so no output is just called 'torque'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LzbLFQKyuRE5DYZmLokKmk
1 parent 2cb4f64 commit cade338

3 files changed

Lines changed: 78 additions & 62 deletions

File tree

src/PerturbedEquilibrium/PerturbedEquilibriumStructs.jl

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ Medium Priority (defer for MWE):
2626
- `singular_point_method::String` - Method for singular point treatment (default: "standard")
2727
2828
Regularization:
29-
# High Priority (MWE)
29+
# High Priority (MWE)
30+
3031
- `reg_spot::Float64` - Regularization width for singular surface smoothing (default: 0.05). Set to 0 to disable. Must be ≥ 0.
3132
"""
3233
@kwdef struct PerturbedEquilibriumControl
@@ -121,6 +122,7 @@ Metadata [n_rational] — identifies each (surface, n) row:
121122
122123
Control-surface forcing/response spectra [numpert_total], in the three Pharr (2026) field
123124
representations (all tesla; no flux/weber is stored):
125+
124126
- `forcing_b`/`response_b` - bare normal field b (Σ⁻¹·b̃)
125127
- `forcing_b_rootarea`/`response_b_rootarea` - root-area-weighted field b̃ (coordinate-invariant)
126128
- `forcing_b_area`/`response_b_area` - area-weighted field b̄ (= S·b̃; flux is Φ = A·b̄)
@@ -145,7 +147,14 @@ well-conditioned flux-space inductances L, Λ:
145147
- `vacuum_energy` - Re( ⟨Φ_x, L⁻¹·Φ_x⟩ ) / 4 (energy to perturb the vacuum)
146148
- `surface_energy` - Re( ⟨Φ_tot, L⁻¹·Φ_tot⟩ ) / 4 (energy at the control surface)
147149
- `plasma_energy` - Re( ⟨Φ_tot, Λ⁻¹·Φ_tot⟩ ) / 4 (energy to perturb the plasma; Fortran's "total energy") # Response fields in mode space [npsi, mpert]
148-
- `toroidal_torque` - -2·n·Im( ⟨Φ_tot, Λ⁻¹·Φ_tot⟩ / 4 )
150+
- `toroidal_torque` - -2·n·Im( ⟨Φ_tot, Λ⁻¹·Φ_tot⟩ / 4 ) — the **boundary-response torque**:
151+
the net toroidal torque implied by the anti-Hermitian part of the plasma inductance at the
152+
control surface. For exact self-consistent solutions this equals the volume-integrated
153+
kinetic torque of the same Euler-Lagrange model (the δW surface-term identity), so any
154+
numerical difference from a ψ-resolved torque profile is a solution/grid-quality diagnostic,
155+
not physics. It is a *distinct construction* from the NTV torque under `KineticForces/`
156+
(independent energy-space bounce-average evaluation); agreement between the two tests whether
157+
the EL matrices faithfully discretize the drift-kinetic operator. Zero for ideal (Hermitian) runs.
149158
"""
150159
@kwdef mutable struct PerturbedEquilibriumState
151160
# Radial grid (FFS ODE integration ψ_n values) [npsi]
@@ -185,18 +194,18 @@ well-conditioned flux-space inductances L, Λ:
185194
rational_surface_idx::Vector{Int} = Int[]
186195

187196
# Control-surface forcing/response spectra in the three weightings of field representations [numpert_total], tesla
188-
forcing_b::Vector{ComplexF64} = ComplexF64[] # bare normal field b (forcing Φ_x)
189-
forcing_b_rootarea::Vector{ComplexF64} = ComplexF64[] # root-area-weighted field b̃ (coordinate-invariant)
190-
forcing_b_area::Vector{ComplexF64} = ComplexF64[] # area-weighted field b̄
191-
response_b::Vector{ComplexF64} = ComplexF64[] # bare normal field b (response Φ_tot = P·Φ_x)
197+
forcing_b::Vector{ComplexF64} = ComplexF64[] # bare normal field b (forcing Φ_x)
198+
forcing_b_rootarea::Vector{ComplexF64} = ComplexF64[] # root-area-weighted field b̃ (coordinate-invariant)
199+
forcing_b_area::Vector{ComplexF64} = ComplexF64[] # area-weighted field b̄
200+
response_b::Vector{ComplexF64} = ComplexF64[] # bare normal field b (response Φ_tot = P·Φ_x)
192201
response_b_rootarea::Vector{ComplexF64} = ComplexF64[] # root-area-weighted field b̃
193-
response_b_area::Vector{ComplexF64} = ComplexF64[] # area-weighted field b̄
202+
response_b_area::Vector{ComplexF64} = ComplexF64[] # area-weighted field b̄
194203

195204
# Control surface matrices [numpert_total × numpert_total], root-area-weighted field (b̃) space
196-
plasma_inductance::Matrix{ComplexF64} = zeros(ComplexF64, 0, 0) # Λ̃ (field space)
205+
plasma_inductance::Matrix{ComplexF64} = zeros(ComplexF64, 0, 0) # Λ̃ (field space)
197206
surface_inductance::Matrix{ComplexF64} = zeros(ComplexF64, 0, 0) # L̃ (field space)
198-
permeability::Matrix{ComplexF64} = zeros(ComplexF64, 0, 0) # P̃ = R⁻¹·Λ·L⁻¹·R
199-
reluctance::Matrix{ComplexF64} = zeros(ComplexF64, 0, 0) # ϱ̃ = R†·L⁻¹·(Λ−L)·L⁻¹·R
207+
permeability::Matrix{ComplexF64} = zeros(ComplexF64, 0, 0) # P̃ = R⁻¹·Λ·L⁻¹·R
208+
reluctance::Matrix{ComplexF64} = zeros(ComplexF64, 0, 0) # ϱ̃ = R†·L⁻¹·(Λ−L)·L⁻¹·R
200209
rootarea_to_area_weight::Matrix{ComplexF64} = zeros(ComplexF64, 0, 0) # S = Σ/√A at psilim: b̃→b̄ recovery operator
201210
surface_area::Float64 = 0.0 # scalar control-surface area A = ∫J|∇ψ|dθ (flux: Φ = A·b̄; conform R = S·A)
202211

src/PerturbedEquilibrium/Response.jl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ function compute_plasma_response!(
9595
state.vacuum_energy = real(vy)
9696
state.surface_energy = real(sy)
9797
state.plasma_energy = real(py) # Fortran's "total energy" is this pengy
98+
# Boundary-response torque: distinct construction from the KineticForces NTV torque —
99+
# see the PerturbedEquilibriumState docstring for the delineation of GPEC torque outputs.
98100
state.toroidal_torque = -2 * nn * imag(py)
99101

100102
xi_modes, b_modes = reconstruct_physical_fields(

src/PerturbedEquilibrium/Utils.jl

Lines changed: 57 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ avoiding repeated index arithmetic throughout the code.
1212
## Mode Indexing Convention
1313
1414
For linear index i ∈ [1, numpert_total]:
15-
- m_modes[i] = (i-1) % mpert + mlow
16-
- n_modes[i] = (i-1) ÷ mpert + nlow
15+
16+
- m_modes[i] = (i-1) % mpert + mlow
17+
- n_modes[i] = (i-1) ÷ mpert + nlow
1718
1819
This matches the convention used in ForceFreeStates where modes are ordered as:
1920
(m1,n1), (m2,n1), ..., (mpert,n1), (m1,n2), (m2,n2), ..., (mpert,npert)
@@ -110,119 +111,119 @@ function write_outputs_to_HDF5(
110111

111112
# Forcing modes
112113
forcing_group = haskey(pe_group, "ForcingModes") ? pe_group["ForcingModes"] : create_group(pe_group, "ForcingModes")
113-
forcing_group["n"] = [mode.n for mode in intr.forcing_modes]
114-
forcing_group["m"] = [mode.m for mode in intr.forcing_modes]
114+
forcing_group["n"] = [mode.n for mode in intr.forcing_modes]
115+
forcing_group["m"] = [mode.m for mode in intr.forcing_modes]
115116
forcing_group["amplitude"] = [mode.amplitude for mode in intr.forcing_modes]
116117

117118
# Control-surface forcing/response spectra in the three Pharr field representations
118119
# (all tesla; flux/weber is never stored). b̃ = root-area-weighted (coordinate-invariant).
119-
!isempty(state.forcing_b) && (pe_group["forcing_b"] = state.forcing_b)
120-
!isempty(state.forcing_b_rootarea) && (pe_group["forcing_b_root_area"] = state.forcing_b_rootarea)
121-
!isempty(state.forcing_b_area) && (pe_group["forcing_b_area"] = state.forcing_b_area)
122-
!isempty(state.response_b) && (pe_group["response_b"] = state.response_b)
120+
!isempty(state.forcing_b) && (pe_group["forcing_b"] = state.forcing_b)
121+
!isempty(state.forcing_b_rootarea) && (pe_group["forcing_b_root_area"] = state.forcing_b_rootarea)
122+
!isempty(state.forcing_b_area) && (pe_group["forcing_b_area"] = state.forcing_b_area)
123+
!isempty(state.response_b) && (pe_group["response_b"] = state.response_b)
123124
!isempty(state.response_b_rootarea) && (pe_group["response_b_root_area"] = state.response_b_rootarea)
124-
!isempty(state.response_b_area) && (pe_group["response_b_area"] = state.response_b_area)
125+
!isempty(state.response_b_area) && (pe_group["response_b_area"] = state.response_b_area)
125126

126127
# Control surface matrices [numpert_total × numpert_total], in coordinate-invariant
127128
# root-area-weighted field (b̃) space. Recover the area-weighted field b̄ with the stored
128129
# operator S ≡ rootarea_to_area_weight (b̄ = S·b̃): e.g. L_b̄ = S·L̃·S†; recover flux with the
129130
# scalar surface_area A: Φ = A·b̄ (internally R = S·A, Φ = R·b̃). [Pharr 2026]
130131
mat_group = haskey(pe_group, "ResponseMatrices") ? pe_group["ResponseMatrices"] : create_group(pe_group, "ResponseMatrices")
131-
!isempty(state.plasma_inductance) && (mat_group["plasma_inductance"] = state.plasma_inductance)
132+
!isempty(state.plasma_inductance) && (mat_group["plasma_inductance"] = state.plasma_inductance)
132133
!isempty(state.surface_inductance) && (mat_group["surface_inductance"] = state.surface_inductance)
133-
!isempty(state.permeability) && (mat_group["permeability"] = state.permeability)
134-
!isempty(state.reluctance) && (mat_group["reluctance"] = state.reluctance)
134+
!isempty(state.permeability) && (mat_group["permeability"] = state.permeability)
135+
!isempty(state.reluctance) && (mat_group["reluctance"] = state.reluctance)
135136
!isempty(state.rootarea_to_area_weight) && (mat_group["rootarea_to_area_weight_operator"] = state.rootarea_to_area_weight)
136-
(state.surface_area != 0.0) && (mat_group["surface_area"] = state.surface_area)
137+
(state.surface_area != 0.0) && (mat_group["surface_area"] = state.surface_area)
137138

138139
# Response fields (ComplexF64 directly)
139140
response_group = haskey(pe_group, "Response") ? pe_group["Response"] : create_group(pe_group, "Response")
140141
!isempty(state.psi_grid) && (response_group["psi"] = state.psi_grid)
141142
have_xi = !isnothing(state.xi_modes)
142-
have_b = have_xi && !isnothing(state.b_modes)
143-
response_group["xi_psi"] = have_xi ? state.xi_modes.psi : ComplexF64[]
144-
response_group["b_psi_area_weighted"] = have_b ? state.b_modes.b_psi_area_weighted : ComplexF64[]
145-
response_group["Jb_theta"] = have_b ? state.b_modes.theta : ComplexF64[]
146-
response_group["Jb_zeta"] = have_b ? state.b_modes.zeta : ComplexF64[]
147-
response_group["b_n"] = !isnothing(state.b_n_modes) ? state.b_n_modes : ComplexF64[]
148-
response_group["xi_n"] = !isnothing(state.xi_n_modes) ? state.xi_n_modes : ComplexF64[]
143+
have_b = have_xi && !isnothing(state.b_modes)
144+
response_group["xi_psi"] = have_xi ? state.xi_modes.psi : ComplexF64[]
145+
response_group["b_psi_area_weighted"] = have_b ? state.b_modes.b_psi_area_weighted : ComplexF64[]
146+
response_group["Jb_theta"] = have_b ? state.b_modes.theta : ComplexF64[]
147+
response_group["Jb_zeta"] = have_b ? state.b_modes.zeta : ComplexF64[]
148+
response_group["b_n"] = !isnothing(state.b_n_modes) ? state.b_n_modes : ComplexF64[]
149+
response_group["xi_n"] = !isnothing(state.xi_n_modes) ? state.xi_n_modes : ComplexF64[]
149150

150151
# Clebsch displacements for PENTRC (matches Fortran gpout_xclebsch)
151152
if have_xi
152-
response_group["xi_clebsch_psi"] = state.xi_modes.clebsch_psi
153-
response_group["dxi_clebsch_psidpsi"] = state.xi_modes.clebsch_psi1
153+
response_group["xi_clebsch_psi"] = state.xi_modes.clebsch_psi
154+
response_group["dxi_clebsch_psidpsi"] = state.xi_modes.clebsch_psi1
154155
response_group["xi_clebsch_alpha"] = state.xi_modes.clebsch_alpha
155156
end
156157

157158
# Contravariant displacement (from gpeq_contra, all J-weighted)
158159
if have_xi
159160
response_group["Jxi_psi"] = state.xi_modes.psi_J
160161
response_group["Jxi_theta"] = state.xi_modes.theta
161-
response_group["Jxi_zeta"] = state.xi_modes.zeta
162+
response_group["Jxi_zeta"] = state.xi_modes.zeta
162163
end
163164

164165
# Covariant components (from gpeq_cova)
165166
if have_xi
166-
response_group["xi_cov_psi"] = state.xi_modes.cova_psi
167+
response_group["xi_cov_psi"] = state.xi_modes.cova_psi
167168
response_group["xi_cov_theta"] = state.xi_modes.cova_theta
168-
response_group["xi_cov_zeta"] = state.xi_modes.cova_zeta
169+
response_group["xi_cov_zeta"] = state.xi_modes.cova_zeta
169170
end
170171
if have_xi
171172
response_group["Jxi_theta_reg"] = state.xi_modes.theta_reg
172-
response_group["Jxi_zeta_reg"] = state.xi_modes.zeta_reg
173+
response_group["Jxi_zeta_reg"] = state.xi_modes.zeta_reg
173174
end
174175
if have_b
175176
response_group["Jb_theta_reg"] = state.b_modes.theta_reg
176-
response_group["Jb_zeta_reg"] = state.b_modes.zeta_reg
177-
response_group["b_cov_psi"] = state.b_modes.cova_psi
177+
response_group["Jb_zeta_reg"] = state.b_modes.zeta_reg
178+
response_group["b_cov_psi"] = state.b_modes.cova_psi
178179
response_group["b_cov_theta"] = state.b_modes.cova_theta
179-
response_group["b_cov_zeta"] = state.b_modes.cova_zeta
180+
response_group["b_cov_zeta"] = state.b_modes.cova_zeta
180181
end
181182

182183
# R,Z,φ cylindrical components in mode-space (from gpeq_rzphi)
183184
if have_xi
184-
response_group["xi_R"] = state.xi_modes.R
185-
response_group["xi_Z"] = state.xi_modes.Z
185+
response_group["xi_R"] = state.xi_modes.R
186+
response_group["xi_Z"] = state.xi_modes.Z
186187
response_group["xi_phi"] = state.xi_modes.phi
187188
end
188189
if have_b
189-
response_group["b_R"] = state.b_modes.R
190-
response_group["b_Z"] = state.b_modes.Z
190+
response_group["b_R"] = state.b_modes.R
191+
response_group["b_Z"] = state.b_modes.Z
191192
response_group["b_phi"] = state.b_modes.phi
192193
end
193194

194195
# Singular coupling
195196
coupling_group = haskey(pe_group, "SingularCoupling") ? pe_group["SingularCoupling"] : create_group(pe_group, "SingularCoupling")
196197

197198
# Coupling matrices [n_rational × numpert_total]
198-
!isempty(state.C_resonant_area_weighted_field) && (coupling_group["C_resonant_area_weighted_field"] = state.C_resonant_area_weighted_field)
199+
!isempty(state.C_resonant_area_weighted_field) && (coupling_group["C_resonant_area_weighted_field"] = state.C_resonant_area_weighted_field)
199200
!isempty(state.C_resonant_current) && (coupling_group["C_resonant_current"] = state.C_resonant_current)
200-
!isempty(state.C_island_width_sq) && (coupling_group["C_island_width_sq"] = state.C_island_width_sq)
201+
!isempty(state.C_island_width_sq) && (coupling_group["C_island_width_sq"] = state.C_island_width_sq)
201202
!isempty(state.C_penetrated_area_weighted_field) && (coupling_group["C_penetrated_area_weighted_field"] = state.C_penetrated_area_weighted_field)
202-
!isempty(state.C_delta_prime) && (coupling_group["C_Delta_prime"] = state.C_delta_prime)
203+
!isempty(state.C_delta_prime) && (coupling_group["C_Delta_prime"] = state.C_delta_prime)
203204

204205
# Applied resonant vectors [n_rational]
205-
!isempty(state.resonant_area_weighted_field) && (coupling_group["resonant_area_weighted_field"] = state.resonant_area_weighted_field)
206-
!isempty(state.resonant_current) && (coupling_group["resonant_current"] = state.resonant_current)
207-
!isempty(state.island_width_sq) && (coupling_group["island_width_sq"] = state.island_width_sq)
208-
!isempty(state.penetrated_area_weighted_field) && (coupling_group["penetrated_area_weighted_field"] = state.penetrated_area_weighted_field)
209-
!isempty(state.delta_prime) && (coupling_group["Delta_prime"] = state.delta_prime)
210-
!isempty(state.forcing_solution_weights) && (coupling_group["forcing_solution_weights"] = state.forcing_solution_weights)
206+
!isempty(state.resonant_area_weighted_field) && (coupling_group["resonant_area_weighted_field"] = state.resonant_area_weighted_field)
207+
!isempty(state.resonant_current) && (coupling_group["resonant_current"] = state.resonant_current)
208+
!isempty(state.island_width_sq) && (coupling_group["island_width_sq"] = state.island_width_sq)
209+
!isempty(state.penetrated_area_weighted_field) && (coupling_group["penetrated_area_weighted_field"] = state.penetrated_area_weighted_field)
210+
!isempty(state.delta_prime) && (coupling_group["Delta_prime"] = state.delta_prime)
211+
!isempty(state.forcing_solution_weights) && (coupling_group["forcing_solution_weights"] = state.forcing_solution_weights)
211212
!isempty(state.rational_area) && (coupling_group["rational_area"] = state.rational_area)
212-
!isempty(state.island_half_width) && (coupling_group["island_half_width"] = state.island_half_width)
213+
!isempty(state.island_half_width) && (coupling_group["island_half_width"] = state.island_half_width)
213214
!isempty(state.chirikov_parameter) && (coupling_group["chirikov_parameter"] = state.chirikov_parameter)
214215

215216
# Metadata [n_rational]
216-
!isempty(state.rational_psi) && (coupling_group["rational_psi"] = state.rational_psi)
217-
!isempty(state.rational_q) && (coupling_group["rational_q"] = state.rational_q)
218-
!isempty(state.rational_m_res) && (coupling_group["rational_m"] = state.rational_m_res)
219-
!isempty(state.rational_n) && (coupling_group["rational_n"] = state.rational_n)
217+
!isempty(state.rational_psi) && (coupling_group["rational_psi"] = state.rational_psi)
218+
!isempty(state.rational_q) && (coupling_group["rational_q"] = state.rational_q)
219+
!isempty(state.rational_m_res) && (coupling_group["rational_m"] = state.rational_m_res)
220+
!isempty(state.rational_n) && (coupling_group["rational_n"] = state.rational_n)
220221

221222
# Energies
222223
energy_group = haskey(pe_group, "Energies") ? pe_group["Energies"] : create_group(pe_group, "Energies")
223-
energy_group["vacuum_energy"] = state.vacuum_energy
224-
energy_group["surface_energy"] = state.surface_energy
225-
energy_group["plasma_energy"] = state.plasma_energy
224+
energy_group["vacuum_energy"] = state.vacuum_energy
225+
energy_group["surface_energy"] = state.surface_energy
226+
energy_group["plasma_energy"] = state.plasma_energy
226227
energy_group["toroidal_torque"] = state.toroidal_torque
227228

228229
annotate_pe!(pe_group)
@@ -261,7 +262,8 @@ const PE_H5_ANNOTATIONS = [
261262
"Response/xi_cov_psi" => (; long_name="covariant radial displacement ξ_ψ = ξ·e_ψ", units="m^2", dims=("psi", "mode"), attach=(1 => "Response/psi",)),
262263
"Response/xi_cov_theta" => (; long_name="covariant poloidal displacement ξ_θ = ξ·e_θ", units="m^2", dims=("psi", "mode"), attach=(1 => "Response/psi",)),
263264
"Response/xi_cov_zeta" => (; long_name="covariant toroidal displacement ξ_ζ = ξ·e_ζ", units="m^2", dims=("psi", "mode"), attach=(1 => "Response/psi",)),
264-
"Response/xi_clebsch_psi" => (; long_name="Clebsch displacement component ξ^ψ (PENTRC input, gpout_xclebsch convention)", dims=("psi", "mode"), attach=(1 => "Response/psi",)),
265+
"Response/xi_clebsch_psi" =>
266+
(; long_name="Clebsch displacement component ξ^ψ (PENTRC input, gpout_xclebsch convention)", dims=("psi", "mode"), attach=(1 => "Response/psi",)),
265267
"Response/dxi_clebsch_psidpsi" =>
266268
(; long_name="regularized ψ_N derivative of ξ^ψ (× singfac²/(singfac²+reg_spot²))", dims=("psi", "mode"), attach=(1 => "Response/psi",)),
267269
"Response/xi_clebsch_alpha" =>
@@ -345,7 +347,10 @@ const PE_H5_ANNOTATIONS = [
345347
"Energies/vacuum_energy" => (; long_name="perturbed vacuum energy", units="J"),
346348
"Energies/surface_energy" => (; long_name="perturbed surface energy", units="J"),
347349
"Energies/plasma_energy" => (; long_name="perturbed plasma energy", units="J"),
348-
"Energies/toroidal_torque" => (; long_name="net toroidal torque on the plasma", units="N*m")
350+
"Energies/toroidal_torque" => (;
351+
long_name="boundary-response toroidal torque −2n·Im⟨Φ_tot,Λ⁻¹Φ_tot⟩/4: equals the volume-integrated Euler-Lagrange kinetic torque for converged self-consistent solutions; distinct construction from the KineticForces NTV torque",
352+
units="N*m"
353+
)
349354
]
350355

351356
# Attach long_name/units/dims + dimension scales (declared in-table) to the

0 commit comments

Comments
 (0)