Skip to content

Commit 7163993

Browse files
committed
Add shape information in to_xarray
- Centralize attribute metadata logic - Include :shape arrays in imas.util.to_xarray - Test that netCDF files written with xarray can be read as DBEntry
1 parent 2accf64 commit 7163993

4 files changed

Lines changed: 100 additions & 57 deletions

File tree

imas/_to_xarray.py

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from imas.ids_data_type import IDSDataType
99

1010
fillvals = {
11-
IDSDataType.INT: -(2**31) + 1,
11+
IDSDataType.INT: numpy.int32(-(2**31) + 1),
1212
IDSDataType.STR: "",
1313
IDSDataType.FLT: numpy.nan,
1414
IDSDataType.CPX: numpy.nan * (1 + 1j),
@@ -54,22 +54,24 @@ def to_xarray(ids: IDSToplevel, *paths: str) -> xarray.Dataset:
5454
if paths and path not in paths:
5555
continue
5656
dimensions = ()
57-
data = ""
57+
data = b""
5858
else:
5959
dimensions = tensorizer.get_dimensions(path)
6060
data = tensorizer.tensorize(path, fillvals[metadata.data_type])
6161

62-
attrs = dict(documentation=metadata.documentation)
63-
if metadata.units:
64-
attrs["units"] = metadata.units
65-
if dimensions:
66-
coordinates = tensorizer.filter_coordinates(path)
67-
if coordinates:
68-
coordinate_names.update(coordinates.split(" "))
69-
attrs["coordinates"] = coordinates
70-
62+
attrs = tensorizer.get_attributes(path, fillvals)
63+
if "coordinates" in attrs:
64+
coordinate_names.update(attrs["coordinates"].split(" "))
7165
data_vars[var_name] = (dimensions, data, attrs)
7266

67+
# :shape array for sparse data
68+
if path in tensorizer.shapes and metadata.ndim:
69+
shape_name = f"{var_name}:shape"
70+
dimensions = tensorizer.get_shape_dimensions(path)
71+
data = tensorizer.shapes[path]
72+
attrs = tensorizer.get_shape_attributes(var_name)
73+
data_vars[shape_name] = (dimensions, data, attrs)
74+
7375
# Remove coordinates from data_vars and put in coordinates mapping:
7476
coordinates = {}
7577
for coordinate_name in coordinate_names:

imas/backends/netcdf/ids2nc.py

Lines changed: 11 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -78,50 +78,17 @@ def create_variables(self) -> None:
7878
var = self.group.createVariable(var_name, dtype, dimensions, **kwargs)
7979

8080
# Fill metadata attributes
81-
var.documentation = metadata.documentation
82-
if metadata.units:
83-
var.units = metadata.units
84-
85-
ancillary_variables = " ".join(
86-
error_var
87-
for error_var in [f"{var_name}_error_upper", f"{var_name}_error_lower"]
88-
if error_var in self.filled_variables
89-
)
90-
if ancillary_variables:
91-
var.ancillary_variables = ancillary_variables
92-
93-
if metadata.data_type is not IDSDataType.STRUCT_ARRAY:
94-
coordinates = self.filter_coordinates(path)
95-
if coordinates:
96-
var.coordinates = coordinates
97-
98-
# Sparsity and :shape array
99-
if path in self.shapes:
100-
if not metadata.ndim:
101-
# Doesn't need a :shape array:
102-
var.sparse = "Sparse data, missing data is filled with _FillValue"
103-
var.sparse += f" ({default_fillvals[metadata.data_type]})"
104-
105-
else:
106-
shape_name = f"{var_name}:shape"
107-
var.sparse = f"Sparse data, data shapes are stored in {shape_name}"
108-
109-
# Create variable to store data shape
110-
dimensions = self.get_dimensions(self.ncmeta.aos.get(path)) + (
111-
f"{metadata.ndim}D",
112-
)
113-
shape_var = self.group.createVariable(
114-
shape_name,
115-
SHAPE_DTYPE,
116-
dimensions,
117-
)
118-
doc_indices = ",".join(chr(ord("i") + i) for i in range(3))
119-
shape_var.documentation = (
120-
f"Shape information for {var_name}.\n"
121-
f"{shape_name}[{doc_indices},:] describes the shape of filled "
122-
f"data of {var_name}[{doc_indices},...]. Data outside this "
123-
"shape is unset (i.e. filled with _Fillvalue)."
124-
)
81+
var.setncatts(self.get_attributes(path, default_fillvals))
82+
83+
# :shape array for sparse data
84+
if path in self.shapes and metadata.ndim:
85+
shape_name = f"{var_name}:shape"
86+
# Create variable to store data shape
87+
dimensions = self.get_shape_dimensions(path)
88+
shape_var = self.group.createVariable(
89+
shape_name, SHAPE_DTYPE, dimensions
90+
)
91+
shape_var.setncatts(self.get_shape_attributes(var_name))
12592

12693
def store_data(self) -> None:
12794
"""Store data in the netCDF variables"""

imas/backends/netcdf/ids_tensorizer.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"""Tensorization logic to convert IDSs to netCDF files and/or xarray Datasets."""
44

55
from collections import deque
6-
from typing import List, Tuple
6+
from typing import List, Tuple, Dict
77

88
import numpy
99

@@ -62,6 +62,11 @@ def get_dimensions(self, path: str) -> Tuple[str, ...]:
6262
"""
6363
return self.ncmeta.get_dimensions(path, self.homogeneous_time)
6464

65+
def get_shape_dimensions(self, path: str) -> Tuple[str, ...]:
66+
"""Get dimensions names for shape array of the tensorized variable"""
67+
ndim = self.ids.metadata[path].ndim
68+
return self.get_dimensions(self.ncmeta.aos.get(path, "")) + (f"{ndim}D",)
69+
6570
def include_coordinate_paths(self) -> None:
6671
"""Append all paths that are coordinates of self.paths_to_tensorize"""
6772
# Use a queue so we can also take coordinates of coordinates into account
@@ -173,6 +178,54 @@ def filter_coordinates(self, path: str) -> str:
173178
if coordinate in self.filled_variables
174179
)
175180

181+
def get_attributes(self, path: str, fillvals: dict) -> Dict[str, str]:
182+
"""Get metadata attributes of the tensorized variable"""
183+
metadata = self.ids.metadata[path]
184+
var_name = path.replace("/", ".")
185+
186+
assert metadata.documentation is not None
187+
attrs = {"documentation": metadata.documentation}
188+
if metadata.units:
189+
attrs["units"] = metadata.units
190+
191+
ancillary_variables = " ".join(
192+
error_var
193+
for error_var in [f"{var_name}_error_upper", f"{var_name}_error_lower"]
194+
if error_var in self.filled_variables
195+
)
196+
if ancillary_variables:
197+
attrs["ancillary_variables"] = ancillary_variables
198+
199+
if metadata.data_type is not IDSDataType.STRUCT_ARRAY:
200+
coordinates = self.filter_coordinates(path)
201+
if coordinates:
202+
attrs["coordinates"] = coordinates
203+
204+
# Sparsity
205+
if path in self.shapes:
206+
if not metadata.ndim:
207+
# Doesn't need a :shape array
208+
attrs["sparse"] = (
209+
"Sparse data, missing data is filled with _FillValue"
210+
f" ({fillvals[metadata.data_type]})"
211+
)
212+
else:
213+
attrs["sparse"] = (
214+
f"Sparse data, data shapes are stored in {var_name}:shape"
215+
)
216+
217+
return attrs
218+
219+
def get_shape_attributes(self, var_name: str) -> Dict[str, str]:
220+
doc_indices = ",".join(chr(ord("i") + i) for i in range(3))
221+
documentation = (
222+
f"Shape information for {var_name}.\n"
223+
f"{var_name}:shape[{doc_indices},:] describes the shape of filled "
224+
f"data of {var_name}[{doc_indices},...]. Data outside this "
225+
"shape is unset (i.e. filled with _Fillvalue)."
226+
)
227+
return {"documentation": documentation}
228+
176229
def tensorize(self, path, fillvalue):
177230
"""
178231
Tensorizes the data at the given path with the specified fill value.

imas/test/test_to_xarray.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import numpy as np
2+
import netCDF4
23
import pytest
34

45
import imas
56
import imas.training
7+
from imas.test.test_helpers import compare_children
68
from imas.util import to_xarray
79

8-
pytest.importorskip("xarray")
10+
xarray = pytest.importorskip("xarray")
911

1012

1113
@pytest.fixture
@@ -94,3 +96,22 @@ def test_to_xarray():
9496
ds3 = to_xarray(ids, "profiles_1d/electrons/temperature")
9597
assert ds1.equals(ds2)
9698
assert ds2.equals(ds3)
99+
100+
101+
@pytest.mark.parametrize("idsname", ["core_profiles", "equilibrium"])
102+
def test_roundtrip_xarray_netcdf(tmp_path, entry, idsname):
103+
ids = entry.get(idsname)
104+
xrds = to_xarray(ids)
105+
fname = f"{tmp_path}/test-{idsname}-xarray.nc"
106+
# First write mandatory file-level metadata
107+
with netCDF4.Dataset(fname, "x") as ds:
108+
ds.data_dictionary_version = imas.util.get_data_dictionary_version(ids)
109+
# Then use xarray to write the IDS
110+
xrds.to_netcdf(fname, "a", format="NETCDF4", group=f"{idsname}/0")
111+
# And read it back with a DBEntry
112+
with imas.DBEntry(fname, "r") as entry:
113+
ids2 = entry.get(idsname)
114+
compare_children(ids, ids2)
115+
# Reading the netCDF file with xarray should produce an identical dataset
116+
ncxrds = xarray.load_dataset(fname, group=f"{idsname}/0")
117+
assert xrds.equals(ncxrds)

0 commit comments

Comments
 (0)