Solving Vacuum Equilibria

Vacuum equilibria can be run from the text input files by specifying the objective: objective = vacuum. The remainder of this example will demonstrate how to reproduce that same functionality from a script. We will solve two different vacuum solutions: first an axisymmetric equilibrium, then perturb that solution to a vacuum stellarator. In this example, the 2D solution is a simple circular tokamak and the 3D solution is from the HELIOTRON example.

If you have access to a GPU, uncomment the following two lines. You should see about an order of magnitude speed improvement with only these two lines of code!

[2]:
# from desc import set_device
# set_device("gpu")
[3]:
%matplotlib inline
import numpy as np

from desc.equilibrium import Equilibrium
from desc.geometry import FourierRZToroidalSurface
from desc.objectives import (
    ObjectiveFunction,
    CurrentDensity,
    get_fixed_boundary_constraints,
)
from desc.optimize import Optimizer
from desc.plotting import plot_1d, plot_section, plot_surfaces
from desc.profiles import PowerSeriesProfile
DESC version 0.7.2+335.gddb33f99.dirty,using JAX backend, jax version=0.4.1, jaxlib version=0.4.1, dtype=float64
Using device: CPU, with 8.85 GB available memory

2D Equilibrium

We start by creating the surface object that represents the axisymmetric boundary.

[4]:
surface_2D = FourierRZToroidalSurface(
    R_lmn=np.array([10, -1]),  # boundary coefficients
    Z_lmn=np.array([1]),
    modes_R=np.array([[0, 0], [1, 0]]),  # [M, N] boundary Fourier modes
    modes_Z=np.array([[-1, 0]]),
    NFP=5,  # number of (toroidal) field periods
)

Now we can initialize an Equilibrium with this boundary surface. We do not need to supply a pressure profile since pressure is irrelevant for the vacuum problem. We also increase the resolution and use a collocation grid that oversamples by a factor of two.

[5]:
iota = PowerSeriesProfile(modes=np.array([0]), params=np.array([0]))
# axisymmetric & stellarator symmetric equilibrium
eq = Equilibrium(iota=iota, surface=surface_2D, sym=True)
eq.change_resolution(L=6, M=6, L_grid=12, M_grid=12)

For a vacuum equilibrium we seek a solution to \(\mathbf{J}=0\), which will satisfy MHD force balance while ensuring the magnetic field is curl-free. This is accomplished using the CurrentDensity objective function, which by default targets a current density of \(0~\text{A}/\text{m}^2\).

[6]:
objective = ObjectiveFunction(CurrentDensity())

Next we need to specify the optimization constraints, which indicate what parameters that will remain fixed during the optimization process. For this fixed-boundary problem we can call the utility function get_fixed_boundary_constraints that returns a list of the desired constraints. The profiles=False option does not constrain the pressure and rotational transform profile, since we want the iota coefficients to be included in the optimization variables.

[7]:
constraints = get_fixed_boundary_constraints(profiles=False)

Finally, we can solve the equilibrium with the objective and constraints specified above. We also chose an optimization algorithm by initializing an Optimizer object. The verbose=3 option will display output at each optimization step.

[8]:
# this is a port of scipy's trust region least squares algorithm but using jax functions for better performance
optimizer = Optimizer("lsq-exact")
eq, solver_outputs = eq.solve(
    objective=objective, constraints=constraints, optimizer=optimizer, verbose=3
)
Building objective: current density
Precomputing transforms
Timer: Precomputing transforms = 695 ms
Timer: Objective build = 3.23 sec
Timer: Linear constraint projection build = 4.71 sec
Compiling objective function and derivatives
Timer: Objective compilation time = 804 ms
Timer: Jacobian compilation time = 2.16 sec
Timer: Total compilation time = 2.97 sec
Number of parameters: 31
Number of objectives: 147
Starting optimization
   Iteration     Total nfev        Cost      Cost reduction    Step norm     Optimality
       0              1         2.2026e-02                                    8.53e-01
       1              6         5.0477e-04      2.15e-02       7.13e-02       5.81e-01
       2              7         9.3376e-07      5.04e-04       1.33e-02       1.35e-02
       3              8         7.6023e-08      8.58e-07       2.60e-02       6.00e-03
       4             10         2.9106e-08      4.69e-08       2.52e-02       3.33e-03
       5             11         2.2348e-09      2.69e-08       2.01e-02       8.02e-04
       6             13         1.9165e-10      2.04e-09       1.10e-02       3.26e-04
       7             14         9.4528e-11      9.71e-11       1.84e-02       2.08e-04
       8             17         5.2672e-12      8.93e-11       1.26e-03       2.98e-05
       9             19         3.9747e-13      4.87e-12       6.17e-04       8.31e-06
      10             21         2.0527e-14      3.77e-13       3.30e-04       1.90e-06
      11             23         1.1042e-15      1.94e-14       1.71e-04       4.40e-07
      12             25         6.7393e-17      1.04e-15       8.69e-05       1.05e-07
      13             27         8.0252e-18      5.94e-17       4.38e-05       2.57e-08
      14             29         4.3380e-18      3.69e-18       2.20e-05       6.33e-09
`gtol` condition satisfied.
         Current function value: 4.338e-18
         Total delta_x: 3.720e-02
         Iterations: 14
         Function evaluations: 29
         Jacobian evaluations: 15
Timer: Solution time = 1.28 sec
Timer: Avg time per step = 85.9 ms
Start of solver
Total (sum of squares):  2.203e-02,
Total current density:  1.224e+06 (A*m)
Total current density:  2.099e-01 (normalized)
End of solver
Total (sum of squares):  4.338e-18,
Total current density:  1.718e-02 (A*m)
Total current density:  2.946e-09 (normalized)

We can analyze the equilibrium solution using the available plotting routines. . Here we plot the magnitude of the total current density \(|\mathbf{J}|\) and the normalized force balance error. We expect both quantities to be low for this vacuum solution.

[9]:
plot_section(eq, "|J|")
plot_section(eq, "|F|", norm_F=True, log=True);
../../_images/notebooks_tutorials_02_Script_Interface_18_0.png
../../_images/notebooks_tutorials_02_Script_Interface_18_1.png

Since this is an axisymmetric vacuum equilibrium, there should be no pressure or rotational transform. We can check the coefficients of both quantities as follows:

[10]:
# power series profile coefficients
print("p_l = {}".format(eq.p_l))  # p(rho) = p_0 + p_2*rho^2 + p_4*rho^4 + p_6*rho^6
print("i_l = {}".format(eq.i_l))  # iota(rho) = i_0 + i_2*rho^2 + i_4*rho^4 + i_6*rho^6
p_l = [0. 0. 0. 0.]
i_l = [ 7.73019132e-19  1.01330938e-18  1.26355579e-19 -4.33251318e-19]

3D Equilibrium

Now we want to solve a stellarator vacuum equilibrium by perturbing the existing tokamak solution we already found. We start by creating a new surface to represent the 3D (non-axisymmetric) stellarator boundary.

[11]:
surface_3D = FourierRZToroidalSurface(
    R_lmn=np.array([10, -1, -0.3, 0.3]),  # boundary coefficients
    Z_lmn=np.array([1, -0.3, -0.3]),
    modes_R=np.array(
        [[0, 0], [1, 0], [1, 1], [-1, -1]]
    ),  # [M, N] boundary Fourier modes
    modes_Z=np.array([[-1, 0], [-1, 1], [1, -1]]),
    NFP=5,  # number of (toroidal) field periods
)

In the previous solution we did not use any toroidal Fourier modes because they were unnecessary for axisymmetry. Now we need to increase the toroidal resolution, and we will also increase the radial and poloidal resolutions as well. Again we oversample the collocation grid by a factor of two.

We will also update the resolution of the 3D surface to match the new resolution of the Equilibrium.

[12]:
eq.change_resolution(L=10, M=10, N=6, L_grid=20, M_grid=20, N_grid=12)
surface_3D.change_resolution(eq.L, eq.M, eq.N)

