|
| 1 | +#!/usr/bin/env python |
| 2 | +r""" |
| 3 | +This coil optimization script is similar to stage_two_optimization.py. However |
| 4 | +in this version, the coils are constrained to be planar, by using the curve type |
| 5 | +CurvePlanarFourier. Also the LinkingNumber objective is used to prevent coils |
| 6 | +from becoming topologically linked with each other. |
| 7 | +
|
| 8 | +In this example we solve a FOCUS like Stage II coil optimisation problem: the |
| 9 | +goal is to find coils that generate a specific target normal field on a given |
| 10 | +surface. In this particular case we consider a vacuum field, so the target is |
| 11 | +just zero. |
| 12 | +
|
| 13 | +The objective is given by |
| 14 | +
|
| 15 | + J = (1/2) \int |B dot n|^2 ds |
| 16 | + + LENGTH_WEIGHT * (sum CurveLength) |
| 17 | + + DISTANCE_WEIGHT * MininumDistancePenalty(DISTANCE_THRESHOLD) |
| 18 | + + CURVATURE_WEIGHT * CurvaturePenalty(CURVATURE_THRESHOLD) |
| 19 | + + MSC_WEIGHT * MeanSquaredCurvaturePenalty(MSC_THRESHOLD) |
| 20 | + + LinkingNumber |
| 21 | +
|
| 22 | +if any of the weights are increased, or the thresholds are tightened, the coils |
| 23 | +are more regular and better separated, but the target normal field may not be |
| 24 | +achieved as well. This example demonstrates the adjustment of weights and |
| 25 | +penalties via the use of the `Weight` class. |
| 26 | +
|
| 27 | +The target equilibrium is the QA configuration of arXiv:2108.03711. |
| 28 | +""" |
| 29 | + |
| 30 | +import os |
| 31 | +from pathlib import Path |
| 32 | +import numpy as np |
| 33 | +from scipy.optimize import minimize |
| 34 | +from simsopt.field import BiotSavart, Current, coils_via_symmetries |
| 35 | +from simsopt.geo import ( |
| 36 | + CurveLength, CurveCurveDistance, |
| 37 | + MeanSquaredCurvature, LpCurveCurvature, CurveSurfaceDistance, LinkingNumber, |
| 38 | + SurfaceRZFourier, curves_to_vtk, create_equally_spaced_planar_curves, |
| 39 | +) |
| 40 | +from simsopt.objectives import Weight, SquaredFlux, QuadraticPenalty |
| 41 | +from simsopt.util import in_github_actions |
| 42 | + |
| 43 | +# Number of unique coil shapes, i.e. the number of coils per half field period: |
| 44 | +# (Since the configuration has nfp = 2, multiply by 4 to get the total number of coils.) |
| 45 | +ncoils = 4 |
| 46 | + |
| 47 | +# Major radius for the initial circular coils: |
| 48 | +R0 = 1.0 |
| 49 | + |
| 50 | +# Minor radius for the initial circular coils: |
| 51 | +R1 = 0.5 |
| 52 | + |
| 53 | +# Number of Fourier modes describing each Cartesian component of each coil: |
| 54 | +order = 5 |
| 55 | + |
| 56 | +# Weight on the curve lengths in the objective function. We use the `Weight` |
| 57 | +# class here to later easily adjust the scalar value and rerun the optimization |
| 58 | +# without having to rebuild the objective. |
| 59 | +LENGTH_WEIGHT = Weight(10) |
| 60 | + |
| 61 | +# Threshold and weight for the coil-to-coil distance penalty in the objective function: |
| 62 | +CC_THRESHOLD = 0.08 |
| 63 | +CC_WEIGHT = 1000 |
| 64 | + |
| 65 | +# Threshold and weight for the coil-to-surface distance penalty in the objective function: |
| 66 | +CS_THRESHOLD = 0.12 |
| 67 | +CS_WEIGHT = 10 |
| 68 | + |
| 69 | +# Threshold and weight for the curvature penalty in the objective function: |
| 70 | +CURVATURE_THRESHOLD = 10. |
| 71 | +CURVATURE_WEIGHT = 1e-6 |
| 72 | + |
| 73 | +# Threshold and weight for the mean squared curvature penalty in the objective function: |
| 74 | +MSC_THRESHOLD = 10 |
| 75 | +MSC_WEIGHT = 1e-6 |
| 76 | + |
| 77 | +# Number of iterations to perform: |
| 78 | +MAXITER = 50 if in_github_actions else 400 |
| 79 | + |
| 80 | +# File for the desired boundary magnetic surface: |
| 81 | +TEST_DIR = (Path(__file__).parent / ".." / ".." / "tests" / "test_files").resolve() |
| 82 | +filename = TEST_DIR / 'input.LandremanPaul2021_QA' |
| 83 | + |
| 84 | +# Directory for output |
| 85 | +OUT_DIR = "./output/" |
| 86 | +os.makedirs(OUT_DIR, exist_ok=True) |
| 87 | + |
| 88 | +####################################################### |
| 89 | +# End of input parameters. |
| 90 | +####################################################### |
| 91 | + |
| 92 | +# Initialize the boundary magnetic surface: |
| 93 | +nphi = 32 |
| 94 | +ntheta = 32 |
| 95 | +s = SurfaceRZFourier.from_vmec_input(filename, range="half period", nphi=nphi, ntheta=ntheta) |
| 96 | + |
| 97 | +# Create the initial coils: |
| 98 | +base_curves = create_equally_spaced_planar_curves(ncoils, s.nfp, stellsym=True, R0=R0, R1=R1, order=order) |
| 99 | +base_currents = [Current(1e5) for i in range(ncoils)] |
| 100 | +# Since the target field is zero, one possible solution is just to set all |
| 101 | +# currents to 0. To avoid the minimizer finding that solution, we fix one |
| 102 | +# of the currents: |
| 103 | +base_currents[0].fix_all() |
| 104 | + |
| 105 | +coils = coils_via_symmetries(base_curves, base_currents, s.nfp, True) |
| 106 | +bs = BiotSavart(coils) |
| 107 | +bs.set_points(s.gamma().reshape((-1, 3))) |
| 108 | + |
| 109 | +curves = [c.curve for c in coils] |
| 110 | +curves_to_vtk(curves, OUT_DIR + "curves_init") |
| 111 | +pointData = {"B_N": np.sum(bs.B().reshape((nphi, ntheta, 3)) * s.unitnormal(), axis=2)[:, :, None]} |
| 112 | +s.to_vtk(OUT_DIR + "surf_init", extra_data=pointData) |
| 113 | + |
| 114 | +# Define the individual terms objective function: |
| 115 | +Jf = SquaredFlux(s, bs) |
| 116 | +Jls = [CurveLength(c) for c in base_curves] |
| 117 | +Jccdist = CurveCurveDistance(curves, CC_THRESHOLD, num_basecurves=ncoils) |
| 118 | +Jcsdist = CurveSurfaceDistance(curves, s, CS_THRESHOLD) |
| 119 | +Jcs = [LpCurveCurvature(c, 2, CURVATURE_THRESHOLD) for c in base_curves] |
| 120 | +Jmscs = [MeanSquaredCurvature(c) for c in base_curves] |
| 121 | +linkNum = LinkingNumber(curves) |
| 122 | + |
| 123 | +# Form the total objective function. To do this, we can exploit the |
| 124 | +# fact that Optimizable objects with J() and dJ() functions can be |
| 125 | +# multiplied by scalars and added: |
| 126 | +JF = Jf \ |
| 127 | + + LENGTH_WEIGHT * QuadraticPenalty(sum(Jls), 2.6*ncoils) \ |
| 128 | + + CC_WEIGHT * Jccdist \ |
| 129 | + + CS_WEIGHT * Jcsdist \ |
| 130 | + + CURVATURE_WEIGHT * sum(Jcs) \ |
| 131 | + + MSC_WEIGHT * sum(QuadraticPenalty(J, MSC_THRESHOLD) for J in Jmscs) \ |
| 132 | + + linkNum |
| 133 | + |
| 134 | +# We don't have a general interface in SIMSOPT for optimisation problems that |
| 135 | +# are not in least-squares form, so we write a little wrapper function that we |
| 136 | +# pass directly to scipy.optimize.minimize |
| 137 | + |
| 138 | + |
| 139 | +def fun(dofs): |
| 140 | + JF.x = dofs |
| 141 | + J = JF.J() |
| 142 | + grad = JF.dJ() |
| 143 | + jf = Jf.J() |
| 144 | + BdotN = np.mean(np.abs(np.sum(bs.B().reshape((nphi, ntheta, 3)) * s.unitnormal(), axis=2))) |
| 145 | + MaxBdotN = np.max(np.abs(np.sum(bs.B().reshape((nphi, ntheta, 3)) * s.unitnormal(), axis=2))) |
| 146 | + mean_AbsB = np.mean(bs.AbsB()) |
| 147 | + outstr = f"J={J:.1e}, Jf={jf:.1e}, ⟨B·n⟩={BdotN:.1e}" |
| 148 | + cl_string = ", ".join([f"{J.J():.1f}" for J in Jls]) |
| 149 | + kap_string = ", ".join(f"{np.max(c.kappa()):.1f}" for c in base_curves) |
| 150 | + msc_string = ", ".join(f"{J.J():.1f}" for J in Jmscs) |
| 151 | + outstr += f", Len=sum([{cl_string}])={sum(J.J() for J in Jls):.1f}, ϰ=[{kap_string}], ∫ϰ²/L=[{msc_string}]" |
| 152 | + outstr += f", C-C-Sep={Jccdist.shortest_distance():.2f}, C-S-Sep={Jcsdist.shortest_distance():.2f}" |
| 153 | + outstr += f", ║∇J║={np.linalg.norm(grad):.1e}" |
| 154 | + outstr += f", ⟨B·n⟩/|B|={BdotN/mean_AbsB:.1e}" |
| 155 | + outstr += f", (Max B·n)/|B|={MaxBdotN/mean_AbsB:.1e}" |
| 156 | + outstr += f", Link Number = {linkNum.J()}" |
| 157 | + print(outstr) |
| 158 | + return J, grad |
| 159 | + |
| 160 | + |
| 161 | +print(""" |
| 162 | +################################################################################ |
| 163 | +### Perform a Taylor test ###################################################### |
| 164 | +################################################################################ |
| 165 | +""") |
| 166 | +f = fun |
| 167 | +dofs = JF.x |
| 168 | +np.random.seed(1) |
| 169 | +h = np.random.uniform(size=dofs.shape) |
| 170 | +J0, dJ0 = f(dofs) |
| 171 | +dJh = sum(dJ0 * h) |
| 172 | +for eps in [1e-3, 1e-4, 1e-5, 1e-6, 1e-7]: |
| 173 | + J1, _ = f(dofs + eps*h) |
| 174 | + J2, _ = f(dofs - eps*h) |
| 175 | + print("err", (J1-J2)/(2*eps) - dJh) |
| 176 | + |
| 177 | +print(""" |
| 178 | +################################################################################ |
| 179 | +### Run the optimisation ####################################################### |
| 180 | +################################################################################ |
| 181 | +""") |
| 182 | +res = minimize(fun, dofs, jac=True, method='L-BFGS-B', options={'maxiter': MAXITER, 'maxcor': 300}, tol=1e-15) |
| 183 | +curves_to_vtk(curves, OUT_DIR + "curves_opt_short") |
| 184 | +pointData = {"B_N": np.sum(bs.B().reshape((nphi, ntheta, 3)) * s.unitnormal(), axis=2)[:, :, None]} |
| 185 | +s.to_vtk(OUT_DIR + "surf_opt_short", extra_data=pointData) |
| 186 | + |
| 187 | + |
| 188 | +# We now use the result from the optimization as the initial guess for a |
| 189 | +# subsequent optimization with reduced penalty for the coil length. This will |
| 190 | +# result in slightly longer coils but smaller `B·n` on the surface. |
| 191 | +dofs = res.x |
| 192 | +LENGTH_WEIGHT *= 0.1 |
| 193 | +res = minimize(fun, dofs, jac=True, method='L-BFGS-B', options={'maxiter': MAXITER, 'maxcor': 300}, tol=1e-15) |
| 194 | +curves_to_vtk(curves, OUT_DIR + "curves_opt_long") |
| 195 | +pointData = {"B_N": np.sum(bs.B().reshape((nphi, ntheta, 3)) * s.unitnormal(), axis=2)[:, :, None]} |
| 196 | +s.to_vtk(OUT_DIR + "surf_opt_long", extra_data=pointData) |
| 197 | + |
| 198 | +# Save the optimized coil shapes and currents so they can be loaded into other scripts for analysis: |
| 199 | +bs.save(OUT_DIR + "biot_savart_opt.json") |
0 commit comments