Skip to content

Commit

Permalink
Implement average charge state equilibria.
Browse files Browse the repository at this point in the history
Electron temperature dependent. Uses a polynomial fit for a selection of common impurities based on Mavrin 2018, including heavy impurities.

* Adds a new routine to calculate average impurity charge state for an IonMixture.

* Modifies the DynamicIonMixture dataclass to hold a separate Z_override attribute instead of calculating the average itself. This is used in the charge state calculation function.

* Updates core_profile_setters to calculate and return the Zimp/Zi profiles according to the new routines

* Better separation of ne and ion density + charge state setting

* Updates Qualikiz to use the new Zimp/Zi arrays

* Adds average Zimp to default output plots

* Updates boundary condition calculations to calculate boundary Zimp according to the boundary temperature.

* New sim integration test with tungsten added.

* Some sim tests which did not have a Z_override now have spatially varying Zimp, which leads to small O(1e-4) changes. The small impact is because the default impurity is Neon.

Small <5% runtime increase. This was ascertained to not be due to the (jitted) charge state calculations themselves, but due to surrounding code in non-jitted functions in the sim loop which call the charge state calculations. This will be rectified in an upcoming PR.

~10% compilation time increase due to added dependency of nimp and zimp on electron temperature.

PiperOrigin-RevId: 717475094
  • Loading branch information
jcitrin authored and Torax team committed Jan 20, 2025
1 parent ce6da2d commit 3b1857e
Show file tree
Hide file tree
Showing 44 changed files with 859 additions and 204 deletions.
5 changes: 5 additions & 0 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,11 @@ Defines the distribution of ion species. The keys and their meanings are as fol
As ``Ai_override``, but for the impurity ion. If provided, this value will be used instead of the A calculated
from the ``impurity`` specification.

The average charge state of each ion in each mixture is determined by `Mavrin polynomials <https://doi.org/10.1080/10420150.2018.1462361>`_,
which are fitted to atomic data, and in the temperature ranges of interest in the tokamak core,
are well approximated as 1D functions of electron temperature. All ions with atomic numbers below
Carbon are assumed to be fully ionized.

Examples
--------

Expand Down
27 changes: 20 additions & 7 deletions docs/physics_models.rst
Original file line number Diff line number Diff line change
Expand Up @@ -45,16 +45,29 @@ Generalization to geometry data beyond CHEASE is also planned.
Plasma composition, initial and prescribed conditions
=====================================================

Presently, TORAX only accommodates a single main ion species,configured with its
atomic mass number (:math:`A_i`) and charge state (:math:`Z_i`). The plasma effective
charge, :math:`Z_\textit{eff}`, is assumed to be radially flat and is also
user-configurable. A single impurity with charge state :math:`Z_\textit{imp}` is
specified to accommodate :math:`Z_\textit{eff} > 1`. The main ion density dilution
is then calculated as follows:
Presently, TORAX accommodates a single main ion species and single impurity species,
which can be comprised of time-dependent mixtures of ions with fractional abundances
summing to 1. This is useful for example for simulating isotope mixes. Based on the
ion symbols and fractional abundances, the average mass of each species is determined.
The average charge state of each ion in each mixture is determined by `Mavrin polynomials <https://doi.org/10.1080/10420150.2018.1462361>`_,
which are fitted to atomic data, and in the temperature ranges of interest in the tokamak core,
are well approximated as 1D functions of electron temperature. All ions with atomic numbers below
Carbon are assumed to be fully ionized.

The impurity and main ion densities are constrained by the plasma effective
charge, :math:`Z_\mathrm{eff}`, which is a user-provided 2D array in both time and space,
as well as quasineutrality.

:math:`n_i`, and :math:`n_{imp}`, are solved from the
following system of equations, where :math:`Z_\mathrm{eff}` and the electron density are
known, and :math:`Z_\mathrm{imp}` is the average impurity charge of the impurity mixture,
with the average charge state for each ion determined from the Mavrin polynomials.

.. math::
n_i=(Z_\textit{imp}-Z_\textit{eff})/(Z_\textit{imp}-1)n_e
n_\mathrm{i}Z_\mathrm{i}^2 + n_\mathrm{imp}Z_\mathrm{imp}^2 = n_\mathrm{e}Z_\mathrm{eff}
n_\mathrm{i}Z_\mathrm{i} + n_\mathrm{imp}Z_\mathrm{imp} = n_\mathrm{e}
Initial conditions for the evolving profiles :math:`T_i`, :math:`T_e`, :math:`n_e`,
and :math:`\psi` are user-configurable. For :math:`T_{i,e}`, both the initial core
Expand Down
166 changes: 166 additions & 0 deletions torax/charge_states.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
# Copyright 2024 DeepMind Technologies Limited
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Routines for calculating impurity charge states."""

from typing import Final, Mapping, Sequence
import immutabledict
from jax import numpy as jnp
import numpy as np
from torax import array_typing
from torax import constants
from torax.config import plasma_composition

# Polynomial fit coefficients from A. A. Mavrin (2018):
# Improved fits of coronal radiative cooling rates for high-temperature plasmas,
# Radiation Effects and Defects in Solids, 173:5-6, 388-398,
# DOI: 10.1080/10420150.2018.1462361
_MAVRIN_Z_COEFFS: Final[Mapping[str, array_typing.ArrayFloat]] = (
immutabledict.immutabledict({
'C': np.array([ # Carbon
[5.8588e00, -1.7632e00, -7.3521e00, -1.2217e01, -7.2007e00],
[6.0000e00, 0.0000e00, 0.0000e00, 0.0000e00, 0.0000e00],
]),
'N': np.array([ # Nitrogen
[6.9728e00, 1.5668e-01, 1.8861e00, 3.3818e00, 0.0000e00],
[7.0000e00, 0.0000e00, 0.0000e00, 0.0000e00, 0.0000e00],
]),
'O': np.array([ # Oxygen
[4.0451e00, -2.2093e01, -3.8664e01, -1.8560e01, 0.0000e00],
[7.9878e00, 8.0180e-02, -3.7050e-02, -4.6261e-01, -4.3092e00],
[8.0000e00, 0.0000e00, 0.0000e00, 0.0000e00, 0.0000e00],
]),
'Ne': np.array([ # Neon
[8.9737e00, -1.3242e01, -5.3631e01, -6.4696e01, -2.5303e01],
[9.9532e00, 2.1413e-01, -8.0723e-01, 3.6868e00, -7.0678e00],
[1.0000e01, 0.0000e00, 0.0000e00, 0.0000e00, 0.0000e00],
]),
'Ar': np.array([ # Argon
[1.3171e01, -2.0781e01, -4.3776e01, -1.1595e01, 6.8717e00],
[1.5986e01, 1.1413e00, 2.5023e00, 1.8455e00, -4.8830e-02],
[1.4948e01, 7.9986e00, -8.0048e00, 3.5667e00, -5.9213e-01],
]),
'Kr': np.array([ # Krypton
[7.7040e01, 3.0638e02, 5.6890e02, 4.6320e02, 1.3630e02],
[2.4728e01, 1.5186e00, 1.5744e01, 6.8446e01, -1.0279e02],
[2.5368e01, 2.3443e01, -2.5703e01, 1.3215e01, -2.4682e00],
]),
'Xe': np.array([ # Xenon
[3.0532e02, 1.3973e03, 2.5189e03, 1.9967e03, 5.8178e02],
[3.2616e01, 1.6271e01, -4.8384e01, -2.9061e01, 8.6824e01],
[4.8066e01, -1.7259e02, 6.6739e02, -9.0008e02, 4.0756e02],
[-5.7527e01, 2.4056e02, -1.9931e02, 7.3261e01, -1.0019e01],
]),
'W': np.array([ # Tungsten
[2.6703e01, 1.6518e01, 2.1027e01, 3.4582e01, 1.6823e01],
[3.6902e01, -7.9611e01, 2.5532e02, -1.0577e01, -2.5887e02],
[6.3795e01, -1.0011e02, 1.5985e02, -8.4207e01, 1.5119e01],
]),
})
)

# Temperature boundaries in keV, separating the rows for the fit coefficients.
_TEMPERATURE_INTERVALS: Final[Mapping[str, array_typing.ArrayFloat]] = (
immutabledict.immutabledict({
'C': np.array([0.7]),
'N': np.array([0.7]),
'O': np.array([0.3, 1.5]),
'Ne': np.array([0.5, 2.0]),
'Ar': np.array([0.6, 3.0]),
'Kr': np.array([0.447, 4.117]),
'Xe': np.array([0.3, 1.5, 8.0]),
'W': np.array([1.5, 4.0]),
})
)


# pylint: disable=invalid-name
def calculate_average_charge_state_single_species(
Te: array_typing.ArrayFloat,
ion_symbol: str,
) -> array_typing.ArrayFloat:
"""Calculates the average charge state of an impurity based on the Marvin 2018 polynomial fit.
The polynomial fit range is 0.1-100 keV, which is well within the typical
bounds of core tokamak modelling. For safety, inputs are clipped to avoid
extrapolation outside this range.
Args:
Te: Electron temperature [keV].
ion_symbol: Species to calculate average charge state for.
Returns:
Z: Average charge state [amu].
"""

if ion_symbol not in constants.ION_SYMBOLS:
raise ValueError(
f'Invalid ion symbol: {ion_symbol}. Allowed symbols are :'
f' {constants.ION_SYMBOLS}'
)
# Return the Z value for light ions that are fully ionized for T > 0.1 keV.
if ion_symbol not in _MAVRIN_Z_COEFFS:
return jnp.ones_like(Te) * constants.ION_PROPERTIES_DICT[ion_symbol].Z

# Avoid extrapolating fitted polynomial out of bounds.
Te_allowed_range = (0.1, 100.0)
Te = jnp.clip(Te, *Te_allowed_range)

# Gather coefficients for each temperature
interval_indices = jnp.searchsorted(_TEMPERATURE_INTERVALS[ion_symbol], Te)
Zavg_coeffs_in_range = jnp.take(
_MAVRIN_Z_COEFFS[ion_symbol], interval_indices, axis=0
).transpose()

def _calculate_in_range(X, coeffs):
"""Return Mavrin 2018 Zavg polynomial."""
A0, A1, A2, A3, A4 = coeffs
return A0 + A1 * X + A2 * X**2 + A3 * X**3 + A4 * X**4

X = jnp.log10(Te)
Zavg = _calculate_in_range(X, Zavg_coeffs_in_range)

return Zavg


def get_average_charge_state(
ion_symbols: Sequence[str],
ion_mixture: plasma_composition.DynamicIonMixture,
Te: array_typing.ArrayFloat,
) -> array_typing.ArrayFloat:
"""Calculates or prescribes average impurity charge state profile (JAX-compatible).
Args:
ion_symbols: Species to calculate average charge state for.
ion_mixture: DynamicIonMixture object containing impurity information. The
index of the ion_mixture.fractions array corresponds to the index of the
ion_symbols array.
Te: Electron temperature [keV]. Can be any sized array, e.g. on cell grid,
face grid, or a single scalar.
Returns:
avg_Z: Average charge state profile [amu].
The shape of avg_Z is the same as Te.
"""

if ion_mixture.Z_override is not None:
return jnp.ones_like(Te) * ion_mixture.Z_override

avg_Z = jnp.zeros_like(Te)
for ion_symbol, fraction in zip(ion_symbols, ion_mixture.fractions):
avg_Z += fraction * calculate_average_charge_state_single_species(
Te, ion_symbol
)

return avg_Z
22 changes: 13 additions & 9 deletions torax/config/plasma_composition.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,11 +180,7 @@ def build_dynamic_params(
ions = self.ion_fractions.keys()
fractions = np.array([self.ion_fractions[ion].get_value(t) for ion in ions])

if not self.Z_override:
Zs = np.array([constants.ION_PROPERTIES_DICT[ion].Z for ion in ions])
avg_Z = np.sum(Zs * fractions)
else:
avg_Z = self.Z_override.get_value(t)
Z_override = None if not self.Z_override else self.Z_override.get_value(t)

if not self.A_override:
As = np.array([constants.ION_PROPERTIES_DICT[ion].A for ion in ions])
Expand All @@ -194,8 +190,8 @@ def build_dynamic_params(

return DynamicIonMixture(
fractions=fractions,
avg_Z=avg_Z,
avg_A=avg_A,
Z_override=Z_override,
)


Expand All @@ -206,11 +202,19 @@ class DynamicIonMixture:
Information on ion names are not stored here, but rather in
StaticRuntimeParamsSlice, to simplify JAX logic and performance in source
functions for fusion power and radiation which are species-dependent.
Attributes:
fractions: Ion fractions for a time slice.
avg_A: Average A of the mixture.
Z_override: Typically, the average Z is calculated according to the
temperature dependent charge-state-distribution, or for low-Z cases
by the atomic numbers of the ions assuming full ionization.
If Z_override is provided, it is used instead for the average Z.
"""