We need to initialize new instances of the objective and constraints. This is necessary because the original instances got built for a specific resolution during the previous 2D equilibrium solve, and are no longer compatible with the Equilibrium after increasing the resolution.

[13]:
objective = ObjectiveFunction(CurrentDensity())
constraints = get_fixed_boundary_constraints(profiles=False)

Next is the boundary perturbation. In this step, we approximate the heliotron equilibrium solution from a 2nd-order Taylor expansion about the axisymmetric solution. This is possible thanks to the wealth of derivative information provided by automatic differentiation.

[14]:
eq.perturb(
    deltas={
        "Rb_lmn": surface_3D.R_lmn - eq.Rb_lmn,  # change in the R boundary coefficients
        "Zb_lmn": surface_3D.Z_lmn - eq.Zb_lmn,
    },  # change in the Z boundary coefficients
    objective=objective,  # perturb the solution such that J=0 is maintained
    constraints=constraints,  # same constraints used in the equilibrium solve
    order=2,  # use a 2nd-order Taylor expansion
    verbose=2,  # display timing data
)
Building objective: current density
Precomputing transforms
Timer: Precomputing transforms = 527 ms
Timer: Objective build = 2.91 sec
Perturbing Rb_lmn, Zb_lmn
Factorizing linear constraints
Timer: linear constraint factorize = 1.43 sec
Computing df
Timer: df computation = 15.5 sec
Factoring df
Timer: df/dx factorization = 1.74 sec
Computing d^2f
Timer: d^2f computation = 11.2 sec
||dx||/||x|| =  5.486e-02
Timer: Total perturbation = 31.6 sec
[14]:
Equilibrium at 0x7f14b81e30d0 (L=10, M=10, N=6, NFP=5, sym=True, spectral_indexing=ansi)

We now have an approximation of the stellarator equilibrium from the tokamak solution! Let us look at the 3D surfaces and rotational transform profile to check that the perturbation actually updated the solution:

[15]:
plot_surfaces(eq)
plot_1d(eq, "iota");
../../_images/notebooks_tutorials_02_Script_Interface_31_0.png
../../_images/notebooks_tutorials_02_Script_Interface_31_1.png

The surfaces match the heliotron boundary we want and there is non-zero rotational transform as expected. But the equilibrium error is now large because the perturbed solution is only an approximation to the true equilibrium:

[16]:
plot_section(eq, "|F|", norm_F=True, log=True);
../../_images/notebooks_tutorials_02_Script_Interface_33_0.png

We can re-solve the equilibrium using the new 3D boundary constraint. This should converge in only a few Newton iterations because we are starting from a good initial guess.

[17]:
eq, solver_outputs = eq.solve(
    objective=objective,  # solve J=0
    constraints=constraints,  # fixed-boundary constraints, except iota is a free parameter
    optimizer=optimizer,  # we can use the same optimizer as before
    ftol=1e-2,  # stopping tolerance on the function value
    xtol=1e-4,  # stopping tolerance on the step size
    gtol=1e-6,  # stopping tolerance on the gradient
    maxiter=20,  # maximum number of iterations
    verbose=3,  # display output at each iteration
)
Timer: Linear constraint projection build = 311 ms
Compiling objective function and derivatives
Timer: Objective compilation time = 2.51 ms
Timer: Jacobian compilation time = 3.49 sec
Timer: Total compilation time = 3.50 sec
Number of parameters: 1011
Number of objectives: 9075
Starting optimization
   Iteration     Total nfev        Cost      Cost reduction    Step norm     Optimality
       0              1         1.1496e-03                                    8.25e-01
       1              2         1.1744e-04      1.03e-03       4.35e-01       3.22e-01
       2              3         1.0472e-05      1.07e-04       1.46e-01       7.93e-02
       3              4         3.5486e-06      6.92e-06       2.90e-02       1.35e-01
       4              6         4.2638e-08      3.51e-06       9.13e-03       1.13e-02
       5              8         2.3911e-09      4.02e-08       4.47e-03       2.93e-03
       6             10         1.5258e-10      2.24e-09       2.36e-03       7.36e-04
       7             12         8.4958e-12      1.44e-10       1.13e-03       1.70e-04
       8             14         1.5605e-12      6.94e-12       5.59e-04       4.39e-05
