ANASEN_analysis/cross_section/cross_section.py
Vignesh Sitaraman d63a422a41 modified: TrackRecon.C
new folder:   cross_section, this folder allows for cross section calculations for the ANASEN data, a readme and some example files are provided
	modified:   run_tr.sh
2026-10-17 12:38:35 -04:00

333 lines
16 KiB
Python
Executable File

"""Step 3: the cross section, from the yields CSV (step 1) and an efficiency (step 2, or any other).
~/myenv/bin/python -B cross_section.py examples/27Al_ap_runA.toml # no ROOT needed; any python with pandas
sigma(E_cm) = Y / (N_b * n * dz * eps) mode = "excitation"
dsigma/dOmega(th) = Y / (N_b * n * dz * eps * dOmega) mode = "angular"
Y yield in the bin, from <out_dir>/yields_<det>.csv (or [yield] csv); see extract_yields.py for the format
N_b number of beam particles [beam]
n target atoms per cm^2 per mm of beam path [target]
dz length of beam path (mm) over which the beam's E_cm lies in the bin, from the beam track E(z)
eps efficiency of the bin [acceptance]
dOmega solid angle of the theta_cm bin (angular mode)
Writes <out_dir>/xs_<det>.csv (every intermediate number; the header records every input) and <out_dir>/xs.png.
WHAT IT DOES, STEP BY STEP (see analyse_detector())
1. beam track: beam energy E at every z
2. normalisation: N_b and n
3. bins: E_cm bins -> their z ranges and dz (or theta_cm bins inside one energy window)
4. yields: share the yield histogram's bins into those bins
5. efficiency: eps for each bin
6. cross section: the formula, with statistical and efficiency errors
"""
import argparse
import datetime
import re
from pathlib import Path
import numpy as np
import pandas as pd
import common
BOLTZMANN = 1.380649e-23 # J/K
PASCAL_PER_TORR = 133.322368
ELEMENTARY_CHARGE = 1.602176634e-19 # C
CM2_PER_MB = 1e-27
YIELD_VARIABLES = ("z", "e_beam", "ecm")
# ============================================================ normalisation
def number_of_beam_particles(beam):
"""N_b from: n_beam directly, or integrated charge, or current x time. Returns (N_b, relative error, text)."""
charge_state = beam.get("charge_state", 1)
if "n_beam" in beam:
n_b, text = beam["n_beam"], "given"
elif "charge_c" in beam:
n_b = beam["charge_c"] / (charge_state * ELEMENTARY_CHARGE)
text = f"{beam['charge_c']} C / ({charge_state} e)"
elif "current_a" in beam:
n_b = beam["current_a"] * beam["time_s"] / (charge_state * ELEMENTARY_CHARGE)
text = f"{beam['current_a']} A x {beam['time_s']} s / ({charge_state} e)"
else:
raise KeyError("[beam] needs n_beam, charge_c, or current_a and time_s")
return float(n_b), float(beam.get("n_beam_rel_err", 0.0)), text
def target_atoms_per_cm2_per_mm(target):
"""n = (gas molecules per cm^3) x (mole fraction of the target molecule) x (target atoms per molecule) x 0.1 cm/mm.
E.g. He in 250 Torr He + 3% CO2 at 293.15 K: mole_fraction 0.97, atoms_per_molecule 1 -> 7.988e17.
Returns (n, relative error, text)."""
if "n_per_cm2_per_mm" in target:
return float(target["n_per_cm2_per_mm"]), float(target.get("rel_err", 0.0)), "given"
pressure_pa = target["pressure_torr"] * PASCAL_PER_TORR
molecules_per_cm3 = pressure_pa / (BOLTZMANN * target["temperature_k"]) * 1e-6
fraction = target.get("mole_fraction", 1.0)
atoms = target.get("atoms_per_molecule", 1)
n = molecules_per_cm3 * fraction * atoms * 0.1
text = f"{target['pressure_torr']} Torr, {target['temperature_k']} K, mole fraction {fraction}, {atoms} atom(s)/molecule"
return n, float(target.get("rel_err", 0.0)), text
# ============================================================ yields
def read_yields(file):
"""The yields CSV of extract_yields.py. Returns (table, variable), variable = z, e_beam or ecm."""
table = pd.read_csv(file, comment="#")
for variable in YIELD_VARIABLES:
if f"{variable}_lo" in table.columns:
return table, variable
raise ValueError(f"{file}: no column z_lo, e_beam_lo or ecm_lo (see extract_yields.py for the format)")
def in_yield_units(variable, k, ecm_range, z_range):
"""Convert a bin, given both as an E_cm range and a z range, into the units of the yield's variable."""
if variable == "ecm":
return ecm_range
if variable == "e_beam":
return ecm_range[0] / k, ecm_range[1] / k
return z_range
def fraction_inside(src_lo, src_hi, lo, hi):
"""Fraction of each yield bin [src_lo, src_hi) that lies inside [lo, hi). Used to share the yield's bins
between the analysis bins when their edges do not line up."""
return np.clip(np.minimum(src_hi, hi) - np.maximum(src_lo, lo), 0, None) / (src_hi - src_lo)
def yields_excitation(table, variable, t, k):
"""Yield and its error in each E_cm bin."""
if "theta_cm_lo" in table.columns: # a 2D yield: add up the theta bins first
table = table.groupby([f"{variable}_lo", f"{variable}_hi"], as_index=False)[["counts", "variance"]].sum()
src_lo, src_hi = table[f"{variable}_lo"].to_numpy(), table[f"{variable}_hi"].to_numpy()
lo, hi = in_yield_units(variable, k, (t["ecm_lo_mev"], t["ecm_hi_mev"]), (t["z_up_mm"], t["z_down_mm"]))
counts, errors = [], []
for a, b in zip(lo, hi):
f = fraction_inside(src_lo, src_hi, a, b)
counts.append(np.sum(f * table["counts"]))
errors.append(np.sqrt(np.sum(f ** 2 * table["variance"])))
return np.array(counts), np.array(errors)
def yields_angular(table, variable, t, window, k):
"""Yield and its error in each theta_cm bin, for the part of the yield inside the energy (or z) window."""
if "theta_cm_lo" not in table.columns:
raise ValueError("angular mode needs a 2D yield (theta_cm_lo/hi columns): set angle_axis or angle_branch in [extract]")
ecm_lo, ecm_hi, z_up, z_down = window
w_lo, w_hi = in_yield_units(variable, k, (ecm_lo, ecm_hi), (z_up, z_down))
in_window = fraction_inside(table[f"{variable}_lo"].to_numpy(), table[f"{variable}_hi"].to_numpy(), w_lo, w_hi)
th_lo, th_hi = table["theta_cm_lo"].to_numpy(), table["theta_cm_hi"].to_numpy()
counts, errors = [], []
for a, b in zip(t["theta_lo_deg"], t["theta_hi_deg"]):
f = in_window * fraction_inside(th_lo, th_hi, a, b)
counts.append(np.sum(f * table["counts"]))
errors.append(np.sqrt(np.sum(f ** 2 * table["variance"])))
return np.array(counts), np.array(errors)
# ============================================================ efficiency
def efficiency(config, det, t, full_path):
"""eps and its relative error for every bin. [acceptance] source =
"geometric": <out_dir>/eps_<det>.csv from geometric_efficiency.py; `column` picks the efficiency
(default eps_geometric), its Monte Carlo error comes with it
"csv": any file: csv, x_col, eps_col, match = "bin" (same bin edge: our column `key` = their x_col)
or "interp" (interpolate at our column `key`; NaN outside the file's range), rel_err_col or rel_err
"value": one number for every bin (value, rel_err)
"none": eps = 1, the apparent cross section (the default)
[[acceptance.extra]] name, value, rel_err: further factors, multiplied in (e.g. an intrinsic efficiency)."""
eff = config.get("acceptance", {})
mode = config.get("mode", "excitation")
source = eff.get("source", "none")
n_bins = len(t)
eps, rel_err = np.ones(n_bins), np.zeros(n_bins)
text = "none (eps = 1: apparent cross section)"
if source == "geometric":
column = eff.get("column", "eps_geometric")
file = common.output_file(config, full_path, "eps", det)
eff = {"csv": str(file), "match": "bin", "eps_col": column, "rel_err_col": f"{column}_rel_err",
"x_col": "ecm_lo_mev" if mode == "excitation" else "theta_lo_deg", "extra": eff.get("extra", [])}
source = "csv"
if source == "csv":
table = pd.read_csv(full_path(eff["csv"]), comment="#")
match = eff.get("match", "interp")
default_key = {("excitation", "bin"): "ecm_lo_mev", ("excitation", "interp"): "ecm_nominal_mev",
("angular", "bin"): "theta_lo_deg", ("angular", "interp"): "theta_c_deg"}[(mode, match)]
key = eff.get("key", default_key)
their_x = table[eff["x_col"]].to_numpy(float)
their_eps = table[eff["eps_col"]].to_numpy(float)
if "rel_err_col" in eff:
their_err = table[eff["rel_err_col"]].to_numpy(float)
else:
their_err = np.full(len(table), eff.get("rel_err", 0.0))
ours = t[key].to_numpy(float)
if match == "bin":
for i, x in enumerate(ours):
same = np.flatnonzero(np.isclose(their_x, x, rtol=1e-6, atol=1e-9))
eps[i] = their_eps[same[0]] if len(same) else np.nan
rel_err[i] = their_err[same[0]] if len(same) else np.nan
else:
order = np.argsort(their_x)
eps = np.interp(ours, their_x[order], their_eps[order], left=np.nan, right=np.nan)
rel_err = np.interp(ours, their_x[order], their_err[order], left=np.nan, right=np.nan)
text = f"{eff['csv']}, column {eff['eps_col']} ({match} on {eff['x_col']} = our {key})"
elif source == "value":
eps = np.full(n_bins, float(eff["value"]))
rel_err = np.full(n_bins, float(eff.get("rel_err", 0.0)))
text = f"constant {eff['value']}"
elif source != "none":
raise ValueError(f"[acceptance] source must be geometric, csv, value or none, not {source!r}")
for extra in eff.get("extra", []):
eps = eps * extra["value"]
rel_err = np.hypot(rel_err, extra.get("rel_err", 0.0))
text += f" x {extra.get('name', 'extra factor')} {extra['value']}"
return eps, rel_err, text
# ============================================================ cross section
def cross_section(t, n_b, n, eps, eps_rel_err, name):
"""The formula. `name` is "sigma_mb" (excitation) or "dsdo_mb_sr" (angular). Adds columns to t:
<name>_app, <name>_app_err apparent cross section (eps = 1), statistical error
<name>, <name>_err_stat divided by eps
<name>_err_eps, <name>_err error from eps; total (stat and eps in quadrature)
The N_b and n uncertainties are a common scale for all bins and are NOT in these columns."""
exposure = n_b * n * t["dz_mm"] * t["d_omega_sr"] * CM2_PER_MB # events per mb (per mb/sr)
exposure = exposure.where(exposure > 0) # dz = 0 (past the stopping point) -> NaN
eps = pd.Series(eps, index=t.index).where(lambda e: e > 0) # eps = 0 -> NaN
t[f"{name}_app"] = t["counts"] / exposure
t[f"{name}_app_err"] = t["stat_err"] / exposure
t["eps"] = eps
t["eps_rel_err"] = eps_rel_err
t[name] = t[f"{name}_app"] / eps
t[f"{name}_err_stat"] = t[f"{name}_app_err"] / eps
t[f"{name}_err_eps"] = t[name] * t["eps_rel_err"]
t[f"{name}_err"] = np.hypot(t[f"{name}_err_stat"], t[f"{name}_err_eps"])
return t
def add_references(t, references, det, x_col, full_path):
"""Reference curves ([[reference]]: csv, x_col, y_col, label, optional scale and dets) interpolated at our x
(NaN outside their range). Returns the curves for the plot."""
curves = []
for ref in references or []:
if "dets" in ref and det not in ref["dets"]:
continue
table = pd.read_csv(full_path(ref["csv"]), comment="#").sort_values(ref["x_col"])
x = table[ref["x_col"]].to_numpy(float)
y = ref.get("scale", 1.0) * table[ref["y_col"]].to_numpy(float)
label = ref.get("label", Path(ref["csv"]).stem)
column = "ref_" + re.sub(r"\W+", "_", label).strip("_").lower()
t[column] = np.interp(t[x_col], x, y, left=np.nan, right=np.nan)
curves.append((label, x, y))
return curves
def analyse_detector(config, det, full_path):
"""Steps 1-6 for one detector. Returns the result table, its header lines, the x column, the cross-section
column name and the reference curves."""
mode = config.get("mode", "excitation")
k = common.ecm_per_beam_energy(config["reaction"])
# 1. beam track
track = common.load_beam_track(config["beam"], full_path)
# 2. normalisation
n_b, n_b_rel_err, n_b_text = number_of_beam_particles(config["beam"])
n, n_rel_err, n_text = target_atoms_per_cm2_per_mm(config["target"])
# 3. bins and 4. yields
yield_file = full_path(config["yield"]["csv"]) if "csv" in config.get("yield", {}) \
else common.output_file(config, full_path, "yields", det)
yields, variable = read_yields(yield_file)
t, window = common.make_bins(config, track, k)
if mode == "excitation":
t["counts"], t["stat_err"] = yields_excitation(yields, variable, t, k)
name, x_col, window_text = "sigma_mb", "ecm_nominal_mev", ""
else:
t["counts"], t["stat_err"] = yields_angular(yields, variable, t, window, k)
name, x_col = "dsdo_mb_sr", "theta_c_deg"
ecm_lo, ecm_hi, z_up, z_down = window
window_text = f"; window E_cm {ecm_lo:.4g} to {ecm_hi:.4g} MeV, z {z_up:.2f} to {z_down:.2f} mm"
# 5. efficiency and 6. cross section
eps, eps_rel_err, eps_text = efficiency(config, det, t, full_path)
t = cross_section(t, n_b, n, eps, eps_rel_err, name)
curves = add_references(t, config.get("reference"), det, x_col, full_path)
header = [
f"{config.get('title', '')} {det.upper()}: {mode} mode{window_text}",
f"yield: {yield_file} (binned in {variable})",
f"beam track: {track.description}; E_cm = {k:.6g} x E_beam",
f"N_b = {n_b:.6g} ({n_b_text}); n = {n:.6g} target atoms per cm2 per mm ({n_text})",
f"eps: {eps_text}",
f"common scale uncertainty from N_b and n: {100 * np.hypot(n_b_rel_err, n_rel_err):.3g} % (not in the error columns)",
f"written by cross_section.py on {datetime.date.today().isoformat()}",
]
return t, header, x_col, name, curves
def make_plot(results, config, out_file):
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
opts = config.get("plot", {})
excitation = config.get("mode", "excitation") == "excitation"
fig, ax = plt.subplots(figsize=opts.get("figsize", (9, 6)))
plotted_refs = set()
for det, (t, x_col, name, curves) in results.items():
ok = np.isfinite(t[name]) & (t[name] > 0)
ax.errorbar(t.loc[ok, x_col], t.loc[ok, name], yerr=t.loc[ok, f"{name}_err"],
fmt="o", ms=4, capsize=2, label=det.upper() or "data")
for label, x, y in curves:
if label not in plotted_refs:
ax.plot(x, y, "-", lw=1.2, label=label)
plotted_refs.add(label)
ax.set_xlabel(opts.get("xlabel", "E$_\\mathrm{cm}$ (MeV)" if excitation else "$\\theta_\\mathrm{cm}$ (deg)"))
ax.set_ylabel(opts.get("ylabel", "$\\sigma$ (mb)" if excitation else "d$\\sigma$/d$\\Omega$ (mb/sr)"))
ax.set_yscale(opts.get("yscale", "log"))
if "xlim" in opts:
ax.set_xlim(*opts["xlim"])
if "ylim" in opts:
ax.set_ylim(*opts["ylim"])
ax.set_title(config.get("title", ""))
ax.legend(fontsize=8)
fig.tight_layout()
fig.savefig(out_file, dpi=110)
plt.close(fig)
def main(argv=None):
parser = argparse.ArgumentParser(description="Step 3: cross section from the yields CSV and an efficiency.")
parser.add_argument("config", help="TOML config file")
args = parser.parse_args(argv)
config, full_path = common.read_config(args.config)
results = {}
for det in config.get("dets", [""]):
print(f"--- {det or 'data'}")
det_config = common.config_for_detector(config, det)
t, header, x_col, name, curves = analyse_detector(det_config, det, full_path)
common.write_csv(t, header, common.output_file(config, full_path, "xs", det))
results[det] = (t, x_col, name, curves)
print("\n".join(header[:-1]))
show = [x_col, "dz_mm" if name == "sigma_mb" else "d_omega_sr", "counts", f"{name}_app", "eps", name,
f"{name}_err"] + [c for c in t.columns if c.startswith("ref_")]
print(t[show].to_string(index=False, float_format=lambda v: f"{v:.4g}"), "\n")
out_png = common.output_file(config, full_path, "xs", "").with_suffix(".png")
make_plot(results, config, out_png)
print(f"written to {out_png.parent}")
return results
if __name__ == "__main__":
main()