Use explicit __init__ for LocalGeometry#384
Conversation
|
The method Note The ability to set This method was only used in the function This method was also used in some examples scripts, but they were easily modified to use a safer method. (This also allowed me to delete a 2 year old FIXME comment I'd left in Also fixed a minor error in the |
|
The latest commit changes # Before
local_geometry = LocalGeometryMXH() # Empty object
local_geometry.from_local_geometry(other_local_geometry)
# After
local_geometry = LocalGeometryMXH.from_local_geometry(other_local_geometry)From here, I could either carry on working on this to complete the additional goals listed above, or (if you're happy with the changes) we could merge this and I'll raise a new PR once I've made some progress. I'm hoping to be done with the further goals roughly by the end of next week. |
| "a_minor": 1.0, | ||
| "Fpsi": 0.0, | ||
| "B0": None, | ||
| "B0": 0.0, |
There was a problem hiding this comment.
I'm nervous about setting this to 0 as we sometimes to 1/B0...
There was a problem hiding this comment.
In some ways it would make more sense to not have a default value at all for basically all of these
There was a problem hiding this comment.
Good point, but the current default value of None is also a problem in that case (albeit one that will throw an error immediately rather than letting inf/NaN propagate throughout the code).
Is there a more sensible default value for B0? If the current defaults are pulled from a standard test case, is B0 defined there?
There was a problem hiding this comment.
@ZedThree I'd like to eventually get these classes to a point where none of the inputs are optional (besides things like delta in LocalGeomtryMXH), but the option to build one with sensible defaults could still be achieved with a class method, e.g. LocalGeometry.default(), as that would make testing a lot easier.
There was a problem hiding this comment.
I think we usually have checks for B0 usually like
if local_geometry.B0:
beta = 1 / local_geometry.B0**2
else:
beta = 0.0Hence the default of None as having beta=0.0 by default is safest.
|
This looks great, makes
|
Function wraps up parameter fitting logic and error checking from _set_shape_coeffecients.
Miller geometries were previously bypassing this and using self.b_poloidal_eq instead
Was previously using self.theta_eq
Simplifies design to use just one variant of the grids throughout
Aiming to avoid all references to 'self' during parameter fitting, so need a convenient way to pass all shape parameters down the stack. New ShapeParams uses a decorator to add some utility funcitons, including the ability to unpack and repack when passing data into/out of the least squares call.
Cleans up a long return type and ensures derivatives aren't returned in the wrong order. However, doesn't stop the tuple from being unpacked in the wrong order!
…arams Also changed to classmethod
…metry A step closer to having the base class __init__ be a dataclass, and from_global_eq/from_local_geometry to determine all inputs. from_gk_data should be replaced by subclass-specific __init__'s Fixes some units headaches in MXH
Fails when delta/s_delta are passed to create MXH, as it can't handle the redundant values. Planning to replace this with a non-classmethod utility function which will be called by each subclass. Calls to from_gk_data will be replaced with simple __init__ calls, and MXH.__init__ can handle the edge case individually.
This is used by from_global_eq and from_local_geometry, as well as all subclass __init__ functions. Provides a single unified method for setting up a LocalGeometry after fitting shape parameters. Problem: Had to introduce variable overwrite_dpsidr, as previous behaviour was to accept dpsidr when provided from an equilibrium or a local geometry, but to always overwrite it when reading from a GK input file. This feels like a bug, and the correct behaviour should be to do this only if dpsidr is not provided, but it the behaviour has been maintained in order to keep the tests correct.
Now takes R, Z, theta, B_poloidal etc as input args
Allows use of inheritance instead of a decorator, and makes type hinting much easier.
There was a problem hiding this comment.
Finally managed to get somewhere with this refactor, though it looks like I took so long that I now have a really messy merge on my hands...
LocalGeometry is now a dataclass, and local geometries are now always built using either their own __init__, from_global_eq, or from_local_geometry. They have a consistent set of attributes whichever method was used, and no further set up is required after initialisation (e.g. no need to assign bunit_over_b0 after building the object).
The fitting process for shaping coefficients has been modified to use @classmethod's and @staticmethod's throughout. This means that self is never referenced during the fitting process, and all necessary data is determined before initialisation (see LocalGeometry._init_with_shape_params). I had to add some extra machinery to get this to work in a generic manner, such as the ShapeParams classes and _shape_params property. This reduces the amount of repeat code in subclasses, at the cost of a more complex base class.
There are still some tasks to complete, two of which I've listed in comments. Further work includes:
- Clean up docs in subclasses
- Remove defaults from subclass
__init__functions. This may be challenging, as quite a lot of the code and the tests currently depend on default behaviour. -
from_global_eqshould not modify the normalisation -- the responsibility for this step should be handed toPyro. -
normaliseshould return a new local geometry
| if overwrite_dpsidr: | ||
| dpsidr = rho * bunit_over_b0 / q |
There was a problem hiding this comment.
When initialising a local geometry, the code was previously overwriting dpsidr if it was provided via a GK input file, but not if it was determined via an equilibrium or another local geometry. This feels like a bug.
This check is included as otherwise a few tests fail that I can't verify for myself. It also necessitates an overwrite_dpsidr variable in all of the subclass __init__ functions. If this is unintended behaviour, I can remove it throughout.
There was a problem hiding this comment.
dpsidr should be determined from LocalGeometry parameters alone, you never actually specify it as an input to a code. You can of course get it from a full equilibrium so that could be a good check but in theory you should always take the one from the LocalGeometry parameters, its like a fit I guess. I guess it should always be overwritten.
There was a problem hiding this comment.
I've been playing around with this and I ran into a bit of a units issue. If we build from a GK input file, then everything is just a float until we attach units during the normalisation step, after which dpsidr is assigned units of lref * bref. However, if we calculate it from an equilibrium file, dpsidr will have units of just [length], as rho * bunit_over_b0 / q has units [length] [dimensionless] / [dimensionless], and when we try to normalise this to lref * bref it throws an error. I think we need to use the provided dpsidr when building from an equilibrium, but otherwise should be fine to recalculate.
There was a problem hiding this comment.
I did some further playing around and it seems this issue appears when building from another local geometry too. It looks like the best solution is to use dpsidr if it's available, and only calculate it as a backup. I've come up with a cleaner solution to the problem though, so it's not a big deal -- not sure why I thought I needed overwrite_dpsidr, as it seems just using a default value of None is sufficient.
There was a problem hiding this comment.
The issue here with bunit_over_b0 is that it technically is Bunit with units of B0 so when bref = B0 then it dimensionally makes sense. To be honest it may be worth adding in the correct units for Bunit which is a just a magnetic field rather than having Bunit/B0.
| # FIXME: L. Pattinson 2024-09-26 | ||
| # This test is failing because the theta grid defined at the top of the function | ||
| # is different to fourier.theta (formerly fourier.theta_eq). The latter was set | ||
| # up using the theta calculation at the top of _set_shape_coefficients, and does | ||
| # not correspond to the original miller theta grid. I don't know why | ||
| # fourier.theta_eq was being set this way, nor why this test is comparing grad_r | ||
| # calculated at different angles. I also don't know how to regenerate the data | ||
| # using sympty. | ||
| # ( | ||
| # {"kappa": 2.0, "delta": 0.5, "s_kappa": 0.5, "s_delta": 0.2, "shift": 0.1}, | ||
| # lambda theta: 2.0 | ||
| # * np.sqrt( | ||
| # 0.25 | ||
| # * (0.523598775598299 * np.cos(theta) + 1) ** 2 | ||
| # * np.sin(theta + 0.523598775598299 * np.sin(theta)) ** 2 | ||
| # + np.cos(theta) ** 2 | ||
| # ) | ||
| # / ( | ||
| # 2.0 | ||
| # * (0.585398163397448 * np.cos(theta) + 0.5) | ||
| # * np.sin(theta) | ||
| # * np.sin(theta + 0.523598775598299 * np.sin(theta)) | ||
| # + 0.2 * np.cos(theta) | ||
| # + 2.0 * np.cos(0.523598775598299 * np.sin(theta)) | ||
| # ), | ||
| # ), |
There was a problem hiding this comment.
I had to disable this test and a few others for FourierGENE/CGYRO, as I think they were comparing values calculated on different theta grids, and I can't figure out how the test parameters were calculated. Where the tests say the data was determined using sympy, it would be good if the code used to generate the data could be included in the docstrings.
There was a problem hiding this comment.
These are analytic solutions for grad_r in a simplified geometry so they should always be true. We can look at getting the sympy version written up somewhere
There was a problem hiding this comment.
Example code using sympy to get the golden answers. Given that we fit say a FourierGENE to a Miller fit we expect it to be close but not exact. Maybe the tolerance should be increased though nothing here should change an answer that already worked... Sometimes we need to be careful with integrals used in minimising b_poloidal and how they are set up with boundaries in theta Maybe the theta grid has changed which could tweak the integral and modify the fit
from sympy import Symbol, simplify, cos, sin, asin, sqrt
def simplify_grad_r(rho, Rmaj, shift, kappa, s_kappa, delta, s_delta):
theta = Symbol("theta")
# Not used but nice to see
R = Rmaj + rho * (cos(theta + asin(delta) * sin(theta)))
Z = rho * kappa * sin(theta)
dRdr = (
shift
+ cos(theta + asin(delta) * sin(theta))
- sin(theta + asin(delta) * sin(theta)) * sin(theta) * s_delta
)
dRdtheta = (
-rho * sin(theta + asin(delta) * sin(theta)) * (1 + asin(delta) * cos(theta))
)
dZdr = kappa * sin(theta) + s_kappa * kappa * sin(theta)
dZdtheta = kappa * rho * cos(theta)
g_tt = dRdtheta**2 + dZdtheta**2
grad_r = sqrt(g_tt) / (dRdr * dZdtheta - dRdtheta * dZdr)
return simplify(grad_r)
# Simple circular plasma
rho = 0.5
Rmaj = 3.0
shift = 0.0
kappa = 1.0
s_kappa = 0.0
delta = 0.0
s_delta = 0.0
my_grad_r = simplify_grad_r(rho, Rmaj, shift, kappa, s_kappa, delta, s_delta)
print(f"Grad_r for circular plasma: {my_grad_r}")
# Elongation gradient
rho = 0.5
Rmaj = 3.0
shift = 0.0
kappa = 1.0
s_kappa = 1.0
delta = 0.0
s_delta = 0.0
my_grad_r = simplify_grad_r(rho, Rmaj, shift, kappa, s_kappa, delta, s_delta)
print(f"Grad_r for circular plasma with s_kappa: {my_grad_r}")
# High shaping
rho = 0.5
Rmaj = 3.0
shift = 0.1
kappa = 2.0
s_kappa = 1.0
delta = 0.5
s_delta = 0.2
my_grad_r = simplify_grad_r(rho, Rmaj, shift, kappa, s_kappa, delta, s_delta)
print(f"Grad_r for shaped plasma: {my_grad_r}")Running this code results in
Grad_r for circular plasma: 1.00000000000000
Grad_r for circular plasma with s_kappa: 1.0/(sin(theta)**2 + 1)
Grad_r for shaped plasma: sqrt(0.25*(0.523598775598299*cos(theta) + 1.0)**2*sin(theta + 0.523598775598299*sin(theta))**2 + 1.0*cos(theta)**2)/(2.0*(0.523598775598299*cos(theta) + 1.0)*sin(theta)*sin(theta + 0.523598775598299*sin(theta)) + 1.0*(-0.2*sin(theta)*sin(theta + 0.523598775598299*sin(theta)) + cos(theta + 0.523598775598299*sin(theta)) + 0.1)*cos(theta))
There was a problem hiding this comment.
Thanks for this, I tried adapting the code to also generate R and Z to make sure we were comparing the same flux surfaces. It looks like we are, but each value of theta corresponds to a different point along the flux surface:
It's a bit too late on a Friday for me to figure out what's going on here. Is it the case that the definition of theta is different in the Miller geometry compared to the Fourier geometries?
There was a problem hiding this comment.
Yes the definition is different which makes a lot of this quite annoying. You need to determine theta when given an array of R and Z. You can see the definitions would be different when looking at the parameterisation for each type.
There was a problem hiding this comment.
I think I have this resolved in the latest commit, but it's a bit hacky! I tried to find an analytic way to convert between the two theta systems, but in the end gave up and just added a small routine that takes R, Z, and theta from one local geometry and finds which Miller-theta should correspond to that using a least squares fit.
| mock = LocalGeometryFourierGENE.from_local_geometry( | ||
| LocalGeometry( | ||
| psi_n=local_geometry.psi_n, | ||
| rho=geometry_dict["rho"] * lref, | ||
| a_minor=local_geometry.a_minor, | ||
| Rmaj=geometry_dict["Rmaj"] * lref, | ||
| Z0=geometry_dict["Z0"] * lref, | ||
| Fpsi=local_geometry.Fpsi, | ||
| FF_prime=local_geometry.FF_prime, | ||
| B0=local_geometry.B0, | ||
| bunit_over_b0=local_geometry.bunit_over_b0, | ||
| dpsidr=geometry_dict["dpsidr"] * bref * lref, | ||
| q=local_geometry.q, | ||
| shat=geometry_dict["shat"], | ||
| beta_prime=geometry_dict["beta_prime"] * bref**2 / lref, | ||
| R=geometry_dict["R"] * lref, | ||
| Z=geometry_dict["Z"] * lref, | ||
| b_poloidal=geometry_dict["b_poloidal"] * bref, | ||
| theta=geometry_dict["theta"], | ||
| bt_ccw=local_geometry.bt_ccw, | ||
| ip_ccw=local_geometry.ip_ccw, | ||
| dRdtheta=local_geometry.dRdtheta, | ||
| dRdr=local_geometry.dRdr, | ||
| dZdtheta=local_geometry.dZdtheta, | ||
| dZdr=local_geometry.dZdr, | ||
| ) |
There was a problem hiding this comment.
I've finished merging and attempted to recreate the new GENE features using my new methods, but I can't tell if I've done so correctly or not. This section of the code looks like it's untested, and I don't really know what it's doing.
There was a problem hiding this comment.
Essentially the full data needed to populate LocalGeometry is contained inside a different file which requires a bit of post-processing to get the data in a form that can actually be read by LocalGeometry. What you do here looks to be correct. This is tested here
There was a problem hiding this comment.
That test doesn't seem to touch the new code in gk_code/gene.py, so I'm not fully confident that my changes haven't broken something. I can leave it alone for now since it's slightly outside the scope of the PR, but it would be good to get a test in that uses one of those new geometry files alongside a GENE input file directly.
There was a problem hiding this comment.
Right I see what you mean! I just fit a FourierGENE to the GEQDSK and compare to a new geometry file I made independently. I can add that in but yes its outside the scope of this PR
Also removes normalisation side-effect from from_global_eq
|
With the aim of promoting immutability, |
Some edits to Miller and LocalGeometry
Further improvements to other local geometry docs
A step towards merging FluxSurface and LocalGeometry
|
I think that's about as far as I'd like to take this PR. I'd still like to merge |

Resolves (partly) #336.
Replaces general
__init__functions of the formdef __init__(self, *args, **kwargs):with more explicit argument lists. Uses the same default arguments as are currently in use.default_inputs()functions moved to class variablesDEFAULT_INPUTS, as this allows them to be used to set default values in the__init__function and from outside the class (they're used in someGKInputclasses).TODO:
from_global_eqto be a@classmethod.from_local_geometryto be a@classmethod.(Guidance needed) Should(EDIT:normalisebe called as standard by the base__init__? If so, do that.normaliseshould return a newLocalGeometryinstead)Stretch goals:
LocalGeometrycould be a@dataclasswhich takesR,Z,thetaandb_poloidalgrids as inputs and therefore represent a 'non-parameterised' local geometry. Subclasses would instead be initialised with their parameters, and would generateR,Z,thetaandb_poloidalgrids from there. This would resolve the problem wherein many functions defined on the base class fail due to missing data that is only generated by subclasses.LocalGeometrywould remove the need for theFluxSurfaceclass, which could be removed from the project.Initialisation with default values could still be achieved by updating the(EDIT: Not clear how this would work on the base class, probably better to avoid default behaviour where possible)defaultmethod to be a@classmethod.LocalGeometryand subclasses