Optimization terminated successfully.
`xtol` condition satisfied.
         Current function value: 1.560e-12
         Total delta_x: 3.910e-01
         Iterations: 8
         Function evaluations: 14
         Jacobian evaluations: 9
Timer: Solution time = 53.1 sec
Timer: Avg time per step = 5.90 sec
Start of solver
Total (sum of squares):  1.150e-03,
Total current density:  3.559e+04 (A*m)
Total current density:  4.795e-02 (normalized)
End of solver
Total (sum of squares):  1.560e-12,
Total current density:  1.311e+00 (A*m)
Total current density:  1.767e-06 (normalized)

We can analyze our final solution using the same plotting commands as before. Note that the flux surfaces and rotational transform profile only had small corrections compared to the perturbed solution, but the equilibrium error was significantly improved.

[18]:
plot_surfaces(eq)
plot_1d(eq, "iota")
plot_section(eq, "|J|", log=True)
plot_section(eq, "|F|", norm_F=True, log=True);
../../_images/notebooks_tutorials_02_Script_Interface_37_0.png
../../_images/notebooks_tutorials_02_Script_Interface_37_1.png
../../_images/notebooks_tutorials_02_Script_Interface_37_2.png
../../_images/notebooks_tutorials_02_Script_Interface_37_3.png

Finite beta stellarator

We’ve solved for a vacuum stellarator, but what if we now want to look at what happens at finite beta? We can simply apply a pressure perturbation.

First, we need to define a new objective function, because the CurrentDensity objective we used for the vacuum case won’t work with finite pressure, and new constraints to account for the fact that pressure and iota are now specified.

[19]:
from desc.objectives import ForceBalance

objective = ObjectiveFunction(ForceBalance())
constraints = get_fixed_boundary_constraints(profiles=True)

Next we’ll make our desired pressure profile, corresponding to a profile of the form \(p(\rho) = 2000(\rho^4 - 2 \rho^2 + 1)\)

[20]:
from desc.profiles import PowerSeriesProfile

pressure = PowerSeriesProfile([2000, 0, -4000, 0, 2000])
pressure.change_resolution(eq.L)
[21]:
eq.perturb(
    deltas={"p_l": pressure.params - eq.p_l},  # change in the R boundary coefficients
    objective=objective,  # perturb the solution such that JxB-grad(p)=0 is maintained
    constraints=constraints,  # same constraints used in the equilibrium solve
    order=2,  # use a 2nd-order Taylor expansion
    verbose=2,  # display timing data
)
Building objective: force
Precomputing transforms
Timer: Precomputing transforms = 235 ms
Timer: Objective build = 647 ms
Perturbing p_l
Factorizing linear constraints
Timer: linear constraint factorize = 1.39 sec
Computing df
Timer: df computation = 16.1 sec
Factoring df
Timer: df/dx factorization = 1.25 sec
Computing d^2f
Timer: d^2f computation = 15.3 sec
||dx||/||x|| =  7.291e-02
Timer: Total perturbation = 35.5 sec
[21]:
Equilibrium at 0x7f14b81e30d0 (L=10, M=10, N=6, NFP=5, sym=True, spectral_indexing=ansi)

We can see that the axis has moved due to the Shafranov shift, and the pressure profile is now nonzero. The force balance error is significantly larger, however.

