From 14bec3eeecbe9cc4066fd2a8041d00cf66ae942d Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Sun, 14 Jun 2026 09:05:19 +0200 Subject: [PATCH 1/7] pybind: expose the unpreconditioned internal-basis gradient Add a precondition flag to VmecModel.evaluate (default true, unchanged behaviour). With precondition=false the forward model returns at the INVARIANT_RESIDUALS checkpoint, so get_forces() yields the raw, unpreconditioned force: the gradient of VMEC's augmented functional (MHD energy plus the spectral-condensation and lambda constraints) with respect to the decomposed internal-basis state. This is the consistent state/gradient pair an external optimizer needs to minimise in VMEC's own basis. The native solver's preconditioned search direction (precondition=true) is a different vector; the raw gradient is the equilibrium residual and vanishes at convergence. Tests: raw force is finite and differs in direction from the preconditioned force, and drops by >1e6 from the initial guess to the converged equilibrium. --- .../cpp/vmecpp/vmec/pybind11/pybind_vmec.cc | 20 ++++- tests/test_internal_gradient.py | 74 +++++++++++++++++++ 2 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 tests/test_internal_gradient.py diff --git a/src/vmecpp/cpp/vmecpp/vmec/pybind11/pybind_vmec.cc b/src/vmecpp/cpp/vmecpp/vmec/pybind11/pybind_vmec.cc index ccdf3f7af..85124ac75 100644 --- a/src/vmecpp/cpp/vmecpp/vmec/pybind11/pybind_vmec.cc +++ b/src/vmecpp/cpp/vmecpp/vmec/pybind11/pybind_vmec.cc @@ -217,9 +217,22 @@ class VmecModel { // decision vector. This is the body of Vmec::UpdateForwardModel with // caller-supplied counters; for free-boundary runs the caller should react to // `need_restart` (a vacuum-activation restart). - void Evaluate(int iter1, int iter2) { + // When precondition is true (default) this runs the full forward model and + // leaves decomposed_f holding the preconditioned search direction, exactly as + // the native solver uses it. When false, the forward model returns at the + // INVARIANT_RESIDUALS checkpoint (vmec.cc line ~836), so decomposed_f holds + // the raw, unpreconditioned force: the gradient of VMEC's augmented + // Lagrangian with respect to the (decomposed) state, including the + // lambda-constraint components. That raw gradient is what gradient-based + // optimizers minimizing the MHD energy functional need; mhd_energy is already + // set earlier in update(), so it is valid at the checkpoint too. + void Evaluate(int iter1, int iter2, bool precondition = true) { bool need_restart = false; std::string error_message; + const vmecpp::VmecCheckpoint checkpoint = + precondition ? vmecpp::VmecCheckpoint::NONE + : vmecpp::VmecCheckpoint::INVARIANT_RESIDUALS; + const int checkpoint_after = precondition ? INT_MAX : 0; // Clear the restart reason before evaluating the forward model, exactly as // Vmec::Evolve does at its start (vmec.cc): the forward model only *sets* a // reason (BAD_JACOBIAN when the Jacobian flips, HUGE_INITIAL_FORCES at @@ -240,7 +253,7 @@ class VmecModel { *vmec_->decomposed_x_[0], *vmec_->physical_x_[0], *vmec_->decomposed_f_[0], *vmec_->physical_f_[0], need_restart, last_preconditioner_update_, last_full_update_nestor_, vmec_->fc_, - iter1, iter2, vmecpp::VmecCheckpoint::NONE, INT_MAX, + iter1, iter2, checkpoint, checkpoint_after, /*verbose=*/false); if (!s.ok()) { error_message = std::string(s.status().message()); @@ -1177,7 +1190,8 @@ PYBIND11_MODULE(_vmecpp, m) { py::class_(m, "VmecModel") .def_static("create", &VmecModel::Create, py::arg("indata"), py::arg("ns"), py::arg("initial_state") = std::nullopt) - .def("evaluate", &VmecModel::Evaluate, py::arg("iter1"), py::arg("iter2")) + .def("evaluate", &VmecModel::Evaluate, py::arg("iter1"), py::arg("iter2"), + py::arg("precondition") = true) .def_property_readonly("need_restart", &VmecModel::need_restart) .def("perform_time_step", &VmecModel::PerformTimeStep, py::arg("velocity_scale"), py::arg("conjugation_parameter"), diff --git a/tests/test_internal_gradient.py b/tests/test_internal_gradient.py new file mode 100644 index 000000000..bc792d40a --- /dev/null +++ b/tests/test_internal_gradient.py @@ -0,0 +1,74 @@ +# SPDX-FileCopyrightText: 2024-present Proxima Fusion GmbH +# +# +# SPDX-License-Identifier: MIT +"""Tests for the unpreconditioned internal-basis gradient. + +VmecModel.evaluate(precondition=False) returns at the INVARIANT_RESIDUALS +checkpoint, so get_forces() yields the raw, unpreconditioned force: the gradient +of VMEC's augmented functional (MHD energy plus the spectral-condensation and +lambda constraints) with respect to the decomposed (internal-basis) state. This +is the gradient an external optimizer working in the internal basis needs. + +The preconditioned force (precondition=True) is the native solver's search +direction and is a different vector. The raw gradient must vanish at the +converged equilibrium. +""" + +from pathlib import Path + +import numpy as np +import pytest + +try: + from vmecpp.cpp import _vmecpp +except ImportError: # allow running against a directly-built extension + import _vmecpp + +SOLOVEV = Path(__file__).resolve().parents[1] / "examples" / "data" / "solovev.json" + + +def _model(ns: int = 11): + indata = _vmecpp.VmecINDATA.from_file(str(SOLOVEV)) + return _vmecpp.VmecModel.create(indata, ns) + + +def test_raw_force_differs_from_preconditioned(): + m = _model() + m.evaluate(2, 2, True) + f_prec = np.asarray(m.get_forces(), float) + m.evaluate(2, 2, False) + f_raw = np.asarray(m.get_forces(), float) + + assert np.all(np.isfinite(f_raw)) + assert np.linalg.norm(f_raw) > 0.0 + # The preconditioner is a non-trivial metric: the two vectors are different + # in direction, not just scale. + cos = np.dot(f_prec, f_raw) / (np.linalg.norm(f_prec) * np.linalg.norm(f_raw)) + assert abs(cos) < 0.99 + + +def test_raw_force_vanishes_at_equilibrium(): + m = _model() + m.evaluate(2, 2, False) + f_initial = np.linalg.norm(np.asarray(m.get_forces(), float)) + + m.solve() + m.evaluate(2, 2, False) + f_converged = np.linalg.norm(np.asarray(m.get_forces(), float)) + + # The augmented-functional gradient is the equilibrium residual: it drops by + # many orders of magnitude once the native solver has converged. + assert f_converged < 1e-6 * f_initial + + +def test_cold_start_is_excluded(): + # evaluate(1, 2) is the cold-start special case (forces initialised to 1.0); + # the raw-gradient path uses iter1 >= 2, where the force is well defined. + m = _model() + m.evaluate(2, 2, False) + assert np.all(np.isfinite(np.asarray(m.get_forces(), float))) + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-v"])) From 7ddb2a14784ea7e6a18e1dbed1cfe01a981d2407 Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Sun, 14 Jun 2026 19:10:56 +0200 Subject: [PATCH 2/7] test: docformatter-format test_internal_gradient docstrings Satisfies the docformatter pre-commit hook (was failing CI). --- tests/test_internal_gradient.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/tests/test_internal_gradient.py b/tests/test_internal_gradient.py index bc792d40a..022697143 100644 --- a/tests/test_internal_gradient.py +++ b/tests/test_internal_gradient.py @@ -4,15 +4,14 @@ # SPDX-License-Identifier: MIT """Tests for the unpreconditioned internal-basis gradient. -VmecModel.evaluate(precondition=False) returns at the INVARIANT_RESIDUALS -checkpoint, so get_forces() yields the raw, unpreconditioned force: the gradient -of VMEC's augmented functional (MHD energy plus the spectral-condensation and -lambda constraints) with respect to the decomposed (internal-basis) state. This -is the gradient an external optimizer working in the internal basis needs. - -The preconditioned force (precondition=True) is the native solver's search -direction and is a different vector. The raw gradient must vanish at the -converged equilibrium. +VmecModel.evaluate(precondition=False) returns at the INVARIANT_RESIDUALS checkpoint, so +get_forces() yields the raw, unpreconditioned force: the gradient of VMEC's augmented +functional (MHD energy plus the spectral-condensation and lambda constraints) with +respect to the decomposed (internal-basis) state. This is the gradient an external +optimizer working in the internal basis needs. + +The preconditioned force (precondition=True) is the native solver's search direction and +is a different vector. The raw gradient must vanish at the converged equilibrium. """ from pathlib import Path From 620d1843c20b2f5f016578c78aa30840adb8d435 Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Sun, 14 Jun 2026 17:31:45 +0200 Subject: [PATCH 3/7] ci: skip benchmark result upload on fork PRs (token is read-only) The 'Compare benchmark result' step uses github-action-benchmark with comment-on-alert and the GITHUB_TOKEN, which is read-only for pull requests from forks -> 'Resource not accessible by integration'. Gate that step on the PR coming from the same repo so fork PRs still run the benchmarks but skip the write-back instead of failing. --- .github/workflows/benchmarks.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/benchmarks.yaml b/.github/workflows/benchmarks.yaml index c2f3fdf4e..54326ec6a 100644 --- a/.github/workflows/benchmarks.yaml +++ b/.github/workflows/benchmarks.yaml @@ -36,7 +36,11 @@ jobs: pytest benchmarks/test_benchmarks.py --benchmark-json=benchmark_results.json - name: Compare benchmark result - if: github.event_name == 'pull_request' + # Skip the result upload/compare for fork PRs: their GITHUB_TOKEN is + # read-only, so comment-on-alert/auto-push hit 'Resource not accessible + # by integration'. The benchmarks still run above; only the write-back + # is skipped for forks. + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository uses: benchmark-action/github-action-benchmark@v1.21.0 with: tool: "pytest" From 7b4f9fb989fe0974ea28ae78722431002dcf8581 Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Sun, 14 Jun 2026 18:12:42 +0200 Subject: [PATCH 4/7] ci: build VMEC2000 from source so the compat test runs on numpy 2 The pinned vmec-0.0.6 cp310 wheel was f90wrapped against numpy 1.x. Under the numpy 2.x that the test env now resolves, importing it dies in the f90wrap array interface (f90wrap_vmec_input__array__rbc: 0-th dimension must be fixed to 2 but got 4), so test_ensure_vmec2000_input_from_vmecpp_input could never actually run on CI (and is currently red on main too, where the wheel's runtime libs are not even installed). Build VMEC2000 from upstream source with current f90wrap, which produces numpy-2-compatible bindings. The recipe mirrors SIMSOPT's own CI (hiddenSymmetries/VMEC2000, cmake/machines/ubuntu.json). An explicit 'import vmec' check in the install step surfaces any remaining problem here rather than as a confusing test failure. --- .github/workflows/tests.yaml | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index a22e0cbdc..4a7964059 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -53,12 +53,27 @@ jobs: - name: Also install VMEC2000 (only on Ubuntu 22.04) if: ${{ matrix.os == 'ubuntu-22.04' && matrix.python-version == '3.10' }} run: | - # mpi4py is needed for VMEC2000 - sudo apt-get install -y libopenmpi-dev - python -m pip install mpi4py - # custom wheel for VMEC2000, needed for some VMEC++/VMEC2000 compatibility tests - # NOTE: this wheel is only guaranteed to work on Ubuntu 22.04 - python -m pip install vmec@https://anaconda.org/eguiraud-pf/vmec/0.0.6/download/vmec-0.0.6-cp310-cp310-linux_x86_64.whl + # Build VMEC2000 from source instead of the old prebuilt wheel. The + # pinned vmec-0.0.6 cp310 wheel was f90wrapped against numpy 1.x and + # fails to import under numpy 2 (f90wrap array-interface break), so the + # compatibility test could never run. Building from source with current + # f90wrap produces numpy-2-compatible bindings. Recipe mirrors SIMSOPT's + # own CI (hiddenSymmetries/VMEC2000 cmake/machines/ubuntu.json). + sudo apt-get install -y libopenmpi-dev libnetcdff-dev libscalapack-mpi-dev \ + libhdf5-dev libhdf5-serial-dev libfftw3-dev libopenblas-dev ninja-build + # Build VMEC2000 with the SYSTEM cmake + ninja. Do not pip-install the + # cmake/ninja wheels: they would shadow the system cmake that + # scikit-build-core's editable vmecpp rebuild records, breaking its + # verify_globs step in the editable matrix job. + python -m pip install mpi4py numpy scikit-build f90wrap setuptools wheel + git clone --depth 1 https://github.com/hiddenSymmetries/VMEC2000.git /tmp/VMEC2000 + cd /tmp/VMEC2000 + cp cmake/machines/ubuntu.json cmake_config_file.json + LD_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu \ + python -m pip install -v --no-build-isolation . + cd - + # fail loudly here if the binding is still broken, not in the test step + python -c "import vmec; print('VMEC2000 import OK')" - name: Install package run: | # on Ubuntu we would not need this, but on MacOS we need to point CMake to gfortran-14 and gcc-14 From 9c57860cc068b5feb68a6919dec6b589a5448f56 Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Sun, 14 Jun 2026 18:20:09 +0200 Subject: [PATCH 5/7] test: skip vmecpp-only indata fields in the VMEC2000 compat subset With VMEC2000 built from current upstream source, the compatibility test runs for the first time and hits vmecpp indata fields that have no counterpart in the legacy VMEC2000 INDATA namelist (e.g. free_boundary_method), which raised AttributeError. The test explicitly checks only the common subset, so guard the lookup with hasattr and skip fields VMEC2000 does not have, instead of enumerating them one by one. --- tests/test_simsopt_compat.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_simsopt_compat.py b/tests/test_simsopt_compat.py index 609dffea2..1443d8cbc 100644 --- a/tests/test_simsopt_compat.py +++ b/tests/test_simsopt_compat.py @@ -303,6 +303,11 @@ def test_ensure_vmec2000_input_from_vmecpp_input(): if varname[1:-1] == "axis_": # these are called differently in VMEC2000, e.g. raxis_c -> raxis_cc varname_vmec2000 = f"{varname[:-1]}c{varname[-1]}" + if not hasattr(vmec2000.indata, varname_vmec2000): + # vmecpp-only field (e.g. free_boundary_method) with no counterpart + # in the legacy VMEC2000 INDATA namelist; not part of the common + # subset under test. + continue vmec2000_var = getattr(vmec2000.indata, varname_vmec2000) if vmecpp_var is None: From 79d3d346b668cfc94ece978f3329d508a6cd0a61 Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Mon, 15 Jun 2026 07:24:20 +0200 Subject: [PATCH 6/7] ci: sync VMEC2000-from-source build, benchmark fork guard, abseil commit pin Bring this stack branch up to the corrected CI baseline (from #583/#564): - tests.yaml: build VMEC2000 from the pinned source commit and cache the wheel; drop the unused FFTW/HDF5 dev packages. - benchmarks.yaml: skip the result upload on fork PRs (read-only token). - test_simsopt_compat.py: skip vmecpp-only INDATA fields. - CMakeLists: pin abseil to the 20260107.1 commit hash for Clang >= 21. --- .github/workflows/tests.yaml | 39 +++++++++++++++++++----------------- CMakeLists.txt | 4 +++- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 4a7964059..0f68ab6e9 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -50,28 +50,31 @@ jobs: run: | # install VMEC++ deps as well as VMEC2000 deps (we need to import VMEC2000 in a test) sudo apt-get update && sudo apt-get install -y build-essential cmake libnetcdf-dev liblapack-dev libomp-dev + - name: Cache the VMEC2000 wheel + if: ${{ matrix.os == 'ubuntu-22.04' && matrix.python-version == '3.10' }} + uses: actions/cache@v4 + with: + path: /tmp/vmec2000-wheel + # Keyed on the pinned VMEC2000 source commit: rebuild only when it changes. + key: vmec2000-728af8bd6c79-cp310-ubuntu22.04 - name: Also install VMEC2000 (only on Ubuntu 22.04) if: ${{ matrix.os == 'ubuntu-22.04' && matrix.python-version == '3.10' }} run: | - # Build VMEC2000 from source instead of the old prebuilt wheel. The - # pinned vmec-0.0.6 cp310 wheel was f90wrapped against numpy 1.x and - # fails to import under numpy 2 (f90wrap array-interface break), so the - # compatibility test could never run. Building from source with current - # f90wrap produces numpy-2-compatible bindings. Recipe mirrors SIMSOPT's - # own CI (hiddenSymmetries/VMEC2000 cmake/machines/ubuntu.json). sudo apt-get install -y libopenmpi-dev libnetcdff-dev libscalapack-mpi-dev \ - libhdf5-dev libhdf5-serial-dev libfftw3-dev libopenblas-dev ninja-build - # Build VMEC2000 with the SYSTEM cmake + ninja. Do not pip-install the - # cmake/ninja wheels: they would shadow the system cmake that - # scikit-build-core's editable vmecpp rebuild records, breaking its - # verify_globs step in the editable matrix job. - python -m pip install mpi4py numpy scikit-build f90wrap setuptools wheel - git clone --depth 1 https://github.com/hiddenSymmetries/VMEC2000.git /tmp/VMEC2000 - cd /tmp/VMEC2000 - cp cmake/machines/ubuntu.json cmake_config_file.json - LD_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu \ - python -m pip install -v --no-build-isolation . - cd - + libopenblas-dev ninja-build + python -m pip install mpi4py + if ! ls /tmp/vmec2000-wheel/vmec-*.whl >/dev/null 2>&1; then + # Build with the SYSTEM cmake + ninja; do NOT pip-install the + # cmake/ninja wheels (they shadow the system cmake that + # scikit-build-core's editable vmecpp rebuild records). + python -m pip install numpy scikit-build f90wrap setuptools wheel + git clone https://github.com/hiddenSymmetries/VMEC2000.git /tmp/VMEC2000 + git -C /tmp/VMEC2000 checkout 728af8bd6c796b36a0aa85fe298e507791e57c6e + cp /tmp/VMEC2000/cmake/machines/ubuntu.json /tmp/VMEC2000/cmake_config_file.json + LD_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu \ + python -m pip wheel /tmp/VMEC2000 --no-build-isolation -w /tmp/vmec2000-wheel + fi + python -m pip install /tmp/vmec2000-wheel/vmec-*.whl # fail loudly here if the binding is still broken, not in the test step python -c "import vmec; print('VMEC2000 import OK')" - name: Install package diff --git a/CMakeLists.txt b/CMakeLists.txt index be3c3255b..8e07b1753 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -66,7 +66,9 @@ find_package(LAPACK REQUIRED) FetchContent_Declare( abseil-cpp GIT_REPOSITORY https://github.com/abseil/abseil-cpp.git - GIT_TAG 4447c7562e3bc702ade25105912dce503f0c4010 + # 20260107.1 LTS: older abseil fails to compile under Clang >= 21 (the + # Enzyme build) on absl::Nonnull SFINAE in absl/strings/ascii.cc. + GIT_TAG 255c84dadd029fd8ad25c5efb5933e47beaa00c7 GIT_SHALLOW TRUE ) FetchContent_Declare( From a248cd9869c53281ca99b2485c75de3dd5851153 Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Mon, 15 Jun 2026 08:57:27 +0200 Subject: [PATCH 7/7] build+ci: abseil commit pin for Clang>=21, VMEC2000-from-source, benchmark fork guard (#564) * build: bump CMake abseil pin to 20260107.1 for Clang >= 21 The CMake FetchContent abseil pin (2024-08) fails to compile under Clang >= 21: absl::Nonnull SFINAE in absl/strings/ascii.cc and the numbers.cc nullability annotations are rejected by the newer frontend. Bump to the 20260107.1 LTS, which compiles cleanly under Clang 21.1.8 and GCC. Clang is the compiler required for the Enzyme autodiff build. The Bazel build keeps its own (BCR) abseil pin and is unaffected. * ci: skip benchmark result upload on fork PRs (token is read-only) The 'Compare benchmark result' step uses github-action-benchmark with comment-on-alert and the GITHUB_TOKEN, which is read-only for pull requests from forks -> 'Resource not accessible by integration'. Gate that step on the PR coming from the same repo so fork PRs still run the benchmarks but skip the write-back instead of failing. * ci: build VMEC2000 from source so the compat test runs on numpy 2 The pinned vmec-0.0.6 cp310 wheel was f90wrapped against numpy 1.x. Under the numpy 2.x that the test env now resolves, importing it dies in the f90wrap array interface (f90wrap_vmec_input__array__rbc: 0-th dimension must be fixed to 2 but got 4), so test_ensure_vmec2000_input_from_vmecpp_input could never actually run on CI (and is currently red on main too, where the wheel's runtime libs are not even installed). Build VMEC2000 from upstream source with current f90wrap, which produces numpy-2-compatible bindings. The recipe mirrors SIMSOPT's own CI (hiddenSymmetries/VMEC2000, cmake/machines/ubuntu.json). An explicit 'import vmec' check in the install step surfaces any remaining problem here rather than as a confusing test failure. * test: skip vmecpp-only indata fields in the VMEC2000 compat subset With VMEC2000 built from current upstream source, the compatibility test runs for the first time and hits vmecpp indata fields that have no counterpart in the legacy VMEC2000 INDATA namelist (e.g. free_boundary_method), which raised AttributeError. The test explicitly checks only the common subset, so guard the lookup with hasattr and skip fields VMEC2000 does not have, instead of enumerating them one by one. * build: pin abseil to the 20260107.1 commit hash Pin the FetchContent abseil dependency to commit 255c84d (the exact commit behind the 20260107.1 LTS tag) instead of the tag itself, so a moved tag cannot change the dependency under us. * ci: cache and pin the VMEC2000-from-source build Use the canonical recipe (cache the built wheel keyed on the pinned source commit 728af8b, drop the unused FFTW/HDF5 dev packages) instead of rebuilding VMEC2000 unpinned on every run. --- .github/workflows/benchmarks.yaml | 6 +++++- .github/workflows/tests.yaml | 28 +++++++++++++++++++++++----- CMakeLists.txt | 4 +++- tests/test_simsopt_compat.py | 5 +++++ 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/.github/workflows/benchmarks.yaml b/.github/workflows/benchmarks.yaml index c2f3fdf4e..54326ec6a 100644 --- a/.github/workflows/benchmarks.yaml +++ b/.github/workflows/benchmarks.yaml @@ -36,7 +36,11 @@ jobs: pytest benchmarks/test_benchmarks.py --benchmark-json=benchmark_results.json - name: Compare benchmark result - if: github.event_name == 'pull_request' + # Skip the result upload/compare for fork PRs: their GITHUB_TOKEN is + # read-only, so comment-on-alert/auto-push hit 'Resource not accessible + # by integration'. The benchmarks still run above; only the write-back + # is skipped for forks. + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository uses: benchmark-action/github-action-benchmark@v1.21.0 with: tool: "pytest" diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index a22e0cbdc..0f68ab6e9 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -50,15 +50,33 @@ jobs: run: | # install VMEC++ deps as well as VMEC2000 deps (we need to import VMEC2000 in a test) sudo apt-get update && sudo apt-get install -y build-essential cmake libnetcdf-dev liblapack-dev libomp-dev + - name: Cache the VMEC2000 wheel + if: ${{ matrix.os == 'ubuntu-22.04' && matrix.python-version == '3.10' }} + uses: actions/cache@v4 + with: + path: /tmp/vmec2000-wheel + # Keyed on the pinned VMEC2000 source commit: rebuild only when it changes. + key: vmec2000-728af8bd6c79-cp310-ubuntu22.04 - name: Also install VMEC2000 (only on Ubuntu 22.04) if: ${{ matrix.os == 'ubuntu-22.04' && matrix.python-version == '3.10' }} run: | - # mpi4py is needed for VMEC2000 - sudo apt-get install -y libopenmpi-dev + sudo apt-get install -y libopenmpi-dev libnetcdff-dev libscalapack-mpi-dev \ + libopenblas-dev ninja-build python -m pip install mpi4py - # custom wheel for VMEC2000, needed for some VMEC++/VMEC2000 compatibility tests - # NOTE: this wheel is only guaranteed to work on Ubuntu 22.04 - python -m pip install vmec@https://anaconda.org/eguiraud-pf/vmec/0.0.6/download/vmec-0.0.6-cp310-cp310-linux_x86_64.whl + if ! ls /tmp/vmec2000-wheel/vmec-*.whl >/dev/null 2>&1; then + # Build with the SYSTEM cmake + ninja; do NOT pip-install the + # cmake/ninja wheels (they shadow the system cmake that + # scikit-build-core's editable vmecpp rebuild records). + python -m pip install numpy scikit-build f90wrap setuptools wheel + git clone https://github.com/hiddenSymmetries/VMEC2000.git /tmp/VMEC2000 + git -C /tmp/VMEC2000 checkout 728af8bd6c796b36a0aa85fe298e507791e57c6e + cp /tmp/VMEC2000/cmake/machines/ubuntu.json /tmp/VMEC2000/cmake_config_file.json + LD_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu \ + python -m pip wheel /tmp/VMEC2000 --no-build-isolation -w /tmp/vmec2000-wheel + fi + python -m pip install /tmp/vmec2000-wheel/vmec-*.whl + # fail loudly here if the binding is still broken, not in the test step + python -c "import vmec; print('VMEC2000 import OK')" - name: Install package run: | # on Ubuntu we would not need this, but on MacOS we need to point CMake to gfortran-14 and gcc-14 diff --git a/CMakeLists.txt b/CMakeLists.txt index be3c3255b..8e07b1753 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -66,7 +66,9 @@ find_package(LAPACK REQUIRED) FetchContent_Declare( abseil-cpp GIT_REPOSITORY https://github.com/abseil/abseil-cpp.git - GIT_TAG 4447c7562e3bc702ade25105912dce503f0c4010 + # 20260107.1 LTS: older abseil fails to compile under Clang >= 21 (the + # Enzyme build) on absl::Nonnull SFINAE in absl/strings/ascii.cc. + GIT_TAG 255c84dadd029fd8ad25c5efb5933e47beaa00c7 GIT_SHALLOW TRUE ) FetchContent_Declare( diff --git a/tests/test_simsopt_compat.py b/tests/test_simsopt_compat.py index 609dffea2..1443d8cbc 100644 --- a/tests/test_simsopt_compat.py +++ b/tests/test_simsopt_compat.py @@ -303,6 +303,11 @@ def test_ensure_vmec2000_input_from_vmecpp_input(): if varname[1:-1] == "axis_": # these are called differently in VMEC2000, e.g. raxis_c -> raxis_cc varname_vmec2000 = f"{varname[:-1]}c{varname[-1]}" + if not hasattr(vmec2000.indata, varname_vmec2000): + # vmecpp-only field (e.g. free_boundary_method) with no counterpart + # in the legacy VMEC2000 INDATA namelist; not part of the common + # subset under test. + continue vmec2000_var = getattr(vmec2000.indata, varname_vmec2000) if vmecpp_var is None: