Compare commits

...
22 Commits
Author SHA1 Message Date
unzhakov 67de4e3baa style: canonicalize black formatting on test files 2026-10-03 01:31:35 +03:00
unzhakov 2788ea0683 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)
2026-10-03 01:08:51 +03:00
unzhakov 7f47a876ae style: apply black formatting to test files 2026-10-02 14:32:04 +03:00
unzhakov 7e31ac4b4e docs: reflect normalization fixes, detector cleanup and CLI overrides
- README: remove stale detector_response.py tree entry and the
  detector-models highlight (module removed); document per-branch
  measured transition endpoints from paceENSDF and JSON-flow CLI
  override semantics
- TODO: drop the DEFAULT_JSON_SCHEMA detector-keys clause (removed)
- data/custom_input_example.json: strip dead detector_* keys
2026-10-02 12:01:45 +03:00
unzhakov 36174b5d25 refactor: remove stale detector schema keys and single-mode duplicates
- DEFAULT_JSON_SCHEMA: drop the 8 detector_* keys left over from the
  removed detector_response module; CLI dry-run drops the dead
  'Detector: not configured (Phase 4)' line
- CSV export: single-mode no longer writes branch_1_PhaseSpace /
  branch_1_Radiative duplicates next to the bare PhaseSpace/Radiative
  columns; per-branch core factors only in multi-branch mode
- Visualizer: debug panels fall back to the unprefixed core-factor
  columns for single-mode CSVs instead of skipping them
- Spectrum ylabel now states the actual units: normalized spectrum
  [MeV^-1], not 'Normalized Counts / keV'
- tests: defaults assert detector keys gone; column layout pinned for
  single vs multi; panel and ylabel assertions
2026-10-02 10:21:07 +03:00
unzhakov 3590ca91a1 fix(nuclear_data): use measured transition endpoints per branch
paceENSDF stores the measured beta endpoint energy of each transition
(ENSDF column 7). The parser instead derived branch endpoints as
base-endpoint minus level energy with the base taken from an arbitrary
first-listed branch, so Co60 produced negative branch endpoints and a
hard ValueError (bs_pnpi Co60 exited 1 regardless of cutoff).

- BranchInfo carries the measured endpoint_MeV
- overall endpoint = max measured endpoint over all branches
- decay_info_to_config prefers measured endpoints; the subtraction is
  retained only as a legacy fallback for hand-built DecayInfo objects
- Co60/Tc99/Cs137 endpoint tests + CLI dry-run/export tests (D3)
2026-10-02 08:42:51 +03:00
unzhakov 9c3dd80b5c refactor(cli): delegate paceENSDF config building to library
Remove the duplicated inline paceENSDF config builder in the CLI.
It renormalized branch intensities before filtering, diverging from
the library decay_info_to_config path that library callers use
(audit finding D5). CLI paceENSDF configs now come from
create_config_from_source exactly once per run.

Regression test spies on create_config_from_source during a CLI run.
2026-10-02 07:13:14 +03:00
unzhakov d166532779 test: cover to_one/concise/single/plot normalization combinations 2026-10-02 06:31:38 +03:00
unzhakov 18a46ad02f 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
2026-10-02 05:46:08 +03:00
unzhakov 62bf7fdac2 fix(output): apply positive-integral export guard to to_one mode
CSVEmitter.export_to_csv raised ValueError only in intensity mode;
degenerate exports with --normalize-to-one silently wrote all-zero
CSV files. The guard now runs before the mode split and no file is
written in either mode.
2026-10-01 23:02:33 +03:00
unzhakov ee7aedc80a fix(spectrum): lone branch defines endpoint in single-branch mode
When config.branches carries exactly one branch (e.g. after
--intensity-cutoff filtering), the branch endpoint/transition define
the spectrum, energy grid, analyzer unified branch view and CSV
header. The stale top-level endpoint inherited from the parent decay
no longer leaks into physics (proven wrong on Cs137: spectrum
extended to 1176 keV while the lone survivor ends at 514 keV).
2026-10-01 22:04:17 +03:00
unzhakov 6bb7b950c7 style: apply black formatting to pending files 2026-09-30 10:41:28 +03:00
unzhakov ea4b652ccb chore: ignore ROOT data files produced by notebooks 2026-09-30 03:16:45 +03:00
unzhakov 40d0836056 docs: document core spectrum, physical branch columns, CSV-driven plots 2026-09-30 03:11:05 +03:00
unzhakov f5b79000a4 fix(scripts): list min/max calls, typed mains, drop unused header reads 2026-09-30 03:11:05 +03:00
unzhakov dc350aacfc fix(fitter): stop referencing removed detector_response module 2026-09-30 03:08:13 +03:00
unzhakov 8172ba2541 test: adapt existing suites to unconditional core, drop dead detector tests 2026-09-30 03:08:13 +03:00
unzhakov 7dc2ec79d1 feat(cli): report core in dry-run, build plots from exported CSV 2026-09-30 03:04:18 +03:00
unzhakov afb7afc5b7 feat(output): physical branch spectra, universal factor, CSV-driven plotting 2026-09-30 03:04:18 +03:00
unzhakov bab19de458 feat: make phase space and Fermi function unconditional core components 2026-09-30 03:04:18 +03:00
unzhakov 8bc9b0fd87 test: pin core-spectrum invariant (phase_space x fermi always included) 2026-09-30 03:04:18 +03:00
unzhakov 687e05cbe2 style: reformat assertion blocks in test files 2026-09-29 13:06:22 +03:00
33 changed files with 2209 additions and 1563 deletions
+3
View File
@@ -180,3 +180,6 @@ htmlcov/
.rpiv/ .rpiv/
.pi-lens/ .pi-lens/
data/raw/ data/raw/
# ROOT data files produced by notebooks
*.root
+13 -7
View File
@@ -60,8 +60,7 @@ beta_shape_pnpi/
│ │ ├── finite_size.py ✓ L0 expansion + charge distribution U term │ │ ├── finite_size.py ✓ L0 expansion + charge distribution U term
│ │ ├── screening.py ✓ Atomic electron screening (ratio method) │ │ ├── screening.py ✓ Atomic electron screening (ratio method)
│ │ ├── exchange.py ✓ Hayen-2018 empirical fit coefficients │ │ ├── exchange.py ✓ Hayen-2018 empirical fit coefficients
│ │ ├── radiative.py ✓ Outer radiative corrections, soft-photon resummation │ │ └── radiative.py ✓ Outer radiative corrections, soft-photon resummation
│ │ └── detector_response.py ✓ Analytical detector response models
├── beta_spectrum/output/ ← output, plotting, visualization, CSV export ├── beta_spectrum/output/ ← output, plotting, visualization, CSV export
│ ├── __init__.py # BetaSpectrumAnalyzer, CSVEmitter, Visualizer │ ├── __init__.py # BetaSpectrumAnalyzer, CSVEmitter, Visualizer
│ ├── core.py # BetaSpectrumAnalyzer base class │ ├── core.py # BetaSpectrumAnalyzer base class
@@ -122,12 +121,19 @@ Defined in `beta_spectrum/spectrum.py`. Orchestrates the calculation:
In `beta_spectrum.output`. Provides debugging and visualization tools: In `beta_spectrum.output`. Provides debugging and visualization tools:
- `total_spectrum(normalize=True/False)` — computed spectrum array - `total_spectrum(normalize=True/False)` — computed spectrum array; raises `ValueError` when every branch is degenerate (non-positive integral)
- `physical_branch_spectrum(i)` — physical spectrum of branch *i*: the core (phase space × Fermi function) times every enabled higher-order correction, clamped beyond the branch endpoint (unnormalized)
- `universal_factor()` — product of the components shared across all branches (Fermi function and the universal higher-order corrections)
- `plot_analysis(save_path=None, show_components=True)` — plot with two modes: - `plot_analysis(save_path=None, show_components=True)` — plot with two modes:
- `show_components=True` (debug mode): 4-panel figure (spectrum, components, cumulative effect, deviations) - `show_components=True` (debug mode): 4-panel figure (spectrum, components, cumulative effect, deviations)
- `show_components=False` (normal mode): single spectrum plot with nuclear data header - `show_components=False` (normal mode): single spectrum plot with nuclear data header
- All plots include commit hash and UTC timestamp for traceability - All plots include commit hash and UTC timestamp for traceability
- `export_to_csv(filename)` — export via pandas DataFrame with YAML-style metadata header - Plots are built *from the exported CSV file* (temporary full-verbosity export), so a plot always shows exactly the bytes the CSV export produces
- `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 `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 - `get_data()` — return all numerical data for custom analysis
______________________________________________________________________ ______________________________________________________________________
@@ -184,9 +190,9 @@ ______________________________________________________________________
For a complete list of implemented and planned features, see [TODO.md](TODO.md). Key highlights: For a complete list of implemented and planned features, see [TODO.md](TODO.md). Key highlights:
- **Physics corrections**: phase space, Fermi function, finite size, screening, exchange, radiative (with delta_cut resummation) - **Physics corrections**: phase space, Fermi function, finite size, screening, exchange, radiative (with delta_cut resummation)
- **Detector response**: Gaussian, Gaussian+tail, Tikhonov models with tabulated support - **Detector response**: not implemented — `SpectrumModel` keeps a duck-typed optional convolution hook; the analytical models were removed (restore-or-delete decision tracked in 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) - **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 - **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)
+13 -2
View File
@@ -32,7 +32,7 @@ beta_spectrum/ ← theory-only core
│ ├── screening.py │ ├── screening.py
│ ├── exchange.py │ ├── exchange.py
│ ├── radiative.py │ ├── radiative.py
│ └── detector_response.py ← stays here (part of theory) │ └── (detector_response.py — removed; restore-or-delete pending below)
└── nuclear_data.py ← paceENSDF integration └── nuclear_data.py ← paceENSDF integration
exp_data/ ← experimental data handling exp_data/ ← experimental data handling
@@ -101,6 +101,11 @@ output/ ← output, visualization, CSV export
- [x] 322 tests pass - [x] 322 tests pass
- [x] README updated with new architecture diagram and Quick Start examples - [x] README updated with new architecture diagram and Quick Start examples
- [x] Version bumped to 0.4.0 - [x] Version bumped to 0.4.0
- [ ] Decide: restore or delete the `detector_response` module — it was removed from
`beta_spectrum/components/` together with its tests; `fitter/model.py` keeps a
duck-typed optional hook only (`DEFAULT_JSON_SCHEMA` detector keys and the CLI
dry-run detector line were removed with audit cleanup).
Restore from git history (commits up to `5f99566`) if convolution is needed again
______________________________________________________________________ ______________________________________________________________________
@@ -317,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)
@@ -349,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"
+78 -75
View File
@@ -40,7 +40,6 @@ def _build_parser() -> argparse.ArgumentParser:
" bs_pnpi --nuclide Tc99 --output spectrum.csv\n" " bs_pnpi --nuclide Tc99 --output spectrum.csv\n"
" bs_pnpi --input custom_input.json --output spectrum.csv\n" " bs_pnpi --input custom_input.json --output spectrum.csv\n"
" bs_pnpi --nuclide Tc99 --plot spectrum.png --output spectrum.csv\n" " bs_pnpi --nuclide Tc99 --plot spectrum.png --output spectrum.csv\n"
" bs_pnpi --nuclide Tc99 --detector gaussian --output convolved.csv\n"
" bs_pnpi --nuclide Tc99 --output spectrum.csv -vv --log-file /tmp/calc.log\n" " bs_pnpi --nuclide Tc99 --output spectrum.csv -vv --log-file /tmp/calc.log\n"
" bs_pnpi --nuclide Tc99 --dry-run\n" " bs_pnpi --nuclide Tc99 --dry-run\n"
), ),
@@ -66,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",
@@ -80,20 +81,27 @@ 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=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(
"-m",
"--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
parser.add_argument( parser.add_argument(
"-o",
"--output", "--output",
type=str, type=str,
metavar="FILE", metavar="FILE",
@@ -102,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",
@@ -125,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",
@@ -132,20 +142,23 @@ 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=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). "
"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",
@@ -153,15 +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=",", 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",
help="Normalize each branch to 1, ignoring intensity. In this case, total spectrum will not be produced (only energy and branch-wise spectra).", default=None,
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
@@ -174,15 +192,10 @@ def _run(args: argparse.Namespace) -> None:
from beta_spectrum.logging_utils import get_logger from beta_spectrum.logging_utils import get_logger
from beta_spectrum.nuclear_data import ( from beta_spectrum.nuclear_data import (
create_config_from_source, create_config_from_source,
get_decay_info_from_paceENSDF,
json_to_config, json_to_config,
load_json_input, load_json_input,
) )
from beta_spectrum.spectrum import ( from beta_spectrum.spectrum import BetaSpectrum
BetaSpectrum,
BranchConfig,
SpectrumConfig,
)
from beta_spectrum.output import BetaSpectrumAnalyzer from beta_spectrum.output import BetaSpectrumAnalyzer
# Determine logging level # Determine logging level
@@ -231,66 +244,45 @@ def _run(args: argparse.Namespace) -> None:
else: else:
source_type = "paceENSDF" source_type = "paceENSDF"
# Build config # 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
separate_branches = bool(args.separate_branches)
# Build config — a single delegated path for every data source.
config: SpectrumConfig config: SpectrumConfig
if source_type == "paceENSDF": if source_type == "paceENSDF":
logger.info("Source: paceENSDF nuclide=%s", source_value) logger.info("Source: paceENSDF nuclide=%s", source_value)
decay_info = get_decay_info_from_paceENSDF(source_value, "beta_minus")
# Normalize branch intensities to sum to 1.0
if decay_info.branches:
total_intensity = sum(b.intensity for b in decay_info.branches)
if total_intensity > 0:
branches = [
BranchConfig(
endpoint_MeV=(
(decay_info.endpoint_MeV - b.level_energy_keV / 1000.0)
if b.level_energy_keV > 0
else decay_info.endpoint_MeV
),
transition_type=b.transition_type,
intensity=b.intensity / total_intensity,
)
for b in decay_info.branches
if b.intensity / total_intensity >= args.intensity_cutoff
]
config = SpectrumConfig(
Z_parent=decay_info.Z_parent,
Z_daughter=decay_info.Z_daughter,
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,
branches=branches,
intensity_cutoff=args.intensity_cutoff,
csv_separator=args.csv_separator,
normalize_to_one=args.normalize_to_one,
)
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, separate_branches=separate_branches,
)
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,
) )
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.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:
@@ -326,9 +318,20 @@ def _run(args: argparse.Namespace) -> None:
) )
logger.info("Spectrum exported to %s", args.output) logger.info("Spectrum exported to %s", args.output)
# Generate plot # Generate plot — always built from the exported CSV data (single
# source of truth): when a full-verbosity CSV was exported it is
# plotted directly from that file, otherwise via a temporary export.
if args.plot: if args.plot:
analyzer = BetaSpectrumAnalyzer(spectrum, config, logger=logger) analyzer = BetaSpectrumAnalyzer(spectrum, config, logger=logger)
if args.output and args.csv_verbosity == "full":
from beta_spectrum.output.visualization import Visualizer
Visualizer.plot_from_csv(
args.output,
save_path=args.plot,
show_components=(args.verbose >= 2),
)
else:
analyzer.plot_analysis( analyzer.plot_analysis(
save_path=args.plot, save_path=args.plot,
show_components=(args.verbose >= 2), show_components=(args.verbose >= 2),
@@ -342,11 +345,8 @@ def _print_dry_run_output(config, source_type: str) -> None:
"""Print resolved configuration for dry-run mode.""" """Print resolved configuration for dry-run mode."""
from beta_spectrum.logging_utils import get_git_short_hash from beta_spectrum.logging_utils import get_git_short_hash
print("Core: phase_space × fermi (basic spectrum, always included)")
enabled = [] enabled = []
if config.use_phase_space:
enabled.append("phase_space")
if config.use_fermi:
enabled.append("fermi")
if config.use_screening: if config.use_screening:
enabled.append("screening") enabled.append("screening")
if config.use_finite_size: if config.use_finite_size:
@@ -366,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'}")
@@ -386,8 +385,6 @@ def _print_dry_run_output(config, source_type: str) -> None:
else: else:
print("Mode: single-branch") print("Mode: single-branch")
# Detector response is a Phase 4 feature — not yet implemented
print("Detector: not configured (Phase 4)")
print("========================================") print("========================================")
@@ -509,10 +506,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)
+41 -31
View File
@@ -96,6 +96,10 @@ class BranchInfo:
log(ft) value for this branch. log(ft) value for this branch.
transition_type : str transition_type : str
Forbiddenness classification for this branch (A, F1, F1U, F2, ...). Forbiddenness classification for this branch (A, F1, F1U, F2, ...).
endpoint_MeV : float
Measured beta endpoint energy of this transition in MeV (ENSDF
column 7). 0.0 means "not provided": consumers fall back to
base-endpoint minus level-energy subtraction.
""" """
level_index: int level_index: int
@@ -103,6 +107,7 @@ class BranchInfo:
intensity: float intensity: float
log_ft: Optional[float] log_ft: Optional[float]
transition_type: str = "A" transition_type: str = "A"
endpoint_MeV: float = 0.0
def _parse_nuclide_symbol(symbol: str) -> Tuple[str, int, int]: def _parse_nuclide_symbol(symbol: str) -> Tuple[str, int, int]:
@@ -384,24 +389,19 @@ def get_decay_info_from_paceENSDF(
# Extract Q-value from ground state branch (state[7] = endpoint energy in keV) # Extract Q-value from ground state branch (state[7] = endpoint energy in keV)
# and half-life from halflife_data # and half-life from halflife_data
endpoint_keV = 0.0
half_life_str = "unknown" half_life_str = "unknown"
# The spectrum endpoint is the maximum measured transition endpoint
# across all branches (ENSDF column 7, keV). Selecting the ground
# state branch or the first branch is wrong whenever those branches
# are absent or non-maximal (e.g. Co60, whose first listed branch
# endpoint is below higher-level transition endpoints).
endpoint_keV = 0.0
if beta_data: if beta_data:
for k, v in beta_data.items(): for _k, v in beta_data.items():
for state in v: for state in v:
if state[0] == 0: # ground state branch if len(state) > 7 and state[7] is not None:
endpoint_keV = float(state[7]) endpoint_keV = max(endpoint_keV, float(state[7]))
break
if endpoint_keV > 0:
break
# No ground state branch — use first available branch
if endpoint_keV == 0.0:
for k, v in beta_data.items():
if v and len(v) > 0:
endpoint_keV = float(v[0][7])
break
if halflife_data: if halflife_data:
for k, v in halflife_data.items(): for k, v in halflife_data.items():
@@ -447,6 +447,11 @@ def get_decay_info_from_paceENSDF(
intensity=float(state[9]) / 100.0, intensity=float(state[9]) / 100.0,
log_ft=float(state[11]) if state[11] is not None else None, log_ft=float(state[11]) if state[11] is not None else None,
transition_type=branch_transition_type, transition_type=branch_transition_type,
endpoint_MeV=(
float(state[7]) / 1000.0
if len(state) > 7 and state[7] is not None
else 0.0
),
) )
branches.append(branch) branches.append(branch)
@@ -472,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.
@@ -489,20 +494,28 @@ 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
------- -------
SpectrumConfig SpectrumConfig
Configuration ready for BetaSpectrum.from_config(). Configuration ready for BetaSpectrum.from_config().
""" """
# Convert BranchInfo -> BranchConfig for SpectrumConfig # Convert BranchInfo -> BranchConfig for SpectrumConfig. Per-branch
# endpoints are the measured transition endpoint energies when
# available; the base-endpoint minus level-energy subtraction is only
# a legacy fallback for hand-built DecayInfo objects without them.
branches: Optional[List[BranchConfig]] = None branches: Optional[List[BranchConfig]] = None
if info.branches: if info.branches:
branches = [ branches = [
BranchConfig( BranchConfig(
endpoint_MeV=info.endpoint_MeV - b.level_energy_keV / 1000.0, endpoint_MeV=(
b.endpoint_MeV
if b.endpoint_MeV > 0.0
else info.endpoint_MeV - b.level_energy_keV / 1000.0
),
transition_type=b.transition_type, transition_type=b.transition_type,
intensity=b.intensity, intensity=b.intensity,
) )
@@ -523,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,
) )
@@ -542,14 +555,6 @@ DEFAULT_JSON_SCHEMA = {
"transition_type": "F2", "transition_type": "F2",
"e_step_MeV": 0.001, "e_step_MeV": 0.001,
"neutrino_mass_MeV": 0.0, "neutrino_mass_MeV": 0.0,
"detector_model": "gaussian",
"detector_sigma_a_keV": 1.0,
"detector_sigma_b": 0.0,
"detector_tail_fraction": 0.0,
"detector_tau_keV": 5.0,
"detector_fano_factor": 0.12,
"detector_n_channels": 4096,
"detector_channel_energy_range": [0.0, 0.35],
} }
@@ -615,8 +620,13 @@ def load_json_input(filepath: str) -> Dict[str, Any]:
if not path.exists(): if not path.exists():
raise FileNotFoundError(f"Input file not found: {filepath}") raise FileNotFoundError(f"Input file not found: {filepath}")
with open(path, "r") as f: try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f) data = json.load(f)
except OSError as exc:
raise FileNotFoundError(f"Input file not readable: {filepath}") from exc
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid JSON in input file {filepath}: {exc}") from exc
# Fill in defaults for missing optional fields # Fill in defaults for missing optional fields
for key, default in DEFAULT_JSON_SCHEMA.items(): for key, default in DEFAULT_JSON_SCHEMA.items():
@@ -699,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,
@@ -707,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:
-3
View File
@@ -12,9 +12,6 @@ from .core import BetaSpectrumAnalyzer, _UnifiedBranch
from .csv_export import CSVEmitter from .csv_export import CSVEmitter
from .visualization import Visualizer from .visualization import Visualizer
# Re-export the main class for backward compatibility
BetaSpectrumAnalyzer = BetaSpectrumAnalyzer
__all__ = [ __all__ = [
"BetaSpectrumAnalyzer", "BetaSpectrumAnalyzer",
"CSVEmitter", "CSVEmitter",
+90 -36
View File
@@ -14,6 +14,8 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional
import numpy as np import numpy as np
from beta_spectrum.components.phase_space import PhaseSpace
from beta_spectrum.components.radiative import RadiativeCorrection
from beta_spectrum.utils import T_to_W from beta_spectrum.utils import T_to_W
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -47,17 +49,26 @@ class BetaSpectrumAnalyzer:
self.config = config self.config = config
self.W, self.energies_MeV = spectrum.get_energy_grid(config) self.W, self.energies_MeV = spectrum.get_energy_grid(config)
self._components_cache: Optional[Dict[str, np.ndarray]] = None self._components_cache: Optional[Dict[str, np.ndarray]] = None
self._branch_spectra_cache: Optional[List[np.ndarray]] = None self._universal_cache: Optional[np.ndarray] = None
self._physical_branch_cache: Dict[int, np.ndarray] = {}
self._logger = logger self._logger = logger
self._is_multi = spectrum._is_multi self._is_multi = spectrum._is_multi
self._timestamp: Optional[str] = None self._timestamp: Optional[str] = None
# Unified branch view: single-branch treated as one-branch multi-branch # Unified branch view: single-branch treated as one-branch multi-branch.
# When config.branches carries exactly one branch, that branch (not the
# top-level decay fields) defines the endpoint and transition type.
if not self._is_multi and self.spectrum.branch_spectra == []: if not self._is_multi and self.spectrum.branch_spectra == []:
endpoint_MeV = config.endpoint_MeV
transition_type = config.transition_type
if config.branches is not None and len(config.branches) == 1:
lone = config.branches[0]
endpoint_MeV = float(lone.endpoint_MeV)
transition_type = str(lone.transition_type)
self._unified_branches: List[_UnifiedBranch] = [ self._unified_branches: List[_UnifiedBranch] = [
_UnifiedBranch( _UnifiedBranch(
endpoint_MeV=config.endpoint_MeV, endpoint_MeV=endpoint_MeV,
transition_type=config.transition_type, transition_type=transition_type,
intensity=1.0, intensity=1.0,
) )
] ]
@@ -82,15 +93,76 @@ class BetaSpectrumAnalyzer:
assert self._components_cache is not None assert self._components_cache is not None
return self._components_cache return self._components_cache
@property def universal_factor(self) -> np.ndarray:
def branch_spectra(self) -> List[np.ndarray]: """Product of the components shared by all branches.
"""Intensity-weighted branch spectra."""
if self._branch_spectra_cache is None: The universal factor always contains the Fermi function (the core
self._branch_spectra_cache = self.spectrum.get_branch_normalized_spectra( 0-order Coulomb correction) and every enabled higher-order
self.W correction that does not depend on the branch endpoint
(screening, finite size, charge distribution, exchange).
In single-branch mode this equals the product of all components
except the per-branch phase space and radiative corrections.
"""
if self._universal_cache is None:
prod: Optional[np.ndarray] = None
for comp in self.spectrum.components:
if not self._is_multi and isinstance(
comp, (PhaseSpace, RadiativeCorrection)
):
continue
values = comp(self.W)
prod = (
np.array(values, dtype=np.float64)
if prod is None
else prod * values
) )
assert self._branch_spectra_cache is not None if prod is None:
return self._branch_spectra_cache prod = np.ones_like(self.W, dtype=np.float64)
self._universal_cache = prod
assert self._universal_cache is not None
return self._universal_cache
def physical_branch_spectrum(self, i: int) -> np.ndarray:
"""Physical spectrum of branch i: core × corrections, unnormalized.
The physical branch spectrum is
core(phase_space × fermi) × all enabled higher-order corrections,
masked to zero beyond the branch endpoint. This is the canonical
branch accessor: every consumer (CSV export, plots) must use it so
that the Fermi function is always present in branch-level spectra.
"""
if i in self._physical_branch_cache:
return self._physical_branch_cache[i]
if i < len(self.spectrum.branch_spectra):
raw = self.spectrum.branch_spectra[i](self.W)
W0 = T_to_W(self._unified_branches[i].endpoint_MeV)
raw = np.where(self.W <= W0, raw, 0.0)
physical = self.universal_factor() * raw
else:
# Single-branch mode: the spectrum itself is the full physical branch
physical = np.asarray(self.spectrum(self.W), dtype=np.float64)
self._physical_branch_cache[i] = physical
return physical
def total_integral(self) -> float:
"""Integral of the raw total spectrum over kinetic energy.
Raises
------
ValueError
If the integral is not positive (e.g. all branches are
degenerate because the neutrino mass exceeds every endpoint).
"""
total_raw = self.spectrum(self.W)
integral = float(np.trapezoid(total_raw, self.energies_MeV))
if integral <= 0.0:
raise ValueError(
"Total spectrum integral is non-positive: every branch is "
"degenerate (check neutrino_mass vs branch endpoints and "
"intensity_cutoff)"
)
return integral
def total_spectrum(self, normalize: bool = True) -> np.ndarray: def total_spectrum(self, normalize: bool = True) -> np.ndarray:
"""Calculate the total spectrum.""" """Calculate the total spectrum."""
@@ -98,6 +170,12 @@ class BetaSpectrumAnalyzer:
if normalize: if normalize:
integral = np.trapezoid(total, self.energies_MeV) integral = np.trapezoid(total, self.energies_MeV)
if integral <= 0.0:
raise ValueError(
"Total spectrum integral is non-positive: every branch "
"is degenerate (check neutrino_mass vs branch endpoints "
"and intensity_cutoff)"
)
total = total / integral total = total / integral
return total return total
@@ -111,36 +189,12 @@ class BetaSpectrumAnalyzer:
"""Number of branches (unified view).""" """Number of branches (unified view)."""
return len(self._unified_branches) return len(self._unified_branches)
def _get_branch_spectrum(self, i: int) -> np.ndarray:
"""Get the spectrum array for branch i."""
if i < len(self.spectrum.branch_spectra):
bs_obj = self.spectrum.branch_spectra[i]
# branch_spectra stores BetaSpectrum objects — call them to get arrays
if hasattr(bs_obj, "__call__"):
val = bs_obj(self.W)
# Clamp to zero beyond endpoint (phase space goes negative)
W0 = T_to_W(self._unified_branches[i].endpoint_MeV)
return np.where(self.W <= W0, val, 0.0)
return np.asarray(bs_obj)
# Single-branch: the spectrum itself is the only branch
return self.spectrum(self.W)
def _get_component_key(self, i: int, name: str) -> str: def _get_component_key(self, i: int, name: str) -> str:
"""Get the canonical key for a per-branch component.""" """Get the canonical key for a per-branch component."""
if self._is_multi: if self._is_multi:
return f"branch_{i}.{name}" return f"branch_{i}.{name}"
return name return name
def _has_component(self, i: int, name: str) -> bool:
"""Check if branch i has a given component."""
key = self._get_component_key(i, name)
return key in self.components
def _get_component(self, i: int, name: str) -> np.ndarray:
"""Get a per-branch component array."""
key = self._get_component_key(i, name)
return self.components[key]
def _element_symbol(self, Z: int) -> str: def _element_symbol(self, Z: int) -> str:
"""Return chemical symbol for atomic number Z.""" """Return chemical symbol for atomic number Z."""
symbols = [ symbols = [
+132 -109
View File
@@ -1,14 +1,28 @@
""" """
CSV export module — CSV export functionality for BetaSpectrumAnalyzer. CSV export module — CSV export functionality for BetaSpectrumAnalyzer.
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,
radiative) are multiplied on top of the core.
- ``branch_{i}_spectrum`` columns carry the PHYSICAL branch spectrum
(core x all enabled corrections), weighted by intensity and
area-normalized so that the sum of branch columns equals total_spectrum
(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.
- ``separate_branches`` mode: each branch column is normalized to
integrate to 1 individually, intensity is ignored, no total column.
""" """
from __future__ import annotations from __future__ import annotations
import pandas as pd
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import TYPE_CHECKING, Dict from typing import TYPE_CHECKING, Dict
import numpy as np import numpy as np
import pandas as pd
from beta_spectrum.logging_utils import get_git_short_hash from beta_spectrum.logging_utils import get_git_short_hash
@@ -37,64 +51,92 @@ class CSVEmitter:
source_type : str source_type : str
Source type (paceENSDF, json, etc.). Source type (paceENSDF, json, etc.).
csv_verbosity : str csv_verbosity : str
Verbosity level: 'full' (all components) or 'concise' (total spectrum and normalized branch components only). Verbosity level: 'full' (all components) or 'concise' (total
""" spectrum and physical branch spectra only).
total = analyzer.total_spectrum(normalize=True)
components = analyzer.components
# Only include enabled corrections that are not basic components Raises
# Basic components (phase_space and fermi) are always used and should not be listed ------
ValueError
If every branch spectrum integrates to a non-positive area
(e.g. all branch endpoints lie below the neutrino mass).
"""
components = analyzer.components
config = analyzer.config
separate = config.separate_branches
n_branches = analyzer._n_branches
# Physical branch spectra: core(phase_space x fermi) x corrections.
physical = [analyzer.physical_branch_spectrum(i) for i in range(n_branches)]
# Only higher-order corrections are toggleable; list them enabled.
enabled = [] enabled = []
if analyzer.config.use_screening: if config.use_screening:
enabled.append("screening") enabled.append("screening")
if analyzer.config.use_finite_size: if config.use_finite_size:
enabled.append("finite_size") enabled.append("finite_size")
if analyzer.config.use_charge_dist: if config.use_charge_dist:
enabled.append("charge_dist") enabled.append("charge_dist")
if analyzer.config.use_radiative: if config.use_radiative:
enabled.append("radiative") enabled.append("radiative")
if analyzer.config.use_exchange: if config.use_exchange:
enabled.append("exchange") enabled.append("exchange")
parent_symbol = analyzer._element_symbol(analyzer.config.Z_parent) # The header endpoint must describe the spectrum that is actually
daughter_symbol = analyzer._element_symbol(analyzer.config.Z_daughter) # computed. Single mode with exactly one configured branch follows
# the branch (the top-level endpoint may be stale from the parent
# decay, e.g. after intensity-cutoff filtering).
basic_endpoint_MeV = config.endpoint_MeV
if (
not analyzer._is_multi
and config.branches is not None
and len(config.branches) == 1
):
basic_endpoint_MeV = analyzer._unified_branches[0].endpoint_MeV
parent_symbol = analyzer._element_symbol(config.Z_parent)
daughter_symbol = analyzer._element_symbol(config.Z_daughter)
# Get git commit hash
git_commit = get_git_short_hash() git_commit = get_git_short_hash()
# Format source line with paceENSDF URL if applicable
if source_type == "paceENSDF": if source_type == "paceENSDF":
source_line = "# source: paceENSDF (https://pypi.org/project/paceENSDF/)" source_line = "# source: paceENSDF (https://pypi.org/project/paceENSDF/)"
else: else:
source_line = f"# source: {source_type}" source_line = f"# source: {source_type}"
header_lines = [ header_lines = [
f"# beta-spectrum v{__import__('beta_spectrum').__version__}(build.{git_commit})", f"# beta-spectrum v{__import__('beta_spectrum').__version__}"
f"(build.{git_commit})",
f"# timestamp: {datetime.now(timezone.utc).isoformat()}", f"# timestamp: {datetime.now(timezone.utc).isoformat()}",
source_line, source_line,
f"# nuclide: {parent_symbol}{analyzer.config.A_number} -> {daughter_symbol}{analyzer.config.A_number} (Z={analyzer.config.Z_parent}->{analyzer.config.Z_daughter})", f"# nuclide: {parent_symbol}{config.A_number} "
f"# basic_endpoint: {analyzer.config.endpoint_MeV * 1000:.1f} keV", f"-> {daughter_symbol}{config.A_number} "
f"(Z={config.Z_parent}->{config.Z_daughter})",
f"# basic_endpoint: {basic_endpoint_MeV * 1000:.1f} keV",
] ]
# Add neutrino mass info if non-zero if config.neutrino_mass_MeV > 0:
if analyzer.config.neutrino_mass_MeV > 0:
header_lines.append( header_lines.append(
f"# neutrino_mass_MeV: {analyzer.config.neutrino_mass_MeV:.6f} (non-zero neutrino mass effects included)" f"# neutrino_mass_MeV: {config.neutrino_mass_MeV:.6f} "
f"(non-zero neutrino mass effects included)"
) )
else: else:
header_lines.append( header_lines.append(f"# neutrino_mass_MeV: {config.neutrino_mass_MeV:.6f}")
f"# neutrino_mass_MeV: {analyzer.config.neutrino_mass_MeV:.6f}"
)
header_lines.append("# core: phase_space x fermi (basic spectrum)")
header_lines.append(f"# corrections: {', '.join(enabled)}") header_lines.append(f"# corrections: {', '.join(enabled)}")
header_lines.append(f"# e_step: {analyzer.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)}")
# Add normalization mode if separate:
if analyzer.config.normalize_to_one: header_lines.append(
header_lines.append("# branch normalization: to_one (ignoring intensity)") "# branch normalization: separate "
"(each branch integrates to 1, intensity ignored)"
)
else: else:
header_lines.append("# branch normalization: intensity") header_lines.append(
"# branch normalization: intensity "
"(sum of branch columns == total_spectrum)"
)
# Separate branches into active and excluded lists # Separate branches into active and excluded lists
active_branches = [] active_branches = []
@@ -102,118 +144,99 @@ class CSVEmitter:
for i, branch in enumerate(analyzer._unified_branches): for i, branch in enumerate(analyzer._unified_branches):
branch_endpoint_keV = branch.endpoint_MeV * 1000 branch_endpoint_keV = branch.endpoint_MeV * 1000
neutrino_mass_keV = analyzer.config.neutrino_mass_MeV * 1000 neutrino_mass_keV = config.neutrino_mass_MeV * 1000
# Check if branch is excluded due to neutrino mass
if branch_endpoint_keV <= neutrino_mass_keV: if branch_endpoint_keV <= neutrino_mass_keV:
excluded_branches.append( excluded_branches.append(
f" branch_{i + 1}: E₀={branch_endpoint_keV:.1f} keV, transition={branch.transition_type}, intensity={branch.intensity:.4f} - excluded: endpoint < m_nu" f" branch_{i + 1}: E₀={branch_endpoint_keV:.1f} keV, "
f"transition={branch.transition_type}, "
f"intensity={branch.intensity:.4f} - excluded: endpoint < m_nu"
) )
# Check if branch is excluded due to intensity cutoff elif branch.intensity < config.intensity_cutoff:
elif branch.intensity < analyzer.config.intensity_cutoff:
excluded_branches.append( excluded_branches.append(
f" branch_{i + 1}: E₀={branch_endpoint_keV:.1f} keV, transition={branch.transition_type}, intensity={branch.intensity:.4f} - excluded: intensity < cutoff" f" branch_{i + 1}: E₀={branch_endpoint_keV:.1f} keV, "
f"transition={branch.transition_type}, "
f"intensity={branch.intensity:.4f} "
"- excluded: intensity < cutoff"
) )
else: else:
active_branches.append( active_branches.append(
f" branch_{i + 1}: E₀={branch_endpoint_keV:.1f} keV, transition={branch.transition_type}, intensity={branch.intensity:.4f}" f" branch_{i + 1}: E₀={branch_endpoint_keV:.1f} keV, "
f"transition={branch.transition_type}, "
f"intensity={branch.intensity:.4f}"
) )
# Add active branches list
if active_branches: if active_branches:
header_lines.append("# active branches:") header_lines.append("# active branches:")
for branch_info in active_branches: header_lines.extend(f"# {b}" for b in active_branches)
header_lines.append(f"# {branch_info}")
# Add excluded branches list if there are any
if excluded_branches: if excluded_branches:
header_lines.append("# excluded branches:") header_lines.append("# excluded branches:")
for branch_info in excluded_branches: header_lines.extend(f"# {b}" for b in excluded_branches)
header_lines.append(f"# {branch_info}")
# Create data dictionary with concise column headers # ----- data columns -----
data: Dict[str, np.ndarray] = { data: Dict[str, np.ndarray] = {
"energy_MeV": analyzer.energies_MeV, "energy_MeV": analyzer.energies_MeV,
} }
# Only add total_spectrum if not normalizing to one # Positive-integral guard applies to BOTH normalization modes: a
if not analyzer.config.normalize_to_one: # degenerate spectrum must never be written (not even as zeros).
data["total_spectrum"] = total total_raw = analyzer.spectrum(analyzer.W)
integral = float(np.trapezoid(total_raw, analyzer.energies_MeV))
if integral <= 0.0:
raise ValueError(
"Total spectrum integral is non-positive: every branch "
"is degenerate (check neutrino_mass vs branch endpoints "
"and intensity_cutoff)"
)
if not separate:
data["total_spectrum"] = total_raw / integral
for i in range(n_branches):
key_i = i + 1
if csv_verbosity == "concise":
col_name = f"branch_{key_i}"
else:
col_name = f"branch_{key_i}_spectrum"
if separate:
area = float(np.trapezoid(physical[i], analyzer.energies_MeV))
if area > 0.0:
data[col_name] = physical[i] / area
else:
data[col_name] = np.zeros_like(physical[i])
else:
intensity_i = analyzer._unified_branches[i].intensity
data[col_name] = physical[i] * intensity_i / integral
if csv_verbosity == "full": if csv_verbosity == "full":
# Per-branch spectra and components with concise branch naming # Per-branch component columns (factorized ingredients).
for i in range(analyzer._n_branches): # Only multi-branch mode carries per-branch factors: in single
# Get branch spectrum # mode the bare PhaseSpace/Radiative columns below already
branch_spectrum = analyzer._get_branch_spectrum(i) # describe the single spectrum, and prefixed duplicates would
# silently disagree with them once renamed.
if analyzer.config.normalize_to_one: if analyzer._is_multi:
# Normalize to 1, ignoring intensity for i in range(n_branches):
sum_spectrum = np.sum(branch_spectrum)
if sum_spectrum > 0:
normalized_spectrum = branch_spectrum / sum_spectrum
else:
normalized_spectrum = branch_spectrum
else:
# Use original spectrum with intensity
normalized_spectrum = (
branch_spectrum * analyzer._unified_branches[i].intensity
)
data[f"branch_{i + 1}_spectrum"] = normalized_spectrum
for comp_name in ["PhaseSpace", "Radiative"]: for comp_name in ["PhaseSpace", "Radiative"]:
key = analyzer._get_component_key(i, comp_name) key = analyzer._get_component_key(i, comp_name)
if key in components: if key in components:
data[f"branch_{i + 1}_{comp_name}"] = components[key] data[f"branch_{i + 1}_{comp_name}"] = components[key]
# Universal components (no prefix) # Universal components (no prefix): Fermi plus universal
# higher-order corrections. In single mode the core factors
# (PhaseSpace, Radiative) also appear here unprefixed.
for name, values in components.items(): for name, values in components.items():
if not name.startswith("branch_"): if not name.startswith("branch_"):
data[name] = values data[name] = values
elif csv_verbosity == "concise":
# Only total spectrum and normalized branch components with concise naming
for i in range(analyzer._n_branches):
branch_spectrum = analyzer._get_branch_spectrum(i)
# Check if branch is degenerate (neutrino mass >= branch endpoint)
branch_endpoint_keV = analyzer._unified_branches[i].endpoint_MeV * 1000
neutrino_mass_keV = analyzer.config.neutrino_mass_MeV * 1000
if branch_endpoint_keV <= neutrino_mass_keV:
# Branch is degenerate, spectrum should be all zeros
data[f"branch_{i + 1}"] = np.zeros_like(branch_spectrum)
else:
# Branch is valid, normalize as usual
if analyzer.config.normalize_to_one:
# Normalize to 1, ignoring intensity
sum_spectrum = np.sum(branch_spectrum)
if sum_spectrum > 0:
data[f"branch_{i + 1}"] = branch_spectrum / sum_spectrum
else:
data[f"branch_{i + 1}"] = branch_spectrum
else:
# Normalize as usual with intensity
sum_spectrum = np.sum(branch_spectrum)
if sum_spectrum > 0:
data[f"branch_{i + 1}"] = (
branch_spectrum
/ sum_spectrum
* analyzer._unified_branches[i].intensity
)
else:
data[f"branch_{i + 1}"] = branch_spectrum
df = pd.DataFrame(data) df = pd.DataFrame(data)
# Determine separator separator = "\t" if config.csv_separator == "tab" else config.csv_separator
separator = ( try:
"\t"
if analyzer.config.csv_separator == "tab"
else analyzer.config.csv_separator
)
df.to_csv(filename, index=False, float_format="%.4e", sep=separator)
with open(filename, "r") as f:
content = f.read()
with open(filename, "w") as f: with open(filename, "w") as f:
f.write("\n".join(header_lines) + "\n" + content) f.write("\n".join(header_lines) + "\n")
df.to_csv(f, index=False, float_format="%.4e", sep=separator)
except OSError as exc:
raise ValueError(f"Cannot write spectrum CSV {filename}: {exc}") from exc
if analyzer._logger: if analyzer._logger:
analyzer._logger.info("CSV exported to %s", filename) analyzer._logger.info("CSV exported to %s", filename)
+332 -308
View File
@@ -1,68 +1,195 @@
""" """
Visualization module — Plotting functionality for BetaSpectrumAnalyzer. Visualization module — plotting built ON TOP OF the CSV export.
All graphical representations are produced from the exported CSV file
(data columns + metadata header) and never recomputed from the analyzer in
parallel. The CSV file is the single source of truth: a plot is produced
exactly from the bytes a researcher would download, which turns every plot
into an additional quality check of the export routine.
""" """
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone import re
from typing import TYPE_CHECKING, Dict, Optional import shutil
import tempfile
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
import numpy as np import numpy as np
import matplotlib.pyplot as plt import pandas as pd
from beta_spectrum.logging_utils import get_git_short_hash import matplotlib.pyplot as plt
from beta_spectrum.utils import T_to_W from matplotlib.figure import Figure
from .csv_export import CSVEmitter
if TYPE_CHECKING: if TYPE_CHECKING:
from matplotlib.figure import Figure
from beta_spectrum.output.core import BetaSpectrumAnalyzer from beta_spectrum.output.core import BetaSpectrumAnalyzer
_BRANCH_LINE_RE = re.compile(
r"^\s*branch_(\d+):\s*E₀=([0-9.]+)\s*keV,\s*transition=(\w+),\s*"
r"intensity=([0-9.]+)(.*)$"
)
_EXCLUDED_SUFFIX_RE = re.compile(r"-\s*excluded:\s*(.*)$")
class Visualizer: _BRANCH_COLORS = [
"""Visualization functionality for BetaSpectrumAnalyzer.""" "#1f77b4",
"#ff7f0e",
"#2ca02c",
"#d62728",
"#9467bd",
"#8c564b",
"#e377c2",
"#7f7f7f",
"#bcbd22",
"#17becf",
]
@staticmethod
def plot_analysis( def parse_spectrum_csv(
analyzer: BetaSpectrumAnalyzer, csv_path: str,
save_path: Optional[str] = None, ) -> Tuple[pd.DataFrame, Dict[str, Any]]:
show_components: bool = True, """Read an exported spectrum CSV and parse its metadata header.
) -> None:
"""
Create visualization of the spectrum and all correction factors.
Parameters Parameters
---------- ----------
analyzer : BetaSpectrumAnalyzer csv_path : str
The analyzer instance to visualize. Path to a CSV file produced by CSVEmitter.export_to_csv.
save_path : str, optional
Path to save the figure. Returns
show_components : bool -------
If True, show full debug view. tuple[pandas.DataFrame, dict]
If False, show only the spectrum plot. The data frame (comment lines skipped, separator auto-detected)
and a metadata dict with keys: version, build, timestamp, source,
nuclide, endpoint_keV, neutrino_mass_MeV, corrections, e_step_MeV,
n_points, normalization, branches.
Raises
------
ValueError
If the file cannot be read or parsed.
""" """
total = analyzer.total_spectrum(normalize=True) try:
components = analyzer.components df = pd.read_csv(csv_path, sep=None, engine="python", comment="#")
analyzer._timestamp = datetime.now(timezone.utc).strftime( raw_lines = []
"%Y-%m-%d %H:%M:%S UTC" with open(csv_path, "r") as f:
) for line in f:
if line.startswith("#"):
if show_components: raw_lines.append(line.rstrip("\n"))
Visualizer._plot_debug_view(analyzer, total, components, save_path)
else: else:
Visualizer._plot_spectra_view(analyzer, total, components, save_path) break # header block ended
except OSError as exc:
raise ValueError(f"Cannot read spectrum CSV {csv_path}: {exc}") from exc
@staticmethod meta: Dict[str, Any] = {
def _build_figure_header(analyzer: BetaSpectrumAnalyzer, fig: "Figure") -> None: "version": None,
"""Add a compact header with nuclide info, e_step, commit and timestamp outside axes.""" "build": None,
parent = analyzer._element_symbol(analyzer.config.Z_parent) "timestamp": None,
daughter = analyzer._element_symbol(analyzer.config.Z_daughter) "source": None,
e_step_keV = analyzer.config.e_step_MeV * 1000 "nuclide": None,
header = ( "endpoint_keV": None,
f"{parent}{analyzer.config.A_number} -> {daughter}{analyzer.config.A_number} " "neutrino_mass_MeV": None,
f"| E₀={analyzer.config.endpoint_MeV * 1000:.1f} keV " "corrections": [],
f"| ΔE={e_step_keV:.1f} keV" "e_step_MeV": None,
"n_points": None,
"normalization": None,
"branches": [],
}
for line in raw_lines:
body = line.lstrip("#").strip()
m = re.match(r"beta-spectrum v([\d.]+)\(build\.(\w+)\)", body)
if m:
meta["version"] = m.group(1)
meta["build"] = m.group(2)
continue
m = re.match(r"timestamp:\s*(.+)", body)
if m:
meta["timestamp"] = m.group(1).strip()
continue
m = re.match(r"source:\s*(\S+)", body)
if m:
meta["source"] = m.group(1)
continue
m = re.match(r"nuclide:\s*(.+?)\s+\(Z=", body)
if m:
meta["nuclide"] = m.group(1).strip()
continue
m = re.match(r"basic_endpoint:\s*([0-9.]+)\s*keV", body)
if m:
meta["endpoint_keV"] = float(m.group(1))
continue
m = re.match(r"neutrino_mass_MeV:\s*([0-9.]+)", body)
if m:
meta["neutrino_mass_MeV"] = float(m.group(1))
continue
m = re.match(r"corrections:\s*(.*)", body)
if m:
meta["corrections"] = [
c.strip() for c in m.group(1).split(",") if c.strip()
]
continue
m = re.match(r"e_step:\s*([0-9.]+)\s*MeV", body)
if m:
meta["e_step_MeV"] = float(m.group(1))
continue
m = re.match(r"n_points:\s*(\d+)", body)
if m:
meta["n_points"] = int(m.group(1))
continue
m = re.match(r"branch normalization:\s*(\S+)", body)
if m:
meta["normalization"] = m.group(1)
continue
m = _BRANCH_LINE_RE.match(body)
if m:
excluded = _EXCLUDED_SUFFIX_RE.search(m.group(5))
meta["branches"].append(
{
"index": int(m.group(1)),
"endpoint_keV": float(m.group(2)),
"transition": m.group(3),
"intensity": float(m.group(4)),
"excluded": None if excluded is None else excluded.group(1).strip(),
}
) )
continue
if meta["n_points"] is None:
meta["n_points"] = len(df)
return df, meta
def _branch_column(df: pd.DataFrame, index: int) -> Optional[str]:
"""Find the branch spectrum column name for a 1-based branch index."""
for candidate in (f"branch_{index}_spectrum", f"branch_{index}"):
if candidate in df.columns:
return candidate
return None
def _active_branches(meta: Dict[str, Any]) -> List[Dict[str, Any]]:
return [b for b in meta["branches"] if b["excluded"] is None]
def _spectrum_title(meta: Dict[str, Any]) -> str:
n_branches = len(meta["branches"])
active = len(_active_branches(meta))
title = f"Beta-decay: {meta['nuclide']}"
if n_branches > 1:
if active < n_branches:
title += f" ({active} of {n_branches} branches)"
else:
title += f" ({n_branches} branches)"
return title
def _apply_figure_header(fig: Figure, meta: Dict[str, Any]) -> None:
"""Write the figure header/footer lines from the CSV metadata."""
e_step_keV = (meta["e_step_MeV"] or 0.0) * 1000.0
endpoint = meta["endpoint_keV"]
header = f"{meta['nuclide']} | E₀={endpoint:.1f} keV | ΔE={e_step_keV:.1f} keV"
fig.text( fig.text(
0.01, 0.01,
0.98, 0.98,
@@ -74,13 +201,11 @@ class Visualizer:
fontfamily="monospace", fontfamily="monospace",
) )
# Add neutrino mass info to header if non-zero if meta["neutrino_mass_MeV"]:
if analyzer.config.neutrino_mass_MeV > 0:
nu_mass_text = f"| m_ν={analyzer.config.neutrino_mass_MeV:.6f} MeV"
fig.text( fig.text(
0.01, 0.01,
0.96, 0.96,
nu_mass_text, f"| m_ν={meta['neutrino_mass_MeV']:.6f} MeV",
transform=fig.transFigure, transform=fig.transFigure,
fontsize=8, fontsize=8,
verticalalignment="top", verticalalignment="top",
@@ -89,11 +214,11 @@ class Visualizer:
color="red", color="red",
) )
id_text = f"{get_git_short_hash(6)} | {analyzer._timestamp}" if meta["build"]:
fig.text( fig.text(
0.01, 0.01,
0.01, 0.01,
id_text, f"{meta['build']} | {meta['timestamp']}",
transform=fig.transFigure, transform=fig.transFigure,
fontsize=8, fontsize=8,
verticalalignment="bottom", verticalalignment="bottom",
@@ -102,309 +227,141 @@ class Visualizer:
color="gray", color="gray",
) )
@staticmethod
def _build_figure_title(analyzer: BetaSpectrumAnalyzer, fig: "Figure") -> str:
"""Build a unified figure title."""
parent = analyzer._element_symbol(analyzer.config.Z_parent)
daughter = analyzer._element_symbol(analyzer.config.Z_daughter)
n = analyzer.config.A_number
title = f"Beta-decay: {n}{parent} -> {n}{daughter}"
if analyzer._n_branches > 1:
# Count active branches (not excluded by neutrino mass or intensity cutoff)
active_branches = 0
for i, branch in enumerate(analyzer._unified_branches):
branch_endpoint_keV = branch.endpoint_MeV * 1000
neutrino_mass_keV = analyzer.config.neutrino_mass_MeV * 1000
is_excluded_by_mass = branch_endpoint_keV <= neutrino_mass_keV
is_excluded_by_intensity = (
branch.intensity < analyzer.config.intensity_cutoff
)
if not is_excluded_by_mass and not is_excluded_by_intensity:
active_branches += 1
if active_branches < analyzer._n_branches: def _plot_spectra_view_from_df(
title += f" ({active_branches} of {analyzer._n_branches} branches)" df: pd.DataFrame,
else: meta: Dict[str, Any],
title += f" ({analyzer._n_branches} branches)"
return title
@staticmethod
def _format_branch_label(analyzer: BetaSpectrumAnalyzer, i: int) -> str:
"""Abbreviated branch label for legend."""
branch = analyzer._unified_branches[i]
e0 = branch.endpoint_MeV * 1000
intensity_pct = branch.intensity * 100
# Check if branch is excluded
branch_endpoint_keV = branch.endpoint_MeV * 1000
neutrino_mass_keV = analyzer.config.neutrino_mass_MeV * 1000
is_excluded_by_mass = branch_endpoint_keV <= neutrino_mass_keV
is_excluded_by_intensity = branch.intensity < analyzer.config.intensity_cutoff
if is_excluded_by_mass:
return f"Br. {i + 1}: {e0:.1f} keV ({intensity_pct:.1f}%) - excluded (endpoint < m_nu)"
elif is_excluded_by_intensity:
return f"Br. {i + 1}: {e0:.1f} keV ({intensity_pct:.1f}%) - excluded (intensity < cutoff)"
else:
return f"Br. {i + 1}: {e0:.1f} keV ({intensity_pct:.1f}%)"
@staticmethod
def _plot_spectra_view(
analyzer: BetaSpectrumAnalyzer,
total: np.ndarray,
components: Dict[str, np.ndarray],
save_path: Optional[str], save_path: Optional[str],
) -> None: ) -> Figure:
"""Plot total spectrum with individual branch decomposition.""" """Plot total spectrum and per-branch spectra from CSV columns."""
energies = df["energy_MeV"].to_numpy()
fig, ax = plt.subplots(figsize=(12, 7)) fig, ax = plt.subplots(figsize=(12, 7))
branch_colors = [ if "total_spectrum" in df.columns:
"#1f77b4",
"#ff7f0e",
"#2ca02c",
"#d62728",
"#9467bd",
"#8c564b",
"#e377c2",
"#7f7f7f",
"#bcbd22",
"#17becf",
]
e_step_keV = analyzer.config.e_step_MeV * 1000
# Compute branch contributions (universal * intensity * branch_spectrum)
branch_contribs = []
# Universal components are shared (no per-branch key prefix)
universal_names = [
"Fermi",
"Screening",
"FiniteSizeL0",
"ChargeDistributionU",
"Exchange",
]
for i in range(analyzer._n_branches):
universal_product = np.ones_like(analyzer.W)
for name in universal_names:
key = analyzer._get_component_key(i, name)
if key in components:
universal_product *= components[key]
elif name in components: # fallback: universal components
universal_product *= components[name]
bs = analyzer._get_branch_spectrum(i)
contrib = universal_product * analyzer._unified_branches[i].intensity * bs
branch_contribs.append(contrib)
total_raw = sum(branch_contribs)
ax.plot( ax.plot(
analyzer.energies_MeV, energies,
total_raw, df["total_spectrum"].to_numpy(),
"k-", "k-",
lw=2.5, lw=2.5,
label="Total spectrum", label="Total spectrum",
zorder=10, zorder=10,
) )
# Only plot active branches (not excluded by neutrino mass or intensity cutoff) for color_i, b in enumerate(_active_branches(meta)):
active_branch_indices = [] col = _branch_column(df, int(b["index"]))
for i in range(analyzer._n_branches): if col is None:
branch_endpoint_keV = analyzer._unified_branches[i].endpoint_MeV * 1000 continue
neutrino_mass_keV = analyzer.config.neutrino_mass_MeV * 1000
is_excluded_by_mass = branch_endpoint_keV <= neutrino_mass_keV
is_excluded_by_intensity = (
analyzer._unified_branches[i].intensity
< analyzer.config.intensity_cutoff
)
if not is_excluded_by_mass and not is_excluded_by_intensity:
active_branch_indices.append(i)
for i in active_branch_indices:
contrib = branch_contribs[i]
color = branch_colors[i % len(branch_colors)]
ax.plot( ax.plot(
analyzer.energies_MeV, energies,
contrib, df[col].to_numpy(),
color=color, color=_BRANCH_COLORS[color_i % len(_BRANCH_COLORS)],
lw=1.5, lw=1.5,
alpha=0.7, alpha=0.7,
label=Visualizer._format_branch_label(analyzer, i), label=(
f"Br. {b['index']}: {b['endpoint_keV']:.1f} keV "
f"({b['intensity'] * 100:.1f}%)"
),
) )
ax.set_xlabel(r"Electron kinetic energy $E$ [MeV]", fontsize=12) ax.set_xlabel(r"Electron kinetic energy $E$ [MeV]", fontsize=12)
ax.set_ylabel(f"Normalized Counts / {e_step_keV:.1f} keV", fontsize=12) ax.set_ylabel(r"Normalized spectrum [MeV$^{-1}$]", fontsize=12)
fig.suptitle( fig.suptitle(_spectrum_title(meta), fontsize=14, fontweight="bold")
Visualizer._build_figure_title(analyzer, fig),
fontsize=14,
fontweight="bold",
)
ax.set_yscale("log") ax.set_yscale("log")
ax.grid(True, alpha=0.3) ax.grid(True, alpha=0.3)
ax.legend( ax.legend(loc="best", fontsize=9, ncol=2)
loc="best",
fontsize=9,
ncol=min(2, len(active_branch_indices) + 1),
)
Visualizer._build_figure_header(analyzer, fig) _apply_figure_header(fig, meta)
fig.tight_layout()
plt.tight_layout()
if save_path: if save_path:
fig.savefig(save_path, dpi=150, bbox_inches="tight") fig.savefig(save_path, dpi=150, bbox_inches="tight")
print(f"Figure saved to {save_path}") print(f"Figure saved to {save_path}")
return fig
@staticmethod
def _plot_debug_view( def _plot_debug_view_from_df(
analyzer: BetaSpectrumAnalyzer, df: pd.DataFrame,
total: np.ndarray, meta: Dict[str, Any],
components: Dict[str, np.ndarray],
save_path: Optional[str], save_path: Optional[str],
) -> None: ) -> Figure:
"""Debug view: vertical layout with 4 panels.""" """Debug view (4 panels) built entirely from CSV columns."""
n_branches = analyzer._n_branches energies = df["energy_MeV"].to_numpy()
e_step_keV = analyzer.config.e_step_MeV * 1000 active = _active_branches(meta)
fig, axes = plt.subplots(4, 1, figsize=(12, 16), sharex=True) fig, axes = plt.subplots(4, 1, figsize=(12, 16), sharex=True)
fig.suptitle( fig.suptitle(_spectrum_title(meta), fontsize=14, fontweight="bold")
Visualizer._build_figure_title(analyzer, fig),
fontsize=14,
fontweight="bold",
)
branch_colors = [
"#1f77b4",
"#ff7f0e",
"#2ca02c",
"#d62728",
"#9467bd",
"#8c564b",
"#e377c2",
"#7f7f7f",
"#bcbd22",
"#17becf",
]
# Panel 1: Total spectrum + per-branch spectra
branch_contribs = []
universal_names = [
"Fermi",
"Screening",
"FiniteSizeL0",
"ChargeDistributionU",
"Exchange",
]
for i in range(n_branches):
universal_product = np.ones_like(analyzer.W)
for name in universal_names:
key = analyzer._get_component_key(i, name)
if key in components:
universal_product *= components[key]
elif name in components:
universal_product *= components[name]
bs = analyzer._get_branch_spectrum(i)
contrib = universal_product * analyzer._unified_branches[i].intensity * bs
branch_contribs.append(contrib)
total_raw = sum(branch_contribs)
# Panel 1: total spectrum + per-branch spectra
ax = axes[0] ax = axes[0]
if "total_spectrum" in df.columns:
ax.plot( ax.plot(
analyzer.energies_MeV, energies,
total_raw, df["total_spectrum"].to_numpy(),
"k-", "k-",
lw=2, lw=2,
label="Total spectrum", label="Total spectrum",
zorder=10, zorder=10,
) )
for color_i, b in enumerate(active):
# Only plot active branches (not excluded by neutrino mass or intensity cutoff) col = _branch_column(df, int(b["index"]))
active_branch_indices = [] if col is None:
for i in range(n_branches): continue
branch_endpoint_keV = analyzer._unified_branches[i].endpoint_MeV * 1000
neutrino_mass_keV = analyzer.config.neutrino_mass_MeV * 1000
is_excluded_by_mass = branch_endpoint_keV <= neutrino_mass_keV
is_excluded_by_intensity = (
analyzer._unified_branches[i].intensity
< analyzer.config.intensity_cutoff
)
if not is_excluded_by_mass and not is_excluded_by_intensity:
active_branch_indices.append(i)
for i in active_branch_indices:
contrib = branch_contribs[i]
color = branch_colors[i % len(branch_colors)]
ax.plot( ax.plot(
analyzer.energies_MeV, energies,
contrib, df[col].to_numpy(),
color=color, color=_BRANCH_COLORS[color_i % len(_BRANCH_COLORS)],
lw=1.5, lw=1.5,
alpha=0.7, alpha=0.7,
label=Visualizer._format_branch_label(analyzer, i), label=f"Br. {b['index']} ({b['intensity'] * 100:.1f}%)",
) )
ax.set_yscale("log") ax.set_yscale("log")
ax.set_ylabel(f"Normalized Counts / {e_step_keV:.1f} keV", fontsize=10) ax.set_ylabel(r"Normalized spectrum [MeV$^{-1}$]", fontsize=10)
ax.set_title("Spectrum", fontsize=11) ax.set_title("Spectrum", fontsize=11)
ax.grid(True, alpha=0.3) ax.grid(True, alpha=0.3)
ax.legend(fontsize=8, loc="best", ncol=min(2, len(active_branch_indices) + 1)) ax.legend(fontsize=8, loc="best", ncol=2)
# Panel 2: Fermi function + all phase spaces # Panel 2: Fermi function + all phase spaces
ax = axes[1] ax = axes[1]
if "Fermi" in components: if "Fermi" in df.columns:
fermi_vals = components["Fermi"] fermi = df["Fermi"].to_numpy()
fermi_scaled = fermi_vals / np.max(fermi_vals) fermi_scaled = fermi / np.max(fermi)
ax.plot(energies, fermi_scaled, "r-", lw=1.5, label="Fermi", alpha=0.8)
for color_i, b in enumerate(active):
col = f"branch_{b['index']}_PhaseSpace"
if col not in df.columns:
# Single-branch CSVs carry the core factors unprefixed.
col = "PhaseSpace"
if col not in df.columns:
continue
ps_vals = df[col].to_numpy()
ps_max = np.max(ps_vals) if np.any(ps_vals > 0) else 1.0
ax.plot( ax.plot(
analyzer.energies_MeV, energies,
fermi_scaled, ps_vals / ps_max if ps_max > 0 else ps_vals,
"r-", color=_BRANCH_COLORS[color_i % len(_BRANCH_COLORS)],
lw=1.5,
label="Fermi",
alpha=0.8,
)
for i in active_branch_indices:
color = branch_colors[i % len(branch_colors)]
branch = analyzer._unified_branches[i]
key = analyzer._get_component_key(i, "PhaseSpace")
if key in components:
ps_vals = components[key]
W0_branch = T_to_W(branch.endpoint_MeV)
mask = analyzer.W <= W0_branch
ps_masked = np.where(mask, ps_vals, 0.0)
ps_max = np.max(ps_masked)
if ps_max > 0:
ps_scaled = ps_masked / ps_max
else:
ps_scaled = ps_masked
ax.plot(
analyzer.energies_MeV,
ps_scaled,
color=color,
lw=1.5, lw=1.5,
alpha=0.7, alpha=0.7,
label=f"PS Br. {i + 1}", label=f"PS Br. {b['index']}",
) )
ax.set_ylabel("Normalized Factor", fontsize=10) ax.set_ylabel("Normalized Factor", fontsize=10)
ax.set_title("Fermi & Phase Space", fontsize=11) ax.set_title("Fermi & Phase Space", fontsize=11)
ax.grid(True, alpha=0.3) ax.grid(True, alpha=0.3)
ax.legend(fontsize=8, loc="best") ax.legend(fontsize=8, loc="best")
# Panel 3: Universal W0-independent corrections # Panel 3: universal higher-order corrections
ax = axes[2] ax = axes[2]
universal_components = [ correction_colors = ["green", "orange", "purple", "brown"]
"Screening", for corr_i, name in enumerate(
"FiniteSizeL0", ["Screening", "FiniteSizeL0", "ChargeDistributionU", "Exchange"]
"ChargeDistributionU", ):
"Exchange", if name in df.columns:
] values = df[name].to_numpy()
colors = ["green", "orange", "purple", "brown"]
for name, color in zip(universal_components, colors):
if name in components:
values = components[name]
values_scaled = values / np.max(values) values_scaled = values / np.max(values)
ax.plot( ax.plot(
analyzer.energies_MeV, energies,
values_scaled, values_scaled,
color=color, color=correction_colors[corr_i],
lw=1.5, lw=1.5,
label=name, label=name,
alpha=0.8, alpha=0.8,
@@ -414,31 +371,98 @@ class Visualizer:
ax.grid(True, alpha=0.3) ax.grid(True, alpha=0.3)
ax.legend(fontsize=8, loc="best") ax.legend(fontsize=8, loc="best")
# Panel 4: W0-dependent corrections (Radiative for each branch) # Panel 4: W0-dependent corrections (Radiative per branch)
ax = axes[3] ax = axes[3]
for i in active_branch_indices: for color_i, b in enumerate(active):
color = branch_colors[i % len(branch_colors)] col = f"branch_{b['index']}_Radiative"
key = analyzer._get_component_key(i, "Radiative") if col not in df.columns:
if key in components: # Single-branch CSVs carry the core factors unprefixed.
rad_vals = components[key] col = "Radiative"
if col not in df.columns:
continue
ax.plot( ax.plot(
analyzer.energies_MeV, energies,
rad_vals, df[col].to_numpy(),
color=color, color=_BRANCH_COLORS[color_i % len(_BRANCH_COLORS)],
lw=1.5, lw=1.5,
alpha=0.7, alpha=0.7,
label=f"Br. {i + 1}", label=f"Br. {b['index']}",
) )
ax.set_ylabel("Radiative Correction", fontsize=10) ax.set_ylabel("Radiative Correction", fontsize=10)
ax.set_xlabel(r"Electron kinetic energy $E$ [MeV]", fontsize=10) ax.set_xlabel(r"Electron kinetic energy $E$ [MeV]", fontsize=10)
ax.set_title("Radiative Corrections", fontsize=11) ax.set_title("Radiative Corrections", fontsize=11)
ax.grid(True, alpha=0.3) ax.grid(True, alpha=0.3)
ax.legend(fontsize=8, loc="best", ncol=min(2, len(active_branch_indices) + 1)) ax.legend(fontsize=8, loc="best", ncol=2)
Visualizer._build_figure_header(analyzer, fig) _apply_figure_header(fig, meta)
fig.tight_layout()
plt.tight_layout()
if save_path: if save_path:
fig.savefig(save_path, dpi=150, bbox_inches="tight") fig.savefig(save_path, dpi=150, bbox_inches="tight")
print(f"Figure saved to {save_path}") print(f"Figure saved to {save_path}")
return fig
class Visualizer:
"""Visualization built from the exported CSV data (single source of truth)."""
@staticmethod
def plot_from_csv(
csv_path: str,
save_path: Optional[str] = None,
show_components: bool = True,
) -> Figure:
"""Create a visualization from an exported CSV spectrum file.
Parameters
----------
csv_path : str
Path to a CSV exported by CSVEmitter.export_to_csv. The plots
are built exclusively from the file contents (data columns and
metadata header) — nothing is recomputed from the model.
save_path : str, optional
Path to save the figure.
show_components : bool
If True, produce the full 4-panel debug view; requires the
'full' CSV verbosity. If the file lacks component columns, a
warning is printed and the simple spectrum view is produced.
"""
df, meta = parse_spectrum_csv(csv_path)
if show_components and "Fermi" in df.columns:
return _plot_debug_view_from_df(df, meta, save_path)
if show_components:
print(
"Warning: requested component panels but CSV lacks component "
"columns (export with --csv-verbosity full); falling back to "
"simple spectrum view."
)
return _plot_spectra_view_from_df(df, meta, save_path)
@staticmethod
def plot_analysis(
analyzer: BetaSpectrumAnalyzer,
save_path: Optional[str] = None,
show_components: bool = True,
) -> Figure:
"""Create a visualization by exporting CSV and plotting from it.
The export goes to a temporary file using the 'full' verbosity so
that the plots exercise the same data pipeline (header + columns)
a researcher receives.
"""
tmp_dir = tempfile.mkdtemp(prefix="bs_plot_")
try:
csv_path = f"{tmp_dir}/spectrum.csv"
CSVEmitter.export_to_csv(
analyzer,
csv_path,
source_type="plot",
csv_verbosity="full",
)
return Visualizer.plot_from_csv(
csv_path,
save_path=save_path,
show_components=show_components,
)
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
+68 -41
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.
@@ -73,9 +70,9 @@ class SpectrumConfig:
e_step_MeV: float = 0.001 e_step_MeV: float = 0.001
neutrino_mass_MeV: float = 0.0 neutrino_mass_MeV: float = 0.0
# Toggle components and corrections # Higher-order corrections. The core spectrum components (phase space
use_phase_space: bool = True # and the Fermi function, both 0-order) are unconditional: there are
use_fermi: bool = True # deliberately no toggles for them.
use_screening: bool = True use_screening: bool = True
use_finite_size: bool = True use_finite_size: bool = True
use_charge_dist: bool = True use_charge_dist: bool = True
@@ -90,9 +87,25 @@ 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]:
"""Resolve (endpoint_MeV, transition_type) for single-branch mode.
When config.branches carries exactly one branch (e.g. after intensity
cutoff filtering removed all but one), that branch defines the spectrum:
its endpoint and transition type win over the (possibly stale) top-level
fields inherited from the parent decay. With branches=None the top-level
fields are used as before.
"""
if config.branches is not None and len(config.branches) == 1:
lone = config.branches[0]
return float(lone.endpoint_MeV), str(lone.transition_type)
return float(config.endpoint_MeV), str(config.transition_type)
class BetaSpectrum: class BetaSpectrum:
@@ -252,23 +265,27 @@ class BetaSpectrum:
def _from_config_single( def _from_config_single(
cls, config: SpectrumConfig, logger: Optional[logging.Logger] = None cls, config: SpectrumConfig, logger: Optional[logging.Logger] = None
) -> BetaSpectrum: ) -> BetaSpectrum:
"""Create single-branch spectrum calculator.""" """Create single-branch spectrum calculator.
components: List[SpectrumComponent] = []
W0 = float(T_to_W(config.endpoint_MeV))
if config.use_phase_space: The core spectrum components (phase space x Fermi function, both
0-order) are always built; only higher-order corrections are
conditional.
"""
components: List[SpectrumComponent] = []
eff_endpoint, eff_transition = _effective_single_fields(config)
W0 = float(T_to_W(eff_endpoint))
# Core spectrum components (0-order): always included.
# Convert neutrino mass from MeV to natural units # Convert neutrino mass from MeV to natural units
m_nu_natural = config.neutrino_mass_MeV / ME_MEV m_nu_natural = config.neutrino_mass_MeV / ME_MEV
components.append( components.append(
PhaseSpace( PhaseSpace(
W0=W0, W0=W0,
transition_type=config.transition_type, transition_type=eff_transition,
m_nu=m_nu_natural, m_nu=m_nu_natural,
logger=logger, logger=logger,
) )
) )
if config.use_fermi:
components.append( components.append(
FermiFunction(Z=config.Z_daughter, A=config.A_number, logger=logger) FermiFunction(Z=config.Z_daughter, A=config.A_number, logger=logger)
) )
@@ -322,13 +339,13 @@ class BetaSpectrum:
Note: Radiative correction is branch-dependent (depends on W0), so it goes Note: Radiative correction is branch-dependent (depends on W0), so it goes
into each branch's calculator, not the universal components. into each branch's calculator, not the universal components.
""" """
if config.branches is None:
raise ValueError("branches must be provided for multi-branch mode")
if logger: if logger:
logger.info("Multi-branch mode: %d branches", len(config.branches)) logger.info("Multi-branch mode: %d branches", len(config.branches))
# Create per-branch calculators # Create per-branch calculators
branch_spectra: List[BetaSpectrum] = [] branch_spectra: List[BetaSpectrum] = []
if config.branches is None:
raise ValueError("branches must be provided for multi-branch mode")
for i, branch in enumerate(config.branches): for i, branch in enumerate(config.branches):
if logger: if logger:
logger.info( logger.info(
@@ -339,37 +356,42 @@ class BetaSpectrum:
branch.intensity, branch.intensity,
) )
# Create a per-branch config with the branch's endpoint and transition type # Per-branch calculator: the per-branch core phase space
branch_config = SpectrumConfig( # (unconditional) plus optional per-branch radiative
Z_parent=config.Z_parent, # corrections. The universal Fermi function and universal
Z_daughter=config.Z_daughter, # higher-order corrections are applied outside, so they must
A_number=config.A_number, # NOT be built here (a Fermi function per branch would
endpoint_MeV=branch.endpoint_MeV, # double-apply it).
W0_branch = float(T_to_W(branch.endpoint_MeV))
m_nu_natural = config.neutrino_mass_MeV / ME_MEV
branch_components: List[SpectrumComponent] = [
PhaseSpace(
W0=W0_branch,
transition_type=branch.transition_type, transition_type=branch.transition_type,
e_step_MeV=config.e_step_MeV, m_nu=m_nu_natural,
neutrino_mass_MeV=config.neutrino_mass_MeV, logger=logger,
# Branch-dependent components )
use_phase_space=config.use_phase_space, ]
use_radiative=config.use_radiative, if config.use_radiative:
# Universal components disabled (handled separately) branch_components.append(
use_fermi=False, RadiativeCorrection(
use_screening=False, W0=W0_branch,
use_finite_size=False, Z=config.Z_daughter,
use_charge_dist=False, A=config.A_number,
use_exchange=False, use_endpoint_resummation=True,
logger=logger,
)
) )
branch_spectrum = cls._from_config_single(branch_config, logger) branch_spectra.append(cls(branch_components, logger=logger))
branch_spectra.append(branch_spectrum)
# Create universal components (shared across all branches) # Create universal components (shared across all branches)
# Note: Radiative is NOT included here because it depends on W0 # Note: Radiative is NOT included here because it depends on W0
universal_components: List[SpectrumComponent] = [] # The Fermi function is a core spectrum component (0-order Coulomb
# correction): always included, not a higher-order correction.
if config.use_fermi: universal_components: List[SpectrumComponent] = [
universal_components.append(
FermiFunction(Z=config.Z_daughter, A=config.A_number, logger=logger) FermiFunction(Z=config.Z_daughter, A=config.A_number, logger=logger)
) ]
if config.use_finite_size: if config.use_finite_size:
universal_components.append( universal_components.append(
@@ -411,6 +433,11 @@ class BetaSpectrum:
""" """
if self._is_multi and self.branches: if self._is_multi and self.branches:
max_endpoint = max(b.endpoint_MeV for b in self.branches) max_endpoint = max(b.endpoint_MeV for b in self.branches)
elif config.branches is not None and len(config.branches) == 1:
# Single-mode with exactly one configured branch: the branch
# endpoint defines the grid, not the (possibly stale) top-level
# endpoint from the parent decay.
max_endpoint = float(config.branches[0].endpoint_MeV)
else: else:
max_endpoint = config.endpoint_MeV max_endpoint = config.endpoint_MeV
+1 -10
View File
@@ -5,14 +5,5 @@
"A_number": 99, "A_number": 99,
"endpoint_MeV": 0.294, "endpoint_MeV": 0.294,
"transition_type": "A", "transition_type": "A",
"e_step_MeV": 0.001, "e_step_MeV": 0.001
"use_detector_response": false,
"detector_model": "gaussian",
"detector_sigma_a_keV": 1.0,
"detector_sigma_b": 0.0,
"detector_tail_fraction": 0.0,
"detector_tau_keV": 5.0,
"detector_fano_factor": 0.12,
"detector_n_channels": 4096,
"detector_channel_energy_range": [0.0, 0.35]
} }
+11 -10
View File
@@ -11,13 +11,12 @@ Usage:
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Any, Optional
import numpy as np import numpy as np
if TYPE_CHECKING: if TYPE_CHECKING:
from beta_spectrum.spectrum import BetaSpectrum from beta_spectrum.spectrum import BetaSpectrum
from beta_spectrum.components.detector_response import DetectorResponse
from exp_data.spectrum import ExpSpectrum from exp_data.spectrum import ExpSpectrum
@@ -39,8 +38,11 @@ class SpectrumModel:
---------- ----------
spectrum : BetaSpectrum spectrum : BetaSpectrum
Theoretical spectrum calculator. Theoretical spectrum calculator.
detector_response : DetectorResponse, optional detector_response : optional
Detector response function. If None, no convolution is applied. Detector response object exposing ``channel_energies`` and
``convolve`` (duck-typed: the DetectorResponse module was removed
from beta_spectrum and is pending a restore-or-delete decision).
If None, no convolution is applied.
channel_energies : np.ndarray, optional channel_energies : np.ndarray, optional
Energy values for detector channels (keV). Used to set up Energy values for detector channels (keV). Used to set up
the response if detector_response is None. the response if detector_response is None.
@@ -49,7 +51,7 @@ class SpectrumModel:
def __init__( def __init__(
self, self,
spectrum: "BetaSpectrum", spectrum: "BetaSpectrum",
detector_response: Optional["DetectorResponse"] = None, detector_response: Optional[Any] = None,
channel_energies: Optional[np.ndarray] = None, channel_energies: Optional[np.ndarray] = None,
): ):
self.spectrum = spectrum self.spectrum = spectrum
@@ -79,11 +81,10 @@ class SpectrumModel:
if energies_keV is not None: if energies_keV is not None:
W = self._keV_to_W(energies_keV) W = self._keV_to_W(energies_keV)
return self.spectrum(W) return self.spectrum(W)
return self.spectrum( w_grid = getattr(self.spectrum, "_W_grid", None)
self.spectrum._W_grid if w_grid is None:
if hasattr(self.spectrum, "_W_grid") and self.spectrum._W_grid is not None return np.array([], dtype=np.float64)
else np.array([]) return self.spectrum(w_grid)
)
def _keV_to_W(self, energies_keV: np.ndarray) -> np.ndarray: def _keV_to_W(self, energies_keV: np.ndarray) -> np.ndarray:
"""Convert kinetic energy in keV to total energy in m_e units.""" """Convert kinetic energy in keV to total energy in m_e units."""
+10 -9
View File
@@ -94,8 +94,6 @@
" endpoint_MeV=0.294,\n", " endpoint_MeV=0.294,\n",
" transition_type=\"F2\",\n", " transition_type=\"F2\",\n",
" e_step_MeV=0.001,\n", " e_step_MeV=0.001,\n",
" use_phase_space=True,\n",
" use_fermi=True,\n",
" use_screening=True,\n", " use_screening=True,\n",
" use_finite_size=True,\n", " use_finite_size=True,\n",
" use_charge_dist=True,\n", " use_charge_dist=True,\n",
@@ -410,13 +408,16 @@
} }
], ],
"source": [ "source": [
"multi_analyzer = BetaSpectrumAnalyzer(multi_spectrum, multi_config)\n",
"\n",
"fig, ax = plt.subplots(figsize=(12, 6))\n", "fig, ax = plt.subplots(figsize=(12, 6))\n",
"ax.plot(T_keV, total, \"k-\", linewidth=2.5, label=\"Total spectrum\")\n", "ax.plot(T_keV, total, \"k-\", linewidth=2.5, label=\"Total spectrum\")\n",
"\n", "\n",
"branch_spectra = multi_spectrum.get_branch_normalized_spectra(W)\n", "# Physical branch spectra: core (phase_space x fermi) x all enabled corrections\n",
"for i, bs in enumerate(branch_spectra):\n", "for i, b in enumerate(branches):\n",
" ax.plot(T_keV, bs, alpha=0.6, linewidth=1.5,\n", " physical = multi_analyzer.physical_branch_spectrum(i)\n",
" label=\"Branch {} (E0={:.0f} keV)\".format(i+1, branches[i].endpoint_MeV*1000))\n", " ax.plot(T_keV, physical, alpha=0.6, linewidth=1.5,\n",
" label=\"Branch {} (E0={:.0f} keV)\".format(i+1, b.endpoint_MeV*1000))\n",
"\n", "\n",
"for i, b in enumerate(branches):\n", "for i, b in enumerate(branches):\n",
" br_end_keV = W_to_T(T_to_W(b.endpoint_MeV))\n", " br_end_keV = W_to_T(T_to_W(b.endpoint_MeV))\n",
@@ -430,7 +431,7 @@
"ax.set_xlim(0, W0_max_keV)\n", "ax.set_xlim(0, W0_max_keV)\n",
"ax.grid(True, alpha=0.3)\n", "ax.grid(True, alpha=0.3)\n",
"plt.tight_layout()\n", "plt.tight_layout()\n",
"plt.show()" "plt.show()\n"
] ]
}, },
{ {
@@ -541,8 +542,8 @@
} }
], ],
"source": [ "source": [
"analyzer = BetaSpectrumAnalyzer(multi_spectrum, multi_config)\n", "analyzer = multi_analyzer\n",
"analyzer.plot_analysis(show_components=True)" "analyzer.plot_analysis(show_components=True)\n"
] ]
}, },
{ {
+1 -9
View File
@@ -81,11 +81,7 @@
"\n", "\n",
"# Detect header size from file\n", "# Detect header size from file\n",
"def detect_header_size(filepath):\n", "def detect_header_size(filepath):\n",
" \"\"\"Detect MCA header size by checking for binary pattern.\"\"\"\n", " \"\"\"Detect MCA header size from the file size.\"\"\"\n",
" with open(filepath, 'rb') as f:\n",
" header = f.read(128)\n",
" # Check if first 32 bytes look like a header (small ints)\n",
" # and bytes 32-128 also look like header\n",
" # Simple heuristic: if file is exactly 32 + 4096*4 = 16412, header=32\n", " # Simple heuristic: if file is exactly 32 + 4096*4 = 16412, header=32\n",
" # If file is 128 + 4096*4 = 16512, header=128\n", " # If file is 128 + 4096*4 = 16512, header=128\n",
" import os\n", " import os\n",
@@ -97,10 +93,6 @@
" elif size == expected_128:\n", " elif size == expected_128:\n",
" return 128\n", " return 128\n",
" else:\n", " else:\n",
" # Try to detect by checking if byte 32 looks like an int\n",
" with open(filepath, 'rb') as f:\n",
" f.seek(32)\n",
" test = f.read(4)\n",
" return 32 # default\n", " return 32 # default\n",
"\n", "\n",
"# --- Raw run spectrum ---\n", "# --- Raw run spectrum ---\n",
+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" }
+34 -15
View File
@@ -17,6 +17,7 @@ from __future__ import annotations
import sys import sys
from pathlib import Path from pathlib import Path
from typing import cast
import numpy as np import numpy as np
from scipy.stats import linregress from scipy.stats import linregress
@@ -101,7 +102,10 @@ def estimate_endpoint_from_counts(
# Linear fit: K = a + b * E, endpoint = -a/b # Linear fit: K = a + b * E, endpoint = -a/b
try: try:
slope, intercept, r_val, _, _ = linregress(E_sel, K)[:5] lin = cast("tuple[float, float, float, float, float]", linregress(E_sel, K))
slope = lin[0]
intercept = lin[1]
r_val = lin[2]
if slope < 0 and abs(r_val) > 0.3: if slope < 0 and abs(r_val) > 0.3:
endpoint = -intercept / slope endpoint = -intercept / slope
# Clamp to reasonable range (Q ± 100 keV) # Clamp to reasonable range (Q ± 100 keV)
@@ -129,7 +133,7 @@ def compute_regional_rates(counts: np.ndarray, energies_keV: np.ndarray) -> dict
} }
def main(): def main() -> None:
"""Main analysis pipeline.""" """Main analysis pipeline."""
data_dir = Path(__file__).parent.parent / "data" / "raw" data_dir = Path(__file__).parent.parent / "data" / "raw"
output_dir = Path(__file__).parent.parent / "output" output_dir = Path(__file__).parent.parent / "output"
@@ -168,8 +172,6 @@ def main():
print("\nSubtracting consecutive runs...") print("\nSubtracting consecutive runs...")
individual_runs = [] individual_runs = []
for i in range(1, len(cumulative_specs)): for i in range(1, len(cumulative_specs)):
parent_a = cumulative_specs[i].run_id
parent_b = cumulative_specs[i - 1].run_id
diff = subtract_spectra( diff = subtract_spectra(
cumulative_a=cumulative_specs[i], cumulative_a=cumulative_specs[i],
cumulative_b=cumulative_specs[i - 1], cumulative_b=cumulative_specs[i - 1],
@@ -215,33 +217,50 @@ def main():
print(f"\n{'=' * 60}") print(f"\n{'=' * 60}")
print(f"Analysis of {len(run_ids)} individual Tc-99 runs") print(f"Analysis of {len(run_ids)} individual Tc-99 runs")
print(f"{'=' * 60}") print(f"{'=' * 60}")
print(f"Run range: {run_ids.min()} - {run_ids.max()}") print(f"Run range: {min(run_ids)} - {max(run_ids)}")
print(f"Total counts: {total_rates.min():.0f} - {total_rates.max():.0f}") print(f"Total counts: {min(total_rates):.0f} - {max(total_rates):.0f}")
print(f"Low-energy fraction: {low_fractions.min():.3f} - {low_fractions.max():.3f}") print(f"Low-energy fraction: {min(low_fractions):.3f} - {max(low_fractions):.3f}")
print(f"Endpoint estimate: {endpoints.min():.1f} - {endpoints.max():.1f} keV") print(f"Endpoint estimate: {min(endpoints):.1f} - {max(endpoints):.1f} keV")
# Endpoint drift # Endpoint drift
if len(run_ids) > 10: if len(run_ids) > 10:
slope, intercept, r_val, _, _ = linregress(run_ids, endpoints) lin_e = cast(
print(f"\nEndpoint drift: {slope:.3f} keV/run (R²={r_val**2:.4f})") "tuple[float, float, float, float, float]",
print(f" Start (run 1): {intercept + slope * run_ids[0]:.1f} keV") linregress(run_ids, endpoints),
print(f" End (run {run_ids[-1]}): {intercept + slope * run_ids[-1]:.1f} keV") )
slope_e = lin_e[0]
intercept_e = lin_e[1]
r_e = lin_e[2]
print(f"\nEndpoint drift: {slope_e:.3f} keV/run (R²={r_e**2:.4f})")
print(f" Start (run 1): {intercept_e + slope_e * run_ids[0]:.1f} keV")
print(
f" End (run {run_ids[-1]}): {intercept_e + slope_e * run_ids[-1]:.1f} keV"
)
# Rate drift # Rate drift
slope, intercept, r_val, _, _ = linregress(run_ids, total_rates) lin_r = cast(
print(f"\nTotal count rate drift: {slope:.0f} counts/run (R²={r_val**2:.4f})") "tuple[float, float, float, float, float]", linregress(run_ids, total_rates)
)
slope_r = lin_r[0]
r_r = lin_r[2]
print(f"\nTotal count rate drift: {slope_r:.0f} counts/run (R²={r_r**2:.4f})")
# Save results # Save results
results_file = output_dir / "rate_analysis.csv" results_file = output_dir / "rate_analysis.csv"
try:
with open(results_file, "w") as f: with open(results_file, "w") as f:
f.write( f.write(
"run,total_counts,low_energy_counts,high_energy_counts,low_fraction,endpoint_keV\n" "run,total_counts,low_energy_counts,high_energy_counts,"
"low_fraction,endpoint_keV\n"
) )
for i in range(len(run_ids)): for i in range(len(run_ids)):
f.write( f.write(
f"{run_ids[i]},{total_rates[i]:.0f},{low_rates[i]:.0f}," f"{run_ids[i]},{total_rates[i]:.0f},{low_rates[i]:.0f},"
f"{high_rates[i]:.0f},{low_fractions[i]:.4f},{endpoints[i]:.1f}\n" f"{high_rates[i]:.0f},{low_fractions[i]:.4f},{endpoints[i]:.1f}\n"
) )
except OSError as exc:
print(f"Cannot write {results_file}: {exc}")
raise SystemExit(1) from exc
print(f"\nResults saved to {results_file}") print(f"\nResults saved to {results_file}")
# Plot # Plot
+1 -1
View File
@@ -471,7 +471,7 @@ CHECKS = [
] ]
def main(): def main() -> int:
parser = argparse.ArgumentParser(description="Check documentation consistency") parser = argparse.ArgumentParser(description="Check documentation consistency")
parser.add_argument( parser.add_argument(
"--strict", action="store_true", help="Treat warnings as errors (exit 1)" "--strict", action="store_true", help="Treat warnings as errors (exit 1)"
-1
View File
@@ -10,7 +10,6 @@ seconds to complete. They are intended for CI/full test runs, not for
fast feedback during development. fast feedback during development.
""" """
import numpy as np import numpy as np
from hypothesis import given, settings, strategies as st from hypothesis import given, settings, strategies as st
+12 -15
View File
@@ -116,14 +116,13 @@ def _test_non_negative_values(
), f"{component_name}: Inf values found in output." ), f"{component_name}: Inf values found in output."
if allow_zero: if allow_zero:
assert np.all(result >= 0), ( assert np.all(
f"{component_name}: negative values found. " f"Min = {np.min(result):.10f}" result >= 0
) ), f"{component_name}: negative values found. Min = {np.min(result):.10f}"
else: else:
assert np.all(result > 0), ( assert np.all(
f"{component_name}: non-positive values found. " result > 0
f"Min = {np.min(result):.10f}" ), f"{component_name}: non-positive values found. Min = {np.min(result):.10f}"
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -140,10 +139,9 @@ def _test_output_shape_preserved(component, W, component_name="Component"):
assert isinstance( assert isinstance(
result, np.ndarray result, np.ndarray
), f"{component_name}: output is {type(result)}, expected np.ndarray" ), f"{component_name}: output is {type(result)}, expected np.ndarray"
assert result.shape == W_arr.shape, ( assert (
f"{component_name}: shape mismatch. " result.shape == W_arr.shape
f"Input {W_arr.shape} → Output {result.shape}" ), f"{component_name}: shape mismatch. Input {W_arr.shape} → Output {result.shape}"
)
@pytest.mark.quality @pytest.mark.quality
@@ -216,10 +214,9 @@ def _test_output_is_ndarray(component, W, component_name="Component"):
W_arr = np.asarray(W, dtype=float) W_arr = np.asarray(W, dtype=float)
result = component(W_arr) result = component(W_arr)
assert isinstance(result, np.ndarray), ( assert isinstance(
f"{component_name}: output type is {type(result).__name__}, " result, np.ndarray
f"expected np.ndarray" ), f"{component_name}: output type is {type(result).__name__}, expected np.ndarray"
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+24 -24
View File
@@ -9,7 +9,7 @@ from fitter.model import SpectrumModel
class TestFitConfig: class TestFitConfig:
"""Test FitConfig defaults.""" """Test FitConfig defaults."""
def test_defaults(self): def test_defaults(self) -> None:
config = FitConfig() config = FitConfig()
assert config.method == "trf" assert config.method == "trf"
assert config.max_nfev == 1000 assert config.max_nfev == 1000
@@ -19,11 +19,13 @@ class TestFitConfig:
class TestSpectrumFitter: class TestSpectrumFitter:
"""Test SpectrumFitter.""" """Test SpectrumFitter."""
def _make_fitter(self): def _make_fitter(self) -> "SpectrumFitter":
"""Create a simple fitter with synthetic data.""" """Create a simple fitter with synthetic data."""
# Create a simple linear model # Create a simple linear model
def linear_model(energies_keV, norm, bg): def linear_model(
energies_keV: np.ndarray, norm: float, bg: float
) -> np.ndarray:
return norm * (1.0 - energies_keV / 100.0) + bg return norm * (1.0 - energies_keV / 100.0) + bg
# Synthetic data # Synthetic data
@@ -33,7 +35,9 @@ class TestSpectrumFitter:
# Mock model # Mock model
class MockModel: class MockModel:
def evaluate(self, energies_keV, norm, bg): def evaluate(
self, energies_keV: np.ndarray, norm: float, bg: float
) -> np.ndarray:
return linear_model(energies_keV, norm, bg) return linear_model(energies_keV, norm, bg)
model = MockModel() model = MockModel()
@@ -42,7 +46,7 @@ class TestSpectrumFitter:
) )
return fitter return fitter
def test_fit_basic(self): def test_fit_basic(self) -> None:
fitter = self._make_fitter() fitter = self._make_fitter()
result = fitter.fit(x0=[10.0, 2.0], param_names=["norm", "bg"]) result = fitter.fit(x0=[10.0, 2.0], param_names=["norm", "bg"])
@@ -53,7 +57,7 @@ class TestSpectrumFitter:
assert result.chi2_per_dof > 0 assert result.chi2_per_dof > 0
assert len(result.parameters) == 2 assert len(result.parameters) == 2
def test_fit_with_bounds(self): def test_fit_with_bounds(self) -> None:
fitter = self._make_fitter() fitter = self._make_fitter()
result = fitter.fit( result = fitter.fit(
x0=[10.0, 2.0], x0=[10.0, 2.0],
@@ -61,36 +65,36 @@ class TestSpectrumFitter:
) )
assert result.success assert result.success
def test_fit_returns_residuals(self): def test_fit_returns_residuals(self) -> None:
fitter = self._make_fitter() fitter = self._make_fitter()
result = fitter.fit(x0=[10.0, 2.0]) result = fitter.fit(x0=[10.0, 2.0])
assert len(result.residuals) == 200 assert len(result.residuals) == 200
assert len(result.model_values) == 200 assert len(result.model_values) == 200
def test_parameters_property(self): def test_parameters_property(self) -> None:
fitter = self._make_fitter() fitter = self._make_fitter()
fitter.fit(x0=[10.0, 2.0]) fitter.fit(x0=[10.0, 2.0])
params = fitter.parameters params = fitter.parameters
assert len(params) == 2 assert len(params) == 2
def test_covariance_property(self): def test_covariance_property(self) -> None:
fitter = self._make_fitter() fitter = self._make_fitter()
fitter.fit(x0=[10.0, 2.0]) fitter.fit(x0=[10.0, 2.0])
cov = fitter.covariance cov = fitter.covariance
assert cov.shape == (2, 2) assert cov.shape == (2, 2)
def test_chi2_per_dof(self): def test_chi2_per_dof(self) -> None:
fitter = self._make_fitter() fitter = self._make_fitter()
result = fitter.fit(x0=[10.0, 2.0]) result = fitter.fit(x0=[10.0, 2.0])
assert result.chi2_per_dof == result.chi2 / result.n_free assert result.chi2_per_dof == result.chi2 / result.n_free
def test_p_value(self): def test_p_value(self) -> None:
fitter = self._make_fitter() fitter = self._make_fitter()
result = fitter.fit(x0=[10.0, 2.0]) result = fitter.fit(x0=[10.0, 2.0])
assert 0.0 <= result.p_value <= 1.0 assert 0.0 <= result.p_value <= 1.0
def test_parameters_with_errors(self): def test_parameters_with_errors(self) -> None:
fitter = self._make_fitter() fitter = self._make_fitter()
result = fitter.fit(x0=[10.0, 2.0]) result = fitter.fit(x0=[10.0, 2.0])
params = result.parameters_with_errors params = result.parameters_with_errors
@@ -100,7 +104,7 @@ class TestSpectrumFitter:
assert isinstance(err, float) assert isinstance(err, float)
assert err >= 0 assert err >= 0
def test_correlation_matrix(self): def test_correlation_matrix(self) -> None:
fitter = self._make_fitter() fitter = self._make_fitter()
result = fitter.fit(x0=[10.0, 2.0]) result = fitter.fit(x0=[10.0, 2.0])
corr = result.correlation_matrix corr = result.correlation_matrix
@@ -108,7 +112,7 @@ class TestSpectrumFitter:
# Diagonal should be 1 # Diagonal should be 1
np.testing.assert_allclose(np.diag(corr), 1.0) np.testing.assert_allclose(np.diag(corr), 1.0)
def test_summary(self): def test_summary(self) -> None:
fitter = self._make_fitter() fitter = self._make_fitter()
result = fitter.fit(x0=[10.0, 2.0], param_names=["norm", "bg"]) result = fitter.fit(x0=[10.0, 2.0], param_names=["norm", "bg"])
summary = result.summary(param_names=["norm", "bg"]) summary = result.summary(param_names=["norm", "bg"])
@@ -116,7 +120,7 @@ class TestSpectrumFitter:
assert "bg" in summary assert "bg" in summary
assert "Fit Results" in summary assert "Fit Results" in summary
def test_profile_likelihood(self): def test_profile_likelihood(self) -> None:
fitter = self._make_fitter() fitter = self._make_fitter()
result = fitter.fit(x0=[10.0, 2.0]) result = fitter.fit(x0=[10.0, 2.0])
param_vals, chi2_vals, norm_vals = result.profile_likelihood(0, n_points=10) param_vals, chi2_vals, norm_vals = result.profile_likelihood(0, n_points=10)
@@ -125,7 +129,7 @@ class TestSpectrumFitter:
assert len(norm_vals) == 10 assert len(norm_vals) == 10
assert np.min(norm_vals) >= 0 # Normalized χ² should be >= 0 assert np.min(norm_vals) >= 0 # Normalized χ² should be >= 0
def test_confidence_interval(self): def test_confidence_interval(self) -> None:
fitter = self._make_fitter() fitter = self._make_fitter()
result = fitter.fit(x0=[10.0, 2.0]) result = fitter.fit(x0=[10.0, 2.0])
ci = result.confidence_interval(0, confidence=0.95) ci = result.confidence_interval(0, confidence=0.95)
@@ -136,7 +140,7 @@ class TestSpectrumFitter:
class TestSpectrumFitterWithRealModel: class TestSpectrumFitterWithRealModel:
"""Test SpectrumFitter with a real SpectrumModel.""" """Test SpectrumFitter with a real SpectrumModel."""
def test_fit_with_real_model(self): def test_fit_with_real_model(self) -> None:
from beta_spectrum import BetaSpectrum, SpectrumConfig from beta_spectrum import BetaSpectrum, SpectrumConfig
config = SpectrumConfig( config = SpectrumConfig(
@@ -144,16 +148,12 @@ class TestSpectrumFitterWithRealModel:
Z_daughter=44, Z_daughter=44,
A_number=99, A_number=99,
endpoint_MeV=0.294, endpoint_MeV=0.294,
detector_model="gaussian",
detector_sigma_a_keV=0.5,
detector_n_channels=200,
) )
spectrum = BetaSpectrum.from_config(config) spectrum = BetaSpectrum.from_config(config)
detector = BetaSpectrum.create_detector_from_config(config) model = SpectrumModel(spectrum)
model = SpectrumModel(spectrum, detector)
# Generate synthetic data from model # Generate synthetic data from the unconvolved model on a keV grid
energies_keV = detector.channel_energies energies_keV = np.linspace(1.0, 293.0, 200)
true_norm = 5.0 true_norm = 5.0
true_bg = 0.1 true_bg = 0.1
counts = model.evaluate(energies_keV, true_norm, true_bg) counts = model.evaluate(energies_keV, true_norm, true_bg)
+15 -36
View File
@@ -10,8 +10,8 @@ from fitter.model import SpectrumModel
class TestSpectrumModel: class TestSpectrumModel:
"""Test SpectrumModel.""" """Test SpectrumModel."""
def _make_model(self, with_detector: bool = False): def _make_model(self) -> tuple[SpectrumModel, SpectrumConfig]:
"""Create a test SpectrumModel.""" """Create a test SpectrumModel (no detector response)."""
config = SpectrumConfig( config = SpectrumConfig(
Z_parent=43, Z_parent=43,
Z_daughter=44, Z_daughter=44,
@@ -20,24 +20,10 @@ class TestSpectrumModel:
) )
spectrum = BetaSpectrum.from_config(config) spectrum = BetaSpectrum.from_config(config)
detector = None model = SpectrumModel(spectrum)
if with_detector:
detector = BetaSpectrum.create_detector_from_config(
SpectrumConfig(
Z_parent=43,
Z_daughter=44,
A_number=99,
endpoint_MeV=0.294,
detector_model="gaussian",
detector_sigma_a_keV=1.0,
detector_n_channels=100,
)
)
model = SpectrumModel(spectrum, detector)
return model, config return model, config
def test_evaluate_unconvolved(self): def test_evaluate_unconvolved(self) -> None:
model, config = self._make_model() model, config = self._make_model()
energies_keV = np.linspace(0.01, 0.29, 50) energies_keV = np.linspace(0.01, 0.29, 50)
values = model.evaluate_unconvolved(energies_keV) values = model.evaluate_unconvolved(energies_keV)
@@ -45,44 +31,34 @@ class TestSpectrumModel:
assert np.all(np.isfinite(values)) assert np.all(np.isfinite(values))
assert np.all(values >= 0) assert np.all(values >= 0)
def test_evaluate_no_detector(self): def test_evaluate_no_detector(self) -> None:
model, config = self._make_model() model, config = self._make_model()
energies_keV = np.linspace(0.01, 0.29, 50) energies_keV = np.linspace(0.01, 0.29, 50)
values = model.evaluate(energies_keV, normalization=1.0, background=0.0) values = model.evaluate(energies_keV, normalization=1.0, background=0.0)
assert len(values) == 50 assert len(values) == 50
assert np.all(np.isfinite(values)) assert np.all(np.isfinite(values))
def test_evaluate_with_normalization(self): def test_evaluate_with_normalization(self) -> None:
model, config = self._make_model() model, config = self._make_model()
energies_keV = np.linspace(0.01, 0.29, 50) energies_keV = np.linspace(0.01, 0.29, 50)
values1 = model.evaluate(energies_keV, normalization=1.0, background=0.0) values1 = model.evaluate(energies_keV, normalization=1.0, background=0.0)
values2 = model.evaluate(energies_keV, normalization=2.0, background=0.0) values2 = model.evaluate(energies_keV, normalization=2.0, background=0.0)
np.testing.assert_allclose(values2, values1 * 2.0) np.testing.assert_allclose(values2, values1 * 2.0)
def test_evaluate_with_background(self): def test_evaluate_with_background(self) -> None:
model, config = self._make_model() model, config = self._make_model()
energies_keV = np.linspace(0.01, 0.29, 50) energies_keV = np.linspace(0.01, 0.29, 50)
values1 = model.evaluate(energies_keV, normalization=1.0, background=0.0) values1 = model.evaluate(energies_keV, normalization=1.0, background=0.0)
values2 = model.evaluate(energies_keV, normalization=1.0, background=5.0) values2 = model.evaluate(energies_keV, normalization=1.0, background=5.0)
np.testing.assert_allclose(values2, values1 + 5.0) np.testing.assert_allclose(values2, values1 + 5.0)
def test_convolve_requires_detector(self): def test_convolve_requires_detector(self) -> None:
model, _ = self._make_model() model, _ = self._make_model()
energies_keV = np.linspace(0.01, 0.29, 50) energies_keV = np.linspace(0.01, 0.29, 50)
with pytest.raises(RuntimeError): with pytest.raises(RuntimeError):
model.convolve(energies_keV) model.convolve(energies_keV)
def test_convolve_with_detector(self): def test_default_params(self) -> None:
model, _ = self._make_model(with_detector=True)
energies_keV = model.detector_response.channel_energies
values = model.convolve(energies_keV)
assert len(values) == model.detector_response.n_channels
assert np.all(np.isfinite(values))
# Convolution can produce small negative values due to numerical effects
# — check that most values are non-negative
assert np.sum(values >= 0) > len(values) * 0.9
def test_default_params(self):
from exp_data.spectrum import ExpSpectrum from exp_data.spectrum import ExpSpectrum
energies = np.linspace(0, 100, 200) energies = np.linspace(0, 100, 200)
@@ -93,7 +69,7 @@ class TestSpectrumModel:
assert norm > 0 assert norm > 0
assert bg >= 0 assert bg >= 0
def test_default_bounds(self): def test_default_bounds(self) -> None:
lower, upper = SpectrumModel.default_bounds(100) lower, upper = SpectrumModel.default_bounds(100)
assert len(lower) == 2 assert len(lower) == 2
assert len(upper) == 2 assert len(upper) == 2
@@ -104,8 +80,11 @@ class TestSpectrumModel:
class TestSpectrumModelKeVToW: class TestSpectrumModelKeVToW:
"""Test energy unit conversion in SpectrumModel.""" """Test energy unit conversion in SpectrumModel."""
def test_keV_to_W(self): def test_keV_to_W(self) -> None:
model = SpectrumModel(None) # type: ignore config = SpectrumConfig(
Z_parent=43, Z_daughter=44, A_number=99, endpoint_MeV=0.294
)
model = SpectrumModel(BetaSpectrum.from_config(config))
# 0 keV → W = 1.0 # 0 keV → W = 1.0
W = model._keV_to_W(np.array([0.0])) W = model._keV_to_W(np.array([0.0]))
assert np.isclose(W[0], 1.0) assert np.isclose(W[0], 1.0)
+208
View File
@@ -0,0 +1,208 @@
"""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_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, "--separate-branches", "--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",
"--separate-branches",
]
)
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_separate_branches(self, tmp_path: Any) -> None:
out = str(tmp_path / "p32.csv")
main(["P32", "--output", out, "--separate-branches"])
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()
def test_paceensdf_co60_dry_run_multi_branch(self, capsys: Any) -> None:
"""Co60 parses into a healthy multi-branch config (D3): no crash,
all branch endpoints positive."""
main(["Co60", "--dry-run"])
out = capsys.readouterr().out
assert "Multi-branch mode: 3 branches" in out
def test_paceensdf_co60_export(self, tmp_path: Any) -> None:
out = str(tmp_path / "co60.csv")
main(["Co60", "--output", out])
df = read_export(out)
area = np.trapezoid(df["total_spectrum"], df["energy_MeV"])
assert np.isclose(area, 1.0, rtol=1e-3)
eps = header_lines_of(out)
assert any("E₀=1492.0" in h for h in eps)
assert not any("excluded" in h for h in eps)
def test_cli_builds_paceensdf_config_via_library(
self, monkeypatch: Any, capsys: Any
) -> None:
"""CLI must delegate paceENSDF config building to the library
(create_config_from_source), not duplicate it inline (D5)."""
import beta_spectrum.nuclear_data as nuclear_data
real_builder = nuclear_data.create_config_from_source
calls: List[Dict[str, Any]] = []
def spy(source: str, **kwargs: Any) -> Any:
calls.append({"source": source, **kwargs})
return real_builder(source, **kwargs)
monkeypatch.setattr(nuclear_data, "create_config_from_source", spy)
main(["Cs137", "--dry-run", "--intensity-cutoff", "0.9"])
capsys.readouterr()
assert len(calls) == 1, (
"CLI must delegate paceENSDF config building to the library "
"builder exactly once"
)
assert calls[0]["source"] == "paceENSDF"
assert calls[0]["nuclide"] == "Cs137"
assert calls[0]["intensity_cutoff"] == 0.9
+9 -183
View File
@@ -7,7 +7,6 @@ work together correctly.
""" """
import numpy as np import numpy as np
import pytest
from beta_spectrum import BetaSpectrum, SpectrumConfig from beta_spectrum import BetaSpectrum, SpectrumConfig
@@ -61,13 +60,12 @@ class TestBetaSpectrumToggles:
"""Test that component toggling works correctly.""" """Test that component toggling works correctly."""
def test_all_disabled(self): def test_all_disabled(self):
"""With all corrections disabled, only phase space remains — still valid output.""" """With all corrections disabled, the core remains — still valid output."""
config = SpectrumConfig( config = SpectrumConfig(
Z_parent=19, Z_parent=19,
Z_daughter=20, Z_daughter=20,
A_number=40, A_number=40,
endpoint_MeV=2.5, endpoint_MeV=2.5,
use_fermi=False,
use_screening=False, use_screening=False,
use_finite_size=False, use_finite_size=False,
use_charge_dist=False, use_charge_dist=False,
@@ -82,14 +80,14 @@ class TestBetaSpectrumToggles:
assert len(values) > 0, "Should return non-empty array" assert len(values) > 0, "Should return non-empty array"
assert np.all(np.isfinite(values)), "All values must be finite" assert np.all(np.isfinite(values)), "All values must be finite"
def test_fermi_only(self): def test_core_equals_phase_space_times_fermi(self):
"""With only fermi function enabled: should give F₀(Z,W) — not just phase space.""" """Core components are unconditional: with corrections disabled the
spectrum equals phase space × Fermi function exactly."""
config_minimal = SpectrumConfig( config_minimal = SpectrumConfig(
Z_parent=19, Z_parent=19,
Z_daughter=20, Z_daughter=20,
A_number=40, A_number=40,
endpoint_MeV=2.5, endpoint_MeV=2.5,
use_phase_space=False,
use_screening=False, use_screening=False,
use_finite_size=False, use_finite_size=False,
use_charge_dist=False, use_charge_dist=False,
@@ -101,8 +99,11 @@ class TestBetaSpectrumToggles:
W, _ = spectrum.get_energy_grid(config_minimal) W, _ = spectrum.get_energy_grid(config_minimal)
values = spectrum(W) values = spectrum(W)
# Fermi function alone is ~1 for Z=20 at mid-energy components = spectrum.calculate_components(W)
assert np.all(np.isfinite(values)), "Fermi-only output must be finite" assert set(components.keys()) == {"PhaseSpace", "Fermi"}
expected = components["PhaseSpace"] * components["Fermi"]
assert np.allclose(values, expected, rtol=1e-10)
assert np.all(np.isfinite(values)), "Core-only output must be finite"
class TestBetaSpectrumComponentExtraction: class TestBetaSpectrumComponentExtraction:
@@ -158,178 +159,3 @@ class TestBetaSpectrumRealisticConfig:
assert np.all(np.isfinite(values)), "C-14 must not produce NaN/inf" assert np.all(np.isfinite(values)), "C-14 must not produce NaN/inf"
# Phase space should peak in the bulk for low-Q decay # Phase space should peak in the bulk for low-Q decay
assert np.any(values > 0), "Should have non-zero spectrum values" assert np.any(values > 0), "Should have non-zero spectrum values"
@pytest.mark.skip(reason="DetectorResponse not yet implemented")
class TestDeclarativeDetectorResponse:
"""Test declarative detector response workflow via SpectrumConfig."""
def test_create_detector_from_config(self):
"""SpectrumConfig detector params should create valid DetectorResponse."""
config = SpectrumConfig(
Z_parent=43,
Z_daughter=44,
A_number=99,
endpoint_MeV=0.294,
detector_model="gaussian",
detector_sigma_a_keV=1.0,
detector_n_channels=512,
)
detector = BetaSpectrum.create_detector_from_config(config)
from beta_spectrum.components.detector_response import DetectorResponse
assert isinstance(detector, DetectorResponse)
assert detector.n_channels == 512
assert detector._mode == "analytical"
assert detector.model == "gaussian"
@pytest.mark.skip(reason="DetectorResponse not yet implemented")
def test_detector_sigma_conversion(self):
"""Sigma in keV should be correctly converted to m_e units."""
from beta_spectrum.utils import T_to_W
config = SpectrumConfig(
Z_parent=43,
Z_daughter=44,
A_number=99,
endpoint_MeV=0.294,
detector_sigma_a_keV=1.0,
detector_fano_factor=0.0,
)
detector = BetaSpectrum.create_detector_from_config(config)
# 1.0 keV = 1.0/511.0 m_e ≈ 0.00196
expected_sigma = 1.0 / 0.510998950
sigma_at_endpoint = detector._resolution_sigma(T_to_W(0.294))
assert np.isclose(sigma_at_endpoint, expected_sigma, rtol=0.01)
@pytest.mark.skip(reason="DetectorResponse not yet implemented")
def test_convolve_detector_single_call(self):
"""convolve_detector() should work in a single call with config params."""
config = SpectrumConfig(
Z_parent=43,
Z_daughter=44,
A_number=99,
endpoint_MeV=0.294,
detector_model="gaussian",
detector_sigma_a_keV=1.0,
detector_n_channels=512,
detector_fano_factor=0.0,
)
spectrum = BetaSpectrum.from_config(config)
W, _ = spectrum.get_energy_grid(config)
convolved = spectrum.convolve_detector(config, W=W)
assert convolved.shape == (512,)
assert np.all(convolved >= 0), "Convolved spectrum should be non-negative"
assert np.sum(convolved) > 0, "Convolved spectrum should have non-zero area"
@pytest.mark.skip(reason="DetectorResponse not yet implemented")
def test_convolve_detector_with_tail(self):
"""Convolution with tail model should produce low-energy tailing."""
config = SpectrumConfig(
Z_parent=43,
Z_daughter=44,
A_number=99,
endpoint_MeV=0.294,
detector_model="gaussian_tail",
detector_sigma_a_keV=1.0,
detector_tail_fraction=0.2,
detector_tau_keV=5.0,
detector_n_channels=512,
detector_fano_factor=0.0,
)
spectrum = BetaSpectrum.from_config(config)
W, _ = spectrum.get_energy_grid(config)
convolved = spectrum.convolve_detector(config, W=W)
assert convolved.shape == (512,)
assert np.all(convolved >= 0)
@pytest.mark.skip(reason="DetectorResponse not yet implemented")
def test_analyzer_convolved_from_config(self):
"""BetaSpectrumAnalyzer should use config detector params automatically."""
config = SpectrumConfig(
Z_parent=43,
Z_daughter=44,
A_number=99,
endpoint_MeV=0.294,
use_detector_response=True,
detector_model="gaussian",
detector_sigma_a_keV=1.0,
detector_n_channels=256,
detector_fano_factor=0.0,
)
spectrum = BetaSpectrum.from_config(config)
from beta_spectrum import BetaSpectrumAnalyzer
analyzer = BetaSpectrumAnalyzer(spectrum, config)
detector = BetaSpectrum.create_detector_from_config(config)
convolved = analyzer.convolved_spectrum(detector, normalize=True)
assert convolved.shape == (256,)
# Normalized spectrum should integrate to ~1 over channel energies
integral = np.trapezoid(convolved, detector.channel_energies)
assert np.isclose(
integral, 1.0, rtol=0.05
), f"Integral should be ~1.0, got {integral}"
@pytest.mark.skip(reason="DetectorResponse not yet implemented")
def test_detector_range_extends_to_endpoint(self):
"""Detector channel range should extend to cover spectrum endpoint."""
config = SpectrumConfig(
Z_parent=43,
Z_daughter=44,
A_number=99,
endpoint_MeV=0.294,
detector_channel_energy_range=(0.0, 0.2), # Less than endpoint
detector_n_channels=256,
detector_fano_factor=0.0,
)
detector = BetaSpectrum.create_detector_from_config(config)
# Range should extend beyond endpoint in m_e units
from beta_spectrum.utils import T_to_W
W0 = T_to_W(0.294)
assert detector.channel_energies[-1] >= W0, (
f"Detector range {detector.channel_energies[-1]:.4f} should cover "
f"endpoint {W0:.4f}"
)
@pytest.mark.skip(reason="DetectorResponse not yet implemented")
def test_different_detector_models(self):
"""All detector models should produce valid convolved spectra."""
models = ["gaussian", "gaussian_tail", "tikhonov"]
for model in models:
config = SpectrumConfig(
Z_parent=43,
Z_daughter=44,
A_number=99,
endpoint_MeV=0.294,
detector_model=model,
detector_sigma_a_keV=1.0,
detector_n_channels=256,
detector_fano_factor=0.0,
)
if model == "gaussian_tail":
config.detector_tail_fraction = 0.1
config.detector_tau_keV = 5.0
spectrum = BetaSpectrum.from_config(config)
W, _ = spectrum.get_energy_grid(config)
convolved = spectrum.convolve_detector(config, W=W)
assert convolved.shape == (256,)
assert np.all(convolved >= 0), f"Model {model} produced negative values"
+731
View File
@@ -0,0 +1,731 @@
"""
Core-spectrum invariant tests.
The basic spectrum is ALWAYS core = phase_space × fermi. Fermi is a core
spectrum component on par with phase space (its 0-order Coulomb correction),
never a higher-order correction. Finite size, charge distribution, screening,
exchange and radiative are higher-order corrections applied to the core.
These tests pin the invariant at every layer:
- SpectrumConfig must not allow disabling the core components
- calculate_components always contains PhaseSpace and Fermi
- total spectrum equals universal × sum(intensity × branch) and every branch
physical spectrum contains the Fermi enhancement
"""
from __future__ import annotations
import inspect
from typing import Any, Dict, List, Tuple
import numpy as np
import pandas as pd
import pytest
from beta_spectrum import (
BetaSpectrum,
BranchConfig,
SpectrumConfig,
)
from beta_spectrum.output import BetaSpectrumAnalyzer
from beta_spectrum.output.visualization import Visualizer, parse_spectrum_csv
ME_MEV = 0.510998950
def multi_branches() -> List[BranchConfig]:
return [
BranchConfig(endpoint_MeV=0.294, transition_type="F2", intensity=0.98),
BranchConfig(endpoint_MeV=0.200, transition_type="F2U", intensity=0.15),
BranchConfig(endpoint_MeV=0.120, transition_type="A", intensity=0.005),
]
def make_multi_config(**over: Any) -> SpectrumConfig:
return SpectrumConfig(
Z_parent=43,
Z_daughter=44,
A_number=99,
endpoint_MeV=0.294,
transition_type="F2",
e_step_MeV=0.001,
branches=multi_branches(),
**over,
)
def make_single_config(**over: Any) -> SpectrumConfig:
return SpectrumConfig(
Z_parent=15,
Z_daughter=16,
A_number=32,
endpoint_MeV=1.7103,
transition_type="A",
e_step_MeV=0.005,
**over,
)
def make_degenerate_config(**over: Any) -> SpectrumConfig:
"""Multi-branch config where every endpoint lies below m_nu."""
return SpectrumConfig(
Z_parent=43,
Z_daughter=44,
A_number=99,
endpoint_MeV=0.294,
e_step_MeV=0.001,
neutrino_mass_MeV=0.35,
**over,
branches=[
BranchConfig(endpoint_MeV=0.294, transition_type="F2", intensity=0.98),
BranchConfig(endpoint_MeV=0.200, transition_type="F2U", intensity=0.02),
BranchConfig(endpoint_MeV=0.120, transition_type="A", intensity=0.005),
],
)
def branches_of(config: SpectrumConfig) -> List[BranchConfig]:
branches = config.branches
assert branches is not None
return branches
def export_and_read(
analyzer: BetaSpectrumAnalyzer,
tmp_path: Any,
name: str = "out.csv",
csv_verbosity: str = "full",
) -> Tuple[str, pd.DataFrame]:
path = str(tmp_path / name)
analyzer.export_to_csv(path, csv_verbosity=csv_verbosity)
df = pd.read_csv(path, sep=None, engine="python", comment="#")
return path, df
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 universal_factor(components: Dict[str, np.ndarray], W: np.ndarray) -> np.ndarray:
"""Product of all components shared by all branches.
Universal components are the bare-named keys except the per-branch core
factors (PhaseSpace) and per-branch radiative corrections.
"""
uni = np.ones_like(np.asarray(W, dtype=float))
for key, values in components.items():
if "." in key:
continue
if key in ("PhaseSpace", "Radiative"):
continue
uni = uni * values
return uni
class TestCoreComponentsAreUnconditional:
"""phase_space × fermi is the core: it can never be disabled."""
def test_config_has_no_core_toggles(self) -> None:
"""use_fermi / use_phase_space must not exist as config fields.
A dataclass __init__ rejects unknown keyword arguments with
TypeError, so the fields' absence is exactly the enforcement that
the core components can never be disabled.
"""
params = set(inspect.signature(SpectrumConfig).parameters)
assert "use_fermi" not in params
assert "use_phase_space" not in params
# Corrections remain configurable:
assert "use_radiative" in params
assert "use_screening" in params
def test_single_branch_components_contain_core(self) -> None:
config = make_single_config()
spectrum = BetaSpectrum.from_config(config)
W, _ = spectrum.get_energy_grid(config)
components = spectrum.calculate_components(W)
assert "PhaseSpace" in components
assert "Fermi" in components
def test_multi_branch_components_contain_core(self) -> None:
config = make_multi_config()
spectrum = BetaSpectrum.from_config(config)
W, _ = spectrum.get_energy_grid(config)
components = spectrum.calculate_components(W)
# Core phase space lives per-branch; Fermi is shared (bare key).
assert "Fermi" in components
for i in range(len(branches_of(config))):
assert f"branch_{i}.PhaseSpace" in components
def test_all_corrections_disabled_still_has_core(self) -> None:
config = make_single_config(
use_screening=False,
use_finite_size=False,
use_charge_dist=False,
use_exchange=False,
use_radiative=False,
)
spectrum = BetaSpectrum.from_config(config)
W, _ = spectrum.get_energy_grid(config)
components = spectrum.calculate_components(W)
assert set(components.keys()) == {"PhaseSpace", "Fermi"}
def test_single_spectrum_equals_core_times_corrections(self) -> None:
config = make_single_config()
spectrum = BetaSpectrum.from_config(config)
W, _ = spectrum.get_energy_grid(config)
components = spectrum.calculate_components(W)
product = np.ones_like(W)
for values in components.values():
product = product * values
assert np.allclose(spectrum(W), product, rtol=1e-10)
class TestMultiBranchCoreInvariant:
"""total = universal × Σ(intensity × physical_branch), Fermi everywhere."""
def test_total_equals_universal_times_intensity_sum(self) -> None:
config = make_multi_config()
spectrum = BetaSpectrum.from_config(config)
W, _ = spectrum.get_energy_grid(config)
components = spectrum.calculate_components(W)
uni = universal_factor(components, W)
branch_sum = np.zeros_like(W)
for i, branch in enumerate(branches_of(config)):
raw = components[f"branch_{i}.PhaseSpace"]
rad_key = f"branch_{i}.Radiative"
raw = raw * components[rad_key] if rad_key in components else raw
W0 = branch.endpoint_MeV / ME_MEV + 1.0
raw = np.where(W <= W0, raw, 0.0)
branch_sum += branch.intensity * raw
assert np.allclose(spectrum(W), uni * branch_sum, rtol=1e-10)
class TestAnalyzerPhysicalBranchSpectrum:
"""analyzer.physical_branch_spectrum(i) = core × corrections, clamped."""
def test_multi_physical_includes_fermi(self) -> None:
config = make_multi_config()
spectrum = BetaSpectrum.from_config(config)
analyzer = BetaSpectrumAnalyzer(spectrum, config)
components = analyzer.components
W = analyzer.W
uni = universal_factor(components, W)
for i, branch in enumerate(branches_of(config)):
raw = components[f"branch_{i}.PhaseSpace"].copy()
rad_key = f"branch_{i}.Radiative"
raw = raw * components[rad_key] if rad_key in components else raw
W0 = branch.endpoint_MeV / ME_MEV + 1.0
raw = np.where(W <= W0, raw, 0.0)
physical = analyzer.physical_branch_spectrum(i)
expected = uni * raw
assert np.allclose(physical, expected, rtol=1e-10)
def test_physical_clamped_beyond_endpoint(self) -> None:
config = make_multi_config()
spectrum = BetaSpectrum.from_config(config)
analyzer = BetaSpectrumAnalyzer(spectrum, config)
# Branch 0 (0.294 MeV) must be zero beyond its endpoint, while the
# grid extends to the maximum endpoint.
W = analyzer.W
W0_branch0 = branches_of(config)[0].endpoint_MeV / ME_MEV + 1.0
W0_max = config.endpoint_MeV / ME_MEV + 1.0
assert W0_branch0 <= W0_max
phys = analyzer.physical_branch_spectrum(0)
beyond = W > W0_branch0
assert np.all(phys[beyond] == 0.0)
assert np.all(phys[~beyond] > 0.0)
def test_sum_intensity_times_physical_equals_spectrum(self) -> None:
config = make_multi_config()
spectrum = BetaSpectrum.from_config(config)
analyzer = BetaSpectrumAnalyzer(spectrum, config)
total_raw = spectrum(analyzer.W)
weighted_sum = np.zeros_like(analyzer.W)
for i, branch in enumerate(branches_of(config)):
weighted_sum += branch.intensity * analyzer.physical_branch_spectrum(i)
assert np.allclose(weighted_sum, total_raw, rtol=1e-10)
def test_single_branch_physical_equals_full_spectrum(self) -> None:
config = make_single_config()
spectrum = BetaSpectrum.from_config(config)
analyzer = BetaSpectrumAnalyzer(spectrum, config)
physical = analyzer.physical_branch_spectrum(0)
assert np.allclose(physical, spectrum(analyzer.W), rtol=1e-10)
class TestCSVExportCoreSemantics:
"""CSV branch columns are physical spectra (core x corrections)."""
def test_branch_columns_sum_to_total(self, tmp_path: Any) -> None:
config = make_multi_config()
spectrum = BetaSpectrum.from_config(config)
analyzer = BetaSpectrumAnalyzer(spectrum, config)
_, df = export_and_read(analyzer, tmp_path)
total = df["total_spectrum"].to_numpy()
branch_sum = np.zeros_like(total)
for i in range(3):
branch_sum += df[f"branch_{i + 1}_spectrum"].to_numpy()
assert np.allclose(branch_sum, total, rtol=5e-4)
def test_branch_columns_equal_physical_times_intensity(self, tmp_path: Any) -> None:
config = make_multi_config()
spectrum = BetaSpectrum.from_config(config)
analyzer = BetaSpectrumAnalyzer(spectrum, config)
_, df = export_and_read(analyzer, tmp_path)
integral = float(np.trapezoid(spectrum(analyzer.W), analyzer.energies_MeV))
for i, branch in enumerate(branches_of(config)):
physical = analyzer.physical_branch_spectrum(i)
expected = physical * branch.intensity / integral
got = df[f"branch_{i + 1}_spectrum"].to_numpy()
assert np.allclose(got, expected, rtol=5e-4)
def test_total_column_integrates_to_one(self, tmp_path: Any) -> None:
config = make_multi_config()
spectrum = BetaSpectrum.from_config(config)
analyzer = BetaSpectrumAnalyzer(spectrum, config)
_, df = export_and_read(analyzer, tmp_path)
area = np.trapezoid(df["total_spectrum"], df["energy_MeV"])
assert np.isclose(area, 1.0, rtol=1e-3)
def test_header_contains_core_line(self, tmp_path: Any) -> None:
config = make_multi_config()
spectrum = BetaSpectrum.from_config(config)
analyzer = BetaSpectrumAnalyzer(spectrum, config)
path, _ = export_and_read(analyzer, tmp_path)
header = header_lines_of(path)
core_line = [h for h in header if h.startswith("# core:")]
assert core_line
assert "phase_space" in core_line[0]
assert "fermi" in core_line[0]
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)
assert "total_spectrum" not in df.columns
for i in range(3):
col = df[f"branch_{i + 1}_spectrum"]
area = np.trapezoid(col, df["energy_MeV"])
assert np.isclose(area, 1.0, rtol=1e-3)
def test_degenerate_export_raises(self, tmp_path: Any) -> None:
config = make_degenerate_config()
spectrum = BetaSpectrum.from_config(config)
analyzer = BetaSpectrumAnalyzer(spectrum, config)
path = str(tmp_path / "degenerate.csv")
with pytest.raises(ValueError, match="non-positive"):
analyzer.export_to_csv(path)
assert not (tmp_path / "degenerate.csv").exists()
def test_degenerate_export_raises_in_separate_branches_mode(
self, tmp_path: Any
) -> None:
"""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(separate_branches=True)
spectrum = BetaSpectrum.from_config(config)
analyzer = BetaSpectrumAnalyzer(spectrum, config)
path = str(tmp_path / "degenerate_to_one.csv")
with pytest.raises(ValueError, match="non-positive"):
analyzer.export_to_csv(path)
assert not (tmp_path / "degenerate_to_one.csv").exists()
def test_concise_values_match_full(self, tmp_path: Any) -> None:
config = make_multi_config()
spectrum = BetaSpectrum.from_config(config)
analyzer = BetaSpectrumAnalyzer(spectrum, config)
_, df_full = export_and_read(analyzer, tmp_path, name="full.csv")
_, df_concise = export_and_read(
analyzer, tmp_path, name="concise.csv", csv_verbosity="concise"
)
assert set(df_concise.columns) == {
"energy_MeV",
"total_spectrum",
"branch_1",
"branch_2",
"branch_3",
}
assert np.allclose(
df_concise["branch_1"], df_full["branch_1_spectrum"], rtol=0, atol=0
)
assert np.allclose(
df_concise["total_spectrum"], df_full["total_spectrum"], rtol=0, atol=0
)
def test_single_branch_branch_column_equals_total(self, tmp_path: Any) -> None:
config = make_single_config()
spectrum = BetaSpectrum.from_config(config)
analyzer = BetaSpectrumAnalyzer(spectrum, config)
_, df = export_and_read(analyzer, tmp_path)
assert np.allclose(
df["branch_1_spectrum"], df["total_spectrum"], rtol=0, atol=0
)
class TestVisualizerBuiltFromCSV:
"""Visualizer must plot from the exported CSV file contents only."""
def test_parse_spectrum_csv_metadata(self, tmp_path: Any) -> None:
config = make_multi_config()
spectrum = BetaSpectrum.from_config(config)
analyzer = BetaSpectrumAnalyzer(spectrum, config)
path, df = export_and_read(analyzer, tmp_path)
parsed_df, meta = parse_spectrum_csv(path)
assert list(parsed_df.columns) == list(df.columns)
assert meta["version"] is not None
assert meta["timestamp"] is not None
assert meta["nuclide"] == "Tc99 -> Ru99"
assert meta["endpoint_keV"] == pytest.approx(294.0)
assert meta["e_step_MeV"] == pytest.approx(0.001)
assert meta["normalization"] == "intensity"
assert "screening" in meta["corrections"]
assert len(meta["branches"]) == 3
assert meta["branches"][0]["intensity"] == pytest.approx(0.98)
assert meta["branches"][0]["excluded"] is None
assert meta["n_points"] == len(df)
def test_plot_from_csv_curves_match_csv(self, tmp_path: Any) -> None:
config = make_multi_config()
spectrum = BetaSpectrum.from_config(config)
analyzer = BetaSpectrumAnalyzer(spectrum, config)
path, df = export_and_read(analyzer, tmp_path)
save = str(tmp_path / "plot.png")
fig = Visualizer.plot_from_csv(path, save_path=save, show_components=True)
assert (tmp_path / "plot.png").exists()
# debug view: 4 panels; panel 0 holds total + active branch curves
assert len(fig.axes) == 4
panel = fig.axes[0]
curves = [np.asarray(ln.get_xydata()) for ln in panel.lines]
assert len(curves) == 1 + 3
energies = df["energy_MeV"].to_numpy()
assert np.allclose(curves[0][:, 0], energies, rtol=0, atol=0)
assert np.allclose(
curves[0][:, 1], df["total_spectrum"].to_numpy(), rtol=0, atol=0
)
for i in range(3):
col = df[f"branch_{i + 1}_spectrum"].to_numpy()
assert np.allclose(curves[i + 1][:, 1], col, rtol=0, atol=0)
def test_plot_from_csv_concise_falls_back_to_spectra(
self, tmp_path: Any, capsys: Any
) -> None:
config = make_multi_config()
spectrum = BetaSpectrum.from_config(config)
analyzer = BetaSpectrumAnalyzer(spectrum, config)
path, _ = export_and_read(
analyzer, tmp_path, name="concise.csv", csv_verbosity="concise"
)
save = str(tmp_path / "plot.png")
fig = Visualizer.plot_from_csv(path, save_path=save, show_components=True)
assert len(fig.axes) == 1
assert "Warning" in capsys.readouterr().out
def test_plot_analysis_saves_figure(self, tmp_path: Any) -> None:
config = make_single_config()
spectrum = BetaSpectrum.from_config(config)
analyzer = BetaSpectrumAnalyzer(spectrum, config)
save = str(tmp_path / "analysis.png")
Visualizer.plot_analysis(analyzer, save_path=save)
assert (tmp_path / "analysis.png").exists()
def make_single_survivor_config(**over: Any) -> SpectrumConfig:
"""A config whose top-level endpoint exceeds a lone branch's endpoint.
This mirrors the intensity_cutoff / decay_info_to_config path where the
parent endpoint_MeV keeps the strongest-decay value while the only
surviving branch sits far below it.
"""
over.setdefault("endpoint_MeV", 1.176)
over.setdefault("transition_type", "F2")
over.setdefault(
"branches",
[BranchConfig(endpoint_MeV=0.514341, transition_type="A", intensity=1.0)],
)
return SpectrumConfig(
Z_parent=55,
Z_daughter=56,
A_number=137,
e_step_MeV=0.001,
**over,
)
class TestSurvivingBranchEndpointFollows:
"""When config.branches has exactly 1 entry, single-mode spectrum,
energy grid and analyzer must follow the branch endpoint/transition,
not the top-level endpoint fields."""
def test_single_mode_builds_from_branch_endpoint(self) -> None:
config = make_single_survivor_config()
spectrum = BetaSpectrum.from_config(config)
assert not spectrum._is_multi
W, E = spectrum.get_energy_grid(config)
# The energy grid must be capped at the lone branch endpoint
# (~514 keV), not the stale top-level endpoint (1176 keV), and
# stay non-negative everywhere it covers.
assert float(E.max()) < 0.514341 + 1e-12
values = spectrum(W)
assert np.all(np.isfinite(values))
assert np.all(values >= 0.0)
def test_single_mode_matches_reference_bare_config(self) -> None:
"""Branch-driven single mode == a bare config set to the branch endpoint."""
branch_ep = 0.514341
cfg_branch = make_single_survivor_config()
cfg_ref = SpectrumConfig(
Z_parent=55,
Z_daughter=56,
A_number=137,
endpoint_MeV=branch_ep,
transition_type="A",
e_step_MeV=0.001,
)
spec_b = BetaSpectrum.from_config(cfg_branch)
spec_ref = BetaSpectrum.from_config(cfg_ref)
Wb, Eb = spec_b.get_energy_grid(cfg_branch)
Wr, Er = spec_ref.get_energy_grid(cfg_ref)
vb = spec_b(Wb)
vr = spec_ref(Wr)
# The branch-driven grid and values must be identical to a bare
# config built directly at the branch endpoint/transition.
assert len(Eb) == len(Er)
assert np.allclose(Eb, Er, rtol=0, atol=1e-12)
assert np.allclose(vb, vr, rtol=1e-10)
def test_analyzer_unified_view_follows_branch(self) -> None:
config = make_single_survivor_config()
spectrum = BetaSpectrum.from_config(config)
analyzer = BetaSpectrumAnalyzer(spectrum, config)
ub = analyzer._unified_branches[0]
assert ub.endpoint_MeV == pytest.approx(0.514341)
assert ub.transition_type == "A"
def test_csv_header_reports_branch_endpoint(self, tmp_path: Any) -> None:
config = make_single_survivor_config()
spectrum = BetaSpectrum.from_config(config)
analyzer = BetaSpectrumAnalyzer(spectrum, config)
path, _ = export_and_read(analyzer, tmp_path)
header = header_lines_of(path)
# The lone surviving branch must be reported at its own endpoint,
# not the 1176 keV top-level endpoint.
assert any("514.3 keV" in h for h in header)
assert not any("1176.0 keV" in h for h in header)
class TestAnalyzerUniversalFactor:
"""analyzer.universal_factor() = product of components shared by all."""
def test_universal_factor_matches_component_product(self) -> None:
config = make_multi_config()
spectrum = BetaSpectrum.from_config(config)
analyzer = BetaSpectrumAnalyzer(spectrum, config)
uni = analyzer.universal_factor()
expected = universal_factor(analyzer.components, analyzer.W)
assert np.allclose(uni, expected, rtol=1e-12)
def test_universal_factor_drops_disabled_corrections(self) -> None:
config = make_multi_config(
use_screening=False,
use_finite_size=False,
use_charge_dist=False,
use_exchange=False,
use_radiative=False,
)
spectrum = BetaSpectrum.from_config(config)
analyzer = BetaSpectrumAnalyzer(spectrum, config)
uni = analyzer.universal_factor()
# Only the Fermi function remains universal: it equals the Fermi
# component alone. Fermi enhancement is > 1 for beta-minus Z=44.
assert np.all(uni > 1.0)
fermi_only = analyzer.components["Fermi"]
assert np.allclose(uni, fermi_only, rtol=1e-12)
class TestNormalizationModeMatrix:
"""separate_branches and intensity modes must behave consistently across CSV
verbosity, branch count and the CSV-driven visualizer."""
def test_separate_branches_concise_multi_unit_area_no_total_no_components(
self, tmp_path: Any
) -> None:
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_concise.csv", csv_verbosity="concise"
)
assert set(df.columns) == {
"energy_MeV",
"branch_1",
"branch_2",
"branch_3",
}
header = header_lines_of(path)
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_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")
assert "total_spectrum" not in df.columns
area = np.trapezoid(
df["branch_1_spectrum"].to_numpy(), df["energy_MeV"].to_numpy()
)
assert np.isclose(area, 1.0, rtol=5e-4)
def test_intensity_concise_branch_columns_sum_to_total(self, tmp_path: Any) -> None:
config = make_multi_config()
spectrum = BetaSpectrum.from_config(config)
analyzer = BetaSpectrumAnalyzer(spectrum, config)
_path, df = export_and_read(
analyzer, tmp_path, "intensity_concise.csv", csv_verbosity="concise"
)
summed = (
df["branch_1"].to_numpy()
+ df["branch_2"].to_numpy()
+ df["branch_3"].to_numpy()
)
total = df["total_spectrum"].to_numpy()
nonzero = total > 1e-12
assert np.all(np.abs(summed[nonzero] - total[nonzero]) <= total[nonzero] * 5e-4)
def test_plot_from_csv_separate_branches_plots_branches_without_total(
self, tmp_path: Any
) -> None:
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")
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 separate-branches mode.
panel = fig.axes[0]
curves = [np.asarray(ln.get_xydata()) for ln in panel.lines]
assert len(curves) == 3
energies = df["energy_MeV"].to_numpy()
assert np.allclose(curves[0][:, 0], energies)
assert np.allclose(curves[0][:, 1], df["branch_1_spectrum"].to_numpy())
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")
fig = Visualizer.plot_analysis(analyzer, save_path=save)
assert len(fig.axes) == 4
assert (tmp_path / "analysis.png").exists()
def test_plot_from_csv_separate_branches_concise_warns_and_falls_back(
self, tmp_path: Any, capsys: Any
) -> None:
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_concise2.csv", csv_verbosity="concise"
)
fig = Visualizer.plot_from_csv(path, save_path=None, show_components=True)
captured = capsys.readouterr()
assert "Warning" in captured.out
# Simple spectra view: one axis, 3 branch curves, no total curve.
assert len(fig.axes) == 1
assert len(fig.axes[0].lines) == 3
class TestSingleModeColumnCleanup:
"""Single-mode CSVs carry the factorized components once, unprefixed;
multi-mode CSVs carry them per branch; the visualizer reads whichever
naming the file actually uses."""
def test_single_mode_full_export_has_unprefixed_components_only(
self, tmp_path: Any
) -> None:
config = make_single_config(use_radiative=True)
spectrum = BetaSpectrum.from_config(config)
analyzer = BetaSpectrumAnalyzer(spectrum, config)
path, df = export_and_read(analyzer, tmp_path, "single_components.csv")
assert "PhaseSpace" in df.columns
assert "Radiative" in df.columns
assert "Fermi" in df.columns
assert "branch_1_spectrum" in df.columns
assert "branch_1_PhaseSpace" not in df.columns
assert "branch_1_Radiative" not in df.columns
def test_multi_mode_full_export_has_prefixed_core_factors(
self, tmp_path: Any
) -> None:
config = make_multi_config(use_radiative=True)
spectrum = BetaSpectrum.from_config(config)
analyzer = BetaSpectrumAnalyzer(spectrum, config)
path, df = export_and_read(analyzer, tmp_path, "multi_components.csv")
assert "branch_1_PhaseSpace" in df.columns
assert "branch_2_Radiative" in df.columns
assert "Fermi" in df.columns
# The bare core-factor names belong to single mode only.
assert "PhaseSpace" not in df.columns
assert "Radiative" not in df.columns
def test_debug_view_single_csv_includes_core_panels(self, tmp_path: Any) -> None:
config = make_single_config(use_radiative=True)
spectrum = BetaSpectrum.from_config(config)
analyzer = BetaSpectrumAnalyzer(spectrum, config)
path, _df = export_and_read(analyzer, tmp_path, "single_plot.csv")
fig = Visualizer.plot_from_csv(path, None, show_components=True)
assert len(fig.axes) == 4
# Panel 2: Fermi + the single branch's phase space.
assert len(fig.axes[1].lines) == 2
labels = [ln.get_label() for ln in fig.axes[1].lines]
assert "Fermi" in labels
assert any("PS Br" in str(lbl) for lbl in labels)
# Panel 4: the radiative correction for the single branch.
assert len(fig.axes[3].lines) == 1
def test_ylabel_uses_per_MeV_units(self, tmp_path: Any) -> None:
config = make_multi_config()
spectrum = BetaSpectrum.from_config(config)
analyzer = BetaSpectrumAnalyzer(spectrum, config)
path, _df = export_and_read(analyzer, tmp_path, "ylabel.csv")
fig = Visualizer.plot_from_csv(path, None, show_components=True)
assert "MeV$^{-1}$" in fig.axes[0].get_ylabel()
-334
View File
@@ -1,334 +0,0 @@
"""
Physics tests for DetectorResponse — unique physical behavior.
Tests detector response shapes, resolution, and convolution physics.
"""
import numpy as np
import pytest
try:
from beta_spectrum.components.detector_response import DetectorResponse
except ImportError:
DetectorResponse = None # type: ignore[misc, assignment]
@pytest.mark.skipif(
DetectorResponse is None, reason="DetectorResponse not yet implemented"
)
class TestAnalyticalGaussianResponse:
"""Test pure Gaussian detector response."""
def test_gaussian_peak_position(self):
"""Gaussian response should peak at the input energy."""
channel_energies = np.linspace(0.0, 5.0, 4096)
resp = DetectorResponse(channel_energies, model="gaussian", sigma_a=0.005)
energy = 2.0
response = resp.get_response(energy)
peak_idx = np.argmax(response)
assert np.isclose(
channel_energies[peak_idx], energy, atol=0.01
), "Gaussian peak should be at input energy"
def test_gaussian_normalization(self):
"""Gaussian response should integrate to 1 (unit area)."""
channel_energies = np.linspace(0.0, 5.0, 8192)
resp = DetectorResponse(channel_energies, model="gaussian", sigma_a=0.005)
energy = 2.0
response = resp.get_response(energy)
integral = np.trapezoid(response, channel_energies)
assert np.isclose(
integral, 1.0, rtol=1e-2
), f"Gaussian should normalize to 1, got {integral}"
def test_gaussian_sigma_dependence(self):
"""Larger sigma_a should produce wider Gaussian."""
channel_energies = np.linspace(0.0, 5.0, 4096)
resp_narrow = DetectorResponse(
channel_energies, model="gaussian", sigma_a=0.002
)
resp_wide = DetectorResponse(channel_energies, model="gaussian", sigma_a=0.02)
energy = 2.0
resp_n = resp_narrow.get_response(energy)
resp_w = resp_wide.get_response(energy)
half_max_n = np.max(resp_n) / 2
half_max_w = np.max(resp_w) / 2
fwhm_n = np.sum(resp_n > half_max_n)
fwhm_w = np.sum(resp_w > half_max_w)
assert fwhm_w > fwhm_n, "Larger sigma should produce wider peak"
def test_delta_limit(self):
"""Zero sigma should produce delta function at peak channel."""
channel_energies = np.linspace(0.0, 5.0, 4096)
resp = DetectorResponse(
channel_energies, model="gaussian", sigma_a=0.0, fano_factor=0.0
)
energy = 2.0
response = resp.get_response(energy)
assert (
np.sum(response > 0) == 1
), "Delta function should have single non-zero element"
assert np.isclose(response[np.argmax(response)], 1.0)
class TestAnalyticalGaussianTailResponse:
"""Test Gaussian + exponential tail detector response."""
def test_tail_increases_low_energy_counts(self):
"""Adding tail should produce non-zero tail contribution below peak."""
channel_energies = np.linspace(0.0, 5.0, 4096)
resp_with_tail = DetectorResponse(
channel_energies,
model="gaussian_tail",
sigma_a=0.005,
tail_fraction=0.3,
tau=0.01,
)
energy = 2.0
response = resp_with_tail.get_response(energy)
below_peak = channel_energies < energy
assert (
np.sum(response[below_peak]) > 0
), "Response below peak should be non-zero with tail enabled"
peak_idx = np.argmax(response)
assert peak_idx > 0, "Peak should not be at first channel"
assert (
response[peak_idx] >= response[peak_idx - 1]
), "Response at peak should be >= response just below peak"
def test_tail_fraction_effect(self):
"""Higher tail_fraction should produce more tail contribution."""
channel_energies = np.linspace(0.0, 5.0, 4096)
resp_low = DetectorResponse(
channel_energies,
model="gaussian_tail",
sigma_a=0.005,
tail_fraction=0.1,
tau=0.01,
)
resp_high = DetectorResponse(
channel_energies,
model="gaussian_tail",
sigma_a=0.005,
tail_fraction=0.5,
tau=0.01,
)
resp_gaussian_only = DetectorResponse(
channel_energies, model="gaussian", sigma_a=0.005
)
energy = 2.0
resp_l = resp_low.get_response(energy)
resp_h = resp_high.get_response(energy)
resp_g = resp_gaussian_only.get_response(energy)
below_peak = channel_energies < energy
tail_low = resp_l[below_peak] - (1.0 - 0.1) * resp_g[below_peak]
tail_high = resp_h[below_peak] - (1.0 - 0.5) * resp_g[below_peak]
assert np.sum(tail_low) > 0, "Low tail_fraction should have positive tail"
assert np.sum(tail_high) > 0, "High tail_fraction should have positive tail"
ratio = np.sum(tail_high) / np.sum(tail_low)
assert ratio > 1.0, (
f"Higher tail_fraction should have more tail contribution, "
f"ratio={ratio:.2f}"
)
def test_tail_normalization(self):
"""Gaussian+tail response should still integrate to 1."""
channel_energies = np.linspace(0.0, 5.0, 8192)
resp = DetectorResponse(
channel_energies,
model="gaussian_tail",
sigma_a=0.005,
tail_fraction=0.2,
tau=0.01,
)
energy = 2.0
response = resp.get_response(energy)
integral = np.trapezoid(response, channel_energies)
assert np.isclose(
integral, 1.0, rtol=1e-2
), f"Gaussian+tail should normalize to 1, got {integral}"
class TestResolutionSigma:
"""Test energy-dependent resolution sigma(E)."""
def test_sigma_increases_with_energy(self):
"""σ(E) should increase with energy for sigma_b > 0."""
channel_energies = np.linspace(0.0, 5.0, 100)
resp = DetectorResponse(
channel_energies, model="gaussian", sigma_a=0.0, sigma_b=0.01
)
sigma_low = resp._resolution_sigma(0.5)
sigma_high = resp._resolution_sigma(4.0)
assert sigma_high > sigma_low, "σ should increase with √E"
def test_sigma_a_only(self):
"""With sigma_b=0, σ should be constant."""
channel_energies = np.linspace(0.0, 5.0, 100)
resp = DetectorResponse(
channel_energies,
model="gaussian",
sigma_a=0.01,
sigma_b=0.0,
fano_factor=0.0,
)
sigma_low = resp._resolution_sigma(0.5)
sigma_high = resp._resolution_sigma(4.0)
assert np.isclose(sigma_low, sigma_high), "σ should be constant when sigma_b=0"
def test_fano_contribution(self):
"""Fano factor should add energy-dependent contribution to σ."""
channel_energies = np.linspace(0.0, 5.0, 100)
resp_no_fano = DetectorResponse(
channel_energies,
model="gaussian",
sigma_a=0.0,
sigma_b=0.0,
fano_factor=0.0,
)
resp_fano = DetectorResponse(
channel_energies,
model="gaussian",
sigma_a=0.0,
sigma_b=0.0,
fano_factor=0.12,
)
sigma_low = resp_no_fano._resolution_sigma(0.5)
sigma_fano = resp_fano._resolution_sigma(0.5)
assert sigma_fano > sigma_low, "Fano factor should increase σ"
class TestTabulatedResponse:
"""Test tabulated (MC-simulated) detector response."""
def test_tabulated_interpolation(self):
"""Tabulated response should interpolate between calibration energies."""
channel_energies = np.linspace(0.0, 5.0, 256)
calib_energies = np.array([1.0, 2.0, 3.0, 4.0])
n_channels = len(channel_energies)
response_matrix = np.zeros((len(calib_energies), n_channels))
for i, e in enumerate(calib_energies):
idx = np.argmin(np.abs(channel_energies - e))
response_matrix[i, idx] = 1.0
resp = DetectorResponse(
channel_energies=channel_energies,
response_matrix=response_matrix,
calibration_energies=calib_energies,
)
response = resp.get_response(2.5)
assert np.any(response > 0), "Interpolated response should be non-zero"
def test_tabulated_outside_range(self):
"""Response outside calibration range should be zero."""
channel_energies = np.linspace(0.0, 5.0, 256)
calib_energies = np.array([1.0, 2.0, 3.0])
response_matrix = np.zeros((3, len(channel_energies)))
for i, e in enumerate(calib_energies):
idx = np.argmin(np.abs(channel_energies - e))
response_matrix[i, idx] = 1.0
resp = DetectorResponse(
channel_energies=channel_energies,
response_matrix=response_matrix,
calibration_energies=calib_energies,
)
response = resp.get_response(0.5)
assert np.all(response == 0), "Response below calibration range should be zero"
response = resp.get_response(4.0)
assert np.all(response == 0), "Response above calibration range should be zero"
class TestConvolution:
"""Test spectrum convolution with detector response."""
def test_convolution_preserves_total_counts(self):
"""Total counts should be approximately preserved after convolution."""
channel_energies = np.linspace(0.0, 5.0, 4096)
resp = DetectorResponse(channel_energies, model="gaussian", sigma_a=0.005)
W = np.linspace(0.0, 5.0, 1000)
spectrum = np.zeros_like(W)
idx = np.argmin(np.abs(W - 2.5))
spectrum[idx] = 1.0 / (W[1] - W[0])
convolved = resp.convolve(W, spectrum, normalize=True)
total_theoretical = np.trapezoid(spectrum, W)
total_convolved = np.trapezoid(convolved, channel_energies)
assert np.isclose(
total_convolved, total_theoretical, rtol=0.05
), f"Total counts: theory={total_theoretical:.4f}, convolved={total_convolved:.4f}"
def test_convolution_widens_peak(self):
"""Convolution should widen a narrow peak."""
channel_energies = np.linspace(0.0, 5.0, 4096)
resp = DetectorResponse(channel_energies, model="gaussian", sigma_a=0.02)
W = np.linspace(0.0, 5.0, 2000)
spectrum = np.zeros_like(W)
idx = np.argmin(np.abs(W - 2.5))
spectrum[idx] = 1.0 / (W[1] - W[0])
convolved = resp.convolve(W, spectrum, normalize=True)
peak_width = np.sum(convolved > np.max(convolved) / 2)
assert peak_width > 1, "Convolved peak should have finite width"
def test_convolution_beta_spectrum(self):
"""Convolution of a beta spectrum should produce a smooth curve."""
channel_energies = np.linspace(0.0, 5.0, 4096)
resp = DetectorResponse(channel_energies, model="gaussian", sigma_a=0.01)
W0 = 5.0
W = np.linspace(1.0, W0 - 0.01, 500)
p = np.sqrt(W**2 - 1)
spectrum = p * W * (W0 - W) ** 2
convolved = resp.convolve(W, spectrum, normalize=True)
assert len(convolved) == len(channel_energies)
assert np.all(convolved >= 0), "Convolved spectrum should be non-negative"
assert np.max(convolved) > 0, "Convolved spectrum should have non-zero peak"
def test_convolve_batch_same_as_convolve(self):
"""Batch convolution should match standard convolution for analytical models."""
channel_energies = np.linspace(0.0, 5.0, 4096)
resp = DetectorResponse(
channel_energies,
model="gaussian_tail",
sigma_a=0.01,
tail_fraction=0.1,
tau=0.01,
)
W = np.linspace(1.0, 4.9, 300)
p = np.sqrt(W**2 - 1)
spectrum = p * W * (4.9 - W) ** 2
result_standard = resp.convolve(W, spectrum, normalize=True)
result_batch = resp.convolve_batch(W, spectrum, normalize=True)
assert np.allclose(
result_standard, result_batch, rtol=1e-5
), "Batch and standard convolution should match for analytical models"
+31 -51
View File
@@ -21,8 +21,7 @@ We test:
6. **Component extraction**: universal + per-branch components correctly separated. 6. **Component extraction**: universal + per-branch components correctly separated.
7. **CSV export**: multi-branch CSV has correct column structure. 7. **CSV export**: multi-branch CSV has correct column structure.
8. **Energy grid**: uses max endpoint across all branches. 8. **Energy grid**: uses max endpoint across all branches.
9. **Detector response**: multi-branch spectra are convolvable. 9. **BranchConfig dataclass**: basic construction and validation.
10. **BranchConfig dataclass**: basic construction and validation.
""" """
import numpy as np import numpy as np
@@ -249,8 +248,8 @@ class TestMultiBranchSpectrum:
Z_daughter=44, Z_daughter=44,
A_number=99, A_number=99,
endpoint_MeV=0.350, endpoint_MeV=0.350,
# Disable universal components to simplify # Disable higher-order universal corrections to simplify:
use_fermi=False, # the universal factor reduces to the core Fermi function.
use_screening=False, use_screening=False,
use_finite_size=False, use_finite_size=False,
use_charge_dist=False, use_charge_dist=False,
@@ -266,8 +265,11 @@ class TestMultiBranchSpectrum:
weighted = spectrum.get_branch_normalized_spectra(W) weighted = spectrum.get_branch_normalized_spectra(W)
assert len(weighted) == 2 assert len(weighted) == 2
# With no universal components, total = sum of weighted branch spectra # Universal factor is the core Fermi function only (corrections off):
total_from_branches = weighted[0] + weighted[1] # total = fermi × sum(weighted branch spectra)
components = spectrum.calculate_components(W)
fermi = components["Fermi"]
total_from_branches = fermi * (weighted[0] + weighted[1])
total = spectrum(W) total = spectrum(W)
assert np.allclose(total, total_from_branches, rtol=1e-10) assert np.allclose(total, total_from_branches, rtol=1e-10)
@@ -278,8 +280,6 @@ class TestMultiBranchSpectrum:
Z_daughter=44, Z_daughter=44,
A_number=99, A_number=99,
endpoint_MeV=0.350, endpoint_MeV=0.350,
use_phase_space=True,
use_fermi=False,
use_screening=False, use_screening=False,
use_finite_size=False, use_finite_size=False,
use_charge_dist=False, use_charge_dist=False,
@@ -293,13 +293,16 @@ class TestMultiBranchSpectrum:
spectrum = BetaSpectrum.from_config(config) spectrum = BetaSpectrum.from_config(config)
W, _ = spectrum.get_energy_grid(config) W, _ = spectrum.get_energy_grid(config)
# Both branches have same endpoint and transition type, # Both branches have same endpoint and transition type, so their
# so phase space should be identical. Total = 0.8*PS + 0.2*PS = PS # per-branch cores are identical: sum = 0.8*core + 0.2*core = core.
# Total = universal (Fermi) × sum.
total = spectrum(W) total = spectrum(W)
branch0 = spectrum.branch_spectra[0](W) branch0 = spectrum.branch_spectra[0](W)
branch1 = spectrum.branch_spectra[1](W) branch1 = spectrum.branch_spectra[1](W)
components = spectrum.calculate_components(W)
fermi = components["Fermi"]
expected = 0.8 * branch0 + 0.2 * branch1 expected = fermi * (0.8 * branch0 + 0.2 * branch1)
assert np.allclose(total, expected, rtol=1e-10) assert np.allclose(total, expected, rtol=1e-10)
@@ -371,8 +374,8 @@ class TestMultiBranchComponents:
assert "Fermi" in components assert "Fermi" in components
assert "branch_0.PhaseSpace" in components assert "branch_0.PhaseSpace" in components
def test_analyzer_branch_spectra_property(self): def test_analyzer_physical_branch_spectra(self):
"""Analyzer should expose branch spectra property.""" """Analyzer physical branch spectra are core × corrections arrays."""
config = SpectrumConfig( config = SpectrumConfig(
Z_parent=43, Z_parent=43,
Z_daughter=44, Z_daughter=44,
@@ -386,8 +389,11 @@ class TestMultiBranchComponents:
spectrum = BetaSpectrum.from_config(config) spectrum = BetaSpectrum.from_config(config)
analyzer = BetaSpectrumAnalyzer(spectrum, config) analyzer = BetaSpectrumAnalyzer(spectrum, config)
branch_spectra = analyzer.branch_spectra physical = [
assert len(branch_spectra) == 2 analyzer.physical_branch_spectrum(i) for i in range(analyzer._n_branches)
]
assert len(physical) == 2
assert physical[0].shape == analyzer.W.shape
def test_total_equals_universal_times_branch_sum(self): def test_total_equals_universal_times_branch_sum(self):
"""Total = universal_components * sum(intensity * branch_spectra).""" """Total = universal_components * sum(intensity * branch_spectra)."""
@@ -481,6 +487,13 @@ class TestMultiBranchCSVExport:
# Should have universal columns # Should have universal columns
assert "Fermi" in df.columns assert "Fermi" in df.columns
# Branch columns are physical spectra: their sum equals total_spectrum
total = df["total_spectrum"].to_numpy()
branch_sum = (
df["branch_1_spectrum"].to_numpy() + df["branch_2_spectrum"].to_numpy()
)
assert np.allclose(branch_sum, total, rtol=1e-3)
def test_single_branch_csv_columns(self, tmp_path): def test_single_branch_csv_columns(self, tmp_path):
"""Single-branch CSV should have standard columns.""" """Single-branch CSV should have standard columns."""
config = SpectrumConfig( config = SpectrumConfig(
@@ -582,42 +595,6 @@ class TestMultiBranchTransitionTypes:
assert np.all(values >= 0) assert np.all(values >= 0)
# ---------------------------------------------------------------------------
# Multi-branch with detector response
# ---------------------------------------------------------------------------
class TestMultiBranchDetectorResponse:
"""Test multi-branch with detector response convolution."""
def test_multi_branch_with_detector(self):
"""Multi-branch spectrum should be convolvable with detector response."""
config = SpectrumConfig(
Z_parent=43,
Z_daughter=44,
A_number=99,
endpoint_MeV=0.350,
use_detector_response=True,
detector_model="gaussian",
detector_sigma_a_keV=1.0,
detector_n_channels=256,
detector_fano_factor=0.0,
branches=[
BranchConfig(endpoint_MeV=0.294, transition_type="F2", intensity=0.7),
BranchConfig(endpoint_MeV=0.150, transition_type="A", intensity=0.3),
],
)
spectrum = BetaSpectrum.from_config(config)
W, _ = spectrum.get_energy_grid(config)
detector = BetaSpectrum.create_detector_from_config(config)
convolved = spectrum.convolve_with_detector(detector, W=W, config=config)
assert convolved.shape == (256,)
assert np.all(convolved >= 0), "Convolved spectrum should be non-negative"
assert np.sum(convolved) > 0, "Convolved spectrum should have non-zero area"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Edge cases # Edge cases
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -705,6 +682,7 @@ class TestMultiBranchEdgeCases:
), ),
], ],
) )
assert config.branches is not None
spectrum = BetaSpectrum.from_config(config) spectrum = BetaSpectrum.from_config(config)
W, energies = spectrum.get_energy_grid(config) W, energies = spectrum.get_energy_grid(config)
@@ -748,6 +726,7 @@ class TestMultiBranchEdgeCases:
BranchConfig(endpoint_MeV=0.3, transition_type="F1", intensity=0.2), BranchConfig(endpoint_MeV=0.3, transition_type="F1", intensity=0.2),
], ],
) )
assert config.branches is not None
spectrum = BetaSpectrum.from_config(config) spectrum = BetaSpectrum.from_config(config)
W, energies = spectrum.get_energy_grid(config) W, energies = spectrum.get_energy_grid(config)
@@ -775,6 +754,7 @@ class TestMultiBranchEdgeCases:
# Each branch integral should be proportional to its intensity # Each branch integral should be proportional to its intensity
# (within numerical tolerance, since different endpoints give different # (within numerical tolerance, since different endpoints give different
# phase space integrals) # phase space integrals)
assert config.branches is not None
for i, (integral, intensity) in enumerate(zip(integrals, config.branches)): for i, (integral, intensity) in enumerate(zip(integrals, config.branches)):
assert integral > 0, f"Branch {i} has zero integral" assert integral > 0, f"Branch {i} has zero integral"
+7 -2
View File
@@ -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")
-136
View File
@@ -1,136 +0,0 @@
"""
API / quality tests for DetectorResponse.
Tests factory methods, repr, error handling.
"""
import numpy as np
import pytest
try:
from beta_spectrum.components.detector_response import DetectorResponse
except ImportError:
DetectorResponse = None # type: ignore[misc, assignment]
@pytest.mark.skipif(
DetectorResponse is None, reason="DetectorResponse not yet implemented"
)
class TestFactoryMethods:
"""Test convenience factory methods."""
def test_from_gaussian_params(self):
"""from_gaussian_params should create correct DetectorResponse."""
resp = DetectorResponse.from_gaussian_params(
channel_energy_range=(0.0, 5.0),
n_channels=1024,
sigma_a=0.005,
sigma_b=0.01,
tail_fraction=0.2,
tau=0.01,
model="gaussian_tail",
fano_factor=0.12,
)
assert resp.n_channels == 1024
assert np.isclose(resp.channel_energies[0], 0.0)
assert np.isclose(resp.channel_energies[-1], 5.0)
assert resp._mode == "analytical"
assert resp.tail_fraction == 0.2
def test_from_mc_simulation(self):
"""from_mc_simulation should create tabulated DetectorResponse."""
channel_energies = np.linspace(0.0, 5.0, 512)
calib_energies = np.array([1.0, 2.0, 3.0])
response_matrix = np.zeros((3, 512))
for i, e in enumerate(calib_energies):
idx = np.argmin(np.abs(channel_energies - e))
response_matrix[i, idx] = 1.0
resp = DetectorResponse.from_mc_simulation(
channel_energies=channel_energies,
response_matrix=response_matrix,
calibration_energies=calib_energies,
)
assert resp._mode == "tabulated"
assert resp.n_channels == 512
assert len(resp.calibration_energies) == 3
@pytest.mark.skipif(
DetectorResponse is None, reason="DetectorResponse not yet implemented"
)
class TestEdgeCases:
"""Test edge cases and error handling."""
def test_repr_analytical(self):
"""__repr__ should show analytical mode info."""
channel_energies = np.linspace(0.0, 5.0, 100)
resp = DetectorResponse(channel_energies, model="gaussian", sigma_a=0.01)
repr_str = repr(resp)
assert "analytical" in repr_str
assert "gaussian" in repr_str
def test_repr_tabulated(self):
"""__repr__ should show tabulated mode info."""
channel_energies = np.linspace(0.0, 5.0, 100)
calib_energies = np.array([1.0, 2.0])
response_matrix = np.zeros((2, 100))
resp = DetectorResponse(
channel_energies=channel_energies,
response_matrix=response_matrix,
calibration_energies=calib_energies,
)
repr_str = repr(resp)
assert "tabulated" in repr_str
def test_invalid_model(self):
"""Should reject unknown model names."""
channel_energies = np.linspace(0.0, 5.0, 100)
resp = DetectorResponse(channel_energies, model="unknown_model")
try:
resp.get_response(2.0)
assert False, "Should have raised ValueError"
except ValueError as e:
assert "Unknown model" in str(e)
def test_convolve_mismatched_lengths(self):
"""Should reject mismatched W and spectrum_values lengths."""
channel_energies = np.linspace(0.0, 5.0, 100)
resp = DetectorResponse(channel_energies, model="gaussian")
W = np.linspace(0.0, 5.0, 100)
spectrum = np.zeros(50)
try:
resp.convolve(W, spectrum)
assert False, "Should have raised ValueError"
except ValueError:
pass
def test_non_1d_input(self):
"""Should reject non-1D inputs."""
channel_energies = np.linspace(0.0, 5.0, 100)
resp = DetectorResponse(channel_energies, model="gaussian")
W = np.array([[0.0, 5.0]])
spectrum = np.array([[1.0, 1.0]])
try:
resp.convolve(W, spectrum)
assert False, "Should have raised ValueError"
except ValueError:
pass
def test_tabulated_validation_wrong_columns(self):
"""Should reject response matrix with wrong column count."""
channel_energies = np.linspace(0.0, 5.0, 100)
calib_energies = np.array([1.0, 2.0])
response_matrix = np.zeros((2, 200))
try:
DetectorResponse(
channel_energies=channel_energies,
response_matrix=response_matrix,
calibration_energies=calib_energies,
)
assert False, "Should have raised ValueError"
except ValueError:
pass
+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):
+6 -2
View File
@@ -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)"
+92 -4
View File
@@ -5,6 +5,7 @@ import tempfile
from pathlib import Path from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
import numpy as np
import pytest import pytest
# ruff: noqa: E402, ANN201, ANN001 # ruff: noqa: E402, ANN201, ANN001
@@ -19,7 +20,7 @@ from beta_spectrum.nuclear_data import ( # noqa: E402
validate_json_input, validate_json_input,
_parse_nuclide_symbol, _parse_nuclide_symbol,
) )
from beta_spectrum.spectrum import SpectrumConfig from beta_spectrum.spectrum import BetaSpectrum, SpectrumConfig
def _has_paceENSDF() -> bool: # noqa: ANN201 def _has_paceENSDF() -> bool: # noqa: ANN201
@@ -160,9 +161,12 @@ class TestJsonInput:
path = self._write_json(data) path = self._write_json(data)
try: try:
loaded = load_json_input(str(path)) loaded = load_json_input(str(path))
# Defaults should be filled in # Defaults should be filled in. The detector_* schema keys were
assert loaded["detector_model"] == "gaussian" # removed together with the detector_response module; JSON
assert loaded["detector_sigma_a_keV"] == 1.0 # defaults carry spectrum physics only.
assert "detector_model" not in loaded
assert loaded["neutrino_mass_MeV"] == 0.0
assert loaded["transition_type"] == "F2"
finally: finally:
path.unlink() path.unlink()
@@ -241,12 +245,96 @@ class TestDecayInfoToConfig:
assert len(config.branches) == 1 assert len(config.branches) == 1
assert config.branches[0].intensity == 0.5 assert config.branches[0].intensity == 0.5
def test_cutoff_single_survivor_endpoint_follows_branch(self):
"""A lone surviving branch must define the spectrum, not the stale
top-level endpoint inherited from the parent decay (D1)."""
info = DecayInfo(
parent_symbol="Cs137",
Z_parent=55,
Z_daughter=56,
A_number=137,
endpoint_keV=1176.0,
endpoint_MeV=1.176,
transition_type="F2",
forbiddenness_code="2F",
parent_J=7.0,
parent_pi=1,
half_life="y",
branches=[
BranchInfo(
level_index=0,
level_energy_keV=0.0,
intensity=0.053,
log_ft=5.0,
transition_type="F2",
),
BranchInfo(
level_index=1,
level_energy_keV=661.66,
intensity=0.947,
log_ft=5.0,
transition_type="A",
),
],
)
cfg = decay_info_to_config(info, intensity_cutoff=0.9)
assert cfg.branches is not None
assert len(cfg.branches) == 1
lone_ep = cfg.branches[0].endpoint_MeV
assert lone_ep == pytest.approx(1.176 - 0.66166)
spectrum = BetaSpectrum.from_config(cfg)
assert not spectrum._is_multi
W, E = spectrum.get_energy_grid(cfg)
# Grid capped at the surviving branch endpoint, not 1.176 MeV.
assert float(E.max()) < lone_ep + 1e-12
values = spectrum(W)
assert np.all(np.isfinite(values))
assert np.all(values >= 0.0)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# get_decay_info_from_paceENSDF # get_decay_info_from_paceENSDF
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.mark.slow
@pytest.mark.requires_ensdf
@pytest.mark.skipif(not _has_paceENSDF(), reason="paceENSDF not installed")
class TestPaceENSDFBranchEndpoints:
"""Per-branch endpoints are the measured transition endpoint energies
(ENSDF column 7), not base-endpoint minus level energy subtractions
with a base taken from an arbitrary first branch (D3)."""
def _branch_endpoints(self, nuclide: str) -> list[float]:
info = get_decay_info_from_paceENSDF(nuclide, "beta_minus")
cfg = decay_info_to_config(info)
assert cfg.branches is not None
return sorted(
(b.endpoint_MeV for b in cfg.branches),
reverse=True,
)
def test_co60_branch_endpoints_positive(self) -> None:
# Measured endpoints: 1492.0, 670.0, 317.88 keV (levels above the
# first branch's endpoint previously produced negative values and
# a hard ValueError from BranchConfig validation).
eps = self._branch_endpoints("Co60")
assert eps == pytest.approx([1.492, 0.670, 0.31788], abs=1e-6)
assert all(e > 0 for e in eps)
def test_tc99_branch_endpoints(self) -> None:
eps = self._branch_endpoints("Tc99")
assert eps == pytest.approx([0.2975, 0.2080], abs=1e-6)
def test_cs137_branch_endpoints(self) -> None:
eps = self._branch_endpoints("Cs137")
assert eps == pytest.approx([1.176, 0.89213, 0.51403], abs=1e-6)
def test_overall_endpoint_is_max_branch_endpoint(self) -> None:
info = get_decay_info_from_paceENSDF("Co60", "beta_minus")
assert info.endpoint_MeV == pytest.approx(1.492, abs=1e-6)
@pytest.mark.requires_ensdf @pytest.mark.requires_ensdf
@pytest.mark.skipif(not _has_paceENSDF(), reason="paceENSDF not installed") @pytest.mark.skipif(not _has_paceENSDF(), reason="paceENSDF not installed")
class TestPaceENSDFIntegration: class TestPaceENSDFIntegration: