diff --git a/beta_spectrum/cli.py b/beta_spectrum/cli.py index 469814c..a54604a 100644 --- a/beta_spectrum/cli.py +++ b/beta_spectrum/cli.py @@ -81,14 +81,18 @@ def _build_parser() -> argparse.ArgumentParser: parser.add_argument( "--e-step", type=float, - default=0.001, - help="Energy step size in MeV (default: 0.001). All energies are in MeV throughout the toolkit.", + default=None, + help="Energy step size in MeV (default: 0.001 for nuclide input; " + "for JSON input the file's e_step_MeV is used when the flag is " + "omitted). 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.", + default=None, + help="Neutrino mass in MeV (default: 0.0, or the JSON file's " + "neutrino_mass_MeV when omitted). Set to non-zero value to include " + "neutrino mass effects in phase space calculation.", ) # Output @@ -138,7 +142,7 @@ def _build_parser() -> argparse.ArgumentParser: log_group.add_argument( "--intensity-cutoff", type=float, - default=0.0, + default=None, metavar="FRAC", help="Minimum branch intensity fraction to include in the spectrum " "(default: 0.0 = include all branches). " @@ -154,12 +158,13 @@ def _build_parser() -> argparse.ArgumentParser: log_group.add_argument( "--csv-separator", type=str, - default=",", + default=None, help="CSV column separator (default: ','). Use 'tab' for tab separation.", ) log_group.add_argument( "--normalize-to-one", 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).", ) @@ -230,6 +235,17 @@ def _run(args: argparse.Namespace) -> None: else: source_type = "paceENSDF" + # Resolve optional CLI flags: None means "not provided". For paceENSDF + # they fall back to literal defaults; for JSON they let the file's own + # values through unless explicitly overridden. + e_step_MeV = 0.001 if args.e_step is None else float(args.e_step) + neutrino_mass_MeV = 0.0 if args.neutrino_mass is None else float(args.neutrino_mass) + intensity_cutoff = ( + 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) + # Build config config: SpectrumConfig if source_type == "paceENSDF": @@ -251,7 +267,7 @@ def _run(args: argparse.Namespace) -> None: intensity=b.intensity / total_intensity, ) for b in decay_info.branches - if b.intensity / total_intensity >= args.intensity_cutoff + if b.intensity / total_intensity >= intensity_cutoff ] config = SpectrumConfig( Z_parent=decay_info.Z_parent, @@ -259,37 +275,48 @@ def _run(args: argparse.Namespace) -> None: A_number=decay_info.A_number, endpoint_MeV=decay_info.endpoint_MeV, transition_type=decay_info.transition_type, - e_step_MeV=args.e_step, - neutrino_mass_MeV=args.neutrino_mass, + e_step_MeV=e_step_MeV, + neutrino_mass_MeV=neutrino_mass_MeV, branches=branches, - intensity_cutoff=args.intensity_cutoff, - csv_separator=args.csv_separator, - normalize_to_one=args.normalize_to_one, + intensity_cutoff=intensity_cutoff, + csv_separator=csv_separator, + normalize_to_one=normalize_to_one, ) else: config = create_config_from_source( "paceENSDF", nuclide=source_value, - e_step_MeV=args.e_step, - intensity_cutoff=args.intensity_cutoff, - neutrino_mass_MeV=args.neutrino_mass, - csv_separator=args.csv_separator, - normalize_to_one=args.normalize_to_one, + e_step_MeV=e_step_MeV, + intensity_cutoff=intensity_cutoff, + neutrino_mass_MeV=neutrino_mass_MeV, + csv_separator=csv_separator, + normalize_to_one=normalize_to_one, ) else: config = create_config_from_source( "paceENSDF", nuclide=source_value, - e_step_MeV=args.e_step, - intensity_cutoff=args.intensity_cutoff, - neutrino_mass_MeV=args.neutrino_mass, - csv_separator=args.csv_separator, - normalize_to_one=args.normalize_to_one, + e_step_MeV=e_step_MeV, + intensity_cutoff=intensity_cutoff, + neutrino_mass_MeV=neutrino_mass_MeV, + csv_separator=csv_separator, + normalize_to_one=normalize_to_one, ) else: logger.info("Source: JSON file=%s", source_value) data = load_json_input(source_value) config = json_to_config(data) + # CLI flags override the JSON file values only when explicitly + # provided; unset flags keep the file's own values. + if args.e_step is not None: + config.e_step_MeV = e_step_MeV + if args.neutrino_mass is not None: + config.neutrino_mass_MeV = neutrino_mass_MeV + if args.intensity_cutoff is not 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 # Dry run: display resolved config and exit if args.dry_run: @@ -516,10 +543,16 @@ def _decay_notation(Z_parent: int, Z_daughter: int, A: int) -> str: return f"{_nuclide_symbol(Z_parent, A)} -> {_nuclide_symbol(Z_daughter, A)}" -def main() -> None: - """Main entry point for the CLI.""" +def main(argv: list[str] | None = None) -> None: + """Main entry point for the CLI. + + Parameters + ---------- + argv : list[str], optional + Explicit argument vector (for tests/embedding); None reads sys.argv. + """ parser = _build_parser() - args = parser.parse_args() + args = parser.parse_args(argv) try: _run(args) diff --git a/tests/integration/test_cli_flows.py b/tests/integration/test_cli_flows.py new file mode 100644 index 0000000..382963e --- /dev/null +++ b/tests/integration/test_cli_flows.py @@ -0,0 +1,164 @@ +"""CLI flow tests: command-line options must reach the config in every +data-source flow (JSON file and paceENSDF), and degenerate exports must +exit non-zero without writing a file (regression D2/D4). + +The CLI is invoked in-process via beta_spectrum.cli.main(argv) so the +paceENSDF dataset cache (loaded once per process) is shared with the +rest of the test session. +""" + +from __future__ import annotations + +import json +from typing import Any, Dict, List, Optional + +import numpy as np +import pandas as pd +import pytest + +from beta_spectrum.cli import main + +pytestmark = pytest.mark.integration + + +def _base_json() -> Dict[str, Any]: + return { + "parent_symbol": "Tc99", + "Z_parent": 43, + "Z_daughter": 44, + "A_number": 99, + "endpoint_MeV": 0.294, + "transition_type": "A", + } + + +def write_json(tmp_path: Any, extra: Optional[Dict[str, Any]] = None) -> str: + data = _base_json() + if extra: + data.update(extra) + path = tmp_path / "input.json" + path.write_text(json.dumps(data)) + return str(path) + + +def header_lines_of(path: str) -> List[str]: + lines: List[str] = [] + with open(path, "r") as f: + for line in f: + if line.startswith("#"): + lines.append(line.rstrip("\n")) + else: + break + return lines + + +def read_export(path: str) -> pd.DataFrame: + return pd.read_csv(path, sep=None, engine="python", comment="#") + + +def n_points_of(header: List[str]) -> int: + for h in header: + if h.startswith("# n_points:"): + return int(h.split(":")[1]) + raise AssertionError(f"n_points header missing: {header}") + + +class TestCLIJsonFlows: + """Options must propagate through the JSON config flow (D2).""" + + def test_json_honors_normalize_to_one_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"]) + + with open(out) as f: + body = "".join(ln for ln in f if not ln.startswith("#")) + assert "\t" in body, "CSV must be tab-separated" + + df = read_export(out) + assert "total_spectrum" not in df.columns + area = np.trapezoid(df["branch_1_spectrum"], df["energy_MeV"]) + assert np.isclose(area, 1.0, rtol=1e-3) + + def test_json_defaults_keep_total_column(self, tmp_path: Any) -> None: + src = write_json(tmp_path) + out = str(tmp_path / "default.csv") + main([src, "--output", out]) + + df = read_export(out) + assert "total_spectrum" in df.columns + area = np.trapezoid(df["total_spectrum"], df["energy_MeV"]) + assert np.isclose(area, 1.0, rtol=1e-3) + + def test_json_cli_overrides_file_physics_values(self, tmp_path: Any) -> None: + src = write_json(tmp_path, {"e_step_MeV": 0.001, "neutrino_mass_MeV": 0.0}) + out = str(tmp_path / "overrides.csv") + main([src, "--output", out, "--e-step", "0.02", "--neutrino-mass", "0.15"]) + + header = header_lines_of(out) + assert any("neutrino_mass_MeV: 0.150000" in h for h in header) + expected_points = int(np.arange(0.02, 0.294, 0.02).size) + assert n_points_of(header) == expected_points + + def test_json_file_values_used_when_flags_absent(self, tmp_path: Any) -> None: + src = write_json(tmp_path, {"e_step_MeV": 0.005}) + out = str(tmp_path / "file_values.csv") + main([src, "--output", out]) + + header = header_lines_of(out) + expected_points = int(np.arange(0.005, 0.294, 0.005).size) + assert n_points_of(header) == expected_points + + +class TestCLIDegenerateFlows: + """Degenerate exports (m_nu above every endpoint) must fail cleanly.""" + + def test_degenerate_intensity_mode_exits_nonzero_no_file( + self, tmp_path: Any + ) -> None: + src = write_json(tmp_path) + out = str(tmp_path / "deg.csv") + with pytest.raises(SystemExit) as exc: + main([src, "--output", out, "--neutrino-mass", "0.35"]) + assert exc.value.code == 1 + assert not (tmp_path / "deg.csv").exists() + + def test_degenerate_to_one_mode_exits_nonzero_no_file(self, tmp_path: Any) -> None: + src = write_json(tmp_path) + out = str(tmp_path / "deg_to_one.csv") + with pytest.raises(SystemExit) as exc: + main( + [ + src, + "--output", + out, + "--neutrino-mass", + "0.35", + "--normalize-to-one", + ] + ) + assert exc.value.code == 1 + assert not (tmp_path / "deg_to_one.csv").exists() + + +@pytest.mark.requires_ensdf +class TestCLIPaceENSDF: + """paceENSDF flow honors the same export options.""" + + def test_paceensdf_honors_normalize_to_one(self, tmp_path: Any) -> None: + out = str(tmp_path / "p32.csv") + main(["P32", "--output", out, "--normalize-to-one"]) + + df = read_export(out) + assert "total_spectrum" not in df.columns + area = np.trapezoid(df["branch_1_spectrum"], df["energy_MeV"]) + assert np.isclose(area, 1.0, rtol=1e-3) + + def test_paceensdf_degenerate_exits_nonzero_no_file(self, tmp_path: Any) -> None: + out = str(tmp_path / "tc99deg.csv") + with pytest.raises(SystemExit) as exc: + main(["Tc99", "--output", out, "--neutrino-mass", "0.35"]) + assert exc.value.code == 1 + assert not (tmp_path / "tc99deg.csv").exists() diff --git a/tests/physics/test_neutrino_mass_effects.py b/tests/physics/test_neutrino_mass_effects.py index 6a84cc6..67bfafb 100644 --- a/tests/physics/test_neutrino_mass_effects.py +++ b/tests/physics/test_neutrino_mass_effects.py @@ -118,10 +118,15 @@ class TestNeutrinoMassCLIIntegration: assert args.neutrino_mass == 0.01, "CLI should parse neutrino_mass correctly" def test_cli_neutrino_mass_default_value(self): - """Test that CLI neutrino mass parameter defaults to 0.0.""" + """Unset flag parses as None ("not provided"); the effective + default (0.0 MeV) is applied when the config is built.""" from beta_spectrum.cli import _build_parser parser = _build_parser() args = parser.parse_args(["--nuclide", "Tc99"]) - assert args.neutrino_mass == 0.0, "CLI neutrino_mass default should be 0.0" + assert args.neutrino_mass is None, ( + "unset --neutrino-mass must parse as None so JSON file values " + "are not overwritten" + ) + assert hasattr(args, "neutrino_mass") diff --git a/tests/quality/test_neutrino_mass_cli.py b/tests/quality/test_neutrino_mass_cli.py index 0847126..88eddb3 100644 --- a/tests/quality/test_neutrino_mass_cli.py +++ b/tests/quality/test_neutrino_mass_cli.py @@ -76,10 +76,14 @@ class TestCLIArguments: assert args.neutrino_mass == 0.01, "CLI neutrino_mass should be 0.01" def test_neutrino_mass_cli_default_value(self): - """Test that --neutrino-mass defaults to 0.0 in CLI.""" + """Unset flag parses as None ("not provided") so JSON file + values are not overwritten; the effective default (0.0 MeV) is + applied when the config is built.""" from beta_spectrum.cli import _build_parser parser = _build_parser() args = parser.parse_args(["--nuclide", "Tc99"]) - assert args.neutrino_mass == 0.0, "CLI neutrino_mass default should be 0.0" + assert ( + args.neutrino_mass is None + ), "unset --neutrino-mass must parse as None (not a value)"