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:
- JSON text frame —
Ackannouncing the binary payload:
{
"type": "ack",
"request_id": "uuid-or-any-client-string",
"shape": [40, 20],
"dims": ["x", "y"],
"fields": ["B_1"],
"units": "code"
}
- 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 toprod(shape)entries); reshape usingshapefrom 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 mirrorfield_dataset_to_arrow_ipcfor particles). - Progressive / chunked transfer — multiple
RecordBatchframes 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
framefield in the request and applyingFieldDataset.transform_tobefore encoding. Easy to add once time-dependent frame transforms land. - Selection-provenance round-trip — carrying the
SelectionSpecshape defined here into stored Zarrattrs.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 |
required |
cors_origins
|
sequence of str
|
Origins the CORS middleware accepts. Default |
('*',)
|
Returns:
| Type | Description |
|---|---|
FastAPI
|
The configured application. Mount it under uvicorn /
gunicorn / hypercorn; see |
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 | |
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 ( |
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 | |
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
|
required |
fields
|
iterable of str
|
Subset of canonical field names to encode. |
None
|
units
|
('code', 'si')
|
|
"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
|
|
UnknownFieldError
|
Any name in fields is absent from the dataset.
Subclasses |
ValueError
|
|
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 | |
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 |
required |
Returns:
| Type | Description |
|---|---|
dict
|
Keys: |
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
UnknownStepError
¶
Bases: PypicError, KeyError
A requested timestep is not available for the simulation.
Source code in src/pypic/exceptions.py
76 77 | |
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 | |
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 | |
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 | |
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 | |
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 | |
BoxSpec
¶
Bases: _StrictModel
Wire form of BoxSelection.
Source code in src/pypic/server/protocol.py
75 76 77 78 79 80 81 82 83 | |
PlaneSpec
¶
Bases: _StrictModel
Wire form of PlaneSelection.
Source code in src/pypic/server/protocol.py
86 87 88 89 90 91 92 93 94 | |
SphereSpec
¶
Bases: _StrictModel
Wire form of SphereSelection.
Source code in src/pypic/server/protocol.py
97 98 99 100 101 102 103 | |
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 | |
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 | |
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 | |