Skip to content

Commit fe0e8ee

Browse files
authored
Merge pull request #242 from boutproject/interpolate-cartesian
Interpolate to a Cartesian grid
2 parents ceb75b3 + da78317 commit fe0e8ee

File tree

7 files changed

+410
-4
lines changed

7 files changed

+410
-4
lines changed

.github/workflows/pythonpackage.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ jobs:
5555
matrix:
5656
python-version: [3.7, 3.8]
5757
pip-packages:
58-
- "setuptools pip pytest pytest-cov coverage codecov boutdata==0.1.4 xarray==0.18.0 dask==2.10.0 numpy==1.18.0 natsort==5.5.0 matplotlib==3.1.1 animatplot==0.4.2 netcdf4==1.4.2 Pillow==6.1.0" # test with oldest supported version of packages. Note, using numpy==1.18.0 as a workaround because numpy==1.17.0 is not supported on Python-3.7, even though we should currently support numpy==1.17.0.
58+
- "setuptools pip pytest pytest-cov coverage codecov boutdata==0.1.4 xarray==0.18.0 dask==2.10.0 numpy==1.18.0 natsort==5.5.0 matplotlib==3.1.1 animatplot==0.4.2 netcdf4==1.4.2 Pillow==7.2.0" # test with oldest supported version of packages. Note, using numpy==1.18.0 as a workaround because numpy==1.17.0 is not supported on Python-3.7, even though we should currently support numpy==1.17.0.
5959
fail-fast: false
6060

6161
steps:

.github/workflows/pythonpublish.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ jobs:
4949
matrix:
5050
python-version: [3.7, 3.8]
5151
pip-packages:
52-
- "setuptools pip pytest pytest-cov coverage codecov boutdata==0.1.4 xarray==0.18.0 dask==2.10.0 numpy==1.18.0 natsort==5.5.0 matplotlib==3.1.1 animatplot==0.4.2 netcdf4==1.4.2 Pillow==6.1.0" # test with oldest supported version of packages. Note, using numpy==1.18.0 as a workaround because numpy==1.17.0 is not supported on Python-3.7, even though we should currently support numpy==1.17.0.
52+
- "setuptools pip pytest pytest-cov coverage codecov boutdata==0.1.4 xarray==0.18.0 dask==2.10.0 numpy==1.18.0 natsort==5.5.0 matplotlib==3.1.1 animatplot==0.4.2 netcdf4==1.4.2 Pillow==7.2.0" # test with oldest supported version of packages. Note, using numpy==1.18.0 as a workaround because numpy==1.17.0 is not supported on Python-3.7, even though we should currently support numpy==1.17.0.
5353
fail-fast: true
5454

5555
steps:

xbout/boutdataarray.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@
1515
from .plotting import plotfuncs
1616
from .plotting.utils import _create_norm
1717
from .region import _from_region
18-
from .utils import _update_metadata_increased_resolution, _get_bounding_surfaces
18+
from .utils import (
19+
_add_cartesian_coordinates,
20+
_update_metadata_increased_resolution,
21+
_get_bounding_surfaces,
22+
)
1923

2024

2125
@register_dataarray_accessor("bout")
@@ -381,6 +385,17 @@ def interpolate_parallel(
381385

382386
return da
383387

388+
def add_cartesian_coordinates(self):
389+
"""
390+
Add Cartesian (X,Y,Z) coordinates.
391+
392+
Returns
393+
-------
394+
DataArray with new coordinates added, which are named 'X_cartesian',
395+
'Y_cartesian', and 'Z_cartesian'
396+
"""
397+
return _add_cartesian_coordinates(self.data)
398+
384399
def remove_yboundaries(self, return_dataset=False, remove_extra_upper=False):
385400
"""
386401
Remove y-boundary points, if present, from the DataArray
@@ -1001,6 +1016,42 @@ def interpolate_from_unstructured(
10011016

10021017
return result
10031018

1019+
def interpolate_to_cartesian(self, *args, **kwargs):
1020+
"""
1021+
Interpolate the DataArray to a regular Cartesian grid.
1022+
1023+
This method is intended to be used to produce data for visualisation, which
1024+
normally does not require double-precision values, so by default the data is
1025+
converted to `np.float32`. Pass `use_float32=False` to retain the original
1026+
precision.
1027+
1028+
Parameters
1029+
----------
1030+
nX : int (default 300)
1031+
Number of grid points in the X direction
1032+
nY : int (default 300)
1033+
Number of grid points in the Y direction
1034+
nZ : int (default 100)
1035+
Number of grid points in the Z direction
1036+
use_float32 : bool (default True)
1037+
Downgrade precision to `np.float32`?
1038+
fill_value : float (default np.nan)
1039+
Value to use for points outside the interpolation domain (passed to
1040+
`scipy.RegularGridInterpolator`)
1041+
1042+
See Also
1043+
--------
1044+
BoutDataset.interpolate_to_cartesian
1045+
"""
1046+
da = self.data
1047+
name = da.name
1048+
ds = da.to_dataset()
1049+
# Dataset needs geometry and metadata attributes, but these are not copied from
1050+
# the DataArray by default
1051+
ds.attrs["geometry"] = da.geometry
1052+
ds.attrs["metadata"] = da.metadata
1053+
return ds.bout.interpolate_to_cartesian(*args, **kwargs)[name]
1054+
10041055
# BOUT-specific plotting functionality: methods that plot on a poloidal (R-Z) plane
10051056
def contour(self, ax=None, **kwargs):
10061057
"""

xbout/boutdataset.py

Lines changed: 170 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@
2727
_parse_coord_option,
2828
)
2929
from .region import _from_region
30-
from .utils import _get_bounding_surfaces, _split_into_restarts
30+
from .utils import (
31+
_add_cartesian_coordinates,
32+
_get_bounding_surfaces,
33+
_split_into_restarts,
34+
)
3135

3236

3337
@xr.register_dataset_accessor("bout")
@@ -542,6 +546,171 @@ def interpolate_from_unstructured(
542546

543547
return ds
544548

549+
def interpolate_to_cartesian(
550+
self, nX=300, nY=300, nZ=100, *, use_float32=True, fill_value=np.nan
551+
):
552+
"""
553+
Interpolate the Dataset to a regular Cartesian grid.
554+
555+
This method is intended to be used to produce data for visualisation, which
556+
normally does not require double-precision values, so by default the data is
557+
converted to `np.float32`. Pass `use_float32=False` to retain the original
558+
precision.
559+
560+
Parameters
561+
----------
562+
nX : int (default 300)
563+
Number of grid points in the X direction
564+
nY : int (default 300)
565+
Number of grid points in the Y direction
566+
nZ : int (default 100)
567+
Number of grid points in the Z direction
568+
use_float32 : bool (default True)
569+
Downgrade precision to `np.float32`?
570+
fill_value : float (default np.nan)
571+
Value to use for points outside the interpolation domain (passed to
572+
`scipy.RegularGridInterpolator`)
573+
574+
See Also
575+
--------
576+
BoutDataArray.interpolate_to_cartesian
577+
"""
578+
ds = self.data
579+
ds = ds.bout.add_cartesian_coordinates()
580+
581+
if not isinstance(use_float32, bool):
582+
raise ValueError(f"use_float32 must be a bool, got '{use_float32}'")
583+
if use_float32:
584+
float_type = np.float32
585+
ds = ds.astype(float_type)
586+
for coord in ds.coords:
587+
# Coordinates are not converted by Dataset.astype, so convert explicitly
588+
ds[coord] = ds[coord].astype(float_type)
589+
fill_value = float_type(fill_value)
590+
else:
591+
float_type = ds[ds.data_vars[0]].dtype
592+
593+
tdim = ds.metadata["bout_tdim"]
594+
zdim = ds.metadata["bout_zdim"]
595+
if tdim in ds.dims:
596+
nt = ds.sizes[tdim]
597+
n_toroidal = ds.sizes[zdim]
598+
599+
# Create Cartesian grid to interpolate to
600+
Xmin = ds["X_cartesian"].min()
601+
Xmax = ds["X_cartesian"].max()
602+
Ymin = ds["Y_cartesian"].min()
603+
Ymax = ds["Y_cartesian"].max()
604+
Zmin = ds["Z_cartesian"].min()
605+
Zmax = ds["Z_cartesian"].max()
606+
newX_1d = xr.DataArray(np.linspace(Xmin, Xmax, nX), dims="X")
607+
newX = newX_1d.expand_dims({"Y": nY, "Z": nZ}, axis=[1, 2])
608+
newY_1d = xr.DataArray(np.linspace(Ymin, Ymax, nY), dims="Y")
609+
newY = newY_1d.expand_dims({"X": nX, "Z": nZ}, axis=[0, 2])
610+
newZ_1d = xr.DataArray(np.linspace(Zmin, Zmax, nZ), dims="Z")
611+
newZ = newZ_1d.expand_dims({"X": nX, "Y": nY}, axis=[0, 1])
612+
newR = np.sqrt(newX**2 + newY**2)
613+
newzeta = np.arctan2(newY, newX)
614+
# Define newzeta in range 0->2*pi
615+
newzeta = np.where(newzeta < 0.0, newzeta + 2.0 * np.pi, newzeta)
616+
617+
from scipy.interpolate import (
618+
RegularGridInterpolator,
619+
griddata,
620+
)
621+
622+
# Create Cylindrical coordinates for intermediate grid
623+
Rcyl_min = float_type(ds["R"].min())
624+
Rcyl_max = float_type(ds["R"].max())
625+
Zcyl_min = float_type(ds["Z"].min())
626+
Zcyl_max = float_type(ds["Z"].max())
627+
n_Rcyl = int(round(nZ * (Rcyl_max - Rcyl_min) / (Zcyl_max - Zcyl_min)))
628+
Rcyl = xr.DataArray(np.linspace(Rcyl_min, Rcyl_max, 2 * n_Rcyl), dims="r")
629+
Zcyl = xr.DataArray(np.linspace(Zcyl_min, Zcyl_max, 2 * nZ), dims="z")
630+
631+
# Create Dataset for result
632+
result = xr.Dataset()
633+
result.attrs["metadata"] = ds.metadata
634+
635+
# Interpolate in two stages for efficiency. Unstructured 3d interpolation is
636+
# very slow. Unstructured 2d interpolation onto Cartesian (R, Z) grids, followed
637+
# by structured 3d interpolation onto the (X, Y, Z) grid, is much faster.
638+
# Structured 3d interpolation straight from (psi, theta, zeta) to (X, Y, Z)
639+
# leaves artifacts in the output, because theta does not vary continuously
640+
# everywhere (has branch cuts).
641+
642+
zeta_out = np.zeros(n_toroidal + 1)
643+
zeta_out[:-1] = ds[zdim].values
644+
zeta_out[-1] = zeta_out[-2] + ds["dz"].mean()
645+
646+
def interp_single_time(da):
647+
print(" interpolate poloidal planes")
648+
649+
da_cyl = da.bout.interpolate_from_unstructured(R=Rcyl, Z=Zcyl).transpose(
650+
"R", "Z", zdim, missing_dims="ignore"
651+
)
652+
653+
if zdim not in da_cyl.dims:
654+
da_cyl = da_cyl.expand_dims({zdim: n_toroidal + 1}, axis=-1)
655+
else:
656+
# Impose toroidal periodicity by appending zdim=0 to end of array
657+
da_cyl = xr.concat((da_cyl, da_cyl.isel({zdim: 0})), zdim)
658+
659+
print(" build 3d interpolator")
660+
interp = RegularGridInterpolator(
661+
(Rcyl.values, Zcyl.values, zeta_out),
662+
da_cyl.values,
663+
bounds_error=False,
664+
fill_value=fill_value,
665+
)
666+
667+
print(" do 3d interpolation")
668+
return interp(
669+
(newR, newZ, newzeta),
670+
method="linear",
671+
)
672+
673+
for name, da in ds.data_vars.items():
674+
print(f"\ninterpolating {name}")
675+
# order of dimensions does not really matter here - output only depends on
676+
# shape of newR, newZ, newzeta. Possibly more efficient to assign the 2d
677+
# results in the loop to the last two dimensions, so put zeta first. Can't
678+
# just use da.min().item() here (to get a scalar value instead of a
679+
# zero-size array) because .item() doesn't work for dask arrays (yet!).
680+
681+
datamin = float_type(da.min().values)
682+
datamax = float_type(da.max().values)
683+
684+
if tdim in da.dims:
685+
data_cartesian = np.zeros((nt, nX, nY, nZ), dtype=float_type)
686+
for tind in range(nt):
687+
print(f" tind={tind}")
688+
data_cartesian[tind, :, :, :] = interp_single_time(
689+
da.isel({tdim: tind})
690+
)
691+
result[name] = xr.DataArray(data_cartesian, dims=[tdim, "X", "Y", "Z"])
692+
else:
693+
data_cartesian = interp_single_time(da)
694+
result[name] = xr.DataArray(data_cartesian, dims=["X", "Y", "Z"])
695+
696+
# Copy metadata to data variables, in case it is needed
697+
result[name].attrs["metadata"] = ds.metadata
698+
699+
result = result.assign_coords(X=newX_1d, Y=newY_1d, Z=newZ_1d)
700+
701+
return result
702+
703+
def add_cartesian_coordinates(self):
704+
"""
705+
Add Cartesian (X,Y,Z) coordinates.
706+
707+
Returns
708+
-------
709+
Dataset with new coordinates added, which are named 'X_cartesian',
710+
'Y_cartesian', and 'Z_cartesian'
711+
"""
712+
return _add_cartesian_coordinates(self.data)
713+
545714
def remove_yboundaries(self, **kwargs):
546715
"""
547716
Remove y-boundary points, if present, from the Dataset

xbout/tests/test_boutdataarray.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -954,6 +954,85 @@ def test_interpolate_parallel_toroidal_points_list(self, bout_xyt_example_files)
954954

955955
xrt.assert_identical(n_highres_truncated, n_highres.isel(zeta=points_list))
956956

957+
def test_interpolate_to_cartesian(self, bout_xyt_example_files):
958+
dataset_list = bout_xyt_example_files(
959+
None, lengths=(2, 16, 17, 18), nxpe=1, nype=1, nt=1
960+
)
961+
with pytest.warns(UserWarning):
962+
ds = open_boutdataset(
963+
datapath=dataset_list, inputfilepath=None, keep_xboundaries=False
964+
)
965+
966+
ds["psixy"] = ds["g11"].copy(deep=True)
967+
ds["Rxy"] = ds["g11"].copy(deep=True)
968+
ds["Zxy"] = ds["g11"].copy(deep=True)
969+
970+
r = np.linspace(1.0, 2.0, ds.metadata["nx"])
971+
theta = np.linspace(0.0, 2.0 * np.pi, ds.metadata["ny"])
972+
R = r[:, np.newaxis] * np.cos(theta[np.newaxis, :])
973+
Z = r[:, np.newaxis] * np.sin(theta[np.newaxis, :])
974+
ds["Rxy"].values[:] = R
975+
ds["Zxy"].values[:] = Z
976+
977+
ds = apply_geometry(ds, "toroidal")
978+
979+
da = ds["n"]
980+
da.values[:] = 1.0
981+
982+
nX = 30
983+
nY = 30
984+
nZ = 10
985+
da_cartesian = da.bout.interpolate_to_cartesian(nX, nY, nZ)
986+
987+
# Check a point inside the original grid
988+
npt.assert_allclose(
989+
da_cartesian.isel(t=0, X=round(nX * 4 / 5), Y=nY // 2, Z=nZ // 2).item(),
990+
1.0,
991+
rtol=1.0e-15,
992+
atol=1.0e-15,
993+
)
994+
# Check a point outside the original grid
995+
assert np.isnan(da_cartesian.isel(t=0, X=0, Y=0, Z=0).item())
996+
# Check output is float32
997+
assert da_cartesian.dtype == np.float32
998+
999+
def test_add_cartesian_coordinates(self, bout_xyt_example_files):
1000+
dataset_list = bout_xyt_example_files(None, nxpe=1, nype=1, nt=1)
1001+
with pytest.warns(UserWarning):
1002+
ds = open_boutdataset(
1003+
datapath=dataset_list, inputfilepath=None, keep_xboundaries=False
1004+
)
1005+
1006+
ds["psixy"] = ds["g11"].copy(deep=True)
1007+
ds["Rxy"] = ds["g11"].copy(deep=True)
1008+
ds["Zxy"] = ds["g11"].copy(deep=True)
1009+
1010+
r = np.linspace(1.0, 2.0, ds.metadata["nx"])
1011+
theta = np.linspace(0.0, 2.0 * np.pi, ds.metadata["ny"])
1012+
R = r[:, np.newaxis] * np.cos(theta[np.newaxis, :])
1013+
Z = r[:, np.newaxis] * np.sin(theta[np.newaxis, :])
1014+
ds["Rxy"].values[:] = R
1015+
ds["Zxy"].values[:] = Z
1016+
1017+
ds = apply_geometry(ds, "toroidal")
1018+
1019+
zeta = ds["zeta"].values
1020+
1021+
da = ds["n"].bout.add_cartesian_coordinates()
1022+
1023+
npt.assert_allclose(
1024+
da["X_cartesian"],
1025+
R[:, :, np.newaxis] * np.cos(zeta[np.newaxis, np.newaxis, :]),
1026+
)
1027+
npt.assert_allclose(
1028+
da["Y_cartesian"],
1029+
R[:, :, np.newaxis] * np.sin(zeta[np.newaxis, np.newaxis, :]),
1030+
)
1031+
npt.assert_allclose(
1032+
da["Z_cartesian"],
1033+
Z[:, :, np.newaxis] * np.ones(ds.metadata["nz"])[np.newaxis, np.newaxis, :],
1034+
)
1035+
9571036
def test_ddx(self, bout_xyt_example_files):
9581037

9591038
nx = 64

0 commit comments

Comments
 (0)