osiris-utils 1.1.10a0__py3-none-any.whl → 1.2.0__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.
Files changed (52) hide show
  1. benchmarks/benchmark_hdf5_io.py +46 -0
  2. benchmarks/benchmark_load_all.py +54 -0
  3. docs/source/api/decks.rst +48 -0
  4. docs/source/api/postprocess.rst +66 -2
  5. docs/source/api/sim_diag.rst +1 -1
  6. docs/source/api/utilities.rst +1 -1
  7. docs/source/conf.py +2 -1
  8. docs/source/examples/example_Derivatives.md +78 -0
  9. docs/source/examples/example_FFT.md +152 -0
  10. docs/source/examples/example_InputDeck.md +148 -0
  11. docs/source/examples/example_Simulation_Diagnostic.md +213 -0
  12. docs/source/examples/quick_start.md +51 -0
  13. docs/source/examples.rst +14 -0
  14. docs/source/index.rst +8 -0
  15. examples/edited-deck.1d +1 -1
  16. examples/example_Derivatives.ipynb +24 -36
  17. examples/example_FFT.ipynb +44 -23
  18. examples/example_InputDeck.ipynb +24 -277
  19. examples/example_Simulation_Diagnostic.ipynb +27 -17
  20. examples/quick_start.ipynb +17 -1
  21. osiris_utils/__init__.py +10 -6
  22. osiris_utils/cli/__init__.py +6 -0
  23. osiris_utils/cli/__main__.py +85 -0
  24. osiris_utils/cli/export.py +199 -0
  25. osiris_utils/cli/info.py +156 -0
  26. osiris_utils/cli/plot.py +189 -0
  27. osiris_utils/cli/validate.py +247 -0
  28. osiris_utils/data/__init__.py +15 -0
  29. osiris_utils/data/data.py +41 -171
  30. osiris_utils/data/diagnostic.py +285 -274
  31. osiris_utils/data/simulation.py +20 -13
  32. osiris_utils/decks/__init__.py +4 -0
  33. osiris_utils/decks/decks.py +83 -8
  34. osiris_utils/decks/species.py +12 -9
  35. osiris_utils/postprocessing/__init__.py +28 -0
  36. osiris_utils/postprocessing/derivative.py +317 -106
  37. osiris_utils/postprocessing/fft.py +135 -24
  38. osiris_utils/postprocessing/field_centering.py +28 -14
  39. osiris_utils/postprocessing/heatflux_correction.py +39 -18
  40. osiris_utils/postprocessing/mft.py +10 -2
  41. osiris_utils/postprocessing/postprocess.py +8 -5
  42. osiris_utils/postprocessing/pressure_correction.py +29 -17
  43. osiris_utils/utils.py +26 -17
  44. osiris_utils/vis/__init__.py +3 -0
  45. osiris_utils/vis/plot3d.py +148 -0
  46. {osiris_utils-1.1.10a0.dist-info → osiris_utils-1.2.0.dist-info}/METADATA +55 -7
  47. {osiris_utils-1.1.10a0.dist-info → osiris_utils-1.2.0.dist-info}/RECORD +51 -34
  48. {osiris_utils-1.1.10a0.dist-info → osiris_utils-1.2.0.dist-info}/WHEEL +1 -1
  49. osiris_utils-1.2.0.dist-info/entry_points.txt +2 -0
  50. {osiris_utils-1.1.10a0.dist-info → osiris_utils-1.2.0.dist-info}/top_level.txt +1 -0
  51. osiris_utils/postprocessing/mft_for_gridfile.py +0 -55
  52. {osiris_utils-1.1.10a0.dist-info → osiris_utils-1.2.0.dist-info}/licenses/LICENSE.txt +0 -0
@@ -1,33 +1,35 @@
1
1
  from __future__ import annotations
2
2
 
3
- """
4
- The utilities on data.py are cool but not useful when you want to work with whole data of a simulation instead
5
- of just a single file. This is what this file is for - deal with ''folders'' of data.
6
-
7
- Took some inspiration from Diogo and Madox's work.
8
-
9
- This would be awsome to compute time derivatives.
10
- """
11
-
12
3
  import glob
13
4
  import logging
14
5
  import operator
15
6
  import os
16
7
  import warnings
17
- from typing import Any, Callable, Iterator, Optional, Tuple, Union
8
+ from collections.abc import Callable, Iterator
9
+ from typing import Any, Literal
18
10
 
19
11
  import h5py
20
- import matplotlib.pyplot as plt
21
12
  import numpy as np
22
13
  import tqdm
23
14
 
15
+ from ..decks.decks import InputDeckIO
16
+ from ..decks.species import Species
24
17
  from .data import OsirisGridFile
25
18
 
26
19
  logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)8s │ %(message)s")
27
20
  logger = logging.getLogger(__name__)
28
21
 
22
+ """
23
+ The utilities on data.py are cool but not useful when you want to work with whole data of a simulation instead
24
+ of just a single file. This is what this file is for - deal with ''folders'' of data.
25
+
26
+ Took some inspiration from Diogo and Madox's work.
27
+
28
+ This would be awsome to compute time derivatives.
29
+ """
30
+
29
31
  OSIRIS_DENSITY = ["n"]
30
- OSIRIS_SPECIE_REPORTS = ["charge", "q1", "q2", "q3", "j1", "j2", "j3"]
32
+ OSIRIS_SPECIE_REPORTS = ["ene", "charge", "q1", "q2", "q3", "j1", "j2", "j3"]
31
33
  OSIRIS_SPECIE_REP_UDIST = [
32
34
  "vfl1",
33
35
  "vfl2",
@@ -35,6 +37,9 @@ OSIRIS_SPECIE_REP_UDIST = [
35
37
  "ufl1",
36
38
  "ufl2",
37
39
  "ufl3",
40
+ "P00",
41
+ "P01",
42
+ "P02",
38
43
  "P11",
39
44
  "P12",
40
45
  "P13",
@@ -103,6 +108,8 @@ _ATTRS_TO_CLONE = [
103
108
  "_quantity",
104
109
  ]
105
110
 
111
+ __all__ = ["Diagnostic", "which_quantities"]
112
+
106
113
 
107
114
  def which_quantities():
108
115
  print("Available quantities:")
@@ -110,8 +117,8 @@ def which_quantities():
110
117
 
111
118
 
112
119
  class Diagnostic:
113
- """
114
- Class to handle diagnostics. This is the "base" class of the code. Diagnostics can be loaded from OSIRIS output files, but are also created when performing operations with other diagnostics.
120
+ """Class to handle diagnostics. This is the "base" class of the code. Diagnostics can be loaded from OSIRIS output files,
121
+ but are also created when performing operations with other diagnostics.
115
122
  Post-processed quantities are also considered diagnostics. This way, we can perform operations with them as well.
116
123
 
117
124
  Parameters
@@ -120,6 +127,10 @@ class Diagnostic:
120
127
  The species to handle the diagnostics.
121
128
  simulation_folder : str
122
129
  The path to the simulation folder. This is the path to the folder where the input deck is located.
130
+ input_deck : str or dict, optional
131
+ The input deck to load the diagnostic attributes. If None, the attributes are loaded from the files.
132
+ If a string is provided, it is assumed to be the path to the input deck file.
133
+ If a dict is provided, it is assumed to be the parsed input deck.
123
134
 
124
135
  Attributes
125
136
  ----------
@@ -136,13 +147,17 @@ class Diagnostic:
136
147
  grid : np.ndarray
137
148
  The grid boundaries.
138
149
  axis : dict
139
- The axis information. Each key is a direction and the value is a dictionary with the keys "name", "long_name", "units" and "plot_label".
150
+ The axis information. Each key is a direction and the value is a dictionary with the keys "name",
151
+ "long_name", "units" and "plot_label".
140
152
  units : str
141
- The units of the diagnostic. This info may not be available for all diagnostics, ie, diagnostics resulting from operations and postprocessing.
153
+ The units of the diagnostic. This info may not be available for all diagnostics, ie,
154
+ diagnostics resulting from operations and postprocessing.
142
155
  name : str
143
- The name of the diagnostic. This info may not be available for all diagnostics, ie, diagnostics resulting from operations and postprocessing.
156
+ The name of the diagnostic. This info may not be available for all diagnostics, ie,
157
+ diagnostics resulting from operations and postprocessing.
144
158
  label : str
145
- The label of the diagnostic. This info may not be available for all diagnostics, ie, diagnostics resulting from operations and postprocessing.
159
+ The label of the diagnostic. This info may not be available for all diagnostics, ie,
160
+ diagnostics resulting from operations and postprocessing.
146
161
  dim : int
147
162
  The dimension of the diagnostic.
148
163
  ndump : int
@@ -189,22 +204,27 @@ class Diagnostic:
189
204
 
190
205
  """
191
206
 
192
- def __init__(self, simulation_folder: Optional[str] = None, species: Any = None, input_deck: Optional[str | None] = None) -> None:
207
+ def __init__(
208
+ self,
209
+ simulation_folder: str | None = None,
210
+ species: Species = None,
211
+ input_deck: InputDeckIO | None = None,
212
+ ) -> None:
193
213
  self._species = species if species else None
194
214
 
195
- self._dx: Optional[Union[float, np.ndarray]] = None # grid spacing in each direction
196
- self._nx: Optional[Union[int, np.ndarray]] = None # number of grid points in each direction
197
- self._x: Optional[np.ndarray] = None # grid points
198
- self._dt: Optional[float] = None # time step
199
- self._grid: Optional[np.ndarray] = None # grid boundaries
200
- self._axis: Optional[Any] = None # axis information
201
- self._units: Optional[str] = None # units of the diagnostic
202
- self._name: Optional[str] = None
203
- self._label: Optional[str] = None
204
- self._dim: Optional[int] = None
205
- self._ndump: Optional[int] = None
206
- self._maxiter: Optional[int] = None
207
- self._tunits: Optional[str] = None # time units
215
+ self._dx: float | np.ndarray | None = None # grid spacing in each direction
216
+ self._nx: int | np.ndarray | None = None # number of grid points in each direction
217
+ self._x: np.ndarray | None = None # grid points
218
+ self._dt: float | None = None # time step
219
+ self._grid: np.ndarray | None = None # grid boundaries
220
+ self._axis: Any | None = None # axis information
221
+ self._units: str | None = None # units of the diagnostic
222
+ self._name: str | None = None
223
+ self._label: str | None = None
224
+ self._dim: int | None = None
225
+ self._ndump: int | None = None
226
+ self._maxiter: int | None = None
227
+ self._tunits: str | None = None # time units
208
228
 
209
229
  if simulation_folder:
210
230
  self._simulation_folder = simulation_folder
@@ -219,7 +239,7 @@ class Diagnostic:
219
239
  self._input_deck = None
220
240
 
221
241
  self._all_loaded: bool = False # if the data is already loaded into memory
222
- self._quantity: Optional[str] = None
242
+ self._quantity: str | None = None
223
243
 
224
244
  #########################################
225
245
  #
@@ -228,13 +248,13 @@ class Diagnostic:
228
248
  #########################################
229
249
 
230
250
  def get_quantity(self, quantity: str) -> None:
231
- """
232
- Get the data for a given quantity.
251
+ """Get the data for a given quantity.
233
252
 
234
253
  Parameters
235
254
  ----------
236
255
  quantity : str
237
256
  The quantity to get the data.
257
+
238
258
  """
239
259
  self._quantity = quantity
240
260
 
@@ -260,11 +280,18 @@ class Diagnostic:
260
280
  self._get_density(self._species.name, "charge")
261
281
  else:
262
282
  raise ValueError(
263
- f"Invalid quantity {self._quantity}. Or it's not implemented yet (this may happen for phase space quantities)."
283
+ f"Invalid quantity {self._quantity}. Or it's not implemented yet (this may happen for phase space quantities).",
264
284
  )
265
285
 
266
286
  def _scan_files(self, pattern: str) -> None:
267
- """Populate _file_list and related attributes from a glob pattern."""
287
+ """Populate _file_list and related attributes from a glob pattern.
288
+
289
+ Parameters
290
+ ----------
291
+ pattern : str
292
+ The glob pattern to search for HDF5 files.
293
+
294
+ """
268
295
  self._file_list = sorted(glob.glob(pattern))
269
296
  if not self._file_list:
270
297
  raise FileNotFoundError(f"No HDF5 files match {pattern}")
@@ -272,6 +299,16 @@ class Diagnostic:
272
299
  self._maxiter = len(self._file_list)
273
300
 
274
301
  def _get_moment(self, species: str, moment: str) -> None:
302
+ """Get the moment data for a given species and moment.
303
+
304
+ Parameters
305
+ ----------
306
+ species : str
307
+ The species to get the moment data.
308
+ moment : str
309
+ The moment to get the data.
310
+
311
+ """
275
312
  if self._simulation_folder is None:
276
313
  raise ValueError("Simulation folder not set. If you're using CustomDiagnostic, this method is not available.")
277
314
  self._path = f"{self._simulation_folder}/MS/UDIST/{species}/{moment}/"
@@ -279,6 +316,14 @@ class Diagnostic:
279
316
  self._load_attributes(self._file_template, self._input_deck)
280
317
 
281
318
  def _get_field(self, field: str) -> None:
319
+ """Get the field data for a given field.
320
+
321
+ Parameters
322
+ ----------
323
+ field : str
324
+ The field to get the data.
325
+
326
+ """
282
327
  if self._simulation_folder is None:
283
328
  raise ValueError("Simulation folder not set. If you're using CustomDiagnostic, this method is not available.")
284
329
  self._path = f"{self._simulation_folder}/MS/FLD/{field}/"
@@ -286,6 +331,16 @@ class Diagnostic:
286
331
  self._load_attributes(self._file_template, self._input_deck)
287
332
 
288
333
  def _get_density(self, species: str, quantity: str) -> None:
334
+ """Get the density data for a given species and quantity.
335
+
336
+ Parameters
337
+ ----------
338
+ species : str
339
+ The species to get the density data.
340
+ quantity : str
341
+ The quantity to get the data.
342
+
343
+ """
289
344
  if self._simulation_folder is None:
290
345
  raise ValueError("Simulation folder not set. If you're using CustomDiagnostic, this method is not available.")
291
346
  self._path = f"{self._simulation_folder}/MS/DENSITY/{species}/{quantity}/"
@@ -293,13 +348,35 @@ class Diagnostic:
293
348
  self._load_attributes(self._file_template, self._input_deck)
294
349
 
295
350
  def _get_phase_space(self, species: str, type: str) -> None:
351
+ """Get the phase space data for a given species and type.
352
+
353
+ Parameters
354
+ ----------
355
+ species : str
356
+ The species to get the phase space data.
357
+ type : str
358
+ The type of phase space to get the data.
359
+
360
+ """
296
361
  if self._simulation_folder is None:
297
362
  raise ValueError("Simulation folder not set. If you're using CustomDiagnostic, this method is not available.")
298
363
  self._path = f"{self._simulation_folder}/MS/PHA/{type}/{species}/"
299
364
  self._scan_files(os.path.join(self._path, "*.h5"))
300
365
  self._load_attributes(self._file_template, self._input_deck)
301
366
 
302
- def _load_attributes(self, file_template: str, input_deck: Optional[dict]) -> None: # this will be replaced by reading the input deck
367
+ def _load_attributes(self, file_template: str, input_deck: dict | None) -> None: # this will be replaced by reading the input deck
368
+ """Load diagnostic attributes from the first available file or input deck.
369
+
370
+ Parameters
371
+ ----------
372
+ file_template : str
373
+ The file template to load the attributes from.
374
+ This is the path to the file without the iteration number and extension.
375
+ (e.g., /path/to/diagnostic/000001.h5 -> /path/to/diagnostic/)
376
+ input_deck : dict, optional
377
+ The input deck to load the diagnostic attributes. If None, the attributes are loaded from the files.
378
+
379
+ """
303
380
  # This can go wrong! NDUMP
304
381
  # if input_deck is not None:
305
382
  # self._dt = float(input_deck["time_step"][0]["dt"])
@@ -339,9 +416,9 @@ class Diagnostic:
339
416
  break
340
417
 
341
418
  if not found_file:
342
- warnings.warn(f"No valid data files found in {self._path} to read metadata from.")
419
+ warnings.warn(f"No valid data files found in {self._path} to read metadata from.", stacklevel=2)
343
420
  except Exception as e:
344
- warnings.warn(f"Error loading diagnostic attributes: {str(e)}. Please verify it there's any file in the folder.")
421
+ warnings.warn(f"Error loading diagnostic attributes: {e!s}. Please verify it there's any file in the folder.", stacklevel=2)
345
422
 
346
423
  ##########################################
347
424
  #
@@ -349,26 +426,53 @@ class Diagnostic:
349
426
  #
350
427
  ##########################################
351
428
 
352
- def _data_generator(self, index: int) -> None:
429
+ def _data_generator(self, index: int, data_slice: tuple | None = None) -> Iterator[np.ndarray]:
430
+ """Data generator for a given index or slice.
431
+
432
+ Parameters
433
+ ----------
434
+ index : int
435
+ The index of the file to load.
436
+ data_slice : tuple, optional
437
+ The slice to apply to the data. This is a tuple of slices, one for each dimension.
438
+
439
+ Returns
440
+ -------
441
+ Iterator[np.ndarray]
442
+ An iterator that yields the data for the given index or slice.
443
+
444
+ """
353
445
  if self._simulation_folder is None:
354
446
  raise ValueError("Simulation folder not set.")
355
447
  if self._file_list is None:
356
448
  raise RuntimeError("File list not initialized. Call get_quantity() first.")
357
449
  try:
358
450
  file = self._file_list[index]
359
- except IndexError:
360
- raise RuntimeError(f"File index {index} out of range (max {self._maxiter - 1}).")
361
- data_object = OsirisGridFile(file)
451
+ except IndexError as err:
452
+ raise RuntimeError(
453
+ f"File index {index} out of range (max {self._maxiter - 1}).",
454
+ ) from err
455
+ # Pass data_slice to OsirisGridFile - HDF5 will efficiently read only the requested slice from disk
456
+ data_object = OsirisGridFile(file, data_slice=data_slice)
362
457
  yield (data_object.data if self._quantity not in OSIRIS_DENSITY else np.sign(self._species.rqm) * data_object.data)
363
458
 
364
- def load_all(self) -> np.ndarray:
365
- """
366
- Load all data into memory (all iterations), in a pre-allocated array.
459
+ def load_all(self, n_workers: int | None = None, use_parallel: bool | None = None) -> np.ndarray:
460
+ """Load all data into memory (all iterations), in a pre-allocated array.
461
+
462
+ Parameters
463
+ ----------
464
+ n_workers : int, optional
465
+ Number of parallel workers for loading data. If None, uses CPU count.
466
+ Only used if use_parallel=True.
467
+ use_parallel : bool, optional
468
+ If True, force parallel loading. If False, force sequential.
469
+ If None (default), automatically choose based on data size.
367
470
 
368
471
  Returns
369
472
  -------
370
473
  data : np.ndarray
371
474
  The data for all iterations. Also stored in self._data.
475
+
372
476
  """
373
477
  if getattr(self, "_all_loaded", False) and self._data is not None:
374
478
  logger.debug("Data already loaded into memory.")
@@ -381,27 +485,58 @@ class Diagnostic:
381
485
  try:
382
486
  first = self[0]
383
487
  except Exception as e:
384
- raise RuntimeError(f"Failed to load first timestep: {e}")
488
+ raise RuntimeError("Failed to load first timestep") from e
385
489
  slice_shape = first.shape
386
490
  dtype = first.dtype
387
491
 
388
492
  data = np.empty((size, *slice_shape), dtype=dtype)
389
493
  data[0] = first
390
494
 
391
- for i in tqdm.trange(1, size, desc="Loading data"):
392
- try:
393
- data[i] = self[i]
394
- except Exception as e:
395
- raise RuntimeError(f"Error loading timestep {i}: {e}")
495
+ # Auto-detect whether to use parallel loading
496
+ if use_parallel is None:
497
+ # Use parallel for large datasets: >10 timesteps AND >1MB per file
498
+ bytes_per_timestep = first.nbytes
499
+ use_parallel = (size > 10) and (bytes_per_timestep > 1_000_000)
500
+
501
+ # Parallel loading for significant performance improvement
502
+ if use_parallel and size > 1:
503
+ import concurrent.futures
504
+ import os
505
+
506
+ if n_workers is None:
507
+ n_workers = min(os.cpu_count() or 4, size - 1)
508
+
509
+ def load_single(i):
510
+ """Helper to load a single timestep"""
511
+ try:
512
+ return i, self[i]
513
+ except Exception as e:
514
+ raise RuntimeError(f"Error loading timestep {i}") from e
515
+
516
+ with concurrent.futures.ThreadPoolExecutor(max_workers=n_workers) as executor:
517
+ # Submit all loading tasks
518
+ futures = {executor.submit(load_single, i): i for i in range(1, size)}
519
+
520
+ # Collect results with progress bar
521
+ with tqdm.tqdm(total=size - 1, desc="Loading data (parallel)") as pbar:
522
+ for future in concurrent.futures.as_completed(futures):
523
+ i, result = future.result()
524
+ data[i] = result
525
+ pbar.update(1)
526
+ else:
527
+ # Sequential loading (fallback or for small files)
528
+ for i in tqdm.trange(1, size, desc="Loading data"):
529
+ try:
530
+ data[i] = self[i]
531
+ except Exception as e:
532
+ raise RuntimeError(f"Error loading timestep {i}") from e
396
533
 
397
534
  self._data = data
398
535
  self._all_loaded = True
399
536
  return self._data
400
537
 
401
538
  def unload(self) -> None:
402
- """
403
- Unload data from memory. This is useful to free memory when the data is not needed anymore.
404
- """
539
+ """Unload data from memory. This is useful to free memory when the data is not needed anymore."""
405
540
  logger.info("Unloading data from memory.")
406
541
  if self._all_loaded is False:
407
542
  logger.warning("Data is not loaded.")
@@ -419,20 +554,22 @@ class Diagnostic:
419
554
  """Return the number of timesteps available."""
420
555
  return getattr(self, "_maxiter", 0)
421
556
 
422
- def __getitem__(self, index: int) -> np.ndarray:
423
- """
424
- Retrieve timestep data.
557
+ def __getitem__(self, index: int | slice | tuple) -> np.ndarray:
558
+ """Retrieve timestep data with optional spatial slicing.
425
559
 
426
560
  Parameters
427
561
  ----------
428
- index : int or slice
429
- - If int, may be negative (Python-style).
430
- - If slice, supports start:stop:step. Zero-length slices return an empty array of shape (0, ...).
562
+ index : int, slice, or tuple
563
+ - If int, loads full spatial data for that timestep.
564
+ - If slice, supports start:stop:step for time. Zero-length slices return an empty array of shape (0, ...).
565
+ - If tuple, first element is time index/slice, remaining elements are spatial slices for each dimension.
566
+ Example: diag[5, :, 100:200] loads timestep 5 with spatial slicing applied.
567
+ **Efficient**: HDF5 reads only the requested spatial slice from disk, not the full array.
431
568
 
432
569
  Returns
433
570
  -------
434
571
  np.ndarray
435
- Array for that timestep (or stacked array for a slice).
572
+ Array for that timestep (or stacked array for a slice), optionally spatially sliced.
436
573
 
437
574
  Raises
438
575
  ------
@@ -440,33 +577,59 @@ class Diagnostic:
440
577
  If the index is out of range or no data generator is available.
441
578
  RuntimeError
442
579
  If loading a specific timestep fails.
580
+
581
+ Examples
582
+ --------
583
+ >>> data = diag[5] # Load full spatial data for timestep 5
584
+ >>> data = diag[5, :, 100:200] # Load timestep 5 with spatial slice (only reads slice from disk!)
585
+ >>> data = diag[0:10] # Load timesteps 0-9 (full spatial domain)
586
+ >>> data = diag[0:10, :, 50:] # Load timesteps 0-9 with spatial slice
587
+
443
588
  """
589
+ # Parse tuple indexing: (time_index, spatial_slice1, spatial_slice2, ...)
590
+ data_slice = None
591
+ if isinstance(index, tuple):
592
+ if len(index) == 0:
593
+ raise IndexError("Empty tuple index not supported")
594
+ time_index = index[0]
595
+ # Remaining indices are for spatial dimensions
596
+ if len(index) > 1:
597
+ data_slice = index[1:]
598
+ else:
599
+ time_index = index
600
+
444
601
  # Quick path: all data already in memory
445
602
  if getattr(self, "_all_loaded", False) and self._data is not None:
446
- return self._data[index]
603
+ if data_slice is not None:
604
+ # Apply spatial slicing to loaded data
605
+ return self._data[(time_index,) + data_slice]
606
+ return self._data[time_index]
447
607
 
448
608
  # Data generator must exist
449
609
  data_gen = getattr(self, "_data_generator", None)
450
610
  if not callable(data_gen):
451
- raise IndexError(f"No data available for indexing; you did something wrong!")
611
+ raise IndexError("No data available for indexing; you did something wrong!")
452
612
 
453
613
  # Handle int indices (including negatives)
454
- if isinstance(index, int):
455
- if index < 0:
456
- index += self._maxiter
457
- if not (0 <= index < self._maxiter):
458
- raise IndexError(f"Index {index} out of range (0..{self._maxiter - 1})")
614
+ if isinstance(time_index, int):
615
+ if time_index < 0:
616
+ time_index += self._maxiter
617
+ if not (0 <= time_index < self._maxiter):
618
+ raise IndexError(f"Index {time_index} out of range (0..{self._maxiter - 1})")
619
+
620
+ # Load data immediately with optional spatial slicing
621
+ # HDF5 will read only the requested slice from disk (efficient!)
459
622
  try:
460
- gen = data_gen(index)
623
+ gen = data_gen(time_index, data_slice=data_slice)
461
624
  return next(gen)
462
625
  except Exception as e:
463
- raise RuntimeError(f"Error loading data at index {index}: {e}")
626
+ raise RuntimeError(f"Error loading data at index {time_index}") from e
464
627
 
465
628
  # Handle slice indices
466
- if isinstance(index, slice):
467
- start = index.start or 0
468
- stop = index.stop if index.stop is not None else self._maxiter
469
- step = index.step or 1
629
+ if isinstance(time_index, slice):
630
+ start = time_index.start or 0
631
+ stop = time_index.stop if time_index.stop is not None else self._maxiter
632
+ step = time_index.step or 1
470
633
  # Normalize negatives
471
634
  if start < 0:
472
635
  start += self._maxiter
@@ -481,7 +644,7 @@ class Diagnostic:
481
644
  if not indices:
482
645
  # Determine single-step shape by peeking at index 0 (if possible)
483
646
  try:
484
- dummy = next(data_gen(0))
647
+ dummy = next(data_gen(0, data_slice=data_slice))
485
648
  empty_shape = (0,) + dummy.shape
486
649
  return np.empty(empty_shape, dtype=dummy.dtype)
487
650
  except Exception:
@@ -491,46 +654,16 @@ class Diagnostic:
491
654
  data_list = []
492
655
  for i in indices:
493
656
  try:
494
- data_list.append(next(data_gen(i)))
657
+ data_list.append(next(data_gen(i, data_slice=data_slice)))
495
658
  except Exception as e:
496
- raise RuntimeError(f"Error loading slice at index {i}: {e}")
497
- return np.stack(data_list)
498
-
499
- # Unsupported index type
500
- raise IndexError(f"Invalid index type {type(index)}; must be int or slice")
501
-
502
- def __iter__(self) -> Iterator[np.ndarray]:
503
- # If this is a file-based diagnostic
504
- if self._simulation_folder is not None:
505
- for i in range(len(sorted(glob.glob(f"{self._path}/*.h5")))):
506
- yield next(self._data_generator(i))
507
-
508
- # If this is a derived diagnostic and data is already loaded
509
- elif self._all_loaded and self._data is not None:
510
- for i in range(self._data.shape[0]):
511
- yield self._data[i]
512
-
513
- # If this is a derived diagnostic with custom generator but no loaded data
514
- elif hasattr(self, "_data_generator") and callable(self._data_generator):
515
- # Determine how many iterations to go through
516
- max_iter = self._maxiter
517
- if max_iter is None:
518
- if hasattr(self, "_diag") and hasattr(self._diag, "_maxiter"):
519
- max_iter = self._diag._maxiter
520
- else:
521
- max_iter = 100 # Default if we can't determine
522
- logger.warning(f"Could not determine iteration count for iteration, using {max_iter}.")
523
-
524
- for i in range(max_iter):
525
- yield next(self._data_generator(i))
659
+ raise RuntimeError(f"Error loading timestep {i}") from e
660
+ return np.stack(data_list, axis=0)
526
661
 
527
662
  # If we don't know how to handle this
528
- else:
529
- raise ValueError("Cannot iterate over this diagnostic. No data loaded and no generator available.")
663
+ raise ValueError("Cannot index this diagnostic. No data loaded and no generator available.")
530
664
 
531
665
  def _clone_meta(self) -> Diagnostic:
532
- """
533
- Create a new Diagnostic instance that carries over metadata only.
666
+ """Create a new Diagnostic instance that carries over metadata only.
534
667
  No data is copied, and no constructor edits are required because we
535
668
  assign attributes dynamically.
536
669
  """
@@ -544,9 +677,8 @@ class Diagnostic:
544
677
  clone._file_list = self._file_list
545
678
  return clone
546
679
 
547
- def _binary_op(self, other: Union["Diagnostic", int, float, np.ndarray], op_func: Callable) -> Diagnostic:
548
- """
549
- Universal helper for `self (op) other`.
680
+ def _binary_op(self, other: Diagnostic | float | np.ndarray, op_func: Callable) -> Diagnostic:
681
+ """Universal helper for `self (op) other`.
550
682
  - If both operands are fully loaded, does eager numpy arithmetic.
551
683
  - Otherwise builds a lazy generator that applies op_func on each timestep.
552
684
  """
@@ -583,9 +715,8 @@ class Diagnostic:
583
715
  seq1 = gen1(idx)
584
716
  if callable(gen2):
585
717
  seq2 = gen2(idx)
586
- return (op_func(a, b) for a, b in zip(seq1, seq2))
587
- else:
588
- return (op_func(a, gen2) for a in seq1)
718
+ return (op_func(a, b) for a, b in zip(seq1, seq2, strict=False))
719
+ return (op_func(a, gen2) for a in seq1)
589
720
 
590
721
  result._data_generator = _make_gen
591
722
  result._all_loaded = False
@@ -593,38 +724,37 @@ class Diagnostic:
593
724
 
594
725
  # Now define each operator in one line:
595
726
 
596
- def __add__(self, other: Union["Diagnostic", int, float, np.ndarray]) -> Diagnostic:
727
+ def __add__(self, other: Diagnostic | float | np.ndarray) -> Diagnostic:
597
728
  return self._binary_op(other, operator.add)
598
729
 
599
- def __radd__(self, other: Union["Diagnostic", int, float, np.ndarray]) -> Diagnostic:
730
+ def __radd__(self, other: Diagnostic | float | np.ndarray) -> Diagnostic:
600
731
  return self + other
601
732
 
602
- def __sub__(self, other: Union["Diagnostic", int, float, np.ndarray]) -> Diagnostic:
733
+ def __sub__(self, other: Diagnostic | float | np.ndarray) -> Diagnostic:
603
734
  return self._binary_op(other, operator.sub)
604
735
 
605
- def __rsub__(self, other: Union["Diagnostic", int, float, np.ndarray]) -> Diagnostic:
736
+ def __rsub__(self, other: Diagnostic | float | np.ndarray) -> Diagnostic:
606
737
  # swap args for reversed subtraction
607
738
  return self._binary_op(other, lambda x, y: operator.sub(y, x))
608
739
 
609
- def __mul__(self, other: Union["Diagnostic", int, float, np.ndarray]) -> Diagnostic:
740
+ def __mul__(self, other: Diagnostic | float | np.ndarray) -> Diagnostic:
610
741
  return self._binary_op(other, operator.mul)
611
742
 
612
- def __rmul__(self, other: Union["Diagnostic", int, float, np.ndarray]) -> Diagnostic:
743
+ def __rmul__(self, other: Diagnostic | float | np.ndarray) -> Diagnostic:
613
744
  return self * other
614
745
 
615
- def __truediv__(self, other: Union["Diagnostic", int, float, np.ndarray]) -> Diagnostic:
746
+ def __truediv__(self, other: Diagnostic | float | np.ndarray) -> Diagnostic:
616
747
  return self._binary_op(other, operator.truediv)
617
748
 
618
- def __rtruediv__(self, other: Union["Diagnostic", int, float, np.ndarray]) -> Diagnostic:
749
+ def __rtruediv__(self, other: Diagnostic | float | np.ndarray) -> Diagnostic:
619
750
  return self._binary_op(other, lambda x, y: operator.truediv(y, x))
620
751
 
621
752
  def __neg__(self) -> Diagnostic:
622
753
  # unary minus as multiplication by -1
623
754
  return self._binary_op(-1, operator.mul)
624
755
 
625
- def __pow__(self, other: Union["Diagnostic", int, float, np.ndarray]) -> Diagnostic:
626
- """
627
- Power operation. Raises the diagnostic data to the power of `other`.
756
+ def __pow__(self, other: Diagnostic | float | np.ndarray) -> Diagnostic:
757
+ """Power operation. Raises the diagnostic data to the power of `other`.
628
758
  If `other` is a Diagnostic, it raises each timestep's data to the corresponding timestep's power.
629
759
  If `other` is a scalar or ndarray, it raises all data to that power.
630
760
  """
@@ -632,14 +762,13 @@ class Diagnostic:
632
762
 
633
763
  def to_h5(
634
764
  self,
635
- savename: Optional[str] = None,
636
- index: Optional[Union[int, List[int]]] = None,
765
+ savename: str | None = None,
766
+ index: int | list[int] | None = None,
637
767
  all: bool = False,
638
768
  verbose: bool = False,
639
- path: Optional[str] = None,
769
+ path: str | None = None,
640
770
  ) -> None:
641
- """
642
- Save the diagnostic data to HDF5 files.
771
+ """Save the diagnostic data to HDF5 files.
643
772
 
644
773
  Parameters
645
774
  ----------
@@ -653,6 +782,7 @@ class Diagnostic:
653
782
  If True, print messages about the saving process.
654
783
  path : str, optional
655
784
  The path to save the HDF5 files. If None, uses the default save path (in simulation folder).
785
+
656
786
  """
657
787
  if path is None:
658
788
  path = self._simulation_folder
@@ -724,15 +854,13 @@ class Diagnostic:
724
854
  axis_dataset.attrs.create("NAME", [np.bytes_(axis_shortnames[i].encode())])
725
855
  axis_dataset.attrs.create("UNITS", [np.bytes_(axis_units[i].encode())])
726
856
  axis_dataset.attrs.create("LONG_NAME", [np.bytes_(axis_longnames[i].encode())])
727
- axis_dataset.attrs.create("TYPE", [np.bytes_("linear".encode())])
857
+ axis_dataset.attrs.create("TYPE", [np.bytes_(b"linear")])
728
858
 
729
859
  if verbose:
730
860
  logger.info(f"File created: {filename}")
731
861
 
732
- logger.info(
733
- f"The savename of the diagnostic is {savename}. Files will be saves as {savename}-000001.h5, {savename}-000002.h5, etc."
734
- )
735
-
862
+ logger.info(f"The savename of the diagnostic is {savename}.")
863
+ logger.info(f"Files will be saved as {savename}-000001.h5, {savename}-000002.h5, etc.")
736
864
  logger.info("If you desire a different name, please set it with the 'name' method (setter).")
737
865
 
738
866
  if self._name is None:
@@ -764,138 +892,18 @@ class Diagnostic:
764
892
  scale_type: Literal["zero_centered", "pos", "neg", "default"] = "default",
765
893
  boundaries: np.ndarray = None,
766
894
  ):
767
- """
768
- *****************************************************************************************************
769
- THIS SHOULD BE REMOVED FROM THE BASE CLASS AND MOVED TO A SEPARATED CLASS DESIGNATED FOR THIS PURPOSE
770
- *****************************************************************************************************
895
+ """**DEPRECATED**: Use `osiris_utils.vis.plot_3d` instead.
771
896
 
772
897
  Plots a 3D scatter plot of the diagnostic data (grid data).
773
-
774
- Parameters
775
- ----------
776
- idx : int
777
- Index of the data to plot.
778
- scale_type : Literal["zero_centered", "pos", "neg", "default"], optional
779
- Type of scaling for the colormap:
780
- - "zero_centered": Center colormap around zero.
781
- - "pos": Colormap for positive values.
782
- - "neg": Colormap for negative values.
783
- - "default": Standard colormap.
784
- boundaries : np.ndarray, optional
785
- Boundaries to plot part of the data. (3,2) If None, uses the default grid boundaries.
786
-
787
- Returns
788
- -------
789
- fig : matplotlib.figure.Figure
790
- The figure object containing the plot.
791
- ax : matplotlib.axes._subplots.Axes3DSubplot
792
- The 3D axes object of the plot.
793
-
794
- Example
795
- -------
796
- sim = ou.Simulation("electrons", "path/to/simulation")
797
- fig, ax = sim["b3"].plot_3d(55, scale_type="zero_centered", boundaries= [[0, 40], [0, 40], [0, 20]])
798
- plt.show()
799
898
  """
800
-
801
- if self._dim != 3:
802
- raise ValueError("This method is only available for 3D diagnostics.")
803
-
804
- if boundaries is None:
805
- boundaries = self._grid
806
-
807
- if not isinstance(boundaries, np.ndarray):
808
- try:
809
- boundaries = np.array(boundaries)
810
- except Exception:
811
- boundaries = self._grid
812
- warnings.warn("boundaries cannot be accessed as a numpy array with shape (3, 2), using default instead")
813
-
814
- if boundaries.shape != (3, 2):
815
- warnings.warn("boundaries should have shape (3, 2), using default instead")
816
- boundaries = self._grid
817
-
818
- # Load data
819
- if self._all_loaded:
820
- data = self._data[idx]
821
- else:
822
- data = self[idx]
823
-
824
- X, Y, Z = np.meshgrid(self._x[0], self._x[1], self._x[2], indexing="ij")
825
-
826
- # Flatten arrays for scatter plot
827
- (
828
- X_flat,
829
- Y_flat,
830
- Z_flat,
831
- ) = (
832
- X.ravel(),
833
- Y.ravel(),
834
- Z.ravel(),
835
- )
836
- data_flat = data.ravel()
837
-
838
- # Apply filter: Keep only chosen points
839
- mask = (
840
- (X_flat > boundaries[0][0])
841
- & (X_flat < boundaries[0][1])
842
- & (Y_flat > boundaries[1][0])
843
- & (Y_flat < boundaries[1][1])
844
- & (Z_flat > boundaries[2][0])
845
- & (Z_flat < boundaries[2][1])
846
- )
847
- X_cut, Y_cut, Z_cut, data_cut = (
848
- X_flat[mask],
849
- Y_flat[mask],
850
- Z_flat[mask],
851
- data_flat[mask],
899
+ _msg = (
900
+ "Diagnostic.plot_3d is deprecated and will be removed in a future version. "
901
+ "Please use osiris_utils.vis.plot_3d(diagnostic, idx, ...) instead."
852
902
  )
903
+ warnings.warn(_msg, DeprecationWarning, stacklevel=2)
904
+ from ..vis.plot3d import plot_3d
853
905
 
854
- if scale_type == "zero_centered":
855
- # Center colormap around zero
856
- cmap = "seismic"
857
- vmax = np.max(np.abs(data_flat)) # Find max absolute value
858
- vmin = -vmax
859
- elif scale_type == "pos":
860
- cmap = "plasma"
861
- vmax = np.max(data_flat)
862
- vmin = 0
863
-
864
- elif scale_type == "neg":
865
- cmap = "plasma"
866
- vmax = 0
867
- vmin = np.min(data_flat)
868
- else:
869
- cmap = "plasma"
870
- vmax = np.max(data_flat)
871
- vmin = np.min(data_flat)
872
-
873
- norm = plt.Normalize(vmin=vmin, vmax=vmax)
874
-
875
- # Plot
876
- fig = plt.figure(figsize=(10, 7))
877
- ax = fig.add_subplot(111, projection="3d")
878
-
879
- # Scatter plot with seismic colormap
880
- sc = ax.scatter(X_cut, Y_cut, Z_cut, c=data_cut, cmap=cmap, norm=norm, alpha=1)
881
-
882
- # Set limits to maintain full background
883
- ax.set_xlim(*self._grid[0])
884
- ax.set_ylim(*self._grid[1])
885
- ax.set_zlim(*self._grid[2])
886
-
887
- # Colorbar
888
- cbar = plt.colorbar(sc, ax=ax, shrink=0.6)
889
-
890
- # Labels
891
- # TODO try to use a latex label instaead of _name
892
- cbar.set_label(r"${}$".format(self._name) + r"$\ [{}]$".format(self._units))
893
- ax.set_title(r"$t={:.2f}$".format(self.time(idx)[0]) + r"$\ [{}]$".format(self.time(idx)[1]))
894
- ax.set_xlabel(r"${}$".format(self.axis[0]["long_name"]) + r"$\ [{}]$".format(self.axis[0]["units"]))
895
- ax.set_ylabel(r"${}$".format(self.axis[1]["long_name"]) + r"$\ [{}]$".format(self.axis[1]["units"]))
896
- ax.set_zlabel(r"${}$".format(self.axis[2]["long_name"]) + r"$\ [{}]$".format(self.axis[2]["units"]))
897
-
898
- return fig, ax
906
+ return plot_3d(self, idx, scale_type, boundaries)
899
907
 
900
908
  def __str__(self):
901
909
  """String representation of the diagnostic."""
@@ -903,10 +911,15 @@ class Diagnostic:
903
911
 
904
912
  def __repr__(self):
905
913
  """Detailed string representation of the diagnostic."""
906
- return (
907
- f"Diagnostic(species={self._species}, name={self._name}, quantity={self._quantity}, "
908
- f"dim={self._dim}, maxiter={self._maxiter}, all_loaded={self._all_loaded})"
909
- )
914
+ parts = [
915
+ f"species={self._species}",
916
+ f"name={self._name}",
917
+ f"quantity={self._quantity}",
918
+ f"dim={self._dim}",
919
+ f"maxiter={self._maxiter}",
920
+ f"all_loaded={self._all_loaded}",
921
+ ]
922
+ return f"Diagnostic({', '.join(parts)})"
910
923
 
911
924
  # Getters
912
925
  @property
@@ -996,9 +1009,7 @@ class Diagnostic:
996
1009
  return [index * self._dt * self._ndump, self._tunits]
997
1010
 
998
1011
  def attributes_to_save(self, index: int = 0) -> None:
999
- """
1000
- Prints the attributes of the diagnostic.
1001
- """
1012
+ """Prints the attributes of the diagnostic."""
1002
1013
  logger.info(
1003
1014
  f"dt: {self._dt}\n"
1004
1015
  f"dim: {self._dim}\n"
@@ -1008,7 +1019,7 @@ class Diagnostic:
1008
1019
  f"name: {self._name}\n"
1009
1020
  f"type: {self._type}\n"
1010
1021
  f"label: {self._label}\n"
1011
- f"units: {self._units}"
1022
+ f"units: {self._units}",
1012
1023
  )
1013
1024
 
1014
1025
  @dx.setter