sdf-xarray 0.9.0__tar.gz → 0.10.0__tar.gz

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.
Files changed (70) hide show
  1. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/PKG-INFO +1 -1
  2. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/animation.md +8 -17
  3. sdf_xarray-0.10.0/docs/known_issues.md +8 -0
  4. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/loading_data.md +32 -17
  5. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/src/sdf_xarray/__init__.py +138 -43
  6. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/src/sdf_xarray/_version.py +3 -3
  7. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/src/sdf_xarray/csdf.pxd +2 -0
  8. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/src/sdf_xarray/download.py +18 -11
  9. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/src/sdf_xarray/sdf_interface.pyx +110 -14
  10. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/tests/test_cython.py +46 -0
  11. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/tests/test_dataset.py +87 -0
  12. sdf_xarray-0.9.0/docs/known_issues.md +0 -15
  13. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/.github/workflows/build_publish.yml +0 -0
  14. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/.github/workflows/lint.yml +0 -0
  15. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/.github/workflows/tests.yml +0 -0
  16. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/.gitignore +0 -0
  17. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/.gitmodules +0 -0
  18. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/.readthedocs.yaml +0 -0
  19. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/CITATION.cff +0 -0
  20. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/CMakeLists.txt +0 -0
  21. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/CONTRIBUTING.md +0 -0
  22. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/LICENCE +0 -0
  23. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/README.md +0 -0
  24. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/.gitignore +0 -0
  25. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/_static/BEAM.png +0 -0
  26. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/_static/PlasmaFAIR.svg +0 -0
  27. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/_static/force_render_dark_xarray_objects.css +0 -0
  28. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/_static/sdf-xarray-logo-dark.svg +0 -0
  29. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/_static/sdf-xarray-logo-light.svg +0 -0
  30. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/_templates/custom-class-template.rst +0 -0
  31. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/_templates/custom-module-template.rst +0 -0
  32. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/api.md +0 -0
  33. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/conf.py +0 -0
  34. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/contributing.md +0 -0
  35. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/epoch_workshop_2026/animating/animate_1D_density.py +0 -0
  36. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/epoch_workshop_2026/animating/animate_1D_poynting_flux.py +0 -0
  37. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/epoch_workshop_2026/animating/animate_2D_dist_fn_multispecies.py +0 -0
  38. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/epoch_workshop_2026/animating/animate_2D_dist_fn_multispecies_alternative.py +0 -0
  39. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/epoch_workshop_2026/animating/animate_2D_poynting_flux.py +0 -0
  40. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/epoch_workshop_2026/animations.md +0 -0
  41. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/epoch_workshop_2026/histograms/plot_hist_ke.py +0 -0
  42. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/epoch_workshop_2026/histograms/plot_hist_ke_probe.py +0 -0
  43. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/epoch_workshop_2026/histograms/plot_hist_px.py +0 -0
  44. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/epoch_workshop_2026/histograms.md +0 -0
  45. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/epoch_workshop_2026/live_demo.ipynb +0 -0
  46. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/epoch_workshop_2026/plots.md +0 -0
  47. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/epoch_workshop_2026/plotting/plot_1D_density.py +0 -0
  48. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/epoch_workshop_2026/plotting/plot_1D_poynting_flux.py +0 -0
  49. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/epoch_workshop_2026/plotting/plot_2D_density.py +0 -0
  50. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/epoch_workshop_2026/plotting/plot_2D_dist_fn_multispecies.py +0 -0
  51. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/epoch_workshop_2026/plotting/plot_2D_poynting_flux.py +0 -0
  52. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/epoch_workshop_2026/plotting/plot_time_temperature.py +0 -0
  53. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/epoch_workshop_2026/plotting/plot_x_px_scatter.py +0 -0
  54. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/epoch_workshop_2026/workshop_details.md +0 -0
  55. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/figures/voxel_plot_high_res.png +0 -0
  56. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/index.md +0 -0
  57. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/installation.md +0 -0
  58. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/make.bat +0 -0
  59. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/plotting.md +0 -0
  60. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/understanding_datasets.md +0 -0
  61. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/unit_conversion.md +0 -0
  62. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/docs/why_sdf_xarray.md +0 -0
  63. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/pyproject.toml +0 -0
  64. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/src/sdf_xarray/dataarray_accessor.py +0 -0
  65. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/src/sdf_xarray/dataset_accessor.py +0 -0
  66. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/src/sdf_xarray/plotting.py +0 -0
  67. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/tests/test_datatree.py +0 -0
  68. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/tests/test_epoch_dataarray_accessor.py +0 -0
  69. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/tests/test_epoch_dataset_accessor.py +0 -0
  70. {sdf_xarray-0.9.0 → sdf_xarray-0.10.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sdf-xarray
3
- Version: 0.9.0
3
+ Version: 0.10.0
4
4
  Summary: Provides a backend for xarray to read SDF files as created by the EPOCH plasma PIC code.
5
5
  Author-Email: Peter Hill <peter.hill@york.ac.uk>, Joel Adams <joel.adams@york.ac.uk>, Shaun Doherty <shaun.doherty@york.ac.uk>, Chris Herdman <chris.herdman@york.ac.uk>, Liam Pattinson <liam.pattinson@york.ac.uk>, Sviatoslav Shekhanov <sviatoslav.shekhanov@york.ac.uk>
6
6
  License-Expression: BSD-3-Clause
@@ -12,7 +12,6 @@ mimic <inv:#xarray.DataArray.plot>.
12
12
 
13
13
  ```{code-cell} ipython3
14
14
  import sdf_xarray as sdfxr
15
- import xarray as xr
16
15
  import matplotlib.pyplot as plt
17
16
  from matplotlib.animation import FuncAnimation
18
17
  from IPython.display import HTML
@@ -139,31 +138,23 @@ EPOCH allows for simulations that have a moving simulation window
139
138
  `move_window` and change the x-axis limits accordingly.
140
139
 
141
140
  ```{warning}
142
- <project:#sdf_xarray.open_mfdataset> does not currently function with moving window data.
143
- You must use <inv:#xarray.open_mfdataset> and specify arguments in the following way.
141
+ Importing some datasets with moving windows can cause vertical banding
142
+ in the <inv:#xarray.Dataset>, which will affect the animation. Each output stores
143
+ its own x grid, and the same position can differ by floating point rounding between
144
+ outputs. Both values are then kept on the combined x axis, so each time step has
145
+ nan values in some columns. Setting `join = "override"` in
146
+ <inv:#xarray.open_mfdataset> does not fix this for moving window data, as it
147
+ relabels every time step with the first output's x grid.
144
148
  ```
145
149
 
146
150
  ```{code-cell} ipython3
147
- ds = xr.open_mfdataset(
148
- "tutorial_dataset_2d_moving_window/*.sdf",
149
- preprocess = sdfxr.SDFPreprocess(),
150
- combine = "nested",
151
- join = "outer",
152
- compat="no_conflicts",
153
- concat_dim="time",
154
- )
151
+ ds = sdfxr.open_mfdataset("tutorial_dataset_2d_moving_window/*.sdf")
155
152
 
156
153
  da = ds["Derived_Number_Density_Beam_Electrons"]
157
154
  anim = da.epoch.animate(move_window = True, fps = 5)
158
155
  anim.show()
159
156
  ```
160
157
 
161
- ```{warning}
162
- Importing some datasets with moving windows can cause vertical banding
163
- in the <inv:#xarray.Dataset>, which will affect the animation. The cause for
164
- this is unknown but can be circumvented by setting `join = "override"`.
165
- ```
166
-
167
158
  ## Customisation
168
159
 
169
160
  The animation can be customised in much the same way as <inv:#xarray.DataArray.plot>,
@@ -0,0 +1,8 @@
1
+ # Known Issues
2
+
3
+ These are the known 'quirks' in `sdf_xarray`:
4
+
5
+ - [Issue #112](https://github.com/epochpic/sdf-xarray/issues/112) Due to the
6
+ way [cylindrical EPOCH](https://github.com/epochpic/cylindrical_epoch) sets up
7
+ grids we can't currently load it's SDF files. If there is enough interest in this
8
+ we will attempt to find a solution otherwise it shall remain low priority.
@@ -85,7 +85,11 @@ is by using the <project:#sdf_xarray.open_mfdataset>.
85
85
  ```{tip}
86
86
  If your simulation includes multiple `output` blocks that specify different variables
87
87
  for output at various time steps, variables not present at a specific step will default
88
- to a nan value. To remove these nan values we suggest using the <inv:#xarray.DataArray.dropna>
88
+ to a nan value. This includes distribution functions and particle data that are only
89
+ output at some time steps. Grids that change between outputs (such as a moving window,
90
+ or a distribution function with an automatic range) are combined onto the union of
91
+ every output's grid points, with nan values outside the part of the grid each output
92
+ covers. To remove these nan values we suggest using the <inv:#xarray.DataArray.dropna>
89
93
  function or following our implmentation in [](#loading-sparse-data).
90
94
  ```
91
95
 
@@ -98,13 +102,20 @@ sdfxr.open_mfdataset("tutorial_dataset_1d/*.sdf")
98
102
  Alternatively, files can be loaded using <inv:#xarray.open_mfdataset>. However, when loading in
99
103
  all the files we have do some processing of the data so that we can correctly align it along
100
104
  the time dimension; This is done via the `preprocess` parameter utilising the
101
- <project:#sdf_xarray.SDFPreprocess> function.
105
+ <project:#sdf_xarray.SDFPreprocess> function. Passing `combine="nested"` and
106
+ `concat_dim="time"` makes sure the files are only joined along time. Otherwise
107
+ <inv:#xarray> treats any coordinate that differs between files (such as a moving
108
+ window's x grid) as another dimension to concatenate along, which can fail or request
109
+ far more memory than needed. Unlike <project:#sdf_xarray.open_mfdataset>, this does
110
+ not handle particle positions that are missing from some files.
102
111
 
103
112
  ```{code-cell} ipython3
104
113
  import xarray as xr
105
114
 
106
115
  xr.open_mfdataset(
107
116
  "tutorial_dataset_1d/*.sdf",
117
+ combine="nested",
118
+ concat_dim="time",
108
119
  join="outer",
109
120
  compat="no_conflicts",
110
121
  preprocess=sdfxr.SDFPreprocess()
@@ -123,6 +134,9 @@ sdfxr.open_mfdatatree("tutorial_dataset_1d/*.sdf")
123
134
 
124
135
  ## Loading sparse data
125
136
 
137
+ By default, <project:#sdf_xarray.open_mfdataset> puts every variable on a single
138
+ time dimension, and a variable missing from an output (including distribution
139
+ functions and particle data) is filled with nan values at that time step.
126
140
  When dealing with sparse data (where different variables are saved at different,
127
141
  non-overlapping time steps) you can optimize memory usage by loading the data with
128
142
  <project:#sdf_xarray.open_mfdataset> using the parameter `separate_times=True`. This
@@ -140,12 +154,17 @@ sdfxr.open_mfdataset("tutorial_dataset_1d/*.sdf", separate_times=True)
140
154
  ## Loading particle data
141
155
 
142
156
  ```{warning}
143
- It is **not recommended** to use <inv:#xarray.open_mfdataset> or
144
- <project:#sdf_xarray.open_mfdataset> to load particle data from multiple
145
- SDF outputs. The number of particles often varies between outputs,
146
- which can lead to inconsistent array shapes that these functions
147
- cannot handle. Instead, consider loading each file individually and
148
- then concatenating them manually.
157
+ <project:#sdf_xarray.open_mfdataset> can load particle data from multiple
158
+ SDF outputs, and outputs that don't include particles are filled with nan
159
+ values. However, every output that does include a species must contain the
160
+ same number of particles of that species. The number of particles often
161
+ varies between outputs (for example with particle injection, losses or probes),
162
+ in which case loading fails with a conflicting dimension sizes error, and you
163
+ should load each file individually instead.
164
+
165
+ The `ID_<species>` dimension is the position in each output's particle array,
166
+ not a particle identity, so the same index at two time steps is not
167
+ necessarily the same particle.
149
168
  ```
150
169
 
151
170
  ```{note}
@@ -157,7 +176,7 @@ Failing to do so will result in dimension name conflicts.
157
176
 
158
177
  By default, particle data isn't kept as it takes up a lot of space.
159
178
  Pass `keep_particles=True` as a keyword argument to
160
- <inv:#xarray.open_dataset> (for single files) or <inv:#xarray.open_mfdataset> (for
179
+ <project:#sdf_xarray.open_dataset> (for single files) or <project:#sdf_xarray.open_mfdataset> (for
161
180
  multiple files).
162
181
 
163
182
  ```{code-cell} ipython3
@@ -168,14 +187,10 @@ sdfxr.open_dataset("tutorial_dataset_1d/0010.sdf", keep_particles=True)
168
187
 
169
188
  ## Loading specific variables
170
189
 
171
- When loading datasets containing several (`>10`) coordinates/dimensions
172
- using <project:#sdf_xarray.open_mfdataset>, <inv:#xarray> may struggle to locate
173
- the necessary RAM to concatenate all of the data (as seen in
174
- [Issue #57](https://github.com/epochpic/sdf-xarray/issues/57)).
175
- In this instance, you can optimize memory usage by loading only the data
176
- you need using the keyword argument `data_vars` and passing one or more
177
- variables. This creates a dataset consisting only of the given variable(s)
178
- and the relevant coordinates/dimensions, significantly reducing memory
190
+ If you only need some of the variables, you can load just those using the
191
+ keyword argument `data_vars` and passing one or more variables to
192
+ <project:#sdf_xarray.open_mfdataset>. This creates a dataset consisting only of
193
+ the given variable(s) and the relevant coordinates/dimensions, reducing memory
179
194
  consumption.
180
195
 
181
196
  ```{code-cell} ipython3
@@ -239,30 +239,42 @@ def combine_datasets(
239
239
  ) -> xr.Dataset:
240
240
  """
241
241
  Combine all datasets using a single time dimension, optionally extract
242
- data from only the listed data_vars
242
+ data from only the listed data_vars.
243
+
244
+ Coordinates with the "point_data" attribute come from SDF point meshes,
245
+ i.e. particle positions such as ``X_Particles_proton``. They have one
246
+ value per particle along an ``ID_<species>`` dimension rather than a
247
+ grid, and SDFPreprocess gives them a time dimension because particles
248
+ move. They are usually only in some files (with ``keep_particles=True``,
249
+ on dumps that output particles). When concatenating along time, xarray
250
+ fills a data variable missing from a file with ``NaN``'s, but raises if a
251
+ coordinate is missing. So each file's point coordinates are temporarily
252
+ turned into data variables and turned back into coordinates after
253
+ combining. Their names are recorded here because particle data variables
254
+ (e.g. ``Particles_Px_proton``) carry "point_data" too, so they can't be
255
+ told apart by the attribute later.
243
256
  """
257
+ point_coords: set[str] = set()
258
+ preprocess = SDFPreprocess(data_vars=data_vars)
244
259
 
245
- if data_vars is not None:
246
- ds = xr.open_mfdataset(
247
- path_glob,
248
- join="outer",
249
- coords="different",
250
- compat="no_conflicts",
251
- combine="nested",
252
- concat_dim="time",
253
- preprocess=SDFPreprocess(data_vars=data_vars),
254
- **kwargs,
255
- )
256
- else:
257
- ds = xr.open_mfdataset(
258
- path_glob,
259
- data_vars="all",
260
- coords="different",
261
- compat="no_conflicts",
262
- join="outer",
263
- preprocess=SDFPreprocess(),
264
- **kwargs,
265
- )
260
+ def _preprocess(ds: xr.Dataset) -> xr.Dataset:
261
+ ds = preprocess(ds)
262
+ names = [k for k, v in ds.coords.items() if v.attrs.get("point_data", False)]
263
+ point_coords.update(names)
264
+ return ds.reset_coords(names)
265
+
266
+ ds = xr.open_mfdataset(
267
+ path_glob,
268
+ join="outer",
269
+ coords="minimal",
270
+ compat="no_conflicts",
271
+ combine="nested",
272
+ concat_dim="time",
273
+ data_vars="all",
274
+ preprocess=_preprocess,
275
+ **kwargs,
276
+ )
277
+ ds = ds.set_coords(sorted(point_coords))
266
278
 
267
279
  ds.attrs["deck"] = _load_deck(ds.attrs["filename"], deck_path)
268
280
 
@@ -626,33 +638,91 @@ def make_time_dims(path_glob):
626
638
 
627
639
 
628
640
  class SDFBackendArray(BackendArray):
629
- """Adapater class required for lazy loading"""
641
+ """Adapter class required for lazy loading.
642
+
643
+ Reads go through :py:meth:`SDFFile.read`, which only reads the range of the
644
+ last axis that is actually requested. SDF arrays are Fortran ordered, so
645
+ that range is one contiguous block on disk (see
646
+ `sdf_xarray.SDFBackendArray.raw_indexing_method`).
647
+
648
+ ``component`` is only used for particle positions. SDF stores the positions
649
+ of a particle species as one point mesh block, holding a 1D array of every
650
+ particle's position for each spatial direction. Each of these directions is
651
+ a component, and becomes its own coordinate, e.g. ``X_Particles_Electron``
652
+ and ``Y_Particles_Electron``. ``component`` is the index of the direction
653
+ this array holds, in the order of the mesh's labels (e.g. 0 for ``X``, 1
654
+ for ``Y``), and ``variable_name`` is then the name of the point mesh. For
655
+ every other variable ``component`` is ``None``.
656
+ """
630
657
 
631
- __slots__ = ("datastore", "dtype", "shape", "variable_name")
658
+ __slots__ = ("component", "datastore", "dtype", "shape", "variable_name")
632
659
 
633
- def __init__(self, variable_name, datastore, shape, dtype):
660
+ def __init__(self, variable_name, datastore, shape, dtype, component=None):
634
661
  self.datastore = datastore
635
662
  self.variable_name = variable_name
636
663
  self.shape = shape
637
664
  self.dtype = dtype
665
+ self.component = component
638
666
 
639
- def get_array(self, needs_lock=True):
640
- with self.datastore.acquire_context(needs_lock) as ds:
641
- return ds.variables[self.variable_name]
642
-
643
- def __getitem__(self, key: indexing.ExplicitIndexer) -> np.typing.ArrayLike:
667
+ def __getitem__(self, indexer: indexing.ExplicitIndexer) -> np.typing.ArrayLike:
644
668
  return indexing.explicit_indexing_adapter(
645
- key,
669
+ indexer,
646
670
  self.shape,
647
- indexing.IndexingSupport.OUTER,
648
- self._raw_indexing_method,
671
+ indexing.IndexingSupport.BASIC,
672
+ self.raw_indexing_method,
649
673
  )
650
674
 
651
- def _raw_indexing_method(self, key: tuple) -> np.typing.ArrayLike:
652
- # thread safe method that access to data on disk
653
- with self.datastore.acquire_context():
654
- original_array = self.get_array(needs_lock=False)
655
- return original_array.data[key]
675
+ def raw_indexing_method(self, indexer: tuple) -> np.typing.ArrayLike:
676
+ """Read the data selected from disk.
677
+
678
+ xarray only calls this once the data is needed (e.g. by ``.values``,
679
+ ``load()``, ``compute()`` or dask), with one int or slice per
680
+ dimension, and expects back the same array numpy would return for
681
+ ``full_array[indexer]``. Any negative ints or steps have already
682
+ been made positive by xarray.
683
+
684
+ SDF stores arrays in Fortran order, where the first index changes
685
+ fastest. A 2D array of shape (nx, ny) is stored as all nx values at
686
+ y=0, then all nx values at y=1, and so on, so for nx=3 and ny=4 each
687
+ element's position in the file is::
688
+
689
+ y=0 y=1 y=2 y=3
690
+ x=0 0 3 6 9
691
+ x=1 1 4 7 10
692
+ x=2 2 5 8 11
693
+
694
+ Each column (one y, every x) is contiguous, and the columns are stored
695
+ one after another. So a range of the *last* index, with every other
696
+ dimension in full, is one contiguous block where ``[:, 1:3]`` is positions
697
+ 3 to 8. A range of any other index is scattered through the file where
698
+ ``[1:3, :]`` is positions 1, 2, 4, 5, 7, 8, 10 and 11. So we only read
699
+ the range of the last dimension that is needed from disk, then index the
700
+ other dimensions in memory.
701
+ """
702
+ *other_indices, last_index = indexer
703
+
704
+ if isinstance(last_index, slice):
705
+ start, stop, step = last_index.indices(self.shape[-1])
706
+ # An empty slice such as 5:1 has stop < start, so read nothing
707
+ stop = max(start, stop)
708
+ data = self.read_last_dim(start, stop)
709
+ # SDF can't skip elements, so a step such as ::2 is applied here
710
+ return data[*other_indices, ::step]
711
+
712
+ data = self.read_last_dim(last_index, last_index + 1)
713
+ # Index with 0, not 0:1, so the dimension is dropped as numpy would
714
+ return data[*other_indices, 0]
715
+
716
+ def read_last_dim(self, start: int, stop: int) -> np.ndarray:
717
+ """Read the range [start, stop) of the last dimension from disk."""
718
+ with self.datastore.acquire_context() as sdf_file:
719
+ if self.component is None:
720
+ variable = sdf_file.variables[self.variable_name]
721
+ return sdf_file.read(variable, start, stop)
722
+
723
+ # Particle positions: one component (e.g. x) of a point mesh
724
+ mesh = sdf_file.grids[self.variable_name]
725
+ return sdf_file.read_mesh_component(mesh, self.component, start, stop)
656
726
 
657
727
 
658
728
  class SDFDataStore(AbstractDataStore):
@@ -765,7 +835,25 @@ class SDFDataStore(AbstractDataStore):
765
835
 
766
836
  base_name = _process_grid_name(value.name, _norm_grid_name)
767
837
 
768
- for label, coord, unit in zip(value.labels, value.data, value.units):
838
+ if value.is_point_data:
839
+ # Particle positions are not dimension coordinates, so they
840
+ # don't need to be in memory to build an index; keep them lazy
841
+ coord_data = [
842
+ indexing.LazilyIndexedArray(
843
+ SDFBackendArray(
844
+ key,
845
+ self,
846
+ shape=value.shape[:1],
847
+ dtype=value.dtype,
848
+ component=component,
849
+ )
850
+ )
851
+ for component in range(len(value.labels))
852
+ ]
853
+ else:
854
+ coord_data = value.data
855
+
856
+ for label, coord, unit in zip(value.labels, coord_data, value.units):
769
857
  full_name = f"{label}_{base_name}"
770
858
  dim_name = (
771
859
  f"ID_{_process_grid_name(key, _grid_species_name)}"
@@ -801,7 +889,14 @@ class SDFDataStore(AbstractDataStore):
801
889
  # scalar, or because it's an array over something
802
890
  # else. We have no more information, so just make up
803
891
  # some (hopefully) unique dimension names
804
- shape = getattr(value.data, "shape", ())
892
+ if isinstance(value, Constant):
893
+ shape = ()
894
+ data = value.data
895
+ else:
896
+ shape = value.shape
897
+ data = indexing.LazilyIndexedArray(
898
+ SDFBackendArray(key, self, shape=shape, dtype=value.dtype)
899
+ )
805
900
  dims = [f"dim_{key}_{n}" for n, _ in enumerate(shape)]
806
901
  base_name = _rename_with_underscore(key)
807
902
 
@@ -811,7 +906,7 @@ class SDFDataStore(AbstractDataStore):
811
906
  if value.units is not None:
812
907
  data_attrs["units"] = value.units
813
908
 
814
- var = Variable(dims, value.data, attrs=data_attrs)
909
+ var = Variable(dims, data, attrs=data_attrs)
815
910
 
816
911
  # Provide preferred_chunks for constants so dask aligns to natural shapes
817
912
  var.encoding["preferred_chunks"] = dict(zip(dims, shape))
@@ -884,14 +979,14 @@ class SDFDataStore(AbstractDataStore):
884
979
  "long_name": long_name,
885
980
  }
886
981
  lazy_data = indexing.LazilyIndexedArray(
887
- SDFBackendArray(key, self, shape=value.shape, dtype=value.data.dtype)
982
+ SDFBackendArray(key, self, shape=value.shape, dtype=value.dtype)
888
983
  )
889
984
  var = Variable(var_coords, lazy_data, data_attrs)
890
985
  # Set preferred chunks to match on-disk layout
891
986
  # For point data (1D): full dimension
892
987
  # For grid data (N-D): individual grid chunk sizes
893
988
  if value.is_point_data:
894
- var.encoding["preferred_chunks"] = {var_coords[0]: len(value.data)}
989
+ var.encoding["preferred_chunks"] = {var_coords[0]: value.shape[0]}
895
990
  else:
896
991
  # Align with on-disk grid structure
897
992
  chunk_dict = {}
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.9.0'
22
- __version_tuple__ = version_tuple = (0, 9, 0)
21
+ __version__ = version = '0.10.0'
22
+ __version_tuple__ = version_tuple = (0, 10, 0)
23
23
 
24
- __commit_id__ = commit_id = 'g65bb865da'
24
+ __commit_id__ = commit_id = 'g878b38c62'
@@ -61,6 +61,7 @@ cdef extern from "sdf.h":
61
61
  int64_t dims[SDF_MAXDIMS]
62
62
  int64_t local_dims[SDF_MAXDIMS]
63
63
  int64_t data_length
64
+ int64_t data_location
64
65
  int32_t ndims, geometry, datatype, blocktype
65
66
  int32_t stagger, datatype_out
66
67
  char const_value[16]
@@ -125,3 +126,4 @@ cdef extern from "sdf_helper.h":
125
126
  cdef extern from "stack_allocator.h":
126
127
  void sdf_stack_destroy(sdf_file_t *h)
127
128
  void sdf_stack_init(sdf_file_t *h)
129
+ void sdf_stack_free_block(sdf_file_t *h, sdf_block_t *b)
@@ -58,18 +58,25 @@ def fetch_dataset(
58
58
  import pooch # noqa: PLC0415
59
59
 
60
60
  logger = pooch.get_logger()
61
- datasets = pooch.create(
62
- path=pooch.os_cache("sdf_datasets"),
63
- base_url="doi:10.5281/zenodo.17618509",
64
- registry=None,
65
- retry_if_failed=10,
66
- )
67
- datasets.load_registry_from_doi()
68
61
 
69
- datasets.fetch(
70
- f"{dataset_name}.zip", processor=pooch.Unzip(extract_dir="."), progressbar=True
71
- )
72
- cache_path = Path(datasets.path) / dataset_name
62
+ cache_dir = Path(pooch.os_cache("sdf_datasets"))
63
+ cache_path = cache_dir / dataset_name
64
+
65
+ # Only download dataset if a local copy is not found
66
+ if not cache_path.is_dir():
67
+ datasets = pooch.create(
68
+ path=cache_dir,
69
+ base_url="doi:10.5281/zenodo.17618509",
70
+ registry=None,
71
+ retry_if_failed=10,
72
+ )
73
+ datasets.load_registry_from_doi()
74
+
75
+ datasets.fetch(
76
+ f"{dataset_name}.zip",
77
+ processor=pooch.Unzip(extract_dir="."),
78
+ progressbar=True,
79
+ )
73
80
 
74
81
  if save_path is not None:
75
82
  save_path = Path(save_path)
@@ -167,6 +167,7 @@ cdef class SDFFile:
167
167
  cdef public dict[str, Mesh] grids
168
168
 
169
169
  def __cinit__(self, filename: str):
170
+ self.filename = filename
170
171
  self._c_sdf_file = csdf.sdf_open(
171
172
  filename.encode("UTF-8"), 0, csdf.SDF_READ, False
172
173
  )
@@ -254,8 +255,14 @@ cdef class SDFFile:
254
255
 
255
256
  block = block.next
256
257
 
257
- cpdef read(self, var: Block):
258
+ cpdef read(self, var: Block, start=None, stop=None):
258
259
  """Read a variable from the file, returning numpy array
260
+
261
+ For variables, ``start`` and ``stop`` select a range of the last index,
262
+ i.e. ``var[..., start:stop]``, so that only that part of the variable is
263
+ read from disk. As SDF arrays are Fortran ordered (the first index
264
+ changes fastest), this range is one contiguous block on disk, so it is
265
+ a single read.
259
266
  """
260
267
 
261
268
  if self._c_sdf_file is NULL:
@@ -276,23 +283,112 @@ cdef class SDFFile:
276
283
  if block is NULL:
277
284
  raise RuntimeError(f"Could not read variable '{var.name}'")
278
285
 
286
+ shape = var.shape
287
+ cdef bint is_section = False
288
+ cdef csdf.int64_t starts[csdf.SDF_MAXDIMS]
289
+ cdef csdf.int64_t ends[csdf.SDF_MAXDIMS]
290
+ if not is_mesh and shape and (start is not None or stop is not None):
291
+ start = 0 if start is None else start
292
+ stop = shape[-1] if stop is None else stop
293
+ if not 0 <= start <= stop <= shape[-1]:
294
+ raise IndexError(
295
+ f"Invalid range [{start}, {stop}) for '{var.name}' "
296
+ f"with shape {shape}"
297
+ )
298
+ if start == stop:
299
+ # Nothing to read, and the library can't read an empty section
300
+ return np.empty((*shape[:-1], 0), dtype=var.dtype)
301
+ is_section = (start, stop) != (0, shape[-1])
302
+
303
+ if is_section and block.blocktype not in (
304
+ csdf.SDF_BLOCKTYPE_PLAIN_VARIABLE, csdf.SDF_BLOCKTYPE_POINT_VARIABLE
305
+ ):
306
+ # Only these readers support array sections (`sdf_read_array`, for
307
+ # example, ignores them), so read everything and slice it here
308
+ return self.read(var)[..., start:stop].copy()
309
+
310
+ if is_section:
311
+ for i in range(block.ndims):
312
+ # The array section is clamped to the local (per-rank) dims,
313
+ # which for point data are only set during the read itself.
314
+ # For serial reads these are always the global dims
315
+ block.local_dims[i] = block.dims[i]
316
+ starts[i] = 0
317
+ ends[i] = block.dims[i]
318
+ starts[block.ndims - 1] = start
319
+ ends[block.ndims - 1] = stop
320
+ csdf.sdf_block_set_array_section(block, block.ndims, starts, ends, NULL)
321
+ shape = (*shape[:-1], stop - start)
322
+
279
323
  self._c_sdf_file.current_block = block
280
- csdf.sdf_helper_read_data(self._c_sdf_file, block)
324
+ try:
325
+ csdf.sdf_helper_read_data(self._c_sdf_file, block)
326
+
327
+ if is_mesh:
328
+ # Meshes store the data for separate dimensions in block.grids
329
+ if not (block.grids is not NULL and block.grids[0] is not NULL):
330
+ raise RuntimeError(f"Could not read variable '{var.name}'")
331
+
332
+ data = []
333
+ for i, dim in enumerate(shape):
334
+ data.append(
335
+ self._make_array((dim,), var.dtype, block.grids[i])
336
+ )
337
+ return tuple(data)
338
+
339
+ # Normal variables
340
+ return self._make_array(shape, var.dtype, block.data)
341
+ finally:
342
+ # The library keeps every block it reads cached until the file is
343
+ # closed, but we've just copied it into numpy, so release it now.
344
+ # Otherwise we hold two copies of everything that's been read
345
+ csdf.sdf_stack_free_block(self._c_sdf_file, block)
346
+ if is_section:
347
+ csdf.sdf_block_set_array_section(block, block.ndims, NULL, NULL, NULL)
348
+
349
+ cpdef read_mesh_component(self, mesh: Mesh, int component, start=None, stop=None):
350
+ """Read one component (e.g. just ``X``) of a mesh, returning numpy array
351
+
352
+ For point meshes, ``start`` and ``stop`` select a range of points so
353
+ that only those are read from disk.
354
+ """
355
+
356
+ if self._c_sdf_file is NULL:
357
+ raise RuntimeError(
358
+ f"Can't read '{mesh.name}', file '{self.filename}' is closed"
359
+ )
281
360
 
282
- if is_mesh:
283
- # Meshes store the data for separate dimensions in block.grids
284
- if not (block.grids is not NULL and block.grids[0] is not NULL):
285
- raise RuntimeError(f"Could not read variable '{var.name}'")
361
+ if not mesh.is_point_data or mesh.parent:
362
+ return self.read(mesh)[component][start:stop]
286
363
 
287
- data = []
288
- for i, dim in enumerate(var.shape):
289
- data.append(
290
- self._make_array((dim,), var.dtype, block.grids[i])
291
- )
292
- return tuple(data)
364
+ cdef csdf.sdf_block_t* block = csdf.sdf_find_block_by_name(
365
+ self._c_sdf_file, mesh.name.encode("utf-8")
366
+ )
293
367
 
294
- # Normal variables
295
- return self._make_array(var.shape, var.dtype, block.data)
368
+ if block is NULL:
369
+ raise RuntimeError(f"Could not read variable '{mesh.name}'")
370
+
371
+ if block.datatype != block.datatype_out:
372
+ # The library is converting the type on read, so let it
373
+ return self.read(mesh)[component][start:stop]
374
+
375
+ # The library always reads every component of a point mesh, and its
376
+ # array sections don't work for them. The components are just stored
377
+ # one after the other though (see `sdf_read_point_mesh`), so read the
378
+ # part we want directly
379
+ npoints = block.dims[0]
380
+ start = 0 if start is None else start
381
+ stop = npoints if stop is None else stop
382
+ if not 0 <= component < block.ndims or not 0 <= start <= stop <= npoints:
383
+ raise IndexError(
384
+ f"Invalid component {component} or range [{start}, {stop}) "
385
+ f"for '{mesh.name}' with {block.ndims} components of {npoints} points"
386
+ )
387
+
388
+ offset = block.data_location + (component * npoints + start) * mesh.dtype.itemsize
389
+ return np.fromfile(
390
+ self.filename, dtype=mesh.dtype, count=stop - start, offset=offset
391
+ )
296
392
 
297
393
  cdef _read_mid_grid(self, mesh: Mesh):
298
394
  """Read a midpoint grid"""
@@ -138,3 +138,49 @@ if __name__ == "__main__":
138
138
  pprint(f.variables["Electric Field/Ex"].data)
139
139
  print("grid")
140
140
  pprint(f.grids["grid"].data)
141
+
142
+
143
+ def test_read_section_matches_full_read():
144
+ with SDFFile(str(TEST_FILES_DIR / "0010.sdf")) as f:
145
+ dropped_key_affixes = ["cpu", "output file", "boundary"]
146
+ variables = [
147
+ v
148
+ for v in f.variables.values()
149
+ if getattr(v, "shape", ())
150
+ and all(key not in v.name.lower() for key in dropped_key_affixes)
151
+ ]
152
+ assert any(v.is_point_data for v in variables)
153
+ for var in variables:
154
+ full = f.read(var)
155
+ size = var.shape[-1]
156
+ ranges = [(0, 0), (0, 1), (1, size), (size // 3, size // 2 + 1)]
157
+ for start, stop in ranges:
158
+ npt.assert_array_equal(
159
+ f.read(var, start, stop), full[..., start:stop], err_msg=var.name
160
+ )
161
+ # Reading a section must not affect later full reads
162
+ npt.assert_array_equal(f.read(var), full, err_msg=var.name)
163
+
164
+
165
+ def test_read_section_out_of_range():
166
+ with SDFFile(str(TEST_FILES_DIR / "0010.sdf")) as f:
167
+ var = f.variables["Electric Field/Ex"]
168
+ with pytest.raises(IndexError):
169
+ f.read(var, 0, var.shape[-1] + 1)
170
+
171
+
172
+ def test_read_mesh_component_matches_full_read():
173
+ with SDFFile(str(TEST_FILES_DIR / "0010.sdf")) as f:
174
+ meshes = [g for g in f.grids.values() if g.is_point_data]
175
+ assert meshes
176
+ for mesh in meshes:
177
+ full = f.read(mesh)
178
+ size = mesh.shape[0]
179
+ for component in range(len(mesh.labels)):
180
+ npt.assert_array_equal(
181
+ f.read_mesh_component(mesh, component), full[component]
182
+ )
183
+ npt.assert_array_equal(
184
+ f.read_mesh_component(mesh, component, 1, size // 2),
185
+ full[component][1 : size // 2],
186
+ )
@@ -7,6 +7,7 @@ import xarray as xr
7
7
 
8
8
  import sdf_xarray as sdfxr
9
9
  from sdf_xarray import (
10
+ SDFFile,
10
11
  SDFPreprocess,
11
12
  _load_deck,
12
13
  _process_latex_name,
@@ -19,6 +20,7 @@ TEST_MISMATCHED_FILES_DIR = download.fetch_dataset("test_mismatched_files")
19
20
  TEST_ARRAYS_DIR = download.fetch_dataset("test_array_no_grids")
20
21
  TEST_3D_DIST_FN = download.fetch_dataset("test_dist_fn")
21
22
  TEST_2D_PARTICLE_DATA = download.fetch_dataset("test_two_probes_2D")
23
+ TEST_2D_MOVING_WINDOW = download.fetch_dataset("test_files_2D_moving_window")
22
24
 
23
25
 
24
26
  # Type hinting support
@@ -206,6 +208,33 @@ def test_multiple_files_multiple_time_dims():
206
208
  assert df["Absorption_Total_Laser_Energy_Injected"].shape == (11,)
207
209
 
208
210
 
211
+ def test_open_mfdataset_varying_grid():
212
+ # A grid that differs between files must not become a concat dimension
213
+ paths = sorted(TEST_2D_MOVING_WINDOW.glob("*.sdf"))
214
+ with sdfxr.open_mfdataset(paths) as df:
215
+ assert df.sizes["time"] == len(paths)
216
+ assert df["Wall_time"].dims == ("time",)
217
+ density = df["Derived_Number_Density_electron"]
218
+ assert density.dims == ("time", "X_Grid_mid", "Y_Grid_mid")
219
+ assert df.indexes["X_Grid_mid"].is_monotonic_increasing
220
+
221
+ for i, path in enumerate(paths):
222
+ with xr.open_dataset(path) as single:
223
+ expected = single["Derived_Number_Density_electron"]
224
+ actual = density.isel(time=i).sel(X_Grid_mid=expected["X_Grid_mid"])
225
+ npt.assert_array_equal(actual, expected)
226
+
227
+
228
+ def test_open_mfdataset_sparse_dist_fn():
229
+ # The first file's dist_fn and its grid are missing from the last file
230
+ with sdfxr.open_mfdataset(TEST_3D_DIST_FN.glob("*.sdf")) as df:
231
+ dist_fn = df["dist_fn_x_px_py_Electron"]
232
+ assert df.sizes["time"] == 3
233
+ assert dist_fn.shape == (3, 16, 20, 20)
234
+ assert dist_fn.isel(time=slice(0, 2)).notnull().all()
235
+ assert dist_fn.isel(time=2).isnull().all()
236
+
237
+
209
238
  def test_resolve_paths_from_string_pattern():
210
239
  pattern = str(TEST_FILES_DIR / "*.sdf")
211
240
  result = resolve_paths(pattern)
@@ -594,6 +623,30 @@ def test_open_mfdataset_data_vars_sparse_time():
594
623
  npt.assert_allclose(time_values, time.values, rtol=1e-6)
595
624
 
596
625
 
626
+ def test_open_mfdataset_data_vars_sparse_particles():
627
+ with sdfxr.open_mfdataset(
628
+ TEST_FILES_DIR.glob("*.sdf"),
629
+ keep_particles=True,
630
+ data_vars=["Particles_Px_proton"],
631
+ ) as df:
632
+ px_protons = df["Particles_Px_proton"]
633
+ assert sorted(px_protons.coords) == sorted(("X_Particles_proton", "time"))
634
+ assert px_protons.shape == (11, 1920)
635
+ has_data = px_protons.notnull().any("ID_proton").values
636
+ assert has_data.tolist() == [False] * 10 + [True]
637
+
638
+
639
+ def test_open_mfdataset_data_vars_sparse_dist_fn():
640
+ with sdfxr.open_mfdataset(
641
+ TEST_3D_DIST_FN.glob("*.sdf"),
642
+ data_vars=["dist_fn_x_px_py_Electron"],
643
+ ) as df:
644
+ dist_fn = df["dist_fn_x_px_py_Electron"]
645
+ assert dist_fn.shape == (3, 16, 20, 20)
646
+ assert dist_fn.isel(time=slice(0, 2)).notnull().all()
647
+ assert dist_fn.isel(time=2).isnull().all()
648
+
649
+
597
650
  def test_open_mfdataset_data_vars_separate_times_single():
598
651
  with sdfxr.open_mfdataset(
599
652
  TEST_FILES_DIR.glob("*.sdf"),
@@ -765,3 +818,37 @@ def test_epydeck_parsing_failing_deck(tmp_path, capsys):
765
818
  _load_deck("", deck)
766
819
  out, _ = capsys.readouterr()
767
820
  assert "error occurred while trying to load the input deck" in out
821
+
822
+
823
+ def test_lazy_particle_coords():
824
+ with sdfxr.open_dataset(TEST_FILES_DIR / "0010.sdf", keep_particles=True) as df:
825
+ x_coord = df["X_Particles_proton"]
826
+ assert not x_coord.variable._in_memory
827
+ with SDFFile(str(TEST_FILES_DIR / "0010.sdf")) as f:
828
+ mesh = next(
829
+ g for g in f.grids.values() if g.name == x_coord.attrs["full_name"]
830
+ )
831
+ expected = f.read(mesh)[mesh.labels.index("X")]
832
+ npt.assert_array_equal(x_coord[2:-3].values, expected[2:-3])
833
+ npt.assert_array_equal(x_coord.values, expected)
834
+
835
+
836
+ @pytest.mark.parametrize(
837
+ "indexers",
838
+ [
839
+ {"dim0": [0, 2, 5], "dim1": [1, 3]},
840
+ {"dim0": [5, 0], "dim2": slice(10, 2, -2)},
841
+ {"dim1": -1, "dim2": [0, -1]},
842
+ ],
843
+ )
844
+ def test_lazy_indexing_matches_eager(indexers):
845
+ path = TEST_3D_DIST_FN / "0000.sdf"
846
+ name = "dist_fn_x_px_py_Electron"
847
+ with xr.open_dataset(path) as eager:
848
+ expected = eager[name].load()
849
+ with xr.open_dataset(path) as lazy:
850
+ dims = lazy[name].dims
851
+ indexers = {dims[int(k[-1])]: v for k, v in indexers.items()}
852
+ npt.assert_array_equal(
853
+ lazy[name].isel(indexers).values, expected.isel(indexers).values
854
+ )
@@ -1,15 +0,0 @@
1
- # Known Issues
2
-
3
- There are a couple of known 'quirks' in `sdf_xarray`:
4
-
5
- - [Issue #57](https://github.com/epochpic/sdf-xarray/issues/57) Loading multiple
6
- SDF files with <project:#sdf_xarray.open_mfdataset> can lead to out-of-memory
7
- errors. The issue is believed to stem from how the underlying <inv:#xarray> library
8
- handles coordinates, causing it to infer an excessively large array shape that
9
- requests far more memory than is needed. Due to the significant architectural
10
- changes required for a fix, the maintainers do not plan to resolve this. The
11
- recommended solution is to load the files individually or in smaller batches.
12
- - [Issue #112](https://github.com/epochpic/sdf-xarray/issues/112) Due to the
13
- way [cylindrical EPOCH](https://github.com/epochpic/cylindrical_epoch) sets up
14
- grids we can't currently load it's SDF files. If there is enough interest in this
15
- we will attempt to find a solution otherwise it shall remain low priority.
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes