Skip to content

Commit 01745de

Browse files
authored
Add option to use exact grid (#14)
## 3.1.0 - 2025-07-11 ### Added * Add `grid` module with types related to grid inputs * Add ability to pass in an exact gridspec as an alternative to the approximate min_extent * Add warnings when applying hidden defaults or accommodating deprecated kwargs ### Changed * Update `typical()` and `typical_outputs` to use min_extent and gridspec inputs
1 parent ae365a2 commit 01745de

File tree

7 files changed

+135
-45
lines changed

7 files changed

+135
-45
lines changed

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# Changelog
22

3+
## 3.1.0 - 2025-07-11
4+
5+
### Added
6+
7+
* Add `grid` module with types related to grid inputs
8+
* Add ability to pass in an exact gridspec as an alternative to the approximate min_extent
9+
* Add warnings when applying hidden defaults or accommodating deprecated kwargs
10+
11+
### Changed
12+
13+
* Update `typical()` and `typical_outputs` to use min_extent and gridspec inputs
14+
315
## 3.0.1 - 2025-07-07
416

517
### Changed

device_inductance/__init__.py

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,17 @@
66

77
from omas import ODS, load_omas_json
88

9-
from device_inductance import contour, logging, mesh, model_reduction, sensors, structures
9+
from device_inductance import (
10+
contour,
11+
logging,
12+
mesh,
13+
model_reduction,
14+
sensors,
15+
structures,
16+
)
1017
from device_inductance.coils import Coil, CoilFilament
1118
from device_inductance.device import DeviceInductance, TypicalOutputs
19+
from device_inductance.grid import Extent, GridSpec, Resolution
1220
from device_inductance.logging import log, logger_is_set_up, logger_setup_default
1321
from device_inductance.structures import PassiveStructureLoop
1422
from device_inductance.utils import (
@@ -76,13 +84,15 @@ def load_default_ods() -> ODS:
7684

7785
def typical(
7886
ods: ODS,
79-
extent: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0),
80-
dxgrid: tuple[float, float] = (0.0, 0.0),
87+
min_extent: Extent | None = None,
88+
dxgrid: Resolution = (0.0, 0.0),
8189
max_nmodes: int = 40,
8290
model_reduction_method: Literal["eigenmode", "stabilized eigenmode"] = "eigenmode",
8391
show_prog: bool = True,
8492
plasma_coil_force_method: Literal["tables", "mask"] = "mask",
8593
n_radial_slices: int = 30,
94+
gridspec: GridSpec | None = None, # Appended to avoid breaking change
95+
**kwargs, # For backwards compatibility with `extent` kwarg only
8696
) -> TypicalOutputs:
8797
"""
8898
Generate a typical set of outputs,
@@ -95,7 +105,9 @@ def typical(
95105
96106
Args:
97107
ods: An OMAS object in the format produced by device_description
98-
extent: [m] Extent of computational domain; adjusted during init
108+
min_extent: [m] rmin, rmax, zmin, zmax extent of computational domain.
109+
This will be updated during mesh initialization, during which it
110+
may be adjusted to satisfy the required spatial resolution.
99111
dxgrid: [m] Spatial resolution of computational grid
100112
max_nmodes: Maximum number of structure modes to keep. Defaults to 40.
101113
show_prog: Whether to show terminal progress bars. Defaults to True.
@@ -105,14 +117,24 @@ def typical(
105117
nonzero entries inside the limiter, which requires a valid limiter geometry.
106118
n_radial_slices: Number of radial slices to use for chunking large structures. Each slice is centered
107119
at the limiter centroid.
120+
gridspec: Exact alternative to min_extent. Only one of min_extent or gridspec should be provided.
108121
109122
Returns:
110123
A fully-computed set of matrices and tables covering the needs of a typical workflow
111124
"""
125+
if not logger_is_set_up():
126+
logger_setup_default()
127+
128+
if "extent" in kwargs:
129+
# Backwards compatibility with `extent` kwarg name only
130+
log().warning("`extent` input is deprecated; use `min_extent` or `gridspec` instead")
131+
min_extent = kwargs.pop("extent")
132+
112133
device = DeviceInductance(
113134
ods=ods,
114135
max_nmodes=max_nmodes,
115-
extent=extent,
136+
min_extent=min_extent,
137+
gridspec=gridspec,
116138
dxgrid=dxgrid,
117139
model_reduction_method=model_reduction_method,
118140
show_prog=show_prog,

device_inductance/device.py

Lines changed: 70 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
)
1212
from numpy.typing import NDArray
1313
from omas import ODS
14+
from pytest import approx
1415
from shapely import Point, Polygon
1516

1617
from device_inductance import model_reduction
@@ -22,6 +23,7 @@
2223
_calc_plasma_coil_forces,
2324
_calc_structure_coil_forces,
2425
)
26+
from device_inductance.grid import Extent, GridSpec, Resolution
2527
from device_inductance.logging import log, logger_is_set_up, logger_setup_default
2628
from device_inductance.mutuals import (
2729
_calc_circuit_mutual_inductances,
@@ -79,13 +81,15 @@ class DeviceInductance:
7981
"""OMAS data object in the format produced by device_description"""
8082
_max_nmodes: int = 40
8183
"""Maximum number of structure modes to retain"""
82-
_min_extent: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0)
84+
_min_extent: Extent | None = None
8385
"""
8486
[m] rmin, rmax, zmin, zmax extent of computational domain.
8587
This will be updated during mesh initialization, during which it
8688
may be adjusted to satisfy the required spatial resolution.
8789
"""
88-
_dxgrid: tuple[float, float] = (0.05, 0.05)
90+
_gridspec: GridSpec | None = None
91+
"""Exact alternative to min_extent. Only one of min_extent or gridspec should be provided."""
92+
_dxgrid: Resolution = (0.05, 0.05)
8993
"""[m] spatial resolution of computational grid"""
9094
_model_reduction_method: Literal["eigenmode", "stabilized eigenmode"] = "eigenmode"
9195
"""Choice of method for truncating passive structure system modes"""
@@ -106,7 +110,8 @@ def __init__(
106110
*,
107111
show_prog: bool = False,
108112
max_nmodes: int = 40,
109-
min_extent: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0),
113+
min_extent: Extent | None = None,
114+
gridspec: GridSpec | None = None,
110115
dxgrid: tuple[float, float] = (0.05, 0.05),
111116
model_reduction_method: Literal["eigenmode", "stabilized eigenmode"] = "eigenmode",
112117
plasma_coil_force_method: Literal["tables", "mask"] = "mask",
@@ -123,6 +128,7 @@ def __init__(
123128
min_extent: [m] rmin, rmax, zmin, zmax extent of computational domain.
124129
This will be updated during mesh initialization, during which it
125130
may be adjusted to satisfy the required spatial resolution.
131+
gridspec: Exact alternative to min_extent. Only one of min_extent or gridspec should be provided.
126132
dxgrid: [m] spatial resolution of computational grid
127133
plasma_coil_force_method: Whether to interpolate B-field on the fully-realized mesh tables,
128134
or do direct filament calculations from points inside the limiter mask.
@@ -137,6 +143,7 @@ def __init__(
137143

138144
if "extent" in kwargs:
139145
# Backwards compatibility with `extent` kwarg name only
146+
log().warning("`extent` input is deprecated; use `min_extent` or `gridspec` instead")
140147
min_extent = kwargs.pop("extent")
141148

142149
if len(kwargs) != 0:
@@ -145,6 +152,7 @@ def __init__(
145152
self._ods = ods
146153
self._max_nmodes = max_nmodes
147154
self._min_extent = min_extent
155+
self._gridspec = gridspec
148156
self._dxgrid = dxgrid
149157
self._model_reduction_method = model_reduction_method
150158
self._show_prog = show_prog
@@ -158,8 +166,8 @@ def __hash__(self) -> int:
158166
return hash(id(self))
159167

160168
def __post_init__(self):
161-
# Set a sensible default that encompasses the coils with 0.1m pad if none was provided
162-
if self._min_extent == (0.0, 0.0, 0.0, 0.0):
169+
# Set a sensible default grid that encompasses the coils with 0.1m pad if none was provided
170+
if (self._min_extent is None or self._min_extent == (0.0, 0.0, 0.0, 0.0)) and self._gridspec is None:
163171
inf = float("inf")
164172
self._min_extent = (inf, -inf, inf, -inf)
165173
for c in self.coils:
@@ -172,6 +180,7 @@ def __post_init__(self):
172180

173181
# Set a sensible default grid resolution to avoid dividing by zero
174182
if self._dxgrid == (0.0, 0.0):
183+
log().warning("Replacing zero grid deltas with sensible default")
175184
self._dxgrid = (0.05, 0.05)
176185

177186
# Immutable after init, except for new cache entries
@@ -195,12 +204,25 @@ def max_nmodes(self) -> int:
195204
return self._max_nmodes
196205

197206
@property
198-
def min_extent(self) -> tuple[float, float, float, float]:
207+
def min_extent(self) -> Extent:
199208
"""[m] (rmin, rmax, zmin, zmax) minimum extent requested at init.
200209
The actual extent of the mesh bounds this minimum, while respecting the
201-
required grid resolution exactly.
210+
required grid resolution (and possibly gridspec) exactly.
202211
"""
203-
return self._min_extent
212+
if self.gridspec is not None:
213+
rmin, nr, zmin, nz = self.gridspec
214+
dr, dz = self.dxgrid
215+
rmax = rmin + (nr - 1) * dr
216+
zmax = zmin + (nz - 1) * dz
217+
return (rmin, rmax, zmin, zmax)
218+
else:
219+
assert self._min_extent is not None, "At least one of min_extent or gridspec must be provided"
220+
return self._min_extent
221+
222+
@property
223+
def gridspec(self) -> GridSpec | None:
224+
"""Exact alternative to min_extent. Only one of min_extent or gridspec should be provided."""
225+
return self._gridspec
204226

205227
@property
206228
def dxgrid(self) -> tuple[float, float]:
@@ -281,29 +303,44 @@ def limiter_mask(self) -> NDArray[F64]:
281303
@cached_property
282304
def _calc_meshes(
283305
self,
284-
) -> tuple[tuple[NDArray[F64], NDArray[F64]], tuple[float, float, float, float]]:
306+
) -> tuple[tuple[NDArray[F64], NDArray[F64]], Extent]:
285307
"""Initialize both meshes and final extent after adjustment to achieve target resolution"""
286-
# Actualize the grid/mesh and update extent
287-
rmin, rmax, zmin, zmax = self.min_extent # [m]
288-
dr, dz = self.dxgrid # [m]
289-
if rmax - rmin > 0.0 and zmax - zmin > 0.0:
290-
rgrid = np.arange(rmin, rmax + dr, dr) # [m] Grid that spans full extent
291-
zgrid = np.arange(zmin, zmax + dz, dz) # [m]
292-
rmesh, zmesh = np.meshgrid(rgrid, zgrid, indexing="ij") # [m]
308+
if self.gridspec is not None:
309+
# Make the grid
310+
rmin, nr, zmin, nz = self.gridspec
311+
extent = self.min_extent # [m] shrinkwraps gridspec
312+
rmin, rmax, zmin, zmax = extent # [m]
313+
rgrid = np.linspace(rmin, rmax, nr) # [m]
314+
zgrid = np.linspace(zmin, zmax, nz) # [m]
315+
# Check that the grid exactly matches the spec
316+
dr, dz = self.dxgrid
317+
assert rgrid[1] - rgrid[0] == approx(dr, rel=1e-8)
318+
assert zgrid[1] - zgrid[0] == approx(dz, rel=1e-8)
293319

294-
# Update the extent, which may have been adjusted
295-
extent = (
296-
float(np.min(rgrid)),
297-
float(np.max(rgrid)),
298-
float(np.min(zgrid)),
299-
float(np.max(zgrid)),
300-
)
320+
rmesh, zmesh = np.meshgrid(rgrid, zgrid, indexing="ij") # [m]
301321
else:
302-
# If our grid spec is zero-size, make a unit mesh
303-
# to allow the calcs to proceed, without providing real tables
304-
rmesh = np.nan * np.zeros((1, 1))
305-
zmesh = np.nan * np.zeros((1, 1))
306-
extent = self.min_extent
322+
# Actualize the grid/mesh and update extent
323+
rmin, rmax, zmin, zmax = self.min_extent # [m]
324+
dr, dz = self.dxgrid # [m]
325+
if rmax - rmin > 0.0 and zmax - zmin > 0.0:
326+
rgrid = np.arange(rmin, rmax + dr, dr) # [m] Grid that spans full extent
327+
zgrid = np.arange(zmin, zmax + dz, dz) # [m]
328+
rmesh, zmesh = np.meshgrid(rgrid, zgrid, indexing="ij") # [m]
329+
330+
# Update the extent, which may have been adjusted
331+
extent = (
332+
float(np.min(rgrid)),
333+
float(np.max(rgrid)),
334+
float(np.min(zgrid)),
335+
float(np.max(zgrid)),
336+
)
337+
else:
338+
# If our grid spec is zero-size, make a unit mesh
339+
# to allow the calcs to proceed, without providing real tables
340+
rmesh = np.nan * np.zeros((1, 1))
341+
zmesh = np.nan * np.zeros((1, 1))
342+
extent = self.min_extent
343+
307344
return (rmesh, zmesh), extent
308345

309346
@cached_property
@@ -321,13 +358,13 @@ def grids(self) -> tuple[NDArray[F64], NDArray[F64]]:
321358
return (rgrid, zgrid)
322359

323360
@cached_property
324-
def extent(self) -> tuple[float, float, float, float]:
361+
def extent(self) -> Extent:
325362
"""[m] The (rmin, rmax, zmin, zmax) extent of the grid cell centers"""
326363
_, extent = self._calc_meshes
327364
return extent
328365

329366
@cached_property
330-
def extent_for_plotting(self) -> tuple[float, float, float, float]:
367+
def extent_for_plotting(self) -> Extent:
331368
"""[m] The (rmin, rmax, zmin, zmax) extent of the grid cell edges"""
332369
rmin, rmax, zmin, zmax = self.extent
333370
dr, dz = self.dxgrid
@@ -829,13 +866,14 @@ class TypicalOutputs:
829866
on a device description ODS.
830867
"""
831868

832-
extent: tuple[float, float, float, float]
869+
extent: Extent
833870
"""
834871
[m] Extent of the computational domain's cell centers.
835872
This will be updated during initialization, during which it
836873
may be adjusted to satisfy the required spatial resolution.
837874
"""
838-
extent_for_plotting: tuple[float, float, float, float]
875+
876+
extent_for_plotting: Extent
839877
"""
840878
[m] The full extent of the computational domain implied by
841879
the cell centers, expanded a half-cell to the boundary cells' edges

device_inductance/grid.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""Grid input definitions"""
2+
3+
GridSpec = tuple[float, int, float, int]
4+
"""[m] rmin, nr, zmin, nz exact specification of regular grid,
5+
as an alternative to approximate minimum extent"""
6+
7+
8+
Extent = tuple[float, float, float, float]
9+
"""[m] rmin, rmax, zmin, zmax rectangular bounds"""
10+
11+
12+
Resolution = tuple[float, float]
13+
"""[m] dr, dz spatial resolution of computational grid"""

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "device_inductance"
3-
version = "3.0.1"
3+
version = "3.1.0"
44
description = "Tokamak core inductance matrices and flux tables"
55
authors = [{ name = "Commonwealth Fusion Systems", email = "jlogan@cfs.energy" }]
66
requires-python = ">=3.10, <3.14"

test/__init__.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,19 @@ def typical_outputs() -> device_inductance.TypicalOutputs:
1010
# but in order for the tables to be testable,
1111
# we need a fairly fine one
1212
dxgrid = (0.05, 0.04) # Different resolution to make sure they are never swapped
13-
extent = (2.0 * dxgrid[0], 4.5, -3.0, 3.0)
13+
# NOTE: min_extent here is slightly larger than the others in order to
14+
# reproduce the padding cell from the approximate method while using the exact method instead.
15+
min_extent = (2.0 * dxgrid[0], 4.55, -3.0, 3.04)
16+
rmin, rmax, zmin, zmax = min_extent
17+
dr, dz = dxgrid
18+
gridspec = (rmin, int((rmax - rmin) / dr), zmin, int((zmax - zmin) / dz))
1419

1520
# Load the default device
1621
ods = device_inductance.load_default_ods()
1722

1823
# Pre-compute the usual set of matrices and tables
1924
typical_outputs = device_inductance.typical(
20-
ods, extent, dxgrid, max_nmodes=int(1e6), show_prog=False
25+
ods, gridspec=gridspec, dxgrid=dxgrid, max_nmodes=int(1e6), show_prog=False
2126
)
2227

2328
return typical_outputs
@@ -30,14 +35,14 @@ def typical_outputs_many_slices() -> device_inductance.TypicalOutputs:
3035
# but in order for the tables to be testable,
3136
# we need a fairly fine one
3237
dxgrid = (0.05, 0.04) # Different resolution to make sure they are never swapped
33-
extent = (2.0 * dxgrid[0], 4.5, -3.0, 3.0)
38+
min_extent = (2.0 * dxgrid[0], 4.5, -3.0, 3.0)
3439

3540
# Load the default device
3641
ods = device_inductance.load_default_ods()
3742

3843
# Pre-compute the usual set of matrices and tables
3944
typical_outputs = device_inductance.typical(
40-
ods, extent, dxgrid, max_nmodes=int(1e6), show_prog=False, n_radial_slices=100
45+
ods, min_extent, dxgrid, max_nmodes=int(1e6), show_prog=False, n_radial_slices=100
4146
)
4247

4348
return typical_outputs
@@ -54,15 +59,15 @@ def typical_outputs_stabilized_eigenmode() -> device_inductance.TypicalOutputs:
5459
# but in order for the tables to be testable,
5560
# we need a fairly fine one
5661
dxgrid = (0.05, 0.04) # Different resolution to make sure they are never swapped
57-
extent = (2.0 * dxgrid[0], 4.5, -3.0, 3.0)
62+
min_extent = (2.0 * dxgrid[0], 4.5, -3.0, 3.0)
5863

5964
# Load the default device
6065
ods = device_inductance.load_default_ods()
6166

6267
# Pre-compute the usual set of matrices and tables
6368
typical_outputs = device_inductance.typical(
6469
ods,
65-
extent,
70+
min_extent,
6671
dxgrid,
6772
max_nmodes=int(1e6),
6873
show_prog=False,

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)