Add massive neutrino support and improve CSV/CLI output
- Add neutrino mass parameter to SpectrumConfig and CLI interface - Update PhaseSpace component to handle neutrino mass effects properly - Update CSV headers to show basic endpoint and all transition types - Add --csv-verbosity option to CLI with 'full' and 'concise' options - Fix comparison plots to properly show linear scale variations - Add comprehensive tests for neutrino mass functionality
This commit is contained in:
@@ -107,7 +107,6 @@ class BetaSpectrumAnalyzer:
|
||||
|
||||
return total
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unified branch view
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -162,17 +161,42 @@ class BetaSpectrumAnalyzer:
|
||||
f"| ΔE={e_step_keV:.1f} keV"
|
||||
)
|
||||
fig.text(
|
||||
0.01, 0.98, header,
|
||||
transform=fig.transFigure, fontsize=9,
|
||||
verticalalignment="top", horizontalalignment="left",
|
||||
0.01,
|
||||
0.98,
|
||||
header,
|
||||
transform=fig.transFigure,
|
||||
fontsize=9,
|
||||
verticalalignment="top",
|
||||
horizontalalignment="left",
|
||||
fontfamily="monospace",
|
||||
)
|
||||
|
||||
# Add neutrino mass info to header if non-zero
|
||||
if self.config.neutrino_mass_MeV > 0:
|
||||
nu_mass_text = f"| m_ν={self.config.neutrino_mass_MeV:.6f} MeV"
|
||||
fig.text(
|
||||
0.01,
|
||||
0.96,
|
||||
nu_mass_text,
|
||||
transform=fig.transFigure,
|
||||
fontsize=8,
|
||||
verticalalignment="top",
|
||||
horizontalalignment="left",
|
||||
fontfamily="monospace",
|
||||
color="red",
|
||||
)
|
||||
|
||||
id_text = f"{get_git_short_hash(6)} | {self._timestamp}"
|
||||
fig.text(
|
||||
0.01, 0.01, id_text,
|
||||
transform=fig.transFigure, fontsize=8,
|
||||
verticalalignment="bottom", horizontalalignment="left",
|
||||
fontfamily="monospace", color="gray",
|
||||
0.01,
|
||||
0.01,
|
||||
id_text,
|
||||
transform=fig.transFigure,
|
||||
fontsize=8,
|
||||
verticalalignment="bottom",
|
||||
horizontalalignment="left",
|
||||
fontfamily="monospace",
|
||||
color="gray",
|
||||
)
|
||||
|
||||
def _build_figure_title(self, fig: "Figure") -> str:
|
||||
@@ -190,7 +214,7 @@ class BetaSpectrumAnalyzer:
|
||||
branch = self._unified_branches[i]
|
||||
e0 = branch.endpoint_MeV * 1000
|
||||
intensity_pct = branch.intensity * 100
|
||||
return f"Br. {i+1}: {e0:.1f} keV ({intensity_pct:.1f}%)"
|
||||
return f"Br. {i + 1}: {e0:.1f} keV ({intensity_pct:.1f}%)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plotting — unified multi-branch approach
|
||||
@@ -231,9 +255,16 @@ class BetaSpectrumAnalyzer:
|
||||
fig, ax = plt.subplots(figsize=(12, 7))
|
||||
|
||||
branch_colors = [
|
||||
"#1f77b4", "#ff7f0e", "#2ca02c", "#d62728",
|
||||
"#9467bd", "#8c564b", "#e377c2", "#7f7f7f",
|
||||
"#bcbd22", "#17becf",
|
||||
"#1f77b4",
|
||||
"#ff7f0e",
|
||||
"#2ca02c",
|
||||
"#d62728",
|
||||
"#9467bd",
|
||||
"#8c564b",
|
||||
"#e377c2",
|
||||
"#7f7f7f",
|
||||
"#bcbd22",
|
||||
"#17becf",
|
||||
]
|
||||
|
||||
e_step_keV = self.config.e_step_MeV * 1000
|
||||
@@ -242,8 +273,11 @@ class BetaSpectrumAnalyzer:
|
||||
branch_contribs = []
|
||||
# Universal components are shared (no per-branch key prefix)
|
||||
universal_names = [
|
||||
"Fermi", "Screening", "FiniteSizeL0",
|
||||
"ChargeDistributionU", "Exchange",
|
||||
"Fermi",
|
||||
"Screening",
|
||||
"FiniteSizeL0",
|
||||
"ChargeDistributionU",
|
||||
"Exchange",
|
||||
]
|
||||
for i in range(self._n_branches):
|
||||
universal_product = np.ones_like(self.W)
|
||||
@@ -260,28 +294,33 @@ class BetaSpectrumAnalyzer:
|
||||
total_raw = sum(branch_contribs)
|
||||
|
||||
ax.plot(
|
||||
self.energies_MeV, total_raw, "k-", lw=2.5,
|
||||
label="Total spectrum", zorder=10,
|
||||
self.energies_MeV,
|
||||
total_raw,
|
||||
"k-",
|
||||
lw=2.5,
|
||||
label="Total spectrum",
|
||||
zorder=10,
|
||||
)
|
||||
|
||||
for i, contrib in enumerate(branch_contribs):
|
||||
color = branch_colors[i % len(branch_colors)]
|
||||
ax.plot(
|
||||
self.energies_MeV, contrib, color=color, lw=1.5,
|
||||
alpha=0.7, label=self._format_branch_label(i),
|
||||
self.energies_MeV,
|
||||
contrib,
|
||||
color=color,
|
||||
lw=1.5,
|
||||
alpha=0.7,
|
||||
label=self._format_branch_label(i),
|
||||
)
|
||||
|
||||
ax.set_xlabel(r"Electron kinetic energy $E$ [MeV]", fontsize=12)
|
||||
ax.set_ylabel(
|
||||
f"Normalized Counts / {e_step_keV:.1f} keV", fontsize=12
|
||||
)
|
||||
fig.suptitle(
|
||||
self._build_figure_title(fig), fontsize=14, fontweight="bold"
|
||||
)
|
||||
ax.set_ylabel(f"Normalized Counts / {e_step_keV:.1f} keV", fontsize=12)
|
||||
fig.suptitle(self._build_figure_title(fig), fontsize=14, fontweight="bold")
|
||||
ax.set_yscale("log")
|
||||
ax.grid(True, alpha=0.3)
|
||||
ax.legend(
|
||||
loc="best", fontsize=9,
|
||||
loc="best",
|
||||
fontsize=9,
|
||||
ncol=min(2, self._n_branches + 1),
|
||||
)
|
||||
|
||||
@@ -304,21 +343,29 @@ class BetaSpectrumAnalyzer:
|
||||
e_step_keV = self.config.e_step_MeV * 1000
|
||||
|
||||
fig, axes = plt.subplots(4, 1, figsize=(12, 16), sharex=True)
|
||||
fig.suptitle(
|
||||
self._build_figure_title(fig), fontsize=14, fontweight="bold"
|
||||
)
|
||||
fig.suptitle(self._build_figure_title(fig), fontsize=14, fontweight="bold")
|
||||
|
||||
branch_colors = [
|
||||
"#1f77b4", "#ff7f0e", "#2ca02c", "#d62728",
|
||||
"#9467bd", "#8c564b", "#e377c2", "#7f7f7f",
|
||||
"#bcbd22", "#17becf",
|
||||
"#1f77b4",
|
||||
"#ff7f0e",
|
||||
"#2ca02c",
|
||||
"#d62728",
|
||||
"#9467bd",
|
||||
"#8c564b",
|
||||
"#e377c2",
|
||||
"#7f7f7f",
|
||||
"#bcbd22",
|
||||
"#17becf",
|
||||
]
|
||||
|
||||
# Panel 1: Total spectrum + per-branch spectra
|
||||
branch_contribs = []
|
||||
universal_names = [
|
||||
"Fermi", "Screening", "FiniteSizeL0",
|
||||
"ChargeDistributionU", "Exchange",
|
||||
"Fermi",
|
||||
"Screening",
|
||||
"FiniteSizeL0",
|
||||
"ChargeDistributionU",
|
||||
"Exchange",
|
||||
]
|
||||
for i in range(n_branches):
|
||||
universal_product = np.ones_like(self.W)
|
||||
@@ -336,19 +383,25 @@ class BetaSpectrumAnalyzer:
|
||||
|
||||
ax = axes[0]
|
||||
ax.plot(
|
||||
self.energies_MeV, total_raw, "k-", lw=2,
|
||||
label="Total spectrum", zorder=10,
|
||||
self.energies_MeV,
|
||||
total_raw,
|
||||
"k-",
|
||||
lw=2,
|
||||
label="Total spectrum",
|
||||
zorder=10,
|
||||
)
|
||||
for i, contrib in enumerate(branch_contribs):
|
||||
color = branch_colors[i % len(branch_colors)]
|
||||
ax.plot(
|
||||
self.energies_MeV, contrib, color=color, lw=1.5,
|
||||
alpha=0.7, label=self._format_branch_label(i),
|
||||
self.energies_MeV,
|
||||
contrib,
|
||||
color=color,
|
||||
lw=1.5,
|
||||
alpha=0.7,
|
||||
label=self._format_branch_label(i),
|
||||
)
|
||||
ax.set_yscale("log")
|
||||
ax.set_ylabel(
|
||||
f"Normalized Counts / {e_step_keV:.1f} keV", fontsize=10
|
||||
)
|
||||
ax.set_ylabel(f"Normalized Counts / {e_step_keV:.1f} keV", fontsize=10)
|
||||
ax.set_title("Spectrum", fontsize=11)
|
||||
ax.grid(True, alpha=0.3)
|
||||
ax.legend(fontsize=8, loc="best", ncol=min(2, n_branches + 1))
|
||||
@@ -359,8 +412,12 @@ class BetaSpectrumAnalyzer:
|
||||
fermi_vals = components["Fermi"]
|
||||
fermi_scaled = fermi_vals / np.max(fermi_vals)
|
||||
ax.plot(
|
||||
self.energies_MeV, fermi_scaled, "r-", lw=1.5,
|
||||
label="Fermi", alpha=0.8,
|
||||
self.energies_MeV,
|
||||
fermi_scaled,
|
||||
"r-",
|
||||
lw=1.5,
|
||||
label="Fermi",
|
||||
alpha=0.8,
|
||||
)
|
||||
for i in range(n_branches):
|
||||
color = branch_colors[i % len(branch_colors)]
|
||||
@@ -377,8 +434,12 @@ class BetaSpectrumAnalyzer:
|
||||
else:
|
||||
ps_scaled = ps_masked
|
||||
ax.plot(
|
||||
self.energies_MeV, ps_scaled, color=color, lw=1.5,
|
||||
alpha=0.7, label=f"PS Br. {i+1}",
|
||||
self.energies_MeV,
|
||||
ps_scaled,
|
||||
color=color,
|
||||
lw=1.5,
|
||||
alpha=0.7,
|
||||
label=f"PS Br. {i + 1}",
|
||||
)
|
||||
ax.set_ylabel("Normalized Factor", fontsize=10)
|
||||
ax.set_title("Fermi & Phase Space", fontsize=11)
|
||||
@@ -388,8 +449,10 @@ class BetaSpectrumAnalyzer:
|
||||
# Panel 3: Universal W0-independent corrections
|
||||
ax = axes[2]
|
||||
universal_components = [
|
||||
"Screening", "FiniteSizeL0",
|
||||
"ChargeDistributionU", "Exchange",
|
||||
"Screening",
|
||||
"FiniteSizeL0",
|
||||
"ChargeDistributionU",
|
||||
"Exchange",
|
||||
]
|
||||
colors = ["green", "orange", "purple", "brown"]
|
||||
for name, color in zip(universal_components, colors):
|
||||
@@ -397,8 +460,12 @@ class BetaSpectrumAnalyzer:
|
||||
values = components[name]
|
||||
values_scaled = values / np.max(values)
|
||||
ax.plot(
|
||||
self.energies_MeV, values_scaled, color=color, lw=1.5,
|
||||
label=name, alpha=0.8,
|
||||
self.energies_MeV,
|
||||
values_scaled,
|
||||
color=color,
|
||||
lw=1.5,
|
||||
label=name,
|
||||
alpha=0.8,
|
||||
)
|
||||
ax.set_ylabel("Normalized Factor", fontsize=10)
|
||||
ax.set_title("Universal Corrections", fontsize=11)
|
||||
@@ -413,8 +480,12 @@ class BetaSpectrumAnalyzer:
|
||||
if key in components:
|
||||
rad_vals = components[key]
|
||||
ax.plot(
|
||||
self.energies_MeV, rad_vals, color=color, lw=1.5,
|
||||
alpha=0.7, label=f"Br. {i+1}",
|
||||
self.energies_MeV,
|
||||
rad_vals,
|
||||
color=color,
|
||||
lw=1.5,
|
||||
alpha=0.7,
|
||||
label=f"Br. {i + 1}",
|
||||
)
|
||||
ax.set_ylabel("Radiative Correction", fontsize=10)
|
||||
ax.set_xlabel(r"Electron kinetic energy $E$ [MeV]", fontsize=10)
|
||||
@@ -433,16 +504,106 @@ class BetaSpectrumAnalyzer:
|
||||
def _element_symbol(self, Z: int) -> str:
|
||||
"""Return chemical symbol for atomic number Z."""
|
||||
symbols = [
|
||||
"", "H", "He", "Li", "Be", "B", "C", "N", "O", "F",
|
||||
"Ne", "Na", "Mg", "Al", "Si", "P", "S", "Cl", "Ar", "K",
|
||||
"Ca", "Sc", "Ti", "V", "Cr", "Mn", "Fe", "Co", "Ni", "Cu",
|
||||
"Zn", "Ga", "Ge", "As", "Se", "Br", "Kr", "Rb", "Sr", "Y",
|
||||
"Zr", "Nb", "Mo", "Tc", "Ru", "Rh", "Pd", "Ag", "Cd", "In",
|
||||
"Sn", "Sb", "Te", "I", "Xe", "Cs", "Ba", "La", "Ce", "Pr",
|
||||
"Nd", "Pm", "Sm", "Eu", "Gd", "Tb", "Dy", "Ho", "Er", "Tm",
|
||||
"Yb", "Lu", "Hf", "Ta", "W", "Re", "Os", "Ir", "Pt", "Au",
|
||||
"Hg", "Tl", "Pb", "Bi", "Po", "At", "Rn", "Fr", "Ra", "Ac",
|
||||
"Th", "Pa", "U", "Np", "Pu", "Am", "Cm", "Bk", "Cf", "Es",
|
||||
"",
|
||||
"H",
|
||||
"He",
|
||||
"Li",
|
||||
"Be",
|
||||
"B",
|
||||
"C",
|
||||
"N",
|
||||
"O",
|
||||
"F",
|
||||
"Ne",
|
||||
"Na",
|
||||
"Mg",
|
||||
"Al",
|
||||
"Si",
|
||||
"P",
|
||||
"S",
|
||||
"Cl",
|
||||
"Ar",
|
||||
"K",
|
||||
"Ca",
|
||||
"Sc",
|
||||
"Ti",
|
||||
"V",
|
||||
"Cr",
|
||||
"Mn",
|
||||
"Fe",
|
||||
"Co",
|
||||
"Ni",
|
||||
"Cu",
|
||||
"Zn",
|
||||
"Ga",
|
||||
"Ge",
|
||||
"As",
|
||||
"Se",
|
||||
"Br",
|
||||
"Kr",
|
||||
"Rb",
|
||||
"Sr",
|
||||
"Y",
|
||||
"Zr",
|
||||
"Nb",
|
||||
"Mo",
|
||||
"Tc",
|
||||
"Ru",
|
||||
"Rh",
|
||||
"Pd",
|
||||
"Ag",
|
||||
"Cd",
|
||||
"In",
|
||||
"Sn",
|
||||
"Sb",
|
||||
"Te",
|
||||
"I",
|
||||
"Xe",
|
||||
"Cs",
|
||||
"Ba",
|
||||
"La",
|
||||
"Ce",
|
||||
"Pr",
|
||||
"Nd",
|
||||
"Pm",
|
||||
"Sm",
|
||||
"Eu",
|
||||
"Gd",
|
||||
"Tb",
|
||||
"Dy",
|
||||
"Ho",
|
||||
"Er",
|
||||
"Tm",
|
||||
"Yb",
|
||||
"Lu",
|
||||
"Hf",
|
||||
"Ta",
|
||||
"W",
|
||||
"Re",
|
||||
"Os",
|
||||
"Ir",
|
||||
"Pt",
|
||||
"Au",
|
||||
"Hg",
|
||||
"Tl",
|
||||
"Pb",
|
||||
"Bi",
|
||||
"Po",
|
||||
"At",
|
||||
"Rn",
|
||||
"Fr",
|
||||
"Ra",
|
||||
"Ac",
|
||||
"Th",
|
||||
"Pa",
|
||||
"U",
|
||||
"Np",
|
||||
"Pu",
|
||||
"Am",
|
||||
"Cm",
|
||||
"Bk",
|
||||
"Cf",
|
||||
"Es",
|
||||
]
|
||||
return symbols[Z] if Z < len(symbols) else f"Z{Z}"
|
||||
|
||||
@@ -450,8 +611,20 @@ class BetaSpectrumAnalyzer:
|
||||
# Export
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def export_to_csv(self, filename: str, source_type: str = "unknown") -> None:
|
||||
"""Export spectrum data to CSV with metadata header."""
|
||||
def export_to_csv(
|
||||
self, filename: str, source_type: str = "unknown", csv_verbosity: str = "full"
|
||||
) -> None:
|
||||
"""Export spectrum data to CSV with metadata header.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
filename : str
|
||||
Output CSV file path.
|
||||
source_type : str
|
||||
Source type (paceENSDF, json, etc.).
|
||||
csv_verbosity : str
|
||||
Verbosity level: 'full' (all components) or 'concise' (total spectrum and normalized branch components only).
|
||||
"""
|
||||
total = self.total_spectrum(normalize=True)
|
||||
components = self.components
|
||||
|
||||
@@ -479,16 +652,32 @@ class BetaSpectrumAnalyzer:
|
||||
f"# timestamp: {datetime.now(timezone.utc).isoformat()}",
|
||||
f"# source: {source_type}",
|
||||
f"# nuclide: {parent_symbol}{self.config.A_number} -> {daughter_symbol}{self.config.A_number} (Z={self.config.Z_parent}->{self.config.Z_daughter})",
|
||||
f"# endpoint: {self.config.endpoint_MeV * 1000:.1f} keV",
|
||||
f"# transition: {self.config.transition_type}",
|
||||
f"# corrections: {', '.join(enabled)}",
|
||||
f"# e_step: {self.config.e_step_MeV:.4f} MeV",
|
||||
f"# basic_endpoint: {self.config.endpoint_MeV * 1000:.1f} keV",
|
||||
]
|
||||
|
||||
# Add neutrino mass info if non-zero
|
||||
if self.config.neutrino_mass_MeV > 0:
|
||||
header_lines.append(
|
||||
f"# neutrino_mass_MeV: {self.config.neutrino_mass_MeV:.6f} (non-zero neutrino mass effects included)"
|
||||
)
|
||||
else:
|
||||
header_lines.append(
|
||||
f"# neutrino_mass_MeV: {self.config.neutrino_mass_MeV:.6f}"
|
||||
)
|
||||
|
||||
header_lines.append(f"# corrections: {', '.join(enabled)}")
|
||||
header_lines.append(f"# e_step: {self.config.e_step_MeV:.4f} MeV")
|
||||
|
||||
# Add transition types for all branches
|
||||
transition_types = []
|
||||
for i, branch in enumerate(self._unified_branches):
|
||||
transition_types.append(f"branch_{i + 1}:{branch.transition_type}")
|
||||
header_lines.append(f"# transition_types: {'; '.join(transition_types)}")
|
||||
|
||||
# Branch info — unified view
|
||||
for i, branch in enumerate(self._unified_branches):
|
||||
header_lines.append(
|
||||
f"# branch_{i+1}: E₀={branch.endpoint_MeV*1000:.1f} keV, "
|
||||
f"# branch_{i + 1}: E₀={branch.endpoint_MeV * 1000:.1f} keV, "
|
||||
f"transition={branch.transition_type}, "
|
||||
f"intensity={branch.intensity:.4f}"
|
||||
)
|
||||
@@ -500,18 +689,27 @@ class BetaSpectrumAnalyzer:
|
||||
"spectrum": total,
|
||||
}
|
||||
|
||||
# Per-branch spectra and components
|
||||
for i in range(self._n_branches):
|
||||
data[f"branch_{i+1}_spectrum"] = self._get_branch_spectrum(i)
|
||||
for comp_name in ["PhaseSpace", "Radiative"]:
|
||||
key = self._get_component_key(i, comp_name)
|
||||
if key in components:
|
||||
data[f"branch_{i+1}_{comp_name}"] = components[key]
|
||||
if csv_verbosity == "full":
|
||||
# Per-branch spectra and components
|
||||
for i in range(self._n_branches):
|
||||
data[f"branch_{i + 1}_spectrum"] = self._get_branch_spectrum(i)
|
||||
for comp_name in ["PhaseSpace", "Radiative"]:
|
||||
key = self._get_component_key(i, comp_name)
|
||||
if key in components:
|
||||
data[f"branch_{i + 1}_{comp_name}"] = components[key]
|
||||
|
||||
# Universal components (no prefix)
|
||||
for name, values in components.items():
|
||||
if not name.startswith("branch_"):
|
||||
data[name] = values
|
||||
# Universal components (no prefix)
|
||||
for name, values in components.items():
|
||||
if not name.startswith("branch_"):
|
||||
data[name] = values
|
||||
elif csv_verbosity == "concise":
|
||||
# Only total spectrum and normalized branch components
|
||||
for i in range(self._n_branches):
|
||||
data[f"branch_{i + 1}_spectrum_normalized"] = (
|
||||
self._get_branch_spectrum(i) / np.sum(self._get_branch_spectrum(i))
|
||||
if np.sum(self._get_branch_spectrum(i)) > 0
|
||||
else self._get_branch_spectrum(i)
|
||||
)
|
||||
|
||||
df = pd.DataFrame(data)
|
||||
df.to_csv(filename, index=False, float_format="%.4e")
|
||||
@@ -538,6 +736,7 @@ class BetaSpectrumAnalyzer:
|
||||
@dataclass
|
||||
class _UnifiedBranch:
|
||||
"""Unified branch view for both single- and multi-branch modes."""
|
||||
|
||||
endpoint_MeV: float
|
||||
transition_type: str
|
||||
intensity: float
|
||||
|
||||
Reference in New Issue
Block a user