1r"""Lazy HDF readers for PSI MAS and POT3D magnetohydrodynamic model output.
2
3This module provides a unified, unit-aware interface for reading three-dimensional
4MHD model output from Predictive Science Inc.'s MAS and POT3D solvers. Both HDF4
5(``.hdf``) and HDF5 (``.h5``) files are supported through a common API.
6
7Class hierarchy
8---------------
9The implementation uses a mixin-based hierarchy that separates file format concerns
10from physical model concerns:
11
12.. code-block:: text
13
14 _HdfInterface (ABC) ← public interface contract
15 └── _HdfData (_HdfInterface, ABC) ← shared MAS/POT3D logic
16 ├── H5MasData (_H5DataMixin, _HdfData) ← HDF5 MAS output
17 ├── H4MasData (_H4DataMixin, _HdfData) ← HDF4 MAS output
18 ├── H5Pot3dData(_H5DataMixin, _HdfData) ← HDF5 POT3D output
19 └── H4Pot3dData(_H4DataMixin, _HdfData) ← HDF4 POT3D output
20
21 _HdfInterface (ABC)
22 └── _HdfScale (_HdfInterface, ABC) ← coordinate axis arrays
23 ├── H5Scale ← HDF5 dimension scale
24 └── H4Scale ← HDF4 SDS dimension
25
26Format mixins inject all file-I/O and raw-array logic:
27
28 _H5DataMixin ← h5py-backed I/O (open/close/data property)
29 _H4DataMixin ← pyhdf-backed I/O (open/close/data property)
30
31Lazy-loading pattern
32--------------------
33All concrete data classes open the HDF file and read metadata at instantiation, but
34do **not** load the array into memory until :meth:`_HdfData.read` is called. This
35makes it cheap to inspect many files:
36
37>>> from pathlib import Path
38>>> from psi_io.mhd_io import PsiData # doctest: +SKIP
39>>> reader = PsiData('br001001.h5', model='mas') # file opened, metadata parsed
40>>> reader.quantity # 'br' — from filename / file attrs
41>>> data, r, t, p = reader.read() # array loaded here
42
43Axis ordering convention
44------------------------
45PSI HDF files are written by Fortran code in column-major order, so when h5py reads
46them into numpy (row-major), the physical ``(r, θ, φ)`` coordinate ordering is
47reversed to ``(φ, θ, r)`` in the numpy array. The reader hides this inversion:
48
49- :attr:`_HdfData.shape` reports the numpy storage shape ``(Nφ, Nθ, Nr)``.
50- :meth:`_HdfInterface.__getitem__` accepts indices in the physical ``(r, θ, φ)``
51 user order and internally reverses them to match numpy storage.
52
53Public factory
54--------------
55:func:`PsiData` is the recommended entry point. It inspects the file extension and
56the ``model`` keyword to select the correct concrete class:
57
58>>> reader = PsiData('br001001.h5', model='mas') # doctest: +SKIP
59>>> reader = PsiData('br001001.hdf', model='pot3d') # doctest: +SKIP
60
61See Also
62--------
63psi_io._props : Physical property metadata (units, mesh stagger) for each quantity.
64psi_io._units : Physical normalization constants and custom astropy units.
65psi_io._mesh : Staggered-grid utilities used during mesh conversion on read.
66"""
67
68from __future__ import annotations
69
70import re
71from abc import abstractmethod, ABC
72from collections import namedtuple
73from itertools import repeat
74from pathlib import Path
75from types import MappingProxyType
76from typing import Optional, Literal, ClassVar
77import numpy as np
78import h5py as h5
79import astropy.units as u
80
81try:
82 import pyhdf.SD as h4
83except ImportError:
84 h4 = None
85
86from psi_io._mesh import (Mesh, MeshCodeType,
87 _normalize_mesh_code, remesh_arr,
88 )
89from psi_io._props import (Props,
90 MasQuantities,
91 Pot3dQuantities,
92 PsiScales,
93 _MAS_QUANTITY_PROPS_MAPPING,
94 _POT3D_QUANTITY_PROPS_MAPPING,
95 _PSI_SCALE_PROPS_MAPPING)
96from psi_io._units import decompose_mas_units
97from psi_io.psi_io import (PathLike,
98 PSI_DATA_ID,
99 SDC_TYPE_CONVERSIONS,
100 _except_no_pyhdf,)
101
102HDF_EXT_MAPPING = {'h5': '.h5', 'h4': '.hdf',}
103"""Mapping from HDF version string to file extension.
104
105Used by :class:`_HdfData.__init__` to validate that the supplied file has an
106extension consistent with the concrete class's format mixin.
107
108``'h5'`` → ``'.h5'`` (HDF5 files, read via h5py)
109``'h4'`` → ``'.hdf'`` (HDF4 files, read via pyhdf)
110"""
111
112_DATA_SLOTS = ('_fileref', '_filepath', '_datalabel', '_quantity', '_sequence', '_unit', '_mesh', '_scales')
113"""Slot names shared by all concrete :class:`_HdfData` subclasses.
114
115Stored as a module-level tuple so it can be referenced in ``__slots__`` declarations
116of both the abstract base and the concrete classes without repetition.
117"""
118
119ModelType = Literal['mas', 'pot3d', 'scale']
120"""Literal type alias for the three recognized PSI model types.
121
122``'mas'``
123 MAS (Magnetohydrodynamic Algorithm outside a Sphere) plasma model output.
124``'pot3d'``
125 POT3D potential-field source-surface (PFSS) magnetic field output.
126``'scale'``
127 Coordinate scale arrays (``r``, ``θ``, ``φ``) embedded in MAS/POT3D HDF files.
128 Used internally; callers should not pass ``'scale'`` to :func:`PsiData`.
129"""
130
131HdfVersionType = Literal['h4', 'h5']
132"""Literal type alias for the two supported HDF file format versions.
133
134``'h5'`` — HDF5, accessed via h5py.
135``'h4'`` — HDF4, accessed via pyhdf (optional dependency).
136"""
137
138_CODE_UNIT_ALIASES = {'native', 'code', 'model'}
139"""Set of strings that request code-unit output from :meth:`_HdfInterface.read`.
140
141When the ``units`` argument to ``read()`` is one of these strings, the data are
142returned in MAS code units (dimensionless ratios) without any physical conversion.
143"""
144
145_REAL_UNIT_ALIASES = {'real', 'phys', 'physical'}
146"""Set of strings that request decomposed CGS output from :meth:`_HdfInterface.read`.
147
148When the ``units`` argument to ``read()`` is one of these strings, the data are
149converted to physical CGS units via :func:`~psi_io._units.decompose_mas_units`.
150"""
151
152
153METADATA_SCHEMA = dict.fromkeys(['quantity', 'sequence', 'unit', 'mesh'])
154"""Template dictionary defining the four metadata fields required by every reader.
155
156Keys: ``'quantity'``, ``'sequence'``, ``'unit'``, ``'mesh'``. Values are all
157``None`` in the template. :meth:`_HdfData._parse_properties` merges caller-supplied
158keyword arguments, file-level HDF attributes, and filename-parsed defaults to produce
159a fully populated copy of this schema.
160"""
161
162MATCH_QUANTITIES = '|'.join(re.escape(q) for q in sorted(_MAS_QUANTITY_PROPS_MAPPING.keys(), key=len, reverse=True))
163"""Regex alternation string matching any valid MAS quantity name (case-insensitive).
164
165Quantities are sorted longest-first to avoid partial matches (e.g. ``'heat'`` must be
166tried before ``'h'``). Used in :data:`FILEPATH_SCHEMA` and
167:func:`extract_quantity_from_filepath`.
168"""
169
170FILEPATH_SCHEMA = rf'^({MATCH_QUANTITIES})(\d{{3}}(?:\d{{3}})?)$'
171"""Regex pattern for the strict MAS filename schema ``<quantity><sequence>``.
172
173The stem (filename without extension) must consist of exactly one recognized MAS
174quantity name followed by a 3- or 6-digit decimal sequence number.
175
176Groups:
177
1781. Quantity name (e.g. ``'br'``, ``'heat'``).
1792. Sequence number (e.g. ``'001'``, ``'001001'``).
180
181Used by :func:`parse_mas_filename_schema`; see also :func:`extract_quantity_from_filepath`
182for a lenient variant that does not require the sequence suffix.
183"""
184
185
[docs]
186def get_mas_quantity_properties(variable: MasQuantities) -> Props:
187 """Return the :class:`~psi_io._props.Props` descriptor for a MAS quantity.
188
189 Parameters
190 ----------
191 variable : MasQuantities
192 Case-insensitive MAS quantity name. Valid values: ``'vr'``, ``'vt'``,
193 ``'vp'``, ``'br'``, ``'bt'``, ``'bp'``, ``'jr'``, ``'jt'``, ``'jp'``,
194 ``'t'``, ``'te'``, ``'tp'``, ``'rho'``, ``'p'``, ``'ep'``, ``'em'``,
195 ``'zp'``, ``'zm'``, ``'heat'``.
196
197 Returns
198 -------
199 out : Props
200 Immutable descriptor carrying the quantity name, description, astropy unit,
201 and mesh stagger code.
202
203 Raises
204 ------
205 ValueError
206 If *variable* is not a recognized MAS quantity.
207
208 Examples
209 --------
210 >>> from psi_io.mhd_io import get_mas_quantity_properties
211 >>> props = get_mas_quantity_properties('br')
212 >>> props.desc
213 'Magnetic Field (Radial Component)'
214 >>> get_mas_quantity_properties('BR').name # case-insensitive
215 'br'
216 """
217 try:
218 return _MAS_QUANTITY_PROPS_MAPPING[variable.lower()]
219 except KeyError:
220 raise ValueError(f"Invalid variable '{variable}'. "
221 f"Valid options are: {', '.join(_MAS_QUANTITY_PROPS_MAPPING.keys())}") from None
222
223
[docs]
224def get_pot3d_quantity_properties(variable: Pot3dQuantities) -> Props:
225 """Return the :class:`~psi_io._props.Props` descriptor for a POT3D quantity.
226
227 Parameters
228 ----------
229 variable : Pot3dQuantities
230 Case-insensitive POT3D quantity name. Valid values: ``'br'``, ``'bt'``,
231 ``'bp'``.
232
233 Returns
234 -------
235 out : Props
236 Immutable descriptor for the requested POT3D magnetic field component.
237
238 Raises
239 ------
240 ValueError
241 If *variable* is not a recognized POT3D quantity.
242
243 Examples
244 --------
245 >>> from psi_io.mhd_io import get_pot3d_quantity_properties
246 >>> get_pot3d_quantity_properties('bp').name
247 'bp'
248 """
249 try:
250 return _POT3D_QUANTITY_PROPS_MAPPING[variable.lower()]
251 except KeyError:
252 raise ValueError(f"Invalid variable '{variable}'. "
253 f"Valid options are: {', '.join(_POT3D_QUANTITY_PROPS_MAPPING.keys())}") from None
254
255
[docs]
256def get_psi_scale_properties(variable: PsiScales) -> Props:
257 """Return the :class:`~psi_io._props.Props` descriptor for a PSI coordinate scale.
258
259 Parameters
260 ----------
261 variable : PsiScales
262 Coordinate label. The first character is used for lookup, so ``'r'``,
263 ``'radius'``, ``'t'``, ``'theta'``, ``'p'``, and ``'phi'`` are all accepted.
264
265 Returns
266 -------
267 out : Props
268 Descriptor for the requested coordinate axis, carrying the appropriate
269 astropy unit (``PSI_rsun`` for ``'r'``, ``PSI_angle`` for ``'t'`` and
270 ``'p'``).
271
272 Raises
273 ------
274 ValueError
275 If the first character of *variable* is not ``'r'``, ``'t'``, or ``'p'``.
276
277 Examples
278 --------
279 >>> from psi_io.mhd_io import get_psi_scale_properties
280 >>> get_psi_scale_properties('r').desc
281 'Radial Scale (Solar Radii)'
282 >>> get_psi_scale_properties('theta').name # uses first character only
283 't'
284 """
285 try:
286 return _PSI_SCALE_PROPS_MAPPING[variable.lower()[0]]
287 except KeyError:
288 raise ValueError(f"Invalid variable '{variable}'. "
289 f"Valid options are: {', '.join(_PSI_SCALE_PROPS_MAPPING.keys())}") from None
290
291
327
328
361
362
363def parse_mas_filename_schema(ifile: Path):
364 """Parse a MAS HDF filename that follows the strict ``<quantity><sequence>`` schema.
365
366 The filename stem must consist of exactly one recognized MAS quantity name
367 followed immediately by a 3- or 6-digit sequence number, with no other characters.
368 The match is case-insensitive.
369
370 Parameters
371 ----------
372 ifile : Path
373 File path to parse. The stem is matched against :data:`FILEPATH_SCHEMA`.
374
375 Returns
376 -------
377 quantity : str
378 Lower-case quantity name (e.g. ``'br'``).
379 sequence : int
380 Integer sequence number (e.g. ``1001``).
381
382 Raises
383 ------
384 ValueError
385 If the filename stem does not match the expected schema.
386
387 Examples
388 --------
389 >>> from pathlib import Path
390 >>> from psi_io.mhd_io import parse_mas_filename_schema
391 >>> parse_mas_filename_schema(Path('br001001.h5'))
392 ('br', 1001)
393 >>> parse_mas_filename_schema(Path('heat001.hdf'))
394 ('heat', 1)
395 >>> parse_mas_filename_schema(Path('notvalid.h5'))
396 Traceback (most recent call last):
397 ...
398 ValueError: Filename 'notvalid.h5' does not match expected MAS filename schema: ...
399 """
400 matches = re.match(FILEPATH_SCHEMA, ifile.stem, re.IGNORECASE)
401 if not matches:
402 raise ValueError(f"Filename '{ifile.name}' does not match expected MAS filename schema: "
403 f"'<quantity><sequence>'. Valid quantities are: {', '.join(_MAS_QUANTITY_PROPS_MAPPING.keys())}. "
404 f"Sequence should be a 3 or 6 digit number.")
405 quantity, sequence = matches.groups()
406 return quantity, int(sequence)
407
408_PROP_MAPPING = {'mas': get_mas_quantity_properties, 'pot3d': get_pot3d_quantity_properties, 'scale': get_psi_scale_properties,}
409"""Internal dispatch table from model type string to its property-lookup function.
410
411Keys are :data:`ModelType` literals; values are the corresponding ``get_*_properties``
412callables. Used by :class:`_HdfData._parse_properties` and the ``description`` and
413``native_properties`` properties of :class:`_HdfInterface`.
414"""
415
416Scales = namedtuple("Scales", ['r', 't', 'p'])
417"""Named tuple holding the three coordinate scale readers for a data object.
418
419Fields
420------
421r : H5Scale or H4Scale
422 Radial coordinate scale in solar radii.
423t : H5Scale or H4Scale
424 Co-latitude scale in radians.
425p : H5Scale or H4Scale
426 Longitude scale in radians.
427
428Created by :meth:`_HdfData._set_scales` during instantiation and exposed as
429:attr:`_HdfData.scales`.
430"""
431
432
433# =============================================================================
434# Abstract interface
435# =============================================================================
436
437class _HdfInterface(ABC):
438 """Abstract base class defining the full public interface for PSI HDF data objects.
439
440 All concrete readers (MAS, POT3D, coordinate scales) and their format mixins
441 satisfy this interface. Consumers should program against this class rather than
442 against concrete subclasses.
443
444 Subclasses must implement all abstract properties and the :meth:`read` and
445 :meth:`_read` methods. The ``read`` method in this base class provides shared
446 unit-conversion and remeshing logic that concrete implementations invoke via
447 ``super().read(*args, **kwargs)``.
448
449 Notes
450 -----
451 The ``__slots__ = ()`` declaration in this class is intentional: it ensures that
452 concrete subclasses using ``__slots__`` do not incur a ``__dict__`` overhead
453 from the abstract base.
454 """
455
456 __slots__ = ()
457
458 _HDFN: ClassVar[HdfVersionType] # provided by format mixin
459 _MODEL: ClassVar[ModelType] # provided by concrete subclass
460
461 def __getitem__(self, args):
462 """Index the underlying HDF dataset with physical ``(r, θ, φ)`` axis ordering.
463
464 PSI HDF files are written in Fortran column-major order so that the radial
465 index ``r`` varies fastest. When read into numpy (row-major), this results
466 in storage shape ``(Nφ, Nθ, Nr)``. This method re-reverses the user-supplied
467 index tuple so that callers can use natural physical ordering ``(r, θ, φ)``:
468
469 .. code-block:: python
470
471 obj[r_slice, t_slice, p_slice] # user order
472 # internally becomes:
473 obj.data[p_slice, t_slice, r_slice] # numpy storage order
474
475 Parameters
476 ----------
477 args : int, slice, or tuple
478 Index argument(s) in physical ``(r, θ, φ)`` order. A single non-tuple
479 argument is treated as indexing along the first physical axis (``r``).
480
481 Returns
482 -------
483 out : numpy.ndarray
484 Slice of the dataset in numpy storage order ``(Nφ, Nθ, Nr)``.
485 """
486 if not isinstance(args, tuple):
487 args = (args,)
488 return self.data[tuple(reversed(args))]
489
490 @property
491 @abstractmethod
492 def shape(self) -> tuple[int, ...]:
493 """Shape of the underlying numpy array in storage order ``(Nφ, Nθ, Nr)``."""
494 ...
495
496 @property
497 @abstractmethod
498 def dtype(self) -> np.dtype:
499 """NumPy dtype of the stored array."""
500 ...
501
502 @property
503 @abstractmethod
504 def size(self) -> int:
505 """Total number of elements in the array."""
506 ...
507
508 @property
509 @abstractmethod
510 def nbytes(self) -> int:
511 """Total memory occupied by the array in bytes."""
512 ...
513
514 @property
515 @abstractmethod
516 def ndim(self) -> int:
517 """Number of array dimensions (always 3 for data objects, 1 for scales)."""
518 ...
519
520 @property
521 @abstractmethod
522 def attrs(self) -> dict:
523 """HDF attributes attached to this dataset as a plain Python dict."""
524 ...
525
526 @property
527 @abstractmethod
528 def unit(self) -> u.Unit:
529 """Astropy unit that converts one code-unit value to physical units."""
530 ...
531
532 @property
533 @abstractmethod
534 def mesh(self) -> tuple[Mesh, ...]:
535 """Normalized mesh-stagger tuple, one :class:`~psi_io._mesh.Mesh` per axis."""
536 ...
537
538 @property
539 @abstractmethod
540 def quantity(self) -> str:
541 """Canonical lower-case quantity identifier (e.g. ``'br'``)."""
542 ...
543
544 @property
545 def description(self) -> str:
546 """Human-readable description of the physical quantity.
547
548 Looked up from the appropriate property mapping via :data:`_PROP_MAPPING`
549 using :attr:`_MODEL` and the stored :attr:`quantity` name.
550
551 Returns
552 -------
553 out : str
554 Description string (e.g. ``'Magnetic Field (Radial Component)'``).
555 """
556 return _PROP_MAPPING[self._MODEL](self._quantity).desc
557
558 @property
559 @abstractmethod
560 def data(self) -> np.ndarray:
561 """Raw array view into the HDF dataset (not yet loaded into RAM).
562
563 For HDF5 objects this is an h5py :class:`~h5py.Dataset`; for HDF4 objects
564 it is a pyhdf ``SDS`` object. Actual data transfer occurs only when the
565 returned object is indexed or converted to a numpy array.
566 """
567 ...
568
569 @property
570 def native_properties(self) -> Props:
571 """The :class:`~psi_io._props.Props` descriptor for this quantity.
572
573 Returns the full property bundle (name, description, unit, mesh code) from
574 the appropriate mapping for this reader's model type and quantity.
575
576 Returns
577 -------
578 out : Props
579 Immutable property descriptor for :attr:`quantity`.
580 """
581 return _PROP_MAPPING[self._MODEL](self._quantity)
582
583 @abstractmethod
584 def read(self,
585 *args,
586 units: Optional[str | u.Unit] = None,
587 mesh: Optional[MeshCodeType] = None,
588 ) -> tuple[u.Quantity, tuple[slice, ...], tuple[bool, ...]]:
589 """Read a slice of data, applying optional unit conversion and remeshing.
590
591 This base implementation handles unit conversion and remesh flag computation.
592 Concrete subclasses call ``super().read(...)`` and then attach coordinate
593 scales or perform additional post-processing.
594
595 Parameters
596 ----------
597 *args : int, slice, tuple, or None
598 Index arguments in physical ``(r, θ, φ)`` axis order. Each positional
599 argument selects elements along one spatial axis in the order
600 ``(r, θ, φ)``. Accepted forms per axis:
601
602 - ``None`` or omitted — full axis (equivalent to ``slice(None)``).
603 - ``int`` — single index (output retains that axis as a length-1
604 dimension).
605 - ``slice`` — standard Python slice.
606 - ``(start, stop)`` or ``(start, stop, step)`` — converted to a slice.
607 - ``Ellipsis`` — expands to ``None`` for all remaining axes.
608
609 units : str or astropy.units.Unit, optional
610 Requested output unit. Special string aliases are accepted:
611
612 - ``'native'`` / ``'code'`` / ``'model'`` — return raw code-unit
613 values (an :class:`~astropy.units.Quantity` whose unit is the custom
614 MAS unit, e.g. ``MAS_b``).
615 - ``'real'`` / ``'phys'`` / ``'physical'`` — decompose to CGS base
616 units via :func:`~psi_io._units.decompose_mas_units`.
617 - Any other string or :class:`~astropy.units.Unit` — passed directly
618 to :meth:`~astropy.units.Quantity.to`.
619
620 If ``None``, the data are returned in the native code unit without
621 conversion.
622
623 mesh : MeshCodeType, optional
624 Target mesh stagger. If provided, half-mesh axes in the stored data
625 that are on the main mesh in *mesh* are averaged to the main mesh before
626 return (via :func:`~psi_io._mesh.remesh_arr`). Attempting to up-sample
627 from main to half mesh raises :class:`ValueError`. If ``None``, no
628 remeshing is performed.
629
630 Returns
631 -------
632 odata : astropy.units.Quantity
633 Data array with requested units and remeshing applied.
634 sargs : tuple[slice, ...]
635 Slice tuple in physical ``(r, θ, φ)`` order, suitable for applying to
636 coordinate scale arrays.
637 remesh : tuple[bool, ...]
638 Boolean flags in physical ``(r, θ, φ)`` order indicating which axes were
639 remeshed from half to main mesh.
640 """
641 if mesh is None:
642 remesh = repeat(False, self.ndim)
643 else:
644 remesh = _parse_remesh(self.mesh, _normalize_mesh_code(mesh, self.ndim))
645 remesh = tuple(remesh)
646
647 sargs = _parse_islice_args(*args, shape=tuple(reversed(self.shape)), remesh=remesh)
648 sargs = tuple(sargs)
649
650 odata = remesh_arr(self[sargs], remesh=tuple(reversed(remesh))) * self.unit
651 if units is not None:
652 ounit = str(units).lower()
653 if ounit in _CODE_UNIT_ALIASES:
654 pass
655 elif ounit in _REAL_UNIT_ALIASES:
656 odata = decompose_mas_units(odata)
657 else:
658 odata = odata.to(units)
659 return odata, sargs, remesh
660
661 @abstractmethod
662 def _read(self,
663 *sargs,
664 remesh) -> u.Quantity:
665 """Internal read using pre-validated slice args and remesh flags.
666
667 Applies *sargs* directly to the dataset via :meth:`__getitem__` (which
668 performs the axis reversal) and then remeshes the result. Called by
669 :meth:`_HdfData.read` when reading coordinate scales to avoid re-parsing
670 slice arguments.
671
672 Parameters
673 ----------
674 *sargs : slice
675 Pre-validated slices in physical ``(r, θ, φ)`` order.
676 remesh : tuple[bool, ...]
677 Remesh flags in physical ``(r, θ, φ)`` order.
678
679 Returns
680 -------
681 out : astropy.units.Quantity
682 Sliced and optionally remeshed data in code units.
683 """
684 return remesh_arr(self[sargs], remesh=remesh) * self.unit
685
686
687# =============================================================================
688# Scale classes
689# =============================================================================
690
691class _HdfScale(_HdfInterface, ABC):
692 """Abstract base for HDF coordinate scale variables (r, t, p).
693
694 A scale object wraps a one-dimensional coordinate array stored alongside the
695 main data in an HDF file. It exposes the same :class:`_HdfInterface` API as
696 data objects so that coordinate arrays can be sliced and unit-converted with the
697 same :meth:`read` call.
698
699 Subclasses supply the format-specific :attr:`data` property and the array
700 introspection properties (``shape``, ``dtype``, etc.).
701 """
702
703 __slots__ = ()
704 _MODEL = 'scale'
705
706 def __init__(self,
707 parent,
708 dim_label: str,
709 data_label: str,):
710 """Initialize a scale from a parent data reader and dimension metadata.
711
712 Parameters
713 ----------
714 parent : _HdfData
715 The data reader to which this scale belongs. The scale holds a reference
716 to *parent* so it can share the open file handle.
717 dim_label : str
718 Coordinate axis label — one of ``'r'``, ``'t'``, ``'p'``. Used to look
719 up the physical unit via :func:`get_psi_scale_properties`.
720 data_label : str
721 Dataset or SDS name within the HDF file where the coordinate values are
722 stored.
723
724 Raises
725 ------
726 ValueError
727 If the underlying coordinate dataset is not one-dimensional.
728 """
729 self._dataref = parent
730 self._datalabel: str = data_label
731
732 if self.ndim != 1:
733 raise ValueError(f"Expected 1D coordinate variable, "
734 f"found {self.ndim}D dataset with shape {self.shape}.")
735
736 self._set_properties(dim_label)
737
738 def _set_properties(self, scale: str):
739 """Look up and cache the unit for this coordinate axis."""
740 try:
741 qprops = _PROP_MAPPING[self._MODEL](scale)
742 self._quantity: PsiScales = qprops.name
743 self._unit: u.Unit = qprops.unit
744 except (ValueError, TypeError) as e:
745 raise ValueError(f"Metadata type coercion failed: {e}") from e
746
747 @property
748 def unit(self) -> u.Unit:
749 """Astropy unit for this coordinate axis (``PSI_rsun`` or ``PSI_angle``)."""
750 return self._unit
751
752 @property
753 def quantity(self) -> PsiScales:
754 """Coordinate axis identifier: ``'r'``, ``'t'``, or ``'p'``."""
755 return self._quantity
756
757 @property
758 def mesh(self) -> tuple[Mesh, ...]:
759 """Single-element mesh tuple matching the parent data's stagger on this axis.
760
761 Returns the mesh position for this coordinate axis as derived from the parent
762 data's stagger tuple. ``'r'`` → index 0, ``'t'`` → index 1, ``'p'`` → 2.
763 """
764 return self._dataref.mesh['rtp'.index(self._quantity)],
765
766 def read(self,
767 *args,
768 **kwargs,
769 ) -> u.Quantity:
770 """Read the coordinate array, returning an astropy Quantity.
771
772 Delegates to :meth:`_HdfInterface.read` and returns only the data
773 (discarding the ``sargs`` and ``remesh`` bookkeeping values).
774
775 Parameters
776 ----------
777 *args
778 Slice arguments; see :meth:`_HdfInterface.read`.
779 **kwargs
780 Keyword arguments forwarded to :meth:`_HdfInterface.read`.
781
782 Returns
783 -------
784 out : astropy.units.Quantity
785 Coordinate values with the appropriate PSI unit attached.
786 """
787 odata, *_ = super().read(*args, **kwargs)
788 return odata
789
790 def _read(self,
791 *sargs,
792 remesh) -> u.Quantity:
793 """Internal read using pre-validated slice args."""
794 return super()._read(*sargs, remesh=remesh)
795
796
797class H5Scale(_HdfScale):
798 """HDF5 coordinate scale variable backed by an h5py dimension scale dataset."""
799
800 __slots__ = ('_dataref', '_datalabel', '_quantity', '_unit')
801
802 @property
803 def shape(self) -> tuple[int, ...]:
804 return self.data.shape
805
806 @property
807 def dtype(self) -> np.dtype:
808 return self.data.dtype
809
810 @property
811 def size(self) -> int:
812 return self.data.size
813
814 @property
815 def nbytes(self) -> int:
816 return self.data.nbytes
817
818 @property
819 def ndim(self) -> int:
820 return self.data.ndim
821
822 @property
823 def attrs(self) -> dict:
824 return dict(self.data.attrs)
825
826 @property
827 def data(self) -> np.ndarray:
828 """h5py Dataset object for this coordinate dimension."""
829 return self._dataref._fileref[self._datalabel]
830
831
832class H4Scale(_HdfScale):
833 """HDF4 coordinate scale variable backed by a pyhdf SDS dimension."""
834
835 __slots__ = ('_dataref', '_datalabel', '_quantity', '_unit')
836
837 @property
838 def shape(self) -> tuple[int, ...]:
839 return self.data.info()[2],
840
841 @property
842 def dtype(self) -> np.dtype:
843 return SDC_TYPE_CONVERSIONS[self.data.info()[3]]
844
845 @property
846 def size(self) -> int:
847 return int(np.prod(self.shape))
848
849 @property
850 def nbytes(self) -> int:
851 return self.size * self.dtype.itemsize
852
853 @property
854 def ndim(self) -> int:
855 return self.data.info()[1]
856
857 @property
858 def attrs(self) -> dict:
859 return self.data.attributes()
860
861 @property
862 def data(self) -> np.ndarray:
863 """pyhdf SDS object for this coordinate dimension."""
864 return self._dataref._fileref.select(self._datalabel)
865
866
867# =============================================================================
868# Format mixins (HDF5 and HDF4 file I/O + raw array access)
869# =============================================================================
870
871class _H5DataMixin:
872 """Mixin providing HDF5 file I/O and raw array access via h5py.
873
874 Concrete data classes inherit from both this mixin and :class:`_HdfData`.
875 The mixin supplies the ``_HDFN = 'h5'`` class variable, the
876 :meth:`read_file` class method, and properties that delegate to the open
877 :class:`h5py.File` handle.
878 """
879
880 __slots__ = ()
881 _HDFN = 'h5'
882
883 @classmethod
884 def read_file(cls, ifile: PathLike):
885 """Open an HDF5 file for reading and return the :class:`h5py.File` handle."""
886 return h5.File(ifile, 'r')
887
888 def open(self):
889 """Re-open the HDF5 file if it was previously closed. Returns ``self``."""
890 if not self._fileref:
891 self._fileref = self.read_file(self._filepath)
892 return self
893
894 def close(self):
895 """Close the HDF5 file handle. Returns ``self``."""
896 if self._fileref is not None:
897 self._fileref.close()
898 self._fileref = None
899 return self
900
901 def delete(self):
902 """Close the file handle during garbage collection (called by ``__del__``)."""
903 fileref = getattr(self, '_fileref', None)
904 if fileref is not None:
905 fileref.close()
906 self._fileref = None
907
908 @property
909 def shape(self) -> tuple[int, ...]:
910 return self.data.shape
911
912 @property
913 def dtype(self) -> np.dtype:
914 return self.data.dtype
915
916 @property
917 def size(self) -> int:
918 return self.data.size
919
920 @property
921 def nbytes(self) -> int:
922 return self.data.nbytes
923
924 @property
925 def ndim(self) -> int:
926 return self.data.ndim
927
928 @property
929 def attrs(self) -> dict:
930 return dict(self.data.attrs)
931
932 @property
933 def data(self) -> np.ndarray:
934 """h5py Dataset object providing lazy access to the array."""
935 return self._fileref[self._datalabel]
936
937 def _set_scales(self):
938 """Construct :class:`H5Scale` objects from h5py dimension scales."""
939 self._scales = Scales(*tuple(H5Scale(self, scale, label.label)
940 for scale, label in zip('rtp', self.data.dims, strict=True)))
941
942
943class _H4DataMixin:
944 """Mixin providing HDF4 file I/O and raw array access via pyhdf.
945
946 Analogous to :class:`_H5DataMixin` but for HDF4 files. Raises an informative
947 error at import time if pyhdf is not installed, via :func:`_except_no_pyhdf`.
948 """
949
950 __slots__ = ()
951 _HDFN = 'h4'
952
953 @classmethod
954 def read_file(cls, ifile: PathLike):
955 """Open an HDF4 file for reading and return the pyhdf ``SD`` object."""
956 _except_no_pyhdf()
957 return h4.SD(str(ifile), h4.SDC.READ)
958
959 def open(self):
960 """Re-open the HDF4 file if it was previously closed. Returns ``self``."""
961 if not self._fileref:
962 self._fileref = self.read_file(self._filepath)
963 return self
964
965 def close(self):
966 """Close the HDF4 file handle via ``end()``."""
967 if self._fileref is not None:
968 self._fileref.end()
969 self._fileref = None
970
971 def delete(self):
972 """Close the HDF4 file handle during garbage collection."""
973 fileref = getattr(self, '_fileref', None)
974 if fileref is not None:
975 fileref.end()
976 self._fileref = None
977
978 @property
979 def shape(self) -> tuple[int, ...]:
980 return tuple(self.data.info()[2])
981
982 @property
983 def dtype(self) -> np.dtype:
984 return SDC_TYPE_CONVERSIONS[self.data.info()[3]]
985
986 @property
987 def size(self) -> int:
988 return int(np.prod(self.shape))
989
990 @property
991 def nbytes(self) -> int:
992 return self.size * self.dtype.itemsize
993
994 @property
995 def ndim(self) -> int:
996 return self.data.info()[1]
997
998 @property
999 def attrs(self) -> dict:
1000 return self.data.attributes()
1001
1002 @property
1003 def data(self) -> np.ndarray:
1004 """pyhdf SDS object providing lazy access to the array."""
1005 return self._fileref.select(self._datalabel)
1006
1007 def _set_scales(self):
1008 """Construct :class:`H4Scale` objects from pyhdf SDS dimensions.
1009
1010 HDF4 dimension order is reversed relative to HDF5 (Fortran vs. C order),
1011 so the dimension list is reversed before zipping with ``'rtp'``.
1012 """
1013 sds = self.data
1014 dims = list(reversed(list(sds.dimensions(full=1).items())))
1015 self._scales = Scales(*tuple(H4Scale(self, scale, k_)
1016 for scale, (k_, v_) in zip('rtp', dims, strict=True)))
1017
1018
1019# =============================================================================
1020# Abstract data base
1021# =============================================================================
1022
1023class _HdfData(_HdfInterface, ABC):
1024 """Abstract base for PSI MHD data files (MAS and POT3D).
1025
1026 Implements the full :class:`_HdfInterface` contract except for the file-I/O
1027 properties (``shape``, ``dtype``, ``data``, etc.) and file management methods
1028 (``open``, ``close``, ``delete``, ``_set_scales``), which are supplied by the
1029 format mixins :class:`_H5DataMixin` and :class:`_H4DataMixin`.
1030
1031 Concrete subclasses must combine a format mixin with this class and declare the
1032 ``_MODEL`` class variable:
1033
1034 .. code-block:: python
1035
1036 class H5MasData(_H5DataMixin, _HdfData):
1037 _MODEL = 'mas'
1038
1039 Metadata resolution
1040 -------------------
1041 :meth:`_parse_properties` resolves the four metadata fields
1042 (``quantity``, ``sequence``, ``unit``, ``mesh``) from three sources, in order
1043 of decreasing priority:
1044
1045 1. Keyword arguments passed to ``__init__``.
1046 2. HDF file-level attributes (``quantity``, ``sequence``, etc.).
1047 3. The filename stem (quantity prefix and sequence digits).
1048
1049 If any field remains ``None`` after merging all sources, a :class:`ValueError`
1050 is raised listing the missing fields.
1051
1052 Context manager
1053 ---------------
1054 Instances can be used as context managers; the file handle is closed on exit:
1055
1056 .. code-block:: python
1057
1058 with PsiData('br001001.h5') as reader:
1059 data, r, t, p = reader.read()
1060 """
1061
1062 __slots__ = _DATA_SLOTS
1063
1064 def __init__(self,
1065 ifile: PathLike, /,
1066 dataset_id: Optional[str] = None,
1067 **kwargs):
1068 """Open an HDF file and parse metadata for one PSI output quantity.
1069
1070 Parameters
1071 ----------
1072 ifile : PathLike
1073 Path to the HDF4 or HDF5 file. Must exist and have the extension
1074 expected by the format mixin (``'.h5'`` or ``'.hdf'``).
1075 dataset_id : str, optional
1076 Name of the dataset (SDS in HDF4, group key in HDF5) to open. Defaults
1077 to the PSI standard dataset identifier for this format
1078 (:data:`~psi_io.psi_io.PSI_DATA_ID`).
1079 **kwargs
1080 Optional metadata overrides. Accepted keys (from
1081 :data:`METADATA_SCHEMA`): ``'quantity'``, ``'sequence'``, ``'unit'``,
1082 ``'mesh'``. Caller-supplied values take precedence over both file
1083 attributes and filename inference.
1084
1085 Raises
1086 ------
1087 FileNotFoundError
1088 If *ifile* does not exist on disk.
1089 ValueError
1090 If *ifile* has the wrong extension for this format mixin, if the
1091 dataset is not three-dimensional, or if any required metadata field
1092 cannot be resolved.
1093 """
1094 ifile = Path(ifile)
1095 if not ifile.is_file():
1096 raise FileNotFoundError(f"File '{ifile}' does not exist.")
1097 if ifile.suffix != HDF_EXT_MAPPING[self._HDFN]:
1098 raise ValueError(f"File '{ifile}' does not have the correct extension for "
1099 f"{self._HDFN} files (expected '{HDF_EXT_MAPPING[self._HDFN]}' extension).")
1100
1101 self._filepath: Path = ifile
1102 self._datalabel: str = dataset_id or PSI_DATA_ID[self._HDFN]
1103 self._fileref = self.read_file(ifile)
1104
1105 if self.ndim != 3:
1106 raise ValueError(f"Expected 3D dataset, "
1107 f"found {self.ndim}D dataset with shape {self.shape}.")
1108
1109 self._set_properties(**self._parse_properties(**kwargs))
1110 self._set_scales()
1111
1112 def __enter__(self):
1113 """Open (or re-open) the file and return ``self`` for use as a context manager."""
1114 return self.open()
1115
1116 def __exit__(self, *args):
1117 """Close the file handle when exiting the context manager."""
1118 return self.close()
1119
1120 def __del__(self):
1121 """Close the file handle when the object is garbage-collected."""
1122 return self.delete()
1123
1124 @classmethod
1125 @abstractmethod
1126 def read_file(cls, ifile: PathLike): ...
1127
1128 @abstractmethod
1129 def open(self): ...
1130
1131 @abstractmethod
1132 def close(self): ...
1133
1134 @abstractmethod
1135 def delete(self): ...
1136
1137 @abstractmethod
1138 def _set_scales(self): ...
1139
1140 def _parse_properties(self, **kwargs):
1141 """Resolve metadata fields by merging caller kwargs, file attrs, and filename.
1142
1143 Merges three sources (highest to lowest priority):
1144
1145 1. Keyword arguments in *kwargs* that match :data:`METADATA_SCHEMA` keys.
1146 2. HDF file-level attributes that match :data:`METADATA_SCHEMA` keys.
1147 3. Quantity name and sequence number inferred from the filename stem.
1148 4. The canonical :class:`~psi_io._props.Props` defaults for the resolved
1149 quantity (unit and mesh).
1150
1151 Parameters
1152 ----------
1153 **kwargs
1154 Caller-supplied metadata overrides.
1155
1156 Returns
1157 -------
1158 out : dict
1159 Fully populated metadata dict with keys
1160 ``{'quantity', 'sequence', 'unit', 'mesh'}``.
1161
1162 Raises
1163 ------
1164 ValueError
1165 If any metadata field is still ``None`` after merging all sources.
1166 """
1167 input_attrs = {k: v for k, v in kwargs.items() if k in METADATA_SCHEMA}
1168 file_attrs = {k: v for k, v in self.attrs.items() if k in METADATA_SCHEMA}
1169
1170 quantity = input_attrs.pop('quantity',
1171 file_attrs.pop('quantity',
1172 extract_quantity_from_filepath(self._filepath, '')))
1173 sequence = input_attrs.pop('sequence',
1174 file_attrs.pop('sequence',
1175 extract_sequence_from_filepath(self._filepath, 0)))
1176
1177 native_props = _PROP_MAPPING[self._MODEL](quantity)
1178
1179 native_attrs = dict(quantity=native_props.name,
1180 sequence=sequence,
1181 unit=native_props.unit,
1182 mesh=native_props.mesh)
1183
1184 attributes = native_attrs | file_attrs | input_attrs
1185 if any(v is None for v in attributes.values()):
1186 missing_meta = ', '.join(k for k, v in attributes.items() if v is None)
1187 raise ValueError(f"Malformed metadata: {missing_meta} is missing. "
1188 f"Provide these as keyword arguments or ensure they "
1189 f"are present in the file attributes.")
1190
1191 return attributes
1192
1193 def _set_properties(self,
1194 quantity: str,
1195 sequence: int,
1196 unit: str,
1197 mesh: MeshCodeType):
1198 """Store validated and type-coerced metadata on the instance.
1199
1200 Parameters
1201 ----------
1202 quantity : str
1203 Quantity name; stored as a string.
1204 sequence : int
1205 Sequence number; coerced to int.
1206 unit : str or astropy.units.Unit
1207 Unit; converted to :class:`~astropy.units.Unit` via ``u.Unit(str(unit))``.
1208 mesh : MeshCodeType
1209 Mesh stagger; normalized to ``tuple[Mesh, ...]`` via
1210 :func:`~psi_io._mesh._normalize_mesh_code`.
1211
1212 Raises
1213 ------
1214 ValueError
1215 If type coercion of any field fails.
1216 """
1217 try:
1218 self._quantity: str = str(quantity)
1219 self._sequence: int = int(sequence)
1220 self._unit: u.Unit = u.Unit(str(unit))
1221 self._mesh: tuple[Mesh, ...] = _normalize_mesh_code(mesh, self.ndim)
1222 except (ValueError, TypeError) as e:
1223 raise ValueError(f"Metadata type coercion failed: {e}") from e
1224
1225 @property
1226 def unit(self) -> u.Unit:
1227 """Astropy unit for converting code-unit values to physical units."""
1228 return self._unit
1229
1230 @property
1231 def mesh(self) -> tuple[Mesh, ...]:
1232 """Normalized mesh-stagger tuple, one :class:`~psi_io._mesh.Mesh` per axis."""
1233 return self._mesh
1234
1235 @property
1236 def quantity(self) -> str:
1237 """Canonical lower-case quantity identifier (e.g. ``'br'``)."""
1238 return self._quantity
1239
1240 @property
1241 def sequence(self) -> int:
1242 """Integer time-step sequence number from the filename or file attributes."""
1243 return self._sequence
1244
1245 @property
1246 def scales(self) -> Scales:
1247 """Named tuple of coordinate scale readers ``(r, t, p)``.
1248
1249 Each element is an :class:`H5Scale` or :class:`H4Scale` that exposes the
1250 same :meth:`read` interface as the main data object, so coordinate arrays
1251 can be sliced in sync with the data.
1252 """
1253 return self._scales
1254
1255 def read(self,
1256 *args,
1257 scales: bool = True,
1258 **kwargs
1259 ) -> u.Quantity | tuple[u.Quantity, ...]:
1260 """Read a slice of data and optionally the corresponding coordinate arrays.
1261
1262 Delegates slice parsing, remeshing, and unit conversion to
1263 :meth:`_HdfInterface.read`, then optionally reads the matching slice of
1264 each coordinate scale.
1265
1266 Parameters
1267 ----------
1268 *args : int, slice, tuple, None, or Ellipsis
1269 Index arguments in physical ``(r, θ, φ)`` order. See
1270 :meth:`_HdfInterface.read` for the full description.
1271 scales : bool, optional
1272 If ``True`` (default), also return the corresponding coordinate slice
1273 for each spatial axis. If ``False``, return only the data array.
1274 **kwargs
1275 Forwarded to :meth:`_HdfInterface.read`; see that method for
1276 ``units`` and ``mesh`` keyword arguments.
1277
1278 Returns
1279 -------
1280 odata : astropy.units.Quantity
1281 Data array in the requested unit.
1282 r_scale : astropy.units.Quantity
1283 Radial coordinate values in solar radii (only if ``scales=True``).
1284 t_scale : astropy.units.Quantity
1285 Co-latitude values in radians (only if ``scales=True``).
1286 p_scale : astropy.units.Quantity
1287 Longitude values in radians (only if ``scales=True``).
1288
1289 Examples
1290 --------
1291 Read the full array and coordinate grids:
1292
1293 >>> data, r, t, p = reader.read() # doctest: +SKIP
1294
1295 Read a radial sub-range and convert to physical CGS units:
1296
1297 >>> data, r, t, p = reader.read(slice(10, 50), units='physical') # doctest: +SKIP
1298
1299 Read data only (no coordinate arrays):
1300
1301 >>> data = reader.read(scales=False) # doctest: +SKIP
1302 """
1303 odata, sargs, remesh = super().read(*args, **kwargs)
1304 if not scales:
1305 return odata
1306 oscales = (scale._read(sarg, remesh=rmesh) for scale, sarg, rmesh in zip(self.scales, sargs, remesh))
1307 return odata, *oscales
1308
1309 def _read(self,
1310 *sargs,
1311 remesh) -> u.Quantity:
1312 """Internal read using pre-validated slice args."""
1313 return super()._read(*sargs, remesh=remesh)
1314
1315
1316# =============================================================================
1317# Concrete data classes
1318# =============================================================================
1319
1320class H5MasData(_H5DataMixin, _HdfData):
1321 """HDF5-backed MAS model data reader.
1322
1323 Combines :class:`_H5DataMixin` (h5py file I/O) with :class:`_HdfData`
1324 (MAS metadata and :meth:`read` logic). Use :func:`PsiData` to instantiate
1325 rather than calling this class directly.
1326 """
1327
1328 __slots__ = _HdfData.__slots__
1329 _MODEL = 'mas'
1330
1331
1332class H5Pot3dData(_H5DataMixin, _HdfData):
1333 """HDF5-backed POT3D model data reader.
1334
1335 Combines :class:`_H5DataMixin` (h5py file I/O) with :class:`_HdfData`
1336 (POT3D metadata and :meth:`read` logic). Use :func:`PsiData` to instantiate
1337 rather than calling this class directly.
1338 """
1339
1340 __slots__ = _HdfData.__slots__
1341 _MODEL = 'pot3d'
1342
1343
1344class H4MasData(_H4DataMixin, _HdfData):
1345 """HDF4-backed MAS model data reader.
1346
1347 Combines :class:`_H4DataMixin` (pyhdf file I/O) with :class:`_HdfData`
1348 (MAS metadata and :meth:`read` logic). Requires the optional ``pyhdf``
1349 dependency. Use :func:`PsiData` to instantiate rather than calling this class
1350 directly.
1351 """
1352
1353 __slots__ = _HdfData.__slots__
1354 _MODEL = 'mas'
1355
1356
1357class H4Pot3dData(_H4DataMixin, _HdfData):
1358 """HDF4-backed POT3D model data reader.
1359
1360 Combines :class:`_H4DataMixin` (pyhdf file I/O) with :class:`_HdfData`
1361 (POT3D metadata and :meth:`read` logic). Requires the optional ``pyhdf``
1362 dependency. Use :func:`PsiData` to instantiate rather than calling this class
1363 directly.
1364 """
1365
1366 __slots__ = _HdfData.__slots__
1367 _MODEL = 'pot3d'
1368
1369
1370# =============================================================================
1371# Private helpers
1372# =============================================================================
1373
1374def _parse_remesh(imesh, omesh):
1375 """Compute per-axis remesh flags from source and target mesh tuples.
1376
1377 Compares the stored mesh stagger *imesh* against the requested target *omesh*
1378 and yields a boolean flag for each axis:
1379
1380 - ``False`` — meshes match; no averaging needed.
1381 - ``True`` — source is half mesh, target is main mesh; averaging will be applied.
1382 - :class:`ValueError` — source is main mesh but target requests half mesh (not
1383 supported; averaging can only go from half → main).
1384
1385 Parameters
1386 ----------
1387 imesh : tuple[Mesh, ...]
1388 Current mesh stagger of the stored data.
1389 omesh : tuple[Mesh, ...]
1390 Target mesh stagger requested by the caller.
1391
1392 Yields
1393 ------
1394 flag : bool
1395 ``True`` if that axis should be remeshed (half → main), ``False`` otherwise.
1396
1397 Raises
1398 ------
1399 ValueError
1400 If any axis requests upsampling from main to half mesh.
1401 """
1402 for im, om in zip(imesh, omesh, strict=True):
1403 if im == om:
1404 yield False
1405 elif im == Mesh.HALF and om == Mesh.MAIN:
1406 yield True
1407 elif im == Mesh.MAIN and om == Mesh.HALF:
1408 raise ValueError(f"Cannot remesh from MAIN mesh to HALF mesh.")
1409
1410
1411def _parse_islice_args(*args, shape: tuple[int, ...], remesh: tuple[bool, ...]):
1412 """Normalize index-space slice arguments to a tuple of :class:`slice` objects.
1413
1414 Accepts a mix of ``None``, ``int``, ``slice``, ``(start, stop[, step])`` tuples,
1415 and ``Ellipsis``, and yields one slice per spatial axis. Validates that any axis
1416 flagged for remeshing contains at least two elements (required for averaging).
1417
1418 Parameters
1419 ----------
1420 *args : None, int, slice, tuple, or Ellipsis
1421 Index arguments in physical ``(r, θ, φ)`` user order. Fewer arguments than
1422 dimensions are padded with ``None`` (full-axis slices).
1423 shape : tuple[int, ...]
1424 Physical ``(r, θ, φ)`` shape (i.e. ``reversed(self.shape)`` from storage).
1425 remesh : tuple[bool, ...]
1426 Per-axis remesh flags in the same ``(r, θ, φ)`` order.
1427
1428 Yields
1429 ------
1430 s : slice
1431 Normalized slice for each axis.
1432
1433 Raises
1434 ------
1435 ValueError
1436 If any remeshed axis contains fewer than two elements.
1437 TypeError
1438 If an argument cannot be converted to a slice (via :func:`_cast_to_slice`).
1439 """
1440 if Ellipsis in args:
1441 n_missing = len(shape) - (len(args) - 1)
1442 idx = args.index(Ellipsis)
1443 args = args[:idx] + (None,) * n_missing + args[idx + 1:]
1444 if len(args) < len(shape):
1445 args += (None,) * (len(shape) - len(args))
1446
1447 for arg, dim_size, do_remesh in zip(args, shape, remesh, strict=True):
1448 slice_ = _cast_to_slice(arg)
1449 start, stop, step = slice_.indices(dim_size)
1450 if do_remesh and (stop - start) // step < 2:
1451 raise ValueError(f"Cannot remesh dimension with slice {slice_} "
1452 f"because it does not include at least two indices.")
1453 yield slice_
1454
1455
1456def _parse_vslice_args(dim, scale):
1457 """Convert a value-space dimension specifier to an index-space slice.
1458
1459 If *dim* is a bare float, it is treated as a value in the coordinate's native
1460 unit and first converted to an :class:`~astropy.units.Quantity`. If *dim* is
1461 an :class:`~astropy.units.Quantity`, its value is located in *scale* via binary
1462 search and a two-element neighborhood slice is returned for interpolation.
1463 Non-quantity inputs are passed through to :func:`_cast_to_slice` unchanged.
1464
1465 Parameters
1466 ----------
1467 dim : float, astropy.units.Quantity, int, slice, or tuple
1468 Dimension specifier. Floats and Quantities trigger value-based lookup;
1469 all other types are forwarded to :func:`_cast_to_slice`.
1470 scale : H5Scale or H4Scale
1471 Coordinate scale providing the values to search and the native unit for
1472 unit conversion.
1473
1474 Returns
1475 -------
1476 s : slice
1477 Index-space slice (a two-index neighborhood for value lookups; the original
1478 slice for index-space inputs).
1479 val : astropy.units.Quantity or None
1480 The matched physical value for value-based lookups, or ``None`` for
1481 index-based inputs.
1482 """
1483 val = None
1484 if isinstance(dim, float):
1485 dim = dim * scale.unit
1486 if isinstance(dim, u.Quantity):
1487 val = dim.to(scale.unit)
1488 i1 = int(np.clip(np.searchsorted(scale.data, val.value), 1, scale.size - 2))
1489 dim = (i1-1, i1+1)
1490 return _cast_to_slice(dim), val
1491
1492
1493def _cast_to_slice(input):
1494 """Convert a dimension index argument to a :class:`slice` object.
1495
1496 Parameters
1497 ----------
1498 input : None, int, slice, list, or tuple
1499 - ``None`` → ``slice(None)`` (full axis).
1500 - :class:`int` → ``slice(input, input + 1)`` (single element, axis retained).
1501 - :class:`slice` → returned unchanged.
1502 - :class:`list` or :class:`tuple` → ``slice(*input)`` (unpacked as
1503 ``(start, stop)`` or ``(start, stop, step)``).
1504
1505 Returns
1506 -------
1507 out : slice
1508
1509 Raises
1510 ------
1511 TypeError
1512 If *input* is not one of the recognized types.
1513 """
1514 if input is None:
1515 return slice(None)
1516 elif isinstance(input, int):
1517 return slice(input, input + 1)
1518 elif isinstance(input, slice):
1519 return input
1520 elif isinstance(input, (list, tuple)):
1521 return slice(*input)
1522 else:
1523 raise TypeError(f"Invalid slice argument: {input!r}. Expected int, slice, or sequence.")
1524
1525
1526_DATA_CLASS_MAP = MappingProxyType({
1527 ('.h5', 'mas'): H5MasData,
1528 ('.h5', 'pot3d'): H5Pot3dData,
1529 ('.hdf', 'mas'): H4MasData,
1530 ('.hdf', 'pot3d'): H4Pot3dData,
1531})
1532"""Read-only dispatch table mapping ``(extension, model)`` pairs to concrete classes.
1533
1534Used by :func:`PsiData` to select the correct reader without a chain of
1535``if``/``elif`` branches. Keys are ``(file_extension, model_string)`` tuples;
1536values are the corresponding concrete :class:`_HdfData` subclasses.
1537"""
1538
1539
[docs]
1540def PsiData(ifile: PathLike, /,
1541 model: ModelType = 'mas',
1542 **kwargs):
1543 """Open a PSI MAS or POT3D HDF file and return the appropriate data reader.
1544
1545 This is the recommended entry point for reading PSI model output. The
1546 function inspects the file extension and the *model* argument to select the
1547 correct concrete reader class, then instantiates and returns it.
1548
1549 .. rubric:: Returned object
1550
1551 The returned object is a lazy reader that holds an open file handle. The
1552 HDF dataset is *not* copied into memory at construction time — data transfer
1553 happens only when :meth:`read` is called (or when the underlying
1554 :attr:`data` handle is explicitly indexed). The object exposes:
1555
1556 - :meth:`read` — primary method for loading a slice of the dataset.
1557 - :attr:`scales` — ``Scales(r, t, p)`` named tuple of coordinate scale
1558 readers; each element supports the same :meth:`read` interface.
1559 - :attr:`quantity` — canonical lower-case quantity string (e.g. ``'br'``).
1560 - :attr:`sequence` — integer time-step sequence number.
1561 - :attr:`unit` — :class:`~astropy.units.Unit` for converting from code units
1562 to physical units.
1563 - :attr:`mesh` — tuple of :class:`~psi_io._mesh.Mesh` flags describing the
1564 Yee-grid stagger position of each axis.
1565 - :attr:`description` — human-readable description of the physical quantity.
1566 - :attr:`native_properties` — full :class:`~psi_io._props.Props` descriptor.
1567
1568 The object also supports the context-manager protocol; the file handle is
1569 closed on exit from the ``with`` block.
1570
1571 .. rubric:: The read method
1572
1573 .. code-block:: python
1574
1575 odata[, r, t, p] = reader.read(*args, units=None, mesh=None, scales=True)
1576
1577 **Positional arguments** — each positional argument selects elements along
1578 one spatial axis in physical ``(r, θ, φ)`` order. Supported forms:
1579
1580 - Omitted / ``None`` — full axis (``slice(None)``).
1581 - ``int`` — single index; the axis is retained as a length-1 dimension.
1582 - ``slice`` — standard Python slice.
1583 - ``(start, stop)`` or ``(start, stop, step)`` — converted to a slice.
1584 - ``Ellipsis`` — expands to ``None`` for all remaining axes.
1585
1586 **Keyword arguments**:
1587
1588 - ``units`` — output unit. String aliases: ``'native'`` / ``'code'`` /
1589 ``'model'`` return values in the custom MAS code unit; ``'real'`` /
1590 ``'phys'`` / ``'physical'`` decompose to CGS base units. Any other
1591 string is forwarded to :class:`~astropy.units.Unit` and must be
1592 parseable by it — this includes SI and CGS unit names (``'Gauss'``,
1593 ``'nT'``, ``'T'``), compound expressions (``'km/s'``,
1594 ``'erg/cm**2/s'``), and scale prefixes (``'mG'``, ``'μT'``); see
1595 :ref:`astropy:unit-format` for the full grammar. An
1596 :class:`~astropy.units.Unit` instance may also be passed directly.
1597 - ``mesh`` — target mesh stagger (:data:`~psi_io._mesh.MeshCodeType`).
1598 Half-mesh axes that are on the main mesh in *mesh* are averaged to the
1599 main mesh via :func:`~psi_io._mesh.remesh_arr` before return.
1600 - ``scales`` — if ``True`` (default), return the coordinate slice for each
1601 axis as additional :class:`~astropy.units.Quantity` values after the data.
1602
1603 .. note::
1604 PSI HDF files are written in Fortran column-major order so that numpy
1605 reads them with shape ``(Nφ, Nθ, Nr)`` — the *last* axis is radial.
1606 The ``read`` positional arguments and coordinate scales are always
1607 returned in physical ``(r, θ, φ)`` order regardless of storage order.
1608
1609 .. warning:: **POT3D unit convention**
1610
1611 POT3D applies **no normalization** to its output. The stored values are in
1612 whatever physical units the input photospheric boundary magnetogram was
1613 provided in — most commonly Gauss, but this is not encoded in the file.
1614 The default ``unit`` for POT3D quantities is therefore
1615 ``dimensionless_unscaled`` (scale factor 1), meaning that calling
1616 ``read(units='physical')`` will **not** perform a meaningful unit
1617 conversion unless the correct unit is supplied explicitly via the *unit*
1618 keyword argument at construction time:
1619
1620 .. code-block:: python
1621
1622 reader = PsiData('br001.h5', model='pot3d', unit='Gauss')
1623 data, r, t, p = reader.read()
1624 # data.unit == u.Gauss
1625
1626 Parameters
1627 ----------
1628 ifile : PathLike
1629 Path to the HDF4 (``.hdf``) or HDF5 (``.h5``) file.
1630 model : {'mas', 'pot3d'}, optional
1631 PSI model type. Defaults to ``'mas'``.
1632 dataset_id : str, optional
1633 Name of the dataset within the HDF file. Defaults to the PSI standard
1634 identifier for the given format. Only needed when the file uses a
1635 non-standard dataset name.
1636 quantity : str, optional
1637 Override the quantity name inferred from the filename or file attributes
1638 (e.g. ``'br'``). Must be a key in the appropriate properties mapping.
1639 sequence : int, optional
1640 Override the time-step sequence number inferred from the filename or
1641 file attributes.
1642 unit : str or astropy.units.Unit, optional
1643 Override the code-to-physical unit derived from the quantity's
1644 :class:`~psi_io._props.Props` entry. Accepts any string parseable
1645 by :class:`~astropy.units.Unit`, including named units (``'Gauss'``,
1646 ``'nT'``, ``'km/s'``), compound expressions (``'erg/cm**2/s'``),
1647 and scale-prefixed forms (``'mG'``, ``'μT'``); see
1648 :ref:`astropy:unit-format` for the complete unit grammar. An
1649 :class:`~astropy.units.Unit` instance may also be passed directly.
1650 mesh : MeshCodeType, optional
1651 Override the mesh stagger inferred from the quantity's
1652 :class:`~psi_io._props.Props` entry. Useful for files whose stagger
1653 differs from the PSI convention.
1654
1655 Returns
1656 -------
1657 out : H5MasData or H4MasData or H5Pot3dData or H4Pot3dData
1658 An open reader object implementing the full :class:`_HdfInterface` API.
1659 The concrete type depends on *model* and the file extension.
1660
1661 Raises
1662 ------
1663 ValueError
1664 If the combination of file extension and *model* is not supported, or if
1665 required metadata cannot be resolved from the file, its attributes, and
1666 the supplied keyword arguments.
1667 FileNotFoundError
1668 If *ifile* does not exist on disk.
1669
1670 See Also
1671 --------
1672 astropy.units.Unit : Unit constructor — accepts strings, compound
1673 expressions, and :class:`~astropy.units.Unit` instances.
1674 astropy.units.Quantity.to : Unit conversion used internally when
1675 a ``units`` string is supplied to :meth:`read`.
1676
1677 Examples
1678 --------
1679 Read a MAS radial magnetic field file — full array with coordinate scales:
1680
1681 >>> from psi_io.mhd_io import PsiData # doctest: +SKIP
1682 >>> reader = PsiData('br001001.h5')
1683 >>> data, r, t, p = reader.read()
1684 >>> data.unit # code unit (MAS_b)
1685
1686 Convert to Gauss on the fly:
1687
1688 >>> data, r, t, p = reader.read(units='Gauss') # doctest: +SKIP
1689
1690 Read only the inner radial shell (indices 0–9 in r) in CGS base units,
1691 without returning coordinate arrays:
1692
1693 >>> data = reader.read(slice(0, 10), units='physical', scales=False) # doctest: +SKIP
1694
1695 Read a POT3D HDF4 file. The boundary magnetogram was in Gauss, so the
1696 unit must be declared explicitly — it cannot be inferred from the file:
1697
1698 >>> reader = PsiData('br001.hdf', model='pot3d', unit='Gauss') # doctest: +SKIP
1699 >>> data, r, t, p = reader.read()
1700
1701 Use as a context manager to guarantee the file handle is released:
1702
1703 >>> with PsiData('vr001001.h5') as reader: # doctest: +SKIP
1704 ... data, r, t, p = reader.read(units='km/s')
1705
1706 Inspect metadata without loading data:
1707
1708 >>> reader = PsiData('rho001001.h5') # doctest: +SKIP
1709 >>> reader.quantity # 'rho'
1710 >>> reader.description # 'Mass Density'
1711 >>> reader.unit # MAS_n (code unit)
1712 >>> reader.mesh # (Mesh.HALF, Mesh.HALF, Mesh.HALF)
1713 >>> reader.shape # (Nφ, Nθ, Nr) — numpy storage order
1714 """
1715 ifile = Path(ifile)
1716 key = (ifile.suffix, model.lower())
1717 cls = _DATA_CLASS_MAP.get(key)
1718 if cls is None:
1719 raise ValueError(
1720 f"Unsupported combination of extension '{ifile.suffix}' and model '{model}'. "
1721 f"Valid combinations: {[f'{ext}/{m}' for ext, m in _DATA_CLASS_MAP]}"
1722 )
1723 return cls(ifile, **kwargs)