Source code for psi_io.mhd_io

   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
[docs] 292def extract_quantity_from_filepath(ifile: Path, default: Optional[str] = None) -> str | None: 293 """Extract the MAS/POT3D quantity name from a filename stem. 294 295 Matches the longest recognized quantity prefix at the start of the stem (before 296 any digit or end-of-string). The match is case-insensitive. 297 298 Parameters 299 ---------- 300 ifile : Path 301 File path whose stem is examined. Only the stem (filename without 302 extension) is inspected. 303 default : str or None, optional 304 Value to return when no quantity prefix is found. Defaults to ``None``. 305 306 Returns 307 ------- 308 out : str or None 309 Lower-case quantity name (e.g. ``'br'``), or *default* if the stem does not 310 begin with a recognized quantity prefix. 311 312 Examples 313 -------- 314 >>> from pathlib import Path 315 >>> from psi_io.mhd_io import extract_quantity_from_filepath 316 >>> extract_quantity_from_filepath(Path('br001001.h5')) 317 'br' 318 >>> extract_quantity_from_filepath(Path('heat001.h5')) 319 'heat' 320 >>> extract_quantity_from_filepath(Path('unknown.h5')) is None 321 True 322 >>> extract_quantity_from_filepath(Path('unknown.h5'), default='br') 323 'br' 324 """ 325 match = re.match(rf'^({MATCH_QUANTITIES})(?=[^a-zA-Z]|$)', ifile.stem, re.IGNORECASE) 326 return match.group(1).lower() if match else default
327 328
[docs] 329def extract_sequence_from_filepath(ifile: Path, default: Optional[int] = None) -> int | None: 330 """Extract the sequence number from a filename stem. 331 332 Searches for the first occurrence of a 3- or 6-digit decimal run in the stem. 333 The match is not anchored to a particular position so it works for both the 334 strict MAS schema (``br001001.h5``) and looser naming conventions. 335 336 Parameters 337 ---------- 338 ifile : Path 339 File path whose stem is examined. 340 default : int or None, optional 341 Value to return when no 3- or 6-digit run is found. Defaults to ``None``. 342 343 Returns 344 ------- 345 out : int or None 346 Integer sequence number, or *default* if no match is found. 347 348 Examples 349 -------- 350 >>> from pathlib import Path 351 >>> from psi_io.mhd_io import extract_sequence_from_filepath 352 >>> extract_sequence_from_filepath(Path('br001001.h5')) 353 1001 354 >>> extract_sequence_from_filepath(Path('vr001.h5')) 355 1 356 >>> extract_sequence_from_filepath(Path('nosequence.h5')) is None 357 True 358 """ 359 match = re.search(r'\d{3}(?:\d{3})?', ifile.stem) 360 return int(match.group()) if match else default
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)