-
Notifications
You must be signed in to change notification settings - Fork 7
Expand file tree
/
Copy pathEulerLagrange.jl
More file actions
1464 lines (1225 loc) · 68.2 KB
/
Copy pathEulerLagrange.jl
File metadata and controls
1464 lines (1225 loc) · 68.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
EdgeScanState
Holds the state and results for the edge dW stability scan over ψ ∈ [psiedge, psilim].
Initialized and populated by `findmax_dW_edge!`; results written to HDF5 under `EdgeScan/`.
The energies are generalized (W, N) pencil values: power-normalized and invariant to the
working (Jacobian) coordinate (see `power_norm_matrix!`).
## Fields
- `wvmat` - Precomputed wv matrix spline (raw, no singfac); singfac applied analytically in `free_compute_total`.
- `wv_hint::Base.RefValue{Int}` - Search hint for wvmat spline (different grid from equilibrium profiles).
- `psi, q` - ψ and q values at each edge scan step.
- `total_eigenvalue, plasma_energy, vacuum_energy, vacuum_eigenvalue` - Power-normalized energy components at each step (NaN for steps where the wp solve was singular). These drive the truncation choice and are written to `EdgeScan/`.
"""
@kwdef mutable struct EdgeScanState
numpert_total::Int
N_edge::Int
# Vacuum matrix spline and evaluation infrastructure
wvmat::CubicSeriesInterpolant{Float64,ComplexF64} = _empty_series_interp_complex(numpert_total^2)
wv_hint::Base.RefValue{Int} = Ref(1)
# Scan results (written to HDF5 under EdgeScan/; NaN where free_compute_total raised SingularException)
psi::Vector{Float64} = Vector{Float64}(undef, N_edge)
q::Vector{Float64} = Vector{Float64}(undef, N_edge)
total_eigenvalue::Vector{ComplexF64} = fill(complex(NaN), N_edge)
plasma_energy::Vector{ComplexF64} = fill(complex(NaN), N_edge)
vacuum_energy::Vector{ComplexF64} = fill(complex(NaN), N_edge)
vacuum_eigenvalue::Vector{Float64} = fill(NaN, N_edge)
end
EdgeScanState(numpert_total::Int, N_edge::Int) = EdgeScanState(; numpert_total, N_edge)
"""
OdeState
A mutable struct to hold the state of the ODE solver used by the ForceFreeStates integration routines.
This struct stores configuration parameters used to allocate arrays, the evolving stored
solution during integration, diagnostic arrays used for normalization / Gaussian reduction,
and a small set of temporary matrices and factors used to compute singular-layer corrections.
## Fields
- `numpert_total::Int` - Total number of Fourier mode combinations (m × n) used in the calculation.
- `numunorms_init::Int` - Initial allocation size for the number of normalization operations recorded.
- `msing::Int` - Number of singular surfaces in the equilibrium (used to size asymptotic coefficient arrays).
- `numsteps_init::Int` - Initial allocation size for the number of integration steps to store.
- `step::Int` - Current integration step index (1-based, like `istep` in the original Fortran).
- `psi_store::Vector{Float64}` - Stored psi values at each saved integration step (length `numsteps_init`).
- `q_store::Vector{Float64}` - Stored q values at each saved integration step (length `numsteps_init`).
- `u_store::Array{ComplexF64,4}` - Stored solution arrays at each saved step with shape
`(numpert_total, numpert_total, 2, numsteps_init)` (complex solution state used by the solver).
- `du_store::Array{ComplexF64,3}` - dΞ_ψ/dψ (the u₁ block only) at each saved step, shape
`(numpert_total, numpert_total, step)`. Empty until `materialize_derivative_stores!` fills it,
except on the galerkin-matched path which supplies the analytic derivative at construction.
du₂/dψ is never stored densely — its only consumer evaluates it on demand at bracket nodes.
- `xi_s_store::Array{ComplexF64,3}` - Clebsch displacement Ξ_s at each saved step, eq. 18 of Glasser 2016,
shape `(numpert_total, numpert_total, step)`. Empty until materialized, same as `du_store`.
- `u_store_el_basis::Bool` - True when `u_store` holds the Euler-Lagrange state `(u₁, u₂)`, so the
derivative kernel can be re-applied to it. False on the sparse parallel path, whose stored columns
are chunk-endpoint Riccati matrices; `materialize_derivative_stores!` refuses to run there.
- `du_store_populated::Bool` - True once `du_store`/`xi_s_store` hold valid data in the final
(post-transform, post-normalization) basis. Set by `materialize_derivative_stores!` or by the
galerkin-matched constructor; stays false where the stores cannot be materialized, e.g. the
sparse parallel path whose solution is in the Riccati basis.
- `crit_store::Vector{Float64}` - Stored crit parameter values (smallest eigenvalue of W⁻ꜝ) (length `numsteps_init`).
- `ca_r::Array{ComplexF64,4}` - Asymptotic coefficients just to the right of each singular surface
with shape `(numpert_total, numpert_total, 2, msing)`.
- `ca_l::Array{ComplexF64,4}` - Asymptotic coefficients just to the left of each singular surface
with shape `(numpert_total, numpert_total, 2, msing)`.
- `ca_populated::Bool` - True once an ideal singular-surface crossing has filled `ca_l`/`ca_r`; kinetic and
galerkin-matched runs never populate them and leave this false, and the HDF5 writer then emits zero-extent
`ca_left`/`ca_right` datasets instead of unpopulated arrays.
- `edge_scan::EdgeScanState` - Edge dW scan state and results. Initialized as a disabled sentinel (N_edge=0) and replaced by `findmax_dW_edge!` when a scan runs.
- `psifac::Float64` - Current normalized flux coordinate for the integrator.
- `q::Float64` - Safety factor value at `psifac` (current q during integration).
- `u::Array{ComplexF64,3}` - Current working solution arrays with shape `(numpert_total, numpert_total, 2)`.
- `ising_start::Int` - Index of the starting singular surface to be crossed during integration.
- `psimax::Float64` - Maximum psi value for which the integrator is allowed to run in next integration region.
- `needs_crossing::Bool` - Flag indicating whether a rational surface needs to be crossed after the current integration region.
- `nzero::Int` - Count of detected zero crossings (used for diagnostics).
- `new::Bool` - Flag indicating whether a new `unorm0` should be computed after a fixup.
# Initialization parameters
- `unorm::Vector{Float64}` - Current norms of the solution vectors (length `numpert_total`).
- `unorm0::Vector{Float64}` - Reference/initial norms of the solution vectors (length `numpert_total`).
# Saved data throughout integration
- `ifix::Int` - Number of normalization operations performed (index into normalization arrays).
# Total ODE solver steps taken (all steps, not just saved ones)
- `index::Array{Int,2}` - Index matrix used for sorting solution norms with shape `(numpert_total, numunorms_init)`.
- `sing_flag::Vector{Bool}` - Boolean flags indicating which stored normalizations correspond to singular solutions # Edge dW scan state and results (disabled sentinel when psiedge >= psilim, i.e. no edge scan)
(length `numunorms_init`).
- `zeroed_idx::Vector{Vector{Int}}` - For each ideal rational surface jump, a vector of indices of solutions that were zeroed. # Data for integrator
- `fixfac::Array{ComplexF64,3}` - Fix-up factors for Gaussian reduction with shape `(numpert_total, numpert_total, numunorms_init)`.
- `fixstep::Vector{Int64}` - Step indices (psi step positions) at which normalization/fixups were performed (length `numunorms_init`).
"""
@kwdef mutable struct OdeState
# Initialization parameters
numpert_total::Int
numunorms_init::Int
msing::Int
numsteps_init::Int
# Saved data throughout integration
step::Int = 1
total_steps::Int = 0 # Total ODE solver steps taken (all steps, not just saved ones)
psi_store::Vector{Float64} = Vector{Float64}(undef, numsteps_init)
q_store::Vector{Float64} = Vector{Float64}(undef, numsteps_init)
u_store::Array{ComplexF64,4} = Array{ComplexF64}(undef, numpert_total, numpert_total, 2, numsteps_init)
du_store::Array{ComplexF64,3} = Array{ComplexF64}(undef, numpert_total, numpert_total, 0)
xi_s_store::Array{ComplexF64,3} = Array{ComplexF64}(undef, numpert_total, numpert_total, 0)
u_store_el_basis::Bool = true
du_store_populated::Bool = false
crit_store::Vector{Float64} = Vector{Float64}(undef, numsteps_init)
ca_r::Array{ComplexF64,4} = zeros(ComplexF64, numpert_total, numpert_total, 2, msing)
ca_l::Array{ComplexF64,4} = zeros(ComplexF64, numpert_total, numpert_total, 2, msing)
ca_populated::Bool = false
# Edge dW scan state and results (disabled sentinel when psiedge >= psilim, i.e. no edge scan)
edge_scan::EdgeScanState = EdgeScanState(numpert_total, 0)
# Data for integrator
psifac::Float64 = 0.0
q::Float64 = 0.0
u::Array{ComplexF64,3} = zeros(ComplexF64, numpert_total, numpert_total, 2)
ising_start::Int = 0
psimax::Float64 = 0.0
needs_crossing::Bool = false
nzero::Int = 0
# Used for Gaussian reduction
new::Bool = true
unorm::Vector{Float64} = zeros(Float64, numpert_total)
unorm0::Vector{Float64} = zeros(Float64, numpert_total)
ifix::Int = 0
index::Array{Int,2} = zeros(Int, numpert_total, numunorms_init)
sing_flag::Vector{Bool} = falses(numunorms_init)
zeroed_idx::Vector{Vector{Int}} = [Int[] for _ in 1:numunorms_init]
fixfac::Array{ComplexF64,3} = zeros(ComplexF64, numpert_total, numpert_total, numunorms_init)
fixstep::Vector{Int64} = zeros(Int64, numunorms_init)
# Kinetic workspace arrays: evaluated from Kw_spline/Kt_spline splines at current psi
kwmat::Array{ComplexF64,3} = zeros(ComplexF64, numpert_total, numpert_total, 6)
ktmat::Array{ComplexF64,3} = zeros(ComplexF64, numpert_total, numpert_total, 6)
# Shared hint for CubicInterpolant interval search optimization during ODE integration
# All splines evaluated at the same psi can share this hint for O(1) interval lookups
spline_hint::Base.RefValue{Int} = Ref(1)
# Shared 2D hint for CubicInterpolantND (rzphi splines) during ODE integration
# Tuple of (psi_hint, theta_hint) for O(1) interval lookups in 2D bicubic splines
rzphi_hint::Tuple{Base.RefValue{Int},Base.RefValue{Int}} = (Ref(1), Ref(1))
# Per-thread hint for MatrixSplines matrix splines (A_spline/B_spline/C_spline/F_spline_lower/K_spline/G_spline
# and kinetic equivalents). Lives on OdeState — which is already cloned per thread in the
# parallel BVP path — so concurrent sing_der! invocations don't race on a shared Ref.
mats_hint::Base.RefValue{Int} = Ref(1)
end
OdeState(numpert_total::Int, numsteps_init::Int, numunorms_init::Int, msing::Int) =
OdeState(; numpert_total, numsteps_init, numunorms_init, msing)
"""
compute_delta_prime_from_ca!(odet, intr, equil)
**STUB — not physically valid.** Compute a per-surface Δ' estimate from the asymptotic
coefficients `ca_l`/`ca_r` using `Δ'[i] = (ca_r[i,i,2,s] - ca_l[i,i,2,s]) / (4π²·psio)`.
The physically valid tearing-stability Δ' is `ForceFreeStatesInternal.delta_prime_matrix`,
computed via the STRIDE global BVP in `compute_delta_prime_matrix!`. The per-surface
ca-based formula here ignores inter-surface coupling and the vacuum BC, and should
**not** be expected to agree with `delta_prime_matrix`. Retained for reference / future
work on intra-surface coupling diagnostics.
Not called from any integration driver. Used only by tests / benchmarks that exercise
the stub formula directly.
"""
function compute_delta_prime_from_ca!(odet::OdeState, intr::ForceFreeStatesInternal, equil::Equilibrium.PlasmaEquilibrium)
denom = (2π)^2 * equil.psio # = twopi * chi1 in SingularCoupling.jl
for s in 1:intr.msing
sing = intr.sing[s]
n_modes = length(sing.m)
resize!(intr.sing[s].delta_prime, n_modes)
for i in 1:n_modes
ipert_res = 1 + sing.m[i] - intr.mlow + (sing.n[i] - intr.nlow) * intr.mpert
if 1 <= ipert_res <= intr.numpert_total
Δca = odet.ca_r[ipert_res, ipert_res, 2, s] - odet.ca_l[ipert_res, ipert_res, 2, s]
intr.sing[s].delta_prime[i] = Δca / denom
else
intr.sing[s].delta_prime[i] = 0.0 + 0.0im
end
end
end
end
# Empirical log-divergent ODE-cost coefficients (a, b) for each reference point:
# axis (ψ=0, steep), rational surfaces (ψ=ψ_s, moderate), edge (ψ=ψ_lim, mild).
# Per reference, the contribution to the cost is (a/b) · |log(1 + b·|ψ-ref|)| evaluated
# at the interval endpoints. Coefficients are ported from STRIDE's ode_itime cost model
# (Fortran reference) and unchanged here. Tune only after re-fitting against a per-chunk
# step-count sweep; touching these affects parallel-chunk load balancing.
const ODE_COST_AXIS = (a = 39695.0, b = 212830.0)
const ODE_COST_RAT = (a = 17147.0, b = 470710.0)
const ODE_COST_EDGE = (a = 1646.0, b = 4683.0)
"""
ode_itime_cost(psi1, psi2, intr) -> Float64
Estimate the relative ODE integration cost for the interval [ψ₁, ψ₂] using the empirical
log-divergent cost model from STRIDE (Glasser 2018). Coefficients are the module constants
`ODE_COST_AXIS`, `ODE_COST_RAT`, `ODE_COST_EDGE`. The cost is additive for sub-intervals
not containing rational surfaces, which makes it suitable for equal-cost splitting via
bisection in `balance_integration_chunks`.
"""
function ode_itime_cost(psi1::Float64, psi2::Float64, intr::ForceFreeStatesInternal)
_logdiv(a, b, x1, x2) = (a / b) * abs(log(1.0 + b * abs(x2)) - log(1.0 + b * abs(x1)))
cost = _logdiv(ODE_COST_AXIS.a, ODE_COST_AXIS.b, psi1, psi2)
for sing in intr.sing
cost += _logdiv(ODE_COST_RAT.a, ODE_COST_RAT.b, psi1 - sing.psifac, psi2 - sing.psifac)
end
cost += _logdiv(ODE_COST_EDGE.a, ODE_COST_EDGE.b, psi1 - intr.psilim, psi2 - intr.psilim)
return cost
end
"""
balance_integration_chunks(chunks, ctrl, intr) -> Vector{IntegrationChunk}
Sub-divide integration chunks to produce a load-balanced set for the Riccati BVP.
Starts from the output of `chunk_el_integration_bounds` and iteratively splits the
highest-cost chunk (by `ode_itime_cost`) until the total chunk count reaches the target
set by `ctrl.nchunks` (`0` = auto). The target is derived from problem structure only —
never from `Threads.nthreads()` — so the chunk list, and hence every Riccati output, is
identical whatever thread count `julia -t` provides.
Each split finds the equal-cost midpoint ψ_mid via bisection:
ode_itime_cost(psi_start, psi_mid) ≈ ode_itime_cost(psi_start, psi_end) / 2
Sub-chunks inherit `needs_crossing=false` and `ising=0`. Only the LAST sub-chunk of
each original chunk retains `needs_crossing=true` and the original `ising`, so the
rational surface crossing still fires at the correct ψ in the serial assembly phase.
"""
function balance_integration_chunks(chunks::Vector{IntegrationChunk}, ctrl::ForceFreeStatesControl, intr::ForceFreeStatesInternal)
min_chunks = 2 * intr.msing + 3
# Ensure enough sub-chunks for BVP propagator conditioning: at least 5 non-crossing
# sub-chunks per segment (axis→surf₁, surfᵢ→surfᵢ₊₁, surfₙ→edge), plus crossing
# chunks. STRIDE uses 33 intervals for comparable problems. Without enough sub-chunks,
# assemble_fm_matrix(condition=true) can't keep accumulated products well-conditioned
# because single long-span propagators may already have cond ~ 10²⁴.
min_bvp_intervals = 8 * (intr.msing + 1) + intr.msing
if ctrl.nchunks > 0
if ctrl.nchunks < min_chunks
@warn "nchunks = $(ctrl.nchunks) is below the $min_chunks chunks required by $(intr.msing) singular surfaces; clamping up."
end
target_n = max(ctrl.nchunks, min_chunks)
else
target_n = max(min_chunks, min_bvp_intervals)
end
result = collect(chunks)
while length(result) < target_n
# Find the highest-cost splittable chunk
best_idx = 0
best_cost = -Inf
for (i, chunk) in enumerate(result)
width = chunk.psi_end - chunk.psi_start
if width > 1e-8
c = ode_itime_cost(chunk.psi_start, chunk.psi_end, intr)
if c > best_cost
best_cost = c
best_idx = i
end
end
end
best_idx == 0 && break # No more splittable chunks
chunk = result[best_idx]
total_cost = best_cost
target_cost = total_cost / 2.0
# Bisect to find ψ_mid where cost(psi_start, ψ_mid) ≈ target_cost
lo, hi = chunk.psi_start, chunk.psi_end
for _ in 1:50
mid = (lo + hi) / 2.0
if ode_itime_cost(chunk.psi_start, mid, intr) < target_cost
lo = mid
else
hi = mid
end
end
psi_mid = (lo + hi) / 2.0
left = IntegrationChunk(; psi_start=chunk.psi_start, psi_end=psi_mid,
needs_crossing=false, ising=0, direction=1)
right = IntegrationChunk(; psi_start=psi_mid, psi_end=chunk.psi_end,
needs_crossing=chunk.needs_crossing, ising=chunk.ising,
direction=chunk.direction)
splice!(result, best_idx, [left, right])
end
return result
end
"""
eulerlagrange_integration(ctrl, equil, mats, intr) -> (odet, propagators, chunks, S_left)
Integrate the Euler-Lagrange equations from the axis to `intr.psilim`, crossing each singular
surface on the way (Fortran `ode_run`). Dispatches on `ctrl.integrator` to
[`riccati_eulerlagrange_integration`](@ref) (the chunked propagator BVP) or
[`forward_eulerlagrange_integration`](@ref).
Only the Riccati branch populates `propagators` / `chunks` / `S_left`, which
`compute_delta_prime_matrix!` consumes for the Δ' BVP; the forward branch returns `nothing`
for all three.
"""
function eulerlagrange_integration(ctrl::ForceFreeStatesControl, equil::Equilibrium.PlasmaEquilibrium, mats::MatrixSplines, intr::ForceFreeStatesInternal)
if ctrl.integrator == "riccati"
ctrl.kinetic_factor > 0 && error("kinetic runs require integrator=\"forward\"; the Riccati integrator has no kinetic crossing.")
return riccati_eulerlagrange_integration(ctrl, equil, mats, intr)
elseif ctrl.integrator == "forward"
return forward_eulerlagrange_integration(ctrl, equil, mats, intr)
elseif ctrl.integrator == "galerkin"
error("integrator = \"galerkin\" solves the Euler-Lagrange system variationally, not by ODE integration; " *
"it is dispatched to galerkin_solve.")
end
error("Unknown integrator: $(ctrl.integrator). Expected \"forward\", \"riccati\", or \"galerkin\".")
end
"""
forward_eulerlagrange_integration(ctrl, equil, mats, intr; verbose=ctrl.verbose) -> (odet, nothing, nothing, nothing)
Forward branch of [`eulerlagrange_integration`](@ref): integrates chunk by chunk from the axis,
applying Gaussian reduction whenever a solution norm ratio exceeds `ctrl.ucrit` and undoing it
via `transform_u!` at the end, so `odet.u_store` comes back dense in the axis basis. Call
directly to force this branch regardless of `ctrl.integrator`; `verbose` overrides
`ctrl.verbose` for progress logging.
"""
function forward_eulerlagrange_integration(ctrl::ForceFreeStatesControl, equil::Equilibrium.PlasmaEquilibrium, mats::MatrixSplines, intr::ForceFreeStatesInternal;
verbose::Bool=ctrl.verbose)
# Initialization
odet = OdeState(intr.numpert_total, ctrl.numsteps_init, ctrl.numunorms_init, intr.msing)
if ctrl.sing_start <= 0
initialize_el_at_axis!(odet, ctrl, mats, equil.profiles, intr)
elseif ctrl.sing_start <= intr.msing
error("sing_start > 0 not implemented yet!")
# initialize_el_at_singular_surf!(ctrl, equil, intr, odet)
else
error("Invalid value for sing_start: $(ctrl.sing_start) > msing = $(intr.msing)")
end
# Pre-compute all integration chunks
chunks = chunk_el_integration_bounds(odet, ctrl, intr)
# Print initial integration condition
if verbose
@info " ψ = $((@sprintf "%.3f" odet.psifac)), q = $((@sprintf "%.3f" equil.profiles.q_spline(odet.psifac)))"
end
# Iterate through each integration chunk
for chunk in chunks
# Integrate this region and display progress
integrate_el_region!(odet, ctrl, equil, mats, intr, chunk)
if verbose
@info " ψ = $((@sprintf "%.3f" odet.psifac)), q = $((@sprintf "%.3f" odet.q)), steps = $(odet.total_steps)"
end
# Cross a singular surface after integration if this chunk requires it
if chunk.needs_crossing
if ctrl.kinetic_factor > 0
cross_kinetic_singular_surf!(odet, ctrl, equil, mats, intr, chunk.ising)
else
cross_ideal_singular_surf!(odet, ctrl, equil, mats, intr, chunk.ising)
end
end
end
# Deallocate unused storage of integration data.
# `odet.step` was incremented one past the last filled index in integrate_el_region!.
odet.step -= 1
trim_storage!(odet)
# Edge-dW scan over [psiedge, psilim] — populates odet.edge_scan for HDF5 output.
# The scan mutates odet.psifac and odet.u internally; save/restore them around the call.
# findmax_dW_edge! also (re)allocates odet.edge_scan; that field is the diagnostic
# product and is intentionally NOT restored.
#
# Default (ctrl.truncate_at_dW_peak = false): diagnostic-only. Integration domain is
# determined solely by qhigh / psihigh / dmlim so Δ' and δW are independent of peak
# location. Legacy path (true) reproduces the ode_record_edge heuristic from Fortran
# STRIDE — psilim/qlim/u are pulled back to the dW peak. Preserved for experimental
# work; see the ForceFreeStatesControl docstring for the reliability caveats.
if ctrl.psiedge < intr.psilim
saved_psifac, saved_u = odet.psifac, copy(odet.u)
peak_step = findmax_dW_edge!(odet, ctrl, equil, mats, intr)
if ctrl.truncate_at_dW_peak
# Legacy: truncate integration data to dW peak (corrupts Δ' and δW).
odet.step = peak_step
trim_storage!(odet)
intr.psilim = odet.psi_store[end]
intr.qlim = odet.q_store[end]
odet.u .= odet.u_store[:, :, :, end]
if verbose
@info "Truncating integration at peak edge dW (LEGACY — Δ'/δW unreliable): ψ = $((@sprintf "%.3f" odet.psi_store[odet.step])), q = $((@sprintf "%.3f" odet.q_store[odet.step]))"
end
else
odet.psifac = saved_psifac
odet.u .= saved_u
if verbose
@info "Edge-dW peak (diagnostic): ψ = $((@sprintf "%.3f" odet.psi_store[peak_step])), q = $((@sprintf "%.3f" odet.q_store[peak_step])); integration domain unchanged"
end
end
end
# Evaluate stability criterion (critical determinant) of saved solutions
if verbose
@info "Evaluating fixed-boundary stability criterion"
end
odet.nzero = evaluate_stability_criterion!(odet, equil.profiles)
# Undo Gaussian reduction to get true solution vectors
transform_u!(odet, intr)
return (odet, nothing, nothing, nothing)
end
"""
compute_axis_init(mats, profiles, intr, psi_low) -> (U1_init, U2_init)
Compute axis initial conditions for the Euler-Lagrange ODE via the Frobenius
leading-coefficient eigenvalue problem [Glasser Phys. Plasmas 2016 112506 Eq. 51]:
lim_{ψ→0} [ψ M(ψ) − a I] v = 0
For each mode j, solves the 2×2 Frobenius eigenvalue problem for the diagonal block of
A₀ = ψ_low · M(ψ_low). The eigenvector with Re(a) ≥ 0 is the regular (non-singular)
Frobenius solution. Returns U₁_init and U₂_init normalized so that U₂_init = I (consistent
with the N independent solutions convention).
For m≠0 the regular eigenvector has a negligible U₁ component (~ψ_low^(|m|/2)), recovering
the Glasser [0, I] limit as ψ_low → 0. For m=0 (degenerate a≈0), the regular eigenvector
is identified by dominant |U₁| component, giving the physically correct constant-displacement
Frobenius solution and avoiding the spurious logarithmic irregularity.
!!! warning "Not the default"
Measured at `psilow = 0.01` on a diverted DIII-D-like equilibrium (n = 1, m = −14…27) the
returned U₁ diagonal is 0.11…0.23 for m = −14…−3 and +6.1, −9.8, −13.7 for m = 3, 4, 5 —
not the ~ψ_low^(|m|/2) limit stated above — and the downstream signed critical eigenvalue
of W_p⁻¹ then disagrees with Fortran DCON from the first stored step onward (see the
`fixed_axis` docstring in `CoreTypes.jl`). Whether the discrepancy is in the diagonal 2×2
truncation of A₀, in the regular-branch selection, or in the asymptotics claimed here has
not been established. Used only when `fixed_axis = false`.
"""
function compute_axis_init(mats::MatrixSplines, profiles::Equilibrium.ProfileSplines,
intr::ForceFreeStatesInternal, psi_low::Float64)
N = intr.numpert_total
hint = Ref(1)
# Evaluate stability matrices at psi_low
F_lower = zeros(ComplexF64, N, N)
kmat = zeros(ComplexF64, N, N)
gmat = zeros(ComplexF64, N, N)
mats.ideal.F_spline_lower(vec(F_lower), psi_low; hint=hint)
mats.ideal.K_spline(vec(kmat), psi_low; hint=hint)
mats.ideal.G_spline(vec(gmat), psi_low; hint=hint)
# singfac[j] = 1 / (m_j − n_j · q) for each mode j
q0 = profiles.q_spline(psi_low; hint=hint)
singfac = vec(1.0 ./ ((intr.mlow:intr.mhigh) .- q0 .* (intr.nlow:intr.nhigh)'))
# F̄⁻¹ = (F_lower · F_lower')⁻¹ via the Cholesky factor
Finv = Matrix{ComplexF64}(I, N, N)
ldiv!(LowerTriangular(F_lower), Finv)
ldiv!(UpperTriangular(F_lower'), Finv)
U1_init = zeros(ComplexF64, N, N)
U2_init = Matrix{ComplexF64}(I, N, N)
for j in 1:N
sf = singfac[j]
fi = Finv[j, j]
k = kmat[j, j]
kd = conj(k) # K̄†[j,j]
g = gmat[j, j]
# 2×2 ODE matrix block for mode j [Glasser 2016 Eq. 22-24, diagonal approximation]
m11 = -sf * fi * k
m12 = sf^2 * fi
m21 = g - kd * fi * k
m22 = sf * kd * fi
# Frobenius matrix A₀_j = ψ_low · M_j [Glasser 2016 Eq. 51]
#! format: off
F_eig = eigen([psi_low*m11 psi_low*m12;
psi_low*m21 psi_low*m22])
#! format: on
eig_vals = F_eig.values
eig_vecs = F_eig.vectors
# Select the regular eigenvector: larger Re(a) for m≠0.
# For degenerate a≈0 (m=0): prefer dominant |U₁| component (regular = constant solution).
r1 = real(eig_vals[1])
r2 = real(eig_vals[2])
i_reg = if abs(r1 - r2) > Base.sqrt(Base.eps(Float64))
r1 > r2 ? 1 : 2
else
abs(eig_vecs[1, 1]) >= abs(eig_vecs[2, 1]) ? 1 : 2
end
v1, v2 = eig_vecs[1, i_reg], eig_vecs[2, i_reg]
# Normalize so that U₂_init[j,j] = 1. If v₂ ≈ 0 (purely displacement solution),
# set U₁=1, U₂=0 instead.
if abs(v2) > Base.sqrt(Base.eps(Float64)) * abs(v1)
U1_init[j, j] = v1 / v2
else
U1_init[j, j] = one(ComplexF64)
U2_init[j, j] = zero(ComplexF64)
end
end
return U1_init, U2_init
end
"""
initialize_el_at_axis!(odet::OdeState, ctrl::ForceFreeStatesControl, mats::MatrixSplines, profiles::Equilibrium.ProfileSplines, intr::ForceFreeStatesInternal)
Initialize the OdeState struct for the case of sing_start = 0 (axis initialization).
Formerly `ode_axis_init!`. This now only initializes `psifac`, `ising_start`, and `u`.
### TODOs
Move ising_start logic to chunk_el_integration_bounds?
"""
function initialize_el_at_axis!(odet::OdeState, ctrl::ForceFreeStatesControl, mats::MatrixSplines,
profiles::Equilibrium.ProfileSplines, intr::ForceFreeStatesInternal)
# Default psifac to minimum equilibrium psi value
odet.psifac = profiles.xs[1]
# Use Newton iteration to find starting psi if qlow is above q0
if ctrl.qlow > profiles.q_spline.y[1]
# Find last index where q < qlow
idx = findlast(jpsi -> profiles.q_spline.y[jpsi-1] < ctrl.qlow, 2:profiles.npts)
if idx !== nothing
odet.psifac = profiles.xs[idx]
end
odet.psifac = find_zero(
(psi -> profiles.q_spline(psi) - ctrl.qlow,
psi -> profiles.q_deriv(psi)),
odet.psifac, Roots.Newton()
)
end
# Find starting singular surface (where sing.psifac > psi(qlow/q0))
# Note: This logic is kept in initialize_el_at_axis! rather than chunk_el_integration_bounds
# because it depends on the starting psifac which is set here. The logic for sing_start != 0
# and kinetic mode would also live here when implemented.
if ctrl.kinetic_factor > 0
# Use kinetic singular surfaces (kinsing) for crossing points
odet.ising_start = searchsortedfirst(getfield.(intr.kinsing, :psifac), odet.psifac) - 1
else
odet.ising_start = searchsortedfirst(getfield.(intr.sing, :psifac), odet.psifac) - 1
end
if ctrl.fixed_axis
# Default. Glasser initialization: U₁=0, U₂=I [Glasser 2016 §VI] — the DCON axis
# condition (dcon/ode.f, ode_axis_init): ξ^ψ=0 for all modes (fixed magnetic axis).
# This is the condition against which the free-boundary energies are validated;
# see the `fixed_axis` docstring in CoreTypes.jl for why the Frobenius state below
# is not the default.
for ipert in 1:intr.numpert_total
odet.u[ipert, ipert, 2] = 1
end
else
# Opt-in. Frobenius initialization [Glasser 2016 §VI Eq. 51]: selects the regular
# (non-logarithmic) solution for each mode, including the constant-displacement
# solution for the degenerate m=0 case (free magnetic axis). At practical psilow
# the returned U₁ is O(0.1–10), not the documented ψ_low^(|m|/2) limit, and the
# resulting crit(ψ) / W_p disagree with DCON — retained for comparison only.
U1_init, U2_init = compute_axis_init(mats, profiles, intr, odet.psifac)
odet.u[:, :, 1] .= U1_init
odet.u[:, :, 2] .= U2_init
end
end
# TODO: NOT IMPLEMENTED YET! (low priority, just make sure sing_start = 0 in toml)
function initialize_el_at_singular_surf()
return
end
"""
chunk_el_integration_bounds(odet::OdeState, ctrl::ForceFreeStatesControl, intr::ForceFreeStatesInternal)
Pre-compute all integration chunks from the current position to the edge.
Returns a vector of `IntegrationChunk` objects, each representing a region to integrate
and whether it needs a rational surface crossing beforehand.
This function replaces the iterative while-loop logic with a single upfront computation,
making the integration flow more predictable and easier to parallelize (e.g., for STRIDE).
### Arguments
- `odet::OdeState` - ODE state struct (starting position and singular surface index)
- `ctrl::ForceFreeStatesControl` - Control parameters
- `intr::ForceFreeStatesInternal` - Internal data (singular surfaces, limits)
### Returns
- `Vector{IntegrationChunk}` - Array of integration chunks to process
"""
function chunk_el_integration_bounds(odet::OdeState, ctrl::ForceFreeStatesControl, intr::ForceFreeStatesInternal; bidirectional::Bool=false)
chunks = IntegrationChunk[]
# Start from current position
psi_current = odet.psifac
ising_current = odet.ising_start
# Wrapper to find next singular surface to integrate toward that is resonant within integration limits
function find_next_resonant_surface!(ising::Int, intr::ForceFreeStatesInternal)
ising += 1
while ising <= intr.msing
if intr.psilim < intr.sing[ising].psifac ||
any(m -> intr.mlow <= m <= intr.mhigh, intr.sing[ising].m)
break
end
ising += 1
end
return ising
end
# Wrapper to find next kinetic singular surface within integration limits
# Mirrors Fortran ode.f:185-191 filter: skip kinsing surfaces beyond psilim
# or whose resonant mode falls outside the truncation range [mlow, mhigh]
function find_next_kinsing!(ising::Int, intr::ForceFreeStatesInternal)
ising += 1
while ising <= intr.kmsing
if intr.psilim < intr.kinsing[ising].psifac
break
end
# Check resonance: n*q should fall within [mlow, mhigh]
nq = intr.kinsing[ising].q * minimum(intr.kinsing[ising].n)
if intr.mlow <= nq && nq <= intr.mhigh
break
end
ising += 1
end
return ising
end
# -------------------- Create chunks ------------------------
if ctrl.kinetic_factor > 0 && intr.kmsing > 0 && ctrl.singfac_min > 0
# Kinetic mode with kinsing surfaces: chunk around each kinetically-displaced
# singular surface, mirroring Fortran ode.f:184-201 (kin_flag path).
# The ODE's F̄⁻¹ blows up at these locations; the trapezoidal crossing in
# cross_kinetic_singular_surf! steps over each singularity.
ising_current = find_next_kinsing!(ising_current, intr)
while ising_current <= intr.kmsing && intr.psilim >= intr.kinsing[ising_current].psifac && ctrl.singfac_min != 0
# Set integration limit to just before the next kinsing surface
# Fortran: psimax = kinsing(ising)%psifac - singfac_min / |nn * kinsing(ising)%q1|
psi_end = intr.kinsing[ising_current].psifac -
ctrl.singfac_min / abs(minimum(intr.kinsing[ising_current].n) * intr.kinsing[ising_current].q1)
if psi_current >= psi_end
# Surface too close to current position — skip it
ising_current = find_next_kinsing!(ising_current, intr)
continue
end
push!(chunks, IntegrationChunk(;
psi_start=psi_current,
psi_end=psi_end,
needs_crossing=true,
ising=ising_current
))
# After crossing, jump to the other side of the singular surface
dpsi = intr.kinsing[ising_current].psifac - psi_end
psi_current = psi_end + 2 * dpsi
ising_current = find_next_kinsing!(ising_current, intr)
end
# Final chunk to the edge
push!(chunks, IntegrationChunk(;
psi_start=psi_current,
psi_end=(intr.psilim * (1 - eps)),
needs_crossing=false,
ising=0
))
elseif ctrl.kinetic_factor > 0
# Kinetic mode with no kinsing surfaces (or singfac_min==0): single chunk.
# Kinetic contributions are weak enough that F̄ stays well-conditioned.
push!(chunks, IntegrationChunk(;
psi_start=psi_current,
psi_end=(intr.psilim * (1 - eps)),
needs_crossing=false,
ising=0
))
else
# Loop through singular surfaces to cross until edge is reached
ising_current = find_next_resonant_surface!(ising_current, intr)
while ising_current <= intr.msing && intr.psilim >= intr.sing[ising_current].psifac && ctrl.singfac_min != 0
# Set integration limit to just before the next singular surface
psi_end = intr.sing[ising_current].psifac - ctrl.singfac_min /
abs(minimum(intr.sing[ising_current].n) * intr.sing[ising_current].q1)
# Validate chunk bounds
@assert psi_current < psi_end "Invalid chunk bounds: psi_start=$psi_current >= psi_end=$psi_end"
@assert isempty(chunks) || psi_current >= chunks[end].psi_end "Overlapping chunks detected"
push!(chunks, IntegrationChunk(;
psi_start=psi_current,
psi_end=psi_end,
needs_crossing=true,
ising=ising_current,
direction = bidirectional ? -1 : 1
))
# After crossing, we jump to the other side of the singular surface
dpsi = intr.sing[ising_current].psifac - psi_end
psi_current = psi_end + 2 * dpsi
# Move to next singular surface that is either resonant or beyond integration limits
ising_current = find_next_resonant_surface!(ising_current, intr)
end
# No more singular surfaces to cross, set integration limit to edge
@assert psi_current < intr.psilim * (1 - eps) "Final chunk has invalid bounds"
@assert isempty(chunks) || psi_current >= chunks[end].psi_end "Final chunk overlaps with previous chunk"
push!(chunks, IntegrationChunk(;
psi_start=psi_current,
psi_end=(intr.psilim * (1 - eps)),
needs_crossing=false,
ising=0
))
end
return chunks
end
"""
cross_ideal_singular_surf!(odet::OdeState, ctrl::ForceFreeStatesControl, equil::Equilibrium.PlasmaEquilibrium, mats::MatrixSplines, intr::ForceFreeStatesInternal)
Handle the crossing of a rational surface during integration if kinetic mode is disabled.
Formerly `ode_ideal_cross!`. Performs the same function as `ode_ideal_cross` in the Fortran code.
Differences mainly in integration data storage logic, but otherwise identical. It normalizes and
reinitializes the solution vector at the singularity, and updates relevant state variables.
Asymptotics are now computed on-demand here instead of being pre-computed, making it clear
that asymptotic calculations are specific to ideal ForceFreeStates and not inherent to the singular surface.
### Arguments
- `ising::Int` - Index of the singular surface being crossed
"""
function cross_ideal_singular_surf!(
odet::OdeState,
ctrl::ForceFreeStatesControl,
equil::Equilibrium.PlasmaEquilibrium,
mats::MatrixSplines,
intr::ForceFreeStatesInternal,
ising::Int
)
# Fixup solution at singular surface
compute_solution_norms!(odet.u, odet, ctrl, intr, true)
# 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)
# Get asymptotic coefficients before crossing (left side)
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
end
# Re-initialize on opposite side of rational surface by approximating solution
params = (ctrl, equil, mats, intr, odet, IntegrationChunk(0.0, 0.0, false, ising, 1))
du1 = zeros(ComplexF64, intr.numpert_total, intr.numpert_total, 2)
du2 = zeros(ComplexF64, intr.numpert_total, intr.numpert_total, 2)
sing_der!(du1, odet.u, params, odet.psifac)
odet.psifac += 2 * dpsi # jump to other side of singular surface
sing_der!(du2, odet.u, params, odet.psifac)
odet.u .+= (du1 .+ du2) .* dpsi
# 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
end
# Get asymptotic coefficients after crossing rational surface
odet.ca_r[:, :, :, ising] .= sing_get_ca(odet.u, ua, intr)
odet.ca_populated = true
# Δ' is NOT computed for the standard path. The physical Δ' requires the solution
# columns to be in the Riccati gauge (U₂=I), maintained only by Riccati renormalization.
# The standard path's solution columns grow from the axis with an arbitrary complex
# phase; dividing by the outer asymptotic coefficient normalizes magnitude but not phase,
# so the result is in a different convention. The canonical Δ' is the STRIDE BVP matrix
# (compute_delta_prime_matrix!) populated by the parallel FM path.
# Store values after crossing step and advance
odet.q = equil.profiles.q_spline(odet.psifac; hint=odet.spline_hint)
store_ode_data!(odet, odet.psifac, odet.u)
end
"""
cross_kinetic_singular_surf!(odet, ctrl, equil, mats, intr, ising)
Cross a kinetically-displaced singular surface using a simple trapezoidal step.
Matches Fortran `ode_kin_cross` with `con_flag=true` (`ode.f:615-619`): evaluate
the ODE RHS on both sides of the singularity and take a trapezoidal Euler step
across. No asymptotic analysis, no Gaussian elimination — the kinetic FKG
formulation absorbs the ideal singularity and the trapezoidal step handles
the residual near-singularity.
Much simpler than `cross_ideal_singular_surf!` which requires asymptotic
power series and solution vector surgery.
"""
function cross_kinetic_singular_surf!(
odet::OdeState,
ctrl::ForceFreeStatesControl,
equil::Equilibrium.PlasmaEquilibrium,
mats::MatrixSplines,
intr::ForceFreeStatesInternal,
ising::Int
)
# Normalize solution at singular surface [Fortran: ode_unorm(.TRUE.)]
compute_solution_norms!(odet.u, odet, ctrl, intr, true)
# Trapezoidal step across the kinsing surface [Fortran ode.f:616-619, con_flag=true]
ksurf = intr.kinsing[ising]
dpsi = ksurf.psifac - odet.psifac
params = (ctrl, equil, mats, intr, odet, IntegrationChunk(0.0, 0.0, false, ising, 1))
du1 = zeros(ComplexF64, intr.numpert_total, intr.numpert_total, 2)
du2 = zeros(ComplexF64, intr.numpert_total, intr.numpert_total, 2)
sing_der!(du1, odet.u, params, odet.psifac)
odet.psifac = ksurf.psifac + dpsi # symmetric jump to other side
sing_der!(du2, odet.u, params, odet.psifac)
odet.u .+= (du1 .+ du2) .* dpsi
# Store crossing step
odet.q = equil.profiles.q_spline(odet.psifac; hint=odet.spline_hint)
store_ode_data!(odet, odet.psifac, odet.u)
end
"""
integrate_el_region!(odet::OdeState, ctrl::ForceFreeStatesControl, equil::Equilibrium.PlasmaEquilibrium, mats::MatrixSplines, intr::ForceFreeStatesInternal, chunk::IntegrationChunk)
Integrate the Euler-Lagrange equations from `psi_start` to `psi_end`.
Formerly `ode_step!`. Performs the same function as `ode_step` in the Fortran code, with the addition of
a callback function to handle tolerances, normalization, and storage at each
step of the integration. In Fortran, this was performed by running LSODE in one-step
mode (so ode_step was called hundreds of times) and calling the relevant functions in
a DO loop. Here, we use the DifferentialEquations.jl interface to achieve the same
functionality in a more Julian way. The integration bounds are now explicit arguments,
making it clear what region is being integrated.
### Arguments
- `odet::OdeState` - ODE state struct (modified in-place)
- `ctrl::ForceFreeStatesControl` - Control parameters
- `equil::Equilibrium.PlasmaEquilibrium` - Plasma equilibrium
- `mats::MatrixSplines` - Fourier fit variables
- `intr::ForceFreeStatesInternal` - Internal data
- `chunk::IntegrationChunk` - Integration chunk containing start and end ψ for integration
### TODOs
Check sensitivity of results to tolerances, currently using same logic as Fortran
Check absolute tolerances, currently only relative tolerances are updated
"""
function integrate_el_region!(
odet::OdeState,
ctrl::ForceFreeStatesControl,
equil::Equilibrium.PlasmaEquilibrium,
mats::MatrixSplines,
intr::ForceFreeStatesInternal,
chunk::IntegrationChunk
)
# Fraction of the q-range defining "near boundary" dense-save zones at each end of
# a segment. TODO: expose as a ctrl field when a good default is validated.
near_q_frac = 0.05
# q at segment boundaries — used for symmetric near-boundary heuristic.
# odet.q is updated at every step inside sing_der!, so we compare against these
# fixed endpoints in the callback rather than using psi-based distances.
q_start = equil.profiles.q_spline(chunk.psi_start)
q_end = equil.profiles.q_spline(chunk.psi_end)
q_range = abs(q_end - q_start)
steps_in_segment = Ref(0)
function segment_callback!(integrator)
ctrl, _, _, intr, odet, chunk = integrator.p
odet.total_steps += 1
steps_in_segment[] += 1
compute_solution_norms!(integrator.u, odet, ctrl, intr, false)
# Save near segment boundaries (symmetric, in q not psi) and every Nth step.
# The step-count fallback (== 1) guarantees the first step is always saved
# even for near-degenerate segments where q_range ≈ 0.
near_start = abs(odet.q - q_start) < near_q_frac * q_range || steps_in_segment[] == 1
near_end = abs(odet.q - q_end) < near_q_frac * q_range
# Always save in the edge scan region so findmax_dW_edge! has dense q coverage.
in_edge_scan = ctrl.psiedge < intr.psilim && integrator.t >= ctrl.psiedge
if near_start || near_end || (odet.total_steps % ctrl.save_interval == 0) || in_edge_scan
# q at the accepted point, not the last internal Runge-Kutta stage
odet.q = equil.profiles.q_spline(integrator.t; hint=odet.spline_hint)
store_ode_data!(odet, integrator.t, integrator.u)
end
end
cb = DiscreteCallback((u, t, integrator) -> true, segment_callback!)
prob = ODEProblem(sing_der!, odet.u, (chunk.psi_start, chunk.psi_end), (ctrl, equil, mats, intr, odet, chunk))
sol = solve(prob, Vern9(); reltol=ctrl.eulerlagrange_tolerance, callback=cb, save_everystep=false, save_end=true)
# Unconditionally save the final step if the callback did not already capture it.
# Guarantees the pre-crossing (or pre-edge) state is always stored in u_store,
# regardless of where the last accepted step landed relative to the near_end band.
if odet.step == 1 || odet.psi_store[odet.step-1] != sol.t[end]
odet.q = equil.profiles.q_spline(sol.t[end]; hint=odet.spline_hint)
store_ode_data!(odet, sol.t[end], sol.u[end])
end
odet.u .= sol.u[end]
odet.psifac = sol.t[end]
end
"""
compute_solution_norms!(u::Array{ComplexF64,3}, odet::OdeState, ctrl::ForceFreeStatesControl, intr::ForceFreeStatesInternal, sing_flag::Bool)
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!`.