[22]:
plot_surfaces(eq)
plot_1d(eq, "p")
plot_section(eq, "|F|", norm_F=True, log=True);
../../_images/notebooks_tutorials_02_Script_Interface_45_0.png
../../_images/notebooks_tutorials_02_Script_Interface_45_1.png
../../_images/notebooks_tutorials_02_Script_Interface_45_2.png
[23]:
eq, solver_outputs = eq.solve(
    objective=objective,  # solve JxB-grad(p)=0
    constraints=constraints,  # fixed-boundary and profile constraints
    optimizer=optimizer,  # we can use the same optimizer as before
    ftol=1e-2,  # stopping tolerance on the function value
    xtol=1e-4,  # stopping tolerance on the step size
    gtol=1e-6,  # stopping tolerance on the gradient
    maxiter=40,  # maximum number of iterations
    verbose=3,  # display output at each iteration
)
Timer: Linear constraint projection build = 324 ms
Compiling objective function and derivatives
Timer: Objective compilation time = 2.79 ms
Timer: Jacobian compilation time = 2.50 sec
Timer: Total compilation time = 2.51 sec
Number of parameters: 1005
Number of objectives: 6050
Starting optimization
   Iteration     Total nfev        Cost      Cost reduction    Step norm     Optimality
       0              1         8.2844e-02                                    6.39e+01
       1              2         1.0103e-03      8.18e-02       6.74e-02       6.46e+00
       2              4         2.0642e-04      8.04e-04       5.71e-02       3.85e+00
       3              6         2.0039e-05      1.86e-04       3.52e-02       1.06e+00
       4              8         1.3312e-06      1.87e-05       1.63e-02       1.31e-01
       5             10         4.2140e-07      9.10e-07       7.72e-03       2.83e-02
       6             12         3.7027e-07      5.11e-08       3.73e-03       6.64e-03
       7             13         3.6807e-07      2.19e-09       6.84e-03       2.56e-02
       8             14         3.3764e-07      3.04e-08       1.70e-03       1.24e-03
       9             15         3.2775e-07      9.89e-09       3.24e-03       5.09e-03
      10             16         3.2047e-07      7.28e-09       5.96e-03       1.77e-02
      11             17         2.9863e-07      2.18e-08       5.55e-03       1.44e-02
      12             18         2.7950e-07      1.91e-08       5.20e-03       1.25e-02
      13             19         2.6222e-07      1.73e-08       4.92e-03       1.16e-02
      14             20         2.4631e-07      1.59e-08       4.71e-03       1.12e-02
      15             21         2.3147e-07      1.48e-08       4.57e-03       1.13e-02
      16             22         2.1751e-07      1.40e-08       4.49e-03       1.16e-02
      17             23         2.0430e-07      1.32e-08       4.46e-03       1.20e-02
      18             25         1.9398e-07      1.03e-08       2.23e-03       3.07e-03
      19             26         1.8574e-07      8.24e-09       4.46e-03       1.25e-02
      20             27         1.7413e-07      1.16e-08       4.50e-03       1.27e-02
      21             29         1.6532e-07      8.80e-09       2.26e-03       3.21e-03
      22             30         1.5777e-07      7.56e-09       4.56e-03       1.28e-02
      23             31         1.4749e-07      1.03e-08       4.60e-03       1.26e-02
      24             33         1.3968e-07      7.81e-09       2.31e-03       3.10e-03
      25             34         1.3300e-07      6.69e-09       4.66e-03       1.22e-02
      26             35         1.2390e-07      9.10e-09       4.70e-03       1.17e-02
      27             37         1.1678e-07      7.12e-09       2.36e-03       2.81e-03
      28             38         1.1110e-07      5.68e-09       4.75e-03       1.13e-02
      29             39         1.0310e-07      8.00e-09       4.77e-03       1.09e-02
      30             40         9.5543e-08      7.55e-09       4.80e-03       1.09e-02
Warning: Maximum number of function evaluations has been exceeded.
         Current function value: 9.554e-08
         Total delta_x: 1.852e-01
         Iterations: 30
         Function evaluations: 40
         Jacobian evaluations: 31
Timer: Solution time = 2.53 min
Timer: Avg time per step = 4.91 sec
Start of solver
Total (sum of squares):  8.284e-02,
Total force:  4.816e+04 (N)
Total force:  4.070e-01 (normalized)
End of solver
Total (sum of squares):  9.554e-08,
Total force:  5.172e+01 (N)
Total force:  4.371e-04 (normalized)

After re-solving, we find the force error is much lower, less than 1% throughout the volume:

[24]:
plot_section(eq, "|F|", norm_F=True, log=True);
../../_images/notebooks_tutorials_02_Script_Interface_48_0.png