From 864d8179289f993ff4d3f0452a935aa1ea6c8692 Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Tue, 4 Aug 2026 20:29:10 +0200 Subject: [PATCH] Let fast_class enable classification on its own Three call sites gate orbit classification: the bmin/bmax cache that separates trapped from passing, the dispatch to trace_orbit_with_classifiers, and the class_parts.dat writer. All three tested (ntcut > 0 .or. class_plot) and ignored fast_class, which is read only inside the classifier routine. So fast_class = .True. with tcut <= 0 and class_plot = .False. traced ordinary orbits, wrote no classification output, and left the flag inert, though it documents "quit immediately after fast classification". That combination is the only one that classifies without the Minkowski fractal cut: the cut fires at kt == ntcut, and the early exit in classification.f90 requires .not. class_plot. Without it, fast classification always costs either the fractal path or full-length traces. Extract the shared predicate as params.classification_enabled and include fast_class in it. Existing configurations are unaffected: both golden records set class_plot = .True., so the predicate keeps its value there. --- python/pysimple/_bminmax.py | 11 +++- src/params.f90 | 15 +++++ src/simple_main.f90 | 12 ++-- test/tests/CMakeLists.txt | 12 ++++ test/tests/bminmax_cache_cases.tsv | 17 +++--- test/tests/test_bminmax_lifecycle.f90 | 8 ++- test/tests/test_bminmax_python.py | 4 +- test/tests/test_fast_class_driver.py | 80 +++++++++++++++++++++++++++ 8 files changed, 139 insertions(+), 20 deletions(-) create mode 100644 test/tests/test_fast_class_driver.py diff --git a/python/pysimple/_bminmax.py b/python/pysimple/_bminmax.py index 061b09a56..fb94b8b65 100644 --- a/python/pysimple/_bminmax.py +++ b/python/pysimple/_bminmax.py @@ -3,8 +3,15 @@ from __future__ import annotations -def needs_bminmax_cache(num_surf: int, ntcut: int, class_plot: bool) -> bool: +def classification_enabled(ntcut: int, class_plot: bool, fast_class: bool) -> bool: + """Match the Fortran params.classification_enabled predicate.""" + return int(ntcut) > 0 or bool(class_plot) or bool(fast_class) + + +def needs_bminmax_cache( + num_surf: int, ntcut: int, class_plot: bool, fast_class: bool = False +) -> bool: """Match the Fortran cache-reader predicate.""" - if int(ntcut) > 0 or bool(class_plot): + if classification_enabled(ntcut, class_plot, fast_class): return int(num_surf) > 1 return int(num_surf) != 1 diff --git a/src/params.f90 b/src/params.f90 index 72e32b0da..b405d0fef 100644 --- a/src/params.f90 +++ b/src/params.f90 @@ -642,6 +642,21 @@ subroutine apply_config_aliases end if end subroutine apply_config_aliases + pure logical function classification_enabled() + !> Whether a run collects orbit classifications at all. Three call sites + !> must agree: the bmin/bmax cache the classifier needs to separate + !> trapped from passing, the dispatch to trace_orbit_with_classifiers, + !> and the class_parts.dat writer. + !> + !> fast_class belongs here. Without it, fast_class alone (tcut <= 0 and + !> class_plot = .False.) silently traced ordinary orbits and wrote no + !> classification output, even though the flag documents the opposite. + !> That combination is the only one giving fast classification without + !> the Minkowski fractal cut, since the cut fires at kt == ntcut and the + !> early exit in classification.f90 requires .not. class_plot. + classification_enabled = (ntcut > 0) .or. class_plot .or. fast_class + end function classification_enabled + subroutine reset_seed_if_deterministic ! for run with fixed random seed integer :: seedsize diff --git a/src/simple_main.f90 b/src/simple_main.f90 index fe59d55f2..391eeed76 100644 --- a/src/simple_main.f90 +++ b/src/simple_main.f90 @@ -19,7 +19,8 @@ module simple_main boundary_event_time_width, integmode, relerr, trace_time, class_plot, & fast_class, generate_start_only, ntcut, iclass, bmin, bmax, zstart, & zend, trap_par, perp_inv, sbeg, ntimstep, should_skip, & - reset_seed_if_deterministic, field_input, isw_field_type, reuse_batch, & + reset_seed_if_deterministic, classification_enabled, & + field_input, isw_field_type, reuse_batch, & max_consecutive_warning_holds, & coord_input, wall_input, wall_units, wall_hit, wall_hit_cart, & wall_query_rho_lcfs, & @@ -1124,9 +1125,8 @@ subroutine init_bminmax end subroutine init_bminmax logical function needs_bminmax_cache() - ! Match the current readers. Classifier semantics are kept unchanged - ! here; broad classifier changes belong in the classifier PR. - if ((ntcut > 0) .or. class_plot) then + ! Match the current readers. + if (classification_enabled()) then needs_bminmax_cache = num_surf > 1 else needs_bminmax_cache = num_surf /= 1 @@ -1486,7 +1486,7 @@ subroutine trace_orbit(anorb, ipart, orbit_traj, orbit_times) if (swcoll) call reset_seed_if_deterministic - if (ntcut > 0 .or. class_plot) then + if (classification_enabled()) then call trace_orbit_with_classifiers(anorb, ipart, class_result) if (class_plot) then call write_classification_results(ipart, class_result) @@ -2819,7 +2819,7 @@ subroutine write_results call write_spectre_crossing_events - if (ntcut > 0 .or. class_plot) then + if (classification_enabled()) then open (newunit=unit, file='class_parts.dat', recl=1024) do i = 1, ntestpart write (unit, *) i, zstart(1, i), perp_inv(i), iclass(:, i) diff --git a/test/tests/CMakeLists.txt b/test/tests/CMakeLists.txt index 15df7a11d..81cc031c2 100644 --- a/test/tests/CMakeLists.txt +++ b/test/tests/CMakeLists.txt @@ -485,6 +485,18 @@ if(SIMPLE_ENABLE_PYTHON_TOOLS) set_tests_properties(test_bminmax_driver PROPERTIES LABELS "integration;system" TIMEOUT 120) + + add_test(NAME test_fast_class_driver + COMMAND ${CMAKE_COMMAND} -E env + CTEST_BINARY_DIRECTORY=${CMAKE_CURRENT_BINARY_DIR} + ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/test_fast_class_driver.py + $ + ${WOUT_FILE} + WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}) + set_tests_properties(test_fast_class_driver PROPERTIES + LABELS "integration;system" + TIMEOUT 120) endif() add_executable (test_coordinates.x test_coordinates.f90) diff --git a/test/tests/bminmax_cache_cases.tsv b/test/tests/bminmax_cache_cases.tsv index fbb66d646..552457543 100644 --- a/test/tests/bminmax_cache_cases.tsv +++ b/test/tests/bminmax_cache_cases.tsv @@ -1,7 +1,10 @@ -1 0 F F -0 0 F T -2 0 F T -0 0 T F -2 0 T T -0 1 F F -2 1 F T +1 0 F F F +0 0 F F T +2 0 F F T +0 0 T F F +2 0 T F T +0 1 F F F +2 1 F F T +1 0 F T F +0 0 F T F +2 0 F T T diff --git a/test/tests/test_bminmax_lifecycle.f90 b/test/tests/test_bminmax_lifecycle.f90 index 8fb5958ed..e66011d0c 100644 --- a/test/tests/test_bminmax_lifecycle.f90 +++ b/test/tests/test_bminmax_lifecycle.f90 @@ -40,7 +40,7 @@ program test_bminmax_lifecycle use find_bminmax_sub, only: get_bminmax, init_bminmax_arrays use magfie_sub, only: dp, magfie use new_vmec_stuff_mod, only: nper - use params, only: class_plot, ntcut, num_surf + use params, only: class_plot, fast_class, ntcut, num_surf use simple_main, only: needs_bminmax_cache use test_bminmax_backend, only: test_magfie_backend use test_utils, only: check, check_close @@ -65,7 +65,7 @@ program test_bminmax_lifecycle subroutine test_cache_gate_cases(errors) integer, intent(inout) :: errors character(len=256) :: cases_path - character(len=1) :: class_plot_token, expected_token + character(len=1) :: class_plot_token, fast_class_token, expected_token integer :: unit_id, ios, case_num logical :: expected, actual @@ -77,7 +77,7 @@ subroutine test_cache_gate_cases(errors) do read(unit_id, *, iostat=ios) num_surf, ntcut, class_plot_token, & - expected_token + fast_class_token, expected_token if (ios < 0) exit if (ios > 0) then print *, "ERROR: failed to read bminmax cache case", case_num + 1 @@ -87,6 +87,7 @@ subroutine test_cache_gate_cases(errors) case_num = case_num + 1 class_plot = class_plot_token == "T" + fast_class = fast_class_token == "T" expected = expected_token == "T" actual = needs_bminmax_cache() call check(actual .eqv. expected, "bminmax cache predicate case failed", & @@ -99,6 +100,7 @@ subroutine test_cache_gate_cases(errors) ntcut = 0 class_plot = .false. + fast_class = .false. num_surf = 1 end subroutine test_cache_gate_cases diff --git a/test/tests/test_bminmax_python.py b/test/tests/test_bminmax_python.py index e9c167ef3..25e853a69 100644 --- a/test/tests/test_bminmax_python.py +++ b/test/tests/test_bminmax_python.py @@ -24,9 +24,9 @@ def iter_cases(path: Path): for line in path.read_text(encoding="utf-8").splitlines(): if not line.strip(): continue - num_surf, ntcut, class_plot, expected = line.split() + num_surf, ntcut, class_plot, fast_class, expected = line.split() yield ( - (int(num_surf), int(ntcut), class_plot == "T"), + (int(num_surf), int(ntcut), class_plot == "T", fast_class == "T"), expected == "T", ) diff --git a/test/tests/test_fast_class_driver.py b/test/tests/test_fast_class_driver.py new file mode 100644 index 000000000..eaab1c1b7 --- /dev/null +++ b/test/tests/test_fast_class_driver.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Driver coverage for standalone fast classification. + +fast_class documents itself as "quit immediately after fast classification". +Standalone (tcut <= 0, class_plot = .False.) it is the only configuration that +classifies orbits without the Minkowski fractal cut, which fires at +kt == ntcut, and without disabling the early exit, which requires +.not. class_plot. + +The oracle is the classification output itself: a classifying run owes a +class_parts.dat with one row per test particle, and a non-classifying run owes +none. Both expectations are independent of how the dispatch predicate is +spelled. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from simple_driver import assert_file_absent, assert_row_count, run_simple_case + +NTESTPART = 4 + +COMMON = """ +&config +multharm = 3 +contr_pp = -1d10 +trace_time = 1d-4 +ntestpart = 4 +nper = 1 +npoiper = 4 +npoiper2 = 32 +ntimstep = 4 +num_surf = 1 +sbeg = 0.3 +notrace_passing = 1 +netcdffile = 'wout.nc' +isw_field_type = 2 +integmode = 3 +deterministic = .True. +/ +""" + +# (name, extra namelist lines, classification expected) +CASES = ( + ("fast_class_standalone", "fast_class = .True.\ntcut = -1d0", True), + ("fast_class_with_cut", "fast_class = .True.\ntcut = 5d-5", True), + ("fast_class_with_plot", "fast_class = .True.\nclass_plot = .True.", True), + ("fast_class_absent", "tcut = -1d0", False), +) + + +def config_with(additions: str) -> str: + return COMMON.replace( + "deterministic = .True.", + f"deterministic = .True.\n{additions}", + ) + + +def main() -> int: + if len(sys.argv) != 3: + print("usage: test_fast_class_driver.py SIMPLE_X WOUT_FILE", file=sys.stderr) + return 2 + + simple_x = Path(sys.argv[1]).resolve() + wout = Path(sys.argv[2]).resolve() + + for name, additions, classifies in CASES: + case_dir = run_simple_case(simple_x, wout, name, config_with(additions)) + if classifies: + assert_row_count(case_dir, "class_parts.dat", NTESTPART) + else: + assert_file_absent(case_dir, "class_parts.dat") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())