Skip to content

Server

pypic.server is the Arrow IPC + JSON HTTP bridge between pypic and webpic (the Three.js/WebGPU viewer). It exposes JSON discovery routes over HTTP and one WebSocket endpoint that streams FieldDataset slices as Arrow IPC bytes.

Install the optional dependencies:

pip install 'pypic-plasma[server]'

Run it:

pypic serve /data/runs --host 127.0.0.1 --port 8000

Where /data/runs is a directory whose subdirectories each contain a simulation.toml file. Each subdirectory becomes one addressable simulation; the directory name is the simulation's URL identifier.

Why Arrow IPC + WebSocket

The transport choice is deliberate:

Choice Why
Arrow IPC Browser-native reader (tableFromIPC in the apache-arrow npm package), Rust reader (arrow-ipc), Python reader (pa.ipc.open_stream). No additional schema layer needed — Arrow's own schema metadata carries everything cross-language.
WebSocket Persistent bidirectional connection (no HTTP-per-request overhead), text + binary frame multiplexing on one wire.
Not Arrow Flight No Flight JS client exists for browsers; gRPC-Web would require an Envoy proxy and lose Flight's advantages.

HTTP routes

All return JSON.

Route Returns
GET /health {"status": "ok", "pypic_version": "..."}
GET /sims {"sims": [<name>, ...]} — names of subdirectories of root that contain simulation.toml.
GET /sims/{sim} Identity + grid + normalization + species for one simulation. JSON dump of typed metadata; client can reconstruct SimulationConfig-equivalent state.
GET /sims/{sim}/steps {"steps": [0, 100, 200, ...]} — available timestep indices.
GET /sims/{sim}/fields?step=N {"step": N, "fields": {canonical: native_name_or_null, ...}}step defaults to the first available step when omitted.

FastAPI auto-generates an OpenAPI schema at /openapi.json and an interactive browser at /docs — useful for webpic developers exploring the surface. (WebSocket routes are out of the OpenAPI 3.0 spec by design; this page is the wire-protocol reference for them.)

WebSocket: WS /sims/{sim}/stream

One persistent connection per webpic tab. Each subscribe frame is one request/response exchange; the connection stays open across exchanges and survives errors.

Request frame (JSON text)

{
  "type": "subscribe",
  "request_id": "uuid-or-any-client-string",
  "step": 0,
  "fields": ["B_1", "B_2", "B_3"],
  "selection": {"kind": "box", "ranges": {"x": [10, 30]}},
  "reduction": {"axis": "z", "op": "mean", "weight": null},
  "units": "code"
}
Key Type Meaning
type "subscribe" Required tag. Other frame types reserved for follow-ups.
request_id string Echoed back in the ack / error so the client can match responses to requests.
step int Timestep to read. Must be in GET /sims/{sim}/steps.
fields list of str (optional) Canonical or alias field names. Empty / omitted → every available field is encoded.
selection object (optional) One of the selection specs below. Applied before reduction.
reduction object (optional) Reduction spec; see below.
units "code" or "si" "code" (default) keeps stored values; "si" calls FieldDataset.in_si per field.

Selection specs (tagged union on kind)

{"kind": "box", "ranges": {"x": [10, 30], "y": [10, 30]}}
{"kind": "plane", "normal": "z", "index": 5}
{"kind": "sphere", "center": [0, 0, 0], "radius": 5.0, "keep": "inside"}

Maps to BoxSelection, PlaneSelection, and SphereSelection respectively. plane.index = null (or omitted) selects the midplane; sphere.keep defaults to "inside".

Reduction spec

{
  "axis": "z",
  "op": "integrate",
  "weight": "rho_c",
  "nan_policy": "omit"
}
Key Type Default Meaning
axis str or list of str required Single axis name or list (e.g. ["y", "z"]).
op str "integrate" One of integrate, sum, mean, median, max, min, std, var, argmax, argmin (the Reduction Literal).
weight str or null null Weight field for mean / integrate only — see Reductions.
nan_policy "omit", "propagate", "raise" "omit" NaN handling.

Response frames

For each request, the server emits two frames:

  1. JSON text frameAck announcing the binary payload:
{
  "type": "ack",
  "request_id": "uuid-or-any-client-string",
  "shape": [40, 20],
  "dims": ["x", "y"],
  "fields": ["B_1"],
  "units": "code"
}
  1. Binary frame — a complete Arrow IPC stream (start + one RecordBatch + EOS marker) containing the field arrays. Each field is a 1-D column (row-major flattened to prod(shape) entries); reshape using shape from the ack. Schema metadata under the "pypic" key carries grid, normalization, species, coordinate arrays, and per-field attrs (quantity type, SI unit, reduction provenance, ...).

Error frame (JSON text)

{
  "type": "error",
  "request_id": "uuid-or-any-client-string",
  "kind": "unknown_field",
  "message": "..."
}

Server emits one error frame on failure and stays connected so the client can retry. kind is one of:

kind Cause
validation The request frame failed Pydantic validation, or pypic raised ValueError (bad axis, malformed selection, ...).
unknown_field A requested field name didn't resolve.
unknown_step The requested step isn't available in that simulation.
unknown_sim The path's {sim} doesn't exist or its directory lacks simulation.toml.
geometry_unsupported Spatial-axis reduction on a non-Cartesian grid (Jacobian-aware integration is not yet implemented).
undeclared_normalization units="si" (or a display unit) on a simulation whose [units] section is missing, so no SI anchor exists. Dimensionless quantities are unaffected; request units="code" for the rest.
internal Anything else. The server logs a full traceback; the client receives the exception message.

Schema metadata in the Arrow payload

Every binary frame carries a b"pypic" JSON metadata entry on the Arrow schema:

{
  "schema_version": "1.0",
  "shape": [4, 3, 2],
  "dims": ["x", "y", "z"],
  "units": "code",
  "grid": { ... },
  "normalization": { ... },
  "species": [ ... ],
  "coords": {"x": [0.5, 1.5, 2.5, 3.5], "y": [...], "z": [...]},
  "fields": {
    "B_1": {
      "quantity_type": "b_field",
      "si_unit": "T",
      "latex": "B_x",
      "long_name": "Magnetic field x-component",
      "unit_dimension": [1, 1, -2, -1, 0, 0, 0],
      "reduction": {"axis": "z", "op": "integrate", "length_axes": 1}
    }
  }
}

schema_version matches the [schema].version tag from simulation.toml and the on-disk Zarr layout. A client that can read v1.0 data on disk reads v1.0 data over the wire by the same code path.

JavaScript client (sketch)

import { tableFromIPC } from 'apache-arrow';

const ws = new WebSocket('ws://localhost:8000/sims/run0/stream');
ws.binaryType = 'arraybuffer';

ws.onmessage = (event) => {
  if (typeof event.data === 'string') {
    const frame = JSON.parse(event.data);
    if (frame.type === 'ack') {
      pendingShape = frame.shape;
    } else if (frame.type === 'error') {
      console.error(frame.kind, frame.message);
    }
  } else {
    const table = tableFromIPC(new Uint8Array(event.data));
    const meta = JSON.parse(table.schema.metadata.get('pypic'));
    const flat = table.getChild('B_1').toArray();  // Float32Array | Float64Array
    // Reshape using meta.shape, upload to Three.js BufferAttribute, ...
  }
};

ws.onopen = () => {
  ws.send(JSON.stringify({
    type: 'subscribe',
    request_id: crypto.randomUUID(),
    step: 0,
    fields: ['B_1'],
    reduction: {axis: 'z', op: 'mean'},
  }));
};

CORS

The default pypic serve runs with --cors-origin "*" so a webpic dev build on a different port can hit the server immediately. Production deployments must tighten this:

pypic serve /data/runs \
    --cors-origin https://webpic.example.org \
    --cors-origin https://staging.example.org

The wide-open default exists because the server is aimed at local development; restricting origins in production is the responsibility of whoever mounts the app behind a real ingress.

Not implemented

Deliberately out of scope for the current server:

  • Authentication — token validation in a middleware. Out of scope.
  • Particle WebSocket — analogous endpoint for ParticleData (the Parquet interchange already exists; the WS bridge would mirror field_dataset_to_arrow_ipc for particles).
  • Progressive / chunked transfer — multiple RecordBatch frames per response for very large datasets. The current single-batch encoding scales to a few hundred MB of double precision; beyond that, switch to chunking.
  • Server-side frame transforms — accepting a frame field in the request and applying FieldDataset.transform_to before encoding. Easy to add once time-dependent frame transforms land.
  • Selection-provenance round-trip — carrying the SelectionSpec shape defined here into stored Zarr attrs.selections, so reduced datasets can replay their region definition.
  • Caching layer — currently every request reads from disk. A per-step-per-field cache (with eviction) drops in cleanly because the encoding is a pure function of the resulting FieldDataset.

API reference

app

FastAPI app factory for the pypic server.

create_app builds a ready-to-mount FastAPI instance with the discovery HTTP routes and the streaming WebSocket endpoint wired up against a single simulation root.

The factory pattern (rather than a module-level app singleton) lets tests construct independent apps against per-test fixtures, and lets production deployments host multiple roots in one process if desired.

CORS is permissive (allow_origins=["*"]) by default to match the local-dev story where webpic runs on a different port. Production deployments must tighten this via cors_origins= — wide-open defaults must not survive the dev → prod transition.

create_app(root, *, cors_origins=('*',))

Build a FastAPI app serving simulations under root.

Parameters:

Name Type Description Default
root Path

Directory whose subdirectories contain simulation.toml files (one per simulation). Discovery walks this tree on every GET /sims request; readers open lazily on first access to a named simulation.

required
cors_origins sequence of str

Origins the CORS middleware accepts. Default ("*",) is permissive — suitable for local dev with webpic running on a different port. Tighten to an explicit allowlist for production.

('*',)

Returns:

Type Description
FastAPI

The configured application. Mount it under uvicorn / gunicorn / hypercorn; see serve for the simple single-process launch path.

Source code in src/pypic/server/app.py
 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
def create_app(
    root: Path,
    *,
    cors_origins: Sequence[str] = ("*",),
) -> FastAPI:
    """Build a FastAPI app serving simulations under *root*.

    Parameters
    ----------
    root : Path
        Directory whose subdirectories contain ``simulation.toml``
        files (one per simulation).  Discovery walks this tree on
        every ``GET /sims`` request; readers open lazily on first
        access to a named simulation.
    cors_origins : sequence of str
        Origins the CORS middleware accepts.  Default ``("*",)`` is
        permissive — suitable for local dev with webpic running on
        a different port.  Tighten to an explicit allowlist for
        production.

    Returns
    -------
    FastAPI
        The configured application.  Mount it under uvicorn /
        gunicorn / hypercorn; see `serve` for the simple
        single-process launch path.
    """
    try:
        from fastapi import APIRouter, FastAPI, Request
        from fastapi.middleware.cors import CORSMiddleware
        from fastapi.responses import JSONResponse
    except ImportError as exc:
        msg = (
            "pypic.server requires FastAPI. "
            'Install with: pip install "pypic-plasma[server]"'
        )
        raise ImportError(msg) from exc

    from pypic.server._state import SimulationRegistry
    from pypic.server.exceptions import PypicError, error_routing
    from pypic.server.routes import register_routes
    from pypic.server.stream import register_stream

    app = FastAPI(
        title="pypic",
        description=(
            "Read, analyze, and stream plasma simulation output. "
            "Discovery via JSON HTTP routes; binary field data via the "
            "Arrow IPC WebSocket at /sims/{sim}/stream."
        ),
        version=_pypic_version(),
    )
    app.add_middleware(
        CORSMiddleware,
        allow_origins=list(cors_origins),
        allow_credentials=False,
        allow_methods=["GET"],
        allow_headers=["*"],
    )

    @app.exception_handler(PypicError)
    async def _pypic_error_handler(
        request: Request,
        exc: PypicError,
    ) -> JSONResponse:
        """Route every typed pypic error to its HTTP status.

        Body shape is ``{"kind": <wire kind>, "detail": <message>}`` —
        ``detail`` carries the message a bare
        ``HTTPException(detail=str(exc))`` would, and ``kind`` is the
        same `ErrorKind` the WebSocket stream sends, so clients dispatch
        identically across both transports.
        """
        kind, status_code = error_routing(exc)
        return JSONResponse(
            status_code=status_code,
            content={"kind": kind, "detail": exc.detail},
        )

    app.state.registry = SimulationRegistry(root)

    router = APIRouter()
    register_routes(router)
    register_stream(router)
    app.include_router(router)

    return app

serve(root, *, host='127.0.0.1', port=8000, reload=False, cors_origins=('*',))

Launch a uvicorn server hosting create_app.

Convenience wrapper for pypic serve; production deployments typically construct the app via create_app and invoke their own ASGI server.

Raises:

Type Description
ImportError

uvicorn is not installed (pip install "pypic-plasma[server]").

