From 2788ea0683846e598c1d8d4af62571a5ff8ee589 Mon Sep 17 00:00:00 2001 From: Evgeniy Unzhakov Date: Sat, 3 Oct 2026 01:08:51 +0300 Subject: [PATCH] feat(cli): separate-branches rename, short aliases, clean dry-run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - --normalize-to-one renamed to --separate-branches (no alias; the name now describes the output choice: total spectrum with intensity-normalized branches vs separate branches, each normalized to unit area with intensities left to the data user). SpectrumConfig.normalize_to_one -> separate_branches, CSV header word to_one -> separate. - every long option gains a single-letter alias: -N -i -s -m -o -p -L -d -c -V -S -b (only -v/-q existed before) - dry-run drops the top-level Transition: line — the per-branch list below already reports each branch's transition type - version bumped to 0.4.1 (pyproject, __version__, CSV semantics docstring, TODO roadmap entry) --- README.md | 4 +- TODO.md | 8 +- beta_spectrum/__init__.py | 2 +- beta_spectrum/cli.py | 25 +++-- beta_spectrum/nuclear_data.py | 13 +-- beta_spectrum/output/csv_export.py | 14 +-- beta_spectrum/spectrum.py | 8 +- pyproject.toml | 2 +- tests/integration/test_cli_flows.py | 10 +- tests/physics/test_core_spectrum.py | 38 ++++---- tests/quality/test_cli_options.py | 137 ++++++++++++++++++++++++++++ tests/quality/test_logging_utils.py | 2 +- 12 files changed, 210 insertions(+), 53 deletions(-) create mode 100644 tests/quality/test_cli_options.py diff --git a/README.md b/README.md index 233b2ec..7f792d2 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ In `beta_spectrum.output`. Provides debugging and visualization tools: - `export_to_csv(filename)` — export via pandas DataFrame with YAML-style metadata header: - the basic spectrum is always the core `phase_space × fermi` (the header carries a `# core:` line); finite size, charge distribution, screening, exchange and radiative are higher-order corrections - `branch_{i}_spectrum` columns contain the *physical* branch spectra weighted by intensity and normalized so that the sum of branch columns equals `total_spectrum` - - with `normalize_to_one=True` each branch column integrates to 1 (intensity ignored, no total column) + - with `separate_branches=True` (CLI `--separate-branches`) each branch column integrates to 1 (intensity ignored, no total_spectrum column) - `parse_spectrum_csv(path)` — read an exported CSV and its metadata header (module function in `beta_spectrum.output.visualization`) - `get_data()` — return all numerical data for custom analysis @@ -194,5 +194,5 @@ For a complete list of implemented and planned features, see [TODO.md](TODO.md). - **Multi-branch decay**: intensity-weighted sum of branch spectra, per-branch calculators with branch-specific endpoint, transition type (from ENSDF), and `--intensity-cutoff` CLI filter - **Analysis tools**: χ² fitter (SpectrumFitter + CurveFitter), C(W) extraction pipeline, Kurie plot analysis, g_V/g_A extraction - **Experimental data**: ExpSpectrum container, energy calibration, peak fitting, dead-time/pile-up correction -- **CLI**: `bs_pnpi --nuclide Tc99` — full paceENSDF integration (auto-lookup of Z, A, Q-value, transition type, half-life, branches, forbiddenness from ENSDF); for JSON input the file's values are used and CLI flags (`--e-step`, `--neutrino-mass`, `--intensity-cutoff`, `--csv-separator`, `--normalize-to-one`) override them only when explicitly given +- **CLI**: `bs_pnpi --nuclide Tc99` — full paceENSDF integration (auto-lookup of Z, A, Q-value, transition type, half-life, branches, forbiddenness from ENSDF); for JSON input the file's values are used and CLI flags (`--e-step`, `--neutrino-mass`, `--intensity-cutoff`, `--csv-separator`, `--separate-branches`) override them only when explicitly given; every long option has a single-letter alias (`-N`, `-i`, `-s`, `-m`, `-o`, `-p`, `-L`, `-d`, `-c`, `-V`, `-S`, `-b`) - **paceENSDF integration**: `get_decay_info_from_paceENSDF()`, `create_config_from_source("paceENSDF", nuclide="Tc99")`, `_parse_nuclide_symbol()` for Z=1..98, FORBIDDENNESS_MAP, `--nuclide` CLI flag; per-branch endpoints are the measured transition endpoint energies from ENSDF (max endpoint across branches defines the spectrum endpoint) diff --git a/TODO.md b/TODO.md index 3f02cd4..c08a927 100644 --- a/TODO.md +++ b/TODO.md @@ -322,7 +322,7 @@ ______________________________________________________________________ ## Current Status -**Version:** 0.3.1 → **In progress: 0.4.0 — Package restructuring** +**Version:** 0.4.0 → **v0.4.1 — CLI options refresh & normalization fixes** (released on `dev/core-fermi-csv`) ### Completed (v0.3.x) @@ -354,6 +354,12 @@ ______________________________________________________________________ - README updated with new architecture diagram and Quick Start examples - Version bumped to 0.4.0 +### Completed (v0.4.1) + +- Core spectrum made unconditional (`phase_space × fermi`): core toggles removed, physical branch spectra in CSV export, plots built from the exported CSV +- Normalization audit fixes: lone surviving branch defines the spectrum, CLI options propagate through the JSON flow, positive-integral guard for both export modes, paceENSDF per-branch measured endpoints (Co60 fixed), single delegated paceENSDF config builder +- CLI surface refresh: `--normalize-to-one` renamed to `--separate-branches` (no alias), single-letter aliases for every option, dry-run drops the redundant top-level `Transition:` line, stale detector_* JSON schema keys removed + ### Next Immediate Step - **Track A: ⁹⁹Tc spectrum-shape analysis** — start real data processing pipeline diff --git a/beta_spectrum/__init__.py b/beta_spectrum/__init__.py index 574ef80..58af078 100644 --- a/beta_spectrum/__init__.py +++ b/beta_spectrum/__init__.py @@ -52,4 +52,4 @@ __all__ = [ "DEFAULT_JSON_SCHEMA", ] -__version__ = "0.4.0" +__version__ = "0.4.1" diff --git a/beta_spectrum/cli.py b/beta_spectrum/cli.py index 41dd627..6a91231 100644 --- a/beta_spectrum/cli.py +++ b/beta_spectrum/cli.py @@ -65,12 +65,14 @@ def _build_parser() -> argparse.ArgumentParser: # Source selection flags (backward compatible, mutually exclusive) source_group = parser.add_mutually_exclusive_group(required=False) source_group.add_argument( + "-N", "--nuclide", type=str, help="Nuclide symbol for paceENSDF lookup (e.g. 'Tc99', 'Co60'). " "Retrieves decay parameters from the ENSDF database via paceENSDF.", ) source_group.add_argument( + "-i", "--input", type=str, metavar="FILE", @@ -79,6 +81,7 @@ def _build_parser() -> argparse.ArgumentParser: # Common parameters parser.add_argument( + "-s", "--e-step", type=float, default=None, @@ -87,6 +90,7 @@ def _build_parser() -> argparse.ArgumentParser: "omitted). All energies are in MeV throughout the toolkit.", ) parser.add_argument( + "-m", "--neutrino-mass", type=float, default=None, @@ -97,6 +101,7 @@ def _build_parser() -> argparse.ArgumentParser: # Output parser.add_argument( + "-o", "--output", type=str, metavar="FILE", @@ -105,6 +110,7 @@ def _build_parser() -> argparse.ArgumentParser: # Plotting parser.add_argument( + "-p", "--plot", type=str, metavar="FILE", @@ -128,6 +134,7 @@ def _build_parser() -> argparse.ArgumentParser: help="Suppress all terminal output (only errors shown).", ) log_group.add_argument( + "-L", "--log-file", type=str, metavar="PATH", @@ -135,11 +142,13 @@ def _build_parser() -> argparse.ArgumentParser: "Log file is always opened in append mode at DEBUG level.", ) log_group.add_argument( + "-d", "--dry-run", action="store_true", help="Validate input and display resolved configuration without computing spectrum.", ) log_group.add_argument( + "-c", "--intensity-cutoff", type=float, default=None, @@ -149,6 +158,7 @@ def _build_parser() -> argparse.ArgumentParser: "Branches below this threshold are excluded from the calculation and plots.", ) log_group.add_argument( + "-V", "--csv-verbosity", type=str, default="full", @@ -156,16 +166,20 @@ def _build_parser() -> argparse.ArgumentParser: help="CSV output verbosity: 'full' (all components) or 'concise' (total spectrum and normalized branch components only) (default: full)", ) log_group.add_argument( + "-S", "--csv-separator", type=str, default=None, help="CSV column separator (default: ','). Use 'tab' for tab separation.", ) log_group.add_argument( - "--normalize-to-one", + "-b", + "--separate-branches", action="store_true", default=None, - help="Normalize each branch to 1, ignoring intensity. In this case, total spectrum will not be produced (only energy and branch-wise spectra).", + help="Emit separate branch spectra, each normalized to integrate to " + "1 (intensities ignored; no total_spectrum column). Default: total " + "spectrum with intensity-normalized branch columns.", ) return parser @@ -239,7 +253,7 @@ def _run(args: argparse.Namespace) -> None: 0.0 if args.intensity_cutoff is None else float(args.intensity_cutoff) ) csv_separator = "," if args.csv_separator is None else args.csv_separator - normalize_to_one = bool(args.normalize_to_one) + separate_branches = bool(args.separate_branches) # Build config — a single delegated path for every data source. config: SpectrumConfig @@ -252,7 +266,7 @@ def _run(args: argparse.Namespace) -> None: intensity_cutoff=intensity_cutoff, neutrino_mass_MeV=neutrino_mass_MeV, csv_separator=csv_separator, - normalize_to_one=normalize_to_one, + separate_branches=separate_branches, ) else: logger.info("Source: JSON file=%s", source_value) @@ -268,7 +282,7 @@ def _run(args: argparse.Namespace) -> None: config.intensity_cutoff = intensity_cutoff if args.csv_separator is not None: config.csv_separator = csv_separator - config.normalize_to_one = normalize_to_one + config.separate_branches = separate_branches # Dry run: display resolved config and exit if args.dry_run: @@ -352,7 +366,6 @@ def _print_dry_run_output(config, source_type: str) -> None: f"Nuclide: {_decay_notation(config.Z_parent, config.Z_daughter, config.A_number)}" ) 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'}") diff --git a/beta_spectrum/nuclear_data.py b/beta_spectrum/nuclear_data.py index dbb916a..f114bef 100644 --- a/beta_spectrum/nuclear_data.py +++ b/beta_spectrum/nuclear_data.py @@ -477,7 +477,7 @@ def decay_info_to_config( intensity_cutoff: float = 0.0, neutrino_mass_MeV: float = 0.0, csv_separator: str = ",", - normalize_to_one: bool = False, + separate_branches: bool = False, ) -> SpectrumConfig: """ Convert DecayInfo to a SpectrumConfig. @@ -494,8 +494,9 @@ def decay_info_to_config( Neutrino mass in MeV. csv_separator : str CSV column separator. - normalize_to_one : bool - If True, normalize each branch to 1, ignoring intensity. + separate_branches : bool + If True, emit separate branch spectra, each normalized to + integrate to 1 (intensity ignored; no total spectrum column). Returns ------- @@ -535,7 +536,7 @@ def decay_info_to_config( branches=branches, intensity_cutoff=intensity_cutoff, csv_separator=csv_separator, - normalize_to_one=normalize_to_one, + separate_branches=separate_branches, ) @@ -708,7 +709,7 @@ def create_config_from_source( intensity_cutoff = kwargs.get("intensity_cutoff", 0.0) neutrino_mass = kwargs.get("neutrino_mass_MeV", 0.0) csv_separator = kwargs.get("csv_separator", ",") - normalize_to_one = kwargs.get("normalize_to_one", False) + separate_branches = kwargs.get("separate_branches", False) info = get_decay_info_from_paceENSDF(nuclide, decay_type, decay_index) return decay_info_to_config( info, @@ -716,7 +717,7 @@ def create_config_from_source( intensity_cutoff=intensity_cutoff, neutrino_mass_MeV=neutrino_mass, csv_separator=csv_separator, - normalize_to_one=normalize_to_one, + separate_branches=separate_branches, ) elif source == "json": if not json_path: diff --git a/beta_spectrum/output/csv_export.py b/beta_spectrum/output/csv_export.py index 0804f82..7ce3657 100644 --- a/beta_spectrum/output/csv_export.py +++ b/beta_spectrum/output/csv_export.py @@ -1,7 +1,7 @@ """ CSV export module — CSV export functionality for BetaSpectrumAnalyzer. -Column semantics (v0.4.0 onward): +Column semantics (v0.4.1 onward): - The basic spectrum is ALWAYS core = phase_space x fermi; the Fermi function is a core spectrum component, never an optional correction. Higher-order corrections (finite size, charge distribution, screening, exchange, @@ -12,7 +12,7 @@ Column semantics (v0.4.0 onward): (the total column itself is normalized to integrate to 1). In the legacy export branch columns contained only the per-branch phase-space x radiative product WITHOUT the Fermi function — physically wrong. -- ``normalize_to_one`` mode: each branch column is normalized to +- ``separate_branches`` mode: each branch column is normalized to integrate to 1 individually, intensity is ignored, no total column. """ @@ -62,7 +62,7 @@ class CSVEmitter: """ components = analyzer.components config = analyzer.config - to_one = config.normalize_to_one + separate = config.separate_branches n_branches = analyzer._n_branches # Physical branch spectra: core(phase_space x fermi) x corrections. @@ -127,9 +127,9 @@ class CSVEmitter: header_lines.append(f"# e_step: {config.e_step_MeV:.4f} MeV") header_lines.append(f"# n_points: {len(analyzer.energies_MeV)}") - if to_one: + if separate: header_lines.append( - "# branch normalization: to_one " + "# branch normalization: separate " "(each branch integrates to 1, intensity ignored)" ) else: @@ -189,7 +189,7 @@ class CSVEmitter: "is degenerate (check neutrino_mass vs branch endpoints " "and intensity_cutoff)" ) - if not to_one: + if not separate: data["total_spectrum"] = total_raw / integral for i in range(n_branches): @@ -199,7 +199,7 @@ class CSVEmitter: else: col_name = f"branch_{key_i}_spectrum" - if to_one: + if separate: area = float(np.trapezoid(physical[i], analyzer.energies_MeV)) if area > 0.0: data[col_name] = physical[i] / area diff --git a/beta_spectrum/spectrum.py b/beta_spectrum/spectrum.py index 00286e9..4cc4017 100644 --- a/beta_spectrum/spectrum.py +++ b/beta_spectrum/spectrum.py @@ -57,9 +57,6 @@ class SpectrumConfig: """ Configuration for beta spectrum calculation. - Supports declarative detector response specification: - to enable automatic detector smearing via convolve_with_detector(). - Supports multi-branch decay via the `branches` field. When branches are provided, the total spectrum is the intensity-weighted sum of individual branch spectra. Each branch has its own endpoint energy and transition type. @@ -90,9 +87,10 @@ class SpectrumConfig: # CSV output options csv_separator: str = "," # Default separator for CSV output - normalize_to_one: bool = ( - False # If True, normalize each branch to 1, ignoring intensity + separate_branches: bool = ( + False # If True: emit separate branch spectra, each normalized to ) + # integrate to 1 (intensity ignored); no total spectrum column def _effective_single_fields(config: SpectrumConfig) -> Tuple[float, str]: diff --git a/pyproject.toml b/pyproject.toml index 74a3819..fcef127 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "beta-spectrum" -version = "0.4.0" +version = "0.4.1" description = "High-precision beta spectrum calculation for nuclear physics" authors = [ { name = "Evgeniy Unzhakov" } diff --git a/tests/integration/test_cli_flows.py b/tests/integration/test_cli_flows.py index 57b044d..21a68a4 100644 --- a/tests/integration/test_cli_flows.py +++ b/tests/integration/test_cli_flows.py @@ -66,12 +66,12 @@ def n_points_of(header: List[str]) -> int: class TestCLIJsonFlows: """Options must propagate through the JSON config flow (D2).""" - def test_json_honors_normalize_to_one_and_tab_separator( + def test_json_honors_separate_branches_and_tab_separator( self, tmp_path: Any ) -> None: src = write_json(tmp_path) out = str(tmp_path / "to_one.csv") - main([src, "--output", out, "--normalize-to-one", "--csv-separator", "tab"]) + main([src, "--output", out, "--separate-branches", "--csv-separator", "tab"]) with open(out) as f: body = "".join(ln for ln in f if not ln.startswith("#")) @@ -136,7 +136,7 @@ class TestCLIDegenerateFlows: out, "--neutrino-mass", "0.35", - "--normalize-to-one", + "--separate-branches", ] ) assert exc.value.code == 1 @@ -147,9 +147,9 @@ class TestCLIDegenerateFlows: class TestCLIPaceENSDF: """paceENSDF flow honors the same export options.""" - def test_paceensdf_honors_normalize_to_one(self, tmp_path: Any) -> None: + def test_paceensdf_honors_separate_branches(self, tmp_path: Any) -> None: out = str(tmp_path / "p32.csv") - main(["P32", "--output", out, "--normalize-to-one"]) + main(["P32", "--output", out, "--separate-branches"]) df = read_export(out) assert "total_spectrum" not in df.columns diff --git a/tests/physics/test_core_spectrum.py b/tests/physics/test_core_spectrum.py index 5759657..82eb69d 100644 --- a/tests/physics/test_core_spectrum.py +++ b/tests/physics/test_core_spectrum.py @@ -313,8 +313,8 @@ class TestCSVExportCoreSemantics: assert "phase_space" in core_line[0] assert "fermi" in core_line[0] - def test_normalize_to_one_per_branch_unit_area(self, tmp_path: Any) -> None: - config = make_multi_config(normalize_to_one=True) + def test_separate_branches_per_branch_unit_area(self, tmp_path: Any) -> None: + config = make_multi_config(separate_branches=True) spectrum = BetaSpectrum.from_config(config) analyzer = BetaSpectrumAnalyzer(spectrum, config) _, df = export_and_read(analyzer, tmp_path) @@ -334,12 +334,12 @@ class TestCSVExportCoreSemantics: analyzer.export_to_csv(path) assert not (tmp_path / "degenerate.csv").exists() - def test_degenerate_export_raises_in_normalize_to_one_mode( + def test_degenerate_export_raises_in_separate_branches_mode( self, tmp_path: Any ) -> None: - """The positive-integral guard must apply in to_one mode too: + """The positive-integral guard must apply in separate-branches mode too: no all-zero CSV may be written for a degenerate spectrum.""" - config = make_degenerate_config(normalize_to_one=True) + config = make_degenerate_config(separate_branches=True) spectrum = BetaSpectrum.from_config(config) analyzer = BetaSpectrumAnalyzer(spectrum, config) path = str(tmp_path / "degenerate_to_one.csv") @@ -567,13 +567,13 @@ class TestAnalyzerUniversalFactor: class TestNormalizationModeMatrix: - """to_one and intensity modes must behave consistently across CSV + """separate_branches and intensity modes must behave consistently across CSV verbosity, branch count and the CSV-driven visualizer.""" - def test_to_one_concise_multi_unit_area_no_total_no_components( + def test_separate_branches_concise_multi_unit_area_no_total_no_components( self, tmp_path: Any ) -> None: - config = make_multi_config(normalize_to_one=True) + config = make_multi_config(separate_branches=True) spectrum = BetaSpectrum.from_config(config) analyzer = BetaSpectrumAnalyzer(spectrum, config) path, df = export_and_read( @@ -587,14 +587,14 @@ class TestNormalizationModeMatrix: "branch_3", } header = header_lines_of(path) - assert any("branch normalization: to_one" in h for h in header) + assert any("branch normalization: separate" in h for h in header) energies = df["energy_MeV"].to_numpy() for col in ("branch_1", "branch_2", "branch_3"): area = np.trapezoid(df[col].to_numpy(), energies) assert np.isclose(area, 1.0, rtol=5e-4) - def test_to_one_single_mode_unit_area(self, tmp_path: Any) -> None: - config = make_single_config(normalize_to_one=True) + def test_separate_branches_single_mode_unit_area(self, tmp_path: Any) -> None: + config = make_single_config(separate_branches=True) spectrum = BetaSpectrum.from_config(config) analyzer = BetaSpectrumAnalyzer(spectrum, config) _path, df = export_and_read(analyzer, tmp_path, "to_one_single.csv") @@ -622,10 +622,10 @@ class TestNormalizationModeMatrix: nonzero = total > 1e-12 assert np.all(np.abs(summed[nonzero] - total[nonzero]) <= total[nonzero] * 5e-4) - def test_plot_from_csv_to_one_plots_branches_without_total( + def test_plot_from_csv_separate_branches_plots_branches_without_total( self, tmp_path: Any ) -> None: - config = make_multi_config(normalize_to_one=True) + config = make_multi_config(separate_branches=True) spectrum = BetaSpectrum.from_config(config) analyzer = BetaSpectrumAnalyzer(spectrum, config) path, df = export_and_read(analyzer, tmp_path, "to_one_plot.csv") @@ -633,7 +633,7 @@ class TestNormalizationModeMatrix: 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. + # exists in separate-branches mode. panel = fig.axes[0] curves = [np.asarray(ln.get_xydata()) for ln in panel.lines] assert len(curves) == 3 @@ -641,8 +641,10 @@ class TestNormalizationModeMatrix: assert np.allclose(curves[0][:, 0], energies) 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) + def test_plot_analysis_separate_branches_returns_figure( + self, tmp_path: Any + ) -> None: + config = make_multi_config(separate_branches=True) spectrum = BetaSpectrum.from_config(config) analyzer = BetaSpectrumAnalyzer(spectrum, config) save = str(tmp_path / "analysis.png") @@ -650,10 +652,10 @@ class TestNormalizationModeMatrix: assert len(fig.axes) == 4 assert (tmp_path / "analysis.png").exists() - def test_plot_from_csv_to_one_concise_warns_and_falls_back( + def test_plot_from_csv_separate_branches_concise_warns_and_falls_back( self, tmp_path: Any, capsys: Any ) -> None: - config = make_multi_config(normalize_to_one=True) + config = make_multi_config(separate_branches=True) spectrum = BetaSpectrum.from_config(config) analyzer = BetaSpectrumAnalyzer(spectrum, config) path, df = export_and_read( diff --git a/tests/quality/test_cli_options.py b/tests/quality/test_cli_options.py new file mode 100644 index 0000000..aab54fa --- /dev/null +++ b/tests/quality/test_cli_options.py @@ -0,0 +1,137 @@ +"""CLI option surface tests: renamed branch-separation flag, short +aliases for every long option, and dry-run output shape (audit follow-up +by the maintainer: rename --normalize-to-one, add short options, drop the +redundant top-level Transition line).""" + +from __future__ import annotations + +import dataclasses +from typing import Any, Optional + +import pytest + +from beta_spectrum import BranchConfig, SpectrumConfig +from beta_spectrum.cli import _build_parser, _print_dry_run_output + +pytestmark = pytest.mark.quality + + +def make_config() -> SpectrumConfig: + return SpectrumConfig( + Z_parent=59, + Z_daughter=60, + A_number=144, + endpoint_MeV=2.98, + transition_type="F1", + e_step_MeV=0.001, + branches=[ + BranchConfig(endpoint_MeV=2.98, transition_type="F1", intensity=0.9), + BranchConfig(endpoint_MeV=2.1, transition_type="A", intensity=0.1), + ], + ) + + +class TestSeparateBranchesRename: + """--normalize-to-one was renamed to --separate-branches (no alias).""" + + def test_flag_parses_true(self) -> None: + parser = _build_parser() + args = parser.parse_args(["input.json", "--separate-branches"]) + assert args.separate_branches is True + + def test_flag_default_is_none(self) -> None: + parser = _build_parser() + args = parser.parse_args(["input.json"]) + assert args.separate_branches is None + + def test_old_flag_is_rejected(self) -> None: + parser = _build_parser() + with pytest.raises(SystemExit): + parser.parse_args(["input.json", "--normalize-to-one"]) + + def test_config_field_renamed(self) -> None: + names = {f.name for f in dataclasses.fields(SpectrumConfig)} + assert "separate_branches" in names + assert "normalize_to_one" not in names + + def test_config_old_kwarg_rejected(self) -> None: + kwargs: dict[str, Any] = { + "Z_parent": 15, + "Z_daughter": 16, + "A_number": 32, + "endpoint_MeV": 1.7103, + "transition_type": "A", + "normalize_to_one": True, + } + with pytest.raises(TypeError): + SpectrumConfig(**kwargs) + + +# (short, long, value-or-None-for-store_true) +_SHORT_EQUIV = [ + ("-N", "--nuclide", "Cs137"), + ("-i", "--input", "in.json"), + ("-s", "--e-step", "0.01"), + ("-m", "--neutrino-mass", "0.15"), + ("-o", "--output", "out.csv"), + ("-p", "--plot", "plot.png"), + ("-L", "--log-file", "calc.log"), + ("-d", "--dry-run", None), + ("-c", "--intensity-cutoff", "0.5"), + ("-V", "--csv-verbosity", "concise"), + ("-S", "--csv-separator", "tab"), + ("-b", "--separate-branches", None), +] + + +class TestShortOptions: + """Every long option gains a single-letter alias (only -v/-q existed).""" + + @pytest.mark.parametrize( + "short,long,value", + _SHORT_EQUIV, + ids=[s.lstrip("-") for s, _, _ in _SHORT_EQUIV], + ) + def test_short_matches_long( + self, short: str, long: str, value: Optional[str] + ) -> None: + parser = _build_parser() + argv_long = [long] if value is None else [long, value] + argv_short = [short] if value is None else [short, value] + args_long = parser.parse_args(["Tc99"] + argv_long) + args_short = parser.parse_args(["Tc99"] + argv_short) + dest = long.lstrip("-").replace("-", "_") + assert getattr(args_short, dest) == getattr(args_long, dest) + assert getattr(args_long, dest) is not None or value is None + + def test_existing_shorts_unchanged(self) -> None: + parser = _build_parser() + args = parser.parse_args(["Tc99", "-v", "-q"]) + assert args.verbose == 1 + assert args.quiet is True + + +class TestDryRunOutputShape: + """The top-level Transition line is redundant with the per-branch + list (for multi-branch runs) and was removed.""" + + def test_multi_branch_dry_run_no_top_level_transition(self, capsys: Any) -> None: + _print_dry_run_output(make_config(), "paceENSDF") + out = capsys.readouterr().out + assert "Transition:" not in out + assert "transition=F1" in out + assert "transition=A" in out + assert "Multi-branch mode: 2 branches" in out + + def test_single_branch_dry_run_reports_endpoint(self, capsys: Any) -> None: + config = SpectrumConfig( + Z_parent=15, + Z_daughter=16, + A_number=32, + endpoint_MeV=1.7103, + transition_type="A", + ) + _print_dry_run_output(config, "json") + out = capsys.readouterr().out + assert "Transition:" not in out + assert "Mode: single-branch" in out diff --git a/tests/quality/test_logging_utils.py b/tests/quality/test_logging_utils.py index f17ea84..b23261d 100644 --- a/tests/quality/test_logging_utils.py +++ b/tests/quality/test_logging_utils.py @@ -154,7 +154,7 @@ class TestCSVMetadataHeader: lines = f.readlines() assert any("# beta-spectrum v" in line for line in lines) - assert any("0.4.0" in line for line in lines) + assert any("0.4.1" in line for line in lines) os.unlink(filename) def test_header_contains_timestamp(self):