Skip to content

Refactor classification.f90: eliminate code duplication with CutDetector #185

Description

@krystophny

Refactor classification.f90 to eliminate code duplication with CutDetector

Problem Statement

The trace_orbit_with_classifiers subroutine in src/classification.f90 is a 416-line monolithic routine that reimplements cut detection logic that already exists in the CutDetector type (src/cut_detector.f90). This creates:

  • Code duplication: ~200 lines of identical tip/period detection logic
  • Maintenance burden: Bug fixes must be applied in two places
  • Technical debt: Prevents clean Python API for classification
  • Testing difficulty: Classification logic intertwined with integration and I/O

Current Architecture

trace_orbit_with_classifiers (classification.f90:48-416)

Does everything in one routine:

  1. Orbit integration (like trace_orbit)
  2. Banana tip detection (v_parallel sign change)
  3. Periodic boundary crossing detection (toroidal cuts)
  4. Stencil interpolation (Lagrange polynomials)
  5. J_parallel classification (check_orbit_type)
  6. Topological ideal/non-ideal classification
  7. Minkowski fractal dimension computation (fract_dimension)
  8. Dynamic array management (reallocating tip/period buffers)
  9. File I/O (writing 10+ fort.* files)
  10. Early exit logic (fast_class, class_plot, regularity detection)

Structure:

subroutine trace_orbit_with_classifiers(anorb, ipart)
  ! Lines 48-151: Setup, coordinate conversion, early exits
  ! Lines 154-207: Allocate stencil & cut storage arrays
  ! Lines 210-401: MAIN LOOP
  !   do it=2,ntimstep                    ! Outer loop
  !     do ktau=1,ntau                    ! Inner loop
  !       call orbit_timestep_sympl(...)  ! Integration
  !       
  !       ! === DUPLICATED FROM trace_to_cut ===
  !       ! Update stencil (lines 241-249)
  !       ! Detect tip (lines 252-254)
  !       ! Interpolate tip (lines 256-263)
  !       ! Detect period (lines 305-314)
  !       ! Interpolate period (lines 316-323)
  !       
  !       ! === CLASSIFICATION (unique) ===
  !       ! classify at tip (lines 288-299)
  !       ! Minkowski at ntcut (lines 348-382)
  !       
  !       ! === FILE I/O (unique) ===
  !       ! write fort.* files (lines 379-387)
  !     enddo
  !   enddo
  ! Lines 403-416: Cleanup
end subroutine

CutDetector::trace_to_cut (cut_detector.f90:70-152)

Clean, focused function:

  • Integrates orbit until next tip or periodic crossing
  • Returns var_cut(6) = [s, θ, φ, |v|, v_∥, J_∥]
  • Returns cut_type (0=tip, 1=periodic)
  • Used by examples/orbits_and_cuts.py for visualization

Key difference: Returns one cut at a time, caller manages loop.

Code Duplication Analysis

Identical Logic in Both Routines

Feature trace_orbit_with_classifiers CutDetector::trace_to_cut
Parameters nplagr=6, nder=0, npl_half=3 nplagr=6, nder=0, nplagr/2=3
Stencil update Lines 241-249 Lines 91-100
Tip detection Lines 252-254 Lines 106-108
Tip interpolation Lines 256-263 Lines 111-117
Period detection Lines 305-314 Lines 126-135
Period interpolation Lines 316-323 Lines 138-144
Parallel invariant Lines 241, 286 Lines 91, 117
Stencil rotation ipoi=cshift(ipoi,1) self%ipoi=cshift(self%ipoi,1)

Total duplicated lines: ~200 (lines 241-263 + 305-343 in classification.f90)

What trace_orbit_with_classifiers Adds

Beyond cut detection, it provides:

  1. Cut storage (lines 267-285, 325-343):

    ! Dynamic arrays that grow as cuts are collected
    real(dp), allocatable :: zpoipl_tip(:,:)   ! (2, nfp_tip)
    real(dp), allocatable :: zpoipl_per(:,:)   ! (2, nfp_per)
    ! Reallocate when full (13 OpenMP critical sections!)
  2. J_parallel & topological classification (lines 288-299):

    fpr_in = [var_tip(1), var_tip(iangvar), var_tip(6)]
    call check_orbit_type(nturns, nfp_cot, fpr_in, ideal, ijpar, ierr_cot)
    iclass(1,ipart) = ijpar    ! 0=unclassified, 1=regular, 2=stochastic
    iclass(2,ipart) = ideal    ! 0=unclassified, 1=ideal, 2=non-ideal
  3. Minkowski/fractal dimension (lines 348-382):

    if(kt == ntcut) then
      call fract_dimension(ifp_tip, zpoipl_tip(:,1:ifp_tip), fraction)
      if(fraction > 0.2d0) then
        regular = .False.
        iclass(3,ipart) = 2  ! Stochastic
      else
        iclass(3,ipart) = 1  ! Regular
      endif
    endif
  4. File output (scattered):

    • fort.20000 - trapped-passing boundary (line 101)
    • fort.10000 - forced regular passing (line 146)
    • fort.10011/10012 - regular passing/trapped (lines 437, 439)
    • fort.10021/10022 - stochastic passing/trapped (lines 443, 445)
    • fort.40012/40022/40032 - J_parallel classes (lines 456-460)
    • fort.50012/50022/50032 - topological classes (lines 470-474)
  5. Early exit conditions:

    • fast_class=.true. → exit after first tip (line 297)
    • class_plot=.true. + Minkowski done → exit (line 380)
    • regular=.true. → skip integration, just count (lines 211-220)

Why This Matters

Current Issues

  1. Python API blocked: Tests (test_classification_api.py, test_simple_api.py, test_batch_api.py) expect high-level classify_fast() API, but implementation requires:

    • Managing global params (class_plot, fast_class, tcut, ntcut)
    • Parsing fort.* files (no data structure return)
    • Working directory changes (fort units are global)
  2. Maintenance cost: Bug fix in cut detection must be applied twice:

    • Fixed in trace_to_cut for visualization
    • Separately fixed in trace_orbit_with_classifiers for classification
  3. Testing: Can't test classification logic independently from:

    • Integration
    • File I/O
    • OpenMP parallelization
  4. Extensibility: Adding new classification methods requires:

    • Modifying 416-line routine
    • Understanding nested loops, dynamic allocation, OpenMP critical sections

Impact on Users

  • Fortran users: Works fine, just hard to maintain
  • Python users: Cannot access classification features without:
    • File I/O workarounds
    • Managing working directory
    • Parsing text files

Proposed Refactoring Plan

Phase 1: Extract Cut Storage (Low Risk)

Goal: Simplify memory management, prepare for CutDetector integration.

New type:

! In classification.f90
type :: ClassificationCutStorage
  integer :: max_cuts
  integer :: n_tips, n_periods
  
  ! Fixed-size arrays (no dynamic reallocation)
  real(dp), allocatable :: tips(:,:)        ! (2, max_cuts)  - [s, θ]
  real(dp), allocatable :: periods(:,:)     ! (2, max_cuts)  - [s, θ]
  real(dp), allocatable :: tip_data(:,:)    ! (6, max_cuts)  - full var_tip
  
contains
  procedure :: init_storage
  procedure :: add_tip
  procedure :: add_period
  procedure :: is_full
end type ClassificationCutStorage

Benefits:

  • Eliminates 13 OpenMP critical sections for reallocation
  • Pre-allocate based on expected number of cuts
  • OpenMP-friendly (no mid-loop allocation)

Changes to trace_orbit_with_classifiers:

! Replace lines 162-167, 267-285, 325-343
type(ClassificationCutStorage) :: storage
call storage%init_storage(max_cuts=1000)

! In loop, replace reallocation with:
if(.not. storage%is_full()) then
  call storage%add_tip(var_tip)
endif

Estimated effort: 2-3 hours
Risk: Low (isolated change, preserves existing logic)


Phase 2: Use CutDetector for Detection (Medium Risk)

Goal: Eliminate duplicated cut detection code.

Key insight: trace_to_cut integrates until next cut, but trace_orbit_with_classifiers integrates fixed timesteps. Need to bridge this:

subroutine trace_orbit_with_classifiers_v2(anorb, ipart)
  type(CutDetector) :: cutty
  type(ClassificationCutStorage) :: storage
  real(dp) :: var_cut(6)
  integer :: cut_type, ierr, n_cuts
  integer(8) :: kt, kt_start, kt_cut
  
  ! === SETUP (keep lines 48-151 mostly as-is) ===
  ! Initialize cutty instead of local variables
  call cutty%init(fper, z)
  call storage%init_storage(max_cuts=1000)
  
  ! === MAIN LOOP: Replace lines 210-401 ===
  n_cuts = 0
  kt = 0
  
  do while(n_cuts < storage%max_cuts .and. kt < ntimstep*ntau)
    kt_start = kt
    
    ! Use CutDetector instead of inline logic
    call trace_to_cut_with_counter(cutty, anorb%si, anorb%f, z, &
                                     var_cut, cut_type, kt_cut, ierr)
    kt = kt + kt_cut
    
    if(ierr /= 0) exit  ! Particle lost
    
    ! Update confined counters (need to backfill timesteps)
    do it = (kt_start/ntau)+1, (kt/ntau)
      if(passing) then
        !$omp atomic
        confpart_pass(it) = confpart_pass(it) + 1.d0
      else
        !$omp atomic
        confpart_trap(it) = confpart_trap(it) + 1.d0
      endif
    enddo
    
    n_cuts = n_cuts + 1
    
    ! === CLASSIFICATION (keep) ===
    if(cut_type == 0) then  ! Tip
      call storage%add_tip(var_cut)
      
      ! J_parallel & topological classification
      fpr_in = [var_cut(1), var_cut(iangvar), var_cut(6)]
      call check_orbit_type(nturns, nfp_cot, fpr_in, ideal, ijpar, ierr_cot)
      iclass(1,ipart) = ijpar
      iclass(2,ipart) = ideal
      
      if(fast_class) exit
      
    elseif(cut_type == 1) then  ! Periodic crossing
      call storage%add_period(var_cut)
    endif
    
    ! === MINKOWSKI AT TIME CUTOFF ===
    if(kt >= ntcut) then
      if(storage%n_tips > 0) then
        call fract_dimension(storage%n_tips, storage%tips(:,1:storage%n_tips), fraction)
        if(fraction > 0.2d0) then
          regular = .False.
          iclass(3,ipart) = 2
        else
          iclass(3,ipart) = 1
        endif
      endif
      
      if(class_plot) then
        call output_minkowsky_class(ipart, regular, passing)
        exit
      endif
      
      ! Continue but skip integration (keep regularity shortcut)
      regular = .True.
    endif
  enddo
  
  ! === CLEANUP (keep lines 403-416) ===
  ! Write files if class_plot
  if(class_plot .and. .not. passing) then
    call output_jpar_class(ipart, ijpar)
    call output_topological_class(ipart, ideal)
  endif
  
  zend(:,ipart) = z
  times_lost(ipart) = kt*dtaumin/v0
end subroutine

New helper needed:

! In cut_detector.f90
subroutine trace_to_cut_with_counter(self, si, f, z, var_cut, cut_type, n_steps, ierr)
  ! Same as trace_to_cut, but also returns n_steps taken
  integer(8), intent(out) :: n_steps
  ! ...
  n_steps = 0
  do i=1, nstep_max
    n_steps = n_steps + 1
    call tstep(...)
    ! ... rest as before
  enddo
end subroutine

Benefits:

  • ✅ Eliminates ~200 lines of duplicate code
  • ✅ Reuses tested cut detection from CutDetector
  • ✅ Single place to fix bugs
  • ✅ Easier to understand (separation of concerns)

Challenges:

  • ⚠️ Need to track timesteps correctly for confpart_pass/trap arrays
  • ⚠️ CutDetector has allocatable components → not thread-safe
    • Solution: Make thread-private or use !$omp critical around trace_to_cut
  • ⚠️ Behavior change: currently updates confpart every ntau steps, new version would update at each cut
    • Solution: Backfill timesteps between cuts (see loop above)

Testing strategy:

  1. Run existing simple.x test cases with fast_class=.true.
  2. Compare iclass(:,:) arrays bit-for-bit
  3. Compare fort.* file outputs
  4. Check confpart_pass/trap arrays match

Estimated effort: 1-2 days
Risk: Medium (needs careful testing, OpenMP considerations)


Phase 3: Separate Classification Logic (High Value)

Goal: Make classification logic reusable, testable, and accessible from Python.

New module: src/orbit_classification.f90

module orbit_classification
  use params, only: dp => real64
  implicit none
  
  private
  public :: classify_from_tips, ClassificationResult
  
  type :: ClassificationResult
    integer :: ijpar      ! 0=unclassified, 1=regular, 2=stochastic
    integer :: ideal      ! 0=unclassified, 1=ideal, 2=non-ideal
    integer :: minkowski  ! 0=unclassified, 1=regular, 2=stochastic
  end type
  
contains
  
  ! Pure function: no side effects, no file I/O, thread-safe
  function classify_from_tips(tip_cuts, n_tips, nturns, tcut, dtaumin, v0) &
       result(classes)
    real(dp), intent(in) :: tip_cuts(:,:)  ! (6, n_tips) from CutDetector
    integer, intent(in) :: n_tips
    integer, intent(in) :: nturns
    real(dp), intent(in) :: tcut, dtaumin, v0
    type(ClassificationResult) :: classes
    
    integer :: nfp_cot, ierr_cot
    real(dp) :: fpr_in(3), fraction
    integer :: i, iangvar
    
    ! Initialize
    classes%ijpar = 0
    classes%ideal = 0
    classes%minkowski = 0
    
    if(n_tips == 0) return
    
    iangvar = 2  ! Use theta angle
    
    ! === J_PARALLEL & TOPOLOGICAL CLASSIFICATION ===
    ! Process each tip sequentially (like trace_orbit_with_classifiers)
    nfp_cot = 0
    do i = 1, n_tips
      fpr_in(1) = tip_cuts(1, i)  ! s
      fpr_in(2) = tip_cuts(iangvar, i)  ! theta
      fpr_in(3) = tip_cuts(6, i)  ! parallel invariant
      
      call check_orbit_type(nturns, nfp_cot, fpr_in, &
                            classes%ideal, classes%ijpar, ierr_cot)
      
      ! Early exit logic could be added here if needed
    enddo
    
    ! === MINKOWSKI FRACTAL DIMENSION ===
    if(n_tips > 0) then
      ! Extract (s, theta) for fractal dimension
      call fract_dimension(n_tips, tip_cuts(1:2, 1:n_tips), fraction)
      
      if(fraction > 0.2d0) then
        classes%minkowski = 2  ! Stochastic
      else
        classes%minkowski = 1  ! Regular
      endif
    endif
    
  end function classify_from_tips
  
  ! Helper for batch processing
  subroutine classify_batch(tip_cuts_batch, n_tips_batch, nturns, &
                            n_particles, classes_out)
    real(dp), intent(in) :: tip_cuts_batch(:,:,:)  ! (6, max_tips, n_particles)
    integer, intent(in) :: n_tips_batch(:)  ! (n_particles)
    integer, intent(in) :: nturns, n_particles
    type(ClassificationResult), intent(out) :: classes_out(:)  ! (n_particles)
    
    integer :: ipart
    
    !$omp parallel do
    do ipart = 1, n_particles
      classes_out(ipart) = classify_from_tips( &
        tip_cuts_batch(:, 1:n_tips_batch(ipart), ipart), &
        n_tips_batch(ipart), nturns, 0.0d0, 0.0d0, 0.0d0)
    enddo
    !$omp end parallel do
  end subroutine classify_batch
  
end module orbit_classification

Update trace_orbit_with_classifiers:

subroutine trace_orbit_with_classifiers_v3(anorb, ipart)
  use orbit_classification, only: classify_from_tips, ClassificationResult
  type(ClassificationResult) :: classes
  
  ! === COLLECT CUTS (using Phase 2 code) ===
  ! ... (Phase 2 loop, but without inline classification)
  
  ! === CLASSIFY FROM COLLECTED CUTS ===
  classes = classify_from_tips(storage%tip_data, storage%n_tips, &
                                nturns, tcut, dtaumin, v0)
  iclass(1,ipart) = classes%ijpar
  iclass(2,ipart) = classes%ideal
  iclass(3,ipart) = classes%minkowski
  
  ! === FILE OUTPUT (optional) ===
  if(class_plot) then
    call write_classification_files(ipart, classes, storage, passing)
  endif
  
  ! === CLEANUP ===
  ! ...
