Skip to content

Commit 1eeab4b

Browse files
authored
Merge pull request #358 from AlexWiedman/planar
Planar
2 parents d0aa01a + 60cb677 commit 1eeab4b

File tree

13 files changed

+2121
-9
lines changed

13 files changed

+2121
-9
lines changed

CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ pybind11_add_module(${PROJECT_NAME}
123123
src/simsoptpp/biot_savart_py.cpp
124124
src/simsoptpp/biot_savart_vjp_py.cpp
125125
src/simsoptpp/regular_grid_interpolant_3d_py.cpp
126-
src/simsoptpp/curve.cpp src/simsoptpp/curverzfourier.cpp src/simsoptpp/curvexyzfourier.cpp
126+
src/simsoptpp/curve.cpp src/simsoptpp/curverzfourier.cpp src/simsoptpp/curvexyzfourier.cpp src/simsoptpp/curveplanarfourier.cpp
127127
src/simsoptpp/surface.cpp src/simsoptpp/surfacerzfourier.cpp src/simsoptpp/surfacexyzfourier.cpp
128128
src/simsoptpp/integral_BdotN.cpp
129129
src/simsoptpp/dipole_field.cpp src/simsoptpp/permanent_magnet_optimization.cpp

docs/source/simsopt.geo.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,14 @@ simsopt.geo.curverzfourier module
6060
:undoc-members:
6161
:show-inheritance:
6262

63+
simsopt.geo.curveplanarfourier module
64+
-------------------------------------
65+
66+
.. automodule:: simsopt.geo.curveplanarfourier
67+
:members:
68+
:undoc-members:
69+
:show-inheritance:
70+
6371
simsopt.geo.curvexyzfourier module
6472
----------------------------------
6573

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
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")

examples/run_serial_examples

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ set -ex
1616
./2_Intermediate/QSC.py
1717
./2_Intermediate/boozer.py
1818
./2_Intermediate/stage_two_optimization.py
19+
./2_Intermediate/stage_two_optimization_planar_coils.py
1920
./2_Intermediate/stage_two_optimization_stochastic.py
2021
./2_Intermediate/stage_two_optimization_finite_beta.py
2122
./2_Intermediate/strain_optimization.py

src/simsopt/geo/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from .curvexyzfourier import *
99
from .curveperturbed import *
1010
from .curveobjectives import *
11+
from .curveplanarfourier import *
1112
from .framedcurve import *
1213
from .finitebuild import *
1314
from .plotting import *
@@ -28,6 +29,7 @@
2829
__all__ = (curve.__all__ + curvehelical.__all__ +
2930
curverzfourier.__all__ + curvexyzfourier.__all__ +
3031
curveperturbed.__all__ + curveobjectives.__all__ +
32+
curveplanarfourier.__all__ +
3133
finitebuild.__all__ + plotting.__all__ +
3234
boozersurface.__all__ + qfmsurface.__all__ +
3335
surface.__all__ +

src/simsopt/geo/curve.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from .jit import jit
1212
from .plotting import fix_matplotlib_3d
1313

14-
__all__ = ['Curve', 'RotatedCurve', 'curves_to_vtk', 'create_equally_spaced_curves']
14+
__all__ = ['Curve', 'RotatedCurve', 'curves_to_vtk', 'create_equally_spaced_curves', 'create_equally_spaced_planar_curves']
1515

1616

1717
@jit
@@ -878,3 +878,46 @@ def create_equally_spaced_curves(ncurves, nfp, stellsym, R0=1.0, R1=0.5, order=6
878878
curve.x = curve.x # need to do this to transfer data to C++
879879
curves.append(curve)
880880
return curves
881+
882+
883+
def create_equally_spaced_planar_curves(ncurves, nfp, stellsym, R0=1.0, R1=0.5, order=6, numquadpoints=None):
884+
"""
885+
Create ``ncurves`` curves of type
886+
:obj:`~simsopt.geo.curveplanarfourier.CurvePlanarFourier` of order
887+
``order`` that will result in circular equally spaced coils (major
888+
radius ``R0`` and minor radius ``R1``) after applying
889+
:obj:`~simsopt.field.coil.coils_via_symmetries`.
890+
"""
891+
892+
if numquadpoints is None:
893+
numquadpoints = 15 * order
894+
curves = []
895+
from simsopt.geo.curveplanarfourier import CurvePlanarFourier
896+
for k in range(ncurves):
897+
angle = (k+0.5)*(2*np.pi) / ((1+int(stellsym))*nfp*ncurves)
898+
curve = CurvePlanarFourier(numquadpoints, order, nfp, stellsym)
899+
900+
rcCoeffs = np.zeros(order+1)
901+
rcCoeffs[0] = R1
902+
rsCoeffs = np.zeros(order)
903+
center = [R0 * cos(angle), R0 * sin(angle), 0]
904+
rotation = [1, -cos(angle), -sin(angle), 0]
905+
dofs = np.zeros(len(curve.get_dofs()))
906+
907+
j = 0
908+
for i in rcCoeffs:
909+
dofs[j] = i
910+
j += 1
911+
for i in rsCoeffs:
912+
dofs[j] = i
913+
j += 1
914+
for i in rotation:
915+
dofs[j] = i
916+
j += 1
917+
for i in center:
918+
dofs[j] = i
919+
j += 1
920+
921+
curve.set_dofs(dofs)
922+
curves.append(curve)
923+
return curves

src/simsopt/geo/curveobjectives.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -520,7 +520,7 @@ def J(self):
520520
integrals = sopp.linkNumber(R1, R2, dR1, dR2) * dS * dT
521521
linkNum[i-1][j-1] = 1/(4*np.pi) * (integrals)
522522
linkNumSum = sum(sum(abs(linkNum)))
523-
return linkNumSum
523+
return round(linkNumSum)
524524

525525
@derivative_dec
526526
def dJ(self):
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import numpy as np
2+
3+
import simsoptpp as sopp
4+
from .curve import Curve
5+
6+
__all__ = ['CurvePlanarFourier']
7+
8+
9+
class CurvePlanarFourier(sopp.CurvePlanarFourier, Curve):
10+
r"""
11+
``CurvePlanarFourier`` is a curve that is restricted to lie in a plane. The
12+
shape of the curve within the plane is represented by a Fourier series in
13+
polar coordinates. The resulting planar curve is then rotated in three
14+
dimensions using a quaternion, and finally a translation is applied. The
15+
Fourier series in polar coordinates is
16+
17+
.. math::
18+
19+
r(\phi) = \sum_{m=0}^{\text{order}} r_{c,m}\cos(m \phi) + \sum_{m=1}^{\text{order}} r_{s,m}\sin(m \phi).
20+
21+
The rotation quaternion is
22+
23+
.. math::
24+
25+
\bf{q} &= [q_0,q_i,q_j,q_k]
26+
27+
&= [\cos(\theta / 2), \hat{x}\sin(\theta / 2), \hat{y}\sin(\theta / 2), \hat{z}\sin(\theta / 2)]
28+
29+
where :math:`\theta` is the counterclockwise rotation angle about a unit axis
30+
:math:`(\hat{x},\hat{y},\hat{z})`. Details of the quaternion rotation can be
31+
found for example in pages 575-576 of
32+
https://www.cis.upenn.edu/~cis5150/ws-book-Ib.pdf.
33+
34+
35+
A quaternion is used for rotation rather than other methods for rotation to
36+
prevent gimbal locking during optimization. The quaternion is normalized
37+
before being applied to prevent scaling of the curve. The dofs themselves are not normalized. This
38+
results in a redundancy in the optimization, where several different sets of
39+
dofs may correspond to the same normalized quaternion. Normalizing the dofs
40+
directly would create a dependence between the quaternion dofs, which may cause
41+
issues during optimization.
42+
43+
The dofs are stored in the order
44+
45+
.. math::
46+
[r_{c,0}, \cdots, r_{c,\text{order}}, r_{s,1}, \cdots, r_{s,\text{order}}, q_0, q_i, q_j, q_k, x_{\text{center}}, y_{\text{center}}, z_{\text{center}}]
47+
48+
49+
"""
50+
51+
def __init__(self, quadpoints, order, nfp, stellsym, dofs=None):
52+
if isinstance(quadpoints, int):
53+
quadpoints = list(np.linspace(0, 1., quadpoints, endpoint=False))
54+
elif isinstance(quadpoints, np.ndarray):
55+
quadpoints = list(quadpoints)
56+
sopp.CurvePlanarFourier.__init__(self, quadpoints, order, nfp, stellsym)
57+
if dofs is None:
58+
Curve.__init__(self, external_dof_setter=CurvePlanarFourier.set_dofs_impl,
59+
x0=self.get_dofs())
60+
else:
61+
Curve.__init__(self, external_dof_setter=CurvePlanarFourier.set_dofs_impl,
62+
dofs=dofs)
63+
64+
def get_dofs(self):
65+
"""
66+
This function returns the dofs associated to this object.
67+
"""
68+
return np.asarray(sopp.CurvePlanarFourier.get_dofs(self))
69+
70+
def set_dofs(self, dofs):
71+
"""
72+
This function sets the dofs associated to this object.
73+
"""
74+
self.local_x = dofs
75+
sopp.CurvePlanarFourier.set_dofs(self, dofs)

0 commit comments

Comments
 (0)