docs: update documentation files and fix linting issues

This commit is contained in:
2026-07-07 13:32:48 +03:00
parent 9bf1e1333e
commit be3e288e0d
27 changed files with 423 additions and 250 deletions
+7 -4
View File
@@ -62,8 +62,11 @@ beta_shape_pnpi/
│ │ ├── 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 │ │ └── detector_response.py ✓ Analytical detector response models
├── beta_spectrum/visualize/ ← plotting / visualization ├── beta_spectrum/output/ ← output, plotting, visualization, CSV export
│ └── __init__.py # BetaSpectrumAnalyzer │ ├── __init__.py # BetaSpectrumAnalyzer, CSVEmitter, Visualizer
│ ├── core.py # BetaSpectrumAnalyzer base class
│ ├── csv_export.py # CSV export functionality
│ └── visualization.py # Plotting and visualization functionality
├── exp_data/ ← experimental data handling ├── exp_data/ ← experimental data handling
│ ├── __init__.py # ExpSpectrum, EnergyCalibrator, GaussianFitter, etc. │ ├── __init__.py # ExpSpectrum, EnergyCalibrator, GaussianFitter, etc.
│ ├── spectrum.py # ExpSpectrum dataclass (energies, counts, errors, metadata) │ ├── spectrum.py # ExpSpectrum dataclass (energies, counts, errors, metadata)
@@ -117,7 +120,7 @@ Defined in `beta_spectrum/spectrum.py`. Orchestrates the calculation:
### `BetaSpectrumAnalyzer` ### `BetaSpectrumAnalyzer`
In `beta_spectrum.visualize`. 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
- `plot_analysis(save_path=None, show_components=True)` — plot with two modes: - `plot_analysis(save_path=None, show_components=True)` — plot with two modes:
@@ -133,7 +136,7 @@ ______________________________________________________________________
```python ```python
from beta_spectrum import SpectrumConfig, BetaSpectrum from beta_spectrum import SpectrumConfig, BetaSpectrum
from beta_spectrum.visualize import BetaSpectrumAnalyzer from beta_spectrum.output import BetaSpectrumAnalyzer
config = SpectrumConfig( config = SpectrumConfig(
Z_parent=90, # Thorium-232 (example) Z_parent=90, # Thorium-232 (example)
+14 -4
View File
@@ -51,8 +51,11 @@ fitter/ ← main analysis framework
├── result.py ← FitResult, analysis results ├── result.py ← FitResult, analysis results
# └── report.py ← PDF report generation (deferred) # └── report.py ← PDF report generation (deferred)
visualize/ ← visualization / plotting output/ ← output, visualization, CSV export
└── visualize/__init__.py ← BetaSpectrumAnalyzer ├── __init__.py ← BetaSpectrumAnalyzer, CSVEmitter, Visualizer
├── core.py ← BetaSpectrumAnalyzer base class
├── csv_export.py ← CSV export functionality
└── visualization.py ← Plotting and visualization functionality
``` ```
### Rationale ### Rationale
@@ -64,6 +67,7 @@ visualize/ ← visualization / plotting
### Migration Plan ### Migration Plan
#### Phase 1: Create `exp_data` module ✅ COMPLETE #### Phase 1: Create `exp_data` module ✅ COMPLETE
- [x] `exp_data/spectrum.py` — `ExpSpectrum` dataclass: energies, counts, errors, metadata - [x] `exp_data/spectrum.py` — `ExpSpectrum` dataclass: energies, counts, errors, metadata
- [x] `exp_data/calibration.py` — `EnergyCalibrator` + `CalibrationResult`: linear/quadratic channel→energy - [x] `exp_data/calibration.py` — `EnergyCalibrator` + `CalibrationResult`: linear/quadratic channel→energy
- [x] `exp_data/fitters.py` — `GaussianFitter` + `PeakFitter`: peak detection and fitting - [x] `exp_data/fitters.py` — `GaussianFitter` + `PeakFitter`: peak detection and fitting
@@ -71,6 +75,7 @@ visualize/ ← visualization / plotting
- [x] 43 tests, ruff + mypy clean - [x] 43 tests, ruff + mypy clean
#### Phase 2: Create `fitter` module ✅ COMPLETE #### Phase 2: Create `fitter` module ✅ COMPLETE
- [x] `fitter/model.py` — `SpectrumModel`: wraps `BetaSpectrum` + `DetectorResponse`, callable model with convolution - [x] `fitter/model.py` — `SpectrumModel`: wraps `BetaSpectrum` + `DetectorResponse`, callable model with convolution
- [x] `fitter/fit_engine.py` — `SpectrumFitter` + `FitConfig` + `FitResult`: χ² fitting, covariance, profile likelihood, confidence intervals - [x] `fitter/fit_engine.py` — `SpectrumFitter` + `FitConfig` + `FitResult`: χ² fitting, covariance, profile likelihood, confidence intervals
- [x] `fitter/extractor.py` — `CWExtractor` + `GVAExtractor`: C(W) and g_V/g_A extraction - [x] `fitter/extractor.py` — `CWExtractor` + `GVAExtractor`: C(W) and g_V/g_A extraction
@@ -79,6 +84,7 @@ visualize/ ← visualization / plotting
- [x] Detector convolution handles W < 1.0 edge cases - [x] Detector convolution handles W < 1.0 edge cases
#### Phase 3: Clean up `beta_spectrum` ✅ COMPLETE #### Phase 3: Clean up `beta_spectrum` ✅ COMPLETE
- [x] Remove `cw_extractor.py` (moved to `fitter/extractor.py`) - [x] Remove `cw_extractor.py` (moved to `fitter/extractor.py`)
- [x] Remove `fitter.py` (moved to `fitter/fit_engine.py`) - [x] Remove `fitter.py` (moved to `fitter/fit_engine.py`)
- [x] Clean up `__init__.py` — only exports theory classes - [x] Clean up `__init__.py` — only exports theory classes
@@ -88,6 +94,7 @@ visualize/ ← visualization / plotting
- [x] 322 tests pass, ruff + mypy clean on all 26 source files - [x] 322 tests pass, ruff + mypy clean on all 26 source files
#### Phase 4: Update package structure ✅ COMPLETE #### Phase 4: Update package structure ✅ COMPLETE
- [x] `pyproject.toml` auto-discovers all 4 packages via `setuptools.packages.find` - [x] `pyproject.toml` auto-discovers all 4 packages via `setuptools.packages.find`
- [x] `beta_spectrum.cli` keeps CLI entry point (no re-exports needed) - [x] `beta_spectrum.cli` keeps CLI entry point (no re-exports needed)
- [x] All imports updated across 26 source files - [x] All imports updated across 26 source files
@@ -313,6 +320,7 @@ ______________________________________________________________________
**Version:** 0.3.1 → **In progress: 0.4.0 — Package restructuring** **Version:** 0.3.1 → **In progress: 0.4.0 — Package restructuring**
### Completed (v0.3.x) ### Completed (v0.3.x)
- All theoretical corrections: phase space, Fermi function, finite size, screening, exchange, radiative (with delta_cut resummation) - All theoretical corrections: phase space, Fermi function, finite size, screening, exchange, radiative (with delta_cut resummation)
- Detector response module with analytical models (Gaussian, Gaussian+tail, Tikhonov), convolution API - Detector response module with analytical models (Gaussian, Gaussian+tail, Tikhonov), convolution API
- χ² curve fitting framework (CurveFitter) with confidence intervals, profile likelihood, correlation analysis - χ² curve fitting framework (CurveFitter) with confidence intervals, profile likelihood, correlation analysis
@@ -327,20 +335,22 @@ ______________________________________________________________________
- Plot output: unified multi-branch approach, externalized headers, compact legends, consistent titles - Plot output: unified multi-branch approach, externalized headers, compact legends, consistent titles
### Completed (v0.4.0) ### Completed (v0.4.0)
- **Track B: Package restructuring** — separating into `beta_spectrum` (theory), `exp_data` (experimental data), `fitter` (analysis framework) - **Track B: Package restructuring** — separating into `beta_spectrum` (theory), `exp_data` (experimental data), `fitter` (analysis framework)
- [x] Phase 1: Create `exp_data` module (raw_data.py, calibration.py, fitters.py, corrections.py, spectrum.py) — 43 tests - [x] Phase 1: Create `exp_data` module (raw_data.py, calibration.py, fitters.py, corrections.py, spectrum.py) — 43 tests
- [x] Phase 2: Create `fitter` module (model.py, fit_engine.py, extractor.py, result.py) — 34 tests - [x] Phase 2: Create `fitter` module (model.py, fit_engine.py, extractor.py, result.py) — 34 tests
- [x] Phase 3: Clean up `beta_spectrum` (remove cw_extractor.py, fitter.py, __init__.py cleanup) — 322 tests - [x] Phase 3: Clean up `beta_spectrum` (remove cw_extractor.py, fitter.py, **init**.py cleanup) — 322 tests
- [x] Phase 4: Package auto-discovered by setuptools, all imports updated, black formatting applied - [x] Phase 4: Package auto-discovered by setuptools, all imports updated, black formatting applied
- **v0.4.0 is COMPLETE** - **v0.4.0 is COMPLETE**
- 322 tests pass (262 original + 60 new) - 322 tests pass (262 original + 60 new)
- ruff + mypy clean on all 26 source files - ruff + mypy clean on all 26 source files
- black formatting applied to all new code - black formatting applied to all new code
- `pyproject.toml` auto-discovers all 4 packages: `beta_spectrum`, `beta_spectrum.visualize`, `exp_data`, `fitter` - `pyproject.toml` auto-discovers all 4 packages: `beta_spectrum`, `beta_spectrum.output`, `exp_data`, `fitter`
- 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
### 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
- Data processing pipeline for experimental runs - Data processing pipeline for experimental runs
- Endpoint fitting and energy calibration - Endpoint fitting and energy calibration
-2
View File
@@ -395,8 +395,6 @@ class EnergyCalibrator:
if best_pair is not None: if best_pair is not None:
ch_low, ch_high = best_pair ch_low, ch_high = best_pair
e_min, e_max = min(known_energies), max(known_energies) e_min, e_max = min(known_energies), max(known_energies)
gain = (ch_high - ch_low) / (e_max - e_min)
intercept = ch_low - gain * e_min
channel_to_energy[float(ch_low)] = e_min channel_to_energy[float(ch_low)] = e_min
channel_to_energy[float(ch_high)] = e_max channel_to_energy[float(ch_high)] = e_max
@@ -42,15 +42,8 @@
" BranchConfig,\n", " BranchConfig,\n",
" BetaSpectrumAnalyzer,\n", " BetaSpectrumAnalyzer,\n",
" PhaseSpace,\n", " PhaseSpace,\n",
" FermiFunction,\n",
" FiniteSizeL0,\n",
" ChargeDistributionU,\n",
" ScreeningCorrection,\n",
" ExchangeCorrection,\n",
" RadiativeCorrection,\n",
" T_to_W,\n", " T_to_W,\n",
" W_to_T,\n", " W_to_T,\n",
" momentum,\n",
" ME_MEV,\n", " ME_MEV,\n",
")\n", ")\n",
"\n", "\n",
-1
View File
@@ -21,7 +21,6 @@
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [],
"source": [ "source": [
"import matplotlib\n",
"# Use non-interactive backend only when no display is available (e.g. jupyter execute)\n", "# Use non-interactive backend only when no display is available (e.g. jupyter execute)\n",
"import matplotlib as mpl\n", "import matplotlib as mpl\n",
"try:\n", "try:\n",
+150 -66
View File
@@ -12,20 +12,18 @@ Exit codes:
""" """
import argparse import argparse
import json
import os import os
import re import re
import subprocess import subprocess
import sys import sys
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Optional
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Types # Types
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@dataclass @dataclass
class Finding: class Finding:
severity: str # "ERROR" | "WARN" | "INFO" severity: str # "ERROR" | "WARN" | "INFO"
@@ -82,9 +80,20 @@ def all_source_files() -> list[Path]:
"""Walk the source tree and return all .py files.""" """Walk the source tree and return all .py files."""
files = [] files = []
for root, dirs, filenames in os.walk(ROOT): for root, dirs, filenames in os.walk(ROOT):
dirs[:] = [d for d in dirs if not d.startswith(".") dirs[:] = [
and d not in ("__pycache__", ".pytest_cache", ".mypy_cache", d
".ruff_cache", ".hypothesis", ".ipynb_checkpoints")] for d in dirs
if not d.startswith(".")
and d
not in (
"__pycache__",
".pytest_cache",
".mypy_cache",
".ruff_cache",
".hypothesis",
".ipynb_checkpoints",
)
]
if "egg-info" in root: if "egg-info" in root:
continue continue
for fn in filenames: for fn in filenames:
@@ -97,6 +106,7 @@ def all_source_files() -> list[Path]:
# Checks # Checks
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def check_version_consistency(report: Report): def check_version_consistency(report: Report):
"""Check that __version__ matches pyproject.toml version.""" """Check that __version__ matches pyproject.toml version."""
pyproject = ROOT / "pyproject.toml" pyproject = ROOT / "pyproject.toml"
@@ -116,8 +126,12 @@ def check_version_consistency(report: Report):
ver = ver_match.group(1) ver = ver_match.group(1)
if ver != py_ver: if ver != py_ver:
rel = pyfile.relative_to(ROOT) rel = pyfile.relative_to(ROOT)
report.add("ERROR", rel.name, "version", report.add(
f"__version__ = '{ver}' but pyproject.toml says '{py_ver}'") "ERROR",
rel.name,
"version",
f"__version__ = '{ver}' but pyproject.toml says '{py_ver}'",
)
def check_version_in_code(report: Report): def check_version_in_code(report: Report):
@@ -134,16 +148,19 @@ def check_version_in_code(report: Report):
try: try:
result = subprocess.run( result = subprocess.run(
["bs_pnpi", "--version"], ["bs_pnpi", "--version"], capture_output=True, text=True, timeout=10
capture_output=True, text=True, timeout=10
) )
if result.returncode == 0: if result.returncode == 0:
cli_ver_match = re.search(r'(\d+\.\d+\.\d+)', result.stdout) cli_ver_match = re.search(r"(\d+\.\d+\.\d+)", result.stdout)
if cli_ver_match: if cli_ver_match:
cli_ver = cli_ver_match.group(1) cli_ver = cli_ver_match.group(1)
if cli_ver != py_ver: if cli_ver != py_ver:
report.add("ERROR", "cli", "version", report.add(
f"CLI reports version '{cli_ver}' but pyproject.toml says '{py_ver}'") "ERROR",
"cli",
"version",
f"CLI reports version '{cli_ver}' but pyproject.toml says '{py_ver}'",
)
except (subprocess.TimeoutExpired, FileNotFoundError): except (subprocess.TimeoutExpired, FileNotFoundError):
pass pass
@@ -168,8 +185,10 @@ def check_file_references(report: Report):
# Skip tree diagrams and multi-line code blocks. # Skip tree diagrams and multi-line code blocks.
for line in content.split("\n"): for line in content.split("\n"):
# Find single-line inline code that looks like a file path # Find single-line inline code that looks like a file path
refs = re.findall(r'`([^`\n]+\.py[^`]*)`', line) refs = re.findall(r"`([^`\n]+\.py[^`]*)`", line)
refs.extend(re.findall(r'`([^`\n]+\\.(csv|json|toml|md|yaml|png|log))`', line)) refs.extend(
re.findall(r"`([^`\n]+\\.(csv|json|toml|md|yaml|png|log))`", line)
)
for ref in refs: for ref in refs:
if isinstance(ref, tuple): if isinstance(ref, tuple):
ref = ref[0] ref = ref[0]
@@ -183,20 +202,40 @@ def check_file_references(report: Report):
if target.exists(): if target.exists():
continue continue
# Filter out tree-diagram artifacts # Filter out tree-diagram artifacts
if any(c in ref for c in ('\u2190', '\u2192', '\u2713', '\u2717', if any(
'[', ']', '|', '\u2502', '\u2514', '\u251c', c in ref
'\u2500', '\u2502', '\u2560', '\u256c')): for c in (
"\u2190",
"\u2192",
"\u2713",
"\u2717",
"[",
"]",
"|",
"\u2502",
"\u2514",
"\u251c",
"\u2500",
"\u2502",
"\u2560",
"\u256c",
)
):
continue continue
# Only warn if it looks like a real file path # Only warn if it looks like a real file path
if re.search(r'[a-zA-Z0-9_\-]+\.[a-zA-Z0-9]+', ref): if re.search(r"[a-zA-Z0-9_\-]+\.[a-zA-Z0-9]+", ref):
basename = ref.split("/")[-1] basename = ref.split("/")[-1]
if basename in known_removed or basename in known_placeholders: if basename in known_removed or basename in known_placeholders:
continue continue
# Skip bare __init__.py (common in tree diagrams, not actionable) # Skip bare __init__.py (common in tree diagrams, not actionable)
if basename == "__init__.py": if basename == "__init__.py":
continue continue
report.add("WARN", doc_name, "file-reference", report.add(
f"Referenced file not found: `{ref}`") "WARN",
doc_name,
"file-reference",
f"Referenced file not found: `{ref}`",
)
def check_cli_commands(report: Report): def check_cli_commands(report: Report):
@@ -207,12 +246,15 @@ def check_cli_commands(report: Report):
if "bs_pnpi" in agents_md: if "bs_pnpi" in agents_md:
try: try:
result = subprocess.run( result = subprocess.run(
["bs_pnpi", "--help"], ["bs_pnpi", "--help"], capture_output=True, text=True, timeout=10
capture_output=True, text=True, timeout=10
) )
if result.returncode != 0: if result.returncode != 0:
report.add("WARN", "AGENTS.md", "cli", report.add(
f"`bs_pnpi --help` failed: {result.stderr.strip()[:100]}") "WARN",
"AGENTS.md",
"cli",
f"`bs_pnpi --help` failed: {result.stderr.strip()[:100]}",
)
except (subprocess.TimeoutExpired, FileNotFoundError): except (subprocess.TimeoutExpired, FileNotFoundError):
report.add("WARN", "AGENTS.md", "cli", "bs_pnpi not found in PATH") report.add("WARN", "AGENTS.md", "cli", "bs_pnpi not found in PATH")
@@ -220,12 +262,15 @@ def check_cli_commands(report: Report):
if "bs_exp" in agents_md: if "bs_exp" in agents_md:
try: try:
result = subprocess.run( result = subprocess.run(
["bs_exp", "--help"], ["bs_exp", "--help"], capture_output=True, text=True, timeout=10
capture_output=True, text=True, timeout=10
) )
if result.returncode != 0: if result.returncode != 0:
report.add("WARN", "AGENTS.md", "cli", report.add(
f"`bs_exp --help` failed: {result.stderr.strip()[:100]}") "WARN",
"AGENTS.md",
"cli",
f"`bs_exp --help` failed: {result.stderr.strip()[:100]}",
)
except (subprocess.TimeoutExpired, FileNotFoundError): except (subprocess.TimeoutExpired, FileNotFoundError):
report.add("WARN", "AGENTS.md", "cli", "bs_exp not found in PATH") report.add("WARN", "AGENTS.md", "cli", "bs_exp not found in PATH")
@@ -234,15 +279,15 @@ def check_imports_in_examples(report: Report):
"""Check that import statements in README examples are valid.""" """Check that import statements in README examples are valid."""
readme = read_doc("README.md") readme = read_doc("README.md")
code_blocks = re.findall(r'```[^\n]*\n(.*?)```', readme, re.DOTALL) code_blocks = re.findall(r"```[^\n]*\n(.*?)```", readme, re.DOTALL)
for block in code_blocks: for block in code_blocks:
for line in block.strip().split("\n"): for line in block.strip().split("\n"):
line = line.strip() line = line.strip()
if not line.startswith("from ") and not line.startswith("import "): if not line.startswith("from ") and not line.startswith("import "):
continue continue
match = re.match(r'from\s+(\S+)\s+import', line) match = re.match(r"from\s+(\S+)\s+import", line)
if not match: if not match:
match = re.match(r'import\s+(\S+)', line) match = re.match(r"import\s+(\S+)", line)
if not match: if not match:
continue continue
@@ -250,8 +295,12 @@ def check_imports_in_examples(report: Report):
try: try:
__import__(module) __import__(module)
except ImportError: except ImportError:
report.add("WARN", "README.md", "imports", report.add(
f"Cannot import module in example: `{module}`") "WARN",
"README.md",
"imports",
f"Cannot import module in example: `{module}`",
)
def check_directory_structure(report: Report): def check_directory_structure(report: Report):
@@ -268,12 +317,14 @@ def check_directory_structure(report: Report):
def _check_tree_in_doc(content: str, doc_name: str, report: Report): def _check_tree_in_doc(content: str, doc_name: str, report: Report):
"""Extract file references from a markdown tree diagram and check existence.""" """Extract file references from a markdown tree diagram and check existence."""
# Find fenced code blocks that look like directory trees # Find fenced code blocks that look like directory trees
trees = re.findall(r'```\w*\n(.*?)```', content, re.DOTALL) trees = re.findall(r"```\w*\n(.*?)```", content, re.DOTALL)
for tree in trees: for tree in trees:
lines = tree.split("\n") lines = tree.split("\n")
for i, line in enumerate(lines): for i, line in enumerate(lines):
# Match lines like: `├── filename.py` or `│ ├── filename.py` # Match lines like: `├── filename.py` or `│ ├── filename.py`
match = re.search(r'[\u251c\u2514\u2502\s]*[\u2500\u2501]+\s+([a-zA-Z0-9_\-]+\.\w+)', line) match = re.search(
r"[\u251c\u2514\u2502\s]*[\u2500\u2501]+\s+([a-zA-Z0-9_\-]+\.\w+)", line
)
if match: if match:
filename = match.group(1) filename = match.group(1)
# Determine depth by counting leading tree chars # Determine depth by counting leading tree chars
@@ -288,20 +339,27 @@ def _check_tree_in_doc(content: str, doc_name: str, report: Report):
seen_dir = False seen_dir = False
for j in range(i - 1, max(i - 10, -1), -1): for j in range(i - 1, max(i - 10, -1), -1):
bl = lines[j] bl = lines[j]
dir_match = re.search(r'[\u251c\u2514\u2502\s]*[\u2500\u2501]+\s+([a-zA-Z0-9_\-]+)/', bl) dir_match = re.search(
r"[\u251c\u2514\u2502\s]*[\u2500\u2501]+\s+([a-zA-Z0-9_\-]+)/",
bl,
)
if dir_match: if dir_match:
dir_depth = len(bl) - len(bl.lstrip()) dir_depth = len(bl) - len(bl.lstrip())
if dir_depth < depth: if dir_depth < depth:
dirs.append(dir_match.group(1)) dirs.append(dir_match.group(1))
seen_dir = True seen_dir = True
elif re.search(r'\w', bl): # non-empty, non-matching line elif re.search(r"\w", bl): # non-empty, non-matching line
if seen_dir: if seen_dir:
break # stop looking after first non-matching content line break # stop looking after first non-matching content line
if dirs and len(dirs) <= 3: # cap at 3 levels if dirs and len(dirs) <= 3: # cap at 3 levels
target = ROOT / "/".join(reversed(dirs)) / filename target = ROOT / "/".join(reversed(dirs)) / filename
if not target.exists(): if not target.exists():
report.add("WARN", doc_name, "structure", report.add(
f"File in structure diagram not found: `{'/'.join(reversed(dirs))}/{filename}`") "WARN",
doc_name,
"structure",
f"File in structure diagram not found: `{'/'.join(reversed(dirs))}/{filename}`",
)
def check_cli_args_consistency(report: Report): def check_cli_args_consistency(report: Report):
@@ -311,30 +369,44 @@ def check_cli_args_consistency(report: Report):
try: try:
result = subprocess.run( result = subprocess.run(
["bs_pnpi", "--help"], ["bs_pnpi", "--help"], capture_output=True, text=True, timeout=10
capture_output=True, text=True, timeout=10
) )
if result.returncode == 0: if result.returncode == 0:
actual_flags = set() actual_flags = set()
for line in result.stdout.split("\n"): for line in result.stdout.split("\n"):
flag_match = re.match(r'\s{0,2}(-\S+)', line) flag_match = re.match(r"\s{0,2}(-\S+)", line)
if flag_match: if flag_match:
actual_flags.add(flag_match.group(1)) actual_flags.add(flag_match.group(1))
known_flags = {"-v", "-vv", "-q", "--nuclide", "--output", known_flags = {
"--input", "--dry-run", "--version", "--plot", "-v",
"--log-file", "--intensity-cutoff", "--help", "-vv",
"-h"} "-q",
"--nuclide",
"--output",
"--input",
"--dry-run",
"--version",
"--plot",
"--log-file",
"--intensity-cutoff",
"--help",
"-h",
}
mentioned = set() mentioned = set()
# Only match actual CLI flag patterns (--flag-name, not random words) # Only match actual CLI flag patterns (--flag-name, not random words)
for flag in re.findall(r'--[a-z][a-z0-9-]{2,}', agents_md): for flag in re.findall(r"--[a-z][a-z0-9-]{2,}", agents_md):
mentioned.add(flag) mentioned.add(flag)
for flag in re.findall(r'-[a-z][a-z0-9-]{2,}', readme): for flag in re.findall(r"-[a-z][a-z0-9-]{2,}", readme):
mentioned.add(flag) mentioned.add(flag)
unknown = mentioned - known_flags unknown = mentioned - known_flags
for flag in sorted(unknown): for flag in sorted(unknown):
report.add("INFO", "AGENTS.md", "cli-args", report.add(
f"Flag `{flag}` mentioned but not in --help (may be custom)") "INFO",
"AGENTS.md",
"cli-args",
f"Flag `{flag}` mentioned but not in --help (may be custom)",
)
except (subprocess.TimeoutExpired, FileNotFoundError): except (subprocess.TimeoutExpired, FileNotFoundError):
pass pass
@@ -350,8 +422,9 @@ def check_markdown_formatting(report: Report):
content = path.read_text() content = path.read_text()
for i, line in enumerate(content.split("\n"), 1): for i, line in enumerate(content.split("\n"), 1):
if line != line.rstrip(): if line != line.rstrip():
report.add("INFO", doc_name, f"line {i}", report.add(
"Trailing whitespace detected") "INFO", doc_name, f"line {i}", "Trailing whitespace detected"
)
break # one per file is enough break # one per file is enough
@@ -361,18 +434,24 @@ def check_todo_completions(report: Report):
# Find completed items that claim specific files/methods exist # Find completed items that claim specific files/methods exist
# Pattern: "- [x] ... implemented: `SomeClass` or `method_name`" # Pattern: "- [x] ... implemented: `SomeClass` or `method_name`"
completed = re.findall(r'- \[x\].*?(?=- \[|$)', todo, re.DOTALL) completed = re.findall(r"- \[x\].*?(?=- \[|$)", todo, re.DOTALL)
for block in completed: for block in completed:
# Skip historical references (items about cleanup, moves, removals) # Skip historical references (items about cleanup, moves, removals)
if re.search(r'(?:remove|moved|replaced|deleted|clean up|cleanup)', block, re.IGNORECASE): if re.search(
r"(?:remove|moved|replaced|deleted|clean up|cleanup)", block, re.IGNORECASE
):
continue continue
# Look for specific file claims # Look for specific file claims
file_claims = re.findall(r'`([^`]+\.py)`', block) file_claims = re.findall(r"`([^`]+\.py)`", block)
for fc in file_claims: for fc in file_claims:
target = ROOT / fc target = ROOT / fc
if not target.exists(): if not target.exists():
report.add("WARN", "TODO.md", "completions", report.add(
f"Completed item references non-existent file: `{fc}`") "WARN",
"TODO.md",
"completions",
f"Completed item references non-existent file: `{fc}`",
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -395,12 +474,10 @@ CHECKS = [
def main(): def main():
parser = argparse.ArgumentParser(description="Check documentation consistency") parser = argparse.ArgumentParser(description="Check documentation consistency")
parser.add_argument( parser.add_argument(
"--strict", action="store_true", "--strict", action="store_true", help="Treat warnings as errors (exit 1)"
help="Treat warnings as errors (exit 1)"
) )
parser.add_argument( parser.add_argument(
"--json", action="store_true", dest="as_json", "--json", action="store_true", dest="as_json", help="Output findings as JSON"
help="Output findings as JSON"
) )
args = parser.parse_args() args = parser.parse_args()
@@ -414,15 +491,22 @@ def main():
# Output # Output
if args.as_json: if args.as_json:
import json as _json import json as _json
output = [ output = [
{"severity": f.severity, "source": f.source, {
"section": f.section, "message": f.message} "severity": f.severity,
"source": f.source,
"section": f.section,
"message": f.message,
}
for f in report.findings for f in report.findings
] ]
print(_json.dumps(output, indent=2)) print(_json.dumps(output, indent=2))
else: else:
print(f"docs_check: {len(report.findings)} findings " print(
f"({len(report.errors)} errors, {len(report.warnings)} warnings)") f"docs_check: {len(report.findings)} findings "
f"({len(report.errors)} errors, {len(report.warnings)} warnings)"
)
print(report.summary()) print(report.summary())
# Exit # Exit
-1
View File
@@ -10,7 +10,6 @@ Each plot gets a descriptive filename based on notebook name and cell index.
""" """
import json import json
import os
import sys import sys
from pathlib import Path from pathlib import Path
-1
View File
@@ -14,7 +14,6 @@ Usage:
""" """
import json import json
import os
import sys import sys
import base64 import base64
from pathlib import Path from pathlib import Path
+21 -26
View File
@@ -36,54 +36,49 @@ import pytest
@pytest.mark.api @pytest.mark.api
def _test_component_is_callable(component, component_name="Component"): def _test_component_is_callable(component, component_name="Component"):
"""Component must be callable with array input.""" """Component must be callable with array input."""
assert callable(component), ( assert callable(component), f"{component_name}: component is not callable"
f"{component_name}: component is not callable"
)
W = np.array([1.5, 2.0, 3.0]) W = np.array([1.5, 2.0, 3.0])
result = component(W) result = component(W)
assert isinstance(result, np.ndarray), ( assert isinstance(result, np.ndarray), (
f"{component_name}: __call__ returned {type(result)}, " f"{component_name}: __call__ returned {type(result)}, " f"expected np.ndarray"
f"expected np.ndarray"
)
assert result.shape == W.shape, (
f"{component_name}: shape mismatch {W.shape} → {result.shape}"
) )
assert (
result.shape == W.shape
), f"{component_name}: shape mismatch {W.shape} → {result.shape}"
@pytest.mark.api @pytest.mark.api
def _test_component_repr(component, component_name="Component"): def _test_component_repr(component, component_name="Component"):
"""Component must have a non-empty repr.""" """Component must have a non-empty repr."""
repr_str = repr(component) repr_str = repr(component)
assert isinstance(repr_str, str), ( assert isinstance(
f"{component_name}: repr returned {type(repr_str)}" repr_str, str
) ), f"{component_name}: repr returned {type(repr_str)}"
assert len(repr_str) > 0, ( assert len(repr_str) > 0, f"{component_name}: repr is empty string"
f"{component_name}: repr is empty string"
)
# repr should contain the class name # repr should contain the class name
assert component_name in repr_str or component.__class__.__name__ in repr_str, ( assert (
f"{component_name}: repr should contain class name. Got: {repr_str}" component_name in repr_str or component.__class__.__name__ in repr_str
) ), f"{component_name}: repr should contain class name. Got: {repr_str}"
@pytest.mark.api @pytest.mark.api
def _test_component_list_input(component, component_name="Component"): def _test_component_list_input(component, component_name="Component"):
"""Component must handle Python list input (numpy converts it).""" """Component must handle Python list input (numpy converts it)."""
result = component([1.5, 2.0, 3.0]) result = component([1.5, 2.0, 3.0])
assert isinstance(result, np.ndarray), ( assert isinstance(
f"{component_name}: list input returned {type(result)}" result, np.ndarray
) ), f"{component_name}: list input returned {type(result)}"
@pytest.mark.api @pytest.mark.api
def _test_component_empty_input(component, component_name="Component"): def _test_component_empty_input(component, component_name="Component"):
"""Component must handle empty array input gracefully.""" """Component must handle empty array input gracefully."""
result = component(np.array([])) result = component(np.array([]))
assert isinstance(result, np.ndarray), ( assert isinstance(
f"{component_name}: empty input returned {type(result)}" result, np.ndarray
) ), f"{component_name}: empty input returned {type(result)}"
assert result.shape == (0,), ( assert result.shape == (
f"{component_name}: empty input should return empty array" 0,
) ), f"{component_name}: empty input should return empty array"
+9 -7
View File
@@ -10,20 +10,18 @@ seconds to complete. They are intended for CI/full test runs, not for
fast feedback during development. fast feedback during development.
""" """
from typing import List
import numpy as np import numpy as np
import pytest
from hypothesis import given, settings, strategies as st from hypothesis import given, settings, strategies as st
from beta_spectrum.components.fermi import FermiFunction from beta_spectrum.components.fermi import FermiFunction
from beta_spectrum.components.phase_space import PhaseSpace from beta_spectrum.components.phase_space import PhaseSpace
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Fermi function: random Z, A, W combinations # Fermi function: random Z, A, W combinations
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@given( @given(
Z=st.integers(min_value=1, max_value=92), Z=st.integers(min_value=1, max_value=92),
A=st.integers(min_value=1, max_value=300), A=st.integers(min_value=1, max_value=300),
@@ -56,8 +54,7 @@ def test_fermi_monotonic_for_fixed_Z(Z: int):
diffs = np.diff(result) diffs = np.diff(result)
assert np.all(diffs <= 0), ( assert np.all(diffs <= 0), (
f"Fermi function not monotonically decreasing for Z={Z}: " f"Fermi function not monotonically decreasing for Z={Z}: " f"diffs = {diffs}"
f"diffs = {diffs}"
) )
@@ -65,6 +62,7 @@ def test_fermi_monotonic_for_fixed_Z(Z: int):
# Phase space: random W0, W combinations # Phase space: random W0, W combinations
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@given( @given(
W0=st.floats(min_value=2.0, max_value=10.0, allow_infinity=False, allow_nan=False), W0=st.floats(min_value=2.0, max_value=10.0, allow_infinity=False, allow_nan=False),
W=st.floats(min_value=1.01, max_value=5.0, allow_infinity=False, allow_nan=False), W=st.floats(min_value=1.01, max_value=5.0, allow_infinity=False, allow_nan=False),
@@ -91,7 +89,9 @@ def test_phase_space_threshold_at_W1(W0: float):
ps = PhaseSpace(W0=W0) ps = PhaseSpace(W0=W0)
result = ps(np.array([1.0])) result = ps(np.array([1.0]))
assert result[0] == 0.0, f"Phase space should be 0 at W=1 for W0={W0:.2f}, got {result[0]}" assert (
result[0] == 0.0
), f"Phase space should be 0 at W=1 for W0={W0:.2f}, got {result[0]}"
@given( @given(
@@ -103,4 +103,6 @@ def test_phase_space_endpoint_at_W0(W0: float):
ps = PhaseSpace(W0=W0) ps = PhaseSpace(W0=W0)
result = ps(np.array([W0])) result = ps(np.array([W0]))
assert result[0] == 0.0, f"Phase space should be 0 at W=W0={W0:.2f}, got {result[0]}" assert (
result[0] == 0.0
), f"Phase space should be 0 at W=W0={W0:.2f}, got {result[0]}"
+24 -21
View File
@@ -52,11 +52,11 @@ or only quality tests (`pytest -m quality`) for fast feedback during development
import numpy as np import numpy as np
import pytest import pytest
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Property tests: positivity # Property tests: positivity
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.mark.property @pytest.mark.property
def _test_positive_values(component, W, component_name="Component"): def _test_positive_values(component, W, component_name="Component"):
"""Component must return positive values at all W in the physical range. """Component must return positive values at all W in the physical range.
@@ -82,9 +82,9 @@ def _test_positive_values(component, W, component_name="Component"):
f"W range: [{W_arr.min():.3f}, {W_arr.max():.3f}], " f"W range: [{W_arr.min():.3f}, {W_arr.max():.3f}], "
f"result range: [{np.nanmin(result):.6f}, {np.nanmax(result):.6f}]" f"result range: [{np.nanmin(result):.6f}, {np.nanmax(result):.6f}]"
) )
assert not np.any(np.isinf(result)), ( assert not np.any(
f"{component_name}: Inf values found in output." np.isinf(result)
) ), f"{component_name}: Inf values found in output."
assert np.all(result > 0), ( assert np.all(result > 0), (
f"{component_name}: non-positive values found. " f"{component_name}: non-positive values found. "
f"Min = {np.min(result):.10f} at W = {W_arr[np.argmin(result)][0]:.4f}" f"Min = {np.min(result):.10f} at W = {W_arr[np.argmin(result)][0]:.4f}"
@@ -92,7 +92,9 @@ def _test_positive_values(component, W, component_name="Component"):
@pytest.mark.property @pytest.mark.property
def _test_non_negative_values(component, W, component_name="Component", allow_zero=True): def _test_non_negative_values(
component, W, component_name="Component", allow_zero=True
):
"""Component must return non-negative values (allows zero at physical boundaries). """Component must return non-negative values (allows zero at physical boundaries).
Use this for components that can legitimately be zero (e.g., phase_space Use this for components that can legitimately be zero (e.g., phase_space
@@ -106,17 +108,16 @@ def _test_non_negative_values(component, W, component_name="Component", allow_ze
W_arr = np.asarray(W, dtype=float) W_arr = np.asarray(W, dtype=float)
result = component(W_arr) result = component(W_arr)
assert not np.any(np.isnan(result)), ( assert not np.any(
f"{component_name}: NaN values found in output." np.isnan(result)
) ), f"{component_name}: NaN values found in output."
assert not np.any(np.isinf(result)), ( assert not np.any(
f"{component_name}: Inf values found in output." np.isinf(result)
) ), f"{component_name}: Inf values found in output."
if allow_zero: if allow_zero:
assert np.all(result >= 0), ( assert np.all(result >= 0), (
f"{component_name}: negative values found. " f"{component_name}: negative values found. " f"Min = {np.min(result):.10f}"
f"Min = {np.min(result):.10f}"
) )
else: else:
assert np.all(result > 0), ( assert np.all(result > 0), (
@@ -129,15 +130,16 @@ def _test_non_negative_values(component, W, component_name="Component", allow_ze
# Property tests: shape preservation # Property tests: shape preservation
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.mark.quality @pytest.mark.quality
def _test_output_shape_preserved(component, W, component_name="Component"): def _test_output_shape_preserved(component, W, component_name="Component"):
"""Output shape must match input shape — no flattening or reshaping.""" """Output shape must match input shape — no flattening or reshaping."""
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 is {type(result)}, expected np.ndarray" result, np.ndarray
) ), f"{component_name}: output is {type(result)}, expected np.ndarray"
assert result.shape == W_arr.shape, ( assert result.shape == W_arr.shape, (
f"{component_name}: shape mismatch. " f"{component_name}: shape mismatch. "
f"Input {W_arr.shape} → Output {result.shape}" f"Input {W_arr.shape} → Output {result.shape}"
@@ -152,15 +154,14 @@ def _test_scalar_input_returns_array(component, W_scalar, component_name="Compon
f"{component_name}: single-element input returned {type(result)}, " f"{component_name}: single-element input returned {type(result)}, "
f"expected np.ndarray" f"expected np.ndarray"
) )
assert result.ndim == 1, ( assert result.ndim == 1, f"{component_name}: expected 1D output, got {result.ndim}D"
f"{component_name}: expected 1D output, got {result.ndim}D"
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Property tests: numerical stability # Property tests: numerical stability
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.mark.quality @pytest.mark.quality
def _test_no_nan_across_range(component, W, component_name="Component"): def _test_no_nan_across_range(component, W, component_name="Component"):
"""No NaN values across the entire energy range.""" """No NaN values across the entire energy range."""
@@ -208,6 +209,7 @@ def _test_full_range_stability(component, W, component_name="Component"):
# Property tests: type correctness # Property tests: type correctness
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.mark.quality @pytest.mark.quality
def _test_output_is_ndarray(component, W, component_name="Component"): def _test_output_is_ndarray(component, W, component_name="Component"):
"""Output must be numpy.ndarray for any valid input.""" """Output must be numpy.ndarray for any valid input."""
@@ -224,6 +226,7 @@ def _test_output_is_ndarray(component, W, component_name="Component"):
# Parametrized test templates for quick component integration # Parametrized test templates for quick component integration
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def make_positive_test(component_class, test_params, component_name=None): def make_positive_test(component_class, test_params, component_name=None):
"""Factory function to create a parametrized positivity test. """Factory function to create a parametrized positivity test.
@@ -258,7 +261,7 @@ def make_positive_test(component_class, test_params, component_name=None):
@pytest.mark.parametrize("W", W_values) @pytest.mark.parametrize("W", W_values)
def test_positive(component_params, W_arr): def test_positive(component_params, W_arr):
comp = component_class(**component_params) comp = component_class(**component_params)
test_positive_values(comp, W_arr, component_name) _test_positive_values(comp, W_arr, component_name)
return test_positive return test_positive
@@ -278,6 +281,6 @@ def make_stability_test(component_class, test_params, component_name=None):
@pytest.mark.parametrize("W", W_values) @pytest.mark.parametrize("W", W_values)
def test_stability(component_params, W_arr): def test_stability(component_params, W_arr):
comp = component_class(**component_params) comp = component_class(**component_params)
test_full_range_stability(comp, W_arr, component_name) _test_full_range_stability(comp, W_arr, component_name)
return test_stability return test_stability
@@ -178,6 +178,7 @@ class TestDeclarativeDetectorResponse:
detector = BetaSpectrum.create_detector_from_config(config) detector = BetaSpectrum.create_detector_from_config(config)
from beta_spectrum.components.detector_response import DetectorResponse
assert isinstance(detector, DetectorResponse) assert isinstance(detector, DetectorResponse)
assert detector.n_channels == 512 assert detector.n_channels == 512
assert detector._mode == "analytical" assert detector._mode == "analytical"
+3 -1
View File
@@ -13,7 +13,9 @@ except ImportError:
DetectorResponse = None # type: ignore[misc, assignment] DetectorResponse = None # type: ignore[misc, assignment]
@pytest.mark.skipif(DetectorResponse is None, reason="DetectorResponse not yet implemented") @pytest.mark.skipif(
DetectorResponse is None, reason="DetectorResponse not yet implemented"
)
class TestAnalyticalGaussianResponse: class TestAnalyticalGaussianResponse:
"""Test pure Gaussian detector response.""" """Test pure Gaussian detector response."""
+9 -3
View File
@@ -22,7 +22,9 @@ class TestExchangePhysics:
ex = ExchangeCorrection(Z=80) ex = ExchangeCorrection(Z=80)
low_W = np.array([1.1]) low_W = np.array([1.1])
result = ex(low_W) result = ex(low_W)
assert result[0] > 1.0, f"Exchange must enhance spectrum at low energy for heavy Z: {result[0]}" assert (
result[0] > 1.0
), f"Exchange must enhance spectrum at low energy for heavy Z: {result[0]}"
@pytest.mark.physics @pytest.mark.physics
def test_grows_as_energy_decreases(self): def test_grows_as_energy_decreases(self):
@@ -35,7 +37,9 @@ class TestExchangePhysics:
s_low = ex(low_W)[0] s_low = ex(low_W)[0]
s_lower = ex(lower_W)[0] s_lower = ex(lower_W)[0]
assert s_lower > s_low, f"Exchange must increase at lower energy: {s_low} → {s_lower}" assert (
s_lower > s_low
), f"Exchange must increase at lower energy: {s_low} → {s_lower}"
@pytest.mark.physics @pytest.mark.physics
def test_vanishes_at_high_energy(self): def test_vanishes_at_high_energy(self):
@@ -43,7 +47,9 @@ class TestExchangePhysics:
ex = ExchangeCorrection(Z=40) ex = ExchangeCorrection(Z=40)
high_W = np.array([3.0, 5.0]) high_W = np.array([3.0, 5.0])
result = ex(high_W) result = ex(high_W)
assert abs(result[0] - 1.0) < 0.5, f"Exchange must approach unity at high energy: {result}" assert (
abs(result[0] - 1.0) < 0.5
), f"Exchange must approach unity at high energy: {result}"
class TestExchangeErrorHandling: class TestExchangeErrorHandling:
+9 -9
View File
@@ -660,9 +660,9 @@ class TestMultiBranchEdgeCases:
W, _ = spectrum.get_energy_grid(config) W, _ = spectrum.get_energy_grid(config)
values = spectrum(W) values = spectrum(W)
assert np.all(np.isfinite(values)), ( assert np.all(
"High-Z multi-branch must not produce NaN/inf" np.isfinite(values)
) ), "High-Z multi-branch must not produce NaN/inf"
def test_no_branches_defaults_to_single(self): def test_no_branches_defaults_to_single(self):
"""No branches list should default to single-branch mode.""" """No branches list should default to single-branch mode."""
@@ -727,9 +727,9 @@ class TestMultiBranchEdgeCases:
contrib = universal_product * branch.intensity * bs[i] contrib = universal_product * branch.intensity * bs[i]
# Branch contribution should not exceed total # Branch contribution should not exceed total
assert np.all(contrib <= total + 1e-10), ( assert np.all(
f"Branch {i} contribution exceeds total at some points" contrib <= total + 1e-10
) ), f"Branch {i} contribution exceeds total at some points"
# Branch contribution should be non-negative # Branch contribution should be non-negative
assert np.all(contrib >= 0), f"Branch {i} contribution has negative values" assert np.all(contrib >= 0), f"Branch {i} contribution has negative values"
@@ -779,6 +779,6 @@ class TestMultiBranchEdgeCases:
assert integral > 0, f"Branch {i} has zero integral" assert integral > 0, f"Branch {i} has zero integral"
# Sum of branch integrals should equal total # Sum of branch integrals should equal total
assert np.isclose(sum(integrals), total_integral, rtol=1e-6), ( assert np.isclose(
"Sum of branch integrals should equal total integral" sum(integrals), total_integral, rtol=1e-6
) ), "Sum of branch integrals should equal total integral"
+27 -27
View File
@@ -20,12 +20,12 @@ class TestNeutrinoMassPhaseSpaceEffects:
result = ps(W_test) result = ps(W_test)
# Should not produce NaN values # Should not produce NaN values
assert not np.any(np.isnan(result)), ( assert not np.any(
"Phase space with m_nu=0 should not produce NaN values" np.isnan(result)
) ), "Phase space with m_nu=0 should not produce NaN values"
assert np.all(result > 0), ( assert np.all(
"Phase space with m_nu=0 should be positive in physical range" result > 0
) ), "Phase space with m_nu=0 should be positive in physical range"
def test_phase_space_with_nonzero_neutrino_mass(self): def test_phase_space_with_nonzero_neutrino_mass(self):
"""Test that phase space calculation works with non-zero neutrino mass.""" """Test that phase space calculation works with non-zero neutrino mass."""
@@ -34,12 +34,12 @@ class TestNeutrinoMassPhaseSpaceEffects:
result = ps(W_test) result = ps(W_test)
# Should not produce NaN values for energies above threshold # Should not produce NaN values for energies above threshold
assert not np.any(np.isnan(result)), ( assert not np.any(
"Phase space with m_nu>0 should not produce NaN values above threshold" np.isnan(result)
) ), "Phase space with m_nu>0 should not produce NaN values above threshold"
assert np.all(result > 0), ( assert np.all(
"Phase space with m_nu>0 should be positive above threshold" result > 0
) ), "Phase space with m_nu>0 should be positive above threshold"
def test_phase_space_with_neutrino_mass_near_endpoint(self): def test_phase_space_with_neutrino_mass_near_endpoint(self):
"""Test that phase space calculation handles energies near endpoint with neutrino mass.""" """Test that phase space calculation handles energies near endpoint with neutrino mass."""
@@ -48,18 +48,18 @@ class TestNeutrinoMassPhaseSpaceEffects:
# Test energies where W0-W > m_nu (should be finite) # Test energies where W0-W > m_nu (should be finite)
W_above_threshold = np.array([4.8]) # W0-W = 0.2 > m_nu=0.1 W_above_threshold = np.array([4.8]) # W0-W = 0.2 > m_nu=0.1
result_above = ps(W_above_threshold) result_above = ps(W_above_threshold)
assert not np.any(np.isnan(result_above)), ( assert not np.any(
"Should produce finite value above threshold" np.isnan(result_above)
) ), "Should produce finite value above threshold"
# Test energies where W0-W < m_nu (should be handled properly) # Test energies where W0-W < m_nu (should be handled properly)
# With our fix, this should return 0 instead of NaN # With our fix, this should return 0 instead of NaN
W_below_threshold = np.array([4.95]) # W0-W = 0.05 < m_nu=0.1 W_below_threshold = np.array([4.95]) # W0-W = 0.05 < m_nu=0.1
result_below = ps(W_below_threshold) result_below = ps(W_below_threshold)
# After our fix, NaN values should be replaced with 0 # After our fix, NaN values should be replaced with 0
assert np.all(result_below == 0.0) or np.all(np.isnan(result_below)), ( assert np.all(result_below == 0.0) or np.all(
"Should handle below-threshold energies properly (either 0 or NaN)" np.isnan(result_below)
) ), "Should handle below-threshold energies properly (either 0 or NaN)"
def test_spectrum_config_with_neutrino_mass(self): def test_spectrum_config_with_neutrino_mass(self):
"""Test that SpectrumConfig accepts and stores neutrino mass parameter.""" """Test that SpectrumConfig accepts and stores neutrino mass parameter."""
@@ -72,9 +72,9 @@ class TestNeutrinoMassPhaseSpaceEffects:
e_step_MeV=0.001, e_step_MeV=0.001,
neutrino_mass_MeV=0.01, neutrino_mass_MeV=0.01,
) )
assert config.neutrino_mass_MeV == 0.01, ( assert (
"SpectrumConfig should store neutrino mass parameter" config.neutrino_mass_MeV == 0.01
) ), "SpectrumConfig should store neutrino mass parameter"
def test_beta_spectrum_with_neutrino_mass(self): def test_beta_spectrum_with_neutrino_mass(self):
"""Test that BetaSpectrum works with neutrino mass in configuration.""" """Test that BetaSpectrum works with neutrino mass in configuration."""
@@ -96,12 +96,12 @@ class TestNeutrinoMassPhaseSpaceEffects:
result = spectrum(W) result = spectrum(W)
# Should not produce NaN values # Should not produce NaN values
assert not np.any(np.isnan(result)), ( assert not np.any(
"BetaSpectrum with neutrino mass should not produce NaN values" np.isnan(result)
) ), "BetaSpectrum with neutrino mass should not produce NaN values"
assert np.all(result >= 0), ( assert np.all(
"BetaSpectrum with neutrino mass should be non-negative" result >= 0
) ), "BetaSpectrum with neutrino mass should be non-negative"
class TestNeutrinoMassCLIIntegration: class TestNeutrinoMassCLIIntegration:
+3 -3
View File
@@ -74,9 +74,9 @@ class TestPhaseSpaceNeutrinoMass:
val_massive = ps_massive(W_near_endpoint)[0] val_massive = ps_massive(W_near_endpoint)[0]
assert not np.isnan(val_massive), "Should produce finite value" assert not np.isnan(val_massive), "Should produce finite value"
assert val_massive < val_zero, ( assert (
"Massive neutrino must suppress spectrum near endpoint" val_massive < val_zero
) ), "Massive neutrino must suppress spectrum near endpoint"
@pytest.mark.physics @pytest.mark.physics
def test_massive_neutrino_below_threshold(self): def test_massive_neutrino_below_threshold(self):
+30 -12
View File
@@ -21,7 +21,9 @@ class TestRadiativeEndpointHandling:
result = rc(W_near_end) result = rc(W_near_end)
assert not np.isnan(result[0]), "Resummed correction must be finite at endpoint" assert not np.isnan(result[0]), "Resummed correction must be finite at endpoint"
assert not np.isinf(result[0]), "Resummed correction must not diverge at endpoint" assert not np.isinf(
result[0]
), "Resummed correction must not diverge at endpoint"
@pytest.mark.physics @pytest.mark.physics
def test_standard_mode_diverges(self): def test_standard_mode_diverges(self):
@@ -34,7 +36,9 @@ class TestRadiativeEndpointHandling:
val_far = rc(W_far)[0] val_far = rc(W_far)[0]
val_close = rc(W_close)[0] val_close = rc(W_close)[0]
assert val_close < val_far, "Standard mode must show divergent behavior near endpoint" assert (
val_close < val_far
), "Standard mode must show divergent behavior near endpoint"
class TestRadiativeEnergyDependence: class TestRadiativeEnergyDependence:
@@ -51,7 +55,9 @@ class TestRadiativeEnergyDependence:
r_mid = rc(mid_W)[0] r_mid = rc(mid_W)[0]
r_near = rc(near_end_W)[0] r_near = rc(near_end_W)[0]
assert r_near <= r_mid, f"Correction factor should decrease near endpoint: {r_mid} → {r_near}" assert (
r_near <= r_mid
), f"Correction factor should decrease near endpoint: {r_mid} → {r_near}"
class TestRadiativeResummationSwitch: class TestRadiativeResummationSwitch:
@@ -73,8 +79,12 @@ class TestRadiativeResummationSwitch:
r_standard = rc_standard(W_near)[0] r_standard = rc_standard(W_near)[0]
assert not np.isnan(r_resummed), "Resummed mode must be finite" assert not np.isnan(r_resummed), "Resummed mode must be finite"
assert not np.isnan(r_standard), "Standard mode must be finite (mask protects endpoint)" assert not np.isnan(
assert r_resummed > r_standard, f"Near endpoint: resummed={r_resummed} > standard={r_standard}" r_standard
), "Standard mode must be finite (mask protects endpoint)"
assert (
r_resummed > r_standard
), f"Near endpoint: resummed={r_resummed} > standard={r_standard}"
@pytest.mark.physics @pytest.mark.physics
def test_resummation_only_near_endpoint(self): def test_resummation_only_near_endpoint(self):
@@ -87,9 +97,9 @@ class TestRadiativeResummationSwitch:
r_resummed_far = rc_resummed(W_far)[0] r_resummed_far = rc_resummed(W_far)[0]
r_standard_far = rc_standard(W_far)[0] r_standard_far = rc_standard(W_far)[0]
assert np.isclose(r_resummed_far, r_standard_far, rtol=1e-10), ( assert np.isclose(
f"Far from endpoint: resummed={r_resummed_far}, standard={r_standard_far}" r_resummed_far, r_standard_far, rtol=1e-10
) ), f"Far from endpoint: resummed={r_resummed_far}, standard={r_standard_far}"
# Very near endpoint (delta_W < delta_cut): resummed is finite and larger # Very near endpoint (delta_W < delta_cut): resummed is finite and larger
W_near = np.array([4.9995]) W_near = np.array([4.9995])
@@ -121,7 +131,9 @@ class TestRadiativeZDependence:
W_test = np.linspace(1.5, 4.5, 10) W_test = np.linspace(1.5, 4.5, 10)
diff = np.abs(rc_z50(W_test) - rc_z1(W_test)) diff = np.abs(rc_z50(W_test) - rc_z1(W_test))
assert np.max(diff) > 1e-4, f"Z=1 and Z=50 should differ: max diff = {np.max(diff)}" assert (
np.max(diff) > 1e-4
), f"Z=1 and Z=50 should differ: max diff = {np.max(diff)}"
@pytest.mark.physics @pytest.mark.physics
def test_z_correction_magnitude_reasonable(self): def test_z_correction_magnitude_reasonable(self):
@@ -129,7 +141,9 @@ class TestRadiativeZDependence:
rc = RadiativeCorrection(W0=5.0, Z=92, use_endpoint_resummation=True) rc = RadiativeCorrection(W0=5.0, Z=92, use_endpoint_resummation=True)
W_test = np.array([3.0]) W_test = np.array([3.0])
result = rc(W_test) result = rc(W_test)
assert 0.9 < result[0] < 3.0, f"Z=92 correction should be reasonable: {result[0]}" assert (
0.9 < result[0] < 3.0
), f"Z=92 correction should be reasonable: {result[0]}"
class TestRadiativeAParameter: class TestRadiativeAParameter:
@@ -138,8 +152,12 @@ class TestRadiativeAParameter:
@pytest.mark.physics @pytest.mark.physics
def test_a_affects_nuclear_model_correction(self): def test_a_affects_nuclear_model_correction(self):
"""A parameter affects the nuclear-structure-dependent part of O(Z*alpha^2).""" """A parameter affects the nuclear-structure-dependent part of O(Z*alpha^2)."""
rc_a100 = RadiativeCorrection(W0=5.0, Z=50, A=100, use_endpoint_resummation=True) rc_a100 = RadiativeCorrection(
rc_a140 = RadiativeCorrection(W0=5.0, Z=50, A=140, use_endpoint_resummation=True) W0=5.0, Z=50, A=100, use_endpoint_resummation=True
)
rc_a140 = RadiativeCorrection(
W0=5.0, Z=50, A=140, use_endpoint_resummation=True
)
W_test = np.array([3.0]) W_test = np.array([3.0])
assert rc_a100(W_test)[0] != rc_a140(W_test)[0], ( assert rc_a100(W_test)[0] != rc_a140(W_test)[0], (
+6 -2
View File
@@ -13,7 +13,9 @@ except ImportError:
DetectorResponse = None # type: ignore[misc, assignment] DetectorResponse = None # type: ignore[misc, assignment]
@pytest.mark.skipif(DetectorResponse is None, reason="DetectorResponse not yet implemented") @pytest.mark.skipif(
DetectorResponse is None, reason="DetectorResponse not yet implemented"
)
class TestFactoryMethods: class TestFactoryMethods:
"""Test convenience factory methods.""" """Test convenience factory methods."""
@@ -56,7 +58,9 @@ class TestFactoryMethods:
assert len(resp.calibration_energies) == 3 assert len(resp.calibration_energies) == 3
@pytest.mark.skipif(DetectorResponse is None, reason="DetectorResponse not yet implemented") @pytest.mark.skipif(
DetectorResponse is None, reason="DetectorResponse not yet implemented"
)
class TestEdgeCases: class TestEdgeCases:
"""Test edge cases and error handling.""" """Test edge cases and error handling."""
+14 -4
View File
@@ -18,15 +18,20 @@ class TestExchangeAPI:
result = ex(np.array([2.0])) result = ex(np.array([2.0]))
assert isinstance(result, np.ndarray) assert isinstance(result, np.ndarray)
class TestExchangeProperties: class TestExchangeProperties:
@pytest.mark.property @pytest.mark.property
@pytest.mark.parametrize("W", [ @pytest.mark.parametrize(
"W",
[
[2.0], [2.0],
np.linspace(1.01, 6.0, 50), np.linspace(1.01, 6.0, 50),
]) ],
)
def test_positive(self, W): def test_positive(self, W):
ex = ExchangeCorrection(Z=92) ex = ExchangeCorrection(Z=92)
from tests.common.test_property_tests import _test_positive_values from tests.common.test_property_tests import _test_positive_values
_test_positive_values(ex, W, component_name="ExchangeCorrection") _test_positive_values(ex, W, component_name="ExchangeCorrection")
@pytest.mark.quality @pytest.mark.quality
@@ -35,13 +40,18 @@ class TestExchangeProperties:
ex = ExchangeCorrection(Z=30) ex = ExchangeCorrection(Z=30)
W = np.linspace(1.1, 3.0, n) W = np.linspace(1.1, 3.0, n)
from tests.common.test_property_tests import _test_output_shape_preserved from tests.common.test_property_tests import _test_output_shape_preserved
_test_output_shape_preserved(ex, W, component_name="ExchangeCorrection") _test_output_shape_preserved(ex, W, component_name="ExchangeCorrection")
@pytest.mark.quality @pytest.mark.quality
@pytest.mark.parametrize("W", [ @pytest.mark.parametrize(
"W",
[
np.linspace(1.01, 6.0, 50), np.linspace(1.01, 6.0, 50),
]) ],
)
def test_no_nan(self, W): def test_no_nan(self, W):
ex = ExchangeCorrection(Z=92) ex = ExchangeCorrection(Z=92)
from tests.common.test_property_tests import _test_no_nan_across_range from tests.common.test_property_tests import _test_no_nan_across_range
_test_no_nan_across_range(ex, W, component_name="ExchangeCorrection") _test_no_nan_across_range(ex, W, component_name="ExchangeCorrection")
+14 -4
View File
@@ -26,19 +26,24 @@ class TestFermiAPI:
ff = FermiFunction(Z=20, A=40) ff = FermiFunction(Z=20, A=40)
assert "FermiFunction" in repr(ff) assert "FermiFunction" in repr(ff)
class TestFermiProperties: class TestFermiProperties:
"""Shared property tests for FermiFunction.""" """Shared property tests for FermiFunction."""
@pytest.mark.property @pytest.mark.property
@pytest.mark.parametrize("W", [ @pytest.mark.parametrize(
"W",
[
[1.5], [1.5],
[1.1, 2.0, 3.0], [1.1, 2.0, 3.0],
np.linspace(1.01, 5.0, 20), np.linspace(1.01, 5.0, 20),
]) ],
)
def test_positive(self, W): def test_positive(self, W):
"""F₀ must be positive everywhere — it's a probability enhancement factor.""" """F₀ must be positive everywhere — it's a probability enhancement factor."""
ff = FermiFunction(Z=92, A=238) # Uranium — extreme case ff = FermiFunction(Z=92, A=238) # Uranium — extreme case
from tests.common.test_property_tests import _test_positive_values from tests.common.test_property_tests import _test_positive_values
_test_positive_values(ff, W, component_name="FermiFunction") _test_positive_values(ff, W, component_name="FermiFunction")
@pytest.mark.quality @pytest.mark.quality
@@ -48,17 +53,22 @@ class TestFermiProperties:
ff = FermiFunction(Z=20, A=40) ff = FermiFunction(Z=20, A=40)
W = np.linspace(1.1, 3.0, n) W = np.linspace(1.1, 3.0, n)
from tests.common.test_property_tests import _test_output_shape_preserved from tests.common.test_property_tests import _test_output_shape_preserved
_test_output_shape_preserved(ff, W, component_name="FermiFunction") _test_output_shape_preserved(ff, W, component_name="FermiFunction")
@pytest.mark.quality @pytest.mark.quality
@pytest.mark.parametrize("W", [ @pytest.mark.parametrize(
"W",
[
np.linspace(1.01, 6.0, 50), np.linspace(1.01, 6.0, 50),
np.linspace(1.001, 10.0, 100), np.linspace(1.001, 10.0, 100),
]) ],
)
def test_no_nan(self, W): def test_no_nan(self, W):
"""For very heavy nuclei, the loggamma implementation must not overflow.""" """For very heavy nuclei, the loggamma implementation must not overflow."""
ff = FermiFunction(Z=92, A=238) ff = FermiFunction(Z=92, A=238)
from tests.common.test_property_tests import _test_no_nan_across_range from tests.common.test_property_tests import _test_no_nan_across_range
_test_no_nan_across_range(ff, W, component_name="FermiFunction") _test_no_nan_across_range(ff, W, component_name="FermiFunction")
@pytest.mark.quality @pytest.mark.quality
+15 -4
View File
@@ -23,15 +23,20 @@ class TestFiniteSizeL0API:
fs = FiniteSizeL0(Z=20, A=40) fs = FiniteSizeL0(Z=20, A=40)
assert "FiniteSizeL0" in repr(fs) assert "FiniteSizeL0" in repr(fs)
class TestFiniteSizeL0Properties: class TestFiniteSizeL0Properties:
@pytest.mark.property @pytest.mark.property
@pytest.mark.parametrize("W", [ @pytest.mark.parametrize(
"W",
[
[2.0], [2.0],
np.linspace(1.05, 6.0, 20), np.linspace(1.05, 6.0, 20),
]) ],
)
def test_positive(self, W): def test_positive(self, W):
fs = FiniteSizeL0(Z=92, A=238) fs = FiniteSizeL0(Z=92, A=238)
from tests.common.test_property_tests import _test_positive_values from tests.common.test_property_tests import _test_positive_values
_test_positive_values(fs, W, component_name="FiniteSizeL0") _test_positive_values(fs, W, component_name="FiniteSizeL0")
@pytest.mark.quality @pytest.mark.quality
@@ -40,6 +45,7 @@ class TestFiniteSizeL0Properties:
fs = FiniteSizeL0(Z=20, A=40) fs = FiniteSizeL0(Z=20, A=40)
W = np.linspace(1.1, 3.0, n) W = np.linspace(1.1, 3.0, n)
from tests.common.test_property_tests import _test_output_shape_preserved from tests.common.test_property_tests import _test_output_shape_preserved
_test_output_shape_preserved(fs, W, component_name="FiniteSizeL0") _test_output_shape_preserved(fs, W, component_name="FiniteSizeL0")
@@ -59,13 +65,17 @@ class TestChargeDistributionUAPI:
class TestChargeDistributionUProperties: class TestChargeDistributionUProperties:
@pytest.mark.property @pytest.mark.property
@pytest.mark.parametrize("W", [ @pytest.mark.parametrize(
"W",
[
[2.0], [2.0],
np.linspace(1.1, 5.0, 10), np.linspace(1.1, 5.0, 10),
]) ],
)
def test_non_negative(self, W): def test_non_negative(self, W):
u = ChargeDistributionU(Z=20, A=40) u = ChargeDistributionU(Z=20, A=40)
from tests.common.test_property_tests import _test_non_negative_values from tests.common.test_property_tests import _test_non_negative_values
_test_non_negative_values(u, W, component_name="ChargeDistributionU") _test_non_negative_values(u, W, component_name="ChargeDistributionU")
@pytest.mark.quality @pytest.mark.quality
@@ -74,4 +84,5 @@ class TestChargeDistributionUProperties:
u = ChargeDistributionU(Z=20, A=40) u = ChargeDistributionU(Z=20, A=40)
W = np.linspace(1.1, 3.0, n) W = np.linspace(1.1, 3.0, n)
from tests.common.test_property_tests import _test_output_shape_preserved from tests.common.test_property_tests import _test_output_shape_preserved
_test_output_shape_preserved(u, W, component_name="ChargeDistributionU") _test_output_shape_preserved(u, W, component_name="ChargeDistributionU")
+14 -4
View File
@@ -21,19 +21,24 @@ class TestPhaseSpaceAPI:
result = ps(np.array([3.0])) result = ps(np.array([3.0]))
assert isinstance(result, np.ndarray) assert isinstance(result, np.ndarray)
class TestPhaseSpaceProperties: class TestPhaseSpaceProperties:
"""Shared property tests for PhaseSpace.""" """Shared property tests for PhaseSpace."""
@pytest.mark.property @pytest.mark.property
@pytest.mark.parametrize("W", [ @pytest.mark.parametrize(
"W",
[
[1.5], [1.5],
[1.1, 2.0, 3.0], [1.1, 2.0, 3.0],
np.linspace(1.01, 4.9, 20), np.linspace(1.01, 4.9, 20),
]) ],
)
def test_non_negative(self, W): def test_non_negative(self, W):
"""Phase space must be non-negative (zero at boundaries).""" """Phase space must be non-negative (zero at boundaries)."""
ps = PhaseSpace(W0=5.0) ps = PhaseSpace(W0=5.0)
from tests.common.test_property_tests import _test_non_negative_values from tests.common.test_property_tests import _test_non_negative_values
_test_non_negative_values(ps, W, component_name="PhaseSpace") _test_non_negative_values(ps, W, component_name="PhaseSpace")
@pytest.mark.quality @pytest.mark.quality
@@ -43,15 +48,20 @@ class TestPhaseSpaceProperties:
ps = PhaseSpace(W0=5.0) ps = PhaseSpace(W0=5.0)
W = np.linspace(1.1, 4.9, n) W = np.linspace(1.1, 4.9, n)
from tests.common.test_property_tests import _test_output_shape_preserved from tests.common.test_property_tests import _test_output_shape_preserved
_test_output_shape_preserved(ps, W, component_name="PhaseSpace") _test_output_shape_preserved(ps, W, component_name="PhaseSpace")
@pytest.mark.quality @pytest.mark.quality
@pytest.mark.parametrize("W", [ @pytest.mark.parametrize(
"W",
[
np.linspace(1.01, 4.99, 50), np.linspace(1.01, 4.99, 50),
np.linspace(1.001, 4.999, 100), np.linspace(1.001, 4.999, 100),
]) ],
)
def test_stability(self, W): def test_stability(self, W):
"""Phase space must be stable across the full energy range.""" """Phase space must be stable across the full energy range."""
ps = PhaseSpace(W0=5.0) ps = PhaseSpace(W0=5.0)
from tests.common.test_property_tests import _test_full_range_stability from tests.common.test_property_tests import _test_full_range_stability
_test_full_range_stability(ps, W, component_name="PhaseSpace") _test_full_range_stability(ps, W, component_name="PhaseSpace")
+14 -4
View File
@@ -39,13 +39,17 @@ class TestRadiativeAPI:
class TestRadiativeProperties: class TestRadiativeProperties:
@pytest.mark.property @pytest.mark.property
@pytest.mark.parametrize("W", [ @pytest.mark.parametrize(
"W",
[
[2.0], [2.0],
np.linspace(1.1, 4.9, 20), np.linspace(1.1, 4.9, 20),
]) ],
)
def test_positive(self, W): def test_positive(self, W):
rc = RadiativeCorrection(W0=5.0) rc = RadiativeCorrection(W0=5.0)
from tests.common.test_property_tests import _test_positive_values from tests.common.test_property_tests import _test_positive_values
_test_positive_values(rc, W, component_name="RadiativeCorrection") _test_positive_values(rc, W, component_name="RadiativeCorrection")
@pytest.mark.quality @pytest.mark.quality
@@ -54,6 +58,7 @@ class TestRadiativeProperties:
rc = RadiativeCorrection(W0=5.0) rc = RadiativeCorrection(W0=5.0)
W = np.linspace(1.1, 4.9, n) W = np.linspace(1.1, 4.9, n)
from tests.common.test_property_tests import _test_output_shape_preserved from tests.common.test_property_tests import _test_output_shape_preserved
_test_output_shape_preserved(rc, W, component_name="RadiativeCorrection") _test_output_shape_preserved(rc, W, component_name="RadiativeCorrection")
@pytest.mark.quality @pytest.mark.quality
@@ -63,17 +68,22 @@ class TestRadiativeProperties:
rc = RadiativeCorrection(W0=W0) rc = RadiativeCorrection(W0=W0)
W = np.linspace(1.1, min(9.9, W0 - 0.01), 20) W = np.linspace(1.1, min(9.9, W0 - 0.01), 20)
from tests.common.test_property_tests import _test_no_nan_across_range from tests.common.test_property_tests import _test_no_nan_across_range
_test_no_nan_across_range(rc, W, component_name=f"RadiativeCorrection(W0={W0})") _test_no_nan_across_range(rc, W, component_name=f"RadiativeCorrection(W0={W0})")
@pytest.mark.quality @pytest.mark.quality
@pytest.mark.parametrize("W", [ @pytest.mark.parametrize(
"W",
[
np.linspace(1.0001, 4.9999, 200), np.linspace(1.0001, 4.9999, 200),
np.linspace(1.001, 9.99, 100), np.linspace(1.001, 9.99, 100),
]) ],
)
def _test_full_range_stability(self, W): def _test_full_range_stability(self, W):
"""Entire energy range from threshold to endpoint must be free of NaN/inf.""" """Entire energy range from threshold to endpoint must be free of NaN/inf."""
rc = RadiativeCorrection(W0=5.0, Z=92, use_endpoint_resummation=True) rc = RadiativeCorrection(W0=5.0, Z=92, use_endpoint_resummation=True)
from tests.common.test_property_tests import _test_full_range_stability from tests.common.test_property_tests import _test_full_range_stability
_test_full_range_stability(rc, W, component_name="RadiativeCorrection(Z=92)") _test_full_range_stability(rc, W, component_name="RadiativeCorrection(Z=92)")
@pytest.mark.quality @pytest.mark.quality
+8 -2
View File
@@ -26,16 +26,21 @@ class TestScreeningAPI:
sc = ScreeningCorrection(ff) sc = ScreeningCorrection(ff)
assert "Screening" in repr(sc) assert "Screening" in repr(sc)
class TestScreeningProperties: class TestScreeningProperties:
@pytest.mark.property @pytest.mark.property
@pytest.mark.parametrize("W", [ @pytest.mark.parametrize(
"W",
[
[2.0], [2.0],
np.linspace(1.02, 3.0, 20), np.linspace(1.02, 3.0, 20),
]) ],
)
def test_positive(self, W): def test_positive(self, W):
ff = FermiFunction(Z=40, A=95) ff = FermiFunction(Z=40, A=95)
sc = ScreeningCorrection(ff) sc = ScreeningCorrection(ff)
from tests.common.test_property_tests import _test_positive_values from tests.common.test_property_tests import _test_positive_values
_test_positive_values(sc, W, component_name="ScreeningCorrection") _test_positive_values(sc, W, component_name="ScreeningCorrection")
@pytest.mark.quality @pytest.mark.quality
@@ -45,4 +50,5 @@ class TestScreeningProperties:
sc = ScreeningCorrection(ff) sc = ScreeningCorrection(ff)
W = np.linspace(1.1, 3.0, n) W = np.linspace(1.1, 3.0, n)
from tests.common.test_property_tests import _test_output_shape_preserved from tests.common.test_property_tests import _test_output_shape_preserved
_test_output_shape_preserved(sc, W, component_name="ScreeningCorrection") _test_output_shape_preserved(sc, W, component_name="ScreeningCorrection")
+3 -3
View File
@@ -56,9 +56,9 @@ class TestNeutrinoMassPhaseSpace:
val_massive = ps_massive(W_near_endpoint)[0] val_massive = ps_massive(W_near_endpoint)[0]
assert not np.isnan(val_massive), "Should produce finite value" assert not np.isnan(val_massive), "Should produce finite value"
assert val_massive < val_zero, ( assert (
"Massive neutrino must suppress spectrum near endpoint" val_massive < val_zero
) ), "Massive neutrino must suppress spectrum near endpoint"
class TestCLIArguments: class TestCLIArguments: