├── tests ├── utils │ ├── __init__.py │ └── regression_results.py ├── regression_results │ ├── AUG_SepOS_result.nc │ └── SPARC_PRD_result.nc ├── test_unit_handling │ ├── test_conversions.py │ ├── test_custom_units.py │ └── test_default_units.py ├── test_infra │ ├── test_point_selection.py │ ├── conftest.py │ ├── test_line_selection.py │ ├── test_transform.py │ └── test_plotting.py ├── test_docs.py ├── test_cli.py ├── test_for_anonymous_algorithms.py ├── conftest.py ├── test_input_file_handling.py ├── test_confinement_switch.py ├── test_helpers.py ├── test_scrape_off_layer_model.py └── test_regression_against_cases.py ├── docs ├── doc_sources │ ├── bib.rst │ ├── Changes-to-SPARC-PRD.pdf │ ├── api.rst │ └── Usage.rst ├── static │ └── theme_overrides.css ├── Makefile └── index.rst ├── cfspopcon ├── formulas │ ├── plasma_profiles │ │ ├── PRF │ │ │ ├── metadata.yaml │ │ │ ├── width.csv │ │ │ └── aLT.csv │ │ ├── __init__.py │ │ ├── temperature_peaking.py │ │ └── density_peaking.py │ ├── auxiliary_power │ │ ├── __init__.py │ │ └── auxiliary_power.py │ ├── atomic_data │ │ └── __init__.py │ ├── plasma_pressure │ │ ├── plasma_temperature.py │ │ ├── __init__.py │ │ └── pressure.py │ ├── separatrix_conditions │ │ ├── power_crossing_separatrix.py │ │ ├── separatrix_operational_space │ │ │ ├── read_sepos_reference.py │ │ │ ├── __init__.py │ │ │ ├── density_limit.py │ │ │ ├── LH_transition.py │ │ │ ├── MHD_limit.py │ │ │ ├── AUG_SepOS_reference.yml │ │ │ └── sustainment_power.py │ │ └── __init__.py │ ├── scrape_off_layer │ │ ├── separatrix_density.py │ │ ├── two_point_model │ │ │ ├── __init__.py │ │ │ ├── required_power_loss_fraction.py │ │ │ ├── separatrix_pressure.py │ │ │ ├── momentum_loss_functions.py │ │ │ ├── target_electron_temp.py │ │ │ ├── target_electron_flux.py │ │ │ └── target_electron_density.py │ │ ├── separatrix_electron_temp.py │ │ ├── __init__.py │ │ └── heat_flux_density.py │ ├── radiated_power │ │ ├── basic_algorithms.py │ │ ├── impurity_radiated_power │ │ │ ├── __init__.py │ │ │ ├── radas.py │ │ │ └── radiated_power.py │ │ ├── __init__.py │ │ ├── bremsstrahlung.py │ │ ├── intrinsic_radiated_power_from_core.py │ │ └── synchrotron.py │ ├── __init__.py │ ├── impurities │ │ ├── set_up_impurity_concentration_array.py │ │ ├── __init__.py │ │ ├── impurity_charge_state.py │ │ ├── zeff_and_dilution_from_impurities.py │ │ └── impurity_array_helpers.py │ ├── energy_confinement │ │ ├── __init__.py │ │ ├── plasma_stored_energy.py │ │ └── read_energy_confinement_scalings.py │ ├── fusion_power │ │ ├── __init__.py │ │ ├── average_fuel_ion_mass.py │ │ ├── fusion_gain.py │ │ └── fusion_rates.py │ ├── metrics │ │ ├── __init__.py │ │ ├── heat_exhaust_metrics.py │ │ ├── larmor_radius.py │ │ └── greenwald_density.py │ ├── geometry │ │ ├── volume_integral.py │ │ ├── __init__.py │ │ └── analytical.py │ └── plasma_current │ │ ├── flux_consumption │ │ └── __init__.py │ │ ├── bootstrap_fraction.py │ │ ├── __init__.py │ │ └── safety_factor.py ├── plotting │ ├── __init__.py │ ├── plot_style_handling.py │ └── coordinate_formatter.py ├── shaping_and_selection │ ├── __init__.py │ └── line_selection.py ├── unit_handling │ ├── __init__.py │ └── setup_unit_handling.py ├── __init__.py ├── helpers.py ├── named_options.py └── cli.py ├── CONTRIBUTING.md ├── poetry.toml ├── .readthedocs.yaml ├── LICENSE ├── example_cases ├── AUG_SepOS │ ├── plot_sepos.yaml │ └── input.yaml └── SPARC_PRD │ ├── plot_remapped.yaml │ └── plot_popcon.yaml ├── .github └── workflows │ ├── stale.yml │ └── workflow_actions.yml ├── Dockerfile ├── .pre-commit-config.yaml ├── README.md ├── .gitignore └── pyproject.toml /tests/utils/__init__.py: -------------------------------------------------------------------------------- 1 | """Developer tools.""" 2 | -------------------------------------------------------------------------------- /docs/doc_sources/bib.rst: -------------------------------------------------------------------------------- 1 | Bibliography 2 | ============= 3 | 4 | .. bibliography:: 5 | :all: 6 | :style: unsrt 7 | -------------------------------------------------------------------------------- /docs/doc_sources/Changes-to-SPARC-PRD.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cfs-energy/cfspopcon/HEAD/docs/doc_sources/Changes-to-SPARC-PRD.pdf -------------------------------------------------------------------------------- /tests/regression_results/AUG_SepOS_result.nc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cfs-energy/cfspopcon/HEAD/tests/regression_results/AUG_SepOS_result.nc -------------------------------------------------------------------------------- /tests/regression_results/SPARC_PRD_result.nc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cfs-energy/cfspopcon/HEAD/tests/regression_results/SPARC_PRD_result.nc -------------------------------------------------------------------------------- /cfspopcon/formulas/plasma_profiles/PRF/metadata.yaml: -------------------------------------------------------------------------------- 1 | notes: "From Pablo Rodriguez-Fernandez, based on outputs from TRANSP." 2 | documentation: "" 3 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Please make sure to check out our [Developer's Guide](docs/doc_sources/dev_guide.rst). 2 | It will show you how to setup a development environment for this project and covers our contributing guidelines. 3 | -------------------------------------------------------------------------------- /cfspopcon/formulas/auxiliary_power/__init__.py: -------------------------------------------------------------------------------- 1 | """Routines to calculate the auxiliary (non-Ohmic, non-fusion) power.""" 2 | 3 | from .auxiliary_power import calc_auxiliary_power 4 | 5 | __all__ = ["calc_auxiliary_power"] 6 | -------------------------------------------------------------------------------- /cfspopcon/formulas/atomic_data/__init__.py: -------------------------------------------------------------------------------- 1 | """Interface to atomic data files.""" 2 | 3 | from .atomic_data import AtomicData, read_atomic_data 4 | from .coeff_interpolator import CoeffInterpolator 5 | 6 | __all__ = ["AtomicData", "CoeffInterpolator", "read_atomic_data"] 7 | -------------------------------------------------------------------------------- /poetry.toml: -------------------------------------------------------------------------------- 1 | # This tells poetry to build the cfspopcon virtual environment 2 | # in a .venv folder inside the cfspopcon repository. This makes 3 | # it easier to clean up afterwards (just delete the folder) 4 | # and to find the virtual environment to use with the Jupyter 5 | # notebooks. 6 | virtualenvs.in-project = true -------------------------------------------------------------------------------- /cfspopcon/plotting/__init__.py: -------------------------------------------------------------------------------- 1 | """Plotting functionality.""" 2 | 3 | from .coordinate_formatter import CoordinateFormatter 4 | from .plot_style_handling import read_plot_style 5 | from .plots import label_contour, make_plot, units_to_string 6 | 7 | __all__ = [ 8 | "CoordinateFormatter", 9 | "label_contour", 10 | "make_plot", 11 | "read_plot_style", 12 | "units_to_string", 13 | ] 14 | -------------------------------------------------------------------------------- /tests/test_unit_handling/test_conversions.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from cfspopcon.unit_handling import Quantity, convert_to_default_units, dimensionless_magnitude, ureg 4 | 5 | 6 | def test_conversion_of_dimensionless(): 7 | val = Quantity(2.0, ureg.percent) 8 | 9 | assert np.isclose(dimensionless_magnitude(val), 2e-2) 10 | assert np.isclose(convert_to_default_units(val, key="beta_total"), 2e-2) 11 | -------------------------------------------------------------------------------- /cfspopcon/formulas/plasma_pressure/plasma_temperature.py: -------------------------------------------------------------------------------- 1 | """Routines to calculate the plasma temperature.""" 2 | 3 | from ...algorithm_class import Algorithm 4 | 5 | calc_average_ion_temp_from_temperature_ratio = Algorithm.from_single_function( 6 | lambda average_electron_temp, ion_to_electron_temp_ratio: average_electron_temp * ion_to_electron_temp_ratio, 7 | return_keys=["average_ion_temp"], 8 | name="calc_average_ion_temp_from_temperature_ratio", 9 | ) 10 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: "ubuntu-20.04" 5 | tools: 6 | python: "3.10" 7 | jobs: 8 | post_create_environment: 9 | # Install poetry 10 | # https://python-poetry.org/docs/#installing-manually 11 | - pip install poetry 12 | post_install: 13 | # VIRTUAL_ENV needs to be set manually for now. 14 | # See https://github.com/readthedocs/readthedocs.org/pull/11152/ 15 | - VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry install 16 | 17 | sphinx: 18 | configuration: docs/conf.py 19 | -------------------------------------------------------------------------------- /tests/test_unit_handling/test_custom_units.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from cfspopcon.unit_handling import Quantity, ureg 4 | 5 | 6 | def test_custom_units(): 7 | assert np.isclose(Quantity(10.0, ureg.n19), Quantity(1.0, ureg.n20)) 8 | assert np.isclose(Quantity(1.0, ureg.n19), Quantity(1e19, ureg.m**-3)) 9 | assert np.isclose(Quantity(1.0, ureg.n20), Quantity(1e20, ureg.m**-3)) 10 | 11 | assert np.isclose(Quantity(100.0, ureg.percent), Quantity(1.0, ureg.dimensionless)) 12 | assert np.isclose(Quantity(100.0, ureg.percent), 1.0) 13 | -------------------------------------------------------------------------------- /cfspopcon/formulas/separatrix_conditions/power_crossing_separatrix.py: -------------------------------------------------------------------------------- 1 | """Calculate the power crossing the separatrix.""" 2 | 3 | import numpy as np 4 | 5 | from ...algorithm_class import Algorithm 6 | from ...unit_handling import Unitfull, ureg 7 | 8 | 9 | @Algorithm.register_algorithm(return_keys=["power_crossing_separatrix"]) 10 | def calc_power_crossing_separatrix(P_in: Unitfull, P_radiation: Unitfull) -> Unitfull: 11 | """Calculate the power crossing the separatrix.""" 12 | P_SOL = P_in - P_radiation 13 | return np.maximum(P_SOL, 0.0 * ureg.MW) 14 | -------------------------------------------------------------------------------- /cfspopcon/formulas/scrape_off_layer/separatrix_density.py: -------------------------------------------------------------------------------- 1 | """Routines to calculate the separatrix density.""" 2 | 3 | from ...algorithm_class import Algorithm 4 | from ...unit_handling import Unitfull 5 | 6 | 7 | @Algorithm.register_algorithm(return_keys=["separatrix_electron_density"]) 8 | def calc_separatrix_electron_density(nesep_over_nebar: Unitfull, average_electron_density: Unitfull) -> Unitfull: 9 | """Calculate the separatrix electron density, assuming a constant ratio to the average electron density.""" 10 | return nesep_over_nebar * average_electron_density 11 | -------------------------------------------------------------------------------- /cfspopcon/formulas/radiated_power/basic_algorithms.py: -------------------------------------------------------------------------------- 1 | """Basic algorithms to operate on the radiated power.""" 2 | 3 | import numpy as np 4 | 5 | from ...algorithm_class import Algorithm 6 | 7 | calc_f_rad_core = Algorithm.from_single_function( 8 | func=lambda P_radiation, P_in: P_radiation / P_in, return_keys=["core_radiated_power_fraction"], name="calc_f_rad_core" 9 | ) 10 | require_P_rad_less_than_P_in = Algorithm.from_single_function( 11 | lambda P_in, P_radiation: np.minimum(P_radiation, P_in), return_keys=["P_radiation"], name="require_P_rad_less_than_P_in" 12 | ) 13 | -------------------------------------------------------------------------------- /cfspopcon/formulas/scrape_off_layer/two_point_model/__init__.py: -------------------------------------------------------------------------------- 1 | """The extended two point model, based on the two-point-model from :cite:`stangeby_2018`.""" 2 | 3 | from .model import solve_two_point_model 4 | from .target_first_model import solve_target_first_two_point_model 5 | from .two_point_model_algorithms import ( 6 | two_point_model_fixed_fpow, 7 | two_point_model_fixed_qpart, 8 | two_point_model_fixed_tet, 9 | ) 10 | 11 | __all__ = [ 12 | "solve_target_first_two_point_model", 13 | "solve_two_point_model", 14 | "two_point_model_fixed_fpow", 15 | "two_point_model_fixed_qpart", 16 | "two_point_model_fixed_tet", 17 | ] 18 | -------------------------------------------------------------------------------- /cfspopcon/formulas/plasma_profiles/__init__.py: -------------------------------------------------------------------------------- 1 | """Routines to calculate estimate 1D profiles for the confined region.""" 2 | 3 | from .density_peaking import calc_density_peaking, calc_effective_collisionality, calc_electron_density_peaking, calc_ion_density_peaking 4 | from .plasma_profiles import calc_1D_plasma_profiles, calc_peaked_profiles 5 | from .temperature_peaking import calc_temperature_peaking 6 | 7 | __all__ = [ 8 | "calc_1D_plasma_profiles", 9 | "calc_density_peaking", 10 | "calc_effective_collisionality", 11 | "calc_electron_density_peaking", 12 | "calc_ion_density_peaking", 13 | "calc_peaked_profiles", 14 | "calc_temperature_peaking", 15 | ] 16 | -------------------------------------------------------------------------------- /cfspopcon/formulas/plasma_pressure/__init__.py: -------------------------------------------------------------------------------- 1 | """Routines to calculate the plasma pressure, beta and related quantities.""" 2 | 3 | from .beta import calc_beta_normalized, calc_beta_poloidal, calc_beta_toroidal, calc_beta_total, calc_troyon_limit 4 | from .plasma_temperature import calc_average_ion_temp_from_temperature_ratio 5 | from .pressure import calc_average_total_pressure, calc_peak_pressure 6 | 7 | __all__ = [ 8 | "calc_average_ion_temp_from_temperature_ratio", 9 | "calc_average_total_pressure", 10 | "calc_beta_normalized", 11 | "calc_beta_poloidal", 12 | "calc_beta_toroidal", 13 | "calc_beta_total", 14 | "calc_peak_pressure", 15 | "calc_troyon_limit", 16 | ] 17 | -------------------------------------------------------------------------------- /docs/static/theme_overrides.css: -------------------------------------------------------------------------------- 1 | /* Fix for: https://github.com/readthedocs/sphinx_rtd_theme/issues/301 2 | /* Fix taken from: https://github.com/readthedocs/sphinx_rtd_theme/pull/383/ */ 3 | span.eqno { 4 | margin-left: 5px; 5 | float: right; 6 | /* position the number above the equation so that :hover is activated */ 7 | z-index: 1; 8 | position: relative; 9 | } 10 | 11 | span.eqno .headerlink { 12 | display: none; 13 | visibility: hidden; 14 | } 15 | 16 | span.eqno:hover .headerlink { 17 | display: inline-block; 18 | visibility: visible; 19 | } 20 | 21 | 22 | .sig.sig-object.py dl{ 23 | margin: 0 0 0 0; 24 | } 25 | 26 | .sig.sig-object.py * dd{ 27 | margin: 0 0 0px 24px; 28 | } 29 | 30 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /cfspopcon/formulas/separatrix_conditions/separatrix_operational_space/read_sepos_reference.py: -------------------------------------------------------------------------------- 1 | """Reads results from the original SepOS paper (:cite:`Eich_2021`) as a reference.""" 2 | 3 | from importlib.resources import as_file, files 4 | 5 | import yaml 6 | 7 | 8 | def read_AUG_SepOS_reference() -> dict[str, dict[str, list[float]]]: 9 | """Reads the arrays of reference values from the AUG_SepOS_reference.yml file.""" 10 | with as_file( 11 | files("cfspopcon.formulas.separatrix_conditions.separatrix_operational_space").joinpath("AUG_SepOS_reference.yml") 12 | ) as filepath: 13 | with open(filepath) as f: 14 | data = yaml.safe_load(f) 15 | 16 | return data # type:ignore[no-any-return] 17 | -------------------------------------------------------------------------------- /cfspopcon/plotting/plot_style_handling.py: -------------------------------------------------------------------------------- 1 | """Handling of yaml plot configuration.""" 2 | 3 | from pathlib import Path 4 | from typing import Any, Union 5 | 6 | import yaml 7 | 8 | 9 | def read_plot_style(plot_style: Union[str, Path]) -> dict[str, Any]: 10 | """Read a yaml file corresponding to a given plot_style. 11 | 12 | plot_style may be passed either as a complete filepath or as a string matching a plot_style in "plot_styles" 13 | """ 14 | if Path(plot_style).exists(): 15 | input_file = plot_style 16 | else: 17 | raise FileNotFoundError(f"Could not find {plot_style}!") 18 | 19 | with open(input_file) as file: 20 | repr_d: dict[str, Any] = yaml.load(file, Loader=yaml.FullLoader) 21 | 22 | return repr_d 23 | -------------------------------------------------------------------------------- /cfspopcon/formulas/radiated_power/impurity_radiated_power/__init__.py: -------------------------------------------------------------------------------- 1 | """Calculate the radiated power due to fuel and impurity species.""" 2 | 3 | from .mavrin_coronal import calc_impurity_radiated_power_mavrin_coronal 4 | from .mavrin_noncoronal import calc_impurity_radiated_power_mavrin_noncoronal 5 | from .post_and_jensen import calc_impurity_radiated_power_post_and_jensen 6 | from .radas import calc_impurity_radiated_power_radas 7 | from .radiated_power import calc_impurity_radiated_power 8 | 9 | __all__ = [ 10 | "calc_impurity_radiated_power", 11 | "calc_impurity_radiated_power_mavrin_coronal", 12 | "calc_impurity_radiated_power_mavrin_noncoronal", 13 | "calc_impurity_radiated_power_post_and_jensen", 14 | "calc_impurity_radiated_power_radas", 15 | ] 16 | -------------------------------------------------------------------------------- /cfspopcon/formulas/__init__.py: -------------------------------------------------------------------------------- 1 | """Formulas used for the POPCON analysis.""" 2 | 3 | from . import ( 4 | atomic_data, 5 | auxiliary_power, 6 | energy_confinement, 7 | fusion_power, 8 | geometry, 9 | impurities, 10 | metrics, 11 | plasma_current, 12 | plasma_pressure, 13 | plasma_profiles, 14 | radiated_power, 15 | scrape_off_layer, 16 | separatrix_conditions, 17 | ) 18 | 19 | __all__ = [ 20 | "atomic_data", 21 | "auxiliary_power", 22 | "energy_confinement", 23 | "fusion_power", 24 | "geometry", 25 | "impurities", 26 | "metrics", 27 | "plasma_current", 28 | "plasma_pressure", 29 | "plasma_profiles", 30 | "radiated_power", 31 | "scrape_off_layer", 32 | "separatrix_conditions", 33 | ] 34 | -------------------------------------------------------------------------------- /tests/test_infra/test_point_selection.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from cfspopcon.shaping_and_selection.point_selection import find_coords_of_maximum 4 | 5 | 6 | def test_find_coords(ds): 7 | coords = find_coords_of_maximum(ds.z3) 8 | assert np.isclose(ds.z3.max(), ds.isel(coords).z3) 9 | 10 | coords = find_coords_of_maximum(ds.z3, keep_dims="x") 11 | assert np.allclose(ds.z3.max(dim="y"), ds.isel(coords).z3) 12 | 13 | coords = find_coords_of_maximum(ds.z3, keep_dims="y") 14 | assert np.allclose(ds.z3.max(dim="x"), ds.isel(coords).z3) 15 | 16 | mask = (ds.y < 1.0) & (ds.x < 5.0) 17 | 18 | coords = find_coords_of_maximum(ds.z3, mask=mask) 19 | # z3 is x + y, so must be close to but less than 1 + 5 20 | assert np.isclose(ds.isel(coords).z3, 6.0, atol=0.1) 21 | -------------------------------------------------------------------------------- /tests/test_unit_handling/test_default_units.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from cfspopcon.unit_handling import ureg 4 | from cfspopcon.unit_handling.default_units import check_units_are_valid, read_default_units_from_file 5 | 6 | 7 | def test_read_default_units(): 8 | """Make sure that the default units can be read without error.""" 9 | read_default_units_from_file() 10 | 11 | 12 | def test_check_units_are_valid(): 13 | valid_dict = dict(value="metres", value2="kg", value3=ureg.eV, value4=ureg.n19) 14 | 15 | check_units_are_valid(valid_dict) 16 | 17 | invalid_dict = dict(value4=ureg.n19, value="ducks", value2="chickens", value3=ureg.eV) 18 | 19 | with pytest.raises(ValueError, match="The following units are not recognized.*"): 20 | check_units_are_valid(invalid_dict) 21 | -------------------------------------------------------------------------------- /cfspopcon/formulas/impurities/set_up_impurity_concentration_array.py: -------------------------------------------------------------------------------- 1 | """Calculate the mean charge state of an impurity for given plasma conditions.""" 2 | 3 | from ...algorithm_class import Algorithm 4 | from ...unit_handling import Unitfull 5 | 6 | 7 | @Algorithm.register_algorithm(return_keys=["impurity_concentration"]) 8 | def set_up_impurity_concentration_array(intrinsic_impurity_concentration: Unitfull) -> Unitfull: 9 | """Set up the impurity concentration array, starting with the intrinsic impurities. 10 | 11 | Args: 12 | intrinsic_impurity_concentration: :term:`glossary link` 13 | 14 | Returns: 15 | :term:`impurity_concentration` 16 | """ 17 | impurity_concentration = intrinsic_impurity_concentration.copy() 18 | return impurity_concentration 19 | -------------------------------------------------------------------------------- /cfspopcon/formulas/energy_confinement/__init__.py: -------------------------------------------------------------------------------- 1 | """Interface to different energy confinement scalings and routines to calculate the plasma stored energy.""" 2 | 3 | from .plasma_stored_energy import calc_plasma_stored_energy 4 | from .read_energy_confinement_scalings import ConfinementScaling, read_confinement_scalings 5 | from .solve_for_input_power import solve_energy_confinement_scaling_for_input_power 6 | from .switch_confinement_scaling_on_threshold import ( 7 | switch_to_L_mode_confinement_below_threshold, 8 | switch_to_linearised_ohmic_confinement_below_threshold, 9 | ) 10 | 11 | __all__ = [ 12 | "ConfinementScaling", 13 | "calc_plasma_stored_energy", 14 | "read_confinement_scalings", 15 | "solve_energy_confinement_scaling_for_input_power", 16 | "switch_to_L_mode_confinement_below_threshold", 17 | "switch_to_linearised_ohmic_confinement_below_threshold", 18 | ] 19 | -------------------------------------------------------------------------------- /cfspopcon/formulas/fusion_power/__init__.py: -------------------------------------------------------------------------------- 1 | """Routines to calculate the fusion power and gain.""" 2 | 3 | from . import fusion_data 4 | from .average_fuel_ion_mass import calc_average_ion_mass 5 | from .fusion_data import ( 6 | DDFusionBoschHale, 7 | DDFusionHively, 8 | DHe3Fusion, 9 | DTFusionBoschHale, 10 | DTFusionHively, 11 | FusionReaction, 12 | pB11Fusion, 13 | ) 14 | from .fusion_gain import calc_fusion_gain, calc_triple_product 15 | from .fusion_rates import calc_fusion_power, calc_neutron_flux_to_walls 16 | 17 | __all__ = [ 18 | "DDFusionBoschHale", 19 | "DDFusionHively", 20 | "DHe3Fusion", 21 | "DTFusionBoschHale", 22 | "DTFusionHively", 23 | "FusionReaction", 24 | "calc_average_ion_mass", 25 | "calc_fusion_gain", 26 | "calc_fusion_power", 27 | "calc_neutron_flux_to_walls", 28 | "calc_triple_product", 29 | "fusion_data", 30 | "pB11Fusion", 31 | ] 32 | -------------------------------------------------------------------------------- /tests/test_docs.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import warnings 3 | 4 | import pytest 5 | 6 | pytest.importorskip("sphinx") 7 | from importlib.resources import files 8 | 9 | 10 | @pytest.mark.docs 11 | def test_docs(): 12 | """Test the Sphinx documentation.""" 13 | popcon_directory = files("cfspopcon") 14 | 15 | doctest_output = subprocess.run( 16 | args=["make", "-C", str(popcon_directory.joinpath("../docs")), "doctest"], capture_output=True, check=True 17 | ) 18 | 19 | linkcheck_output = subprocess.run( 20 | args=["make", "-C", str(popcon_directory.joinpath("../docs")), "linkcheck"], capture_output=True, check=True 21 | ) 22 | 23 | if len(doctest_output.stderr) > 0: 24 | warnings.warn("Doctest returned stderr") 25 | 26 | if len(linkcheck_output.stderr) > 0: 27 | warnings.warn("Linkcheck returned stderr") 28 | 29 | if "term not in glossary" in str(linkcheck_output.stderr): 30 | raise RuntimeError(linkcheck_output) 31 | -------------------------------------------------------------------------------- /cfspopcon/formulas/separatrix_conditions/separatrix_operational_space/__init__.py: -------------------------------------------------------------------------------- 1 | """The separatrix operational space, as defined in :cite:`Eich_2021`.""" 2 | 3 | from .density_limit import calc_SepOS_L_mode_density_limit 4 | from .LH_transition import calc_SepOS_LH_transition 5 | from .MHD_limit import calc_SepOS_ideal_MHD_limit 6 | from .read_sepos_reference import read_AUG_SepOS_reference 7 | from .shared import calc_critical_alpha_MHD, calc_poloidal_sound_larmor_radius 8 | from .sustainment_power import ( 9 | calc_power_crossing_separatrix_in_electron_channel, 10 | calc_power_crossing_separatrix_in_ion_channel, 11 | ) 12 | 13 | __all__ = [ 14 | "calc_SepOS_LH_transition", 15 | "calc_SepOS_L_mode_density_limit", 16 | "calc_SepOS_ideal_MHD_limit", 17 | "calc_critical_alpha_MHD", 18 | "calc_poloidal_sound_larmor_radius", 19 | "calc_power_crossing_separatrix_in_electron_channel", 20 | "calc_power_crossing_separatrix_in_ion_channel", 21 | "read_AUG_SepOS_reference", 22 | ] 23 | -------------------------------------------------------------------------------- /cfspopcon/shaping_and_selection/__init__.py: -------------------------------------------------------------------------------- 1 | """Functions to post-process a POPCON analysis, such as finding the coordinates matching a particular condition.""" 2 | 3 | from .line_selection import ( 4 | find_coords_of_contour, 5 | interpolate_onto_line, 6 | ) 7 | from .point_selection import ( 8 | build_mask_from_dict, 9 | find_coords_of_maximum, 10 | find_coords_of_minimum, 11 | find_coords_of_nearest_point, 12 | find_values_at_nearest_point, 13 | ) 14 | from .transform_coords import ( 15 | build_transform_function_from_dict, 16 | interpolate_array_onto_new_coords, 17 | order_dimensions, 18 | ) 19 | 20 | __all__ = [ 21 | "build_mask_from_dict", 22 | "build_transform_function_from_dict", 23 | "find_coords_of_contour", 24 | "find_coords_of_maximum", 25 | "find_coords_of_minimum", 26 | "find_coords_of_nearest_point", 27 | "find_values_at_nearest_point", 28 | "interpolate_array_onto_new_coords", 29 | "interpolate_onto_line", 30 | "order_dimensions", 31 | ] 32 | -------------------------------------------------------------------------------- /cfspopcon/formulas/plasma_profiles/temperature_peaking.py: -------------------------------------------------------------------------------- 1 | """Estimate the temperature peaking.""" 2 | 3 | from ...algorithm_class import Algorithm 4 | from ...unit_handling import Unitfull 5 | 6 | 7 | @Algorithm.register_algorithm(return_keys=["peak_electron_temp", "peak_ion_temp"]) 8 | def calc_temperature_peaking( 9 | average_electron_temp: Unitfull, 10 | average_ion_temp: Unitfull, 11 | temperature_peaking: Unitfull, 12 | ) -> tuple[Unitfull, ...]: 13 | """Apply the temperature peaking. 14 | 15 | Args: 16 | average_electron_temp: :term:`glossary link` 17 | average_ion_temp: :term:`glossary link` 18 | temperature_peaking: :term:`glossary link` 19 | 20 | Returns: 21 | :term:`peak_electron_temp`, :term:`peak_ion_temp` 22 | """ 23 | peak_electron_temp = average_electron_temp * temperature_peaking 24 | peak_ion_temp = average_ion_temp * temperature_peaking 25 | 26 | return peak_electron_temp, peak_ion_temp 27 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import matplotlib 4 | import pytest 5 | from click.testing import CliRunner 6 | 7 | from cfspopcon.cli import run_popcon_cli, write_algorithms_yaml 8 | 9 | 10 | @pytest.mark.cli 11 | @pytest.mark.filterwarnings("ignore:Matplotlib is currently using agg") 12 | @pytest.mark.filterwarnings("ignore:FigureCanvasAgg is non-interactive") 13 | def test_popcon_cli(): 14 | matplotlib.use("Agg") 15 | 16 | runner = CliRunner() 17 | example_case = Path(__file__).parents[1] / "example_cases" / "SPARC_PRD" 18 | result = runner.invoke( 19 | run_popcon_cli, 20 | [str(example_case), "--show"], 21 | ) 22 | assert result.exit_code == 0 23 | 24 | 25 | @pytest.mark.cli 26 | def test_write_algorithms_yaml(tmpdir): 27 | test_file = tmpdir.mkdir("test").join("test_popcon_algorithms.yaml") 28 | runner = CliRunner() 29 | result = runner.invoke(write_algorithms_yaml, ["-o", str(test_file)]) 30 | assert result.exit_code == 0 31 | assert test_file.exists() 32 | -------------------------------------------------------------------------------- /tests/test_infra/conftest.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | import xarray as xr 4 | 5 | 6 | @pytest.fixture() 7 | def x(): 8 | return np.linspace(0, 10, num=400) 9 | 10 | 11 | @pytest.fixture() 12 | def y(): 13 | return np.linspace(-5, 5, num=500) 14 | 15 | 16 | @pytest.fixture() 17 | def z(x, y): 18 | x_grid, y_grid = np.meshgrid(x, y) 19 | return xr.DataArray(x_grid + y_grid, coords=dict(y=y, x=x)) 20 | 21 | 22 | @pytest.fixture() 23 | def z1(x, y): 24 | x_grid, y_grid = np.meshgrid(x, y) 25 | return xr.DataArray(x_grid + y_grid**2, coords=dict(y=y, x=x)) 26 | 27 | 28 | @pytest.fixture() 29 | def z2(x, y): 30 | x_grid, y_grid = np.meshgrid(x, y) 31 | return xr.DataArray(x_grid**2 + y_grid, coords=dict(y=y, x=x)) 32 | 33 | 34 | @pytest.fixture() 35 | def z3(x, y): 36 | x_grid, y_grid = np.meshgrid(x, y) 37 | return xr.DataArray(np.abs(y_grid + x_grid), coords=dict(y=y, x=x)) 38 | 39 | 40 | @pytest.fixture() 41 | def ds(x, y, z, z1, z2, z3): 42 | return xr.Dataset(dict(x=x, y=y, z=z, z1=z1, z2=z2, z3=z3)) 43 | -------------------------------------------------------------------------------- /tests/test_for_anonymous_algorithms.py: -------------------------------------------------------------------------------- 1 | from importlib import import_module 2 | 3 | import cfspopcon 4 | from cfspopcon.algorithm_class import Algorithm 5 | 6 | 7 | def import_all_submodules(importable, module, prefix): 8 | for module in module.__all__: 9 | prefix = f"cfspopcon.formulas.{module}" 10 | importable.append(module) 11 | try: 12 | submodule = import_module(prefix) 13 | if hasattr(submodule, "__all__"): 14 | import_all_submodules(importable, submodule, prefix=f"cfspopcon.formulas.{module}") 15 | except ModuleNotFoundError: 16 | pass 17 | 18 | 19 | def test_for_anonymous_algorithms(): 20 | importable = [] 21 | import_all_submodules(importable, cfspopcon.formulas, "cfspopcon.formulas") 22 | 23 | not_found = 0 24 | for name, _ in Algorithm.instances.items(): 25 | if name not in importable: 26 | print(f"Cannot import {name} from cfspopcon.formulas. Algorithms must be importable.") 27 | not_found += 1 28 | 29 | assert not_found == 0 30 | -------------------------------------------------------------------------------- /cfspopcon/plotting/coordinate_formatter.py: -------------------------------------------------------------------------------- 1 | """Adds a readout of the field at the current mouse position for a colormapped field plotted with pcolormesh, contour, quiver, etc. 2 | 3 | Usage: 4 | >>> import matplotlib.pyplot as plt 5 | >>> from cfspopcon.plotting import CoordinateFormatter 6 | >>> fig, ax = plt.subplots() 7 | >>> ax.format_coord = CoordinateFormatter(...) 8 | """ 9 | 10 | import xarray as xr 11 | 12 | 13 | class CoordinateFormatter: 14 | """Data storage object used for providing a coordinate formatter.""" 15 | 16 | def __init__(self, array: xr.DataArray): # pragma: nocover 17 | """Stores the data required for grid lookup.""" 18 | self.array = array 19 | 20 | def __call__(self, mouse_x, mouse_y): # pragma: nocover 21 | """Returns a string which gives the field value at the queried mouse position.""" 22 | lookup = dict(zip(self.array.dims, (mouse_y, mouse_x))) 23 | 24 | mouse_z = float(self.array.sel(lookup, method="nearest").item()) 25 | 26 | return f"x={mouse_x:f}, y={mouse_y:f}, z={mouse_z:f}" 27 | -------------------------------------------------------------------------------- /cfspopcon/formulas/metrics/__init__.py: -------------------------------------------------------------------------------- 1 | """Various metrics for operating points, generally used when we can't fit something into another logical grouping.""" 2 | 3 | from .collisionality import ( 4 | calc_alpha_t, 5 | calc_coulomb_logarithm, 6 | calc_edge_collisionality, 7 | calc_normalised_collisionality, 8 | ) 9 | from .greenwald_density import ( 10 | calc_average_electron_density_from_greenwald_fraction, 11 | calc_greenwald_density_limit, 12 | calc_greenwald_fraction, 13 | ) 14 | from .heat_exhaust_metrics import ( 15 | calc_PB_over_R, 16 | calc_PBpRnSq, 17 | ) 18 | from .larmor_radius import ( 19 | calc_larmor_radius, 20 | calc_rho_star, 21 | ) 22 | 23 | __all__ = [ 24 | "calc_PB_over_R", 25 | "calc_PBpRnSq", 26 | "calc_alpha_t", 27 | "calc_average_electron_density_from_greenwald_fraction", 28 | "calc_coulomb_logarithm", 29 | "calc_edge_collisionality", 30 | "calc_greenwald_density_limit", 31 | "calc_greenwald_fraction", 32 | "calc_larmor_radius", 33 | "calc_normalised_collisionality", 34 | "calc_rho_star", 35 | ] 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 Commonwealth Fusion Systems 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the “Software”), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /cfspopcon/formulas/impurities/__init__.py: -------------------------------------------------------------------------------- 1 | """Routines to calculate seeded impurity concentrations and the change in effective charge and dilution due to seeded and intrinsic impurities.""" 2 | 3 | from .core_radiator_conc import ( 4 | calc_core_seeded_impurity_concentration, 5 | calc_min_P_radiation_from_fraction, 6 | calc_min_P_radiation_from_LH_factor, 7 | calc_P_radiation_from_core_seeded_impurity, 8 | ) 9 | from .edge_radiator_conc import calc_edge_impurity_concentration 10 | from .impurity_charge_state import calc_impurity_charge_state 11 | from .set_up_impurity_concentration_array import set_up_impurity_concentration_array 12 | from .zeff_and_dilution_from_impurities import calc_zeff_and_dilution_due_to_impurities 13 | 14 | __all__ = [ 15 | "calc_P_radiation_from_core_seeded_impurity", 16 | "calc_core_seeded_impurity_concentration", 17 | "calc_edge_impurity_concentration", 18 | "calc_impurity_charge_state", 19 | "calc_min_P_radiation_from_LH_factor", 20 | "calc_min_P_radiation_from_fraction", 21 | "calc_zeff_and_dilution_due_to_impurities", 22 | "set_up_impurity_concentration_array", 23 | ] 24 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | import xarray as xr 5 | import yaml 6 | 7 | xr.set_options(display_width=300) 8 | 9 | 10 | @pytest.fixture(scope="session") 11 | def test_directory() -> Path: 12 | path = Path(__file__).parent 13 | assert path.exists() 14 | return path 15 | 16 | 17 | @pytest.fixture(scope="session") 18 | def repository_directory(test_directory) -> Path: 19 | path = test_directory.parent 20 | assert path.exists() 21 | return path 22 | 23 | 24 | @pytest.fixture(scope="session") 25 | def module_directory(repository_directory) -> Path: 26 | path = repository_directory / "cfspopcon" 27 | assert path.exists() 28 | return path 29 | 30 | 31 | @pytest.fixture(scope="session") 32 | def cases_directory(repository_directory) -> Path: 33 | path = repository_directory / "example_cases" 34 | assert path.exists() 35 | return path 36 | 37 | 38 | @pytest.fixture(scope="session") 39 | def example_inputs(cases_directory) -> dict: 40 | filepath = cases_directory / "SPARC_PRD" / "input.yaml" 41 | assert filepath.exists() 42 | 43 | return yaml.safe_load(filepath) 44 | -------------------------------------------------------------------------------- /tests/test_infra/test_line_selection.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import xarray as xr 3 | 4 | from cfspopcon.shaping_and_selection.line_selection import find_coords_of_contour, interpolate_onto_line 5 | from cfspopcon.unit_handling import Quantity, convert_units, magnitude, ureg 6 | 7 | 8 | def test_extract_values_along_contour(): 9 | x_vals = np.linspace(-5, 5, num=500) 10 | y_vals = np.linspace(-4, 4, num=400) 11 | 12 | x_coord = "dim_x" 13 | y_coord = "dim_y" 14 | 15 | units = ureg.kW 16 | level = Quantity(1200.0, ureg.W) 17 | 18 | x_mesh, y_mesh = np.meshgrid(x_vals, y_vals) 19 | z_vals = xr.DataArray(np.sqrt(x_mesh**2 + y_mesh**2), coords={y_coord: y_vals, x_coord: x_vals}).pint.quantify(units) 20 | 21 | contour_x, contour_y = find_coords_of_contour(z_vals, x_coord=x_coord, y_coord=y_coord, level=level) 22 | 23 | assert np.allclose(np.sqrt(contour_x**2 + contour_y**2), level.magnitude / 1e3, rtol=1e-3) 24 | 25 | assert np.allclose( 26 | magnitude(convert_units(interpolate_onto_line(z_vals, contour_x, contour_y), units)), 27 | magnitude(convert_units(level, units)), 28 | rtol=1e-3, 29 | ) 30 | -------------------------------------------------------------------------------- /cfspopcon/formulas/geometry/volume_integral.py: -------------------------------------------------------------------------------- 1 | """Common functionality shared between other functions.""" 2 | 3 | import numpy as np 4 | from numpy import float64 5 | from numpy.typing import NDArray 6 | 7 | from ...unit_handling import ureg, wraps_ufunc 8 | 9 | 10 | @wraps_ufunc( 11 | input_units=dict(array_per_m3=ureg.m**-3, rho=ureg.dimensionless, plasma_volume=ureg.m**3), 12 | return_units=dict(volume_integrated_value=ureg.dimensionless), 13 | input_core_dims=[("dim_rho",), ("dim_rho",), ()], 14 | ) 15 | def integrate_profile_over_volume( 16 | array_per_m3: NDArray[float64], 17 | rho: NDArray[float64], 18 | plasma_volume: float, 19 | ) -> float: 20 | """Approximate the volume integral of a profile given as a function of rho. 21 | 22 | Args: 23 | array_per_m3: a profile of values [units * m^-3] 24 | rho: [~] :term:`glossary link` 25 | plasma_volume: [m^3] :term:`glossary link` 26 | 27 | Returns: 28 | volume_integrated_value [units] 29 | """ 30 | drho = rho[1] - rho[0] 31 | result: float = np.sum(array_per_m3 * 2.0 * rho * drho) * plasma_volume 32 | return result 33 | -------------------------------------------------------------------------------- /cfspopcon/formulas/metrics/heat_exhaust_metrics.py: -------------------------------------------------------------------------------- 1 | """Calculate simple metrics for the heat exhaust challenge.""" 2 | 3 | from ...algorithm_class import Algorithm 4 | from ...unit_handling import Unitfull 5 | 6 | 7 | @Algorithm.register_algorithm(return_keys=["PB_over_R"]) 8 | def calc_PB_over_R(power_crossing_separatrix: Unitfull, magnetic_field_on_axis: Unitfull, major_radius: Unitfull) -> Unitfull: 9 | """Calculate P_sep*B0/R0, which scales roughly the same as the parallel heat flux density entering the scrape-off-layer.""" 10 | return power_crossing_separatrix * magnetic_field_on_axis / major_radius 11 | 12 | 13 | @Algorithm.register_algorithm(return_keys=["PBpRnSq"]) 14 | def calc_PBpRnSq( 15 | power_crossing_separatrix: Unitfull, 16 | magnetic_field_on_axis: Unitfull, 17 | q_star: Unitfull, 18 | major_radius: Unitfull, 19 | average_electron_density: Unitfull, 20 | ) -> Unitfull: 21 | """Calculate P_sep * B_pol / (R * n^2), which scales roughly the same as the impurity fraction required for detachment.""" 22 | return (power_crossing_separatrix * (magnetic_field_on_axis / q_star) / major_radius) / (average_electron_density**2.0) 23 | -------------------------------------------------------------------------------- /cfspopcon/unit_handling/__init__.py: -------------------------------------------------------------------------------- 1 | """Uses pint and xarray to enable unit-handling over multi-dimensional arrays.""" 2 | 3 | from typing import Union 4 | 5 | import xarray as xr 6 | from pint import DimensionalityError, UndefinedUnitError, UnitStrippedWarning 7 | 8 | from .decorator import wraps_ufunc 9 | from .default_units import convert_to_default_units, default_unit, magnitude_in_default_units, set_default_units 10 | from .setup_unit_handling import ( 11 | Quantity, 12 | Unit, 13 | convert_units, 14 | dimensionless_magnitude, 15 | get_units, 16 | magnitude, 17 | magnitude_in_units, 18 | ureg, 19 | ) 20 | 21 | Unitfull = Union[Quantity, xr.DataArray] 22 | 23 | __all__ = [ 24 | "DimensionalityError", 25 | "Quantity", 26 | "UndefinedUnitError", 27 | "Unit", 28 | "UnitStrippedWarning", 29 | "Unitfull", 30 | "convert_to_default_units", 31 | "convert_units", 32 | "default_unit", 33 | "dimensionless_magnitude", 34 | "get_units", 35 | "magnitude", 36 | "magnitude_in_default_units", 37 | "magnitude_in_units", 38 | "set_default_units", 39 | "ureg", 40 | "wraps_ufunc", 41 | ] 42 | -------------------------------------------------------------------------------- /tests/test_infra/test_transform.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import xarray as xr 3 | 4 | from cfspopcon.shaping_and_selection import transform_coords 5 | 6 | 7 | def test_interpolate_onto_new_coords(z, z1, z2): 8 | z_interp = transform_coords.interpolate_array_onto_new_coords(array=z, new_coords=dict(z1=z1, z2=z2), default_resolution=5) 9 | 10 | assert z_interp.min() >= z.min() 11 | assert z_interp.max() <= z.max() 12 | assert "z1" in z_interp.dims 13 | assert "z2" in z_interp.dims 14 | 15 | 16 | def test_order_dimensions(z): 17 | assert transform_coords.order_dimensions(z, dims=("x", "y"), order_for_plotting=True).dims == ("y", "x") 18 | assert transform_coords.order_dimensions(z, dims=("x", "y"), order_for_plotting=False).dims == ("x", "y") 19 | 20 | with pytest.raises(ValueError): 21 | assert transform_coords.order_dimensions(z.isel(x=0), dims=("x", "y")).dims == ("x", "y") 22 | 23 | assert transform_coords.order_dimensions(z.isel(x=0), dims=("x", "y"), template=z).dims == ("y", "x") 24 | ds = xr.Dataset(dict(z=z)) 25 | assert transform_coords.order_dimensions(z.isel(x=0), dims=("x", "y"), template=ds).dims == ("y", "x") 26 | -------------------------------------------------------------------------------- /cfspopcon/__init__.py: -------------------------------------------------------------------------------- 1 | """Physics calculations & lumped-parameter models.""" 2 | 3 | from importlib.metadata import metadata 4 | 5 | __version__ = metadata(__package__)["Version"] 6 | __author__ = metadata(__package__)["Author"] 7 | 8 | from . import file_io, formulas, named_options, shaping_and_selection 9 | from .algorithm_class import Algorithm, CompositeAlgorithm 10 | from .formulas.atomic_data import AtomicData 11 | from .input_file_handling import process_input_dictionary, read_case 12 | from .plotting import read_plot_style 13 | from .unit_handling import ( 14 | convert_to_default_units, 15 | convert_units, 16 | magnitude_in_default_units, 17 | set_default_units, 18 | ) 19 | 20 | # export main classes users should need as well as the option enums 21 | __all__ = [ 22 | "Algorithm", 23 | "AtomicData", 24 | "CompositeAlgorithm", 25 | "convert_to_default_units", 26 | "convert_units", 27 | "file_io", 28 | "formulas", 29 | "magnitude_in_default_units", 30 | "named_options", 31 | "process_input_dictionary", 32 | "read_case", 33 | "read_plot_style", 34 | "set_default_units", 35 | "shaping_and_selection", 36 | ] 37 | -------------------------------------------------------------------------------- /example_cases/AUG_SepOS/plot_sepos.yaml: -------------------------------------------------------------------------------- 1 | type: popcon 2 | 3 | figsize: [8, 6] 4 | show_dpi: 150 5 | save_as: "AUG_SepOS" 6 | 7 | coords: 8 | x: 9 | dimension: separatrix_electron_density 10 | label: "$n_{e,sep}$ [$10^{19} m^{-3}$]" 11 | units: n19 12 | y: 13 | dimension: separatrix_electron_temp 14 | label: "$T_{e,sep}$ [$eV$]" 15 | units: eV 16 | 17 | points: 18 | AUG_SepOS_minTe: 19 | label: "LH minimum $P_{SOL}$" 20 | marker: "x" 21 | color: "red" 22 | size: 50.0 23 | 24 | # Suggested colors are "tab:red", "tab:blue", "tab:orange", "tab:green", "tab:purple", 25 | # "tab:brown", "tab:pink", "tab:gray", "tab:olive", "tab:cyan" 26 | contour: 27 | 28 | SepOS_LH_transition: 29 | label: "LH transition" 30 | levels: [1.0] 31 | color: "tab:blue" 32 | 33 | SepOS_MHD_limit: 34 | label: "Ideal MHD limit" 35 | levels: [1.0] 36 | color: "black" 37 | 38 | SepOS_density_limit: 39 | label: "L-mode density limit" 40 | levels: [1.0] 41 | color: "tab:red" 42 | 43 | alpha_t: 44 | label: "$\\alpha_t$" 45 | levels: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0] 46 | color: "tab:green" 47 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Mark stale issues and pull requests 2 | 3 | on: 4 | schedule: 5 | # Everyday 6:30 am 6 | - cron: "30 6 * * *" 7 | 8 | jobs: 9 | stale_bot: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | issues: write 13 | pull-requests: write 14 | steps: 15 | - uses: actions/stale@v9 16 | with: 17 | repo-token: ${{ secrets.GITHUB_TOKEN }} 18 | # We skip issues with these labels 19 | exempt-issue-labels: "bug,enhancement" 20 | stale-issue-message: > 21 | This issue has not seen any activity in the past 60 days. 22 | It is now marked as stale and will be closed in 7 days if 23 | no further activity is registered. 24 | # We skip PRs with these labels 25 | exempt-pr-labels: 'WIP,blocked' 26 | stale-pr-message: > 27 | This PR has not seen any activity in the past 60 days. 28 | It is now marked as stale and will be closed in 7 days if 29 | no further activity is registered. 30 | stale-issue-label: 'no-issue-activity' 31 | stale-pr-label: 'no-pr-activity' 32 | days-before-stale: 60 33 | days-before-close: 7 34 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Dockerfile for mybinder.org 2 | # 3 | # You can build a Docker image locally using 4 | # >>> docker build --rm -t cfspopcon . 5 | # 6 | # To start the docker image and launch a Jupyter notebook, use 7 | # >>> docker run -it -p 8888:8888 -t cfspopcon 8 | # 9 | # To start the docker image with a bash shell, use 10 | # >>> docker run --entrypoint /bin/bash -it -p 8888:8888 -t cfspopcon 11 | 12 | # 1. Start by using a base image from 13 | # https://jupyter-docker-stacks.readthedocs.io/en/latest/using/selecting.html#jupyter-scipy-notebook 14 | # 15 | # N.b. for mybinder.org, we need to pin to a specific tag 16 | FROM jupyter/minimal-notebook:python-3.11 17 | 18 | # 2. Install poetry, which is what we use to build cfspopcon 19 | RUN pip install poetry==1.8.2 20 | 21 | # 3. Copy in the files from the local directory 22 | COPY --chown=$NB_USER:$NB_GID . ./ 23 | 24 | # 4. Tell poetry to install in the global python environment, 25 | # so we don't have to worry about custom kernels or venvs. 26 | RUN poetry config virtualenvs.create false 27 | # 5. Install cfspopcon in the global python environment. 28 | RUN poetry install --without dev 29 | 30 | # 6. Run radas to get the atomic data files 31 | RUN poetry run radas -c radas_config.yaml 32 | -------------------------------------------------------------------------------- /cfspopcon/formulas/auxiliary_power/auxiliary_power.py: -------------------------------------------------------------------------------- 1 | """Routines to calculate the auxiliary (non-Ohmic, non-fusion) power.""" 2 | 3 | from ...algorithm_class import Algorithm 4 | from ...unit_handling import Unitfull, ureg 5 | 6 | 7 | @Algorithm.register_algorithm(return_keys=["P_external", "P_auxiliary_absorbed", "P_auxiliary_launched"]) 8 | def calc_auxiliary_power(P_in: Unitfull, P_alpha: Unitfull, P_ohmic: Unitfull, fraction_of_external_power_coupled: Unitfull) -> Unitfull: 9 | """Calculate the required auxiliary power. 10 | 11 | Args: 12 | P_in: [MW] :term:`glossary link` 13 | P_alpha: [MW] :term:`glossary link` 14 | P_ohmic: [MW] :term:`glossary link` 15 | fraction_of_external_power_coupled: [~]: :term:`glossary link` 16 | 17 | Returns: 18 | :term:`P_external` [MW], :term:`P_auxiliary_absorbed` [MW], :term:`P_auxiliary_launched` [MW] 19 | """ 20 | P_external = (P_in - P_alpha).clip(min=0.0 * ureg.MW) 21 | P_auxiliary_absorbed = (P_external - P_ohmic).clip(min=0.0 * ureg.MW) 22 | P_auxiliary_launched = P_auxiliary_absorbed / fraction_of_external_power_coupled 23 | 24 | return P_external, P_auxiliary_absorbed, P_auxiliary_launched 25 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # per default we only run over the files in the python package 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v5.0.0 5 | hooks: 6 | - id: check-added-large-files 7 | # but no large files anywhere ;) 8 | files: '' 9 | exclude: ".*getting_started.ipynb" 10 | - repo: local 11 | hooks: 12 | - id: ruff_format 13 | name: ruff_format 14 | entry: poetry run ruff format 15 | language: system 16 | types: [python] 17 | - repo: local 18 | hooks: 19 | - id: ruff_check 20 | name: ruff_check 21 | entry: poetry run ruff check 22 | language: system 23 | types: [python] 24 | files: '^cfspopcon/' 25 | - repo: local 26 | hooks: 27 | - id: mypy 28 | name: mypy 29 | entry: poetry run mypy 30 | language: system 31 | types: [python] 32 | files: '^cfspopcon/' 33 | exclude: ^cfspopcon/plotting 34 | - repo: local 35 | hooks: 36 | - id: check_variables 37 | name: Check variables 38 | entry: poetry run python tests/utils/variable_consistency_checker.py --run 39 | language: system 40 | pass_filenames: false 41 | always_run: true 42 | -------------------------------------------------------------------------------- /cfspopcon/formulas/radiated_power/__init__.py: -------------------------------------------------------------------------------- 1 | """Routines to calculate the radiated power due to Bremsstrahlung, synchrotron and impurity-line radiation.""" 2 | 3 | from .basic_algorithms import ( 4 | calc_f_rad_core, 5 | require_P_rad_less_than_P_in, 6 | ) 7 | from .bremsstrahlung import calc_bremsstrahlung_radiation, calc_P_rad_hydrogen_bremsstrahlung 8 | from .impurity_radiated_power import ( 9 | calc_impurity_radiated_power, 10 | calc_impurity_radiated_power_mavrin_coronal, 11 | calc_impurity_radiated_power_mavrin_noncoronal, 12 | calc_impurity_radiated_power_post_and_jensen, 13 | calc_impurity_radiated_power_radas, 14 | ) 15 | from .intrinsic_radiated_power_from_core import calc_intrinsic_radiated_power_from_core 16 | from .synchrotron import calc_synchrotron_radiation 17 | 18 | __all__ = [ 19 | "calc_P_rad_hydrogen_bremsstrahlung", 20 | "calc_bremsstrahlung_radiation", 21 | "calc_f_rad_core", 22 | "calc_impurity_radiated_power", 23 | "calc_impurity_radiated_power_mavrin_coronal", 24 | "calc_impurity_radiated_power_mavrin_noncoronal", 25 | "calc_impurity_radiated_power_post_and_jensen", 26 | "calc_impurity_radiated_power_radas", 27 | "calc_intrinsic_radiated_power_from_core", 28 | "calc_synchrotron_radiation", 29 | "require_P_rad_less_than_P_in", 30 | ] 31 | -------------------------------------------------------------------------------- /tests/utils/regression_results.py: -------------------------------------------------------------------------------- 1 | """Tools to generate and interface with regression results for testing.""" 2 | 3 | from pathlib import Path 4 | 5 | import click 6 | import xarray as xr 7 | 8 | from cfspopcon.file_io import write_dataset_to_netcdf, write_point_to_file 9 | from cfspopcon.input_file_handling import read_case 10 | 11 | CASES_DIR = Path(__file__).parent.parent.parent / "example_cases" 12 | ALL_CASE_PATHS = list(CASES_DIR.rglob("input.yaml")) 13 | ALL_CASE_NAMES = [path.parent.relative_to(CASES_DIR).stem for path in ALL_CASE_PATHS] 14 | 15 | 16 | @click.command() 17 | def update_regression_results_cli() -> None: 18 | """Run the example cases and save them in tests/regression_results.""" 19 | for case in ALL_CASE_PATHS: 20 | input_parameters, algorithm, points, _ = read_case(case) 21 | 22 | dataset = xr.Dataset(input_parameters) 23 | 24 | dataset = algorithm.update_dataset(dataset) 25 | 26 | filepath = Path(__file__).parents[1] / "regression_results" 27 | write_dataset_to_netcdf(dataset, filepath / f"{case.parent.stem}_result.nc") 28 | 29 | for point, point_params in points.items(): 30 | write_point_to_file(dataset, point, point_params, output_dir=filepath) 31 | 32 | 33 | if __name__ == "__main__": 34 | update_regression_results_cli() 35 | -------------------------------------------------------------------------------- /cfspopcon/formulas/geometry/__init__.py: -------------------------------------------------------------------------------- 1 | """Routines to calculate terms related to the plasma geometry, such as the volume inside the last-closed-flux-surface.""" 2 | 3 | from .analytical import ( 4 | calc_areal_elongation_from_elongation_at_psi95, 5 | calc_elongation_at_psi95_from_areal_elongation, 6 | calc_inverse_aspect_ratio, 7 | calc_minor_radius_from_inverse_aspect_ratio, 8 | calc_plasma_poloidal_circumference, 9 | calc_plasma_surface_area, 10 | calc_plasma_volume, 11 | calc_separatrix_elongation_from_areal_elongation, 12 | calc_separatrix_triangularity_from_triangularity95, 13 | calc_vertical_minor_radius_from_elongation_and_minor_radius, 14 | ) 15 | from .volume_integral import integrate_profile_over_volume 16 | 17 | __all__ = [ 18 | "calc_areal_elongation_from_elongation_at_psi95", 19 | "calc_elongation_at_psi95_from_areal_elongation", 20 | "calc_inverse_aspect_ratio", 21 | "calc_minor_radius_from_inverse_aspect_ratio", 22 | "calc_plasma_poloidal_circumference", 23 | "calc_plasma_surface_area", 24 | "calc_plasma_volume", 25 | "calc_separatrix_elongation_from_areal_elongation", 26 | "calc_separatrix_triangularity_from_triangularity95", 27 | "calc_vertical_minor_radius_from_elongation_and_minor_radius", 28 | "integrate_profile_over_volume", 29 | ] 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | cfspopcon: 0D Plasma Calculations & Plasma OPerating CONtours 2 | -------------------------------------------------------------- 3 | 4 | [![Build Status](https://github.com/cfs-energy/cfspopcon/actions/workflows/workflow_actions.yml/badge.svg)](https://github.com/cfs-energy/cfspopcon/actions) 5 | [![Checked with mypy](http://www.mypy-lang.org/static/mypy_badge.svg)](http://mypy-lang.org/) 6 | [![Documentation Status](https://readthedocs.org/projects/cfspopcon/badge/?version=latest)](https://cfspopcon.readthedocs.io/en/latest/?badge=latest) 7 | [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/cfs-energy/cfspopcon/HEAD) 8 | [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.10054879.svg)](https://doi.org/10.5281/zenodo.10054879) 9 | 10 | POPCONs (Plasma OPerating CONtours) is a tool developed to explore the performance and constraints of tokamak designs based on 0D scaling laws, model plasma kinetic profiles, and physics assumptions on the properties and behavior of the core plasma. 11 | 12 | All of our documentation is available at [cfspopcon.readthedocs.io](https://cfspopcon.readthedocs.io/en/latest/). There, you can find installation instructions, instructions for how to run `cfsPOPCON` via the command-line-interface and also explanations of the example Jupyter notebooks in [docs/doc_sources](docs/doc_sources). 13 | -------------------------------------------------------------------------------- /cfspopcon/formulas/separatrix_conditions/__init__.py: -------------------------------------------------------------------------------- 1 | """Calculate the power crossing the separatrix and the threshold values for confinement-regime transitions.""" 2 | 3 | from . import separatrix_operational_space 4 | from .power_crossing_separatrix import calc_power_crossing_separatrix 5 | from .separatrix_operational_space import ( 6 | calc_critical_alpha_MHD, 7 | calc_poloidal_sound_larmor_radius, 8 | calc_power_crossing_separatrix_in_electron_channel, 9 | calc_power_crossing_separatrix_in_ion_channel, 10 | calc_SepOS_ideal_MHD_limit, 11 | calc_SepOS_L_mode_density_limit, 12 | calc_SepOS_LH_transition, 13 | ) 14 | from .threshold_power import calc_LH_transition_threshold_power, calc_LI_transition_threshold_power, calc_ratio_P_LH, calc_ratio_P_LI 15 | 16 | __all__ = [ 17 | "calc_LH_transition_threshold_power", 18 | "calc_LI_transition_threshold_power", 19 | "calc_SepOS_LH_transition", 20 | "calc_SepOS_L_mode_density_limit", 21 | "calc_SepOS_ideal_MHD_limit", 22 | "calc_critical_alpha_MHD", 23 | "calc_poloidal_sound_larmor_radius", 24 | "calc_power_crossing_separatrix", 25 | "calc_power_crossing_separatrix_in_electron_channel", 26 | "calc_power_crossing_separatrix_in_ion_channel", 27 | "calc_ratio_P_LH", 28 | "calc_ratio_P_LI", 29 | "separatrix_operational_space", 30 | ] 31 | -------------------------------------------------------------------------------- /cfspopcon/formulas/plasma_current/flux_consumption/__init__.py: -------------------------------------------------------------------------------- 1 | """Module to calculate the central-solenoid flux required to drive the plasma current.""" 2 | 3 | from .flux_consumption import ( 4 | calc_breakdown_flux_consumption, 5 | calc_external_flux, 6 | calc_flux_needed_from_solenoid_over_rampup, 7 | calc_internal_flux, 8 | calc_max_flattop_duration, 9 | calc_poloidal_field_flux, 10 | calc_resistive_flux, 11 | ) 12 | from .inductances import ( 13 | calc_external_inductance, 14 | calc_internal_inductance_for_cylindrical, 15 | calc_internal_inductance_for_noncylindrical, 16 | calc_internal_inductivity, 17 | calc_invmu_0_dLedR, 18 | calc_vertical_field_mutual_inductance, 19 | calc_vertical_magnetic_field, 20 | ) 21 | 22 | __all__ = [ 23 | "calc_breakdown_flux_consumption", 24 | "calc_external_flux", 25 | "calc_external_inductance", 26 | "calc_flux_needed_from_solenoid_over_rampup", 27 | "calc_internal_flux", 28 | "calc_internal_inductance_for_cylindrical", 29 | "calc_internal_inductance_for_noncylindrical", 30 | "calc_internal_inductivity", 31 | "calc_invmu_0_dLedR", 32 | "calc_max_flattop_duration", 33 | "calc_poloidal_field_flux", 34 | "calc_resistive_flux", 35 | "calc_vertical_field_mutual_inductance", 36 | "calc_vertical_magnetic_field", 37 | ] 38 | -------------------------------------------------------------------------------- /cfspopcon/formulas/plasma_profiles/PRF/width.csv: -------------------------------------------------------------------------------- 1 | ,,aLT,aLT,aLT,aLT,aLT,aLT,aLT,aLT,aLT,aLT 2 | ,,1.0,1.33333333,1.66666667,2.0,2.33333333,2.66666667,3.0,3.33333333,3.66666667,4.0 3 | peaking,1.5,0.55525657,0.78965428,0.94114763,1.04129876,1.10808831,1.15259499,1.18196076,1.20093812,1.2127608,1.21966273 4 | peaking,1.66666667,0.30947926,0.57192988,0.75135627,0.87761883,0.96722366,1.03104933,1.07655831,1.10896602,1.13196415,1.14819024 5 | peaking,1.83333333,0.11214453,0.39602688,0.59122468,0.73461171,0.84095725,0.920017,0.9788843,1.02278864,1.05559384,1.08015795 6 | peaking,2.0,-0.06643852,0.25158105,0.45634084,0.61014671,0.72816489,0.81886394,0.88856167,0.9421717,0.98349881,1.01546521 7 | peaking,2.16666667,-0.2559608,0.12822814,0.34229274,0.50209313,0.62772236,0.72695611,0.80521335,0.86688094,0.91552797,0.95401137 8 | peaking,2.33333333,-0.4861132,0.01560391,0.24466838,0.40832025,0.53850546,0.64365945,0.72846229,0.79668207,0.85153022,0.89569578 9 | peaking,2.5,-0.78658663,-0.09665591,0.15905574,0.3266974,0.45938999,0.56833991,0.65793142,0.73134083,0.7913545,0.8404178 10 | peaking,2.66666667,-1.18707197,-0.21891553,0.08104281,0.25509385,0.38925174,0.50036343,0.59324367,0.67062294,0.7348497,0.78807679 11 | peaking,2.83333333,-1.71726012,-0.36153923,0.00621758,0.19137892,0.32696652,0.43909596,0.53402199,0.61429414,0.68186476,0.73857209 12 | peaking,3.0,-2.406842,-0.53489122,-0.06983196,0.1334219,0.27141011,0.38390344,0.47988931,0.56212015,0.63224858,0.69180306 13 | -------------------------------------------------------------------------------- /tests/test_infra/test_plotting.py: -------------------------------------------------------------------------------- 1 | import matplotlib.pyplot as plt 2 | import numpy as np 3 | import pytest 4 | 5 | from cfspopcon import plotting 6 | from cfspopcon.unit_handling import ureg 7 | 8 | 9 | def test_coordinate_formatter(z): 10 | formatter = plotting.CoordinateFormatter(z) 11 | 12 | x_test = 1.23 13 | y_test = -3.45 14 | ret_string = formatter(x_test, y_test) 15 | 16 | x_string, y_string, z_string = ret_string.split(",") 17 | 18 | assert np.isclose(float(x_string.split("=")[1]), x_test) 19 | assert np.isclose(float(y_string.split("=")[1]), y_test) 20 | # Nearest-neighbor interpolation is not particularly accurate. 21 | assert np.isclose(float(z_string.split("=")[1]), x_test + y_test, atol=0.01) 22 | 23 | 24 | @pytest.mark.filterwarnings("error") 25 | def test_label_contour(z): 26 | # Make sure that the label contour functionality runs through. 27 | _, ax = plt.subplots() 28 | CS = z.plot.contour(ax=ax, colors=["r"]) 29 | 30 | contour_labels = dict() 31 | contour_labels["z"] = plotting.label_contour(ax=ax, contour_set=CS, format_spec="3.2f", fontsize=12) 32 | 33 | ax.legend(contour_labels.values(), contour_labels.keys()) 34 | 35 | plt.close() 36 | 37 | 38 | def test_units_to_string(): 39 | assert plotting.units_to_string(ureg.dimensionless) == "" 40 | assert plotting.units_to_string(ureg.m) == "[m]" 41 | assert plotting.units_to_string(ureg.percent) == "[%]" 42 | -------------------------------------------------------------------------------- /cfspopcon/formulas/energy_confinement/plasma_stored_energy.py: -------------------------------------------------------------------------------- 1 | """Calculate the plasma stored energy.""" 2 | 3 | from ...algorithm_class import Algorithm 4 | from ...unit_handling import Unitfull, convert_units, ureg 5 | 6 | 7 | @Algorithm.register_algorithm(return_keys=["plasma_stored_energy"]) 8 | def calc_plasma_stored_energy( 9 | average_electron_density: Unitfull, 10 | average_electron_temp: Unitfull, 11 | average_ion_density: Unitfull, 12 | summed_impurity_density: Unitfull, 13 | average_ion_temp: Unitfull, 14 | plasma_volume: Unitfull, 15 | ) -> Unitfull: 16 | """Calculates the plasma thermal stored energy. 17 | 18 | Args: 19 | average_electron_density: :term:`glossary link` 20 | average_electron_temp: :term:`glossary link` 21 | average_ion_density: :term:`glossary link` 22 | summed_impurity_density: :term:`glossary link` 23 | average_ion_temp: :term:`glossary link` 24 | plasma_volume: :term:`glossary link` 25 | 26 | Returns: 27 | :term:`plasma_stored_energy` 28 | """ 29 | Wp = ( 30 | (3.0 / 2.0) 31 | * ((average_electron_density * average_electron_temp) + ((average_ion_density + summed_impurity_density) * average_ion_temp)) 32 | * plasma_volume 33 | ) 34 | 35 | return convert_units(Wp, ureg.MJ) 36 | -------------------------------------------------------------------------------- /cfspopcon/formulas/fusion_power/average_fuel_ion_mass.py: -------------------------------------------------------------------------------- 1 | """Calculate the average fuel mass in atomic mass units.""" 2 | 3 | import xarray as xr 4 | 5 | from ...algorithm_class import Algorithm 6 | from ...unit_handling import Unitfull 7 | from .fusion_data import REACTIONS, DDFusionBoschHale, DDFusionHively, DHe3Fusion, DTFusionBoschHale, DTFusionHively, pB11Fusion 8 | 9 | 10 | @Algorithm.register_algorithm(return_keys=["average_ion_mass"]) 11 | def calc_average_ion_mass(fusion_reaction: str, heavier_fuel_species_fraction: Unitfull) -> Unitfull: 12 | """Calculate the average mass of the fuel ions, based on reaction type and fuel mixture ratio. 13 | 14 | Args: 15 | fusion_reaction: reaction type. 16 | heavier_fuel_species_fraction: n_heavier / (n_heavier + n_lighter) number fraction. 17 | 18 | Returns: 19 | :term:`average_ion_mass` [amu] 20 | """ 21 | if isinstance(fusion_reaction, xr.DataArray): 22 | fusion_reaction = fusion_reaction.item() 23 | reaction = REACTIONS[fusion_reaction] 24 | if isinstance(reaction, (DTFusionBoschHale, DTFusionHively, DHe3Fusion, pB11Fusion)): 25 | return reaction.calc_average_ion_mass(heavier_fuel_species_fraction) 26 | elif isinstance(reaction, (DDFusionBoschHale, DDFusionHively)): 27 | return reaction.calc_average_ion_mass() 28 | else: 29 | raise NotImplementedError(f"No implementation for calc_average_ion_mass for {fusion_reaction}") 30 | -------------------------------------------------------------------------------- /cfspopcon/formulas/plasma_pressure/pressure.py: -------------------------------------------------------------------------------- 1 | """Routines to calculate the pressure.""" 2 | 3 | from ...algorithm_class import Algorithm 4 | from ...unit_handling import Unitfull, convert_units, ureg 5 | 6 | 7 | @Algorithm.register_algorithm(return_keys=["average_total_pressure"]) 8 | def calc_average_total_pressure( 9 | average_electron_density: Unitfull, average_electron_temp: Unitfull, average_ion_temp: Unitfull 10 | ) -> Unitfull: 11 | """Calculate the average total pressure.""" 12 | return average_electron_density * (average_electron_temp + average_ion_temp) 13 | 14 | 15 | @Algorithm.register_algorithm(return_keys=["peak_pressure"]) 16 | def calc_peak_pressure( 17 | peak_electron_temp: Unitfull, 18 | peak_ion_temp: Unitfull, 19 | peak_electron_density: Unitfull, 20 | peak_fuel_ion_density: Unitfull, 21 | ) -> Unitfull: 22 | """Calculate the peak pressure (needed for solving for the magnetic equilibrium). 23 | 24 | Args: 25 | peak_electron_temp: [keV] :term:`glossary link` 26 | peak_ion_temp: [keV] :term:`glossary link` 27 | peak_electron_density: [1e19 m^-3] :term:`glossary link` 28 | peak_fuel_ion_density: [~] :term:`glossary link` 29 | 30 | Returns: 31 | peak_pressure [Pa] 32 | """ 33 | return convert_units(peak_electron_temp * peak_electron_density + peak_ion_temp * peak_fuel_ion_density, ureg.Pa) 34 | -------------------------------------------------------------------------------- /cfspopcon/formulas/plasma_profiles/PRF/aLT.csv: -------------------------------------------------------------------------------- 1 | ,,width,width,width,width,width,width,width,width,width,width 2 | ,,0,0.10555556,0.21111111,0.31666667,0.42222222,0.52777778,0.63333333,0.73888889,0.84444444,0.95 3 | peaking,1,-0.05069091,-0.05582754,-0.06176364,-0.06864038,-0.07662657,-0.08590871,-0.09666655,-0.1090216,-0.12294055,-0.13807588 4 | peaking,1.22222222,0.25014517,0.27424909,0.30241125,0.33536809,0.37396822,0.41910617,0.47157803,0.5318111,0.59941914,0.67257332 5 | peaking,1.44444444,0.51647618,0.56574693,0.62368044,0.69191175,0.772276,0.86662501,0.9764702,1.10238582,1.243156,1.39477072 6 | peaking,1.66666667,0.75227265,0.82363737,0.90825389,1.0087155,1.12785707,1.26841447,1.43241685,1.6202727,1.82964466,2.05439189 7 | peaking,1.88888889,0.96150508,1.05289181,1.16234156,1.29350424,1.45027173,1.63624119,1.8538249,2.1030419,2.38025976,2.67731241 8 | peaking,2.11111111,1.14814398,1.25848166,1.39215342,1.55400289,1.74908029,1.98187183,2.25510124,2.56826356,2.91637592,3.28940788 9 | peaking,2.33333333,1.31615985,1.44537832,1.60389941,1.79793635,2.03384305,2.31707304,2.65065277,3.03350782,3.45936777,3.91655387 10 | peaking,2.55555556,1.46952321,1.61855318,1.8037895,2.03302952,2.31412033,2.65361146,3.05488639,3.51634484,4.03060994,4.58462596 11 | peaking,2.77777778,1.61220457,1.78297764,1.99803364,2.26700733,2.59947242,3.00325374,3.482209,4.03434476,4.65147707,5.31949974 12 | peaking,3,1.74817444,1.94362311,2.19284179,2.50759466,2.89945963,3.37776654,3.94702751,4.60507774,5.34334378,6.1470508 13 | -------------------------------------------------------------------------------- /example_cases/SPARC_PRD/plot_remapped.yaml: -------------------------------------------------------------------------------- 1 | type: popcon 2 | 3 | figsize: [8, 6] 4 | show_dpi: 150 5 | 6 | new_coords: 7 | x: 8 | dimension: P_auxiliary_launched 9 | label: "$P_{RF}$ [$MW$]" 10 | units: MW 11 | max: 25.0 12 | y: 13 | dimension: average_electron_density 14 | label: "$$ [$10^{20} m^{-3}$]" 15 | units: n20 16 | 17 | fill: 18 | variable: Q 19 | where: 20 | Q: 21 | min: 1.0 22 | P_auxiliary_launched: 23 | min: 0.0 24 | max: 25.0 25 | units: MW 26 | greenwald_fraction: 27 | max: 0.9 28 | ratio_of_P_SOL_to_P_LH: 29 | min: 1.0 30 | 31 | points: 32 | PRD: 33 | label: "PRD" 34 | marker: "x" 35 | color: "red" 36 | size: 50.0 37 | 38 | # Suggested colors are "tab:red", "tab:blue", "tab:orange", "tab:green", "tab:purple", 39 | # "tab:brown", "tab:pink", "tab:gray", "tab:olive", "tab:cyan" 40 | contour: 41 | 42 | Q: 43 | label: $Q$ 44 | levels: [0.1, 1.0, 2.0, 5.0, 10.0, 50.0] 45 | color: "tab:red" 46 | format: "1.2g" 47 | 48 | ratio_of_P_SOL_to_P_LH: 49 | label: "$P_{SOL}/P_{LH}$" 50 | color: "tab:blue" 51 | levels: [1.0] 52 | format: "1.2g" 53 | 54 | P_auxiliary_launched: 55 | label: "$P_{aux}$" 56 | levels: [1.0, 5.0, 10.0, 25.0, 50.0] 57 | color: "tab:gray" 58 | format: "1.2g" 59 | 60 | P_fusion: 61 | label: "$P_{fusion}$" 62 | color: "tab:purple" 63 | levels: [50.0, 100.0, 150.0, 200.0] 64 | format: "1.2g" 65 | -------------------------------------------------------------------------------- /cfspopcon/formulas/metrics/larmor_radius.py: -------------------------------------------------------------------------------- 1 | """Calculate rho_star, which gives the radio of the device size to the Larmor radius.""" 2 | 3 | import numpy as np 4 | 5 | from ...algorithm_class import Algorithm 6 | from ...unit_handling import Unitfull, ureg 7 | 8 | 9 | def calc_larmor_radius(species_temperature: Unitfull, magnetic_field_strength: Unitfull, species_mass: Unitfull) -> Unitfull: 10 | """Calculate the Larmor radius. 11 | 12 | Equation 1 from :cite:`Eich_2020` 13 | """ 14 | return np.sqrt(species_temperature * species_mass) / (ureg.e * magnetic_field_strength) 15 | 16 | 17 | @Algorithm.register_algorithm(return_keys=["rho_star"]) 18 | def calc_rho_star( 19 | average_ion_mass: Unitfull, average_ion_temp: Unitfull, magnetic_field_on_axis: Unitfull, minor_radius: Unitfull 20 | ) -> Unitfull: 21 | """Calculate rho* (normalized gyroradius). 22 | 23 | Equation 1a from :cite:`Verdoolaege_2021` 24 | 25 | Args: 26 | average_ion_mass: [amu] :term:`glossary link` 27 | average_ion_temp: [keV] :term:`glossary link` 28 | magnetic_field_on_axis: :term:`glossary link` 29 | minor_radius: [m] :term:`glossary link` 30 | 31 | Returns: 32 | rho_star [~] 33 | """ 34 | rho_s = calc_larmor_radius( 35 | species_temperature=average_ion_temp, magnetic_field_strength=magnetic_field_on_axis, species_mass=average_ion_mass 36 | ) 37 | 38 | return rho_s / minor_radius 39 | -------------------------------------------------------------------------------- /cfspopcon/formulas/scrape_off_layer/separatrix_electron_temp.py: -------------------------------------------------------------------------------- 1 | """Routines to calculate the upstream electron temperature.""" 2 | 3 | from typing import Union 4 | 5 | import xarray as xr 6 | 7 | from ...algorithm_class import Algorithm 8 | from ...unit_handling import Quantity 9 | 10 | 11 | @Algorithm.register_algorithm(return_keys=["separatrix_electron_temp"]) 12 | def calc_separatrix_electron_temp( 13 | target_electron_temp: Union[Quantity, xr.DataArray], 14 | q_parallel: Union[Quantity, xr.DataArray], 15 | parallel_connection_length: Union[Quantity, xr.DataArray], 16 | kappa_e0: Union[Quantity, xr.DataArray], 17 | SOL_conduction_fraction: Union[float, xr.DataArray] = 1.0, 18 | ) -> Union[Quantity, xr.DataArray]: 19 | """Calculate the upstream electron temperature assuming Spitzer-Harm heat conductivity. 20 | 21 | Equation 38 from :cite:`stangeby_2018`, keeping the dependence on target_electron_temp. 22 | 23 | Args: 24 | target_electron_temp: [eV] :term:`glossary link` 25 | q_parallel: [GW/m^2] :term:`glossary link` 26 | parallel_connection_length: [m] :term:`glossary link` 27 | kappa_e0: [W / (eV**3.5 m)] :term:`glossary link` 28 | SOL_conduction_fraction: [eV] :term:`glossary link` 29 | 30 | Returns: 31 | separatrix_electron_temp [eV] 32 | """ 33 | return (target_electron_temp**3.5 + 3.5 * (SOL_conduction_fraction * q_parallel * parallel_connection_length / kappa_e0)) ** (2.0 / 7.0) 34 | -------------------------------------------------------------------------------- /cfspopcon/formulas/scrape_off_layer/two_point_model/required_power_loss_fraction.py: -------------------------------------------------------------------------------- 1 | """Calculate the SOL power loss fraction required to achieve a specified target electron temperature.""" 2 | 3 | from typing import Union 4 | 5 | import numpy as np 6 | import xarray as xr 7 | 8 | from ....unit_handling import Quantity 9 | 10 | 11 | def calc_required_SOL_power_loss_fraction( 12 | target_electron_temp_basic: Union[Quantity, xr.DataArray], 13 | f_other_target_electron_temp: Union[float, xr.DataArray], 14 | SOL_momentum_loss_fraction: Union[Quantity, xr.DataArray], 15 | required_target_electron_temp: Union[Quantity, xr.DataArray], 16 | ) -> Union[float, xr.DataArray]: 17 | """Calculate the SOL radiated power fraction required to reach a desired target electron temperature. 18 | 19 | This equation is equation 15 of :cite:`stangeby_2018`, rearranged for $f_{cooling}$. 20 | 21 | Args: 22 | target_electron_temp_basic: from target_electron_temp module [eV] 23 | f_other_target_electron_temp: from target_electron_temp module [~] 24 | SOL_momentum_loss_fraction: fraction of momentum lost in SOL [~] 25 | required_target_electron_temp: what target temperature do we want? [eV] 26 | 27 | Returns: 28 | SOL_power_loss_fraction [~] 29 | """ 30 | required_SOL_power_loss_fraction = xr.DataArray( 31 | 1.0 32 | - np.sqrt( 33 | required_target_electron_temp 34 | / target_electron_temp_basic 35 | * (1.0 - SOL_momentum_loss_fraction) ** 2 36 | / f_other_target_electron_temp 37 | ) 38 | ) 39 | 40 | return required_SOL_power_loss_fraction.clip(min=0.0) 41 | -------------------------------------------------------------------------------- /tests/test_input_file_handling.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | import yaml 5 | 6 | from cfspopcon.algorithm_class import Algorithm, CompositeAlgorithm 7 | from cfspopcon.input_file_handling import read_case, process_input_dictionary 8 | 9 | 10 | @pytest.fixture 11 | def test_dict(): 12 | return dict(Q=1.0) 13 | 14 | 15 | @pytest.fixture 16 | def case_dir(): 17 | return Path(".").absolute() 18 | 19 | 20 | def test_blank_dictionary(test_dict, case_dir): 21 | process_input_dictionary(test_dict, case_dir) 22 | 23 | 24 | def test_blank_file(test_dict, tmp_path): 25 | with open(tmp_path / "input.yaml", "w") as file: 26 | yaml.dump(test_dict, file) 27 | 28 | read_case(tmp_path) 29 | 30 | 31 | def test_blank_file_with_another_suffix(test_dict, tmp_path): 32 | with open(tmp_path / "another.filetype", "w") as file: 33 | yaml.dump(test_dict, file) 34 | 35 | read_case(tmp_path / "another.filetype") 36 | 37 | 38 | def test_algorithm_read_single_from_input_file(case_dir): 39 | test_dict = dict(algorithms=["read_atomic_data"]) 40 | 41 | repr_d, algorithm, points, plots = process_input_dictionary(test_dict, case_dir) 42 | 43 | assert isinstance(algorithm, Algorithm) 44 | 45 | 46 | def test_algorithm_read_multiple_from_input_file(case_dir): 47 | test_dict = dict(algorithms=["read_atomic_data", "set_up_impurity_concentration_array"]) 48 | 49 | repr_d, algorithm, points, plots = process_input_dictionary(test_dict, case_dir) 50 | 51 | assert isinstance(algorithm, CompositeAlgorithm) 52 | 53 | 54 | def test_read_example_input_file(): 55 | example_case = Path(__file__).parents[1] / "example_cases" / "SPARC_PRD" / "input.yaml" 56 | 57 | read_case(example_case) 58 | -------------------------------------------------------------------------------- /example_cases/SPARC_PRD/plot_popcon.yaml: -------------------------------------------------------------------------------- 1 | type: popcon 2 | 3 | figsize: [8, 6] 4 | show_dpi: 150 5 | legend_loc: "upper right" 6 | 7 | coords: 8 | x: 9 | dimension: average_electron_temp 10 | label: "$$ [$keV$]" 11 | units: keV 12 | y: 13 | dimension: average_electron_density 14 | label: "$$ [$10^{20} m^{-3}$]" 15 | units: n20 16 | 17 | fill: 18 | variable: Q 19 | cbar_label: Q 20 | where: 21 | Q: 22 | min: 1.0 23 | P_auxiliary_launched: 24 | min: 0.0 25 | max: 25.0 26 | units: MW 27 | greenwald_fraction: 28 | max: 0.9 29 | ratio_of_P_SOL_to_P_LH: 30 | min: 1.0 31 | max_flattop_duration: 32 | min: 0.0 33 | units: seconds 34 | 35 | points: 36 | PRD: 37 | label: "PRD" 38 | marker: "x" 39 | color: "red" 40 | size: 50.0 41 | 42 | # Suggested colors are "tab:red", "tab:blue", "tab:orange", "tab:green", "tab:purple", 43 | # "tab:brown", "tab:pink", "tab:gray", "tab:olive", "tab:cyan" 44 | contour: 45 | 46 | Q: 47 | label: $Q$ 48 | levels: [0.1, 1.0, 2.0, 5.0, 10.0, 50.0] 49 | color: "tab:red" 50 | format: "1.2g" 51 | 52 | ratio_of_P_SOL_to_P_LH: 53 | label: "$P_{SOL}/P_{LH}$" 54 | color: "tab:blue" 55 | levels: [1.0] 56 | format: "1.2g" 57 | 58 | P_auxiliary_launched: 59 | label: "$P_{aux,launched}$" 60 | levels: [1.0, 5.0, 10.0, 25.0, 50.0] 61 | color: "tab:gray" 62 | format: "1.2g" 63 | 64 | P_fusion: 65 | label: "$P_{fusion}$" 66 | color: "tab:purple" 67 | levels: [50.0, 100.0, 150.0, 200.0] 68 | format: "1.2g" 69 | 70 | max_flattop_duration: 71 | label: "$t_{flattop}$" 72 | color: "tab:orange" 73 | levels: [0.0, 1.0, 2.0, 4.0, 6.0, 8.0, 10.0] 74 | format: "1.2g" 75 | -------------------------------------------------------------------------------- /cfspopcon/formulas/impurities/impurity_charge_state.py: -------------------------------------------------------------------------------- 1 | """Calculate the mean charge state of an impurity for given plasma conditions.""" 2 | 3 | import numpy as np 4 | import xarray as xr 5 | 6 | from ...algorithm_class import Algorithm 7 | from ...helpers import get_item 8 | from ...unit_handling import Unitfull 9 | from ..atomic_data import AtomicData 10 | 11 | 12 | @Algorithm.register_algorithm(return_keys=["impurity_charge_state"]) 13 | def calc_impurity_charge_state( 14 | average_electron_density: Unitfull, 15 | average_electron_temp: Unitfull, 16 | impurity_concentration: xr.DataArray, 17 | atomic_data: AtomicData | xr.DataArray, 18 | ) -> Unitfull: 19 | """Calculate the impurity charge state for each species in impurity_concentration. 20 | 21 | Args: 22 | average_electron_density: :term:`glossary link` 23 | average_electron_temp: :term:`glossary link` 24 | impurity_concentration: :term:`glossary link` 25 | atomic_data: :term:`glossary link` 26 | 27 | Returns: 28 | :term:`impurity_charge_state` 29 | """ 30 | atomic_data = get_item(atomic_data) 31 | 32 | def calc_mean_z(impurity_concentration: xr.DataArray) -> xr.DataArray: 33 | species = get_item(impurity_concentration.dim_species) 34 | interpolator = atomic_data.get_coronal_Z_interpolator(species) 35 | mean_z = interpolator.eval(electron_density=average_electron_density, electron_temp=average_electron_temp, allow_extrap=True) 36 | 37 | mean_z = np.minimum(mean_z, atomic_data.datasets[species].atomic_number) 38 | mean_z = np.maximum(mean_z, 0.0) 39 | return mean_z # type:ignore[no-any-return] 40 | 41 | return impurity_concentration.groupby("dim_species").map(calc_mean_z) 42 | -------------------------------------------------------------------------------- /cfspopcon/formulas/fusion_power/fusion_gain.py: -------------------------------------------------------------------------------- 1 | """Calculate the fusion gain factor.""" 2 | 3 | from ...algorithm_class import Algorithm 4 | from ...unit_handling import Unitfull, ureg 5 | 6 | 7 | @Algorithm.register_algorithm(return_keys=["Q"]) 8 | def calc_fusion_gain(P_fusion: Unitfull, P_ohmic: Unitfull, P_auxiliary_launched: Unitfull) -> Unitfull: 9 | """Calculate the fusion gain, using the launched power in the denominator. 10 | 11 | This is the thermal gain using the launched power. 12 | A slightly more optimistic Q can be obtained by using the absorbed power (P_external) in 13 | the denominator, but for scientific breakeven the launched power will be used. 14 | 15 | The denominator is forced to be at least 1W, to prevent a division-by-zero error. 16 | 17 | Args: 18 | P_fusion: [MW] :term:`glossary link` 19 | P_ohmic: [MW] :term:`glossary link` 20 | P_auxiliary_launched: [MW] :term:`glossary link` 21 | 22 | Returns: 23 | :term:`Q` [~] 24 | """ 25 | Q = P_fusion / (P_ohmic + P_auxiliary_launched).clip(min=1.0 * ureg.W) 26 | 27 | return Q 28 | 29 | 30 | @Algorithm.register_algorithm(return_keys=["fusion_triple_product"]) 31 | def calc_triple_product(peak_fuel_ion_density: Unitfull, peak_ion_temp: Unitfull, energy_confinement_time: Unitfull) -> Unitfull: 32 | """Calculate the fusion triple product. 33 | 34 | Args: 35 | peak_fuel_ion_density: [1e20 m^-3] :term:`glossary link` 36 | peak_ion_temp: [keV] :term:`glossary link` 37 | energy_confinement_time: [s] :term:`glossary link` 38 | 39 | Returns: 40 | fusion_triple_product [10e20 m**-3 keV s] 41 | """ 42 | return peak_fuel_ion_density * peak_ion_temp * energy_confinement_time 43 | -------------------------------------------------------------------------------- /cfspopcon/formulas/scrape_off_layer/__init__.py: -------------------------------------------------------------------------------- 1 | """Routines to calculate the scrape-off-layer conditions and check divertor survivability.""" 2 | 3 | from .heat_flux_density import calc_B_pol_omp, calc_B_tor_omp, calc_fieldline_pitch_at_omp, calc_parallel_heat_flux_density, calc_q_perp 4 | from .lambda_q import ( 5 | calc_lambda_q, 6 | calc_lambda_q_with_brunner, 7 | calc_lambda_q_with_eich_regression_9, 8 | calc_lambda_q_with_eich_regression_14, 9 | calc_lambda_q_with_eich_regression_15, 10 | ) 11 | from .reattachment_models import ( 12 | calc_ionization_volume_from_AUG, 13 | calc_neutral_flux_density_factor, 14 | calc_neutral_pressure_kallenbach, 15 | calc_reattachment_time_henderson, 16 | ) 17 | from .separatrix_density import calc_separatrix_electron_density 18 | from .separatrix_electron_temp import calc_separatrix_electron_temp 19 | from .two_point_model import ( 20 | solve_target_first_two_point_model, 21 | solve_two_point_model, 22 | two_point_model_fixed_fpow, 23 | two_point_model_fixed_qpart, 24 | two_point_model_fixed_tet, 25 | ) 26 | 27 | __all__ = [ 28 | "calc_B_pol_omp", 29 | "calc_B_tor_omp", 30 | "calc_fieldline_pitch_at_omp", 31 | "calc_ionization_volume_from_AUG", 32 | "calc_lambda_q", 33 | "calc_lambda_q_with_brunner", 34 | "calc_lambda_q_with_eich_regression_9", 35 | "calc_lambda_q_with_eich_regression_14", 36 | "calc_lambda_q_with_eich_regression_15", 37 | "calc_neutral_flux_density_factor", 38 | "calc_neutral_pressure_kallenbach", 39 | "calc_parallel_heat_flux_density", 40 | "calc_q_perp", 41 | "calc_reattachment_time_henderson", 42 | "calc_separatrix_electron_density", 43 | "calc_separatrix_electron_temp", 44 | "solve_target_first_two_point_model", 45 | "solve_two_point_model", 46 | "two_point_model_fixed_fpow", 47 | "two_point_model_fixed_qpart", 48 | "two_point_model_fixed_tet", 49 | ] 50 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. cfspopcon documentation master file, created by 2 | sphinx-quickstart on Mon Nov 14 16:09:52 2022. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | ##################################### 7 | Welcome to cfspopcon's documentation! 8 | ##################################### 9 | 10 | POPCONs (Plasma OPerating CONtours) is a tool developed to explore the performance and constraints of tokamak designs based on 0D scaling laws, model plasma kinetic profiles, and physics assumptions on the properties and behavior of the core plasma. 11 | 12 | POPCONs was initially described in :cite:`prd` where it was applied to the design of the SPARC tokamak. 13 | Further, :cite:`prd` also introduced the SPARC Primary Reference Discharge (PRD) defining the highest fusion gain achievable on SPARC. 14 | Since that paper was released, the design point has been refined and the latest design point is now available as :code:`example_cases/SPARC_PRD`, which is covered in the :ref:`Getting Started ` guide. 15 | A document explaining the changes between the SPARC Physics Basis and the current version are detailed in :download:`Changes-to-SPARC-PRD.pdf`. 16 | 17 | To start generating your fist plasma operating contours with cfspopcon, head over to the :ref:`Getting Started ` guide. 18 | 19 | A useful resource is our :ref:`Physics Glossary ` which lists all input and output variables, including definitions and citations for formulas where relevant. 20 | 21 | If you are interested in how to setup a development environment to make changes to cfspopcon, we suggest you checkout the :ref:`Developer's Guide `. 22 | 23 | .. toctree:: 24 | :maxdepth: 1 25 | 26 | doc_sources/Usage 27 | doc_sources/physics_glossary 28 | doc_sources/dev_guide 29 | doc_sources/api 30 | doc_sources/bib 31 | -------------------------------------------------------------------------------- /cfspopcon/helpers.py: -------------------------------------------------------------------------------- 1 | """Constructors and helper functions.""" 2 | 3 | from typing import Any 4 | 5 | import xarray as xr 6 | 7 | from .named_options import ( 8 | AtomicSpecies, 9 | ConfinementPowerScaling, 10 | LambdaQScaling, 11 | MomentumLossFunction, 12 | ProfileForm, 13 | RadiationMethod, 14 | ) 15 | 16 | 17 | def convert_named_options(key: str, val: Any) -> Any: # noqa: PLR0911 18 | """Given a 'key' matching a named_option, return the corresponding Enum value.""" 19 | from .formulas.impurities.impurity_array_helpers import make_impurity_concentration_array 20 | 21 | if key in ["temp_profile_form", "density_profile_form"]: 22 | return ProfileForm[val] 23 | elif key == "radiated_power_method": 24 | return RadiationMethod[val] 25 | elif key in ["impurity_concentration", "intrinsic_impurity_concentration"]: 26 | return make_impurity_concentration_array(list(val.keys()), list(val.values())) 27 | elif key in ["core_impurity_species", "edge_impurity_species"]: 28 | return AtomicSpecies[val] 29 | elif key == "lambda_q_scaling": 30 | return LambdaQScaling[val] 31 | elif key == "SOL_momentum_loss_function": 32 | return MomentumLossFunction[val] 33 | elif key == "radiation_method": 34 | return RadiationMethod[val] 35 | elif key == "confinement_power_scaling": 36 | return ConfinementPowerScaling[val] 37 | else: 38 | # If the key doesn't match, don't convert the value 39 | return val 40 | 41 | 42 | def get_item(value: Any) -> Any: 43 | """Check if an object is an xr.DataArray, and if so, return the ".item()" element.""" 44 | if isinstance(value, xr.DataArray): 45 | return value.item() 46 | else: 47 | return value 48 | 49 | 50 | def get_values(array: Any) -> Any: 51 | """Check if an object is an xr.DataArray, and if so, return the ".values" element.""" 52 | if isinstance(array, xr.DataArray): 53 | return array.values 54 | else: 55 | return array 56 | -------------------------------------------------------------------------------- /cfspopcon/formulas/plasma_current/bootstrap_fraction.py: -------------------------------------------------------------------------------- 1 | """Formulas to calculate the bootstrap fraction.""" 2 | 3 | from ...algorithm_class import Algorithm 4 | from ...unit_handling import Unitfull 5 | 6 | 7 | @Algorithm.register_algorithm(return_keys=["bootstrap_fraction"]) 8 | def calc_bootstrap_fraction( 9 | ion_density_peaking: Unitfull, 10 | electron_density_peaking: Unitfull, 11 | temperature_peaking: Unitfull, 12 | z_effective: Unitfull, 13 | q_star: Unitfull, 14 | inverse_aspect_ratio: Unitfull, 15 | beta_poloidal: Unitfull, 16 | ) -> Unitfull: 17 | """Calculate bootstrap current fraction. 18 | 19 | K. Gi et al, Bootstrap current fraction scaling :cite:`gi_bootstrap_2014` 20 | Equation assumes q0 = 1 21 | 22 | Args: 23 | ion_density_peaking: [~] :term:`glossary link` 24 | electron_density_peaking: [~] :term:`glossary link` 25 | temperature_peaking: [~] :term:`glossary link` 26 | z_effective: [~] :term:`glossary link` 27 | q_star: [~] :term:`glossary link` 28 | inverse_aspect_ratio: [~] :term:`glossary link` 29 | beta_poloidal: [~] :term:`glossary link` 30 | 31 | Returns: 32 | :term:`bootstrap_fraction` [~] 33 | """ 34 | nu_n = (ion_density_peaking + electron_density_peaking) / 2 35 | 36 | bootstrap_fraction = 0.474 * ( 37 | (temperature_peaking - 1.0 + nu_n - 1.0) ** 0.974 38 | * (temperature_peaking - 1.0) ** -0.416 39 | * z_effective**0.178 40 | * q_star**-0.133 41 | * inverse_aspect_ratio**0.4 42 | * beta_poloidal 43 | ) 44 | 45 | return bootstrap_fraction 46 | 47 | 48 | calc_inductive_plasma_current = Algorithm.from_single_function( 49 | func=lambda plasma_current, bootstrap_fraction: plasma_current * (1.0 - bootstrap_fraction), 50 | name="calc_inductive_plasma_current", 51 | return_keys=["inductive_plasma_current"], 52 | ) 53 | -------------------------------------------------------------------------------- /docs/doc_sources/api.rst: -------------------------------------------------------------------------------- 1 | API 2 | ********* 3 | 4 | 5 | 6 | Configuration Enums 7 | ===================== 8 | .. automodule:: cfspopcon.named_options 9 | 10 | Formulas 11 | ======================== 12 | 13 | Auxiliary Power 14 | ---------------- 15 | .. automodule:: cfspopcon.formulas.auxiliary_power 16 | 17 | Energy Confinement 18 | ---------------------- 19 | .. automodule:: cfspopcon.formulas.energy_confinement 20 | 21 | Fusion Power 22 | ---------------- 23 | .. automodule:: cfspopcon.formulas.fusion_power 24 | 25 | Geometry 26 | ---------------- 27 | .. automodule:: cfspopcon.formulas.geometry 28 | 29 | Metrics 30 | ---------------- 31 | .. automodule:: cfspopcon.formulas.metrics 32 | 33 | Plasma Current 34 | ---------------- 35 | .. automodule:: cfspopcon.formulas.plasma_current 36 | 37 | Plasma Pressure 38 | ----------------------- 39 | .. automodule:: cfspopcon.formulas.plasma_pressure 40 | 41 | Plasma Profiles 42 | -------------------- 43 | .. automodule:: cfspopcon.formulas.plasma_profiles 44 | 45 | Separatrix Conditions 46 | ------------------------------- 47 | .. automodule:: cfspopcon.formulas.separatrix_conditions 48 | 49 | Radiated Power 50 | -------------------- 51 | .. automodule:: cfspopcon.formulas.radiated_power 52 | 53 | Scrape Off Layer 54 | -------------------- 55 | .. automodule:: cfspopcon.formulas.scrape_off_layer 56 | 57 | Impurity seeding, dilution and Z-effective 58 | ------------------------------------------- 59 | .. automodule:: cfspopcon.formulas.impurities 60 | 61 | Atomic Data 62 | --------------------------------- 63 | .. automodule:: cfspopcon.formulas.atomic_data 64 | 65 | Algorithms 66 | ===================== 67 | .. automodule:: cfspopcon.algorithm_class 68 | 69 | 70 | Internals 71 | ===================== 72 | 73 | The functions and classes below are listed here for completeness. 74 | But these are internals, so it should usually not be required to interact with any of them directly. 75 | 76 | .. py:currentmodule:: none 77 | .. autoclass:: cfspopcon.unit_handling.Unitfull 78 | 79 | .. automodule:: cfspopcon.helpers 80 | 81 | -------------------------------------------------------------------------------- /cfspopcon/formulas/radiated_power/impurity_radiated_power/radas.py: -------------------------------------------------------------------------------- 1 | """Calculate the impurity radiated power using the radas atomic_data.""" 2 | 3 | from ....algorithm_class import Algorithm 4 | from ....helpers import get_item 5 | from ....unit_handling import Unitfull, ureg 6 | from ...atomic_data import AtomicData 7 | from ...geometry.volume_integral import integrate_profile_over_volume 8 | 9 | 10 | @Algorithm.register_algorithm(return_keys=["P_rad_impurity"]) 11 | def calc_impurity_radiated_power_radas( 12 | rho: Unitfull, 13 | electron_temp_profile: Unitfull, 14 | electron_density_profile: Unitfull, 15 | impurity_concentration: Unitfull, 16 | plasma_volume: Unitfull, 17 | atomic_data: AtomicData, 18 | ) -> Unitfull: 19 | """Calculation of radiated power using radas atomic_data datasets. 20 | 21 | Args: 22 | rho: [~] :term:`glossary link` 23 | electron_temp_profile: [eV] :term:`glossary link` 24 | electron_density_profile: [m^-3] :term:`glossary link` 25 | impurity_species: [] :term:`glossary link` 26 | impurity_concentration: [~] :term:`glossary link` 27 | plasma_volume: [m^3] :term:`glossary link` 28 | atomic_data: :term:`glossary link` 29 | 30 | Returns: 31 | [MW] Estimated radiation power due to this impurity 32 | """ 33 | 34 | def calc_radiated_power_for_one_species(impurity_concentration: Unitfull) -> Unitfull: 35 | interpolator = atomic_data.get_coronal_Lz_interpolator(get_item(impurity_concentration.dim_species)) 36 | 37 | Lz = interpolator.vector_eval(electron_density=electron_density_profile, electron_temp=electron_temp_profile, allow_extrap=True) 38 | radiated_power_profile = impurity_concentration * electron_density_profile**2 * Lz 39 | 40 | return integrate_profile_over_volume(radiated_power_profile / ureg.MW, rho, plasma_volume) * ureg.MW 41 | 42 | return impurity_concentration.groupby("dim_species").map(calc_radiated_power_for_one_species) 43 | -------------------------------------------------------------------------------- /example_cases/AUG_SepOS/input.yaml: -------------------------------------------------------------------------------- 1 | algorithms: 2 | - calc_cylindrical_edge_safety_factor 3 | - calc_alpha_t 4 | - calc_critical_alpha_MHD 5 | - calc_poloidal_sound_larmor_radius 6 | - calc_SepOS_L_mode_density_limit 7 | - calc_SepOS_LH_transition 8 | - calc_SepOS_ideal_MHD_limit 9 | - calc_B_tor_omp 10 | - calc_B_pol_omp 11 | - calc_inverse_aspect_ratio 12 | - calc_areal_elongation_from_elongation_at_psi95 13 | - calc_plasma_surface_area 14 | - calc_power_crossing_separatrix_in_electron_channel 15 | - calc_power_crossing_separatrix_in_ion_channel 16 | 17 | grid: 18 | # input variables in the 'grid' block will be replaced by 19 | # a corresponding linspace or logspace of values 20 | 21 | separatrix_electron_temp: 22 | # Average electron temperature in eV 23 | min: 1.0 24 | max: 150.0 25 | num: 30 26 | spacing: linear 27 | 28 | separatrix_electron_density: 29 | # Average electron density in particles / m^3 30 | min: 0.01 31 | max: 7.0 32 | num: 40 33 | spacing: linear 34 | 35 | 36 | points: 37 | AUG_SepOS_minTe: 38 | minimize: 39 | separatrix_electron_temp 40 | where: 41 | SepOS_LH_transition: 42 | min: 1.0 43 | 44 | 45 | plots: 46 | "AUG SepOS": CASE_DIR/plot_sepos.yaml 47 | 48 | 49 | # Toroidal field on-axis in Tesla 50 | magnetic_field_on_axis: 2.5 51 | # Major radius in metres 52 | major_radius: 1.65 53 | # Minor radius in metres 54 | minor_radius: 0.49 55 | # Ion mass in amu 56 | average_ion_mass: 2.0 57 | # Plasma current in A 58 | plasma_current: 0.8e+6 59 | # Elongation (kappa) at the psiN = 0.95 flux surface 60 | elongation_psi95: 1.6 61 | # Ratio of kappaA / kappa95 62 | elongation_ratio_areal_to_psi95: 1.025 63 | # Triangularity (delta) at the psiN = 0.95 flux surface 64 | triangularity_psi95: 0.3 65 | # Effective ion charge 66 | z_effective: 1.25 67 | # Mean ion charge 68 | mean_ion_charge_state: 1.0 69 | # Target electron temp in eV 70 | target_electron_temp: 10.0 71 | # Fraction of P_SOL going to the outer divertor 72 | fraction_of_P_SOL_to_divertor: 0.6 73 | # Ion heat diffusivity (chi_i) in m^2/s 74 | ion_heat_diffusivity: 0.5 75 | -------------------------------------------------------------------------------- /cfspopcon/formulas/metrics/greenwald_density.py: -------------------------------------------------------------------------------- 1 | """Calculate the Greenwald density limit.""" 2 | 3 | import numpy as np 4 | 5 | from ...algorithm_class import Algorithm 6 | from ...unit_handling import Unitfull, ureg, wraps_ufunc 7 | 8 | 9 | @Algorithm.register_algorithm(return_keys=["greenwald_fraction"]) 10 | def calc_greenwald_fraction(average_electron_density: Unitfull, greenwald_density_limit: Unitfull) -> Unitfull: 11 | """Calculate the fraction of the Greenwald density limit. 12 | 13 | Args: 14 | average_electron_density: [1e20 m^-3] :term:`glossary link` 15 | greenwald_density_limit: [1e20 m^-3] :term:`glossary link` 16 | 17 | Returns: 18 | :term:`greenwald_fraction` [~] 19 | """ 20 | return average_electron_density / greenwald_density_limit 21 | 22 | 23 | @Algorithm.register_algorithm(return_keys=["greenwald_density_limit"]) 24 | @wraps_ufunc(return_units=dict(nG=ureg.n20), input_units=dict(plasma_current=ureg.MA, minor_radius=ureg.m)) 25 | def calc_greenwald_density_limit(plasma_current: float, minor_radius: float) -> float: 26 | """Calculate the Greenwald density limit. 27 | 28 | Args: 29 | plasma_current: [MA] :term:`glossary link` 30 | minor_radius: [m] :term:`glossary link` 31 | 32 | Returns: 33 | :term:`greenwald_density_limit` [1e20 m^-3] 34 | """ 35 | return plasma_current / (np.pi * minor_radius**2) 36 | 37 | 38 | @Algorithm.register_algorithm(return_keys=["average_electron_density"]) 39 | def calc_average_electron_density_from_greenwald_fraction(greenwald_fraction: Unitfull, greenwald_density_limit: Unitfull) -> Unitfull: 40 | """Calculate the average electron density corresponding to a given Greenwald fraction. 41 | 42 | Args: 43 | greenwald_fraction: :term:`glossary link` 44 | greenwald_density_limit: :term:`glossary link` 45 | 46 | Returns: 47 | :term:`average_electron_density` in same units as `greenwald_density_limit` 48 | """ 49 | return greenwald_fraction * greenwald_density_limit 50 | -------------------------------------------------------------------------------- /cfspopcon/formulas/plasma_current/__init__.py: -------------------------------------------------------------------------------- 1 | """Routines to calculate the plasma current, safety factor and ohmic heating.""" 2 | 3 | from .bootstrap_fraction import calc_bootstrap_fraction, calc_inductive_plasma_current 4 | from .flux_consumption import ( 5 | calc_breakdown_flux_consumption, 6 | calc_external_flux, 7 | calc_external_inductance, 8 | calc_flux_needed_from_solenoid_over_rampup, 9 | calc_internal_flux, 10 | calc_internal_inductance_for_cylindrical, 11 | calc_internal_inductance_for_noncylindrical, 12 | calc_internal_inductivity, 13 | calc_invmu_0_dLedR, 14 | calc_max_flattop_duration, 15 | calc_poloidal_field_flux, 16 | calc_resistive_flux, 17 | calc_vertical_field_mutual_inductance, 18 | calc_vertical_magnetic_field, 19 | ) 20 | from .resistive_heating import ( 21 | calc_current_relaxation_time, 22 | calc_loop_voltage, 23 | calc_neoclassical_loop_resistivity, 24 | calc_ohmic_power, 25 | calc_resistivity_trapped_enhancement, 26 | calc_Spitzer_loop_resistivity, 27 | ) 28 | from .safety_factor import ( 29 | calc_cylindrical_edge_safety_factor, 30 | calc_f_shaping_for_qstar, 31 | calc_plasma_current_from_qstar, 32 | calc_q_star_from_plasma_current, 33 | ) 34 | 35 | __all__ = [ 36 | "calc_Spitzer_loop_resistivity", 37 | "calc_bootstrap_fraction", 38 | "calc_breakdown_flux_consumption", 39 | "calc_current_relaxation_time", 40 | "calc_cylindrical_edge_safety_factor", 41 | "calc_external_flux", 42 | "calc_external_inductance", 43 | "calc_f_shaping_for_qstar", 44 | "calc_flux_needed_from_solenoid_over_rampup", 45 | "calc_inductive_plasma_current", 46 | "calc_internal_flux", 47 | "calc_internal_inductance_for_cylindrical", 48 | "calc_internal_inductance_for_noncylindrical", 49 | "calc_internal_inductivity", 50 | "calc_invmu_0_dLedR", 51 | "calc_loop_voltage", 52 | "calc_max_flattop_duration", 53 | "calc_neoclassical_loop_resistivity", 54 | "calc_ohmic_power", 55 | "calc_plasma_current_from_qstar", 56 | "calc_poloidal_field_flux", 57 | "calc_q_star_from_plasma_current", 58 | "calc_resistive_flux", 59 | "calc_resistivity_trapped_enhancement", 60 | "calc_vertical_field_mutual_inductance", 61 | "calc_vertical_magnetic_field", 62 | ] 63 | -------------------------------------------------------------------------------- /tests/test_confinement_switch.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from cfspopcon.formulas.energy_confinement.read_energy_confinement_scalings import read_confinement_scalings 4 | from cfspopcon.formulas.energy_confinement.solve_for_input_power import solve_energy_confinement_scaling_for_input_power 5 | from cfspopcon.formulas.energy_confinement.switch_confinement_scaling_on_threshold import switch_to_L_mode_confinement_below_threshold 6 | from cfspopcon.unit_handling import magnitude_in_units, ureg 7 | 8 | 9 | def test_switch_to_L_mode_confinement_below_threshold(): 10 | kwargs = dict( 11 | plasma_stored_energy=20.0 * ureg.MJ, 12 | average_electron_density=25.0 * ureg.n19, 13 | confinement_time_scalar=1.0, 14 | plasma_current=8.7 * ureg.MA, 15 | magnetic_field_on_axis=12.16 * ureg.T, 16 | major_radius=1.85 * ureg.m, 17 | areal_elongation=1.75, 18 | separatrix_elongation=1.96, 19 | inverse_aspect_ratio=0.308, 20 | average_ion_mass=2.5 * ureg.amu, 21 | triangularity_psi95=0.3, 22 | separatrix_triangularity=0.54, 23 | q_star=3.29, 24 | ) 25 | 26 | read_confinement_scalings() 27 | 28 | tau_E_H_mode, P_in_H_mode = solve_energy_confinement_scaling_for_input_power( 29 | **kwargs, 30 | energy_confinement_scaling="ITER98y2", 31 | ) 32 | 33 | tau_E_L_mode, _ = solve_energy_confinement_scaling_for_input_power( 34 | **kwargs, 35 | energy_confinement_scaling="ITER89P", 36 | ) 37 | 38 | tau_E_should_be_L_mode, _ = switch_to_L_mode_confinement_below_threshold( 39 | **kwargs, 40 | ratio_of_P_SOL_to_P_LH=0.90, 41 | energy_confinement_time=tau_E_H_mode, 42 | P_in=P_in_H_mode, 43 | energy_confinement_scaling_for_L_mode="ITER89P", 44 | ) 45 | 46 | tau_E_should_be_H_mode, _ = switch_to_L_mode_confinement_below_threshold( 47 | **kwargs, 48 | ratio_of_P_SOL_to_P_LH=1.2, 49 | energy_confinement_time=tau_E_H_mode, 50 | P_in=P_in_H_mode, 51 | energy_confinement_scaling_for_L_mode="ITER89P", 52 | ) 53 | 54 | np.testing.assert_allclose(magnitude_in_units(tau_E_should_be_L_mode, ureg.s), magnitude_in_units(tau_E_L_mode, ureg.s), rtol=1e-3) 55 | np.testing.assert_allclose(magnitude_in_units(tau_E_should_be_H_mode, ureg.s), magnitude_in_units(tau_E_H_mode, ureg.s), rtol=1e-3) 56 | -------------------------------------------------------------------------------- /cfspopcon/formulas/energy_confinement/read_energy_confinement_scalings.py: -------------------------------------------------------------------------------- 1 | """Read in information about various energy confinement scalings from the energy_confinement_scalings.yaml file.""" 2 | 3 | from __future__ import annotations 4 | 5 | from importlib.resources import as_file, files 6 | from typing import Any, ClassVar 7 | 8 | import yaml 9 | 10 | from ...algorithm_class import Algorithm 11 | 12 | 13 | class ConfinementScaling: 14 | """Class to handle different energy confinement scalings.""" 15 | 16 | instances: ClassVar[dict[str, ConfinementScaling]] = dict() 17 | 18 | def __init__(self, name: str, data: dict[str, Any]) -> None: 19 | """Initialises an energy confinement scaling from a block of the energy_confinement_scalings.yaml file.""" 20 | self.instances[name] = self 21 | 22 | self.data = data 23 | 24 | self.name: str = name 25 | self.reference: str = data["metadata"]["reference"] 26 | self.notes: str = data["metadata"]["notes"] 27 | self.regime: str = data["metadata"]["regime"] 28 | 29 | self.constant = data["params"]["constant"] 30 | self.mass_ratio_alpha = data["params"]["mass_ratio_alpha"] 31 | self.field_on_axis_alpha = data["params"]["field_on_axis_alpha"] 32 | self.plasma_current_alpha = data["params"]["plasma_current_alpha"] 33 | self.input_power_alpha = data["params"]["input_power_alpha"] 34 | self.major_radius_alpha = data["params"]["major_radius_alpha"] 35 | self.triangularity_alpha = data["params"]["triangularity_alpha"] 36 | self.inverse_aspect_ratio_alpha = data["params"]["inverse_aspect_ratio_alpha"] 37 | self.areal_elongation_alpha = data["params"]["areal_elongation_alpha"] 38 | self.separatrix_elongation_alpha = data["params"]["separatrix_elongation_alpha"] 39 | self.average_density_alpha = data["params"]["average_density_alpha"] 40 | self.qstar_alpha = data["params"]["qstar_alpha"] 41 | 42 | 43 | @Algorithm.register_algorithm(return_keys=[]) 44 | def read_confinement_scalings() -> None: 45 | """Reads the energy confinement scalings from an energy_confinement_scalings.yaml file.""" 46 | with as_file(files("cfspopcon.formulas.energy_confinement").joinpath("energy_confinement_scalings.yaml")) as filepath: 47 | with open(filepath) as f: 48 | data = yaml.safe_load(f) 49 | 50 | for scaling_name, scaling_data in data.items(): 51 | ConfinementScaling(scaling_name, scaling_data) 52 | -------------------------------------------------------------------------------- /cfspopcon/formulas/separatrix_conditions/separatrix_operational_space/density_limit.py: -------------------------------------------------------------------------------- 1 | """Calculate a function which is used to determine the L-mode density limit.""" 2 | 3 | from ....algorithm_class import Algorithm 4 | from ....unit_handling import Unitfull 5 | from .shared import ( 6 | calc_curvature_drive, 7 | calc_electromagnetic_wavenumber, 8 | calc_electron_beta, 9 | calc_electron_pressure_decay_length_Manz2023L, 10 | calc_electron_to_ion_mass_ratio, 11 | calc_resistive_ballooning_wavenumber, 12 | ) 13 | 14 | 15 | @Algorithm.register_algorithm(return_keys=["SepOS_density_limit"]) 16 | def calc_SepOS_L_mode_density_limit( 17 | separatrix_electron_density: Unitfull, 18 | separatrix_electron_temp: Unitfull, 19 | major_radius: Unitfull, 20 | magnetic_field_on_axis: Unitfull, 21 | average_ion_mass: Unitfull, 22 | critical_alpha_MHD: Unitfull, 23 | alpha_t: Unitfull, 24 | ) -> Unitfull: 25 | """Calculate a condition function which gives the L-mode density limit when SepOS_density_limit=1. 26 | 27 | If SepOS_density_limit < 1, the operating point is stable 28 | If SepOS_density_limit > 1, the operating point will disrupt if not above the LH transition 29 | 30 | Equation 3 from :cite:`Eich_2021` 31 | 32 | Args: 33 | separatrix_electron_density: :term:`glossary link` 34 | separatrix_electron_temp: :term:`glossary link` 35 | major_radius: :term:`glossary link` 36 | magnetic_field_on_axis: :term:`glossary link` 37 | average_ion_mass: :term:`glossary link` 38 | critical_alpha_MHD: :term:`glossary link` 39 | alpha_t: :term:`glossary link` 40 | 41 | Returns: 42 | :term:`SepOS_density_limit` 43 | """ 44 | electron_pressure_decay_length = calc_electron_pressure_decay_length_Manz2023L(alpha_t=alpha_t) 45 | 46 | omega_B = calc_curvature_drive(perpendicular_decay_length=electron_pressure_decay_length, major_radius=major_radius) 47 | beta_e = calc_electron_beta( 48 | electron_density=separatrix_electron_density, electron_temp=separatrix_electron_temp, magnetic_field_strength=magnetic_field_on_axis 49 | ) 50 | mu = calc_electron_to_ion_mass_ratio(average_ion_mass=average_ion_mass) 51 | 52 | k_EM = calc_electromagnetic_wavenumber(beta_e=beta_e, mu=mu) 53 | k_RBM = calc_resistive_ballooning_wavenumber(critical_alpha_MHD=critical_alpha_MHD, alpha_t=alpha_t, omega_B=omega_B) 54 | 55 | return k_EM / k_RBM 56 | -------------------------------------------------------------------------------- /cfspopcon/formulas/radiated_power/bremsstrahlung.py: -------------------------------------------------------------------------------- 1 | """Calculate the inherent Bremsstrahlung radiated power.""" 2 | 3 | import numpy as np 4 | from numpy import float64 5 | from numpy.typing import NDArray 6 | 7 | from ...algorithm_class import Algorithm 8 | from ...unit_handling import ureg, wraps_ufunc 9 | from ..geometry.volume_integral import integrate_profile_over_volume 10 | 11 | 12 | @Algorithm.register_algorithm(return_keys=["P_rad_bremsstrahlung"]) 13 | @wraps_ufunc( 14 | return_units=dict(P_rad_bremsstrahlung=ureg.MW), 15 | input_units=dict( 16 | rho=ureg.dimensionless, 17 | electron_density_profile=ureg.n19, 18 | electron_temp_profile=ureg.keV, 19 | z_effective=ureg.dimensionless, 20 | plasma_volume=ureg.m**3, 21 | ), 22 | input_core_dims=[("dim_rho",), ("dim_rho",), ("dim_rho",), (), ()], 23 | ) 24 | def calc_bremsstrahlung_radiation( 25 | rho: NDArray[float64], 26 | electron_density_profile: NDArray[float64], 27 | electron_temp_profile: NDArray[float64], 28 | z_effective: float, 29 | plasma_volume: float, 30 | ) -> float: 31 | """Calculate the Bremsstrahlung radiated power due to the main plasma. 32 | 33 | Formula 13 in :cite:`stott_feasibility_2005` 34 | 35 | Args: 36 | electron_density_profile: [1e19 m^-3] :term:`glossary link` 37 | electron_temp_profile: [keV] :term:`glossary link` 38 | z_effective: [~] :term:`glossary link` 39 | rho: [~] :term:`glossary link` 40 | plasma_volume: [m^3] :term:`glossary link` 41 | 42 | Returns: 43 | Radiated bremsstrahlung power per cubic meter [MW / m^3] 44 | """ 45 | ne20 = electron_density_profile / 10 46 | 47 | Tm = 511.0 # keV, Tm = m_e * c**2 48 | xrel = (1.0 + 2.0 * electron_temp_profile / Tm) * ( 49 | 1.0 + (2.0 / z_effective) * (1.0 - 1.0 / (1.0 + electron_temp_profile / Tm)) 50 | ) # relativistic correction factor 51 | 52 | fKb = ne20**2 * np.sqrt(electron_temp_profile) * xrel 53 | Kb = integrate_profile_over_volume.unitless_func(fKb, rho, plasma_volume) # radial profile factor 54 | 55 | P_brem: float = 5.35e-3 * z_effective * Kb # volume-averaged bremsstrahlung radiaton in MW 56 | 57 | return P_brem 58 | 59 | 60 | calc_P_rad_hydrogen_bremsstrahlung = Algorithm.from_single_function( 61 | func=lambda rho, electron_density_profile, electron_temp_profile, plasma_volume: calc_bremsstrahlung_radiation( 62 | rho, electron_density_profile, electron_temp_profile, 1.0, plasma_volume 63 | ), 64 | name="calc_P_rad_hydrogen_bremsstrahlung", 65 | return_keys=["P_rad_hydrogen_bremsstrahlung"], 66 | ) 67 | -------------------------------------------------------------------------------- /cfspopcon/formulas/scrape_off_layer/two_point_model/separatrix_pressure.py: -------------------------------------------------------------------------------- 1 | """Routines to calculate the combined electron and ion pressure in the SOL.""" 2 | 3 | from typing import Union 4 | 5 | import xarray as xr 6 | 7 | from ....unit_handling import Quantity 8 | 9 | 10 | def calc_upstream_total_pressure( 11 | separatrix_electron_density: Union[Quantity, xr.DataArray], 12 | separatrix_electron_temp: Union[Quantity, xr.DataArray], 13 | upstream_ratio_of_ion_to_electron_temp: Union[float, xr.DataArray], 14 | upstream_ratio_of_electron_to_ion_density: Union[float, xr.DataArray], 15 | upstream_mach_number: Union[float, xr.DataArray] = 0.0, 16 | ) -> Union[Quantity, xr.DataArray]: 17 | """Calculate the upstream total pressure (including the ion temperature contribution). 18 | 19 | Same as calc_total_pressure, but with a default value upstream_mach_number=0.0. 20 | 21 | Args: 22 | separatrix_electron_density: [m^-3] 23 | separatrix_electron_temp: [eV] 24 | upstream_ratio_of_ion_to_electron_temp: tau_t = (T_i / T_e) [~] 25 | upstream_ratio_of_electron_to_ion_density: z_t = (ne / ni) [~] 26 | upstream_mach_number: M_t = (parallel ion velocity / sound speed) [~] 27 | 28 | Returns: 29 | upstream_total_pressure [atm] 30 | """ 31 | return calc_total_pressure( 32 | electron_density=separatrix_electron_density, 33 | electron_temp=separatrix_electron_temp, 34 | ratio_of_ion_to_electron_temp=upstream_ratio_of_ion_to_electron_temp, 35 | ratio_of_electron_to_ion_density=upstream_ratio_of_electron_to_ion_density, 36 | mach_number=upstream_mach_number, 37 | ) 38 | 39 | 40 | def calc_total_pressure( 41 | electron_density: Union[Quantity, xr.DataArray], 42 | electron_temp: Union[Quantity, xr.DataArray], 43 | ratio_of_ion_to_electron_temp: Union[float, xr.DataArray], 44 | ratio_of_electron_to_ion_density: Union[float, xr.DataArray], 45 | mach_number: Union[float, xr.DataArray], 46 | ) -> Union[Quantity, xr.DataArray]: 47 | """Calculate the total pressure (including ion temperature contribution). 48 | 49 | From equation 20, :cite:`stangeby_2018`. 50 | 51 | Args: 52 | electron_density: [m^-3] 53 | electron_temp: [eV] 54 | ratio_of_ion_to_electron_temp: tau_t = (T_i / T_e) [~] 55 | ratio_of_electron_to_ion_density: z_t = (ne / ni) [~] 56 | mach_number: M_t = (parallel ion velocity / sound speed) [~] 57 | 58 | Returns: 59 | upstream_total_pressure [atm] 60 | """ 61 | return ( 62 | (1.0 + mach_number**2) * electron_density * electron_temp * (1.0 + ratio_of_ion_to_electron_temp / ratio_of_electron_to_ion_density) 63 | ) 64 | -------------------------------------------------------------------------------- /cfspopcon/shaping_and_selection/line_selection.py: -------------------------------------------------------------------------------- 1 | """Routines to extract the values of an array along a line of points (typically a contour, but interpolate_onto_line is flexible).""" 2 | 3 | import xarray as xr 4 | from contourpy import contour_generator 5 | from scipy.interpolate import RegularGridInterpolator 6 | 7 | from cfspopcon.unit_handling import Quantity, convert_units, get_units, magnitude 8 | 9 | 10 | def find_coords_of_contour(array: xr.DataArray, x_coord: str, y_coord: str, level: Quantity) -> tuple[xr.DataArray, xr.DataArray]: 11 | """Find the x and y values of a contour of the input array.""" 12 | units = get_units(array) 13 | 14 | cont_gen = contour_generator( 15 | x=array.coords[y_coord], 16 | y=array.coords[x_coord], 17 | z=magnitude(array.transpose(x_coord, y_coord)), 18 | ) 19 | 20 | lines = cont_gen.lines(magnitude(convert_units(level, units))) # type:ignore[arg-type] 21 | if not len(lines) == 1: 22 | raise RuntimeError( 23 | f"find_coords_of_contour returned {len(lines)} contours at level {level}. Use masks to isolate a single contour." 24 | ) 25 | lines = lines[0] # type:ignore[assignment] 26 | 27 | contour_x = xr.DataArray(lines[:, 1], coords={x_coord: lines[:, 1]}) # type:ignore[call-overload] 28 | contour_y = xr.DataArray(lines[:, 0], coords={x_coord: lines[:, 1]}) # type:ignore[call-overload] 29 | contour_x.name, contour_y.name = x_coord, y_coord 30 | return contour_x, contour_y 31 | 32 | 33 | def interpolate_onto_line( 34 | array: xr.DataArray, line_x: xr.DataArray, line_y: xr.DataArray, interpolation_method: str = "cubic" 35 | ) -> xr.DataArray: 36 | """Return values of array at the positions given by line_x and line_y. 37 | 38 | line_x and line_y must have names matching the coordinates of array. You can set their names using 39 | >>> contour_x.name, contour_y.name = x_coord, y_coord 40 | """ 41 | units = get_units(array) 42 | 43 | if not array.ndim == 2: 44 | raise RuntimeError("Contour interpolation only supported for 2 dimensions. Use apply_ufunc to apply to higher dimensions.") 45 | 46 | x_coord, y_coord = line_x.name, line_y.name 47 | array = array.transpose(x_coord, y_coord) 48 | 49 | interpolator = RegularGridInterpolator( # type: ignore[call-overload] 50 | points=((array.coords[x_coord], array.coords[y_coord])), 51 | values=magnitude(array).to_numpy(), # type:ignore[union-attr] 52 | method=interpolation_method, 53 | ) 54 | 55 | interpolated_values = interpolator((line_x, line_y)) 56 | 57 | return xr.DataArray(interpolated_values, coords={x_coord: line_x}).pint.quantify(units) # type:ignore[no-any-return] 58 | -------------------------------------------------------------------------------- /cfspopcon/named_options.py: -------------------------------------------------------------------------------- 1 | """Enumerators to constrain options for functions.""" 2 | 3 | from enum import Enum, auto 4 | 5 | 6 | class ProfileForm(Enum): 7 | """Methods to calculate nT profiles.""" 8 | 9 | analytic = auto() 10 | prf = auto() 11 | 12 | 13 | class RadiationMethod(Enum): 14 | """Methods to calculate radiation losses.""" 15 | 16 | Inherent = "Bremsstrahlung and synchrotron radiation only" 17 | PostJensen = "Impurity radiation, using a coronal equilibrium model from Post & Jensen 1977" 18 | MavrinCoronal = "Impurity radiation, using a coronal equilibrium model from Mavrin 2018" 19 | MavrinNoncoronal = "Impurity radiation, using a non-coronal model from Mavrin 2017" 20 | Radas = "Impurity line and bremsstrahlung radiation, using coronal Lz curves from Radas" 21 | 22 | 23 | class AtomicSpecies(Enum): 24 | """Enum of possible atomic species.""" 25 | 26 | Hydrogen = auto() 27 | Deuterium = auto() 28 | Tritium = auto() 29 | Helium = auto() 30 | Lithium = auto() 31 | Beryllium = auto() 32 | Boron = auto() 33 | Carbon = auto() 34 | Nitrogen = auto() 35 | Oxygen = auto() 36 | Neon = auto() 37 | Argon = auto() 38 | Krypton = auto() 39 | Xenon = auto() 40 | Tungsten = auto() 41 | 42 | def __lt__(self, other: "AtomicSpecies") -> bool: 43 | """Implements '<' to allow sorting.""" 44 | return self.value < other.value 45 | 46 | def __gt__(self, other: "AtomicSpecies") -> bool: 47 | """Implements '>' to allow sorting.""" 48 | return self.value > other.value 49 | 50 | 51 | class MomentumLossFunction(Enum): 52 | """Select which SOL momentum loss function to use.""" 53 | 54 | KotovReiter = auto() 55 | Sang = auto() 56 | Jarvinen = auto() 57 | Moulton = auto() 58 | PerezH = auto() 59 | PerezL = auto() 60 | 61 | 62 | class LambdaQScaling(Enum): 63 | """Options for heat flux decay length scaling.""" 64 | 65 | Brunner = auto() 66 | EichRegression9 = auto() 67 | EichRegression14 = auto() 68 | EichRegression15 = auto() 69 | 70 | 71 | class ConfinementPowerScaling(Enum): 72 | """Options for which confinement threshold power scaling to use.""" 73 | 74 | I_mode_AUG = auto() 75 | I_mode_HubbardNF17 = auto() 76 | I_mode_HubbardNF12 = auto() 77 | H_mode_Martin = auto() 78 | 79 | 80 | class VertMagneticFieldEq(Enum): 81 | """Vertical magnetic field equation from various papers. 82 | 83 | NOTE: the choice of Barr vs. Mitarai also affects invmu_0_dLedR and the vertical_magnetic_field_mutual_inductance. 84 | """ 85 | 86 | Mit_and_Taka_Eq13 = auto() 87 | Barr = auto() 88 | Jean = auto() 89 | MagneticFusionEnergyFormulary = auto() 90 | 91 | 92 | class SurfaceInductanceCoeffs(Enum): 93 | """Coefficients to calculate external inductance components.""" 94 | 95 | Hirshman = auto() 96 | Barr = auto() 97 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_* 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # vscode 132 | *.vscode 133 | 134 | #macOS metadata 135 | .DS_Store 136 | 137 | # Avoid committing nc files and other working files 138 | example_cases/**/output/* 139 | tests/regression_results/test*.nc 140 | radas_dir/* 141 | popcon_algorithms.yaml 142 | 143 | # Have an untracked folder for rough working 144 | untracked/ 145 | # Have a cases folder for personal cases which shouldn't be added 146 | # to the index 147 | cases/* -------------------------------------------------------------------------------- /tests/test_helpers.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | import xarray as xr 4 | 5 | from cfspopcon import named_options 6 | from cfspopcon.formulas.impurities.impurity_array_helpers import ( 7 | extend_impurity_concentration_array, 8 | make_impurity_concentration_array, 9 | make_impurity_concentration_array_from_kwargs, 10 | ) 11 | from cfspopcon.helpers import ( 12 | convert_named_options, 13 | ) 14 | from cfspopcon.named_options import AtomicSpecies 15 | 16 | 17 | def test_convert_named_options(): 18 | for val, key in ( 19 | (named_options.ProfileForm.analytic, "density_profile_form"), 20 | (named_options.RadiationMethod.Radas, "radiated_power_method"), 21 | (named_options.AtomicSpecies.Neon, "edge_impurity_species"), 22 | (named_options.AtomicSpecies.Xenon, "core_impurity_species"), 23 | (named_options.LambdaQScaling.EichRegression15, "lambda_q_scaling"), 24 | (named_options.MomentumLossFunction.KotovReiter, "SOL_momentum_loss_function"), 25 | ): 26 | assert convert_named_options(key=key, val=val.name) == val 27 | 28 | assert convert_named_options(key="ducks", val=23.0) == 23.0 29 | 30 | da = convert_named_options(key="impurity_concentration", val=dict(tungsten=1e-5, helium=1e-2)) 31 | 32 | assert da.sel(dim_species=named_options.AtomicSpecies.Tungsten) == 1e-5 33 | assert da.sel(dim_species=named_options.AtomicSpecies.Helium) == 1e-2 34 | 35 | 36 | def test_impurity_array_helpers(): 37 | array = xr.DataArray([[1, 2], [3, 4]], coords=dict(a=[1, 2], b=[3, 5])) 38 | 39 | make_impurity_concentration_array(xr.DataArray("tungsten"), array) 40 | 41 | from_lists = make_impurity_concentration_array([named_options.AtomicSpecies.Tungsten, "Xenon"], [array, 2 * array]) 42 | from_kwargs = make_impurity_concentration_array_from_kwargs(tungsten=array, xenon=2 * array) 43 | 44 | assert from_lists.equals(from_kwargs) 45 | 46 | from_extension = make_impurity_concentration_array(["tungsten"], [array]) 47 | from_extension = extend_impurity_concentration_array(from_extension, "xenon", 2 * array) 48 | 49 | assert from_extension.equals(from_kwargs) 50 | 51 | with pytest.raises(ValueError): 52 | from_lists = make_impurity_concentration_array("Xenon", [array, 2 * array, 3 * array]) 53 | with pytest.raises(ValueError): 54 | from_lists = make_impurity_concentration_array(["Xenon", "tungsten"], [array]) 55 | 56 | array2 = make_impurity_concentration_array_from_kwargs( 57 | helium=xr.DataArray([0.1, 0.2], dims=("a")), 58 | tungsten=xr.DataArray([0.3, 0.4], dims=("b")), 59 | ) 60 | 61 | ds = xr.Dataset(data_vars=dict(array1=array, array2=array2)) 62 | 63 | ds["array2"] = extend_impurity_concentration_array(ds["array2"], "nitrogen", 1e-3) 64 | 65 | assert np.isclose(ds["array2"].sel(dim_species=AtomicSpecies.Helium).isel(a=0, b=1).item(), 0.1) 66 | assert np.isclose(ds["array2"].sel(dim_species=AtomicSpecies.Tungsten).isel(a=1, b=1).item(), 0.4) 67 | -------------------------------------------------------------------------------- /cfspopcon/formulas/scrape_off_layer/two_point_model/momentum_loss_functions.py: -------------------------------------------------------------------------------- 1 | """Calculate SOL momentum loss as a function target electron temperature. 2 | 3 | See Figure 15 of :cite:`stangeby_2018`. 4 | """ 5 | 6 | import numpy as np 7 | 8 | from ....named_options import MomentumLossFunction 9 | from ....unit_handling import Quantity, ureg, wraps_ufunc 10 | 11 | 12 | def _calc_SOL_momentum_loss_fraction(A: float, Tstar: float, n: float, target_electron_temp: float) -> float: 13 | """Calculates the fraction of momentum lost in the SOL, for a generic SOL momentum loss function. 14 | 15 | This is equation 33 of :cite:`stangeby_2018`, rearranged for $f^{total}_{mom-loss}$ 16 | """ 17 | return float(1.0 - A * (1.0 - np.exp(-target_electron_temp / Tstar)) ** n) 18 | 19 | 20 | @wraps_ufunc( 21 | return_units=dict(momentum_loss_fraction=ureg.dimensionless), 22 | input_units=dict(key=None, target_electron_temp=ureg.eV), 23 | ) 24 | def calc_SOL_momentum_loss_fraction(key: MomentumLossFunction, target_electron_temp: Quantity) -> float: 25 | """Calculate the fraction of momentum lost in the SOL. 26 | 27 | The coefficients come from figure captions in :cite:`stangeby_2018` 28 | * KotovReiter: SOLPS scans with Deuterium only, no impurities for JET vertical target. Figure 7a) 29 | * Sang: SOLPS scans for Deuterium with Carbon impurity, for DIII-D, variety of divertor configurations. Figure 7b) 30 | * Jarvinen: EDGE2D density scan for JET with a horizontal target, for a variety of targets and injected impurities. Figure 10a) 31 | * Moulton: SOLPS density scan for narrow slot divertor, no impurities. Figure 10b) 32 | * PerezH: SOLPS density scan for AUG H-mode, only trace impurities. Figure 11a) 33 | * PerezL: SOLPS density scan for AUG L-mode, with Carbon impurity. Figure 11b) 34 | 35 | Comparison is in Figure 15. 36 | 37 | Args: 38 | key: which momentum loss function to use 39 | target_electron_temp: electron temperature at the target [eV] 40 | 41 | Returns: 42 | SOL_momentum_loss_fraction [~] 43 | """ 44 | if key == MomentumLossFunction.KotovReiter: 45 | return _calc_SOL_momentum_loss_fraction(1.0, 0.8, 2.1, target_electron_temp) 46 | 47 | elif key == MomentumLossFunction.Sang: 48 | return _calc_SOL_momentum_loss_fraction(1.3, 1.8, 1.6, target_electron_temp) 49 | 50 | elif key == MomentumLossFunction.Jarvinen: 51 | return _calc_SOL_momentum_loss_fraction(1.7, 2.2, 1.2, target_electron_temp) 52 | 53 | elif key == MomentumLossFunction.Moulton: 54 | return _calc_SOL_momentum_loss_fraction(1.0, 1.0, 1.5, target_electron_temp) 55 | 56 | elif key == MomentumLossFunction.PerezH: 57 | return _calc_SOL_momentum_loss_fraction(0.8, 2.0, 1.2, target_electron_temp) 58 | 59 | elif key == MomentumLossFunction.PerezL: 60 | return _calc_SOL_momentum_loss_fraction(1.1, 3.0, 0.9, target_electron_temp) 61 | 62 | else: 63 | raise NotImplementedError(f"No implementation for MomentumLossFunction {key}") 64 | -------------------------------------------------------------------------------- /cfspopcon/formulas/separatrix_conditions/separatrix_operational_space/LH_transition.py: -------------------------------------------------------------------------------- 1 | """Calculate a function which is used to determine the LH transition.""" 2 | 3 | import numpy as np 4 | 5 | from ....algorithm_class import Algorithm 6 | from ....unit_handling import Unitfull 7 | from .shared import ( 8 | calc_curvature_drive, 9 | calc_electromagnetic_wavenumber, 10 | calc_electron_beta, 11 | calc_electron_pressure_decay_length_Eich2021H, 12 | calc_electron_to_ion_mass_ratio, 13 | ) 14 | 15 | 16 | @Algorithm.register_algorithm(return_keys=["SepOS_LH_transition"]) 17 | def calc_SepOS_LH_transition( 18 | separatrix_electron_density: Unitfull, 19 | separatrix_electron_temp: Unitfull, 20 | major_radius: Unitfull, 21 | magnetic_field_on_axis: Unitfull, 22 | average_ion_mass: Unitfull, 23 | critical_alpha_MHD: Unitfull, 24 | alpha_t: Unitfull, 25 | poloidal_sound_larmor_radius: Unitfull, 26 | ) -> Unitfull: 27 | """Calculate a condition function which gives the LH transition at SepOS_LH_transition=1. 28 | 29 | If SepOS_LH_transition < 1, the operating point will be in L-mode 30 | If SepOS_LH_transition > 1, the operating point will be in H-mode 31 | 32 | Equation 8 from :cite:`Eich_2021` 33 | 34 | Args: 35 | separatrix_electron_density: :term:`glossary link` 36 | separatrix_electron_temp: :term:`glossary link` 37 | major_radius: :term:`glossary link` 38 | magnetic_field_on_axis: :term:`glossary link` 39 | average_ion_mass: :term:`glossary link` 40 | critical_alpha_MHD: :term:`glossary link` 41 | alpha_t: :term:`glossary link` 42 | poloidal_sound_larmor_radius: :term:`glossary link` 43 | 44 | Returns: 45 | :term:`SepOS_LH_transition` 46 | """ 47 | beta_e = calc_electron_beta(separatrix_electron_density, separatrix_electron_temp, magnetic_field_on_axis) 48 | mu = calc_electron_to_ion_mass_ratio(average_ion_mass) 49 | 50 | electron_pressure_decay_length = calc_electron_pressure_decay_length_Eich2021H( 51 | alpha_t=alpha_t, poloidal_sound_larmor_radius=poloidal_sound_larmor_radius 52 | ) 53 | 54 | k_EM = calc_electromagnetic_wavenumber(beta_e=beta_e, mu=mu) 55 | omega_B = calc_curvature_drive(perpendicular_decay_length=electron_pressure_decay_length, major_radius=major_radius) 56 | 57 | flow_shear_stabilisation = critical_alpha_MHD * k_EM / (1.0 + (alpha_t * k_EM / critical_alpha_MHD) ** 2) 58 | 59 | electron_turbulence_destabilisation = 0.5 * alpha_t 60 | kinetic_turbulence_destabilisation = k_EM**2 * alpha_t 61 | ion_turbulence_destabilisation = critical_alpha_MHD / (2.0 * k_EM**2) * np.sqrt(omega_B) 62 | total_destabilisation = electron_turbulence_destabilisation + ion_turbulence_destabilisation + kinetic_turbulence_destabilisation 63 | 64 | return flow_shear_stabilisation / total_destabilisation 65 | -------------------------------------------------------------------------------- /cfspopcon/formulas/radiated_power/impurity_radiated_power/radiated_power.py: -------------------------------------------------------------------------------- 1 | """Compute the total radiated power.""" 2 | 3 | import numpy as np 4 | import xarray as xr 5 | 6 | from ....algorithm_class import Algorithm 7 | from ....named_options import RadiationMethod 8 | from ....unit_handling import Unitfull, ureg 9 | from ...atomic_data import AtomicData 10 | from .mavrin_coronal import calc_impurity_radiated_power_mavrin_coronal 11 | from .mavrin_noncoronal import calc_impurity_radiated_power_mavrin_noncoronal 12 | from .post_and_jensen import calc_impurity_radiated_power_post_and_jensen 13 | from .radas import calc_impurity_radiated_power_radas 14 | 15 | 16 | @Algorithm.register_algorithm(return_keys=["P_rad_impurity"]) 17 | def calc_impurity_radiated_power( 18 | radiated_power_method: RadiationMethod, 19 | rho: Unitfull, 20 | electron_temp_profile: Unitfull, 21 | electron_density_profile: Unitfull, 22 | impurity_concentration: xr.DataArray, 23 | plasma_volume: Unitfull, 24 | atomic_data: AtomicData, 25 | ) -> xr.DataArray: 26 | """Compute the total radiated power due to fuel and impurity species. 27 | 28 | Args: 29 | radiated_power_method: [] :term:`glossary link` 30 | rho: [~] :term:`glossary link` 31 | electron_temp_profile: [keV] :term:`glossary link` 32 | electron_density_profile: [1e19 m^-3] :term:`glossary link` 33 | impurity_concentration: [] :term:`glossary link` 34 | plasma_volume: [m^3] :term:`glossary link` 35 | atomic_data: :term:`glossary link` 36 | 37 | Returns: 38 | [MW] Estimated radiation power due to this impurity 39 | """ 40 | P_rad_kwargs = dict( 41 | rho=rho, 42 | electron_temp_profile=electron_temp_profile, 43 | electron_density_profile=electron_density_profile, 44 | impurity_concentration=impurity_concentration, 45 | plasma_volume=plasma_volume, 46 | ) 47 | species = impurity_concentration.dim_species 48 | 49 | if radiated_power_method == RadiationMethod.PostJensen: 50 | P_rad_impurity = calc_impurity_radiated_power_post_and_jensen(**P_rad_kwargs, impurity_species=species) 51 | elif radiated_power_method == RadiationMethod.MavrinCoronal: 52 | P_rad_impurity = calc_impurity_radiated_power_mavrin_coronal(**P_rad_kwargs, impurity_species=species) 53 | elif radiated_power_method == RadiationMethod.MavrinNoncoronal: 54 | P_rad_impurity = calc_impurity_radiated_power_mavrin_noncoronal( 55 | **P_rad_kwargs, impurity_species=species, impurity_residence_time=np.inf * ureg.s 56 | ) 57 | elif radiated_power_method == RadiationMethod.Radas: 58 | P_rad_impurity = calc_impurity_radiated_power_radas(**P_rad_kwargs, atomic_data=atomic_data) 59 | else: 60 | raise NotImplementedError(f"No implementation for radiated_power_method = {radiated_power_method}") 61 | 62 | return P_rad_impurity # type:ignore[no-any-return] 63 | -------------------------------------------------------------------------------- /cfspopcon/formulas/separatrix_conditions/separatrix_operational_space/MHD_limit.py: -------------------------------------------------------------------------------- 1 | """Calculate a function which is used to determine the ideal MHD limit.""" 2 | 3 | import numpy as np 4 | 5 | from ....algorithm_class import Algorithm 6 | from ....unit_handling import Unitfull, convert_units, ureg 7 | from .shared import ( 8 | calc_curvature_drive, 9 | calc_electron_beta, 10 | calc_electron_pressure_decay_length_Eich2021H, 11 | calc_ideal_MHD_wavenumber, 12 | calc_resistive_ballooning_wavenumber, 13 | calc_squared_scale_ratio, 14 | ) 15 | 16 | 17 | @Algorithm.register_algorithm(return_keys=["SepOS_MHD_limit"]) 18 | def calc_SepOS_ideal_MHD_limit( 19 | separatrix_electron_density: Unitfull, 20 | separatrix_electron_temp: Unitfull, 21 | major_radius: Unitfull, 22 | magnetic_field_on_axis: Unitfull, 23 | cylindrical_safety_factor: Unitfull, 24 | critical_alpha_MHD: Unitfull, 25 | alpha_t: Unitfull, 26 | poloidal_sound_larmor_radius: Unitfull, 27 | ion_to_electron_temp_ratio: float = 1.0, 28 | ) -> Unitfull: 29 | """Calculate a condition function which gives the ideal MHD limit at SepOS_MHD_limit=1. 30 | 31 | If SepOS_MHD_limit < 1, the operating point is stable 32 | If SepOS_MHD_limit > 1, the transport will increase until SepOS_MHD_limit < 1 (soft limit) 33 | 34 | Equation 12 from :cite:`Eich_2021` 35 | 36 | Args: 37 | separatrix_electron_density: :term:`glossary link` 38 | separatrix_electron_temp: :term:`glossary link` 39 | major_radius: :term:`glossary link` 40 | magnetic_field_on_axis: :term:`glossary link` 41 | cylindrical_safety_factor: :term:`glossary link` 42 | critical_alpha_MHD: :term:`glossary link` 43 | alpha_t: :term:`glossary link` 44 | poloidal_sound_larmor_radius: :term:`glossary link` 45 | ion_to_electron_temp_ratio: :term:`glossary link` 46 | 47 | Returns: 48 | :term:`SepOS_MHD_limit` 49 | """ 50 | k_RBM_factor = np.sqrt(2.0) 51 | electron_pressure_decay_length = calc_electron_pressure_decay_length_Eich2021H( 52 | alpha_t=alpha_t, poloidal_sound_larmor_radius=poloidal_sound_larmor_radius 53 | ) 54 | 55 | omega_B = calc_curvature_drive(perpendicular_decay_length=electron_pressure_decay_length, major_radius=major_radius) 56 | beta_e = calc_electron_beta( 57 | electron_density=separatrix_electron_density, electron_temp=separatrix_electron_temp, magnetic_field_strength=magnetic_field_on_axis 58 | ) 59 | epsilon_hat = calc_squared_scale_ratio( 60 | safety_factor=cylindrical_safety_factor, major_radius=major_radius, perpendicular_decay_length=electron_pressure_decay_length 61 | ) 62 | 63 | k_ideal = calc_ideal_MHD_wavenumber( 64 | beta_e=beta_e, epsilon_hat=epsilon_hat, omega_B=omega_B, tau_i=ion_to_electron_temp_ratio, alpha_t=alpha_t 65 | ) 66 | k_RBM = calc_resistive_ballooning_wavenumber(critical_alpha_MHD=critical_alpha_MHD, alpha_t=alpha_t, omega_B=omega_B) * k_RBM_factor 67 | 68 | return convert_units(k_ideal / k_RBM, ureg.dimensionless) 69 | -------------------------------------------------------------------------------- /docs/doc_sources/Usage.rst: -------------------------------------------------------------------------------- 1 | .. _gettingstarted: 2 | 3 | Getting Started 4 | =================== 5 | 6 | Installation 7 | ^^^^^^^^^^^^^ 8 | 9 | The cfspopcon package is available on the `Python Package Index `, thus installation is as simple as: 10 | 11 | .. code:: 12 | 13 | >>> pip install cfspopcon 14 | >>> radas -d ./radas_dir 15 | 16 | The second step is to generate a folder of OpenADAS atomic data files. We can't ship these files with 17 | cfsPOPCON due to licensing issues, but they're easy to make with `radas`. You only need to do this once. 18 | 19 | Running cfspopcon from the command line 20 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 21 | 22 | Once you've installed :code:`cfspopcon`, you can run it from the command line using 23 | 24 | .. code:: 25 | 26 | >>> popcon example_cases/SPARC_PRD --show 27 | 28 | This will run the :code:`run_popcon_cli` function from :code:`cfspopcon/cli.py`. The first argument to :code:`popcon` should be a path to a folder containing an :code:`input.yaml` file, which 29 | sets the parameters for the POPCON analysis. Have a look at :code:`example_cases/SPARC_PRD/input.yaml` to see how this file is structured. 30 | 31 | The results of the POPCON analysis are stored in a :code:`output` folder in the directory where :code:`input.yaml` was read from. For the example case above, you can find the outputs in 32 | :code:`example_cases/SPARC_PRD/outputs`. These include a NetCDF dataset containing the results of the run, a JSON file representing points in plain-text, as well as any plots requested in the 33 | :code:`input.yaml` file. 34 | 35 | Getting started with Jupyter 36 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 37 | 38 | You can also run :code:`cfspopcon` via a Jupyter notebook. If you've installed :code:`cfspopcon` using :code:`pip`, launch :code:`jupyter` from the environment you installed :code:`cfspopcon` 39 | into. You can also run :code:`cfspopcon` without installing anything at all, by using the `binder interface `_. If you've installed using 40 | :code:`poetry`, you can either :code:`poetry run jupyter lab` or open Jupyter (either directly or via an IDE like VSCode) and select :code:`.venv/bin/python` as your kernel. 41 | 42 | An example notebook in the `docs folder `_. The contents and results of this can be compared to the 43 | static representation below: 44 | 45 | .. toctree:: 46 | 47 | getting_started 48 | 49 | More detailed documentation 50 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 51 | 52 | We also provide interactive examples for POPCON functionality using Jupyter notebooks, since this lets us ensure that our documentation always produces runnable code. You can find extended 53 | examples listed below: 54 | 55 | .. toctree:: 56 | 57 | understanding_algorithms 58 | 59 | Other example notebooks 60 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 61 | 62 | Jupyter notebooks provide a convenient way of demonstrating and documenting features of the code. You can find a collection of demonstration notebooks listed below. If you add features to 63 | :code:`cfspopcon`, or have a nice notebook which documents some pre-existing functionality, please add it below (this is a great way to start developing :code:`cfspopcon`). 64 | 65 | .. toctree:: 66 | 67 | separatrix_operational_space 68 | time_independent_inductances_and_fluxes 69 | -------------------------------------------------------------------------------- /cfspopcon/cli.py: -------------------------------------------------------------------------------- 1 | #!.venv/bin/python 2 | # Run this script from the repository directory. 3 | """CLI for cfspopcon.""" 4 | 5 | import warnings 6 | from pathlib import Path 7 | 8 | import click 9 | import matplotlib.pyplot as plt 10 | import xarray as xr 11 | 12 | from cfspopcon import file_io 13 | from cfspopcon.input_file_handling import read_case 14 | from cfspopcon.plotting import make_plot, read_plot_style 15 | from cfspopcon.unit_handling import UnitStrippedWarning 16 | 17 | 18 | @click.command() 19 | @click.argument("case", type=click.Path(exists=True)) 20 | @click.option("--dict", "-d", "kwargs", type=(str, str), multiple=True, help="Command-line arguments, takes precedence over config.") 21 | @click.option("--show", is_flag=True, help="Display an interactive figure of the result.") 22 | @click.option("--debug", is_flag=True, help="Enable the ipdb exception catcher. (Development helper)", hidden=True) 23 | def run_popcon_cli(case: str, show: bool, debug: bool, kwargs: tuple[tuple[str, str]]) -> None: 24 | """Run POPCON from the command line. 25 | 26 | This function uses "Click" to develop the command line interface. You can execute it using 27 | poetry run python cfspopcon/cli.py --help 28 | """ 29 | cli_args: dict[str, str] = dict(kwargs) 30 | 31 | if debug: 32 | with warnings.catch_warnings(): 33 | warnings.simplefilter("error", category=UnitStrippedWarning) 34 | try: 35 | # if ipdb is installed we use it to catch exceptions during development 36 | from ipdb import launch_ipdb_on_exception # type:ignore[import-untyped] 37 | 38 | with launch_ipdb_on_exception(): 39 | run_popcon(case, show, cli_args) 40 | except ModuleNotFoundError: 41 | run_popcon(case, show, cli_args) 42 | else: 43 | run_popcon(case, show, cli_args) 44 | 45 | 46 | @click.command() 47 | @click.option("-o", "--output", default="./popcon_algorithms.yaml", type=click.Path(exists=False)) 48 | def write_algorithms_yaml(output: str) -> None: 49 | """Write all available algorithms to a yaml helper file.""" 50 | from cfspopcon import Algorithm 51 | 52 | Algorithm.write_yaml(Path(output)) 53 | 54 | 55 | def run_popcon(case: str, show: bool, cli_args: dict[str, str]) -> None: 56 | """Run popcon case. 57 | 58 | Args: 59 | case: specify case to run (corresponding to a case in cases) 60 | show: show the resulting plots 61 | cli_args: command-line arguments, takes precedence over config. 62 | """ 63 | input_parameters, algorithm, points, plots = read_case(case, cli_args) 64 | 65 | dataset = xr.Dataset(input_parameters) 66 | 67 | algorithm.validate_inputs(dataset) 68 | dataset = algorithm.update_dataset(dataset) 69 | 70 | output_dir = Path(case) / "output" if Path(case).is_dir() else Path(case).parent / "output" 71 | output_dir.mkdir(exist_ok=True) 72 | 73 | file_io.write_dataset_to_netcdf(dataset, filepath=output_dir / "dataset.nc") 74 | 75 | if points is not None: 76 | for point, point_params in points.items(): 77 | file_io.write_point_to_file(dataset, point, point_params, output_dir=output_dir) 78 | 79 | # Plot the results 80 | if plots is not None: 81 | for plot_name, plot_style in plots.items(): 82 | print(f"Plotting {plot_name}") 83 | make_plot(dataset, read_plot_style(plot_style), points, title=plot_name, output_dir=output_dir, save_name=plot_style.stem) 84 | 85 | print("Done") 86 | if show: 87 | plt.show() 88 | 89 | 90 | if __name__ == "__main__": 91 | run_popcon_cli() 92 | -------------------------------------------------------------------------------- /cfspopcon/formulas/impurities/zeff_and_dilution_from_impurities.py: -------------------------------------------------------------------------------- 1 | """Calculate the impact of core impurities on z_effective and dilution.""" 2 | 3 | import xarray as xr 4 | 5 | from ...algorithm_class import Algorithm 6 | from ...unit_handling import Unitfull 7 | from .impurity_charge_state import calc_impurity_charge_state 8 | 9 | 10 | @Algorithm.register_algorithm( 11 | return_keys=[ 12 | "impurity_charge_state", 13 | "change_in_zeff", 14 | "change_in_dilution", 15 | "z_effective", 16 | "dilution", 17 | "summed_impurity_density", 18 | "average_ion_density", 19 | ] 20 | ) 21 | def calc_zeff_and_dilution_due_to_impurities( 22 | average_electron_density: Unitfull, 23 | average_electron_temp: Unitfull, 24 | impurity_concentration: xr.DataArray, 25 | atomic_data: xr.DataArray, 26 | ) -> tuple[Unitfull, ...]: 27 | """Calculate the impact of core impurities on z_effective and dilution. 28 | 29 | Args: 30 | average_electron_density: :term:`glossary link` 31 | average_electron_temp: :term:`glossary link` 32 | impurity_concentration: :term:`glossary link` 33 | atomic_data: :term:`glossary link` 34 | 35 | Returns: 36 | :term:`impurity_charge_state`, :term:`change_in_zeff`, :term:`change_in_dilution`, :term:`z_effective`, :term:`dilution`, :term:`summed_impurity_density`, :term:`average_ion_density` 37 | 38 | """ 39 | starting_zeff = 1.0 40 | starting_dilution = 1.0 41 | 42 | impurity_charge_state = calc_impurity_charge_state(average_electron_density, average_electron_temp, impurity_concentration, atomic_data) 43 | change_in_zeff = calc_change_in_zeff(impurity_charge_state, impurity_concentration) 44 | change_in_dilution = calc_change_in_dilution(impurity_charge_state, impurity_concentration) 45 | 46 | z_effective = starting_zeff + change_in_zeff.sum(dim="dim_species") 47 | dilution = starting_dilution - change_in_dilution.sum(dim="dim_species") 48 | 49 | # For strong seeding, the impurity content can reach levels where there are no electrons 50 | # left for the main ions. The following line prevents the main ion density from reaching 51 | # negative values. 52 | dilution = dilution.where(dilution >= 0, 0.0) 53 | summed_impurity_density = impurity_concentration.sum(dim="dim_species") * average_electron_density 54 | average_ion_density = dilution * average_electron_density 55 | 56 | return impurity_charge_state, change_in_zeff, change_in_dilution, z_effective, dilution, summed_impurity_density, average_ion_density 57 | 58 | 59 | def calc_change_in_zeff(impurity_charge_state: float, impurity_concentration: xr.DataArray) -> xr.DataArray: 60 | """Calculate the change in the effective charge due to the specified impurities. 61 | 62 | Args: 63 | impurity_charge_state: [~] :term:`glossary link` 64 | impurity_concentration: [~] :term:`glossary link` 65 | 66 | Returns: 67 | change in zeff [~] 68 | """ 69 | return impurity_charge_state * (impurity_charge_state - 1.0) * impurity_concentration 70 | 71 | 72 | def calc_change_in_dilution(impurity_charge_state: float, impurity_concentration: xr.DataArray) -> xr.DataArray: 73 | """Calculate the change in n_fuel/n_e due to the specified impurities. 74 | 75 | Args: 76 | impurity_charge_state: [~] :term:`glossary link` 77 | impurity_concentration: [~] :term:`glossary link` 78 | 79 | Returns: 80 | change in dilution [~] 81 | """ 82 | return impurity_charge_state * impurity_concentration 83 | -------------------------------------------------------------------------------- /cfspopcon/formulas/radiated_power/intrinsic_radiated_power_from_core.py: -------------------------------------------------------------------------------- 1 | """Calculate the power radiated from the confined region due to the fuel and impurity species.""" 2 | 3 | import xarray as xr 4 | 5 | from ... import named_options 6 | from ...algorithm_class import Algorithm 7 | from ...helpers import get_item 8 | from ...unit_handling import Unitfull 9 | from .bremsstrahlung import calc_bremsstrahlung_radiation 10 | from .impurity_radiated_power import calc_impurity_radiated_power 11 | from .synchrotron import calc_synchrotron_radiation 12 | 13 | 14 | @Algorithm.register_algorithm(return_keys=["P_radiation"]) 15 | def calc_intrinsic_radiated_power_from_core( 16 | rho: Unitfull, 17 | electron_density_profile: Unitfull, 18 | electron_temp_profile: Unitfull, 19 | z_effective: Unitfull, 20 | plasma_volume: Unitfull, 21 | major_radius: Unitfull, 22 | minor_radius: Unitfull, 23 | magnetic_field_on_axis: Unitfull, 24 | separatrix_elongation: Unitfull, 25 | radiated_power_method: named_options.RadiationMethod, 26 | radiated_power_scalar: Unitfull, 27 | impurity_concentration: xr.DataArray, 28 | atomic_data: xr.DataArray, 29 | ) -> Unitfull: 30 | """Calculate the power radiated from the confined region due to the fuel and impurity species. 31 | 32 | Args: 33 | rho: :term:`glossary link` 34 | electron_density_profile: :term:`glossary link` 35 | electron_temp_profile: :term:`glossary link` 36 | z_effective: :term:`glossary link` 37 | plasma_volume: :term:`glossary link` 38 | major_radius: :term:`glossary link` 39 | minor_radius: :term:`glossary link` 40 | magnetic_field_on_axis: :term:`glossary link` 41 | separatrix_elongation: :term:`glossary link` 42 | radiated_power_method: :term:`glossary link` 43 | radiated_power_scalar: :term:`glossary link` 44 | impurity_concentration: :term:`glossary link` 45 | atomic_data: :term:`glossary link` 46 | 47 | Returns: 48 | :term:`P_radiation` 49 | 50 | """ 51 | P_rad_bremsstrahlung = calc_bremsstrahlung_radiation(rho, electron_density_profile, electron_temp_profile, z_effective, plasma_volume) 52 | P_rad_bremsstrahlung_from_hydrogen = calc_bremsstrahlung_radiation( 53 | rho, electron_density_profile, electron_temp_profile, 1.0, plasma_volume 54 | ) 55 | P_rad_synchrotron = calc_synchrotron_radiation( 56 | rho, 57 | electron_density_profile, 58 | electron_temp_profile, 59 | major_radius, 60 | minor_radius, 61 | magnetic_field_on_axis, 62 | separatrix_elongation, 63 | plasma_volume, 64 | ) 65 | 66 | # Calculate radiated power due to Bremsstrahlung, Synchrotron and impurities 67 | if radiated_power_method == named_options.RadiationMethod.Inherent: 68 | return radiated_power_scalar * (P_rad_bremsstrahlung + P_rad_synchrotron) 69 | else: 70 | P_rad_impurity = calc_impurity_radiated_power( 71 | radiated_power_method=radiated_power_method, 72 | rho=rho, 73 | electron_temp_profile=electron_temp_profile, 74 | electron_density_profile=electron_density_profile, 75 | impurity_concentration=impurity_concentration, 76 | plasma_volume=plasma_volume, 77 | atomic_data=get_item(atomic_data), 78 | ) 79 | 80 | return radiated_power_scalar * (P_rad_bremsstrahlung_from_hydrogen + P_rad_synchrotron + P_rad_impurity.sum(dim="dim_species")) 81 | -------------------------------------------------------------------------------- /cfspopcon/formulas/scrape_off_layer/two_point_model/target_electron_temp.py: -------------------------------------------------------------------------------- 1 | """Routines to calculate the target electron temperature, following the 2-point-model method of Stangeby, PPCF 2018.""" 2 | 3 | from typing import Union 4 | 5 | import xarray as xr 6 | 7 | from ....unit_handling import Quantity 8 | 9 | 10 | def calc_target_electron_temp( 11 | target_electron_temp_basic: Union[Quantity, xr.DataArray], 12 | f_vol_loss_target_electron_temp: Union[float, xr.DataArray], 13 | f_other_target_electron_temp: Union[float, xr.DataArray], 14 | ) -> Union[Quantity, xr.DataArray]: 15 | """Calculate the target electron temperature, correcting for volume-losses and other effects. 16 | 17 | Components are calculated using the other functions in this file. 18 | """ 19 | return target_electron_temp_basic * f_vol_loss_target_electron_temp * f_other_target_electron_temp 20 | 21 | 22 | def calc_target_electron_temp_basic( 23 | average_ion_mass: Union[Quantity, xr.DataArray], 24 | q_parallel: Union[Quantity, xr.DataArray], 25 | upstream_total_pressure: Union[Quantity, xr.DataArray], 26 | sheath_heat_transmission_factor: Union[float, xr.DataArray], 27 | ) -> Union[Quantity, xr.DataArray]: 28 | """Calculate the electron temperature at the target according to the basic two-point-model. 29 | 30 | From equation 24, :cite:`stangeby_2018`. 31 | 32 | Args: 33 | average_ion_mass: [amu] 34 | q_parallel: [GW/m^2] 35 | upstream_total_pressure: [atm] 36 | sheath_heat_transmission_factor: [~] 37 | 38 | Returns: 39 | target_electron_temp_basic [eV] 40 | """ 41 | return (8.0 * average_ion_mass / sheath_heat_transmission_factor**2) * (q_parallel**2 / upstream_total_pressure**2) 42 | 43 | 44 | def calc_f_vol_loss_target_electron_temp( 45 | SOL_power_loss_fraction: Union[float, xr.DataArray], 46 | SOL_momentum_loss_fraction: Union[float, xr.DataArray], 47 | ) -> Union[float, xr.DataArray]: 48 | """Calculate the volume-loss correction term for the electron temperature at the target. 49 | 50 | From equation 24, :cite:`stangeby_2018`. 51 | 52 | Args: 53 | SOL_power_loss_fraction: f_cooling [~] 54 | SOL_momentum_loss_fraction: f_mom-loss [~] 55 | 56 | Returns: 57 | f_vol_loss_target_electron_temp [~] 58 | """ 59 | return (1.0 - SOL_power_loss_fraction) ** 2 / (1.0 - SOL_momentum_loss_fraction) ** 2 60 | 61 | 62 | def calc_f_other_target_electron_temp( 63 | target_ratio_of_ion_to_electron_temp: Union[float, xr.DataArray], 64 | target_ratio_of_electron_to_ion_density: Union[float, xr.DataArray], 65 | target_mach_number: Union[float, xr.DataArray], 66 | toroidal_flux_expansion: Union[float, xr.DataArray], 67 | ) -> Union[float, xr.DataArray]: 68 | """Calculate correction terms other than the volume-loss correction for the electron temperature at the target. 69 | 70 | Includes flux expansion, dilution of ions, different electron and ion temperatures and sub/super-sonic outflow. 71 | 72 | From equation 24, :cite:`stangeby_2018`., with 73 | 74 | Args: 75 | target_ratio_of_ion_to_electron_temp: tau_t = (T_i / T_e)_target [equation 21] [~] 76 | target_ratio_of_electron_to_ion_density: z_t = (ne / total ion density)_target [equation 22] [~] 77 | target_mach_number: M_t = (parallel ion velocity / sound speed)_target [~] 78 | toroidal_flux_expansion: R_t/R_u = major radius at target / major radius upstream [see discussion around equation 12] [~] 79 | 80 | Returns: 81 | f_other_target_electron_temp [~] 82 | """ 83 | return ( 84 | ((1.0 + target_ratio_of_ion_to_electron_temp / target_ratio_of_electron_to_ion_density) / 2.0) 85 | * ((1.0 + target_mach_number**2) ** 2 / (4.0 * target_mach_number**2)) 86 | * toroidal_flux_expansion**-2 87 | ) 88 | -------------------------------------------------------------------------------- /cfspopcon/formulas/scrape_off_layer/two_point_model/target_electron_flux.py: -------------------------------------------------------------------------------- 1 | """Routines to calculate the target electron flux, following the 2-point-model method of Stangeby, PPCF 2018.""" 2 | 3 | from typing import Union 4 | 5 | import xarray as xr 6 | 7 | from ....unit_handling import Quantity 8 | 9 | 10 | def calc_target_electron_flux( 11 | target_electron_flux_basic: Union[Quantity, xr.DataArray], 12 | f_vol_loss_target_electron_flux: Union[float, xr.DataArray], 13 | f_other_target_electron_flux: Union[float, xr.DataArray], 14 | ) -> Union[Quantity, xr.DataArray]: 15 | """Calculate the target electron flux, correcting for volume-losses and other effects. 16 | 17 | Components are calculated using the other functions in this file. 18 | """ 19 | return target_electron_flux_basic * f_vol_loss_target_electron_flux * f_other_target_electron_flux 20 | 21 | 22 | def calc_target_electron_flux_basic( 23 | average_ion_mass: Union[Quantity, xr.DataArray], 24 | q_parallel: Union[Quantity, xr.DataArray], 25 | upstream_total_pressure: Union[Quantity, xr.DataArray], 26 | sheath_heat_transmission_factor: Union[float, xr.DataArray], 27 | ) -> Union[Quantity, xr.DataArray]: 28 | """Calculate the flux of electrons (particles per square-metre per second) at the target according to the basic two-point-model. 29 | 30 | From equation 24, :cite:`stangeby_2018`. 31 | 32 | Args: 33 | average_ion_mass: [amu] 34 | q_parallel: [GW/m^2] 35 | upstream_total_pressure: [atm] 36 | sheath_heat_transmission_factor: [~] 37 | 38 | Returns: 39 | target_electron_flux_basic [m^-2 s^-1] 40 | """ 41 | return sheath_heat_transmission_factor / (8.0 * average_ion_mass) * upstream_total_pressure**2 / q_parallel 42 | 43 | 44 | def calc_f_vol_loss_target_electron_flux( 45 | SOL_power_loss_fraction: Union[float, xr.DataArray], 46 | SOL_momentum_loss_fraction: Union[float, xr.DataArray], 47 | ) -> Union[float, xr.DataArray]: 48 | """Calculate the volume-loss correction term for the electron flux at the target. 49 | 50 | From equation 24, :cite:`stangeby_2018`. 51 | 52 | Args: 53 | SOL_power_loss_fraction: f_cooling [~] 54 | SOL_momentum_loss_fraction: f_mom-loss [~] 55 | 56 | Returns: 57 | f_vol_loss_target_electron_flux [~] 58 | """ 59 | return (1.0 - SOL_momentum_loss_fraction) ** 2 / (1.0 - SOL_power_loss_fraction) 60 | 61 | 62 | def calc_f_other_target_electron_flux( 63 | target_ratio_of_ion_to_electron_temp: Union[float, xr.DataArray], 64 | target_ratio_of_electron_to_ion_density: Union[float, xr.DataArray], 65 | target_mach_number: Union[float, xr.DataArray], 66 | toroidal_flux_expansion: Union[float, xr.DataArray], 67 | ) -> Union[float, xr.DataArray]: 68 | """Calculate correction terms other than the volume-loss correction for the electron flux at the target. 69 | 70 | Includes flux expansion, dilution of ions, different electron and ion temperatures and sub/super-sonic outflow. 71 | From equation 24, :cite:`stangeby_2018`., with 72 | 73 | Args: 74 | target_ratio_of_ion_to_electron_temp: tau_t = (T_i / T_e)_target [equation 21] [~] 75 | target_ratio_of_electron_to_ion_density: z_t = (ne / total ion density)_target [equation 22] [~] 76 | target_mach_number: M_t = (parallel ion velocity / sound speed)_target [~] 77 | toroidal_flux_expansion: R_t/R_u = major radius at target / major radius upstream [see discussion around equation 12] [~] 78 | 79 | Returns: 80 | f_other_target_electron_flux [~] 81 | """ 82 | return ( 83 | (2.0 / (1.0 + target_ratio_of_ion_to_electron_temp / target_ratio_of_electron_to_ion_density)) 84 | * 4.0 85 | * target_mach_number**2 86 | / (1.0 + target_mach_number**2) ** 2 87 | * toroidal_flux_expansion 88 | ) 89 | -------------------------------------------------------------------------------- /cfspopcon/formulas/separatrix_conditions/separatrix_operational_space/AUG_SepOS_reference.yml: -------------------------------------------------------------------------------- 1 | LH_power_in_electron_channel: 2 | separatrix_density: 3 | - 0.5335805521219613 4 | - 0.5681911825298723 5 | - 0.6056860321384426 6 | - 0.677791512154924 7 | - 0.747012772970746 8 | - 0.8796868562010712 9 | - 1.0065925010300782 10 | - 1.2142562834775443 11 | - 1.5718994643592912 12 | - 1.9699217140502676 13 | - 2.4083230325504736 14 | - 2.84672435105068 15 | - 3.2966625463535224 16 | - 4.00041203131438 17 | - 4.536876802637001 18 | - 5.044499381953029 19 | - 5.719406674907293 20 | separatrix_power: 21 | - 4.988221436984688 22 | - 3.998822143698469 23 | - 2.9946996466431095 24 | - 1.9935217903415783 25 | - 1.4899882214369842 26 | - 1.001177856301532 27 | - 0.8186101295641928 28 | - 0.7067137809187276 29 | - 0.727326266195524 30 | - 0.8568904593639575 31 | - 1.0806831566548887 32 | - 1.3692579505300357 33 | - 1.7196702002355715 34 | - 2.417550058892815 35 | - 3.0918727915194344 36 | - 3.8339222614840986 37 | - 4.994110718492344 38 | LH_power_in_ion_channel: 39 | separatrix_density: 40 | - 0.42005754212905844 41 | - 0.7250308261405669 42 | - 1.104808877928483 43 | - 1.5536374845869299 44 | - 2.1290587751746815 45 | - 2.451294697903822 46 | - 2.917385943279901 47 | - 3.2568845047266746 48 | - 3.699958898479244 49 | - 4.07973695026716 50 | - 4.419235511713934 51 | - 4.70694615700781 52 | - 5.150020550760377 53 | - 5.411837237977805 54 | - 5.685162351006989 55 | - 5.964241676942047 56 | - 6.286477599671187 57 | - 6.942457870941224 58 | - 6.660501438553226 59 | separatrix_power: 60 | - 0.01758499413833514 61 | - 0.0316529894490033 62 | - 0.04308323563892125 63 | - 0.053927315357561345 64 | - 0.06828839390386848 65 | - 0.07796014067995294 66 | - 0.09466588511137147 67 | - 0.10873388042203969 68 | - 0.13130128956623666 69 | - 0.15445486518171148 70 | - 0.1793669402110198 71 | - 0.20252051582649455 72 | - 0.24443141852286038 73 | - 0.27227432590855793 74 | - 0.30480656506447823 75 | - 0.34202813599062126 76 | - 0.38774912075029305 77 | - 0.49882766705744436 78 | - 0.44753810082063306 79 | LH_transition: 80 | separatrix_density: 81 | - 0.40384615384615385 82 | - 0.4725274725274725 83 | - 0.5686813186813188 84 | - 0.6785714285714285 85 | - 0.8983516483516484 86 | - 1.1648351648351647 87 | - 1.5549450549450552 88 | - 2.027472527472528 89 | - 2.5000000000000004 90 | - 3.3241758241758244 91 | - 4.502747252747253 92 | - 5.500000000000001 93 | - 6.502747252747252 94 | separatrix_temp: 95 | - 142.07865168539323 96 | - 118.98876404494379 97 | - 98.42696629213481 98 | - 82.75280898876403 99 | - 67.41573033707864 100 | - 60.16853932584269 101 | - 56.79775280898875 102 | - 56.123595505617956 103 | - 57.30337078651684 104 | - 61.01123595505618 105 | - 68.93258426966288 106 | - 76.76966292134827 107 | - 85.44943820224717 108 | MHD_limit: 109 | separatrix_density: 110 | - 4.054945054945055 111 | - 4.299450549450548 112 | - 4.802197802197801 113 | - 5.302197802197801 114 | - 5.79945054945055 115 | - 6.302197802197802 116 | - 6.799450549450549 117 | separatrix_temp: 118 | - 149.49438202247188 119 | - 144.438202247191 120 | - 137.86516853932582 121 | - 133.9887640449438 122 | - 131.5449438202247 123 | - 130.36516853932582 124 | - 129.60674157303367 125 | density_limit: 126 | separatrix_density: 127 | - 1.9395604395604398 128 | - 2.2005494505494507 129 | - 2.401098901098902 130 | - 2.598901098901099 131 | - 2.7994505494505555 132 | separatrix_temp: 133 | - 26.292134831460658 134 | - 33.876404494382015 135 | - 40.44943820224721 136 | - 47.443820224719104 137 | - 55.02808988764066 138 | -------------------------------------------------------------------------------- /cfspopcon/formulas/scrape_off_layer/two_point_model/target_electron_density.py: -------------------------------------------------------------------------------- 1 | """Routines to calculate the target electron density, following the 2-point-model method of Stangeby, PPCF 2018.""" 2 | 3 | from typing import Union 4 | 5 | import xarray as xr 6 | 7 | from ....unit_handling import Quantity 8 | 9 | 10 | def calc_target_electron_density( 11 | target_electron_density_basic: Union[Quantity, xr.DataArray], 12 | f_vol_loss_target_electron_density: Union[float, xr.DataArray], 13 | f_other_target_electron_density: Union[float, xr.DataArray], 14 | ) -> Union[Quantity, xr.DataArray]: 15 | """Calculate the target electron density, correcting for volume-losses and other effects. 16 | 17 | Components are calculated using the other functions in this file. 18 | """ 19 | return target_electron_density_basic * f_vol_loss_target_electron_density * f_other_target_electron_density 20 | 21 | 22 | def calc_target_electron_density_basic( 23 | average_ion_mass: Union[Quantity, xr.DataArray], 24 | q_parallel: Union[Quantity, xr.DataArray], 25 | upstream_total_pressure: Union[Quantity, xr.DataArray], 26 | sheath_heat_transmission_factor: Union[float, xr.DataArray], 27 | ) -> Union[Quantity, xr.DataArray]: 28 | """Calculate the electron density at the target according to the basic two-point-model. 29 | 30 | From equation 24, :cite:`stangeby_2018`. 31 | 32 | Args: 33 | average_ion_mass: [amu] 34 | q_parallel: [GW/m^2] 35 | upstream_total_pressure: [atm] 36 | sheath_heat_transmission_factor: [~] 37 | 38 | Returns: 39 | target_electron_density_basic [m^-3] 40 | """ 41 | return sheath_heat_transmission_factor**2 / (32.0 * average_ion_mass) * upstream_total_pressure**3 / q_parallel**2 42 | 43 | 44 | def calc_f_vol_loss_target_electron_density( 45 | SOL_power_loss_fraction: Union[float, xr.DataArray], 46 | SOL_momentum_loss_fraction: Union[float, xr.DataArray], 47 | ) -> Union[float, xr.DataArray]: 48 | """Calculate the volume-loss correction term for the electron density at the target. 49 | 50 | From equation 24, :cite:`stangeby_2018`. 51 | 52 | Args: 53 | SOL_power_loss_fraction: f_cooling [~] 54 | SOL_momentum_loss_fraction: f_mom-loss [~] 55 | 56 | Returns: 57 | f_vol_loss_target_electron_density [~] 58 | """ 59 | return (1.0 - SOL_momentum_loss_fraction) ** 3 / (1.0 - SOL_power_loss_fraction) ** 2 60 | 61 | 62 | def calc_f_other_target_electron_density( 63 | target_ratio_of_ion_to_electron_temp: Union[float, xr.DataArray], 64 | target_ratio_of_electron_to_ion_density: Union[float, xr.DataArray], 65 | target_mach_number: Union[float, xr.DataArray], 66 | toroidal_flux_expansion: Union[float, xr.DataArray], 67 | ) -> Union[float, xr.DataArray]: 68 | """Calculate correction terms other than the volume-loss correction for the electron density at the target. 69 | 70 | Includes flux expansion, dilution of ions, different electron and ion temperatures and sub/super-sonic outflow. 71 | 72 | From equation 24, :cite:`stangeby_2018`., with 73 | 74 | Args: 75 | target_ratio_of_ion_to_electron_temp: tau_t = (T_i / T_e)_target [equation 21] [~] 76 | target_ratio_of_electron_to_ion_density: z_t = (ne / total ion density)_target [equation 22] [~] 77 | target_mach_number: M_t = (parallel ion velocity / sound speed)_target [~] 78 | toroidal_flux_expansion: R_t/R_u = major radius at target / major radius upstream [see discussion around equation 12] [~] 79 | 80 | Returns: 81 | f_other_target_electron_density [~] 82 | """ 83 | return ( 84 | (4.0 / (1.0 + target_ratio_of_ion_to_electron_temp / target_ratio_of_electron_to_ion_density) ** 2) 85 | * 8.0 86 | * target_mach_number**2 87 | / (1.0 + target_mach_number**2) ** 3 88 | * toroidal_flux_expansion**2 89 | ) 90 | -------------------------------------------------------------------------------- /cfspopcon/formulas/radiated_power/synchrotron.py: -------------------------------------------------------------------------------- 1 | """Calculate the inherent Synchrotron radiated power.""" 2 | 3 | import numpy as np 4 | from numpy import float64 5 | from numpy.typing import NDArray 6 | 7 | from ...algorithm_class import Algorithm 8 | from ...unit_handling import ureg, wraps_ufunc 9 | from ..geometry.volume_integral import integrate_profile_over_volume 10 | 11 | 12 | @Algorithm.register_algorithm(return_keys=["P_rad_synchrotron"]) 13 | @wraps_ufunc( 14 | return_units=dict(P_rad_synchrotron=ureg.MW), 15 | input_units=dict( 16 | rho=ureg.dimensionless, 17 | electron_density_profile=ureg.n19, 18 | electron_temp_profile=ureg.keV, 19 | major_radius=ureg.m, 20 | minor_radius=ureg.m, 21 | magnetic_field_on_axis=ureg.T, 22 | separatrix_elongation=ureg.dimensionless, 23 | plasma_volume=ureg.m**3, 24 | ), 25 | input_core_dims=[("dim_rho",), ("dim_rho",), ("dim_rho",), (), (), (), (), ()], 26 | ) 27 | def calc_synchrotron_radiation( 28 | rho: NDArray[float64], 29 | electron_density_profile: NDArray[float64], 30 | electron_temp_profile: NDArray[float64], 31 | major_radius: float, 32 | minor_radius: float, 33 | magnetic_field_on_axis: float, 34 | separatrix_elongation: float, 35 | plasma_volume: float, 36 | ) -> float: 37 | """Calculate the Synchrotron radiated power due to the main plasma. 38 | 39 | This can be an important loss mechanism in high temperature plasmas. 40 | 41 | Formula 15 in :cite:`stott_feasibility_2005` 42 | 43 | For now this calculation assumes 90% wall reflectivity, consistent with stott_feasibility_2005. 44 | 45 | This calculation also assumes profiles of the form n(r) = n[1 - (r/a)**2]**alpha_n and 46 | T(r) = Tedge + (T - Tedge)[1 - (r/a)**gamma_T]**alpha_T. For now, these are assumed as 47 | gamma_T = 2, alpha_n = 0.5 and alpha_T = 1, consistent with stott_feasibility_2005. 48 | 49 | An alternative approach could be developed using formula 6 in :cite:`zohm_use_2019`, which assumes 80% wall reflectivity. 50 | 51 | Args: 52 | electron_density_profile: [1e19 m^-3] :term:`glossary link` 53 | electron_temp_profile: [keV] :term:`glossary link` 54 | major_radius: [m] :term:`glossary link` 55 | minor_radius: [m] :term:`glossary link` 56 | magnetic_field_on_axis: [T] :term:`glossary link` 57 | separatrix_elongation: [~] :term:`glossary link` 58 | rho: [~] :term:`glossary link` 59 | plasma_volume: [m^3] :term:`glossary link` 60 | 61 | Returns: 62 | Radiated bremsstrahlung power per cubic meter [MW / m^3] 63 | """ 64 | ne20 = electron_density_profile / 10 65 | 66 | Rw = 0.8 # wall reflectivity 67 | gamma_T = 2 # temperature profile inner exponent (2 is ~parabolic) 68 | alpha_n = 0.5 # density profile outer exponent (0.5 is rather broad) 69 | alpha_T = 1 # temperature profile outer exponent (1 is ~parabolic) 70 | 71 | # effective optical thickness 72 | rhoa = 6.04e3 * minor_radius * ne20 / magnetic_field_on_axis 73 | # profile peaking correction 74 | Ks = ( 75 | (alpha_n + 3.87 * alpha_T + 1.46) ** (-0.79) 76 | * (1.98 + alpha_n) ** (1.36) 77 | * gamma_T**2.14 78 | * (gamma_T**1.53 + 1.87 * alpha_T - 0.16) ** (-1.33) 79 | ) 80 | # aspect ratio correction 81 | Gs = 0.93 * (1 + 0.85 * np.exp(-0.82 * major_radius / minor_radius)) 82 | 83 | # dimensionless parameter to account for plasma transparency and wall reflections 84 | Phi = ( 85 | 6.86e-5 86 | * separatrix_elongation ** (-0.21) 87 | * (16 + electron_temp_profile) ** (2.61) 88 | * ((rhoa / (1 - Rw)) ** (0.41) + 0.12 * electron_temp_profile) ** (-1.51) 89 | * Ks 90 | * Gs 91 | ) 92 | 93 | P_sync_r = 6.25e-3 * ne20 * electron_temp_profile * magnetic_field_on_axis**2 * Phi 94 | P_sync: float = integrate_profile_over_volume.unitless_func(P_sync_r, rho, plasma_volume) 95 | 96 | return P_sync 97 | -------------------------------------------------------------------------------- /tests/test_scrape_off_layer_model.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | from cfspopcon import formulas 5 | from cfspopcon.named_options import LambdaQScaling 6 | from cfspopcon.unit_handling import ureg 7 | 8 | lambda_q_tests = { 9 | LambdaQScaling.Brunner: 0.4332283874128845 * ureg.mm, 10 | LambdaQScaling.EichRegression14: 0.20533809707365488 * ureg.mm, 11 | LambdaQScaling.EichRegression15: 0.34842497310813536 * ureg.mm, 12 | LambdaQScaling.EichRegression9: 0.5865460692254366 * ureg.mm, 13 | } 14 | 15 | 16 | @pytest.fixture() 17 | def average_total_pressure(): 18 | return 732028.9793 * ureg.Pa 19 | 20 | 21 | @pytest.fixture() 22 | def power_crossing_separatrix(): 23 | return 25.57417052 * ureg.MW 24 | 25 | 26 | @pytest.fixture() 27 | def major_radius(): 28 | return 1.85 * ureg.m 29 | 30 | 31 | @pytest.fixture() 32 | def B_pol_out_mid(): 33 | return 3.052711915 * ureg.T 34 | 35 | 36 | @pytest.fixture() 37 | def inverse_aspect_ratio(): 38 | return 0.3081000000 39 | 40 | 41 | @pytest.fixture() 42 | def magnetic_field_on_axis(): 43 | return 12.20000000 * ureg.T 44 | 45 | 46 | @pytest.fixture() 47 | def q_star(): 48 | return 3.290275716 49 | 50 | 51 | @pytest.fixture() 52 | def lambda_q_factor(): 53 | return 1.23 54 | 55 | 56 | @pytest.mark.parametrize(["scaling", "result"], lambda_q_tests.items(), ids=[key.name for key in lambda_q_tests.keys()]) 57 | def test_lambda_q_scalings( 58 | scaling, 59 | result, 60 | average_total_pressure, 61 | power_crossing_separatrix, 62 | major_radius, 63 | B_pol_out_mid, 64 | inverse_aspect_ratio, 65 | magnetic_field_on_axis, 66 | q_star, 67 | lambda_q_factor, 68 | ): 69 | lambda_q = formulas.scrape_off_layer.calc_lambda_q( 70 | lambda_q_scaling=scaling, 71 | average_total_pressure=average_total_pressure, 72 | power_crossing_separatrix=power_crossing_separatrix, 73 | major_radius=major_radius, 74 | B_pol_out_mid=B_pol_out_mid, 75 | inverse_aspect_ratio=inverse_aspect_ratio, 76 | magnetic_field_on_axis=magnetic_field_on_axis, 77 | q_star=q_star, 78 | lambda_q_factor=lambda_q_factor, 79 | ) 80 | 81 | assert np.isclose(lambda_q, result) 82 | 83 | 84 | @pytest.mark.parametrize(["scaling", "result"], lambda_q_tests.items(), ids=[key.name for key in lambda_q_tests.keys()]) 85 | def test_lambda_q_scalings_with_algorithms( 86 | scaling, 87 | result, 88 | average_total_pressure, 89 | power_crossing_separatrix, 90 | major_radius, 91 | B_pol_out_mid, 92 | inverse_aspect_ratio, 93 | magnetic_field_on_axis, 94 | q_star, 95 | lambda_q_factor, 96 | ): 97 | if scaling == LambdaQScaling.Brunner: 98 | lambda_q = formulas.scrape_off_layer.lambda_q.calc_lambda_q_with_brunner( 99 | average_total_pressure=average_total_pressure, lambda_q_factor=lambda_q_factor 100 | ) 101 | elif scaling == LambdaQScaling.EichRegression14: 102 | lambda_q = formulas.scrape_off_layer.lambda_q.calc_lambda_q_with_eich_regression_14( 103 | B_pol_out_mid=B_pol_out_mid, 104 | lambda_q_factor=lambda_q_factor, 105 | ) 106 | elif scaling == LambdaQScaling.EichRegression15: 107 | lambda_q = formulas.scrape_off_layer.lambda_q.calc_lambda_q_with_eich_regression_15( 108 | power_crossing_separatrix=power_crossing_separatrix, 109 | major_radius=major_radius, 110 | B_pol_out_mid=B_pol_out_mid, 111 | inverse_aspect_ratio=inverse_aspect_ratio, 112 | lambda_q_factor=lambda_q_factor, 113 | ) 114 | elif scaling == LambdaQScaling.EichRegression9: 115 | lambda_q = formulas.scrape_off_layer.lambda_q.calc_lambda_q_with_eich_regression_9( 116 | magnetic_field_on_axis=magnetic_field_on_axis, 117 | q_star=q_star, 118 | power_crossing_separatrix=power_crossing_separatrix, 119 | lambda_q_factor=lambda_q_factor, 120 | ) 121 | else: 122 | raise NotImplementedError(f"Add the algorithm for {scaling.name}.") 123 | 124 | assert np.isclose(lambda_q, result) 125 | -------------------------------------------------------------------------------- /tests/test_regression_against_cases.py: -------------------------------------------------------------------------------- 1 | import copy 2 | from pathlib import Path 3 | 4 | import pytest 5 | import xarray as xr 6 | from utils.regression_results import ( 7 | ALL_CASE_NAMES, 8 | ALL_CASE_PATHS, 9 | CASES_DIR, 10 | ) 11 | from xarray.testing import assert_allclose 12 | 13 | from cfspopcon.file_io import read_dataset_from_netcdf, write_dataset_to_netcdf 14 | from cfspopcon.input_file_handling import read_case 15 | 16 | ignored_variables = ["radas_version"] 17 | 18 | 19 | @pytest.mark.regression 20 | @pytest.mark.parametrize("case", ALL_CASE_PATHS, ids=ALL_CASE_NAMES) 21 | @pytest.mark.filterwarnings("ignore:Not all input parameters were used") 22 | def test_regression_against_case(case: Path): 23 | input_parameters, algorithm, _, _ = read_case(case) 24 | case_name = case.parent.stem 25 | 26 | dataset = algorithm.run(**input_parameters).merge(input_parameters) 27 | write_dataset_to_netcdf(dataset, Path(__file__).parent / "regression_results" / f"test1_{case.parent.stem}.nc") 28 | 29 | dataset = ( 30 | read_dataset_from_netcdf(Path(__file__).parent / "regression_results" / f"test1_{case.parent.stem}.nc") 31 | .load() 32 | .drop_vars(ignored_variables, errors="ignore") 33 | ) 34 | reference_dataset = ( 35 | read_dataset_from_netcdf(Path(__file__).parent / "regression_results" / f"{case_name}_result.nc") 36 | .load() 37 | .drop_vars(ignored_variables, errors="ignore") 38 | ) 39 | 40 | ordered_dims = [dim for dim in reference_dataset.dims] 41 | assert_allclose(dataset.transpose(*ordered_dims), reference_dataset.transpose(*ordered_dims)) 42 | 43 | 44 | @pytest.mark.regression 45 | @pytest.mark.parametrize("case", ALL_CASE_PATHS, ids=ALL_CASE_NAMES) 46 | @pytest.mark.filterwarnings("ignore:Not all input parameters were used") 47 | def test_regression_against_case_with_update(case: Path): 48 | input_parameters, algorithm, _, _ = read_case(case) 49 | case_name = case.parent.stem 50 | 51 | dataset = xr.Dataset(input_parameters) 52 | 53 | dataset = algorithm.update_dataset(dataset) 54 | write_dataset_to_netcdf(dataset, Path(__file__).parent / "regression_results" / f"test2_{case.parent.stem}.nc") 55 | 56 | dataset = ( 57 | read_dataset_from_netcdf(Path(__file__).parent / "regression_results" / f"test2_{case.parent.stem}.nc") 58 | .load() 59 | .drop_vars(ignored_variables, errors="ignore") 60 | ) 61 | reference_dataset = ( 62 | read_dataset_from_netcdf(Path(__file__).parent / "regression_results" / f"{case_name}_result.nc") 63 | .load() 64 | .drop_vars(ignored_variables, errors="ignore") 65 | ) 66 | 67 | ordered_dims = [dim for dim in reference_dataset.dims] 68 | assert_allclose(dataset.transpose(*ordered_dims), reference_dataset.transpose(*ordered_dims)) 69 | 70 | 71 | @pytest.mark.regression 72 | @pytest.mark.filterwarnings("ignore:Not all input parameters were used") 73 | def test_regression_against_case_with_repeated_update(): 74 | case = CASES_DIR / "SPARC_PRD" / "input.yaml" 75 | 76 | input_parameters, algorithm, _, _ = read_case(case) 77 | input_parameters["average_electron_density"] = input_parameters["average_electron_density"][::5] 78 | input_parameters["average_electron_temp"] = input_parameters["average_electron_temp"][::5] 79 | 80 | dataset = xr.Dataset(input_parameters) 81 | 82 | first_run = algorithm.update_dataset(dataset) 83 | 84 | # Make sure that first_run isn't being modified in-place when we re-run it. 85 | first_run_copy = copy.deepcopy(first_run) 86 | second_run = algorithm.update_dataset(first_run_copy) 87 | 88 | for variable in ["atomic_data"]: 89 | first_run = first_run.drop_vars(variable) 90 | second_run = second_run.drop_vars(variable) 91 | 92 | # The ordering of the dimensions changes between the runs, and for some reason the automatic 93 | # xarray broadcasting isn't handling this. Because of this, we manually ensure that the 94 | # dimension ordering matches. 95 | ordered_dims = [dim for dim in first_run.dims] 96 | assert_allclose( 97 | first_run.transpose(*ordered_dims), 98 | second_run.transpose(*ordered_dims), 99 | ) 100 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "cfspopcon" 3 | version = "7.2.0" 4 | description = "Empirically-derived scoping of tokamak operational space." 5 | authors = ["Commonwealth Fusion Systems"] 6 | readme = "README.md" 7 | classifiers = [ 8 | "Development Status :: 5 - Production/Stable", 9 | "Intended Audience :: Science/Research", 10 | "Programming Language :: Python :: 3", 11 | "Programming Language :: Python :: 3.10", 12 | "Programming Language :: Python :: 3.11", 13 | "Programming Language :: Python :: 3.12", 14 | "Programming Language :: Python :: 3 :: Only", 15 | "Topic :: Scientific/Engineering :: Physics", 16 | "License :: OSI Approved :: MIT License", 17 | ] 18 | 19 | [tool.poetry.scripts] 20 | popcon = 'cfspopcon.cli:run_popcon_cli' 21 | cfspopcon = 'cfspopcon.cli:run_popcon_cli' 22 | popcon_algorithms = 'cfspopcon.cli:write_algorithms_yaml' 23 | 24 | [tool.poetry.dependencies] 25 | python = ">=3.10" 26 | numpy = [ 27 | {version = "^2.3", python = ">=3.11"}, 28 | {version = "^2.2", python = "<3.11"} 29 | ] 30 | pandas = "^2.2" 31 | scipy = "^1.8" 32 | seaborn = "^0.13" 33 | pyyaml = "^6.0" 34 | toml = "^0.10.2" 35 | typing-extensions = "^4.12" 36 | pint = "^0.24" 37 | xarray = ">=2024" 38 | pint-xarray = "^0.5" 39 | click = "^8.1.0" 40 | netcdf4 = "^1.7" 41 | radas = ">=2024.8.0" 42 | contourpy = "^1.2.1" 43 | 44 | [tool.poetry.group.dev.dependencies] 45 | pre-commit = "^4.2" 46 | pytest = "^8.2" 47 | coverage = "^7.6" 48 | pytest-cov = "^6.0" 49 | types-pyyaml = "^6.0.12.2" 50 | pandas-stubs = "^2.0" 51 | mypy = "^1.10" 52 | scipy-stubs = "^1.15.3.0" 53 | sphinx = [ 54 | {version = "^8.0", python = ">=3.11"}, 55 | {version = "^7.3", python = "<3.11"} 56 | ] 57 | sphinx-rtd-theme = [ 58 | {version = "^3.0", python = ">=3.11"}, 59 | {version = "^2.0", python = "<3.11"} 60 | ] 61 | sphinxcontrib-bibtex = "^2.6.1" 62 | sphinx-copybutton = "^0.5.2" 63 | ruff = "^0.11" 64 | pickleshare = "^0.7.5" 65 | nbmake = "^1.5" 66 | nbsphinx = "^0.9" 67 | ipdb = "^0.13.13" 68 | # Added as workaround for sphinx bibtex issue https://github.com/mcmtroffaes/sphinxcontrib-bibtex/issues/345 69 | setuptools = ">=71.0.3,<79.0.0" 70 | 71 | [tool.coverage.report] 72 | fail_under = 82 73 | 74 | [tool.pytest.ini_options] 75 | addopts = "--cov=cfspopcon --cov-report term-missing --cov-report xml:coverage.xml --verbose -s --nbmake" 76 | testpaths = [ 77 | "tests", 78 | "docs/doc_sources" 79 | ] 80 | markers = [ 81 | "docs: marks tests as testing the documentation (deselect with '-m \"not docs\"')", 82 | "cli: marks tests as testing the command-line-interface (deselect with '-m \"not cli\"')", 83 | "regression: marks tests as checking the regression result (deselect with '-m \"not regression\"')", 84 | ] 85 | filterwarnings = [ 86 | "error", 87 | "ignore:numpy.ndarray size changed, may indicate binary incompatibility. Expected 16 from C header, got 96 from PyObject", 88 | ] 89 | 90 | [build-system] 91 | requires = ["poetry-core>=1.0.0"] 92 | build-backend = "poetry.core.masonry.api" 93 | 94 | [tool.mypy] 95 | strict = true 96 | disallow_any_generics=false 97 | exclude = [ 98 | '^cfspopcon/plotting/.*\.py$', # these need to fixed 99 | ] 100 | 101 | [tool.ruff] 102 | lint.select = [ 103 | "A", # avoid shadowing 104 | "B", # flake8-bugbear 105 | "C4", # comprehensions 106 | "D", #docstrings 107 | "E", # pycodestyle Errors 108 | "ERA", # no commented out code 109 | "F", # pyflakes 110 | "FLY", # flynt 111 | "I001", # isort 112 | "ISC", # implicit string concatenation 113 | "PERF", # Perflint 114 | "PIE", # flake8-pie 115 | "PGH", # pygrep-hooks 116 | "PL", # pylint 117 | "Q", # flake8-quotes 118 | "RUF", # ruff builtins e.g. noqa checking 119 | "T10", # flake8-debugger (no breakpoint etc) 120 | "TCH",# type-checking imports 121 | "UP", # pyupgrade 122 | "W", # pycodestyle warnings 123 | ] 124 | 125 | lint.ignore = [ 126 | "E501", # Never enforce line length violations, we have black for that. 127 | "PLR0913", #ignore limit on number of args 128 | "PLR2004", #ignore magic values warning, at least for now 129 | "C408", # use {} instead of dict(), but we use dict heavily, for now leave it 130 | "ISC001", 131 | ] 132 | lint.pyupgrade.keep-runtime-typing=true 133 | lint.pydocstyle.convention = "google" 134 | target-version = "py39" 135 | line-length = 140 136 | 137 | -------------------------------------------------------------------------------- /cfspopcon/formulas/impurities/impurity_array_helpers.py: -------------------------------------------------------------------------------- 1 | """Helper functions for dealing with the arrays of species.""" 2 | 3 | from typing import Any, Union 4 | 5 | import xarray as xr 6 | 7 | from ...named_options import ( 8 | AtomicSpecies, 9 | ) 10 | 11 | 12 | def make_impurity_concentration_array( 13 | species_list: Union[list[Union[str, AtomicSpecies]], Union[str, AtomicSpecies]], 14 | concentrations_list: Union[list[Union[float, xr.DataArray]], Union[float, xr.DataArray]], 15 | ) -> xr.DataArray: 16 | """Make an xr.DataArray with impurity species and their corresponding concentrations. 17 | 18 | This array should be used as the `impurity_concentration` variable. 19 | """ 20 | # Convert DataArrays of species into plain lists. This is useful if you want to store AtomicSpecies objects in a dataset. 21 | if isinstance(species_list, (xr.DataArray)): 22 | species_list = species_list.values.tolist() 23 | # Deal with single-value input (not recommended, but avoids a confusing user error) 24 | if isinstance(species_list, (str, AtomicSpecies)): 25 | species_list = [ 26 | species_list, 27 | ] 28 | if isinstance(concentrations_list, (float, xr.DataArray)): 29 | concentrations_list = [ 30 | concentrations_list, 31 | ] 32 | 33 | if not len(species_list) == len(concentrations_list): 34 | raise ValueError(f"Dimension mismatch. Input was species list [{species_list}], concentrations list [{concentrations_list}]") 35 | 36 | array = xr.DataArray() 37 | for species, concentration in zip(species_list, concentrations_list): 38 | array = extend_impurity_concentration_array(array, species, concentration) 39 | 40 | return array 41 | 42 | 43 | def make_impurity_concentration_array_from_kwargs(**kwargs: Any) -> xr.DataArray: 44 | """Make an xr.DataArray with impurity species and their corresponding concentrations, using the format (species1=concentration1, ...).""" 45 | return make_impurity_concentration_array(list(kwargs.keys()), list(kwargs.values())) 46 | 47 | 48 | def extend_impurity_concentration_array( 49 | array: xr.DataArray, species: Union[str, AtomicSpecies], concentration: Union[float, xr.DataArray] 50 | ) -> xr.DataArray: 51 | """Append a new element to the impurity_concentration array. 52 | 53 | This method automatically handles broadcasting. 54 | 55 | N.b. You can also 'extend' an empty array, constructed via xr.DataArray() 56 | 57 | When writing this back into a xr.Dataset, make sure you use xr.merge instead of assignment! 58 | See: https://github.com/cfs-energy/cfspopcon/pull/66#issuecomment-2256667631 59 | """ 60 | if isinstance(species, xr.DataArray): 61 | species = species.item() 62 | 63 | if not isinstance(species, AtomicSpecies): 64 | species = AtomicSpecies[species.capitalize()] 65 | 66 | if not isinstance(concentration, xr.DataArray): 67 | concentration = xr.DataArray(concentration) 68 | 69 | if array.ndim == 0: 70 | # If the input array is empty, then just return the concentration instead of the input array 71 | return concentration.expand_dims("dim_species").assign_coords(dim_species=[species]) 72 | elif species in array.dim_species: 73 | # If the input array already has the species that we are writing, we need to carefully write these values 74 | # into the array 75 | # First, we make sure that the array is of the correct shape to write concentration in 76 | array = array.broadcast_like(concentration).copy() 77 | # Then, we overwrite the values for the species that we are writing, using .loc instead of .sel since 78 | # we can't assign values with .sel 79 | array.loc[dict(dim_species=species)] = concentration 80 | # Finally, we sort by the species atomic number, to ensure consistent ordering of the species. 81 | return array.sortby("dim_species") 82 | else: 83 | # If the input array doesn't have the species that we're writing, we can simply concatenate the 84 | # concentration array in, along the 'dim_species' dimension. 85 | array = xr.concat((array, concentration.expand_dims("dim_species").assign_coords(dim_species=[species])), dim="dim_species") 86 | # We again sort by the species atomic number, to ensure consistent ordering of the species. 87 | return array.sortby("dim_species") 88 | -------------------------------------------------------------------------------- /cfspopcon/formulas/separatrix_conditions/separatrix_operational_space/sustainment_power.py: -------------------------------------------------------------------------------- 1 | """Routines to calculate the separatrix power required to reach the LH transition.""" 2 | 3 | import numpy as np 4 | 5 | from ....algorithm_class import Algorithm 6 | from ....unit_handling import Quantity, Unitfull, ureg 7 | from .shared import calc_lambda_q_Eich2020H 8 | 9 | 10 | @Algorithm.register_algorithm(return_keys=["sustainment_power_in_ion_channel"]) 11 | def calc_power_crossing_separatrix_in_ion_channel( 12 | surface_area: Unitfull, 13 | separatrix_electron_density: Unitfull, 14 | separatrix_electron_temp: Unitfull, 15 | alpha_t: Unitfull, 16 | poloidal_sound_larmor_radius: Unitfull, 17 | ion_heat_diffusivity: Unitfull, 18 | temp_scale_length_ratio: float = 1.0, 19 | ) -> Unitfull: 20 | """Calculate the power crossing the separatrix in the ion channel. 21 | 22 | This algorithm computes the power required to sustain a particular 23 | ion temperature gradient, given a ion heat diffusivity, using 24 | the method from section 4.1 in :cite:`Eich_2021`. 25 | 26 | temp_scale_length_ratio = Ti / Te * lambda_Te / lambda_Ti = L_Te / L_Ti 27 | 28 | Args: 29 | surface_area: :term:`glossary link` 30 | separatrix_electron_density: :term:`glossary link` 31 | separatrix_electron_temp: :term:`glossary link` 32 | alpha_t: :term:`glossary link` 33 | poloidal_sound_larmor_radius: :term:`glossary link` 34 | ion_heat_diffusivity: :term:`glossary link` 35 | temp_scale_length_ratio: :term:`glossary link` 36 | 37 | Returns: 38 | :term:`sustainment_power_in_ion_channel` 39 | """ 40 | lambda_q = calc_lambda_q_Eich2020H(alpha_t, poloidal_sound_larmor_radius) 41 | lambda_Te = 3.5 * lambda_q 42 | 43 | L_Te = lambda_Te / separatrix_electron_temp 44 | L_Ti = L_Te / temp_scale_length_ratio 45 | 46 | P_SOL_i = surface_area * separatrix_electron_density * ion_heat_diffusivity / L_Ti 47 | 48 | return P_SOL_i 49 | 50 | 51 | @Algorithm.register_algorithm(return_keys=["sustainment_power_in_electron_channel"]) 52 | def calc_power_crossing_separatrix_in_electron_channel( 53 | separatrix_electron_temp: Unitfull, 54 | target_electron_temp: Unitfull, 55 | cylindrical_safety_factor: Unitfull, 56 | major_radius: Unitfull, 57 | minor_radius: Unitfull, 58 | B_pol_out_mid: Unitfull, 59 | B_t_out_mid: Unitfull, 60 | fraction_of_P_SOL_to_divertor: Unitfull, 61 | z_effective: Unitfull, 62 | alpha_t: Unitfull, 63 | poloidal_sound_larmor_radius: Unitfull, 64 | ) -> Unitfull: 65 | """Calculate the power crossing the separatrix for a given separatrix temperature. 66 | 67 | Equation 11 from :cite:`Eich_2021`, inverting the Spitzer-Harm power balance. 68 | 69 | Args: 70 | separatrix_electron_temp: :term:`glossary link` 71 | target_electron_temp: :term:`glossary link` 72 | cylindrical_safety_factor: :term:`glossary link` 73 | major_radius: :term:`glossary link` 74 | minor_radius: :term:`glossary link` 75 | B_pol_out_mid: :term:`glossary link` 76 | B_t_out_mid: :term:`glossary link` 77 | fraction_of_P_SOL_to_divertor: :term:`glossary link` 78 | z_effective: :term:`glossary link` 79 | alpha_t: :term:`glossary link` 80 | poloidal_sound_larmor_radius: :term:`glossary link` 81 | 82 | Returns: 83 | :term:`sustainment_power_in_electron_channel` 84 | """ 85 | lambda_q = calc_lambda_q_Eich2020H(alpha_t, poloidal_sound_larmor_radius) 86 | 87 | f_Zeff = 0.672 + 0.076 * np.sqrt(z_effective) + 0.252 * z_effective 88 | kappa_0e = Quantity(2600.0, ureg.W / (ureg.eV**3.5 * ureg.m)) / f_Zeff 89 | 90 | L_parallel = np.pi * cylindrical_safety_factor * major_radius 91 | 92 | A_SOL = 2.0 * np.pi * (major_radius + minor_radius) * lambda_q * B_pol_out_mid / B_t_out_mid 93 | 94 | P_SOL_e = ( 95 | 2.0 96 | / 7.0 97 | * kappa_0e 98 | * A_SOL 99 | / (L_parallel * fraction_of_P_SOL_to_divertor) 100 | * (separatrix_electron_temp**3.5 - target_electron_temp**3.5) 101 | ) 102 | 103 | return P_SOL_e 104 | -------------------------------------------------------------------------------- /cfspopcon/unit_handling/setup_unit_handling.py: -------------------------------------------------------------------------------- 1 | """Set up the pint library for unit handling.""" 2 | 3 | import warnings 4 | from collections.abc import Callable 5 | from functools import wraps 6 | from typing import Any, TypeVar, Union, overload 7 | 8 | import numpy as np 9 | import numpy.typing as npt 10 | import pint 11 | import pint_xarray # type:ignore[import-untyped] 12 | import xarray as xr 13 | from typing_extensions import ParamSpec 14 | 15 | ureg = pint_xarray.setup_registry( 16 | pint.UnitRegistry( 17 | force_ndarray_like=True, 18 | ) 19 | ) 20 | 21 | Quantity = ureg.Quantity 22 | Unit = ureg.Unit 23 | 24 | Params = ParamSpec("Params") 25 | Ret = TypeVar("Ret") 26 | 27 | # Define custom units for density as n_19 or n_20 (used in several formulas) 28 | # Format is "canonical_name = definition = unit_symbol = alias1 = alias2" where unit_symbol and aliases are optional 29 | ureg.define("_1e19_per_cubic_metre = 1e19 m^-3 = 1e19 m^-3 = n19") 30 | ureg.define("_1e20_per_cubic_metre = 1e20 m^-3 = 1e20 m^-3 = n20") 31 | 32 | # Needed for serialization/deserialization 33 | pint.set_application_registry(ureg) # type:ignore[no-untyped-call] 34 | 35 | 36 | def suppress_downcast_warning(func: Callable[Params, Ret]) -> Callable[Params, Ret]: 37 | """Suppresses a common warning about downcasting quantities to arrays.""" 38 | 39 | @wraps(func) 40 | def wrapper(*args: Params.args, **kwargs: Params.kwargs) -> Ret: 41 | with warnings.catch_warnings(): 42 | warnings.filterwarnings("ignore", message="The unit of the quantity is stripped when downcasting to ndarray.") 43 | return func(*args, **kwargs) 44 | 45 | return wrapper 46 | 47 | 48 | @overload 49 | def convert_units(array: xr.DataArray, units: Union[str, pint.Unit]) -> xr.DataArray: ... 50 | 51 | 52 | @overload 53 | def convert_units(array: pint.Quantity, units: Union[str, pint.Unit]) -> pint.Quantity: ... 54 | 55 | 56 | def convert_units(array: Union[xr.DataArray, pint.Quantity], units: Any) -> Union[xr.DataArray, pint.Quantity]: 57 | """Convert an array to specified units, handling both Quantities and xr.DataArrays.""" 58 | if units is None: 59 | # Replace None with ureg.dimensionless. 60 | # Otherwise, convert_units(Quantity([1.0], ""), None) will fail with an AttributeError 61 | units = ureg.dimensionless 62 | 63 | if isinstance(array, xr.DataArray): 64 | if not hasattr(array.pint, "units") or array.pint.units is None: 65 | array = array.pint.quantify(ureg.dimensionless) 66 | 67 | return array.pint.to(units) # type: ignore[no-any-return] 68 | elif isinstance(array, Quantity): 69 | return array.to(units) # type:ignore[no-any-return] 70 | elif isinstance(array, float) and Quantity(1.0, units).check("[]"): 71 | return (array * ureg.dimensionless).to(units) 72 | else: 73 | raise NotImplementedError(f"No implementation for 'convert_units' with an array of type {type(array)} ({array})") 74 | 75 | 76 | @suppress_downcast_warning 77 | def magnitude(array: Union[xr.DataArray, pint.Quantity]) -> Union[npt.NDArray[np.float32], float]: 78 | """Return the magnitude of an array, handling both Quantities and xr.DataArrays.""" 79 | if isinstance(array, xr.DataArray): 80 | return array.pint.dequantify() # type: ignore[no-any-return] 81 | elif isinstance(array, Quantity): 82 | return array.magnitude # type: ignore[no-any-return] 83 | else: 84 | raise NotImplementedError(f"No implementation for 'magnitude' with an array of type {type(array)} ({array})") 85 | 86 | 87 | def get_units(array: Union[xr.DataArray, pint.Quantity]) -> Any: 88 | """Returns the unit of an array, handling both Quantities and xr.DataArrays.""" 89 | if isinstance(array, xr.DataArray): 90 | return array.pint.units 91 | elif isinstance(array, Quantity): 92 | return array.units 93 | else: 94 | raise NotImplementedError(f"No implementation for 'get_units' with an array of type {type(array)} ({array})") 95 | 96 | 97 | def magnitude_in_units(array: Union[xr.DataArray, pint.Quantity], units: Any) -> Union[npt.NDArray[np.float32], float]: 98 | """Convert the array to the specified units and then return the magnitude.""" 99 | return magnitude(convert_units(array, units)) 100 | 101 | 102 | def dimensionless_magnitude(array: Union[xr.DataArray, pint.Quantity]) -> Union[npt.NDArray[np.float32], float]: 103 | """Converts the array to dimensionless and returns the magnitude.""" 104 | return magnitude_in_units(array, ureg.dimensionless) 105 | -------------------------------------------------------------------------------- /cfspopcon/formulas/scrape_off_layer/heat_flux_density.py: -------------------------------------------------------------------------------- 1 | """Calculate the parallel and perpendicular heat flux density entering the scrape-off-layer.""" 2 | 3 | import numpy as np 4 | from scipy import constants 5 | 6 | from ...algorithm_class import Algorithm 7 | from ...unit_handling import Unitfull, ureg, wraps_ufunc 8 | 9 | 10 | @Algorithm.register_algorithm(return_keys=["B_pol_out_mid"]) 11 | @wraps_ufunc( 12 | return_units=dict(B_pol_omp=ureg.T), 13 | input_units=dict(plasma_current=ureg.A, minor_radius=ureg.m), 14 | ) 15 | def calc_B_pol_omp(plasma_current: float, minor_radius: float) -> float: 16 | """Calculate the poloidal magnetic field at the outboard midplane. 17 | 18 | Args: 19 | plasma_current: [MA] :term:`glossary link` 20 | minor_radius: [m] :term:`glossary link` 21 | 22 | Returns: 23 | B_pol_out_mid [T] 24 | """ 25 | return float(constants.mu_0 * plasma_current / (2.0 * np.pi * minor_radius)) 26 | 27 | 28 | @Algorithm.register_algorithm(return_keys=["B_t_out_mid"]) 29 | def calc_B_tor_omp(magnetic_field_on_axis: Unitfull, major_radius: Unitfull, minor_radius: Unitfull) -> Unitfull: 30 | """Calculate the toroidal magnetic field at the outboard midplane. 31 | 32 | Args: 33 | magnetic_field_on_axis: [T] :term:`glossary link` 34 | major_radius: [m] :term:`glossary link` 35 | minor_radius: [m] :term:`glossary link` 36 | 37 | Returns: 38 | B_t_out_mid [T] 39 | """ 40 | return magnetic_field_on_axis * (major_radius / (major_radius + minor_radius)) 41 | 42 | 43 | @Algorithm.register_algorithm(return_keys=["fieldline_pitch_at_omp"]) 44 | def calc_fieldline_pitch_at_omp(B_t_out_mid: Unitfull, B_pol_out_mid: Unitfull) -> Unitfull: 45 | """Calculate the pitch of the magnetic field at the outboard midplane. 46 | 47 | Args: 48 | B_t_out_mid: [T] :term:`glossary link` 49 | B_pol_out_mid: [T] :term:`glossary link` 50 | 51 | Returns: 52 | fieldline_pitch_at_omp [~] 53 | """ 54 | return np.sqrt(B_t_out_mid**2 + B_pol_out_mid**2) / B_pol_out_mid 55 | 56 | 57 | @Algorithm.register_algorithm(return_keys=["q_parallel"]) 58 | def calc_parallel_heat_flux_density( 59 | power_crossing_separatrix: Unitfull, 60 | fraction_of_P_SOL_to_divertor: Unitfull, 61 | major_radius: Unitfull, 62 | minor_radius: Unitfull, 63 | lambda_q: Unitfull, 64 | fieldline_pitch_at_omp: Unitfull, 65 | ) -> Unitfull: 66 | """Calculate the parallel heat flux density entering a flux tube (q_par) at the outboard midplane. 67 | 68 | This expression is power to target divided by the area perpendicular to the flux tube. 69 | 1. Power to target = power crossing separatrix * fraction of that power going to the target considered 70 | 2. The poloidal area of a ring at the outboard midplane is 2 * pi * (R + minor_radius) * width 71 | 3. For the width, we take the heat flux decay length lambda_q 72 | 4. P_SOL * f_share / (2 * pi * (R + minor_radius) lambda_q) gives the heat flux per poloidal area 73 | 5. We project this poloidal heat flux density into a parallel heat flux density by dividing by the field-line pitch 74 | 75 | Args: 76 | power_crossing_separatrix: [MW] :term:`glossary link` 77 | fraction_of_P_SOL_to_divertor: :term:`glossary link ` 78 | major_radius: [m] :term:`glossary link ` 79 | minor_radius: [m] :term:`glossary link ` 80 | lambda_q: [mm] :term:`glossary link` 81 | fieldline_pitch_at_omp: B_total / B_poloidal at outboard midplane separatrix [~] 82 | 83 | Returns: 84 | q_parallel [GW/m^2] 85 | """ 86 | upstream_major_radius = major_radius + minor_radius 87 | return ( 88 | power_crossing_separatrix 89 | * fraction_of_P_SOL_to_divertor 90 | / (2.0 * np.pi * upstream_major_radius * lambda_q) 91 | * fieldline_pitch_at_omp 92 | ) 93 | 94 | 95 | @Algorithm.register_algorithm(return_keys=["q_perp"]) 96 | def calc_q_perp(power_crossing_separatrix: Unitfull, major_radius: Unitfull, minor_radius: Unitfull, lambda_q: Unitfull) -> Unitfull: 97 | """Calculate the perpendicular heat flux at the outboard midplane. 98 | 99 | Args: 100 | power_crossing_separatrix: [MW] :term:`glossary link` 101 | major_radius: [m] :term:`glossary link ` 102 | minor_radius: [m] :term:`glossary link ` 103 | lambda_q: [mm] :term:`glossary link` 104 | 105 | Returns: 106 | q_perp [MW/m^2] 107 | """ 108 | return power_crossing_separatrix / (2.0 * np.pi * (major_radius + minor_radius) * lambda_q) 109 | -------------------------------------------------------------------------------- /cfspopcon/formulas/plasma_profiles/density_peaking.py: -------------------------------------------------------------------------------- 1 | """Estimate the density peaking based on scaling from C. Angioni.""" 2 | 3 | import numpy as np 4 | import xarray as xr 5 | 6 | from ...algorithm_class import Algorithm 7 | from ...unit_handling import Unitfull, ureg, wraps_ufunc 8 | 9 | 10 | def calc_density_peaking(effective_collisionality: Unitfull, beta_toroidal: Unitfull, nu_noffset: Unitfull) -> Unitfull: 11 | """Calculate the density peaking (peak over volume average). 12 | 13 | Equation 3 from p1334 of Angioni et al, "Scaling of density peaking in H-mode plasmas based on a combined 14 | database of AUG and JET observations" :cite:`angioni_scaling_2007` 15 | 16 | Args: 17 | effective_collisionality: [~] :term:`glossary link ` 18 | beta_toroidal: :term:`glossary link ` [~] 19 | nu_noffset: scalar offset added to peaking factor [~] 20 | 21 | Returns: 22 | nu_n [~] 23 | """ 24 | nu_n = (1.347 - 0.117 * np.log(effective_collisionality) - 4.03 * beta_toroidal) + nu_noffset 25 | if isinstance(nu_n, xr.DataArray): 26 | return nu_n.clip(1.0, float("inf")) 27 | else: 28 | return max(nu_n, 1.0 * ureg.dimensionless) 29 | 30 | 31 | @Algorithm.register_algorithm(return_keys=["ion_density_peaking", "peak_fuel_ion_density"]) 32 | def calc_ion_density_peaking( 33 | effective_collisionality: Unitfull, 34 | beta_toroidal: Unitfull, 35 | ion_density_peaking_offset: Unitfull, 36 | average_electron_density: Unitfull, 37 | dilution: Unitfull, 38 | ) -> tuple[Unitfull, ...]: 39 | """Calculate the ion density peaking. 40 | 41 | Args: 42 | effective_collisionality: [~] :term:`glossary link ` 43 | beta_toroidal: :term:`glossary link ` [~] 44 | ion_density_peaking_offset: :term:`glossary link` [~] 45 | average_electron_density: :term:`glossary link` 46 | dilution: :term:`glossary link` 47 | 48 | Returns: 49 | :term:`ion_density_peaking`, :term:`peak_fuel_ion_density` 50 | """ 51 | ion_density_peaking = calc_density_peaking(effective_collisionality, beta_toroidal, nu_noffset=ion_density_peaking_offset) 52 | peak_fuel_ion_density = average_electron_density * dilution * ion_density_peaking 53 | 54 | return ion_density_peaking, peak_fuel_ion_density 55 | 56 | 57 | @Algorithm.register_algorithm(return_keys=["electron_density_peaking", "peak_electron_density"]) 58 | def calc_electron_density_peaking( 59 | effective_collisionality: Unitfull, 60 | beta_toroidal: Unitfull, 61 | electron_density_peaking_offset: Unitfull, 62 | average_electron_density: Unitfull, 63 | ) -> tuple[Unitfull, ...]: 64 | """Calculate the electron density peaking. 65 | 66 | Args: 67 | effective_collisionality: [~] :term:`glossary link ` 68 | beta_toroidal: :term:`glossary link ` [~] 69 | electron_density_peaking_offset: :term:`glossary link` [~] 70 | average_electron_density: :term:`glossary link` 71 | 72 | Returns: 73 | :term:`electron_density_peaking`, :term:`peak_electron_density` 74 | """ 75 | electron_density_peaking = calc_density_peaking(effective_collisionality, beta_toroidal, nu_noffset=electron_density_peaking_offset) 76 | peak_electron_density = average_electron_density * electron_density_peaking 77 | 78 | return electron_density_peaking, peak_electron_density 79 | 80 | 81 | @Algorithm.register_algorithm(return_keys=["effective_collisionality"]) 82 | @wraps_ufunc( 83 | return_units=dict(effective_collisionality=ureg.dimensionless), 84 | input_units=dict( 85 | average_electron_density=ureg.n19, average_electron_temp=ureg.keV, major_radius=ureg.m, z_effective=ureg.dimensionless 86 | ), 87 | ) 88 | def calc_effective_collisionality( 89 | average_electron_density: float, average_electron_temp: float, major_radius: float, z_effective: float 90 | ) -> float: 91 | """Calculate the effective collisionality. 92 | 93 | From p1327 of Angioni et al, "Scaling of density peaking in H-mode plasmas based on a combined 94 | database of AUG and JET observations" :cite:`angioni_scaling_2007` 95 | 96 | Args: 97 | average_electron_density: [1e19 m^-3] :term:`glossary link` 98 | average_electron_temp: [keV] :term:`glossary link` 99 | major_radius: [m] :term:`glossary link` 100 | z_effective: [~] :term:`glossary link` 101 | 102 | Returns: 103 | :term:`effective_collisionality` [~] 104 | """ 105 | return float((0.1 * z_effective * average_electron_density * major_radius) / (average_electron_temp**2.0)) 106 | -------------------------------------------------------------------------------- /cfspopcon/formulas/geometry/analytical.py: -------------------------------------------------------------------------------- 1 | """Plasma geometry (inside the last-closed-flux-surface).""" 2 | 3 | import numpy as np 4 | 5 | from ...algorithm_class import Algorithm 6 | from ...unit_handling import Unitfull 7 | 8 | 9 | @Algorithm.register_algorithm(return_keys=["plasma_volume"]) 10 | def calc_plasma_volume(major_radius: Unitfull, inverse_aspect_ratio: Unitfull, areal_elongation: Unitfull) -> Unitfull: 11 | """Calculate the plasma volume inside an up-down symmetrical last-closed-flux-surface. 12 | 13 | Geometric formulas for system codes including the effect of negative triangularity :cite: `sauter` 14 | NOTE: delta=1.0 is assumed since this was found to give a closer match to 2D equilibria from FreeGS. 15 | 16 | Args: 17 | major_radius: [m] :term:`glossary link` 18 | inverse_aspect_ratio: [~] :term:`glossary link` 19 | areal_elongation: [~] :term:`glossary link` 20 | 21 | Returns: 22 | :term:`plasma_volume` [m^3] 23 | """ 24 | return ( 25 | 2.0 26 | * np.pi 27 | * major_radius**3.0 28 | * inverse_aspect_ratio**2.0 29 | * areal_elongation 30 | * (np.pi - (np.pi - 8.0 / 3.0) * inverse_aspect_ratio) 31 | ) 32 | 33 | 34 | @Algorithm.register_algorithm(return_keys=["surface_area"]) 35 | def calc_plasma_surface_area(major_radius: Unitfull, inverse_aspect_ratio: Unitfull, areal_elongation: Unitfull) -> Unitfull: 36 | """Calculate the plasma surface area inside the last-closed-flux-surface. 37 | 38 | Args: 39 | major_radius: [m] :term:`glossary link` 40 | inverse_aspect_ratio: [~] :term:`glossary link` 41 | areal_elongation: [~] :term:`glossary link` 42 | 43 | Returns: 44 | :term:`surface_area` [m^2] 45 | """ 46 | return ( 47 | 2.0 * np.pi * (major_radius**2.0) * inverse_aspect_ratio * areal_elongation * (np.pi + 2.0 - (np.pi - 2.0) * inverse_aspect_ratio) 48 | ) 49 | 50 | 51 | @Algorithm.register_algorithm(return_keys=["poloidal_circumference"]) 52 | def calc_plasma_poloidal_circumference(minor_radius: Unitfull, areal_elongation: Unitfull) -> Unitfull: 53 | """Calculate the plasma poloidal circumference at the last-closed-flux-surface. 54 | 55 | Geometric formulas for system codes including the effect of negative triangularity :cite: `sauter` 56 | 57 | Args: 58 | minor_radius: [m] :term:`glossary link` 59 | areal_elongation: [~] :term:`glossary link` 60 | 61 | Returns: 62 | :term:`poloidal_circumference` [m] 63 | """ 64 | return 2 * np.pi * minor_radius * (1 + 0.55 * (areal_elongation - 1)) 65 | 66 | 67 | calc_areal_elongation_from_elongation_at_psi95 = Algorithm.from_single_function( 68 | func=lambda elongation_psi95, elongation_ratio_areal_to_psi95: elongation_psi95 * elongation_ratio_areal_to_psi95, 69 | return_keys=["areal_elongation"], 70 | name="calc_areal_elongation_from_elongation_at_psi95", 71 | ) 72 | 73 | calc_elongation_at_psi95_from_areal_elongation = Algorithm.from_single_function( 74 | func=lambda areal_elongation, elongation_ratio_areal_to_psi95: areal_elongation / elongation_ratio_areal_to_psi95, 75 | return_keys=["elongation_psi95"], 76 | name="calc_elongation_at_psi95_from_areal_elongation", 77 | ) 78 | 79 | calc_separatrix_elongation_from_areal_elongation = Algorithm.from_single_function( 80 | func=lambda areal_elongation, elongation_ratio_sep_to_areal: areal_elongation * elongation_ratio_sep_to_areal, 81 | return_keys=["separatrix_elongation"], 82 | name="calc_separatrix_elongation_from_areal_elongation", 83 | ) 84 | 85 | calc_separatrix_triangularity_from_triangularity95 = Algorithm.from_single_function( 86 | func=lambda triangularity_psi95, triangularity_ratio_sep_to_psi95: triangularity_psi95 * triangularity_ratio_sep_to_psi95, 87 | return_keys=["separatrix_triangularity"], 88 | name="calc_separatrix_triangularity_from_triangularity95", 89 | ) 90 | 91 | calc_minor_radius_from_inverse_aspect_ratio = Algorithm.from_single_function( 92 | func=lambda major_radius, inverse_aspect_ratio: major_radius * inverse_aspect_ratio, 93 | return_keys=["minor_radius"], 94 | name="calc_minor_radius_from_inverse_aspect_ratio", 95 | ) 96 | 97 | calc_inverse_aspect_ratio = Algorithm.from_single_function( 98 | func=lambda major_radius, minor_radius: minor_radius / major_radius, 99 | return_keys=["inverse_aspect_ratio"], 100 | name="calc_inverse_aspect_ratio", 101 | ) 102 | 103 | calc_vertical_minor_radius_from_elongation_and_minor_radius = Algorithm.from_single_function( 104 | func=lambda minor_radius, separatrix_elongation: minor_radius * separatrix_elongation, 105 | return_keys=["vertical_minor_radius"], 106 | name="calc_vertical_minor_radius_from_elongation_and_minor_radius", 107 | ) 108 | -------------------------------------------------------------------------------- /cfspopcon/formulas/plasma_current/safety_factor.py: -------------------------------------------------------------------------------- 1 | """Routines relating the plasma current to an analytical estimate of the 95% safety factor q_star.""" 2 | 3 | import numpy as np 4 | 5 | from ...algorithm_class import Algorithm 6 | from ...unit_handling import Unitfull, ureg, wraps_ufunc 7 | 8 | 9 | @Algorithm.register_algorithm(return_keys=["f_shaping"]) 10 | def calc_f_shaping_for_qstar(inverse_aspect_ratio: Unitfull, areal_elongation: Unitfull, triangularity_psi95: Unitfull) -> Unitfull: 11 | """Calculate the shaping function. 12 | 13 | Equation A11 from ITER Physics Basis Ch. 1. Eqn. A-11 :cite:`editors_iter_1999` 14 | See following discussion for how this function is used. 15 | q_95 = 5 * minor_radius^2 * magnetic_field_on_axis / (R * plasma_current) f_shaping 16 | 17 | Args: 18 | inverse_aspect_ratio: [~] :term:`glossary link` 19 | areal_elongation: [~] :term:`glossary link` 20 | triangularity_psi95: [~] :term:`glossary link` 21 | 22 | Returns: 23 | :term:`f_shaping` [~] 24 | """ 25 | return ((1.0 + areal_elongation**2.0 * (1.0 + 2.0 * triangularity_psi95**2.0 - 1.2 * triangularity_psi95**3.0)) / 2.0) * ( 26 | (1.17 - 0.65 * inverse_aspect_ratio) / (1.0 - inverse_aspect_ratio**2.0) ** 2.0 27 | ) 28 | 29 | 30 | @Algorithm.register_algorithm(return_keys=["plasma_current"]) 31 | @wraps_ufunc( 32 | input_units=dict( 33 | magnetic_field_on_axis=ureg.T, 34 | major_radius=ureg.m, 35 | inverse_aspect_ratio=ureg.dimensionless, 36 | q_star=ureg.dimensionless, 37 | f_shaping=ureg.dimensionless, 38 | ), 39 | return_units=dict(plasma_current=ureg.MA), 40 | ) 41 | def calc_plasma_current_from_qstar( 42 | magnetic_field_on_axis: float, major_radius: float, inverse_aspect_ratio: float, q_star: float, f_shaping: float 43 | ) -> float: 44 | """Calculate the plasma current in mega-amperes. 45 | 46 | Updated formula from ITER Physics Basis Ch. 1. :cite:`editors_iter_1999` 47 | 48 | Args: 49 | magnetic_field_on_axis: [T] :term:`glossary link` 50 | major_radius: [m] :term:`glossary link` 51 | inverse_aspect_ratio: [~] :term:`glossary link` 52 | q_star: [~] :term:`glossary link` 53 | f_shaping: [~] :term:`glossary link` 54 | 55 | Returns: 56 | :term:`plasma_current` [MA] 57 | """ 58 | plasma_current: float = ( 59 | 5.0 * ((inverse_aspect_ratio * major_radius) ** 2.0) * (magnetic_field_on_axis / (q_star * major_radius)) * f_shaping 60 | ) 61 | return plasma_current 62 | 63 | 64 | @Algorithm.register_algorithm(return_keys=["q_star"]) 65 | @wraps_ufunc( 66 | input_units=dict( 67 | magnetic_field_on_axis=ureg.T, 68 | major_radius=ureg.m, 69 | inverse_aspect_ratio=ureg.dimensionless, 70 | plasma_current=ureg.MA, 71 | f_shaping=ureg.dimensionless, 72 | ), 73 | return_units=dict(q_star=ureg.dimensionless), 74 | ) 75 | def calc_q_star_from_plasma_current( 76 | magnetic_field_on_axis: float, major_radius: float, inverse_aspect_ratio: float, plasma_current: float, f_shaping: float 77 | ) -> float: 78 | """Calculate an analytical estimate for the edge safety factor q_star. 79 | 80 | Updated formula from ITER Physics Basis Ch. 1. :cite:`editors_iter_1999` 81 | 82 | Args: 83 | magnetic_field_on_axis: [T] :term:`glossary link` 84 | major_radius: [m] :term:`glossary link` 85 | inverse_aspect_ratio: [~] :term:`glossary link` 86 | plasma_current: [MA] :term:`glossary link` 87 | f_shaping: [~] :term:`glossary link` 88 | 89 | Returns: 90 | :term:`q_star` [~] 91 | """ 92 | q_star: float = ( 93 | 5.0 * (inverse_aspect_ratio * major_radius) ** 2.0 * magnetic_field_on_axis / (plasma_current * major_radius) * f_shaping 94 | ) 95 | return q_star 96 | 97 | 98 | @Algorithm.register_algorithm(return_keys=["cylindrical_safety_factor"]) 99 | def calc_cylindrical_edge_safety_factor( 100 | major_radius: Unitfull, 101 | minor_radius: Unitfull, 102 | elongation_psi95: Unitfull, 103 | triangularity_psi95: Unitfull, 104 | magnetic_field_on_axis: Unitfull, 105 | plasma_current: Unitfull, 106 | ) -> Unitfull: 107 | """Calculate the edge safety factor, following the formula used in the SepOS paper. 108 | 109 | Equation K.6 from :cite:`Eich_2021` 110 | 111 | Should use kappa_95 and delta_95 values. 112 | 113 | Gives a slightly different result to our standard q_star calculation. 114 | """ 115 | shaping_correction = np.sqrt((1.0 + elongation_psi95**2 * (1.0 + 2.0 * triangularity_psi95**2 - 1.2 * triangularity_psi95**3)) / 2.0) 116 | 117 | poloidal_circumference = 2.0 * np.pi * minor_radius * shaping_correction 118 | 119 | average_B_pol = ureg.mu_0 * plasma_current / poloidal_circumference 120 | 121 | return magnetic_field_on_axis / average_B_pol * minor_radius / major_radius * shaping_correction 122 | -------------------------------------------------------------------------------- /cfspopcon/formulas/fusion_power/fusion_rates.py: -------------------------------------------------------------------------------- 1 | """Calculate fusion power and corresponding neutron wall loading.""" 2 | 3 | import xarray as xr 4 | from numpy import float64 5 | from numpy.typing import NDArray 6 | 7 | from ...algorithm_class import Algorithm 8 | from ...unit_handling import Unitfull, ureg 9 | from ..geometry.volume_integral import integrate_profile_over_volume 10 | from .fusion_data import ( 11 | REACTIONS, 12 | DTFusionBoschHale, 13 | DTFusionHively, 14 | ) 15 | 16 | 17 | @Algorithm.register_algorithm(return_keys=["P_fusion", "P_neutron", "P_alpha"]) 18 | def calc_fusion_power( 19 | fusion_reaction: str, 20 | ion_temp_profile: NDArray[float64], 21 | heavier_fuel_species_fraction: float, 22 | fuel_ion_density_profile: NDArray[float64], 23 | rho: NDArray[float64], 24 | plasma_volume: float, 25 | ) -> tuple[Unitfull, Unitfull, Unitfull]: 26 | """Calculate the fusion power. 27 | 28 | Args: 29 | fusion_reaction: which nuclear reaction is being considered 30 | ion_temp_profile: [keV] :term:`glossary link` 31 | heavier_fuel_species_fraction: :term:`glossary link` 32 | fuel_ion_density_profile: [1e19 m^-3] :term:`glossary link` 33 | rho: [~] :term:`glossary link` 34 | plasma_volume: [m^3] :term:`glossary link` 35 | 36 | Returns: 37 | :term:`P_fusion` [MW], :term:`P_neutron` [MW], :term:`P_alpha` [MW] 38 | """ 39 | if isinstance(fusion_reaction, xr.DataArray): 40 | fusion_reaction = fusion_reaction.item() 41 | reaction = REACTIONS[fusion_reaction] 42 | if not isinstance(reaction, (DTFusionBoschHale, DTFusionHively)): 43 | raise NotImplementedError( 44 | f"Reaction {fusion_reaction} is currently disabled. See https://github.com/cfs-energy/cfspopcon/issues/43" 45 | ) 46 | 47 | power_density_factor = reaction.calc_power_density( 48 | ion_temp=ion_temp_profile, heavier_fuel_species_fraction=heavier_fuel_species_fraction 49 | ) 50 | neutral_power_density_factor = reaction.calc_power_density_to_neutrals( 51 | ion_temp=ion_temp_profile, heavier_fuel_species_fraction=heavier_fuel_species_fraction 52 | ) 53 | charged_power_density_factor = reaction.calc_power_density_to_charged( 54 | ion_temp=ion_temp_profile, heavier_fuel_species_fraction=heavier_fuel_species_fraction 55 | ) 56 | 57 | total_fusion_power = _integrate_power( 58 | power_density_factor=power_density_factor, 59 | fuel_density=fuel_ion_density_profile, 60 | rho=rho, 61 | plasma_volume=plasma_volume, 62 | ) 63 | 64 | fusion_power_to_neutral = _integrate_power( 65 | power_density_factor=neutral_power_density_factor, 66 | fuel_density=fuel_ion_density_profile, 67 | rho=rho, 68 | plasma_volume=plasma_volume, 69 | ) 70 | 71 | fusion_power_to_charged = _integrate_power( 72 | power_density_factor=charged_power_density_factor, 73 | fuel_density=fuel_ion_density_profile, 74 | rho=rho, 75 | plasma_volume=plasma_volume, 76 | ) 77 | 78 | return total_fusion_power, fusion_power_to_neutral, fusion_power_to_charged 79 | 80 | 81 | @Algorithm.register_algorithm(return_keys=["neutron_power_flux_to_walls", "neutron_rate"]) 82 | def calc_neutron_flux_to_walls( 83 | P_neutron: float, 84 | surface_area: float, 85 | fusion_reaction: str, 86 | ion_temp_profile: NDArray[float64], 87 | heavier_fuel_species_fraction: float, 88 | ) -> tuple[float, float]: 89 | """Calculate the neutron loading on the wall. 90 | 91 | Args: 92 | P_neutron: [MW] :term:`glossary link` 93 | surface_area: [m^2] :term:`glossary link` 94 | fusion_reaction: which nuclear reaction is being considered 95 | ion_temp_profile: [keV] :term:`glossary link` 96 | heavier_fuel_species_fraction: fraction of fuel mixture which is the heavier nuclide 97 | 98 | Returns: 99 | neutron_power_flux_to_walls [MW / m^2], neutron_rate [s^-1] 100 | """ 101 | if isinstance(fusion_reaction, xr.DataArray): 102 | fusion_reaction = fusion_reaction.item() 103 | reaction = REACTIONS[fusion_reaction] 104 | if not isinstance(reaction, (DTFusionBoschHale, DTFusionHively)): 105 | raise NotImplementedError( 106 | f"Reaction {fusion_reaction} is currently disabled. See https://github.com/cfs-energy/cfspopcon/issues/43" 107 | ) 108 | 109 | neutron_power_flux_to_walls = P_neutron / surface_area 110 | energy_to_neutrals_per_reaction = reaction.calc_energy_to_neutrals_per_reaction() 111 | 112 | # Prevent division by zero. 113 | neutron_rate = xr.where(energy_to_neutrals_per_reaction > 0, P_neutron / energy_to_neutrals_per_reaction, 0.0) # type:ignore[no-untyped-call] 114 | 115 | return neutron_power_flux_to_walls, neutron_rate 116 | 117 | 118 | def _integrate_power( 119 | power_density_factor: Unitfull, 120 | fuel_density: Unitfull, 121 | rho: NDArray[float64], 122 | plasma_volume: float, 123 | ) -> Unitfull: 124 | """Calculate the total power due to a nuclear reaction. 125 | 126 | Args: 127 | power_density_factor: energy per unit volume divided by fuel species densities [MW*m^3] 128 | fuel_density: density of fuel species [m^-3] 129 | rho: [~] :term:`glossary link` 130 | plasma_volume: [m^3] :term:`glossary link` 131 | 132 | Returns: 133 | power [MW] 134 | """ 135 | power_density = power_density_factor * fuel_density * fuel_density 136 | 137 | power = integrate_profile_over_volume(power_density / ureg.MW, rho, plasma_volume) * ureg.MW 138 | 139 | return power 140 | -------------------------------------------------------------------------------- /.github/workflows/workflow_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: workflow_actions 5 | 6 | # Controls when the workflow will run 7 | on: 8 | # Triggers the workflow on push or pull request events but only for the "main" branch 9 | pull_request: [] 10 | push: 11 | tags: 12 | - '*' 13 | branches: 14 | - 'main' 15 | # Allows you to run this workflow manually from the Actions tab 16 | workflow_dispatch: 17 | tag: "Manual run" 18 | 19 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 20 | jobs: 21 | radas: 22 | runs-on: ubuntu-24.04 23 | steps: 24 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 25 | - uses: actions/checkout@v4 26 | 27 | - name: Install Poetry 28 | run: curl -sSL https://install.python-poetry.org | python - --version 2.1.3 29 | 30 | - name: Set up Python 3.11 31 | uses: actions/setup-python@v5 32 | with: 33 | python-version: '3.11' 34 | cache: 'poetry' 35 | 36 | - name: Setup 37 | run: poetry install 38 | 39 | - name: Cache radas results 40 | id: radas 41 | uses: actions/cache@v4 42 | with: 43 | path: ./radas_dir 44 | key: radas-${{ hashFiles('poetry.lock')}} 45 | 46 | - name: Make radas data 47 | if: steps.radas.outputs.cache-hit != 'true' 48 | run: poetry run radas -c radas_config.yaml 49 | 50 | - name: Upload radas artifacts 51 | uses: actions/upload-artifact@v4 52 | with: 53 | name: radas_dir 54 | path: ./radas_dir 55 | 56 | build: 57 | needs: radas 58 | # The type of runner that the job will run on 59 | runs-on: ubuntu-24.04 60 | strategy: 61 | fail-fast: false 62 | matrix: 63 | python-version: ['3.10', '3.11', '3.12'] # should test the versions we allow for in pyproject.toml 64 | 65 | # Steps represent a sequence of tasks that will be executed as part of the job 66 | steps: 67 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 68 | - uses: actions/checkout@v4 69 | 70 | - name: Install pandoc 71 | run: sudo apt-get update && sudo apt-get install pandoc 72 | 73 | - name: Install Poetry 74 | run: curl -sSL https://install.python-poetry.org | python - --version 2.1.3 75 | 76 | - name: Set up Python ${{ matrix.python-version }} 77 | uses: actions/setup-python@v5 78 | with: 79 | python-version: ${{ matrix.python-version }} 80 | cache: 'poetry' 81 | 82 | - name: Setup 83 | run: poetry install 84 | 85 | - uses: actions/cache/restore@v4 86 | id: radas 87 | with: 88 | path: ./radas_dir 89 | key: radas-${{ hashFiles('poetry.lock')}} 90 | 91 | - name: Check cache hit 92 | if: steps.radas.outputs.cache-hit != 'true' 93 | run: exit 1 94 | 95 | - name: Tests 96 | run: MPLBACKEND=Agg poetry run pytest tests --nbmake example_cases -m "not docs" 97 | 98 | - name: Tests with new regression results 99 | run: | 100 | poetry run python tests/utils/regression_results.py 101 | MPLBACKEND=Agg poetry run pytest --no-cov tests/test_regression_against_cases.py 102 | 103 | - name: Upload regression results 104 | uses: actions/upload-artifact@v4 105 | if: failure() 106 | with: 107 | name: regression_results 108 | path: tests/regression_results 109 | 110 | - name: Test package 111 | run: | 112 | poetry build -f wheel 113 | python -m venv test_env 114 | source ./test_env/bin/activate 115 | pip install $(find ./dist -name "*.whl") 116 | # enter tempdir so import cfspopcon doesn't find the cfspopcon directory 117 | mkdir tmp_dir && cd tmp_dir 118 | MPLBACKEND=Agg popcon ../example_cases/SPARC_PRD -d radas_dir WORKING_DIR/../radas_dir 119 | 120 | - name: Run pre-commit checks 121 | run: poetry run pre-commit run --show-diff-on-failure --color=always --all-files 122 | 123 | - name: Test docs 124 | # instead of make html we use sphinx-build directly to add more options 125 | run: | 126 | cd docs 127 | poetry run sphinx-build --keep-going -Wnb html . _build/ 128 | poetry run make doctest 129 | poetry run make linkcheck 130 | 131 | 132 | build_release: 133 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 134 | needs: build 135 | runs-on: ubuntu-24.04 136 | 137 | steps: 138 | - uses: actions/checkout@v4 139 | 140 | - name: Install Poetry 141 | run: curl -sSL https://install.python-poetry.org | python - --version 2.1.3 142 | 143 | - name: Poetry build 144 | run: poetry build 145 | 146 | - uses: actions/upload-artifact@v4 147 | with: 148 | name: pypi-build 149 | path: ./dist 150 | 151 | publish: 152 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 153 | needs: build_release 154 | runs-on: ubuntu-24.04 155 | environment: 156 | name: pypi-publish 157 | url: https://pypi.org/project/cfspopcon/ 158 | permissions: 159 | id-token: write # IMPORTANT: this permission is mandatory for trusted publishing 160 | steps: 161 | - uses: actions/download-artifact@v4 162 | 163 | - name: Publish package distributions to PyPI 164 | uses: pypa/gh-action-pypi-publish@release/v1 165 | with: 166 | packages-dir: pypi-build/ 167 | --------------------------------------------------------------------------------