Many thicknesses and extrapolation
Running Monte Carlo at every thickness is expensive. A more practical approach is to run Monte Carlo at a few thin shields, then fit the build-up factor and predict it at all thicknesses.
Why analytical-form fits?
BuildupFit fits the Monte Carlo data points with a closed-form parametric expression chosen to match the physics:
- 1D (single material): a Shin-Ishii / Taylor double-exponential
B(μt) = A · exp(-α₁·μt) + (1-A) · exp(-α₂·μt). Three free parameters, withB(0) = 1baked in. Captures growth (heavy multiplying materials), decay below 1 (hydrogenous materials), peak-and-decay (beryllium), and dip-and-recover all with the same form. - Multi-D (multi-layer geometries): a thin-plate-spline radial-basis-function with degree-1 polynomial augmentation. No hyperparameters, works on scattered points.
Both forms are weighted by Monte Carlo statistical uncertainty (the fit gives more weight to points with smaller MC error bars).
Basic example
Run Monte Carlo at 4 thicknesses, then fit and extrapolate to 10:
import rad_point_kernel as rpk
concrete = rpk.Material(
composition={
"H": 0.01, "O": 0.53, "Si": 0.34,
"Ca": 0.04, "Al": 0.03, "Fe": 0.01,
},
density=2.3,
fraction="mass",
)
PARTICLES_PER_HOUR = 1e12 * 3600 # 1e12 photons/sec activity, scale to /hour
source = rpk.Source(particle="photon", energy=1e6)
# Step 1: Monte Carlo at 4 thicknesses
mc_thicknesses = [5, 10, 15, 20]
mc_geometries = [
[rpk.Layer(thickness=t, material=concrete)]
for t in mc_thicknesses
]
mc_results = rpk.compute_buildup(
geometries=mc_geometries,
source=source,
quantities=["dose-AP-photon"],
)
for t, r in zip(mc_thicknesses, mc_results):
print(f" {t:>2d} cm: B = {r.buildup['dose-AP-photon']}")
# Step 2: Fit
fit = rpk.BuildupFit(
points=[{"thickness": t} for t in mc_thicknesses],
results=mc_results,
)
# Step 3: Predict build-up at any thickness
all_thicknesses = mc_thicknesses + [30, 50, 75, 100, 150, 200]
for t in all_thicknesses:
layers = [rpk.Layer(thickness=t, material=concrete)]
bi = fit.interpolate(thickness=t)
result = rpk.calculate_dose(
layers=layers,
source=source,
geometry="AP",
buildup=bi,
).scale(strength=PARTICLES_PER_HOUR)
status = "EXTRAPOLATED" if bi.is_extrapolated else "interpolated"
print(f" {t} cm: dose = {result.dose} Sv/hr, "
f"B = {bi.value} ({status})")
5 cm: B = 1.503520381729799
10 cm: B = 2.1295183238160256
15 cm: B = 2.8389002304314457
20 cm: B = 3.611892545246658
5 cm: dose = 37.14693271906117 Sv/hr, B = 1.5085934044570912 (interpolated)
10 cm: dose = 6.219448809958847 Sv/hr, B = 2.111102640629385 (interpolated)
15 cm: dose = 1.768121857437373 Sv/hr, B = 2.821632031738581 (interpolated)
20 cm: dose = 0.6167646807541094 Sv/hr, B = 3.65622881856949 (interpolated)
30 cm: dose = 0.09913249255478243 Sv/hr, B = 5.773096872149159 (EXTRAPOLATED)
50 cm: dose = 0.004048478769199178 Sv/hr, B = 12.48461178827747 (EXTRAPOLATED)
75 cm: dose = 0.00010320327943064591 Sv/hr, B = 28.523332523120708 (EXTRAPOLATED)
100 cm: dose = 3.0734328337459617e-06 Sv/hr, B = 60.151857028702146 (EXTRAPOLATED)
150 cm: dose = 3.3830612971473387e-09 Sv/hr, B = 236.37407550454012 (EXTRAPOLATED)
200 cm: dose = 4.320711474311484e-12 Sv/hr, B = 851.5396237784894 (EXTRAPOLATED)
InterpolationResult
fit.interpolate() returns an InterpolationResult with:
value- predicted build-up factoris_extrapolated- True if the query point is outside the range of Monte Carlo dataextrapolated_axes- dict showing which axes are extrapolated and by how muchsigma- not exposed (NaN). Predictive uncertainty is on the roadmap; see the project TODO.
Using InterpolationResult as build-up
You can pass an InterpolationResult directly to any calculation function:
bi = fit.interpolate(thickness=50)
result = rpk.calculate_dose(
layers=[rpk.Layer(thickness=50, material=concrete)],
source=source,
geometry="AP",
buildup=bi,
).scale(strength=PARTICLES_PER_HOUR)
print(f"Dose at 50 cm concrete: {result.dose} Sv/hr (B = {bi.value})")
Using your own build-up values
You don't have to use BuildupFit - if you have build-up factors from another source (literature, your own fitting, a different interpolation method), you can apply them directly:
# Your own B value from any source
my_B = 3.2
layers = [rpk.Layer(thickness=50, material=concrete)]
result = rpk.calculate_dose(
layers=layers,
source=source,
geometry="AP",
buildup=rpk.BuildupModel.constant(my_B),
).scale(strength=PARTICLES_PER_HOUR)
print(f"Dose with B={my_B}: {result.dose} Sv/hr")
This means you can use any interpolation or fitting method you prefer (scipy, scikit-learn, a lookup table, or even a hand-drawn curve) and feed the result into the point-kernel calculation.
Multi-dimensional fits
BuildupFit supports N-dimensional parameter spaces. For multi-layer geometries it switches automatically to a thin-plate-spline RBF interpolator. Example with water and concrete thicknesses as two axes:
import rad_point_kernel as rpk
water = rpk.Material(composition={"H2O": 1.0}, density=1.0)
concrete = rpk.Material(
composition={
"H": 0.01, "O": 0.53, "Si": 0.34,
"Ca": 0.04, "Al": 0.03, "Fe": 0.01,
},
density=2.3,
fraction="mass",
)
# Monte Carlo at a grid of (water, concrete) thicknesses
mc_water = [0, 10, 20]
mc_conc = [10, 20, 30]
points = []
geometries = []
for w in mc_water:
for c in mc_conc:
points.append({"water": w, "conc": c})
layers = [rpk.Layer(thickness=1000)]
if w > 0:
layers.append(rpk.Layer(thickness=w, material=water))
layers.append(rpk.Layer(thickness=c, material=concrete))
geometries.append(layers)
source = rpk.Source(particle="neutron", energy=14.06e6)
mc_results = rpk.compute_buildup(
geometries=geometries,
source=source,
quantities=["dose-AP-neutron"],
)
fit = rpk.BuildupFit(points=points, results=mc_results)
# Query at any (water, concrete) combination
bi = fit.interpolate(water=15, conc=25, quantity="dose-AP-neutron")
print(f"B = {bi.value}")
Plotting build-up factors
from io import StringIO
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
# After running Monte Carlo and fitting (as above)
thicknesses = np.linspace(5, 200, 100)
b_values = [fit.interpolate(thickness=float(t), warn=False).value for t in thicknesses]
fig, ax = plt.subplots()
ax.plot(thicknesses, b_values, "b-", label="Shin-Ishii fit")
# Monte Carlo points with statistical error bars on B
# sigma_B = mc_std_dev / pk (the same per-point sigma that weights the fit)
mc_bs = [r.buildup["dose-AP-photon"] for r in mc_results]
mc_b_err = [r.mc_std_dev["dose-AP-photon"] / r.pk["dose-AP-photon"] for r in mc_results]
ax.errorbar(mc_thicknesses, mc_bs, yerr=mc_b_err,
fmt="ko", markersize=7, capsize=3, label="Monte Carlo")
ax.set_xlabel("Thickness (cm)")
ax.set_ylabel("Build-up factor B")
ax.legend()
buf = StringIO()
fig.savefig(buf, format="svg")
plt.close(fig)
print(buf.getvalue())
Plotting dose
from io import StringIO
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
doses = []
for t in all_thicknesses:
layers = [rpk.Layer(thickness=t, material=concrete)]
bi = fit.interpolate(thickness=t, warn=False)
pk = rpk.calculate_dose(
layers=layers,
source=source,
geometry="AP",
).scale(strength=PARTICLES_PER_HOUR)
doses.append(pk.dose * bi.value)
fig, ax = plt.subplots()
ax.plot(all_thicknesses, doses, "b-", label="PK with build-up")
# Monte Carlo reference points
mc_scaled = [r.scale(strength=PARTICLES_PER_HOUR) for r in mc_results]
mc_doses = [r.mc["dose-AP-photon"] for r in mc_scaled]
ax.plot(mc_thicknesses, mc_doses, "ko", markersize=7, label="Monte Carlo")
ax.set_xlabel("Thickness (cm)")
ax.set_ylabel("Dose rate (Sv/hr)")
ax.set_yscale("log")
ax.legend()
buf = StringIO()
fig.savefig(buf, format="svg")
plt.close(fig)
print(buf.getvalue())