Skip to content
Open
118 changes: 66 additions & 52 deletions src/ForceFreeStates/EulerLagrange.jl
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ and a small set of temporary matrices and factors used to compute singular-layer

# Initialization parameters

- `zeroed_idx::Vector{Vector{Int}}` - For each ideal rational surface jump, a vector of indices of solutions that were zeroed. # Data for integrator
- `zeroed_idx::Vector{Vector{Int}}` - For each ideal crossing, the positions in `index` of the zeroed resonant solutions (`1:nres`). # Data for integrator

- `fixfac::Array{ComplexF64,3}` - Fix-up factors for Gaussian reduction with shape `(numpert_total, numpert_total, numunorms_init)`. # Data for integrator

Expand Down Expand Up @@ -763,11 +763,15 @@ function cross_ideal_singular_surf!(
ising::Int
)

# Fixup solution at singular surface
compute_solution_norms!(odet.u, odet, ctrl, intr, true)
singp = intr.sing[ising]
ipert_res = 1 .+ singp.m .- intr.mlow .+ (singp.n .- intr.nlow) .* intr.mpert
nres = length(ipert_res)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

For clarity/concistency, maybe define

nres = length(ipert_res)

here, and then use for loop bounds below?

# Fixup solution at singular surface; the ideal jump reduces on the resonant rows so the
# solution it removes below is the resonant one
compute_solution_norms!(odet.u, odet, ctrl, intr, true; resonant_rows=ipert_res)

