Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Integrate on boundary to compute length scale quantities #1094

Draft
wants to merge 31 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
56ead44
Merge branch 'fieldline_compute' into integrate_on_boundary
unalmis Jul 2, 2024
504cdfc
Merge branch 'fieldline_compute' into integrate_on_boundary
unalmis Jul 4, 2024
27a6fcb
Update master compute data with follog differences:
unalmis Jul 4, 2024
f1c7634
Add scaling by max(rho) information to description
unalmis Jul 4, 2024
2694e8c
Update master_compute_data from master
unalmis Jul 5, 2024
69fa9ce
Merge branch 'fieldline_compute' into integrate_on_boundary
unalmis Jul 5, 2024
93c710b
Remove unneeded resolution requirement from divergence theorems compu…
unalmis Jul 5, 2024
7970177
Merge branch 'fieldline_compute' into integrate_on_boundary
unalmis Jul 10, 2024
be11015
Make changes in light of https://github.com/PlasmaControl/DESC/issues…
unalmis Jul 11, 2024
80feb38
Merge branch 'fieldline_compute' into integrate_on_boundary
unalmis Jul 11, 2024
c31abcd
Fix math error in comment
unalmis Jul 12, 2024
acf0486
Update master compute data xyz: following changes
unalmis Jul 13, 2024
9974137
Merge branch 'fieldline_compute' into integrate_on_boundary
ddudt Jul 15, 2024
3ea229d
Merge branch 'master' into integrate_on_boundary
unalmis Jul 20, 2024
1f6c8ce
Loosen test tolerance for vmec comparison test for length scale quant…
unalmis Jul 20, 2024
8c778fa
Remove changes to adding_compute_funs.rst
unalmis Jul 20, 2024
5912c79
Fix bug in plot_1d
unalmis Jul 20, 2024
1659fc0
Fix test by suppressing warning
unalmis Jul 21, 2024
4222b74
Fix unrecognized char error since warnings doesn't recognize unicode
unalmis Jul 21, 2024
cf81a3c
Fix test that assumes S = S(r) instead of outermost
unalmis Jul 21, 2024
1bc81ae
Merge branch 'master' into integrate_on_boundary
unalmis Aug 21, 2024
b6bb3ce
Merge branch 'master' into integrate_on_boundary
unalmis Sep 4, 2024
167156f
Merge branch 'master' into integrate_on_boundary
unalmis Sep 16, 2024
780ddb3
clean up comment
unalmis Sep 16, 2024
a93b98b
Remove old code
unalmis Sep 17, 2024
66b402a
Merge branch 'master' into integrate_on_boundary
unalmis Sep 19, 2024
01e212f
Merge branch 'master' into integrate_on_boundary
unalmis Sep 30, 2024
d7c6d26
Merge branch 'master' into integrate_on_boundary
unalmis Oct 6, 2024
3fb1ccf
Merge branch 'master' into integrate_on_boundary
unalmis Oct 17, 2024
c21913e
Merge branch 'master' into integrate_on_boundary
unalmis Oct 18, 2024
6adaa96
Using grid requirement instead of warnings
unalmis Oct 20, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions desc/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,14 @@ def tangent_solve(g, y):
)
return x, (jnp.linalg.norm(res), niter)

def trapezoid(y, x=None, dx=1.0, axis=-1):
"""Integrate along the given axis using the composite trapezoidal rule."""
if hasattr(jnp, "trapezoid"):
# https://github.com/google/jax/issues/20410
return jnp.trapezoid(y, x, dx, axis)
else:
return jax.scipy.integrate.trapezoid(y, x, dx, axis)


# we can't really test the numpy backend stuff in automated testing, so we ignore it
# for coverage purposes
Expand Down Expand Up @@ -644,6 +652,14 @@ def repeat(a, repeats, axis=None, total_repeat_length=None):
out = out[:total_repeat_length]
return out

def trapezoid(y, x=None, dx=1.0, axis=-1):
"""Integrate along the given axis using the composite trapezoidal rule."""
if hasattr(np, "trapezoid"):
# https://github.com/numpy/numpy/issues/25586
return np.trapezoid(y, x, dx, axis)
else:
return np.trapz(y, x, dx, axis)

def custom_jvp(fun, *args, **kwargs):
"""Dummy function for custom_jvp without JAX."""
fun.defjvp = lambda *args, **kwargs: None
Expand Down
126 changes: 65 additions & 61 deletions desc/compute/_geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@
expensive computations.
"""

from desc.backend import jnp
from desc.backend import jnp, trapezoid

from ..grid import QuadratureGrid
from .data_index import register_compute_fun
from .geom_utils import warnif_sym
from .utils import cross, dot, line_integrals, safenorm, surface_integrals


Expand All @@ -26,11 +28,16 @@
transforms={"grid": []},
profiles=[],
coordinates="",
data=["sqrt(g)"],
data=["sqrt(g)", "V(r)", "rho"],
resolution_requirement="rtz",
)
def _V(params, transforms, profiles, data, **kwargs):
data["V"] = jnp.sum(data["sqrt(g)"] * transforms["grid"].weights)
if isinstance(transforms["grid"], QuadratureGrid):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we include logic like this for some of the other cases? My main worry is that for higher order quantities that depend on V,A,a etc we're making it so that you need multiple different grids just to compute everything correctly, but i think in theory a quadrature grid should work for everything (though may be overkill in some cases)

data["V"] = jnp.sum(data["sqrt(g)"] * transforms["grid"].weights)
else:
# To approximate volume at ρ ~ 1, we scale by ρ⁻², assuming the integrand
# varies little from ρ = max_rho to ρ = 1 and a roughly circular cross-section.
data["V"] = jnp.max(data["V(r)"]) / jnp.max(data["rho"]) ** 2
return data


Expand All @@ -39,27 +46,19 @@ def _V(params, transforms, profiles, data, **kwargs):
label="V",
units="m^{3}",
units_long="cubic meters",
description="Volume",
description="Volume enclosed by surface, scaled by max(ρ)⁻²",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

idk if this docstring is clear enough, something like "if grid does not contain rho=1" or something could possibly help?

dim=1,
params=[],
transforms={"grid": []},
profiles=[],
coordinates="",
data=["e_theta", "e_zeta", "x"],
data=["V(r)", "rho"],
parameterization="desc.geometry.surface.FourierRZToroidalSurface",
resolution_requirement="tz",
)
def _V_FourierRZToroidalSurface(params, transforms, profiles, data, **kwargs):
# divergence theorem: integral(dV div [0, 0, Z]) = integral(dS dot [0, 0, Z])
data["V"] = jnp.max( # take max in case there are multiple surfaces for some reason
jnp.abs(
surface_integrals(
transforms["grid"],
cross(data["e_theta"], data["e_zeta"])[:, 2] * data["x"][:, 2],
expand_out=False,
)
)
)
# To approximate volume at ρ ~ 1, we scale by ρ⁻², assuming the integrand
# varies little from ρ = max_rho to ρ = 1 and a roughly circular cross-section.
data["V"] = jnp.max(data["V(r)"]) / jnp.max(data["rho"]) ** 2
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to do this scaling here? IE, if the user creates a surface with rho=0.7, do we want to compute the actual volume? or the extrapolated volume?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a good point, if I want an interior volume this would still extrapolate. Maybe there should be a "V_full" key that does extrapolation and a "V" that just gives the volume? I guess that is just an alias for V(r) though

return data


Expand All @@ -75,6 +74,10 @@ def _V_FourierRZToroidalSurface(params, transforms, profiles, data, **kwargs):
profiles=[],
coordinates="r",
data=["e_theta", "e_zeta", "Z"],
parameterization=[
"desc.equilibrium.equilibrium.Equilibrium",
"desc.geometry.surface.FourierRZToroidalSurface",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using this for FourierRZToroidalSurface feels a bit weird since in that case its not really a funciton of rho.

],
resolution_requirement="tz",
)
def _V_of_r(params, transforms, profiles, data, **kwargs):
Expand Down Expand Up @@ -155,45 +158,20 @@ def _V_rrr_of_r(params, transforms, profiles, data, **kwargs):
label="A(\\zeta)",
units="m^{2}",
units_long="square meters",
description="Cross-sectional area as function of zeta",
description="Area of enclosed cross-section (enclosed constant phi surface), "
"scaled by max(ρ)⁻², as function of zeta",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same point on clarity as above

dim=1,
params=[],
transforms={"grid": []},
profiles=[],
coordinates="z",
data=["|e_rho x e_theta|"],
data=["Z", "n_rho", "e_theta|r,p", "rho"],
parameterization=[
"desc.equilibrium.equilibrium.Equilibrium",
"desc.geometry.surface.ZernikeRZToroidalSection",
"desc.geometry.surface.FourierRZToroidalSurface",
],
resolution_requirement="rt",
)
def _A_of_z(params, transforms, profiles, data, **kwargs):
data["A(z)"] = surface_integrals(
unalmis marked this conversation as resolved.
Show resolved Hide resolved
transforms["grid"],
data["|e_rho x e_theta|"],
surface_label="zeta",
expand_out=True,
)
return data


@register_compute_fun(
name="A(z)",
label="A(\\zeta)",
units="m^{2}",
units_long="square meters",
description="Area of enclosed cross-section (enclosed constant phi surface), "
"scaled by max(ρ)⁻², as function of zeta",
dim=1,
params=[],
transforms={"grid": []},
profiles=[],
coordinates="z",
data=["Z", "n_rho", "e_theta|r,p", "rho"],
parameterization=["desc.geometry.surface.FourierRZToroidalSurface"],
resolution_requirement="rt", # just need max(rho) near 1
# FIXME: Add source grid requirement once omega is nonzero.
resolution_requirement="t",
)
def _A_of_z_FourierRZToroidalSurface(params, transforms, profiles, data, **kwargs):
# Denote any vector v = [vᴿ, v^ϕ, vᶻ] with a tuple of its contravariant components.
Expand All @@ -204,6 +182,7 @@ def _A_of_z_FourierRZToroidalSurface(params, transforms, profiles, data, **kwarg
# where n is the unit normal such that n dot e_θ|ρ,ϕ = 0 and n dot e_ϕ|R,Z = 0,
# and the labels following | denote those coordinates are fixed.
# Now choose v = [0, 0, Z], and n in the direction (e_θ|ρ,ζ × e_ζ|ρ,θ) ⊗ [1, 0, 1].
warnif_sym(transforms["grid"], "A(z)")
n = data["n_rho"]
n = n.at[:, 1].set(0)
n = n / jnp.linalg.norm(n, axis=-1)[:, jnp.newaxis]
Expand Down Expand Up @@ -232,23 +211,46 @@ def _A_of_z_FourierRZToroidalSurface(params, transforms, profiles, data, **kwarg
label="A",
units="m^{2}",
units_long="square meters",
description="Average cross-sectional area",
description="Average enclosed cross-sectional area, scaled by max(ρ)⁻²",
dim=0,
params=[],
transforms={"grid": []},
profiles=[],
coordinates="",
data=["A(z)"],
data=["Z", "n_rho", "e_theta|r,p", "rho", "phi"],
parameterization=[
"desc.equilibrium.equilibrium.Equilibrium",
"desc.geometry.core.Surface",
"desc.equilibrium.equilibrium.Equilibrium",
],
resolution_requirement="z",
resolution_requirement="tz",
)
def _A(params, transforms, profiles, data, **kwargs):
data["A"] = jnp.mean(
transforms["grid"].compress(data["A(z)"], surface_label="zeta")
# Denote any vector v = [vᴿ, v^ϕ, vᶻ] with a tuple of its contravariant components.
# We use a 2D divergence theorem over constant ϕ toroidal surface (i.e. R, Z plane).
# In this geometry, the divergence operator on a polar basis vector is
# div = ([∂_R, ∂_ϕ, ∂_Z] ⊗ [1, 0, 1]) dot .
unalmis marked this conversation as resolved.
Show resolved Hide resolved
# ∫ dA div v = ∫ dℓ n dot v
# where n is the unit normal such that n dot e_θ|ρ,ϕ = 0 and n dot e_ϕ|R,Z = 0,
# and the labels following | denote those coordinates are fixed.
# Now choose v = [0, 0, Z], and n in the direction (e_θ|ρ,ζ × e_ζ|ρ,θ) ⊗ [1, 0, 1].
n = data["n_rho"]
Copy link
Collaborator Author

@unalmis unalmis Oct 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

iirc because n_rho should not be defined on zeta cross section (#1127 ) the task is to rewrite this to obtain normal vector purely from e_theta which is defined on zeta cross section alone. basically like #597 (comment).

n = n.at[:, 1].set(0)
n = n / jnp.linalg.norm(n, axis=-1)[:, jnp.newaxis]
max_rho = jnp.max(data["rho"])
A = jnp.abs(
line_integrals(
transforms["grid"],
data["Z"] * n[:, 2] * safenorm(data["e_theta|r,p"], axis=-1),
line_label="theta",
fix_surface=("rho", max_rho),
expand_out=False,
)
# To approximate area at ρ ~ 1, we scale by ρ⁻², assuming the integrand
# varies little from ρ = max_rho to ρ = 1 and a roughly circular cross-section.
/ max_rho**2
)
phi = transforms["grid"].compress(data["phi"], "zeta")
data["A"] = jnp.squeeze(trapezoid(A, phi) / jnp.ptp(phi) if A.size > 1 else A)
return data


Expand All @@ -275,25 +277,22 @@ def _A_of_r(params, transforms, profiles, data, **kwargs):
label="S",
units="m^{2}",
units_long="square meters",
description="Surface area of outermost flux surface",
description="Surface area of outermost flux surface, scaled by max(ρ)⁻¹",
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want this?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you referring to the docstring saying scaled by max rho? I think so

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but I also changed the compute function so that we scale by max rho. Do we want to do that?

dim=0,
params=[],
transforms={"grid": []},
profiles=[],
coordinates="",
data=["|e_theta x e_zeta|"],
data=["S(r)", "rho"],
parameterization=[
"desc.equilibrium.equilibrium.Equilibrium",
"desc.geometry.surface.FourierRZToroidalSurface",
],
resolution_requirement="tz",
)
def _S(params, transforms, profiles, data, **kwargs):
data["S"] = jnp.max(
surface_integrals(
transforms["grid"], data["|e_theta x e_zeta|"], expand_out=False
)
)
# To approximate surface are at ρ ~ 1, we scale by ρ⁻¹, assuming the integrand
# varies little from ρ = max_rho to ρ = 1.
data["S"] = jnp.max(data["S(r)"]) / jnp.max(data["rho"])
return data


Expand All @@ -309,6 +308,10 @@ def _S(params, transforms, profiles, data, **kwargs):
profiles=[],
coordinates="r",
data=["|e_theta x e_zeta|"],
parameterization=[
"desc.equilibrium.equilibrium.Equilibrium",
"desc.geometry.surface.FourierRZToroidalSurface",
],
resolution_requirement="tz",
)
def _S_of_r(params, transforms, profiles, data, **kwargs):
Expand Down Expand Up @@ -440,9 +443,10 @@ def _R0_over_a(params, transforms, profiles, data, **kwargs):
"desc.equilibrium.equilibrium.Equilibrium",
"desc.geometry.core.Surface",
],
resolution_requirement="rt", # just need max(rho) near 1
resolution_requirement="t",
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

otherwise, we get unnecessary warnings when using a grid with rho=1 surface

)
def _perimeter_of_z(params, transforms, profiles, data, **kwargs):
warnif_sym(transforms["grid"], "perimeter(z)")
max_rho = jnp.max(data["rho"])
data["perimeter(z)"] = (
line_integrals(
Expand Down
29 changes: 29 additions & 0 deletions desc/compute/geom_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,40 @@

import functools

from termcolor import colored

from desc.backend import jnp

from ..utils import ResolutionWarning, errorif, warnif
from .utils import safenorm, safenormalize


def warnif_sym(grid, name):
"""Warn if grid has truncated poloidal domain to [0, π] ⊂ [0, 2π)."""
warnif(
grid.sym,
ResolutionWarning,
msg=colored(
"This grid only samples the poloidal domain θ ∈ [0, π], "
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should mention stellarator symmetry in the warning as well (this grid has stellarator sym, which means it only samples...)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe also say that a grid with sym=False should be used instead

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the first note, in a related issue mentioned at top, I mention the goal

Update documentation of Grid class to reflect that grid.sym is unrelated to stellarator symmetry, rather it's a memory optimization to compute certain quantities under symmetry.

So I don't want to refer to grid as having stellarator symmetry.

f"but, in general, the full domain [0, 2π) is required to compute {name}.",
"yellow",
),
)


def errorif_sym(grid, name):
"""Warn if grid has truncated poloidal domain to [0, π] ⊂ [0, 2π)."""
errorif(
grid.sym,
ResolutionWarning,
msg=colored(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto above comment

"This grid only samples the poloidal domain θ ∈ [0, π], "
f"but, in general, the full domain [0, 2π) is required to compute {name}.",
"yellow",
),
)


def reflection_matrix(normal):
"""Matrix to reflect points across plane through origin with specified normal.

Expand Down
2 changes: 1 addition & 1 deletion desc/compute/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -903,7 +903,7 @@ def line_integrals(
The coordinate curve to compute the integration over.
To clarify, a theta (poloidal) curve is the intersection of a
rho surface (flux surface) and zeta (toroidal) surface.
fix_surface : str, float
fix_surface : (str, float)
A tuple of the form: label, value.
``fix_surface`` label should differ from ``line_label``.
By default, ``fix_surface`` is chosen to be the flux surface at rho=1.
Expand Down
36 changes: 25 additions & 11 deletions desc/grid.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,19 +66,19 @@ def _set_up(self):
del self._unique_theta_idx

def _enforce_symmetry(self):
"""Enforce stellarator symmetry.
"""Remove unnecessary nodes assuming poloidal symmetry.

1. Remove nodes with theta > pi.
2. Rescale theta spacing to preserve dtheta weight.
Need to rescale on each theta coordinate curve by a different factor.
dtheta should = 2π / number of nodes remaining on that theta curve.
Nodes on the symmetry line should not be rescaled.
1. Remove nodes with θ > π.
2. Rescale θ spacing to preserve weight.
Need to rescale on each θ coordinate curve by a different factor.
= 2π / number of nodes remaining on that θ curve.
Nodes on the symmetry line should not be rescaled.

"""
if not self.sym:
return
# indices where poloidal coordinate is off the symmetry line of
# poloidal coord=0 or pi
# poloidal coord=0 or π
off_sym_line_idx = self.nodes[:, 1] % np.pi != 0
__, inverse, off_sym_line_per_rho_surf_count = np.unique(
self.nodes[off_sym_line_idx, 0], return_inverse=True, return_counts=True
Expand Down Expand Up @@ -109,7 +109,7 @@ def _enforce_symmetry(self):
# The first two assumptions let _per_poloidal_curve = _per_rho_surf.
# The third assumption lets the scale factor be constant over a
# particular theta curve, so that each node in the open interval
# (0, pi) has its spacing scaled up by the same factor.
# (0, π) has its spacing scaled up by the same factor.
# Nodes at endpoints 0, π should not be scaled.
scale = off_sym_line_per_rho_surf_count / (
off_sym_line_per_rho_surf_count - to_delete_per_rho_surf_count
Expand Down Expand Up @@ -206,7 +206,13 @@ def NFP(self):

@property
def sym(self):
"""bool: True for stellarator symmetry, False otherwise."""
"""bool: True for poloidal symmetry, False otherwise.

If true, the poloidal domain of this grid is [0, π] ⊂ [0, 2π).
Note that this is distinct from stellarator symmetry.
Still, when stellarator symmetry exists, flux surface integrals and
volume integrals are invariant to this truncation.
"""
return self.__dict__.setdefault("_sym", False)

@property
Expand Down Expand Up @@ -865,7 +871,11 @@ class LinearGrid(_Grid):
NFP : int
Number of field periods (Default = 1).
sym : bool
True for stellarator symmetry, False otherwise (Default = False).
Whether to truncate the poloidal domain to [0, π] ⊂ [0, 2π).
Note that this is distinct from stellarator symmetry.
Still, when stellarator symmetry exists, flux surface integrals and
volume integrals are invariant to this truncation, so setting this flag
to true will reduce memory consumption. (Default = False).
axis : bool
True to include a point at rho=0 (default), False for rho[0] = rho[1]/2.
endpoint : bool
Expand Down Expand Up @@ -1318,7 +1328,11 @@ class ConcentricGrid(_Grid):
NFP : int
number of field periods (Default = 1)
sym : bool
True for stellarator symmetry, False otherwise (Default = False)
Whether to truncate the poloidal domain to [0, π] ⊂ [0, 2π).
Note that this is distinct from stellarator symmetry.
Still, when stellarator symmetry exists, flux surface integrals and
volume integrals are invariant to this truncation, so setting this flag
to true will reduce memory consumption. (Default = False).
axis : bool
True to include the magnetic axis, False otherwise (Default = False)
node_pattern : {``'cheb1'``, ``'cheb2'``, ``'jacobi'``, ``linear``}
Expand Down
Loading