From be3e288e0d066d312fcc35be35cb9c5a215733ab Mon Sep 17 00:00:00 2001 From: Evgeniy Unzhakov Date: Tue, 7 Jul 2026 13:32:48 +0300 Subject: [PATCH] docs: update documentation files and fix linting issues --- README.md | 11 +- TODO.md | 18 +- exp_data/calibration.py | 2 - notebooks/01-beta-spectrum-calculator.ipynb | 7 - notebooks/exp_calibration.ipynb | 1 - scripts/docs_check.py | 220 ++++++++++++++------ scripts/notebook_plots.py | 1 - scripts/run_notebooks.py | 1 - tests/common/test_api_tests.py | 47 ++--- tests/common/test_hypothesis_tests.py | 16 +- tests/common/test_property_tests.py | 45 ++-- tests/integration/test_spectrum_pipeline.py | 1 + tests/physics/test_detector_response.py | 4 +- tests/physics/test_exchange.py | 12 +- tests/physics/test_fermi.py | 4 +- tests/physics/test_multi_branch.py | 18 +- tests/physics/test_neutrino_mass_effects.py | 54 ++--- tests/physics/test_phase_space.py | 6 +- tests/physics/test_radiative.py | 44 ++-- tests/quality/test_api_detector_response.py | 8 +- tests/quality/test_api_exchange.py | 24 ++- tests/quality/test_api_fermi.py | 28 ++- tests/quality/test_api_finite_size.py | 27 ++- tests/quality/test_api_phase_space.py | 28 ++- tests/quality/test_api_radiative.py | 26 ++- tests/quality/test_api_screening.py | 14 +- tests/quality/test_neutrino_mass_cli.py | 6 +- 27 files changed, 423 insertions(+), 250 deletions(-) diff --git a/README.md b/README.md index 6408807..4822f3b 100644 --- a/README.md +++ b/README.md @@ -62,8 +62,11 @@ beta_shape_pnpi/ │ │ ├── exchange.py ✓ Hayen-2018 empirical fit coefficients │ │ ├── radiative.py ✓ Outer radiative corrections, soft-photon resummation │ │ └── detector_response.py ✓ Analytical detector response models -├── beta_spectrum/visualize/ ← plotting / visualization -│ └── __init__.py # BetaSpectrumAnalyzer +├── beta_spectrum/output/ ← output, plotting, visualization, CSV export +│ ├── __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 │ ├── __init__.py # ExpSpectrum, EnergyCalibrator, GaussianFitter, etc. │ ├── spectrum.py # ExpSpectrum dataclass (energies, counts, errors, metadata) @@ -117,7 +120,7 @@ Defined in `beta_spectrum/spectrum.py`. Orchestrates the calculation: ### `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 - `plot_analysis(save_path=None, show_components=True)` — plot with two modes: @@ -133,7 +136,7 @@ ______________________________________________________________________ ```python from beta_spectrum import SpectrumConfig, BetaSpectrum -from beta_spectrum.visualize import BetaSpectrumAnalyzer +from beta_spectrum.output import BetaSpectrumAnalyzer config = SpectrumConfig( Z_parent=90, # Thorium-232 (example) diff --git a/TODO.md b/TODO.md index 81a3f20..f857d41 100644 --- a/TODO.md +++ b/TODO.md @@ -51,8 +51,11 @@ fitter/ ← main analysis framework ├── result.py ← FitResult, analysis results # └── report.py ← PDF report generation (deferred) -visualize/ ← visualization / plotting -└── visualize/__init__.py ← BetaSpectrumAnalyzer +output/ ← output, visualization, CSV export +├── __init__.py ← BetaSpectrumAnalyzer, CSVEmitter, Visualizer +├── core.py ← BetaSpectrumAnalyzer base class +├── csv_export.py ← CSV export functionality +└── visualization.py ← Plotting and visualization functionality ``` ### Rationale @@ -64,6 +67,7 @@ visualize/ ← visualization / plotting ### Migration Plan #### Phase 1: Create `exp_data` module ✅ COMPLETE + - [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/fitters.py` — `GaussianFitter` + `PeakFitter`: peak detection and fitting @@ -71,6 +75,7 @@ visualize/ ← visualization / plotting - [x] 43 tests, ruff + mypy clean #### Phase 2: Create `fitter` module ✅ COMPLETE + - [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/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 #### Phase 3: Clean up `beta_spectrum` ✅ COMPLETE + - [x] Remove `cw_extractor.py` (moved to `fitter/extractor.py`) - [x] Remove `fitter.py` (moved to `fitter/fit_engine.py`) - [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 #### Phase 4: Update package structure ✅ COMPLETE + - [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] All imports updated across 26 source files @@ -313,6 +320,7 @@ ______________________________________________________________________ **Version:** 0.3.1 → **In progress: 0.4.0 — Package restructuring** ### Completed (v0.3.x) + - 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 - χ² 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 ### Completed (v0.4.0) + - **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 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 - **v0.4.0 is COMPLETE** - 322 tests pass (262 original + 60 new) - ruff + mypy clean on all 26 source files - 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 - Version bumped to 0.4.0 ### Next Immediate Step + - **Track A: ⁹⁹Tc spectrum-shape analysis** — start real data processing pipeline - Data processing pipeline for experimental runs - Endpoint fitting and energy calibration diff --git a/exp_data/calibration.py b/exp_data/calibration.py index 014e37c..a0bcc74 100644 --- a/exp_data/calibration.py +++ b/exp_data/calibration.py @@ -395,8 +395,6 @@ class EnergyCalibrator: if best_pair is not None: ch_low, ch_high = best_pair 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_high)] = e_max diff --git a/notebooks/01-beta-spectrum-calculator.ipynb b/notebooks/01-beta-spectrum-calculator.ipynb index 15b4870..3a4f34c 100644 --- a/notebooks/01-beta-spectrum-calculator.ipynb +++ b/notebooks/01-beta-spectrum-calculator.ipynb @@ -42,15 +42,8 @@ " BranchConfig,\n", " BetaSpectrumAnalyzer,\n", " PhaseSpace,\n", - " FermiFunction,\n", - " FiniteSizeL0,\n", - " ChargeDistributionU,\n", - " ScreeningCorrection,\n", - " ExchangeCorrection,\n", - " RadiativeCorrection,\n", " T_to_W,\n", " W_to_T,\n", - " momentum,\n", " ME_MEV,\n", ")\n", "\n", diff --git a/notebooks/exp_calibration.ipynb b/notebooks/exp_calibration.ipynb index 07aa456..9747328 100644 --- a/notebooks/exp_calibration.ipynb +++ b/notebooks/exp_calibration.ipynb @@ -21,7 +21,6 @@ "metadata": {}, "outputs": [], "source": [ - "import matplotlib\n", "# Use non-interactive backend only when no display is available (e.g. jupyter execute)\n", "import matplotlib as mpl\n", "try:\n", diff --git a/scripts/docs_check.py b/scripts/docs_check.py index 378b119..71f7eba 100644 --- a/scripts/docs_check.py +++ b/scripts/docs_check.py @@ -12,25 +12,23 @@ Exit codes: """ import argparse -import json import os import re import subprocess import sys from dataclasses import dataclass, field from pathlib import Path -from typing import Optional - # --------------------------------------------------------------------------- # Types # --------------------------------------------------------------------------- + @dataclass class Finding: severity: str # "ERROR" | "WARN" | "INFO" - source: str # which doc file - section: str # which section + source: str # which doc file + section: str # which section message: str @@ -82,9 +80,20 @@ def all_source_files() -> list[Path]: """Walk the source tree and return all .py files.""" files = [] for root, dirs, filenames in os.walk(ROOT): - dirs[:] = [d for d in dirs if not d.startswith(".") - and d not in ("__pycache__", ".pytest_cache", ".mypy_cache", - ".ruff_cache", ".hypothesis", ".ipynb_checkpoints")] + dirs[:] = [ + d + 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: continue for fn in filenames: @@ -97,6 +106,7 @@ def all_source_files() -> list[Path]: # Checks # --------------------------------------------------------------------------- + def check_version_consistency(report: Report): """Check that __version__ matches pyproject.toml version.""" pyproject = ROOT / "pyproject.toml" @@ -116,8 +126,12 @@ def check_version_consistency(report: Report): ver = ver_match.group(1) if ver != py_ver: rel = pyfile.relative_to(ROOT) - report.add("ERROR", rel.name, "version", - f"__version__ = '{ver}' but pyproject.toml says '{py_ver}'") + report.add( + "ERROR", + rel.name, + "version", + f"__version__ = '{ver}' but pyproject.toml says '{py_ver}'", + ) def check_version_in_code(report: Report): @@ -134,16 +148,19 @@ def check_version_in_code(report: Report): try: result = subprocess.run( - ["bs_pnpi", "--version"], - capture_output=True, text=True, timeout=10 + ["bs_pnpi", "--version"], capture_output=True, text=True, timeout=10 ) 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: cli_ver = cli_ver_match.group(1) if cli_ver != py_ver: - report.add("ERROR", "cli", "version", - f"CLI reports version '{cli_ver}' but pyproject.toml says '{py_ver}'") + report.add( + "ERROR", + "cli", + "version", + f"CLI reports version '{cli_ver}' but pyproject.toml says '{py_ver}'", + ) except (subprocess.TimeoutExpired, FileNotFoundError): pass @@ -168,8 +185,10 @@ def check_file_references(report: Report): # Skip tree diagrams and multi-line code blocks. for line in content.split("\n"): # Find single-line inline code that looks like a file path - refs = re.findall(r'`([^`\n]+\.py[^`]*)`', line) - refs.extend(re.findall(r'`([^`\n]+\\.(csv|json|toml|md|yaml|png|log))`', line)) + refs = re.findall(r"`([^`\n]+\.py[^`]*)`", line) + refs.extend( + re.findall(r"`([^`\n]+\\.(csv|json|toml|md|yaml|png|log))`", line) + ) for ref in refs: if isinstance(ref, tuple): ref = ref[0] @@ -183,20 +202,40 @@ def check_file_references(report: Report): if target.exists(): continue # Filter out tree-diagram artifacts - if any(c in ref for c in ('\u2190', '\u2192', '\u2713', '\u2717', - '[', ']', '|', '\u2502', '\u2514', '\u251c', - '\u2500', '\u2502', '\u2560', '\u256c')): + if any( + c in ref + for c in ( + "\u2190", + "\u2192", + "\u2713", + "\u2717", + "[", + "]", + "|", + "\u2502", + "\u2514", + "\u251c", + "\u2500", + "\u2502", + "\u2560", + "\u256c", + ) + ): continue # 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] if basename in known_removed or basename in known_placeholders: continue # Skip bare __init__.py (common in tree diagrams, not actionable) if basename == "__init__.py": continue - report.add("WARN", doc_name, "file-reference", - f"Referenced file not found: `{ref}`") + report.add( + "WARN", + doc_name, + "file-reference", + f"Referenced file not found: `{ref}`", + ) def check_cli_commands(report: Report): @@ -207,12 +246,15 @@ def check_cli_commands(report: Report): if "bs_pnpi" in agents_md: try: result = subprocess.run( - ["bs_pnpi", "--help"], - capture_output=True, text=True, timeout=10 + ["bs_pnpi", "--help"], capture_output=True, text=True, timeout=10 ) if result.returncode != 0: - report.add("WARN", "AGENTS.md", "cli", - f"`bs_pnpi --help` failed: {result.stderr.strip()[:100]}") + report.add( + "WARN", + "AGENTS.md", + "cli", + f"`bs_pnpi --help` failed: {result.stderr.strip()[:100]}", + ) except (subprocess.TimeoutExpired, FileNotFoundError): 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: try: result = subprocess.run( - ["bs_exp", "--help"], - capture_output=True, text=True, timeout=10 + ["bs_exp", "--help"], capture_output=True, text=True, timeout=10 ) if result.returncode != 0: - report.add("WARN", "AGENTS.md", "cli", - f"`bs_exp --help` failed: {result.stderr.strip()[:100]}") + report.add( + "WARN", + "AGENTS.md", + "cli", + f"`bs_exp --help` failed: {result.stderr.strip()[:100]}", + ) except (subprocess.TimeoutExpired, FileNotFoundError): 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.""" 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 line in block.strip().split("\n"): line = line.strip() if not line.startswith("from ") and not line.startswith("import "): continue - match = re.match(r'from\s+(\S+)\s+import', line) + match = re.match(r"from\s+(\S+)\s+import", line) if not match: - match = re.match(r'import\s+(\S+)', line) + match = re.match(r"import\s+(\S+)", line) if not match: continue @@ -250,8 +295,12 @@ def check_imports_in_examples(report: Report): try: __import__(module) except ImportError: - report.add("WARN", "README.md", "imports", - f"Cannot import module in example: `{module}`") + report.add( + "WARN", + "README.md", + "imports", + f"Cannot import module in example: `{module}`", + ) 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): """Extract file references from a markdown tree diagram and check existence.""" # 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: lines = tree.split("\n") for i, line in enumerate(lines): # 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: filename = match.group(1) # 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 for j in range(i - 1, max(i - 10, -1), -1): 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: dir_depth = len(bl) - len(bl.lstrip()) if dir_depth < depth: dirs.append(dir_match.group(1)) 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: break # stop looking after first non-matching content line if dirs and len(dirs) <= 3: # cap at 3 levels target = ROOT / "/".join(reversed(dirs)) / filename if not target.exists(): - report.add("WARN", doc_name, "structure", - f"File in structure diagram not found: `{'/'.join(reversed(dirs))}/{filename}`") + report.add( + "WARN", + doc_name, + "structure", + f"File in structure diagram not found: `{'/'.join(reversed(dirs))}/{filename}`", + ) def check_cli_args_consistency(report: Report): @@ -311,30 +369,44 @@ def check_cli_args_consistency(report: Report): try: result = subprocess.run( - ["bs_pnpi", "--help"], - capture_output=True, text=True, timeout=10 + ["bs_pnpi", "--help"], capture_output=True, text=True, timeout=10 ) if result.returncode == 0: actual_flags = set() 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: actual_flags.add(flag_match.group(1)) - known_flags = {"-v", "-vv", "-q", "--nuclide", "--output", - "--input", "--dry-run", "--version", "--plot", - "--log-file", "--intensity-cutoff", "--help", - "-h"} + known_flags = { + "-v", + "-vv", + "-q", + "--nuclide", + "--output", + "--input", + "--dry-run", + "--version", + "--plot", + "--log-file", + "--intensity-cutoff", + "--help", + "-h", + } mentioned = set() # 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) - 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) unknown = mentioned - known_flags for flag in sorted(unknown): - report.add("INFO", "AGENTS.md", "cli-args", - f"Flag `{flag}` mentioned but not in --help (may be custom)") + report.add( + "INFO", + "AGENTS.md", + "cli-args", + f"Flag `{flag}` mentioned but not in --help (may be custom)", + ) except (subprocess.TimeoutExpired, FileNotFoundError): pass @@ -350,8 +422,9 @@ def check_markdown_formatting(report: Report): content = path.read_text() for i, line in enumerate(content.split("\n"), 1): if line != line.rstrip(): - report.add("INFO", doc_name, f"line {i}", - "Trailing whitespace detected") + report.add( + "INFO", doc_name, f"line {i}", "Trailing whitespace detected" + ) 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 # 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: # 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 # Look for specific file claims - file_claims = re.findall(r'`([^`]+\.py)`', block) + file_claims = re.findall(r"`([^`]+\.py)`", block) for fc in file_claims: target = ROOT / fc if not target.exists(): - report.add("WARN", "TODO.md", "completions", - f"Completed item references non-existent file: `{fc}`") + report.add( + "WARN", + "TODO.md", + "completions", + f"Completed item references non-existent file: `{fc}`", + ) # --------------------------------------------------------------------------- @@ -395,12 +474,10 @@ CHECKS = [ def main(): parser = argparse.ArgumentParser(description="Check documentation consistency") parser.add_argument( - "--strict", action="store_true", - help="Treat warnings as errors (exit 1)" + "--strict", action="store_true", help="Treat warnings as errors (exit 1)" ) parser.add_argument( - "--json", action="store_true", dest="as_json", - help="Output findings as JSON" + "--json", action="store_true", dest="as_json", help="Output findings as JSON" ) args = parser.parse_args() @@ -414,15 +491,22 @@ def main(): # Output if args.as_json: import json as _json + 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 ] print(_json.dumps(output, indent=2)) else: - print(f"docs_check: {len(report.findings)} findings " - f"({len(report.errors)} errors, {len(report.warnings)} warnings)") + print( + f"docs_check: {len(report.findings)} findings " + f"({len(report.errors)} errors, {len(report.warnings)} warnings)" + ) print(report.summary()) # Exit diff --git a/scripts/notebook_plots.py b/scripts/notebook_plots.py index 654dee9..3963ca7 100644 --- a/scripts/notebook_plots.py +++ b/scripts/notebook_plots.py @@ -10,7 +10,6 @@ Each plot gets a descriptive filename based on notebook name and cell index. """ import json -import os import sys from pathlib import Path diff --git a/scripts/run_notebooks.py b/scripts/run_notebooks.py index 08e9cea..6c61427 100644 --- a/scripts/run_notebooks.py +++ b/scripts/run_notebooks.py @@ -14,7 +14,6 @@ Usage: """ import json -import os import sys import base64 from pathlib import Path diff --git a/tests/common/test_api_tests.py b/tests/common/test_api_tests.py index f9722a7..127b853 100644 --- a/tests/common/test_api_tests.py +++ b/tests/common/test_api_tests.py @@ -36,54 +36,49 @@ import pytest @pytest.mark.api def _test_component_is_callable(component, component_name="Component"): """Component must be callable with array input.""" - assert callable(component), ( - f"{component_name}: component is not callable" - ) + assert callable(component), f"{component_name}: component is not callable" W = np.array([1.5, 2.0, 3.0]) result = component(W) assert isinstance(result, np.ndarray), ( - f"{component_name}: __call__ returned {type(result)}, " - f"expected np.ndarray" - ) - assert result.shape == W.shape, ( - f"{component_name}: shape mismatch {W.shape} → {result.shape}" + f"{component_name}: __call__ returned {type(result)}, " f"expected np.ndarray" ) + assert ( + result.shape == W.shape + ), f"{component_name}: shape mismatch {W.shape} → {result.shape}" @pytest.mark.api def _test_component_repr(component, component_name="Component"): """Component must have a non-empty repr.""" repr_str = repr(component) - assert isinstance(repr_str, str), ( - f"{component_name}: repr returned {type(repr_str)}" - ) - assert len(repr_str) > 0, ( - f"{component_name}: repr is empty string" - ) + assert isinstance( + repr_str, str + ), f"{component_name}: repr returned {type(repr_str)}" + assert len(repr_str) > 0, f"{component_name}: repr is empty string" # repr should contain the class name - assert component_name in repr_str or component.__class__.__name__ in repr_str, ( - f"{component_name}: repr should contain class name. Got: {repr_str}" - ) + assert ( + 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 def _test_component_list_input(component, component_name="Component"): """Component must handle Python list input (numpy converts it).""" result = component([1.5, 2.0, 3.0]) - assert isinstance(result, np.ndarray), ( - f"{component_name}: list input returned {type(result)}" - ) + assert isinstance( + result, np.ndarray + ), f"{component_name}: list input returned {type(result)}" @pytest.mark.api def _test_component_empty_input(component, component_name="Component"): """Component must handle empty array input gracefully.""" result = component(np.array([])) - assert isinstance(result, np.ndarray), ( - f"{component_name}: empty input returned {type(result)}" - ) - assert result.shape == (0,), ( - f"{component_name}: empty input should return empty array" - ) + assert isinstance( + result, np.ndarray + ), f"{component_name}: empty input returned {type(result)}" + assert result.shape == ( + 0, + ), f"{component_name}: empty input should return empty array" diff --git a/tests/common/test_hypothesis_tests.py b/tests/common/test_hypothesis_tests.py index 8c47172..191020f 100644 --- a/tests/common/test_hypothesis_tests.py +++ b/tests/common/test_hypothesis_tests.py @@ -10,20 +10,18 @@ seconds to complete. They are intended for CI/full test runs, not for fast feedback during development. """ -from typing import List import numpy as np -import pytest from hypothesis import given, settings, strategies as st from beta_spectrum.components.fermi import FermiFunction from beta_spectrum.components.phase_space import PhaseSpace - # --------------------------------------------------------------------------- # Fermi function: random Z, A, W combinations # --------------------------------------------------------------------------- + @given( Z=st.integers(min_value=1, max_value=92), 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) assert np.all(diffs <= 0), ( - f"Fermi function not monotonically decreasing for Z={Z}: " - f"diffs = {diffs}" + f"Fermi function not monotonically decreasing for Z={Z}: " f"diffs = {diffs}" ) @@ -65,6 +62,7 @@ def test_fermi_monotonic_for_fixed_Z(Z: int): # Phase space: random W0, W combinations # --------------------------------------------------------------------------- + @given( 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), @@ -91,7 +89,9 @@ def test_phase_space_threshold_at_W1(W0: float): ps = PhaseSpace(W0=W0) 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( @@ -103,4 +103,6 @@ def test_phase_space_endpoint_at_W0(W0: float): ps = PhaseSpace(W0=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]}" diff --git a/tests/common/test_property_tests.py b/tests/common/test_property_tests.py index 267807c..9731f0f 100644 --- a/tests/common/test_property_tests.py +++ b/tests/common/test_property_tests.py @@ -52,11 +52,11 @@ or only quality tests (`pytest -m quality`) for fast feedback during development import numpy as np import pytest - # --------------------------------------------------------------------------- # Property tests: positivity # --------------------------------------------------------------------------- + @pytest.mark.property def _test_positive_values(component, W, component_name="Component"): """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"result range: [{np.nanmin(result):.6f}, {np.nanmax(result):.6f}]" ) - assert not np.any(np.isinf(result)), ( - f"{component_name}: Inf values found in output." - ) + assert not np.any( + np.isinf(result) + ), f"{component_name}: Inf values found in output." assert np.all(result > 0), ( f"{component_name}: non-positive values found. " 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 -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). 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) result = component(W_arr) - assert not np.any(np.isnan(result)), ( - f"{component_name}: NaN values found in output." - ) - assert not np.any(np.isinf(result)), ( - f"{component_name}: Inf values found in output." - ) + assert not np.any( + np.isnan(result) + ), f"{component_name}: NaN values found in output." + assert not np.any( + np.isinf(result) + ), f"{component_name}: Inf values found in output." if allow_zero: assert np.all(result >= 0), ( - f"{component_name}: negative values found. " - f"Min = {np.min(result):.10f}" + f"{component_name}: negative values found. " f"Min = {np.min(result):.10f}" ) else: 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 # --------------------------------------------------------------------------- + @pytest.mark.quality def _test_output_shape_preserved(component, W, component_name="Component"): """Output shape must match input shape — no flattening or reshaping.""" W_arr = np.asarray(W, dtype=float) result = component(W_arr) - assert isinstance(result, np.ndarray), ( - f"{component_name}: output is {type(result)}, expected np.ndarray" - ) + assert isinstance( + result, np.ndarray + ), f"{component_name}: output is {type(result)}, expected np.ndarray" assert result.shape == W_arr.shape, ( f"{component_name}: shape mismatch. " 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"expected np.ndarray" ) - assert result.ndim == 1, ( - f"{component_name}: expected 1D output, got {result.ndim}D" - ) + assert result.ndim == 1, f"{component_name}: expected 1D output, got {result.ndim}D" # --------------------------------------------------------------------------- # Property tests: numerical stability # --------------------------------------------------------------------------- + @pytest.mark.quality def _test_no_nan_across_range(component, W, component_name="Component"): """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 # --------------------------------------------------------------------------- + @pytest.mark.quality def _test_output_is_ndarray(component, W, component_name="Component"): """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 # --------------------------------------------------------------------------- + def make_positive_test(component_class, test_params, component_name=None): """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) def test_positive(component_params, W_arr): comp = component_class(**component_params) - test_positive_values(comp, W_arr, component_name) + _test_positive_values(comp, W_arr, component_name) return test_positive @@ -278,6 +281,6 @@ def make_stability_test(component_class, test_params, component_name=None): @pytest.mark.parametrize("W", W_values) def test_stability(component_params, W_arr): 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 diff --git a/tests/integration/test_spectrum_pipeline.py b/tests/integration/test_spectrum_pipeline.py index 39358ec..47c6690 100644 --- a/tests/integration/test_spectrum_pipeline.py +++ b/tests/integration/test_spectrum_pipeline.py @@ -178,6 +178,7 @@ class TestDeclarativeDetectorResponse: detector = BetaSpectrum.create_detector_from_config(config) + from beta_spectrum.components.detector_response import DetectorResponse assert isinstance(detector, DetectorResponse) assert detector.n_channels == 512 assert detector._mode == "analytical" diff --git a/tests/physics/test_detector_response.py b/tests/physics/test_detector_response.py index d05d3bd..c8a5a7a 100644 --- a/tests/physics/test_detector_response.py +++ b/tests/physics/test_detector_response.py @@ -13,7 +13,9 @@ except ImportError: 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: """Test pure Gaussian detector response.""" diff --git a/tests/physics/test_exchange.py b/tests/physics/test_exchange.py index f25ff3b..35d7631 100644 --- a/tests/physics/test_exchange.py +++ b/tests/physics/test_exchange.py @@ -22,7 +22,9 @@ class TestExchangePhysics: ex = ExchangeCorrection(Z=80) low_W = np.array([1.1]) 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 def test_grows_as_energy_decreases(self): @@ -35,7 +37,9 @@ class TestExchangePhysics: s_low = ex(low_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 def test_vanishes_at_high_energy(self): @@ -43,7 +47,9 @@ class TestExchangePhysics: ex = ExchangeCorrection(Z=40) high_W = np.array([3.0, 5.0]) 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: diff --git a/tests/physics/test_fermi.py b/tests/physics/test_fermi.py index e491eb7..3e0d1f2 100644 --- a/tests/physics/test_fermi.py +++ b/tests/physics/test_fermi.py @@ -75,8 +75,8 @@ class TestFermiPhysicalBehavior: """ ff = FermiFunction(Z=92, A=238) - W_low = np.array([1.01]) # Near threshold - W_mid = np.array([3.0]) # Mid-range + W_low = np.array([1.01]) # Near threshold + W_mid = np.array([3.0]) # Mid-range f_low = ff(W_low)[0] f_mid = ff(W_mid)[0] diff --git a/tests/physics/test_multi_branch.py b/tests/physics/test_multi_branch.py index d3981f3..f81a6c8 100644 --- a/tests/physics/test_multi_branch.py +++ b/tests/physics/test_multi_branch.py @@ -660,9 +660,9 @@ class TestMultiBranchEdgeCases: W, _ = spectrum.get_energy_grid(config) values = spectrum(W) - assert np.all(np.isfinite(values)), ( - "High-Z multi-branch must not produce NaN/inf" - ) + assert np.all( + np.isfinite(values) + ), "High-Z multi-branch must not produce NaN/inf" def test_no_branches_defaults_to_single(self): """No branches list should default to single-branch mode.""" @@ -727,9 +727,9 @@ class TestMultiBranchEdgeCases: contrib = universal_product * branch.intensity * bs[i] # Branch contribution should not exceed total - assert np.all(contrib <= total + 1e-10), ( - f"Branch {i} contribution exceeds total at some points" - ) + assert np.all( + contrib <= total + 1e-10 + ), f"Branch {i} contribution exceeds total at some points" # Branch contribution should be non-negative 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" # Sum of branch integrals should equal total - assert np.isclose(sum(integrals), total_integral, rtol=1e-6), ( - "Sum of branch integrals should equal total integral" - ) + assert np.isclose( + sum(integrals), total_integral, rtol=1e-6 + ), "Sum of branch integrals should equal total integral" diff --git a/tests/physics/test_neutrino_mass_effects.py b/tests/physics/test_neutrino_mass_effects.py index 14a1f3b..6a84cc6 100644 --- a/tests/physics/test_neutrino_mass_effects.py +++ b/tests/physics/test_neutrino_mass_effects.py @@ -20,12 +20,12 @@ class TestNeutrinoMassPhaseSpaceEffects: result = ps(W_test) # Should not produce NaN values - assert not np.any(np.isnan(result)), ( - "Phase space with m_nu=0 should not produce NaN values" - ) - assert np.all(result > 0), ( - "Phase space with m_nu=0 should be positive in physical range" - ) + assert not np.any( + np.isnan(result) + ), "Phase space with m_nu=0 should not produce NaN values" + assert np.all( + result > 0 + ), "Phase space with m_nu=0 should be positive in physical range" def test_phase_space_with_nonzero_neutrino_mass(self): """Test that phase space calculation works with non-zero neutrino mass.""" @@ -34,12 +34,12 @@ class TestNeutrinoMassPhaseSpaceEffects: result = ps(W_test) # Should not produce NaN values for energies above threshold - assert not np.any(np.isnan(result)), ( - "Phase space with m_nu>0 should not produce NaN values above threshold" - ) - assert np.all(result > 0), ( - "Phase space with m_nu>0 should be positive above threshold" - ) + assert not np.any( + np.isnan(result) + ), "Phase space with m_nu>0 should not produce NaN values above threshold" + assert np.all( + result > 0 + ), "Phase space with m_nu>0 should be positive above threshold" def test_phase_space_with_neutrino_mass_near_endpoint(self): """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) W_above_threshold = np.array([4.8]) # W0-W = 0.2 > m_nu=0.1 result_above = ps(W_above_threshold) - assert not np.any(np.isnan(result_above)), ( - "Should produce finite value above threshold" - ) + assert not np.any( + np.isnan(result_above) + ), "Should produce finite value above threshold" # Test energies where W0-W < m_nu (should be handled properly) # 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 result_below = ps(W_below_threshold) # After our fix, NaN values should be replaced with 0 - assert np.all(result_below == 0.0) or np.all(np.isnan(result_below)), ( - "Should handle below-threshold energies properly (either 0 or NaN)" - ) + assert np.all(result_below == 0.0) or np.all( + np.isnan(result_below) + ), "Should handle below-threshold energies properly (either 0 or NaN)" def test_spectrum_config_with_neutrino_mass(self): """Test that SpectrumConfig accepts and stores neutrino mass parameter.""" @@ -72,9 +72,9 @@ class TestNeutrinoMassPhaseSpaceEffects: e_step_MeV=0.001, neutrino_mass_MeV=0.01, ) - assert config.neutrino_mass_MeV == 0.01, ( - "SpectrumConfig should store neutrino mass parameter" - ) + assert ( + config.neutrino_mass_MeV == 0.01 + ), "SpectrumConfig should store neutrino mass parameter" def test_beta_spectrum_with_neutrino_mass(self): """Test that BetaSpectrum works with neutrino mass in configuration.""" @@ -96,12 +96,12 @@ class TestNeutrinoMassPhaseSpaceEffects: result = spectrum(W) # Should not produce NaN values - assert not np.any(np.isnan(result)), ( - "BetaSpectrum with neutrino mass should not produce NaN values" - ) - assert np.all(result >= 0), ( - "BetaSpectrum with neutrino mass should be non-negative" - ) + assert not np.any( + np.isnan(result) + ), "BetaSpectrum with neutrino mass should not produce NaN values" + assert np.all( + result >= 0 + ), "BetaSpectrum with neutrino mass should be non-negative" class TestNeutrinoMassCLIIntegration: diff --git a/tests/physics/test_phase_space.py b/tests/physics/test_phase_space.py index 1adf3a2..95356da 100644 --- a/tests/physics/test_phase_space.py +++ b/tests/physics/test_phase_space.py @@ -74,9 +74,9 @@ class TestPhaseSpaceNeutrinoMass: val_massive = ps_massive(W_near_endpoint)[0] assert not np.isnan(val_massive), "Should produce finite value" - assert val_massive < val_zero, ( - "Massive neutrino must suppress spectrum near endpoint" - ) + assert ( + val_massive < val_zero + ), "Massive neutrino must suppress spectrum near endpoint" @pytest.mark.physics def test_massive_neutrino_below_threshold(self): diff --git a/tests/physics/test_radiative.py b/tests/physics/test_radiative.py index ca66e10..401a195 100644 --- a/tests/physics/test_radiative.py +++ b/tests/physics/test_radiative.py @@ -21,20 +21,24 @@ class TestRadiativeEndpointHandling: result = rc(W_near_end) 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 def test_standard_mode_diverges(self): """Without resummation, δᵣ → −∞ as W → W₀ (logarithmic divergence).""" 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 val_far = rc(W_far)[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: @@ -51,7 +55,9 @@ class TestRadiativeEnergyDependence: r_mid = rc(mid_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: @@ -73,8 +79,12 @@ class TestRadiativeResummationSwitch: r_standard = rc_standard(W_near)[0] 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 r_resummed > r_standard, f"Near endpoint: resummed={r_resummed} > standard={r_standard}" + assert not np.isnan( + 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 def test_resummation_only_near_endpoint(self): @@ -87,9 +97,9 @@ class TestRadiativeResummationSwitch: r_resummed_far = rc_resummed(W_far)[0] r_standard_far = rc_standard(W_far)[0] - assert np.isclose(r_resummed_far, r_standard_far, rtol=1e-10), ( - f"Far from endpoint: resummed={r_resummed_far}, standard={r_standard_far}" - ) + assert np.isclose( + 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 W_near = np.array([4.9995]) @@ -121,7 +131,9 @@ class TestRadiativeZDependence: W_test = np.linspace(1.5, 4.5, 10) 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 def test_z_correction_magnitude_reasonable(self): @@ -129,7 +141,9 @@ class TestRadiativeZDependence: rc = RadiativeCorrection(W0=5.0, Z=92, use_endpoint_resummation=True) W_test = np.array([3.0]) 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: @@ -138,8 +152,12 @@ class TestRadiativeAParameter: @pytest.mark.physics def test_a_affects_nuclear_model_correction(self): """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_a140 = RadiativeCorrection(W0=5.0, Z=50, A=140, use_endpoint_resummation=True) + rc_a100 = RadiativeCorrection( + 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]) assert rc_a100(W_test)[0] != rc_a140(W_test)[0], ( diff --git a/tests/quality/test_api_detector_response.py b/tests/quality/test_api_detector_response.py index b610d17..cfe4406 100644 --- a/tests/quality/test_api_detector_response.py +++ b/tests/quality/test_api_detector_response.py @@ -13,7 +13,9 @@ except ImportError: 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: """Test convenience factory methods.""" @@ -56,7 +58,9 @@ class TestFactoryMethods: 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: """Test edge cases and error handling.""" diff --git a/tests/quality/test_api_exchange.py b/tests/quality/test_api_exchange.py index f38a5d8..9a01127 100644 --- a/tests/quality/test_api_exchange.py +++ b/tests/quality/test_api_exchange.py @@ -18,15 +18,20 @@ class TestExchangeAPI: result = ex(np.array([2.0])) assert isinstance(result, np.ndarray) + class TestExchangeProperties: @pytest.mark.property - @pytest.mark.parametrize("W", [ - [2.0], - np.linspace(1.01, 6.0, 50), - ]) + @pytest.mark.parametrize( + "W", + [ + [2.0], + np.linspace(1.01, 6.0, 50), + ], + ) def test_positive(self, W): ex = ExchangeCorrection(Z=92) from tests.common.test_property_tests import _test_positive_values + _test_positive_values(ex, W, component_name="ExchangeCorrection") @pytest.mark.quality @@ -35,13 +40,18 @@ class TestExchangeProperties: ex = ExchangeCorrection(Z=30) W = np.linspace(1.1, 3.0, n) from tests.common.test_property_tests import _test_output_shape_preserved + _test_output_shape_preserved(ex, W, component_name="ExchangeCorrection") @pytest.mark.quality - @pytest.mark.parametrize("W", [ - np.linspace(1.01, 6.0, 50), - ]) + @pytest.mark.parametrize( + "W", + [ + np.linspace(1.01, 6.0, 50), + ], + ) def test_no_nan(self, W): ex = ExchangeCorrection(Z=92) from tests.common.test_property_tests import _test_no_nan_across_range + _test_no_nan_across_range(ex, W, component_name="ExchangeCorrection") diff --git a/tests/quality/test_api_fermi.py b/tests/quality/test_api_fermi.py index ee7c1b3..2eb19f2 100644 --- a/tests/quality/test_api_fermi.py +++ b/tests/quality/test_api_fermi.py @@ -26,19 +26,24 @@ class TestFermiAPI: ff = FermiFunction(Z=20, A=40) assert "FermiFunction" in repr(ff) + class TestFermiProperties: """Shared property tests for FermiFunction.""" @pytest.mark.property - @pytest.mark.parametrize("W", [ - [1.5], - [1.1, 2.0, 3.0], - np.linspace(1.01, 5.0, 20), - ]) + @pytest.mark.parametrize( + "W", + [ + [1.5], + [1.1, 2.0, 3.0], + np.linspace(1.01, 5.0, 20), + ], + ) def test_positive(self, W): """F₀ must be positive everywhere — it's a probability enhancement factor.""" ff = FermiFunction(Z=92, A=238) # Uranium — extreme case from tests.common.test_property_tests import _test_positive_values + _test_positive_values(ff, W, component_name="FermiFunction") @pytest.mark.quality @@ -48,17 +53,22 @@ class TestFermiProperties: ff = FermiFunction(Z=20, A=40) W = np.linspace(1.1, 3.0, n) from tests.common.test_property_tests import _test_output_shape_preserved + _test_output_shape_preserved(ff, W, component_name="FermiFunction") @pytest.mark.quality - @pytest.mark.parametrize("W", [ - np.linspace(1.01, 6.0, 50), - np.linspace(1.001, 10.0, 100), - ]) + @pytest.mark.parametrize( + "W", + [ + np.linspace(1.01, 6.0, 50), + np.linspace(1.001, 10.0, 100), + ], + ) def test_no_nan(self, W): """For very heavy nuclei, the loggamma implementation must not overflow.""" ff = FermiFunction(Z=92, A=238) from tests.common.test_property_tests import _test_no_nan_across_range + _test_no_nan_across_range(ff, W, component_name="FermiFunction") @pytest.mark.quality diff --git a/tests/quality/test_api_finite_size.py b/tests/quality/test_api_finite_size.py index d05908a..05fcdc9 100644 --- a/tests/quality/test_api_finite_size.py +++ b/tests/quality/test_api_finite_size.py @@ -23,15 +23,20 @@ class TestFiniteSizeL0API: fs = FiniteSizeL0(Z=20, A=40) assert "FiniteSizeL0" in repr(fs) + class TestFiniteSizeL0Properties: @pytest.mark.property - @pytest.mark.parametrize("W", [ - [2.0], - np.linspace(1.05, 6.0, 20), - ]) + @pytest.mark.parametrize( + "W", + [ + [2.0], + np.linspace(1.05, 6.0, 20), + ], + ) def test_positive(self, W): fs = FiniteSizeL0(Z=92, A=238) from tests.common.test_property_tests import _test_positive_values + _test_positive_values(fs, W, component_name="FiniteSizeL0") @pytest.mark.quality @@ -40,6 +45,7 @@ class TestFiniteSizeL0Properties: fs = FiniteSizeL0(Z=20, A=40) W = np.linspace(1.1, 3.0, n) from tests.common.test_property_tests import _test_output_shape_preserved + _test_output_shape_preserved(fs, W, component_name="FiniteSizeL0") @@ -59,13 +65,17 @@ class TestChargeDistributionUAPI: class TestChargeDistributionUProperties: @pytest.mark.property - @pytest.mark.parametrize("W", [ - [2.0], - np.linspace(1.1, 5.0, 10), - ]) + @pytest.mark.parametrize( + "W", + [ + [2.0], + np.linspace(1.1, 5.0, 10), + ], + ) def test_non_negative(self, W): u = ChargeDistributionU(Z=20, A=40) from tests.common.test_property_tests import _test_non_negative_values + _test_non_negative_values(u, W, component_name="ChargeDistributionU") @pytest.mark.quality @@ -74,4 +84,5 @@ class TestChargeDistributionUProperties: u = ChargeDistributionU(Z=20, A=40) W = np.linspace(1.1, 3.0, n) from tests.common.test_property_tests import _test_output_shape_preserved + _test_output_shape_preserved(u, W, component_name="ChargeDistributionU") diff --git a/tests/quality/test_api_phase_space.py b/tests/quality/test_api_phase_space.py index ee06968..0601667 100644 --- a/tests/quality/test_api_phase_space.py +++ b/tests/quality/test_api_phase_space.py @@ -21,19 +21,24 @@ class TestPhaseSpaceAPI: result = ps(np.array([3.0])) assert isinstance(result, np.ndarray) + class TestPhaseSpaceProperties: """Shared property tests for PhaseSpace.""" @pytest.mark.property - @pytest.mark.parametrize("W", [ - [1.5], - [1.1, 2.0, 3.0], - np.linspace(1.01, 4.9, 20), - ]) + @pytest.mark.parametrize( + "W", + [ + [1.5], + [1.1, 2.0, 3.0], + np.linspace(1.01, 4.9, 20), + ], + ) def test_non_negative(self, W): """Phase space must be non-negative (zero at boundaries).""" ps = PhaseSpace(W0=5.0) from tests.common.test_property_tests import _test_non_negative_values + _test_non_negative_values(ps, W, component_name="PhaseSpace") @pytest.mark.quality @@ -43,15 +48,20 @@ class TestPhaseSpaceProperties: ps = PhaseSpace(W0=5.0) W = np.linspace(1.1, 4.9, n) from tests.common.test_property_tests import _test_output_shape_preserved + _test_output_shape_preserved(ps, W, component_name="PhaseSpace") @pytest.mark.quality - @pytest.mark.parametrize("W", [ - np.linspace(1.01, 4.99, 50), - np.linspace(1.001, 4.999, 100), - ]) + @pytest.mark.parametrize( + "W", + [ + np.linspace(1.01, 4.99, 50), + np.linspace(1.001, 4.999, 100), + ], + ) def test_stability(self, W): """Phase space must be stable across the full energy range.""" ps = PhaseSpace(W0=5.0) from tests.common.test_property_tests import _test_full_range_stability + _test_full_range_stability(ps, W, component_name="PhaseSpace") diff --git a/tests/quality/test_api_radiative.py b/tests/quality/test_api_radiative.py index 33307ee..fd38668 100644 --- a/tests/quality/test_api_radiative.py +++ b/tests/quality/test_api_radiative.py @@ -39,13 +39,17 @@ class TestRadiativeAPI: class TestRadiativeProperties: @pytest.mark.property - @pytest.mark.parametrize("W", [ - [2.0], - np.linspace(1.1, 4.9, 20), - ]) + @pytest.mark.parametrize( + "W", + [ + [2.0], + np.linspace(1.1, 4.9, 20), + ], + ) def test_positive(self, W): rc = RadiativeCorrection(W0=5.0) from tests.common.test_property_tests import _test_positive_values + _test_positive_values(rc, W, component_name="RadiativeCorrection") @pytest.mark.quality @@ -54,6 +58,7 @@ class TestRadiativeProperties: rc = RadiativeCorrection(W0=5.0) W = np.linspace(1.1, 4.9, n) from tests.common.test_property_tests import _test_output_shape_preserved + _test_output_shape_preserved(rc, W, component_name="RadiativeCorrection") @pytest.mark.quality @@ -63,17 +68,22 @@ class TestRadiativeProperties: rc = RadiativeCorrection(W0=W0) W = np.linspace(1.1, min(9.9, W0 - 0.01), 20) 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})") @pytest.mark.quality - @pytest.mark.parametrize("W", [ - np.linspace(1.0001, 4.9999, 200), - np.linspace(1.001, 9.99, 100), - ]) + @pytest.mark.parametrize( + "W", + [ + np.linspace(1.0001, 4.9999, 200), + np.linspace(1.001, 9.99, 100), + ], + ) def _test_full_range_stability(self, W): """Entire energy range from threshold to endpoint must be free of NaN/inf.""" rc = RadiativeCorrection(W0=5.0, Z=92, use_endpoint_resummation=True) from tests.common.test_property_tests import _test_full_range_stability + _test_full_range_stability(rc, W, component_name="RadiativeCorrection(Z=92)") @pytest.mark.quality diff --git a/tests/quality/test_api_screening.py b/tests/quality/test_api_screening.py index f27c16e..cb213bc 100644 --- a/tests/quality/test_api_screening.py +++ b/tests/quality/test_api_screening.py @@ -26,16 +26,21 @@ class TestScreeningAPI: sc = ScreeningCorrection(ff) assert "Screening" in repr(sc) + class TestScreeningProperties: @pytest.mark.property - @pytest.mark.parametrize("W", [ - [2.0], - np.linspace(1.02, 3.0, 20), - ]) + @pytest.mark.parametrize( + "W", + [ + [2.0], + np.linspace(1.02, 3.0, 20), + ], + ) def test_positive(self, W): ff = FermiFunction(Z=40, A=95) sc = ScreeningCorrection(ff) from tests.common.test_property_tests import _test_positive_values + _test_positive_values(sc, W, component_name="ScreeningCorrection") @pytest.mark.quality @@ -45,4 +50,5 @@ class TestScreeningProperties: sc = ScreeningCorrection(ff) W = np.linspace(1.1, 3.0, n) from tests.common.test_property_tests import _test_output_shape_preserved + _test_output_shape_preserved(sc, W, component_name="ScreeningCorrection") diff --git a/tests/quality/test_neutrino_mass_cli.py b/tests/quality/test_neutrino_mass_cli.py index 6f102d3..0847126 100644 --- a/tests/quality/test_neutrino_mass_cli.py +++ b/tests/quality/test_neutrino_mass_cli.py @@ -56,9 +56,9 @@ class TestNeutrinoMassPhaseSpace: val_massive = ps_massive(W_near_endpoint)[0] assert not np.isnan(val_massive), "Should produce finite value" - assert val_massive < val_zero, ( - "Massive neutrino must suppress spectrum near endpoint" - ) + assert ( + val_massive < val_zero + ), "Massive neutrino must suppress spectrum near endpoint" class TestCLIArguments: