alfvex 1.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. alfvex-1.0.1/CITATION.cff +24 -0
  2. alfvex-1.0.1/LICENSE +22 -0
  3. alfvex-1.0.1/MANIFEST.in +3 -0
  4. alfvex-1.0.1/PKG-INFO +156 -0
  5. alfvex-1.0.1/README.md +108 -0
  6. alfvex-1.0.1/examples/benchmark.py +247 -0
  7. alfvex-1.0.1/examples/boundary_gradient.py +27 -0
  8. alfvex-1.0.1/examples/ctok_continuum.toml +16 -0
  9. alfvex-1.0.1/examples/ctok_eigenmodes.toml +20 -0
  10. alfvex-1.0.1/examples/data/input.ITPA_Konies2018 +9 -0
  11. alfvex-1.0.1/examples/data/input.LandremanPaul2021_QA_lowres +109 -0
  12. alfvex-1.0.1/examples/data/input.LandremanPaul2021_QH_reactorScale_lowres +157 -0
  13. alfvex-1.0.1/examples/knyazev_qa.py +378 -0
  14. alfvex-1.0.1/examples/koenies_tae_drive.py +40 -0
  15. alfvex-1.0.1/examples/optimize_alfven.py +267 -0
  16. alfvex-1.0.1/examples/qa_continuum.toml +16 -0
  17. alfvex-1.0.1/examples/quickstart.py +35 -0
  18. alfvex-1.0.1/pyproject.toml +70 -0
  19. alfvex-1.0.1/setup.cfg +4 -0
  20. alfvex-1.0.1/src/alfvex.egg-info/PKG-INFO +156 -0
  21. alfvex-1.0.1/src/alfvex.egg-info/SOURCES.txt +50 -0
  22. alfvex-1.0.1/src/alfvex.egg-info/dependency_links.txt +1 -0
  23. alfvex-1.0.1/src/alfvex.egg-info/entry_points.txt +2 -0
  24. alfvex-1.0.1/src/alfvex.egg-info/requires.txt +31 -0
  25. alfvex-1.0.1/src/alfvex.egg-info/top_level.txt +1 -0
  26. alfvex-1.0.1/src/fax/__init__.py +70 -0
  27. alfvex-1.0.1/src/fax/continuum.py +109 -0
  28. alfvex-1.0.1/src/fax/drive.py +204 -0
  29. alfvex-1.0.1/src/fax/eigen.py +342 -0
  30. alfvex-1.0.1/src/fax/geometry.py +321 -0
  31. alfvex-1.0.1/src/fax/io.py +131 -0
  32. alfvex-1.0.1/src/fax/objectives.py +333 -0
  33. alfvex-1.0.1/src/fax/plot.py +42 -0
  34. alfvex-1.0.1/tests/conftest.py +14 -0
  35. alfvex-1.0.1/tests/data/PROVENANCE.md +72 -0
  36. alfvex-1.0.1/tests/data/ae3d_ctok_modes.npz +0 -0
  37. alfvex-1.0.1/tests/data/ae3d_v31_ctok.npz +0 -0
  38. alfvex-1.0.1/tests/data/ae3d_v31_instrumentation.patch +111 -0
  39. alfvex-1.0.1/tests/data/boozmn_ctok.nc +0 -0
  40. alfvex-1.0.1/tests/data/knyazev_qa.npz +0 -0
  41. alfvex-1.0.1/tests/data/make_ae3d_v31_ctok.py +65 -0
  42. alfvex-1.0.1/tests/data/make_ctok_fixtures.py +31 -0
  43. alfvex-1.0.1/tests/data/stellgap_ctok_continuum.npz +0 -0
  44. alfvex-1.0.1/tests/install_smoke.py +25 -0
  45. alfvex-1.0.1/tests/test_api.py +122 -0
  46. alfvex-1.0.1/tests/test_continuum.py +188 -0
  47. alfvex-1.0.1/tests/test_drive.py +347 -0
  48. alfvex-1.0.1/tests/test_eigen.py +482 -0
  49. alfvex-1.0.1/tests/test_knyazev.py +58 -0
  50. alfvex-1.0.1/tests/test_objectives.py +136 -0
  51. alfvex-1.0.1/tests/test_provenance.py +16 -0
  52. alfvex-1.0.1/tests/test_vmex.py +255 -0
@@ -0,0 +1,24 @@
1
+ cff-version: 1.2.0
2
+ message: "If you use FAX in your research, please cite it as below."
3
+ title: "FAX: differentiable shear-Alfvén spectra and fast-ion drive for stellarators in JAX"
4
+ type: software
5
+ authors:
6
+ - family-names: Jorge
7
+ given-names: Rogerio
8
+ email: rogerio.jorge@wisc.edu
9
+ affiliation: University of Wisconsin-Madison
10
+ abstract: >-
11
+ Differentiable JAX code for the shear-Alfvén continuum, AE3D-form global eigenmodes and the perturbative passing
12
+ fast-ion drive in Boozer geometry, usable inside VMEX boundary optimization.
13
+ repository-code: "https://github.com/uwplasma/fax"
14
+ url: "https://fax.readthedocs.io"
15
+ license: MIT
16
+ version: 1.0.1
17
+ keywords:
18
+ - Alfvén eigenmodes
19
+ - fast ions
20
+ - JAX
21
+ - stellarator
22
+ - automatic differentiation
23
+ - plasma physics
24
+ date-released: 2026-10-30
alfvex-1.0.1/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 UWPlasma contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
@@ -0,0 +1,3 @@
1
+ include CITATION.cff
2
+ graft tests
3
+ graft examples
alfvex-1.0.1/PKG-INFO ADDED
@@ -0,0 +1,156 @@
1
+ Metadata-Version: 2.4
2
+ Name: alfvex
3
+ Version: 1.0.1
4
+ Summary: Differentiable JAX code for Alfvén eigenmodes and fast-ion physics in stellarators.
5
+ Author-email: Rogerio Jorge <rogerio.jorge@wisc.edu>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/uwplasma/fax
8
+ Project-URL: Repository, https://github.com/uwplasma/fax
9
+ Project-URL: Documentation, https://fax.readthedocs.io
10
+ Project-URL: Issues, https://github.com/uwplasma/fax/issues
11
+ Keywords: alfven eigenmodes,fast ions,jax,stellarator,vmec
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Scientific/Engineering :: Physics
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: jax>=0.4.38
26
+ Requires-Dist: solvax>=0.32.1
27
+ Requires-Dist: numpy
28
+ Requires-Dist: netCDF4
29
+ Requires-Dist: tomli; python_version < "3.11"
30
+ Provides-Extra: plot
31
+ Requires-Dist: matplotlib; extra == "plot"
32
+ Provides-Extra: vmex
33
+ Requires-Dist: vmex>=0.11.10; python_version >= "3.11" and extra == "vmex"
34
+ Provides-Extra: test
35
+ Requires-Dist: pytest; extra == "test"
36
+ Requires-Dist: coverage; extra == "test"
37
+ Requires-Dist: matplotlib; extra == "test"
38
+ Provides-Extra: docs
39
+ Requires-Dist: sphinx; extra == "docs"
40
+ Requires-Dist: furo; extra == "docs"
41
+ Requires-Dist: myst-parser; extra == "docs"
42
+ Provides-Extra: dev
43
+ Requires-Dist: alfvex[docs,test]; extra == "dev"
44
+ Requires-Dist: ruff; extra == "dev"
45
+ Requires-Dist: build; extra == "dev"
46
+ Requires-Dist: twine; extra == "dev"
47
+ Dynamic: license-file
48
+
49
+ # FAX
50
+
51
+ [![Tests](https://github.com/uwplasma/fax/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/uwplasma/fax/actions/workflows/tests.yml)
52
+ [![Docs](https://readthedocs.org/projects/fax/badge/?version=latest)](https://fax.readthedocs.io)
53
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
54
+
55
+ FAX is a lean JAX code for shear-Alfvén physics in stellarators: the continuum, AE3D-form global modes and a perturbative
56
+ fast-ion drive, with first-order derivatives with respect to profiles and, through VMEX, the VMEC boundary.
57
+
58
+ <p align="center">
59
+ <img src="docs/_static/ctok_eigenmodes_continuum.png" width="44%" alt="CTOK continuum and global modes">
60
+ <img src="docs/_static/koenies_tae_drive.png" width="48%" alt="Könies TAE drive">
61
+ </p>
62
+
63
+ *Left: the n = 2 CTOK continuum with the six global modes nearest 384 kHz (red). Right: the passing-only drive of the Könies et al. (2018) TAE.*
64
+
65
+ ## Scope
66
+
67
+ | Supported | Not supported |
68
+ |---|---|
69
+ | Low-β continuum on each Boozer surface (STELLGAP form) | Sound coupling, so no BAE or BAAE |
70
+ | AE3D-form reduced ideal-MHD global modes, signed $\omega^2$ | Continuum damping and electron damping |
71
+ | `finite_beta=True`: restricted $J_\parallel$ and pressure terms (experimental) | A full-MHD or compressional model |
72
+ | Drive of a fixed mode: zero orbit width, passing ions only | Trapped particles, finite orbit width, finite Larmor radius |
73
+
74
+ The drive is perturbative and valid up to about 200 keV in the Könies case ($\gamma/\omega \le 4\%$). It is about 0.6 of the CAS3D-K
75
+ zero-orbit-width curve, plausibly the missing trapped share; that attribution is not established. Derivatives are first order and
76
+ are NaN unless the eigensolve converged and its selection is not a tie.
77
+
78
+ ## Install
79
+
80
+ Python 3.10 or newer, JAX 0.4.38 or newer, `solvax>=0.32.1`. FAX is on PyPI as **`alfvex`**; the code is imported as `fax`.
81
+ FAX will soon be renamed ALFVEX everywhere (repository, import and command line). Do not `pip install fax`: that is an unrelated package.
82
+
83
+ ```bash
84
+ pip install alfvex # core
85
+ pip install "alfvex[vmex]" # VMEC solves and boundary gradients (Python >= 3.11)
86
+ pip install "alfvex[plot]" # matplotlib, for the figure examples
87
+ ```
88
+
89
+ From a clone, `pip install .` (with the same extras) installs the development version.
90
+
91
+ ## Quickstart
92
+
93
+ This runs from an installed wheel with no data files: a continuum, a global TAE and one gradient on the analytic Könies et al. (2018) torus.
94
+
95
+ ```python
96
+ import jax, jax.numpy as jnp, fax
97
+
98
+ s = (jnp.arange(200) + 0.5) / 200
99
+ geo = fax.circular_torus(s=s, major_radius=10.0, minor_radius=1.0, B0=3.0, iota=1 / (1.71 + 0.16 * s), epsilon_B=0.1)
100
+ harmonics, rho = fax.mode_family(6, 16, 5), fax.mass_density(2e19, 1.0)
101
+ f_hz = fax.continuum(geo, harmonics, rho) # (surfaces, harmonics)
102
+ modes = fax.eigenmodes(geo, harmonics, rho, target_hz=6.46e4, count=3, ntheta=32, nzeta=4)
103
+ print(modes.frequencies_hz[-1], modes.status.converged) # the TAE, 64.63 kHz
104
+ f = lambda r: fax.frequency_hz(geo, harmonics, r, 2, 3, target_hz=6.46e4, count=3, ntheta=32, nzeta=4)
105
+ print(jax.grad(f)(rho)) # equals -f / (2 rho)
106
+ ```
107
+
108
+ The command line runs the continuum and global-mode tasks from a TOML case; the drive, objectives and boundary gradients are Python only.
109
+
110
+ ## Examples (checkout only)
111
+
112
+ These read files in the repository or need an extra; each has editable settings at the top and no command-line parsing.
113
+
114
+ | File | Needs | Shows |
115
+ |---|---|---|
116
+ | `examples/quickstart.py` | nothing | the quickstart above |
117
+ | `examples/ctok_continuum.toml`, `ctok_eigenmodes.toml` | `tests/data` | `fax run <case> --plot` for CTOK |
118
+ | `examples/qa_continuum.toml` | `vmex` | VMEC solve, Boozer transform, continuum |
119
+ | `examples/koenies_tae_drive.py` | `plot` | drive against fast-ion temperature |
120
+ | `examples/boundary_gradient.py` | `vmex` | gradient with respect to VMEC boundary coefficients |
121
+ | `examples/optimize_alfven.py` | `vmex`, `plot` | QS plus Paul's continuum cost (QH) or a classified gap-mode window (QA) |
122
+ | `examples/knyazev_qa.py` | Zenodo archive | rebuilds the offline fixture and the AE3D comparison |
123
+
124
+ ## Evidence
125
+
126
+ | Check | Result |
127
+ |---|---|
128
+ | STELLGAP CTOK continuum (97 surfaces) | 6.3e-3 mean, 6.6e-3 p95 relative |
129
+ | AE3D v31 CTOK, six global modes | within 1.4% |
130
+ | AE3D CTOK TAE, 337.9 kHz | not reproduced. On identical input three $\iota'$ terms of AE3D's bending blocks differ from the energy (pinned block comparison; real-space quadrature FAX 0.1–0.3%, AE3D 2–37%). The $J_\parallel$ blocks still disagree, cause open. Not reported upstream |
131
+ | Knyazev et al. (2026) precise QA | continuum 3.0e-3 mean from STELLGAP. The AE3D global mode (30.31 kHz) is not a FAX eigenmode (overlap 0.18, 5.1% below); cause unresolved |
132
+ | Könies et al. (2018) TAE (n = 6) | 4.061e5 rad/s (CAS3D 4.01e5, GYGLES 4.13e5), converged to 1e-4 |
133
+ | Drive, 100/200/300/400 keV | 4.26/16.27/32.48/64.02 ×10³ s⁻¹; 0.58–0.60 of CAS3D-K ZOW at 82–191 keV (content unmatched, not a validation) |
134
+ | Gradients | fixed-geometry AD vs finite differences to about 1e-9 at best; LP-QA gap-mode shift of 4 kHz predicted to 0.05 kHz, resolution budget 0.13 kHz; the $m = 1$ gauge leaves a 1e-2 floor on `zs11`, not closed |
135
+
136
+ The [validation page](https://fax.readthedocs.io/en/latest/validation.html) has every gate, its test and what is not done.
137
+
138
+ ## Ecosystem
139
+
140
+ [SOLVAX](https://github.com/uwplasma/SOLVAX) provides the generalized eigensolver and its implicit derivatives;
141
+ [VMEX](https://github.com/uwplasma/vmex) the equilibrium and its implicit derivative;
142
+ [booz_xform_jax](https://github.com/uwplasma/booz_xform_jax) the Boozer transform. ESSOS orbit transport of FAX modes is planned, not implemented.
143
+
144
+ ## Documentation and testing
145
+
146
+ [fax.readthedocs.io](https://fax.readthedocs.io): getting started and TOML reference, tutorials, theory, validation, API with its derivative contract.
147
+
148
+ ```bash
149
+ pip install -e ".[dev,vmex]"
150
+ python -m pytest -q -m 'not scheduled'
151
+ ruff check . && ruff format --check . && sphinx-build -W -b html docs docs/_build/html
152
+ ```
153
+
154
+ ## Citing and license
155
+
156
+ Cite the repository through [`CITATION.cff`](CITATION.cff) and the papers whose models FAX implements ([references](https://fax.readthedocs.io/en/latest/references.html)). MIT license.
alfvex-1.0.1/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # FAX
2
+
3
+ [![Tests](https://github.com/uwplasma/fax/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/uwplasma/fax/actions/workflows/tests.yml)
4
+ [![Docs](https://readthedocs.org/projects/fax/badge/?version=latest)](https://fax.readthedocs.io)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
6
+
7
+ FAX is a lean JAX code for shear-Alfvén physics in stellarators: the continuum, AE3D-form global modes and a perturbative
8
+ fast-ion drive, with first-order derivatives with respect to profiles and, through VMEX, the VMEC boundary.
9
+
10
+ <p align="center">
11
+ <img src="docs/_static/ctok_eigenmodes_continuum.png" width="44%" alt="CTOK continuum and global modes">
12
+ <img src="docs/_static/koenies_tae_drive.png" width="48%" alt="Könies TAE drive">
13
+ </p>
14
+
15
+ *Left: the n = 2 CTOK continuum with the six global modes nearest 384 kHz (red). Right: the passing-only drive of the Könies et al. (2018) TAE.*
16
+
17
+ ## Scope
18
+
19
+ | Supported | Not supported |
20
+ |---|---|
21
+ | Low-β continuum on each Boozer surface (STELLGAP form) | Sound coupling, so no BAE or BAAE |
22
+ | AE3D-form reduced ideal-MHD global modes, signed $\omega^2$ | Continuum damping and electron damping |
23
+ | `finite_beta=True`: restricted $J_\parallel$ and pressure terms (experimental) | A full-MHD or compressional model |
24
+ | Drive of a fixed mode: zero orbit width, passing ions only | Trapped particles, finite orbit width, finite Larmor radius |
25
+
26
+ The drive is perturbative and valid up to about 200 keV in the Könies case ($\gamma/\omega \le 4\%$). It is about 0.6 of the CAS3D-K
27
+ zero-orbit-width curve, plausibly the missing trapped share; that attribution is not established. Derivatives are first order and
28
+ are NaN unless the eigensolve converged and its selection is not a tie.
29
+
30
+ ## Install
31
+
32
+ Python 3.10 or newer, JAX 0.4.38 or newer, `solvax>=0.32.1`. FAX is on PyPI as **`alfvex`**; the code is imported as `fax`.
33
+ FAX will soon be renamed ALFVEX everywhere (repository, import and command line). Do not `pip install fax`: that is an unrelated package.
34
+
35
+ ```bash
36
+ pip install alfvex # core
37
+ pip install "alfvex[vmex]" # VMEC solves and boundary gradients (Python >= 3.11)
38
+ pip install "alfvex[plot]" # matplotlib, for the figure examples
39
+ ```
40
+
41
+ From a clone, `pip install .` (with the same extras) installs the development version.
42
+
43
+ ## Quickstart
44
+
45
+ This runs from an installed wheel with no data files: a continuum, a global TAE and one gradient on the analytic Könies et al. (2018) torus.
46
+
47
+ ```python
48
+ import jax, jax.numpy as jnp, fax
49
+
50
+ s = (jnp.arange(200) + 0.5) / 200
51
+ geo = fax.circular_torus(s=s, major_radius=10.0, minor_radius=1.0, B0=3.0, iota=1 / (1.71 + 0.16 * s), epsilon_B=0.1)
52
+ harmonics, rho = fax.mode_family(6, 16, 5), fax.mass_density(2e19, 1.0)
53
+ f_hz = fax.continuum(geo, harmonics, rho) # (surfaces, harmonics)
54
+ modes = fax.eigenmodes(geo, harmonics, rho, target_hz=6.46e4, count=3, ntheta=32, nzeta=4)
55
+ print(modes.frequencies_hz[-1], modes.status.converged) # the TAE, 64.63 kHz
56
+ f = lambda r: fax.frequency_hz(geo, harmonics, r, 2, 3, target_hz=6.46e4, count=3, ntheta=32, nzeta=4)
57
+ print(jax.grad(f)(rho)) # equals -f / (2 rho)
58
+ ```
59
+
60
+ The command line runs the continuum and global-mode tasks from a TOML case; the drive, objectives and boundary gradients are Python only.
61
+
62
+ ## Examples (checkout only)
63
+
64
+ These read files in the repository or need an extra; each has editable settings at the top and no command-line parsing.
65
+
66
+ | File | Needs | Shows |
67
+ |---|---|---|
68
+ | `examples/quickstart.py` | nothing | the quickstart above |
69
+ | `examples/ctok_continuum.toml`, `ctok_eigenmodes.toml` | `tests/data` | `fax run <case> --plot` for CTOK |
70
+ | `examples/qa_continuum.toml` | `vmex` | VMEC solve, Boozer transform, continuum |
71
+ | `examples/koenies_tae_drive.py` | `plot` | drive against fast-ion temperature |
72
+ | `examples/boundary_gradient.py` | `vmex` | gradient with respect to VMEC boundary coefficients |
73
+ | `examples/optimize_alfven.py` | `vmex`, `plot` | QS plus Paul's continuum cost (QH) or a classified gap-mode window (QA) |
74
+ | `examples/knyazev_qa.py` | Zenodo archive | rebuilds the offline fixture and the AE3D comparison |
75
+
76
+ ## Evidence
77
+
78
+ | Check | Result |
79
+ |---|---|
80
+ | STELLGAP CTOK continuum (97 surfaces) | 6.3e-3 mean, 6.6e-3 p95 relative |
81
+ | AE3D v31 CTOK, six global modes | within 1.4% |
82
+ | AE3D CTOK TAE, 337.9 kHz | not reproduced. On identical input three $\iota'$ terms of AE3D's bending blocks differ from the energy (pinned block comparison; real-space quadrature FAX 0.1–0.3%, AE3D 2–37%). The $J_\parallel$ blocks still disagree, cause open. Not reported upstream |
83
+ | Knyazev et al. (2026) precise QA | continuum 3.0e-3 mean from STELLGAP. The AE3D global mode (30.31 kHz) is not a FAX eigenmode (overlap 0.18, 5.1% below); cause unresolved |
84
+ | Könies et al. (2018) TAE (n = 6) | 4.061e5 rad/s (CAS3D 4.01e5, GYGLES 4.13e5), converged to 1e-4 |
85
+ | Drive, 100/200/300/400 keV | 4.26/16.27/32.48/64.02 ×10³ s⁻¹; 0.58–0.60 of CAS3D-K ZOW at 82–191 keV (content unmatched, not a validation) |
86
+ | Gradients | fixed-geometry AD vs finite differences to about 1e-9 at best; LP-QA gap-mode shift of 4 kHz predicted to 0.05 kHz, resolution budget 0.13 kHz; the $m = 1$ gauge leaves a 1e-2 floor on `zs11`, not closed |
87
+
88
+ The [validation page](https://fax.readthedocs.io/en/latest/validation.html) has every gate, its test and what is not done.
89
+
90
+ ## Ecosystem
91
+
92
+ [SOLVAX](https://github.com/uwplasma/SOLVAX) provides the generalized eigensolver and its implicit derivatives;
93
+ [VMEX](https://github.com/uwplasma/vmex) the equilibrium and its implicit derivative;
94
+ [booz_xform_jax](https://github.com/uwplasma/booz_xform_jax) the Boozer transform. ESSOS orbit transport of FAX modes is planned, not implemented.
95
+
96
+ ## Documentation and testing
97
+
98
+ [fax.readthedocs.io](https://fax.readthedocs.io): getting started and TOML reference, tutorials, theory, validation, API with its derivative contract.
99
+
100
+ ```bash
101
+ pip install -e ".[dev,vmex]"
102
+ python -m pytest -q -m 'not scheduled'
103
+ ruff check . && ruff format --check . && sphinx-build -W -b html docs docs/_build/html
104
+ ```
105
+
106
+ ## Citing and license
107
+
108
+ Cite the repository through [`CITATION.cff`](CITATION.cff) and the papers whose models FAX implements ([references](https://fax.readthedocs.io/en/latest/references.html)). MIT license.
@@ -0,0 +1,247 @@
1
+ """Equal-accuracy timings of the FAX eigensolvers on one assembled pencil ``F y = lam G y``.
2
+
3
+ Run ``python examples/benchmark.py`` on a quiet machine (each job waits up to ``WAIT_MAX`` seconds for a 1-minute load below
4
+ ``LOAD_MAX``; the loads are reported); edit the settings below. Methods:
5
+
6
+ * ``targeted``: ``fax.eigenmodes(target_hz, count)``, SOLVAX shift-invert on the block-tridiagonal pencil;
7
+ * ``dense-jax``: ``fax.eigenmodes()``, FAX's jitted dense generalized solve of all modes (Cholesky + ``eigh``);
8
+ * ``dense-scipy``: ``scipy.linalg.eigh(F, G)`` of all modes, the reference.
9
+
10
+ Each (case, method) runs in a fresh spawned process, so ``cold`` (compile + first call) and the peak RSS (above the
11
+ post-import baseline, compile included) belong to that method alone; ``warm`` is the median of ``REPS`` synchronized calls
12
+ (``block_until_ready``). The forward pass includes assembly. ``eta`` is the largest ``||F y - lam G y|| / ((||F|| + |lam| ||G||)
13
+ ||y||)`` of the requested modes (infinity norm of the matrices), recomputed here from the returned vectors; ``err`` is the
14
+ relative eigenvalue error against ``dense-scipy`` and ``gerr`` the relative L2 gradient difference against ``dense-jax``.
15
+ ``busy thr`` is CPU time over wall time of the warm forward calls: ``THREADS`` pins BLAS/LAPACK, but XLA's own dots may
16
+ still use several cores on this jaxlib. Speed-ups are printed only when both methods reach ``TOL``. The gradient is that of
17
+ ``frequency_hz`` (mean of the requested modes) with respect to every floating-point geometry array.
18
+ """
19
+
20
+ import os
21
+
22
+ THREADS, REPS, SLOW, TOL, LOAD_MAX, WAIT_MAX, DENSE_MAX, ITERS = (
23
+ 1,
24
+ 5,
25
+ 30.0,
26
+ 1e-10,
27
+ 4.0,
28
+ 120,
29
+ 5000,
30
+ 1000,
31
+ ) # SLOW: seconds above which REPS falls to 2
32
+ CASES = [ # (label, kind, N, H, requested modes); N = 0 takes the file's surfaces (CTOK) or the fixture's (49)
33
+ ("CTOK n=2", "ctok", 0, 13, 6),
34
+ ("Koenies N=200", "koenies", 200, 12, 3),
35
+ ("Koenies N=400", "koenies", 400, 12, 3),
36
+ ("Knyazev QA", "knyazev", 49, 64, 6),
37
+ ]
38
+ BASE = ("koenies", 200, 12, 3) # scaling sweeps vary one of (N, H, modes) at a time from here
39
+ SWEEPS = {"N": (200, 400, 800, 1600, 3200), "H": (12, 16, 24, 32, 48), "modes": (1, 3, 6, 12, 24)} # N H > 2048: no dense fallback
40
+
41
+ os.environ.update( # before numpy and jax load; spawned workers inherit them
42
+ OMP_NUM_THREADS=str(THREADS),
43
+ OPENBLAS_NUM_THREADS=str(THREADS),
44
+ VECLIB_MAXIMUM_THREADS=str(THREADS),
45
+ XLA_FLAGS=f"--xla_cpu_multi_thread_eigen={THREADS > 1} intra_op_parallelism_threads={THREADS}".replace("True", "true").replace(
46
+ "False", "false"
47
+ ),
48
+ )
49
+
50
+ import platform # noqa: E402
51
+ import resource # noqa: E402
52
+ import statistics # noqa: E402
53
+ import sys # noqa: E402
54
+ import time # noqa: E402
55
+ from multiprocessing import get_context # noqa: E402
56
+ from pathlib import Path # noqa: E402
57
+
58
+ import jax # noqa: E402
59
+ import jax.numpy as jnp # noqa: E402
60
+ import jaxlib # noqa: E402
61
+ import numpy as np # noqa: E402
62
+ import scipy # noqa: E402
63
+ import scipy.linalg # noqa: E402
64
+ import solvax # noqa: E402
65
+
66
+ import fax # noqa: E402
67
+ from fax.eigen import band, dense, global_matrices # noqa: E402
68
+
69
+ ROOT = Path(__file__).resolve().parents[1]
70
+
71
+
72
+ def build(kind, N, H):
73
+ """``(geometry, harmonics, density, target Hz, grid)`` of a case."""
74
+ if kind == "ctok":
75
+ geo = fax.read_boozmn(ROOT / "tests" / "data" / "boozmn_ctok.nc").full_mesh()
76
+ return geo, fax.mode_family(2, 12), fax.mass_density(1e19, 2.0), 384e3, dict(ntheta=32, nzeta=4)
77
+ if kind == "koenies": # Koenies et al. (2018) ITPA TAE, n = 6, q = 1.71 + 0.16 r^2; m = 5 .. 4 + H on a grid that resolves them
78
+ s = (jnp.arange(N) + 0.5) / N
79
+ geo = fax.circular_torus(s=s, major_radius=10.0, minor_radius=1.0, B0=3.0, iota=1 / (1.71 + 0.16 * s), epsilon_B=0.1)
80
+ return geo, fax.mode_family(6, 4 + H, 5), fax.mass_density(2e19, 1.0), 6.46e4, dict(ntheta=32 * -(-H // 12), nzeta=4)
81
+ from knyazev_qa import RHO, geometry, load # examples/ is on sys.path
82
+
83
+ fx = load()
84
+ return geometry(fx, N), jnp.asarray(fx["mn"][fx["order"][:H]], dtype=float), RHO, 30.3e3, dict(ntheta=64, nzeta=48)
85
+
86
+
87
+ def rss():
88
+ """Peak RSS of this process in MB."""
89
+ return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / (1e6 if sys.platform == "darwin" else 1e3)
90
+
91
+
92
+ def cpu():
93
+ """CPU seconds used by this process so far."""
94
+ u = resource.getrusage(resource.RUSAGE_SELF)
95
+ return u.ru_utime + u.ru_stime
96
+
97
+
98
+ def timed(f, *args):
99
+ """``(result, cold, warm, reps, threads)``: compile + first call, the median of ``REPS`` synchronized calls (2 if the
100
+ first is slow) and the mean number of busy threads (CPU time over wall time) of those calls."""
101
+ t = time.perf_counter()
102
+ out = jax.block_until_ready(f(*args))
103
+ cold, runs, c = time.perf_counter() - t, [], cpu()
104
+ for _ in range(REPS if cold < SLOW else 2):
105
+ t = time.perf_counter()
106
+ jax.block_until_ready(f(*args))
107
+ runs.append(time.perf_counter() - t)
108
+ return out, cold, statistics.median(runs), len(runs), (cpu() - c) / sum(runs)
109
+
110
+
111
+ def eta(F, G, lam, y):
112
+ """Largest relative residual of ``F y = lam G y`` for ``y`` of shape ``(modes, N, H)`` (blocks ``F``, ``G``)."""
113
+ y = jnp.moveaxis(y, 0, -1)
114
+ mv = lambda A: solvax.block_tridiag_matvec(*band(A), y) # noqa: E731
115
+ norm = lambda A: jnp.max(sum(jnp.abs(b).sum(-1) for b in band(A))) # noqa: E731 - infinity norm
116
+ r = jnp.linalg.norm((mv(F) - lam * mv(G)).reshape(-1, len(lam)), axis=0)
117
+ return float(jnp.max(r / ((norm(F) + jnp.abs(lam) * norm(G)) * jnp.linalg.norm(y.reshape(-1, len(lam)), axis=0))))
118
+
119
+
120
+ def work(job):
121
+ """One (case, method) in this process: forward and gradient timings, peak memory, then accuracy and solve-only diagnostics."""
122
+ kind, N, H, count, method = job
123
+ geo, h, rho, target, grid = build(kind, N, H)
124
+ N, H = geo.s.size, h.shape[0]
125
+ jax.block_until_ready(geo)
126
+ base, w0 = rss(), (2 * np.pi * target) ** 2
127
+ opts = dict(target_hz=target, count=count, iters=ITERS, **grid) if method == "targeted" else grid
128
+ pick = lambda w: np.sort(np.argsort(np.abs(np.asarray(w) - w0))[:count]) # noqa: E731 - the modes nearest the target
129
+ asm = jax.jit(lambda g: tuple(dense(b) for b in global_matrices(g, h, rho, **grid)))
130
+ fwd = {
131
+ "targeted": lambda: jax.jit(lambda g: fax.eigenmodes(g, h, rho, **opts)),
132
+ "dense-jax": lambda: jax.jit(lambda g: fax.eigenmodes(g, h, rho, **opts)),
133
+ "dense-scipy": lambda: lambda g: scipy.linalg.eigh(*map(np.asarray, asm(g))),
134
+ }[method]()
135
+ r, cold, warm, reps, threads = timed(fwd, geo)
136
+ out = dict(N=N, H=H, count=count, base=base, grid=grid, cold=cold, warm=warm, reps=reps, threads=threads, mem=rss() - base)
137
+ if method == "dense-scipy": # (eigenvalues, eigenvectors): the modes nearest the target
138
+ lam, y = r[0][pick(r[0])], r[1][:, pick(r[0])].T.reshape(count, N, H)
139
+ else:
140
+ idx = slice(None) if method == "targeted" else pick(r.omega2)
141
+ lam, y = r.omega2[idx], r.structures[idx]
142
+ start = int(idx[0]) if method == "dense-jax" else 0 # the nearest modes are contiguous in the sorted spectrum
143
+ grad = jax.jit(jax.grad(lambda g: fax.frequency_hz(g, h, rho, start, start + count, **opts), allow_int=True))
144
+ gr, out["gcold"], out["gwarm"], *_ = timed(grad, geo)
145
+ out["gmem"] = rss() - base
146
+ out["grad"] = np.concatenate([np.ravel(a) for a in jax.tree_util.tree_leaves(gr) if a.dtype.kind == "f"])
147
+ out["temp"] = fwd.lower(geo).compile().memory_analysis().temp_size_in_bytes / 1e6 # XLA static buffers of the forward pass
148
+ F, G = global_matrices(geo, h, rho, **grid)
149
+ out["lam"], out["eta"] = np.asarray(lam), eta(F, G, jnp.asarray(lam), jnp.asarray(y))
150
+ if method == "targeted":
151
+ solve = jax.jit(lambda F, G: fax.eigen.targeted_eigenpairs(F, G, w0, count, ITERS)[0])
152
+ fac = jax.jit(lambda F, G: solvax.block_thomas_factor(*(f - w0 * g for f, g in zip(band(F), band(G), strict=True))))
153
+ out["factor"] = timed(fac, F, G)[2] # one factorization of F - sigma G: O(N H^3) work, O(N H^2) storage
154
+ out["fmb"] = sum(a.nbytes for a in jax.tree_util.tree_leaves(fac(F, G))) / 1e6
155
+ out["solve"], out["iters"] = timed(solve, F, G)[2], int(r.status.iterations)
156
+ out["stemp"] = solve.lower(F, G).compile().memory_analysis().temp_size_in_bytes / 1e6
157
+ return out
158
+
159
+
160
+ def wait():
161
+ """Block until the 1-minute load average is below ``LOAD_MAX`` (at most ``WAIT_MAX`` seconds); return it."""
162
+ t = time.time()
163
+ while (load := os.getloadavg()[0]) >= LOAD_MAX and time.time() - t < WAIT_MAX:
164
+ time.sleep(10)
165
+ return load
166
+
167
+
168
+ def fit(rows, key, var):
169
+ """Observed exponent of ``key`` against ``var`` (least squares in log-log)."""
170
+ return np.polyfit(np.log([r[var] for r in rows]), np.log([max(r[key], 1e-6) for r in rows]), 1)[0]
171
+
172
+
173
+ def gerr(r, g0):
174
+ """Relative L2 difference of the gradient of ``r`` from the reference gradient ``g0``."""
175
+ return np.linalg.norm(r["grad"] - g0) / np.linalg.norm(g0) if g0 is not None and "grad" in r else np.nan
176
+
177
+
178
+ def show(head, rows):
179
+ """Print a right-aligned table; numbers get three significant digits (``nan`` where not applicable)."""
180
+ cells = [head] + [[c if isinstance(c, str) else f"{c:.3g}" for c in row] for row in rows]
181
+ for row in cells:
182
+ print(" ".join(c.rjust(max(len(x[i]) for x in cells)) if i else c.ljust(max(len(x[0]) for x in cells)) for i, c in enumerate(row)))
183
+
184
+
185
+ if __name__ == "__main__":
186
+ pool, loads = get_context("spawn").Pool(1, maxtasksperchild=1), []
187
+
188
+ def run(case, method):
189
+ loads.append(wait())
190
+ return {**pool.apply(work, ((*case, method),)), "load": loads[-1]}
191
+
192
+ print(
193
+ f"fax {fax.__version__}, solvax {solvax.__version__}, jax {jax.__version__}, jaxlib {jaxlib.__version__}, "
194
+ f"numpy {np.__version__}, scipy {scipy.__version__}; python {platform.python_version()}, {platform.machine()} {platform.system()}"
195
+ )
196
+ print(
197
+ f"{jax.devices()[0].device_kind} ({jax.default_backend()}), {os.cpu_count()} cores, {THREADS} BLAS thread(s), float64, TOL {TOL:g}"
198
+ )
199
+ nan = np.nan
200
+ for label, *case in CASES:
201
+ res = {
202
+ m: run(case, m) for m in ("dense-scipy", "dense-jax", "targeted") if m == "targeted" or (case[1] or 100) * case[2] <= DENSE_MAX
203
+ }
204
+ ref, g0 = res.get("dense-scipy", res["dense-jax"]), res.get("dense-jax", {}).get("grad")
205
+ r0 = res["targeted"]
206
+ for r in res.values():
207
+ r["err"], r["gerr"] = np.max(np.abs(r["lam"] / ref["lam"] - 1)), gerr(r, g0)
208
+ print(f"\n{label}: N {r0['N']}, H {r0['H']}, {r0['count']} modes, angle grid {r0['grid']}, warm = median of {REPS} (fewer if slow)")
209
+ cols = {
210
+ "eta": lambda r: r["eta"],
211
+ "err": lambda r: r["err"],
212
+ "gerr": lambda r: r["gerr"],
213
+ "fwd cold": lambda r: r["cold"],
214
+ "fwd warm": lambda r: r["warm"],
215
+ "busy thr": lambda r: r["threads"],
216
+ "solve": lambda r: r.get("solve", nan),
217
+ "grad cold": lambda r: r.get("gcold", nan),
218
+ "grad warm": lambda r: r.get("gwarm", nan),
219
+ "RSS f/g MB": lambda r: f"{r['mem']:.0f}/{r.get('gmem', nan):.0f}",
220
+ "XLA MB": lambda r: r.get("temp", nan),
221
+ "iters": lambda r: r.get("iters", 0),
222
+ "load": lambda r: r["load"],
223
+ }
224
+ show(["method", *cols], [[m, *(f(r) for f in cols.values())] for m, r in res.items()])
225
+ if any(r["reps"] < REPS for r in res.values()):
226
+ print("warm forward reps: " + ", ".join(f"{m} {r['reps']}" for m, r in res.items()))
227
+ if "dense-jax" in res and max(res["dense-jax"]["eta"], r0["eta"]) <= TOL:
228
+ d = res["dense-jax"]
229
+ print(
230
+ f"targeted vs dense-jax at eta <= {TOL:g}: forward x{d['warm'] / r0['warm']:.1f}, gradient x{d['gwarm'] / r0['gwarm']:.1f}"
231
+ )
232
+ print(f"\nscaling (targeted, from {BASE[0]} N={BASE[1]} H={BASE[2]} modes={BASE[3]}); factor, solve: SOLVAX on assembled blocks")
233
+ cols = dict(N="N", H="H", modes="count", eta="eta", iters="iters", factor="factor", solve="solve")
234
+ cols |= {"fwd warm": "warm", "grad warm": "gwarm", "factor MB": "fmb", "solve MB": "stemp", "RSS MB": "mem", "load": "load"}
235
+ rows = {
236
+ var: [run((BASE[0], *[v if k == var else b for k, b in zip(SWEEPS, BASE[1:], strict=True)]), "targeted") for v in values]
237
+ for var, values in SWEEPS.items()
238
+ }
239
+ show(["var", *cols], [[var, *(r[c] for c in cols.values())] for var, rs in rows.items() for r in rs])
240
+ for k, rs in rows.items():
241
+ names = {"N": "N", "H": "H", "modes": "count"}
242
+ ok = [r for r in rs if r["eta"] <= TOL] # only points that reached the tolerance
243
+ fits = {n: fit(ok, cols[n], names[k]) for n in ("factor", "solve", "fwd warm", "grad warm", "factor MB", "solve MB", "RSS MB")}
244
+ print(f"observed exponent in {k} (eta <= TOL only): " + ", ".join(f"{n} {e:.2f}" for n, e in fits.items()))
245
+ print("expected: factorization O(N H^3), factor storage O(N H^2); solve = factor + iterations x O(N H^2 (2 modes + 8))")
246
+ print(f"load before each run: min {min(loads):.1f}, median {np.median(loads):.1f}, max {max(loads):.1f}, {len(loads)} runs,")
247
+ print(f"{sum(x >= LOAD_MAX for x in loads)} not below {LOAD_MAX:g}; RSS is above a baseline of {r0['base']:.0f} MB")
@@ -0,0 +1,27 @@
1
+ """Gradient of global Alfvén frequencies with respect to VMEC boundary coefficients.
2
+
3
+ Run: python examples/boundary_gradient.py (needs ``pip install "alfvex[vmex]"``)
4
+ """
5
+
6
+ from pathlib import Path
7
+
8
+ import jax
9
+ import jax.numpy as jnp
10
+
11
+ import fax
12
+
13
+ design = fax.prepare_design(
14
+ Path(__file__).parent / "data" / "input.LandremanPaul2021_QA_lowres",
15
+ harmonics=[[1.0, 0.0], [2.0, 0.0], [3.0, 0.0]],
16
+ density=3.4e-8,
17
+ parameters=("rc01", "zs01"),
18
+ ntheta=8,
19
+ nzeta=8,
20
+ )
21
+ objective = lambda p: fax.window_risk(fax.design_frequencies(p, design)[0], (250e3, 290e3), 10e3) # noqa: E731
22
+ p = jnp.zeros(2)
23
+ value, grad = jax.value_and_grad(objective)(p)
24
+ print(f"frequencies (kHz): {fax.design_frequencies(p, design)[0] / 1e3}")
25
+ print(f"risk = {value:.4e}, d risk / d {design.names} = {grad}")
26
+ step = -1e-3 * grad / jnp.linalg.norm(grad)
27
+ print(f"risk after a 1e-3 step along -grad: {objective(p + step):.4e}")
@@ -0,0 +1,16 @@
1
+ # STELLGAP CTOK test case: n = 2 continuum in a circular tokamak, deuterium at 1e19 m^-3.
2
+ [geometry]
3
+ source = "boozmn"
4
+ path = "../tests/data/boozmn_ctok.nc"
5
+
6
+ [modes]
7
+ n = [2]
8
+ m_max = 12
9
+
10
+ [plasma]
11
+ density = 1e19
12
+ ion_mass = 2.0
13
+
14
+ [run]
15
+ task = "continuum"
16
+ output = "../outputs/ctok_continuum"
@@ -0,0 +1,20 @@
1
+ # Global AE3D-form eigenmodes of CTOK nearest 384 kHz (AE3D's m = 2, 3 global mode).
2
+ [geometry]
3
+ source = "boozmn"
4
+ path = "../tests/data/boozmn_ctok.nc"
5
+
6
+ [modes]
7
+ n = [2]
8
+ m_max = 12
9
+
10
+ [plasma]
11
+ density = 1e19
12
+ ion_mass = 2.0
13
+
14
+ [run]
15
+ task = "eigenmodes"
16
+ output = "../outputs/ctok_eigenmodes"
17
+ ntheta = 32
18
+ nzeta = 4
19
+ target_khz = 384.0
20
+ count = 6
@@ -0,0 +1,9 @@
1
+ &INDATA
2
+ LASYM = F, NFP = 1, MPOL = 8, NTOR = 0, NZETA = 1, NTHETA = 32,
3
+ NS_ARRAY = 16 51, FTOL_ARRAY = 1e-10 1e-12, NITER_ARRAY = 2000 5000,
4
+ DELT = 0.9, NSTEP = 200, PHIEDGE = 9.42477796, NCURR = 0, GAMMA = 0.0,
5
+ PRES_SCALE = 1.0, AM = 7.17e3 -6.811e3 -3.585e2,
6
+ AI = 5.84795322e-01 -5.47176905e-02 5.11977817e-03 -4.79011335e-04 4.47304912e-05 -4.05618024e-06 2.86012825e-07,
7
+ RAXIS_CC = 10.0, ZAXIS_CS = 0.0,
8
+ RBC(0,0) = 10.0, RBC(0,1) = 1.0, ZBS(0,1) = 1.0,
9
+ /