Fast, streaming reader for MCNP RSSA (surface source) files, with a Rust core and a Polars-based Python API.
RSSA files record every particle that crosses a given surface during an MCNP (or
D1S-UNED) simulation, and can easily reach tens or hundreds of gigabytes. aci_rssa
memory-maps the file and decodes track records in parallel (via rayon),
handing the result to Python as a Polars DataFrame — either all at once, or as a lazy,
batch-at-a-time stream for files too large to fit in memory.
Installation
pip install aci_rssa
Prebuilt wheels are published for Linux, macOS, and Windows (x86_64/arm64) on CPython ≥3.10 — no Rust toolchain needed to install.
Quickstart
from aci_rssa import RSSA
rssa = RSSA.read_from_file("surface_source.w")
print(rssa) # summary: surfaces, track/history counts, source code
rssa.tracks # polars.DataFrame: history, particle_type, weight, energy,
# time, x, y, z, u, v, w, surface_id
rssa.neutron_tracks # tracks filtered to neutrons
rssa.photon_tracks # tracks filtered to everything else (typically photons)
Reading only the header (fast, never touches the potentially huge track data):
from aci_rssa import FileParameters
parameters = FileParameters.read_from_file("surface_source.w")
parameters.nrss # number of tracks recorded
parameters.surfaces # surfaces tracks were recorded on
parameters.surface_ids # just their ids
surface = parameters.get_surface(63)
surface.mnemonic # "cz"
surface.axis, surface.center, surface.radius # "z", (0.0, 0.0), 164.3
print(surface.describe()) # surface 63: cz (cylinder about the z axis, radius 164.30 cm)
Surface coefficients are exposed as MCNP stores them (surface.parameters, e.g. a
cylinder's squared radius); the properties above do the conversion. Planes give
normal and offset instead.
Very large files
For files too large to load eagerly, scan_tracks returns a Polars LazyFrame backed
by a Rust reader that decodes the file batch by batch, driven by Polars' query engine —
filters and column selection are pushed down so you only decode what you need:
from aci_rssa import scan_tracks
import polars as pl
(
scan_tracks("surface_source.w")
.filter(pl.col("energy") > 1.0)
.select("x", "y", "z", "energy")
.collect(engine="streaming")
)
RSSAPlot and RSSASpectraPlot accept a LazyFrame directly, so a scan_tracks()
result can be plotted without ever loading the file eagerly — build them with
RSSAPlot.for_surface() instead of going through RSSA.read_from_file(), which
requires an in-memory RSSA:
from aci_rssa import FileParameters, RSSAPlot, neutrons, scan_tracks
parameters = FileParameters.read_from_file("surface_source.w") # header only
plot = RSSAPlot.for_surface(scan_tracks("surface_source.w"), parameters, 63)
(
plot.filter(neutrons())
.with_bin_width(10)
.particle_flux(1e17)
.show()
)
Every aggregation (with_bin_width, particle_flux, …) streams through the file
batch by batch rather than materializing it.
Plotting
rssa.plot_surface(63).with_bin_width(10).particle_flux(1e17).show()
rssa.plot_spectra().spectrum().show()
There are three steps, and they are separate on purpose: a projection decides how
the surface is unrolled into 2D, a plot bins the tracks on it, and a result
(CurrentMap, VectorCurrent, Spectrum) is a computed value that knows how to
render itself.
Projections
plot_surface() takes the geometry from the file header: it keeps only the tracks that
crossed that surface, and picks the projection from the surface's own type — a cylinder
is unrolled around its own axis and centre over its full circumference, a px/py/pz
plane is plotted on the two axes spanning it, and a tilted p plane gets an orthonormal
basis of the plane itself. The surface id may be omitted only when the file recorded a
single surface.
This is the only entry point to a current map, because the projection is what makes the
map's numbers right: every quantity divides each bin's summed weight by dx * dy,
so a projection that doesn't preserve distances on the surface — dropping a tilted
plane onto two global axes shrinks one axis by the cosine of the tilt; measuring a
cylinder's angle about the coordinate origin instead of its own axis smears it — gives
a plausible plot that is wrong by that factor.
The escape hatch is therefore to override the geometry, not to skip it:
from aci_rssa import Cylinder
rssa.plot_surface(63, projection=Cylinder(axis="z", radius=164.3, center=(0.0, 0.0)))
Cylinder, AxisAlignedPlane and TiltedPlane are the built-in projections; a surface
type none of them fits (a sphere, a torus) raises rather than being rastered onto two
global axes. Unless verify=False, a sample of the tracks is checked against the
surface's equation, so a header that means something other than what is assumed here
fails loudly instead of producing a plausible, wrong plot.
Selecting tracks
The tracks are a Polars LazyFrame, so selection is done with ordinary Polars
expressions via filter(). aci_rssa only supplies the two predicates that need
knowledge of the file rather than of Polars — the packed neutron code, and surface ids
(matched irrespective of sign, and refused on files that don't record them per track):
import polars as pl
from aci_rssa import neutrons, on_surface
plot.filter(neutrons(), pl.col("energy") > 1.0, pl.col("z").is_between(-600, 800))
filter() returns a new plot, so one object can feed several derived ones.
Results
plot = rssa.plot_surface(63).filter(neutrons()).with_bin_width(10)
flux = plot.particle_flux(1e17) # CurrentMap: scalar flux, #/cm2/s per bin
current = plot.particle_current(1e17) # CurrentMap: crossing rate per bin
net = plot.net_current(1e17) # CurrentMap: net leakage per bin, signed
arrows = plot.vector_current(1e17) # VectorCurrent: J and anisotropy per bin
angles = plot.angular_distribution(1e17) # AngularDistribution: ψ(Ω) itself
flux.with_parameters(vmin=1e6, vmax=1e12).show()
flux.ratio_to(other_flux).save("difference.png")
Each result carries its own values, bins and rendering options; with_parameters()
takes keyword overrides (keeping the labels and title derived from the surface) or a
whole PlotParameters to replace them. figure() hands back the Matplotlib figure and
axes for anything the parameters don't cover.
Pass errors=True to any of the maps to estimate its statistical error in the same
pass, then plot it with error_map():
flux = plot.particle_flux(1e17, errors=True)
flux.relative_errors # array, or None when it wasn't asked for
flux.error_map().show() # the errors as a map of their own
The error is carried on the map rather than computed by a separate call, so it can only ever belong to the quantity it was estimated for — the error of a flux is not the error of a current.
Which quantity do you want?
All four come from one identity: a set of surface crossings samples the angular flux
weighted by |μ| — head-on crossings are over-represented, because a patch of surface
presents its full area to them. Summing w·f(Ω) over tracks then estimates
∫f(Ω)|μ|ψ(Ω)dΩ, and the choice of f is the choice of quantity:
f(Ω) |
Quantity | Method | Use for |
|---|---|---|---|
1/|μ| |
scalar flux Φ |
particle_flux() |
dose rates, reaction rates, activation — anything multiplied by a response function |
1 |
crossing rate J⁺+J⁻ |
particle_current() |
how much weight crosses a patch, e.g. re-emitting the file as a source |
sign(μ) |
net current J·n̂ |
net_current() |
what actually leaks through, with backscatter cancelling |
Ω/|μ| |
vector current J |
vector_current() |
which way it flows, and how collimated |
If you are about to multiply a map by a response function, you want particle_flux(),
not particle_current(). They are not interchangeable: for an isotropic field the mean
of 1/|μ| over the crossing distribution is exactly 2, so a current map understates the
flux by a factor of two where the field is diffuse while agreeing with it where the field
is collimated — it distorts the shape of the map, not just its scale. This is MCNP's F1
vs F2 distinction, and aci_rssa computes both from the surface normal the projection
already carries.
VectorCurrent.table's anisotropy column is |J|/Φ, the flux-to-current ratio: 1 for
a beam, 0.5 for a half-isotropic distribution, ~0 where directions cancel. It also bounds
the gap above — since 1/|μ| is convex, Jensen's inequality gives
Φ / crossing rate ≥ 1 / anisotropy.
Tracks approaching tangency (|μ| → 0) would send 1/|μ| to infinity. Following MCNP,
particle_flux() scores a track below |μ| = 1e-3 as if it were 5e-4, and
grazing_fraction() maps how much of each bin's flux came from tracks that were capped.
That blow-up is real physics — a shallow track really does deposit more path length in a
thin volume at the surface — so it is capped, not clipped away.
Seeing the directions themselves
The four quantities above are all moments of one thing, the angular flux ψ(Ω).
angular_distribution() estimates ψ(Ω) directly, which is worth doing wherever a
moment is not enough — and one place it never is, is a mean direction: vector_current()
cannot tell an isotropic field from two opposing beams, since both average to |J| = 0.
Where anisotropy is low, this is what says which of the two you have.
angles = plot.angular_distribution(1e17)
angles.show() # two equal-area polar disks, outgoing and incoming
angles.scalar_flux # ∫ψ dΩ, the scalar flux averaged over the patch
Directions are binned on μ and on the azimuth φ in the surface's own tangent
plane, aligned with the map's axes: φ = 0 points the way the plot's x axis grows, so
a lobe leaning up-and-right in the disk means particles heading up-and-right on the
current map — including on a cylinder, where the frame turns from bin to bin. Sectors
are centred on the named directions rather than divided by them, so a beam aimed along
an axis reads as one petal instead of two halves.
The radius is √(2(1−|μ|)) — head-on at the centre, grazing at the rim. That is the
Lambert equal-area radius, chosen so each cell's area on the page is proportional to the
solid angle it stands for: plotting the polar angle as the radius instead would inflate
the near-normal directions into a wide bullseye, the same area-encoding bias that makes
a bar-length rose diagram overstate its longest petal. The μ grid is uniform for the
same reason, so every cell subtends the same solid angle and the colours are directly
comparable — as are the two disks, which share one scale.
Each track enters its cell as w/|μ|, exactly as in particle_flux(). This is not
cosmetic: crossings sample ψ weighted by |μ|, so summing raw weight per direction
would draw a dent at grazing angles that is an artefact of how a surface samples a field
rather than a feature of the field itself — and grazing angles are precisely where an
angular plot gets read.
Unlike the maps, this is a pooled quantity: every track inside the bins contributes
equally to one histogram, with no per-bin averaging. The bins therefore only choose the
region — filter or narrow them to zoom in — and the area ψ is divided by comes from
the extent the tracks span, not from the bin grid, so bin_width does not change the
result. (It would otherwise: with_bin_width() pads its last bin past the data, so a
100 cm bin over tracks spanning 60 cm claims a strip two thirds empty and dilutes every
value by 1.67. A per-bin map is unaffected — there the padding lands in one edge bin
that really does cover surface no track reached.) Pass area= to state the patch
explicitly when the region is a deliberate choice rather than wherever the tracks
landed:
plot.angular_distribution(1e17, area=200 * 200) # a defined window, empty parts and all
Migrating from 0.0.7
| Before | Now |
|---|---|
rssa.plot_cyl(axis=...), rssa.plot_plane(x=..., y=...) |
rssa.plot_surface(id), optionally with projection=Cylinder(...) / AxisAlignedPlane(...) / TiltedPlane(...) |
RSSAPlot(tracks, params).set_surface(surface) |
RSSAPlot.for_surface(tracks, params, surface_id) |
.set_particle("n"), .set_surface_ids([63]), .set_z_limits(a, b), .set_axis_limits(c, a, b), .set_perimeter_limits(a, b) |
.filter(neutrons()), .filter(on_surface(params, 63)), .filter(pl.col("z").is_between(a, b)), … (the old names still work, with a DeprecationWarning) |
.set_bins(x, y), .calculate_bins(bin_width=w) |
.with_bins(x, y), .with_bin_width(w) |
.get_particle_current(i), .get_vector_current(i) |
.particle_current(i), .vector_current(i) — each returns a result rather than mutating the plot |
.get_particle_current_errors() |
.particle_current(i, errors=True).error_map() — the error now travels with the quantity it belongs to, and any of particle_flux/particle_current/net_current can produce one |
.get_ratio_to(other) |
current.ratio_to(other_current) |
.set_plot_parameters(PlotParameters(...)) |
result.with_parameters(...) |
.get_plot(), .show(), .save_figure(p) |
result.figure(), result.show(), result.save(p) |
.get_vector_current_plot(), .show_vector_current(), .save_vector_current_figure(p) |
vector_current.figure() / .show() / .save(p) |
.get_spectra_info(), .get_combined_plot_with_other_spectras(*others) |
.spectrum(), spectrum.overlay(*others) |
rssa.x, rssa.y, rssa.z, rssa.energies, rssa.weight, rssa.histories |
rssa.tracks["x"], … (weight used to return .abs(), unlike every aggregation in the package) |
The default colormap changed from jet to plasma, and relative errors now render on a
linear scale from 0 rather than a log one.
Two changes affect numbers, not just names:
vector_current()now returns the realJ. It used to sumw·Ωwith no1/|μ|, which is the first moment of the crossing distribution rather than of the flux — correct only at normal incidence, and short by the incidence cosine everywhere else. Itsscalar_currentcolumn is nowcrossing_rate, joined byflux,net_current, and ananisotropythat is|J|/Φrather than|J|/crossing rate.particle_current()is unchanged, but is now one of four quantities rather than the only one. If you were reading it as a flux, switch toparticle_flux(); if you were reading it as net leakage, switch tonet_current().
Development
This is a Cargo workspace (rssa-core, the pure-Rust parsing/decoding library, and
rssa-python, its PyO3 bindings) plus a Python package in python/, built together
with maturin.
python -m venv .venv && source .venv/bin/activate
pip install "maturin>=1.9.4,<2.0"
maturin develop --extras test,dev # builds the extension module, installs test/dev deps
cargo test # Rust tests (rssa-core)
pytest # Python tests
ty check python/aci_rssa # type checking
License
Licensed under the European Union Public Licence v1.2 (EUPL-1.2).
Release files for aci-rssa 0.0.10
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| aci_rssa-0.0.10.tar.gz | 95.6 kB | Details |
Built distributions (wheels)
| File | Reset | |||
|---|---|---|---|---|
| aci_rssa-0.0.10-cp310-abi3-win_arm64.whl | CPython 3.10 | abi3 | Windows ARM64 | Details |
| aci_rssa-0.0.10-cp310-abi3-win_amd64.whl | CPython 3.10 | abi3 | Windows x86-64 | Details |
| aci_rssa-0.0.10-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl | CPython 3.10 | abi3 | Linux glibc 2.17+ x86-64 | Details |
| aci_rssa-0.0.10-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl | CPython 3.10 | abi3 | Linux glibc 2.17+ ARM64 | Details |
| aci_rssa-0.0.10-cp310-abi3-macosx_11_0_arm64.whl | CPython 3.10 | abi3 | macOS 11.0+ ARM64 | Details |
| aci_rssa-0.0.10-cp310-abi3-macosx_10_12_x86_64.whl | CPython 3.10 | abi3 | macOS 10.12+ x86-64 | Details |
Total release size: 28.7 MB
Release files / aci_rssa-0.0.10.tar.gz
| Download URL | aci_rssa-0.0.10.tar.gz |
|---|---|
| Size | 95.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
d39f65b7517010920e34c3773f417cd8c32f3c4b5c12feb75576b21f2e5f7d22
|
|
BLAKE2b-256 checksum How to use checksums |
c6b61692dffe639fed4dada3a3494c20fe185388ba0b737b8b9739b61cdc1f39
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 29, 2026.
Transparency logRelease files / aci_rssa-0.0.10-cp310-abi3-win_arm64.whl
| Download URL | aci_rssa-0.0.10-cp310-abi3-win_arm64.whl |
|---|---|
| Size | 4.5 MB |
| Tags | CPython 3.10 Windows ARM64 abi3 |
|
SHA-256 checksum How to use checksums |
793b915b1612a3f818ad784b6eab42195c4650047896aa764b9c246c913151ba
|
|
BLAKE2b-256 checksum How to use checksums |
abed3ceccba37fc5d24da6e324d1f8dd22c299c17bb96732761ea98f12e4194a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 29, 2026.
Transparency logRelease files / aci_rssa-0.0.10-cp310-abi3-win_amd64.whl
| Download URL | aci_rssa-0.0.10-cp310-abi3-win_amd64.whl |
|---|---|
| Size | 5.1 MB |
| Tags | CPython 3.10 Windows x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
3b83460e31886988507121a0da4d37471e202b2d0497a5bfd95996910ec3e716
|
|
BLAKE2b-256 checksum How to use checksums |
d0e90bd206297e9b717d80c05764e088e353f13fabc6e6e1ef5ddb6f6e484634
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 29, 2026.
Transparency logRelease files / aci_rssa-0.0.10-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
| Download URL | aci_rssa-0.0.10-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl |
|---|---|
| Size | 5.0 MB |
| Tags | CPython 3.10 Linux glibc 2.17+ x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
69d0dc391a73c666580a0ded119f3cf09d988d2dbee5b1c28d79ba863b574c99
|
|
BLAKE2b-256 checksum How to use checksums |
97e70604d623e65ea79c0e90dd0d04c539e4e84bc5dbb02621588722556edb4e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 29, 2026.
Transparency logRelease files / aci_rssa-0.0.10-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
| Download URL | aci_rssa-0.0.10-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl |
|---|---|
| Size | 4.6 MB |
| Tags | CPython 3.10 Linux glibc 2.17+ ARM64 abi3 |
|
SHA-256 checksum How to use checksums |
c51e677832e9d49be3e9d3a68716d14687eaa3ac2c7aeaf001cfe3e7334fd644
|
|
BLAKE2b-256 checksum How to use checksums |
8a9f5a0148fe5431a1da2990d16df96734970f8e03fbdc4974611252cb6e890d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 29, 2026.
Transparency logRelease files / aci_rssa-0.0.10-cp310-abi3-macosx_11_0_arm64.whl
| Download URL | aci_rssa-0.0.10-cp310-abi3-macosx_11_0_arm64.whl |
|---|---|
| Size | 4.5 MB |
| Tags | CPython 3.10 abi3 macOS 11.0+ ARM64 |
|
SHA-256 checksum How to use checksums |
ab607cb2db5e3897707b08c721ade1ca4ee42bba0eca19a8520fe83b8770d853
|
|
BLAKE2b-256 checksum How to use checksums |
881cb08e8521a341e0c82d3294a29abb45559d0d80a79327bb801fe240f70b40
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 29, 2026.
Transparency logRelease files / aci_rssa-0.0.10-cp310-abi3-macosx_10_12_x86_64.whl
| Download URL | aci_rssa-0.0.10-cp310-abi3-macosx_10_12_x86_64.whl |
|---|---|
| Size | 4.9 MB |
| Tags | CPython 3.10 abi3 macOS 10.12+ x86-64 |
|
SHA-256 checksum How to use checksums |
83b6369f47c46f7fa8136334c723ceae2b352432e3f4f7a550921f6566cd4017
|
|
BLAKE2b-256 checksum How to use checksums |
972e85d6fd21ac82afcac6728a7027445d48ec60b272c60a84343fd691a2df99
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 29, 2026.
Transparency log