From 62bf7fdac2d95da8c85ad295f2fef1f3c49d48b0 Mon Sep 17 00:00:00 2001 From: Evgeniy Unzhakov Date: Thu, 1 Oct 2026 23:02:33 +0300 Subject: [PATCH] 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. --- beta_spectrum/output/csv_export.py | 22 +++++++++++----------- tests/physics/test_core_spectrum.py | 19 +++++++++++++++++-- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/beta_spectrum/output/csv_export.py b/beta_spectrum/output/csv_export.py index ec92c6b..2648b03 100644 --- a/beta_spectrum/output/csv_export.py +++ b/beta_spectrum/output/csv_export.py @@ -19,7 +19,7 @@ Column semantics (v0.4.0 onward): from __future__ import annotations from datetime import datetime, timezone -from typing import TYPE_CHECKING, Dict, Optional +from typing import TYPE_CHECKING, Dict import numpy as np import pandas as pd @@ -179,16 +179,17 @@ class CSVEmitter: "energy_MeV": analyzer.energies_MeV, } - integral: Optional[float] = None + # Positive-integral guard applies to BOTH normalization modes: a + # degenerate spectrum must never be written (not even as zeros). + total_raw = analyzer.spectrum(analyzer.W) + integral = float(np.trapezoid(total_raw, analyzer.energies_MeV)) + if integral <= 0.0: + raise ValueError( + "Total spectrum integral is non-positive: every branch " + "is degenerate (check neutrino_mass vs branch endpoints " + "and intensity_cutoff)" + ) if not to_one: - total_raw = analyzer.spectrum(analyzer.W) - integral = float(np.trapezoid(total_raw, analyzer.energies_MeV)) - if integral <= 0.0: - raise ValueError( - "Total spectrum integral is non-positive: every branch " - "is degenerate (check neutrino_mass vs branch endpoints " - "and intensity_cutoff)" - ) data["total_spectrum"] = total_raw / integral for i in range(n_branches): @@ -205,7 +206,6 @@ class CSVEmitter: else: data[col_name] = np.zeros_like(physical[i]) else: - assert integral is not None intensity_i = analyzer._unified_branches[i].intensity data[col_name] = physical[i] * intensity_i / integral diff --git a/tests/physics/test_core_spectrum.py b/tests/physics/test_core_spectrum.py index f9f8ed9..08b0f4f 100644 --- a/tests/physics/test_core_spectrum.py +++ b/tests/physics/test_core_spectrum.py @@ -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.""" return SpectrumConfig( Z_parent=43, @@ -75,6 +75,7 @@ def make_degenerate_config() -> SpectrumConfig: endpoint_MeV=0.294, e_step_MeV=0.001, neutrino_mass_MeV=0.35, + **over, branches=[ BranchConfig(endpoint_MeV=0.294, transition_type="F2", intensity=0.98), BranchConfig(endpoint_MeV=0.200, transition_type="F2U", intensity=0.02), @@ -329,8 +330,22 @@ class TestCSVExportCoreSemantics: spectrum = BetaSpectrum.from_config(config) analyzer = BetaSpectrumAnalyzer(spectrum, config) path = str(tmp_path / "degenerate.csv") - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="non-positive"): 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: config = make_multi_config()