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
+20 -1
View File
@@ -85,6 +85,12 @@ def _build_parser() -> argparse.ArgumentParser:
default=0.001,
help="Energy step size in MeV (default: 0.001). All energies are in MeV throughout the toolkit.",
)
parser.add_argument(
"--neutrino-mass",
type=float,
default=0.0,
help="Neutrino mass in MeV (default: 0.0). Set to non-zero value to include neutrino mass effects in phase space calculation.",
)
# Output
parser.add_argument(
@@ -139,6 +145,13 @@ def _build_parser() -> argparse.ArgumentParser:
"(default: 0.0 = include all branches). "
"Branches below this threshold are excluded from the calculation and plots.",
)
log_group.add_argument(
"--csv-verbosity",
type=str,
default="full",
choices=["full", "concise"],
help="CSV output verbosity: 'full' (all components) or 'concise' (total spectrum and normalized branch components only) (default: full)",
)
return parser
@@ -237,6 +250,7 @@ def _run(args: argparse.Namespace) -> None:
endpoint_MeV=decay_info.endpoint_MeV,
transition_type=decay_info.transition_type,
e_step_MeV=args.e_step,
neutrino_mass_MeV=args.neutrino_mass,
branches=branches,
intensity_cutoff=args.intensity_cutoff,
)
@@ -246,6 +260,7 @@ def _run(args: argparse.Namespace) -> None:
nuclide=source_value,
e_step_MeV=args.e_step,
intensity_cutoff=args.intensity_cutoff,
neutrino_mass_MeV=args.neutrino_mass,
)
else:
config = create_config_from_source(
@@ -253,6 +268,7 @@ def _run(args: argparse.Namespace) -> None:
nuclide=source_value,
e_step_MeV=args.e_step,
intensity_cutoff=args.intensity_cutoff,
neutrino_mass_MeV=args.neutrino_mass,
)
else:
logger.info("Source: JSON file=%s", source_value)
@@ -288,7 +304,9 @@ def _run(args: argparse.Namespace) -> None:
# Export to CSV
if args.output:
analyzer = BetaSpectrumAnalyzer(spectrum, config, logger=logger)
analyzer.export_to_csv(args.output, source_type=source_type)
analyzer.export_to_csv(
args.output, source_type=source_type, csv_verbosity=args.csv_verbosity
)
logger.info("Spectrum exported to %s", args.output)
# Generate plot
@@ -333,6 +351,7 @@ def _print_dry_run_output(config, source_type: str) -> None:
print(f"Endpoint: {config.endpoint_MeV * 1000:.1f} keV")
print(f"Transition: {config.transition_type}")
print(f"e_step: {config.e_step_MeV:.4f} MeV")
print(f"neutrino_mass_MeV: {config.neutrino_mass_MeV:.6f}")
print(f"Corrections: {', '.join(enabled) if enabled else 'none'}")
if config.branches and len(config.branches) > 1:
+22 -1
View File
@@ -50,10 +50,26 @@ class PhaseSpace(SpectrumComponent):
w_e = W
p_e = momentum(W, self.m_e)
w_nu = self.W0 - W
p_nu = momentum(w_nu, self.m_nu)
# Calculate p_nu, handling the case where w_nu < self.m_nu
# In this case, the neutrino energy is less than its mass, which is unphysical
# The phase space should be 0 in this region
if self.m_nu > 0:
# Check where w_nu < self.m_nu
below_threshold = w_nu < self.m_nu
# Calculate p_nu only where it's physically valid
p_nu = np.zeros_like(w_nu)
valid_mask = ~below_threshold
if np.any(valid_mask):
p_nu[valid_mask] = momentum(w_nu[valid_mask], self.m_nu)
else:
below_threshold = np.zeros_like(w_nu, dtype=bool)
p_nu = momentum(w_nu, self.m_nu)
# Calculate phase space factor
phase_space = p_e * w_e * p_nu * w_nu
# Calculate forbid factor
if self.tr_type in ["A", "F1"]:
forbid_factor: float | np.ndarray = 1
elif self.tr_type in ["F1U", "F2"]:
@@ -66,6 +82,11 @@ class PhaseSpace(SpectrumComponent):
)
result = np.asarray(phase_space * forbid_factor, dtype=np.float64)
# For regions below the neutrino mass threshold, the phase space should be 0
if self.m_nu > 0:
result[below_threshold] = 0.0
if self._logger:
self._logger.debug(
"PhaseSpace: output range=[%.6e, %.6e]",
+8
View File
@@ -470,6 +470,7 @@ def decay_info_to_config(
info: DecayInfo,
e_step_MeV: float = 0.001,
intensity_cutoff: float = 0.0,
neutrino_mass_MeV: float = 0.0,
) -> SpectrumConfig:
"""
Convert DecayInfo to a SpectrumConfig.
@@ -482,6 +483,8 @@ def decay_info_to_config(
Energy step size in MeV.
intensity_cutoff : float
Minimum branch intensity fraction to include.
neutrino_mass_MeV : float
Neutrino mass in MeV.
Returns
-------
@@ -510,6 +513,7 @@ def decay_info_to_config(
endpoint_MeV=info.endpoint_MeV,
transition_type=info.transition_type,
e_step_MeV=e_step_MeV,
neutrino_mass_MeV=neutrino_mass_MeV,
branches=branches,
intensity_cutoff=intensity_cutoff,
)
@@ -529,6 +533,7 @@ DEFAULT_JSON_SCHEMA = {
"A_number": 99,
"transition_type": "F2",
"e_step_MeV": 0.001,
"neutrino_mass_MeV": 0.0,
"detector_model": "gaussian",
"detector_sigma_a_keV": 1.0,
"detector_sigma_b": 0.0,
@@ -638,6 +643,7 @@ def json_to_config(data: Dict[str, Any]) -> SpectrumConfig:
endpoint_MeV=float(data["endpoint_MeV"]),
transition_type=str(data.get("transition_type", "A")),
e_step_MeV=float(data.get("e_step_MeV", 0.001)),
neutrino_mass_MeV=float(data.get("neutrino_mass_MeV", 0.0)),
)
@@ -683,11 +689,13 @@ def create_config_from_source(
decay_index = kwargs.get("decay_index", None)
e_step = kwargs.get("e_step_MeV", 0.001)
intensity_cutoff = kwargs.get("intensity_cutoff", 0.0)
neutrino_mass = kwargs.get("neutrino_mass_MeV", 0.0)
info = get_decay_info_from_paceENSDF(nuclide, decay_type, decay_index)
return decay_info_to_config(
info,
e_step_MeV=e_step,
intensity_cutoff=intensity_cutoff,
neutrino_mass_MeV=neutrino_mass,
)
elif source == "json":
if not json_path:
+9 -7
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
from typing import Dict, List, Optional, Tuple
from dataclasses import dataclass
import numpy as np
@@ -15,7 +15,6 @@ from beta_spectrum.components.screening import ScreeningCorrection
from beta_spectrum.components.exchange import ExchangeCorrection
from beta_spectrum.components.radiative import RadiativeCorrection
from beta_spectrum.utils import T_to_W
from beta_spectrum.constants import ME_MEV
@dataclass
@@ -71,6 +70,7 @@ class SpectrumConfig:
endpoint_MeV: float
transition_type: str = "A" # should be [A, F1, F1U, F2, F2U, F3, F3U, F4]
e_step_MeV: float = 0.001
neutrino_mass_MeV: float = 0.0
# Toggle components and corrections
use_phase_space: bool = True
@@ -251,7 +251,12 @@ class BetaSpectrum:
if config.use_phase_space:
components.append(
PhaseSpace(W0=W0, transition_type=config.transition_type, logger=logger)
PhaseSpace(
W0=W0,
transition_type=config.transition_type,
m_nu=config.neutrino_mass_MeV,
logger=logger,
)
)
if config.use_fermi:
@@ -331,6 +336,7 @@ class BetaSpectrum:
endpoint_MeV=branch.endpoint_MeV,
transition_type=branch.transition_type,
e_step_MeV=config.e_step_MeV,
neutrino_mass_MeV=config.neutrino_mass_MeV,
# Branch-dependent components
use_phase_space=config.use_phase_space,
use_radiative=config.use_radiative,
@@ -451,7 +457,3 @@ class BetaSpectrum:
branch_spectrum = branch_spec(W)
result.append(self.branches[i].intensity * np.maximum(0.0, branch_spectrum))
return result
+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