docs: update documentation files and fix linting issues
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
+152
-68
@@ -12,25 +12,23 @@ 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"
|
||||||
source: str # which doc file
|
source: str # which doc file
|
||||||
section: str # which section
|
section: str # which section
|
||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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]}"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -75,8 +75,8 @@ class TestFermiPhysicalBehavior:
|
|||||||
"""
|
"""
|
||||||
ff = FermiFunction(Z=92, A=238)
|
ff = FermiFunction(Z=92, A=238)
|
||||||
|
|
||||||
W_low = np.array([1.01]) # Near threshold
|
W_low = np.array([1.01]) # Near threshold
|
||||||
W_mid = np.array([3.0]) # Mid-range
|
W_mid = np.array([3.0]) # Mid-range
|
||||||
|
|
||||||
f_low = ff(W_low)[0]
|
f_low = ff(W_low)[0]
|
||||||
f_mid = ff(W_mid)[0]
|
f_mid = ff(W_mid)[0]
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -21,20 +21,24 @@ 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):
|
||||||
"""Without resummation, δᵣ → −∞ as W → W₀ (logarithmic divergence)."""
|
"""Without resummation, δᵣ → −∞ as W → W₀ (logarithmic divergence)."""
|
||||||
rc = RadiativeCorrection(W0=5.0, use_endpoint_resummation=False)
|
rc = RadiativeCorrection(W0=5.0, use_endpoint_resummation=False)
|
||||||
|
|
||||||
W_far = np.array([4.9]) # ΔW = 0.1
|
W_far = np.array([4.9]) # ΔW = 0.1
|
||||||
W_close = np.array([4.99]) # ΔW = 0.01
|
W_close = np.array([4.99]) # ΔW = 0.01
|
||||||
|
|
||||||
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], (
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
[2.0],
|
"W",
|
||||||
np.linspace(1.01, 6.0, 50),
|
[
|
||||||
])
|
[2.0],
|
||||||
|
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(
|
||||||
np.linspace(1.01, 6.0, 50),
|
"W",
|
||||||
])
|
[
|
||||||
|
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")
|
||||||
|
|||||||
@@ -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(
|
||||||
[1.5],
|
"W",
|
||||||
[1.1, 2.0, 3.0],
|
[
|
||||||
np.linspace(1.01, 5.0, 20),
|
[1.5],
|
||||||
])
|
[1.1, 2.0, 3.0],
|
||||||
|
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(
|
||||||
np.linspace(1.01, 6.0, 50),
|
"W",
|
||||||
np.linspace(1.001, 10.0, 100),
|
[
|
||||||
])
|
np.linspace(1.01, 6.0, 50),
|
||||||
|
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
|
||||||
|
|||||||
@@ -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(
|
||||||
[2.0],
|
"W",
|
||||||
np.linspace(1.05, 6.0, 20),
|
[
|
||||||
])
|
[2.0],
|
||||||
|
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(
|
||||||
[2.0],
|
"W",
|
||||||
np.linspace(1.1, 5.0, 10),
|
[
|
||||||
])
|
[2.0],
|
||||||
|
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")
|
||||||
|
|||||||
@@ -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(
|
||||||
[1.5],
|
"W",
|
||||||
[1.1, 2.0, 3.0],
|
[
|
||||||
np.linspace(1.01, 4.9, 20),
|
[1.5],
|
||||||
])
|
[1.1, 2.0, 3.0],
|
||||||
|
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(
|
||||||
np.linspace(1.01, 4.99, 50),
|
"W",
|
||||||
np.linspace(1.001, 4.999, 100),
|
[
|
||||||
])
|
np.linspace(1.01, 4.99, 50),
|
||||||
|
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")
|
||||||
|
|||||||
@@ -39,13 +39,17 @@ class TestRadiativeAPI:
|
|||||||
|
|
||||||
class TestRadiativeProperties:
|
class TestRadiativeProperties:
|
||||||
@pytest.mark.property
|
@pytest.mark.property
|
||||||
@pytest.mark.parametrize("W", [
|
@pytest.mark.parametrize(
|
||||||
[2.0],
|
"W",
|
||||||
np.linspace(1.1, 4.9, 20),
|
[
|
||||||
])
|
[2.0],
|
||||||
|
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(
|
||||||
np.linspace(1.0001, 4.9999, 200),
|
"W",
|
||||||
np.linspace(1.001, 9.99, 100),
|
[
|
||||||
])
|
np.linspace(1.0001, 4.9999, 200),
|
||||||
|
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
|
||||||
|
|||||||
@@ -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(
|
||||||
[2.0],
|
"W",
|
||||||
np.linspace(1.02, 3.0, 20),
|
[
|
||||||
])
|
[2.0],
|
||||||
|
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")
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user