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:
2026-07-02 10:33:28 +03:00
parent 406fd57bcd
commit 876e1e3ab8
48 changed files with 5213 additions and 97 deletions
+277 -78
View File
@@ -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