feat(cli): separate-branches rename, short aliases, clean dry-run

- --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)
This commit is contained in:
2026-10-03 01:08:51 +03:00
parent 7f47a876ae
commit 2788ea0683
12 changed files with 210 additions and 53 deletions
+2 -2
View File
@@ -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: - `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 - 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` - `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`) - `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 - `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 - **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 - **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 - **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) - **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)
+7 -1
View File
@@ -322,7 +322,7 @@ ______________________________________________________________________
## Current Status ## 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) ### Completed (v0.3.x)
@@ -354,6 +354,12 @@ ______________________________________________________________________
- README updated with new architecture diagram and Quick Start examples - README updated with new architecture diagram and Quick Start examples
- Version bumped to 0.4.0 - 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 ### Next Immediate Step
- **Track A: ⁹⁹Tc spectrum-shape analysis** — start real data processing pipeline - **Track A: ⁹⁹Tc spectrum-shape analysis** — start real data processing pipeline
+1 -1
View File
@@ -52,4 +52,4 @@ __all__ = [
"DEFAULT_JSON_SCHEMA", "DEFAULT_JSON_SCHEMA",
] ]
__version__ = "0.4.0" __version__ = "0.4.1"
+19 -6
View File
@@ -65,12 +65,14 @@ def _build_parser() -> argparse.ArgumentParser:
# Source selection flags (backward compatible, mutually exclusive) # Source selection flags (backward compatible, mutually exclusive)
source_group = parser.add_mutually_exclusive_group(required=False) source_group = parser.add_mutually_exclusive_group(required=False)
source_group.add_argument( source_group.add_argument(
"-N",
"--nuclide", "--nuclide",
type=str, type=str,
help="Nuclide symbol for paceENSDF lookup (e.g. 'Tc99', 'Co60'). " help="Nuclide symbol for paceENSDF lookup (e.g. 'Tc99', 'Co60'). "
"Retrieves decay parameters from the ENSDF database via paceENSDF.", "Retrieves decay parameters from the ENSDF database via paceENSDF.",
) )
source_group.add_argument( source_group.add_argument(
"-i",
"--input", "--input",
type=str, type=str,
metavar="FILE", metavar="FILE",
@@ -79,6 +81,7 @@ def _build_parser() -> argparse.ArgumentParser:
# Common parameters # Common parameters
parser.add_argument( parser.add_argument(
"-s",
"--e-step", "--e-step",
type=float, type=float,
default=None, default=None,
@@ -87,6 +90,7 @@ def _build_parser() -> argparse.ArgumentParser:
"omitted). All energies are in MeV throughout the toolkit.", "omitted). All energies are in MeV throughout the toolkit.",
) )
parser.add_argument( parser.add_argument(
"-m",
"--neutrino-mass", "--neutrino-mass",
type=float, type=float,
default=None, default=None,
@@ -97,6 +101,7 @@ def _build_parser() -> argparse.ArgumentParser:
# Output # Output
parser.add_argument( parser.add_argument(
"-o",
"--output", "--output",
type=str, type=str,
metavar="FILE", metavar="FILE",
@@ -105,6 +110,7 @@ def _build_parser() -> argparse.ArgumentParser:
# Plotting # Plotting
parser.add_argument( parser.add_argument(
"-p",
"--plot", "--plot",
type=str, type=str,
metavar="FILE", metavar="FILE",
@@ -128,6 +134,7 @@ def _build_parser() -> argparse.ArgumentParser:
help="Suppress all terminal output (only errors shown).", help="Suppress all terminal output (only errors shown).",
) )
log_group.add_argument( log_group.add_argument(
"-L",
"--log-file", "--log-file",
type=str, type=str,
metavar="PATH", metavar="PATH",
@@ -135,11 +142,13 @@ def _build_parser() -> argparse.ArgumentParser:
"Log file is always opened in append mode at DEBUG level.", "Log file is always opened in append mode at DEBUG level.",
) )
log_group.add_argument( log_group.add_argument(
"-d",
"--dry-run", "--dry-run",
action="store_true", action="store_true",
help="Validate input and display resolved configuration without computing spectrum.", help="Validate input and display resolved configuration without computing spectrum.",
) )
log_group.add_argument( log_group.add_argument(
"-c",
"--intensity-cutoff", "--intensity-cutoff",
type=float, type=float,
default=None, default=None,
@@ -149,6 +158,7 @@ def _build_parser() -> argparse.ArgumentParser:
"Branches below this threshold are excluded from the calculation and plots.", "Branches below this threshold are excluded from the calculation and plots.",
) )
log_group.add_argument( log_group.add_argument(
"-V",
"--csv-verbosity", "--csv-verbosity",
type=str, type=str,
default="full", 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)", help="CSV output verbosity: 'full' (all components) or 'concise' (total spectrum and normalized branch components only) (default: full)",
) )
log_group.add_argument( log_group.add_argument(
"-S",
"--csv-separator", "--csv-separator",
type=str, type=str,
default=None, 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", "-b",
"--separate-branches",
action="store_true", action="store_true",
default=None, 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 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) 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 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. # Build config — a single delegated path for every data source.
config: SpectrumConfig config: SpectrumConfig
@@ -252,7 +266,7 @@ def _run(args: argparse.Namespace) -> None:
intensity_cutoff=intensity_cutoff, intensity_cutoff=intensity_cutoff,
neutrino_mass_MeV=neutrino_mass_MeV, neutrino_mass_MeV=neutrino_mass_MeV,
csv_separator=csv_separator, csv_separator=csv_separator,
normalize_to_one=normalize_to_one, separate_branches=separate_branches,
) )
else: else:
logger.info("Source: JSON file=%s", source_value) logger.info("Source: JSON file=%s", source_value)
@@ -268,7 +282,7 @@ def _run(args: argparse.Namespace) -> None:
config.intensity_cutoff = intensity_cutoff config.intensity_cutoff = intensity_cutoff
if args.csv_separator is not None: if args.csv_separator is not None:
config.csv_separator = csv_separator config.csv_separator = csv_separator
config.normalize_to_one = normalize_to_one config.separate_branches = separate_branches
# Dry run: display resolved config and exit # Dry run: display resolved config and exit
if args.dry_run: 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)}" f"Nuclide: {_decay_notation(config.Z_parent, config.Z_daughter, config.A_number)}"
) )
print(f"Endpoint: {config.endpoint_MeV * 1000:.1f} keV") 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"e_step: {config.e_step_MeV:.4f} MeV")
print(f"neutrino_mass_MeV: {config.neutrino_mass_MeV:.6f}") print(f"neutrino_mass_MeV: {config.neutrino_mass_MeV:.6f}")
print(f"Corrections: {', '.join(enabled) if enabled else 'none'}") print(f"Corrections: {', '.join(enabled) if enabled else 'none'}")
+7 -6
View File
@@ -477,7 +477,7 @@ def decay_info_to_config(
intensity_cutoff: float = 0.0, intensity_cutoff: float = 0.0,
neutrino_mass_MeV: float = 0.0, neutrino_mass_MeV: float = 0.0,
csv_separator: str = ",", csv_separator: str = ",",
normalize_to_one: bool = False, separate_branches: bool = False,
) -> SpectrumConfig: ) -> SpectrumConfig:
""" """
Convert DecayInfo to a SpectrumConfig. Convert DecayInfo to a SpectrumConfig.
@@ -494,8 +494,9 @@ def decay_info_to_config(
Neutrino mass in MeV. Neutrino mass in MeV.
csv_separator : str csv_separator : str
CSV column separator. CSV column separator.
normalize_to_one : bool separate_branches : bool
If True, normalize each branch to 1, ignoring intensity. If True, emit separate branch spectra, each normalized to
integrate to 1 (intensity ignored; no total spectrum column).
Returns Returns
------- -------
@@ -535,7 +536,7 @@ def decay_info_to_config(
branches=branches, branches=branches,
intensity_cutoff=intensity_cutoff, intensity_cutoff=intensity_cutoff,
csv_separator=csv_separator, 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) intensity_cutoff = kwargs.get("intensity_cutoff", 0.0)
neutrino_mass = kwargs.get("neutrino_mass_MeV", 0.0) neutrino_mass = kwargs.get("neutrino_mass_MeV", 0.0)
csv_separator = kwargs.get("csv_separator", ",") 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) info = get_decay_info_from_paceENSDF(nuclide, decay_type, decay_index)
return decay_info_to_config( return decay_info_to_config(
info, info,
@@ -716,7 +717,7 @@ def create_config_from_source(
intensity_cutoff=intensity_cutoff, intensity_cutoff=intensity_cutoff,
neutrino_mass_MeV=neutrino_mass, neutrino_mass_MeV=neutrino_mass,
csv_separator=csv_separator, csv_separator=csv_separator,
normalize_to_one=normalize_to_one, separate_branches=separate_branches,
) )
elif source == "json": elif source == "json":
if not json_path: if not json_path:
+7 -7
View File
@@ -1,7 +1,7 @@
""" """
CSV export module — CSV export functionality for BetaSpectrumAnalyzer. 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 - The basic spectrum is ALWAYS core = phase_space x fermi; the Fermi function
is a core spectrum component, never an optional correction. Higher-order is a core spectrum component, never an optional correction. Higher-order
corrections (finite size, charge distribution, screening, exchange, 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 (the total column itself is normalized to integrate to 1). In the legacy
export branch columns contained only the per-branch phase-space x export branch columns contained only the per-branch phase-space x
radiative product WITHOUT the Fermi function — physically wrong. 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. integrate to 1 individually, intensity is ignored, no total column.
""" """
@@ -62,7 +62,7 @@ class CSVEmitter:
""" """
components = analyzer.components components = analyzer.components
config = analyzer.config config = analyzer.config
to_one = config.normalize_to_one separate = config.separate_branches
n_branches = analyzer._n_branches n_branches = analyzer._n_branches
# Physical branch spectra: core(phase_space x fermi) x corrections. # 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"# e_step: {config.e_step_MeV:.4f} MeV")
header_lines.append(f"# n_points: {len(analyzer.energies_MeV)}") header_lines.append(f"# n_points: {len(analyzer.energies_MeV)}")
if to_one: if separate:
header_lines.append( header_lines.append(
"# branch normalization: to_one " "# branch normalization: separate "
"(each branch integrates to 1, intensity ignored)" "(each branch integrates to 1, intensity ignored)"
) )
else: else:
@@ -189,7 +189,7 @@ class CSVEmitter:
"is degenerate (check neutrino_mass vs branch endpoints " "is degenerate (check neutrino_mass vs branch endpoints "
"and intensity_cutoff)" "and intensity_cutoff)"
) )
if not to_one: if not separate:
data["total_spectrum"] = total_raw / integral data["total_spectrum"] = total_raw / integral
for i in range(n_branches): for i in range(n_branches):
@@ -199,7 +199,7 @@ class CSVEmitter:
else: else:
col_name = f"branch_{key_i}_spectrum" col_name = f"branch_{key_i}_spectrum"
if to_one: if separate:
area = float(np.trapezoid(physical[i], analyzer.energies_MeV)) area = float(np.trapezoid(physical[i], analyzer.energies_MeV))
if area > 0.0: if area > 0.0:
data[col_name] = physical[i] / area data[col_name] = physical[i] / area
+3 -5
View File
@@ -57,9 +57,6 @@ class SpectrumConfig:
""" """
Configuration for beta spectrum calculation. 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 Supports multi-branch decay via the `branches` field. When branches are
provided, the total spectrum is the intensity-weighted sum of individual provided, the total spectrum is the intensity-weighted sum of individual
branch spectra. Each branch has its own endpoint energy and transition type. branch spectra. Each branch has its own endpoint energy and transition type.
@@ -90,9 +87,10 @@ class SpectrumConfig:
# CSV output options # CSV output options
csv_separator: str = "," # Default separator for CSV output csv_separator: str = "," # Default separator for CSV output
normalize_to_one: bool = ( separate_branches: bool = (
False # If True, normalize each branch to 1, ignoring intensity 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]: def _effective_single_fields(config: SpectrumConfig) -> Tuple[float, str]:
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "beta-spectrum" name = "beta-spectrum"
version = "0.4.0" version = "0.4.1"
description = "High-precision beta spectrum calculation for nuclear physics" description = "High-precision beta spectrum calculation for nuclear physics"
authors = [ authors = [
{ name = "Evgeniy Unzhakov" } { name = "Evgeniy Unzhakov" }
+5 -5
View File
@@ -66,12 +66,12 @@ def n_points_of(header: List[str]) -> int:
class TestCLIJsonFlows: class TestCLIJsonFlows:
"""Options must propagate through the JSON config flow (D2).""" """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 self, tmp_path: Any
) -> None: ) -> None:
src = write_json(tmp_path) src = write_json(tmp_path)
out = str(tmp_path / "to_one.csv") 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: with open(out) as f:
body = "".join(ln for ln in f if not ln.startswith("#")) body = "".join(ln for ln in f if not ln.startswith("#"))
@@ -136,7 +136,7 @@ class TestCLIDegenerateFlows:
out, out,
"--neutrino-mass", "--neutrino-mass",
"0.35", "0.35",
"--normalize-to-one", "--separate-branches",
] ]
) )
assert exc.value.code == 1 assert exc.value.code == 1
@@ -147,9 +147,9 @@ class TestCLIDegenerateFlows:
class TestCLIPaceENSDF: class TestCLIPaceENSDF:
"""paceENSDF flow honors the same export options.""" """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") out = str(tmp_path / "p32.csv")
main(["P32", "--output", out, "--normalize-to-one"]) main(["P32", "--output", out, "--separate-branches"])
df = read_export(out) df = read_export(out)
assert "total_spectrum" not in df.columns assert "total_spectrum" not in df.columns
+20 -18
View File
@@ -313,8 +313,8 @@ class TestCSVExportCoreSemantics:
assert "phase_space" in core_line[0] assert "phase_space" in core_line[0]
assert "fermi" in core_line[0] assert "fermi" in core_line[0]
def test_normalize_to_one_per_branch_unit_area(self, tmp_path: Any) -> None: def test_separate_branches_per_branch_unit_area(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) spectrum = BetaSpectrum.from_config(config)
analyzer = BetaSpectrumAnalyzer(spectrum, config) analyzer = BetaSpectrumAnalyzer(spectrum, config)
_, df = export_and_read(analyzer, tmp_path) _, df = export_and_read(analyzer, tmp_path)
@@ -334,12 +334,12 @@ class TestCSVExportCoreSemantics:
analyzer.export_to_csv(path) analyzer.export_to_csv(path)
assert not (tmp_path / "degenerate.csv").exists() 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 self, tmp_path: Any
) -> None: ) -> 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.""" 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) spectrum = BetaSpectrum.from_config(config)
analyzer = BetaSpectrumAnalyzer(spectrum, config) analyzer = BetaSpectrumAnalyzer(spectrum, config)
path = str(tmp_path / "degenerate_to_one.csv") path = str(tmp_path / "degenerate_to_one.csv")
@@ -567,13 +567,13 @@ class TestAnalyzerUniversalFactor:
class TestNormalizationModeMatrix: 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.""" 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 self, tmp_path: Any
) -> None: ) -> None:
config = make_multi_config(normalize_to_one=True) config = make_multi_config(separate_branches=True)
spectrum = BetaSpectrum.from_config(config) spectrum = BetaSpectrum.from_config(config)
analyzer = BetaSpectrumAnalyzer(spectrum, config) analyzer = BetaSpectrumAnalyzer(spectrum, config)
path, df = export_and_read( path, df = export_and_read(
@@ -587,14 +587,14 @@ class TestNormalizationModeMatrix:
"branch_3", "branch_3",
} }
header = header_lines_of(path) 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() energies = df["energy_MeV"].to_numpy()
for col in ("branch_1", "branch_2", "branch_3"): for col in ("branch_1", "branch_2", "branch_3"):
area = np.trapezoid(df[col].to_numpy(), energies) area = np.trapezoid(df[col].to_numpy(), energies)
assert np.isclose(area, 1.0, rtol=5e-4) assert np.isclose(area, 1.0, rtol=5e-4)
def test_to_one_single_mode_unit_area(self, tmp_path: Any) -> None: def test_separate_branches_single_mode_unit_area(self, tmp_path: Any) -> None:
config = make_single_config(normalize_to_one=True) config = make_single_config(separate_branches=True)
spectrum = BetaSpectrum.from_config(config) spectrum = BetaSpectrum.from_config(config)
analyzer = BetaSpectrumAnalyzer(spectrum, config) analyzer = BetaSpectrumAnalyzer(spectrum, config)
_path, df = export_and_read(analyzer, tmp_path, "to_one_single.csv") _path, df = export_and_read(analyzer, tmp_path, "to_one_single.csv")
@@ -622,10 +622,10 @@ class TestNormalizationModeMatrix:
nonzero = total > 1e-12 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( def test_plot_from_csv_separate_branches_plots_branches_without_total(
self, tmp_path: Any self, tmp_path: Any
) -> None: ) -> None:
config = make_multi_config(normalize_to_one=True) config = make_multi_config(separate_branches=True)
spectrum = BetaSpectrum.from_config(config) spectrum = BetaSpectrum.from_config(config)
analyzer = BetaSpectrumAnalyzer(spectrum, config) analyzer = BetaSpectrumAnalyzer(spectrum, config)
path, df = export_and_read(analyzer, tmp_path, "to_one_plot.csv") 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) fig = Visualizer.plot_from_csv(path, save_path=None, show_components=True)
assert len(fig.axes) == 4 assert len(fig.axes) == 4
# Panel 1 has exactly the 3 branch curves — no total spectrum # 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] panel = fig.axes[0]
curves = [np.asarray(ln.get_xydata()) for ln in panel.lines] curves = [np.asarray(ln.get_xydata()) for ln in panel.lines]
assert len(curves) == 3 assert len(curves) == 3
@@ -641,8 +641,10 @@ class TestNormalizationModeMatrix:
assert np.allclose(curves[0][:, 0], energies) 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: def test_plot_analysis_separate_branches_returns_figure(
config = make_multi_config(normalize_to_one=True) self, tmp_path: Any
) -> None:
config = make_multi_config(separate_branches=True)
spectrum = BetaSpectrum.from_config(config) spectrum = BetaSpectrum.from_config(config)
analyzer = BetaSpectrumAnalyzer(spectrum, config) analyzer = BetaSpectrumAnalyzer(spectrum, config)
save = str(tmp_path / "analysis.png") save = str(tmp_path / "analysis.png")
@@ -650,10 +652,10 @@ class TestNormalizationModeMatrix:
assert len(fig.axes) == 4 assert len(fig.axes) == 4
assert (tmp_path / "analysis.png").exists() 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 self, tmp_path: Any, capsys: Any
) -> None: ) -> None:
config = make_multi_config(normalize_to_one=True) config = make_multi_config(separate_branches=True)
spectrum = BetaSpectrum.from_config(config) spectrum = BetaSpectrum.from_config(config)
analyzer = BetaSpectrumAnalyzer(spectrum, config) analyzer = BetaSpectrumAnalyzer(spectrum, config)
path, df = export_and_read( path, df = export_and_read(
+137
View File
@@ -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
+1 -1
View File
@@ -154,7 +154,7 @@ class TestCSVMetadataHeader:
lines = f.readlines() lines = f.readlines()
assert any("# beta-spectrum v" in line for line in lines) 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) os.unlink(filename)
def test_header_contains_timestamp(self): def test_header_contains_timestamp(self):