fractions: array_typing.ArrayFloat # Ion fractions for a time slice.
avg_Z: array_typing.ScalarFloat # Average Z of the mixture.
avg_A: array_typing.ScalarFloat # Average A of the mixture.
fractions: array_typing.ArrayFloat
avg_A: array_typing.ScalarFloat
Z_override: array_typing.ScalarFloat | None = None


@chex.dataclass
Expand Down
16 changes: 13 additions & 3 deletions torax/config/tests/plasma_composition.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from absl.testing import absltest
from absl.testing import parameterized
import numpy as np
from torax import charge_states
from torax import interpolated_param
from torax.config import plasma_composition
from torax.geometry import geometry
Expand Down Expand Up @@ -194,7 +195,12 @@ def test_ion_mixture_averaging(self, species, time, expected_Z, expected_A):
mixture = plasma_composition.IonMixture(species=species)
provider = mixture.make_provider()
dynamic_mixture = provider.build_dynamic_params(time)
np.testing.assert_allclose(dynamic_mixture.avg_Z, expected_Z)
calculated_Z = charge_states.get_average_charge_state(
ion_symbols=tuple(species.keys()),
ion_mixture=dynamic_mixture,
Te=np.array(10.0), # Ensure that all ions in test are fully ionized
)
np.testing.assert_allclose(calculated_Z, expected_Z)
np.testing.assert_allclose(dynamic_mixture.avg_A, expected_A)