# Compute direction-specific asymptotic power series for this singular surface
singp = intr.sing[ising]
sing_asymp_right = compute_sing_asymptotics(singp, ctrl, equil, mats, intr; sig=1.0)
sing_asymp_left = compute_sing_asymptotics(singp, ctrl, equil, mats, intr; sig=-1.0, alpha_override=sing_asymp_right.alpha)
dpsi = singp.psifac - odet.psifac # ψ_res - ψ (positive)
Expand All @@ -776,19 +780,13 @@ function cross_ideal_singular_surf!(
ua = sing_get_ua(sing_asymp_left, dpsi)
odet.ca_l[:, :, :, ising] .= sing_get_ca(odet.u, ua, intr)

# Single n: remove largest solution and sub in asymptotics on the other side
# Multi-n: if we remove the N largest modes in arbitrary order, we can mess up the
# diagonal structure of the matrix and later calculations. zeroed_idx let's us make sure
# the solution vector we're zeroing corresponds to the same block as the resonant mode we
# introduce. It is also needed when transforming u back to the full solution after integration.
ipert_res = 1 .+ singp.m .- intr.mlow .+ (singp.n .- intr.nlow) .* intr.mpert
if ctrl.kinetic_factor == 0
# Eliminate the solution with the largest norm (in the same block) for each resonance
odet.zeroed_idx[odet.ifix] = Int[]
for i in eachindex(sing_asymp_right.r1)
push!(odet.zeroed_idx[odet.ifix], findfirst(j -> (ipert_res[i] - 1) ÷ intr.mpert == (odet.index[j, odet.ifix] - 1) ÷ intr.mpert, 1:intr.numpert_total))
odet.u[:, odet.index[odet.zeroed_idx[odet.ifix][i], odet.ifix], :] .= 0
end
# Remove the resonant solution for each resonance and sub in asymptotics on the other side.
# The reduction above placed the column pivoted on ipert_res[i] at index position i, which also
# keeps each zeroed column in the same n-block as the resonant mode introduced below; zeroed_idx
# records the positions for transforming u back to the full solution after integration.
odet.zeroed_idx[odet.ifix] = collect(1:nres)
for i in 1:nres
odet.u[:, odet.index[i, odet.ifix], :] .= 0
end

# Re-initialize on opposite side of rational surface by approximating solution
Expand All @@ -802,13 +800,11 @@ function cross_ideal_singular_surf!(

# Apply asymptotic solution on other side of singular surface (right side)
ua = sing_get_ua(sing_asymp_right, dpsi)
if ctrl.kinetic_factor == 0
for i in eachindex(sing_asymp_right.r1)
# Zero out the resonant components
odet.u[ipert_res[i], :, :] .= 0
# Introduce the small asymptotic resonant solution on the other side of the singular surface
odet.u[:, odet.index[odet.zeroed_idx[odet.ifix][i], odet.ifix], :] .= ua[:, ipert_res[i]+intr.numpert_total, :]
end
for i in 1:nres
# Zero out the resonant components
odet.u[ipert_res[i], :, :] .= 0
# Introduce the small asymptotic resonant solution on the other side of the singular surface
odet.u[:, odet.index[i, odet.ifix], :] .= ua[:, ipert_res[i]+intr.numpert_total, :]
end
# Get asymptotic coefficients after crossing rational surface
odet.ca_r[:, :, :, ising] .= sing_get_ca(odet.u, ua, intr)
Expand Down Expand Up @@ -989,7 +985,7 @@ function integrate_el_region!(
end

"""
compute_solution_norms!(u::Array{ComplexF64,3}, odet::OdeState, ctrl::ForceFreeStatesControl, intr::ForceFreeStatesInternal, sing_flag::Bool)
compute_solution_norms!(u::Array{ComplexF64,3}, odet::OdeState, ctrl::ForceFreeStatesControl, intr::ForceFreeStatesInternal, sing_flag::Bool; resonant_rows=Int[])

Computes norms of the solution vectors of the array `u` and normalizes them
if this is not the first call after a fixup. Formerly `ode_unorm!`.
Expand All @@ -1008,12 +1004,14 @@ operate on `u` directly without extra copies.

- u: Current solution vector array, updated in-place if fixfac is called
- sing_flag: Indicates if normalization is occuring at a singular surface or not
- resonant_rows: Resonant rows at an ideal crossing; when given, the reduction always runs and pivots on them

### TODOs

Add resizing logic for unorm arrays when ifix exceeds allocated size
"""
function compute_solution_norms!(u::Array{ComplexF64,3}, odet::OdeState, ctrl::ForceFreeStatesControl, intr::ForceFreeStatesInternal, sing_flag::Bool)
function compute_solution_norms!(u::Array{ComplexF64,3}, odet::OdeState, ctrl::ForceFreeStatesControl, intr::ForceFreeStatesInternal, sing_flag::Bool;
resonant_rows=Int[])

# Compute norms of first solution vectors, abort if any are zero
odet.unorm .= norm.(eachcol(u[:, :, 1]))
Expand All @@ -1022,29 +1020,36 @@ function compute_solution_norms!(u::Array{ComplexF64,3}, odet::OdeState, ctrl::F
error("One of the first solution vector norms unorm(1,$jmax) = 0")
end

# Normalize unorm and perform Gaussian reduction if required
if odet.new
# The first call after a reduction records the reference norms; an ideal crossing reduces regardless
at_ideal_crossing = !isempty(resonant_rows)
if odet.new && !at_ideal_crossing
odet.new = false
odet.unorm0 .= odet.unorm
else
return
end

# Growth since the last reduction (raw norms if a crossing comes right after one)
if !odet.new
odet.unorm ./= odet.unorm0
uratio = maximum(odet.unorm) / minimum(odet.unorm)
if uratio > ctrl.ucrit || sing_flag
# TODO: add resizing logic here as well
if odet.ifix < ctrl.numunorms_init
odet.ifix += 1
else
@warn "unorm storage reached, no longer saving fixfac data. Stability outputs and unorming will be correct, but cannot reconstruct `u`. \n
Increase `numunorms_init` if needed. Automatic resizing will be added in a future version."
end
apply_gaussian_reduction!(u, odet, intr, sing_flag)
odet.new = true
end
uratio = maximum(odet.unorm) / minimum(odet.unorm)

# Perform Gaussian reduction if the ucrit ratio is reached or at a singular surface
if uratio > ctrl.ucrit || sing_flag
# TODO: add resizing logic here as well
if odet.ifix < ctrl.numunorms_init
odet.ifix += 1
else
@warn "unorm storage reached, no longer saving fixfac data. Stability outputs and unorming will be correct, but cannot reconstruct `u`. \n
Increase `numunorms_init` if needed. Automatic resizing will be added in a future version."
end
apply_gaussian_reduction!(u, odet, intr, sing_flag; resonant_rows)
odet.new = true
end
end

"""
apply_gaussian_reduction!(u::Array{ComplexF64,3}, odet::OdeState, intr::ForceFreeStatesInternal, sing_flag::Bool)
apply_gaussian_reduction!(u::Array{ComplexF64,3}, odet::OdeState, intr::ForceFreeStatesInternal, sing_flag::Bool; resonant_rows=Int[])

Applies Gaussian reduction to orthogonalize solution vectors in `u`.
Formerly `ode_fixup!`. Performs the same function as `ode_fixup` in the Fortran code,
Expand All @@ -1053,8 +1058,12 @@ Used when the spread in norms exceeds a threshold or when a rational surface is
This will update both `u` and relevant fields in `odet` in-place. See the
description of `compute_solution_norms!` for more details on the benefits of in-place `u`
updates.

At an ideal crossing, the first pivots are the `resonant_rows`, each on the remaining column largest
there. Growth order cannot pick the resonant solution if a `ucrit` reduction fired just before the
crossing, since every column has then grown alike.
"""
function apply_gaussian_reduction!(u::Array{ComplexF64,3}, odet::OdeState, intr::ForceFreeStatesInternal, sing_flag::Bool)
function apply_gaussian_reduction!(u::Array{ComplexF64,3}, odet::OdeState, intr::ForceFreeStatesInternal, sing_flag::Bool; resonant_rows=Int[])

# Store data for the current fixup
ifix = odet.ifix
Expand All @@ -1067,20 +1076,25 @@ function apply_gaussian_reduction!(u::Array{ComplexF64,3}, odet::OdeState, intr:
for isol in 1:intr.numpert_total
odet.fixfac[isol, isol, ifix] = 1
end
# Sort unorm in descending order (since we triangularize from largest to smallest)
odet.index[:, ifix] = sortperm(odet.unorm; rev=true)

# Triangularize primary solutions
mask = trues(2, intr.numpert_total)
# Triangularize primary solutions, resonant rows first, then columns from largest to smallest growth
growth_order = sortperm(odet.unorm; rev=true)
row_free = trues(intr.numpert_total)
col_free = trues(intr.numpert_total)
for isol in 1:intr.numpert_total
ksol = odet.index[isol, ifix]
mask[2, ksol] = false
# Set pivot row based on max location
@views kpert = argmax(abs.(u[:, ksol, 1]) .* mask[1, :])
mask[1, kpert] = false
if isol <= length(resonant_rows)
kpert = resonant_rows[isol]
ksol = argmax(j -> col_free[j] ? abs(u[kpert, j, 1]) : -Inf, 1:intr.numpert_total)
else
ksol = growth_order[findfirst(j -> col_free[j], growth_order)]
@views kpert = argmax(abs.(u[:, ksol, 1]) .* row_free)
end
odet.index[isol, ifix] = ksol
col_free[ksol] = false
row_free[kpert] = false
# Eliminate other solution vectors below the pivot
for jsol in 1:intr.numpert_total
if mask[2, jsol]
if col_free[jsol]
odet.fixfac[ksol, jsol, ifix] = -u[kpert, jsol, 1] / u[kpert, ksol, 1]
@. @views u[:, jsol, :] .= u[:, jsol, :] .+ u[:, ksol, :] .* odet.fixfac[ksol, jsol, ifix]
u[kpert, jsol, 1] = 0
Expand Down
37 changes: 37 additions & 0 deletions test/runtests_eulerlagrange.jl
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,43 @@ end
@test odet.new == true # fixup triggered
end

@testset "ideal-crossing reduction isolates the resonant harmonic" begin
FFS = GeneralizedPerturbedEquilibrium.ForceFreeStates
# Column 1 has the largest norm but no resonant (row 3) component; column 2 carries the resonance.
# Growth order alone would lead with column 1 and pivot it on row 1, leaving row 3 in columns 2 and 3.
u0 = zeros(ComplexF64, 3, 3, 2)
u0[:, 1, 1] .= [6.0, 0.5, 0.0]
u0[:, 2, 1] .= [0.4, 0.3, 5.0]
u0[:, 3, 1] .= [0.2, 2.0, 1.5]
u0[:, :, 2] .= 1.0
intr = FFS.ForceFreeStatesInternal(; numpert_total=3)
ctrl = FFS.ForceFreeStatesControl(; ucrit=1e3)
fresh(u) = (odet = FFS.OdeState(3, 10, 10, 1); odet.u .= u; odet)

odet = fresh(u0)
odet.new = false
odet.unorm0 .= norm.(eachcol(u0[:, :, 1])) # every column grown alike since the last reduction
FFS.compute_solution_norms!(odet.u, odet, ctrl, intr, true; resonant_rows=[3])
@test odet.index[1, 1] == 2
@test odet.u[3, 2, 1] == u0[3, 2, 1]
@test odet.u[3, 1, 1] == 0 && odet.u[3, 3, 1] == 0 # only the lead column keeps the resonant harmonic
@test odet.sing_flag[1]

# A crossing right after a ucrit reduction (new = true) still reduces on the resonant row.
odet = fresh(u0)
FFS.compute_solution_norms!(odet.u, odet, ctrl, intr, true; resonant_rows=[3])
@test odet.ifix == 1 && odet.index[1, 1] == 2 && odet.new
@test odet.u[3, 1, 1] == 0 && odet.u[3, 3, 1] == 0

# Without resonant rows the growth-ordered reduction is unchanged.
odet = fresh(u0)
odet.new = false
odet.unorm0 .= 1.0
FFS.compute_solution_norms!(odet.u, odet, ctrl, intr, true)
@test odet.index[1, 1] == 1
@test odet.u[3, 2, 1] != 0
end

@testset "OdeState construction" begin
# Test basic OdeState initialization
numpert_total = 5
Expand Down
20 changes: 20 additions & 0 deletions test/runtests_fullruns.jl
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using HDF5
using TOML

# Run GeneralizedPerturbedEquilibrium.main on the provided example directories and assert it completes without throwing.
@testset "Full ForceFreeStates runs" begin
Expand All @@ -9,6 +10,25 @@ using HDF5
true
end

# A ucrit reduction landing just before an ideal crossing must not change which solution the crossing removes.
@info "Running Solovev ideal example with frequent Gaussian reductions"
@testset "et[1] is independent of the reduction frequency" begin
deck = TOML.parsefile(joinpath(ex1, "gpec.toml"))
deck["ForceFreeStates"]["write_outputs_to_HDF5"] = true
function total_energy(ucrit)
deck["ForceFreeStates"]["ucrit"] = ucrit
return mktempdir() do workdir
open(io -> TOML.print(io, deck), joinpath(workdir, "gpec.toml"), "w")
GeneralizedPerturbedEquilibrium.main([workdir])
return h5open(h5 -> read(h5["ForceFreeStates/FreeBoundaryStability/eigenmode_energies"])[1], joinpath(workdir, "gpec.h5"), "r")
end
end
et_ref = total_energy(1e3)
for ucrit in (2.0, 3.0, 5.0, 10.0)
@test total_energy(ucrit) ≈ et_ref rtol = 1e-6
end
end

ex2 = joinpath(@__DIR__, "test_data", "regression_solovev_ideal_example_multi_n")
@info "Running Solovev ideal multi-n example"
@test begin
Expand Down
Loading