Skip to content

Commit

Permalink
Merge branch 'master' into rc/examples
Browse files Browse the repository at this point in the history
  • Loading branch information
f0uriest authored Dec 4, 2024
2 parents d44f467 + 005c4f8 commit cecc2f0
Show file tree
Hide file tree
Showing 11 changed files with 315 additions and 27 deletions.
13 changes: 10 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
Changelog
=========

New Feature

- Adds a new profile class ``PowerProfile`` for raising profiles to a power.

v0.13.0
-------

New Features

- Adds ``from_input_file`` method to ``Equilibrium`` class to generate an ``Equilibrium`` object with boundary, profiles, resolution and flux specified in a given DESC or VMEC input file
Expand All @@ -14,20 +21,20 @@ New Features
* use of both this and the ``QuadraticFlux`` objective allows for REGCOIL solutions to be obtained through the optimization framework, and combined with other objectives as well.
- Changes local area weighting of Bn in QuadraticFlux objective to be the square root of the local area element (Note that any existing optimizations using this objective may need different weights to achieve the same result now.)
- Adds a new tutorial showing how to use``REGCOIL`` features.
- Adds an ``NFP`` attribute to ``ScalarPotentialField``, ``VectorPotentialField`` and ``DommaschkPotentialField``, to allow ``SplineMagneticField.from_field`` and ``MagneticField.save_mgrid`` to efficiently take advantage of the discrete toroidal symmetry of these fields, if present.
- Adds ``SurfaceQuadraticFlux`` objective which minimizes the quadratic magnetic flux through a ``FourierRZToroidalSurface`` object, allowing for optimizing for Quadratic flux minimizing (QFM) surfaces.
- Allows ``ToroidalFlux`` objective to accept ``FourierRZToroidalSurface`` so it can be used to specify the toroidal flux through a QFM surface.
- Adds ``eq_fixed`` flag to ``ToroidalFlux`` to allow for the equilibrium/QFM surface to vary during optimization, useful for single-stage optimizations.
- Adds tutorial notebook showcasing QFM surface capability.
- Adds ``rotate_zeta`` function to ``desc.compat`` to rotate an ``Equilibrium`` around Z axis.
- Adds an option ``scaled_termination`` (defaults to True) to all of the desc optimizers to measure the norms for ``xtol`` and ``gtol`` in the scaled norm provided by ``x_scale`` (which defaults to using an adaptive scaling based on the Jacobian or Hessian). This should make things more robust when optimizing parameters with widely different magnitudes. The old behavior can be recovered by passing ``options={"scaled_termination": False}``.


Bug Fixes

- Fixes bug that occurs when taking the gradient of ``root`` and ``root_scalar`` with newer versions of JAX (>=0.4.34) and unpins the JAX version
- Fixes bug that occurs when taking the gradient of ``root`` and ``root_scalar`` with newer versions of JAX (>=0.4.34) and unpins the JAX version.
- Changes ``FixLambdaGauge`` constraint to now enforce zero flux surface average for lambda, instead of enforcing lambda(rho,0,0)=0 as it was incorrectly doing before.
- Fixes bug in ``softmin/softmax`` implementation.

- Fixes bug that occured when using ``ProximalProjection`` with a scalar optimization algorithm.

v0.12.3
-------
Expand Down
30 changes: 26 additions & 4 deletions desc/magnetic_fields/_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -1885,7 +1885,7 @@ def from_mgrid(cls, mgrid_file, extcur=None, method="cubic", extrap=False):