Source code in src/pypic/server/app.py
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
def serve(
    root: Path,
    *,
    host: str = "127.0.0.1",
    port: int = 8000,
    reload: bool = False,
    cors_origins: Sequence[str] = ("*",),
) -> None:
    """Launch a uvicorn server hosting `create_app`.

    Convenience wrapper for ``pypic serve``; production deployments
    typically construct the app via `create_app` and invoke
    their own ASGI server.

    Raises
    ------
    ImportError
        uvicorn is not installed (``pip install "pypic-plasma[server]"``).
    """
    try:
        import uvicorn
    except ImportError as exc:
        msg = (
            "pypic serve requires uvicorn. "
            'Install with: pip install "pypic-plasma[server]"'
        )
        raise ImportError(msg) from exc

    app = create_app(root, cors_origins=cors_origins)
    uvicorn.run(app, host=host, port=port, reload=reload)

arrow

Arrow IPC encoding of a FieldDataset.

Produces a self-contained Arrow IPC byte stream that webpic (or any Arrow-aware consumer in JS / Rust / Python) can decode with one call:

  • Python: pa.ipc.open_stream(bytes).read_all()
  • JavaScript: tableFromIPC(bytes) (apache-arrow npm)
  • Rust: arrow_ipc::reader::StreamReader::try_new(&bytes[..], None)

Each call returns a single RecordBatch with one column per field (flattened to a 1-D buffer) plus one coordinate column per surviving axis. Original N-D shape, axis names, normalization, and per-field attrs travel in the schema metadata under the b"pypic" key as JSON — mirroring the convention used by pypic.io._arrow for particles.

field_dataset_to_arrow_ipc(fds, *, fields=None, units='code')

Encode a FieldDataset as an Arrow IPC stream.

Parameters:

Name Type Description Default
fds FieldDataset

Source dataset. May be 1-D, 2-D, or 3-D after slicing / reduction — the encoder reads the surviving shape from fds.grid.dimensions and fds.xr.dims.

required
fields iterable of str

Subset of canonical field names to encode. None encodes every data variable on the dataset. Unknown names raise KeyError.

None
units ('code', 'si')

"code" (default) preserves the dataset's stored numeric values (code units, the round-trippable form). "si" walks each requested field, calls FieldDataset.in_si to produce SI values, and stamps units="si" on the schema metadata so the consumer knows not to multiply by the normalization references again.

"code"

Returns:

Type Description
bytes

Complete Arrow IPC stream (schema + RecordBatch + EOS marker). Decodable in one shot by any Arrow consumer.

Raises:

Type Description
ImportError

pyarrow is not installed (install with pip install "pypic-plasma[server]").

UnknownFieldError

Any name in fields is absent from the dataset. Subclasses KeyError.

ValueError

units is not one of "code" or "si".

Source code in src/pypic/server/arrow.py
 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
def field_dataset_to_arrow_ipc(
    fds: FieldDataset,
    *,
    fields: Iterable[str] | None = None,
    units: str = "code",
) -> bytes:
    r"""Encode a FieldDataset as an Arrow IPC stream.

    Parameters
    ----------
    fds : FieldDataset
        Source dataset.  May be 1-D, 2-D, or 3-D after slicing /
        reduction — the encoder reads the surviving shape from
        ``fds.grid.dimensions`` and ``fds.xr.dims``.
    fields : iterable of str, optional
        Subset of canonical field names to encode.  ``None`` encodes
        every data variable on the dataset.  Unknown names raise
        `KeyError`.
    units : {"code", "si"}
        ``"code"`` (default) preserves the dataset's stored numeric
        values (code units, the round-trippable form).  ``"si"`` walks
        each requested field, calls `FieldDataset.in_si` to
        produce SI values, and stamps ``units="si"`` on the schema
        metadata so the consumer knows not to multiply by the
        normalization references again.

    Returns
    -------
    bytes
        Complete Arrow IPC stream (schema + RecordBatch + EOS marker).
        Decodable in one shot by any Arrow consumer.

    Raises
    ------
    ImportError
        ``pyarrow`` is not installed (install with
        ``pip install "pypic-plasma[server]"``).
    UnknownFieldError
        Any name in *fields* is absent from the dataset.
        Subclasses ``KeyError``.
    ValueError
        ``units`` is not one of ``"code"`` or ``"si"``.
    """
    if units not in ("code", "si"):
        msg = f"units must be 'code' or 'si', got {units!r}"
        raise ValueError(msg)

    ensure_arrow()
    import pyarrow as pa

    selected = _resolve_fields(fds, fields)
    dims = tuple(str(d) for d in fds.xr.dims)

    columns: dict[str, pa.Array] = {}
    field_attrs: dict[str, dict[str, Any]] = {}

    for name in selected:
        da = fds.xr[name]
        values = fds.in_si(name) if units == "si" else da.values
        # Flatten to 1-D so the RecordBatch sees a single buffer per
        # field.  Original N-D shape is recovered from schema metadata.
        columns[name] = pa.array(values.ravel(order="C"))
        field_attrs[name] = _serialize_field_attrs(dict(da.attrs))

    # One column per surviving axis.  Their length differs from the field
    # columns (flattened products of dims), so rather than emit a second
    # batch they travel in the schema metadata and the call stays single-shot.
    # Coordinates must be finite: the payload is ``json.dumps``-ed, and
    # NaN/inf would emit non-strict JSON or mislead JS/Rust clients.
    coord_arrays: dict[str, list[float]] = {}
    for dim in dims:
        if dim in fds.xr.coords:
            values = fds.xr.coords[dim].values
            if not np.isfinite(values).all():
                msg = (
                    f"Coordinate {dim!r} contains non-finite values; "
                    "the Arrow IPC wire format requires finite coordinates."
                )
                raise ValueError(msg)
            coord_arrays[dim] = [float(v) for v in values]

    schema_meta = {
        _PYPIC_META_KEY: json.dumps(
            _build_schema_metadata(
                fds=fds,
                dims=dims,
                field_attrs=field_attrs,
                coord_arrays=coord_arrays,
                units=units,
            )
        ).encode("utf-8")
    }

    arrays = list(columns.values())
    names = list(columns.keys())
    schema = pa.schema(
        [pa.field(n, a.type) for n, a in zip(names, arrays, strict=True)],
        metadata=schema_meta,
    )
    batch = pa.RecordBatch.from_arrays(arrays, schema=schema)

    sink = pa.BufferOutputStream()
    with pa.ipc.new_stream(sink, schema) as writer:
        writer.write_batch(batch)
    return bytes(sink.getvalue().to_pybytes())

