Translate CSG
Nucleide translates a scoped subset of MCNP constructive-solid-geometry decks
to OpenMC geometry.xml, Serpent surf/cell cards, PHITS
[Surface]/[Cell] sections, or a GDML (Geant4) document with the
nucleide-csg-xlate crate. The scope
is deliberately narrow — everything inside it translates; everything outside
it raises a ValueError instead of guessing. Each call returns the emitted
text plus a drift report: a list of every approximation the translator made
(expanded macrobodies, dropped parameters, …).
Translate a deck
parse_csg_to_* takes deck text; read_csg_to_* takes a path and returns
the identical result for the same deck:
import xml.etree.ElementTree as ET
from nucleide import mcnp
deck = """lattice demo
1x2x1 LAT=1 lattice in RPP bounds, every element filled
1 1 -1.0 -1 u=1
2 1 -1.0 -2 u=2
10 0 -100 lat=1 fill=0:0 0:1 0:0 1 2
20 0 #10
1 sph 1 1 1 0.5
2 sph 3 1 1 0.5
100 rpp 0 4 0 4 0 2
m1 92235 1.0
"""
xml, drift = mcnp.parse_csg_to_openmc(deck)
root = ET.fromstring(xml)
lat = root.find("lattice")
assert lat is not None and lat.get("type") == "rectangular"
assert lat.findtext("dimension") == "1 2 1"
by_id = {c.get("id"): c for c in root.findall("cell")}
assert by_id["10"].get("fill") == lat.get("id")
assert by_id["10"].get("material") is None # filled cells carry no material
assert any(d["action"] == "lattice-emitted" and d["target"] == "10" for d in drift)
Assert on structure, never on emitted bytes — the text is a rendering detail; the drift report is the authoritative record (see below). The same deck renders to the other codes with the scoped per-code simplifications:
text, _ = mcnp.parse_csg_to_serpent(deck)
assert "cell 10 0 fill 21 -100" in text.splitlines() # cuboidal lat 21 behind the fill
text, _ = mcnp.parse_csg_to_phits(deck)
assert "10 0 -100 LAT=1 FILL=0:0 0:1 0:0 1 2" in text.splitlines() # matrix FILL verbatim
xml, _ = mcnp.parse_csg_to_gdml(deck)
gdml = ET.fromstring(xml)
vol10 = next(v for v in gdml.find("structure") if v.get("name") == "vol10")
assert len(vol10.findall("physvol")) == 2 # one placement per lattice element
What translates
The scoped v3 subset covers 34 surface kinds (planes, on-axis cylinders,
spheres, SPH, and axis-aligned RPP/RCC macrobodies), cell regions with
: unions, # complements, and parentheses, plus nested universes — cell
U=k assignments and single-universe FILL n — and rectangular LAT=1
lattices whose FILL gives a full matrix (no holes). A lattice’s pitch and
lower-left come only from its cell’s RPP or axis-plane box bounds.
Macrobodies expand (RPP to six planes, RCC to a cylinder plus caps), #n
complements inline as the negated region, and filled cells emit without a
material. Materials stay a stub: cell cards carry material="1" /
m<n> / 1 / mat_<n> names and you supply the actual definitions
yourself. The GDML direction renders cells as named boolean solids inside a
world volume, universes as assemblies, and lattices as one placement per
element; because Geant4 has no infinite solids, infinite half-spaces are
bounded by a per-deck cutoff recorded in the drift report. GDML lengths are
millimetres (the schema default): every MCNP centimetre value — positions
and solid dimensions — is converted ×10 at emission.
The drift report
Every accepted-but-not-lossless step lands in the drift report as a dict with
string keys scope ("cell", "surface", or "deck"), target (the
cell/surface number, "0" for deck scope), action, and a human-readable
reason:
for entry in drift:
print(entry["scope"], entry["target"], entry["action"], entry["reason"])
Actions you will meet: macrobody-expansion (RPP/RCC broken into primitive
surfaces), complement-expansion (#n inlined), universe-assigned and
fill-applied (universe plumbing), lattice-emitted (the matrix FILL
became a lattice element), lattice-expanded (GDML per-element placements),
halfspace-bounded and material-stub (GDML cutoff and placeholder
materials), reflective-applied / periodic-link (boundary mapping), and
dropped-cell-param / dropped-data-card (tokens with no target-code
spelling — listed in the report instead of vanishing silently).
What is not translated
Out-of-scope geometry fails with a ValueError that names the offending
cell, in all four directions. Hexagonal LAT=2 lattices, lattice FILL
matrices with 0 holes, and lattice cells whose bounds are not an RPP
interior or an axis-plane box are all rejected:
try:
mcnp.parse_csg_to_openmc(deck.replace("lat=1", "lat=2"))
except ValueError as e:
print(e)
# needs lattice translation (rectangular LAT=1 only)
Transforms are rejected the same way on any card that carries them:
try:
mcnp.parse_csg_to_openmc("msg\ntitle\n1 0 -1 trcl=1\n\n1 so 10\n\ntr1 0 0 0\n")
except ValueError as e:
print(e)
# transforms are out of v1 scope: cell 1 parameter `trcl=1` needs transform translation
The same funnel rejects cones, quadrics, and tori, U=-n, transformed fills,
matrix fills without LAT=1, tallies, source cards, and READ includes.
Serpent and PHITS add their own direction-specific unsupported cases —
reflecting and periodic boundaries have no verified Serpent mapping, and
periodic pointers have no PHITS spelling — the GDML direction rejects both
boundary kinds (Geant4 expresses boundaries through wrapper code, not
geometry markup) — and each raises a clear error.
See also
tests/test_csg_xlate.pyfor the full structural-assertion and reject suites, andfixtures/mcnp/inp/deck_csg_*.txtfor the sample decks they read.- Parse MCNP output for the deck reader underneath
(
read_deck/parse_deck).