end subroutine

Benefits:

  • Python-accessible: Can wrap classify_from_tips directly
  • Testable: Pure function, no side effects
  • Reusable: Works with any cut data source
  • Composable: Can chain with other analysis
  • Thread-safe: No global state, no I/O in classification

Python API enabled:

import pysimple

pysimple.init(vmec_file, trace_time=0.015)
particles = pysimple.sample_surface(n=16, surface=0.4)

# Collect cuts (Phase 2)
cuts_data = pysimple.collect_orbit_cuts(particles, max_cuts=100)

# Classify (Phase 3 - NEW!)
results = pysimple.classify_orbits(cuts_data)

# Results dict:
# {
#   'j_parallel': np.array([1, 2, 0, ...]),  # per-particle
#   'topology': np.array([1, 0, 2, ...]),    # per-particle
#   'minkowski': np.array([1, 2, 1, ...]),   # per-particle
#   'n_tips': np.array([45, 67, 32, ...])    # per-particle
# }

Estimated effort: 2-3 days
Risk: Medium (need to preserve exact classification behavior)


Phase 4: File I/O Separation (Optional Polish)

Goal: Decouple file writing from classification logic.

New module: src/classification_io.f90

module classification_io
  use orbit_classification, only: ClassificationResult
  implicit none
  
  private
  public :: write_classification_files, ClassificationFileWriter
  
  type :: ClassificationFileWriter
    logical :: class_plot
    integer :: units(10)  ! Fort unit numbers
    logical :: units_open(10)
  contains
    procedure :: open_files
    procedure :: close_files
    procedure :: write_particle
  end type
  
contains
  
  subroutine write_classification_files(ipart, classes, storage, passing)
    integer, intent(in) :: ipart
    type(ClassificationResult), intent(in) :: classes
    type(ClassificationCutStorage), intent(in) :: storage
    logical, intent(in) :: passing
    
    ! Write to appropriate fort.* files based on classification
    ! (Move all write() statements here from trace_orbit_with_classifiers)
    
    select case(classes%ijpar)
      case(0)
        write(iaaa_jer, *) zstart(2,ipart), zstart(5,ipart), trap_par(ipart)
      case(1)
        write(iaaa_jre, *) zstart(2,ipart), zstart(5,ipart), trap_par(ipart)
      case(2)
        write(iaaa_jst, *) zstart(2,ipart), zstart(5,ipart), trap_par(ipart)
    end select
    
    ! ... similar for topology, minkowski
  end subroutine
  
end module classification_io

Benefits:

  • ✅ Clean separation: classification logic vs. output formatting
  • ✅ Can disable I/O for Python API (just return data structures)
  • ✅ Easier to support multiple output formats (HDF5, NetCDF, etc.)

Estimated effort: 4-6 hours
Risk: Low (pure refactoring, preserves file formats)


Migration Strategy

Backward Compatibility

During refactoring, maintain both versions:

! classification.f90
subroutine trace_orbit_with_classifiers(anorb, ipart)
  ! Current implementation (keep as legacy)
  ! Mark as deprecated in comments
end subroutine

subroutine trace_orbit_with_classifiers_v2(anorb, ipart)
  ! New implementation (Phase 2+)
end subroutine

Switch controlled by compile flag:

option(USE_LEGACY_CLASSIFICATION "Use old classification routine" OFF)

Testing:

! New test: test/tests/test_classification_parity.f90
! Compare outputs of both versions for same inputs

Gradual Rollout

  1. Phase 1 (Week 1): Implement ClassificationCutStorage

    • Test with current trace_orbit_with_classifiers
    • Verify identical results
  2. Phase 2 (Week 2-3): Implement trace_orbit_with_classifiers_v2 using CutDetector

    • Run in parallel with legacy version
    • Compare outputs, fix discrepancies
  3. Phase 3 (Week 4-5): Extract orbit_classification module

    • Implement Python wrappers
    • Update tests to use new API
  4. Phase 4 (Week 6): Polish

    • Extract I/O to classification_io
    • Update documentation
    • Deprecate legacy version
  5. Phase 5 (Week 7+): Remove legacy after soak period

    • Delete old trace_orbit_with_classifiers
    • Clean up compile flags