decode_field_dataset_ipc(ipc_bytes)

Decode an IPC stream produced by field_dataset_to_arrow_ipc.

Convenience helper for tests and Python consumers. Returns a dict with reconstructed N-D field arrays, the schema metadata, and the coordinate arrays. Production consumers (JS / Rust) parse the IPC bytes directly via their own Arrow stack.

Parameters:

Name Type Description Default
ipc_bytes bytes

Output of field_dataset_to_arrow_ipc.

required

Returns:

Type Description
dict

Keys: fields (mapping name → N-D NumPy array reshaped to the original shape), coords (mapping dim → 1-D array), metadata (the parsed JSON from schema metadata).

Source code in src/pypic/server/arrow.py
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
def decode_field_dataset_ipc(ipc_bytes: bytes) -> dict[str, Any]:
    """Decode an IPC stream produced by `field_dataset_to_arrow_ipc`.

    Convenience helper for tests and Python consumers.  Returns a dict
    with reconstructed N-D field arrays, the schema metadata, and the
    coordinate arrays.  Production consumers (JS / Rust) parse the
    IPC bytes directly via their own Arrow stack.

    Parameters
    ----------
    ipc_bytes : bytes
        Output of `field_dataset_to_arrow_ipc`.

    Returns
    -------
    dict
        Keys: ``fields`` (mapping name → N-D NumPy array reshaped to
        the original ``shape``), ``coords`` (mapping dim → 1-D array),
        ``metadata`` (the parsed JSON from schema metadata).
    """
    ensure_arrow()
    import pyarrow as pa

    reader = pa.ipc.open_stream(ipc_bytes)
    table = reader.read_all()

    raw_meta = (table.schema.metadata or {}).get(_PYPIC_META_KEY)
    if raw_meta is None:
        msg = "Arrow IPC stream missing pypic schema metadata"
        raise ValueError(msg)
    meta = json.loads(raw_meta)

    shape = tuple(meta["shape"])
    fields_out: dict[str, np.ndarray] = {}
    for name in table.column_names:
        flat = table.column(name).to_numpy(zero_copy_only=False)
        fields_out[name] = flat.reshape(shape)

    coords_out: dict[str, np.ndarray] = {
        dim: np.asarray(arr, dtype=np.float64)
        for dim, arr in meta.get("coords", {}).items()
    }

    return {"fields": fields_out, "coords": coords_out, "metadata": meta}

exceptions

Server-boundary exception hierarchy and error routing.

Re-exports the library-raised typed exceptions from pypic.exceptions (base PypicError plus its four subclasses) so HTTP/WebSocket handlers have a single import line, defines ValidationFailedError — the server wrapper for pydantic.ValidationError from request-frame parsing and simulation.toml validation — and owns error_routing, the one table mapping exception type to wire kind and HTTP status. The library raises the types; only this boundary knows what they mean over HTTP.

The library does not raise ValidationFailedError directly; the server constructs it at the boundary where pydantic errors are caught, so pypic.server.app.create_app's single @exception_handler(PypicError) can route every server-visible error type through the same path.

GeometryUnsupportedError

Bases: PypicError, NotImplementedError

An operation is not implemented for the dataset's coordinate geometry.

Examples: regrid on spherical geometry, derivative-based derived quantities on non-Cartesian grids, spatial-axis reductions on non-Cartesian grids.

Source code in src/pypic/exceptions.py
 97
 98
 99
100
101
102
103
class GeometryUnsupportedError(PypicError, NotImplementedError):
    """An operation is not implemented for the dataset's coordinate geometry.

    Examples: `regrid` on spherical geometry, derivative-based
    derived quantities on non-Cartesian grids, spatial-axis reductions
    on non-Cartesian grids.
    """

PypicError

Bases: Exception

Base for every typed pypic exception.

Catching this catches every error pypic raises deliberately.

Source code in src/pypic/exceptions.py
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
class PypicError(Exception):
    """Base for every typed pypic exception.

    Catching this catches every error pypic raises deliberately.
    """

    @property
    def detail(self) -> str:
        r"""Human-readable message, free of ``KeyError``-style requoting.

        The `KeyError`-inheriting subclasses (Unknown\*Error)
        otherwise ``str()`` to ``"'msg'"`` because ``KeyError.__str__``
        calls ``repr()`` on ``args[0]``.  Wire consumers (the HTTP body's
        ``detail`` field, `ErrorFrame.message`) want the bare message,
        which ``args[0]`` gives directly — stripping quotes off
        ``str(exc)`` would mangle messages that legitimately carry them.
        """
        if self.args:
            return str(self.args[0])
        return super().__str__()

detail property

Human-readable message, free of KeyError-style requoting.

The KeyError-inheriting subclasses (Unknown*Error) otherwise str() to "'msg'" because KeyError.__str__ calls repr() on args[0]. Wire consumers (the HTTP body's detail field, ErrorFrame.message) want the bare message, which args[0] gives directly — stripping quotes off str(exc) would mangle messages that legitimately carry them.

UndeclaredNormalizationError

Bases: PypicError, ValueError

SI conversion was asked for, but no unit system was ever declared.

A reader that finds no simulation.toml cannot invent the one absolute anchor SI conversion needs — a PIC deck fixes only dimensionless ratios, so the reference density is a modelling choice, not data in the file. Rather than return code units labelled tesla, Normalization.si_factor raises this for any dimensional quantity. Dimensionless quantities (beta, M_A, agyrotropy) are exempt: they are correct under any anchor.

Subclass of ValueError, matching the unknown-quantity raise from the same function.

Source code in src/pypic/exceptions.py
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
class UndeclaredNormalizationError(PypicError, ValueError):
    """SI conversion was asked for, but no unit system was ever declared.

    A reader that finds no ``simulation.toml`` cannot invent the one
    absolute anchor SI conversion needs — a PIC deck fixes only
    dimensionless ratios, so the reference density is a modelling
    choice, not data in the file.  Rather than return code units
    labelled tesla, `Normalization.si_factor` raises this for any
    dimensional quantity.  Dimensionless quantities (``beta``,
    ``M_A``, ``agyrotropy``) are exempt: they are correct under any
    anchor.

    Subclass of `ValueError`, matching the unknown-quantity raise
    from the same function.
    """

UnknownFieldError

Bases: PypicError, KeyError

A requested field name does not resolve in the dataset.

Raised by FieldDataset.resolve_key and by Simulation.read when strict_fields=True (the default) and one of the requested names matched no loaded field.

Source code in src/pypic/exceptions.py
67
68
69
70
71
72
73
class UnknownFieldError(PypicError, KeyError):
    """A requested field name does not resolve in the dataset.

    Raised by `FieldDataset.resolve_key` and by
    `Simulation.read` when ``strict_fields=True`` (the default)
    and one of the requested names matched no loaded field.
    """

UnknownSimulationError

Bases: PypicError, KeyError

No simulation with the requested name exists under the registry root.

Subclass of KeyError so callers that catch the broader type still work, while letting the server route on type rather than inspect message strings.

Source code in src/pypic/exceptions.py
58
59
60
61
62
63
64
class UnknownSimulationError(PypicError, KeyError):
    """No simulation with the requested name exists under the registry root.

    Subclass of `KeyError` so callers that catch the broader type
    still work, while letting the server route on type rather than
    inspect message strings.
    """

UnknownStepError

Bases: PypicError, KeyError

A requested timestep is not available for the simulation.

Source code in src/pypic/exceptions.py
76
77
class UnknownStepError(PypicError, KeyError):
    """A requested timestep is not available for the simulation."""

ValidationFailedError

Bases: PypicError, ValueError

Pydantic validation failed at the server boundary.

Wraps pydantic.ValidationError from SubscribeRequest.model_validate_json (wire frame) and from validate_simulation_toml (simulation.toml parse during open_simulation). The original ValidationError is preserved on __cause__ (via raise … from exc) so callers that need the structured error tree can still reach it.

Source code in src/pypic/server/exceptions.py
50
51
52
53
54
55
56
57
58
59
class ValidationFailedError(PypicError, ValueError):
    """Pydantic validation failed at the server boundary.

    Wraps `pydantic.ValidationError` from
    `SubscribeRequest.model_validate_json` (wire frame) and from
    `validate_simulation_toml` (``simulation.toml`` parse during
    `open_simulation`).  The original ``ValidationError`` is
    preserved on ``__cause__`` (via ``raise … from exc``) so callers
    that need the structured error tree can still reach it.
    """

error_routing(exc)

Return the (wire kind, HTTP status) for a raised pypic error.

Walks the MRO rather than looking the exact type up: ErrorKind (in pypic.server.protocol) is a closed vocabulary, so a subclass nobody mapped must degrade to its nearest mapped base instead of putting an unknown string on the wire. PypicError itself maps to internal / 500, which is why the walk always terminates.

Source code in src/pypic/server/exceptions.py
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
def error_routing(exc: PypicError) -> tuple[ErrorKind, int]:
    """Return the ``(wire kind, HTTP status)`` for a raised pypic error.

    Walks the MRO rather than looking the exact type up: ``ErrorKind``
    (in [`pypic.server.protocol`][pypic.server.protocol]) is a closed
    vocabulary, so a subclass nobody mapped must degrade to its nearest
    mapped base instead of putting an unknown string on the wire.
    ``PypicError`` itself maps to ``internal`` / 500, which is why the
    walk always terminates.
    """
    for base in type(exc).__mro__:
        route = _ROUTING.get(base)
        if route is not None:
            return route
    return _ROUTING[PypicError]  # unreachable; mypy wants the exit

protocol

Wire-format models for the pypic server.

Pydantic v2 models that describe every WebSocket request/response shape — and only those. HTTP responses are plain JSON dumps of existing typed values (SimulationConfig, schema bodies), so they do not need wrappers here.

The selection / reduction wire shapes are tagged-union dicts that map onto the established BoxSelection / PlaneSelection / SphereSelection dataclasses + pypic.reduce kwargs. Keeping the JSON ↔ object conversion in this module preserves the architecture rule that selections are pure region descriptions — they get constructed from validated specs outside the dataclass definitions.

Recording selection provenance in a stored Zarr (attrs.selections) will reuse SelectionSpec directly; designing the wire shape here lets that storage side land later as a pure write addition.

SelectionSpec = Annotated[BoxSpec | PlaneSpec | SphereSpec, Field(discriminator='kind')] module-attribute

SubscribeRequest

Bases: _StrictModel

Inbound WebSocket frame requesting one FieldDataset slice.

Source code in src/pypic/server/protocol.py
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
class SubscribeRequest(_StrictModel):
    """Inbound WebSocket frame requesting one FieldDataset slice."""

    type: Literal["subscribe"] = "subscribe"
    request_id: str = Field(
        description="Caller-supplied UUID echoed in every server response."
    )
    sim: str | None = Field(
        default=None,
        description="Simulation name. Optional when the WS path already "
        "carries /sims/{sim}/stream; required for multiplexed connections.",
    )
    step: int
    fields: list[str] = Field(
        default_factory=list,
        description="Canonical or alias field names. Empty → server "
        "returns every field available at the step.",
    )
    selection: SelectionSpec | None = None
    reduction: ReductionSpec | None = None
    units: Literal["code", "si"] = "code"

Ack

Bases: _StrictModel

Outbound JSON text frame announcing the binary payload to follow.

Source code in src/pypic/server/protocol.py
191
192
193
194
195
196
197
198
199
class Ack(_StrictModel):
    """Outbound JSON text frame announcing the binary payload to follow."""

    type: Literal["ack"] = "ack"
    request_id: str
    shape: list[int]
    dims: list[str]
    fields: list[str]
    units: Literal["code", "si"]

ErrorFrame

Bases: _StrictModel

Outbound JSON text frame on request failure.

kind is a coarse category for client-side dispatch; message is the human-readable detail straight from the exception.

Source code in src/pypic/server/protocol.py
202
203
204
205
206
207
208
209
210
211
212
class ErrorFrame(_StrictModel):
    """Outbound JSON text frame on request failure.

    ``kind`` is a coarse category for client-side dispatch; ``message``
    is the human-readable detail straight from the exception.
    """

    type: Literal["error"] = "error"
    request_id: str
    kind: ErrorKind
    message: str

BoxSpec

Bases: _StrictModel

Wire form of BoxSelection.

Source code in src/pypic/server/protocol.py
75
76
77
78
79
80
81
82
83
class BoxSpec(_StrictModel):
    """Wire form of [`BoxSelection`][pypic.selections.BoxSelection]."""

    kind: Literal["box"] = "box"
    ranges: dict[str, tuple[int, int]] = Field(
        default_factory=dict,
        description="Axis name → (start, stop) integer index range. "
        "Empty dict is a no-op.",
    )

PlaneSpec

Bases: _StrictModel

Wire form of PlaneSelection.

Source code in src/pypic/server/protocol.py
86
87
88
89
90
91
92
93
94
class PlaneSpec(_StrictModel):
    """Wire form of [`PlaneSelection`][pypic.selections.PlaneSelection]."""

    kind: Literal["plane"] = "plane"
    normal: str
    index: int | None = Field(
        default=None,
        description="None → midplane.",
    )

SphereSpec

Bases: _StrictModel

Wire form of SphereSelection.

Source code in src/pypic/server/protocol.py
 97
 98
 99
100
101
102
103
class SphereSpec(_StrictModel):
    """Wire form of [`SphereSelection`][pypic.selections.SphereSelection]."""

    kind: Literal["sphere"] = "sphere"
    center: tuple[float, float, float]
    radius: float = Field(gt=0.0)
    keep: Literal["inside", "outside"] = "inside"

ReductionSpec

Bases: _StrictModel

Wire form of a pypic.reduce call.

Source code in src/pypic/server/protocol.py
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
class ReductionSpec(_StrictModel):
    """Wire form of a [`pypic.reduce`][pypic.reduce] call."""

    axis: str | list[str] = Field(
        description="Single axis name or list of axes (e.g. ['y', 'z']).",
    )
    op: Reduction = Field(
        default="integrate",
        description="Reduction operation. See pypic.Reduction.",
    )
    weight: str | None = Field(
        default=None,
        description="Optional weight field (only mean / integrate accept).",
    )
    nan_policy: Literal["omit", "propagate", "raise"] = "omit"

to_selection(spec)

Construct the corresponding selection dataclass from a validated spec.

Source code in src/pypic/server/protocol.py
112
113
114
115
116
117
118
119
120
121
122
123
124
def to_selection(
    spec: SelectionSpec,
) -> BoxSelection | PlaneSelection | SphereSelection:
    """Construct the corresponding selection dataclass from a validated spec."""
    match spec:
        case BoxSpec(ranges=r):
            return BoxSelection(ranges=dict(r))
        case PlaneSpec(normal=n, index=i):
            return PlaneSelection(normal=n, index=i)
        case SphereSpec(center=c, radius=r, keep=k):
            return SphereSelection(center=c, radius=r, keep=k)
        case _ as unreachable:
            assert_never(unreachable)

to_reduction_kwargs(spec)

Translate a ReductionSpec to kwargs for pypic.reduce.

The axis field accepts either a single string or a list; the list form gets passed through as a tuple (which pypic.reduce expects for multi-axis input).

Source code in src/pypic/server/protocol.py
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
def to_reduction_kwargs(spec: ReductionSpec) -> dict[str, Any]:
    """Translate a ``ReductionSpec`` to kwargs for [`pypic.reduce`][pypic.reduce].

    The ``axis`` field accepts either a single string or a list; the
    list form gets passed through as a tuple (which pypic.reduce
    expects for multi-axis input).
    """
    axis: str | tuple[str, ...] = (
        tuple(spec.axis) if isinstance(spec.axis, list) else spec.axis
    )
    return {
        "axis": axis,
        "reduction": spec.op,
        "weight": spec.weight,
        "nan_policy": spec.nan_policy,
    }