Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions TEST_SUMMARY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Analytical Tokamak Field - Test Summary

## Test Hierarchy

### Unit Tests ✅ COMPLETE

1. **test_analytical_circular** (`test/source/test_analytical_circular.f90`)
- Tests analytical GS field direct evaluation
- Verifies B-field values at various locations
- Validates field with/without TF ripple
- **Status**: PASS

2. **test_analytical_geoflux** (`test/source/test_analytical_geoflux.f90`)
- Tests geoflux initialization with analytical psi evaluator
- Verifies field evaluation through geoflux coordinates
- Tests both axisymmetric and rippled configurations
- **Status**: PASS

3. **test_ripple_field** (`test/source/test_ripple_field.f90`)
- Tests TF coil ripple perturbation (9-coil configuration)
- Validates 9-fold periodicity
- Confirms ~12.65% peak-to-peak variation
- Generates CSV data and plots
- **Status**: PASS

### Integration Tests ✅ COMPLETE

4. **test_analytical_geoflux_integration** (`test/source/test_analytical_geoflux_integration.f90`)
- **Coordinate round-trip**: geoflux ↔ cylindrical transformation consistency
- **Field consistency**: Compares geoflux field vs direct analytical evaluation
- **Flux surface nesting**: Verifies monotonic psi (proper flux surface ordering)
- Tolerance: 1e-3 (numerical interpolation on cached grid)
- **Status**: PASS

## Test Results

```bash
$ cd build && ctest -R analytical
Test #14: test_analytical_circular .............. Passed 0.02 sec
Test #15: test_analytical_geoflux ............... Passed 0.05 sec
Test #16: test_analytical_geoflux_integration ... Passed 0.03 sec

100% tests passed, 0 tests failed out of 3
Total Test time (real) = 0.10 sec
```

## Key Achievement

**Geoflux coordinates are now field-agnostic**:
- Accept `psi_evaluator_i` callback (like VMEC flux coordinates)
- Support both GEQDSK and analytical GS fields
- Enable Meiss/Albert canonical coordinates on analytical equilibria

## Next: SIMPLE System Tests

System-level tests will validate full orbit integration:

1. **Alpha particle confinement** (ITER-size tokamak, no ripple)
- 128 particles, E=3.5 MeV, starting at s=0.3
- Meiss coordinates on geoflux
- Duration: 0.001 s
- **Expected**: Zero particles lost

2. **Ripple-induced transport** (9-coil ripple)
- Same configuration with ripple enabled
- **Expected**: Some particles lost due to ripple perturbation

These will be implemented in SIMPLE repository under `examples/` and `test/`.
168 changes: 162 additions & 6 deletions src/magfie/analytical_tokamak_field.f90
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ module analytical_tokamak_field
!> Provides magnetic field components for circular tokamak equilibria
use, intrinsic :: iso_fortran_env, only: dp => real64
use analytical_gs_solver
use neo_field_base, only: field_t
implicit none

private
public :: analytical_circular_eq_t
public :: compute_analytical_field_cylindrical

!> Analytical tokamak equilibrium (supports arbitrary shaping)
type :: analytical_circular_eq_t
type, extends(field_t) :: analytical_circular_eq_t
real(dp) :: R0 !< Major radius [m]
real(dp) :: epsilon !< Inverse aspect ratio
real(dp) :: kappa !< Elongation
Expand All @@ -23,13 +25,22 @@ module analytical_tokamak_field
real(dp) :: B0 !< Toroidal field on axis [T]
real(dp) :: psimult !< Flux scaling factor
real(dp) :: coeffs(7) !< Boundary condition coefficients
integer :: Nripple = 0 !< Number of TF coils (0 = no ripple)
real(dp) :: a0 !< Minor radius for ripple [m]
real(dp) :: alpha0 !< Ripple radial dependency exponent
real(dp) :: delta0 !< Ripple amplitude
real(dp) :: z0 !< Midplane z coordinate for ripple [m]
logical :: initialized = .false.
contains
procedure :: init => init_circular_equilibrium
procedure :: eval_psi => evaluate_psi
procedure :: eval_psi_derivatives
procedure :: eval_bfield => evaluate_bfield
procedure :: eval_bfield_ripple
procedure :: cleanup => destroy_equilibrium
procedure :: compute_afield
procedure :: compute_bfield
procedure :: compute_abfield
end type analytical_circular_eq_t

