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:
+20
-1
@@ -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:
|
||||
|
||||
@@ -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]",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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