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

Spec refactoring/improvement (PR 1 of 3 for single-stage) #418

Merged
merged 37 commits into from
Jul 8, 2024

Conversation

smiet
Copy link
Contributor

@smiet smiet commented May 30, 2024

Updates to Spec and NormalField and a few helper functions .

Spec class has been updated with the following features:

  • detection when free-boundary solution is not converged, re-set boundary field guess when detected.
  • default free-boundary configuration and classmethod to generate.
  • re-written loops pythonically where possible (for vol in range(self.inputlist.mvol+1) -> for surface in self.initial_guess)
  • implemented array_translator to reduce looping over index counters.
  • activate_profile helper function.

The NormalField class has been augmented to:

  • Always include the boundary on which it is defined (reference to associated SPEC boundary object).
  • Store the Fourier components that describe it in array-form.
  • include getters and setters for the FCs in array form.
  • set the dofs in their entirety even when setting single (fixes bug where changing a DOF did not trigger recompute bell).

The Surface base class has two new methods: fourier_transform_field and inverse_fourier_transform_field to efficiently calculate fourier transforms in the VMEC convention (-ntor to ntor toroidal components, 0:mpol poloidal). Allows for different Fourier normalization conventions through the 'normalization' parameter.

This is PR 1 of 3, the following two:

  • CoilSet and ReducedCoilSet: helper classes to enable interchanging of coils, and create new optimization variables through SVD on coil DOFS
  • CoilNormalField and misc changes: class inheriting form NormalField that interfaces to the CoilSets, all other misc changes needed to make the single-stage optimizations work.

Copy link

codecov bot commented May 30, 2024

Codecov Report

Attention: Patch coverage is 92.56018% with 34 lines in your changes missing coverage. Please review.

Project coverage is 92.52%. Comparing base (2c0f057) to head (a2a05ae).

Files Patch % Lines
src/simsopt/mhd/spec.py 90.29% 23 Missing ⚠️
src/simsopt/geo/surfacerzfourier.py 88.70% 7 Missing ⚠️
src/simsopt/field/normal_field.py 97.41% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #418      +/-   ##
==========================================
+ Coverage   92.00%   92.52%   +0.52%     
==========================================
  Files          75       75              
  Lines       13504    13833     +329     
==========================================
+ Hits        12424    12799     +375     
+ Misses       1080     1034      -46     
Flag Coverage Δ
unittests 92.52% <92.56%> (+0.52%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

Copy link
Contributor

@landreman landreman left a comment

Choose a reason for hiding this comment

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

Having the new Fourier transform and inverse transform functions go in the base Surface class feels awkward to me. These functions need mpol and ntor, which not all Surface classes have (e.g. SurfaceHenneberg, SurfaceGarabedian). Also the transform and inverse-transform functions don't use any of the surface geometry, just the grid points. So I would suggest moving those two functions to simsopt.util (probably not in a class, just standalone functions).

It also feels awkward for NormalField to carry around computational_boundary. See the comments attached to specific line numbers for some specific issues.

Codecov is flagging a lot of lines not covered by tests, 71% coverage for the changes compared to 91% for simsopt overall.


The Fourier harmonics are the degees of freedom, the computational boundary is kept fixed.
The normal field should not be normalized to unit area, i.e. it is the
fourier components of B.(\grad\theta \times \nabla\zeta) on the surface.
Copy link
Contributor

@landreman landreman Jun 2, 2024

Choose a reason for hiding this comment

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

$(\partial \vec{r}/\partial\theta) \times (\partial \vec{r}/\partial\phi)$ rather than $\grad\theta \times \nabla\zeta$

But stepping back, why not choose the opposite convention, B dot n where n is the unit vector? Then computational_boundary.normal() could be removed on line 487, making it mostly unnecessary for NormalField to keep or refer to computational_boundary. The only other dependence is that get_real_space_field() uses quadrature points but those could be arguments.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I use this convention because this is the way that SPEC requires the Fourier components of the normal field. This is currently the only use-case for NormalField, and it is simplest if the degrees-of-freedom correspond directly with the variables passed to SPEC.
It is possible to remove the convention and add have SPEC do the translation. or to pass to the class a convention='SPEC', which would raise a NotImplementedError if another convention is requested. would that be a solution?

I do prefer to have the NormalField carry a reference to the surface on which it is calculated; without this the DoF's of NormalField are meaningless. If the user needs to keep track of which surface object the field is evaluated on, it makes sense to me to have NormalField to reference it. Modifying its DoFs I agree is not warranted.
The surface info is also used in helper functions such as get_real_space_field, and I would consider adding a .plot funcionality where it plots the field on the surface.

@@ -839,6 +839,130 @@ def interpolate_on_arclength_grid(self, function, theta_evaluate):

return function_interpolated

def fourier_transform_field(self, field, mpol=None, ntor=None, normalization=None, **kwargs):
Copy link
Contributor

Choose a reason for hiding this comment

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

Throughout this function and the inverse-transform function, the use of the word "field" is potentially confusing. It is natural to interpret it to mean the magnetic field, which is a vector. But I guess you mean any scalar field? It would be good to clarify this in the function names, docstrings, etc.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I have changed the name to scalar as it is a general method of transforming scalars, and adapted it in the docstrings. It is now also moved to surfaceRZFourier and not the Surface base class.

The calculation of such a transform always requires information from the surface on which the scalar quantity is evaluated. The function could be in util, but I find it conceptually cleaner to have it as a method of the surfaceRZFourier.

tests/geo/test_surface_rzfourier.py Outdated Show resolved Hide resolved

# Test that all other elements are close to zero
sines_mask = np.ones_like(ft_sines, dtype=bool)
cosines_mask = sines_mask
Copy link
Contributor

Choose a reason for hiding this comment

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

You want a copy() here I think. Presently cosines_mask and sines_mask are two names for the same single object, so setting an entry in one modifies both.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks!

tests/mhd/test_spec.py Outdated Show resolved Hide resolved
src/simsopt/mhd/spec.py Outdated Show resolved Hide resolved
src/simsopt/field/normal_field.py Outdated Show resolved Hide resolved
src/simsopt/field/normal_field.py Outdated Show resolved Hide resolved
Comment on lines 953 to 957
if not stellsym:
cosangle = np.cos(angle)
field = field + A_mns[m, n + ntor] * sinangle
if not stellsym:
field = field + A_mnc[m, n + ntor] * cosangle
Copy link
Contributor

Choose a reason for hiding this comment

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

The two if statements can be combined into one.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

combined

@smiet
Copy link
Contributor Author

smiet commented Jun 17, 2024

I have adapted most changes and would like to move the review forward.

Almost all new untested lines are now covered.

Especially awaiting comments including a SurfaceRZFourier object with the NormalField. I think it is useful to have here, but especially in the next PR, where I create a CoilNormalField that inherits from NormalField it will involve many more functions that have to access the Surface (to calculate the coefficients etc).

Adapted NormalField.from_spec method to generate a SurfaceRZFourier class from the SPEC data.

P.S. I might have accidentally 'started a review' myself, my apologies.

Copy link
Contributor

@landreman landreman left a comment

Choose a reason for hiding this comment

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

Hi @smiet, I see the testing was expanded but some of the issues above are still open. The main one is the location of the Fourier transform routines - I still think it doesn't make sense for them to be in the base Surface class, for the reasons explained above. There are also the issues about explaining that "field" is not a magnetic field vector, cosines_mask = sines_mask, and combining if statements


# create a test field where only Fourier elements [m=2, n=3]
# and [m=4,n=5] are nonzero:
field = field = 0.8 * np.sin(2*theta2d - 3*s.nfp*phi2d) + 0.2*np.sin(4*theta2d - 5*s.nfp*phi2d)+ 0.7*np.cos(3*theta2d - 3*s.nfp*phi2d)
Copy link
Contributor

Choose a reason for hiding this comment

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

Duplicate field =

Copy link
Contributor Author

Choose a reason for hiding this comment

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

corrected

vnc = np.zeros((self.mpol + 1, 2 * self.ntor + 1))

if surface is None:
from simsopt.geo import SurfaceRZFourier
Copy link
Contributor

Choose a reason for hiding this comment

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

Why not put this import with the other imports at the top of the file?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

done

@@ -91,6 +106,7 @@ def from_spec(cls, filename):
if py_spec is None:
raise RuntimeError(
"Initialization from Spec requires py_spec to be installed.")
from simsopt.geo import SurfaceRZFourier
Copy link
Contributor

Choose a reason for hiding this comment

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

Why not put this import with the others at the top of the file?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

done

copy_to_pwd: boolean, if True, the default input file will be copied to the current working directory. Has to be set True as free-boundary SPEC can only handle files in the current working directory.
verbose: boolean, if True, print statements will be printed
"""
import shutil, os
Copy link
Contributor

Choose a reason for hiding this comment

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

Imports usually go at the top of the file unless necessary

Copy link
Contributor Author

Choose a reason for hiding this comment

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

moved

test the default freeboundary file, and re-set if Picard
iteration fails
"""
from simsopt._core.util import ObjectiveFailure
Copy link
Contributor

Choose a reason for hiding this comment

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

Imports to top of file

Copy link
Contributor Author

Choose a reason for hiding this comment

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

done

Copy link
Contributor Author

@smiet smiet left a comment

Choose a reason for hiding this comment

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

Hi Matt,

I am sorry, had entered many comments and discussion that are pending (aparently not for a response from you, but not submitted and hence not visible to you!

My apologies, submitting these now and I will put the imports at the top of the file where requested.


The Fourier harmonics are the degees of freedom, the computational boundary is kept fixed.
The normal field should not be normalized to unit area, i.e. it is the
fourier components of B.(\grad\theta \times \nabla\zeta) on the surface.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I use this convention because this is the way that SPEC requires the Fourier components of the normal field. This is currently the only use-case for NormalField, and it is simplest if the degrees-of-freedom correspond directly with the variables passed to SPEC.
It is possible to remove the convention and add have SPEC do the translation. or to pass to the class a convention='SPEC', which would raise a NotImplementedError if another convention is requested. would that be a solution?

I do prefer to have the NormalField carry a reference to the surface on which it is calculated; without this the DoF's of NormalField are meaningless. If the user needs to keep track of which surface object the field is evaluated on, it makes sense to me to have NormalField to reference it. Modifying its DoFs I agree is not warranted.
The surface info is also used in helper functions such as get_real_space_field, and I would consider adding a .plot funcionality where it plots the field on the surface.

src/simsopt/field/normal_field.py Outdated Show resolved Hide resolved
@@ -839,6 +839,130 @@ def interpolate_on_arclength_grid(self, function, theta_evaluate):

return function_interpolated

def fourier_transform_field(self, field, mpol=None, ntor=None, normalization=None, **kwargs):
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I have changed the name to scalar as it is a general method of transforming scalars, and adapted it in the docstrings. It is now also moved to surfaceRZFourier and not the Surface base class.

The calculation of such a transform always requires information from the surface on which the scalar quantity is evaluated. The function could be in util, but I find it conceptually cleaner to have it as a method of the surfaceRZFourier.

src/simsopt/geo/surface.py Outdated Show resolved Hide resolved
Comment on lines 953 to 957
if not stellsym:
cosangle = np.cos(angle)
field = field + A_mns[m, n + ntor] * sinangle
if not stellsym:
field = field + A_mnc[m, n + ntor] * cosangle
Copy link
Contributor Author

Choose a reason for hiding this comment

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

combined

tests/mhd/test_spec.py Outdated Show resolved Hide resolved

# Test that all other elements are close to zero
sines_mask = np.ones_like(ft_sines, dtype=bool)
cosines_mask = sines_mask
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks!

src/simsopt/mhd/spec.py Outdated Show resolved Hide resolved
src/simsopt/field/normal_field.py Outdated Show resolved Hide resolved
@mbkumar
Copy link
Collaborator

mbkumar commented Jun 27, 2024

@smiet
Can you please take a look at the SPEC failure here?

@smiet
Copy link
Contributor Author

smiet commented Jun 27, 2024

I had forgotten to include the python 3.8 exception to the numpy 2.0 build-time dependency in the python wrapper. Just merged the commit into SPEC master right before you triggered the tests.

Your commit just now should trigger the tests which will pull the update and they should now pass

@smiet
Copy link
Contributor Author

smiet commented Jul 3, 2024

@landreman, @mbkumar, could this PR be moved forward?

Because the discussion is mixed in with a lot of changed code, I am summarizing the main open threads:

NormalField with a reference to the surface

@landreman noted:

It also feels awkward for NormalField to carry around computational_boundary.

I believe this is necessary for the user, as just the Fourier components are meaningless without the surface. I have also added methods as NormalField.get_real_space_field() that performs the Fourier transform (using the surface properties) and is useful for plotting and analyzing the real space field (and passing to a Focus-like algorithm if you want to generate coils for your Spec). My insistence on this is also because of future development in PR3, where I will introduce a CoilNormalField that inherits from it, and requires the Surface. This way the two classes will have more attributes in common.

Location of Fourier-transforming routine

@landreman noted:

Having the new Fourier transform and inverse transform functions go in the base Surface class feels awkward to me. These functions need mpol and ntor, which not all Surface classes have (e.g. SurfaceHenneberg, SurfaceGarabedian). Also the transform and inverse-transform functions don't use any of the surface geometry, just the grid points. So I would suggest moving those two functions to simsopt.util (probably not in a class, just standalone functions).

I agree that having the transform in the Surface base class is not optimal, but I do think it fits well in the SurfaceRZFourier, as it uses the same VMEC Fourier convention. As a user who has a SurfaceRZFourier, and evaluates some quantity on it, it is only natural for the surface to provide the method to transform it into the same representation as it uses itself. Having a separate function in util that you pass both a Surface and the quantity you want transformed seems unnatural to me.
I have moved the function to the SurfaceRZFourier class
If you still prefer util, let me know and I will make the change

implementation of Fourier-transforming routine

@andrewgiuliani noted:

one suggestion to simplify this function is to use a prewritten FFT and inverse FFT algorithm here

I tried this, but failed, the current code works and is clear (though not optimally efficient), hope it is OK.

@landreman
Copy link
Contributor

landreman commented Jul 3, 2024

Presently, the fourier_transform and inverse_fourier_transform methods exist twice each, in both Surface and SurfaceRZFourier. Had you intended to remove them from Surface? Having these functions only in SurfaceRZFourier would be ok.

Copy link
Contributor

@andrewgiuliani andrewgiuliani left a comment

Choose a reason for hiding this comment

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

Hi Chris, there are lines missing from code coverage and a merge conflict that needs to be resolved too.

@smiet
Copy link
Contributor Author

smiet commented Jul 5, 2024

I have covered the new and functionally significant added lines of code. The patch has increased coverage over the old state of the SPEC code (though still under target, sorry). Uncovered are mainly raise statements and specific cases regarding the running of nonstellaratorsymmetric equilibria.

I hope to cover those in the future, but it will take time to generate a suitable nonstellaratorsymmetric spec file that runs in a reasonable time (for now we only test readin, the nonstellaratorsymmetric case takes cpu-hours to run and is unsitbable).

With the increased coverage, passing tests, and removed duplicate transform method, I hope we are good to merge!

Thank you all for your input and suggestions and time reviewing.

@andrewgiuliani
Copy link
Contributor

Here's an example unit test for exceptions, it's not a massive change and it makes sure no lines of code in production are untested:

def test_curvexyzsymmetries_raisesexception(self):

@landreman landreman self-requested a review July 5, 2024 14:08
landreman
landreman previously approved these changes Jul 5, 2024
@smiet
Copy link
Contributor Author

smiet commented Jul 5, 2024

I added the tests to the raises in NormalField. I will be doing some further additions during my next PR's is it OK if I catch the straggling uncovered exceptions in my next PR?
(the push of these extra tests undid the approval, sorry @landreman)

@landreman landreman self-requested a review July 5, 2024 17:02
@smiet smiet merged commit f6a8412 into master Jul 8, 2024
47 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants