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 30c08f588..22504f21f 100644 --- a/src/params.f90 +++ b/src/params.f90 @@ -707,6 +707,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 bcb558ceb..445762556 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, & @@ -1473,9 +1474,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 @@ -1835,7 +1835,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) @@ -3168,7 +3168,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 b969e564f..16ba439b4 100644 --- a/test/tests/CMakeLists.txt +++ b/test/tests/CMakeLists.txt @@ -503,6 +503,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())