Skip to content

Readers

Simulation data readers, the reader protocols, and the auto-detection registry. The FieldDataset they return is documented under Core Containers.

readers

Simulation data readers and auto-detection registry.

ReaderBase

Base class for readers that turn a run's files into FieldDataset.

Subclasses implement available_timesteps, read_timestep and available_fields_mapping; everything else a Simulation may ask for has a default here. The contract every reader honours:

  • read_timestep builds its dataset through _finish, the one place arrays become a FieldDataset. It stamps metadata["step"] and, when the file records one, metadata["time"], and takes normalization, species, physics, frame and transforms from the merged SimulationConfig, so a simulation.toml reaches the data.
  • Arrays handed to _finish are in code units on a co-located grid. Destaggering, when a reader gains it, happens before that call.
  • available_fields is the sorted key set of available_fields_mapping: readers list, they do not load.

Parameters:

Name Type Description Default
sim_config SimulationConfig | None

Merged run configuration. A reader that only learns its grid from each file (SimpleReader) passes None here and the resolved config to _finish instead.

None
Source code in src/pypic/readers/_base.py
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
class ReaderBase:
    """Base class for readers that turn a run's files into `FieldDataset`.

    Subclasses implement ``available_timesteps``, ``read_timestep`` and
    ``available_fields_mapping``; everything else a `Simulation` may ask
    for has a default here. The contract every reader honours:

    - ``read_timestep`` builds its dataset through `_finish`, the one
      place arrays become a `FieldDataset`. It stamps ``metadata["step"]``
      and, when the file records one, ``metadata["time"]``, and takes
      normalization, species, physics, frame and transforms from the
      merged `SimulationConfig`, so a ``simulation.toml`` reaches the data.
    - Arrays handed to `_finish` are in code units on a co-located grid.
      Destaggering, when a reader gains it, happens before that call.
    - ``available_fields`` is the sorted key set of
      ``available_fields_mapping``: readers list, they do not load.

    Parameters
    ----------
    sim_config : SimulationConfig | None
        Merged run configuration. A reader that only learns its grid
        from each file (`SimpleReader`) passes ``None`` here and the
        resolved config to `_finish` instead.
    """

    def __init__(self, sim_config: SimulationConfig | None = None) -> None:
        self._sim_config = sim_config

    def available_fields_mapping(self, path: Path, step: int) -> dict[str, str | None]:
        """Map canonical field names to native (on-disk) names at *step*."""
        raise NotImplementedError

    def available_fields(self, path: Path, step: int) -> list[str]:
        """Sorted canonical field names at *step*, without loading arrays."""
        return sorted(self.available_fields_mapping(path, step))

    def available_auxiliary(self, path: Path) -> list[str]:
        """Names of auxiliary datasets at *path*; none unless overridden."""
        return []

    def load_auxiliary(self, path: Path, name: str) -> TabularData:
        """Load a named auxiliary dataset; raises unless overridden."""
        msg = f"No auxiliary dataset {name!r}"
        raise KeyError(msg)

    def _require_config(self) -> SimulationConfig:
        """Return the merged config; readers that resolve it per file pass theirs."""
        if self._sim_config is None:
            msg = f"{type(self).__name__} has no SimulationConfig to build from"
            raise ValueError(msg)
        return self._sim_config

    def _finish(
        self,
        fields: Mapping[str, FloatArray],
        *,
        step: int,
        time: float | None = None,
        grid: GridInfo | None = None,
        coords: Mapping[str, FloatArray] | None = None,
        extra: Mapping[str, Any] | None = None,
        config: SimulationConfig | None = None,
    ) -> FieldDataset:
        """Wrap code-unit *fields* in a dataset carrying the run's config.

        Parameters
        ----------
        fields : Mapping[str, FloatArray]
            Canonical-named arrays in code units.
        step : int
            Timestep index; always stamped into ``metadata``.
        time : float | None
            Snapshot time in code units when the file records one.
            `FieldDataset.time` derives ``step * grid.dt`` otherwise.
        grid : GridInfo | None
            Grid the arrays live on; defaults to the config's. Readers
            that rebuild the grid per read (AMR regridding) pass it.
        coords : Mapping[str, FloatArray] | None
            True coordinate arrays for non-uniform meshes, per axis name.
        extra : Mapping[str, Any] | None
            Reader-specific metadata (format, stagger provenance, ...).
            Config metadata comes first, then *extra*, then step and time.
        config : SimulationConfig | None
            Overrides the config given at construction, for readers
            that resolve it per file.
        """
        sc = config if config is not None else self._require_config()
        metadata: dict[str, Any] = {**sc.metadata, **(extra or {}), "step": step}
        if time is not None:
            metadata["time"] = time
        return FieldDataset.from_arrays(
            fields,
            grid if grid is not None else sc.grid,
            sc.normalization,
            species=sc.species,
            physics=sc.physics,
            metadata=metadata,
            frame=sc.frame,
            transforms=sc.transforms or None,
            coords=coords,
            strict_fields=False,
        )

available_fields_mapping(path, step)

Map canonical field names to native (on-disk) names at step.

Source code in src/pypic/readers/_base.py
46
47
48
def available_fields_mapping(self, path: Path, step: int) -> dict[str, str | None]:
    """Map canonical field names to native (on-disk) names at *step*."""
    raise NotImplementedError

available_fields(path, step)

Sorted canonical field names at step, without loading arrays.

Source code in src/pypic/readers/_base.py
50
51
52
def available_fields(self, path: Path, step: int) -> list[str]:
    """Sorted canonical field names at *step*, without loading arrays."""
    return sorted(self.available_fields_mapping(path, step))

available_auxiliary(path)

Names of auxiliary datasets at path; none unless overridden.

Source code in src/pypic/readers/_base.py
54
55
56
def available_auxiliary(self, path: Path) -> list[str]:
    """Names of auxiliary datasets at *path*; none unless overridden."""
    return []

load_auxiliary(path, name)

Load a named auxiliary dataset; raises unless overridden.

Source code in src/pypic/readers/_base.py
58
59
60
61
def load_auxiliary(self, path: Path, name: str) -> TabularData:
    """Load a named auxiliary dataset; raises unless overridden."""
    msg = f"No auxiliary dataset {name!r}"
    raise KeyError(msg)

AuxiliaryDataReader

Bases: Protocol

Opt-in protocol for readers that provide auxiliary tabular data.

Readers implement this alongside SimulationReader to advertise and load non-field data (conserved quantities, diagnostics, probes).

Source code in src/pypic/readers/_protocols.py
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
@runtime_checkable
class AuxiliaryDataReader(Protocol):
    """Opt-in protocol for readers that provide auxiliary tabular data.

    Readers implement this alongside ``SimulationReader`` to advertise
    and load non-field data (conserved quantities, diagnostics, probes).
    """

    def available_auxiliary(self, path: Path) -> list[str]:
        """Return names of auxiliary datasets discoverable at *path*."""
        ...

    def load_auxiliary(self, path: Path, name: str) -> TabularData:
        """Load a named auxiliary dataset from *path*."""
        ...

available_auxiliary(path)

Return names of auxiliary datasets discoverable at path.

Source code in src/pypic/readers/_protocols.py
104
105
106
def available_auxiliary(self, path: Path) -> list[str]:
    """Return names of auxiliary datasets discoverable at *path*."""
    ...

load_auxiliary(path, name)

Load a named auxiliary dataset from path.

Source code in src/pypic/readers/_protocols.py
108
109
110
def load_auxiliary(self, path: Path, name: str) -> TabularData:
    """Load a named auxiliary dataset from *path*."""
    ...

ParticleDataReader

Bases: Protocol

Opt-in protocol for readers that provide particle data.

Readers implement this alongside SimulationReader to advertise and load per-species particle arrays (position, velocity, weight) plus scalar species charge/mass — see docs/schema.md § Per-particle data columns.

Source code in src/pypic/readers/_protocols.py
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
@runtime_checkable
class ParticleDataReader(Protocol):
    """Opt-in protocol for readers that provide particle data.

    Readers implement this alongside ``SimulationReader`` to advertise
    and load per-species particle arrays (position, velocity, weight)
    plus scalar species charge/mass — see ``docs/schema.md`` § Per-particle
    data columns.
    """

    def available_particle_steps(self, path: Path) -> list[int]:
        """Return sorted timestep indices that have particle data."""
        ...

    def read_particles(
        self,
        path: Path,
        step: int,
        species: int,
        *,
        columns: Iterable[str] | None = None,
    ) -> ParticleData:
        """Load particle data for one species at one timestep.

        Parameters
        ----------
        path : Path
            Simulation output directory.
        step : int
            Timestep index.
        species : int
            Zero-based species index.
        columns : Iterable[str] | None
            Subset of ``{"position", "velocity"}`` to load.
            ``None`` loads all.  Per-particle ``weight`` and the scalar
            ``species_charge``/``species_mass`` are always populated
            (canonical layout, ``docs/schema.md``).
        """
        ...

available_particle_steps(path)

Return sorted timestep indices that have particle data.

Source code in src/pypic/readers/_protocols.py
42
43
44
def available_particle_steps(self, path: Path) -> list[int]:
    """Return sorted timestep indices that have particle data."""
    ...

read_particles(path, step, species, *, columns=None)

Load particle data for one species at one timestep.

Parameters:

Name Type Description Default
path Path

Simulation output directory.

required
step int

Timestep index.

required
species int

Zero-based species index.

required
columns Iterable[str] | None

Subset of {"position", "velocity"} to load. None loads all. Per-particle weight and the scalar species_charge/species_mass are always populated (canonical layout, docs/schema.md).

None
Source code in src/pypic/readers/_protocols.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
def read_particles(
    self,
    path: Path,
    step: int,
    species: int,
    *,
    columns: Iterable[str] | None = None,
) -> ParticleData:
    """Load particle data for one species at one timestep.

    Parameters
    ----------
    path : Path
        Simulation output directory.
    step : int
        Timestep index.
    species : int
        Zero-based species index.
    columns : Iterable[str] | None
        Subset of ``{"position", "velocity"}`` to load.
        ``None`` loads all.  Per-particle ``weight`` and the scalar
        ``species_charge``/``species_mass`` are always populated
        (canonical layout, ``docs/schema.md``).
    """
    ...

SimulationReader

Bases: Protocol

Protocol for simulation-specific file readers.

Any class with read_timestep and available_timesteps methods satisfies this protocol — no inheritance required.

Source code in src/pypic/readers/_protocols.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@runtime_checkable
class SimulationReader(Protocol):
    """Protocol for simulation-specific file readers.

    Any class with ``read_timestep`` and ``available_timesteps`` methods
    satisfies this protocol — no inheritance required.
    """

    def read_timestep(self, path: Path, step: int) -> FieldDataset:
        """Read field data for a single timestep."""
        ...

    def available_timesteps(self, path: Path) -> list[int]:
        """Return sorted list of available timestep indices."""
        ...

read_timestep(path, step)

Read field data for a single timestep.

Source code in src/pypic/readers/_protocols.py
23
24
25
def read_timestep(self, path: Path, step: int) -> FieldDataset:
    """Read field data for a single timestep."""
    ...

available_timesteps(path)

Return sorted list of available timestep indices.

Source code in src/pypic/readers/_protocols.py
27
28
29
def available_timesteps(self, path: Path) -> list[int]:
    """Return sorted list of available timestep indices."""
    ...

ProbeResult dataclass

Result of a single reader's format probe.

Parameters:

Name Type Description Default
name str

Reader name.

required
confidence float

Confidence score in [0.0, 1.0].

required
error str | None

Error message if the probe or factory raised, else None.

None
Source code in src/pypic/readers/_registry.py
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
@dataclass(frozen=True, slots=True)
class ProbeResult:
    """Result of a single reader's format probe.

    Parameters
    ----------
    name : str
        Reader name.
    confidence : float
        Confidence score in ``[0.0, 1.0]``.
    error : str | None
        Error message if the probe or factory raised, else ``None``.
    """

    name: str
    confidence: float
    error: str | None = None

ReaderEntry dataclass

A registered reader with its format detector and factory.

Parameters:

Name Type Description Default
name str

Short identifier (e.g. "ipic3d", "batsrus").

required
can_read_confidence CanReadFunction

Returns a confidence score in [0.0, 1.0] that path contains data readable by this reader.

required
factory ReaderFactory

Callable that opens a simulation directory.

required
Source code in src/pypic/readers/_registry.py
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
@dataclass(frozen=True, slots=True)
class ReaderEntry:
    """A registered reader with its format detector and factory.

    Parameters
    ----------
    name : str
        Short identifier (e.g. ``"ipic3d"``, ``"batsrus"``).
    can_read_confidence : CanReadFunction
        Returns a confidence score in ``[0.0, 1.0]`` that *path*
        contains data readable by this reader.
    factory : ReaderFactory
        Callable that opens a simulation directory.
    """

    name: str
    can_read_confidence: CanReadFunction
    factory: ReaderFactory

Simulation

Ergonomic wrapper around a simulation reader, config, and path.

Returned by open_simulation. Provides direct access to config properties and reads timesteps without repeating the data path.

Also unpacks as a (reader, config) tuple::

sim = open_simulation(path)            # preferred
reader, config = open_simulation(path) # still works

Cross-model comparison workflow:

  1. Open both simulations via open_simulation().
  2. Read matching timesteps from each.
  3. Regrid to a common grid via align_grids when the two runs do not already share one.
  4. Compare fields: use in_si() for cross-model comparison (different normalizations make code units incomparable), or compare in code units for same-model parameter studies (identical normalization). Dimensionless quantities (beta, Mach number, entropy) need no conversion.

Known limitations: no automatic timestep alignment across simulations (different codes use different step numbering and output cadences).

Parameters:

Name Type Description Default
reader SimulationReader

The underlying reader instance.

required
config SimulationConfig

Parsed simulation metadata.

required
path Path

Data directory (remembered for read / steps).

required

Examples:

>>> from unittest.mock import MagicMock
>>> r = MagicMock()
>>> r.available_timesteps.return_value = [0, 10]
>>> from pypic.containers import SimulationConfig
>>> from pypic.grid import GridInfo
>>> from pypic.coordinates.geometry import CARTESIAN
>>> from pypic.units import Normalization, SpeciesInfo
>>> cfg = SimulationConfig(
...     model_name="test", model_type="PIC",
...     grid=GridInfo(
...         dimensions=(4,), spacing=(1.0,), origin=(0.0,),
...         geometry=CARTESIAN,
...     ),
...     normalization=Normalization.identity(),
...     species=(SpeciesInfo(name="e", charge=-1.0, mass=1.0),),
... )
>>> sim = Simulation(r, cfg, path="/tmp")
>>> sim.model_name
'test'
>>> sim.steps
[0, 10]
Source code in src/pypic/readers/_registry.py
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
class Simulation:
    """Ergonomic wrapper around a simulation reader, config, and path.

    Returned by `open_simulation`.  Provides direct access to config
    properties and reads timesteps without repeating the data path.

    Also unpacks as a ``(reader, config)`` tuple::

        sim = open_simulation(path)            # preferred
        reader, config = open_simulation(path) # still works

    **Cross-model comparison workflow:**

    1. Open both simulations via ``open_simulation()``.
    2. Read matching timesteps from each.
    3. Regrid to a common grid via [`align_grids`][pypic.regridding.align_grids]
       when the two runs do not already share one.
    4. Compare fields: use ``in_si()`` for cross-model comparison
       (different normalizations make code units incomparable), or
       compare in code units for same-model parameter studies (identical
       normalization). Dimensionless quantities (beta, Mach number,
       entropy) need no conversion.

    Known limitations: no automatic timestep alignment across simulations
    (different codes use different step numbering and output cadences).

    Parameters
    ----------
    reader : SimulationReader
        The underlying reader instance.
    config : SimulationConfig
        Parsed simulation metadata.
    path : Path
        Data directory (remembered for ``read`` / ``steps``).

    Examples
    --------
    >>> from unittest.mock import MagicMock
    >>> r = MagicMock()
    >>> r.available_timesteps.return_value = [0, 10]
    >>> from pypic.containers import SimulationConfig
    >>> from pypic.grid import GridInfo
    >>> from pypic.coordinates.geometry import CARTESIAN
    >>> from pypic.units import Normalization, SpeciesInfo
    >>> cfg = SimulationConfig(
    ...     model_name="test", model_type="PIC",
    ...     grid=GridInfo(
    ...         dimensions=(4,), spacing=(1.0,), origin=(0.0,),
    ...         geometry=CARTESIAN,
    ...     ),
    ...     normalization=Normalization.identity(),
    ...     species=(SpeciesInfo(name="e", charge=-1.0, mass=1.0),),
    ... )
    >>> sim = Simulation(r, cfg, path="/tmp")
    >>> sim.model_name
    'test'
    >>> sim.steps
    [0, 10]
    """

    def __init__(
        self,
        reader: SimulationReader,
        config: SimulationConfig,
        path: Path | str,
        *,
        probe_results: tuple[ProbeResult, ...] | None = None,
    ) -> None:
        self._reader = reader
        self._config = config
        self._path = Path(path)
        self._steps: list[int] | None = None
        self._probe_results = probe_results

    @property
    def reader(self) -> SimulationReader:
        """The underlying reader instance."""
        return self._reader

    @property
    def config(self) -> SimulationConfig:
        """Full simulation configuration."""
        return self._config

    @property
    def path(self) -> Path:
        """Data directory."""
        return self._path

    @property
    def run(self) -> Run | None:
        """``[run]`` provenance from the merged configuration.

        Resolved once when the simulation is opened. A per-file ``/run/``
        group is attached per dataset instead, so
        ``sim.read(step).metadata["run"]`` is the authoritative record for
        a given timestep when the two disagree.
        """
        return self._config.run

    @property
    def model_name(self) -> str:
        """Simulation code name (e.g. ``"iPIC3D"``)."""
        return self._config.model_name

    @property
    def model_type(self) -> str:
        """Model type (e.g. ``"PIC"``, ``"MHD"``)."""
        return self._config.model_type

    @property
    def grid(self) -> GridInfo:
        """Grid metadata."""
        return self._config.grid

    @property
    def normalization(self) -> Normalization:
        """Unit normalization."""
        return self._config.normalization

    @property
    def species(self) -> tuple[SpeciesInfo, ...]:
        """Species definitions."""
        return self._config.species

    @property
    def physics(self) -> PhysicsParams:
        """Physics parameters."""
        return self._config.physics

    @property
    def shrink_factor(self) -> float:
        """Domain shrink factor (1.0 when unscaled)."""
        scaling = self._config.metadata.get("scaling", {})
        return float(scaling.get("shrink_factor", 1.0))

    @property
    def probe_results(self) -> tuple[ProbeResult, ...] | None:
        """Auto-detection probe results, or ``None`` if reader was explicit."""
        return self._probe_results

    @property
    def steps(self) -> list[int]:
        """Available timestep indices (cached after first access)."""
        if self._steps is None:
            self._steps = self._reader.available_timesteps(self._path)
        return self._steps

    def refresh_steps(self) -> list[int]:
        """Re-scan for available timesteps, clearing the cache."""
        self._steps = None
        return self.steps

    @property
    def first_step(self) -> int:
        """First available timestep index."""
        return self.steps[0]

    @property
    def last_step(self) -> int:
        """Last available timestep index."""
        return self.steps[-1]

    def read(
        self,
        step: int,
        *,
        fields: Iterable[str] | None = None,
        strict_fields: bool = True,
        **kwargs: Any,  # noqa: ANN401 — reader-specific params (e.g. target_resolution)
    ) -> FieldDataset:
        """Read field data for a single timestep.

        Parameters
        ----------
        step : int
            Timestep index.
        fields : Iterable[str] | None
            When given, only these fields are read.  Accepts canonical
            names (``"B_1"``) and geometry aliases (``"Bx"``).  Readers
            that support selective I/O skip unwanted datasets; others
            read all fields then filter.
        strict_fields : bool
            When ``True`` (default), raise
            [`UnknownFieldError`][pypic.exceptions.UnknownFieldError] if any name in
            *fields* matches no loaded field — the project's fail-loud
            rule for selection APIs.  Pass ``False`` only for
            exploratory scripts where some requested names are
            optional; missing names are then logged as warnings.
        **kwargs
            Forwarded to readers that accept extra parameters
            (e.g. ``target_resolution`` for BATSRUS).

        Returns
        -------
        FieldDataset

        Raises
        ------
        KeyError
            If *strict_fields* is true (the default) and any requested
            field name yielded nothing.
        """
        if fields is None and not kwargs:
            return self._attach_config_provenance(
                self._reader.read_timestep(self._path, step)
            )

        canonical: set[str] | None = None
        alias_map: dict[str, str] = {}
        if fields is not None:
            alias_map = _default_aliases(self._config.grid.geometry)
            canonical = set()
            for name in fields:
                canonical |= _expand_requested(name, alias_map)

        if supports_selective_read(self._reader):
            ds = self._reader.read_timestep(  # type: ignore[call-arg]
                self._path,
                step,
                fields=canonical,
                **kwargs,
            )
        else:
            if kwargs:
                msg = (
                    f"{type(self._reader).__name__} takes no read options; "
                    f"got {sorted(kwargs)}"
                )
                raise TypeError(msg)
            ds = self._reader.read_timestep(self._path, step)
            if canonical is not None:
                # Filter to names actually present before narrowing the
                # dataset; the post-read check below reports unmatched
                # names uniformly for both reader paths.
                available = set(ds.field_names())
                ds = ds.select_fields(canonical & available)

        # Warn (or raise, if strict_fields) for any user-requested name
        # that yielded no loaded fields.
        if fields is not None:
            loaded = set(ds.field_names())
            missing = [
                name
                for name in fields
                if not loaded & _expand_requested(name, alias_map)
            ]
            if missing:
                if strict_fields:
                    msg = (
                        f"fields={list(fields)!r}: "
                        f"{missing!r} matched no fields in the dataset. "
                        f"Available: {sorted(loaded)!r}"
                    )
                    raise UnknownFieldError(msg)
                for name in missing:
                    log.warning(
                        "fields=%r: %r matched no fields in the dataset",
                        list(fields),
                        name,
                    )

        return self._attach_config_provenance(ds)

    def _attach_config_provenance(self, fds: FieldDataset) -> FieldDataset:
        """Stamp ``[run]`` and verbatim ``simulation.toml`` onto ``fds.metadata``.

        Lifted to top-level ``attrs.run`` / ``attrs.simulation_toml`` at
        Zarr write time by ``encode_pypic_attrs`` (schema.md §4.2).
        No-op when the config carries neither (legacy/synthetic configs
        assembled outside the schema path).  Existing reader-supplied
        values win — readers may have stamped a code-specific run object
        that pypic shouldn't overwrite.
        """
        cfg = self._config
        raw_toml = cfg.metadata.get("simulation_toml")
        run = cfg.run
        if run is None and raw_toml is None:
            return fds
        new_meta = dict(fds.metadata)
        if run is not None:
            new_meta.setdefault("run", run)
        if raw_toml is not None:
            new_meta.setdefault("simulation_toml", raw_toml)
        # Internal mutation: read_timestep returns a fresh FieldDataset
        # each call, so the caller has no prior reference to invalidate.
        # Cheaper than a full reconstruction (alias resolution etc.).
        fds._metadata = new_meta  # noqa: SLF001 — see above
        return fds

    def available_fields(self, step: int) -> list[str]:
        """List canonical field names at *step* without loading arrays.

        Uses the reader's lightweight probe when available (via
        `FieldListingReader`);
        otherwise falls back to a full `read` and extracts
        [`field_names`][pypic.dataset.FieldDataset.field_names].

        Parameters
        ----------
        step : int
            Timestep index.

        Returns
        -------
        list[str]
            Sorted canonical field names.
        """
        from pypic.readers._protocols import FieldListingReader

        if isinstance(self._reader, FieldListingReader):
            return self._reader.available_fields(self._path, step)
        return sorted(self.read(step).field_names())

    def available_fields_mapping(self, step: int) -> dict[str, str | None]:
        """Map canonical field names to native (on-disk) names at *step*.

        Uses the reader's lightweight probe when available; otherwise
        falls back to `available_fields` with ``None`` for all
        native names (native mapping unknown without reader support).

        Parameters
        ----------
        step : int
            Timestep index.

        Returns
        -------
        dict[str, str | None]
            Canonical name → native name, or ``None`` for computed
            fields or when the reader has no mapping support.
        """
        from pypic.readers._protocols import FieldListingReader

        if isinstance(self._reader, FieldListingReader):
            return self._reader.available_fields_mapping(self._path, step)
        return dict.fromkeys(self.available_fields(step))

    @property
    def auxiliary_names(self) -> list[str]:
        """Names of available auxiliary datasets, or ``[]`` if unsupported."""
        from pypic.readers._protocols import AuxiliaryDataReader

        if isinstance(self._reader, AuxiliaryDataReader):
            return self._reader.available_auxiliary(self._path)
        return []

    @property
    def particle_steps(self) -> list[int]:
        """Timesteps with particle data, or ``[]`` if unsupported."""
        from pypic.readers._protocols import ParticleDataReader

        if isinstance(self._reader, ParticleDataReader):
            return self._reader.available_particle_steps(self._path)
        return []

    def particles(
        self,
        step: int,
        species: int,
        *,
        columns: Iterable[str] | None = None,
    ) -> ParticleData:
        """Load particle data for a species at a timestep.

        Parameters
        ----------
        step : int
            Timestep index.
        species : int
            Zero-based species index.
        columns : Iterable[str] | None
            Subset of ``{"position", "velocity"}`` to load.
            ``None`` loads all.  Per-particle ``weight`` and the scalar
            ``species_charge``/``species_mass`` are always populated
            (canonical layout, ``docs/schema.md``).

        Returns
        -------
        ParticleData

        Raises
        ------
        TypeError
            If the reader does not support particle data.
        UnknownFieldError
            If *columns* names a column the reader does not recognize.
        """
        from pypic.readers._protocols import ParticleDataReader

        if isinstance(self._reader, ParticleDataReader):
            return self._reader.read_particles(
                self._path, step, species, columns=columns
            )
        msg = (
            f"Reader {type(self._reader).__name__!r} does not support "
            f"particle data (missing ParticleDataReader protocol)"
        )
        raise TypeError(msg)

    def auxiliary(self, name: str) -> TabularData:
        """Load a named auxiliary dataset.

        Parameters
        ----------
        name : str
            Dataset name (e.g. ``"conserved_quantities"``).

        Returns
        -------
        TabularData

        Raises
        ------
        TypeError
            If the reader does not support auxiliary data.
        """
        from pypic.readers._protocols import AuxiliaryDataReader

        if isinstance(self._reader, AuxiliaryDataReader):
            return self._reader.load_auxiliary(self._path, name)
        msg = (
            f"Reader {type(self._reader).__name__!r} does not support "
            f"auxiliary data (missing AuxiliaryDataReader protocol)"
        )
        raise TypeError(msg)

    def describe(self) -> str:
        """Multi-line summary of the simulation (no I/O).

        Returns
        -------
        str
        """
        dims = " x ".join(str(d) for d in self.grid.dimensions)
        spacing = " x ".join(f"{s:.2f}" for s in self.grid.spacing)
        geom = self.grid.geometry.type.value
        lines = [
            f"Simulation: {self.model_name} ({self.model_type})",
            f"  Path:    {self._path}",
        ]
        run = self._config.run
        if run is not None:
            label = run.id or run.name
            if run.id is not None:
                label = f"{run.id} ({run.name})"
            lines.append(f"  Run:     {label}")
        lines += [
            f"  Grid:    {dims} ({geom})",
            f"  Spacing: {spacing}",
            # Without this the only way to learn the data has no SI anchor
            # is to call in_si() and read the exception.
            f"  Units:   {self._config.normalization.summary()}",
        ]
        if self._config.species:
            species_parts = []
            for sp in self._config.species:
                if sp.charge_to_mass is not None:
                    species_parts.append(f"{sp.name} (q/m={sp.charge_to_mass})")
                else:
                    species_parts.append(sp.name)
            lines.append(f"  Species: {', '.join(species_parts)}")
        if self._steps is not None:
            if self._steps:
                step_range = f"{self._steps[0]}..{self._steps[-1]}"
            else:
                step_range = "empty"
            lines.append(f"  Steps:   {len(self._steps)} [{step_range}]")
        return "\n".join(lines)

    def __iter__(self) -> Iterator[SimulationReader | SimulationConfig]:
        """Support ``reader, config = open_simulation(path)``."""
        yield self._reader
        yield self._config

    def __repr__(self) -> str:
        dims = "x".join(str(d) for d in self.grid.dimensions)
        n_steps = len(self.steps) if self._steps is not None else "?"
        return (
            f"Simulation({self.model_name!r}, {self.model_type}, "
            f"grid={dims}, steps={n_steps})"
        )

reader property

The underlying reader instance.

config property

Full simulation configuration.

path property

Data directory.

run property

[run] provenance from the merged configuration.

Resolved once when the simulation is opened. A per-file /run/ group is attached per dataset instead, so sim.read(step).metadata["run"] is the authoritative record for a given timestep when the two disagree.

model_name property

Simulation code name (e.g. "iPIC3D").

model_type property

Model type (e.g. "PIC", "MHD").

grid property

Grid metadata.

normalization property

Unit normalization.

species property

Species definitions.

physics property

Physics parameters.

shrink_factor property

Domain shrink factor (1.0 when unscaled).

probe_results property

Auto-detection probe results, or None if reader was explicit.

steps property

Available timestep indices (cached after first access).

first_step property

First available timestep index.

last_step property

Last available timestep index.

auxiliary_names property

Names of available auxiliary datasets, or [] if unsupported.

particle_steps property

Timesteps with particle data, or [] if unsupported.

refresh_steps()

Re-scan for available timesteps, clearing the cache.

Source code in src/pypic/readers/_registry.py
305
306
307
308
def refresh_steps(self) -> list[int]:
    """Re-scan for available timesteps, clearing the cache."""
    self._steps = None
    return self.steps

read(step, *, fields=None, strict_fields=True, **kwargs)

Read field data for a single timestep.

Parameters:

Name Type Description Default
step int

Timestep index.

required
fields Iterable[str] | None

When given, only these fields are read. Accepts canonical names ("B_1") and geometry aliases ("Bx"). Readers that support selective I/O skip unwanted datasets; others read all fields then filter.

None
strict_fields bool

When True (default), raise UnknownFieldError if any name in fields matches no loaded field — the project's fail-loud rule for selection APIs. Pass False only for exploratory scripts where some requested names are optional; missing names are then logged as warnings.

True
**kwargs Any

Forwarded to readers that accept extra parameters (e.g. target_resolution for BATSRUS).

{}

Returns:

Type Description
FieldDataset

Raises:

Type Description
KeyError

If strict_fields is true (the default) and any requested field name yielded nothing.

Source code in src/pypic/readers/_registry.py
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
def read(
    self,
    step: int,
    *,
    fields: Iterable[str] | None = None,
    strict_fields: bool = True,
    **kwargs: Any,  # noqa: ANN401 — reader-specific params (e.g. target_resolution)
) -> FieldDataset:
    """Read field data for a single timestep.

    Parameters
    ----------
    step : int
        Timestep index.
    fields : Iterable[str] | None
        When given, only these fields are read.  Accepts canonical
        names (``"B_1"``) and geometry aliases (``"Bx"``).  Readers
        that support selective I/O skip unwanted datasets; others
        read all fields then filter.
    strict_fields : bool
        When ``True`` (default), raise
        [`UnknownFieldError`][pypic.exceptions.UnknownFieldError] if any name in
        *fields* matches no loaded field — the project's fail-loud
        rule for selection APIs.  Pass ``False`` only for
        exploratory scripts where some requested names are
        optional; missing names are then logged as warnings.
    **kwargs
        Forwarded to readers that accept extra parameters
        (e.g. ``target_resolution`` for BATSRUS).

    Returns
    -------
    FieldDataset

    Raises
    ------
    KeyError
        If *strict_fields* is true (the default) and any requested
        field name yielded nothing.
    """
    if fields is None and not kwargs:
        return self._attach_config_provenance(
            self._reader.read_timestep(self._path, step)
        )

    canonical: set[str] | None = None
    alias_map: dict[str, str] = {}
    if fields is not None:
        alias_map = _default_aliases(self._config.grid.geometry)
        canonical = set()
        for name in fields:
            canonical |= _expand_requested(name, alias_map)

    if supports_selective_read(self._reader):
        ds = self._reader.read_timestep(  # type: ignore[call-arg]
            self._path,
            step,
            fields=canonical,
            **kwargs,
        )
    else:
        if kwargs:
            msg = (
                f"{type(self._reader).__name__} takes no read options; "
                f"got {sorted(kwargs)}"
            )
            raise TypeError(msg)
        ds = self._reader.read_timestep(self._path, step)
        if canonical is not None:
            # Filter to names actually present before narrowing the
            # dataset; the post-read check below reports unmatched
            # names uniformly for both reader paths.
            available = set(ds.field_names())
            ds = ds.select_fields(canonical & available)

    # Warn (or raise, if strict_fields) for any user-requested name
    # that yielded no loaded fields.
    if fields is not None:
        loaded = set(ds.field_names())
        missing = [
            name
            for name in fields
            if not loaded & _expand_requested(name, alias_map)
        ]
        if missing:
            if strict_fields:
                msg = (
                    f"fields={list(fields)!r}: "
                    f"{missing!r} matched no fields in the dataset. "
                    f"Available: {sorted(loaded)!r}"
                )
                raise UnknownFieldError(msg)
            for name in missing:
                log.warning(
                    "fields=%r: %r matched no fields in the dataset",
                    list(fields),
                    name,
                )

    return self._attach_config_provenance(ds)

available_fields(step)

List canonical field names at step without loading arrays.

Uses the reader's lightweight probe when available (via FieldListingReader); otherwise falls back to a full read and extracts field_names.

Parameters:

Name Type Description Default
step int

Timestep index.

required

Returns:

Type Description
list[str]

Sorted canonical field names.

Source code in src/pypic/readers/_registry.py
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
def available_fields(self, step: int) -> list[str]:
    """List canonical field names at *step* without loading arrays.

    Uses the reader's lightweight probe when available (via
    `FieldListingReader`);
    otherwise falls back to a full `read` and extracts
    [`field_names`][pypic.dataset.FieldDataset.field_names].

    Parameters
    ----------
    step : int
        Timestep index.

    Returns
    -------
    list[str]
        Sorted canonical field names.
    """
    from pypic.readers._protocols import FieldListingReader

    if isinstance(self._reader, FieldListingReader):
        return self._reader.available_fields(self._path, step)
    return sorted(self.read(step).field_names())

available_fields_mapping(step)

Map canonical field names to native (on-disk) names at step.

Uses the reader's lightweight probe when available; otherwise falls back to available_fields with None for all native names (native mapping unknown without reader support).

Parameters:

Name Type Description Default
step int

Timestep index.

required

Returns:

Type Description
dict[str, str | None]

Canonical name → native name, or None for computed fields or when the reader has no mapping support.

Source code in src/pypic/readers/_registry.py
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
def available_fields_mapping(self, step: int) -> dict[str, str | None]:
    """Map canonical field names to native (on-disk) names at *step*.

    Uses the reader's lightweight probe when available; otherwise
    falls back to `available_fields` with ``None`` for all
    native names (native mapping unknown without reader support).

    Parameters
    ----------
    step : int
        Timestep index.

    Returns
    -------
    dict[str, str | None]
        Canonical name → native name, or ``None`` for computed
        fields or when the reader has no mapping support.
    """
    from pypic.readers._protocols import FieldListingReader

    if isinstance(self._reader, FieldListingReader):
        return self._reader.available_fields_mapping(self._path, step)
    return dict.fromkeys(self.available_fields(step))

particles(step, species, *, columns=None)

Load particle data for a species at a timestep.

Parameters:

Name Type Description Default
step int

Timestep index.

required
species int

Zero-based species index.

required
columns Iterable[str] | None

Subset of {"position", "velocity"} to load. None loads all. Per-particle weight and the scalar species_charge/species_mass are always populated (canonical layout, docs/schema.md).

None

Returns:

Type Description
ParticleData

Raises:

Type Description
TypeError

If the reader does not support particle data.

UnknownFieldError

If columns names a column the reader does not recognize.

Source code in src/pypic/readers/_registry.py
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
def particles(
    self,
    step: int,
    species: int,
    *,
    columns: Iterable[str] | None = None,
) -> ParticleData:
    """Load particle data for a species at a timestep.

    Parameters
    ----------
    step : int
        Timestep index.
    species : int
        Zero-based species index.
    columns : Iterable[str] | None
        Subset of ``{"position", "velocity"}`` to load.
        ``None`` loads all.  Per-particle ``weight`` and the scalar
        ``species_charge``/``species_mass`` are always populated
        (canonical layout, ``docs/schema.md``).

    Returns
    -------
    ParticleData

    Raises
    ------
    TypeError
        If the reader does not support particle data.
    UnknownFieldError
        If *columns* names a column the reader does not recognize.
    """
    from pypic.readers._protocols import ParticleDataReader

    if isinstance(self._reader, ParticleDataReader):
        return self._reader.read_particles(
            self._path, step, species, columns=columns
        )
    msg = (
        f"Reader {type(self._reader).__name__!r} does not support "
        f"particle data (missing ParticleDataReader protocol)"
    )
    raise TypeError(msg)

auxiliary(name)

Load a named auxiliary dataset.

Parameters:

Name Type Description Default
name str

Dataset name (e.g. "conserved_quantities").

required

Returns:

Type Description
TabularData

Raises:

Type Description
TypeError

If the reader does not support auxiliary data.

Source code in src/pypic/readers/_registry.py
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
def auxiliary(self, name: str) -> TabularData:
    """Load a named auxiliary dataset.

    Parameters
    ----------
    name : str
        Dataset name (e.g. ``"conserved_quantities"``).

    Returns
    -------
    TabularData

    Raises
    ------
    TypeError
        If the reader does not support auxiliary data.
    """
    from pypic.readers._protocols import AuxiliaryDataReader

    if isinstance(self._reader, AuxiliaryDataReader):
        return self._reader.load_auxiliary(self._path, name)
    msg = (
        f"Reader {type(self._reader).__name__!r} does not support "
        f"auxiliary data (missing AuxiliaryDataReader protocol)"
    )
    raise TypeError(msg)

describe()

Multi-line summary of the simulation (no I/O).

Returns:

Type Description
str
Source code in src/pypic/readers/_registry.py
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
def describe(self) -> str:
    """Multi-line summary of the simulation (no I/O).

    Returns
    -------
    str
    """
    dims = " x ".join(str(d) for d in self.grid.dimensions)
    spacing = " x ".join(f"{s:.2f}" for s in self.grid.spacing)
    geom = self.grid.geometry.type.value
    lines = [
        f"Simulation: {self.model_name} ({self.model_type})",
        f"  Path:    {self._path}",
    ]
    run = self._config.run
    if run is not None:
        label = run.id or run.name
        if run.id is not None:
            label = f"{run.id} ({run.name})"
        lines.append(f"  Run:     {label}")
    lines += [
        f"  Grid:    {dims} ({geom})",
        f"  Spacing: {spacing}",
        # Without this the only way to learn the data has no SI anchor
        # is to call in_si() and read the exception.
        f"  Units:   {self._config.normalization.summary()}",
    ]
    if self._config.species:
        species_parts = []
        for sp in self._config.species:
            if sp.charge_to_mass is not None:
                species_parts.append(f"{sp.name} (q/m={sp.charge_to_mass})")
            else:
                species_parts.append(sp.name)
        lines.append(f"  Species: {', '.join(species_parts)}")
    if self._steps is not None:
        if self._steps:
            step_range = f"{self._steps[0]}..{self._steps[-1]}"
        else:
            step_range = "empty"
        lines.append(f"  Steps:   {len(self._steps)} [{step_range}]")
    return "\n".join(lines)

__iter__()

Support reader, config = open_simulation(path).

Source code in src/pypic/readers/_registry.py
627
628
629
630
def __iter__(self) -> Iterator[SimulationReader | SimulationConfig]:
    """Support ``reader, config = open_simulation(path)``."""
    yield self._reader
    yield self._config

SimpleReader

Bases: ReaderBase

Minimal HDF5 reader implementing the SimulationReader protocol.

Reads HDF5 files where field arrays live under a configurable group (default "fields/"). Grid metadata is resolved in priority order:

  1. HDF5 grid/ group attributes (self-describing files).
  2. Explicit grid parameter.
  3. Grid from config, if provided.

You only need to supply what the HDF5 files don't already contain.

Parameters:

Name Type Description Default
file_pattern str

Python format string with a {step} placeholder. Used both to locate files (read_timestep) and to scan for available timesteps.

'output_{step:06d}.h5'
field_map dict[str, str] | None

Mapping from native HDF5 dataset names to canonical field names (e.g. {"Bx_code": "B_1"}). When None, dataset names are assumed to already be canonical.

None
grid GridInfo | None

Explicit grid metadata. Takes precedence over config but is overridden by HDF5 grid/ attributes when present.

None
normalization Normalization | None

Unit normalization. Defaults to Normalization.undeclared(), under which dimensional SI conversion raises rather than silently returning code units.

None
config SimulationConfig | None

Full simulation configuration. Used as a fallback for grid, normalization, species, and physics when individual params are not given.

None
fields_group str

HDF5 group containing field datasets. Use "" for files that store datasets at the root level.

'fields'

Examples:

>>> from pathlib import Path
>>> reader = SimpleReader(file_pattern="out_{step:04d}.h5")
>>> reader.file_pattern
'out_{step:04d}.h5'
Source code in src/pypic/readers/_simple.py
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
class SimpleReader(ReaderBase):
    r"""Minimal HDF5 reader implementing the ``SimulationReader`` protocol.

    Reads HDF5 files where field arrays live under a configurable group
    (default ``"fields/"``).  Grid metadata is resolved in priority
    order:

    1. HDF5 ``grid/`` group attributes (self-describing files).
    2. Explicit *grid* parameter.
    3. Grid from *config*, if provided.

    You only need to supply what the HDF5 files don't already contain.

    Parameters
    ----------
    file_pattern : str
        Python format string with a ``{step}`` placeholder.
        Used both to locate files (``read_timestep``) and to scan
        for available timesteps.
    field_map : dict[str, str] | None
        Mapping from native HDF5 dataset names to canonical field
        names (e.g. ``{"Bx_code": "B_1"}``).  When ``None``, dataset
        names are assumed to already be canonical.
    grid : GridInfo | None
        Explicit grid metadata.  Takes precedence over *config* but
        is overridden by HDF5 ``grid/`` attributes when present.
    normalization : Normalization | None
        Unit normalization.  Defaults to ``Normalization.undeclared()``,
        under which dimensional SI conversion raises rather than
        silently returning code units.
    config : SimulationConfig | None
        Full simulation configuration.  Used as a fallback for grid,
        normalization, species, and physics when individual params
        are not given.
    fields_group : str
        HDF5 group containing field datasets.  Use ``""`` for files
        that store datasets at the root level.

    Examples
    --------
    >>> from pathlib import Path
    >>> reader = SimpleReader(file_pattern="out_{step:04d}.h5")
    >>> reader.file_pattern
    'out_{step:04d}.h5'
    """

    def __init__(
        self,
        *,
        file_pattern: str = "output_{step:06d}.h5",
        field_map: dict[str, str] | None = None,
        grid: GridInfo | None = None,
        normalization: Normalization | None = None,
        config: SimulationConfig | None = None,
        fields_group: str = "fields",
    ) -> None:
        if config is not None:
            config = copy.replace(
                config,
                grid=grid or config.grid,
                normalization=normalization or config.normalization,
            )
        elif grid is not None:
            config = SimulationConfig(
                model_name="unknown",
                model_type="PIC",
                grid=grid,
                normalization=normalization or Normalization.undeclared(),
            )
        super().__init__(config)
        self._file_pattern = file_pattern
        self._field_map = dict(field_map) if field_map else None
        self._normalization = normalization
        self._fields_group = fields_group
        self._glob, self._regex = _parse_file_pattern(file_pattern)

    @property
    def file_pattern(self) -> str:
        """The file naming pattern."""
        return self._file_pattern

    def available_timesteps(self, path: Path) -> list[int]:
        """Return sorted timestep indices found in *path*.

        Scans for files matching ``file_pattern`` and extracts the
        step number from each filename.

        Parameters
        ----------
        path : Path
            Directory to scan.

        Returns
        -------
        list[int]
            Sorted step numbers.
        """
        steps: list[int] = []
        for entry in path.glob(self._glob):
            m = self._regex.match(entry.name)
            if m:
                steps.append(int(m.group(1)))
        return sorted(steps)

    def available_fields_mapping(self, path: Path, step: int) -> dict[str, str | None]:
        """Map canonical field names to native (on-disk) names at *step*.

        Opens the HDF5 file and lists dataset names in the fields
        group, applying ``field_map`` if configured.

        Parameters
        ----------
        path : Path
            Directory containing the data files.
        step : int
            Timestep index.

        Returns
        -------
        dict[str, str | None]
            Canonical → native name.
        """
        filepath = path / self._file_pattern.format(step=step)
        with h5py.File(filepath, "r") as f:
            group = self._resolve_fields_group(f)
            native_names = [
                name
                for name in group
                if isinstance(group[name], h5py.Dataset) and group[name].ndim >= 2
            ]
        if self._field_map is not None:
            fm = self._field_map
            return {fm.get(n, n): n for n in native_names}
        return {n: n for n in native_names}

    def read_timestep(
        self,
        path: Path,
        step: int,
        *,
        fields: Iterable[str] | None = None,
    ) -> FieldDataset:
        """Read field data for a single timestep.

        Parameters
        ----------
        path : Path
            Directory containing the data files.
        step : int
            Timestep index.
        fields : Iterable[str] | None
            When given, only read these canonical field names.

        Returns
        -------
        FieldDataset
            Field data with canonical names.

        Raises
        ------
        FileNotFoundError
            If the expected file does not exist (default I/O path).
        ValueError
            If grid metadata is missing from both the file
            and ``config``.
        """
        filepath = path / self._file_pattern.format(step=step)
        is_custom = self._is_read_raw_overridden()

        if not is_custom and not filepath.exists():
            msg = f"File not found: {filepath}"
            raise FileNotFoundError(msg)

        canonical_set = set(fields) if fields is not None else None
        raw = self._read_raw(filepath, fields=canonical_set)
        field_data = self._apply_field_map(raw)

        if is_custom:
            if canonical_set is not None:
                field_data = {k: v for k, v in field_data.items() if k in canonical_set}
            config = self._resolve_config(None, filepath)
            return self._finish(
                _to_code_units(field_data, config), step=step, config=config
            )

        with h5py.File(filepath, "r") as f:
            config = self._resolve_config(_read_grid_attrs(f), filepath)
            file_step, time = self._snapshot(f, step)
            run = read_run_group(f)
        return self._finish(
            _to_code_units(field_data, config),
            step=file_step,
            time=time,
            config=config,
            # The file is more specific than the directory, so a /run/
            # group wins over a sibling simulation.toml via _finish's
            # extra-beats-config merge.
            extra=None if run is None else {"run": run},
        )

    def _read_raw(
        self,
        filepath: Path,
        *,
        fields: set[str] | None = None,
    ) -> dict[str, FloatArray]:
        r"""Read raw arrays from a single file.

        Returns arrays keyed by **native** field names (before
        ``field_map`` is applied).  The parent class handles renaming,
        grid resolution, and ``FieldDataset`` construction.

        Override this method to support non-standard file formats
        (binary, NetCDF, transposed HDF5, multi-file, etc.).  When
        overridden, grid metadata must come from ``grid=`` or
        ``config=`` — the framework will not try to read HDF5
        attributes.

        Parameters
        ----------
        filepath : Path
            Full path to the data file.
        fields : set[str] | None
            Canonical field names to read.  When ``None``, read all.
            Only used by the base-class implementation; subclass
            overrides may ignore this parameter.

        Returns
        -------
        dict[str, FloatArray]
            Arrays keyed by native (pre-mapping) field names.
        """
        with h5py.File(filepath, "r") as f:
            group = self._resolve_fields_group(f)
            if self._field_map is not None:
                return self._read_mapped_native(group, fields=fields)
            return self._read_all_arrays(group, fields=fields)

    def _apply_field_map(
        self,
        raw: dict[str, FloatArray],
    ) -> dict[str, FloatArray]:
        """Rename native field names to canonical using ``field_map``."""
        if self._field_map is None:
            return raw
        return {self._field_map.get(k, k): v for k, v in raw.items()}

    def _is_read_raw_overridden(self) -> bool:
        """Check whether a subclass overrides ``_read_raw``."""
        # Both sides are this class's own method; comparing identity against
        # the base is the only way to detect a subclass override.
        return type(self)._read_raw is not SimpleReader._read_raw  # noqa: SLF001

    def _resolve_fields_group(
        self,
        f: h5py.File,
    ) -> h5py.Group | h5py.File:
        """Resolve the HDF5 group containing field datasets.

        Tries ``fields_group`` first; falls back to root when the
        group is ``"fields"`` and doesn't exist.

        Raises
        ------
        TypeError
            If *fields_group* resolves to a Dataset or other non-Group
            HDF5 object — surfaces a misconfiguration with a clear
            message instead of crashing downstream during iteration.
        """
        if not self._fields_group:
            return f
        if self._fields_group in f:
            grp = f[self._fields_group]
            if not isinstance(grp, h5py.Group):
                msg = (
                    f"{self._fields_group!r} in {f.filename} is a "
                    f"{type(grp).__name__}, expected a Group. Configure "
                    f"fields_group= to point at an HDF5 Group containing "
                    f"field datasets."
                )
                raise TypeError(msg)
            return grp
        if self._fields_group != "fields":
            msg = (
                f"Group {self._fields_group!r} not found in "
                f"{f.filename}. Available: {list(f.keys())}"
            )
            raise KeyError(msg)
        # "fields" not found → fall back to root silently
        return f

    def _read_mapped_native(
        self,
        group: h5py.Group | h5py.File,
        *,
        fields: set[str] | None = None,
    ) -> dict[str, FloatArray]:
        """Read datasets from the group, return native names.

        Mapped fields are included unconditionally.  Unmapped fields
        are included only when they have ndim >= 2 (skipping scalars
        and 1-D coordinate arrays).

        When *fields* is given (canonical names), only datasets whose
        canonical name is in the set are read.
        """
        assert self._field_map is not None
        mapped_native = set(self._field_map.keys())
        result: dict[str, FloatArray] = {}
        for name in group:
            ds = group[name]
            if not isinstance(ds, h5py.Dataset):
                continue
            if name not in mapped_native and ds.ndim < 2:
                continue
            if fields is not None:
                canonical = self._field_map.get(name, name)
                if canonical not in fields:
                    continue
            result[name] = np.asarray(ds, dtype=np.float64)
        return result

    def _read_all_arrays(
        self,
        group: h5py.Group | h5py.File,
        *,
        fields: set[str] | None = None,
    ) -> dict[str, FloatArray]:
        """Read datasets with ndim >= 2 (skip scalars, coords).

        When *fields* is given (canonical names — same as dataset names
        when no ``field_map``), only matching datasets are read.
        """
        result: dict[str, FloatArray] = {}
        for name in group:
            ds = group[name]
            if not isinstance(ds, h5py.Dataset):
                continue
            if ds.ndim < 2:
                continue
            if fields is not None and name not in fields:
                continue
            result[name] = np.asarray(ds, dtype=np.float64)
        return result

    def _resolve_config(
        self, file_grid: GridInfo | None, filename: Path
    ) -> SimulationConfig:
        """Resolve the config for one file: its ``grid/`` attributes win over ours."""
        config = self._sim_config
        if file_grid is None:
            if config is None:
                msg = (
                    f"No grid for {filename.name}: pass grid=GridInfo(...) "
                    "or config=SimulationConfig(...)."
                )
                raise ValueError(msg)
            return config
        if config is None:
            return SimulationConfig(
                model_name="unknown",
                model_type="PIC",
                grid=file_grid,
                normalization=self._normalization or Normalization.undeclared(),
            )
        return copy.replace(config, grid=file_grid)

    @staticmethod
    def _snapshot(f: h5py.File, step: int) -> tuple[int, float | None]:
        """Step and time from the root attributes; the filename's step otherwise."""
        file_step = int(f.attrs["step"]) if "step" in f.attrs else step
        time = float(f.attrs["time"]) if "time" in f.attrs else None
        return file_step, time

file_pattern property

The file naming pattern.

available_timesteps(path)

Return sorted timestep indices found in path.

Scans for files matching file_pattern and extracts the step number from each filename.

Parameters:

Name Type Description Default
path Path

Directory to scan.

required

Returns:

Type Description
list[int]

Sorted step numbers.

Source code in src/pypic/readers/_simple.py
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
def available_timesteps(self, path: Path) -> list[int]:
    """Return sorted timestep indices found in *path*.

    Scans for files matching ``file_pattern`` and extracts the
    step number from each filename.

    Parameters
    ----------
    path : Path
        Directory to scan.

    Returns
    -------
    list[int]
        Sorted step numbers.
    """
    steps: list[int] = []
    for entry in path.glob(self._glob):
        m = self._regex.match(entry.name)
        if m:
            steps.append(int(m.group(1)))
    return sorted(steps)

available_fields_mapping(path, step)

Map canonical field names to native (on-disk) names at step.

Opens the HDF5 file and lists dataset names in the fields group, applying field_map if configured.

Parameters:

Name Type Description Default
path Path

Directory containing the data files.

required
step int

Timestep index.

required

Returns:

Type Description
dict[str, str | None]

Canonical → native name.

Source code in src/pypic/readers/_simple.py
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
def available_fields_mapping(self, path: Path, step: int) -> dict[str, str | None]:
    """Map canonical field names to native (on-disk) names at *step*.

    Opens the HDF5 file and lists dataset names in the fields
    group, applying ``field_map`` if configured.

    Parameters
    ----------
    path : Path
        Directory containing the data files.
    step : int
        Timestep index.

    Returns
    -------
    dict[str, str | None]
        Canonical → native name.
    """
    filepath = path / self._file_pattern.format(step=step)
    with h5py.File(filepath, "r") as f:
        group = self._resolve_fields_group(f)
        native_names = [
            name
            for name in group
            if isinstance(group[name], h5py.Dataset) and group[name].ndim >= 2
        ]
    if self._field_map is not None:
        fm = self._field_map
        return {fm.get(n, n): n for n in native_names}
    return {n: n for n in native_names}

read_timestep(path, step, *, fields=None)

Read field data for a single timestep.

Parameters:

Name Type Description Default
path Path

Directory containing the data files.

required
step int

Timestep index.

required
fields Iterable[str] | None

When given, only read these canonical field names.

None

Returns:

Type Description
FieldDataset

Field data with canonical names.

Raises:

Type Description
FileNotFoundError

If the expected file does not exist (default I/O path).

ValueError

If grid metadata is missing from both the file and config.

Source code in src/pypic/readers/_simple.py
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
def read_timestep(
    self,
    path: Path,
    step: int,
    *,
    fields: Iterable[str] | None = None,
) -> FieldDataset:
    """Read field data for a single timestep.

    Parameters
    ----------
    path : Path
        Directory containing the data files.
    step : int
        Timestep index.
    fields : Iterable[str] | None
        When given, only read these canonical field names.

    Returns
    -------
    FieldDataset
        Field data with canonical names.

    Raises
    ------
    FileNotFoundError
        If the expected file does not exist (default I/O path).
    ValueError
        If grid metadata is missing from both the file
        and ``config``.
    """
    filepath = path / self._file_pattern.format(step=step)
    is_custom = self._is_read_raw_overridden()

    if not is_custom and not filepath.exists():
        msg = f"File not found: {filepath}"
        raise FileNotFoundError(msg)

    canonical_set = set(fields) if fields is not None else None
    raw = self._read_raw(filepath, fields=canonical_set)
    field_data = self._apply_field_map(raw)

    if is_custom:
        if canonical_set is not None:
            field_data = {k: v for k, v in field_data.items() if k in canonical_set}
        config = self._resolve_config(None, filepath)
        return self._finish(
            _to_code_units(field_data, config), step=step, config=config
        )

    with h5py.File(filepath, "r") as f:
        config = self._resolve_config(_read_grid_attrs(f), filepath)
        file_step, time = self._snapshot(f, step)
        run = read_run_group(f)
    return self._finish(
        _to_code_units(field_data, config),
        step=file_step,
        time=time,
        config=config,
        # The file is more specific than the directory, so a /run/
        # group wins over a sibling simulation.toml via _finish's
        # extra-beats-config merge.
        extra=None if run is None else {"run": run},
    )

BATSRUSConfig dataclass

Parsed BATSRUS PARAM.in configuration.

Source code in src/pypic/readers/batsrus/_config.py
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
@dataclass(frozen=True, slots=True)
class BATSRUSConfig:
    """Parsed BATSRUS ``PARAM.in`` configuration."""

    description: str = ""
    coord_system: str = "simulation"
    n_root_blocks: tuple[int, int, int] = (1, 1, 1)
    domain_min: tuple[float, float, float] = (0.0, 0.0, 0.0)
    domain_max: tuple[float, float, float] = (1.0, 1.0, 1.0)
    gamma: float = 5.0 / 3.0
    io_units: str = ""
    normalization_type: str = ""
    body_radius: float | None = None
    body_density_dim: float | None = None
    body_temp_dim: float | None = None
    solar_wind: dict[str, float] = field(default_factory=dict)
    start_time: dict[str, int] = field(default_factory=dict)
    dt_fixed: float | None = None
    geometry: str = "cartesian"
    use_splitb: bool = False
    divb_method: str = ""
    outer_boundary: tuple[str, ...] = ()
    metadata: dict[str, Any] = field(default_factory=dict)

    def __post_init__(self) -> None:
        # Wrap mutable dicts in read-only proxies
        _freeze = MappingProxyType
        object.__setattr__(self, "solar_wind", _freeze(dict(self.solar_wind)))
        object.__setattr__(self, "start_time", _freeze(dict(self.start_time)))
        object.__setattr__(self, "metadata", _freeze(dict(self.metadata)))

BATSRUSOutputFormat

Bases: StrEnum

BATSRUS output file format.

Source code in src/pypic/readers/batsrus/__init__.py
41
42
43
44
45
46
class BATSRUSOutputFormat(StrEnum):
    """BATSRUS output file format."""

    HDF5 = "hdf5"
    IDL = "idl"
    OUT = "out"

BATSRUSReader

Bases: ReaderBase

Read BATSRUS simulation output in IDL or HDF5 format.

Supports three output formats:

  • Per-cell IDL (.h + *_pe*.idl): raw per-processor binary
  • Merged IDL (.out / .outs): postprocessed snapshot files
  • HDF5 BATL (.batl): block-structured HDF5

AMR grids are automatically regridded to the finest resolution.

Source code in src/pypic/readers/batsrus/_reader.py
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
class BATSRUSReader(ReaderBase):
    """Read BATSRUS simulation output in IDL or HDF5 format.

    Supports three output formats:

    - **Per-cell IDL** (``.h`` + ``*_pe*.idl``): raw per-processor binary
    - **Merged IDL** (``.out`` / ``.outs``): postprocessed snapshot files
    - **HDF5 BATL** (``.batl``): block-structured HDF5

    AMR grids are automatically regridded to the finest resolution.
    """

    def __init__(
        self,
        config: BATSRUSConfig,
        output_format: BATSRUSOutputFormat,
        prefix: str,
        *,
        geometry: str = "cartesian",
        sim_config: SimulationConfig | None = None,
    ) -> None:
        super().__init__(sim_config)
        self._config = config
        self._output_format = output_format
        self._prefix = prefix
        self._geometry = geometry

    def available_timesteps(self, path: Path) -> list[int]:
        """Return sorted list of available timestep indices."""
        from pypic.readers.batsrus import BATSRUSOutputFormat

        match self._output_format:
            case BATSRUSOutputFormat.HDF5:
                pattern = f"{self._prefix}*.batl"
            case BATSRUSOutputFormat.IDL:
                pattern = f"{self._prefix}*.h"
            case BATSRUSOutputFormat.OUT:
                pattern = f"{self._prefix}*.out"
            case _ as unreachable:
                assert_never(unreachable)

        steps = {extract_step_from_filename(f.name) for f in path.glob(pattern)}
        return sorted(step for step in steps if step is not None)

    def _build_var_mapping(self, var_names: tuple[str, ...]) -> dict[str, str | None]:
        """Map native BATSRUS var names to canonical, return canonical→native."""
        mapping: dict[str, str | None] = {}
        for vname in var_names:
            if vname in SKIP_FIELDS:
                continue
            canonical = FIELD_NAME_MAP.get(vname, vname)
            mapping[canonical] = vname
        return mapping

    def _get_var_names(self, path: Path, step: int) -> tuple[str, ...]:
        """Extract native variable names from header/metadata at *step*."""
        import h5py

        from pypic.readers.batsrus import BATSRUSOutputFormat

        match self._output_format:
            case BATSRUSOutputFormat.IDL:
                header_file = self._find_file(path, step, ".h")
                return parse_header(header_file).var_names
            case BATSRUSOutputFormat.HDF5:
                batl_file = self._find_file(path, step, ".batl")
                with h5py.File(batl_file, "r") as f:
                    return tuple(x.decode().strip() for x in f["NamePlotVar_V"][:])
            case BATSRUSOutputFormat.OUT:
                out_file = self._find_file(path, step, ".out")
                # Not parse_header: that reads the `#SECTION`-delimited .h
                # text format. A .out carries its own header, and binary
                # variants are not text at all.
                return read_out_header(out_file)[0]
            case _ as unreachable:
                assert_never(unreachable)

    def available_fields_mapping(self, path: Path, step: int) -> dict[str, str | None]:
        """Map canonical field names to native (on-disk) names at *step*.

        Parses file headers or HDF5 metadata without loading arrays.

        Parameters
        ----------
        path : Path
            Directory containing the simulation output.
        step : int
            Timestep index.

        Returns
        -------
        dict[str, str | None]
            Canonical → native name.
        """
        return self._build_var_mapping(self._get_var_names(path, step))

    def read_timestep(
        self,
        path: Path,
        step: int,
        *,
        fields: Iterable[str] | None = None,
        target_resolution: float | None = None,
    ) -> FieldDataset:
        """Read field data for a single timestep.

        Parameters
        ----------
        path
            Directory containing the simulation output.
        step
            Timestep index.
        fields : Iterable[str] | None
            When given, only include these canonical field names.
        target_resolution
            Target cell size in code units for AMR regridding. When
            ``None`` (default), regrids to the finest resolution. When
            set, snapped to the nearest AMR level present in the data.
            Ignored for uniform grids.

        Returns
        -------
        FieldDataset
            Field data with canonical names, optionally converted to SI.
        """
        from pypic.readers.batsrus import BATSRUSOutputFormat

        canonical_set = set(fields) if fields is not None else None
        match self._output_format:
            case BATSRUSOutputFormat.HDF5:
                return self._read_hdf5(
                    path,
                    step,
                    fields=canonical_set,
                    target_resolution=target_resolution,
                )
            case BATSRUSOutputFormat.IDL:
                return self._read_idl(
                    path,
                    step,
                    fields=canonical_set,
                    target_resolution=target_resolution,
                )
            case BATSRUSOutputFormat.OUT:
                return self._read_out(path, step, fields=canonical_set)
            case _ as unreachable:
                assert_never(unreachable)

    def _read_idl(
        self,
        path: Path,
        step: int,
        *,
        fields: set[str] | None = None,
        target_resolution: float | None = None,
    ) -> FieldDataset:
        """Read per-cell IDL format."""
        header_file = self._find_file(path, step, ".h")
        header = parse_header(header_file)
        geo = GEOMETRY_BY_NAME.get(header.geometry, CARTESIAN)

        idl_files = self._find_idl_files(path, step)
        all_coords = []
        all_dx = []
        all_state = []
        for idl_file in idl_files:
            coords, dx, state = read_idl_cells(idl_file, header)
            all_coords.append(coords)
            all_dx.append(dx)
            all_state.append(state)

        coords = np.concatenate(all_coords, axis=0)
        dx = np.concatenate(all_dx, axis=0)
        state = np.concatenate(all_state, axis=0)

        if is_uniform_idl(dx):
            field_data, grid = assemble_uniform_idl(
                coords, dx, state, header.var_names, header.ndim, geometry=geo
            )
        else:
            field_data, grid = regrid_amr_idl(
                coords,
                dx,
                state,
                header.var_names,
                header.ndim,
                target_dx=target_resolution,
                geometry=geo,
            )

        # Unit conversion
        unit_names = self._parse_unit_names(header)
        if unit_names and not is_normalized(header.unit_string):
            field_data = convert_fields_to_si(
                field_data,
                header.var_names,
                unit_names,
            )

        if fields is not None:
            field_data = {k: v for k, v in field_data.items() if k in fields}
        return self._finish_si(
            field_data,
            grid,
            path=path,
            header=header,
            step=header.n_step,
            time=header.time,
            output_format="idl",
            is_regridded=not is_uniform_idl(dx),
        )

    def _read_hdf5(
        self,
        path: Path,
        step: int,
        *,
        fields: set[str] | None = None,
        target_resolution: float | None = None,
    ) -> FieldDataset:
        """Read HDF5 BATL format."""
        batl_file = self._find_file(path, step, ".batl")

        # Compute native names to read from canonical wanted set
        native_wanted: set[str] | None = None
        if fields is not None:
            native_wanted = set()
            for native, canonical in FIELD_NAME_MAP.items():
                if canonical in fields:
                    native_wanted.add(native)
            # Also include canonical names not in the map (pass-through)
            for f in fields:
                if f not in FIELD_NAME_MAP.values():
                    native_wanted.add(f)

        batl = read_batl(batl_file, fields=native_wanted)
        geo = GEOMETRY_BY_NAME.get(self._geometry, CARTESIAN)

        is_uniform = len(set(batl.refine_level)) <= 1
        if is_uniform:
            field_data, grid = assemble_uniform_hdf5(batl, geometry=geo)
        else:
            field_data, grid = regrid_amr_hdf5(
                batl, target_dx=target_resolution, geometry=geo
            )

        unit_names = batl.unit_names
        unit_str = " ".join(unit_names)
        if unit_names and not is_normalized(unit_str):
            field_data = convert_fields_to_si(
                field_data,
                batl.var_names,
                unit_names,
            )

        if fields is not None:
            field_data = {k: v for k, v in field_data.items() if k in fields}
        return self._finish_si(
            field_data,
            grid,
            path=path,
            step=batl.n_step,
            time=batl.time,
            output_format="hdf5",
            is_regridded=not is_uniform,
        )

    def _read_out(
        self,
        path: Path,
        step: int,
        *,
        fields: set[str] | None = None,
    ) -> FieldDataset:
        """Read merged .out format."""
        out_file = self._find_file(path, step, ".out")
        coord, state, var_names, out_meta = read_out_file(out_file)

        ndim = int(out_meta["ndim"])
        dims = out_meta["dims"]

        # Determine geometry: .out files encode non-Cartesian as negative ndim
        is_cart = out_meta.get("is_cartesian", True)
        geo = CARTESIAN if is_cart else GEOMETRY_BY_NAME.get(self._geometry, CARTESIAN)

        # Build fields dict with canonical names
        field_data: dict[str, np.ndarray] = {}
        for iv, vname in enumerate(var_names):
            if vname in SKIP_FIELDS:
                continue
            canonical = FIELD_NAME_MAP.get(vname, vname)
            if fields is not None and canonical not in fields:
                continue
            field_data[canonical] = state[iv]

        # Build grid from coordinate arrays. Step along axis *d* specifically:
        # `.flat[1]` walks the last axis, so it reads 0 for every axis but the
        # innermost one.
        spacing = tuple(
            float(np.diff(coord[d], axis=d).flat[0]) if dims[d] > 1 else 1.0
            for d in range(ndim)
        )
        origin = tuple(float(coord[d].flat[0] - spacing[d] / 2) for d in range(ndim))

        grid = GridInfo(
            dimensions=dims,
            spacing=spacing,
            origin=origin,
            geometry=geo,
        )

        # Unit conversion, as in _read_idl / _read_hdf5. The .out head line
        # carries the same unit string the .h header exposes.
        head_line = str(out_meta.get("head", ""))
        unit_names = parse_unit_names(head_line, len(var_names))
        if unit_names and not is_normalized(head_line):
            field_data = convert_fields_to_si(field_data, var_names, unit_names)

        return self._finish_si(
            field_data,
            grid,
            path=path,
            step=out_meta.get("step", step),
            time=out_meta.get("time", 0.0),
            output_format="out",
        )

    def _finish_si(
        self,
        field_data: dict[str, np.ndarray],
        grid: GridInfo,
        *,
        path: Path,
        header: BATSRUSHeader | None = None,
        step: int,
        time: float,
        output_format: str,
        is_regridded: bool = False,
    ) -> FieldDataset:
        """Normalize SI-valued *field_data* by the run's references and wrap it."""
        if grid.boundary is None:
            # The one place all three read paths converge while a GridInfo is
            # still in scope.  to_simulation_config is skipped whenever a
            # sim_config was supplied, which open_batsrus always does.
            tags = boundary_tags(self._config, header, len(grid.dimensions))
            if tags is not None:
                grid = copy.replace(grid, boundary=tags)
        if self._sim_config is None:
            self._sim_config = to_simulation_config(
                self._config, header, grid=grid, sim_dir=path
            )
        extra: dict[str, Any] = {
            "format": output_format,
            "stagger": StaggerInfo(convention="cell"),
        }
        if is_regridded:
            extra["is_regridded"] = True
        return self._finish(
            normalize_fields(field_data, self._sim_config.normalization),
            step=step,
            time=time,
            grid=grid,
            extra=extra,
        )

    def _find_file(self, path: Path, step: int, suffix: str) -> Path:
        """Find the one file matching the prefix and step number."""
        step_str = f"_n{step:08d}"
        # Also try time-based naming: _t{time}_n{step}
        candidates = list(path.glob(f"{self._prefix}*{step_str}*{suffix}"))
        if not candidates:
            # Try without prefix
            candidates = list(path.glob(f"*{step_str}*{suffix}"))
        if not candidates:
            msg = f"No {suffix} file found for step {step} in {path}"
            raise FileNotFoundError(msg)
        if len(candidates) > 1:
            names = sorted(c.name for c in candidates)
            msg = f"Ambiguous {suffix} files for step {step} in {path}: {names}"
            raise ValueError(msg)
        return candidates[0]

    def _find_idl_files(self, path: Path, step: int) -> list[Path]:
        """Find all per-processor .idl files for a given step."""
        step_str = f"_n{step:08d}"
        # Also try time-based: _t{time}_n{step}
        files = sorted(path.glob(f"*{step_str}*_pe*.idl"))
        if not files:
            # Try matching on time pattern
            for h_file in path.glob(f"*_n{step:08d}.h"):
                stem = h_file.stem
                files = sorted(path.glob(f"{stem}_pe*.idl"))
                if files:
                    break
        if not files:
            # Broadest search: find any .idl files with this step number
            files = sorted(f for f in path.glob("*.idl") if step_str in f.name)
        if not files:
            msg = f"No .idl files found for step {step} in {path}"
            raise FileNotFoundError(msg)
        return files

    def _parse_unit_names(self, header: BATSRUSHeader) -> tuple[str, ...]:
        """Extract per-variable unit strings from a parsed ``.h`` header."""
        return parse_unit_names(header.unit_string, header.n_plot_var)

available_timesteps(path)

Return sorted list of available timestep indices.

Source code in src/pypic/readers/batsrus/_reader.py
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
def available_timesteps(self, path: Path) -> list[int]:
    """Return sorted list of available timestep indices."""
    from pypic.readers.batsrus import BATSRUSOutputFormat

    match self._output_format:
        case BATSRUSOutputFormat.HDF5:
            pattern = f"{self._prefix}*.batl"
        case BATSRUSOutputFormat.IDL:
            pattern = f"{self._prefix}*.h"
        case BATSRUSOutputFormat.OUT:
            pattern = f"{self._prefix}*.out"
        case _ as unreachable:
            assert_never(unreachable)

    steps = {extract_step_from_filename(f.name) for f in path.glob(pattern)}
    return sorted(step for step in steps if step is not None)

available_fields_mapping(path, step)

Map canonical field names to native (on-disk) names at step.

Parses file headers or HDF5 metadata without loading arrays.

Parameters:

Name Type Description Default
path Path

Directory containing the simulation output.

required
step int

Timestep index.

required

Returns:

Type Description
dict[str, str | None]

Canonical → native name.

Source code in src/pypic/readers/batsrus/_reader.py
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
def available_fields_mapping(self, path: Path, step: int) -> dict[str, str | None]:
    """Map canonical field names to native (on-disk) names at *step*.

    Parses file headers or HDF5 metadata without loading arrays.

    Parameters
    ----------
    path : Path
        Directory containing the simulation output.
    step : int
        Timestep index.

    Returns
    -------
    dict[str, str | None]
        Canonical → native name.
    """
    return self._build_var_mapping(self._get_var_names(path, step))

read_timestep(path, step, *, fields=None, target_resolution=None)

Read field data for a single timestep.

Parameters:

Name Type Description Default
path Path

Directory containing the simulation output.

required
step int

Timestep index.

required
fields Iterable[str] | None

When given, only include these canonical field names.

None
target_resolution float | None

Target cell size in code units for AMR regridding. When None (default), regrids to the finest resolution. When set, snapped to the nearest AMR level present in the data. Ignored for uniform grids.

None

Returns:

Type Description
FieldDataset

Field data with canonical names, optionally converted to SI.

Source code in src/pypic/readers/batsrus/_reader.py
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
def read_timestep(
    self,
    path: Path,
    step: int,
    *,
    fields: Iterable[str] | None = None,
    target_resolution: float | None = None,
) -> FieldDataset:
    """Read field data for a single timestep.

    Parameters
    ----------
    path
        Directory containing the simulation output.
    step
        Timestep index.
    fields : Iterable[str] | None
        When given, only include these canonical field names.
    target_resolution
        Target cell size in code units for AMR regridding. When
        ``None`` (default), regrids to the finest resolution. When
        set, snapped to the nearest AMR level present in the data.
        Ignored for uniform grids.

    Returns
    -------
    FieldDataset
        Field data with canonical names, optionally converted to SI.
    """
    from pypic.readers.batsrus import BATSRUSOutputFormat

    canonical_set = set(fields) if fields is not None else None
    match self._output_format:
        case BATSRUSOutputFormat.HDF5:
            return self._read_hdf5(
                path,
                step,
                fields=canonical_set,
                target_resolution=target_resolution,
            )
        case BATSRUSOutputFormat.IDL:
            return self._read_idl(
                path,
                step,
                fields=canonical_set,
                target_resolution=target_resolution,
            )
        case BATSRUSOutputFormat.OUT:
            return self._read_out(path, step, fields=canonical_set)
        case _ as unreachable:
            assert_never(unreachable)

ConservedQuantities dataclass

Time series of conserved quantities from an iPIC3D run.

Two output formats exist:

Format A (Roman numeral header) — single file from phdf5/shdf5 runs. Columns: cycle, electric energy (total, x, y, z), magnetic energy (total, x, y, z), kinetic energy, total energy, energy variation, momentum.

Format B (comment header) — per-restart-segment files from H5hut runs. Columns: cycle, total energy, energy variation, electric energy, local B energy, kinetic energy, momentum, total B energy, internal B energy, KE removed, E removed, then per-species (npart, charge, KE).

Parameters:

Name Type Description Default
cycle FloatArray

Cycle numbers (int-valued but stored as float for array uniformity).

required
total_energy FloatArray

Total energy at each cycle.

required
electric_energy FloatArray

Total electric field energy.

required
magnetic_energy FloatArray

Total magnetic field energy.

required
kinetic_energy FloatArray

Total kinetic energy (all species).

required
momentum FloatArray

Total momentum magnitude.

required
species_npart tuple[FloatArray, ...]

Number of particles per species at each cycle.

required
species_charge tuple[FloatArray, ...]

Total charge per species.

required
species_kinetic_energy tuple[FloatArray, ...]

Kinetic energy per species.

required
Source code in src/pypic/readers/ipic3d/_conserved.py
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
@dataclass(frozen=True, slots=True)
class ConservedQuantities:
    """Time series of conserved quantities from an iPIC3D run.

    Two output formats exist:

    **Format A (Roman numeral header)** — single file from phdf5/shdf5 runs.
    Columns: cycle, electric energy (total, x, y, z), magnetic energy
    (total, x, y, z), kinetic energy, total energy, energy variation,
    momentum.

    **Format B (comment header)** — per-restart-segment files from H5hut
    runs. Columns: cycle, total energy, energy variation, electric energy,
    local B energy, kinetic energy, momentum, total B energy, internal B
    energy, KE removed, E removed, then per-species (npart, charge, KE).

    Parameters
    ----------
    cycle : FloatArray
        Cycle numbers (int-valued but stored as float for array uniformity).
    total_energy : FloatArray
        Total energy at each cycle.
    electric_energy : FloatArray
        Total electric field energy.
    magnetic_energy : FloatArray
        Total magnetic field energy.
    kinetic_energy : FloatArray
        Total kinetic energy (all species).
    momentum : FloatArray
        Total momentum magnitude.
    species_npart : tuple[FloatArray, ...]
        Number of particles per species at each cycle.
    species_charge : tuple[FloatArray, ...]
        Total charge per species.
    species_kinetic_energy : tuple[FloatArray, ...]
        Kinetic energy per species.
    """

    cycle: FloatArray
    total_energy: FloatArray
    electric_energy: FloatArray
    magnetic_energy: FloatArray
    kinetic_energy: FloatArray
    momentum: FloatArray
    species_npart: tuple[FloatArray, ...]
    species_charge: tuple[FloatArray, ...]
    species_kinetic_energy: tuple[FloatArray, ...]

IPic3DConfig dataclass

Native iPIC3D simulation parameters.

Stores the raw values from an .inp file or settings.hdf, before any conversion to the canonical pypic schema.

Parameters:

Name Type Description Default
nxc int

Number of cells along each axis.

required
nyc int

Number of cells along each axis.

required
nzc int

Number of cells along each axis.

required
lx float

Domain size along each axis (code units).

required
ly float

Domain size along each axis (code units).

required
lz float

Domain size along each axis (code units).

required
dx float

Cell spacing (code units). Computed as L / N.

required
dy float

Cell spacing (code units). Computed as L / N.

required
dz float

Cell spacing (code units). Computed as L / N.

required
dt float

Timestep in code units.

required
xlen int

MPI topology (processors per axis).

required
ylen int

MPI topology (processors per axis).

required
zlen int

MPI topology (processors per axis).

required
c float

Speed of light in code units.

required
th float

Implicitness parameter (0.5 = Crank-Nicolson).

required
b0 tuple[float, float, float]

Background magnetic field (B0x, B0y, B0z).

required
ns int

Number of particle species.

required
qom tuple[float, ...]

Charge-to-mass ratio per species.

required
uth tuple[float, ...]

Thermal velocities per species (x, y, z components).

required
vth tuple[float, ...]

Thermal velocities per species (x, y, z components).

required
wth tuple[float, ...]

Thermal velocities per species (x, y, z components).

required
u0 tuple[float, ...]

Drift velocities per species (x, y, z components).

required
v0 tuple[float, ...]

Drift velocities per species (x, y, z components).

required
w0 tuple[float, ...]

Drift velocities per species (x, y, z components).

required
rho_init tuple[float, ...]

Initial number density per species (code units).

required
npcelx tuple[int, ...]

Particles per cell per species (x, y, z).

required
npcely tuple[int, ...]

Particles per cell per species (x, y, z).

required
npcelz tuple[int, ...]

Particles per cell per species (x, y, z).

required
periodic_x bool

Periodicity per axis.

required
periodic_y bool

Periodicity per axis.

required
periodic_z bool

Periodicity per axis.

required
write_method str

Output format ("phdf5" or "shdf5").

required
field_output_cycle int

Field output frequency (cycles between dumps).

required
field_output_tag str

Space-separated tags controlling which fields are written.

required
particles_output_cycle int

Particle output frequency (cycles between dumps; <=0 = disabled).

required
case str

Simulation case identifier.

required
simulation_name str

Human-readable simulation name.

required
Source code in src/pypic/readers/ipic3d/_config.py
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
@dataclass(frozen=True, slots=True)
class IPic3DConfig:
    """Native iPIC3D simulation parameters.

    Stores the raw values from an ``.inp`` file or ``settings.hdf``,
    before any conversion to the canonical pypic schema.

    Parameters
    ----------
    nxc, nyc, nzc : int
        Number of cells along each axis.
    lx, ly, lz : float
        Domain size along each axis (code units).
    dx, dy, dz : float
        Cell spacing (code units). Computed as ``L / N``.
    dt : float
        Timestep in code units.
    xlen, ylen, zlen : int
        MPI topology (processors per axis).
    c : float
        Speed of light in code units.
    th : float
        Implicitness parameter (0.5 = Crank-Nicolson).
    b0 : tuple[float, float, float]
        Background magnetic field ``(B0x, B0y, B0z)``.
    ns : int
        Number of particle species.
    qom : tuple[float, ...]
        Charge-to-mass ratio per species.
    uth, vth, wth : tuple[float, ...]
        Thermal velocities per species (x, y, z components).
    u0, v0, w0 : tuple[float, ...]
        Drift velocities per species (x, y, z components).
    rho_init : tuple[float, ...]
        Initial number density per species (code units).
    npcelx, npcely, npcelz : tuple[int, ...]
        Particles per cell per species (x, y, z).
    periodic_x, periodic_y, periodic_z : bool
        Periodicity per axis.
    write_method : str
        Output format (``"phdf5"`` or ``"shdf5"``).
    field_output_cycle : int
        Field output frequency (cycles between dumps).
    field_output_tag : str
        Space-separated tags controlling which fields are written.
    particles_output_cycle : int
        Particle output frequency (cycles between dumps; <=0 = disabled).
    case : str
        Simulation case identifier.
    simulation_name : str
        Human-readable simulation name.
    """

    nxc: int
    nyc: int
    nzc: int
    lx: float
    ly: float
    lz: float
    dx: float
    dy: float
    dz: float
    dt: float
    xlen: int
    ylen: int
    zlen: int
    c: float
    th: float
    b0: tuple[float, float, float]
    ns: int
    qom: tuple[float, ...]
    uth: tuple[float, ...]
    vth: tuple[float, ...]
    wth: tuple[float, ...]
    u0: tuple[float, ...]
    v0: tuple[float, ...]
    w0: tuple[float, ...]
    rho_init: tuple[float, ...]
    npcelx: tuple[int, ...]
    npcely: tuple[int, ...]
    npcelz: tuple[int, ...]
    periodic_x: bool
    periodic_y: bool
    periodic_z: bool
    write_method: str
    field_output_cycle: int
    field_output_tag: str
    particles_output_cycle: int
    case: str
    simulation_name: str
    extra: dict[str, Any] = field(default_factory=dict)  # frozen via __post_init__

    def __post_init__(self) -> None:
        object.__setattr__(self, "extra", MappingProxyType(dict(self.extra)))

IPic3DH5hutReader

Bases: IPic3DReaderBase

Read iPIC3D H5hut field output.

H5hut files store all fields for a single timestep in one file named {SimulationName}-Fields_{cycle:06d}.h5. Arrays are stored in ZYX order ((nzc+1, nyc+1, nxc+1)) and must be transposed.

H5hut stores all moment quantities (density, current, pressure) divided by 4π (Gaussian convention). The reader applies the 4π correction to density, current, and pressure, matching the phdf5/shdf5 readers. Electromagnetic fields are unaffected.

Unique to this reader: the single-file-per-timestep layout, ZYX transpose, H5hut-specific field naming (uppercase axis letters in _PRESSURE_COMPONENT_MAP), and passthrough of unknown native fields. Field-name mapping for everything else, the Gaussian conversions, pressure-tensor mass correction, and config translation live in pypic.readers.ipic3d._field_map and pypic.readers.ipic3d._config, shared with the parallel and serial readers.

Source code in src/pypic/readers/ipic3d/_h5hut.py
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
class IPic3DH5hutReader(IPic3DReaderBase):
    """Read iPIC3D H5hut field output.

    H5hut files store all fields for a single timestep in one file
    named ``{SimulationName}-Fields_{cycle:06d}.h5``. Arrays are stored
    in ZYX order (``(nzc+1, nyc+1, nxc+1)``) and must be transposed.

    H5hut stores **all moment quantities** (density, current, pressure)
    divided by 4π (Gaussian convention). The reader applies the 4π
    correction to density, current, and pressure, matching the phdf5/shdf5
    readers. Electromagnetic fields are unaffected.

    Unique to this reader: the single-file-per-timestep layout, ZYX
    transpose, H5hut-specific field naming (uppercase axis letters
    in ``_PRESSURE_COMPONENT_MAP``), and passthrough of unknown native
    fields. Field-name mapping for everything else, the Gaussian
    conversions, pressure-tensor mass correction, and config
    translation live in `pypic.readers.ipic3d._field_map` and
    `pypic.readers.ipic3d._config`, shared with the parallel and
    serial readers.
    """

    def available_timesteps(self, path: Path) -> list[int]:
        """Sorted cycle numbers, from the ``*-Fields_*.h5`` files under *path*."""
        return sorted(
            int(m.group(1))
            for entry in path.iterdir()
            if (m := _FIELDS_PATTERN.search(entry.name))
        )

    def available_fields_mapping(self, path: Path, step: int) -> dict[str, str | None]:
        """Map canonical field names to native (on-disk) names at *step*.

        Opens the H5hut fields file and inspects ``Step#0/Block/``
        keys. Unknown native keys pass through with the same name as
        both key and value, matching `read_timestep`.

        Parameters
        ----------
        path : Path
            Simulation output directory.
        step : int
            Timestep (cycle) index.

        Returns
        -------
        dict[str, str | None]
            Canonical → native name, ``None`` for computed totals.
        """
        ns = self._config.ns
        with h5py.File(self._find_fields_file(path, step), "r") as f:
            available = set(f["Step#0"]["Block"].keys())

        mapping: dict[str, str | None] = {}
        consumed: set[str] = set()
        for native, canon in _KNOWN_FIELDS.items():
            if native in available:
                mapping[canon] = native
                consumed.add(native)
        for s in range(ns):
            for canon, native in species_moment_names(
                s, _PRESSURE_COMPONENT_MAP
            ).items():
                key = f"{native}_{s}"
                if key in available:
                    mapping[canon] = key
                    consumed.add(key)
        mapping.update((native, native) for native in available - consumed)

        for total in infer_total_fields(set(mapping), ns):
            mapping[total] = None
        return mapping

    def _find_fields_file(self, path: Path, step: int) -> Path:
        """Locate the H5hut fields file for a given cycle."""
        sim_name = self._config.simulation_name or self._config.case
        candidate = path / f"{sim_name}-Fields_{step:06d}.h5"
        if candidate.exists():
            return candidate
        # Fall back to glob
        matches = list(path.glob(f"*-Fields_{step:06d}.h5"))
        if not matches:
            matches = list(path.glob(f"*-Fields_{step}.h5"))
        if not matches:
            msg = f"No H5hut fields file found for step {step} in {path}"
            raise FileNotFoundError(msg)
        return matches[0]

    def read_timestep(
        self,
        path: Path,
        step: int,
        *,
        fields: Iterable[str] | None = None,
    ) -> FieldDataset:
        """Read field and moment data for a single timestep.

        Parameters
        ----------
        path : Path
            Simulation output directory.
        step : int
            Cycle number (e.g. 202500).
        fields : Iterable[str] | None
            When given, only read these canonical field names.
            Dependencies (per-species fields needed for totals)
            are expanded automatically and excluded from the result.

        Returns
        -------
        FieldDataset
            Field data with canonical names. Density, current, and
            pressure tensor all corrected by 4π (Gaussian→SI-rationalized).
        """
        fields_file = self._find_fields_file(path, step)
        wanted: set[str] | None = set(fields) if fields is not None else None
        field_data: dict[str, FloatArray] = {}

        with h5py.File(fields_file, "r") as f:
            step_group = f["Step#0"]
            nspec = int(step_group.attrs["nspec"][0])
            if nspec != self._config.ns:
                msg = (
                    f"Species count mismatch in {fields_file.name}: HDF5 has "
                    f"nspec={nspec} but the config declares ns={self._config.ns}"
                )
                raise ValueError(msg)
            block = step_group["Block"]
            available = set(block.keys())
            expanded: set[str] | None = (
                expand_moment_dependencies(wanted, nspec)
                if wanted is not None
                else None
            )

            consumed: set[str] = set()
            for native, canon in _KNOWN_FIELDS.items():
                if native in available:
                    consumed.add(native)
                    if expanded is None or canon in expanded:
                        field_data[canon] = _read_field(block, native)

            for s in range(nspec):
                names = species_moment_names(s, _PRESSURE_COMPONENT_MAP)
                consumed.update(
                    key
                    for native in names.values()
                    if (key := f"{native}_{s}") in available
                )
                field_data.update(
                    read_species_moments(
                        _species_loader(block, available, s),
                        s,
                        species_qom=self._config.qom[s],
                        expanded=expanded,
                        pressure_map=_PRESSURE_COMPONENT_MAP,
                    )
                )

            # Unknown fields pass through under their native names, unconverted.
            for native in available - consumed:
                if expanded is None or native in expanded:
                    field_data[native] = _read_field(block, native)

        field_data = compute_totals_and_filter(field_data, nspec, expanded, wanted)
        return self._finish(field_data, step=step)

available_timesteps(path)

Sorted cycle numbers, from the *-Fields_*.h5 files under path.

Source code in src/pypic/readers/ipic3d/_h5hut.py
76
77
78
79
80
81
82
def available_timesteps(self, path: Path) -> list[int]:
    """Sorted cycle numbers, from the ``*-Fields_*.h5`` files under *path*."""
    return sorted(
        int(m.group(1))
        for entry in path.iterdir()
        if (m := _FIELDS_PATTERN.search(entry.name))
    )

available_fields_mapping(path, step)

Map canonical field names to native (on-disk) names at step.

Opens the H5hut fields file and inspects Step#0/Block/ keys. Unknown native keys pass through with the same name as both key and value, matching read_timestep.

Parameters:

Name Type Description Default
path Path

Simulation output directory.

required
step int

Timestep (cycle) index.

required

Returns:

Type Description
dict[str, str | None]

Canonical → native name, None for computed totals.

Source code in src/pypic/readers/ipic3d/_h5hut.py
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
def available_fields_mapping(self, path: Path, step: int) -> dict[str, str | None]:
    """Map canonical field names to native (on-disk) names at *step*.

    Opens the H5hut fields file and inspects ``Step#0/Block/``
    keys. Unknown native keys pass through with the same name as
    both key and value, matching `read_timestep`.

    Parameters
    ----------
    path : Path
        Simulation output directory.
    step : int
        Timestep (cycle) index.

    Returns
    -------
    dict[str, str | None]
        Canonical → native name, ``None`` for computed totals.
    """
    ns = self._config.ns
    with h5py.File(self._find_fields_file(path, step), "r") as f:
        available = set(f["Step#0"]["Block"].keys())

    mapping: dict[str, str | None] = {}
    consumed: set[str] = set()
    for native, canon in _KNOWN_FIELDS.items():
        if native in available:
            mapping[canon] = native
            consumed.add(native)
    for s in range(ns):
        for canon, native in species_moment_names(
            s, _PRESSURE_COMPONENT_MAP
        ).items():
            key = f"{native}_{s}"
            if key in available:
                mapping[canon] = key
                consumed.add(key)
    mapping.update((native, native) for native in available - consumed)

    for total in infer_total_fields(set(mapping), ns):
        mapping[total] = None
    return mapping

read_timestep(path, step, *, fields=None)

Read field and moment data for a single timestep.

Parameters:

Name Type Description Default
path Path

Simulation output directory.

required
step int

Cycle number (e.g. 202500).

required
fields Iterable[str] | None

When given, only read these canonical field names. Dependencies (per-species fields needed for totals) are expanded automatically and excluded from the result.

None

Returns:

Type Description
FieldDataset

Field data with canonical names. Density, current, and pressure tensor all corrected by 4π (Gaussian→SI-rationalized).

Source code in src/pypic/readers/ipic3d/_h5hut.py
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
def read_timestep(
    self,
    path: Path,
    step: int,
    *,
    fields: Iterable[str] | None = None,
) -> FieldDataset:
    """Read field and moment data for a single timestep.

    Parameters
    ----------
    path : Path
        Simulation output directory.
    step : int
        Cycle number (e.g. 202500).
    fields : Iterable[str] | None
        When given, only read these canonical field names.
        Dependencies (per-species fields needed for totals)
        are expanded automatically and excluded from the result.

    Returns
    -------
    FieldDataset
        Field data with canonical names. Density, current, and
        pressure tensor all corrected by 4π (Gaussian→SI-rationalized).
    """
    fields_file = self._find_fields_file(path, step)
    wanted: set[str] | None = set(fields) if fields is not None else None
    field_data: dict[str, FloatArray] = {}

    with h5py.File(fields_file, "r") as f:
        step_group = f["Step#0"]
        nspec = int(step_group.attrs["nspec"][0])
        if nspec != self._config.ns:
            msg = (
                f"Species count mismatch in {fields_file.name}: HDF5 has "
                f"nspec={nspec} but the config declares ns={self._config.ns}"
            )
            raise ValueError(msg)
        block = step_group["Block"]
        available = set(block.keys())
        expanded: set[str] | None = (
            expand_moment_dependencies(wanted, nspec)
            if wanted is not None
            else None
        )

        consumed: set[str] = set()
        for native, canon in _KNOWN_FIELDS.items():
            if native in available:
                consumed.add(native)
                if expanded is None or canon in expanded:
                    field_data[canon] = _read_field(block, native)

        for s in range(nspec):
            names = species_moment_names(s, _PRESSURE_COMPONENT_MAP)
            consumed.update(
                key
                for native in names.values()
                if (key := f"{native}_{s}") in available
            )
            field_data.update(
                read_species_moments(
                    _species_loader(block, available, s),
                    s,
                    species_qom=self._config.qom[s],
                    expanded=expanded,
                    pressure_map=_PRESSURE_COMPONENT_MAP,
                )
            )

        # Unknown fields pass through under their native names, unconverted.
        for native in available - consumed:
            if expanded is None or native in expanded:
                field_data[native] = _read_field(block, native)

    field_data = compute_totals_and_filter(field_data, nspec, expanded, wanted)
    return self._finish(field_data, step=step)

IPic3DParallelReader

Bases: IPic3DReaderBase

Read iPIC3D parallel HDF5 (phdf5) output.

Each timestep is stored in separate Fields_XXXXX/ and Moments_XXXXX/ directories containing one .h5 file per field group.

Unique to this reader: scanning timestep directories and the one-file-per-moment layout. Field-name mapping, Gaussian-CGS unit conversions, pressure-tensor mass correction, and config translation live in pypic.readers.ipic3d._field_map and pypic.readers.ipic3d._config, shared with the serial and H5hut readers.

Source code in src/pypic/readers/ipic3d/_parallel.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
class IPic3DParallelReader(IPic3DReaderBase):
    """Read iPIC3D parallel HDF5 (phdf5) output.

    Each timestep is stored in separate ``Fields_XXXXX/`` and
    ``Moments_XXXXX/`` directories containing one ``.h5`` file per
    field group.

    Unique to this reader: scanning timestep directories and the
    one-file-per-moment layout. Field-name mapping, Gaussian-CGS unit
    conversions, pressure-tensor mass correction, and config
    translation live in `pypic.readers.ipic3d._field_map` and
    `pypic.readers.ipic3d._config`, shared with the serial and
    H5hut readers.
    """

    def available_timesteps(self, path: Path) -> list[int]:
        """Sorted timestep numbers, from the ``Fields_XXXXX`` directories."""
        return sorted(
            int(m.group(1))
            for entry in path.iterdir()
            if entry.is_dir() and (m := _FIELDS_DIR_RE.match(entry.name))
        )

    def available_fields_mapping(self, path: Path, step: int) -> dict[str, str | None]:
        """Map canonical field names to native (on-disk) names at *step*.

        Probes HDF5 files in the ``Fields_XXXXX/`` and
        ``Moments_XXXXX/`` directories without loading arrays.

        Parameters
        ----------
        path : Path
            Simulation output directory.
        step : int
            Timestep index.

        Returns
        -------
        dict[str, str | None]
            Canonical → native name, ``None`` for computed totals.
        """
        step_str = f"{step:05d}"
        ns = self._config.ns
        mapping: dict[str, str | None] = {}

        for prefix in ("B", "E"):
            em_path = path / f"Fields_{step_str}" / f"{prefix}_{step_str}.h5"
            if em_path.exists():
                with h5py.File(em_path, "r") as f:
                    mapping.update(
                        (_FIELD_NAME_MAP[name], name)
                        for name in f["Fields"]
                        if name in _FIELD_NAME_MAP
                    )

        for s in range(ns):
            with _MomentFiles(path / f"Moments_{step_str}", step_str, s) as files:
                names = species_moment_names(s, _PHDF5_PRESSURE_MAP)
                mapping.update(
                    (canon, native)
                    for canon, native in names.items()
                    if files.has(native)
                )

        for total in infer_total_fields(set(mapping), ns):
            mapping[total] = None
        return mapping

    def read_timestep(
        self,
        path: Path,
        step: int,
        *,
        fields: Iterable[str] | None = None,
    ) -> FieldDataset:
        """Read field and moment data for a single timestep.

        Parameters
        ----------
        path : Path
            Simulation output directory.
        step : int
            Timestep index (e.g. 0, 10, 20).
        fields : Iterable[str] | None
            When given, only read these canonical field names.

        Returns
        -------
        FieldDataset
            Field data with canonical names and 4π corrections applied.
        """
        step_str = f"{step:05d}"
        ns = self._config.ns
        wanted: set[str] | None = set(fields) if fields is not None else None
        expanded: set[str] | None = (
            expand_moment_dependencies(wanted, ns) if wanted is not None else None
        )
        field_data: dict[str, FloatArray] = {}

        for prefix in ("B", "E"):
            if expanded is not None and not any(
                f"{prefix}_{i}" in expanded for i in "123"
            ):
                continue
            em_path = path / f"Fields_{step_str}" / f"{prefix}_{step_str}.h5"
            with h5py.File(em_path, "r") as f:
                for ipic_name, canon_name in _FIELD_NAME_MAP.items():
                    if (
                        ipic_name.startswith(prefix)
                        and ipic_name in f["Fields"]
                        and (expanded is None or canon_name in expanded)
                    ):
                        field_data[canon_name] = np.array(f["Fields"][ipic_name])

        for s in range(ns):
            with _MomentFiles(path / f"Moments_{step_str}", step_str, s) as files:
                field_data.update(
                    read_species_moments(
                        files.load,
                        s,
                        species_qom=self._config.qom[s],
                        expanded=expanded,
                        pressure_map=_PHDF5_PRESSURE_MAP,
                    )
                )

        field_data = compute_totals_and_filter(field_data, ns, expanded, wanted)
        return self._finish(field_data, step=step)

    def available_particle_steps(self, path: Path) -> list[int]:
        """Return sorted timestep indices that have particle data."""
        return detect_particle_steps(path)

    def read_particles(
        self,
        path: Path,
        step: int,
        species: int,
        *,
        columns: Iterable[str] | None = None,
    ) -> ParticleData:
        """Load particle data for one species at one timestep.

        Parameters
        ----------
        path : Path
            Simulation output directory.
        step : int
            Timestep index.
        species : int
            Zero-based species index.
        columns : Iterable[str] | None
            Subset of ``{"position", "velocity"}`` to load.
            ``None`` loads all.  Per-particle ``weight`` and the scalar
            ``species_charge``/``species_mass`` are always populated
            (canonical layout, ``docs/schema.md``).

        Returns
        -------
        ParticleData
        """
        return read_phdf5_particles(path, step, species, self._config, columns=columns)

available_timesteps(path)

Sorted timestep numbers, from the Fields_XXXXX directories.

Source code in src/pypic/readers/ipic3d/_parallel.py
107
108
109
110
111
112
113
def available_timesteps(self, path: Path) -> list[int]:
    """Sorted timestep numbers, from the ``Fields_XXXXX`` directories."""
    return sorted(
        int(m.group(1))
        for entry in path.iterdir()
        if entry.is_dir() and (m := _FIELDS_DIR_RE.match(entry.name))
    )

available_fields_mapping(path, step)

Map canonical field names to native (on-disk) names at step.

Probes HDF5 files in the Fields_XXXXX/ and Moments_XXXXX/ directories without loading arrays.

Parameters:

Name Type Description Default
path Path

Simulation output directory.

required
step int

Timestep index.

required

Returns:

Type Description
dict[str, str | None]

Canonical → native name, None for computed totals.

Source code in src/pypic/readers/ipic3d/_parallel.py
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
def available_fields_mapping(self, path: Path, step: int) -> dict[str, str | None]:
    """Map canonical field names to native (on-disk) names at *step*.

    Probes HDF5 files in the ``Fields_XXXXX/`` and
    ``Moments_XXXXX/`` directories without loading arrays.

    Parameters
    ----------
    path : Path
        Simulation output directory.
    step : int
        Timestep index.

    Returns
    -------
    dict[str, str | None]
        Canonical → native name, ``None`` for computed totals.
    """
    step_str = f"{step:05d}"
    ns = self._config.ns
    mapping: dict[str, str | None] = {}

    for prefix in ("B", "E"):
        em_path = path / f"Fields_{step_str}" / f"{prefix}_{step_str}.h5"
        if em_path.exists():
            with h5py.File(em_path, "r") as f:
                mapping.update(
                    (_FIELD_NAME_MAP[name], name)
                    for name in f["Fields"]
                    if name in _FIELD_NAME_MAP
                )

    for s in range(ns):
        with _MomentFiles(path / f"Moments_{step_str}", step_str, s) as files:
            names = species_moment_names(s, _PHDF5_PRESSURE_MAP)
            mapping.update(
                (canon, native)
                for canon, native in names.items()
                if files.has(native)
            )

    for total in infer_total_fields(set(mapping), ns):
        mapping[total] = None
    return mapping

read_timestep(path, step, *, fields=None)

Read field and moment data for a single timestep.

Parameters:

Name Type Description Default
path Path

Simulation output directory.

required
step int

Timestep index (e.g. 0, 10, 20).

required
fields Iterable[str] | None

When given, only read these canonical field names.

None

Returns:

Type Description
FieldDataset

Field data with canonical names and 4π corrections applied.

Source code in src/pypic/readers/ipic3d/_parallel.py
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
def read_timestep(
    self,
    path: Path,
    step: int,
    *,
    fields: Iterable[str] | None = None,
) -> FieldDataset:
    """Read field and moment data for a single timestep.

    Parameters
    ----------
    path : Path
        Simulation output directory.
    step : int
        Timestep index (e.g. 0, 10, 20).
    fields : Iterable[str] | None
        When given, only read these canonical field names.

    Returns
    -------
    FieldDataset
        Field data with canonical names and 4π corrections applied.
    """
    step_str = f"{step:05d}"
    ns = self._config.ns
    wanted: set[str] | None = set(fields) if fields is not None else None
    expanded: set[str] | None = (
        expand_moment_dependencies(wanted, ns) if wanted is not None else None
    )
    field_data: dict[str, FloatArray] = {}

    for prefix in ("B", "E"):
        if expanded is not None and not any(
            f"{prefix}_{i}" in expanded for i in "123"
        ):
            continue
        em_path = path / f"Fields_{step_str}" / f"{prefix}_{step_str}.h5"
        with h5py.File(em_path, "r") as f:
            for ipic_name, canon_name in _FIELD_NAME_MAP.items():
                if (
                    ipic_name.startswith(prefix)
                    and ipic_name in f["Fields"]
                    and (expanded is None or canon_name in expanded)
                ):
                    field_data[canon_name] = np.array(f["Fields"][ipic_name])

    for s in range(ns):
        with _MomentFiles(path / f"Moments_{step_str}", step_str, s) as files:
            field_data.update(
                read_species_moments(
                    files.load,
                    s,
                    species_qom=self._config.qom[s],
                    expanded=expanded,
                    pressure_map=_PHDF5_PRESSURE_MAP,
                )
            )

    field_data = compute_totals_and_filter(field_data, ns, expanded, wanted)
    return self._finish(field_data, step=step)

available_particle_steps(path)

Return sorted timestep indices that have particle data.

Source code in src/pypic/readers/ipic3d/_parallel.py
221
222
223
def available_particle_steps(self, path: Path) -> list[int]:
    """Return sorted timestep indices that have particle data."""
    return detect_particle_steps(path)

read_particles(path, step, species, *, columns=None)

Load particle data for one species at one timestep.

Parameters:

Name Type Description Default
path Path

Simulation output directory.

required
step int

Timestep index.

required
species int

Zero-based species index.

required
columns Iterable[str] | None

Subset of {"position", "velocity"} to load. None loads all. Per-particle weight and the scalar species_charge/species_mass are always populated (canonical layout, docs/schema.md).

None

Returns:

Type Description
ParticleData
Source code in src/pypic/readers/ipic3d/_parallel.py
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
def read_particles(
    self,
    path: Path,
    step: int,
    species: int,
    *,
    columns: Iterable[str] | None = None,
) -> ParticleData:
    """Load particle data for one species at one timestep.

    Parameters
    ----------
    path : Path
        Simulation output directory.
    step : int
        Timestep index.
    species : int
        Zero-based species index.
    columns : Iterable[str] | None
        Subset of ``{"position", "velocity"}`` to load.
        ``None`` loads all.  Per-particle ``weight`` and the scalar
        ``species_charge``/``species_mass`` are always populated
        (canonical layout, ``docs/schema.md``).

    Returns
    -------
    ParticleData
    """
    return read_phdf5_particles(path, step, species, self._config, columns=columns)

IPic3DSerialReader

Bases: IPic3DReaderBase

Read iPIC3D serial HDF5 (shdf5) output.

Each MPI process writes to its own procN.hdf file containing all timesteps. This reader assembles the global arrays from the per-process local patches.

Unique to this reader: the per-process patch reassembly. Field-name mapping, Gaussian-CGS unit conversions, pressure-tensor mass correction, and config translation live in pypic.readers.ipic3d._field_map and pypic.readers.ipic3d._config, shared with the parallel and H5hut readers.

Source code in src/pypic/readers/ipic3d/_serial.py
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
class IPic3DSerialReader(IPic3DReaderBase):
    """Read iPIC3D serial HDF5 (shdf5) output.

    Each MPI process writes to its own ``procN.hdf`` file containing all
    timesteps. This reader assembles the global arrays from the per-process
    local patches.

    Unique to this reader: the per-process patch reassembly. Field-name
    mapping, Gaussian-CGS unit conversions, pressure-tensor mass
    correction, and config translation live in
    `pypic.readers.ipic3d._field_map` and
    `pypic.readers.ipic3d._config`, shared with the parallel and
    H5hut readers.
    """

    def available_timesteps(self, path: Path) -> list[int]:
        """Sorted timestep numbers, from the cycle keys in ``proc0.hdf``."""
        with h5py.File(path / "proc0.hdf", "r") as f:
            return sorted(
                int(m.group(1)) for key in f["fields/Bx"] if (m := _CYCLE_RE.match(key))
            )

    @staticmethod
    def _present_moments(proc0: Path, cycle_key: str) -> set[str]:
        """``species_N/<native>`` moment groups carrying *cycle_key* in proc0."""
        with h5py.File(proc0, "r") as f:
            if "moments" not in f:
                return set()
            return {
                f"{species}/{native}"
                for species, group in f["moments"].items()
                for native, cycles in group.items()
                if cycle_key in cycles
            }

    def available_fields_mapping(self, path: Path, step: int) -> dict[str, str | None]:
        """Map canonical field names to native (on-disk) names at *step*.

        Opens ``proc0.hdf`` and inspects HDF5 group keys without
        loading array data.

        Parameters
        ----------
        path : Path
            Simulation output directory.
        step : int
            Timestep index.

        Returns
        -------
        dict[str, str | None]
            Canonical → native name, ``None`` for computed totals.
        """
        proc0 = path / "proc0.hdf"
        cycle_key = f"cycle_{step}"
        ns = self._config.ns
        mapping: dict[str, str | None] = {}

        with h5py.File(proc0, "r") as f:
            if "fields" in f:
                mapping.update(
                    (_FIELD_NAME_MAP[name], name)
                    for name in f["fields"]
                    if name in _FIELD_NAME_MAP
                )
        present = self._present_moments(proc0, cycle_key)
        for s in range(ns):
            names = species_moment_names(s, _PHDF5_PRESSURE_MAP)
            mapping.update(
                (canon, native)
                for canon, native in names.items()
                if f"species_{s}/{native}" in present
            )

        for total in infer_total_fields(set(mapping), ns):
            mapping[total] = None
        return mapping

    def _assemble_field(
        self,
        proc_files: list[Path],
        group_path: str,
        cycle_key: str,
    ) -> FloatArray:
        """Assemble a global array from per-process local patches.

        Parameters
        ----------
        proc_files : list[Path]
            Paths to all proc*.hdf files.
        group_path : str
            HDF5 group path (e.g. ``"fields/Bx"``).
        cycle_key : str
            Cycle dataset name (e.g. ``"cycle_10"``).

        Returns
        -------
        FloatArray
            Assembled global array of shape ``(Nxc+1, Nyc+1, Nzc+1)``.
        """
        cfg = self._config
        global_shape = (cfg.nxc + 1, cfg.nyc + 1, cfg.nzc + 1)
        result = np.zeros(global_shape, dtype=np.float64)

        # Base local sizes and remainders for uneven MPI decompositions.
        # iPIC3D gives the first (N % P) ranks one extra cell.
        nxc_base = cfg.nxc // cfg.xlen
        nyc_base = cfg.nyc // cfg.ylen
        nzc_base = cfg.nzc // cfg.zlen
        nxc_extra = cfg.nxc % cfg.xlen
        nyc_extra = cfg.nyc % cfg.ylen
        nzc_extra = cfg.nzc % cfg.zlen

        for proc_path in proc_files:
            with h5py.File(proc_path, "r") as f:
                coords = f["topology/cartesian_coord"][()]
                ix, iy, iz = int(coords[0]), int(coords[1]), int(coords[2])

                data = np.array(f[group_path][cycle_key])
                nx_local, ny_local, nz_local = data.shape

                x0 = ix * nxc_base + min(ix, nxc_extra)
                y0 = iy * nyc_base + min(iy, nyc_extra)
                z0 = iz * nzc_base + min(iz, nzc_extra)

                result[x0 : x0 + nx_local, y0 : y0 + ny_local, z0 : z0 + nz_local] = (
                    data
                )

        return result

    def _load_moment(
        self,
        proc_files: list[Path],
        cycle_key: str,
        present: set[str],
        species: int,
        native: str,
    ) -> FloatArray | None:
        """Assemble one species moment, or ``None`` when proc0 lacks it."""
        if f"species_{species}/{native}" not in present:
            return None
        return self._assemble_field(
            proc_files, f"moments/species_{species}/{native}", cycle_key
        )

    def read_timestep(
        self,
        path: Path,
        step: int,
        *,
        fields: Iterable[str] | None = None,
    ) -> FieldDataset:
        """Read field and moment data for a single timestep.

        Assembles global arrays from per-process files, applies 4π
        correction to densities and currents, and computes totals.

        Parameters
        ----------
        path : Path
            Simulation output directory.
        step : int
            Timestep index (e.g. 0, 10, 20).
        fields : Iterable[str] | None
            When given, only read these canonical field names.

        Returns
        -------
        FieldDataset
            Field data with canonical names and 4π corrections applied.
        """
        proc_files = sorted(path.glob("proc*.hdf"))
        if not proc_files:
            msg = f"No proc*.hdf files found in {path}"
            raise FileNotFoundError(msg)
        cycle_key = f"cycle_{step}"
        ns = self._config.ns
        wanted: set[str] | None = set(fields) if fields is not None else None
        expanded: set[str] | None = (
            expand_moment_dependencies(wanted, ns) if wanted is not None else None
        )
        field_data: dict[str, FloatArray] = {}

        for ipic_name, canon_name in _FIELD_NAME_MAP.items():
            if expanded is not None and canon_name not in expanded:
                continue
            field_data[canon_name] = self._assemble_field(
                proc_files, f"fields/{ipic_name}", cycle_key
            )

        present = self._present_moments(proc_files[0], cycle_key)
        for s in range(ns):
            field_data.update(
                read_species_moments(
                    partial(self._load_moment, proc_files, cycle_key, present, s),
                    s,
                    species_qom=self._config.qom[s],
                    expanded=expanded,
                    pressure_map=_PHDF5_PRESSURE_MAP,
                )
            )

        field_data = compute_totals_and_filter(field_data, ns, expanded, wanted)
        return self._finish(field_data, step=step)

available_timesteps(path)

Sorted timestep numbers, from the cycle keys in proc0.hdf.

Source code in src/pypic/readers/ipic3d/_serial.py
48
49
50
51
52
53
def available_timesteps(self, path: Path) -> list[int]:
    """Sorted timestep numbers, from the cycle keys in ``proc0.hdf``."""
    with h5py.File(path / "proc0.hdf", "r") as f:
        return sorted(
            int(m.group(1)) for key in f["fields/Bx"] if (m := _CYCLE_RE.match(key))
        )

available_fields_mapping(path, step)

Map canonical field names to native (on-disk) names at step.

Opens proc0.hdf and inspects HDF5 group keys without loading array data.

Parameters:

Name Type Description Default
path Path

Simulation output directory.

required
step int

Timestep index.

required

Returns:

Type Description
dict[str, str | None]

Canonical → native name, None for computed totals.

Source code in src/pypic/readers/ipic3d/_serial.py
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
def available_fields_mapping(self, path: Path, step: int) -> dict[str, str | None]:
    """Map canonical field names to native (on-disk) names at *step*.

    Opens ``proc0.hdf`` and inspects HDF5 group keys without
    loading array data.

    Parameters
    ----------
    path : Path
        Simulation output directory.
    step : int
        Timestep index.

    Returns
    -------
    dict[str, str | None]
        Canonical → native name, ``None`` for computed totals.
    """
    proc0 = path / "proc0.hdf"
    cycle_key = f"cycle_{step}"
    ns = self._config.ns
    mapping: dict[str, str | None] = {}

    with h5py.File(proc0, "r") as f:
        if "fields" in f:
            mapping.update(
                (_FIELD_NAME_MAP[name], name)
                for name in f["fields"]
                if name in _FIELD_NAME_MAP
            )
    present = self._present_moments(proc0, cycle_key)
    for s in range(ns):
        names = species_moment_names(s, _PHDF5_PRESSURE_MAP)
        mapping.update(
            (canon, native)
            for canon, native in names.items()
            if f"species_{s}/{native}" in present
        )

    for total in infer_total_fields(set(mapping), ns):
        mapping[total] = None
    return mapping

read_timestep(path, step, *, fields=None)

Read field and moment data for a single timestep.

Assembles global arrays from per-process files, applies 4π correction to densities and currents, and computes totals.

Parameters:

Name Type Description Default
path Path

Simulation output directory.

required
step int

Timestep index (e.g. 0, 10, 20).

required
fields Iterable[str] | None

When given, only read these canonical field names.

None

Returns:

Type Description
FieldDataset

Field data with canonical names and 4π corrections applied.

Source code in src/pypic/readers/ipic3d/_serial.py
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
def read_timestep(
    self,
    path: Path,
    step: int,
    *,
    fields: Iterable[str] | None = None,
) -> FieldDataset:
    """Read field and moment data for a single timestep.

    Assembles global arrays from per-process files, applies 4π
    correction to densities and currents, and computes totals.

    Parameters
    ----------
    path : Path
        Simulation output directory.
    step : int
        Timestep index (e.g. 0, 10, 20).
    fields : Iterable[str] | None
        When given, only read these canonical field names.

    Returns
    -------
    FieldDataset
        Field data with canonical names and 4π corrections applied.
    """
    proc_files = sorted(path.glob("proc*.hdf"))
    if not proc_files:
        msg = f"No proc*.hdf files found in {path}"
        raise FileNotFoundError(msg)
    cycle_key = f"cycle_{step}"
    ns = self._config.ns
    wanted: set[str] | None = set(fields) if fields is not None else None
    expanded: set[str] | None = (
        expand_moment_dependencies(wanted, ns) if wanted is not None else None
    )
    field_data: dict[str, FloatArray] = {}

    for ipic_name, canon_name in _FIELD_NAME_MAP.items():
        if expanded is not None and canon_name not in expanded:
            continue
        field_data[canon_name] = self._assemble_field(
            proc_files, f"fields/{ipic_name}", cycle_key
        )

    present = self._present_moments(proc_files[0], cycle_key)
    for s in range(ns):
        field_data.update(
            read_species_moments(
                partial(self._load_moment, proc_files, cycle_key, present, s),
                s,
                species_qom=self._config.qom[s],
                expanded=expanded,
                pressure_map=_PHDF5_PRESSURE_MAP,
            )
        )

    field_data = compute_totals_and_filter(field_data, ns, expanded, wanted)
    return self._finish(field_data, step=step)

OpenGGCMGrid dataclass

Non-uniform grid definition from an OpenGGCM grid file.

Parameters:

Name Type Description Default
nx int

Number of grid points along each axis.

required
ny int

Number of grid points along each axis.

required
nz int

Number of grid points along each axis.

required
x FloatArray

X-coordinates (non-uniform), shape (nx,), in \(R_E\).

required
y FloatArray

Y-coordinates (non-uniform), shape (ny,), in \(R_E\).

required
z FloatArray

Z-coordinates (non-uniform), shape (nz,), in \(R_E\).

required
stagger MappingProxyType[str, tuple[FloatArray, FloatArray, FloatArray]]

Staggered grid positions keyed by field component ("bx", "by", "bz", "ex", "ey", "ez"). Each value is (gx, gy, gz) for that component.

required
metadata MappingProxyType[str, str]

Header metadata (DIPOLETIME, BASETIME).

required
Source code in src/pypic/readers/openggcm/_grid.py
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@dataclass(frozen=True, slots=True)
class OpenGGCMGrid:
    r"""Non-uniform grid definition from an OpenGGCM grid file.

    Parameters
    ----------
    nx, ny, nz : int
        Number of grid points along each axis.
    x : FloatArray
        X-coordinates (non-uniform), shape ``(nx,)``, in $R_E$.
    y : FloatArray
        Y-coordinates (non-uniform), shape ``(ny,)``, in $R_E$.
    z : FloatArray
        Z-coordinates (non-uniform), shape ``(nz,)``, in $R_E$.
    stagger : MappingProxyType[str, tuple[FloatArray, FloatArray, FloatArray]]
        Staggered grid positions keyed by field component (``"bx"``,
        ``"by"``, ``"bz"``, ``"ex"``, ``"ey"``, ``"ez"``).  Each value
        is ``(gx, gy, gz)`` for that component.
    metadata : MappingProxyType[str, str]
        Header metadata (``DIPOLETIME``, ``BASETIME``).
    """

    nx: int
    ny: int
    nz: int
    x: FloatArray
    y: FloatArray
    z: FloatArray
    stagger: MappingProxyType[str, tuple[FloatArray, FloatArray, FloatArray]]
    metadata: MappingProxyType[str, str]

OpenGGCMReader

Bases: ReaderBase

Read OpenGGCM .3df field output on a non-uniform grid.

Parameters:

Name Type Description Default
grid OpenGGCMGrid

Parsed grid definition.

required
prefix str

Filename prefix (e.g. "gc012" for gc012.3df.006300).

required
sim_config SimulationConfig

Merged run configuration. Its normalization converts the SI values on disk to code units; identity leaves them in SI.

required
Source code in src/pypic/readers/openggcm/_reader.py
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
class OpenGGCMReader(ReaderBase):
    """Read OpenGGCM .3df field output on a non-uniform grid.

    Parameters
    ----------
    grid : OpenGGCMGrid
        Parsed grid definition.
    prefix : str
        Filename prefix (e.g. ``"gc012"`` for ``gc012.3df.006300``).
    sim_config : SimulationConfig
        Merged run configuration. Its normalization converts the SI
        values on disk to code units; identity leaves them in SI.
    """

    def __init__(
        self,
        grid: OpenGGCMGrid,
        prefix: str,
        sim_config: SimulationConfig,
    ) -> None:
        super().__init__(sim_config)
        self._grid = grid
        self._prefix = prefix

    @property
    def grid(self) -> OpenGGCMGrid:
        """The OpenGGCM non-uniform grid."""
        return self._grid

    def available_timesteps(self, path: Path) -> list[int]:
        """Return sorted list of available timestep indices.

        Scans for ``{prefix}.3df.*`` files under *path*.

        Parameters
        ----------
        path : Path
            Directory containing .3df files.

        Returns
        -------
        list[int]
            Sorted timestep indices.
        """
        return sorted(
            int(m.group(1))
            for entry in path.glob(f"{self._prefix}.3df.*")
            if (m := _3DF_PATTERN.search(entry.name))
        )

    def available_fields_mapping(self, path: Path, step: int) -> dict[str, str | None]:
        """Map canonical field names to native ``.3df`` record names at *step*.

        Scans the file's ``FIELD-3D-1`` markers without decoding any
        WRN2 payload.
        """
        filename = path / f"{self._prefix}.3df.{step:06d}"
        native = [
            name for name in read_3df_field_names(filename) if name not in DEFAULT_SKIP
        ]
        mapping: dict[str, str | None] = {
            FIELD_NAME_MAP.get(name, name): name for name in native
        }
        if "rr" in native:
            mapping["n_s0"] = "rr"
        return mapping

    def read_timestep(
        self,
        path: Path,
        step: int,
        *,
        fields: Iterable[str] | None = None,
    ) -> FieldDataset:
        """Read fields for a single timestep.

        Parameters
        ----------
        path : Path
            Directory containing .3df files.
        step : int
            Timestep index (e.g. 6300).
        fields : Iterable[str] | None
            When given, only read these canonical field names.  Skips
            WRN2 decompression for unwanted native fields.

        Returns
        -------
        FieldDataset
            Field data with canonical names in SI (or normalized) units.
            ``metadata["is_uniform_grid"]`` is ``False`` — the grid is
            non-uniform, so ``grid.spacing`` is a mean approximation.
            Use the xarray coordinates for accurate spacing.
        """
        filename = path / f"{self._prefix}.3df.{step:06d}"
        if not filename.exists():
            msg = f"File not found: {filename}"
            raise FileNotFoundError(msg)

        skip = set(DEFAULT_SKIP)
        wanted_canonical: set[str] | None = None
        if fields is not None:
            wanted_canonical = set(fields)
            wanted_native: set[str] = set()
            for native, canonical in FIELD_NAME_MAP.items():
                if canonical in wanted_canonical:
                    wanted_native.add(native)
            # n_s0 is derived from "rr" in convert_fields_to_si
            if "n_s0" in wanted_canonical:
                wanted_native.add("rr")
            # Skip known native fields that aren't wanted
            skip = skip | (set(FIELD_NAME_MAP.keys()) - wanted_native)

        raw_fields, _ts, nx, ny, nz = read_3df_file(filename, skip=skip)

        # Verify grid dimensions match
        if (nx, ny, nz) != (self._grid.nx, self._grid.ny, self._grid.nz):
            msg = (
                f"Dimension mismatch: file ({nx}, {ny}, {nz}) vs "
                f"grid ({self._grid.nx}, {self._grid.ny}, {self._grid.nz})"
            )
            raise ValueError(msg)

        sc = self._require_config()
        code_fields = normalize_fields(
            convert_fields_to_si(raw_fields), sc.normalization
        )
        if wanted_canonical is not None:
            code_fields = {
                k: v for k, v in code_fields.items() if k in wanted_canonical
            }

        return self._finish(
            code_fields,
            step=step,
            coords={"x": self._grid.x, "y": self._grid.y, "z": self._grid.z},
            extra={"is_uniform_grid": False, "stagger": _STAGGER},
        )

grid property

The OpenGGCM non-uniform grid.

available_timesteps(path)

Return sorted list of available timestep indices.

Scans for {prefix}.3df.* files under path.

Parameters:

Name Type Description Default
path Path

Directory containing .3df files.

required

Returns:

Type Description
list[int]

Sorted timestep indices.

Source code in src/pypic/readers/openggcm/_reader.py
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
def available_timesteps(self, path: Path) -> list[int]:
    """Return sorted list of available timestep indices.

    Scans for ``{prefix}.3df.*`` files under *path*.

    Parameters
    ----------
    path : Path
        Directory containing .3df files.

    Returns
    -------
    list[int]
        Sorted timestep indices.
    """
    return sorted(
        int(m.group(1))
        for entry in path.glob(f"{self._prefix}.3df.*")
        if (m := _3DF_PATTERN.search(entry.name))
    )

available_fields_mapping(path, step)

Map canonical field names to native .3df record names at step.

Scans the file's FIELD-3D-1 markers without decoding any WRN2 payload.

Source code in src/pypic/readers/openggcm/_reader.py
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
def available_fields_mapping(self, path: Path, step: int) -> dict[str, str | None]:
    """Map canonical field names to native ``.3df`` record names at *step*.

    Scans the file's ``FIELD-3D-1`` markers without decoding any
    WRN2 payload.
    """
    filename = path / f"{self._prefix}.3df.{step:06d}"
    native = [
        name for name in read_3df_field_names(filename) if name not in DEFAULT_SKIP
    ]
    mapping: dict[str, str | None] = {
        FIELD_NAME_MAP.get(name, name): name for name in native
    }
    if "rr" in native:
        mapping["n_s0"] = "rr"
    return mapping

read_timestep(path, step, *, fields=None)

Read fields for a single timestep.

Parameters:

Name Type Description Default
path Path

Directory containing .3df files.

required
step int

Timestep index (e.g. 6300).

required
fields Iterable[str] | None

When given, only read these canonical field names. Skips WRN2 decompression for unwanted native fields.

None

Returns:

Type Description
FieldDataset

Field data with canonical names in SI (or normalized) units. metadata["is_uniform_grid"] is False — the grid is non-uniform, so grid.spacing is a mean approximation. Use the xarray coordinates for accurate spacing.

Source code in src/pypic/readers/openggcm/_reader.py
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
def read_timestep(
    self,
    path: Path,
    step: int,
    *,
    fields: Iterable[str] | None = None,
) -> FieldDataset:
    """Read fields for a single timestep.

    Parameters
    ----------
    path : Path
        Directory containing .3df files.
    step : int
        Timestep index (e.g. 6300).
    fields : Iterable[str] | None
        When given, only read these canonical field names.  Skips
        WRN2 decompression for unwanted native fields.

    Returns
    -------
    FieldDataset
        Field data with canonical names in SI (or normalized) units.
        ``metadata["is_uniform_grid"]`` is ``False`` — the grid is
        non-uniform, so ``grid.spacing`` is a mean approximation.
        Use the xarray coordinates for accurate spacing.
    """
    filename = path / f"{self._prefix}.3df.{step:06d}"
    if not filename.exists():
        msg = f"File not found: {filename}"
        raise FileNotFoundError(msg)

    skip = set(DEFAULT_SKIP)
    wanted_canonical: set[str] | None = None
    if fields is not None:
        wanted_canonical = set(fields)
        wanted_native: set[str] = set()
        for native, canonical in FIELD_NAME_MAP.items():
            if canonical in wanted_canonical:
                wanted_native.add(native)
        # n_s0 is derived from "rr" in convert_fields_to_si
        if "n_s0" in wanted_canonical:
            wanted_native.add("rr")
        # Skip known native fields that aren't wanted
        skip = skip | (set(FIELD_NAME_MAP.keys()) - wanted_native)

    raw_fields, _ts, nx, ny, nz = read_3df_file(filename, skip=skip)

    # Verify grid dimensions match
    if (nx, ny, nz) != (self._grid.nx, self._grid.ny, self._grid.nz):
        msg = (
            f"Dimension mismatch: file ({nx}, {ny}, {nz}) vs "
            f"grid ({self._grid.nx}, {self._grid.ny}, {self._grid.nz})"
        )
        raise ValueError(msg)

    sc = self._require_config()
    code_fields = normalize_fields(
        convert_fields_to_si(raw_fields), sc.normalization
    )
    if wanted_canonical is not None:
        code_fields = {
            k: v for k, v in code_fields.items() if k in wanted_canonical
        }

    return self._finish(
        code_fields,
        step=step,
        coords={"x": self._grid.x, "y": self._grid.y, "z": self._grid.z},
        extra={"is_uniform_grid": False, "stagger": _STAGGER},
    )

merge_simulation_toml(sim_dir, base)

Merge simulation.toml overrides into base if present.

Every SimulationConfig field outside READER_OWNED_FIELDS is taken from the TOML when it is set there (normalization, frame, transforms, run, probes, ...); metadata is merged with TOML keys winning on conflict. Reader-owned fields come from base unchanged, so the native config stays authoritative for the grid and species the data was actually produced with.

Returns base unmodified if sim_dir is None or has no simulation.toml.

Parameters:

Name Type Description Default
sim_dir Path | None

Directory to scan for simulation.toml. None skips the merge.

required
base SimulationConfig

Reader-built SimulationConfig to enrich.

required

Returns:

Type Description
SimulationConfig

New SimulationConfig with merged fields, or base unchanged.

Source code in src/pypic/readers/_config_helpers.py
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
def merge_simulation_toml(
    sim_dir: Path | None, base: SimulationConfig
) -> SimulationConfig:
    """Merge ``simulation.toml`` overrides into *base* if present.

    Every ``SimulationConfig`` field outside `READER_OWNED_FIELDS` is taken
    from the TOML when it is set there (``normalization``, ``frame``,
    ``transforms``, ``run``, ``probes``, ...); ``metadata`` is merged with
    TOML keys winning on conflict. Reader-owned fields come from *base*
    unchanged, so the native config stays authoritative for the grid and
    species the data was actually produced with.

    Returns *base* unmodified if *sim_dir* is ``None`` or has no
    ``simulation.toml``.

    Parameters
    ----------
    sim_dir : Path | None
        Directory to scan for ``simulation.toml``. ``None`` skips the merge.
    base : SimulationConfig
        Reader-built SimulationConfig to enrich.

    Returns
    -------
    SimulationConfig
        New ``SimulationConfig`` with merged fields, or *base* unchanged.
    """
    if sim_dir is None:
        return base
    toml_path = sim_dir / "simulation.toml"
    if not toml_path.exists():
        return base

    # Local import keeps this module free of circular import risk: config.py
    # depends on the core containers, which depend on nothing reader-specific.
    from pypic.readers.config import load_config

    toml_config = load_config(toml_path)
    overrides: dict[str, object] = {
        "metadata": {**dict(base.metadata), **dict(toml_config.metadata)}
    }
    for spec in dataclasses.fields(SimulationConfig):
        if spec.name in READER_OWNED_FIELDS or spec.name == "metadata":
            continue
        value = getattr(toml_config, spec.name)
        if value is not None and value != () and value != {}:
            overrides[spec.name] = value
    return copy.replace(base, **overrides)

score_signals(path, signals)

Sum weights of glob patterns that match entries under path.

For each (pattern, weight) pair the helper checks whether path.glob(pattern) yields at least one entry and, if so, adds weight to the running score. Intended for reader probe functions (can_read_confidence) so the glob-and-accumulate boilerplate does not get duplicated across every reader.

Signals that require reading file contents, filtering matches by regex, or distinguishing files from directories should be evaluated by the caller and added on top of the returned score. The caller is responsible for any conditional logic beyond "pattern present → add weight".

Parameters:

Name Type Description Default
path Path

Directory to scan. Non-directories return 0.0 immediately.

required
signals Sequence[tuple[str, float]]

Pairs of (glob_pattern, weight) to test against path.

required

Returns:

Type Description
float

Sum of matching weights, clamped to [0.0, 1.0].

Examples:

>>> import tempfile
>>> from pathlib import Path
>>> with tempfile.TemporaryDirectory() as d:
...     p = Path(d)
...     (p / "config.toml").touch()
...     (p / "data.h5").touch()
...     score_signals(p, [("*.toml", 0.5), ("*.h5", 0.3), ("*.nc", 0.9)])
0.8
Source code in src/pypic/readers/_protocols.py
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
def score_signals(path: Path, signals: Sequence[tuple[str, float]]) -> float:
    """Sum weights of glob patterns that match entries under *path*.

    For each ``(pattern, weight)`` pair the helper checks whether
    ``path.glob(pattern)`` yields at least one entry and, if so, adds
    ``weight`` to the running score. Intended for reader probe functions
    (``can_read_confidence``) so the glob-and-accumulate boilerplate does
    not get duplicated across every reader.

    Signals that require reading file contents, filtering matches by
    regex, or distinguishing files from directories should be evaluated
    by the caller and added on top of the returned score. The caller is
    responsible for any conditional logic beyond "pattern present → add
    weight".

    Parameters
    ----------
    path : Path
        Directory to scan. Non-directories return ``0.0`` immediately.
    signals : Sequence[tuple[str, float]]
        Pairs of ``(glob_pattern, weight)`` to test against *path*.

    Returns
    -------
    float
        Sum of matching weights, clamped to ``[0.0, 1.0]``.

    Examples
    --------
    >>> import tempfile
    >>> from pathlib import Path
    >>> with tempfile.TemporaryDirectory() as d:
    ...     p = Path(d)
    ...     (p / "config.toml").touch()
    ...     (p / "data.h5").touch()
    ...     score_signals(p, [("*.toml", 0.5), ("*.h5", 0.3), ("*.nc", 0.9)])
    0.8
    """
    if not path.is_dir():
        return 0.0
    score = 0.0
    for pattern, weight in signals:
        if next(path.glob(pattern), None) is not None:
            score += weight
    return min(score, 1.0)

supports_selective_read(reader)

Check whether reader accepts a fields keyword on read_timestep.

Inspects the method signature once at dispatch time. This is more reliable than @runtime_checkable protocols (which only check method names, not parameter signatures) and clearer than calling inspect.signature inline at the call site.

Examples:

>>> class Selective:
...     def read_timestep(self, path, step, *, fields=None): ...
...     def available_timesteps(self, path): return []
>>> supports_selective_read(Selective())
True
>>> class Basic:
...     def read_timestep(self, path, step): ...
...     def available_timesteps(self, path): return []
>>> supports_selective_read(Basic())
False
Source code in src/pypic/readers/_protocols.py
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
def supports_selective_read(reader: SimulationReader) -> bool:
    """Check whether *reader* accepts a ``fields`` keyword on ``read_timestep``.

    Inspects the method signature once at dispatch time.  This is more
    reliable than ``@runtime_checkable`` protocols (which only check
    method names, not parameter signatures) and clearer than calling
    ``inspect.signature`` inline at the call site.

    Examples
    --------
    >>> class Selective:
    ...     def read_timestep(self, path, step, *, fields=None): ...
    ...     def available_timesteps(self, path): return []
    >>> supports_selective_read(Selective())
    True
    >>> class Basic:
    ...     def read_timestep(self, path, step): ...
    ...     def available_timesteps(self, path): return []
    >>> supports_selective_read(Basic())
    False
    """
    import inspect

    sig = inspect.signature(reader.read_timestep)
    return "fields" in sig.parameters

open_simulation(path, *, reader=None, physical_extent=None, physical_extent_unit='m', **kwargs)

Open a simulation directory, auto-detecting the format.

Returns a Simulation object that remembers the data path::

sim = open_simulation(path)
sim.model_name          # "iPIC3D"
sim.grid.dimensions     # (128, 64, 64)
sim.steps               # [0, 100, 200, ...]
ds = sim.read(step=100)

Also unpacks as a (reader, config) tuple::

reader, config = open_simulation(path)

Parameters:

Name Type Description Default
path Path | str

Simulation output directory (or file).

required
reader str | ReaderFactory | None

How to select the reader:

  • None (default) — query all registered readers and pick the highest-confidence match.
  • str — look up a registered reader by name (e.g. "ipic3d").
  • callable — call it directly as reader(path, **kwargs) -> (reader, config).
None
physical_extent tuple[float, ...] | None

Physical domain size per axis in physical_extent_unit. When provided, auto-computes the spatial scale factor for frame transforms. Overrides physical_extent from TOML.

None
physical_extent_unit str

Length unit for physical_extent (default "m"). Valid: "m", "km", "R_E", "AU", "R_S".

'm'
**kwargs Any

Forwarded to the reader factory (e.g. normalization=... for OpenGGCM).

{}

Returns:

Type Description
Simulation

Wraps the reader, config, and path.

Raises:

Type Description
KeyError

If reader is a string not found in the registry.

FileNotFoundError

If auto-detection finds no matching reader.

ExceptionGroup

If every candidate reader was tried and each one failed — the usual outcome for a corrupt or ambiguous directory. The group carries one sub-exception per candidate.

PypicError

Unwrapped, aborting the candidate loop, when a reader refuses the data deliberately rather than failing to parse it — a UnsupportedGridError on a [grid.stretched] deck, say. Such a refusal comes from the shared load_config, so every remaining candidate would raise it identically.

Source code in src/pypic/readers/_registry.py
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
def open_simulation(
    path: Path | str,
    *,
    reader: str | ReaderFactory | None = None,
    physical_extent: tuple[float, ...] | None = None,
    physical_extent_unit: str = "m",
    **kwargs: Any,  # noqa: ANN401 — reader-specific kwargs
) -> Simulation:
    """Open a simulation directory, auto-detecting the format.

    Returns a `Simulation` object that remembers the data path::

        sim = open_simulation(path)
        sim.model_name          # "iPIC3D"
        sim.grid.dimensions     # (128, 64, 64)
        sim.steps               # [0, 100, 200, ...]
        ds = sim.read(step=100)

    Also unpacks as a ``(reader, config)`` tuple::

        reader, config = open_simulation(path)

    Parameters
    ----------
    path : Path | str
        Simulation output directory (or file).
    reader : str | ReaderFactory | None
        How to select the reader:

        - ``None`` (default) — query all registered readers and pick
          the highest-confidence match.
        - ``str`` — look up a registered reader by name
          (e.g. ``"ipic3d"``).
        - callable — call it directly as
          ``reader(path, **kwargs) -> (reader, config)``.
    physical_extent : tuple[float, ...] | None
        Physical domain size per axis in *physical_extent_unit*.
        When provided, auto-computes the spatial scale factor for
        frame transforms. Overrides ``physical_extent`` from TOML.
    physical_extent_unit : str
        Length unit for *physical_extent* (default ``"m"``).
        Valid: ``"m"``, ``"km"``, ``"R_E"``, ``"AU"``, ``"R_S"``.
    **kwargs
        Forwarded to the reader factory (e.g. ``normalization=...``
        for OpenGGCM).

    Returns
    -------
    Simulation
        Wraps the reader, config, and path.

    Raises
    ------
    KeyError
        If *reader* is a string not found in the registry.
    FileNotFoundError
        If auto-detection finds no matching reader.
    ExceptionGroup
        If every candidate reader was tried and each one failed — the
        usual outcome for a corrupt or ambiguous directory. The group
        carries one sub-exception per candidate.
    PypicError
        Unwrapped, aborting the candidate loop, when a reader refuses the
        data deliberately rather than failing to parse it — a
        `UnsupportedGridError` on a ``[grid.stretched]`` deck, say. Such a
        refusal comes from the shared `load_config`, so every remaining
        candidate would raise it identically.
    """
    path = Path(path)

    def _maybe_apply_extent(config: SimulationConfig) -> SimulationConfig:
        if physical_extent is not None:
            from pypic.readers.config import apply_physical_extent

            return apply_physical_extent(config, physical_extent, physical_extent_unit)
        return config

    result: tuple[SimulationReader, SimulationConfig]

    # Explicit callable override
    if callable(reader) and not isinstance(reader, str):
        result = reader(path, **kwargs)
        return Simulation(result[0], _maybe_apply_extent(result[1]), path)

    # Explicit name lookup
    if isinstance(reader, str):
        entry = _REGISTRY.get(reader)
        if entry is None:
            available = sorted(_REGISTRY)
            msg = f"No reader registered with name {reader!r}. Available: {available}"
            raise KeyError(msg)
        result = entry.factory(path, **kwargs)
        return Simulation(result[0], _maybe_apply_extent(result[1]), path)

    # Auto-detect: sensors indicate multiple possible formats
    with _lock:
        registry_snapshot = dict(_REGISTRY)
    probe_results: list[ProbeResult] = []
    scores: list[tuple[float, str]] = []
    for name, entry in sorted(registry_snapshot.items()):
        try:
            confidence = entry.can_read_confidence(path)
        except Exception as exc:  # noqa: BLE001 — a third-party probe must not
            # break detection for every other reader; the failure is recorded.
            probe_results.append(ProbeResult(name, 0.0, f"{type(exc).__name__}: {exc}"))
            log.debug(
                "can_read_confidence %r raised, skipping",
                name,
                exc_info=True,
            )
            continue
        probe_results.append(ProbeResult(name, confidence))
        if confidence > 0.0:
            scores.append((confidence, name))

    frozen_probes = tuple(probe_results)

    if not scores:
        lines = [f"No registered reader recognized {path}.", "", "Probe results:"]
        for pr in sorted(frozen_probes, key=lambda p: p.name):
            detail = f"  {pr.name}: {pr.confidence:.2f}"
            if pr.error:
                detail += f"  ({pr.error})"
            lines.append(detail)
        raise FileNotFoundError("\n".join(lines))

    # Highest confidence wins; alphabetical tiebreak for determinism
    scores.sort(key=lambda pair: (-pair[0], pair[1]))

    errors: list[Exception] = []
    for confidence, name in scores:
        try:
            log.info(
                "Trying reader %r (confidence %.2f) for %s",
                name,
                confidence,
                path,
            )
            result = registry_snapshot[name].factory(path, **kwargs)
            return Simulation(
                result[0],
                _maybe_apply_extent(result[1]),
                path,
                probe_results=frozen_probes,
            )
        except PypicError:
            # A typed refusal is about the data, not this reader: every
            # candidate resolves the same simulation.toml through the same
            # load_config, so the next one fails identically and the message
            # is worth more than the fallthrough.
            raise
        except Exception as exc:  # noqa: BLE001 — try the next candidate reader;
            # everything collected here is re-raised as one ExceptionGroup below.
            log.warning("Reader %r (confidence=%.2f) failed: %s", name, confidence, exc)
            errors.append(exc)

    raise ExceptionGroup(
        f"All candidate readers failed for {path}",
        errors,
    )

register_reader(name, can_read_confidence, factory)

Register a reader for auto-detection.

Parameters:

Name Type Description Default
name str

Short identifier (e.g. "ipic3d").

required
can_read_confidence CanReadFunction

Returns confidence in [0.0, 1.0] that path contains data readable by this reader. Must be lightweight (filesystem glob only, no actual I/O).

required
factory ReaderFactory

factory(path, **kwargs) -> (reader, config).

required

Raises:

Type Description
ValueError

If name is already registered; call unregister_reader first.

Source code in src/pypic/readers/_registry.py
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
def register_reader(
    name: str,
    can_read_confidence: CanReadFunction,
    factory: ReaderFactory,
) -> None:
    """Register a reader for auto-detection.

    Parameters
    ----------
    name : str
        Short identifier (e.g. ``"ipic3d"``).
    can_read_confidence : CanReadFunction
        Returns confidence in ``[0.0, 1.0]`` that *path* contains
        data readable by this reader.  Must be lightweight
        (filesystem glob only, no actual I/O).
    factory : ReaderFactory
        ``factory(path, **kwargs) -> (reader, config)``.

    Raises
    ------
    ValueError
        If *name* is already registered; call `unregister_reader` first.
    """
    with _lock:
        if name in _REGISTRY:
            msg = f"Reader {name!r} is already registered"
            raise ValueError(msg)
        _REGISTRY[name] = ReaderEntry(
            name=name,
            can_read_confidence=can_read_confidence,
            factory=factory,
        )

registered_readers()

Return a read-only view of all registered readers.

Source code in src/pypic/readers/_registry.py
152
153
154
def registered_readers() -> MappingProxyType[str, ReaderEntry]:
    """Return a read-only view of all registered readers."""
    return MappingProxyType(_REGISTRY)

unregister_reader(name)

Remove a reader from the registry.

Raises:

Type Description
KeyError

If name is not registered.

Source code in src/pypic/readers/_registry.py
136
137
138
139
140
141
142
143
144
145
146
147
148
149
def unregister_reader(name: str) -> None:
    """Remove a reader from the registry.

    Raises
    ------
    KeyError
        If *name* is not registered.
    """
    with _lock:
        try:
            del _REGISTRY[name]
        except KeyError:
            msg = f"No reader registered with name {name!r}"
            raise KeyError(msg) from None

open_simple(path, *, file_pattern='output_{step:06d}.h5', field_map=None, grid=None, normalization=None, config=None, config_path=None, fields_group='fields')

Open a directory of HDF5 files.

Returns a Simulation object::

sim = open_simple(path, field_map={...})
sim.steps               # [0, 100, 200]
ds = sim.read(step=100)

Metadata resolution (each level overrides the next):

  1. simulation.toml from config_path or in path.
  2. HDF5 grid/ attributes in the first matching file.
  3. Explicit grid / normalization parameters.
  4. Explicit config parameter.

For most cases you only need to supply what the files lack.

Parameters:

Name Type Description Default
path Path

Directory containing HDF5 output files.

required
file_pattern str

Filename pattern with {step} placeholder.

'output_{step:06d}.h5'
field_map dict[str, str] | None

Native-to-canonical field name mapping.

None
grid GridInfo | None

Explicit grid metadata (when HDF5 files lack it).

None
normalization Normalization | None

Unit normalization (defaults to identity).

None
config SimulationConfig | None

Full simulation configuration. When provided, grid and normalization are ignored.

None
config_path Path | None

Explicit path to a simulation.toml file. When None, auto-detected from path.

None
fields_group str

HDF5 group name containing field datasets.

'fields'

Returns:

Type Description
Simulation

Wraps the reader, config, and path.

Source code in src/pypic/readers/_simple.py
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
def open_simple(
    path: Path,
    *,
    file_pattern: str = "output_{step:06d}.h5",
    field_map: dict[str, str] | None = None,
    grid: GridInfo | None = None,
    normalization: Normalization | None = None,
    config: SimulationConfig | None = None,
    config_path: Path | None = None,
    fields_group: str = "fields",
) -> Simulation:
    """Open a directory of HDF5 files.

    Returns a `Simulation` object::

        sim = open_simple(path, field_map={...})
        sim.steps               # [0, 100, 200]
        ds = sim.read(step=100)

    Metadata resolution (each level overrides the next):

    1. ``simulation.toml`` from *config_path* or in *path*.
    2. HDF5 ``grid/`` attributes in the first matching file.
    3. Explicit *grid* / *normalization* parameters.
    4. Explicit *config* parameter.

    For most cases you only need to supply what the files lack.

    Parameters
    ----------
    path : Path
        Directory containing HDF5 output files.
    file_pattern : str
        Filename pattern with ``{step}`` placeholder.
    field_map : dict[str, str] | None
        Native-to-canonical field name mapping.
    grid : GridInfo | None
        Explicit grid metadata (when HDF5 files lack it).
    normalization : Normalization | None
        Unit normalization (defaults to identity).
    config : SimulationConfig | None
        Full simulation configuration.  When provided, *grid* and
        *normalization* are ignored.
    config_path : Path | None
        Explicit path to a ``simulation.toml`` file.  When
        ``None``, auto-detected from *path*.
    fields_group : str
        HDF5 group name containing field datasets.

    Returns
    -------
    Simulation
        Wraps the reader, config, and path.
    """
    # Deferred to avoid circular import: _registry imports _simple at module level
    from pypic.readers._registry import Simulation

    reader, resolved = _open_reader(
        path,
        file_pattern=file_pattern,
        field_map=field_map,
        grid=grid,
        normalization=normalization,
        config=config,
        config_path=config_path,
        fields_group=fields_group,
    )
    return Simulation(reader, resolved, pathlib.Path(path))

open_batsrus(path, *, config_path=None)

Auto-detect BATSRUS output format and return a reader.

Detection order: 1. .batl files → HDF5 BATL format 2. .h + *_pe*.idl files → per-cell IDL binary 3. .out / .outs files → merged IDL

Prefers 3D data over 2D slices when both are available.

Parameters:

Name Type Description Default
path Path

Directory containing BATSRUS output files.

required
config_path Path | None

Explicit path to a PARAM.in file. When None, auto-detected from path.

None

Returns:

Name Type Description
reader SimulationReader

A BATSRUSReader instance.

config SimulationConfig

Simulation configuration parsed from PARAM.in and/or headers.

Raises:

Type Description
FileNotFoundError

If no recognizable BATSRUS output is found.

Source code in src/pypic/readers/batsrus/__init__.py
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
def open_batsrus(
    path: Path,
    *,
    config_path: Path | None = None,
) -> tuple[SimulationReader, SimulationConfig]:
    """Auto-detect BATSRUS output format and return a reader.

    Detection order:
    1. ``.batl`` files → HDF5 BATL format
    2. ``.h`` + ``*_pe*.idl`` files → per-cell IDL binary
    3. ``.out`` / ``.outs`` files → merged IDL

    Prefers 3D data over 2D slices when both are available.

    Parameters
    ----------
    path
        Directory containing BATSRUS output files.
    config_path : Path | None
        Explicit path to a ``PARAM.in`` file.  When ``None``,
        auto-detected from *path*.

    Returns
    -------
    reader : SimulationReader
        A `BATSRUSReader` instance.
    config : SimulationConfig
        Simulation configuration parsed from ``PARAM.in``
        and/or headers.

    Raises
    ------
    FileNotFoundError
        If no recognizable BATSRUS output is found.
    """
    path = Path(path)
    param_file = config_path or (path / "PARAM.in")
    batsrus_config = (
        parse_param_in(param_file) if param_file.exists() else BATSRUSConfig()
    )

    # Detect format and find the best prefix
    batl_files = sorted(path.glob("*.batl"))
    h_files = sorted(path.glob("*.h"))
    out_files = sorted(path.glob("*.out")) + sorted(path.glob("*.outs"))

    if batl_files:
        output_format = BATSRUSOutputFormat.HDF5
        prefix = _detect_prefix_batl(batl_files)
    elif h_files:
        output_format = BATSRUSOutputFormat.IDL
        prefix = _detect_prefix_h(h_files)
    elif out_files:
        output_format = BATSRUSOutputFormat.OUT
        prefix = _detect_prefix_out(out_files)
    else:
        msg = f"No BATSRUS output files found in {path}"
        raise FileNotFoundError(msg)

    # Extract geometry from header if available
    geometry = "cartesian"
    header = None
    grid = None
    if h_files:
        header = parse_header(h_files[0])
        geometry = header.geometry

    batsrus_config = copy.replace(batsrus_config, geometry=geometry)

    if batl_files and header is None:
        batl = read_batl(batl_files[0])
        is_uniform = len(set(batl.refine_level)) <= 1
        if is_uniform:
            _, grid = assemble_uniform_hdf5(batl)
        else:
            _, grid = regrid_amr_hdf5(batl)

    sim_config = to_simulation_config(batsrus_config, header, grid=grid, sim_dir=path)
    reader = BATSRUSReader(
        batsrus_config, output_format, prefix, geometry=geometry, sim_config=sim_config
    )
    return reader, sim_config

parse_param_in(path)

Parse a BATSRUS PARAM.in file.

Parameters:

Name Type Description Default
path Path

Path to the PARAM.in file.

required

Returns:

Type Description
BATSRUSConfig

Frozen dataclass with extracted configuration.

Source code in src/pypic/readers/batsrus/_config.py
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
def parse_param_in(path: Path) -> BATSRUSConfig:
    """Parse a BATSRUS ``PARAM.in`` file.

    Parameters
    ----------
    path
        Path to the ``PARAM.in`` file.

    Returns
    -------
    BATSRUSConfig
        Frozen dataclass with extracted configuration.
    """
    text = path.read_text()
    lines = text.splitlines()

    description = ""
    coord_system = "simulation"
    n_root = [1, 1, 1]
    domain_min = [0.0, 0.0, 0.0]
    domain_max = [1.0, 1.0, 1.0]
    gamma = 5.0 / 3.0
    io_units = ""
    normalization_type = ""
    body_radius: float | None = None
    body_density_dim: float | None = None
    body_temp_dim: float | None = None
    solar_wind: dict[str, float] = {}
    start_time: dict[str, int] = {}
    dt_fixed: float | None = None
    use_splitb = False
    divb_method = ""
    outer_boundary: tuple[str, ...] = ()
    metadata: dict[str, Any] = {}

    i = 0
    while i < len(lines):
        line = lines[i].strip()

        if line == "#DESCRIPTION" and i + 1 < len(lines):
            description = lines[i + 1].strip()
            i += 2
            continue

        if line == "#COORDSYSTEM" and i + 1 < len(lines):
            coord_system = _first_token(lines[i + 1])
            i += 2
            continue

        if line == "#IOUNITS" and i + 1 < len(lines):
            io_units = _first_token(lines[i + 1])
            i += 2
            continue

        if line == "#NORMALIZATION" and i + 1 < len(lines):
            normalization_type = _first_token(lines[i + 1])
            i += 2
            continue

        if line == "#GAMMA" and i + 1 < len(lines):
            gamma = float(_first_token(lines[i + 1]))
            i += 2
            continue

        if line == "#GRID" and i + 9 < len(lines):
            n_root[0] = int(_first_token(lines[i + 1]))
            n_root[1] = int(_first_token(lines[i + 2]))
            n_root[2] = int(_first_token(lines[i + 3]))
            domain_min[0] = float(_first_token(lines[i + 4]))
            domain_max[0] = float(_first_token(lines[i + 5]))
            domain_min[1] = float(_first_token(lines[i + 6]))
            domain_max[1] = float(_first_token(lines[i + 7]))
            domain_min[2] = float(_first_token(lines[i + 8]))
            domain_max[2] = float(_first_token(lines[i + 9]))
            i += 10
            continue

        if line == "#BODY" and i + 1 < len(lines):
            use_body = _first_token(lines[i + 1])
            if use_body == "T" and i + 4 < len(lines):
                body_radius = float(_first_token(lines[i + 2]))
                # skip rCurrents
                body_density_dim = float(_first_token(lines[i + 4]))
                body_temp_dim = float(_first_token(lines[i + 5]))
                i += 6
            else:
                i += 2
            continue

        if line == "#SOLARWIND" and i + 8 < len(lines):
            solar_wind = {
                "rho_dim": float(_first_token(lines[i + 1])),
                "t_dim": float(_first_token(lines[i + 2])),
                "ux_dim": float(_first_token(lines[i + 3])),
                "uy_dim": float(_first_token(lines[i + 4])),
                "uz_dim": float(_first_token(lines[i + 5])),
                "bx_dim": float(_first_token(lines[i + 6])),
                "by_dim": float(_first_token(lines[i + 7])),
                "bz_dim": float(_first_token(lines[i + 8])),
            }
            i += 9
            continue

        if line == "#STARTTIME" and i + 7 < len(lines):
            start_time = {
                "year": int(_first_token(lines[i + 1])),
                "month": int(_first_token(lines[i + 2])),
                "day": int(_first_token(lines[i + 3])),
                "hour": int(_first_token(lines[i + 4])),
                "minute": int(_first_token(lines[i + 5])),
                "second": int(_first_token(lines[i + 6])),
            }
            i += 8
            continue

        if line == "#FIXEDTIMESTEP" and i + 2 < len(lines):
            use_fixed = _first_token(lines[i + 1])
            if use_fixed == "T":
                dt_fixed = float(_first_token(lines[i + 2]))
            i += 3
            continue

        if line == "#SCHEME" and i + 1 < len(lines):
            n_order = int(_first_token(lines[i + 1]))
            metadata["scheme_order"] = n_order
            if i + 2 < len(lines):
                metadata["flux_type"] = _first_token(lines[i + 2])
            i += 3 + max(0, n_order - 1)
            continue

        if line == "#SPLITB" and i + 1 < len(lines):
            use_splitb = _first_token(lines[i + 1]) == "T"
            i += 2
            continue

        if line == "#DIVB" and i + 1 < len(lines):
            divb_method = _first_token(lines[i + 1])
            i += 2
            continue

        if line == "#OUTERBOUNDARY":
            # Unlike every other command here the face count is variable
            # (4 in 2D, 6 in 3D), so read to the next blank or command line.
            faces: list[str] = []
            j = i + 1
            while j < len(lines) and len(faces) < 6:
                entry = lines[j].strip()
                if not entry or entry.startswith("#"):
                    break
                faces.append(_first_token(entry).lower())
                j += 1
            outer_boundary = tuple(faces)
            i = j
            continue

        i += 1

    return BATSRUSConfig(
        description=description,
        coord_system=coord_system,
        n_root_blocks=tuple(n_root),  # type: ignore[arg-type]
        domain_min=tuple(domain_min),  # type: ignore[arg-type]
        domain_max=tuple(domain_max),  # type: ignore[arg-type]
        gamma=gamma,
        io_units=io_units,
        normalization_type=normalization_type,
        body_radius=body_radius,
        body_density_dim=body_density_dim,
        body_temp_dim=body_temp_dim,
        solar_wind=solar_wind,
        start_time=start_time,
        dt_fixed=dt_fixed,
        geometry="cartesian",
        use_splitb=use_splitb,
        divb_method=divb_method,
        outer_boundary=outer_boundary,
        metadata=metadata,
    )

load_config(path)

Parse a simulation.toml file into a SimulationConfig.

Validates the file against the v2.0 schema (pypic.schema) and builds the internal SimulationConfig from the result. The raw TOML text is captured and attached to metadata["simulation_toml"] so downstream FieldDataset writers can round-trip it verbatim into attrs.simulation_toml (schema.md §4.2) — losslessly preserving sections ([bodies], [drivers], [output], [restart], [probes], ...) that the typed SimulationConfig drops on the way to FieldDataset.

Parameters:

Name Type Description Default
path Path

Path to a TOML file conforming to the v2.0 schema.

required

Returns:

Type Description
SimulationConfig

Fully typed configuration with grid, normalization, species, physics, frame, and transforms populated.

Raises:

Type Description
ValidationError

If the document fails schema validation. Dotted field paths in the error message point to every violation.

UnsupportedGridError

If the document is valid but declares a grid pypic cannot represent — today, [grid.stretched]. The two are different statements: the first says the deck is wrong, this one says pypic's reader is what is missing.

Source code in src/pypic/readers/config.py
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
def load_config(path: Path) -> SimulationConfig:
    """Parse a ``simulation.toml`` file into a SimulationConfig.

    Validates the file against the v2.0 schema
    ([`pypic.schema`][pypic.schema]) and builds the internal `SimulationConfig`
    from the result.  The raw TOML text is captured and attached to
    ``metadata["simulation_toml"]`` so downstream FieldDataset writers
    can round-trip it verbatim into ``attrs.simulation_toml`` (schema.md
    §4.2) — losslessly preserving sections (``[bodies]``, ``[drivers]``,
    ``[output]``, ``[restart]``, ``[probes]``, ...) that the typed
    SimulationConfig drops on the way to FieldDataset.

    Parameters
    ----------
    path : Path
        Path to a TOML file conforming to the v2.0 schema.

    Returns
    -------
    SimulationConfig
        Fully typed configuration with grid, normalization, species,
        physics, frame, and transforms populated.

    Raises
    ------
    pydantic.ValidationError
        If the document fails schema validation. Dotted field paths in
        the error message point to every violation.
    UnsupportedGridError
        If the document is valid but declares a grid pypic cannot
        represent — today, ``[grid.stretched]``.  The two are different
        statements: the first says the deck is wrong, this one says
        pypic's reader is what is missing.
    """
    raw_text = Path(path).read_text(encoding="utf-8")
    schema = validate_simulation_toml(raw_text)
    config = _from_schema(schema)
    # Re-stamp metadata with the verbatim TOML text.  ``_from_schema``
    # already populated typed sections; this is purely additive.
    new_metadata = dict(config.metadata)
    new_metadata["simulation_toml"] = raw_text
    return copy.replace(config, metadata=new_metadata)

conserved_to_tabular(cq)

Convert a ConservedQuantities to a generic TabularData.

Scalar fields map directly. Per-species tuples are flattened to "npart_s0", "charge_s0", "kinetic_energy_s0", etc.

Parameters:

Name Type Description Default
cq ConservedQuantities

Typed iPIC3D conserved quantities.

required

Returns:

Type Description
TabularData

Columnar representation with index_column="cycle".

Examples:

>>> import numpy as np
>>> cq = ConservedQuantities(
...     cycle=np.array([0.0, 1.0]),
...     total_energy=np.array([5.0, 5.1]),
...     electric_energy=np.array([1.0, 1.1]),
...     magnetic_energy=np.array([2.0, 2.0]),
...     kinetic_energy=np.array([2.0, 2.0]),
...     momentum=np.array([0.1, 0.1]),
...     species_npart=(np.array([100.0, 100.0]),),
...     species_charge=(np.array([1.0, 1.0]),),
...     species_kinetic_energy=(np.array([1.0, 1.0]),),
... )
>>> tab = conserved_to_tabular(cq)
>>> "npart_s0" in tab
True
>>> tab.index_column
'cycle'
Source code in src/pypic/readers/ipic3d/_conserved.py
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
def conserved_to_tabular(cq: ConservedQuantities) -> TabularData:
    """Convert a ``ConservedQuantities`` to a generic ``TabularData``.

    Scalar fields map directly.  Per-species tuples are flattened to
    ``"npart_s0"``, ``"charge_s0"``, ``"kinetic_energy_s0"``, etc.

    Parameters
    ----------
    cq : ConservedQuantities
        Typed iPIC3D conserved quantities.

    Returns
    -------
    TabularData
        Columnar representation with ``index_column="cycle"``.

    Examples
    --------
    >>> import numpy as np
    >>> cq = ConservedQuantities(
    ...     cycle=np.array([0.0, 1.0]),
    ...     total_energy=np.array([5.0, 5.1]),
    ...     electric_energy=np.array([1.0, 1.1]),
    ...     magnetic_energy=np.array([2.0, 2.0]),
    ...     kinetic_energy=np.array([2.0, 2.0]),
    ...     momentum=np.array([0.1, 0.1]),
    ...     species_npart=(np.array([100.0, 100.0]),),
    ...     species_charge=(np.array([1.0, 1.0]),),
    ...     species_kinetic_energy=(np.array([1.0, 1.0]),),
    ... )
    >>> tab = conserved_to_tabular(cq)
    >>> "npart_s0" in tab
    True
    >>> tab.index_column
    'cycle'
    """
    columns: dict[str, FloatArray] = {
        "cycle": cq.cycle,
        "total_energy": cq.total_energy,
        "electric_energy": cq.electric_energy,
        "magnetic_energy": cq.magnetic_energy,
        "kinetic_energy": cq.kinetic_energy,
        "momentum": cq.momentum,
    }
    for s, arr in enumerate(cq.species_npart):
        columns[f"npart_s{s}"] = arr
    for s, arr in enumerate(cq.species_charge):
        columns[f"charge_s{s}"] = arr
    for s, arr in enumerate(cq.species_kinetic_energy):
        columns[f"kinetic_energy_s{s}"] = arr

    return TabularData(
        name="conserved_quantities",
        columns=columns,
        index_column="cycle",
        metadata={"source": "iPIC3D ConservedQuantities"},
    )

detect_particle_steps(path)

Scan for Particles_XXXXX/ directories and return sorted step list.

Parameters:

Name Type Description Default
path Path

Simulation output directory.

required

Returns:

Type Description
list[int]

Sorted timestep indices with particle output.

Source code in src/pypic/readers/ipic3d/_particles.py
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
def detect_particle_steps(path: Path) -> list[int]:
    """Scan for ``Particles_XXXXX/`` directories and return sorted step list.

    Parameters
    ----------
    path : Path
        Simulation output directory.

    Returns
    -------
    list[int]
        Sorted timestep indices with particle output.
    """
    pattern = re.compile(r"^Particles_(\d+)$")
    steps: list[int] = []
    for entry in path.iterdir():
        if entry.is_dir():
            m = pattern.match(entry.name)
            if m:
                steps.append(int(m.group(1)))
    return sorted(steps)

load_conserved_quantities(path)

Load conserved quantities from an iPIC3D run, auto-detecting format.

Parameters:

Name Type Description Default
path Path

Either a single ConservedQuantities.txt file (Format A) or a directory containing ConservedQuantities*.txt files (Format B).

required

Returns:

Type Description
ConservedQuantities

Parsed time series.

Raises:

Type Description
FileNotFoundError

If no conserved quantities data is found.

Source code in src/pypic/readers/ipic3d/_conserved.py
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
def load_conserved_quantities(path: Path) -> ConservedQuantities:
    """Load conserved quantities from an iPIC3D run, auto-detecting format.

    Parameters
    ----------
    path : Path
        Either a single ``ConservedQuantities.txt`` file (Format A) or
        a directory containing ``ConservedQuantities*.txt`` files (Format B).

    Returns
    -------
    ConservedQuantities
        Parsed time series.

    Raises
    ------
    FileNotFoundError
        If no conserved quantities data is found.
    """
    if path.is_dir():
        return _parse_multi(path)

    text = path.read_text()
    if _is_roman_header(text):
        return _parse_single(path)

    # Single Format B file
    data, nspec = _parse_multi_file(path)
    species_npart: list[FloatArray] = []
    species_charge: list[FloatArray] = []
    species_ke: list[FloatArray] = []
    for s in range(nspec):
        base_col = _B_SPECIES_BASE + s * _B_SPECIES_STRIDE
        species_npart.append(data[:, base_col])
        species_charge.append(data[:, base_col + 1])
        species_ke.append(data[:, base_col + 2])

    return ConservedQuantities(
        cycle=data[:, _B_CYCLE],
        total_energy=data[:, _B_TOTAL],
        electric_energy=data[:, _B_ELECTRIC],
        magnetic_energy=data[:, _B_MAGNETIC],
        kinetic_energy=data[:, _B_KINETIC],
        momentum=data[:, _B_MOMENTUM],
        species_npart=tuple(species_npart),
        species_charge=tuple(species_charge),
        species_kinetic_energy=tuple(species_ke),
    )

open_ipic3d(path, *, config_path=None)

Auto-detect iPIC3D format and return the appropriate reader.

Detection priority:

  1. Parse config from .inp or settings.hdf.
  2. If *-Fields_*.h5 files exist → IPic3DH5hutReader.
  3. If WriteMethod == "shdf5"IPic3DSerialReader.
  4. If WriteMethod == "h5hut"IPic3DH5hutReader.
  5. Default → IPic3DParallelReader.

File-based detection (step 2) takes precedence because WriteMethod is often commented out in H5hut runs.

Parameters:

Name Type Description Default
path Path

Simulation output directory.

required
config_path Path | None

Explicit path to an .inp or settings.hdf file. When None, auto-detected from path.

None

Returns:

Type Description
tuple[SimulationReader, SimulationConfig]

A (reader, config) pair ready for reader.read_timestep(path, step).

Raises:

Type Description
FileNotFoundError

If no .inp or settings.hdf file is found.

Source code in src/pypic/readers/ipic3d/__init__.py
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
def open_ipic3d(
    path: Path,
    *,
    config_path: Path | None = None,
) -> tuple[SimulationReader, SimulationConfig]:
    """Auto-detect iPIC3D format and return the appropriate reader.

    Detection priority:

    1. Parse config from ``.inp`` or ``settings.hdf``.
    2. If ``*-Fields_*.h5`` files exist → `IPic3DH5hutReader`.
    3. If ``WriteMethod == "shdf5"`` → `IPic3DSerialReader`.
    4. If ``WriteMethod == "h5hut"`` → `IPic3DH5hutReader`.
    5. Default → `IPic3DParallelReader`.

    File-based detection (step 2) takes precedence because
    ``WriteMethod`` is often commented out in H5hut runs.

    Parameters
    ----------
    path : Path
        Simulation output directory.
    config_path : Path | None
        Explicit path to an ``.inp`` or ``settings.hdf`` file.
        When ``None``, auto-detected from *path*.

    Returns
    -------
    tuple[SimulationReader, SimulationConfig]
        A (reader, config) pair ready for
        ``reader.read_timestep(path, step)``.

    Raises
    ------
    FileNotFoundError
        If no ``.inp`` or ``settings.hdf`` file is found.
    """
    if config_path is not None:
        suffix = config_path.suffix
        if suffix == ".hdf":
            cfg = parse_settings_hdf(config_path)
        else:
            cfg = parse_inp(config_path)
    elif inp_files := list(path.glob("*.inp")):
        cfg = parse_inp(inp_files[0])
    elif (path / "settings.hdf").exists():
        cfg = parse_settings_hdf(path / "settings.hdf")
    else:
        msg = f"No .inp or settings.hdf found in {path}"
        raise FileNotFoundError(msg)

    sim_config = to_simulation_config(cfg, path)

    reader: SimulationReader
    if _has_h5hut_files(path):
        reader = IPic3DH5hutReader(cfg, sim_config)
    else:
        match cfg.write_method:
            case "shdf5":
                reader = IPic3DSerialReader(cfg, sim_config)
            case "h5hut":
                reader = IPic3DH5hutReader(cfg, sim_config)
            case _:
                reader = IPic3DParallelReader(cfg, sim_config)

    return reader, sim_config

parse_inp(path)

Parse an iPIC3D .inp configuration file.

Parameters:

Name Type Description Default
path Path

Path to the .inp file.

required

Returns:

Type Description
IPic3DConfig

Parsed configuration.

Raises:

Type Description
ExceptionGroup

If required keys are missing.

Source code in src/pypic/readers/ipic3d/_config.py
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
def parse_inp(path: Path) -> IPic3DConfig:
    """Parse an iPIC3D ``.inp`` configuration file.

    Parameters
    ----------
    path : Path
        Path to the ``.inp`` file.

    Returns
    -------
    IPic3DConfig
        Parsed configuration.

    Raises
    ------
    ExceptionGroup
        If required keys are missing.
    """
    kv: dict[str, str] = {}
    text = path.read_text()
    for raw_line in text.splitlines():
        line = raw_line.strip()
        if not line or line.startswith("#"):
            continue
        match = re.match(r"(\w+)\s*=\s*(.+)", line)
        if match:
            raw_value = match.group(2)
            if "#" in raw_value:
                raw_value = raw_value[: raw_value.index("#")]
            kv[match.group(1)] = raw_value.strip()

    errors: list[Exception] = []

    def require(key: str) -> str:
        if key not in kv:
            errors.append(KeyError(f"Missing required key: {key!r}"))
            return ""
        return kv[key]

    # Grid
    nxc_s = require("nxc")
    nyc_s = require("nyc")
    nzc_s = require("nzc")
    lx_s = require("Lx")
    ly_s = require("Ly")
    lz_s = require("Lz")

    # Time / physics
    dt_s = require("dt")
    c_s = require("c")
    th_s = require("th")

    # MPI
    xlen_s = require("XLEN")
    ylen_s = require("YLEN")
    zlen_s = require("ZLEN")

    # Species
    ns_s = require("ns")
    qom_s = require("qom")

    if errors:
        raise ExceptionGroup("Missing keys in iPIC3D .inp file", errors)

    nxc = int(nxc_s)
    nyc = int(nyc_s)
    nzc = int(nzc_s)
    lx = float(lx_s)
    ly = float(ly_s)
    lz = float(lz_s)
    ns = int(ns_s)

    qom = _parse_array_float(qom_s)
    if len(qom) < ns:
        errors.append(ValueError(f"qom has {len(qom)} entries, expected ns={ns}"))
    elif len(qom) > ns:
        log.debug("qom has %d entries but ns=%d; truncating", len(qom), ns)
        qom = qom[:ns]
    _per_species_keys = (
        "uth",
        "vth",
        "wth",
        "u0",
        "v0",
        "w0",
        "rhoINIT",
        "rhoINJECT",
        "npcelx",
        "npcely",
        "npcelz",
    )
    for key in _per_species_keys:
        if key in kv:
            n = len(kv[key].split())
            if n < ns:
                errors.append(ValueError(f"{key} has {n} entries, expected ns={ns}"))
            elif n > ns:
                log.debug("%s has %d entries but ns=%d; truncating", key, n, ns)
                kv[key] = " ".join(kv[key].split()[:ns])
    if errors:
        raise ExceptionGroup("Validation errors in iPIC3D .inp file", errors)

    return IPic3DConfig(
        nxc=nxc,
        nyc=nyc,
        nzc=nzc,
        lx=lx,
        ly=ly,
        lz=lz,
        dx=lx / nxc,
        dy=ly / nyc,
        dz=lz / nzc,
        dt=float(dt_s),
        xlen=int(xlen_s),
        ylen=int(ylen_s),
        zlen=int(zlen_s),
        c=float(c_s),
        th=float(th_s),
        b0=(
            float(kv.get("B0x", "0.0")),
            float(kv.get("B0y", "0.0")),
            float(kv.get("B0z", "0.0")),
        ),
        ns=ns,
        qom=qom,
        uth=_parse_array_float(kv.get("uth", " ".join(["0.0"] * ns))),
        vth=_parse_array_float(kv.get("vth", " ".join(["0.0"] * ns))),
        wth=_parse_array_float(kv.get("wth", " ".join(["0.0"] * ns))),
        u0=_parse_array_float(kv.get("u0", " ".join(["0.0"] * ns))),
        v0=_parse_array_float(kv.get("v0", " ".join(["0.0"] * ns))),
        w0=_parse_array_float(kv.get("w0", " ".join(["0.0"] * ns))),
        rho_init=_parse_array_float(kv.get("rhoINIT", " ".join(["1.0"] * ns))),
        npcelx=_parse_array_int(kv.get("npcelx", " ".join(["0"] * ns))),
        npcely=_parse_array_int(kv.get("npcely", " ".join(["0"] * ns))),
        npcelz=_parse_array_int(kv.get("npcelz", " ".join(["0"] * ns))),
        periodic_x=kv.get("PERIODICX", "0") == "1",
        periodic_y=kv.get("PERIODICY", "0") == "1",
        periodic_z=kv.get("PERIODICZ", "0") == "1",
        write_method=kv.get("WriteMethod", "phdf5"),
        field_output_cycle=int(kv.get("FieldOutputCycle", "0")),
        field_output_tag=kv.get("FieldOutputTag", ""),
        particles_output_cycle=int(kv.get("ParticlesOutputCycle", "0")),
        case=kv.get("Case", ""),
        simulation_name=kv.get("SimulationName", ""),
    )

read_phdf5_particles(path, step, species, config, *, columns=None)

Read particle data from a phdf5-format iPIC3D output file.

Parameters:

Name Type Description Default
path Path

Simulation output directory.

required
step int

Timestep index.

required
species int

Zero-based species index.

required
config IPic3DConfig

Parsed iPIC3D configuration. Provides per-species charge/mass via qom. Emits canonical form: weight (derived from native per-particle q as |q| since iPIC3D sets |q_species| = 1) plus scalar species_charge and species_mass.

required
columns Iterable[str] | None

Subset of {"position", "velocity"} to load. None loads all.

None

Returns:

Type Description
ParticleData

Raises:

Type Description
UnknownFieldError

If columns names anything outside {"position", "velocity"}. A typo fails here rather than silently dropping the column.

Source code in src/pypic/readers/ipic3d/_particles.py
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
def read_phdf5_particles(
    path: Path,
    step: int,
    species: int,
    config: IPic3DConfig,
    *,
    columns: Iterable[str] | None = None,
) -> ParticleData:
    r"""Read particle data from a phdf5-format iPIC3D output file.

    Parameters
    ----------
    path : Path
        Simulation output directory.
    step : int
        Timestep index.
    species : int
        Zero-based species index.
    config : IPic3DConfig
        Parsed iPIC3D configuration.  Provides per-species charge/mass via
        ``qom``.  Emits canonical form: ``weight`` (derived from native
        per-particle ``q`` as ``|q|`` since iPIC3D sets ``|q_species| = 1``)
        plus scalar ``species_charge`` and ``species_mass``.
    columns : Iterable[str] | None
        Subset of ``{"position", "velocity"}`` to load.  ``None`` loads all.

    Returns
    -------
    ParticleData

    Raises
    ------
    UnknownFieldError
        If *columns* names anything outside ``{"position", "velocity"}``.
        A typo fails here rather than silently dropping the column.
    """
    step_str = f"{step:05d}"
    h5_path = path / f"Particles_{step_str}" / f"species_{species}_{step_str}.h5"
    group_name = f"Particles/species_{species}"

    want = set(columns) if columns is not None else set(_PARTICLE_COLUMNS)
    unknown = want - _PARTICLE_COLUMNS
    if unknown:
        msg = (
            f"Unknown particle column(s) {sorted(unknown)}. "
            f"Available: {sorted(_PARTICLE_COLUMNS)}."
        )
        raise UnknownFieldError(msg)

    with h5py.File(h5_path, "r") as f:
        group = f[group_name]

        # Determine n_particles from whichever dataset is available
        if "position" in group:
            n_particles = group["position"].shape[0]
        elif "velocity" in group:
            n_particles = group["velocity"].shape[0]
        else:
            msg = f"No position or velocity dataset in {h5_path}:{group_name}"
            raise ValueError(msg)

        position = None
        if "position" in want:
            position = np.array(group["position"])

        velocity = None
        if "velocity" in want:
            velocity = np.array(group["velocity"])

        # iPIC3D stores macroparticle charge q_macro = q_s * w. |q_s| = 1
        # by convention, so weight = |q_macro|. Uniform-weight runs emit a
        # scalar/singleton; particle-splitting or non-uniform-density runs
        # emit a per-particle array.
        q_raw = np.asarray(group["q"])
        if q_raw.size == n_particles:
            weight = np.abs(q_raw.reshape(n_particles).astype(np.float64))
        elif q_raw.size == 1:
            weight = np.full(n_particles, abs(float(q_raw.flat[0])), dtype=np.float64)
        else:
            msg = (
                f"iPIC3D 'q' dataset size {q_raw.size} is neither 1 nor "
                f"n_particles={n_particles} in {h5_path}:{group_name}"
            )
            raise ValueError(msg)

        # ID: optional integer tracking ID
        particle_id = None
        if "ID" in group:
            particle_id = np.array(group["ID"], dtype=np.int64).ravel()

    species_name = f"species_{species}"

    # iPIC3D normalization: |q_species| = 1, sign(q_species) = sign(qom[s]),
    # m_species = 1 / |qom[s]|.
    qom_s = config.qom[species]
    species_charge = float(np.sign(qom_s))
    species_mass = 1.0 / abs(qom_s)

    return ParticleData(
        species_index=species,
        species_name=species_name,
        position=position,
        velocity=velocity,
        n_particles=n_particles,
        metadata={"path": str(h5_path), "format": "phdf5"},
        id=particle_id,
        weight=weight,
        species_charge=species_charge,
        species_mass=species_mass,
    )

open_openggcm(path, normalization=None, *, config_path=None)

Auto-detect OpenGGCM files and return a reader + config.

Looks for grid.*.dat and *.3df.* files under path.

Parameters:

Name Type Description Default
path Path

Directory containing OpenGGCM output files.

required
normalization Normalization | None

If provided, data is normalized from SI to code units.

None
config_path Path | None

Explicit path to a grid.*.dat file. When None, auto-detected from path.

None

Returns:

Name Type Description
reader OpenGGCMReader

Configured reader instance.

config SimulationConfig

Simulation metadata.

Source code in src/pypic/readers/openggcm/__init__.py
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
def open_openggcm(
    path: Path,
    normalization: Normalization | None = None,
    *,
    config_path: Path | None = None,
) -> tuple[OpenGGCMReader, SimulationConfig]:
    """Auto-detect OpenGGCM files and return a reader + config.

    Looks for ``grid.*.dat`` and ``*.3df.*`` files under *path*.

    Parameters
    ----------
    path : Path
        Directory containing OpenGGCM output files.
    normalization : Normalization | None
        If provided, data is normalized from SI to code units.
    config_path : Path | None
        Explicit path to a ``grid.*.dat`` file.  When ``None``,
        auto-detected from *path*.

    Returns
    -------
    reader : OpenGGCMReader
        Configured reader instance.
    config : SimulationConfig
        Simulation metadata.
    """
    # Find grid file
    if config_path is not None:
        grid_file = config_path
    else:
        grid_files = list(path.glob("grid.*.dat"))
        if not grid_files:
            msg = f"No grid.*.dat file found in {path}"
            raise FileNotFoundError(msg)
        grid_file = grid_files[0]
    grid = parse_grid_file(grid_file)
    log.info(
        "Grid: %d x %d x %d, x=[%.1f, %.1f] R_E",
        grid.nx,
        grid.ny,
        grid.nz,
        grid.x[0],
        grid.x[-1],
    )

    # Detect prefix from .3df files
    prefix = _detect_prefix(path)

    grid_info = _make_grid_info(grid)
    base_config = SimulationConfig(
        model_name="OpenGGCM",
        model_type="MHD",
        grid=grid_info,
        normalization=normalization or Normalization.undeclared(),
        physics=PhysicsParams(),
        frame="GSM",
        metadata={
            "grid_file": grid_file.name,
            "prefix": prefix,
            **dict(grid.metadata),
        },
    )

    config = merge_simulation_toml(path, base_config)
    return OpenGGCMReader(grid, prefix, config), config

parse_grid_file(path)

Parse an OpenGGCM ASCII grid file.

The file contains header metadata followed by 21 FIELD-1D-1 sections: primary grids (gridx, gridy, gridz) then 18 staggered grids for the six field components (B and E, three directions each).

Parameters:

Name Type Description Default
path Path

Path to the grid.*.dat file.

required

Returns:

Type Description
OpenGGCMGrid
Source code in src/pypic/readers/openggcm/_grid.py
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
def parse_grid_file(path: Path) -> OpenGGCMGrid:
    """Parse an OpenGGCM ASCII grid file.

    The file contains header metadata followed by 21 ``FIELD-1D-1``
    sections: primary grids (``gridx``, ``gridy``, ``gridz``) then 18
    staggered grids for the six field components (B and E, three
    directions each).

    Parameters
    ----------
    path : Path
        Path to the ``grid.*.dat`` file.

    Returns
    -------
    OpenGGCMGrid
    """
    text = path.read_text(encoding="ascii")
    lines = text.splitlines()

    # Parse header metadata
    meta: dict[str, str] = {}
    idx = 0
    while idx < len(lines) and "FIELD-1D-1" not in lines[idx]:
        line = lines[idx].strip()
        if line.startswith("DIPOLETIME:"):
            meta["DIPOLETIME"] = line.split(":", 1)[1]
        elif line.startswith("BASETIME:"):
            meta["BASETIME"] = line.split(":", 1)[1]
        idx += 1

    # Parse all FIELD-1D-1 sections
    grids: dict[str, FloatArray] = {}
    while idx < len(lines):
        if "FIELD-1D-1" in lines[idx]:
            name, _, values, idx = _parse_field_1d(lines, idx)
            grids[name] = values
        else:
            idx += 1

    # Extract primary grids
    x = grids["gridx"]
    y = grids["gridy"]
    z = grids["gridz"]

    # Build staggered grid mapping
    stagger: dict[str, tuple[FloatArray, FloatArray, FloatArray]] = {}
    for component in ("bx", "by", "bz", "ex", "ey", "ez"):
        gx_key = f"gx-{component}"
        gy_key = f"gy-{component}"
        gz_key = f"gz-{component}"
        if gx_key in grids and gy_key in grids and gz_key in grids:
            stagger[component] = (grids[gx_key], grids[gy_key], grids[gz_key])

    return OpenGGCMGrid(
        nx=len(x),
        ny=len(y),
        nz=len(z),
        x=x,
        y=y,
        z=z,
        stagger=MappingProxyType(stagger),
        metadata=MappingProxyType(meta),
    )

batsrus

BATSRUS MHD simulation reader.

Supports three output formats:

  • Per-cell IDL (.h + *_pe*.idl): raw per-processor binary
  • Merged IDL (.out / .outs): postprocessed snapshot files
  • HDF5 BATL (.batl): block-structured HDF5 from BATL library

Auto-detection via open_batsrus examines directory contents to select the appropriate reader.

BATSRUSConfig dataclass

Parsed BATSRUS PARAM.in configuration.

Source code in src/pypic/readers/batsrus/_config.py
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
@dataclass(frozen=True, slots=True)
class BATSRUSConfig:
    """Parsed BATSRUS ``PARAM.in`` configuration."""

    description: str = ""
    coord_system: str = "simulation"
    n_root_blocks: tuple[int, int, int] = (1, 1, 1)
    domain_min: tuple[float, float, float] = (0.0, 0.0, 0.0)
    domain_max: tuple[float, float, float] = (1.0, 1.0, 1.0)
    gamma: float = 5.0 / 3.0
    io_units: str = ""
    normalization_type: str = ""
    body_radius: float | None = None
    body_density_dim: float | None = None
    body_temp_dim: float | None = None
    solar_wind: dict[str, float] = field(default_factory=dict)
    start_time: dict[str, int] = field(default_factory=dict)
    dt_fixed: float | None = None
    geometry: str = "cartesian"
    use_splitb: bool = False
    divb_method: str = ""
    outer_boundary: tuple[str, ...] = ()
    metadata: dict[str, Any] = field(default_factory=dict)

    def __post_init__(self) -> None:
        # Wrap mutable dicts in read-only proxies
        _freeze = MappingProxyType
        object.__setattr__(self, "solar_wind", _freeze(dict(self.solar_wind)))
        object.__setattr__(self, "start_time", _freeze(dict(self.start_time)))
        object.__setattr__(self, "metadata", _freeze(dict(self.metadata)))

BATSRUSHeader dataclass

Parsed content of a BATSRUS .h output header file.

These header files accompany per-processor .idl data files and contain all metadata needed to interpret the binary records.

Source code in src/pypic/readers/batsrus/_header.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
@dataclass(frozen=True, slots=True)
class BATSRUSHeader:
    """Parsed content of a BATSRUS ``.h`` output header file.

    These header files accompany per-processor ``.idl`` data files and
    contain all metadata needed to interpret the binary records.
    """

    ndim: int
    block_size: tuple[int, ...]
    n_root_blocks: tuple[int, ...]
    geometry: str
    domain_min: tuple[float, ...]
    domain_max: tuple[float, ...]
    is_periodic: tuple[bool, ...]
    n_step: int
    time: float
    n_cells: int
    cell_size_min: tuple[float, ...]
    n_param: int
    param_names: tuple[str, ...]
    param_values: tuple[float, ...]
    n_plot_var: int
    var_names: tuple[str, ...]
    unit_string: str
    output_format: str
    is_binary: bool
    n_byte_real: int

BATSRUSReader

Bases: ReaderBase

Read BATSRUS simulation output in IDL or HDF5 format.

Supports three output formats:

  • Per-cell IDL (.h + *_pe*.idl): raw per-processor binary
  • Merged IDL (.out / .outs): postprocessed snapshot files
  • HDF5 BATL (.batl): block-structured HDF5

AMR grids are automatically regridded to the finest resolution.

Source code in src/pypic/readers/batsrus/_reader.py
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
class BATSRUSReader(ReaderBase):
    """Read BATSRUS simulation output in IDL or HDF5 format.

    Supports three output formats:

    - **Per-cell IDL** (``.h`` + ``*_pe*.idl``): raw per-processor binary
    - **Merged IDL** (``.out`` / ``.outs``): postprocessed snapshot files
    - **HDF5 BATL** (``.batl``): block-structured HDF5

    AMR grids are automatically regridded to the finest resolution.
    """

    def __init__(
        self,
        config: BATSRUSConfig,
        output_format: BATSRUSOutputFormat,
        prefix: str,
        *,
        geometry: str = "cartesian",
        sim_config: SimulationConfig | None = None,
    ) -> None:
        super().__init__(sim_config)
        self._config = config
        self._output_format = output_format
        self._prefix = prefix
        self._geometry = geometry

    def available_timesteps(self, path: Path) -> list[int]:
        """Return sorted list of available timestep indices."""
        from pypic.readers.batsrus import BATSRUSOutputFormat

        match self._output_format:
            case BATSRUSOutputFormat.HDF5:
                pattern = f"{self._prefix}*.batl"
            case BATSRUSOutputFormat.IDL:
                pattern = f"{self._prefix}*.h"
            case BATSRUSOutputFormat.OUT:
                pattern = f"{self._prefix}*.out"
            case _ as unreachable:
                assert_never(unreachable)

        steps = {extract_step_from_filename(f.name) for f in path.glob(pattern)}
        return sorted(step for step in steps if step is not None)

    def _build_var_mapping(self, var_names: tuple[str, ...]) -> dict[str, str | None]:
        """Map native BATSRUS var names to canonical, return canonical→native."""
        mapping: dict[str, str | None] = {}
        for vname in var_names:
            if vname in SKIP_FIELDS:
                continue
            canonical = FIELD_NAME_MAP.get(vname, vname)
            mapping[canonical] = vname
        return mapping

    def _get_var_names(self, path: Path, step: int) -> tuple[str, ...]:
        """Extract native variable names from header/metadata at *step*."""
        import h5py

        from pypic.readers.batsrus import BATSRUSOutputFormat

        match self._output_format:
            case BATSRUSOutputFormat.IDL:
                header_file = self._find_file(path, step, ".h")
                return parse_header(header_file).var_names
            case BATSRUSOutputFormat.HDF5:
                batl_file = self._find_file(path, step, ".batl")
                with h5py.File(batl_file, "r") as f:
                    return tuple(x.decode().strip() for x in f["NamePlotVar_V"][:])
            case BATSRUSOutputFormat.OUT:
                out_file = self._find_file(path, step, ".out")
                # Not parse_header: that reads the `#SECTION`-delimited .h
                # text format. A .out carries its own header, and binary
                # variants are not text at all.
                return read_out_header(out_file)[0]
            case _ as unreachable:
                assert_never(unreachable)

    def available_fields_mapping(self, path: Path, step: int) -> dict[str, str | None]:
        """Map canonical field names to native (on-disk) names at *step*.

        Parses file headers or HDF5 metadata without loading arrays.

        Parameters
        ----------
        path : Path
            Directory containing the simulation output.
        step : int
            Timestep index.

        Returns
        -------
        dict[str, str | None]
            Canonical → native name.
        """
        return self._build_var_mapping(self._get_var_names(path, step))

    def read_timestep(
        self,
        path: Path,
        step: int,
        *,
        fields: Iterable[str] | None = None,
        target_resolution: float | None = None,
    ) -> FieldDataset:
        """Read field data for a single timestep.

        Parameters
        ----------
        path
            Directory containing the simulation output.
        step
            Timestep index.
        fields : Iterable[str] | None
            When given, only include these canonical field names.
        target_resolution
            Target cell size in code units for AMR regridding. When
            ``None`` (default), regrids to the finest resolution. When
            set, snapped to the nearest AMR level present in the data.
            Ignored for uniform grids.

        Returns
        -------
        FieldDataset
            Field data with canonical names, optionally converted to SI.
        """
        from pypic.readers.batsrus import BATSRUSOutputFormat

        canonical_set = set(fields) if fields is not None else None
        match self._output_format:
            case BATSRUSOutputFormat.HDF5:
                return self._read_hdf5(
                    path,
                    step,
                    fields=canonical_set,
                    target_resolution=target_resolution,
                )
            case BATSRUSOutputFormat.IDL:
                return self._read_idl(
                    path,
                    step,
                    fields=canonical_set,
                    target_resolution=target_resolution,
                )
            case BATSRUSOutputFormat.OUT:
                return self._read_out(path, step, fields=canonical_set)
            case _ as unreachable:
                assert_never(unreachable)

    def _read_idl(
        self,
        path: Path,
        step: int,
        *,
        fields: set[str] | None = None,
        target_resolution: float | None = None,
    ) -> FieldDataset:
        """Read per-cell IDL format."""
        header_file = self._find_file(path, step, ".h")
        header = parse_header(header_file)
        geo = GEOMETRY_BY_NAME.get(header.geometry, CARTESIAN)

        idl_files = self._find_idl_files(path, step)
        all_coords = []
        all_dx = []
        all_state = []
        for idl_file in idl_files:
            coords, dx, state = read_idl_cells(idl_file, header)
            all_coords.append(coords)
            all_dx.append(dx)
            all_state.append(state)

        coords = np.concatenate(all_coords, axis=0)
        dx = np.concatenate(all_dx, axis=0)
        state = np.concatenate(all_state, axis=0)

        if is_uniform_idl(dx):
            field_data, grid = assemble_uniform_idl(
                coords, dx, state, header.var_names, header.ndim, geometry=geo
            )
        else:
            field_data, grid = regrid_amr_idl(
                coords,
                dx,
                state,
                header.var_names,
                header.ndim,
                target_dx=target_resolution,
                geometry=geo,
            )

        # Unit conversion
        unit_names = self._parse_unit_names(header)
        if unit_names and not is_normalized(header.unit_string):
            field_data = convert_fields_to_si(
                field_data,
                header.var_names,
                unit_names,
            )

        if fields is not None:
            field_data = {k: v for k, v in field_data.items() if k in fields}
        return self._finish_si(
            field_data,
            grid,
            path=path,
            header=header,
            step=header.n_step,
            time=header.time,
            output_format="idl",
            is_regridded=not is_uniform_idl(dx),
        )

    def _read_hdf5(
        self,
        path: Path,
        step: int,
        *,
        fields: set[str] | None = None,
        target_resolution: float | None = None,
    ) -> FieldDataset:
        """Read HDF5 BATL format."""
        batl_file = self._find_file(path, step, ".batl")

        # Compute native names to read from canonical wanted set
        native_wanted: set[str] | None = None
        if fields is not None:
            native_wanted = set()
            for native, canonical in FIELD_NAME_MAP.items():
                if canonical in fields:
                    native_wanted.add(native)
            # Also include canonical names not in the map (pass-through)
            for f in fields:
                if f not in FIELD_NAME_MAP.values():
                    native_wanted.add(f)

        batl = read_batl(batl_file, fields=native_wanted)
        geo = GEOMETRY_BY_NAME.get(self._geometry, CARTESIAN)

        is_uniform = len(set(batl.refine_level)) <= 1
        if is_uniform:
            field_data, grid = assemble_uniform_hdf5(batl, geometry=geo)
        else:
            field_data, grid = regrid_amr_hdf5(
                batl, target_dx=target_resolution, geometry=geo
            )

        unit_names = batl.unit_names
        unit_str = " ".join(unit_names)
        if unit_names and not is_normalized(unit_str):
            field_data = convert_fields_to_si(
                field_data,
                batl.var_names,
                unit_names,
            )

        if fields is not None:
            field_data = {k: v for k, v in field_data.items() if k in fields}
        return self._finish_si(
            field_data,
            grid,
            path=path,
            step=batl.n_step,
            time=batl.time,
            output_format="hdf5",
            is_regridded=not is_uniform,
        )

    def _read_out(
        self,
        path: Path,
        step: int,
        *,
        fields: set[str] | None = None,
    ) -> FieldDataset:
        """Read merged .out format."""
        out_file = self._find_file(path, step, ".out")
        coord, state, var_names, out_meta = read_out_file(out_file)

        ndim = int(out_meta["ndim"])
        dims = out_meta["dims"]

        # Determine geometry: .out files encode non-Cartesian as negative ndim
        is_cart = out_meta.get("is_cartesian", True)
        geo = CARTESIAN if is_cart else GEOMETRY_BY_NAME.get(self._geometry, CARTESIAN)

        # Build fields dict with canonical names
        field_data: dict[str, np.ndarray] = {}
        for iv, vname in enumerate(var_names):
            if vname in SKIP_FIELDS:
                continue
            canonical = FIELD_NAME_MAP.get(vname, vname)
            if fields is not None and canonical not in fields:
                continue
            field_data[canonical] = state[iv]

        # Build grid from coordinate arrays. Step along axis *d* specifically:
        # `.flat[1]` walks the last axis, so it reads 0 for every axis but the
        # innermost one.
        spacing = tuple(
            float(np.diff(coord[d], axis=d).flat[0]) if dims[d] > 1 else 1.0
            for d in range(ndim)
        )
        origin = tuple(float(coord[d].flat[0] - spacing[d] / 2) for d in range(ndim))

        grid = GridInfo(
            dimensions=dims,
            spacing=spacing,
            origin=origin,
            geometry=geo,
        )

        # Unit conversion, as in _read_idl / _read_hdf5. The .out head line
        # carries the same unit string the .h header exposes.
        head_line = str(out_meta.get("head", ""))
        unit_names = parse_unit_names(head_line, len(var_names))
        if unit_names and not is_normalized(head_line):
            field_data = convert_fields_to_si(field_data, var_names, unit_names)

        return self._finish_si(
            field_data,
            grid,
            path=path,
            step=out_meta.get("step", step),
            time=out_meta.get("time", 0.0),
            output_format="out",
        )

    def _finish_si(
        self,
        field_data: dict[str, np.ndarray],
        grid: GridInfo,
        *,
        path: Path,
        header: BATSRUSHeader | None = None,
        step: int,
        time: float,
        output_format: str,
        is_regridded: bool = False,
    ) -> FieldDataset:
        """Normalize SI-valued *field_data* by the run's references and wrap it."""
        if grid.boundary is None:
            # The one place all three read paths converge while a GridInfo is
            # still in scope.  to_simulation_config is skipped whenever a
            # sim_config was supplied, which open_batsrus always does.
            tags = boundary_tags(self._config, header, len(grid.dimensions))
            if tags is not None:
                grid = copy.replace(grid, boundary=tags)
        if self._sim_config is None:
            self._sim_config = to_simulation_config(
                self._config, header, grid=grid, sim_dir=path
            )
        extra: dict[str, Any] = {
            "format": output_format,
            "stagger": StaggerInfo(convention="cell"),
        }
        if is_regridded:
            extra["is_regridded"] = True
        return self._finish(
            normalize_fields(field_data, self._sim_config.normalization),
            step=step,
            time=time,
            grid=grid,
            extra=extra,
        )

    def _find_file(self, path: Path, step: int, suffix: str) -> Path:
        """Find the one file matching the prefix and step number."""
        step_str = f"_n{step:08d}"
        # Also try time-based naming: _t{time}_n{step}
        candidates = list(path.glob(f"{self._prefix}*{step_str}*{suffix}"))
        if not candidates:
            # Try without prefix
            candidates = list(path.glob(f"*{step_str}*{suffix}"))
        if not candidates:
            msg = f"No {suffix} file found for step {step} in {path}"
            raise FileNotFoundError(msg)
        if len(candidates) > 1:
            names = sorted(c.name for c in candidates)
            msg = f"Ambiguous {suffix} files for step {step} in {path}: {names}"
            raise ValueError(msg)
        return candidates[0]

    def _find_idl_files(self, path: Path, step: int) -> list[Path]:
        """Find all per-processor .idl files for a given step."""
        step_str = f"_n{step:08d}"
        # Also try time-based: _t{time}_n{step}
        files = sorted(path.glob(f"*{step_str}*_pe*.idl"))
        if not files:
            # Try matching on time pattern
            for h_file in path.glob(f"*_n{step:08d}.h"):
                stem = h_file.stem
                files = sorted(path.glob(f"{stem}_pe*.idl"))
                if files:
                    break
        if not files:
            # Broadest search: find any .idl files with this step number
            files = sorted(f for f in path.glob("*.idl") if step_str in f.name)
        if not files:
            msg = f"No .idl files found for step {step} in {path}"
            raise FileNotFoundError(msg)
        return files

    def _parse_unit_names(self, header: BATSRUSHeader) -> tuple[str, ...]:
        """Extract per-variable unit strings from a parsed ``.h`` header."""
        return parse_unit_names(header.unit_string, header.n_plot_var)
available_timesteps(path)

Return sorted list of available timestep indices.

Source code in src/pypic/readers/batsrus/_reader.py
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
def available_timesteps(self, path: Path) -> list[int]:
    """Return sorted list of available timestep indices."""
    from pypic.readers.batsrus import BATSRUSOutputFormat

    match self._output_format:
        case BATSRUSOutputFormat.HDF5:
            pattern = f"{self._prefix}*.batl"
        case BATSRUSOutputFormat.IDL:
            pattern = f"{self._prefix}*.h"
        case BATSRUSOutputFormat.OUT:
            pattern = f"{self._prefix}*.out"
        case _ as unreachable:
            assert_never(unreachable)

    steps = {extract_step_from_filename(f.name) for f in path.glob(pattern)}
    return sorted(step for step in steps if step is not None)
available_fields_mapping(path, step)

Map canonical field names to native (on-disk) names at step.

Parses file headers or HDF5 metadata without loading arrays.

Parameters:

Name Type Description Default
path Path

Directory containing the simulation output.

required
step int

Timestep index.

required

Returns:

Type Description
dict[str, str | None]

Canonical → native name.

Source code in src/pypic/readers/batsrus/_reader.py
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
def available_fields_mapping(self, path: Path, step: int) -> dict[str, str | None]:
    """Map canonical field names to native (on-disk) names at *step*.

    Parses file headers or HDF5 metadata without loading arrays.

    Parameters
    ----------
    path : Path
        Directory containing the simulation output.
    step : int
        Timestep index.

    Returns
    -------
    dict[str, str | None]
        Canonical → native name.
    """
    return self._build_var_mapping(self._get_var_names(path, step))
read_timestep(path, step, *, fields=None, target_resolution=None)

Read field data for a single timestep.

Parameters:

Name Type Description Default
path Path

Directory containing the simulation output.

required
step int

Timestep index.

required
fields Iterable[str] | None

When given, only include these canonical field names.

None
target_resolution float | None

Target cell size in code units for AMR regridding. When None (default), regrids to the finest resolution. When set, snapped to the nearest AMR level present in the data. Ignored for uniform grids.

None

Returns:

Type Description
FieldDataset

Field data with canonical names, optionally converted to SI.

Source code in src/pypic/readers/batsrus/_reader.py
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
def read_timestep(
    self,
    path: Path,
    step: int,
    *,
    fields: Iterable[str] | None = None,
    target_resolution: float | None = None,
) -> FieldDataset:
    """Read field data for a single timestep.

    Parameters
    ----------
    path
        Directory containing the simulation output.
    step
        Timestep index.
    fields : Iterable[str] | None
        When given, only include these canonical field names.
    target_resolution
        Target cell size in code units for AMR regridding. When
        ``None`` (default), regrids to the finest resolution. When
        set, snapped to the nearest AMR level present in the data.
        Ignored for uniform grids.

    Returns
    -------
    FieldDataset
        Field data with canonical names, optionally converted to SI.
    """
    from pypic.readers.batsrus import BATSRUSOutputFormat

    canonical_set = set(fields) if fields is not None else None
    match self._output_format:
        case BATSRUSOutputFormat.HDF5:
            return self._read_hdf5(
                path,
                step,
                fields=canonical_set,
                target_resolution=target_resolution,
            )
        case BATSRUSOutputFormat.IDL:
            return self._read_idl(
                path,
                step,
                fields=canonical_set,
                target_resolution=target_resolution,
            )
        case BATSRUSOutputFormat.OUT:
            return self._read_out(path, step, fields=canonical_set)
        case _ as unreachable:
            assert_never(unreachable)

BATSRUSOutputFormat

Bases: StrEnum

BATSRUS output file format.

Source code in src/pypic/readers/batsrus/__init__.py
41
42
43
44
45
46
class BATSRUSOutputFormat(StrEnum):
    """BATSRUS output file format."""

    HDF5 = "hdf5"
    IDL = "idl"
    OUT = "out"

parse_param_in(path)

Parse a BATSRUS PARAM.in file.

Parameters:

Name Type Description Default
path Path

Path to the PARAM.in file.

required

Returns:

Type Description
BATSRUSConfig

Frozen dataclass with extracted configuration.

Source code in src/pypic/readers/batsrus/_config.py
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
def parse_param_in(path: Path) -> BATSRUSConfig:
    """Parse a BATSRUS ``PARAM.in`` file.

    Parameters
    ----------
    path
        Path to the ``PARAM.in`` file.

    Returns
    -------
    BATSRUSConfig
        Frozen dataclass with extracted configuration.
    """
    text = path.read_text()
    lines = text.splitlines()

    description = ""
    coord_system = "simulation"
    n_root = [1, 1, 1]
    domain_min = [0.0, 0.0, 0.0]
    domain_max = [1.0, 1.0, 1.0]
    gamma = 5.0 / 3.0
    io_units = ""
    normalization_type = ""
    body_radius: float | None = None
    body_density_dim: float | None = None
    body_temp_dim: float | None = None
    solar_wind: dict[str, float] = {}
    start_time: dict[str, int] = {}
    dt_fixed: float | None = None
    use_splitb = False
    divb_method = ""
    outer_boundary: tuple[str, ...] = ()
    metadata: dict[str, Any] = {}

    i = 0
    while i < len(lines):
        line = lines[i].strip()

        if line == "#DESCRIPTION" and i + 1 < len(lines):
            description = lines[i + 1].strip()
            i += 2
            continue

        if line == "#COORDSYSTEM" and i + 1 < len(lines):
            coord_system = _first_token(lines[i + 1])
            i += 2
            continue

        if line == "#IOUNITS" and i + 1 < len(lines):
            io_units = _first_token(lines[i + 1])
            i += 2
            continue

        if line == "#NORMALIZATION" and i + 1 < len(lines):
            normalization_type = _first_token(lines[i + 1])
            i += 2
            continue

        if line == "#GAMMA" and i + 1 < len(lines):
            gamma = float(_first_token(lines[i + 1]))
            i += 2
            continue

        if line == "#GRID" and i + 9 < len(lines):
            n_root[0] = int(_first_token(lines[i + 1]))
            n_root[1] = int(_first_token(lines[i + 2]))
            n_root[2] = int(_first_token(lines[i + 3]))
            domain_min[0] = float(_first_token(lines[i + 4]))
            domain_max[0] = float(_first_token(lines[i + 5]))
            domain_min[1] = float(_first_token(lines[i + 6]))
            domain_max[1] = float(_first_token(lines[i + 7]))
            domain_min[2] = float(_first_token(lines[i + 8]))
            domain_max[2] = float(_first_token(lines[i + 9]))
            i += 10
            continue

        if line == "#BODY" and i + 1 < len(lines):
            use_body = _first_token(lines[i + 1])
            if use_body == "T" and i + 4 < len(lines):
                body_radius = float(_first_token(lines[i + 2]))
                # skip rCurrents
                body_density_dim = float(_first_token(lines[i + 4]))
                body_temp_dim = float(_first_token(lines[i + 5]))
                i += 6
            else:
                i += 2
            continue

        if line == "#SOLARWIND" and i + 8 < len(lines):
            solar_wind = {
                "rho_dim": float(_first_token(lines[i + 1])),
                "t_dim": float(_first_token(lines[i + 2])),
                "ux_dim": float(_first_token(lines[i + 3])),
                "uy_dim": float(_first_token(lines[i + 4])),
                "uz_dim": float(_first_token(lines[i + 5])),
                "bx_dim": float(_first_token(lines[i + 6])),
                "by_dim": float(_first_token(lines[i + 7])),
                "bz_dim": float(_first_token(lines[i + 8])),
            }
            i += 9
            continue

        if line == "#STARTTIME" and i + 7 < len(lines):
            start_time = {
                "year": int(_first_token(lines[i + 1])),
                "month": int(_first_token(lines[i + 2])),
                "day": int(_first_token(lines[i + 3])),
                "hour": int(_first_token(lines[i + 4])),
                "minute": int(_first_token(lines[i + 5])),
                "second": int(_first_token(lines[i + 6])),
            }
            i += 8
            continue

        if line == "#FIXEDTIMESTEP" and i + 2 < len(lines):
            use_fixed = _first_token(lines[i + 1])
            if use_fixed == "T":
                dt_fixed = float(_first_token(lines[i + 2]))
            i += 3
            continue

        if line == "#SCHEME" and i + 1 < len(lines):
            n_order = int(_first_token(lines[i + 1]))
            metadata["scheme_order"] = n_order
            if i + 2 < len(lines):
                metadata["flux_type"] = _first_token(lines[i + 2])
            i += 3 + max(0, n_order - 1)
            continue

        if line == "#SPLITB" and i + 1 < len(lines):
            use_splitb = _first_token(lines[i + 1]) == "T"
            i += 2
            continue

        if line == "#DIVB" and i + 1 < len(lines):
            divb_method = _first_token(lines[i + 1])
            i += 2
            continue

        if line == "#OUTERBOUNDARY":
            # Unlike every other command here the face count is variable
            # (4 in 2D, 6 in 3D), so read to the next blank or command line.
            faces: list[str] = []
            j = i + 1
            while j < len(lines) and len(faces) < 6:
                entry = lines[j].strip()
                if not entry or entry.startswith("#"):
                    break
                faces.append(_first_token(entry).lower())
                j += 1
            outer_boundary = tuple(faces)
            i = j
            continue

        i += 1

    return BATSRUSConfig(
        description=description,
        coord_system=coord_system,
        n_root_blocks=tuple(n_root),  # type: ignore[arg-type]
        domain_min=tuple(domain_min),  # type: ignore[arg-type]
        domain_max=tuple(domain_max),  # type: ignore[arg-type]
        gamma=gamma,
        io_units=io_units,
        normalization_type=normalization_type,
        body_radius=body_radius,
        body_density_dim=body_density_dim,
        body_temp_dim=body_temp_dim,
        solar_wind=solar_wind,
        start_time=start_time,
        dt_fixed=dt_fixed,
        geometry="cartesian",
        use_splitb=use_splitb,
        divb_method=divb_method,
        outer_boundary=outer_boundary,
        metadata=metadata,
    )

to_simulation_config(config, header=None, *, grid=None, sim_dir=None)

Build a SimulationConfig from BATSRUS config and header.

If a simulation.toml exists in sim_dir, its normalization, frame, transforms, and metadata are merged in via pypic.readers._config_helpers.merge_simulation_toml.

Parameters:

Name Type Description Default
config BATSRUSConfig

Parsed PARAM.in.

required
header BATSRUSHeader | None

Parsed .h header (provides grid dimensions from actual output).

None
grid GridInfo | None

Pre-built GridInfo (overrides header-derived grid).

None
sim_dir Path | None

Simulation directory to scan for simulation.toml. None skips the merge.

None

Returns:

Type Description
SimulationConfig
Source code in src/pypic/readers/batsrus/_config.py
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
def to_simulation_config(
    config: BATSRUSConfig,
    header: BATSRUSHeader | None = None,
    *,
    grid: GridInfo | None = None,
    sim_dir: Path | None = None,
) -> SimulationConfig:
    """Build a `SimulationConfig` from BATSRUS config and header.

    If a ``simulation.toml`` exists in *sim_dir*, its normalization, frame,
    transforms, and metadata are merged in via
    `pypic.readers._config_helpers.merge_simulation_toml`.

    Parameters
    ----------
    config
        Parsed ``PARAM.in``.
    header
        Parsed ``.h`` header (provides grid dimensions from actual output).
    grid
        Pre-built GridInfo (overrides header-derived grid).
    sim_dir
        Simulation directory to scan for ``simulation.toml``. ``None`` skips
        the merge.

    Returns
    -------
    SimulationConfig
    """
    geometry = GEOMETRY_BY_NAME.get(config.geometry, CARTESIAN)

    if grid is None:
        if header is not None:
            ndim = header.ndim
            domain_min = header.domain_min
            domain_max = header.domain_max
            dims = _compute_grid_dims(header)
            spacing = tuple(
                (mx - mn) / d
                for mn, mx, d in zip(domain_min, domain_max, dims, strict=True)
            )
        else:
            ndim = 3 if config.n_root_blocks[2] > 1 else 2
            domain_min = config.domain_min[:ndim]
            domain_max = config.domain_max[:ndim]
            # BATSRUS AMR: 8 cells per root block per axis
            dims = tuple(8 * n for n in config.n_root_blocks[:ndim])
            spacing = tuple(
                (mx - mn) / d
                for mn, mx, d in zip(domain_min, domain_max, dims, strict=True)
            )

        grid = GridInfo(
            dimensions=dims,
            spacing=spacing,
            origin=domain_min,
            geometry=geometry,
            dt=config.dt_fixed,
            boundary=boundary_tags(config, header, len(dims)),
        )
    elif grid.boundary is None:
        grid = copy.replace(
            grid, boundary=boundary_tags(config, header, len(grid.dimensions))
        )

    extra: dict[str, Any] = {}
    if config.use_splitb:
        extra["use_splitb"] = True
    if config.divb_method:
        extra["divb_method"] = config.divb_method
    if config.solar_wind:
        extra["solar_wind"] = config.solar_wind
    physics = PhysicsParams(gamma=config.gamma, extra=extra)

    meta: dict[str, Any] = dict(config.metadata)
    if config.outer_boundary:
        # GridInfo holds one tag per axis; the per-face pairs only survive here.
        meta["outer_boundary"] = list(config.outer_boundary)
    if config.start_time:
        meta["start_time"] = config.start_time
    if config.description:
        meta["description"] = config.description

    base = SimulationConfig(
        model_name="BATSRUS",
        model_type="MHD",
        grid=grid,
        # PARAM.in carries no unit block; a simulation.toml supplies one.
        normalization=Normalization.undeclared(),
        physics=physics,
        frame=config.coord_system,
        metadata=meta,
    )
    return merge_simulation_toml(sim_dir, base)

extract_step_from_filename(name)

Extract the timestep number from a BATSRUS output filename.

Examples:

>>> extract_step_from_filename("3d__mhd_2_t00000010_n00000042.batl")
42
>>> extract_step_from_filename("PARAM.in") is None
True
Source code in src/pypic/readers/batsrus/_header.py
17
18
19
20
21
22
23
24
25
26
27
28
def extract_step_from_filename(name: str) -> int | None:
    """Extract the timestep number from a BATSRUS output filename.

    Examples
    --------
    >>> extract_step_from_filename("3d__mhd_2_t00000010_n00000042.batl")
    42
    >>> extract_step_from_filename("PARAM.in") is None
    True
    """
    m = STEP_RE.search(name)
    return int(m.group(1)) if m else None

parse_header(path)

Parse a BATSRUS .h header file into a BATSRUSHeader.

Parameters:

Name Type Description Default
path Path

Path to the .h file.

required

Returns:

Type Description
BATSRUSHeader

Frozen dataclass with all extracted metadata.

Source code in src/pypic/readers/batsrus/_header.py
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
def parse_header(path: Path) -> BATSRUSHeader:
    """Parse a BATSRUS ``.h`` header file into a `BATSRUSHeader`.

    Parameters
    ----------
    path
        Path to the ``.h`` file.

    Returns
    -------
    BATSRUSHeader
        Frozen dataclass with all extracted metadata.
    """
    text = path.read_text()
    lines = text.splitlines()

    # Accumulate values by section
    section: str | None = None
    section_lines: list[str] = []
    sections: dict[str, list[str]] = {}

    for line in lines:
        stripped = line.strip()
        if stripped.startswith("#"):
            if section is not None:
                sections[section] = section_lines
            section = stripped
            section_lines = []
        elif stripped:
            section_lines.append(stripped)
    if section is not None:
        sections[section] = section_lines

    # Every field below has a default, so without this guard any text file
    # yields a plausible-looking header (ndim=3, n_step=0, var_names=())
    # instead of an error. #HEADFILE and #NDIM are written by every BATSRUS
    # plot header.
    if not sections.keys() & {"#HEADFILE", "#NDIM"}:
        msg = (
            f"{path} is not a BATSRUS .h header: no #HEADFILE or #NDIM "
            f"section found (got {sorted(sections) or 'no sections'})"
        )
        raise ValueError(msg)

    head_lines = sections.get("#HEADFILE", [])
    is_binary = True
    n_byte_real = 8
    for ln in head_lines:
        val = ln.split()[0]
        if "IsBinary" in ln:
            is_binary = val == "T"
        elif "nByteReal" in ln:
            n_byte_real = int(val)

    ndim_lines = sections.get("#NDIM", [])
    ndim = int(ndim_lines[0].split()[0]) if ndim_lines else 3

    block_lines = sections.get("#GRIDBLOCKSIZE", [])
    block_size = tuple(int(ln.split()[0]) for ln in block_lines)

    root_lines = sections.get("#ROOTBLOCK", [])
    n_root_blocks = tuple(int(ln.split()[0]) for ln in root_lines)

    geo_lines = sections.get("#GRIDGEOMETRYLIMIT", [])
    geometry = "cartesian"
    domain_min: list[float] = []
    domain_max: list[float] = []
    if geo_lines:
        geometry = geo_lines[0].split()[0].lower()
        for i in range(ndim):
            domain_min.append(float(geo_lines[1 + 2 * i].split()[0]))
            domain_max.append(float(geo_lines[2 + 2 * i].split()[0]))

    periodic_lines = sections.get("#PERIODIC", [])
    is_periodic = tuple(ln.split()[0] == "T" for ln in periodic_lines)

    nstep_lines = sections.get("#NSTEP", [])
    n_step = int(nstep_lines[0].split()[0]) if nstep_lines else 0

    time_lines = sections.get("#TIMESIMULATION", [])
    time = float(time_lines[0].split()[0]) if time_lines else 0.0

    ncell_lines = sections.get("#NCELL", [])
    n_cells = int(ncell_lines[0].split()[0]) if ncell_lines else 0

    cellsize_lines = sections.get("#CELLSIZE", [])
    cell_size_min = tuple(float(ln.split()[0]) for ln in cellsize_lines)

    param_lines = sections.get("#SCALARPARAM", [])
    n_param = 0
    param_values: list[float] = []
    param_names: list[str] = []
    if param_lines:
        n_param = int(param_lines[0].split()[0])
        for ln in param_lines[1:]:
            parts = ln.split()
            param_values.append(float(parts[0]))
            if len(parts) > 1:
                param_names.append(parts[1])

    plotvar_lines = sections.get("#PLOTVARIABLE", [])
    n_plot_var = 0
    var_names_list: list[str] = []
    unit_string = ""
    if plotvar_lines:
        n_plot_var = int(plotvar_lines[0].split()[0])
        if len(plotvar_lines) > 1:
            all_names = plotvar_lines[1].split()
            var_names_list = all_names[:n_plot_var]
        if len(plotvar_lines) > 2:
            unit_string = plotvar_lines[2]

    fmt_lines = sections.get("#OUTPUTFORMAT", [])
    output_format = fmt_lines[0].split()[0] if fmt_lines else "binary"

    return BATSRUSHeader(
        ndim=ndim,
        block_size=block_size,
        n_root_blocks=n_root_blocks,
        geometry=geometry,
        domain_min=tuple(domain_min),
        domain_max=tuple(domain_max),
        is_periodic=is_periodic,
        n_step=n_step,
        time=time,
        n_cells=n_cells,
        cell_size_min=cell_size_min,
        n_param=n_param,
        param_names=tuple(param_names),
        param_values=tuple(param_values),
        n_plot_var=n_plot_var,
        var_names=tuple(var_names_list),
        unit_string=unit_string,
        output_format=output_format,
        is_binary=is_binary,
        n_byte_real=n_byte_real,
    )

can_read_confidence(path)

Estimate confidence that path contains BATSRUS output.

Detection signals (additive, capped at 1.0):

  • PARAM.in: +0.3
  • .batl files (HDF5 BATL): +0.5
  • *_pe*.idl per-cell files: +0.2
  • .h header files with BATSRUS timestamp pattern: +0.3
  • .out / .outs merged files: +0.3 (only if nothing else matched)

Parameters:

Name Type Description Default
path Path

Directory to check.

required

Returns:

Type Description
float

Confidence in [0.0, 1.0].

Source code in src/pypic/readers/batsrus/_probe.py
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
def can_read_confidence(path: Path) -> float:
    """Estimate confidence that *path* contains BATSRUS output.

    Detection signals (additive, capped at 1.0):

    - ``PARAM.in``: +0.3
    - ``.batl`` files (HDF5 BATL): +0.5
    - ``*_pe*.idl`` per-cell files: +0.2
    - ``.h`` header files with BATSRUS timestamp pattern: +0.3
    - ``.out`` / ``.outs`` merged files: +0.3 (only if nothing else matched)

    Parameters
    ----------
    path : Path
        Directory to check.

    Returns
    -------
    float
        Confidence in ``[0.0, 1.0]``.
    """
    if not path.is_dir():
        return 0.0

    score = score_signals(path, _CORE_SIGNALS)

    # Filter .h files by BATSRUS timestamp pattern to avoid C header
    # false positives (reader.h, config.h, ...).
    if any(TIMESTAMP_RE.search(f.name) for f in path.glob("*.h")):
        score += 0.3

    # Merged .out / .outs fall back only if nothing else matched, and
    # contribute a single 0.3 regardless of which variant is present.
    if score == 0.0 and (
        next(path.glob("*.out"), None) is not None
        or next(path.glob("*.outs"), None) is not None
    ):
        score += 0.3

    return min(score, 1.0)

open_batsrus(path, *, config_path=None)

Auto-detect BATSRUS output format and return a reader.

Detection order: 1. .batl files → HDF5 BATL format 2. .h + *_pe*.idl files → per-cell IDL binary 3. .out / .outs files → merged IDL

Prefers 3D data over 2D slices when both are available.

Parameters:

Name Type Description Default
path Path

Directory containing BATSRUS output files.

required
config_path Path | None

Explicit path to a PARAM.in file. When None, auto-detected from path.

None

Returns:

Name Type Description
reader SimulationReader

A BATSRUSReader instance.

config SimulationConfig

Simulation configuration parsed from PARAM.in and/or headers.

Raises:

Type Description
FileNotFoundError

If no recognizable BATSRUS output is found.

Source code in src/pypic/readers/batsrus/__init__.py
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
def open_batsrus(
    path: Path,
    *,
    config_path: Path | None = None,
) -> tuple[SimulationReader, SimulationConfig]:
    """Auto-detect BATSRUS output format and return a reader.

    Detection order:
    1. ``.batl`` files → HDF5 BATL format
    2. ``.h`` + ``*_pe*.idl`` files → per-cell IDL binary
    3. ``.out`` / ``.outs`` files → merged IDL

    Prefers 3D data over 2D slices when both are available.

    Parameters
    ----------
    path
        Directory containing BATSRUS output files.
    config_path : Path | None
        Explicit path to a ``PARAM.in`` file.  When ``None``,
        auto-detected from *path*.

    Returns
    -------
    reader : SimulationReader
        A `BATSRUSReader` instance.
    config : SimulationConfig
        Simulation configuration parsed from ``PARAM.in``
        and/or headers.

    Raises
    ------
    FileNotFoundError
        If no recognizable BATSRUS output is found.
    """
    path = Path(path)
    param_file = config_path or (path / "PARAM.in")
    batsrus_config = (
        parse_param_in(param_file) if param_file.exists() else BATSRUSConfig()
    )

    # Detect format and find the best prefix
    batl_files = sorted(path.glob("*.batl"))
    h_files = sorted(path.glob("*.h"))
    out_files = sorted(path.glob("*.out")) + sorted(path.glob("*.outs"))

    if batl_files:
        output_format = BATSRUSOutputFormat.HDF5
        prefix = _detect_prefix_batl(batl_files)
    elif h_files:
        output_format = BATSRUSOutputFormat.IDL
        prefix = _detect_prefix_h(h_files)
    elif out_files:
        output_format = BATSRUSOutputFormat.OUT
        prefix = _detect_prefix_out(out_files)
    else:
        msg = f"No BATSRUS output files found in {path}"
        raise FileNotFoundError(msg)

    # Extract geometry from header if available
    geometry = "cartesian"
    header = None
    grid = None
    if h_files:
        header = parse_header(h_files[0])
        geometry = header.geometry

    batsrus_config = copy.replace(batsrus_config, geometry=geometry)

    if batl_files and header is None:
        batl = read_batl(batl_files[0])
        is_uniform = len(set(batl.refine_level)) <= 1
        if is_uniform:
            _, grid = assemble_uniform_hdf5(batl)
        else:
            _, grid = regrid_amr_hdf5(batl)

    sim_config = to_simulation_config(batsrus_config, header, grid=grid, sim_dir=path)
    reader = BATSRUSReader(
        batsrus_config, output_format, prefix, geometry=geometry, sim_config=sim_config
    )
    return reader, sim_config

config

Load simulation configuration from a TOML file.

The Pydantic validator in pypic.schema is the authoritative source of the v2.0 schema — this module is a thin translator from a validated SimulationSchema to the internal dataclasses (SimulationConfig, GridInfo, Normalization, SpeciesInfo). All shape validation happens in the Pydantic layer; this module only maps fields.

apply_physical_extent(config, physical_extent, physical_extent_unit='m')

Auto-compute transform scale factors from physical domain extent.

When a simulation represents a physical domain of known size (e.g., 46 R_E across), this function computes the scale — the coordinate conversion factor from code units to target units (e.g., 0.25 R_E/d_i). For transforms with the default scale=1.0, the computed scale is applied. For transforms with an explicit scale, consistency is validated.

If the normalization is not identity, also computes the shrink factor — how much the physical domain is compressed relative to what the normalization implies (e.g., 3.5× for reduced mass ratio PIC). A shrink factor of 1.0 means no spatial rescaling.

Parameters:

Name Type Description Default
config SimulationConfig

Original simulation configuration.

required
physical_extent tuple[float, ...]

Domain size per target-frame axis, in physical_extent_unit.

required
physical_extent_unit str

Length unit name. See LENGTH_UNITS for valid values.

'm'

Returns:

Type Description
SimulationConfig

New config with computed scale factors and metadata.

Raises:

Type Description
ValueError

If the unit is unknown or the implied scale is not uniform.

Source code in src/pypic/readers/config.py
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
def apply_physical_extent(
    config: SimulationConfig,
    physical_extent: tuple[float, ...],
    physical_extent_unit: str = "m",
) -> SimulationConfig:
    r"""Auto-compute transform scale factors from physical domain extent.

    When a simulation represents a physical domain of known size (e.g.,
    46 R_E across), this function computes the **scale** — the coordinate
    conversion factor from code units to target units (e.g., 0.25 R_E/d_i).
    For transforms with the default ``scale=1.0``, the computed scale is
    applied. For transforms with an explicit scale, consistency is validated.

    If the normalization is not identity, also computes the **shrink factor**
    — how much the physical domain is compressed relative to what the
    normalization implies (e.g., 3.5× for reduced mass ratio PIC).
    A shrink factor of 1.0 means no spatial rescaling.

    Parameters
    ----------
    config : SimulationConfig
        Original simulation configuration.
    physical_extent : tuple[float, ...]
        Domain size per target-frame axis, in *physical_extent_unit*.
    physical_extent_unit : str
        Length unit name. See ``LENGTH_UNITS`` for valid values.

    Returns
    -------
    SimulationConfig
        New config with computed scale factors and metadata.

    Raises
    ------
    ValueError
        If the unit is unknown or the implied scale is not uniform.
    """
    if physical_extent_unit == "d_i":
        # d_i is the natural PIC length unit and equals the simulation's
        # length_ref by construction. Identity normalization makes this a
        # no-op (length_ref = 1), which is fine for grids already in d_i.
        unit_factor = config.normalization.length_ref
    else:
        unit_factor_lookup = LENGTH_UNITS.get(physical_extent_unit)
        if unit_factor_lookup is None:
            valid = ", ".join(sorted(LENGTH_UNITS))
            raise ValueError(
                f"Unknown physical_extent_unit {physical_extent_unit!r}. "
                f"Valid: {valid}, d_i"
            )
        unit_factor = unit_factor_lookup

    grid = config.grid
    grid_extent = tuple(
        d * s for d, s in zip(grid.dimensions, grid.spacing, strict=True)
    )
    computed_scale: float | None = None

    new_transforms: dict[str, FrameTransform] = {}
    for name, transform in config.transforms.items():
        r_abs = np.abs(transform.rotation_matrix)
        rotated = r_abs @ np.array(grid_extent[:3])
        n = min(len(physical_extent), len(rotated))
        scales = np.array(physical_extent[:n]) / rotated[:n]

        computed_scale = float(np.mean(scales))
        for s in scales:
            if (
                abs(s - computed_scale) / abs(computed_scale)
                > _SCALE_UNIFORMITY_TOLERANCE
            ):
                raise ValueError(
                    f"physical_extent implies non-uniform scale for "
                    f"transform {name!r}: per-axis ratios {scales.tolist()}"
                )

        if transform.scale == 1.0:
            new_transforms[name] = copy.replace(transform, scale=computed_scale)
            log.info(
                "Spatial scaling: %s d_i -> %s %s (scale=%.4f)",
                " x ".join(f"{e:.0f}" for e in grid_extent),
                " x ".join(f"{e:.0f}" for e in physical_extent),
                physical_extent_unit,
                computed_scale,
            )
        else:
            new_transforms[name] = transform
            rel_diff = abs(transform.scale - computed_scale) / abs(computed_scale)
            if rel_diff > _SCALE_UNIFORMITY_TOLERANCE:
                log.warning(
                    "Scale mismatch: transform %r has scale=%.4f, but "
                    "physical_extent implies scale=%.4f (%.1f%% difference)",
                    name,
                    transform.scale,
                    computed_scale,
                    rel_diff * 100,
                )

    new_metadata = dict(config.metadata)
    new_metadata["physical_extent"] = physical_extent
    new_metadata["physical_extent_unit"] = physical_extent_unit

    if computed_scale is not None and not config.normalization.is_identity:
        norm_scale = config.normalization.length_ref / unit_factor
        shrink_factor = computed_scale / norm_scale
        new_metadata.setdefault("scaling", {})["shrink_factor"] = round(
            shrink_factor, 4
        )
        log.info(
            "Shrink factor: %.2fx (d_i = %.1f km, 1 %s = %.1f d_i physical, "
            "%.1f d_i effective)",
            shrink_factor,
            config.normalization.length_ref / 1e3,
            physical_extent_unit,
            unit_factor / config.normalization.length_ref,
            1.0 / computed_scale,
        )

    return copy.replace(
        config,
        transforms=new_transforms,
        metadata=new_metadata,
    )

load_config(path)

Parse a simulation.toml file into a SimulationConfig.

Validates the file against the v2.0 schema (pypic.schema) and builds the internal SimulationConfig from the result. The raw TOML text is captured and attached to metadata["simulation_toml"] so downstream FieldDataset writers can round-trip it verbatim into attrs.simulation_toml (schema.md §4.2) — losslessly preserving sections ([bodies], [drivers], [output], [restart], [probes], ...) that the typed SimulationConfig drops on the way to FieldDataset.

Parameters:

Name Type Description Default
path Path

Path to a TOML file conforming to the v2.0 schema.

required

Returns:

Type Description
SimulationConfig

Fully typed configuration with grid, normalization, species, physics, frame, and transforms populated.

Raises:

Type Description
ValidationError

If the document fails schema validation. Dotted field paths in the error message point to every violation.

UnsupportedGridError

If the document is valid but declares a grid pypic cannot represent — today, [grid.stretched]. The two are different statements: the first says the deck is wrong, this one says pypic's reader is what is missing.

Source code in src/pypic/readers/config.py
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
def load_config(path: Path) -> SimulationConfig:
    """Parse a ``simulation.toml`` file into a SimulationConfig.

    Validates the file against the v2.0 schema
    ([`pypic.schema`][pypic.schema]) and builds the internal `SimulationConfig`
    from the result.  The raw TOML text is captured and attached to
    ``metadata["simulation_toml"]`` so downstream FieldDataset writers
    can round-trip it verbatim into ``attrs.simulation_toml`` (schema.md
    §4.2) — losslessly preserving sections (``[bodies]``, ``[drivers]``,
    ``[output]``, ``[restart]``, ``[probes]``, ...) that the typed
    SimulationConfig drops on the way to FieldDataset.

    Parameters
    ----------
    path : Path
        Path to a TOML file conforming to the v2.0 schema.

    Returns
    -------
    SimulationConfig
        Fully typed configuration with grid, normalization, species,
        physics, frame, and transforms populated.

    Raises
    ------
    pydantic.ValidationError
        If the document fails schema validation. Dotted field paths in
        the error message point to every violation.
    UnsupportedGridError
        If the document is valid but declares a grid pypic cannot
        represent — today, ``[grid.stretched]``.  The two are different
        statements: the first says the deck is wrong, this one says
        pypic's reader is what is missing.
    """
    raw_text = Path(path).read_text(encoding="utf-8")
    schema = validate_simulation_toml(raw_text)
    config = _from_schema(schema)
    # Re-stamp metadata with the verbatim TOML text.  ``_from_schema``
    # already populated typed sections; this is purely additive.
    new_metadata = dict(config.metadata)
    new_metadata["simulation_toml"] = raw_text
    return copy.replace(config, metadata=new_metadata)

ipic3d

iPIC3D simulation readers (parallel HDF5, serial HDF5, and H5hut).

IPic3DConfig dataclass

Native iPIC3D simulation parameters.

Stores the raw values from an .inp file or settings.hdf, before any conversion to the canonical pypic schema.

Parameters:

Name Type Description Default
nxc int

Number of cells along each axis.

required
nyc int

Number of cells along each axis.

required
nzc int

Number of cells along each axis.

required
lx float

Domain size along each axis (code units).

required
ly float

Domain size along each axis (code units).

required
lz float

Domain size along each axis (code units).

required
dx float

Cell spacing (code units). Computed as L / N.

required
dy float

Cell spacing (code units). Computed as L / N.

required
dz float

Cell spacing (code units). Computed as L / N.

required
dt float

Timestep in code units.

required
xlen int

MPI topology (processors per axis).

required
ylen int

MPI topology (processors per axis).

required
zlen int

MPI topology (processors per axis).

required
c float

Speed of light in code units.

required
th float

Implicitness parameter (0.5 = Crank-Nicolson).

required
b0 tuple[float, float, float]

Background magnetic field (B0x, B0y, B0z).

required
ns int

Number of particle species.

required
qom tuple[float, ...]

Charge-to-mass ratio per species.

required
uth tuple[float, ...]

Thermal velocities per species (x, y, z components).

required
vth tuple[float, ...]

Thermal velocities per species (x, y, z components).

required
wth tuple[float, ...]

Thermal velocities per species (x, y, z components).

required
u0 tuple[float, ...]

Drift velocities per species (x, y, z components).

required
v0 tuple[float, ...]

Drift velocities per species (x, y, z components).

required
w0 tuple[float, ...]

Drift velocities per species (x, y, z components).

required
rho_init tuple[float, ...]

Initial number density per species (code units).

required
npcelx tuple[int, ...]

Particles per cell per species (x, y, z).

required
npcely tuple[int, ...]

Particles per cell per species (x, y, z).

required
npcelz tuple[int, ...]

Particles per cell per species (x, y, z).

required
periodic_x bool

Periodicity per axis.

required
periodic_y bool

Periodicity per axis.

required
periodic_z bool

Periodicity per axis.

required
write_method str

Output format ("phdf5" or "shdf5").

required
field_output_cycle int

Field output frequency (cycles between dumps).

required
field_output_tag str

Space-separated tags controlling which fields are written.

required
particles_output_cycle int

Particle output frequency (cycles between dumps; <=0 = disabled).

required
case str

Simulation case identifier.

required
simulation_name str

Human-readable simulation name.

required
Source code in src/pypic/readers/ipic3d/_config.py
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
@dataclass(frozen=True, slots=True)
class IPic3DConfig:
    """Native iPIC3D simulation parameters.

    Stores the raw values from an ``.inp`` file or ``settings.hdf``,
    before any conversion to the canonical pypic schema.

    Parameters
    ----------
    nxc, nyc, nzc : int
        Number of cells along each axis.
    lx, ly, lz : float
        Domain size along each axis (code units).
    dx, dy, dz : float
        Cell spacing (code units). Computed as ``L / N``.
    dt : float
        Timestep in code units.
    xlen, ylen, zlen : int
        MPI topology (processors per axis).
    c : float
        Speed of light in code units.
    th : float
        Implicitness parameter (0.5 = Crank-Nicolson).
    b0 : tuple[float, float, float]
        Background magnetic field ``(B0x, B0y, B0z)``.
    ns : int
        Number of particle species.
    qom : tuple[float, ...]
        Charge-to-mass ratio per species.
    uth, vth, wth : tuple[float, ...]
        Thermal velocities per species (x, y, z components).
    u0, v0, w0 : tuple[float, ...]
        Drift velocities per species (x, y, z components).
    rho_init : tuple[float, ...]
        Initial number density per species (code units).
    npcelx, npcely, npcelz : tuple[int, ...]
        Particles per cell per species (x, y, z).
    periodic_x, periodic_y, periodic_z : bool
        Periodicity per axis.
    write_method : str
        Output format (``"phdf5"`` or ``"shdf5"``).
    field_output_cycle : int
        Field output frequency (cycles between dumps).
    field_output_tag : str
        Space-separated tags controlling which fields are written.
    particles_output_cycle : int
        Particle output frequency (cycles between dumps; <=0 = disabled).
    case : str
        Simulation case identifier.
    simulation_name : str
        Human-readable simulation name.
    """

    nxc: int
    nyc: int
    nzc: int
    lx: float
    ly: float
    lz: float
    dx: float
    dy: float
    dz: float
    dt: float
    xlen: int
    ylen: int
    zlen: int
    c: float
    th: float
    b0: tuple[float, float, float]
    ns: int
    qom: tuple[float, ...]
    uth: tuple[float, ...]
    vth: tuple[float, ...]
    wth: tuple[float, ...]
    u0: tuple[float, ...]
    v0: tuple[float, ...]
    w0: tuple[float, ...]
    rho_init: tuple[float, ...]
    npcelx: tuple[int, ...]
    npcely: tuple[int, ...]
    npcelz: tuple[int, ...]
    periodic_x: bool
    periodic_y: bool
    periodic_z: bool
    write_method: str
    field_output_cycle: int
    field_output_tag: str
    particles_output_cycle: int
    case: str
    simulation_name: str
    extra: dict[str, Any] = field(default_factory=dict)  # frozen via __post_init__

    def __post_init__(self) -> None:
        object.__setattr__(self, "extra", MappingProxyType(dict(self.extra)))

ConservedQuantities dataclass

Time series of conserved quantities from an iPIC3D run.

Two output formats exist:

Format A (Roman numeral header) — single file from phdf5/shdf5 runs. Columns: cycle, electric energy (total, x, y, z), magnetic energy (total, x, y, z), kinetic energy, total energy, energy variation, momentum.

Format B (comment header) — per-restart-segment files from H5hut runs. Columns: cycle, total energy, energy variation, electric energy, local B energy, kinetic energy, momentum, total B energy, internal B energy, KE removed, E removed, then per-species (npart, charge, KE).

Parameters:

Name Type Description Default
cycle FloatArray

Cycle numbers (int-valued but stored as float for array uniformity).

required
total_energy FloatArray

Total energy at each cycle.

required
electric_energy FloatArray

Total electric field energy.

required
magnetic_energy FloatArray

Total magnetic field energy.

required
kinetic_energy FloatArray

Total kinetic energy (all species).

required
momentum FloatArray

Total momentum magnitude.

required
species_npart tuple[FloatArray, ...]

Number of particles per species at each cycle.

required
species_charge tuple[FloatArray, ...]

Total charge per species.

required
species_kinetic_energy tuple[FloatArray, ...]

Kinetic energy per species.

required
Source code in src/pypic/readers/ipic3d/_conserved.py
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
@dataclass(frozen=True, slots=True)
class ConservedQuantities:
    """Time series of conserved quantities from an iPIC3D run.

    Two output formats exist:

    **Format A (Roman numeral header)** — single file from phdf5/shdf5 runs.
    Columns: cycle, electric energy (total, x, y, z), magnetic energy
    (total, x, y, z), kinetic energy, total energy, energy variation,
    momentum.

    **Format B (comment header)** — per-restart-segment files from H5hut
    runs. Columns: cycle, total energy, energy variation, electric energy,
    local B energy, kinetic energy, momentum, total B energy, internal B
    energy, KE removed, E removed, then per-species (npart, charge, KE).

    Parameters
    ----------
    cycle : FloatArray
        Cycle numbers (int-valued but stored as float for array uniformity).
    total_energy : FloatArray
        Total energy at each cycle.
    electric_energy : FloatArray
        Total electric field energy.
    magnetic_energy : FloatArray
        Total magnetic field energy.
    kinetic_energy : FloatArray
        Total kinetic energy (all species).
    momentum : FloatArray
        Total momentum magnitude.
    species_npart : tuple[FloatArray, ...]
        Number of particles per species at each cycle.
    species_charge : tuple[FloatArray, ...]
        Total charge per species.
    species_kinetic_energy : tuple[FloatArray, ...]
        Kinetic energy per species.
    """

    cycle: FloatArray
    total_energy: FloatArray
    electric_energy: FloatArray
    magnetic_energy: FloatArray
    kinetic_energy: FloatArray
    momentum: FloatArray
    species_npart: tuple[FloatArray, ...]
    species_charge: tuple[FloatArray, ...]
    species_kinetic_energy: tuple[FloatArray, ...]

IPic3DH5hutReader

Bases: IPic3DReaderBase

Read iPIC3D H5hut field output.

H5hut files store all fields for a single timestep in one file named {SimulationName}-Fields_{cycle:06d}.h5. Arrays are stored in ZYX order ((nzc+1, nyc+1, nxc+1)) and must be transposed.

H5hut stores all moment quantities (density, current, pressure) divided by 4π (Gaussian convention). The reader applies the 4π correction to density, current, and pressure, matching the phdf5/shdf5 readers. Electromagnetic fields are unaffected.

Unique to this reader: the single-file-per-timestep layout, ZYX transpose, H5hut-specific field naming (uppercase axis letters in _PRESSURE_COMPONENT_MAP), and passthrough of unknown native fields. Field-name mapping for everything else, the Gaussian conversions, pressure-tensor mass correction, and config translation live in pypic.readers.ipic3d._field_map and pypic.readers.ipic3d._config, shared with the parallel and serial readers.

Source code in src/pypic/readers/ipic3d/_h5hut.py
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
class IPic3DH5hutReader(IPic3DReaderBase):
    """Read iPIC3D H5hut field output.

    H5hut files store all fields for a single timestep in one file
    named ``{SimulationName}-Fields_{cycle:06d}.h5``. Arrays are stored
    in ZYX order (``(nzc+1, nyc+1, nxc+1)``) and must be transposed.

    H5hut stores **all moment quantities** (density, current, pressure)
    divided by 4π (Gaussian convention). The reader applies the 4π
    correction to density, current, and pressure, matching the phdf5/shdf5
    readers. Electromagnetic fields are unaffected.

    Unique to this reader: the single-file-per-timestep layout, ZYX
    transpose, H5hut-specific field naming (uppercase axis letters
    in ``_PRESSURE_COMPONENT_MAP``), and passthrough of unknown native
    fields. Field-name mapping for everything else, the Gaussian
    conversions, pressure-tensor mass correction, and config
    translation live in `pypic.readers.ipic3d._field_map` and
    `pypic.readers.ipic3d._config`, shared with the parallel and
    serial readers.
    """

    def available_timesteps(self, path: Path) -> list[int]:
        """Sorted cycle numbers, from the ``*-Fields_*.h5`` files under *path*."""
        return sorted(
            int(m.group(1))
            for entry in path.iterdir()
            if (m := _FIELDS_PATTERN.search(entry.name))
        )

    def available_fields_mapping(self, path: Path, step: int) -> dict[str, str | None]:
        """Map canonical field names to native (on-disk) names at *step*.

        Opens the H5hut fields file and inspects ``Step#0/Block/``
        keys. Unknown native keys pass through with the same name as
        both key and value, matching `read_timestep`.

        Parameters
        ----------
        path : Path
            Simulation output directory.
        step : int
            Timestep (cycle) index.

        Returns
        -------
        dict[str, str | None]
            Canonical → native name, ``None`` for computed totals.
        """
        ns = self._config.ns
        with h5py.File(self._find_fields_file(path, step), "r") as f:
            available = set(f["Step#0"]["Block"].keys())

        mapping: dict[str, str | None] = {}
        consumed: set[str] = set()
        for native, canon in _KNOWN_FIELDS.items():
            if native in available:
                mapping[canon] = native
                consumed.add(native)
        for s in range(ns):
            for canon, native in species_moment_names(
                s, _PRESSURE_COMPONENT_MAP
            ).items():
                key = f"{native}_{s}"
                if key in available:
                    mapping[canon] = key
                    consumed.add(key)
        mapping.update((native, native) for native in available - consumed)

        for total in infer_total_fields(set(mapping), ns):
            mapping[total] = None
        return mapping

    def _find_fields_file(self, path: Path, step: int) -> Path:
        """Locate the H5hut fields file for a given cycle."""
        sim_name = self._config.simulation_name or self._config.case
        candidate = path / f"{sim_name}-Fields_{step:06d}.h5"
        if candidate.exists():
            return candidate
        # Fall back to glob
        matches = list(path.glob(f"*-Fields_{step:06d}.h5"))
        if not matches:
            matches = list(path.glob(f"*-Fields_{step}.h5"))
        if not matches:
            msg = f"No H5hut fields file found for step {step} in {path}"
            raise FileNotFoundError(msg)
        return matches[0]

    def read_timestep(
        self,
        path: Path,
        step: int,
        *,
        fields: Iterable[str] | None = None,
    ) -> FieldDataset:
        """Read field and moment data for a single timestep.

        Parameters
        ----------
        path : Path
            Simulation output directory.
        step : int
            Cycle number (e.g. 202500).
        fields : Iterable[str] | None
            When given, only read these canonical field names.
            Dependencies (per-species fields needed for totals)
            are expanded automatically and excluded from the result.

        Returns
        -------
        FieldDataset
            Field data with canonical names. Density, current, and
            pressure tensor all corrected by 4π (Gaussian→SI-rationalized).
        """
        fields_file = self._find_fields_file(path, step)
        wanted: set[str] | None = set(fields) if fields is not None else None
        field_data: dict[str, FloatArray] = {}

        with h5py.File(fields_file, "r") as f:
            step_group = f["Step#0"]
            nspec = int(step_group.attrs["nspec"][0])
            if nspec != self._config.ns:
                msg = (
                    f"Species count mismatch in {fields_file.name}: HDF5 has "
                    f"nspec={nspec} but the config declares ns={self._config.ns}"
                )
                raise ValueError(msg)
            block = step_group["Block"]
            available = set(block.keys())
            expanded: set[str] | None = (
                expand_moment_dependencies(wanted, nspec)
                if wanted is not None
                else None
            )

            consumed: set[str] = set()
            for native, canon in _KNOWN_FIELDS.items():
                if native in available:
                    consumed.add(native)
                    if expanded is None or canon in expanded:
                        field_data[canon] = _read_field(block, native)

            for s in range(nspec):
                names = species_moment_names(s, _PRESSURE_COMPONENT_MAP)
                consumed.update(
                    key
                    for native in names.values()
                    if (key := f"{native}_{s}") in available
                )
                field_data.update(
                    read_species_moments(
                        _species_loader(block, available, s),
                        s,
                        species_qom=self._config.qom[s],
                        expanded=expanded,
                        pressure_map=_PRESSURE_COMPONENT_MAP,
                    )
                )

            # Unknown fields pass through under their native names, unconverted.
            for native in available - consumed:
                if expanded is None or native in expanded:
                    field_data[native] = _read_field(block, native)

        field_data = compute_totals_and_filter(field_data, nspec, expanded, wanted)
        return self._finish(field_data, step=step)
available_timesteps(path)

Sorted cycle numbers, from the *-Fields_*.h5 files under path.

Source code in src/pypic/readers/ipic3d/_h5hut.py
76
77
78
79
80
81
82
def available_timesteps(self, path: Path) -> list[int]:
    """Sorted cycle numbers, from the ``*-Fields_*.h5`` files under *path*."""
    return sorted(
        int(m.group(1))
        for entry in path.iterdir()
        if (m := _FIELDS_PATTERN.search(entry.name))
    )
available_fields_mapping(path, step)

Map canonical field names to native (on-disk) names at step.

Opens the H5hut fields file and inspects Step#0/Block/ keys. Unknown native keys pass through with the same name as both key and value, matching read_timestep.

Parameters:

Name Type Description Default
path Path

Simulation output directory.

required
step int

Timestep (cycle) index.

required

Returns:

Type Description
dict[str, str | None]

Canonical → native name, None for computed totals.

Source code in src/pypic/readers/ipic3d/_h5hut.py
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
def available_fields_mapping(self, path: Path, step: int) -> dict[str, str | None]:
    """Map canonical field names to native (on-disk) names at *step*.

    Opens the H5hut fields file and inspects ``Step#0/Block/``
    keys. Unknown native keys pass through with the same name as
    both key and value, matching `read_timestep`.

    Parameters
    ----------
    path : Path
        Simulation output directory.
    step : int
        Timestep (cycle) index.

    Returns
    -------
    dict[str, str | None]
        Canonical → native name, ``None`` for computed totals.
    """
    ns = self._config.ns
    with h5py.File(self._find_fields_file(path, step), "r") as f:
        available = set(f["Step#0"]["Block"].keys())

    mapping: dict[str, str | None] = {}
    consumed: set[str] = set()
    for native, canon in _KNOWN_FIELDS.items():
        if native in available:
            mapping[canon] = native
            consumed.add(native)
    for s in range(ns):
        for canon, native in species_moment_names(
            s, _PRESSURE_COMPONENT_MAP
        ).items():
            key = f"{native}_{s}"
            if key in available:
                mapping[canon] = key
                consumed.add(key)
    mapping.update((native, native) for native in available - consumed)

    for total in infer_total_fields(set(mapping), ns):
        mapping[total] = None
    return mapping
read_timestep(path, step, *, fields=None)

Read field and moment data for a single timestep.

Parameters:

Name Type Description Default
path Path

Simulation output directory.

required
step int

Cycle number (e.g. 202500).

required
fields Iterable[str] | None

When given, only read these canonical field names. Dependencies (per-species fields needed for totals) are expanded automatically and excluded from the result.

None

Returns:

Type Description
FieldDataset

Field data with canonical names. Density, current, and pressure tensor all corrected by 4π (Gaussian→SI-rationalized).

Source code in src/pypic/readers/ipic3d/_h5hut.py
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
def read_timestep(
    self,
    path: Path,
    step: int,
    *,
    fields: Iterable[str] | None = None,
) -> FieldDataset:
    """Read field and moment data for a single timestep.

    Parameters
    ----------
    path : Path
        Simulation output directory.
    step : int
        Cycle number (e.g. 202500).
    fields : Iterable[str] | None
        When given, only read these canonical field names.
        Dependencies (per-species fields needed for totals)
        are expanded automatically and excluded from the result.

    Returns
    -------
    FieldDataset
        Field data with canonical names. Density, current, and
        pressure tensor all corrected by 4π (Gaussian→SI-rationalized).
    """
    fields_file = self._find_fields_file(path, step)
    wanted: set[str] | None = set(fields) if fields is not None else None
    field_data: dict[str, FloatArray] = {}

    with h5py.File(fields_file, "r") as f:
        step_group = f["Step#0"]
        nspec = int(step_group.attrs["nspec"][0])
        if nspec != self._config.ns:
            msg = (
                f"Species count mismatch in {fields_file.name}: HDF5 has "
                f"nspec={nspec} but the config declares ns={self._config.ns}"
            )
            raise ValueError(msg)
        block = step_group["Block"]
        available = set(block.keys())
        expanded: set[str] | None = (
            expand_moment_dependencies(wanted, nspec)
            if wanted is not None
            else None
        )

        consumed: set[str] = set()
        for native, canon in _KNOWN_FIELDS.items():
            if native in available:
                consumed.add(native)
                if expanded is None or canon in expanded:
                    field_data[canon] = _read_field(block, native)

        for s in range(nspec):
            names = species_moment_names(s, _PRESSURE_COMPONENT_MAP)
            consumed.update(
                key
                for native in names.values()
                if (key := f"{native}_{s}") in available
            )
            field_data.update(
                read_species_moments(
                    _species_loader(block, available, s),
                    s,
                    species_qom=self._config.qom[s],
                    expanded=expanded,
                    pressure_map=_PRESSURE_COMPONENT_MAP,
                )
            )

        # Unknown fields pass through under their native names, unconverted.
        for native in available - consumed:
            if expanded is None or native in expanded:
                field_data[native] = _read_field(block, native)

    field_data = compute_totals_and_filter(field_data, nspec, expanded, wanted)
    return self._finish(field_data, step=step)

IPic3DParallelReader

Bases: IPic3DReaderBase

Read iPIC3D parallel HDF5 (phdf5) output.

Each timestep is stored in separate Fields_XXXXX/ and Moments_XXXXX/ directories containing one .h5 file per field group.

Unique to this reader: scanning timestep directories and the one-file-per-moment layout. Field-name mapping, Gaussian-CGS unit conversions, pressure-tensor mass correction, and config translation live in pypic.readers.ipic3d._field_map and pypic.readers.ipic3d._config, shared with the serial and H5hut readers.

Source code in src/pypic/readers/ipic3d/_parallel.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
class IPic3DParallelReader(IPic3DReaderBase):
    """Read iPIC3D parallel HDF5 (phdf5) output.

    Each timestep is stored in separate ``Fields_XXXXX/`` and
    ``Moments_XXXXX/`` directories containing one ``.h5`` file per
    field group.

    Unique to this reader: scanning timestep directories and the
    one-file-per-moment layout. Field-name mapping, Gaussian-CGS unit
    conversions, pressure-tensor mass correction, and config
    translation live in `pypic.readers.ipic3d._field_map` and
    `pypic.readers.ipic3d._config`, shared with the serial and
    H5hut readers.
    """

    def available_timesteps(self, path: Path) -> list[int]:
        """Sorted timestep numbers, from the ``Fields_XXXXX`` directories."""
        return sorted(
            int(m.group(1))
            for entry in path.iterdir()
            if entry.is_dir() and (m := _FIELDS_DIR_RE.match(entry.name))
        )

    def available_fields_mapping(self, path: Path, step: int) -> dict[str, str | None]:
        """Map canonical field names to native (on-disk) names at *step*.

        Probes HDF5 files in the ``Fields_XXXXX/`` and
        ``Moments_XXXXX/`` directories without loading arrays.

        Parameters
        ----------
        path : Path
            Simulation output directory.
        step : int
            Timestep index.

        Returns
        -------
        dict[str, str | None]
            Canonical → native name, ``None`` for computed totals.
        """
        step_str = f"{step:05d}"
        ns = self._config.ns
        mapping: dict[str, str | None] = {}

        for prefix in ("B", "E"):
            em_path = path / f"Fields_{step_str}" / f"{prefix}_{step_str}.h5"
            if em_path.exists():
                with h5py.File(em_path, "r") as f:
                    mapping.update(
                        (_FIELD_NAME_MAP[name], name)
                        for name in f["Fields"]
                        if name in _FIELD_NAME_MAP
                    )

        for s in range(ns):
            with _MomentFiles(path / f"Moments_{step_str}", step_str, s) as files:
                names = species_moment_names(s, _PHDF5_PRESSURE_MAP)
                mapping.update(
                    (canon, native)
                    for canon, native in names.items()
                    if files.has(native)
                )

        for total in infer_total_fields(set(mapping), ns):
            mapping[total] = None
        return mapping

    def read_timestep(
        self,
        path: Path,
        step: int,
        *,
        fields: Iterable[str] | None = None,
    ) -> FieldDataset:
        """Read field and moment data for a single timestep.

        Parameters
        ----------
        path : Path
            Simulation output directory.
        step : int
            Timestep index (e.g. 0, 10, 20).
        fields : Iterable[str] | None
            When given, only read these canonical field names.

        Returns
        -------
        FieldDataset
            Field data with canonical names and 4π corrections applied.
        """
        step_str = f"{step:05d}"
        ns = self._config.ns
        wanted: set[str] | None = set(fields) if fields is not None else None
        expanded: set[str] | None = (
            expand_moment_dependencies(wanted, ns) if wanted is not None else None
        )
        field_data: dict[str, FloatArray] = {}

        for prefix in ("B", "E"):
            if expanded is not None and not any(
                f"{prefix}_{i}" in expanded for i in "123"
            ):
                continue
            em_path = path / f"Fields_{step_str}" / f"{prefix}_{step_str}.h5"
            with h5py.File(em_path, "r") as f:
                for ipic_name, canon_name in _FIELD_NAME_MAP.items():
                    if (
                        ipic_name.startswith(prefix)
                        and ipic_name in f["Fields"]
                        and (expanded is None or canon_name in expanded)
                    ):
                        field_data[canon_name] = np.array(f["Fields"][ipic_name])

        for s in range(ns):
            with _MomentFiles(path / f"Moments_{step_str}", step_str, s) as files:
                field_data.update(
                    read_species_moments(
                        files.load,
                        s,
                        species_qom=self._config.qom[s],
                        expanded=expanded,
                        pressure_map=_PHDF5_PRESSURE_MAP,
                    )
                )

        field_data = compute_totals_and_filter(field_data, ns, expanded, wanted)
        return self._finish(field_data, step=step)

    def available_particle_steps(self, path: Path) -> list[int]:
        """Return sorted timestep indices that have particle data."""
        return detect_particle_steps(path)

    def read_particles(
        self,
        path: Path,
        step: int,
        species: int,
        *,
        columns: Iterable[str] | None = None,
    ) -> ParticleData:
        """Load particle data for one species at one timestep.

        Parameters
        ----------
        path : Path
            Simulation output directory.
        step : int
            Timestep index.
        species : int
            Zero-based species index.
        columns : Iterable[str] | None
            Subset of ``{"position", "velocity"}`` to load.
            ``None`` loads all.  Per-particle ``weight`` and the scalar
            ``species_charge``/``species_mass`` are always populated
            (canonical layout, ``docs/schema.md``).

        Returns
        -------
        ParticleData
        """
        return read_phdf5_particles(path, step, species, self._config, columns=columns)
available_timesteps(path)

Sorted timestep numbers, from the Fields_XXXXX directories.

Source code in src/pypic/readers/ipic3d/_parallel.py
107
108
109
110
111
112
113
def available_timesteps(self, path: Path) -> list[int]:
    """Sorted timestep numbers, from the ``Fields_XXXXX`` directories."""
    return sorted(
        int(m.group(1))
        for entry in path.iterdir()
        if entry.is_dir() and (m := _FIELDS_DIR_RE.match(entry.name))
    )
available_fields_mapping(path, step)

Map canonical field names to native (on-disk) names at step.

Probes HDF5 files in the Fields_XXXXX/ and Moments_XXXXX/ directories without loading arrays.

Parameters:

Name Type Description Default
path Path

Simulation output directory.

required
step int

Timestep index.

required

Returns:

Type Description
dict[str, str | None]

Canonical → native name, None for computed totals.

Source code in src/pypic/readers/ipic3d/_parallel.py
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
def available_fields_mapping(self, path: Path, step: int) -> dict[str, str | None]:
    """Map canonical field names to native (on-disk) names at *step*.

    Probes HDF5 files in the ``Fields_XXXXX/`` and
    ``Moments_XXXXX/`` directories without loading arrays.

    Parameters
    ----------
    path : Path
        Simulation output directory.
    step : int
        Timestep index.

    Returns
    -------
    dict[str, str | None]
        Canonical → native name, ``None`` for computed totals.
    """
    step_str = f"{step:05d}"
    ns = self._config.ns
    mapping: dict[str, str | None] = {}

    for prefix in ("B", "E"):
        em_path = path / f"Fields_{step_str}" / f"{prefix}_{step_str}.h5"
        if em_path.exists():
            with h5py.File(em_path, "r") as f:
                mapping.update(
                    (_FIELD_NAME_MAP[name], name)
                    for name in f["Fields"]
                    if name in _FIELD_NAME_MAP
                )

    for s in range(ns):
        with _MomentFiles(path / f"Moments_{step_str}", step_str, s) as files:
            names = species_moment_names(s, _PHDF5_PRESSURE_MAP)
            mapping.update(
                (canon, native)
                for canon, native in names.items()
                if files.has(native)
            )

    for total in infer_total_fields(set(mapping), ns):
        mapping[total] = None
    return mapping
read_timestep(path, step, *, fields=None)

Read field and moment data for a single timestep.

Parameters:

Name Type Description Default
path Path

Simulation output directory.

required
step int

Timestep index (e.g. 0, 10, 20).

required
fields Iterable[str] | None

When given, only read these canonical field names.

None

Returns:

Type Description
FieldDataset

Field data with canonical names and 4π corrections applied.

Source code in src/pypic/readers/ipic3d/_parallel.py
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
def read_timestep(
    self,
    path: Path,
    step: int,
    *,
    fields: Iterable[str] | None = None,
) -> FieldDataset:
    """Read field and moment data for a single timestep.

    Parameters
    ----------
    path : Path
        Simulation output directory.
    step : int
        Timestep index (e.g. 0, 10, 20).
    fields : Iterable[str] | None
        When given, only read these canonical field names.

    Returns
    -------
    FieldDataset
        Field data with canonical names and 4π corrections applied.
    """
    step_str = f"{step:05d}"
    ns = self._config.ns
    wanted: set[str] | None = set(fields) if fields is not None else None
    expanded: set[str] | None = (
        expand_moment_dependencies(wanted, ns) if wanted is not None else None
    )
    field_data: dict[str, FloatArray] = {}

    for prefix in ("B", "E"):
        if expanded is not None and not any(
            f"{prefix}_{i}" in expanded for i in "123"
        ):
            continue
        em_path = path / f"Fields_{step_str}" / f"{prefix}_{step_str}.h5"
        with h5py.File(em_path, "r") as f:
            for ipic_name, canon_name in _FIELD_NAME_MAP.items():
                if (
                    ipic_name.startswith(prefix)
                    and ipic_name in f["Fields"]
                    and (expanded is None or canon_name in expanded)
                ):
                    field_data[canon_name] = np.array(f["Fields"][ipic_name])

    for s in range(ns):
        with _MomentFiles(path / f"Moments_{step_str}", step_str, s) as files:
            field_data.update(
                read_species_moments(
                    files.load,
                    s,
                    species_qom=self._config.qom[s],
                    expanded=expanded,
                    pressure_map=_PHDF5_PRESSURE_MAP,
                )
            )

    field_data = compute_totals_and_filter(field_data, ns, expanded, wanted)
    return self._finish(field_data, step=step)
available_particle_steps(path)

Return sorted timestep indices that have particle data.

Source code in src/pypic/readers/ipic3d/_parallel.py
221
222
223
def available_particle_steps(self, path: Path) -> list[int]:
    """Return sorted timestep indices that have particle data."""
    return detect_particle_steps(path)
read_particles(path, step, species, *, columns=None)

Load particle data for one species at one timestep.

Parameters:

Name Type Description Default
path Path

Simulation output directory.

required
step int

Timestep index.

required
species int

Zero-based species index.

required
columns Iterable[str] | None

Subset of {"position", "velocity"} to load. None loads all. Per-particle weight and the scalar species_charge/species_mass are always populated (canonical layout, docs/schema.md).

None

Returns:

Type Description
ParticleData
Source code in src/pypic/readers/ipic3d/_parallel.py
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
def read_particles(
    self,
    path: Path,
    step: int,
    species: int,
    *,
    columns: Iterable[str] | None = None,
) -> ParticleData:
    """Load particle data for one species at one timestep.

    Parameters
    ----------
    path : Path
        Simulation output directory.
    step : int
        Timestep index.
    species : int
        Zero-based species index.
    columns : Iterable[str] | None
        Subset of ``{"position", "velocity"}`` to load.
        ``None`` loads all.  Per-particle ``weight`` and the scalar
        ``species_charge``/``species_mass`` are always populated
        (canonical layout, ``docs/schema.md``).

    Returns
    -------
    ParticleData
    """
    return read_phdf5_particles(path, step, species, self._config, columns=columns)

IPic3DSerialReader

Bases: IPic3DReaderBase

Read iPIC3D serial HDF5 (shdf5) output.

Each MPI process writes to its own procN.hdf file containing all timesteps. This reader assembles the global arrays from the per-process local patches.

Unique to this reader: the per-process patch reassembly. Field-name mapping, Gaussian-CGS unit conversions, pressure-tensor mass correction, and config translation live in pypic.readers.ipic3d._field_map and pypic.readers.ipic3d._config, shared with the parallel and H5hut readers.

Source code in src/pypic/readers/ipic3d/_serial.py
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
class IPic3DSerialReader(IPic3DReaderBase):
    """Read iPIC3D serial HDF5 (shdf5) output.

    Each MPI process writes to its own ``procN.hdf`` file containing all
    timesteps. This reader assembles the global arrays from the per-process
    local patches.

    Unique to this reader: the per-process patch reassembly. Field-name
    mapping, Gaussian-CGS unit conversions, pressure-tensor mass
    correction, and config translation live in
    `pypic.readers.ipic3d._field_map` and
    `pypic.readers.ipic3d._config`, shared with the parallel and
    H5hut readers.
    """

    def available_timesteps(self, path: Path) -> list[int]:
        """Sorted timestep numbers, from the cycle keys in ``proc0.hdf``."""
        with h5py.File(path / "proc0.hdf", "r") as f:
            return sorted(
                int(m.group(1)) for key in f["fields/Bx"] if (m := _CYCLE_RE.match(key))
            )

    @staticmethod
    def _present_moments(proc0: Path, cycle_key: str) -> set[str]:
        """``species_N/<native>`` moment groups carrying *cycle_key* in proc0."""
        with h5py.File(proc0, "r") as f:
            if "moments" not in f:
                return set()
            return {
                f"{species}/{native}"
                for species, group in f["moments"].items()
                for native, cycles in group.items()
                if cycle_key in cycles
            }

    def available_fields_mapping(self, path: Path, step: int) -> dict[str, str | None]:
        """Map canonical field names to native (on-disk) names at *step*.

        Opens ``proc0.hdf`` and inspects HDF5 group keys without
        loading array data.

        Parameters
        ----------
        path : Path
            Simulation output directory.
        step : int
            Timestep index.

        Returns
        -------
        dict[str, str | None]
            Canonical → native name, ``None`` for computed totals.
        """
        proc0 = path / "proc0.hdf"
        cycle_key = f"cycle_{step}"
        ns = self._config.ns
        mapping: dict[str, str | None] = {}

        with h5py.File(proc0, "r") as f:
            if "fields" in f:
                mapping.update(
                    (_FIELD_NAME_MAP[name], name)
                    for name in f["fields"]
                    if name in _FIELD_NAME_MAP
                )
        present = self._present_moments(proc0, cycle_key)
        for s in range(ns):
            names = species_moment_names(s, _PHDF5_PRESSURE_MAP)
            mapping.update(
                (canon, native)
                for canon, native in names.items()
                if f"species_{s}/{native}" in present
            )

        for total in infer_total_fields(set(mapping), ns):
            mapping[total] = None
        return mapping

    def _assemble_field(
        self,
        proc_files: list[Path],
        group_path: str,
        cycle_key: str,
    ) -> FloatArray:
        """Assemble a global array from per-process local patches.

        Parameters
        ----------
        proc_files : list[Path]
            Paths to all proc*.hdf files.
        group_path : str
            HDF5 group path (e.g. ``"fields/Bx"``).
        cycle_key : str
            Cycle dataset name (e.g. ``"cycle_10"``).

        Returns
        -------
        FloatArray
            Assembled global array of shape ``(Nxc+1, Nyc+1, Nzc+1)``.
        """
        cfg = self._config
        global_shape = (cfg.nxc + 1, cfg.nyc + 1, cfg.nzc + 1)
        result = np.zeros(global_shape, dtype=np.float64)

        # Base local sizes and remainders for uneven MPI decompositions.
        # iPIC3D gives the first (N % P) ranks one extra cell.
        nxc_base = cfg.nxc // cfg.xlen
        nyc_base = cfg.nyc // cfg.ylen
        nzc_base = cfg.nzc // cfg.zlen
        nxc_extra = cfg.nxc % cfg.xlen
        nyc_extra = cfg.nyc % cfg.ylen
        nzc_extra = cfg.nzc % cfg.zlen

        for proc_path in proc_files:
            with h5py.File(proc_path, "r") as f:
                coords = f["topology/cartesian_coord"][()]
                ix, iy, iz = int(coords[0]), int(coords[1]), int(coords[2])

                data = np.array(f[group_path][cycle_key])
                nx_local, ny_local, nz_local = data.shape

                x0 = ix * nxc_base + min(ix, nxc_extra)
                y0 = iy * nyc_base + min(iy, nyc_extra)
                z0 = iz * nzc_base + min(iz, nzc_extra)

                result[x0 : x0 + nx_local, y0 : y0 + ny_local, z0 : z0 + nz_local] = (
                    data
                )

        return result

    def _load_moment(
        self,
        proc_files: list[Path],
        cycle_key: str,
        present: set[str],
        species: int,
        native: str,
    ) -> FloatArray | None:
        """Assemble one species moment, or ``None`` when proc0 lacks it."""
        if f"species_{species}/{native}" not in present:
            return None
        return self._assemble_field(
            proc_files, f"moments/species_{species}/{native}", cycle_key
        )

    def read_timestep(
        self,
        path: Path,
        step: int,
        *,
        fields: Iterable[str] | None = None,
    ) -> FieldDataset:
        """Read field and moment data for a single timestep.

        Assembles global arrays from per-process files, applies 4π
        correction to densities and currents, and computes totals.

        Parameters
        ----------
        path : Path
            Simulation output directory.
        step : int
            Timestep index (e.g. 0, 10, 20).
        fields : Iterable[str] | None
            When given, only read these canonical field names.

        Returns
        -------
        FieldDataset
            Field data with canonical names and 4π corrections applied.
        """
        proc_files = sorted(path.glob("proc*.hdf"))
        if not proc_files:
            msg = f"No proc*.hdf files found in {path}"
            raise FileNotFoundError(msg)
        cycle_key = f"cycle_{step}"
        ns = self._config.ns
        wanted: set[str] | None = set(fields) if fields is not None else None
        expanded: set[str] | None = (
            expand_moment_dependencies(wanted, ns) if wanted is not None else None
        )
        field_data: dict[str, FloatArray] = {}

        for ipic_name, canon_name in _FIELD_NAME_MAP.items():
            if expanded is not None and canon_name not in expanded:
                continue
            field_data[canon_name] = self._assemble_field(
                proc_files, f"fields/{ipic_name}", cycle_key
            )

        present = self._present_moments(proc_files[0], cycle_key)
        for s in range(ns):
            field_data.update(
                read_species_moments(
                    partial(self._load_moment, proc_files, cycle_key, present, s),
                    s,
                    species_qom=self._config.qom[s],
                    expanded=expanded,
                    pressure_map=_PHDF5_PRESSURE_MAP,
                )
            )

        field_data = compute_totals_and_filter(field_data, ns, expanded, wanted)
        return self._finish(field_data, step=step)
available_timesteps(path)

Sorted timestep numbers, from the cycle keys in proc0.hdf.

Source code in src/pypic/readers/ipic3d/_serial.py
48
49
50
51
52
53
def available_timesteps(self, path: Path) -> list[int]:
    """Sorted timestep numbers, from the cycle keys in ``proc0.hdf``."""
    with h5py.File(path / "proc0.hdf", "r") as f:
        return sorted(
            int(m.group(1)) for key in f["fields/Bx"] if (m := _CYCLE_RE.match(key))
        )
available_fields_mapping(path, step)

Map canonical field names to native (on-disk) names at step.

Opens proc0.hdf and inspects HDF5 group keys without loading array data.

Parameters:

Name Type Description Default
path Path

Simulation output directory.

required
step int

Timestep index.

required

Returns:

Type Description
dict[str, str | None]

Canonical → native name, None for computed totals.

Source code in src/pypic/readers/ipic3d/_serial.py
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
def available_fields_mapping(self, path: Path, step: int) -> dict[str, str | None]:
    """Map canonical field names to native (on-disk) names at *step*.

    Opens ``proc0.hdf`` and inspects HDF5 group keys without
    loading array data.

    Parameters
    ----------
    path : Path
        Simulation output directory.
    step : int
        Timestep index.

    Returns
    -------
    dict[str, str | None]
        Canonical → native name, ``None`` for computed totals.
    """
    proc0 = path / "proc0.hdf"
    cycle_key = f"cycle_{step}"
    ns = self._config.ns
    mapping: dict[str, str | None] = {}

    with h5py.File(proc0, "r") as f:
        if "fields" in f:
            mapping.update(
                (_FIELD_NAME_MAP[name], name)
                for name in f["fields"]
                if name in _FIELD_NAME_MAP
            )
    present = self._present_moments(proc0, cycle_key)
    for s in range(ns):
        names = species_moment_names(s, _PHDF5_PRESSURE_MAP)
        mapping.update(
            (canon, native)
            for canon, native in names.items()
            if f"species_{s}/{native}" in present
        )

    for total in infer_total_fields(set(mapping), ns):
        mapping[total] = None
    return mapping
read_timestep(path, step, *, fields=None)

Read field and moment data for a single timestep.

Assembles global arrays from per-process files, applies 4π correction to densities and currents, and computes totals.

Parameters:

Name Type Description Default
path Path

Simulation output directory.

required
step int

Timestep index (e.g. 0, 10, 20).

required
fields Iterable[str] | None

When given, only read these canonical field names.

None

Returns:

Type Description
FieldDataset

Field data with canonical names and 4π corrections applied.

Source code in src/pypic/readers/ipic3d/_serial.py
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
def read_timestep(
    self,
    path: Path,
    step: int,
    *,
    fields: Iterable[str] | None = None,
) -> FieldDataset:
    """Read field and moment data for a single timestep.

    Assembles global arrays from per-process files, applies 4π
    correction to densities and currents, and computes totals.

    Parameters
    ----------
    path : Path
        Simulation output directory.
    step : int
        Timestep index (e.g. 0, 10, 20).
    fields : Iterable[str] | None
        When given, only read these canonical field names.

    Returns
    -------
    FieldDataset
        Field data with canonical names and 4π corrections applied.
    """
    proc_files = sorted(path.glob("proc*.hdf"))
    if not proc_files:
        msg = f"No proc*.hdf files found in {path}"
        raise FileNotFoundError(msg)
    cycle_key = f"cycle_{step}"
    ns = self._config.ns
    wanted: set[str] | None = set(fields) if fields is not None else None
    expanded: set[str] | None = (
        expand_moment_dependencies(wanted, ns) if wanted is not None else None
    )
    field_data: dict[str, FloatArray] = {}

    for ipic_name, canon_name in _FIELD_NAME_MAP.items():
        if expanded is not None and canon_name not in expanded:
            continue
        field_data[canon_name] = self._assemble_field(
            proc_files, f"fields/{ipic_name}", cycle_key
        )

    present = self._present_moments(proc_files[0], cycle_key)
    for s in range(ns):
        field_data.update(
            read_species_moments(
                partial(self._load_moment, proc_files, cycle_key, present, s),
                s,
                species_qom=self._config.qom[s],
                expanded=expanded,
                pressure_map=_PHDF5_PRESSURE_MAP,
            )
        )

    field_data = compute_totals_and_filter(field_data, ns, expanded, wanted)
    return self._finish(field_data, step=step)

parse_inp(path)

Parse an iPIC3D .inp configuration file.

Parameters:

Name Type Description Default
path Path

Path to the .inp file.

required

Returns:

Type Description
IPic3DConfig

Parsed configuration.

Raises:

Type Description
ExceptionGroup

If required keys are missing.

Source code in src/pypic/readers/ipic3d/_config.py
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
def parse_inp(path: Path) -> IPic3DConfig:
    """Parse an iPIC3D ``.inp`` configuration file.

    Parameters
    ----------
    path : Path
        Path to the ``.inp`` file.

    Returns
    -------
    IPic3DConfig
        Parsed configuration.

    Raises
    ------
    ExceptionGroup
        If required keys are missing.
    """
    kv: dict[str, str] = {}
    text = path.read_text()
    for raw_line in text.splitlines():
        line = raw_line.strip()
        if not line or line.startswith("#"):
            continue
        match = re.match(r"(\w+)\s*=\s*(.+)", line)
        if match:
            raw_value = match.group(2)
            if "#" in raw_value:
                raw_value = raw_value[: raw_value.index("#")]
            kv[match.group(1)] = raw_value.strip()

    errors: list[Exception] = []

    def require(key: str) -> str:
        if key not in kv:
            errors.append(KeyError(f"Missing required key: {key!r}"))
            return ""
        return kv[key]

    # Grid
    nxc_s = require("nxc")
    nyc_s = require("nyc")
    nzc_s = require("nzc")
    lx_s = require("Lx")
    ly_s = require("Ly")
    lz_s = require("Lz")

    # Time / physics
    dt_s = require("dt")
    c_s = require("c")
    th_s = require("th")

    # MPI
    xlen_s = require("XLEN")
    ylen_s = require("YLEN")
    zlen_s = require("ZLEN")

    # Species
    ns_s = require("ns")
    qom_s = require("qom")

    if errors:
        raise ExceptionGroup("Missing keys in iPIC3D .inp file", errors)

    nxc = int(nxc_s)
    nyc = int(nyc_s)
    nzc = int(nzc_s)
    lx = float(lx_s)
    ly = float(ly_s)
    lz = float(lz_s)
    ns = int(ns_s)

    qom = _parse_array_float(qom_s)
    if len(qom) < ns:
        errors.append(ValueError(f"qom has {len(qom)} entries, expected ns={ns}"))
    elif len(qom) > ns:
        log.debug("qom has %d entries but ns=%d; truncating", len(qom), ns)
        qom = qom[:ns]
    _per_species_keys = (
        "uth",
        "vth",
        "wth",
        "u0",
        "v0",
        "w0",
        "rhoINIT",
        "rhoINJECT",
        "npcelx",
        "npcely",
        "npcelz",
    )
    for key in _per_species_keys:
        if key in kv:
            n = len(kv[key].split())
            if n < ns:
                errors.append(ValueError(f"{key} has {n} entries, expected ns={ns}"))
            elif n > ns:
                log.debug("%s has %d entries but ns=%d; truncating", key, n, ns)
                kv[key] = " ".join(kv[key].split()[:ns])
    if errors:
        raise ExceptionGroup("Validation errors in iPIC3D .inp file", errors)

    return IPic3DConfig(
        nxc=nxc,
        nyc=nyc,
        nzc=nzc,
        lx=lx,
        ly=ly,
        lz=lz,
        dx=lx / nxc,
        dy=ly / nyc,
        dz=lz / nzc,
        dt=float(dt_s),
        xlen=int(xlen_s),
        ylen=int(ylen_s),
        zlen=int(zlen_s),
        c=float(c_s),
        th=float(th_s),
        b0=(
            float(kv.get("B0x", "0.0")),
            float(kv.get("B0y", "0.0")),
            float(kv.get("B0z", "0.0")),
        ),
        ns=ns,
        qom=qom,
        uth=_parse_array_float(kv.get("uth", " ".join(["0.0"] * ns))),
        vth=_parse_array_float(kv.get("vth", " ".join(["0.0"] * ns))),
        wth=_parse_array_float(kv.get("wth", " ".join(["0.0"] * ns))),
        u0=_parse_array_float(kv.get("u0", " ".join(["0.0"] * ns))),
        v0=_parse_array_float(kv.get("v0", " ".join(["0.0"] * ns))),
        w0=_parse_array_float(kv.get("w0", " ".join(["0.0"] * ns))),
        rho_init=_parse_array_float(kv.get("rhoINIT", " ".join(["1.0"] * ns))),
        npcelx=_parse_array_int(kv.get("npcelx", " ".join(["0"] * ns))),
        npcely=_parse_array_int(kv.get("npcely", " ".join(["0"] * ns))),
        npcelz=_parse_array_int(kv.get("npcelz", " ".join(["0"] * ns))),
        periodic_x=kv.get("PERIODICX", "0") == "1",
        periodic_y=kv.get("PERIODICY", "0") == "1",
        periodic_z=kv.get("PERIODICZ", "0") == "1",
        write_method=kv.get("WriteMethod", "phdf5"),
        field_output_cycle=int(kv.get("FieldOutputCycle", "0")),
        field_output_tag=kv.get("FieldOutputTag", ""),
        particles_output_cycle=int(kv.get("ParticlesOutputCycle", "0")),
        case=kv.get("Case", ""),
        simulation_name=kv.get("SimulationName", ""),
    )

parse_settings_hdf(path)

Parse an iPIC3D settings.hdf file (serial format metadata).

Parameters:

Name Type Description Default
path Path

Path to the settings.hdf file.

required

Returns:

Type Description
IPic3DConfig

Parsed configuration (equivalent to parse_inp output).

Source code in src/pypic/readers/ipic3d/_config.py
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
def parse_settings_hdf(path: Path) -> IPic3DConfig:
    """Parse an iPIC3D ``settings.hdf`` file (serial format metadata).

    Parameters
    ----------
    path : Path
        Path to the ``settings.hdf`` file.

    Returns
    -------
    IPic3DConfig
        Parsed configuration (equivalent to `parse_inp` output).
    """
    with h5py.File(path, "r") as f:
        col = f["collective"]
        topo = f["topology"]

        nxc = int(col["Nxc"][()].item())
        nyc = int(col["Nyc"][()].item())
        nzc = int(col["Nzc"][()].item())
        lx = float(col["Lx"][()].item())
        ly = float(col["Ly"][()].item())
        lz = float(col["Lz"][()].item())
        ns = int(col["Ns"][()].item())
        xlen = int(topo["XLEN"][()].item())
        ylen = int(topo["YLEN"][()].item())
        zlen = int(topo["ZLEN"][()].item())

        def _read_scalar(
            group: Any,  # noqa: ANN401 — h5py Group lacks typed protocol
            key: str,
            default: float = 0.0,
        ) -> float:
            if key in group:
                return float(group[key][()].item())
            return default

        qom: list[float] = []
        uth: list[float] = []
        vth: list[float] = []
        wth: list[float] = []
        u0: list[float] = []
        v0: list[float] = []
        w0: list[float] = []
        npcelx: list[int] = []
        npcely: list[int] = []
        npcelz: list[int] = []

        for s in range(ns):
            sp = col[f"species_{s}"]
            qom.append(float(sp["qom"][()].item()))
            uth.append(float(sp["uth"][()].item()))
            vth.append(float(sp["vth"][()].item()))
            wth.append(float(sp["wth"][()].item()))
            u0.append(float(sp["u0"][()].item()))
            v0.append(float(sp["v0"][()].item()))
            w0.append(float(sp["w0"][()].item()))
            npcelx.append(int(sp["Npcelx"][()].item()))
            npcely.append(int(sp["Npcely"][()].item()))
            npcelz.append(int(sp["Npcelz"][()].item()))

        is_periodic_x = int(topo["periodicX"][()].item()) == 1
        is_periodic_y = int(topo["periodicY"][()].item()) == 1
        is_periodic_z = int(topo["periodicZ"][()].item()) == 1

        dt = _read_scalar(col, "Dt")
        c = _read_scalar(col, "c", 1.0)
        th = _read_scalar(col, "Th", 0.5)
        b0 = (
            _read_scalar(col, "Bx0"),
            _read_scalar(col, "By0"),
            _read_scalar(col, "Bz0"),
        )

    return IPic3DConfig(
        nxc=nxc,
        nyc=nyc,
        nzc=nzc,
        lx=lx,
        ly=ly,
        lz=lz,
        dx=lx / nxc,
        dy=ly / nyc,
        dz=lz / nzc,
        dt=dt,
        xlen=xlen,
        ylen=ylen,
        zlen=zlen,
        c=c,
        th=th,
        b0=b0,
        ns=ns,
        qom=tuple(qom),
        uth=tuple(uth),
        vth=tuple(vth),
        wth=tuple(wth),
        u0=tuple(u0),
        v0=tuple(v0),
        w0=tuple(w0),
        rho_init=(1.0,) * ns,  # not stored in settings.hdf
        npcelx=tuple(npcelx),
        npcely=tuple(npcely),
        npcelz=tuple(npcelz),
        periodic_x=is_periodic_x,
        periodic_y=is_periodic_y,
        periodic_z=is_periodic_z,
        write_method="shdf5",  # settings.hdf only exists for serial output
        field_output_cycle=0,  # not stored in settings.hdf
        field_output_tag="",  # not stored in settings.hdf
        particles_output_cycle=0,  # not stored in settings.hdf
        case="",
        simulation_name="",
    )

to_simulation_config(cfg, sim_dir=None)

Convert iPIC3D config to the canonical SimulationConfig.

Uses the node-centered origin offset trick: origin = -dx/2 so that coordinate_arrays() produces exact node positions 0, dx, 2dx, ..., L.

If a simulation.toml exists in sim_dir, its normalization, frame, transforms, and metadata are merged in via pypic.readers._config_helpers.merge_simulation_toml.

Parameters:

Name Type Description Default
cfg IPic3DConfig

Native iPIC3D configuration.

required
sim_dir Path | None

Simulation directory to scan for simulation.toml. None skips the merge.

None

Returns:

Type Description
SimulationConfig

Canonical simulation configuration.

Source code in src/pypic/readers/ipic3d/_config.py
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
def to_simulation_config(
    cfg: IPic3DConfig, sim_dir: Path | None = None
) -> SimulationConfig:
    """Convert iPIC3D config to the canonical SimulationConfig.

    Uses the node-centered origin offset trick: ``origin = -dx/2`` so that
    ``coordinate_arrays()`` produces exact node positions ``0, dx, 2dx, ..., L``.

    If a ``simulation.toml`` exists in *sim_dir*, its normalization, frame,
    transforms, and metadata are merged in via
    `pypic.readers._config_helpers.merge_simulation_toml`.

    Parameters
    ----------
    cfg : IPic3DConfig
        Native iPIC3D configuration.
    sim_dir : Path | None
        Simulation directory to scan for ``simulation.toml``. ``None`` skips
        the merge.

    Returns
    -------
    SimulationConfig
        Canonical simulation configuration.
    """
    boundary_map = {True: "periodic", False: "open"}
    grid = GridInfo(
        dimensions=(cfg.nxc + 1, cfg.nyc + 1, cfg.nzc + 1),
        spacing=(cfg.dx, cfg.dy, cfg.dz),
        origin=(-cfg.dx / 2, -cfg.dy / 2, -cfg.dz / 2),
        geometry=CARTESIAN,
        dt=cfg.dt,
        boundary=(
            boundary_map[cfg.periodic_x],
            boundary_map[cfg.periodic_y],
            boundary_map[cfg.periodic_z],
        ),
    )

    physics = PhysicsParams(
        c=cfg.c,
        extra={"theta": cfg.th, "b0": cfg.b0, **cfg.extra},
    )

    metadata: dict[str, Any] = {
        "stagger": StaggerInfo(convention="node"),
        "write_method": cfg.write_method,
    }
    if cfg.case:
        metadata["case"] = cfg.case
    if cfg.simulation_name:
        metadata["simulation_name"] = cfg.simulation_name
    if cfg.field_output_cycle > 0:
        metadata["field_output_cycle"] = cfg.field_output_cycle
    if cfg.field_output_tag:
        metadata["field_output_tag"] = cfg.field_output_tag
    if cfg.particles_output_cycle > 0:
        metadata["particles_output_cycle"] = cfg.particles_output_cycle

    base = SimulationConfig(
        model_name="iPIC3D",
        model_type="PIC",
        grid=grid,
        # An .inp deck fixes only dimensionless ratios; the SI anchor
        # arrives with a simulation.toml or not at all.
        normalization=Normalization.undeclared(),
        species=_build_species(cfg),
        physics=physics,
        frame="",
        metadata=metadata,
    )
    return merge_simulation_toml(sim_dir, base)

conserved_to_tabular(cq)

Convert a ConservedQuantities to a generic TabularData.

Scalar fields map directly. Per-species tuples are flattened to "npart_s0", "charge_s0", "kinetic_energy_s0", etc.

Parameters:

Name Type Description Default
cq ConservedQuantities

Typed iPIC3D conserved quantities.

required

Returns:

Type Description
TabularData

Columnar representation with index_column="cycle".

Examples:

>>> import numpy as np
>>> cq = ConservedQuantities(
...     cycle=np.array([0.0, 1.0]),
...     total_energy=np.array([5.0, 5.1]),
...     electric_energy=np.array([1.0, 1.1]),
...     magnetic_energy=np.array([2.0, 2.0]),
...     kinetic_energy=np.array([2.0, 2.0]),
...     momentum=np.array([0.1, 0.1]),
...     species_npart=(np.array([100.0, 100.0]),),
...     species_charge=(np.array([1.0, 1.0]),),
...     species_kinetic_energy=(np.array([1.0, 1.0]),),
... )
>>> tab = conserved_to_tabular(cq)
>>> "npart_s0" in tab
True
>>> tab.index_column
'cycle'
Source code in src/pypic/readers/ipic3d/_conserved.py
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
def conserved_to_tabular(cq: ConservedQuantities) -> TabularData:
    """Convert a ``ConservedQuantities`` to a generic ``TabularData``.

    Scalar fields map directly.  Per-species tuples are flattened to
    ``"npart_s0"``, ``"charge_s0"``, ``"kinetic_energy_s0"``, etc.

    Parameters
    ----------
    cq : ConservedQuantities
        Typed iPIC3D conserved quantities.

    Returns
    -------
    TabularData
        Columnar representation with ``index_column="cycle"``.

    Examples
    --------
    >>> import numpy as np
    >>> cq = ConservedQuantities(
    ...     cycle=np.array([0.0, 1.0]),
    ...     total_energy=np.array([5.0, 5.1]),
    ...     electric_energy=np.array([1.0, 1.1]),
    ...     magnetic_energy=np.array([2.0, 2.0]),
    ...     kinetic_energy=np.array([2.0, 2.0]),
    ...     momentum=np.array([0.1, 0.1]),
    ...     species_npart=(np.array([100.0, 100.0]),),
    ...     species_charge=(np.array([1.0, 1.0]),),
    ...     species_kinetic_energy=(np.array([1.0, 1.0]),),
    ... )
    >>> tab = conserved_to_tabular(cq)
    >>> "npart_s0" in tab
    True
    >>> tab.index_column
    'cycle'
    """
    columns: dict[str, FloatArray] = {
        "cycle": cq.cycle,
        "total_energy": cq.total_energy,
        "electric_energy": cq.electric_energy,
        "magnetic_energy": cq.magnetic_energy,
        "kinetic_energy": cq.kinetic_energy,
        "momentum": cq.momentum,
    }
    for s, arr in enumerate(cq.species_npart):
        columns[f"npart_s{s}"] = arr
    for s, arr in enumerate(cq.species_charge):
        columns[f"charge_s{s}"] = arr
    for s, arr in enumerate(cq.species_kinetic_energy):
        columns[f"kinetic_energy_s{s}"] = arr

    return TabularData(
        name="conserved_quantities",
        columns=columns,
        index_column="cycle",
        metadata={"source": "iPIC3D ConservedQuantities"},
    )

load_conserved_quantities(path)

Load conserved quantities from an iPIC3D run, auto-detecting format.

Parameters:

Name Type Description Default
path Path

Either a single ConservedQuantities.txt file (Format A) or a directory containing ConservedQuantities*.txt files (Format B).

required

Returns:

Type Description
ConservedQuantities

Parsed time series.

Raises:

Type Description
FileNotFoundError

If no conserved quantities data is found.

Source code in src/pypic/readers/ipic3d/_conserved.py
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
def load_conserved_quantities(path: Path) -> ConservedQuantities:
    """Load conserved quantities from an iPIC3D run, auto-detecting format.

    Parameters
    ----------
    path : Path
        Either a single ``ConservedQuantities.txt`` file (Format A) or
        a directory containing ``ConservedQuantities*.txt`` files (Format B).

    Returns
    -------
    ConservedQuantities
        Parsed time series.

    Raises
    ------
    FileNotFoundError
        If no conserved quantities data is found.
    """
    if path.is_dir():
        return _parse_multi(path)

    text = path.read_text()
    if _is_roman_header(text):
        return _parse_single(path)

    # Single Format B file
    data, nspec = _parse_multi_file(path)
    species_npart: list[FloatArray] = []
    species_charge: list[FloatArray] = []
    species_ke: list[FloatArray] = []
    for s in range(nspec):
        base_col = _B_SPECIES_BASE + s * _B_SPECIES_STRIDE
        species_npart.append(data[:, base_col])
        species_charge.append(data[:, base_col + 1])
        species_ke.append(data[:, base_col + 2])

    return ConservedQuantities(
        cycle=data[:, _B_CYCLE],
        total_energy=data[:, _B_TOTAL],
        electric_energy=data[:, _B_ELECTRIC],
        magnetic_energy=data[:, _B_MAGNETIC],
        kinetic_energy=data[:, _B_KINETIC],
        momentum=data[:, _B_MOMENTUM],
        species_npart=tuple(species_npart),
        species_charge=tuple(species_charge),
        species_kinetic_energy=tuple(species_ke),
    )

load_species_quantities(path)

Parse iPIC3D SpeciesQuantities.txt into a TabularData.

Format: one row per species per cycle. Columns: cycle, species, momentum, total_ke, bulk_ke, thermal_ke.

The output pivots per-species data into separate columns: cycle, momentum_s0, total_ke_s0, bulk_ke_s0, thermal_ke_s0, ..._s1, ...

Parameters:

Name Type Description Default
path Path

Path to SpeciesQuantities.txt file.

required

Returns:

Type Description
TabularData

Columnar representation with index_column="cycle".

Raises:

Type Description
FileNotFoundError

If the file does not exist.

Source code in src/pypic/readers/ipic3d/_conserved.py
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
def load_species_quantities(path: Path) -> TabularData:
    """Parse iPIC3D ``SpeciesQuantities.txt`` into a ``TabularData``.

    Format: one row per species per cycle. Columns:
    ``cycle, species, momentum, total_ke, bulk_ke, thermal_ke``.

    The output pivots per-species data into separate columns:
    ``cycle, momentum_s0, total_ke_s0, bulk_ke_s0, thermal_ke_s0, ..._s1, ...``

    Parameters
    ----------
    path : Path
        Path to ``SpeciesQuantities.txt`` file.

    Returns
    -------
    TabularData
        Columnar representation with ``index_column="cycle"``.

    Raises
    ------
    FileNotFoundError
        If the file does not exist.
    """
    if not path.exists():
        msg = f"SpeciesQuantities file not found: {path}"
        raise FileNotFoundError(msg)

    rows: list[list[float]] = []
    with path.open() as fh:
        for line in fh:
            stripped = line.strip()
            if not stripped or stripped.startswith(("#", "-")):
                continue
            parts = stripped.split()
            if len(parts) < 6:
                continue
            try:
                rows.append([float(x) for x in parts])
            except ValueError:
                continue

    if not rows:
        msg = f"No data rows found in {path}"
        raise ValueError(msg)

    data = np.array(rows, dtype=np.float64)

    species_ids = sorted({int(x) for x in data[:, _SQ_SPECIES]})
    cycles = sorted(set(data[:, _SQ_CYCLE]))
    n_cycles = len(cycles)
    cycle_arr = np.array(cycles, dtype=np.float64)

    # Build cycle→row-index mapping per species
    columns: dict[str, FloatArray] = {"cycle": cycle_arr}
    for s in species_ids:
        mask = data[:, _SQ_SPECIES] == s
        s_data = data[mask]
        order = np.argsort(s_data[:, _SQ_CYCLE])
        s_data = s_data[order]
        # Ensure same cycle count (truncate to common set)
        n = min(len(s_data), n_cycles)
        columns[f"momentum_s{s}"] = s_data[:n, _SQ_MOMENTUM]
        columns[f"total_ke_s{s}"] = s_data[:n, _SQ_TOTAL_KE]
        columns[f"bulk_ke_s{s}"] = s_data[:n, _SQ_BULK_KE]
        columns[f"thermal_ke_s{s}"] = s_data[:n, _SQ_THERMAL_KE]

    return TabularData(
        name="species_quantities",
        columns=columns,
        index_column="cycle",
        metadata={"source": "iPIC3D SpeciesQuantities"},
    )

detect_particle_steps(path)

Scan for Particles_XXXXX/ directories and return sorted step list.

Parameters:

Name Type Description Default
path Path

Simulation output directory.

required

Returns:

Type Description
list[int]

Sorted timestep indices with particle output.

Source code in src/pypic/readers/ipic3d/_particles.py
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
def detect_particle_steps(path: Path) -> list[int]:
    """Scan for ``Particles_XXXXX/`` directories and return sorted step list.

    Parameters
    ----------
    path : Path
        Simulation output directory.

    Returns
    -------
    list[int]
        Sorted timestep indices with particle output.
    """
    pattern = re.compile(r"^Particles_(\d+)$")
    steps: list[int] = []
    for entry in path.iterdir():
        if entry.is_dir():
            m = pattern.match(entry.name)
            if m:
                steps.append(int(m.group(1)))
    return sorted(steps)

read_phdf5_particles(path, step, species, config, *, columns=None)

Read particle data from a phdf5-format iPIC3D output file.

Parameters:

Name Type Description Default
path Path

Simulation output directory.

required
step int

Timestep index.

required
species int

Zero-based species index.

required
config IPic3DConfig

Parsed iPIC3D configuration. Provides per-species charge/mass via qom. Emits canonical form: weight (derived from native per-particle q as |q| since iPIC3D sets |q_species| = 1) plus scalar species_charge and species_mass.

required
columns Iterable[str] | None

Subset of {"position", "velocity"} to load. None loads all.

None

Returns:

Type Description
ParticleData

Raises:

Type Description
UnknownFieldError

If columns names anything outside {"position", "velocity"}. A typo fails here rather than silently dropping the column.

Source code in src/pypic/readers/ipic3d/_particles.py
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
def read_phdf5_particles(
    path: Path,
    step: int,
    species: int,
    config: IPic3DConfig,
    *,
    columns: Iterable[str] | None = None,
) -> ParticleData:
    r"""Read particle data from a phdf5-format iPIC3D output file.

    Parameters
    ----------
    path : Path
        Simulation output directory.
    step : int
        Timestep index.
    species : int
        Zero-based species index.
    config : IPic3DConfig
        Parsed iPIC3D configuration.  Provides per-species charge/mass via
        ``qom``.  Emits canonical form: ``weight`` (derived from native
        per-particle ``q`` as ``|q|`` since iPIC3D sets ``|q_species| = 1``)
        plus scalar ``species_charge`` and ``species_mass``.
    columns : Iterable[str] | None
        Subset of ``{"position", "velocity"}`` to load.  ``None`` loads all.

    Returns
    -------
    ParticleData

    Raises
    ------
    UnknownFieldError
        If *columns* names anything outside ``{"position", "velocity"}``.
        A typo fails here rather than silently dropping the column.
    """
    step_str = f"{step:05d}"
    h5_path = path / f"Particles_{step_str}" / f"species_{species}_{step_str}.h5"
    group_name = f"Particles/species_{species}"

    want = set(columns) if columns is not None else set(_PARTICLE_COLUMNS)
    unknown = want - _PARTICLE_COLUMNS
    if unknown:
        msg = (
            f"Unknown particle column(s) {sorted(unknown)}. "
            f"Available: {sorted(_PARTICLE_COLUMNS)}."
        )
        raise UnknownFieldError(msg)

    with h5py.File(h5_path, "r") as f:
        group = f[group_name]

        # Determine n_particles from whichever dataset is available
        if "position" in group:
            n_particles = group["position"].shape[0]
        elif "velocity" in group:
            n_particles = group["velocity"].shape[0]
        else:
            msg = f"No position or velocity dataset in {h5_path}:{group_name}"
            raise ValueError(msg)

        position = None
        if "position" in want:
            position = np.array(group["position"])

        velocity = None
        if "velocity" in want:
            velocity = np.array(group["velocity"])

        # iPIC3D stores macroparticle charge q_macro = q_s * w. |q_s| = 1
        # by convention, so weight = |q_macro|. Uniform-weight runs emit a
        # scalar/singleton; particle-splitting or non-uniform-density runs
        # emit a per-particle array.
        q_raw = np.asarray(group["q"])
        if q_raw.size == n_particles:
            weight = np.abs(q_raw.reshape(n_particles).astype(np.float64))
        elif q_raw.size == 1:
            weight = np.full(n_particles, abs(float(q_raw.flat[0])), dtype=np.float64)
        else:
            msg = (
                f"iPIC3D 'q' dataset size {q_raw.size} is neither 1 nor "
                f"n_particles={n_particles} in {h5_path}:{group_name}"
            )
            raise ValueError(msg)

        # ID: optional integer tracking ID
        particle_id = None
        if "ID" in group:
            particle_id = np.array(group["ID"], dtype=np.int64).ravel()

    species_name = f"species_{species}"

    # iPIC3D normalization: |q_species| = 1, sign(q_species) = sign(qom[s]),
    # m_species = 1 / |qom[s]|.
    qom_s = config.qom[species]
    species_charge = float(np.sign(qom_s))
    species_mass = 1.0 / abs(qom_s)

    return ParticleData(
        species_index=species,
        species_name=species_name,
        position=position,
        velocity=velocity,
        n_particles=n_particles,
        metadata={"path": str(h5_path), "format": "phdf5"},
        id=particle_id,
        weight=weight,
        species_charge=species_charge,
        species_mass=species_mass,
    )

can_read_confidence(path)

Estimate confidence that path contains iPIC3D output.

Detection signals (additive, capped at 1.0):

  • *.inp config file: +0.5
  • settings.hdf: +0.4
  • *-Fields_*.h5 (H5hut files): +0.3
  • Fields_* subdirectories (phdf5): +0.2 (fallback only)
  • proc*.hdf files (shdf5): +0.2 (fallback only)
  • Moments_* subdirectories: +0.15 (reinforcement, only if 0 < score < 0.8)
  • Particles_* subdirectories: +0.1 (reinforcement, only if 0 < score < 0.8)

Parameters:

Name Type Description Default
path Path

Directory to check.

required

Returns:

Type Description
float

Confidence in [0.0, 1.0].

Source code in src/pypic/readers/ipic3d/_probe.py
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
def can_read_confidence(path: Path) -> float:
    """Estimate confidence that *path* contains iPIC3D output.

    Detection signals (additive, capped at 1.0):

    - ``*.inp`` config file: +0.5
    - ``settings.hdf``: +0.4
    - ``*-Fields_*.h5`` (H5hut files): +0.3
    - ``Fields_*`` subdirectories (phdf5): +0.2 (fallback only)
    - ``proc*.hdf`` files (shdf5): +0.2 (fallback only)
    - ``Moments_*`` subdirectories: +0.15 (reinforcement, only if 0 < score < 0.8)
    - ``Particles_*`` subdirectories: +0.1 (reinforcement, only if 0 < score < 0.8)

    Parameters
    ----------
    path : Path
        Directory to check.

    Returns
    -------
    float
        Confidence in ``[0.0, 1.0]``.
    """
    if not path.is_dir():
        return 0.0

    score = score_signals(path, _CORE_SIGNALS)

    # Weaker fallback signals for partial-output directories.
    if score == 0.0:
        if _has_subdir_prefix(path, "Fields_"):
            score += 0.2
        if next(path.glob("proc*.hdf"), None) is not None:
            score += 0.2

    # Moments_/Particles_ subdirs reinforce a positive core score but
    # don't bump a near-full score any further.
    if 0.0 < score < 0.8:
        if _has_subdir_prefix(path, "Moments_"):
            score += 0.15
        if _has_subdir_prefix(path, "Particles_"):
            score += 0.1

    return min(score, 1.0)

open_ipic3d(path, *, config_path=None)

Auto-detect iPIC3D format and return the appropriate reader.

Detection priority:

  1. Parse config from .inp or settings.hdf.
  2. If *-Fields_*.h5 files exist → IPic3DH5hutReader.
  3. If WriteMethod == "shdf5"IPic3DSerialReader.
  4. If WriteMethod == "h5hut"IPic3DH5hutReader.
  5. Default → IPic3DParallelReader.

File-based detection (step 2) takes precedence because WriteMethod is often commented out in H5hut runs.

Parameters:

Name Type Description Default
path Path

Simulation output directory.

required
config_path Path | None

Explicit path to an .inp or settings.hdf file. When None, auto-detected from path.

None

Returns:

Type Description
tuple[SimulationReader, SimulationConfig]

A (reader, config) pair ready for reader.read_timestep(path, step).

Raises:

Type Description
FileNotFoundError

If no .inp or settings.hdf file is found.

Source code in src/pypic/readers/ipic3d/__init__.py
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
def open_ipic3d(
    path: Path,
    *,
    config_path: Path | None = None,
) -> tuple[SimulationReader, SimulationConfig]:
    """Auto-detect iPIC3D format and return the appropriate reader.

    Detection priority:

    1. Parse config from ``.inp`` or ``settings.hdf``.
    2. If ``*-Fields_*.h5`` files exist → `IPic3DH5hutReader`.
    3. If ``WriteMethod == "shdf5"`` → `IPic3DSerialReader`.
    4. If ``WriteMethod == "h5hut"`` → `IPic3DH5hutReader`.
    5. Default → `IPic3DParallelReader`.

    File-based detection (step 2) takes precedence because
    ``WriteMethod`` is often commented out in H5hut runs.

    Parameters
    ----------
    path : Path
        Simulation output directory.
    config_path : Path | None
        Explicit path to an ``.inp`` or ``settings.hdf`` file.
        When ``None``, auto-detected from *path*.

    Returns
    -------
    tuple[SimulationReader, SimulationConfig]
        A (reader, config) pair ready for
        ``reader.read_timestep(path, step)``.

    Raises
    ------
    FileNotFoundError
        If no ``.inp`` or ``settings.hdf`` file is found.
    """
    if config_path is not None:
        suffix = config_path.suffix
        if suffix == ".hdf":
            cfg = parse_settings_hdf(config_path)
        else:
            cfg = parse_inp(config_path)
    elif inp_files := list(path.glob("*.inp")):
        cfg = parse_inp(inp_files[0])
    elif (path / "settings.hdf").exists():
        cfg = parse_settings_hdf(path / "settings.hdf")
    else:
        msg = f"No .inp or settings.hdf found in {path}"
        raise FileNotFoundError(msg)

    sim_config = to_simulation_config(cfg, path)

    reader: SimulationReader
    if _has_h5hut_files(path):
        reader = IPic3DH5hutReader(cfg, sim_config)
    else:
        match cfg.write_method:
            case "shdf5":
                reader = IPic3DSerialReader(cfg, sim_config)
            case "h5hut":
                reader = IPic3DH5hutReader(cfg, sim_config)
            case _:
                reader = IPic3DParallelReader(cfg, sim_config)

    return reader, sim_config

openggcm

OpenGGCM-UCLA MHD .3df reader.

Reads .3df field output files and grid.*.dat grid definitions from the OpenGGCM global MHD model. The .3df format uses WRN2 lossy compression (~12.5-bit precision via logarithmic quantization + run-length encoding).

Quick start::

from pypic.readers.openggcm import open_openggcm
from pathlib import Path

path = Path("tests/data/openggcm-small")
reader, cfg = open_openggcm(path)
ds = reader.read_timestep(path, 6300)
sorted(ds.field_names())
# ['B_1', 'B_2', 'B_3', 'P', 'V_1', 'V_2', 'V_3', 'n_s0', ...]

OpenGGCMGrid dataclass

Non-uniform grid definition from an OpenGGCM grid file.

Parameters:

Name Type Description Default
nx int

Number of grid points along each axis.

required
ny int

Number of grid points along each axis.

required
nz int

Number of grid points along each axis.

required
x FloatArray

X-coordinates (non-uniform), shape (nx,), in \(R_E\).

required
y FloatArray

Y-coordinates (non-uniform), shape (ny,), in \(R_E\).

required
z FloatArray

Z-coordinates (non-uniform), shape (nz,), in \(R_E\).

required
stagger MappingProxyType[str, tuple[FloatArray, FloatArray, FloatArray]]

Staggered grid positions keyed by field component ("bx", "by", "bz", "ex", "ey", "ez"). Each value is (gx, gy, gz) for that component.

required
metadata MappingProxyType[str, str]

Header metadata (DIPOLETIME, BASETIME).

required
Source code in src/pypic/readers/openggcm/_grid.py
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@dataclass(frozen=True, slots=True)
class OpenGGCMGrid:
    r"""Non-uniform grid definition from an OpenGGCM grid file.

    Parameters
    ----------
    nx, ny, nz : int
        Number of grid points along each axis.
    x : FloatArray
        X-coordinates (non-uniform), shape ``(nx,)``, in $R_E$.
    y : FloatArray
        Y-coordinates (non-uniform), shape ``(ny,)``, in $R_E$.
    z : FloatArray
        Z-coordinates (non-uniform), shape ``(nz,)``, in $R_E$.
    stagger : MappingProxyType[str, tuple[FloatArray, FloatArray, FloatArray]]
        Staggered grid positions keyed by field component (``"bx"``,
        ``"by"``, ``"bz"``, ``"ex"``, ``"ey"``, ``"ez"``).  Each value
        is ``(gx, gy, gz)`` for that component.
    metadata : MappingProxyType[str, str]
        Header metadata (``DIPOLETIME``, ``BASETIME``).
    """

    nx: int
    ny: int
    nz: int
    x: FloatArray
    y: FloatArray
    z: FloatArray
    stagger: MappingProxyType[str, tuple[FloatArray, FloatArray, FloatArray]]
    metadata: MappingProxyType[str, str]

OpenGGCMReader

Bases: ReaderBase

Read OpenGGCM .3df field output on a non-uniform grid.

Parameters:

Name Type Description Default
grid OpenGGCMGrid

Parsed grid definition.

required
prefix str

Filename prefix (e.g. "gc012" for gc012.3df.006300).

required
sim_config SimulationConfig

Merged run configuration. Its normalization converts the SI values on disk to code units; identity leaves them in SI.

required
Source code in src/pypic/readers/openggcm/_reader.py
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
class OpenGGCMReader(ReaderBase):
    """Read OpenGGCM .3df field output on a non-uniform grid.

    Parameters
    ----------
    grid : OpenGGCMGrid
        Parsed grid definition.
    prefix : str
        Filename prefix (e.g. ``"gc012"`` for ``gc012.3df.006300``).
    sim_config : SimulationConfig
        Merged run configuration. Its normalization converts the SI
        values on disk to code units; identity leaves them in SI.
    """

    def __init__(
        self,
        grid: OpenGGCMGrid,
        prefix: str,
        sim_config: SimulationConfig,
    ) -> None:
        super().__init__(sim_config)
        self._grid = grid
        self._prefix = prefix

    @property
    def grid(self) -> OpenGGCMGrid:
        """The OpenGGCM non-uniform grid."""
        return self._grid

    def available_timesteps(self, path: Path) -> list[int]:
        """Return sorted list of available timestep indices.

        Scans for ``{prefix}.3df.*`` files under *path*.

        Parameters
        ----------
        path : Path
            Directory containing .3df files.

        Returns
        -------
        list[int]
            Sorted timestep indices.
        """
        return sorted(
            int(m.group(1))
            for entry in path.glob(f"{self._prefix}.3df.*")
            if (m := _3DF_PATTERN.search(entry.name))
        )

    def available_fields_mapping(self, path: Path, step: int) -> dict[str, str | None]:
        """Map canonical field names to native ``.3df`` record names at *step*.

        Scans the file's ``FIELD-3D-1`` markers without decoding any
        WRN2 payload.
        """
        filename = path / f"{self._prefix}.3df.{step:06d}"
        native = [
            name for name in read_3df_field_names(filename) if name not in DEFAULT_SKIP
        ]
        mapping: dict[str, str | None] = {
            FIELD_NAME_MAP.get(name, name): name for name in native
        }
        if "rr" in native:
            mapping["n_s0"] = "rr"
        return mapping

    def read_timestep(
        self,
        path: Path,
        step: int,
        *,
        fields: Iterable[str] | None = None,
    ) -> FieldDataset:
        """Read fields for a single timestep.

        Parameters
        ----------
        path : Path
            Directory containing .3df files.
        step : int
            Timestep index (e.g. 6300).
        fields : Iterable[str] | None
            When given, only read these canonical field names.  Skips
            WRN2 decompression for unwanted native fields.

        Returns
        -------
        FieldDataset
            Field data with canonical names in SI (or normalized) units.
            ``metadata["is_uniform_grid"]`` is ``False`` — the grid is
            non-uniform, so ``grid.spacing`` is a mean approximation.
            Use the xarray coordinates for accurate spacing.
        """
        filename = path / f"{self._prefix}.3df.{step:06d}"
        if not filename.exists():
            msg = f"File not found: {filename}"
            raise FileNotFoundError(msg)

        skip = set(DEFAULT_SKIP)
        wanted_canonical: set[str] | None = None
        if fields is not None:
            wanted_canonical = set(fields)
            wanted_native: set[str] = set()
            for native, canonical in FIELD_NAME_MAP.items():
                if canonical in wanted_canonical:
                    wanted_native.add(native)
            # n_s0 is derived from "rr" in convert_fields_to_si
            if "n_s0" in wanted_canonical:
                wanted_native.add("rr")
            # Skip known native fields that aren't wanted
            skip = skip | (set(FIELD_NAME_MAP.keys()) - wanted_native)

        raw_fields, _ts, nx, ny, nz = read_3df_file(filename, skip=skip)

        # Verify grid dimensions match
        if (nx, ny, nz) != (self._grid.nx, self._grid.ny, self._grid.nz):
            msg = (
                f"Dimension mismatch: file ({nx}, {ny}, {nz}) vs "
                f"grid ({self._grid.nx}, {self._grid.ny}, {self._grid.nz})"
            )
            raise ValueError(msg)

        sc = self._require_config()
        code_fields = normalize_fields(
            convert_fields_to_si(raw_fields), sc.normalization
        )
        if wanted_canonical is not None:
            code_fields = {
                k: v for k, v in code_fields.items() if k in wanted_canonical
            }

        return self._finish(
            code_fields,
            step=step,
            coords={"x": self._grid.x, "y": self._grid.y, "z": self._grid.z},
            extra={"is_uniform_grid": False, "stagger": _STAGGER},
        )
grid property

The OpenGGCM non-uniform grid.

available_timesteps(path)

Return sorted list of available timestep indices.

Scans for {prefix}.3df.* files under path.

Parameters:

Name Type Description Default
path Path

Directory containing .3df files.

required

Returns:

Type Description
list[int]

Sorted timestep indices.

Source code in src/pypic/readers/openggcm/_reader.py
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
def available_timesteps(self, path: Path) -> list[int]:
    """Return sorted list of available timestep indices.

    Scans for ``{prefix}.3df.*`` files under *path*.

    Parameters
    ----------
    path : Path
        Directory containing .3df files.

    Returns
    -------
    list[int]
        Sorted timestep indices.
    """
    return sorted(
        int(m.group(1))
        for entry in path.glob(f"{self._prefix}.3df.*")
        if (m := _3DF_PATTERN.search(entry.name))
    )
available_fields_mapping(path, step)

Map canonical field names to native .3df record names at step.

Scans the file's FIELD-3D-1 markers without decoding any WRN2 payload.

Source code in src/pypic/readers/openggcm/_reader.py
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
def available_fields_mapping(self, path: Path, step: int) -> dict[str, str | None]:
    """Map canonical field names to native ``.3df`` record names at *step*.

    Scans the file's ``FIELD-3D-1`` markers without decoding any
    WRN2 payload.
    """
    filename = path / f"{self._prefix}.3df.{step:06d}"
    native = [
        name for name in read_3df_field_names(filename) if name not in DEFAULT_SKIP
    ]
    mapping: dict[str, str | None] = {
        FIELD_NAME_MAP.get(name, name): name for name in native
    }
    if "rr" in native:
        mapping["n_s0"] = "rr"
    return mapping
read_timestep(path, step, *, fields=None)

Read fields for a single timestep.

Parameters:

Name Type Description Default
path Path

Directory containing .3df files.

required
step int

Timestep index (e.g. 6300).

required
fields Iterable[str] | None

When given, only read these canonical field names. Skips WRN2 decompression for unwanted native fields.

None

Returns:

Type Description
FieldDataset

Field data with canonical names in SI (or normalized) units. metadata["is_uniform_grid"] is False — the grid is non-uniform, so grid.spacing is a mean approximation. Use the xarray coordinates for accurate spacing.

Source code in src/pypic/readers/openggcm/_reader.py
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
def read_timestep(
    self,
    path: Path,
    step: int,
    *,
    fields: Iterable[str] | None = None,
) -> FieldDataset:
    """Read fields for a single timestep.

    Parameters
    ----------
    path : Path
        Directory containing .3df files.
    step : int
        Timestep index (e.g. 6300).
    fields : Iterable[str] | None
        When given, only read these canonical field names.  Skips
        WRN2 decompression for unwanted native fields.

    Returns
    -------
    FieldDataset
        Field data with canonical names in SI (or normalized) units.
        ``metadata["is_uniform_grid"]`` is ``False`` — the grid is
        non-uniform, so ``grid.spacing`` is a mean approximation.
        Use the xarray coordinates for accurate spacing.
    """
    filename = path / f"{self._prefix}.3df.{step:06d}"
    if not filename.exists():
        msg = f"File not found: {filename}"
        raise FileNotFoundError(msg)

    skip = set(DEFAULT_SKIP)
    wanted_canonical: set[str] | None = None
    if fields is not None:
        wanted_canonical = set(fields)
        wanted_native: set[str] = set()
        for native, canonical in FIELD_NAME_MAP.items():
            if canonical in wanted_canonical:
                wanted_native.add(native)
        # n_s0 is derived from "rr" in convert_fields_to_si
        if "n_s0" in wanted_canonical:
            wanted_native.add("rr")
        # Skip known native fields that aren't wanted
        skip = skip | (set(FIELD_NAME_MAP.keys()) - wanted_native)

    raw_fields, _ts, nx, ny, nz = read_3df_file(filename, skip=skip)

    # Verify grid dimensions match
    if (nx, ny, nz) != (self._grid.nx, self._grid.ny, self._grid.nz):
        msg = (
            f"Dimension mismatch: file ({nx}, {ny}, {nz}) vs "
            f"grid ({self._grid.nx}, {self._grid.ny}, {self._grid.nz})"
        )
        raise ValueError(msg)

    sc = self._require_config()
    code_fields = normalize_fields(
        convert_fields_to_si(raw_fields), sc.normalization
    )
    if wanted_canonical is not None:
        code_fields = {
            k: v for k, v in code_fields.items() if k in wanted_canonical
        }

    return self._finish(
        code_fields,
        step=step,
        coords={"x": self._grid.x, "y": self._grid.y, "z": self._grid.z},
        extra={"is_uniform_grid": False, "stagger": _STAGGER},
    )

parse_grid_file(path)

Parse an OpenGGCM ASCII grid file.

The file contains header metadata followed by 21 FIELD-1D-1 sections: primary grids (gridx, gridy, gridz) then 18 staggered grids for the six field components (B and E, three directions each).

Parameters:

Name Type Description Default
path Path

Path to the grid.*.dat file.

required

Returns:

Type Description
OpenGGCMGrid
Source code in src/pypic/readers/openggcm/_grid.py
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
def parse_grid_file(path: Path) -> OpenGGCMGrid:
    """Parse an OpenGGCM ASCII grid file.

    The file contains header metadata followed by 21 ``FIELD-1D-1``
    sections: primary grids (``gridx``, ``gridy``, ``gridz``) then 18
    staggered grids for the six field components (B and E, three
    directions each).

    Parameters
    ----------
    path : Path
        Path to the ``grid.*.dat`` file.

    Returns
    -------
    OpenGGCMGrid
    """
    text = path.read_text(encoding="ascii")
    lines = text.splitlines()

    # Parse header metadata
    meta: dict[str, str] = {}
    idx = 0
    while idx < len(lines) and "FIELD-1D-1" not in lines[idx]:
        line = lines[idx].strip()
        if line.startswith("DIPOLETIME:"):
            meta["DIPOLETIME"] = line.split(":", 1)[1]
        elif line.startswith("BASETIME:"):
            meta["BASETIME"] = line.split(":", 1)[1]
        idx += 1

    # Parse all FIELD-1D-1 sections
    grids: dict[str, FloatArray] = {}
    while idx < len(lines):
        if "FIELD-1D-1" in lines[idx]:
            name, _, values, idx = _parse_field_1d(lines, idx)
            grids[name] = values
        else:
            idx += 1

    # Extract primary grids
    x = grids["gridx"]
    y = grids["gridy"]
    z = grids["gridz"]

    # Build staggered grid mapping
    stagger: dict[str, tuple[FloatArray, FloatArray, FloatArray]] = {}
    for component in ("bx", "by", "bz", "ex", "ey", "ez"):
        gx_key = f"gx-{component}"
        gy_key = f"gy-{component}"
        gz_key = f"gz-{component}"
        if gx_key in grids and gy_key in grids and gz_key in grids:
            stagger[component] = (grids[gx_key], grids[gy_key], grids[gz_key])

    return OpenGGCMGrid(
        nx=len(x),
        ny=len(y),
        nz=len(z),
        x=x,
        y=y,
        z=z,
        stagger=MappingProxyType(stagger),
        metadata=MappingProxyType(meta),
    )

can_read_confidence(path)

Estimate confidence that path contains OpenGGCM output.

Detection signals (additive, capped at 1.0):

  • grid.*.dat grid file: +0.5
  • *.3df.* field files: +0.5

Parameters:

Name Type Description Default
path Path

Directory to check.

required

Returns:

Type Description
float

Confidence in [0.0, 1.0].

Source code in src/pypic/readers/openggcm/_probe.py
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
def can_read_confidence(path: Path) -> float:
    """Estimate confidence that *path* contains OpenGGCM output.

    Detection signals (additive, capped at 1.0):

    - ``grid.*.dat`` grid file: +0.5
    - ``*.3df.*`` field files: +0.5

    Parameters
    ----------
    path : Path
        Directory to check.

    Returns
    -------
    float
        Confidence in ``[0.0, 1.0]``.
    """
    return score_signals(path, _SIGNALS)

open_openggcm(path, normalization=None, *, config_path=None)

Auto-detect OpenGGCM files and return a reader + config.

Looks for grid.*.dat and *.3df.* files under path.

Parameters:

Name Type Description Default
path Path

Directory containing OpenGGCM output files.

required
normalization Normalization | None

If provided, data is normalized from SI to code units.

None
config_path Path | None

Explicit path to a grid.*.dat file. When None, auto-detected from path.

None

Returns:

Name Type Description
reader OpenGGCMReader

Configured reader instance.

config SimulationConfig

Simulation metadata.

Source code in src/pypic/readers/openggcm/__init__.py
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
def open_openggcm(
    path: Path,
    normalization: Normalization | None = None,
    *,
    config_path: Path | None = None,
) -> tuple[OpenGGCMReader, SimulationConfig]:
    """Auto-detect OpenGGCM files and return a reader + config.

    Looks for ``grid.*.dat`` and ``*.3df.*`` files under *path*.

    Parameters
    ----------
    path : Path
        Directory containing OpenGGCM output files.
    normalization : Normalization | None
        If provided, data is normalized from SI to code units.
    config_path : Path | None
        Explicit path to a ``grid.*.dat`` file.  When ``None``,
        auto-detected from *path*.

    Returns
    -------
    reader : OpenGGCMReader
        Configured reader instance.
    config : SimulationConfig
        Simulation metadata.
    """
    # Find grid file
    if config_path is not None:
        grid_file = config_path
    else:
        grid_files = list(path.glob("grid.*.dat"))
        if not grid_files:
            msg = f"No grid.*.dat file found in {path}"
            raise FileNotFoundError(msg)
        grid_file = grid_files[0]
    grid = parse_grid_file(grid_file)
    log.info(
        "Grid: %d x %d x %d, x=[%.1f, %.1f] R_E",
        grid.nx,
        grid.ny,
        grid.nz,
        grid.x[0],
        grid.x[-1],
    )

    # Detect prefix from .3df files
    prefix = _detect_prefix(path)

    grid_info = _make_grid_info(grid)
    base_config = SimulationConfig(
        model_name="OpenGGCM",
        model_type="MHD",
        grid=grid_info,
        normalization=normalization or Normalization.undeclared(),
        physics=PhysicsParams(),
        frame="GSM",
        metadata={
            "grid_file": grid_file.name,
            "prefix": prefix,
            **dict(grid.metadata),
        },
    )

    config = merge_simulation_toml(path, base_config)
    return OpenGGCMReader(grid, prefix, config), config