@classmethod
def from_field(
cls, field, R, phi, Z, params=None, method="cubic", extrap=False, NFP=1
cls, field, R, phi, Z, params=None, method="cubic", extrap=False, NFP=None
):
"""Create a splined magnetic field from another field for faster evaluation.
Expand All @@ -1903,14 +1903,16 @@ def from_field(
extrap : bool
whether to extrapolate splines beyond specified R,phi,Z
NFP : int, optional
Number of toroidal field periods.
Number of toroidal field periods. If not provided, will default to 1 or
the provided field's NFP, if it has that attribute.
"""
R, phi, Z = map(np.asarray, (R, phi, Z))
rr, pp, zz = np.meshgrid(R, phi, Z, indexing="ij")
shp = rr.shape
coords = np.array([rr.flatten(), pp.flatten(), zz.flatten()]).T
BR, BP, BZ = field.compute_magnetic_field(coords, params, basis="rpz").T
NFP = getattr(field, "_NFP", 1)
try:
AR, AP, AZ = field.compute_magnetic_vector_potential(
coords, params, basis="rpz"
Expand Down Expand Up @@ -1948,12 +1950,22 @@ class ScalarPotentialField(_MagneticField):
R,phi,Z are arrays of cylindrical coordinates.
params : dict, optional
default parameters to pass to potential function
NFP : int, optional
Whether the field has a discrete periodicity. This is only used when making
a ``SplineMagneticField`` from this field using its ``from_field`` method,
or when saving this field as an mgrid file using the ``save_mgrid`` method.
"""

def __init__(self, potential, params=None):
def __init__(self, potential, params=None, NFP=1):
self._potential = potential
self._params = params
self._NFP = NFP

@property
def NFP(self):
"""int: Number of (toroidal) field periods."""
return self._NFP

def compute_magnetic_field(
self, coords, params=None, basis="rpz", source_grid=None, transforms=None
Expand Down Expand Up @@ -2042,12 +2054,22 @@ class VectorPotentialField(_MagneticField):
R,phi,Z are arrays of cylindrical coordinates.
params : dict, optional
default parameters to pass to potential function
NFP : int, optional
Whether the field has a discrete periodicity. This is only used when making
a ``SplineMagneticField`` from this field using its ``from_field`` method,
or when saving this field as an mgrid file using the ``save_mgrid`` method.
"""

def __init__(self, potential, params=None):
def __init__(self, potential, params=None, NFP=1):
self._potential = potential
self._params = params
self._NFP = NFP

@property
def NFP(self):
"""int: Number of (toroidal) field periods."""
return self._NFP

def _compute_A_or_B(
self,
Expand Down
12 changes: 11 additions & 1 deletion desc/magnetic_fields/_dommaschk.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ class DommaschkPotentialField(ScalarPotentialField):
d_m_l coefficients of V_m_l terms, which multiply the sin(m*phi)*N_m_l-1 terms
B0: float
scale strength of the magnetic field's 1/R portion
NFP : int, optional
Whether the field has a discrete periodicity. This is only used when making
a ``SplineMagneticField`` from this field using its ``from_field`` method,
or when saving this field as an mgrid file using the ``save_mgrid`` method.
"""

Expand All @@ -50,6 +54,7 @@ def __init__(
c_arr=jnp.array([0.0]),
d_arr=jnp.array([0.0]),
B0=1.0,
NFP=1,
):
ms = jnp.atleast_1d(jnp.asarray(ms))
ls = jnp.atleast_1d(jnp.asarray(ls))
Expand All @@ -68,6 +73,11 @@ def __init__(
jnp.isscalar(B0) or jnp.atleast_1d(B0).size == 1
), "B0 should be a scalar value!"

ms_over_NFP = ms / NFP
assert jnp.allclose(
ms_over_NFP, ms_over_NFP.astype(int)
), "To enforce desired NFP, `ms` should be all integer multiples of NFP"

params = {}
params["ms"] = ms
params["ls"] = ls
Expand All @@ -77,7 +87,7 @@ def __init__(
params["d_arr"] = d_arr
params["B0"] = B0

super().__init__(dommaschk_potential, params)
super().__init__(dommaschk_potential, params, NFP)

@classmethod
def fit_magnetic_field( # noqa: C901
Expand Down
19 changes: 19 additions & 0 deletions desc/optimize/_constraint_wrappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -836,6 +836,25 @@ def compute_scaled_error(self, x, constants=None):
xopt, _ = self._update_equilibrium(x, store=False)
return self._objective.compute_scaled_error(xopt, constants[0])

def compute_scalar(self, x, constants=None):
"""Compute the sum of squares error.
Parameters
----------
x : ndarray
State vector.
constants : list
Constant parameters passed to sub-objectives.
Returns
-------
f : float
Objective function scalar value.
"""
f = jnp.sum(self.compute_scaled_error(x, constants=constants) ** 2) / 2
return f

def compute_unscaled(self, x, constants=None):
"""Compute the raw value of the objective function.
Expand Down
156 changes: 147 additions & 9 deletions desc/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,17 @@ def __sub__(self, x):
"""Subtract another profile from this one."""
return self.__add__(-x)

def __pow__(self, x):
"""Raise this profile to a power."""
if np.isscalar(x):
return PowerProfile(x, self)
else:
raise NotImplementedError()

def __rpow__(self, x):
"""Raise this profile to a power."""
return self.__pow__(x)


class ScaledProfile(_Profile):
"""Profile times a constant value.
Expand All @@ -252,10 +263,10 @@ class ScaledProfile(_Profile):
Parameters
----------
profile : Profile
Base profile to scale.
scale : float
Scale factor.
profile : Profile
Base profile to scale.
"""

Expand Down Expand Up @@ -335,6 +346,128 @@ def __repr__(self):
return s


class PowerProfile(_Profile):
"""Profile raised to a power.
f_1(x) = f(x)**a
Parameters
----------
power : float
Exponent of the new profile.
profile : Profile
Base profile to raise to a power.
"""

_io_attrs_ = _Profile._io_attrs_ + ["_profile", "_power"]

def __init__(self, power, profile, **kwargs):
assert isinstance(
profile, _Profile
), "profile in a PowerProfile must be a Profile or subclass, got {}.".format(
str(profile)
)
assert np.isscalar(power), "power must be a scalar."

self._profile = profile.copy()
self._power = power

self._check_params()

kwargs.setdefault("name", profile.name)
super().__init__(**kwargs)

def _check_params(self, params=None):
"""Check params and throw warnings or errors if necessary."""
params = self.params if params is None else params
power, params = self._parse_params(params)
warnif(
power < 0,
UserWarning,
"This profile may be undefined at some points because power < 0.",
)

@property
def params(self):
"""ndarray: Parameters for computation [power, profile.params]."""
return jnp.concatenate([jnp.atleast_1d(self._power), self._profile.params])

@params.setter
def params(self, x):
self._check_params(x)
self._power, self._profile.params = self._parse_params(x)

def _parse_params(self, x):
if x is None:
power = self._power
params = self._profile.params
elif isinstance(x, (tuple, list)) and len(x) == 2:
params = x[1]
power = x[0]
elif np.isscalar(x):
power = x
params = self._profile.params
elif len(x) == len(self._profile.params):
power = self._power
params = x
elif len(x) == len(self.params):
power = x[0]
params = x[1:]
else:
raise ValueError("Got wrong number of parameters for PowerProfile")
return power, params

def compute(self, grid, params=None, dr=0, dt=0, dz=0):
"""Compute values of profile at specified nodes.
Parameters
----------
grid : Grid
locations to compute values at.
params : array-like
Parameters to use. If not given, uses the
values given by the self.params attribute.
dr, dt, dz : int
derivative order in rho, theta, zeta.
Returns
-------
values : ndarray
values of the profile or its derivative at the points specified.
"""
if dt > 0 or dz > 0:
raise NotImplementedError(
"Poloidal and toroidal derivatives of PowerProfile have not been "
+ "implemented yet."
)
power, params = self._parse_params(params)
f0 = self._profile.compute(grid, params, 0, dt, dz)
if dr >= 1:
df1 = self._profile.compute(grid, params, 1, dt, dz) # df/dr
fn1 = self.compute(grid, (power - 1, params), 0, dt, dz) # f^(n-1)
if dr >= 2:
df2 = self._profile.compute(grid, params, 2, dt, dz) # d^2f/dr^2
fn2 = self.compute(grid, (power - 2, params), 0, dt, dz) # f^(n-2)
if dr == 0:
f = f0**power
elif dr == 1:
f = power * fn1 * df1
elif dr == 2:
f = power * ((power - 1) * fn2 * df1**2 + fn1 * df2)
else:
raise NotImplementedError("dr > 2 not implemented for PowerProfile!")
return f

def __repr__(self):
"""Get the string form of the object."""
s = super().__repr__()
s = s[:-1]
s += ", power={})".format(self._power)
return s


class SumProfile(_Profile):
"""Sum of two or more Profiles.
Expand Down Expand Up @@ -724,17 +857,24 @@ def __init__(self, params=None, name=""):
params = [0, 1, 1]
self._params = np.atleast_1d(params)

self._check_params()

def _check_params(self, params=None):
"""Check params and throw warnings or errors if necessary."""
params = self.params if params is None else params
errorif(
self._params.size != 3, ValueError, "params must be an array of size 3."
params.size != 3,
ValueError,
f"params must be an array of size 3, got {len(params)}.",
)
warnif(
self._params[1] < 1,
params[1] < 1,
UserWarning,
"Derivatives of this profile will be infinite at rho=0 "
+ "because params[1] < 1.",
)
warnif(
self._params[2] < 1,
params[2] < 1,
UserWarning,
"Derivatives of this profile will be infinite at rho=1 "
+ "because params[2] < 1.",
Expand All @@ -748,10 +888,8 @@ def params(self):
@params.setter
def params(self, new):
new = jnp.atleast_1d(jnp.asarray(new))
if new.size == 3:
self._params = jnp.asarray(new)
else:
raise ValueError(f"params should be an array of size 3, got {len(new)}.")
self._check_params(new)
self._params = new

def compute(self, grid, params=None, dr=0, dt=0, dz=0):
"""Compute values of profile at specified nodes.
Expand Down
Loading

0 comments on commit cecc2f0

Please sign in to comment.