contains
Expand All @@ -44,7 +55,13 @@ module analytical_tokamak_field
!> A_in - Shafranov parameter (controls q-profile)
!> B0_in - Toroidal field on axis [T]
!> psimult_in - Flux scaling factor (optional, default=200)
subroutine init_circular_equilibrium(self, R0_in, epsilon_in, kappa_in, delta_in, A_in, B0_in, psimult_in)
!> Nripple_in - Number of TF coils for ripple (optional, 0=no ripple)
!> a0_in - Minor radius for ripple calculation (optional)
!> alpha0_in - Ripple radial dependency exponent (optional, default=2.0)
!> delta0_in - Ripple amplitude (optional, default=0.0)
!> z0_in - Midplane z coordinate for ripple (optional, default=0.0)
subroutine init_circular_equilibrium(self, R0_in, epsilon_in, kappa_in, delta_in, A_in, B0_in, &
psimult_in, Nripple_in, a0_in, alpha0_in, delta0_in, z0_in)
class(analytical_circular_eq_t), intent(inout) :: self
real(dp), intent(in) :: R0_in
real(dp), intent(in) :: epsilon_in
Expand All @@ -53,6 +70,11 @@ subroutine init_circular_equilibrium(self, R0_in, epsilon_in, kappa_in, delta_in
real(dp), intent(in) :: A_in
real(dp), intent(in) :: B0_in
real(dp), intent(in), optional :: psimult_in
integer, intent(in), optional :: Nripple_in
real(dp), intent(in), optional :: a0_in
real(dp), intent(in), optional :: alpha0_in
real(dp), intent(in), optional :: delta0_in
real(dp), intent(in), optional :: z0_in

self%R0 = R0_in
self%epsilon = epsilon_in
Expand All @@ -62,22 +84,51 @@ subroutine init_circular_equilibrium(self, R0_in, epsilon_in, kappa_in, delta_in
if (present(kappa_in)) then
self%kappa = kappa_in
else
self%kappa = 1.0_dp ! Circular
self%kappa = 1.0_dp
end if

if (present(delta_in)) then
self%delta = delta_in
else
self%delta = 0.0_dp ! Circular
self%delta = 0.0_dp
end if

if (present(psimult_in)) then
self%psimult = psimult_in
else
self%psimult = 200.0_dp ! Standard normalization for ITER
self%psimult = 200.0_dp
end if

if (present(Nripple_in)) then
self%Nripple = Nripple_in
else
self%Nripple = 0
end if

if (present(a0_in)) then
self%a0 = a0_in
else
self%a0 = R0_in * epsilon_in
end if

if (present(alpha0_in)) then
self%alpha0 = alpha0_in
else
self%alpha0 = 2.0_dp
end if

if (present(delta0_in)) then
self%delta0 = delta0_in
else
self%delta0 = 0.0_dp
end if

if (present(z0_in)) then
self%z0 = z0_in
else
self%z0 = 0.0_dp
end if

! Solve for coefficients with specified shape
call solve_coefficients(self%epsilon, self%kappa, self%delta, self%A_param, self%coeffs)

self%initialized = .true.
Expand Down Expand Up @@ -235,10 +286,115 @@ subroutine evaluate_bfield(self, R, Z, B_R, B_Z, B_phi, B_mod)
B_mod = sqrt(B_R**2 + B_Z**2 + B_phi**2)
end subroutine evaluate_bfield

!> Evaluate magnetic field components with TF ripple
!>
!> Parameters:
!> R - Major radius coordinate [m]
!> phi - Toroidal angle [rad]
!> Z - Vertical coordinate [m]
!>
!> Returns:
!> B_R - Radial field component [T]
!> B_Z - Vertical field component [T]
!> B_phi - Toroidal field component with ripple [T]
!> B_mod - Total field magnitude [T]
subroutine eval_bfield_ripple(self, R, phi, Z, B_R, B_Z, B_phi, B_mod)
class(analytical_circular_eq_t), intent(in) :: self
real(dp), intent(in) :: R, phi, Z
real(dp), intent(out) :: B_R, B_Z, B_phi, B_mod

real(dp) :: dpsi_dR, dpsi_dZ
real(dp) :: F_psi
real(dp) :: radius, theta, delta_ripple

if (.not. self%initialized) then
error stop "analytical_circular_eq_t not initialized"
end if

call self%eval_psi_derivatives(R, Z, dpsi_dR, dpsi_dZ)

B_R = -(1.0_dp / R) * dpsi_dZ
B_Z = (1.0_dp / R) * dpsi_dR

F_psi = self%B0 * self%R0
B_phi = F_psi / R

if (self%Nripple > 0) then
radius = sqrt((R - self%R0)**2 + (Z - self%z0)**2)
theta = atan2(Z - self%z0, R - self%R0)
delta_ripple = self%delta0 * exp(-0.5_dp * theta**2) &
* (radius / self%a0)**self%alpha0
B_phi = B_phi * (1.0_dp + delta_ripple * cos(real(self%Nripple, dp) * phi))
end if

B_mod = sqrt(B_R**2 + B_Z**2 + B_phi**2)
end subroutine eval_bfield_ripple

!> Cleanup
subroutine destroy_equilibrium(self)
class(analytical_circular_eq_t), intent(inout) :: self
self%initialized = .false.
end subroutine destroy_equilibrium

!> Standalone interface function for external use (e.g., SIMPLE)
!>
!> Evaluates magnetic field in cylindrical coordinates with optional ripple.
!> This function provides a simple interface matching the pattern used by
!> other libneo field modules (e.g., neo_biotsavart).
!>
!> Parameters:
!> eq - Initialized analytical equilibrium
!> R - Major radius [m]
!> phi - Toroidal angle [rad]
!> Z - Vertical coordinate [m]
!>
!> Returns:
!> B_R - Radial field component [T]
!> B_Z - Vertical field component [T]
!> B_phi - Toroidal field component (with ripple if enabled) [T]
!> B_mod - Total field magnitude [T]
subroutine compute_analytical_field_cylindrical(eq, R, phi, Z, B_R, B_Z, B_phi, B_mod)
type(analytical_circular_eq_t), intent(in) :: eq
real(dp), intent(in) :: R, phi, Z
real(dp), intent(out) :: B_R, B_Z, B_phi, B_mod

call eq%eval_bfield_ripple(R, phi, Z, B_R, B_Z, B_phi, B_mod)
end subroutine compute_analytical_field_cylindrical

!> field_t interface: compute vector potential
!> x(3) = (R, phi, Z) in cylindrical coordinates
subroutine compute_afield(self, x, A)
class(analytical_circular_eq_t), intent(in) :: self
real(dp), intent(in) :: x(3)
real(dp), intent(out) :: A(3)

error stop 'analytical_circular_eq_t: compute_afield not implemented'
end subroutine compute_afield

!> field_t interface: compute magnetic field
!> x(3) = (R, phi, Z) in cylindrical coordinates
subroutine compute_bfield(self, x, B)
class(analytical_circular_eq_t), intent(in) :: self
real(dp), intent(in) :: x(3)
real(dp), intent(out) :: B(3)

real(dp) :: B_R, B_Z, B_phi, B_mod

call self%eval_bfield_ripple(x(1), x(2), x(3), B_R, B_Z, B_phi, B_mod)

B(1) = B_R
B(2) = B_phi
B(3) = B_Z
end subroutine compute_bfield

!> field_t interface: compute vector potential and field
subroutine compute_abfield(self, x, A, B)
class(analytical_circular_eq_t), intent(in) :: self
real(dp), intent(in) :: x(3)
real(dp), intent(out) :: A(3), B(3)

call self%compute_afield(x, A)
call self%compute_bfield(x, B)
end subroutine compute_abfield

end module analytical_tokamak_field
17 changes: 17 additions & 0 deletions test/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,9 @@ target_link_libraries(test_chartmap_validator.x neo)
add_executable(test_analytical_circular.x source/test_analytical_circular.f90)
target_link_libraries(test_analytical_circular.x ${MAGFIE_LIB} ${COMMON_LIBS})

add_executable(test_ripple_field.x source/test_ripple_field.f90)
target_link_libraries(test_ripple_field.x ${MAGFIE_LIB} ${COMMON_LIBS})

add_executable(test_nctools_nc_get3.x source/test_nctools_nc_get3.f90)
target_link_libraries(test_nctools_nc_get3.x
${COMMON_LIBS}
Expand Down Expand Up @@ -238,6 +241,20 @@ set_tests_properties(test_geqdsk_tools PROPERTIES
# test_ascot5_compare is run separately in CI workflow (not in ctest)
# because a5py requires libascot.so at import time, which the test builds

add_test(
NAME test_ripple_field_data
COMMAND test_ripple_field.x
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}/test
)

add_test(
NAME test_ripple_field_plot
COMMAND ${Python_EXECUTABLE} ${CMAKE_SOURCE_DIR}/test/scripts/plot_ripple_comparison.py
--data-dir ${CMAKE_BINARY_DIR}/test
--output-dir ${CMAKE_BINARY_DIR}/test
)
set_tests_properties(test_ripple_field_plot PROPERTIES DEPENDS test_ripple_field_data)

if(LIBNEO_TESTING_ENABLED)
add_test(
NAME test_geoflux
Expand Down
Loading