Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67de4e3baa | ||
|
|
2788ea0683 | ||
|
|
7f47a876ae | ||
|
|
7e31ac4b4e | ||
|
|
36174b5d25 | ||
|
|
3590ca91a1 | ||
|
|
9c3dd80b5c | ||
|
|
d166532779 | ||
|
|
18a46ad02f | ||
|
|
62bf7fdac2 | ||
|
|
ee7aedc80a | ||
|
|
6bb7b950c7 | ||
|
|
ea4b652ccb | ||
|
|
40d0836056 | ||
|
|
f5b79000a4 | ||
|
|
dc350aacfc | ||
|
|
8172ba2541 | ||
|
|
7dc2ec79d1 | ||
|
|
afb7afc5b7 | ||
|
|
bab19de458 | ||
|
|
8bc9b0fd87 | ||
|
|
687e05cbe2 |
@@ -180,3 +180,6 @@ htmlcov/
|
|||||||
.rpiv/
|
.rpiv/
|
||||||
.pi-lens/
|
.pi-lens/
|
||||||
data/raw/
|
data/raw/
|
||||||
|
|
||||||
|
# ROOT data files produced by notebooks
|
||||||
|
*.root
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -52,4 +52,4 @@ __all__ = [
|
|||||||
"DEFAULT_JSON_SCHEMA",
|
"DEFAULT_JSON_SCHEMA",
|
||||||
]
|
]
|
||||||
|
|
||||||
__version__ = "0.4.0"
|
__version__ = "0.4.1"
|
||||||
|
|||||||
+86
-83
@@ -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")
|
config = create_config_from_source(
|
||||||
|
"paceENSDF",
|
||||||
# Normalize branch intensities to sum to 1.0
|
nuclide=source_value,
|
||||||
if decay_info.branches:
|
e_step_MeV=e_step_MeV,
|
||||||
total_intensity = sum(b.intensity for b in decay_info.branches)
|
intensity_cutoff=intensity_cutoff,
|
||||||
if total_intensity > 0:
|
neutrino_mass_MeV=neutrino_mass_MeV,
|
||||||
branches = [
|
csv_separator=csv_separator,
|
||||||
BranchConfig(
|
separate_branches=separate_branches,
|
||||||
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(
|
|
||||||
"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:
|
|
||||||
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,13 +318,24 @@ 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)
|
||||||
analyzer.plot_analysis(
|
if args.output and args.csv_verbosity == "full":
|
||||||
save_path=args.plot,
|
from beta_spectrum.output.visualization import Visualizer
|
||||||
show_components=(args.verbose >= 2),
|
|
||||||
)
|
Visualizer.plot_from_csv(
|
||||||
|
args.output,
|
||||||
|
save_path=args.plot,
|
||||||
|
show_components=(args.verbose >= 2),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
analyzer.plot_analysis(
|
||||||
|
save_path=args.plot,
|
||||||
|
show_components=(args.verbose >= 2),
|
||||||
|
)
|
||||||
logger.info("Analysis plot saved to %s", args.plot)
|
logger.info("Analysis plot saved to %s", args.plot)
|
||||||
|
|
||||||
logger.info("Done.")
|
logger.info("Done.")
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
data = json.load(f)
|
with open(path, "r", encoding="utf-8") as 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:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
if prod is None:
|
||||||
|
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)"
|
||||||
)
|
)
|
||||||
assert self._branch_spectra_cache is not None
|
return integral
|
||||||
return self._branch_spectra_cache
|
|
||||||
|
|
||||||
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 = [
|
||||||
|
|||||||
+137
-114
@@ -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._is_multi:
|
||||||
|
for i in range(n_branches):
|
||||||
|
for comp_name in ["PhaseSpace", "Radiative"]:
|
||||||
|
key = analyzer._get_component_key(i, comp_name)
|
||||||
|
if key in components:
|
||||||
|
data[f"branch_{i + 1}_{comp_name}"] = components[key]
|
||||||
|
|
||||||
if analyzer.config.normalize_to_one:
|
# Universal components (no prefix): Fermi plus universal
|
||||||
# Normalize to 1, ignoring intensity
|
# higher-order corrections. In single mode the core factors
|
||||||
sum_spectrum = np.sum(branch_spectrum)
|
# (PhaseSpace, Radiative) also appear here unprefixed.
|
||||||
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"]:
|
|
||||||
key = analyzer._get_component_key(i, comp_name)
|
|
||||||
if key in components:
|
|
||||||
data[f"branch_{i + 1}_{comp_name}"] = components[key]
|
|
||||||
|
|
||||||
# Universal components (no prefix)
|
|
||||||
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"
|
with open(filename, "w") as f:
|
||||||
if analyzer.config.csv_separator == "tab"
|
f.write("\n".join(header_lines) + "\n")
|
||||||
else analyzer.config.csv_separator
|
df.to_csv(f, index=False, float_format="%.4e", sep=separator)
|
||||||
)
|
except OSError as exc:
|
||||||
df.to_csv(filename, index=False, float_format="%.4e", sep=separator)
|
raise ValueError(f"Cannot write spectrum CSV {filename}: {exc}") from exc
|
||||||
|
|
||||||
with open(filename, "r") as f:
|
|
||||||
content = f.read()
|
|
||||||
with open(filename, "w") as f:
|
|
||||||
f.write("\n".join(header_lines) + "\n" + content)
|
|
||||||
|
|
||||||
if analyzer._logger:
|
if analyzer._logger:
|
||||||
analyzer._logger.info("CSV exported to %s", filename)
|
analyzer._logger.info("CSV exported to %s", filename)
|
||||||
|
|||||||
@@ -1,99 +1,224 @@
|
|||||||
"""
|
"""
|
||||||
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(
|
|
||||||
analyzer: BetaSpectrumAnalyzer,
|
|
||||||
save_path: Optional[str] = None,
|
|
||||||
show_components: bool = True,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Create visualization of the spectrum and all correction factors.
|
|
||||||
|
|
||||||
Parameters
|
def parse_spectrum_csv(
|
||||||
----------
|
csv_path: str,
|
||||||
analyzer : BetaSpectrumAnalyzer
|
) -> Tuple[pd.DataFrame, Dict[str, Any]]:
|
||||||
The analyzer instance to visualize.
|
"""Read an exported spectrum CSV and parse its metadata header.
|
||||||
save_path : str, optional
|
|
||||||
Path to save the figure.
|
|
||||||
show_components : bool
|
|
||||||
If True, show full debug view.
|
|
||||||
If False, show only the spectrum plot.
|
|
||||||
"""
|
|
||||||
total = analyzer.total_spectrum(normalize=True)
|
|
||||||
components = analyzer.components
|
|
||||||
analyzer._timestamp = datetime.now(timezone.utc).strftime(
|
|
||||||
"%Y-%m-%d %H:%M:%S UTC"
|
|
||||||
)
|
|
||||||
|
|
||||||
if show_components:
|
Parameters
|
||||||
Visualizer._plot_debug_view(analyzer, total, components, save_path)
|
----------
|
||||||
|
csv_path : str
|
||||||
|
Path to a CSV file produced by CSVEmitter.export_to_csv.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
tuple[pandas.DataFrame, dict]
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
df = pd.read_csv(csv_path, sep=None, engine="python", comment="#")
|
||||||
|
raw_lines = []
|
||||||
|
with open(csv_path, "r") as f:
|
||||||
|
for line in f:
|
||||||
|
if line.startswith("#"):
|
||||||
|
raw_lines.append(line.rstrip("\n"))
|
||||||
|
else:
|
||||||
|
break # header block ended
|
||||||
|
except OSError as exc:
|
||||||
|
raise ValueError(f"Cannot read spectrum CSV {csv_path}: {exc}") from exc
|
||||||
|
|
||||||
|
meta: Dict[str, Any] = {
|
||||||
|
"version": None,
|
||||||
|
"build": None,
|
||||||
|
"timestamp": None,
|
||||||
|
"source": None,
|
||||||
|
"nuclide": None,
|
||||||
|
"endpoint_keV": None,
|
||||||
|
"neutrino_mass_MeV": None,
|
||||||
|
"corrections": [],
|
||||||
|
"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:
|
else:
|
||||||
Visualizer._plot_spectra_view(analyzer, total, components, save_path)
|
title += f" ({n_branches} branches)"
|
||||||
|
return title
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _build_figure_header(analyzer: BetaSpectrumAnalyzer, fig: "Figure") -> None:
|
def _apply_figure_header(fig: Figure, meta: Dict[str, Any]) -> None:
|
||||||
"""Add a compact header with nuclide info, e_step, commit and timestamp outside axes."""
|
"""Write the figure header/footer lines from the CSV metadata."""
|
||||||
parent = analyzer._element_symbol(analyzer.config.Z_parent)
|
e_step_keV = (meta["e_step_MeV"] or 0.0) * 1000.0
|
||||||
daughter = analyzer._element_symbol(analyzer.config.Z_daughter)
|
endpoint = meta["endpoint_keV"]
|
||||||
e_step_keV = analyzer.config.e_step_MeV * 1000
|
header = f"{meta['nuclide']} | E₀={endpoint:.1f} keV | ΔE={e_step_keV:.1f} keV"
|
||||||
header = (
|
fig.text(
|
||||||
f"{parent}{analyzer.config.A_number} -> {daughter}{analyzer.config.A_number} "
|
0.01,
|
||||||
f"| E₀={analyzer.config.endpoint_MeV * 1000:.1f} keV "
|
0.98,
|
||||||
f"| ΔE={e_step_keV:.1f} keV"
|
header,
|
||||||
)
|
transform=fig.transFigure,
|
||||||
|
fontsize=9,
|
||||||
|
verticalalignment="top",
|
||||||
|
horizontalalignment="left",
|
||||||
|
fontfamily="monospace",
|
||||||
|
)
|
||||||
|
|
||||||
|
if meta["neutrino_mass_MeV"]:
|
||||||
fig.text(
|
fig.text(
|
||||||
0.01,
|
0.01,
|
||||||
0.98,
|
0.96,
|
||||||
header,
|
f"| m_ν={meta['neutrino_mass_MeV']:.6f} MeV",
|
||||||
transform=fig.transFigure,
|
transform=fig.transFigure,
|
||||||
fontsize=9,
|
fontsize=8,
|
||||||
verticalalignment="top",
|
verticalalignment="top",
|
||||||
horizontalalignment="left",
|
horizontalalignment="left",
|
||||||
fontfamily="monospace",
|
fontfamily="monospace",
|
||||||
|
color="red",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add neutrino mass info to header if non-zero
|
if meta["build"]:
|
||||||
if analyzer.config.neutrino_mass_MeV > 0:
|
|
||||||
nu_mass_text = f"| m_ν={analyzer.config.neutrino_mass_MeV:.6f} MeV"
|
|
||||||
fig.text(
|
|
||||||
0.01,
|
|
||||||
0.96,
|
|
||||||
nu_mass_text,
|
|
||||||
transform=fig.transFigure,
|
|
||||||
fontsize=8,
|
|
||||||
verticalalignment="top",
|
|
||||||
horizontalalignment="left",
|
|
||||||
fontfamily="monospace",
|
|
||||||
color="red",
|
|
||||||
)
|
|
||||||
|
|
||||||
id_text = f"{get_git_short_hash(6)} | {analyzer._timestamp}"
|
|
||||||
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,343 +227,242 @@ 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)"
|
save_path: Optional[str],
|
||||||
return title
|
) -> Figure:
|
||||||
|
"""Plot total spectrum and per-branch spectra from CSV columns."""
|
||||||
|
energies = df["energy_MeV"].to_numpy()
|
||||||
|
|
||||||
@staticmethod
|
fig, ax = plt.subplots(figsize=(12, 7))
|
||||||
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],
|
|
||||||
) -> None:
|
|
||||||
"""Plot total spectrum with individual branch decomposition."""
|
|
||||||
fig, ax = plt.subplots(figsize=(12, 7))
|
|
||||||
|
|
||||||
branch_colors = [
|
|
||||||
"#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)
|
|
||||||
|
|
||||||
|
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.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(
|
|
||||||
analyzer.energies_MeV,
|
|
||||||
contrib,
|
|
||||||
color=color,
|
|
||||||
lw=1.5,
|
|
||||||
alpha=0.7,
|
|
||||||
label=Visualizer._format_branch_label(analyzer, i),
|
|
||||||
)
|
|
||||||
|
|
||||||
ax.set_xlabel(r"Electron kinetic energy $E$ [MeV]", fontsize=12)
|
|
||||||
ax.set_ylabel(f"Normalized Counts / {e_step_keV:.1f} keV", fontsize=12)
|
|
||||||
fig.suptitle(
|
|
||||||
Visualizer._build_figure_title(analyzer, fig),
|
|
||||||
fontsize=14,
|
|
||||||
fontweight="bold",
|
|
||||||
)
|
|
||||||
ax.set_yscale("log")
|
|
||||||
ax.grid(True, alpha=0.3)
|
|
||||||
ax.legend(
|
|
||||||
loc="best",
|
|
||||||
fontsize=9,
|
|
||||||
ncol=min(2, len(active_branch_indices) + 1),
|
|
||||||
)
|
|
||||||
|
|
||||||
Visualizer._build_figure_header(analyzer, fig)
|
|
||||||
|
|
||||||
plt.tight_layout()
|
|
||||||
|
|
||||||
if save_path:
|
|
||||||
fig.savefig(save_path, dpi=150, bbox_inches="tight")
|
|
||||||
print(f"Figure saved to {save_path}")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _plot_debug_view(
|
|
||||||
analyzer: BetaSpectrumAnalyzer,
|
|
||||||
total: np.ndarray,
|
|
||||||
components: Dict[str, np.ndarray],
|
|
||||||
save_path: Optional[str],
|
|
||||||
) -> None:
|
|
||||||
"""Debug view: vertical layout with 4 panels."""
|
|
||||||
n_branches = analyzer._n_branches
|
|
||||||
e_step_keV = analyzer.config.e_step_MeV * 1000
|
|
||||||
|
|
||||||
fig, axes = plt.subplots(4, 1, figsize=(12, 16), sharex=True)
|
|
||||||
fig.suptitle(
|
|
||||||
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)
|
|
||||||
|
|
||||||
ax = axes[0]
|
|
||||||
ax.plot(
|
ax.plot(
|
||||||
analyzer.energies_MeV,
|
energies,
|
||||||
total_raw,
|
df[col].to_numpy(),
|
||||||
|
color=_BRANCH_COLORS[color_i % len(_BRANCH_COLORS)],
|
||||||
|
lw=1.5,
|
||||||
|
alpha=0.7,
|
||||||
|
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_ylabel(r"Normalized spectrum [MeV$^{-1}$]", fontsize=12)
|
||||||
|
fig.suptitle(_spectrum_title(meta), fontsize=14, fontweight="bold")
|
||||||
|
ax.set_yscale("log")
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
ax.legend(loc="best", fontsize=9, ncol=2)
|
||||||
|
|
||||||
|
_apply_figure_header(fig, meta)
|
||||||
|
fig.tight_layout()
|
||||||
|
|
||||||
|
if save_path:
|
||||||
|
fig.savefig(save_path, dpi=150, bbox_inches="tight")
|
||||||
|
print(f"Figure saved to {save_path}")
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
def _plot_debug_view_from_df(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
meta: Dict[str, Any],
|
||||||
|
save_path: Optional[str],
|
||||||
|
) -> Figure:
|
||||||
|
"""Debug view (4 panels) built entirely from CSV columns."""
|
||||||
|
energies = df["energy_MeV"].to_numpy()
|
||||||
|
active = _active_branches(meta)
|
||||||
|
|
||||||
|
fig, axes = plt.subplots(4, 1, figsize=(12, 16), sharex=True)
|
||||||
|
fig.suptitle(_spectrum_title(meta), fontsize=14, fontweight="bold")
|
||||||
|
|
||||||
|
# Panel 1: total spectrum + per-branch spectra
|
||||||
|
ax = axes[0]
|
||||||
|
if "total_spectrum" in df.columns:
|
||||||
|
ax.plot(
|
||||||
|
energies,
|
||||||
|
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):
|
||||||
|
col = _branch_column(df, int(b["index"]))
|
||||||
|
if col is None:
|
||||||
|
continue
|
||||||
|
ax.plot(
|
||||||
|
energies,
|
||||||
|
df[col].to_numpy(),
|
||||||
|
color=_BRANCH_COLORS[color_i % len(_BRANCH_COLORS)],
|
||||||
|
lw=1.5,
|
||||||
|
alpha=0.7,
|
||||||
|
label=f"Br. {b['index']} ({b['intensity'] * 100:.1f}%)",
|
||||||
|
)
|
||||||
|
ax.set_yscale("log")
|
||||||
|
ax.set_ylabel(r"Normalized spectrum [MeV$^{-1}$]", fontsize=10)
|
||||||
|
ax.set_title("Spectrum", fontsize=11)
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
ax.legend(fontsize=8, loc="best", ncol=2)
|
||||||
|
|
||||||
# Only plot active branches (not excluded by neutrino mass or intensity cutoff)
|
# Panel 2: Fermi function + all phase spaces
|
||||||
active_branch_indices = []
|
ax = axes[1]
|
||||||
for i in range(n_branches):
|
if "Fermi" in df.columns:
|
||||||
branch_endpoint_keV = analyzer._unified_branches[i].endpoint_MeV * 1000
|
fermi = df["Fermi"].to_numpy()
|
||||||
neutrino_mass_keV = analyzer.config.neutrino_mass_MeV * 1000
|
fermi_scaled = fermi / np.max(fermi)
|
||||||
is_excluded_by_mass = branch_endpoint_keV <= neutrino_mass_keV
|
ax.plot(energies, fermi_scaled, "r-", lw=1.5, label="Fermi", alpha=0.8)
|
||||||
is_excluded_by_intensity = (
|
for color_i, b in enumerate(active):
|
||||||
analyzer._unified_branches[i].intensity
|
col = f"branch_{b['index']}_PhaseSpace"
|
||||||
< analyzer.config.intensity_cutoff
|
if col not in df.columns:
|
||||||
)
|
# Single-branch CSVs carry the core factors unprefixed.
|
||||||
if not is_excluded_by_mass and not is_excluded_by_intensity:
|
col = "PhaseSpace"
|
||||||
active_branch_indices.append(i)
|
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(
|
||||||
|
energies,
|
||||||
|
ps_vals / ps_max if ps_max > 0 else ps_vals,
|
||||||
|
color=_BRANCH_COLORS[color_i % len(_BRANCH_COLORS)],
|
||||||
|
lw=1.5,
|
||||||
|
alpha=0.7,
|
||||||
|
label=f"PS Br. {b['index']}",
|
||||||
|
)
|
||||||
|
ax.set_ylabel("Normalized Factor", fontsize=10)
|
||||||
|
ax.set_title("Fermi & Phase Space", fontsize=11)
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
ax.legend(fontsize=8, loc="best")
|
||||||
|
|
||||||
for i in active_branch_indices:
|
# Panel 3: universal higher-order corrections
|
||||||
contrib = branch_contribs[i]
|
ax = axes[2]
|
||||||
color = branch_colors[i % len(branch_colors)]
|
correction_colors = ["green", "orange", "purple", "brown"]
|
||||||
|
for corr_i, name in enumerate(
|
||||||
|
["Screening", "FiniteSizeL0", "ChargeDistributionU", "Exchange"]
|
||||||
|
):
|
||||||
|
if name in df.columns:
|
||||||
|
values = df[name].to_numpy()
|
||||||
|
values_scaled = values / np.max(values)
|
||||||
ax.plot(
|
ax.plot(
|
||||||
analyzer.energies_MeV,
|
energies,
|
||||||
contrib,
|
values_scaled,
|
||||||
color=color,
|
color=correction_colors[corr_i],
|
||||||
lw=1.5,
|
lw=1.5,
|
||||||
alpha=0.7,
|
label=name,
|
||||||
label=Visualizer._format_branch_label(analyzer, i),
|
|
||||||
)
|
|
||||||
ax.set_yscale("log")
|
|
||||||
ax.set_ylabel(f"Normalized Counts / {e_step_keV:.1f} keV", fontsize=10)
|
|
||||||
ax.set_title("Spectrum", fontsize=11)
|
|
||||||
ax.grid(True, alpha=0.3)
|
|
||||||
ax.legend(fontsize=8, loc="best", ncol=min(2, len(active_branch_indices) + 1))
|
|
||||||
|
|
||||||
# Panel 2: Fermi function + all phase spaces
|
|
||||||
ax = axes[1]
|
|
||||||
if "Fermi" in components:
|
|
||||||
fermi_vals = components["Fermi"]
|
|
||||||
fermi_scaled = fermi_vals / np.max(fermi_vals)
|
|
||||||
ax.plot(
|
|
||||||
analyzer.energies_MeV,
|
|
||||||
fermi_scaled,
|
|
||||||
"r-",
|
|
||||||
lw=1.5,
|
|
||||||
label="Fermi",
|
|
||||||
alpha=0.8,
|
alpha=0.8,
|
||||||
)
|
)
|
||||||
for i in active_branch_indices:
|
ax.set_ylabel("Normalized Factor", fontsize=10)
|
||||||
color = branch_colors[i % len(branch_colors)]
|
ax.set_title("Universal Corrections", fontsize=11)
|
||||||
branch = analyzer._unified_branches[i]
|
ax.grid(True, alpha=0.3)
|
||||||
key = analyzer._get_component_key(i, "PhaseSpace")
|
ax.legend(fontsize=8, loc="best")
|
||||||
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,
|
|
||||||
alpha=0.7,
|
|
||||||
label=f"PS Br. {i + 1}",
|
|
||||||
)
|
|
||||||
ax.set_ylabel("Normalized Factor", fontsize=10)
|
|
||||||
ax.set_title("Fermi & Phase Space", fontsize=11)
|
|
||||||
ax.grid(True, alpha=0.3)
|
|
||||||
ax.legend(fontsize=8, loc="best")
|
|
||||||
|
|
||||||
# Panel 3: Universal W0-independent corrections
|
# Panel 4: W0-dependent corrections (Radiative per branch)
|
||||||
ax = axes[2]
|
ax = axes[3]
|
||||||
universal_components = [
|
for color_i, b in enumerate(active):
|
||||||
"Screening",
|
col = f"branch_{b['index']}_Radiative"
|
||||||
"FiniteSizeL0",
|
if col not in df.columns:
|
||||||
"ChargeDistributionU",
|
# Single-branch CSVs carry the core factors unprefixed.
|
||||||
"Exchange",
|
col = "Radiative"
|
||||||
]
|
if col not in df.columns:
|
||||||
colors = ["green", "orange", "purple", "brown"]
|
continue
|
||||||
for name, color in zip(universal_components, colors):
|
ax.plot(
|
||||||
if name in components:
|
energies,
|
||||||
values = components[name]
|
df[col].to_numpy(),
|
||||||
values_scaled = values / np.max(values)
|
color=_BRANCH_COLORS[color_i % len(_BRANCH_COLORS)],
|
||||||
ax.plot(
|
lw=1.5,
|
||||||
analyzer.energies_MeV,
|
alpha=0.7,
|
||||||
values_scaled,
|
label=f"Br. {b['index']}",
|
||||||
color=color,
|
)
|
||||||
lw=1.5,
|
ax.set_ylabel("Radiative Correction", fontsize=10)
|
||||||
label=name,
|
ax.set_xlabel(r"Electron kinetic energy $E$ [MeV]", fontsize=10)
|
||||||
alpha=0.8,
|
ax.set_title("Radiative Corrections", fontsize=11)
|
||||||
)
|
ax.grid(True, alpha=0.3)
|
||||||
ax.set_ylabel("Normalized Factor", fontsize=10)
|
ax.legend(fontsize=8, loc="best", ncol=2)
|
||||||
ax.set_title("Universal Corrections", fontsize=11)
|
|
||||||
ax.grid(True, alpha=0.3)
|
|
||||||
ax.legend(fontsize=8, loc="best")
|
|
||||||
|
|
||||||
# Panel 4: W0-dependent corrections (Radiative for each branch)
|
_apply_figure_header(fig, meta)
|
||||||
ax = axes[3]
|
fig.tight_layout()
|
||||||
for i in active_branch_indices:
|
|
||||||
color = branch_colors[i % len(branch_colors)]
|
|
||||||
key = analyzer._get_component_key(i, "Radiative")
|
|
||||||
if key in components:
|
|
||||||
rad_vals = components[key]
|
|
||||||
ax.plot(
|
|
||||||
analyzer.energies_MeV,
|
|
||||||
rad_vals,
|
|
||||||
color=color,
|
|
||||||
lw=1.5,
|
|
||||||
alpha=0.7,
|
|
||||||
label=f"Br. {i + 1}",
|
|
||||||
)
|
|
||||||
ax.set_ylabel("Radiative Correction", fontsize=10)
|
|
||||||
ax.set_xlabel(r"Electron kinetic energy $E$ [MeV]", fontsize=10)
|
|
||||||
ax.set_title("Radiative Corrections", fontsize=11)
|
|
||||||
ax.grid(True, alpha=0.3)
|
|
||||||
ax.legend(fontsize=8, loc="best", ncol=min(2, len(active_branch_indices) + 1))
|
|
||||||
|
|
||||||
Visualizer._build_figure_header(analyzer, fig)
|
if save_path:
|
||||||
|
fig.savefig(save_path, dpi=150, bbox_inches="tight")
|
||||||
|
print(f"Figure saved to {save_path}")
|
||||||
|
return fig
|
||||||
|
|
||||||
plt.tight_layout()
|
|
||||||
|
|
||||||
if save_path:
|
class Visualizer:
|
||||||
fig.savefig(save_path, dpi=150, bbox_inches="tight")
|
"""Visualization built from the exported CSV data (single source of truth)."""
|
||||||
print(f"Figure saved to {save_path}")
|
|
||||||
|
@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)
|
||||||
|
|||||||
+81
-54
@@ -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,26 +265,30 @@ 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.
|
||||||
|
|
||||||
|
The core spectrum components (phase space x Fermi function, both
|
||||||
|
0-order) are always built; only higher-order corrections are
|
||||||
|
conditional.
|
||||||
|
"""
|
||||||
components: List[SpectrumComponent] = []
|
components: List[SpectrumComponent] = []
|
||||||
W0 = float(T_to_W(config.endpoint_MeV))
|
eff_endpoint, eff_transition = _effective_single_fields(config)
|
||||||
|
W0 = float(T_to_W(eff_endpoint))
|
||||||
|
|
||||||
if config.use_phase_space:
|
# 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(
|
|
||||||
FermiFunction(Z=config.Z_daughter, A=config.A_number, logger=logger)
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
components.append(
|
||||||
|
FermiFunction(Z=config.Z_daughter, A=config.A_number, logger=logger)
|
||||||
|
)
|
||||||
|
|
||||||
if config.use_finite_size:
|
if config.use_finite_size:
|
||||||
components.append(
|
components.append(
|
||||||
@@ -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).
|
||||||
transition_type=branch.transition_type,
|
W0_branch = float(T_to_W(branch.endpoint_MeV))
|
||||||
e_step_MeV=config.e_step_MeV,
|
m_nu_natural = config.neutrino_mass_MeV / ME_MEV
|
||||||
neutrino_mass_MeV=config.neutrino_mass_MeV,
|
branch_components: List[SpectrumComponent] = [
|
||||||
# Branch-dependent components
|
PhaseSpace(
|
||||||
use_phase_space=config.use_phase_space,
|
W0=W0_branch,
|
||||||
use_radiative=config.use_radiative,
|
transition_type=branch.transition_type,
|
||||||
# Universal components disabled (handled separately)
|
m_nu=m_nu_natural,
|
||||||
use_fermi=False,
|
logger=logger,
|
||||||
use_screening=False,
|
)
|
||||||
use_finite_size=False,
|
]
|
||||||
use_charge_dist=False,
|
if config.use_radiative:
|
||||||
use_exchange=False,
|
branch_components.append(
|
||||||
)
|
RadiativeCorrection(
|
||||||
|
W0=W0_branch,
|
||||||
|
Z=config.Z_daughter,
|
||||||
|
A=config.A_number,
|
||||||
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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."""
|
||||||
|
|||||||
@@ -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"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,11 +93,7 @@
|
|||||||
" 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",
|
" return 32 # default\n",
|
||||||
" with open(filepath, 'rb') as f:\n",
|
|
||||||
" f.seek(32)\n",
|
|
||||||
" test = f.read(4)\n",
|
|
||||||
" return 32 # default\n",
|
|
||||||
"\n",
|
"\n",
|
||||||
"# --- Raw run spectrum ---\n",
|
"# --- Raw run spectrum ---\n",
|
||||||
"header_size = detect_header_size(RAW_FILE)\n",
|
"header_size = detect_header_size(RAW_FILE)\n",
|
||||||
|
|||||||
+1
-1
@@ -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" }
|
||||||
|
|||||||
+40
-21
@@ -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"
|
||||||
with open(results_file, "w") as f:
|
try:
|
||||||
f.write(
|
with open(results_file, "w") as f:
|
||||||
"run,total_counts,low_energy_counts,high_energy_counts,low_fraction,endpoint_keV\n"
|
|
||||||
)
|
|
||||||
for i in range(len(run_ids)):
|
|
||||||
f.write(
|
f.write(
|
||||||
f"{run_ids[i]},{total_rates[i]:.0f},{low_rates[i]:.0f},"
|
"run,total_counts,low_energy_counts,high_energy_counts,"
|
||||||
f"{high_rates[i]:.0f},{low_fractions[i]:.4f},{endpoints[i]:.1f}\n"
|
"low_fraction,endpoint_keV\n"
|
||||||
)
|
)
|
||||||
|
for i in range(len(run_ids)):
|
||||||
|
f.write(
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
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
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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"
|
|
||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user