From 36174b5d251461719a6a594d39e4bea66fa1d0e1 Mon Sep 17 00:00:00 2001 From: Evgeniy Unzhakov Date: Fri, 2 Oct 2026 10:21:07 +0300 Subject: [PATCH] refactor: remove stale detector schema keys and single-mode duplicates - DEFAULT_JSON_SCHEMA: drop the 8 detector_* keys left over from the removed detector_response module; CLI dry-run drops the dead 'Detector: not configured (Phase 4)' line - CSV export: single-mode no longer writes branch_1_PhaseSpace / branch_1_Radiative duplicates next to the bare PhaseSpace/Radiative columns; per-branch core factors only in multi-branch mode - Visualizer: debug panels fall back to the unprefixed core-factor columns for single-mode CSVs instead of skipping them - Spectrum ylabel now states the actual units: normalized spectrum [MeV^-1], not 'Normalized Counts / keV' - tests: defaults assert detector keys gone; column layout pinned for single vs multi; panel and ylabel assertions --- beta_spectrum/cli.py | 2 - beta_spectrum/nuclear_data.py | 8 --- beta_spectrum/output/csv_export.py | 18 ++++-- beta_spectrum/output/visualization.py | 12 ++-- tests/physics/test_core_spectrum.py | 82 +++++++++++++++++++++------ tests/quality/test_nuclear_data.py | 9 ++- 6 files changed, 92 insertions(+), 39 deletions(-) diff --git a/beta_spectrum/cli.py b/beta_spectrum/cli.py index a008666..41dd627 100644 --- a/beta_spectrum/cli.py +++ b/beta_spectrum/cli.py @@ -372,8 +372,6 @@ def _print_dry_run_output(config, source_type: str) -> None: else: print("Mode: single-branch") - # Detector response is a Phase 4 feature — not yet implemented - print("Detector: not configured (Phase 4)") print("========================================") diff --git a/beta_spectrum/nuclear_data.py b/beta_spectrum/nuclear_data.py index aad8929..dbb916a 100644 --- a/beta_spectrum/nuclear_data.py +++ b/beta_spectrum/nuclear_data.py @@ -554,14 +554,6 @@ DEFAULT_JSON_SCHEMA = { "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, - "detector_tail_fraction": 0.0, - "detector_tau_keV": 5.0, - "detector_fano_factor": 0.12, - "detector_n_channels": 4096, - "detector_channel_energy_range": [0.0, 0.35], } diff --git a/beta_spectrum/output/csv_export.py b/beta_spectrum/output/csv_export.py index 2648b03..0804f82 100644 --- a/beta_spectrum/output/csv_export.py +++ b/beta_spectrum/output/csv_export.py @@ -211,14 +211,20 @@ class CSVEmitter: if csv_verbosity == "full": # Per-branch component columns (factorized ingredients). - for i in range(n_branches): - for comp_name in ["PhaseSpace", "Radiative"]: - key = analyzer._get_component_key(i, comp_name) - if key in components: - data[f"branch_{i + 1}_{comp_name}"] = components[key] + # Only multi-branch mode carries per-branch factors: in single + # mode the bare PhaseSpace/Radiative columns below already + # describe the single spectrum, and prefixed duplicates would + # silently disagree with them once renamed. + if analyzer._is_multi: + for i in range(n_branches): + for comp_name in ["PhaseSpace", "Radiative"]: + key = analyzer._get_component_key(i, comp_name) + if key in components: + data[f"branch_{i + 1}_{comp_name}"] = components[key] # Universal components (no prefix): Fermi plus universal - # higher-order corrections. + # higher-order corrections. In single mode the core factors + # (PhaseSpace, Radiative) also appear here unprefixed. for name, values in components.items(): if not name.startswith("branch_"): data[name] = values diff --git a/beta_spectrum/output/visualization.py b/beta_spectrum/output/visualization.py index fad5450..c0ce85f 100644 --- a/beta_spectrum/output/visualization.py +++ b/beta_spectrum/output/visualization.py @@ -235,7 +235,6 @@ def _plot_spectra_view_from_df( ) -> Figure: """Plot total spectrum and per-branch spectra from CSV columns.""" energies = df["energy_MeV"].to_numpy() - e_step_keV = (meta["e_step_MeV"] or 0.0) * 1000.0 fig, ax = plt.subplots(figsize=(12, 7)) @@ -266,7 +265,7 @@ def _plot_spectra_view_from_df( ) ax.set_xlabel(r"Electron kinetic energy $E$ [MeV]", fontsize=12) - ax.set_ylabel(f"Normalized Counts / {e_step_keV:.1f} keV", fontsize=12) + ax.set_ylabel(r"Normalized spectrum [MeV$^{-1}$]", fontsize=12) fig.suptitle(_spectrum_title(meta), fontsize=14, fontweight="bold") ax.set_yscale("log") ax.grid(True, alpha=0.3) @@ -288,7 +287,6 @@ def _plot_debug_view_from_df( ) -> Figure: """Debug view (4 panels) built entirely from CSV columns.""" energies = df["energy_MeV"].to_numpy() - e_step_keV = (meta["e_step_MeV"] or 0.0) * 1000.0 active = _active_branches(meta) fig, axes = plt.subplots(4, 1, figsize=(12, 16), sharex=True) @@ -318,7 +316,7 @@ def _plot_debug_view_from_df( label=f"Br. {b['index']} ({b['intensity'] * 100:.1f}%)", ) ax.set_yscale("log") - ax.set_ylabel(f"Normalized Counts / {e_step_keV:.1f} keV", fontsize=10) + ax.set_ylabel(r"Normalized spectrum [MeV$^{-1}$]", fontsize=10) ax.set_title("Spectrum", fontsize=11) ax.grid(True, alpha=0.3) ax.legend(fontsize=8, loc="best", ncol=2) @@ -331,6 +329,9 @@ def _plot_debug_view_from_df( ax.plot(energies, fermi_scaled, "r-", lw=1.5, label="Fermi", alpha=0.8) for color_i, b in enumerate(active): col = f"branch_{b['index']}_PhaseSpace" + if col not in df.columns: + # Single-branch CSVs carry the core factors unprefixed. + col = "PhaseSpace" if col not in df.columns: continue ps_vals = df[col].to_numpy() @@ -374,6 +375,9 @@ def _plot_debug_view_from_df( ax = axes[3] for color_i, b in enumerate(active): col = f"branch_{b['index']}_Radiative" + if col not in df.columns: + # Single-branch CSVs carry the core factors unprefixed. + col = "Radiative" if col not in df.columns: continue ax.plot( diff --git a/tests/physics/test_core_spectrum.py b/tests/physics/test_core_spectrum.py index 670d8b2..5759657 100644 --- a/tests/physics/test_core_spectrum.py +++ b/tests/physics/test_core_spectrum.py @@ -605,9 +605,7 @@ class TestNormalizationModeMatrix: ) assert np.isclose(area, 1.0, rtol=5e-4) - def test_intensity_concise_branch_columns_sum_to_total( - self, tmp_path: Any - ) -> None: + def test_intensity_concise_branch_columns_sum_to_total(self, tmp_path: Any) -> None: config = make_multi_config() spectrum = BetaSpectrum.from_config(config) analyzer = BetaSpectrumAnalyzer(spectrum, config) @@ -622,10 +620,7 @@ class TestNormalizationModeMatrix: ) total = df["total_spectrum"].to_numpy() nonzero = total > 1e-12 - assert np.all( - np.abs(summed[nonzero] - total[nonzero]) - <= total[nonzero] * 5e-4 - ) + assert np.all(np.abs(summed[nonzero] - total[nonzero]) <= total[nonzero] * 5e-4) def test_plot_from_csv_to_one_plots_branches_without_total( self, tmp_path: Any @@ -635,9 +630,7 @@ class TestNormalizationModeMatrix: analyzer = BetaSpectrumAnalyzer(spectrum, config) path, df = export_and_read(analyzer, tmp_path, "to_one_plot.csv") - fig = Visualizer.plot_from_csv( - path, save_path=None, show_components=True - ) + fig = Visualizer.plot_from_csv(path, save_path=None, show_components=True) assert len(fig.axes) == 4 # Panel 1 has exactly the 3 branch curves — no total spectrum # exists in to_one mode. @@ -646,9 +639,7 @@ class TestNormalizationModeMatrix: assert len(curves) == 3 energies = df["energy_MeV"].to_numpy() assert np.allclose(curves[0][:, 0], energies) - assert np.allclose( - curves[0][:, 1], df["branch_1_spectrum"].to_numpy() - ) + assert np.allclose(curves[0][:, 1], df["branch_1_spectrum"].to_numpy()) def test_plot_analysis_to_one_returns_figure(self, tmp_path: Any) -> None: config = make_multi_config(normalize_to_one=True) @@ -669,11 +660,70 @@ class TestNormalizationModeMatrix: analyzer, tmp_path, "to_one_concise2.csv", csv_verbosity="concise" ) - fig = Visualizer.plot_from_csv( - path, save_path=None, show_components=True - ) + fig = Visualizer.plot_from_csv(path, save_path=None, show_components=True) captured = capsys.readouterr() assert "Warning" in captured.out # Simple spectra view: one axis, 3 branch curves, no total curve. assert len(fig.axes) == 1 assert len(fig.axes[0].lines) == 3 + + +class TestSingleModeColumnCleanup: + """Single-mode CSVs carry the factorized components once, unprefixed; + multi-mode CSVs carry them per branch; the visualizer reads whichever + naming the file actually uses.""" + + def test_single_mode_full_export_has_unprefixed_components_only( + self, tmp_path: Any + ) -> None: + config = make_single_config(use_radiative=True) + spectrum = BetaSpectrum.from_config(config) + analyzer = BetaSpectrumAnalyzer(spectrum, config) + path, df = export_and_read(analyzer, tmp_path, "single_components.csv") + + assert "PhaseSpace" in df.columns + assert "Radiative" in df.columns + assert "Fermi" in df.columns + assert "branch_1_spectrum" in df.columns + assert "branch_1_PhaseSpace" not in df.columns + assert "branch_1_Radiative" not in df.columns + + def test_multi_mode_full_export_has_prefixed_core_factors( + self, tmp_path: Any + ) -> None: + config = make_multi_config(use_radiative=True) + spectrum = BetaSpectrum.from_config(config) + analyzer = BetaSpectrumAnalyzer(spectrum, config) + path, df = export_and_read(analyzer, tmp_path, "multi_components.csv") + + assert "branch_1_PhaseSpace" in df.columns + assert "branch_2_Radiative" in df.columns + assert "Fermi" in df.columns + # The bare core-factor names belong to single mode only. + assert "PhaseSpace" not in df.columns + assert "Radiative" not in df.columns + + def test_debug_view_single_csv_includes_core_panels(self, tmp_path: Any) -> None: + config = make_single_config(use_radiative=True) + spectrum = BetaSpectrum.from_config(config) + analyzer = BetaSpectrumAnalyzer(spectrum, config) + path, _df = export_and_read(analyzer, tmp_path, "single_plot.csv") + + fig = Visualizer.plot_from_csv(path, None, show_components=True) + assert len(fig.axes) == 4 + # Panel 2: Fermi + the single branch's phase space. + assert len(fig.axes[1].lines) == 2 + labels = [ln.get_label() for ln in fig.axes[1].lines] + assert "Fermi" in labels + assert any("PS Br" in str(lbl) for lbl in labels) + # Panel 4: the radiative correction for the single branch. + assert len(fig.axes[3].lines) == 1 + + def test_ylabel_uses_per_MeV_units(self, tmp_path: Any) -> None: + config = make_multi_config() + spectrum = BetaSpectrum.from_config(config) + analyzer = BetaSpectrumAnalyzer(spectrum, config) + path, _df = export_and_read(analyzer, tmp_path, "ylabel.csv") + + fig = Visualizer.plot_from_csv(path, None, show_components=True) + assert "MeV$^{-1}$" in fig.axes[0].get_ylabel() diff --git a/tests/quality/test_nuclear_data.py b/tests/quality/test_nuclear_data.py index c85cd45..d9ae2e3 100644 --- a/tests/quality/test_nuclear_data.py +++ b/tests/quality/test_nuclear_data.py @@ -161,9 +161,12 @@ class TestJsonInput: path = self._write_json(data) try: loaded = load_json_input(str(path)) - # Defaults should be filled in - assert loaded["detector_model"] == "gaussian" - assert loaded["detector_sigma_a_keV"] == 1.0 + # Defaults should be filled in. The detector_* schema keys were + # removed together with the detector_response module; JSON + # defaults carry spectrum physics only. + assert "detector_model" not in loaded + assert loaded["neutrino_mass_MeV"] == 0.0 + assert loaded["transition_type"] == "F2" finally: path.unlink()