feat(cli): propagate export options through JSON/paceENSDF flows
- main(argv) signature enables in-process CLI invocation and testing - parser defaults for --e-step/--neutrino-mass/--intensity-cutoff/ --csv-separator/--normalize-to-one now None = 'not provided' - JSON flow applies file values by default and CLI flags as overrides (previously these five options were silently discarded) - paceENSDF flow resolves None flags to literal defaults - new tests/integration/test_cli_flows.py: JSON propagation, degenerate-mode non-zero exit with no file (JSON + paceENSDF) - adapted two neutrino-mass default tests to None-parsing semantics
This commit is contained in:
+58
-25
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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)"
|
||||
|
||||
Reference in New Issue
Block a user