├── .cargo └── config.toml ├── .github └── workflows │ ├── CI.yml │ └── actions.yml ├── .gitignore ├── .readthedocs.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── build_docs.sh ├── cfsem ├── __init__.py ├── bindings.py ├── py.typed └── types.py ├── docs ├── assets │ └── logo.svg ├── index.md ├── javascripts │ └── mathjax.js ├── python │ ├── example_outputs │ │ └── .placeholder │ ├── examples.md │ ├── filament.md │ ├── flux_density.md │ ├── force.md │ ├── grad_shafranov.md │ ├── inductance.md │ ├── math.md │ ├── types.md │ └── vector_potential.md ├── requirements.txt └── stylesheets │ └── extra.css ├── examples ├── helmholtz.py └── inductance.py ├── mkdocs.yml ├── pyproject.toml ├── src └── lib.rs └── test ├── __init__.py ├── test_electromagnetics.py ├── test_examples.py ├── test_filaments.py ├── test_funcs.py ├── test_gradshafranov.py └── test_math.py /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [target.'cfg(any(target_arch = "x86", target_arch = "x86_64"))'] 2 | rustflags = ["-Ctarget-feature=+fma"] 3 | -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | # This file is autogenerated by maturin v1.8.2 2 | # To update, run 3 | # 4 | # maturin generate-ci github 5 | # 6 | name: CI 7 | 8 | on: 9 | push: 10 | branches: 11 | - release 12 | workflow_dispatch: 13 | 14 | permissions: 15 | contents: read 16 | 17 | jobs: 18 | linux: 19 | runs-on: ${{ matrix.platform.runner }} 20 | strategy: 21 | matrix: 22 | python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] 23 | platform: 24 | - runner: ubuntu-22.04 25 | target: x86_64 26 | - runner: ubuntu-22.04 27 | target: x86 28 | - runner: ubuntu-22.04 29 | target: aarch64 30 | - runner: ubuntu-22.04 31 | target: armv7 32 | - runner: ubuntu-22.04 33 | target: s390x 34 | - runner: ubuntu-22.04 35 | target: ppc64le 36 | steps: 37 | - uses: actions/checkout@v4 38 | - uses: actions/setup-python@v5 39 | with: 40 | python-version: ${{ matrix.python-version }} 41 | - name: Build wheels 42 | uses: PyO3/maturin-action@v1 43 | with: 44 | target: ${{ matrix.platform.target }} 45 | args: --release --out dist --find-interpreter 46 | sccache: 'true' 47 | manylinux: auto 48 | - name: Upload wheels 49 | uses: actions/upload-artifact@v4 50 | with: 51 | name: wheels-linux-${{ matrix.platform.target }}-py${{ matrix.python-version }} 52 | path: dist 53 | 54 | musllinux: 55 | runs-on: ${{ matrix.platform.runner }} 56 | strategy: 57 | matrix: 58 | python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] 59 | platform: 60 | - runner: ubuntu-22.04 61 | target: x86_64 62 | - runner: ubuntu-22.04 63 | target: x86 64 | - runner: ubuntu-22.04 65 | target: aarch64 66 | - runner: ubuntu-22.04 67 | target: armv7 68 | steps: 69 | - uses: actions/checkout@v4 70 | - uses: actions/setup-python@v5 71 | with: 72 | python-version: ${{ matrix.python-version }} 73 | - name: Build wheels 74 | uses: PyO3/maturin-action@v1 75 | with: 76 | target: ${{ matrix.platform.target }} 77 | args: --release --out dist --find-interpreter 78 | sccache: 'true' 79 | manylinux: musllinux_1_2 80 | - name: Upload wheels 81 | uses: actions/upload-artifact@v4 82 | with: 83 | name: wheels-musllinux-${{ matrix.platform.target }}-py${{ matrix.python-version }} 84 | path: dist 85 | 86 | windows: 87 | runs-on: ${{ matrix.platform.runner }} 88 | strategy: 89 | matrix: 90 | python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] 91 | platform: 92 | - runner: windows-latest 93 | target: x64 94 | - runner: windows-latest 95 | target: x86 96 | steps: 97 | - uses: actions/checkout@v4 98 | - uses: actions/setup-python@v5 99 | with: 100 | python-version: ${{ matrix.python-version }} 101 | architecture: ${{ matrix.platform.target }} 102 | - name: Build wheels 103 | uses: PyO3/maturin-action@v1 104 | with: 105 | target: ${{ matrix.platform.target }} 106 | args: --release --out dist --find-interpreter 107 | sccache: 'true' 108 | - name: Upload wheels 109 | uses: actions/upload-artifact@v4 110 | with: 111 | name: wheels-windows-${{ matrix.platform.target }}-py${{ matrix.python-version }} 112 | path: dist 113 | 114 | macos: 115 | runs-on: ${{ matrix.platform.runner }} 116 | strategy: 117 | matrix: 118 | python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] 119 | platform: 120 | - runner: macos-13 121 | target: x86_64 122 | - runner: macos-14 123 | target: aarch64 124 | steps: 125 | - uses: actions/checkout@v4 126 | - uses: actions/setup-python@v5 127 | with: 128 | python-version: ${{ matrix.python-version }} 129 | - name: Build wheels 130 | uses: PyO3/maturin-action@v1 131 | with: 132 | target: ${{ matrix.platform.target }} 133 | args: --release --out dist --find-interpreter 134 | sccache: 'true' 135 | - name: Upload wheels 136 | uses: actions/upload-artifact@v4 137 | with: 138 | name: wheels-macos-${{ matrix.platform.target }}-py${{ matrix.python-version }} 139 | path: dist 140 | 141 | sdist: 142 | runs-on: ubuntu-latest 143 | steps: 144 | - uses: actions/checkout@v4 145 | - name: Build sdist 146 | uses: PyO3/maturin-action@v1 147 | with: 148 | command: sdist 149 | args: --out dist 150 | - name: Upload sdist 151 | uses: actions/upload-artifact@v4 152 | with: 153 | name: wheels-sdist 154 | path: dist 155 | 156 | release: 157 | name: Release 158 | runs-on: ubuntu-latest 159 | #if: ${{ startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' }} 160 | needs: [linux, musllinux, windows, macos, sdist] 161 | permissions: 162 | # Use to sign the release artifacts 163 | id-token: write 164 | # Used to upload release artifacts 165 | contents: write 166 | # Used to generate artifact attestation 167 | attestations: write 168 | steps: 169 | - uses: actions/download-artifact@v4 170 | - name: Generate artifact attestation 171 | uses: actions/attest-build-provenance@v1 172 | with: 173 | subject-path: 'wheels-*/*' 174 | - name: Publish to PyPI 175 | #if: ${{ startsWith(github.ref, 'refs/tags/') }} 176 | uses: PyO3/maturin-action@v1 177 | env: 178 | MATURIN_PYPI_TOKEN: ${{ secrets.MATURIN_PASSWORD }} 179 | with: 180 | command: upload 181 | args: --non-interactive --skip-existing wheels-*/* 182 | -------------------------------------------------------------------------------- /.github/workflows/actions.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: build_and_test 5 | 6 | on: 7 | # push: [] # Save time on actions 8 | pull_request: [] 9 | workflow_dispatch: 10 | tag: "Manual Run" 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-22.04 15 | timeout-minutes: 15 16 | strategy: 17 | matrix: 18 | python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v1 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | - name: Setup 27 | run: | 28 | 29 | # Base setup 30 | sudo apt update 31 | sudo apt install build-essential 32 | 33 | # Install Rust & Cargo 34 | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y 35 | rustup component add clippy 36 | 37 | # Set up pip and a virtualenv (s.t. maturin can run) 38 | python -m pip install --upgrade pip 39 | python -m venv ./env 40 | source ./env/bin/activate 41 | 42 | # Install with dev deps 43 | pip install maturin 44 | maturin develop 45 | pip install .[dev] 46 | 47 | - name: Test 48 | run: | 49 | source ./env/bin/activate 50 | 51 | # Python lint, test, and coverage 52 | ruff check ./cfsem 53 | pyright ./cfsem --pythonversion 3.11 54 | coverage run --source=./cfsem -m pytest ./test/ 55 | coverage report 56 | 57 | # Rust format and lint 58 | 59 | cargo fmt --check 60 | cargo clippy 61 | 62 | # Build docs 63 | 64 | sh build_docs.sh 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | .pytest_cache/ 6 | *.py[cod] 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | .venv/ 14 | env/ 15 | bin/ 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | include/ 26 | man/ 27 | venv/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | *.ruff_cache 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | pip-selfcheck.json 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | 46 | # Translations 47 | *.mo 48 | 49 | # Mr Developer 50 | .mr.developer.cfg 51 | .project 52 | .pydevproject 53 | 54 | # Rope 55 | .ropeproject 56 | 57 | # Django stuff: 58 | *.log 59 | *.pot 60 | 61 | .DS_Store 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyCharm 67 | .idea/ 68 | 69 | # VSCode 70 | .vscode/ 71 | 72 | # Pyenv 73 | .python-version 74 | 75 | target 76 | 77 | # Documentation site generated by mkdocs. 78 | site 79 | 80 | examples/*.png 81 | docs/python/example_outputs/*.png -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file for MkDocs projects 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | # Required 5 | version: 2 6 | 7 | # Set the version of Python and other tools you might need 8 | build: 9 | os: ubuntu-22.04 10 | tools: 11 | python: "3.11" 12 | rust: "1.70" 13 | jobs: 14 | pre_build: # Generate plots for docs 15 | - MPLBACKEND="Agg" sh build_docs.sh 16 | 17 | mkdocs: 18 | configuration: mkdocs.yml 19 | 20 | # Optionally declare the Python requirements required to build your docs 21 | python: 22 | install: 23 | - requirements: docs/requirements.txt -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 2.3.1 2025-03-19 4 | 5 | ### Changed 6 | 7 | * Update rust to 2024 edition 8 | * Update pyo3 rust dep version to resolve conditional compilation issue in build for linux arm/aarch64 9 | 10 | ## 2.3.0 2025-03-19 11 | 12 | ### Added 13 | 14 | * Add support for python 3.13 15 | 16 | ## 2.2.0 2025-02-03 17 | 18 | ### Added 19 | 20 | * Add circular-to-linear filament bridge functions 21 | * B-field and mutual inductance 22 | * Much faster and more accurate than discretizing loops and using linear filament functions, and more cross-platform consistent than doing coordinate conversions on circular filament methods at the python level 23 | * Add dipole flux density function 24 | * Add JxB body force density functions 25 | * Add run-time assertions to check domain of validity of Lyle's method for self-inductance 26 | 27 | ### Changed 28 | 29 | * Roll rust bindings forward to latest numpy and pyo3 30 | * Update interfaces with rust functions that have changed their function signature 31 | * Update python bindings with convenience functions for making inputs contiguous and flat, to reduce repetition 32 | 33 | ## 2.1.0 2024-08-27 34 | 35 | ### Added 36 | 37 | * Add vector potential calcs for linear and circular filaments 38 | * Add parallel options for circular-filament calcs 39 | * Add parametrization of unit tests over parallel and serial variants 40 | * Add optional parallel flags to functions that use functions with new parallel options 41 | 42 | ### Changed 43 | 44 | ## 2.0.4 2024-07-10 45 | 46 | ### Changed 47 | 48 | * Use `cfsem` rust dep from crates.io 49 | * Update docs and license for open-souce release 50 | 51 | ## 2.0.3 2024-07-09 52 | 53 | ### Changed 54 | 55 | * Support python 3.12 56 | 57 | ## 2.0.2 2024-07-02 58 | 59 | ### Changed 60 | 61 | * Update filament_coil function to use np.meshgrid instead of nested comprehension 62 | * Update test of filament_coil to check against previous version of calc 63 | * Move filamentization tests to their own test file 64 | * Update nalgebra dep 65 | * Update ruff dep 66 | 67 | ## 2.0.1 2024-06-18 68 | 69 | ### Changed 70 | 71 | * Improve docstrings 72 | * Test building docs in CI 73 | * Update length checks in `inductance_piecewise_linear_filaments` to allow use when first and second path do not have the same number of segments 74 | 75 | ## 2.0.0 2024-05-10 76 | 77 | ### Changed 78 | 79 | * (!) Transpose filament_helix_path() array inputs and outputs to eliminate some unnecessary copies 80 | * (!) Transpose array inputs to piecewise linear inductance methods to eliminate some unnecessary copies 81 | * Remove dep on scipy for release (now dev only) 82 | * Add more parametrized cases to tests for filament_helix_path 83 | * Use cubic interpolation method for boundary flux in distributed inductance calc 84 | * Update flux_density_biot_savart rust function to mutate an input slice instead of allocating locally 85 | * Parametrize tests of flux_density_biot_savart over parallel and serial implementations 86 | * Add optional parallel flag to flux_density_biot_savart python bindings 87 | * Roll forward rust deps on numpy and pyo3 88 | * Run linting, tests, and coverage directly and remove dep on pre-commit 89 | 90 | ### Added 91 | 92 | * Add `mesh` module in rust library 93 | * Add rust implementation of filament_helix_path() 94 | * Add rotate_filaments_about_path() via rust bindings 95 | * Add CHANGELOG.md 96 | * Add MU_0 from handcalc to eliminate scipy dep 97 | * Add _filament_helix_path_py to test functions 98 | * Integrated error over the length of a filament makes this not very useful for testing against new calc 99 | * Add flux_density_biot_savart_par in rust library 100 | * Add nalgebra and rayon to rust deps 101 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions consistent with the goals and anti-goals of the package are welcome. 4 | 5 | Please make an issue ticket to discuss changes before investing significant time into a branch. 6 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "_cfsem" 7 | version = "0.1.0" 8 | dependencies = [ 9 | "cfsem", 10 | "numpy", 11 | "pyo3", 12 | ] 13 | 14 | [[package]] 15 | name = "approx" 16 | version = "0.5.1" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" 19 | dependencies = [ 20 | "num-traits", 21 | ] 22 | 23 | [[package]] 24 | name = "autocfg" 25 | version = "1.3.0" 26 | source = "registry+https://github.com/rust-lang/crates.io-index" 27 | checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" 28 | 29 | [[package]] 30 | name = "bytemuck" 31 | version = "1.16.0" 32 | source = "registry+https://github.com/rust-lang/crates.io-index" 33 | checksum = "78834c15cb5d5efe3452d58b1e8ba890dd62d21907f867f383358198e56ebca5" 34 | 35 | [[package]] 36 | name = "cfg-if" 37 | version = "1.0.0" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 40 | 41 | [[package]] 42 | name = "cfsem" 43 | version = "2.0.0" 44 | source = "registry+https://github.com/rust-lang/crates.io-index" 45 | checksum = "756becb9d9fc8fed74e794677856d42436525ba318adeb6943e7829ab5943b06" 46 | dependencies = [ 47 | "libm", 48 | "nalgebra", 49 | "num-traits", 50 | "rayon", 51 | ] 52 | 53 | [[package]] 54 | name = "crossbeam-deque" 55 | version = "0.8.5" 56 | source = "registry+https://github.com/rust-lang/crates.io-index" 57 | checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" 58 | dependencies = [ 59 | "crossbeam-epoch", 60 | "crossbeam-utils", 61 | ] 62 | 63 | [[package]] 64 | name = "crossbeam-epoch" 65 | version = "0.9.18" 66 | source = "registry+https://github.com/rust-lang/crates.io-index" 67 | checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" 68 | dependencies = [ 69 | "crossbeam-utils", 70 | ] 71 | 72 | [[package]] 73 | name = "crossbeam-utils" 74 | version = "0.8.20" 75 | source = "registry+https://github.com/rust-lang/crates.io-index" 76 | checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" 77 | 78 | [[package]] 79 | name = "either" 80 | version = "1.12.0" 81 | source = "registry+https://github.com/rust-lang/crates.io-index" 82 | checksum = "3dca9240753cf90908d7e4aac30f630662b02aebaa1b58a3cadabdb23385b58b" 83 | 84 | [[package]] 85 | name = "heck" 86 | version = "0.5.0" 87 | source = "registry+https://github.com/rust-lang/crates.io-index" 88 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 89 | 90 | [[package]] 91 | name = "indoc" 92 | version = "2.0.5" 93 | source = "registry+https://github.com/rust-lang/crates.io-index" 94 | checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" 95 | 96 | [[package]] 97 | name = "libc" 98 | version = "0.2.155" 99 | source = "registry+https://github.com/rust-lang/crates.io-index" 100 | checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" 101 | 102 | [[package]] 103 | name = "libm" 104 | version = "0.2.11" 105 | source = "registry+https://github.com/rust-lang/crates.io-index" 106 | checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa" 107 | 108 | [[package]] 109 | name = "matrixmultiply" 110 | version = "0.3.8" 111 | source = "registry+https://github.com/rust-lang/crates.io-index" 112 | checksum = "7574c1cf36da4798ab73da5b215bbf444f50718207754cb522201d78d1cd0ff2" 113 | dependencies = [ 114 | "autocfg", 115 | "rawpointer", 116 | ] 117 | 118 | [[package]] 119 | name = "memoffset" 120 | version = "0.9.1" 121 | source = "registry+https://github.com/rust-lang/crates.io-index" 122 | checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" 123 | dependencies = [ 124 | "autocfg", 125 | ] 126 | 127 | [[package]] 128 | name = "nalgebra" 129 | version = "0.33.2" 130 | source = "registry+https://github.com/rust-lang/crates.io-index" 131 | checksum = "26aecdf64b707efd1310e3544d709c5c0ac61c13756046aaaba41be5c4f66a3b" 132 | dependencies = [ 133 | "approx", 134 | "matrixmultiply", 135 | "nalgebra-macros", 136 | "num-complex", 137 | "num-rational", 138 | "num-traits", 139 | "simba", 140 | "typenum", 141 | ] 142 | 143 | [[package]] 144 | name = "nalgebra-macros" 145 | version = "0.2.2" 146 | source = "registry+https://github.com/rust-lang/crates.io-index" 147 | checksum = "254a5372af8fc138e36684761d3c0cdb758a4410e938babcff1c860ce14ddbfc" 148 | dependencies = [ 149 | "proc-macro2", 150 | "quote", 151 | "syn", 152 | ] 153 | 154 | [[package]] 155 | name = "ndarray" 156 | version = "0.15.6" 157 | source = "registry+https://github.com/rust-lang/crates.io-index" 158 | checksum = "adb12d4e967ec485a5f71c6311fe28158e9d6f4bc4a447b474184d0f91a8fa32" 159 | dependencies = [ 160 | "matrixmultiply", 161 | "num-complex", 162 | "num-integer", 163 | "num-traits", 164 | "rawpointer", 165 | ] 166 | 167 | [[package]] 168 | name = "num-bigint" 169 | version = "0.4.6" 170 | source = "registry+https://github.com/rust-lang/crates.io-index" 171 | checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" 172 | dependencies = [ 173 | "num-integer", 174 | "num-traits", 175 | ] 176 | 177 | [[package]] 178 | name = "num-complex" 179 | version = "0.4.6" 180 | source = "registry+https://github.com/rust-lang/crates.io-index" 181 | checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" 182 | dependencies = [ 183 | "num-traits", 184 | ] 185 | 186 | [[package]] 187 | name = "num-integer" 188 | version = "0.1.46" 189 | source = "registry+https://github.com/rust-lang/crates.io-index" 190 | checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" 191 | dependencies = [ 192 | "num-traits", 193 | ] 194 | 195 | [[package]] 196 | name = "num-rational" 197 | version = "0.4.2" 198 | source = "registry+https://github.com/rust-lang/crates.io-index" 199 | checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" 200 | dependencies = [ 201 | "num-bigint", 202 | "num-integer", 203 | "num-traits", 204 | ] 205 | 206 | [[package]] 207 | name = "num-traits" 208 | version = "0.2.19" 209 | source = "registry+https://github.com/rust-lang/crates.io-index" 210 | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 211 | dependencies = [ 212 | "autocfg", 213 | "libm", 214 | ] 215 | 216 | [[package]] 217 | name = "numpy" 218 | version = "0.24.0" 219 | source = "registry+https://github.com/rust-lang/crates.io-index" 220 | checksum = "a7cfbf3f0feededcaa4d289fe3079b03659e85c5b5a177f4ba6fb01ab4fb3e39" 221 | dependencies = [ 222 | "libc", 223 | "ndarray", 224 | "num-complex", 225 | "num-integer", 226 | "num-traits", 227 | "pyo3", 228 | "pyo3-build-config", 229 | "rustc-hash", 230 | ] 231 | 232 | [[package]] 233 | name = "once_cell" 234 | version = "1.19.0" 235 | source = "registry+https://github.com/rust-lang/crates.io-index" 236 | checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" 237 | 238 | [[package]] 239 | name = "paste" 240 | version = "1.0.15" 241 | source = "registry+https://github.com/rust-lang/crates.io-index" 242 | checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" 243 | 244 | [[package]] 245 | name = "portable-atomic" 246 | version = "1.6.0" 247 | source = "registry+https://github.com/rust-lang/crates.io-index" 248 | checksum = "7170ef9988bc169ba16dd36a7fa041e5c4cbeb6a35b76d4c03daded371eae7c0" 249 | 250 | [[package]] 251 | name = "proc-macro2" 252 | version = "1.0.85" 253 | source = "registry+https://github.com/rust-lang/crates.io-index" 254 | checksum = "22244ce15aa966053a896d1accb3a6e68469b97c7f33f284b99f0d576879fc23" 255 | dependencies = [ 256 | "unicode-ident", 257 | ] 258 | 259 | [[package]] 260 | name = "pyo3" 261 | version = "0.24.0" 262 | source = "registry+https://github.com/rust-lang/crates.io-index" 263 | checksum = "7f1c6c3591120564d64db2261bec5f910ae454f01def849b9c22835a84695e86" 264 | dependencies = [ 265 | "cfg-if", 266 | "indoc", 267 | "libc", 268 | "memoffset", 269 | "once_cell", 270 | "portable-atomic", 271 | "pyo3-build-config", 272 | "pyo3-ffi", 273 | "pyo3-macros", 274 | "unindent", 275 | ] 276 | 277 | [[package]] 278 | name = "pyo3-build-config" 279 | version = "0.24.0" 280 | source = "registry+https://github.com/rust-lang/crates.io-index" 281 | checksum = "e9b6c2b34cf71427ea37c7001aefbaeb85886a074795e35f161f5aecc7620a7a" 282 | dependencies = [ 283 | "once_cell", 284 | "target-lexicon", 285 | ] 286 | 287 | [[package]] 288 | name = "pyo3-ffi" 289 | version = "0.24.0" 290 | source = "registry+https://github.com/rust-lang/crates.io-index" 291 | checksum = "5507651906a46432cdda02cd02dd0319f6064f1374c9147c45b978621d2c3a9c" 292 | dependencies = [ 293 | "libc", 294 | "pyo3-build-config", 295 | ] 296 | 297 | [[package]] 298 | name = "pyo3-macros" 299 | version = "0.24.0" 300 | source = "registry+https://github.com/rust-lang/crates.io-index" 301 | checksum = "b0d394b5b4fd8d97d48336bb0dd2aebabad39f1d294edd6bcd2cccf2eefe6f42" 302 | dependencies = [ 303 | "proc-macro2", 304 | "pyo3-macros-backend", 305 | "quote", 306 | "syn", 307 | ] 308 | 309 | [[package]] 310 | name = "pyo3-macros-backend" 311 | version = "0.24.0" 312 | source = "registry+https://github.com/rust-lang/crates.io-index" 313 | checksum = "fd72da09cfa943b1080f621f024d2ef7e2773df7badd51aa30a2be1f8caa7c8e" 314 | dependencies = [ 315 | "heck", 316 | "proc-macro2", 317 | "pyo3-build-config", 318 | "quote", 319 | "syn", 320 | ] 321 | 322 | [[package]] 323 | name = "quote" 324 | version = "1.0.36" 325 | source = "registry+https://github.com/rust-lang/crates.io-index" 326 | checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" 327 | dependencies = [ 328 | "proc-macro2", 329 | ] 330 | 331 | [[package]] 332 | name = "rawpointer" 333 | version = "0.2.1" 334 | source = "registry+https://github.com/rust-lang/crates.io-index" 335 | checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" 336 | 337 | [[package]] 338 | name = "rayon" 339 | version = "1.10.0" 340 | source = "registry+https://github.com/rust-lang/crates.io-index" 341 | checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" 342 | dependencies = [ 343 | "either", 344 | "rayon-core", 345 | ] 346 | 347 | [[package]] 348 | name = "rayon-core" 349 | version = "1.12.1" 350 | source = "registry+https://github.com/rust-lang/crates.io-index" 351 | checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" 352 | dependencies = [ 353 | "crossbeam-deque", 354 | "crossbeam-utils", 355 | ] 356 | 357 | [[package]] 358 | name = "rustc-hash" 359 | version = "2.1.0" 360 | source = "registry+https://github.com/rust-lang/crates.io-index" 361 | checksum = "c7fb8039b3032c191086b10f11f319a6e99e1e82889c5cc6046f515c9db1d497" 362 | 363 | [[package]] 364 | name = "safe_arch" 365 | version = "0.7.2" 366 | source = "registry+https://github.com/rust-lang/crates.io-index" 367 | checksum = "c3460605018fdc9612bce72735cba0d27efbcd9904780d44c7e3a9948f96148a" 368 | dependencies = [ 369 | "bytemuck", 370 | ] 371 | 372 | [[package]] 373 | name = "simba" 374 | version = "0.9.0" 375 | source = "registry+https://github.com/rust-lang/crates.io-index" 376 | checksum = "b3a386a501cd104797982c15ae17aafe8b9261315b5d07e3ec803f2ea26be0fa" 377 | dependencies = [ 378 | "approx", 379 | "num-complex", 380 | "num-traits", 381 | "paste", 382 | "wide", 383 | ] 384 | 385 | [[package]] 386 | name = "syn" 387 | version = "2.0.66" 388 | source = "registry+https://github.com/rust-lang/crates.io-index" 389 | checksum = "c42f3f41a2de00b01c0aaad383c5a45241efc8b2d1eda5661812fda5f3cdcff5" 390 | dependencies = [ 391 | "proc-macro2", 392 | "quote", 393 | "unicode-ident", 394 | ] 395 | 396 | [[package]] 397 | name = "target-lexicon" 398 | version = "0.13.2" 399 | source = "registry+https://github.com/rust-lang/crates.io-index" 400 | checksum = "e502f78cdbb8ba4718f566c418c52bc729126ffd16baee5baa718cf25dd5a69a" 401 | 402 | [[package]] 403 | name = "typenum" 404 | version = "1.17.0" 405 | source = "registry+https://github.com/rust-lang/crates.io-index" 406 | checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" 407 | 408 | [[package]] 409 | name = "unicode-ident" 410 | version = "1.0.12" 411 | source = "registry+https://github.com/rust-lang/crates.io-index" 412 | checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" 413 | 414 | [[package]] 415 | name = "unindent" 416 | version = "0.2.3" 417 | source = "registry+https://github.com/rust-lang/crates.io-index" 418 | checksum = "c7de7d73e1754487cb58364ee906a499937a0dfabd86bcb980fa99ec8c8fa2ce" 419 | 420 | [[package]] 421 | name = "wide" 422 | version = "0.7.24" 423 | source = "registry+https://github.com/rust-lang/crates.io-index" 424 | checksum = "8a040b111774ab63a19ef46bbc149398ab372b4ccdcfd719e9814dbd7dfd76c8" 425 | dependencies = [ 426 | "bytemuck", 427 | "safe_arch", 428 | ] 429 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "_cfsem" 3 | version = "0.1.0" # Version is controlled by python project 4 | edition = "2024" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | [lib] 8 | name = "_cfsem" 9 | crate-type = ["cdylib"] 10 | 11 | [dependencies] 12 | pyo3 = "0.24.0" 13 | numpy = "0.24.0" # This must match pyo3 version! 14 | cfsem = "2.0.0" 15 | 16 | [profile.release] 17 | opt-level = 3 18 | lto = true 19 | codegen-units = 1 20 | overflow-checks = true 21 | 22 | [profile.dev] 23 | # Nearly full optimizations for debug builds to test for perf regressions 24 | # when using maturin develop 25 | opt-level = 3 26 | lto = true 27 | codegen-units = 1 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Commonwealth Fusion Systems 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cfsem 2 | 3 | [Docs - Rust](https://docs.rs/cfsem) | [Docs - Python](https://cfsem-py.readthedocs.io/) 4 | 5 | Quasi-steady electromagnetics including filamentized approximations, Biot-Savart, and Grad-Shafranov. 6 | 7 | ## Installation 8 | 9 | Requirements 10 | 11 | * Python 3.9-3.12 and pip 12 | * Don't worry about this: 13 | * This info provided for troubleshooting purposes: 14 | * If on an x86 processor, you will need a CPU that supports SSE through 4.1, AVX, and FMA. 15 | * This should be true on any modern machine. 16 | 17 | ```bash 18 | pip install cfsem 19 | ``` 20 | 21 | ## Development 22 | 23 | Requirements 24 | 25 | * [Rust](https://www.rust-lang.org/tools/install) 26 | 27 | To install in the active python environment, do 28 | 29 | ```bash 30 | pip install -e .[dev] 31 | ``` 32 | 33 | To build the Rust bindings only, do 34 | 35 | ```bash 36 | maturin develop --release 37 | ``` 38 | 39 | No part of installation requires root. If access issues are encountered, this can likely be resolved by using a virtual environment. 40 | 41 | Some computationally-expensive calculations are written in Rust. These calculations and their python bindings are installed from pre-built binaries when installing from pypi or compiled during local development installation, with no intervention from the user in either case. Symmetric bindings with docstrings are available in the `bindings.py` module and re-exported at the library level. 42 | 43 | To build with all of the optimizations available on your local machine, you can do: 44 | 45 | ```bash 46 | RUSTCFLAGS="-Ctarget-cpu=native" maturin develop --release 47 | pip install -e .[dev] 48 | ``` 49 | 50 | ## Contributing 51 | 52 | Contributions consistent with the goals and anti-goals of the package are welcome. 53 | 54 | Please make an issue ticket to discuss changes before investing significant time into a branch. 55 | 56 | Goals 57 | 58 | * Library-level functions and formulas 59 | * Comprehensive documentation including literature references, assumptions, and units-of-measure 60 | * Quantitative unit-testing of formulas 61 | * Performance (both speed and memory-efficiency) 62 | * Guide development of performance-sensitive functions with structured benchmarking 63 | * Cross-platform compatibility 64 | * Minimization of long-term maintenance overhead (both for the library, and for users of the library) 65 | * Semantic versioning 66 | * Automated linting and formatting tools 67 | * Centralized CI and toolchain configuration in as few files as possible 68 | 69 | Anti-Goals 70 | 71 | * Fanciness that increases environment complexity, obfuscates reasoning, or introduces platform restrictions 72 | * Brittle CI or toolchain processes that drive increased maintenance overhead 73 | * Application-level functionality (graphical interfaces, simulation frameworks, etc) 74 | 75 | ## License 76 | 77 | Licensed under the MIT license ([LICENSE](LICENSE) or http://opensource.org/licenses/MIT) . 78 | -------------------------------------------------------------------------------- /build_docs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Run the python examples that generate figures for the documentation. 4 | (cd examples && for f in *.py; do python "$f"; done) 5 | cp examples/*.png docs/python/example_outputs 6 | 7 | # Build the top-level documentation (including the python docs) 8 | if [[ -z "$READTHEDOCS_OUTPUT" ]]; then 9 | # This is not a readthedocs build 10 | mkdocs build; 11 | fi 12 | -------------------------------------------------------------------------------- /cfsem/__init__.py: -------------------------------------------------------------------------------- 1 | """Quasi-steady electromagnetics calcs""" 2 | 3 | from typing import Tuple 4 | import numpy as np 5 | from numpy.typing import NDArray 6 | from interpn import MulticubicRectilinear 7 | 8 | from cfsem.types import Array3xN 9 | 10 | from cfsem.bindings import ( 11 | flux_circular_filament, 12 | flux_density_linear_filament, 13 | flux_density_biot_savart, 14 | flux_density_circular_filament, 15 | gs_operator_order2, 16 | gs_operator_order4, 17 | inductance_piecewise_linear_filaments, 18 | filament_helix_path, 19 | rotate_filaments_about_path, 20 | vector_potential_linear_filament, 21 | vector_potential_circular_filament, 22 | flux_density_circular_filament_cartesian, 23 | mutual_inductance_circular_to_linear, 24 | flux_density_dipole, 25 | body_force_density_circular_filament_cartesian, 26 | body_force_density_linear_filament, 27 | ) 28 | 29 | from ._cfsem import ellipe, ellipk 30 | 31 | MU_0 = 4.0 * np.pi * 1e-7 * (1.0 + 5.5e-10) 32 | """ 33 | Vacuum permeability with slight correction from latest measurements per NIST CODATA 2019 (SP-961), 34 | https://www.physics.nist.gov/cuu/pdf/wall_2018.pdf . 35 | """ 36 | 37 | __all__ = [ 38 | "flux_circular_filament", 39 | "flux_density_biot_savart", 40 | "flux_density_linear_filament", 41 | "flux_density_circular_filament", 42 | "gs_operator_order2", 43 | "gs_operator_order4", 44 | "filament_helix_path", 45 | "inductance_piecewise_linear_filaments", 46 | "self_inductance_piecewise_linear_filaments", 47 | "mutual_inductance_piecewise_linear_filaments", 48 | "flux_density_ideal_solenoid", 49 | "self_inductance_lyle6", 50 | "mutual_inductance_of_circular_filaments", 51 | "mutual_inductance_of_cylindrical_coils", 52 | "filament_coil", 53 | "self_inductance_circular_ring_wien", 54 | "self_inductance_annular_ring", 55 | "self_inductance_distributed_axisymmetric_conductor", 56 | "ellipe", 57 | "ellipk", 58 | "rotate_filaments_about_path", 59 | "vector_potential_linear_filament", 60 | "vector_potential_circular_filament", 61 | "flux_density_circular_filament_cartesian", 62 | "mutual_inductance_circular_to_linear", 63 | "flux_density_dipole", 64 | "body_force_density_circular_filament_cartesian", 65 | "body_force_density_linear_filament", 66 | ] 67 | 68 | 69 | def self_inductance_piecewise_linear_filaments(xyzp: Array3xN) -> float: 70 | """ 71 | Estimate the self-inductance of one piecewise-linear current filament. 72 | 73 | Uses Neumann's Formula for the mutual inductance of arbitrary loops 74 | for non-self-pairings, zeroes-out the contributions from self-pairings 75 | to resolve the thin-filament self-inductance singularity, and replaces the 76 | segment self-inductance term with an analytic value from [3]. 77 | 78 | Assumes: 79 | 80 | * Thin, well-behaved filaments 81 | * Uniform current distribution within segments 82 | * Low frequency operation; no skin effect 83 | (which would reduce the segment self-field term) 84 | * Vacuum permeability everywhere 85 | * Each filament has a constant current in all segments 86 | (otherwise we need an inductance matrix) 87 | 88 | References: 89 | [1] “Inductance,” Wikipedia. Dec. 12, 2022. Accessed: Jan. 23, 2023. [Online]. 90 | Available: 91 | 92 | [2] F. E. Neumann, “Allgemeine Gesetze der inducirten elektrischen Ströme,” 93 | Jan. 1846, doi: [10.1002/andp.18461430103](https://doi.org/10.1002/andp.18461430103) 94 | 95 | [3] R. Dengler, “Self inductance of a wire loop as a curve integral,” 96 | AEM, vol. 5, no. 1, p. 1, Jan. 2016, doi: [10.7716/aem.v5i1.331](https://doi.org/10.7716/aem.v5i1.331) 97 | 98 | Args: 99 | xyzp: [m] 3xN point series describing the filament 100 | 101 | Returns: 102 | [H] Scalar self-inductance 103 | """ 104 | # Indexing numpy arrays here produces some `Any`-type hints and strips the element type 105 | # erroneously in the pyright output as of pyright 1.1.393. 106 | x, y, z = xyzp # type: ignore 107 | xyzfil = (x[:-1], y[:-1], z[:-1]) # type: ignore 108 | dlxyzfil = (x[1:] - x[:-1], y[1:] - y[:-1], z[1:] - z[:-1]) # type: ignore 109 | 110 | self_inductance = inductance_piecewise_linear_filaments( 111 | xyzfil, # type: ignore 112 | dlxyzfil, # type: ignore 113 | xyzfil, # type: ignore 114 | dlxyzfil, # type: ignore 115 | True, 116 | ) 117 | 118 | return self_inductance # [H] 119 | 120 | 121 | def mutual_inductance_piecewise_linear_filaments( 122 | xyz0: Array3xN, 123 | xyz1: Array3xN, 124 | ) -> float: 125 | """ 126 | Estimate the mutual inductance between two piecewise-linear current filaments. 127 | 128 | Uses Neumann's Formula for the mutual inductance of arbitrary loops, which is 129 | originally from [2] and can be found in a more friendly format on wikipedia. 130 | 131 | Assumes: 132 | 133 | * Thin, well-behaved filaments 134 | * Vacuum permeability everywhere 135 | * Each filament has a constant current in all segments 136 | (otherwise we need an inductance matrix) 137 | * All segments between the two filaments are distinct; no identical pairs 138 | 139 | References: 140 | [1] “Inductance,” Wikipedia. Dec. 12, 2022. Accessed: Jan. 23, 2023. [Online]. 141 | Available: 142 | 143 | [2] F. E. Neumann, “Allgemeine Gesetze der inducirten elektrischen Ströme,” 144 | Jan. 1846, doi: [10.1002/andp.18461430103](https://doi.org/10.1002/andp.18461430103) 145 | 146 | Args: 147 | xyz0: [m] 3xN point series describing the first filament 148 | xyz1: [m] 3xM point series describing the second filament 149 | 150 | Returns: 151 | [H] Scalar mutual inductance between the two filaments 152 | """ 153 | # Indexing numpy arrays here produces some `Any`-type hints and strips the element type 154 | # erroneously in the pyright output as of pyright 1.1.393. 155 | x0, y0, z0 = xyz0 # type: ignore 156 | xyzfil0 = (x0[:-1], y0[:-1], z0[:-1]) # type: ignore 157 | dlxyzfil0 = (x0[1:] - x0[:-1], y0[1:] - y0[:-1], z0[1:] - z0[:-1]) # type: ignore 158 | 159 | x1, y1, z1 = xyz1 # type: ignore 160 | xyzfil1 = (x1[:-1], y1[:-1], z1[:-1]) # type: ignore 161 | dlxyzfil1 = (x1[1:] - x1[:-1], y1[1:] - y1[:-1], z1[1:] - z1[:-1]) # type: ignore 162 | 163 | inductance = inductance_piecewise_linear_filaments( 164 | xyzfil0, # type: ignore 165 | dlxyzfil0, # type: ignore 166 | xyzfil1, # type: ignore 167 | dlxyzfil1, # type: ignore 168 | False, 169 | ) 170 | 171 | return inductance # [H] 172 | 173 | 174 | def flux_density_ideal_solenoid( 175 | current: float, num_turns: float, length: float 176 | ) -> float: 177 | """ 178 | Axial B-field on centerline of an ideal (infinitely long) solenoid. 179 | 180 | This calc converges reasonably well for coil L/D > 20. 181 | 182 | Args: 183 | current: [A] solenoid current 184 | num_turns: [#] number of conductor turns 185 | length: [m] length of winding pack 186 | 187 | Returns: 188 | [T] B-field on axis (in the direction aligned with the axis) 189 | """ 190 | b_on_axis = MU_0 * num_turns * current / length 191 | return b_on_axis # [T] 192 | 193 | 194 | def self_inductance_lyle6(r: float, dr: float, dz: float, n: float) -> float: 195 | """ 196 | Self-inductance of a cylindrically-symmetric coil of rectangular 197 | cross-section, estimated to 6th order. 198 | 199 | This estimate is viable up to an L/D of about 1.5, above which it 200 | rapidly accumulates error and eventually produces negative values. 201 | 202 | References: 203 | [1] T. R. Lyle, 204 | “IX. On the self-inductance of circular coils of rectangular section,” 205 | Philosophical Transactions of the Royal Society of London. 206 | Series A, Containing Papers of a Mathematical or Physical Character, 207 | vol. 213, no. 497-508, pp. 421-435, Jan. 1914, doi: [10.1098/rsta.1914.0009](https://doi.org/10.1098/rsta.1914.0009) 208 | 209 | Args: 210 | r: [m] radius, coil center 211 | dr: [m] radial width of coil 212 | dz: [m] cylindrical height of coil 213 | n: number of turns 214 | 215 | Returns: 216 | [H] self-inductance 217 | """ 218 | 219 | assert dr < 2.0 * r, "Axisymmetric coil edges can't extend to negative R" 220 | assert dz / r <= 3.5, "Coil geometry outside domain of validity of Lyle's formula" 221 | 222 | # Guarantee 64-bit floats needed for 6th-order shape term 223 | a = np.float64(r) 224 | b = np.float64(dz) 225 | c = np.float64(dr) 226 | 227 | # Build up reusable terms for calculation of shape parameter 228 | d = np.sqrt(b**2 + c**2) # [m] diagonal length 229 | u = ((b / c) ** 2) * 2 * np.log(d / b) 230 | v = ((c / b) ** 2) * 2 * np.log(d / c) 231 | w = (b / c) * np.arctan(c / b) 232 | ww = (c / b) * np.arctan(b / c) 233 | 234 | bd2 = (b / d) ** 2 235 | cd2 = (c / d) ** 2 236 | da2 = (d / a) ** 2 237 | ml = np.log(8 * a / d) 238 | 239 | f = ( 240 | ml 241 | + (1 + u + v - 8 * (w + ww)) / 12.0 # 0th order in d/a 242 | + ( 243 | da2 244 | * ( 245 | cd2 * (221 + 60 * ml - 6 * v) 246 | + 3 * bd2 * (69 + 60 * ml + 10 * u - 64 * w) 247 | ) 248 | ) 249 | / 5760.0 # 2nd order 250 | + ( 251 | da2**2 252 | * ( 253 | 2 * cd2**2 * (5721 + 3080 * ml - 345 * v) 254 | + 5 * bd2 * cd2 * (407 + 5880 * ml + 6720 * u - 14336 * w) 255 | - 10 * bd2**2 * (3659 + 2520 * ml + 805 * u - 6144 * w) 256 | ) 257 | ) 258 | / 2.58048e7 # 4th order 259 | + ( 260 | da2**3 261 | * ( 262 | 3 * cd2**3 * (4308631 + 86520 * ml - 10052 * v) 263 | - 14 * bd2**2 * cd2 * (617423 + 289800 * ml + 579600 * u - 1474560 * w) 264 | + 21 * bd2**3 * (308779 + 63000 * ml + 43596 * u - 409600 * w) 265 | + 42 * bd2 * cd2**2 * (-8329 + 46200 * ml + 134400 * u - 172032 * w) 266 | ) 267 | ) 268 | / 1.73408256e10 # 6th order 269 | ) # [nondim] shape parameter 270 | 271 | self_inductance = MU_0 * (n**2) * a * f 272 | 273 | assert self_inductance >= 0.0, "Coil geometry outside domain of validity of Lyle's formula" 274 | 275 | return self_inductance # [H] 276 | 277 | 278 | def mutual_inductance_of_circular_filaments( 279 | rzn1: NDArray, rzn2: NDArray, par: bool = True 280 | ) -> float: 281 | """ 282 | Analytic mutual inductance between a pair of ideal cylindrically-symmetric coaxial filaments. 283 | 284 | This is equivalent to taking the flux produced by each circular filament from 285 | either collection of filaments to the other. Mutual inductance is reflexive, 286 | so the order of the inputs is not important. 287 | 288 | Args: 289 | rzn1 (array): 3x1 array (r [m], z [m], n []) coordinates and number of turns 290 | rzn2 (array): 3x1 array (r [m], z [m], n []) coordinates and number of turns 291 | par: Whether to use CPU parallelism 292 | 293 | Returns: 294 | float: [H] mutual inductance 295 | """ 296 | 297 | m = mutual_inductance_of_cylindrical_coils( 298 | rzn1.reshape((3, 1)), rzn2.reshape((3, 1)), par 299 | ) 300 | 301 | return m # [H] 302 | 303 | 304 | def mutual_inductance_of_cylindrical_coils( 305 | f1: NDArray, f2: NDArray, par: bool = True 306 | ) -> float: 307 | """ 308 | Analytical mutual inductance between two coaxial collections of ideal filaments. 309 | 310 | Each collection typically represents a discretized "real" cylindrically-symmetric 311 | coil of rectangular cross-section, but could have any cross-section as long as it 312 | maintains cylindrical symmetry. 313 | 314 | Args: 315 | f1: 3 x N array of filament definitions like (r [m], z [m], n []) 316 | f2: 3 x N array of filament definitions like (r [m], z [m], n []) 317 | par: Whether to use CPU parallelism 318 | 319 | Returns: 320 | [H] mutual inductance of the two discretized coils 321 | """ 322 | r1, z1, n1 = f1 323 | r2, z2, n2 = f2 324 | 325 | # Using n2 as the current per filament is equivalent to examining a 1A reference current, 326 | # which gives us the flux per amp (inductance) 327 | m = np.sum( 328 | n1 * flux_circular_filament(n2, r2, z2, r1, z1, par) 329 | ) # [H] total mutual inductance 330 | 331 | return m # [H] 332 | 333 | 334 | def filament_coil( 335 | r: float, z: float, w: float, h: float, nt: float, nr: int, nz: int 336 | ) -> NDArray: 337 | """ 338 | Create an array of filaments from coil cross-section, evenly spaced 339 | _inside_ the winding pack. No filaments are coincident with the coil surface. 340 | 341 | Args: 342 | r: [m] radius, coil center 343 | z: [m] axial position, coil center 344 | w: [m] width of coil pack 345 | h: [m] height of coil pack 346 | nt: turns 347 | nr: radial discretizations 348 | nz: axial discretizations 349 | 350 | Returns: 351 | (nr*nz) x 3, (r,z,n) of each filament 352 | """ 353 | 354 | # Build a 2D mesh of points evenly spaced in the interior of the bounding rectangle 355 | rs = np.linspace(r - w * (nr - 1) / nr / 2, r + w * (nr - 1) / nr / 2, nr) # [m] 356 | zs = np.linspace(z - h * (nz - 1) / nz / 2, z + h * (nz - 1) / nz / 2, nz) # [m] 357 | rmesh, zmesh = np.meshgrid(rs, zs, indexing="ij") # [m] 358 | 359 | # Number of turns attributed to each point is not necessarily an integer 360 | n = np.full_like(rmesh.flatten(), float(nt) / (nr * nz)) 361 | 362 | # Pack filament locations and number of turns into an array together 363 | filaments = np.dstack([rmesh.flatten(), zmesh.flatten(), n]).reshape(nr * nz, 3) 364 | 365 | return filaments # [m], [m], [dimensionless] 366 | 367 | 368 | def self_inductance_circular_ring_wien( 369 | major_radius: NDArray, minor_radius: NDArray 370 | ) -> NDArray: 371 | """ 372 | Wien's formula for the self-inductance of a circular ring 373 | with thin circular cross section. 374 | 375 | Uses equation 7 from reference [1]. 376 | 377 | References: 378 | [1] E Rosa and L Cohen, "On the Self-Inductance of Circles," 379 | Bulletin of the Bureau of Standards, 1908. 380 | [Online]. Available: 381 | 382 | 383 | Args: 384 | major_radius: [m] Major radius of the ring. 385 | minor_radius: [m] Radius of the ring's cross section. 386 | 387 | Returns: 388 | [H] self-inductance 389 | """ 390 | ar = major_radius / minor_radius # [], a / rho, dimensionless 391 | ra2 = (minor_radius / major_radius) ** 2 # [], (rho / a)^2, dimensionless 392 | # Equation 7 in Rosa & Cohen lacks the factor of 1e-7 393 | # because it is not in SI base units. 394 | self_inductance = ( 395 | MU_0 * major_radius * ((1 + 0.125 * ra2) * np.log(8 * ar) - 0.0083 * ra2 - 1.75) 396 | ) # [H] 397 | return self_inductance # [H] 398 | 399 | 400 | def self_inductance_distributed_axisymmetric_conductor( 401 | current: float, 402 | grid: Tuple[NDArray, NDArray], 403 | mesh: Tuple[NDArray, NDArray], 404 | b_part: Tuple[NDArray, NDArray], 405 | psi_part: NDArray, 406 | mask: NDArray, 407 | edge_path: Tuple[NDArray, NDArray], 408 | ) -> Tuple[float, float, float]: 409 | """ 410 | Calculation of a distributed conductor's self-inductance from two components: 411 | 412 | * External inductance: the portion related to the poloidal flux exactly at the conductor edge 413 | * Internal inductance: the portion related to the poloidal magnetic field inside the conductor, 414 | where the filamentized method used for coils does not apply due to the parallel arrangement 415 | and variable current density. 416 | 417 | Note: the B-field and flux inputs are _not_ the total from all sources - they are only the 418 | contribution from the distributed conductor under examination. 419 | 420 | This calculation was developed for use with tokamak plasmas, but applies similarly to 421 | other kinds of distributed axisymmetric conductor. 422 | 423 | Assumptions: 424 | 425 | * Cylindrically-symmetric, distributed, single-winding, contiguous conductor 426 | * No high-magnetic-permeability materials in the vicinity 427 | * Isopotential on the edge contour 428 | * This is slightly less restrictive than isopotential on the section, 429 | but notably does _not_ allow the calc to be used with, for example, 430 | large, shell conductors where different regions are meaningfully 431 | independent of each other. 432 | * Conductor interior does not touch the edge of the computational domain 433 | * At least one grid cell of padding is needed to support finite differences 434 | 435 | References: 436 | [1] S. Ejima, R. W. Callis, J. L. Luxon, R. D. Stambaugh, T. S. Taylor, and J. C. Wesley, 437 | “Volt-second analysis and consumption in Doublet III plasmas,” 438 | Nucl. Fusion, vol. 22, no. 10, pp. 1313-1319, Oct. 1982, 439 | doi: [10.1088/0029-5515/22/10/006](https://doi.org/10.1088/0029-5515/22/10/006) 440 | 441 | [2] J. A. Romero and J.-E. Contributors, “Plasma internal inductance dynamics in a tokamak,” 442 | arXiv.org. Accessed: Dec. 21, 2023. [Online]. Available: https://arxiv.org/abs/1009.1984v1 443 | doi: [10.1088/0029-5515/50/11/115002](https://doi.org/10.1088/0029-5515/50/11/115002) 444 | 445 | [3] J. T. Wai and E. Kolemen, “GSPD: An algorithm for time-dependent tokamak equilibria design.” 446 | arXiv, Jun. 22, 2023. Accessed: Sep. 15, 2023. [Online]. Available: https://arxiv.org/abs/2306.13163 447 | doi: [10.48550/arXiv.2306.13163](https://doi.org/10.48550/arXiv.2306.13163) 448 | 449 | Args: 450 | current: [A] total toroidal current in this conductor 451 | grid: [m] (1 X nr) grids of (R coords, Z coords) 452 | mesh: [m] (nr X nz) meshgrids of (R coords, Z coords) 453 | b_part: [T] (nr X nz) Flux density (R-component, Z-component) due to this conductor 454 | psi_part: [V-s] or [T-m^2] (nr X nz) this conductor's poloidal flux field 455 | mask: (nr X nz) positive mask of the conductor's interior region 456 | edge_path: [m] (2 x N) closed (r, z) path along conductor edge 457 | 458 | Returns: 459 | (Lt, Li, Le) Total, internal, and external self-inductance components 460 | """ 461 | # Unpack 462 | rgrid, zgrid = grid # [m] 463 | rmesh, zmesh = mesh # [m] 464 | br, bz = b_part # [T] 465 | 466 | # Set up 467 | nr = rgrid.size 468 | nz = zgrid.size 469 | psi_interpolator = MulticubicRectilinear.new([rgrid, zgrid], psi_part.flatten()) 470 | 471 | # Same-length diffs assuming last grid cell is the same size 472 | # as the previous one. In general the conductor can't touch the 473 | # edge of the computational domain without breaking the solver, 474 | # so it's ok to allow some potential error at the last index. 475 | drmesh = np.zeros_like(rmesh) # [m] 476 | drmesh[: nr - 1, :] = np.diff(rmesh, axis=0) 477 | drmesh[-1, :] = drmesh[-2, :] 478 | 479 | dzmesh = np.zeros_like(zmesh) # [m] 480 | dzmesh[:, : nz - 1] = np.diff(zmesh, axis=1) 481 | dzmesh[:, -1] = dzmesh[:, -2] 482 | 483 | # Toroidal volume of each cell in the mesh. 484 | # 485 | # Ideally we'd use a two-sided difference for 486 | # calculating dr and dz in order to properly handle cell volume 487 | # for nonuniform grids, but (1) that's a lot of extra array handling 488 | # for minimal real benefit and (2) the grid is almost always uniform 489 | volmesh = 2.0 * np.pi * rmesh * drmesh * dzmesh # [m^3] 490 | 491 | # Stored poloidal magnetic energy inside the conductor volume. 492 | # 493 | # Because E ~ B^2 and B = sqrt(Br^2 + Bz^2 + Btor^2) -> B^2 = Br^2 + Bz^2 + Btor^2, 494 | # the components of magnetic energy (and induction) due to a current on different axes 495 | # are separable, and we can ignore the toroidal field entirely when treating the 496 | # poloidal inductance. 497 | # 498 | # That doesn't mean there isn't store energy related to the conductor's toroidal field, 499 | # only that it can be separated from the poloidal inductance. 500 | wmag_pol = (1.0 / (2.0 * MU_0)) * np.sum((br**2 + bz**2) * volmesh * mask) # [J] 501 | 502 | # Internal inductance. 503 | # 504 | # Because E = (1/2) * L * I^2, we can superpose multiple inductance terms 505 | # for a given current-carrying element, and say E = (1/2) * (Li + Le) * I^2 . 506 | # 507 | # Now that we have the internal stored magnetic energy, we can calculate 508 | # the part of the self-inductance related to that energy directly. 509 | internal_inductance = 2.0 * wmag_pol / current**2 # [H] 510 | 511 | # External inductance. 512 | # 513 | # This is a hack based on Poynting's Theorem relating surface conditions to 514 | # magnetic energy. 515 | # 516 | # Do weighted average of flux along the edge contour 517 | # to adjust for the length of individual segments 518 | edge_dr = np.diff(edge_path[0]) # [m] 519 | edge_dz = np.diff(edge_path[1]) # [m] 520 | edge_dl = (edge_dr**2 + edge_dz**2) ** 0.5 # [m] length of each segment 521 | edge_length = np.sum(edge_dl) # [m] total length of conductor limit contour 522 | edge_psi = psi_interpolator.eval([x[:-1] for x in edge_path]) # [V-s] 523 | edge_psi_mean = np.sum(edge_psi * edge_dl) / edge_length # [V-s] 524 | # Take the inductance 525 | external_inductance = edge_psi_mean / current # [H] 526 | 527 | # Total inductance. 528 | total_inductance = internal_inductance + external_inductance # [H] 529 | 530 | return ( 531 | float(total_inductance), 532 | float(internal_inductance), 533 | float(external_inductance), 534 | ) 535 | 536 | 537 | def self_inductance_annular_ring(r: float, a: float, b: float) -> float: 538 | """ 539 | Low-frequency self-inductance of a thick-walled tube bent in a circle. 540 | 541 | Uses Wien's method, per the 1912 NIST handbook [1], Eqn. 64 on pg. 112, 542 | with a correction to a misprint in term 5 and a unit conversion factor 543 | in the final expression. 544 | 545 | This is an approximation that drops terms of order higher than `(a/r)^2` 546 | and `(b/r)^2`. 547 | 548 | References: 549 | [1] E. B. Rosa and F. W. Grover, “Formulas and tables for the calculation of mutual and self-inductance (Revised),” 550 | BULL. NATL. BUR. STAND., vol. 8, no. 1, p. 1, Jan. 1912, 551 | doi: [10.6028/bulletin.185](https://doi.org/10.6028/bulletin.185) 552 | 553 | Args: 554 | r: [m] major radius of loop 555 | a: [m] inner minor radius (tube inside radius) 556 | b: [m] outer minor radius (tube outside radius) 557 | 558 | Returns: 559 | [H] self-inductance [H] 560 | """ 561 | 562 | if not ((r > 0.0) and (a > 0.0) and (b > 0.0)): 563 | raise ValueError("All radii must be positive and nonzero.") 564 | if not (a < b): 565 | raise ValueError("Inner minor radius must be less than outer minor radius.") 566 | if not (b < r): 567 | raise ValueError("Outer minor radius must be less than major radius.") 568 | 569 | term1 = (1.0 + (a**2 + b**2) / (8.0 * r**2)) * np.log(8.0 * r / b) 570 | term2 = -1.75 + (2.0 * b**2 + a**2) / (32.0 * r**2) 571 | term3 = -0.5 * a**2 / (b**2 - a**2) 572 | term4 = (a**4 / ((b**2 - a**2) ** 2)) * (1.0 + a**2 / (8.0 * r**2)) * np.log(b / a) 573 | term5 = -(a**4 + a**2 * b**2 + b**4) / (48.0 * r**2 * (b**2 - a**2)) 574 | 575 | self_inductance = MU_0 * r * (term1 + term2 + term3 + term4 + term5) # [H] 576 | 577 | return self_inductance # [H] 578 | -------------------------------------------------------------------------------- /cfsem/bindings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Symmetric bindings for backend calcs. 3 | 4 | This fulfills the function of typing stubs, while also guaranteeing arrays are 5 | passed as contiguous and reallocating into contiguous inputs if necessary. 6 | """ 7 | 8 | from cfsem.types import Array3xN 9 | 10 | from numpy import ascontiguousarray, float64, zeros_like 11 | from numpy.typing import NDArray 12 | 13 | from ._cfsem import flux_circular_filament as em_flux_circular_filament 14 | from ._cfsem import flux_density_linear_filament as em_flux_density_linear_filament 15 | from ._cfsem import flux_density_circular_filament as em_flux_density_circular_filament 16 | from ._cfsem import ( 17 | flux_density_circular_filament_cartesian as em_flux_density_circular_filament_cartesian, 18 | ) 19 | from ._cfsem import ( 20 | mutual_inductance_circular_to_linear as em_mutual_inductance_circular_to_linear, 21 | ) 22 | from ._cfsem import ( 23 | body_force_density_circular_filament_cartesian as em_body_force_density_circular_filament_cartesian, 24 | ) 25 | 26 | from ._cfsem import gs_operator_order2 as em_gs_operator_order2 27 | from ._cfsem import gs_operator_order4 as em_gs_operator_order4 28 | from ._cfsem import ( 29 | inductance_piecewise_linear_filaments as em_inductance_piecewise_linear_filaments, 30 | ) 31 | from ._cfsem import filament_helix_path as em_filament_helix_path 32 | from ._cfsem import rotate_filaments_about_path as em_rotate_filaments_about_path 33 | from ._cfsem import ( 34 | vector_potential_circular_filament as em_vector_potential_circular_filament, 35 | ) 36 | from ._cfsem import ( 37 | vector_potential_linear_filament as em_vector_potential_linear_filament, 38 | ) 39 | from ._cfsem import ( 40 | body_force_density_linear_filament as em_body_force_density_linear_filament, 41 | ) 42 | 43 | from ._cfsem import flux_density_dipole as em_flux_density_dipole 44 | 45 | 46 | def flux_circular_filament( 47 | ifil: NDArray[float64], 48 | rfil: NDArray[float64], 49 | zfil: NDArray[float64], 50 | rprime: NDArray[float64], 51 | zprime: NDArray[float64], 52 | par: bool = True, 53 | ) -> NDArray[float64]: 54 | """ 55 | Flux contributions from some circular filaments to some observation points, 56 | which happens to be the Green's function for the Grad-Shafranov solve. 57 | 58 | This represents the integral of $\\vec{B} \\cdot \\hat{n} \\, dA$ from the z-axis to each 59 | (`rprime`, `zprime`) observation location with $\\hat{n}$ oriented parallel to the z-axis. 60 | 61 | A convenient interpretation of the flux is as the mutual inductance 62 | between a circular filament at (`rfil`, `zfil`) and a second circular 63 | filament at (`rprime`, `zprime`); this can be used to get the mutual inductance 64 | between two filamentized coils as the sum of flux contributions between each coil's filaments. 65 | Because mutual inductance is reflexive, the order of the coils can be reversed and 66 | the same result is obtained. 67 | 68 | Args: 69 | ifil: [A] filament current 70 | rfil: [m] filament R-coord 71 | zfil: [m] filament Z-coord 72 | rprime: [m] Observation point R-coord 73 | zprime: [m] Observation point Z-coord 74 | par: Whether to use CPU parallelism 75 | 76 | Returns: 77 | [Wb] or [T-m^2] or [V-s] psi, poloidal flux at each observation point 78 | """ 79 | ifil, rfil, zfil = _3tup_contig((ifil, rfil, zfil)) 80 | rprime, zprime = _2tup_contig((rprime, zprime)) 81 | psi = em_flux_circular_filament(ifil, rfil, zfil, rprime, zprime, par) 82 | return psi # [Wb] or [T-m^2] or [V-s] 83 | 84 | 85 | def vector_potential_circular_filament( 86 | ifil: NDArray[float64], 87 | rfil: NDArray[float64], 88 | zfil: NDArray[float64], 89 | rprime: NDArray[float64], 90 | zprime: NDArray[float64], 91 | par: bool = True, 92 | ) -> NDArray[float64]: 93 | """ 94 | Vector potential contributions from some circular filaments to some observation points. 95 | Off-axis A_phi component for a circular current filament in vacuum. 96 | 97 | The vector potential of a loop has zero r- and z- components due to symmetry, 98 | and does not vary in the phi-direction. 99 | 100 | Note that to recover the B-field as the curl of A, the curl operator for cylindrical 101 | coordinates must be used with the output of this function incorporated into a full 102 | 3D A-field like [A_r, A_phi, A_z]. 103 | 104 | References: 105 | [1] J. C. Simpson, J. E. Lane, C. D. Immer, R. C. Youngquist, and T. Steinrock, 106 | “Simple Analytic Expressions for the Magnetic Field of a Circular Current Loop,” 107 | Jan. 01, 2001. Accessed: Sep. 06, 2022. [Online]. Available: 108 | 109 | Args: 110 | ifil: [A] filament current 111 | rfil: [m] filament R-coord 112 | zfil: [m] filament Z-coord 113 | rprime: [m] Observation point R-coord 114 | zprime: [m] Observation point Z-coord 115 | par: Whether to use CPU parallelism 116 | 117 | Returns: 118 | [Wb/m] or [V-s/m] a_phi, vector potential in the toroidal direction 119 | """ 120 | ifil, rfil, zfil = _3tup_contig((ifil, rfil, zfil)) 121 | rprime, zprime = _2tup_contig((rprime, zprime)) 122 | a_phi = em_vector_potential_circular_filament(ifil, rfil, zfil, rprime, zprime, par) 123 | return a_phi # [Wb/m] or [V-s/m] 124 | 125 | 126 | def flux_density_circular_filament( 127 | ifil: NDArray[float64], 128 | rfil: NDArray[float64], 129 | zfil: NDArray[float64], 130 | rprime: NDArray[float64], 131 | zprime: NDArray[float64], 132 | par: bool = True, 133 | ) -> tuple[NDArray[float64], NDArray[float64]]: 134 | """ 135 | Off-axis Br,Bz components for a circular current filament in vacuum. 136 | 137 | Near-exact formula (except numerically-evaluated elliptic integrals) 138 | See eqns. 12, 13 pg. 34 in [1], eqn 9.8.7 in [2], and all of [3]. 139 | 140 | Note the formula for Br as given by [1] is incorrect and does not satisfy the 141 | constraints of the calculation without correcting by a factor of ($z / r$). 142 | 143 | References: 144 | [1] D. B. Montgomery and J. Terrell, 145 | “Some Useful Information For The Design Of Aircore Solenoids, 146 | Part I. Relationships Between Magnetic Field, Power, Ampere-Turns 147 | And Current Density. Part II. Homogeneous Magnetic Fields,” 148 | Massachusetts Inst. Of Tech. Francis Bitter National Magnet Lab, Cambridge, MA, 149 | Nov. 1961. Accessed: May 18, 2021. [Online]. 150 | Available: 151 | 152 | [2] 8.02 Course Notes. Available: 153 | 154 | 155 | [3] Eric Dennyson, "Magnet Formulas". Available: 156 | 157 | 158 | Args: 159 | ifil: [A] filament current 160 | rfil: [m] filament R-coord 161 | zfil: [m] filament Z-coord 162 | rprime: [m] Observation point R-coord 163 | zprime: [m] Observation point Z-coord 164 | par: Whether to use CPU parallelism 165 | 166 | Returns: 167 | [T] (Br, Bz) flux density components 168 | """ 169 | ifil, rfil, zfil = _3tup_contig((ifil, rfil, zfil)) 170 | rprime, zprime = _2tup_contig((rprime, zprime)) 171 | br, bz = em_flux_density_circular_filament(ifil, rfil, zfil, rprime, zprime, par) 172 | return br, bz # [T] 173 | 174 | 175 | def flux_density_linear_filament( 176 | xyzp: Array3xN, 177 | xyzfil: Array3xN, 178 | dlxyzfil: Array3xN, 179 | ifil: NDArray[float64], 180 | par: bool = True, 181 | ) -> Array3xN: 182 | """ 183 | Biot-Savart law calculation for B-field contributions from many filament segments 184 | to many observation points. 185 | 186 | Args: 187 | xyzp: [m] x,y,z coords of observation points 188 | xyzfil: [m] x,y,z coords of current filament origins (start of segment) 189 | dlxyzfil: [m] x,y,z length delta of current filaments 190 | ifil: [A] current in each filament segment 191 | par: Whether to use CPU parallelism 192 | 193 | Returns: 194 | [T] (Bx, By, Bz) magnetic flux density at observation points 195 | """ 196 | xyzp = _3tup_contig(xyzp) 197 | xyzfil = _3tup_contig(xyzfil) 198 | dlxyzfil = _3tup_contig(dlxyzfil) 199 | ifil = ascontiguousarray(ifil).flatten() 200 | return em_flux_density_linear_filament(xyzp, xyzfil, dlxyzfil, ifil, par) 201 | 202 | 203 | flux_density_biot_savart = flux_density_linear_filament # For backwards-compatibility 204 | 205 | 206 | def vector_potential_linear_filament( 207 | xyzp: Array3xN, 208 | xyzfil: Array3xN, 209 | dlxyzfil: Array3xN, 210 | ifil: NDArray[float64], 211 | par: bool = True, 212 | ) -> Array3xN: 213 | """ 214 | Vector potential calculation for A-field contribution from many current filament 215 | segments to many observation points. 216 | 217 | Args: 218 | xyzp: [m] x,y,z coords of observation points 219 | xyzfil: [m] x,y,z coords of current filament origins (start of segment) 220 | dlxyzfil: [m] x,y,z length delta of current filaments 221 | ifil: [A] current in each filament segment 222 | par: Whether to use CPU parallelism 223 | 224 | Returns: 225 | [Wb/m] or [V-s/m] (Ax, Ay, Az) magnetic vector potential at observation points 226 | """ 227 | xyzp = _3tup_contig(xyzp) 228 | xyzfil = _3tup_contig(xyzfil) 229 | dlxyzfil = _3tup_contig(dlxyzfil) 230 | ifil = ascontiguousarray(ifil).flatten() 231 | return em_vector_potential_linear_filament(xyzp, xyzfil, dlxyzfil, ifil, par) 232 | 233 | 234 | def inductance_piecewise_linear_filaments( 235 | xyzfil0: Array3xN, 236 | dlxyzfil0: Array3xN, 237 | xyzfil1: Array3xN, 238 | dlxyzfil1: Array3xN, 239 | self_inductance: bool = False, 240 | ) -> float: 241 | """ 242 | Estimate the mutual inductance between two piecewise-linear current filaments, 243 | or estimate self-inductance by passing the same filaments twice and setting 244 | `self_inductance = True`. 245 | 246 | It may be easier to use wrappers of this function that are specialized for self- and mutual-inductance 247 | calculations: 248 | [`self_inductance_piecewise_linear_filaments`][cfsem.self_inductance_piecewise_linear_filaments] 249 | and [`mutual_inductance_piecewise_linear_filaments`][cfsem.mutual_inductance_piecewise_linear_filaments]. 250 | 251 | Uses Neumann's Formula for the mutual inductance of arbitrary loops, which is 252 | originally from [2] and can be found in a more friendly format on wikipedia. 253 | 254 | When self_inductance flag is set, zeroes-out the contributions from self-pairings 255 | to resolve the thin-filament self-inductance singularity and replaces the 256 | segment self-inductance term with an analytic value from [3]. 257 | 258 | Assumes: 259 | 260 | * Thin, well-behaved filaments 261 | * Uniform current distribution within segments 262 | * Low frequency operation; no skin effect 263 | (which would reduce the segment self-field term) 264 | * Vacuum permeability everywhere 265 | * Each filament has a constant current in all segments 266 | (otherwise we need an inductance matrix) 267 | 268 | References: 269 | [1] “Inductance,” Wikipedia. Dec. 12, 2022. Accessed: Jan. 23, 2023. [Online]. 270 | Available: 271 | 272 | [2] F. E. Neumann, “Allgemeine Gesetze der inducirten elektrischen Ströme,” 273 | Jan. 1846, doi: [10.1002/andp.18461430103](https://doi.org/10.1002/andp.18461430103) 274 | 275 | [3] R. Dengler, “Self inductance of a wire loop as a curve integral,” 276 | AEM, vol. 5, no. 1, p. 1, Jan. 2016, doi: [10.7716/aem.v5i1.331](https://doi.org/10.7716/aem.v5i1.331) 277 | 278 | Args: 279 | xyzfil0: [m] Nx3 point series describing the filament origins 280 | dlxyzfil0: [m] Nx3 length vector of each filament 281 | xyzfil1: [m] Nx3 point series describing the filament origins 282 | dlxyzfil1: [m] Nx3 length vector of each filament 283 | self_inductance: Whether this is being used as a self-inductance calc 284 | 285 | Returns: 286 | [H] Scalar inductance 287 | """ 288 | xyzfil0 = _3tup_contig(xyzfil0) 289 | dlxyzfil0 = _3tup_contig(dlxyzfil0) 290 | xyzfil1 = _3tup_contig(xyzfil1) 291 | dlxyzfil1 = _3tup_contig(dlxyzfil1) 292 | 293 | return em_inductance_piecewise_linear_filaments( 294 | xyzfil0, dlxyzfil0, xyzfil1, dlxyzfil1, self_inductance 295 | ) 296 | 297 | 298 | def gs_operator_order2(rs: NDArray[float64], zs: NDArray[float64]) -> Array3xN: 299 | """Build second-order Grad-Shafranov operator in triplet format. 300 | Assumes regular grid spacing. 301 | 302 | Args: 303 | rs: [m] r-coordinates of finite difference grid 304 | zs: [m] z-coordinates of finite difference grid 305 | 306 | Returns: 307 | Differential operator as triplet format sparse matrix 308 | """ 309 | rs, zs = _2tup_contig((rs, zs)) 310 | return em_gs_operator_order2(rs, zs) 311 | 312 | 313 | def gs_operator_order4(rs: NDArray[float64], zs: NDArray[float64]) -> Array3xN: 314 | """ 315 | Build fourth-order Grad-Shafranov operator in triplet format. 316 | Assumes regular grid spacing. 317 | 318 | Args: 319 | rs: [m] r-coordinates of finite difference grid 320 | zs: [m] z-coordinates of finite difference grid 321 | 322 | Returns: 323 | Differential operator as triplet format sparse matrix 324 | """ 325 | rs, zs = _2tup_contig((rs, zs)) 326 | return em_gs_operator_order4(rs, zs) 327 | 328 | 329 | def filament_helix_path( 330 | path: Array3xN, 331 | helix_start_offset: tuple[float, float, float], 332 | twist_pitch: float, 333 | angle_offset: float, 334 | ) -> NDArray[float64]: 335 | """ 336 | Filamentize a helix about an arbitrary piecewise-linear path. 337 | 338 | Assumes angle between sequential path segments is small and will fail 339 | if that angle approaches or exceeds 90 degrees. 340 | 341 | The helix initial position vector, helix_start_offset, must be in a plane normal to 342 | the first path segment in order to produce good results. If it is not in-plane, 343 | it will be projected on to that plane and then scaled to the magnitude of its 344 | original length s.t. the distance from the helix to the path center is preserved 345 | but its orientation is not. 346 | 347 | Description of the method: 348 | 349 | 1. Translate [filament segment n-1] to the base of [path segment n] 350 | and call it [filament segment n] 351 | 2. Take cross product of [path segment n] with [path segment n-1] 352 | 3. Rotate [filament segment n] segment about the axis of that cross product 353 | to bring it into the plane defined by [path segment n] as a normal vector 354 | 4. Rotate [filament seg. n] about [path seg. n] to continue the helix orbit 355 | 356 | Args: 357 | path: [m] 3xN Centerline points 358 | helix_start_offset: [m] (3x1) Initial position of helix rel. to centerline path 359 | twist_pitch: [m] (scalar) Centerline length per helix orbit 360 | angle_offset: [rad] (scalar) Initial rotation offset about centerline 361 | 362 | Returns: 363 | [m] 3xN array of points on the helix that twists around the path 364 | """ 365 | 366 | # Make sure input is contiguous, reallocating only if necessary 367 | path = ascontiguousarray(path) 368 | 369 | # Allocate output 370 | helix = zeros_like(path) # [m] 371 | 372 | # Calculate, mutating output 373 | em_filament_helix_path( 374 | (*path,), 375 | helix_start_offset, 376 | twist_pitch, 377 | angle_offset, 378 | (*helix,), 379 | ) 380 | 381 | return helix # [m] 382 | 383 | 384 | def rotate_filaments_about_path( 385 | path: Array3xN, angle_offset: float, fils: Array3xN 386 | ) -> NDArray[float64]: 387 | """ 388 | Rotate a path of point about another path. 389 | 390 | Intended for rotating a helix generated by [`filament_helix_path`][cfsem.filament_helix_path] 391 | about the centerline that was used to generate it. 392 | 393 | Args: 394 | path: [m] x,y,z Centerline points 395 | angle_offset: [rad] (scalar) Initial rotation offset about centerline 396 | fils: [m] x,y,z Filaments to rotate around centerline 397 | 398 | Returns: 399 | [m] 3xN array of points on the helix that twists around the path 400 | """ 401 | 402 | # Make sure input is contiguous, reallocating only if necessary 403 | path = ascontiguousarray(path) 404 | 405 | new_fils = ascontiguousarray(fils).copy() 406 | 407 | em_rotate_filaments_about_path( 408 | (*path,), 409 | angle_offset, 410 | (*new_fils,), 411 | ) 412 | 413 | return new_fils # [m] 414 | 415 | 416 | def flux_density_circular_filament_cartesian( 417 | ifil: NDArray[float64], 418 | rfil: NDArray[float64], 419 | zfil: NDArray[float64], 420 | xyzp: Array3xN, 421 | par: bool = True, 422 | ) -> NDArray[float64]: 423 | """ 424 | Flux density of a circular filament in cartesian form 425 | at a set of locations given in cartesian coordinates. 426 | 427 | Args: 428 | ifil: [A] filament current 429 | rfil: [m] filament R-coord 430 | zfil: [m] filament Z-coord 431 | xyzp: [m] x,y,z coords of observation points 432 | par: Whether to use CPU parallelism 433 | 434 | Returns: 435 | [T] flux density 436 | """ 437 | ifil, rfil, zfil = _3tup_contig((ifil, rfil, zfil)) 438 | xyzp = _3tup_contig(xyzp) 439 | bx, by, bz = em_flux_density_circular_filament_cartesian( 440 | ifil, rfil, zfil, xyzp, par 441 | ) # [T] 442 | 443 | return bx, by, bz # type: ignore 444 | 445 | 446 | def mutual_inductance_circular_to_linear( 447 | rfil: NDArray[float64], 448 | zfil: NDArray[float64], 449 | nfil: NDArray[float64], 450 | xyzfil: Array3xN, 451 | dlxyzfil: Array3xN, 452 | par: bool = True, 453 | ) -> NDArray[float64]: 454 | """ 455 | Mutual inductance between a collection of circular filaments and a piecewise-linear filament. 456 | This method is much faster (~100x typically) than discretizing the circular loop 457 | into linear segments and using Neumann's formula. 458 | 459 | Args: 460 | rfil: [m] filament R-coord 461 | zfil: [m] filament Z-coord 462 | nfil: [dimensionless] filament number of turns 463 | xyzfil: [m] x,y,z coords of current filament origins (start of segment) 464 | dlxyzfil: [m] x,y,z length delta of current filaments 465 | par: Whether to use CPU parallelism 466 | 467 | Returns: 468 | [H] mutual inductance 469 | """ 470 | rfil, zfil, nfil = _3tup_contig((rfil, zfil, nfil)) 471 | xyzfil = _3tup_contig(xyzfil) 472 | dlxyzfil = _3tup_contig(dlxyzfil) 473 | m = em_mutual_inductance_circular_to_linear(rfil, zfil, nfil, xyzfil, dlxyzfil, par) 474 | 475 | return m # [H] 476 | 477 | 478 | def flux_density_dipole( 479 | loc: Array3xN, 480 | moment: Array3xN, 481 | xyzp: Array3xN, 482 | par: bool = True, 483 | ) -> NDArray[float64]: 484 | """ 485 | Magnetic flux density of a dipole in cartesian coordiantes. 486 | 487 | Args: 488 | loc: [m] x,y,z coordinates of dipole 489 | moment: [A-m^2] dipole magnetic moment vector 490 | xyzp: [m] x,y,z coords of observation points 491 | par: Whether to use CPU parallelism 492 | 493 | Returns: 494 | [T] flux density 495 | """ 496 | loc = _3tup_contig(loc) 497 | moment = _3tup_contig(moment) 498 | xyzp = _3tup_contig(xyzp) 499 | bx, by, bz = em_flux_density_dipole(loc, moment, xyzp, par) # [T] 500 | 501 | return bx, by, bz # type: ignore 502 | 503 | 504 | def body_force_density_circular_filament_cartesian( 505 | ifil: NDArray[float64], 506 | rfil: NDArray[float64], 507 | zfil: NDArray[float64], 508 | obs: Array3xN, 509 | j: Array3xN, 510 | par: bool = True, 511 | ) -> NDArray[float64]: 512 | """ 513 | JxB (Lorentz) body force density (per volume) in cartesian form due to a circular current 514 | filament segment at an observation point in cartesian form with some current density (per area). 515 | 516 | Args: 517 | ifil: [A] filament current 518 | rfil: [m] filament R-coord 519 | zfil: [m] filament Z-coord 520 | obs: [m] x,y,z coords of observation locations 521 | j: [A/m^2] current density vector at observation locations 522 | par: Whether to use CPU parallelism 523 | 524 | Returns: 525 | [N/m^3] body force density 526 | """ 527 | ifil, rfil, zfil = _3tup_contig((ifil, rfil, zfil)) 528 | obs = _3tup_contig(obs) 529 | j = _3tup_contig(j) 530 | jxbx, jxby, jxbz = em_body_force_density_circular_filament_cartesian( 531 | ifil, rfil, zfil, obs, j, par 532 | ) # [N/m^3] 533 | 534 | return jxbx, jxby, jxbz # type: ignore 535 | 536 | 537 | def body_force_density_linear_filament( 538 | xyzfil: Array3xN, 539 | dlxyzfil: Array3xN, 540 | ifil: NDArray[float64], 541 | obs: Array3xN, 542 | j: Array3xN, 543 | par: bool = True, 544 | ) -> NDArray[float64]: 545 | """ 546 | JxB (Lorentz) body force density (per volume) due to a linear current 547 | filament segment at an observation point with some current density (per area). 548 | 549 | Args: 550 | xyzfil: [m] x,y,z coords of current filament origins (start of segment) 551 | dlxyzfil: [m] x,y,z length delta of current filaments 552 | ifil: [A] filament current 553 | obs: [m] x,y,z coords of observation locations 554 | j: [A/m^2] current density vector at observation locations 555 | par: Whether to use CPU parallelism 556 | 557 | Returns: 558 | [N/m^3] body force density 559 | """ 560 | xyzfil = _3tup_contig(xyzfil) 561 | dlxyzfil = _3tup_contig(dlxyzfil) 562 | ifil = ascontiguousarray(ifil).flatten() 563 | obs = _3tup_contig(obs) 564 | j = _3tup_contig(j) 565 | jxbx, jxby, jxbz = em_body_force_density_linear_filament( 566 | xyzfil, dlxyzfil, ifil, obs, j, par 567 | ) # [N/m^3] 568 | 569 | return jxbx, jxby, jxbz # type: ignore 570 | 571 | 572 | def _3tup_contig( 573 | t: Array3xN, 574 | ) -> tuple[NDArray[float64], NDArray[float64], NDArray[float64]]: 575 | """Make contiguous references or copies to arrays in a 3-tuple. Only copies data if it is not already contiguous.""" 576 | return ( 577 | ascontiguousarray(t[0]).flatten(), 578 | ascontiguousarray(t[1]).flatten(), 579 | ascontiguousarray(t[2]).flatten(), 580 | ) 581 | 582 | 583 | def _2tup_contig( 584 | t: tuple[NDArray[float64], NDArray[float64]], 585 | ) -> tuple[NDArray[float64], NDArray[float64]]: 586 | """Make contiguous references or copies to arrays in a 2-tuple. Only copies data if it is not already contiguous.""" 587 | return (ascontiguousarray(t[0]).flatten(), ascontiguousarray(t[1]).flatten()) 588 | -------------------------------------------------------------------------------- /cfsem/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cfs-energy/cfsem-py/e1f7d4394365d27279eee1f6570e8f9fe2437625/cfsem/py.typed -------------------------------------------------------------------------------- /cfsem/types.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple, Union 2 | from numpy.typing import NDArray 3 | from numpy import float64 4 | 5 | Array3xN = Union[ 6 | NDArray[float64], Tuple[NDArray[float64], NDArray[float64], NDArray[float64]] 7 | ] 8 | -------------------------------------------------------------------------------- /docs/assets/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # cfsem 2 | 3 | Quasi-steady electromagnetics including filamentized approximations, Biot-Savart, and Grad-Shafranov. 4 | 5 | ![Helmholtz coil example](python/example_outputs/helmholtz.png) 6 | 7 | Above is the B-field of a Helmholtz coil, calculated with cfsem. 8 | On the left, the red dashes outline where the B-field magnitude is within 1% of its value at (r=0, z=0), and the black dots show where the coils intersect the r-z plane. 9 | [Full example here](python/examples.md#helmholtz-coil-pair). 10 | -------------------------------------------------------------------------------- /docs/javascripts/mathjax.js: -------------------------------------------------------------------------------- 1 | window.MathJax = { 2 | tex: { 3 | inlineMath: [["\\(", "\\)"], ["$", "$"]], 4 | displayMath: [["\\[", "\\]"]], 5 | processEscapes: true, 6 | processEnvironments: true, 7 | }, 8 | options: { 9 | ignoreHtmlClass: ".*|", 10 | processHtmlClass: "arithmatex", 11 | }, 12 | }; 13 | 14 | document$.subscribe(() => { 15 | MathJax.startup.output.clearCache(); 16 | MathJax.typesetClear(); 17 | MathJax.texReset(); 18 | MathJax.typesetPromise(); 19 | }); 20 | -------------------------------------------------------------------------------- /docs/python/example_outputs/.placeholder: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cfs-energy/cfsem-py/e1f7d4394365d27279eee1f6570e8f9fe2437625/docs/python/example_outputs/.placeholder -------------------------------------------------------------------------------- /docs/python/examples.md: -------------------------------------------------------------------------------- 1 | # cfsem python examples 2 | 3 | ## Helmholtz Coil Pair 4 | 5 | This example uses [cfsem.flux_density_circular_filament][] to map the B-field of 6 | a [Helmholtz coil pair](https://en.wikipedia.org/wiki/Helmholtz_coil), 7 | an arrangement of two circular coils which produces a region of 8 | nearly uniform magnetic field. 9 | 10 |
11 | ![Helmholtz coil example](example_outputs/helmholtz.png) 12 |
13 | B-field of a Helmholtz coil, calculated with cfsem. 14 | On the left, the red dashes outline where the B-field magnitude is within 1% of its value at (r=0, z=0), and the black dots show where the coils intersect the r-z plane. 15 |
16 |
17 | 18 | ``` py title="examples/helmholtz.py" 19 | --8<-- "examples/helmholtz.py" 20 | ``` 21 | 22 | ## High-aspect-ratio Coil Inductance 23 | 24 | Estimate the (low-frequency) self- and mutual- inductance of a pair of air-core solenoids, 25 | comparing results from modeling as either collections of axisymmetric loops or 26 | as thin helical filaments. 27 | 28 | ``` py title="examples/inductance.py" 29 | --8<-- "examples/inductance.py" 30 | ``` 31 | -------------------------------------------------------------------------------- /docs/python/filament.md: -------------------------------------------------------------------------------- 1 | # Filaments and paths 2 | 3 | ::: cfsem.filament_coil 4 | 5 | ::: cfsem.filament_helix_path 6 | 7 | ::: cfsem.rotate_filaments_about_path 8 | -------------------------------------------------------------------------------- /docs/python/flux_density.md: -------------------------------------------------------------------------------- 1 | # Magnetic flux and flux density (B field) 2 | 3 | ::: cfsem.flux_density_linear_filament 4 | 5 | ::: cfsem.flux_density_circular_filament 6 | 7 | ::: cfsem.flux_density_ideal_solenoid 8 | 9 | ::: cfsem.flux_density_circular_filament_cartesian 10 | 11 | ::: cfsem.flux_density_dipole -------------------------------------------------------------------------------- /docs/python/force.md: -------------------------------------------------------------------------------- 1 | # Force 2 | 3 | ::: cfsem.body_force_density_linear_filament 4 | 5 | ::: cfsem.body_force_density_circular_filament_cartesian -------------------------------------------------------------------------------- /docs/python/grad_shafranov.md: -------------------------------------------------------------------------------- 1 | # Grad-Shafranov 2 | 3 | ::: cfsem.gs_operator_order2 4 | 5 | ::: cfsem.gs_operator_order4 6 | -------------------------------------------------------------------------------- /docs/python/inductance.md: -------------------------------------------------------------------------------- 1 | # Inductance 2 | 3 | ## Mutual inductance 4 | 5 | ::: cfsem.flux_circular_filament 6 | 7 | ::: cfsem.mutual_inductance_of_circular_filaments 8 | 9 | ::: cfsem.mutual_inductance_of_cylindrical_coils 10 | 11 | ::: cfsem.mutual_inductance_piecewise_linear_filaments 12 | 13 | ::: cfsem.mutual_inductance_circular_to_linear 14 | 15 | ## Self inductance 16 | 17 | ::: cfsem.self_inductance_annular_ring 18 | 19 | ::: cfsem.self_inductance_circular_ring_wien 20 | 21 | ::: cfsem.self_inductance_distributed_axisymmetric_conductor 22 | 23 | ::: cfsem.self_inductance_lyle6 24 | 25 | ::: cfsem.self_inductance_piecewise_linear_filaments 26 | 27 | ## General 28 | 29 | ::: cfsem.inductance_piecewise_linear_filaments 30 | -------------------------------------------------------------------------------- /docs/python/math.md: -------------------------------------------------------------------------------- 1 | # Math and constants 2 | 3 | ::: cfsem.MU_0 4 | 5 | ::: cfsem.ellipe 6 | 7 | ::: cfsem.ellipk 8 | -------------------------------------------------------------------------------- /docs/python/types.md: -------------------------------------------------------------------------------- 1 | # Types 2 | 3 | ::: cfsem.types 4 | options: 5 | members: true 6 | -------------------------------------------------------------------------------- /docs/python/vector_potential.md: -------------------------------------------------------------------------------- 1 | # Vector Potential 2 | 3 | ::: cfsem.vector_potential_circular_filament 4 | 5 | ::: cfsem.vector_potential_linear_filament 6 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | -e .[dev] 2 | -------------------------------------------------------------------------------- /docs/stylesheets/extra.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --md-primary-fg-color: #152536; 3 | --md-accent-fg-color: #AF5B3A; 4 | } -------------------------------------------------------------------------------- /examples/helmholtz.py: -------------------------------------------------------------------------------- 1 | """Calculate the B-field from a Helmholtz coil pair.""" 2 | 3 | import os 4 | import numpy as np 5 | from matplotlib import pyplot as plt 6 | from cfsem import flux_density_circular_filament, MU_0 7 | 8 | coil_radius = 0.2 # [m] 9 | 10 | # The helmholtz coil pair comprised two circular coils, 11 | # separated by a distance equal to their radius. 12 | # In our coordinate system, z = 0 is the midpoint between the coils. 13 | rfil = [coil_radius, coil_radius] # [m] 14 | zfil = [-0.5 * coil_radius, 0.5 * coil_radius] # [m] 15 | 16 | # [A] The current * turns of each coil. 17 | ifil = [100.0, 100.0] 18 | 19 | # Define a mesh on which to evaluate the B-field. 20 | # Note that we cannot evaluate the B-field at exactly r = 0. 21 | r = np.linspace(1e-9, 2.0 * coil_radius, 100) # [m] 22 | z = np.linspace(-1.5 * coil_radius, 1.5 * coil_radius, 100) # [m] 23 | rmesh, zmesh = np.meshgrid(r, z) 24 | rmesh_flat = rmesh.flatten() 25 | zmesh_flat = zmesh.flatten() 26 | 27 | # Calculate the B-field at every mesh point using cfsem. 28 | Br_flat, Bz_flat = flux_density_circular_filament( 29 | ifil, rfil, zfil, rmesh_flat, zmesh_flat 30 | ) 31 | 32 | 33 | Br = Br_flat.reshape(rmesh.shape) 34 | Bz = Bz_flat.reshape(rmesh.shape) 35 | 36 | Bmag = np.sqrt(Br**2 + Bz**2) # [T] Magnitude of the B-field. 37 | 38 | fig, axes = plt.subplots(1, 2, figsize=(8, 4)) 39 | ax_map, ax_center = axes 40 | ax_map.set_aspect("equal") 41 | # Plot magnetic field lines. 42 | ax_map.streamplot(rmesh, zmesh, Br, Bz, color="black", linewidth=0.5) 43 | # Make a color plot of the magnetic field magnitude. 44 | dr = r[1] - r[0] 45 | dz = z[1] - z[0] 46 | extent = (r[0] - dr / 2, r[-1] + dr / 2, z[0] - dz / 2, z[-1] + dz / 2) 47 | im = ax_map.imshow( 48 | Bmag, extent=extent, origin="lower", interpolation="bicubic", norm="log" 49 | ) 50 | fig.colorbar(im, label="$|\\vec{B}|$ [T]", ax=ax_map) 51 | # Outline the region where the B-field magnitude is within 1% of the center value. 52 | B_center = (4 / 5) ** (3 / 2) * MU_0 * ifil[0] / coil_radius # [T] 53 | ax_map.contour( 54 | rmesh, 55 | zmesh, 56 | np.abs(Bmag - B_center), 57 | levels=[0.01 * B_center], 58 | colors=["red"], 59 | linestyles="dashed", 60 | ) 61 | # Draw the location of the coils. 62 | ax_map.plot(rfil, zfil, color="black", marker="o", markersize=8, linestyle="none") 63 | ax_map.set_title("$(r, z)$ map of $\\vec{B}$") 64 | 65 | ax_map.set_xlabel("$r$ [m]") 66 | ax_map.set_ylabel("$z$ [m]") 67 | ax_map.set_xlim(0.0, r[-1]) 68 | ax_map.set_ylim(z[0], z[-1]) 69 | 70 | 71 | # Plot the centerline B-field from cfsem versus the analytic solution. 72 | # Analytic formula from https://en.wikipedia.org/wiki/Helmholtz_coil#Derivation 73 | def xi(x): 74 | """Helper function for calculating the centerline B-field analytically.""" 75 | return (1 + (x / coil_radius) ** 2) ** (-3 / 2) 76 | 77 | 78 | Bmag_analytic = ( 79 | MU_0 / (2 * coil_radius) * (ifil[0] * xi(z - zfil[0]) + ifil[1] * xi(z - zfil[1])) 80 | ) 81 | ax_center.plot( 82 | z, Bz[:, 0], label="cfsem", marker="+", color="tab:blue", linestyle="none" 83 | ) 84 | ax_center.plot(z, Bmag_analytic, label="analytic", color="black") 85 | # Annotate the coil locations. 86 | for i in (0, 1): 87 | ax_center.axvline(zfil[i], color="gray", linestyle="--") 88 | ax_center.text( 89 | s=f"coil {i + 1}", 90 | x=zfil[i], 91 | y=0.5 * np.max(Bmag_analytic), 92 | rotation=90, 93 | ha="right", 94 | va="center", 95 | color="grey", 96 | ) 97 | ax_center.axvline(zfil[1], color="gray", linestyle="--") 98 | ax_center.set_xlabel("$z$ [m]") 99 | ax_center.set_ylabel("$|\\vec{B}|$ [T]") 100 | ax_center.legend(loc="lower right") 101 | ax_center.set_ylim(0.0, 1.1 * np.max(Bmag_analytic)) 102 | ax_center.set_title("Centerline ($r=0$) $B$-field") 103 | 104 | fig.tight_layout() 105 | 106 | if not os.environ.get("CFSEM_TESTING", False): 107 | fig.savefig("helmholtz.png", dpi=300) 108 | -------------------------------------------------------------------------------- /examples/inductance.py: -------------------------------------------------------------------------------- 1 | """ 2 | Comparison of self- and mutual- inductance of coils modeled as either an axisymmetric filament collection 3 | or as a piecewise-linear helix. 4 | """ 5 | 6 | import numpy as np 7 | import cfsem 8 | 9 | # Center radius and height, winding pack width and height, 10 | # and number of windings in r and z directions 11 | # for two solenoids. 12 | r1, z1, w1, h1, nr1, nz1 = (0.3, 0.0, 0.01, 1.0, 1, 10) 13 | r2, z2, w2, h2, nr2, nz2 = (0.5, 0.2, 0.01, 0.5, 1, 5) 14 | 15 | nt1 = nr1 * nz1 # Total number of turns for each coil 16 | nt2 = nr2 * nz2 17 | 18 | # Build axisymmetric filament representation 19 | filaments_1 = cfsem.filament_coil(r1, z1, w1, h1, nt1, nr1, nz1) 20 | filaments_2 = cfsem.filament_coil(r2, z2, w2, h2, nt2, nr2, nz2) 21 | 22 | # Build helix representations with 100 points per turn. 23 | # The first and last point in the helices span the full height of the winding pack 24 | # such that each filament in the axisymmetric representation captures a half turn 25 | # of the helical representation above and below its z-location. 26 | angles1 = np.linspace(0.0, 2.0 * np.pi * nt1, 100 * nt1) # [rad] 27 | angles2 = np.linspace(0.0, 2.0 * np.pi * nt2, 100 * nt2) # [rad] 28 | 29 | xhelix1 = r1 * np.cos(angles1) # [m] 30 | yhelix1 = r1 * np.sin(angles1) # [m] 31 | zhelix1 = np.linspace(z1 - h1 / 2, z1 + h1 / 2, angles1.size) # [m] 32 | 33 | xhelix2 = r2 * np.cos(angles2) # [m] 34 | yhelix2 = r2 * np.sin(angles2) # [m] 35 | zhelix2 = np.linspace(z2 - h2 / 2, z2 + h2 / 2, angles2.size) # [m] 36 | 37 | # Estimate the self-inductance by 2 different methods, 38 | # hand-calc and helical filaments. 39 | self_inductance_handcalc_1 = cfsem.self_inductance_lyle6(r1, w1, h1, nt1) 40 | self_inductance_handcalc_2 = cfsem.self_inductance_lyle6(r2, w2, h2, nt2) 41 | 42 | self_inductance_helical_1 = cfsem.self_inductance_piecewise_linear_filaments( 43 | (xhelix1, yhelix1, zhelix1) 44 | ) 45 | self_inductance_helical_2 = cfsem.self_inductance_piecewise_linear_filaments( 46 | (xhelix2, yhelix2, zhelix2) 47 | ) 48 | 49 | print("First coil self-inductance") 50 | print(f" Handcalc: {self_inductance_handcalc_1:.2e} [H]") 51 | print(f" Helical: {self_inductance_helical_1:.2e} [H]") 52 | 53 | print("Second coil self-inductance") 54 | print(f" Handcalc: {self_inductance_handcalc_2:.2e} [H]") 55 | print(f" Helical: {self_inductance_helical_2:.2e} [H]") 56 | 57 | # Estimate mutual inductance by 2 different methods, 58 | # axisymmetric filaments and helical filaments. 59 | mutual_inductance_axisymmetric = cfsem.mutual_inductance_of_cylindrical_coils( 60 | filaments_1.T, filaments_2.T 61 | ) 62 | mutual_inductance_helical = cfsem.mutual_inductance_piecewise_linear_filaments( 63 | (xhelix1, yhelix1, zhelix1), (xhelix2, yhelix2, zhelix2) 64 | ) 65 | # Mutual inductance is reflexive, so we get the same answer if we reverse the inputs 66 | mutual_inductance_axisymmetric_reflexive = cfsem.mutual_inductance_of_cylindrical_coils( 67 | filaments_2.T, filaments_1.T 68 | ) 69 | mutual_inductance_helical_reflexive = ( 70 | cfsem.mutual_inductance_piecewise_linear_filaments( 71 | (xhelix2, yhelix2, zhelix2), (xhelix1, yhelix1, zhelix1) 72 | ) 73 | ) 74 | 75 | print("Mutual-inductance") 76 | print(f" Axisymmetric: {mutual_inductance_axisymmetric:.2e} [H]") 77 | print(f" Helical: {mutual_inductance_helical:.2e} [H]") 78 | print(f" Axisymmetric reflexive: {mutual_inductance_axisymmetric_reflexive:.2e} [H]") 79 | print(f" Helical reflexive: {mutual_inductance_helical_reflexive:.2e} [H]") 80 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: cfsem 2 | site_url: https://cfsem-py.readthedocs.io 3 | theme: 4 | name: material 5 | logo: assets/logo.svg 6 | palette: 7 | primary: custom 8 | accent: custom 9 | 10 | plugins: 11 | - search 12 | - mkdocstrings: 13 | handlers: 14 | python: 15 | options: 16 | heading_level: 3 17 | show_root_heading: true 18 | separate_signature: true 19 | show_signature_annotations: true 20 | 21 | nav: 22 | - "About cfsem": index.md 23 | - "Python Examples": "python/examples.md" 24 | - "Python API": 25 | - "python/filament.md" 26 | - "python/flux_density.md" 27 | - "python/vector_potential.md" 28 | - "python/inductance.md" 29 | - "python/grad_shafranov.md" 30 | - "python/math.md" 31 | - "python/types.md" 32 | - "Rust API": "https://docs.rs/cfsem" 33 | 34 | markdown_extensions: 35 | - pymdownx.arithmatex: 36 | generic: true 37 | - toc: 38 | permalink: true 39 | - md_in_html 40 | - pymdownx.highlight: 41 | anchor_linenums: true 42 | line_spans: __span 43 | pygments_lang_class: true 44 | - pymdownx.inlinehilite 45 | - pymdownx.snippets: 46 | check_paths: true 47 | - pymdownx.superfences 48 | 49 | extra_javascript: 50 | - javascripts/mathjax.js 51 | - https://polyfill.io/v3/polyfill.min.js?features=es6 52 | - https://unpkg.com/mathjax@3/es5/tex-mml-chtml.js 53 | 54 | extra_css: 55 | - stylesheets/extra.css 56 | 57 | watch: 58 | - cfsem 59 | 60 | exclude_docs: | 61 | rust/static.files/SourceSerif4-LICENSE-*.md 62 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["maturin>=1.7,<2.0"] 3 | build-backend = "maturin" 4 | 5 | [project] 6 | name = "cfsem" 7 | version = "2.3.1" 8 | description = "Quasi-steady electromagnetics including filamentized approximations, Biot-Savart, and Grad-Shafranov." 9 | authors = [{name = "Commonwealth Fusion Systems", email = "jlogan@cfs.energy"}] 10 | requires-python = ">=3.9, <3.14" 11 | classifiers = [ 12 | "Programming Language :: Rust", 13 | "Programming Language :: Python :: Implementation :: CPython", 14 | "Programming Language :: Python :: Implementation :: PyPy", 15 | ] 16 | dependencies = [ 17 | "numpy >= 2", 18 | "interpn >= 0.2.5", 19 | ] 20 | license = "MIT" 21 | 22 | [project.optional-dependencies] 23 | dev = [ 24 | # Tests 25 | "pytest >= 7.1.3", 26 | "coverage >= 6.5.0", 27 | "ruff >= 0.6.2", 28 | "pyright == 1.1.393", 29 | "scipy >= 1.8", 30 | # Docs 31 | "mkdocs >= 1.6.0", 32 | "mkdocs-material >= 9.5.25", 33 | "mkdocstrings[python] >= 0.25.1", 34 | # Examples 35 | "matplotlib >= 3.9", 36 | ] 37 | 38 | [tool.maturin] 39 | features = ["pyo3/extension-module"] 40 | module-name = "cfsem._cfsem" 41 | 42 | [tool.ruff] 43 | target-version = "py311" 44 | 45 | [tool.coverage.report] 46 | fail_under = 100 47 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Python bindings for cfsemrs 2 | #![allow(non_snake_case)] 3 | 4 | use numpy::PyArray1; 5 | use numpy::PyArrayMethods; 6 | use pyo3::exceptions; 7 | use pyo3::prelude::*; 8 | use std::fmt::Debug; 9 | 10 | use cfsem::{math, mesh, physics}; 11 | 12 | /// Errors from mismatch between python and rust 13 | #[derive(Debug)] 14 | #[allow(dead_code)] 15 | enum PyInteropError { 16 | DimensionalityError { msg: String }, 17 | } 18 | 19 | impl From for PyErr { 20 | fn from(val: PyInteropError) -> Self { 21 | exceptions::PyValueError::new_err(format!("{:#?}", &val)) 22 | } 23 | } 24 | 25 | #[pyfunction] 26 | fn filament_helix_path<'py>( 27 | path: ( 28 | Bound<'py, PyArray1>, 29 | Bound<'py, PyArray1>, 30 | Bound<'py, PyArray1>, 31 | ), // [m] 32 | helix_start_offset: (f64, f64, f64), 33 | twist_pitch: f64, 34 | angle_offset: f64, 35 | out: ( 36 | Bound<'py, PyArray1>, 37 | Bound<'py, PyArray1>, 38 | Bound<'py, PyArray1>, 39 | ), 40 | ) -> PyResult<()> { 41 | // Unpack 42 | _3tup_slice_ro!(path); 43 | _3tup_slice_mut!(out); 44 | 45 | // Calculate 46 | match mesh::filament_helix_path(path, helix_start_offset, twist_pitch, angle_offset, out) { 47 | Ok(_) => (), 48 | Err(x) => { 49 | let err: PyErr = PyInteropError::DimensionalityError { msg: x.to_string() }.into(); 50 | return Err(err); 51 | } 52 | } 53 | 54 | Ok(()) 55 | } 56 | 57 | #[pyfunction] 58 | fn rotate_filaments_about_path<'py>( 59 | path: ( 60 | Bound<'py, PyArray1>, 61 | Bound<'py, PyArray1>, 62 | Bound<'py, PyArray1>, 63 | ), // [m] 64 | angle_offset: f64, 65 | out: ( 66 | Bound<'py, PyArray1>, 67 | Bound<'py, PyArray1>, 68 | Bound<'py, PyArray1>, 69 | ), 70 | ) -> PyResult<()> { 71 | // Unpack 72 | _3tup_slice_ro!(path); 73 | _3tup_slice_mut!(out); 74 | 75 | // Calculate 76 | match mesh::rotate_filaments_about_path(path, angle_offset, out) { 77 | Ok(_) => (), 78 | Err(x) => { 79 | let err: PyErr = PyInteropError::DimensionalityError { msg: x.to_string() }.into(); 80 | return Err(err); 81 | } 82 | } 83 | 84 | Ok(()) 85 | } 86 | 87 | /// Python bindings for cfsemrs::physics::flux_circular_filament 88 | #[pyfunction] 89 | fn flux_circular_filament<'py>( 90 | current: Bound<'py, PyArray1>, 91 | rfil: Bound<'py, PyArray1>, 92 | zfil: Bound<'py, PyArray1>, 93 | rprime: Bound<'py, PyArray1>, 94 | zprime: Bound<'py, PyArray1>, 95 | par: bool, 96 | ) -> PyResult>> { 97 | // Get references to contiguous data as slice 98 | // or error if data is not contiguous 99 | let rzifil = (rfil, zfil, current); 100 | _3tup_slice_ro!(rzifil); 101 | let obs = (rprime, zprime); 102 | _2tup_slice_ro!(obs); 103 | 104 | // Initialize output 105 | let mut psi = vec![0.0; obs.0.len()]; 106 | 107 | // Select variant 108 | let func = match par { 109 | true => physics::circular_filament::flux_circular_filament_par, 110 | false => physics::circular_filament::flux_circular_filament, 111 | }; 112 | 113 | // Do calculations 114 | match func(rzifil, obs, &mut psi[..]) { 115 | Ok(_) => {} 116 | Err(x) => { 117 | let err: PyErr = PyInteropError::DimensionalityError { msg: x.to_string() }.into(); 118 | return Err(err); 119 | } 120 | } 121 | 122 | // Acquire global interpreter lock, which will be released when it goes out of scope 123 | Python::with_gil(|py| { 124 | Ok(PyArray1::from_vec(py, psi).unbind()) // Make PyObject 125 | }) 126 | } 127 | 128 | /// Python bindings for cfsemrs::physics::circular_filament::vector_potential_circular_filament 129 | #[pyfunction] 130 | fn vector_potential_circular_filament<'py>( 131 | current: Bound<'py, PyArray1>, 132 | rfil: Bound<'py, PyArray1>, 133 | zfil: Bound<'py, PyArray1>, 134 | rprime: Bound<'py, PyArray1>, 135 | zprime: Bound<'py, PyArray1>, 136 | par: bool, 137 | ) -> PyResult>> { 138 | // Get references to contiguous data as slice 139 | // or error if data is not contiguous 140 | let rzifil = (rfil, zfil, current); 141 | _3tup_slice_ro!(rzifil); 142 | let obs = (rprime, zprime); 143 | _2tup_slice_ro!(obs); 144 | 145 | // Initialize output 146 | let mut out = vec![0.0; obs.0.len()]; 147 | 148 | // Select variant 149 | let func = match par { 150 | true => physics::circular_filament::vector_potential_circular_filament_par, 151 | false => physics::circular_filament::vector_potential_circular_filament, 152 | }; 153 | 154 | // Do calculations 155 | match func(rzifil, obs, &mut out[..]) { 156 | Ok(_) => {} 157 | Err(x) => { 158 | let err: PyErr = PyInteropError::DimensionalityError { msg: x.to_string() }.into(); 159 | return Err(err); 160 | } 161 | } 162 | 163 | // Acquire global interpreter lock, which will be released when it goes out of scope 164 | Python::with_gil(|py| { 165 | Ok(PyArray1::from_vec(py, out).unbind()) // Make PyObject 166 | }) 167 | } 168 | 169 | /// Python bindings for cfsemrs::physics::flux_density_circular_filament 170 | #[pyfunction] 171 | fn flux_density_circular_filament<'py>( 172 | current: Bound<'py, PyArray1>, 173 | rfil: Bound<'py, PyArray1>, 174 | zfil: Bound<'py, PyArray1>, 175 | rprime: Bound<'py, PyArray1>, 176 | zprime: Bound<'py, PyArray1>, 177 | par: bool, 178 | ) -> PyResult<(Py>, Py>)> { 179 | // Get references to contiguous data as slice 180 | // or error if data is not contiguous 181 | let rzifil = (rfil, zfil, current); 182 | _3tup_slice_ro!(rzifil); 183 | let obs = (rprime, zprime); 184 | _2tup_slice_ro!(obs); 185 | 186 | // Initialize output 187 | let n = obs.0.len(); 188 | let (mut br, mut bz) = (vec![0.0; n], vec![0.0; n]); 189 | 190 | // Select variant 191 | let func = match par { 192 | true => physics::circular_filament::flux_density_circular_filament_par, 193 | false => physics::circular_filament::flux_density_circular_filament, 194 | }; 195 | 196 | // Do calculations 197 | match func(rzifil, obs, (&mut br, &mut bz)) { 198 | Ok(_) => {} 199 | Err(x) => { 200 | let err: PyErr = PyInteropError::DimensionalityError { msg: x.to_string() }.into(); 201 | return Err(err); 202 | } 203 | } 204 | 205 | // Acquire global interpreter lock, which will be released when it goes out of scope 206 | Python::with_gil(|py| { 207 | let br: Py> = PyArray1::from_slice(py, &br).unbind(); // Make PyObject 208 | let bz: Py> = PyArray1::from_slice(py, &bz).unbind(); // Make PyObject 209 | 210 | Ok((br, bz)) 211 | }) 212 | } 213 | 214 | /// Python bindings for cfsemrs::physics::linear_filament::flux_density_linear_filament 215 | #[pyfunction] 216 | fn flux_density_linear_filament<'py>( 217 | xyzp: ( 218 | Bound<'py, PyArray1>, 219 | Bound<'py, PyArray1>, 220 | Bound<'py, PyArray1>, 221 | ), // [m] Test point coords 222 | xyzfil: ( 223 | Bound<'py, PyArray1>, 224 | Bound<'py, PyArray1>, 225 | Bound<'py, PyArray1>, 226 | ), // [m] Filament origin coords (start of segment) 227 | dlxyzfil: ( 228 | Bound<'py, PyArray1>, 229 | Bound<'py, PyArray1>, 230 | Bound<'py, PyArray1>, 231 | ), // [m] Filament length delta 232 | ifil: Bound<'py, PyArray1>, // [A] filament current 233 | par: bool, 234 | ) -> PyResult<(Py>, Py>, Py>)> { 235 | // Get references to contiguous data as slice 236 | // or error if data is not contiguous 237 | _3tup_slice_ro!(xyzp); 238 | _3tup_slice_ro!(xyzfil); 239 | _3tup_slice_ro!(dlxyzfil); 240 | let ifilro = ifil.readonly(); 241 | let ifil = ifilro.as_slice()?; 242 | 243 | // Do calculations 244 | let n = xyzp.0.len(); 245 | let (mut bx, mut by, mut bz) = (vec![0.0; n], vec![0.0; n], vec![0.0; n]); 246 | 247 | let func = match par { 248 | true => physics::linear_filament::flux_density_linear_filament_par, 249 | false => physics::linear_filament::flux_density_linear_filament, 250 | }; 251 | match func(xyzp, xyzfil, dlxyzfil, ifil, (&mut bx, &mut by, &mut bz)) { 252 | Ok(x) => x, 253 | Err(x) => { 254 | let err: PyErr = PyInteropError::DimensionalityError { msg: x.to_string() }.into(); 255 | return Err(err); 256 | } 257 | }; 258 | 259 | _3tup_ret!((bx, f64), (by, f64), (bz, f64)) 260 | } 261 | 262 | /// Python bindings for cfsemrs::physics::linear_filament::vector_potential_linear_filament 263 | #[pyfunction] 264 | fn vector_potential_linear_filament<'py>( 265 | xyzp: ( 266 | Bound<'py, PyArray1>, 267 | Bound<'py, PyArray1>, 268 | Bound<'py, PyArray1>, 269 | ), // [m] Test point coords 270 | xyzfil: ( 271 | Bound<'py, PyArray1>, 272 | Bound<'py, PyArray1>, 273 | Bound<'py, PyArray1>, 274 | ), // [m] Filament origin coords (start of segment) 275 | dlxyzfil: ( 276 | Bound<'py, PyArray1>, 277 | Bound<'py, PyArray1>, 278 | Bound<'py, PyArray1>, 279 | ), // [m] Filament length delta 280 | ifil: Bound<'py, PyArray1>, // [A] filament current 281 | par: bool, 282 | ) -> PyResult<(Py>, Py>, Py>)> { 283 | // Get references to contiguous data as slice 284 | // or error if data is not contiguous 285 | _3tup_slice_ro!(xyzp); 286 | _3tup_slice_ro!(xyzfil); 287 | _3tup_slice_ro!(dlxyzfil); 288 | 289 | let ifilro = ifil.readonly(); 290 | let ifil = ifilro.as_slice()?; 291 | 292 | // Do calculations 293 | let n = xyzp.0.len(); 294 | let (mut outx, mut outy, mut outz) = (vec![0.0; n], vec![0.0; n], vec![0.0; n]); 295 | 296 | let func = match par { 297 | true => physics::linear_filament::vector_potential_linear_filament_par, 298 | false => physics::linear_filament::vector_potential_linear_filament, 299 | }; 300 | match func( 301 | xyzp, 302 | xyzfil, 303 | dlxyzfil, 304 | ifil, 305 | (&mut outx, &mut outy, &mut outz), 306 | ) { 307 | Ok(x) => x, 308 | Err(x) => { 309 | let err: PyErr = PyInteropError::DimensionalityError { msg: x.to_string() }.into(); 310 | return Err(err); 311 | } 312 | }; 313 | 314 | _3tup_ret!((outx, f64), (outy, f64), (outz, f64)) 315 | } 316 | 317 | #[pyfunction] 318 | fn inductance_piecewise_linear_filaments<'py>( 319 | xyzfil0: ( 320 | Bound<'py, PyArray1>, 321 | Bound<'py, PyArray1>, 322 | Bound<'py, PyArray1>, 323 | ), // [m] Filament origin coords (start of segment) 324 | dlxyzfil0: ( 325 | Bound<'py, PyArray1>, 326 | Bound<'py, PyArray1>, 327 | Bound<'py, PyArray1>, 328 | ), // [m] Filament length delta 329 | xyzfil1: ( 330 | Bound<'py, PyArray1>, 331 | Bound<'py, PyArray1>, 332 | Bound<'py, PyArray1>, 333 | ), // [m] Filament origin coords (start of segment) 334 | dlxyzfil1: ( 335 | Bound<'py, PyArray1>, 336 | Bound<'py, PyArray1>, 337 | Bound<'py, PyArray1>, 338 | ), // [m] Filament length delta 339 | self_inductance: bool, // Whether this is being used as a self-inductance calc 340 | ) -> PyResult { 341 | // Get references to contiguous data as slice 342 | // or error if data is not contiguous 343 | _3tup_slice_ro!(xyzfil0); 344 | _3tup_slice_ro!(dlxyzfil0); 345 | _3tup_slice_ro!(xyzfil1); 346 | _3tup_slice_ro!(dlxyzfil1); 347 | 348 | // Do calculations 349 | let inductance = match physics::linear_filament::inductance_piecewise_linear_filaments( 350 | xyzfil0, 351 | dlxyzfil0, 352 | xyzfil1, 353 | dlxyzfil1, 354 | self_inductance, 355 | ) { 356 | Ok(x) => x, 357 | Err(x) => { 358 | let err: PyErr = PyInteropError::DimensionalityError { msg: x.to_string() }.into(); 359 | return Err(err); 360 | } 361 | }; 362 | 363 | Ok(inductance) 364 | } 365 | 366 | /// Python bindings for cfsemrs::physics::gradshafranov::gs_operator_order2 367 | #[pyfunction] 368 | fn gs_operator_order2<'py>( 369 | rs: Bound<'py, PyArray1>, 370 | zs: Bound<'py, PyArray1>, 371 | ) -> PyResult<(Py>, Py>, Py>)> { 372 | // Process inputs 373 | let rsro = rs.readonly(); 374 | let rs = rsro.as_slice()?; 375 | let zsro = zs.readonly(); 376 | let zs = zsro.as_slice()?; 377 | 378 | // Do calculations 379 | let (vals, rows, cols) = physics::gradshafranov::gs_operator_order2(rs, zs); 380 | 381 | _3tup_ret!((vals, f64), (rows, usize), (cols, usize)) 382 | } 383 | 384 | /// Python bindings for cfsemrs::physics::gradshafranov::gs_operator_order4 385 | #[pyfunction] 386 | fn gs_operator_order4<'py>( 387 | rs: Bound<'py, PyArray1>, 388 | zs: Bound<'py, PyArray1>, 389 | ) -> PyResult<(Py>, Py>, Py>)> { 390 | // Process inputs 391 | let rsro = rs.readonly(); 392 | let rs = rsro.as_slice()?; 393 | let zsro = zs.readonly(); 394 | let zs = zsro.as_slice()?; 395 | 396 | // Do calculations 397 | let (vals, rows, cols) = physics::gradshafranov::gs_operator_order4(rs, zs); 398 | 399 | _3tup_ret!((vals, f64), (rows, usize), (cols, usize)) 400 | } 401 | 402 | /// Python bindings for cfsemrs::math::ellipe 403 | #[pyfunction] 404 | fn ellipe(x: f64) -> f64 { 405 | math::ellipe(x) 406 | } 407 | 408 | /// Python bindings for cfsemrs::math::ellipk 409 | #[pyfunction] 410 | fn ellipk(x: f64) -> f64 { 411 | math::ellipk(x) 412 | } 413 | 414 | /// Python bindings for cfsemrs::physics::flux_density_circular_filament_cartesian 415 | #[pyfunction] 416 | fn flux_density_circular_filament_cartesian<'py>( 417 | current: Bound<'py, PyArray1>, 418 | rfil: Bound<'py, PyArray1>, 419 | zfil: Bound<'py, PyArray1>, 420 | xyzobs: ( 421 | Bound<'py, PyArray1>, 422 | Bound<'py, PyArray1>, 423 | Bound<'py, PyArray1>, 424 | ), // [m] Observation point coords 425 | par: bool, 426 | ) -> PyResult<(Py>, Py>, Py>)> { 427 | // Get references to contiguous data as slice 428 | // or error if data is not contiguous 429 | let rzifil = (rfil, zfil, current); 430 | _3tup_slice_ro!(rzifil); 431 | let (rfil, zfil, current) = rzifil; 432 | _3tup_slice_ro!(xyzobs); 433 | 434 | // Initialize output 435 | let n = xyzobs.0.len(); 436 | let (mut bx, mut by, mut bz) = (vec![0.0; n], vec![0.0; n], vec![0.0; n]); 437 | 438 | // Select variant 439 | let func = match par { 440 | true => physics::circular_filament::flux_density_circular_filament_cartesian_par, 441 | false => physics::circular_filament::flux_density_circular_filament_cartesian, 442 | }; 443 | 444 | // Do calculations 445 | match func( 446 | (&rfil, &zfil, ¤t), 447 | xyzobs, 448 | (&mut bx, &mut by, &mut bz), 449 | ) { 450 | Ok(_) => {} 451 | Err(x) => { 452 | let err: PyErr = PyInteropError::DimensionalityError { msg: x.to_string() }.into(); 453 | return Err(err); 454 | } 455 | } 456 | 457 | _3tup_ret!((bx, f64), (by, f64), (bz, f64)) 458 | } 459 | 460 | /// Python bindings for cfsemrs::physics::mutual_inductance_circular_to_linear 461 | #[pyfunction] 462 | fn mutual_inductance_circular_to_linear<'py>( 463 | rfil: Bound<'py, PyArray1>, 464 | zfil: Bound<'py, PyArray1>, 465 | nfil: Bound<'py, PyArray1>, 466 | xyzfil: ( 467 | Bound<'py, PyArray1>, 468 | Bound<'py, PyArray1>, 469 | Bound<'py, PyArray1>, 470 | ), // [m] Filament origin coords (start of segment) 471 | dlxyzfil: ( 472 | Bound<'py, PyArray1>, 473 | Bound<'py, PyArray1>, 474 | Bound<'py, PyArray1>, 475 | ), // [m] Filament length delta 476 | par: bool, 477 | ) -> PyResult { 478 | // Get references to contiguous data as slice 479 | // or error if data is not contiguous 480 | let rznfil = (rfil, zfil, nfil); 481 | _3tup_slice_ro!(rznfil); 482 | _3tup_slice_ro!(xyzfil); 483 | _3tup_slice_ro!(dlxyzfil); 484 | 485 | // Select variant 486 | let func = match par { 487 | true => physics::circular_filament::mutual_inductance_circular_to_linear_par, 488 | false => physics::circular_filament::mutual_inductance_circular_to_linear, 489 | }; 490 | 491 | // Do calculations 492 | let m = match func(rznfil, xyzfil, dlxyzfil) { 493 | Ok(x) => x, 494 | Err(x) => { 495 | let err: PyErr = PyInteropError::DimensionalityError { msg: x.to_string() }.into(); 496 | return Err(err); 497 | } 498 | }; 499 | 500 | Ok(m) 501 | } 502 | 503 | /// Python bindings for cfsemrs::physics::point_source::flux_density_dipole 504 | #[pyfunction] 505 | fn flux_density_dipole<'py>( 506 | loc: ( 507 | Bound<'py, PyArray1>, 508 | Bound<'py, PyArray1>, 509 | Bound<'py, PyArray1>, 510 | ), // [m] dipole locations in cartesian coordinates 511 | moment: ( 512 | Bound<'py, PyArray1>, 513 | Bound<'py, PyArray1>, 514 | Bound<'py, PyArray1>, 515 | ), // [A-m^2] dipole moment vector 516 | obs: ( 517 | Bound<'py, PyArray1>, 518 | Bound<'py, PyArray1>, 519 | Bound<'py, PyArray1>, 520 | ), // [m] Observation point coords 521 | par: bool, 522 | ) -> PyResult<(Py>, Py>, Py>)> { 523 | // Get references to contiguous data as slice 524 | // or error if data is not contiguous 525 | _3tup_slice_ro!(loc); 526 | _3tup_slice_ro!(moment); 527 | _3tup_slice_ro!(obs); 528 | 529 | // Do calculations 530 | let n = obs.0.len(); 531 | let (mut outx, mut outy, mut outz) = (vec![0.0; n], vec![0.0; n], vec![0.0; n]); 532 | 533 | let func = match par { 534 | true => physics::point_source::flux_density_dipole_par, 535 | false => physics::point_source::flux_density_dipole, 536 | }; 537 | match func(loc, moment, obs, (&mut outx, &mut outy, &mut outz)) { 538 | Ok(x) => x, 539 | Err(x) => { 540 | let err: PyErr = PyInteropError::DimensionalityError { msg: x.to_string() }.into(); 541 | return Err(err); 542 | } 543 | }; 544 | 545 | _3tup_ret!((outx, f64), (outy, f64), (outz, f64)) 546 | } 547 | 548 | /// Python bindings for cfsemrs::physics::body_force_density_circular_filament_cartesian 549 | #[pyfunction] 550 | fn body_force_density_circular_filament_cartesian<'py>( 551 | current: Bound<'py, PyArray1>, 552 | rfil: Bound<'py, PyArray1>, 553 | zfil: Bound<'py, PyArray1>, 554 | obs: ( 555 | Bound<'py, PyArray1>, 556 | Bound<'py, PyArray1>, 557 | Bound<'py, PyArray1>, 558 | ), // [m] Filament origin coords (start of segment) 559 | j: ( 560 | Bound<'py, PyArray1>, 561 | Bound<'py, PyArray1>, 562 | Bound<'py, PyArray1>, 563 | ), // [A/m^2] current density at observation points 564 | par: bool, 565 | ) -> PyResult<(Py>, Py>, Py>)> { 566 | // Get references to contiguous data as slice 567 | // or error if data is not contiguous 568 | let rzifil = (rfil, zfil, current); 569 | _3tup_slice_ro!(rzifil); 570 | let (rfil, zfil, current) = rzifil; 571 | _3tup_slice_ro!(obs); 572 | _3tup_slice_ro!(j); 573 | 574 | // Select variant 575 | let func = match par { 576 | true => physics::circular_filament::body_force_density_circular_filament_cartesian_par, 577 | false => physics::circular_filament::body_force_density_circular_filament_cartesian, 578 | }; 579 | 580 | // Do calculations 581 | let n = obs.0.len(); 582 | let (mut outx, mut outy, mut outz) = (vec![0.0; n], vec![0.0; n], vec![0.0; n]); 583 | let out = (&mut outx[..], &mut outy[..], &mut outz[..]); 584 | 585 | match func((&rfil, &zfil, ¤t), obs, j, out) { 586 | Ok(_) => (), 587 | Err(x) => { 588 | let err: PyErr = PyInteropError::DimensionalityError { msg: x.to_string() }.into(); 589 | return Err(err); 590 | } 591 | }; 592 | 593 | _3tup_ret!((outx, f64), (outy, f64), (outz, f64)) 594 | } 595 | 596 | /// Python bindings for cfsemrs::physics::body_force_density_linear_filament 597 | #[pyfunction] 598 | fn body_force_density_linear_filament<'py>( 599 | xyzfil: ( 600 | Bound<'py, PyArray1>, 601 | Bound<'py, PyArray1>, 602 | Bound<'py, PyArray1>, 603 | ), // [m] Filament origin coords (start of segment) 604 | dlxyzfil: ( 605 | Bound<'py, PyArray1>, 606 | Bound<'py, PyArray1>, 607 | Bound<'py, PyArray1>, 608 | ), // [m] Filament length delta 609 | ifil: Bound<'py, PyArray1>, // [A] filament current 610 | obs: ( 611 | Bound<'py, PyArray1>, 612 | Bound<'py, PyArray1>, 613 | Bound<'py, PyArray1>, 614 | ), // [m] Filament origin coords (start of segment) 615 | j: ( 616 | Bound<'py, PyArray1>, 617 | Bound<'py, PyArray1>, 618 | Bound<'py, PyArray1>, 619 | ), // [A/m^2] current density at observation points 620 | par: bool, 621 | ) -> PyResult<(Py>, Py>, Py>)> { 622 | // Get references to contiguous data as slice 623 | // or error if data is not contiguous 624 | let xfilro = xyzfil.0.readonly(); 625 | let yfilro = xyzfil.1.readonly(); 626 | let zfilro = xyzfil.2.readonly(); 627 | let xyzfil = (xfilro.as_slice()?, yfilro.as_slice()?, zfilro.as_slice()?); 628 | 629 | let dlxfilro = dlxyzfil.0.readonly(); 630 | let dlyfilro = dlxyzfil.1.readonly(); 631 | let dlzfilro = dlxyzfil.2.readonly(); 632 | let dlxyzfil = ( 633 | dlxfilro.as_slice()?, 634 | dlyfilro.as_slice()?, 635 | dlzfilro.as_slice()?, 636 | ); 637 | let ifilro = ifil.readonly(); 638 | let ifil = ifilro.as_slice()?; 639 | 640 | let obsxro = obs.0.readonly(); 641 | let obsyro = obs.1.readonly(); 642 | let obszro = obs.2.readonly(); 643 | let obs = (obsxro.as_slice()?, obsyro.as_slice()?, obszro.as_slice()?); 644 | 645 | let jxro = j.0.readonly(); 646 | let jyro = j.1.readonly(); 647 | let jzro = j.2.readonly(); 648 | let j = (jxro.as_slice()?, jyro.as_slice()?, jzro.as_slice()?); 649 | 650 | // Select variant 651 | let func = match par { 652 | true => physics::linear_filament::body_force_density_linear_filament_par, 653 | false => physics::linear_filament::body_force_density_linear_filament, 654 | }; 655 | 656 | // Do calculations 657 | let n = obs.0.len(); 658 | let (mut outx, mut outy, mut outz) = (vec![0.0; n], vec![0.0; n], vec![0.0; n]); 659 | let out = (&mut outx[..], &mut outy[..], &mut outz[..]); 660 | 661 | match func(xyzfil, dlxyzfil, ifil, obs, j, out) { 662 | Ok(_) => (), 663 | Err(x) => { 664 | let err: PyErr = PyInteropError::DimensionalityError { msg: x.to_string() }.into(); 665 | return Err(err); 666 | } 667 | }; 668 | 669 | _3tup_ret!((outx, f64), (outy, f64), (outz, f64)) 670 | } 671 | 672 | /// A Python module implemented in Rust. The name of this function must match 673 | /// the `lib.name` setting in the `Cargo.toml`, else Python will not be able to 674 | /// import the module. 675 | #[pymodule] 676 | #[pyo3(name = "_cfsem")] 677 | fn _cfsem<'py>(_py: Python, m: Bound<'py, PyModule>) -> PyResult<()> { 678 | // Circular filaments 679 | m.add_function(wrap_pyfunction!(flux_circular_filament, m.clone())?)?; 680 | m.add_function(wrap_pyfunction!(flux_density_circular_filament, m.clone())?)?; 681 | m.add_function(wrap_pyfunction!( 682 | flux_density_circular_filament_cartesian, 683 | m.clone() 684 | )?)?; 685 | m.add_function(wrap_pyfunction!( 686 | vector_potential_circular_filament, 687 | m.clone() 688 | )?)?; 689 | m.add_function(wrap_pyfunction!( 690 | mutual_inductance_circular_to_linear, 691 | m.clone() 692 | )?)?; 693 | m.add_function(wrap_pyfunction!( 694 | body_force_density_circular_filament_cartesian, 695 | m.clone() 696 | )?)?; 697 | 698 | // Linear filaments 699 | m.add_function(wrap_pyfunction!(flux_density_linear_filament, m.clone())?)?; 700 | m.add_function(wrap_pyfunction!( 701 | vector_potential_linear_filament, 702 | m.clone() 703 | )?)?; 704 | m.add_function(wrap_pyfunction!( 705 | inductance_piecewise_linear_filaments, 706 | m.clone() 707 | )?)?; 708 | m.add_function(wrap_pyfunction!( 709 | body_force_density_linear_filament, 710 | m.clone() 711 | )?)?; 712 | 713 | // Differential operators 714 | m.add_function(wrap_pyfunction!(gs_operator_order2, m.clone())?)?; 715 | m.add_function(wrap_pyfunction!(gs_operator_order4, m.clone())?)?; 716 | 717 | // Pure math 718 | m.add_function(wrap_pyfunction!(ellipe, m.clone())?)?; 719 | m.add_function(wrap_pyfunction!(ellipk, m.clone())?)?; 720 | 721 | // Filamentization and meshing 722 | m.add_function(wrap_pyfunction!(filament_helix_path, m.clone())?)?; 723 | m.add_function(wrap_pyfunction!(rotate_filaments_about_path, m.clone())?)?; 724 | 725 | // Point sources 726 | m.add_function(wrap_pyfunction!(flux_density_dipole, m.clone())?)?; 727 | 728 | Ok(()) 729 | } 730 | 731 | /// Convert a 3-tuple of PyArray to read-only slices, shadowing the original name 732 | macro_rules! _3tup_slice_ro { 733 | ($x:ident) => { 734 | let _ro = ($x.0.readonly(), $x.1.readonly(), $x.2.readonly()); 735 | let $x = (_ro.0.as_slice()?, _ro.1.as_slice()?, _ro.2.as_slice()?); 736 | }; 737 | } 738 | 739 | /// Convert a 2-tuple of PyArray to read-only slices, shadowing the original name 740 | macro_rules! _2tup_slice_ro { 741 | ($x:ident) => { 742 | let _ro = ($x.0.readonly(), $x.1.readonly()); 743 | let $x = (_ro.0.as_slice()?, _ro.1.as_slice()?); 744 | }; 745 | } 746 | 747 | /// Convert a 3-tuple of PyArray to read-write slices, shadowing the original name 748 | macro_rules! _3tup_slice_mut { 749 | ($x:ident) => { 750 | let mut _rw = ($x.0.readwrite(), $x.1.readwrite(), $x.2.readwrite()); 751 | let $x = ( 752 | _rw.0.as_slice_mut()?, 753 | _rw.1.as_slice_mut()?, 754 | _rw.2.as_slice_mut()?, 755 | ); 756 | }; 757 | } 758 | 759 | /// Assemble an Ok((x, y, z)) for 3 output arrays of potentially different types 760 | macro_rules! _3tup_ret { 761 | (($x:ident, $xt:ty), ($y:ident, $yt:ty), ($z:ident, $zt:ty)) => { 762 | // Acquire global interpreter lock, which will be released when it goes out of scope 763 | Python::with_gil(|py| { 764 | let $x: Py> = PyArray1::from_vec(py, $x).unbind(); 765 | let $y: Py> = PyArray1::from_vec(py, $y).unbind(); 766 | let $z: Py> = PyArray1::from_vec(py, $z).unbind(); 767 | 768 | Ok(($x, $y, $z)) 769 | }) 770 | }; 771 | } 772 | 773 | pub(crate) use _2tup_slice_ro; 774 | pub(crate) use _3tup_ret; 775 | pub(crate) use _3tup_slice_mut; 776 | pub(crate) use _3tup_slice_ro; 777 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cfs-energy/cfsem-py/e1f7d4394365d27279eee1f6570e8f9fe2437625/test/__init__.py -------------------------------------------------------------------------------- /test/test_electromagnetics.py: -------------------------------------------------------------------------------- 1 | """Tests of standalone electromagnetics calcs""" 2 | 3 | import numpy as np 4 | from pytest import approx, mark, raises 5 | 6 | import cfsem 7 | 8 | from . import test_funcs as _test 9 | 10 | 11 | @mark.parametrize("r", [0.775, np.pi]) 12 | @mark.parametrize("z", [0.0, np.e / 2, -np.e / 2]) 13 | @mark.parametrize("par", [True, False]) 14 | def test_body_force_density(r, z, par): 15 | """Spot check bindings; more complete tests are run in Rust""" 16 | xp = np.linspace(0.1, 0.8, 5) 17 | yp = np.zeros(5) 18 | zp = np.linspace(-1.0, 1.0, 5) 19 | xmesh, ymesh, zmesh = np.meshgrid(xp, yp, zp, indexing="ij") 20 | xmesh = xmesh.flatten() 21 | ymesh = ymesh.flatten() 22 | zmesh = zmesh.flatten() 23 | obs = (xmesh, ymesh, zmesh) 24 | 25 | rng = np.random.default_rng(1234098) 26 | j = [rng.uniform(-1e6, 1e6, len(xmesh)) for _ in range(3)] 27 | 28 | fil, dlxyzfil = _test._filament_loop(r, z, 1000) 29 | xyzfil = (fil[0][:-1], fil[1][:-1], fil[2][:-1]) 30 | ifil = np.ones_like(xyzfil[0]) 31 | 32 | jxbx, jxby, jxbz = cfsem.body_force_density_circular_filament_cartesian( 33 | [1.0], [r], [z], obs, j, par 34 | ) 35 | jxbx1, jxby1, jxbz1 = cfsem.body_force_density_linear_filament( 36 | xyzfil, dlxyzfil, ifil, obs, j, par 37 | ) 38 | 39 | assert np.allclose(jxbx, jxbx1, rtol=1e-2, atol=1e-9) 40 | assert np.allclose(jxby, jxby1, rtol=1e-2, atol=1e-9) 41 | assert np.allclose(jxbz, jxbz1, rtol=1e-2, atol=1e-9) 42 | 43 | 44 | @mark.parametrize("r", [0.775 * 2, np.pi]) 45 | @mark.parametrize("z", [0.0, np.e / 2, -np.e / 2]) 46 | @mark.parametrize("par", [True, False]) 47 | def test_flux_density_dipole(r, z, par): 48 | """Spot check bindings; more complete tests are run in Rust""" 49 | r = r / 300.0 # Make a very small filament 50 | xp = np.linspace(0.1, 0.8, 5) 51 | yp = np.zeros(5) 52 | zp = np.linspace(-1.0, 1.0, 5) 53 | area = np.pi * r**2 # m^2 54 | xmesh, ymesh, zmesh = np.meshgrid(xp, yp, zp, indexing="ij") 55 | xmesh = xmesh.flatten() 56 | ymesh = ymesh.flatten() 57 | zmesh = zmesh.flatten() 58 | 59 | bx, by, bz = cfsem.flux_density_circular_filament_cartesian( 60 | [1.0], [r], [z], (xmesh, ymesh, zmesh), par 61 | ) 62 | 63 | bxd, byd, bzd = cfsem.flux_density_dipole( 64 | loc=([0.0], [0.0], [z]), 65 | moment=([0.0], [0.0], [area]), 66 | xyzp=(xmesh, ymesh, zmesh), 67 | par=par, 68 | ) 69 | 70 | assert np.allclose(bx, bxd, rtol=5e-2, atol=1e-12) 71 | assert np.allclose(by, byd, rtol=5e-2, atol=1e-12) 72 | assert np.allclose(bz, bzd, rtol=5e-2, atol=1e-12) 73 | 74 | # Make sure we're not just comparing numbers that are too small to examine properly 75 | assert not np.allclose(by, bzd, rtol=5e-2, atol=1e-12) 76 | 77 | 78 | @mark.parametrize("r", [0.775, np.pi]) 79 | @mark.parametrize("z", [0.0, np.e / 2, -np.e / 2]) 80 | @mark.parametrize("par", [True, False]) 81 | def test_mutual_inductance_circular_to_linear(r, z, par): 82 | """Spot check bindings; more complete tests are run in Rust""" 83 | r1, z1 = (r + 0.1, abs(z) ** 0.5) 84 | fil, _dl = _test._filament_loop(r, z, ndiscr=200) 85 | fil1, dl1 = _test._filament_loop(r1, z1, ndiscr=200) 86 | (x1, y1, z1) = fil1 87 | 88 | m_linear = cfsem.mutual_inductance_piecewise_linear_filaments(fil, fil1) 89 | m_circular = cfsem.flux_circular_filament([1.0], [r], [z], [r1], [z1]) 90 | m_circular_to_linear = cfsem.mutual_inductance_circular_to_linear( 91 | [r], [z], [1.0], (x1[:-1], y1[:-1], z1[:-1]), dl1, par 92 | ) 93 | 94 | # Linear discretization really is not very good unless we use a number of discretizations that is 95 | # not reasonable for testing, so the tolerances are pretty loose 96 | assert m_circular_to_linear == approx(m_circular, rel=0.2) 97 | assert m_circular_to_linear == approx(m_linear, rel=0.2) 98 | 99 | 100 | @mark.parametrize("r", [0.775, np.pi]) 101 | @mark.parametrize("z", [0.0, np.e / 2, -np.e / 2]) 102 | @mark.parametrize("par", [True, False]) 103 | def test_flux_density_circular_filament_cartesian(r, z, par): 104 | """Spot check bindings; more complete tests are run in Rust""" 105 | xp = np.linspace(0.1, 0.8, 5) 106 | yp = np.zeros(5) 107 | zp = np.linspace(-1.0, 1.0, 5) 108 | xmesh, ymesh, zmesh = np.meshgrid(xp, yp, zp, indexing="ij") 109 | xmesh = xmesh.flatten() 110 | ymesh = ymesh.flatten() 111 | zmesh = zmesh.flatten() 112 | 113 | bx, by, bz = cfsem.flux_density_circular_filament_cartesian( 114 | [1.0], [r], [z], (xmesh, ymesh, zmesh), par 115 | ) 116 | br, bz_circ = cfsem.flux_density_circular_filament( 117 | [1.0], [r], [z], xmesh, zmesh, par 118 | ) 119 | 120 | assert np.allclose(bx, br, rtol=1e-6, atol=1e-10) 121 | assert np.allclose(bz, bz_circ, rtol=1e-6, atol=1e-10) 122 | assert np.allclose(by, np.zeros_like(by), atol=1e-10) 123 | 124 | 125 | @mark.parametrize("r", [7.7, np.pi]) # Needs to be large for Lyle with very small width 126 | @mark.parametrize("z", [0.0, np.e / 2]) 127 | @mark.parametrize("h_over_r", [5e-2, 0.25, 1.0]) 128 | def test_self_inductance_piecewise_linear_filaments(r, z, h_over_r): 129 | # Test self inductance via neumann's formula 130 | # against Lyle's calc for finite-thickness coils 131 | w = 0.001 # [m] can't be infinitesimally thin for Lyle's calc, but can be very thin compared to height and radius 132 | h = h_over_r * r # [m] 133 | 134 | nt = 13 # number of turns 135 | n = int(1e4) 136 | 137 | thetas = np.linspace(0.0, 2.0 * np.pi * nt, n, endpoint=True) 138 | 139 | x1 = np.cos(thetas) * r 140 | y1 = np.sin(thetas) * r 141 | z1 = np.linspace(z - h / 2, z + h / 2, n) 142 | 143 | xyz1 = np.vstack((x1, y1, z1)) 144 | 145 | self_inductance_piecewise_linear = cfsem.self_inductance_piecewise_linear_filaments( 146 | xyz1 147 | ) # [H] 148 | 149 | self_inductance_lyle6 = cfsem.self_inductance_lyle6(r, w, h, nt) # [H] 150 | 151 | assert self_inductance_piecewise_linear == approx(self_inductance_lyle6, rel=5e-2) 152 | 153 | 154 | @mark.parametrize("r1", [0.5, np.pi]) 155 | @mark.parametrize("r2", [0.1, np.pi / 10.0]) 156 | @mark.parametrize("z", [0.0, np.e / 2, -np.e / 2]) 157 | @mark.parametrize("par", [True, False]) 158 | def test_mutual_inductance_piecewise_linear_filaments(r1, r2, z, par): 159 | # Test against calc for mutual inductance of circular filaments 160 | rzn1 = np.array([[r1], [z], [1.0]]) 161 | rzn2 = np.array([[r2], [-z / np.e], [1.0]]) 162 | 163 | m_circular = cfsem.mutual_inductance_of_circular_filaments(rzn1, rzn2, par) 164 | 165 | n = 100 166 | 167 | thetas = np.linspace(0.0, 2.0 * np.pi, n, endpoint=True) 168 | 169 | x1 = np.cos(thetas) * rzn1[0] 170 | y1 = np.sin(thetas) * rzn1[0] 171 | z1 = np.ones_like(thetas) * rzn1[1] 172 | 173 | x2 = np.cos(thetas) * rzn2[0] 174 | y2 = np.sin(thetas) * rzn2[0] 175 | z2 = np.ones_like(thetas) * rzn2[1] 176 | 177 | xyz1 = np.vstack((x1, y1, z1)) 178 | xyz2 = np.vstack((x2, y2, z2)) 179 | 180 | m_piecewise_linear = cfsem.mutual_inductance_piecewise_linear_filaments(xyz1, xyz2) 181 | 182 | assert np.allclose([m_circular], [m_piecewise_linear], rtol=1e-4) 183 | 184 | 185 | @mark.parametrize("r", [0.1, np.pi / 10.0]) 186 | @mark.parametrize("par", [True, False]) 187 | def test_biot_savart_against_flux_density_ideal_solenoid(r, par): 188 | # Check Biot-Savart calc against ideal solenoid calc 189 | length = 20.0 * r # [m] 190 | num_turns = 7 # [#] 191 | current = np.e # [A] 192 | 193 | # Ideal calc 194 | b_ideal = cfsem.flux_density_ideal_solenoid(current, num_turns, length) # [T] 195 | 196 | # Biot-Savart calc should produce the same magnitude 197 | # Build a spiral coil 198 | n_filaments = int(1e4) 199 | x1 = np.linspace(-length / 2, length / 2, n_filaments + 1) 200 | y1 = r * np.cos(num_turns * 2.0 * np.pi * x1 / length) 201 | z1 = r * np.sin(num_turns * 2.0 * np.pi * x1 / length) 202 | xyz1 = np.stack((x1, y1, z1), 1).T 203 | dl1 = xyz1[:, 1:] - xyz1[:, 0:-1] 204 | dlxyzfil = ( 205 | np.ascontiguousarray(dl1[0, :]), 206 | np.ascontiguousarray(dl1[1, :]), 207 | np.ascontiguousarray(dl1[2, :]), 208 | ) 209 | ifil = current * np.ones(n_filaments) 210 | xyzfil = (x1[:-1], y1[:-1], z1[:-1]) 211 | # Get B-field at the origin 212 | zero = np.array([0.0]) 213 | bx, _by, _bz = cfsem.flux_density_biot_savart( 214 | xyzp=(zero, zero, zero), xyzfil=xyzfil, dlxyzfil=dlxyzfil, ifil=ifil, par=par 215 | ) 216 | b_bs = bx[0] # [T] First and only element on the axis of the solenoid 217 | 218 | assert b_bs == approx(b_ideal, rel=1e-2) 219 | 220 | 221 | @mark.parametrize("r", [0.775, np.pi]) 222 | @mark.parametrize("z", [0.0, np.e / 2, -np.e / 2]) 223 | @mark.parametrize("par", [True, False]) 224 | def test_biot_savart_against_flux_density_circular_filament(r, z, par): 225 | # Note we are mapping between (x, y, z) and (r, phi, z) coordinates here 226 | 227 | # Biot-Savart filaments in cartesian coords 228 | n_filaments = int(1e4) 229 | phi = np.linspace(0.0, 2.0 * np.pi, n_filaments) 230 | xfils = r * np.cos(phi) 231 | yfils = r * np.sin(phi) 232 | zfils = np.ones_like(xfils) * z 233 | 234 | # Observation grid 235 | rs = np.linspace(0.01, r - 0.1, 10) 236 | zs = np.linspace(-1.0, 1.0, 10) 237 | 238 | R, Z = np.meshgrid(rs, zs, indexing="ij") 239 | rprime = R.flatten() 240 | zprime = Z.flatten() 241 | 242 | # Circular filament calc 243 | # [T] 244 | Br_circular, Bz_circular = cfsem.flux_density_circular_filament( 245 | np.ones(1), np.array([r]), np.array([z]), rprime, zprime, par 246 | ) 247 | 248 | # Biot-Savart calc 249 | xyzp = (rprime, np.zeros_like(zprime), zprime) 250 | xyzfil = (xfils[1:], yfils[1:], zfils[1:]) 251 | dlxyzfil = (xfils[1:] - xfils[:-1], yfils[1:] - yfils[:-1], zfils[1:] - zfils[:-1]) 252 | ifil = np.ones_like(xfils[1:]) 253 | Br_bs, By_bs, Bz_bs = cfsem.flux_density_biot_savart( 254 | xyzp, xyzfil, dlxyzfil, ifil, par 255 | ) # [T] 256 | 257 | assert np.allclose( 258 | Br_circular, Br_bs, rtol=1e-6, atol=1e-7 259 | ) # Should match circular calc 260 | assert np.allclose(Bz_circular, Bz_bs, rtol=1e-6, atol=1e-7) # ... 261 | assert np.allclose( 262 | By_bs, np.zeros_like(By_bs), atol=1e-7 263 | ) # Should sum to zero everywhere 264 | 265 | 266 | @mark.parametrize("r", [0.775, np.pi]) 267 | @mark.parametrize("z", [0.0, np.e / 2, -np.e / 2]) 268 | @mark.parametrize("par", [True, False]) 269 | def test_flux_circular_filament_against_mutual_inductance_of_cylindrical_coils( 270 | r, z, par 271 | ): 272 | # Two single-turn coils with irrelevant cross-section, 273 | # each discretized into a single filament 274 | rc1 = r # Coil center radii 275 | rc2 = 10.0 * r # Large enough to be much larger than 1 276 | rzn1 = cfsem.filament_coil(rc1, z, 0.05, 0.05, 1.5, 2, 2) 277 | rzn2 = cfsem.filament_coil(rc2, -z, 0.05, 0.05, 1.5, 2, 2) 278 | 279 | # Unpack and copy to make contiguous in memory 280 | r1, z1, n1 = rzn1.T 281 | r2, z2, n2 = rzn2.T 282 | r1, z1, n1, r2, z2, n2 = [x.copy() for x in [r1, z1, n1, r2, z2, n2]] 283 | 284 | # Calculate mutual inductance between these two filaments 285 | f1 = np.array((r1, z1, n1)) 286 | f2 = np.array((r2, z2, n2)) 287 | m_filaments = cfsem.mutual_inductance_of_cylindrical_coils(f1, f2, par) 288 | 289 | # Calculate mutual inductance via python test calc 290 | # and test the mutual inductance of coils calc. 291 | # This also tests the mutual_inductance_of_circular_filaments calc 292 | # against the python version at the same time. 293 | m_filaments_test = _test._mutual_inductance_of_cylindrical_coils(f1.T, f2.T) 294 | assert abs(1 - m_filaments / m_filaments_test) < 1e-6 295 | 296 | # Do flux calcs 297 | psi_2to1 = np.sum(n1 * cfsem.flux_circular_filament(n2, r2, z2, r1, z1, par)) 298 | psi_1to2 = np.sum(n2 * cfsem.flux_circular_filament(n1, r1, z1, r2, z2, par)) 299 | 300 | # Because the integrated poloidal flux at a given location is the same as mutual inductance, 301 | # we should get the same number using our mutual inductance calc 302 | current = 1.0 # 1A reference current just for clarity 303 | m_from_psi = psi_2to1 / current 304 | assert abs(1 - m_from_psi / m_filaments) < 1e-6 305 | 306 | # Because mutual inductance is reflexive, reversing the direction of the check should give the same result 307 | # so we can check to make sure the psi calc gives the same result in both directions 308 | assert psi_2to1 == approx(psi_1to2, rel=1e-6) 309 | 310 | 311 | @mark.parametrize("r", [0.775, np.pi]) 312 | @mark.parametrize("z", [0.0, np.e / 2, -np.e / 2]) 313 | @mark.parametrize("par", [True, False]) 314 | def test_flux_density_circular_filament_against_flux_circular_filament(r, z, par): 315 | rzn1 = cfsem.filament_coil(r, z, 0.05, 0.05, 1.0, 4, 4) 316 | rfil, zfil, _ = rzn1.T 317 | ifil = np.ones_like(rfil) 318 | 319 | rs = np.linspace(0.01, min(rfil) - 0.1, 10) 320 | zs = np.linspace(-1.0, 1.0, 10) 321 | 322 | R, Z = np.meshgrid(rs, zs, indexing="ij") 323 | rprime = R.flatten() 324 | zprime = Z.flatten() 325 | 326 | Br, Bz = cfsem.flux_density_circular_filament( 327 | ifil, rfil, zfil, rprime, zprime, par 328 | ) # [T] 329 | 330 | # We can also get B from the derivative of the flux function (Wesson eqn 3.2.2), 331 | # so we'll use that to check that we get the same result. 332 | # Wesson uses flux per radian (as opposed to our total flux), so we have to adjust out a factor 333 | # of 2*pi in the conversion from flux to B-field. This makes sense because we are converting 334 | # between the _integral_ of B (psi) and B itself, so we should see a factor related to the 335 | # space we integrated over to get psi. 336 | 337 | dr = 1e-4 338 | dz = 1e-4 339 | psi = cfsem.flux_circular_filament(ifil, rfil, zfil, rprime, zprime, par) 340 | dpsidz = ( 341 | cfsem.flux_circular_filament(ifil, rfil, zfil, rprime, zprime + dz, par) - psi 342 | ) / dz 343 | dpsidr = ( 344 | cfsem.flux_circular_filament(ifil, rfil, zfil, rprime + dr, zprime, par) - psi 345 | ) / dr 346 | 347 | Br_from_psi = -dpsidz / rprime / (2.0 * np.pi) # [T] 348 | Bz_from_psi = dpsidr / rprime / (2.0 * np.pi) # [T] 349 | 350 | assert np.allclose(Br, Br_from_psi, rtol=1e-2) 351 | assert np.allclose(Bz, Bz_from_psi, rtol=1e-2) 352 | 353 | 354 | @mark.parametrize("r", [np.e / 100, 0.775, np.pi]) 355 | @mark.parametrize("par", [True, False]) 356 | def test_flux_density_circular_filament_against_ideal_solenoid(r, par): 357 | # We can also check against the ideal solenoid calc to make sure we don't have a systematic 358 | # offset or scaling error 359 | 360 | length = 20.0 * r # [m] 361 | rzn1 = cfsem.filament_coil(r, 0.0, 0.05, length, 1.0, 1, 40) 362 | rfil, zfil, _ = rzn1.T 363 | ifil = np.ones_like(rfil) 364 | 365 | b_ideal = cfsem.flux_density_ideal_solenoid( 366 | current=1.0, num_turns=ifil.size, length=length 367 | ) # [T] ideal solenoid Bz at origin 368 | _, bz_origin = cfsem.flux_density_circular_filament( 369 | ifil, rfil, zfil, np.zeros(1), np.zeros(1), par 370 | ) 371 | 372 | assert np.allclose(np.array([b_ideal]), bz_origin, rtol=1e-2) 373 | 374 | 375 | @mark.parametrize("r", [np.e / 100, 0.775, np.pi]) 376 | @mark.parametrize("par", [True, False]) 377 | def test_flux_density_circular_filament_against_ideal_loop(r, par): 378 | # We can also check against an ideal current loop calc 379 | # http://hyperphysics.phy-astr.gsu.edu/hbase/magnetic/curloo.html 380 | 381 | current = 1.0 # [A] 382 | ifil = np.array([current]) 383 | rfil = np.array([r]) 384 | zfil = np.array([0.0]) 385 | 386 | b_ideal = cfsem.MU_0 * current / (2.0 * r) # [T] ideal loop Bz at origin 387 | _, bz_origin = cfsem.flux_density_circular_filament( 388 | ifil, rfil, zfil, np.zeros(1), np.zeros(1), par 389 | ) 390 | 391 | assert np.allclose(np.array([b_ideal]), bz_origin, rtol=1e-6) 392 | 393 | 394 | @mark.parametrize("a", [0.775, np.pi]) 395 | @mark.parametrize("z", [0.0, np.e / 2, -np.e / 2]) 396 | @mark.parametrize("par", [True, False]) 397 | def test_flux_density_circular_filament_against_numerical(a, z, par): 398 | # Test the elliptic-integral calc for B-field of a loop against numerical integration 399 | 400 | n = 10 401 | rs = np.linspace(0.1, 10.0, n) 402 | zs = np.linspace(-5.0, 5.0, n) 403 | 404 | R, Z = np.meshgrid(rs, zs, indexing="ij") 405 | rprime = R.flatten() 406 | zprime = Z.flatten() 407 | 408 | current = 1.0 # 1A reference current 409 | 410 | # Calc using elliptic integral fits 411 | Br, Bz = cfsem.flux_density_circular_filament( 412 | np.array([current]), np.array([a]), np.array([z]), rprime, zprime, par 413 | ) # [T] 414 | 415 | # Calc using numerical integration around the loop 416 | Br_num = np.zeros_like(Br) 417 | Bz_num = np.zeros_like(Br) 418 | for i, x in enumerate(zip(rprime, zprime)): 419 | robs, zobs = x 420 | Br_num[i], Bz_num[i] = _test._flux_density_circular_filament_numerical( 421 | current, a, robs, zobs - z, n=100 422 | ) 423 | 424 | assert np.allclose(Br, Br_num) 425 | assert np.allclose(Bz, Bz_num) 426 | 427 | 428 | @mark.parametrize("par", [True, False]) 429 | def test_self_inductance_lyle6_against_filamentization_and_distributed(par): 430 | # Test that the Lyle approximation gives a similar result to 431 | # a case done by brute-force filamentization w/ a heuristic for self-inductance of a loop 432 | r, z, dr, dz, nt, nr, nz = (0.8, 0.0, 0.5, 2.0, 3.0, 20, 20) 433 | L_Lyle = cfsem.self_inductance_lyle6( 434 | r, dr, dz, nt 435 | ) # Estimate self-inductance via closed-form approximation 436 | L_fil = _test._self_inductance_filamentized( 437 | r, z, dr, dz, nt, nr, nz 438 | ) # Estimate self-inductance via discretization 439 | 440 | # Set up distributed-conductor solve 441 | fils = cfsem.filament_coil(r, z, dr, dz, nt, nr, nz) 442 | rfil, zfil, _ = fils.T 443 | current = np.ones_like(rfil) / rfil.size # [A] 1A total reference current 444 | rgrid = np.arange(0.5, 2.0, 0.05) 445 | zgrid = np.arange(-3.0, 3.0, 0.05) 446 | rmesh, zmesh = np.meshgrid(rgrid, zgrid, indexing="ij") 447 | # Do filamentized psi and B calcs for convenience, 448 | # although ideally we'd do a grad-shafranov solve here for a smoother field 449 | psi = cfsem.flux_circular_filament( 450 | current, rfil, zfil, rmesh.flatten(), zmesh.flatten(), par 451 | ) 452 | psi = psi.reshape(rmesh.shape) 453 | br, bz = cfsem.flux_density_circular_filament( 454 | current, rfil, zfil, rmesh.flatten(), zmesh.flatten(), par 455 | ) 456 | br = br.reshape(rmesh.shape) 457 | bz = bz.reshape(rmesh.shape) 458 | # Build up the mask of the conductor region 459 | rmin = r - dr / 2 460 | rmax = r + dr / 2 461 | zmin = z - dz / 2 462 | zmax = z + dz / 2 463 | mask = np.where(rmesh > rmin, True, False) 464 | mask *= np.where(rmesh < rmax, True, False) 465 | mask *= np.where(zmesh > zmin, True, False) 466 | mask *= np.where(zmesh < zmax, True, False) 467 | # Build a rough approximation of the conductor bounding contour 468 | rleft = (rmin - 0.05) * np.ones(10) 469 | rtop = np.linspace(rmin - 0.05, rmax + 0.05, 10) 470 | rright = (rmax + 0.05) * np.ones(10) 471 | rbot = rtop[::-1] 472 | rpath = np.concatenate((rleft, rtop, rright, rbot)) 473 | zleft = np.linspace(zmin - 0.05, zmax + 0.05, 10) 474 | ztop = (zmax + 0.05) * np.ones(10) 475 | zright = zleft[::-1] 476 | zbot = (zmin - 0.05) * np.ones(10) 477 | zpath = np.concatenate((zleft, ztop, zright, zbot)) 478 | # Do the distributed conductor calc 479 | L_distributed, _, _ = cfsem.self_inductance_distributed_axisymmetric_conductor( 480 | current=1.0, 481 | grid=(rgrid, zgrid), 482 | mesh=(rmesh, zmesh), 483 | b_part=(br, bz), 484 | psi_part=psi, 485 | mask=mask, 486 | edge_path=(rpath, zpath), 487 | ) 488 | 489 | # Require 5% accuracy (seat of the pants, since we're comparing approximations) 490 | assert L_Lyle == approx(L_fil, 0.05) 491 | assert (nt**2 * L_distributed) == approx(L_fil, 0.05) 492 | 493 | 494 | @mark.parametrize("r", [0.775, np.pi]) 495 | @mark.parametrize("dr", [0.001, 0.02]) 496 | @mark.parametrize("nt", [1.0, 7.7]) 497 | def test_self_inductance_lyle6_against_wien(r, dr, nt): 498 | """Test that the Lyle approximation gives a similar result to 499 | Wien's formula for self-inductance of a thin circular loop.""" 500 | r, dr, dz, nt = (r, dr, dr, nt) 501 | L_Lyle = cfsem.self_inductance_lyle6( 502 | r, dr, dz, nt 503 | ) # [H] Estimate self-inductance via closed-form approximation 504 | L_wien = nt**2 * cfsem.self_inductance_circular_ring_wien( 505 | major_radius=r, minor_radius=(0.5 * (dr**2 + dz**2) ** 0.5) 506 | ) # [H] Estimate self-inductance via Wien's formula 507 | assert L_Lyle == approx(L_wien, rel=0.05) # Require 5% accuracy (seat of the pants) 508 | 509 | 510 | def test_wien_against_paper_examples(): 511 | """ 512 | Test self_inductance_circular_ring_wien againts the examples in the paper it is taken from. 513 | This is indirectly tested against a parametrized filamentization in test_self_inductance_annular_ring . 514 | """ 515 | major_radius_1 = 25e-2 516 | minor_radius_1 = 0.05e-2 517 | L_ref_1 = 654.40537 * np.pi * 1e-7 * 1e-2 # units: henry 518 | L_1 = cfsem.self_inductance_circular_ring_wien(major_radius_1, minor_radius_1) 519 | assert L_1 == approx(L_ref_1) 520 | 521 | major_radius_2 = 25e-2 522 | minor_radius_2 = 0.5e-2 523 | L_ref_2 = 424.1761 * np.pi * 1e-7 * 1e-2 # units: henry 524 | L_2 = cfsem.self_inductance_circular_ring_wien(major_radius_2, minor_radius_2) 525 | assert L_2 == approx(L_ref_2) 526 | 527 | 528 | @mark.parametrize("r", [0.775, 1.5]) 529 | @mark.parametrize("z", [0.0, np.pi]) 530 | @mark.parametrize("dr_over_r", [0.1, 0.2]) 531 | @mark.parametrize("dz_over_r", [0.1, 3.5]) 532 | @mark.parametrize("nt", [3.0, 400.0]) 533 | def test_self_inductance_lyle6_against_filamentized( 534 | r, z, dr_over_r, dz_over_r, nt 535 | ): 536 | # Test that the Lyle approximation gives a similar result to 537 | # a case done by brute-force filamentization w/ a heuristic for self-inductance of a loop 538 | r, z, dr, dz, nt, nr, nz = ( 539 | r, 540 | z, 541 | r * dr_over_r, 542 | r * dz_over_r, 543 | nt, 544 | 5, 545 | 100, 546 | ) 547 | L_Lyle = cfsem.self_inductance_lyle6( 548 | r, dr, dz, nt 549 | ) # Estimate self-inductance via closed-form approximation 550 | L_fil = _test._self_inductance_filamentized( 551 | r, z, dr, dz, nt, nr, nz 552 | ) # Estimate self-inductance via discretization 553 | assert float(L_Lyle) == approx(L_fil, 0.05) # Require 5% accuracy (seat of the pants) 554 | 555 | 556 | @mark.parametrize("major_radius", np.linspace(0.35, 1.25, 3, endpoint=True)) 557 | @mark.parametrize("a", np.linspace(0.01, 0.04, 3, endpoint=True)) 558 | @mark.parametrize("b", np.linspace(0.05, 0.1, 3, endpoint=True)) 559 | def test_self_inductance_annular_ring(major_radius, a, b): 560 | # First, test a near-solid version against Wien for a solid loop 561 | major_radius_1 = major_radius 562 | minor_radius_1 = b 563 | inner_minor_radius_1 = 1e-4 564 | 565 | L_wien_1 = cfsem.self_inductance_circular_ring_wien(major_radius_1, minor_radius_1) 566 | L_annular_1 = cfsem.self_inductance_annular_ring( 567 | major_radius_1, inner_minor_radius_1, minor_radius_1 568 | ) 569 | 570 | assert L_annular_1 == approx(L_wien_1, rel=1e-2) 571 | 572 | # Then, test thick hollow version against filamentization 573 | major_radius_2 = major_radius 574 | minor_radius_2 = b 575 | inner_minor_radius_2 = a 576 | 577 | L_annular_2 = cfsem.self_inductance_annular_ring( 578 | major_radius_2, inner_minor_radius_2, minor_radius_2 579 | ) 580 | 581 | n = 100 582 | rs = np.linspace( 583 | major_radius_2 - minor_radius_2, 584 | major_radius_2 + minor_radius_2, 585 | n, 586 | endpoint=True, 587 | ) 588 | 589 | zs = np.linspace( 590 | -minor_radius_2, 591 | minor_radius_2, 592 | n, 593 | endpoint=True, 594 | ) 595 | 596 | rmesh, zmesh = np.meshgrid(rs, zs, indexing="ij") 597 | mask = np.ones_like(rmesh) 598 | mask *= np.where( 599 | np.sqrt(zmesh**2 + (rmesh - major_radius_2) ** 2) <= minor_radius_2, True, False 600 | ) 601 | mask *= np.where( 602 | np.sqrt(zmesh**2 + (rmesh - major_radius_2) ** 2) >= inner_minor_radius_2, 603 | True, 604 | False, 605 | ) 606 | 607 | L_fil = _test._self_inductance_filamentized( 608 | major_radius_2, 609 | 0.0, 610 | minor_radius_2 * 2, 611 | minor_radius_2 * 2, 612 | nt=1.0, 613 | nr=10, 614 | nz=10, 615 | mask=(rs, zs, mask), 616 | ) # Estimate self-inductance via discretization 617 | 618 | assert L_annular_2 == approx(L_fil, rel=2e-2) 619 | 620 | # Exercise error handling 621 | with raises(ValueError): 622 | # Zero radius 623 | cfsem.self_inductance_annular_ring(0.1, 0.0, 0.01) 624 | 625 | with raises(ValueError): 626 | # Larger inner than outer 627 | cfsem.self_inductance_annular_ring(0.1, 0.02, 0.01) 628 | 629 | with raises(ValueError): 630 | # Larger outer than major 631 | cfsem.self_inductance_annular_ring(0.1, 0.01, 0.11) 632 | 633 | 634 | @mark.parametrize("r", [0.775, 1.51]) 635 | @mark.parametrize("z", [0.0, np.pi]) 636 | @mark.parametrize("par", [True, False]) 637 | def test_vector_potential_axisymmetric(r, z, par): 638 | # Spot-check vector potential against inductance calcs. 639 | # More detailed three-way tests against both B-field and inductance 640 | # are done in the Rust library. 641 | 642 | # Filament 643 | ifil = np.atleast_1d([1.0]) # [A] 1A total reference current 644 | rfil = np.atleast_1d([r]) 645 | zfil = np.atleast_1d([z]) 646 | 647 | # Observation points 648 | rgrid = np.arange(0.5, 2.0, 0.05) 649 | zgrid = np.arange(-3.0, 3.0, 0.05) 650 | rmesh, zmesh = np.meshgrid(rgrid, zgrid, indexing="ij") 651 | 652 | psi = cfsem.flux_circular_filament( 653 | ifil, rfil, zfil, rmesh.flatten(), zmesh.flatten(), par 654 | ) 655 | a_phi = cfsem.vector_potential_circular_filament( 656 | ifil, rfil, zfil, rmesh.flatten(), zmesh.flatten(), par 657 | ) 658 | 659 | # Integrate vector potential around a loop to get the flux 660 | psi_from_a = 2.0 * np.pi * rmesh.flatten() * a_phi # [Wb] 661 | 662 | # We should not be able to tell the difference above a single roundoff 663 | assert np.allclose(psi, psi_from_a, rtol=1e-16, atol=1e-16) 664 | 665 | 666 | @mark.parametrize("r", [0.775, np.pi]) 667 | @mark.parametrize("z", [0.0, np.e / 2, -np.e / 2]) 668 | @mark.parametrize("par", [True, False]) 669 | def test_vector_potential_linear_against_circular_filament(r, z, par): 670 | # Note we are mapping between (x, y, z) and (r, phi, z) coordinates here 671 | 672 | # Biot-Savart filaments in cartesian coords 673 | n_filaments = int(1e4) 674 | phi = np.linspace(0.0, 2.0 * np.pi, n_filaments) 675 | xfils = r * np.cos(phi) 676 | yfils = r * np.sin(phi) 677 | zfils = np.ones_like(xfils) * z 678 | 679 | # Observation grid 680 | rs = np.linspace(0.01, r - 0.1, 10) 681 | zs = np.linspace(-1.0, 1.0, 10) 682 | 683 | rmesh, zmesh = np.meshgrid(rs, zs, indexing="ij") 684 | rprime = rmesh.flatten() 685 | zprime = zmesh.flatten() 686 | 687 | # Circular filament calc 688 | a_phi = cfsem.vector_potential_circular_filament( 689 | np.ones(1), np.array([r]), np.array([z]), rprime, zprime, par 690 | ) # [V-s/m] 691 | 692 | # Biot-Savart calc 693 | xyzp = (rprime, np.zeros_like(zprime), zprime) 694 | xyzfil = (xfils[1:], yfils[1:], zfils[1:]) 695 | dlxyzfil = (xfils[1:] - xfils[:-1], yfils[1:] - yfils[:-1], zfils[1:] - zfils[:-1]) 696 | ifil = np.ones_like(xfils[1:]) 697 | ax, ay, az = cfsem.vector_potential_linear_filament( 698 | xyzp, xyzfil, dlxyzfil, ifil, par 699 | ) # [V-s/m] 700 | 701 | assert np.allclose(a_phi, ay, rtol=1e-12, atol=1e-12) # Should match circular calc 702 | assert np.allclose( 703 | az, np.zeros_like(az), atol=1e-9 704 | ) # Should sum to zero everywhere 705 | assert np.allclose(ax, np.zeros_like(ax), atol=1e-9) # ... 706 | -------------------------------------------------------------------------------- /test/test_examples.py: -------------------------------------------------------------------------------- 1 | """Run each example file, failing the test on any errors""" 2 | 3 | import os 4 | import pathlib 5 | import runpy 6 | 7 | import pytest 8 | 9 | os.environ["CFSEM_TESTING"] = "True" 10 | 11 | EXAMPLES_DIR = pathlib.Path(__file__).parent / "../examples" 12 | EXAMPLES = [ 13 | EXAMPLES_DIR / x 14 | for x in os.listdir(EXAMPLES_DIR) 15 | if (os.path.isfile(EXAMPLES_DIR / x) and x[-3:] == ".py") 16 | ] 17 | 18 | 19 | @pytest.mark.parametrize("example_file", EXAMPLES) 20 | def test_example(example_file: pathlib.Path): 21 | runpy.run_path(str(example_file), run_name="__main__") 22 | -------------------------------------------------------------------------------- /test/test_filaments.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from pytest import mark 3 | 4 | import cfsem 5 | 6 | from . import test_funcs as _test 7 | 8 | 9 | @mark.parametrize("r", [0.1, np.pi / 6.0]) 10 | @mark.parametrize("nt", [0.5, 13.0]) 11 | @mark.parametrize("twist_pitch", [0.1, np.pi / 10.0, 1.0, float("inf")]) 12 | @mark.parametrize("angle_offset", [0.0, 0.01, np.pi]) 13 | def test_filament_helix_path(r, nt, twist_pitch, angle_offset): 14 | # Make sure it doesn't produce any obvious error on a fairly complicated 3d path case 15 | n = 50 16 | x = np.linspace(0, 2 * np.pi * nt, n) 17 | y = 0.5**0.5 * np.cos(x) 18 | z = 0.5**0.5 * np.sin(2 * x) 19 | path = (x, y, z) 20 | helix = cfsem.filament_helix_path( 21 | path=path, 22 | helix_start_offset=(0.0, r, 0.0), 23 | twist_pitch=twist_pitch, 24 | angle_offset=angle_offset, 25 | ) 26 | 27 | helix = np.array(helix) 28 | path = np.array(path) 29 | 30 | # Make sure they are all the right distance from the path centerline 31 | distances = helix - path 32 | distance_err = np.sqrt(np.sum(distances * distances, axis=0)) - r 33 | assert np.allclose(distance_err, np.zeros_like(distance_err), atol=1e-3) 34 | # Make sure they are all perpendicular to the path centerline 35 | ds = path[:, 1:] - path[:, :-1] 36 | for i in range(n - 1): 37 | assert ( 38 | np.dot(ds[:, i], distances[:, i]) < 1e-3 39 | ), f"perpendicularity error at index {i}, helix {helix[i]}, path {path[i]}, ds {ds[i]}, radius {distances[i]}" 40 | 41 | # Make sure it produces the correct result for some simple analytic cases 42 | x = np.linspace(0.0, 1.0, 50) 43 | y = np.zeros_like(x) 44 | z = np.zeros_like(x) 45 | path = (x, y, z) 46 | 47 | first_radius = ( 48 | 0.01, 49 | r, 50 | 0.0, 51 | ) # [m] with a small out-of-plane component to test projection 52 | helix = cfsem.filament_helix_path( 53 | path=path, 54 | helix_start_offset=first_radius, 55 | twist_pitch=twist_pitch, 56 | angle_offset=angle_offset, 57 | ) 58 | 59 | yhelix = r * np.cos(2.0 * np.pi * x / twist_pitch + angle_offset) 60 | zhelix = r * np.sin(2.0 * np.pi * x / twist_pitch + angle_offset) 61 | 62 | helix_handcalc = (x, yhelix, zhelix) 63 | 64 | assert np.allclose(helix, helix_handcalc, rtol=5e-3) 65 | 66 | # Make sure rotate_filaments_about_path produces the same result 67 | # as building a helix with an angle offset 68 | helix_without_rotation = cfsem.filament_helix_path( 69 | path=path, 70 | helix_start_offset=first_radius, 71 | twist_pitch=twist_pitch, 72 | angle_offset=0.0, 73 | ) 74 | 75 | helix_post_rotated = cfsem.rotate_filaments_about_path( 76 | path=path, angle_offset=angle_offset, fils=helix_without_rotation 77 | ) 78 | 79 | assert np.allclose(helix, helix_post_rotated) 80 | 81 | 82 | def test_filament_coil(): 83 | r, z, dr, dz, nt, nr, nz = (1.0, 0.0, 0.1, 0.1, 7.0, 2, 2) 84 | f = cfsem.filament_coil(r, z, dr, dz, nt, nr, nz) 85 | assert f.shape[0] == 4 # Right number of filaments generated? 86 | assert f.shape[1] == 3 # Right number of components? 87 | assert np.sum(f[:, 2]) == nt # Turns sum to the right number? 88 | assert all( 89 | [rzn[0] < r + dr / 2 for rzn in f] 90 | ) # Are all the filaments inside the coil pack? 91 | assert all([rzn[0] > r - dr / 2 for rzn in f]) 92 | assert all([rzn[1] < z + dz / 2 for rzn in f]) 93 | assert all([rzn[1] > z - dz / 2 for rzn in f]) 94 | 95 | # Compare to earlier version of the function 96 | f_comprehension = _test._filament_coil_comprehension(r, z, dr, dz, nt, nr, nz) 97 | assert np.allclose(f, f_comprehension) 98 | -------------------------------------------------------------------------------- /test/test_funcs.py: -------------------------------------------------------------------------------- 1 | """Brute-force calculations for testing more efficient methods""" 2 | 3 | from typing import Optional 4 | 5 | import numpy as np 6 | from numpy.typing import NDArray 7 | from scipy.constants import mu_0 8 | from scipy.special import ellipe, ellipk 9 | from interpn import MultilinearRectilinear 10 | import cfsem 11 | 12 | 13 | def _self_inductance_filamentized( 14 | r, z, dr, dz, nt, nr, nz, mask: Optional[tuple[NDArray, NDArray, NDArray]] = None 15 | ) -> float: 16 | """ 17 | USE L_lyle6 INSTEAD! This function is for verification of L_lyle6 in 18 | unit testing, and is very slow 19 | 20 | Estimate self-inductance of filamentized coil pack 21 | using an approximation for the self-inductance of filaments as 22 | the mutual inductance between the center of the region of the pack 23 | associated with that filament and its inner edge 24 | 25 | Args: 26 | r (float): radius, coil center 27 | z (float): axial position, coil center 28 | dr (float): width of coil pack 29 | dz (float): height of coil pack 30 | nt (float): turns 31 | nr (int): radial discretizations 32 | nz (int): axial discretizations 33 | mask: (rgrid, zgrid, vals) mask describing where to keep or discard filaments 34 | 35 | Returns: 36 | float: [H], estimated self-inductance 37 | """ 38 | 39 | # Make estimate of same coil's self-inductance based on filamentized model 40 | fs = cfsem.filament_coil(r, z, dr, dz, nt, nr, nz) # Generate filaments 41 | # Filter filaments based on mask 42 | if mask is not None: 43 | interp = MultilinearRectilinear.new([mask[0], mask[1]], mask[2].flatten()) 44 | fs = np.array( 45 | [f for f in fs if interp.eval([np.array([f[0]]), np.array([f[1]])]) > 0.0] 46 | ) 47 | fs[:, 2] *= nt / np.sum(fs[:, 2]) 48 | 49 | # Mutual inductances between filaments, with erroneous elements on the diagonal 50 | nfil = fs.shape[0] 51 | M = np.zeros((nfil, nfil)) 52 | for i in range(nfil): 53 | for j in range(nfil): 54 | if i != j: 55 | # If this is a mutual inductance between two different filaments, use that calc 56 | M[i, j] = _mutual_inductance_of_circular_filaments( 57 | fs[i, :], fs[j, :] 58 | ) # [H] 59 | else: 60 | # Self-inductance of this filament 61 | major_radius_filament = fs[i, :][0] # [m] Filament radius 62 | minor_radius_filament = ( 63 | (dr / nr) / 2 64 | ) # [m] Heuristic approximation for effective wire radius of filament 65 | num_turns = fs[i, :][2] # [] Filament number of turns 66 | L_f = num_turns**2 * cfsem.self_inductance_circular_ring_wien( 67 | major_radius_filament, minor_radius_filament 68 | ) # [H] 69 | M[i, j] = ( 70 | L_f # [H] Rough estimate of self-inductance of conductor cross-section assigned to this filament 71 | ) 72 | 73 | # Since all current and turns values are equal across the filaments, effective L is just the sum of all elements of M 74 | L = np.sum(np.sum(M)) # [H] 75 | 76 | return L # [H] 77 | 78 | 79 | def _flux_density_circular_filament_numerical( 80 | I: float, a: float, r: float, z: float, n: int = 100 81 | ) -> tuple[float, float]: 82 | """ 83 | DO NOT USE - for unit testing, slow and not very precise 84 | 85 | Numerical integration approach to calculating B-field of a current loop 86 | 87 | Based on 8.02 notes 88 | https://web.mit.edu/8.02t/www/802TEAL3D/visualizations/coursenotes/modules/guide09.pdf 89 | appendix 1 90 | 91 | This function only exists to validate flux_density_circular_filament 92 | 93 | Args: 94 | I (float): [A] current 95 | a (float): [m] radius of current loop 96 | r (float): [m] r-coord of point to evaluate, relative to loop center 97 | z (float): [m] z-coord of point to evaluate, relative to loop axis 98 | n (float): [] number of discretization points 99 | """ 100 | 101 | mu_0_over_4pi = 1e-7 # Collapse some algebra to reduce float error 102 | a0 = mu_0_over_4pi * I * a # Leading term of both 103 | 104 | # Window out a small region near zero to avoid singularity 105 | phis = np.linspace(0.0 + 1e-8, 2.0 * np.pi - 1e-8, n) 106 | 107 | # Evaluate elliptic integrals numerically from calculus statement of PDE 108 | a1 = (a**2 + r**2 + z**2 - 2.0 * r * a * np.sin(phis)) ** -1.5 # Shared denominator 109 | 110 | Brs = a0 * z * np.sin(phis) * a1 111 | Br = np.trapezoid(x=phis, y=Brs) # [T] 112 | 113 | Bzs = a0 * (a - r * np.sin(phis)) * a1 114 | Bz = np.trapezoid(x=phis, y=Bzs) # [T] 115 | 116 | return Br, Bz # [T] 117 | 118 | 119 | def _mutual_inductance_of_circular_filaments(rzn1: NDArray, rzn2: NDArray) -> float: 120 | """ 121 | Analytic mutual inductance between ideal 122 | cylindrically-symmetric coaxial filament pair. 123 | 124 | This is equivalent to taking the flux produced by each circular filament from 125 | either collection of filaments to the other. Mutual inductance is reflexive, 126 | so the order of the inputs is not important. 127 | 128 | Args: 129 | rzn1 (array): 3x1 array (r [m], z [m], n []) coordinates and number of turns 130 | rzn2 (array): 3x1 array (r [m], z [m], n []) coordinates and number of turns 131 | 132 | Returns: 133 | float: [H] mutual inductance 134 | """ 135 | 136 | r1, z1, n1 = rzn1 137 | r2, z2, n2 = rzn2 138 | 139 | k2 = 4 * r1 * r2 / ((r1 + r2) ** 2 + (z1 - z2) ** 2) 140 | amp = 2 * mu_0 * r1 * r2 / np.sqrt((r1 + r2) ** 2 + (z1 - z2) ** 2) 141 | M0 = n1 * n2 * amp * ((2 - k2) * ellipk(k2) - 2 * ellipe(k2)) / k2 142 | 143 | return M0 # [H] 144 | 145 | 146 | def _mutual_inductance_of_cylindrical_coils(f1: NDArray, f2: NDArray) -> float: 147 | """ 148 | Analytical mutual inductance between two coaxial collections of ideal coils 149 | Each collection typically represents a discretized "real" cylindrically-symmetric 150 | coil of rectangular cross-section, but could have any cross-section as long as it 151 | maintains symmetry. 152 | 153 | Args: 154 | f1: m x 3 array of filament definitions like (r [m], z [m], n []) 155 | f2: m x 3 array of filament definitions like (r [m], z [m], n []) 156 | 157 | Returns: 158 | [H] mutual inductance of the two discretized coils 159 | """ 160 | M = 0.0 161 | for i in range(f1.shape[0]): 162 | for j in range(f2.shape[0]): 163 | M += _mutual_inductance_of_circular_filaments(f1[i, :], f2[j, :]) 164 | return M # [H] 165 | 166 | 167 | def _filament_coil_comprehension( 168 | r: float, z: float, w: float, h: float, nt: float, nr: int, nz: int 169 | ) -> NDArray: 170 | """ 171 | Create an array of filaments from coil cross-section, evenly spaced 172 | _inside_ the winding pack. No filaments are coincident with the coil surface. 173 | 174 | Args: 175 | r: [m] radius, coil center 176 | z: [m] axial position, coil center 177 | w: [m] width of coil pack 178 | h: [m] height of coil pack 179 | nt: turns 180 | nr: radial discretizations 181 | nz: axial discretizations 182 | 183 | Returns: 184 | (nr*nz) x 3, (r,z,n) of each filament 185 | """ 186 | 187 | rs = np.linspace(r - w * (nr - 1) / nr / 2, r + w * (nr - 1) / nr / 2, nr) 188 | zs = np.linspace(z - h * (nz - 1) / nz / 2, z + h * (nz - 1) / nz / 2, nz) 189 | 190 | rz = [(rr, zz) for rr in rs for zz in zs] 191 | R = [x[0] for x in rz] 192 | Z = [x[1] for x in rz] 193 | N = np.full_like(R, float(nt) / (nr * nz)) 194 | filaments = np.dstack([R, Z, N]).reshape(nr * nz, 3) 195 | 196 | return filaments 197 | 198 | 199 | def _filament_loop( 200 | r: float, z: float, ndiscr: int 201 | ) -> tuple[tuple[NDArray, NDArray, NDArray], tuple[NDArray, NDArray, NDArray]]: 202 | """Make linear filaments from a circular filament""" 203 | phi = np.linspace(0.0, 2.0 * np.pi, ndiscr) 204 | x = r * np.cos(phi) 205 | y = r * np.sin(phi) 206 | z = z * np.ones_like(x) 207 | 208 | dx = np.diff(x) 209 | dy = np.diff(y) 210 | dz = np.diff(z) 211 | 212 | return ((x, y, z), (dx, dy, dz)) 213 | -------------------------------------------------------------------------------- /test/test_gradshafranov.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | import numpy as np 5 | from scipy.sparse import csc_matrix 6 | 7 | from cfsem import gs_operator_order2, gs_operator_order4 8 | 9 | HERE_PATH = Path(os.path.dirname(os.path.abspath(__file__))) 10 | 11 | 12 | def test_gs_operators(): 13 | """Check that both Jardin's 2nd-order operator and our homebrewed 4th-order operator give similar results""" 14 | 15 | # Need high enough resolution that finite differences should be well-converged 16 | n = 200 17 | m = 201 18 | 19 | xmin, xmax = (5.0, 100.0) # Must not cross zero 20 | ymin, ymax = (-50.0, 50.0) 21 | 22 | z1func = lambda x, y: 3 * x + 7 * y 23 | z2func = lambda x, y: 3 * x**2 + 7 * y**2 24 | 25 | xs = np.linspace(xmin, xmax, n) 26 | ys = np.linspace(ymin, ymax, m) 27 | X, Y = np.meshgrid(xs, ys, indexing="ij") 28 | Z1 = z1func(X, Y) 29 | Z2 = z2func(X, Y) 30 | 31 | gs_op_order2_triplet = gs_operator_order2(xs, ys) 32 | gs_op_order4_triplet = gs_operator_order4(xs, ys) 33 | 34 | gs_op_order2 = csc_matrix( 35 | (gs_op_order2_triplet[0], (gs_op_order2_triplet[1], gs_op_order2_triplet[2])), 36 | shape=(n * m, n * m), 37 | ) 38 | gs_op_order4 = csc_matrix( 39 | (gs_op_order4_triplet[0], (gs_op_order4_triplet[1], gs_op_order4_triplet[2])), 40 | shape=(n * m, n * m), 41 | ) 42 | 43 | z1_op_order2 = gs_op_order2 @ Z1.flatten() 44 | z1_op_order4 = gs_op_order4 @ Z1.flatten() 45 | 46 | assert np.allclose(z1_op_order2, z1_op_order4, rtol=5e-3) 47 | 48 | z2_op_order2 = gs_op_order2 @ Z2.flatten() 49 | z2_op_order4 = gs_op_order4 @ Z2.flatten() 50 | 51 | assert np.allclose(z2_op_order2, z2_op_order4, rtol=1e-6) 52 | -------------------------------------------------------------------------------- /test/test_math.py: -------------------------------------------------------------------------------- 1 | """Test rusteq.math module against scipy/numpy""" 2 | 3 | import numpy as np 4 | from scipy.special import ellipe, ellipk 5 | 6 | import cfsem 7 | 8 | 9 | def test_ellipe(): 10 | # 64-bit version 11 | xs = np.linspace(0.0, 1.0 - 1e-7, 100) 12 | assert np.allclose(ellipe(xs), np.array([cfsem.ellipe(x) for x in xs])) 13 | 14 | 15 | def test_ellipk(): 16 | # 64-bit version 17 | xs = np.linspace(0.0, 1.0 - 1e-7, 100) 18 | assert np.allclose(ellipk(xs), np.array([cfsem.ellipk(x) for x in xs])) 19 | --------------------------------------------------------------------------------