diff --git a/.github/workflows/extensive_test.yml b/.github/workflows/extensive_test.yml index 1f2fb6e5d..681930653 100644 --- a/.github/workflows/extensive_test.yml +++ b/.github/workflows/extensive_test.yml @@ -29,7 +29,7 @@ jobs: matrix: test-type: [unit, integrated] packages: [all, vmec, spec, none] - python-version: [3.8, 3.9, "3.10"] + python-version: [3.9, "3.10", "3.11"] include: - python-version: 3.9 test-type: unit diff --git a/.gitmodules b/.gitmodules index 8aa6c7a54..fcab45429 100644 --- a/.gitmodules +++ b/.gitmodules @@ -18,4 +18,4 @@ url = https://gitlab.com/libeigen/eigen.git [submodule "thirdparty/fmt"] path = thirdparty/fmt - url = https://github.com/fmtlib/fmt.git + url = https://github.com/fmtlib/fmt.git \ No newline at end of file diff --git a/examples/3_Advanced/coil_forces.py b/examples/3_Advanced/coil_forces.py new file mode 100755 index 000000000..2d16669aa --- /dev/null +++ b/examples/3_Advanced/coil_forces.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python + +""" +Example script for the force metric in a stage-two coil optimization +""" +import os +from pathlib import Path +from scipy.optimize import minimize +import numpy as np +from simsopt.geo import curves_to_vtk, create_equally_spaced_curves +from simsopt.geo import SurfaceRZFourier +from simsopt.field import Current, coils_via_symmetries +from simsopt.objectives import SquaredFlux, Weight, QuadraticPenalty +from simsopt.geo import (CurveLength, CurveCurveDistance, CurveSurfaceDistance, + MeanSquaredCurvature, LpCurveCurvature) +from simsopt.field import BiotSavart +from simsopt.field.force import coil_force, LpCurveForce +from simsopt.field.selffield import regularization_circ +from simsopt.util import in_github_actions + + +############################################################################### +# INPUT PARAMETERS +############################################################################### + +# Number of unique coil shapes, i.e. the number of coils per half field period: +# (Since the configuration has nfp = 2, multiply by 4 to get the total number of coils.) +ncoils = 4 + +# Major radius for the initial circular coils: +R0 = 1.0 + +# Minor radius for the initial circular coils: +R1 = 0.5 + +# Number of Fourier modes describing each Cartesian component of each coil: +order = 5 + +# Weight on the curve lengths in the objective function. We use the `Weight` +# class here to later easily adjust the scalar value and rerun the optimization +# without having to rebuild the objective. +LENGTH_WEIGHT = Weight(1e-03) +LENGTH_TARGET = 17.4 + +# Threshold and weight for the coil-to-coil distance penalty in the objective function: +CC_THRESHOLD = 0.1 +CC_WEIGHT = 1000 + +# Threshold and weight for the coil-to-surface distance penalty in the objective function: +CS_THRESHOLD = 0.3 +CS_WEIGHT = 10 + +# Threshold and weight for the curvature penalty in the objective function: +CURVATURE_THRESHOLD = 5. +CURVATURE_WEIGHT = 1e-6 + +# Threshold and weight for the mean squared curvature penalty in the objective function: +MSC_THRESHOLD = 5 +MSC_WEIGHT = 1e-6 + +# Weight on the mean squared force penalty in the objective function +FORCE_WEIGHT = Weight(1e-26) + +# Number of iterations to perform: +MAXITER = 50 if in_github_actions else 400 + +# File for the desired boundary magnetic surface: +TEST_DIR = (Path(__file__).parent / ".." / ".." / "tests" / "test_files").resolve() +filename = TEST_DIR / 'input.LandremanPaul2021_QA' + +# Directory for output +OUT_DIR = "./output/" +os.makedirs(OUT_DIR, exist_ok=True) + + +############################################################################### +# SET UP OBJECTIVE FUNCTION +############################################################################### + +# Initialize the boundary magnetic surface: +nphi = 32 +ntheta = 32 +s = SurfaceRZFourier.from_vmec_input(filename, range="half period", nphi=nphi, ntheta=ntheta) + +# Create the initial coils: +base_curves = create_equally_spaced_curves(ncoils, s.nfp, stellsym=True, R0=R0, R1=R1, order=order) +base_currents = [Current(1e5) for i in range(ncoils)] +# Since the target field is zero, one possible solution is just to set all +# currents to 0. To avoid the minimizer finding that solution, we fix one +# of the currents: +base_currents[0].fix_all() + +coils = coils_via_symmetries(base_curves, base_currents, s.nfp, True) +base_coils = coils[:ncoils] +bs = BiotSavart(coils) +bs.set_points(s.gamma().reshape((-1, 3))) + +curves = [c.curve for c in coils] +curves_to_vtk(curves, OUT_DIR + "curves_init", close=True) +pointData = {"B_N": np.sum(bs.B().reshape((nphi, ntheta, 3)) * s.unitnormal(), axis=2)[:, :, None]} +s.to_vtk(OUT_DIR + "surf_init", extra_data=pointData) + +# Define the individual terms objective function: +Jf = SquaredFlux(s, bs) +Jls = [CurveLength(c) for c in base_curves] +Jccdist = CurveCurveDistance(curves, CC_THRESHOLD, num_basecurves=ncoils) +Jcsdist = CurveSurfaceDistance(curves, s, CS_THRESHOLD) +Jcs = [LpCurveCurvature(c, 2, CURVATURE_THRESHOLD) for c in base_curves] +Jmscs = [MeanSquaredCurvature(c) for c in base_curves] +Jforce = [LpCurveForce(c, coils, regularization_circ(0.05), p=4) for c in base_coils] +# Jforce = [MeanSquaredForce(c, coils, regularization_circ(0.05)) for c in base_coils] + + +# Form the total objective function. To do this, we can exploit the +# fact that Optimizable objects with J() and dJ() functions can be +# multiplied by scalars and added: +JF = Jf \ + + LENGTH_WEIGHT * QuadraticPenalty(sum(Jls), LENGTH_TARGET, "max") \ + + CC_WEIGHT * Jccdist \ + + CS_WEIGHT * Jcsdist \ + + CURVATURE_WEIGHT * sum(Jcs) \ + + MSC_WEIGHT * sum(QuadraticPenalty(J, MSC_THRESHOLD, "max") for J in Jmscs) \ + + FORCE_WEIGHT * sum(Jforce) + +# We don't have a general interface in SIMSOPT for optimisation problems that +# are not in least-squares form, so we write a little wrapper function that we +# pass directly to scipy.optimize.minimize + + +def fun(dofs): + JF.x = dofs + J = JF.J() + grad = JF.dJ() + return J, grad + + +# print(""" +# ############################################################################### +# # Perform a Taylor test +# ############################################################################### +# """) +# print("(It make take jax several minutes to compile the objective for the first evaluation.)") +# f = fun +# dofs = JF.x +# np.random.seed(1) +# h = np.random.uniform(size=dofs.shape) +# J0, dJ0 = f(dofs) +# dJh = sum(dJ0 * h) +# for eps in [1e-3, 1e-4, 1e-5, 1e-6, 1e-7]: +# J1, _ = f(dofs + eps*h) +# J2, _ = f(dofs - eps*h) +# print("err", (J1-J2)/(2*eps) - dJh) + +############################################################################### +# RUN THE OPTIMIZATION +############################################################################### + + +def pointData_forces(coils): + forces = [] + for c in coils: + force = np.linalg.norm(coil_force(c, coils, regularization_circ(0.05)), axis=1) + force = np.append(force, force[0]) + forces = np.concatenate([forces, force]) + point_data = {"F": forces} + return point_data + + +dofs = JF.x +print(f"Optimization with FORCE_WEIGHT={FORCE_WEIGHT.value} and LENGTH_WEIGHT={LENGTH_WEIGHT.value}") +# print("INITIAL OPTIMIZATION") +res = minimize(fun, dofs, jac=True, method='L-BFGS-B', options={'maxiter': MAXITER, 'maxcor': 300}, tol=1e-15) +curves_to_vtk(curves, OUT_DIR + "curves_opt_short", close=True, extra_data=pointData_forces(coils)) +pointData_surf = {"B_N": np.sum(bs.B().reshape((nphi, ntheta, 3)) * s.unitnormal(), axis=2)[:, :, None]} +s.to_vtk(OUT_DIR + "surf_opt_short", extra_data=pointData_surf) + +# We now use the result from the optimization as the initial guess for a +# subsequent optimization with reduced penalty for the coil length. This will +# result in slightly longer coils but smaller `B·n` on the surface. +dofs = res.x +LENGTH_WEIGHT *= 0.1 +# print("OPTIMIZATION WITH REDUCED LENGTH PENALTY\n") +res = minimize(fun, dofs, jac=True, method='L-BFGS-B', options={'maxiter': MAXITER, 'maxcor': 300}, tol=1e-15) +curves_to_vtk(curves, OUT_DIR + f"curves_opt_force_FWEIGHT={FORCE_WEIGHT.value:e}_LWEIGHT={LENGTH_WEIGHT.value*10:e}", close=True, extra_data=pointData_forces(coils)) +pointData_surf = {"B_N": np.sum(bs.B().reshape((nphi, ntheta, 3)) * s.unitnormal(), axis=2)[:, :, None]} +s.to_vtk(OUT_DIR + f"surf_opt_force_WEIGHT={FORCE_WEIGHT.value:e}_LWEIGHT={LENGTH_WEIGHT.value*10:e}", extra_data=pointData_surf) + +# Save the optimized coil shapes and currents so they can be loaded into other scripts for analysis: +bs.save(OUT_DIR + "biot_savart_opt.json") + +#Print out final important info: +JF.x = dofs +J = JF.J() +grad = JF.dJ() +jf = Jf.J() +BdotN = np.mean(np.abs(np.sum(bs.B().reshape((nphi, ntheta, 3)) * s.unitnormal(), axis=2))) +force = [np.max(np.linalg.norm(coil_force(c, coils, regularization_circ(0.05)), axis=1)) for c in base_coils] +outstr = f"J={J:.1e}, Jf={jf:.1e}, ⟨B·n⟩={BdotN:.1e}" +cl_string = ", ".join([f"{J.J():.1f}" for J in Jls]) +kap_string = ", ".join(f"{np.max(c.kappa()):.1f}" for c in base_curves) +msc_string = ", ".join(f"{J.J():.1f}" for J in Jmscs) +jforce_string = ", ".join(f"{J.J():.2e}" for J in Jforce) +force_string = ", ".join(f"{f:.2e}" for f in force) +outstr += f", Len=sum([{cl_string}])={sum(J.J() for J in Jls):.1f}, ϰ=[{kap_string}], ∫ϰ²/L=[{msc_string}], Jforce=[{jforce_string}], force=[{force_string}]" +outstr += f", C-C-Sep={Jccdist.shortest_distance():.2f}, C-S-Sep={Jcsdist.shortest_distance():.2f}" +outstr += f", ║∇J║={np.linalg.norm(grad):.1e}" +print(outstr) diff --git a/examples/run_serial_examples b/examples/run_serial_examples index b87f64702..5d401d57c 100755 --- a/examples/run_serial_examples +++ b/examples/run_serial_examples @@ -24,3 +24,4 @@ set -ex ./2_Intermediate/permanent_magnet_QA.py ./2_Intermediate/permanent_magnet_PM4Stell.py ./3_Advanced/stage_two_optimization_finitebuild.py +./3_Advanced/coil_forces.py diff --git a/src/simsopt/field/__init__.py b/src/simsopt/field/__init__.py index 8e652395e..7edb96596 100644 --- a/src/simsopt/field/__init__.py +++ b/src/simsopt/field/__init__.py @@ -6,6 +6,7 @@ from .mgrid import * from .normal_field import * from .tracing import * +from .selffield import * from .magnetic_axis_helpers import * __all__ = ( @@ -17,5 +18,6 @@ + mgrid.__all__ + normal_field.__all__ + tracing.__all__ + + selffield.__all__ + magnetic_axis_helpers.__all__ ) diff --git a/src/simsopt/field/coil.py b/src/simsopt/field/coil.py index e2ab7dd0b..bdbe1cbab 100644 --- a/src/simsopt/field/coil.py +++ b/src/simsopt/field/coil.py @@ -93,7 +93,6 @@ def __init__(self, current, dofs=None, **kwargs): def vjp(self, v_current): return Derivative({self: v_current}) - @property def current(self): return self.get_value() diff --git a/src/simsopt/field/force.py b/src/simsopt/field/force.py new file mode 100644 index 000000000..caed1d643 --- /dev/null +++ b/src/simsopt/field/force.py @@ -0,0 +1,273 @@ +"""Implements the force on a coil in its own magnetic field and the field of other coils.""" +from scipy import constants +import numpy as np +import jax.numpy as jnp +from jax import grad +from .biotsavart import BiotSavart +from .selffield import B_regularized_pure, B_regularized, regularization_circ, regularization_rect +from ..geo.jit import jit +from .._core.optimizable import Optimizable +from .._core.derivative import derivative_dec + +Biot_savart_prefactor = constants.mu_0 / 4 / np.pi + + +def coil_force(coil, allcoils, regularization): + gammadash = coil.curve.gammadash() + gammadash_norm = np.linalg.norm(gammadash, axis=1)[:, None] + tangent = gammadash / gammadash_norm + mutual_coils = [c for c in allcoils if c is not coil] + mutual_field = BiotSavart(mutual_coils).set_points(coil.curve.gamma()).B() + mutualforce = np.cross(coil.current.get_value() * tangent, mutual_field) + selfforce = self_force(coil, regularization) + return selfforce + mutualforce + + +def coil_force_pure(B, I, t): + """force on coil for optimization""" + return jnp.cross(I * t, B) + + +def self_force(coil, regularization): + """ + Compute the self-force of a coil. + """ + I = coil.current.get_value() + tangent = coil.curve.gammadash() / np.linalg.norm(coil.curve.gammadash(), + axis=1)[:, None] + B = B_regularized(coil, regularization) + return coil_force_pure(B, I, tangent) + + +def self_force_circ(coil, a): + """Compute the Lorentz self-force of a coil with circular cross-section""" + return self_force(coil, regularization_circ(a)) + + +def self_force_rect(coil, a, b): + """Compute the Lorentz self-force of a coil with rectangular cross-section""" + return self_force(coil, regularization_rect(a, b)) + + +@jit +def lp_force_pure(gamma, gammadash, gammadashdash, quadpoints, current, regularization, B_mutual, p, threshold): + r"""Pure function for minimizing the Lorentz force on a coil. + + The function is + + .. math:: + J = \frac{1}{p}\left(\int \text{max}(|\vec{F}| - F_0, 0)^p d\ell\right) + + where :math:`\vec{F}` is the Lorentz force, :math:`F_0` is a threshold force, + and :math:`\ell` is arclength along the coil. + """ + + B_self = B_regularized_pure(gamma, gammadash, gammadashdash, quadpoints, current, regularization) + gammadash_norm = jnp.linalg.norm(gammadash, axis=1)[:, None] + tangent = gammadash / gammadash_norm + force = jnp.cross(current * tangent, B_self + B_mutual) + force_norm = jnp.linalg.norm(force, axis=1)[:, None] + return (jnp.sum(jnp.maximum(force_norm - threshold, 0)**p * gammadash_norm))*(1./p) + + +class LpCurveForce(Optimizable): + r""" Optimizable class to minimize the Lorentz force on a coil. + + The objective function is + + .. math:: + J = \frac{1}{p}\left(\int \text{max}(|\vec{F}| - F_0, 0)^p d\ell\right) + + where :math:`\vec{F}` is the Lorentz force, :math:`F_0` is a threshold force, + and :math:`\ell` is arclength along the coil. + """ + + def __init__(self, coil, allcoils, regularization, p=1.0, threshold=0.0): + self.coil = coil + self.allcoils = allcoils + self.othercoils = [c for c in allcoils if c is not coil] + self.biotsavart = BiotSavart(self.othercoils) + quadpoints = self.coil.curve.quadpoints + + self.J_jax = jit( + lambda gamma, gammadash, gammadashdash, current, B_mutual: + lp_force_pure(gamma, gammadash, gammadashdash, quadpoints, current, regularization, B_mutual, p, threshold) + ) + + self.dJ_dgamma = jit( + lambda gamma, gammadash, gammadashdash, current, B_mutual: + grad(self.J_jax, argnums=0)(gamma, gammadash, gammadashdash, current, B_mutual) + ) + + self.dJ_dgammadash = jit( + lambda gamma, gammadash, gammadashdash, current, B_mutual: + grad(self.J_jax, argnums=1)(gamma, gammadash, gammadashdash, current, B_mutual) + ) + + self.dJ_dgammadashdash = jit( + lambda gamma, gammadash, gammadashdash, current, B_mutual: + grad(self.J_jax, argnums=2)(gamma, gammadash, gammadashdash, current, B_mutual) + ) + + self.dJ_dcurrent = jit( + lambda gamma, gammadash, gammadashdash, current, B_mutual: + grad(self.J_jax, argnums=3)(gamma, gammadash, gammadashdash, current, B_mutual) + ) + + self.dJ_dB_mutual = jit( + lambda gamma, gammadash, gammadashdash, current, B_mutual: + grad(self.J_jax, argnums=4)(gamma, gammadash, gammadashdash, current, B_mutual) + ) + + super().__init__(depends_on=allcoils) + + def J(self): + self.biotsavart.set_points(self.coil.curve.gamma()) + + args = [ + self.coil.curve.gamma(), + self.coil.curve.gammadash(), + self.coil.curve.gammadashdash(), + self.coil.current.get_value(), + self.biotsavart.B() + ] + + return self.J_jax(*args) + + @derivative_dec + def dJ(self): + self.biotsavart.set_points(self.coil.curve.gamma()) + + args = [ + self.coil.curve.gamma(), + self.coil.curve.gammadash(), + self.coil.curve.gammadashdash(), + self.coil.current.get_value(), + self.biotsavart.B() + ] + + dJ_dB = self.dJ_dB_mutual(*args) + dB_dX = self.biotsavart.dB_by_dX() + dJ_dX = np.einsum('ij,ikj->ik', dJ_dB, dB_dX) + + return ( + self.coil.curve.dgamma_by_dcoeff_vjp(self.dJ_dgamma(*args) + dJ_dX) + + self.coil.curve.dgammadash_by_dcoeff_vjp(self.dJ_dgammadash(*args)) + + self.coil.curve.dgammadashdash_by_dcoeff_vjp(self.dJ_dgammadashdash(*args)) + + self.coil.current.vjp(jnp.asarray([self.dJ_dcurrent(*args)])) + + self.biotsavart.B_vjp(dJ_dB) + ) + + return_fn_map = {'J': J, 'dJ': dJ} + + +@jit +def mean_squared_force_pure(gamma, gammadash, gammadashdash, quadpoints, current, regularization, B_mutual): + r"""Pure function for minimizing the Lorentz force on a coil. + + The function is + + .. math: + J = \frac{\int |\vec{F}|^2 d\ell}{\int d\ell} + + where :math:`\vec{F}` is the Lorentz force and :math:`\ell` is arclength + along the coil. + """ + + B_self = B_regularized_pure(gamma, gammadash, gammadashdash, quadpoints, current, regularization) + gammadash_norm = jnp.linalg.norm(gammadash, axis=1)[:, None] + tangent = gammadash / gammadash_norm + force = jnp.cross(current * tangent, B_self + B_mutual) + force_norm = jnp.linalg.norm(force, axis=1)[:, None] + return jnp.sum(gammadash_norm * force_norm**2) / jnp.sum(gammadash_norm) + + +class MeanSquaredForce(Optimizable): + r"""Optimizable class to minimize the Lorentz force on a coil. + + The objective function is + + .. math: + J = \frac{\int |\vec{F}|^2 d\ell}{\int d\ell} + + where :math:`\vec{F}` is the Lorentz force and :math:`\ell` is arclength + along the coil. + """ + + def __init__(self, coil, allcoils, regularization): + self.coil = coil + self.allcoils = allcoils + self.othercoils = [c for c in allcoils if c is not coil] + self.biotsavart = BiotSavart(self.othercoils) + quadpoints = self.coil.curve.quadpoints + + self.J_jax = jit( + lambda gamma, gammadash, gammadashdash, current, B_mutual: + mean_squared_force_pure(gamma, gammadash, gammadashdash, quadpoints, current, regularization, B_mutual) + ) + + self.dJ_dgamma = jit( + lambda gamma, gammadash, gammadashdash, current, B_mutual: + grad(self.J_jax, argnums=0)(gamma, gammadash, gammadashdash, current, B_mutual) + ) + + self.dJ_dgammadash = jit( + lambda gamma, gammadash, gammadashdash, current, B_mutual: + grad(self.J_jax, argnums=1)(gamma, gammadash, gammadashdash, current, B_mutual) + ) + + self.dJ_dgammadashdash = jit( + lambda gamma, gammadash, gammadashdash, current, B_mutual: + grad(self.J_jax, argnums=2)(gamma, gammadash, gammadashdash, current, B_mutual) + ) + + self.dJ_dcurrent = jit( + lambda gamma, gammadash, gammadashdash, current, B_mutual: + grad(self.J_jax, argnums=3)(gamma, gammadash, gammadashdash, current, B_mutual) + ) + + self.dJ_dB_mutual = jit( + lambda gamma, gammadash, gammadashdash, current, B_mutual: + grad(self.J_jax, argnums=4)(gamma, gammadash, gammadashdash, current, B_mutual) + ) + + super().__init__(depends_on=allcoils) + + def J(self): + self.biotsavart.set_points(self.coil.curve.gamma()) + + args = [ + self.coil.curve.gamma(), + self.coil.curve.gammadash(), + self.coil.curve.gammadashdash(), + self.coil.current.get_value(), + self.biotsavart.B() + ] + + return self.J_jax(*args) + + @derivative_dec + def dJ(self): + self.biotsavart.set_points(self.coil.curve.gamma()) + + args = [ + self.coil.curve.gamma(), + self.coil.curve.gammadash(), + self.coil.curve.gammadashdash(), + self.coil.current.get_value(), + self.biotsavart.B() + ] + + dJ_dB = self.dJ_dB_mutual(*args) + dB_dX = self.biotsavart.dB_by_dX() + dJ_dX = np.einsum('ij,ikj->ik', dJ_dB, dB_dX) + + return ( + self.coil.curve.dgamma_by_dcoeff_vjp(self.dJ_dgamma(*args) + dJ_dX) + + self.coil.curve.dgammadash_by_dcoeff_vjp(self.dJ_dgammadash(*args)) + + self.coil.curve.dgammadashdash_by_dcoeff_vjp(self.dJ_dgammadashdash(*args)) + + self.coil.current.vjp(jnp.asarray([self.dJ_dcurrent(*args)])) + + self.biotsavart.B_vjp(dJ_dB) + ) + + return_fn_map = {'J': J, 'dJ': dJ} \ No newline at end of file diff --git a/src/simsopt/field/selffield.py b/src/simsopt/field/selffield.py new file mode 100755 index 000000000..0c197305c --- /dev/null +++ b/src/simsopt/field/selffield.py @@ -0,0 +1,96 @@ +""" +This module contains functions for computing the self-field of a coil using the +methods from Hurwitz, Landreman, & Antonsen, arXiv:2310.09313 (2023) and +Landreman, Hurwitz, & Antonsen, arXiv:2310.12087 (2023). +""" + +from scipy import constants +import numpy as np +import jax.numpy as jnp + +Biot_savart_prefactor = constants.mu_0 / (4 * np.pi) + +__all__ = ['B_regularized_pure', 'regularization_rect'] + +def rectangular_xsection_k(a, b): + """Auxiliary function for field in rectangular conductor""" + return (4 * b) / (3 * a) * jnp.arctan(a/b) + (4*a)/(3*b)*jnp.arctan(b/a) + \ + (b**2)/(6*a**2)*jnp.log(b/a) + (a**2)/(6*b**2)*jnp.log(a/b) - \ + (a**4 - 6*a**2*b**2 + b**4)/(6*a**2*b**2)*jnp.log(a/b+b/a) + + +def rectangular_xsection_delta(a, b): + """Auxiliary function for field in rectangular conductor""" + return jnp.exp(-25/6 + rectangular_xsection_k(a, b)) + + +def regularization_circ(a): + """Regularization for a circular conductor""" + return a**2 / jnp.sqrt(jnp.e) + + +def regularization_rect(a, b): + """Regularization for a rectangular conductor""" + return a * b * rectangular_xsection_delta(a, b) + + +def B_regularized_singularity_term(rc_prime, rc_prime_prime, regularization): + """The term in the regularized Biot-Savart law in which the near-singularity + has been integrated analytically. + + regularization corresponds to delta * a * b for rectangular x-section, or to + a²/√e for circular x-section. + + A prefactor of μ₀ I / (4π) is not included. + + The derivatives rc_prime, rc_prime_prime refer to an angle that goes up to + 2π, not up to 1. + """ + norm_rc_prime = jnp.linalg.norm(rc_prime, axis=1) + return jnp.cross(rc_prime, rc_prime_prime) * ( + 0.5 * (-2 + jnp.log(64 * norm_rc_prime * norm_rc_prime / regularization)) / (norm_rc_prime**3) + )[:, None] + + +def B_regularized_pure(gamma, gammadash, gammadashdash, quadpoints, current, regularization): + # The factors of 2π in the next few lines come from the fact that simsopt + # uses a curve parameter that goes up to 1 rather than 2π. + phi = quadpoints * 2 * jnp.pi + rc = gamma + rc_prime = gammadash / 2 / jnp.pi + rc_prime_prime = gammadashdash / 4 / jnp.pi**2 + n_quad = phi.shape[0] + dphi = 2 * jnp.pi / n_quad + + analytic_term = B_regularized_singularity_term(rc_prime, rc_prime_prime, regularization) + + dr = rc[:, None] - rc[None, :] + first_term = jnp.cross(rc_prime[None, :], dr) / ((jnp.sum(dr * dr, axis=2) + regularization) ** 1.5)[:, :, None] + cos_fac = 2 - 2 * jnp.cos(phi[None, :] - phi[:, None]) + denominator2 = cos_fac * jnp.sum(rc_prime * rc_prime, axis=1)[:, None] + regularization + factor2 = 0.5 * cos_fac / denominator2**1.5 + second_term = jnp.cross(rc_prime_prime, rc_prime)[:, None, :] * factor2[:, :, None] + + integral_term = dphi * jnp.sum(first_term + second_term, 1) + + return current * Biot_savart_prefactor * (analytic_term + integral_term) + + +def B_regularized(coil, regularization): + """Calculate the regularized field on a coil following the Landreman and Hurwitz method""" + return B_regularized_pure( + coil.curve.gamma(), + coil.curve.gammadash(), + coil.curve.gammadashdash(), + coil.curve.quadpoints, + coil._current.get_value(), + regularization, + ) + + +def B_regularized_circ(coil, a): + return B_regularized(coil, regularization_circ(a)) + + +def B_regularized_rect(coil, a, b): + return B_regularized(coil, regularization_rect(a, b)) diff --git a/src/simsopt/geo/curve.py b/src/simsopt/geo/curve.py index bda3a5e61..4c012f372 100644 --- a/src/simsopt/geo/curve.py +++ b/src/simsopt/geo/curve.py @@ -813,7 +813,7 @@ def flip(self): return True if self.rotmat[2][2] == -1 else False -def curves_to_vtk(curves, filename, close=False): +def curves_to_vtk(curves, filename, close=False, extra_data=None): """ Export a list of Curve objects in VTK format, so they can be viewed using Paraview. This function requires the python package ``pyevtk``, @@ -840,7 +840,12 @@ def wrap(data): z = np.concatenate([c.gamma()[:, 2] for c in curves]) ppl = np.asarray([c.gamma().shape[0] for c in curves]) data = np.concatenate([i*np.ones((ppl[i], )) for i in range(len(curves))]) - polyLinesToVTK(str(filename), x, y, z, pointsPerLine=ppl, pointData={'idx': data}) + pointData = {'idx': data} + + if extra_data is not None: + pointData = {**pointData, **extra_data} + + polyLinesToVTK(str(filename), x, y, z, pointsPerLine=ppl, pointData=pointData) def create_equally_spaced_curves(ncurves, nfp, stellsym, R0=1.0, R1=0.5, order=6, numquadpoints=None): diff --git a/src/simsopt/geo/framedcurve.py b/src/simsopt/geo/framedcurve.py index dc883418f..d4488ca0a 100644 --- a/src/simsopt/geo/framedcurve.py +++ b/src/simsopt/geo/framedcurve.py @@ -647,4 +647,4 @@ def binormal_curvature_pure_centroid(gamma, gammadash, gammadashdash, gamma, gammadash, gammadashdash, alpha, alphadash) tdash *= 1/jnp.linalg.norm(gammadash, axis=1)[:, None] - return inner(tdash, b) + return inner(tdash, b) \ No newline at end of file diff --git a/src/simsopt/objectives/utilities.py b/src/simsopt/objectives/utilities.py index 2fe22c2e2..3cb50a4d5 100644 --- a/src/simsopt/objectives/utilities.py +++ b/src/simsopt/objectives/utilities.py @@ -194,13 +194,16 @@ def dJ(self): class Weight(object): - def __init__(self, value): self.value = float(value) def __float__(self): return float(self.value) + def __iadd__(self, alpha): + self.value += alpha + return self + def __imul__(self, alpha): self.value *= alpha return self diff --git a/tests/core/test_derivative.py b/tests/core/test_derivative.py index f14d7128a..d0d19a957 100644 --- a/tests/core/test_derivative.py +++ b/tests/core/test_derivative.py @@ -155,7 +155,7 @@ def test_scaled_optimizable_operator(self): """ Confirm that values and derivatives behave correctly when an Optimizable object is scaled by a constant, overloading the * - operator. + and + operators. """ opt1a = Opt(n=2) opt1b = Opt(n=5) @@ -179,6 +179,11 @@ def test_scaled_optimizable_operator(self): taylor_test(obj3) np.testing.assert_allclose(obj3.J(), 3*factor * obj1.J()) np.testing.assert_allclose(obj3.dJ(), 3*factor * obj1.dJ()) + shift = 46.2 + w += shift + taylor_test(obj3) + np.testing.assert_allclose(obj3.J(), (3*factor + shift) * obj1.J()) + np.testing.assert_allclose(obj3.dJ(), (3*factor + shift) * obj1.dJ()) def test_optimizable_sum(self): """ diff --git a/tests/field/test_mgrid.py b/tests/field/test_mgrid.py index c4872a247..f5b9b1c2e 100644 --- a/tests/field/test_mgrid.py +++ b/tests/field/test_mgrid.py @@ -92,6 +92,7 @@ def test_free_boundary_vmec(self): bs = BiotSavart(coils) eq = Vmec(input_file) nphi = 24 + original_directory = os.getcwd() with tempfile.TemporaryDirectory() as tmpdir: os.chdir(tmpdir) # Use temporary directory for vmec files filename = Path(tmpdir) / "mgrid.bfield.nc" @@ -126,3 +127,6 @@ def test_free_boundary_vmec(self): assert eq.wout.fsqz < ftol assert eq.wout.ier_flag == 0 + # If we do not change back to the original directory, so the current + # directory no longer exists, then later tests involving get_*_data will fail. + os.chdir(original_directory) diff --git a/tests/field/test_selffieldforces.py b/tests/field/test_selffieldforces.py new file mode 100644 index 000000000..78e762165 --- /dev/null +++ b/tests/field/test_selffieldforces.py @@ -0,0 +1,315 @@ +import unittest +import logging + +import numpy as np +from scipy import constants +from scipy.interpolate import interp1d + +from simsopt.field import Coil, Current, coils_via_symmetries +from simsopt.geo.curve import create_equally_spaced_curves +from simsopt.configs import get_hsx_data, get_ncsx_data +from simsopt.geo import CurveXYZFourier +from simsopt.field.selffield import ( + B_regularized_circ, + B_regularized_rect, + rectangular_xsection_k, + rectangular_xsection_delta, + regularization_circ, +) +from simsopt.field.force import ( + coil_force, + self_force_circ, + self_force_rect, + MeanSquaredForce, + LpCurveForce) + +logger = logging.getLogger(__name__) + + +class SpecialFunctionsTests(unittest.TestCase): + """ + Test the functions that are specific to the reduced model for rectangular + cross-section coils. + """ + + def test_k_square(self): + """Check value of k for a square cross-section.""" + truth = 2.556493222766492 + np.testing.assert_allclose(rectangular_xsection_k(0.3, 0.3), truth) + np.testing.assert_allclose(rectangular_xsection_k(2.7, 2.7), truth) + + def test_delta_square(self): + """Check value of delta for a square cross-section.""" + truth = 0.19985294779417703 + np.testing.assert_allclose(rectangular_xsection_delta(0.3, 0.3), truth) + np.testing.assert_allclose(rectangular_xsection_delta(2.7, 2.7), truth) + + def test_symmetry(self): + """k and delta should be unchanged if a and b are swapped.""" + d = 0.01 # Geometric mean of a and b + for ratio in [0.1, 3.7]: + a = d * ratio + b = d / ratio + np.testing.assert_allclose( + rectangular_xsection_delta(a, b), rectangular_xsection_delta(b, a) + ) + np.testing.assert_allclose( + rectangular_xsection_k(a, b), rectangular_xsection_k(b, a) + ) + + def test_limits(self): + """Check limits of k and delta for a >> b and b >> a.""" + ratios = [1.1e6, 2.2e4, 3.5e5] + xs = [0.2, 1.0, 7.3] + for ratio in ratios: + for x in xs: + # a >> b + b = x + a = b * ratio + np.testing.assert_allclose(rectangular_xsection_k(a, b), (7.0 / 6) + np.log(a / b), rtol=1e-3) + np.testing.assert_allclose(rectangular_xsection_delta(a, b), a / (b * np.exp(3)), rtol=1e-3) + + # b >> a + a = x + b = ratio * a + np.testing.assert_allclose(rectangular_xsection_k(a, b), (7.0 / 6) + np.log(b / a), rtol=1e-3) + np.testing.assert_allclose(rectangular_xsection_delta(a, b), b / (a * np.exp(3)), rtol=1e-3) + + +class CoilForcesTest(unittest.TestCase): + + def test_circular_coil(self): + """Check whether B_reg and hoop force on a circular-centerline coil are correct.""" + R0 = 1.7 + I = 10000 + a = 0.01 + b = 0.023 + order = 1 + + # Analytic field has only a z component + B_reg_analytic_circ = constants.mu_0 * I / (4 * np.pi * R0) * (np.log(8 * R0 / a) - 3 / 4) + # Eq (98) in Landreman Hurwitz Antonsen: + B_reg_analytic_rect = constants.mu_0 * I / (4 * np.pi * R0) * ( + np.log(8 * R0 / np.sqrt(a * b)) + 13.0 / 12 - rectangular_xsection_k(a, b) / 2 + ) + force_analytic_circ = B_reg_analytic_circ * I + force_analytic_rect = B_reg_analytic_rect * I + + for N_quad in [23, 13, 23]: + + # Create a circle of radius R0 in the x-y plane: + curve = CurveXYZFourier(N_quad, order) + curve.x = np.array([0, 0, 1, 0, 1, 0, 0, 0., 0.]) * R0 + phi = 2 * np.pi * curve.quadpoints + + current = Current(I) + coil = Coil(curve, current) + + # Check the case of circular cross-section: + + B_reg_test = B_regularized_circ(coil, a) + np.testing.assert_allclose(B_reg_test[:, 2], B_reg_analytic_circ) + np.testing.assert_allclose(B_reg_test[:, 0:2], 0) + + force_test = self_force_circ(coil, a) + np.testing.assert_allclose(force_test[:, 0], force_analytic_circ * np.cos(phi)) + np.testing.assert_allclose(force_test[:, 1], force_analytic_circ * np.sin(phi)) + np.testing.assert_allclose(force_test[:, 2], 0.0) + + # Check the case of rectangular cross-section: + + B_reg_test = B_regularized_rect(coil, a, b) + np.testing.assert_allclose(B_reg_test[:, 2], B_reg_analytic_rect) + np.testing.assert_allclose(B_reg_test[:, 0:2], 0) + + force_test = self_force_rect(coil, a, b) + np.testing.assert_allclose(force_test[:, 0], force_analytic_rect * np.cos(phi)) + np.testing.assert_allclose(force_test[:, 1], force_analytic_rect * np.sin(phi)) + np.testing.assert_allclose(force_test[:, 2], 0.0) + + def test_force_convergence(self): + """Check that the self-force is approximately independent of the number of quadrature points""" + ppps = [8, 4, 2, 7, 5] + for j, ppp in enumerate(ppps): + curves, currents, ma = get_hsx_data(ppp=ppp) + curve = curves[0] + I = 1.5e3 + a = 0.01 + coil = Coil(curve, Current(I)) + force = self_force_circ(coil, a) + max_force = np.max(np.abs(force)) + #print("ppp:", ppp, " max force:", max_force) + if j == 0: + interpolant = interp1d(curve.quadpoints, force, axis=0) + max_force_ref = max_force + else: + np.testing.assert_allclose(force, interpolant(curve.quadpoints), atol=max_force_ref / 60) + #print(np.max(np.abs(force - interpolant(curve.quadpoints)))) + #plt.plot(curve.quadpoints, force[:, 0], '+-') + #plt.plot(curve.quadpoints, interpolant(curve.quadpoints)[:, 0], 'x-') + #plt.show() + + def test_hsx_coil(self): + """Compare self-force for HSX coil 1 to result from CoilForces.jl""" + curves, currents, ma = get_hsx_data() + assert len(curves[0].quadpoints) == 160 + I = 150e3 + a = 0.01 + b = 0.023 + coil = Coil(curves[0], Current(I)) + + # Case of circular cross-section + + # The data from CoilForces.jl were generated with the command + # CoilForces.reference_HSX_force_for_simsopt_test() + F_x_benchmark = np.array( + [-15624.06752062059, -21673.892879345873, -27805.92218896322, -33138.2025931857, -36514.62850757798, -37154.811045050716, -35224.36483811566, -31790.6909934216, -28271.570764376913, -25877.063414550663, -25275.54000792784, -26426.552957555898, -28608.08732785721, -30742.66146788618, -31901.1192650387, -31658.2982018783, -30115.01252455622, -27693.625158453917, -24916.97602450875, -22268.001550194127, -20113.123569572494, -18657.02934190755, -17925.729621918534, -17787.670352261383, -18012.98424762069, -18355.612668419068, -18631.130455525174, -18762.19098176415, -18778.162916012046, -18776.500656205895, -18866.881771744567, -19120.832848894337, -19543.090214569205, -20070.954769137115, -20598.194181114803, -21013.020202255055, -21236.028702664324, -21244.690600996386, -21076.947768954156, -20815.355048694666, -20560.007956111527, -20400.310604802795, -20393.566682281307, -20554.83647318684, -20858.986285059094, -21253.088938981215, -21675.620708707665, -22078.139271497712, -22445.18444801059, -22808.75225496607, -23254.130115531163, -23913.827617806084, -24946.957266144746, -26504.403695291898, -28685.32300927181, -31495.471071978012, -34819.49374359714, -38414.82789487393, -41923.29333627555, -44885.22293635466, -46749.75134352123, -46917.59025432583, -44896.50887106118, -40598.462003586974, -34608.57105847433, -28108.332731765862, -22356.321253373, -18075.405570497107, -15192.820251877345, -13027.925896696135, -10728.68775277632, -7731.104577216556, -4026.458734812997, -67.65800705092924, 3603.7480987311537, 6685.7274727329805, 9170.743233515725, 11193.25631660189, 12863.446736995473, 14199.174999621611, 15157.063376046968, 15709.513692788054, 15907.086239630167, 15889.032882713132, 15843.097529146156, 15944.109516240991, 16304.199171854023, 16953.280592130628, 17852.57440796256, 18932.066168700923, 20133.516941300426, 21437.167716977303, 22858.402963585464, 24417.568974489524, 26100.277202379944, 27828.811426061613, 29459.771430218898, 30813.7836860175, 31730.62350657151, 32128.502820609796, 32038.429339023023, 31593.803847403953, 30979.028723505002, 30362.077268204735, 29840.850204702965, 29422.877198133527, 29042.28057709125, 28604.02774189412, 28036.121314230902, 27327.793860493435, 26538.11580899982, 25773.01411179288, 25142.696104375616, 24718.6066327647, 24507.334842447635, 24451.10991168722, 24454.085831995577, 24423.536258237124, 24308.931868210013, 24122.627773352768, 23933.764307662732, 23838.57162949479, 23919.941100154054, 24212.798983180386, 24689.158548635372, 25269.212310785344, 25854.347267952628, 26368.228758087153, 26787.918123459167, 27150.79244000832, 27533.348289627098, 28010.279752667528, 28611.021858534772, 29293.073660468486, 29946.40958260143, 30430.92513540546, 30631.564524187717, 30503.197269324868, 30080.279217014842, 29444.6938562621, 28667.38229651914, 27753.348490269695, 26621.137071620036, 25137.82866539427, 23205.371963209964, 20853.92976118877, 18273.842305983166, 15753.018584850472, 13562.095187201534, 11864.517807863573, 10688.16332321768, 9935.766441264674, 9398.023223792645, 8766.844594289494, 7680.841209848606, 5824.4042671660145, 3040.702284846631, -630.2054351866387, -5035.57692055936, -10048.785939525675] + ) + + F_x_test = self_force_circ(coil, a)[:, 0] + np.testing.assert_allclose(F_x_benchmark, F_x_test, rtol=1e-9, atol=0) + + # Case of rectangular cross-section + + F_x_benchmark = np.array( + [-15905.20099921593, -22089.84960387874, -28376.348489470365, -33849.08438046449, -37297.138833218974, -37901.3580214951, -35838.71064362283, -32228.643120480687, -28546.9118841109, -26046.96628692484, -25421.777194138715, -26630.791911489407, -28919.842325785943, -31157.40078884933, -32368.19957740524, -32111.184287572887, -30498.330514718982, -27974.45692852191, -25085.400672446423, -22334.49737678633, -20104.78648017159, -18610.931535243944, -17878.995292047493, -17767.35330442759, -18030.259902092654, -18406.512856357545, -18702.39969540496, -18838.862854941028, -18849.823944445518, -18840.62799920807, -18928.85330885538, -19191.02138695175, -19632.210519767978, -20185.474968977625, -20737.621297822592, -21169.977809582055, -21398.747768091078, -21400.62658689198, -21216.133558586924, -20932.595132161085, -20655.60793743372, -20479.40191077005, -20464.28582628529, -20625.83431400738, -20936.962932518098, -21341.067527434556, -21772.38656616101, -22178.862986210577, -22542.999300185398, -22897.045487538875, -23329.342412912913, -23978.387795050137, -25011.595805992223, -26588.8272541588, -28816.499234411625, -31703.566987071903, -35132.3971671138, -38852.71510558583, -42494.50815372789, -45583.48852415488, -47551.1577527285, -47776.415427331594, -45743.97982645536, -41354.37991615283, -35210.20495138465, -28540.23742988024, -22654.55869049082, -18301.96907423793, -15401.963398143102, -13243.762349314706, -10939.450828758423, -7900.820612170931, -4120.028225769904, -72.86209546891608, 3674.253747922276, 6809.0803070326565, 9328.115750414787, 11374.122069162511, 13062.097330371573, 14409.383808494194, 15369.251684718018, 15911.988418337934, 16090.021555975769, 16048.21613878066, 15981.151899412167, 16068.941633738388, 16425.88464448961, 17080.88532516404, 17992.129241265648, 19086.46631302506, 20304.322975363317, 21627.219065732254, 23073.563938875737, 24666.38845701993, 26391.47816311481, 28167.521012668185, 29843.93199662863, 31232.367301229497, 32164.969954389788, 32556.923587447265, 32442.446350951064, 31963.284032424053, 31314.01211399212, 30670.79551082286, 30135.039340095944, 29712.330052677768, 29330.71025802117, 28887.8200773726, 28306.412420411067, 27574.83013193789, 26755.843397583598, 25961.936385889934, 25310.01540139794, 24875.789463354584, 24666.066357125907, 24619.136261928328, 24632.619408002214, 24607.413073397413, 24489.503028993608, 24292.044623409187, 24088.74651990258, 23982.195361428472, 24060.929104794097, 24362.6460843878, 24858.082439252874, 25462.457564195745, 26070.50973682213, 26600.547196554344, 27028.01270305341, 27393.03996450607, 27777.872708277075, 28263.357416931998, 28882.7902495421, 29593.307386932454, 30279.887846398404, 30794.507327329207, 31014.791285198782, 30892.485429183558, 30464.50108998591, 29819.03800239511, 29033.577206319136, 28116.32127507844, 26983.626000124084, 25495.394951521277, 23544.852551314456, 21157.350595114454, 18526.131317622883, 15948.394109661942, 13705.248433750054, 11967.480036214449, 10766.293968812726, 10004.685998499026, 9470.706025372589, 8849.607342610005, 7769.149525451194, 5902.017638994769, 3084.6416074691333, -641.878548205229, -5119.944566458021, -10221.371299891642] + ) + + F_x_test = self_force_rect(coil, a, b)[:, 0] + np.testing.assert_allclose(F_x_benchmark, F_x_test, rtol=1e-9, atol=0) + + def test_force_objectives(self): + """Check whether objective function matches function for export""" + nfp = 3 + ncoils = 4 + I = 1.7e4 + regularization = regularization_circ(0.05) + + base_curves = create_equally_spaced_curves(ncoils, nfp, True) + base_currents = [Current(I) for j in range(ncoils)] + coils = coils_via_symmetries(base_curves, base_currents, nfp, True) + + # Test LpCurveForce + + p = 2.5 + threshold = 1.0e3 + objective = float(LpCurveForce(coils[0], coils, regularization, p=p, threshold=threshold).J()) + + # Now compute the objective a different way, using the independent + # coil_force function + gammadash_norm = np.linalg.norm(coils[0].curve.gammadash(), axis=1) + force_norm = np.linalg.norm(coil_force(coils[0], coils, regularization), axis=1) + print("force_norm mean:", np.mean(force_norm), "max:", np.max(force_norm)) + objective_alt = (1 / p) * np.sum(np.maximum(force_norm - threshold, 0)**p * gammadash_norm) + + print("objective:", objective, "objective_alt:", objective_alt, "diff:", objective - objective_alt) + np.testing.assert_allclose(objective, objective_alt) + + # Test MeanSquaredForce + + objective = float(MeanSquaredForce(coils[0], coils, regularization).J()) + + # Now compute the objective a different way, using the independent + # coil_force function + force_norm = np.linalg.norm(coil_force(coils[0], coils, regularization), axis=1) + objective_alt = np.sum(force_norm**2 * gammadash_norm) / np.sum(gammadash_norm) + + print("objective:", objective, "objective_alt:", objective_alt, "diff:", objective - objective_alt) + np.testing.assert_allclose(objective, objective_alt) + + + def test_update_points(self): + """Confirm that Biot-Savart evaluation points are updated when the + curve shapes change.""" + nfp = 4 + ncoils = 3 + I = 1.7e4 + regularization = regularization_circ(0.05) + + for objective_class in [MeanSquaredForce, LpCurveForce]: + + base_curves = create_equally_spaced_curves(ncoils, nfp, True, order=2) + base_currents = [Current(I) for j in range(ncoils)] + coils = coils_via_symmetries(base_curves, base_currents, nfp, True) + + objective = objective_class(coils[0], coils, regularization) + old_objective_value = objective.J() + old_biot_savart_points = objective.biotsavart.get_points_cart() + + # A deterministic random shift to the coil dofs: + shift = np.array([-0.06797948, -0.0808704 , -0.02680599, -0.02775893, -0.0325402 , + 0.04382695, 0.06629717, 0.05050437, -0.09781039, -0.07473099, + 0.03492035, 0.08474462, 0.06076695, 0.02420473, 0.00388997, + 0.06992079, 0.01505771, -0.09350505, -0.04637735, 0.00321853, + -0.04975992, 0.01802391, 0.09454193, 0.01964133, 0.09205931, + -0.09633654, -0.01449546, 0.07617653, 0.03008342, 0.00636141, + 0.09065833, 0.01628199, 0.02683667, 0.03453558, 0.03439423, + -0.07455501, 0.08084003, -0.02490166, -0.05911573, -0.0782221 , + -0.03001621, 0.01356862, 0.00085723, 0.06887564, 0.02843625, + -0.04448741, -0.01301828, 0.01511824]) + + objective.x = objective.x + shift + assert abs(objective.J() - old_objective_value) > 1e-6 + new_biot_savart_points = objective.biotsavart.get_points_cart() + assert not np.allclose(old_biot_savart_points, new_biot_savart_points) + # Objective2 is created directly at the new points after they are moved: + objective2 = objective_class(coils[0], coils, regularization) + print("objective 1:", objective.J(), "objective 2:", objective2.J()) + np.testing.assert_allclose(objective.J(), objective2.J()) + + + def test_meansquaredforces_taylor_test(self): + """Verify that dJ matches finite differences of J""" + # The Fourier spectrum of the NCSX coils is truncated - we don't need the + # actual coil shapes from the experiment, just a few nonzero dofs. + + curves, currents, axis = get_ncsx_data(Nt_coils=2) + coils = [Coil(curve, current) for curve, current in zip(curves, currents)] + + J = MeanSquaredForce(coils[0], coils, regularization_circ(0.05)) + dJ = J.dJ() + deriv = np.sum(dJ * np.ones_like(J.x)) + dofs = J.x + h = np.ones_like(dofs) + err = 100 + for i in range(10, 18): + eps = 0.5**i + J.x = dofs + eps * h + Jp = J.J() + J.x = dofs - eps * h + Jm = J.J() + deriv_est = (Jp - Jm) / (2 * eps) + err_new = np.abs(deriv_est - deriv) / np.abs(deriv) + # print("i:", i, "deriv_est:", deriv_est, "deriv:", deriv, "err_new:", err_new, "err:", err, "ratio:", err_new / err) + np.testing.assert_array_less(err_new, 0.3 * err) + err = err_new + + def test_lpcurveforces_taylor_test(self): + """Verify that dJ matches finite differences of J""" + # The Fourier spectrum of the NCSX coils is truncated - we don't need the + # actual coil shapes from the experiment, just a few nonzero dofs. + + curves, currents, axis = get_ncsx_data(Nt_coils=2) + coils = [Coil(curve, current) for curve, current in zip(curves, currents)] + + J = LpCurveForce(coils[0], coils, regularization_circ(0.05), 2.5) + dJ = J.dJ() + deriv = np.sum(dJ * np.ones_like(J.x)) + dofs = J.x + h = np.ones_like(dofs) + err = 100 + for i in range(10, 19): + eps = 0.5**i + J.x = dofs + eps * h + Jp = J.J() + J.x = dofs - eps * h + Jm = J.J() + deriv_est = (Jp - Jm) / (2 * eps) + err_new = np.abs(deriv_est - deriv) / np.abs(deriv) + # print("i:", i, "deriv_est:", deriv_est, "deriv:", deriv, "err_new:", err_new, "err:", err, "ratio:", err_new / err) + np.testing.assert_array_less(err_new, 0.31 * err) + err = err_new + + +if __name__ == '__main__': + unittest.main()