-
Notifications
You must be signed in to change notification settings - Fork 14
/
equilibrium.py
2442 lines (2173 loc) · 83.2 KB
/
equilibrium.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Core class representing MHD equilibrium."""
import copy
import numbers
import warnings
from collections.abc import MutableSequence
import numpy as np
from scipy import special
from scipy.constants import mu_0
from termcolor import colored
from desc.backend import jnp
from desc.basis import FourierZernikeBasis, fourier, zernike_radial
from desc.compat import ensure_positive_jacobian
from desc.compute import compute as compute_fun
from desc.compute import data_index
from desc.compute.utils import get_data_deps, get_params, get_profiles, get_transforms
from desc.geometry import (
FourierRZCurve,
FourierRZToroidalSurface,
ZernikeRZToroidalSection,
)
from desc.grid import LinearGrid, QuadratureGrid, _Grid
from desc.io import IOAble
from desc.objectives import (
ForceBalance,
ObjectiveFunction,
get_equilibrium_objective,
get_fixed_axis_constraints,
get_fixed_boundary_constraints,
)
from desc.optimizable import Optimizable, optimizable_parameter
from desc.optimize import Optimizer
from desc.perturbations import perturb
from desc.profiles import PowerSeriesProfile, SplineProfile
from desc.transform import Transform
from desc.utils import Timer, copy_coeffs, errorif, isposint, only1, setdefault
from .coords import compute_theta_coords, is_nested, map_coordinates, to_sfl
from .initial_guess import set_initial_guess
from .utils import _assert_nonnegint, parse_axis, parse_profile, parse_surface
class Equilibrium(IOAble, Optimizable):
"""Equilibrium is an object that represents a plasma equilibrium.
It contains information about a plasma state, including the shapes of flux surfaces
and profile inputs. It can compute additional information, such as the magnetic
field and plasma currents, as well as "solving" itself by finding the equilibrium
fields, and perturbing those fields to find nearby equilibria.
Parameters
----------
Psi : float (optional)
total toroidal flux (in Webers) within LCFS. Default 1.0
NFP : int (optional)
number of field periods Default ``surface.NFP`` or 1
L : int (optional)
Radial resolution. Default 2*M for ``spectral_indexing=='fringe'``, else M
M : int (optional)
Poloidal resolution. Default surface.M or 1
N : int (optional)
Toroidal resolution. Default surface.N or 0
L_grid : int (optional)
resolution of real space nodes in radial direction
M_grid : int (optional)
resolution of real space nodes in poloidal direction
N_grid : int (optional)
resolution of real space nodes in toroidal direction
pressure : Profile or ndarray shape(k,2) (optional)
Pressure profile or array of mode numbers and spectral coefficients.
Default is a PowerSeriesProfile with zero pressure
iota : Profile or ndarray shape(k,2) (optional)
Rotational transform profile or array of mode numbers and spectral coefficients
current : Profile or ndarray shape(k,2) (optional)
Toroidal current profile or array of mode numbers and spectral coefficients
Default is a PowerSeriesProfile with zero toroidal current
electron_temperature : Profile or ndarray shape(k,2) (optional)
Electron temperature (eV) profile or array of mode numbers and spectral
coefficients. Must be supplied with corresponding density.
Cannot specify both kinetic profiles and pressure.
electron_density : Profile or ndarray shape(k,2) (optional)
Electron density (m^-3) profile or array of mode numbers and spectral
coefficients. Must be supplied with corresponding temperature.
Cannot specify both kinetic profiles and pressure.
ion_temperature : Profile or ndarray shape(k,2) (optional)
Ion temperature (eV) profile or array of mode numbers and spectral coefficients.
Default is to assume electrons and ions have the same temperature.
atomic_number : Profile or ndarray shape(k,2) (optional)
Effective atomic number (Z_eff) profile or ndarray of mode numbers and spectral
coefficients. Default is 1
anisotropy : Profile or ndarray
Anisotropic pressure profile or array of mode numbers and spectral coefficients.
Default is a PowerSeriesProfile with zero anisotropic pressure.
surface: Surface or ndarray shape(k,5) (optional)
Fixed boundary surface shape, as a Surface object or array of
spectral mode numbers and coefficients of the form [l, m, n, R, Z].
Default is a FourierRZToroidalSurface with major radius 10 and minor radius 1
axis : Curve or ndarray shape(k,3) (optional)
Initial guess for the magnetic axis as a Curve object or ndarray
of mode numbers and spectral coefficients of the form [n, R, Z].
Default is the centroid of the surface.
sym : bool (optional)
Whether to enforce stellarator symmetry. Default surface.sym or False.
spectral_indexing : str (optional)
Type of Zernike indexing scheme to use. Default ``'ansi'``
check_orientation : bool
ensure that this equilibrium has a right handed orientation. Do not set to False
unless you are sure the parameterization you have given is right handed
(ie, e_theta x e_zeta points outward from the surface).
ensure_nested : bool
If True, and the default initial guess does not produce nested surfaces,
run a small optimization problem to attempt to refine initial guess to improve
coordinate mapping.
"""
_io_attrs_ = [
"_sym",
"_R_sym",
"_Z_sym",
"_Psi",
"_NFP",
"_L",
"_M",
"_N",
"_R_lmn",
"_Z_lmn",
"_L_lmn",
"_R_basis",
"_Z_basis",
"_L_basis",
"_surface",
"_axis",
"_pressure",
"_iota",
"_current",
"_electron_temperature",
"_electron_density",
"_ion_temperature",
"_atomic_number",
"_anisotropy",
"_spectral_indexing",
"_bdry_mode",
"_L_grid",
"_M_grid",
"_N_grid",
]
def __init__(
self,
Psi=1.0,
NFP=None,
L=None,
M=None,
N=None,
L_grid=None,
M_grid=None,
N_grid=None,
pressure=None,
iota=None,
current=None,
electron_temperature=None,
electron_density=None,
ion_temperature=None,
atomic_number=None,
anisotropy=None,
surface=None,
axis=None,
sym=None,
spectral_indexing=None,
check_orientation=True,
ensure_nested=True,
**kwargs,
):
errorif(
not isinstance(Psi, numbers.Real),
ValueError,
f"Psi should be a real integer or float, got {type(Psi)}",
)
self._Psi = float(Psi)
errorif(
spectral_indexing
not in [
None,
"ansi",
"fringe",
],
ValueError,
"spectral_indexing should be one of 'ansi', 'fringe', None, got "
+ f"{spectral_indexing}",
)
self._spectral_indexing = setdefault(
spectral_indexing, getattr(surface, "spectral_indexing", "ansi")
)
errorif(
(NFP is not None) and not isposint(NFP),
ValueError,
f"NFP should be a positive integer, got {NFP}",
)
self._NFP = int(
setdefault(NFP, getattr(surface, "NFP", getattr(axis, "NFP", 1)))
)
# stellarator symmetry for bases
errorif(
sym
not in [
None,
True,
False,
],
ValueError,
f"sym should be one of True, False, None, got {sym}",
)
self._sym = setdefault(sym, getattr(surface, "sym", False))
self._R_sym = "cos" if self.sym else False
self._Z_sym = "sin" if self.sym else False
# surface
self._surface, self._bdry_mode = parse_surface(
surface, self.NFP, self.sym, self.spectral_indexing
)
# magnetic axis
self._axis = parse_axis(axis, self.NFP, self.sym, self.surface)
# resolution
_assert_nonnegint(L, "L")
_assert_nonnegint(M, "M")
_assert_nonnegint(N, "N")
_assert_nonnegint(L_grid, "L_grid")
_assert_nonnegint(M_grid, "M_grid")
_assert_nonnegint(N_grid, "N_grid")
self._N = int(setdefault(N, self.surface.N))
self._M = int(setdefault(M, self.surface.M))
self._L = int(
setdefault(
L,
max(
self.surface.L,
self.M if (self.spectral_indexing == "ansi") else 2 * self.M,
),
)
)
self._L_grid = setdefault(L_grid, 2 * self.L)
self._M_grid = setdefault(M_grid, 2 * self.M)
self._N_grid = setdefault(N_grid, 2 * self.N)
self._surface.change_resolution(self.L, self.M, self.N)
self._axis.change_resolution(self.N)
# bases
self._R_basis = FourierZernikeBasis(
L=self.L,
M=self.M,
N=self.N,
NFP=self.NFP,
sym=self._R_sym,
spectral_indexing=self.spectral_indexing,
)
self._Z_basis = FourierZernikeBasis(
L=self.L,
M=self.M,
N=self.N,
NFP=self.NFP,
sym=self._Z_sym,
spectral_indexing=self.spectral_indexing,
)
self._L_basis = FourierZernikeBasis(
L=self.L,
M=self.M,
N=self.N,
NFP=self.NFP,
sym=self._Z_sym,
spectral_indexing=self.spectral_indexing,
)
# profiles
self._pressure = None
self._iota = None
self._current = None
self._electron_temperature = None
self._electron_density = None
self._ion_temperature = None
self._atomic_number = None
if current is None and iota is None:
current = 0
use_kinetic = any(
[electron_temperature is not None, electron_density is not None]
)
errorif(
current is not None and iota is not None,
ValueError,
"Cannot specify both iota and current profiles.",
)
errorif(
((pressure is not None) or (anisotropy is not None)) and use_kinetic,
ValueError,
"Cannot specify both pressure and kinetic profiles.",
)
errorif(
use_kinetic and (electron_temperature is None or electron_density is None),
ValueError,
"Must give at least electron temperature and density to use "
+ "kinetic profiles.",
)
if use_kinetic and atomic_number is None:
atomic_number = 1
if use_kinetic and ion_temperature is None:
ion_temperature = electron_temperature
if not use_kinetic and pressure is None:
pressure = 0
self._electron_temperature = parse_profile(
electron_temperature, "electron temperature"
)
self._electron_density = parse_profile(electron_density, "electron density")
self._ion_temperature = parse_profile(ion_temperature, "ion temperature")
self._atomic_number = parse_profile(atomic_number, "atomic number")
self._pressure = parse_profile(pressure, "pressure")
self._anisotropy = parse_profile(anisotropy, "anisotropy")
self._iota = parse_profile(iota, "iota")
self._current = parse_profile(current, "current")
# ensure profiles have the right resolution
for profile in [
"pressure",
"iota",
"current",
"electron_temperature",
"electron_density",
"ion_temperature",
"atomic_number",
"anisotropy",
]:
p = getattr(self, profile)
if hasattr(p, "change_resolution"):
p.change_resolution(max(p.basis.L, self.L))
if isinstance(p, PowerSeriesProfile) and p.sym != "even":
warnings.warn(
colored(f"{profile} profile is not an even power series.", "yellow")
)
# ensure number of field periods agree before setting guesses
eq_NFP = self.NFP
surf_NFP = self.surface.NFP if hasattr(self.surface, "NFP") else self.NFP
axis_NFP = self._axis.NFP
errorif(
not (eq_NFP == surf_NFP == axis_NFP),
ValueError,
"Unequal number of field periods for equilibrium "
+ f"{eq_NFP}, surface {surf_NFP}, and axis {axis_NFP}",
)
# make sure symmetry agrees
errorif(
self.sym != self.surface.sym,
ValueError,
"Surface and Equilibrium must have the same symmetry",
)
self._R_lmn = np.zeros(self.R_basis.num_modes)
self._Z_lmn = np.zeros(self.Z_basis.num_modes)
self._L_lmn = np.zeros(self.L_basis.num_modes)
if ("R_lmn" in kwargs) or ("Z_lmn" in kwargs):
assert ("R_lmn" in kwargs) and ("Z_lmn" in kwargs), "Must give both R and Z"
self.R_lmn = kwargs.pop("R_lmn")
self.Z_lmn = kwargs.pop("Z_lmn")
self.L_lmn = kwargs.pop("L_lmn", jnp.zeros(self.L_basis.num_modes))
else:
self.set_initial_guess(ensure_nested=ensure_nested)
if check_orientation:
ensure_positive_jacobian(self)
if kwargs.get("check_kwargs", True):
errorif(
len(kwargs),
TypeError,
f"Equilibrium got unexpected kwargs: {kwargs.keys()}",
)
def _set_up(self):
"""Set unset attributes after loading.
To ensure object has all properties needed for current DESC version.
Allows for backwards-compatibility with equilibria saved/ran with older
DESC versions.
"""
for attribute in self._io_attrs_:
if not hasattr(self, attribute):
setattr(self, attribute, None)
if self.current is not None and hasattr(self.current, "_get_transform"):
# Need to rebuild derivative matrices to get higher order derivatives
# on equilibrium's saved before GitHub pull request #586.
self.current._transform = self.current._get_transform(self.current.grid)
def _sort_args(self, args):
"""Put arguments in a canonical order. Returns unique sorted elements.
For Equilibrium, alphabetical order seems to lead to some numerical instability
so we enforce a particular order that has worked well.
"""
arg_order = (
"R_lmn",
"Z_lmn",
"L_lmn",
"p_l",
"i_l",
"c_l",
"Psi",
"Te_l",
"ne_l",
"Ti_l",
"Zeff_l",
"a_lmn",
"Ra_n",
"Za_n",
"Rb_lmn",
"Zb_lmn",
"I",
"G",
"Phi_mn",
)
assert sorted(args) == sorted(arg_order)
return [arg for arg in arg_order if arg in args]
def __repr__(self):
"""String form of the object."""
return (
type(self).__name__
+ " at "
+ str(hex(id(self)))
+ " (L={}, M={}, N={}, NFP={}, sym={}, spectral_indexing={})".format(
self.L, self.M, self.N, self.NFP, self.sym, self.spectral_indexing
)
)
def set_initial_guess(self, *args, ensure_nested=True):
"""Set the initial guess for the flux surfaces, eg R_lmn, Z_lmn, L_lmn.
Parameters
----------
eq : Equilibrium
Equilibrium to initialize
args :
either:
- No arguments, in which case eq.surface will be scaled for the guess.
- Another Surface object, which will be scaled to generate the guess.
Optionally a Curve object may also be supplied for the magnetic axis.
- Another Equilibrium, whose flux surfaces will be used.
- File path to a VMEC or DESC equilibrium, which will be loaded and used.
- Grid and 2-3 ndarrays, specifying the flux surface locations (R, Z, and
optionally lambda) at fixed flux coordinates. All arrays should have the
same length. Optionally, an ndarray of shape(k,3) may be passed instead
of a grid.
ensure_nested : bool
If True, and the default initial guess does not produce nested surfaces,
run a small optimization problem to attempt to refine initial guess to
improve coordinate mapping.
Examples
--------
Use existing equil.surface and scales down for guess:
>>> equil.set_initial_guess()
Use supplied Surface and scales down for guess. Assumes axis is centroid
of user supplied surface:
>>> equil.set_initial_guess(surface)
Optionally, an interior surface may be scaled by giving the surface a
flux label:
>>> surf = FourierRZToroidalSurface(rho=0.7)
>>> equil.set_initial_guess(surf)
Use supplied Surface and a supplied Curve for axis and scales between
them for guess:
>>> equil.set_initial_guess(surface, curve)
Use the flux surfaces from an existing Equilibrium:
>>> equil.set_initial_guess(equil2)
Use flux surfaces from existing Equilibrium or VMEC output stored on disk:
>>> equil.set_initial_guess(path_to_saved_DESC_or_VMEC_output)
Use flux surfaces specified by points:
nodes should either be a Grid or an ndarray, shape(k,3) giving the locations
in rho, theta, zeta coordinates. R, Z, and optionally lambda should be
array-like, shape(k,) giving the corresponding real space coordinates
>>> equil.set_initial_guess(nodes, R, Z, lambda)
"""
set_initial_guess(self, *args, ensure_nested=ensure_nested)
def copy(self, deepcopy=True):
"""Return a (deep)copy of this equilibrium."""
if deepcopy:
new = copy.deepcopy(self)
else:
new = copy.copy(self)
return new
def change_resolution(
self,
L=None,
M=None,
N=None,
L_grid=None,
M_grid=None,
N_grid=None,
NFP=None,
sym=None,
):
"""Set the spectral resolution and real space grid resolution.
Parameters
----------
L : int
Maximum radial Zernike mode number.
M : int
Maximum poloidal Fourier mode number.
N : int
Maximum toroidal Fourier mode number.
L_grid : int
Radial real space grid resolution.
M_grid : int
Poloidal real space grid resolution.
N_grid : int
Toroidal real space grid resolution.
NFP : int
Number of field periods.
sym : bool
Whether to enforce stellarator symmetry.
"""
self._L = int(setdefault(L, self.L))
self._M = int(setdefault(M, self.M))
self._N = int(setdefault(N, self.N))
self._L_grid = int(setdefault(L_grid, self.L_grid))
self._M_grid = int(setdefault(M_grid, self.M_grid))
self._N_grid = int(setdefault(N_grid, self.N_grid))
self._NFP = int(setdefault(NFP, self.NFP))
self._sym = setdefault(sym, self.sym)
old_modes_R = self.R_basis.modes
old_modes_Z = self.Z_basis.modes
old_modes_L = self.L_basis.modes
self.R_basis.change_resolution(
self.L, self.M, self.N, NFP=self.NFP, sym="cos" if self.sym else self.sym
)
self.Z_basis.change_resolution(
self.L, self.M, self.N, NFP=self.NFP, sym="sin" if self.sym else self.sym
)
self.L_basis.change_resolution(
self.L, self.M, self.N, NFP=self.NFP, sym="sin" if self.sym else self.sym
)
for profile in [
"pressure",
"iota",
"current",
"electron_temperature",
"electron_density",
"ion_temperature",
"atomic_number",
"anisotropy",
]:
p = getattr(self, profile)
if hasattr(p, "change_resolution"):
p.change_resolution(max(p.basis.L, self.L))
self.surface.change_resolution(
self.L, self.M, self.N, NFP=self.NFP, sym=self.sym
)
self.axis.change_resolution(self.N, NFP=self.NFP, sym=self.sym)
self._R_lmn = copy_coeffs(self.R_lmn, old_modes_R, self.R_basis.modes)
self._Z_lmn = copy_coeffs(self.Z_lmn, old_modes_Z, self.Z_basis.modes)
self._L_lmn = copy_coeffs(self.L_lmn, old_modes_L, self.L_basis.modes)
def get_surface_at(self, rho=None, theta=None, zeta=None):
"""Return a representation for a given coordinate surface.
Parameters
----------
rho, theta, zeta : float or None
radial, poloidal, or toroidal coordinate for the surface. Only
one may be specified.
Returns
-------
surf : Surface
object representing the given surface, either a FourierRZToroidalSurface
for surfaces of constant rho, or a ZernikeRZToroidalSection for
surfaces of constant zeta.
"""
errorif(
not only1(rho is not None, theta is not None, zeta is not None),
ValueError,
f"Only one coordinate can be specified, got {rho}, {theta}, {zeta}",
)
errorif(
theta is not None,
NotImplementedError,
"Constant theta surfaces have not been implemented yet",
)
if rho is not None:
assert (rho >= 0) and (rho <= 1)
surface = FourierRZToroidalSurface(sym=self.sym, NFP=self.NFP, rho=rho)
surface.change_resolution(self.M, self.N)
AR = np.zeros((surface.R_basis.num_modes, self.R_basis.num_modes))
AZ = np.zeros((surface.Z_basis.num_modes, self.Z_basis.num_modes))
Js = []
zernikeR = zernike_radial(
rho, self.R_basis.modes[:, 0], self.R_basis.modes[:, 1]
)
for i, (l, m, n) in enumerate(self.R_basis.modes):
j = np.argwhere(
np.logical_and(
surface.R_basis.modes[:, 1] == m,
surface.R_basis.modes[:, 2] == n,
)
)
Js.append(j.flatten())
Js = np.array(Js)
# Broadcasting at once is faster. We need to use np.arange to avoid
# setting the value to the whole row.
AR[Js[:, 0], np.arange(self.R_basis.num_modes)] = zernikeR
Js = []
zernikeZ = zernike_radial(
rho, self.Z_basis.modes[:, 0], self.Z_basis.modes[:, 1]
)
for i, (l, m, n) in enumerate(self.Z_basis.modes):
j = np.argwhere(
np.logical_and(
surface.Z_basis.modes[:, 1] == m,
surface.Z_basis.modes[:, 2] == n,
)
)
Js.append(j.flatten())
Js = np.array(Js)
# Broadcasting at once is faster. We need to use np.arange to avoid
# setting the value to the whole row.
AZ[Js[:, 0], np.arange(self.Z_basis.num_modes)] = zernikeZ
Rb = AR @ self.R_lmn
Zb = AZ @ self.Z_lmn
surface.R_lmn = Rb
surface.Z_lmn = Zb
return surface
if zeta is not None:
assert (zeta >= 0) and (zeta <= 2 * np.pi)
surface = ZernikeRZToroidalSection(sym=self.sym, zeta=zeta)
surface.change_resolution(self.L, self.M)
AR = np.zeros((surface.R_basis.num_modes, self.R_basis.num_modes))
AZ = np.zeros((surface.Z_basis.num_modes, self.Z_basis.num_modes))
for i, (l, m, n) in enumerate(self.R_basis.modes):
j = np.argwhere(
np.logical_and(
surface.R_basis.modes[:, 0] == l,
surface.R_basis.modes[:, 1] == m,
)
)
AR[j, i] = fourier(zeta, n, self.NFP)
for i, (l, m, n) in enumerate(self.Z_basis.modes):
j = np.argwhere(
np.logical_and(
surface.Z_basis.modes[:, 0] == l,
surface.Z_basis.modes[:, 1] == m,
)
)
AZ[j, i] = fourier(zeta, n, self.NFP)
Rb = AR @ self.R_lmn
Zb = AZ @ self.Z_lmn
surface.R_lmn = Rb
surface.Z_lmn = Zb
return surface
def get_profile(self, name, grid=None, kind="spline", **kwargs):
"""Return a SplineProfile of the desired quantity.
Parameters
----------
name : str
Name of the quantity to compute.
grid : Grid, optional
Grid of coordinates to evaluate at. Defaults to the quadrature grid.
Note profile will only be a function of the radial coordinate.
kind : {"power_series", "spline", "fourier_zernike"}
Type of returned profile.
Returns
-------
profile : SplineProfile
Radial profile of the desired quantity.
"""
assert kind in {"power_series", "spline", "fourier_zernike"}
if grid is None:
grid = QuadratureGrid(self.L_grid, self.M_grid, self.N_grid, self.NFP)
data = self.compute(name, grid=grid, **kwargs)
f = data[name]
f = grid.compress(f, surface_label="rho")
x = grid.nodes[grid.unique_rho_idx, 0]
p = SplineProfile(f, x, name=name)
if kind == "power_series":
p = p.to_powerseries(order=min(self.L, len(x)), xs=x, sym=True)
if kind == "fourier_zernike":
p = p.to_fourierzernike(L=min(self.L, len(x)), xs=x)
return p
def get_axis(self):
"""Return a representation for the magnetic axis.
Returns
-------
axis : FourierRZCurve
object representing the magnetic axis.
"""
# value of Zernike polynomials at rho=0 for unique radial modes (+/-1)
sign_l = np.atleast_2d(((np.arange(0, self.L + 1, 2) / 2) % 2) * -2 + 1).T
# indices where m=0
idx0_R = np.where(self.R_basis.modes[:, 1] == 0)[0]
idx0_Z = np.where(self.Z_basis.modes[:, 1] == 0)[0]
# indices where l=0 & m=0
idx00_R = np.where((self.R_basis.modes[:, :2] == [0, 0]).all(axis=1))[0]
idx00_Z = np.where((self.Z_basis.modes[:, :2] == [0, 0]).all(axis=1))[0]
# this reshaping assumes the FourierZernike bases are sorted
R_n = np.sum(
sign_l * np.reshape(self.R_lmn[idx0_R], (-1, idx00_R.size), order="F"),
axis=0,
)
modes_R = self.R_basis.modes[idx00_R, 2]
if len(idx00_Z):
Z_n = np.sum(
sign_l * np.reshape(self.Z_lmn[idx0_Z], (-1, idx00_Z.size), order="F"),
axis=0,
)
modes_Z = self.Z_basis.modes[idx00_Z, 2]
else: # catch cases such as axisymmetry with stellarator symmetry
Z_n = 0
modes_Z = 0
axis = FourierRZCurve(R_n, Z_n, modes_R, modes_Z, NFP=self.NFP, sym=self.sym)
return axis
def compute(
self,
names,
grid=None,
params=None,
transforms=None,
profiles=None,
data=None,
override_grid=True,
**kwargs,
):
"""Compute the quantity given by name on grid.
Parameters
----------
names : str or array-like of str
Name(s) of the quantity(s) to compute.
grid : Grid, optional
Grid of coordinates to evaluate at. Defaults to the quadrature grid.
params : dict of ndarray
Parameters from the equilibrium, such as R_lmn, Z_lmn, i_l, p_l, etc
Defaults to attributes of self.
transforms : dict of Transform
Transforms for R, Z, lambda, etc. Default is to build from grid
profiles : dict of Profile
Profile objects for pressure, iota, current, etc. Defaults to attributes
of self
data : dict of ndarray
Data computed so far, generally output from other compute functions
override_grid : bool
If True, override the user supplied grid if necessary and use a full
resolution grid to compute quantities and then downsample to user requested
grid. If False, uses only the user specified grid, which may lead to
inaccurate values for surface or volume averages.
Returns
-------
data : dict of ndarray
Computed quantity and intermediate variables.
"""
if isinstance(names, str):
names = [names]
if grid is None:
grid = QuadratureGrid(self.L_grid, self.M_grid, self.N_grid, self.NFP)
elif not isinstance(grid, _Grid):
raise TypeError(
"must pass in a Grid object for argument grid!"
f" instead got type {type(grid)}"
)
if params is None:
params = get_params(names, obj=self, has_axis=grid.axis.size)
if profiles is None:
profiles = get_profiles(names, obj=self, grid=grid)
if transforms is None:
transforms = get_transforms(names, obj=self, grid=grid, **kwargs)
if data is None:
data = {}
# To avoid the issue of using the wrong grid for surface and volume averages,
# we first figure out what needed qtys are flux functions or volume integrals
# and compute those first on a full grid
p = "desc.equilibrium.equilibrium.Equilibrium"
deps = list(set(get_data_deps(names, obj=p, has_axis=grid.axis.size) + names))
# TODO: replace this logic with `grid_type` from data_index
dep0d = [
dep
for dep in deps
if (data_index[p][dep]["coordinates"] == "") and (dep not in data)
]
dep1dr = [
dep
for dep in deps
if (data_index[p][dep]["coordinates"] == "r") and (dep not in data)
]
dep1dz = [
dep
for dep in deps
if (data_index[p][dep]["coordinates"] == "z") and (dep not in data)
]
# whether we need to calculate 0d or 1d quantities on a special grid
calc0d = bool(len(dep0d))
calc1dr = bool(len(dep1dr))
calc1dz = bool(len(dep1dz))
if ( # see if the grid we're already using will work for desired qtys
(grid.L >= self.L_grid)
and (grid.M >= self.M_grid)
and (grid.N >= self.N_grid)
):
if isinstance(grid, QuadratureGrid):
calc0d = calc1dr = calc1dz = False
if isinstance(grid, LinearGrid):
calc1dr = calc1dz = False
if calc0d and override_grid:
grid0d = QuadratureGrid(self.L_grid, self.M_grid, self.N_grid, self.NFP)
data0d = compute_fun(
self,
dep0d,
params=params,
transforms=get_transforms(dep0d, obj=self, grid=grid0d, **kwargs),
profiles=get_profiles(dep0d, obj=self, grid=grid0d),
data=None,
**kwargs,
)
# these should all be 0d quantities so don't need to compress/expand
data0d = {key: val for key, val in data0d.items() if key in dep0d}
data.update(data0d)
if calc1dr and override_grid:
grid1dr = LinearGrid(
rho=grid.nodes[grid.unique_rho_idx, 0],
M=self.M_grid,
N=self.N_grid,
NFP=self.NFP,
sym=self.sym,
)
# TODO: Pass in data0d as a seed once there are 1d quantities that
# depend on 0d quantities in data_index.
data1dr = compute_fun(
self,
dep1dr,
params=params,
transforms=get_transforms(dep1dr, obj=self, grid=grid1dr, **kwargs),
profiles=get_profiles(dep1dr, obj=self, grid=grid1dr),
data=None,
**kwargs,
)
# need to make this data broadcast with the data on the original grid
data1dr = {
key: grid.expand(
grid1dr.compress(val, surface_label="rho"), surface_label="rho"
)
for key, val in data1dr.items()
if key in dep1dr
}
data.update(data1dr)
if calc1dz and override_grid:
grid1dz = LinearGrid(
zeta=grid.nodes[grid.unique_zeta_idx, 2],
L=self.L_grid,
M=self.M_grid,
NFP=grid.NFP, # ex: self.NFP>1 but grid.NFP=1 for plot_3d
sym=self.sym,
)
# TODO: Pass in data0d as a seed once there are 1d quantities that
# depend on 0d quantities in data_index.
data1dz = compute_fun(
self,
dep1dz,
params=params,
transforms=get_transforms(dep1dz, obj=self, grid=grid1dz, **kwargs),
profiles=get_profiles(dep1dz, obj=self, grid=grid1dz),
data=None,
**kwargs,
)
# need to make this data broadcast with the data on the original grid
data1dz = {
key: grid.expand(
grid1dz.compress(val, surface_label="zeta"), surface_label="zeta"
)
for key, val in data1dz.items()
if key in dep1dz
}
data.update(data1dz)
# TODO: we can probably reduce the number of deps computed here if some are only
# needed as inputs for 0d and 1d qtys, unless the user asks for them
# specifically?
data = compute_fun(
self,
names,
params=params,
transforms=transforms,
profiles=profiles,
data=data,
**kwargs,
)
return data
def map_coordinates( # noqa: C901
self,
coords,
inbasis,
outbasis=("rho", "theta", "zeta"),
guess=None,
params=None,
period=(np.inf, np.inf, np.inf),
tol=1e-6,
maxiter=30,
full_output=False,
**kwargs,
):
"""Given coordinates in inbasis, compute corresponding coordinates in outbasis.
First solves for the computational coordinates that correspond to inbasis, then
evaluates outbasis at those locations.
Parameters
----------
coords : ndarray, shape(k,3)
2D array of input coordinates. Each row is a different
point in space.
inbasis, outbasis : tuple of str
Labels for input and output coordinates, eg ("R", "phi", "Z") or
("rho", "alpha", "zeta") or any combination thereof. Labels should be the
same as the compute function data key
guess : None or ndarray, shape(k,3)
Initial guess for the computational coordinates ['rho', 'theta', 'zeta']
corresponding to coords in inbasis. If None, heuristics are used based on
in basis and a nearest neighbor search on a coarse grid.
params : dict
Values of equilibrium parameters to use, eg eq.params_dict
period : tuple of float
Assumed periodicity for each quantity in inbasis.
Use np.inf to denote no periodicity.
tol : float
Stopping tolerance.
maxiter : int > 0
Maximum number of Newton iterations
full_output : bool, optional
If True, also return a tuple where the first element is the residual from
the root finding and the second is the number of iterations.
kwargs : dict, optional
Additional keyword arguments to pass to ``root`` such as ``maxiter_ls``,
``alpha``.
Returns
-------
coords : ndarray, shape(k,3)
Coordinates mapped from inbasis to outbasis.