Skip to content

Getting Started

Installation

pypic requires Python 3.13+. Install with uv:

uv add pypic-plasma

The distribution is named pypic-plasma; the import name is pypic.

Core dependencies (NumPy, SciPy, xarray, h5py, pydantic) are installed automatically. Everything heavier sits behind an extra, and extras compose:

uv add "pypic-plasma[plot,cli]"        # what most installs want
uv add "pypic-plasma[plot,zarr,cli]"   # ... plus modern I/O
Extra Pulls in Enables
plot matplotlib Field slices, comparisons, line plots, kymographs, quiver/streamlines, spectra, and the theme system
3d pyvista 3D rendering and field-line visualization
cli typer, rich the pypic command
zarr zarr, numcodecs, virtualizarr, icechunk Zarr v3 export/import, VirtualiZarr views over legacy HDF5, Icechunk storage
icechunk icechunk, zarr, numcodecs Icechunk versioned storage without the VirtualiZarr dependency
arrow pyarrow Parquet/Arrow particle I/O
duckdb duckdb, pyarrow SQL queries over particle Parquet
server fastapi, uvicorn, pyarrow, websockets the Arrow IPC server behind pypic serve

Working from a checkout instead:

git clone https://github.com/rusaitis/pypic.git && cd pypic
uv sync --all-extras --all-groups

Without simulation data

Everything below works on a dataset you build yourself, so you can try pypic before you have output from a code. FieldDataset.from_arrays takes a dict of NumPy arrays plus a GridInfo:

import numpy as np
from pypic import CARTESIAN, FieldDataset, GridInfo, Normalization

nx, ny, nz = 32, 32, 1
grid = GridInfo(
    dimensions=(nx, ny, nz),
    spacing=(0.5, 0.5, 1.0),
    origin=(-8.0, -8.0, 0.0),
    geometry=CARTESIAN,
)

# A Harris current sheet: B_x reverses across y, pressure balances it.
y = grid.origin[1] + (np.arange(ny) + 0.5) * grid.spacing[1]
bx = np.tanh(y / 2.0)[None, :, None] * np.ones((nx, ny, nz))

data = FieldDataset.from_arrays(
    {
        "B_1": bx,
        "B_2": np.zeros((nx, ny, nz)),
        "B_3": np.zeros((nx, ny, nz)),
        "rho_m": np.ones((nx, ny, nz)),
        "P": 0.5 * (1.0 - bx**2) + 0.1,
    },
    grid,
    # The SI anchor. `identity()` says these arrays already are SI;
    # omit it and pypic records the units as undeclared, which is
    # honest but makes `in_si()` below raise instead of guessing.
    Normalization.identity(),
)

print(data.compute("|B|").max())   # -> 0.999, saturating at the edges
print(data.compute("beta").max())  # -> 76.6, pressure-dominated at the centre

Field names must resolve through the registry — see Schema § 3 for the canonical set. Pass strict_fields=False to allow unregistered names through, or with_field(name, array, quantity_type) to attach one with metadata.

About that Normalization. Code units fix only dimensionless ratios, so converting to SI needs one absolute anchor that no reader can recover from the data. Supply it three ways: ship a simulation.toml with a [units] section, pass normalization= as above, or stay in code units and use the dimensionless quantities (beta, M_A, the agyrotropy measures), which are correct under any anchor. A dataset built without one reports its units as undeclared — sim.describe() and pypic info both say so — and in_si() raises rather than returning code units labelled as tesla. See Conventions for why this is a modelling choice rather than a missing feature.

The same ground is covered by runnable scripts in examples/, numbered so each one adds a layer over the last.

This is also the entry point a new reader uses: examples/custom_reader_example.py is a self-contained script that generates a synthetic HDF5 file, maps its native names onto canonical ones, and reads it back through open_simulation.

Loading simulation data

pypic auto-detects simulation formats (iPIC3D, BATSRUS, OpenGGCM, and generic HDF5):

from pypic import open_simulation

sim = open_simulation("path/to/output")
print(sim.describe())           # metadata, grid, species
print(sim.steps)                # available timesteps

data = sim.read(step=0)         # load fields for timestep 0
print(data.field_names())       # canonical field names

For selective loading (faster for large datasets):

data = sim.read(step=0, fields=["B", "rho_m", "P"])

Vector shorthand expands automatically: "B" loads B_1, B_2, B_3.

Computing derived quantities

All derived quantities are computed from the canonical fields:

beta = data.compute("beta")       # plasma beta (2P/B²)
v_a = data.compute("v_A")         # Alfvén speed
mach = data.compute("M_A")        # Alfvén Mach number

# Attach to the dataset for reuse
data = data.with_derived("|B|", "beta", "v_A")

See Compute & Recipes for the full list of computable quantities, and the field registry for their display metadata and SI units.

Unit conversion

All computation uses normalized code units. Convert at display boundaries:

from pypic import field_info

# SI conversion
b_si = data.in_si("B_1")          # Tesla
v_si = data.in_si("v_A")         # m/s

# Display units
b_nt = data.in_units("B_1", "nT")
v_kms = data.in_units("v_A", "km/s")

# Check metadata
info = field_info("|B|")
print(info.long_name, info.si_unit)  # "Magnetic field magnitude" "T"

Selecting regions

Slice 3D data into lower-dimensional views:

from pypic import PlaneSelection, BoxSelection

# XY plane at the midpoint
plane = PlaneSelection(normal="z")
slice_2d = plane.apply(data)

# Subvolume
box = BoxSelection(ranges={"x": (10, 50), "y": (20, 40)})
subregion = box.apply(data)

Plotting

Basic 2D field visualization:

from pypic.plotting import plot_field_slice

# `plane=` takes a PlaneSelection; omit it to slice the midplane.
plot_field_slice(data, field="|B|")

For comparison of two simulations:

from pypic import PlaneSelection
from pypic.plotting import plot_comparison

plot_comparison(data_a, data_b, field="beta", plane=PlaneSelection(normal="y"))