@parameterized.named_parameters(
Expand All @@ -211,12 +217,16 @@ def test_ion_mixture_override(self, Z_override, A_override, Z, A):
Z_override=Z_override,
A_override=A_override,
)

provider = mixture.make_provider()
dynamic_mixture = provider.build_dynamic_params(t=0.0)
calculated_Z = charge_states.get_average_charge_state(
ion_symbols=tuple(mixture.species.keys()),
ion_mixture=dynamic_mixture,
Te=np.array(1.0), # arbitrary temperature, won't be used for D
)
Z_expected = Z if Z_override is None else Z_override
A_expected = A if A_override is None else A_override
np.testing.assert_allclose(dynamic_mixture.avg_Z, Z_expected)
np.testing.assert_allclose(calculated_Z, Z_expected)
np.testing.assert_allclose(dynamic_mixture.avg_A, A_expected)

def test_from_config(self):
Expand Down
32 changes: 16 additions & 16 deletions torax/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ class IonProperties:
symbol: str
name: str
A: float
Z: int
Z: float


@chex.dataclass(frozen=True)
Expand All @@ -66,21 +66,21 @@ class Constants:
# Taken from
# https://www.nist.gov/pml/periodic-table-elements and https://ciaaw.org.
ION_PROPERTIES: Final[tuple[IonProperties, ...]] = (
IonProperties(symbol='H', name='Hydrogen', A=1.008, Z=1),
IonProperties(symbol='D', name='Deuterium', A=2.0141, Z=1),
IonProperties(symbol='T', name='Tritium', A=3.0160, Z=1),
IonProperties(symbol='He3', name='Helium-3', A=3.0160, Z=2),
IonProperties(symbol='He4', name='Helium-4', A=4.0026, Z=2),
IonProperties(symbol='Li', name='Lithium', A=5.3917, Z=3),
IonProperties(symbol='Be', name='Beryllium', A=9.0122, Z=4),
IonProperties(symbol='C', name='Carbon', A=12.011, Z=6),
IonProperties(symbol='N', name='Nitrogen', A=14.007, Z=7),
IonProperties(symbol='O', name='Oxygen', A=15.999, Z=8),
IonProperties(symbol='Ne', name='Neon', A=20.180, Z=10),
IonProperties(symbol='Ar', name='Argon', A=39.95, Z=18),
IonProperties(symbol='Kr', name='Krypton', A=83.798, Z=36),
IonProperties(symbol='Xe', name='Xenon', A=131.29, Z=54),
IonProperties(symbol='W', name='Tungsten', A=183.84, Z=74),
IonProperties(symbol='H', name='Hydrogen', A=1.008, Z=1.0),
IonProperties(symbol='D', name='Deuterium', A=2.0141, Z=1.0),
IonProperties(symbol='T', name='Tritium', A=3.0160, Z=1.0),
IonProperties(symbol='He3', name='Helium-3', A=3.0160, Z=2.0),
IonProperties(symbol='He4', name='Helium-4', A=4.0026, Z=2.0),
IonProperties(symbol='Li', name='Lithium', A=5.3917, Z=3.0),
IonProperties(symbol='Be', name='Beryllium', A=9.0122, Z=4.0),
IonProperties(symbol='C', name='Carbon', A=12.011, Z=6.0),
IonProperties(symbol='N', name='Nitrogen', A=14.007, Z=7.0),
IonProperties(symbol='O', name='Oxygen', A=15.999, Z=8.0),
IonProperties(symbol='Ne', name='Neon', A=20.180, Z=10.0),
IonProperties(symbol='Ar', name='Argon', A=39.95, Z=18.0),
IonProperties(symbol='Kr', name='Krypton', A=83.798, Z=36.0),
IonProperties(symbol='Xe', name='Xenon', A=131.29, Z=54.0),
IonProperties(symbol='W', name='Tungsten', A=183.84, Z=74.0),
)

ION_PROPERTIES_DICT: Final[Mapping[str, IonProperties]] = (
Expand Down
Loading

0 comments on commit 3b1857e

Please sign in to comment.