imas-python 1.2.1.dev13__py3-none-any.whl → 2.0.0.post1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,205 @@
1
+ # This file is part of IMAS-Python.
2
+ # You should have received the IMAS-Python LICENSE file with this project.
3
+ """Tensorization logic to convert IDSs to netCDF files and/or xarray Datasets."""
4
+
5
+ from collections import deque
6
+ from typing import List
7
+
8
+ import numpy
9
+
10
+ from imas.backends.netcdf.iterators import indexed_tree_iter
11
+ from imas.backends.netcdf.nc_metadata import NCMetadata
12
+ from imas.ids_data_type import IDSDataType
13
+ from imas.ids_defs import IDS_TIME_MODE_HOMOGENEOUS
14
+ from imas.ids_toplevel import IDSToplevel
15
+
16
+ dtypes = {
17
+ IDSDataType.INT: numpy.dtype(numpy.int32),
18
+ IDSDataType.STR: str,
19
+ IDSDataType.FLT: numpy.dtype(numpy.float64),
20
+ IDSDataType.CPX: numpy.dtype(numpy.complex128),
21
+ }
22
+ SHAPE_DTYPE = numpy.int32
23
+
24
+
25
+ class IDSTensorizer:
26
+ """Common functionality for tensorizing IDSs. Used in IDS2NC and util.to_xarray."""
27
+
28
+ def __init__(self, ids: IDSToplevel, paths_to_tensorize: List[str]) -> None:
29
+ """Initialize IDSTensorizer.
30
+
31
+ Args:
32
+ ids: IDSToplevel to store in the netCDF group
33
+ paths_to_tensorize: Restrict tensorization to the provided paths. If an
34
+ empty list is provided, all filled quantities in the IDS will be
35
+ tensorized.
36
+ """
37
+ self.ids = ids
38
+ """IDS to tensorize."""
39
+ self.paths_to_tensorize = paths_to_tensorize
40
+ """List of paths to tensorize"""
41
+
42
+ self.ncmeta = NCMetadata(ids.metadata)
43
+ """NetCDF related metadata."""
44
+ self.dimension_size = {}
45
+ """Map dimension name to its size."""
46
+ self.filled_data = {}
47
+ """Map of IDS paths to filled data nodes."""
48
+ self.filled_variables = set()
49
+ """Set of filled IDS variables"""
50
+ self.homogeneous_time = (
51
+ ids.ids_properties.homogeneous_time == IDS_TIME_MODE_HOMOGENEOUS
52
+ )
53
+ """True iff the IDS time mode is homogeneous."""
54
+ self.shapes = {}
55
+ """Map of IDS paths to data shape arrays."""
56
+
57
+ def include_coordinate_paths(self) -> None:
58
+ """Append all paths that are coordinates of self.paths_to_tensorize"""
59
+ # Use a queue so we can also take coordinates of coordinates into account
60
+ queue = deque(self.paths_to_tensorize)
61
+ # Include all parent AoS as well:
62
+ for path in self.paths_to_tensorize:
63
+ while path:
64
+ path, _, _ = path.rpartition("/")
65
+ if self.ncmeta.get_dimensions(path, self.homogeneous_time):
66
+ queue.append(path)
67
+
68
+ self.paths_to_tensorize = []
69
+ while queue:
70
+ path = queue.popleft()
71
+ if path in self.paths_to_tensorize:
72
+ continue # already processed
73
+ self.paths_to_tensorize.append(path)
74
+ for coordinate in self.ncmeta.get_coordinates(path, self.homogeneous_time):
75
+ queue.append(coordinate.replace(".", "/"))
76
+
77
+ def collect_filled_data(self) -> None:
78
+ """Collect all filled data in the IDS and determine dimension sizes.
79
+
80
+ Results are stored in :attr:`filled_data` and :attr:`dimension_size`.
81
+ """
82
+ # Initialize dictionary with all paths that could exist in this IDS
83
+ filled_data = {path: {} for path in self.ncmeta.paths}
84
+ dimension_size = {}
85
+ get_dimensions = self.ncmeta.get_dimensions
86
+
87
+ if self.paths_to_tensorize:
88
+ # Restrict tensorization to provided paths
89
+ iterator = (
90
+ item
91
+ for path in self.paths_to_tensorize
92
+ for item in indexed_tree_iter(self.ids, self.ids.metadata[path])
93
+ if item[1].has_value # Skip nodes without value set
94
+ )
95
+ else:
96
+ # Tensorize all non-empty nodes
97
+ iterator = indexed_tree_iter(self.ids)
98
+
99
+ for aos_index, node in iterator:
100
+ path = node.metadata.path_string
101
+ filled_data[path][aos_index] = node
102
+ ndim = node.metadata.ndim
103
+ if not ndim:
104
+ continue
105
+ dimensions = get_dimensions(path, self.homogeneous_time)
106
+ # We're only interested in the non-tensorized dimensions: [-ndim:]
107
+ for dim_name, size in zip(dimensions[-ndim:], node.shape):
108
+ dimension_size[dim_name] = max(dimension_size.get(dim_name, 0), size)
109
+
110
+ # Remove paths without data
111
+ self.filled_data = {path: data for path, data in filled_data.items() if data}
112
+ self.filled_variables = {path.replace("/", ".") for path in self.filled_data}
113
+ # Store dimension sizes
114
+ self.dimension_size = dimension_size
115
+
116
+ def determine_data_shapes(self) -> None:
117
+ """Determine tensorized data shapes and sparsity, save in :attr:`shapes`."""
118
+ get_dimensions = self.ncmeta.get_dimensions
119
+
120
+ for path, nodes_dict in self.filled_data.items():
121
+ metadata = self.ids.metadata[path]
122
+ # Structures don't have a size
123
+ if metadata.data_type is IDSDataType.STRUCTURE:
124
+ continue
125
+ ndim = metadata.ndim
126
+ dimensions = get_dimensions(path, self.homogeneous_time)
127
+
128
+ # node shape if it is completely filled
129
+ full_shape = tuple(self.dimension_size[dim] for dim in dimensions[-ndim:])
130
+
131
+ if len(dimensions) == ndim:
132
+ # Data at this path is not tensorized
133
+ node = nodes_dict[()]
134
+ sparse = node.shape != full_shape
135
+ if sparse:
136
+ shapes = numpy.array(node.shape, dtype=SHAPE_DTYPE)
137
+
138
+ else:
139
+ # Data is tensorized, determine if it is homogeneously shaped
140
+ aos_dims = get_dimensions(self.ncmeta.aos[path], self.homogeneous_time)
141
+ shapes_shape = [self.dimension_size[dim] for dim in aos_dims]
142
+ if ndim:
143
+ shapes_shape.append(ndim)
144
+ shapes = numpy.zeros(shapes_shape, dtype=SHAPE_DTYPE)
145
+
146
+ if ndim: # ND types have a shape
147
+ for aos_coords, node in nodes_dict.items():
148
+ shapes[aos_coords] = node.shape
149
+ sparse = not numpy.array_equiv(shapes, full_shape)
150
+
151
+ else: # 0D types don't have a shape
152
+ for aos_coords in nodes_dict.keys():
153
+ shapes[aos_coords] = 1
154
+ sparse = not shapes.all()
155
+ shapes = None
156
+
157
+ if sparse:
158
+ self.shapes[path] = shapes
159
+ if ndim:
160
+ # Ensure there is a pseudo-dimension f"{ndim}D" for shapes variable
161
+ self.dimension_size[f"{ndim}D"] = ndim
162
+
163
+ def filter_coordinates(self, path: str) -> str:
164
+ """Filter the coordinates list from NCMetadata to filled variables only."""
165
+ return " ".join(
166
+ coordinate
167
+ for coordinate in self.ncmeta.get_coordinates(path, self.homogeneous_time)
168
+ if coordinate in self.filled_variables
169
+ )
170
+
171
+ def tensorize(self, path, fillvalue):
172
+ """
173
+ Tensorizes the data at the given path with the specified fill value.
174
+
175
+ Args:
176
+ path: The path to the data in the IDS.
177
+ fillvalue: The value to fill the tensor with. Can be of any type,
178
+ including strings.
179
+
180
+ Returns:
181
+ A tensor filled with the data from the specified path.
182
+ """
183
+ dimensions = self.ncmeta.get_dimensions(path, self.homogeneous_time)
184
+ shape = tuple(self.dimension_size[dim] for dim in dimensions)
185
+
186
+ # TODO: depending on the data, tmp_var may be HUGE, we may need a more
187
+ # efficient assignment algorithm for large and/or irregular data
188
+ tmp_var = numpy.full(shape, fillvalue)
189
+ if isinstance(fillvalue, str):
190
+ tmp_var = numpy.asarray(tmp_var, dtype=object)
191
+
192
+ shapes = self.shapes.get(path)
193
+ nodes_dict = self.filled_data[path]
194
+
195
+ # Fill tmp_var
196
+ if shapes is None:
197
+ # Data is not sparse, so we can assign to the aos_coords
198
+ for aos_coords, node in nodes_dict.items():
199
+ tmp_var[aos_coords] = node.value
200
+ else:
201
+ # Data is sparse, so we must select a slice
202
+ for aos_coords, node in nodes_dict.items():
203
+ tmp_var[aos_coords + tuple(map(slice, node.shape))] = node.value
204
+
205
+ return tmp_var
@@ -0,0 +1,98 @@
1
+ from typing import Iterator, List, Optional, Tuple
2
+
3
+ from imas.ids_base import IDSBase
4
+ from imas.ids_data_type import IDSDataType
5
+ from imas.ids_metadata import IDSMetadata
6
+ from imas.ids_struct_array import IDSStructArray
7
+ from imas.ids_structure import IDSStructure
8
+ from imas.ids_toplevel import IDSToplevel
9
+
10
+
11
+ def _split_on_aos(metadata: IDSMetadata):
12
+ """Split paths per IDS."""
13
+ paths = []
14
+ curpath = metadata.name
15
+
16
+ item = metadata
17
+ while item._parent.data_type is not None:
18
+ item = item._parent
19
+ if item.data_type is IDSDataType.STRUCT_ARRAY:
20
+ paths.append(curpath)
21
+ curpath = item.name
22
+ else:
23
+ curpath = f"{item.name}/{curpath}"
24
+ paths.append(curpath)
25
+ return paths[::-1]
26
+
27
+
28
+ IndexedNode = Tuple[Tuple[int, ...], IDSBase]
29
+
30
+
31
+ def indexed_tree_iter(
32
+ ids: IDSToplevel, metadata: Optional[IDSMetadata] = None
33
+ ) -> Iterator[IndexedNode]:
34
+ """Tree iterator that tracks indices of all ancestor array of structures.
35
+
36
+ Args:
37
+ ids: IDS top level element to iterate over
38
+ metadata: Iterate over all nodes inside the IDS at the metadata object.
39
+ If ``None``, all filled items in the IDS are iterated over.
40
+
41
+ Yields:
42
+ (aos_indices, node) for all filled nodes.
43
+
44
+ Example:
45
+ >>> ids = imas.IDSFactory().new("core_profiles")
46
+ >>> ids.profiles_1d.resize(2)
47
+ >>> ids.profiles_1d[0].time = 1.0
48
+ >>> ids.profiles_1d[1].t_i_average = [1.0]
49
+ >>> list(indexed_tree_iter(ids))
50
+ [
51
+ ((), <IDSStructArray (IDS:core_profiles, profiles_1d with 2 items)>),
52
+ ((0,), <IDSFloat0D (IDS:core_profiles, profiles_1d[0]/time, FLT_0D)>),
53
+ ((1,), <IDSNumericArray (IDS:core_profiles, profiles_1d[1]/t_i_average, FLT_1D)>)
54
+ ]
55
+ >>> list(indexed_tree_iter(ids, ids.metadata["profiles_1d/time"]))
56
+ [
57
+ ((0,), <IDSFloat0D (IDS:core_profiles, profiles_1d[0]/time, FLT_0D)>),
58
+ ((1,), <IDSFloat0D (IDS:core_profiles, profiles_1d[1]/time, empty FLT_0D)>)
59
+ ]
60
+ """ # noqa: E501
61
+ if metadata is None:
62
+ # Iterate over all filled nodes in the IDS
63
+ yield from _full_tree_iter(ids, ())
64
+
65
+ else:
66
+ paths = _split_on_aos(metadata)
67
+ if len(paths) == 1:
68
+ yield (), ids[paths[0]]
69
+ else:
70
+ yield from _tree_iter(ids, paths, ())
71
+
72
+
73
+ def _tree_iter(
74
+ structure: IDSStructure, paths: List[str], curindex: Tuple[int, ...]
75
+ ) -> Iterator[IndexedNode]:
76
+ aos_path, *paths = paths
77
+ aos = structure[aos_path]
78
+
79
+ if len(paths) == 1:
80
+ path = paths[0]
81
+ for i, node in enumerate(aos):
82
+ yield curindex + (i,), node[path]
83
+
84
+ else:
85
+ for i, node in enumerate(aos):
86
+ yield from _tree_iter(node, paths, curindex + (i,))
87
+
88
+
89
+ def _full_tree_iter(
90
+ node: IDSStructure, cur_index: Tuple[int, ...]
91
+ ) -> Iterator[IndexedNode]:
92
+ for child in node.iter_nonempty_():
93
+ yield (cur_index, child)
94
+ if isinstance(child, IDSStructArray):
95
+ for i in range(len(child)):
96
+ yield from _full_tree_iter(child[i], cur_index + (i,))
97
+ elif isinstance(child, IDSStructure):
98
+ yield from _full_tree_iter(child, cur_index)
@@ -1,17 +1,18 @@
1
1
  import logging
2
2
  import os
3
- from typing import Iterator, List, Optional, Tuple
3
+ from typing import Optional
4
4
 
5
5
  import netCDF4
6
+ import numpy as np
6
7
 
7
8
  from imas.backends.netcdf import ids2nc
8
9
  from imas.backends.netcdf.nc_metadata import NCMetadata
10
+ from imas.backends.netcdf.iterators import indexed_tree_iter
9
11
  from imas.exception import InvalidNetCDFEntry
10
- from imas.ids_base import IDSBase
12
+ from imas.ids_convert import NBCPathMap
11
13
  from imas.ids_data_type import IDSDataType
12
14
  from imas.ids_defs import IDS_TIME_MODE_HOMOGENEOUS
13
15
  from imas.ids_metadata import IDSMetadata
14
- from imas.ids_structure import IDSStructure
15
16
  from imas.ids_toplevel import IDSToplevel
16
17
 
17
18
  logger = logging.getLogger(__name__)
@@ -24,68 +25,40 @@ def variable_error(var, issue, value, expected=None) -> InvalidNetCDFEntry:
24
25
  )
25
26
 
26
27
 
27
- def split_on_aos(metadata: IDSMetadata):
28
- paths = []
29
- curpath = metadata.name
30
-
31
- item = metadata
32
- while item._parent.data_type is not None:
33
- item = item._parent
34
- if item.data_type is IDSDataType.STRUCT_ARRAY:
35
- paths.append(curpath)
36
- curpath = item.name
37
- else:
38
- curpath = f"{item.name}/{curpath}"
39
- paths.append(curpath)
40
- return paths[::-1]
41
-
42
-
43
- IndexedNode = Tuple[Tuple[int, ...], IDSBase]
44
-
45
-
46
- def tree_iter(structure: IDSStructure, metadata: IDSMetadata) -> Iterator[IndexedNode]:
47
- paths = split_on_aos(metadata)
48
- if len(paths) == 1:
49
- yield (), structure[paths[0]]
50
- else:
51
- yield from _tree_iter(structure, paths, ())
52
-
53
-
54
- def _tree_iter(
55
- structure: IDSStructure, paths: List[str], curindex: Tuple[int, ...]
56
- ) -> Iterator[IndexedNode]:
57
- aos_path, *paths = paths
58
- aos = structure[aos_path]
59
-
60
- if len(paths) == 1:
61
- path = paths[0]
62
- for i, node in enumerate(aos):
63
- yield curindex + (i,), node[path]
64
-
65
- else:
66
- for i, node in enumerate(aos):
67
- yield from _tree_iter(node, paths, curindex + (i,))
68
-
69
-
70
28
  class NC2IDS:
71
29
  """Class responsible for reading an IDS from a NetCDF group."""
72
30
 
73
- def __init__(self, group: netCDF4.Group, ids: IDSToplevel) -> None:
31
+ def __init__(
32
+ self,
33
+ group: netCDF4.Group,
34
+ ids: IDSToplevel,
35
+ ids_metadata: IDSMetadata,
36
+ nbc_map: Optional[NBCPathMap],
37
+ ) -> None:
74
38
  """Initialize NC2IDS converter.
75
39
 
76
40
  Args:
77
41
  group: NetCDF group that stores the IDS data.
78
42
  ids: Corresponding IDS toplevel to store the data in.
43
+ ids_metadata: Metadata corresponding to the DD version that the data is
44
+ stored in.
45
+ nbc_map: Path map for implicit DD conversions.
79
46
  """
80
47
  self.group = group
81
48
  """NetCDF Group that the IDS is stored in."""
82
49
  self.ids = ids
83
50
  """IDS to store the data in."""
51
+ self.ids_metadata = ids_metadata
52
+ """Metadata of the IDS in the DD version that the data is stored in"""
53
+ self.nbc_map = nbc_map
54
+ """Path map for implicit DD conversions."""
84
55
 
85
- self.ncmeta = NCMetadata(ids.metadata)
56
+ self.ncmeta = NCMetadata(ids_metadata)
86
57
  """NetCDF related metadata."""
87
58
  self.variables = list(group.variables)
88
59
  """List of variable names stored in the netCDF group."""
60
+
61
+ self._lazy_map = {}
89
62
  # Don't use masked arrays: they're slow and we'll handle most of the unset
90
63
  # values through the `:shape` arrays
91
64
  self.group.set_auto_mask(False)
@@ -99,7 +72,7 @@ class NC2IDS:
99
72
  "Mandatory variable `ids_properties.homogeneous_time` does not exist."
100
73
  )
101
74
  var = group["ids_properties.homogeneous_time"]
102
- self._validate_variable(var, ids.ids_properties.homogeneous_time.metadata)
75
+ self._validate_variable(var, ids.metadata["ids_properties/homogeneous_time"])
103
76
  if var[()] not in [0, 1, 2]:
104
77
  raise InvalidNetCDFEntry(
105
78
  f"Invalid value for ids_properties.homogeneous_time: {var[()]}. "
@@ -107,23 +80,52 @@ class NC2IDS:
107
80
  )
108
81
  self.homogeneous_time = var[()] == IDS_TIME_MODE_HOMOGENEOUS
109
82
 
110
- def run(self) -> None:
83
+ def run(self, lazy: bool) -> None:
111
84
  """Load the data from the netCDF group into the IDS."""
112
85
  self.variables.sort()
113
86
  self.validate_variables()
87
+ if lazy:
88
+ self.ids._set_lazy_context(LazyContext(self))
114
89
  for var_name in self.variables:
115
90
  if var_name.endswith(":shape"):
116
91
  continue
117
- metadata = self.ids.metadata[var_name]
92
+ metadata = self.ids_metadata[var_name]
118
93
 
119
94
  if metadata.data_type is IDSDataType.STRUCTURE:
120
95
  continue # This only contains DD metadata we already know
121
96
 
97
+ # Handle implicit DD version conversion
98
+ if self.nbc_map is None:
99
+ target_metadata = metadata # no conversion
100
+ elif metadata.path_string in self.nbc_map:
101
+ new_path = self.nbc_map.path[metadata.path_string]
102
+ if new_path is None:
103
+ logging.info(
104
+ "Not loading data for %s: no equivalent data structure exists "
105
+ "in the target Data Dictionary version.",
106
+ metadata.path_string,
107
+ )
108
+ continue
109
+ target_metadata = self.ids.metadata[new_path]
110
+ elif metadata.path_string in self.nbc_map.type_change:
111
+ logging.info(
112
+ "Not loading data for %s: cannot hanlde type changes when "
113
+ "implicitly converting data to the target Data Dictionary version.",
114
+ metadata.path_string,
115
+ )
116
+ continue
117
+ else:
118
+ target_metadata = metadata # no conversion required
119
+
122
120
  var = self.group[var_name]
121
+ if lazy:
122
+ self._lazy_map[target_metadata.path_string] = var
123
+ continue
124
+
123
125
  if metadata.data_type is IDSDataType.STRUCT_ARRAY:
124
126
  if "sparse" in var.ncattrs():
125
127
  shapes = self.group[var_name + ":shape"][()]
126
- for index, node in tree_iter(self.ids, metadata):
128
+ for index, node in indexed_tree_iter(self.ids, target_metadata):
127
129
  node.resize(shapes[index][0])
128
130
 
129
131
  else:
@@ -132,7 +134,7 @@ class NC2IDS:
132
134
  metadata.path_string, self.homogeneous_time
133
135
  )[-1]
134
136
  size = self.group.dimensions[dim].size
135
- for _, node in tree_iter(self.ids, metadata):
137
+ for _, node in indexed_tree_iter(self.ids, target_metadata):
136
138
  node.resize(size)
137
139
 
138
140
  continue
@@ -144,23 +146,30 @@ class NC2IDS:
144
146
  if "sparse" in var.ncattrs():
145
147
  if metadata.ndim:
146
148
  shapes = self.group[var_name + ":shape"][()]
147
- for index, node in tree_iter(self.ids, metadata):
149
+ for index, node in indexed_tree_iter(self.ids, target_metadata):
148
150
  shape = shapes[index]
149
151
  if shape.all():
150
- node.value = data[index + tuple(map(slice, shapes[index]))]
152
+ # NOTE: bypassing IDSPrimitive.value.setter logic
153
+ node._IDSPrimitive__value = data[
154
+ index + tuple(map(slice, shape))
155
+ ]
151
156
  else:
152
- for index, node in tree_iter(self.ids, metadata):
157
+ for index, node in indexed_tree_iter(self.ids, target_metadata):
153
158
  value = data[index]
154
159
  if value != getattr(var, "_FillValue", None):
155
- node.value = data[index]
160
+ # NOTE: bypassing IDSPrimitive.value.setter logic
161
+ node._IDSPrimitive__value = value
156
162
 
157
163
  elif metadata.path_string not in self.ncmeta.aos:
158
164
  # Shortcut for assigning untensorized data
159
- self.ids[metadata.path] = data
165
+ # Note: var[()] can return 0D numpy arrays. Instead of handling this
166
+ # here, we'll let IDSPrimitive.value.setter take care of it:
167
+ self.ids[target_metadata.path].value = data
160
168
 
161
169
  else:
162
- for index, node in tree_iter(self.ids, metadata):
163
- node.value = data[index]
170
+ for index, node in indexed_tree_iter(self.ids, target_metadata):
171
+ # NOTE: bypassing IDSPrimitive.value.setter logic
172
+ node._IDSPrimitive__value = data[index]
164
173
 
165
174
  def validate_variables(self) -> None:
166
175
  """Validate that all variables in the netCDF Group exist and match the DD."""
@@ -194,7 +203,7 @@ class NC2IDS:
194
203
  # Check that the DD defines this variable, and validate its metadata
195
204
  var = self.group[var_name]
196
205
  try:
197
- metadata = self.ids.metadata[var_name]
206
+ metadata = self.ids_metadata[var_name]
198
207
  except KeyError:
199
208
  raise InvalidNetCDFEntry(
200
209
  f"Invalid variable {var_name}: no such variable exists in the "
@@ -300,3 +309,109 @@ class NC2IDS:
300
309
  raise variable_error(
301
310
  shape_var, "dtype", shape_var.dtype, "any integer type"
302
311
  )
312
+
313
+
314
+ class LazyContext:
315
+ def __init__(self, nc2ids, index=()):
316
+ self.nc2ids = nc2ids
317
+ self.index = index
318
+
319
+ def get_child(self, child):
320
+ """
321
+ Retrieves and sets the appropriate context or value for a given
322
+ child node based on its metadata.
323
+
324
+ Args:
325
+ child: The child IDS node which should be lazy loaded.
326
+
327
+ """
328
+ metadata = child.metadata
329
+ path = metadata.path_string
330
+ data_type = metadata.data_type
331
+ nc2ids = self.nc2ids
332
+ var = nc2ids._lazy_map.get(path)
333
+
334
+ if data_type is IDSDataType.STRUCT_ARRAY:
335
+ # Determine size of the aos
336
+ if var is None:
337
+ size = 0
338
+ elif "sparse" in var.ncattrs():
339
+ size = nc2ids.group[var.name + ":shape"][self.index][0]
340
+ else:
341
+ # FIXME: extract dimension name from nc file?
342
+ dim = nc2ids.ncmeta.get_dimensions(
343
+ metadata.path_string, nc2ids.homogeneous_time
344
+ )[-1]
345
+ size = nc2ids.group.dimensions[dim].size
346
+
347
+ child._set_lazy_context(LazyArrayStructContext(nc2ids, self.index, size))
348
+
349
+ elif data_type is IDSDataType.STRUCTURE:
350
+ child._set_lazy_context(self)
351
+
352
+ elif var is not None: # Data elements
353
+ value = None
354
+ if "sparse" in var.ncattrs():
355
+ if metadata.ndim:
356
+ shape_var = nc2ids.group[var.name + ":shape"]
357
+ shape = shape_var[self.index]
358
+ if shape.all():
359
+ value = var[self.index + tuple(map(slice, shape))]
360
+ else:
361
+ value = var[self.index]
362
+ if value == getattr(var, "_FillValue", None):
363
+ value = None # Skip setting
364
+ else:
365
+ value = var[self.index]
366
+
367
+ if value is not None:
368
+ if isinstance(value, np.ndarray):
369
+ # Convert the numpy array to a read-only view
370
+ value = value.view()
371
+ value.flags.writeable = False
372
+ # NOTE: bypassing IDSPrimitive.value.setter logic
373
+ child._IDSPrimitive__value = value
374
+
375
+
376
+ class LazyArrayStructContext(LazyContext):
377
+ """
378
+ LazyArrayStructContext is a subclass of LazyContext that provides a context for
379
+ handling structured arrays in a lazy manner. It is initialized with a NetCDF to
380
+ IDS mapping object, an index, and a size.
381
+ """
382
+
383
+ def __init__(self, nc2ids, index, size):
384
+ """
385
+ Initialize the instance with nc2ids, index, and size.
386
+
387
+ Args:
388
+ nc2ids: The NetCDF to IDS mapping object.
389
+ index: The index within the NetCDF file.
390
+ size: The size of the data to be processed.
391
+ """
392
+ super().__init__(nc2ids, index)
393
+ self.size = size
394
+
395
+ def get_context(self):
396
+ """
397
+ Returns the current context.
398
+
399
+ This method returns the current instance of the class, which is expected
400
+ to have a 'size' attribute as required by IDSStructArray.
401
+
402
+ Returns:
403
+ The current instance of the class.
404
+ """
405
+ return self # IDSStructArray expects to get something with a size attribute
406
+
407
+ def iterate_to_index(self, index: int) -> LazyContext:
408
+ """
409
+ Iterates to a specified index and returns a LazyContext object.
410
+
411
+ Args:
412
+ index (int): The index to iterate to.
413
+
414
+ Returns:
415
+ LazyContext: A LazyContext object initialized with the updated index.
416
+ """
417
+ return LazyContext(self.nc2ids, self.index + (index,))
@@ -47,8 +47,9 @@ def validate_netcdf_file(filename: str) -> None:
47
47
  for ids_name in ids_names:
48
48
  for occurrence in entry.list_all_occurrences(ids_name):
49
49
  group = dataset[f"{ids_name}/{occurrence}"]
50
+ ids = factory.new(ids_name)
50
51
  try:
51
- NC2IDS(group, factory.new(ids_name)).validate_variables()
52
+ NC2IDS(group, ids, ids.metadata, None).validate_variables()
52
53
  except InvalidNetCDFEntry as exc:
53
54
  occ = f":{occurrence}" if occurrence else ""
54
55
  raise InvalidNetCDFEntry(f"Invalid IDS {ids_name}{occ}: {exc}")
imas/ids_convert.py CHANGED
@@ -533,7 +533,8 @@ def _add_provenance_entry(
533
533
  # DD version after IMAS-5304
534
534
  node.reference.resize(len(node.reference) + 1, keep=True)
535
535
  node.reference[-1].name = source_txt
536
- timestamp = datetime.datetime.now(datetime.UTC).isoformat(timespec="seconds")
536
+ utc = getattr(datetime, "UTC", datetime.timezone.utc)
537
+ timestamp = datetime.datetime.now(utc).isoformat(timespec="seconds")
537
538
  node.reference[-1].timestamp = timestamp.replace("+00:00", "Z")
538
539
  else:
539
540
  # DD before IMAS-5304 (between 3.34.0 and 3.41.0)