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(
|
parser.add_argument(
|
||||||
"--e-step",
|
"--e-step",
|
||||||
type=float,
|
type=float,
|
||||||
default=0.001,
|
default=None,
|
||||||
help="Energy step size in MeV (default: 0.001). All energies are in MeV throughout the toolkit.",
|
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(
|
parser.add_argument(
|
||||||
"--neutrino-mass",
|
"--neutrino-mass",
|
||||||
type=float,
|
type=float,
|
||||||
default=0.0,
|
default=None,
|
||||||
help="Neutrino mass in MeV (default: 0.0). Set to non-zero value to include neutrino mass effects in phase space calculation.",
|
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
|
# Output
|
||||||
@@ -138,7 +142,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||||||
log_group.add_argument(
|
log_group.add_argument(
|
||||||
"--intensity-cutoff",
|
"--intensity-cutoff",
|
||||||
type=float,
|
type=float,
|
||||||
default=0.0,
|
default=None,
|
||||||
metavar="FRAC",
|
metavar="FRAC",
|
||||||
help="Minimum branch intensity fraction to include in the spectrum "
|
help="Minimum branch intensity fraction to include in the spectrum "
|
||||||
"(default: 0.0 = include all branches). "
|
"(default: 0.0 = include all branches). "
|
||||||
@@ -154,12 +158,13 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||||||
log_group.add_argument(
|
log_group.add_argument(
|
||||||
"--csv-separator",
|
"--csv-separator",
|
||||||
type=str,
|
type=str,
|
||||||
default=",",
|
default=None,
|
||||||
help="CSV column separator (default: ','). Use 'tab' for tab separation.",
|
help="CSV column separator (default: ','). Use 'tab' for tab separation.",
|
||||||
)
|
)
|
||||||
log_group.add_argument(
|
log_group.add_argument(
|
||||||
"--normalize-to-one",
|
"--normalize-to-one",
|
||||||
action="store_true",
|
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="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:
|
else:
|
||||||
source_type = "paceENSDF"
|
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
|
# Build config
|
||||||
config: SpectrumConfig
|
config: SpectrumConfig
|
||||||
if source_type == "paceENSDF":
|
if source_type == "paceENSDF":
|
||||||
@@ -251,7 +267,7 @@ def _run(args: argparse.Namespace) -> None:
|
|||||||
intensity=b.intensity / total_intensity,
|
intensity=b.intensity / total_intensity,
|
||||||
)
|
)
|
||||||
for b in decay_info.branches
|
for b in decay_info.branches
|
||||||
if b.intensity / total_intensity >= args.intensity_cutoff
|
if b.intensity / total_intensity >= intensity_cutoff
|
||||||
]
|
]
|
||||||
config = SpectrumConfig(
|
config = SpectrumConfig(
|
||||||
Z_parent=decay_info.Z_parent,
|
Z_parent=decay_info.Z_parent,
|
||||||
@@ -259,37 +275,48 @@ def _run(args: argparse.Namespace) -> None:
|
|||||||
A_number=decay_info.A_number,
|
A_number=decay_info.A_number,
|
||||||
endpoint_MeV=decay_info.endpoint_MeV,
|
endpoint_MeV=decay_info.endpoint_MeV,
|
||||||
transition_type=decay_info.transition_type,
|
transition_type=decay_info.transition_type,
|
||||||
e_step_MeV=args.e_step,
|
e_step_MeV=e_step_MeV,
|
||||||
neutrino_mass_MeV=args.neutrino_mass,
|
neutrino_mass_MeV=neutrino_mass_MeV,
|
||||||
branches=branches,
|
branches=branches,
|
||||||
intensity_cutoff=args.intensity_cutoff,
|
intensity_cutoff=intensity_cutoff,
|
||||||
csv_separator=args.csv_separator,
|
csv_separator=csv_separator,
|
||||||
normalize_to_one=args.normalize_to_one,
|
normalize_to_one=normalize_to_one,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
config = create_config_from_source(
|
config = create_config_from_source(
|
||||||
"paceENSDF",
|
"paceENSDF",
|
||||||
nuclide=source_value,
|
nuclide=source_value,
|
||||||
e_step_MeV=args.e_step,
|
e_step_MeV=e_step_MeV,
|
||||||
intensity_cutoff=args.intensity_cutoff,
|
intensity_cutoff=intensity_cutoff,
|
||||||
neutrino_mass_MeV=args.neutrino_mass,
|
neutrino_mass_MeV=neutrino_mass_MeV,
|
||||||
csv_separator=args.csv_separator,
|
csv_separator=csv_separator,
|
||||||
normalize_to_one=args.normalize_to_one,
|
normalize_to_one=normalize_to_one,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
config = create_config_from_source(
|
config = create_config_from_source(
|
||||||
"paceENSDF",
|
"paceENSDF",
|
||||||
nuclide=source_value,
|
nuclide=source_value,
|
||||||
e_step_MeV=args.e_step,
|
e_step_MeV=e_step_MeV,
|
||||||
intensity_cutoff=args.intensity_cutoff,
|
intensity_cutoff=intensity_cutoff,
|
||||||
neutrino_mass_MeV=args.neutrino_mass,
|
neutrino_mass_MeV=neutrino_mass_MeV,
|
||||||
csv_separator=args.csv_separator,
|
csv_separator=csv_separator,
|
||||||
normalize_to_one=args.normalize_to_one,
|
normalize_to_one=normalize_to_one,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.info("Source: JSON file=%s", source_value)
|
logger.info("Source: JSON file=%s", source_value)
|
||||||
data = load_json_input(source_value)
|
data = load_json_input(source_value)
|
||||||
config = json_to_config(data)
|
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
|
# Dry run: display resolved config and exit
|
||||||
if args.dry_run:
|
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)}"
|
return f"{_nuclide_symbol(Z_parent, A)} -> {_nuclide_symbol(Z_daughter, A)}"
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main(argv: list[str] | None = None) -> None:
|
||||||
"""Main entry point for the CLI."""
|
"""Main entry point for the CLI.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
argv : list[str], optional
|
||||||
|
Explicit argument vector (for tests/embedding); None reads sys.argv.
|
||||||
|
"""
|
||||||
parser = _build_parser()
|
parser = _build_parser()
|
||||||
args = parser.parse_args()
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_run(args)
|
_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"
|
assert args.neutrino_mass == 0.01, "CLI should parse neutrino_mass correctly"
|
||||||
|
|
||||||
def test_cli_neutrino_mass_default_value(self):
|
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
|
from beta_spectrum.cli import _build_parser
|
||||||
|
|
||||||
parser = _build_parser()
|
parser = _build_parser()
|
||||||
args = parser.parse_args(["--nuclide", "Tc99"])
|
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"
|
assert args.neutrino_mass == 0.01, "CLI neutrino_mass should be 0.01"
|
||||||
|
|
||||||
def test_neutrino_mass_cli_default_value(self):
|
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
|
from beta_spectrum.cli import _build_parser
|
||||||
|
|
||||||
parser = _build_parser()
|
parser = _build_parser()
|
||||||
|
|
||||||
args = parser.parse_args(["--nuclide", "Tc99"])
|
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