Testing Requirements

Unit Tests (New)

  1. test_classification_cut_storage.f90:

    • Test ClassificationCutStorage type
    • Test overflow handling
    • Test thread-safety
  2. test_classify_from_tips.f90:

    • Test classify_from_tips with known inputs
    • Compare against check_orbit_type direct calls
    • Test edge cases (0 tips, 1 tip, many tips)
  3. test_cut_detector_consistency.f90:

    • Verify CutDetector::trace_to_cut gives same cuts as inline version
    • Use fixed seed, identical initial conditions

Integration Tests

  1. test_classification_parity.f90:
    • Run both legacy and new trace_orbit_with_classifiers
    • Compare iclass(:,:) arrays
    • Compare fort.* file contents
    • Test with multiple VMEC files, surface values

Regression Tests

  1. Golden record comparison:
    • Save outputs from current version (commit hash)
    • Re-run after each phase
    • Verify bit-for-bit identical results

Python Tests

  1. test_python_classification_api.py:
    • Test pysimple.collect_orbit_cuts()
    • Test pysimple.classify_orbits()
    • Compare with Fortran simple.x outputs
    • Re-enable currently skipped tests

Success Criteria

Must Have

  • Phase 1-3 complete
  • All existing tests pass
  • New unit tests added and passing
  • Regression tests show identical results (< 1e-14 difference)
  • Python API can call classification
  • Documentation updated

Nice to Have

  • Phase 4 complete (I/O separation)
  • Performance benchmarks (ensure no slowdown)
  • Python tests re-enabled and passing
  • Example notebooks using new API

Metrics

  • Code reduction: ~200 lines eliminated (duplication)
  • Module count: +2 (orbit_classification, classification_io)
  • Test coverage: +6 test files
  • Python API: +2 functions (collect_orbit_cuts, classify_orbits)

Open Questions

  1. OpenMP threading: Should CutDetector be thread-safe (thread-private) or use critical sections?

    • Proposal: Make thread-private copy per particle in trace_parallel
  2. Timestep tracking: How to maintain exact confpart_pass/trap behavior when using trace_to_cut?

    • Proposal: Add n_steps output to trace_to_cut_with_counter
  3. File I/O from Python: Should Python API support writing fort.* files or only return data?

    • Proposal: Return data by default, add legacy_files=True option
  4. Backward compatibility period: How long to keep legacy version?

    • Proposal: 2 release cycles (~6 months)
  5. Performance impact: Will function call overhead of trace_to_cut slow down classification?

    • Need: Benchmark Phase 2 against current version
    • Mitigation: Inlining hints, profiling

Related Issues

References

  • src/classification.f90: Current implementation
  • src/cut_detector.f90: Reusable cut detection
  • examples/orbits_and_cuts.py: Example usage of trace_to_cut
  • src/check_orbit_type_sub.f90: J_parallel classification logic

Implementation Checklist

Phase 1: Cut Storage

  • Define ClassificationCutStorage type
  • Implement init_storage, add_tip, add_period methods
  • Replace dynamic allocation in trace_orbit_with_classifiers
  • Test thread-safety
  • Verify identical outputs

Phase 2: Use CutDetector

  • Add trace_to_cut_with_counter to cut_detector.f90
  • Implement trace_orbit_with_classifiers_v2
  • Handle timestep tracking for confpart arrays
  • Add OpenMP thread-safety
  • Parity testing with legacy version
  • Performance benchmarking

Phase 3: Extract Classification

  • Create orbit_classification.f90 module
  • Implement classify_from_tips function
  • Implement classify_batch for parallel
  • Update trace_orbit_with_classifiers_v2 to use new module
  • Add Python wrappers (f90wrap)
  • Python API tests
  • Documentation

Phase 4: I/O Separation (Optional)

  • Create classification_io.f90 module
  • Extract all file writes
  • Add Python legacy_files option
  • Update examples

Phase 5: Cleanup

  • Deprecation warnings on legacy version
  • Update all examples to new API
  • Remove compile flag after soak period
  • Delete legacy code
  • Final documentation pass

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions