fix(output): apply positive-integral export guard to to_one mode

CSVEmitter.export_to_csv raised ValueError only in intensity mode;
degenerate exports with --normalize-to-one silently wrote all-zero
CSV files. The guard now runs before the mode split and no file is
written in either mode.
This commit is contained in:
2026-10-01 23:02:33 +03:00
parent ee7aedc80a
commit 62bf7fdac2
2 changed files with 28 additions and 13 deletions
+4 -4
View File
@@ -19,7 +19,7 @@ Column semantics (v0.4.0 onward):
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import TYPE_CHECKING, Dict, Optional from typing import TYPE_CHECKING, Dict
import numpy as np import numpy as np
import pandas as pd import pandas as pd
@@ -179,8 +179,8 @@ class CSVEmitter:
"energy_MeV": analyzer.energies_MeV, "energy_MeV": analyzer.energies_MeV,
} }
integral: Optional[float] = None # Positive-integral guard applies to BOTH normalization modes: a
if not to_one: # degenerate spectrum must never be written (not even as zeros).
total_raw = analyzer.spectrum(analyzer.W) total_raw = analyzer.spectrum(analyzer.W)
integral = float(np.trapezoid(total_raw, analyzer.energies_MeV)) integral = float(np.trapezoid(total_raw, analyzer.energies_MeV))
if integral <= 0.0: if integral <= 0.0:
@@ -189,6 +189,7 @@ class CSVEmitter:
"is degenerate (check neutrino_mass vs branch endpoints " "is degenerate (check neutrino_mass vs branch endpoints "
"and intensity_cutoff)" "and intensity_cutoff)"
) )
if not to_one:
data["total_spectrum"] = total_raw / integral data["total_spectrum"] = total_raw / integral
for i in range(n_branches): for i in range(n_branches):
@@ -205,7 +206,6 @@ class CSVEmitter:
else: else:
data[col_name] = np.zeros_like(physical[i]) data[col_name] = np.zeros_like(physical[i])
else: else:
assert integral is not None
intensity_i = analyzer._unified_branches[i].intensity intensity_i = analyzer._unified_branches[i].intensity
data[col_name] = physical[i] * intensity_i / integral data[col_name] = physical[i] * intensity_i / integral
+17 -2
View File
@@ -66,7 +66,7 @@ def make_single_config(**over: Any) -> SpectrumConfig:
) )
def make_degenerate_config() -> SpectrumConfig: def make_degenerate_config(**over: Any) -> SpectrumConfig:
"""Multi-branch config where every endpoint lies below m_nu.""" """Multi-branch config where every endpoint lies below m_nu."""
return SpectrumConfig( return SpectrumConfig(
Z_parent=43, Z_parent=43,
@@ -75,6 +75,7 @@ def make_degenerate_config() -> SpectrumConfig:
endpoint_MeV=0.294, endpoint_MeV=0.294,
e_step_MeV=0.001, e_step_MeV=0.001,
neutrino_mass_MeV=0.35, neutrino_mass_MeV=0.35,
**over,
branches=[ branches=[
BranchConfig(endpoint_MeV=0.294, transition_type="F2", intensity=0.98), BranchConfig(endpoint_MeV=0.294, transition_type="F2", intensity=0.98),
BranchConfig(endpoint_MeV=0.200, transition_type="F2U", intensity=0.02), BranchConfig(endpoint_MeV=0.200, transition_type="F2U", intensity=0.02),
@@ -329,8 +330,22 @@ class TestCSVExportCoreSemantics:
spectrum = BetaSpectrum.from_config(config) spectrum = BetaSpectrum.from_config(config)
analyzer = BetaSpectrumAnalyzer(spectrum, config) analyzer = BetaSpectrumAnalyzer(spectrum, config)
path = str(tmp_path / "degenerate.csv") path = str(tmp_path / "degenerate.csv")
with pytest.raises(ValueError): with pytest.raises(ValueError, match="non-positive"):
analyzer.export_to_csv(path) analyzer.export_to_csv(path)
assert not (tmp_path / "degenerate.csv").exists()
def test_degenerate_export_raises_in_normalize_to_one_mode(
self, tmp_path: Any
) -> None:
"""The positive-integral guard must apply in to_one mode too:
no all-zero CSV may be written for a degenerate spectrum."""
config = make_degenerate_config(normalize_to_one=True)
spectrum = BetaSpectrum.from_config(config)
analyzer = BetaSpectrumAnalyzer(spectrum, config)
path = str(tmp_path / "degenerate_to_one.csv")
with pytest.raises(ValueError, match="non-positive"):
analyzer.export_to_csv(path)
assert not (tmp_path / "degenerate_to_one.csv").exists()
def test_concise_values_match_full(self, tmp_path: Any) -> None: def test_concise_values_match_full(self, tmp_path: Any) -> None:
config = make_multi_config() config = make_multi_config()