diff --git a/examples/transport_free_random_ray/README.md b/examples/transport_free_random_ray/README.md new file mode 100644 index 00000000000..9cfa6b5b5c7 --- /dev/null +++ b/examples/transport_free_random_ray/README.md @@ -0,0 +1,71 @@ +# Transport-free MGXS → random ray: fusion-shield benchmark + +A decisive end-to-end test of the `transport_free` MGXS generation method (added to +`Model.convert_to_multigroup(method="transport_free")`): generate a multigroup library +deterministically (no Monte Carlo, no transport solve), feed it to OpenMC's **random ray** +solver, and compare the resulting flux against a **continuous-energy Monte Carlo reference** +on a deep-penetration fusion shield. The same is done with `stochastic_slab` (the existing +MC-based method) so the two can be compared head-to-head against the CE truth. + +## Geometry + +Spherical shield using the [neutronics-workshop](https://fusion-energy.github.io/neutronics-workshop) +tokamak materials and thicknesses: a 14.1 MeV plasma source in the centre, then a tungsten +armor layer, a steel structural layer, a lithium breeder blanket, and a concrete bioshield. + +![benchmark geometry](rr_benchmark_geometry.png) + +## Running + +```bash +python rr_fusion_bench.py ce # continuous-energy reference (Monte Carlo) +python rr_fusion_bench.py rr stochastic_slab # random ray fed by the slab MGXS +python rr_fusion_bench.py rr transport_free # random ray fed by the transport-free MGXS +python rr_analyze.py # volume-averaged comparison vs CE +``` + +Append a group-structure name (e.g. `CCFE-709`) to any command to change the energy mesh. + +## Metrics + +CE flux tallies are volume-*integrated* (track length); random ray +`volume_normalized_flux_tallies` are volume-*averaged*. `rr_analyze.py` converts both to a +common volume-averaged region spectrum using analytic shell volumes, then reports: + +- **spectrum-shape error** vs CE per region (each spectrum normalized to unit sum — + normalization-independent), +- **deep-attenuation error**: how accurately the flux suppression deep in the shield + (φ_region / φ_first-wall) matches CE. + +## Result + +**Robustness.** `transport_free` produces a library that is **valid in every group** +(positive total XS, no Monte Carlo noise), so it feeds random ray with no fixups. +`stochastic_slab` produced zero / negative total cross sections (unpopulated deep-thermal +groups + a transport-correction artifact in lithium) that random ray rejects; the harness +applies an identical positive-floor fixup to both for a fair comparison. The noise is also +visible directly: in the deep-thermal tail of the near-source regions `stochastic_slab` +swings over tens of decades of Monte Carlo noise while `transport_free` is smooth (see +`rr_spectra_*.png`). + +**Accuracy vs CE.** The verdict is group-structure dependent: + +- At a **coarse** structure (`VITAMIN-J-42`, 40 groups) `transport_free` is markedly more + accurate deep in the shield — the deep-attenuation error vs CE is ~41 % versus ~234 % for + `stochastic_slab`, because the slab's noisy/biased coarse-group cross sections badly + mis-predict the flux suppression. +- At a **fine fusion** structure (`CCFE-709`, 650 groups) the two methods **converge to + near-parity** (flux-weighted spectral error within a few % of each other in every region): + `stochastic_slab` keeps a small edge at the near-source first wall, `transport_free` is + slightly better at the deepest point. Finer groups make the within-group weighting nearly + irrelevant, so the methods agree — consistent with the collapse-metric studies on PR #113. + +So for the random ray workflow, `transport_free` **matches `stochastic_slab`'s accuracy at +the fine groups used for fusion shielding, beats it at coarse groups, and is strictly more +robust** (deterministic, noise-free, valid in every group). + +**Caveat.** The random ray solve here is moderately resolved (a handful of flat source +regions per layer); absolute errors vs CE (~20–40 % flux-weighted at `CCFE-709`) are +dominated by that spatial resolution and are common to both methods. A higher-resolution +random ray solve (more source regions, linear sources, more rays) would sharpen the absolute +accuracy verdict; the *relative* method comparison is unaffected. diff --git a/examples/transport_free_random_ray/rr_analyze.py b/examples/transport_free_random_ray/rr_analyze.py new file mode 100644 index 00000000000..df7b089de40 --- /dev/null +++ b/examples/transport_free_random_ray/rr_analyze.py @@ -0,0 +1,106 @@ +"""Consistent volume-averaged comparison of RR(slab) and RR(transport_free) vs the CE +reference, reading the three statepoints directly. CE flux is volume-INTEGRATED +(track-length); RR volume_normalized flux is volume-AVERAGED. Convert both to a common +volume-AVERAGED region spectrum using analytic shell volumes, then score.""" +import sys, glob, numpy as np, openmc +# normalize argv so rr_fusion_bench's import-time group parsing sees the group name +_grp = sys.argv[1] if len(sys.argv) > 1 else "VITAMIN-J-42" +sys.argv = [sys.argv[0], "ce", _grp] +import rr_fusion_bench as B + +EDGES, NG, LAYOUT = B.EDGES, B.NG, B.LAYOUT +GROUPS = B.GROUPS + +# rebuild geometry to recover per-cell radii/volumes and the region->cells map +def cell_geometry(): + r_prev = 0.0; cells = [] # list of (region, r_in, r_out) + for name, r_out, nsub in LAYOUT: + rs = np.linspace(r_prev, r_out, nsub + 1) + for k in range(nsub): + cells.append((name, rs[k], rs[k + 1])) + r_prev = r_out + vols = np.array([4/3*np.pi*(ro**3 - ri**3) for _, ri, ro in cells]) + regions = [c[0] for c in cells] + return regions, vols + +def per_cell_flux(sp_path): + sp = openmc.StatePoint(sp_path) + t = sp.get_tally(name="flux_spectrum") + return t.get_values(scores=["flux"]).reshape(-1, NG) # [cell, group] low->high E + +def region_avg(flux_cell, regions, vols, integrated): + """Volume-averaged region spectrum [NG], OpenMC ordering (group1=high E). + integrated=True: flux_cell is vol-integrated (CE) -> sum/V. False: vol-averaged (RR) -> V-weighted mean.""" + names = [n for n, _, _ in LAYOUT] + out = {} + for nm in names: + idx = [i for i, r in enumerate(regions) if r == nm] + V = vols[idx].sum() + if integrated: + spec = flux_cell[idx].sum(0) / V + else: + spec = (flux_cell[idx] * vols[idx][:, None]).sum(0) / V + out[nm] = spec[::-1] + return out + +def shape_err(a, b): + a = a / a.sum(); b = b / b.sum(); k = b > b.max() * 1e-6 + return 100 * np.mean(np.abs((a - b)[k] / b[k])) + +regions, vols = cell_geometry() +ce_sp = sorted(glob.glob("run_ce/statepoint.*.h5"))[-1] +sl_sp = sorted(glob.glob("run_rr_stochastic_slab/statepoint.*.h5"))[-1] +tf_sp = sorted(glob.glob("run_rr_transport_free/statepoint.*.h5"))[-1] +ce = region_avg(per_cell_flux(ce_sp), regions, vols, integrated=True) +sl = region_avg(per_cell_flux(sl_sp), regions, vols, integrated=False) +tf = region_avg(per_cell_flux(tf_sp), regions, vols, integrated=False) + +names = [n for n, _, _ in LAYOUT] +fw = "tungsten" +print(f"=== RR-vs-CE fusion shield, {GROUPS} ({NG} groups), correction=None ===") +print(f"statepoints: CE={ce_sp} slab={sl_sp} tf={tf_sp}\n") +print(f"{'region':9}| spectrum-shape err vs CE (%) | attenuation phi_region/phi_tungsten") +print(f"{'':9}| slab tf (winner) | CE slab tf") +slab_wins = tf_wins = 0 +for nm in names: + if nm == "plasma": + continue + se_sl, se_tf = shape_err(sl[nm], ce[nm]), shape_err(tf[nm], ce[nm]) + win = "tf" if se_tf < se_sl else "slab" + if nm in ("steel", "lithium", "concrete"): # deep regions (downstream of resonant metals) + tf_wins += se_tf < se_sl; slab_wins += se_tf >= se_sl + a_ce = ce[nm].sum() / ce[fw].sum() + a_sl = sl[nm].sum() / sl[fw].sum() + a_tf = tf[nm].sum() / tf[fw].sum() + print(f"{nm:9}| {se_sl:6.2f} {se_tf:6.2f} ({win:4}) | {a_ce:.3e} {a_sl:.3e} {a_tf:.3e}") + +# headline: attenuation accuracy (deep flux suppression vs CE) + deep spectrum +def atten_err(m): + return 100 * np.mean([abs((m[nm].sum()/m[fw].sum()) / (ce[nm].sum()/ce[fw].sum()) - 1) + for nm in ("steel", "lithium", "concrete")]) +print(f"\nDeep-attenuation error vs CE (mean over steel/Li/concrete): " + f"slab {atten_err(sl):.1f}% transport_free {atten_err(tf):.1f}%") +deep = "concrete" +print(f"DEEP ({deep}) spectrum-shape error vs CE: slab {shape_err(sl[deep], ce[deep]):.2f}% " + f"tf {shape_err(tf[deep], ce[deep]):.2f}%") +print(f"\nDeep-region (steel/Li/concrete) spectrum-shape wins: transport_free {tf_wins}/3, slab {slab_wins}/3") + +# spectra plot: normalized flux/group, CE vs RR(slab) vs RR(transport_free) +try: + import matplotlib; matplotlib.use("Agg"); import matplotlib.pyplot as plt + mid = np.sqrt(EDGES[:-1] * EDGES[1:])[::-1] + fig, axs = plt.subplots(1, 2, figsize=(13, 5)) + for ax, nm in zip(axs, ("tungsten", "concrete")): + for lab, d, c, ls, lw in [("CE ref", ce, "k", "-", 1.6), + ("RR slab", sl, "tab:orange", "--", 1.2), + ("RR transport_free", tf, "tab:blue", "--", 1.2)]: + y = d[nm] / d[nm].sum() + ax.step(mid, y, where="mid", label=lab, color=c, ls=ls, lw=lw) + ax.set_xscale("log"); ax.set_yscale("log") + ax.set_title(f"{nm} region - normalized flux/group ({GROUPS})") + ax.set_xlabel("E [eV]"); ax.set_ylabel("normalized flux") + ax.legend(fontsize=8); ax.grid(alpha=0.3, which="both") + fig.tight_layout(); fig.savefig(f"rr_spectra_{GROUPS}.png", dpi=130, bbox_inches="tight") + print(f"saved rr_spectra_{GROUPS}.png") +except Exception as e: + print("plot skipped:", e) diff --git a/examples/transport_free_random_ray/rr_fusion_bench.py b/examples/transport_free_random_ray/rr_fusion_bench.py new file mode 100644 index 00000000000..2caac1ba627 --- /dev/null +++ b/examples/transport_free_random_ray/rr_fusion_bench.py @@ -0,0 +1,205 @@ +"""RR-vs-CE fusion-shield benchmark: does transport_free MGXS beat stochastic_slab when +fed to OpenMC's random ray solver, judged against a continuous-energy reference? + +Geometry: spherical fusion shield using the neutronics-workshop tokamak materials/thicknesses +(14.1 MeV plasma source -> tungsten armor -> steel structure -> lithium blanket -> concrete +bioshield). Deep-penetration: the deep-region spectrum depends on the upstream metals' MGXS. + +Metrics (normalization-independent, so CE-vs-RR is apples-to-apples): + - deep-region group flux SHAPE (each spectrum normalized to unit sum) + - radial attenuation profile (region flux / first-wall flux) + +usage: rr_fusion_bench.py {ce | rr | compare} [GROUPS] +""" +import sys, warnings, numpy as np, openmc +warnings.simplefilter("ignore") + +_gpos = 3 if (len(sys.argv) > 1 and sys.argv[1] == "rr") else 2 +GROUPS = sys.argv[_gpos] if len(sys.argv) > _gpos else "VITAMIN-J-42" +_full = np.asarray(openmc.mgxs.GROUP_STRUCTURES[GROUPS]); EDGES = _full[_full <= 2.0e7] +GE = openmc.mgxs.EnergyGroups(EDGES); NG = GE.num_groups +SRC_E = 14.1e6 + +# ---- materials (workshop compositions) ------------------------------------------------- +def materials(): + plasma = openmc.Material(name="plasma") # near-void DT-ish gas, source region + plasma.add_element("H", 1.0); plasma.set_density("g/cm3", 1e-5) + w = openmc.Material(name="tungsten"); w.add_element("W", 1.0); w.set_density("g/cm3", 19.3) + steel = openmc.Material(name="steel") # SS316-ish + for el, f in [("Fe", 0.66), ("Cr", 0.17), ("Ni", 0.12), ("Mo", 0.025), ("Mn", 0.02)]: + steel.add_element(el, f, "wo") + steel.set_density("g/cm3", 7.93) + li = openmc.Material(name="lithium"); li.add_element("Li", 1.0); li.set_density("g/cm3", 0.534) + conc = openmc.Material(name="concrete") + for el, f in [("O", 0.52), ("Si", 0.325), ("Ca", 0.06), ("Al", 0.033), + ("Fe", 0.014), ("H", 0.01), ("Na", 0.017), ("Mg", 0.002), ("K", 0.019)]: + conc.add_element(el, f, "wo") + conc.set_density("g/cm3", 2.3) + return [plasma, w, steel, li, conc] + +# region outer radii [cm]; sub = radial subdivisions (FSRs for RR spatial resolution) +LAYOUT = [("plasma", 30.0, 2), ("tungsten", 50.0, 3), ("steel", 70.0, 3), + ("lithium", 120.0, 5), ("concrete", 170.0, 5)] + +def build_model(energy_mode="continuous-energy"): + mats = {m.name: m for m in materials()} + surfs = []; r_prev = 0.0; cells = []; region_cells = {} + for name, r_out, nsub in LAYOUT: + rs = np.linspace(r_prev, r_out, nsub + 1)[1:] + cl = [] + for k, r in enumerate(rs): + s = openmc.Sphere(r=r) + inner = -surfs[-1] if surfs else None + cell = openmc.Cell(fill=mats[name], region=(-s & +surfs[-1]) if surfs else -s) + surfs.append(s); cells.append(cell); cl.append(cell) + region_cells[name] = cl + r_prev = r_out + surfs[-1].boundary_type = "vacuum" + geom = openmc.Geometry(cells) + st = openmc.Settings(); st.run_mode = "fixed source"; st.energy_mode = energy_mode + st.batches = 100 if energy_mode == "continuous-energy" else 100 + st.particles = 200000 if energy_mode == "continuous-energy" else 2000 + # physical source: uniform over plasma region, 14.1 MeV (CE) / top group (MG) + if energy_mode == "continuous-energy": + e_dist = openmc.stats.Discrete([SRC_E], [1.0]) + else: + mid = np.sqrt(EDGES[:-1] * EDGES[1:]); g = int(np.argmin(np.abs(mid - SRC_E))) + e_dist = openmc.stats.Discrete([mid[g]], [1.0]) + space = openmc.stats.Point() # placeholder; constrained below for RR + st.source = openmc.IndependentSource(energy=e_dist, angle=openmc.stats.Isotropic()) + model = openmc.Model(geom, openmc.Materials(list(mats.values())), st) + return model, region_cells + +def add_tally(model, region_cells): + """Flux per group in each region (volume-summed over its sub-cells).""" + t = openmc.Tally(name="flux_spectrum") + cf = openmc.CellFilter([c for cl in region_cells.values() for c in cl]) + ef = openmc.EnergyFilter(EDGES) + t.filters = [cf, ef]; t.scores = ["flux"] + model.tallies = openmc.Tallies([t]) + return [c.id for cl in region_cells.values() for c in cl] + +def region_flux(sp_path, cell_ids, region_cells): + """Return {region: group_flux[NG]} summing the sub-cells of each region.""" + sp = openmc.StatePoint(sp_path) + t = sp.get_tally(name="flux_spectrum") + fl = t.get_values(scores=["flux"]).reshape(len(cell_ids), NG) # [cell, group] low->high E + id_index = {cid: i for i, cid in enumerate(cell_ids)} + out = {} + for name, cl in region_cells.items(): + idx = [id_index[c.id] for c in cl] + out[name] = fl[idx].sum(0)[::-1] # -> high E = group 1 (OpenMC ordering) + return out + +# ---- stages ---------------------------------------------------------------------------- +def run_ce(): + model, rc = build_model("continuous-energy") + cell_ids = add_tally(model, rc) + sp = model.run(cwd="run_ce") + fl = region_flux(sp, cell_ids, rc) + np.savez(f"rr_ref_ce_{GROUPS}.npz", cell_ids=cell_ids, + **{k: v for k, v in fl.items()}) + print("CE reference done ->", f"rr_ref_ce_{GROUPS}.npz") + for k, v in fl.items(): + print(f" {k:9} integral flux = {v.sum():.4e}") + +def _sanitize(path, floor=1e-5): + """RR rejects zero/negative total XS. Clip total (and absorption) to a small floor + where needed; applied identically to every method so the comparison stays fair. + Returns the count of clipped (material, group) entries.""" + lib = openmc.MGXSLibrary.from_hdf5(path); n = 0 + for x in lib.xsdatas: + tot = np.array(x._total[0]) + bad = tot <= 0 + if bad.any(): + n += int(bad.sum()); tot[bad] = floor + x._total[0] = tot + ab = np.array(x._absorption[0]); ab[bad] = np.maximum(ab[bad], 0.0); x._absorption[0] = ab + if n: + lib.export_to_hdf5(path) + return n + +def run_rr(method): + model, rc = build_model("continuous-energy") # start CE, then convert + src_dist = model.settings.source[0].energy + model.convert_to_multigroup(method=method, groups=GE, + mgxs_path=f"rr_mgxs_{method}_{GROUPS}.h5", + overwrite_mgxs_library=True, correction=None, + source_energy=openmc.stats.Discrete([SRC_E], [1.0])) + nclip = _sanitize(f"rr_mgxs_{method}_{GROUPS}.h5") + print(f" [{method}] sanitized {nclip} zero/negative total-XS entries") + # constrain the physical source to the plasma material (RR fixed-source domain) + plasma_mat = [m for m in model.materials if m.name == "plasma"][0] + model.settings.source = [openmc.IndependentSource( + energy=src_dist, constraints={"domains": [plasma_mat]}, strength=1.0)] + cell_ids = add_tally(model, rc) + model.convert_to_random_ray() + model.settings.random_ray["volume_normalized_flux_tallies"] = True + model.settings.particles = 4000; model.settings.batches = 150; model.settings.inactive = 50 + sp = model.run(cwd=f"run_rr_{method}") + fl = region_flux(sp, cell_ids, rc) + np.savez(f"rr_res_{method}_{GROUPS}.npz", **{k: v for k, v in fl.items()}) + print(f"RR ({method}) done ->", f"rr_res_{method}_{GROUPS}.npz") + for k, v in fl.items(): + print(f" {k:9} integral flux = {v.sum():.4e}") + +def compare(): + ref = np.load(f"rr_ref_ce_{GROUPS}.npz") + regions = [n for n, _, _ in LAYOUT] + methods = ["stochastic_slab", "transport_free"] + res = {m: np.load(f"rr_res_{m}_{GROUPS}.npz") for m in methods} + def shape_err(a, b): # normalized-spectrum mean abs % error + a = a / a.sum(); b = b / b.sum(); k = b > b.max() * 1e-6 + return 100 * np.mean(np.abs((a - b)[k] / b[k])) + print(f"\n=== RR-vs-CE, {GROUPS} ({NG} groups) ===") + print(f"{'region':9}| spectrum shape err (%) vs CE | atten ratio (region/tungsten) CE / slab / tf") + fw = "tungsten" + for nm in regions: + if nm == "plasma": continue + ce = ref[nm] + line = f"{nm:9}| slab {shape_err(res['stochastic_slab'][nm], ce):6.2f} tf {shape_err(res['transport_free'][nm], ce):6.2f} |" + a_ce = ce.sum() / ref[fw].sum() + a_sl = res['stochastic_slab'][nm].sum() / res['stochastic_slab'][fw].sum() + a_tf = res['transport_free'][nm].sum() / res['transport_free'][fw].sum() + line += f" {a_ce:.3e} / {a_sl:.3e} / {a_tf:.3e}" + print(line) + # headline: deep concrete spectrum, who wins + deep = "concrete" + sl = shape_err(res['stochastic_slab'][deep], ref[deep]) + tf = shape_err(res['transport_free'][deep], ref[deep]) + print(f"\nDEEP ({deep}) spectrum shape error vs CE: slab {sl:.2f}% transport_free {tf:.2f}% " + f"-> {'transport_free WINS' if tf < sl else 'stochastic_slab wins'}") + +def plot_geometry(): + import matplotlib; matplotlib.use("Agg"); import matplotlib.pyplot as plt + import matplotlib.patches as mp + model, rc = build_model("continuous-energy") + mats = {m.name: m for m in model.materials} + colmap = {"plasma": (255, 235, 150), "tungsten": (90, 90, 110), "steel": (140, 150, 165), + "lithium": (190, 225, 235), "concrete": (205, 180, 150)} + colors = {mats[n]: c for n, c in colmap.items()} + R = LAYOUT[-1][1] + fig, ax = plt.subplots(figsize=(7.2, 7.2), dpi=150) + model.plot(basis="xy", width=(2*R+40, 2*R+40), pixels=(1400, 1400), + colors=colors, color_by="material", axes=ax, legend=False) + ax.set_title("RR-vs-CE fusion shield benchmark (XY slice)\n" + "14.1 MeV plasma source -> W armor -> steel -> Li blanket -> concrete", fontsize=11) + ax.set_xlabel("x [cm]"); ax.set_ylabel("y [cm]") + r_prev = 0.0; handles = [] + for name, r_out, _ in LAYOUT: + handles.append(mp.Patch(facecolor=np.array(colmap[name])/255.0, edgecolor="k", + label=f"{name} ({r_prev:.0f}-{r_out:.0f} cm)")) + r_prev = r_out + ax.legend(handles=handles, loc="upper right", fontsize=8, framealpha=0.9) + ax.add_patch(plt.Circle((0, 0), LAYOUT[0][1], fill=False, ls="--", lw=1.2, ec="red")) + ax.plot(0, 0, marker="*", color="red", ms=14) + fig.tight_layout(); fig.savefig("rr_benchmark_geometry.png", bbox_inches="tight") + print("saved rr_benchmark_geometry.png") + +if __name__ == "__main__": + stage = sys.argv[1] + if stage == "ce": run_ce() + elif stage == "rr": run_rr(sys.argv[2] if len(sys.argv) > 2 else "transport_free") + elif stage == "compare": compare() + elif stage == "plot": plot_geometry() + else: print(__doc__) diff --git a/openmc/mgxs/transport_free.py b/openmc/mgxs/transport_free.py new file mode 100644 index 00000000000..a9aee98a345 --- /dev/null +++ b/openmc/mgxs/transport_free.py @@ -0,0 +1,570 @@ +"""Deterministic, transport-free multigroup cross-section collapse. + +Collapses continuous-energy nuclear data against an *assumed* weighting flux +with narrow-resonance (Bondarenko) self-shielding -- no Monte Carlo, no +transport solve, no ``nparticles``. Each material is collapsed *directly* +(its own macroscopic flux); per-nuclide multigroup data are never combined, +because multigroup cross sections are flux-weighted averages and do not add +cleanly (see shimwell/openmc#112, design principle 7). + +``collapse_material`` gives the vector cross sections (total / absorption / +capture / fission); ``scatter_matrix`` gives the P0 group-to-group scattering +matrix (elastic with the real CM angular distribution, discrete inelastic levels, +and unit-base-interpolated continuum / (n,xn) with multiplicity) -- together a +complete MG library for a P0 solver such as random ray. Weighting is a 1/E +(+ optional source) narrow-resonance flux; self-shielding (resolved resonances +plus the unresolved range via probability tables) is always applied. +""" +from __future__ import annotations + +import numpy as np + +import openmc +import openmc.data +from .groups import EnergyGroups + +# numpy >= 2.0 renamed trapz -> trapezoid +_trapz = getattr(np, "trapezoid", None) or np.trapz + + +def _as_energy_groups(groups) -> EnergyGroups: + from openmc.mgxs import GROUP_STRUCTURES # defined in openmc/mgxs/__init__.py + if isinstance(groups, EnergyGroups): + return groups + if isinstance(groups, str): + return EnergyGroups(GROUP_STRUCTURES[groups]) + return EnergyGroups(np.asarray(groups, dtype=float)) + + +def _nearest_temperature(inc: "openmc.data.IncidentNeutron", temperature: float) -> str: + temps = np.array([float(t[:-1]) for t in inc.temperatures]) + return inc.temperatures[int(np.argmin(np.abs(temps - temperature)))] + + +def _source_pdf(dist, grid): + """Evaluate a source energy distribution as a normalized PDF on ``grid``.""" + import openmc.stats as st + pdf = np.zeros_like(grid) + if isinstance(dist, st.Normal): # e.g. openmc.stats.muir() + mu, sig = float(dist.mean_value), float(dist.std_dev) + pdf = np.exp(-0.5 * ((grid - mu) / sig) ** 2) / (sig * np.sqrt(2 * np.pi)) + elif isinstance(dist, st.Discrete): # mono lines (e.g. DD 2.45 MeV) + for xi, pi in zip(np.atleast_1d(dist.x), np.atleast_1d(dist.p)): + j = int(np.clip(np.searchsorted(grid, xi), 1, len(grid) - 1)) + pdf[j - 1] += pi / max(grid[j] - grid[j - 1], 1e-30) + elif isinstance(dist, st.Tabular): # TT continuum / arbitrary + pdf = np.interp(grid, dist.x, dist.p, left=0.0, right=0.0) + elif isinstance(dist, st.Mixture): # mixtures + for p, d in zip(dist.probability, dist.distribution): + pdf = pdf + p * _source_pdf(d, grid) + integral = _trapz(pdf, grid) + return pdf / integral if integral > 0 else pdf + + +def _macroscopic(incs, dens, temp_str, grid, mt): + """Macroscopic pointwise xs (1/cm) for reaction ``mt`` on ``grid``; None if absent.""" + total = np.zeros_like(grid) + present = False + for nuc, n in dens.items(): + inc = incs[nuc] + try: + xs = inc[mt].xs[temp_str[nuc]] + except KeyError: + continue + total += n * xs(grid) + present = True + return total if present else None + + +def _apply_urr(incs, dens, temp_str, grid, sigma_t_smooth, temperature): + """Unresolved-resonance self-shielding via probability tables (Bondarenko). + + In the unresolved range the pointwise data is the infinitely-dilute (smooth) + average, so phi = 1/Sigma_t applies *no* self-shielding there. The probability + tables restore the band structure: for each band b (probability p_b, micro + total sigma_t,b), the flux ~ 1/(sigma_t,b + sigma_0), giving the self-shielded + effective micro xs = sum_b p_b sigma_x,b/(sigma_t,b+sigma_0) + / sum_b p_b/(sigma_t,b+sigma_0), + with sigma_0 the per-resonant-nuclide background from the rest of the material. + Tables here are LSSF=1 factors on the smooth xs (``multiply_smooth``). + + Returns {mt: macroscopic delta array (1/cm)} to ADD to the dilute macroscopic + cross sections, for mt in (1, 101, 102). + """ + delta = {1: np.zeros_like(grid), 101: np.zeros_like(grid), 102: np.zeros_like(grid)} + for nuc, n in dens.items(): + inc = incs[nuc] + urr = getattr(inc, 'urr', None) + if not urr: + continue + cand = [t for t in urr if urr.get(t) is not None and t in inc.temperatures] + if not cand: + continue + ts = min(cand, key=lambda t: abs(float(t[:-1]) - temperature)) + pt = urr[ts] + if not getattr(pt, 'multiply_smooth', False): + continue # only LSSF=1 factor tables + e = np.asarray(pt.energy, dtype=float) + tab = np.asarray(pt.table, dtype=float) # [nE, 6, nbands] + idx = np.where((grid >= e[0]) & (grid <= e[-1]))[0] + if idx.size == 0: + continue + st = inc[1].xs[ts](grid) # smooth micro total + sc = inc[102].xs[ts](grid) # smooth micro capture + try: + sa = inc[101].xs[ts](grid) + except Exception: + sa = sc + for gi in idx: + j = int(np.clip(np.searchsorted(e, grid[gi]), 1, len(e) - 1)) + row = tab[j - 1] if abs(e[j - 1] - grid[gi]) <= abs(e[j] - grid[gi]) else tab[j] + p = np.diff(np.concatenate(([0.0], row[0]))) # band probabilities + sig0 = (sigma_t_smooth[gi] - n * st[gi]) / n # micro background (others) + if sig0 < 0: + sig0 = 0.0 + stb = st[gi] * row[1] # band micro total + w = p / (stb + sig0) + denom = w.sum() + if denom <= 0: + continue + st_eff = float((w * stb).sum() / denom) + sc_eff = float((w * (sc[gi] * row[4])).sum() / denom) + fc = sc_eff / sc[gi] if sc[gi] > 0 else 1.0 # shield absorption like capture + delta[1][gi] += n * (st_eff - st[gi]) + delta[102][gi] += n * (sc_eff - sc[gi]) + delta[101][gi] += n * sa[gi] * (fc - 1.0) + return delta + + +def _urr_elastic_factor(incs, dens, temp_str, grid, sigma_t_smooth, temperature): + """Per-nuclide micro elastic self-shielding factor f_el(E) = /sigma_el,smooth + in the unresolved range (1.0 elsewhere), from the same probability-table band average + as :func:`_apply_urr` but for the elastic channel (table column 2). Lets the scatter + matrix self-shield its elastic consistently with the URR-corrected vector total -- + otherwise the scatter row-sum is the dilute (too-high) elastic across the URR. + """ + fac = {} + for nuc, n in dens.items(): + inc = incs[nuc] + urr = getattr(inc, 'urr', None) + if not urr: + continue + cand = [t for t in urr if urr.get(t) is not None and t in inc.temperatures] + if not cand: + continue + ts = min(cand, key=lambda t: abs(float(t[:-1]) - temperature)) + pt = urr[ts] + if not getattr(pt, 'multiply_smooth', False): + continue + e = np.asarray(pt.energy, float); tab = np.asarray(pt.table, float) + idx = np.where((grid >= e[0]) & (grid <= e[-1]))[0] + if idx.size == 0: + continue + st = inc[1].xs[ts](grid); se = inc[2].xs[ts](grid) + f = np.ones_like(grid) + for gj in idx: + if se[gj] <= 0: + continue + j = int(np.clip(np.searchsorted(e, grid[gj]), 1, len(e) - 1)) + row = tab[j - 1] if abs(e[j - 1] - grid[gj]) <= abs(e[j] - grid[gj]) else tab[j] + p = np.diff(np.concatenate(([0.0], row[0]))) + sig0 = (sigma_t_smooth[gj] - n * st[gj]) / n + if sig0 < 0: + sig0 = 0.0 + wgt = p / (st[gj] * row[1] + sig0) + den = wgt.sum() + if den <= 0: + continue + f[gj] = float((wgt * (se[gj] * row[2])).sum() / den) / se[gj] + fac[nuc] = f + return fac + + +def collapse_material(material, groups, temperature=294.0, cross_sections=None, + source=None): + """Transport-free macroscopic multigroup cross sections for one material. + + Parameters + ---------- + material : openmc.Material + groups : EnergyGroups | str | sequence of float + temperature : float + Target temperature [K] (nearest available data temperature is used). + + Self-shielding (resolved resonances via phi = w/Sigma_t, plus the unresolved + range via probability tables) is always applied: a real material is never at + infinite dilution, so there is no knob to turn it off. + + Returns + ------- + dict with 'group_edges' (eV, ascending) and macroscopic group XS arrays + (1/cm), ordered group 1 = highest energy (OpenMC convention). + """ + groups = _as_energy_groups(groups) + edges = np.asarray(groups.group_edges, dtype=float) + emin, emax = edges[0], edges[-1] + + if cross_sections is None: + cross_sections = openmc.config['cross_sections'] + datalib = openmc.data.DataLibrary.from_xml(cross_sections) + + dens = material.get_nuclide_atom_densities() # {nuclide: atom/b-cm} + + incs, temp_str, grids = {}, {}, [edges] + for nuc in dens: + entry = datalib.get_by_material(nuc, data_type='neutron') + inc = openmc.data.IncidentNeutron.from_hdf5(entry['path']) + incs[nuc] = inc + ts = _nearest_temperature(inc, temperature) + temp_str[nuc] = ts + grids.append(np.asarray(inc.energy[ts])) + + grid = np.unique(np.concatenate(grids)) + grid = grid[(grid >= emin) & (grid <= emax)] + + sigma_t = _macroscopic(incs, dens, temp_str, grid, 1) + if sigma_t is None: + raise ValueError("no total cross section (MT=1) found for material") + + sigma_a = _macroscopic(incs, dens, temp_str, grid, 101) + sigma_c = _macroscopic(incs, dens, temp_str, grid, 102) + sigma_f = _macroscopic(incs, dens, temp_str, grid, 18) + + # Unresolved-resonance self-shielding (probability tables): correct the dilute + # total / absorption / capture band-by-band in the URR before weighting. Always + # applied -- it is part of self-shielding, which a real material always has. + d = _apply_urr(incs, dens, temp_str, grid, sigma_t, temperature) + sigma_t = sigma_t + d[1] + if sigma_a is not None: + sigma_a = sigma_a + d[101] + if sigma_c is not None: + sigma_c = sigma_c + d[102] + + # Narrow-resonance weighting flux: phi = w(E)/Sigma_t(E). The smooth part + # w = 1/E (asymptotic slowing-down) is optionally sharpened in the fast groups + # by the source spectrum, then self-shielded by the material's own + # (URR-corrected) total. + w = 1.0 / np.clip(grid, 1e-11, None) + if source is not None: + w = w + _source_pdf(source, grid) + phi = w / np.clip(sigma_t, 1e-30, None) + + reactions = { + 'total': sigma_t, + 'absorption': sigma_a, + 'capture': sigma_c, + 'fission': sigma_f, + } + + G = groups.num_groups + out = {'group_edges': edges} + for name, sig in reactions.items(): + if sig is None: + continue + sig_g = np.zeros(G) + for g in range(G): # g: ascending in energy + lo, hi = edges[g], edges[g + 1] + m = (grid >= lo) & (grid <= hi) + if m.sum() < 2: + continue + x, p, s = grid[m], phi[m], sig[m] + den = _trapz(p, x) + sig_g[g] = _trapz(s * p, x) / den if den > 0 else 0.0 + # OpenMC orders groups with group 1 = highest energy → reverse + out[name] = sig_g[::-1] + return out + + +# ---------------------------------------------------------------------------- +# P0 group-to-group scattering matrix (deterministic) +# ---------------------------------------------------------------------------- + +def _curve_frac(x, p, edges): + """Group-integrate a lin-lin density (x, p) over ascending edges -> len-G, normalized.""" + x = np.asarray(x, float); p = np.asarray(p, float) + if x.size < 2: + return None + cc = np.zeros_like(x); cc[1:] = np.cumsum(0.5 * (p[1:] + p[:-1]) * np.diff(x)) + tot = cc[-1] + if tot <= 0: + return None + return np.diff(np.interp(edges, x, cc, left=0.0, right=tot)) / tot + + +def _tab_frac(t, edges): + """Group fractions for an ``openmc.stats.Tabular`` outgoing distribution.""" + try: + cc = np.asarray(t.cdf(), float); x = np.asarray(t.x, float) + if cc[-1] <= 0: + return None + return np.diff(np.interp(edges, x, cc, left=0.0, right=cc[-1])) / cc[-1] + except Exception: + return _curve_frac(getattr(t, 'x', []), getattr(t, 'p', []), edges) + + +def _unitbase_frac(d_lo, d_hi, f, edges): + """Unit-base interpolation of two Tabulars at fraction ``f``, group-integrated. + + Both outgoing distributions are mapped to the unit interval, blended there + (valid because the two span different outgoing-energy ranges), then mapped back + so every outgoing energy stays within the interpolated kinematic range. + """ + xl, pl = np.asarray(d_lo.x, float), np.asarray(d_lo.p, float) + xh, ph = np.asarray(d_hi.x, float), np.asarray(d_hi.p, float) + if xl.size < 2 or xh.size < 2: + return _tab_frac(d_lo if f < 0.5 else d_hi, edges) + Ll, Lh = xl[-1] - xl[0], xh[-1] - xh[0] + if Ll <= 0 or Lh <= 0: + return _tab_frac(d_lo if f < 0.5 else d_hi, edges) + E0 = xl[0] + f * (xh[0] - xl[0]); E1 = xl[-1] + f * (xh[-1] - xl[-1]) + if E1 <= E0: + return None + ul = (xl - xl[0]) / Ll; uh = (xh - xh[0]) / Lh + u = np.union1d(ul, uh) + pu = (1 - f) * np.interp(u, ul, pl * Ll, left=0, right=0) + f * np.interp(u, uh, ph * Lh, left=0, right=0) + return _curve_frac(E0 + u * (E1 - E0), pu, edges) + + +def _incident_code(dist, k): + """Interpolation code on the incident interval starting at index ``k`` (2=lin-lin, 1=histogram).""" + bps = getattr(dist, 'breakpoints', None); itp = getattr(dist, 'interpolation', None) + if bps is None or itp is None: + return 2 + for r, bp in enumerate(np.atleast_1d(bps)): + if k + 1 <= bp: + return int(np.atleast_1d(itp)[r]) + return 2 + + +def _tabdist(dist): + """Return the object exposing .energy (incident) + .energy_out (Tabulars), or None.""" + if hasattr(dist, 'energy_out') and hasattr(dist, 'energy') and \ + not isinstance(getattr(dist, 'energy'), openmc.data.LevelInelastic): + return dist + ed = getattr(dist, 'energy', None) + if ed is not None and hasattr(ed, 'energy_out') and hasattr(ed, 'energy'): + return ed + return None + + +def _unitbase_anchors(td, edges, per=4): + """Precompute unit-base group fractions at sub-sampled incident energies.""" + ein = np.asarray(td.energy, float); eos = td.energy_out; K = len(eos) + Ea, GF = [], [] + G = len(edges) - 1 + for k in range(K - 1): + hist = _incident_code(td, k) == 1 + for s in range(per): + f = s / per + gf = _tab_frac(eos[k], edges) if hist else _unitbase_frac(eos[k], eos[k + 1], f, edges) + Ea.append(ein[k] + f * (ein[k + 1] - ein[k])); GF.append(gf if gf is not None else np.zeros(G)) + gf = _tab_frac(eos[-1], edges) + Ea.append(ein[-1]); GF.append(gf if gf is not None else np.zeros(G)) + return np.array(Ea), np.array(GF) + + +def _freegas_gf(E, A, kT, edges, nv=28, nmu=14): + """Free-gas (ideal-gas) elastic energy-transfer group fractions at incident energy E. + + Quadrature over the target Maxwellian (speed v_t, cosine mu_t); for each target the + isotropic-CM elastic scatter gives the lab outgoing energy uniform over + [0.5(Vcm-w)^2, 0.5(Vcm+w)^2]. Captures thermal up-scatter and broadening that the + static (target-at-rest) kernel misses; reduces to Wigner-Wilkins for A=1 (verified + to ~1-2%). E and kT in eV; speeds in sqrt(eV) with m_n = 1. + """ + vn = np.sqrt(2.0 * E); vth = np.sqrt(2.0 * kT / max(A, 1e-9)) + vt = np.linspace(0.02 * vth, 6.0 * vth, nv) + wv = vt**2 * np.exp(-A * vt**2 / (2.0 * kT)) + mu = np.linspace(-1.0, 1.0, nmu) + VT = vt[:, None]; MU = mu[None, :] + vrel = np.sqrt(np.clip(vn*vn + VT*VT - 2*vn*VT*MU, 0, None)) + Vcm = np.sqrt(np.clip(vn*vn + A*A*VT*VT + 2*A*vn*VT*MU, 0, None)) / (A + 1.0) + w = (A / (A + 1.0)) * vrel + Elo = (0.5 * (Vcm - w)**2).ravel(); Ehi = (0.5 * (Vcm + w)**2).ravel() + wgt = ((wv[:, None]) * np.ones_like(mu)[None, :] * vrel).ravel() + rng = np.clip(Ehi - Elo, 1e-30, None); tot = wgt.sum() + if tot <= 0: + return None + cdf = (np.clip((edges[:, None] - Elo[None, :]) / rng[None, :], 0, 1) * wgt[None, :]).sum(1) / tot + return np.diff(cdf) + + +def scatter_matrix(material, groups, temperature=294.0, cross_sections=None, source=None, + return_p1=False, thermal=True): + """Deterministic P0 group-to-group scattering matrix for one material. + + Returns the macroscopic Sigma_s,g->g' (1/cm) as an ``[G_in, G_out]`` array in + OpenMC ordering (group 1 = highest energy), matching ``openmc.XSdata`` P0 + (Legendre order 0). Kernels: elastic with the real CM angular distribution, + discrete inelastic levels spread over their lab energy range, and unit-base + interpolated continuum / (n,xn) distributions summed over all neutron products + with their multiplicity. No Monte Carlo. Same weighting flux as + :func:`collapse_material`. + """ + groups = _as_energy_groups(groups) + edges = np.asarray(groups.group_edges, float) + emin, emax = edges[0], edges[-1]; G = groups.num_groups + if cross_sections is None: + cross_sections = openmc.config['cross_sections'] + datalib = openmc.data.DataLibrary.from_xml(cross_sections) + dens = material.get_nuclide_atom_densities() + incs, temp_str, grids = {}, {}, [edges] + for nuc in dens: + entry = datalib.get_by_material(nuc, data_type='neutron') + inc = openmc.data.IncidentNeutron.from_hdf5(entry['path']); incs[nuc] = inc + ts = _nearest_temperature(inc, temperature); temp_str[nuc] = ts + grids.append(np.asarray(inc.energy[ts])) + grid = np.unique(np.concatenate(grids)); grid = grid[(grid >= emin) & (grid <= emax)] + # panel sub-grid: interior points in every group so the incoming integration + # (esp. the elastic in-group vs down-scatter split) is resolved where data is sparse. + sub = np.concatenate([np.geomspace(edges[g], edges[g + 1], 17)[1:-1] for g in range(G)]) + grid = np.unique(np.concatenate([grid, sub])) + + sigma_t = _macroscopic(incs, dens, temp_str, grid, 1) + sigma_t = sigma_t + _apply_urr(incs, dens, temp_str, grid, sigma_t, temperature)[1] + w = 1.0 / np.clip(grid, 1e-11, None) + if source is not None: + w = w + _source_pdf(source, grid) + phi = w / np.clip(sigma_t, 1e-30, None) + fel = _urr_elastic_factor(incs, dens, temp_str, grid, sigma_t, temperature) # URR elastic self-shielding + dwid = np.empty_like(grid) + dwid[1:-1] = 0.5 * (grid[2:] - grid[:-2]); dwid[0] = 0.5*(grid[1]-grid[0]); dwid[-1] = 0.5*(grid[-1]-grid[-2]) + wt = phi * dwid + gi = np.clip(np.searchsorted(edges, grid, side='right') - 1, 0, G - 1) + M = np.zeros((G, G)); denom = np.zeros(G); np.add.at(denom, gi, wt) + M1 = np.zeros((G, G)) if return_p1 else None # P1 (mu_lab-weighted) elastic outscatter + + def overlap(lo, hi): + if hi <= lo: + return None + return np.clip(np.minimum(edges[1:], hi) - np.maximum(edges[:-1], lo), 0, None) / (hi - lo) + + for nuc, n in dens.items(): + inc = incs[nuc]; ts = temp_str[nuc]; A = inc.atomic_weight_ratio; a1 = (A + 1.0) ** 2 + for mt, r in inc.reactions.items(): + is_el = (mt == 2) + if not (is_el or (51 <= mt <= 91) or mt in (16, 17)): + continue + try: + xs = r.xs[ts](grid) + except Exception: + continue + if is_el and nuc in fel: # self-shield elastic in the URR + xs = xs * fel[nuc] + nz = np.nonzero(xs > 0)[0] + if nz.size == 0: + continue + src = n * xs * wt + if is_el: # elastic: CM angular distribution + try: + ang = r.products[0].distribution[0].angle; aE = np.asarray(ang.energy) + except Exception: + ang = None + kT = 8.617e-5 * temperature # free-gas thermal for light nuclides + E_TH = 400.0 * kT # = OpenMC free_gas_threshold (default 400 kT); + do_fg = thermal and A <= 20.0 # when model-driven, read settings.free_gas_threshold + tgf = {} + if do_fg: + mid = np.sqrt(edges[:-1] * edges[1:]) + for g in np.where(mid < E_TH)[0]: + gf = _freegas_gf(mid[g], A, kT, edges) + if gf is not None: + tgf[int(g)] = gf + for i in nz: + E = grid[i] + if do_fg and E < E_TH and gi[i] in tgf: # thermal: free-gas energy transfer + M[gi[i]] += src[i] * tgf[gi[i]]; continue + mu = fp = None + if ang is not None: + t = ang.mu[min(np.searchsorted(aE, E), len(aE) - 1)] + if hasattr(t, 'x') and hasattr(t, 'p'): + mu = np.asarray(t.x, float); fp = np.asarray(t.p, float) + if mu is None or mu.size < 2: + mu = np.linspace(-1, 1, 33); fp = np.full(33, 0.5) + if mu[0] > mu[-1]: + mu = mu[::-1]; fp = fp[::-1] + # E_out is monotonic in mu, so spread f(mu) SMOOTHLY across outgoing + # groups via the angular CDF -- not one delta per mu point, which piles + # hydrogen's wide (mu~-1 -> E_out~0) down-scatter into the lowest group. + eout = E * (A*A + 2*A*mu + 1.0) / a1 + cmu = np.concatenate(([0.0], np.cumsum(0.5*(fp[1:]+fp[:-1])*np.diff(mu)))) + if cmu[-1] <= 0: + M[gi[i], gi[i]] += src[i]; continue + M[gi[i]] += src[i] * np.diff(np.interp(edges, eout, cmu/cmu[-1], left=0.0, right=1.0)) + if M1 is not None: # mu_lab = (1+A mu)/sqrt(A^2+2A mu+1) + mulab = (1.0 + A*mu) / np.sqrt(A*A + 2*A*mu + 1.0) + cm1 = np.concatenate(([0.0], np.cumsum(0.5*(fp[1:]*mulab[1:]+fp[:-1]*mulab[:-1])*np.diff(mu)))) + M1[gi[i]] += src[i] * np.diff(np.interp(edges, eout, cm1, left=0.0, right=cm1[-1])) / cmu[-1] + continue + for prod in r.products: # inelastic: all neutron products + if prod.particle != 'neutron': + continue + try: + yld = np.atleast_1d(np.asarray(prod.yield_(grid), float)) + if yld.size == 1: + yld = np.full(len(grid), float(yld[0])) + except Exception: + yld = np.ones(len(grid)) + dists = prod.distribution; nd = len(dists) + appl = getattr(prod, 'applicability', None) + for k, dist in enumerate(dists): + if nd > 1 and appl and k < len(appl): + try: + app = np.atleast_1d(np.asarray(appl[k](grid), float)) + if app.size == 1: + app = np.full(len(grid), float(app[0])) + except Exception: + app = np.ones(len(grid)) + else: + app = np.ones(len(grid)) + ed = getattr(dist, 'energy', None) + if isinstance(ed, openmc.data.LevelInelastic): # discrete level + thr, mr = float(ed.threshold), float(ed.mass_ratio) + for i in nz: + E = grid[i] + if E <= thr: + continue + ecm = mr * (E - thr) + if ecm <= 0: + continue + base = ecm + E / a1; amp = 2.0 * np.sqrt(E * ecm) / (A + 1.0) + of = overlap(base - amp, base + amp); wgt = src[i] * yld[i] * app[i] + if of is None: + M[gi[i], min(max(np.searchsorted(edges, base) - 1, 0), G - 1)] += wgt + else: + M[gi[i]] += wgt * of + continue + td = _tabdist(dist) # continuum / (n,xn) + if td is None: + continue + Ea, GF = _unitbase_anchors(td, edges) + ein0 = float(np.asarray(td.energy, float)[0]) + for i in nz: + E = grid[i] + if E < ein0: + continue + j = np.searchsorted(Ea, E) + if j <= 0: + gf = GF[0] + elif j >= len(Ea): + gf = GF[-1] + else: + fr = (E - Ea[j-1]) / (Ea[j] - Ea[j-1]); gf = (1 - fr) * GF[j-1] + fr * GF[j] + M[gi[i]] += src[i] * yld[i] * app[i] * gf + + # fold upscatter artifacts into the diagonal, but keep real thermal up-scatter (free-gas, below E_TH) + _midf = np.sqrt(edges[:-1] * edges[1:]); _eth = 400.0 * 8.617e-5 * temperature + for g in range(G - 1): + if thermal and _midf[g] < _eth: + continue + up = M[g, g+1:].sum() + if up: + M[g, g] += up; M[g, g+1:] = 0.0 + M = M / np.clip(denom[:, None], 1e-30, None) + if return_p1: + # P1 outscatter moment Sigma_s1,g per group, for a transport-corrected (TC-P0) + # library: emit sigma_tr = sigma_t - Sigma_s1 and subtract Sigma_s1 from the + # in-group diagonal. Random ray expects the correction applied here, not in the solver. + sigma_s1 = (M1.sum(1) / np.clip(denom, 1e-30, None))[::-1] + return M[::-1, ::-1], sigma_s1 + return M[::-1, ::-1] # -> OpenMC ordering (group 1 = high E) diff --git a/openmc/model/model.py b/openmc/model/model.py index d927b65ae64..009d00cb157 100644 --- a/openmc/model/model.py +++ b/openmc/model/model.py @@ -2703,8 +2703,14 @@ def convert_to_multigroup( Parameters ---------- - method : {"material_wise", "stochastic_slab", "infinite_medium"}, optional - Method to generate the MGXS. + method : {"material_wise", "stochastic_slab", "infinite_medium", \ + "transport_free"}, optional + Method to generate the MGXS. "transport_free" collapses each + material's pointwise data deterministically (no Monte Carlo, no + transport solve) against a narrow-resonance weighting flux with + resonance self-shielding, and builds a deterministic P0 scattering + matrix; it introduces no Monte Carlo noise and is valid in every + group. See :func:`openmc.mgxs.transport_free.collapse_material`. groups : openmc.mgxs.EnergyGroups, str, or sequence of float, optional Energy group structure for the MGXS. Can be an :class:`openmc.mgxs.EnergyGroups` object, a string name of a @@ -2793,6 +2799,9 @@ def convert_to_multigroup( self._generate_stochastic_slab_mgxs( groups, nparticles, mgxs_path, correction, tmpdir, source_energy, temperatures, temperature_settings) + elif method == "transport_free": + self._generate_transport_free_mgxs( + groups, mgxs_path, correction, source_energy, temperatures) else: raise ValueError( f'MGXS generation method "{method}" not recognized') @@ -2809,6 +2818,73 @@ def convert_to_multigroup( self.settings.energy_mode = 'multi-group' + def _generate_transport_free_mgxs( + self, groups, mgxs_path, correction, source_energy, temperatures + ): + """Deterministically generate an MGXS library (no Monte Carlo, no transport solve). + + Each material's pointwise cross sections are collapsed against a narrow-resonance + (1/E + source) weighting flux with always-on resonance self-shielding (resolved + range by 1/(sigma_t + sigma_0); unresolved range via NJOY probability tables), + together with a deterministic P0 group-to-group scattering matrix (including a + free-gas thermal kernel for light nuclides). Unlike "material_wise" and + "stochastic_slab", this introduces no Monte Carlo noise and yields a positive + total cross section in every group. See :mod:`openmc.mgxs.transport_free`. + + Parameters + ---------- + groups : openmc.mgxs.EnergyGroups + Energy group structure for the MGXS. + mgxs_path : PathLike + Filename for the MGXS HDF5 file. + correction : str or None + "P0" applies the transport-corrected outscatter P0 correction + (sigma_tr = sigma_t - Sigma_s1, with Sigma_s1 removed from the in-group + diagonal); None emits the full P0 matrix. + source_energy : openmc.stats.Univariate or None + Weighting-flux source spectrum. If None, the model's source energy + distribution is used; if the model has no source, a pure 1/E weight is used. + temperatures : Sequence[float] or None + Temperatures to generate MGXS at. Defaults to room temperature. + """ + from openmc.mgxs.transport_free import collapse_material, scatter_matrix + + # Weighting source spectrum: explicit argument, else the model's source energy + # distribution, else None (pure 1/E slowing-down weighting). + src = source_energy + if src is None and self.settings.source: + s0 = self.settings.source + s0 = s0[0] if isinstance(s0, (list, tuple)) else s0 + src = getattr(s0, 'energy', None) + + temps = list(temperatures) if temperatures else [294.0] + transport_correct = (correction == "P0") + diag = np.arange(groups.num_groups) + + mgxs_lib = openmc.MGXSLibrary(energy_groups=groups) + for material in self.materials: + xsd = openmc.XSdata(material.name, groups, temperatures=temps) + xsd.order = 0 + for temperature in temps: + coll = collapse_material(material, groups, temperature=temperature, + source=src) + total = np.asarray(coll['total'], float) + scat = scatter_matrix(material, groups, temperature=temperature, + source=src, return_p1=transport_correct) + if transport_correct: + matrix, sigma_s1 = scat + matrix = np.array(matrix, float) + total = total - sigma_s1 + matrix[diag, diag] -= sigma_s1 + else: + matrix = np.asarray(scat, float) + xsd.set_total(total, temperature=temperature) + xsd.set_absorption(np.asarray(coll['absorption'], float), + temperature=temperature) + xsd.set_scatter_matrix(matrix[:, :, np.newaxis], temperature=temperature) + mgxs_lib.add_xsdata(xsd) + mgxs_lib.export_to_hdf5(mgxs_path) + def convert_to_random_ray(self): """Convert a multigroup model to use random ray.