From 417f2c1ba518c94ee7b5f3ffbb32a544db917b71 Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Tue, 30 Sep 2025 15:06:58 +0200 Subject: [PATCH 01/19] docs: Add GEQDSK/geoflux implementation plan with 3D field support - Add comprehensive TODO for tokamak GEQDSK support using geoflux coordinates - Reference Simpson rule implementation in KAMEL fix-integration branch - Document 3D field superposition via field_divB0 module - Note that 3D perturbations affect field but not reference coordinates --- TODO.md | 918 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 918 insertions(+) create mode 100644 TODO.md diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..6a092676 --- /dev/null +++ b/TODO.md @@ -0,0 +1,918 @@ +# TODO: GEQDSK Support with Geoflux Reference Coordinates + +## Overview + +Add tokamak field support from GEQDSK files using new "geoflux" reference coordinates: +- **Radial**: s_tor = ψ_tor/ψ_tor,edge (normalized toroidal flux computed from ψ_pol and q-profile) +- **Poloidal**: θ_geo = atan2(Z - Z_axis, R - R_axis) (geometric angle from magnetic axis) +- **Toroidal**: φ = φ_cyl (cylindrical toroidal angle) + +**Architecture**: Follow VMEC pattern with clean separation between libneo (reference coordinates) and SIMPLE (canonical coordinates). + +**Key Simplification**: Reuse libneo's existing `field_divB0` module for all GEQDSK I/O and field evaluation. We only need to add: +1. Coordinate transformations: (s_tor, θ_geo, φ) ↔ (R, φ, Z) +2. Toroidal flux computation: s_tor(ψ_pol) from q-profile integration +3. Flux surface cache: Pre-compute (R, Z) on (s_tor, θ_geo) grid for performance + +This significantly reduces implementation effort compared to reimplementing GEQDSK handling from scratch. + +--- + +## Phase 1: libneo - Geoflux Reference Coordinate System + +### 1.1 Create Geoflux Coordinate Module in libneo +**File**: `../libneo/src/coordinates/geoflux_coordinates.f90` + +**Purpose**: Provide coordinate transformations between geoflux (s_tor, θ_geo, φ) and cylindrical (R, φ, Z), similar to `vmec_coordinates.f90`. + +**Implementation details**: + +```fortran +module geoflux_coordinates + use iso_c_binding + implicit none + + ! Geoflux coordinate data structure + type :: geoflux_t + ! GEQDSK data + type(geqdsk_t) :: geqdsk + + ! Magnetic axis location + real(8) :: R_axis, Z_axis + + ! Flux conversion data + real(8) :: psi_pol_axis, psi_pol_edge + real(8), allocatable :: s_tor_of_psi_pol(:) ! 1D spline for s_tor(psi_pol) + real(8), allocatable :: psi_pol_of_s_tor(:) ! 1D spline for inverse + + ! Flux surface geometry cache (optional, for performance) + integer :: ns_cache, ntheta_cache + real(8), allocatable :: R_cache(:,:) ! R(s_tor, theta_geo) + real(8), allocatable :: Z_cache(:,:) ! Z(s_tor, theta_geo) + logical :: cache_built + end type geoflux_t + +contains + + ! Initialize from GEQDSK file + subroutine init_geoflux(self, geqdsk_file) + + ! Compute toroidal flux from poloidal flux using q-profile + ! Integral: s_tor = integral_0^psi_pol q(psi') dpsi' / (2*pi*psi_tor_edge) + subroutine compute_toroidal_flux_mapping(self) + + ! Build flux surface geometry cache on (s_tor, theta_geo) grid + subroutine build_flux_surface_cache(self, ns, ntheta) + + ! Core transformations (similar to vmec_coordinates) + subroutine geoflux_to_cyl(self, s_tor, theta_geo, phi, R, Z, & + dRds, dRdtheta, dZds, dZdtheta) + + subroutine cyl_to_geoflux(self, R, phi, Z, s_tor, theta_geo, & + dsdr, dsdz, dthetadr, dthetadz) + + ! Get field components in geoflux coordinates + subroutine get_field_geoflux(self, s_tor, theta_geo, phi, & + B_s, B_theta, B_phi, Bmod) + +end module geoflux_coordinates +``` + +**Key algorithms**: + +1. **Toroidal flux computation** (using q-profile from GEQDSK): + ``` + Note: read_eqfile1 already reads qpsi(i) array (line 418 in field_divB0.f90)! + + For each psi_pol from axis to edge: + s_tor(psi_pol) = (1/psi_tor_edge) * integral[q(psi') * dpsi', psi'=0..psi_pol] + where psi_tor_edge can be computed from: + psi_tor_edge = integral[q(psi) * dpsi, psi=axis..edge] + + Use trapezoidal rule or Simpson's rule on qpsi array from GEQDSK. + + Reference implementation: Simpson rule in branch `fix-integration` of + itpplasma/KAMEL repo (private, github.com/itpplasma/KAMEL). + Plan: Extract to utility module in libneo, then update KAMEL to use as library routine. + ``` + +2. **Flux surface tracing** (for given s_tor, theta_geo → R, Z): + - Convert s_tor → psi_pol using spline + - Starting from geometric ray: R₀ = R_axis + r₀*cos(θ_geo), Z₀ = Z_axis + r₀*sin(θ_geo) + - Newton iteration to find (R, Z) where: + - ψ_interp(R, Z) = psi_pol (on flux surface) + - atan2(Z - Z_axis, R - R_axis) = theta_geo (correct angle) + - Cache results on grid for performance + +3. **Inverse transformation** (R, Z → s_tor, theta_geo): + - Interpolate ψ_pol(R, Z) from GEQDSK grid + - Compute s_tor from psi_pol using spline + - Compute theta_geo = atan2(Z - Z_axis, R - R_axis) + +**Dependencies**: +- `use geqdsk_tools, only: geqdsk_t, geqdsk_read, geqdsk_standardise` +- `use interpolate, only: ...` (for spline construction/evaluation) +- `use binsrc_sub, only: ...` (for table lookup) + +**Files to reference**: +- `../libneo/src/coordinates/vmec_coordinates.f90` (template structure) +- `../libneo/src/magfie/geqdsk_tools.f90` (GEQDSK I/O) +- `../libneo/src/magfie/field_divB0.f90` (field evaluation from GEQDSK) + +--- + +### 1.2 Create Geoflux Spline Data Module +**File**: `../libneo/src/magfie/geoflux_field.f90` + +**Purpose**: Provide spline-based field evaluation in geoflux coordinates, analogous to `splint_vmec_data` for VMEC. + +**Key insight**: **Reuse existing `field_divB0` module!** It already has: +- `read_eqfile1`: Reads GEQDSK format (lines 377-443 in `field_divB0.f90`) +- `field_eq`: Evaluates field in cylindrical (R,φ,Z) with derivatives (lines 127-274) +- 2D bicubic spline interpolation on ψ(R,Z) +- F_pol(ψ) handling for toroidal field + +```fortran +module geoflux_field + use geoflux_coordinates + use field_sub, only: field_eq ! Reuse existing field evaluation! + implicit none + + ! Global geoflux data instance (similar to VMEC pattern) + type(geoflux_t), save :: geoflux_data + +contains + + ! Initialize geoflux data (analogous to spline_vmec_data) + subroutine spline_geoflux_data(geqdsk_file, ns, ntheta) + character(len=*), intent(in) :: geqdsk_file + integer, intent(in) :: ns, ntheta + + ! Read GEQDSK using existing field_divB0 infrastructure + ! This initializes the module-level splines automatically + call read_eqfile1(...) ! from field_divB0 + + ! Find magnetic axis from GEQDSK data + call find_magnetic_axis(geoflux_data) + + ! Compute s_tor from psi_pol using q-profile + call compute_toroidal_flux_mapping(geoflux_data) + + ! Build flux surface cache + call build_flux_surface_cache(geoflux_data, ns, ntheta) + end subroutine + + ! Evaluate field in geoflux coordinates (analogous to splint_can_sub) + subroutine splint_geoflux_field(s_tor, theta_geo, phi, & + Acov, hcov, Bmod, sqgBctr) + real(8), intent(in) :: s_tor, theta_geo, phi + real(8), intent(out) :: Acov(3), hcov(3), Bmod, sqgBctr + real(8) :: R, Z, B_R, B_phi, B_Z + real(8) :: dBrdR, dBrdp, dBrdZ, dBpdR, dBpdp, dBpdZ, dBzdR, dBzdp, dBzdZ + + ! Transform to cylindrical using flux surface cache + call geoflux_to_cyl(geoflux_data, s_tor, theta_geo, phi, R, Z, & + dRds, dRdtheta, dZds, dZdtheta) + + ! Call existing field_eq from field_divB0 module + call field_eq(R, phi, Z, B_R, B_phi, B_Z, & + dBrdR, dBrdp, dBrdZ, dBpdR, dBpdp, dBpdZ, dBzdR, dBzdp, dBzdZ) + + ! Transform B from cylindrical to geoflux covariant components + ! Using Jacobian ∂(R,Z)/∂(s_tor,θ_geo) + ! Compute vector potential A and normalized field h + ! Compute Jacobian sqrt(g) and metric tensor + + end subroutine + +end module geoflux_field +``` + +**Key implementation notes**: +- **Reuse `field_divB0.f90`** for all GEQDSK I/O and field evaluation in cylindrical +- Only add coordinate transformation layer: (s_tor, θ_geo, φ) ↔ (R, φ, Z) +- `field_eq` already handles: 2D splines, F_pol(ψ), derivatives +- Toroidal symmetry ⟹ no φ-dependence (simplifies to 2D problem) +- Compute Jacobian: √g = R * |∂(R,Z)/∂(s_tor,θ_geo)| + +**3D field superposition support**: +- The `field_divB0` module supports adding 3D perturbations on top of the axisymmetric equilibrium +- 3D fields (e.g., RMPs, error fields, test coils) affect the magnetic field evaluation +- Reference coordinates (s_tor, θ_geo, φ) remain based on the axisymmetric equilibrium +- This allows studying non-axisymmetric effects without losing the clean flux coordinate system + +**Trade-offs on GEQDSK infrastructure**: + +Option A: **Use `field_divB0` entirely** (RECOMMENDED) +- ✅ Battle-tested field evaluation with derivatives +- ✅ 2D bicubic splines already implemented +- ✅ Handles F_pol(ψ) correctly +- ⚠️ No COCOS standardization (assumes specific convention) +- ⚠️ Module-level variables (but threadprivate for OpenMP) + +Option B: Use `geqdsk_tools` for I/O + implement field evaluation +- ✅ Modern interface with COCOS awareness +- ✅ Clean data structures (geqdsk_t type) +- ❌ Need to implement field evaluation from scratch +- ❌ More work, potential for bugs + +**Decision**: Use `field_divB0` for field evaluation. Optionally wrap reading in `geqdsk_tools` for COCOS standardization, then pass data to `field_divB0` structures. Can refactor later if needed. + +--- + +### 1.3 Update libneo Build System +**Files to modify**: +- `../libneo/CMakeLists.txt`: Add new source files +- `../libneo/fpm.toml`: Add to source list (if using fpm) + +**Changes**: +```cmake +# Add to libneo sources +set(LIBNEO_SOURCES + ... + src/coordinates/geoflux_coordinates.f90 + src/magfie/geoflux_field.f90 + ... +) +``` + +--- + +### 1.4 Create libneo Tests for Geoflux +**File**: `../libneo/test/source/test_geoflux.f90` + +**Test cases**: +1. **Load GEQDSK**: Read and standardize GEQDSK file +2. **Toroidal flux mapping**: Verify s_tor computation from q-profile +3. **Coordinate transformations**: + - Round-trip: (s_tor, θ, φ) → (R, φ, Z) → (s_tor, θ, φ) + - Check Jacobians and metric tensors +4. **Flux surface accuracy**: Verify ψ(R(s,θ), Z(s,θ)) = ψ(s) to tolerance +5. **Field evaluation**: Check ∇·B = 0, compare with known tokamak solutions + +**Test data**: Use `../libneo/python/tests/test.geqdsk` (MAST equilibrium) + +--- + +## Phase 2: SIMPLE - GEQDSK Field Class (Boozer-like representation) + +### 2.1 Create GEQDSK Field Type +**File**: `src/field/field_geqdsk.f90` + +**Purpose**: Extend `MagneticField` base class to handle GEQDSK files with geoflux reference coordinates. + +```fortran +module field_geqdsk + use field_base, only: MagneticField + use geoflux_field, only: spline_geoflux_data, splint_geoflux_field + implicit none + + type, extends(MagneticField) :: GeqdskField + character(len=256) :: filename + contains + procedure :: init => init_geqdsk + procedure :: evaluate => evaluate_geqdsk + end type GeqdskField + +contains + + subroutine init_geqdsk(self, filename) + class(GeqdskField), intent(inout) :: self + character(len=*), intent(in) :: filename + integer :: ns, ntheta + + self%filename = filename + + ! Use reasonable grid resolution (adjust as needed) + ns = 128 + ntheta = 256 + + ! Initialize libneo geoflux data + call spline_geoflux_data(filename, ns, ntheta) + end subroutine + + subroutine evaluate_geqdsk(self, x, Acov, hcov, Bmod, sqgBctr) + class(GeqdskField), intent(in) :: self + real(8), intent(in) :: x(3) ! (r=√s_tor, theta_geo, phi) + real(8), intent(out) :: Acov(3), hcov(3), Bmod, sqgBctr + real(8) :: s_tor + + ! Convert r to s_tor (SIMPLE convention: x(1) = √s) + s_tor = x(1)**2 + + ! Call libneo evaluation + call splint_geoflux_field(s_tor, x(2), x(3), Acov, hcov, Bmod, sqgBctr) + end subroutine + +end module field_geqdsk +``` + +**Note**: This provides geoflux coordinates as reference system, similar to how `field_vmec.f90` provides VMEC coordinates as reference. + +--- + +### 2.2 Update Field Dispatcher +**File**: `src/field.F90` + +**Modify**: `field_from_file` subroutine (around line 15-44) + +**Add**: +```fortran +use field_geqdsk, only: GeqdskField + +! In field_from_file routine: +else if (endswith(filename, '.geqdsk') .or. endswith(filename, '.eqdsk')) then + allocate(GeqdskField :: field) + call field%init(filename) +``` + +--- + +### 2.3 Update Coordinate Module +**File**: `src/coordinates/coordinates.f90` + +**Add**: Geoflux ↔ Cylindrical ↔ Cartesian transformations + +```fortran +! Add to get_transform function +case('geoflux') + select case(trim(to)) + case('cyl') + get_transform => geoflux_to_cyl_wrapper + case('cart') + get_transform => geoflux_to_cart_wrapper + end select +case('cyl') + if (trim(to) == 'geoflux') then + get_transform => cyl_to_geoflux_wrapper + end if +``` + +**Implementation**: Wrappers call libneo's `geoflux_coordinates` module. + +--- + +## Phase 3: SIMPLE - Canonical Coordinates on Geoflux + +### 3.1 Meiss Canonical Coordinates for Geoflux +**File**: `src/field/field_can_meiss_geoflux.f90` + +**Purpose**: Implement symmetry-flux canonical coordinates using geoflux as reference (instead of VMEC). + +**Strategy**: +1. Copy `src/field/field_can_meiss.f90` as template +2. Replace all VMEC-specific calls with geoflux equivalents: + - `vmec_to_cyl` → `geoflux_to_cyl` + - VMEC spline evaluations → geoflux spline evaluations +3. Simplify for axisymmetry: + - No φ-grid needed (∂/∂φ = 0 for equilibrium) + - Can use 2D batch splines instead of 3D (save memory) + - ODE integration only in (s_tor, θ_geo) plane + +**Key changes from VMEC-based Meiss**: +```fortran +module field_can_meiss_geoflux + use field_base + use geoflux_field + use interpolate ! For batch splines + + type, extends(MagneticField) :: MeissGeofluxField + ! Reference field (geoflux) + character(len=256) :: geqdsk_file + + ! Canonical transformation batch splines (2D in s_tor, theta_geo) + ! Axisymmetry ⟹ no phi dependence for equilibrium quantities + type(batch_spline_2d_t) :: spline_transformation ! [lambda_phi, chi_gauge] + type(batch_spline_2d_t) :: spline_field ! [A_*, h_*, Bmod, sqgBctr] + contains + procedure :: init => init_meiss_geoflux + procedure :: evaluate => evaluate_meiss_geoflux + end type + +contains + + subroutine init_meiss_geoflux(self, geqdsk_file, ns, ntheta) + ! 1. Initialize geoflux reference field + call spline_geoflux_data(geqdsk_file, ns, ntheta) + + ! 2. Integrate ODEs for canonical transformation (simplified for axisymmetry) + ! Grid: 2D (s_tor, theta_geo), no phi needed + ! ODE: dlambda_phi/ds = -h_s / h_phi, dchi/ds = A_s + A_phi * dlambda/ds + + ! 3. Build 2D batch splines for transformation and field + end subroutine + +end module +``` + +**Testing**: Verify orbits match between Meiss-geoflux and Meiss-VMEC for same tokamak equilibrium (if VMEC file available). + +--- + +### 3.2 Albert Canonical Coordinates for Geoflux +**File**: `src/field/field_can_albert_geoflux.f90` + +**Purpose**: Implement poloidal-flux canonical coordinates on top of Meiss-geoflux (replacing s_tor with ψ_pol). + +**Strategy**: +1. Copy `src/field/field_can_albert.f90` as template +2. Replace VMEC references with geoflux +3. Use existing `psi_transform` module to switch from s_tor to ψ_pol +4. This is the **primary coordinate system for GEQDSK**: matches natural poloidal-flux representation + +**Key changes**: +```fortran +module field_can_albert_geoflux + use field_can_meiss_geoflux + use psi_transform + + type, extends(MeissGeofluxField) :: AlbertGeofluxField + ! Add PSI transformation on top of Meiss + type(psi_grid_t) :: psi_grid + contains + procedure :: init => init_albert_geoflux + end type + +contains + + subroutine init_albert_geoflux(self, geqdsk_file, ns, ntheta) + ! 1. Initialize Meiss-geoflux base + call self%MeissGeofluxField%init(geqdsk_file, ns, ntheta) + + ! 2. Apply PSI transformation: s_tor → psi_pol + ! This makes radial coordinate proportional to poloidal flux + call init_psi_transform(self%psi_grid, ...) + + ! 3. Rebuild splines with new radial coordinate + call self%rebuild_splines_with_psi() + end subroutine + +end module +``` + +**Note**: This matches the "geoflux" name - Albert coordinates naturally use geometric flux (ψ_pol) as radial label. + +--- + +### 3.3 Update Field Initialization in Main Code +**File**: `src/simple.f90` + +**Modify**: Initialization section (around lines 47-75) + +**Add**: +```fortran +! Detect field type from filename extension +if (endswith(equil_file, '.nc')) then + ! VMEC file - existing logic + call spline_vmec_data(...) + ! Initialize VMEC-based canonical fields + +else if (endswith(equil_file, '.geqdsk') .or. endswith(equil_file, '.eqdsk')) then + ! GEQDSK file - new logic + select case(trim(field_type)) + case('geoflux', 'boozer') + ! Direct geoflux reference coordinates + allocate(GeqdskField :: mag_field) + + case('meiss') + ! Meiss canonical on geoflux reference + allocate(MeissGeofluxField :: mag_field) + + case('albert') + ! Albert canonical on geoflux reference + allocate(AlbertGeofluxField :: mag_field) + + case default + print *, 'For GEQDSK files, field_type must be: geoflux, meiss, or albert' + stop + end select + +else + print *, 'Unknown equilibrium file format' + stop +end if +``` + +--- + +## Phase 4: Testing and Validation + +### 4.1 Unit Tests + +**Files to create**: + +1. **libneo tests** (`../libneo/test/source/test_geoflux.f90`): + - Load MAST GEQDSK: `../libneo/python/tests/test.geqdsk` + - Test coordinate transformations + - Verify field evaluation (∇·B = 0) + - Check flux surface mapping accuracy + +2. **SIMPLE integration test** (`test/fortran/test_geqdsk_field.f90`): + - Load GEQDSK field + - Initialize particle on flux surface + - Trace single orbit + - Verify energy conservation + +3. **Canonical coordinate test** (`test/fortran/test_can_geoflux.f90`): + - Compare Meiss-geoflux vs Meiss-VMEC (if VMEC available for same tokamak) + - Verify Hamiltonian conservation + - Check symplecticity of transformations + +--- + +### 4.2 Integration Tests + +**Test cases** (use `examples/` directory): + +1. **Simple tokamak orbit**: + - Input: `examples/simple_geqdsk.in` (new file) + - GEQDSK: Use MAST equilibrium from libneo + - Field type: `geoflux` (reference coordinates) + - Trace 100 particles, verify confinement statistics + +2. **Meiss canonical tokamak**: + - Field type: `meiss` + - Compare with `geoflux` results (should be equivalent modulo gauge) + +3. **Albert canonical tokamak**: + - Field type: `albert` + - Test with various tokamak profiles (circular, shaped, etc.) + +--- + +### 4.3 Comparison with Existing Codes + +**Validation strategy**: +1. Generate GEQDSK from known VMEC tokamak equilibrium (if converter available) +2. Trace identical particle sets with both: + - SIMPLE + VMEC file (existing) + - SIMPLE + GEQDSK file (new) +3. Compare: + - Orbit trajectories (should match to tolerance) + - Confinement times + - Poincaré sections + +**Alternative**: Compare with established tokamak orbit codes (e.g., ORBIT, VENUS, etc.) using same GEQDSK. + +--- + +### 4.4 Test Data Organization + +**Location**: `test/data/geqdsk/` + +**Files to include**: +- `test.geqdsk` (symlink to `../libneo/python/tests/test.geqdsk`) +- Additional GEQDSK files from literature or databases: + - ITER equilibrium + - NSTX/MAST (spherical tokamak) + - DIII-D (shaped tokamak) + - Circular tokamak (for analytical validation) + +--- + +## Phase 5: Documentation and Examples + +### 5.1 Update Documentation + +**Files to modify**: + +1. **README.md**: Add GEQDSK support to features list +2. **DESIGN.md**: Document geoflux coordinate system architecture +3. **examples/README.md**: Add GEQDSK usage examples + +--- + +### 5.2 Example Input Files + +**Create**: `examples/simple_geqdsk.in` + +```fortran +&simple_config + equil_file = 'test.geqdsk' + field_type = 'albert' ! Options: geoflux, meiss, albert + + ! Particle initialization + nparticles = 1000 + sample_mode = 'surface' ! Start on flux surface + s_sample = 0.5 + + ! Integration parameters + integrator = 'lobatto4' + dt = 1.0d-2 + nsteps = 100000 + + ! Output + output_step = 100 + write_orbits = .true. +/ +``` + +--- + +### 5.3 Usage Examples + +**Create**: `examples/example_geqdsk.f90` + +Minimal example demonstrating: +1. Load GEQDSK file +2. Initialize geoflux field +3. Trace single particle orbit +4. Output to file + +--- + +## Phase 6: Performance Optimization + +### 6.1 Flux Surface Cache Optimization + +**Approach**: Pre-compute (R, Z) on dense (s_tor, theta_geo) grid + +**Trade-offs**: +- Memory: ~O(ns × ntheta × 2) doubles (e.g., 256×512×2 = 2.6 MB, negligible) +- Speed: Replace Newton iteration with 2D spline interpolation +- Accuracy: Control via grid resolution + +**Implementation**: Already included in `geoflux_t%R_cache`, `geoflux_t%Z_cache`. + +--- + +### 6.2 Profile Spline Optimization + +**Approach**: Pre-spline all 1D profiles (fpol, q, etc.) during initialization + +**Benefit**: Avoid repeated spline construction during orbit integration + +--- + +### 6.3 Axisymmetry Exploitation + +**Simplifications** for canonical coordinates: +- 2D batch splines instead of 3D (factor of ~nφ memory reduction) +- Skip φ-derivatives in ODE integration +- Simplified Jacobian calculations + +**Expected speedup**: ~2-3× for canonical coordinate initialization compared to 3D stellarator case. + +--- + +## Implementation Priority and Timeline + +### Minimal Working Implementation (Priority 1) +**Goal**: Trace particles in GEQDSK file with geoflux reference coordinates + +**Tasks**: +- [ ] Phase 1.1: `geoflux_coordinates.f90` in libneo (coordinate transformations) +- [ ] Phase 1.2: `geoflux_field.f90` in libneo (thin wrapper around `field_divB0`) +- [ ] Phase 2.1: `field_geqdsk.f90` in SIMPLE +- [ ] Phase 2.2: Update field dispatcher +- [ ] Phase 4.1: Basic unit tests + +**Estimated effort**: 1-2 weeks (reduced from 2-3 weeks due to reusing `field_divB0`) + +--- + +### Canonical Coordinates (Priority 2) +**Goal**: Support Meiss and Albert canonical coordinates for tokamaks + +**Tasks**: +- [ ] Phase 3.1: `field_can_meiss_geoflux.f90` +- [ ] Phase 3.2: `field_can_albert_geoflux.f90` +- [ ] Phase 3.3: Update `simple.f90` initialization +- [ ] Phase 4.2-4.3: Integration and validation tests + +**Estimated effort**: 2-3 weeks (after Priority 1 complete) + +--- + +### Polish and Documentation (Priority 3) +**Goal**: Production-ready feature + +**Tasks**: +- [ ] Phase 4.4: Comprehensive test suite +- [ ] Phase 5: Documentation and examples +- [ ] Phase 6: Performance optimization + +**Estimated effort**: 1-2 weeks + +--- + +## Technical Challenges and Mitigation + +### Challenge 1: Toroidal Flux Computation +**Issue**: Need to integrate q(ψ_pol) from GEQDSK to get ψ_tor(ψ_pol) + +**Solution**: +- Use trapezoidal or Simpson's rule on q-profile +- Verify: ι = 1/q should match rotational transform +- Test with known analytical profiles (e.g., circular tokamak) + +--- + +### Challenge 2: Magnetic Axis Location +**Issue**: Need magnetic axis location (R_axis, Z_axis) for geometric angle θ_geo + +**Solution**: +- **Already provided by GEQDSK!** `read_eqfile1` returns `rmaxis, zmaxis` (line 407) +- Validate: ∇ψ should vanish at axis +- Alternative: Search for minimum on ψ grid if needed: `minloc(geqdsk%psirz)` + +--- + +### Challenge 3: Flux Surface Singular Behavior +**Issue**: At magnetic axis, θ_geo becomes ill-defined (polar singularity) + +**Solution**: +- Exclude innermost surfaces: Start grid at s_tor_min = 0.01 (not 0) +- Use L'Hôpital's rule or Taylor expansion for near-axis derivatives +- Alternative: Use Cartesian representation near axis + +--- + +### Challenge 4: Separatrix and SOL +**Issue**: Particles may reach separatrix (ψ = ψ_edge) or beyond + +**Solution**: +- Detect when s_tor > 1.0 (outside separatrix) +- Terminate orbit or extend field to SOL with simple model +- Document limitation in user guide + +--- + +### Challenge 5: COCOS Convention Complications +**Issue**: Different GEQDSK files may use different sign conventions + +**Solution**: +- **Already handled by libneo**: `geqdsk_standardise` converts to COCOS 3 +- Always call standardization after reading +- Include COCOS info in log output for debugging + +--- + +## Testing Strategy + +### Unit Tests (libneo) +- [x] GEQDSK read and standardize +- [ ] Toroidal flux computation from q-profile +- [ ] Flux surface tracing accuracy +- [ ] Coordinate transformation round-trips +- [ ] Field evaluation: verify ∇·B = 0 + +### Integration Tests (SIMPLE) +- [ ] Load GEQDSK and initialize field +- [ ] Single particle orbit (energy conservation) +- [ ] Multiple particles (statistics) +- [ ] Compare geoflux vs Meiss vs Albert + +### Validation Tests +- [ ] Compare with VMEC-based results (if available) +- [ ] Compare with other tokamak codes +- [ ] Analytical test cases (circular tokamak) + +--- + +## Dependencies and Prerequisites + +### External Libraries (already available) +- ✓ LAPACK/BLAS (linear algebra) +- ✓ NetCDF (for GEQDSK? No, GEQDSK is ASCII) +- ✓ libneo (GEQDSK tools, splines, field evaluation) + +### Internal Modules (reuse from VMEC implementation) +- ✓ `interpolate` module (batch splines) +- ✓ `psi_transform` module (Albert coordinates) +- ✓ `binsrc_sub` (binary search) +- ✓ Integration infrastructure (ODE solvers, symplectic integrators) + +### New Modules Required +- [ ] `geoflux_coordinates` (Phase 1.1) +- [ ] `geoflux_field` (Phase 1.2) +- [ ] `field_geqdsk` (Phase 2.1) +- [ ] `field_can_meiss_geoflux` (Phase 3.1) +- [ ] `field_can_albert_geoflux` (Phase 3.2) + +--- + +## File Structure Summary + +### libneo (new files) +``` +../libneo/ +├── src/ +│ ├── coordinates/ +│ │ └── geoflux_coordinates.f90 [NEW - Phase 1.1] +│ └── magfie/ +│ └── geoflux_field.f90 [NEW - Phase 1.2] +└── test/ + └── source/ + └── test_geoflux.f90 [NEW - Phase 4.1] +``` + +### SIMPLE (new files) +``` +SIMPLE/ +├── src/ +│ ├── field/ +│ │ ├── field_geqdsk.f90 [NEW - Phase 2.1] +│ │ ├── field_can_meiss_geoflux.f90 [NEW - Phase 3.1] +│ │ └── field_can_albert_geoflux.f90 [NEW - Phase 3.2] +│ ├── field.F90 [MODIFY - Phase 2.2] +│ ├── coordinates/ +│ │ └── coordinates.f90 [MODIFY - Phase 2.3] +│ └── simple.f90 [MODIFY - Phase 3.3] +├── test/ +│ ├── fortran/ +│ │ ├── test_geqdsk_field.f90 [NEW - Phase 4.2] +│ │ └── test_can_geoflux.f90 [NEW - Phase 4.2] +│ └── data/ +│ └── geqdsk/ +│ └── test.geqdsk [SYMLINK to libneo] +└── examples/ + ├── simple_geqdsk.in [NEW - Phase 5.2] + └── example_geqdsk.f90 [NEW - Phase 5.3] +``` + +--- + +## Key Design Decisions + +### 1. Why implement in libneo? +**Rationale**: Follow VMEC pattern where coordinate transformations live in libneo, allowing reuse across multiple codes. + +### 2. Why "geoflux" name? +**Rationale**: +- Emphasizes **geometric** poloidal angle (not Boozer/Hamada) +- Combines with toroidal **flux** as radial coordinate +- Distinguishes from VMEC's Fourier-based coordinates + +### 3. Why support both Meiss and Albert? +**Rationale**: +- **Meiss**: Uses toroidal flux s_tor (natural for energy conservation) +- **Albert**: Uses poloidal flux ψ_pol (natural for GEQDSK representation) +- Gives users flexibility depending on application + +### 4. Why cache flux surfaces? +**Rationale**: +- Newton iteration for (s_tor, θ_geo) → (R, Z) is expensive (~10-20 iterations) +- Pre-computing on grid reduces to 2D spline evaluation (~10× faster) +- Memory cost is negligible (<10 MB for typical grids) + +--- + +## Success Criteria + +### Milestone 1: Basic Functionality +- [ ] Load GEQDSK file and trace particle orbits +- [ ] Output matches expected tokamak behavior (banana orbits, passing orbits) +- [ ] Passes unit tests for coordinate transformations + +### Milestone 2: Canonical Coordinates +- [ ] Meiss and Albert coordinates implemented +- [ ] Hamiltonian conserved to machine precision +- [ ] Symplectic structure preserved (Poincaré return map) + +### Milestone 3: Production Ready +- [ ] Full test suite passing (unit + integration + validation) +- [ ] Documentation complete +- [ ] Performance comparable to VMEC-based workflow + +--- + +## Future Extensions + +### Included via field_divB0: +- **Non-axisymmetric perturbations**: 3D fields (RMPs, TBMs, error fields) on top of GEQDSK + - Supported through `field_divB0` module's superposition capability + - Reference coordinates remain axisymmetric (based on equilibrium) + - Perturbations affect field evaluation but not coordinate system + +### Out of Scope: +- **Kinetic MHD equilibria**: Pressure anisotropy, flows +- **Real-time equilibrium reconstruction**: EFIT/LIUQE interface +- **Scrape-off layer**: Extend field beyond separatrix + +These could be added later as separate features building on the geoflux foundation. + +--- + +## References + +### Code References +- SIMPLE field system: `src/field/field_base.f90`, `src/field/field_vmec.f90` +- SIMPLE canonical coordinates: `src/field/field_can_*.f90` +- libneo VMEC: `../libneo/src/coordinates/vmec_coordinates.f90` +- libneo GEQDSK: `../libneo/src/magfie/geqdsk_tools.f90` + +### Scientific References +- Meiss coordinates: [cite canonical coordinate papers] +- Albert coordinates: [cite geoflux canonical coordinate papers] +- GEQDSK format: EFIT documentation +- COCOS conventions: O. Sauter & S.Yu. Medvedev, Comput. Phys. Commun. 184, 293 (2013) + +--- + +## Notes + +- This TODO assumes familiarity with the existing SIMPLE and libneo architecture +- Adjust grid resolutions (ns, ntheta) based on performance testing +- Consider parallelizing flux surface tracing if it becomes a bottleneck +- Keep GEQDSK-specific code isolated for maintainability \ No newline at end of file From 760b8a7551dd4f5130dbfeefd2d88d0cc1a994e0 Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Tue, 30 Sep 2025 15:13:01 +0200 Subject: [PATCH 02/19] docs: Add comprehensive 3D field superposition details Document field_divB0 perturbation modes: - Vacuum perturbation workflow (ipert=1) with Biot-Savart coil fields - Plasma response modes (ipert=2,3) with Fourier representation - Coil file formats (AUG, GPEC, Nemov, STELLOPT) - vacfield.x program for generating field files from coil geometries - Phased implementation plan for SIMPLE+GEQDSK Note: libneo has full 3D capability but no regression tests yet --- TODO.md | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/TODO.md b/TODO.md index 6a092676..a47014c0 100644 --- a/TODO.md +++ b/TODO.md @@ -201,6 +201,63 @@ end module geoflux_field - Reference coordinates (s_tor, θ_geo, φ) remain based on the axisymmetric equilibrium - This allows studying non-axisymmetric effects without losing the clean flux coordinate system +**How 3D perturbations work in field_divB0**: + +The `ipert` switch controls perturbation mode: +- `ipert=0`: Axisymmetric equilibrium only (what we need for basic geoflux implementation) +- `ipert=1`: Vacuum perturbation (cylindrical coil field via Biot-Savart) +- `ipert=2`: Vacuum + plasma response (no derivatives, uses Fourier representation) +- `ipert=3`: Vacuum + plasma response with full derivatives (7× slower) + +The `iequil` switch controls whether equilibrium is included: +- `iequil=0`: Perturbation field alone (useful for debugging) +- `iequil=1`: Total field = equilibrium + perturbation (normal mode) + +**Vacuum perturbation workflow** (`ipert=1`): +1. Read 3D coil field from `pfile` on cylindrical (R, φ, Z) grid +2. Supported file formats (`icftype`): + - Type 1-3: Legacy formats with fixed grid sizes + - Type 4: Simple format with header: `nr np nz`, `rmin rmax`, `pmin pmax`, `zmin zmax`, then `Br Bp Bz` values +3. Convert field to divergence-free representation via vector potentials (`vector_potentials`) + - Decomposes into Fourier harmonics (up to `ntor` modes) + - Uses stretch coordinates to handle complex geometry +4. Evaluate perturbation field at any point via `field_divfree` +5. Add to equilibrium field with amplitude scaling: `B_total = B_eq + ampl * B_pert` + +**Plasma response workflow** (`ipert=2,3`): +- Reads pre-computed plasma response in flux coordinates (`fluxdatapath`) +- Uses `field_fourier` / `field_fourier_derivs` for evaluation +- Requires `inthecore` cutoff to define region where plasma response is valid + +**Generating vacuum field files**: +- Use `vacfield.x` program from libneo to compute coil fields +- Reads coil geometries in various formats: + - AUG format (ASDEX Upgrade convention) + - GPEC format (General Perturbation Equilibrium Code) + - Nemov format (Wendelstein 7-X convention) + - STELLOPT/MAKEGRID filament format (coils.c09r00) +- Computes Biot-Savart field on specified (R, φ, Z) grid +- Can output either real-space field or Fourier representation + +**Coil geometry tools**: +- `coil_tools.f90` module provides coil I/O and Biot-Savart routines +- `coil_convert.x` program converts between coil file formats +- Python interface via `libneo.coils` module + +**Current status in libneo tests**: +- All `field_divB0.inp` test files have `ipert=0` (equilibrium only) +- No automated tests for 3D field superposition exist yet +- The infrastructure is implemented and used in production codes, but not regression-tested + +**For SIMPLE+GEQDSK implementation**: +1. Phase 1 (basic): Use `ipert=0` for pure axisymmetric equilibrium +2. Phase 2 (advanced): Enable `ipert=1` for RMP/error field studies + - Requires pre-computing coil fields on appropriate grid + - Use `vacfield.x` to generate field files from coil geometries +3. Phase 3 (full): Add `ipert=2,3` for self-consistent plasma response + - Requires coupling to GPEC, MARS-F, or similar MHD codes + - Out of scope for initial implementation + **Trade-offs on GEQDSK infrastructure**: Option A: **Use `field_divB0` entirely** (RECOMMENDED) From c7e4f72a18947c33467af893980ea2174269bf05 Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Tue, 30 Sep 2025 15:17:26 +0200 Subject: [PATCH 03/19] docs: Add executive summary of 3D field superposition capability Add comprehensive summary section covering: - 4 perturbation modes (ipert=0/1/2/3) with use cases - Vacuum perturbation workflow using vacfield.x - Plasma response mode requirements - Available tools (coil_tools, coil_convert, Python interfaces) - Current status: fully implemented but not regression tested - 3-phase integration plan for SIMPLE+GEQDSK - Example field_divB0.inp configuration This provides quick reference for understanding libneo's 3D capabilities before diving into detailed implementation phases. --- TODO.md | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/TODO.md b/TODO.md index a47014c0..dbda7968 100644 --- a/TODO.md +++ b/TODO.md @@ -18,6 +18,105 @@ This significantly reduces implementation effort compared to reimplementing GEQD --- +## Summary: 3D Field Superposition Capability + +### What field_divB0 Provides + +The `field_divB0` module in libneo supports **3D non-axisymmetric perturbations** on top of axisymmetric equilibria through the `ipert` parameter: + +| Mode | Description | Use Case | +|------|-------------|----------| +| `ipert=0` | Axisymmetric equilibrium only | Basic tokamak physics, initial GEQDSK implementation | +| `ipert=1` | Vacuum perturbation (Biot-Savart) | RMPs, error fields, test coils (no plasma response) | +| `ipert=2` | Vacuum + plasma response | Linear MHD response (fast, no derivatives) | +| `ipert=3` | Vacuum + plasma response + derivatives | Full linear MHD (7× slower) | + +**Key insight**: Reference coordinates remain axisymmetric (based on equilibrium), while the magnetic field includes 3D perturbations. + +### How It Works + +**Vacuum Perturbation Mode (ipert=1)**: +1. Pre-compute 3D coil field on cylindrical (R, φ, Z) grid using **vacfield.x** +2. Store in `pfile` (format controlled by `icftype`) +3. `field_divB0` converts to divergence-free Fourier representation (up to `ntor` harmonics) +4. At runtime: `B_total = B_equilibrium + ampl * B_perturbation` + +**Generating Coil Fields**: +```bash +# Use libneo's vacfield.x program +vacfield.x AUG 2 coil_file1.dat coil_file2.dat Bvac grid.inp output.h5 +``` + +Supported coil formats: +- **AUG**: ASDEX Upgrade format +- **GPEC**: General Perturbation Equilibrium Code +- **Nemov**: Wendelstein 7-X format +- **STELLOPT**: Filament format (coils.c09r00) + +**Plasma Response Mode (ipert=2,3)**: +- Requires pre-computed linear MHD response in flux coordinates +- Typically from GPEC, MARS-F, or similar codes +- Reads Fourier harmonics from `fluxdatapath` +- Uses `field_fourier` / `field_fourier_derivs` for evaluation + +### Tools Available + +**Fortran**: +- `coil_tools.f90`: Coil I/O, Biot-Savart evaluation +- `coil_convert.x`: Convert between coil file formats +- `vacfield.x`: Generate field files from coil geometries +- `bdivfree.f90`: Divergence-free field representation + +**Python**: +- `libneo.coils`: Read/write coil files, metadata extraction +- `libneo.mgrid`: STELLOPT M-grid format handling + +### Current Status + +**✅ Fully Implemented**: +- All 4 modes working in production codes +- Coil geometry readers for multiple formats +- Biot-Savart field computation +- Fourier decomposition and evaluation + +**❌ Missing**: +- No automated regression tests (all test files have `ipert=0`) +- No example workflows documented +- No validation suite for 3D modes + +**📋 For SIMPLE+GEQDSK**: +1. **Phase 1 (Priority 1)**: Implement pure equilibrium (`ipert=0`) + - Focus on coordinate transformations and canonical coordinates + - Get basic tokamak orbit tracing working +2. **Phase 2 (Future)**: Add RMP capability (`ipert=1`) + - Interface SIMPLE with pre-computed coil fields + - Enable RMP and error field studies + - Requires: grid generation, `pfile` format support +3. **Phase 3 (Advanced)**: Full plasma response (`ipert=2,3`) + - Couple to GPEC or MARS-F outputs + - Study self-consistent perturbed equilibria + - Out of scope for initial implementation + +### Integration References + +**In field_divB0.inp**: +```fortran +0 ipert ! 0=eq only, 1=vac, 2,3=vac+plas +1 iequil ! 0=pert. alone, 1=with equil. +1.00 ampl ! amplitude of perturbation, a.u. +72 ntor ! number of toroidal harmonics +0.99 cutoff ! inner cutoff in psi/psi_a units +4 icftype ! type of coil file +'test.geqdsk' gfile ! equilibrium file +'coils.dat' pfile ! coil field file +'convexwall.dat' convexfile ! convex file for stretchcoords +'fluxdata/' fluxdatapath ! directory with plasma response +``` + +**Simpson rule for flux integration**: Reference implementation in `fix-integration` branch of itpplasma/KAMEL repo. Plan: extract to libneo utility module, then update KAMEL to use as library routine. + +--- + ## Phase 1: libneo - Geoflux Reference Coordinate System ### 1.1 Create Geoflux Coordinate Module in libneo From c8a7c133d3419ccfa97a884fa71bc4c2015d1845 Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Wed, 1 Oct 2025 10:20:02 +0200 Subject: [PATCH 04/19] WIP: geoflux integration plan --- CMakeLists.txt | 14 + TODO.md | 1174 +++------------------------- cmake/Util.cmake | 1 - examples/tokamak/.gitignore | 1 + examples/tokamak/Makefile | 19 + examples/tokamak/simple.in | 7 + fpm.toml | 2 +- src/CMakeLists.txt | 18 + src/coordinates/coordinates.f90 | 41 + src/field.F90 | 38 +- src/field/field_geoflux.f90 | 96 +++ src/magfie.f90 | 212 ++++- src/simple.f90 | 28 +- test/tests/CMakeLists.txt | 33 + test/tests/test_field_geoflux.f90 | 44 ++ test/tests/test_field_vmec.f90 | 33 + test/tests/test_magfie_geoflux.f90 | 51 ++ 17 files changed, 747 insertions(+), 1065 deletions(-) create mode 100644 examples/tokamak/.gitignore create mode 100644 examples/tokamak/Makefile create mode 100644 examples/tokamak/simple.in create mode 100644 src/field/field_geoflux.f90 create mode 100644 test/tests/test_field_geoflux.f90 create mode 100644 test/tests/test_field_vmec.f90 create mode 100644 test/tests/test_magfie_geoflux.f90 diff --git a/CMakeLists.txt b/CMakeLists.txt index 362abea0..25884682 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,6 +14,20 @@ add_compile_options( $<$:-fbacktrace> ) +# Prefer local libneo checkout on sibling path; fall back to configured branch +if (NOT DEFINED libneo_SOURCE_DIR) + set(_local_libneo "${CMAKE_SOURCE_DIR}/../libneo") + if (EXISTS "${_local_libneo}/CMakeLists.txt") + get_filename_component(_libneo_abs "${_local_libneo}" ABSOLUTE) + set(libneo_SOURCE_DIR "${_libneo_abs}") + message(STATUS "Detected local libneo checkout at ${libneo_SOURCE_DIR}") + endif() +endif() + +if (NOT DEFINED LIBNEO_BRANCH) + set(LIBNEO_BRANCH "tokamak" CACHE STRING "libneo branch to fetch when no local checkout is present") +endif() + # Disable executable stack for security (trampolines/closures create execstack) if(CMAKE_Fortran_COMPILER_ID STREQUAL "GNU") # Apple's linker doesn't support -z,noexecstack (uses different security model) diff --git a/TODO.md b/TODO.md index dbda7968..d1042529 100644 --- a/TODO.md +++ b/TODO.md @@ -1,1074 +1,136 @@ -# TODO: GEQDSK Support with Geoflux Reference Coordinates +# TODO: Meiss Canonical Coordinates on EQDSK/Geoflux — Full Implementation & Robust Testing -## Overview +_Last updated: 2025-09-30_ -Add tokamak field support from GEQDSK files using new "geoflux" reference coordinates: -- **Radial**: s_tor = ψ_tor/ψ_tor,edge (normalized toroidal flux computed from ψ_pol and q-profile) -- **Poloidal**: θ_geo = atan2(Z - Z_axis, R - R_axis) (geometric angle from magnetic axis) -- **Toroidal**: φ = φ_cyl (cylindrical toroidal angle) - -**Architecture**: Follow VMEC pattern with clean separation between libneo (reference coordinates) and SIMPLE (canonical coordinates). - -**Key Simplification**: Reuse libneo's existing `field_divB0` module for all GEQDSK I/O and field evaluation. We only need to add: -1. Coordinate transformations: (s_tor, θ_geo, φ) ↔ (R, φ, Z) -2. Toroidal flux computation: s_tor(ψ_pol) from q-profile integration -3. Flux surface cache: Pre-compute (R, Z) on (s_tor, θ_geo) grid for performance - -This significantly reduces implementation effort compared to reimplementing GEQDSK handling from scratch. +This is an exhaustive execution guide. Every step includes file names, code snippets, commands, and validation criteria. The emphasis is on **precise, fast, non-tautological tests** and **system-level regressions**. --- - -## Summary: 3D Field Superposition Capability - -### What field_divB0 Provides - -The `field_divB0` module in libneo supports **3D non-axisymmetric perturbations** on top of axisymmetric equilibria through the `ipert` parameter: - -| Mode | Description | Use Case | -|------|-------------|----------| -| `ipert=0` | Axisymmetric equilibrium only | Basic tokamak physics, initial GEQDSK implementation | -| `ipert=1` | Vacuum perturbation (Biot-Savart) | RMPs, error fields, test coils (no plasma response) | -| `ipert=2` | Vacuum + plasma response | Linear MHD response (fast, no derivatives) | -| `ipert=3` | Vacuum + plasma response + derivatives | Full linear MHD (7× slower) | - -**Key insight**: Reference coordinates remain axisymmetric (based on equilibrium), while the magnetic field includes 3D perturbations. - -### How It Works - -**Vacuum Perturbation Mode (ipert=1)**: -1. Pre-compute 3D coil field on cylindrical (R, φ, Z) grid using **vacfield.x** -2. Store in `pfile` (format controlled by `icftype`) -3. `field_divB0` converts to divergence-free Fourier representation (up to `ntor` harmonics) -4. At runtime: `B_total = B_equilibrium + ampl * B_perturbation` - -**Generating Coil Fields**: -```bash -# Use libneo's vacfield.x program -vacfield.x AUG 2 coil_file1.dat coil_file2.dat Bvac grid.inp output.h5 -``` - -Supported coil formats: -- **AUG**: ASDEX Upgrade format -- **GPEC**: General Perturbation Equilibrium Code -- **Nemov**: Wendelstein 7-X format -- **STELLOPT**: Filament format (coils.c09r00) - -**Plasma Response Mode (ipert=2,3)**: -- Requires pre-computed linear MHD response in flux coordinates -- Typically from GPEC, MARS-F, or similar codes -- Reads Fourier harmonics from `fluxdatapath` -- Uses `field_fourier` / `field_fourier_derivs` for evaluation - -### Tools Available - -**Fortran**: -- `coil_tools.f90`: Coil I/O, Biot-Savart evaluation -- `coil_convert.x`: Convert between coil file formats -- `vacfield.x`: Generate field files from coil geometries -- `bdivfree.f90`: Divergence-free field representation - -**Python**: -- `libneo.coils`: Read/write coil files, metadata extraction -- `libneo.mgrid`: STELLOPT M-grid format handling - -### Current Status - -**✅ Fully Implemented**: -- All 4 modes working in production codes -- Coil geometry readers for multiple formats -- Biot-Savart field computation -- Fourier decomposition and evaluation - -**❌ Missing**: -- No automated regression tests (all test files have `ipert=0`) -- No example workflows documented -- No validation suite for 3D modes - -**📋 For SIMPLE+GEQDSK**: -1. **Phase 1 (Priority 1)**: Implement pure equilibrium (`ipert=0`) - - Focus on coordinate transformations and canonical coordinates - - Get basic tokamak orbit tracing working -2. **Phase 2 (Future)**: Add RMP capability (`ipert=1`) - - Interface SIMPLE with pre-computed coil fields - - Enable RMP and error field studies - - Requires: grid generation, `pfile` format support -3. **Phase 3 (Advanced)**: Full plasma response (`ipert=2,3`) - - Couple to GPEC or MARS-F outputs - - Study self-consistent perturbed equilibria - - Out of scope for initial implementation - -### Integration References - -**In field_divB0.inp**: -```fortran -0 ipert ! 0=eq only, 1=vac, 2,3=vac+plas -1 iequil ! 0=pert. alone, 1=with equil. -1.00 ampl ! amplitude of perturbation, a.u. -72 ntor ! number of toroidal harmonics -0.99 cutoff ! inner cutoff in psi/psi_a units -4 icftype ! type of coil file -'test.geqdsk' gfile ! equilibrium file -'coils.dat' pfile ! coil field file -'convexwall.dat' convexfile ! convex file for stretchcoords -'fluxdata/' fluxdatapath ! directory with plasma response -``` - -**Simpson rule for flux integration**: Reference implementation in `fix-integration` branch of itpplasma/KAMEL repo. Plan: extract to libneo utility module, then update KAMEL to use as library routine. - ---- - -## Phase 1: libneo - Geoflux Reference Coordinate System - -### 1.1 Create Geoflux Coordinate Module in libneo -**File**: `../libneo/src/coordinates/geoflux_coordinates.f90` - -**Purpose**: Provide coordinate transformations between geoflux (s_tor, θ_geo, φ) and cylindrical (R, φ, Z), similar to `vmec_coordinates.f90`. - -**Implementation details**: - -```fortran -module geoflux_coordinates - use iso_c_binding - implicit none - - ! Geoflux coordinate data structure - type :: geoflux_t - ! GEQDSK data - type(geqdsk_t) :: geqdsk - - ! Magnetic axis location - real(8) :: R_axis, Z_axis - - ! Flux conversion data - real(8) :: psi_pol_axis, psi_pol_edge - real(8), allocatable :: s_tor_of_psi_pol(:) ! 1D spline for s_tor(psi_pol) - real(8), allocatable :: psi_pol_of_s_tor(:) ! 1D spline for inverse - - ! Flux surface geometry cache (optional, for performance) - integer :: ns_cache, ntheta_cache - real(8), allocatable :: R_cache(:,:) ! R(s_tor, theta_geo) - real(8), allocatable :: Z_cache(:,:) ! Z(s_tor, theta_geo) - logical :: cache_built - end type geoflux_t - -contains - - ! Initialize from GEQDSK file - subroutine init_geoflux(self, geqdsk_file) - - ! Compute toroidal flux from poloidal flux using q-profile - ! Integral: s_tor = integral_0^psi_pol q(psi') dpsi' / (2*pi*psi_tor_edge) - subroutine compute_toroidal_flux_mapping(self) - - ! Build flux surface geometry cache on (s_tor, theta_geo) grid - subroutine build_flux_surface_cache(self, ns, ntheta) - - ! Core transformations (similar to vmec_coordinates) - subroutine geoflux_to_cyl(self, s_tor, theta_geo, phi, R, Z, & - dRds, dRdtheta, dZds, dZdtheta) - - subroutine cyl_to_geoflux(self, R, phi, Z, s_tor, theta_geo, & - dsdr, dsdz, dthetadr, dthetadz) - - ! Get field components in geoflux coordinates - subroutine get_field_geoflux(self, s_tor, theta_geo, phi, & - B_s, B_theta, B_phi, Bmod) - -end module geoflux_coordinates -``` - -**Key algorithms**: - -1. **Toroidal flux computation** (using q-profile from GEQDSK): +## 0. Baseline Safety Net (Tests Only) +1. **Ensure clean trees** (SIMPLE + libneo): + ```bash + git status + cd ../libneo && git status + cd ../SIMPLE ``` - Note: read_eqfile1 already reads qpsi(i) array (line 418 in field_divB0.f90)! - - For each psi_pol from axis to edge: - s_tor(psi_pol) = (1/psi_tor_edge) * integral[q(psi') * dpsi', psi'=0..psi_pol] - where psi_tor_edge can be computed from: - psi_tor_edge = integral[q(psi) * dpsi, psi=axis..edge] - - Use trapezoidal rule or Simpson's rule on qpsi array from GEQDSK. - Reference implementation: Simpson rule in branch `fix-integration` of - itpplasma/KAMEL repo (private, github.com/itpplasma/KAMEL). - Plan: Extract to utility module in libneo, then update KAMEL to use as library routine. +2. **VMEC Meiss guard** (`test/tests/test_field_can_meiss_vmec.f90`) + - Implement as described previously **but add analytic invariants**: + - After `evaluate_meiss`, compute energy `H = 0.5*vpar^2 + mu*Bmod` and ensure matches reference value from prior run (hard-code expected value with tolerance 1e-10). + - Validate orthogonality: `dot_product(hcov, tracer%f%dhth)` should be near zero (tolerance 1e-12). + - Register in CMake and run: `ctest -R test_field_can_meiss_vmec`. + +3. **EQDSK guard (WILL_FAIL initially)** (`test/tests/test_field_can_meiss_eqdsk.f90`) + - Implement same checks as VMEC guard; currently expect failure. + - Mark test `WILL_FAIL TRUE` in CMake. + - Run: `ctest -R test_field_can_meiss_eqdsk` and confirm it fails but suite continues. + +4. **Geoflux field smoke** (`ctest -R test_magfie_geoflux`) – must pass. + +5. **Golden record awareness** + - Inspect `test/golden_record/canonical/simple.in` – note it uses `isw_field_type=0` (canonical flux). + - Plan to add new golden case for Meiss EQDSK later (see §4). + +--- +## 1. libneo Coordinate System (follow ../libneo/TODO.md) +Complete libneo work first. Do not proceed until `../libneo/build/ctest -R test_coordinate_systems` passes and exposes `make_vmec_coordinate_system` / `make_geoflux_coordinate_system`. + +--- +## 2. Integrate coordinate_system_t (no wrappers) + +1. **Imports** + - Add `use libneo_coordinates` wherever geometry is needed: `src/field/field_can_meiss.f90`, `src/field_can.f90`, `app/simple_diag_meiss.f90`, `src/diag/diag_meiss.f90`, and any helper modules use the geometry. + +2. **Global storage** (`field_can_meiss`): + - Replace `field_noncan` with: + ```fortran + class(coordinate_system_t), allocatable :: meiss_coords + class(MagneticField), allocatable :: source_field + ``` + +3. **Initialise coordinate system** (`init_meiss`) + - After storing incoming magnetic field, allocate coordinate system directly: + ```fortran + if (allocated(meiss_coords)) deallocate(meiss_coords) + if (geoflux_ready()) then + call make_geoflux_coordinate_system(meiss_coords) + else + call make_vmec_coordinate_system(meiss_coords) + end if + ``` + - Note: `geoflux_ready()` should be a new public function from `field_geoflux`. Add it if missing (e.g., module procedure returning logical flag). + +4. **Use coordinate system** across the module: + - Replace all VMEC-specific data access (e.g., global arrays `lam_phi`, `h_cov`, etc.) with method calls: + - `meiss_coords%evaluate_point(u, position)`. + - `meiss_coords%covariant_basis(u, basis)` to compute derivatives. + - `meiss_coords%metric_tensor(u, g, ginv, sqrtg)`. + - Compute derivatives/invariants using the returned basis/metric—*no* direct `splint_vmec_data` or `geoflux_to_cyl` inside SIMPLE. + +5. **Magnetic field sampling** + - Continue using the existing `MagneticField` polymorphic object (`source_field`) for `evaluate` calls. Geometry now depends solely on `meiss_coords`. + +6. **Entry points update** (`src/field_can.f90`) + - Ensure `init_field_can` passes the magnetic field so `init_meiss` can allocate the right coordinate system. + - No new SIMPLE types are created; everything uses libneo’s `coordinate_system_t` instance. + +7. **Diagnostics** + - In `app/simple_diag_meiss.f90`, after reading config, call `field_from_file` and then `init_meiss(field)`; remove direct `init_vmec` + manual cache calls. + - `src/diag/diag_meiss.f90` simply relies on `rh_can`; confirm that `rh_can` internally uses `meiss_coords`. + +8. **Upgrade EQDSK test** + - Remove `WILL_FAIL TRUE` from EQDSK test once refactor is complete. + - Enhance checks in both VMEC and EQDSK tests to compare outputs against reference values stored in a small JSON/Fortran data file (e.g., `test/reference/meiss_expected.dat`). Populate this file by running the old VMEC pipeline and recording `Bmod`, `hth`, `hph` for a few canonical points; these become the expected values with tolerance. + +9. **Run focused unit tests** + ```bash + cmake --build build --target test_field_can_meiss_vmec.x test_field_can_meiss_eqdsk.x + ctest -R test_field_can_meiss_vmec + ctest -R test_field_can_meiss_eqdsk + ctest -R test_magfie_geoflux ``` - -2. **Flux surface tracing** (for given s_tor, theta_geo → R, Z): - - Convert s_tor → psi_pol using spline - - Starting from geometric ray: R₀ = R_axis + r₀*cos(θ_geo), Z₀ = Z_axis + r₀*sin(θ_geo) - - Newton iteration to find (R, Z) where: - - ψ_interp(R, Z) = psi_pol (on flux surface) - - atan2(Z - Z_axis, R - R_axis) = theta_geo (correct angle) - - Cache results on grid for performance - -3. **Inverse transformation** (R, Z → s_tor, theta_geo): - - Interpolate ψ_pol(R, Z) from GEQDSK grid - - Compute s_tor from psi_pol using spline - - Compute theta_geo = atan2(Z - Z_axis, R - R_axis) - -**Dependencies**: -- `use geqdsk_tools, only: geqdsk_t, geqdsk_read, geqdsk_standardise` -- `use interpolate, only: ...` (for spline construction/evaluation) -- `use binsrc_sub, only: ...` (for table lookup) - -**Files to reference**: -- `../libneo/src/coordinates/vmec_coordinates.f90` (template structure) -- `../libneo/src/magfie/geqdsk_tools.f90` (GEQDSK I/O) -- `../libneo/src/magfie/field_divB0.f90` (field evaluation from GEQDSK) - ---- - -### 1.2 Create Geoflux Spline Data Module -**File**: `../libneo/src/magfie/geoflux_field.f90` - -**Purpose**: Provide spline-based field evaluation in geoflux coordinates, analogous to `splint_vmec_data` for VMEC. - -**Key insight**: **Reuse existing `field_divB0` module!** It already has: -- `read_eqfile1`: Reads GEQDSK format (lines 377-443 in `field_divB0.f90`) -- `field_eq`: Evaluates field in cylindrical (R,φ,Z) with derivatives (lines 127-274) -- 2D bicubic spline interpolation on ψ(R,Z) -- F_pol(ψ) handling for toroidal field - -```fortran -module geoflux_field - use geoflux_coordinates - use field_sub, only: field_eq ! Reuse existing field evaluation! - implicit none - - ! Global geoflux data instance (similar to VMEC pattern) - type(geoflux_t), save :: geoflux_data - -contains - - ! Initialize geoflux data (analogous to spline_vmec_data) - subroutine spline_geoflux_data(geqdsk_file, ns, ntheta) - character(len=*), intent(in) :: geqdsk_file - integer, intent(in) :: ns, ntheta - - ! Read GEQDSK using existing field_divB0 infrastructure - ! This initializes the module-level splines automatically - call read_eqfile1(...) ! from field_divB0 - - ! Find magnetic axis from GEQDSK data - call find_magnetic_axis(geoflux_data) - - ! Compute s_tor from psi_pol using q-profile - call compute_toroidal_flux_mapping(geoflux_data) - - ! Build flux surface cache - call build_flux_surface_cache(geoflux_data, ns, ntheta) - end subroutine - - ! Evaluate field in geoflux coordinates (analogous to splint_can_sub) - subroutine splint_geoflux_field(s_tor, theta_geo, phi, & - Acov, hcov, Bmod, sqgBctr) - real(8), intent(in) :: s_tor, theta_geo, phi - real(8), intent(out) :: Acov(3), hcov(3), Bmod, sqgBctr - real(8) :: R, Z, B_R, B_phi, B_Z - real(8) :: dBrdR, dBrdp, dBrdZ, dBpdR, dBpdp, dBpdZ, dBzdR, dBzdp, dBzdZ - - ! Transform to cylindrical using flux surface cache - call geoflux_to_cyl(geoflux_data, s_tor, theta_geo, phi, R, Z, & - dRds, dRdtheta, dZds, dZdtheta) - - ! Call existing field_eq from field_divB0 module - call field_eq(R, phi, Z, B_R, B_phi, B_Z, & - dBrdR, dBrdp, dBrdZ, dBpdR, dBpdp, dBpdZ, dBzdR, dBzdp, dBzdZ) - - ! Transform B from cylindrical to geoflux covariant components - ! Using Jacobian ∂(R,Z)/∂(s_tor,θ_geo) - ! Compute vector potential A and normalized field h - ! Compute Jacobian sqrt(g) and metric tensor - - end subroutine - -end module geoflux_field -``` - -**Key implementation notes**: -- **Reuse `field_divB0.f90`** for all GEQDSK I/O and field evaluation in cylindrical -- Only add coordinate transformation layer: (s_tor, θ_geo, φ) ↔ (R, φ, Z) -- `field_eq` already handles: 2D splines, F_pol(ψ), derivatives -- Toroidal symmetry ⟹ no φ-dependence (simplifies to 2D problem) -- Compute Jacobian: √g = R * |∂(R,Z)/∂(s_tor,θ_geo)| - -**3D field superposition support**: -- The `field_divB0` module supports adding 3D perturbations on top of the axisymmetric equilibrium -- 3D fields (e.g., RMPs, error fields, test coils) affect the magnetic field evaluation -- Reference coordinates (s_tor, θ_geo, φ) remain based on the axisymmetric equilibrium -- This allows studying non-axisymmetric effects without losing the clean flux coordinate system - -**How 3D perturbations work in field_divB0**: - -The `ipert` switch controls perturbation mode: -- `ipert=0`: Axisymmetric equilibrium only (what we need for basic geoflux implementation) -- `ipert=1`: Vacuum perturbation (cylindrical coil field via Biot-Savart) -- `ipert=2`: Vacuum + plasma response (no derivatives, uses Fourier representation) -- `ipert=3`: Vacuum + plasma response with full derivatives (7× slower) - -The `iequil` switch controls whether equilibrium is included: -- `iequil=0`: Perturbation field alone (useful for debugging) -- `iequil=1`: Total field = equilibrium + perturbation (normal mode) - -**Vacuum perturbation workflow** (`ipert=1`): -1. Read 3D coil field from `pfile` on cylindrical (R, φ, Z) grid -2. Supported file formats (`icftype`): - - Type 1-3: Legacy formats with fixed grid sizes - - Type 4: Simple format with header: `nr np nz`, `rmin rmax`, `pmin pmax`, `zmin zmax`, then `Br Bp Bz` values -3. Convert field to divergence-free representation via vector potentials (`vector_potentials`) - - Decomposes into Fourier harmonics (up to `ntor` modes) - - Uses stretch coordinates to handle complex geometry -4. Evaluate perturbation field at any point via `field_divfree` -5. Add to equilibrium field with amplitude scaling: `B_total = B_eq + ampl * B_pert` - -**Plasma response workflow** (`ipert=2,3`): -- Reads pre-computed plasma response in flux coordinates (`fluxdatapath`) -- Uses `field_fourier` / `field_fourier_derivs` for evaluation -- Requires `inthecore` cutoff to define region where plasma response is valid - -**Generating vacuum field files**: -- Use `vacfield.x` program from libneo to compute coil fields -- Reads coil geometries in various formats: - - AUG format (ASDEX Upgrade convention) - - GPEC format (General Perturbation Equilibrium Code) - - Nemov format (Wendelstein 7-X convention) - - STELLOPT/MAKEGRID filament format (coils.c09r00) -- Computes Biot-Savart field on specified (R, φ, Z) grid -- Can output either real-space field or Fourier representation - -**Coil geometry tools**: -- `coil_tools.f90` module provides coil I/O and Biot-Savart routines -- `coil_convert.x` program converts between coil file formats -- Python interface via `libneo.coils` module - -**Current status in libneo tests**: -- All `field_divB0.inp` test files have `ipert=0` (equilibrium only) -- No automated tests for 3D field superposition exist yet -- The infrastructure is implemented and used in production codes, but not regression-tested - -**For SIMPLE+GEQDSK implementation**: -1. Phase 1 (basic): Use `ipert=0` for pure axisymmetric equilibrium -2. Phase 2 (advanced): Enable `ipert=1` for RMP/error field studies - - Requires pre-computing coil fields on appropriate grid - - Use `vacfield.x` to generate field files from coil geometries -3. Phase 3 (full): Add `ipert=2,3` for self-consistent plasma response - - Requires coupling to GPEC, MARS-F, or similar MHD codes - - Out of scope for initial implementation - -**Trade-offs on GEQDSK infrastructure**: - -Option A: **Use `field_divB0` entirely** (RECOMMENDED) -- ✅ Battle-tested field evaluation with derivatives -- ✅ 2D bicubic splines already implemented -- ✅ Handles F_pol(ψ) correctly -- ⚠️ No COCOS standardization (assumes specific convention) -- ⚠️ Module-level variables (but threadprivate for OpenMP) - -Option B: Use `geqdsk_tools` for I/O + implement field evaluation -- ✅ Modern interface with COCOS awareness -- ✅ Clean data structures (geqdsk_t type) -- ❌ Need to implement field evaluation from scratch -- ❌ More work, potential for bugs - -**Decision**: Use `field_divB0` for field evaluation. Optionally wrap reading in `geqdsk_tools` for COCOS standardization, then pass data to `field_divB0` structures. Can refactor later if needed. - ---- - -### 1.3 Update libneo Build System -**Files to modify**: -- `../libneo/CMakeLists.txt`: Add new source files -- `../libneo/fpm.toml`: Add to source list (if using fpm) - -**Changes**: -```cmake -# Add to libneo sources -set(LIBNEO_SOURCES - ... - src/coordinates/geoflux_coordinates.f90 - src/magfie/geoflux_field.f90 - ... -) -``` - ---- - -### 1.4 Create libneo Tests for Geoflux -**File**: `../libneo/test/source/test_geoflux.f90` - -**Test cases**: -1. **Load GEQDSK**: Read and standardize GEQDSK file -2. **Toroidal flux mapping**: Verify s_tor computation from q-profile -3. **Coordinate transformations**: - - Round-trip: (s_tor, θ, φ) → (R, φ, Z) → (s_tor, θ, φ) - - Check Jacobians and metric tensors -4. **Flux surface accuracy**: Verify ψ(R(s,θ), Z(s,θ)) = ψ(s) to tolerance -5. **Field evaluation**: Check ∇·B = 0, compare with known tokamak solutions - -**Test data**: Use `../libneo/python/tests/test.geqdsk` (MAST equilibrium) - ---- - -## Phase 2: SIMPLE - GEQDSK Field Class (Boozer-like representation) - -### 2.1 Create GEQDSK Field Type -**File**: `src/field/field_geqdsk.f90` - -**Purpose**: Extend `MagneticField` base class to handle GEQDSK files with geoflux reference coordinates. - -```fortran -module field_geqdsk - use field_base, only: MagneticField - use geoflux_field, only: spline_geoflux_data, splint_geoflux_field - implicit none - - type, extends(MagneticField) :: GeqdskField - character(len=256) :: filename - contains - procedure :: init => init_geqdsk - procedure :: evaluate => evaluate_geqdsk - end type GeqdskField - -contains - - subroutine init_geqdsk(self, filename) - class(GeqdskField), intent(inout) :: self - character(len=*), intent(in) :: filename - integer :: ns, ntheta - - self%filename = filename - - ! Use reasonable grid resolution (adjust as needed) - ns = 128 - ntheta = 256 - - ! Initialize libneo geoflux data - call spline_geoflux_data(filename, ns, ntheta) - end subroutine - - subroutine evaluate_geqdsk(self, x, Acov, hcov, Bmod, sqgBctr) - class(GeqdskField), intent(in) :: self - real(8), intent(in) :: x(3) ! (r=√s_tor, theta_geo, phi) - real(8), intent(out) :: Acov(3), hcov(3), Bmod, sqgBctr - real(8) :: s_tor - - ! Convert r to s_tor (SIMPLE convention: x(1) = √s) - s_tor = x(1)**2 - - ! Call libneo evaluation - call splint_geoflux_field(s_tor, x(2), x(3), Acov, hcov, Bmod, sqgBctr) - end subroutine - -end module field_geqdsk -``` - -**Note**: This provides geoflux coordinates as reference system, similar to how `field_vmec.f90` provides VMEC coordinates as reference. - ---- - -### 2.2 Update Field Dispatcher -**File**: `src/field.F90` - -**Modify**: `field_from_file` subroutine (around line 15-44) - -**Add**: -```fortran -use field_geqdsk, only: GeqdskField - -! In field_from_file routine: -else if (endswith(filename, '.geqdsk') .or. endswith(filename, '.eqdsk')) then - allocate(GeqdskField :: field) - call field%init(filename) -``` - ---- - -### 2.3 Update Coordinate Module -**File**: `src/coordinates/coordinates.f90` - -**Add**: Geoflux ↔ Cylindrical ↔ Cartesian transformations - -```fortran -! Add to get_transform function -case('geoflux') - select case(trim(to)) - case('cyl') - get_transform => geoflux_to_cyl_wrapper - case('cart') - get_transform => geoflux_to_cart_wrapper - end select -case('cyl') - if (trim(to) == 'geoflux') then - get_transform => cyl_to_geoflux_wrapper - end if -``` - -**Implementation**: Wrappers call libneo's `geoflux_coordinates` module. - ---- - -## Phase 3: SIMPLE - Canonical Coordinates on Geoflux - -### 3.1 Meiss Canonical Coordinates for Geoflux -**File**: `src/field/field_can_meiss_geoflux.f90` - -**Purpose**: Implement symmetry-flux canonical coordinates using geoflux as reference (instead of VMEC). - -**Strategy**: -1. Copy `src/field/field_can_meiss.f90` as template -2. Replace all VMEC-specific calls with geoflux equivalents: - - `vmec_to_cyl` → `geoflux_to_cyl` - - VMEC spline evaluations → geoflux spline evaluations -3. Simplify for axisymmetry: - - No φ-grid needed (∂/∂φ = 0 for equilibrium) - - Can use 2D batch splines instead of 3D (save memory) - - ODE integration only in (s_tor, θ_geo) plane - -**Key changes from VMEC-based Meiss**: -```fortran -module field_can_meiss_geoflux - use field_base - use geoflux_field - use interpolate ! For batch splines - - type, extends(MagneticField) :: MeissGeofluxField - ! Reference field (geoflux) - character(len=256) :: geqdsk_file - - ! Canonical transformation batch splines (2D in s_tor, theta_geo) - ! Axisymmetry ⟹ no phi dependence for equilibrium quantities - type(batch_spline_2d_t) :: spline_transformation ! [lambda_phi, chi_gauge] - type(batch_spline_2d_t) :: spline_field ! [A_*, h_*, Bmod, sqgBctr] - contains - procedure :: init => init_meiss_geoflux - procedure :: evaluate => evaluate_meiss_geoflux - end type - -contains - - subroutine init_meiss_geoflux(self, geqdsk_file, ns, ntheta) - ! 1. Initialize geoflux reference field - call spline_geoflux_data(geqdsk_file, ns, ntheta) - - ! 2. Integrate ODEs for canonical transformation (simplified for axisymmetry) - ! Grid: 2D (s_tor, theta_geo), no phi needed - ! ODE: dlambda_phi/ds = -h_s / h_phi, dchi/ds = A_s + A_phi * dlambda/ds - - ! 3. Build 2D batch splines for transformation and field - end subroutine - -end module -``` - -**Testing**: Verify orbits match between Meiss-geoflux and Meiss-VMEC for same tokamak equilibrium (if VMEC file available). - ---- - -### 3.2 Albert Canonical Coordinates for Geoflux -**File**: `src/field/field_can_albert_geoflux.f90` - -**Purpose**: Implement poloidal-flux canonical coordinates on top of Meiss-geoflux (replacing s_tor with ψ_pol). - -**Strategy**: -1. Copy `src/field/field_can_albert.f90` as template -2. Replace VMEC references with geoflux -3. Use existing `psi_transform` module to switch from s_tor to ψ_pol -4. This is the **primary coordinate system for GEQDSK**: matches natural poloidal-flux representation - -**Key changes**: -```fortran -module field_can_albert_geoflux - use field_can_meiss_geoflux - use psi_transform - - type, extends(MeissGeofluxField) :: AlbertGeofluxField - ! Add PSI transformation on top of Meiss - type(psi_grid_t) :: psi_grid - contains - procedure :: init => init_albert_geoflux - end type - -contains - - subroutine init_albert_geoflux(self, geqdsk_file, ns, ntheta) - ! 1. Initialize Meiss-geoflux base - call self%MeissGeofluxField%init(geqdsk_file, ns, ntheta) - - ! 2. Apply PSI transformation: s_tor → psi_pol - ! This makes radial coordinate proportional to poloidal flux - call init_psi_transform(self%psi_grid, ...) - - ! 3. Rebuild splines with new radial coordinate - call self%rebuild_splines_with_psi() - end subroutine - -end module -``` - -**Note**: This matches the "geoflux" name - Albert coordinates naturally use geometric flux (ψ_pol) as radial label. - ---- - -### 3.3 Update Field Initialization in Main Code -**File**: `src/simple.f90` - -**Modify**: Initialization section (around lines 47-75) - -**Add**: -```fortran -! Detect field type from filename extension -if (endswith(equil_file, '.nc')) then - ! VMEC file - existing logic - call spline_vmec_data(...) - ! Initialize VMEC-based canonical fields - -else if (endswith(equil_file, '.geqdsk') .or. endswith(equil_file, '.eqdsk')) then - ! GEQDSK file - new logic - select case(trim(field_type)) - case('geoflux', 'boozer') - ! Direct geoflux reference coordinates - allocate(GeqdskField :: mag_field) - - case('meiss') - ! Meiss canonical on geoflux reference - allocate(MeissGeofluxField :: mag_field) - - case('albert') - ! Albert canonical on geoflux reference - allocate(AlbertGeofluxField :: mag_field) - - case default - print *, 'For GEQDSK files, field_type must be: geoflux, meiss, or albert' - stop - end select - -else - print *, 'Unknown equilibrium file format' - stop -end if -``` - ---- - -## Phase 4: Testing and Validation - -### 4.1 Unit Tests - -**Files to create**: - -1. **libneo tests** (`../libneo/test/source/test_geoflux.f90`): - - Load MAST GEQDSK: `../libneo/python/tests/test.geqdsk` - - Test coordinate transformations - - Verify field evaluation (∇·B = 0) - - Check flux surface mapping accuracy - -2. **SIMPLE integration test** (`test/fortran/test_geqdsk_field.f90`): - - Load GEQDSK field - - Initialize particle on flux surface - - Trace single orbit - - Verify energy conservation - -3. **Canonical coordinate test** (`test/fortran/test_can_geoflux.f90`): - - Compare Meiss-geoflux vs Meiss-VMEC (if VMEC available for same tokamak) - - Verify Hamiltonian conservation - - Check symplecticity of transformations - ---- - -### 4.2 Integration Tests - -**Test cases** (use `examples/` directory): - -1. **Simple tokamak orbit**: - - Input: `examples/simple_geqdsk.in` (new file) - - GEQDSK: Use MAST equilibrium from libneo - - Field type: `geoflux` (reference coordinates) - - Trace 100 particles, verify confinement statistics - -2. **Meiss canonical tokamak**: - - Field type: `meiss` - - Compare with `geoflux` results (should be equivalent modulo gauge) - -3. **Albert canonical tokamak**: - - Field type: `albert` - - Test with various tokamak profiles (circular, shaped, etc.) - ---- - -### 4.3 Comparison with Existing Codes - -**Validation strategy**: -1. Generate GEQDSK from known VMEC tokamak equilibrium (if converter available) -2. Trace identical particle sets with both: - - SIMPLE + VMEC file (existing) - - SIMPLE + GEQDSK file (new) -3. Compare: - - Orbit trajectories (should match to tolerance) - - Confinement times - - Poincaré sections - -**Alternative**: Compare with established tokamak orbit codes (e.g., ORBIT, VENUS, etc.) using same GEQDSK. - ---- - -### 4.4 Test Data Organization - -**Location**: `test/data/geqdsk/` - -**Files to include**: -- `test.geqdsk` (symlink to `../libneo/python/tests/test.geqdsk`) -- Additional GEQDSK files from literature or databases: - - ITER equilibrium - - NSTX/MAST (spherical tokamak) - - DIII-D (shaped tokamak) - - Circular tokamak (for analytical validation) - ---- - -## Phase 5: Documentation and Examples - -### 5.1 Update Documentation - -**Files to modify**: - -1. **README.md**: Add GEQDSK support to features list -2. **DESIGN.md**: Document geoflux coordinate system architecture -3. **examples/README.md**: Add GEQDSK usage examples - ---- - -### 5.2 Example Input Files - -**Create**: `examples/simple_geqdsk.in` - -```fortran -&simple_config - equil_file = 'test.geqdsk' - field_type = 'albert' ! Options: geoflux, meiss, albert - - ! Particle initialization - nparticles = 1000 - sample_mode = 'surface' ! Start on flux surface - s_sample = 0.5 - - ! Integration parameters - integrator = 'lobatto4' - dt = 1.0d-2 - nsteps = 100000 - - ! Output - output_step = 100 - write_orbits = .true. -/ -``` - ---- - -### 5.3 Usage Examples - -**Create**: `examples/example_geqdsk.f90` - -Minimal example demonstrating: -1. Load GEQDSK file -2. Initialize geoflux field -3. Trace single particle orbit -4. Output to file - ---- - -## Phase 6: Performance Optimization - -### 6.1 Flux Surface Cache Optimization - -**Approach**: Pre-compute (R, Z) on dense (s_tor, theta_geo) grid - -**Trade-offs**: -- Memory: ~O(ns × ntheta × 2) doubles (e.g., 256×512×2 = 2.6 MB, negligible) -- Speed: Replace Newton iteration with 2D spline interpolation -- Accuracy: Control via grid resolution - -**Implementation**: Already included in `geoflux_t%R_cache`, `geoflux_t%Z_cache`. - ---- - -### 6.2 Profile Spline Optimization - -**Approach**: Pre-spline all 1D profiles (fpol, q, etc.) during initialization - -**Benefit**: Avoid repeated spline construction during orbit integration - ---- - -### 6.3 Axisymmetry Exploitation - -**Simplifications** for canonical coordinates: -- 2D batch splines instead of 3D (factor of ~nφ memory reduction) -- Skip φ-derivatives in ODE integration -- Simplified Jacobian calculations - -**Expected speedup**: ~2-3× for canonical coordinate initialization compared to 3D stellarator case. + All must pass (<1s each). --- +## 3. Regression Tests & Golden Record +1. **Add Meiss EQDSK golden test** + - Create `test/golden_record/meiss_eqdsk/simple.in` with EQDSK-specific configuration (e.g., `netcdffile='EQDSK_I.geqdsk'`, `isw_field_type=3` to request Meiss, set deterministic seeds). + - Update `test/golden_record/run_golden_tests.sh` so it copies the EQDSK file into each test case directory (download once to `$TEST_DATA_DIR/EQDSK_I.geqdsk`). + - Ensure symlink/hard copy placed in each run directory (similar to `wout.nc`). + - Add validation at the end of each run: check `diag_meiss_*` outputs exist if expected. -## Implementation Priority and Timeline - -### Minimal Working Implementation (Priority 1) -**Goal**: Trace particles in GEQDSK file with geoflux reference coordinates +2. **Golden record reference update** + - Generate reference outputs by running `test/golden_record/golden_record.sh main` (or chosen reference commit) after new code is in place. Keep `RUN_DIR_REF` results for diff. + - Ensure `compare_golden_results.sh` accounts for new files (update script to include Meiss-specific outputs). -**Tasks**: -- [ ] Phase 1.1: `geoflux_coordinates.f90` in libneo (coordinate transformations) -- [ ] Phase 1.2: `geoflux_field.f90` in libneo (thin wrapper around `field_divB0`) -- [ ] Phase 2.1: `field_geqdsk.f90` in SIMPLE -- [ ] Phase 2.2: Update field dispatcher -- [ ] Phase 4.1: Basic unit tests - -**Estimated effort**: 1-2 weeks (reduced from 2-3 weeks due to reusing `field_divB0`) +3. **System regression command** + - After every change, run: + ```bash + ./test/golden_record/golden_record.sh main + ``` + and ensure the current run matches reference (no diffs aside from known improvements). --- - -### Canonical Coordinates (Priority 2) -**Goal**: Support Meiss and Albert canonical coordinates for tokamaks - -**Tasks**: -- [ ] Phase 3.1: `field_can_meiss_geoflux.f90` -- [ ] Phase 3.2: `field_can_albert_geoflux.f90` -- [ ] Phase 3.3: Update `simple.f90` initialization -- [ ] Phase 4.2-4.3: Integration and validation tests - -**Estimated effort**: 2-3 weeks (after Priority 1 complete) +## 4. Diagnostics Automation +1. **CTest driver** `test/diag/run_diag_meiss.cmake` (see prior template). Extend to check *both* VMEC and EQDSK outputs and to verify key numeric summaries (e.g., parse `diag_meiss_*.pdf` metadata using a small Python script or check associated data files if generated). +2. **Register test** `diag_meiss_runs` with `TIMEOUT 120`. +3. **Run** `ctest -R diag_meiss_runs`. --- - -### Polish and Documentation (Priority 3) -**Goal**: Production-ready feature - -**Tasks**: -- [ ] Phase 4.4: Comprehensive test suite -- [ ] Phase 5: Documentation and examples -- [ ] Phase 6: Performance optimization - -**Estimated effort**: 1-2 weeks - ---- - -## Technical Challenges and Mitigation - -### Challenge 1: Toroidal Flux Computation -**Issue**: Need to integrate q(ψ_pol) from GEQDSK to get ψ_tor(ψ_pol) - -**Solution**: -- Use trapezoidal or Simpson's rule on q-profile -- Verify: ι = 1/q should match rotational transform -- Test with known analytical profiles (e.g., circular tokamak) - ---- - -### Challenge 2: Magnetic Axis Location -**Issue**: Need magnetic axis location (R_axis, Z_axis) for geometric angle θ_geo - -**Solution**: -- **Already provided by GEQDSK!** `read_eqfile1` returns `rmaxis, zmaxis` (line 407) -- Validate: ∇ψ should vanish at axis -- Alternative: Search for minimum on ψ grid if needed: `minloc(geqdsk%psirz)` - ---- - -### Challenge 3: Flux Surface Singular Behavior -**Issue**: At magnetic axis, θ_geo becomes ill-defined (polar singularity) - -**Solution**: -- Exclude innermost surfaces: Start grid at s_tor_min = 0.01 (not 0) -- Use L'Hôpital's rule or Taylor expansion for near-axis derivatives -- Alternative: Use Cartesian representation near axis - ---- - -### Challenge 4: Separatrix and SOL -**Issue**: Particles may reach separatrix (ψ = ψ_edge) or beyond - -**Solution**: -- Detect when s_tor > 1.0 (outside separatrix) -- Terminate orbit or extend field to SOL with simple model -- Document limitation in user guide - ---- - -### Challenge 5: COCOS Convention Complications -**Issue**: Different GEQDSK files may use different sign conventions - -**Solution**: -- **Already handled by libneo**: `geqdsk_standardise` converts to COCOS 3 -- Always call standardization after reading -- Include COCOS info in log output for debugging - ---- - -## Testing Strategy - -### Unit Tests (libneo) -- [x] GEQDSK read and standardize -- [ ] Toroidal flux computation from q-profile -- [ ] Flux surface tracing accuracy -- [ ] Coordinate transformation round-trips -- [ ] Field evaluation: verify ∇·B = 0 - -### Integration Tests (SIMPLE) -- [ ] Load GEQDSK and initialize field -- [ ] Single particle orbit (energy conservation) -- [ ] Multiple particles (statistics) -- [ ] Compare geoflux vs Meiss vs Albert - -### Validation Tests -- [ ] Compare with VMEC-based results (if available) -- [ ] Compare with other tokamak codes -- [ ] Analytical test cases (circular tokamak) +## 5. Examples & Documentation +1. **Makefile** `examples/tokamak/Makefile` – ensure targets `all`, `run`, `diag`, `clean` as previously outlined. +2. **README** `examples/tokamak/README.md` must include: + - Download step (`make`). + - Execution (`make run`), expected runtime, note about OpenMP threads. + - Diagnostics (`make diag`), location of generated plots. + - Troubleshooting tips (e.g., missing libneo branch). +3. **Manual run**: follow README to completion; confirm outputs align with documentation. --- +## 6. Final Regression Sweep +1. **Unit tests**: `ctest --output-on-failure` in SIMPLE repo. +2. **System tests**: `./test/golden_record/golden_record.sh main` (ensure 0 exit code). +3. **libneo tests**: `ninja -C ../libneo/build && ctest`. +4. **Clean status**: `git status` (SIMPLE + libneo). +5. **Document** results (changelog, PR summary). -## Dependencies and Prerequisites - -### External Libraries (already available) -- ✓ LAPACK/BLAS (linear algebra) -- ✓ NetCDF (for GEQDSK? No, GEQDSK is ASCII) -- ✓ libneo (GEQDSK tools, splines, field evaluation) - -### Internal Modules (reuse from VMEC implementation) -- ✓ `interpolate` module (batch splines) -- ✓ `psi_transform` module (Albert coordinates) -- ✓ `binsrc_sub` (binary search) -- ✓ Integration infrastructure (ODE solvers, symplectic integrators) - -### New Modules Required -- [ ] `geoflux_coordinates` (Phase 1.1) -- [ ] `geoflux_field` (Phase 1.2) -- [ ] `field_geqdsk` (Phase 2.1) -- [ ] `field_can_meiss_geoflux` (Phase 3.1) -- [ ] `field_can_albert_geoflux` (Phase 3.2) - ---- - -## File Structure Summary - -### libneo (new files) -``` -../libneo/ -├── src/ -│ ├── coordinates/ -│ │ └── geoflux_coordinates.f90 [NEW - Phase 1.1] -│ └── magfie/ -│ └── geoflux_field.f90 [NEW - Phase 1.2] -└── test/ - └── source/ - └── test_geoflux.f90 [NEW - Phase 4.1] -``` - -### SIMPLE (new files) -``` -SIMPLE/ -├── src/ -│ ├── field/ -│ │ ├── field_geqdsk.f90 [NEW - Phase 2.1] -│ │ ├── field_can_meiss_geoflux.f90 [NEW - Phase 3.1] -│ │ └── field_can_albert_geoflux.f90 [NEW - Phase 3.2] -│ ├── field.F90 [MODIFY - Phase 2.2] -│ ├── coordinates/ -│ │ └── coordinates.f90 [MODIFY - Phase 2.3] -│ └── simple.f90 [MODIFY - Phase 3.3] -├── test/ -│ ├── fortran/ -│ │ ├── test_geqdsk_field.f90 [NEW - Phase 4.2] -│ │ └── test_can_geoflux.f90 [NEW - Phase 4.2] -│ └── data/ -│ └── geqdsk/ -│ └── test.geqdsk [SYMLINK to libneo] -└── examples/ - ├── simple_geqdsk.in [NEW - Phase 5.2] - └── example_geqdsk.f90 [NEW - Phase 5.3] -``` - ---- - -## Key Design Decisions - -### 1. Why implement in libneo? -**Rationale**: Follow VMEC pattern where coordinate transformations live in libneo, allowing reuse across multiple codes. - -### 2. Why "geoflux" name? -**Rationale**: -- Emphasizes **geometric** poloidal angle (not Boozer/Hamada) -- Combines with toroidal **flux** as radial coordinate -- Distinguishes from VMEC's Fourier-based coordinates - -### 3. Why support both Meiss and Albert? -**Rationale**: -- **Meiss**: Uses toroidal flux s_tor (natural for energy conservation) -- **Albert**: Uses poloidal flux ψ_pol (natural for GEQDSK representation) -- Gives users flexibility depending on application - -### 4. Why cache flux surfaces? -**Rationale**: -- Newton iteration for (s_tor, θ_geo) → (R, Z) is expensive (~10-20 iterations) -- Pre-computing on grid reduces to 2D spline evaluation (~10× faster) -- Memory cost is negligible (<10 MB for typical grids) - ---- - -## Success Criteria - -### Milestone 1: Basic Functionality -- [ ] Load GEQDSK file and trace particle orbits -- [ ] Output matches expected tokamak behavior (banana orbits, passing orbits) -- [ ] Passes unit tests for coordinate transformations - -### Milestone 2: Canonical Coordinates -- [ ] Meiss and Albert coordinates implemented -- [ ] Hamiltonian conserved to machine precision -- [ ] Symplectic structure preserved (Poincaré return map) - -### Milestone 3: Production Ready -- [ ] Full test suite passing (unit + integration + validation) -- [ ] Documentation complete -- [ ] Performance comparable to VMEC-based workflow - ---- - -## Future Extensions - -### Included via field_divB0: -- **Non-axisymmetric perturbations**: 3D fields (RMPs, TBMs, error fields) on top of GEQDSK - - Supported through `field_divB0` module's superposition capability - - Reference coordinates remain axisymmetric (based on equilibrium) - - Perturbations affect field evaluation but not coordinate system - -### Out of Scope: -- **Kinetic MHD equilibria**: Pressure anisotropy, flows -- **Real-time equilibrium reconstruction**: EFIT/LIUQE interface -- **Scrape-off layer**: Extend field beyond separatrix - -These could be added later as separate features building on the geoflux foundation. - ---- - -## References - -### Code References -- SIMPLE field system: `src/field/field_base.f90`, `src/field/field_vmec.f90` -- SIMPLE canonical coordinates: `src/field/field_can_*.f90` -- libneo VMEC: `../libneo/src/coordinates/vmec_coordinates.f90` -- libneo GEQDSK: `../libneo/src/magfie/geqdsk_tools.f90` - -### Scientific References -- Meiss coordinates: [cite canonical coordinate papers] -- Albert coordinates: [cite geoflux canonical coordinate papers] -- GEQDSK format: EFIT documentation -- COCOS conventions: O. Sauter & S.Yu. Medvedev, Comput. Phys. Commun. 184, 293 (2013) - ---- - -## Notes - -- This TODO assumes familiarity with the existing SIMPLE and libneo architecture -- Adjust grid resolutions (ns, ntheta) based on performance testing -- Consider parallelizing flux surface tracing if it becomes a bottleneck -- Keep GEQDSK-specific code isolated for maintainability \ No newline at end of file +Completion criteria: unit & system tests all green, new golden record case passes, diagnostics produce EQDSK Meiss plots, documentation updated. diff --git a/cmake/Util.cmake b/cmake/Util.cmake index 83d5ccae..4ea35d79 100644 --- a/cmake/Util.cmake +++ b/cmake/Util.cmake @@ -1,5 +1,4 @@ include(FetchContent) - function(get_branch_or_main REPO_URL REMOTE_BRANCH) execute_process( COMMAND git rev-parse --abbrev-ref HEAD diff --git a/examples/tokamak/.gitignore b/examples/tokamak/.gitignore new file mode 100644 index 00000000..c68efbf8 --- /dev/null +++ b/examples/tokamak/.gitignore @@ -0,0 +1 @@ +EQDSK_I.geqdsk diff --git a/examples/tokamak/Makefile b/examples/tokamak/Makefile new file mode 100644 index 00000000..f12ead21 --- /dev/null +++ b/examples/tokamak/Makefile @@ -0,0 +1,19 @@ +EQDSK := EQDSK_I.geqdsk +URL := https://crppwww.epfl.ch/~sauter/benchmark/EQDSK_I +RUN := ../../build/simple.x + +.PHONY: all run clean + +all: $(EQDSK) + +$(EQDSK): + @printf 'Downloading %s...\n' $(EQDSK) + @curl -L --fail --show-error --output $(EQDSK).tmp $(URL) + @mv $(EQDSK).tmp $(EQDSK) + @printf 'Saved %s\n' $(EQDSK) + +run: $(EQDSK) + @$(RUN) simple.in + +clean: + @rm -f $(EQDSK) diff --git a/examples/tokamak/simple.in b/examples/tokamak/simple.in new file mode 100644 index 00000000..364e95a6 --- /dev/null +++ b/examples/tokamak/simple.in @@ -0,0 +1,7 @@ +&config +trace_time = 1d-2 +sbeg = 0.3d0 +ntestpart = 32 +netcdffile = 'EQDSK_I.geqdsk' +isw_field_type = 1 +/ diff --git a/fpm.toml b/fpm.toml index f227394f..35d75d70 100644 --- a/fpm.toml +++ b/fpm.toml @@ -6,7 +6,7 @@ maintainer = "albert@tugraz.at" copyright = "Copyright 2025, Plasma Physics at TU Graz" [dependencies] -libneo.git = "https://github.com/itpplasma/libneo.git" +libneo.git = { url = "https://github.com/itpplasma/libneo.git", branch = "tokamak" } openmp = "*" hdf5 = "*" netcdf = "*" diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 2dc64993..bceb83c9 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -11,6 +11,7 @@ set(SOURCES field/field_coils.f90 field/field_vmec.f90 field/field_splined.f90 + field/field_geoflux.f90 field/vmec_field_eval.f90 field/field_newton.F90 field.F90 @@ -68,6 +69,20 @@ endif() add_library (simple STATIC ${SOURCES}) +set(_magfie_stamp "${CMAKE_BINARY_DIR}/magfie_modules.stamp") +add_custom_command( + OUTPUT ${_magfie_stamp} + COMMAND ${CMAKE_COMMAND} -E touch ${_magfie_stamp} + DEPENDS magfie +) +add_custom_target(prepare_magfie_modules DEPENDS ${_magfie_stamp}) + +set_source_files_properties(field/field_geoflux.f90 + PROPERTIES OBJECT_DEPENDS ${_magfie_stamp}) +set_source_files_properties(coordinates/coordinates.f90 + PROPERTIES OBJECT_DEPENDS ${_magfie_stamp}) + + # Link pyplot library to SIMPLE target_link_libraries(simple PUBLIC pyplot) @@ -88,8 +103,11 @@ target_link_libraries(simple PUBLIC target_link_libraries(simple PUBLIC LIBNEO::neo + LIBNEO::magfie ) +add_dependencies(simple neo prepare_magfie_modules) + # Conditionally link GVEC if enabled if(ENABLE_GVEC) target_include_directories(simple PRIVATE diff --git a/src/coordinates/coordinates.f90 b/src/coordinates/coordinates.f90 index 11621563..c6ad2b03 100644 --- a/src/coordinates/coordinates.f90 +++ b/src/coordinates/coordinates.f90 @@ -4,6 +4,9 @@ module simple_coordinates use vmec_coordinates, only: vmec_to_cyl_lib => vmec_to_cyl, & vmec_to_cart_lib => vmec_to_cart, & cyl_to_cart_lib => cyl_to_cart + use geoflux_coordinates, only: geoflux_to_cyl_lib => geoflux_to_cyl, & + geoflux_to_cart_lib => geoflux_to_cart, & + cyl_to_geoflux_lib => cyl_to_geoflux implicit none @@ -45,6 +48,33 @@ subroutine transform_cyl_to_cart(xfrom, xto, dxto_dxfrom) end subroutine transform_cyl_to_cart +subroutine transform_geoflux_to_cyl(xfrom, xto, dxto_dxfrom) + real(dp), intent(in) :: xfrom(3) + real(dp), intent(out) :: xto(3) + real(dp), intent(out), optional :: dxto_dxfrom(3,3) + + call geoflux_to_cyl_lib(xfrom, xto, dxto_dxfrom) +end subroutine transform_geoflux_to_cyl + + +subroutine transform_geoflux_to_cart(xfrom, xto, dxto_dxfrom) + real(dp), intent(in) :: xfrom(3) + real(dp), intent(out) :: xto(3) + real(dp), intent(out), optional :: dxto_dxfrom(3,3) + + call geoflux_to_cart_lib(xfrom, xto, dxto_dxfrom) +end subroutine transform_geoflux_to_cart + + +subroutine transform_cyl_to_geoflux(xfrom, xto, dxto_dxfrom) + real(dp), intent(in) :: xfrom(3) + real(dp), intent(out) :: xto(3) + real(dp), intent(out), optional :: dxto_dxfrom(3,3) + + call cyl_to_geoflux_lib(xfrom, xto, dxto_dxfrom) +end subroutine transform_cyl_to_geoflux + + function get_transform(from, to) procedure(transform_i), pointer :: get_transform character(*), intent(in) :: from, to @@ -56,6 +86,8 @@ function get_transform(from, to) select case (trim(to)) case ('cart') get_transform => transform_cyl_to_cart + case ('geoflux') + get_transform => transform_cyl_to_geoflux case default call handle_transform_error(from, to) end select @@ -68,6 +100,15 @@ function get_transform(from, to) case default call handle_transform_error(from, to) end select + case ('geoflux') + select case (trim(to)) + case ('cyl') + get_transform => transform_geoflux_to_cyl + case ('cart') + get_transform => transform_geoflux_to_cart + case default + call handle_transform_error(from, to) + end select case default print *, "get_transform: Unknown transform from ", from error stop diff --git a/src/field.F90 b/src/field.F90 index d0305197..908da1bb 100644 --- a/src/field.F90 +++ b/src/field.F90 @@ -4,6 +4,8 @@ module field use, intrinsic :: iso_fortran_env, only: dp => real64 use field_base, only: magnetic_field_t use field_vmec, only: vmec_field_t, create_vmec_field + use field_geoflux, only: geoflux_field_t, create_geoflux_field, & + initialize_geoflux_field use field_coils, only: coils_field_t, create_coils_field use field_splined, only: splined_field_t, create_splined_field #ifdef GVEC_AVAILABLE @@ -26,13 +28,18 @@ subroutine field_from_file(filename, field) type(coils_field_t) :: raw_coils type(splined_field_t), allocatable :: splined_coils type(vmec_field_t) :: vmec_field + type(geoflux_field_t) :: geoflux_field #ifdef GVEC_AVAILABLE class(gvec_field_t), allocatable :: gvec_temp #endif stripped_name = strip_directory(filename) - if (endswith(filename, '.nc')) then + if (is_geqdsk(filename)) then + call initialize_geoflux_field(trim(filename)) + call create_geoflux_field(geoflux_field) + allocate(field, source=geoflux_field) + else if (endswith(filename, '.nc')) then call create_vmec_field(vmec_field) allocate(field, source=vmec_field) else if (startswidth(stripped_name, 'coils') .or. & @@ -106,4 +113,33 @@ function strip_directory(filename) end do end function strip_directory + + logical function is_geqdsk(filename) + character(*), intent(in) :: filename + + character(:), allocatable :: lower_name + + lower_name = to_lower(trim(filename)) + + is_geqdsk = endswith(lower_name, '.geqdsk') .or. endswith(lower_name, '.eqdsk') + if (.not. is_geqdsk) then + is_geqdsk = startswidth(strip_directory(lower_name), 'geqdsk') + end if + end function is_geqdsk + + + function to_lower(text) result(lower) + character(*), intent(in) :: text + character(len(text)) :: lower + integer :: i + + lower = text + do i = 1, len(text) + select case (text(i:i)) + case ('A':'Z') + lower(i:i) = achar(iachar(text(i:i)) + 32) + end select + end do + end function to_lower + end module field diff --git a/src/field/field_geoflux.f90 b/src/field/field_geoflux.f90 new file mode 100644 index 00000000..6efe70d6 --- /dev/null +++ b/src/field/field_geoflux.f90 @@ -0,0 +1,96 @@ +module field_geoflux + +use, intrinsic :: iso_fortran_env, only: dp => real64 +use field_base, only: magnetic_field_t +use libneo_coordinates, only: make_geoflux_coordinate_system +use geoflux_field, only: spline_geoflux_data, splint_geoflux_field + +implicit none + +type, extends(magnetic_field_t) :: geoflux_field_t +contains + procedure :: evaluate => geoflux_evaluate +end type geoflux_field_t + +character(len=:), allocatable :: cached_geqdsk +logical :: geoflux_ready = .false. +integer, parameter :: default_ns_cache = 128 +integer, parameter :: default_ntheta_cache = 256 + +contains + +subroutine create_geoflux_field(field) + type(geoflux_field_t), intent(out) :: field + + call make_geoflux_coordinate_system(field%coords) +end subroutine create_geoflux_field + + +subroutine initialize_geoflux_field(geqdsk_file, ns_cache, ntheta_cache) + character(len=*), intent(in) :: geqdsk_file + integer, intent(in), optional :: ns_cache, ntheta_cache + integer :: ns_val, ntheta_val + character(len=:), allocatable :: normalized_path + + ns_val = default_ns_cache + if (present(ns_cache)) ns_val = ns_cache + + ntheta_val = default_ntheta_cache + if (present(ntheta_cache)) ntheta_val = ntheta_cache + + normalized_path = trim(adjustl(geqdsk_file)) + + if (geoflux_ready) then + if (allocated(cached_geqdsk)) then + if (normalized_path == cached_geqdsk) return + end if + end if + + call spline_geoflux_data(normalized_path, ns_val, ntheta_val) + + cached_geqdsk = normalized_path + geoflux_ready = .true. +end subroutine initialize_geoflux_field + + +subroutine geoflux_evaluate(self, x, Acov, hcov, Bmod, sqgBctr) + class(geoflux_field_t), intent(in) :: self + real(dp), intent(in) :: x(3) + real(dp), intent(out) :: Acov(3) + real(dp), intent(out) :: hcov(3) + real(dp), intent(out) :: Bmod + real(dp), intent(out), optional :: sqgBctr(3) + + real(dp) :: geo_s, theta_geo, phi_geo + real(dp) :: Acov_geo(3), hcov_geo(3) + real(dp) :: sqgBctr_geo(3) + real(dp) :: ds_dr + + if (.not. geoflux_ready) then + error stop 'geoflux_field_t: call initialize_geoflux_field before evaluation' + end if + + geo_s = max(0.0_dp, min(1.0_dp, x(1)*x(1))) + theta_geo = x(2) + phi_geo = x(3) + ds_dr = 2.0_dp * x(1) + + if (present(sqgBctr)) then + call splint_geoflux_field(geo_s, theta_geo, phi_geo, Acov_geo, hcov_geo, Bmod, sqgBctr_geo) + else + call splint_geoflux_field(geo_s, theta_geo, phi_geo, Acov_geo, hcov_geo, Bmod) + end if + + Acov = Acov_geo + hcov = hcov_geo + + Acov(1) = Acov(1) * ds_dr + hcov(1) = hcov(1) * ds_dr + + if (present(sqgBctr)) then + sqgBctr = sqgBctr_geo + sqgBctr(1) = sqgBctr(1) * ds_dr + end if +end subroutine geoflux_evaluate + +end module field_geoflux diff --git a/src/magfie.f90 b/src/magfie.f90 index ea20affa..2aaa0123 100644 --- a/src/magfie.f90 +++ b/src/magfie.f90 @@ -3,6 +3,11 @@ module magfie_sub use field_can_meiss, only: magfie_meiss use field_can_albert, only: magfie_albert use magfie_can_boozer_sub, only: magfie_can, magfie_boozer +use util, only: twopi +use, intrinsic :: ieee_arithmetic, only: ieee_is_finite +use field_geoflux, only: geoflux_ready +use geoflux_coordinates, only: geoflux_to_cyl +use geoflux_field, only: splint_geoflux_field implicit none @@ -31,7 +36,7 @@ end subroutine magfie_base procedure(magfie_base), pointer :: magfie => null() -integer, parameter :: TEST=-1, CANFLUX=0, VMEC=1, BOOZER=2, MEISS=3, ALBERT=4 +integer, parameter :: TEST=-1, CANFLUX=0, VMEC=1, BOOZER=2, MEISS=3, ALBERT=4, GEOFLUX=5 contains @@ -43,14 +48,20 @@ subroutine init_magfie(id) magfie => magfie_test case(CANFLUX) magfie => magfie_can - case(VMEC) - magfie => magfie_vmec +case(VMEC) + if (geoflux_ready) then + magfie => magfie_geoflux + else + magfie => magfie_vmec + end if case(BOOZER) magfie => magfie_boozer case(MEISS) magfie => magfie_meiss case(ALBERT) magfie => magfie_albert + case(GEOFLUX) + magfie => magfie_geoflux case default print *,'init_magfie: unknown id ', id error stop @@ -289,4 +300,199 @@ end subroutine magfie_vmec !ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc ! + subroutine magfie_geoflux(x, bmod, sqrtg, bder, hcovar, hctrvr, hcurl) + real(dp), intent(in) :: x(3) + real(dp), intent(out) :: bmod, sqrtg + real(dp), intent(out) :: bder(3), hcovar(3), hctrvr(3), hcurl(3) + + real(dp) :: r, theta, phi + real(dp) :: dr_fwd, dr_bwd, dr_den + real(dp) :: dt_step, dp_step + real(dp) :: bmod_plus, bmod_minus + real(dp) :: bmod_theta_plus, bmod_theta_minus + real(dp) :: bmod_phi_plus, bmod_phi_minus + real(dp) :: hcov_plus(3), hcov_minus(3) + real(dp) :: hcov_theta_plus(3), hcov_theta_minus(3) + real(dp) :: hcov_phi_plus(3), hcov_phi_minus(3) + real(dp) :: basis(3, 3), g(3, 3), ginv(3, 3) + real(dp) :: detg, sqrtg_geom + real(dp) :: dh_dr(3), dh_dt(3), dh_dp(3) + real(dp) :: phi_plus, phi_minus + + r = max(0.0_dp, min(1.0_dp, x(1))) + theta = x(2) + phi = x(3) + + call geoflux_eval_point(r, theta, phi, bmod, hcovar, sqrtg, basis, g, ginv, detg, sqrtg_geom) + + if (sqrtg <= 0.0_dp) sqrtg = max(sqrtg_geom, 1.0d-12) + sqrtg = max(sqrtg, 1.0d-12) + + if (.not. ieee_is_finite(bmod)) then + error stop 'magfie_geoflux: non-finite Bmod' + end if + if (.not. all(ieee_is_finite(hcovar))) then + error stop 'magfie_geoflux: non-finite hcovar' + end if + + dr_fwd = min(1.0d-3, 1.0_dp - r) + dr_bwd = min(1.0d-3, r) + dt_step = 1.0d-3*twopi + dp_step = dt_step/5.0d0 + + call geoflux_eval_basic(r + dr_fwd, theta, phi, bmod_plus, hcov_plus) + call geoflux_eval_basic(r - dr_bwd, theta, phi, bmod_minus, hcov_minus) + + dr_den = dr_fwd + dr_bwd + if (dr_den > 1.0d-12) then + bder(1) = (bmod_plus - bmod_minus)/dr_den + dh_dr = (hcov_plus - hcov_minus)/dr_den + else + bder(1) = 0.0_dp + dh_dr = 0.0_dp + end if + + call geoflux_eval_basic(r, theta + dt_step, phi, bmod_theta_plus, hcov_theta_plus) + call geoflux_eval_basic(r, theta - dt_step, phi, bmod_theta_minus, hcov_theta_minus) + bder(2) = (bmod_theta_plus - bmod_theta_minus)/(2.0_dp*dt_step) + dh_dt = (hcov_theta_plus - hcov_theta_minus)/(2.0_dp*dt_step) + + phi_plus = modulo(phi + dp_step, twopi) + phi_minus = modulo(phi - dp_step, twopi) + call geoflux_eval_basic(r, theta, phi_plus, bmod_phi_plus, hcov_phi_plus) + call geoflux_eval_basic(r, theta, phi_minus, bmod_phi_minus, hcov_phi_minus) + bder(3) = (bmod_phi_plus - bmod_phi_minus)/(2.0_dp*dp_step) + dh_dp = (hcov_phi_plus - hcov_phi_minus)/(2.0_dp*dp_step) + + bder = bder / max(bmod, 1.0d-12) + + hctrvr = matmul(ginv, hcovar) + + if (sqrtg > 0.0_dp) then + hcurl(1) = (dh_dp(3) - dh_dt(3))/sqrtg + hcurl(2) = (dh_dp(1) - dh_dr(3))/sqrtg + hcurl(3) = (dh_dr(2) - dh_dt(1))/sqrtg + else + hcurl = 0.0_dp + end if + + end subroutine magfie_geoflux + + subroutine geoflux_eval_point(r, theta, phi, bmod, hcov, sqrtg, basis, g, ginv, detg, sqrtg_geom) + real(dp), intent(in) :: r, theta, phi + real(dp), intent(out) :: bmod, hcov(3), sqrtg + real(dp), intent(out) :: basis(3, 3), g(3, 3), ginv(3, 3) + real(dp), intent(out) :: detg, sqrtg_geom + real(dp) :: xcyl(3), jac(3, 3) + real(dp) :: dRdr, dZdr, dRdtheta, dZdtheta, dRdphi, dZdphi + real(dp) :: cosphi, sinphi, ds_dr + real(dp) :: cross12(3) + + call geoflux_eval_basic(r, theta, phi, bmod, hcov, sqrtg, xcyl, jac) + + ds_dr = max(2.0_dp*max(r, 0.0_dp), 1.0d-8) + cosphi = cos(xcyl(2)) + sinphi = sin(xcyl(2)) + + dRdr = jac(1, 1) * ds_dr + dZdr = jac(3, 1) * ds_dr + dRdtheta = jac(1, 2) + dZdtheta = jac(3, 2) + dRdphi = jac(1, 3) + dZdphi = jac(3, 3) + + basis(:, 1) = (/ dRdr * cosphi, dRdr * sinphi, dZdr /) + basis(:, 2) = (/ dRdtheta * cosphi, dRdtheta * sinphi, dZdtheta /) + basis(:, 3) = (/ dRdphi * cosphi - xcyl(1) * sinphi, & + dRdphi * sinphi + xcyl(1) * cosphi, dZdphi /) + + call compute_metric(basis, g, ginv, detg) + call cross_product(basis(:, 2), basis(:, 3), cross12) + sqrtg_geom = abs(dot_product(basis(:, 1), cross12)) + sqrtg = max(sqrtg, sqrtg_geom) + end subroutine geoflux_eval_point + + subroutine geoflux_eval_basic(r, theta, phi, bmod, hcov, sqrtg, xcyl, jac) + real(dp), intent(in) :: r, theta, phi + real(dp), intent(out) :: bmod, hcov(3) + real(dp), intent(out), optional :: sqrtg + real(dp), intent(out), optional :: xcyl(3), jac(3, 3) + + real(dp) :: r_clip, s_geo, ds_dr + real(dp) :: sqg_tmp(3) + real(dp) :: acov_tmp(3), hcov_tmp(3) + real(dp) :: cyl_tmp(3), jac_tmp(3, 3) + + r_clip = max(0.0_dp, min(1.0_dp, r)) + s_geo = r_clip * r_clip + + call geoflux_to_cyl((/ s_geo, theta, phi /), cyl_tmp, jac_tmp) + call splint_geoflux_field(s_geo, theta, phi, acov_tmp, hcov_tmp, bmod, sqg_tmp) + + ds_dr = max(2.0_dp * max(r_clip, 0.0_dp), 1.0d-8) + hcov(1) = hcov_tmp(1) * ds_dr + hcov(2) = hcov_tmp(2) + hcov(3) = hcov_tmp(3) + + if (present(sqrtg)) sqrtg = abs(sqg_tmp(1) * ds_dr) + if (present(xcyl)) xcyl = cyl_tmp + if (present(jac)) jac = jac_tmp + end subroutine geoflux_eval_basic + + subroutine compute_metric(basis, g, ginv, detg) + real(dp), intent(in) :: basis(3, 3) + real(dp), intent(out) :: g(3, 3), ginv(3, 3) + real(dp), intent(out) :: detg + integer :: i, j + + do i = 1, 3 + do j = 1, 3 + g(i, j) = dot_product(basis(:, i), basis(:, j)) + end do + end do + + call invert3x3(g, ginv, detg) + if (abs(detg) < 1.0d-16) then + detg = 1.0d0 + ginv = 0.0_dp + do i = 1, 3 + ginv(i, i) = 1.0_dp + end do + end if + end subroutine compute_metric + + subroutine cross_product(a, b, c) + real(dp), intent(in) :: a(3), b(3) + real(dp), intent(out) :: c(3) + c(1) = a(2) * b(3) - a(3) * b(2) + c(2) = a(3) * b(1) - a(1) * b(3) + c(3) = a(1) * b(2) - a(2) * b(1) + end subroutine cross_product + + subroutine invert3x3(a, ainv, det) + real(dp), intent(in) :: a(3, 3) + real(dp), intent(out) :: ainv(3, 3) + real(dp), intent(out) :: det + + det = a(1, 1) * (a(2, 2) * a(3, 3) - a(2, 3) * a(3, 2)) & + - a(1, 2) * (a(2, 1) * a(3, 3) - a(2, 3) * a(3, 1)) & + + a(1, 3) * (a(2, 1) * a(3, 2) - a(2, 2) * a(3, 1)) + + if (abs(det) < 1.0d-16) then + det = 0.0_dp + ainv = 0.0_dp + return + end if + + ainv(1, 1) = (a(2, 2) * a(3, 3) - a(2, 3) * a(3, 2)) / det + ainv(1, 2) = -(a(1, 2) * a(3, 3) - a(1, 3) * a(3, 2)) / det + ainv(1, 3) = (a(1, 2) * a(2, 3) - a(1, 3) * a(2, 2)) / det + ainv(2, 1) = -(a(2, 1) * a(3, 3) - a(2, 3) * a(3, 1)) / det + ainv(2, 2) = (a(1, 1) * a(3, 3) - a(1, 3) * a(3, 1)) / det + ainv(2, 3) = -(a(1, 1) * a(2, 3) - a(1, 3) * a(2, 1)) / det + ainv(3, 1) = (a(2, 1) * a(3, 2) - a(2, 2) * a(3, 1)) / det + ainv(3, 2) = -(a(1, 1) * a(3, 2) - a(1, 2) * a(3, 1)) / det + ainv(3, 3) = (a(1, 1) * a(2, 2) - a(1, 2) * a(2, 1)) / det + end subroutine invert3x3 + end module magfie_sub diff --git a/src/simple.f90 b/src/simple.f90 index efbe54f2..ee4d979c 100644 --- a/src/simple.f90 +++ b/src/simple.f90 @@ -46,13 +46,21 @@ module simple subroutine init_vmec(vmec_file, ans_s, ans_tp, amultharm, fper) use spline_vmec_sub, only : spline_vmec_data, volume_and_B00 use vmecin_sub, only : stevvo + use field, only : is_geqdsk + use field_geoflux, only : initialize_geoflux_field, geoflux_ready + use geoflux_coordinates, only : geoflux_get_axis + use geoflux_field, only : splint_geoflux_field + use new_vmec_stuff_mod, only : nper, rmajor, vmec_B_scale, vmec_RZ_scale character(*), intent(in) :: vmec_file integer, intent(in) :: ans_s, ans_tp, amultharm real(dp), intent(out) :: fper - integer :: L1i - real(dp) :: RT0, R0i, cbfi, bz0i, bf0, volume, B00 + integer :: L1i + real(dp) :: RT0, R0i, cbfi, bz0i, bf0, volume, B00 + real(dp) :: R_axis, Z_axis + real(dp) :: Acov_axis(3), hcov_axis(3), B_axis + real(dp) :: sqg_axis(3) ! TODO: Remove side effects netcdffile = vmec_file @@ -60,11 +68,25 @@ subroutine init_vmec(vmec_file, ans_s, ans_tp, amultharm, fper) ns_tp = ans_tp multharm = amultharm + if (is_geqdsk(vmec_file)) then + call initialize_geoflux_field(vmec_file) + call geoflux_get_axis(R_axis, Z_axis) + nper = 1 + rmajor = R_axis + fper = twopi + vmec_B_scale = 1.0d0 + vmec_RZ_scale = 1.0d0 + call splint_geoflux_field(0.0_dp, 0.0_dp, 0.0_dp, Acov_axis, hcov_axis, B_axis, sqg_axis) + print *, 'GEQDSK equilibrium loaded. R_axis = ', R_axis, ' cm, fper = ', fper + print *, 'B_axis = ', B_axis, ' G' + return + end if + call spline_vmec_data ! initialize splines for VMEC field call stevvo(RT0, R0i, L1i, cbfi, bz0i, bf0) ! initialize periods and major radius fper = twopi/dble(L1i) !<= field period print *, 'R0 = ', RT0, ' cm, fper = ', fper - call volume_and_B00(volume,B00) + call volume_and_B00(volume, B00) print *,'volume = ',volume,' cm^3, B_00 = ',B00,' G' end subroutine init_vmec diff --git a/test/tests/CMakeLists.txt b/test/tests/CMakeLists.txt index f8022206..7d4daa89 100644 --- a/test/tests/CMakeLists.txt +++ b/test/tests/CMakeLists.txt @@ -17,6 +17,18 @@ endif() # Create symlink in test binary directory for backward compatibility file(CREATE_LINK "${WOUT_FILE}" "${CMAKE_CURRENT_BINARY_DIR}/wout.nc" SYMBOLIC) +set(GEOFLUX_FILE "${TEST_DATA_DIR}/EQDSK_I.geqdsk") +set(GEOFLUX_URL "https://crppwww.epfl.ch/~sauter/benchmark/EQDSK_I") + +if(NOT EXISTS "${GEOFLUX_FILE}") + file(MAKE_DIRECTORY "${TEST_DATA_DIR}") + message(STATUS "Downloading GEQDSK test file to ${GEOFLUX_FILE}...") + file(DOWNLOAD "${GEOFLUX_URL}" "${GEOFLUX_FILE}" TLS_VERIFY OFF) + message(STATUS "Downloaded GEQDSK test file") +endif() + +file(CREATE_LINK "${GEOFLUX_FILE}" "${CMAKE_CURRENT_BINARY_DIR}/EQDSK_I.geqdsk" SYMBOLIC) + # Convert VMEC to GVEC format for testing field_vmec vs field_gvec # This requires GVEC Python API or command-line tools if(ENABLE_GVEC) @@ -121,6 +133,27 @@ add_executable (test_coordinates.x test_coordinates.f90) target_link_libraries(test_coordinates.x simple) add_test(NAME test_coordinates COMMAND test_coordinates.x vmec cyl) +add_executable (test_field_geoflux.x test_field_geoflux.f90) +target_link_libraries(test_field_geoflux.x simple) +add_test(NAME test_field_geoflux COMMAND test_field_geoflux.x) +set_tests_properties(test_field_geoflux PROPERTIES + WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} +) + +add_executable (test_field_vmec.x test_field_vmec.f90) +target_link_libraries(test_field_vmec.x simple) +add_test(NAME test_field_vmec COMMAND test_field_vmec.x) +set_tests_properties(test_field_vmec PROPERTIES + WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} +) + +add_executable (test_magfie_geoflux.x test_magfie_geoflux.f90) +target_link_libraries(test_magfie_geoflux.x simple) +add_test(NAME test_magfie_geoflux COMMAND test_magfie_geoflux.x) +set_tests_properties(test_magfie_geoflux PROPERTIES + WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} +) + # Generate GVEC test data file for test_gvec using elliptic tokamak example set(GVEC_TEST_INPUT "${CMAKE_CURRENT_SOURCE_DIR}/../../build/_deps/gvec-src/test-CI/examples/analytic_gs_elliptok/parameter.ini") set(GVEC_TEST_STATE "${CMAKE_CURRENT_SOURCE_DIR}/../../test/test_data/GVEC_elliptok_State_final.dat") diff --git a/test/tests/test_field_geoflux.f90 b/test/tests/test_field_geoflux.f90 new file mode 100644 index 00000000..34765d17 --- /dev/null +++ b/test/tests/test_field_geoflux.f90 @@ -0,0 +1,44 @@ +program test_field_geoflux + + use, intrinsic :: iso_fortran_env, only: dp => real64 + use field, only: field_from_file, MagneticField + use field_geoflux, only: GeofluxField + + implicit none + + class(MagneticField), allocatable :: field_obj + real(dp) :: x(3), Acov(3), hcov(3), Bmod + real(dp) :: norm_sq + character(len=512) :: geqdsk_path + integer :: status + + geqdsk_path = 'EQDSK_I.geqdsk' + call get_environment_variable('LIBNEO_TEST_GEQDSK', value=geqdsk_path, status=status) + if (status /= 0 .or. len_trim(geqdsk_path) == 0) then + geqdsk_path = 'EQDSK_I.geqdsk' + end if + + call field_from_file(trim(geqdsk_path), field_obj) + + select type(field_obj) + type is (GeofluxField) + x = [sqrt(0.25_dp), 0.3_dp, 0.0_dp] + call field_obj%evaluate(x, Acov, hcov, Bmod) + + if (Bmod <= 0.0_dp) then + error stop 'GeofluxField: Bmod must be positive' + end if + + if (abs(Acov(3)) <= 0.0_dp) then + error stop 'GeofluxField: Aphi expected to be non-zero away from axis' + end if + + norm_sq = sum(hcov**2) + if (norm_sq <= 0.0_dp) then + error stop 'GeofluxField: hcov has zero magnitude' + end if + class default + error stop 'field_from_file did not return GeofluxField for GEQDSK input' + end select + +end program test_field_geoflux diff --git a/test/tests/test_field_vmec.f90 b/test/tests/test_field_vmec.f90 new file mode 100644 index 00000000..d39e6a98 --- /dev/null +++ b/test/tests/test_field_vmec.f90 @@ -0,0 +1,33 @@ +program test_field_vmec + + use, intrinsic :: iso_fortran_env, only : dp => real64 + use field, only : field_from_file, MagneticField + use field_vmec, only : VmecField + + implicit none + + class(MagneticField), allocatable :: field_obj + real(dp) :: x(3), Acov(3), hcov(3), Bmod + real(dp) :: norm_sq + + call field_from_file('wout.nc', field_obj) + + select type(field_obj) + type is (VmecField) + x = [sqrt(0.25_dp), 0.1_dp, 0.2_dp] + call field_obj%evaluate(x, Acov, hcov, Bmod) + + if (Bmod <= 0.0_dp) then + error stop 'VmecField: Bmod must be positive' + end if + + norm_sq = sum(hcov**2) + if (abs(norm_sq - 1.0_dp) > 5.0d-10) then + error stop 'VmecField: hcov not normalized' + end if + + class default + error stop 'field_from_file did not return VmecField for VMEC input' + end select + +end program test_field_vmec diff --git a/test/tests/test_magfie_geoflux.f90 b/test/tests/test_magfie_geoflux.f90 new file mode 100644 index 00000000..4ebc8f13 --- /dev/null +++ b/test/tests/test_magfie_geoflux.f90 @@ -0,0 +1,51 @@ +program test_magfie_geoflux + + use, intrinsic :: iso_fortran_env, only : dp => real64 + use, intrinsic :: ieee_arithmetic, only : ieee_is_finite + use simple, only : init_vmec + use magfie_sub, only : init_magfie, VMEC, magfie + use new_vmec_stuff_mod, only : nper + use util, only : twopi + + implicit none + + real(dp) :: fper + real(dp) :: x(3) + real(dp) :: bmod, sqrtg + real(dp) :: bder(3), hcov(3), hctrvr(3), hcurl(3) + integer :: i + + call init_vmec('EQDSK_I.geqdsk', 5, 5, 5, fper) + if (nper /= 1) then + error stop 'test_magfie_geoflux: nper should be 1 for GEQDSK' + end if + + call init_magfie(VMEC) + + call random_seed() + + do i = 1, 128 + call random_number(x) + x(1) = min(0.999_dp, x(1)) + x(2) = (x(2) - 0.5_dp) * twopi + x(3) = x(3) * twopi + call magfie(x, bmod, sqrtg, bder, hcov, hctrvr, hcurl) + + if (bmod <= 0.0_dp) then + error stop 'test_magfie_geoflux: Bmod not positive' + end if + if (.not. ieee_is_finite(sqrtg)) then + error stop 'test_magfie_geoflux: sqrtg not finite' + end if + if (.not. all(ieee_is_finite(hcov))) then + error stop 'test_magfie_geoflux: hcov not finite' + end if + if (.not. all(ieee_is_finite(hctrvr))) then + error stop 'test_magfie_geoflux: hctrvr not finite' + end if + if (.not. all(ieee_is_finite(hcurl))) then + error stop 'test_magfie_geoflux: hcurl not finite' + end if + end do + +end program test_magfie_geoflux From d7975983bff77c1865c9ed4de7cec361dcd20273 Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Sat, 13 Dec 2025 10:13:10 +0100 Subject: [PATCH 05/19] Fix deterministic libneo flags for golden record --- CMakeLists.txt | 64 ++++++++++++++++++++++++------- python/CMakeLists.txt | 6 ++- src/CMakeLists.txt | 15 -------- test/tests/test_field_geoflux.f90 | 17 ++++---- test/tests/test_field_vmec.f90 | 15 ++++---- 5 files changed, 71 insertions(+), 46 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 25884682..7000437d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,20 +14,6 @@ add_compile_options( $<$:-fbacktrace> ) -# Prefer local libneo checkout on sibling path; fall back to configured branch -if (NOT DEFINED libneo_SOURCE_DIR) - set(_local_libneo "${CMAKE_SOURCE_DIR}/../libneo") - if (EXISTS "${_local_libneo}/CMakeLists.txt") - get_filename_component(_libneo_abs "${_local_libneo}" ABSOLUTE) - set(libneo_SOURCE_DIR "${_libneo_abs}") - message(STATUS "Detected local libneo checkout at ${libneo_SOURCE_DIR}") - endif() -endif() - -if (NOT DEFINED LIBNEO_BRANCH) - set(LIBNEO_BRANCH "tokamak" CACHE STRING "libneo branch to fetch when no local checkout is present") -endif() - # Disable executable stack for security (trampolines/closures create execstack) if(CMAKE_Fortran_COMPILER_ID STREQUAL "GNU") # Apple's linker doesn't support -z,noexecstack (uses different security model) @@ -60,6 +46,11 @@ option(ENABLE_GVEC "Enable GVEC field support (experimental)" OFF) option(ENABLE_COVERAGE "Enable code coverage analysis (Debug/Profile builds only)" OFF) option(SIMPLE_DETERMINISTIC_FP "Disable fast-math for reproducible floating-point" OFF) +# Golden record regression tests require deterministic floating-point output. +if (SIMPLE_TESTING) + set(SIMPLE_DETERMINISTIC_FP ON CACHE BOOL "Disable fast-math for reproducible floating-point" FORCE) +endif() + # Conditionally fetch GVEC if enabled if(ENABLE_GVEC) message(STATUS "GVEC support enabled - fetching GVEC library") @@ -106,8 +97,53 @@ add_link_options(${NETCDF_FLIBS}) find_package(BLAS REQUIRED) find_package(LAPACK REQUIRED) +if (SIMPLE_TESTING) + # Golden record tests (and CI) must not silently pick up local checkouts via + # the CODE environment variable, otherwise the current build can differ from + # the reference build even without code changes. + if (DEFINED ENV{CODE} AND NOT "$ENV{CODE}" STREQUAL "") + message(STATUS "SIMPLE_TESTING: ignoring CODE=$ENV{CODE} for reproducible dependency resolution") + set(ENV{CODE} "") + endif() +endif() + find_or_fetch(libneo) +# libneo enables -ffast-math in Release/Fast by default, which breaks strict +# reproducibility. In deterministic mode, strip fast-math options from the libneo +# targets that SIMPLE links against. +if (SIMPLE_DETERMINISTIC_FP AND CMAKE_Fortran_COMPILER_ID STREQUAL "GNU") + function(_simple_strip_libneo_fast_math target_name) + if (NOT TARGET ${target_name}) + return() + endif() + + get_target_property(_opts ${target_name} COMPILE_OPTIONS) + if (NOT _opts) + target_compile_options(${target_name} PRIVATE -ffp-contract=off) + return() + endif() + + set(_new_opts "") + foreach(_opt IN LISTS _opts) + if (_opt STREQUAL "-ffast-math") + continue() + endif() + if (_opt STREQUAL "-ffp-contract=fast") + continue() + endif() + list(APPEND _new_opts "${_opt}") + endforeach() + + set_target_properties(${target_name} PROPERTIES COMPILE_OPTIONS "${_new_opts}") + target_compile_options(${target_name} PRIVATE -ffp-contract=off) + endfunction() + + foreach(_libneo_target neo neo_field interpolate neo_polylag hdf5_tools CONTRIB magfie) + _simple_strip_libneo_fast_math(${_libneo_target}) + endforeach() +endif() + # Fetch fortplot for diagnostic plotting include(FetchContent) FetchContent_Declare( diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt index 554c642e..eaf8f083 100644 --- a/python/CMakeLists.txt +++ b/python/CMakeLists.txt @@ -85,8 +85,10 @@ set(FILES_TO_WRAP magfie_wrapper.f90 ) -# Add libneo files to wrap -if(DEFINED ENV{CODE}) +# Add libneo files to wrap. +# Prefer explicit CODE only when it is non-empty; otherwise use the resolved +# libneo checkout from FetchContent (libneo_SOURCE_DIR). +if(DEFINED ENV{CODE} AND NOT "$ENV{CODE}" STREQUAL "") set(LIBNEO_SOURCE_DIR $ENV{CODE}/libneo) else() set(LIBNEO_SOURCE_DIR ${libneo_SOURCE_DIR}) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index bceb83c9..f2d8e230 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -69,19 +69,6 @@ endif() add_library (simple STATIC ${SOURCES}) -set(_magfie_stamp "${CMAKE_BINARY_DIR}/magfie_modules.stamp") -add_custom_command( - OUTPUT ${_magfie_stamp} - COMMAND ${CMAKE_COMMAND} -E touch ${_magfie_stamp} - DEPENDS magfie -) -add_custom_target(prepare_magfie_modules DEPENDS ${_magfie_stamp}) - -set_source_files_properties(field/field_geoflux.f90 - PROPERTIES OBJECT_DEPENDS ${_magfie_stamp}) -set_source_files_properties(coordinates/coordinates.f90 - PROPERTIES OBJECT_DEPENDS ${_magfie_stamp}) - # Link pyplot library to SIMPLE target_link_libraries(simple PUBLIC pyplot) @@ -106,8 +93,6 @@ target_link_libraries(simple PUBLIC LIBNEO::magfie ) -add_dependencies(simple neo prepare_magfie_modules) - # Conditionally link GVEC if enabled if(ENABLE_GVEC) target_include_directories(simple PRIVATE diff --git a/test/tests/test_field_geoflux.f90 b/test/tests/test_field_geoflux.f90 index 34765d17..beaf4994 100644 --- a/test/tests/test_field_geoflux.f90 +++ b/test/tests/test_field_geoflux.f90 @@ -1,12 +1,13 @@ program test_field_geoflux use, intrinsic :: iso_fortran_env, only: dp => real64 - use field, only: field_from_file, MagneticField - use field_geoflux, only: GeofluxField + use field, only: field_from_file + use field_base, only: magnetic_field_t + use field_geoflux, only: geoflux_field_t implicit none - class(MagneticField), allocatable :: field_obj + class(magnetic_field_t), allocatable :: field_obj real(dp) :: x(3), Acov(3), hcov(3), Bmod real(dp) :: norm_sq character(len=512) :: geqdsk_path @@ -21,24 +22,24 @@ program test_field_geoflux call field_from_file(trim(geqdsk_path), field_obj) select type(field_obj) - type is (GeofluxField) + type is (geoflux_field_t) x = [sqrt(0.25_dp), 0.3_dp, 0.0_dp] call field_obj%evaluate(x, Acov, hcov, Bmod) if (Bmod <= 0.0_dp) then - error stop 'GeofluxField: Bmod must be positive' + error stop 'geoflux_field_t: Bmod must be positive' end if if (abs(Acov(3)) <= 0.0_dp) then - error stop 'GeofluxField: Aphi expected to be non-zero away from axis' + error stop 'geoflux_field_t: Aphi expected to be non-zero away from axis' end if norm_sq = sum(hcov**2) if (norm_sq <= 0.0_dp) then - error stop 'GeofluxField: hcov has zero magnitude' + error stop 'geoflux_field_t: hcov has zero magnitude' end if class default - error stop 'field_from_file did not return GeofluxField for GEQDSK input' + error stop 'field_from_file did not return geoflux_field_t for GEQDSK input' end select end program test_field_geoflux diff --git a/test/tests/test_field_vmec.f90 b/test/tests/test_field_vmec.f90 index d39e6a98..96f68167 100644 --- a/test/tests/test_field_vmec.f90 +++ b/test/tests/test_field_vmec.f90 @@ -1,33 +1,34 @@ program test_field_vmec use, intrinsic :: iso_fortran_env, only : dp => real64 - use field, only : field_from_file, MagneticField - use field_vmec, only : VmecField + use field, only : field_from_file + use field_base, only: magnetic_field_t + use field_vmec, only : vmec_field_t implicit none - class(MagneticField), allocatable :: field_obj + class(magnetic_field_t), allocatable :: field_obj real(dp) :: x(3), Acov(3), hcov(3), Bmod real(dp) :: norm_sq call field_from_file('wout.nc', field_obj) select type(field_obj) - type is (VmecField) + type is (vmec_field_t) x = [sqrt(0.25_dp), 0.1_dp, 0.2_dp] call field_obj%evaluate(x, Acov, hcov, Bmod) if (Bmod <= 0.0_dp) then - error stop 'VmecField: Bmod must be positive' + error stop 'vmec_field_t: Bmod must be positive' end if norm_sq = sum(hcov**2) if (abs(norm_sq - 1.0_dp) > 5.0d-10) then - error stop 'VmecField: hcov not normalized' + error stop 'vmec_field_t: hcov not normalized' end if class default - error stop 'field_from_file did not return VmecField for VMEC input' + error stop 'field_from_file did not return vmec_field_t for VMEC input' end select end program test_field_vmec From 2866bff13c4b9afb170cc9298b242731ff83950f Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Sat, 13 Dec 2025 11:03:46 +0100 Subject: [PATCH 06/19] Fix geoflux reference coordinates for RK45 --- src/coordinates/reference_coordinates.f90 | 106 ++- src/field/field_geoflux.f90 | 152 ++-- src/magfie.f90 | 1000 +++++++++++---------- test/tests/test_field_geoflux.f90 | 2 +- 4 files changed, 672 insertions(+), 588 deletions(-) diff --git a/src/coordinates/reference_coordinates.f90 b/src/coordinates/reference_coordinates.f90 index 3690787c..9a2917c4 100644 --- a/src/coordinates/reference_coordinates.f90 +++ b/src/coordinates/reference_coordinates.f90 @@ -1,29 +1,101 @@ module reference_coordinates - use, intrinsic :: iso_fortran_env, only : dp => real64 - use libneo_coordinates, only : coordinate_system_t, make_vmec_coordinate_system + use, intrinsic :: iso_fortran_env, only: dp => real64 + use libneo_coordinates, only: coordinate_system_t, make_vmec_coordinate_system, & + make_geoflux_coordinate_system - implicit none + implicit none - class(coordinate_system_t), allocatable, public :: ref_coords + class(coordinate_system_t), allocatable, public :: ref_coords contains - subroutine init_reference_coordinates(coord_input) - character(*), intent(in) :: coord_input + subroutine init_reference_coordinates(coord_input) + character(*), intent(in) :: coord_input - if (len_trim(coord_input) == 0) then - print *, 'reference_coordinates.init_reference_coordinates: ', & - 'coord_input must be set (see params.apply_config_aliases)' - error stop - end if + if (len_trim(coord_input) == 0) then + print *, 'reference_coordinates.init_reference_coordinates: ', & + 'coord_input must be set (see params.apply_config_aliases)' + error stop + end if - if (allocated(ref_coords)) deallocate(ref_coords) + if (allocated(ref_coords)) deallocate (ref_coords) - ! For now we always use VMEC reference coordinates. The params module - ! is responsible for resolving coord_input versus legacy netcdffile - ! and field_input; here we only rely on the final coord_input value. - call make_vmec_coordinate_system(ref_coords) - end subroutine init_reference_coordinates + if (is_geqdsk_name(coord_input)) then + call make_geoflux_coordinate_system(ref_coords) + else + call make_vmec_coordinate_system(ref_coords) + end if + end subroutine init_reference_coordinates + + logical function is_geqdsk_name(filename) + character(*), intent(in) :: filename + + character(:), allocatable :: lower_name + + lower_name = to_lower(trim(filename)) + + is_geqdsk_name = endswith(lower_name, '.geqdsk') .or. & + endswith(lower_name, '.eqdsk') + if (.not. is_geqdsk_name) then + is_geqdsk_name = startswith(strip_directory(lower_name), 'geqdsk') + end if + end function is_geqdsk_name + + logical function startswith(text, start) + character(*), intent(in) :: text + character(*), intent(in) :: start + integer :: len_text, len_start + + len_text = len_trim(text) + len_start = len_trim(start) + + startswith = .false. + if (len_text >= len_start) then + startswith = (text(1:len_start) == start) + end if + end function startswith + + logical function endswith(text, ending) + character(*), intent(in) :: text + character(*), intent(in) :: ending + integer :: len_text, len_end + + len_text = len_trim(text) + len_end = len_trim(ending) + + endswith = .false. + if (len_text >= len_end) then + endswith = (text(len_text - len_end + 1:len_text) == ending) + end if + end function endswith + + function strip_directory(filename) + character(*), intent(in) :: filename + character(len(filename)) :: strip_directory + integer :: i + + strip_directory = filename + do i = len(filename), 1, -1 + if (filename(i:i) == '/') then + strip_directory = filename(i + 1:len(filename)) + return + end if + end do + end function strip_directory + + function to_lower(text) result(lower) + character(*), intent(in) :: text + character(len(text)) :: lower + integer :: i + + lower = text + do i = 1, len(text) + select case (text(i:i)) + case ('A':'Z') + lower(i:i) = achar(iachar(text(i:i)) + 32) + end select + end do + end function to_lower end module reference_coordinates diff --git a/src/field/field_geoflux.f90 b/src/field/field_geoflux.f90 index 6efe70d6..6463f7c2 100644 --- a/src/field/field_geoflux.f90 +++ b/src/field/field_geoflux.f90 @@ -1,96 +1,92 @@ module field_geoflux -use, intrinsic :: iso_fortran_env, only: dp => real64 -use field_base, only: magnetic_field_t -use libneo_coordinates, only: make_geoflux_coordinate_system -use geoflux_field, only: spline_geoflux_data, splint_geoflux_field + use, intrinsic :: iso_fortran_env, only: dp => real64 + use field_base, only: magnetic_field_t + use libneo_coordinates, only: make_geoflux_coordinate_system + use geoflux_field, only: spline_geoflux_data, splint_geoflux_field -implicit none + implicit none -type, extends(magnetic_field_t) :: geoflux_field_t -contains - procedure :: evaluate => geoflux_evaluate -end type geoflux_field_t + type, extends(magnetic_field_t) :: geoflux_field_t + contains + procedure :: evaluate => geoflux_evaluate + end type geoflux_field_t -character(len=:), allocatable :: cached_geqdsk -logical :: geoflux_ready = .false. -integer, parameter :: default_ns_cache = 128 -integer, parameter :: default_ntheta_cache = 256 + character(len=:), allocatable :: cached_geqdsk + logical :: geoflux_ready = .false. + integer, parameter :: default_ns_cache = 128 + integer, parameter :: default_ntheta_cache = 256 contains -subroutine create_geoflux_field(field) - type(geoflux_field_t), intent(out) :: field + subroutine create_geoflux_field(field) + type(geoflux_field_t), intent(out) :: field + + call make_geoflux_coordinate_system(field%coords) + end subroutine create_geoflux_field + + subroutine initialize_geoflux_field(geqdsk_file, ns_cache, ntheta_cache) + character(len=*), intent(in) :: geqdsk_file + integer, intent(in), optional :: ns_cache, ntheta_cache + integer :: ns_val, ntheta_val + character(len=:), allocatable :: normalized_path - call make_geoflux_coordinate_system(field%coords) -end subroutine create_geoflux_field + ns_val = default_ns_cache + if (present(ns_cache)) ns_val = ns_cache + ntheta_val = default_ntheta_cache + if (present(ntheta_cache)) ntheta_val = ntheta_cache + + normalized_path = trim(adjustl(geqdsk_file)) + + if (geoflux_ready) then + if (allocated(cached_geqdsk)) then + if (normalized_path == cached_geqdsk) return + end if + end if -subroutine initialize_geoflux_field(geqdsk_file, ns_cache, ntheta_cache) - character(len=*), intent(in) :: geqdsk_file - integer, intent(in), optional :: ns_cache, ntheta_cache - integer :: ns_val, ntheta_val - character(len=:), allocatable :: normalized_path + call spline_geoflux_data(normalized_path, ns_val, ntheta_val) - ns_val = default_ns_cache - if (present(ns_cache)) ns_val = ns_cache + cached_geqdsk = normalized_path + geoflux_ready = .true. + end subroutine initialize_geoflux_field - ntheta_val = default_ntheta_cache - if (present(ntheta_cache)) ntheta_val = ntheta_cache + subroutine geoflux_evaluate(self, x, Acov, hcov, Bmod, sqgBctr) + class(geoflux_field_t), intent(in) :: self + real(dp), intent(in) :: x(3) + real(dp), intent(out) :: Acov(3) + real(dp), intent(out) :: hcov(3) + real(dp), intent(out) :: Bmod + real(dp), intent(out), optional :: sqgBctr(3) + + real(dp) :: geo_s, theta_geo, phi_geo + real(dp) :: Acov_geo(3), hcov_geo(3) + real(dp) :: sqgBctr_geo(3) + + if (.not. geoflux_ready) then + error stop & + 'geoflux_field_t: call initialize_geoflux_field before evaluation' + end if + + geo_s = max(0.0_dp, min(1.0_dp, x(1))) + theta_geo = x(2) + phi_geo = x(3) + + if (present(sqgBctr)) then + call splint_geoflux_field(geo_s, theta_geo, phi_geo, Acov_geo, & + hcov_geo, Bmod, & + sqgBctr_geo) + else + call splint_geoflux_field(geo_s, theta_geo, phi_geo, Acov_geo, & + hcov_geo, Bmod) + end if - normalized_path = trim(adjustl(geqdsk_file)) + Acov = Acov_geo + hcov = hcov_geo - if (geoflux_ready) then - if (allocated(cached_geqdsk)) then - if (normalized_path == cached_geqdsk) return + if (present(sqgBctr)) then + sqgBctr = sqgBctr_geo end if - end if - - call spline_geoflux_data(normalized_path, ns_val, ntheta_val) - - cached_geqdsk = normalized_path - geoflux_ready = .true. -end subroutine initialize_geoflux_field - - -subroutine geoflux_evaluate(self, x, Acov, hcov, Bmod, sqgBctr) - class(geoflux_field_t), intent(in) :: self - real(dp), intent(in) :: x(3) - real(dp), intent(out) :: Acov(3) - real(dp), intent(out) :: hcov(3) - real(dp), intent(out) :: Bmod - real(dp), intent(out), optional :: sqgBctr(3) - - real(dp) :: geo_s, theta_geo, phi_geo - real(dp) :: Acov_geo(3), hcov_geo(3) - real(dp) :: sqgBctr_geo(3) - real(dp) :: ds_dr - - if (.not. geoflux_ready) then - error stop 'geoflux_field_t: call initialize_geoflux_field before evaluation' - end if - - geo_s = max(0.0_dp, min(1.0_dp, x(1)*x(1))) - theta_geo = x(2) - phi_geo = x(3) - ds_dr = 2.0_dp * x(1) - - if (present(sqgBctr)) then - call splint_geoflux_field(geo_s, theta_geo, phi_geo, Acov_geo, hcov_geo, Bmod, sqgBctr_geo) - else - call splint_geoflux_field(geo_s, theta_geo, phi_geo, Acov_geo, hcov_geo, Bmod) - end if - - Acov = Acov_geo - hcov = hcov_geo - - Acov(1) = Acov(1) * ds_dr - hcov(1) = hcov(1) * ds_dr - - if (present(sqgBctr)) then - sqgBctr = sqgBctr_geo - sqgBctr(1) = sqgBctr(1) * ds_dr - end if -end subroutine geoflux_evaluate + end subroutine geoflux_evaluate end module field_geoflux diff --git a/src/magfie.f90 b/src/magfie.f90 index 2aaa0123..4ebd0593 100644 --- a/src/magfie.f90 +++ b/src/magfie.f90 @@ -1,498 +1,514 @@ module magfie_sub -use spline_vmec_sub, only: vmec_field -use field_can_meiss, only: magfie_meiss -use field_can_albert, only: magfie_albert -use magfie_can_boozer_sub, only: magfie_can, magfie_boozer -use util, only: twopi -use, intrinsic :: ieee_arithmetic, only: ieee_is_finite -use field_geoflux, only: geoflux_ready -use geoflux_coordinates, only: geoflux_to_cyl -use geoflux_field, only: splint_geoflux_field - -implicit none + use spline_vmec_sub, only: vmec_field + use field_can_meiss, only: magfie_meiss + use field_can_albert, only: magfie_albert + use magfie_can_boozer_sub, only: magfie_can, magfie_boozer + use util, only: twopi + use, intrinsic :: ieee_arithmetic, only: ieee_is_finite + use field_geoflux, only: geoflux_ready + use geoflux_coordinates, only: geoflux_to_cyl + use geoflux_field, only: splint_geoflux_field + + implicit none ! Define real(dp) kind parameter -integer, parameter :: dp = kind(1.0d0) - -abstract interface - subroutine magfie_base(x,bmod,sqrtg,bder,hcovar,hctrvr,hcurl) - import :: dp - ! x(i) - set of 3 curvilinear space coordinates (input) - ! bmod - dimensionless magnetic field module: bmod=B/B_ref - ! sqrtg - Jacobian of space coordinates (square root of - ! metric tensor - ! bder - derivatives of logarithm of bmod over space coords - ! (covariant vector) - ! hcovar - covariant components of the unit vector along - ! the magnetic field - ! hctrvr - contravariant components of the unit vector along - ! the magnetic field - ! hcurl - contravariant components of the curl of this vector - real(dp), intent(in) :: x(3) - real(dp), intent(out) :: bmod,sqrtg - real(dp), intent(out) :: bder(3),hcovar(3),hctrvr(3),hcurl(3) - end subroutine magfie_base -end interface - -procedure(magfie_base), pointer :: magfie => null() - -integer, parameter :: TEST=-1, CANFLUX=0, VMEC=1, BOOZER=2, MEISS=3, ALBERT=4, GEOFLUX=5 + integer, parameter :: dp = kind(1.0d0) + + abstract interface + subroutine magfie_base(x, bmod, sqrtg, bder, hcovar, hctrvr, hcurl) + import :: dp + ! x(i) - set of 3 curvilinear space coordinates (input) + ! bmod - dimensionless magnetic field module: bmod=B/B_ref + ! sqrtg - Jacobian of space coordinates (square root of + ! metric tensor + ! bder - derivatives of logarithm of bmod over space coords + ! (covariant vector) + ! hcovar - covariant components of the unit vector along + ! the magnetic field + ! hctrvr - contravariant components of the unit vector along + ! the magnetic field + ! hcurl - contravariant components of the curl of this vector + real(dp), intent(in) :: x(3) + real(dp), intent(out) :: bmod, sqrtg + real(dp), intent(out) :: bder(3), hcovar(3), hctrvr(3), hcurl(3) + end subroutine magfie_base + end interface + + procedure(magfie_base), pointer :: magfie => null() + + integer, parameter :: TEST = -1, CANFLUX = 0, VMEC = 1, BOOZER = 2, MEISS = & + 3, ALBERT & + = 4, GEOFLUX = 5 contains -subroutine init_magfie(id) - integer, intent(in) :: id - - select case(id) - case(TEST) - magfie => magfie_test - case(CANFLUX) - magfie => magfie_can -case(VMEC) - if (geoflux_ready) then - magfie => magfie_geoflux - else - magfie => magfie_vmec - end if - case(BOOZER) - magfie => magfie_boozer - case(MEISS) - magfie => magfie_meiss - case(ALBERT) - magfie => magfie_albert - case(GEOFLUX) - magfie => magfie_geoflux - case default - print *,'init_magfie: unknown id ', id - error stop - end select -end subroutine init_magfie - - -subroutine magfie_test(x, bmod, sqrtg, bder, hcovar, hctrvr, hcurl) - !> Magnetic field for analytic circular tokamak (TEST field). - !> Coordinates: x(1)=r (minor radius), x(2)=theta (poloidal), x(3)=phi (toroidal) - !> Uses same geometry as field_can_test: B0=1, R0=1, a=0.5, iota=1 - !> - !> WARNING: hcurl is set to zero (curvature drift not computed). - !> This is acceptable for symplectic integration (integmode > 0) which uses - !> field_can_test instead. For RK45 integration (integmode=0), curvature - !> drift would be missing - use symplectic integration with TEST field. - implicit none - - real(dp), intent(in) :: x(3) - real(dp), intent(out) :: bmod, sqrtg, bder(3), hcovar(3), hctrvr(3), hcurl(3) - - real(dp), parameter :: B0 = 1.0_dp, R0 = 1.0_dp, a = 0.5_dp, iota0 = 1.0_dp - real(dp) :: r, theta, cth, sth, R_cyl, dBmod_dr, dBmod_dth - - r = x(1) - theta = x(2) - cth = cos(theta) - sth = sin(theta) - - ! Major radius at this point - R_cyl = R0 + r * cth - - ! Magnetic field magnitude: B = B0 * (1 - r/R0 * cos(theta)) - bmod = B0 * (1.0_dp - r / R0 * cth) - - ! Jacobian sqrt(g) = r * R for circular tokamak - sqrtg = r * R_cyl - - ! Derivatives of log(B) - dBmod_dr = -B0 / R0 * cth - dBmod_dth = B0 * r / R0 * sth - bder(1) = dBmod_dr / bmod - bder(2) = dBmod_dth / bmod - bder(3) = 0.0_dp - - ! Covariant components of unit vector h = B/|B| - ! In (r, theta, phi) coordinates for circular tokamak with iota=1 - hcovar(1) = 0.0_dp - hcovar(2) = iota0 * (1.0_dp - r**2 / a**2) * r**2 / R0 / bmod - hcovar(3) = R_cyl / bmod - - ! Contravariant components - hctrvr(1) = 0.0_dp - hctrvr(2) = B0 * iota0 / (r * R_cyl * bmod) - hctrvr(3) = B0 / (r * R_cyl * bmod) - - ! Curl of h (simplified - not fully computed for TEST field) - hcurl(1) = 0.0_dp - hcurl(2) = 0.0_dp - hcurl(3) = 0.0_dp - -end subroutine magfie_test - - - !ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc - ! - subroutine magfie_vmec(x,bmod,sqrtg,bder,hcovar,hctrvr,hcurl) - ! - ! Computes magnetic field module in units of the magnetic code - bmod, - ! square root of determinant of the metric tensor - sqrtg, - ! derivatives of the logarythm of the magnetic field module - ! over coordinates - bder, - ! covariant componets of the unit vector of the magnetic - ! field direction - hcovar, - ! contravariant components of this vector - hctrvr, - ! contravariant component of the curl of this vector - hcurl - ! Order of coordinates is the following: x(1)=s (normalized toroidal flux), - ! x(2)=theta (VMEC poloidal angle), x(3)=varphi (geometrical toroidal angle). - ! - ! Input parameters: - ! formal: x(3) - array of VMEC coordinates - ! Output parameters: - ! formal: bmod - ! sqrtg - ! bder(3) - derivatives of $\log(B)$ - ! hcovar(3) - covariant components of unit vector $\bh$ along $\bB$ - ! hctrvr(3) - contra-variant components of unit vector $\bh$ along $\bB$ - ! hcurl(3) - contra-variant components of curl of $\bh$ - ! - ! Called routines: vmec_field - ! - implicit none - ! - real(dp), parameter :: twopi=2.d0*3.14159265358979d0, hs=1.d-3, ht=hs*twopi, hp=ht/5.d0 - ! - real(dp), intent(out) :: bmod,sqrtg - real(dp) :: s,theta,varphi,A_theta,A_phi,dA_theta_ds,dA_phi_ds,aiota, & - sqg,alam,dl_ds,dl_dt,dl_dp,Bctrvr_vartheta,Bctrvr_varphi, & - Bcovar_r,Bcovar_vartheta,Bcovar_varphi - real(dp) :: cjac,bcov_s_vmec,bcov_t_vmec,bcov_p_vmec - real(dp) :: dhs_dt,dhs_dp,dht_ds,dht_dp,dhp_ds,dhp_dt - real(dp), dimension(3), intent(in) :: x - real(dp), dimension(3), intent(out) :: bder,hcovar,hctrvr,hcurl - ! - ! Begin derivatives over s - ! - theta=x(2) - varphi=x(3) - s=x(1)+hs - ! - call vmec_field(s,theta,varphi,A_theta,A_phi,dA_theta_ds,dA_phi_ds,aiota, & - sqg,alam,dl_ds,dl_dt,dl_dp,Bctrvr_vartheta,Bctrvr_varphi, & - Bcovar_r,Bcovar_vartheta,Bcovar_varphi) - ! - bmod=sqrt(Bctrvr_vartheta*Bcovar_vartheta+Bctrvr_varphi*Bcovar_varphi) - bcov_s_vmec=Bcovar_r+dl_ds*Bcovar_vartheta - bcov_t_vmec=(1.d0+dl_dt)*Bcovar_vartheta - bcov_p_vmec=Bcovar_varphi+dl_dp*Bcovar_vartheta - bder(1)=bmod - dht_ds=bcov_t_vmec/bmod - dhp_ds=bcov_p_vmec/bmod - ! - s=x(1)-hs - ! - call vmec_field(s,theta,varphi,A_theta,A_phi,dA_theta_ds,dA_phi_ds,aiota, & - sqg,alam,dl_ds,dl_dt,dl_dp,Bctrvr_vartheta,Bctrvr_varphi, & - Bcovar_r,Bcovar_vartheta,Bcovar_varphi) - ! - bmod=sqrt(Bctrvr_vartheta*Bcovar_vartheta+Bctrvr_varphi*Bcovar_varphi) - bcov_s_vmec=Bcovar_r+dl_ds*Bcovar_vartheta - bcov_t_vmec=(1.d0+dl_dt)*Bcovar_vartheta - bcov_p_vmec=Bcovar_varphi+dl_dp*Bcovar_vartheta - bder(1)=(bder(1)-bmod)/(2.d0*hs) - dht_ds=(dht_ds-bcov_t_vmec/bmod)/(2.d0*hs) - dhp_ds=(dhp_ds-bcov_p_vmec/bmod)/(2.d0*hs) - ! - ! End derivatives over s - ! - !------------------------- - ! - ! Begin derivatives over theta - ! - s=x(1) - theta=x(2)+ht - ! - call vmec_field(s,theta,varphi,A_theta,A_phi,dA_theta_ds,dA_phi_ds,aiota, & - sqg,alam,dl_ds,dl_dt,dl_dp,Bctrvr_vartheta,Bctrvr_varphi, & - Bcovar_r,Bcovar_vartheta,Bcovar_varphi) - ! - bmod=sqrt(Bctrvr_vartheta*Bcovar_vartheta+Bctrvr_varphi*Bcovar_varphi) - bcov_s_vmec=Bcovar_r+dl_ds*Bcovar_vartheta - bcov_t_vmec=(1.d0+dl_dt)*Bcovar_vartheta - bcov_p_vmec=Bcovar_varphi+dl_dp*Bcovar_vartheta - bder(2)=bmod - dhs_dt=bcov_s_vmec/bmod - dhp_dt=bcov_p_vmec/bmod - ! - theta=x(2)-ht - ! - call vmec_field(s,theta,varphi,A_theta,A_phi,dA_theta_ds,dA_phi_ds,aiota, & - sqg,alam,dl_ds,dl_dt,dl_dp,Bctrvr_vartheta,Bctrvr_varphi, & - Bcovar_r,Bcovar_vartheta,Bcovar_varphi) - ! - bmod=sqrt(Bctrvr_vartheta*Bcovar_vartheta+Bctrvr_varphi*Bcovar_varphi) - bcov_s_vmec=Bcovar_r+dl_ds*Bcovar_vartheta - bcov_t_vmec=(1.d0+dl_dt)*Bcovar_vartheta - bcov_p_vmec=Bcovar_varphi+dl_dp*Bcovar_vartheta - bder(2)=(bder(2)-bmod)/(2.d0*ht) - dhs_dt=(dhs_dt-bcov_s_vmec/bmod)/(2.d0*ht) - dhp_dt=(dhp_dt-bcov_p_vmec/bmod)/(2.d0*ht) - ! - ! End derivatives over theta - ! - !------------------------- - ! - ! Begin derivatives over varphi - ! - theta=x(2) - varphi=x(3)+hp - ! - call vmec_field(s,theta,varphi,A_theta,A_phi,dA_theta_ds,dA_phi_ds,aiota, & - sqg,alam,dl_ds,dl_dt,dl_dp,Bctrvr_vartheta,Bctrvr_varphi, & - Bcovar_r,Bcovar_vartheta,Bcovar_varphi) - ! - bmod=sqrt(Bctrvr_vartheta*Bcovar_vartheta+Bctrvr_varphi*Bcovar_varphi) - bcov_s_vmec=Bcovar_r+dl_ds*Bcovar_vartheta - bcov_t_vmec=(1.d0+dl_dt)*Bcovar_vartheta - bcov_p_vmec=Bcovar_varphi+dl_dp*Bcovar_vartheta - bder(3)=bmod - dhs_dp=bcov_s_vmec/bmod - dht_dp=bcov_t_vmec/bmod - ! - varphi=x(3)-hp - ! - call vmec_field(s,theta,varphi,A_theta,A_phi,dA_theta_ds,dA_phi_ds,aiota, & - sqg,alam,dl_ds,dl_dt,dl_dp,Bctrvr_vartheta,Bctrvr_varphi, & - Bcovar_r,Bcovar_vartheta,Bcovar_varphi) - ! - bmod=sqrt(Bctrvr_vartheta*Bcovar_vartheta+Bctrvr_varphi*Bcovar_varphi) - bcov_s_vmec=Bcovar_r+dl_ds*Bcovar_vartheta - bcov_t_vmec=(1.d0+dl_dt)*Bcovar_vartheta - bcov_p_vmec=Bcovar_varphi+dl_dp*Bcovar_vartheta - bder(3)=(bder(3)-bmod)/(2.d0*hp) - dhs_dp=(dhs_dp-bcov_s_vmec/bmod)/(2.d0*hp) - dht_dp=(dht_dp-bcov_t_vmec/bmod)/(2.d0*hp) - ! - ! End derivatives over varphi - ! - !------------------------- - ! - varphi=x(3) - ! - call vmec_field(s,theta,varphi,A_theta,A_phi,dA_theta_ds,dA_phi_ds,aiota, & - sqg,alam,dl_ds,dl_dt,dl_dp,Bctrvr_vartheta,Bctrvr_varphi, & - Bcovar_r,Bcovar_vartheta,Bcovar_varphi) - ! - bmod=sqrt(Bctrvr_vartheta*Bcovar_vartheta+Bctrvr_varphi*Bcovar_varphi) - cjac=1.d0+dl_dt - sqrtg=sqg*cjac - bder=bder/bmod - bcov_s_vmec=Bcovar_r+dl_ds*Bcovar_vartheta - bcov_t_vmec=(1.d0+dl_dt)*Bcovar_vartheta - bcov_p_vmec=Bcovar_varphi+dl_dp*Bcovar_vartheta - hcovar(1)=bcov_s_vmec/bmod - hcovar(2)=bcov_t_vmec/bmod - hcovar(3)=bcov_p_vmec/bmod - hctrvr(1)=0.d0 - hctrvr(2)=(Bctrvr_vartheta-dl_dp*Bctrvr_varphi)/(cjac*bmod) - hctrvr(3)=Bctrvr_varphi/bmod - hcurl(1)=(dhp_dt-dht_dp)/sqrtg - hcurl(2)=(dhs_dp-dhp_ds)/sqrtg - hcurl(3)=(dht_ds-dhs_dt)/sqrtg - ! - end subroutine magfie_vmec - ! - !ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc - ! - - subroutine magfie_geoflux(x, bmod, sqrtg, bder, hcovar, hctrvr, hcurl) - real(dp), intent(in) :: x(3) - real(dp), intent(out) :: bmod, sqrtg - real(dp), intent(out) :: bder(3), hcovar(3), hctrvr(3), hcurl(3) - - real(dp) :: r, theta, phi - real(dp) :: dr_fwd, dr_bwd, dr_den - real(dp) :: dt_step, dp_step - real(dp) :: bmod_plus, bmod_minus - real(dp) :: bmod_theta_plus, bmod_theta_minus - real(dp) :: bmod_phi_plus, bmod_phi_minus - real(dp) :: hcov_plus(3), hcov_minus(3) - real(dp) :: hcov_theta_plus(3), hcov_theta_minus(3) - real(dp) :: hcov_phi_plus(3), hcov_phi_minus(3) - real(dp) :: basis(3, 3), g(3, 3), ginv(3, 3) - real(dp) :: detg, sqrtg_geom - real(dp) :: dh_dr(3), dh_dt(3), dh_dp(3) - real(dp) :: phi_plus, phi_minus - - r = max(0.0_dp, min(1.0_dp, x(1))) - theta = x(2) - phi = x(3) - - call geoflux_eval_point(r, theta, phi, bmod, hcovar, sqrtg, basis, g, ginv, detg, sqrtg_geom) - - if (sqrtg <= 0.0_dp) sqrtg = max(sqrtg_geom, 1.0d-12) - sqrtg = max(sqrtg, 1.0d-12) - - if (.not. ieee_is_finite(bmod)) then - error stop 'magfie_geoflux: non-finite Bmod' - end if - if (.not. all(ieee_is_finite(hcovar))) then - error stop 'magfie_geoflux: non-finite hcovar' - end if - - dr_fwd = min(1.0d-3, 1.0_dp - r) - dr_bwd = min(1.0d-3, r) - dt_step = 1.0d-3*twopi - dp_step = dt_step/5.0d0 - - call geoflux_eval_basic(r + dr_fwd, theta, phi, bmod_plus, hcov_plus) - call geoflux_eval_basic(r - dr_bwd, theta, phi, bmod_minus, hcov_minus) - - dr_den = dr_fwd + dr_bwd - if (dr_den > 1.0d-12) then - bder(1) = (bmod_plus - bmod_minus)/dr_den - dh_dr = (hcov_plus - hcov_minus)/dr_den - else - bder(1) = 0.0_dp - dh_dr = 0.0_dp - end if - - call geoflux_eval_basic(r, theta + dt_step, phi, bmod_theta_plus, hcov_theta_plus) - call geoflux_eval_basic(r, theta - dt_step, phi, bmod_theta_minus, hcov_theta_minus) - bder(2) = (bmod_theta_plus - bmod_theta_minus)/(2.0_dp*dt_step) - dh_dt = (hcov_theta_plus - hcov_theta_minus)/(2.0_dp*dt_step) - - phi_plus = modulo(phi + dp_step, twopi) - phi_minus = modulo(phi - dp_step, twopi) - call geoflux_eval_basic(r, theta, phi_plus, bmod_phi_plus, hcov_phi_plus) - call geoflux_eval_basic(r, theta, phi_minus, bmod_phi_minus, hcov_phi_minus) - bder(3) = (bmod_phi_plus - bmod_phi_minus)/(2.0_dp*dp_step) - dh_dp = (hcov_phi_plus - hcov_phi_minus)/(2.0_dp*dp_step) - - bder = bder / max(bmod, 1.0d-12) - - hctrvr = matmul(ginv, hcovar) - - if (sqrtg > 0.0_dp) then - hcurl(1) = (dh_dp(3) - dh_dt(3))/sqrtg - hcurl(2) = (dh_dp(1) - dh_dr(3))/sqrtg - hcurl(3) = (dh_dr(2) - dh_dt(1))/sqrtg - else - hcurl = 0.0_dp - end if - - end subroutine magfie_geoflux - - subroutine geoflux_eval_point(r, theta, phi, bmod, hcov, sqrtg, basis, g, ginv, detg, sqrtg_geom) - real(dp), intent(in) :: r, theta, phi - real(dp), intent(out) :: bmod, hcov(3), sqrtg - real(dp), intent(out) :: basis(3, 3), g(3, 3), ginv(3, 3) - real(dp), intent(out) :: detg, sqrtg_geom - real(dp) :: xcyl(3), jac(3, 3) - real(dp) :: dRdr, dZdr, dRdtheta, dZdtheta, dRdphi, dZdphi - real(dp) :: cosphi, sinphi, ds_dr - real(dp) :: cross12(3) - - call geoflux_eval_basic(r, theta, phi, bmod, hcov, sqrtg, xcyl, jac) - - ds_dr = max(2.0_dp*max(r, 0.0_dp), 1.0d-8) - cosphi = cos(xcyl(2)) - sinphi = sin(xcyl(2)) - - dRdr = jac(1, 1) * ds_dr - dZdr = jac(3, 1) * ds_dr - dRdtheta = jac(1, 2) - dZdtheta = jac(3, 2) - dRdphi = jac(1, 3) - dZdphi = jac(3, 3) - - basis(:, 1) = (/ dRdr * cosphi, dRdr * sinphi, dZdr /) - basis(:, 2) = (/ dRdtheta * cosphi, dRdtheta * sinphi, dZdtheta /) - basis(:, 3) = (/ dRdphi * cosphi - xcyl(1) * sinphi, & - dRdphi * sinphi + xcyl(1) * cosphi, dZdphi /) - - call compute_metric(basis, g, ginv, detg) - call cross_product(basis(:, 2), basis(:, 3), cross12) - sqrtg_geom = abs(dot_product(basis(:, 1), cross12)) - sqrtg = max(sqrtg, sqrtg_geom) - end subroutine geoflux_eval_point - - subroutine geoflux_eval_basic(r, theta, phi, bmod, hcov, sqrtg, xcyl, jac) - real(dp), intent(in) :: r, theta, phi - real(dp), intent(out) :: bmod, hcov(3) - real(dp), intent(out), optional :: sqrtg - real(dp), intent(out), optional :: xcyl(3), jac(3, 3) - - real(dp) :: r_clip, s_geo, ds_dr - real(dp) :: sqg_tmp(3) - real(dp) :: acov_tmp(3), hcov_tmp(3) - real(dp) :: cyl_tmp(3), jac_tmp(3, 3) - - r_clip = max(0.0_dp, min(1.0_dp, r)) - s_geo = r_clip * r_clip - - call geoflux_to_cyl((/ s_geo, theta, phi /), cyl_tmp, jac_tmp) - call splint_geoflux_field(s_geo, theta, phi, acov_tmp, hcov_tmp, bmod, sqg_tmp) - - ds_dr = max(2.0_dp * max(r_clip, 0.0_dp), 1.0d-8) - hcov(1) = hcov_tmp(1) * ds_dr - hcov(2) = hcov_tmp(2) - hcov(3) = hcov_tmp(3) - - if (present(sqrtg)) sqrtg = abs(sqg_tmp(1) * ds_dr) - if (present(xcyl)) xcyl = cyl_tmp - if (present(jac)) jac = jac_tmp - end subroutine geoflux_eval_basic - - subroutine compute_metric(basis, g, ginv, detg) - real(dp), intent(in) :: basis(3, 3) - real(dp), intent(out) :: g(3, 3), ginv(3, 3) - real(dp), intent(out) :: detg - integer :: i, j - - do i = 1, 3 - do j = 1, 3 - g(i, j) = dot_product(basis(:, i), basis(:, j)) - end do - end do - - call invert3x3(g, ginv, detg) - if (abs(detg) < 1.0d-16) then - detg = 1.0d0 - ginv = 0.0_dp - do i = 1, 3 - ginv(i, i) = 1.0_dp - end do - end if - end subroutine compute_metric - - subroutine cross_product(a, b, c) - real(dp), intent(in) :: a(3), b(3) - real(dp), intent(out) :: c(3) - c(1) = a(2) * b(3) - a(3) * b(2) - c(2) = a(3) * b(1) - a(1) * b(3) - c(3) = a(1) * b(2) - a(2) * b(1) - end subroutine cross_product - - subroutine invert3x3(a, ainv, det) - real(dp), intent(in) :: a(3, 3) - real(dp), intent(out) :: ainv(3, 3) - real(dp), intent(out) :: det - - det = a(1, 1) * (a(2, 2) * a(3, 3) - a(2, 3) * a(3, 2)) & - - a(1, 2) * (a(2, 1) * a(3, 3) - a(2, 3) * a(3, 1)) & - + a(1, 3) * (a(2, 1) * a(3, 2) - a(2, 2) * a(3, 1)) - - if (abs(det) < 1.0d-16) then - det = 0.0_dp - ainv = 0.0_dp - return - end if - - ainv(1, 1) = (a(2, 2) * a(3, 3) - a(2, 3) * a(3, 2)) / det - ainv(1, 2) = -(a(1, 2) * a(3, 3) - a(1, 3) * a(3, 2)) / det - ainv(1, 3) = (a(1, 2) * a(2, 3) - a(1, 3) * a(2, 2)) / det - ainv(2, 1) = -(a(2, 1) * a(3, 3) - a(2, 3) * a(3, 1)) / det - ainv(2, 2) = (a(1, 1) * a(3, 3) - a(1, 3) * a(3, 1)) / det - ainv(2, 3) = -(a(1, 1) * a(2, 3) - a(1, 3) * a(2, 1)) / det - ainv(3, 1) = (a(2, 1) * a(3, 2) - a(2, 2) * a(3, 1)) / det - ainv(3, 2) = -(a(1, 1) * a(3, 2) - a(1, 2) * a(3, 1)) / det - ainv(3, 3) = (a(1, 1) * a(2, 2) - a(1, 2) * a(2, 1)) / det - end subroutine invert3x3 - - end module magfie_sub + subroutine init_magfie(id) + integer, intent(in) :: id + + select case (id) + case (TEST) + magfie => magfie_test + case (CANFLUX) + magfie => magfie_can + case (VMEC) + if (geoflux_ready) then + magfie => magfie_geoflux + else + magfie => magfie_vmec + end if + case (BOOZER) + magfie => magfie_boozer + case (MEISS) + magfie => magfie_meiss + case (ALBERT) + magfie => magfie_albert + case (GEOFLUX) + magfie => magfie_geoflux + case default + print *, 'init_magfie: unknown id ', id + error stop + end select + end subroutine init_magfie + + subroutine magfie_test(x, bmod, sqrtg, bder, hcovar, hctrvr, hcurl) + !> Magnetic field for analytic circular tokamak (TEST field). + !> Coordinates: x(1)=r (minor radius), x(2)=theta (poloidal), x(3)=phi (toroidal) + !> Uses same geometry as field_can_test: B0=1, R0=1, a=0.5, iota=1 + !> + !> WARNING: hcurl is set to zero (curvature drift not computed). + !> This is acceptable for symplectic integration (integmode > 0) which uses + !> field_can_test instead. For RK45 integration (integmode=0), curvature + !> drift would be missing - use symplectic integration with TEST field. + implicit none + + real(dp), intent(in) :: x(3) + real(dp), intent(out) :: bmod, sqrtg, bder(3), hcovar(3), hctrvr(3), hcurl(3) + + real(dp), parameter :: B0 = 1.0_dp, R0 = 1.0_dp, a = 0.5_dp, iota0 = 1.0_dp + real(dp) :: r, theta, cth, sth, R_cyl, dBmod_dr, dBmod_dth + + r = x(1) + theta = x(2) + cth = cos(theta) + sth = sin(theta) + + ! Major radius at this point + R_cyl = R0 + r*cth + + ! Magnetic field magnitude: B = B0 * (1 - r/R0 * cos(theta)) + bmod = B0*(1.0_dp - r/R0*cth) + + ! Jacobian sqrt(g) = r * R for circular tokamak + sqrtg = r*R_cyl + + ! Derivatives of log(B) + dBmod_dr = -B0/R0*cth + dBmod_dth = B0*r/R0*sth + bder(1) = dBmod_dr/bmod + bder(2) = dBmod_dth/bmod + bder(3) = 0.0_dp + + ! Covariant components of unit vector h = B/|B| + ! In (r, theta, phi) coordinates for circular tokamak with iota=1 + hcovar(1) = 0.0_dp + hcovar(2) = iota0*(1.0_dp - r**2/a**2)*r**2/R0/bmod + hcovar(3) = R_cyl/bmod + + ! Contravariant components + hctrvr(1) = 0.0_dp + hctrvr(2) = B0*iota0/(r*R_cyl*bmod) + hctrvr(3) = B0/(r*R_cyl*bmod) + + ! Curl of h (simplified - not fully computed for TEST field) + hcurl(1) = 0.0_dp + hcurl(2) = 0.0_dp + hcurl(3) = 0.0_dp + + end subroutine magfie_test + + !ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc + ! + subroutine magfie_vmec(x, bmod, sqrtg, bder, hcovar, hctrvr, hcurl) + ! + ! Computes magnetic field module in units of the magnetic code - bmod, + ! square root of determinant of the metric tensor - sqrtg, + ! derivatives of the logarythm of the magnetic field module + ! over coordinates - bder, + ! covariant componets of the unit vector of the magnetic + ! field direction - hcovar, + ! contravariant components of this vector - hctrvr, + ! contravariant component of the curl of this vector - hcurl + ! Order of coordinates is the following: x(1)=s (normalized toroidal flux), + ! x(2)=theta (VMEC poloidal angle), x(3)=varphi (geometrical toroidal angle). + ! + ! Input parameters: + ! formal: x(3) - array of VMEC coordinates + ! Output parameters: + ! formal: bmod + ! sqrtg + ! bder(3) - derivatives of $\log(B)$ +! hcovar(3) - covariant components of unit vector $\bh$ along $\bB$ +! hctrvr(3) - contra-variant components of unit vector $\bh$ along $\bB$ + ! hcurl(3) - contra-variant components of curl of $\bh$ + ! + ! Called routines: vmec_field + ! + implicit none + ! + real(dp), parameter :: twopi = 2.d0*3.14159265358979d0, hs = 1.d-3, ht = & + hs*twopi, & + hp = ht/5.d0 + ! + real(dp), intent(out) :: bmod, sqrtg + real(dp) :: s, theta, varphi, A_theta, A_phi, dA_theta_ds, dA_phi_ds, aiota, & + sqg, alam, dl_ds, dl_dt, dl_dp, Bctrvr_vartheta, Bctrvr_varphi, & + Bcovar_r, Bcovar_vartheta, Bcovar_varphi + real(dp) :: cjac, bcov_s_vmec, bcov_t_vmec, bcov_p_vmec + real(dp) :: dhs_dt, dhs_dp, dht_ds, dht_dp, dhp_ds, dhp_dt + real(dp), dimension(3), intent(in) :: x + real(dp), dimension(3), intent(out) :: bder, hcovar, hctrvr, hcurl + ! + ! Begin derivatives over s + ! + theta = x(2) + varphi = x(3) + s = x(1) + hs + ! + call vmec_field(s, theta, varphi, A_theta, A_phi, dA_theta_ds, & + dA_phi_ds, aiota, & + sqg, alam, dl_ds, dl_dt, dl_dp, Bctrvr_vartheta, & + Bctrvr_varphi, & + Bcovar_r, Bcovar_vartheta, Bcovar_varphi) + ! + bmod = sqrt(Bctrvr_vartheta*Bcovar_vartheta + Bctrvr_varphi*Bcovar_varphi) + bcov_s_vmec = Bcovar_r + dl_ds*Bcovar_vartheta + bcov_t_vmec = (1.d0 + dl_dt)*Bcovar_vartheta + bcov_p_vmec = Bcovar_varphi + dl_dp*Bcovar_vartheta + bder(1) = bmod + dht_ds = bcov_t_vmec/bmod + dhp_ds = bcov_p_vmec/bmod + ! + s = x(1) - hs + ! + call vmec_field(s, theta, varphi, A_theta, A_phi, dA_theta_ds, & + dA_phi_ds, aiota, & + sqg, alam, dl_ds, dl_dt, dl_dp, Bctrvr_vartheta, & + Bctrvr_varphi, & + Bcovar_r, Bcovar_vartheta, Bcovar_varphi) + ! + bmod = sqrt(Bctrvr_vartheta*Bcovar_vartheta + Bctrvr_varphi*Bcovar_varphi) + bcov_s_vmec = Bcovar_r + dl_ds*Bcovar_vartheta + bcov_t_vmec = (1.d0 + dl_dt)*Bcovar_vartheta + bcov_p_vmec = Bcovar_varphi + dl_dp*Bcovar_vartheta + bder(1) = (bder(1) - bmod)/(2.d0*hs) + dht_ds = (dht_ds - bcov_t_vmec/bmod)/(2.d0*hs) + dhp_ds = (dhp_ds - bcov_p_vmec/bmod)/(2.d0*hs) + ! + ! End derivatives over s + ! + !------------------------- + ! + ! Begin derivatives over theta + ! + s = x(1) + theta = x(2) + ht + ! + call vmec_field(s, theta, varphi, A_theta, A_phi, dA_theta_ds, & + dA_phi_ds, aiota, & + sqg, alam, dl_ds, dl_dt, dl_dp, Bctrvr_vartheta, & + Bctrvr_varphi, & + Bcovar_r, Bcovar_vartheta, Bcovar_varphi) + ! + bmod = sqrt(Bctrvr_vartheta*Bcovar_vartheta + Bctrvr_varphi*Bcovar_varphi) + bcov_s_vmec = Bcovar_r + dl_ds*Bcovar_vartheta + bcov_t_vmec = (1.d0 + dl_dt)*Bcovar_vartheta + bcov_p_vmec = Bcovar_varphi + dl_dp*Bcovar_vartheta + bder(2) = bmod + dhs_dt = bcov_s_vmec/bmod + dhp_dt = bcov_p_vmec/bmod + ! + theta = x(2) - ht + ! + call vmec_field(s, theta, varphi, A_theta, A_phi, dA_theta_ds, & + dA_phi_ds, aiota, & + sqg, alam, dl_ds, dl_dt, dl_dp, Bctrvr_vartheta, & + Bctrvr_varphi, & + Bcovar_r, Bcovar_vartheta, Bcovar_varphi) + ! + bmod = sqrt(Bctrvr_vartheta*Bcovar_vartheta + Bctrvr_varphi*Bcovar_varphi) + bcov_s_vmec = Bcovar_r + dl_ds*Bcovar_vartheta + bcov_t_vmec = (1.d0 + dl_dt)*Bcovar_vartheta + bcov_p_vmec = Bcovar_varphi + dl_dp*Bcovar_vartheta + bder(2) = (bder(2) - bmod)/(2.d0*ht) + dhs_dt = (dhs_dt - bcov_s_vmec/bmod)/(2.d0*ht) + dhp_dt = (dhp_dt - bcov_p_vmec/bmod)/(2.d0*ht) + ! + ! End derivatives over theta + ! + !------------------------- + ! + ! Begin derivatives over varphi + ! + theta = x(2) + varphi = x(3) + hp + ! + call vmec_field(s, theta, varphi, A_theta, A_phi, dA_theta_ds, & + dA_phi_ds, aiota, & + sqg, alam, dl_ds, dl_dt, dl_dp, Bctrvr_vartheta, & + Bctrvr_varphi, & + Bcovar_r, Bcovar_vartheta, Bcovar_varphi) + ! + bmod = sqrt(Bctrvr_vartheta*Bcovar_vartheta + Bctrvr_varphi*Bcovar_varphi) + bcov_s_vmec = Bcovar_r + dl_ds*Bcovar_vartheta + bcov_t_vmec = (1.d0 + dl_dt)*Bcovar_vartheta + bcov_p_vmec = Bcovar_varphi + dl_dp*Bcovar_vartheta + bder(3) = bmod + dhs_dp = bcov_s_vmec/bmod + dht_dp = bcov_t_vmec/bmod + ! + varphi = x(3) - hp + ! + call vmec_field(s, theta, varphi, A_theta, A_phi, dA_theta_ds, & + dA_phi_ds, aiota, & + sqg, alam, dl_ds, dl_dt, dl_dp, Bctrvr_vartheta, & + Bctrvr_varphi, & + Bcovar_r, Bcovar_vartheta, Bcovar_varphi) + ! + bmod = sqrt(Bctrvr_vartheta*Bcovar_vartheta + Bctrvr_varphi*Bcovar_varphi) + bcov_s_vmec = Bcovar_r + dl_ds*Bcovar_vartheta + bcov_t_vmec = (1.d0 + dl_dt)*Bcovar_vartheta + bcov_p_vmec = Bcovar_varphi + dl_dp*Bcovar_vartheta + bder(3) = (bder(3) - bmod)/(2.d0*hp) + dhs_dp = (dhs_dp - bcov_s_vmec/bmod)/(2.d0*hp) + dht_dp = (dht_dp - bcov_t_vmec/bmod)/(2.d0*hp) + ! + ! End derivatives over varphi + ! + !------------------------- + ! + varphi = x(3) + ! + call vmec_field(s, theta, varphi, A_theta, A_phi, dA_theta_ds, & + dA_phi_ds, aiota, & + sqg, alam, dl_ds, dl_dt, dl_dp, Bctrvr_vartheta, & + Bctrvr_varphi, & + Bcovar_r, Bcovar_vartheta, Bcovar_varphi) + ! + bmod = sqrt(Bctrvr_vartheta*Bcovar_vartheta + Bctrvr_varphi*Bcovar_varphi) + cjac = 1.d0 + dl_dt + sqrtg = sqg*cjac + bder = bder/bmod + bcov_s_vmec = Bcovar_r + dl_ds*Bcovar_vartheta + bcov_t_vmec = (1.d0 + dl_dt)*Bcovar_vartheta + bcov_p_vmec = Bcovar_varphi + dl_dp*Bcovar_vartheta + hcovar(1) = bcov_s_vmec/bmod + hcovar(2) = bcov_t_vmec/bmod + hcovar(3) = bcov_p_vmec/bmod + hctrvr(1) = 0.d0 + hctrvr(2) = (Bctrvr_vartheta - dl_dp*Bctrvr_varphi)/(cjac*bmod) + hctrvr(3) = Bctrvr_varphi/bmod + hcurl(1) = (dhp_dt - dht_dp)/sqrtg + hcurl(2) = (dhs_dp - dhp_ds)/sqrtg + hcurl(3) = (dht_ds - dhs_dt)/sqrtg + ! + end subroutine magfie_vmec + ! + !ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc + ! + + subroutine magfie_geoflux(x, bmod, sqrtg, bder, hcovar, hctrvr, hcurl) + real(dp), intent(in) :: x(3) + real(dp), intent(out) :: bmod, sqrtg + real(dp), intent(out) :: bder(3), hcovar(3), hctrvr(3), hcurl(3) + + real(dp) :: s, theta, phi + real(dp) :: ds_fwd, ds_bwd, ds_den + real(dp) :: dt_step, dp_step + real(dp) :: bmod_plus, bmod_minus + real(dp) :: bmod_theta_plus, bmod_theta_minus + real(dp) :: bmod_phi_plus, bmod_phi_minus + real(dp) :: hcov_plus(3), hcov_minus(3) + real(dp) :: hcov_theta_plus(3), hcov_theta_minus(3) + real(dp) :: hcov_phi_plus(3), hcov_phi_minus(3) + real(dp) :: basis(3, 3), g(3, 3), ginv(3, 3) + real(dp) :: detg, sqrtg_geom + real(dp) :: dh_ds(3), dh_dt(3), dh_dp(3) + real(dp) :: phi_plus, phi_minus + + s = max(0.0_dp, min(1.0_dp, x(1))) + theta = x(2) + phi = x(3) + + call geoflux_eval_point(s, theta, phi, bmod, hcovar, sqrtg, basis, g, & + ginv, detg, & + sqrtg_geom) + + if (sqrtg <= 0.0_dp) sqrtg = max(sqrtg_geom, 1.0d-12) + sqrtg = max(sqrtg, 1.0d-12) + + if (.not. ieee_is_finite(bmod)) then + error stop 'magfie_geoflux: non-finite Bmod' + end if + if (.not. all(ieee_is_finite(hcovar))) then + error stop 'magfie_geoflux: non-finite hcovar' + end if + + ds_fwd = min(1.0d-3, 1.0_dp - s) + ds_bwd = min(1.0d-3, s) + dt_step = 1.0d-3*twopi + dp_step = dt_step/5.0d0 + + call geoflux_eval_basic(s + ds_fwd, theta, phi, bmod_plus, hcov_plus) + call geoflux_eval_basic(s - ds_bwd, theta, phi, bmod_minus, hcov_minus) + + ds_den = ds_fwd + ds_bwd + if (ds_den > 1.0d-12) then + bder(1) = (bmod_plus - bmod_minus)/ds_den + dh_ds = (hcov_plus - hcov_minus)/ds_den + else + bder(1) = 0.0_dp + dh_ds = 0.0_dp + end if + + call geoflux_eval_basic(s, theta + dt_step, phi, bmod_theta_plus, & + hcov_theta_plus) + call geoflux_eval_basic(s, theta - dt_step, phi, bmod_theta_minus, & + hcov_theta_minus) + bder(2) = (bmod_theta_plus - bmod_theta_minus)/(2.0_dp*dt_step) + dh_dt = (hcov_theta_plus - hcov_theta_minus)/(2.0_dp*dt_step) + + phi_plus = modulo(phi + dp_step, twopi) + phi_minus = modulo(phi - dp_step, twopi) + call geoflux_eval_basic(s, theta, phi_plus, bmod_phi_plus, hcov_phi_plus) + call geoflux_eval_basic(s, theta, phi_minus, bmod_phi_minus, hcov_phi_minus) + bder(3) = (bmod_phi_plus - bmod_phi_minus)/(2.0_dp*dp_step) + dh_dp = (hcov_phi_plus - hcov_phi_minus)/(2.0_dp*dp_step) + + bder = bder/max(bmod, 1.0d-12) + + hctrvr = matmul(ginv, hcovar) + + if (sqrtg > 0.0_dp) then + hcurl(1) = (dh_dp(3) - dh_dt(3))/sqrtg + hcurl(2) = (dh_dp(1) - dh_ds(3))/sqrtg + hcurl(3) = (dh_ds(2) - dh_dt(1))/sqrtg + else + hcurl = 0.0_dp + end if + + end subroutine magfie_geoflux + + subroutine geoflux_eval_point(s, theta, phi, bmod, hcov, sqrtg, basis, g, ginv, & + detg, sqrtg_geom) + real(dp), intent(in) :: s, theta, phi + real(dp), intent(out) :: bmod, hcov(3), sqrtg + real(dp), intent(out) :: basis(3, 3), g(3, 3), ginv(3, 3) + real(dp), intent(out) :: detg, sqrtg_geom + real(dp) :: xcyl(3), jac(3, 3) + real(dp) :: dRds, dZds, dRdtheta, dZdtheta, dRdphi, dZdphi + real(dp) :: cosphi, sinphi + real(dp) :: cross12(3) + + call geoflux_eval_basic(s, theta, phi, bmod, hcov, sqrtg, xcyl, jac) + + cosphi = cos(xcyl(2)) + sinphi = sin(xcyl(2)) + + dRds = jac(1, 1) + dZds = jac(3, 1) + dRdtheta = jac(1, 2) + dZdtheta = jac(3, 2) + dRdphi = jac(1, 3) + dZdphi = jac(3, 3) + + basis(:, 1) = (/dRds*cosphi, dRds*sinphi, dZds/) + basis(:, 2) = (/dRdtheta*cosphi, dRdtheta*sinphi, dZdtheta/) + basis(:, 3) = (/dRdphi*cosphi - xcyl(1)*sinphi, & + dRdphi*sinphi + xcyl(1)*cosphi, dZdphi/) + + call compute_metric(basis, g, ginv, detg) + call cross_product(basis(:, 2), basis(:, 3), cross12) + sqrtg_geom = abs(dot_product(basis(:, 1), cross12)) + sqrtg = max(sqrtg, sqrtg_geom) + end subroutine geoflux_eval_point + + subroutine geoflux_eval_basic(s, theta, phi, bmod, hcov, sqrtg, xcyl, jac) + real(dp), intent(in) :: s, theta, phi + real(dp), intent(out) :: bmod, hcov(3) + real(dp), intent(out), optional :: sqrtg + real(dp), intent(out), optional :: xcyl(3), jac(3, 3) + + real(dp) :: s_clip + real(dp) :: sqg_tmp(3) + real(dp) :: acov_tmp(3), hcov_tmp(3) + real(dp) :: cyl_tmp(3), jac_tmp(3, 3) + + s_clip = max(0.0_dp, min(1.0_dp, s)) + + call geoflux_to_cyl((/s_clip, theta, phi/), cyl_tmp, jac_tmp) + call splint_geoflux_field(s_clip, theta, phi, acov_tmp, hcov_tmp, bmod, sqg_tmp) + + hcov = hcov_tmp + + if (present(sqrtg)) sqrtg = abs(sqg_tmp(1)) + if (present(xcyl)) xcyl = cyl_tmp + if (present(jac)) jac = jac_tmp + end subroutine geoflux_eval_basic + + subroutine compute_metric(basis, g, ginv, detg) + real(dp), intent(in) :: basis(3, 3) + real(dp), intent(out) :: g(3, 3), ginv(3, 3) + real(dp), intent(out) :: detg + integer :: i, j + + do i = 1, 3 + do j = 1, 3 + g(i, j) = dot_product(basis(:, i), basis(:, j)) + end do + end do + + call invert3x3(g, ginv, detg) + if (abs(detg) < 1.0d-16) then + detg = 1.0d0 + ginv = 0.0_dp + do i = 1, 3 + ginv(i, i) = 1.0_dp + end do + end if + end subroutine compute_metric + + subroutine cross_product(a, b, c) + real(dp), intent(in) :: a(3), b(3) + real(dp), intent(out) :: c(3) + c(1) = a(2)*b(3) - a(3)*b(2) + c(2) = a(3)*b(1) - a(1)*b(3) + c(3) = a(1)*b(2) - a(2)*b(1) + end subroutine cross_product + + subroutine invert3x3(a, ainv, det) + real(dp), intent(in) :: a(3, 3) + real(dp), intent(out) :: ainv(3, 3) + real(dp), intent(out) :: det + + det = a(1, 1)*(a(2, 2)*a(3, 3) - a(2, 3)*a(3, 2)) & + - a(1, 2)*(a(2, 1)*a(3, 3) - a(2, 3)*a(3, 1)) & + + a(1, 3)*(a(2, 1)*a(3, 2) - a(2, 2)*a(3, 1)) + + if (abs(det) < 1.0d-16) then + det = 0.0_dp + ainv = 0.0_dp + return + end if + + ainv(1, 1) = (a(2, 2)*a(3, 3) - a(2, 3)*a(3, 2))/det + ainv(1, 2) = -(a(1, 2)*a(3, 3) - a(1, 3)*a(3, 2))/det + ainv(1, 3) = (a(1, 2)*a(2, 3) - a(1, 3)*a(2, 2))/det + ainv(2, 1) = -(a(2, 1)*a(3, 3) - a(2, 3)*a(3, 1))/det + ainv(2, 2) = (a(1, 1)*a(3, 3) - a(1, 3)*a(3, 1))/det + ainv(2, 3) = -(a(1, 1)*a(2, 3) - a(1, 3)*a(2, 1))/det + ainv(3, 1) = (a(2, 1)*a(3, 2) - a(2, 2)*a(3, 1))/det + ainv(3, 2) = -(a(1, 1)*a(3, 2) - a(1, 2)*a(3, 1))/det + ainv(3, 3) = (a(1, 1)*a(2, 2) - a(1, 2)*a(2, 1))/det + end subroutine invert3x3 + +end module magfie_sub diff --git a/test/tests/test_field_geoflux.f90 b/test/tests/test_field_geoflux.f90 index beaf4994..e7109cf6 100644 --- a/test/tests/test_field_geoflux.f90 +++ b/test/tests/test_field_geoflux.f90 @@ -23,7 +23,7 @@ program test_field_geoflux select type(field_obj) type is (geoflux_field_t) - x = [sqrt(0.25_dp), 0.3_dp, 0.0_dp] + x = [0.25_dp, 0.3_dp, 0.0_dp] call field_obj%evaluate(x, Acov, hcov, Bmod) if (Bmod <= 0.0_dp) then From 5f6ab06c0bde22af0df893437519c17efdff375e Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Sat, 13 Dec 2025 11:14:31 +0100 Subject: [PATCH 07/19] Add geoflux RK45 orbit plot regression test --- test/tests/CMakeLists.txt | 9 ++ test/tests/test_geoflux_rk45_orbit_plot.f90 | 150 ++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 test/tests/test_geoflux_rk45_orbit_plot.f90 diff --git a/test/tests/CMakeLists.txt b/test/tests/CMakeLists.txt index 7d4daa89..6e51d9f2 100644 --- a/test/tests/CMakeLists.txt +++ b/test/tests/CMakeLists.txt @@ -154,6 +154,15 @@ set_tests_properties(test_magfie_geoflux PROPERTIES WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} ) +add_executable(test_geoflux_rk45_orbit_plot.x test_geoflux_rk45_orbit_plot.f90) +target_link_libraries(test_geoflux_rk45_orbit_plot.x simple) +add_test(NAME test_geoflux_rk45_orbit_plot COMMAND test_geoflux_rk45_orbit_plot.x) +set_tests_properties(test_geoflux_rk45_orbit_plot PROPERTIES + WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} + LABELS "regression;plot" + ENVIRONMENT "LIBNEO_TEST_GEQDSK=${CMAKE_CURRENT_BINARY_DIR}/EQDSK_I.geqdsk;SIMPLE_VISUAL_DIR=${CMAKE_CURRENT_BINARY_DIR}/visual_artifacts" +) + # Generate GVEC test data file for test_gvec using elliptic tokamak example set(GVEC_TEST_INPUT "${CMAKE_CURRENT_SOURCE_DIR}/../../build/_deps/gvec-src/test-CI/examples/analytic_gs_elliptok/parameter.ini") set(GVEC_TEST_STATE "${CMAKE_CURRENT_SOURCE_DIR}/../../test/test_data/GVEC_elliptok_State_final.dat") diff --git a/test/tests/test_geoflux_rk45_orbit_plot.f90 b/test/tests/test_geoflux_rk45_orbit_plot.f90 new file mode 100644 index 00000000..5ede011d --- /dev/null +++ b/test/tests/test_geoflux_rk45_orbit_plot.f90 @@ -0,0 +1,150 @@ +program test_geoflux_rk45_orbit_plot + + use, intrinsic :: iso_fortran_env, only: dp => real64 + use params, only: read_config, params_init, netcdffile, ns_s, ns_tp, multharm, & + integmode, isw_field_type, dtaumin, relerr, ntimstep, v0, zstart + use simple, only: tracer_t + use simple_main, only: init_field + use magfie_sub, only: init_magfie + use alpha_lifetime_sub, only: orbit_timestep_axis + use samplers, only: load_starting_points + use geoflux_coordinates, only: geoflux_to_cyl + use fortplot, only: figure, plot, savefig, xlabel, ylabel, title + use util, only: twopi + + implicit none + + type(tracer_t) :: norb + character(len=1024) :: out_root, out_dir, config_file, start_file, geqdsk_file + character(len=1024) :: pdf_rz, pdf_s, cmd + integer :: status, ierr, i, nmax, n_used, mkdir_stat + real(dp), allocatable :: s_traj(:), theta_traj(:), phi_traj(:), time_traj(:) + real(dp), allocatable :: r_traj(:), z_traj(:) + real(dp) :: z(5), xcyl(3) + logical :: exists + + out_root = '' + call get_environment_variable('SIMPLE_VISUAL_DIR', value=out_root, status=status) + if (status /= 0 .or. len_trim(out_root) == 0) then + out_root = '/tmp/SIMPLE_visual_artifacts' + end if + + out_dir = trim(out_root)//'/geoflux_rk45_orbit' + cmd = 'mkdir -p '//trim(out_dir) + call execute_command_line(trim(cmd), exitstat=mkdir_stat) + if (mkdir_stat /= 0) then + error stop 'test_geoflux_rk45_orbit_plot: failed to create output directory' + end if + + geqdsk_file = 'EQDSK_I.geqdsk' + call get_environment_variable('LIBNEO_TEST_GEQDSK', value=geqdsk_file, status=status) + if (status /= 0 .or. len_trim(geqdsk_file) == 0) then + geqdsk_file = 'EQDSK_I.geqdsk' + end if + + config_file = trim(out_dir)//'/simple_geoflux_rk45_plot.in' + start_file = trim(out_dir)//'/start.dat' + + call write_config(trim(config_file), trim(geqdsk_file)) + call write_start(trim(start_file)) + + call read_config(trim(config_file)) + call init_field(norb, netcdffile, ns_s, ns_tp, multharm, integmode) + call params_init + call init_magfie(isw_field_type) + + call load_starting_points(zstart, trim(start_file)) + z = zstart(:, 1) + + nmax = ntimstep + allocate(s_traj(nmax), theta_traj(nmax), phi_traj(nmax), time_traj(nmax)) + allocate(r_traj(nmax), z_traj(nmax)) + + ierr = 0 + n_used = 0 + do i = 1, nmax + s_traj(i) = z(1) + theta_traj(i) = z(2) + phi_traj(i) = z(3) + time_traj(i) = real(i - 1, dp) * dtaumin / max(v0, 1.0d-12) + + call geoflux_to_cyl((/ z(1), z(2), z(3) /), xcyl) + r_traj(i) = xcyl(1) + z_traj(i) = xcyl(3) + + n_used = i + call orbit_timestep_axis(z, dtaumin, dtaumin, relerr, ierr) + if (ierr /= 0) exit + if (z(1) < 0.0_dp .or. z(1) > 1.0_dp) exit + end do + + pdf_rz = trim(out_dir)//'/geoflux_rk45_orbit_RZ.pdf' + pdf_s = trim(out_dir)//'/geoflux_rk45_orbit_s.pdf' + + call figure() + call plot(r_traj(1:n_used), z_traj(1:n_used), linestyle='b-') + call xlabel('R (cm)') + call ylabel('Z (cm)') + call title('GEQDSK geoflux RK45 orbit projection (R,Z)') + call savefig(trim(pdf_rz)) + + call figure() + call plot(time_traj(1:n_used), s_traj(1:n_used), linestyle='k-') + call xlabel('t (s) [scaled]') + call ylabel('s') + call title('GEQDSK geoflux RK45 orbit: s(t)') + call savefig(trim(pdf_s)) + + inquire(file=trim(pdf_rz), exist=exists) + if (.not. exists) then + error stop 'test_geoflux_rk45_orbit_plot: missing RZ plot output' + end if + inquire(file=trim(pdf_s), exist=exists) + if (.not. exists) then + error stop 'test_geoflux_rk45_orbit_plot: missing s plot output' + end if + + print *, 'VISUAL_ARTIFACT: ', trim(pdf_rz) + print *, 'VISUAL_ARTIFACT: ', trim(pdf_s) + +contains + + subroutine write_config(path, geqdsk_path) + character(len=*), intent(in) :: path + character(len=*), intent(in) :: geqdsk_path + integer :: unit + + open(newunit=unit, file=trim(path), status='replace', action='write') + write(unit, '(A)') '&config' + write(unit, '(A)') 'netcdffile = '''//trim(geqdsk_path)//'''' + write(unit, '(A)') 'integmode = 0' + write(unit, '(A)') 'isw_field_type = 1' + write(unit, '(A)') 'ntestpart = 1' + write(unit, '(A)') 'ntimstep = 400' + write(unit, '(A)') 'npoiper2 = 64' + write(unit, '(A)') 'multharm = 3' + write(unit, '(A)') 'trace_time = 1d-10' + write(unit, '(A)') 'relerr = 1d-10' + write(unit, '(A)') 'deterministic = .True.' + write(unit, '(A)') '/' + close(unit) + end subroutine write_config + + subroutine write_start(path) + character(len=*), intent(in) :: path + integer :: unit + real(dp) :: s0, th0, ph0, p0, lam0 + + s0 = 0.25_dp + th0 = 0.1_dp * twopi + ph0 = 0.0_dp + p0 = 1.0_dp + lam0 = 0.2_dp + + open(newunit=unit, file=trim(path), status='replace', action='write') + write(unit, *) s0, th0, ph0, p0, lam0 + close(unit) + end subroutine write_start + +end program test_geoflux_rk45_orbit_plot + From 49d22d8337d13d546b6e089e50d6991ead3985ed Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Sat, 13 Dec 2025 11:32:54 +0100 Subject: [PATCH 08/19] Expand geoflux RK45 visual diagnostics --- test/tests/CMakeLists.txt | 4 +- test/tests/test_geoflux_rk45_orbit_plot.f90 | 280 +++++++++++++++++--- 2 files changed, 248 insertions(+), 36 deletions(-) diff --git a/test/tests/CMakeLists.txt b/test/tests/CMakeLists.txt index 6e51d9f2..b55a9fe6 100644 --- a/test/tests/CMakeLists.txt +++ b/test/tests/CMakeLists.txt @@ -159,8 +159,8 @@ target_link_libraries(test_geoflux_rk45_orbit_plot.x simple) add_test(NAME test_geoflux_rk45_orbit_plot COMMAND test_geoflux_rk45_orbit_plot.x) set_tests_properties(test_geoflux_rk45_orbit_plot PROPERTIES WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} - LABELS "regression;plot" - ENVIRONMENT "LIBNEO_TEST_GEQDSK=${CMAKE_CURRENT_BINARY_DIR}/EQDSK_I.geqdsk;SIMPLE_VISUAL_DIR=${CMAKE_CURRENT_BINARY_DIR}/visual_artifacts" + LABELS "plot;geoflux;rk45" + ENVIRONMENT "LIBNEO_TEST_GEQDSK=${CMAKE_CURRENT_BINARY_DIR}/EQDSK_I.geqdsk;SIMPLE_ARTIFACT_DIR=${CMAKE_BINARY_DIR}/artifacts" ) # Generate GVEC test data file for test_gvec using elliptic tokamak example diff --git a/test/tests/test_geoflux_rk45_orbit_plot.f90 b/test/tests/test_geoflux_rk45_orbit_plot.f90 index 5ede011d..7e8352f3 100644 --- a/test/tests/test_geoflux_rk45_orbit_plot.f90 +++ b/test/tests/test_geoflux_rk45_orbit_plot.f90 @@ -9,28 +9,50 @@ program test_geoflux_rk45_orbit_plot use alpha_lifetime_sub, only: orbit_timestep_axis use samplers, only: load_starting_points use geoflux_coordinates, only: geoflux_to_cyl - use fortplot, only: figure, plot, savefig, xlabel, ylabel, title + use geoflux_field, only: splint_geoflux_field + use field_sub, only: field_eq, psif + use pyplot_module, only: pyplot use util, only: twopi implicit none type(tracer_t) :: norb - character(len=1024) :: out_root, out_dir, config_file, start_file, geqdsk_file - character(len=1024) :: pdf_rz, pdf_s, cmd - integer :: status, ierr, i, nmax, n_used, mkdir_stat + type(pyplot) :: plt + character(len=1024) :: out_root, out_dir, out_orbit, out_flux, out_field + character(len=1024) :: config_file, start_file, geqdsk_file + character(len=1024) :: png_orbit_rz, png_s_t, png_theta_t, png_phi_t, png_bmod_t + character(len=1024) :: png_flux_rz, png_psi_rz, png_bmod_st + character(len=1024) :: png_bmod_rz, png_br_rz, png_bphi_rz, png_bz_rz + character(len=1024) :: traj_dat + character(len=1024) :: cmd + integer :: status, ierr, i, itheta, isurf, nmax, n_used, mkdir_stat + integer :: ntheta_plot, nsurf_plot, ns_plot real(dp), allocatable :: s_traj(:), theta_traj(:), phi_traj(:), time_traj(:) - real(dp), allocatable :: r_traj(:), z_traj(:) + real(dp), allocatable :: r_traj(:), z_traj(:), bmod_traj(:) + real(dp), allocatable :: theta_grid(:), s_grid(:) + real(dp), allocatable :: bmod_grid(:, :) + real(dp), allocatable :: r_surf(:, :), z_surf(:, :) + real(dp), allocatable :: surf_s(:) + real(dp), allocatable :: r_grid(:), z_grid(:) + real(dp), allocatable :: psi_rz(:, :), bmod_rz(:, :) + real(dp), allocatable :: br_rz(:, :), bphi_rz(:, :), bz_rz(:, :) real(dp) :: z(5), xcyl(3) + real(dp) :: acov_tmp(3), hcov_tmp(3), sqg_tmp(3) + real(dp) :: smin, smax, rmin, rmax, zmin, zmax logical :: exists out_root = '' - call get_environment_variable('SIMPLE_VISUAL_DIR', value=out_root, status=status) + call get_environment_variable('SIMPLE_ARTIFACT_DIR', value=out_root, status=status) if (status /= 0 .or. len_trim(out_root) == 0) then - out_root = '/tmp/SIMPLE_visual_artifacts' + out_root = '/tmp/SIMPLE_artifacts' end if - out_dir = trim(out_root)//'/geoflux_rk45_orbit' - cmd = 'mkdir -p '//trim(out_dir) + out_dir = trim(out_root)//'/plot/test_geoflux_rk45_orbit_plot' + out_orbit = trim(out_dir)//'/orbit' + out_flux = trim(out_dir)//'/flux_surfaces' + out_field = trim(out_dir)//'/fields' + + cmd = 'mkdir -p '//trim(out_orbit)//' '//trim(out_flux)//' '//trim(out_field) call execute_command_line(trim(cmd), exitstat=mkdir_stat) if (mkdir_stat /= 0) then error stop 'test_geoflux_rk45_orbit_plot: failed to create output directory' @@ -58,7 +80,7 @@ program test_geoflux_rk45_orbit_plot nmax = ntimstep allocate(s_traj(nmax), theta_traj(nmax), phi_traj(nmax), time_traj(nmax)) - allocate(r_traj(nmax), z_traj(nmax)) + allocate(r_traj(nmax), z_traj(nmax), bmod_traj(nmax)) ierr = 0 n_used = 0 @@ -71,6 +93,7 @@ program test_geoflux_rk45_orbit_plot call geoflux_to_cyl((/ z(1), z(2), z(3) /), xcyl) r_traj(i) = xcyl(1) z_traj(i) = xcyl(3) + call splint_geoflux_field(z(1), z(2), z(3), acov_tmp, hcov_tmp, bmod_traj(i), sqg_tmp) n_used = i call orbit_timestep_axis(z, dtaumin, dtaumin, relerr, ierr) @@ -78,34 +101,115 @@ program test_geoflux_rk45_orbit_plot if (z(1) < 0.0_dp .or. z(1) > 1.0_dp) exit end do - pdf_rz = trim(out_dir)//'/geoflux_rk45_orbit_RZ.pdf' - pdf_s = trim(out_dir)//'/geoflux_rk45_orbit_s.pdf' + if (n_used < 50) then + error stop 'test_geoflux_rk45_orbit_plot: orbit produced too few points' + end if + + call compute_ranges(s_traj(1:n_used), r_traj(1:n_used), z_traj(1:n_used), smin, smax, rmin, rmax, zmin, zmax) + if (.not. (rmax > rmin .and. zmax > zmin .and. smax > smin)) then + error stop 'test_geoflux_rk45_orbit_plot: orbit appears degenerate' + end if + + png_orbit_rz = trim(out_orbit)//'/orbit_RZ.png' + png_s_t = trim(out_orbit)//'/orbit_s_t.png' + png_theta_t = trim(out_orbit)//'/orbit_theta_t.png' + png_phi_t = trim(out_orbit)//'/orbit_phi_t.png' + png_bmod_t = trim(out_orbit)//'/orbit_Bmod_t.png' + traj_dat = trim(out_orbit)//'/trajectory.dat' + + call plt%initialize(grid=.true., xlabel='R (cm)', ylabel='Z (cm)', & + title='GEQDSK geoflux RK45 orbit projection (R,Z)', legend=.true., figsize=[10, 8]) + call plt%add_plot(r_traj(1:n_used), z_traj(1:n_used), label='orbit', linestyle='-') + call plt%savefig(trim(png_orbit_rz), pyfile=trim(out_orbit)//'/orbit_RZ.py') + + call write_trajectory_table(traj_dat, time_traj(1:n_used), s_traj(1:n_used), theta_traj(1:n_used), & + phi_traj(1:n_used), r_traj(1:n_used), z_traj(1:n_used), bmod_traj(1:n_used)) + + call plt%initialize(grid=.true., xlabel='t (s) [scaled]', ylabel='s', & + title='GEQDSK geoflux RK45 orbit: s(t)', figsize=[10, 6]) + call plt%add_plot(time_traj(1:n_used), s_traj(1:n_used), label='s', linestyle='-') + call plt%savefig(trim(png_s_t), pyfile=trim(out_orbit)//'/orbit_s_t.py') + + call plt%initialize(grid=.true., xlabel='t (s) [scaled]', ylabel='theta (rad)', & + title='GEQDSK geoflux RK45 orbit: theta(t)', figsize=[10, 6]) + call plt%add_plot(time_traj(1:n_used), theta_traj(1:n_used), label='theta', linestyle='-') + call plt%savefig(trim(png_theta_t), pyfile=trim(out_orbit)//'/orbit_theta_t.py') + + call plt%initialize(grid=.true., xlabel='t (s) [scaled]', ylabel='phi (rad)', & + title='GEQDSK geoflux RK45 orbit: phi(t)', figsize=[10, 6]) + call plt%add_plot(time_traj(1:n_used), phi_traj(1:n_used), label='phi', linestyle='-') + call plt%savefig(trim(png_phi_t), pyfile=trim(out_orbit)//'/orbit_phi_t.py') + + call plt%initialize(grid=.true., xlabel='t (s) [scaled]', ylabel='Bmod (G)', & + title='GEQDSK geoflux RK45 orbit: Bmod(t)', figsize=[10, 6]) + call plt%add_plot(time_traj(1:n_used), bmod_traj(1:n_used), label='Bmod', linestyle='-') + call plt%savefig(trim(png_bmod_t), pyfile=trim(out_orbit)//'/orbit_Bmod_t.py') + + png_flux_rz = trim(out_flux)//'/flux_surfaces_RZ_phi0.png' + + nsurf_plot = 6 + ntheta_plot = 361 + ns_plot = 128 + allocate(surf_s(nsurf_plot)) + surf_s = [0.10_dp, 0.25_dp, 0.40_dp, 0.60_dp, 0.80_dp, 0.95_dp] + allocate(r_surf(ntheta_plot, nsurf_plot), z_surf(ntheta_plot, nsurf_plot)) + allocate(theta_grid(ntheta_plot)) + + do itheta = 1, ntheta_plot + theta_grid(itheta) = (real(itheta - 1, dp) / real(ntheta_plot - 1, dp)) * twopi + end do + + do isurf = 1, nsurf_plot + do itheta = 1, ntheta_plot + call geoflux_to_cyl((/ surf_s(isurf), theta_grid(itheta), 0.0_dp /), xcyl) + r_surf(itheta, isurf) = xcyl(1) + z_surf(itheta, isurf) = xcyl(3) + end do + end do - call figure() - call plot(r_traj(1:n_used), z_traj(1:n_used), linestyle='b-') - call xlabel('R (cm)') - call ylabel('Z (cm)') - call title('GEQDSK geoflux RK45 orbit projection (R,Z)') - call savefig(trim(pdf_rz)) + call plt%initialize(grid=.true., xlabel='R (cm)', ylabel='Z (cm)', & + title='GEQDSK flux surfaces at phi=0 with orbit overlay', legend=.true., figsize=[10, 8]) + do isurf = 1, nsurf_plot + call plt%add_plot(r_surf(:, isurf), z_surf(:, isurf), label='surface', linestyle='-') + end do + call plt%add_plot(r_traj(1:n_used), z_traj(1:n_used), label='orbit', linestyle='-') + call plt%savefig(trim(png_flux_rz), pyfile=trim(out_flux)//'/flux_surfaces_overlay.py') + + call build_rz_field_maps(plt, out_field, out_flux, r_surf(:, nsurf_plot), z_surf(:, nsurf_plot), & + r_traj(1:n_used), z_traj(1:n_used)) - call figure() - call plot(time_traj(1:n_used), s_traj(1:n_used), linestyle='k-') - call xlabel('t (s) [scaled]') - call ylabel('s') - call title('GEQDSK geoflux RK45 orbit: s(t)') - call savefig(trim(pdf_s)) + allocate(s_grid(ns_plot)) + allocate(bmod_grid(nsurf_plot, ntheta_plot)) + do isurf = 1, nsurf_plot + do itheta = 1, ntheta_plot + call splint_geoflux_field(surf_s(isurf), theta_grid(itheta), 0.0_dp, acov_tmp, hcov_tmp, bmod_grid(isurf, itheta), sqg_tmp) + end do + end do - inquire(file=trim(pdf_rz), exist=exists) + png_bmod_st = trim(out_field)//'/Bmod_s_theta_phi0.png' + call plt%initialize(grid=.true., xlabel='theta (rad)', ylabel='surface index', & + title='GEQDSK Bmod(s,theta) at phi=0 (sampled on flux surfaces)', figsize=[10, 6]) + call plt%add_imshow(bmod_grid) + call plt%savefig(trim(png_bmod_st), pyfile=trim(out_field)//'/Bmod_s_theta_phi0.py') + + inquire(file=trim(png_orbit_rz), exist=exists) if (.not. exists) then error stop 'test_geoflux_rk45_orbit_plot: missing RZ plot output' end if - inquire(file=trim(pdf_s), exist=exists) + inquire(file=trim(png_flux_rz), exist=exists) if (.not. exists) then - error stop 'test_geoflux_rk45_orbit_plot: missing s plot output' + error stop 'test_geoflux_rk45_orbit_plot: missing flux surfaces plot output' end if - print *, 'VISUAL_ARTIFACT: ', trim(pdf_rz) - print *, 'VISUAL_ARTIFACT: ', trim(pdf_s) + print *, 'ARTIFACT_DIR: ', trim(out_dir) + print *, 'ARTIFACT: ', trim(png_orbit_rz) + print *, 'ARTIFACT: ', trim(png_s_t) + print *, 'ARTIFACT: ', trim(png_theta_t) + print *, 'ARTIFACT: ', trim(png_phi_t) + print *, 'ARTIFACT: ', trim(png_bmod_t) + print *, 'ARTIFACT: ', trim(png_flux_rz) + print *, 'ARTIFACT: ', trim(png_bmod_st) + print *, 'ARTIFACT: ', trim(traj_dat) contains @@ -120,11 +224,11 @@ subroutine write_config(path, geqdsk_path) write(unit, '(A)') 'integmode = 0' write(unit, '(A)') 'isw_field_type = 1' write(unit, '(A)') 'ntestpart = 1' - write(unit, '(A)') 'ntimstep = 400' + write(unit, '(A)') 'ntimstep = 800' write(unit, '(A)') 'npoiper2 = 64' write(unit, '(A)') 'multharm = 3' - write(unit, '(A)') 'trace_time = 1d-10' - write(unit, '(A)') 'relerr = 1d-10' + write(unit, '(A)') 'trace_time = 1d-6' + write(unit, '(A)') 'relerr = 1d-11' write(unit, '(A)') 'deterministic = .True.' write(unit, '(A)') '/' close(unit) @@ -139,12 +243,120 @@ subroutine write_start(path) th0 = 0.1_dp * twopi ph0 = 0.0_dp p0 = 1.0_dp - lam0 = 0.2_dp + lam0 = 0.7_dp open(newunit=unit, file=trim(path), status='replace', action='write') write(unit, *) s0, th0, ph0, p0, lam0 close(unit) end subroutine write_start -end program test_geoflux_rk45_orbit_plot + subroutine compute_ranges(s_arr, r_arr, z_arr, smin, smax, rmin, rmax, zmin, zmax) + real(dp), intent(in) :: s_arr(:), r_arr(:), z_arr(:) + real(dp), intent(out) :: smin, smax, rmin, rmax, zmin, zmax + smin = minval(s_arr) + smax = maxval(s_arr) + rmin = minval(r_arr) + rmax = maxval(r_arr) + zmin = minval(z_arr) + zmax = maxval(z_arr) + end subroutine compute_ranges + + subroutine write_trajectory_table(path, t, s, theta, phi, r, zc, bmod) + character(len=*), intent(in) :: path + real(dp), intent(in) :: t(:), s(:), theta(:), phi(:), r(:), zc(:), bmod(:) + integer :: unit, i + + open(newunit=unit, file=trim(path), status='replace', action='write') + write(unit, '(A)') '# t_scaled s theta phi R_cm Z_cm Bmod_G' + do i = 1, size(t) + write(unit, '(7ES22.14)') t(i), s(i), theta(i), phi(i), r(i), zc(i), bmod(i) + end do + close(unit) + end subroutine write_trajectory_table + + subroutine build_rz_field_maps(plt, out_field_dir, out_flux_dir, r_lcfs, z_lcfs, r_orbit, z_orbit) + type(pyplot), intent(inout) :: plt + character(len=*), intent(in) :: out_field_dir, out_flux_dir + real(dp), intent(in) :: r_lcfs(:), z_lcfs(:) + real(dp), intent(in) :: r_orbit(:), z_orbit(:) + + integer, parameter :: nr = 220, nz = 220 + real(dp) :: rmin_g, rmax_g, zmin_g, zmax_g, dr, dz + real(dp) :: phi0 + real(dp), allocatable :: rgrid(:), zgrid(:) + real(dp), allocatable :: psi_map(:, :), bmod_map(:, :) + real(dp), allocatable :: br_map(:, :), bphi_map(:, :), bz_map(:, :) + integer :: i, j + real(dp) :: br, bphi, bz, d1, d2, d3, d4, d5, d6, d7, d8, d9 + + phi0 = 0.0_dp + + rmin_g = min(minval(r_lcfs), minval(r_orbit)) + rmax_g = max(maxval(r_lcfs), maxval(r_orbit)) + zmin_g = min(minval(z_lcfs), minval(z_orbit)) + zmax_g = max(maxval(z_lcfs), maxval(z_orbit)) + + rmin_g = rmin_g - 0.1_dp * (rmax_g - rmin_g) + rmax_g = rmax_g + 0.1_dp * (rmax_g - rmin_g) + zmin_g = zmin_g - 0.1_dp * (zmax_g - zmin_g) + zmax_g = zmax_g + 0.1_dp * (zmax_g - zmin_g) + + allocate(rgrid(nr), zgrid(nz)) + allocate(psi_map(nr, nz), bmod_map(nr, nz)) + allocate(br_map(nr, nz), bphi_map(nr, nz), bz_map(nr, nz)) + + dr = (rmax_g - rmin_g) / real(nr - 1, dp) + dz = (zmax_g - zmin_g) / real(nz - 1, dp) + + do i = 1, nr + rgrid(i) = rmin_g + real(i - 1, dp) * dr + end do + do j = 1, nz + zgrid(j) = zmin_g + real(j - 1, dp) * dz + end do + + do j = 1, nz + do i = 1, nr + call field_eq(rgrid(i), phi0, zgrid(j), br, bphi, bz, d1, d2, d3, d4, d5, d6, d7, d8, d9) + psi_map(i, j) = psif + br_map(i, j) = br + bphi_map(i, j) = bphi + bz_map(i, j) = bz + bmod_map(i, j) = sqrt(br * br + bphi * bphi + bz * bz) + end do + end do + + call plt%initialize(grid=.true., xlabel='R (cm)', ylabel='Z (cm)', & + title='GEQDSK psi(R,Z) contours at phi=0 with orbit overlay', legend=.true., figsize=[10, 8]) + call plt%add_contour(rgrid, zgrid, transpose(psi_map), linestyle='-', colorbar=.false.) + call plt%add_plot(r_lcfs, z_lcfs, label='LCFS approx', linestyle='-') + call plt%add_plot(r_orbit, z_orbit, label='orbit', linestyle='-') + call plt%savefig(trim(out_flux_dir)//'/psi_contours_RZ_phi0.png', pyfile=trim(out_flux_dir)//'/psi_contours_RZ_phi0.py') + + call plt%initialize(grid=.true., xlabel='R (cm)', ylabel='Z (cm)', & + title='GEQDSK |B|(R,Z) at phi=0 with orbit overlay', legend=.true., figsize=[10, 8]) + call plt%add_contour(rgrid, zgrid, transpose(bmod_map), linestyle='-', colorbar=.false.) + call plt%add_plot(r_lcfs, z_lcfs, label='LCFS approx', linestyle='-') + call plt%add_plot(r_orbit, z_orbit, label='orbit', linestyle='-') + call plt%savefig(trim(out_field_dir)//'/Bmod_RZ_phi0.png', pyfile=trim(out_field_dir)//'/Bmod_RZ_phi0.py') + + call plt%initialize(grid=.true., xlabel='R (cm)', ylabel='Z (cm)', & + title='GEQDSK Br(R,Z) at phi=0', legend=.false., figsize=[10, 8]) + call plt%add_contour(rgrid, zgrid, transpose(br_map), linestyle='-', colorbar=.false.) + call plt%savefig(trim(out_field_dir)//'/Br_RZ_phi0.png', pyfile=trim(out_field_dir)//'/Br_RZ_phi0.py') + + call plt%initialize(grid=.true., xlabel='R (cm)', ylabel='Z (cm)', & + title='GEQDSK Bphi(R,Z) at phi=0', legend=.false., figsize=[10, 8]) + call plt%add_contour(rgrid, zgrid, transpose(bphi_map), linestyle='-', colorbar=.false.) + call plt%savefig(trim(out_field_dir)//'/Bphi_RZ_phi0.png', pyfile=trim(out_field_dir)//'/Bphi_RZ_phi0.py') + + call plt%initialize(grid=.true., xlabel='R (cm)', ylabel='Z (cm)', & + title='GEQDSK Bz(R,Z) at phi=0', legend=.false., figsize=[10, 8]) + call plt%add_contour(rgrid, zgrid, transpose(bz_map), linestyle='-', colorbar=.false.) + call plt%savefig(trim(out_field_dir)//'/Bz_RZ_phi0.png', pyfile=trim(out_field_dir)//'/Bz_RZ_phi0.py') + + deallocate(rgrid, zgrid, psi_map, bmod_map, br_map, bphi_map, bz_map) + end subroutine build_rz_field_maps + +end program test_geoflux_rk45_orbit_plot From 7a0013ad19f50594fbb6b0ff1fe8a2bbe2d99aa8 Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Sat, 13 Dec 2025 11:43:08 +0100 Subject: [PATCH 09/19] Fix geoflux contour orientation --- test/tests/test_geoflux_rk45_orbit_plot.f90 | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/tests/test_geoflux_rk45_orbit_plot.f90 b/test/tests/test_geoflux_rk45_orbit_plot.f90 index 7e8352f3..22ce4dbd 100644 --- a/test/tests/test_geoflux_rk45_orbit_plot.f90 +++ b/test/tests/test_geoflux_rk45_orbit_plot.f90 @@ -329,31 +329,31 @@ subroutine build_rz_field_maps(plt, out_field_dir, out_flux_dir, r_lcfs, z_lcfs, call plt%initialize(grid=.true., xlabel='R (cm)', ylabel='Z (cm)', & title='GEQDSK psi(R,Z) contours at phi=0 with orbit overlay', legend=.true., figsize=[10, 8]) - call plt%add_contour(rgrid, zgrid, transpose(psi_map), linestyle='-', colorbar=.false.) + call plt%add_contour(rgrid, zgrid, psi_map, linestyle='-', colorbar=.false.) call plt%add_plot(r_lcfs, z_lcfs, label='LCFS approx', linestyle='-') call plt%add_plot(r_orbit, z_orbit, label='orbit', linestyle='-') call plt%savefig(trim(out_flux_dir)//'/psi_contours_RZ_phi0.png', pyfile=trim(out_flux_dir)//'/psi_contours_RZ_phi0.py') call plt%initialize(grid=.true., xlabel='R (cm)', ylabel='Z (cm)', & title='GEQDSK |B|(R,Z) at phi=0 with orbit overlay', legend=.true., figsize=[10, 8]) - call plt%add_contour(rgrid, zgrid, transpose(bmod_map), linestyle='-', colorbar=.false.) + call plt%add_contour(rgrid, zgrid, bmod_map, linestyle='-', colorbar=.false.) call plt%add_plot(r_lcfs, z_lcfs, label='LCFS approx', linestyle='-') call plt%add_plot(r_orbit, z_orbit, label='orbit', linestyle='-') call plt%savefig(trim(out_field_dir)//'/Bmod_RZ_phi0.png', pyfile=trim(out_field_dir)//'/Bmod_RZ_phi0.py') call plt%initialize(grid=.true., xlabel='R (cm)', ylabel='Z (cm)', & title='GEQDSK Br(R,Z) at phi=0', legend=.false., figsize=[10, 8]) - call plt%add_contour(rgrid, zgrid, transpose(br_map), linestyle='-', colorbar=.false.) + call plt%add_contour(rgrid, zgrid, br_map, linestyle='-', colorbar=.false.) call plt%savefig(trim(out_field_dir)//'/Br_RZ_phi0.png', pyfile=trim(out_field_dir)//'/Br_RZ_phi0.py') call plt%initialize(grid=.true., xlabel='R (cm)', ylabel='Z (cm)', & title='GEQDSK Bphi(R,Z) at phi=0', legend=.false., figsize=[10, 8]) - call plt%add_contour(rgrid, zgrid, transpose(bphi_map), linestyle='-', colorbar=.false.) + call plt%add_contour(rgrid, zgrid, bphi_map, linestyle='-', colorbar=.false.) call plt%savefig(trim(out_field_dir)//'/Bphi_RZ_phi0.png', pyfile=trim(out_field_dir)//'/Bphi_RZ_phi0.py') call plt%initialize(grid=.true., xlabel='R (cm)', ylabel='Z (cm)', & title='GEQDSK Bz(R,Z) at phi=0', legend=.false., figsize=[10, 8]) - call plt%add_contour(rgrid, zgrid, transpose(bz_map), linestyle='-', colorbar=.false.) + call plt%add_contour(rgrid, zgrid, bz_map, linestyle='-', colorbar=.false.) call plt%savefig(trim(out_field_dir)//'/Bz_RZ_phi0.png', pyfile=trim(out_field_dir)//'/Bz_RZ_phi0.py') deallocate(rgrid, zgrid, psi_map, bmod_map, br_map, bphi_map, bz_map) From e39862eb3c47945be977345b54af86d39e7d211e Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Sat, 13 Dec 2025 11:46:47 +0100 Subject: [PATCH 10/19] Add trapped orbit to geoflux RK45 plot test --- test/tests/test_geoflux_rk45_orbit_plot.f90 | 161 +++++++++++++------- 1 file changed, 103 insertions(+), 58 deletions(-) diff --git a/test/tests/test_geoflux_rk45_orbit_plot.f90 b/test/tests/test_geoflux_rk45_orbit_plot.f90 index 22ce4dbd..a345b7c1 100644 --- a/test/tests/test_geoflux_rk45_orbit_plot.f90 +++ b/test/tests/test_geoflux_rk45_orbit_plot.f90 @@ -19,16 +19,21 @@ program test_geoflux_rk45_orbit_plot type(tracer_t) :: norb type(pyplot) :: plt character(len=1024) :: out_root, out_dir, out_orbit, out_flux, out_field - character(len=1024) :: config_file, start_file, geqdsk_file + character(len=1024) :: config_file, start_pass_file, start_trap_file, geqdsk_file + character(len=256) :: config_file_256 character(len=1024) :: png_orbit_rz, png_s_t, png_theta_t, png_phi_t, png_bmod_t character(len=1024) :: png_flux_rz, png_psi_rz, png_bmod_st character(len=1024) :: png_bmod_rz, png_br_rz, png_bphi_rz, png_bz_rz character(len=1024) :: traj_dat character(len=1024) :: cmd - integer :: status, ierr, i, itheta, isurf, nmax, n_used, mkdir_stat + integer, parameter :: norbits = 2 + integer :: status, ierr, i, itheta, isurf, nmax, mkdir_stat + integer :: iorb + integer :: n_used(norbits) integer :: ntheta_plot, nsurf_plot, ns_plot - real(dp), allocatable :: s_traj(:), theta_traj(:), phi_traj(:), time_traj(:) - real(dp), allocatable :: r_traj(:), z_traj(:), bmod_traj(:) + real(dp), allocatable :: time_traj(:) + real(dp), allocatable :: s_traj(:, :), theta_traj(:, :), phi_traj(:, :) + real(dp), allocatable :: r_traj(:, :), z_traj(:, :), bmod_traj(:, :) real(dp), allocatable :: theta_grid(:), s_grid(:) real(dp), allocatable :: bmod_grid(:, :) real(dp), allocatable :: r_surf(:, :), z_surf(:, :) @@ -39,6 +44,8 @@ program test_geoflux_rk45_orbit_plot real(dp) :: z(5), xcyl(3) real(dp) :: acov_tmp(3), hcov_tmp(3), sqg_tmp(3) real(dp) :: smin, smax, rmin, rmax, zmin, zmax + real(dp) :: color_pass(3), color_trap(3) + character(len=32) :: orbit_label(norbits) logical :: exists out_root = '' @@ -65,50 +72,49 @@ program test_geoflux_rk45_orbit_plot end if config_file = trim(out_dir)//'/simple_geoflux_rk45_plot.in' - start_file = trim(out_dir)//'/start.dat' + start_pass_file = trim(out_dir)//'/start_passing.dat' + start_trap_file = trim(out_dir)//'/start_trapped.dat' call write_config(trim(config_file), trim(geqdsk_file)) - call write_start(trim(start_file)) + call write_start(trim(start_pass_file), 0.25_dp, 0.1_dp*twopi, 0.0_dp, 1.0_dp, 0.7_dp) + call write_start(trim(start_trap_file), 0.25_dp, 0.0_dp, 0.0_dp, 1.0_dp, 0.0_dp) - call read_config(trim(config_file)) + config_file_256 = trim(config_file) + call read_config(config_file_256) call init_field(norb, netcdffile, ns_s, ns_tp, multharm, integmode) call params_init call init_magfie(isw_field_type) - call load_starting_points(zstart, trim(start_file)) - z = zstart(:, 1) + orbit_label(1) = 'passing' + orbit_label(2) = 'trapped' + color_pass = [0.0_dp, 0.0_dp, 1.0_dp] + color_trap = [1.0_dp, 0.0_dp, 0.0_dp] nmax = ntimstep - allocate(s_traj(nmax), theta_traj(nmax), phi_traj(nmax), time_traj(nmax)) - allocate(r_traj(nmax), z_traj(nmax), bmod_traj(nmax)) + allocate(time_traj(nmax)) + allocate(s_traj(nmax, norbits), theta_traj(nmax, norbits), phi_traj(nmax, norbits)) + allocate(r_traj(nmax, norbits), z_traj(nmax, norbits), bmod_traj(nmax, norbits)) - ierr = 0 - n_used = 0 do i = 1, nmax - s_traj(i) = z(1) - theta_traj(i) = z(2) - phi_traj(i) = z(3) time_traj(i) = real(i - 1, dp) * dtaumin / max(v0, 1.0d-12) + end do - call geoflux_to_cyl((/ z(1), z(2), z(3) /), xcyl) - r_traj(i) = xcyl(1) - z_traj(i) = xcyl(3) - call splint_geoflux_field(z(1), z(2), z(3), acov_tmp, hcov_tmp, bmod_traj(i), sqg_tmp) + call integrate_orbit_from_start(trim(start_pass_file), 1) + call integrate_orbit_from_start(trim(start_trap_file), 2) - n_used = i - call orbit_timestep_axis(z, dtaumin, dtaumin, relerr, ierr) - if (ierr /= 0) exit - if (z(1) < 0.0_dp .or. z(1) > 1.0_dp) exit + do iorb = 1, norbits + if (n_used(iorb) < 50) then + error stop 'test_geoflux_rk45_orbit_plot: orbit produced too few points' + end if end do - if (n_used < 50) then - error stop 'test_geoflux_rk45_orbit_plot: orbit produced too few points' - end if - - call compute_ranges(s_traj(1:n_used), r_traj(1:n_used), z_traj(1:n_used), smin, smax, rmin, rmax, zmin, zmax) - if (.not. (rmax > rmin .and. zmax > zmin .and. smax > smin)) then - error stop 'test_geoflux_rk45_orbit_plot: orbit appears degenerate' - end if + do iorb = 1, norbits + call compute_ranges(s_traj(1:n_used(iorb), iorb), r_traj(1:n_used(iorb), iorb), z_traj(1:n_used(iorb), iorb), & + smin, smax, rmin, rmax, zmin, zmax) + if (.not. (rmax > rmin .and. zmax > zmin .and. smax > smin)) then + error stop 'test_geoflux_rk45_orbit_plot: orbit appears degenerate' + end if + end do png_orbit_rz = trim(out_orbit)//'/orbit_RZ.png' png_s_t = trim(out_orbit)//'/orbit_s_t.png' @@ -119,30 +125,37 @@ program test_geoflux_rk45_orbit_plot call plt%initialize(grid=.true., xlabel='R (cm)', ylabel='Z (cm)', & title='GEQDSK geoflux RK45 orbit projection (R,Z)', legend=.true., figsize=[10, 8]) - call plt%add_plot(r_traj(1:n_used), z_traj(1:n_used), label='orbit', linestyle='-') + call plt%add_plot(r_traj(1:n_used(1), 1), z_traj(1:n_used(1), 1), label='passing', linestyle='-', color=color_pass) + call plt%add_plot(r_traj(1:n_used(2), 2), z_traj(1:n_used(2), 2), label='trapped', linestyle='-', color=color_trap) call plt%savefig(trim(png_orbit_rz), pyfile=trim(out_orbit)//'/orbit_RZ.py') - call write_trajectory_table(traj_dat, time_traj(1:n_used), s_traj(1:n_used), theta_traj(1:n_used), & - phi_traj(1:n_used), r_traj(1:n_used), z_traj(1:n_used), bmod_traj(1:n_used)) + call write_trajectory_table(trim(out_orbit)//'/trajectory_passing.dat', time_traj(1:n_used(1)), s_traj(1:n_used(1), 1), & + theta_traj(1:n_used(1), 1), phi_traj(1:n_used(1), 1), r_traj(1:n_used(1), 1), z_traj(1:n_used(1), 1), bmod_traj(1:n_used(1), 1)) + call write_trajectory_table(trim(out_orbit)//'/trajectory_trapped.dat', time_traj(1:n_used(2)), s_traj(1:n_used(2), 2), & + theta_traj(1:n_used(2), 2), phi_traj(1:n_used(2), 2), r_traj(1:n_used(2), 2), z_traj(1:n_used(2), 2), bmod_traj(1:n_used(2), 2)) call plt%initialize(grid=.true., xlabel='t (s) [scaled]', ylabel='s', & title='GEQDSK geoflux RK45 orbit: s(t)', figsize=[10, 6]) - call plt%add_plot(time_traj(1:n_used), s_traj(1:n_used), label='s', linestyle='-') + call plt%add_plot(time_traj(1:n_used(1)), s_traj(1:n_used(1), 1), label='passing', linestyle='-', color=color_pass) + call plt%add_plot(time_traj(1:n_used(2)), s_traj(1:n_used(2), 2), label='trapped', linestyle='-', color=color_trap) call plt%savefig(trim(png_s_t), pyfile=trim(out_orbit)//'/orbit_s_t.py') call plt%initialize(grid=.true., xlabel='t (s) [scaled]', ylabel='theta (rad)', & title='GEQDSK geoflux RK45 orbit: theta(t)', figsize=[10, 6]) - call plt%add_plot(time_traj(1:n_used), theta_traj(1:n_used), label='theta', linestyle='-') + call plt%add_plot(time_traj(1:n_used(1)), theta_traj(1:n_used(1), 1), label='passing', linestyle='-', color=color_pass) + call plt%add_plot(time_traj(1:n_used(2)), theta_traj(1:n_used(2), 2), label='trapped', linestyle='-', color=color_trap) call plt%savefig(trim(png_theta_t), pyfile=trim(out_orbit)//'/orbit_theta_t.py') call plt%initialize(grid=.true., xlabel='t (s) [scaled]', ylabel='phi (rad)', & title='GEQDSK geoflux RK45 orbit: phi(t)', figsize=[10, 6]) - call plt%add_plot(time_traj(1:n_used), phi_traj(1:n_used), label='phi', linestyle='-') + call plt%add_plot(time_traj(1:n_used(1)), phi_traj(1:n_used(1), 1), label='passing', linestyle='-', color=color_pass) + call plt%add_plot(time_traj(1:n_used(2)), phi_traj(1:n_used(2), 2), label='trapped', linestyle='-', color=color_trap) call plt%savefig(trim(png_phi_t), pyfile=trim(out_orbit)//'/orbit_phi_t.py') call plt%initialize(grid=.true., xlabel='t (s) [scaled]', ylabel='Bmod (G)', & title='GEQDSK geoflux RK45 orbit: Bmod(t)', figsize=[10, 6]) - call plt%add_plot(time_traj(1:n_used), bmod_traj(1:n_used), label='Bmod', linestyle='-') + call plt%add_plot(time_traj(1:n_used(1)), bmod_traj(1:n_used(1), 1), label='passing', linestyle='-', color=color_pass) + call plt%add_plot(time_traj(1:n_used(2)), bmod_traj(1:n_used(2), 2), label='trapped', linestyle='-', color=color_trap) call plt%savefig(trim(png_bmod_t), pyfile=trim(out_orbit)//'/orbit_Bmod_t.py') png_flux_rz = trim(out_flux)//'/flux_surfaces_RZ_phi0.png' @@ -172,11 +185,13 @@ program test_geoflux_rk45_orbit_plot do isurf = 1, nsurf_plot call plt%add_plot(r_surf(:, isurf), z_surf(:, isurf), label='surface', linestyle='-') end do - call plt%add_plot(r_traj(1:n_used), z_traj(1:n_used), label='orbit', linestyle='-') + call plt%add_plot(r_traj(1:n_used(1), 1), z_traj(1:n_used(1), 1), label='passing', linestyle='-', color=color_pass) + call plt%add_plot(r_traj(1:n_used(2), 2), z_traj(1:n_used(2), 2), label='trapped', linestyle='-', color=color_trap) call plt%savefig(trim(png_flux_rz), pyfile=trim(out_flux)//'/flux_surfaces_overlay.py') call build_rz_field_maps(plt, out_field, out_flux, r_surf(:, nsurf_plot), z_surf(:, nsurf_plot), & - r_traj(1:n_used), z_traj(1:n_used)) + r_traj(1:n_used(1), 1), z_traj(1:n_used(1), 1), r_traj(1:n_used(2), 2), z_traj(1:n_used(2), 2), & + color_pass, color_trap) allocate(s_grid(ns_plot)) allocate(bmod_grid(nsurf_plot, ntheta_plot)) @@ -209,7 +224,8 @@ program test_geoflux_rk45_orbit_plot print *, 'ARTIFACT: ', trim(png_bmod_t) print *, 'ARTIFACT: ', trim(png_flux_rz) print *, 'ARTIFACT: ', trim(png_bmod_st) - print *, 'ARTIFACT: ', trim(traj_dat) + print *, 'ARTIFACT: ', trim(out_orbit)//'/trajectory_passing.dat' + print *, 'ARTIFACT: ', trim(out_orbit)//'/trajectory_trapped.dat' contains @@ -234,16 +250,10 @@ subroutine write_config(path, geqdsk_path) close(unit) end subroutine write_config - subroutine write_start(path) + subroutine write_start(path, s0, th0, ph0, p0, lam0) character(len=*), intent(in) :: path + real(dp), intent(in) :: s0, th0, ph0, p0, lam0 integer :: unit - real(dp) :: s0, th0, ph0, p0, lam0 - - s0 = 0.25_dp - th0 = 0.1_dp * twopi - ph0 = 0.0_dp - p0 = 1.0_dp - lam0 = 0.7_dp open(newunit=unit, file=trim(path), status='replace', action='write') write(unit, *) s0, th0, ph0, p0, lam0 @@ -275,11 +285,14 @@ subroutine write_trajectory_table(path, t, s, theta, phi, r, zc, bmod) close(unit) end subroutine write_trajectory_table - subroutine build_rz_field_maps(plt, out_field_dir, out_flux_dir, r_lcfs, z_lcfs, r_orbit, z_orbit) + subroutine build_rz_field_maps(plt, out_field_dir, out_flux_dir, r_lcfs, z_lcfs, & + r_orbit_pass, z_orbit_pass, r_orbit_trap, z_orbit_trap, color_pass, color_trap) type(pyplot), intent(inout) :: plt character(len=*), intent(in) :: out_field_dir, out_flux_dir real(dp), intent(in) :: r_lcfs(:), z_lcfs(:) - real(dp), intent(in) :: r_orbit(:), z_orbit(:) + real(dp), intent(in) :: r_orbit_pass(:), z_orbit_pass(:) + real(dp), intent(in) :: r_orbit_trap(:), z_orbit_trap(:) + real(dp), intent(in) :: color_pass(3), color_trap(3) integer, parameter :: nr = 220, nz = 220 real(dp) :: rmin_g, rmax_g, zmin_g, zmax_g, dr, dz @@ -292,10 +305,10 @@ subroutine build_rz_field_maps(plt, out_field_dir, out_flux_dir, r_lcfs, z_lcfs, phi0 = 0.0_dp - rmin_g = min(minval(r_lcfs), minval(r_orbit)) - rmax_g = max(maxval(r_lcfs), maxval(r_orbit)) - zmin_g = min(minval(z_lcfs), minval(z_orbit)) - zmax_g = max(maxval(z_lcfs), maxval(z_orbit)) + rmin_g = min(minval(r_lcfs), min(minval(r_orbit_pass), minval(r_orbit_trap))) + rmax_g = max(maxval(r_lcfs), max(maxval(r_orbit_pass), maxval(r_orbit_trap))) + zmin_g = min(minval(z_lcfs), min(minval(z_orbit_pass), minval(z_orbit_trap))) + zmax_g = max(maxval(z_lcfs), max(maxval(z_orbit_pass), maxval(z_orbit_trap))) rmin_g = rmin_g - 0.1_dp * (rmax_g - rmin_g) rmax_g = rmax_g + 0.1_dp * (rmax_g - rmin_g) @@ -331,14 +344,16 @@ subroutine build_rz_field_maps(plt, out_field_dir, out_flux_dir, r_lcfs, z_lcfs, title='GEQDSK psi(R,Z) contours at phi=0 with orbit overlay', legend=.true., figsize=[10, 8]) call plt%add_contour(rgrid, zgrid, psi_map, linestyle='-', colorbar=.false.) call plt%add_plot(r_lcfs, z_lcfs, label='LCFS approx', linestyle='-') - call plt%add_plot(r_orbit, z_orbit, label='orbit', linestyle='-') + call plt%add_plot(r_orbit_pass, z_orbit_pass, label='passing', linestyle='-', color=color_pass) + call plt%add_plot(r_orbit_trap, z_orbit_trap, label='trapped', linestyle='-', color=color_trap) call plt%savefig(trim(out_flux_dir)//'/psi_contours_RZ_phi0.png', pyfile=trim(out_flux_dir)//'/psi_contours_RZ_phi0.py') call plt%initialize(grid=.true., xlabel='R (cm)', ylabel='Z (cm)', & title='GEQDSK |B|(R,Z) at phi=0 with orbit overlay', legend=.true., figsize=[10, 8]) call plt%add_contour(rgrid, zgrid, bmod_map, linestyle='-', colorbar=.false.) call plt%add_plot(r_lcfs, z_lcfs, label='LCFS approx', linestyle='-') - call plt%add_plot(r_orbit, z_orbit, label='orbit', linestyle='-') + call plt%add_plot(r_orbit_pass, z_orbit_pass, label='passing', linestyle='-', color=color_pass) + call plt%add_plot(r_orbit_trap, z_orbit_trap, label='trapped', linestyle='-', color=color_trap) call plt%savefig(trim(out_field_dir)//'/Bmod_RZ_phi0.png', pyfile=trim(out_field_dir)//'/Bmod_RZ_phi0.py') call plt%initialize(grid=.true., xlabel='R (cm)', ylabel='Z (cm)', & @@ -359,4 +374,34 @@ subroutine build_rz_field_maps(plt, out_field_dir, out_flux_dir, r_lcfs, z_lcfs, deallocate(rgrid, zgrid, psi_map, bmod_map, br_map, bphi_map, bz_map) end subroutine build_rz_field_maps + subroutine integrate_orbit_from_start(start_path, orbit_index) + character(len=*), intent(in) :: start_path + integer, intent(in) :: orbit_index + + integer :: i_local + real(dp) :: z_local(5) + integer :: ierr_local + + call load_starting_points(zstart, trim(start_path)) + z_local = zstart(:, 1) + + ierr_local = 0 + n_used(orbit_index) = 0 + do i_local = 1, nmax + s_traj(i_local, orbit_index) = z_local(1) + theta_traj(i_local, orbit_index) = z_local(2) + phi_traj(i_local, orbit_index) = z_local(3) + + call geoflux_to_cyl((/ z_local(1), z_local(2), z_local(3) /), xcyl) + r_traj(i_local, orbit_index) = xcyl(1) + z_traj(i_local, orbit_index) = xcyl(3) + call splint_geoflux_field(z_local(1), z_local(2), z_local(3), acov_tmp, hcov_tmp, bmod_traj(i_local, orbit_index), sqg_tmp) + + n_used(orbit_index) = i_local + call orbit_timestep_axis(z_local, dtaumin, dtaumin, relerr, ierr_local) + if (ierr_local /= 0) exit + if (z_local(1) < 0.0_dp .or. z_local(1) > 1.0_dp) exit + end do + end subroutine integrate_orbit_from_start + end program test_geoflux_rk45_orbit_plot From 40c5ab7d97fa6003366dacf8f56bb92d81eb7105 Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Sat, 13 Dec 2025 13:13:42 +0100 Subject: [PATCH 11/19] Use GEQDSK major radius in params_init --- src/params.f90 | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/params.f90 b/src/params.f90 index a4c977f2..1088f6a4 100644 --- a/src/params.f90 +++ b/src/params.f90 @@ -2,13 +2,14 @@ module params use util, only: pi, c, e_charge, p_mass, ev use parmot_mod, only : ro0, rmu use new_vmec_stuff_mod, only : old_axis_healing, old_axis_healing_boundary, & - netcdffile, ns_s, ns_tp, multharm, vmec_B_scale, vmec_RZ_scale + netcdffile, ns_s, ns_tp, multharm, vmec_B_scale, vmec_RZ_scale, rmajor use velo_mod, only : isw_field_type use magfie_sub, only : TEST use field_can_mod, only : eval_field => evaluate, field_can_t use orbit_symplectic_base, only : symplectic_integrator_t, multistage_integrator_t, & EXPL_IMPL_EULER use vmecin_sub, only : stevvo + use field, only : is_geqdsk use callback, only : output_error, output_orbits_macrostep implicit none @@ -126,6 +127,7 @@ end subroutine read_config subroutine params_init real(dp) :: E_alpha integer :: L1i + real(dp) :: RT0_local if (isw_field_type == TEST) then ! TEST field uses normalized units: B0=1, R0=1, a=0.5 @@ -156,8 +158,16 @@ subroutine params_init ! normalized time step: dtau=tau/dble(ntimstep-1) ! parameters for the vacuum chamber: - call stevvo(RT0,R0i,L1i,cbfi,bz0i,bf0) - rbig=rt0 + if (is_geqdsk(netcdffile)) then + ! GEQDSK/geoflux mode: use axis major radius set during init_vmec. + ! VMEC stevvo parameters are not valid here. + RT0_local = rmajor + L1i = 1 + rbig = RT0_local + else + call stevvo(RT0, R0i, L1i, cbfi, bz0i, bf0) + rbig = RT0 + end if ! field line integration step step over phi (to check chamber wall crossing) dphi=2.d0*pi/(L1i*npoiper) ! orbit integration time step (to check chamber wall crossing) From 2adcdc5102f260250015f1a398708632fb0011f1 Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Sat, 13 Dec 2025 13:13:47 +0100 Subject: [PATCH 12/19] Start trapped RK45 orbit at Bmax --- test/tests/test_geoflux_rk45_orbit_plot.f90 | 36 +++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/test/tests/test_geoflux_rk45_orbit_plot.f90 b/test/tests/test_geoflux_rk45_orbit_plot.f90 index a345b7c1..163379a4 100644 --- a/test/tests/test_geoflux_rk45_orbit_plot.f90 +++ b/test/tests/test_geoflux_rk45_orbit_plot.f90 @@ -76,8 +76,6 @@ program test_geoflux_rk45_orbit_plot start_trap_file = trim(out_dir)//'/start_trapped.dat' call write_config(trim(config_file), trim(geqdsk_file)) - call write_start(trim(start_pass_file), 0.25_dp, 0.1_dp*twopi, 0.0_dp, 1.0_dp, 0.7_dp) - call write_start(trim(start_trap_file), 0.25_dp, 0.0_dp, 0.0_dp, 1.0_dp, 0.0_dp) config_file_256 = trim(config_file) call read_config(config_file_256) @@ -85,6 +83,9 @@ program test_geoflux_rk45_orbit_plot call params_init call init_magfie(isw_field_type) + call write_passing_start(trim(start_pass_file)) + call write_trapped_start_at_bmax(trim(start_trap_file)) + orbit_label(1) = 'passing' orbit_label(2) = 'trapped' color_pass = [0.0_dp, 0.0_dp, 1.0_dp] @@ -260,6 +261,37 @@ subroutine write_start(path, s0, th0, ph0, p0, lam0) close(unit) end subroutine write_start + subroutine write_passing_start(path) + character(len=*), intent(in) :: path + call write_start(path, 0.25_dp, 0.1_dp*twopi, 0.0_dp, 1.0_dp, 0.7_dp) + end subroutine write_passing_start + + subroutine write_trapped_start_at_bmax(path) + character(len=*), intent(in) :: path + + integer, parameter :: ntheta_scan = 721 + real(dp) :: s0, phi0 + real(dp) :: theta_scan, bmod_val, bmod_best, theta_best + integer :: i + real(dp) :: acov_local(3), hcov_local(3), sqg_local(3) + + s0 = 0.25_dp + phi0 = 0.0_dp + + theta_best = 0.0_dp + bmod_best = -1.0_dp + do i = 1, ntheta_scan + theta_scan = (real(i - 1, dp) / real(ntheta_scan - 1, dp)) * twopi + call splint_geoflux_field(s0, theta_scan, phi0, acov_local, hcov_local, bmod_val, sqg_local) + if (bmod_val > bmod_best) then + bmod_best = bmod_val + theta_best = theta_scan + end if + end do + + call write_start(path, s0, theta_best, phi0, 1.0_dp, 0.0_dp) + end subroutine write_trapped_start_at_bmax + subroutine compute_ranges(s_arr, r_arr, z_arr, smin, smax, rmin, rmax, zmin, zmax) real(dp), intent(in) :: s_arr(:), r_arr(:), z_arr(:) real(dp), intent(out) :: smin, smax, rmin, rmax, zmin, zmax From 9db96cc5210796a7cb31bce166a574897f97eb2a Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Sat, 13 Dec 2025 16:49:03 +0100 Subject: [PATCH 13/19] Fix geoflux hcurl component for guiding-center --- src/magfie.f90 | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/magfie.f90 b/src/magfie.f90 index 4ebd0593..fce7443e 100644 --- a/src/magfie.f90 +++ b/src/magfie.f90 @@ -386,15 +386,16 @@ subroutine magfie_geoflux(x, bmod, sqrtg, bder, hcovar, hctrvr, hcurl) bder = bder/max(bmod, 1.0d-12) - hctrvr = matmul(ginv, hcovar) - - if (sqrtg > 0.0_dp) then - hcurl(1) = (dh_dp(3) - dh_dt(3))/sqrtg - hcurl(2) = (dh_dp(1) - dh_ds(3))/sqrtg - hcurl(3) = (dh_ds(2) - dh_dt(1))/sqrtg - else - hcurl = 0.0_dp - end if + hctrvr = matmul(ginv, hcovar) + + if (sqrtg > 0.0_dp) then + ! curl(h)^s = (∂_θ h_φ - ∂_φ h_θ) / sqrtg + hcurl(1) = (dh_dt(3) - dh_dp(2))/sqrtg + hcurl(2) = (dh_dp(1) - dh_ds(3))/sqrtg + hcurl(3) = (dh_ds(2) - dh_dt(1))/sqrtg + else + hcurl = 0.0_dp + end if end subroutine magfie_geoflux From ce51ad4fb4ab25d869d727de3949a6626a1cac03 Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Sat, 13 Dec 2025 16:49:08 +0100 Subject: [PATCH 14/19] Add phi=0 Poincare plot for banana --- test/tests/test_geoflux_rk45_orbit_plot.f90 | 55 +++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/test/tests/test_geoflux_rk45_orbit_plot.f90 b/test/tests/test_geoflux_rk45_orbit_plot.f90 index 163379a4..4e5edaf2 100644 --- a/test/tests/test_geoflux_rk45_orbit_plot.f90 +++ b/test/tests/test_geoflux_rk45_orbit_plot.f90 @@ -22,6 +22,7 @@ program test_geoflux_rk45_orbit_plot character(len=1024) :: config_file, start_pass_file, start_trap_file, geqdsk_file character(len=256) :: config_file_256 character(len=1024) :: png_orbit_rz, png_s_t, png_theta_t, png_phi_t, png_bmod_t + character(len=1024) :: png_poincare_phi0 character(len=1024) :: png_flux_rz, png_psi_rz, png_bmod_st character(len=1024) :: png_bmod_rz, png_br_rz, png_bphi_rz, png_bz_rz character(len=1024) :: traj_dat @@ -122,6 +123,7 @@ program test_geoflux_rk45_orbit_plot png_theta_t = trim(out_orbit)//'/orbit_theta_t.png' png_phi_t = trim(out_orbit)//'/orbit_phi_t.png' png_bmod_t = trim(out_orbit)//'/orbit_Bmod_t.png' + png_poincare_phi0 = trim(out_orbit)//'/orbit_poincare_phi0.png' traj_dat = trim(out_orbit)//'/trajectory.dat' call plt%initialize(grid=.true., xlabel='R (cm)', ylabel='Z (cm)', & @@ -130,6 +132,8 @@ program test_geoflux_rk45_orbit_plot call plt%add_plot(r_traj(1:n_used(2), 2), z_traj(1:n_used(2), 2), label='trapped', linestyle='-', color=color_trap) call plt%savefig(trim(png_orbit_rz), pyfile=trim(out_orbit)//'/orbit_RZ.py') + call plot_poincare_phi0(plt, png_poincare_phi0, trim(out_orbit)//'/orbit_poincare_phi0.py') + call write_trajectory_table(trim(out_orbit)//'/trajectory_passing.dat', time_traj(1:n_used(1)), s_traj(1:n_used(1), 1), & theta_traj(1:n_used(1), 1), phi_traj(1:n_used(1), 1), r_traj(1:n_used(1), 1), z_traj(1:n_used(1), 1), bmod_traj(1:n_used(1), 1)) call write_trajectory_table(trim(out_orbit)//'/trajectory_trapped.dat', time_traj(1:n_used(2)), s_traj(1:n_used(2), 2), & @@ -219,6 +223,7 @@ program test_geoflux_rk45_orbit_plot print *, 'ARTIFACT_DIR: ', trim(out_dir) print *, 'ARTIFACT: ', trim(png_orbit_rz) + print *, 'ARTIFACT: ', trim(png_poincare_phi0) print *, 'ARTIFACT: ', trim(png_s_t) print *, 'ARTIFACT: ', trim(png_theta_t) print *, 'ARTIFACT: ', trim(png_phi_t) @@ -436,4 +441,54 @@ subroutine integrate_orbit_from_start(start_path, orbit_index) end do end subroutine integrate_orbit_from_start + subroutine plot_poincare_phi0(plt, png_path, py_path) + type(pyplot), intent(inout) :: plt + character(len=*), intent(in) :: png_path, py_path + + integer, parameter :: max_points = 5000 + real(dp), parameter :: tol = 2.0d-2 + real(dp) :: phi_wrapped, phase + real(dp) :: r_pts_pass(max_points), z_pts_pass(max_points) + real(dp) :: r_pts_trap(max_points), z_pts_trap(max_points) + integer :: i, n_pass, n_trap + + n_pass = 0 + do i = 1, n_used(1) + phi_wrapped = modulo(phi_traj(i, 1), twopi) + phase = phi_wrapped + if (phase > 0.5_dp*twopi) phase = phase - twopi + if (abs(phase) < tol) then + if (n_pass < max_points) then + n_pass = n_pass + 1 + r_pts_pass(n_pass) = r_traj(i, 1) + z_pts_pass(n_pass) = z_traj(i, 1) + end if + end if + end do + + n_trap = 0 + do i = 1, n_used(2) + phi_wrapped = modulo(phi_traj(i, 2), twopi) + phase = phi_wrapped + if (phase > 0.5_dp*twopi) phase = phase - twopi + if (abs(phase) < tol) then + if (n_trap < max_points) then + n_trap = n_trap + 1 + r_pts_trap(n_trap) = r_traj(i, 2) + z_pts_trap(n_trap) = z_traj(i, 2) + end if + end if + end do + + call plt%initialize(grid=.true., xlabel='R (cm)', ylabel='Z (cm)', & + title='Poincare section at phi≈0 (shows banana)', legend=.true., figsize=[10, 8]) + if (n_pass > 0) then + call plt%add_plot(r_pts_pass(1:n_pass), z_pts_pass(1:n_pass), label='passing', linestyle='o', color=color_pass, markersize=2) + end if + if (n_trap > 0) then + call plt%add_plot(r_pts_trap(1:n_trap), z_pts_trap(1:n_trap), label='trapped', linestyle='o', color=color_trap, markersize=2) + end if + call plt%savefig(trim(png_path), pyfile=trim(py_path)) + end subroutine plot_poincare_phi0 + end program test_geoflux_rk45_orbit_plot From 03c7d39f96fc167c082270c4772e55e46c912376 Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Sat, 13 Dec 2025 17:01:04 +0100 Subject: [PATCH 15/19] Fix near-axis (s,theta) transform and add RK45 invariants plots --- src/sub_alpha_lifetime_can.f90 | 24 ++++++------- test/tests/test_geoflux_rk45_orbit_plot.f90 | 40 ++++++++++++++++++--- 2 files changed, 47 insertions(+), 17 deletions(-) diff --git a/src/sub_alpha_lifetime_can.f90 b/src/sub_alpha_lifetime_can.f90 index e9c7687f..937e0642 100644 --- a/src/sub_alpha_lifetime_can.f90 +++ b/src/sub_alpha_lifetime_can.f90 @@ -356,17 +356,17 @@ subroutine velo_axis(tau,z_axis,vz_axis) real(dp), intent(out) :: vz_axis(:) real(dp) :: derlogsqs real(dp), dimension(5) :: z,vz + real(dp) :: s_safe ! - ! z(1)=z_axis(1)**2+z_axis(2)**2 - z(1)=sqrt(z_axis(1)**2+z_axis(2)**2) - z(1)=max(z(1),1.d-8) + z(1)=z_axis(1)**2+z_axis(2)**2 + z(1)=max(z(1),1.d-16) z(2)=atan2(z_axis(2),z_axis(1)) z(3:5)=z_axis(3:5) ! call velo_can(tau,z,vz) ! - ! derlogsqs=0.5d0*vz(1)/sqrt(z(1)) - derlogsqs=vz(1)/z(1) + s_safe = max(z(1), 1.d-16) + derlogsqs=0.5d0*vz(1)/s_safe vz_axis(1)=derlogsqs*z_axis(1)-vz(2)*z_axis(2) vz_axis(2)=derlogsqs*z_axis(2)+vz(2)*z_axis(1) vz_axis(3:5)=vz(3:5) @@ -415,7 +415,7 @@ subroutine orbit_timestep_axis(z,dtau,dtaumin,relerr,ierr) if(near_axis) then if(z(1)**2+z(2)**2.gt.snear_axis**2) then near_axis=.false. - z1=sqrt(z(1)**2+z(2)**2) + z1=z(1)**2+z(2)**2 z2=atan2(z(2),z(1)) z(1)=z1 z(2)=z2 @@ -437,8 +437,8 @@ subroutine orbit_timestep_axis(z,dtau,dtaumin,relerr,ierr) else if(z(1).lt.snear_axis) then near_axis=.true. - z1=z(1)*cos(z(2)) - z2=z(1)*sin(z(2)) + z1=sqrt(max(z(1), 0.d0))*cos(z(2)) + z2=sqrt(max(z(1), 0.d0))*sin(z(2)) z(1)=z1 z(2)=z2 ! @@ -467,7 +467,7 @@ subroutine orbit_timestep_axis(z,dtau,dtaumin,relerr,ierr) if(near_axis) then if(z(1)**2+z(2)**2.gt.snear_axis**2) then near_axis=.false. - z1=sqrt(z(1)**2+z(2)**2) + z1=z(1)**2+z(2)**2 z2=atan2(z(2),z(1)) z(1)=z1 z(2)=z2 @@ -489,8 +489,8 @@ subroutine orbit_timestep_axis(z,dtau,dtaumin,relerr,ierr) else if(z(1).lt.snear_axis) then near_axis=.true. - z1=z(1)*cos(z(2)) - z2=z(1)*sin(z(2)) + z1=sqrt(max(z(1), 0.d0))*cos(z(2)) + z2=sqrt(max(z(1), 0.d0))*sin(z(2)) z(1)=z1 z(2)=z2 ! @@ -512,7 +512,7 @@ subroutine orbit_timestep_axis(z,dtau,dtaumin,relerr,ierr) endif ! if(near_axis) then - z1=sqrt(z(1)**2+z(2)**2) + z1=z(1)**2+z(2)**2 z2=atan2(z(2),z(1)) z(1)=z1 z(2)=z2 diff --git a/test/tests/test_geoflux_rk45_orbit_plot.f90 b/test/tests/test_geoflux_rk45_orbit_plot.f90 index 4e5edaf2..184f7596 100644 --- a/test/tests/test_geoflux_rk45_orbit_plot.f90 +++ b/test/tests/test_geoflux_rk45_orbit_plot.f90 @@ -35,6 +35,7 @@ program test_geoflux_rk45_orbit_plot real(dp), allocatable :: time_traj(:) real(dp), allocatable :: s_traj(:, :), theta_traj(:, :), phi_traj(:, :) real(dp), allocatable :: r_traj(:, :), z_traj(:, :), bmod_traj(:, :) + real(dp), allocatable :: p_traj(:, :), lam_traj(:, :), mu_traj(:, :) real(dp), allocatable :: theta_grid(:), s_grid(:) real(dp), allocatable :: bmod_grid(:, :) real(dp), allocatable :: r_surf(:, :), z_surf(:, :) @@ -96,6 +97,7 @@ program test_geoflux_rk45_orbit_plot allocate(time_traj(nmax)) allocate(s_traj(nmax, norbits), theta_traj(nmax, norbits), phi_traj(nmax, norbits)) allocate(r_traj(nmax, norbits), z_traj(nmax, norbits), bmod_traj(nmax, norbits)) + allocate(p_traj(nmax, norbits), lam_traj(nmax, norbits), mu_traj(nmax, norbits)) do i = 1, nmax time_traj(i) = real(i - 1, dp) * dtaumin / max(v0, 1.0d-12) @@ -135,9 +137,11 @@ program test_geoflux_rk45_orbit_plot call plot_poincare_phi0(plt, png_poincare_phi0, trim(out_orbit)//'/orbit_poincare_phi0.py') call write_trajectory_table(trim(out_orbit)//'/trajectory_passing.dat', time_traj(1:n_used(1)), s_traj(1:n_used(1), 1), & - theta_traj(1:n_used(1), 1), phi_traj(1:n_used(1), 1), r_traj(1:n_used(1), 1), z_traj(1:n_used(1), 1), bmod_traj(1:n_used(1), 1)) + theta_traj(1:n_used(1), 1), phi_traj(1:n_used(1), 1), r_traj(1:n_used(1), 1), z_traj(1:n_used(1), 1), bmod_traj(1:n_used(1), 1), & + p_traj(1:n_used(1), 1), lam_traj(1:n_used(1), 1), mu_traj(1:n_used(1), 1)) call write_trajectory_table(trim(out_orbit)//'/trajectory_trapped.dat', time_traj(1:n_used(2)), s_traj(1:n_used(2), 2), & - theta_traj(1:n_used(2), 2), phi_traj(1:n_used(2), 2), r_traj(1:n_used(2), 2), z_traj(1:n_used(2), 2), bmod_traj(1:n_used(2), 2)) + theta_traj(1:n_used(2), 2), phi_traj(1:n_used(2), 2), r_traj(1:n_used(2), 2), z_traj(1:n_used(2), 2), bmod_traj(1:n_used(2), 2), & + p_traj(1:n_used(2), 2), lam_traj(1:n_used(2), 2), mu_traj(1:n_used(2), 2)) call plt%initialize(grid=.true., xlabel='t (s) [scaled]', ylabel='s', & title='GEQDSK geoflux RK45 orbit: s(t)', figsize=[10, 6]) @@ -163,6 +167,24 @@ program test_geoflux_rk45_orbit_plot call plt%add_plot(time_traj(1:n_used(2)), bmod_traj(1:n_used(2), 2), label='trapped', linestyle='-', color=color_trap) call plt%savefig(trim(png_bmod_t), pyfile=trim(out_orbit)//'/orbit_Bmod_t.py') + call plt%initialize(grid=.true., xlabel='t (s) [scaled]', ylabel='p (normalized)', & + title='GEQDSK geoflux RK45 orbit: p(t)', figsize=[10, 6]) + call plt%add_plot(time_traj(1:n_used(1)), p_traj(1:n_used(1), 1), label='passing', linestyle='-', color=color_pass) + call plt%add_plot(time_traj(1:n_used(2)), p_traj(1:n_used(2), 2), label='trapped', linestyle='-', color=color_trap) + call plt%savefig(trim(out_orbit)//'/orbit_p_t.png', pyfile=trim(out_orbit)//'/orbit_p_t.py') + + call plt%initialize(grid=.true., xlabel='t (s) [scaled]', ylabel='lambda = v_par/v', & + title='GEQDSK geoflux RK45 orbit: lambda(t)', figsize=[10, 6]) + call plt%add_plot(time_traj(1:n_used(1)), lam_traj(1:n_used(1), 1), label='passing', linestyle='-', color=color_pass) + call plt%add_plot(time_traj(1:n_used(2)), lam_traj(1:n_used(2), 2), label='trapped', linestyle='-', color=color_trap) + call plt%savefig(trim(out_orbit)//'/orbit_lambda_t.png', pyfile=trim(out_orbit)//'/orbit_lambda_t.py') + + call plt%initialize(grid=.true., xlabel='t (s) [scaled]', ylabel='mu ~ p^2 (1-lambda^2) / (2 B)', & + title='GEQDSK geoflux RK45 orbit: mu(t) diagnostic', figsize=[10, 6]) + call plt%add_plot(time_traj(1:n_used(1)), mu_traj(1:n_used(1), 1), label='passing', linestyle='-', color=color_pass) + call plt%add_plot(time_traj(1:n_used(2)), mu_traj(1:n_used(2), 2), label='trapped', linestyle='-', color=color_trap) + call plt%savefig(trim(out_orbit)//'/orbit_mu_t.png', pyfile=trim(out_orbit)//'/orbit_mu_t.py') + png_flux_rz = trim(out_flux)//'/flux_surfaces_RZ_phi0.png' nsurf_plot = 6 @@ -228,6 +250,9 @@ program test_geoflux_rk45_orbit_plot print *, 'ARTIFACT: ', trim(png_theta_t) print *, 'ARTIFACT: ', trim(png_phi_t) print *, 'ARTIFACT: ', trim(png_bmod_t) + print *, 'ARTIFACT: ', trim(out_orbit)//'/orbit_p_t.png' + print *, 'ARTIFACT: ', trim(out_orbit)//'/orbit_lambda_t.png' + print *, 'ARTIFACT: ', trim(out_orbit)//'/orbit_mu_t.png' print *, 'ARTIFACT: ', trim(png_flux_rz) print *, 'ARTIFACT: ', trim(png_bmod_st) print *, 'ARTIFACT: ', trim(out_orbit)//'/trajectory_passing.dat' @@ -309,15 +334,16 @@ subroutine compute_ranges(s_arr, r_arr, z_arr, smin, smax, rmin, rmax, zmin, zma zmax = maxval(z_arr) end subroutine compute_ranges - subroutine write_trajectory_table(path, t, s, theta, phi, r, zc, bmod) + subroutine write_trajectory_table(path, t, s, theta, phi, r, zc, bmod, p, lam, mu) character(len=*), intent(in) :: path real(dp), intent(in) :: t(:), s(:), theta(:), phi(:), r(:), zc(:), bmod(:) + real(dp), intent(in) :: p(:), lam(:), mu(:) integer :: unit, i open(newunit=unit, file=trim(path), status='replace', action='write') - write(unit, '(A)') '# t_scaled s theta phi R_cm Z_cm Bmod_G' + write(unit, '(A)') '# t_scaled s theta phi R_cm Z_cm Bmod_G p lambda mu' do i = 1, size(t) - write(unit, '(7ES22.14)') t(i), s(i), theta(i), phi(i), r(i), zc(i), bmod(i) + write(unit, '(10ES22.14)') t(i), s(i), theta(i), phi(i), r(i), zc(i), bmod(i), p(i), lam(i), mu(i) end do close(unit) end subroutine write_trajectory_table @@ -428,17 +454,21 @@ subroutine integrate_orbit_from_start(start_path, orbit_index) s_traj(i_local, orbit_index) = z_local(1) theta_traj(i_local, orbit_index) = z_local(2) phi_traj(i_local, orbit_index) = z_local(3) + p_traj(i_local, orbit_index) = z_local(4) + lam_traj(i_local, orbit_index) = z_local(5) call geoflux_to_cyl((/ z_local(1), z_local(2), z_local(3) /), xcyl) r_traj(i_local, orbit_index) = xcyl(1) z_traj(i_local, orbit_index) = xcyl(3) call splint_geoflux_field(z_local(1), z_local(2), z_local(3), acov_tmp, hcov_tmp, bmod_traj(i_local, orbit_index), sqg_tmp) + mu_traj(i_local, orbit_index) = 0.5_dp * z_local(4)**2 * max(0.0_dp, 1.0_dp - z_local(5)**2) / max(bmod_traj(i_local, orbit_index), 1.0d-12) n_used(orbit_index) = i_local call orbit_timestep_axis(z_local, dtaumin, dtaumin, relerr, ierr_local) if (ierr_local /= 0) exit if (z_local(1) < 0.0_dp .or. z_local(1) > 1.0_dp) exit end do + end subroutine integrate_orbit_from_start subroutine plot_poincare_phi0(plt, png_path, py_path) From 28fd41dc03c6e878289680df7760feeeefe1b917 Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Sat, 13 Dec 2025 17:15:46 +0100 Subject: [PATCH 16/19] Add RK45 tokamak testfield plot and fix TEST magfie --- src/magfie.f90 | 164 +++++-- src/simple_main.f90 | 16 +- test/tests/CMakeLists.txt | 9 + ...test_tokamak_testfield_rk45_orbit_plot.f90 | 426 ++++++++++++++++++ 4 files changed, 578 insertions(+), 37 deletions(-) create mode 100644 test/tests/test_tokamak_testfield_rk45_orbit_plot.f90 diff --git a/src/magfie.f90 b/src/magfie.f90 index fce7443e..4b0770f7 100644 --- a/src/magfie.f90 +++ b/src/magfie.f90 @@ -72,60 +72,160 @@ end subroutine init_magfie subroutine magfie_test(x, bmod, sqrtg, bder, hcovar, hctrvr, hcurl) !> Magnetic field for analytic circular tokamak (TEST field). - !> Coordinates: x(1)=r (minor radius), x(2)=theta (poloidal), x(3)=phi (toroidal) - !> Uses same geometry as field_can_test: B0=1, R0=1, a=0.5, iota=1 !> - !> WARNING: hcurl is set to zero (curvature drift not computed). - !> This is acceptable for symplectic integration (integmode > 0) which uses - !> field_can_test instead. For RK45 integration (integmode=0), curvature - !> drift would be missing - use symplectic integration with TEST field. + !> Coordinates: x(1)=s (flux-like), x(2)=theta (poloidal), x(3)=phi (toroidal). + !> Mapping to minor radius: r = a*sqrt(s), with B0=1, R0=1, a=0.5, iota=1. + !> + !> This routine provides the full magfie interface (including hcurl) so that + !> RK45 guiding-center integration has a consistent test field baseline. implicit none real(dp), intent(in) :: x(3) real(dp), intent(out) :: bmod, sqrtg, bder(3), hcovar(3), hctrvr(3), hcurl(3) real(dp), parameter :: B0 = 1.0_dp, R0 = 1.0_dp, a = 0.5_dp, iota0 = 1.0_dp - real(dp) :: r, theta, cth, sth, R_cyl, dBmod_dr, dBmod_dth + real(dp), parameter :: hs = 1.0d-4, ht = 1.0d-3*twopi + real(dp) :: s, theta, phi, r, cth, sth, R_cyl + real(dp) :: Ath, Aph, dAth_dr, dAph_dr + real(dp) :: ds_fwd, ds_bwd, ds_den + real(dp) :: bmod_plus, bmod_minus, bmod_tplus, bmod_tminus + real(dp) :: hcov_plus(3), hcov_minus(3) + real(dp) :: hcov_tplus(3), hcov_tminus(3) + real(dp) :: dh_ds(3), dh_dt(3) + real(dp) :: gss, gtt, gpp + real(dp) :: Bsup_theta, Bsup_phi + real(dp) :: Bcov_theta, Bcov_phi + real(dp) :: sqrtg_geom - r = x(1) + s = max(0.0_dp, min(1.0_dp, x(1))) theta = x(2) + phi = x(3) cth = cos(theta) sth = sin(theta) - ! Major radius at this point + r = a*sqrt(s) R_cyl = R0 + r*cth - ! Magnetic field magnitude: B = B0 * (1 - r/R0 * cos(theta)) - bmod = B0*(1.0_dp - r/R0*cth) + ! Covariant vector potential (Ath, Aph) from field_can_test. + Ath = B0*(r**2/2.0_dp - r**3/(3.0_dp*R0)*cth) + Aph = -B0*iota0*(r**2/2.0_dp - r**4/(4.0_dp*a**2)) + + ! Derivatives w.r.t r + dAth_dr = B0*(r - r**2/R0*cth) + dAph_dr = -B0*iota0*(r - r**3/a**2) + + ! Jacobian for (s,theta,phi) with r=a*sqrt(s): + ! dV = (a^2/2) * R(s,theta) ds dtheta dphi. + sqrtg_geom = 0.5_dp*a*a*R_cyl + sqrtg = max(sqrtg_geom, 1.0d-14) + + ! Contravariant components of B = curl(A) in (s,theta,phi): + ! B^s = (∂_θ A_φ - ∂_φ A_θ)/sqrtg = 0 for axisym + Aph(r). + ! B^θ = -(∂_s A_φ)/sqrtg, B^φ = (∂_s A_θ)/sqrtg. + if (s > 0.0_dp) then + Bsup_theta = -(dAph_dr * (a/(2.0_dp*sqrt(s)))) / sqrtg + Bsup_phi = (dAth_dr * (a/(2.0_dp*sqrt(s)))) / sqrtg + else + Bsup_theta = (B0*iota0)/max(R_cyl, 1.0d-12) + Bsup_phi = B0/max(R_cyl, 1.0d-12) + end if - ! Jacobian sqrt(g) = r * R for circular tokamak - sqrtg = r*R_cyl + gss = (a*a)/(4.0_dp*max(s, 1.0d-14)) + gtt = r*r + gpp = R_cyl*R_cyl - ! Derivatives of log(B) - dBmod_dr = -B0/R0*cth - dBmod_dth = B0*r/R0*sth - bder(1) = dBmod_dr/bmod - bder(2) = dBmod_dth/bmod - bder(3) = 0.0_dp + Bcov_theta = gtt*Bsup_theta + Bcov_phi = gpp*Bsup_phi + + bmod = sqrt((Bsup_theta*Bcov_theta) + (Bsup_phi*Bcov_phi)) + bmod = max(bmod, 1.0d-14) + + hcovar = 0.0_dp + hcovar(2) = Bcov_theta/bmod + hcovar(3) = Bcov_phi/bmod + + hctrvr = 0.0_dp + hctrvr(2) = Bsup_theta/bmod + hctrvr(3) = Bsup_phi/bmod + + ! Finite-difference derivatives for bder = ∂ ln(B)/∂x^i, and curl(h)^i. + ds_fwd = min(hs, 1.0_dp - s) + ds_bwd = min(hs, s) + ds_den = ds_fwd + ds_bwd + + call magfie_test_eval_basic(s + ds_fwd, theta, phi, bmod_plus, hcov_plus) + call magfie_test_eval_basic(s - ds_bwd, theta, phi, bmod_minus, hcov_minus) + + if (ds_den > 1.0d-16) then + bder(1) = (bmod_plus - bmod_minus)/ds_den + dh_ds = (hcov_plus - hcov_minus)/ds_den + else + bder(1) = 0.0_dp + dh_ds = 0.0_dp + end if - ! Covariant components of unit vector h = B/|B| - ! In (r, theta, phi) coordinates for circular tokamak with iota=1 - hcovar(1) = 0.0_dp - hcovar(2) = iota0*(1.0_dp - r**2/a**2)*r**2/R0/bmod - hcovar(3) = R_cyl/bmod + call magfie_test_eval_basic(s, theta + ht, phi, bmod_tplus, hcov_tplus) + call magfie_test_eval_basic(s, theta - ht, phi, bmod_tminus, hcov_tminus) + bder(2) = (bmod_tplus - bmod_tminus)/(2.0_dp*ht) + dh_dt = (hcov_tplus - hcov_tminus)/(2.0_dp*ht) - ! Contravariant components - hctrvr(1) = 0.0_dp - hctrvr(2) = B0*iota0/(r*R_cyl*bmod) - hctrvr(3) = B0/(r*R_cyl*bmod) + bder(3) = 0.0_dp + + bder = bder/bmod - ! Curl of h (simplified - not fully computed for TEST field) - hcurl(1) = 0.0_dp - hcurl(2) = 0.0_dp - hcurl(3) = 0.0_dp + ! curl(h)^s = (∂_θ h_φ - ∂_φ h_θ)/sqrtg, axisymmetric -> ∂_φ = 0. + hcurl = 0.0_dp + hcurl(1) = dh_dt(3)/sqrtg + hcurl(2) = -dh_ds(3)/sqrtg + hcurl(3) = dh_ds(2)/sqrtg end subroutine magfie_test + subroutine magfie_test_eval_basic(s, theta, phi, bmod, hcov) + real(dp), intent(in) :: s, theta, phi + real(dp), intent(out) :: bmod, hcov(3) + + real(dp), parameter :: B0 = 1.0_dp, R0 = 1.0_dp, a = 0.5_dp, iota0 = 1.0_dp + real(dp) :: s_clip, r, R_cyl, cth + real(dp) :: Ath, Aph, dAth_dr, dAph_dr + real(dp) :: sqrtg, gss, gtt, gpp + real(dp) :: Bsup_theta, Bsup_phi, Bcov_theta, Bcov_phi + + s_clip = max(0.0_dp, min(1.0_dp, s)) + r = a*sqrt(s_clip) + cth = cos(theta) + R_cyl = R0 + r*cth + + Ath = B0*(r**2/2.0_dp - r**3/(3.0_dp*R0)*cth) + Aph = -B0*iota0*(r**2/2.0_dp - r**4/(4.0_dp*a**2)) + dAth_dr = B0*(r - r**2/R0*cth) + dAph_dr = -B0*iota0*(r - r**3/a**2) + + sqrtg = max(0.5_dp*a*a*R_cyl, 1.0d-14) + + if (s_clip > 0.0_dp) then + Bsup_theta = -(dAph_dr * (a/(2.0_dp*sqrt(s_clip)))) / sqrtg + Bsup_phi = (dAth_dr * (a/(2.0_dp*sqrt(s_clip)))) / sqrtg + else + Bsup_theta = (B0*iota0)/max(R_cyl, 1.0d-12) + Bsup_phi = B0/max(R_cyl, 1.0d-12) + end if + + gss = (a*a)/(4.0_dp*max(s_clip, 1.0d-14)) + gtt = r*r + gpp = R_cyl*R_cyl + + Bcov_theta = gtt*Bsup_theta + Bcov_phi = gpp*Bsup_phi + + bmod = sqrt((Bsup_theta*Bcov_theta) + (Bsup_phi*Bcov_phi)) + bmod = max(bmod, 1.0d-14) + + hcov = 0.0_dp + hcov(2) = Bcov_theta/bmod + hcov(3) = Bcov_phi/bmod + end subroutine magfie_test_eval_basic + !ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc ! subroutine magfie_vmec(x, bmod, sqrtg, bder, hcovar, hctrvr, hcurl) diff --git a/src/simple_main.f90 b/src/simple_main.f90 index 8ff8b3b9..eb10c073 100644 --- a/src/simple_main.f90 +++ b/src/simple_main.f90 @@ -327,16 +327,17 @@ subroutine sample_particles_test_field !> TEST field uses (r, theta, phi) coordinates with B0=1, R0=1, a=0.5, iota=1. !> bmod = B0 * (1 - r/R0 * cos(theta)) use util, only : twopi - use params, only : ntestpart, sbeg, bmod00, bmin, bmax, zstart, & + use params, only : ntestpart, sbeg, bmod00, bmin, bmax, zstart, integmode, & reset_seed_if_deterministic real(dp), parameter :: B0 = 1.0d0, R0 = 1.0d0, a = 0.5d0 - real(dp) :: r_start, tmp_rand + real(dp) :: r_start, s_start, tmp_rand integer :: ipart - ! Use sbeg(1) as the starting minor radius (mapped to r for tokamak) - ! sbeg(1) is input as a flux-like value; for TEST field, interpret as r/a + ! Use sbeg(1) as the starting minor radius (mapped to r for tokamak). + ! sbeg(1) is input as a flux-like value; for TEST field, interpret as r/a. r_start = sbeg(1) * a + s_start = (r_start/a)**2 ! Set magnetic field bounds for this r value ! bmod = B0*(1 - r/R0*cos(theta)) @@ -352,7 +353,12 @@ subroutine sample_particles_test_field ! Sample particles uniformly in (theta, phi) at fixed r do ipart = 1, ntestpart - zstart(1, ipart) = r_start + if (integmode == 0) then + ! RK45 uses the non-canonical magfie interface which expects x(1)=s. + zstart(1, ipart) = s_start + else + zstart(1, ipart) = r_start + end if call random_number(tmp_rand) zstart(2, ipart) = twopi * tmp_rand call random_number(tmp_rand) diff --git a/test/tests/CMakeLists.txt b/test/tests/CMakeLists.txt index b55a9fe6..b1322a75 100644 --- a/test/tests/CMakeLists.txt +++ b/test/tests/CMakeLists.txt @@ -163,6 +163,15 @@ set_tests_properties(test_geoflux_rk45_orbit_plot PROPERTIES ENVIRONMENT "LIBNEO_TEST_GEQDSK=${CMAKE_CURRENT_BINARY_DIR}/EQDSK_I.geqdsk;SIMPLE_ARTIFACT_DIR=${CMAKE_BINARY_DIR}/artifacts" ) +add_executable(test_tokamak_testfield_rk45_orbit_plot.x test_tokamak_testfield_rk45_orbit_plot.f90) +target_link_libraries(test_tokamak_testfield_rk45_orbit_plot.x simple) +add_test(NAME test_tokamak_testfield_rk45_orbit_plot COMMAND test_tokamak_testfield_rk45_orbit_plot.x) +set_tests_properties(test_tokamak_testfield_rk45_orbit_plot PROPERTIES + WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} + LABELS "plot;rk45;tokamak;testfield" + ENVIRONMENT "SIMPLE_ARTIFACT_DIR=${CMAKE_BINARY_DIR}/artifacts" +) + # Generate GVEC test data file for test_gvec using elliptic tokamak example set(GVEC_TEST_INPUT "${CMAKE_CURRENT_SOURCE_DIR}/../../build/_deps/gvec-src/test-CI/examples/analytic_gs_elliptok/parameter.ini") set(GVEC_TEST_STATE "${CMAKE_CURRENT_SOURCE_DIR}/../../test/test_data/GVEC_elliptok_State_final.dat") diff --git a/test/tests/test_tokamak_testfield_rk45_orbit_plot.f90 b/test/tests/test_tokamak_testfield_rk45_orbit_plot.f90 new file mode 100644 index 00000000..ab89ab01 --- /dev/null +++ b/test/tests/test_tokamak_testfield_rk45_orbit_plot.f90 @@ -0,0 +1,426 @@ +program test_tokamak_testfield_rk45_orbit_plot + + use, intrinsic :: iso_fortran_env, only: dp => real64 + use params, only: read_config, params_init, netcdffile, ns_s, ns_tp, multharm, & + integmode, isw_field_type, dtaumin, relerr, ntimstep, v0, zstart + use simple, only: tracer_t + use simple_main, only: init_field + use magfie_sub, only: init_magfie, TEST + use alpha_lifetime_sub, only: orbit_timestep_axis, velo_can + use samplers, only: load_starting_points + use pyplot_module, only: pyplot + use util, only: twopi + use, intrinsic :: ieee_arithmetic, only: ieee_is_finite + + implicit none + + type(tracer_t) :: norb + type(pyplot) :: plt + character(len=1024) :: out_root, out_dir, out_orbit, out_flux, out_field + character(len=1024) :: config_file, start_pass_file, start_trap_file + character(len=256) :: config_file_256 + character(len=1024) :: cmd + integer :: status, mkdir_stat + + integer, parameter :: norbits = 2 + integer :: nmax, i, iorb, ierr + integer :: n_used(norbits) + character(len=16) :: orbit_label(norbits) + real(dp) :: color_pass(3), color_trap(3) + + real(dp), allocatable :: time_traj(:) + real(dp), allocatable :: s_traj(:, :), theta_traj(:, :), phi_traj(:, :) + real(dp), allocatable :: r_traj(:, :), z_traj(:, :), bmod_traj(:, :) + real(dp), allocatable :: p_traj(:, :), lam_traj(:, :), mu_traj(:, :) + + out_root = '' + call get_environment_variable('SIMPLE_ARTIFACT_DIR', value=out_root, status=status) + if (status /= 0 .or. len_trim(out_root) == 0) then + out_root = '/tmp/SIMPLE_artifacts' + end if + + out_dir = trim(out_root)//'/plot/test_tokamak_testfield_rk45_orbit_plot' + out_orbit = trim(out_dir)//'/orbit' + out_flux = trim(out_dir)//'/flux_surfaces' + out_field = trim(out_dir)//'/fields' + + cmd = 'mkdir -p '//trim(out_orbit)//' '//trim(out_flux)//' '//trim(out_field) + call execute_command_line(trim(cmd), exitstat=mkdir_stat) + if (mkdir_stat /= 0) then + error stop 'test_tokamak_testfield_rk45_orbit_plot: failed to create output directory' + end if + + config_file = trim(out_dir)//'/simple_tokamak_testfield_rk45_plot.in' + start_pass_file = trim(out_dir)//'/start_passing.dat' + start_trap_file = trim(out_dir)//'/start_trapped.dat' + + call write_config(trim(config_file)) + + config_file_256 = trim(config_file) + call read_config(config_file_256) + call init_field(norb, netcdffile, ns_s, ns_tp, multharm, integmode) + call params_init + call init_magfie(TEST) + + call write_passing_start(trim(start_pass_file)) + call write_trapped_start_at_bmax(trim(start_trap_file)) + + orbit_label(1) = 'passing' + orbit_label(2) = 'trapped' + color_pass = [0.0_dp, 0.0_dp, 1.0_dp] + color_trap = [1.0_dp, 0.0_dp, 0.0_dp] + + nmax = ntimstep + allocate(time_traj(nmax)) + allocate(s_traj(nmax, norbits), theta_traj(nmax, norbits), phi_traj(nmax, norbits)) + allocate(r_traj(nmax, norbits), z_traj(nmax, norbits), bmod_traj(nmax, norbits)) + allocate(p_traj(nmax, norbits), lam_traj(nmax, norbits), mu_traj(nmax, norbits)) + + do i = 1, nmax + time_traj(i) = real(i - 1, dp) * dtaumin / max(v0, 1.0d-12) + end do + + call integrate_orbit_from_start(trim(start_pass_file), 1) + call integrate_orbit_from_start(trim(start_trap_file), 2) + + do iorb = 1, norbits + if (n_used(iorb) < 50) then + error stop 'test_tokamak_testfield_rk45_orbit_plot: orbit produced too few points' + end if + end do + + call plot_orbit_and_diagnostics() + call plot_flux_surfaces_and_fields() + + print *, 'ARTIFACT_DIR: ', trim(out_dir) + call print_artifacts() + +contains + + subroutine write_config(path) + character(len=*), intent(in) :: path + integer :: unit + + open(newunit=unit, file=trim(path), status='replace', action='write') + write(unit, '(A)') '&config' + write(unit, '(A)') 'netcdffile = ''wout.nc''' + write(unit, '(A)') 'integmode = 0' + write(unit, '(A)') 'isw_field_type = -1' + write(unit, '(A)') 'ntestpart = 1' + write(unit, '(A)') 'ntimstep = 800' + write(unit, '(A)') 'npoiper2 = 128' + write(unit, '(A)') 'trace_time = 1d-6' + write(unit, '(A)') 'relerr = 1d-11' + write(unit, '(A)') 'deterministic = .True.' + write(unit, '(A)') '/' + close(unit) + end subroutine write_config + + subroutine write_start(path, s0, th0, ph0, p0, lam0) + character(len=*), intent(in) :: path + real(dp), intent(in) :: s0, th0, ph0, p0, lam0 + integer :: unit + + open(newunit=unit, file=trim(path), status='replace', action='write') + write(unit, *) s0, th0, ph0, p0, lam0 + close(unit) + end subroutine write_start + + subroutine write_passing_start(path) + character(len=*), intent(in) :: path + call write_start(path, 0.25_dp, 0.1_dp*twopi, 0.0_dp, 1.0_dp, 0.7_dp) + end subroutine write_passing_start + + subroutine write_trapped_start_at_bmax(path) + character(len=*), intent(in) :: path + real(dp) :: s0, phi0, theta0 + + s0 = 0.25_dp + phi0 = 0.0_dp + theta0 = 0.25_dp*twopi + + call write_start(path, s0, theta0, phi0, 1.0_dp, 0.0_dp) + end subroutine write_trapped_start_at_bmax + + subroutine integrate_orbit_from_start(start_path, orbit_index) + character(len=*), intent(in) :: start_path + integer, intent(in) :: orbit_index + + real(dp) :: z_local(5) + real(dp) :: vz_local(5) + real(dp) :: xcyl(3) + integer :: i_local, ierr_local + real(dp) :: bmod_val + + call load_starting_points(zstart, trim(start_path)) + z_local = zstart(:, 1) + + call velo_can(0.0_dp, z_local, vz_local) + if (.not. all(ieee_is_finite(vz_local))) then + print *, 'Non-finite velo_can at start for orbit ', orbit_index + print *, 'z = ', z_local + print *, 'vz = ', vz_local + error stop 'test_tokamak_testfield_rk45_orbit_plot: non-finite initial velocity' + end if + + ierr_local = 0 + n_used(orbit_index) = 0 + do i_local = 1, nmax + s_traj(i_local, orbit_index) = z_local(1) + theta_traj(i_local, orbit_index) = z_local(2) + phi_traj(i_local, orbit_index) = z_local(3) + p_traj(i_local, orbit_index) = z_local(4) + lam_traj(i_local, orbit_index) = z_local(5) + + call testfield_to_cyl((/z_local(1), z_local(2), z_local(3)/), xcyl) + r_traj(i_local, orbit_index) = xcyl(1) + z_traj(i_local, orbit_index) = xcyl(3) + + call eval_testfield_bmod(z_local(1), z_local(2), z_local(3), bmod_val) + bmod_traj(i_local, orbit_index) = bmod_val + mu_traj(i_local, orbit_index) = 0.5_dp*z_local(4)**2 * max(0.0_dp, 1.0_dp - z_local(5)**2) / max(bmod_val, 1.0d-12) + + n_used(orbit_index) = i_local + call orbit_timestep_axis(z_local, dtaumin, dtaumin, relerr, ierr_local) + if (ierr_local /= 0) exit + if (z_local(1) < 0.0_dp .or. z_local(1) > 1.0_dp) exit + end do + end subroutine integrate_orbit_from_start + + subroutine testfield_to_cyl(x_geo, x_cyl) + real(dp), intent(in) :: x_geo(3) + real(dp), intent(out) :: x_cyl(3) + + real(dp), parameter :: R0 = 1.0_dp, a = 0.5_dp + real(dp) :: s, theta, phi, r + + s = max(0.0_dp, min(1.0_dp, x_geo(1))) + theta = x_geo(2) + phi = x_geo(3) + + r = a*sqrt(s) + x_cyl(1) = R0 + r*cos(theta) + x_cyl(2) = phi + x_cyl(3) = r*sin(theta) + end subroutine testfield_to_cyl + + subroutine cyl_to_testfield(x_cyl, x_geo, inside) + real(dp), intent(in) :: x_cyl(3) + real(dp), intent(out) :: x_geo(3) + logical, intent(out) :: inside + + real(dp), parameter :: R0 = 1.0_dp, a = 0.5_dp + real(dp) :: dR, Z, r, theta + + dR = x_cyl(1) - R0 + Z = x_cyl(3) + r = sqrt(dR*dR + Z*Z) + + inside = (r <= a) + if (inside) then + theta = atan2(Z, dR) + x_geo(1) = (r/a)**2 + x_geo(2) = theta + x_geo(3) = x_cyl(2) + else + x_geo = 0.0_dp + end if + end subroutine cyl_to_testfield + + subroutine eval_testfield_bmod(s, theta, phi, bmod) + real(dp), intent(in) :: s, theta, phi + real(dp), intent(out) :: bmod + + real(dp), parameter :: B0 = 1.0_dp, R0 = 1.0_dp, a = 0.5_dp, iota0 = 1.0_dp + real(dp) :: r, R_cyl, Bphi, Bpol + + r = a*sqrt(max(0.0_dp, min(1.0_dp, s))) + R_cyl = R0 + r*cos(theta) + + Bphi = B0*(1.0_dp - r/R0*cos(theta)) + Bpol = B0*iota0*(1.0_dp - (r/a)**2)*r/max(R_cyl, 1.0d-12) + + bmod = sqrt(Bphi*Bphi + Bpol*Bpol) + end subroutine eval_testfield_bmod + + subroutine eval_testfield_cyl(s, theta, phi, Br, Bphi, Bz, Bmod) + real(dp), intent(in) :: s, theta, phi + real(dp), intent(out) :: Br, Bphi, Bz, Bmod + + real(dp), parameter :: B0 = 1.0_dp, R0 = 1.0_dp, a = 0.5_dp, iota0 = 1.0_dp + real(dp) :: r, R_cyl, Bpol + + r = a*sqrt(max(0.0_dp, min(1.0_dp, s))) + R_cyl = R0 + r*cos(theta) + + Bphi = B0*(1.0_dp - r/R0*cos(theta)) + Bpol = B0*iota0*(1.0_dp - (r/a)**2)*r/max(R_cyl, 1.0d-12) + Br = -Bpol*sin(theta) + Bz = Bpol*cos(theta) + Bmod = sqrt(Br*Br + Bphi*Bphi + Bz*Bz) + end subroutine eval_testfield_cyl + + subroutine plot_orbit_and_diagnostics() + character(len=1024) :: png_orbit_rz + + png_orbit_rz = trim(out_orbit)//'/orbit_RZ.png' + call plt%initialize(grid=.true., xlabel='R', ylabel='Z', & + title='TEST tokamak RK45 orbit projection (R,Z)', legend=.true., figsize=[10, 8]) + call plt%add_plot(r_traj(1:n_used(1), 1), z_traj(1:n_used(1), 1), label='passing', linestyle='-', color=color_pass) + call plt%add_plot(r_traj(1:n_used(2), 2), z_traj(1:n_used(2), 2), label='trapped', linestyle='-', color=color_trap) + call plt%savefig(trim(png_orbit_rz), pyfile=trim(out_orbit)//'/orbit_RZ.py') + + call plt%initialize(grid=.true., xlabel='t (s) [scaled]', ylabel='s', & + title='TEST tokamak RK45 orbit: s(t)', figsize=[10, 6]) + call plt%add_plot(time_traj(1:n_used(1)), s_traj(1:n_used(1), 1), label='passing', linestyle='-', color=color_pass) + call plt%add_plot(time_traj(1:n_used(2)), s_traj(1:n_used(2), 2), label='trapped', linestyle='-', color=color_trap) + call plt%savefig(trim(out_orbit)//'/orbit_s_t.png', pyfile=trim(out_orbit)//'/orbit_s_t.py') + + call plt%initialize(grid=.true., xlabel='t (s) [scaled]', ylabel='theta (rad)', & + title='TEST tokamak RK45 orbit: theta(t)', figsize=[10, 6]) + call plt%add_plot(time_traj(1:n_used(1)), theta_traj(1:n_used(1), 1), label='passing', linestyle='-', color=color_pass) + call plt%add_plot(time_traj(1:n_used(2)), theta_traj(1:n_used(2), 2), label='trapped', linestyle='-', color=color_trap) + call plt%savefig(trim(out_orbit)//'/orbit_theta_t.png', pyfile=trim(out_orbit)//'/orbit_theta_t.py') + + call plt%initialize(grid=.true., xlabel='t (s) [scaled]', ylabel='phi (rad)', & + title='TEST tokamak RK45 orbit: phi(t)', figsize=[10, 6]) + call plt%add_plot(time_traj(1:n_used(1)), phi_traj(1:n_used(1), 1), label='passing', linestyle='-', color=color_pass) + call plt%add_plot(time_traj(1:n_used(2)), phi_traj(1:n_used(2), 2), label='trapped', linestyle='-', color=color_trap) + call plt%savefig(trim(out_orbit)//'/orbit_phi_t.png', pyfile=trim(out_orbit)//'/orbit_phi_t.py') + + call plt%initialize(grid=.true., xlabel='t (s) [scaled]', ylabel='Bmod', & + title='TEST tokamak RK45 orbit: Bmod(t)', figsize=[10, 6]) + call plt%add_plot(time_traj(1:n_used(1)), bmod_traj(1:n_used(1), 1), label='passing', linestyle='-', color=color_pass) + call plt%add_plot(time_traj(1:n_used(2)), bmod_traj(1:n_used(2), 2), label='trapped', linestyle='-', color=color_trap) + call plt%savefig(trim(out_orbit)//'/orbit_Bmod_t.png', pyfile=trim(out_orbit)//'/orbit_Bmod_t.py') + + call plt%initialize(grid=.true., xlabel='t (s) [scaled]', ylabel='p (normalized)', & + title='TEST tokamak RK45 orbit: p(t)', figsize=[10, 6]) + call plt%add_plot(time_traj(1:n_used(1)), p_traj(1:n_used(1), 1), label='passing', linestyle='-', color=color_pass) + call plt%add_plot(time_traj(1:n_used(2)), p_traj(1:n_used(2), 2), label='trapped', linestyle='-', color=color_trap) + call plt%savefig(trim(out_orbit)//'/orbit_p_t.png', pyfile=trim(out_orbit)//'/orbit_p_t.py') + + call plt%initialize(grid=.true., xlabel='t (s) [scaled]', ylabel='lambda = v_par/v', & + title='TEST tokamak RK45 orbit: lambda(t)', figsize=[10, 6]) + call plt%add_plot(time_traj(1:n_used(1)), lam_traj(1:n_used(1), 1), label='passing', linestyle='-', color=color_pass) + call plt%add_plot(time_traj(1:n_used(2)), lam_traj(1:n_used(2), 2), label='trapped', linestyle='-', color=color_trap) + call plt%savefig(trim(out_orbit)//'/orbit_lambda_t.png', pyfile=trim(out_orbit)//'/orbit_lambda_t.py') + + call plt%initialize(grid=.true., xlabel='t (s) [scaled]', ylabel='mu ~ p^2 (1-lambda^2) / (2 B)', & + title='TEST tokamak RK45 orbit: mu(t) diagnostic', figsize=[10, 6]) + call plt%add_plot(time_traj(1:n_used(1)), mu_traj(1:n_used(1), 1), label='passing', linestyle='-', color=color_pass) + call plt%add_plot(time_traj(1:n_used(2)), mu_traj(1:n_used(2), 2), label='trapped', linestyle='-', color=color_trap) + call plt%savefig(trim(out_orbit)//'/orbit_mu_t.png', pyfile=trim(out_orbit)//'/orbit_mu_t.py') + end subroutine plot_orbit_and_diagnostics + + subroutine plot_flux_surfaces_and_fields() + integer, parameter :: nsurf_plot = 6, ntheta_plot = 361 + integer, parameter :: nr = 240, nz = 240 + real(dp) :: surf_s(nsurf_plot), theta_grid(ntheta_plot) + real(dp) :: r_surf(ntheta_plot, nsurf_plot), z_surf(ntheta_plot, nsurf_plot) + real(dp) :: rmin_g, rmax_g, zmin_g, zmax_g, dr, dz + real(dp) :: rgrid(nr), zgrid(nz) + real(dp) :: bmod_map(nr, nz), br_map(nr, nz), bphi_map(nr, nz), bz_map(nr, nz) + real(dp) :: x_geo(3), x_cyl(3) + logical :: inside + integer :: isurf, itheta, iR, iZ + real(dp) :: Br, Bphi, Bz, Bmod + + surf_s = [0.05_dp, 0.15_dp, 0.25_dp, 0.45_dp, 0.70_dp, 0.95_dp] + do itheta = 1, ntheta_plot + theta_grid(itheta) = (real(itheta - 1, dp) / real(ntheta_plot - 1, dp)) * twopi + end do + + do isurf = 1, nsurf_plot + do itheta = 1, ntheta_plot + call testfield_to_cyl((/surf_s(isurf), theta_grid(itheta), 0.0_dp/), x_cyl) + r_surf(itheta, isurf) = x_cyl(1) + z_surf(itheta, isurf) = x_cyl(3) + end do + end do + + call plt%initialize(grid=.true., xlabel='R', ylabel='Z', & + title='TEST tokamak flux surfaces at phi=0 with orbit overlay', legend=.true., figsize=[10, 8]) + do isurf = 1, nsurf_plot + call plt%add_plot(r_surf(:, isurf), z_surf(:, isurf), label='surface', linestyle='-') + end do + call plt%add_plot(r_traj(1:n_used(1), 1), z_traj(1:n_used(1), 1), label='passing', linestyle='-', color=color_pass) + call plt%add_plot(r_traj(1:n_used(2), 2), z_traj(1:n_used(2), 2), label='trapped', linestyle='-', color=color_trap) + call plt%savefig(trim(out_flux)//'/flux_surfaces_RZ_phi0.png', pyfile=trim(out_flux)//'/flux_surfaces_overlay.py') + + rmin_g = minval(r_surf(:, nsurf_plot)) + rmax_g = maxval(r_surf(:, nsurf_plot)) + zmin_g = minval(z_surf(:, nsurf_plot)) + zmax_g = maxval(z_surf(:, nsurf_plot)) + + rmin_g = rmin_g - 0.1_dp*(rmax_g - rmin_g) + rmax_g = rmax_g + 0.1_dp*(rmax_g - rmin_g) + zmin_g = zmin_g - 0.1_dp*(zmax_g - zmin_g) + zmax_g = zmax_g + 0.1_dp*(zmax_g - zmin_g) + + dr = (rmax_g - rmin_g)/real(nr - 1, dp) + dz = (zmax_g - zmin_g)/real(nz - 1, dp) + do iR = 1, nr + rgrid(iR) = rmin_g + real(iR - 1, dp)*dr + end do + do iZ = 1, nz + zgrid(iZ) = zmin_g + real(iZ - 1, dp)*dz + end do + + do iZ = 1, nz + do iR = 1, nr + x_cyl = [rgrid(iR), 0.0_dp, zgrid(iZ)] + call cyl_to_testfield(x_cyl, x_geo, inside) + if (inside) then + call eval_testfield_cyl(x_geo(1), x_geo(2), 0.0_dp, Br, Bphi, Bz, Bmod) + br_map(iR, iZ) = Br + bphi_map(iR, iZ) = Bphi + bz_map(iR, iZ) = Bz + bmod_map(iR, iZ) = Bmod + else + br_map(iR, iZ) = 0.0_dp + bphi_map(iR, iZ) = 0.0_dp + bz_map(iR, iZ) = 0.0_dp + bmod_map(iR, iZ) = 0.0_dp + end if + end do + end do + + call plt%initialize(grid=.true., xlabel='R', ylabel='Z', & + title='TEST tokamak |B|(R,Z) at phi=0', legend=.false., figsize=[10, 8]) + call plt%add_contour(rgrid, zgrid, bmod_map, linestyle='-', colorbar=.false.) + call plt%savefig(trim(out_field)//'/Bmod_RZ_phi0.png', pyfile=trim(out_field)//'/Bmod_RZ_phi0.py') + + call plt%initialize(grid=.true., xlabel='R', ylabel='Z', & + title='TEST tokamak Br(R,Z) at phi=0', legend=.false., figsize=[10, 8]) + call plt%add_contour(rgrid, zgrid, br_map, linestyle='-', colorbar=.false.) + call plt%savefig(trim(out_field)//'/Br_RZ_phi0.png', pyfile=trim(out_field)//'/Br_RZ_phi0.py') + + call plt%initialize(grid=.true., xlabel='R', ylabel='Z', & + title='TEST tokamak Bphi(R,Z) at phi=0', legend=.false., figsize=[10, 8]) + call plt%add_contour(rgrid, zgrid, bphi_map, linestyle='-', colorbar=.false.) + call plt%savefig(trim(out_field)//'/Bphi_RZ_phi0.png', pyfile=trim(out_field)//'/Bphi_RZ_phi0.py') + + call plt%initialize(grid=.true., xlabel='R', ylabel='Z', & + title='TEST tokamak Bz(R,Z) at phi=0', legend=.false., figsize=[10, 8]) + call plt%add_contour(rgrid, zgrid, bz_map, linestyle='-', colorbar=.false.) + call plt%savefig(trim(out_field)//'/Bz_RZ_phi0.png', pyfile=trim(out_field)//'/Bz_RZ_phi0.py') + end subroutine plot_flux_surfaces_and_fields + + subroutine print_artifacts() + print *, 'ARTIFACT: ', trim(out_orbit)//'/orbit_RZ.png' + print *, 'ARTIFACT: ', trim(out_orbit)//'/orbit_s_t.png' + print *, 'ARTIFACT: ', trim(out_orbit)//'/orbit_theta_t.png' + print *, 'ARTIFACT: ', trim(out_orbit)//'/orbit_phi_t.png' + print *, 'ARTIFACT: ', trim(out_orbit)//'/orbit_Bmod_t.png' + print *, 'ARTIFACT: ', trim(out_orbit)//'/orbit_p_t.png' + print *, 'ARTIFACT: ', trim(out_orbit)//'/orbit_lambda_t.png' + print *, 'ARTIFACT: ', trim(out_orbit)//'/orbit_mu_t.png' + print *, 'ARTIFACT: ', trim(out_flux)//'/flux_surfaces_RZ_phi0.png' + print *, 'ARTIFACT: ', trim(out_field)//'/Bmod_RZ_phi0.png' + print *, 'ARTIFACT: ', trim(out_field)//'/Br_RZ_phi0.png' + print *, 'ARTIFACT: ', trim(out_field)//'/Bphi_RZ_phi0.png' + print *, 'ARTIFACT: ', trim(out_field)//'/Bz_RZ_phi0.png' + end subroutine print_artifacts + +end program test_tokamak_testfield_rk45_orbit_plot From be54231f55ece8aa04579531505ab09042d19486 Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Sat, 13 Dec 2025 17:17:19 +0100 Subject: [PATCH 17/19] Add RK45 Poincare and B(s,theta) plots for tokamak test field --- ...test_tokamak_testfield_rk45_orbit_plot.f90 | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/test/tests/test_tokamak_testfield_rk45_orbit_plot.f90 b/test/tests/test_tokamak_testfield_rk45_orbit_plot.f90 index ab89ab01..4e46d016 100644 --- a/test/tests/test_tokamak_testfield_rk45_orbit_plot.f90 +++ b/test/tests/test_tokamak_testfield_rk45_orbit_plot.f90 @@ -262,6 +262,7 @@ end subroutine eval_testfield_cyl subroutine plot_orbit_and_diagnostics() character(len=1024) :: png_orbit_rz + character(len=1024) :: png_poincare_phi0 png_orbit_rz = trim(out_orbit)//'/orbit_RZ.png' call plt%initialize(grid=.true., xlabel='R', ylabel='Z', & @@ -270,6 +271,9 @@ subroutine plot_orbit_and_diagnostics() call plt%add_plot(r_traj(1:n_used(2), 2), z_traj(1:n_used(2), 2), label='trapped', linestyle='-', color=color_trap) call plt%savefig(trim(png_orbit_rz), pyfile=trim(out_orbit)//'/orbit_RZ.py') + png_poincare_phi0 = trim(out_orbit)//'/orbit_poincare_phi0.png' + call plot_poincare_phi0(plt, trim(png_poincare_phi0), trim(out_orbit)//'/orbit_poincare_phi0.py', color_pass, color_trap) + call plt%initialize(grid=.true., xlabel='t (s) [scaled]', ylabel='s', & title='TEST tokamak RK45 orbit: s(t)', figsize=[10, 6]) call plt%add_plot(time_traj(1:n_used(1)), s_traj(1:n_used(1), 1), label='passing', linestyle='-', color=color_pass) @@ -321,6 +325,7 @@ subroutine plot_flux_surfaces_and_fields() real(dp) :: rmin_g, rmax_g, zmin_g, zmax_g, dr, dz real(dp) :: rgrid(nr), zgrid(nz) real(dp) :: bmod_map(nr, nz), br_map(nr, nz), bphi_map(nr, nz), bz_map(nr, nz) + real(dp) :: bmod_st(nsurf_plot, ntheta_plot) real(dp) :: x_geo(3), x_cyl(3) logical :: inside integer :: isurf, itheta, iR, iZ @@ -336,6 +341,8 @@ subroutine plot_flux_surfaces_and_fields() call testfield_to_cyl((/surf_s(isurf), theta_grid(itheta), 0.0_dp/), x_cyl) r_surf(itheta, isurf) = x_cyl(1) z_surf(itheta, isurf) = x_cyl(3) + + call eval_testfield_bmod(surf_s(isurf), theta_grid(itheta), 0.0_dp, bmod_st(isurf, itheta)) end do end do @@ -405,10 +412,16 @@ subroutine plot_flux_surfaces_and_fields() title='TEST tokamak Bz(R,Z) at phi=0', legend=.false., figsize=[10, 8]) call plt%add_contour(rgrid, zgrid, bz_map, linestyle='-', colorbar=.false.) call plt%savefig(trim(out_field)//'/Bz_RZ_phi0.png', pyfile=trim(out_field)//'/Bz_RZ_phi0.py') + + call plt%initialize(grid=.true., xlabel='theta (rad)', ylabel='surface index', & + title='TEST tokamak Bmod(s,theta) at phi=0 (sampled on flux surfaces)', figsize=[10, 6]) + call plt%add_imshow(bmod_st) + call plt%savefig(trim(out_field)//'/Bmod_s_theta_phi0.png', pyfile=trim(out_field)//'/Bmod_s_theta_phi0.py') end subroutine plot_flux_surfaces_and_fields subroutine print_artifacts() print *, 'ARTIFACT: ', trim(out_orbit)//'/orbit_RZ.png' + print *, 'ARTIFACT: ', trim(out_orbit)//'/orbit_poincare_phi0.png' print *, 'ARTIFACT: ', trim(out_orbit)//'/orbit_s_t.png' print *, 'ARTIFACT: ', trim(out_orbit)//'/orbit_theta_t.png' print *, 'ARTIFACT: ', trim(out_orbit)//'/orbit_phi_t.png' @@ -418,9 +431,61 @@ subroutine print_artifacts() print *, 'ARTIFACT: ', trim(out_orbit)//'/orbit_mu_t.png' print *, 'ARTIFACT: ', trim(out_flux)//'/flux_surfaces_RZ_phi0.png' print *, 'ARTIFACT: ', trim(out_field)//'/Bmod_RZ_phi0.png' + print *, 'ARTIFACT: ', trim(out_field)//'/Bmod_s_theta_phi0.png' print *, 'ARTIFACT: ', trim(out_field)//'/Br_RZ_phi0.png' print *, 'ARTIFACT: ', trim(out_field)//'/Bphi_RZ_phi0.png' print *, 'ARTIFACT: ', trim(out_field)//'/Bz_RZ_phi0.png' end subroutine print_artifacts + subroutine plot_poincare_phi0(plt, png_path, py_path, color_pass, color_trap) + type(pyplot), intent(inout) :: plt + character(len=*), intent(in) :: png_path, py_path + real(dp), intent(in) :: color_pass(3), color_trap(3) + + integer, parameter :: max_points = 5000 + real(dp), parameter :: tol = 2.0d-2 + real(dp) :: phi_wrapped, phase + real(dp) :: r_pts_pass(max_points), z_pts_pass(max_points) + real(dp) :: r_pts_trap(max_points), z_pts_trap(max_points) + integer :: i, n_pass, n_trap + + n_pass = 0 + do i = 1, n_used(1) + phi_wrapped = modulo(phi_traj(i, 1), twopi) + phase = phi_wrapped + if (phase > 0.5_dp*twopi) phase = phase - twopi + if (abs(phase) < tol) then + if (n_pass < max_points) then + n_pass = n_pass + 1 + r_pts_pass(n_pass) = r_traj(i, 1) + z_pts_pass(n_pass) = z_traj(i, 1) + end if + end if + end do + + n_trap = 0 + do i = 1, n_used(2) + phi_wrapped = modulo(phi_traj(i, 2), twopi) + phase = phi_wrapped + if (phase > 0.5_dp*twopi) phase = phase - twopi + if (abs(phase) < tol) then + if (n_trap < max_points) then + n_trap = n_trap + 1 + r_pts_trap(n_trap) = r_traj(i, 2) + z_pts_trap(n_trap) = z_traj(i, 2) + end if + end if + end do + + call plt%initialize(grid=.true., xlabel='R', ylabel='Z', & + title='Poincare section at phi≈0', legend=.true., figsize=[10, 8]) + if (n_pass > 0) then + call plt%add_plot(r_pts_pass(1:n_pass), z_pts_pass(1:n_pass), label='passing', linestyle='o', color=color_pass, markersize=2) + end if + if (n_trap > 0) then + call plt%add_plot(r_pts_trap(1:n_trap), z_pts_trap(1:n_trap), label='trapped', linestyle='o', color=color_trap, markersize=2) + end if + call plt%savefig(trim(png_path), pyfile=trim(py_path)) + end subroutine plot_poincare_phi0 + end program test_tokamak_testfield_rk45_orbit_plot From c9a464cc9e1d90b65c1bd5a6c4e616399e7c2a0f Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Sat, 13 Dec 2025 17:33:57 +0100 Subject: [PATCH 18/19] Add chartmap reference coords and GEQDSK RK45 comparison plots --- src/coordinates/reference_coordinates.f90 | 16 +- src/magfie.f90 | 163 ++++++ src/params.f90 | 15 +- test/tests/CMakeLists.txt | 9 + ...oflux_rk45_orbit_plot_chartmap_compare.f90 | 492 ++++++++++++++++++ 5 files changed, 689 insertions(+), 6 deletions(-) create mode 100644 test/tests/test_geoflux_rk45_orbit_plot_chartmap_compare.f90 diff --git a/src/coordinates/reference_coordinates.f90 b/src/coordinates/reference_coordinates.f90 index 9a2917c4..fe3149d2 100644 --- a/src/coordinates/reference_coordinates.f90 +++ b/src/coordinates/reference_coordinates.f90 @@ -2,7 +2,7 @@ module reference_coordinates use, intrinsic :: iso_fortran_env, only: dp => real64 use libneo_coordinates, only: coordinate_system_t, make_vmec_coordinate_system, & - make_geoflux_coordinate_system + make_geoflux_coordinate_system, make_chartmap_coordinate_system implicit none @@ -21,13 +21,25 @@ subroutine init_reference_coordinates(coord_input) if (allocated(ref_coords)) deallocate (ref_coords) - if (is_geqdsk_name(coord_input)) then + if (is_chartmap_name(coord_input)) then + call make_chartmap_coordinate_system(ref_coords, trim(coord_input)) + else if (is_geqdsk_name(coord_input)) then call make_geoflux_coordinate_system(ref_coords) else call make_vmec_coordinate_system(ref_coords) end if end subroutine init_reference_coordinates + logical function is_chartmap_name(filename) + character(*), intent(in) :: filename + + character(:), allocatable :: lower_name + + lower_name = to_lower(trim(filename)) + + is_chartmap_name = index(strip_directory(lower_name), 'chartmap') > 0 + end function is_chartmap_name + logical function is_geqdsk_name(filename) character(*), intent(in) :: filename diff --git a/src/magfie.f90 b/src/magfie.f90 index 4b0770f7..fba0dcb6 100644 --- a/src/magfie.f90 +++ b/src/magfie.f90 @@ -8,6 +8,9 @@ module magfie_sub use field_geoflux, only: geoflux_ready use geoflux_coordinates, only: geoflux_to_cyl use geoflux_field, only: splint_geoflux_field + use reference_coordinates, only: ref_coords + use libneo_coordinates, only: chartmap_coordinate_system_t + use field_sub, only: field_eq, psif implicit none @@ -51,6 +54,19 @@ subroutine init_magfie(id) case (CANFLUX) magfie => magfie_can case (VMEC) + if (allocated(ref_coords)) then + select type (ref_coords) + type is (chartmap_coordinate_system_t) + if (.not. geoflux_ready) then + error stop 'init_magfie: chartmap coordinates require GEQDSK/geoflux field' + end if + magfie => magfie_chartmap + return + class default + continue + end select + end if + if (geoflux_ready) then magfie => magfie_geoflux else @@ -499,6 +515,153 @@ subroutine magfie_geoflux(x, bmod, sqrtg, bder, hcovar, hctrvr, hcurl) end subroutine magfie_geoflux + subroutine magfie_chartmap(x, bmod, sqrtg, bder, hcovar, hctrvr, hcurl) + real(dp), intent(in) :: x(3) + real(dp), intent(out) :: bmod, sqrtg + real(dp), intent(out) :: bder(3), hcovar(3), hctrvr(3), hcurl(3) + + real(dp) :: u(3) + real(dp) :: g(3, 3), ginv(3, 3), sqrtg_local + real(dp) :: xyz(3), e_cov(3, 3) + real(dp) :: xcyl(3) + real(dp) :: Br, Bphi, Bz + real(dp) :: d1, d2, d3, d4, d5, d6, d7, d8, d9 + real(dp) :: bvec(3) + real(dp) :: ds_fwd, ds_bwd, ds_den + real(dp) :: dt_step, dp_step + real(dp) :: bmod_plus, bmod_minus + real(dp) :: bmod_theta_plus, bmod_theta_minus + real(dp) :: bmod_phi_plus, bmod_phi_minus + real(dp) :: hcov_plus(3), hcov_minus(3) + real(dp) :: hcov_theta_plus(3), hcov_theta_minus(3) + real(dp) :: hcov_phi_plus(3), hcov_phi_minus(3) + real(dp) :: dh_ds(3), dh_dt(3), dh_dp(3) + real(dp) :: u_plus(3), u_minus(3) + + u(1) = max(0.0_dp, min(1.0_dp, x(1))) + u(2) = modulo(x(2), twopi) + u(3) = modulo(x(3), twopi) + + call ref_coords%evaluate_point(u, xyz) + call ref_coords%covariant_basis(u, e_cov) + call ref_coords%metric_tensor(u, g, ginv, sqrtg_local) + sqrtg = max(sqrtg_local, 1.0d-12) + + call cart_to_cyl(xyz, xcyl) + call field_eq(xcyl(1), xcyl(2), xcyl(3), Br, Bphi, Bz, d1, d2, d3, d4, d5, d6, d7, d8, d9) + + bmod = sqrt(Br*Br + Bphi*Bphi + Bz*Bz) + bmod = max(bmod, 1.0d-14) + + call cylB_to_cartB(xcyl(2), Br, Bphi, Bz, bvec) + + hcovar(1) = dot_product(bvec, e_cov(:, 1))/bmod + hcovar(2) = dot_product(bvec, e_cov(:, 2))/bmod + hcovar(3) = dot_product(bvec, e_cov(:, 3))/bmod + + hctrvr = matmul(ginv, hcovar) + + ds_fwd = min(1.0d-3, 1.0_dp - u(1)) + ds_bwd = min(1.0d-3, u(1)) + dt_step = 1.0d-3*twopi + dp_step = dt_step + + u_plus = u + u_minus = u + u_plus(1) = u(1) + ds_fwd + u_minus(1) = u(1) - ds_bwd + call chartmap_eval_basic(u_plus, bmod_plus, hcov_plus) + call chartmap_eval_basic(u_minus, bmod_minus, hcov_minus) + + ds_den = ds_fwd + ds_bwd + if (ds_den > 1.0d-12) then + bder(1) = (bmod_plus - bmod_minus)/ds_den + dh_ds = (hcov_plus - hcov_minus)/ds_den + else + bder(1) = 0.0_dp + dh_ds = 0.0_dp + end if + + u_plus = u + u_minus = u + u_plus(2) = modulo(u(2) + dt_step, twopi) + u_minus(2) = modulo(u(2) - dt_step, twopi) + call chartmap_eval_basic(u_plus, bmod_theta_plus, hcov_theta_plus) + call chartmap_eval_basic(u_minus, bmod_theta_minus, hcov_theta_minus) + bder(2) = (bmod_theta_plus - bmod_theta_minus)/(2.0_dp*dt_step) + dh_dt = (hcov_theta_plus - hcov_theta_minus)/(2.0_dp*dt_step) + + u_plus = u + u_minus = u + u_plus(3) = modulo(u(3) + dp_step, twopi) + u_minus(3) = modulo(u(3) - dp_step, twopi) + call chartmap_eval_basic(u_plus, bmod_phi_plus, hcov_phi_plus) + call chartmap_eval_basic(u_minus, bmod_phi_minus, hcov_phi_minus) + bder(3) = (bmod_phi_plus - bmod_phi_minus)/(2.0_dp*dp_step) + dh_dp = (hcov_phi_plus - hcov_phi_minus)/(2.0_dp*dp_step) + + bder = bder/bmod + + if (sqrtg > 0.0_dp) then + hcurl(1) = (dh_dt(3) - dh_dp(2))/sqrtg + hcurl(2) = (dh_dp(1) - dh_ds(3))/sqrtg + hcurl(3) = (dh_ds(2) - dh_dt(1))/sqrtg + else + hcurl = 0.0_dp + end if + end subroutine magfie_chartmap + + subroutine chartmap_eval_basic(u, bmod, hcov) + real(dp), intent(in) :: u(3) + real(dp), intent(out) :: bmod + real(dp), intent(out) :: hcov(3) + + real(dp) :: xyz(3), e_cov(3, 3) + real(dp) :: xcyl(3) + real(dp) :: Br, Bphi, Bz + real(dp) :: d1, d2, d3, d4, d5, d6, d7, d8, d9 + real(dp) :: bvec(3) + real(dp) :: uu(3) + + uu(1) = max(0.0_dp, min(1.0_dp, u(1))) + uu(2) = modulo(u(2), twopi) + uu(3) = modulo(u(3), twopi) + + call ref_coords%evaluate_point(uu, xyz) + call ref_coords%covariant_basis(uu, e_cov) + call cart_to_cyl(xyz, xcyl) + call field_eq(xcyl(1), xcyl(2), xcyl(3), Br, Bphi, Bz, d1, d2, d3, d4, d5, d6, d7, d8, d9) + + bmod = sqrt(Br*Br + Bphi*Bphi + Bz*Bz) + bmod = max(bmod, 1.0d-14) + + call cylB_to_cartB(xcyl(2), Br, Bphi, Bz, bvec) + hcov(1) = dot_product(bvec, e_cov(:, 1))/bmod + hcov(2) = dot_product(bvec, e_cov(:, 2))/bmod + hcov(3) = dot_product(bvec, e_cov(:, 3))/bmod + end subroutine chartmap_eval_basic + + subroutine cart_to_cyl(xyz, xcyl) + real(dp), intent(in) :: xyz(3) + real(dp), intent(out) :: xcyl(3) + + xcyl(1) = sqrt(xyz(1)*xyz(1) + xyz(2)*xyz(2)) + xcyl(2) = atan2(xyz(2), xyz(1)) + xcyl(3) = xyz(3) + end subroutine cart_to_cyl + + subroutine cylB_to_cartB(phi, Br, Bphi, Bz, bvec) + real(dp), intent(in) :: phi, Br, Bphi, Bz + real(dp), intent(out) :: bvec(3) + real(dp) :: cph, sph + + cph = cos(phi) + sph = sin(phi) + bvec(1) = Br*cph - Bphi*sph + bvec(2) = Br*sph + Bphi*cph + bvec(3) = Bz + end subroutine cylB_to_cartB + subroutine geoflux_eval_point(s, theta, phi, bmod, hcov, sqrtg, basis, g, ginv, & detg, sqrtg_geom) real(dp), intent(in) :: s, theta, phi diff --git a/src/params.f90 b/src/params.f90 index 1088f6a4..6428d924 100644 --- a/src/params.f90 +++ b/src/params.f90 @@ -356,7 +356,9 @@ subroutine apply_config_aliases ! field_input: explicit > netcdffile > '' ! coord_input: explicit > netcdffile > field_input > '' - ! netcdffile serves as fallback for both field_input and coord_input + ! netcdffile serves as fallback for both field_input and coord_input. + ! It also remains the equilibrium source used by init_vmec(). Do not overwrite it + ! when coord_input differs (e.g., chartmap coordinate files with GEQDSK fields). if (field_input == '' .and. len_trim(netcdffile) > 0) then field_input = netcdffile end if @@ -370,9 +372,14 @@ subroutine apply_config_aliases coord_input = field_input end if - ! Sync coord_input back to netcdffile for libneo compatibility - if (len_trim(coord_input) > 0) then - netcdffile = coord_input + ! Backward-compatibility: if netcdffile is unset, fall back to field_input, + ! then coord_input. + if (len_trim(netcdffile) == 0) then + if (len_trim(field_input) > 0) then + netcdffile = field_input + else if (len_trim(coord_input) > 0) then + netcdffile = coord_input + end if end if ! isw_field_type is deprecated alias for integ_coords diff --git a/test/tests/CMakeLists.txt b/test/tests/CMakeLists.txt index b1322a75..f3045e0e 100644 --- a/test/tests/CMakeLists.txt +++ b/test/tests/CMakeLists.txt @@ -172,6 +172,15 @@ set_tests_properties(test_tokamak_testfield_rk45_orbit_plot PROPERTIES ENVIRONMENT "SIMPLE_ARTIFACT_DIR=${CMAKE_BINARY_DIR}/artifacts" ) +add_executable(test_geoflux_rk45_orbit_plot_chartmap_compare.x test_geoflux_rk45_orbit_plot_chartmap_compare.f90) +target_link_libraries(test_geoflux_rk45_orbit_plot_chartmap_compare.x simple) +add_test(NAME test_geoflux_rk45_orbit_plot_chartmap_compare COMMAND test_geoflux_rk45_orbit_plot_chartmap_compare.x) +set_tests_properties(test_geoflux_rk45_orbit_plot_chartmap_compare PROPERTIES + WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} + LABELS "plot;rk45;geoflux;chartmap" + ENVIRONMENT "LIBNEO_TEST_GEQDSK=${CMAKE_CURRENT_BINARY_DIR}/EQDSK_I.geqdsk;SIMPLE_ARTIFACT_DIR=${CMAKE_BINARY_DIR}/artifacts" +) + # Generate GVEC test data file for test_gvec using elliptic tokamak example set(GVEC_TEST_INPUT "${CMAKE_CURRENT_SOURCE_DIR}/../../build/_deps/gvec-src/test-CI/examples/analytic_gs_elliptok/parameter.ini") set(GVEC_TEST_STATE "${CMAKE_CURRENT_SOURCE_DIR}/../../test/test_data/GVEC_elliptok_State_final.dat") diff --git a/test/tests/test_geoflux_rk45_orbit_plot_chartmap_compare.f90 b/test/tests/test_geoflux_rk45_orbit_plot_chartmap_compare.f90 new file mode 100644 index 00000000..7679abb4 --- /dev/null +++ b/test/tests/test_geoflux_rk45_orbit_plot_chartmap_compare.f90 @@ -0,0 +1,492 @@ +program test_geoflux_rk45_orbit_plot_chartmap_compare + + use, intrinsic :: iso_fortran_env, only: dp => real64 + use params, only: read_config, params_init, netcdffile, ns_s, ns_tp, multharm, & + integmode, isw_field_type, dtaumin, relerr, ntimstep, v0, zstart + use simple, only: tracer_t + use simple_main, only: init_field + use magfie_sub, only: init_magfie, VMEC, magfie + use alpha_lifetime_sub, only: orbit_timestep_axis + use samplers, only: load_starting_points + use reference_coordinates, only: ref_coords + use pyplot_module, only: pyplot + use util, only: twopi + use geoflux_coordinates, only: geoflux_to_cart + use netcdf + + implicit none + + type(tracer_t) :: norb + type(pyplot) :: plt + + character(len=1024) :: out_root, out_dir + character(len=1024) :: out_geoflux, out_chartmap, out_compare + character(len=1024) :: cfg_geoflux, cfg_chartmap + character(len=1024) :: start_pass, start_trap + character(len=1024) :: geqdsk_file, chartmap_file, chartmap_env + character(len=256) :: cfg256 + integer :: status, mkdir_stat + + integer, parameter :: norbits = 2 + integer, parameter :: ntheta_plot = 361 + integer, parameter :: nsurf_plot = 6 + integer :: nmax, i, iorb + integer :: n_used_geo(norbits), n_used_cm(norbits) + real(dp) :: color_geo(3), color_cm(3) + + real(dp), allocatable :: time_traj(:) + + real(dp), allocatable :: s_geo(:, :), th_geo(:, :), ph_geo(:, :) + real(dp), allocatable :: R_geo(:, :), Z_geo(:, :), B_geo(:, :) + + real(dp), allocatable :: s_cm(:, :), th_cm(:, :), ph_cm(:, :) + real(dp), allocatable :: R_cm(:, :), Z_cm(:, :), B_cm(:, :) + + real(dp) :: theta_grid(ntheta_plot), surf_s(nsurf_plot) + real(dp) :: R_surf_geo(ntheta_plot, nsurf_plot), Z_surf_geo(ntheta_plot, nsurf_plot) + real(dp) :: R_surf_cm(ntheta_plot, nsurf_plot), Z_surf_cm(ntheta_plot, nsurf_plot) + + out_root = '' + call get_environment_variable('SIMPLE_ARTIFACT_DIR', value=out_root, status=status) + if (status /= 0 .or. len_trim(out_root) == 0) then + out_root = '/tmp/SIMPLE_artifacts' + end if + + out_dir = trim(out_root)//'/plot/test_geoflux_rk45_orbit_plot_chartmap_compare' + out_geoflux = trim(out_dir)//'/geoflux' + out_chartmap = trim(out_dir)//'/chartmap' + out_compare = trim(out_dir)//'/compare' + + call mkdir_p(trim(out_geoflux)//'/orbit') + call mkdir_p(trim(out_geoflux)//'/flux_surfaces') + call mkdir_p(trim(out_chartmap)//'/orbit') + call mkdir_p(trim(out_chartmap)//'/flux_surfaces') + call mkdir_p(trim(out_compare)) + + geqdsk_file = 'EQDSK_I.geqdsk' + call get_environment_variable('LIBNEO_TEST_GEQDSK', value=geqdsk_file, status=status) + if (status /= 0 .or. len_trim(geqdsk_file) == 0) geqdsk_file = 'EQDSK_I.geqdsk' + + chartmap_file = trim(out_dir)//'/chartmap_from_geoflux.nc' + call get_environment_variable('SIMPLE_CHARTMAP_FILE', value=chartmap_env, status=status) + if (status == 0 .and. len_trim(chartmap_env) > 0) chartmap_file = trim(chartmap_env) + + cfg_geoflux = trim(out_dir)//'/simple_geoflux_ref.in' + cfg_chartmap = trim(out_dir)//'/simple_chartmap_ref.in' + start_pass = trim(out_dir)//'/start_passing.dat' + start_trap = trim(out_dir)//'/start_trapped.dat' + + call write_start(start_pass, 0.25_dp, 0.1_dp*twopi, 0.0_dp, 1.0_dp, 0.7_dp) + call write_start(start_trap, 0.25_dp, 0.25_dp*twopi, 0.0_dp, 1.0_dp, 0.0_dp) + + call write_config_geoflux(cfg_geoflux, geqdsk_file) + call write_config_chartmap(cfg_chartmap, geqdsk_file, chartmap_file) + + call init_case(cfg_geoflux, 'geoflux') + if (.not. (status == 0 .and. len_trim(chartmap_env) > 0)) then + call write_chartmap_from_geoflux(trim(chartmap_file), 65, 129, 65) + end if + + nmax = ntimstep + allocate(time_traj(nmax)) + do i = 1, nmax + time_traj(i) = real(i - 1, dp) * dtaumin / max(v0, 1.0d-12) + end do + + allocate(s_geo(nmax, norbits), th_geo(nmax, norbits), ph_geo(nmax, norbits)) + allocate(R_geo(nmax, norbits), Z_geo(nmax, norbits), B_geo(nmax, norbits)) + allocate(s_cm(nmax, norbits), th_cm(nmax, norbits), ph_cm(nmax, norbits)) + allocate(R_cm(nmax, norbits), Z_cm(nmax, norbits), B_cm(nmax, norbits)) + + call integrate_orbit(cfg_geoflux, start_pass, 1, n_used_geo, s_geo, th_geo, ph_geo, R_geo, Z_geo, B_geo) + call integrate_orbit(cfg_geoflux, start_trap, 2, n_used_geo, s_geo, th_geo, ph_geo, R_geo, Z_geo, B_geo) + + call integrate_orbit(cfg_chartmap, start_pass, 1, n_used_cm, s_cm, th_cm, ph_cm, R_cm, Z_cm, B_cm) + call integrate_orbit(cfg_chartmap, start_trap, 2, n_used_cm, s_cm, th_cm, ph_cm, R_cm, Z_cm, B_cm) + + do iorb = 1, norbits + if (min(n_used_geo(iorb), n_used_cm(iorb)) < 50) then + error stop 'test_geoflux_rk45_orbit_plot_chartmap_compare: orbit produced too few points' + end if + end do + + color_geo = [0.0_dp, 0.0_dp, 1.0_dp] + color_cm = [1.0_dp, 0.0_dp, 0.0_dp] + + call build_theta_surf_grids(theta_grid, surf_s) + call build_flux_surfaces(cfg_geoflux, theta_grid, surf_s, R_surf_geo, Z_surf_geo) + call build_flux_surfaces(cfg_chartmap, theta_grid, surf_s, R_surf_cm, Z_surf_cm) + + call plot_case(out_geoflux, 'geoflux reference', time_traj, n_used_geo, R_geo, Z_geo, s_geo, th_geo, ph_geo, B_geo, & + R_surf_geo, Z_surf_geo, theta_grid, surf_s) + + call plot_case(out_chartmap, 'chartmap reference', time_traj, n_used_cm, R_cm, Z_cm, s_cm, th_cm, ph_cm, B_cm, & + R_surf_cm, Z_surf_cm, theta_grid, surf_s) + + call plot_compare(out_compare, time_traj, n_used_geo, n_used_cm, R_geo, Z_geo, R_cm, Z_cm, color_geo, color_cm) + + print *, 'ARTIFACT_DIR: ', trim(out_dir) + print *, 'ARTIFACT: ', trim(out_compare)//'/orbit_RZ_overlay.png' + print *, 'ARTIFACT: ', trim(out_compare)//'/orbit_deltaRZ_t.png' + print *, 'ARTIFACT: ', trim(out_compare)//'/flux_surfaces_RZ_phi0_overlay.png' + +contains + + subroutine mkdir_p(path) + character(len=*), intent(in) :: path + character(len=2048) :: cmd + integer :: stat + + cmd = 'mkdir -p '//trim(path) + call execute_command_line(trim(cmd), exitstat=stat) + if (stat /= 0) error stop 'mkdir_p failed' + end subroutine mkdir_p + + logical function file_exists(path) + character(len=*), intent(in) :: path + inquire(file=trim(path), exist=file_exists) + end function file_exists + + subroutine write_config_geoflux(path, geqdsk_path) + character(len=*), intent(in) :: path + character(len=*), intent(in) :: geqdsk_path + integer :: unit + + open(newunit=unit, file=trim(path), status='replace', action='write') + write(unit, '(A)') '&config' + write(unit, '(A)') 'netcdffile = '''//trim(geqdsk_path)//'''' + write(unit, '(A)') 'field_input = '''//trim(geqdsk_path)//'''' + write(unit, '(A)') 'coord_input = '''//trim(geqdsk_path)//'''' + write(unit, '(A)') 'integmode = 0' + write(unit, '(A)') 'isw_field_type = 1' + write(unit, '(A)') 'ntestpart = 1' + write(unit, '(A)') 'ntimstep = 800' + write(unit, '(A)') 'npoiper2 = 64' + write(unit, '(A)') 'multharm = 3' + write(unit, '(A)') 'trace_time = 1d-6' + write(unit, '(A)') 'relerr = 1d-11' + write(unit, '(A)') 'deterministic = .True.' + write(unit, '(A)') '/' + close(unit) + end subroutine write_config_geoflux + + subroutine write_config_chartmap(path, geqdsk_path, chartmap_path) + character(len=*), intent(in) :: path + character(len=*), intent(in) :: geqdsk_path + character(len=*), intent(in) :: chartmap_path + integer :: unit + + open(newunit=unit, file=trim(path), status='replace', action='write') + write(unit, '(A)') '&config' + write(unit, '(A)') 'netcdffile = '''//trim(geqdsk_path)//'''' + write(unit, '(A)') 'field_input = '''//trim(geqdsk_path)//'''' + write(unit, '(A)') 'coord_input = '''//trim(chartmap_path)//'''' + write(unit, '(A)') 'integmode = 0' + write(unit, '(A)') 'isw_field_type = 1' + write(unit, '(A)') 'ntestpart = 1' + write(unit, '(A)') 'ntimstep = 800' + write(unit, '(A)') 'npoiper2 = 64' + write(unit, '(A)') 'multharm = 3' + write(unit, '(A)') 'trace_time = 1d-6' + write(unit, '(A)') 'relerr = 1d-11' + write(unit, '(A)') 'deterministic = .True.' + write(unit, '(A)') '/' + close(unit) + end subroutine write_config_chartmap + + subroutine init_case(config_path, label) + character(len=*), intent(in) :: config_path + character(len=*), intent(in) :: label + character(len=256) :: cfg_local + + cfg_local = trim(config_path) + call read_config(cfg_local) + call init_field(norb, netcdffile, ns_s, ns_tp, multharm, integmode) + call params_init + call init_magfie(VMEC) + + if (label == '') then + continue + end if + end subroutine init_case + + subroutine integrate_orbit(cfg_path, start_path, orbit_index, n_used, s_arr, th_arr, ph_arr, R_arr, Z_arr, B_arr) + character(len=*), intent(in) :: cfg_path, start_path + integer, intent(in) :: orbit_index + integer, intent(inout) :: n_used(norbits) + real(dp), intent(inout) :: s_arr(:, :), th_arr(:, :), ph_arr(:, :) + real(dp), intent(inout) :: R_arr(:, :), Z_arr(:, :), B_arr(:, :) + + integer :: i_local, ierr_local + real(dp) :: z_local(5) + real(dp) :: xyz(3), xcyl(3) + real(dp) :: bmod_local, sqrtg_local + real(dp) :: bder(3), hcov(3), hctrvr(3), hcurl(3) + character(len=256) :: cfg_local + + cfg_local = trim(cfg_path) + call read_config(cfg_local) + call init_field(norb, netcdffile, ns_s, ns_tp, multharm, integmode) + call params_init + call init_magfie(VMEC) + + call load_starting_points(zstart, trim(start_path)) + z_local = zstart(:, 1) + + ierr_local = 0 + n_used(orbit_index) = 0 + do i_local = 1, nmax + s_arr(i_local, orbit_index) = z_local(1) + th_arr(i_local, orbit_index) = z_local(2) + ph_arr(i_local, orbit_index) = z_local(3) + + call ref_coords%evaluate_point((/ z_local(1), z_local(2), z_local(3) /), xyz) + call cart_to_cyl(xyz, xcyl) + R_arr(i_local, orbit_index) = xcyl(1) + Z_arr(i_local, orbit_index) = xcyl(3) + + call magfie((/ z_local(1), z_local(2), z_local(3) /), bmod_local, sqrtg_local, bder, hcov, hctrvr, hcurl) + B_arr(i_local, orbit_index) = bmod_local + + n_used(orbit_index) = i_local + call orbit_timestep_axis(z_local, dtaumin, dtaumin, relerr, ierr_local) + if (ierr_local /= 0) exit + if (z_local(1) < 0.0_dp .or. z_local(1) > 1.0_dp) exit + end do + end subroutine integrate_orbit + + subroutine cart_to_cyl(xyz, xcyl) + real(dp), intent(in) :: xyz(3) + real(dp), intent(out) :: xcyl(3) + + xcyl(1) = sqrt(xyz(1)*xyz(1) + xyz(2)*xyz(2)) + xcyl(2) = atan2(xyz(2), xyz(1)) + xcyl(3) = xyz(3) + end subroutine cart_to_cyl + + subroutine build_theta_surf_grids(theta, svals) + real(dp), intent(out) :: theta(ntheta_plot) + real(dp), intent(out) :: svals(nsurf_plot) + integer :: it + + do it = 1, ntheta_plot + theta(it) = (real(it - 1, dp) / real(ntheta_plot - 1, dp)) * twopi + end do + svals = [0.10_dp, 0.25_dp, 0.40_dp, 0.60_dp, 0.80_dp, 0.95_dp] + end subroutine build_theta_surf_grids + + subroutine build_flux_surfaces(cfg_path, theta, svals, R_surf, Z_surf) + character(len=*), intent(in) :: cfg_path + real(dp), intent(in) :: theta(ntheta_plot) + real(dp), intent(in) :: svals(nsurf_plot) + real(dp), intent(out) :: R_surf(ntheta_plot, nsurf_plot) + real(dp), intent(out) :: Z_surf(ntheta_plot, nsurf_plot) + + integer :: isurf, it + real(dp) :: xyz(3), xcyl(3) + character(len=256) :: cfg_local + + cfg_local = trim(cfg_path) + call read_config(cfg_local) + call init_field(norb, netcdffile, ns_s, ns_tp, multharm, integmode) + call params_init + call init_magfie(VMEC) + + do isurf = 1, nsurf_plot + do it = 1, ntheta_plot + call ref_coords%evaluate_point((/ svals(isurf), theta(it), 0.0_dp /), xyz) + call cart_to_cyl(xyz, xcyl) + R_surf(it, isurf) = xcyl(1) + Z_surf(it, isurf) = xcyl(3) + end do + end do + end subroutine build_flux_surfaces + + subroutine plot_case(out_case, title_prefix, t, n_used, R, Z, s, th, ph, B, R_surf, Z_surf, theta, svals) + character(len=*), intent(in) :: out_case, title_prefix + real(dp), intent(in) :: t(:) + integer, intent(in) :: n_used(norbits) + real(dp), intent(in) :: R(:, :), Z(:, :), s(:, :), th(:, :), ph(:, :), B(:, :) + real(dp), intent(in) :: R_surf(ntheta_plot, nsurf_plot), Z_surf(ntheta_plot, nsurf_plot) + real(dp), intent(in) :: theta(ntheta_plot), svals(nsurf_plot) + + integer :: isurf + + call plt%initialize(grid=.true., xlabel='R (cm)', ylabel='Z (cm)', & + title=trim(title_prefix)//': orbit projection (R,Z)', legend=.true., figsize=[10, 8]) + call plt%add_plot(R(1:n_used(1), 1), Z(1:n_used(1), 1), label='passing', linestyle='-') + call plt%add_plot(R(1:n_used(2), 2), Z(1:n_used(2), 2), label='trapped', linestyle='-') + call plt%savefig(trim(out_case)//'/orbit/orbit_RZ.png', pyfile=trim(out_case)//'/orbit/orbit_RZ.py') + + call plt%initialize(grid=.true., xlabel='t (s) [scaled]', ylabel='s', & + title=trim(title_prefix)//': s(t)', figsize=[10, 6]) + call plt%add_plot(t(1:n_used(1)), s(1:n_used(1), 1), label='passing', linestyle='-') + call plt%add_plot(t(1:n_used(2)), s(1:n_used(2), 2), label='trapped', linestyle='-') + call plt%savefig(trim(out_case)//'/orbit/orbit_s_t.png', pyfile=trim(out_case)//'/orbit/orbit_s_t.py') + + call plt%initialize(grid=.true., xlabel='t (s) [scaled]', ylabel='Bmod (G)', & + title=trim(title_prefix)//': Bmod(t)', figsize=[10, 6]) + call plt%add_plot(t(1:n_used(1)), B(1:n_used(1), 1), label='passing', linestyle='-') + call plt%add_plot(t(1:n_used(2)), B(1:n_used(2), 2), label='trapped', linestyle='-') + call plt%savefig(trim(out_case)//'/orbit/orbit_Bmod_t.png', pyfile=trim(out_case)//'/orbit/orbit_Bmod_t.py') + + call plt%initialize(grid=.true., xlabel='R (cm)', ylabel='Z (cm)', & + title=trim(title_prefix)//': flux surfaces at phi=0', legend=.true., figsize=[10, 8]) + do isurf = 1, nsurf_plot + call plt%add_plot(R_surf(:, isurf), Z_surf(:, isurf), label='surface', linestyle='-') + end do + call plt%add_plot(R(1:n_used(1), 1), Z(1:n_used(1), 1), label='passing', linestyle='-') + call plt%add_plot(R(1:n_used(2), 2), Z(1:n_used(2), 2), label='trapped', linestyle='-') + call plt%savefig(trim(out_case)//'/flux_surfaces/flux_surfaces_RZ_phi0.png', pyfile=trim(out_case)//'/flux_surfaces/flux_surfaces_RZ_phi0.py') + end subroutine plot_case + + subroutine plot_compare(out_cmp, t, n_geo, n_cm, Rg, Zg, Rc, Zc, cgeo, ccm) + character(len=*), intent(in) :: out_cmp + real(dp), intent(in) :: t(:) + integer, intent(in) :: n_geo(norbits), n_cm(norbits) + real(dp), intent(in) :: Rg(:, :), Zg(:, :), Rc(:, :), Zc(:, :) + real(dp), intent(in) :: cgeo(3), ccm(3) + + integer :: n1, n2 + real(dp), allocatable :: dR_pass(:), dZ_pass(:), dR_trap(:), dZ_trap(:) + + call plt%initialize(grid=.true., xlabel='R (cm)', ylabel='Z (cm)', & + title='GEQDSK RK45: orbit overlay (geoflux vs chartmap)', legend=.true., figsize=[10, 8]) + call plt%add_plot(Rg(1:n_geo(1), 1), Zg(1:n_geo(1), 1), label='geoflux passing', linestyle='-', color=cgeo) + call plt%add_plot(Rg(1:n_geo(2), 2), Zg(1:n_geo(2), 2), label='geoflux trapped', linestyle='--', color=cgeo) + call plt%add_plot(Rc(1:n_cm(1), 1), Zc(1:n_cm(1), 1), label='chartmap passing', linestyle='-', color=ccm) + call plt%add_plot(Rc(1:n_cm(2), 2), Zc(1:n_cm(2), 2), label='chartmap trapped', linestyle='--', color=ccm) + call plt%savefig(trim(out_cmp)//'/orbit_RZ_overlay.png', pyfile=trim(out_cmp)//'/orbit_RZ_overlay.py') + + n1 = min(n_geo(1), n_cm(1)) + n2 = min(n_geo(2), n_cm(2)) + allocate(dR_pass(n1), dZ_pass(n1), dR_trap(n2), dZ_trap(n2)) + dR_pass = Rc(1:n1, 1) - Rg(1:n1, 1) + dZ_pass = Zc(1:n1, 1) - Zg(1:n1, 1) + dR_trap = Rc(1:n2, 2) - Rg(1:n2, 2) + dZ_trap = Zc(1:n2, 2) - Zg(1:n2, 2) + + call plt%initialize(grid=.true., xlabel='t (s) [scaled]', ylabel='delta (cm)', & + title='GEQDSK RK45: chartmap - geoflux deltaR, deltaZ', legend=.true., figsize=[10, 6]) + call plt%add_plot(t(1:n1), dR_pass, label='passing deltaR', linestyle='-') + call plt%add_plot(t(1:n1), dZ_pass, label='passing deltaZ', linestyle='-') + call plt%add_plot(t(1:n2), dR_trap, label='trapped deltaR', linestyle='--') + call plt%add_plot(t(1:n2), dZ_trap, label='trapped deltaZ', linestyle='--') + call plt%savefig(trim(out_cmp)//'/orbit_deltaRZ_t.png', pyfile=trim(out_cmp)//'/orbit_deltaRZ_t.py') + + deallocate(dR_pass, dZ_pass, dR_trap, dZ_trap) + + call plt%initialize(grid=.true., xlabel='R (cm)', ylabel='Z (cm)', & + title='GEQDSK: flux surfaces overlay (geoflux vs chartmap)', legend=.true., figsize=[10, 8]) + call plt%add_plot(R_surf_geo(:, nsurf_plot), Z_surf_geo(:, nsurf_plot), label='geoflux outer', linestyle='-', color=cgeo) + call plt%add_plot(R_surf_cm(:, nsurf_plot), Z_surf_cm(:, nsurf_plot), label='chartmap outer', linestyle='-', color=ccm) + call plt%savefig(trim(out_cmp)//'/flux_surfaces_RZ_phi0_overlay.png', pyfile=trim(out_cmp)//'/flux_surfaces_RZ_phi0_overlay.py') + end subroutine plot_compare + + subroutine write_start(path, s0, th0, ph0, p0, lam0) + character(len=*), intent(in) :: path + real(dp), intent(in) :: s0, th0, ph0, p0, lam0 + integer :: unit + + open(newunit=unit, file=trim(path), status='replace', action='write') + write(unit, *) s0, th0, ph0, p0, lam0 + close(unit) + end subroutine write_start + + subroutine write_chartmap_from_geoflux(filename, nrho, ntheta, nzeta) + character(len=*), intent(in) :: filename + integer, intent(in) :: nrho, ntheta, nzeta + + integer :: ncid, dim_rho, dim_theta, dim_zeta + integer :: var_rho, var_theta, var_zeta + integer :: var_x, var_y, var_z + integer :: dimids_xyz(3) + integer :: ierr + integer :: i_r, i_t, i_z + real(dp), allocatable :: rho(:), theta(:), zeta(:) + real(dp), allocatable :: x(:, :, :), y(:, :, :), z(:, :, :) + real(dp) :: u(3), xyz(3) + + allocate(rho(nrho), theta(ntheta), zeta(nzeta)) + allocate(x(nrho, ntheta, nzeta), y(nrho, ntheta, nzeta), z(nrho, ntheta, nzeta)) + + do i_r = 1, nrho + rho(i_r) = real(i_r - 1, dp)/real(nrho - 1, dp) + end do + do i_t = 1, ntheta + theta(i_t) = (real(i_t - 1, dp)/real(ntheta - 1, dp))*twopi + end do + do i_z = 1, nzeta + zeta(i_z) = (real(i_z - 1, dp)/real(nzeta - 1, dp))*twopi + end do + + do i_z = 1, nzeta + do i_t = 1, ntheta + do i_r = 1, nrho + u = [rho(i_r), theta(i_t), zeta(i_z)] + call geoflux_to_cart(u, xyz) + x(i_r, i_t, i_z) = xyz(1) + y(i_r, i_t, i_z) = xyz(2) + z(i_r, i_t, i_z) = xyz(3) + end do + end do + end do + + ierr = nf90_create(trim(filename), nf90_clobber, ncid) + call nc_check(ierr, 'nf90_create') + + ierr = nf90_def_dim(ncid, 'rho', nrho, dim_rho) + call nc_check(ierr, 'def_dim rho') + ierr = nf90_def_dim(ncid, 'theta', ntheta, dim_theta) + call nc_check(ierr, 'def_dim theta') + ierr = nf90_def_dim(ncid, 'zeta', nzeta, dim_zeta) + call nc_check(ierr, 'def_dim zeta') + + ierr = nf90_def_var(ncid, 'rho', nf90_double, (/dim_rho/), var_rho) + call nc_check(ierr, 'def_var rho') + ierr = nf90_def_var(ncid, 'theta', nf90_double, (/dim_theta/), var_theta) + call nc_check(ierr, 'def_var theta') + ierr = nf90_def_var(ncid, 'zeta', nf90_double, (/dim_zeta/), var_zeta) + call nc_check(ierr, 'def_var zeta') + + dimids_xyz = (/dim_rho, dim_theta, dim_zeta/) + ierr = nf90_def_var(ncid, 'x', nf90_double, dimids_xyz, var_x) + call nc_check(ierr, 'def_var x') + ierr = nf90_def_var(ncid, 'y', nf90_double, dimids_xyz, var_y) + call nc_check(ierr, 'def_var y') + ierr = nf90_def_var(ncid, 'z', nf90_double, dimids_xyz, var_z) + call nc_check(ierr, 'def_var z') + + ierr = nf90_enddef(ncid) + call nc_check(ierr, 'enddef') + + ierr = nf90_put_var(ncid, var_rho, rho) + call nc_check(ierr, 'put rho') + ierr = nf90_put_var(ncid, var_theta, theta) + call nc_check(ierr, 'put theta') + ierr = nf90_put_var(ncid, var_zeta, zeta) + call nc_check(ierr, 'put zeta') + ierr = nf90_put_var(ncid, var_x, x) + call nc_check(ierr, 'put x') + ierr = nf90_put_var(ncid, var_y, y) + call nc_check(ierr, 'put y') + ierr = nf90_put_var(ncid, var_z, z) + call nc_check(ierr, 'put z') + + ierr = nf90_close(ncid) + call nc_check(ierr, 'close') + + deallocate(rho, theta, zeta, x, y, z) + end subroutine write_chartmap_from_geoflux + + subroutine nc_check(ierr, what) + integer, intent(in) :: ierr + character(len=*), intent(in) :: what + + if (ierr /= nf90_noerr) then + print *, 'NetCDF error in ', trim(what), ': ', trim(nf90_strerror(ierr)) + error stop 'NetCDF failure' + end if + end subroutine nc_check + +end program test_geoflux_rk45_orbit_plot_chartmap_compare From 5f4635e5cca51dd89ac795b3342d2c41d26c2a44 Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Sat, 13 Dec 2025 17:47:17 +0100 Subject: [PATCH 19/19] Tidy chartmap compare and include flux surfaces in tokamak RK45 orbit plot --- src/coordinates/reference_coordinates.f90 | 46 +++++++++++-- ...oflux_rk45_orbit_plot_chartmap_compare.f90 | 64 ++++++------------- ...test_tokamak_testfield_rk45_orbit_plot.f90 | 43 +++++++++---- 3 files changed, 92 insertions(+), 61 deletions(-) diff --git a/src/coordinates/reference_coordinates.f90 b/src/coordinates/reference_coordinates.f90 index fe3149d2..6fa00936 100644 --- a/src/coordinates/reference_coordinates.f90 +++ b/src/coordinates/reference_coordinates.f90 @@ -3,6 +3,7 @@ module reference_coordinates use, intrinsic :: iso_fortran_env, only: dp => real64 use libneo_coordinates, only: coordinate_system_t, make_vmec_coordinate_system, & make_geoflux_coordinate_system, make_chartmap_coordinate_system + use netcdf, only: nf90_close, nf90_inq_dimid, nf90_inq_varid, nf90_noerr, nf90_nowrite, nf90_open implicit none @@ -21,7 +22,7 @@ subroutine init_reference_coordinates(coord_input) if (allocated(ref_coords)) deallocate (ref_coords) - if (is_chartmap_name(coord_input)) then + if (is_chartmap_file(coord_input)) then call make_chartmap_coordinate_system(ref_coords, trim(coord_input)) else if (is_geqdsk_name(coord_input)) then call make_geoflux_coordinate_system(ref_coords) @@ -30,15 +31,52 @@ subroutine init_reference_coordinates(coord_input) end if end subroutine init_reference_coordinates - logical function is_chartmap_name(filename) + logical function is_chartmap_file(filename) character(*), intent(in) :: filename + integer :: ncid + integer :: dimid + integer :: varid + integer :: ierr + logical :: exists character(:), allocatable :: lower_name lower_name = to_lower(trim(filename)) - is_chartmap_name = index(strip_directory(lower_name), 'chartmap') > 0 - end function is_chartmap_name + inquire(file=trim(filename), exist=exists) + if (.not. exists) then + is_chartmap_file = index(strip_directory(lower_name), 'chartmap') > 0 + return + end if + + ierr = nf90_open(trim(filename), nf90_nowrite, ncid) + if (ierr /= nf90_noerr) then + is_chartmap_file = index(strip_directory(lower_name), 'chartmap') > 0 + return + end if + + is_chartmap_file = .true. + + ierr = nf90_inq_dimid(ncid, 'rho', dimid) + if (ierr /= nf90_noerr) is_chartmap_file = .false. + ierr = nf90_inq_dimid(ncid, 'theta', dimid) + if (ierr /= nf90_noerr) is_chartmap_file = .false. + ierr = nf90_inq_dimid(ncid, 'zeta', dimid) + if (ierr /= nf90_noerr) is_chartmap_file = .false. + + ierr = nf90_inq_varid(ncid, 'x', varid) + if (ierr /= nf90_noerr) is_chartmap_file = .false. + ierr = nf90_inq_varid(ncid, 'y', varid) + if (ierr /= nf90_noerr) is_chartmap_file = .false. + ierr = nf90_inq_varid(ncid, 'z', varid) + if (ierr /= nf90_noerr) is_chartmap_file = .false. + + ierr = nf90_close(ncid) + + if (.not. is_chartmap_file) then + is_chartmap_file = index(strip_directory(lower_name), 'chartmap') > 0 + end if + end function is_chartmap_file logical function is_geqdsk_name(filename) character(*), intent(in) :: filename diff --git a/test/tests/test_geoflux_rk45_orbit_plot_chartmap_compare.f90 b/test/tests/test_geoflux_rk45_orbit_plot_chartmap_compare.f90 index 7679abb4..16dfc7fc 100644 --- a/test/tests/test_geoflux_rk45_orbit_plot_chartmap_compare.f90 +++ b/test/tests/test_geoflux_rk45_orbit_plot_chartmap_compare.f90 @@ -24,8 +24,7 @@ program test_geoflux_rk45_orbit_plot_chartmap_compare character(len=1024) :: cfg_geoflux, cfg_chartmap character(len=1024) :: start_pass, start_trap character(len=1024) :: geqdsk_file, chartmap_file, chartmap_env - character(len=256) :: cfg256 - integer :: status, mkdir_stat + integer :: status integer, parameter :: norbits = 2 integer, parameter :: ntheta_plot = 361 @@ -82,7 +81,7 @@ program test_geoflux_rk45_orbit_plot_chartmap_compare call write_config_geoflux(cfg_geoflux, geqdsk_file) call write_config_chartmap(cfg_chartmap, geqdsk_file, chartmap_file) - call init_case(cfg_geoflux, 'geoflux') + call load_case(cfg_geoflux) if (.not. (status == 0 .and. len_trim(chartmap_env) > 0)) then call write_chartmap_from_geoflux(trim(chartmap_file), 65, 129, 65) end if @@ -98,31 +97,30 @@ program test_geoflux_rk45_orbit_plot_chartmap_compare allocate(s_cm(nmax, norbits), th_cm(nmax, norbits), ph_cm(nmax, norbits)) allocate(R_cm(nmax, norbits), Z_cm(nmax, norbits), B_cm(nmax, norbits)) - call integrate_orbit(cfg_geoflux, start_pass, 1, n_used_geo, s_geo, th_geo, ph_geo, R_geo, Z_geo, B_geo) - call integrate_orbit(cfg_geoflux, start_trap, 2, n_used_geo, s_geo, th_geo, ph_geo, R_geo, Z_geo, B_geo) - - call integrate_orbit(cfg_chartmap, start_pass, 1, n_used_cm, s_cm, th_cm, ph_cm, R_cm, Z_cm, B_cm) - call integrate_orbit(cfg_chartmap, start_trap, 2, n_used_cm, s_cm, th_cm, ph_cm, R_cm, Z_cm, B_cm) - - do iorb = 1, norbits - if (min(n_used_geo(iorb), n_used_cm(iorb)) < 50) then - error stop 'test_geoflux_rk45_orbit_plot_chartmap_compare: orbit produced too few points' - end if - end do + call integrate_orbit_from_start(start_pass, 1, n_used_geo, s_geo, th_geo, ph_geo, R_geo, Z_geo, B_geo) + call integrate_orbit_from_start(start_trap, 2, n_used_geo, s_geo, th_geo, ph_geo, R_geo, Z_geo, B_geo) color_geo = [0.0_dp, 0.0_dp, 1.0_dp] color_cm = [1.0_dp, 0.0_dp, 0.0_dp] call build_theta_surf_grids(theta_grid, surf_s) - call build_flux_surfaces(cfg_geoflux, theta_grid, surf_s, R_surf_geo, Z_surf_geo) - call build_flux_surfaces(cfg_chartmap, theta_grid, surf_s, R_surf_cm, Z_surf_cm) - + call build_flux_surfaces(theta_grid, surf_s, R_surf_geo, Z_surf_geo) call plot_case(out_geoflux, 'geoflux reference', time_traj, n_used_geo, R_geo, Z_geo, s_geo, th_geo, ph_geo, B_geo, & R_surf_geo, Z_surf_geo, theta_grid, surf_s) + call load_case(cfg_chartmap) + call integrate_orbit_from_start(start_pass, 1, n_used_cm, s_cm, th_cm, ph_cm, R_cm, Z_cm, B_cm) + call integrate_orbit_from_start(start_trap, 2, n_used_cm, s_cm, th_cm, ph_cm, R_cm, Z_cm, B_cm) + call build_flux_surfaces(theta_grid, surf_s, R_surf_cm, Z_surf_cm) call plot_case(out_chartmap, 'chartmap reference', time_traj, n_used_cm, R_cm, Z_cm, s_cm, th_cm, ph_cm, B_cm, & R_surf_cm, Z_surf_cm, theta_grid, surf_s) + do iorb = 1, norbits + if (min(n_used_geo(iorb), n_used_cm(iorb)) < 50) then + error stop 'test_geoflux_rk45_orbit_plot_chartmap_compare: orbit produced too few points' + end if + end do + call plot_compare(out_compare, time_traj, n_used_geo, n_used_cm, R_geo, Z_geo, R_cm, Z_cm, color_geo, color_cm) print *, 'ARTIFACT_DIR: ', trim(out_dir) @@ -194,9 +192,8 @@ subroutine write_config_chartmap(path, geqdsk_path, chartmap_path) close(unit) end subroutine write_config_chartmap - subroutine init_case(config_path, label) + subroutine load_case(config_path) character(len=*), intent(in) :: config_path - character(len=*), intent(in) :: label character(len=256) :: cfg_local cfg_local = trim(config_path) @@ -204,14 +201,10 @@ subroutine init_case(config_path, label) call init_field(norb, netcdffile, ns_s, ns_tp, multharm, integmode) call params_init call init_magfie(VMEC) + end subroutine load_case - if (label == '') then - continue - end if - end subroutine init_case - - subroutine integrate_orbit(cfg_path, start_path, orbit_index, n_used, s_arr, th_arr, ph_arr, R_arr, Z_arr, B_arr) - character(len=*), intent(in) :: cfg_path, start_path + subroutine integrate_orbit_from_start(start_path, orbit_index, n_used, s_arr, th_arr, ph_arr, R_arr, Z_arr, B_arr) + character(len=*), intent(in) :: start_path integer, intent(in) :: orbit_index integer, intent(inout) :: n_used(norbits) real(dp), intent(inout) :: s_arr(:, :), th_arr(:, :), ph_arr(:, :) @@ -222,13 +215,6 @@ subroutine integrate_orbit(cfg_path, start_path, orbit_index, n_used, s_arr, th_ real(dp) :: xyz(3), xcyl(3) real(dp) :: bmod_local, sqrtg_local real(dp) :: bder(3), hcov(3), hctrvr(3), hcurl(3) - character(len=256) :: cfg_local - - cfg_local = trim(cfg_path) - call read_config(cfg_local) - call init_field(norb, netcdffile, ns_s, ns_tp, multharm, integmode) - call params_init - call init_magfie(VMEC) call load_starting_points(zstart, trim(start_path)) z_local = zstart(:, 1) @@ -253,7 +239,7 @@ subroutine integrate_orbit(cfg_path, start_path, orbit_index, n_used, s_arr, th_ if (ierr_local /= 0) exit if (z_local(1) < 0.0_dp .or. z_local(1) > 1.0_dp) exit end do - end subroutine integrate_orbit + end subroutine integrate_orbit_from_start subroutine cart_to_cyl(xyz, xcyl) real(dp), intent(in) :: xyz(3) @@ -275,8 +261,7 @@ subroutine build_theta_surf_grids(theta, svals) svals = [0.10_dp, 0.25_dp, 0.40_dp, 0.60_dp, 0.80_dp, 0.95_dp] end subroutine build_theta_surf_grids - subroutine build_flux_surfaces(cfg_path, theta, svals, R_surf, Z_surf) - character(len=*), intent(in) :: cfg_path + subroutine build_flux_surfaces(theta, svals, R_surf, Z_surf) real(dp), intent(in) :: theta(ntheta_plot) real(dp), intent(in) :: svals(nsurf_plot) real(dp), intent(out) :: R_surf(ntheta_plot, nsurf_plot) @@ -284,13 +269,6 @@ subroutine build_flux_surfaces(cfg_path, theta, svals, R_surf, Z_surf) integer :: isurf, it real(dp) :: xyz(3), xcyl(3) - character(len=256) :: cfg_local - - cfg_local = trim(cfg_path) - call read_config(cfg_local) - call init_field(norb, netcdffile, ns_s, ns_tp, multharm, integmode) - call params_init - call init_magfie(VMEC) do isurf = 1, nsurf_plot do it = 1, ntheta_plot diff --git a/test/tests/test_tokamak_testfield_rk45_orbit_plot.f90 b/test/tests/test_tokamak_testfield_rk45_orbit_plot.f90 index 4e46d016..b737c638 100644 --- a/test/tests/test_tokamak_testfield_rk45_orbit_plot.f90 +++ b/test/tests/test_tokamak_testfield_rk45_orbit_plot.f90 @@ -23,15 +23,22 @@ program test_tokamak_testfield_rk45_orbit_plot integer :: status, mkdir_stat integer, parameter :: norbits = 2 + integer, parameter :: nsurf_plot = 6 + integer, parameter :: ntheta_plot = 361 integer :: nmax, i, iorb, ierr integer :: n_used(norbits) character(len=16) :: orbit_label(norbits) real(dp) :: color_pass(3), color_trap(3) + real(dp) :: color_surf(3) real(dp), allocatable :: time_traj(:) real(dp), allocatable :: s_traj(:, :), theta_traj(:, :), phi_traj(:, :) real(dp), allocatable :: r_traj(:, :), z_traj(:, :), bmod_traj(:, :) real(dp), allocatable :: p_traj(:, :), lam_traj(:, :), mu_traj(:, :) + real(dp) :: surf_s(nsurf_plot) + real(dp) :: theta_grid(ntheta_plot) + real(dp) :: r_surf(ntheta_plot, nsurf_plot), z_surf(ntheta_plot, nsurf_plot) + real(dp) :: bmod_st(nsurf_plot, ntheta_plot) out_root = '' call get_environment_variable('SIMPLE_ARTIFACT_DIR', value=out_root, status=status) @@ -69,6 +76,7 @@ program test_tokamak_testfield_rk45_orbit_plot orbit_label(2) = 'trapped' color_pass = [0.0_dp, 0.0_dp, 1.0_dp] color_trap = [1.0_dp, 0.0_dp, 0.0_dp] + color_surf = [0.7_dp, 0.7_dp, 0.7_dp] nmax = ntimstep allocate(time_traj(nmax)) @@ -89,6 +97,8 @@ program test_tokamak_testfield_rk45_orbit_plot end if end do + call compute_flux_surface_data() + call plot_orbit_and_diagnostics() call plot_flux_surfaces_and_fields() @@ -263,10 +273,14 @@ end subroutine eval_testfield_cyl subroutine plot_orbit_and_diagnostics() character(len=1024) :: png_orbit_rz character(len=1024) :: png_poincare_phi0 + integer :: isurf png_orbit_rz = trim(out_orbit)//'/orbit_RZ.png' call plt%initialize(grid=.true., xlabel='R', ylabel='Z', & title='TEST tokamak RK45 orbit projection (R,Z)', legend=.true., figsize=[10, 8]) + do isurf = 1, nsurf_plot + call plt%add_plot(r_surf(:, isurf), z_surf(:, isurf), label='', linestyle='-', color=color_surf) + end do call plt%add_plot(r_traj(1:n_used(1), 1), z_traj(1:n_used(1), 1), label='passing', linestyle='-', color=color_pass) call plt%add_plot(r_traj(1:n_used(2), 2), z_traj(1:n_used(2), 2), label='trapped', linestyle='-', color=color_trap) call plt%savefig(trim(png_orbit_rz), pyfile=trim(out_orbit)//'/orbit_RZ.py') @@ -317,19 +331,9 @@ subroutine plot_orbit_and_diagnostics() call plt%savefig(trim(out_orbit)//'/orbit_mu_t.png', pyfile=trim(out_orbit)//'/orbit_mu_t.py') end subroutine plot_orbit_and_diagnostics - subroutine plot_flux_surfaces_and_fields() - integer, parameter :: nsurf_plot = 6, ntheta_plot = 361 - integer, parameter :: nr = 240, nz = 240 - real(dp) :: surf_s(nsurf_plot), theta_grid(ntheta_plot) - real(dp) :: r_surf(ntheta_plot, nsurf_plot), z_surf(ntheta_plot, nsurf_plot) - real(dp) :: rmin_g, rmax_g, zmin_g, zmax_g, dr, dz - real(dp) :: rgrid(nr), zgrid(nz) - real(dp) :: bmod_map(nr, nz), br_map(nr, nz), bphi_map(nr, nz), bz_map(nr, nz) - real(dp) :: bmod_st(nsurf_plot, ntheta_plot) - real(dp) :: x_geo(3), x_cyl(3) - logical :: inside - integer :: isurf, itheta, iR, iZ - real(dp) :: Br, Bphi, Bz, Bmod + subroutine compute_flux_surface_data() + integer :: isurf, itheta + real(dp) :: x_cyl(3) surf_s = [0.05_dp, 0.15_dp, 0.25_dp, 0.45_dp, 0.70_dp, 0.95_dp] do itheta = 1, ntheta_plot @@ -345,11 +349,22 @@ subroutine plot_flux_surfaces_and_fields() call eval_testfield_bmod(surf_s(isurf), theta_grid(itheta), 0.0_dp, bmod_st(isurf, itheta)) end do end do + end subroutine compute_flux_surface_data + + subroutine plot_flux_surfaces_and_fields() + integer, parameter :: nr = 240, nz = 240 + real(dp) :: rmin_g, rmax_g, zmin_g, zmax_g, dr, dz + real(dp) :: rgrid(nr), zgrid(nz) + real(dp) :: bmod_map(nr, nz), br_map(nr, nz), bphi_map(nr, nz), bz_map(nr, nz) + real(dp) :: x_geo(3), x_cyl(3) + logical :: inside + integer :: isurf, iR, iZ + real(dp) :: Br, Bphi, Bz, Bmod call plt%initialize(grid=.true., xlabel='R', ylabel='Z', & title='TEST tokamak flux surfaces at phi=0 with orbit overlay', legend=.true., figsize=[10, 8]) do isurf = 1, nsurf_plot - call plt%add_plot(r_surf(:, isurf), z_surf(:, isurf), label='surface', linestyle='-') + call plt%add_plot(r_surf(:, isurf), z_surf(:, isurf), label='', linestyle='-', color=color_surf) end do call plt%add_plot(r_traj(1:n_used(1), 1), z_traj(1:n_used(1), 1), label='passing', linestyle='-', color=color_pass) call plt%add_plot(r_traj(1:n_used(2), 2), z_traj(1:n_used(2), 2), label='trapped', linestyle='-', color=color_trap)