Source code for struphy.fields_background.equils

"Available fluid backgrounds:"

import copy
import importlib.util
import logging
import os
import sys
import warnings
from time import time
from typing import TYPE_CHECKING

import cunumpy as xp
from feectools.ddm.mpi import MockMPI
from feectools.ddm.mpi import mpi as MPI
from line_profiler import profile
from scipy.integrate import odeint, quad
from scipy.interpolate import RectBivariateSpline, UnivariateSpline
from scipy.optimize import fsolve, minimize

import struphy
from struphy.fields_background.base import (
    AxisymmMHDequilibrium,
    CartesianFluidEquilibrium,
    CartesianFluidEquilibriumWithB,
    CartesianMHDequilibrium,
    FluidEquilibrium,
    FluidEquilibriumWithB,
    LogicalFluidEquilibrium,
    LogicalFluidEquilibriumWithB,
    LogicalMHDequilibrium,
    MHDequilibrium,
    NumericalFluidEquilibrium,
    NumericalFluidEquilibriumWithB,
    NumericalMHDequilibrium,
)
from struphy.fields_background.mhd_equil.eqdsk import readeqdsk
from struphy.io.options import BaseUnits
from struphy.physics.physics import Units
from struphy.utils.utils import all_class_params_are_default, read_state, subp_run

if TYPE_CHECKING:
    from struphy import domains

logger = logging.getLogger("struphy")

if isinstance(MPI, MockMPI):
    comm = None
    rank = 0
    size = 1
    Barrier = lambda: None
else:
    comm = MPI.COMM_WORLD
    rank = comm.Get_rank()
    size = comm.Get_size()
    Barrier = comm.Barrier


[docs] class HomogenSlab(CartesianMHDequilibrium): r""" Homogeneous MHD equilibrium: .. math:: \mathbf B &= B_{0x}\,\mathbf e_x + B_{0y}\,\mathbf e_y + B_{0z}\,\mathbf e_z = const.\,, p &= \beta \frac{|\mathbf B|^2}{2}=const.\,, n &= n_0 = const.\,. Units are those defned in the parameter file (through :class:`~struphy.io.options.BaseUnits`). Parameters ---------- B0x : float x-component of magnetic field (default: 0.). B0y : float y-component of magnetic field (default: 0.). B0z : float z-component of magnetic field (default: 1.). beta : float Plasma beta (ratio of kinematic pressure to B^2/(2*mu0), default: 0.1). n0 : float Ion number density (default: 1.). Note ---- In the parameter .yml, use the following in the section ``fluid_background``:: HomogenSlab : B0x : 0. # magnetic field in x B0y : 0. # magnetic field in y B0z : 1. # magnetic field in z beta : .1 # plasma beta = p*(2*mu_0)/B^2 n0 : 1. # number density """ def __init__( self, B0x: float = 0.0, B0y: float = 0.0, B0z: float = 1.0, beta: float = 0.1, n0: float = 1.0, ): # use params setter self.params = copy.deepcopy(locals()) # =============================================================== # profiles on physical domain # =============================================================== # equilibrium magnetic field (curl of equilibrium vector potential)
[docs] def b_xyz(self, x, y, z): """Magnetic field.""" bx = self.params["B0x"] - 0 * x by = self.params["B0y"] - 0 * x bz = self.params["B0z"] - 0 * x return bx, by, bz
# equilibrium vector potential
[docs] def a_xyz(self, x, y, z): """Vector potential.""" bx = self.params["B0x"] - 0 * x by = self.params["B0y"] - 0 * x bz = self.params["B0z"] - 0 * x ax = by * z ay = bz * x az = bx * y return ax, ay, az
# equilibrium current (curl of equilibrium magnetic field)
[docs] def j_xyz(self, x, y, z): """Current density.""" jx = 0 * x jy = 0 * x jz = 0 * x return jx, jy, jz
# equilibrium pressure
[docs] def p_xyz(self, x, y, z): """Plasma pressure.""" pp = ( self.params["beta"] * (self.params["B0x"] ** 2 + self.params["B0y"] ** 2 + self.params["B0z"] ** 2) / 2.0 - 0 * x ) return pp
# equilibrium number density
[docs] def n_xyz(self, x, y, z): """Number density.""" nn = self.params["n0"] - 0 * x return nn
# equilibrium current (curl of equilibrium magnetic field)
[docs] def gradB_xyz(self, x, y, z): """Current density.""" gradBx = 0 * x gradBy = 0 * x gradBz = 0 * x return gradBx, gradBy, gradBz
[docs] class ShearedSlab(CartesianMHDequilibrium): r""" Sheared slab MHD equilibrium in a cube with side lengths :math:`L_x=a,\,L_y=2\pi a,\,L_z=2\pi R_0`. Profiles depend on :math:`x` solely: .. math:: \mathbf B(x) &= B_{0} \left( \mathbf e_z + \frac{a}{q(x)R_0}\mathbf e_y\right)\,,\qquad q(x) = q_0 + ( q_1 - q_0 )\frac{x^2}{a^2}\,, p(x) &= \beta\frac{B_{0}^2}{2} \left( 1 + \frac{a^2}{q(x)^2 R_0^2} \right) + B_{0}^2 \frac{a^2}{R_0^2} \left( \frac{1}{q_0^2} - \frac{1}{q(x)^2} \right)\,, n(x) &= n_a + ( 1 - n_a ) \left( 1 - \left(\frac{x}{a}\right)^{n_1} \right)^{n_2} \,. Units are those defned in the parameter file (through :class:`~struphy.io.options.BaseUnits`). Parameters ---------- a : float "Minor" radius (must be compatible with :math:`L_x=a` and :math:`L_y=2\pi a`, default: 1.). R0 : float "Major" radius (must be compatible with :math:`L_z=2\pi R_0`, default: 3.). B0 : float z-component of magnetic field (constant) (default: 1.). q0 : float Safety factor at x=0 (default: 1.05). q1 : float Safety factor at x=a (default: 1.80). n1 : float 1st shape factor for ion number density profile (default: 0.). n2 : float 2nd shape factor for ion number density profile (default: 0.). na : float Ion number density at x=a (default: 1.). beta : float Plasma beta (ratio of kinematic pressure to B^2/2, default: 0.1). q_kind : int Kind of safety factor profile, (0 or 1, default: 0). Note ---- In the parameter .yml, use the following in the section ``fluid_background``:: ShearedSlab : a : 1. # minor radius (Lx=a, Ly=2*pi*a) R0 : 3. # major radius (Lz=2*pi*R0) B0 : 1. # magnetic field in z-direction q0 : 1.05 # safety factor at x = 0 q1 : 1.80 # safety factor at x = a n1 : 0. # 1st shape factor for ion number density profile n2 : 0. # 2nd shape factor for ion number density profile na : 1. # number density at r=a beta : .1 # plasma beta = p*2/B^2 q_kind : 0. # kind of safety factor profile """ def __init__( self, a: float = 1.0, R0: float = 3.0, B0: float = 1.0, q0: float = 1.05, q1: float = 1.80, n1: float = 0.0, n2: float = 0.0, na: float = 1.0, beta: float = 0.1, q_kind: int = 0, ): # use params setter self.params = copy.deepcopy(locals()) # =============================================================== # profiles for a sheared slab geometry # ===============================================================
[docs] def q_x(self, x, der=0): """Safety factor profile q = q(x) (or its first derivative if der=1).""" assert der >= 0 and der <= 1, "Only first derivative available!" if self.params["q0"] == "inf" and self.params["q1"] == "inf": if der == 0: qout = 101.0 - 0 * x else: qout = 0 * x else: if self.params["q_kind"] == 0: if der == 0: qout = self.params["q0"] + (self.params["q1"] - self.params["q0"]) * (x / self.params["a"]) ** 2 else: qout = 2 * (self.params["q1"] - self.params["q0"]) * x / self.params["a"] ** 2 else: if der == 0: qout = self.params["q0"] + self.params["q1"] * xp.sin(2.0 * xp.pi * x / self.params["a"]) else: qout = ( 2.0 * xp.pi / self.params["a"] * self.params["q1"] * xp.cos(2.0 * xp.pi * x / self.params["a"]) ) return qout
[docs] def p_x(self, x): """Pressure profile p = p(x).""" q = self.q_x(x) eps = self.params["a"] / self.params["R0"] if xp.all(q >= 100.0): pout = self.params["B0"] ** 2 * self.params["beta"] / 2.0 - 0 * x else: pout = self.params["B0"] ** 2 * self.params["beta"] / 2.0 * (1 + eps**2 / q**2) + self.params[ "B0" ] ** 2 * eps**2 * (1 / self.params["q0"] ** 2 - 1 / q**2) return pout
[docs] def n_x(self, x): """Ion number density profile n = n(x).""" nout = (1 - self.params["na"]) * (1 - (x / self.params["a"]) ** self.params["n1"]) ** self.params[ "n2" ] + self.params["na"] return nout
[docs] def plot_profiles(self, n_pts=501): """Plots radial profiles.""" import matplotlib.pyplot as plt x = xp.linspace(0.0, self.params["a"], n_pts) fig, ax = plt.subplots(1, 3) fig.set_figheight(3) fig.set_figwidth(12) ax[0].plot(x, self.q_x(x)) ax[0].set_xlabel("x") ax[0].set_ylabel("q") ax[1].plot(x, self.p_x(x)) ax[1].set_xlabel("x") ax[1].set_ylabel("p") ax[2].plot(x, self.n_x(x)) ax[2].set_xlabel("x") ax[2].set_ylabel("n") plt.subplots_adjust(wspace=0.4) plt.show()
# =============================================================== # profiles on physical domain # =============================================================== # equilibrium magnetic field
[docs] def b_xyz(self, x, y, z): """Magnetic field.""" bx = 0 * x q = self.q_x(x) eps = self.params["a"] / self.params["R0"] if xp.all(q >= 100.0): by = 0 * x bz = self.params["B0"] - 0 * x else: by = self.params["B0"] * eps / q bz = self.params["B0"] - 0 * x return bx, by, bz
# equilibrium current (curl of equilibrium magnetic field)
[docs] def j_xyz(self, x, y, z): """Current density.""" jx = 0 * x jy = 0 * x q = self.q_x(x) eps = self.params["a"] / self.params["R0"] if xp.all(q >= 100.0): jz = 0 * x else: jz = -self.params["B0"] * eps * self.q_x(x, der=1) / q**2 return jx, jy, jz
# equilibrium pressure
[docs] def p_xyz(self, x, y, z): """Pressure.""" pp = self.p_x(x) return pp
# equilibrium number density
[docs] def n_xyz(self, x, y, z): """Number density.""" nn = self.n_x(x) return nn
# gradient of equilibrium magnetic field (grad of equilibrium magnetic field)
[docs] def gradB_xyz(self, x, y, z): """Gradient of magnetic field.""" gradBy = 0 * x gradBz = 0 * x q = self.q_x(x) eps = self.params["a"] / self.params["R0"] if xp.all(q >= 100.0): gradBx = 0 * x else: gradBx = ( -self.params["B0"] * eps**2 / xp.sqrt(1 + eps**2 / self.q_x(x) ** 2) * self.q_x(x, der=1) / self.q_x(x) ** 3 ) return gradBx, gradBy, gradBz
[docs] class ShearFluid(CartesianMHDequilibrium): r""" Sheared fluid equilibrium in a cube with side lengths :math:`L_x=a,\,L_y=b,\,L_z=c`. Profiles depend on :math:`z` solely: .. math:: p(z) &= p_a + T(z)p_b \,, n(z) &= n_a + T(z)n_b \,. T(z) &= (\tanh(z - z_1)/\delta)-\tanh(z - z_2)/\delta)) \,. \mathbf B &= B_{0x}\,\mathbf e_x + B_{0y}\,\mathbf e_y + B_{0z}\,\mathbf e_z = const.\,, Units are those defned in the parameter file (through :class:`~struphy.io.options.BaseUnits`). Parameters ---------- a : float Dimension of the slab in x (default: 1.). b : float Dimension of the slab in y (default: 1.). c : float Dimension of the slab in z (default: 1.). z1 : float Location of the first swap in density (default 0.25). z2 : float Location of the second swap in density (default 0.75). delta : float Characteristic size of the swap region (default 1/15). na : float Exterior value for the density (default: 1.). nb : float Deviation of the density (default 0.25). pa : float Exterior value for the pressure (default: 1.). pb : float Deviation of the pressure (default 0.). B0x : float x-component of magnetic field (default: 0.). B0y : float y-component of magnetic field (default: 0.). B0z : float z-component of magnetic field (default: 1.). Note ---- In the parameter .yml, use the following in the section ``fluid_background``:: ShearFluid : a : 1. # dimension in x b : 1. # dimension in y c : 2. # dimension in z z1 : 0.5 # first swap location z2 : 1.5 # second swap location delta: 0.06666666 # characteristic size of the swap na : 1.25 # exterior density nb : 0.75 # deviation from the average pa : 1. # constant pressure pb : 0. # deviation pressure B0x : 1. # magnetic field in x B0y : 0. # magnetic field in y B0z : 0. # magnetic field in z """ def __init__( self, a: float = 1.0, b: float = 1.0, c: float = 1.0, z1: float = 0.25, z2: float = 0.75, delta: float = 0.06666666, na: float = 1.0, nb: float = 0.25, pa: float = 1.0, pb: float = 0.0, B0x: float = 1.0, B0y: float = 0.0, B0z: float = 0.0, ): # use params setter self.params = copy.deepcopy(locals()) # =============================================================== # profiles for a sheared slab geometry # ===============================================================
[docs] def T_z(self, z): r"""Swap function T(z) = \tanh(z - z_1)/\delta) - \tanh(z - z_2)/\delta)""" Tout = ( xp.tanh((z - self.params["z1"]) / self.params["delta"]) - xp.tanh((z - self.params["z2"]) / self.params["delta"]) ) / 2.0 return Tout
[docs] def p_z(self, z): """Pressure profile p = p(z).""" pout = self.params["pa"] + self.params["pb"] * self.T_z(z) return pout
[docs] def n_z(self, z): """Ion number density profile n = n(z).""" nout = self.params["na"] + self.params["nb"] * self.T_z(z) return nout
[docs] def plot_profiles(self, n_pts=501): """Plots radial profiles.""" import matplotlib.pyplot as plt z = xp.linspace(0.0, self.params["c"], n_pts) fig, ax = plt.subplots(1, 3) fig.set_figheight(3) fig.set_figwidth(12) ax[1].plot(z, self.p_z(z)) ax[1].set_xlabel("z") ax[1].set_ylabel("p") ax[2].plot(z, self.n_z(z)) ax[2].set_xlabel("z") ax[2].set_ylabel("n") plt.subplots_adjust(wspace=0.4) plt.show()
# =============================================================== # profiles on physical domain # =============================================================== # equilibrium magnetic field (curl of equilibrium vector potential)
[docs] def b_xyz(self, x, y, z): """Magnetic field.""" bx = self.params["B0x"] - 0 * x by = self.params["B0y"] - 0 * x bz = self.params["B0z"] - 0 * x return bx, by, bz
# equilibrium vector potential
[docs] def a_xyz(self, x, y, z): """Vector potential.""" bx = self.params["B0x"] - 0 * x by = self.params["B0y"] - 0 * x bz = self.params["B0z"] - 0 * x ax = by * z ay = bz * x az = bx * y return ax, ay, az
# equilibrium current (curl of equilibrium magnetic field)
[docs] def j_xyz(self, x, y, z): """Current density.""" jx = 0 * x jy = 0 * x jz = 0 * x return jx, jy, jz
# equilibrium pressure
[docs] def p_xyz(self, x, y, z): """Pressure.""" pp = self.p_z(z) return pp
# equilibrium number density
[docs] def n_xyz(self, x, y, z): """Number density.""" nn = self.n_z(z) return nn
# gradient of equilibrium magnetic field (grad of equilibrium magnetic field)
[docs] def gradB_xyz(self, x, y, z): """Gradient of magnetic field.""" gradBy = 0 * x gradBz = 0 * x gradBx = 0 * x return gradBx, gradBy, gradBz
[docs] class ScrewPinch(CartesianMHDequilibrium): r""" Straight tokamak (screw pinch) MHD equilibrium for a cylindrical geometry of radius :math:`a` and length :math:`L_z=2\pi R_0`. The profiles in cylindrical coordinates :math:`(r, \theta, z)` with transformation formulae .. math:: x &= r\cos\theta\,, y &= r\sin\theta\,, z &= z\,, are: .. math:: \mathbf B(r) &= B_{0}\left( \mathbf e_z + \frac{r}{q(r) R_0}\mathbf e_\theta \right)\,,\qquad q(r) = q_0 + ( q_1 - q_0 )\frac{r^2}{a^2}\,, p(r) &= p0 + \left\{\begin{aligned} &\frac{B_{0}^2 a^2 q_0}{ 2 R_0^2(q_1 - q_0) } \left( \frac{1}{q(r)^2} - \frac{1}{q_1^2} \right) \quad &&\textnormal{if}\quad q_1\neq q_0\neq\infty\,, &\frac{B_{0}^2 a^2}{R_0^2q_0^2} \left(1 - \frac{r^2}{a^2} \right) \quad &&\textnormal{if}\quad q_1= q_0\neq\infty\,, &\beta\frac{B_{0}^2}{2} \quad &&\textnormal{if}\quad q_0= q_1=\infty\,, \end{aligned}\right. n(r) &= n_a + ( 1 - n_a )\left( 1 - \left(\frac{r}{a}\right)^{n_1} \right)^{n_2}\,. Units are those defned in the parameter file (through :class:`~struphy.io.options.BaseUnits`). Parameters ---------- a : float "Minor" radius (radius of cylinder, default: 1.). R0 : float "Major" radius (must be compatible with :math:`L_z=2\pi R_0`, default: 5.). B0 : float z-component of magnetic field (constant) (default: 1.). q0 : float, str Safety factor at r=0 (use the string "inf" for infinity, default: 1.05). q1 : float, str Safety factor at r=a (use the string "inf" for infinity, default: 1.80). n1 : float 1st shape factor for ion number density profile (default: 0.). n2 : float 2nd shape factor for ion number density profile (default: 0.). na : float Ion nnumber density at r=a (default: 1.). p0 : float Pressure offset to avoid numerical issues (default: 1e-8) beta : float Plasma beta for :math:`q_0=q_1=\infty` (ratio of kinematic pressure to B^2/2, default: 0.1). Note ---- In the parameter .yml, use the following in the section ``fluid_background``:: ScrewPinch : a : 1. # minor radius (radius of cylinder) R0 : 3. # major radius (length of pinch Lz=2*pi*R0) B0 : 1. # magnetic field in z-direction q0 : 1.05 # safety factor at r=0 q1 : 1.80 # safety factor at r=a n1 : 0. # 1st shape factor for ion number density profile n2 : 0. # 2nd shape factor for ion number density profile na : 1. # ion number density at r=a p0 : 1. # pressure offset beta : 0.1 # plasma beta = p*2/B^2 for q0=q1=inf (pure axial field). """ def __init__( self, a: float = 1.0, R0: float = 5.0, B0: float = 1.0, q0: float = 1.05, q1: float = 1.80, n1: float = 0.0, n2: float = 0.0, na: float = 1.0, p0: float = 1.0e-8, beta: float = 0.1, ): # use params setter self.params = copy.deepcopy(locals()) # inverse cylindrical coordinate transformation (x, y, z) --> (r, theta, phi) self.r = lambda x, y, z: xp.sqrt(x**2 + y**2) self.theta = lambda x, y, z: xp.arctan2(y, x) self.z = lambda x, y, z: 1 * z # =============================================================== # profiles for a straight tokamak equilibrium # ===============================================================
[docs] def q_r(self, r, der=0): """Radial safety factor profile q = q(r) (and first derivative).""" assert der >= 0 and der <= 1, "Only first derivative available!" if self.params["q0"] == "inf" and self.params["q1"] == "inf": if der == 0: qout = 101.0 - 0 * r else: qout = 0 * r else: if der == 0: qout = self.params["q0"] + (self.params["q1"] - self.params["q0"]) * (r / self.params["a"]) ** 2 else: qout = 2 * (self.params["q1"] - self.params["q0"]) * r / self.params["a"] ** 2 return qout
[docs] def p_r(self, r): """Radial pressure profile p = p(r).""" eps = self.params["a"] / self.params["R0"] q0 = self.params["q0"] q1 = self.params["q1"] B0 = self.params["B0"] if q0 == "inf" and q1 == "inf": pout = B0**2 * self.params["beta"] / 2.0 - 0 * r else: if q0 == q1: pout = (B0**2 * eps**2 / q0**2) * (1 - r**2 / self.params["a"] ** 2) else: pout = B0**2 * eps**2 * q0 / (2 * (q1 - q0)) * (1 / self.q_r(r) ** 2 - 1 / q1**2) # add offset to avoid zero pressure return pout + self.params["p0"]
[docs] def n_r(self, r): """Radial ion number density profile n = n(r).""" nout = (1 - self.params["na"]) * (1 - (r / self.params["a"]) ** self.params["n1"]) ** self.params[ "n2" ] + self.params["na"] return nout
[docs] def plot_profiles(self, n_pts=501): """Plots radial profiles.""" import matplotlib.pyplot as plt r = xp.linspace(0.0, self.params["a"], n_pts) fig, ax = plt.subplots(1, 3) fig.set_figheight(3) fig.set_figwidth(12) ax[0].plot(r, self.q_r(r)) ax[0].set_xlabel("r") ax[0].set_ylabel("q") ax[0].plot(r, xp.ones(r.size), "k--") ax[1].plot(r, self.p_r(r)) ax[1].set_xlabel("r") ax[1].set_ylabel("p") ax[2].plot(r, self.n_r(r)) ax[2].set_xlabel("r") ax[2].set_ylabel("n") plt.subplots_adjust(wspace=0.4) plt.show()
# =============================================================== # profiles on physical domain # =============================================================== # equilibrium magnetic field
[docs] def b_xyz(self, x, y, z): """Magnetic field.""" r = self.r(x, y, z) theta = self.theta(x, y, z) q = self.q_r(r) # azimuthal component if xp.all(q >= 100.0): b_theta = 0 * r else: b_theta = self.params["B0"] * r / (self.params["R0"] * q) # cartesian x-component bx = -b_theta * xp.sin(theta) by = b_theta * xp.cos(theta) bz = self.params["B0"] - 0 * x return bx, by, bz
# equilibrium current (curl of equilibrium magnetic field)
[docs] def j_xyz(self, x, y, z): """Current density.""" jx = 0 * x jy = 0 * x r = self.r(x, y, z) q = self.q_r(r) q_p = self.q_r(r, der=1) if xp.all(q >= 100.0): jz = 0 * x else: jz = self.params["B0"] / (self.params["R0"] * q**2) * (2 * q - r * q_p) return jx, jy, jz
# equilibrium pressure
[docs] def p_xyz(self, x, y, z): """Pressure.""" pp = self.p_r(self.r(x, y, z)) return pp
# equilibrium number density
[docs] def n_xyz(self, x, y, z): """Number density.""" nn = self.n_r(self.r(x, y, z)) return nn
# gradient of equilibrium magnetic field (grad of equilibrium magnetic field)
[docs] def gradB_xyz(self, x, y, z): """Gradient of magnetic field.""" r = self.r(x, y, z) theta = self.theta(x, y, z) q = self.q_r(r) if xp.all(q >= 100.0): gradBr = 0 * x else: gradBr = ( self.params["B0"] / self.params["R0"] ** 2 / xp.sqrt( 1 + r**2 / self.q_r( r, ) ** 2 / self.params["R0"] ** 2, ) * (r / self.q_r(r) ** 2 - r**2 / self.q_r(r) ** 3 * self.q_r(r, der=1)) ) gradBx = gradBr * xp.cos(theta) gradBy = gradBr * xp.sin(theta) gradBz = 0 * x return gradBx, gradBy, gradBz
[docs] class AdhocTorus(AxisymmMHDequilibrium): r""" Ad hoc tokamak MHD equilibrium with circular concentric flux surfaces. For a cylindrical coordinate system :math:`(R, \phi, Z)` with transformation formulae .. math:: x &= R\cos(\phi)\,, &&R = \sqrt{x^2 + y^2}\,, y &= R\sin(\phi)\,, &&\phi = \arctan(y/x)\,, z &= Z\,, &&Z = z\,, the magnetic field is given by .. math:: \mathbf B = \nabla\psi\times\nabla\phi+g\nabla\phi\,, where :math:`g=g(R, Z)=-B_0R_0=const.` is the toroidal field function, :math:`R_0` the major radius of the torus and :math:`B_0` the on-axis magnetic field. The ad hoc poloidal flux function :math:`\psi=\psi(r)` is the solution of .. math:: \frac{\textnormal{d}\psi}{\textnormal{d}r}=\frac{B_0r}{q(r)\sqrt{1 - r^2/R_0^2}}\,,\qquad r=\sqrt{Z^2+(R-R_0)^2}\,, for some given safety factor profile. Two profiles in terms of the on-axis :math:`q_0\equiv q(r=0)` and edge :math:`q_1\equiv q(r=a)` safety factor values are available (:math:`a` is the minor radius of the torus): .. math:: q(r) &= \left\{\begin{aligned} &q_0 + ( q_1 - q_0 )\frac{r^2}{a^2} \quad &&\textnormal{if} \quad q_\textnormal{kind}=0\,, &\frac{q_0}{1-\left(1-\frac{r^2}{a^2}\right)^{\frac{q_1}{q_0}}}\frac{r^2}{a^2} \quad &&\textnormal{if} \quad q_\textnormal{kind}=1\,. &q_0 + l\frac{r}{a} ( q_1 - q_0 )\frac{r^2}{a^2} \quad &&\textnormal{if} \quad q_\textnormal{kind}=2\,, \end{aligned}\right. The pressure profile .. math:: p^\prime(r) &= -\frac{B_0^2}{R_0^2}\frac{r\left[2q(r)-rq^\prime(r)\right]}{q(r)^3} \quad &&\textnormal{if} \quad p_\textnormal{kind}=0\,, p(r) &= \beta \frac{B_{0}^2}{2} \left( p_0 - p_1 \frac{r^2}{a^2} - p_2 \frac{r^4}{a^4} \right) \quad &&\textnormal{if} \quad p_\textnormal{kind}=1\,, is either the exact solution of the MHD equilibrium condition in the cylindrical limit (:math:`p_\textnormal{kind}=0`) or an monotonically decreasing adhoc profile for some given on-axis plasma beta (:math:`p_\textnormal{kind}=1`). Finally, the number density profile is chosen as .. math:: n(r) = n_a + ( 1 - n_a ) \left( 1 - \left(\frac{r}{a}\right)^{n_1} \right)^{n_2}\,. Units are those defned in the parameter file (through :class:`~struphy.io.options.BaseUnits`). Parameters ---------- a : float Minor radius of torus (default: 1.). R0 : float Major radius of torus (default: 3.). B0 : float On-axis (r=0) toroidal magnetic field (default: 2.). q_kind : int Which safety factor profile, see docstring (0, 1 or 2, default: 0). q0 : float Safety factor at r=0 (default: 1.71). q1 : float Safety factor at r=a (default: 1.87). l : float Linear term factor for q profile if q_kind=2 (default: 0.). n1 : float 1st shape factor for ion number density profile (default: 0.). n2 : float 2nd shape factor for ion number density profile (default: 0.). na : float Ion number density at r=a (default: 1.). p_kind : int Kind of pressure profile, see docstring (0 or 1, default: 1). p0 : float constant factor for ad hoc pressure profile (default: 1.). p1 : float 1st shape factor for ad hoc pressure profile (default: 0.). p2 : float 2nd shape factor for ad hoc pressure profile (default: 0.). beta : float On-axis (r=0) plasma beta if p_kind=1 (ratio of kinematic pressure to B^2/(2*mu0), default: 0.179). psi_k : int Spline degree to be used for interpolation of poloidal flux function (if q_kind=1, default=3). psi_nel : int Number of cells to be used for interpolation of poloidal flux function (if q_kind=1, default=50). Note ---- In the parameter .yml, use the following in the section ``fluid_background``:: AdhocTorus : a : 1. # minor radius R0 : 3. # major radius B0 : 2. # on-axis toroidal magnetic field q_kind : 0 # which profile (0 : parabolic, 1 : other, 2 : parabolic with linear term, see documentation) q0 : 1.05 # safety factor at r=0 q1 : 1.80 # safety factor at r=a l : 0. # linear term factor for q profile if q_kind=2 n1 : .5 # 1st shape factor for number density profile n2 : 1. # 2nd shape factor for number density profile na : .2 # number density at r=a p_kind : 1 # kind of pressure profile (0 : cylindrical limit, 1 : ad hoc) p0 : 1. # constant factor for ad hoc pressure profile p1 : .1 # 1st shape factor for ad hoc pressure profile p2 : .1 # 2nd shape factor for ad hoc pressure profile beta : .01 # plasma beta = p*(2*mu_0)/B^2 for flat safety factor psi_k : 3 # spline degree to be used for interpolation of poloidal flux function (only needed if q_kind=1) psi_nel : 50 # number of cells to be used for interpolation of poloidal flux function (only needed if q_kind=1) """ def __init__( self, a: float = 1.0, R0: float = 3.0, B0: float = 2.0, q_kind: int = 0, q0: float = 1.71, q1: float = 1.87, l: float = 0.0, n1: float = 2.0, n2: float = 1.0, na: float = 0.2, p_kind: int = 1, p0: float = 1.0, p1: float = 0.1, p2: float = 0.1, beta: float = 0.179, psi_k: int = 3, psi_nel: int = 50, ): # use params setter self.params = copy.deepcopy(locals()) # plasma boundary contour ths = xp.linspace(0.0, 2 * xp.pi, 201) self._rbs = self.params["R0"] * (1 + self.params["a"] / self.params["R0"] * xp.cos(ths)) self._zbs = self.params["a"] * xp.sin(ths) # set on-axis and boundary fluxes if self.params["q_kind"] == 0: self._psi0 = self.psi(self.params["R0"], 0.0) self._psi1 = self.psi(self.params["R0"] + self.params["a"], 0.0) self._psi_i = None self._p_i = None elif self.params["q_kind"] == 1 or self.params["q_kind"] == 2: r_i = xp.linspace(0.0, self.params["a"], self.params["psi_nel"] + 1) def dpsi_dr(r): return self.params["B0"] * r / (self.q_r(r) * xp.sqrt(1 - r**2 / self.params["R0"] ** 2)) psis = xp.zeros_like(r_i) for i, rr in enumerate(r_i): psis[i] = quad(dpsi_dr, 0.0, rr)[0] self._psi_i = UnivariateSpline( r_i, psis, k=self.params["psi_k"], s=0.0, ext=3, ) self._psi0 = 0.0 self._psi1 = self.psi(self.params["R0"] + self.params["a"], 0.0) def dp_dr(r): return ( -(self.params["B0"] ** 2 * r) / (self.params["R0"] ** 2 * self.q_r(r) ** 3) * (2 * self.q_r(r) - r * self.q_r(r, der=1)) ) ps = xp.zeros_like(r_i) for i, rr in enumerate(r_i): ps[i] = quad(dp_dr, 0.0, rr)[0] self._p_i = UnivariateSpline( r_i, ps - ps[-1], k=self.params["psi_k"], s=0.0, ext=3, ) @property def boundary_pts_R(self): """R-coordinates of plasma boundary contour.""" return self._rbs @property def boundary_pts_Z(self): """Z-coordinates of plasma boundary contour.""" return self._zbs # =============================================================== # abstract properties # =============================================================== @property def psi_range(self): """Psi on-axis and at plasma boundary.""" return [self._psi0, self._psi1] @property def psi_axis_RZ(self): """Location of magnetic axis in R-Z-coordinates.""" return [self.params["R0"], 0.0] # =============================================================== # radial profiles for an ad hoc tokamak equilibrium # ===============================================================
[docs] def psi_r(self, r, der=0): """Ad hoc poloidal flux function psi = psi(r).""" assert der >= 0 and der <= 2, "Only first and second derivative available!" # parabolic profile (analytical) if self.params["q_kind"] == 0: eps = self.params["a"] / self.params["R0"] q0 = self.params["q0"] q1 = self.params["q1"] dq = q1 - q0 # geometric correction factor and its first derivative gf_0 = xp.sqrt(1 - (r / self.params["R0"]) ** 2) gf_1 = -r / (self.params["R0"] ** 2 * gf_0) # safety factors q_0 = self.q_r(r, der=0) q_1 = self.q_r(r, der=1) q_bar_0 = q_0 * gf_0 q_bar_1 = q_1 * gf_0 + q_0 * gf_1 if der == 0: out = -self.params["B0"] * self.params["a"] ** 2 / xp.sqrt(dq * q0 * eps**2 + dq**2) out *= xp.arctanh( xp.sqrt((dq - dq * (r / self.params["R0"]) ** 2) / (q0 * eps**2 + dq)), ) elif der == 1: out = self.params["B0"] * r / q_bar_0 elif der == 2: out = self.params["B0"] * (q_bar_0 - r * q_bar_1) / q_bar_0**2 # alternative profile (interpolated) elif self.params["q_kind"] == 1 or self.params["q_kind"] == 2: out = self._psi_i(r, nu=der) # remove all "dimensions" for point-wise evaluation if isinstance(r, (int, float)): assert out.ndim == 0 out = out.item() return out
[docs] def q_r(self, r, der=0): """Radial safety factor profile q = q(r) (and first derivative).""" assert der >= 0 and der <= 1, "Only first derivative available!" q0 = self.params["q0"] q1 = self.params["q1"] l = self.params["l"] a = self.params["a"] # parabolic profile if self.params["q_kind"] == 0: if der == 0: qout = q0 + (q1 - q0) * (r / a) ** 2 else: qout = 2 * (q1 - q0) * r / a**2 elif self.params["q_kind"] == 2: if der == 0: qout = q0 + (q1 - q0) * (r / a) ** 2 + l * r / a else: qout = 2 * (q1 - q0) * r / a**2 + l / a # alternative profile elif self.params["q_kind"] == 1: # int/float input if isinstance(r, (int, float)): if r == 0: if der == 0: qout = 1 * q0 else: qout = 0 * r else: if der == 0: if self.params["q0"] == self.params["q1"]: qout = 1 * q0 else: qout = q1 * (r / a) ** 2 / (1 - (1 - (r / a) ** 2) ** (q1 / q0)) else: if self.params["q0"] == self.params["q1"]: qout = 0 * r else: qout = ( (2 * r * q1 / a**2) * ( 1 - (1 - (r / a) ** 2) ** (q1 / q0) - (r / a) ** 2 * (q1 / q0) * (1 - (r / a) ** 2) ** (q1 / q0 - 1) ) / (1 - (1 - (r / a) ** 2) ** (q1 / q0)) ** 2 ) # vector input else: sh = r.shape r_flat = r.flatten() r_zeros = xp.where(r_flat == 0.0)[0] r_nzero = xp.where(r_flat != 0.0)[0] qout = xp.zeros(r_flat.size, dtype=float) if der == 0: if self.params["q0"] == self.params["q1"]: qout[:] = 1 * q0 else: qout[r_zeros] = 1 * q0 qout[r_nzero] = ( q1 * (r_flat[r_nzero] / a) ** 2 / (1 - (1 - (r_flat[r_nzero] / a) ** 2) ** (q1 / q0)) ) else: if self.params["q0"] == self.params["q1"]: qout[:] = 0.0 else: qout[r_zeros] = 0 * r_zeros qout[r_nzero] = ( (2 * r_flat[r_nzero] * q1 / a**2) * ( 1 - (1 - (r_flat[r_nzero] / a) ** 2) ** (q1 / q0) - (r_flat[r_nzero] / a) ** 2 * (q1 / q0) * (1 - (r_flat[r_nzero] / a) ** 2) ** (q1 / q0 - 1) ) / (1 - (1 - (r_flat[r_nzero] / a) ** 2) ** (q1 / q0)) ** 2 ) qout = qout.reshape(sh).copy() return qout
[docs] def p_r(self, r): """Radial pressure profile p = p(r).""" eps = self.params["a"] / self.params["R0"] # profile in cylindrical limit if self.params["p_kind"] == 0: # parabolic q-profile if self.params["q_kind"] == 0: if self.params["q0"] == self.params["q1"]: pout = ( self.params["B0"] ** 2 * self.params["a"] ** 2 / (self.params["R0"] ** 2 * self.params["q0"] ** 2) * (1 - r**2 / self.params["a"] ** 2) ) else: pout = ( self.params["B0"] ** 2 * eps**2 * self.params["q0"] / (2 * (self.params["q1"] - self.params["q0"])) * (1 / self.q_r(r) ** 2 - 1 / self.params["q1"] ** 2) ) # alternative profile elif self.params["q_kind"] == 1: pout = self._p_i(r) # remove all "dimensions" for point-wise evaluation if isinstance(r, (int, float)): assert pout.ndim == 0 pout = pout.item() # ad-hoc profile elif self.params["p_kind"] == 1: pout = ( self.params["B0"] ** 2 * self.params["beta"] / 2.0 * ( self.params["p0"] - self.params["p1"] * r**2 / self.params["a"] ** 2 - self.params["p2"] * r**4 / self.params["a"] ** 4 ) ) return pout
[docs] def n_r(self, r): """Radial number density profile n = n(r).""" nout = (1 - self.params["na"]) * (1 - (r / self.params["a"]) ** self.params["n1"]) ** self.params[ "n2" ] + self.params["na"] return nout
[docs] def plot_profiles(self, n_pts=501): """Plots 1d profiles.""" import matplotlib.pyplot as plt r = xp.linspace(0.0, self.params["a"], n_pts) fig, ax = plt.subplots(2, 2) fig.set_figheight(5) fig.set_figwidth(6) ax[0, 0].plot(r, self.psi_r(r)) ax[0, 0].set_xlabel("$r$") ax[0, 0].set_ylabel(r"$\psi$") ax[0, 1].plot(r, self.q_r(r)) ax[0, 1].set_xlabel("$r$") ax[0, 1].set_ylabel("$q$") ax[1, 0].plot(r, self.p_r(r)) ax[1, 0].set_xlabel("$r$") ax[1, 0].set_ylabel("$p$") ax[1, 1].plot(r, self.n_r(r)) ax[1, 1].set_xlabel("$r$") ax[1, 1].set_ylabel("$n$") plt.subplots_adjust(wspace=0.4, hspace=0.4) plt.show()
# =============================================================== # abstract methods # ===============================================================
[docs] def psi(self, R, Z, dR=0, dZ=0): """Poloidal flux function psi = psi(R, Z).""" r = xp.sqrt(Z**2 + (R - self.params["R0"]) ** 2) if dR == 0 and dZ == 0: out = self.psi_r(r, der=0) else: dr_dR = (R - self.params["R0"]) / r dr_dZ = Z / r d2r_dR2 = (r - (R - self.params["R0"]) * dr_dR) / r**2 d2r_dZ2 = (r - Z * dr_dZ) / r**2 d2r_dRdZ = -Z * (R - self.params["R0"]) / r**3 if dR == 1 and dZ == 0: out = self.psi_r(r, der=1) * dr_dR elif dR == 0 and dZ == 1: out = self.psi_r(r, der=1) * dr_dZ elif dR == 2 and dZ == 0: out = self.psi_r(r, der=2) * dr_dR**2 + self.psi_r(r, der=1) * d2r_dR2 elif dR == 0 and dZ == 2: out = self.psi_r(r, der=2) * dr_dZ**2 + self.psi_r(r, der=1) * d2r_dZ2 elif dR == 1 and dZ == 1: out = self.psi_r(r, der=2) * dr_dR * dr_dZ + self.psi_r(r, der=1) * d2r_dRdZ else: raise NotImplementedError( "Only combinations (dR=0, dZ=0), (dR=1, dZ=0), (dR=0, dZ=1), (dR=2, dZ=0), (dR=0, dZ=2) and (dR=1, dZ=1) possible!", ) return out
[docs] def g_tor(self, R, Z, dR=0, dZ=0): """Toroidal field function g = g(R, Z).""" if dR == 0 and dZ == 0: out = -self.params["B0"] * self.params["R0"] - 0 * R elif dR == 1 and dZ == 0: out = 0 * R elif dR == 0 and dZ == 1: out = 0 * Z else: raise NotImplementedError( "Only combinations (dR=0, dZ=0), (dR=1, dZ=0) and (dR=0, dZ=1) possible!", ) return out
[docs] def p_xyz(self, x, y, z): """Pressure p = p(x, y, z).""" r = xp.sqrt((xp.sqrt(x**2 + y**2) - self.params["R0"]) ** 2 + z**2) pp = self.p_r(r) return pp
[docs] def n_xyz(self, x, y, z): """Number density n = n(x, y, z).""" r = xp.sqrt((xp.sqrt(x**2 + y**2) - self.params["R0"]) ** 2 + z**2) nn = self.n_r(r) return nn
[docs] class AdhocTorusQPsi(AxisymmMHDequilibrium): r""" Ad hoc tokamak MHD equilibrium with circular concentric flux surfaces. For a cylindrical coordinate system :math:`(R, \phi, Z)` with transformation formulae .. math:: x &= R\cos(\phi)\,, &&R = \sqrt{x^2 + y^2}\,, y &= R\sin(\phi)\,, &&\phi = \arctan(y/x)\,, z &= Z\,, &&Z = z\,, the magnetic field is given by .. math:: \mathbf B = \nabla\psi\times\nabla\phi+g\nabla\phi\,, where :math:`g=g(R, Z)=-B_0R_0=const.` is the toroidal field function, :math:`R_0` the major radius of the torus and :math:`B_0` the on-axis magnetic field. The ad hoc poloidal flux function :math:`\psi=\psi(r)` is the solution of .. math:: \frac{\textnormal{d}\psi}{\textnormal{d}r}=\frac{B_0r}{q(\psi(r))\sqrt{1 - r^2/R_0^2}}\,,\qquad r=\sqrt{Z^2+(R-R_0)^2}\,, for a safety factor profile .. math:: q(\psi) &= q_0 + \psi_{\textnormal{norm}}\left[ q_1-q_0+(q_1^\prime-q_1+q_0)\frac{(1-\psi_s)(\psi_{\textnormal{norm}}-1)}{\psi_{\textnormal{norm}}-\psi_s} \right]\,, \psi_{\textnormal{norm}} &= \frac{\psi-\psi(0)}{\psi(a)-\psi(0)}\,, \psi_s &= (q_1^\prime-q_1+q_0)/(q_0^\prime+q_1^\prime-2q_1+2q_0)\,, where :math:`a` is the minor radius of the torus. The pressure and number density profiles are chosen as .. math:: p(\psi) &= \frac{\beta B_0^2}{2}\exp\left(-\frac{\psi_{\textnormal{norm}}}{p_1}\right)\,, n(\psi) &= n_a + ( 1 - n_a ) \left( 1 - \psi_{\textnormal{norm}}^{n_1} \right)^{n_2}\,. Units are those defned in the parameter file (through :class:`~struphy.io.options.BaseUnits`). Parameters ---------- a : float Minor radius of torus (default: 0.361925). R0 : float Major radius of torus (default: 1.). B0 : float On-axis (r=0) toroidal magnetic field (default: 1.). q0 : float Safety factor at r=0 (default: 0.6). q1 : float Safety factor at r=a (default: 2.5). q0p : float Derivative of safety factor at r=0 (w.r.t. poloidal flux function, default: 0.78). q1p : float Derivative of safety factor at r=a (w.r.t. poloidal flux function, default: 5.00). n1 : float 1st shape factor for ion number density profile (default: 0.). n2 : float 2nd shape factor for ion number density profile (default: 0.). na : float Ion number density at r=a (default: 1.). beta : float On-axis (r=0) plasma beta (ratio of kinematic pressure to B^2/(2*mu0), default: 0.1). p1 : float Shape factor for pressure profile, see docstring (default: 0.25). psi_k : int Spline degree to be used for interpolation of poloidal flux function (default=3). psi_nel : int Number of cells to be used for interpolation of poloidal flux function (default=50). Note ---- In the parameter .yml, use the following in the section ``fluid_background``:: AdhocTorusQPsi : a : 0.361925 # minor radius R0 : 1. # major radius B0 : 1. # on-axis toroidal magnetic field q0 : 0.6 # safety factor at r=0 q1 : 2.5 # safety factor at r=a q0p : 0.78 # derivative of safety factor at r=0 (w.r.t. to poloidal flux function) q1p : 5.00 # derivative of safety factor at r=a (w.r.t. to poloidal flux function) n1 : .5 # shape factor for number density profile n2 : 1. # shape factor for number density profile na : .2 # number density at r=a beta : .1 # plasma beta = p*(2*mu_0)/B^2 for flat safety factor p1 : 0.25 # shape factor of pressure profile psi_k : 3 # spline degree to be used for interpolation of poloidal flux function psi_nel : 50 # number of cells to be used for interpolation of poloidal flux functionq_kind=1) """ def __init__( self, a: float = 0.361925, R0: float = 1.0, B0: float = 1.0, q0: float = 0.6, q1: float = 2.5, q0p: float = 0.78, q1p: float = 5.00, n1: float = 2.0, n2: float = 1.0, na: float = 0.2, beta: float = 4.0, p1: float = 0.25, psi_k: int = 3, psi_nel: int = 50, ): # use params setter self.params = copy.deepcopy(locals()) # plasma boundary contour ths = xp.linspace(0.0, 2 * xp.pi, 201) self._rbs = self.params["R0"] * (1 + self.params["a"] / self.params["R0"] * xp.cos(ths)) self._zbs = self.params["a"] * xp.sin(ths) # on-axis flux (arbitrary value) self._psi0 = -10.0 # poloidal flux function differential equation: dpsi_dr(r) = B0*r/(q(psi(r))*sqrt(1 - r**2/R0**2)) def dpsi_dr(psi, r, psi1): q0 = self.params["q0"] q1 = self.params["q1"] q0p = self.params["q0p"] q1p = self.params["q1p"] B0 = self.params["B0"] R0 = self.params["R0"] psi_norm = (psi - self._psi0) / (psi1 - self._psi0) psi_s = (q1p - q1 + q0) / (q0p + q1p - 2 * q1 + 2 * q0) q = q0 + psi_norm * (q1 - q0 + (q1p - q1 + q0) * (1 - psi_s) * (psi_norm - 1) / (psi_norm - psi_s)) out = B0 * r / (q * xp.sqrt(1 - r**2 / R0**2)) return out # solve differential equation and fix boundary flux r_i = xp.linspace(0.0, self.params["a"], self.params["psi_nel"] + 1) def fun(psi1): out = odeint(dpsi_dr, self._psi0, r_i, args=(psi1,)).flatten() return out[-1] - psi1 self._psi1 = fsolve(fun, -9.5)[0] # interpolate flux function self._psi_i = UnivariateSpline( r_i, odeint(dpsi_dr, self._psi0, r_i, args=(self._psi1,)).flatten(), k=self.params["psi_k"], s=0.0, ext=3, ) @property def boundary_pts_R(self): """R-coordinates of plasma boundary contour.""" return self._rbs @property def boundary_pts_Z(self): """Z-coordinates of plasma boundary contour.""" return self._zbs # =============================================================== # abstract properties # =============================================================== @property def psi_range(self): """Psi on-axis and at plasma boundary.""" return [self._psi0, self._psi1] @property def psi_axis_RZ(self): """Location of magnetic axis in R-Z-coordinates.""" return [self.params["R0"], 0.0] # =============================================================== # 1d profiles for an ad hoc tokamak equilibrium # ===============================================================
[docs] def psi_r(self, r, der=0): """Ad hoc poloidal flux function psi = psi(r).""" assert der >= 0 and der <= 2, "Only first and second derivatives available!" out = self._psi_i(r, nu=der) # remove all "dimensions" for point-wise evaluation if isinstance(r, (int, float)): assert out.ndim == 0 out = out.item() return out
[docs] def q_psi(self, psi): """Safety factor profile q = q(psi).""" q0 = self.params["q0"] q1 = self.params["q1"] q0p = self.params["q0p"] q1p = self.params["q1p"] psi_s = (q1p - q1 + q0) / (q0p + q1p - 2 * q1 + 2 * q0) psi_norm = (psi - self._psi0) / (self._psi1 - self._psi0) q = q0 + psi_norm * (q1 - q0 + (q1p - q1 + q0) * (1 - psi_s) * (psi_norm - 1) / (psi_norm - psi_s)) return q
[docs] def p_psi(self, psi, der=0): """Pressure profile p = p(psi).""" assert der >= 0 and der <= 1, "Only first derivative available!" beta, p1, B0 = self.params["beta"], self.params["p1"], self.params["B0"] psi_norm = (psi - self._psi0) / (self._psi1 - self._psi0) if der == 0: out = self.params["beta"] * self.params["B0"] ** 2 / 2.0 * xp.exp(-psi_norm / p1) else: out = ( -self.params["beta"] * self.params["B0"] ** 2 / 2.0 * xp.exp(-psi_norm / p1) / (p1 * (self._psi1 - self._psi0)) ) return out
[docs] def n_psi(self, psi, der=0): """Number density profile n = n(psi).""" assert der >= 0 and der <= 1, "Only first derivative available!" n1, n2, na = self.params["n1"], self.params["n2"], self.params["na"] psi_norm = (psi - self._psi0) / (self._psi1 - self._psi0) if der == 0: out = (1 - na) * (1 - psi_norm**n1) ** n2 + na else: out = ( -(1 - na) * n1 * n2 / (self._psi1 - self._psi0) * (1 - psi_norm**n1) ** (n2 - 1) * psi_norm ** (n1 - 1) ) return out
[docs] def plot_profiles(self, n_pts=501): """Plots 1d profiles.""" import matplotlib.pyplot as plt r = xp.linspace(0.0, self.params["a"], n_pts) psi = xp.linspace(self._psi0, self._psi1, n_pts) fig, ax = plt.subplots(2, 2) fig.set_figheight(5) fig.set_figwidth(6) ax[0, 0].plot(r, self.psi_r(r)) ax[0, 0].set_xlabel("$r$") ax[0, 0].set_ylabel(r"$\psi$") ax[0, 1].plot(psi, self.q_psi(psi)) ax[0, 1].set_xlabel(r"$\psi$") ax[0, 1].set_ylabel("$q$") ax[1, 0].plot(psi, self.p_psi(psi)) ax[1, 0].set_xlabel(r"$\psi$") ax[1, 0].set_ylabel("$p$") ax[1, 1].plot(psi, self.n_psi(psi)) ax[1, 1].set_xlabel(r"$\psi$") ax[1, 1].set_ylabel("$n$") plt.subplots_adjust(wspace=0.4, hspace=0.4) plt.show()
# =============================================================== # abstract methods # ===============================================================
[docs] def psi(self, R, Z, dR=0, dZ=0): """Poloidal flux function psi = psi(R, Z).""" r = xp.sqrt(Z**2 + (R - self.params["R0"]) ** 2) if dR == 0 and dZ == 0: out = self.psi_r(r, der=0) else: dr_dR = (R - self.params["R0"]) / r dr_dZ = Z / r d2r_dR2 = (r - (R - self.params["R0"]) * dr_dR) / r**2 d2r_dZ2 = (r - Z * dr_dZ) / r**2 if dR == 1 and dZ == 0: out = self.psi_r(r, der=1) * dr_dR elif dR == 0 and dZ == 1: out = self.psi_r(r, der=1) * dr_dZ elif dR == 2 and dZ == 0: out = self.psi_r(r, der=2) * dr_dR**2 + self.psi_r(r, der=1) * d2r_dR2 elif dR == 0 and dZ == 2: out = self.psi_r(r, der=2) * dr_dZ**2 + self.psi_r(r, der=1) * d2r_dZ2 else: raise NotImplementedError( "Only combinations (dR=0, dZ=0), (dR=1, dZ=0), (dR=0, dZ=1), (dR=2, dZ=0) and (dR=0, dZ=2) possible!", ) return out
[docs] def g_tor(self, R, Z, dR=0, dZ=0): """Toroidal field function g = g(R, Z).""" if dR == 0 and dZ == 0: out = -self.params["B0"] * self.params["R0"] - 0 * R elif dR == 1 and dZ == 0: out = 0 * R elif dR == 0 and dZ == 1: out = 0 * Z else: raise NotImplementedError( "Only combinations (dR=0, dZ=0), (dR=1, dZ=0) and (dR=0, dZ=1) possible!", ) return out
[docs] def p_xyz(self, x, y, z): """Pressure p = p(x, y, z).""" r = xp.sqrt((xp.sqrt(x**2 + y**2) - self.params["R0"]) ** 2 + z**2) return self.p_psi(self.psi_r(r))
[docs] def n_xyz(self, x, y, z): """Number density n = n(x, y, z).""" r = xp.sqrt((xp.sqrt(x**2 + y**2) - self.params["R0"]) ** 2 + z**2) return self.n_psi(self.psi_r(r))
[docs] class EQDSKequilibrium(AxisymmMHDequilibrium): """ Interface to `EQDSK file format <https://w3.pppl.gov/ntcc/TORAY/G_EQDSK.pdf>`_. Parameters ---------- rel_path : bool Whether file is relative to "<struphy_path>/fields_background/mhd_equil/eqdsk/data/", or is an absolute path (default: True). file : str Path to eqdsk file (default: "AUGNLED_g031213.00830.high"). data_type : int 0: there is no space between data, 1: there is space between data (default: 0). degree_for_psi : tuple[int] Spline degrees in (R, Z) directions used for interpolation of psi data (default: [3, 3]). psi_resolution : tuple[float] Resolution of psi data in (R, Z) directions in %, e.g. [50., 50.] uses every second psi data point (default: [25., 6.25]). degree_for_flux : int Spline degree in psi direction used for interpolation of 1d functions that depend on psi: f=f(psi) (default: 3). flux_resolution : float Resolution of 1d f=f(psi) data in %, e.g. 25. uses every forth data point (default: 50.). n1 : float 1st shape factor for ion number density profile n = n(psi) (default: 0.). n2 : float 2nd shape factor for ion number density profile n = n(psi) (default: 0.). na : float Ion number density at plasma boundary (default: 1.). base_units : BaseUnits Struphy base units. If None, no rescaling of output is performed. """ def __init__( self, rel_path: bool = True, file: str = None, data_type: int = 0, degree_for_psi: tuple = (3, 3), psi_resolution: tuple = (25.0, 6.25), degree_for_flux: int = 3, flux_resolution: float = 50.0, n1: float = 2.0, n2: float = 1.0, na: float = 0.2, base_units: BaseUnits = None, ): # use params setter self.params = copy.deepcopy(locals()) # default input file if file is None: file = "AUGNLED_g031213.00830.high" if rank == 0: logger.info(f"EQDSK: taking default file {file}.") # units self._units = Units(base=base_units) if base_units is None: self.units._j = 1.0 self.units._p = 1.0 warnings.warn( f"{self.units =}, no rescaling performed in EQDSK output.", ) # path if self.params["rel_path"]: _path = struphy.__path__[0] + "/fields_background/mhd_equil/eqdsk/data/" + file else: _path = file eqdsk = readeqdsk.Geqdsk() eqdsk.openFile(_path, data_type=self.params["data_type"]) # Number of horizontal R grid points nR = eqdsk.data["nw"][0] # Number of vertical Z grid points nZ = eqdsk.data["nh"][0] # toroidal field function in m-T on flux grid, g = B^1_phi g_profile = eqdsk.data["fpol"][0] # plasma pressure in Nt/m^2 on uniform flux grid p_profile = eqdsk.data["pres"][0] # poloidal flux in Weber/rad on the rectangular grid points psi = eqdsk.data["psirz"][0].T # poloidal flux in Weber/rad at the plasma boundary psi_edge = eqdsk.data["sibry"][0] # q values on uniform flux grid from axis to boundary q_profile = eqdsk.data["qpsi"][0] # Horizontal dimension in meter of computational box rdim = eqdsk.data["rdim"][0] # Vertical dimension in meter of computational box zdim = eqdsk.data["zdim"][0] # Minimum R in meter of rectangular computational box rleft = eqdsk.data["rleft"][0] # Z of center of computational box in meter zmid = eqdsk.data["zmid"][0] # R of magnetic axis in meter R_at_axis = eqdsk.data["rmaxis"][0] # Z of magnetic axis in meter Z_at_axis = eqdsk.data["zmaxis"][0] # R of boundary points in meter self._rbs = eqdsk.data["rbbbs"][0] # Z of boundary points in meter self._zbs = eqdsk.data["zbbbs"][0] # R of limiter contour in meter self._rlims = eqdsk.data["rlim"][0] # Z of limiter contour in meter self._zlims = eqdsk.data["zlim"][0] assert g_profile.size == p_profile.size assert g_profile.size == q_profile.size assert psi.shape == (nR, nZ) # spline interpolation of smoothed flux function self._r_range = [rleft, rleft + rdim] self._z_range = [zmid - zdim / 2, zmid + zdim / 2] R = xp.linspace(self._r_range[0], self._r_range[1], nR) Z = xp.linspace(self._z_range[0], self._z_range[1], nZ) smooth_steps = [ int(1 / (self.params["psi_resolution"][0] * 0.01)), int(1 / (self.params["psi_resolution"][1] * 0.01)), ] self._psi_i = RectBivariateSpline( R[:: smooth_steps[0]], Z[:: smooth_steps[1]], psi[:: smooth_steps[0], :: smooth_steps[1]], kx=self.params["degree_for_psi"][0], ky=self.params["degree_for_psi"][1], s=0.0, ) # find minimum of interpolated flux function (is not the same as (R_at_axis, Z_at_axis) and psi.min()!) self._psi_i_min = minimize( lambda x: self.psi( x[0], x[1], ), x0=[R_at_axis, Z_at_axis], ) # set on-axis and boundary fluxes self._psi0 = self._psi_i_min["fun"] self._psi1 = psi_edge # interpolate toroidal field function, pressure profile and q-profile on unifrom flux grid from axis to boundary flux_grid = xp.linspace(self._psi0, self._psi1, g_profile.size) smooth_step = int(1 / (self.params["flux_resolution"] * 0.01)) self._g_i = UnivariateSpline( flux_grid[::smooth_step], g_profile[::smooth_step], k=self.params["degree_for_flux"], s=0.0, ext=3, ) self._p_i = UnivariateSpline( flux_grid[::smooth_step], p_profile[::smooth_step], k=self.params["degree_for_flux"], s=0.0, ext=3, ) self._q_i = UnivariateSpline( flux_grid[::smooth_step], q_profile[::smooth_step], k=self.params["degree_for_flux"], s=0.0, ext=3, ) @property def units(self) -> Units: """All Struphy units.""" return self._units @property def boundary_pts_R(self): """R-coordinates of plasma boundary contour.""" return self._rbs @property def boundary_pts_Z(self): """Z-coordinates of plasma boundary contour.""" return self._zbs @property def limiter_pts_R(self): """R-coordinates of limiter contour.""" return self._rlims @property def limiter_pts_Z(self): """Z-coordinates of limiter contour.""" return self._zlims @property def range_R(self): """range of R of flux data.""" return self._r_range @property def range_Z(self): """range of Z of flux data.""" return self._z_range # =============================================================== # abstract properties # =============================================================== @property def psi_range(self): """Psi on-axis and at plasma boundary.""" return [self._psi0, self._psi1] @property def psi_axis_RZ(self): """Location of magnetic axis in R-Z-coordinates.""" return list(self._psi_i_min["x"]) # =============================================================== # 1d flux function profiles f = f(psi) # ===============================================================
[docs] def q_psi(self, psi, der=0): """Safety factor q = q(psi).""" out = self._q_i(psi, nu=der) # remove all "dimensions" for point-wise evaluation if isinstance(psi, (int, float)): assert out.ndim == 0 out = out.item() return out
[docs] def g_psi(self, psi, der=0): """Toroidal field function g = g(psi).""" out = self._g_i(psi, nu=der) # remove all "dimensions" for point-wise evaluation if isinstance(psi, (int, float)): assert out.ndim == 0 out = out.item() return out
[docs] def p_psi(self, psi, der=0): """Pressure profile g = g(psi).""" out = self._p_i(psi, nu=der) # remove all "dimensions" for point-wise evaluation if isinstance(psi, (int, float)): assert out.ndim == 0 out = out.item() # rescale to Struphy units out /= self.units.p return out
[docs] def n_psi(self, psi, der=0): """Number density profile n = n(psi).""" assert der >= 0 and der <= 1, "Only first derivative available!" n1, n2, na = self.params["n1"], self.params["n2"], self.params["na"] psi_norm = (psi - self._psi0) / (self._psi1 - self._psi0) if der == 0: out = (1 - na) * (1 - psi_norm**n1) ** n2 + na else: out = ( -(1 - na) * n1 * n2 / (self._psi1 - self._psi0) * (1 - psi_norm**n1) ** (n2 - 1) * psi_norm ** (n1 - 1) ) return out
# =============================================================== # abstract methods # ===============================================================
[docs] def psi(self, R, Z, dR=0, dZ=0): """Poloidal flux function psi = psi(R, Z) in units Tesla*m^2.""" is_float = all(isinstance(v, (int, float)) for v in [R, Z]) out = self._psi_i(R, Z, dx=dR, dy=dZ, grid=False) # remove all "dimensions" for point-wise evaluation if is_float: assert out.ndim == 0 out = out.item() # rescale to Struphy units out /= self.units.B * self.units.x**2 return out
[docs] def g_tor(self, R, Z, dR=0, dZ=0): """Toroidal field function g = g(R, Z) in units Tesla*m.""" if dR == 0 and dZ == 0: out = self.g_psi(self.psi(R, Z, dR=0, dZ=0), der=0) elif dR == 1 and dZ == 0: out = self.g_psi(self.psi(R, Z, dR=0, dZ=0), der=1) * self.psi(R, Z, dR=1, dZ=0) elif dR == 0 and dZ == 1: out = self.g_psi(self.psi(R, Z, dR=0, dZ=0), der=1) * self.psi(R, Z, dR=0, dZ=1) # rescale to Struphy units out /= self.units.B * self.units.x return out
[docs] def p_xyz(self, x, y, z): """Pressure p = p(x, y, z) in units 1 Tesla^2/mu_0.""" R = xp.sqrt(x**2 + y**2) Z = 1 * z out = self.p_psi(self.psi(R, Z)) # rescale to Struphy units out /= self.units.p return out
[docs] def n_xyz(self, x, y, z): """Number density in physical space. Units from parameter file.""" R = xp.sqrt(x**2 + y**2) Z = 1 * z out = self.n_psi(self.psi(R, Z)) return out
[docs] class GVECequilibrium(NumericalMHDequilibrium): r""" Numerical equilibrium via an interface to `pygvec <https://gvec.readthedocs.io/latest/index.html>`_. Density profile can be set to .. math:: n(r)= \left\{\begin{aligned} \ &n_0 p(r) \quad &&\textnormal{if density_profile = 'pressure'}\,, \ &n_1+\left(1-\left(\frac{r}{a}\right)^2\right) (n_0-n_1) \quad &&\textnormal{if density_profile = 'parabolic'}\,, \ &n_1+\left(1-\frac{r}{a}\right) (n_0-n_1) \quad &&\textnormal{if density_profile = 'linear'}\,, \end{aligned}\right. \,. Parameters ---------- rel_path : bool Whether dat_file (json_file) are relative to "<struphy_path>/fields_background/mhd_equil/gvec/", or are absolute paths (default: True). dat_file : str Path to .dat file (default: "/run_01/CIRCTOK_State_0000_00000000.dat"). param_file : str Path to Gvec parameter.ini file (default: /run_01/parameter.ini). use_boozer : bool Whether to use Boozer coordinates (default: False). use_nfp : bool Whether the field periods of the stellarator should be used in the mapping, i.e. phi = 2*pi*eta3 / nfp (piece of cake) (default: True). rmin : float Between [0, 1), radius (in logical space) of the domian hole around the magnetic axis (default: rmin=0.01). num_elements : tuple[int] Number of cells in each direction used for interpolation of the mapping (default: (16, 16, 16)). degree : tuple[int] Spline degree in each direction used for interpolation of the mapping (default: (3, 3, 3)). density_profile : str 'parabolic' for a parabolic density profile, 'linear' for a linear density profile or 'pressure' for a density profile proportional to pressure p0 : float constant added to the pressure (default: 0.) n0 : float shape factor for ion number density profile (default: 0.2). n1 : float shape factor for ion number density profile (default: 0.). base_units : BaseUnits All Struphy units. If None, no rescaling of output is performed. """ def __init__( self, rel_path: bool = True, # dat_file: str = "run_01/CIRCTOK_State_0000_00000000.dat", # dat_file: str = "run_02/W7X_State_0000_00000000.dat", dat_file: str = "run_03/NEO-SPITZER_State_0000_00003307.dat", # param_file: str = "run_01/parameter.ini", # param_file: str = "run_02/parameter-w7x.ini", param_file: str = "run_03/parameter-fig8.ini", use_boozer: bool = False, use_nfp: bool = True, rmin: float = 0.01, num_elements: tuple[int] = (16, 16, 16), degree: tuple[int] = (3, 3, 3), density_profile: str = "pressure", p0: float = 0.1, n0: float = 0.2, n1: float = 0.0, base_units: BaseUnits = None, ): # use params setter self.params = copy.deepcopy(locals()) # install if necessary gvec_spec = importlib.util.find_spec("gvec") if gvec_spec is None: import pytest with pytest.raises(SystemExit) as exc: if rank == 0: logger.info("Simulation aborted, gvec must be installed (pip install gvec)!") sys.exit(1) if rank == 0: logger.info(f"{exc.value.code =}") import gvec from struphy.geometry.domains import GVECunit # units self._units = Units(base=base_units) if base_units is None: self.units._j = 1.0 self.units._p = 1.0 warnings.warn( f"{self.units =}, no rescaling performed in GVEC output.", ) # path assert self.params["dat_file"][-4:] == ".dat" assert self.params["param_file"][-4:] == ".ini" if self.params["rel_path"]: gvec_path = os.path.join( struphy.__path__[0], "fields_background", "mhd_equil", "gvec", ) dat_file = os.path.join( gvec_path, self.params["dat_file"], ) param_file = os.path.join( gvec_path, self.params["param_file"], ) else: dat_file = self.params["dat_file"] param_file = self.params["param_file"] # gvec object self._state = gvec.State(param_file, dat_file) if self.params["use_nfp"]: self._nfp = self._state.nfp else: self._nfp = 1 # struphy domain object self._domain = GVECunit(self) @property def numerical_domain(self): """Domain object that characterizes the mapping from the logical to the physical domain.""" return self._domain @property def state(self): """Gvec state object.""" return self._state @property def units(self) -> Units: """All Struphy units.""" return self._units
[docs] @profile def bv(self, *etas, squeeze_out=False): """Contra-variant (vector field) magnetic field on logical cube [0, 1]^3 in Tesla / meter.""" # evaluate ev, flat_eval = self._gvec_evaluations(*etas) bt = "B_contra_t" bz = "B_contra_z" if self.params["use_boozer"]: bt += "_B" bz += "_B" self.state.compute(ev, bt, bz) bv_2 = getattr(ev, bt).data / (2 * xp.pi) bv_3 = getattr(ev, bz).data / (2 * xp.pi) * self._nfp out = (xp.zeros_like(bv_2), bv_2, bv_3) # apply struphy units for o in out: o /= self.units.B / self.units.x return out
[docs] @profile def jv(self, *etas, squeeze_out=False): """Contra-variant (vector field) current density (=curl B) on logical cube [0, 1]^3 in Ampere / meter^3.""" # evaluate ev, flat_eval = self._gvec_evaluations(*etas) jr = "J_contra_r" jt = "J_contra_t" jz = "J_contra_z" self.state.compute(ev, jr, jt, jz) rmin = self.params["rmin"] jv_1 = ev.J_contra_r.data / (1.0 - rmin) jv_2 = ev.J_contra_t.data / (2 * xp.pi) jv_3 = ev.J_contra_z.data / (2 * xp.pi) * self._nfp if self.params["use_boozer"]: warnings.warn("GVEC current density in Boozer coords not yet implemented, set to zero.") # jr += "_B" # jt += "_B" # jz += "_B" jv_1[:] = 0.0 jv_2[:] = 0.0 jv_3[:] = 0.0 out = (jv_1, jv_2, jv_3) # apply struphy units for o in out: o /= self.units.j / self.units.x return out
[docs] @profile def p0(self, *etas, squeeze_out=False): """0-form equilibrium pressure on logical cube [0, 1]^3.""" # evaluate ev, flat_eval = self._gvec_evaluations(*etas) self.state.compute(ev, "p") if not flat_eval: eta2 = etas[1] eta3 = etas[2] if isinstance(eta2, xp.ndarray): if eta2.ndim == 3: eta2 = eta2[0, :, 0] eta3 = eta3[0, 0, :] tmp, _1, _2 = xp.meshgrid(ev.p.data, eta2, eta3, indexing="ij") else: tmp = ev.p.data return self.params["p0"] + tmp / self.units.p
[docs] @profile def n0(self, *etas, squeeze_out=False): """0-form equilibrium density on logical cube [0, 1]^3.""" if self.params["density_profile"] == "pressure": return self.params["n0"] * self.p0(*etas) else: # flat (marker) evaluation if len(etas) == 1: assert etas[0].ndim == 2 eta1 = etas[0][:, 0] eta2 = etas[0][:, 1] eta3 = etas[0][:, 2] flat_eval = True # meshgrid evaluation else: assert len(etas) == 3 eta1 = etas[0] eta2 = etas[1] eta3 = etas[2] flat_eval = False rmin = self.params["rmin"] r = rmin + eta1 * (1.0 - rmin) if self.params["density_profile"] == "parabolic": return self.params["n1"] + (1.0 - r**2) * (self.params["n0"] - self.params["n1"]) elif self.params["density_profile"] == "linear": return self.params["n1"] + (1.0 - r) * (self.params["n0"] - self.params["n1"]) else: raise ValueError("wrong type of density profile for GVEC equilibrium")
[docs] @profile def gradB1(self, *etas, squeeze_out=False): """1-form gradient of magnetic field strength on logical cube [0, 1]^3.""" raise NotImplementedError( "1-form gradient of magnetic field of GVECequilibrium is not implemented", )
@profile def _gvec_evaluations(self, *etas): """Call gvec.Evaluations with Struphy coordinates.""" import gvec # flat (marker) evaluation if len(etas) == 1: assert etas[0].ndim == 2 eta1 = etas[0][:, 0] eta2 = etas[0][:, 1] eta3 = etas[0][:, 2] flat_eval = True # meshgrid evaluation else: assert len(etas) == 3 etas = list(etas) for i, eta in enumerate(etas): if isinstance(eta, (float, int)): etas[i] = xp.array((eta,)) assert etas[0].ndim == etas[1].ndim == etas[2].ndim if etas[0].ndim == 1: eta1 = etas[0] eta2 = etas[1] eta3 = etas[2] elif etas[0].ndim == 3: # assuming ij-indexing of meshgrid eta1 = etas[0][:, 0, 0] eta2 = etas[1][0, :, 0] eta3 = etas[2][0, 0, :] flat_eval = False rmin = self.params["rmin"] # gvec coordinates rho = rmin + eta1 * (1.0 - rmin) theta = 2 * xp.pi * eta2 zeta = 2 * xp.pi * eta3 # evaluate if self.params["use_boozer"]: ev = gvec.EvaluationsBoozer(rho=rho, theta_B=theta, zeta_B=zeta, state=self.state) else: ev = gvec.Evaluations(rho=rho, theta=theta, zeta=zeta, state=self.state) return ev, flat_eval
[docs] class DESCequilibrium(NumericalMHDequilibrium): """ Numerical equilibrium via an interface to the `DESC code <https://desc-docs.readthedocs.io/en/latest/index.html>`_. Parameters ---------- eq_name : str Name of existing DESC equilibrium object (.h5 or binary). rel_path : bool Whether to add "<struphy_path>/fields_background/mhd_equil/desc/" before eq_name (default: False). use_pest : bool Whether to use straigh-field line coordinates (PEST) (default: False). use_nfp : bool Whether the field periods of the stellarator should be used in the mapping, i.e. phi = 2*pi*eta3 / nfp (piece of cake) (default: True). rmin : float Between [0, 1), radius (in logical space) of the domian hole around the magnetic axis (default: rmin=0.01). num_elements : tuple[int] Number of cells in each direction used for interpolation of the mapping (default: (16, 16, 16)). degree : tuple[int] Spline degree in each direction used for interpolation of the mapping (default: (3, 3, 3)). T_kelvin : float maximum of temperature in Kelvin (default: 100000). base_units : BaseUnits Struphy base units. If None, no rescaling of output is performed. """ def __init__( self, eq_name: str = None, rel_path: bool = False, use_pest: bool = False, use_nfp: bool = True, rmin: float = 0.01, num_elements: tuple[int] = (16, 16, 50), degree: tuple[int] = (3, 3, 3), T_kelvin: float = 100000.0, base_units: BaseUnits = None, ): # use params setter self.params = copy.deepcopy(locals()) t = time() # install if necessary desc_spec = importlib.util.find_spec("desc") if desc_spec is None: if rank == 0: logger.info("Simulation aborted, desc-opt must be installed!") logger.info("Install with:\npip install desc-opt") sys.exit(1) import desc logger.debug(f"DESC import: {time() - t} seconds") from struphy.geometry.domains import DESCunit # units self._units = Units(base=base_units) if base_units is None: self.units._j = 1.0 self.units._p = 1.0 warnings.warn( f"{self.units =}, no rescaling performed in DESC output.", ) # path if self.params["rel_path"]: eq_name = os.path.join( struphy.__path__[0], "fields_background/mhd_equil/desc", self.params["eq_name"], ) else: eq_name = self.params["eq_name"] t = time() # desc object if eq_name is None: self._eq = desc.examples.get("W7-X") else: self._eq = desc.io.load(eq_name) logger.debug(f"Eq. load: {time() - t} seconds") self._rmin = self.params["rmin"] self._use_nfp = self.params["use_nfp"] # straight field line coords if self.params["use_pest"]: raise ValueError( "PEST coordinates not yet implemented in desc interface.", ) mapping = "unit_pest" else: mapping = "unit" # struphy domain object self._domain = DESCunit(self) # create cache self._cache = { "bv": {"grids": [], "outs": []}, "jv": {"grids": [], "outs": []}, "gradB1": {"grids": [], "outs": []}, } @property def numerical_domain(self): """Domain object that characterizes the mapping from the logical to the physical domain.""" return self._domain @property def eq(self): """DESC object.""" return self._eq @property def rmin(self): """Radius of domain hole around magnetic axis.""" return self._rmin @property def use_nfp(self): """True (=default) if to use the field periods of the stellarator in the mapping, i.e. phi = 2*pi*eta3 / nfp (piece of cake). """ return self._use_nfp @property def units(self) -> Units: """All Struphy units.""" return self._units
[docs] @profile def bv(self, *etas, squeeze_out=False): """Contra-variant (vector field) magnetic field on logical cube [0, 1]^3 in Tesla / meter.""" # check if already cached cached = False if len(self._cache["bv"]["grids"]) > 0: for i, grid in enumerate(self._cache["bv"]["grids"]): if len(grid) == len(etas): li = [] for gi, ei in zip(grid, etas): if gi.shape == ei.shape: li += [xp.allclose(gi, ei)] else: li += [False] if all(li): cached = True break if cached: out = self._cache["bv"]["outs"][i] # logger.info(f'Used cached bv at {i = }.') else: out = self._eval_bv(*etas, squeeze_out=squeeze_out) self._cache["bv"]["grids"] += [etas] self._cache["bv"]["outs"] += [out] else: # logger.info('No bv grids yet.') out = self._eval_bv(*etas, squeeze_out=squeeze_out) self._cache["bv"]["grids"] += [etas] self._cache["bv"]["outs"] += [out] return out
@profile def _eval_bv(self, *etas, squeeze_out=False): # flat (marker) evaluation if len(etas) == 1: assert etas[0].ndim == 2 eta1 = etas[0][:, 0] eta2 = etas[0][:, 1] eta3 = etas[0][:, 2] flat_eval = True # meshgrid evaluation else: assert len(etas) == 3 eta1 = etas[0] eta2 = etas[1] eta3 = etas[2] flat_eval = False nfp = self.eq.NFP if not self.use_nfp: nfp = 1 out = [] for var in ["B^rho", "B^theta", "B^zeta"]: tmp1 = self.desc_eval(var, eta1, eta2, eta3, flat_eval=flat_eval, nfp=nfp) # copy to set writebale tmp = tmp1.copy() tmp.flags["WRITEABLE"] = True # pull back to eta-coordinates if var == "B^rho": tmp /= 1.0 - self.rmin elif var == "B^theta": tmp /= 2.0 * xp.pi elif var == "B^zeta": tmp /= 2.0 * xp.pi / nfp # adjust for Struphy units tmp /= self.units.B / self.units.x out += [tmp] return out
[docs] @profile def jv(self, *etas, squeeze_out=False): """Contra-variant (vector field) current density (=curl B) on logical cube [0, 1]^3 in Ampere / meter^3. """ # check if already cached cached = False if len(self._cache["jv"]["grids"]) > 0: for i, grid in enumerate(self._cache["jv"]["grids"]): if len(grid) == len(etas): li = [] for gi, ei in zip(grid, etas): if gi.shape == ei.shape: li += [xp.allclose(gi, ei)] else: li += [False] if all(li): cached = True break if cached: out = self._cache["jv"]["outs"][i] # logger.info(f'Used cached jv at {i = }.') else: out = self._eval_jv(*etas, squeeze_out=squeeze_out) self._cache["jv"]["grids"] += [etas] self._cache["jv"]["outs"] += [out] else: # logger.info('No jv grids yet.') out = self._eval_jv(*etas, squeeze_out=squeeze_out) self._cache["jv"]["grids"] += [etas] self._cache["jv"]["outs"] += [out] return out
@profile def _eval_jv(self, *etas, squeeze_out=False): # flat (marker) evaluation if len(etas) == 1: assert etas[0].ndim == 2 eta1 = etas[0][:, 0] eta2 = etas[0][:, 1] eta3 = etas[0][:, 2] flat_eval = True # meshgrid evaluation else: assert len(etas) == 3 eta1 = etas[0] eta2 = etas[1] eta3 = etas[2] flat_eval = False nfp = self.eq.NFP if not self.use_nfp: nfp = 1 out = [] for var in ["J^rho", "J^theta", "J^zeta"]: tmp1 = self.desc_eval(var, eta1, eta2, eta3, flat_eval=flat_eval, nfp=nfp) # copy to set writebale tmp = tmp1.copy() tmp.flags["WRITEABLE"] = True # pull back to eta-coordinates if var == "J^rho": tmp /= 1.0 - self.rmin elif var == "J^theta": tmp /= 2.0 * xp.pi elif var == "J^zeta": tmp /= 2.0 * xp.pi / nfp # adjust for Struphy units tmp /= self.units.j / self.units.x out += [tmp] return out
[docs] @profile def p0(self, *etas, squeeze_out=False): """0-form equilibrium pressure on logical cube [0, 1]^3 in Pascal.""" # flat (marker) evaluation if len(etas) == 1: assert etas[0].ndim == 2 eta1 = etas[0][:, 0] eta2 = etas[0][:, 1] eta3 = etas[0][:, 2] flat_eval = True # meshgrid evaluation else: assert len(etas) == 3 eta1 = etas[0] eta2 = etas[1] eta3 = etas[2] flat_eval = False out1 = self.desc_eval("p", eta1, eta2, eta3, flat_eval=flat_eval) # copy to set writebale out = out1.copy() out.flags["WRITEABLE"] = True # eliminate negative values out[out < 0.0] = 1e-14 out /= self.units.p return out
[docs] @profile def n0(self, *etas, squeeze_out=False): """0-form equilibrium density on logical cube [0, 1]^3.""" # flat (marker) evaluation if len(etas) == 1: assert etas[0].ndim == 2 eta1 = etas[0][:, 0] eta2 = etas[0][:, 1] eta3 = etas[0][:, 2] flat_eval = True # meshgrid evaluation else: assert len(etas) == 3 eta1 = etas[0] eta2 = etas[1] eta3 = etas[2] flat_eval = False # Ori 25/06/24 - Add option to set temperature maximum and then set density accordingly, still proportional to pressure k_Boltzmann = 1.38 * 1e-23 p0_pascal = self.p0(*etas, squeeze_out=squeeze_out) * self.units.p # computes pressure in units of 1 Pa # density in default units, n=1 --> 10^20 m^(-3) return p0_pascal / (self.params["T_kelvin"] * k_Boltzmann) / self.units.n
[docs] @profile def gradB1(self, *etas, squeeze_out=False): """1-form gradient of magnetic field strength on logical cube [0, 1]^3.""" # check if already cached cached = False if len(self._cache["gradB1"]["grids"]) > 0: for i, grid in enumerate(self._cache["gradB1"]["grids"]): if len(grid) == len(etas): li = [] for gi, ei in zip(grid, etas): if gi.shape == ei.shape: li += [xp.allclose(gi, ei)] else: li += [False] if all(li): cached = True break if cached: out = self._cache["gradB1"]["outs"][i] else: out = self._eval_gradB1(*etas, squeeze_out=squeeze_out) self._cache["gradB1"]["grids"] += [etas] self._cache["gradB1"]["outs"] += [out] else: # logger.info('No bv grids yet.') out = self._eval_gradB1(*etas, squeeze_out=squeeze_out) self._cache["gradB1"]["grids"] += [etas] self._cache["gradB1"]["outs"] += [out] return out
@profile def _eval_gradB1(self, *etas, squeeze_out=False): # flat (marker) evaluation if len(etas) == 1: assert etas[0].ndim == 2 eta1 = etas[0][:, 0] eta2 = etas[0][:, 1] eta3 = etas[0][:, 2] flat_eval = True # meshgrid evaluation else: assert len(etas) == 3 eta1 = etas[0] eta2 = etas[1] eta3 = etas[2] flat_eval = False nfp = self.eq.NFP if not self.use_nfp: nfp = 1 out = [] for var in ["|B|_r", "|B|_t", "|B|_z"]: tmp1 = self.desc_eval(var, eta1, eta2, eta3, flat_eval=flat_eval, nfp=nfp) # copy to set writebale tmp = tmp1.copy() tmp.flags["WRITEABLE"] = True # pull back to eta-coordinates if var == "|B|_r": tmp *= 1.0 - self.rmin elif var == "|B|_t": tmp *= 2.0 * xp.pi elif var == "|B|_z": tmp *= 2.0 * xp.pi / nfp # adjust for Struphy units tmp /= self.units.B out += [tmp] return out
[docs] @profile def desc_eval( self, var: str, e1: xp.ndarray, e2: xp.ndarray, e3: xp.ndarray, flat_eval: bool = False, nfp: int = 1, ): """Transform the input grids to conform to desc's .compute method and evaluate var. Parameters ---------- var : str Desc equilibrium quantitiy to evaluate, from `https://desc-docs.readthedocs.io/en/latest/variables.html#list-of-variables`_. e1, e2, e3 : xp.ndarray Input grids, either 1d or 3d. flat_eval : bool Whether to do flat (marker) evaluation. nfp : int Number of stellarator field periods to be used in the mapping (nfp=1 uses the whole stellarator). """ import warnings from desc.grid import Grid warnings.filterwarnings("ignore") ttime = time() # Fix issue 353 with float dummy etas e1 = xp.array([e1]) if isinstance(e1, float) else e1 e2 = xp.array([e2]) if isinstance(e2, float) else e2 e3 = xp.array([e3]) if isinstance(e3, float) else e3 # transform input grids if e1.ndim == 3: assert e1.shape == e2.shape == e3.shape rho = self.rmin + e1[:, 0, 0] * (1.0 - self.rmin) theta = 2 * xp.pi * e2[0, :, 0] zeta = 2 * xp.pi * e3[0, 0, :] / nfp else: assert e1.ndim == e2.ndim == e3.ndim == 1 rho = self.rmin + e1 * (1.0 - self.rmin) theta = 2 * xp.pi * e2 zeta = 2 * xp.pi * e3 / nfp # eval type if flat_eval: assert rho.size == theta.size == zeta.size r = rho t = theta z = zeta else: r, t, z = xp.meshgrid(rho, theta, zeta, indexing="ij") r = r.flatten() t = t.flatten() z = z.flatten() nodes = xp.stack((r, t, z)).T grid_3d = Grid(nodes, spacing=xp.ones_like(nodes), jitable=False) # compute output corresponding to the generated desc grid node_values = self.eq.compute( var, grid=grid_3d, override_grid=False, ) if flat_eval: out = node_values[var] rho1 = grid_3d.nodes[:, 0] theta1 = grid_3d.nodes[:, 1] zeta1 = grid_3d.nodes[:, 2] else: out = node_values[var].reshape( (rho.size, theta.size, zeta.size), order="C", ) rho1 = ( grid_3d.nodes[:, 0].reshape( (rho.size, theta.size, zeta.size), order="C", ) )[:, 0, 0] theta1 = ( grid_3d.nodes[:, 1].reshape( (rho.size, theta.size, zeta.size), order="C", ) )[0, :, 0] zeta1 = ( grid_3d.nodes[:, 2].reshape( (rho.size, theta.size, zeta.size), order="C", ) )[0, 0, :] # make sure the desc grid is correct assert xp.all(rho == rho1) assert xp.all(theta == theta1) assert xp.all(zeta == zeta1) # import sys logger.debug(f"\n{nfp =}") logger.debug(f"{self.eq.axis =}") logger.debug(f"{rho.size =}") logger.debug(f"{theta.size =}") logger.debug(f"{zeta.size =}") logger.debug(f"{grid_3d.num_rho =}") logger.debug(f"{grid_3d.num_theta =}") logger.debug(f"{grid_3d.num_zeta =}") # logger.debug(f'\n{grid_3d.nodes[:, 0] = }') # logger.debug(f'\n{grid_3d.nodes[:, 1] = }') # logger.debug(f'\n{grid_3d.nodes[:, 2] = }') logger.debug(f"\n{rho =}") logger.debug(f"{rho1 =}") logger.debug(f"\n{theta =}") logger.debug(f"{theta1 =}") logger.debug(f"\n{zeta =}") logger.debug(f"{zeta1 =}") # make c-contiguous out = xp.ascontiguousarray(out) logger.info(f"desc_eval for {var}: {time() - ttime} seconds") return out
[docs] class ConstantVelocity(CartesianFluidEquilibrium): r"""Constant-velocity background equilibrium. Represents a simple fluid equilibrium with a spatially-constant bulk velocity (``ux``, ``uy``, ``uz``) and configurable density/pressure profiles. The class provides the following instance methods used by the solver: - ``u_xyz(x, y, z)``: return the ion bulk velocity components matching the shape of the inputs ``x, y, z``. If ``velocity_step_function_in_y`` is set the x-velocity is applied only for ``y < velocity_step_function_in_y``. - ``n_xyz(x, y, z)``: return the number-density according to ``density_profile``. Supported profiles are: - ``"constant"`` : returns ``n`` everywhere. - ``"affine"`` : returns ``n + n1 * x``. - ``"gaussian_xy"`` : returns ``n * exp(-(x**2 + y**2)/p0)``. - ``"step_function_xy"`` : returns a step-like density using the optional bounds ``upper_x``, ``lower_x``, ``upper_y``, and ``lower_y`` (expects a ``Cuboid`` domain for meaningful bounds). - ``p_xyz(x, y, z)``: return an isotropic pressure (constant ``p0``). Parameters ---------- ux, uy, uz : float Bulk velocity components in x, y and z directions. velocity_step_function_in_y : float or None If provided, x-velocity is applied only for ``y < value``. n : float Base number density. n1 : float Linear coefficient used when ``density_profile == 'affine'``. density_profile : str One of ``'constant'``, ``'affine'``, ``'gaussian_xy'``, or ``'step_function_xy'``. upper_x, lower_x, upper_y, lower_y : float or None Bounds used by the ``'step_function_xy'`` profile (optional). p0 : float Reference pressure (also used as the Gaussian width parameter). Notes ----- The input arrays ``x, y, z`` are treated elementwise and the returned arrays match their shapes. Small numerical floors (e.g. ``1e-8``) may be used internally to avoid exact zeros where necessary. """ def __init__( self, ux: float = 0.0, uy: float = 0.0, uz: float = 0.0, velocity_step_function_in_y: float | None = None, n: float = 1.0, n1: float = 0.0, density_profile: str = "constant", upper_x: float | None = None, lower_x: float | None = None, upper_y: float | None = None, lower_y: float | None = None, p0: float = 1.0, ): # use params setter self.params = copy.deepcopy(locals()) # equilibrium ion velocity
[docs] def u_xyz(self, x, y, z): """Ion velocity.""" if self.params["velocity_step_function_in_y"] is None: ux = 0 * x + self.params["ux"] else: ux = 1e-8 + 0 * x mask = y < self.params["velocity_step_function_in_y"] ux[mask] = self.params["ux"] uy = 0 * x + self.params["uy"] uz = 0 * x + self.params["uz"] return ux, uy, uz
# equilibrium pressure
[docs] def p_xyz(self, x, y, z): """Plasma pressure.""" pp = 0 * x + self.params["p0"] return pp
# equilibrium number density
[docs] def n_xyz(self, x, y, z): """Number density.""" if self.params["density_profile"] == "constant": return self.params["n"] + 0 * x elif self.params["density_profile"] == "affine": return self.params["n"] + self.params["n1"] * x elif self.params["density_profile"] == "gaussian_xy": return self.params["n"] * xp.exp(-(x**2 + y**2) / self.params["p0"]) elif self.params["density_profile"] == "step_function_xy": from struphy.geometry.domains import Cuboid assert isinstance(self.domain, Cuboid) l1 = self.domain.params["l1"] r1 = self.domain.params["r1"] l2 = self.domain.params["l2"] r2 = self.domain.params["r2"] out = 1e-8 + 0 * x if self.params["upper_x"] is not None: mask_x_upper = x < self.params["upper_x"] else: mask_x_upper = xp.ones_like(x, dtype=bool) if self.params["lower_x"] is not None: mask_x_lower = x > self.params["lower_x"] else: mask_x_lower = xp.ones_like(x, dtype=bool) if self.params["upper_y"] is not None: mask_y_upper = y < self.params["upper_y"] else: mask_y_upper = xp.ones_like(y, dtype=bool) if self.params["lower_y"] is not None: mask_y_lower = y > self.params["lower_y"] else: mask_y_lower = xp.ones_like(y, dtype=bool) mask_x = xp.logical_and(mask_x_upper, mask_x_lower) mask_y = xp.logical_and(mask_y_upper, mask_y_lower) mask = xp.logical_and(mask_x, mask_y) out[mask] = self.params["n"] return out
[docs] class HomogenSlabITG(CartesianFluidEquilibriumWithB): r""" Homogenous slab equilibrium with temperature gradient in x, B-field in z: .. math:: \mathbf B &= B_{0z}\,\mathbf e_z = const.\,, \qquad n &= n_0 = const. p &= p_0*(1 - \frac{x}{L_x} ) + p_\textrm{min}\,, \mathbf u &= - \epsilon \frac{p_0}{L_x} \mathbf e_y\,. Units are those defned in the parameter file (through :class:`~struphy.io.options.BaseUnits`). Parameters ---------- B0z : float z-component of magnetic field (default: 1.). Lx : float Domain length in x; 1/Lx is the temperature scale length. p0 : float Constant pressure coefficient (default: 1.). pmin : float Minimum pressure at x = Lx. n0 : float Ion number density (default: 1.). eps : float The unit factor :math:`1/(\hat\Omega_i \hat t)`. Note ---- In the parameter .yml, use the following in the section ``fluid_background``:: HomogenSlabITG : B0z : 1. Lx : 1. p0 : 1. pmin : .1 n0 : 1. eps : .1 """ def __init__( self, B0z: float = 1.0, Lx: float = 6.0, p0: float = 1.0, pmin: float = 0.1, n0: float = 1.0, eps: float = 0.1, ): # use params setter self.params = copy.deepcopy(locals()) # =============================================================== # profiles on physical domain # =============================================================== # equilibrium magnetic field (curl of equilibrium vector potential)
[docs] def b_xyz(self, x, y, z): """Magnetic field.""" bx = 0 * x by = 0 * x bz = self.params["B0z"] - 0 * x return bx, by, bz
# equilibrium ion velocity
[docs] def u_xyz(self, x, y, z): """Ion velocity.""" ux = 0 * x uy = -self.params["eps"] * self.params["p0"] / self.params["Lx"] - 0 * x uz = 0 * x return ux, uy, uz
# equilibrium pressure
[docs] def p_xyz(self, x, y, z): """Plasma pressure.""" pp = self.params["p0"] * (1.0 - x / self.params["Lx"]) + self.params["pmin"] return pp
# equilibrium number density
[docs] def n_xyz(self, x, y, z): """Number density.""" nn = self.params["n0"] - 0 * x return nn
# equilibrium current (curl of equilibrium magnetic field)
[docs] def gradB_xyz(self, x, y, z): """Field strength gradient.""" gradBx = 0 * x gradBy = 0 * x gradBz = 0 * x return gradBx, gradBy, gradBz
[docs] class CircularTokamak(AxisymmMHDequilibrium): r""" Tokamak MHD equilibrium with circular concentric flux surfaces. For a cylindrical coordinate system :math:`(R, \phi, Z)` with transformation formulae .. math:: x &= R\cos(\phi)\,, &&R = \sqrt{x^2 + y^2}\,, y &= R\sin(\phi)\,, &&\phi = \arctan(y/x)\,, z &= Z\,, &&Z = z\,, the magnetic field is given by .. math:: \mathbf B = \nabla\psi\times\nabla\phi+g\nabla\phi\,, where :math:`g=g(R, Z)=B_0R_0=const.` is the toroidal field function, :math:`R_0` the major radius of the torus and :math:`B_0` the on-axis magnetic field. The flux :math:`\psi=\psi(R, Z)` is given by .. math:: \psi=a R_0 B_p \frac{(R-R_0)^2+Z^2}{2 a^2}\, for the given constants. The pressure profile and the number density profile are not specified Units are those defined in the parameter file (through :class:`~struphy.io.options.BaseUnits`). Parameters ---------- a : float Minor radius of torus (default: 1.). R0 : float Major radius of torus (default: 2.). B0 : float On-axis (r=0) toroidal magnetic field (default: 10.). Bp : float Poloidal magnetic field (default: 12.5). Note ---- In the parameter .yml, use the following in the section ``fluid_background``:: CircularTokamak : a : 1. # minor radius R0 : 2. # major radius B0 : 10. # on-axis toroidal magnetic field Bp : 12.5 # poloidal magnetic field """ def __init__( self, a: float = 1.0, R0: float = 2.0, B0: float = 10.0, Bp: float = 12.5, ): # use params setter self.params = copy.deepcopy(locals()) self._psi0 = 0.0 self._psi1 = self.params["a"] * self.params["R0"] * self.params["Bp"] * 0.5 # =============================================================== # abstract properties # =============================================================== @property def psi_range(self): """Psi on-axis and at plasma boundary.""" return [self._psi0, self._psi1] @property def psi_axis_RZ(self): """Location of magnetic axis in R-Z-coordinates.""" return [self.params["R0"], 0.0] # =============================================================== # abstract methods # ===============================================================
[docs] def psi(self, R, Z, dR=0, dZ=0): """Poloidal flux function psi = psi(R, Z).""" if dR == 0 and dZ == 0: out = ( self.params["a"] * self.params["R0"] * self.params["Bp"] * ((R - self.params["R0"]) ** 2 + Z**2) / (2 * self.params["a"] ** 2) ) else: if dR == 1 and dZ == 0: out = self.params["R0"] * self.params["Bp"] * (R - self.params["R0"]) / (self.params["a"]) elif dR == 0 and dZ == 1: out = self.params["R0"] * self.params["Bp"] * (Z) / (self.params["a"]) elif dR == 2 and dZ == 0: out = self.params["R0"] * self.params["Bp"] / (self.params["a"]) elif dR == 0 and dZ == 2: out = self.params["R0"] * self.params["Bp"] / (self.params["a"]) elif dR == 1 and dZ == 1: out = 0 * R + 0 * Z else: raise NotImplementedError( "Only combinations (dR=0, dZ=0), (dR=1, dZ=0), (dR=0, dZ=1), (dR=2, dZ=0), (dR=0, dZ=2) and (dR=1, dZ=1) possible!", ) return -out
[docs] def g_tor(self, R, Z, dR=0, dZ=0): """Toroidal field function g = g(R, Z).""" if dR == 0 and dZ == 0: out = self.params["B0"] * self.params["R0"] elif dR == 1 and dZ == 0: out = 0 * R elif dR == 0 and dZ == 1: out = 0 * Z else: raise NotImplementedError( "Only combinations (dR=0, dZ=0), (dR=1, dZ=0) and (dR=0, dZ=1) possible!", ) return -out
[docs] def p_xyz(self, x, y, z): """Pressure p = p(x, y, z).""" pp = 0.0 * x + 1.0 return pp
[docs] def n_xyz(self, x, y, z): """Number density n = n(x, y, z).""" nn = 0.0 * x + 1.0 return nn
def set_defaults(params_in, params_default): """ Sets missing default key-value pairs in dictionary "params_in" according to "params_default". Parameters ---------- params_in : dict Dictionary which is compared to the dictionary "params_default" and to which missing defaults are added. params_default : dict Dictionary with default values. Returns ------- params : dict Dictionary with same keys as "params_default" and default values for missing keys. """ if params_in is None: params_in = {} # check for correct keys in params_in for key in params_in: assert key in params_default, f'Unknown key "{key}". Please choose one of {[*params_default]}.' # set default values if key is missing params = params_in for key, val in params_default.items(): params.setdefault(key, val) return params
[docs] class CurrentSheet(CartesianMHDequilibrium): r""" Current sheet equilibrium .. math:: B_y &= \text{tanh}(z / \delta) \,, B_x &= \sqrt{(1 - B_y^2)} \,, p &= p_0 = 5/2\,, n &= n_0 = 1 \,. Units are those defned in the parameter file (through :class:`~struphy.io.options.BaseUnits`). Parameters ---------- delta : characteristic size of the current sheet amp : amplitude of the current sheet Note ---- In the parameter .yml, use the following in the section ``fluid_background``:: CurrentSheet : amp : 1. delta : 0.1 """ def __init__(self, delta: float = 0.1, amp: float = 1.0): # use params setter self.params = copy.deepcopy(locals()) # =============================================================== # profiles for a straight tokamak equilibrium # ===============================================================
[docs] def plot_profiles(self, n_pts=501): """Plots radial profiles.""" import matplotlib.pyplot as plt r = xp.linspace(0.0, self.params["a"], n_pts) fig, ax = plt.subplots(1, 3) fig.set_figheight(3) fig.set_figwidth(12) ax[0].plot(r, self.q_r(r)) ax[0].set_xlabel("r") ax[0].set_ylabel("q") ax[0].plot(r, xp.ones(r.size), "k--") ax[1].plot(r, self.p_r(r)) ax[1].set_xlabel("r") ax[1].set_ylabel("p") ax[2].plot(r, self.n_r(r)) ax[2].set_xlabel("r") ax[2].set_ylabel("n") plt.subplots_adjust(wspace=0.4) plt.show()
# =============================================================== # profiles on physical domain # =============================================================== # equilibrium magnetic field
[docs] def b_xyz(self, x, y, z): """Magnetic field.""" bz = 0 * x by = xp.tanh(z / self.params["delta"]) bx = xp.sqrt(1 - by**2) bxs = self.params["amp"] * bx bys = self.params["amp"] * by return bxs, bys, bz
# equilibrium current, set to 0
[docs] def j_xyz(self, x, y, z): """Current density.""" jx = 0 * x jy = 0 * x jz = 0 * x return jx, jy, jz
# equilibrium pressure
[docs] def p_xyz(self, x, y, z): """Pressure.""" return 0 * x + 5 / 2
# equilibrium number density
[docs] def n_xyz(self, x, y, z): """Number density.""" return 1.0 + 0.0 * x
# gradient of equilibrium magnetic field (not set)
[docs] def gradB_xyz(self, x, y, z): """Gradient of magnetic field.""" gradBx = 0 * x gradBy = 0 * x gradBz = 0 * x return gradBx, gradBy, gradBz
[docs] class GenericCartesianFluidEquilibrium(CartesianFluidEquilibrium): """Generic Cartesian fluid equilibrium with callable fields. This class extends CartesianFluidEquilibrium to allow user-defined callable functions for velocity, pressure, and number density fields. It provides a flexible interface for specifying equilibrium quantities as functions of spatial coordinates (x, y, z). Methods ------- u_xyz : callable Velocity field as a function of (x, y, z) coordinates. p_xyz : callable Pressure field as a function of (x, y, z) coordinates. n_xyz : callable Number density field as a function of (x, y, z) coordinates. Attributes ---------- params : dict Dictionary of initialization parameters for reproducibility. """ def __init__( self, u_xyz: callable = None, p_xyz: callable = None, n_xyz: callable = None, ): # use params setter self.params = copy.deepcopy(locals()) if u_xyz is None: u_xyz = lambda x, y, z: (0.0 * x, 0.0 * x, 0.0 * x) else: assert callable(u_xyz) if p_xyz is None: p_xyz = lambda x, y, z: xp.ones_like(x, dtype=float) else: assert callable(p_xyz) if n_xyz is None: n_xyz = lambda x, y, z: xp.ones_like(x, dtype=float) else: assert callable(n_xyz) self._u_xyz = u_xyz self._p_xyz = p_xyz self._n_xyz = n_xyz
[docs] def u_xyz(self, x, y, z): return self._u_xyz(x, y, z)
[docs] def p_xyz(self, x, y, z): return self._p_xyz(x, y, z)
[docs] def n_xyz(self, x, y, z): return self._n_xyz(x, y, z)
[docs] class GenericCartesianFluidEquilibriumWithB(GenericCartesianFluidEquilibrium): """Generic Cartesian fluid equilibrium with magnetic field and callable fields. This class extends GenericCartesianFluidEquilibrium to include magnetic field and its gradient. It allows user-defined callable functions for velocity, pressure, number density, magnetic field, and magnetic field gradient as functions of spatial coordinates (x, y, z). Methods ------- b_xyz : callable Magnetic field as a function of (x, y, z) coordinates. gradB_xyz : callable Gradient of the magnetic field magnitude as a function of (x, y, z) coordinates. Attributes ---------- params : dict Dictionary of initialization parameters for reproducibility. """ def __init__( self, u_xyz: callable = None, p_xyz: callable = None, n_xyz: callable = None, b_xyz: callable = None, gradB_xyz: callable = None, ): # use params setter self.params = copy.deepcopy(locals()) super().__init__(u_xyz=u_xyz, p_xyz=p_xyz, n_xyz=n_xyz) if b_xyz is None: b_xyz = lambda x, y, z: (0.0 * x, 0.0 * x, 0.0 * x) else: assert callable(b_xyz) if gradB_xyz is None: gradB_xyz = lambda x, y, z: (0.0 * x, 0.0 * x, 0.0 * x) else: assert callable(gradB_xyz) self._b_xyz = b_xyz self._gradB_xyz = gradB_xyz
[docs] def b_xyz(self, x, y, z): return self._b_xyz(x, y, z)
[docs] def gradB_xyz(self, x, y, z): return self._gradB_xyz(x, y, z)